From 440b49c8890ac521524982073cd5f5af9cd392d0 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 29 May 2026 22:04:20 +0200 Subject: [PATCH] Replace Color Game with pod number reaction game on the API WebSocket. Players match a displayed digit to the correct pod via tap, with round timing and adjustable LED brightness. Co-authored-by: Cursor --- scenes/color_game.tscn | 142 ++++++++++++++------- scripts/color_game.gd | 281 +++++++++++++++++++++++++++++++++++------ 2 files changed, 342 insertions(+), 81 deletions(-) diff --git a/scenes/color_game.tscn b/scenes/color_game.tscn index 655f433..818eb38 100644 --- a/scenes/color_game.tscn +++ b/scenes/color_game.tscn @@ -11,7 +11,7 @@ grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_color") -[node name="ColorRect" type="ColorRect" parent="."] +[node name="Background" type="ColorRect" parent="."] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -19,55 +19,108 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 -color = Color(0.5, 0.3, 0.7, 1) +color = Color(0.08, 0.09, 0.14, 1) [node name="UiLayer" type="CanvasLayer" parent="."] -[node name="BackButton" type="Button" parent="UiLayer"] +[node name="TopBar" type="HBoxContainer" parent="UiLayer"] +anchors_preset = 10 +anchor_right = 1.0 offset_left = 16.0 offset_top = 12.0 -offset_right = 120.0 -offset_bottom = 44.0 +offset_right = -16.0 +offset_bottom = 52.0 +grow_horizontal = 2 +theme_override_constants/separation = 16 + +[node name="BackButton" type="Button" parent="UiLayer/TopBar"] +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 text = "Menü" -[node name="Title" type="Label" parent="UiLayer"] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -200.0 +[node name="Title" type="Label" parent="UiLayer/TopBar"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.9, 0.92, 0.98, 1) +theme_override_font_sizes/font_size = 22 +text = "Zahlen-Spiel" + +[node name="StatusLabel" type="Label" parent="UiLayer/TopBar"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.55, 0.6, 0.7, 1) +theme_override_font_sizes/font_size = 13 +horizontal_alignment = 2 +text = "Verbinde…" + +[node name="SettingsPanel" type="HBoxContainer" parent="UiLayer"] +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -320.0 offset_top = 60.0 +offset_right = -16.0 +offset_bottom = 96.0 +grow_horizontal = 0 +theme_override_constants/separation = 10 + +[node name="IntensityCaption" type="Label" parent="UiLayer/SettingsPanel"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) +theme_override_font_sizes/font_size = 13 +text = "Helligkeit:" + +[node name="IntensitySlider" type="HSlider" parent="UiLayer/SettingsPanel"] +custom_minimum_size = Vector2(180, 0) +layout_mode = 2 +size_flags_horizontal = 3 +min_value = 0.0 +max_value = 255.0 +step = 1.0 +value = 180.0 +tick_count = 0 + +[node name="IntensityValueLabel" type="Label" parent="UiLayer/SettingsPanel"] +custom_minimum_size = Vector2(36, 0) +layout_mode = 2 +theme_override_colors/font_color = Color(0.9, 0.92, 0.98, 1) +theme_override_font_sizes/font_size = 13 +horizontal_alignment = 2 +text = "180" + +[node name="Center" type="VBoxContainer" parent="UiLayer"] +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -200.0 +offset_top = -160.0 offset_right = 200.0 -offset_bottom = 100.0 +offset_bottom = 120.0 grow_horizontal = 2 -theme_override_colors/font_color = Color(1, 1, 1, 1) -theme_override_font_sizes/font_size = 28 -horizontal_alignment = 1 -text = "Color Game" +grow_vertical = 2 +theme_override_constants/separation = 16 -[node name="TargetRect" type="ColorRect" parent="UiLayer"] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -60.0 -offset_top = 120.0 -offset_right = 60.0 -offset_bottom = 240.0 -grow_horizontal = 2 -color = Color(0.85, 0.2, 0.3, 1) - -[node name="TargetLabel" type="Label" parent="UiLayer"] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -100.0 -offset_top = 250.0 -offset_right = 100.0 -offset_bottom = 280.0 -grow_horizontal = 2 -theme_override_colors/font_color = Color(1, 1, 1, 0.9) -theme_override_font_sizes/font_size = 14 +[node name="SubtitleLabel" type="Label" parent="UiLayer/Center"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) +theme_override_font_sizes/font_size = 18 horizontal_alignment = 1 -text = "Zielfarbe" +text = "Warte auf Pods…" + +[node name="TargetLabel" type="Label" parent="UiLayer/Center"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.35, 0.92, 1, 1) +theme_override_font_sizes/font_size = 140 +horizontal_alignment = 1 +text = "—" + +[node name="TimerLabel" type="Label" parent="UiLayer/Center"] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.88, 0.4, 1) +theme_override_font_sizes/font_size = 36 +horizontal_alignment = 1 +text = "" [node name="HintLabel" type="Label" parent="UiLayer"] anchors_preset = 7 @@ -75,14 +128,13 @@ anchor_left = 0.5 anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 -offset_left = -320.0 -offset_top = -80.0 -offset_right = 320.0 -offset_bottom = -40.0 +offset_left = -360.0 +offset_top = -72.0 +offset_right = 360.0 +offset_bottom = -24.0 grow_horizontal = 2 grow_vertical = 0 -theme_override_colors/font_color = Color(1, 1, 1, 0.85) -theme_override_font_sizes/font_size = 14 +theme_override_colors/font_color = Color(1, 0.55, 0.5, 1) +theme_override_font_sizes/font_size = 16 horizontal_alignment = 1 autowrap_mode = 3 -text = "Neige das Gerät, um die Farbe anzupassen" diff --git a/scripts/color_game.gd b/scripts/color_game.gd index 83581f8..499acd3 100644 --- a/scripts/color_game.gd +++ b/scripts/color_game.gd @@ -1,66 +1,275 @@ extends Control -const WS_URL := "ws://localhost:9090/ws" +const WS_URL := "ws://localhost:8081/ws" const MENU_SCENE := "res://scenes/menu.tscn" +const STREAM_INTERVAL_MS := 16 +const NEXT_ROUND_DELAY_SEC := 2.5 +const DEFAULT_DIGIT_INTENSITY := 180 -@onready var color_rect: ColorRect = $ColorRect -@onready var target_rect: ColorRect = $UiLayer/TargetRect +enum Phase { IDLE, WAITING, PLAYING, WON } + +@onready var status_label: Label = $UiLayer/TopBar/StatusLabel +@onready var target_label: Label = $UiLayer/Center/TargetLabel +@onready var subtitle_label: Label = $UiLayer/Center/SubtitleLabel +@onready var timer_label: Label = $UiLayer/Center/TimerLabel @onready var hint_label: Label = $UiLayer/HintLabel -@onready var back_button: Button = $UiLayer/BackButton +@onready var back_button: Button = $UiLayer/TopBar/BackButton +@onready var intensity_slider: HSlider = $UiLayer/SettingsPanel/IntensitySlider +@onready var intensity_value_label: Label = $UiLayer/SettingsPanel/IntensityValueLabel var _socket := WebSocketPeer.new() -var _accel := Vector3i.ZERO -var _target_hue := 0.0 +var _connected := false +var _phase := Phase.IDLE +var _pod_ids: PackedInt32Array = PackedInt32Array() +var _pod_digits: Dictionary = {} +var _target_digit := -1 +var _target_cid := -1 +var _round_start_ms := 0 +var _round_token := 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" + intensity_slider.value = DEFAULT_DIGIT_INTENSITY + intensity_slider.value_changed.connect(_on_intensity_changed) + _on_intensity_changed(intensity_slider.value) + _set_phase(Phase.IDLE, "Verbinde…") + _connect_ws() + + +func _exit_tree() -> void: + _cleanup_streams() func _return_to_menu() -> void: + _cleanup_streams() 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 _connect_ws() -> void: + _connected = false + var err := _socket.connect_to_url(WS_URL) + if err != OK: + _set_phase(Phase.IDLE, "Verbindung fehlgeschlagen: %s" % error_string(err)) + + +func _send(payload: Dictionary) -> void: + if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: + return + _socket.send_text(JSON.stringify(payload)) + + +func _cleanup_streams() -> void: + if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: + return + _send({"type": "set_tap_stream", "enable": false, "interval_ms": STREAM_INTERVAL_MS}) + for cid in _pod_ids: + _send({ + "type": "set_tap_notify", + "client_id": cid, + "single": false, + "double_tap": false, + "triple": false, + }) + _send_led_clear(cid) func _physics_process(_delta: float) -> void: _socket.poll() + var state := _socket.get_ready_state() - if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN: + if 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)" + _handle_message(_socket.get_packet().get_string_from_utf8()) + if _connected: + status_label.text = "Verbunden · %s" % WS_URL + if _phase == Phase.PLAYING: + _update_live_timer() + elif state == WebSocketPeer.STATE_CONNECTING: + status_label.text = "Verbinde…" + elif state == WebSocketPeer.STATE_CLOSING or state == WebSocketPeer.STATE_CLOSED: + _connected = false + _pod_ids = PackedInt32Array() + _set_phase(Phase.IDLE, "Getrennt – erneuter Verbindungsversuch…") + if state == WebSocketPeer.STATE_CLOSED: + _connect_ws() -func _handle_accel_message(text: String) -> void: +func _handle_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"): + + match data.get("type", ""): + "hello": + _connected = true + _send({"type": "list_clients"}) + "client_list": + _on_client_list(data) + "tap": + _on_tap(data) + + +func _on_client_list(data: Dictionary) -> void: + if not data.get("success", false): + _set_phase(Phase.IDLE, "Client-Liste fehlgeschlagen: %s" % data.get("error", "?")) return - _accel = Vector3i(int(data["x"]), int(data["y"]), int(data["z"])) + _pod_ids = PackedInt32Array() + for entry in data.get("clients", []): + if typeof(entry) != TYPE_DICTIONARY: + continue + if not entry.get("available", false): + continue + var cid := int(entry.get("id", 0)) + if cid > 0: + _pod_ids.append(cid) + + _pod_ids.sort() + + if _pod_ids.size() < 2: + _set_phase(Phase.WAITING, "Mindestens 2 online Pods nötig (aktuell: %d)" % _pod_ids.size()) + target_label.text = "—" + timer_label.text = "" + return + + _start_round() + + +func _start_round() -> void: + _round_token += 1 + _pod_digits.clear() + + var pool: Array = [] + for d in range(10): + pool.append(d) + pool.shuffle() + + for i in _pod_ids.size(): + _pod_digits[_pod_ids[i]] = int(pool[i]) + + var pick_idx := randi() % _pod_ids.size() + _target_cid = _pod_ids[pick_idx] + _target_digit = int(_pod_digits[_target_cid]) + + for cid in _pod_ids: + _send_led_digit(cid, int(_pod_digits[cid])) + + _enable_tap_stream() + _round_start_ms = Time.get_ticks_msec() + _set_phase(Phase.PLAYING, "%d Pods bereit" % _pod_ids.size()) + target_label.text = str(_target_digit) + subtitle_label.text = "Drücke den Pod mit dieser Zahl" + timer_label.text = "0,00 s" + hint_label.text = "" + + +func _enable_tap_stream() -> void: + for cid in _pod_ids: + _send({ + "type": "set_tap_notify", + "client_id": cid, + "single": true, + "double_tap": false, + "triple": false, + }) + _send({"type": "set_tap_stream", "enable": true, "interval_ms": STREAM_INTERVAL_MS}) + + +func _on_tap(data: Dictionary) -> void: + if _phase != Phase.PLAYING or not data.get("success", false): + return + + for event in data.get("events", []): + if typeof(event) != TYPE_DICTIONARY: + continue + var cid := int(event.get("client_id", 0)) + if not _pod_digits.has(cid): + continue + if cid == _target_cid: + _on_correct_tap() + else: + hint_label.text = "Falsch! Pod #%d hat %d — versuch es nochmal." % [cid, int(_pod_digits[cid])] + _send_led_blink(cid, Color(1.0, 0.2, 0.2)) + var wrong_digit := int(_pod_digits[cid]) + get_tree().create_timer(0.5).timeout.connect( + func() -> void: + if _phase == Phase.PLAYING and _pod_digits.has(cid): + _send_led_digit(cid, wrong_digit), + CONNECT_ONE_SHOT + ) + + +func _on_correct_tap() -> void: + var elapsed_sec := (Time.get_ticks_msec() - _round_start_ms) / 1000.0 + _set_phase(Phase.WON, "%d Pods" % _pod_ids.size()) + timer_label.text = "%.2f s" % elapsed_sec + subtitle_label.text = "Richtig!" + hint_label.text = "Nächste Runde in %.0f s…" % NEXT_ROUND_DELAY_SEC + _send_led_blink(_target_cid, Color(0.2, 1.0, 0.35)) + + var token := _round_token + get_tree().create_timer(NEXT_ROUND_DELAY_SEC).timeout.connect( + func() -> void: + if _round_token != token or not is_inside_tree(): + return + if _pod_ids.size() >= 2: + _start_round(), + CONNECT_ONE_SHOT + ) + + +func _update_live_timer() -> void: + var elapsed_sec := (Time.get_ticks_msec() - _round_start_ms) / 1000.0 + timer_label.text = "%.2f s" % elapsed_sec + + +func _set_phase(phase: Phase, status: String) -> void: + _phase = phase + status_label.text = status + if phase == Phase.WAITING or phase == Phase.IDLE: + subtitle_label.text = "Warte auf Pods…" + hint_label.text = "" + + +func _digit_intensity() -> int: + return int(intensity_slider.value) + + +func _on_intensity_changed(_value: float) -> void: + intensity_value_label.text = str(_digit_intensity()) + if _pod_digits.is_empty(): + return + for cid in _pod_digits: + _send_led_digit(cid, int(_pod_digits[cid])) + + +func _send_led_digit(cid: int, digit: int) -> void: + _send({ + "type": "set_led_ring", + "client_id": cid, + "mode": "digit", + "digit": digit, + "r": 40, + "g": 220, + "b": 255, + "intensity": _digit_intensity(), + }) + + +func _send_led_clear(cid: int) -> void: + _send({"type": "set_led_ring", "client_id": cid, "mode": "clear"}) + + +func _send_led_blink(cid: int, color: Color) -> void: + _send({ + "type": "set_led_ring", + "client_id": cid, + "mode": "blink", + "blink_ms": 200, + "blink_count": 2, + "r": int(color.r * 255), + "g": int(color.g * 255), + "b": int(color.b * 255), + "intensity": _digit_intensity(), + })