Add main menu, WebSocket API demo, and multi-scene game hub.

Replace the single-entry Pong flow with a menu launcher, a per-client API demo on :8081/ws with stream controls and tap flash feedback, and bump the viewport to 1920×1080.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 21:43:44 +02:00
co-authored by Cursor
parent 9107dea6ff
commit 84827c9782
20 changed files with 1503 additions and 29 deletions
+274
View File
@@ -0,0 +1,274 @@
extends Node2D
enum GameState { CALIBRATION, PLAYING }
@onready var platform: Polygon2D = $Platform
@onready var ball: Node2D = $Ball
@onready var status_label: Label = $UiLayer/StatusLabel
@onready var accel_label: Label = $UiLayer/AccelLabel
@onready var threshold_slider: HSlider = $UiLayer/ThresholdPanel/ThresholdSlider
@onready var threshold_caption: Label = $UiLayer/ThresholdPanel/ThresholdCaption
@onready var sensitivity_slider: HSlider = $UiLayer/ThresholdPanel/SensitivitySlider
@onready var sensitivity_caption: Label = $UiLayer/ThresholdPanel/SensitivityCaption
@onready var threshold_panel: Control = $UiLayer/ThresholdPanel
@onready var calibration_overlay: Control = $UiLayer/CalibrationOverlay
@onready var recalib_button: Button = $UiLayer/RecalibButton
@onready var menu_button: Button = $UiLayer/MenuButton
var _socket := WebSocketPeer.new()
var _velocity_x := 0.0
var _accel := Vector3i.ZERO
var _state := GameState.CALIBRATION
var _calibration := AccelCalibration.new()
const WS_URL := "ws://localhost:9090/ws"
const MENU_SCENE := "res://scenes/menu.tscn"
const MAX_SPEED := 900.0
const FRICTION := 0.9
const PLATFORM_HALF_WIDTH := 80.0
const MARGIN := 24.0
const PLAY_TOP := 160.0
func _ready() -> void:
_place_platform_bottom_center()
ball.reset_to(_play_area().get_center())
_set_playing_ui_visible(false)
threshold_slider.value_changed.connect(_on_threshold_changed)
_on_threshold_changed(threshold_slider.value)
sensitivity_slider.value_changed.connect(_on_sensitivity_changed)
_on_sensitivity_changed(sensitivity_slider.value)
calibration_overlay.calibration_finished.connect(_on_calibration_finished)
recalib_button.pressed.connect(_restart_calibration)
menu_button.pressed.connect(_return_to_menu)
calibration_overlay.start()
var err := _socket.connect_to_url(WS_URL)
if err != OK:
status_label.text = "WebSocket connect failed: %s" % error_string(err)
else:
status_label.text = "Connecting to %s…" % WS_URL
func _return_to_menu() -> void:
get_tree().change_scene_to_file(MENU_SCENE)
func _physics_process(delta: float) -> void:
_socket.poll()
_update_connection_status()
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
while _socket.get_available_packet_count() > 0:
var packet := _socket.get_packet().get_string_from_utf8()
_handle_accel_message(packet)
if _state == GameState.CALIBRATION:
if calibration_overlay.is_active():
calibration_overlay.feed_accel(_accel)
_update_accel_label()
return
var eff := _effective_accel()
if eff == 0.0:
_velocity_x *= FRICTION
else:
var target_v := eff * _velocity_scale()
_velocity_x = move_toward(_velocity_x, target_v, maxf(absf(target_v) * 0.35, 120.0) * delta)
_velocity_x = clampf(_velocity_x, -MAX_SPEED, MAX_SPEED)
var next_x := platform.position.x + _velocity_x * delta
platform.position.x = _clamp_x(next_x)
ball.step(delta, platform.position, _velocity_x, _play_area())
_update_accel_label()
func _restart_calibration() -> void:
_state = GameState.CALIBRATION
_velocity_x = 0.0
_set_playing_ui_visible(false)
calibration_overlay.start()
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
calibration_overlay.notify_connected()
else:
status_label.text = "Kalibrierung – tippe „Kalibrierung starten“"
func _on_calibration_finished(calibration: AccelCalibration) -> void:
_calibration = calibration
threshold_slider.value = calibration.suggested_threshold
_on_threshold_changed(calibration.suggested_threshold)
_start_playing()
func _start_playing() -> void:
_state = GameState.PLAYING
_set_playing_ui_visible(true)
_velocity_x = 0.0
_place_platform_bottom_center()
ball.reset_to(_play_area().get_center())
status_label.text = "Kalibriert: Achse %s · Schwellwert %d" % [
_calibration.axis_name(), int(threshold_slider.value)
]
func _set_playing_ui_visible(visible: bool) -> void:
platform.visible = visible
ball.visible = visible
threshold_panel.visible = visible
recalib_button.visible = visible
func _on_threshold_changed(value: float) -> void:
threshold_caption.text = "Schwellwert |%s|: %d" % [_calibration.axis_name(), int(value)]
func _on_sensitivity_changed(value: float) -> void:
sensitivity_caption.text = "Bewegungsstärke: %d %%" % int(value)
func _velocity_scale() -> float:
return sensitivity_slider.value / 100.0
func _calibrated_raw() -> float:
return _calibration.project(_accel)
func _effective_accel() -> float:
var raw := _calibrated_raw()
var centered := raw - _calibration.center
var threshold := threshold_slider.value
var abs_v := absf(centered)
if abs_v <= threshold:
return 0.0
return signf(centered) * (abs_v - threshold)
func _update_connection_status() -> void:
if _state == GameState.CALIBRATION:
match _socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
status_label.text = "Kalibrierung – verbunden"
calibration_overlay.notify_connected()
WebSocketPeer.STATE_CONNECTING:
status_label.text = "Kalibrierung – verbinde…"
_:
status_label.text = "Kalibrierung – keine Verbindung"
return
match _socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
var eff := _effective_accel()
status_label.text = "Connected · Achse %s · vx=%.0f · eff=%.0f" % [
_calibration.axis_name(), _velocity_x, eff
]
WebSocketPeer.STATE_CONNECTING:
status_label.text = "Connecting…"
WebSocketPeer.STATE_CLOSING, WebSocketPeer.STATE_CLOSED:
status_label.text = "Disconnected – retrying…"
if _socket.get_ready_state() == WebSocketPeer.STATE_CLOSED:
_socket.connect_to_url(WS_URL)
func _handle_accel_message(text: String) -> void:
var data = JSON.parse_string(text)
if typeof(data) != TYPE_DICTIONARY:
return
if data.get("type") != "accel" or not data.get("success", false):
return
var parsed: Variant = _parse_accel_vector(data)
if parsed == null:
return
_accel = parsed as Vector3i
func _parse_accel_vector(data: Dictionary) -> Variant:
var source: Variant = _accel_fields_dict(data)
if source == null:
return null
return Vector3i(
_to_int(source.get("x")),
_to_int(source.get("y")),
_to_int(source.get("z"))
)
func _accel_fields_dict(data: Dictionary) -> Variant:
if data.has("x") and data.has("y") and data.has("z"):
return data
for key in ["accel", "values", "data", "payload"]:
var nested: Variant = data.get(key)
if nested is Dictionary and nested.has("x") and nested.has("y") and nested.has("z"):
return nested
return null
func _to_int(value: Variant) -> int:
match typeof(value):
TYPE_INT:
return value
TYPE_FLOAT:
return int(value)
TYPE_STRING:
return int(float(value))
return 0
func _play_area() -> Rect2:
var size := get_viewport_rect().size
var bottom_y := size.y - MARGIN
return Rect2(
MARGIN,
PLAY_TOP,
size.x - MARGIN * 2.0,
bottom_y - PLAY_TOP
)
func _place_platform_bottom_center() -> void:
var size := get_viewport_rect().size
platform.position = Vector2(_clamp_x(size.x * 0.5), size.y - 48.0)
func _platform_x_limits() -> Vector2:
var view_w := get_viewport_rect().size.x
return Vector2(PLATFORM_HALF_WIDTH + MARGIN, view_w - PLATFORM_HALF_WIDTH - MARGIN)
func _clamp_x(x: float) -> float:
var limits := _platform_x_limits()
var min_x := limits.x
var max_x := limits.y
if x < min_x:
if _velocity_x < 0.0:
_velocity_x = 0.0
return min_x
if x > max_x:
if _velocity_x > 0.0:
_velocity_x = 0.0
return max_x
return x
func _update_accel_label() -> void:
if _state == GameState.CALIBRATION:
accel_label.text = "x: %d y: %d z: %d\nKalibrierung läuft…" % [_accel.x, _accel.y, _accel.z]
return
var eff := _effective_accel()
accel_label.text = "x: %d y: %d z: %d\nAchse %s → %.0f (Schw. %d, eff %.0f)" % [
_accel.x, _accel.y, _accel.z,
_calibration.axis_name(), _calibration.project(_accel),
int(threshold_slider.value), eff
]
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_SIZE_CHANGED:
platform.position.y = get_viewport_rect().size.y - 48.0
platform.position.x = _clamp_x(platform.position.x)
if _state == GameState.PLAYING:
ball.reset_to(_play_area().get_center())