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