Add menu entries for both variants, use eight high-contrast colors with allowed duplicates, and turn wrong-guess pods off after a red blink until the next round. Co-authored-by: Cursor <cursoragent@cursor.com>
428 lines
12 KiB
GDScript
428 lines
12 KiB
GDScript
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(),
|
||
})
|