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
+66
View File
@@ -0,0 +1,66 @@
extends Control
const WS_URL := "ws://localhost:9090/ws"
const MENU_SCENE := "res://scenes/menu.tscn"
@onready var color_rect: ColorRect = $ColorRect
@onready var target_rect: ColorRect = $UiLayer/TargetRect
@onready var hint_label: Label = $UiLayer/HintLabel
@onready var back_button: Button = $UiLayer/BackButton
var _socket := WebSocketPeer.new()
var _accel := Vector3i.ZERO
var _target_hue := 0.0
func _ready() -> void:
back_button.pressed.connect(_return_to_menu)
_new_target_color()
var err := _socket.connect_to_url(WS_URL)
if err != OK:
hint_label.text = "WebSocket connect failed: %s" % error_string(err)
else:
hint_label.text = "Neige das Gerät, um die Farbe anzupassen"
func _return_to_menu() -> void:
get_tree().change_scene_to_file(MENU_SCENE)
func _new_target_color() -> void:
_target_hue = randf()
target_rect.color = Color.from_hsv(_target_hue, 0.75, 0.85)
func _physics_process(_delta: float) -> void:
_socket.poll()
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)
elif _socket.get_ready_state() == WebSocketPeer.STATE_CLOSED:
_socket.connect_to_url(WS_URL)
var hue := fmod(float(_accel.x) / 20000.0 + 1.0, 1.0)
color_rect.color = Color.from_hsv(hue, 0.75, 0.85)
var diff := absf(hue - _target_hue)
diff = minf(diff, 1.0 - diff)
if diff < 0.04:
hint_label.text = "Treffer! Neues Ziel…"
_new_target_color()
else:
hint_label.text = "Passe deine Farbe der Zielfarbe an (Neigung steuert den Farbton)"
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
if not data.has("x") or not data.has("y") or not data.has("z"):
return
_accel = Vector3i(int(data["x"]), int(data["y"]), int(data["z"]))
+1
View File
@@ -0,0 +1 @@
uid://gya8sitagfna
+19
View File
@@ -0,0 +1,19 @@
extends Control
const MENU_SCENE := "res://scenes/menu.tscn"
const WS_URL := "ws://localhost:9090/ws"
@onready var back_button: Button = $UiLayer/BackButton
@onready var ws_url_label: Label = $UiLayer/Panel/WSUrlLabel
@onready var viewport_label: Label = $UiLayer/Panel/ViewportLabel
func _ready() -> void:
back_button.pressed.connect(_return_to_menu)
ws_url_label.text = "WebSocket: %s" % WS_URL
var size := get_viewport_rect().size
viewport_label.text = "Viewport: %d × %d" % [int(size.x), int(size.y)]
func _return_to_menu() -> void:
get_tree().change_scene_to_file(MENU_SCENE)
+1
View File
@@ -0,0 +1 @@
uid://cdjjsf3f0o2ex
+418
View File
@@ -0,0 +1,418 @@
extends Control
const WS_URL := "ws://localhost:8081/ws"
const MENU_SCENE := "res://scenes/menu.tscn"
const MAX_LOG_LINES := 200
const TAP_FLASH_SEC := 2.0
const PANEL_STYLE_NORMAL := Color(0.11, 0.12, 0.17, 1.0)
const PANEL_BORDER_NORMAL := Color(0.22, 0.25, 0.32, 1.0)
const PANEL_STYLE_FLASH := Color(0.32, 0.28, 0.06, 1.0)
const PANEL_BORDER_FLASH := Color(1.0, 0.82, 0.15, 1.0)
@onready var status_label: Label = $UiLayer/TopBar/StatusLabel
@onready var log_output: TextEdit = $UiLayer/MainSplit/LogPanel/LogOutput
@onready var stream_output: Label = $UiLayer/MainSplit/LogPanel/StreamOutput
@onready var back_button: Button = $UiLayer/TopBar/BackButton
@onready var interval_spin: SpinBox = $UiLayer/MainSplit/LeftPanel/Toolbar/IntervalSpin
@onready var list_clients_btn: Button = $UiLayer/MainSplit/LeftPanel/Toolbar/ListClientsBtn
@onready var clients_list: VBoxContainer = $UiLayer/MainSplit/LeftPanel/ClientsScroll/ClientsList
@onready var empty_hint: Label = $UiLayer/MainSplit/LeftPanel/ClientsScroll/ClientsList/EmptyHint
@onready var start_stream_btn: Button = $UiLayer/MainSplit/LeftPanel/StreamBar/StartStreamBtn
@onready var stop_stream_btn: Button = $UiLayer/MainSplit/LeftPanel/StreamBar/StopStreamBtn
var _socket := WebSocketPeer.new()
var _connected := false
var _streaming := false
var _log_lines: PackedStringArray = []
var _client_ui: Dictionary = {}
func _ready() -> void:
back_button.pressed.connect(_return_to_menu)
list_clients_btn.pressed.connect(func(): _send({"type": "list_clients"}))
start_stream_btn.pressed.connect(_start_stream)
stop_stream_btn.pressed.connect(_stop_stream)
_connect_ws()
func _return_to_menu() -> void:
get_tree().change_scene_to_file(MENU_SCENE)
func _connect_ws() -> void:
_connected = false
var err := _socket.connect_to_url(WS_URL)
if err != OK:
_set_status("Verbindung fehlgeschlagen: %s" % error_string(err))
else:
_set_status("Verbinde mit %s" % WS_URL)
func _interval_ms() -> int:
return int(interval_spin.value)
func _send(payload: Dictionary) -> void:
if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
_log("Nicht verbunden Befehl verworfen: %s" % payload.get("type", "?"))
return
var text := JSON.stringify(payload)
_socket.send_text(text)
_log("%s" % text)
func _start_stream() -> void:
if _client_ui.is_empty():
_log("Keine Clients geladen")
return
var want_accel := false
var want_tap := false
for cid in _client_ui:
var ui: Dictionary = _client_ui[cid]
if not ui.get("available", false):
continue
var accel_on: bool = ui["accel_cb"].button_pressed
_send({"type": "set_accel_stream", "client_id": cid, "enable": accel_on})
if accel_on:
want_accel = true
var tap_single: bool = ui["tap_single"].button_pressed
var tap_double: bool = ui["tap_double"].button_pressed
var tap_triple: bool = ui["tap_triple"].button_pressed
var tap_on := tap_single or tap_double or tap_triple
_send({
"type": "set_tap_notify",
"client_id": cid,
"single": tap_single,
"double_tap": tap_double,
"triple": tap_triple,
})
if tap_on:
want_tap = true
_send({"type": "set_stream", "enable": want_accel, "interval_ms": _interval_ms()})
_send({"type": "set_tap_stream", "enable": want_tap, "interval_ms": _interval_ms()})
_streaming = true
start_stream_btn.disabled = true
stop_stream_btn.disabled = false
_log("Stream gestartet (accel=%s, tap=%s)" % [want_accel, want_tap])
func _stop_stream() -> void:
_send({"type": "set_stream", "enable": false, "interval_ms": _interval_ms()})
_send({"type": "set_tap_stream", "enable": false, "interval_ms": _interval_ms()})
for cid in _client_ui:
var ui: Dictionary = _client_ui[cid]
if not ui.get("available", false):
continue
_send({"type": "set_accel_stream", "client_id": cid, "enable": false})
_send({
"type": "set_tap_notify",
"client_id": cid,
"single": false,
"double_tap": false,
"triple": false,
})
ui["values_label"].text = ""
_streaming = false
start_stream_btn.disabled = _client_ui.is_empty()
stop_stream_btn.disabled = true
stream_output.text = ""
_log("Stream gestoppt")
func _clear_client_panels() -> void:
_client_ui.clear()
for child in clients_list.get_children():
if child != empty_hint:
child.queue_free()
func _populate_clients(clients: Array) -> void:
_clear_client_panels()
var has_available := false
for entry in clients:
if typeof(entry) != TYPE_DICTIONARY:
continue
var cid := int(entry.get("id", 0))
if cid <= 0:
continue
var available: bool = entry.get("available", false)
if available:
has_available = true
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var normal_style := _make_panel_style(PANEL_STYLE_NORMAL, PANEL_BORDER_NORMAL, 1)
var flash_style := _make_panel_style(PANEL_STYLE_FLASH, PANEL_BORDER_FLASH, 2)
panel.add_theme_stylebox_override("panel", normal_style)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 8)
margin.add_theme_constant_override("margin_bottom", 8)
panel.add_child(margin)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 6)
margin.add_child(vbox)
var mac: String = str(entry.get("mac", "?"))
var status := "online" if available else "offline"
var header := Label.new()
header.text = "Client #%d · %s · %s" % [cid, mac, status]
header.add_theme_font_size_override("font_size", 14)
header.add_theme_color_override("font_color", Color(0.9, 0.92, 0.98) if available else Color(0.55, 0.58, 0.65))
vbox.add_child(header)
var accel_cb := CheckBox.new()
accel_cb.text = "Accel Stream"
accel_cb.button_pressed = entry.get("accel_stream", false)
accel_cb.disabled = not available
vbox.add_child(accel_cb)
var tap_label := Label.new()
tap_label.text = "Tap Notify:"
tap_label.add_theme_font_size_override("font_size", 12)
tap_label.add_theme_color_override("font_color", Color(0.7, 0.75, 0.85))
vbox.add_child(tap_label)
var tap_row := HBoxContainer.new()
tap_row.add_theme_constant_override("separation", 12)
vbox.add_child(tap_row)
var tap_single := CheckBox.new()
tap_single.text = "Single"
tap_single.button_pressed = entry.get("tap_notify_single", false)
tap_single.disabled = not available
var tap_double := CheckBox.new()
tap_double.text = "Double"
tap_double.button_pressed = entry.get("tap_notify_double", false)
tap_double.disabled = not available
var tap_triple := CheckBox.new()
tap_triple.text = "Triple"
tap_triple.button_pressed = entry.get("tap_notify_triple", false)
tap_triple.disabled = not available
tap_row.add_child(tap_single)
tap_row.add_child(tap_double)
tap_row.add_child(tap_triple)
var values_label := Label.new()
values_label.text = ""
values_label.add_theme_font_size_override("font_size", 12)
values_label.add_theme_color_override("font_color", Color(0.65, 0.85, 0.75))
values_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(values_label)
clients_list.add_child(panel)
_client_ui[cid] = {
"available": available,
"panel": panel,
"normal_style": normal_style,
"flash_style": flash_style,
"flash_token": 0,
"accel_cb": accel_cb,
"tap_single": tap_single,
"tap_double": tap_double,
"tap_triple": tap_triple,
"values_label": values_label,
"last_tap": "",
}
empty_hint.visible = _client_ui.is_empty()
start_stream_btn.disabled = _client_ui.is_empty() or not has_available or _streaming
_log("%d Client(s) geladen" % _client_ui.size())
func _physics_process(_delta: float) -> void:
_socket.poll()
var state := _socket.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
while _socket.get_available_packet_count() > 0:
_handle_message(_socket.get_packet().get_string_from_utf8())
match state:
WebSocketPeer.STATE_OPEN:
if _connected:
_set_status("Verbunden · %s" % WS_URL)
WebSocketPeer.STATE_CONNECTING:
_set_status("Verbinde…")
WebSocketPeer.STATE_CLOSING, WebSocketPeer.STATE_CLOSED:
_connected = false
_streaming = false
start_stream_btn.disabled = _client_ui.is_empty()
stop_stream_btn.disabled = true
_set_status("Getrennt erneuter Verbindungsversuch…")
if state == WebSocketPeer.STATE_CLOSED:
_connect_ws()
func _handle_message(text: String) -> void:
var data = JSON.parse_string(text)
if typeof(data) != TYPE_DICTIONARY:
_log("← (ungültiges JSON) %s" % text.substr(0, mini(text.length(), 120)))
return
var msg_type: String = data.get("type", "")
match msg_type:
"hello":
_on_hello(data)
"client_list":
_on_client_list(data)
"accel":
_on_accel(data)
"tap":
_on_tap(data)
_:
if not _streaming or not msg_type.ends_with("_status"):
_log("%s" % _format_response(data))
func _on_hello(data: Dictionary) -> void:
_connected = true
if data.has("interval_ms"):
interval_spin.value = int(data["interval_ms"])
_log("← hello (Port: %s)" % data.get("serial_port", "?"))
func _on_client_list(data: Dictionary) -> void:
if not data.get("success", false):
_log("← client_list FAILED: %s" % data.get("error", "?"))
return
_populate_clients(data.get("clients", []))
func _on_accel(data: Dictionary) -> void:
if not data.get("success", false):
stream_output.text = "accel FEHLER: %s" % data.get("error", "?")
return
var summary: PackedStringArray = []
for client in data.get("clients", []):
if typeof(client) != TYPE_DICTIONARY:
continue
var cid := int(client.get("client_id", 0))
if not _client_ui.has(cid):
continue
var ui: Dictionary = _client_ui[cid]
var tap_part: String = ui.get("last_tap", "")
if client.get("valid", false):
var accel_text := "x=%d y=%d z=%d (age %sms)" % [
int(client.get("x", 0)),
int(client.get("y", 0)),
int(client.get("z", 0)),
str(client.get("age_ms", "?")),
]
ui["values_label"].text = accel_text + ("\n" + tap_part if tap_part else "")
summary.append("#%d: %s" % [cid, accel_text])
else:
ui["values_label"].text = "accel: —" + ("\n" + tap_part if tap_part else "")
if summary.is_empty():
stream_output.text = "accel (keine gültigen Samples)"
else:
stream_output.text = "accel\n" + "\n".join(summary)
func _on_tap(data: Dictionary) -> void:
if not data.get("success", false):
return
var summary: PackedStringArray = []
for event in data.get("events", []):
if typeof(event) != TYPE_DICTIONARY:
continue
var cid := int(event.get("client_id", 0))
if not _client_ui.has(cid):
continue
var tap_text := "tap: %s (age %sms)" % [str(event.get("kind", "?")), str(event.get("age_ms", "?"))]
var ui: Dictionary = _client_ui[cid]
ui["last_tap"] = tap_text
var existing: String = ui["values_label"].text
if existing == "" or existing.begins_with("accel: —"):
ui["values_label"].text = tap_text
elif "\n" in existing:
ui["values_label"].text = existing.split("\n", false, 1)[0] + "\n" + tap_text
else:
ui["values_label"].text = existing + "\n" + tap_text
summary.append("#%d: %s" % [cid, tap_text])
_flash_client_panel(cid)
if not summary.is_empty():
stream_output.text = "tap\n" + "\n".join(summary)
func _make_panel_style(bg: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = bg
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(6)
style.set_content_margin_all(0)
return style
func _flash_client_panel(cid: int) -> void:
if not _client_ui.has(cid):
return
var ui: Dictionary = _client_ui[cid]
var panel: PanelContainer = ui["panel"]
panel.add_theme_stylebox_override("panel", ui["flash_style"])
ui["flash_token"] = int(ui.get("flash_token", 0)) + 1
var token: int = ui["flash_token"]
get_tree().create_timer(TAP_FLASH_SEC).timeout.connect(
func() -> void:
if not _client_ui.has(cid):
return
var current: Dictionary = _client_ui[cid]
if int(current.get("flash_token", 0)) != token:
return
current["panel"].add_theme_stylebox_override("panel", current["normal_style"]),
CONNECT_ONE_SHOT
)
func _format_response(data: Dictionary) -> String:
var msg_type: String = data.get("type", "?")
if not data.get("success", true) and data.has("error"):
return "%s FAILED: %s" % [msg_type, data["error"]]
return JSON.stringify(data)
func _set_status(text: String) -> void:
status_label.text = text
func _log(text: String) -> void:
var line := "[%s] %s" % [_time_str(), text]
_log_lines.append(line)
while _log_lines.size() > MAX_LOG_LINES:
_log_lines.remove_at(0)
log_output.text = "\n".join(_log_lines)
log_output.set_caret_line(log_output.get_line_count() - 1)
func _time_str() -> String:
var t := Time.get_time_dict_from_system()
return "%02d:%02d:%02d" % [t.hour, t.minute, t.second]
+1
View File
@@ -0,0 +1 @@
uid://cl77don5tdl5h
-1
View File
@@ -1 +0,0 @@
uid://g8bye3sv5ifc
+28
View File
@@ -0,0 +1,28 @@
extends Control
const DEMO_SCENE := "res://scenes/demo.tscn"
const CONFIG_SCENE := "res://scenes/config.tscn"
const PONG_SCENE := "res://scenes/pong.tscn"
const COLOR_GAME_SCENE := "res://scenes/color_game.tscn"
@onready var demo_button: Button = $CenterBox/DemoButton
@onready var config_button: Button = $CenterBox/ConfigButton
@onready var pong_button: Button = $CenterBox/PongButton
@onready var color_game_button: Button = $CenterBox/ColorGameButton
@onready var exit_button: Button = $CenterBox/ExitButton
func _ready() -> void:
demo_button.pressed.connect(func(): _open_scene(DEMO_SCENE))
config_button.pressed.connect(func(): _open_scene(CONFIG_SCENE))
pong_button.pressed.connect(func(): _open_scene(PONG_SCENE))
color_game_button.pressed.connect(func(): _open_scene(COLOR_GAME_SCENE))
exit_button.pressed.connect(_exit_game)
func _open_scene(path: String) -> void:
get_tree().change_scene_to_file(path)
func _exit_game() -> void:
get_tree().quit()
+1
View File
@@ -0,0 +1 @@
uid://b32rtcn0rpvii
+7
View File
@@ -13,6 +13,7 @@ enum GameState { CALIBRATION, PLAYING }
@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
@@ -21,6 +22,7 @@ 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
@@ -40,6 +42,7 @@ func _ready() -> void:
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)
@@ -49,6 +52,10 @@ func _ready() -> void:
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()
+1
View File
@@ -0,0 +1 @@
uid://bs5f1aw8bcqbf