Files
CosmicClash/Game/scripts/HUDController.gd
T
Josh Creek a5cbc977b5 feat(multiplayer): Phase 5 tasks 5.6-5.10 - disconnects, spectators, replay log
Completes Phase 5's implementation. Every task is verified at 1v1; the
3v3 phase gate itself has not been run and remains outstanding.

5.6/5.7 disconnects: a ship is never despawned. The slot keeps it and
swaps the controller (--fill-bots gives it a bot, the default leaves it
inert per §1.4), sets `stalled` immediately so the nameplate greys out
rather than waiting ~500ms for the abandoned jitter buffer to starve,
and reserves the slot for 30s keyed by player name so a reconnect gets
the same ship back.

5.7 was a real bug, found by the test rather than by review:
SlotInfo.controller was declared RLShipController, but the takeover
swaps in an AIShipController or the base controller - the narrower type
makes that assignment fail its type check, leaving the field pointing at
the controller set_controller() just queue_free()d. It surfaced as
controller_valid=false on the first run. The per-tick action write is
now also gated on `is RLShipController`, since a disconnected slot's bot
drives itself and overwriting it from a starving buffer would pin it to
the departed player's last input.

§6.4's two rules conflict: reserve for 30s, but abort when the last
human leaves. Applied naively the abort wins instantly in a 1v1 and the
reservation can never be redeemed, making reconnect unreachable exactly
when it matters. Abort now waits for no connections AND no outstanding
reservations.

5.8 spectators: a slotless peer spawns no ship and receives the same
snapshot broadcast. HUDController.spectator_mode keeps the clock, score
and goal celebration and hides only the ship instrument cluster - it
previously push_error'd and bailed, leaving a spectator with a dead HUD.
Camera cycles ships in slot order then the ball. --max-spectators caps
it, counted from the live peer list so a dropped spectator cannot leak a
unit of the cap.

5.9 escape respawn: new GameMode._on_bodies_respawned() virtual;
NetworkedMatch bumps reset_gen through Phase 2's deferred path so the
bump and the respawned pose land in the same broadcast. Single-player
modes are unaffected - the base is a no-op.

5.10 replay log: scripts/replay_log.gd, --replay-log=<path>, storing the
wire bytes verbatim in both directions rather than re-serialising - a
re-encode would launder away precisely the malformed payload being
chased. A live 6s match recorded 1115 records (557 inputs / 558
snapshots) and a stored snapshot decodes back to server_tick=100
match_state=WARMUP bodies=2.

Note for future work: --check-only --script is the only thing that
catches a parse error in networked_match.gd, because the unit runner
never loads it. Two separate breakages passed the full unit suite while
breaking every two-process run. A new class_name also needs --import
before it resolves.

Test surface: --role=host-disconnect (three-process 5.6/5.7 scenario),
--match-length=<s>, --replay-log, --fill-bots/--no-fill-bots,
--max-spectators. The ball-contact scenario now steers at the ball with
closed-loop real input instead of a hand-tuned fixed heading, which 5.3
broke by adding KICKOFF_YAW_JITTER; thrusting while turning took it from
2/3 to 5/5.

Regression: 87 unit tests; free-flight LAN p99 0.094m with 0 hard snaps;
transition gate 0.00%; ball contact 5/5; lifecycle goal cycle and full
match to RESULTS/LOBBY; disconnect+reconnect; two-bot CI.
2026-08-21 10:25:15 +01:00

293 lines
13 KiB
GDScript

# HUD Controller - receives its target ship from the game mode (see
# GameMode.spawn_camera_rig), discovers the camera rig/game mode via groups,
# and routes their signals to the display widgets. All flight data is
# rendered by aircraft-style instruments (attitude indicator, heading tape,
# bar gauges) — this node is only the wiring hub; the instruments are dumb
# displays.
extends CanvasLayer
class_name HUDController
@onready var score_row = get_node_or_null("Control/ScoreboardPanel")
@onready var timer_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/TimerLabel")
@onready var team0_name_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/Team0Name")
@onready var team0_score_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/Team0Score")
@onready var team1_score_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/Team1Score")
@onready var team1_name_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/Team1Name")
@onready var kickoff_label = get_node_or_null("Control/KickoffLabel")
@onready var result_overlay = get_node_or_null("Control/ResultOverlay")
@onready var result_panel = get_node_or_null("Control/ResultOverlay/Center/ResultPanel")
@onready var result_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/ResultLabel")
@onready var final_score_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/FinalScoreLabel")
@onready var goal_celebration = get_node_or_null("Control/GoalCelebration")
@onready var goal_tint = get_node_or_null("Control/GoalCelebration/Tint")
@onready var goal_title = get_node_or_null("Control/GoalCelebration/Center/GoalTitle")
@onready var heading_tape: HudHeadingTape = get_node_or_null("Control/Instruments/HeadingTape")
@onready var attitude_indicator: HudAttitudeIndicator = get_node_or_null("Control/Instruments/Cluster/Dials/AttitudeIndicator")
@onready var speed_gauge: HudGauge = get_node_or_null("Control/Instruments/Cluster/Dials/SpeedGauge")
@onready var altitude_gauge: HudGauge = get_node_or_null("Control/Instruments/Cluster/Dials/AltitudeGauge")
@onready var thrust_bar: HudGauge = get_node_or_null("Control/Instruments/Cluster/ThrustBar")
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
var ship: Node
# §6.3 (task 5.8). Set by the game mode BEFORE this node enters the tree when
# the local peer has no ship of its own. Distinct from `ship == null` by
# accident: a missing ship is still an error for a player, and silently
# degrading to a spectator HUD would hide that.
var spectator_mode := false
var _last_score := {0: 0, 1: 0}
var _goal_tween: Tween
func _ready():
# Wait one frame to ensure ship is ready and added to group
await get_tree().process_frame
_initialize_hud()
func _initialize_hud():
# `ship` is assigned by the game mode (GameMode.spawn_camera_rig) before
# this runs — not discovered via group, since the "ship" group can have
# 2+ members and there's no reliable way to tell which one is "ours".
if not ship:
# §6.3 (task 5.8): a spectator legitimately has no ship of its own, and
# must still get the score, clock and goal celebration. Only the
# per-ship instrument cluster is meaningless without one, so hide that
# and carry on wiring everything else — this used to push_error and
# bail, which left a spectator with a completely dead HUD.
if spectator_mode:
print("HUDController: spectator mode — hiding ship instruments")
_hide_ship_instruments()
_connect_mode_signals()
return
push_error("HUDController: No ship assigned")
return
print("HUDController: Found ship: ", ship.name)
# Gauge ranges derive from the ship and arena rather than restating numbers
if speed_gauge and "max_speed" in ship:
speed_gauge.max_value = ship.max_speed
if altitude_gauge:
altitude_gauge.max_value = ArenaBoundary.INNER_HEIGHT
_connect_ship_signals()
# Camera mode comes from the camera rig, not the ship
var camera_rig = get_tree().get_first_node_in_group("ship_camera")
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
_connect_mode_signals()
func _hide_ship_instruments() -> void:
# The per-ship cluster (speed, altitude, thrust, boost, camera mode) has no
# meaning without a ship. Everything else on the HUD still does.
for node in [speed_gauge, altitude_gauge, camera_mode_label]:
if node and is_instance_valid(node):
node.visible = false
var cluster := get_node_or_null("Control/Instruments/Cluster")
if cluster and is_instance_valid(cluster):
cluster.visible = false
# Everything that depends on the MODE rather than on owning a ship: clock,
# score, team identity, match-ended, kickoff countdown. A spectator gets all
# of it.
func _connect_mode_signals() -> void:
# Connect to game manager's timer signal; modes without a timer
# (e.g. free play) just don't show one
var game_manager = get_tree().get_first_node_in_group("game")
var has_timer = game_manager and game_manager.has_signal("timer_updated")
if has_timer:
game_manager.timer_updated.connect(_on_timer_updated)
print("HUDController: Connected to game timer")
if timer_label and is_instance_valid(timer_label):
timer_label.visible = has_timer
# Team identity (name + color) is fixed regardless of whether this
# mode tracks score, so set it here rather than baking it into the
# scene where it can't track TeamColors.
if team0_name_label and is_instance_valid(team0_name_label):
team0_name_label.text = TeamColors.TEAM_NAMES[0]
team0_name_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[0])
if team0_score_label and is_instance_valid(team0_score_label):
team0_score_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[0])
if team1_score_label and is_instance_valid(team1_score_label):
team1_score_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[1])
if team1_name_label and is_instance_valid(team1_name_label):
team1_name_label.text = TeamColors.TEAM_NAMES[1]
team1_name_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[1])
# Score display follows the same pattern: modes without scoring
# (e.g. free play) just don't show it
var has_score = game_manager and game_manager.has_signal("score_changed")
if has_score:
game_manager.score_changed.connect(_on_score_changed)
for label in [team0_name_label, team0_score_label, team1_score_label, team1_name_label]:
if label and is_instance_valid(label):
label.visible = has_score
# The banner itself (timer + score share one panel) only fully hides
# when neither signal exists at all.
if score_row and is_instance_valid(score_row):
score_row.visible = has_timer or has_score
if game_manager and game_manager.has_signal("match_ended"):
game_manager.match_ended.connect(_on_match_ended)
if game_manager and game_manager.has_signal("kickoff_countdown"):
game_manager.kickoff_countdown.connect(_on_kickoff_countdown)
if game_manager and game_manager.has_signal("overtime_started"):
game_manager.overtime_started.connect(_on_overtime_started)
func _connect_ship_signals():
# Route ship telemetry to the flight instruments
if ship.has_signal("speed_changed"):
ship.speed_changed.connect(_on_ship_speed_changed)
if ship.has_signal("altitude_changed"):
ship.altitude_changed.connect(_on_ship_altitude_changed)
if ship.has_signal("attitude_changed"):
ship.attitude_changed.connect(_on_ship_attitude_changed)
if ship.has_signal("heading_changed"):
ship.heading_changed.connect(_on_ship_heading_changed)
if ship.has_signal("thrust_changed"):
ship.thrust_changed.connect(_on_ship_thrust_changed)
# Instrument signal handlers - all include null checks for robustness
func _on_ship_speed_changed(speed: float):
if speed_gauge and is_instance_valid(speed_gauge):
speed_gauge.set_value(speed)
func _on_ship_altitude_changed(altitude: float):
if altitude_gauge and is_instance_valid(altitude_gauge):
altitude_gauge.set_value(altitude)
func _on_ship_attitude_changed(pitch: float, roll: float, _yaw: float):
if attitude_indicator and is_instance_valid(attitude_indicator):
attitude_indicator.set_attitude(pitch, roll)
func _on_ship_heading_changed(heading_degrees: float):
if heading_tape and is_instance_valid(heading_tape):
heading_tape.set_heading(heading_degrees)
func _on_ship_thrust_changed(thrust_percent: float):
if thrust_bar and is_instance_valid(thrust_bar):
thrust_bar.set_value(thrust_percent)
func _on_ship_camera_mode_changed(is_ball_cam: bool):
if camera_mode_label and is_instance_valid(camera_mode_label):
var camera_mode = "Ball Cam" if is_ball_cam else "Ship Cam"
camera_mode_label.text = "Camera: %s" % camera_mode
# Timer signal handler from game manager
func _on_timer_updated(minutes: int, seconds: int):
if timer_label and is_instance_valid(timer_label):
timer_label.text = "%02d:%02d" % [minutes, seconds]
# Simple timer update method (kept for compatibility)
func update_timer(time_text: String):
if timer_label and is_instance_valid(timer_label):
timer_label.text = time_text
func _on_score_changed(score: Dictionary):
_update_score_label(team0_score_label, score, 0)
_update_score_label(team1_score_label, score, 1)
_last_score = score.duplicate()
func _update_score_label(label: Label, score: Dictionary, team: int):
if not label or not is_instance_valid(label):
return
label.text = str(score.get(team, 0))
if score.get(team, 0) > _last_score.get(team, 0):
_flash_score_label(label)
# Brief scale pop to draw the eye to whichever team just scored.
func _flash_score_label(label: Label) -> void:
label.pivot_offset = label.size / 2.0
var tween := create_tween()
tween.tween_property(label, "scale", Vector2(1.4, 1.4), 0.1)
tween.tween_property(label, "scale", Vector2(1.0, 1.0), 0.2)
func show_goal_celebration(scoring_team: int) -> void:
if not goal_celebration or not goal_title or not goal_tint:
return
if _goal_tween and _goal_tween.is_valid():
_goal_tween.kill()
var tint: Color = TeamColors.TEAM_COLORS[scoring_team]
goal_tint.color = Color(tint.r, tint.g, tint.b, 0.34)
goal_title.text = "%s GOAL!" % TeamColors.TEAM_NAMES[scoring_team].to_upper()
goal_title.add_theme_color_override("font_color", tint.lightened(0.35))
goal_title.scale = Vector2(0.55, 0.55)
goal_title.modulate.a = 0.0
goal_celebration.visible = true
await get_tree().process_frame
if not is_instance_valid(goal_title):
return
goal_title.pivot_offset = goal_title.size / 2.0
_goal_tween = create_tween().set_parallel(true).set_ignore_time_scale(true)
_goal_tween.tween_property(goal_title, "scale", Vector2.ONE, 0.32).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
_goal_tween.tween_property(goal_title, "modulate:a", 1.0, 0.16)
_goal_tween.tween_property(goal_tint, "color:a", 0.12, 0.7).set_delay(0.12)
func hide_goal_celebration() -> void:
if not goal_celebration or not is_instance_valid(goal_celebration):
return
if _goal_tween and _goal_tween.is_valid():
_goal_tween.kill()
_goal_tween = create_tween().set_parallel(true).set_ignore_time_scale(true)
_goal_tween.tween_property(goal_title, "modulate:a", 0.0, 0.16)
_goal_tween.tween_property(goal_tint, "color:a", 0.0, 0.2)
_goal_tween.chain().tween_callback(func(): goal_celebration.visible = false)
func _on_overtime_started():
if timer_label and is_instance_valid(timer_label):
timer_label.text = "OT"
timer_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2))
func _on_kickoff_countdown(count: int):
if not kickoff_label or not is_instance_valid(kickoff_label):
return
kickoff_label.visible = count > 0
if count > 0:
kickoff_label.text = str(count)
func _on_match_ended(winning_team: int, score: Dictionary):
if not result_overlay or not is_instance_valid(result_overlay):
return
if winning_team < 0:
result_label.text = "Draw"
result_label.remove_theme_color_override("font_color")
_set_result_panel_accent(Color(1, 1, 1, 0.2))
else:
result_label.text = "%s team wins!" % TeamColors.TEAM_NAMES[winning_team]
result_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[winning_team])
_set_result_panel_accent(TeamColors.TEAM_COLORS[winning_team])
final_score_label.text = "%d - %d" % [score.get(0, 0), score.get(1, 0)]
result_overlay.visible = true
_animate_result_panel_in()
# Tints the result panel's border to match the winning team (neutral for a draw).
func _set_result_panel_accent(color: Color) -> void:
if not result_panel or not is_instance_valid(result_panel):
return
var style: StyleBoxFlat = result_panel.get_theme_stylebox("panel").duplicate()
style.border_color = color
result_panel.add_theme_stylebox_override("panel", style)
# Quick scale/fade pop so the results screen doesn't just snap into view.
func _animate_result_panel_in() -> void:
if not result_panel or not is_instance_valid(result_panel):
return
result_panel.scale = Vector2(0.85, 0.85)
result_panel.modulate.a = 0.0
# ResultOverlay was hidden since scene start, so ResultPanel's
# container-computed size isn't valid until the next layout pass.
await get_tree().process_frame
if not is_instance_valid(result_panel):
return
result_panel.pivot_offset = result_panel.size / 2.0
var tween := create_tween().set_parallel(true)
tween.tween_property(result_panel, "scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(result_panel, "modulate:a", 1.0, 0.2)