Compare commits

...

7 Commits

Author SHA1 Message Date
Josh Creek 401d882ff0 fix(assets): remove unreferenced duplicate planet_surface texture
assets/textures/planet_surface.png was byte-identical to and unused
in favor of assets/models/nebula_planet_planet_surface.png, the
sidecar Godot's glTF importer actually extracted from nebula_planet.glb
and uses at runtime. Deleted the orphan (+ its .import) and stopped
gen_planet_surface.py from writing it.

Also measured nebula_dust.gdshader's per-fragment depth-texture sample
cost (~0.07ms/frame at 500 particles, within noise) -- negligible, so
no budgeting concern for further particle work.
2026-08-05 09:51:33 +01:00
Josh Creek 9bdeb73fa7 docs: mark trained-bot compatibility items resolved in TODO.md
Records the drag/aperture/collider-bake/beyond-1v1 items as done, with a
short note on what actually shipped (scoped up to 5v5 mid-implementation)
and the explicit non-goals (no 2v2+ curriculum, no team-size UI) so it's
clear what's still open.
2026-08-05 09:18:08 +01:00
Josh Creek 3049c42867 feat(training): support N-vs-M matches with persistent per-ship spawn IDs
Extends ShipObservations beyond the old self+1-opponent layout to padded
teammate/opponent arrays (MAX_TEAMMATES=4, MAX_OPPONENTS=5, SIZE=83),
zero-filling slots past the real roster size the same way the old single-
opponent slot was zero-filled when absent.

Slot stability across ticks requires a persistent identity: Ship gains
spawn_index (set once by GameMode.spawn_ship, never reassigned — there's no
despawn path anywhere in this codebase, so a roster is fixed for the whole
episode/match). ai_ship_controller.gd's opponent discovery is rewritten from
"first non-self ship" to classify every other ship by team and sort by
spawn_index; training_mode.gd/ship_ai_controller.gd carry the equivalent
sorted lists through the training path so both agree on slot assignment for
the same roster.

training_mode.gd and match_mode.gd both gain a team_size export (default 1,
so every existing curriculum script and match keeps today's 1v1 behaviour
unchanged). This is plumbing only: no 2v2+ curriculum or reward design, and
no match-mode UI to pick team size, has been done yet. The two checkpoints
in Game/bots/promoted/ are fitted to the old 35-float layout and are not
migrated — expected to go stale until the next training run.
2026-08-05 09:17:56 +01:00
Josh Creek 18fdb0f232 feat(arena): resize for 5v5, cut a real goal-wall hole, and bake collision geometry
Retraining from scratch removes the constraint that blocked these: the
existing checkpoints in Game/bots/promoted/ no longer need byte-identical
geometry.

- Fix the goal aperture constants (were 1.85/1.65, now match
  objects/goal.tscn's actual sensor exactly at 1.75/1.5) so a ball crossing
  the visible edge can't fail to score.
- Cut a real navigable hole in each end wall's collision
  (_build_end_wall_colliders), replacing the previous solid box — the ball
  now genuinely enters the net instead of triggering the sensor a hair
  before hitting a solid wall. Correct for both FLOOR and ELEVATED goal
  modes via _goal_surround_bounds. A separate, slightly wider
  GOAL_VISUAL_APERTURE_* pair keeps the collision hole exact while still
  giving goal.gd's bezel/rim frame clearance to be seen against the hull cut.
- Resize the play volume 1.5x (INNER_HALF_X/Z/HEIGHT 12/18/12 -> 18/27/18) to
  comfortably fit a 5v5 roster: updates every hand-authored literal in
  arena_boundary.tscn/arena_base.tscn/the elevated arena variants that
  doesn't derive from those constants, adds 5 spawn markers per team, and
  rescales ship_observations.gd's normalization scales and arena_02's
  cosmetic decoration/nebula-dust shader uniforms to match.
- Bake the ~170 runtime-generated CollisionShape3D nodes into the scene via
  a new bake_colliders()/tools/bake_arena_boundary.gd instead of rebuilding
  them on every load — a real load-time cost repeated in every parallel
  headless training env. Colliders must be direct children of the
  StaticBody3D to register at all, so bake_colliders() parents them onto
  self and tags them with a group for idempotent re-baking, rather than
  grouping them under an intermediate container node. _ready() self-heals:
  it skips regenerating only when an existing bake's goal_mode metadata
  matches the current one, so the FLOOR-mode bake in the shared scene is
  never silently reused by an ELEVATED arena variant.
2026-08-05 09:17:13 +01:00
Josh Creek a02e0770af fix(training): avoid ship-ship overlap when placing a multi-ship roster
_place_ships_random/_place_air_drill sampled each ship's randomized episode-
start position independently, so a team_size > 1 roster could spawn
interpenetrating (ships are ~1x1x4). Both now resample (up to 20 attempts,
matching the existing corner/fillet rejection-sampling pattern) against
every ship already placed that reset, rejecting anything within
MIN_SHIP_SEPARATION (4.5m, matching the arena spawn-marker spacing) of one.
2026-08-05 09:16:35 +01:00
Josh Creek 661c588fef fix(game-mode): recover ships/ball that escape through an open goal in every mode
The goal mouths are now a real navigable hole in the end walls, sized to the
ball rather than the ship — a ship's 1x1 cross-section fits through it, and
there's nothing behind the net to stop it. The escape failsafe previously
only existed in TrainingMode (where a physics regression just wastes
training time); now that any ship can genuinely fly out through an open
goal, every mode needs it or a stray ship/ball falls into the void with no
way back short of quitting. Moved up to GameMode as the shared default
_physics_process, removing TrainingMode's now-duplicate copy.
2026-08-05 09:16:00 +01:00
Josh Creek 0f7603d3cb fix(ship): delta-scale drag so it stays correct at any physics tick rate
drag_coefficient/angular_drag/the idle angular-drag multiplier were applied
once per physics tick with no delta scaling, correct only because
project.godot never pins physics/common/physics_ticks_per_second and
Godot's default happens to be 60. _tick_scaled(k, state.step) makes the
decay rate invariant to tick rate instead. Also promotes the previously
hardcoded 0.9 idle angular-drag literal to an export, matching its sibling.
2026-08-05 09:13:52 +01:00
20 changed files with 1698 additions and 250 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bxsadfd04v6r2"
path="res://.godot/imported/planet_surface.png-d3aaf8310508e4c85b26a3d093bcf9c8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/textures/planet_surface.png"
dest_files=["res://.godot/imported/planet_surface.png-d3aaf8310508e4c85b26a3d093bcf9c8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -8,13 +8,13 @@
goal_mode = 1 goal_mode = 1
[node name="GoalTeam0" parent="." index="5"] [node name="GoalTeam0" parent="." index="5"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.0, 27)
[node name="GoalTeam1" parent="." index="6"] [node name="GoalTeam1" parent="." index="6"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18) transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 9.0, -27)
[node name="Spawn1" parent="SpawnsTeam0" index="0"] [node name="Spawn1" parent="SpawnsTeam0" index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.0, 2.8, 20.25)
[node name="Spawn1" parent="SpawnsTeam1" index="0"] [node name="Spawn1" parent="SpawnsTeam1" index="0"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5) transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -6.0, 2.8, -20.25)
+12 -12
View File
@@ -32,7 +32,7 @@ gradient = SubResource("Gradient_dust")
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_dust"] [sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_dust"]
emission_shape = 3 emission_shape = 3
emission_box_extents = Vector3(26, 11, 28) emission_box_extents = Vector3(39, 16.5, 42)
direction = Vector3(0, 1, 0) direction = Vector3(0, 1, 0)
spread = 180.0 spread = 180.0
initial_velocity_min = 0.1 initial_velocity_min = 0.1
@@ -68,48 +68,48 @@ light_color = Color(0.55, 0.35, 0.65, 1)
light_energy = 0.45 light_energy = 0.45
[node name="NebulaDust" type="GPUParticles3D" parent="."] [node name="NebulaDust" type="GPUParticles3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 7.5, 0)
amount = 500 amount = 500
lifetime = 22.0 lifetime = 22.0
preprocess = 8.0 preprocess = 8.0
randomness = 1.0 randomness = 1.0
visibility_aabb = AABB(-30, -14, -32, 60, 28, 64) visibility_aabb = AABB(-45, -21, -48, 90, 42, 96)
process_material = SubResource("ParticleProcessMaterial_dust") process_material = SubResource("ParticleProcessMaterial_dust")
draw_pass_1 = SubResource("QuadMesh_dust") draw_pass_1 = SubResource("QuadMesh_dust")
[node name="Decoration" type="Node3D" parent="."] [node name="Decoration" type="Node3D" parent="."]
[node name="NebulaStationA" parent="Decoration" instance=ExtResource("8_station")] [node name="NebulaStationA" parent="Decoration" instance=ExtResource("8_station")]
transform = Transform3D(1.8, 0, 0, 0, 1.8, 0, 0, 0, 1.8, 28, 5, -6) transform = Transform3D(1.8, 0, 0, 0, 1.8, 0, 0, 0, 1.8, 42, 7.5, -9)
rotation = Vector3(0, -0.436332, 0) rotation = Vector3(0, -0.436332, 0)
[node name="NebulaDebrisA" parent="Decoration" instance=ExtResource("9_debris")] [node name="NebulaDebrisA" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(1.2, 0, 0, 0, 1.2, 0, 0, 0, 1.2, -22, 9, 11) transform = Transform3D(1.2, 0, 0, 0, 1.2, 0, 0, 0, 1.2, -33, 13.5, 16.5)
rotation = Vector3(0.261799, 0.698132, 0.087266) rotation = Vector3(0.261799, 0.698132, 0.087266)
[node name="NebulaDebrisB" parent="Decoration" instance=ExtResource("9_debris")] [node name="NebulaDebrisB" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(0.8, 0, 0, 0, 0.8, 0, 0, 0, 0.8, 34, 3, 15) transform = Transform3D(0.8, 0, 0, 0, 0.8, 0, 0, 0, 0.8, 51, 4.5, 22.5)
rotation = Vector3(-0.174533, 1.919862, 0.349066) rotation = Vector3(-0.174533, 1.919862, 0.349066)
[node name="NebulaDebrisC" parent="Decoration" instance=ExtResource("9_debris")] [node name="NebulaDebrisC" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, -30, 11, -21) transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, -45, 16.5, -31.5)
rotation = Vector3(0.087266, -1.047198, -0.261799) rotation = Vector3(0.087266, -1.047198, -0.261799)
[node name="NebulaStationB" parent="Decoration" instance=ExtResource("8_station")] [node name="NebulaStationB" parent="Decoration" instance=ExtResource("8_station")]
transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, -32, 7, 9) transform = Transform3D(1.5, 0, 0, 0, 1.5, 0, 0, 0, 1.5, -48, 10.5, 13.5)
rotation = Vector3(0.05, 1.134464, -0.02) rotation = Vector3(0.05, 1.134464, -0.02)
[node name="NebulaStationC" parent="Decoration" instance=ExtResource("8_station")] [node name="NebulaStationC" parent="Decoration" instance=ExtResource("8_station")]
transform = Transform3D(1.3, 0, 0, 0, 1.3, 0, 0, 0, 1.3, 9, 8, 42) transform = Transform3D(1.3, 0, 0, 0, 1.3, 0, 0, 0, 1.3, 13.5, 12, 63)
rotation = Vector3(-0.03, 2.740167, 0.06) rotation = Vector3(-0.03, 2.740167, 0.06)
[node name="NebulaDebrisD" parent="Decoration" instance=ExtResource("9_debris")] [node name="NebulaDebrisD" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(1.0, 0, 0, 0, 1.0, 0, 0, 0, 1.0, 13, 15, -35) transform = Transform3D(1.0, 0, 0, 0, 1.0, 0, 0, 0, 1.0, 19.5, 22.5, -52.5)
rotation = Vector3(0.4, 1.3, -0.2) rotation = Vector3(0.4, 1.3, -0.2)
[node name="NebulaDebrisE" parent="Decoration" instance=ExtResource("9_debris")] [node name="NebulaDebrisE" parent="Decoration" instance=ExtResource("9_debris")]
transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -11, 4, 33) transform = Transform3D(0.9, 0, 0, 0, 0.9, 0, 0, 0, 0.9, -16.5, 6, 49.5)
rotation = Vector3(-0.3, -0.9, 0.15) rotation = Vector3(-0.3, -0.9, 0.15)
[node name="NebulaPlanet" parent="Decoration" instance=ExtResource("11_planet")] [node name="NebulaPlanet" parent="Decoration" instance=ExtResource("11_planet")]
transform = Transform3D(30, 0, 0, 0, 30, 0, 0, 0, 30, -55, 38, -80) transform = Transform3D(30, 0, 0, 0, 30, 0, 0, 0, 30, -82.5, 57, -120)
+4 -4
View File
@@ -8,13 +8,13 @@
goal_mode = 1 goal_mode = 1
[node name="GoalTeam0" parent="." index="5"] [node name="GoalTeam0" parent="." index="5"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.0, 27)
[node name="GoalTeam1" parent="." index="6"] [node name="GoalTeam1" parent="." index="6"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18) transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 9.0, -27)
[node name="Spawn1" parent="SpawnsTeam0" index="0"] [node name="Spawn1" parent="SpawnsTeam0" index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.0, 2.8, 20.25)
[node name="Spawn1" parent="SpawnsTeam1" index="0"] [node name="Spawn1" parent="SpawnsTeam1" index="0"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5) transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -6.0, 2.8, -20.25)
+4 -4
View File
@@ -8,13 +8,13 @@
goal_mode = 1 goal_mode = 1
[node name="GoalTeam0" parent="." index="5"] [node name="GoalTeam0" parent="." index="5"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6.0, 18) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.0, 27)
[node name="GoalTeam1" parent="." index="6"] [node name="GoalTeam1" parent="." index="6"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 6.0, -18) transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 9.0, -27)
[node name="Spawn1" parent="SpawnsTeam0" index="0"] [node name="Spawn1" parent="SpawnsTeam0" index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.0, 2.8, 13.5) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.0, 2.8, 20.25)
[node name="Spawn1" parent="SpawnsTeam1" index="0"] [node name="Spawn1" parent="SpawnsTeam1" index="0"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.0, 2.8, -13.5) transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -6.0, 2.8, -20.25)
+30 -6
View File
@@ -53,16 +53,16 @@ shadow_enabled = false
environment = SubResource("Environment_base") environment = SubResource("Environment_base")
[node name="ReflectionProbe" type="ReflectionProbe" parent="."] [node name="ReflectionProbe" type="ReflectionProbe" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 6, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9, 0)
size = Vector3(26, 14, 40) size = Vector3(38, 20, 58)
box_projection = true box_projection = true
update_mode = 0 update_mode = 0
[node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")] [node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 27)
[node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")] [node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18) transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -27)
team = 1 team = 1
[node name="BallSpawn" type="Marker3D" parent="."] [node name="BallSpawn" type="Marker3D" parent="."]
@@ -71,9 +71,33 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
[node name="SpawnsTeam0" type="Node3D" parent="."] [node name="SpawnsTeam0" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"] [node name="Spawn1" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 13.5) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9, 2.8, 20.25)
[node name="Spawn2" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4.5, 2.8, 20.25)
[node name="Spawn3" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.8, 20.25)
[node name="Spawn4" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.5, 2.8, 20.25)
[node name="Spawn5" type="Marker3D" parent="SpawnsTeam0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 2.8, 20.25)
[node name="SpawnsTeam1" type="Node3D" parent="."] [node name="SpawnsTeam1" type="Node3D" parent="."]
[node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"] [node name="Spawn1" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -13.5) transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -9, 2.8, -20.25)
[node name="Spawn2" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, -4.5, 2.8, -20.25)
[node name="Spawn3" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 2.8, -20.25)
[node name="Spawn4" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 4.5, 2.8, -20.25)
[node name="Spawn5" type="Marker3D" parent="SpawnsTeam1"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 9, 2.8, -20.25)
+29 -9
View File
@@ -35,7 +35,8 @@ var _action := ShipAction.new()
var _ticks_until_decision := 0 var _ticks_until_decision := 0
var _ship: Ship var _ship: Ship
var _opponent: Ship var _teammates: Array[Ship] = []
var _opponents: Array[Ship] = []
var _ball: RigidBody3D var _ball: RigidBody3D
var _attack_goal_position: Vector3 var _attack_goal_position: Vector3
var _scene_refs_ready := false var _scene_refs_ready := false
@@ -60,7 +61,7 @@ func get_action() -> ShipAction:
func _decide() -> void: func _decide() -> void:
var obs := ShipObservations.build(_ship, _opponent, _ball, _attack_goal_position) var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
var out := _policy.forward(obs) var out := _policy.forward(obs)
# See ShipActionCodec for the decode — the single source of truth shared # See ShipActionCodec for the decode — the single source of truth shared
# with the training side, so this must never reimplement layout/ordering # with the training side, so this must never reimplement layout/ordering
@@ -76,21 +77,40 @@ func _decide() -> void:
_action = ShipActionCodec.from_logits(out, action_noise) _action = ShipActionCodec.from_logits(out, action_noise)
# Find ship/ball/opponent/goal once everything is spawned. ShipAction axes # Find ship/ball/teammates/opponents/goal once everything is spawned.
# are body-frame so only observations need team context (ShipObservations). # ShipAction axes are body-frame so only observations need team context
# (ShipObservations). Rosters never change mid-match (no despawn path exists
# anywhere in this codebase), so this only needs to run once — sorted by
# spawn_index so a given ship keeps the same observation slot for the whole
# match, matching TrainingMode's identically-sorted lists.
func _discover_scene_refs() -> bool: func _discover_scene_refs() -> bool:
_ship = get_parent() as Ship _ship = get_parent() as Ship
if _ship == null or not is_inside_tree(): if _ship == null or not is_inside_tree():
return false return false
_ball = get_tree().get_first_node_in_group("ball") _ball = get_tree().get_first_node_in_group("ball")
if _ball == null:
return false
# Cleared, not just appended to: if an earlier call reached this point but
# a later check still failed, a retry must not re-append onto whatever it
# already collected — that would duplicate every ship in the roster.
_teammates.clear()
_opponents.clear()
for node in get_tree().get_nodes_in_group("ship"): for node in get_tree().get_nodes_in_group("ship"):
if node != _ship: var other := node as Ship
_opponent = node if other == _ship:
break continue
if other.team == _ship.team:
_teammates.append(other)
else:
_opponents.append(other)
_teammates.sort_custom(_by_spawn_index)
_opponents.sort_custom(_by_spawn_index)
for goal in get_tree().get_nodes_in_group("goal"): for goal in get_tree().get_nodes_in_group("goal"):
if goal.team == 1 - _ship.team: if goal.team == 1 - _ship.team:
_attack_goal_position = goal.global_position _attack_goal_position = goal.global_position
if _ball == null:
return false
_scene_refs_ready = true _scene_refs_ready = true
return true return true
static func _by_spawn_index(a: Ship, b: Ship) -> bool:
return a.spawn_index < b.spawn_index
+139 -20
View File
@@ -5,9 +5,9 @@ extends StaticBody3D
# instances objects/arena_boundary.tscn so all arenas share one size; code # instances objects/arena_boundary.tscn so all arenas share one size; code
# that needs field dimensions derives them from these constants rather than # that needs field dimensions derives them from these constants rather than
# restating numbers. # restating numbers.
const INNER_HALF_X := 12.0 const INNER_HALF_X := 18.0
const INNER_HALF_Z := 18.0 const INNER_HALF_Z := 27.0
const INNER_HEIGHT := 12.0 const INNER_HEIGHT := 18.0
# Goal-centre distance from arena centre. Flush with the end walls (see # Goal-centre distance from arena centre. Flush with the end walls (see
# arena_01.tscn's goal transforms), so there is no floating gap between the # arena_01.tscn's goal transforms), so there is no floating gap between the
# goal and the wall for a ball to ramp across before reaching the sensor. # goal and the wall for a ball to ramp across before reaching the sensor.
@@ -45,14 +45,30 @@ const BASE_RADIUS := 2.0
# The end-wall fillets stop short of the goal mouth so floor-level shots # The end-wall fillets stop short of the goal mouth so floor-level shots
# roll flat into the goal sensor (3.5 m wide) instead of ramping over it. # roll flat into the goal sensor (3.5 m wide) instead of ramping over it.
const GOAL_MOUTH_HALF_WIDTH := 2.5 const GOAL_MOUTH_HALF_WIDTH := 2.5
# The aperture the visual shell leaves for the goal itself, and the opaque # The real navigable hole in the wall's collision (see
# bulkhead surrounding it. Mirrors objects/goal.tscn's 3.5 x 1.5 sensor with a # _build_end_wall_colliders) — must track objects/goal.tscn's BoxShape3D
# little clearance, so no sliver of panel shows through the goal frame. The # (3.5 x 1.5, i.e. half-width 1.75, height 1.5) exactly. Anything wider than
# surround has to be opaque hull: backed by the translucent field panel # the scoring sensor lets the ball cross the wall opening without entering
# the sensor and failing to score; this is collision-authoritative, do not
# widen it for cosmetic reasons — see GOAL_VISUAL_APERTURE_* below for that.
# Half-width matches the sensor exactly; height is a few cm off in FLOOR mode
# (the sensor is vertically offset by the goal node's own y=0.79 placement,
# while the hole itself is measured from the floor at y=0 — see
# _goal_surround_bounds), which is immaterial at the ball's radius but means
# "exact match" isn't literally true.
const GOAL_APERTURE_HALF_WIDTH := 1.75
const GOAL_APERTURE_HEIGHT := 1.5
# The (larger) aperture the visual shell/opaque bulkhead actually cuts —
# deliberately wider than the true collision hole above, so goal.gd's bezel/
# rim frame (built starting exactly at the sensor's own half-extents) has
# clearance to be seen against the hull's cut edge instead of sitting flush
# with it. Cosmetic only: collision still uses the exact GOAL_APERTURE_*
# pair above, so this can't reopen the "ball crosses without scoring" bug.
const GOAL_VISUAL_APERTURE_HALF_WIDTH := GOAL_APERTURE_HALF_WIDTH + 0.1
const GOAL_VISUAL_APERTURE_HEIGHT := GOAL_APERTURE_HEIGHT + 0.15
# The surround has to be opaque hull: backed by the translucent field panel
# instead, the goal's recess and net showed straight through the wall beside # instead, the goal's recess and net showed straight through the wall beside
# the mouth and read as a second, duplicated net. # the mouth and read as a second, duplicated net.
const GOAL_APERTURE_HALF_WIDTH := 1.85
const GOAL_APERTURE_HEIGHT := 1.65
# ELEVATED only — in FLOOR mode the surround spans the deck to the fillet # ELEVATED only — in FLOOR mode the surround spans the deck to the fillet
# tangent, so its height is BASE_RADIUS and needs no constant. # tangent, so its height is BASE_RADIUS and needs no constant.
const GOAL_SURROUND_HALF_HEIGHT := 1.6 const GOAL_SURROUND_HALF_HEIGHT := 1.6
@@ -102,12 +118,28 @@ var _field_material: ShaderMaterial
# pattern so the viewport lookup isn't repeated every frame. # pattern so the viewport lookup isn't repeated every frame.
var _camera: Camera3D var _camera: Camera3D
# Group every generated collider is tagged with. A CollisionShape3D only
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
# earlier version of this code grouped generated colliders under an
# intermediate Node3D container for identification, which silently made
# every one of them inert (no shape ever reached the StaticBody3D). They must
# be direct children of `self`; the group tag is how bake_colliders()
# identifies (and clears, for idempotent re-baking) its own prior output
# without an intermediate node.
const GENERATED_COLLIDER_GROUP := "_arena_generated_collider"
func _ready() -> void: func _ready() -> void:
add_to_group("arena_boundary") add_to_group("arena_boundary")
_build_corner_colliders() # goal_mode affects the generated geometry itself (see _fillet_runs'
_build_fillet_colliders(BASE_FILLET) # `split` and _build_end_wall_colliders' hole placement), but it's an
_build_fillet_colliders(CEILING_FILLET) # instance-level override each arena_0X_elevated.tscn applies to the
# shared arena_boundary.tscn's Boundary node — so a bake done in FLOOR
# mode (the shared .tscn's default) must not be silently reused by an
# ELEVATED instance. Only skip regenerating when the existing bake was
# actually made in the current goal_mode.
if get_meta("baked_goal_mode", -1) != goal_mode:
bake_colliders()
# Visuals are pure decoration and training spawns many headless instances # Visuals are pure decoration and training spawns many headless instances
# that never render one, so skip the mesh build there entirely. Collision # that never render one, so skip the mesh build there entirely. Collision
# is unaffected either way. # is unaffected either way.
@@ -117,6 +149,29 @@ func _ready() -> void:
_build_visual_shell() _build_visual_shell()
# Builds every generated collider (corner curves, base/ceiling fillets, end-
# wall goal holes) as direct children of this ArenaBoundary (required for
# them to register with the StaticBody3D at all) and records the goal_mode
# they were built for. Idempotent: clears any of its own prior output first
# (identified via GENERATED_COLLIDER_GROUP), so calling this repeatedly — a
# stale bake whose goal_mode doesn't match (see _ready), or re-running
# tools/bake_arena_boundary.gd on an already-baked scene — replaces rather
# than duplicates. _ready() calls this itself for any scene that hasn't been
# baked yet, so behaviour is identical either way; baking only skips redoing
# this work on every subsequent load, which matters most for the many
# parallel headless training envs that would otherwise pay it on every
# episode reset.
func bake_colliders() -> void:
for child in get_children():
if child.is_in_group(GENERATED_COLLIDER_GROUP):
child.free()
_build_corner_colliders()
_build_fillet_colliders(BASE_FILLET)
_build_fillet_colliders(CEILING_FILLET)
_build_end_wall_colliders()
set_meta("baked_goal_mode", goal_mode)
# Wall+ceiling-only proximity force field ("artificial gravity" grav-plating, # Wall+ceiling-only proximity force field ("artificial gravity" grav-plating,
# weaker than the floor's plain default gravity, which this deliberately # weaker than the floor's plain default gravity, which this deliberately
# leaves untouched). Ship and Ball each call this with their own # leaves untouched). Ship and Ball each call this with their own
@@ -248,8 +303,10 @@ func _fillet_runs(rise: float) -> Array[Dictionary]:
# --- collision --------------------------------------------------------------- # --- collision ---------------------------------------------------------------
# These build the tangent-box collider rings only. Their geometry is what # Builds the tangent-box collider rings and each end wall's real goal hole.
# trained policies in Game/bots/ were fitted against, so it must not change. # Geometry is free to evolve now that bots are retrained from scratch — just
# re-run tools/bake_arena_boundary.gd afterward so the baked scene (see
# bake_colliders) reflects the change.
# Vertical quarter-cylinder curves across the four wall-wall corners. # Vertical quarter-cylinder curves across the four wall-wall corners.
@@ -332,9 +389,65 @@ func _add_curve_collider(
box.size = Vector3(SURFACE_THICKNESS, run_length, face_width) box.size = Vector3(SURFACE_THICKNESS, run_length, face_width)
collision.shape = box collision.shape = box
collision.transform = Transform3D(segment_basis, origin) collision.transform = Transform3D(segment_basis, origin)
collision.add_to_group(GENERATED_COLLIDER_GROUP)
add_child(collision) add_child(collision)
# A plain axis-aligned box collider centred on `center`. Used for the flat
# end-wall panels below, which aren't tangent to a curve so don't need
# _add_curve_collider's outward/along framing.
func _add_box_collider(center: Vector3, size: Vector3) -> void:
var collision := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
collision.shape = box
collision.transform = Transform3D(Basis.IDENTITY, center)
collision.add_to_group(GENERATED_COLLIDER_GROUP)
add_child(collision)
# One rectangular end-wall panel spanning x in [x0, x1] and y in [y0, y1] at
# the given wall_z, SURFACE_THICKNESS deep. No-ops for a degenerate (zero or
# negative area) range, which happens whenever the goal hole's aperture
# extends to the wall's own boundary (e.g. FLOOR mode's aperture bottom sits
# at the wall's own y0).
func _add_end_wall_panel(wall_z: float, x0: float, x1: float, y0: float, y1: float) -> void:
if x1 <= x0 or y1 <= y0:
return
_add_box_collider(
Vector3((x0 + x1) / 2.0, (y0 + y1) / 2.0, wall_z),
Vector3(x1 - x0, y1 - y0, SURFACE_THICKNESS)
)
# Real navigable hole in each end wall, sized to the goal's actual scoring
# aperture (GOAL_APERTURE_HALF_WIDTH/HEIGHT, which now track
# objects/goal.tscn's sensor — see that constant's comment) instead of the
# previous single solid box that let the ball trigger the goal sensor but
# never actually fly through the wall. _goal_surround_bounds() already knows
# the aperture's vertical placement for both FLOOR (floor-level) and ELEVATED
# (raised) goal modes, so this works unmodified for either. Left/right panels
# carry the full wall height; top/bottom panels close off the remainder of
# the aperture band above/below the hole. In FLOOR mode the bottom panel
# (y -1..0) is a harmless duplicate — FloorShape already occupies that
# region — rather than a true no-op: _add_end_wall_panel still emits it,
# since aperture_bottom (0) is above y_bottom (-1). Left in rather than
# special-cased since it costs one extra collider and can't create a gap.
func _build_end_wall_colliders() -> void:
var half_width := INNER_HALF_X + 1.0
var y_bottom := -1.0
var y_top := INNER_HEIGHT
var bounds := _goal_surround_bounds(GOAL_APERTURE_HEIGHT)
var aperture_bottom: float = bounds.z
var aperture_top: float = bounds.w
for sz in [-1.0, 1.0]:
var wall_z: float = sz * (INNER_HALF_Z + 0.5)
_add_end_wall_panel(wall_z, -half_width, -GOAL_APERTURE_HALF_WIDTH, y_bottom, y_top)
_add_end_wall_panel(wall_z, GOAL_APERTURE_HALF_WIDTH, half_width, y_bottom, y_top)
_add_end_wall_panel(wall_z, -GOAL_APERTURE_HALF_WIDTH, GOAL_APERTURE_HALF_WIDTH, aperture_top, y_top)
_add_end_wall_panel(wall_z, -GOAL_APERTURE_HALF_WIDTH, GOAL_APERTURE_HALF_WIDTH, y_bottom, aperture_bottom)
# --- visual shell ------------------------------------------------------------ # --- visual shell ------------------------------------------------------------
# One mesh covering the inner surface of the play volume exactly once. Every # One mesh covering the inner surface of the play volume exactly once. Every
# piece is cut to meet its neighbours edge-on, so no two translucent surfaces # piece is cut to meet its neighbours edge-on, so no two translucent surfaces
@@ -444,21 +557,27 @@ func _add_goal_aprons(st: SurfaceTool) -> void:
# panel, the recess and net behind it showed through the wall and the net read # panel, the recess and net behind it showed through the wall and the net read
# as duplicated either side of the frame. # as duplicated either side of the frame.
func _add_goal_surrounds(st: SurfaceTool) -> void: func _add_goal_surrounds(st: SurfaceTool) -> void:
var bounds := _goal_surround_bounds() var bounds := _goal_surround_bounds(GOAL_VISUAL_APERTURE_HEIGHT)
for side in [-1.0, 1.0]: for side in [-1.0, 1.0]:
_add_aperture_panel(st, _add_aperture_panel(st,
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP, Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
Vector3(0, 0, -side), GOAL_MOUTH_HALF_WIDTH, bounds.x, bounds.y, Vector3(0, 0, -side), GOAL_MOUTH_HALF_WIDTH, bounds.x, bounds.y,
GOAL_APERTURE_HALF_WIDTH, bounds.z, bounds.w) GOAL_VISUAL_APERTURE_HALF_WIDTH, bounds.z, bounds.w)
# (surround bottom, surround top, aperture bottom, aperture top) on the end # (surround bottom, surround top, aperture bottom, aperture top) on the end
# wall. In FLOOR mode the surround spans deck to fillet tangent with the goal # wall. In FLOOR mode the surround spans deck to fillet tangent with the goal
# sitting on the deck; in ELEVATED it is a band centred on the raised goal. # sitting on the deck; in ELEVATED it is a band centred on the raised goal.
func _goal_surround_bounds() -> Vector4: # `aperture_height` is the caller's choice of GOAL_APERTURE_HEIGHT (the true,
# collision-matching size — see _build_end_wall_colliders) or
# GOAL_VISUAL_APERTURE_HEIGHT (the cosmetically-widened cut — see
# _add_goal_surrounds); it only affects the returned aperture bottom/top
# (z/w), not the surround bottom/top (x/y), which are independent of it in
# both modes.
func _goal_surround_bounds(aperture_height: float) -> Vector4:
if goal_mode == GoalMode.FLOOR: if goal_mode == GoalMode.FLOOR:
return Vector4(0.0, BASE_RADIUS, 0.0, GOAL_APERTURE_HEIGHT) return Vector4(0.0, BASE_RADIUS, 0.0, aperture_height)
var half := GOAL_APERTURE_HEIGHT / 2.0 var half := aperture_height / 2.0
return Vector4( return Vector4(
GOAL_CENTER_Y - GOAL_SURROUND_HALF_HEIGHT, GOAL_CENTER_Y - GOAL_SURROUND_HALF_HEIGHT,
GOAL_CENTER_Y + GOAL_SURROUND_HALF_HEIGHT, GOAL_CENTER_Y + GOAL_SURROUND_HALF_HEIGHT,
@@ -485,7 +604,7 @@ func _add_wall_panels(st: SurfaceTool) -> void:
var y0 := BASE_RADIUS var y0 := BASE_RADIUS
var y1 := INNER_HEIGHT - BASE_RADIUS var y1 := INNER_HEIGHT - BASE_RADIUS
var elevated := goal_mode == GoalMode.ELEVATED var elevated := goal_mode == GoalMode.ELEVATED
var bounds := _goal_surround_bounds() var bounds := _goal_surround_bounds(GOAL_VISUAL_APERTURE_HEIGHT)
for side in [-1.0, 1.0]: for side in [-1.0, 1.0]:
_add_aperture_panel(st, _add_aperture_panel(st,
Vector3(side * INNER_HALF_X, 0, 0), Vector3(0, 0, side), Vector3.UP, Vector3(side * INNER_HALF_X, 0, 0), Vector3(0, 0, side), Vector3.UP,
+35
View File
@@ -90,6 +90,7 @@ func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = nu
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
ship.global_transform = spawn_transform ship.global_transform = spawn_transform
ship.team = team ship.team = team
ship.spawn_index = spawn_index
if controller: if controller:
ship.set_controller(controller) ship.set_controller(controller)
ships.append(ship) ships.append(ship)
@@ -175,3 +176,37 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
func _unhandled_input(event): func _unhandled_input(event):
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU) get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
# Escape failsafe. The arena is meant to be fully enclosed, but the goal
# mouths are now a real navigable hole in the end walls (see
# ArenaBoundary._build_end_wall_colliders) sized to the ball, not the ship —
# a ship's 1x1 cross-section fits through it, and there is nothing behind the
# net to stop it. Previously this only existed in TrainingMode (a physics
# regression there just wastes training time); now that any ship can
# genuinely fly out through an open goal, every mode needs it, or a stray
# ship/ball falls into the void with no way back short of quitting. Runs by
# default every tick; TrainingMode overrides _physics_process entirely and
# calls this itself alongside its own episode logic.
const ESCAPE_MARGIN := 15.0
func _physics_process(_delta: float) -> void:
_respawn_escaped_bodies()
func _respawn_escaped_bodies() -> void:
for ship in ships:
if is_instance_valid(ship) and _is_escaped(ship.global_position):
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
_reset_body(ship, _ship_spawn_transforms[ship])
if is_instance_valid(ball) and _is_escaped(ball.global_position):
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
_reset_body(ball, arena.get_ball_spawn())
func _is_escaped(position: Vector3) -> bool:
return absf(position.x) > ArenaBoundary.INNER_HALF_X + ESCAPE_MARGIN \
or absf(position.z) > ArenaBoundary.INNER_HALF_Z + ESCAPE_MARGIN \
or position.y < -ESCAPE_MARGIN \
or position.y > ArenaBoundary.INNER_HEIGHT + ESCAPE_MARGIN
+10 -1
View File
@@ -20,6 +20,12 @@ const KICKOFF_COUNTDOWN_SECONDS := 3
@export var match_length_seconds := 150.0 @export var match_length_seconds := 150.0
# Ships per team. The player always controls one ship on team 0; every other
# ship (teammates and the whole opposing team) is AI-controlled — no local
# multiplayer input. Plumbing only for this pass: there's no menu UI yet to
# pick a team size above 1.
@export_range(1, 5) var team_size: int = 1
@export_group("AI opponent") @export_group("AI opponent")
# Trained policy for the opponent; empty = inert placeholder ship. # Trained policy for the opponent; empty = inert placeholder ship.
@export_file("*.json") var bot_model_path: String = "" @export_file("*.json") var bot_model_path: String = ""
@@ -41,7 +47,10 @@ func _start() -> void:
spawn_ball() spawn_ball()
var player_ship := spawn_ship(0, 0, PlayerShipController.new()) var player_ship := spawn_ship(0, 0, PlayerShipController.new())
spawn_camera_rig(player_ship) spawn_camera_rig(player_ship)
spawn_ship(1, 0, _make_opponent_controller()) for i in range(1, team_size):
spawn_ship(0, i, _make_opponent_controller())
for i in team_size:
spawn_ship(1, i, _make_opponent_controller())
await _run_kickoff_countdown() await _run_kickoff_countdown()
+22 -4
View File
@@ -18,6 +18,7 @@ extends RigidBody3D
@export var max_angular_speed = 3.0 # Maximum rotation speed @export var max_angular_speed = 3.0 # Maximum rotation speed
@export var drag_coefficient = 0.98 # Linear drag (air resistance) @export var drag_coefficient = 0.98 # Linear drag (air resistance)
@export var angular_drag = 0.95 # Rotational drag @export var angular_drag = 0.95 # Rotational drag
@export var idle_angular_drag = 0.9 # Rotational drag when no rotation input is held
@export_group("Surface Pull") @export_group("Surface Pull")
@export var wall_pull_strength = 6.0 # Wall grav-plating strength (m/s^2-equivalent) @export var wall_pull_strength = 6.0 # Wall grav-plating strength (m/s^2-equivalent)
@@ -57,6 +58,13 @@ var team: int = 0:
team = value team = value
_apply_team_color() _apply_team_color()
# This ship's index within its team's roster (0, 1, 2, ...), set once by
# GameMode.spawn_ship and never changed afterward. The stable identity
# AIShipController/ShipAIController sort teammates/opponents by, so both
# training and in-game inference assign the same ship to the same
# observation-vector slot for the whole match (see ShipObservations).
var spawn_index: int = -1
# Shared per-team accent material, built once per team and reused by every # Shared per-team accent material, built once per team and reused by every
# ship — avoids allocating a fresh StandardMaterial3D from both _ready and # ship — avoids allocating a fresh StandardMaterial3D from both _ready and
# the team setter (previously ran at least twice per ship). # the team setter (previously ran at least twice per ship).
@@ -279,12 +287,22 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
state.apply_torque(torque) state.apply_torque(torque)
# Scales a per-tick decay multiplier `k` (defined at a 60 Hz reference rate)
# by the actual elapsed tick time `step`, so `v *= _tick_scaled(k, step)`
# decays at the same rate per second regardless of physics_ticks_per_second.
func _tick_scaled(k: float, step: float) -> float:
return pow(k, step * 60.0)
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3): func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
# Linear drag (air resistance) # Linear drag (air resistance)
# Physics: F_drag = -½ * ρ * v² * C_d * A (drag force equation) # Physics: F_drag = -½ * ρ * v² * C_d * A (drag force equation)
# Simplified: v_new = v_old * drag_coefficient (exponential decay) # Simplified: v_new = v_old * drag_coefficient (exponential decay)
# This simulates air resistance reducing velocity over time # This simulates air resistance reducing velocity over time.
state.linear_velocity *= drag_coefficient # _tick_scaled makes the decay rate invariant to the physics tick rate —
# drag_coefficient/angular_drag/idle_angular_drag are all defined as the
# per-tick multiplier at a 60 Hz reference rate.
state.linear_velocity *= _tick_scaled(drag_coefficient, state.step)
# Angular drag (rotational resistance) # Angular drag (rotational resistance)
# Physics: Similar to linear drag but for rotational motion # Physics: Similar to linear drag but for rotational motion
@@ -292,10 +310,10 @@ func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vect
# Simplified: ω_new = ω_old * angular_drag (exponential decay) # Simplified: ω_new = ω_old * angular_drag (exponential decay)
if rotation_input.length() < 0.01: if rotation_input.length() < 0.01:
# More drag when not actively rotating to stop quicker # More drag when not actively rotating to stop quicker
state.angular_velocity *= 0.9 state.angular_velocity *= _tick_scaled(idle_angular_drag, state.step)
else: else:
# Normal drag when actively rotating # Normal drag when actively rotating
state.angular_velocity *= angular_drag state.angular_velocity *= _tick_scaled(angular_drag, state.step)
# Limit maximum speeds # Limit maximum speeds
# Physics: Terminal velocity concept - maximum achievable speed # Physics: Terminal velocity concept - maximum achievable speed
+10 -5
View File
@@ -106,7 +106,8 @@ const MAX_BALL_DISTANCE := sqrt(
var ship: Ship var ship: Ship
var rl_controller: RLShipController var rl_controller: RLShipController
var ball: RigidBody3D var ball: RigidBody3D
var opponent: Ship var teammates: Array[Ship] = []
var opponents: Array[Ship] = []
var attack_goal_position: Vector3 var attack_goal_position: Vector3
# Set directly by TrainingMode (_on_goal_scored / the timeout branch in # Set directly by TrainingMode (_on_goal_scored / the timeout branch in
@@ -146,12 +147,16 @@ var _air_touches := 0
# Wire up references after the ship is spawned. `attack_goal` is the goal # Wire up references after the ship is spawned. `attack_goal` is the goal
# this ship scores into (goal.team == opponent's team). # this ship scores into (goal.team == the opposing team's team).
func setup(p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D, p_opponent: Ship, p_attack_goal_position: Vector3) -> void: func setup(
p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D,
p_teammates: Array[Ship], p_opponents: Array[Ship], p_attack_goal_position: Vector3
) -> void:
ship = p_ship ship = p_ship
rl_controller = p_rl_controller rl_controller = p_rl_controller
ball = p_ball ball = p_ball
opponent = p_opponent teammates = p_teammates
opponents = p_opponents
attack_goal_position = p_attack_goal_position attack_goal_position = p_attack_goal_position
init(ship) init(ship)
@@ -161,7 +166,7 @@ func setup(p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D,
func get_obs() -> Dictionary: func get_obs() -> Dictionary:
return {"obs": ShipObservations.build(ship, opponent, ball, attack_goal_position)} return {"obs": ShipObservations.build(ship, teammates, opponents, ball, attack_goal_position)}
func get_reward() -> float: func get_reward() -> float:
+60 -28
View File
@@ -11,13 +11,13 @@ extends RefCounted
# The same rotation must be inverted when interpreting actions (see canon — # The same rotation must be inverted when interpreting actions (see canon —
# it is its own inverse). # it is its own inverse).
# Normalization scales. Standard arena volume (see ArenaBoundary): x ±12, # Normalization scales. Standard arena volume (see ArenaBoundary): x ±18,
# z ±18, height 12, goals at z ±18 (flush with the end walls); positions are # z ±27, height 18, goals at z ±27 (flush with the end walls); positions are
# soft-normalized to roughly [-1, 1]. Do not retune without retraining every # soft-normalized to roughly [-1, 1]. Do not retune without retraining every
# model in Game/bots/. # model in Game/bots/.
const POSITION_SCALE := Vector3(20.0, 10.0, 20.0) const POSITION_SCALE := Vector3(30.0, 15.0, 30.0)
const BALL_SPEED_SCALE := 30.0 const BALL_SPEED_SCALE := 30.0
const GOAL_DISTANCE_SCALE := 40.0 const GOAL_DISTANCE_SCALE := 60.0
# Contact normals with y above this are floor contact; below it they read as # Contact normals with y above this are floor contact; below it they read as
# wall (sideways) or ceiling (downward) — mirrors # wall (sideways) or ceiling (downward) — mirrors
@@ -26,13 +26,23 @@ const GOAL_DISTANCE_SCALE := 40.0
# counts as "in contact" for the reward/observation to stay consistent). # counts as "in contact" for the reward/observation to stay consistent).
const FLOOR_NORMAL_MIN_Y := 0.7 const FLOOR_NORMAL_MIN_Y := 0.7
# Number of floats build() returns; the policy input size. APPEND-ONLY: new # Fixed roster caps for the padded teammate/opponent slots below — the
# features go on the end and existing indices never move, so an old exported # largest supported match size is 5v5. Slots beyond the real teammate/
# model (whose network was trained against a shorter SIZE) still decodes its # opponent count are zero-filled, mirroring the old single-opponent's
# first N inputs identically when SIZE grows — see PolicyNetwork.forward's # null-zero-fill (see build()). Callers must pass teammates/opponents already
# input_size slice/guard. Do not retune an *existing* index without # sorted by Ship.spawn_index, so a given ship occupies the same slot in every
# retraining every model in Game/bots/. # tick's observation for the whole match, in both training and in-game
const SIZE := 35 # inference (see AIShipController._discover_scene_refs /
# TrainingMode._start).
const MAX_TEAMMATES := 4
const MAX_OPPONENTS := 5
# Number of floats build() returns; the policy input size.
# 15 (own) + 6 (ball) + 6*MAX_TEAMMATES + 6*MAX_OPPONENTS + 4 (goal) + 4 (contact)
# Game/bots/promoted/*.json were exported against the old single-opponent,
# SIZE=35 layout and are not migrated — this is a from-scratch retrain, so
# those checkpoints are expected to go stale rather than keep decoding.
const SIZE := 15 + 6 + 6 * MAX_TEAMMATES + 6 * MAX_OPPONENTS + 4 + 4
# 180° rotation about Y for team 1; identity for team 0. A proper rotation # 180° rotation about Y for team 1; identity for team 0. A proper rotation
@@ -43,8 +53,14 @@ static func canon(v: Vector3, team: int) -> Vector3:
# attack_goal_position: centre of the goal this ship is trying to score in # attack_goal_position: centre of the goal this ship is trying to score in
# (the goal whose `team` == the opponent's team). # (the goal whose `team` == the opponent's team). teammates/opponents must
static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_position: Vector3) -> Array: # already be sorted by Ship.spawn_index (ascending) by the caller — see
# MAX_TEAMMATES/MAX_OPPONENTS's comment for why slot stability matters; this
# function only pads/truncates to the fixed cap, it doesn't sort.
static func build(
ship: Ship, teammates: Array[Ship], opponents: Array[Ship],
ball: RigidBody3D, attack_goal_position: Vector3
) -> Array:
var team := ship.team var team := ship.team
var obs := [] var obs := []
@@ -60,27 +76,23 @@ static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_pos
_append(obs, canon(ball_rel, team) / POSITION_SCALE) _append(obs, canon(ball_rel, team) / POSITION_SCALE)
_append(obs, canon(ball.linear_velocity, team) / BALL_SPEED_SCALE) _append(obs, canon(ball.linear_velocity, team) / BALL_SPEED_SCALE)
# Opponent, relative to self (zeros if absent, e.g. a 1-ship drill) # Teammates and opponents, relative to self, each padded/truncated to a
if is_instance_valid(opponent): # fixed slot count (zeros past the real roster size, e.g. a 1v1 match or
var opp_rel := opponent.global_position - ship.global_position # a solo drill) so the vector shape never depends on match size.
_append(obs, canon(opp_rel, team) / POSITION_SCALE) _append_ship_slots(obs, ship, team, teammates, MAX_TEAMMATES)
_append(obs, canon(opponent.linear_velocity, team) / ship.max_speed) _append_ship_slots(obs, ship, team, opponents, MAX_OPPONENTS)
else:
_append(obs, Vector3.ZERO)
_append(obs, Vector3.ZERO)
# Goal we are attacking, relative to self # Goal we are attacking, relative to self
var goal_rel := attack_goal_position - ship.global_position var goal_rel := attack_goal_position - ship.global_position
_append(obs, canon(goal_rel, team) / POSITION_SCALE) _append(obs, canon(goal_rel, team) / POSITION_SCALE)
obs.append(goal_rel.length() / GOAL_DISTANCE_SCALE) obs.append(goal_rel.length() / GOAL_DISTANCE_SCALE)
# Own contact state (appended — see SIZE's append-only invariant). # Own contact state. Added for generation 4: ShipAIController's
# Added for generation 4: ShipAIController's wall_contact_penalty used to # wall_contact_penalty used to fire on a condition the observation vector
# fire on a condition the observation vector couldn't see coming, # couldn't see coming, leaving the value function to predict a reward
# leaving the value function to predict a reward with no supporting # with no supporting signal. Also gives the policy a direct "am I resting
# signal. Also gives the policy a direct "am I resting on a surface" # on a surface" signal it can use to push off (a real aerial mechanic),
# signal it can use to push off (a real aerial mechanic), distinct from # distinct from inferring it indirectly from position/up-vector.
# inferring it indirectly from position/up-vector.
var normal := contact_normal(ship) var normal := contact_normal(ship)
_append(obs, canon(normal, team)) _append(obs, canon(normal, team))
obs.append(1.0 if normal != Vector3.ZERO else 0.0) obs.append(1.0 if normal != Vector3.ZERO else 0.0)
@@ -88,6 +100,26 @@ static func build(ship: Ship, opponent: Ship, ball: RigidBody3D, attack_goal_pos
return obs return obs
# Appends up to `slot_count` other ships' (relative position, relative
# velocity) — 6 floats each — zero-filling any slots beyond the real roster
# size, or beyond slot_count if the roster somehow has more (sorted-by-
# spawn_index order means truncation drops the highest spawn_index ships,
# not the nearest ones — acceptable since slot_count already covers the
# largest supported match size, 5v5).
static func _append_ship_slots(
obs: Array, ship: Ship, team: int, others: Array[Ship], slot_count: int
) -> void:
for i in slot_count:
if i < others.size() and is_instance_valid(others[i]):
var other := others[i]
var rel := other.global_position - ship.global_position
_append(obs, canon(rel, team) / POSITION_SCALE)
_append(obs, canon(other.linear_velocity, team) / ship.max_speed)
else:
_append(obs, Vector3.ZERO)
_append(obs, Vector3.ZERO)
static func _append(obs: Array, v: Vector3) -> void: static func _append(obs: Array, v: Vector3) -> void:
obs.append(v.x) obs.append(v.x)
obs.append(v.y) obs.append(v.y)
+79 -41
View File
@@ -65,6 +65,12 @@ extends GameMode
# _place_air_drill. # _place_air_drill.
@export_range(0.0, 1.0) var air_drill_chance := 0.0 @export_range(0.0, 1.0) var air_drill_chance := 0.0
# Ships per team. Default 1 preserves every existing curriculum script's 1v1
# behaviour unchanged; up to 5 matches ShipObservations.MAX_TEAMMATES/
# MAX_OPPONENTS. Plumbing only for this pass — no 2v2+ curriculum/reward
# design has been done, so a run above 1 is untested territory.
@export_range(1, 5) var team_size: int = 1
# Placement bounds for randomized episode starts, derived from the standard # Placement bounds for randomized episode starts, derived from the standard
# enclosure (ArenaBoundary). The inset keeps a randomly oriented ship (1x1x4 # enclosure (ArenaBoundary). The inset keeps a randomly oriented ship (1x1x4
# box, worst-case half-extent ~2.05) from spawning intersecting the walls, # box, worst-case half-extent ~2.05) from spawning intersecting the walls,
@@ -86,16 +92,17 @@ const CORNER_LIMIT := ArenaBoundary.INNER_HALF_X + ArenaBoundary.INNER_HALF_Z \
const FILLET_CLEAR_Y := ArenaBoundary.BASE_RADIUS + FIELD_MIN_Y const FILLET_CLEAR_Y := ArenaBoundary.BASE_RADIUS + FIELD_MIN_Y
const MAX_RANDOM_BALL_SPEED := 12.0 const MAX_RANDOM_BALL_SPEED := 12.0
const MAX_RANDOM_SHIP_SPEED := 8.0 const MAX_RANDOM_SHIP_SPEED := 8.0
# Minimum centre-to-centre separation enforced between ships placed in the
# same randomized reset (team_size > 1) — without it, _place_ships_random/
# _place_air_drill sample each ship independently and can spawn them
# interpenetrating. Twice the ~2.05m worst-case rotated half-extent noted
# above clears any relative orientation; matches arena_base.tscn's spawn
# marker spacing, which uses the same margin for the same reason.
const MIN_SHIP_SEPARATION := 4.5
# Sim runs at 60 physics ticks per sim-second regardless of speedup. # Sim runs at 60 physics ticks per sim-second regardless of speedup.
const TICKS_PER_SIM_SECOND := 60.0 const TICKS_PER_SIM_SECOND := 60.0
# The arena is physically enclosed, so nothing should ever get this far out.
# If a body escapes anyway (physics regression, boundary edit), it is warned
# about and respawned with no reward change and no episode end — a multi-hour
# training run must survive it, and the escape must not shape rewards.
const ESCAPE_MARGIN := 15.0
var _agents: Array[ShipAIController] = [] var _agents: Array[ShipAIController] = []
# Eval mode state (see header comment) # Eval mode state (see header comment)
@@ -152,22 +159,46 @@ func _start() -> void:
spawn_ship(team, 0, bot) spawn_ship(team, 0, bot)
return return
var ship_team0 := spawn_ship(0, 0, RLShipController.new()) var team0_ships: Array[Ship] = []
var ship_team1: Ship for i in team_size:
team0_ships.append(spawn_ship(0, i, RLShipController.new()))
# The opponent_mode branch applies uniformly to every ship on team 1: an
# "inert"/"frozen" run means the whole opposing team gets that treatment,
# not just one ship.
var team1_ships: Array[Ship] = []
for i in team_size:
var ship1: Ship
match _opponent_mode: match _opponent_mode:
"inert": "inert":
ship_team1 = spawn_ship(1, 0, ShipController.new()) ship1 = spawn_ship(1, i, ShipController.new())
_inert_ships.append(ship_team1) _inert_ships.append(ship1)
"frozen": "frozen":
var bot := AIShipController.new() var bot := AIShipController.new()
bot.model_path = _opponent_model_path bot.model_path = _opponent_model_path
ship_team1 = spawn_ship(1, 0, bot) ship1 = spawn_ship(1, i, bot)
_: _:
ship_team1 = spawn_ship(1, 0, RLShipController.new()) ship1 = spawn_ship(1, i, RLShipController.new())
team1_ships.append(ship1)
_attach_agent(ship_team0, ship_team1) # All ships spawn before any agent attaches, so every agent's
# teammates/opponents lists see the other side's full roster.
for ship in team0_ships:
_attach_agent(ship, _other_ships(team0_ships, ship), team1_ships)
if _opponent_mode == "self_play": if _opponent_mode == "self_play":
_attach_agent(ship_team1, ship_team0) for ship in team1_ships:
_attach_agent(ship, _other_ships(team1_ships, ship), team0_ships)
# `roster` minus `ship`, preserving order — rosters are built by spawn_index
# already, so this stays spawn_index-sorted (see ShipObservations' slot-
# stability requirement).
func _other_ships(roster: Array[Ship], ship: Ship) -> Array[Ship]:
var others: Array[Ship] = []
for s in roster:
if s != ship:
others.append(s)
return others
# Shared "--key=value" cmdline scan used by both eval and curriculum parsing. # Shared "--key=value" cmdline scan used by both eval and curriculum parsing.
@@ -260,14 +291,14 @@ func _ai_default(name: String) -> Variant:
_: return null _: return null
func _attach_agent(ship: Ship, opponent: Ship) -> void: func _attach_agent(ship: Ship, teammates: Array[Ship], opponents: Array[Ship]) -> void:
var agent := ShipAIController.new() var agent := ShipAIController.new()
agent.name = "ShipAIController" agent.name = "ShipAIController"
agent.reset_after = int(episode_length_seconds * TICKS_PER_SIM_SECOND) agent.reset_after = int(episode_length_seconds * TICKS_PER_SIM_SECOND)
for key in _ai_overrides: for key in _ai_overrides:
agent.set(key, _ai_overrides[key]) agent.set(key, _ai_overrides[key])
ship.add_child(agent) ship.add_child(agent)
agent.setup(ship, ship.controller as RLShipController, ball, opponent, _attack_goal_position(ship.team)) agent.setup(ship, ship.controller as RLShipController, ball, teammates, opponents, _attack_goal_position(ship.team))
_agents.append(agent) _agents.append(agent)
@@ -315,29 +346,11 @@ func _physics_process(_delta):
# A goal (_on_goal_scored) does NOT do this — a goal is a # A goal (_on_goal_scored) does NOT do this — a goal is a
# genuine terminal, V(s)=0 is correct there. # genuine terminal, V(s)=0 is correct there.
agent.truncated_this_episode = true agent.truncated_this_episode = true
agent.terminal_obs = ShipObservations.build(agent.ship, agent.opponent, agent.ball, agent.attack_goal_position) agent.terminal_obs = ShipObservations.build(agent.ship, agent.teammates, agent.opponents, agent.ball, agent.attack_goal_position)
_reset_episode() _reset_episode()
return return
# Escape failsafe: see ESCAPE_MARGIN.
func _respawn_escaped_bodies() -> void:
for ship in ships:
if is_instance_valid(ship) and _escaped(ship.global_position):
push_warning("TrainingMode: ship escaped the enclosed arena — check boundary colliders")
_place_body(ship, _ship_spawn_transforms[ship], Vector3.ZERO, Vector3.ZERO)
if is_instance_valid(ball) and _escaped(ball.global_position):
push_warning("TrainingMode: ball escaped the enclosed arena — check boundary colliders")
_place_body(ball, arena.get_ball_spawn(), Vector3.ZERO, Vector3.ZERO)
func _escaped(position: Vector3) -> bool:
return absf(position.x) > ArenaBoundary.INNER_HALF_X + ESCAPE_MARGIN \
or absf(position.z) > ArenaBoundary.INNER_HALF_Z + ESCAPE_MARGIN \
or position.y < -ESCAPE_MARGIN \
or position.y > ArenaBoundary.INNER_HEIGHT + ESCAPE_MARGIN
func _on_goal_scored(conceding_team: int) -> void: func _on_goal_scored(conceding_team: int) -> void:
if _eval: if _eval:
_eval_goals[1 - conceding_team] += 1 _eval_goals[1 - conceding_team] += 1
@@ -416,17 +429,27 @@ func _place_air_drill() -> void:
var ball_velocity := _random_direction() * randf_range(0.0, MAX_RANDOM_BALL_SPEED * 0.5) var ball_velocity := _random_direction() * randf_range(0.0, MAX_RANDOM_BALL_SPEED * 0.5)
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), ball_velocity, Vector3.ZERO) _place_body(ball, Transform3D(Basis.IDENTITY, ball_position), ball_velocity, Vector3.ZERO)
# Each ship's lateral offset is sampled independently, so with more than
# one ship per team (team_size > 1) two could otherwise land within their
# own hulls of each other — resample against every ship already placed
# this reset (see MIN_SHIP_SEPARATION).
var placed: Array[Vector3] = []
for ship in ships: for ship in ships:
if ship in _inert_ships: if ship in _inert_ships:
continue continue
var ship_position := Vector3.ZERO
for _attempt in 20:
var lateral_offset := Vector3(randf_range(-1, 1), 0.0, randf_range(-1, 1)) var lateral_offset := Vector3(randf_range(-1, 1), 0.0, randf_range(-1, 1))
lateral_offset = lateral_offset.normalized() if lateral_offset.length_squared() > 0.001 else Vector3.FORWARD lateral_offset = lateral_offset.normalized() if lateral_offset.length_squared() > 0.001 else Vector3.FORWARD
lateral_offset *= randf_range(6.0, 14.0) lateral_offset *= randf_range(6.0, 14.0)
var ship_position := Vector3( ship_position = Vector3(
clampf(ball_position.x + lateral_offset.x, -FIELD_HALF_X, FIELD_HALF_X), clampf(ball_position.x + lateral_offset.x, -FIELD_HALF_X, FIELD_HALF_X),
randf_range(FIELD_MIN_Y, 4.0), randf_range(FIELD_MIN_Y, 4.0),
clampf(ball_position.z + lateral_offset.z, -FIELD_HALF_Z, FIELD_HALF_Z) clampf(ball_position.z + lateral_offset.z, -FIELD_HALF_Z, FIELD_HALF_Z)
) )
if _far_enough_from(ship_position, placed):
break
placed.append(ship_position)
var orientation := Basis.from_euler(Vector3( var orientation := Basis.from_euler(Vector3(
randf_range(-0.4, 0.4), randf_range(-PI, PI), randf_range(-0.4, 0.4) randf_range(-0.4, 0.4), randf_range(-PI, PI), randf_range(-0.4, 0.4)
)) ))
@@ -453,6 +476,10 @@ func _place_ball_near_goal() -> void:
func _place_ships_random() -> void: func _place_ships_random() -> void:
# Placed one at a time, resampling each against every position already
# placed this reset (see MIN_SHIP_SEPARATION) — otherwise a team_size > 1
# roster is sampled independently per ship and can spawn interpenetrating.
var placed: Array[Vector3] = []
for ship in ships: for ship in ships:
# Inert opponents (opponent_mode=inert) stay parked at their arena # Inert opponents (opponent_mode=inert) stay parked at their arena
# spawn instead of drifting into the play area as a stray obstacle — # spawn instead of drifting into the play area as a stray obstacle —
@@ -465,13 +492,17 @@ func _place_ships_random() -> void:
randf_range(-0.4, 0.4) randf_range(-0.4, 0.4)
)) ))
var velocity := _random_direction() * randf_range(0.0, MAX_RANDOM_SHIP_SPEED) var velocity := _random_direction() * randf_range(0.0, MAX_RANDOM_SHIP_SPEED)
_place_body(ship, Transform3D(orientation, _random_position()), velocity, Vector3.ZERO) var position := _random_position(placed)
placed.append(position)
_place_body(ship, Transform3D(orientation, position), velocity, Vector3.ZERO)
func _random_position() -> Vector3: func _random_position(exclude: Array[Vector3] = []) -> Vector3:
# Resample anything too close to a corner curve or wall-base fillet (see # Resample anything too close to a corner curve or wall-base fillet (see
# CORNER_LIMIT / FILLET_CLEAR_Y); the violating region is a few percent # CORNER_LIMIT / FILLET_CLEAR_Y), or too close to an already-placed ship
# of the volume, so 20 attempts effectively never fall through. # this same reset (see MIN_SHIP_SEPARATION); the violating region is a
# few percent of the volume, so 20 attempts effectively never fall
# through even placing a full 5v5 roster one at a time.
var position := Vector3.ZERO var position := Vector3.ZERO
for _attempt in 20: for _attempt in 20:
position = Vector3( position = Vector3(
@@ -479,11 +510,18 @@ func _random_position() -> Vector3:
randf_range(FIELD_MIN_Y, FIELD_MAX_Y), randf_range(FIELD_MIN_Y, FIELD_MAX_Y),
randf_range(-FIELD_HALF_Z, FIELD_HALF_Z) randf_range(-FIELD_HALF_Z, FIELD_HALF_Z)
) )
if _spawn_position_clear(position): if _spawn_position_clear(position) and _far_enough_from(position, exclude):
break break
return position return position
func _far_enough_from(position: Vector3, others: Array[Vector3]) -> bool:
for other in others:
if position.distance_squared_to(other) < MIN_SHIP_SEPARATION * MIN_SHIP_SEPARATION:
return false
return true
func _spawn_position_clear(position: Vector3) -> bool: func _spawn_position_clear(position: Vector3) -> bool:
if absf(position.x) + absf(position.z) > CORNER_LIMIT: if absf(position.x) + absf(position.z) > CORNER_LIMIT:
return false return false
+2 -2
View File
@@ -11,8 +11,8 @@ uniform float soft_fade_distance : hint_range(0.0, 5.0) = 1.0;
// see ArenaBoundary.INNER_HALF_X/INNER_HALF_Z/INNER_HEIGHT. Dust is fully // see ArenaBoundary.INNER_HALF_X/INNER_HALF_Z/INNER_HEIGHT. Dust is fully
// hidden inside that box and fades in over arena_clear_distance beyond it, // hidden inside that box and fades in over arena_clear_distance beyond it,
// so it reads as drifting around the station rather than through the pitch. // so it reads as drifting around the station rather than through the pitch.
uniform vec3 arena_half_extents = vec3(12.0, 6.0, 18.0); uniform vec3 arena_half_extents = vec3(18.0, 9.0, 27.0);
uniform vec3 arena_centre = vec3(0.0, 6.0, 0.0); uniform vec3 arena_centre = vec3(0.0, 9.0, 0.0);
uniform float arena_clear_distance : hint_range(0.0, 10.0) = 2.0; uniform float arena_clear_distance : hint_range(0.0, 10.0) = 2.0;
varying float v_flicker; varying float v_flicker;
+47
View File
@@ -0,0 +1,47 @@
extends SceneTree
# One-off bake: runs ArenaBoundary's collider generation once and commits the
# result into objects/arena_boundary.tscn, so every arena load (and every
# parallel headless training env) skips regenerating ~170 CollisionShape3D
# nodes at runtime — ArenaBoundary._ready() already skips this itself once a
# bake matching the current goal_mode exists (see bake_colliders).
#
# Safe to re-run any time a geometry constant changes (arena size, corner/
# fillet radii, goal aperture, segment counts, etc.) — bake_colliders() clears
# its own prior output first, so this replaces rather than duplicates:
# godot --headless --path Game --script res://tools/bake_arena_boundary.gd
const ARENA_BOUNDARY_PATH := "res://objects/arena_boundary.tscn"
func _initialize() -> void:
var packed: PackedScene = load(ARENA_BOUNDARY_PATH)
var instance := packed.instantiate()
instance.bake_colliders()
# PackedScene.pack() only serializes nodes whose `owner` is set to the
# scene root — newly created nodes have no owner by default, so without
# this, pack() silently produces a scene with none of the just-built
# colliders in it.
_set_owner_recursive(instance, instance)
var rebaked := PackedScene.new()
var pack_result := rebaked.pack(instance)
if pack_result != OK:
push_error("bake_arena_boundary: pack() failed with error %d" % pack_result)
quit(1)
return
var save_result := ResourceSaver.save(rebaked, ARENA_BOUNDARY_PATH)
if save_result != OK:
push_error("bake_arena_boundary: save() failed with error %d" % save_result)
quit(1)
return
print("bake_arena_boundary: baked colliders into %s" % ARENA_BOUNDARY_PATH)
quit()
func _set_owner_recursive(node: Node, scene_root: Node) -> void:
for child in node.get_children():
child.owner = scene_root
_set_owner_recursive(child, scene_root)
-21
View File
@@ -10,18 +10,6 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
- [ ] Frozen-opponent league: train the live policy against a *pool* of past exported checkpoints, sampled per-episode (today's `--opponent-mode=frozen` only supports one fixed model per run) to prevent self-play strategy collapse on long runs. - [ ] Frozen-opponent league: train the live policy against a *pool* of past exported checkpoints, sampled per-episode (today's `--opponent-mode=frozen` only supports one fixed model per run) to prevent self-play strategy collapse on long runs.
- [ ] Richer state setter / curriculum: aerial states, wall plays, rebound scenarios as skill grows (beyond the score/defend/draw staging already in place). - [ ] Richer state setter / curriculum: aerial states, wall plays, rebound scenarios as skill grows (beyond the score/defend/draw staging already in place).
## Correctness
Bugs found in an adversarial review. None are gameplay- or physics-affecting, so all are safe to land against the current `Game/bots/` checkpoints.
- [ ] 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.
- [ ] Delete the duplicate 1 MB texture — `assets/textures/planet_surface.png` and `assets/models/nebula_planet_planet_surface.png` are byte-identical.
## Performance
- [ ] 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.
## Presentation / AAA polish ## Presentation / AAA polish
The largest gap between this and a AAA-feeling product is presentation, not code. Sequenced after the above for pragmatic reasons, but this is the highest impact per hour. The largest gap between this and a AAA-feeling product is presentation, not code. Sequenced after the above for pragmatic reasons, but this is the highest impact per hour.
@@ -37,15 +25,6 @@ The largest gap between this and a AAA-feeling product is presentation, not code
- [ ] Custom font + a real `Theme` resource for the HUD. The procedural instruments are well-engineered, but `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay. - [ ] Custom font + a real `Theme` resource for the HUD. The procedural instruments are well-engineered, but `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay.
- [ ] Post-processing beyond glow: DoF, motion blur, vignette, chromatic aberration on turbo. - [ ] Post-processing beyond glow: DoF, motion blur, vignette, chromatic aberration on turbo.
## Open decision — trained-bot compatibility
Four items collide with the checkpoints in `Game/bots/`. Decide the policy before scheduling any of them; everything in Correctness and DRY above is safe either way.
- [ ] **Per-tick drag** (`ship.gd`): `state.linear_velocity *= drag_coefficient` and `angular_velocity *= 0.9` aren't delta-scaled, and `project.godot` never pins `physics/common/physics_ticks_per_second`. Correct at 60 Hz, silently different at any other rate. Zero-risk option: pin the tick rate to 60 and document the dependency. Correct-but-breaking option: delta-scale it and retrain.
- [ ] **Goal aperture** — cutting a real opening in the end walls so the ball visibly enters the net changes collision geometry the policies were fitted against. Today the walls are solid and the pocket/net are unreachable decoration.
- [ ] **Collider bake** (see Performance above) — safe only if byte-identical to the current generated output.
- [ ] **Beyond 1v1**`ai_ship_controller.gd` takes the first non-self ship as "the opponent" and `ship_observations.gd` has room for exactly one. The observation space is the hardest thing to change later, so decide whether 2v2/3v3 is in scope before more training time is spent.
## Multiplayer (long term) ## Multiplayer (long term)
- [ ] `RemoteShipController extends ShipController` — feeds replicated `ShipAction`s from a network peer into the local ship simulation. - [ ] `RemoteShipController extends ShipController` — feeds replicated `ShipAction`s from a network peer into the local ship simulation.
+11 -15
View File
@@ -1,11 +1,11 @@
"""Generates Game/assets/textures/planet_surface.png (and the byte-identical """Generates Game/assets/models/nebula_planet_planet_surface.png, the sidecar
Game/assets/models/nebula_planet_planet_surface.png sidecar that actually feeds that actually feeds the live nebula_planet.glb material -- Godot's glTF
the live nebula_planet.glb material -- Godot's glTF importer extracted the importer extracted the embedded image there at import time, so the imported
embedded image there at import time, so the imported scene references that scene references that external file, not the glb's internal binary. A
external file, not the glb's internal binary): a 2048x1024 equirectangular 2048x1024 equirectangular decorative planet with latitude bands, storm
decorative planet with latitude bands, storm vortices, and a lit/unlit vortices, and a lit/unlit terminator, via layered FFT/domain-warped noise in
terminator, via layered FFT/domain-warped noise in the same style as the same style as gen_nebula_sky.py (helpers duplicated here to keep both
gen_nebula_sky.py (helpers duplicated here to keep both scripts standalone). scripts standalone).
""" """
from pathlib import Path from pathlib import Path
@@ -178,12 +178,8 @@ color = np.clip(color, 0.0, 1.0)
print("saving planet surface...") print("saving planet surface...")
root = Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parents[2]
out_paths = [ out_path = root / "Game" / "assets" / "models" / "nebula_planet_planet_surface.png"
root / "Game" / "assets" / "textures" / "planet_surface.png",
root / "Game" / "assets" / "models" / "nebula_planet_planet_surface.png",
]
img = Image.fromarray((color * 255).astype(np.uint8), "RGB") img = Image.fromarray((color * 255).astype(np.uint8), "RGB")
for p in out_paths: img.save(out_path)
img.save(p) print("saved:", out_path)
print("saved:", p)
print("done") print("done")