demo-game/scripts/demo.gd
simon 4f3435ba37 Add per-client LED controls aligned with the REST API schema.
Each client panel gets mode-specific LED inputs (color, intensity, progress, digit, blink) and the Makefile now copies API_REST.md alongside the WebSocket docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 21:55:18 +02:00

658 lines
21 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
const LED_MODES: PackedStringArray = ["color", "clear", "progress", "digit", "blink", "find-me"]
const LED_MODES_COLOR_INTENSITY: PackedStringArray = ["color", "progress", "digit", "blink"]
@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 led_label := Label.new()
led_label.text = "LED Ring:"
led_label.add_theme_font_size_override("font_size", 12)
led_label.add_theme_color_override("font_color", Color(0.7, 0.75, 0.85))
vbox.add_child(led_label)
var led_row := HBoxContainer.new()
led_row.add_theme_constant_override("separation", 8)
vbox.add_child(led_row)
var led_mode := OptionButton.new()
for mode in LED_MODES:
led_mode.add_item(mode)
led_mode.disabled = not available
led_row.add_child(led_mode)
var led_apply := Button.new()
led_apply.text = "Anwenden"
led_apply.disabled = not available
led_apply.pressed.connect(_apply_led_for_client.bind(cid))
led_row.add_child(led_apply)
var led_params := VBoxContainer.new()
led_params.add_theme_constant_override("separation", 4)
vbox.add_child(led_params)
var led_color_row := HBoxContainer.new()
led_color_row.add_theme_constant_override("separation", 8)
led_params.add_child(led_color_row)
var led_color_lbl := Label.new()
led_color_lbl.text = "Farbe:"
led_color_row.add_child(led_color_lbl)
var led_color := ColorPickerButton.new()
led_color.color = Color(1.0, 0.0, 0.0)
led_color.custom_minimum_size = Vector2(36, 28)
led_color.disabled = not available
led_color_row.add_child(led_color)
var led_intensity_row := HBoxContainer.new()
led_intensity_row.add_theme_constant_override("separation", 8)
led_params.add_child(led_intensity_row)
var led_intensity_lbl := Label.new()
led_intensity_lbl.text = "Intensität:"
led_intensity_row.add_child(led_intensity_lbl)
var led_intensity := SpinBox.new()
led_intensity.min_value = 0
led_intensity.max_value = 255
led_intensity.value = 128
led_intensity.custom_minimum_size = Vector2(80, 0)
_set_spinbox_enabled(led_intensity, available)
led_intensity_row.add_child(led_intensity)
var led_progress_row := HBoxContainer.new()
led_progress_row.add_theme_constant_override("separation", 8)
led_params.add_child(led_progress_row)
var led_progress_lbl := Label.new()
led_progress_lbl.text = "Fortschritt %:"
led_progress_row.add_child(led_progress_lbl)
var led_progress := SpinBox.new()
led_progress.min_value = 0
led_progress.max_value = 100
led_progress.value = 50
led_progress.custom_minimum_size = Vector2(80, 0)
_set_spinbox_enabled(led_progress, available)
led_progress_row.add_child(led_progress)
var led_digit_row := HBoxContainer.new()
led_digit_row.add_theme_constant_override("separation", 8)
led_params.add_child(led_digit_row)
var led_digit_lbl := Label.new()
led_digit_lbl.text = "Ziffer:"
led_digit_row.add_child(led_digit_lbl)
var led_digit := SpinBox.new()
led_digit.min_value = 0
led_digit.max_value = 10
led_digit.value = 3
led_digit.custom_minimum_size = Vector2(80, 0)
_set_spinbox_enabled(led_digit, available)
led_digit_row.add_child(led_digit)
var led_blink_row := HBoxContainer.new()
led_blink_row.add_theme_constant_override("separation", 8)
led_params.add_child(led_blink_row)
var led_blink_ms_lbl := Label.new()
led_blink_ms_lbl.text = "Blink ms:"
led_blink_row.add_child(led_blink_ms_lbl)
var led_blink_ms := SpinBox.new()
led_blink_ms.min_value = 1
led_blink_ms.max_value = 10000
led_blink_ms.value = 500
led_blink_ms.custom_minimum_size = Vector2(80, 0)
_set_spinbox_enabled(led_blink_ms, available)
led_blink_row.add_child(led_blink_ms)
var led_blink_count_lbl := Label.new()
led_blink_count_lbl.text = "Anzahl:"
led_blink_row.add_child(led_blink_count_lbl)
var led_blink_count := SpinBox.new()
led_blink_count.min_value = 1
led_blink_count.max_value = 100
led_blink_count.value = 3
led_blink_count.custom_minimum_size = Vector2(60, 0)
_set_spinbox_enabled(led_blink_count, available)
led_blink_row.add_child(led_blink_count)
led_mode.item_selected.connect(func(_idx: int) -> void: _update_led_fields(cid))
var led_presets := HBoxContainer.new()
led_presets.add_theme_constant_override("separation", 6)
vbox.add_child(led_presets)
_add_led_preset_btn(led_presets, "Aus", cid, available, func() -> void: _led_apply_preset(cid, "clear"))
_add_led_preset_btn(led_presets, "Rot", cid, available, func() -> void: _led_apply_preset(cid, "color", Color(1, 0.2, 0.2), 128))
_add_led_preset_btn(led_presets, "Grün", cid, available, func() -> void: _led_apply_preset(cid, "color", Color(0.2, 1, 0.3), 128))
_add_led_preset_btn(led_presets, "Blau", cid, available, func() -> void: _led_apply_preset(cid, "color", Color(0.2, 0.4, 1), 128))
_add_led_preset_btn(led_presets, "Find", cid, available, func() -> void: _led_apply_preset(cid, "find-me"))
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,
"led_color": led_color,
"led_mode": led_mode,
"led_intensity": led_intensity,
"led_progress": led_progress,
"led_digit": led_digit,
"led_blink_ms": led_blink_ms,
"led_blink_count": led_blink_count,
"led_color_row": led_color_row,
"led_intensity_row": led_intensity_row,
"led_progress_row": led_progress_row,
"led_digit_row": led_digit_row,
"led_blink_row": led_blink_row,
"values_label": values_label,
"last_tap": "",
}
_update_led_fields(cid)
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)
"led_ring_status":
_log("← LED #%s: %s" % [data.get("client_id", "?"), "OK" if data.get("success", false) else data.get("error", "?")])
_:
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 _set_spinbox_enabled(spin: SpinBox, enabled: bool) -> void:
spin.editable = enabled
spin.get_line_edit().editable = enabled
func _add_led_preset_btn(
row: HBoxContainer,
text: String,
cid: int,
available: bool,
on_pressed: Callable
) -> void:
var btn := Button.new()
btn.text = text
btn.disabled = not available
btn.pressed.connect(on_pressed)
row.add_child(btn)
func _update_led_fields(cid: int) -> void:
if not _client_ui.has(cid):
return
var ui: Dictionary = _client_ui[cid]
var mode: String = ui["led_mode"].get_item_text(ui["led_mode"].selected)
ui["led_color_row"].visible = mode in LED_MODES_COLOR_INTENSITY
ui["led_intensity_row"].visible = mode in LED_MODES_COLOR_INTENSITY
ui["led_progress_row"].visible = mode == "progress"
ui["led_digit_row"].visible = mode == "digit"
ui["led_blink_row"].visible = mode == "blink"
func _select_led_mode(ui: Dictionary, mode: String) -> void:
for i in ui["led_mode"].item_count:
if ui["led_mode"].get_item_text(i) == mode:
ui["led_mode"].select(i)
return
func _led_apply_preset(
cid: int,
mode: String,
color: Color = Color.WHITE,
intensity: int = 128,
param: int = 50
) -> void:
if not _client_ui.has(cid):
return
var ui: Dictionary = _client_ui[cid]
_select_led_mode(ui, mode)
if mode in LED_MODES_COLOR_INTENSITY:
ui["led_color"].color = color
ui["led_intensity"].value = intensity
if mode == "progress":
ui["led_progress"].value = param
if mode == "digit":
ui["led_digit"].value = param
if mode == "blink":
ui["led_blink_ms"].value = param
ui["led_blink_count"].value = 3
_update_led_fields(cid)
_apply_led_for_client(cid)
func _apply_led_for_client(cid: int) -> void:
if not _client_ui.has(cid):
return
_send(_build_led_payload(cid))
func _build_led_payload(cid: int) -> Dictionary:
var ui: Dictionary = _client_ui[cid]
var mode: String = ui["led_mode"].get_item_text(ui["led_mode"].selected)
var payload := {"type": "set_led_ring", "client_id": cid, "mode": mode}
match mode:
"clear", "find-me":
pass
"color":
_payload_add_color_intensity(payload, ui)
"progress":
payload["progress"] = int(ui["led_progress"].value)
_payload_add_color_intensity(payload, ui)
"digit":
payload["digit"] = int(ui["led_digit"].value)
_payload_add_color_intensity(payload, ui)
"blink":
payload["blink_ms"] = int(ui["led_blink_ms"].value)
payload["blink_count"] = int(ui["led_blink_count"].value)
_payload_add_color_intensity(payload, ui)
return payload
func _payload_add_color_intensity(payload: Dictionary, ui: Dictionary) -> void:
_payload_add_rgb(payload, ui["led_color"].color)
payload["intensity"] = int(ui["led_intensity"].value)
func _payload_add_rgb(payload: Dictionary, color: Color) -> void:
payload["r"] = int(color.r * 255)
payload["g"] = int(color.g * 255)
payload["b"] = int(color.b * 255)
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]