macrosoft3dpinball/godot/scripts/Table.gd
m3ultra 2f1a1250cc Pinball: data-driven table spine (3 tables, 11 part kinds)
Replaces the single hand-built .tscn table with the Floorplan/Levels pattern from
Destroyulator: Table.gd builds a playfield from a spec, Tables.gd holds the specs,
Main.gd orchestrates. A table is now a Dictionary you can diff and hot-swap (T),
parallel work doesn't collide in one scene file, and a headless autotest builds
every table and proves the flippers swing.

Part vocabulary: flipper, bumper, sling, target, drop (banked, with cleared
detection), spinner (scores per revolution), saucer (captures + ejects), rollover
(lit lanes/glyphs), ramp (speed-gated, scores only at the top), wall, post.

Three ORIGINAL tables — NEON ARCADE (wide open, 3-bank, spinner lane), THE GROTTO
(narrow, open outlanes, a 5-bank guarding the only ramp), THE FOUNDRY (long, twin
ramps, upper flipper, 2x3 target array). Not traced from the vendored decomp.

Main also centralises the engine divergence: Box3D's hinge motor runs opposite to
Jolt/GodotPhysics, so flip_sign is resolved once at boot from the engine name
rather than authoring two sets of tables.

Caught by the new autotest: Godot's HingeJoint3D spins about its own local Z, so a
flipper built in code needs the joint stood on end to swing about world Y. Commanded
correctly and utterly motionless until fixed. AUTOTEST now: 3 tables, 7 flippers,
68-84 deg swing, ALL TABLES OK.

Rules/Juice/Hud are spawned by class name if present, so those lanes land independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:24:07 +10:00

699 lines
24 KiB
GDScript

extends Node3D
class_name Table
## A pinball table, built from a DATA SPEC (see Tables.gd) rather than a scene file.
##
## Same reasoning as Destroyulator's Floorplan/Levels split: a table becomes a Dictionary
## you can diff, generate and hot-swap, parallel work doesn't collide in one .tscn, and a
## headless probe can build every table and measure it without a renderer.
##
## Everything that scores emits `hit`. Nothing in here knows what a point is worth —
## Rules.gd owns that, Juice.gd owns how it feels, Hud.gd owns how it reads.
##
## Godot 4.7. Physics engine is whatever project.godot / override.cfg selects.
signal hit(kind: String, id: String, at: Vector3, data: Dictionary)
signal drained(ball: RigidBody3D)
signal ball_launched(ball: RigidBody3D)
const BALL_R := 0.0135 ## 27 mm pinball
const BALL_MASS := 0.080 ## 80 g
const PLAYFIELD_W := 0.52
const PLAYFIELD_L := 1.10
var spec: Dictionary = {}
var balls: Array[RigidBody3D] = []
var flipper_joints: Array[HingeJoint3D] = []
var flipper_bodies: Array[RigidBody3D] = []
var _flip_side: Array[int] = [] # +1 right, -1 left
var _parts: Dictionary = {} # id -> node
var _mat_cache: Dictionary = {}
var _drop_state: Dictionary = {} # bank name -> how many are down
# The plunger lane sits at +X, and the ball is launched toward -Z (up the table).
var lane_x := 0.0
var lane_z := 0.0
func build(s: Dictionary) -> void:
spec = s
for c in get_children():
c.queue_free()
balls.clear()
flipper_joints.clear()
flipper_bodies.clear()
_parts.clear()
_drop_state.clear()
_playfield()
for p in spec.get("parts", []):
_part(p as Dictionary)
_spawn_ball()
func table_name() -> String:
return String(spec.get("name", "UNTITLED"))
func part(id: String) -> Node:
return _parts.get(id)
func parts_of(kind: String) -> Array:
var out: Array = []
for k in _parts:
var n = _parts[k]
if is_instance_valid(n) and n.get_meta("kind", "") == kind:
out.append(n)
return out
# ---------------------------------------------------------------- materials
func _mat(c: Color, metal := 0.0, rough := 0.6, emit := 0.0) -> StandardMaterial3D:
var key := "%s_%.2f_%.2f_%.2f" % [c, metal, rough, emit]
if _mat_cache.has(key):
return _mat_cache[key]
var m := StandardMaterial3D.new()
m.albedo_color = c
m.metallic = metal
m.roughness = rough
if emit > 0.0:
m.emission_enabled = true
m.emission = c
m.emission_energy_multiplier = emit
_mat_cache[key] = m
return m
func _pal(key: String, fallback: Color) -> Color:
var p: Dictionary = spec.get("palette", {})
return p.get(key, fallback)
# ---------------------------------------------------------------- the slab
## Playfield, side rails and the back wall. The whole table is built flat in XZ and the
## *gravity vector* is tilted (project.godot), which is how a real table works and keeps
## every local transform readable.
func _playfield() -> void:
var w: float = float(spec.get("width", PLAYFIELD_W))
var l: float = float(spec.get("length", PLAYFIELD_L))
var body := StaticBody3D.new()
body.name = "Playfield"
add_child(body)
_slab(body, Vector3(w, 0.02, l), Vector3(0, -0.01, 0), _pal("field", Color(0.12, 0.16, 0.22)))
# rails: left, right, top. The bottom is open — that's the drain.
var h := 0.06
_slab(body, Vector3(0.02, h, l), Vector3(-w * 0.5, h * 0.5, 0), _pal("rail", Color(0.55, 0.57, 0.62)), 0.85, 0.25)
_slab(body, Vector3(0.02, h, l), Vector3(w * 0.5, h * 0.5, 0), _pal("rail", Color(0.55, 0.57, 0.62)), 0.85, 0.25)
_slab(body, Vector3(w, h, 0.02), Vector3(0, h * 0.5, -l * 0.5), _pal("rail", Color(0.55, 0.57, 0.62)), 0.85, 0.25)
# plunger lane divider — a wall that stops short so the ball can enter the field
lane_x = w * 0.5 - 0.035
lane_z = l * 0.5 - 0.06
_slab(body, Vector3(0.014, h, l * 0.62), Vector3(w * 0.5 - 0.07, h * 0.5, l * 0.19),
_pal("rail", Color(0.55, 0.57, 0.62)), 0.8, 0.3)
# the drain: an Area at the bottom, plus catch walls angled to feed it
var drain := Area3D.new()
drain.name = "Drain"
var ds := CollisionShape3D.new()
var db := BoxShape3D.new()
db.size = Vector3(w, 0.10, 0.05)
ds.shape = db
drain.add_child(ds)
add_child(drain)
drain.position = Vector3(0, 0.03, l * 0.5 + 0.03)
drain.body_entered.connect(func(b: Node3D) -> void:
if b is RigidBody3D and balls.has(b):
drained.emit(b))
func _slab(host: StaticBody3D, size: Vector3, at: Vector3, col: Color,
metal := 0.0, rough := 0.7) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = size
mi.mesh = bm
mi.material_override = _mat(col, metal, rough)
mi.position = at
host.add_child(mi)
var cs := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = size
cs.shape = sh
cs.position = at
host.add_child(cs)
return mi
# ---------------------------------------------------------------- the ball
func _spawn_ball() -> RigidBody3D:
var b := RigidBody3D.new()
b.name = "Ball%d" % (balls.size() + 1)
b.mass = BALL_MASS
# CCD on: GodotPhysics and Jolt both tunnel a fast ball through thin walls without it.
# Box3D contains it either way (speculative contacts are always on) — see README.
b.continuous_cd = true
b.contact_monitor = true
b.max_contacts_reported = 8
b.can_sleep = false
var pm := PhysicsMaterial.new()
pm.bounce = 0.32
pm.friction = 0.08
b.physics_material_override = pm
var mi := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = BALL_R
sm.height = BALL_R * 2.0
mi.mesh = sm
mi.material_override = _mat(Color(0.86, 0.88, 0.92), 1.0, 0.12)
b.add_child(mi)
var cs := CollisionShape3D.new()
var sp := SphereShape3D.new()
sp.radius = BALL_R
cs.shape = sp
b.add_child(cs)
add_child(b)
b.position = Vector3(lane_x, BALL_R + 0.005, lane_z)
balls.append(b)
return b
## Extra balls for multiball — spawned at the same feed point with a small spread.
func add_ball(at := Vector3.INF) -> RigidBody3D:
var b := _spawn_ball()
if at != Vector3.INF:
b.position = at
b.linear_velocity = Vector3(randf_range(-0.4, 0.4), 0, randf_range(-0.6, -0.2))
ball_launched.emit(b)
return b
func remove_ball(b: RigidBody3D) -> void:
balls.erase(b)
if is_instance_valid(b):
b.queue_free()
func park_ball(b: RigidBody3D) -> void:
if not is_instance_valid(b):
return
b.linear_velocity = Vector3.ZERO
b.angular_velocity = Vector3.ZERO
b.position = Vector3(lane_x, BALL_R + 0.005, lane_z)
func ball_in_lane(b: RigidBody3D) -> bool:
return is_instance_valid(b) and b.position.x > lane_x - 0.03 \
and b.position.z > lane_z - 0.08 and b.linear_velocity.length() < 0.06
# ---------------------------------------------------------------- parts
func _part(p: Dictionary) -> void:
var kind := String(p.get("kind", ""))
match kind:
"flipper": _flipper(p)
"bumper": _bumper(p)
"sling": _sling(p)
"target": _target(p)
"drop": _drop(p)
"spinner": _spinner(p)
"saucer": _saucer(p)
"rollover": _rollover(p)
"wall": _wall(p)
"post": _post(p)
"ramp": _ramp(p)
_: push_warning("[table] unknown part kind: %s" % kind)
func _register(n: Node, p: Dictionary, kind: String) -> void:
var id := String(p.get("id", "%s_%d" % [kind, _parts.size()]))
n.set_meta("kind", kind)
n.set_meta("id", id)
n.set_meta("points", int(p.get("points", 0)))
_parts[id] = n
func _emit(kind: String, n: Node, at: Vector3, extra := {}) -> void:
var d := extra.duplicate()
d["points"] = int(n.get_meta("points", 0))
hit.emit(kind, String(n.get_meta("id", "")), at, d)
## A flipper: a bat on a motorised hinge. The motor SIGN differs between engines —
## Box3D is inverted versus Jolt/GodotPhysics (documented in README), so `side` is applied
## through `Main.flip_sign()` rather than baked in here.
func _flipper(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var side := int(p.get("side", -1)) # -1 left, +1 right
var len_: float = float(p.get("length", 0.075))
var body := RigidBody3D.new()
body.name = "Flipper_%s" % p.get("id", "f")
body.mass = 0.12
body.can_sleep = false
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(len_, 0.016, 0.020)
mi.mesh = bm
mi.position = Vector3(-side * len_ * 0.5, 0, 0)
mi.material_override = _mat(_pal("flipper", Color(0.90, 0.25, 0.30)), 0.2, 0.35)
body.add_child(mi)
var cs := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = bm.size
cs.shape = sh
cs.position = mi.position
body.add_child(cs)
add_child(body)
body.position = at
var anchor := StaticBody3D.new()
anchor.name = "FlipperAnchor_%s" % p.get("id", "f")
add_child(anchor)
anchor.position = at
var j := HingeJoint3D.new()
add_child(j)
j.position = at
# Godot's HingeJoint3D spins about its own local Z. A flipper has to swing flat across
# the playfield — i.e. about world Y — so stand the joint on end. Without this the
# motor is commanded correctly and nothing moves, which is exactly what the autotest
# caught the first time this table was built in code instead of in a .tscn.
j.rotation.x = deg_to_rad(-90.0)
j.node_a = j.get_path_to(anchor)
j.node_b = j.get_path_to(body)
j.set_flag(HingeJoint3D.FLAG_USE_LIMIT, true)
var lo: float = deg_to_rad(float(p.get("limit_lo", -30.0)))
var hi: float = deg_to_rad(float(p.get("limit_hi", 30.0)))
j.set_param(HingeJoint3D.PARAM_LIMIT_LOWER, lo)
j.set_param(HingeJoint3D.PARAM_LIMIT_UPPER, hi)
j.set_flag(HingeJoint3D.FLAG_ENABLE_MOTOR, true)
j.set_param(HingeJoint3D.PARAM_MOTOR_MAX_IMPULSE, float(p.get("torque", 6.0)))
flipper_joints.append(j)
flipper_bodies.append(body)
_flip_side.append(side)
_register(body, p, "flipper")
func flip_side(i: int) -> int:
return _flip_side[i] if i < _flip_side.size() else -1
## Pop bumper: a post that kicks the ball away from its centre, hard.
func _bumper(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var r: float = float(p.get("radius", 0.028))
var host := StaticBody3D.new()
host.name = "Bumper_%s" % p.get("id", "b")
var mi := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = r
cm.bottom_radius = r * 1.15
cm.height = 0.05
mi.mesh = cm
mi.position.y = 0.025
mi.material_override = _mat(_pal("bumper", Color(0.95, 0.75, 0.2)), 0.3, 0.3, 0.6)
host.add_child(mi)
var cs := CollisionShape3D.new()
var sh := CylinderShape3D.new()
sh.radius = r
sh.height = 0.05
cs.shape = sh
cs.position.y = 0.025
host.add_child(cs)
add_child(host)
host.position = at
var area := Area3D.new()
var acs := CollisionShape3D.new()
var asp := SphereShape3D.new()
asp.radius = r + BALL_R + 0.002
acs.shape = asp
area.add_child(acs)
add_child(area)
area.position = at + Vector3(0, 0.02, 0)
var kick: float = float(p.get("kick", 0.16))
area.body_entered.connect(func(b: Node3D) -> void:
if not (b is RigidBody3D) or not balls.has(b):
return
var away := (b.global_position - at)
away.y = 0.0
if away.length() < 0.0001:
away = Vector3(0, 0, -1)
(b as RigidBody3D).apply_central_impulse(away.normalized() * kick)
_emit("bumper", host, at))
_register(host, p, "bumper")
## Slingshot: the angled kicker above each outlane. Same idea as a bumper but it fires
## along its own normal, which is what makes a ball rattle across the lower playfield.
func _sling(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var dir: Vector3 = p.get("dir", Vector3(1, 0, -1)).normalized()
var len_: float = float(p.get("length", 0.09))
var host := StaticBody3D.new()
host.name = "Sling_%s" % p.get("id", "s")
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(len_, 0.04, 0.014)
mi.mesh = bm
mi.material_override = _mat(_pal("sling", Color(0.35, 0.85, 0.45)), 0.1, 0.4, 0.35)
mi.position.y = 0.02
host.add_child(mi)
var cs := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = bm.size
cs.shape = sh
cs.position.y = 0.02
host.add_child(cs)
add_child(host)
host.position = at
host.rotation.y = float(p.get("yaw", 0.0))
var area := Area3D.new()
var acs := CollisionShape3D.new()
var ab := BoxShape3D.new()
ab.size = Vector3(len_, 0.05, 0.03)
acs.shape = ab
area.add_child(acs)
add_child(area)
area.position = at + Vector3(0, 0.02, 0)
area.rotation.y = host.rotation.y
var kick: float = float(p.get("kick", 0.13))
area.body_entered.connect(func(b: Node3D) -> void:
if b is RigidBody3D and balls.has(b):
(b as RigidBody3D).apply_central_impulse(dir * kick)
_emit("sling", host, at))
_register(host, p, "sling")
## Standing target: hit it, score it, it stays up.
func _target(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var host := StaticBody3D.new()
host.name = "Target_%s" % p.get("id", "t")
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(float(p.get("width", 0.035)), 0.035, 0.010)
mi.mesh = bm
mi.position.y = 0.018
mi.material_override = _mat(_pal("target", Color(0.95, 0.35, 0.75)), 0.1, 0.4, 0.3)
host.add_child(mi)
var cs := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = bm.size
cs.shape = sh
cs.position.y = 0.018
host.add_child(cs)
add_child(host)
host.position = at
host.rotation.y = float(p.get("yaw", 0.0))
var area := Area3D.new()
var acs := CollisionShape3D.new()
var ab := BoxShape3D.new()
ab.size = bm.size + Vector3(0.006, 0.01, 0.018)
acs.shape = ab
area.add_child(acs)
add_child(area)
area.position = at + Vector3(0, 0.018, 0)
area.rotation.y = host.rotation.y
area.body_entered.connect(func(b: Node3D) -> void:
if b is RigidBody3D and balls.has(b):
_emit("target", host, at))
_register(host, p, "target")
## Drop target: falls out of the playfield when hit. A whole bank down = a bonus, and
## Rules.gd decides what that's worth. `bank` groups them.
func _drop(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var bank := String(p.get("bank", "bank"))
var host := StaticBody3D.new()
host.name = "Drop_%s" % p.get("id", "d")
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(0.030, 0.038, 0.010)
mi.mesh = bm
mi.position.y = 0.019
mi.material_override = _mat(_pal("drop", Color(0.98, 0.85, 0.30)), 0.1, 0.4, 0.35)
host.add_child(mi)
var cs := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = bm.size
cs.shape = sh
cs.position.y = 0.019
host.add_child(cs)
add_child(host)
host.position = at
host.rotation.y = float(p.get("yaw", 0.0))
host.set_meta("bank", bank)
host.set_meta("down", false)
var area := Area3D.new()
var acs := CollisionShape3D.new()
var ab := BoxShape3D.new()
ab.size = bm.size + Vector3(0.006, 0.01, 0.018)
acs.shape = ab
area.add_child(acs)
add_child(area)
area.position = at + Vector3(0, 0.019, 0)
area.rotation.y = host.rotation.y
area.body_entered.connect(func(b: Node3D) -> void:
if not (b is RigidBody3D) or not balls.has(b) or host.get_meta("down"):
return
_drop_down(host, bank))
_register(host, p, "drop")
func _drop_down(host: StaticBody3D, bank: String) -> void:
host.set_meta("down", true)
host.process_mode = Node.PROCESS_MODE_DISABLED
host.visible = false
for c in host.get_children():
if c is CollisionShape3D:
(c as CollisionShape3D).set_deferred("disabled", true)
_drop_state[bank] = int(_drop_state.get(bank, 0)) + 1
var total := 0
for k in _parts:
var n = _parts[k]
if is_instance_valid(n) and n.get_meta("kind", "") == "drop" and n.get_meta("bank", "") == bank:
total += 1
var cleared: bool = int(_drop_state[bank]) >= total
_emit("drop", host, host.global_position, {"bank": bank, "cleared": cleared})
if cleared:
hit.emit("bank_cleared", bank, host.global_position, {"bank": bank, "points": 0})
## Put a whole bank back up (Rules calls this after awarding the bonus).
func reset_bank(bank: String) -> void:
_drop_state[bank] = 0
for k in _parts:
var n = _parts[k]
if not is_instance_valid(n) or n.get_meta("kind", "") != "drop":
continue
if n.get_meta("bank", "") != bank:
continue
n.set_meta("down", false)
n.process_mode = Node.PROCESS_MODE_INHERIT
n.visible = true
for c in (n as Node).get_children():
if c is CollisionShape3D:
(c as CollisionShape3D).set_deferred("disabled", false)
## Spinner: a blade the ball whips through. Scores per revolution, so a fast shot
## through it is worth many times a slow one — the classic risk/reward lane.
func _spinner(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var area := Area3D.new()
area.name = "Spinner_%s" % p.get("id", "sp")
var acs := CollisionShape3D.new()
var ab := BoxShape3D.new()
ab.size = Vector3(0.040, 0.05, 0.012)
acs.shape = ab
area.add_child(acs)
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(0.038, 0.030, 0.003)
mi.mesh = bm
mi.material_override = _mat(_pal("spinner", Color(0.75, 0.85, 0.95)), 0.9, 0.2)
mi.position.y = 0.02
area.add_child(mi)
add_child(area)
area.position = at
area.rotation.y = float(p.get("yaw", 0.0))
area.set_meta("blade", mi)
area.body_entered.connect(func(b: Node3D) -> void:
if not (b is RigidBody3D) or not balls.has(b):
return
var spd: float = (b as RigidBody3D).linear_velocity.length()
var spins: int = clampi(int(spd * 6.0), 1, 24)
area.set_meta("spin_left", float(spins))
_emit("spinner", area, at, {"spins": spins, "speed": spd}))
_register(area, p, "spinner")
## Saucer / kicker hole: swallows the ball, holds it, spits it back out. The classic
## "award" device — Rules decides what being captured is worth.
func _saucer(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var area := Area3D.new()
area.name = "Saucer_%s" % p.get("id", "sc")
var acs := CollisionShape3D.new()
var asp := SphereShape3D.new()
asp.radius = 0.024
acs.shape = asp
area.add_child(acs)
var mi := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = 0.024
cm.bottom_radius = 0.020
cm.height = 0.006
mi.mesh = cm
mi.material_override = _mat(_pal("saucer", Color(0.15, 0.18, 0.25)), 0.4, 0.5)
area.add_child(mi)
add_child(area)
area.position = at
var eject: Vector3 = p.get("eject", Vector3(0, 0, -1))
var hold: float = float(p.get("hold", 0.9))
var power: float = float(p.get("power", 0.20))
area.body_entered.connect(func(b: Node3D) -> void:
if not (b is RigidBody3D) or not balls.has(b) or area.get_meta("busy", false):
return
area.set_meta("busy", true)
var rb := b as RigidBody3D
rb.linear_velocity = Vector3.ZERO
rb.angular_velocity = Vector3.ZERO
rb.freeze = true
rb.global_position = at + Vector3(0, BALL_R, 0)
_emit("saucer", area, at, {"captured": true})
await get_tree().create_timer(hold).timeout
if is_instance_valid(rb):
rb.freeze = false
rb.apply_central_impulse(eject.normalized() * power)
area.set_meta("busy", false))
_register(area, p, "saucer")
## Rollover lane: a wire trigger you roll over. Lit lanes are how you spell a word.
func _rollover(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var area := Area3D.new()
area.name = "Roll_%s" % p.get("id", "r")
var acs := CollisionShape3D.new()
var ab := BoxShape3D.new()
ab.size = Vector3(0.034, 0.04, 0.030)
acs.shape = ab
area.add_child(acs)
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(0.030, 0.002, 0.026)
mi.mesh = bm
mi.material_override = _mat(_pal("lane", Color(0.35, 0.55, 0.95)), 0.2, 0.5, 0.25)
area.add_child(mi)
add_child(area)
area.position = at
area.set_meta("lit", false)
area.set_meta("glyph", String(p.get("glyph", "")))
area.body_entered.connect(func(b: Node3D) -> void:
if b is RigidBody3D and balls.has(b):
_emit("rollover", area, at, {"glyph": area.get_meta("glyph", ""),
"was_lit": area.get_meta("lit", false)}))
_register(area, p, "rollover")
func set_rollover_lit(id: String, lit: bool) -> void:
var n = _parts.get(id)
if n == null or not is_instance_valid(n):
return
n.set_meta("lit", lit)
for c in (n as Node).get_children():
if c is MeshInstance3D:
var m := (c as MeshInstance3D).material_override as StandardMaterial3D
if m != null:
var d := m.duplicate() as StandardMaterial3D
d.emission_energy_multiplier = 1.6 if lit else 0.25
(c as MeshInstance3D).material_override = d
## A plain wall segment — the guides, the horseshoe, the outlane dividers.
func _wall(p: Dictionary) -> void:
var host := StaticBody3D.new()
host.name = "Wall_%s" % p.get("id", "w")
add_child(host)
host.position = p.get("at", Vector3.ZERO)
host.rotation.y = float(p.get("yaw", 0.0))
var size: Vector3 = p.get("size", Vector3(0.10, 0.05, 0.012))
_slab(host, size, Vector3(0, size.y * 0.5, 0),
_pal("wall", Color(0.45, 0.48, 0.55)), 0.6, 0.35)
_register(host, p, "wall")
## A round post — the pegs a ball threads between.
func _post(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var host := StaticBody3D.new()
host.name = "Post_%s" % p.get("id", "p")
var r: float = float(p.get("radius", 0.008))
var mi := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = r
cm.bottom_radius = r
cm.height = 0.05
mi.mesh = cm
mi.position.y = 0.025
mi.material_override = _mat(_pal("post", Color(0.9, 0.9, 0.95)), 0.5, 0.25)
host.add_child(mi)
var cs := CollisionShape3D.new()
var sh := CylinderShape3D.new()
sh.radius = r
sh.height = 0.05
cs.shape = sh
cs.position.y = 0.025
host.add_child(cs)
add_child(host)
host.position = at
_register(host, p, "post")
## A ramp: an inclined run the ball can take if it arrives fast enough. Built as a
## sloped slab with side rails so a slow ball rolls back down — that speed gate is the
## whole point of a ramp shot.
func _ramp(p: Dictionary) -> void:
var at: Vector3 = p.get("at", Vector3.ZERO)
var len_: float = float(p.get("length", 0.26))
var wid: float = float(p.get("width", 0.05))
var rise: float = float(p.get("rise", 0.055))
var host := StaticBody3D.new()
host.name = "Ramp_%s" % p.get("id", "rm")
add_child(host)
host.position = at
host.rotation.y = float(p.get("yaw", 0.0))
var pitch := atan2(rise, len_)
var bed := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(wid, 0.008, len_)
bed.mesh = bm
bed.material_override = _mat(_pal("ramp", Color(0.25, 0.65, 0.85)), 0.35, 0.3)
bed.position = Vector3(0, rise * 0.5, -len_ * 0.5)
bed.rotation.x = pitch
host.add_child(bed)
var cs := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = bm.size
cs.shape = sh
cs.position = bed.position
cs.rotation.x = pitch
host.add_child(cs)
for s in [-1.0, 1.0]:
var rail := CollisionShape3D.new()
var rs := BoxShape3D.new()
rs.size = Vector3(0.006, 0.030, len_)
rail.shape = rs
rail.position = bed.position + Vector3(s * (wid * 0.5), 0.018, 0)
rail.rotation.x = pitch
host.add_child(rail)
var rm := MeshInstance3D.new()
var rbm := BoxMesh.new()
rbm.size = rs.size
rm.mesh = rbm
rm.material_override = _mat(_pal("rail", Color(0.55, 0.57, 0.62)), 0.8, 0.3)
rm.position = rail.position
rm.rotation.x = pitch
host.add_child(rm)
# the reward trigger sits at the TOP — you only score it if you made it up
var area := Area3D.new()
var acs := CollisionShape3D.new()
var ab := BoxShape3D.new()
ab.size = Vector3(wid, 0.05, 0.03)
acs.shape = ab
area.add_child(acs)
host.add_child(area)
area.position = Vector3(0, rise + 0.02, -len_)
area.body_entered.connect(func(b: Node3D) -> void:
if b is RigidBody3D and balls.has(b):
_emit("ramp", host, area.global_position, {}))
_register(host, p, "ramp")
# ---------------------------------------------------------------- per-frame
func _process(delta: float) -> void:
# spin down the spinner blades so a whipped spinner visibly keeps turning
for k in _parts:
var n = _parts[k]
if not is_instance_valid(n) or n.get_meta("kind", "") != "spinner":
continue
var left := float(n.get_meta("spin_left", 0.0))
if left <= 0.0:
continue
var blade = n.get_meta("blade")
if blade is MeshInstance3D:
(blade as MeshInstance3D).rotation.x += delta * 26.0
n.set_meta("spin_left", maxf(0.0, left - delta * 6.0))