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>
This commit is contained in:
2026-06-06 18:56:55 +02:00
co-authored by Cursor
parent 70e66f4d60
commit a32a223881
635 changed files with 15367 additions and 52 deletions
+82 -33
View File
@@ -3,18 +3,33 @@ extends RefCounted
enum Axis { X, Y, Z }
const MIN_SAMPLES := 25
const TAIL_FRACTION := 0.55
var axis: Axis = Axis.X
var sign: float = 1.0
var center: float = 0.0
var suggested_threshold: float = 3000.0
var direction: Vector3 = Vector3.RIGHT
var neutral: Vector3 = Vector3.ZERO
var uses_vector: bool = false
var _samples_neutral: Array[Vector3] = []
var _samples_right: Array[Vector3] = []
var _samples_left: Array[Vector3] = []
func reset() -> void:
_samples_neutral.clear()
_samples_right.clear()
_samples_left.clear()
uses_vector = false
direction = Vector3.RIGHT
neutral = Vector3.ZERO
func record_neutral(accel: Vector3i) -> void:
_samples_neutral.append(Vector3(accel))
func record_right(accel: Vector3i) -> void:
@@ -26,56 +41,74 @@ func record_left(accel: Vector3i) -> void:
func is_ready() -> bool:
return _samples_right.size() >= 20 and _samples_left.size() >= 20
return (
_samples_neutral.size() >= MIN_SAMPLES
and _samples_right.size() >= MIN_SAMPLES
and _samples_left.size() >= MIN_SAMPLES
)
func analyze() -> bool:
if not is_ready():
return false
var mean_r := _mean(_samples_right)
var mean_l := _mean(_samples_left)
var diff := mean_r - mean_l
var mean_n := _tail_mean(_samples_neutral)
var mean_r := _tail_mean(_samples_right)
var mean_l := _tail_mean(_samples_left)
var delta := mean_r - mean_l
var diffs := [absf(diff.x), absf(diff.y), absf(diff.z)]
axis = Axis.X
var best: float = diffs[0]
if diffs[1] > best:
best = diffs[1]
axis = Axis.Y
if diffs[2] > best:
best = diffs[2]
axis = Axis.Z
if delta.length_squared() < 150.0 * 150.0:
return false
var delta := _component(diff, axis)
sign = 1.0 if delta >= 0.0 else -1.0
direction = delta.normalized()
neutral = mean_n
uses_vector = true
sign = 1.0
_set_axis_from_direction()
var all_samples: Array[Vector3] = []
all_samples.append_array(_samples_right)
all_samples.append_array(_samples_left)
var projections: Array[float] = []
for sample in all_samples:
projections.append(_component(sample, axis) * sign)
projections.sort()
center = projections[projections.size() / 2]
var neutral_proj: Array[float] = []
for sample in _samples_neutral:
neutral_proj.append(_project_vec(sample))
neutral_proj.sort()
center = neutral_proj[neutral_proj.size() / 2]
var deviations: Array[float] = []
for value in projections:
for value in neutral_proj:
deviations.append(absf(value - center))
deviations.sort()
var median_idx := deviations.size() / 2
var spread := deviations[median_idx] if not deviations.is_empty() else 500.0
suggested_threshold = clampf(spread * 1.35, 200.0, 12000.0)
var spread := deviations[deviations.size() / 2] if not deviations.is_empty() else 500.0
suggested_threshold = clampf(spread * 2.2 + 180.0, 250.0, 8000.0)
var right_proj := _project_vec(mean_r) - center
var left_proj := _project_vec(mean_l) - center
var separation := absf(right_proj - left_proj)
if separation < suggested_threshold * 2.0:
suggested_threshold = clampf(separation * 0.18, 250.0, 8000.0)
return true
func project(accel: Vector3i) -> float:
if uses_vector:
return _project_vec(Vector3(accel))
return _component(Vector3(accel), axis) * sign
func duplicate() -> AccelCalibration:
var copy := AccelCalibration.new()
copy.axis = axis
copy.sign = sign
copy.center = center
copy.suggested_threshold = suggested_threshold
copy.direction = direction
copy.neutral = neutral
copy.uses_vector = uses_vector
return copy
func axis_name() -> String:
if uses_vector:
return "3D-Schwank (%+.1f,%+.1f,%+.1f)" % [direction.x, direction.y, direction.z]
match axis:
Axis.X:
return "x"
@@ -86,6 +119,26 @@ func axis_name() -> String:
return "?"
func _project_vec(v: Vector3) -> float:
return (v - neutral).dot(direction)
func _tail_mean(samples: Array[Vector3]) -> Vector3:
var start_idx := int(samples.size() * TAIL_FRACTION)
return _mean(samples.slice(start_idx))
func _set_axis_from_direction() -> void:
var abs_dir := direction.abs()
axis = Axis.X
var best := abs_dir.x
if abs_dir.y > best:
best = abs_dir.y
axis = Axis.Y
if abs_dir.z > best:
axis = Axis.Z
func _mean(samples: Array[Vector3]) -> Vector3:
if samples.is_empty():
return Vector3.ZERO
@@ -95,10 +148,6 @@ func _mean(samples: Array[Vector3]) -> Vector3:
return sum / float(samples.size())
func _mean_scalar(samples: Array[Vector3], ax: Axis) -> float:
return _component(_mean(samples), ax)
func _component(v: Vector3, ax: Axis) -> float:
match ax:
Axis.X:
+94 -17
View File
@@ -2,10 +2,12 @@ extends Control
signal calibration_finished(calibration: AccelCalibration)
enum Phase { WAITING, RIGHT, LEFT, DONE }
enum Phase { WAITING, NEUTRAL, RIGHT, LEFT, DONE }
const TRAVEL_PX := 140.0
const PHASE_DURATION := 4.5
const NEUTRAL_DURATION := 3.5
const SWAY_DURATION := 5.0
const SAMPLE_START_FRACTION := 0.5
const CIRCLE_RADIUS := 44.0
const NEEDLE_LEN := 58.0
@@ -15,6 +17,8 @@ var _guide_offset := Vector2.ZERO
var _instruction := ""
var _calibration := AccelCalibration.new()
var _connection_notified := false
var _context_label := ""
var _last_accel := Vector3i.ZERO
@onready var _hint_label: Label = $HintLabel
@onready var _start_button: Button = $StartButton
@@ -27,12 +31,19 @@ func _ready() -> void:
_show_waiting()
func set_context_label(text: String) -> void:
_context_label = text
if _phase == Phase.WAITING:
_show_waiting()
func start() -> void:
_calibration.reset()
_connection_notified = false
_phase = Phase.WAITING
_phase_time = 0.0
_guide_offset = Vector2.ZERO
_last_accel = Vector3i.ZERO
visible = true
_start_button.visible = true
_show_waiting()
@@ -44,27 +55,37 @@ func is_active() -> bool:
func feed_accel(accel: Vector3i) -> void:
_last_accel = accel
if not _should_record():
return
match _phase:
Phase.NEUTRAL:
_calibration.record_neutral(accel)
Phase.RIGHT:
_calibration.record_right(accel)
Phase.LEFT:
_calibration.record_left(accel)
func get_live_projection() -> float:
if _calibration.uses_vector:
return _calibration.project(_last_accel) - _calibration.center
return 0.0
func notify_connected() -> void:
if _phase != Phase.WAITING or _connection_notified:
return
_hint_label.text = "Verbunden tippe „Kalibrierung starten“ oder warte…"
# Automatisch starten sobald verbunden
begin_calibration()
func begin_calibration() -> void:
if _phase == Phase.RIGHT or _phase == Phase.LEFT:
if _phase == Phase.NEUTRAL or _phase == Phase.RIGHT or _phase == Phase.LEFT:
return
_connection_notified = true
_start_button.visible = false
_begin_phase(Phase.RIGHT)
_begin_phase(Phase.NEUTRAL)
func _on_start_pressed() -> void:
@@ -76,34 +97,73 @@ func _process(delta: float) -> void:
return
_phase_time += delta
var t := clampf(_phase_time / PHASE_DURATION, 0.0, 1.0)
var duration := _phase_duration()
var t := clampf(_phase_time / duration, 0.0, 1.0)
var ease := t * t * (3.0 - 2.0 * t)
match _phase:
Phase.NEUTRAL:
_guide_offset = Vector2.ZERO
_instruction = _phase_instruction(
"Halte den Zylinder senkrecht und ruhig\n(mittlere Position nicht schwanken)"
)
if _phase_time >= duration:
_begin_phase(Phase.RIGHT)
Phase.RIGHT:
_guide_offset.x = TRAVEL_PX * ease
_instruction = "Neige das Gerät nach rechts folge dem Kreis →"
if _phase_time >= PHASE_DURATION:
_instruction = _phase_instruction(
"Schwank den Pod langsam nach rechts ↷\nWie ein Pendel Spitze kippt nach rechts"
)
if _phase_time >= duration:
_begin_phase(Phase.LEFT)
Phase.LEFT:
_guide_offset.x = -TRAVEL_PX * ease
_instruction = "Neige das Gerät nach links folge dem Kreis ←"
if _phase_time >= PHASE_DURATION:
_instruction = _phase_instruction(
"Schwank den Pod langsam nach links ↶\nWie ein Pendel Spitze kippt nach links"
)
if _phase_time >= duration:
_finish()
_hint_label.text = _instruction
var record_hint := ""
if _phase != Phase.NEUTRAL and _phase_time < duration * SAMPLE_START_FRACTION:
record_hint = "\n\n… halte gleich die Endposition"
elif _phase != Phase.WAITING and _phase != Phase.DONE:
record_hint = "\n\n✓ Aufnahme läuft"
_hint_label.text = _instruction + record_hint
queue_redraw()
func _phase_duration() -> float:
match _phase:
Phase.NEUTRAL:
return NEUTRAL_DURATION
return SWAY_DURATION
func _should_record() -> bool:
if _phase == Phase.WAITING or _phase == Phase.DONE:
return false
return _phase_time >= _phase_duration() * SAMPLE_START_FRACTION
func _begin_phase(phase: Phase) -> void:
_phase = phase
_phase_time = 0.0
_guide_offset = Vector2.ZERO
match phase:
Phase.NEUTRAL:
_instruction = _phase_instruction(
"Halte den Zylinder senkrecht und ruhig\n(mittlere Position nicht schwanken)"
)
Phase.RIGHT:
_instruction = "Neige das Gerät nach rechts folge dem Kreis →"
_instruction = _phase_instruction(
"Schwank den Pod langsam nach rechts ↷\nWie ein Pendel Spitze kippt nach rechts"
)
Phase.LEFT:
_instruction = "Neige das Gerät nach links folge dem Kreis ←"
_instruction = _phase_instruction(
"Schwank den Pod langsam nach links ↶\nWie ein Pendel Spitze kippt nach links"
)
_hint_label.text = _instruction
queue_redraw()
@@ -112,17 +172,27 @@ func _finish() -> void:
_phase = Phase.DONE
visible = false
if _calibration.analyze():
calibration_finished.emit(_calibration)
calibration_finished.emit(_calibration.duplicate())
else:
_hint_label.text = "Kalibrierung fehlgeschlagen zu wenig Daten"
_hint_label.text = (
"Kalibrierung fehlgeschlagen.\n"
+ "Schwank weiter links/rechts oder halte die Neutralposition länger still."
)
visible = true
_start_button.visible = true
_show_waiting()
func _phase_instruction(base: String) -> String:
if _context_label == "":
return base
return "%s\n\n%s" % [_context_label, base]
func _show_waiting() -> void:
_instruction = "Warte auf Verbindung zu localhost:8081…"
_hint_label.text = _instruction + "\n(Oder „Kalibrierung starten“ ohne Verbindung)"
var prefix := "%s\n\n" % _context_label if _context_label != "" else ""
_hint_label.text = prefix + _instruction + "\n(Oder „Kalibrierung starten“ ohne Verbindung)"
func _screen_center() -> Vector2:
@@ -134,6 +204,14 @@ func _draw() -> void:
return
var pos := _screen_center() + _guide_offset
var center := _screen_center()
if _phase == Phase.NEUTRAL:
var pulse := 0.85 + 0.15 * sin(_phase_time * 4.0)
draw_circle(center, CIRCLE_RADIUS * pulse, Color(0.35, 0.85, 0.55, 0.18))
draw_arc(center, CIRCLE_RADIUS * pulse, 0.0, TAU, 64, Color(0.45, 0.95, 0.65), 2.5)
draw_line(center + Vector2(-18, 0), center + Vector2(18, 0), Color(1, 1, 1, 0.35), 2.0)
return
draw_circle(pos, CIRCLE_RADIUS, Color(0.25, 0.72, 0.95, 0.2))
draw_arc(pos, CIRCLE_RADIUS, 0.0, TAU, 64, Color(0.35, 0.82, 1.0), 2.5)
@@ -142,7 +220,6 @@ func _draw() -> void:
draw_line(pos, needle_tip, Color(1.0, 1.0, 1.0, 0.95), 3.0)
draw_circle(needle_tip, 5.0, Color(1.0, 1.0, 1.0))
var center := _screen_center()
if _phase == Phase.RIGHT:
draw_line(center, center + Vector2(TRAVEL_PX, 0.0), Color(1.0, 1.0, 1.0, 0.12), 1.0)
elif _phase == Phase.LEFT:
+3
View File
@@ -4,11 +4,13 @@ const DEMO_SCENE := "res://scenes/demo.tscn"
const CONFIG_SCENE := "res://scenes/config.tscn"
const PONG_SCENE := "res://scenes/pong.tscn"
const COLOR_GAME_SCENE := "res://scenes/color_game.tscn"
const SPACE_SHOOTER_SCENE := "res://scenes/space_shooter.tscn"
@onready var demo_button: Button = $CenterBox/DemoButton
@onready var config_button: Button = $CenterBox/ConfigButton
@onready var pong_button: Button = $CenterBox/PongButton
@onready var color_game_button: Button = $CenterBox/ColorGameButton
@onready var space_shooter_button: Button = $CenterBox/SpaceShooterButton
@onready var exit_button: Button = $CenterBox/ExitButton
@@ -17,6 +19,7 @@ func _ready() -> void:
config_button.pressed.connect(func(): _open_scene(CONFIG_SCENE))
pong_button.pressed.connect(func(): _open_scene(PONG_SCENE))
color_game_button.pressed.connect(func(): _open_scene(COLOR_GAME_SCENE))
space_shooter_button.pressed.connect(func(): _open_scene(SPACE_SHOOTER_SCENE))
exit_button.pressed.connect(_exit_game)
+895
View File
@@ -0,0 +1,895 @@
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)
+1
View File
@@ -0,0 +1 @@
uid://csydp7koiklsd
+40
View File
@@ -0,0 +1,40 @@
class_name SpriteAtlas
extends RefCounted
static var _sheet: Texture2D
static func sheet() -> Texture2D:
if _sheet == null:
_sheet = load("res://assets/Spritesheet/sheet.png")
return _sheet
static func region(x: int, y: int, w: int, h: int) -> AtlasTexture:
var tex := AtlasTexture.new()
tex.atlas = sheet()
tex.region = Rect2i(x, y, w, h)
return tex
static func named(name: String) -> AtlasTexture:
match name:
"meteorBrown_big1":
return region(224, 664, 101, 84)
"playerShip1_blue":
return region(211, 941, 99, 75)
"playerShip1_orange":
return region(247, 84, 99, 75)
"laserBlue01":
return region(856, 421, 9, 54)
"laserRed01":
return region(858, 230, 9, 54)
"laserGreen01":
return region(855, 173, 9, 57)
"star1":
return region(628, 681, 25, 24)
"star2":
return region(222, 84, 25, 24)
_:
push_warning("SpriteAtlas: unknown sprite '%s'" % name)
return region(0, 0, 1, 1)
+1
View File
@@ -0,0 +1 @@
uid://bfp8rhmuuiunl