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>
703 lines
24 KiB
GDScript
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))
|