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>
237 lines
8.6 KiB
GDScript
237 lines
8.6 KiB
GDScript
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)
|