|
|
|
@@ -13,18 +13,31 @@ const INNER_HEIGHT := 12.0
|
|
|
|
|
# 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, and base fillets
|
|
|
|
|
# easing the floor into every wall. Everything is generated in _ready from
|
|
|
|
|
# these constants, but collision and visuals deliberately differ:
|
|
|
|
|
# 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 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.
|
|
|
|
|
# - 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
|
|
|
|
@@ -32,6 +45,17 @@ 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 aperture the visual shell leaves for the goal itself, and the opaque
|
|
|
|
|
# bulkhead surrounding it. Mirrors objects/goal.tscn's 3.5 x 1.5 sensor with a
|
|
|
|
|
# little clearance, so no sliver of panel shows through the goal frame. 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.
|
|
|
|
|
const GOAL_APERTURE_HALF_WIDTH := 1.85
|
|
|
|
|
const GOAL_APERTURE_HEIGHT := 1.65
|
|
|
|
|
# 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
|
|
|
|
@@ -42,27 +66,52 @@ const WRAP_SEGMENTS := 3
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# 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] = []
|
|
|
|
|
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 := Color(0.15, 0.45, 1.0)
|
|
|
|
|
@export var team1_tint := Color(1.0, 0.35, 0.25)
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _ready() -> void:
|
|
|
|
|
add_to_group("arena_boundary")
|
|
|
|
|
_build_corner_curves()
|
|
|
|
|
_build_base_fillets()
|
|
|
|
|
_build_corner_colliders()
|
|
|
|
|
_build_fillet_colliders(BASE_FILLET)
|
|
|
|
|
_build_fillet_colliders(CEILING_FILLET)
|
|
|
|
|
# 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Wall+ceiling-only proximity force field ("artificial gravity" grav-plating,
|
|
|
|
@@ -82,8 +131,8 @@ func get_surface_pull(
|
|
|
|
|
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.
|
|
|
|
|
# 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
|
|
|
|
@@ -97,40 +146,103 @@ func _falloff(dist: float, field_range: float) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
# 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_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
|
|
|
|
|
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
# --- 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 ---------------------------------------------------------------
|
|
|
|
|
# These build the tangent-box collider rings only. Their geometry is what
|
|
|
|
|
# trained policies in Game/bots/ were fitted against, so it must not 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 := Vector3(
|
|
|
|
|
sx * (INNER_HALF_X - CORNER_RADIUS),
|
|
|
|
|
0.0,
|
|
|
|
|
sz * (INNER_HALF_Z - CORNER_RADIUS)
|
|
|
|
|
)
|
|
|
|
|
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
|
|
|
|
@@ -141,115 +253,37 @@ func _build_corner_curves() -> void:
|
|
|
|
|
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
|
|
|
|
|
# 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
|
|
|
|
|
# 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:
|
|
|
|
|
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"]
|
|
|
|
|
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)
|
|
|
|
|
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)
|
|
|
|
|
_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)
|
|
|
|
|
_add_wrap_colliders(sx, sz, rise)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
# 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
|
|
|
|
@@ -259,70 +293,12 @@ func _add_fillet_corner_wrap(st: SurfaceTool, sx: float, sz: float) -> void:
|
|
|
|
|
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 ring_radius := WRAP_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)
|
|
|
|
|
+ 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)
|
|
|
|
|
# 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
|
|
|
|
@@ -342,6 +318,282 @@ func _add_curve_collider(
|
|
|
|
|
add_child(collision)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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()
|
|
|
|
|
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_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.
|
|
|
|
|
func _goal_surround_bounds() -> Vector4:
|
|
|
|
|
if goal_mode == GoalMode.FLOOR:
|
|
|
|
|
return Vector4(0.0, BASE_RADIUS, 0.0, GOAL_APERTURE_HEIGHT)
|
|
|
|
|
var half := GOAL_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()
|
|
|
|
|
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(
|
|
|
|
|