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>
220 lines
12 KiB
GDScript
220 lines
12 KiB
GDScript
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,
|
|
}
|