# HUD Controller - discovers the ship/camera/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 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") # Team identity, mirroring TEAM_COLORS in ship.gd const TEAM_NAMES := {0: "Blue", 1: "Orange"} const TEAM_COLORS := {0: Color(0.25, 0.55, 1.0), 1: Color(1.0, 0.5, 0.15)} var ship: Node var _last_score := {0: 0, 1: 0} func _ready(): # Wait one frame to ensure ship is ready and added to group await get_tree().process_frame _initialize_hud() func _initialize_hud(): # Find the ship ship = get_tree().get_first_node_in_group("ship") if not ship: push_error("HUDController: No ship found in 'ship' group") 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 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 # 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 _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!" % TEAM_NAMES[winning_team] result_label.add_theme_color_override("font_color", TEAM_COLORS[winning_team]) _set_result_panel_accent(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)