extends Control 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 const MAX_PODS := 16 const MIN_PODS := 2 enum GameMode { DIGITS, COLORS } enum Phase { IDLE, WAITING, PLAYING, WON } ## Set by the menu before opening this scene. static var start_mode: GameMode = GameMode.DIGITS ## Max 8 high-contrast colors; duplicates across pods are intentional when N > 8. const COLOR_PALETTE: Array[Dictionary] = [ {"name": "Rot", "color": Color(1.0, 0.12, 0.12)}, {"name": "Orange", "color": Color(1.0, 0.45, 0.05)}, {"name": "Gelb", "color": Color(1.0, 0.92, 0.1)}, {"name": "Grün", "color": Color(0.1, 0.85, 0.2)}, {"name": "Türkis", "color": Color(0.05, 0.85, 0.9)}, {"name": "Blau", "color": Color(0.15, 0.35, 1.0)}, {"name": "Lila", "color": Color(0.75, 0.2, 1.0)}, {"name": "Weiß", "color": Color(0.95, 0.95, 1.0)}, ] @onready var status_label: Label = $UiLayer/TopBar/StatusLabel @onready var title_label: Label = $UiLayer/TopBar/Title @onready var target_label: Label = $UiLayer/Center/TargetLabel @onready var target_swatch: ColorRect = $UiLayer/Center/TargetSwatch @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/TopBar/BackButton @onready var intensity_slider: HSlider = $UiLayer/SettingsPanel/IntensitySlider @onready var intensity_value_label: Label = $UiLayer/SettingsPanel/IntensityValueLabel var _mode: GameMode = GameMode.DIGITS var _socket := WebSocketPeer.new() var _connected := false var _phase := Phase.IDLE var _pod_ids: PackedInt32Array = PackedInt32Array() ## digit (int) or palette index (int), depending on mode var _pod_values: Dictionary = {} ## client_id → true for pods that guessed wrong this round (LED off after red blink) var _eliminated: Dictionary = {} var _target_value := -1 var _target_cid := -1 var _round_start_ms := 0 var _round_token := 0 const WRONG_BLINK_SEC := 0.5 func _ready() -> void: _mode = start_mode back_button.pressed.connect(_return_to_menu) intensity_slider.value = DEFAULT_DIGIT_INTENSITY intensity_slider.value_changed.connect(_on_intensity_changed) _on_intensity_changed(intensity_slider.value) _apply_mode_chrome() _set_phase(Phase.IDLE, "Verbinde…") _connect_ws() func _exit_tree() -> void: _cleanup_streams() func _apply_mode_chrome() -> void: if _mode == GameMode.DIGITS: title_label.text = "Zahlen-Spiel" target_label.visible = true target_swatch.visible = false else: title_label.text = "Farben-Spiel" target_label.visible = false target_swatch.visible = true target_swatch.color = Color(0.2, 0.22, 0.28) func _return_to_menu() -> void: _cleanup_streams() 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_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_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 state == WebSocketPeer.STATE_OPEN: while _socket.get_available_packet_count() > 0: _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_message(text: String) -> void: var data = JSON.parse_string(text) if typeof(data) != TYPE_DICTIONARY: return match data.get("type", ""): "hello": _connected = true _send({"type": "list_clients"}) "client_list": _on_client_list(data) "input": _on_input(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 var all_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: all_ids.append(cid) all_ids.sort() _pod_ids = PackedInt32Array() for i in mini(all_ids.size(), MAX_PODS): _pod_ids.append(all_ids[i]) if _pod_ids.size() < MIN_PODS: _set_phase( Phase.WAITING, "Mindestens %d online Pods nötig (aktuell: %d)" % [MIN_PODS, _pod_ids.size()] ) _clear_target_display() timer_label.text = "" return _start_round() func _clear_target_display() -> void: target_label.text = "—" target_swatch.color = Color(0.2, 0.22, 0.28) func _build_value_pool(count: int) -> Array: var pool: Array = [] if _mode == GameMode.DIGITS: for d in range(10): pool.append(d) pool.shuffle() # With >10 pods, reuse digits but keep the first `count` slots unique # where possible; remaining slots get shuffled leftovers with duplicates. while pool.size() < count: var extra: Array = [] for d in range(10): extra.append(d) extra.shuffle() pool.append_array(extra) return pool.slice(0, count) for i in COLOR_PALETTE.size(): pool.append(i) pool.shuffle() while pool.size() < count: var extra: Array = [] for i in COLOR_PALETTE.size(): extra.append(i) extra.shuffle() pool.append_array(extra) return pool.slice(0, count) func _start_round() -> void: _round_token += 1 _pod_values.clear() _eliminated.clear() var pool := _build_value_pool(_pod_ids.size()) for i in _pod_ids.size(): _pod_values[_pod_ids[i]] = int(pool[i]) # Ensure the chosen target value appears on exactly one pod when possible # (digits can collide above 10 pods; re-roll until unique or give up). var pick_idx := randi() % _pod_ids.size() _target_cid = _pod_ids[pick_idx] _target_value = int(_pod_values[_target_cid]) if _mode == GameMode.DIGITS and _pod_ids.size() > 10: _ensure_unique_target_digit() for cid in _pod_ids: _apply_pod_display(cid, int(_pod_values[cid])) _enable_input_stream() _round_start_ms = Time.get_ticks_msec() _set_phase(Phase.PLAYING, "%d Pods bereit" % _pod_ids.size()) _show_target() timer_label.text = "0,00 s" hint_label.text = "" func _ensure_unique_target_digit() -> void: # Make sure only the target pod shows `_target_value`. for cid in _pod_ids: if cid == _target_cid: continue if int(_pod_values[cid]) != _target_value: continue var replacement := (_target_value + 1 + randi() % 9) % 10 _pod_values[cid] = replacement func _show_target() -> void: if _mode == GameMode.DIGITS: target_label.visible = true target_swatch.visible = false target_label.text = str(_target_value) target_label.add_theme_color_override("font_color", Color(0.35, 0.92, 1, 1)) subtitle_label.text = "Drücke den Pod mit dieser Zahl" else: target_label.visible = false target_swatch.visible = true var entry: Dictionary = COLOR_PALETTE[_target_value] var c: Color = entry["color"] target_swatch.color = c subtitle_label.text = "Drücke den Pod mit dieser Farbe · %s" % str(entry["name"]) func _value_label(value: int) -> String: if _mode == GameMode.DIGITS: return str(value) return str(COLOR_PALETTE[value]["name"]) func _apply_pod_display(cid: int, value: int) -> void: if _mode == GameMode.DIGITS: _send_led_digit(cid, value) else: _send_led_color(cid, COLOR_PALETTE[value]["color"]) func _enable_input_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_stream", "enable": true, "interval_ms": STREAM_INTERVAL_MS}) func _on_input(data: Dictionary) -> void: if _phase != Phase.PLAYING or not data.get("success", false): return for client in data.get("clients", []): if typeof(client) != TYPE_DICTIONARY: continue if str(client.get("tap_kind", "none")) == "none": continue var cid := int(client.get("client_id", 0)) if not _pod_values.has(cid): continue if _eliminated.has(cid): continue if int(_pod_values[cid]) == _target_value: _target_cid = cid _on_correct_tap() else: _eliminate_pod(cid) func _eliminate_pod(cid: int) -> void: if _eliminated.has(cid): return _eliminated[cid] = true var wrong_value := int(_pod_values[cid]) hint_label.text = "Falsch! Pod #%d hatte %s — ausgeschieden." % [ cid, _value_label(wrong_value) ] _send_led_blink(cid, Color(1.0, 0.2, 0.2)) var token := _round_token get_tree().create_timer(WRONG_BLINK_SEC).timeout.connect( func() -> void: if not is_inside_tree(): return if _round_token != token: return if _eliminated.has(cid): _send_led_clear(cid), 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() >= MIN_PODS: _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_values.is_empty() or _phase != Phase.PLAYING: return for cid in _pod_values: if _eliminated.has(cid): continue _apply_pod_display(cid, int(_pod_values[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_color(cid: int, color: Color) -> void: _send({ "type": "set_led_ring", "client_id": cid, "mode": "color", "r": int(color.r * 255), "g": int(color.g * 255), "b": int(color.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(), })