refactor(*): DRY up arena scenes, game modes, and HUD instruments

Arenas inherit from a new arena_base.tscn instead of restating ~40 shared
lines each; only sky/ambient/glow/tint/decoration vary, exposed via new
Arena exports since nested Environment properties aren't overridable
through scene inheritance. Bot construction and score-keeping move onto
GameMode, shared by match and spectate modes while preserving their
differing GameSettings-override behavior and the HUD's score-row
duck-typing. HUD instruments share a HudInstrument base for the
smoothing-weight calc and angle-lerp helper. Also dedupes
MAIN_MENU_SCENE_PATH into ScenePaths and documents why DIFFICULTIES
tiers share one checkpoint.
This commit is contained in:
Josh Creek
2026-08-04 15:07:33 +01:00
parent b285d012dc
commit 08a0f74391
18 changed files with 203 additions and 268 deletions
+3 -74
View File
@@ -1,8 +1,6 @@
[gd_scene load_steps=8 format=3]
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
[ext_resource type="PackedScene" path="res://scenes/arena_base.tscn" id="1_base"]
[sub_resource type="Shader" id="Shader_stars"]
code = "shader_type sky;
@@ -27,74 +25,5 @@ void sky() {
[sub_resource type="ShaderMaterial" id="ShaderMaterial_stars"]
shader = SubResource("Shader_stars")
[sub_resource type="Sky" id="Sky_space"]
[node name="Arena" instance=ExtResource("1_base")]
sky_material = SubResource("ShaderMaterial_stars")
[sub_resource type="Environment" id="Environment_space"]
background_mode = 2
sky = SubResource("Sky_space")
ambient_light_source = 2
ambient_light_color = Color(0.55, 0.6, 0.75, 1)
ambient_light_energy = 0.4
glow_enabled = true
glow_levels/1 = 0.0
glow_levels/2 = 0.4
glow_levels/3 = 0.8
glow_levels/4 = 0.6
glow_levels/5 = 0.2
glow_intensity = 0.9
glow_strength = 1.2
glow_bloom = 0.05
glow_blend_mode = 1
glow_hdr_threshold = 1.0
glow_hdr_scale = 2.0
adjustment_enabled = true
adjustment_brightness = 1.0
adjustment_contrast = 1.05
adjustment_saturation = 1.08
[node name="Arena" type="Node3D"]
script = ExtResource("1_iywne")
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
field_tint = Color(0.5, 0.7, 1, 1)
field_intensity = 0.09
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
shadow_enabled = true
[node name="FillLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
light_color = Color(0.55, 0.65, 0.85, 1)
light_energy = 0.4
shadow_enabled = false
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_space")
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
size = Vector3(26, 14, 40)
box_projection = true
update_mode = 0
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
team = 1
[node name="BallSpawn" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
[node name="SpawnsTeam0" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 13.5)
[node name="SpawnsTeam1" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -13.5)
+13 -68
View File
@@ -1,8 +1,6 @@
[gd_scene load_steps=16 format=3]
[gd_scene load_steps=13 format=3]
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
[ext_resource type="PackedScene" path="res://scenes/arena_base.tscn" id="1_base"]
[ext_resource type="Texture2D" path="res://assets/textures/sky_nebula.png" id="7_nebula"]
[ext_resource type="PackedScene" path="res://assets/models/nebula_station.glb" id="8_station"]
[ext_resource type="PackedScene" path="res://assets/models/nebula_debris.glb" id="9_debris"]
@@ -13,32 +11,6 @@
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_nebula"]
panorama = ExtResource("7_nebula")
[sub_resource type="Sky" id="Sky_nebula"]
sky_material = SubResource("PanoramaSkyMaterial_nebula")
[sub_resource type="Environment" id="Environment_nebula"]
background_mode = 2
sky = SubResource("Sky_nebula")
ambient_light_source = 2
ambient_light_color = Color(0.65, 0.35, 0.75, 1)
ambient_light_energy = 0.45
glow_enabled = true
glow_levels/1 = 0.0
glow_levels/2 = 0.4
glow_levels/3 = 0.8
glow_levels/4 = 0.6
glow_levels/5 = 0.2
glow_intensity = 0.8
glow_strength = 1.0
glow_bloom = 0.03
glow_blend_mode = 1
glow_hdr_threshold = 1.1
glow_hdr_scale = 2.0
adjustment_enabled = true
adjustment_brightness = 1.0
adjustment_contrast = 1.05
adjustment_saturation = 1.08
[sub_resource type="ShaderMaterial" id="ShaderMaterial_dust"]
shader = ExtResource("12_dust_shader")
shader_parameter/albedo_tex = ExtResource("10_dust")
@@ -75,52 +47,25 @@ scale_min = 0.5
scale_max = 1.6
color_ramp = SubResource("GradientTexture1D_dust")
[node name="Arena" type="Node3D"]
script = ExtResource("1_iywne")
[node name="Arena" instance=ExtResource("1_base")]
sky_material = SubResource("PanoramaSkyMaterial_nebula")
ambient_light_color = Color(0.65, 0.35, 0.75, 1)
ambient_light_energy = 0.45
glow_intensity = 0.8
glow_strength = 1.0
glow_bloom = 0.03
glow_hdr_threshold = 1.1
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
[node name="Boundary" parent="." index="0"]
field_tint = Color(0.78, 0.55, 1, 1)
field_intensity = 0.1
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
[node name="DirectionalLight3D" parent="." index="1"]
light_color = Color(0.85, 0.75, 1, 1)
shadow_enabled = true
[node name="FillLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
[node name="FillLight" parent="." index="2"]
light_color = Color(0.55, 0.35, 0.65, 1)
light_energy = 0.45
shadow_enabled = false
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_nebula")
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
size = Vector3(26, 14, 40)
box_projection = true
update_mode = 0
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
team = 1
[node name="BallSpawn" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
[node name="SpawnsTeam0" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 13.5)
[node name="SpawnsTeam1" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -13.5)
[node name="NebulaDust" type="GPUParticles3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5, 0)
+6 -66
View File
@@ -1,84 +1,24 @@
[gd_scene load_steps=6 format=3]
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
[ext_resource type="PackedScene" path="res://scenes/arena_base.tscn" id="1_base"]
[ext_resource type="Shader" path="res://shaders/sky_asteroid_field.gdshader" id="7_asteroid"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_asteroid"]
shader = ExtResource("7_asteroid")
[sub_resource type="Sky" id="Sky_asteroid"]
[node name="Arena" instance=ExtResource("1_base")]
sky_material = SubResource("ShaderMaterial_asteroid")
[sub_resource type="Environment" id="Environment_asteroid"]
background_mode = 2
sky = SubResource("Sky_asteroid")
ambient_light_source = 2
ambient_light_color = Color(0.6, 0.45, 0.3, 1)
ambient_light_energy = 0.3
glow_enabled = true
glow_levels/1 = 0.0
glow_levels/2 = 0.4
glow_levels/3 = 0.8
glow_levels/4 = 0.6
glow_levels/5 = 0.2
glow_intensity = 0.9
glow_strength = 1.2
glow_bloom = 0.05
glow_blend_mode = 1
glow_hdr_threshold = 1.0
glow_hdr_scale = 2.0
adjustment_enabled = true
adjustment_brightness = 1.0
adjustment_contrast = 1.05
adjustment_saturation = 1.08
[node name="Arena" type="Node3D"]
script = ExtResource("1_iywne")
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
[node name="Boundary" parent="." index="0"]
field_tint = Color(1, 0.72, 0.4, 1)
field_intensity = 0.09
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
[node name="DirectionalLight3D" parent="." index="1"]
light_color = Color(1, 0.85, 0.65, 1)
shadow_enabled = true
[node name="FillLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
[node name="FillLight" parent="." index="2"]
light_color = Color(0.55, 0.4, 0.3, 1)
light_energy = 0.4
shadow_enabled = false
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_asteroid")
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
size = Vector3(26, 14, 40)
box_projection = true
update_mode = 0
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
team = 1
[node name="BallSpawn" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
[node name="SpawnsTeam0" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 13.5)
[node name="SpawnsTeam1" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -13.5)
[node name="Decoration" type="Node3D" parent="."]
+79
View File
@@ -0,0 +1,79 @@
[gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://scripts/arena.gd" id="1_iywne"]
[ext_resource type="PackedScene" path="res://objects/arena_boundary.tscn" id="2_bndry"]
[ext_resource type="PackedScene" uid="uid://cofdcxo5170rs" path="res://objects/goal.tscn" id="6_p57ef"]
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_base"]
[sub_resource type="Sky" id="Sky_base"]
sky_material = SubResource("ProceduralSkyMaterial_base")
[sub_resource type="Environment" id="Environment_base"]
background_mode = 2
sky = SubResource("Sky_base")
ambient_light_source = 2
ambient_light_color = Color(0.55, 0.6, 0.75, 1)
ambient_light_energy = 0.4
glow_enabled = true
glow_levels/1 = 0.0
glow_levels/2 = 0.4
glow_levels/3 = 0.8
glow_levels/4 = 0.6
glow_levels/5 = 0.2
glow_intensity = 0.9
glow_strength = 1.2
glow_bloom = 0.05
glow_blend_mode = 1
glow_hdr_threshold = 1.0
glow_hdr_scale = 2.0
adjustment_enabled = true
adjustment_brightness = 1.0
adjustment_contrast = 1.05
adjustment_saturation = 1.08
[node name="Arena" type="Node3D"]
script = ExtResource("1_iywne")
[node name="Boundary" parent="." instance=ExtResource("2_bndry")]
field_tint = Color(0.5, 0.7, 1, 1)
field_intensity = 0.09
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866025, -0.383022, 0.321394, 0, 0.642788, 0.766044, -0.5, -0.663414, 0.55667, 0, 11, 0)
shadow_enabled = true
[node name="FillLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(-0.866025, -0.383022, -0.321394, 0, 0.642788, 0.766044, 0.5, -0.663414, 0.55667, 0, 11, 0)
light_color = Color(0.55, 0.65, 0.85, 1)
light_energy = 0.4
shadow_enabled = false
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_base")
[node name="ReflectionProbe" type="ReflectionProbe" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0)
size = Vector3(26, 14, 40)
box_projection = true
update_mode = 0
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18)
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18)
team = 1
[node name="BallSpawn" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
[node name="SpawnsTeam0" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 13.5)
[node name="SpawnsTeam1" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -13.5)
+25 -1
View File
@@ -5,12 +5,36 @@ extends Node3D
# spawn markers. An arena holds no rules and no state — game modes query it
# for spawn transforms and goals, then spawn ships/ball themselves.
# Per-arena Environment tuning. These live as exports (rather than baked
# into each arena's Environment sub-resource) because Godot's scene
# inheritance can only override whole top-level node properties, not
# properties nested inside a sub-resource — so each arena_0N.tscn overrides
# these on the Arena root instead, and _ready() applies them here.
@export var sky_material: Material
@export var ambient_light_color := Color(0.55, 0.6, 0.75, 1)
@export var ambient_light_energy := 0.4
@export var glow_intensity := 0.9
@export var glow_strength := 1.2
@export var glow_bloom := 0.05
@export var glow_hdr_threshold := 1.0
func _ready():
add_to_group("arena")
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
if world_env and world_env.environment:
VideoSettings.apply_to_environment(world_env.environment)
var env := world_env.environment
if sky_material:
if not env.sky:
env.sky = Sky.new()
env.sky.sky_material = sky_material
env.ambient_light_color = ambient_light_color
env.ambient_light_energy = ambient_light_energy
env.glow_intensity = glow_intensity
env.glow_strength = glow_strength
env.glow_bloom = glow_bloom
env.glow_hdr_threshold = glow_hdr_threshold
VideoSettings.apply_to_environment(env)
func get_ball_spawn() -> Transform3D:
+29 -2
View File
@@ -11,13 +11,18 @@ extends Node3D
@export var ball_scene: PackedScene = preload("res://objects/ball.tscn")
const CAMERA_RIG_SCENE = preload("res://scenes/ship_camera_rig.tscn")
const MAIN_MENU_SCENE_PATH = "res://scenes/main_menu.tscn"
var arena: Arena
var ball: RigidBody3D
var ships: Array[Ship] = []
var _ship_spawn_transforms := {}
# Shared by modes that keep score (Match, Spectate); Free Play never
# references this or emits a score_changed signal, and HUDController relies
# on has_signal("score_changed") to decide whether to show a score row — so
# that signal stays declared per-subclass, not here.
var score := {0: 0, 1: 0}
func _ready():
# Group lets the HUD discover the game mode for timer/score signals
@@ -97,6 +102,28 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
return rig
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
# apply their own GameSettings-override logic first, which differs between
# modes (Match lets GameSettings override all three fields, Spectate only
# the path) — builds a trained-policy controller, or an inert placeholder if
# the path is empty or missing.
func _build_opponent(model_path: String, reaction_ticks: int, action_noise: float, label: String = "GameMode") -> ShipController:
if not model_path.is_empty() and FileAccess.file_exists(model_path):
var bot := AIShipController.new()
bot.model_path = model_path
bot.reaction_ticks = reaction_ticks
bot.action_noise = action_noise
return bot
if not model_path.is_empty():
push_warning("%s: bot model not found at %s, spawning inert opponent" % [label, model_path])
return ShipController.new() # inert placeholder
func _record_goal(scoring_team: int) -> void:
score[scoring_team] += 1
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
# Tiny per-reset randomization, well below anything a player would notice as
# "not a real kickoff" — just enough that two ships running the identical
# deterministic AI policy (action_noise = 0) don't start every kickoff from a
@@ -141,4 +168,4 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
func _unhandled_input(event):
if event.is_action_pressed("ui_cancel"):
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+2 -8
View File
@@ -1,5 +1,5 @@
class_name HudAttitudeIndicator
extends Control
extends HudInstrument
# Aircraft-style attitude indicator (artificial horizon): the sky/ground disc
# rolls and the pitch ladder scrolls behind a fixed ship symbol, so pitch and
@@ -14,8 +14,6 @@ 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
@@ -29,16 +27,12 @@ func set_attitude(pitch_deg: float, roll_deg: float) -> void:
func _process(delta: float) -> void:
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
var t := _smoothing_weight(delta)
_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
+2 -4
View File
@@ -1,5 +1,5 @@
class_name HudGauge
extends Control
extends HudInstrument
# Generic bar gauge (vertical or horizontal) with a short label and numeric
# readout — used for speed, altitude, and thrust. A dumb display fed by
@@ -9,8 +9,6 @@ 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
@@ -27,7 +25,7 @@ func set_value(value: float) -> void:
func _process(delta: float) -> void:
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
var t := _smoothing_weight(delta)
_value = lerpf(_value, _target, t)
queue_redraw()
+3 -4
View File
@@ -1,5 +1,5 @@
class_name HudHeadingTape
extends Control
extends HudInstrument
# 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
@@ -11,7 +11,6 @@ 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"}
@@ -28,8 +27,8 @@ func set_heading(heading_deg: float) -> void:
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)
var t := _smoothing_weight(delta)
_heading = fmod(lerp_angle_deg(_heading, _target_heading, t) + 360.0, 360.0)
queue_redraw()
+17
View File
@@ -0,0 +1,17 @@
class_name HudInstrument
extends Control
# Shared base for the HUD's procedural instruments (heading tape, attitude
# indicator, gauges): the exponential-smoothing weight calc and the
# angle-aware lerp helper they all use. Each instrument still owns its own
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
const SMOOTHING := 12.0
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 _smoothing_weight(delta: float) -> float:
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
+1
View File
@@ -0,0 +1 @@
uid://bfjncr0nnakun
+4
View File
@@ -11,6 +11,10 @@ extends Control
const BOTS_DIR := "res://bots"
# All three tiers deliberately point at the one trained checkpoint
# (easy.json) and differ only by reaction_ticks/action_noise handicap, not by
# model skill. Superseded once real medium/hard checkpoints are promoted into
# res://bots/promoted/ — see the "AI opponent" section of TODO.md.
const DIFFICULTIES := [
{"name": "Easy", "model": "res://bots/promoted/easy.json", "reaction_ticks": 24, "action_noise": 0.35},
{"name": "Medium", "model": "res://bots/promoted/easy.json", "reaction_ticks": 14, "action_noise": 0.15},
+5 -13
View File
@@ -27,7 +27,6 @@ const KICKOFF_COUNTDOWN_SECONDS := 3
@export_range(1, 60) var bot_reaction_ticks: int = 8
@export_range(0.0, 1.0) var bot_action_noise: float = 0.0
var score := {0: 0, 1: 0}
var match_timer: Timer
var _in_overtime := false
@@ -56,15 +55,9 @@ func _make_opponent_controller() -> ShipController:
# 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 = path
bot.reaction_ticks = bot_reaction_ticks if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks
bot.action_noise = bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_bot_action_noise
return bot
if not path.is_empty():
push_warning("MatchMode: bot model not found at %s, spawning inert opponent" % path)
return ShipController.new() # inert placeholder
var reaction_ticks := bot_reaction_ticks if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks
var action_noise := bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_bot_action_noise
return _build_opponent(path, reaction_ticks, action_noise, "MatchMode")
func _process(_delta):
@@ -75,9 +68,8 @@ func _process(_delta):
func _on_goal_scored(conceding_team: int) -> void:
var scoring_team := 1 - conceding_team
score[scoring_team] += 1
_record_goal(scoring_team)
score_changed.emit(score.duplicate())
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
if _in_overtime:
# Golden goal: the first score after a draw ends the match outright.
_end_match(scoring_team)
@@ -136,4 +128,4 @@ func _end_match(winning_team: int) -> void:
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)
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+3
View File
@@ -0,0 +1,3 @@
class_name ScenePaths
const MAIN_MENU := "res://scenes/main_menu.tscn"
+1
View File
@@ -0,0 +1 @@
uid://5msygpcnvgfd
+1 -3
View File
@@ -6,8 +6,6 @@ extends Control
# apply the next time an arena loads (see arena.gd), since they scale each
# arena's own tuned Environment values rather than something viewport-wide.
const MAIN_MENU_SCENE_PATH := "res://scenes/main_menu.tscn"
const AA_OPTIONS := [
{"name": "Off", "mode": VideoSettings.AAMode.OFF},
{"name": "FXAA", "mode": VideoSettings.AAMode.FXAA},
@@ -62,7 +60,7 @@ func _on_brightness_slider_value_changed(value: float) -> void:
func _on_back_pressed() -> void:
VideoSettings.save()
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _unhandled_input(event: InputEvent) -> void:
+3 -19
View File
@@ -16,9 +16,6 @@ signal score_changed(score: Dictionary)
@export_range(1, 60) var bot_b_reaction_ticks: int = 8
@export_range(0.0, 1.0) var bot_b_action_noise: float = 0.0
var score := {0: 0, 1: 0}
func _get_arena_scene_path() -> String:
return ArenaRegistry.random_path()
@@ -29,28 +26,15 @@ func _start() -> void:
# 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))
var ship_a := spawn_ship(0, 0, _build_opponent(path_a, bot_a_reaction_ticks, bot_a_action_noise, "SpectateMode"))
spawn_ship(1, 0, _build_opponent(path_b, bot_b_reaction_ticks, bot_b_action_noise, "SpectateMode"))
spawn_camera_rig(ship_a)
func _make_bot(model_path: String, reaction_ticks: int, action_noise: float) -> ShipController:
if not model_path.is_empty() and FileAccess.file_exists(model_path):
var bot := AIShipController.new()
bot.model_path = model_path
bot.reaction_ticks = reaction_ticks
bot.action_noise = action_noise
return bot
if not model_path.is_empty():
push_warning("SpectateMode: bot model not found at %s, spawning inert ship" % model_path)
return ShipController.new() # inert placeholder
func _on_goal_scored(conceding_team: int) -> void:
var scoring_team := 1 - conceding_team
score[scoring_team] += 1
_record_goal(scoring_team)
score_changed.emit(score.duplicate())
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
reset_ball()
reset_ships()
+6 -6
View File
@@ -25,12 +25,12 @@ Bugs found in an adversarial review. None are gameplay- or physics-affecting, so
## DRY / structure
- [ ] `arena_base.tscn` + inherited themes. `arena_01/02/03.tscn` each restate ~40 identical lines (both lights, the reflection probe, goal transforms, ball/ship spawn markers); only sky, ambient, three glow numbers, tint and decoration differ. The `_elevated` variants already prove the inherited-scene pattern works here — `arena_01_elevated.tscn` is 20 lines to `arena_01.tscn`'s 100. Unblocks cheap new arenas.
- [ ] Hoist bot construction into `GameMode``match_mode._make_opponent_controller` and `spectate_mode._make_bot` are the same function (same existence check, same three fields, same warning + inert fallback).
- [ ] Hoist score-keeping into `GameMode` — both modes declare `score := {0:0, 1:0}`, a `score_changed` signal, and the identical increment/emit/print block.
- [ ] `HudInstrument extends Control` base for the three HUD widgets: each repeats `const SMOOTHING := 12.0` and the same `1.0 - exp(-SMOOTHING * delta)` lerp/redraw `_process`, and `hud_heading_tape.gd` reaches across to a static angle helper parked on `HudAttitudeIndicator`.
- [ ] `MAIN_MENU_SCENE_PATH` is declared in both `game_mode.gd` and `settings_menu.gd`.
- [ ] Comment `main_menu.gd`'s `DIFFICULTIES` to say the tiers deliberately share `easy.json` and differ only by handicap (superseded once real `medium`/`hard` tiers land — see the AI section above).
- [x] `arena_base.tscn` + inherited themes. `arena_01/02/03.tscn` each restate ~40 identical lines (both lights, the reflection probe, goal transforms, ball/ship spawn markers); only sky, ambient, three glow numbers, tint and decoration differ. The `_elevated` variants already prove the inherited-scene pattern works here — `arena_01_elevated.tscn` is 20 lines to `arena_01.tscn`'s 100. Unblocks cheap new arenas.
- [x] Hoist bot construction into `GameMode``match_mode._make_opponent_controller` and `spectate_mode._make_bot` are the same function (same existence check, same three fields, same warning + inert fallback).
- [x] Hoist score-keeping into `GameMode` — both modes declare `score := {0:0, 1:0}`, a `score_changed` signal, and the identical increment/emit/print block.
- [x] `HudInstrument extends Control` base for the three HUD widgets: each repeats `const SMOOTHING := 12.0` and the same `1.0 - exp(-SMOOTHING * delta)` lerp/redraw `_process`, and `hud_heading_tape.gd` reaches across to a static angle helper parked on `HudAttitudeIndicator`.
- [x] `MAIN_MENU_SCENE_PATH` is declared in both `game_mode.gd` and `settings_menu.gd`.
- [x] Comment `main_menu.gd`'s `DIFFICULTIES` to say the tiers deliberately share `easy.json` and differ only by handicap (superseded once real `medium`/`hard` tiers land — see the AI section above).
## Performance