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.
This commit is contained in:
Josh Creek
2026-08-05 09:16:35 +01:00
parent 661c588fef
commit a02e0770af
+45 -13
View File
@@ -86,6 +86,13 @@ const CORNER_LIMIT := ArenaBoundary.INNER_HALF_X + ArenaBoundary.INNER_HALF_Z \
const FILLET_CLEAR_Y := ArenaBoundary.BASE_RADIUS + FIELD_MIN_Y
const MAX_RANDOM_BALL_SPEED := 12.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.
const TICKS_PER_SIM_SECOND := 60.0
@@ -392,17 +399,27 @@ func _place_air_drill() -> void:
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)
# 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:
if ship in _inert_ships:
continue
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 *= randf_range(6.0, 14.0)
var ship_position := Vector3(
clampf(ball_position.x + lateral_offset.x, -FIELD_HALF_X, FIELD_HALF_X),
randf_range(FIELD_MIN_Y, 4.0),
clampf(ball_position.z + lateral_offset.z, -FIELD_HALF_Z, FIELD_HALF_Z)
)
var ship_position := Vector3.ZERO
for _attempt in 20:
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 *= randf_range(6.0, 14.0)
ship_position = Vector3(
clampf(ball_position.x + lateral_offset.x, -FIELD_HALF_X, FIELD_HALF_X),
randf_range(FIELD_MIN_Y, 4.0),
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(
randf_range(-0.4, 0.4), randf_range(-PI, PI), randf_range(-0.4, 0.4)
))
@@ -429,6 +446,10 @@ func _place_ball_near_goal() -> 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:
# Inert opponents (opponent_mode=inert) stay parked at their arena
# spawn instead of drifting into the play area as a stray obstacle —
@@ -441,13 +462,17 @@ func _place_ships_random() -> void:
randf_range(-0.4, 0.4)
))
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
# CORNER_LIMIT / FILLET_CLEAR_Y); the violating region is a few percent
# of the volume, so 20 attempts effectively never fall through.
# CORNER_LIMIT / FILLET_CLEAR_Y), or too close to an already-placed ship
# 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
for _attempt in 20:
position = Vector3(
@@ -455,11 +480,18 @@ func _random_position() -> Vector3:
randf_range(FIELD_MIN_Y, FIELD_MAX_Y),
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
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:
if absf(position.x) + absf(position.z) > CORNER_LIMIT:
return false