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>
896 lines
26 KiB
GDScript
896 lines
26 KiB
GDScript
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)
|