feat(*): Add bot selection, score HUD, and winner reveal to matches, replace HUD text with aircraft-style flight instruments, and fix camera judder

This commit is contained in:
Josh Creek
2026-07-20 07:12:09 +01:00
parent 48369c50dc
commit 240e362f2f
20 changed files with 653 additions and 91 deletions
+9
View File
@@ -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"
+133 -33
View File
@@ -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")
+88 -7
View File
@@ -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"]
+1 -1
View File
@@ -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")]
+2 -2
View File
@@ -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")]
+73 -37
View File
@@ -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
+3
View File
@@ -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):
+11
View File
@@ -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 = ""
+1
View File
@@ -0,0 +1 @@
uid://dv4wiy0oor065
+106
View File
@@ -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)
@@ -0,0 +1 @@
uid://dhe6eyuuuqauf
+65
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dkdy0yxxveg7s
+64
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dtk57s0hvb4sf
+55 -2
View File
@@ -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")
+21 -4
View File
@@ -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)
+6 -2
View File
@@ -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)
+6 -1
View File
@@ -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)
+6 -2
View File
@@ -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)