extends Node2D enum GameState { SETUP, PLAYING } 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 := 24.0 const DIVIDER_WIDTH := 4.0 const HUD_INSET := 12.0 const MAX_SHIP_SPEED := 720.0 const SHIP_FRICTION := 0.88 const DEFAULT_SENSITIVITY := 12.0 const ACCEL_SMOOTH := 0.3 ## Maps steer-roll radians onto the old accel scale for ship velocity. const STEER_GAIN := 10000.0 const BULLET_SPEED := 900.0 const FIRE_INTERVAL := 0.14 const ASTEROID_SCROLL := 220.0 const ASTEROID_SPAWN_INTERVAL := 2.2 const ASTEROID_HEALTH := 1.0 const BULLET_DAMAGE := 0.18 const SCORE_DESTROY := 100 const SCORE_DODGE := 40 const MAX_LIVES := 10 const BOOST_COOLDOWN_SEC := 30.0 const BOOST_DURATION_SEC := 6.0 const BOOST_DAMAGE_MULT := 2.0 const LED_INTENSITY := 2 const LED_UPDATE_INTERVAL := 0.2 const LED_BLINK_MS := 200 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 setup_panel: Control = $UiLayer/SetupPanel @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 _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] = [] 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 = [] func _ready() -> void: menu_button.pressed.connect(_return_to_menu) start_game_button.pressed.connect(_on_start_game_pressed) 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" _rebuild_setup_slots() _init_stars() _connect_ws() func _exit_tree() -> void: _cleanup_hardware() 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 _player_client_ids: if cid <= 0: continue _send({"type": "set_input_stream", "client_id": cid, "enable": false}) _send({ "type": "set_tap_notify", "client_id": cid, "single": false, "double_tap": false, "triple": false, }) _send_led_clear(cid) _stream_client_ids = PackedInt32Array() func _return_to_menu() -> void: _cleanup_hardware() get_tree().change_scene_to_file(MENU_SCENE) func _connect_ws() -> void: var err := _socket.connect_to_url(WS_URL) if err != OK: status_label.text = "Verbindung fehlgeschlagen: %s" % error_string(err) else: status_label.text = "Verbinde mit %s…" % WS_URL 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") star.modulate = Color(1, 1, 1, randf_range(0.25, 0.85)) star.scale = Vector2(randf_range(0.35, 0.9), randf_range(0.35, 0.9)) var view := get_viewport_rect().size star.position = Vector2(randf_range(0, view.x), randf_range(PLAY_TOP, view.y)) star.set_meta("speed", randf_range(80.0, 220.0)) stars_layer.add_child(star) _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() _update_connection_status() match _state: GameState.SETUP: return GameState.PLAYING: _update_boost_state(delta) _update_stars(delta) _update_ships(delta) _update_bullets(delta) _update_asteroids(delta) _try_spawn_asteroids(delta) _check_collisions() _sync_sprite_positions() _update_leds_throttled(delta) func _poll_messages() -> void: if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: if _socket.get_ready_state() == WebSocketPeer.STATE_CLOSED: _socket.connect_to_url(WS_URL) return while _socket.get_available_packet_count() > 0: _handle_message(_socket.get_packet().get_string_from_utf8()) func _handle_message(text: String) -> void: var data = JSON.parse_string(text) if typeof(data) != TYPE_DICTIONARY: return match data.get("type", ""): "hello": _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): setup_hint_label.text = "Client-Liste fehlgeschlagen: %s" % data.get("error", "?") return _available_clients = 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: _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: for option in _slot_options: option.clear() for cid in _available_clients: option.add_item("Pod #%d" % cid, cid) 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: _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 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 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: 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() func _begin_playing() -> void: _state = GameState.PLAYING _clear_entities() _resize_player_state(_player_count()) _create_player_visuals() _led_update_timer = 0.0 for i in _player_count(): _ship_x[i] = _player_play_rect(i).get_center().x _show_gameplay() _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) ) func _show_gameplay() -> void: 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() func _clear_entities() -> void: for entry in _asteroids: entry["node"].queue_free() for entry in _bullets: entry["node"].queue_free() _asteroids.clear() _bullets.clear() func _enable_player_streams() -> void: _disable_all_streams() _stream_client_ids = _player_client_ids.duplicate() for cid in _stream_client_ids: _send({"type": "set_input_stream", "client_id": cid, "enable": true}) _send({ "type": "set_tap_notify", "client_id": cid, "single": true, "double_tap": true, "triple": false, }) _send({"type": "set_stream", "enable": true, "interval_ms": STREAM_INTERVAL_MS}) for i in _player_count(): _update_led_for_player(i) func _disable_all_streams() -> void: if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: _stream_client_ids = PackedInt32Array() return _send({"type": "set_stream", "enable": false, "interval_ms": STREAM_INTERVAL_MS}) for cid in _stream_client_ids: _send({"type": "set_input_stream", "client_id": cid, "enable": false}) _send({ "type": "set_tap_notify", "client_id": cid, "single": false, "double_tap": false, "triple": false, }) _stream_client_ids = PackedInt32Array() func _on_input(data: Dictionary) -> void: if not data.get("success", false): return for client in data.get("clients", []): if typeof(client) != TYPE_DICTIONARY: continue var cid := int(client.get("client_id", 0)) var player := _player_for_client(cid) if player < 0: continue if client.get("valid", false): var accel := Vector3i( _to_int(client.get("x")), _to_int(client.get("y")), _to_int(client.get("z")) ) _set_filtered_accel(player, accel) if _state == GameState.PLAYING and _is_boost_tap(str(client.get("tap_kind", ""))): _on_boost_tap(player) 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(player: int) -> void: if not _alive[player]: return if _boost_cooldown_left[player] > 0.0 or _boost_time_left[player] > 0.0: return _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 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(player: int) -> bool: return _alive[player] and _boost_time_left[player] > 0.0 func _update_leds_throttled(delta: float) -> void: _led_update_timer -= delta if _led_update_timer > 0.0: return _led_update_timer = LED_UPDATE_INTERVAL for i in _player_count(): _update_led_for_player(i) func _update_led_for_player(player: int) -> void: var cid := _client_id_for_player(player) if cid <= 0: return if not _alive[player]: _send_led_color(cid, Color(0.35, 0.05, 0.05), LED_INTENSITY) return 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[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, PLAYER_LED_COLORS[player]) func _set_filtered_accel(player: int, raw: Vector3i) -> void: var target := Vector3(raw) _accel_filtered[player] = _accel_filtered[player].lerp(target, ACCEL_SMOOTH) 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 i in _player_count(): if not _alive[i]: continue var eff := _effective_accel(i) if eff == 0.0: _ship_vx[i] *= SHIP_FRICTION else: 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[i] = clampf(_ship_vx[i], -MAX_SHIP_SPEED, MAX_SHIP_SPEED) 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[i] = 0.0 if next_x > rect.end.x - half_w: next_x = rect.end.x - half_w _ship_vx[i] = 0.0 _ship_x[i] = next_x _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(player: int) -> void: var boosted := _is_boost_active(player) var laser := Sprite2D.new() if boosted: laser.texture = SpriteAtlas.named(PLAYER_BOOST_LASERS[player]) laser.modulate = Color(1.2, 1.2, 1.2) else: 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(player) var damage := BULLET_DAMAGE * (BOOST_DAMAGE_MULT if boosted else 1.0) _bullets.append({ "node": laser, "side": player, "x": _ship_x[player], "y": ship_y - 36.0, "damage": damage, }) func _try_spawn_asteroids(delta: float) -> void: for i in _player_count(): if not _alive[i]: continue _spawn_timer[i] -= delta if _spawn_timer[i] > 0.0: continue _spawn_timer[i] = ASTEROID_SPAWN_INTERVAL + randf_range(-0.4, 0.6) _spawn_asteroid(i) 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 := _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": player, "x": x, "y": rect.position.y - 40.0, "health": ASTEROID_HEALTH, "scored": false, }) func _update_bullets(delta: float) -> void: var remove: Array = [] for entry in _bullets: entry["y"] -= BULLET_SPEED * delta 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() _bullets.erase(entry) func _update_asteroids(delta: float) -> void: var remove: Array = [] for entry in _asteroids: entry["y"] += ASTEROID_SCROLL * delta var player: int = entry["side"] if entry["scored"]: continue if entry["y"] > _ship_y(player) + 70.0: if not _asteroid_hits_ship(entry): _award_points(player, SCORE_DODGE) entry["scored"] = true remove.append(entry) for entry in remove: entry["node"].queue_free() _asteroids.erase(entry) func _check_collisions() -> void: var bullet_remove: Array = [] var asteroid_remove: Array = [] for bullet in _bullets: for asteroid in _asteroids: if bullet["side"] != asteroid["side"]: continue if bullet in bullet_remove or asteroid in asteroid_remove: continue if _hit_test(bullet, asteroid, 18.0, 42.0): var damage: float = bullet.get("damage", BULLET_DAMAGE) asteroid["health"] -= damage bullet_remove.append(bullet) if asteroid["health"] <= 0.0 and not asteroid["scored"]: _award_points(asteroid["side"], SCORE_DESTROY) asteroid["scored"] = true asteroid_remove.append(asteroid) break for bullet in bullet_remove: bullet["node"].queue_free() _bullets.erase(bullet) for asteroid in asteroid_remove: asteroid["node"].queue_free() _asteroids.erase(asteroid) for asteroid in _asteroids.duplicate(): if asteroid["scored"]: continue if _asteroid_hits_ship(asteroid): asteroid["scored"] = true asteroid["node"].queue_free() _asteroids.erase(asteroid) _damage_ship(int(asteroid["side"])) func _damage_ship(player: int) -> void: if not _alive[player]: return _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 player < _alive.size() and _alive[player]: _ships[player].modulate = Color.WHITE, CONNECT_ONE_SHOT ) 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_player_asteroids(player: int) -> void: for entry in _asteroids.duplicate(): if entry["side"] != player: continue entry["node"].queue_free() _asteroids.erase(entry) func _asteroid_hits_ship(asteroid: Dictionary) -> bool: var player: int = asteroid["side"] if not _alive[player]: return false return _hit_test( {"x": _ship_x[player], "y": _ship_y(player)}, asteroid, 38.0, 42.0 ) func _hit_test(a: Dictionary, b: Dictionary, radius_a: float, radius_b: float) -> bool: var dx := float(a["x"]) - float(b["x"]) var dy := float(a["y"]) - float(b["y"]) var r := radius_a + radius_b return dx * dx + dy * dy <= r * r func _award_points(player: int, amount: int) -> void: _scores[player] += amount _update_score_labels() func _update_score_labels() -> void: for i in _score_labels.size(): _layout_score_label(i) _score_labels[i].text = _player_hud_text(i) 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[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[player], _lives[player], MAX_LIVES, boost_text ] func _sync_sprite_positions() -> void: 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: entry["node"].position = Vector2(entry["x"], entry["y"]) func _update_stars(delta: float) -> void: var view_h := get_viewport_rect().size.y for star in _stars: var speed: float = star.get_meta("speed") star.position.y += speed * delta if star.position.y > view_h + 20.0: star.position.y = PLAY_TOP - 20.0 star.position.x = randf_range(0.0, get_viewport_rect().size.x) func _effective_accel(player: int) -> float: # Lateral +X → Schiff nach rechts (flach kippen und frontal Lenkrad). var a := _accel_for_player(player) return OrientationGizmo.steer_roll(Vector3(a)) * STEER_GAIN func _velocity_scale(player: int) -> float: return _sensitivities[player] / 100.0 func _ship_y(player: int) -> float: var rect := _player_play_rect(player) return rect.end.y - SHIP_Y_OFFSET func _player_play_rect(player: int) -> Rect2: var view := get_viewport_rect().size 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) 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: match _state: GameState.SETUP: match _socket.get_ready_state(): WebSocketPeer.STATE_OPEN: status_label.text = "Verbunden · Spieler wählen" WebSocketPeer.STATE_CONNECTING: status_label.text = "Verbinde…" _: status_label.text = "Getrennt – erneuter Verbindungsversuch…" GameState.PLAYING: match _socket.get_ready_state(): WebSocketPeer.STATE_OPEN: status_label.text = ( "%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…" _: status_label.text = "Getrennt – erneuter Verbindungsversuch…" if _socket.get_ready_state() == WebSocketPeer.STATE_CLOSED: _connect_ws() func _send(payload: Dictionary) -> void: if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN: return _socket.send_text(JSON.stringify(payload)) func _send_led_clear(cid: int) -> void: _send({"type": "set_led_ring", "client_id": cid, "mode": "clear"}) func _send_led_color(cid: int, color: Color, intensity: int) -> 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": intensity, }) func _send_led_progress(cid: int, progress: int, color: Color) -> void: _send({ "type": "set_led_ring", "client_id": cid, "mode": "progress", "progress": clampi(progress, 0, 100), "r": int(color.r * 255), "g": int(color.g * 255), "b": int(color.b * 255), "intensity": LED_INTENSITY, }) func _send_led_boost_blink(cid: int, color: Color) -> void: var cycle_sec := LED_BLINK_MS * 2.0 / 1000.0 var count := maxi(int(ceil(BOOST_DURATION_SEC / cycle_sec)), 1) _send({ "type": "set_led_ring", "client_id": cid, "mode": "blink", "blink_ms": LED_BLINK_MS, "blink_count": count, "r": int(color.r * 255), "g": int(color.g * 255), "b": int(color.b * 255), "intensity": LED_INTENSITY, }) func _to_int(value: Variant) -> int: match typeof(value): TYPE_INT: return value TYPE_FLOAT: return int(value) TYPE_STRING: return int(float(value)) return 0 func _draw() -> void: if _state != GameState.PLAYING or _player_count() == 0: return 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)