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>
This commit is contained in:
m3ultra 2026-08-09 17:24:07 +10:00
parent 5fa6bbf67b
commit 2f1a1250cc
8 changed files with 1163 additions and 1 deletions

View File

@ -7,7 +7,7 @@ config_version=5
[application]
config/name="macrosoft3dpinball testbed"
run/main_scene="res://scenes/table.tscn"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7")
[physics]

6
godot/scenes/main.tscn Normal file
View File

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/Main.gd" id="1_main"]
[node name="Main" type="Node3D"]
script = ExtResource("1_main")

236
godot/scripts/Main.gd Normal file
View File

@ -0,0 +1,236 @@
extends Node3D
class_name Main
## Orchestrator: builds a table, drives the flippers, and wires the three systems that
## don't know about each other — Rules (what a shot is worth), Juice (how it feels),
## Hud (how it reads). Everything hangs off Table's `hit` / `drained` signals.
##
## Controls: Z / SLASH flippers · SPACE plunger · ARROWS nudge (tilt if you lean on it)
## T next table · R new game · ESC quit
##
## Godot 4.7. Runs under Box3D, Jolt or GodotPhysics — see BOX3D_ENGINE_NOTES.md.
const NUDGE_IMPULSE := 0.035
const NUDGE_COOLDOWN := 0.25
const PLUNGE_MAX := 0.44
const PLUNGE_CHARGE := 1.6 ## how fast the plunger winds up, per second
var table: Table
var rules: Node ## Rules.gd — scoring, multiball, ball count
var juice: Node ## Juice.gd — audio, flash, shake
var hud: CanvasLayer ## Hud.gd — score, ball, messages
var table_id := "neon"
var _cam: Camera3D
var _flip_sign := 1.0 ## Box3D's hinge motor runs opposite Jolt/GodotPhysics
var _plunge := 0.0
var _nudge_cd := 0.0
func _ready() -> void:
randomize()
var engine := String(ProjectSettings.get_setting("physics/3d/physics_engine", "?"))
# THE ENGINE DIVERGENCE, handled in one place: under Box3D a positive motor velocity
# swings a flipper the opposite way to Jolt/GodotPhysics. Rather than author two sets
# of tables, flip the sign once here. See README's A/B results.
_flip_sign = -1.0 if engine.begins_with("Box3D") else 1.0
print("[main] physics engine: %s flip_sign=%.0f" % [engine, _flip_sign])
_world()
table = Table.new()
table.name = "Table"
add_child(table)
# The three systems, each optional and each ignorant of the others. Instantiated by
# class name so a lane can land its file and it just plugs in — nothing here needs
# editing to pick up Rules/Juice/Hud, and the game still runs if one is missing.
rules = _spawn_system("Rules")
juice = _spawn_system("Juice")
hud = _spawn_system("Hud")
_load_table(table_id)
if OS.get_environment("PINBALL_AUTOTEST") == "1":
await _autotest()
## Instantiate a system by class name if that class exists yet, else return null. Lets the
## Rules/Juice/Hud lanes land independently without anyone editing this file.
func _spawn_system(cls: String) -> Node:
if not ClassDB.class_exists(cls) and not _script_class_exists(cls):
print("[main] %s not present — skipping" % cls)
return null
var n: Node = ClassDB.instantiate(cls) if ClassDB.class_exists(cls) else null
if n == null:
var path := "res://scripts/%s.gd" % cls
if not ResourceLoader.exists(path):
return null
var scr := load(path) as Script
if scr == null:
return null
var o = scr.new()
if o is Node:
n = o
else:
return null
n.name = cls
add_child(n)
if n.has_method("setup"):
n.call("setup", self)
print("[main] %s online" % cls)
return n
func _script_class_exists(cls: String) -> bool:
return ResourceLoader.exists("res://scripts/%s.gd" % cls)
func _world() -> void:
var we := WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.02, 0.02, 0.04)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.45, 0.48, 0.60)
env.ambient_light_energy = 0.55
env.glow_enabled = true
env.glow_intensity = 0.5
env.glow_bloom = 0.15
we.environment = env
add_child(we)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-62, 24, 0)
key.light_energy = 1.1
key.shadow_enabled = true
add_child(key)
_cam = Camera3D.new()
add_child(_cam)
_cam.position = Vector3(0.0, 0.92, 0.86)
_cam.rotation_degrees = Vector3(-46, 0, 0)
_cam.fov = 52.0
_cam.current = true
## Build a table and re-wire every system to it. Safe to call at any time.
func _load_table(id: String) -> void:
table_id = id
table.build(Tables.get_table(id))
table.hit.connect(_on_hit)
table.drained.connect(_on_drained)
if rules != null and rules.has_method("start_game"):
rules.call("start_game", table)
if hud != null and hud.has_method("set_table"):
hud.call("set_table", table.table_name(), String(table.spec.get("subtitle", "")))
print("[main] table: %s (%d parts)" % [table.table_name(), table.spec.get("parts", []).size()])
# ---------------------------------------------------------------- signal fan-out
func _on_hit(kind: String, id: String, at: Vector3, data: Dictionary) -> void:
if rules != null and rules.has_method("on_hit"):
rules.call("on_hit", kind, id, at, data)
if juice != null and juice.has_method("on_hit"):
juice.call("on_hit", kind, id, at, data)
func _on_drained(ball: RigidBody3D) -> void:
if rules != null and rules.has_method("on_drained"):
rules.call("on_drained", ball)
else:
table.park_ball(ball) # no rules loaded: just recycle it
if juice != null and juice.has_method("on_drained"):
juice.call("on_drained", ball)
# ---------------------------------------------------------------- input
func _physics_process(delta: float) -> void:
_nudge_cd = maxf(0.0, _nudge_cd - delta)
var tilted: bool = rules != null and rules.get("tilted") == true
# --- flippers. Held = drive to the stop, released = fall back. A tilt kills them,
# --- which is the entire point of a tilt.
var lf := Input.is_physical_key_pressed(KEY_Z) and not tilted
var rf := Input.is_physical_key_pressed(KEY_SLASH) and not tilted
for i in table.flipper_joints.size():
var j := table.flipper_joints[i]
var side := table.flip_side(i)
var held := lf if side < 0 else rf
var v: float = (26.0 if held else -9.0) * float(side) * _flip_sign
j.set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, v)
# --- plunger: hold SPACE to wind up, release to fire. A real plunger rewards feel.
var in_lane := false
for b in table.balls:
if table.ball_in_lane(b):
in_lane = true
break
if in_lane and Input.is_physical_key_pressed(KEY_SPACE):
_plunge = minf(PLUNGE_MAX, _plunge + PLUNGE_CHARGE * delta * PLUNGE_MAX)
elif _plunge > 0.0:
for b in table.balls:
if table.ball_in_lane(b):
b.apply_central_impulse(Vector3(0, 0, -_plunge))
table.ball_launched.emit(b)
if juice != null and juice.has_method("on_plunge"):
juice.call("on_plunge", _plunge / PLUNGE_MAX)
break
_plunge = 0.0
# --- nudge: shove the whole table. Too much, too fast and Rules tilts you.
if _nudge_cd <= 0.0:
var n := Vector3.ZERO
if Input.is_physical_key_pressed(KEY_LEFT): n.x -= 1.0
if Input.is_physical_key_pressed(KEY_RIGHT): n.x += 1.0
if Input.is_physical_key_pressed(KEY_UP): n.z -= 1.0
if n != Vector3.ZERO and not tilted:
_nudge_cd = NUDGE_COOLDOWN
for b in table.balls:
b.apply_central_impulse(n.normalized() * NUDGE_IMPULSE)
if rules != null and rules.has_method("on_nudge"):
rules.call("on_nudge")
if juice != null and juice.has_method("on_nudge"):
juice.call("on_nudge", n)
func plunge_charge() -> float:
return _plunge / PLUNGE_MAX
func _unhandled_input(e: InputEvent) -> void:
if not (e is InputEventKey) or not e.pressed or e.echo:
return
match (e as InputEventKey).keycode:
KEY_T: _load_table(Tables.next_id(table_id))
KEY_R:
if rules != null and rules.has_method("start_game"):
rules.call("start_game", table)
else:
for b in table.balls:
table.park_ball(b)
KEY_ESCAPE: get_tree().quit(0)
# ---------------------------------------------------------------- headless test
## Build every table, prove the flippers actually swing under whatever engine is
## selected, and report. This is the gate — a table that doesn't flip isn't a table.
func _autotest() -> void:
set_physics_process(false)
var fails := 0
for id in Tables.ORDER:
_load_table(id)
for i in 8:
await get_tree().physics_frame
var before: Array[float] = []
for b in table.flipper_bodies:
before.append(b.rotation.y)
for i in table.flipper_joints.size():
var side := table.flip_side(i)
table.flipper_joints[i].set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY,
26.0 * float(side) * _flip_sign)
for i in 30:
await get_tree().physics_frame
var swings: Array[String] = []
var worst := 999.0
for i in table.flipper_bodies.size():
var d := rad_to_deg(absf(angle_difference(before[i], table.flipper_bodies[i].rotation.y)))
swings.append("%.0f" % d)
worst = minf(worst, d)
var parts: int = table.spec.get("parts", []).size()
var ok := worst > 8.0
if not ok:
fails += 1
print("AUTOTEST %-14s parts=%-3d flippers=%d swing_deg=[%s] %s" % [
id, parts, table.flipper_bodies.size(), ", ".join(swings),
"OK" if ok else "FAIL(flipper did not swing)"])
print("AUTOTEST result: %s" % ("ALL TABLES OK" if fails == 0 else "%d TABLE(S) FAILED" % fails))
get_tree().quit(1 if fails > 0 else 0)

View File

@ -0,0 +1 @@
uid://csr8hsdi5l3e

698
godot/scripts/Table.gd Normal file
View File

@ -0,0 +1,698 @@
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))

View File

@ -0,0 +1 @@
uid://caxsihhuv2gwf

219
godot/scripts/Tables.gd Normal file
View File

@ -0,0 +1,219 @@
extends RefCounted
class_name Tables
## The tables, as data. Each is a Table spec (see Table.gd for the part vocabulary).
##
## These are ORIGINAL layouts, not recreations of the Microsoft table vendored next door —
## that decomp is here as a reference and a build target, not something to trace.
##
## A table is a Dictionary: name, palette, size, and a flat list of parts. That means a new
## table is authorable in minutes, tables can be diffed, and dev/probe_tables.gd can build
## every one of them headless and assert the playfield is sane.
const ORDER := ["neon", "grotto", "foundry"]
static func get_table(id: String) -> Dictionary:
match id:
"grotto": return grotto()
"foundry": return foundry()
return neon()
static func next_id(id: String) -> String:
var i := ORDER.find(id)
return ORDER[(i + 1) % ORDER.size()] if i >= 0 else ORDER[0]
# ================================================================ TABLE 1
## NEON ARCADE — the friendly one. Wide open lower field, a three-bank of drop targets,
## two pop bumpers up top and a spinner lane on the left. Learn the flippers here.
static func neon() -> Dictionary:
var parts: Array = []
# --- the lower field: flippers, slings, outlane guides ---
parts.append({"kind": "flipper", "id": "flip_l", "at": Vector3(-0.062, 0.022, 0.400),
"side": -1, "length": 0.078, "limit_lo": -32.0, "limit_hi": 30.0, "torque": 7.0})
parts.append({"kind": "flipper", "id": "flip_r", "at": Vector3(0.062, 0.022, 0.400),
"side": 1, "length": 0.078, "limit_lo": -30.0, "limit_hi": 32.0, "torque": 7.0})
parts.append({"kind": "sling", "id": "sling_l", "at": Vector3(-0.125, 0.0, 0.320),
"dir": Vector3(1, 0, -0.55), "yaw": deg_to_rad(-32.0), "kick": 0.135, "points": 120})
parts.append({"kind": "sling", "id": "sling_r", "at": Vector3(0.125, 0.0, 0.320),
"dir": Vector3(-1, 0, -0.55), "yaw": deg_to_rad(32.0), "kick": 0.135, "points": 120})
# outlane / inlane dividers — the guides that decide whether a drain was fair
parts.append({"kind": "wall", "id": "guide_l", "at": Vector3(-0.175, 0.0, 0.380),
"size": Vector3(0.012, 0.05, 0.150), "yaw": deg_to_rad(9.0)})
parts.append({"kind": "wall", "id": "guide_r", "at": Vector3(0.175, 0.0, 0.380),
"size": Vector3(0.012, 0.05, 0.150), "yaw": deg_to_rad(-9.0)})
parts.append({"kind": "post", "id": "post_l", "at": Vector3(-0.108, 0.0, 0.330)})
parts.append({"kind": "post", "id": "post_r", "at": Vector3(0.108, 0.0, 0.330)})
# --- mid field: the drop-target bank and a pair of standing targets ---
for i in 3:
parts.append({"kind": "drop", "id": "drop_%d" % i, "bank": "neon",
"at": Vector3(-0.130 + i * 0.034, 0.0, 0.055), "points": 500})
parts.append({"kind": "target", "id": "targ_l", "at": Vector3(-0.196, 0.0, -0.070),
"yaw": deg_to_rad(24.0), "points": 300})
parts.append({"kind": "target", "id": "targ_r", "at": Vector3(0.196, 0.0, -0.070),
"yaw": deg_to_rad(-24.0), "points": 300})
# --- the spinner lane, left side, fed by a hard left flipper shot ---
parts.append({"kind": "spinner", "id": "spin_main", "at": Vector3(-0.155, 0.0, 0.170),
"yaw": deg_to_rad(12.0), "points": 90})
parts.append({"kind": "wall", "id": "spin_guide", "at": Vector3(-0.205, 0.0, 0.170),
"size": Vector3(0.012, 0.05, 0.180), "yaw": deg_to_rad(6.0)})
# --- top: two pops and a saucer between them ---
parts.append({"kind": "bumper", "id": "pop_l", "at": Vector3(-0.075, 0.0, -0.250),
"kick": 0.165, "points": 250})
parts.append({"kind": "bumper", "id": "pop_r", "at": Vector3(0.075, 0.0, -0.250),
"kick": 0.165, "points": 250})
parts.append({"kind": "bumper", "id": "pop_t", "at": Vector3(0.0, 0.0, -0.335),
"kick": 0.165, "points": 250})
parts.append({"kind": "saucer", "id": "saucer_top", "at": Vector3(0.0, 0.0, -0.150),
"eject": Vector3(0.1, 0, 1), "hold": 0.85, "power": 0.20, "points": 2500})
# --- the N-E-O-N rollover lanes across the top arch ---
var glyphs := ["N", "E", "O", "N"]
for i in glyphs.size():
parts.append({"kind": "rollover", "id": "lane_%d" % i, "glyph": glyphs[i],
"at": Vector3(-0.105 + i * 0.070, 0.0, -0.430), "points": 150})
# --- the right ramp: the money shot ---
parts.append({"kind": "ramp", "id": "ramp_r", "at": Vector3(0.120, 0.0, 0.230),
"yaw": deg_to_rad(-14.0), "length": 0.30, "width": 0.052, "rise": 0.06,
"points": 1500})
return {
"name": "NEON ARCADE",
"subtitle": "wide open. learn the flippers here.",
"width": 0.52, "length": 1.10,
"palette": {
"field": Color(0.10, 0.09, 0.20), "rail": Color(0.62, 0.64, 0.72),
"flipper": Color(0.98, 0.30, 0.55), "bumper": Color(0.35, 0.95, 0.90),
"sling": Color(0.55, 0.95, 0.45), "target": Color(0.98, 0.55, 0.20),
"drop": Color(0.98, 0.88, 0.30), "spinner": Color(0.80, 0.90, 1.00),
"lane": Color(0.45, 0.60, 1.00), "ramp": Color(0.30, 0.75, 0.95),
"wall": Color(0.30, 0.32, 0.45), "post": Color(0.90, 0.92, 1.00),
"saucer": Color(0.08, 0.10, 0.18),
},
"parts": parts,
}
# ================================================================ TABLE 2
## THE GROTTO — tight and mean. Narrower field, a five-bank guarding the only ramp, and
## the outlanes are wide open, so a bad shot is punished immediately.
static func grotto() -> Dictionary:
var parts: Array = []
parts.append({"kind": "flipper", "id": "flip_l", "at": Vector3(-0.058, 0.022, 0.410),
"side": -1, "length": 0.072, "limit_lo": -34.0, "limit_hi": 28.0, "torque": 6.5})
parts.append({"kind": "flipper", "id": "flip_r", "at": Vector3(0.058, 0.022, 0.410),
"side": 1, "length": 0.072, "limit_lo": -28.0, "limit_hi": 34.0, "torque": 6.5})
parts.append({"kind": "sling", "id": "sling_l", "at": Vector3(-0.118, 0.0, 0.330),
"dir": Vector3(1, 0, -0.7), "yaw": deg_to_rad(-38.0), "kick": 0.155, "points": 150})
parts.append({"kind": "sling", "id": "sling_r", "at": Vector3(0.118, 0.0, 0.330),
"dir": Vector3(-1, 0, -0.7), "yaw": deg_to_rad(38.0), "kick": 0.155, "points": 150})
# no inlane posts — the outlanes are open, which is the whole personality of this table
# a five-bank straight across the middle: it BLOCKS the ramp until you clear it
for i in 5:
parts.append({"kind": "drop", "id": "gd_%d" % i, "bank": "gate",
"at": Vector3(-0.096 + i * 0.048, 0.0, 0.010), "points": 400})
parts.append({"kind": "ramp", "id": "ramp_c", "at": Vector3(0.0, 0.0, -0.040),
"yaw": 0.0, "length": 0.26, "width": 0.048, "rise": 0.07, "points": 3000})
parts.append({"kind": "spinner", "id": "spin_l", "at": Vector3(-0.170, 0.0, 0.130),
"yaw": deg_to_rad(16.0), "points": 120})
parts.append({"kind": "spinner", "id": "spin_r", "at": Vector3(0.170, 0.0, 0.130),
"yaw": deg_to_rad(-16.0), "points": 120})
parts.append({"kind": "bumper", "id": "pop_a", "at": Vector3(-0.060, 0.0, -0.290),
"kick": 0.180, "points": 300})
parts.append({"kind": "bumper", "id": "pop_b", "at": Vector3(0.060, 0.0, -0.290),
"kick": 0.180, "points": 300})
parts.append({"kind": "saucer", "id": "saucer_l", "at": Vector3(-0.150, 0.0, -0.220),
"eject": Vector3(0.4, 0, 1), "hold": 1.1, "power": 0.22, "points": 4000})
parts.append({"kind": "saucer", "id": "saucer_r", "at": Vector3(0.150, 0.0, -0.220),
"eject": Vector3(-0.4, 0, 1), "hold": 1.1, "power": 0.22, "points": 4000})
for i in 3:
parts.append({"kind": "rollover", "id": "glane_%d" % i, "glyph": "DIG".substr(i, 1),
"at": Vector3(-0.085 + i * 0.085, 0.0, -0.420), "points": 200})
parts.append({"kind": "post", "id": "gp_l", "at": Vector3(-0.100, 0.0, 0.190), "radius": 0.010})
parts.append({"kind": "post", "id": "gp_r", "at": Vector3(0.100, 0.0, 0.190), "radius": 0.010})
return {
"name": "THE GROTTO",
"subtitle": "open outlanes. the bank guards the ramp.",
"width": 0.46, "length": 1.10,
"palette": {
"field": Color(0.06, 0.14, 0.13), "rail": Color(0.45, 0.55, 0.52),
"flipper": Color(0.20, 0.85, 0.75), "bumper": Color(0.95, 0.60, 0.25),
"sling": Color(0.30, 0.70, 0.95), "target": Color(0.90, 0.35, 0.45),
"drop": Color(0.85, 0.95, 0.55), "spinner": Color(0.70, 0.95, 0.90),
"lane": Color(0.30, 0.85, 0.70), "ramp": Color(0.95, 0.75, 0.35),
"wall": Color(0.14, 0.26, 0.24), "post": Color(0.75, 0.90, 0.86),
"saucer": Color(0.04, 0.10, 0.09),
},
"parts": parts,
}
# ================================================================ TABLE 3
## THE FOUNDRY — the long one. A tall upper field stacked with pops, twin ramps, and a
## target array you have to pick apart. Slower, more deliberate, higher ceiling.
static func foundry() -> Dictionary:
var parts: Array = []
parts.append({"kind": "flipper", "id": "flip_l", "at": Vector3(-0.064, 0.022, 0.430),
"side": -1, "length": 0.082, "limit_lo": -30.0, "limit_hi": 32.0, "torque": 8.0})
parts.append({"kind": "flipper", "id": "flip_r", "at": Vector3(0.064, 0.022, 0.430),
"side": 1, "length": 0.082, "limit_lo": -32.0, "limit_hi": 30.0, "torque": 8.0})
# an upper-left third flipper, fed by the left ramp — the skill shot of this table
parts.append({"kind": "flipper", "id": "flip_u", "at": Vector3(-0.090, 0.022, -0.120),
"side": -1, "length": 0.062, "limit_lo": -28.0, "limit_hi": 26.0, "torque": 6.0})
parts.append({"kind": "sling", "id": "sling_l", "at": Vector3(-0.132, 0.0, 0.350),
"dir": Vector3(1, 0, -0.5), "yaw": deg_to_rad(-30.0), "kick": 0.130, "points": 100})
parts.append({"kind": "sling", "id": "sling_r", "at": Vector3(0.132, 0.0, 0.350),
"dir": Vector3(-1, 0, -0.5), "yaw": deg_to_rad(30.0), "kick": 0.130, "points": 100})
parts.append({"kind": "post", "id": "fp_l", "at": Vector3(-0.112, 0.0, 0.345)})
parts.append({"kind": "post", "id": "fp_r", "at": Vector3(0.112, 0.0, 0.345)})
# twin ramps flanking the centre
parts.append({"kind": "ramp", "id": "ramp_l", "at": Vector3(-0.135, 0.0, 0.190),
"yaw": deg_to_rad(12.0), "length": 0.28, "width": 0.050, "rise": 0.065, "points": 2000})
parts.append({"kind": "ramp", "id": "ramp_r", "at": Vector3(0.135, 0.0, 0.190),
"yaw": deg_to_rad(-12.0), "length": 0.28, "width": 0.050, "rise": 0.065, "points": 2000})
# a 2x3 target array in the centre you pick apart shot by shot
for row in 2:
for col in 3:
parts.append({"kind": "target", "id": "arr_%d_%d" % [row, col],
"at": Vector3(-0.058 + col * 0.058, 0.0, 0.020 - row * 0.075),
"width": 0.030, "points": 350})
# a four-bank up top
for i in 4:
parts.append({"kind": "drop", "id": "fd_%d" % i, "bank": "smelt",
"at": Vector3(-0.075 + i * 0.050, 0.0, -0.230), "points": 600})
parts.append({"kind": "spinner", "id": "spin_c", "at": Vector3(0.0, 0.0, 0.150),
"points": 150})
parts.append({"kind": "bumper", "id": "pop_1", "at": Vector3(-0.100, 0.0, -0.330),
"kick": 0.170, "points": 300})
parts.append({"kind": "bumper", "id": "pop_2", "at": Vector3(0.0, 0.0, -0.390),
"kick": 0.170, "points": 300})
parts.append({"kind": "bumper", "id": "pop_3", "at": Vector3(0.100, 0.0, -0.330),
"kick": 0.170, "points": 300})
parts.append({"kind": "saucer", "id": "saucer_deep", "at": Vector3(0.170, 0.0, -0.130),
"eject": Vector3(-0.6, 0, 1), "hold": 1.2, "power": 0.24, "points": 5000})
for i in 4:
parts.append({"kind": "rollover", "id": "flane_%d" % i, "glyph": "CAST".substr(i, 1),
"at": Vector3(-0.120 + i * 0.080, 0.0, -0.455), "points": 175})
return {
"name": "THE FOUNDRY",
"subtitle": "twin ramps, an upper flipper, and a long way up.",
"width": 0.54, "length": 1.20,
"palette": {
"field": Color(0.16, 0.10, 0.08), "rail": Color(0.58, 0.52, 0.46),
"flipper": Color(0.95, 0.55, 0.15), "bumper": Color(0.98, 0.85, 0.35),
"sling": Color(0.85, 0.40, 0.20), "target": Color(0.75, 0.80, 0.90),
"drop": Color(0.95, 0.45, 0.25), "spinner": Color(0.90, 0.85, 0.75),
"lane": Color(0.98, 0.70, 0.30), "ramp": Color(0.65, 0.68, 0.75),
"wall": Color(0.26, 0.18, 0.14), "post": Color(0.85, 0.80, 0.72),
"saucer": Color(0.10, 0.06, 0.05),
},
"parts": parts,
}

View File

@ -0,0 +1 @@
uid://b1ghqe4fkrdvm