From 3457d4ca84da7d2ecd42cda4216de6d1fc072232 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:20:33 +0100 Subject: [PATCH] feat(*): Add rounded arena boundaries and reward shaping to curb corner-camping --- Game/scenes/arena_01.tscn | 4 +- Game/scripts/arena_boundary.gd | 315 ++++++++++++++++++++++++++++- Game/scripts/ship_ai_controller.gd | 28 ++- Game/scripts/ship_observations.gd | 5 +- Game/scripts/training_mode.gd | 37 +++- 5 files changed, 376 insertions(+), 13 deletions(-) diff --git a/Game/scenes/arena_01.tscn b/Game/scenes/arena_01.tscn index fb1af2e2..f68a2d82 100644 --- a/Game/scenes/arena_01.tscn +++ b/Game/scenes/arena_01.tscn @@ -50,10 +50,10 @@ shadow_enabled = true environment = SubResource("Environment_space") [node name="GoalTeam0" parent="." instance=ExtResource("6_p57ef")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 17) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.79, 18) [node name="GoalTeam1" parent="." instance=ExtResource("6_p57ef")] -transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -17) +transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.79, -18) team = 1 [node name="BallSpawn" type="Marker3D" parent="."] diff --git a/Game/scripts/arena_boundary.gd b/Game/scripts/arena_boundary.gd index cae7af2d..33729beb 100644 --- a/Game/scripts/arena_boundary.gd +++ b/Game/scripts/arena_boundary.gd @@ -8,9 +8,45 @@ extends StaticBody3D 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 +# Goal-centre distance from arena centre. Flush with the end walls (see +# arena_01.tscn's goal transforms), so there is no floating gap between the +# goal and the wall for a ball to ramp across before reaching the sensor. +const GOAL_LINE_Z := INNER_HALF_Z + +# Curved transitions, so the ball rolls back into play instead of wedging +# into a 90° pocket and ships can carry speed up the walls: quarter-cylinder +# corner curves spanning the four vertical wall-wall edges, and base fillets +# easing the floor into every wall. Everything is generated in _ready from +# these constants, but collision and visuals deliberately differ: +# - collision is rings of thick flat boxes tangent to the true arc — a +# concave curve can't be one convex collider, primitives give Jolt clean +# stable contact normals (the wall-contact reward reads them), and 1 m of +# thickness is tunnel-proof at ball speeds; +# - visuals are single smooth ArrayMesh surfaces (one per corner plus one +# for all fillets) — proper curved normals, no seams or double-tinted +# overlaps, one draw call each. +# The corner curves reach at most the chord plane +# |x| + |z| = INNER_HALF_X + INNER_HALF_Z - CORNER_RADIUS. +const CORNER_RADIUS := 4.0 +const BASE_RADIUS := 2.0 +# The end-wall fillets stop short of the goal mouth so floor-level shots +# roll flat into the goal sensor (3.5 m wide) instead of ramping over it. +const GOAL_MOUTH_HALF_WIDTH := 2.5 +# Flat collider segments per quarter arc. Max sag from the true curve is +# R * (1 - cos(45° / N)): under 4 cm for both radii, invisible to the ball. +const CORNER_SEGMENTS := 6 +const BASE_SEGMENTS := 4 +# Path segments carrying the fillet around each corner curve's base. +const WRAP_SEGMENTS := 3 +# Arc steps for the smooth visual surfaces (finer than the colliders; the +# ball can sit at most ~4 cm proud of the drawn surface, which never reads). +const CORNER_VISUAL_ARCS := 16 +const FILLET_VISUAL_ARCS := 8 +# Match the boxes in arena_boundary.tscn: 1 m thick surfaces, walls spanning +# y -1..12 (flush with the floor slab's bottom and the ceiling slab's top). +const SURFACE_THICKNESS := 1.0 +const WALL_HEIGHT := INNER_HEIGHT + 1.0 +const WALL_CENTRE_Y := INNER_HEIGHT / 2.0 - 0.5 @onready var _wall_pos_x: MeshInstance3D = $WallPosXMesh @onready var _wall_neg_x: MeshInstance3D = $WallNegXMesh @@ -18,6 +54,15 @@ const GOAL_LINE_Z := 17.0 @onready var _wall_neg_z: MeshInstance3D = $WallNegZMesh @onready var _ceiling: MeshInstance3D = $CeilingMesh +# Corner curve visuals, following the same hide-when-the-camera-is-outside +# rule as the walls: {mesh, point (on the 45° tangent plane), outward}. +var _corner_visuals: Array[Dictionary] = [] + + +func _ready() -> void: + _build_corner_curves() + _build_base_fillets() + func _process(_delta: float) -> void: # The translucent field material tints everything behind it, so any face @@ -33,3 +78,267 @@ func _process(_delta: float) -> void: _wall_pos_z.visible = p.z < INNER_HALF_Z _wall_neg_z.visible = p.z > -INNER_HALF_Z _ceiling.visible = p.y < INNER_HEIGHT + for visual in _corner_visuals: + var mesh: MeshInstance3D = visual["mesh"] + var point: Vector3 = visual["point"] + var outward: Vector3 = visual["outward"] + mesh.visible = (p - point).dot(outward) < 0.0 + + +# Vertical quarter-cylinder curves across the four wall-wall corners, faced +# with the walls' translucent field material. +func _build_corner_curves() -> void: + var field_material: Material = (_wall_pos_x.mesh as BoxMesh).material + var arc_step := (PI / 2.0) / CORNER_SEGMENTS + # Wide enough that adjacent tangent segments overlap instead of gapping. + var face_width := 2.0 * CORNER_RADIUS * tan(arc_step / 2.0) + 0.4 + for sx in [-1.0, 1.0]: + for sz in [-1.0, 1.0]: + var arc_centre := Vector3( + sx * (INNER_HALF_X - CORNER_RADIUS), + 0.0, + sz * (INNER_HALF_Z - CORNER_RADIUS) + ) + for i in CORNER_SEGMENTS: + # Angle sweeps the quarter arc from facing the ±x wall (0) + # to facing the ±z wall (90°); segments are tangent at + # their arc midpoints, so the ends sit flush on the walls. + var angle := (i + 0.5) * arc_step + var outward := Vector3(sx * cos(angle), 0.0, sz * sin(angle)) + _add_curve_collider( + arc_centre + outward * CORNER_RADIUS + Vector3.UP * WALL_CENTRE_Y, + outward, Vector3.UP, face_width, WALL_HEIGHT + ) + _add_corner_visual(sx, sz, arc_centre, field_material) + + +# One smooth quarter-cylinder surface (floor to ceiling) plus a top cap so +# the curve doesn't read as a hollow tube from above the (hidden) ceiling. +func _add_corner_visual(sx: float, sz: float, arc_centre: Vector3, material: Material) -> void: + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var arc_step := (PI / 2.0) / CORNER_VISUAL_ARCS + var top := Vector3.UP * INNER_HEIGHT + var cap_corner := Vector3(sx * INNER_HALF_X, INNER_HEIGHT, sz * INNER_HALF_Z) + for i in CORNER_VISUAL_ARCS: + var dir_a := Vector3(sx * cos(i * arc_step), 0.0, sz * sin(i * arc_step)) + var dir_b := Vector3(sx * cos((i + 1) * arc_step), 0.0, sz * sin((i + 1) * arc_step)) + var base_a := arc_centre + dir_a * CORNER_RADIUS + var base_b := arc_centre + dir_b * CORNER_RADIUS + _add_quad(st, base_a, -dir_a, base_b, -dir_b, base_b + top, -dir_b, base_a + top, -dir_a) + _add_cap_tri(st, cap_corner, base_a + top, base_b + top, Vector3.UP) + st.set_material(material) + var mesh_instance := MeshInstance3D.new() + mesh_instance.mesh = st.commit() + mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(mesh_instance) + # Hide when the camera crosses the 45° tangent plane — the deepest point + # of the curve, so it never vanishes while the camera is still in play. + var outward_45 := Vector3(sx, 0.0, sz).normalized() + _corner_visuals.append({ + "mesh": mesh_instance, + "point": arc_centre + outward_45 * CORNER_RADIUS, + "outward": outward_45, + }) + + +# Quarter-cylinder fillets easing the floor into each wall, faced with the +# floor material (they read as curved skirting, and like the floor they are +# never hidden — they sit too low to block the view). Torus sections wrap +# the fillet around each corner curve, joining the side- and end-wall runs +# with no exposed end face; the goal-mouth ends stay a simple flat cutoff +# (capped) since the goal now sits flush with the wall there (see +# GOAL_LINE_Z) — there is no floating approach for a ship to hug at speed. +func _build_base_fillets() -> void: + var floor_material: Material = ($FloorMesh.mesh as BoxMesh).material + var arc_step := (PI / 2.0) / BASE_SEGMENTS + var face_width := 2.0 * BASE_RADIUS * tan(arc_step / 2.0) + 0.3 + # Side-wall fillets run the full span between the corner wraps; end-wall + # fillets run from the corner wraps to the goal mouth. + var side_run := 2.0 * (INNER_HALF_Z - CORNER_RADIUS) + var end_run := (INNER_HALF_X - CORNER_RADIUS) - GOAL_MOUTH_HALF_WIDTH + var end_centre_x := GOAL_MOUTH_HALF_WIDTH + end_run / 2.0 + # Each run: horizontal unit vector toward its wall, unit direction along + # the wall, centre of its arc axis, run length, and which ends (in + # +run_dir / -run_dir order) are exposed and need a cap rather than + # meeting a corner wrap. + var runs: Array[Dictionary] = [] + for side in [-1.0, 1.0]: + runs.append({ + "wall_out": Vector3(side, 0, 0), + "run_dir": Vector3(0, 0, 1), + "centre": Vector3(side * (INNER_HALF_X - BASE_RADIUS), BASE_RADIUS, 0), + "length": side_run, + "caps": [false, false], + }) + for goal_side in [-1.0, 1.0]: + runs.append({ + "wall_out": Vector3(0, 0, side), + "run_dir": Vector3(1, 0, 0), + "centre": Vector3(goal_side * end_centre_x, BASE_RADIUS, side * (INNER_HALF_Z - BASE_RADIUS)), + "length": end_run, + # The end at the corner wrap is unexposed; the end at the + # goal mouth needs a cap. run_dir is always +X, so the + # mouth-facing end is -run_dir when goal_side is +1. + "caps": [goal_side > 0.0, goal_side < 0.0], + }) + + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + for run in runs: + var wall_out: Vector3 = run["wall_out"] + var run_dir: Vector3 = run["run_dir"] + var centre: Vector3 = run["centre"] + var length: float = run["length"] + var caps: Array = run["caps"] + # Profile angle sweeps the quarter arc from facing the floor (0) to + # facing the wall (90°); the direction points from the arc axis into + # the fillet material. + for i in BASE_SEGMENTS: + var outward := _fillet_dir(wall_out, (i + 0.5) * arc_step) + _add_curve_collider(centre + outward * BASE_RADIUS, outward, run_dir, face_width, length) + _add_fillet_visual(st, wall_out, run_dir, centre, length, caps[0], caps[1]) + for sx in [-1.0, 1.0]: + for sz in [-1.0, 1.0]: + _add_fillet_corner_wrap(st, sx, sz) + st.set_material(floor_material) + var mesh_instance := MeshInstance3D.new() + mesh_instance.mesh = st.commit() + mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(mesh_instance) + + +# Torus section carrying the fillet around a corner curve's base: the fillet +# profile swept along the corner arc, meeting the straight runs flush at both +# ends. Sweep position: with u(phi) the horizontal radial direction from the +# corner arc's centre, the surface is +# P(phi, theta) = centre + u * (CORNER_RADIUS - BASE_RADIUS +# + BASE_RADIUS * sin(theta)) + UP * BASE_RADIUS * (1 - cos(theta)) +# whose outward (into-material) normal is u * sin(theta) - UP * cos(theta). +func _add_fillet_corner_wrap(st: SurfaceTool, sx: float, sz: float) -> void: + var origin := Vector3(sx * (INNER_HALF_X - CORNER_RADIUS), 0.0, sz * (INNER_HALF_Z - CORNER_RADIUS)) + var axis_radius := CORNER_RADIUS - BASE_RADIUS + var path_step := (PI / 2.0) / WRAP_SEGMENTS + var profile_step := (PI / 2.0) / BASE_SEGMENTS + var face_width := 2.0 * BASE_RADIUS * tan(profile_step / 2.0) + 0.3 + for i in WRAP_SEGMENTS: + var phi := (i + 0.5) * path_step + var u := Vector3(sx * cos(phi), 0.0, sz * sin(phi)) + var along := Vector3(-sx * sin(phi), 0.0, sz * cos(phi)) + for j in BASE_SEGMENTS: + var theta := (j + 0.5) * profile_step + var ring_radius := axis_radius + BASE_RADIUS * sin(theta) + var face_centre := origin + u * ring_radius \ + + Vector3.UP * (BASE_RADIUS * (1.0 - cos(theta))) + var outward := u * sin(theta) - Vector3.UP * cos(theta) + var run_length := ring_radius * 2.0 * tan(path_step / 2.0) + 0.3 + _add_curve_collider(face_centre, outward, along, face_width, run_length) + # Smooth visual patch over the same torus. + var v_path := 8 + var v_step := (PI / 2.0) / v_path + var p_step := (PI / 2.0) / FILLET_VISUAL_ARCS + for i in v_path: + for j in FILLET_VISUAL_ARCS: + var points: Array[Vector3] = [] + var normals: Array[Vector3] = [] + for corner in [[i, j], [i + 1, j], [i + 1, j + 1], [i, j + 1]]: + var u_c := Vector3(sx * cos(corner[0] * v_step), 0.0, sz * sin(corner[0] * v_step)) + var theta_c: float = corner[1] * p_step + points.append(origin + u_c * (axis_radius + BASE_RADIUS * sin(theta_c)) \ + + Vector3.UP * (BASE_RADIUS * (1.0 - cos(theta_c)))) + normals.append(-(u_c * sin(theta_c) - Vector3.UP * cos(theta_c))) + _add_quad( + st, points[0], normals[0], points[1], normals[1], + points[2], normals[2], points[3], normals[3] + ) + + +# One smooth fillet strip, optionally capped at either end (see caller: an +# end is capped when it stops at the goal mouth rather than meeting a +# corner wrap). +func _add_fillet_visual( + st: SurfaceTool, wall_out: Vector3, run_dir: Vector3, centre: Vector3, length: float, + cap_start: bool, cap_end: bool +) -> void: + var arc_step := (PI / 2.0) / FILLET_VISUAL_ARCS + var end_a := centre - run_dir * (length / 2.0) + var end_b := centre + run_dir * (length / 2.0) + for i in FILLET_VISUAL_ARCS: + var dir_a := _fillet_dir(wall_out, i * arc_step) + var dir_b := _fillet_dir(wall_out, (i + 1) * arc_step) + _add_quad( + st, + end_a + dir_a * BASE_RADIUS, -dir_a, + end_b + dir_a * BASE_RADIUS, -dir_a, + end_b + dir_b * BASE_RADIUS, -dir_b, + end_a + dir_b * BASE_RADIUS, -dir_b + ) + var caps: Array[Array] = [] + if cap_start: + caps.append([end_a, -run_dir]) + if cap_end: + caps.append([end_b, run_dir]) + for cap in caps: + var end_point: Vector3 = cap[0] + var cap_normal: Vector3 = cap[1] + # Fan from the wall-floor corner of the cross-section to the arc. + var cap_corner := end_point + (wall_out + Vector3.DOWN) * BASE_RADIUS + for i in FILLET_VISUAL_ARCS: + var p0 := end_point + _fillet_dir(wall_out, i * arc_step) * BASE_RADIUS + var p1 := end_point + _fillet_dir(wall_out, (i + 1) * arc_step) * BASE_RADIUS + _add_cap_tri(st, cap_corner, p0, p1, cap_normal) + + +func _fillet_dir(wall_out: Vector3, angle: float) -> Vector3: + return wall_out * sin(angle) + Vector3.DOWN * cos(angle) + + +# One flat tangent collider segment of a curved surface: a box whose inner +# face is centred on face_centre, facing -outward, running run_length along +# `along` and face_width across. +func _add_curve_collider( + face_centre: Vector3, outward: Vector3, along: Vector3, + face_width: float, run_length: float +) -> void: + var segment_basis := Basis(outward, along, outward.cross(along)) + var origin := face_centre + outward * (SURFACE_THICKNESS / 2.0) + var collision := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(SURFACE_THICKNESS, run_length, face_width) + collision.shape = box + collision.transform = Transform3D(segment_basis, origin) + add_child(collision) + + +# Quad a-b-c-d with per-vertex normals, wound so the front faces the normals +# (Godot front faces wind clockwise when seen from the normal side). +func _add_quad( + st: SurfaceTool, + a: Vector3, na: Vector3, b: Vector3, nb: Vector3, + c: Vector3, nc: Vector3, d: Vector3, nd: Vector3 +) -> void: + if (b - a).cross(c - a).dot(na + nb + nc + nd) < 0.0: + _add_tri(st, a, na, b, nb, c, nc) + _add_tri(st, a, na, c, nc, d, nd) + else: + _add_tri(st, a, na, d, nd, c, nc) + _add_tri(st, a, na, c, nc, b, nb) + + +func _add_cap_tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, normal: Vector3) -> void: + if (b - a).cross(c - a).dot(normal) < 0.0: + _add_tri(st, a, normal, b, normal, c, normal) + else: + _add_tri(st, a, normal, c, normal, b, normal) + + +func _add_tri( + st: SurfaceTool, + a: Vector3, na: Vector3, b: Vector3, nb: Vector3, c: Vector3, nc: Vector3 +) -> void: + st.set_normal(na) + st.add_vertex(a) + st.set_normal(nb) + st.add_vertex(b) + st.set_normal(nc) + st.add_vertex(c) diff --git a/Game/scripts/ship_ai_controller.gd b/Game/scripts/ship_ai_controller.gd index 6e74fd38..ed3b40fe 100644 --- a/Game/scripts/ship_ai_controller.gd +++ b/Game/scripts/ship_ai_controller.gd @@ -16,6 +16,12 @@ extends AIController3D # per sim-second); event terms fire once. Exported so tuning needs no code # edits. Goal rewards are added by TrainingMode, which owns goal events. @export var ball_touch_reward := 1.0 +# Ball touches pay out at most once per this many physics ticks (1 sim- +# second at 60). Run07 lesson: body_entered re-fires on every micro- +# separation, so pinning the ball against a surface farmed ~2 touches/s — +# outearning every other term while the goal rate fell. The cooldown keeps +# touches a stepping-stone signal instead of the objective. +@export var ball_touch_cooldown_ticks := 60 @export var velocity_to_ball_weight := 0.02 @export var ball_velocity_to_goal_weight := 0.004 # Per-tick penalty scaled by distance to the ball (full value at the arena's @@ -36,6 +42,12 @@ extends AIController3D # value (-0.12/s) when inverted. A penalty rather than an upright bonus so a # flat, idle ship farms nothing. @export var tilt_penalty := 0.002 +# Per-tick bonus for own speed: 0 stationary, full value (+0.24/s) at +# max_speed. Run07 lesson: after the kickoff flurry both ships parked next to +# a cornered ball — with every other dense term near zero there, standing +# still was a rest state. Sized well below velocity_to_ball_weight so flying +# fast toward the ball still beats flying fast anywhere else. +@export var speed_reward_weight := 0.004 # Contact normals with y above this are floor contact (exempt from the wall # penalty); below it they read as wall (sideways) or ceiling (downward). @@ -55,6 +67,8 @@ var ball: RigidBody3D var opponent: Ship var attack_goal_position: Vector3 +var _ticks_since_ball_touch := 1 << 30 # large so the first touch always pays + # Wire up references after the ship is spawned. `attack_goal` is the goal # this ship scores into (goal.team == opponent's team). @@ -97,10 +111,16 @@ func set_action(action) -> void: rl_controller.action.turbo = int(action["turbo"]) == 1 +func reset(): + super() + _ticks_since_ball_touch = 1 << 30 + + func _physics_process(delta): super(delta) if not is_instance_valid(ship) or not is_instance_valid(ball): return + _ticks_since_ball_touch += 1 # Dense shaping: own velocity toward the ball var to_ball := ball.global_position - ship.global_position @@ -113,6 +133,11 @@ func _physics_process(delta): if ball_distance_penalty > 0.0: reward -= ball_distance_penalty * to_ball.length() / MAX_BALL_DISTANCE + # Dense bonus: own speed, so hovering in place is never a rest state + # (see speed_reward_weight). + if speed_reward_weight > 0.0: + reward += speed_reward_weight * ship.linear_velocity.length() / ship.max_speed + # Dense shaping: ball velocity toward the goal we attack var ball_to_goal := attack_goal_position - ball.global_position if ball_to_goal.length_squared() > 0.0001: @@ -149,5 +174,6 @@ func _wall_or_ceiling_contact() -> bool: func _on_ship_body_entered(body: Node) -> void: - if body.is_in_group("ball"): + if body.is_in_group("ball") and _ticks_since_ball_touch >= ball_touch_cooldown_ticks: reward += ball_touch_reward + _ticks_since_ball_touch = 0 diff --git a/Game/scripts/ship_observations.gd b/Game/scripts/ship_observations.gd index 3b3e8361..31bb9f52 100644 --- a/Game/scripts/ship_observations.gd +++ b/Game/scripts/ship_observations.gd @@ -12,8 +12,9 @@ extends RefCounted # it is its own inverse). # 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/. +# z ±18, height 12, goals at z ±18 (flush with the end walls); 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 diff --git a/Game/scripts/training_mode.gd b/Game/scripts/training_mode.gd index bf23bf28..445de9d9 100644 --- a/Game/scripts/training_mode.gd +++ b/Game/scripts/training_mode.gd @@ -33,6 +33,16 @@ 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 := 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 +# from that plane as from the walls (perpendicular distance, hence the +# sqrt(2) when expressed in |x| + |z| terms). The true curve bulges outward +# from the chord, so this is conservative. +const CORNER_LIMIT := ArenaBoundary.INNER_HALF_X + ArenaBoundary.INNER_HALF_Z \ + - ArenaBoundary.CORNER_RADIUS - SPAWN_INSET * sqrt(2.0) +# Below this height a tilted ship could reach down into the wall-base +# fillets, so low spawns stay an extra BASE_RADIUS off the walls. +const FILLET_CLEAR_Y := ArenaBoundary.BASE_RADIUS + FIELD_MIN_Y const MAX_RANDOM_BALL_SPEED := 12.0 const MAX_RANDOM_SHIP_SPEED := 8.0 @@ -224,11 +234,28 @@ func _place_ships_random() -> void: func _random_position() -> Vector3: - return Vector3( - randf_range(-FIELD_HALF_X, FIELD_HALF_X), - randf_range(FIELD_MIN_Y, FIELD_MAX_Y), - randf_range(-FIELD_HALF_Z, FIELD_HALF_Z) - ) + # 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. + var position := Vector3.ZERO + for _attempt in 20: + position = Vector3( + randf_range(-FIELD_HALF_X, FIELD_HALF_X), + randf_range(FIELD_MIN_Y, FIELD_MAX_Y), + randf_range(-FIELD_HALF_Z, FIELD_HALF_Z) + ) + if _spawn_position_clear(position): + break + return position + + +func _spawn_position_clear(position: Vector3) -> bool: + if absf(position.x) + absf(position.z) > CORNER_LIMIT: + return false + if position.y >= FILLET_CLEAR_Y: + return true + return absf(position.x) <= FIELD_HALF_X - ArenaBoundary.BASE_RADIUS \ + and absf(position.z) <= FIELD_HALF_Z - ArenaBoundary.BASE_RADIUS func _random_direction() -> Vector3: