diff --git a/scenes/color_game.tscn b/scenes/color_game.tscn index 818eb38..d076e3c 100644 --- a/scenes/color_game.tscn +++ b/scenes/color_game.tscn @@ -115,6 +115,12 @@ theme_override_font_sizes/font_size = 140 horizontal_alignment = 1 text = "—" +[node name="TargetSwatch" type="ColorRect" parent="UiLayer/Center"] +custom_minimum_size = Vector2(220, 220) +layout_mode = 2 +size_flags_horizontal = 4 +color = Color(0.2, 0.22, 0.28, 1) + [node name="TimerLabel" type="Label" parent="UiLayer/Center"] layout_mode = 2 theme_override_colors/font_color = Color(1, 0.88, 0.4, 1) diff --git a/scenes/menu.tscn b/scenes/menu.tscn index 31723ab..4f8f010 100644 --- a/scenes/menu.tscn +++ b/scenes/menu.tscn @@ -29,12 +29,12 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -180.0 -offset_top = -280.0 +offset_top = -320.0 offset_right = 180.0 -offset_bottom = 280.0 +offset_bottom = 320.0 grow_horizontal = 2 grow_vertical = 2 -theme_override_constants/separation = 16 +theme_override_constants/separation = 14 alignment = 1 [node name="Title" type="Label" parent="CenterBox"] @@ -76,12 +76,19 @@ size_flags_horizontal = 4 theme_override_font_sizes/font_size = 18 text = "Pong" -[node name="ColorGameButton" type="Button" parent="CenterBox"] +[node name="DigitsGameButton" type="Button" parent="CenterBox"] custom_minimum_size = Vector2(280, 48) layout_mode = 2 size_flags_horizontal = 4 theme_override_font_sizes/font_size = 18 -text = "Color Game" +text = "Zahlen-Spiel" + +[node name="ColorsGameButton" type="Button" parent="CenterBox"] +custom_minimum_size = Vector2(280, 48) +layout_mode = 2 +size_flags_horizontal = 4 +theme_override_font_sizes/font_size = 18 +text = "Farben-Spiel" [node name="SpaceShooterButton" type="Button" parent="CenterBox"] custom_minimum_size = Vector2(280, 48) diff --git a/scripts/color_game.gd b/scripts/color_game.gd index fffe2ef..94ac863 100644 --- a/scripts/color_game.gd +++ b/scripts/color_game.gd @@ -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"}) diff --git a/scripts/menu.gd b/scripts/menu.gd index 4bfa153..79150dc 100644 --- a/scripts/menu.gd +++ b/scripts/menu.gd @@ -5,11 +5,13 @@ const CONFIG_SCENE := "res://scenes/config.tscn" const PONG_SCENE := "res://scenes/pong.tscn" const COLOR_GAME_SCENE := "res://scenes/color_game.tscn" const SPACE_SHOOTER_SCENE := "res://scenes/space_shooter.tscn" +const ColorGameScript = preload("res://scripts/color_game.gd") @onready var demo_button: Button = $CenterBox/DemoButton @onready var config_button: Button = $CenterBox/ConfigButton @onready var pong_button: Button = $CenterBox/PongButton -@onready var color_game_button: Button = $CenterBox/ColorGameButton +@onready var digits_game_button: Button = $CenterBox/DigitsGameButton +@onready var colors_game_button: Button = $CenterBox/ColorsGameButton @onready var space_shooter_button: Button = $CenterBox/SpaceShooterButton @onready var exit_button: Button = $CenterBox/ExitButton @@ -18,11 +20,22 @@ func _ready() -> void: demo_button.pressed.connect(func(): _open_scene(DEMO_SCENE)) config_button.pressed.connect(func(): _open_scene(CONFIG_SCENE)) pong_button.pressed.connect(func(): _open_scene(PONG_SCENE)) - color_game_button.pressed.connect(func(): _open_scene(COLOR_GAME_SCENE)) + digits_game_button.pressed.connect(_open_digits_game) + colors_game_button.pressed.connect(_open_colors_game) space_shooter_button.pressed.connect(func(): _open_scene(SPACE_SHOOTER_SCENE)) exit_button.pressed.connect(_exit_game) +func _open_digits_game() -> void: + ColorGameScript.start_mode = ColorGameScript.GameMode.DIGITS + _open_scene(COLOR_GAME_SCENE) + + +func _open_colors_game() -> void: + ColorGameScript.start_mode = ColorGameScript.GameMode.COLORS + _open_scene(COLOR_GAME_SCENE) + + func _open_scene(path: String) -> void: get_tree().change_scene_to_file(path)