From 2138039b72c49f8f5f53ba3da67260b236c0a67b Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 1 Nov 2026 23:36:42 +0100 Subject: [PATCH] =?UTF-8?q?Support=20dynamic=202=E2=80=938=20player=20Spac?= =?UTF-8?q?e=20Shooter=20from=20selected=20pods.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed left/right split with selectable pod slots and a scalable grid layout so player count follows the chosen pods. Co-authored-by: Cursor --- scenes/space_shooter.tscn | 71 ++-- scripts/space_shooter.gd | 753 ++++++++++++++++++++++++++------------ scripts/sprite_atlas.gd | 12 + 3 files changed, 563 insertions(+), 273 deletions(-) diff --git a/scenes/space_shooter.tscn b/scenes/space_shooter.tscn index d895d07..c4f5051 100644 --- a/scenes/space_shooter.tscn +++ b/scenes/space_shooter.tscn @@ -39,42 +39,19 @@ theme_override_colors/font_color = Color(0.75, 0.8, 0.9, 1) theme_override_font_sizes/font_size = 14 text = "Starting…" -[node name="LeftScoreLabel" type="Label" parent="UiLayer"] -offset_left = 24.0 -offset_top = 52.0 -offset_right = 280.0 -offset_bottom = 120.0 -theme_override_colors/font_color = Color(0.45, 0.75, 1, 1) -theme_override_font_sizes/font_size = 18 -text = "Links · —" - -[node name="RightScoreLabel" type="Label" parent="UiLayer"] -anchors_preset = 1 -anchor_left = 1.0 -anchor_right = 1.0 -offset_left = -280.0 -offset_top = 52.0 -offset_right = -24.0 -offset_bottom = 120.0 -grow_horizontal = 0 -theme_override_colors/font_color = Color(1, 0.72, 0.35, 1) -theme_override_font_sizes/font_size = 18 -horizontal_alignment = 2 -text = "Rechts · —" - [node name="SetupPanel" type="VBoxContainer" parent="UiLayer"] anchors_preset = 8 anchor_left = 0.5 anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 -offset_left = -280.0 -offset_top = -160.0 -offset_right = 280.0 -offset_bottom = 160.0 +offset_left = -300.0 +offset_top = -220.0 +offset_right = 300.0 +offset_bottom = 220.0 grow_horizontal = 2 grow_vertical = 2 -theme_override_constants/separation = 18 +theme_override_constants/separation = 16 alignment = 1 [node name="SetupTitle" type="Label" parent="UiLayer/SetupPanel"] @@ -89,34 +66,36 @@ layout_mode = 2 theme_override_colors/font_color = Color(0.6, 0.65, 0.75, 1) theme_override_font_sizes/font_size = 14 horizontal_alignment = 1 -text = "Split-Screen · kippen oder Lenkrad drehen" +text = "Raster · kippen oder Lenkrad drehen" -[node name="ClientGrid" type="GridContainer" parent="UiLayer/SetupPanel"] +[node name="SlotControls" type="HBoxContainer" parent="UiLayer/SetupPanel"] layout_mode = 2 size_flags_horizontal = 4 -theme_override_constants/h_separation = 16 -theme_override_constants/v_separation = 12 -columns = 2 +theme_override_constants/separation = 12 +alignment = 1 -[node name="LeftCaption" type="Label" parent="UiLayer/SetupPanel/ClientGrid"] +[node name="RemoveSlotButton" type="Button" parent="UiLayer/SetupPanel/SlotControls"] +custom_minimum_size = Vector2(44, 36) layout_mode = 2 -theme_override_colors/font_color = Color(0.45, 0.75, 1, 1) +text = "−" + +[node name="SlotCountLabel" type="Label" parent="UiLayer/SetupPanel/SlotControls"] +custom_minimum_size = Vector2(110, 0) +layout_mode = 2 +theme_override_colors/font_color = Color(0.85, 0.88, 0.95, 1) theme_override_font_sizes/font_size = 16 -text = "Links" +horizontal_alignment = 1 +text = "2 Spieler" -[node name="LeftClientOption" type="OptionButton" parent="UiLayer/SetupPanel/ClientGrid"] -custom_minimum_size = Vector2(220, 0) +[node name="AddSlotButton" type="Button" parent="UiLayer/SetupPanel/SlotControls"] +custom_minimum_size = Vector2(44, 36) layout_mode = 2 +text = "+" -[node name="RightCaption" type="Label" parent="UiLayer/SetupPanel/ClientGrid"] -layout_mode = 2 -theme_override_colors/font_color = Color(1, 0.72, 0.35, 1) -theme_override_font_sizes/font_size = 16 -text = "Rechts" - -[node name="RightClientOption" type="OptionButton" parent="UiLayer/SetupPanel/ClientGrid"] -custom_minimum_size = Vector2(220, 0) +[node name="PlayerSlots" type="VBoxContainer" parent="UiLayer/SetupPanel"] layout_mode = 2 +size_flags_horizontal = 4 +theme_override_constants/separation = 10 [node name="SetupHintLabel" type="Label" parent="UiLayer/SetupPanel"] layout_mode = 2 diff --git a/scripts/space_shooter.gd b/scripts/space_shooter.gd index 1832aa6..e3d8304 100644 --- a/scripts/space_shooter.gd +++ b/scripts/space_shooter.gd @@ -2,17 +2,19 @@ extends Node2D enum GameState { SETUP, PLAYING } -enum PlayerSide { LEFT, RIGHT } - const WS_URL := "ws://localhost:8081/ws" const MENU_SCENE := "res://scenes/menu.tscn" const STREAM_INTERVAL_MS := 16 +const MIN_PLAYERS := 2 +const MAX_PLAYERS := 8 + const PLAY_TOP := 72.0 const PLAY_BOTTOM_MARGIN := 32.0 const SHIP_Y_OFFSET := 88.0 -const SIDE_MARGIN := 48.0 +const SIDE_MARGIN := 24.0 const DIVIDER_WIDTH := 4.0 +const HUD_INSET := 12.0 const MAX_SHIP_SPEED := 720.0 const SHIP_FRICTION := 0.88 @@ -42,40 +44,105 @@ const SHIP_SCALE := 0.72 const ASTEROID_SCALE := 0.78 const LASER_SCALE := 1.4 +const PLAYER_SHIP_SPRITES: PackedStringArray = [ + "playerShip1_blue", + "playerShip1_orange", + "playerShip1_green", + "playerShip1_red", + "playerShip2_blue", + "playerShip2_orange", + "playerShip2_green", + "playerShip2_red", +] +const PLAYER_LASER_SPRITES: PackedStringArray = [ + "laserBlue01", + "laserRed01", + "laserGreen01", + "laserRed01", + "laserBlue01", + "laserRed01", + "laserGreen01", + "laserRed01", +] +const PLAYER_BOOST_LASERS: PackedStringArray = [ + "laserGreen01", + "laserGreen01", + "laserGreen01", + "laserGreen01", + "laserGreen01", + "laserGreen01", + "laserGreen01", + "laserGreen01", +] +const PLAYER_LED_COLORS: Array[Color] = [ + Color(0.2, 0.75, 1.0), + Color(1.0, 0.55, 0.15), + Color(0.25, 0.95, 0.4), + Color(1.0, 0.3, 0.35), + Color(0.45, 0.85, 1.0), + Color(1.0, 0.7, 0.3), + Color(0.4, 1.0, 0.65), + Color(1.0, 0.45, 0.7), +] +const PLAYER_BOOST_COLORS: Array[Color] = [ + Color(0.3, 1.0, 0.45), + Color(1.0, 0.35, 0.2), + Color(0.55, 1.0, 0.35), + Color(1.0, 0.45, 0.55), + Color(0.5, 1.0, 0.7), + Color(1.0, 0.5, 0.25), + Color(0.65, 1.0, 0.45), + Color(1.0, 0.55, 0.75), +] +const PLAYER_HUD_COLORS: Array[Color] = [ + Color(0.45, 0.75, 1.0), + Color(1.0, 0.72, 0.35), + Color(0.45, 0.95, 0.55), + Color(1.0, 0.5, 0.55), + Color(0.55, 0.85, 1.0), + Color(1.0, 0.8, 0.45), + Color(0.55, 1.0, 0.7), + Color(1.0, 0.6, 0.75), +] + @onready var game_layer: Node2D = $GameLayer @onready var stars_layer: Node2D = $StarsLayer +@onready var ui_layer: CanvasLayer = $UiLayer @onready var status_label: Label = $UiLayer/StatusLabel @onready var menu_button: Button = $UiLayer/MenuButton -@onready var left_score_label: Label = $UiLayer/LeftScoreLabel -@onready var right_score_label: Label = $UiLayer/RightScoreLabel @onready var setup_panel: Control = $UiLayer/SetupPanel -@onready var left_client_option: OptionButton = $UiLayer/SetupPanel/ClientGrid/LeftClientOption -@onready var right_client_option: OptionButton = $UiLayer/SetupPanel/ClientGrid/RightClientOption +@onready var setup_subtitle: Label = $UiLayer/SetupPanel/SetupSubtitle +@onready var player_slots: VBoxContainer = $UiLayer/SetupPanel/PlayerSlots +@onready var slot_count_label: Label = $UiLayer/SetupPanel/SlotControls/SlotCountLabel +@onready var remove_slot_button: Button = $UiLayer/SetupPanel/SlotControls/RemoveSlotButton +@onready var add_slot_button: Button = $UiLayer/SetupPanel/SlotControls/AddSlotButton @onready var start_game_button: Button = $UiLayer/SetupPanel/StartGameButton @onready var setup_hint_label: Label = $UiLayer/SetupPanel/SetupHintLabel var _socket := WebSocketPeer.new() var _state := GameState.SETUP var _available_clients: PackedInt32Array = PackedInt32Array() -var _left_client_id := 0 -var _right_client_id := 0 +var _player_client_ids: PackedInt32Array = PackedInt32Array() var _stream_client_ids: PackedInt32Array = PackedInt32Array() +var _setup_slot_count := MIN_PLAYERS +var _slot_options: Array[OptionButton] = [] -var _sensitivities: Array[float] = [DEFAULT_SENSITIVITY, DEFAULT_SENSITIVITY] -var _accel_filtered: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO] -var _ship_x: Array[float] = [0.0, 0.0] -var _ship_vx: Array[float] = [0.0, 0.0] -var _scores: Array[int] = [0, 0] -var _fire_cooldown: Array[float] = [0.0, 0.0] -var _spawn_timer: Array[float] = [0.0, 0.0] -var _lives: Array[int] = [MAX_LIVES, MAX_LIVES] -var _alive: Array[bool] = [true, true] -var _boost_time_left: Array[float] = [0.0, 0.0] -var _boost_cooldown_left: Array[float] = [0.0, 0.0] -var _boost_led_active: Array[bool] = [false, false] +var _sensitivities: Array[float] = [] +var _accel_filtered: Array[Vector3] = [] +var _ship_x: Array[float] = [] +var _ship_vx: Array[float] = [] +var _scores: Array[int] = [] +var _fire_cooldown: Array[float] = [] +var _spawn_timer: Array[float] = [] +var _lives: Array[int] = [] +var _alive: Array[bool] = [] +var _boost_time_left: Array[float] = [] +var _boost_cooldown_left: Array[float] = [] +var _boost_led_active: Array[bool] = [] var _led_update_timer := 0.0 var _ships: Array[Sprite2D] = [] +var _score_labels: Array[Label] = [] var _asteroids: Array = [] var _bullets: Array = [] var _stars: Array = [] @@ -84,14 +151,16 @@ var _stars: Array = [] func _ready() -> void: menu_button.pressed.connect(_return_to_menu) start_game_button.pressed.connect(_on_start_game_pressed) - left_client_option.item_selected.connect(_on_client_selection_changed) - right_client_option.item_selected.connect(_on_client_selection_changed) + add_slot_button.pressed.connect(_on_add_slot_pressed) + remove_slot_button.pressed.connect(_on_remove_slot_pressed) _hide_gameplay() setup_panel.visible = true _state = GameState.SETUP + setup_subtitle.text = "Raster · kippen oder Lenkrad drehen" - _init_visuals() + _rebuild_setup_slots() + _init_stars() _connect_ws() @@ -102,7 +171,7 @@ func _exit_tree() -> void: func _cleanup_hardware() -> void: if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN: _send({"type": "set_stream", "enable": false, "interval_ms": STREAM_INTERVAL_MS}) - for cid in [_left_client_id, _right_client_id]: + for cid in _player_client_ids: if cid <= 0: continue _send({"type": "set_input_stream", "client_id": cid, "enable": false}) @@ -130,15 +199,70 @@ func _connect_ws() -> void: status_label.text = "Verbinde mit %s…" % WS_URL -func _init_visuals() -> void: - for side in [PlayerSide.LEFT, PlayerSide.RIGHT]: - var ship := Sprite2D.new() - ship.texture = SpriteAtlas.named("playerShip1_blue") if side == PlayerSide.LEFT else SpriteAtlas.named("playerShip1_orange") - ship.scale = Vector2(SHIP_SCALE, SHIP_SCALE) - ship.visible = false - game_layer.add_child(ship) - _ships.append(ship) +func _player_count() -> int: + return _player_client_ids.size() + +func _grid_cols() -> int: + var n := maxi(_player_count(), 1) + if n <= 2: + return n + if n <= 4: + return 2 + if n <= 6: + return 3 + return 4 + + +func _grid_rows() -> int: + var n := maxi(_player_count(), 1) + return int(ceil(float(n) / float(_grid_cols()))) + + +func _resize_player_state(n: int) -> void: + _sensitivities.clear() + _accel_filtered.clear() + _ship_x.clear() + _ship_vx.clear() + _scores.clear() + _fire_cooldown.clear() + _spawn_timer.clear() + _lives.clear() + _alive.clear() + _boost_time_left.clear() + _boost_cooldown_left.clear() + _boost_led_active.clear() + + for i in n: + _sensitivities.append(DEFAULT_SENSITIVITY) + _accel_filtered.append(Vector3.ZERO) + _ship_x.append(0.0) + _ship_vx.append(0.0) + _scores.append(0) + _fire_cooldown.append(0.0) + _spawn_timer.append(0.8 + i * 0.3) + _lives.append(MAX_LIVES) + _alive.append(true) + _boost_time_left.append(0.0) + _boost_cooldown_left.append(0.0) + _boost_led_active.append(false) + + +func _clear_ships() -> void: + for ship in _ships: + if is_instance_valid(ship): + ship.queue_free() + _ships.clear() + + +func _clear_score_labels() -> void: + for label in _score_labels: + if is_instance_valid(label): + label.queue_free() + _score_labels.clear() + + +func _init_stars() -> void: for i in 40: var star := Sprite2D.new() star.texture = SpriteAtlas.named("star1" if i % 2 == 0 else "star2") @@ -151,6 +275,37 @@ func _init_visuals() -> void: _stars.append(star) +func _create_player_visuals() -> void: + _clear_ships() + _clear_score_labels() + var n := _player_count() + for i in n: + var ship := Sprite2D.new() + ship.texture = SpriteAtlas.named(PLAYER_SHIP_SPRITES[i]) + ship.scale = Vector2(SHIP_SCALE, SHIP_SCALE) + ship.visible = false + game_layer.add_child(ship) + _ships.append(ship) + + var label := Label.new() + label.name = "ScoreLabel%d" % i + label.add_theme_color_override("font_color", PLAYER_HUD_COLORS[i]) + label.add_theme_font_size_override("font_size", 16) + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + ui_layer.add_child(label) + _score_labels.append(label) + _layout_score_label(i) + + +func _layout_score_label(player: int) -> void: + if player < 0 or player >= _score_labels.size(): + return + var rect := _player_play_rect(player) + var label := _score_labels[player] + label.position = Vector2(rect.position.x + HUD_INSET, rect.position.y + 4.0) + label.size = Vector2(maxi(rect.size.x - HUD_INSET * 2.0, 80.0), 72.0) + + func _physics_process(delta: float) -> void: _socket.poll() _poll_messages() @@ -211,53 +366,165 @@ func _on_client_list(data: Dictionary) -> void: _available_clients.append(cid) _available_clients.sort() + if _state != GameState.SETUP: + return + + var max_slots := clampi(_available_clients.size(), MIN_PLAYERS, MAX_PLAYERS) + if _available_clients.size() < MIN_PLAYERS: + _setup_slot_count = MIN_PLAYERS + else: + _setup_slot_count = clampi(_setup_slot_count, MIN_PLAYERS, max_slots) + _rebuild_setup_slots() + _populate_client_options() + _on_client_selection_changed(-1) + + +func _max_setup_slots() -> int: + if _available_clients.is_empty(): + return MIN_PLAYERS + return clampi(_available_clients.size(), MIN_PLAYERS, MAX_PLAYERS) + + +func _rebuild_setup_slots() -> void: + for child in player_slots.get_children(): + player_slots.remove_child(child) + child.free() + _slot_options.clear() + + for i in _setup_slot_count: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 12) + + var caption := Label.new() + caption.custom_minimum_size = Vector2(110, 0) + caption.add_theme_color_override("font_color", PLAYER_HUD_COLORS[i]) + caption.add_theme_font_size_override("font_size", 16) + caption.text = "Spieler %d" % (i + 1) + row.add_child(caption) + + var option := OptionButton.new() + option.custom_minimum_size = Vector2(220, 0) + option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + option.item_selected.connect(_on_client_selection_changed) + row.add_child(option) + _slot_options.append(option) + + player_slots.add_child(row) + + _update_slot_controls() + + +func _update_slot_controls() -> void: + var max_slots := _max_setup_slots() + slot_count_label.text = "%d Spieler" % _setup_slot_count + remove_slot_button.disabled = _setup_slot_count <= MIN_PLAYERS + add_slot_button.disabled = _setup_slot_count >= max_slots or _available_clients.size() < MIN_PLAYERS + + +func _on_add_slot_pressed() -> void: + if _setup_slot_count >= _max_setup_slots(): + return + _setup_slot_count += 1 + _rebuild_setup_slots() + _populate_client_options() + _on_client_selection_changed(-1) + + +func _on_remove_slot_pressed() -> void: + if _setup_slot_count <= MIN_PLAYERS: + return + _setup_slot_count -= 1 + _rebuild_setup_slots() _populate_client_options() _on_client_selection_changed(-1) func _populate_client_options() -> void: - left_client_option.clear() - right_client_option.clear() - for cid in _available_clients: - var label := "Pod #%d" % cid - left_client_option.add_item(label, cid) - right_client_option.add_item(label, cid) + for option in _slot_options: + option.clear() + for cid in _available_clients: + option.add_item("Pod #%d" % cid, cid) - if _available_clients.size() >= 2: - left_client_option.select(0) - right_client_option.select(1) - elif _available_clients.size() == 1: - left_client_option.select(0) - right_client_option.select(0) + for i in _slot_options.size(): + var option := _slot_options[i] + if option.item_count == 0: + continue + var select_idx := mini(i, option.item_count - 1) + option.select(select_idx) + + +func _read_selected_client_ids() -> PackedInt32Array: + var ids := PackedInt32Array() + for option in _slot_options: + if option.item_count == 0: + ids.append(0) + else: + ids.append(option.get_selected_id()) + return ids + + +func _selection_has_duplicates(ids: PackedInt32Array) -> bool: + var seen := {} + for cid in ids: + if cid <= 0: + continue + if seen.has(cid): + return true + seen[cid] = true + return false func _on_client_selection_changed(_idx: int) -> void: - if left_client_option.item_count == 0: - _left_client_id = 0 - _right_client_id = 0 + _update_slot_controls() + var ids := _read_selected_client_ids() + _player_client_ids = ids + + if _available_clients.is_empty() or _slot_options.is_empty() or _slot_options[0].item_count == 0: start_game_button.disabled = true setup_hint_label.text = "Keine Pods online – warte auf Verbindung…" return - _left_client_id = left_client_option.get_selected_id() - _right_client_id = right_client_option.get_selected_id() + if _available_clients.size() < MIN_PLAYERS: + start_game_button.disabled = true + setup_hint_label.text = "Mindestens %d Pods nötig (aktuell: %d)" % [ + MIN_PLAYERS, _available_clients.size() + ] + return - var ok := _available_clients.size() >= 2 and _left_client_id > 0 and _right_client_id > 0 and _left_client_id != _right_client_id - start_game_button.disabled = not ok - if _available_clients.size() < 2: - setup_hint_label.text = "Mindestens 2 Pods nötig (aktuell: %d)" % _available_clients.size() - elif _left_client_id == _right_client_id: - setup_hint_label.text = "Links und rechts müssen verschiedene Pods sein." - else: - setup_hint_label.text = ( - "Pod #%d links · Pod #%d rechts · kippen oder Lenkrad drehen" - % [_left_client_id, _right_client_id] - ) + var all_valid := true + for cid in ids: + if cid <= 0: + all_valid = false + break + + if not all_valid: + start_game_button.disabled = true + setup_hint_label.text = "Jeden Slot mit einem Pod belegen." + return + + if _selection_has_duplicates(ids): + start_game_button.disabled = true + setup_hint_label.text = "Jeder Spieler braucht einen eigenen Pod." + return + + start_game_button.disabled = false + var parts: PackedStringArray = PackedStringArray() + for i in ids.size(): + parts.append("S%d=#%d" % [i + 1, ids[i]]) + setup_hint_label.text = "%s · kippen oder Lenkrad drehen" % ", ".join(parts) func _on_start_game_pressed() -> void: - if _left_client_id <= 0 or _right_client_id <= 0 or _left_client_id == _right_client_id: + var ids := _read_selected_client_ids() + if ids.size() < MIN_PLAYERS: return + for cid in ids: + if cid <= 0: + return + if _selection_has_duplicates(ids): + return + + _player_client_ids = ids setup_panel.visible = false _begin_playing() @@ -265,24 +532,17 @@ func _on_start_game_pressed() -> void: func _begin_playing() -> void: _state = GameState.PLAYING _clear_entities() - _scores = [0, 0] - _ship_vx = [0.0, 0.0] - _fire_cooldown = [0.0, 0.0] - _spawn_timer = [0.8, 1.1] - _lives = [MAX_LIVES, MAX_LIVES] - _alive = [true, true] - _boost_time_left = [0.0, 0.0] - _boost_cooldown_left = [0.0, 0.0] - _boost_led_active = [false, false] + _resize_player_state(_player_count()) + _create_player_visuals() _led_update_timer = 0.0 - _sensitivities = [DEFAULT_SENSITIVITY, DEFAULT_SENSITIVITY] - for side in [PlayerSide.LEFT, PlayerSide.RIGHT]: - _ship_x[side] = _side_play_rect(side).get_center().x + for i in _player_count(): + _ship_x[i] = _player_play_rect(i).get_center().x _show_gameplay() - _enable_both_streams() + _enable_player_streams() _update_score_labels() + queue_redraw() status_label.text = ( "Pod halten · links/rechts kippen oder Lenkrad · Tap = Boost (%ds)" % int(BOOST_DURATION_SEC) @@ -290,13 +550,17 @@ func _begin_playing() -> void: func _show_gameplay() -> void: - for ship in _ships: - ship.visible = true + for i in _ships.size(): + _ships[i].visible = _alive[i] if i < _alive.size() else true + for label in _score_labels: + label.visible = true func _hide_gameplay() -> void: for ship in _ships: ship.visible = false + for label in _score_labels: + label.visible = false _clear_entities() @@ -309,9 +573,9 @@ func _clear_entities() -> void: _bullets.clear() -func _enable_both_streams() -> void: +func _enable_player_streams() -> void: _disable_all_streams() - _stream_client_ids = PackedInt32Array([_left_client_id, _right_client_id]) + _stream_client_ids = _player_client_ids.duplicate() for cid in _stream_client_ids: _send({"type": "set_input_stream", "client_id": cid, "enable": true}) _send({ @@ -322,8 +586,8 @@ func _enable_both_streams() -> void: "triple": false, }) _send({"type": "set_stream", "enable": true, "interval_ms": STREAM_INTERVAL_MS}) - for side in [PlayerSide.LEFT, PlayerSide.RIGHT]: - _update_led_for_side(side) + for i in _player_count(): + _update_led_for_player(i) func _disable_all_streams() -> void: @@ -351,8 +615,8 @@ func _on_input(data: Dictionary) -> void: if typeof(client) != TYPE_DICTIONARY: continue var cid := int(client.get("client_id", 0)) - var side := _side_for_client(cid) - if side < 0: + var player := _player_for_client(cid) + if player < 0: continue if client.get("valid", false): var accel := Vector3i( @@ -360,48 +624,53 @@ func _on_input(data: Dictionary) -> void: _to_int(client.get("y")), _to_int(client.get("z")) ) - _set_filtered_accel(side, accel) + _set_filtered_accel(player, accel) if _state == GameState.PLAYING and _is_boost_tap(str(client.get("tap_kind", ""))): - _on_boost_tap(side) + _on_boost_tap(player) -func _side_for_client(cid: int) -> int: - if cid == _left_client_id: - return PlayerSide.LEFT - if cid == _right_client_id: - return PlayerSide.RIGHT +func _player_for_client(cid: int) -> int: + for i in _player_client_ids.size(): + if _player_client_ids[i] == cid: + return i return -1 +func _client_id_for_player(player: int) -> int: + if player < 0 or player >= _player_client_ids.size(): + return 0 + return _player_client_ids[player] + + func _is_boost_tap(tap_kind: String) -> bool: return tap_kind == "single" or tap_kind == "double" -func _on_boost_tap(side: PlayerSide) -> void: - if not _alive[side]: +func _on_boost_tap(player: int) -> void: + if not _alive[player]: return - if _boost_cooldown_left[side] > 0.0 or _boost_time_left[side] > 0.0: + if _boost_cooldown_left[player] > 0.0 or _boost_time_left[player] > 0.0: return - _boost_time_left[side] = BOOST_DURATION_SEC - _boost_led_active[side] = false + _boost_time_left[player] = BOOST_DURATION_SEC + _boost_led_active[player] = false _led_update_timer = 0.0 _update_score_labels() func _update_boost_state(delta: float) -> void: - for side in [PlayerSide.LEFT, PlayerSide.RIGHT]: - if _boost_time_left[side] > 0.0: - _boost_time_left[side] -= delta - if _boost_time_left[side] <= 0.0: - _boost_time_left[side] = 0.0 - _boost_cooldown_left[side] = BOOST_COOLDOWN_SEC - _boost_led_active[side] = false - elif _boost_cooldown_left[side] > 0.0: - _boost_cooldown_left[side] = maxf(_boost_cooldown_left[side] - delta, 0.0) + for i in _player_count(): + if _boost_time_left[i] > 0.0: + _boost_time_left[i] -= delta + if _boost_time_left[i] <= 0.0: + _boost_time_left[i] = 0.0 + _boost_cooldown_left[i] = BOOST_COOLDOWN_SEC + _boost_led_active[i] = false + elif _boost_cooldown_left[i] > 0.0: + _boost_cooldown_left[i] = maxf(_boost_cooldown_left[i] - delta, 0.0) -func _is_boost_active(side: PlayerSide) -> bool: - return _alive[side] and _boost_time_left[side] > 0.0 +func _is_boost_active(player: int) -> bool: + return _alive[player] and _boost_time_left[player] > 0.0 func _update_leds_throttled(delta: float) -> void: @@ -409,127 +678,119 @@ func _update_leds_throttled(delta: float) -> void: if _led_update_timer > 0.0: return _led_update_timer = LED_UPDATE_INTERVAL - for side in [PlayerSide.LEFT, PlayerSide.RIGHT]: - _update_led_for_side(side) + for i in _player_count(): + _update_led_for_player(i) -func _update_led_for_side(side: PlayerSide) -> void: - var cid := _client_id_for_side(side) +func _update_led_for_player(player: int) -> void: + var cid := _client_id_for_player(player) if cid <= 0: return - if not _alive[side]: + if not _alive[player]: _send_led_color(cid, Color(0.35, 0.05, 0.05), LED_INTENSITY) return - if _boost_time_left[side] > 0.0: - if not _boost_led_active[side]: - _boost_led_active[side] = true - _send_led_boost_blink(cid, _boost_color(side)) + if _boost_time_left[player] > 0.0: + if not _boost_led_active[player]: + _boost_led_active[player] = true + _send_led_boost_blink(cid, PLAYER_BOOST_COLORS[player]) return - if _boost_cooldown_left[side] > 0.0: - var progress := int((1.0 - _boost_cooldown_left[side] / BOOST_COOLDOWN_SEC) * 100.0) - _send_led_progress(cid, progress, _side_led_color(side)) + if _boost_cooldown_left[player] > 0.0: + var progress := int((1.0 - _boost_cooldown_left[player] / BOOST_COOLDOWN_SEC) * 100.0) + _send_led_progress(cid, progress, PLAYER_LED_COLORS[player]) return - _send_led_progress(cid, 100, _side_led_color(side)) + _send_led_progress(cid, 100, PLAYER_LED_COLORS[player]) -func _side_led_color(side: PlayerSide) -> Color: - return Color(0.2, 0.75, 1.0) if side == PlayerSide.LEFT else Color(1.0, 0.55, 0.15) - - -func _boost_color(side: PlayerSide) -> Color: - return Color(0.3, 1.0, 0.45) if side == PlayerSide.LEFT else Color(1.0, 0.35, 0.2) - - -func _set_filtered_accel(side: PlayerSide, raw: Vector3i) -> void: +func _set_filtered_accel(player: int, raw: Vector3i) -> void: var target := Vector3(raw) - _accel_filtered[side] = _accel_filtered[side].lerp(target, ACCEL_SMOOTH) + _accel_filtered[player] = _accel_filtered[player].lerp(target, ACCEL_SMOOTH) -func _accel_for_side(side: PlayerSide) -> Vector3i: - var v := _accel_filtered[side] +func _accel_for_player(player: int) -> Vector3i: + var v := _accel_filtered[player] return Vector3i(int(v.x), int(v.y), int(v.z)) func _update_ships(delta: float) -> void: - for side in [PlayerSide.LEFT, PlayerSide.RIGHT]: - if not _alive[side]: + for i in _player_count(): + if not _alive[i]: continue - var eff := _effective_accel(side) + var eff := _effective_accel(i) if eff == 0.0: - _ship_vx[side] *= SHIP_FRICTION + _ship_vx[i] *= SHIP_FRICTION else: - var target_v := eff * _velocity_scale(side) - _ship_vx[side] = move_toward( - _ship_vx[side], target_v, maxf(absf(target_v) * 0.35, 100.0) * delta + var target_v := eff * _velocity_scale(i) + _ship_vx[i] = move_toward( + _ship_vx[i], target_v, maxf(absf(target_v) * 0.35, 100.0) * delta ) - _ship_vx[side] = clampf(_ship_vx[side], -MAX_SHIP_SPEED, MAX_SHIP_SPEED) + _ship_vx[i] = clampf(_ship_vx[i], -MAX_SHIP_SPEED, MAX_SHIP_SPEED) - var rect := _side_play_rect(side) - var next_x := _ship_x[side] + _ship_vx[side] * delta + var rect := _player_play_rect(i) + var next_x := _ship_x[i] + _ship_vx[i] * delta var half_w := 42.0 if next_x < rect.position.x + half_w: next_x = rect.position.x + half_w - _ship_vx[side] = 0.0 + _ship_vx[i] = 0.0 if next_x > rect.end.x - half_w: next_x = rect.end.x - half_w - _ship_vx[side] = 0.0 - _ship_x[side] = next_x + _ship_vx[i] = 0.0 + _ship_x[i] = next_x - _fire_cooldown[side] = maxf(_fire_cooldown[side] - delta, 0.0) - if _fire_cooldown[side] <= 0.0: - _fire_cooldown[side] = FIRE_INTERVAL - _spawn_bullet(side) + _fire_cooldown[i] = maxf(_fire_cooldown[i] - delta, 0.0) + if _fire_cooldown[i] <= 0.0: + _fire_cooldown[i] = FIRE_INTERVAL + _spawn_bullet(i) -func _spawn_bullet(side: int) -> void: - var boosted := _is_boost_active(side) +func _spawn_bullet(player: int) -> void: + var boosted := _is_boost_active(player) var laser := Sprite2D.new() if boosted: - laser.texture = SpriteAtlas.named("laserGreen01") if side == PlayerSide.LEFT else SpriteAtlas.named("laserRed01") + laser.texture = SpriteAtlas.named(PLAYER_BOOST_LASERS[player]) laser.modulate = Color(1.2, 1.2, 1.2) else: - laser.texture = SpriteAtlas.named("laserBlue01") + laser.texture = SpriteAtlas.named(PLAYER_LASER_SPRITES[player]) laser.scale = Vector2(LASER_SCALE, LASER_SCALE) laser.centered = true game_layer.add_child(laser) - var ship_y := _ship_y(side) + var ship_y := _ship_y(player) var damage := BULLET_DAMAGE * (BOOST_DAMAGE_MULT if boosted else 1.0) _bullets.append({ "node": laser, - "side": side, - "x": _ship_x[side], + "side": player, + "x": _ship_x[player], "y": ship_y - 36.0, "damage": damage, }) func _try_spawn_asteroids(delta: float) -> void: - for side in [PlayerSide.LEFT, PlayerSide.RIGHT]: - if not _alive[side]: + for i in _player_count(): + if not _alive[i]: continue - _spawn_timer[side] -= delta - if _spawn_timer[side] > 0.0: + _spawn_timer[i] -= delta + if _spawn_timer[i] > 0.0: continue - _spawn_timer[side] = ASTEROID_SPAWN_INTERVAL + randf_range(-0.4, 0.6) - _spawn_asteroid(side) + _spawn_timer[i] = ASTEROID_SPAWN_INTERVAL + randf_range(-0.4, 0.6) + _spawn_asteroid(i) -func _spawn_asteroid(side: int) -> void: +func _spawn_asteroid(player: int) -> void: var sprite := Sprite2D.new() sprite.texture = SpriteAtlas.named("meteorBrown_big1") sprite.scale = Vector2(ASTEROID_SCALE, ASTEROID_SCALE) sprite.centered = true game_layer.add_child(sprite) - var rect := _side_play_rect(side) + var rect := _player_play_rect(player) var half_w := 52.0 var x := randf_range(rect.position.x + half_w, rect.end.x - half_w) _asteroids.append({ "node": sprite, - "side": side, + "side": player, "x": x, - "y": PLAY_TOP - 40.0, + "y": rect.position.y - 40.0, "health": ASTEROID_HEALTH, "scored": false, }) @@ -539,7 +800,9 @@ func _update_bullets(delta: float) -> void: var remove: Array = [] for entry in _bullets: entry["y"] -= BULLET_SPEED * delta - if entry["y"] < PLAY_TOP - 60.0: + var player: int = entry["side"] + var top := _player_play_rect(player).position.y if player >= 0 and player < _player_count() else PLAY_TOP + if entry["y"] < top - 60.0: remove.append(entry) for entry in remove: entry["node"].queue_free() @@ -550,12 +813,12 @@ func _update_asteroids(delta: float) -> void: var remove: Array = [] for entry in _asteroids: entry["y"] += ASTEROID_SCROLL * delta - var side: int = entry["side"] + var player: int = entry["side"] if entry["scored"]: continue - if entry["y"] > _ship_y(side) + 70.0: + if entry["y"] > _ship_y(player) + 70.0: if not _asteroid_hits_ship(entry): - _award_points(side, SCORE_DODGE) + _award_points(player, SCORE_DODGE) entry["scored"] = true remove.append(entry) for entry in remove: @@ -600,41 +863,41 @@ func _check_collisions() -> void: _damage_ship(int(asteroid["side"])) -func _damage_ship(side: PlayerSide) -> void: - if not _alive[side]: +func _damage_ship(player: int) -> void: + if not _alive[player]: return - _lives[side] -= 1 - _ships[side].modulate = Color(1.5, 0.45, 0.45) + _lives[player] -= 1 + _ships[player].modulate = Color(1.5, 0.45, 0.45) get_tree().create_timer(0.15).timeout.connect( func() -> void: - if is_inside_tree() and _alive[side]: - _ships[side].modulate = Color.WHITE, + if is_inside_tree() and player < _alive.size() and _alive[player]: + _ships[player].modulate = Color.WHITE, CONNECT_ONE_SHOT ) - if _lives[side] <= 0: - _alive[side] = false - _ships[side].visible = false - _boost_time_left[side] = 0.0 - _boost_led_active[side] = false - _clear_side_asteroids(side) - _update_led_for_side(side) + if _lives[player] <= 0: + _alive[player] = false + _ships[player].visible = false + _boost_time_left[player] = 0.0 + _boost_led_active[player] = false + _clear_player_asteroids(player) + _update_led_for_player(player) _update_score_labels() -func _clear_side_asteroids(side: int) -> void: +func _clear_player_asteroids(player: int) -> void: for entry in _asteroids.duplicate(): - if entry["side"] != side: + if entry["side"] != player: continue entry["node"].queue_free() _asteroids.erase(entry) func _asteroid_hits_ship(asteroid: Dictionary) -> bool: - var side: int = asteroid["side"] - if not _alive[side]: + var player: int = asteroid["side"] + if not _alive[player]: return false return _hit_test( - {"x": _ship_x[side], "y": _ship_y(side)}, + {"x": _ship_x[player], "y": _ship_y(player)}, asteroid, 38.0, 42.0 @@ -648,35 +911,36 @@ func _hit_test(a: Dictionary, b: Dictionary, radius_a: float, radius_b: float) - return dx * dx + dy * dy <= r * r -func _award_points(side: int, amount: int) -> void: - _scores[side] += amount +func _award_points(player: int, amount: int) -> void: + _scores[player] += amount _update_score_labels() func _update_score_labels() -> void: - left_score_label.text = _player_hud_text(PlayerSide.LEFT) - right_score_label.text = _player_hud_text(PlayerSide.RIGHT) + for i in _score_labels.size(): + _layout_score_label(i) + _score_labels[i].text = _player_hud_text(i) -func _player_hud_text(side: PlayerSide) -> String: - var side_label := "Links" if side == PlayerSide.LEFT else "Rechts" - var cid := _client_id_for_side(side) - if not _alive[side]: - return "%s · Pod #%d\n%d Pkt · TOT" % [side_label, cid, _scores[side]] +func _player_hud_text(player: int) -> String: + var side_label := "Spieler %d" % (player + 1) + var cid := _client_id_for_player(player) + if not _alive[player]: + return "%s · Pod #%d\n%d Pkt · TOT" % [side_label, cid, _scores[player]] var boost_text := "Boost bereit" - if _boost_time_left[side] > 0.0: - boost_text = "BOOST %.0fs" % _boost_time_left[side] - elif _boost_cooldown_left[side] > 0.0: - boost_text = "Boost %.0fs" % _boost_cooldown_left[side] + if _boost_time_left[player] > 0.0: + boost_text = "BOOST %.0fs" % _boost_time_left[player] + elif _boost_cooldown_left[player] > 0.0: + boost_text = "Boost %.0fs" % _boost_cooldown_left[player] return "%s · Pod #%d\n%d Pkt · ♥ %d/%d · %s" % [ - side_label, cid, _scores[side], _lives[side], MAX_LIVES, boost_text + side_label, cid, _scores[player], _lives[player], MAX_LIVES, boost_text ] func _sync_sprite_positions() -> void: - for side in [PlayerSide.LEFT, PlayerSide.RIGHT]: - if _alive[side]: - _ships[side].position = Vector2(_ship_x[side], _ship_y(side)) + for i in _player_count(): + if _alive[i]: + _ships[i].position = Vector2(_ship_x[i], _ship_y(i)) for entry in _bullets: entry["node"].position = Vector2(entry["x"], entry["y"]) for entry in _asteroids: @@ -693,30 +957,36 @@ func _update_stars(delta: float) -> void: star.position.x = randf_range(0.0, get_viewport_rect().size.x) -func _effective_accel(side: int) -> float: +func _effective_accel(player: int) -> float: # Lateral +X → Schiff nach rechts (flach kippen und frontal Lenkrad). - var a := _accel_for_side(side) + var a := _accel_for_player(player) return OrientationGizmo.steer_roll(Vector3(a)) * STEER_GAIN -func _velocity_scale(side: int) -> float: - return _sensitivities[side] / 100.0 +func _velocity_scale(player: int) -> float: + return _sensitivities[player] / 100.0 -func _ship_y(_side: int) -> float: - return get_viewport_rect().size.y - PLAY_BOTTOM_MARGIN - SHIP_Y_OFFSET +func _ship_y(player: int) -> float: + var rect := _player_play_rect(player) + return rect.end.y - SHIP_Y_OFFSET -func _side_play_rect(side: PlayerSide) -> Rect2: +func _player_play_rect(player: int) -> Rect2: var view := get_viewport_rect().size - var half_w := view.x * 0.5 - if side == PlayerSide.LEFT: - return Rect2(SIDE_MARGIN, PLAY_TOP, half_w - SIDE_MARGIN - DIVIDER_WIDTH * 0.5, view.y - PLAY_TOP - PLAY_BOTTOM_MARGIN) - return Rect2(half_w + DIVIDER_WIDTH * 0.5, PLAY_TOP, half_w - SIDE_MARGIN - DIVIDER_WIDTH * 0.5, view.y - PLAY_TOP - PLAY_BOTTOM_MARGIN) + var n := maxi(_player_count(), 1) + var cols := _grid_cols() + var rows := int(ceil(float(n) / float(cols))) + var col := player % cols + var row := int(player / cols) - -func _client_id_for_side(side: PlayerSide) -> int: - return _left_client_id if side == PlayerSide.LEFT else _right_client_id + var total_w := view.x - SIDE_MARGIN * 2.0 + var total_h := view.y - PLAY_TOP - PLAY_BOTTOM_MARGIN + var cell_w := (total_w - DIVIDER_WIDTH * float(cols - 1)) / float(cols) + var cell_h := (total_h - DIVIDER_WIDTH * float(rows - 1)) / float(rows) + var x := SIDE_MARGIN + float(col) * (cell_w + DIVIDER_WIDTH) + var y := PLAY_TOP + float(row) * (cell_h + DIVIDER_WIDTH) + return Rect2(x, y, cell_w, cell_h) func _update_connection_status() -> void: @@ -733,8 +1003,13 @@ func _update_connection_status() -> void: match _socket.get_ready_state(): WebSocketPeer.STATE_OPEN: status_label.text = ( - "Kippen / Lenkrad · Tap = Boost (%ds) · ♥×%d · CD %ds" - % [int(BOOST_DURATION_SEC), MAX_LIVES, int(BOOST_COOLDOWN_SEC)] + "%d Spieler · Kippen / Lenkrad · Tap = Boost (%ds) · ♥×%d · CD %ds" + % [ + _player_count(), + int(BOOST_DURATION_SEC), + MAX_LIVES, + int(BOOST_COOLDOWN_SEC), + ] ) WebSocketPeer.STATE_CONNECTING: status_label.text = "Verbinde…" @@ -807,11 +1082,35 @@ func _to_int(value: Variant) -> int: func _draw() -> void: - var view := get_viewport_rect().size - var mid_x := view.x * 0.5 - draw_line(Vector2(mid_x, PLAY_TOP), Vector2(mid_x, view.y), Color(0.35, 0.4, 0.55, 0.55), DIVIDER_WIDTH) + if _state != GameState.PLAYING or _player_count() == 0: + return - var left_rect := _side_play_rect(PlayerSide.LEFT) - var right_rect := _side_play_rect(PlayerSide.RIGHT) - draw_rect(left_rect, Color(0.12, 0.14, 0.22, 0.35), false, 1.0) - draw_rect(right_rect, Color(0.12, 0.14, 0.22, 0.35), false, 1.0) + var n := _player_count() + var cols := _grid_cols() + var rows := _grid_rows() + var view := get_viewport_rect().size + var total_w := view.x - SIDE_MARGIN * 2.0 + var total_h := view.y - PLAY_TOP - PLAY_BOTTOM_MARGIN + var cell_w := (total_w - DIVIDER_WIDTH * float(cols - 1)) / float(cols) + var cell_h := (total_h - DIVIDER_WIDTH * float(rows - 1)) / float(rows) + var line_color := Color(0.35, 0.4, 0.55, 0.55) + + for c in range(1, cols): + var x := SIDE_MARGIN + float(c) * cell_w + float(c - 1) * DIVIDER_WIDTH + DIVIDER_WIDTH * 0.5 + draw_line( + Vector2(x, PLAY_TOP), + Vector2(x, PLAY_TOP + total_h), + line_color, + DIVIDER_WIDTH + ) + for r in range(1, rows): + var y := PLAY_TOP + float(r) * cell_h + float(r - 1) * DIVIDER_WIDTH + DIVIDER_WIDTH * 0.5 + draw_line( + Vector2(SIDE_MARGIN, y), + Vector2(SIDE_MARGIN + total_w, y), + line_color, + DIVIDER_WIDTH + ) + + for i in n: + draw_rect(_player_play_rect(i), Color(0.12, 0.14, 0.22, 0.35), false, 1.0) diff --git a/scripts/sprite_atlas.gd b/scripts/sprite_atlas.gd index 86f63dd..1b2f839 100644 --- a/scripts/sprite_atlas.gd +++ b/scripts/sprite_atlas.gd @@ -25,6 +25,18 @@ static func named(name: String) -> AtlasTexture: return region(211, 941, 99, 75) "playerShip1_orange": return region(247, 84, 99, 75) + "playerShip1_green": + return region(237, 377, 99, 75) + "playerShip1_red": + return region(224, 832, 99, 75) + "playerShip2_blue": + return region(112, 791, 112, 75) + "playerShip2_orange": + return region(112, 716, 112, 75) + "playerShip2_green": + return region(112, 866, 112, 75) + "playerShip2_red": + return region(0, 941, 112, 75) "laserBlue01": return region(856, 421, 9, 54) "laserRed01":