Files
Josh Creek 04691aaa48 chore(multiplayer): Phase 0 refactors + graphics/perf settings groundwork
Lands the non-networked Phase 0 tasks from multiplayer-todo.md (ship/camera/
arena refactors, sim constants, background FPS handling) plus a first pass
at exposing graphics/performance settings (presets, resolution scaling,
vsync, FPS cap, perf overlay) and a GPU profiling harness for the
real-hardware follow-up in task 0.15b.
2026-08-19 22:37:17 +01:00

781 lines
35 KiB
GDScript

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 := 18.0
const INNER_HALF_Z := 27.0
const INNER_HEIGHT := 18.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
# FLOOR (default) keeps the goal flush with the floor, as above. ELEVATED
# moves the goal to GOAL_CENTER_Y — see the arena_0X_elevated.tscn scenes,
# which bake that Y directly onto their Goal nodes and override this export.
# There's no ramp: ships fly freely (see ship.gd), so nothing physically
# needs to lead up to an elevated goal.
enum GoalMode { FLOOR, ELEVATED }
@export var goal_mode: GoalMode = GoalMode.FLOOR
const GOAL_CENTER_Y := INNER_HEIGHT / 2.0 # ELEVATED mode goal centre
# 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, base fillets
# easing the floor into every wall, and ceiling fillets easing every wall
# into the ceiling. 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 one merged shell (see _build_visual_shell) — proper curved
# normals, and every surface drawn exactly once.
# The base and ceiling fillets are the same skirting mirrored vertically, so
# every function that builds one takes a `rise` of +1 (base, climbing away
# from the floor) or -1 (ceiling, dropping away from it) and serves both.
# 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
# The real navigable hole in the wall's collision (see
# _build_end_wall_colliders) — must track objects/goal.tscn's BoxShape3D
# (3.5 x 1.5, i.e. half-width 1.75, height 1.5) exactly. Anything wider than
# the scoring sensor lets the ball cross the wall opening without entering
# the sensor and failing to score; this is collision-authoritative, do not
# widen it for cosmetic reasons — see GOAL_VISUAL_APERTURE_* below for that.
# Half-width matches the sensor exactly; height is a few cm off in FLOOR mode
# (the sensor is vertically offset by the goal node's own y=0.79 placement,
# while the hole itself is measured from the floor at y=0 — see
# _goal_surround_bounds), which is immaterial at the ball's radius but means
# "exact match" isn't literally true.
const GOAL_APERTURE_HALF_WIDTH := 1.75
const GOAL_APERTURE_HEIGHT := 1.5
# The (larger) aperture the visual shell/opaque bulkhead actually cuts —
# deliberately wider than the true collision hole above, so goal.gd's bezel/
# rim frame (built starting exactly at the sensor's own half-extents) has
# clearance to be seen against the hull's cut edge instead of sitting flush
# with it. Cosmetic only: collision still uses the exact GOAL_APERTURE_*
# pair above, so this can't reopen the "ball crosses without scoring" bug.
const GOAL_VISUAL_APERTURE_HALF_WIDTH := GOAL_APERTURE_HALF_WIDTH + 0.1
const GOAL_VISUAL_APERTURE_HEIGHT := GOAL_APERTURE_HEIGHT + 0.15
# The surround has to be opaque hull: backed by the translucent field panel
# instead, the goal's recess and net showed straight through the wall beside
# the mouth and read as a second, duplicated net.
# ELEVATED only — in FLOOR mode the surround spans the deck to the fillet
# tangent, so its height is BASE_RADIUS and needs no constant.
const GOAL_SURROUND_HALF_HEIGHT := 1.6
# 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
# Path steps for the torus patches carrying a fillet around a corner curve.
const WRAP_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
# Where the flat deck/ceiling stops and the fillets take over, and where the
# fillets hand over to the wall panels. Every piece of the shell is cut to
# these so no two surfaces overlap.
const FLAT_HALF_X := INNER_HALF_X - BASE_RADIUS
const FLAT_HALF_Z := INNER_HALF_Z - BASE_RADIUS
const CORNER_CENTRE_X := INNER_HALF_X - CORNER_RADIUS
const CORNER_CENTRE_Z := INNER_HALF_Z - CORNER_RADIUS
const WRAP_RADIUS := CORNER_RADIUS - BASE_RADIUS
const BASE_FILLET := 1.0
const CEILING_FILLET := -1.0
const FIELD_SHADER_PATH := "res://shaders/energy_field.gdshader"
const DECK_SHADER_PATH := "res://shaders/arena_deck.gdshader"
@export var field_tint := Color(0.45, 0.65, 1.0)
@export var field_intensity := 0.09
@export var team0_tint := TeamColors.TEAM_COLORS[0]
@export var team1_tint := TeamColors.TEAM_COLORS[1]
# The single merged surface shell and the material whose camera-side fade
# _process() drives. Both stay null in headless runs, which never render.
var _shell: MeshInstance3D
var _field_material: ShaderMaterial
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
# pattern so the viewport lookup isn't repeated every frame.
var _camera: Camera3D
var _last_camera_local_pos := Vector3.INF
# Below this, the shader's per-pixel facing test can't produce a visibly
# different result — skip the to_local()/set_shader_parameter() call.
const CAMERA_UNIFORM_UPDATE_THRESHOLD := 0.05
# Group every generated collider is tagged with. A CollisionShape3D only
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
# earlier version of this code grouped generated colliders under an
# intermediate Node3D container for identification, which silently made
# every one of them inert (no shape ever reached the StaticBody3D). They must
# be direct children of `self`; the group tag is how bake_colliders()
# identifies (and clears, for idempotent re-baking) its own prior output
# without an intermediate node.
const GENERATED_COLLIDER_GROUP := "_arena_generated_collider"
func _ready() -> void:
add_to_group("arena_boundary")
# goal_mode affects the generated geometry itself (see _fillet_runs'
# `split` and _build_end_wall_colliders' hole placement), but it's an
# instance-level override each arena_0X_elevated.tscn applies to the
# shared arena_boundary.tscn's Boundary node — so a bake done in FLOOR
# mode (the shared .tscn's default) must not be silently reused by an
# ELEVATED instance. Only skip regenerating when the existing bake was
# actually made in the current goal_mode.
if get_meta("baked_goal_mode", -1) != goal_mode:
bake_colliders()
# Visuals are pure decoration and training spawns many headless instances
# that never render one, so skip the mesh build there entirely. Collision
# is unaffected either way.
if DisplayServer.get_name() == "headless":
set_process(false)
return
_build_visual_shell()
# Builds every generated collider (corner curves, base/ceiling fillets, end-
# wall goal holes) as direct children of this ArenaBoundary (required for
# them to register with the StaticBody3D at all) and records the goal_mode
# they were built for. Idempotent: clears any of its own prior output first
# (identified via GENERATED_COLLIDER_GROUP), so calling this repeatedly — a
# stale bake whose goal_mode doesn't match (see _ready), or re-running
# tools/bake_arena_boundary.gd on an already-baked scene — replaces rather
# than duplicates. _ready() calls this itself for any scene that hasn't been
# baked yet, so behaviour is identical either way; baking only skips redoing
# this work on every subsequent load, which matters most for the many
# parallel headless training envs that would otherwise pay it on every
# episode reset.
func bake_colliders() -> void:
for child in get_children():
if child.is_in_group(GENERATED_COLLIDER_GROUP):
child.free()
_build_corner_colliders()
_build_fillet_colliders(BASE_FILLET)
_build_fillet_colliders(CEILING_FILLET)
_build_end_wall_colliders()
set_meta("baked_goal_mode", goal_mode)
# 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:
# Early-out: every dynamic body pays to_local() plus five _falloff calls
# every tick even mid-arena, where every term is exactly zero. Compared
# directly against global_pos, matching the same identity-transform
# assumption GameMode._is_escaped already makes against these constants.
if absf(global_pos.x) < INNER_HALF_X - wall_range \
and absf(global_pos.z) < INNER_HALF_Z - wall_range \
and global_pos.y < INNER_HEIGHT - ceiling_range:
return Vector3.ZERO
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 / _fillet_runs), 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 field shader fades out any facet the camera has crossed to the
# outside of, so looking into the arena from outside stays clear. It does
# that per-pixel from this one uniform, which is what lets the whole
# enclosure be a single mesh instead of per-face MeshInstance3Ds toggled
# individually. Collision is untouched.
if _field_material == null:
return
var camera := _get_camera()
if camera == null:
return # headless (RL/CI) has no camera
var local_pos := to_local(camera.global_position)
if local_pos.distance_to(_last_camera_local_pos) < CAMERA_UNIFORM_UPDATE_THRESHOLD:
return
_last_camera_local_pos = local_pos
_field_material.set_shader_parameter("camera_local_pos", local_pos)
# Caches the viewport's active camera; a plain is_instance_valid revalidation
# is enough because exactly one ship_camera_rig (Camera3D) is spawned per
# game-mode run (GameMode.spawn_camera_rig, called once each from
# free_play.gd/match_mode.gd/spectate_mode.gd) and never re-spawned mid-match
# — there's no active-camera-switch scenario today. ArenaBoundary itself is
# destroyed/recreated per scene change, so the cache naturally resets with it;
# there's no stale-cache-across-scenes concern. Revisit this if split-screen
# or multiplayer camera-switching lands (see TODO.md's multiplayer section).
func _get_camera() -> Camera3D:
if not is_instance_valid(_camera):
_camera = get_viewport().get_camera_3d()
return _camera
# --- shared layout -----------------------------------------------------------
# Consumed by both the collider rings and the visual shell, so the two can
# never end up describing different geometry.
func _corner_centre(sx: float, sz: float) -> Vector3:
return Vector3(sx * CORNER_CENTRE_X, 0.0, sz * CORNER_CENTRE_Z)
# The surface a fillet eases out of: the floor for the base run, the ceiling
# for the mirrored one.
func _fillet_base_y(rise: float) -> float:
return 0.0 if rise > 0.0 else INNER_HEIGHT
# Profile direction at `angle`, sweeping from facing the surface the fillet
# eases out of (0) round to facing the wall (90°), pointing from the arc axis
# into the fillet material.
func _fillet_dir(wall_out: Vector3, angle: float, rise: float) -> Vector3:
return wall_out * sin(angle) - Vector3.UP * (rise * cos(angle))
# 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 visual cap rather than meeting a
# corner wrap.
#
# In FLOOR mode the base run along each end wall stops short of the goal mouth
# (flat cutoff, capped) so floor-level shots roll flat into the goal sensor
# instead of ramping over it — the goal sits flush with the wall there (see
# GOAL_LINE_Z). Nothing else splits: an elevated goal isn't at floor level, and
# the ceiling has nothing goal-related to keep clear, so both run full width.
func _fillet_runs(rise: float) -> Array[Dictionary]:
var centre_y := _fillet_base_y(rise) + rise * BASE_RADIUS
var split := rise > 0.0 and goal_mode == GoalMode.FLOOR
var side_run := 2.0 * CORNER_CENTRE_Z
var end_run_full := 2.0 * CORNER_CENTRE_X
var end_run_split := CORNER_CENTRE_X - GOAL_MOUTH_HALF_WIDTH
var end_centre_x := GOAL_MOUTH_HALF_WIDTH + end_run_split / 2.0
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 * FLAT_HALF_X, centre_y, 0),
"length": side_run,
"caps": [false, false],
})
if not split:
runs.append({
"wall_out": Vector3(0, 0, side),
"run_dir": Vector3(1, 0, 0),
"centre": Vector3(0, centre_y, side * FLAT_HALF_Z),
"length": end_run_full,
"caps": [false, false],
})
continue
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, centre_y, side * FLAT_HALF_Z),
"length": end_run_split,
# 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],
})
return runs
# --- collision ---------------------------------------------------------------
# Builds the tangent-box collider rings and each end wall's real goal hole.
# Geometry is free to evolve now that bots are retrained from scratch — just
# re-run tools/bake_arena_boundary.gd afterward so the baked scene (see
# bake_colliders) reflects the change.
# Vertical quarter-cylinder curves across the four wall-wall corners.
func _build_corner_colliders() -> void:
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 := _corner_centre(sx, sz)
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
)
# Quarter-cylinder fillets easing every wall into the floor (rise +1) or the
# ceiling (rise -1), with torus sections wrapping each corner curve so the
# side- and end-wall runs join with no exposed end face.
func _build_fillet_colliders(rise: float) -> void:
var arc_step := (PI / 2.0) / BASE_SEGMENTS
var face_width := 2.0 * BASE_RADIUS * tan(arc_step / 2.0) + 0.3
for run in _fillet_runs(rise):
var wall_out: Vector3 = run["wall_out"]
var run_dir: Vector3 = run["run_dir"]
var centre: Vector3 = run["centre"]
var length: float = run["length"]
for i in BASE_SEGMENTS:
var outward := _fillet_dir(wall_out, (i + 0.5) * arc_step, rise)
_add_curve_collider(centre + outward * BASE_RADIUS, outward, run_dir, face_width, length)
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
_add_wrap_colliders(sx, sz, rise)
# Torus section carrying a fillet around a corner curve. With u(phi) the
# horizontal radial direction from the corner arc's centre, the surface is
# P(phi, theta) = origin + u * (CORNER_RADIUS - BASE_RADIUS
# + BASE_RADIUS * sin(theta))
# + UP * rise * BASE_RADIUS * (1 - cos(theta))
# whose outward (into-material) normal is
# u * sin(theta) - UP * rise * cos(theta).
# Note `rise` enters the position and the normal with opposite signs.
func _add_wrap_colliders(sx: float, sz: float, rise: float) -> void:
var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise)
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 := WRAP_RADIUS + BASE_RADIUS * sin(theta)
var face_centre := origin + u * ring_radius \
+ Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta)))
var outward := u * sin(theta) - Vector3.UP * (rise * 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)
# 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)
collision.add_to_group(GENERATED_COLLIDER_GROUP)
add_child(collision)
# A plain axis-aligned box collider centred on `center`. Used for the flat
# end-wall panels below, which aren't tangent to a curve so don't need
# _add_curve_collider's outward/along framing.
func _add_box_collider(center: Vector3, size: Vector3) -> void:
var collision := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
collision.shape = box
collision.transform = Transform3D(Basis.IDENTITY, center)
collision.add_to_group(GENERATED_COLLIDER_GROUP)
add_child(collision)
# One rectangular end-wall panel spanning x in [x0, x1] and y in [y0, y1] at
# the given wall_z, SURFACE_THICKNESS deep. No-ops for a degenerate (zero or
# negative area) range, which happens whenever the goal hole's aperture
# extends to the wall's own boundary (e.g. FLOOR mode's aperture bottom sits
# at the wall's own y0).
func _add_end_wall_panel(wall_z: float, x0: float, x1: float, y0: float, y1: float) -> void:
if x1 <= x0 or y1 <= y0:
return
_add_box_collider(
Vector3((x0 + x1) / 2.0, (y0 + y1) / 2.0, wall_z),
Vector3(x1 - x0, y1 - y0, SURFACE_THICKNESS)
)
# Real navigable hole in each end wall, sized to the goal's actual scoring
# aperture (GOAL_APERTURE_HALF_WIDTH/HEIGHT, which now track
# objects/goal.tscn's sensor — see that constant's comment) instead of the
# previous single solid box that let the ball trigger the goal sensor but
# never actually fly through the wall. _goal_surround_bounds() already knows
# the aperture's vertical placement for both FLOOR (floor-level) and ELEVATED
# (raised) goal modes, so this works unmodified for either. Left/right panels
# carry the full wall height; top/bottom panels close off the remainder of
# the aperture band above/below the hole. In FLOOR mode the bottom panel
# (y -1..0) is a harmless duplicate — FloorShape already occupies that
# region — rather than a true no-op: _add_end_wall_panel still emits it,
# since aperture_bottom (0) is above y_bottom (-1). Left in rather than
# special-cased since it costs one extra collider and can't create a gap.
func _build_end_wall_colliders() -> void:
var half_width := INNER_HALF_X + 1.0
var y_bottom := -1.0
var y_top := INNER_HEIGHT
var bounds := _goal_surround_bounds(GOAL_APERTURE_HEIGHT)
var aperture_bottom: float = bounds.z
var aperture_top: float = bounds.w
for sz in [-1.0, 1.0]:
var wall_z: float = sz * (INNER_HALF_Z + 0.5)
_add_end_wall_panel(wall_z, -half_width, -GOAL_APERTURE_HALF_WIDTH, y_bottom, y_top)
_add_end_wall_panel(wall_z, GOAL_APERTURE_HALF_WIDTH, half_width, y_bottom, y_top)
_add_end_wall_panel(wall_z, -GOAL_APERTURE_HALF_WIDTH, GOAL_APERTURE_HALF_WIDTH, aperture_top, y_top)
_add_end_wall_panel(wall_z, -GOAL_APERTURE_HALF_WIDTH, GOAL_APERTURE_HALF_WIDTH, y_bottom, aperture_bottom)
# --- visual shell ------------------------------------------------------------
# One mesh covering the inner surface of the play volume exactly once. Every
# piece is cut to meet its neighbours edge-on, so no two translucent surfaces
# overlap — overlapping alpha was what made the enclosure read as patches of
# differing brightness. Only inward-facing triangles are emitted; the outer
# faces and the 1 m overhang the old BoxMeshes carried simply don't exist.
#
# Two surfaces share the mesh: an opaque hull (deck, base fillets and the goal
# surrounds) and the translucent containment field (walls, corners, ceiling
# fillets, ceiling).
func _build_visual_shell() -> void:
var mesh := ArrayMesh.new()
var deck := SurfaceTool.new()
deck.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_flat_panel(deck, 0.0, Vector3.UP)
_add_goal_aprons(deck)
_add_goal_surrounds(deck)
_add_fillet_surface(deck, BASE_FILLET)
deck.commit(mesh)
mesh.surface_set_material(0, _make_deck_material())
_field_material = _make_field_material()
var field := SurfaceTool.new()
field.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_wall_panels(field)
_add_corner_panels(field)
_add_fillet_surface(field, CEILING_FILLET)
_add_flat_panel(field, INNER_HEIGHT, Vector3.DOWN)
field.commit(mesh)
mesh.surface_set_material(1, _field_material)
_shell = MeshInstance3D.new()
_shell.name = "SurfaceShell"
_shell.mesh = mesh
# Ships and the ball still cast onto the deck; the deck shadowing itself
# would only produce acne.
_shell.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_shell)
func _make_deck_material() -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = load(DECK_SHADER_PATH)
# The markings are drawn from these, so they track the collision geometry.
mat.set_shader_parameter("half_x", INNER_HALF_X)
mat.set_shader_parameter("half_z", INNER_HALF_Z)
mat.set_shader_parameter("goal_line_z", GOAL_LINE_Z)
mat.set_shader_parameter("base_radius", BASE_RADIUS)
mat.set_shader_parameter("team0_color", team0_tint)
mat.set_shader_parameter("team1_color", team1_tint)
mat.set_shader_parameter("seam_color", field_tint)
return mat
func _make_field_material() -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = load(FIELD_SHADER_PATH)
mat.set_shader_parameter("field_color", field_tint)
mat.set_shader_parameter("field_intensity", field_intensity)
return mat
# The flat deck (or ceiling): a rounded rectangle whose straight edges stop on
# the fillet tangents and whose corners follow the corner wraps' inner arc, so
# it meets the fillets exactly once. Decomposed into a centre span, two end
# spans narrowed to clear the corner arcs, and four quarter-discs — a partition
# with no overlaps.
func _add_flat_panel(st: SurfaceTool, y: float, face: Vector3) -> void:
var origin := Vector3(0, y, 0)
var right := Vector3(1, 0, 0)
var forward := Vector3(0, 0, 1)
_add_plane_quad(st, origin, right, forward, face,
-FLAT_HALF_X, FLAT_HALF_X, -CORNER_CENTRE_Z, CORNER_CENTRE_Z)
for sz in [-1.0, 1.0]:
var z0: float = sz * CORNER_CENTRE_Z
var z1: float = sz * FLAT_HALF_Z
_add_plane_quad(st, origin, right, forward, face,
-CORNER_CENTRE_X, CORNER_CENTRE_X, minf(z0, z1), maxf(z0, z1))
var step := (PI / 2.0) / FILLET_VISUAL_ARCS
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
var centre := _corner_centre(sx, sz) + Vector3.UP * y
for i in FILLET_VISUAL_ARCS:
var d0 := Vector3(sx * cos(i * step), 0.0, sz * sin(i * step))
var d1 := Vector3(sx * cos((i + 1) * step), 0.0, sz * sin((i + 1) * step))
_add_cap_tri(st, centre, centre + d0 * WRAP_RADIUS, centre + d1 * WRAP_RADIUS, face)
# In FLOOR mode the end-wall fillet stops short of the goal mouth, so the deck
# has to run flat all the way to the end wall across that gap — otherwise there
# would be a hole in front of each goal.
func _add_goal_aprons(st: SurfaceTool) -> void:
if goal_mode != GoalMode.FLOOR:
return
for sz in [-1.0, 1.0]:
var z0: float = sz * FLAT_HALF_Z
var z1: float = sz * INNER_HALF_Z
_add_plane_quad(st, Vector3.ZERO, Vector3(1, 0, 0), Vector3(0, 0, 1), Vector3.UP,
-GOAL_MOUTH_HALF_WIDTH, GOAL_MOUTH_HALF_WIDTH, minf(z0, z1), maxf(z0, z1))
# Opaque bulkhead around each goal mouth, with the aperture cut out of it. This
# is the solid hull the goal recess is set into: left as translucent field
# panel, the recess and net behind it showed through the wall and the net read
# as duplicated either side of the frame.
func _add_goal_surrounds(st: SurfaceTool) -> void:
var bounds := _goal_surround_bounds(GOAL_VISUAL_APERTURE_HEIGHT)
for side in [-1.0, 1.0]:
_add_aperture_panel(st,
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
Vector3(0, 0, -side), GOAL_MOUTH_HALF_WIDTH, bounds.x, bounds.y,
GOAL_VISUAL_APERTURE_HALF_WIDTH, bounds.z, bounds.w)
# (surround bottom, surround top, aperture bottom, aperture top) on the end
# wall. In FLOOR mode the surround spans deck to fillet tangent with the goal
# sitting on the deck; in ELEVATED it is a band centred on the raised goal.
# `aperture_height` is the caller's choice of GOAL_APERTURE_HEIGHT (the true,
# collision-matching size — see _build_end_wall_colliders) or
# GOAL_VISUAL_APERTURE_HEIGHT (the cosmetically-widened cut — see
# _add_goal_surrounds); it only affects the returned aperture bottom/top
# (z/w), not the surround bottom/top (x/y), which are independent of it in
# both modes.
func _goal_surround_bounds(aperture_height: float) -> Vector4:
if goal_mode == GoalMode.FLOOR:
return Vector4(0.0, BASE_RADIUS, 0.0, aperture_height)
var half := aperture_height / 2.0
return Vector4(
GOAL_CENTER_Y - GOAL_SURROUND_HALF_HEIGHT,
GOAL_CENTER_Y + GOAL_SURROUND_HALF_HEIGHT,
GOAL_CENTER_Y - half,
GOAL_CENTER_Y + half)
func _add_fillet_surface(st: SurfaceTool, rise: float) -> void:
for run in _fillet_runs(rise):
var caps: Array = run["caps"]
_add_fillet_visual(st, run["wall_out"], run["run_dir"], run["centre"], run["length"],
caps[0], caps[1], rise)
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
_add_wrap_visual(st, sx, sz, rise)
# Wall panels, spanning between the fillets vertically and between the corner
# curves horizontally. Each end wall is cut around the goal surround, which is
# opaque hull carrying the aperture itself (see _add_goal_surrounds). In FLOOR
# mode that surround sits below the panels entirely, so only the ELEVATED one
# punches a hole through them.
func _add_wall_panels(st: SurfaceTool) -> void:
var y0 := BASE_RADIUS
var y1 := INNER_HEIGHT - BASE_RADIUS
var elevated := goal_mode == GoalMode.ELEVATED
var bounds := _goal_surround_bounds(GOAL_VISUAL_APERTURE_HEIGHT)
for side in [-1.0, 1.0]:
_add_aperture_panel(st,
Vector3(side * INNER_HALF_X, 0, 0), Vector3(0, 0, side), Vector3.UP,
Vector3(-side, 0, 0), CORNER_CENTRE_Z, y0, y1, 0.0, 0.0, 0.0)
_add_aperture_panel(st,
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
Vector3(0, 0, -side), CORNER_CENTRE_X, y0, y1,
GOAL_MOUTH_HALF_WIDTH if elevated else 0.0, bounds.x, bounds.y)
# The corner quarter-cylinders, trimmed to the wall panels' height range so
# they abut the fillet wraps instead of running through them.
func _add_corner_panels(st: SurfaceTool) -> void:
var y0 := BASE_RADIUS
var y1 := INNER_HEIGHT - BASE_RADIUS
var arc_step := (PI / 2.0) / CORNER_VISUAL_ARCS
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
var arc_centre := _corner_centre(sx, sz)
for i in CORNER_VISUAL_ARCS:
var d0 := Vector3(sx * cos(i * arc_step), 0.0, sz * sin(i * arc_step))
var d1 := Vector3(sx * cos((i + 1) * arc_step), 0.0, sz * sin((i + 1) * arc_step))
var b0 := arc_centre + d0 * CORNER_RADIUS
var b1 := arc_centre + d1 * CORNER_RADIUS
_add_quad(st,
b0 + Vector3.UP * y0, -d0, b1 + Vector3.UP * y0, -d1,
b1 + Vector3.UP * y1, -d1, b0 + Vector3.UP * y1, -d0)
# One smooth fillet strip, optionally capped at either end (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, rise: float
) -> 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, rise)
var dir_b := _fillet_dir(wall_out, (i + 1) * arc_step, rise)
_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 round to the arc.
var cap_corner := end_point + (wall_out + _fillet_dir(wall_out, 0.0, rise)) * BASE_RADIUS
for i in FILLET_VISUAL_ARCS:
var p0 := end_point + _fillet_dir(wall_out, i * arc_step, rise) * BASE_RADIUS
var p1 := end_point + _fillet_dir(wall_out, (i + 1) * arc_step, rise) * BASE_RADIUS
_add_cap_tri(st, cap_corner, p0, p1, cap_normal)
# Smooth torus patch over the same sweep _add_wrap_colliders describes.
func _add_wrap_visual(st: SurfaceTool, sx: float, sz: float, rise: float) -> void:
var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise)
var path_step := (PI / 2.0) / WRAP_VISUAL_ARCS
var profile_step := (PI / 2.0) / FILLET_VISUAL_ARCS
for i in WRAP_VISUAL_ARCS:
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 phi: float = corner[0] * path_step
var theta: float = corner[1] * profile_step
var u := Vector3(sx * cos(phi), 0.0, sz * sin(phi))
points.append(origin + u * (WRAP_RADIUS + BASE_RADIUS * sin(theta))
+ Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta))))
normals.append(-(u * sin(theta) - Vector3.UP * (rise * cos(theta))))
_add_quad(
st, points[0], normals[0], points[1], normals[1],
points[2], normals[2], points[3], normals[3]
)
# --- meshing primitives ------------------------------------------------------
# A flat rectangle in the plane through `plane_origin` spanned by `right`/`up`,
# with an optional rectangular aperture cut out of it (hole_half_width <= 0 for
# none). Decomposed into the up-to-four quads surrounding the hole so the panel
# stays a single non-overlapping layer.
func _add_aperture_panel(
st: SurfaceTool, plane_origin: Vector3, right: Vector3, up: Vector3, normal: Vector3,
half_width: float, v0: float, v1: float,
hole_half_width: float, hole_v0: float, hole_v1: float
) -> void:
if hole_half_width <= 0.0:
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, v0, v1)
return
var lo: float = clampf(hole_v0, v0, v1)
var hi: float = clampf(hole_v1, v0, v1)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, v0, lo)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, hi, v1)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, -hole_half_width, lo, hi)
_add_plane_quad(st, plane_origin, right, up, normal, hole_half_width, half_width, lo, hi)
# One quad in the plane through `plane_origin` spanned by `right`/`up`, over
# the given parameter ranges. Degenerate spans are skipped so callers can pass
# empty slices (an aperture flush with a panel edge, say) without guarding.
func _add_plane_quad(
st: SurfaceTool, plane_origin: Vector3, right: Vector3, up: Vector3,
normal: Vector3, u0: float, u1: float, v0: float, v1: float
) -> void:
if u1 - u0 <= 0.0001 or v1 - v0 <= 0.0001:
return
_add_quad(st,
plane_origin + right * u0 + up * v0, normal,
plane_origin + right * u1 + up * v0, normal,
plane_origin + right * u1 + up * v1, normal,
plane_origin + right * u0 + up * v1, normal)
# 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)