Files
demo-game/scripts/color_game.gd
T
skrueckenandCursor 639f4b088d Split Color Game into digit and color modes with up to 16 pods.
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>
2026-11-02 00:00:21 +01:00

428 lines
12 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 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(),
})