From a02e0770af061f5d12a2478fe3c914bd920a29f0 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:16:35 +0100 Subject: [PATCH] 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. --- Game/scripts/training_mode.gd | 58 +++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/Game/scripts/training_mode.gd b/Game/scripts/training_mode.gd index ab19fd9e..582b764f 100644 --- a/Game/scripts/training_mode.gd +++ b/Game/scripts/training_mode.gd @@ -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