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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://csydp7koiklsd
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfp8rhmuuiunl
|
||||
Reference in New Issue
Block a user