# 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)