feat(*): Replace the test terrain arena with an enclosed standard-size space-platform arena (shared ArenaBoundary floor/walls/ceiling scene, starfield sky, ball CCD) and derive TrainingMode placement bounds from it, dropping the out-of-bounds reward guard

This commit is contained in:
Josh Creek
2026-07-18 20:18:03 +01:00
parent 85f96eb15e
commit 379ef9910e
15 changed files with 181 additions and 6492 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — terrain, lighting, two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (inner x ±12, z ±18, height 12, goal lines z ±17) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
- **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.
- **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`).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

-41
View File
@@ -1,41 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cqubdkwj11v1i"
path.s3tc="res://.godot/imported/Grass.jpg-6a640b9c5e86d03c7318eda5c14b9c10.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://assets/models/Grass.jpg"
dest_files=["res://.godot/imported/Grass.jpg-6a640b9c5e86d03c7318eda5c14b9c10.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
-13
View File
@@ -1,13 +0,0 @@
# Blender MTL File: 'None'
# Material Count: 1
newmtl Grass
Ns 0.000000
Ka 1.000000 1.000000 1.000000
Kd 0.800000 0.800000 0.800000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.450000
d 1.000000
illum 2
map_Kd C:\\Users\\Daniel\\Desktop\\Godot Projects\\OpenWorldGame\\Procedural Generation\\Textures\\Grass.jpg
File diff suppressed because it is too large Load Diff
-25
View File
@@ -1,25 +0,0 @@
[remap]
importer="wavefront_obj"
importer_version=1
type="Mesh"
uid="uid://cndcxj6gf0smr"
path="res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"
[deps]
files=["res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"]
source_file="res://assets/models/Terrain.obj"
dest_files=["res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh", "res://.godot/imported/Terrain.obj-180d9e542e76d4bc65ef6f60023d2068.mesh"]
[params]
generate_tangents=true
generate_lods=true
generate_shadow_mesh=true
generate_lightmap_uv2=false
generate_lightmap_uv2_texel_size=0.2
scale_mesh=Vector3(1, 1, 1)
offset_mesh=Vector3(0, 0, 0)
force_disable_mesh_compression=false
-6
View File
@@ -1,6 +0,0 @@
[gd_resource type="StandardMaterial3D" load_steps=2 format=3 uid="uid://drm3clvqpis42"]
[ext_resource type="Texture2D" uid="uid://cqubdkwj11v1i" path="res://assets/models/Grass.jpg" id="1_imo8j"]
[resource]
albedo_texture = ExtResource("1_imo8j")
+94
View File
@@ -0,0 +1,94 @@
[gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://scripts/arena_boundary.gd" id="1_bndry"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
albedo_color = Color(0.08, 0.09, 0.12, 1)
metallic = 0.3
roughness = 0.6
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_field"]
transparency = 1
shading_mode = 0
albedo_color = Color(0, 0.368627, 1, 0.08)
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
material = SubResource("StandardMaterial3D_floor")
size = Vector3(28, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_ceiling"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(28, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_side_wall"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(1, 13, 38)
[sub_resource type="BoxMesh" id="BoxMesh_end_wall"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(26, 13, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_slab"]
size = Vector3(28, 1, 40)
[sub_resource type="BoxShape3D" id="BoxShape3D_side_wall"]
size = Vector3(1, 13, 38)
[sub_resource type="BoxShape3D" id="BoxShape3D_end_wall"]
size = Vector3(26, 13, 1)
[node name="ArenaBoundary" type="StaticBody3D"]
script = ExtResource("1_bndry")
[node name="FloorShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
shape = SubResource("BoxShape3D_slab")
[node name="FloorMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
mesh = SubResource("BoxMesh_floor")
[node name="WallPosXShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
shape = SubResource("BoxShape3D_side_wall")
[node name="WallPosXMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_side_wall")
[node name="WallNegXShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
shape = SubResource("BoxShape3D_side_wall")
[node name="WallNegXMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_side_wall")
[node name="WallPosZShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
shape = SubResource("BoxShape3D_end_wall")
[node name="WallPosZMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
cast_shadow = 0
mesh = SubResource("BoxMesh_end_wall")
[node name="WallNegZShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
shape = SubResource("BoxShape3D_end_wall")
[node name="WallNegZMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
cast_shadow = 0
mesh = SubResource("BoxMesh_end_wall")
[node name="CeilingShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
shape = SubResource("BoxShape3D_slab")
[node name="CeilingMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_ceiling")
+1
View File
@@ -11,6 +11,7 @@ friction = 0.3
[node name="Ball" type="RigidBody3D" groups=["ball"]]
mass = 3
physics_material_override = SubResource("PhysicsMaterial_ball")
continuous_cd = true
inertia = Vector3(3, 3, 3)
gravity_scale = 0.8
linear_damp = 0.1
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
class_name ArenaBoundary
extends StaticBody3D
# The standard arena play volume (inner faces of the enclosure). Every arena
# instances objects/arena_boundary.tscn so all arenas share one size; code
# that needs field dimensions derives them from these constants rather than
# restating numbers.
const INNER_HALF_X := 12.0
const INNER_HALF_Z := 18.0
const INNER_HEIGHT := 12.0
# Goal-centre distance from arena centre; the end walls sit 1 m behind, so a
# ball pinned against them still overlaps the goal sensor.
const GOAL_LINE_Z := 17.0
+1
View File
@@ -0,0 +1 @@
uid://f32otkpo3lvr
+3 -2
View File
@@ -11,8 +11,9 @@ extends RefCounted
# The same rotation must be inverted when interpreting actions (see canon —
# it is its own inverse).
# Normalization scales. Arena bounds: goals at z ≈ ±15.56, ship spawns at
# z = ±12; positions are soft-normalized to roughly [-1, 1].
# Normalization scales. Standard arena volume (see ArenaBoundary): x ±12,
# z ±18, height 12, goals at z ±17; positions are soft-normalized to roughly
# [-1, 1]. Do not retune without retraining every model in Game/bots/.
const POSITION_SCALE := Vector3(20.0, 10.0, 20.0)
const BALL_SPEED_SCALE := 30.0
const GOAL_DISTANCE_SCALE := 40.0
+30 -31
View File
@@ -24,24 +24,26 @@ extends GameMode
@export_range(0.0, 1.0) var kickoff_state_chance := 0.2
@export_range(0.0, 1.0) var ball_near_goal_chance := 0.2
# Placement bounds, inset from the arena (goals at z ≈ ±15.56).
const FIELD_HALF_X := 10.0
const FIELD_HALF_Z := 13.0
# Placement bounds for randomized episode starts, derived from the standard
# enclosure (ArenaBoundary). The inset keeps a randomly oriented ship (1x1x4
# box, worst-case half-extent ~2.05) from spawning intersecting the walls,
# ceiling, or goal sensors.
const SPAWN_INSET := 2.5
const FIELD_HALF_X := ArenaBoundary.INNER_HALF_X - SPAWN_INSET
const FIELD_HALF_Z := ArenaBoundary.GOAL_LINE_Z - SPAWN_INSET
const FIELD_MIN_Y := 1.5
const FIELD_MAX_Y := 8.0
const FIELD_MAX_Y := ArenaBoundary.INNER_HEIGHT - SPAWN_INSET
const MAX_RANDOM_BALL_SPEED := 12.0
const MAX_RANDOM_SHIP_SPEED := 8.0
# Sim runs at 60 physics ticks per sim-second regardless of speedup.
const TICKS_PER_SIM_SECOND := 60.0
# The arena has no walls/ceiling yet (see TODO.md), so an untrained policy can
# simply fly away. Leaving this volume ends the episode with a small penalty.
const BOUNDS_HALF_X := 25.0
const BOUNDS_HALF_Z := 25.0
const BOUNDS_MIN_Y := -5.0
const BOUNDS_MAX_Y := 25.0
@export var out_of_bounds_penalty := 1.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] = []
@@ -102,13 +104,10 @@ func _attack_goal_position(team: int) -> Vector3:
func _physics_process(_delta):
_respawn_escaped_bodies()
if _eval:
_episode_ticks += 1
for ship in ships:
if is_instance_valid(ship) and _out_of_bounds(ship.global_position):
_place_body(ship, _ship_spawn_transforms[ship], Vector3.ZERO, Vector3.ZERO)
var ball_lost := is_instance_valid(ball) and _out_of_bounds(ball.global_position)
if _episode_ticks > int(episode_length_seconds * TICKS_PER_SIM_SECOND) or ball_lost:
if _episode_ticks > int(episode_length_seconds * TICKS_PER_SIM_SECOND):
_eval_draws += 1
_end_eval_episode()
return
@@ -128,23 +127,23 @@ func _physics_process(_delta):
_reset_episode()
return
# A ship leaving the play volume is penalized and respawned at its kickoff
# spawn (the episode continues); a lost ball ends the episode for both.
for agent in _agents:
if _out_of_bounds(agent.ship.global_position):
agent.reward -= out_of_bounds_penalty
_place_body(agent.ship, _ship_spawn_transforms[agent.ship], Vector3.ZERO, Vector3.ZERO)
if is_instance_valid(ball) and _out_of_bounds(ball.global_position) and not _agents.is_empty():
for agent in _agents:
agent.done = true
_reset_episode()
# 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 _out_of_bounds(position: Vector3) -> bool:
return absf(position.x) > BOUNDS_HALF_X \
or absf(position.z) > BOUNDS_HALF_Z \
or position.y < BOUNDS_MIN_Y \
or position.y > BOUNDS_MAX_Y
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:
+1 -2
View File
@@ -29,5 +29,4 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
## General
- [ ] No autoloads yet by design — add a singleton only when cross-scene state is actually needed (e.g. passing match settings/results between menu, match, and results screens).
- [ ] More arenas: `arena_01.tscn` is the template — an arena is terrain + lighting + two team-tagged goals + spawn markers, with no rules or state.
- [ ] Enclose the arena (walls/ceiling): ships and ball can currently fly out of the play volume. TrainingMode papers over this with an out-of-bounds episode-end + penalty; a real enclosed arena fixes it for players too and the training guard can then be removed.
- [ ] More arenas: `arena_01.tscn` is the template — an arena is a setting (sky, lighting, decoration) + two team-tagged goals on the standard goal lines + spawn markers + an instance of the shared `objects/arena_boundary.tscn`, with no rules or state. All arenas share the standard play volume defined by `ArenaBoundary`'s constants.