From 240e362f2f8d93fecf48b4004aea42cb185660e1 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:12:09 +0100 Subject: [PATCH] feat(*): Add bot selection, score HUD, and winner reveal to matches, replace HUD text with aircraft-style flight instruments, and fix camera judder --- Game/project.godot | 9 ++ Game/scenes/HUD.tscn | 166 +++++++++++++++++---- Game/scenes/main_menu.tscn | 95 +++++++++++- Game/scenes/match.tscn | 2 +- Game/scenes/spectate.tscn | 4 +- Game/scripts/HUDController.gd | 110 +++++++++----- Game/scripts/game_mode.gd | 3 + Game/scripts/game_settings.gd | 11 ++ Game/scripts/game_settings.gd.uid | 1 + Game/scripts/hud_attitude_indicator.gd | 106 +++++++++++++ Game/scripts/hud_attitude_indicator.gd.uid | 1 + Game/scripts/hud_gauge.gd | 65 ++++++++ Game/scripts/hud_gauge.gd.uid | 1 + Game/scripts/hud_heading_tape.gd | 64 ++++++++ Game/scripts/hud_heading_tape.gd.uid | 1 + Game/scripts/main_menu.gd | 57 ++++++- Game/scripts/match_mode.gd | 25 +++- Game/scripts/ship.gd | 8 +- Game/scripts/ship_camera.gd | 7 +- Game/scripts/spectate_mode.gd | 8 +- 20 files changed, 653 insertions(+), 91 deletions(-) create mode 100644 Game/scripts/game_settings.gd create mode 100644 Game/scripts/game_settings.gd.uid create mode 100644 Game/scripts/hud_attitude_indicator.gd create mode 100644 Game/scripts/hud_attitude_indicator.gd.uid create mode 100644 Game/scripts/hud_gauge.gd create mode 100644 Game/scripts/hud_gauge.gd.uid create mode 100644 Game/scripts/hud_heading_tape.gd create mode 100644 Game/scripts/hud_heading_tape.gd.uid diff --git a/Game/project.godot b/Game/project.godot index f47e8a1f..55485a06 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -112,6 +112,7 @@ roll_right={ [physics] +common/physics_interpolation=true 3d/physics_engine="Jolt Physics" [autoload] @@ -121,3 +122,11 @@ roll_right={ + +GameSettings="*res://scripts/game_settings.gd" + + + + + + diff --git a/Game/scenes/HUD.tscn b/Game/scenes/HUD.tscn index 4b34b52e..9763a2d0 100644 --- a/Game/scenes/HUD.tscn +++ b/Game/scenes/HUD.tscn @@ -1,11 +1,15 @@ -[gd_scene load_steps=3 format=3 uid="uid://c8kak2l3m4n5"] +[gd_scene load_steps=6 format=3 uid="uid://c8kak2l3m4n5"] [ext_resource type="Script" uid="uid://du7y176h5aaq4" path="res://scripts/HUDController.gd" id="1_hud_controller"] +[ext_resource type="Script" path="res://scripts/hud_attitude_indicator.gd" id="2_adi"] +[ext_resource type="Script" path="res://scripts/hud_heading_tape.gd" id="3_tape"] +[ext_resource type="Script" path="res://scripts/hud_gauge.gd" id="4_gauge"] [sub_resource type="LabelSettings" id="LabelSettings_hud"] font_size = 32 [node name="HUD" type="CanvasLayer"] +process_mode = 3 script = ExtResource("1_hud_controller") [node name="Control" type="Control" parent="."] @@ -15,6 +19,7 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 +mouse_filter = 2 [node name="TimerLabel" type="Label" parent="Control"] layout_mode = 1 @@ -28,7 +33,37 @@ grow_horizontal = 2 label_settings = SubResource("LabelSettings_hud") horizontal_alignment = 1 -[node name="Instruments" type="Control" parent="Control"] +[node name="ScoreRow" type="HBoxContainer" parent="Control"] +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -90.5 +offset_right = 90.5 +offset_top = 64.0 +offset_bottom = 110.0 +grow_horizontal = 2 +alignment = 1 + +[node name="Team0Score" type="Label" parent="Control/ScoreRow"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.25, 0.55, 1, 1) +theme_override_font_sizes/font_size = 32 +text = "0" + +[node name="ScoreDash" type="Label" parent="Control/ScoreRow"] +layout_mode = 2 +theme_override_font_sizes/font_size = 32 +text = " - " + +[node name="Team1Score" type="Label" parent="Control/ScoreRow"] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.5, 0.15, 1) +theme_override_font_sizes/font_size = 32 +text = "0" + +[node name="ResultOverlay" type="Control" parent="Control"] +visible = false layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -36,48 +71,113 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -[node name="InfoPanel" type="VBoxContainer" parent="Control"] +[node name="Dim" type="ColorRect" parent="Control/ResultOverlay"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 0.5) + +[node name="Center" type="CenterContainer" parent="Control/ResultOverlay"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBox" type="VBoxContainer" parent="Control/ResultOverlay/Center"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="ResultLabel" type="Label" parent="Control/ResultOverlay/Center/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 56 +text = "Draw" +horizontal_alignment = 1 + +[node name="FinalScoreLabel" type="Label" parent="Control/ResultOverlay/Center/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 36 +text = "0 - 0" +horizontal_alignment = 1 + +[node name="Instruments" type="Control" parent="Control"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="HeadingTape" type="Control" parent="Control/Instruments"] +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -180.0 +offset_right = 180.0 +offset_top = 116.0 +offset_bottom = 156.0 +grow_horizontal = 2 +mouse_filter = 2 +script = ExtResource("3_tape") + +[node name="Cluster" type="VBoxContainer" parent="Control/Instruments"] layout_mode = 1 anchors_preset = 2 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = 10.0 -offset_top = -200.0 -offset_right = 300.0 -offset_bottom = -10.0 +offset_left = 16.0 +offset_top = -248.0 +offset_right = 294.0 +offset_bottom = -16.0 grow_vertical = 0 +theme_override_constants/separation = 8 +mouse_filter = 2 -[node name="SpeedLabel" type="Label" parent="Control/InfoPanel"] +[node name="Dials" type="HBoxContainer" parent="Control/Instruments/Cluster"] layout_mode = 2 -text = "Speed: 0.0 m/s" -label_settings = SubResource("LabelSettings_hud") +theme_override_constants/separation = 8 +mouse_filter = 2 -[node name="AltitudeLabel" type="Label" parent="Control/InfoPanel"] +[node name="SpeedGauge" type="Control" parent="Control/Instruments/Cluster/Dials"] +custom_minimum_size = Vector2(46, 170) layout_mode = 2 -text = "Altitude: 0.0 m" -label_settings = SubResource("LabelSettings_hud") +mouse_filter = 2 +script = ExtResource("4_gauge") +label = "SPD" +max_value = 35.0 -[node name="AngularVelLabel" type="Label" parent="Control/InfoPanel"] +[node name="AttitudeIndicator" type="Control" parent="Control/Instruments/Cluster/Dials"] +custom_minimum_size = Vector2(170, 170) layout_mode = 2 -text = "Angular Vel: 0.0 rad/s" -label_settings = SubResource("LabelSettings_hud") +mouse_filter = 2 +script = ExtResource("2_adi") -[node name="CameraModeLabel" type="Label" parent="Control/InfoPanel"] +[node name="AltitudeGauge" type="Control" parent="Control/Instruments/Cluster/Dials"] +custom_minimum_size = Vector2(46, 170) layout_mode = 2 +mouse_filter = 2 +script = ExtResource("4_gauge") +label = "ALT" +max_value = 12.0 +value_format = "%.1f" + +[node name="ThrustBar" type="Control" parent="Control/Instruments/Cluster"] +custom_minimum_size = Vector2(278, 16) +layout_mode = 2 +mouse_filter = 2 +script = ExtResource("4_gauge") +label = "THR" +horizontal = true +fill_color = Color(1, 0.55, 0.15, 0.85) + +[node name="CameraModeLabel" type="Label" parent="Control/Instruments/Cluster"] +modulate = Color(1, 1, 1, 0.6) +layout_mode = 2 +theme_override_font_sizes/font_size = 14 text = "Camera: Ball Cam" -label_settings = SubResource("LabelSettings_hud") - -[node name="AttitudeLabel" type="Label" parent="Control/InfoPanel"] -layout_mode = 2 -text = "Pitch: 0° Roll: 0°" -label_settings = SubResource("LabelSettings_hud") - -[node name="HeadingLabel" type="Label" parent="Control/InfoPanel"] -layout_mode = 2 -text = "Heading: 0°" -label_settings = SubResource("LabelSettings_hud") - -[node name="ThrustLabel" type="Label" parent="Control/InfoPanel"] -layout_mode = 2 -text = "Thrust: 0%" -label_settings = SubResource("LabelSettings_hud") diff --git a/Game/scenes/main_menu.tscn b/Game/scenes/main_menu.tscn index 4d4f663b..d4bdf8d7 100644 --- a/Game/scenes/main_menu.tscn +++ b/Game/scenes/main_menu.tscn @@ -20,8 +20,9 @@ grow_horizontal = 2 grow_vertical = 2 [node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"] +custom_minimum_size = Vector2(420, 0) layout_mode = 2 -theme_override_constants/separation = 24 +theme_override_constants/separation = 10 [node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"] layout_mode = 2 @@ -29,20 +30,100 @@ theme_override_font_sizes/font_size = 48 text = "Cosmic Clash" horizontal_alignment = 1 +[node name="SubtitleLabel" type="Label" parent="CenterContainer/VBoxContainer"] +modulate = Color(1, 1, 1, 0.55) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Physics-based soccer in space" +horizontal_alignment = 1 + +[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"] +custom_minimum_size = Vector2(0, 14) +layout_mode = 2 + [node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"] -custom_minimum_size = Vector2(330, 60) +custom_minimum_size = Vector2(0, 56) layout_mode = 2 text = "Free Play" -[node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"] -custom_minimum_size = Vector2(330, 60) +[node name="FreePlayHint" type="Label" parent="CenterContainer/VBoxContainer"] +modulate = Color(1, 1, 1, 0.55) layout_mode = 2 +theme_override_font_sizes/font_size = 13 +text = "Solo practice — no timer, R resets the ball" +horizontal_alignment = 1 + +[node name="MatchSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 + +[node name="MatchHeader" type="Label" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 22 text = "Match" -[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer"] -custom_minimum_size = Vector2(330, 60) +[node name="MatchHint" type="Label" parent="CenterContainer/VBoxContainer"] +modulate = Color(1, 1, 1, 0.55) layout_mode = 2 -text = "Spectate (Bot vs Bot)" +theme_override_font_sizes/font_size = 13 +text = "A 2:30 match — you vs a trained bot" + +[node name="MatchRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="OpponentLabel" type="Label" parent="CenterContainer/VBoxContainer/MatchRow"] +layout_mode = 2 +text = "Opponent" + +[node name="BotDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/MatchRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"] +custom_minimum_size = Vector2(0, 56) +layout_mode = 2 +text = "Play Match" + +[node name="SpectateSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 + +[node name="SpectateHeader" type="Label" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "Spectate" + +[node name="SpectateHint" type="Label" parent="CenterContainer/VBoxContainer"] +modulate = Color(1, 1, 1, 0.55) +layout_mode = 2 +theme_override_font_sizes/font_size = 13 +text = "Watch two bots play each other" + +[node name="SpectateRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="BotADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/SpectateRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="VsLabel" type="Label" parent="CenterContainer/VBoxContainer/SpectateRow"] +layout_mode = 2 +text = "vs" + +[node name="BotBDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/SpectateRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer"] +custom_minimum_size = Vector2(0, 56) +layout_mode = 2 +text = "Watch Match" [connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"] [connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"] diff --git a/Game/scenes/match.tscn b/Game/scenes/match.tscn index cd62ba01..2e489665 100644 --- a/Game/scenes/match.tscn +++ b/Game/scenes/match.tscn @@ -6,7 +6,7 @@ [node name="Match" type="Node3D"] script = ExtResource("1_m") -bot_model_path = "res://bots/run03.json" +bot_model_path = "res://bots/run05.json" [node name="Arena" parent="." instance=ExtResource("2_m")] diff --git a/Game/scenes/spectate.tscn b/Game/scenes/spectate.tscn index 3b19254a..f0325b5f 100644 --- a/Game/scenes/spectate.tscn +++ b/Game/scenes/spectate.tscn @@ -6,8 +6,8 @@ [node name="Spectate" type="Node3D"] script = ExtResource("1_s") -bot_a_model_path = "res://bots/run02.json" -bot_b_model_path = "res://bots/run01.json" +bot_a_model_path = "res://bots/run05.json" +bot_b_model_path = "res://bots/run05.json" [node name="Arena" parent="." instance=ExtResource("2_s")] diff --git a/Game/scripts/HUDController.gd b/Game/scripts/HUDController.gd index b9ef21a8..5b47bcdb 100644 --- a/Game/scripts/HUDController.gd +++ b/Game/scripts/HUDController.gd @@ -1,18 +1,28 @@ -# HUD Controller - handles all display logic and label updates -# Receives ship data via signals and updates UI accordingly -# Simplified version focusing on labels only +# 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 -# UI Label References @onready var timer_label = get_node("Control/TimerLabel") -@onready var speed_label = get_node("Control/InfoPanel/SpeedLabel") -@onready var altitude_label = get_node("Control/InfoPanel/AltitudeLabel") -@onready var angular_vel_label = get_node("Control/InfoPanel/AngularVelLabel") -@onready var camera_mode_label = get_node("Control/InfoPanel/CameraModeLabel") -@onready var attitude_label = get_node("Control/InfoPanel/AttitudeLabel") -@onready var heading_label = get_node("Control/InfoPanel/HeadingLabel") -@onready var thrust_label = get_node("Control/InfoPanel/ThrustLabel") +@onready var score_row = get_node_or_null("Control/ScoreRow") +@onready var team0_score_label = get_node_or_null("Control/ScoreRow/Team0Score") +@onready var team1_score_label = get_node_or_null("Control/ScoreRow/Team1Score") +@onready var result_overlay = get_node_or_null("Control/ResultOverlay") +@onready var result_label = get_node_or_null("Control/ResultOverlay/Center/VBox/ResultLabel") +@onready var final_score_label = get_node_or_null("Control/ResultOverlay/Center/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 @@ -29,6 +39,11 @@ func _initialize_hud(): 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 @@ -46,15 +61,23 @@ func _initialize_hud(): 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) + if score_row and is_instance_valid(score_row): + score_row.visible = has_score + + if game_manager and game_manager.has_signal("match_ended"): + game_manager.match_ended.connect(_on_match_ended) + func _connect_ship_signals(): - # Connect ship signals to label update methods + # Route ship telemetry to the flight instruments if ship.has_signal("speed_changed"): ship.speed_changed.connect(_on_ship_speed_changed) - print("HUDController: Connected to speed_changed signal") if ship.has_signal("altitude_changed"): ship.altitude_changed.connect(_on_ship_altitude_changed) - if ship.has_signal("angular_velocity_changed"): - ship.angular_velocity_changed.connect(_on_ship_angular_velocity_changed) if ship.has_signal("attitude_changed"): ship.attitude_changed.connect(_on_ship_attitude_changed) if ship.has_signal("heading_changed"): @@ -62,38 +85,33 @@ func _connect_ship_signals(): if ship.has_signal("thrust_changed"): ship.thrust_changed.connect(_on_ship_thrust_changed) -# Signal handlers for ship data - these update the legacy text labels -# All handlers include null checks for robustness +# Instrument signal handlers - all include null checks for robustness func _on_ship_speed_changed(speed: float): - if speed_label and is_instance_valid(speed_label): - speed_label.text = "Speed: %.1f m/s" % speed + if speed_gauge and is_instance_valid(speed_gauge): + speed_gauge.set_value(speed) func _on_ship_altitude_changed(altitude: float): - if altitude_label and is_instance_valid(altitude_label): - altitude_label.text = "Altitude: %.1f m" % altitude + if altitude_gauge and is_instance_valid(altitude_gauge): + altitude_gauge.set_value(altitude) -func _on_ship_angular_velocity_changed(angular_speed: float): - if angular_vel_label and is_instance_valid(angular_vel_label): - angular_vel_label.text = "Angular Vel: %.2f rad/s" % angular_speed +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 -func _on_ship_attitude_changed(pitch: float, roll: float, _yaw: float): - if attitude_label and is_instance_valid(attitude_label): - attitude_label.text = "Pitch: %.0f° Roll: %.0f°" % [pitch, roll] - -func _on_ship_heading_changed(heading_degrees: float): - if heading_label and is_instance_valid(heading_label): - heading_label.text = "Heading: %.0f°" % heading_degrees - -func _on_ship_thrust_changed(thrust_percent: float): - if thrust_label and is_instance_valid(thrust_label): - thrust_label.text = "Thrust: %.0f%%" % thrust_percent - -# Timer signal handler from game manager (restored original functionality) +# 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] @@ -102,3 +120,21 @@ func _on_timer_updated(minutes: int, seconds: int): 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): + if team0_score_label and is_instance_valid(team0_score_label): + team0_score_label.text = str(score.get(0, 0)) + if team1_score_label and is_instance_valid(team1_score_label): + team1_score_label.text = str(score.get(1, 0)) + +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") + else: + result_label.text = "%s team wins!" % TEAM_NAMES[winning_team] + result_label.add_theme_color_override("font_color", TEAM_COLORS[winning_team]) + final_score_label.text = "%d - %d" % [score.get(0, 0), score.get(1, 0)] + result_overlay.visible = true diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd index 3bd38f01..b880f7e6 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -101,6 +101,9 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void: body.set_deferred("global_transform", to) body.set_deferred("linear_velocity", Vector3.ZERO) body.set_deferred("angular_velocity", Vector3.ZERO) + # A kickoff reset is a teleport: without this, physics interpolation + # smears the body across the arena for a frame + body.call_deferred("reset_physics_interpolation") func _unhandled_input(event): diff --git a/Game/scripts/game_settings.gd b/Game/scripts/game_settings.gd new file mode 100644 index 00000000..0548e6be --- /dev/null +++ b/Game/scripts/game_settings.gd @@ -0,0 +1,11 @@ +extends Node + +# Autoload: cross-scene settings handed from the main menu to game modes. +# Defaults keep direct-scene runs (F6), training, and headless runs unchanged. + +# Opponent policy chosen in the main menu; empty = use the mode's own export. +var selected_bot_path: String = "" + +# Spectate (bot vs bot) policies chosen in the main menu; empty = mode export. +var spectate_bot_a_path: String = "" +var spectate_bot_b_path: String = "" diff --git a/Game/scripts/game_settings.gd.uid b/Game/scripts/game_settings.gd.uid new file mode 100644 index 00000000..d8435e5e --- /dev/null +++ b/Game/scripts/game_settings.gd.uid @@ -0,0 +1 @@ +uid://dv4wiy0oor065 diff --git a/Game/scripts/hud_attitude_indicator.gd b/Game/scripts/hud_attitude_indicator.gd new file mode 100644 index 00000000..0368ee1a --- /dev/null +++ b/Game/scripts/hud_attitude_indicator.gd @@ -0,0 +1,106 @@ +class_name HudAttitudeIndicator +extends Control + +# Aircraft-style attitude indicator (artificial horizon): the sky/ground disc +# rolls and the pitch ladder scrolls behind a fixed ship symbol, so pitch and +# roll are readable at a glance. Pure _draw code — no textures. A dumb display: +# HUDController pushes values in via set_attitude; it never reads the ship. + +const SKY_COLOR := Color(0.10, 0.24, 0.42, 0.9) +const GROUND_COLOR := Color(0.22, 0.15, 0.10, 0.9) +const LINE_COLOR := Color(0.85, 0.92, 1.0, 0.9) +const SHIP_SYMBOL_COLOR := Color(1.0, 0.8, 0.2) +const RING_COLOR := Color(0.5, 0.85, 1.0, 0.7) + +# Instrument scale: degrees of pitch visible from centre to rim. +const PITCH_RANGE := 45.0 +# Display smoothing rate (per second); high = snappy, low = floaty. +const SMOOTHING := 12.0 + +var _target_pitch := 0.0 +var _target_roll := 0.0 +var _pitch := 0.0 +var _roll := 0.0 + + +func set_attitude(pitch_deg: float, roll_deg: float) -> void: + _target_pitch = pitch_deg + _target_roll = roll_deg + + +func _process(delta: float) -> void: + var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent + _pitch = lerpf(_pitch, _target_pitch, t) + _roll = lerp_angle_deg(_roll, _target_roll, t) + queue_redraw() + + +static func lerp_angle_deg(from: float, to: float, weight: float) -> float: + return rad_to_deg(lerp_angle(deg_to_rad(from), deg_to_rad(to), weight)) + + +func _draw() -> void: + var center := size / 2.0 + var radius := minf(size.x, size.y) / 2.0 - 10.0 + var px_per_deg := radius / PITCH_RANGE + # Screen y grows downward, so nose-up (positive pitch) pushes the horizon + # down the instrument (+y) and reveals more sky. + var horizon_y := clampf(_pitch * px_per_deg, -radius, radius) + var roll_rad := deg_to_rad(_roll) + + # Everything inside the dial is drawn in the horizon frame: rotated + # opposite to roll so the horizon stays level while the instrument banks. + draw_set_transform(center, -roll_rad, Vector2.ONE) + + # Ground disc, then the sky as the circle segment above the horizon chord. + draw_circle(Vector2.ZERO, radius, GROUND_COLOR) + var sky := PackedVector2Array() + for i in 97: + # Start sampling at the bottom of the circle (excluded region) so the + # kept arc through the top is contiguous. + var ang := PI / 2.0 + float(i) * TAU / 96.0 + var p := Vector2(cos(ang), sin(ang)) * radius + if p.y < horizon_y: + sky.append(p) + if sky.size() >= 3: + draw_colored_polygon(sky, SKY_COLOR) + + # Horizon line, clipped to the dial. + if absf(horizon_y) < radius: + var half_chord := sqrt(radius * radius - horizon_y * horizon_y) + draw_line(Vector2(-half_chord, horizon_y), Vector2(half_chord, horizon_y), LINE_COLOR, 2.0) + + # Pitch ladder: rungs every 10 degrees, labelled, scrolling with pitch. + var font := ThemeDB.fallback_font + for ladder_deg: int in [-30, -20, -10, 10, 20, 30]: + var y := horizon_y - ladder_deg * px_per_deg + if absf(y) > radius - 12.0: + continue + var half_w := 22.0 if ladder_deg % 20 == 0 else 14.0 + draw_line(Vector2(-half_w, y), Vector2(half_w, y), LINE_COLOR, 1.0) + draw_string(font, Vector2(half_w + 4.0, y + 4.0), str(absi(ladder_deg)), + HORIZONTAL_ALIGNMENT_LEFT, -1, 10, LINE_COLOR) + + # Roll pointer: triangle at the dial's top edge in the horizon frame, so + # it swings against the fixed tick marks as the ship banks. + draw_colored_polygon(PackedVector2Array([ + Vector2(0, -radius + 2.0), Vector2(-5, -radius + 12.0), Vector2(5, -radius + 12.0), + ]), SHIP_SYMBOL_COLOR) + + # Back to the instrument frame for the fixed furniture. + draw_set_transform(center, 0.0, Vector2.ONE) + + # Roll scale ticks at 0, +/-30, +/-60 degrees around the top of the dial. + for tick_deg: int in [-60, -30, 0, 30, 60]: + var ang := deg_to_rad(float(tick_deg)) - PI / 2.0 + var dir := Vector2(cos(ang), sin(ang)) + var tick_len := 8.0 if tick_deg == 0 else 5.0 + draw_line(dir * radius, dir * (radius + tick_len), RING_COLOR, 2.0) + + # Fixed ship symbol: wings + centre dot, what the ladder is read against. + draw_line(Vector2(-34, 0), Vector2(-10, 0), SHIP_SYMBOL_COLOR, 3.0) + draw_line(Vector2(10, 0), Vector2(34, 0), SHIP_SYMBOL_COLOR, 3.0) + draw_line(Vector2(-10, 0), Vector2(0, 5), SHIP_SYMBOL_COLOR, 3.0) + draw_line(Vector2(0, 5), Vector2(10, 0), SHIP_SYMBOL_COLOR, 3.0) + + draw_arc(Vector2.ZERO, radius, 0.0, TAU, 96, RING_COLOR, 2.0, true) diff --git a/Game/scripts/hud_attitude_indicator.gd.uid b/Game/scripts/hud_attitude_indicator.gd.uid new file mode 100644 index 00000000..8e58e7c5 --- /dev/null +++ b/Game/scripts/hud_attitude_indicator.gd.uid @@ -0,0 +1 @@ +uid://dhe6eyuuuqauf diff --git a/Game/scripts/hud_gauge.gd b/Game/scripts/hud_gauge.gd new file mode 100644 index 00000000..150399e0 --- /dev/null +++ b/Game/scripts/hud_gauge.gd @@ -0,0 +1,65 @@ +class_name HudGauge +extends Control + +# Generic bar gauge (vertical or horizontal) with a short label and numeric +# readout — used for speed, altitude, and thrust. A dumb display fed by +# HUDController via set_value. + +const BG_COLOR := Color(0.05, 0.07, 0.11, 0.55) +const FRAME_COLOR := Color(0.5, 0.85, 1.0, 0.7) +const TEXT_COLOR := Color(0.85, 0.92, 1.0, 0.9) + +const SMOOTHING := 12.0 + +@export var label := "SPD" +@export var max_value := 100.0 +@export var horizontal := false +@export var fill_color := Color(0.5, 0.85, 1.0, 0.85) +# Printf pattern for the numeric readout. +@export var value_format := "%.0f" + +var _target := 0.0 +var _value := 0.0 + + +func set_value(value: float) -> void: + _target = value + + +func _process(delta: float) -> void: + var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent + _value = lerpf(_value, _target, t) + queue_redraw() + + +func _draw() -> void: + var font := ThemeDB.fallback_font + var fraction := clampf(_value / max_value, 0.0, 1.0) + var reading := value_format % _value + + if horizontal: + # [label] [======bar======] [value] + var hbar := Rect2(34, 2, size.x - 74, size.y - 4) + draw_string(font, Vector2(0, size.y - 4), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, TEXT_COLOR) + draw_rect(hbar, BG_COLOR) + draw_rect(Rect2(hbar.position, Vector2(hbar.size.x * fraction, hbar.size.y)), fill_color) + draw_rect(hbar, FRAME_COLOR, false, 1.0) + draw_string(font, Vector2(hbar.end.x + 6, size.y - 4), reading, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, TEXT_COLOR) + return + + # label / vertical bar filling upward / value + var bar := Rect2(size.x / 2.0 - 9, 16, 18, size.y - 34) + var label_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11) + draw_string(font, Vector2(size.x / 2.0 - label_size.x / 2.0, 12), label, + HORIZONTAL_ALIGNMENT_CENTER, -1, 11, TEXT_COLOR) + draw_rect(bar, BG_COLOR) + var fill_h := bar.size.y * fraction + draw_rect(Rect2(Vector2(bar.position.x, bar.end.y - fill_h), Vector2(bar.size.x, fill_h)), fill_color) + draw_rect(bar, FRAME_COLOR, false, 1.0) + # Quarter tick marks up the side of the bar. + for i in range(1, 4): + var y := bar.end.y - bar.size.y * i / 4.0 + draw_line(Vector2(bar.position.x - 3, y), Vector2(bar.position.x, y), FRAME_COLOR, 1.0) + var reading_size := font.get_string_size(reading, HORIZONTAL_ALIGNMENT_CENTER, -1, 11) + draw_string(font, Vector2(size.x / 2.0 - reading_size.x / 2.0, size.y - 2), reading, + HORIZONTAL_ALIGNMENT_CENTER, -1, 11, TEXT_COLOR) diff --git a/Game/scripts/hud_gauge.gd.uid b/Game/scripts/hud_gauge.gd.uid new file mode 100644 index 00000000..66b8448b --- /dev/null +++ b/Game/scripts/hud_gauge.gd.uid @@ -0,0 +1 @@ +uid://dkdy0yxxveg7s diff --git a/Game/scripts/hud_heading_tape.gd b/Game/scripts/hud_heading_tape.gd new file mode 100644 index 00000000..9f68e36b --- /dev/null +++ b/Game/scripts/hud_heading_tape.gd @@ -0,0 +1,64 @@ +class_name HudHeadingTape +extends Control + +# Aircraft-style heading tape: a strip of compass ticks slides under a fixed +# centre marker, with cardinal letters so the player always knows which way +# they're facing. A dumb display fed by HUDController via set_heading. + +const BG_COLOR := Color(0.05, 0.07, 0.11, 0.55) +const TICK_COLOR := Color(0.85, 0.92, 1.0, 0.9) +const MARKER_COLOR := Color(1.0, 0.8, 0.2) + +# Degrees of heading visible across the full tape width. +const VISIBLE_RANGE := 90.0 +const SMOOTHING := 12.0 + +const CARDINALS := {0: "N", 45: "NE", 90: "E", 135: "SE", 180: "S", 225: "SW", 270: "W", 315: "NW"} + +var _target_heading := 0.0 +var _heading := 0.0 + + +func _ready() -> void: + clip_contents = true + + +func set_heading(heading_deg: float) -> void: + _target_heading = heading_deg + + +func _process(delta: float) -> void: + var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent + _heading = fmod(HudAttitudeIndicator.lerp_angle_deg(_heading, _target_heading, t) + 360.0, 360.0) + queue_redraw() + + +func _draw() -> void: + var font := ThemeDB.fallback_font + var center_x := size.x / 2.0 + var px_per_deg := size.x / VISIBLE_RANGE + + draw_rect(Rect2(Vector2.ZERO, size), BG_COLOR) + + # Ticks every 5 degrees across the visible window, majors every 15. + var first := ceili((_heading - VISIBLE_RANGE / 2.0) / 5.0) * 5 + for d in range(first, first + int(VISIBLE_RANGE) + 5, 5): + var x := center_x + (float(d) - _heading) * px_per_deg + var shown := wrapi(d, 0, 360) + var major := shown % 15 == 0 + draw_line(Vector2(x, 0), Vector2(x, 10.0 if major else 5.0), TICK_COLOR, 1.0) + if major: + var text: String = CARDINALS.get(shown, str(shown)) + var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_CENTER, -1, 11) + draw_string(font, Vector2(x - text_size.x / 2.0, 22.0), text, + HORIZONTAL_ALIGNMENT_CENTER, -1, 11, + MARKER_COLOR if CARDINALS.has(shown) else TICK_COLOR) + + # Fixed centre marker and numeric readout. + draw_colored_polygon(PackedVector2Array([ + Vector2(center_x, 12.0), Vector2(center_x - 5, 2.0), Vector2(center_x + 5, 2.0), + ]), MARKER_COLOR) + var reading := "%03d" % (roundi(_heading) % 360) + var reading_size := font.get_string_size(reading, HORIZONTAL_ALIGNMENT_CENTER, -1, 13) + draw_string(font, Vector2(center_x - reading_size.x / 2.0, size.y - 4.0), reading, + HORIZONTAL_ALIGNMENT_CENTER, -1, 13, MARKER_COLOR) diff --git a/Game/scripts/hud_heading_tape.gd.uid b/Game/scripts/hud_heading_tape.gd.uid new file mode 100644 index 00000000..8da878b5 --- /dev/null +++ b/Game/scripts/hud_heading_tape.gd.uid @@ -0,0 +1 @@ +uid://dtk57s0hvb4sf diff --git a/Game/scripts/main_menu.gd b/Game/scripts/main_menu.gd index 1f9b7cbf..4ac2484c 100644 --- a/Game/scripts/main_menu.gd +++ b/Game/scripts/main_menu.gd @@ -1,7 +1,57 @@ extends Control -# Main menu: one handler per game mode. Adding a mode later (e.g. Vs AI) -# is a new button + a one-line handler pointing at its scene. +# Main menu: one handler per game mode. Bot dropdowns are populated from +# res://bots at runtime so newly trained bots appear automatically — never +# maintain a hardcoded list. Selections are pushed into the GameSettings +# autoload for the target mode to read, and restored when returning to the +# menu within a session. + +const BOTS_DIR := "res://bots" + +@onready var bot_dropdown: OptionButton = %BotDropdown +@onready var bot_a_dropdown: OptionButton = %BotADropdown +@onready var bot_b_dropdown: OptionButton = %BotBDropdown + + +func _ready() -> void: + var bots := _list_bots() + _populate_dropdown(bot_dropdown, bots, GameSettings.selected_bot_path) + _populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path) + _populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path) + $CenterContainer/VBoxContainer/FreePlayButton.grab_focus() + + +func _list_bots() -> Array[String]: + var files: Array[String] = [] + var dir := DirAccess.open(BOTS_DIR) + if dir: + for f in dir.get_files(): + if f.get_extension() == "json": + files.append(f) + files.sort() + return files + + +# Fills a dropdown with bot names (metadata = full model path). Reselects +# `preferred_path` if it's still on disk, else the newest (last) bot. +func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String) -> void: + dropdown.clear() + for f in bots: + dropdown.add_item(f.get_basename()) + dropdown.set_item_metadata(dropdown.item_count - 1, BOTS_DIR + "/" + f) + if dropdown.item_count == 0: + return + dropdown.select(dropdown.item_count - 1) + for i in dropdown.item_count: + if dropdown.get_item_metadata(i) == preferred_path: + dropdown.select(i) + break + + +func _selected_path(dropdown: OptionButton) -> String: + if dropdown.item_count > 0 and dropdown.selected >= 0: + return dropdown.get_item_metadata(dropdown.selected) + return "" func _on_free_play_pressed() -> void: @@ -9,8 +59,11 @@ func _on_free_play_pressed() -> void: func _on_match_pressed() -> void: + GameSettings.selected_bot_path = _selected_path(bot_dropdown) get_tree().change_scene_to_file("res://scenes/match.tscn") func _on_spectate_pressed() -> void: + GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown) + GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown) get_tree().change_scene_to_file("res://scenes/spectate.tscn") diff --git a/Game/scripts/match_mode.gd b/Game/scripts/match_mode.gd index 6c186101..cd1f0e58 100644 --- a/Game/scripts/match_mode.gd +++ b/Game/scripts/match_mode.gd @@ -7,6 +7,11 @@ extends GameMode signal timer_updated(minutes: int, seconds: int) signal score_changed(score: Dictionary) +# winning_team is -1 for a draw. +signal match_ended(winning_team: int, score: Dictionary) + +# How long the result overlay stays up before returning to the menu. +const RESULT_SCREEN_SECONDS := 5.0 @export var match_length_seconds := 150.0 @@ -36,14 +41,17 @@ func _start() -> void: func _make_opponent_controller() -> ShipController: - if not bot_model_path.is_empty() and FileAccess.file_exists(bot_model_path): + # The main menu's dropdown selection (GameSettings autoload) wins over the + # scene's export, which stays as the fallback for direct-scene runs. + var path := bot_model_path if GameSettings.selected_bot_path.is_empty() else GameSettings.selected_bot_path + if not path.is_empty() and FileAccess.file_exists(path): var bot := AIShipController.new() - bot.model_path = bot_model_path + bot.model_path = path bot.reaction_ticks = bot_reaction_ticks bot.action_noise = bot_action_noise return bot - if not bot_model_path.is_empty(): - push_warning("MatchMode: bot model not found at %s, spawning inert opponent" % bot_model_path) + if not path.is_empty(): + push_warning("MatchMode: bot model not found at %s, spawning inert opponent" % path) return ShipController.new() # inert placeholder @@ -64,4 +72,13 @@ func _on_goal_scored(conceding_team: int) -> void: func _on_match_timer_timeout() -> void: print("Full time! Final score: %d - %d" % [score[0], score[1]]) + var winning_team := -1 + if score[0] != score[1]: + winning_team = 0 if score[0] > score[1] else 1 + match_ended.emit(winning_team, score.duplicate()) + # Freeze gameplay while the HUD (process_mode ALWAYS) shows the result. + get_tree().paused = true + await get_tree().create_timer(RESULT_SCREEN_SECONDS).timeout + # Unpause before leaving, or the menu arrives paused and unresponsive. + get_tree().paused = false get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH) diff --git a/Game/scripts/ship.gd b/Game/scripts/ship.gd index e47a4f51..606452e6 100644 --- a/Game/scripts/ship.gd +++ b/Game/scripts/ship.gd @@ -230,8 +230,12 @@ func _emit_telemetry_data(): # Attitude telemetry (pitch, roll, yaw from ship orientation) # Physics: Euler angles from rotation matrix - # Pitch = rotation around X-axis, Roll = rotation around Z-axis - var ship_rotation = global_transform.basis.get_euler(EULER_ORDER_XYZ) + # Aviation convention (yaw → pitch → roll, EULER_ORDER_YXZ): pitch stays in + # ±90° and yaw covers the full circle. XYZ order would instead constrain + # yaw to ±90°, so an upright ship facing "south" would be reported as + # pitch 180 + roll 180 — mathematically equivalent, but it reads as + # upside-down on the attitude indicator and breaks the heading readout. + var ship_rotation = global_transform.basis.get_euler(EULER_ORDER_YXZ) var pitch_deg = rad_to_deg(ship_rotation.x) var roll_deg = rad_to_deg(ship_rotation.z) var yaw_deg = rad_to_deg(ship_rotation.y) diff --git a/Game/scripts/ship_camera.gd b/Game/scripts/ship_camera.gd index 1c29fa00..a59de588 100644 --- a/Game/scripts/ship_camera.gd +++ b/Game/scripts/ship_camera.gd @@ -70,7 +70,12 @@ func _update_ball_cam(delta, ball: Node3D): var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z) if flat.length() > 0.25: var t := 1.0 - exp(-orbit_smoothing * delta) # frame-rate independent - _orbit_dir = _orbit_dir.slerp(flat.normalized(), t).normalized() + # Both directions are horizontal, so swing about the exact UP axis. + # (Vector3.slerp derives its axis from the cross product, which + # degenerates when the directions are nearly parallel — the common + # case here — and spams normalization errors every frame.) + var swing := _orbit_dir.signed_angle_to(flat.normalized(), Vector3.UP) + _orbit_dir = _orbit_dir.rotated(Vector3.UP, swing * t).normalized() var camera_target_pos := ship_pos + _orbit_dir * camera_distance + Vector3.UP * camera_height camera_target_pos.y = maxf(camera_target_pos.y, min_camera_height) diff --git a/Game/scripts/spectate_mode.gd b/Game/scripts/spectate_mode.gd index 53f30859..a7882044 100644 --- a/Game/scripts/spectate_mode.gd +++ b/Game/scripts/spectate_mode.gd @@ -21,8 +21,12 @@ var score := {0: 0, 1: 0} func _start() -> void: spawn_ball() - var ship_a := spawn_ship(0, 0, _make_bot(bot_a_model_path, bot_a_reaction_ticks, bot_a_action_noise)) - spawn_ship(1, 0, _make_bot(bot_b_model_path, bot_b_reaction_ticks, bot_b_action_noise)) + # The main menu's dropdown selections (GameSettings autoload) win over the + # scene's exports, which stay as the fallback for direct-scene runs. + var path_a := bot_a_model_path if GameSettings.spectate_bot_a_path.is_empty() else GameSettings.spectate_bot_a_path + var path_b := bot_b_model_path if GameSettings.spectate_bot_b_path.is_empty() else GameSettings.spectate_bot_b_path + var ship_a := spawn_ship(0, 0, _make_bot(path_a, bot_a_reaction_ticks, bot_a_action_noise)) + spawn_ship(1, 0, _make_bot(path_b, bot_b_reaction_ticks, bot_b_action_noise)) spawn_camera_rig(ship_a)