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>
This commit is contained in:
2026-11-02 00:00:21 +01:00
co-authored by Cursor
parent 2138039b72
commit 639f4b088d
4 changed files with 217 additions and 41 deletions
+184 -34
View File
@@ -5,11 +5,31 @@ 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
@@ -17,22 +37,29 @@ enum Phase { IDLE, WAITING, PLAYING, WON }
@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()
var _pod_digits: Dictionary = {}
var _target_digit := -1
## 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()
@@ -41,6 +68,18 @@ 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)
@@ -115,7 +154,7 @@ func _on_client_list(data: Dictionary) -> void:
_set_phase(Phase.IDLE, "Client-Liste fehlgeschlagen: %s" % data.get("error", "?"))
return
_pod_ids = PackedInt32Array()
var all_ids := PackedInt32Array()
for entry in data.get("clients", []):
if typeof(entry) != TYPE_DICTIONARY:
continue
@@ -123,47 +162,126 @@ func _on_client_list(data: Dictionary) -> void:
continue
var cid := int(entry.get("id", 0))
if cid > 0:
_pod_ids.append(cid)
all_ids.append(cid)
_pod_ids.sort()
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() < 2:
_set_phase(Phase.WAITING, "Mindestens 2 online Pods nötig (aktuell: %d)" % _pod_ids.size())
target_label.text = "—"
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_digits.clear()
var pool: Array = []
for d in range(10):
pool.append(d)
pool.shuffle()
_pod_values.clear()
_eliminated.clear()
var pool := _build_value_pool(_pod_ids.size())
for i in _pod_ids.size():
_pod_digits[_pod_ids[i]] = int(pool[i])
_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_digit = int(_pod_digits[_target_cid])
_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:
_send_led_digit(cid, int(_pod_digits[cid]))
_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())
target_label.text = str(_target_digit)
subtitle_label.text = "Drücke den Pod mit dieser Zahl"
_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({
@@ -186,20 +304,37 @@ func _on_input(data: Dictionary) -> void:
if str(client.get("tap_kind", "none")) == "none":
continue
var cid := int(client.get("client_id", 0))
if not _pod_digits.has(cid):
if not _pod_values.has(cid):
continue
if cid == _target_cid:
if _eliminated.has(cid):
continue
if int(_pod_values[cid]) == _target_value:
_target_cid = 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
)
_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:
@@ -215,7 +350,7 @@ func _on_correct_tap() -> void:
func() -> void:
if _round_token != token or not is_inside_tree():
return
if _pod_ids.size() >= 2:
if _pod_ids.size() >= MIN_PODS:
_start_round(),
CONNECT_ONE_SHOT
)
@@ -240,10 +375,13 @@ func _digit_intensity() -> int:
func _on_intensity_changed(_value: float) -> void:
intensity_value_label.text = str(_digit_intensity())
if _pod_digits.is_empty():
if _pod_values.is_empty() or _phase != Phase.PLAYING:
return
for cid in _pod_digits:
_send_led_digit(cid, int(_pod_digits[cid]))
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:
@@ -259,6 +397,18 @@ func _send_led_digit(cid: int, digit: int) -> void:
})
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"})