macrosoft3dpinball/godot/scripts/Table.gd
m3ultra 7347ec4db7 Pinball: Rules, Juice, HUD, a table probe — and the three bugs it found
Four lanes built in parallel onto the data-driven spine:

- Rules.gd (509) — score with a decaying combo multiplier, 3 balls, ball save,
  escalating drop-bank bonuses, lit rollover lanes that level up when swept,
  saucer-lock multiball, two-warning tilt, end-of-ball bonus, per-table high
  scores in user://pinball_scores.cfg.
- Juice.gd (784) — 12 procedural voices synthesized at boot, no assets. The
  spinner ratchet is timed to Table.gd bleeding spin_left at 6.0/s so the ticking
  stops exactly when the blade does. Pooled voices/lights/sparks, quadratic-falloff
  shake, and a damped spring for nudge. Palette-tinted lights with a value floor,
  because a light the colour of a near-black saucer illuminates nothing.
- Hud.gd (935) — score that ROLLS UP rather than snapping, combo, award callouts,
  plunger meter, tilt banner, ball save, game over panel.
- dev/probe_tables.gd (480) — the gate: builds every table and checks parts, part
  overlaps, playfield bounds, flipper swing, that the ball actually DRAINS, and
  that everything settles.

The probe immediately found three real bugs the flipper autotest could not see:

1. The drain sat 3 cm PAST the playfield lip, so on four of five tables the ball
   rolled off the end into the void and the trigger never fired. A table you cannot
   lose a ball on is not a table. Moved inside the slab.
2. Inlane posts were level with the slingshots and interpenetrated them on three
   tables — on a real table the posts sit below. Moved down-table.
3. THE DIG's counter ramp ran through the crate bank, and a 15 deg yaw over a 28 cm
   ramp swings its far end 7 cm sideways, which then walked it through the left rail.
   Less yaw, tucked in, crates slid right.

PROBE_TABLES: PASS 5/5. AUTOTEST: ALL TABLES OK.

Still open, and it is an ENGINE finding not a table one: godot-box3d does not enforce
HingeJoint3D angular limits — flippers swing 147-160 deg against a 62 deg limit. The
probe reports it as a NOTE per table. Worth an upstream issue alongside the motor-sign
inversion already in the README.

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

703 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)
# INSIDE the slab, not past its lip. Parked just beyond the edge, a ball reaches the
# end of the playfield and falls into the void without ever entering the trigger —
# probe_tables reported "off the edge, drain never fired" on four of five tables, which
# is the difference between a pinball table and a hole in the floor.
drain.position = Vector3(0, 0.03, l * 0.5 - 0.02)
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))