Files
demo-game/scripts/space_shooter.gd
T
skrueckenandCursor 2138039b72 Support dynamic 2–8 player Space Shooter from selected pods.
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 <cursoragent@cursor.com>
2026-11-01 23:36:42 +01:00

1117 lines
30 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 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)