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
+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: