class_name ArenaBoundary extends StaticBody3D # The standard arena play volume (inner faces of the enclosure). Every arena # instances objects/arena_boundary.tscn so all arenas share one size; code # that needs field dimensions derives them from these constants rather than # restating numbers. const INNER_HALF_X := 12.0 const INNER_HALF_Z := 18.0 const INNER_HEIGHT := 12.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 @onready var _wall_pos_z: MeshInstance3D = $WallPosZMesh @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: add_to_group("arena_boundary") _build_corner_curves() _build_base_fillets() # Wall+ceiling-only proximity force field ("artificial gravity" grav-plating, # weaker than the floor's plain default gravity, which this deliberately # leaves untouched). Ship and Ball each call this with their own # strength/range so wall adherence and ceiling adherence can be tuned # independently per body — see ship.gd/ball.gd. Quadratic falloff keeps a # casual flyby near a wall almost force-free, concentrating the pull in # roughly the last third of the range so it only bites once something is # genuinely close to the surface. func get_surface_pull( global_pos: Vector3, wall_strength: float, wall_range: float, ceiling_strength: float, ceiling_range: float ) -> Vector3: var p := to_local(global_pos) var pull := Vector3.ZERO pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength pull += Vector3(-1, 0, 0) * _falloff(INNER_HALF_X + p.x, wall_range) * wall_strength # End walls are gated off inside the goal mouth — there is no physical # wall there (see GOAL_MOUTH_HALF_WIDTH / _build_base_fillets), so a shot # heading straight for the net doesn't feel a phantom sideways tug. if abs(p.x) >= GOAL_MOUTH_HALF_WIDTH: pull += Vector3(0, 0, 1) * _falloff(INNER_HALF_Z - p.z, wall_range) * wall_strength pull += Vector3(0, 0, -1) * _falloff(INNER_HALF_Z + p.z, wall_range) * wall_strength pull += Vector3.UP * _falloff(INNER_HEIGHT - p.y, ceiling_range) * ceiling_strength return pull func _falloff(dist: float, field_range: float) -> float: var t: float = clamp(1.0 - dist / field_range, 0.0, 1.0) return t * t func _process(_delta: float) -> void: # The translucent field material tints everything behind it, so any face # the camera has crossed to the outside of is hidden entirely — looking # into the arena from outside stays clear, while faces seen from inside # keep their tint. Collision is untouched; only the meshes toggle. var camera := get_viewport().get_camera_3d() if camera == null: return # headless (RL/CI) has no camera var p := to_local(camera.global_position) _wall_pos_x.visible = p.x < INNER_HALF_X _wall_neg_x.visible = p.x > -INNER_HALF_X _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)