feat(training): add wall and rebound curriculum states

This commit is contained in:
Josh Creek
2026-09-01 17:37:52 +01:00
parent 7170400f49
commit e376675fa6
6 changed files with 68 additions and 11 deletions
+41 -4
View File
@@ -70,6 +70,11 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
# a real goal and ships start low behind/lateral to it, so a useful touch is
# naturally reinforced by the existing goal-directed ball rewards.
@export_range(0.0, 1.0) var air_intercept_chance := 0.0
# Wall-play and rebound starts are separate: wall-play begins beside a wall
# with the ball travelling inward, while rebound begins just before an
# outward wall impact. Both default off to preserve existing distributions.
@export_range(0.0, 1.0) var wall_play_chance := 0.0
@export_range(0.0, 1.0) var rebound_chance := 0.0
# Ground-start branch for the generation-5 handling stage: ships spawn level
# and resting on the floor with a low, floor-level ball. Every other branch
# samples ship Y uniformly across the full 18m volume (see _random_position),
@@ -82,8 +87,8 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
# 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.
# MAX_OPPONENTS. Team-credit reward and paired 2v2 evaluation are opt-in;
# no teamplay training stage is enabled by default.
@export_range(1, 5) var team_size: int = 1
# Placement bounds for randomized episode starts, derived from the standard
@@ -100,6 +105,9 @@ const FIELD_MIN_Y := 1.5
# spawning interpenetrated with it.
const GROUND_START_Y := 0.35
const GROUND_START_BALL_Y := 0.55
const WALL_PLAY_BALL_CLEARANCE := 1.0
const REBOUND_BALL_CLEARANCE := 0.75
const WALL_PLAY_SPEED := Vector2(4.0, 9.0)
const FIELD_MAX_Y := ArenaBoundary.INNER_HEIGHT - SPAWN_INSET
# The corner curves reach at most their chord plane |x| + |z| = INNER_HALF_X
# + INNER_HALF_Z - CORNER_RADIUS; spawns keep the same SPAWN_INSET clearance
@@ -264,6 +272,7 @@ const TRAINING_MODE_OVERRIDES := [
"goal_reward", "draw_penalty", "kickoff_state_chance",
"ball_near_goal_chance", "attack_goal_bias", "air_drill_chance",
"air_intercept_chance", "ground_start_chance", "team_size",
"wall_play_chance", "rebound_chance",
]
# ShipAIController @export names a curriculum run may override, read as
# --ai_<name>=<value> to avoid colliding with the names above.
@@ -292,8 +301,8 @@ func _parse_curriculum_args() -> void:
if args.has(name):
set(name, _typed_like(args[name], get(name)))
var start_probability := kickoff_state_chance + ball_near_goal_chance \
+ air_drill_chance + air_intercept_chance
start_probability += ground_start_chance
+ air_drill_chance + air_intercept_chance + ground_start_chance \
+ wall_play_chance + rebound_chance
if start_probability > 1.0:
push_error("TrainingMode: episode-start probabilities sum to %.3f (> 1.0)" % start_probability)
@@ -455,6 +464,12 @@ func _reset_episode() -> void:
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance:
_place_ground_start()
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance + wall_play_chance:
_place_wall_state(false)
elif roll < kickoff_state_chance + ball_near_goal_chance + air_drill_chance \
+ air_intercept_chance + ground_start_chance + wall_play_chance + rebound_chance:
_place_wall_state(true)
else:
_place_ships_random()
_place_ball_random()
@@ -564,6 +579,28 @@ func _place_ground_start() -> void:
)
# Wall-play/rebound states (see wall_play_chance/rebound_chance). The ball is
# placed against a side wall, never in a corner or goal sensor. A wall-play
# state starts after the bounce and sends the ball inward; a rebound state
# starts before contact and sends it outward so the physics engine supplies
# the reflected trajectory. Ships use the ordinary randomized placement, so
# the policy has to read the wall/rebound context instead of memorising a
# fixed attacker spawn.
func _place_wall_state(rebound: bool) -> void:
_place_ships_random()
var side := -1.0 if randf() < 0.5 else 1.0
var clearance := REBOUND_BALL_CLEARANCE if rebound else WALL_PLAY_BALL_CLEARANCE
var ball_position := Vector3(
side * (ArenaBoundary.INNER_HALF_X - clearance),
randf_range(1.0, minf(FIELD_MAX_Y, 7.0)),
randf_range(-FIELD_HALF_Z, FIELD_HALF_Z)
)
var toward_field := Vector3(-side, randf_range(-0.15, 0.15), randf_range(-0.15, 0.15)).normalized()
var direction := -toward_field if rebound else toward_field
var velocity := direction * randf_range(WALL_PLAY_SPEED.x, WALL_PLAY_SPEED.y)
_place_body(ball, Transform3D(Basis.IDENTITY, ball_position), velocity, Vector3.ZERO)
# Air-intercept drill geometry. These six ranges are not free tuning knobs —
# together they decide whether the drill is solvable at all, and the original
# values made it arithmetically impossible (see the Round 9 note in