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)
