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