feat(*): Add rounded arena boundaries and reward shaping to curb corner-camping

This commit is contained in:
Josh Creek
2026-07-20 08:20:33 +01:00
parent 6cb5902eb6
commit 3457d4ca84
5 changed files with 376 additions and 13 deletions
+2 -2
View File
@@ -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="."]
+312 -3
View File
@@ -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)
+27 -1
View File
@@ -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
+3 -2
View File
@@ -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
+32 -5
View File
@@ -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: