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)