demo-game/scripts/space_shooter.gd
simon a32a223881 Add split-screen Space Shooter with pod steering and boost.
Two-player mode with client assignment, 3D vector calibration for cylindrical
pods, lives, tap/double-tap boost with low-intensity LED cooldown feedback,
and Kenney sprite assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 18:56:55 +02:00

896 lines
26 KiB
GDScript
Raw Permalink 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, CALIBRATION, PLAYING }
enum PlayerSide { LEFT, RIGHT }
const WS_URL := "ws://localhost:8081/ws"
const MENU_SCENE := "res://scenes/menu.tscn"
const STREAM_INTERVAL_MS := 16
const PLAY_TOP := 72.0
const PLAY_BOTTOM_MARGIN := 32.0
const SHIP_Y_OFFSET := 88.0
const SIDE_MARGIN := 48.0
const DIVIDER_WIDTH := 4.0
const MAX_SHIP_SPEED := 720.0
const SHIP_FRICTION := 0.88
const DEFAULT_SENSITIVITY := 12.0
const ACCEL_SMOOTH := 0.3
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
@onready var game_layer: Node2D = $GameLayer
@onready var stars_layer: Node2D = $StarsLayer
@onready var status_label: Label = $UiLayer/StatusLabel
@onready var menu_button: Button = $UiLayer/MenuButton
@onready var left_score_label: Label = $UiLayer/LeftScoreLabel
@onready var right_score_label: Label = $UiLayer/RightScoreLabel
@onready var setup_panel: Control = $UiLayer/SetupPanel
@onready var left_client_option: OptionButton = $UiLayer/SetupPanel/ClientGrid/LeftClientOption
@onready var right_client_option: OptionButton = $UiLayer/SetupPanel/ClientGrid/RightClientOption
@onready var start_game_button: Button = $UiLayer/SetupPanel/StartGameButton
@onready var setup_hint_label: Label = $UiLayer/SetupPanel/SetupHintLabel
@onready var calibration_overlay: Control = $UiLayer/CalibrationOverlay
@onready var recalib_button: Button = $UiLayer/RecalibButton
var _socket := WebSocketPeer.new()
var _state := GameState.SETUP
var _available_clients: PackedInt32Array = PackedInt32Array()
var _left_client_id := 0
var _right_client_id := 0
var _calib_side := PlayerSide.LEFT
var _stream_client_ids: PackedInt32Array = PackedInt32Array()
var _calibrations: Array[AccelCalibration] = [AccelCalibration.new(), AccelCalibration.new()]
var _thresholds: Array[float] = [3000.0, 3000.0]
var _sensitivities: Array[float] = [DEFAULT_SENSITIVITY, DEFAULT_SENSITIVITY]
var _accel_filtered: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO]
var _ship_x: Array[float] = [0.0, 0.0]
var _ship_vx: Array[float] = [0.0, 0.0]
var _scores: Array[int] = [0, 0]
var _fire_cooldown: Array[float] = [0.0, 0.0]
var _spawn_timer: Array[float] = [0.0, 0.0]
var _lives: Array[int] = [MAX_LIVES, MAX_LIVES]
var _alive: Array[bool] = [true, true]
var _boost_time_left: Array[float] = [0.0, 0.0]
var _boost_cooldown_left: Array[float] = [0.0, 0.0]
var _boost_led_active: Array[bool] = [false, false]
var _led_update_timer := 0.0
var _ships: Array[Sprite2D] = []
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)
left_client_option.item_selected.connect(_on_client_selection_changed)
right_client_option.item_selected.connect(_on_client_selection_changed)
recalib_button.pressed.connect(_restart_calibration)
calibration_overlay.calibration_finished.connect(_on_calibration_finished)
_hide_gameplay()
setup_panel.visible = true
recalib_button.visible = false
_state = GameState.SETUP
_init_visuals()
_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 [_left_client_id, _right_client_id]:
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 _init_visuals() -> void:
for side in [PlayerSide.LEFT, PlayerSide.RIGHT]:
var ship := Sprite2D.new()
ship.texture = SpriteAtlas.named("playerShip1_blue") if side == PlayerSide.LEFT else SpriteAtlas.named("playerShip1_orange")
ship.scale = Vector2(SHIP_SCALE, SHIP_SCALE)
ship.visible = false
game_layer.add_child(ship)
_ships.append(ship)
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 _physics_process(delta: float) -> void:
_socket.poll()
_poll_messages()
_update_connection_status()
match _state:
GameState.SETUP:
return
GameState.CALIBRATION:
if calibration_overlay.is_active():
calibration_overlay.feed_accel(_accel_for_side(_calib_side))
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()
_populate_client_options()
_on_client_selection_changed(-1)
func _populate_client_options() -> void:
left_client_option.clear()
right_client_option.clear()
for cid in _available_clients:
var label := "Pod #%d" % cid
left_client_option.add_item(label, cid)
right_client_option.add_item(label, cid)
if _available_clients.size() >= 2:
left_client_option.select(0)
right_client_option.select(1)
elif _available_clients.size() == 1:
left_client_option.select(0)
right_client_option.select(0)
func _on_client_selection_changed(_idx: int) -> void:
if left_client_option.item_count == 0:
_left_client_id = 0
_right_client_id = 0
start_game_button.disabled = true
setup_hint_label.text = "Keine Pods online warte auf Verbindung…"
return
_left_client_id = left_client_option.get_selected_id()
_right_client_id = right_client_option.get_selected_id()
var ok := _available_clients.size() >= 2 and _left_client_id > 0 and _right_client_id > 0 and _left_client_id != _right_client_id
start_game_button.disabled = not ok
if _available_clients.size() < 2:
setup_hint_label.text = "Mindestens 2 Pods nötig (aktuell: %d)" % _available_clients.size()
elif _left_client_id == _right_client_id:
setup_hint_label.text = "Links und rechts müssen verschiedene Pods sein."
else:
setup_hint_label.text = "Pod #%d spielt links · Pod #%d spielt rechts" % [_left_client_id, _right_client_id]
func _on_start_game_pressed() -> void:
if _left_client_id <= 0 or _right_client_id <= 0 or _left_client_id == _right_client_id:
return
setup_panel.visible = false
_start_calibration(PlayerSide.LEFT)
func _start_calibration(side: PlayerSide) -> void:
_state = GameState.CALIBRATION
_calib_side = side
_hide_gameplay()
recalib_button.visible = false
var cid := _client_id_for_side(side)
calibration_overlay.set_context_label(
"Kalibrierung %s · Pod #%d\nZylinder senkrecht halten, dann links/rechts schwanken" % [
_side_name(side), cid
]
)
calibration_overlay.start()
_set_stream_for_calibration(cid)
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
calibration_overlay.notify_connected()
func _on_calibration_finished(calibration: AccelCalibration) -> void:
_calibrations[_calib_side] = calibration
_thresholds[_calib_side] = calibration.suggested_threshold
_sensitivities[_calib_side] = DEFAULT_SENSITIVITY
if _calib_side == PlayerSide.LEFT:
_start_calibration(PlayerSide.RIGHT)
else:
_begin_playing()
func _begin_playing() -> void:
_state = GameState.PLAYING
_clear_entities()
_scores = [0, 0]
_ship_vx = [0.0, 0.0]
_fire_cooldown = [0.0, 0.0]
_spawn_timer = [0.8, 1.1]
_lives = [MAX_LIVES, MAX_LIVES]
_alive = [true, true]
_boost_time_left = [0.0, 0.0]
_boost_cooldown_left = [0.0, 0.0]
_boost_led_active = [false, false]
_led_update_timer = 0.0
for side in [PlayerSide.LEFT, PlayerSide.RIGHT]:
_ship_x[side] = _side_play_rect(side).get_center().x
_show_gameplay()
recalib_button.visible = true
_enable_both_streams()
_update_score_labels()
status_label.text = (
"Pod #%d · %s · Schwelle %.0f | Pod #%d · %s · Schwelle %.0f"
% [
_left_client_id,
_calibrations[PlayerSide.LEFT].axis_name(),
_thresholds[PlayerSide.LEFT],
_right_client_id,
_calibrations[PlayerSide.RIGHT].axis_name(),
_thresholds[PlayerSide.RIGHT],
]
)
func _restart_calibration() -> void:
_clear_entities()
_hide_gameplay()
_start_calibration(PlayerSide.LEFT)
func _show_gameplay() -> void:
for ship in _ships:
ship.visible = true
func _hide_gameplay() -> void:
for ship in _ships:
ship.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 _set_stream_for_calibration(client_id: int) -> void:
_disable_all_streams()
_stream_client_ids = PackedInt32Array([client_id])
_send({"type": "set_input_stream", "client_id": client_id, "enable": true})
_send({"type": "set_tap_notify", "client_id": client_id, "single": false, "double_tap": false, "triple": false})
_send({"type": "set_stream", "enable": true, "interval_ms": STREAM_INTERVAL_MS})
func _enable_both_streams() -> void:
_disable_all_streams()
_stream_client_ids = PackedInt32Array([_left_client_id, _right_client_id])
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 side in [PlayerSide.LEFT, PlayerSide.RIGHT]:
_update_led_for_side(side)
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 side := _side_for_client(cid)
if side < 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(side, accel)
if _state == GameState.PLAYING and _is_boost_tap(str(client.get("tap_kind", ""))):
_on_boost_tap(side)
func _side_for_client(cid: int) -> int:
if cid == _left_client_id:
return PlayerSide.LEFT
if cid == _right_client_id:
return PlayerSide.RIGHT
return -1
func _is_boost_tap(tap_kind: String) -> bool:
return tap_kind == "single" or tap_kind == "double"
func _on_boost_tap(side: PlayerSide) -> void:
if not _alive[side]:
return
if _boost_cooldown_left[side] > 0.0 or _boost_time_left[side] > 0.0:
return
_boost_time_left[side] = BOOST_DURATION_SEC
_boost_led_active[side] = false
_led_update_timer = 0.0
_update_score_labels()
func _update_boost_state(delta: float) -> void:
for side in [PlayerSide.LEFT, PlayerSide.RIGHT]:
if _boost_time_left[side] > 0.0:
_boost_time_left[side] -= delta
if _boost_time_left[side] <= 0.0:
_boost_time_left[side] = 0.0
_boost_cooldown_left[side] = BOOST_COOLDOWN_SEC
_boost_led_active[side] = false
elif _boost_cooldown_left[side] > 0.0:
_boost_cooldown_left[side] = maxf(_boost_cooldown_left[side] - delta, 0.0)
func _is_boost_active(side: PlayerSide) -> bool:
return _alive[side] and _boost_time_left[side] > 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 side in [PlayerSide.LEFT, PlayerSide.RIGHT]:
_update_led_for_side(side)
func _update_led_for_side(side: PlayerSide) -> void:
var cid := _client_id_for_side(side)
if cid <= 0:
return
if not _alive[side]:
_send_led_color(cid, Color(0.35, 0.05, 0.05), LED_INTENSITY)
return
if _boost_time_left[side] > 0.0:
if not _boost_led_active[side]:
_boost_led_active[side] = true
_send_led_boost_blink(cid, _boost_color(side))
return
if _boost_cooldown_left[side] > 0.0:
var progress := int((1.0 - _boost_cooldown_left[side] / BOOST_COOLDOWN_SEC) * 100.0)
_send_led_progress(cid, progress, _side_led_color(side))
return
_send_led_progress(cid, 100, _side_led_color(side))
func _side_led_color(side: PlayerSide) -> Color:
return Color(0.2, 0.75, 1.0) if side == PlayerSide.LEFT else Color(1.0, 0.55, 0.15)
func _boost_color(side: PlayerSide) -> Color:
return Color(0.3, 1.0, 0.45) if side == PlayerSide.LEFT else Color(1.0, 0.35, 0.2)
func _set_filtered_accel(side: PlayerSide, raw: Vector3i) -> void:
var target := Vector3(raw)
_accel_filtered[side] = _accel_filtered[side].lerp(target, ACCEL_SMOOTH)
func _accel_for_side(side: PlayerSide) -> Vector3i:
var v := _accel_filtered[side]
return Vector3i(int(v.x), int(v.y), int(v.z))
func _update_ships(delta: float) -> void:
for side in [PlayerSide.LEFT, PlayerSide.RIGHT]:
if not _alive[side]:
continue
var eff := _effective_accel(side)
if eff == 0.0:
_ship_vx[side] *= SHIP_FRICTION
else:
var target_v := eff * _velocity_scale(side)
_ship_vx[side] = move_toward(
_ship_vx[side], target_v, maxf(absf(target_v) * 0.35, 100.0) * delta
)
_ship_vx[side] = clampf(_ship_vx[side], -MAX_SHIP_SPEED, MAX_SHIP_SPEED)
var rect := _side_play_rect(side)
var next_x := _ship_x[side] + _ship_vx[side] * delta
var half_w := 42.0
if next_x < rect.position.x + half_w:
next_x = rect.position.x + half_w
_ship_vx[side] = 0.0
if next_x > rect.end.x - half_w:
next_x = rect.end.x - half_w
_ship_vx[side] = 0.0
_ship_x[side] = next_x
_fire_cooldown[side] = maxf(_fire_cooldown[side] - delta, 0.0)
if _fire_cooldown[side] <= 0.0:
_fire_cooldown[side] = FIRE_INTERVAL
_spawn_bullet(side)
func _spawn_bullet(side: int) -> void:
var boosted := _is_boost_active(side)
var laser := Sprite2D.new()
if boosted:
laser.texture = SpriteAtlas.named("laserGreen01") if side == PlayerSide.LEFT else SpriteAtlas.named("laserRed01")
laser.modulate = Color(1.2, 1.2, 1.2)
else:
laser.texture = SpriteAtlas.named("laserBlue01")
laser.scale = Vector2(LASER_SCALE, LASER_SCALE)
laser.centered = true
game_layer.add_child(laser)
var ship_y := _ship_y(side)
var damage := BULLET_DAMAGE * (BOOST_DAMAGE_MULT if boosted else 1.0)
_bullets.append({
"node": laser,
"side": side,
"x": _ship_x[side],
"y": ship_y - 36.0,
"damage": damage,
})
func _try_spawn_asteroids(delta: float) -> void:
for side in [PlayerSide.LEFT, PlayerSide.RIGHT]:
if not _alive[side]:
continue
_spawn_timer[side] -= delta
if _spawn_timer[side] > 0.0:
continue
_spawn_timer[side] = ASTEROID_SPAWN_INTERVAL + randf_range(-0.4, 0.6)
_spawn_asteroid(side)
func _spawn_asteroid(side: int) -> void:
var sprite := Sprite2D.new()
sprite.texture = SpriteAtlas.named("meteorBrown_big1")
sprite.scale = Vector2(ASTEROID_SCALE, ASTEROID_SCALE)
sprite.centered = true
game_layer.add_child(sprite)
var rect := _side_play_rect(side)
var half_w := 52.0
var x := randf_range(rect.position.x + half_w, rect.end.x - half_w)
_asteroids.append({
"node": sprite,
"side": side,
"x": x,
"y": PLAY_TOP - 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
if entry["y"] < PLAY_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 side: int = entry["side"]
if entry["scored"]:
continue
if entry["y"] > _ship_y(side) + 70.0:
if not _asteroid_hits_ship(entry):
_award_points(side, 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(side: PlayerSide) -> void:
if not _alive[side]:
return
_lives[side] -= 1
_ships[side].modulate = Color(1.5, 0.45, 0.45)
get_tree().create_timer(0.15).timeout.connect(
func() -> void:
if is_inside_tree() and _alive[side]:
_ships[side].modulate = Color.WHITE,
CONNECT_ONE_SHOT
)
if _lives[side] <= 0:
_alive[side] = false
_ships[side].visible = false
_boost_time_left[side] = 0.0
_boost_led_active[side] = false
_clear_side_asteroids(side)
_update_led_for_side(side)
_update_score_labels()
func _clear_side_asteroids(side: int) -> void:
for entry in _asteroids.duplicate():
if entry["side"] != side:
continue
entry["node"].queue_free()
_asteroids.erase(entry)
func _asteroid_hits_ship(asteroid: Dictionary) -> bool:
var side: int = asteroid["side"]
if not _alive[side]:
return false
return _hit_test(
{"x": _ship_x[side], "y": _ship_y(side)},
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(side: int, amount: int) -> void:
_scores[side] += amount
_update_score_labels()
func _update_score_labels() -> void:
left_score_label.text = _player_hud_text(PlayerSide.LEFT)
right_score_label.text = _player_hud_text(PlayerSide.RIGHT)
func _player_hud_text(side: PlayerSide) -> String:
var side_label := "Links" if side == PlayerSide.LEFT else "Rechts"
var cid := _client_id_for_side(side)
if not _alive[side]:
return "%s · Pod #%d\n%d Pkt · TOT" % [side_label, cid, _scores[side]]
var boost_text := "Boost bereit"
if _boost_time_left[side] > 0.0:
boost_text = "BOOST %.0fs" % _boost_time_left[side]
elif _boost_cooldown_left[side] > 0.0:
boost_text = "Boost %.0fs" % _boost_cooldown_left[side]
return "%s · Pod #%d\n%d Pkt · ♥ %d/%d · %s" % [
side_label, cid, _scores[side], _lives[side], MAX_LIVES, boost_text
]
func _sync_sprite_positions() -> void:
for side in [PlayerSide.LEFT, PlayerSide.RIGHT]:
if _alive[side]:
_ships[side].position = Vector2(_ship_x[side], _ship_y(side))
for 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(side: int) -> float:
var cal: AccelCalibration = _calibrations[side]
var raw := cal.project(_accel_for_side(side))
var centered := raw - cal.center
var threshold := _thresholds[side]
var abs_v := absf(centered)
if abs_v <= threshold:
return 0.0
return signf(centered) * (abs_v - threshold)
func _velocity_scale(side: int) -> float:
return _sensitivities[side] / 100.0
func _ship_y(_side: int) -> float:
return get_viewport_rect().size.y - PLAY_BOTTOM_MARGIN - SHIP_Y_OFFSET
func _side_play_rect(side: PlayerSide) -> Rect2:
var view := get_viewport_rect().size
var half_w := view.x * 0.5
if side == PlayerSide.LEFT:
return Rect2(SIDE_MARGIN, PLAY_TOP, half_w - SIDE_MARGIN - DIVIDER_WIDTH * 0.5, view.y - PLAY_TOP - PLAY_BOTTOM_MARGIN)
return Rect2(half_w + DIVIDER_WIDTH * 0.5, PLAY_TOP, half_w - SIDE_MARGIN - DIVIDER_WIDTH * 0.5, view.y - PLAY_TOP - PLAY_BOTTOM_MARGIN)
func _client_id_for_side(side: PlayerSide) -> int:
return _left_client_id if side == PlayerSide.LEFT else _right_client_id
func _side_name(side: PlayerSide) -> String:
return "links" if side == PlayerSide.LEFT else "rechts"
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.CALIBRATION:
match _socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
status_label.text = "Kalibrierung %s · Pod #%d" % [
_side_name(_calib_side), _client_id_for_side(_calib_side)
]
calibration_overlay.notify_connected()
WebSocketPeer.STATE_CONNECTING:
status_label.text = "Kalibrierung verbinde…"
_:
status_label.text = "Kalibrierung keine Verbindung"
GameState.PLAYING:
match _socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
status_label.text = (
"Tap/Doppel-Tap = Boost (%ds) · x2 Schaden · ♥×%d · Cooldown %ds"
% [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:
var view := get_viewport_rect().size
var mid_x := view.x * 0.5
draw_line(Vector2(mid_x, PLAY_TOP), Vector2(mid_x, view.y), Color(0.35, 0.4, 0.55, 0.55), DIVIDER_WIDTH)
var left_rect := _side_play_rect(PlayerSide.LEFT)
var right_rect := _side_play_rect(PlayerSide.RIGHT)
draw_rect(left_rect, Color(0.12, 0.14, 0.22, 0.35), false, 1.0)
draw_rect(right_rect, Color(0.12, 0.14, 0.22, 0.35), false, 1.0)