macrosoft3dpinball/godot/scripts/Main.gd
m3ultra 2f1a1250cc 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>
2026-08-09 17:24:07 +10:00

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)