Compare commits

...

15 Commits

Author SHA1 Message Date
Josh Creek 8551d9e835 perf(ship): reuse member ShipAction instead of allocating per tick
ship.gd's controllerless path and player_ship_controller.gd each
allocated a fresh ShipAction every physics tick; ai_ship_controller.gd
and rl_ship_controller.gd already avoid this via a persistent member.
Convert both to reuse a member instance, matching the existing
full-field-overwrite convention (rather than +=/-= off a fresh zero).

Also drop the completed items from TODO.md.
2026-08-04 22:23:02 +01:00
CosmicClash Training Bot 6198dc67fc chore(training): Add 20260803-1829-curric-s4-unmask-retry2 checkpoints, logs, and exported policy 2026-08-04 22:06:04 +01:00
Josh Creek 884b7799a0 fix(hud): have game mode hand HUD its target ship instead of group lookup
HUDController found its ship via get_first_node_in_group("ship"), a group
that has 2+ members once a match has an AI opponent — it only worked
because the player ship happened to spawn first. spawn_camera_rig now
wires the HUD's ship the same way it already wires the camera rig's
target.
2026-08-04 19:54:55 +01:00
Josh Creek 5193776d86 perf(match): emit timer_updated only when the displayed second changes
match_mode.gd fired the signal every frame while only the once-a-second
value is displayed, forcing the HUD to re-format and re-shape the label
each frame. Gate emission on the whole-second value changing, matching
ship.gd's threshold-gated telemetry discipline.
2026-08-04 19:41:37 +01:00
Josh Creek c96325144a fix(match): guard kickoff countdown against a post-match resume
The coroutine stalls mid-countdown while the tree is paused for the
results screen, but _end_match unpauses before the deferred scene
change actually tears things down — letting it resume for a frame and
re-emit kickoff_countdown / unfreeze bodies in the dying scene.
2026-08-04 19:35:41 +01:00
Josh Creek fa9590d9c3 refactor(team-colors): collapse disagreeing team palettes into one source of truth
ship.gd, HUDController.gd, and goal.gd each declared their own TEAM_COLORS,
arena_boundary.gd/arena_deck.gdshader had a third pair, and HUD.tscn baked
in a fourth (hardcoded "BLUE"/"ORANGE" labels) — nose, goal rim, end zone,
and scoreboard all rendered different blues. New scripts/team_colors.gd
(class_name TeamColors) is now the single source every one of those reads
from, and team identity moves to purple/green.
2026-08-04 19:18:45 +01:00
Josh Creek ff2e40198f fix(arena): duplicate shared Environment before per-arena mutation
WorldEnvironment's Environment sub-resource was shared across every
instantiate() of a cached arena PackedScene, so glow/brightness/sky
tweaks in Arena._ready() compounded further each time a player
re-entered an arena instead of applying fresh.
2026-08-04 18:56:22 +01:00
Josh Creek c75e142c8a perf(physics): name and assign collision layers
Adds a [layer_names] section to project.godot (Ships/Ball/Arena/
GoalSensor) and sets collision_layer/collision_mask on the 4 physics
body roots (Ship, Ball, Goal Area3D, ArenaBoundary StaticBody3D),
which previously all sat on the default layer 1 / mask 1 so every
body broadphase-tested against every other.

Ships collide with ships/ball/arena but no longer test against the
goal sensor; the goal's mask (Ball only) is the physics-level fix for
what goal.gd's is_in_group("ball") check was doing defensively in
code (left in place, now a no-op guard). ArenaBoundary's runtime-
generated CollisionShape3D children inherit layer/mask from the
StaticBody3D root automatically.

Verified live via godot-mcp under Jolt Physics: ship-vs-ship and
ship-vs-ball collisions still transfer momentum, the ball still
bounces off arena walls, a ball entering a goal still fires
goal_scored (score updates / HUD reset), and a ship teleported into a
goal recess produces zero overlapping bodies on the goal Area3D (was
reachable before, physics-level fix confirmed, not just the code
guard). Headless smoke test (free_play.tscn) is clean.
2026-08-04 18:32:54 +01:00
Josh Creek 96ff503fa8 perf(ship): merge non-tinted hull meshes into one node
Hull/Canopy/EngineGlowL/EngineGlowR are runtime-baked into one ArrayMesh
in _ready via SurfaceTool.append_from, dropping 4 MeshInstance3D children
to 1 (Nose/TailFin stay separate, they're retinted per-team). Skipped in
headless mode like the goal/arena_boundary visual builds, since physics
only cares about CollisionShape3D.

All 6 source surfaces (hull, canopy, engine_l x2, engine_r x2) carry
distinct materials, so this doesn't literally cut draw calls 6 to 3 as
TODO.md assumed — Godot still issues one draw call per surface regardless
of node count. The real win is scene-tree/transform overhead, not batching.
2026-08-04 18:16:02 +01:00
Josh Creek 7b086fbd8f perf(ship): share per-team accent material instead of allocating per ship
_apply_team_color() allocated a fresh StandardMaterial3D on every call, and
ran at least twice per ship (once from _ready at the default team, once from
the team setter when the game mode assigns the real team). Cache one
StandardMaterial3D per team in a static dict on Ship and reuse it across
every ship on that team.
2026-08-04 17:53:15 +01:00
Josh Creek 0603452264 perf(goal): merge goal visuals into one ArrayMesh
Goal._build_visuals() built 10 MeshInstance3D nodes across 4 materials
(1 pocket + 1 net + 4 bezel-ring + 4 rim-ring boxes) per goal. Replaced
with a single MeshInstance3D wrapping one ArrayMesh with 4 SurfaceTool-
committed surfaces (pocket, net, bezel, rim), one material per surface
via surface_set_material — 10 nodes down to 1, same 4 materials.

Kept 4 surfaces rather than collapsing further: the rim is a tuned
team-tinted emitter, the net carries its own discard shader, and the
pocket/bezel differ in albedo/metallic/roughness. Merging those into a
shared material would be a visible regression, not a free win.

Added a local box-to-SurfaceTool helper (6 quads via arena_boundary.gd's
_add_quad/_add_tri winding-correction trick, copied in rather than
shared since that file's geometry is collision-adjacent). The pocket's
old cull_mode = CULL_FRONT trick is replaced by emitting its geometry
with inverted winding; the net's cull_front stays material-driven since
goal_net.gdshader's own render_mode depends on that winding convention.

Verified in the editor: both team-tinted goals render an intact pocket,
net, bezel and glowing rim with no backface/winding artifacts, and the
headless free_play smoke test still runs clean.
2026-08-04 17:48:39 +01:00
Josh Creek b01d5a68d9 perf(arena): cache the active camera lookup in ArenaBoundary
_process() called get_viewport().get_camera_3d() every frame to drive
the containment field's camera-side fade. Cache it the same way
ship_camera.gd caches the ball, revalidating with is_instance_valid
since exactly one camera rig is spawned per game-mode run today.
2026-08-04 17:36:37 +01:00
Josh Creek bb56f69edc perf(hud): stop flight instruments redrawing when values have settled
hud_gauge, hud_attitude_indicator, and hud_heading_tape now skip
queue_redraw() when the newly-lerped value hasn't moved past a small
epsilon, instead of redrawing every frame forever. Angle-wrapping
values (heading, attitude roll) use a new shared angle_delta_deg
helper on HudInstrument so the wrap boundary doesn't read as a false
jump.

HUD.tscn's Instruments node now sets process_mode = 1 (PAUSABLE),
overriding the inherited ALWAYS mode from the HUD root so instruments
stop processing during the post-match pause freeze, while sibling
ResultOverlay keeps running its win-screen tween.
2026-08-04 17:33:52 +01:00
Josh Creek fbb783b947 perf(ship): gate telemetry emission on listeners and headless mode
Ship._emit_telemetry_data() ran get_euler()+trig every physics tick
for every ship regardless of whether a HUD was watching, wasting work
on AI ships and every headless training instance. Disable
_physics_process outright when headless, and skip emission the rest
of the time unless a signal actually has a listener.
2026-08-04 15:15:43 +01:00
Josh Creek 08a0f74391 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.
2026-08-04 15:07:33 +01:00
2436 changed files with 553 additions and 413 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
- **Camera** (`scenes/ship_camera_rig.tscn`, `scripts/ship_camera.gd`, group `"ship_camera"`) is spawned by the game mode and given a `target` ship — ships have no camera/HUD dependency, so headless RL runs work (`godot --headless`).
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the ship, camera rig, and game mode via groups (`"ship"`, `"ship_camera"`, `"game"`), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
- **HUD / telemetry pattern**: `Ship` emits flight data via signals only when values change past thresholds (`_last_*` fields, `*_THRESHOLD` constants). `HUDController` (`scripts/HUDController.gd` on `scenes/HUD.tscn`, instanced by each mode's scene) discovers the camera rig and game mode via groups (`"ship_camera"`, `"game"`) but receives its target ship directly from the game mode via `GameMode.spawn_camera_rig` (mirroring the camera rig's `target`, not group lookup — the `"ship"` group can have 2+ members), connects to signals, and only updates labels — no polling. Follow this discovery-by-group + signal-push pattern for new instruments or cross-node communication, not hardcoded `get_node` paths or per-frame polling.
- **Input actions** are defined in `Game/project.godot` under `[input]` (`move_forward`, `turn_left`, `turbo`, `reset_ball`, etc.) and read only by `PlayerShipController` (plus mode-level `_unhandled_input` for `reset_ball`/`ui_cancel`) — add new controls there rather than hardcoding key checks.
- Physics engine is Jolt (`Game/project.godot`, `[physics] 3d/physics_engine="Jolt Physics"`).
File diff suppressed because one or more lines are too long
+2
View File
@@ -12,6 +12,8 @@ size = Vector3(1, 13, 38)
size = Vector3(26, 13, 1)
[node name="ArenaBoundary" type="StaticBody3D"]
collision_layer = 4
collision_mask = 3
script = ExtResource("1_bndry")
[node name="FloorShape" type="CollisionShape3D" parent="."]
+2
View File
@@ -10,6 +10,8 @@ bounce = 0.5
friction = 0.4
[node name="Ball" type="RigidBody3D" groups=["ball"]]
collision_layer = 2
collision_mask = 13
mass = 3
physics_material_override = SubResource("PhysicsMaterial_ball")
continuous_cd = true
+2
View File
@@ -6,6 +6,8 @@
size = Vector3(3.5, 1.5, 0.1)
[node name="Goal" type="Area3D"]
collision_layer = 8
collision_mask = 2
script = ExtResource("1_v8ikr")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
+2 -20
View File
@@ -1,12 +1,8 @@
[gd_scene load_steps=13 format=3 uid="uid://p07epxnh8wwp"]
[ext_resource type="Script" uid="uid://dyq1n7q1bqjps" path="res://scripts/ship.gd" id="1_efag7"]
[ext_resource type="ArrayMesh" path="res://assets/models/ship_hull.res" id="2_hull"]
[ext_resource type="ArrayMesh" path="res://assets/models/ship_nose.res" id="3_nose"]
[ext_resource type="ArrayMesh" path="res://assets/models/ship_canopy.res" id="4_canopy"]
[ext_resource type="ArrayMesh" path="res://assets/models/ship_tailfin.res" id="5_talfin"]
[ext_resource type="ArrayMesh" path="res://assets/models/ship_engine_l.res" id="6_enginel"]
[ext_resource type="ArrayMesh" path="res://assets/models/ship_engine_r.res" id="7_enginer"]
[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_ship"]
friction = 0.1
@@ -16,34 +12,20 @@ bounce = 0.15
size = Vector3(1, 1, 4)
[node name="Ship" type="RigidBody3D"]
collision_layer = 1
collision_mask = 7
mass = 5.0
physics_material_override = SubResource("PhysicsMaterial_ship")
inertia = Vector3(1, 1, 1)
script = ExtResource("1_efag7")
[node name="Hull" type="MeshInstance3D" parent="."]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
mesh = ExtResource("2_hull")
[node name="Nose" type="MeshInstance3D" parent="."]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
mesh = ExtResource("3_nose")
[node name="Canopy" type="MeshInstance3D" parent="."]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.31, -0.55)
mesh = ExtResource("4_canopy")
[node name="TailFin" type="MeshInstance3D" parent="."]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.24, 0.72)
mesh = ExtResource("5_talfin")
[node name="EngineGlowL" type="MeshInstance3D" parent="."]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -0.42, -0.05, 0.95)
mesh = ExtResource("6_enginel")
[node name="EngineGlowR" type="MeshInstance3D" parent="."]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0.42, -0.05, 0.95)
mesh = ExtResource("7_enginer")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_dsjou")
+7
View File
@@ -119,6 +119,13 @@ roll_right={
]
}
[layer_names]
3d_physics/layer_1/name="Ships"
3d_physics/layer_2/name="Ball"
3d_physics/layer_3/name="Arena"
3d_physics/layer_4/name="GoalSensor"
[physics]
3d/physics_engine="Jolt Physics"
+1 -6
View File
@@ -71,15 +71,12 @@ theme_override_constants/separation = 16
[node name="Team0Name" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_colors/font_color = Color(0.25, 0.55, 1, 1)
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
text = "BLUE"
[node name="Team0Score" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_colors/font_color = Color(0.25, 0.55, 1, 1)
theme_override_font_sizes/font_size = 32
horizontal_alignment = 1
text = "0"
@@ -93,7 +90,6 @@ horizontal_alignment = 1
[node name="Team1Score" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_colors/font_color = Color(1, 0.5, 0.15, 1)
theme_override_font_sizes/font_size = 32
horizontal_alignment = 1
text = "0"
@@ -101,10 +97,8 @@ text = "0"
[node name="Team1Name" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_colors/font_color = Color(1, 0.5, 0.15, 1)
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
text = "ORANGE"
[node name="KickoffLabel" type="Label" parent="Control"]
visible = false
@@ -171,6 +165,7 @@ text = "0 - 0"
horizontal_alignment = 1
[node name="Instruments" type="Control" parent="Control"]
process_mode = 1
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
+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)
+27 -14
View File
@@ -1,7 +1,9 @@
# 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.
# HUD Controller - receives its target ship from the game mode (see
# GameMode.spawn_camera_rig), discovers the camera rig/game mode via groups,
# and routes their signals to the display widgets. All flight data is
# rendered by aircraft-style instruments (attitude indicator, heading tape,
# bar gauges) — this node is only the wiring hub; the instruments are dumb
# displays.
extends CanvasLayer
class_name HUDController
@@ -24,10 +26,6 @@ class_name HUDController
@onready var thrust_bar: HudGauge = get_node_or_null("Control/Instruments/Cluster/ThrustBar")
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
# Team identity, mirroring TEAM_COLORS in ship.gd
const TEAM_NAMES := {0: "Blue", 1: "Orange"}
const TEAM_COLORS := {0: Color(0.25, 0.55, 1.0), 1: Color(1.0, 0.5, 0.15)}
var ship: Node
var _last_score := {0: 0, 1: 0}
@@ -37,10 +35,11 @@ func _ready():
_initialize_hud()
func _initialize_hud():
# Find the ship
ship = get_tree().get_first_node_in_group("ship")
# `ship` is assigned by the game mode (GameMode.spawn_camera_rig) before
# this runs — not discovered via group, since the "ship" group can have
# 2+ members and there's no reliable way to tell which one is "ours".
if not ship:
push_error("HUDController: No ship found in 'ship' group")
push_error("HUDController: No ship assigned")
return
print("HUDController: Found ship: ", ship.name)
@@ -66,6 +65,20 @@ func _initialize_hud():
if timer_label and is_instance_valid(timer_label):
timer_label.visible = has_timer
# Team identity (name + color) is fixed regardless of whether this
# mode tracks score, so set it here rather than baking it into the
# scene where it can't track TeamColors.
if team0_name_label and is_instance_valid(team0_name_label):
team0_name_label.text = TeamColors.TEAM_NAMES[0]
team0_name_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[0])
if team0_score_label and is_instance_valid(team0_score_label):
team0_score_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[0])
if team1_score_label and is_instance_valid(team1_score_label):
team1_score_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[1])
if team1_name_label and is_instance_valid(team1_name_label):
team1_name_label.text = TeamColors.TEAM_NAMES[1]
team1_name_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[1])
# Score display follows the same pattern: modes without scoring
# (e.g. free play) just don't show it
var has_score = game_manager and game_manager.has_signal("score_changed")
@@ -177,9 +190,9 @@ func _on_match_ended(winning_team: int, score: Dictionary):
result_label.remove_theme_color_override("font_color")
_set_result_panel_accent(Color(1, 1, 1, 0.2))
else:
result_label.text = "%s team wins!" % TEAM_NAMES[winning_team]
result_label.add_theme_color_override("font_color", TEAM_COLORS[winning_team])
_set_result_panel_accent(TEAM_COLORS[winning_team])
result_label.text = "%s team wins!" % TeamColors.TEAM_NAMES[winning_team]
result_label.add_theme_color_override("font_color", TeamColors.TEAM_COLORS[winning_team])
_set_result_panel_accent(TeamColors.TEAM_COLORS[winning_team])
final_score_label.text = "%d - %d" % [score.get(0, 0), score.get(1, 0)]
result_overlay.visible = true
_animate_result_panel_in()
+26 -1
View File
@@ -5,12 +5,37 @@ 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.duplicate(true) as Environment
world_env.environment = env
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:
+20 -3
View File
@@ -91,13 +91,16 @@ const DECK_SHADER_PATH := "res://shaders/arena_deck.gdshader"
@export var field_tint := Color(0.45, 0.65, 1.0)
@export var field_intensity := 0.09
@export var team0_tint := Color(0.15, 0.45, 1.0)
@export var team1_tint := Color(1.0, 0.35, 0.25)
@export var team0_tint := TeamColors.TEAM_COLORS[0]
@export var team1_tint := TeamColors.TEAM_COLORS[1]
# The single merged surface shell and the material whose camera-side fade
# _process() drives. Both stay null in headless runs, which never render.
var _shell: MeshInstance3D
var _field_material: ShaderMaterial
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
# pattern so the viewport lookup isn't repeated every frame.
var _camera: Camera3D
func _ready() -> void:
@@ -153,12 +156,26 @@ func _process(_delta: float) -> void:
# individually. Collision is untouched.
if _field_material == null:
return
var camera := get_viewport().get_camera_3d()
var camera := _get_camera()
if camera == null:
return # headless (RL/CI) has no camera
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
# Caches the viewport's active camera; a plain is_instance_valid revalidation
# is enough because exactly one ship_camera_rig (Camera3D) is spawned per
# game-mode run (GameMode.spawn_camera_rig, called once each from
# free_play.gd/match_mode.gd/spectate_mode.gd) and never re-spawned mid-match
# — there's no active-camera-switch scenario today. ArenaBoundary itself is
# destroyed/recreated per scene change, so the cache naturally resets with it;
# there's no stale-cache-across-scenes concern. Revisit this if split-screen
# or multiplayer camera-switching lands (see TODO.md's multiplayer section).
func _get_camera() -> Camera3D:
if not is_instance_valid(_camera):
_camera = get_viewport().get_camera_3d()
return _camera
# --- shared layout -----------------------------------------------------------
# Consumed by both the collider rings and the visual shell, so the two can
# never end up describing different geometry.
+36 -3
View File
@@ -11,13 +11,19 @@ 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 hud: HUDController
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
@@ -25,7 +31,8 @@ func _ready():
for child in get_children():
if child is Arena:
arena = child
break
elif child is HUDController:
hud = child
if not arena:
var path := _get_arena_scene_path()
if not path.is_empty():
@@ -94,9 +101,35 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
add_child(rig)
rig.target = target
# Also wires the scene's static HUD (if any) to the same ship, rather
# than letting it guess via the "ship" group.
if hud:
hud.ship = target
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 +174,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)
+105 -31
View File
@@ -10,9 +10,6 @@ extends Area3D
signal goal_scored(team: int)
# Frame tints, indexed by team.
const TEAM_COLORS := [Color(0.2, 0.55, 1.0), Color(1.0, 0.4, 0.28)]
# The pocket is sunk into the end wall, so it can be at most as deep as that
# wall is thick or it pokes out the back of the arena. ArenaBoundary cuts the
# matching aperture in the hull (see its GOAL_APERTURE_* constants).
@@ -52,50 +49,127 @@ func _on_body_entered(body):
func _build_visuals() -> void:
var mouth := ($CollisionShape3D.shape as BoxShape3D).size
var half := Vector2(mouth.x, mouth.y) / 2.0
var tint: Color = TEAM_COLORS[team % TEAM_COLORS.size()]
var tint: Color = TeamColors.TEAM_COLORS[team % TeamColors.TEAM_COLORS.size()]
# Pocket shell, seen from the inside (hence CULL_FRONT) so it reads as a
# void carved into the hull rather than a box stuck onto it.
var pocket := _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9)
pocket.cull_mode = BaseMaterial3D.CULL_FRONT
_add_box(
# One ArrayMesh, four surfaces (pocket, net, bezel, rim). They stay separate
# surfaces rather than sharing materials because they aren't visually
# interchangeable: the rim is a team-tinted emitter at a tuned energy, the
# net carries its own discard-based shader, and the pocket/bezel differ in
# albedo/metallic/roughness. Merging those would be a visual regression,
# not a free draw-call win — this still takes the goal from 10 nodes/4
# materials down to 1 node/4 materials.
var mesh := ArrayMesh.new()
# Pocket shell. Built with inverted winding so it reads correctly from the
# inside (a void carved into the hull) without needing a material-level
# CULL_FRONT override.
var pocket_st := SurfaceTool.new()
pocket_st.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_box_to_surface(pocket_st,
Vector3(mouth.x + POCKET_CLEARANCE * 2.0, mouth.y + POCKET_CLEARANCE * 2.0, POCKET_DEPTH),
Vector3(0, 0, POCKET_DEPTH / 2.0), pocket)
Vector3(0, 0, POCKET_DEPTH / 2.0), true)
pocket_st.commit(mesh)
mesh.surface_set_material(0, _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9))
# The net is the same trick: a box viewed from inside gives a five-sided
# pocket of netting from one mesh, instead of a flat panel across the back.
_add_box(Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88),
Vector3(0, 0, POCKET_DEPTH / 2.0), _net_material(tint))
# The net is the same trick, but the "seen from inside" flip is baked into
# goal_net.gdshader's own `render_mode cull_front` (and its FRONT_FACING
# normal flip), so this geometry must keep standard, non-inverted winding
# to match what that shader expects.
var net_st := SurfaceTool.new()
net_st.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_box_to_surface(net_st, Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88),
Vector3(0, 0, POCKET_DEPTH / 2.0))
net_st.commit(mesh)
mesh.surface_set_material(1, _net_material(tint))
# Bezel lines the opening (inset into the wall); the rim sits flush with
# the wall face and carries the team colour.
_add_frame_ring(half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0,
_surface(Color(0.05, 0.055, 0.07), 0.75, 0.32))
_add_frame_ring(half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0, _emissive(tint))
var bezel_st := SurfaceTool.new()
bezel_st.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_frame_ring(bezel_st, half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0)
bezel_st.commit(mesh)
mesh.surface_set_material(2, _surface(Color(0.05, 0.055, 0.07), 0.75, 0.32))
var rim_st := SurfaceTool.new()
rim_st.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_frame_ring(rim_st, half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0)
rim_st.commit(mesh)
mesh.surface_set_material(3, _emissive(tint))
var visuals := MeshInstance3D.new()
visuals.name = "Visuals"
visuals.mesh = mesh
visuals.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(visuals)
# Four bars around the mouth. The uprights run the full outer height so each
# corner is covered exactly once.
func _add_frame_ring(
half: Vector2, thickness: float, depth: float, z: float, material: Material
st: SurfaceTool, half: Vector2, thickness: float, depth: float, z: float
) -> void:
for sx in [-1.0, 1.0]:
_add_box(Vector3(thickness, (half.y + thickness) * 2.0, depth),
Vector3(sx * (half.x + thickness / 2.0), 0.0, z), material)
_add_box_to_surface(st, Vector3(thickness, (half.y + thickness) * 2.0, depth),
Vector3(sx * (half.x + thickness / 2.0), 0.0, z))
for sy in [-1.0, 1.0]:
_add_box(Vector3(half.x * 2.0, thickness, depth),
Vector3(0.0, sy * (half.y + thickness / 2.0), z), material)
_add_box_to_surface(st, Vector3(half.x * 2.0, thickness, depth),
Vector3(0.0, sy * (half.y + thickness / 2.0), z))
func _add_box(size: Vector3, pos: Vector3, material: Material) -> void:
var mesh := BoxMesh.new()
mesh.size = size
var instance := MeshInstance3D.new()
instance.mesh = mesh
instance.position = pos
instance.material_override = material
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(instance)
# Emits a box's 6 faces as quads into st, centered at pos. With invert, face
# winding/normals are flipped — used for the pocket so it renders correctly
# as seen from inside without a material-level CULL_FRONT override.
func _add_box_to_surface(
st: SurfaceTool, size: Vector3, pos: Vector3, invert: bool = false
) -> void:
var h := size / 2.0
var n := -1.0 if invert else 1.0
# The 8 corners of the box, named by the sign of each axis.
var nnn := pos + Vector3(-h.x, -h.y, -h.z)
var nnp := pos + Vector3(-h.x, -h.y, h.z)
var npn := pos + Vector3(-h.x, h.y, -h.z)
var npp := pos + Vector3(-h.x, h.y, h.z)
var pnn := pos + Vector3(h.x, -h.y, -h.z)
var pnp := pos + Vector3(h.x, -h.y, h.z)
var ppn := pos + Vector3(h.x, h.y, -h.z)
var ppp := pos + Vector3(h.x, h.y, h.z)
_add_quad(st, pnn, Vector3(n, 0, 0), ppn, Vector3(n, 0, 0), ppp, Vector3(n, 0, 0), pnp, Vector3(n, 0, 0))
_add_quad(st, nnn, Vector3(-n, 0, 0), nnp, Vector3(-n, 0, 0), npp, Vector3(-n, 0, 0), npn, Vector3(-n, 0, 0))
_add_quad(st, npn, Vector3(0, n, 0), npp, Vector3(0, n, 0), ppp, Vector3(0, n, 0), ppn, Vector3(0, n, 0))
_add_quad(st, nnn, Vector3(0, -n, 0), pnn, Vector3(0, -n, 0), pnp, Vector3(0, -n, 0), nnp, Vector3(0, -n, 0))
_add_quad(st, nnp, Vector3(0, 0, n), pnp, Vector3(0, 0, n), ppp, Vector3(0, 0, n), npp, Vector3(0, 0, n))
_add_quad(st, nnn, Vector3(0, 0, -n), npn, Vector3(0, 0, -n), ppn, Vector3(0, 0, -n), pnn, Vector3(0, 0, -n))
# Quad a-b-c-d with per-vertex normals, wound so the front faces the normals
# (Godot front faces wind clockwise when seen from the normal side). Copied
# from arena_boundary.gd's _add_quad/_add_tri rather than shared, since that
# file's geometry is collision-adjacent and not worth coupling to.
func _add_quad(
st: SurfaceTool,
a: Vector3, na: Vector3, b: Vector3, nb: Vector3,
c: Vector3, nc: Vector3, d: Vector3, nd: Vector3
) -> void:
if (b - a).cross(c - a).dot(na + nb + nc + nd) < 0.0:
_add_tri(st, a, na, b, nb, c, nc)
_add_tri(st, a, na, c, nc, d, nd)
else:
_add_tri(st, a, na, d, nd, c, nc)
_add_tri(st, a, na, c, nc, b, nb)
func _add_tri(
st: SurfaceTool,
a: Vector3, na: Vector3, b: Vector3, nb: Vector3, c: Vector3, nc: Vector3
) -> void:
st.set_normal(na)
st.add_vertex(a)
st.set_normal(nb)
st.add_vertex(b)
st.set_normal(nc)
st.add_vertex(c)
func _surface(albedo: Color, metallic: float, roughness: float) -> StandardMaterial3D:
+13 -11
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
@@ -28,15 +26,19 @@ func set_attitude(pitch_deg: float, roll_deg: float) -> void:
_target_roll = roll_deg
# Below this, both axes have visually settled — skip the redraw.
const REDRAW_EPSILON_DEG := 0.05
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))
var t := _smoothing_weight(delta)
var new_pitch := lerpf(_pitch, _target_pitch, t)
var new_roll := lerp_angle_deg(_roll, _target_roll, t)
var changed := absf(new_pitch - _pitch) > REDRAW_EPSILON_DEG \
or absf(angle_delta_deg(_roll, new_roll)) > REDRAW_EPSILON_DEG
_pitch = new_pitch
_roll = new_roll
if changed:
queue_redraw()
func _draw() -> void:
+10 -6
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,9 +25,15 @@ func set_value(value: float) -> void:
func _process(delta: float) -> void:
var t := 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
_value = lerpf(_value, _target, t)
queue_redraw()
var t := _smoothing_weight(delta)
var new_value := lerpf(_value, _target, t)
# Redraw threshold scales with the gauge's own range so it stays
# imperceptible whether this is a 0-12 altitude gauge or a 0-100 thrust
# bar, while skipping the redraw once the lerp has visually settled.
var changed := absf(new_value - _value) > max_value * 0.001
_value = new_value
if changed:
queue_redraw()
func _draw() -> void:
+10 -5
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"}
@@ -27,10 +26,16 @@ func set_heading(heading_deg: float) -> void:
_target_heading = heading_deg
# Below this, the tape has visually settled — skip the redraw.
const REDRAW_EPSILON_DEG := 0.05
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()
var t := _smoothing_weight(delta)
var new_heading := fmod(lerp_angle_deg(_heading, _target_heading, t) + 360.0, 360.0)
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
_heading = new_heading
if changed:
queue_redraw()
func _draw() -> void:
+25
View File
@@ -0,0 +1,25 @@
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))
# Shortest signed distance from `from` to `to` in degrees, wrapped to
# [-180, 180]. Use this (never a plain subtraction) to decide whether an
# angle that wraps around (heading, roll) has moved enough to redraw —
# a naive delta gives a false-large jump at the 0/360 boundary.
static func angle_delta_deg(from: float, to: float) -> float:
return wrapf(to - from, -180.0, 180.0)
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},
+20 -15
View File
@@ -27,9 +27,10 @@ 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
var _match_over := false
var _last_timer_remaining := -1
func _get_arena_scene_path() -> String:
@@ -56,28 +57,25 @@ 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):
if match_timer and match_timer.time_left > 0:
var remaining := ceili(match_timer.time_left)
timer_updated.emit(floori(remaining / 60.0), remaining % 60)
if remaining != _last_timer_remaining:
_last_timer_remaining = remaining
timer_updated.emit(floori(remaining / 60.0), remaining % 60)
func _on_goal_scored(conceding_team: int) -> void:
if _match_over:
return
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)
@@ -90,6 +88,8 @@ func _on_goal_scored(conceding_team: int) -> void:
# changes to ship.gd/ball.gd/ship_controller.gd, keeping training_mode.gd
# (which never calls into this file) completely untouched.
func _run_kickoff_countdown() -> void:
if _match_over:
return
reset_ball()
reset_ships()
_set_frozen(true)
@@ -98,8 +98,12 @@ func _run_kickoff_countdown() -> void:
# process_always=false: if full-time fires mid-countdown (see
# _on_match_timer_timeout's get_tree().paused = true), this stalls
# harmlessly in lockstep with the pause instead of ticking a
# countdown label over the results screen.
# countdown label over the results screen. _end_match unpauses the
# tree before the scene teardown actually lands, so the _match_over
# check below is what stops this from resuming into a dying scene.
await get_tree().create_timer(1.0, false).timeout
if _match_over:
return
kickoff_countdown.emit(0)
_set_frozen(false)
@@ -129,6 +133,7 @@ func _start_overtime() -> void:
func _end_match(winning_team: int) -> void:
_match_over = true
print("Match over! Final score: %d - %d" % [score[0], score[1]])
match_ended.emit(winning_team, score.duplicate())
# Freeze gameplay while the HUD (process_mode ALWAYS) shows the result.
@@ -136,4 +141,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)
+18 -27
View File
@@ -4,46 +4,37 @@ extends ShipController
# Drives a Ship from the local player's input actions (see project.godot
# [input] and FLIGHT_MANUAL.md).
var _action := ShipAction.new()
func get_action() -> ShipAction:
var action := ShipAction.new()
# Full overwrite per axis (not +=/-=): _action is reused across ticks, so
# fields must not depend on starting from a fresh Vector3.ZERO each call.
# Forward/Backward thrust (main engines)
if Input.is_action_pressed("move_forward"):
action.thrust.z += 1.0
if Input.is_action_pressed("move_back"):
action.thrust.z -= 1.0
_action.thrust.z = (1.0 if Input.is_action_pressed("move_forward") else 0.0) \
- (1.0 if Input.is_action_pressed("move_back") else 0.0)
# Strafe thrusters (left/right)
if Input.is_action_pressed("move_left"):
action.thrust.x -= 1.0
if Input.is_action_pressed("move_right"):
action.thrust.x += 1.0
_action.thrust.x = (1.0 if Input.is_action_pressed("move_right") else 0.0) \
- (1.0 if Input.is_action_pressed("move_left") else 0.0)
# Vertical thrusters (up/down)
if Input.is_action_pressed("move_up"):
action.thrust.y += 1.0
if Input.is_action_pressed("move_down"):
action.thrust.y -= 1.0
_action.thrust.y = (1.0 if Input.is_action_pressed("move_up") else 0.0) \
- (1.0 if Input.is_action_pressed("move_down") else 0.0)
# Yaw (turn left/right around Y axis)
if Input.is_action_pressed("turn_left"):
action.rotation.y += 1.0
if Input.is_action_pressed("turn_right"):
action.rotation.y -= 1.0
_action.rotation.y = (1.0 if Input.is_action_pressed("turn_left") else 0.0) \
- (1.0 if Input.is_action_pressed("turn_right") else 0.0)
# Pitch (nose up/down around X axis)
if Input.is_action_pressed("pitch_up"):
action.rotation.x -= 1.0
if Input.is_action_pressed("pitch_down"):
action.rotation.x += 1.0
_action.rotation.x = (1.0 if Input.is_action_pressed("pitch_down") else 0.0) \
- (1.0 if Input.is_action_pressed("pitch_up") else 0.0)
# Roll (bank left/right around Z axis)
if Input.is_action_pressed("roll_left"):
action.rotation.z += 1.0
if Input.is_action_pressed("roll_right"):
action.rotation.z -= 1.0
_action.rotation.z = (1.0 if Input.is_action_pressed("roll_left") else 0.0) \
- (1.0 if Input.is_action_pressed("roll_right") else 0.0)
action.turbo = Input.is_action_pressed("turbo")
_action.turbo = Input.is_action_pressed("turbo")
return action
return _action
+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:
+94 -16
View File
@@ -25,12 +25,31 @@ extends RigidBody3D
@export var ceiling_pull_strength = 11.5 # Ceiling grav-plating strength; nets above gravity so a ship can hold a ceiling
@export var ceiling_pull_range = 3.0 # Metres from the ceiling where pull begins
# Accent colours per team, applied to the nose and tail fin meshes so the
# two sides are tellable apart at a glance.
const TEAM_COLORS := {
0: Color(0.25, 0.55, 1.0),
1: Color(1.0, 0.5, 0.15),
}
# Non-tinted hull meshes, runtime-merged into one ArrayMesh by
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
# _apply_team_color() retints them per-team and must keep addressing them by
# name). Verified via get_surface_count()/surface_get_material() before
# writing this: hull and canopy are each a single surface with their own
# distinct opaque StandardMaterial3D (canopy is NOT alpha/transparent despite
# the name), and engine_l/engine_r are each 2 surfaces, also all distinct
# materials — none of the 6 source surfaces share a material with any other,
# including the L/R engine pair. So this merge does not collapse draw calls
# the way TODO.md's "6 draw calls down to 3" assumed (Godot still issues one
# draw call per surface regardless of how many MeshInstance3Ds they're spread
# across); the real win is scene-tree node count, 4 MeshInstance3D children
# down to 1, cutting per-frame transform/visibility overhead.
const MERGED_MESH_PATHS := [
"res://assets/models/ship_hull.res",
"res://assets/models/ship_canopy.res",
"res://assets/models/ship_engine_l.res",
"res://assets/models/ship_engine_r.res",
]
const MERGED_MESH_TRANSFORMS := [
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0, 0, 0)), # Hull
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0, 0.31, -0.55)), # Canopy
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(-0.42, -0.05, 0.95)), # EngineGlowL
Transform3D(Basis(Vector3(-1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, -1)), Vector3(0.42, -0.05, 0.95)), # EngineGlowR
]
# Which team this ship plays for (0 or 1). Set by the game mode on spawn.
var team: int = 0:
@@ -38,8 +57,29 @@ var team: int = 0:
team = value
_apply_team_color()
# Shared per-team accent material, built once per team and reused by every
# ship — avoids allocating a fresh StandardMaterial3D from both _ready and
# the team setter (previously ran at least twice per ship).
static var _team_materials: Dictionary = {} # team:int -> StandardMaterial3D
static func _get_team_material(team: int) -> StandardMaterial3D:
if _team_materials.has(team):
return _team_materials[team]
var color: Color = TeamColors.TEAM_COLORS.get(team, TeamColors.TEAM_COLORS[0])
var accent := StandardMaterial3D.new()
accent.albedo_color = color
accent.metallic = 0.3
accent.roughness = 0.5
accent.emission_enabled = true
accent.emission = color
accent.emission_energy_multiplier = 0.35
_team_materials[team] = accent
return accent
var controller: ShipController
var _current_action: ShipAction = ShipAction.new()
var _inert_action: ShipAction = ShipAction.new()
var _boundary: ArenaBoundary
# Instrument signals for efficient data distribution
@@ -83,24 +123,51 @@ func _ready():
_boundary = get_tree().get_first_node_in_group("arena_boundary")
# Telemetry emission and the merged-hull visual mesh are both render-only
# work; headless (RL/CI) instances never render or have a HUD watching
# them, so skip both rather than relying on later no-ops.
if DisplayServer.get_name() == "headless":
set_physics_process(false)
else:
_build_merged_hull()
func _apply_team_color() -> void:
if not is_inside_tree():
return
var color: Color = TEAM_COLORS.get(team, TEAM_COLORS[0])
var accent := StandardMaterial3D.new()
accent.albedo_color = color
accent.metallic = 0.3
accent.roughness = 0.5
accent.emission_enabled = true
accent.emission = color
accent.emission_energy_multiplier = 0.35
var accent := _get_team_material(team)
for mesh_name in ["Nose", "TailFin"]:
var mesh := get_node_or_null(mesh_name) as MeshInstance3D
if mesh:
mesh.material_override = accent
# Runtime-bakes Hull/Canopy/EngineGlowL/EngineGlowR (see MERGED_MESH_PATHS
# comment above) into one ArrayMesh, one destination surface per source
# surface via SurfaceTool.append_from, each keeping its own original
# material — preserves current visuals exactly regardless of surface count.
# Mirrors the runtime-bake pattern already used by goal.gd/arena_boundary.gd.
# Skipped in headless mode (see _ready): purely visual, costs nothing
# physics/RL cares about.
func _build_merged_hull() -> void:
var mesh := ArrayMesh.new()
var dest_idx := 0
for i in MERGED_MESH_PATHS.size():
var src: ArrayMesh = load(MERGED_MESH_PATHS[i])
var xform: Transform3D = MERGED_MESH_TRANSFORMS[i]
for surf in src.get_surface_count():
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
st.append_from(src, surf, xform)
st.commit(mesh)
mesh.surface_set_material(dest_idx, src.surface_get_material(surf))
dest_idx += 1
var instance := MeshInstance3D.new()
instance.name = "MergedHull"
instance.mesh = mesh
add_child(instance)
# Attach the node that drives this ship (player, AI, or network). Replaces
# any existing controller; parents the new one under the ship if needed.
func set_controller(new_controller: ShipController) -> void:
@@ -112,12 +179,23 @@ func set_controller(new_controller: ShipController) -> void:
func _physics_process(_delta):
_emit_telemetry_data()
if _has_telemetry_listeners():
_emit_telemetry_data()
# Covers a second/AI ship with no HUD watching it - headless mode is already
# handled by disabling _physics_process entirely in _ready.
func _has_telemetry_listeners() -> bool:
return speed_changed.get_connections().size() > 0 \
or altitude_changed.get_connections().size() > 0 \
or attitude_changed.get_connections().size() > 0 \
or heading_changed.get_connections().size() > 0 \
or thrust_changed.get_connections().size() > 0
func _integrate_forces(state):
# One action per physics tick, pulled from the controller (deterministic)
_current_action = controller.get_action() if controller else ShipAction.new()
_current_action = controller.get_action() if controller else _inert_action
# === TRANSLATION (Movement) ===
apply_thruster_forces(state, _current_action)
+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()
+10
View File
@@ -0,0 +1,10 @@
class_name TeamColors
# Single source of truth for team identity. Ship nose/tailfin, HUD result
# panel, goal rim/net, and the arena end-zone floor shader all read from
# here — do not restate these values locally (see TODO.md history).
const TEAM_NAMES := {0: "Purple", 1: "Green"}
const TEAM_COLORS := {
0: Color(0.48, 0.18, 0.88),
1: Color(0.24, 0.86, 0.42),
}
+1
View File
@@ -0,0 +1 @@
uid://c6r8q1dpd8klq
+6 -2
View File
@@ -21,8 +21,12 @@ uniform vec3 panel_color : source_color = vec3(0.104, 0.116, 0.146);
uniform float hull_fill : hint_range(0.0, 2.0) = 0.55;
uniform float panel_variation : hint_range(0.0, 1.0) = 0.4;
uniform vec3 line_color : source_color = vec3(0.62, 0.78, 1.0);
uniform vec3 team0_color : source_color = vec3(0.15, 0.45, 1.0);
uniform vec3 team1_color : source_color = vec3(1.0, 0.35, 0.25);
// Must track TeamColors.TEAM_COLORS in scripts/team_colors.gd — shaders can't
// import GDScript consts, and arena_boundary.gd always overrides these at
// runtime, but keep the literal defaults in sync for anyone inspecting the
// shader/material directly.
uniform vec3 team0_color : source_color = vec3(0.48, 0.18, 0.88);
uniform vec3 team1_color : source_color = vec3(0.24, 0.86, 0.42);
uniform vec3 seam_color : source_color = vec3(0.35, 0.75, 1.0);
// Play-volume dimensions, from ArenaBoundary's constants.
-22
View File
@@ -14,33 +14,11 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
Bugs found in an adversarial review. None are gameplay- or physics-affecting, so all are safe to land against the current `Game/bots/` checkpoints.
- [ ] `VideoSettings.apply_to_environment()` (`scripts/video_settings.gd`) compounds on every arena load: `env.glow_intensity *= glow_scale` mutates an `Environment` that is a `[sub_resource]` of the arena scene, and Godot shares sub-resources across instantiations of a cached `PackedScene`. Glow at 50% becomes 25% then 12.5% across repeat entries. Fix by duplicating the Environment in `Arena._ready()` (`scripts/arena.gd`). Regression test: set glow to 50%, enter/leave Free Play three times, confirm it's still 50%.
- [ ] Collapse the four disagreeing team palettes into one source of truth — `ship.gd`, `HUDController.gd` and `goal.gd` each declare `TEAM_COLORS`, `arena_boundary.gd` exports `team0_tint`/`team1_tint`, and `arena_deck.gdshader` defaults to a fifth pair. Three of them disagree, so nose, goal rim, end zone and scoreboard are all different blues.
- [ ] Goal scoring volume (3.5 x 1.5, `objects/goal.tscn`) is smaller than the drawn mouth (3.7 x 1.65, `ArenaBoundary.GOAL_APERTURE_*`) — a ball crossing the visible edge doesn't score. Derive the aperture constants from the goal's collision shape, the way `goal.gd:53` already measures its own visuals.
- [ ] `match_mode.gd`: full time can fire mid-kickoff-countdown, and the stalled coroutine resumes into the dying scene (can re-emit `kickoff_countdown` / unfreeze bodies for a frame). Guard `_run_kickoff_countdown` with a match-over flag.
- [ ] `match_mode.gd` emits `timer_updated` every frame for a value that changes once a second; the HUD re-formats and re-shapes the label each time. Emit only on change, matching `ship.gd`'s threshold-gated telemetry discipline.
- [ ] `HUDController` binds to `get_first_node_in_group("ship")` in a group that always has 2+ members — works only because the player ship happens to spawn first. Have the game mode hand the HUD its target ship.
- [ ] Reuse a member `ShipAction` in `ship.gd` (controllerless path) and `player_ship_controller.gd` instead of allocating one per physics tick; `ai_ship_controller.gd` already does this correctly.
- [ ] Delete the duplicate 1 MB texture — `assets/textures/planet_surface.png` and `assets/models/nebula_planet_planet_surface.png` are byte-identical.
## 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).
## Performance
- [ ] Gate `Ship._emit_telemetry_data()` — it runs `get_euler()` + trig per ship per physics tick for every ship, including AI ships nobody displays and `--headless` training where no HUD exists. Gate on having signal connections, and `set_physics_process(false)` when headless (`arena_boundary.gd` already does this correctly for its `_process`).
- [ ] Stop the HUD instruments redrawing once settled: all five `queue_redraw()` every frame forever, re-recording canvas items with `draw_string` glyph work, and `HUD.tscn` sets `process_mode = 3` so it continues while paused.
- [ ] Shared per-team materials instead of `Ship._apply_team_color()` allocating a fresh `StandardMaterial3D` and assigning it as `material_override` (and running at least twice per ship — once from `_ready`, once from the `team` setter). Removes the allocation and lets same-team ships batch.
- [ ] Merge each goal's visuals into one `ArrayMesh` with a hull surface and a net surface — currently 11 `MeshInstance3D`s and 4 materials per goal, ~22 draw calls for a static prop. `arena_boundary.gd:333` already demonstrates the `SurfaceTool` technique in this codebase.
- [ ] Merge the four non-tinted ship meshes (Hull/Canopy/EngineGlowL/R) into one — 6 draw calls per ship down to 3. Irrelevant at 1v1; 36 calls before VFX at 3v3.
- [ ] Cache the camera in `ArenaBoundary._process` instead of a `get_viewport().get_camera_3d()` tree lookup every frame (`ship_camera.gd` already caches the ball this way).
- [ ] Name collision layers in `project.godot` and assign them — nothing configures `collision_layer`/`collision_mask` today, so every body tests against every other.
- [ ] Measure `nebula_dust.gdshader`'s per-fragment depth-texture sample across 500 large soft billboards before adding more particle work.
- [ ] Bake `ArenaBoundary`'s ~160 runtime-generated `CollisionShape3D` nodes into the scene. Costs a load hitch on every arena entry and repeats in every parallel headless training env. **Blocked on the trained-bot decision below**`arena_boundary.gd:235` notes this geometry is what the shipped policies were fitted against, so the bake must be verified byte-identical.

Some files were not shown because too many files have changed in this diff Show More