From 7347ec4db71f98e072733f6ecdca0abb9334ebf9 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Sun, 9 Aug 2026 18:18:15 +1000 Subject: [PATCH] =?UTF-8?q?Pinball:=20Rules,=20Juice,=20HUD,=20a=20table?= =?UTF-8?q?=20probe=20=E2=80=94=20and=20the=20three=20bugs=20it=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four lanes built in parallel onto the data-driven spine: - Rules.gd (509) — score with a decaying combo multiplier, 3 balls, ball save, escalating drop-bank bonuses, lit rollover lanes that level up when swept, saucer-lock multiball, two-warning tilt, end-of-ball bonus, per-table high scores in user://pinball_scores.cfg. - Juice.gd (784) — 12 procedural voices synthesized at boot, no assets. The spinner ratchet is timed to Table.gd bleeding spin_left at 6.0/s so the ticking stops exactly when the blade does. Pooled voices/lights/sparks, quadratic-falloff shake, and a damped spring for nudge. Palette-tinted lights with a value floor, because a light the colour of a near-black saucer illuminates nothing. - Hud.gd (935) — score that ROLLS UP rather than snapping, combo, award callouts, plunger meter, tilt banner, ball save, game over panel. - dev/probe_tables.gd (480) — the gate: builds every table and checks parts, part overlaps, playfield bounds, flipper swing, that the ball actually DRAINS, and that everything settles. The probe immediately found three real bugs the flipper autotest could not see: 1. The drain sat 3 cm PAST the playfield lip, so on four of five tables the ball rolled off the end into the void and the trigger never fired. A table you cannot lose a ball on is not a table. Moved inside the slab. 2. Inlane posts were level with the slingshots and interpenetrated them on three tables — on a real table the posts sit below. Moved down-table. 3. THE DIG's counter ramp ran through the crate bank, and a 15 deg yaw over a 28 cm ramp swings its far end 7 cm sideways, which then walked it through the left rail. Less yaw, tucked in, crates slid right. PROBE_TABLES: PASS 5/5. AUTOTEST: ALL TABLES OK. Still open, and it is an ENGINE finding not a table one: godot-box3d does not enforce HingeJoint3D angular limits — flippers swing 147-160 deg against a 62 deg limit. The probe reports it as a NOTE per table. Worth an upstream issue alongside the motor-sign inversion already in the README. Co-Authored-By: Claude Fable 5 --- godot/dev/probe_tables.gd | 480 +++++++++++++++++ godot/dev/probe_tables.gd.uid | 1 + godot/scripts/Hud.gd | 935 ++++++++++++++++++++++++++++++++++ godot/scripts/Hud.gd.uid | 1 + godot/scripts/Juice.gd | 784 ++++++++++++++++++++++++++++ godot/scripts/Juice.gd.uid | 1 + godot/scripts/Rules.gd | 509 ++++++++++++++++++ godot/scripts/Rules.gd.uid | 1 + godot/scripts/Table.gd | 6 +- godot/scripts/Tables.gd | 18 +- 10 files changed, 2726 insertions(+), 10 deletions(-) create mode 100644 godot/dev/probe_tables.gd create mode 100644 godot/dev/probe_tables.gd.uid create mode 100644 godot/scripts/Hud.gd create mode 100644 godot/scripts/Hud.gd.uid create mode 100644 godot/scripts/Juice.gd create mode 100644 godot/scripts/Juice.gd.uid create mode 100644 godot/scripts/Rules.gd create mode 100644 godot/scripts/Rules.gd.uid diff --git a/godot/dev/probe_tables.gd b/godot/dev/probe_tables.gd new file mode 100644 index 0000000..d08bbe6 --- /dev/null +++ b/godot/dev/probe_tables.gd @@ -0,0 +1,480 @@ +extends SceneTree + +## THE TABLE GATE. Builds every table in Tables.ORDER headless and measures the six things +## that have to be true before a layout is playable at all: +## +## 1 IT BUILDS every part in the spec produced a node +## 2 FLIPPERS SWING the motors actually move the bats, with this engine's sign +## 3 NO OVERLAPS no two solid parts interpenetrate — a wedge is a stuck ball +## 4 INSIDE THE FIELD nothing sits outside the playfield rectangle +## 5 THE BALL DRAINS a released ball reaches the drain instead of being lost or trapped +## 6 IT SETTLES at rest, nothing is moving except the ball +## +## Same job as Destroyulator's dev/probe_levels.gd — measure the level instead of squinting at +## a screenshot. A table can look perfect and still be unplayable because two colliders share +## a millimetre, or because a ball that reaches the bottom is never reported as drained. +## +## The numbers legitimately DIFFER between Box3D / Jolt / GodotPhysics — that divergence is +## the entire point of this testbed — so the active engine is printed with every run and +## belongs in any report of a failure. Exits 1 if any table fails. +## +## Godot --headless --path godot --script dev/probe_tables.gd +## +## Two lines of stderr noise are expected and are NOT probe failures: Box3D logs "Parameter +## joint is null" while each flipper hinge is being wired (the spine sets joint params before +## node_a/node_b resolve), and it reports unfreed RIDs at shutdown — the spine's own +## PINBALL_AUTOTEST does both too. Add 2>/dev/null for a clean read. + +const SWING_FRAMES := 30 ## half a second of motor, same window as Main's autotest +const SWING_MIN_DEG := 8.0 ## below this the bat is unjointed or jammed +const SWING_SLOP_DEG := 25.0 ## past the spec'd limit range = the stop is not holding +const SETTLE_FRAMES := 30 +const LANE_DRAIN_S := 4.0 ## a parked ball rolls the shooter lane in well under this +const FIELD_DRAIN_S := 10.0 ## a loose ball may sit in a saucer and rattle the pops first +const STALL_FRAMES := 120 ## 2 s under STALL_V and the table is holding the ball +const STALL_V := 0.02 +const OVERLAP_CLEAR := 0.002 ## metres of slop before a pair counts as interpenetrating +const EDGE_SLOP := 0.001 +const STILL_LIN := 0.02 ## m/s +const STILL_ANG := 0.60 ## rad/s — a motored flipper on its stop still creeps + +var _flip_sign := 1.0 +var _frame := 0 ## physics frames since boot; the probe's only clock +var _drain_frame := -1 +var _drained_ball: RigidBody3D = null + +func _initialize() -> void: + # Table.add_ball() spreads new balls with randf, and a gate that reports a different + # verdict on every run is worthless. Pin the stream. + seed(20260809) + # _initialize() runs BEFORE the SceneTree attaches its root, so anything add_child'd here + # is not actually inside the tree: global_transform reads back as identity and physics + # nodes register late. One frame fixes it. (Measured, not folklore — building a table + # before this line gives every part a global position of (0,0,0).) + await process_frame + var engine := String(ProjectSettings.get_setting("physics/3d/physics_engine", "?")) + # Resolved exactly as Main._ready does it: under Box3D a positive motor velocity swings a + # hinge the opposite way to Jolt/GodotPhysics. Probing with the wrong sign reads as "the + # flipper does not swing" when in fact it is swinging hard into its own stop. + _flip_sign = -1.0 if engine.begins_with("Box3D") else 1.0 + print("PROBE_TABLES engine=\"%s\" flip_sign=%.0f tick=%d gravity=%s" % [ + engine, _flip_sign, Engine.physics_ticks_per_second, + ProjectSettings.get_setting("physics/3d/default_gravity_vector", Vector3.DOWN)]) + + var failed: Array[String] = [] + for id in Tables.ORDER: + if not await _probe(String(id)): + failed.append(String(id)) + + var n: int = Tables.ORDER.size() + print("\nPROBE_TABLES result: %s (%d/%d tables passed, engine %s)" % [ + "PASS" if failed.is_empty() else "FAIL [" + ", ".join(failed) + "]", + n - failed.size(), n, engine]) + quit(1 if failed.size() > 0 else 0) + +# ---------------------------------------------------------------- one table +func _probe(id: String) -> bool: + var table := Table.new() + table.name = "T_%s" % id + # In the tree before build(), the way Main does it: the flipper hinges resolve node_a and + # node_b by PATH, and every part position this probe measures is a global_transform read. + get_root().add_child(table) + table.build(Tables.get_table(id)) + table.drained.connect(_on_drained) + var spec: Dictionary = table.spec + var w: float = float(spec.get("width", Table.PLAYFIELD_W)) + var l: float = float(spec.get("length", Table.PLAYFIELD_L)) + + print("\n=== %-9s %s" % [id, table.table_name()]) + var fails: Array[String] = [] + + # --- 1 IT BUILDS --------------------------------------------------------- + var want: Dictionary = {} + var missing: Array[String] = [] + for p in spec.get("parts", []): + var pd: Dictionary = p + want[String(pd.get("kind", "?"))] = int(want.get(pd.get("kind", "?"), 0)) + 1 + if table.part(String(pd.get("id", ""))) == null: + missing.append(String(pd.get("id", "?"))) + var kinds := want.keys() + kinds.sort() + var tally: Array[String] = [] + var gaps: Array[String] = [] + for k in kinds: + var got: int = table.parts_of(String(k)).size() + tally.append("%s %d" % [k, got]) + if got != int(want[k]): + gaps.append("%s %d/%d" % [k, got, int(want[k])]) + var total: int = spec.get("parts", []).size() + print(" build %d/%d parts · %s" % [total - missing.size(), total, " ".join(tally)]) + if total == 0 or not missing.is_empty() or not gaps.is_empty(): + fails.append("build") + print(" MISSING ids: %s · kind gaps: %s" % [ + ", ".join(missing) if missing.size() > 0 else "-", + ", ".join(gaps) if gaps.size() > 0 else "-"]) + + # --- 5a THE BALL DRAINS, from the shooter lane --------------------------- + # Rest the motors first: Main drives the flippers onto their stops whenever no key is + # held, so "no flipper input" means driven-to-rest, not limp. A limp bat hangs loose in + # the tilted gravity and the ball would meet a flipper that never exists in the game. + _motors(table, false) + var lane := await _chase_drain(table, table.balls[0] if table.balls.size() > 0 else null, + w, l, int(LANE_DRAIN_S * Engine.physics_ticks_per_second)) + if String(lane["code"]) != "drained": + fails.append("lane-drain") + elif _drained_ball != null: + table.remove_ball(_drained_ball) # keep it out of the settle and overlap reads + + # --- 6 IT SETTLES -------------------------------------------------------- + await _step(SETTLE_FRAMES) + var rest := _residual(table) + var still: bool = float(rest["lin"]) < STILL_LIN and float(rest["ang"]) < STILL_ANG + + # --- 3 NO OVERLAPS + 4 INSIDE THE FIELD ---------------------------------- + # Measured at rest, not at t=0: a flipper is only in its real playing pose once the motor + # has pushed it onto its stop, and that pose is what a ball actually meets. + var boxes := _colliders(table) + var pairs := _overlaps(boxes) + var outside := _outside(boxes, w, l) + print(" overlaps %d solid pair(s) of %d collider(s)" % [pairs.size(), boxes.size()]) + for h in pairs.slice(0, 6): + print(" %.4f m into %-16s / %-16s at %s" % [ + h["pen"], h["a"], h["b"], (h["at"] as Vector3).snappedf(0.001)]) + if not pairs.is_empty(): + fails.append("overlap") + print(" bounds %d part(s) outside the %.2f x %.2f playfield" % [outside.size(), w, l]) + for o in outside.slice(0, 6): + print(" %-16s reaches %s" % [o["id"], (o["at"] as Vector3).snappedf(0.001)]) + if not outside.is_empty(): + fails.append("bounds") + + # --- 2 FLIPPERS SWING ---------------------------------------------------- + var before: Array[float] = [] + for b in table.flipper_bodies: + before.append(b.rotation.y) + _motors(table, true) + await _step(SWING_FRAMES) + var swings: Array[String] = [] + var worst := 999.0 + var burst: Array[String] = [] + 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("%.1f" % d) + worst = minf(worst, d) + # A bat that travels much further than its own limit range has punched through its + # stop. Not fatal to the gate, but it sweeps playfield it was never meant to reach, + # and which engine does it is exactly what this testbed is for (see README). + var lim := _limit_range(spec, String(table.flipper_bodies[i].get_meta("id", ""))) + if lim > 0.0 and d > lim + SWING_SLOP_DEG: + burst.append("%s %.0f>%.0f" % [table.flipper_bodies[i].get_meta("id", "?"), d, lim]) + var flips_ok: bool = table.flipper_bodies.size() > 0 and worst > SWING_MIN_DEG + print(" flippers %d · swing_deg [%s] · min %.1f %s%s" % [ + table.flipper_bodies.size(), ", ".join(swings), 0.0 if worst > 900.0 else worst, + "OK" if flips_ok else "FAIL", + " NOTE past limit: " + ", ".join(burst) if not burst.is_empty() else ""]) + if not flips_ok: + fails.append("flippers") + _motors(table, false) + await _step(SETTLE_FRAMES) + + # --- 5b THE BALL DRAINS, from out on the playfield ------------------------ + # The lane roll only proves the lane slopes. This is the real question: dropped into the + # open field with no flipper help, does the ball find its way out? Release point is the + # clearest spot the collider survey above could find, because a hand-picked coordinate + # lands underneath a centre ramp on half these tables and measures the probe, not the table. + # + # Saucers are parked first. Table._saucer holds the ball on a SceneTreeTimer, and a + # SceneTreeTimer counts IDLE frames — headless, the loop spins as fast as it can, so a + # 0.9 s hold lasts a different number of PHYSICS steps on every machine and every load, + # and every trajectory downstream of a capture moves with it. Verified: adding one print + # statement flipped this test's verdict on two tables. Park them and the answer is about + # the table again. (Not a bug in the game at a steady 60 fps — a gotcha for anything that + # measures it. If a saucer ever needs testing, test the eject on its own.) + for s in table.parts_of("saucer"): + if s is Area3D: + (s as Area3D).set_deferred("monitoring", false) + await _step(2) + var spot := _release_spot(boxes, w, l, table.lane_x) + var fb := table.add_ball(Vector3(spot.x, Table.BALL_R + 0.006, spot.y)) + fb.linear_velocity = Vector3.ZERO + fb.angular_velocity = Vector3.ZERO + var field := await _chase_drain(table, fb, w, l, + int(FIELD_DRAIN_S * Engine.physics_ticks_per_second)) + # "out" counts as a pass here alongside "drained", and that is not slack — it is working + # around a measured engine divergence rather than blaming the table for it. + # + # Under Box3D the drain Area only reports a ball arriving SLOWLY. Swept at x=0 down the + # open bottom edge: 0.05 / 0.10 / 0.20 / 0.30 m/s all fire body_entered, 0.40 / 0.60 / + # 1.00 m/s never do — and the fast ones sit geometrically inside the 5 cm drain volume for + # seven physics ticks while doing it, so this is not tunnelling. Under Jolt and + # GodotPhysics the same ball at the same speed reports normally. It is not about + # teleporting either: a ball moved with add_ball(at) still trips bumpers and rollovers. + # + # A ball only ever reaches the shooter lane's bottom at a crawl, which is why the lane + # test above passes everywhere and why nothing had caught this. So: the lane test gates + # the drain SIGNAL, and this test gates the geometry — does anything out on this playfield + # hold a ball forever. The printed line says which way the ball actually left. + if not (String(field["code"]) in ["drained", "out"]): + fails.append("field-drain") + + print(" drain lane %s · field %s (released at %.3f, %.3f)" % [ + _drain_str(lane, LANE_DRAIN_S), _drain_str(field, FIELD_DRAIN_S), spot.x, spot.y]) + for d in [lane, field]: + var code := String(d["code"]) + if code == "out" and d == lane: + # Worse than a stuck ball: it is gone AND Rules never hears about it, so the game + # sits there waiting for a drain that already happened. + print(" left the table without tripping the drain, at %s" % [ + (d["at"] as Vector3).snappedf(0.001)]) + elif code == "stall" or code == "escaped": + print(" %s at %s" % [code, (d["at"] as Vector3).snappedf(0.001)]) + print(" settling lin %.4f m/s · ang %.3f rad/s · worst %s %s" % [ + rest["lin"], rest["ang"], rest["who"], "OK" if still else "FAIL"]) + if not still: + fails.append("settling") + + print(" VERDICT %s" % ("PASS" if fails.is_empty() else "FAIL — " + ", ".join(fails))) + # Out of the tree and freed outright rather than queue_free'd: teardown then happens here + # instead of at some later idle frame, so the next table never shares a physics step with + # the last one's bodies. Everything above turned out to be sensitive to exactly that kind + # of overlap, so it is not worth leaving to chance. + get_root().remove_child(table) + table.free() + await process_frame + return fails.is_empty() + +# ---------------------------------------------------------------- driving +## Exactly Main._physics_process's numbers, so the probe measures the game's flippers and not +## some idealised version of them. +func _motors(table: Table, held: bool) -> void: + for i in table.flipper_joints.size(): + var v: float = (26.0 if held else -9.0) * float(table.flip_side(i)) * _flip_sign + table.flipper_joints[i].set_param(HingeJoint3D.PARAM_MOTOR_TARGET_VELOCITY, v) + +func _step(n: int) -> void: + for i in n: + _frame += 1 + await physics_frame + +func _on_drained(b: RigidBody3D) -> void: + _drain_frame = _frame + _drained_ball = b + +func _limit_range(spec: Dictionary, id: String) -> float: + for p in spec.get("parts", []): + var pd: Dictionary = p + if String(pd.get("id", "")) == id: + return absf(float(pd.get("limit_hi", 0.0)) - float(pd.get("limit_lo", 0.0))) + return 0.0 + +## Follow one ball until the table resolves it, and say HOW it resolved: +## drained — the drain Area fired, which is the only ending Rules can actually see +## out — the ball left past the bottom edge but the Area never fired +## escaped — it left sideways, over a rail +## stall — the geometry stopped it and is holding it +## timeout — still rattling when the budget ran out +func _chase_drain(table: Table, ball: RigidBody3D, w: float, l: float, limit: int) -> Dictionary: + _drain_frame = -1 + _drained_ball = null + if ball == null: + return {"code": "missing", "frames": -1, "at": Vector3.ZERO} + var start := _frame + var stall := 0 + while _frame - start < limit: + _frame += 1 + await physics_frame + if _drain_frame >= 0: + return {"code": "drained", "frames": _drain_frame - start, "at": ball.position} + if not is_instance_valid(ball): + return {"code": "out", "frames": _frame - start, "at": Vector3.ZERO} + var p := ball.position + if absf(p.x) > w * 0.5 + 0.05: + return {"code": "escaped", "frames": _frame - start, "at": p} + if p.z > l * 0.5 + 0.06 or p.y < -0.05: + return {"code": "out", "frames": _frame - start, "at": p} + # A saucer legitimately holds the ball frozen for a second or so; only a ball the + # table has stopped under its own power counts as stalled. + if not ball.freeze and ball.linear_velocity.length() < STALL_V: + stall += 1 + if stall > STALL_FRAMES: + return {"code": "stall", "frames": _frame - start, "at": p} + else: + stall = 0 + return {"code": "timeout", "frames": limit, + "at": ball.position if is_instance_valid(ball) else Vector3.ZERO} + +func _drain_str(d: Dictionary, budget: float) -> String: + var code := String(d["code"]) + var t := float(d["frames"]) / float(Engine.physics_ticks_per_second) + match code: + "drained": return "%.2f s" % t + "out": return "%.2f s off the edge, drain never fired" % t + "escaped": return "ESCAPED over a rail" + "stall": return "STALLED on the playfield" + "missing": return "NO BALL" + return "NEVER (>%.0f s)" % budget + +func _residual(table: Table) -> Dictionary: + var lin := 0.0 + var ang := 0.0 + var who := "-" + for n in table.get_children(): + var b := n as RigidBody3D + if b == null or b.freeze or table.balls.has(b): + continue + var lv := b.linear_velocity.length() + var av := b.angular_velocity.length() + if lv > lin or av > ang: + who = String(b.name) + lin = maxf(lin, lv) + ang = maxf(ang, av) + return {"lin": lin, "ang": ang, "who": who} + +# ---------------------------------------------------------------- geometry +## Every collider of every registered part, reduced to a box that yaws about Y. +## +## That reduction is exact for this part vocabulary: parts yaw about Y and nothing else, +## except a ramp bed whose shapes also pitch about their local X — folded in below by +## widening the Y/Z half-extents to the pitched box's own bounds, which is tight, not sloppy. +func _colliders(table: Table) -> Array: + var out: Array = [] + for p in table.spec.get("parts", []): + var pd: Dictionary = p + var pid := String(pd.get("id", "")) + var n := table.part(pid) + if n == null or not is_instance_valid(n): + continue + var host := n as Node3D + # Only bodies are solid. A spinner/saucer/rollover is an Area3D the ball is MEANT to + # pass through, so overlapping triggers are not a fault — they are still bounds-checked. + var solid: bool = n is StaticBody3D or n is RigidBody3D + for c in host.get_children(): + var cs := c as CollisionShape3D + if cs == null or cs.disabled or cs.shape == null: + continue + var h := Vector3.ZERO + var round_xz := false + var round_y := false + if cs.shape is BoxShape3D: + h = (cs.shape as BoxShape3D).size * 0.5 + elif cs.shape is CylinderShape3D: + var cy := cs.shape as CylinderShape3D + h = Vector3(cy.radius, cy.height * 0.5, cy.radius) + round_xz = true + elif cs.shape is SphereShape3D: + var r: float = (cs.shape as SphereShape3D).radius + h = Vector3(r, r, r) + round_xz = true + round_y = true + else: + continue + var pitch := cs.rotation.x + if absf(pitch) > 0.0001: + h = Vector3(h.x, + absf(h.y * cos(pitch)) + absf(h.z * sin(pitch)), + absf(h.y * sin(pitch)) + absf(h.z * cos(pitch))) + # A round shape boxed up over-reports at the corners, so parts that are nested but + # clear would false-positive; shrink to the inscribed square for the overlap test + # only. Bounds and clearance still use the true extent. + var hov := h + if round_xz: + hov = Vector3(h.x * 0.707, hov.y, h.z * 0.707) + if round_y: + hov.y = h.y * 0.707 + out.append({"id": pid, "solid": solid, "c": cs.global_position, + "h": h, "hov": hov, "yaw": host.global_rotation.y}) + return out + +## Two boxes that only yaw about Y keep their Y axes parallel, so the test splits into a 1D +## interval on Y and a 2D rotated-rectangle SAT in XZ — exact, and far cheaper than a general +## OBB test. Returns the smallest overlap over all separating axes: the depth of the wedge. +func _pen(a: Dictionary, b: Dictionary) -> float: + var ha: Vector3 = a["hov"] + var hb: Vector3 = b["hov"] + var ca: Vector3 = a["c"] + var cb: Vector3 = b["c"] + var best: float = (ha.y + hb.y) - absf(ca.y - cb.y) + if best <= 0.0: + return 0.0 + var ya: float = a["yaw"] + var yb: float = b["yaw"] + var ax := [Vector2(cos(ya), -sin(ya)), Vector2(sin(ya), cos(ya)), + Vector2(cos(yb), -sin(yb)), Vector2(sin(yb), cos(yb))] + var d := Vector2(cb.x - ca.x, cb.z - ca.z) + for u in ax: + var ra: float = absf(ha.x * ax[0].dot(u)) + absf(ha.z * ax[1].dot(u)) + var rb: float = absf(hb.x * ax[2].dot(u)) + absf(hb.z * ax[3].dot(u)) + var o: float = ra + rb - absf(d.dot(u)) + if o <= 0.0: + return 0.0 + best = minf(best, o) + return best + +func _overlaps(boxes: Array) -> Array: + var hits: Array = [] + for i in boxes.size(): + var a: Dictionary = boxes[i] + if not a["solid"]: + continue + for j in range(i + 1, boxes.size()): + var b: Dictionary = boxes[j] + if not b["solid"] or a["id"] == b["id"]: + continue + var pen := _pen(a, b) + if pen > OVERLAP_CLEAR: + hits.append({"a": a["id"], "b": b["id"], "pen": pen, + "at": ((a["c"] as Vector3) + (b["c"] as Vector3)) * 0.5}) + hits.sort_custom(func(x, y) -> bool: return float(x["pen"]) > float(y["pen"])) + return hits + +## A part outside the playfield rectangle is unreachable at best and a ball trap at worst. +## A yawed box's footprint is its own extents projected onto X and Z. +func _outside(boxes: Array, w: float, l: float) -> Array: + var bad: Array = [] + var seen: Dictionary = {} + for e in boxes: + var b: Dictionary = e + var h: Vector3 = b["h"] + var yaw: float = b["yaw"] + var c: Vector3 = b["c"] + var reach := Vector3(absf(c.x) + absf(h.x * cos(yaw)) + absf(h.z * sin(yaw)), c.y, + absf(c.z) + absf(h.x * sin(yaw)) + absf(h.z * cos(yaw))) + if reach.x > w * 0.5 + EDGE_SLOP or reach.z > l * 0.5 + EDGE_SLOP: + if seen.has(b["id"]): + continue + seen[b["id"]] = true + bad.append({"id": b["id"], "at": reach}) + return bad + +## The emptiest point in the upper playfield, as (x, z). Sweeps a grid and keeps the sample +## whose worst footprint clearance is largest, preferring higher up the table on a tie. +## Colliders that sit entirely above ball height are skipped — a ball passes under a raised +## ramp bed, it does not collide with its plan view. +func _release_spot(boxes: Array, w: float, l: float, lane_x: float) -> Vector2: + var rects: Array = [] + for e in boxes: + var b: Dictionary = e + var h: Vector3 = b["h"] + var c: Vector3 = b["c"] + if c.y - h.y > Table.BALL_R * 2.0 + 0.01: + continue + var yaw: float = b["yaw"] + rects.append({"c": Vector2(c.x, c.z), + "e": Vector2(absf(h.x * cos(yaw)) + absf(h.z * sin(yaw)), + absf(h.x * sin(yaw)) + absf(h.z * cos(yaw)))}) + var best := Vector2(0.0, -l * 0.2) + var best_clear := -99.0 + for zi in 41: + var z: float = -l * 0.36 + float(zi) / 40.0 * (l * 0.28) + for xi in 33: + var x: float = -w * 0.34 + float(xi) / 32.0 * (w * 0.68) + if x > lane_x - 0.05: + continue # never release into the shooter lane + var clear := 99.0 + for r in rects: + var d: Vector2 = (Vector2(x, z) - (r["c"] as Vector2)).abs() - (r["e"] as Vector2) + clear = minf(clear, maxf(d.x, d.y)) + if clear > best_clear: + best_clear = clear + best = Vector2(x, z) + return best diff --git a/godot/dev/probe_tables.gd.uid b/godot/dev/probe_tables.gd.uid new file mode 100644 index 0000000..b338114 --- /dev/null +++ b/godot/dev/probe_tables.gd.uid @@ -0,0 +1 @@ +uid://bi3gcjqlqjsok diff --git a/godot/scripts/Hud.gd b/godot/scripts/Hud.gd new file mode 100644 index 0000000..57512d7 --- /dev/null +++ b/godot/scripts/Hud.gd @@ -0,0 +1,935 @@ +extends CanvasLayer +class_name Hud + +## The read-out — everything a player learns without looking at the ball: score, ball number, +## combo, what just got awarded, how hard the plunger is wound, and whether the machine is +## about to tilt. +## +## The HUD is a READER, never a source of truth. It polls the Rules node each frame and listens +## to its `awarded` signal. Every one of those is probed defensively (has_method / property +## list) because Rules is a separate lane that may not exist, and may spell things differently. +## With no Rules present the HUD still works: it falls back to totting up the table's own hit +## points so the score reel has something to roll. +## +## Built entirely in code — no .tscn, no font or image files. Type is SystemFont wrapped in a +## FontVariation, which is the only way to get letter-spacing: Label has no tracking constant. +## +## Godot 4.7. + +# --- score reel. A real reel never crawls and never takes forever; both ends are clamped. +const REEL_SPAN := 0.55 ## seconds to close any gap, however large +const REEL_MIN_RATE := 900.0 ## ...but at least this many points/sec, so small adds tick +const REEL_MIN_DIGITS := 6 ## the display is this wide even at zero, like a real backglass + +const CALLOUT_SLOTS := 3 +const CALLOUT_LIFE := 2.4 +const CALLOUT_FADE := 0.7 +const CALLOUT_STEP := 46.0 ## vertical gap between stacked awards, in 720p pixels + +const COMBO_CEIL := 8.0 ## combo value at which the badge is maximally loud + +const HINT := "Z / SLASH flippers · SPACE plunger · ARROWS nudge · T table · R new game" + +# ---------------------------------------------------------------- state +var main: Node = null +var _rules: Node = null +var _rules_props: Dictionary = {} ## property names Rules actually has, so we never blind-get +var _rules_consts: Dictionary = {} ## its script constants — window lengths we must divide by +var _awarded_wired := false +var _table_wired := false + +var _t := 0.0 +var _ui := 1.0 ## everything is sized in "720p pixels" times this +var _vp := Vector2(1152, 648) + +var _accent := Color(1.0, 0.35, 0.55) +var _accent2 := Color(0.35, 0.95, 0.90) + +var _score_target := 0.0 +var _score_shown := 0.0 +var _reel_digits := REEL_MIN_DIGITS +var _delta_amount := 0 +var _delta_t := 99.0 + +var _combo := 1.0 +var _combo_loud := 0.0 +var _combo_pulse := 0.0 +var _combo_col := Color.WHITE +var _combo_left := -1.0 ## 0..1 combo timer if Rules exposes one, else -1 +var _combo_span := 0.001 ## longest combo_left ever seen — normalises a seconds timer + +var _plunge_view := 0.0 +var _plunge_flash := 0.0 + +var _tilt_t := 0.0 +var _warnings := 0 +var _over_t := 0.0 +var _was_over := false +var _new_high := false + +var _fallback_score := 0 ## only used when no Rules lane is present +var _callouts: Array[Dictionary] = [] + +# ---------------------------------------------------------------- nodes +var _root: Control +var _vig: Control +var _bar: Panel +var _table_lbl: Label +var _sub_lbl: Label +var _extra_lbl: Label +var _score_ghost: Label +var _score_glow: Label +var _score_lbl: Label +var _delta_lbl: Label +var _ball_lbl: Label +var _combo_box: Control +var _combo_lbl: Label +var _combo_cap: Label +var _status_lbl: Label +var _cal_slots: Array[Control] = [] +var _meter: Control +var _meter_cap: Label +var _save_pill: Panel +var _mb_pill: Panel +var _tilt_lbl: Label +var _warn_lbl: Label +var _over_panel: Panel +var _over_title: Label +var _over_score: Label +var _over_high: Label +var _over_hint: Label +var _hint_lbl: Label + +var _font_cache: Dictionary = {} +var _sf_display: SystemFont +var _sf_mono: SystemFont +var _sb_bar: StyleBoxFlat +var _sb_meter: StyleBoxFlat +var _sb_combo: StyleBoxFlat +var _sb_glow: StyleBoxFlat +var _sb_save: StyleBoxFlat +var _sb_mb: StyleBoxFlat +var _sb_over: StyleBoxFlat + +# home positions captured at layout time; per-frame animation offsets from these +var _combo_home := Vector2.ZERO +var _cal_home_y := 0.0 + +# ---------------------------------------------------------------- lifecycle +func _ready() -> void: + layer = 100 # the read-out is always the topmost thing on screen + _build() + _layout() + var vp := get_viewport() + if vp != null and not vp.size_changed.is_connected(_layout): + vp.size_changed.connect(_layout) + +## Main hands us itself after add_child, so _ready has already run by the time we get here. +func setup(m: Node) -> void: + main = m + _find_rules() + _sync_table() + +## Called by Main every time a table is built. Also the moment we can steal the table's own +## palette — the HUD adopting the playfield's colours is free cohesion for three tables. +func set_table(tname: String, subtitle: String) -> void: + _table_lbl.text = tname.to_upper() + _sub_lbl.text = subtitle + _sync_table() + var spec: Dictionary = _table_spec() + var pal: Dictionary = spec.get("palette", {}) + if pal.has("flipper"): + _accent = _ui_tint(pal["flipper"]) + if pal.has("bumper"): + _accent2 = _ui_tint(pal["bumper"]) + elif pal.has("lane"): + _accent2 = _ui_tint(pal["lane"]) + _restyle_accent() + +func _table_spec() -> Dictionary: + if main == null: + return {} + var t = main.get("table") + if t == null or not is_instance_valid(t): + return {} + var s = t.get("spec") + return s if s is Dictionary else {} + +## Playfield colours are chosen to look good under a light; on a dark HUD plate the dim ones +## turn to mud. Push saturation and value into a band that stays legible. +func _ui_tint(c: Color) -> Color: + return Color.from_hsv(c.h, clampf(c.s, 0.35, 0.92), maxf(c.v, 0.88)) + +# ---------------------------------------------------------------- rules discovery +func _find_rules() -> void: + if is_instance_valid(_rules): + return + var p := get_parent() + if p == null: + return + var r := p.get_node_or_null("Rules") + if r == null: + return + _rules = r + _rules_props.clear() + # Object has has_method() but no has_property(), and a blind get() on a missing name is a + # silent null we can't tell from a real null. Index the property list once instead. + for pr in r.get_property_list(): + _rules_props[String(pr.get("name", ""))] = true + # Timers come back as seconds; to draw a depleting bar we need the window they started + # from, and that lives in Rules' consts (which never show up in get_property_list()). + var scr: Variant = r.get_script() + if scr is Script: + _rules_consts = (scr as Script).get_script_constant_map() + if not _awarded_wired and r.has_signal("awarded"): + r.connect("awarded", _on_awarded) + _awarded_wired = true + +## Ask Rules for a value under any of its plausible spellings, method first, then property. +func _ask(names: PackedStringArray, fallback: Variant) -> Variant: + if not is_instance_valid(_rules): + return fallback + for n in names: + if _rules.has_method(n): + return _rules.call(n) + for n in names: + if _rules_props.has(n): + return _rules.get(n) + return fallback + +func _ask_f(names: PackedStringArray, fallback: float) -> float: + var v: Variant = _ask(names, fallback) + return float(v) if (v is float or v is int or v is bool) else fallback + +func _ask_i(names: PackedStringArray, fallback: int) -> int: + return int(round(_ask_f(names, float(fallback)))) + +func _ask_b(names: PackedStringArray, fallback: bool) -> bool: + var v: Variant = _ask(names, fallback) + if v is bool: + return v + if v is int or v is float: + return float(v) > 0.0 + return fallback + +func _ask_s(names: PackedStringArray, fallback: String) -> String: + var v: Variant = _ask(names, fallback) + return String(v) if v is String else fallback + +func _rule_const(names: PackedStringArray, fallback: float) -> float: + for n in names: + if _rules_consts.has(n): + var v: Variant = _rules_consts[n] + if v is float or v is int: + return float(v) + return fallback + +func _rules_scores() -> bool: + return is_instance_valid(_rules) and (_rules.has_method("score") or _rules_props.has("score")) + +## Hook the table's raw hit stream. Only consumed when no Rules lane is scoring — see _on_hit. +func _sync_table() -> void: + if _table_wired or main == null: + return + var t = main.get("table") + if t == null or not is_instance_valid(t) or not t.has_signal("hit"): + return + t.connect("hit", _on_hit) + _table_wired = true + +# ---------------------------------------------------------------- build +func _build() -> void: + _sf_display = SystemFont.new() + # Impact first for the machine-shop look, then progressively less exciting fallbacks. This + # is a font *request*, not a font file — nothing ships on disk. + _sf_display.font_names = PackedStringArray(["Impact", "Haettenschweiler", "Arial Black", + "Helvetica Neue", "Helvetica", "Arial", "Sans-Serif"]) + _sf_display.font_weight = 900 + _sf_display.antialiasing = TextServer.FONT_ANTIALIASING_GRAY + + _sf_mono = SystemFont.new() + _sf_mono.font_names = PackedStringArray(["SF Mono", "Menlo", "Monaco", "Consolas", + "DejaVu Sans Mono", "Courier New", "Monospace"]) + _sf_mono.font_weight = 700 + _sf_mono.antialiasing = TextServer.FONT_ANTIALIASING_GRAY + + _root = Control.new() + _root.name = "Root" + _root.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(_root) + + # tilt vignette goes in first so every readout draws over it + _vig = _mk_ctrl() + _vig.draw.connect(_draw_vignette) + + _sb_bar = StyleBoxFlat.new() + _sb_bar.bg_color = Color(0.015, 0.02, 0.04, 0.78) + _sb_bar.border_width_bottom = 2 + _sb_bar.border_color = _accent + _bar = Panel.new() + _bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + _bar.add_theme_stylebox_override("panel", _sb_bar) + _root.add_child(_bar) + + _table_lbl = _mk_label(HORIZONTAL_ALIGNMENT_LEFT) + _sub_lbl = _mk_label(HORIZONTAL_ALIGNMENT_LEFT) + _extra_lbl = _mk_label(HORIZONTAL_ALIGNMENT_LEFT) + _extra_lbl.visible = false + # unlit segments: a full row of 8s behind the live score, the way a real display idles + _score_ghost = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT) + _score_glow = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT) + _score_lbl = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT) + _delta_lbl = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT) + _ball_lbl = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT) + + _combo_box = _mk_ctrl() + _combo_box.draw.connect(_draw_combo) + _combo_cap = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _combo_box) + _combo_cap.text = "COMBO" + _combo_lbl = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _combo_box) + _combo_lbl.text = "1x" + + _status_lbl = _mk_label(HORIZONTAL_ALIGNMENT_CENTER) + + for i in CALLOUT_SLOTS: + var slot := _mk_ctrl() + var title := _mk_label(HORIZONTAL_ALIGNMENT_CENTER, slot) + title.name = "T" + var pts := _mk_label(HORIZONTAL_ALIGNMENT_CENTER, slot) + pts.name = "P" + slot.visible = false + _cal_slots.append(slot) + + _sb_meter = StyleBoxFlat.new() + _sb_meter.bg_color = Color(0.02, 0.03, 0.06, 0.75) + _sb_meter.border_color = Color(1, 1, 1, 0.16) + _sb_meter.set_border_width_all(1) + _sb_meter.set_corner_radius_all(3) + _meter = _mk_ctrl() + _meter.draw.connect(_draw_meter) + _meter.visible = false + _meter_cap = _mk_label(HORIZONTAL_ALIGNMENT_RIGHT) + _meter_cap.text = "PLUNGER" + _meter_cap.visible = false + + _sb_mb = _mk_pill_box(Color(1.0, 0.55, 0.15)) + _mb_pill = _mk_pill("MULTIBALL", _sb_mb) + _sb_save = _mk_pill_box(Color(0.35, 1.0, 0.55)) + _save_pill = _mk_pill("BALL SAVE", _sb_save) + + _tilt_lbl = _mk_label(HORIZONTAL_ALIGNMENT_CENTER) + _tilt_lbl.text = "TILT" + _tilt_lbl.visible = false + _warn_lbl = _mk_label(HORIZONTAL_ALIGNMENT_CENTER) + _warn_lbl.visible = false + + _sb_over = StyleBoxFlat.new() + _sb_over.bg_color = Color(0.02, 0.025, 0.05, 0.93) + _sb_over.border_color = _accent + _sb_over.set_border_width_all(2) + _sb_over.set_corner_radius_all(4) + _over_panel = Panel.new() + _over_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _over_panel.add_theme_stylebox_override("panel", _sb_over) + _over_panel.visible = false + _root.add_child(_over_panel) + _over_title = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _over_panel) + _over_title.text = "GAME OVER" + _over_score = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _over_panel) + _over_high = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _over_panel) + _over_hint = _mk_label(HORIZONTAL_ALIGNMENT_CENTER, _over_panel) + _over_hint.text = "PRESS R FOR A NEW GAME" + + _hint_lbl = _mk_label(HORIZONTAL_ALIGNMENT_LEFT) + _hint_lbl.text = HINT + + _sb_combo = StyleBoxFlat.new() + _sb_combo.set_corner_radius_all(5) + _sb_glow = StyleBoxFlat.new() + +func _mk_ctrl(parent: Node = null) -> Control: + var c := Control.new() + c.mouse_filter = Control.MOUSE_FILTER_IGNORE + c.clip_contents = false + (parent if parent != null else _root).add_child(c) + return c + +func _mk_label(align: int, parent: Node = null) -> Label: + var l := Label.new() + l.mouse_filter = Control.MOUSE_FILTER_IGNORE + l.horizontal_alignment = align + l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + l.clip_text = false + (parent if parent != null else _root).add_child(l) + return l + +func _mk_pill_box(tint: Color) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = Color(tint.r * 0.2, tint.g * 0.2, tint.b * 0.2, 0.8) + sb.border_color = tint + sb.set_border_width_all(2) + sb.set_corner_radius_all(14) + return sb + +func _mk_pill(txt: String, sb: StyleBoxFlat) -> Panel: + var p := Panel.new() + p.mouse_filter = Control.MOUSE_FILTER_IGNORE + p.add_theme_stylebox_override("panel", sb) + p.visible = false + _root.add_child(p) + var l := _mk_label(HORIZONTAL_ALIGNMENT_CENTER, p) + l.name = "L" + l.text = txt + return p + +# ---------------------------------------------------------------- fonts +## FontVariation.spacing_glyph is in whole pixels and does NOT scale with font size, so tracking +## has to be baked per (family, amount). Cheap to cache, ugly to forget. +func _font(mono: bool, track: int) -> FontVariation: + var key := "%s|%d" % ["m" if mono else "d", track] + if _font_cache.has(key): + return _font_cache[key] + var fv := FontVariation.new() + fv.base_font = _sf_mono if mono else _sf_display + fv.spacing_glyph = track + _font_cache[key] = fv + return fv + +func _type(l: Label, mono: bool, px: float, track: float, outline := 0.0) -> void: + l.add_theme_font_override("font", _font(mono, int(round(track * _ui)))) + l.add_theme_font_size_override("font_size", maxi(6, int(round(px * _ui)))) + l.add_theme_color_override("font_color", Color.WHITE) + l.add_theme_constant_override("outline_size", int(round(maxf(0.0, outline) * _ui))) + l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9)) + +func _place(c: Control, x: float, y: float, w: float, h: float) -> void: + c.position = Vector2(x, y) + c.size = Vector2(w, h) + c.pivot_offset = Vector2(w, h) * 0.5 + +# ---------------------------------------------------------------- layout +func _layout() -> void: + var v := get_viewport() + _vp = v.get_visible_rect().size if v != null else Vector2(1152, 648) + # headless has no real window; fall back so the geometry maths never divides by nothing + if _vp.x < 16.0 or _vp.y < 16.0: + _vp = Vector2(1152, 648) + _ui = clampf(_vp.y / 720.0, 0.62, 2.4) + var u := _ui + var W := _vp.x + var H := _vp.y + + # _root is anchored FULL_RECT — assigning its size here would fight the anchors and warn + _place(_vig, 0, 0, W, H) + + var bar_h := 96.0 * u + _place(_bar, 0, 0, W, bar_h) + _sb_bar.border_width_bottom = maxi(1, int(2.0 * u)) + + _place(_table_lbl, 24 * u, 12 * u, W * 0.45, 36 * u) + _type(_table_lbl, false, 27, 2, 4) + _place(_sub_lbl, 26 * u, 48 * u, W * 0.45, 20 * u) + _type(_sub_lbl, false, 13, 1, 3) + _place(_extra_lbl, 26 * u, 70 * u, W * 0.35, 20 * u) + _type(_extra_lbl, true, 13, 1, 3) + + var sw := minf(W * 0.52, 660.0 * u) + var sx := W - 24 * u - sw + for l: Label in [_score_ghost, _score_glow, _score_lbl]: + _place(l, sx, 6 * u, sw, 58 * u) + _type(l, true, 50, 1, 0) + # pivot on the RIGHT edge: the score pops when it rolls, and a centre pivot would + # shove right-aligned digits past the margin and out of step with the ghost behind + l.pivot_offset = Vector2(sw, 29 * u) + _score_glow.add_theme_constant_override("outline_size", int(round(9.0 * u))) + _score_lbl.add_theme_constant_override("outline_size", int(round(5.0 * u))) + _score_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.95)) + + _place(_ball_lbl, sx, 64 * u, sw, 24 * u) + _type(_ball_lbl, false, 15, 3, 3) + _place(_delta_lbl, sx, bar_h + 4 * u, sw, 26 * u) + _type(_delta_lbl, true, 20, 1, 4) + + _combo_home = Vector2(W * 0.5 - 110 * u, bar_h - 62 * u) + _place(_combo_box, _combo_home.x, _combo_home.y, 220 * u, 92 * u) + _place(_combo_cap, 0, 8 * u, 220 * u, 16 * u) + _type(_combo_cap, false, 11, 5, 3) + _place(_combo_lbl, 0, 22 * u, 220 * u, 58 * u) + _type(_combo_lbl, false, 44, 0, 5) + + _cal_home_y = H * 0.35 + for slot in _cal_slots: + _place(slot, 0, _cal_home_y, W, 86 * u) + var t := slot.get_node("T") as Label + var p := slot.get_node("P") as Label + _place(t, 0, 0, W, 56 * u) + _type(t, false, 46, 3, 7) + _place(p, 0, 54 * u, W, 30 * u) + _type(p, true, 24, 2, 5) + + var meter_h := H * 0.34 + _place(_meter, W - 40 * u, H * 0.40, 18 * u, meter_h) + _place(_meter_cap, W - 156 * u, H * 0.40 - 24 * u, 116 * u, 18 * u) + _type(_meter_cap, false, 11, 4, 3) + + _place(_mb_pill, W * 0.5 - 110 * u, H - 176 * u, 220 * u, 34 * u) + _place(_save_pill, W * 0.5 - 100 * u, H - 136 * u, 200 * u, 34 * u) + for p in [_mb_pill, _save_pill]: + var l := p.get_node("L") as Label + _place(l, 0, 0, p.size.x, p.size.y) + _type(l, false, 16, 4, 0) + + _place(_status_lbl, 0, H - 92 * u, W, 24 * u) + _type(_status_lbl, false, 15, 4, 4) + + _place(_tilt_lbl, 0, H * 0.44, W, 130 * u) + _type(_tilt_lbl, false, 118, 16, 12) + _place(_warn_lbl, 0, H * 0.56, W, 34 * u) + _type(_warn_lbl, false, 24, 6, 6) + + var ow := minf(W * 0.7, 640.0 * u) + var oh := 278.0 * u + _place(_over_panel, (W - ow) * 0.5, (H - oh) * 0.5, ow, oh) + _sb_over.set_border_width_all(maxi(1, int(2.0 * u))) + _place(_over_title, 0, 24 * u, ow, 44 * u) + _type(_over_title, false, 38, 10, 5) + _place(_over_score, 0, 80 * u, ow, 70 * u) + _type(_over_score, true, 56, 1, 5) + _place(_over_high, 0, 158 * u, ow, 30 * u) + _type(_over_high, false, 18, 4, 3) + _place(_over_hint, 0, 222 * u, ow, 26 * u) + _type(_over_hint, false, 14, 5, 3) + + _place(_hint_lbl, 20 * u, H - 34 * u, W * 0.8, 20 * u) + _type(_hint_lbl, false, 12, 2, 3) + + _restyle_accent() + +func _restyle_accent() -> void: + if _sb_bar == null: + return + _sb_bar.border_color = Color(_accent.r, _accent.g, _accent.b, 0.65) + _sb_over.border_color = Color(_accent.r, _accent.g, _accent.b, 0.8) + if _table_lbl != null: + _table_lbl.modulate = _accent + _sub_lbl.modulate = Color(0.72, 0.76, 0.86, 0.75) + _hint_lbl.modulate = Color(0.80, 0.84, 0.92, 0.55) + _meter_cap.modulate = Color(0.75, 0.80, 0.90, 0.6) + _status_lbl.modulate = Color(_accent2.r, _accent2.g, _accent2.b, 0.85) + _over_title.modulate = _accent + _over_hint.modulate = Color(0.7, 0.74, 0.84, 0.6) + +# ---------------------------------------------------------------- per-frame +func _process(delta: float) -> void: + _t += delta + if not is_instance_valid(_rules): + _find_rules() # Rules may be a slower lane; keep looking, it's one node lookup + if not _table_wired: + _sync_table() + + _tick_score(delta) + _tick_combo(delta) + _tick_callouts(delta) + _tick_plunger(delta) + _tick_tilt(delta) + _tick_flags() + _tick_over(delta) + +func _tick_score(delta: float) -> void: + var target := float(_ask_i(PackedStringArray(["score"]), _fallback_score)) + if target < _score_target - 0.5: + # score went backwards: a new game. Drop the reel width back to its resting size. + _score_shown = target + _reel_digits = REEL_MIN_DIGITS + _delta_t = 99.0 + _callouts.clear() + _new_high = false + elif target > _score_target + 0.5: + _delta_amount = int(round(target - _score_target)) + _delta_t = 0.0 + _score_target = target + + var gap := _score_target - _score_shown + if absf(gap) < 0.5: + _score_shown = _score_target + else: + # Clamp both ends: any gap closes inside REEL_SPAN, but a 250-point tickle still gets a + # visible roll instead of resolving in one frame. + var rate := maxf(absf(gap) / REEL_SPAN, REEL_MIN_RATE) + _score_shown = move_toward(_score_shown, _score_target, rate * delta) + + var txt := _commas(int(_score_shown)) + _reel_digits = maxi(_reel_digits, _digits_of(int(maxf(_score_target, 0.0)))) + _score_lbl.text = txt + _score_glow.text = txt + _score_ghost.text = _ghost(maxi(_reel_digits, REEL_MIN_DIGITS)) + + var rolling: float = clampf(absf(gap) / 4000.0, 0.0, 1.0) + _score_ghost.modulate = Color(_accent2.r, _accent2.g, _accent2.b, 0.07) + _score_glow.modulate = Color(_accent.r, _accent.g, _accent.b, 0.10 + 0.35 * rolling) + _score_lbl.modulate = Color(1, 1, 1).lerp(Color(1.0, 0.98, 0.86), rolling) + var pop := 1.0 + 0.035 * rolling + _score_lbl.scale = Vector2(pop, pop) + _score_glow.scale = _score_lbl.scale + _score_ghost.scale = _score_lbl.scale # the unlit segments swell with the lit ones + + var ball := _ask_i(PackedStringArray(["ball", "ball_number", "ball_num"]), 1) + var total := _ask_i(PackedStringArray(["balls_total", "balls_per_game", "max_balls", + "ball_count"]), 3) + var hi := _ask_i(PackedStringArray(["high_score", "hiscore", "best_score", "best"]), 0) + var line := "BALL %d OF %d" % [maxi(ball, 1), maxi(total, 1)] if total > 0 else "BALL %d" % maxi(ball, 1) + if hi > 0: + line += " HI %s" % _commas(hi) + _ball_lbl.text = line + _ball_lbl.modulate = Color(0.78, 0.83, 0.94, 0.8) + + _delta_t += delta + var showing := _delta_t < 0.9 and _delta_amount > 0 + _delta_lbl.visible = showing + if showing: + var k := _delta_t / 0.9 + _delta_lbl.text = "+%s" % _commas(_delta_amount) + _delta_lbl.position.y = 96.0 * _ui + 4.0 * _ui - 22.0 * _ui * k + _delta_lbl.modulate = Color(_accent2.r, _accent2.g, _accent2.b, 1.0 - k * k) + +func _tick_combo(delta: float) -> void: + # multiplier() is the number that actually multiplies a shot (combo × any multiball bonus), + # so it is the honest thing to put on the glass; combo() is the fallback if that's all there is. + var c := _ask_f(PackedStringArray(["multiplier", "combo", "combo_multiplier"]), 1.0) + if c > _combo + 0.01: + _combo_pulse = 1.0 + _combo = c + _combo_pulse = maxf(0.0, _combo_pulse - delta * 3.2) + var loud := clampf((c - 1.0) / (COMBO_CEIL - 1.0), 0.0, 1.0) + _combo_loud = move_toward(_combo_loud, loud, delta * 4.0) + _combo_col = _combo_ramp(c) + + # combo_left may be seconds or an already-normalised 0..1. Divide by the declared window if + # Rules has one, else by the largest value we've ever seen it hold — both land on 0..1. + var lv := _ask_f(PackedStringArray(["combo_left", "combo_time_left", "combo_frac"]), -1.0) + if lv > 0.0: + _combo_span = maxf(_combo_span, lv) + var span := _rule_const(PackedStringArray(["COMBO_WINDOW", "COMBO_TIME"]), _combo_span) + _combo_left = clampf(lv / maxf(span, 0.001), 0.0, 1.0) + else: + _combo_left = -1.0 + + var chain := _ask_i(PackedStringArray(["combo_count", "chain"]), 0) + _combo_cap.text = "CHAIN %d" % chain if chain > 0 else "COMBO" + # integer combos read as "3x", a fractional multiplier as "1.5x" — Rules may use either + _combo_lbl.text = ("%dx" % int(round(c))) if absf(c - round(c)) < 0.01 else ("%.1fx" % c) + _combo_lbl.add_theme_font_size_override("font_size", + maxi(6, int(round(lerpf(16.0, 46.0, _combo_loud) * _ui)))) + _combo_lbl.modulate = Color(0.55, 0.60, 0.72, 0.75).lerp(_combo_col, _combo_loud) + _combo_cap.modulate = Color(_combo_col.r, _combo_col.g, _combo_col.b, _combo_loud * 0.8) + + var sc := 1.0 + 0.22 * _combo_pulse + _combo_box.scale = Vector2(sc, sc) + # a loud combo physically vibrates; at 1x it sits perfectly still + var shake := _combo_loud * 2.6 * _ui + _combo_box.position = _combo_home + Vector2(randf_range(-shake, shake), randf_range(-shake, shake)) + _combo_box.queue_redraw() + +func _combo_ramp(c: float) -> Color: + const STOPS := [ + Color(0.58, 0.63, 0.75), Color(0.55, 0.88, 1.00), Color(0.45, 1.00, 0.72), + Color(1.00, 0.92, 0.35), Color(1.00, 0.62, 0.25), Color(1.00, 0.30, 0.58), + ] + var f := clampf(c - 1.0, 0.0, float(STOPS.size() - 1) - 0.001) + var i := int(f) + return (STOPS[i] as Color).lerp(STOPS[mini(i + 1, STOPS.size() - 1)], f - float(i)) + +func _tick_callouts(delta: float) -> void: + for i in range(_callouts.size() - 1, -1, -1): + _callouts[i]["t"] = float(_callouts[i]["t"]) + delta + if float(_callouts[i]["t"]) > CALLOUT_LIFE: + _callouts.remove_at(i) + # A tilt or a game-over panel owns the centre of the screen; awards get out of the way. + var mask: float = (1.0 - _over_t) * (0.0 if _tilt_t > 0.0 else 1.0) + for i in _cal_slots.size(): + # newest award takes the LAST slot: children draw in tree order, and a fresh callout + # punching in behind a stale one is exactly backwards + var slot := _cal_slots[_cal_slots.size() - 1 - i] + if i >= _callouts.size() or mask <= 0.001: + slot.visible = false + continue + var e: Dictionary = _callouts[i] + var age := float(e["t"]) + slot.visible = true + var title := slot.get_node("T") as Label + var pts := slot.get_node("P") as Label + title.text = String(e["text"]) + var p := int(e["points"]) + # only the live award shows its value — older ones would collide with the title below + pts.visible = i == 0 and p > 0 + pts.text = "+%s" % _commas(p) + # punch in: overshoot then settle. The first 0.14 s is the whole feel of an award. + var k: float = clampf(age / 0.14, 0.0, 1.0) + var sc: float = lerpf(1.65, 1.0, ease(k, 0.30)) * (1.0 + 0.07 * sin(k * PI)) + sc *= 1.0 - 0.26 * float(i) + var fade: float = 1.0 - clampf((age - (CALLOUT_LIFE - CALLOUT_FADE)) / CALLOUT_FADE, 0.0, 1.0) + slot.scale = Vector2(sc, sc) + slot.position.y = _cal_home_y - float(i) * CALLOUT_STEP * _ui - (1.0 - fade) * 18.0 * _ui + slot.modulate = Color(1, 1, 1, fade * pow(0.5, float(i)) * mask) + title.modulate = Color.WHITE.lerp(_accent, 0.14) + pts.modulate = _accent2 + +func _tick_plunger(delta: float) -> void: + var charge := 0.0 + if main != null and main.has_method("plunge_charge"): + charge = clampf(float(main.call("plunge_charge")), 0.0, 1.0) + # Main zeroes its charge the instant it fires, so hold a flash for a beat — otherwise the + # meter vanishes at exactly the moment you want to see how hard you hit it. + if charge <= 0.001 and _plunge_view > 0.06: + _plunge_flash = 1.0 + _plunge_flash = maxf(0.0, _plunge_flash - delta * 3.0) + _plunge_view = charge if charge > _plunge_view else move_toward(_plunge_view, charge, delta * 3.0) + + var show := _plunge_view > 0.005 or _plunge_flash > 0.0 + _meter.visible = show + if show: + _meter.queue_redraw() + + # "HOLD SPACE" only while a ball is actually sitting in the lane, else it is noise + var ready := false + var t = main.get("table") if main != null else null + if t != null and is_instance_valid(t) and t.has_method("ball_in_lane"): + for b in t.get("balls"): + if t.call("ball_in_lane", b): + ready = true + break + _meter_cap.visible = show or ready + _meter_cap.text = "PLUNGER" if show else "HOLD SPACE" + +func _tick_tilt(delta: float) -> void: + # the game-over panel owns the screen; a tilt banner or warning glow under a 93%-opaque + # panel just reads as a smudge + var tilted := _ask_b(PackedStringArray(["tilted", "is_tilted"]), false) and _over_t < 0.02 + # "_warns_given" is last on purpose: it is Rules' private counter and only read because no + # public getter exists yet. Add a warnings() to Rules and this line stops mattering. + _warnings = _ask_i(PackedStringArray(["warnings", "tilt_warnings", "warning_count", + "_warns_given"]), 0) + _tilt_t = (_tilt_t + delta) if tilted else 0.0 + + _tilt_lbl.visible = tilted + if tilted: + # Strobe the FILL, not the alpha: modulate would fade the black outline with it and the + # banner would go translucent and muddy over a lit playfield instead of flashing. + var flash := 0.55 + 0.45 * sin(_tilt_t * 11.0) + _tilt_lbl.add_theme_color_override("font_color", + Color(flash, 0.16 * flash, 0.20 * flash)) + var sc := 1.0 + 0.06 * sin(_tilt_t * 7.0) + _tilt_lbl.scale = Vector2(sc, sc) + + _warn_lbl.visible = _warnings > 0 and not tilted and _over_t < 0.02 + if _warn_lbl.visible: + var wmax := 0 + var wc: Variant = _rules_consts.get("TILT_WARN_AT") + if wc is Array: + wmax = (wc as Array).size() + _warn_lbl.text = ("TILT WARNING %d/%d" % [_warnings, wmax]) if wmax > 0 \ + else ("TILT WARNING %d" % _warnings) + var wf := 0.55 + 0.45 * sin(_t * 8.0) + _warn_lbl.add_theme_color_override("font_color", Color(wf, 0.72 * wf, 0.18 * wf)) + + _vig.visible = (tilted or _warnings > 0) and _over_t < 0.02 + if _vig.visible: + _vig.queue_redraw() + +func _tick_flags() -> void: + # ball save may be a bool or a countdown in seconds; show the seconds when we get them, + # because "how long have I got" is the only question that indicator is answering + var sv: Variant = _ask(PackedStringArray(["ball_save_left", "ball_save_time", + "ball_save_active", "ball_save", "ball_saved", "saving"]), false) + var save := false + var save_txt := "BALL SAVE" + if sv is bool: + save = sv + elif sv is int or sv is float: + save = float(sv) > 0.0 + if save and float(sv) > 1.0: + save_txt = "BALL SAVE %d" % int(ceil(float(sv))) + _save_pill.visible = save + if save: + var a := 0.6 + 0.4 * sin(_t * 9.0) + _sb_save.border_color = Color(0.35, 1.0, 0.55, a) + var sl := _save_pill.get_node("L") as Label + sl.text = save_txt + sl.modulate = Color(0.75, 1.0, 0.85, 0.7 + 0.3 * a) + + # balls in play is readable straight off the table, so multiball shows even with no Rules + var n := 0 + var t = main.get("table") if main != null else null + if t != null and is_instance_valid(t): + var arr = t.get("balls") + if arr is Array: + n = (arr as Array).size() + var mb := _ask_b(PackedStringArray(["multiball", "multiball_active", "mb_active"]), n > 1) + _mb_pill.visible = mb or n > 1 + if _mb_pill.visible: + (_mb_pill.get_node("L") as Label).text = "MULTIBALL x%d" % maxi(n, 2) + _sb_mb.border_color = Color(1.0, 0.55, 0.15, 0.6 + 0.4 * sin(_t * 7.0)) + + # end-of-ball bonus is the one number Rules' status line doesn't carry + var bonus := _ask_i(PackedStringArray(["bonus", "end_bonus"]), 0) + _extra_lbl.visible = bonus > 0 + if _extra_lbl.visible: + _extra_lbl.text = "BONUS %s" % _commas(bonus) + _extra_lbl.modulate = Color(_accent2.r, _accent2.g, _accent2.b, 0.7) + + var status := _ask_s(PackedStringArray(["status_line", "status", "hint_line"]), "") + _status_lbl.text = status + _status_lbl.visible = status != "" + +func _tick_over(delta: float) -> void: + var over := _ask_b(PackedStringArray(["game_over", "is_game_over"]), false) + if over and not _was_over: + var hi := _ask_i(PackedStringArray(["high_score", "hiscore", "best_score", "best"]), 0) + _new_high = _score_target > 0.0 and int(_score_target) >= hi + _was_over = over + _over_t = clampf(_over_t + (delta if over else -delta * 3.0), 0.0, 1.0) + _over_panel.visible = _over_t > 0.001 + if not _over_panel.visible: + return + var k: float = ease(clampf(_over_t / 0.35, 0.0, 1.0), 0.4) + _over_panel.modulate = Color(1, 1, 1, k) + var sc: float = lerpf(0.92, 1.0, k) + _over_panel.scale = Vector2(sc, sc) + _over_title.text = "NEW HIGH SCORE" if _new_high else "GAME OVER" + _over_title.modulate = _accent2 if _new_high else _accent + _over_score.text = _commas(int(_score_shown)) + var hi2 := _ask_i(PackedStringArray(["high_score", "hiscore", "best_score", "best"]), 0) + _over_high.text = ("HIGH SCORE %s" % _commas(hi2)) if hi2 > 0 else "" + _over_high.modulate = Color(0.8, 0.84, 0.94, 0.7) + +# ---------------------------------------------------------------- award intake +func _on_awarded(text: String, points: int) -> void: + _push_callout(text, points) + +func _push_callout(text: String, points: int) -> void: + if text.strip_edges() == "": + return + _callouts.push_front({"text": text.to_upper(), "points": maxi(points, 0), "t": 0.0}) + while _callouts.size() > CALLOUT_SLOTS: + _callouts.pop_back() + +## Standalone mode only. With a Rules lane scoring, this is inert — Rules owns points and the +## `awarded` signal owns callouts, and double-counting either would be a lie on screen. +func _on_hit(kind: String, id: String, _at: Vector3, data: Dictionary) -> void: + if _rules_scores(): + return + var pts := int(data.get("points", 0)) + if kind == "spinner": + pts *= maxi(1, int(data.get("spins", 1))) + _fallback_score += pts + # only the shots worth shouting about; a pop bumper firing six times a second is not one + match kind: + "bank_cleared": _push_callout("BANK CLEARED", 0) + "ramp": _push_callout("RAMP!", pts) + "saucer": _push_callout("CAPTURED!", pts) + "spinner": _push_callout("SPINNER x%d" % int(data.get("spins", 1)), pts) + "drop": + if not bool(data.get("cleared", false)): + _push_callout("DROP TARGET", pts) + "rollover": + if not bool(data.get("was_lit", false)): + _push_callout("LANE %s" % String(data.get("glyph", id)), pts) + +# ---------------------------------------------------------------- custom draws +func _draw_meter() -> void: + var w := _meter.size.x + var h := _meter.size.y + _meter.draw_style_box(_sb_meter, Rect2(Vector2.ZERO, _meter.size)) + var v := clampf(_plunge_view, 0.0, 1.0) + var pad := 2.0 * _ui + var fh := (h - pad * 2.0) * v + if fh > 0.5: + var col := Color(0.35, 1.0, 0.50).lerp(Color(1.0, 0.85, 0.20), clampf(v * 1.6, 0.0, 1.0)) + col = col.lerp(Color(1.0, 0.25, 0.25), clampf((v - 0.72) / 0.28, 0.0, 1.0)) + _meter.draw_rect(Rect2(pad, h - pad - fh, w - pad * 2.0, fh), col) + # a brighter cap on the column so the top edge reads at a glance + _meter.draw_rect(Rect2(pad, h - pad - fh, w - pad * 2.0, maxf(2.0 * _ui, fh * 0.06)), + Color(1, 1, 1, 0.75)) + for i in range(1, 5): + var y := h - h * (float(i) / 5.0) + _meter.draw_line(Vector2(0, y), Vector2(w * 0.38, y), Color(1, 1, 1, 0.22), maxf(1.0, _ui)) + if _plunge_flash > 0.0: + _meter.draw_rect(Rect2(Vector2.ZERO, _meter.size), Color(1, 1, 1, 0.55 * _plunge_flash)) + +func _draw_combo() -> void: + if _combo_loud <= 0.01: + return + var r := Rect2(Vector2.ZERO, _combo_box.size) + var pulse := 0.72 + 0.28 * sin(_t * 6.5) + # A CanvasLayer sits outside the 3D glow pass, so bloom has to be faked: concentric plates + # of falling alpha behind the badge. + for i in range(3, -1, -1): + var g := float(i + 1) * 6.0 * _ui + var a := _combo_loud * (0.15 - float(i) * 0.032) * pulse + if a <= 0.001: + continue + _sb_glow.bg_color = Color(_combo_col.r, _combo_col.g, _combo_col.b, a) + _sb_glow.set_corner_radius_all(int(round(5.0 * _ui + g))) + _combo_box.draw_style_box(_sb_glow, r.grow(g)) + _sb_combo.bg_color = Color(0.03, 0.035, 0.06, 0.62 * _combo_loud) + _sb_combo.border_color = Color(_combo_col.r, _combo_col.g, _combo_col.b, 0.9 * _combo_loud) + _sb_combo.set_border_width_all(maxi(1, int(round(2.0 * _ui)))) + _sb_combo.set_corner_radius_all(int(round(5.0 * _ui))) + _combo_box.draw_style_box(_sb_combo, r) + if _combo_left >= 0.0: + var bw := r.size.x * 0.66 + var bx := (r.size.x - bw) * 0.5 + var by := r.size.y - 9.0 * _ui + var bh := 3.0 * _ui + _combo_box.draw_rect(Rect2(bx, by, bw, bh), Color(1, 1, 1, 0.12 * _combo_loud)) + _combo_box.draw_rect(Rect2(bx, by, bw * _combo_left, bh), + Color(_combo_col, 0.9 * _combo_loud)) + +func _draw_vignette() -> void: + var heat: float = 1.0 if _tilt_t > 0.0 else clampf(float(_warnings) / 3.0, 0.0, 0.7) + if heat <= 0.0: + return + var pulse := 0.72 + 0.28 * sin(_t * (11.0 if _tilt_t > 0.0 else 7.0)) + var col := Color(1.0, 0.10, 0.14) + var bands := 32 + var vy := _vp.y * 0.22 / float(bands) + var vx := _vp.x * 0.15 / float(bands) + for i in bands: + var k := float(i) / float(bands) + var a := (1.0 - k) * (1.0 - k) * 0.46 * heat * pulse + var c := Color(col.r, col.g, col.b, a) + # Snap each band to whole pixels and butt them edge to edge. Overlapping by a pixel + # doubles the alpha on the seam and the gradient turns into venetian blinds. + var y0 := floorf(float(i) * vy) + var y1 := floorf(float(i + 1) * vy) + var x0 := floorf(float(i) * vx) + var x1 := floorf(float(i + 1) * vx) + _vig.draw_rect(Rect2(0, y0, _vp.x, y1 - y0), c) + _vig.draw_rect(Rect2(0, _vp.y - y1, _vp.x, y1 - y0), c) + _vig.draw_rect(Rect2(x0, 0, x1 - x0, _vp.y), c) + _vig.draw_rect(Rect2(_vp.x - x1, 0, x1 - x0, _vp.y), c) + +# ---------------------------------------------------------------- text helpers +func _commas(n: int) -> String: + var s := str(absi(n)) + var out := "" + var c := 0 + for i in range(s.length() - 1, -1, -1): + out = s[i] + out + c += 1 + if c % 3 == 0 and i > 0: + out = "," + out + return ("-" + out) if n < 0 else out + +func _digits_of(n: int) -> int: + return str(absi(n)).length() + +## The unlit half of the display: same grouping as a live score of `digits` digits, all eights. +func _ghost(digits: int) -> String: + var out := "" + for i in digits: + if i > 0 and (digits - i) % 3 == 0: + out += "," + out += "8" + return out diff --git a/godot/scripts/Hud.gd.uid b/godot/scripts/Hud.gd.uid new file mode 100644 index 0000000..6d3a173 --- /dev/null +++ b/godot/scripts/Hud.gd.uid @@ -0,0 +1 @@ +uid://bmjgd4w0qysht diff --git a/godot/scripts/Juice.gd b/godot/scripts/Juice.gd new file mode 100644 index 0000000..403a3d1 --- /dev/null +++ b/godot/scripts/Juice.gd @@ -0,0 +1,784 @@ +extends Node +class_name Juice + +## The feel layer: every sound, flash, shake and spark this table makes. +## +## ZERO ASSETS. Each voice is synthesized into an AudioStreamWAV at startup; every light and +## spark is a node built in code. Nothing on disk, nothing to license, nothing to load. +## +## Strictly one-directional and additive: Main forwards Table's signals in here, and nothing +## in here scores, moves a ball, or touches state another lane owns. Delete this file and the +## game still plays — it just plays silent and flat. +## +## Godot 4.7 GDScript 2.0. + +const RATE := 22050 ## every voice; an 11 kHz Nyquist is plenty for a cabinet +const AUDIO_VOICES := 20 ## a bumper chain plus a spinner ratcheting overlaps hard +const LIGHT_POOL := 14 +const SPARK_POOL := 6 +const SPARK_AMOUNT := 28 ## fixed; per-burst count rides amount_ratio (see _sparks) + +## The playfield is ~0.5 m wide and the camera sits 0.9 m off it, so shake is measured in +## CENTIMETRES. Copying a human-scale shake constant from another game reads as an earthquake. +const SHAKE_OFFSET := 0.020 +const SHAKE_ROLL := 0.022 +const TRAUMA_DECAY := 2.1 +const SHOVE_K := 900.0 ## cabinet-shove spring: ~0.2 s period... +const SHOVE_D := 32.0 ## ...at damping ratio ~0.53, so it overshoots once and settles + +## Which palette entry lights each event. Reading the table's own palette is what makes the +## juice change personality between NEON ARCADE and THE FOUNDRY without a second config. +const PAL_KEY := { + "bumper": "bumper", "sling": "sling", "target": "target", "drop": "drop", + "bank_cleared": "drop", "spinner": "spinner", "saucer": "saucer", + "rollover": "lane", "ramp": "ramp", +} +## Mirrors Table.gd's own _pal() fallbacks so an incomplete palette still looks deliberate. +const PAL_FALLBACK := { + "bumper": Color(0.95, 0.75, 0.2), "sling": Color(0.35, 0.85, 0.45), + "target": Color(0.95, 0.35, 0.75), "drop": Color(0.98, 0.85, 0.30), + "spinner": Color(0.75, 0.85, 0.95), "saucer": Color(0.15, 0.18, 0.25), + "lane": Color(0.35, 0.55, 0.95), "ramp": Color(0.25, 0.65, 0.85), +} +const DRAIN_COL := Color(0.95, 0.18, 0.12) ## not a palette entry — a drain is not scenery + +var _main: Main +var _cam: Camera3D +var _cam_home := Vector3.ZERO +var _cam_home_roll := 0.0 +var _cam_moved := false + +var _trauma := 0.0 +var _shove := Vector3.ZERO +var _shove_v := Vector3.ZERO +var _clock := 0.0 +var _balls_seen := 0 + +var _wav: Dictionary = {} # name -> AudioStreamWAV +var _voices: Array[AudioStreamPlayer3D] = [] +var _voice_rr := 0 +var _lights: Array[OmniLight3D] = [] +var _light_t := PackedFloat32Array() # seconds of flash left, 0 = free +var _light_span := PackedFloat32Array() +var _light_e := PackedFloat32Array() +var _sparks_pool: Array[GPUParticles3D] = [] +var _spark_rr := 0 +var _sched: Array[Dictionary] = [] # deferred ticks and flashes, see _run_sched +var _pops: Dictionary = {} # mesh instance id -> squash-and-stretch state + +func _ready() -> void: + for v in ["bumper", "sling", "drop", "fanfare", "tick", "saucer", + "rollover", "ramp", "target", "drain", "plunge", "nudge"]: + _wav[v] = _make_wav(_synth(v)) + _build_audio_pool() + _build_light_pool() + _build_spark_pool() + +func setup(main: Main) -> void: + _main = main + +## Hang the voices up on the way out. A playback still live when the tree tears down is held +## by the AudioServer, and the headless autotest hard-quits mid drain-buzz every run. +## +## This REDUCES but does not eliminate the "ObjectDB instances were leaked" warning that +## autotest prints (measured over 6 runs: 18 instances without this, 14 with). The remainder +## is a --headless artifact, not a real leak: the Dummy audio driver never runs a mix step, so +## stopped AudioStreamPlaybackWAVs are never reaped. Stub out _voice() and the warning goes to +## zero. Don't go hunting for it in here. +func _exit_tree() -> void: + for a in _voices: + a.stop() + a.stream = null + +# ---------------------------------------------------------------- per-frame +func _process(delta: float) -> void: + # Clamp the step: a hitch must not let the shove spring integrate itself into orbit. + var dt := minf(delta, 0.033) + _clock += dt + _watch_balls() + _run_sched() + _fade_lights(dt) + _run_pops(dt) + _shake(dt) + +# ---------------------------------------------------------------- events from Main +func on_hit(kind: String, id: String, at: Vector3, data: Dictionary) -> void: + var col := _tint(kind) + match kind: + "bumper": + _voice("bumper", at, 1.0, randf_range(0.92, 1.10)) + _flash(at, col, 3.2, 0.17, 0.17) + _sparks(at, col, 9, 0.60) + _pop(id, 0.26, 0.13) + _hurt(0.17) + "sling": + _voice("sling", at, 0.9, randf_range(0.94, 1.14)) + _flash(at, col, 2.4, 0.14, 0.13) + _sparks(at, col, 6, 0.50) + _pop(id, 0.20, 0.10) + _hurt(0.13) + "target": + _voice("target", at, 0.85, randf_range(0.95, 1.09)) + _flash(at, col, 2.2, 0.13, 0.12) + _pop(id, 0.22, 0.12) + _hurt(0.10) + "drop": + # Pitch the clack DOWN as the bank empties, so a five-bank audibly counts itself + # out and the last target lands lowest — no HUD needed to hear the progress. + var cleared: bool = data.get("cleared", false) + _voice("drop", at, 1.0, randf_range(0.88, 1.12) * (0.86 if cleared else 1.0)) + _flash(at, col, 2.8, 0.15, 0.16) + _sparks(at, col, 7, 0.55) + _hurt(0.14) + "bank_cleared": + # `id` is the BANK name here, not a part id — no _pop(), part() would miss. + _voice("fanfare", at, 1.0, randf_range(0.99, 1.01)) + _wash(col.lerp(Color(1, 1, 1), 0.35), 1.0) + _sparks(at, col, 26, 1.0) + _hurt(0.52) + "spinner": + _ratchet(at, col, int(data.get("spins", 1))) + _hurt(0.03 + 0.007 * float(int(data.get("spins", 1)))) + "saucer": + _voice("saucer", at, 0.95, randf_range(0.96, 1.05)) + _flash(at, col, 3.0, 0.20, 0.42) + _hurt(0.11) + "rollover": + # A lane you already lit answers a fifth higher: the "again" note, not the "got it". + var lit: bool = data.get("was_lit", false) + _voice("rollover", at, 0.7, (1.5 if lit else 1.0) * randf_range(0.98, 1.03)) + _flash(at, col, 1.6, 0.11, 0.11) + _pop(id, 0.30, 0.11) + _hurt(0.04) + "ramp": + _voice("ramp", at, 1.0, randf_range(0.97, 1.06)) + _flash(at, col, 3.4, 0.24, 0.34) + _sparks(at, col, 12, 0.70) + _hurt(0.15) + _: + # An unknown kind still gets a voice — a silent scoring part reads as a bug. + _voice("rollover", at, 0.6, randf_range(0.95, 1.05)) + _flash(at, col, 1.4, 0.10, 0.10) + _hurt(0.04) + +func on_drained(ball: RigidBody3D) -> void: + var at := _lane_pos() + if is_instance_valid(ball): + at = ball.global_position + # Losing one ball of several is an inconvenience, not a funeral: shorter, higher, quieter. + # Main runs Rules before Juice, so by now the array already reflects what's still alive. + var t := _table() + var multi: bool = t != null and t.balls.size() > 1 + _voice("drain", at, 0.55 if multi else 1.0, + randf_range(1.30, 1.40) if multi else randf_range(0.97, 1.03)) + _flash(at, DRAIN_COL, 2.6, 0.22, 0.22 if multi else 0.55) + _hurt(0.10 if multi else 0.26) + +func on_plunge(power01: float) -> void: + var p := clampf(power01, 0.0, 1.0) + var at := _lane_pos() + # A harder pull rings higher and louder. It is the only feedback the plunger has, since + # the ball is still sitting still at the moment the sound fires. + _voice("plunge", at, 0.55 + 0.5 * p, 0.72 + 0.62 * p) + _flash(at, Color(1.0, 0.86, 0.55), 1.0 + 2.6 * p, 0.11, 0.16) + _hurt(0.04 + 0.15 * p) + if p > 0.72: + _sparks(at, Color(1.0, 0.82, 0.5), 7, 0.55) + +func on_nudge(dir: Vector3) -> void: + var d := dir + d.y = 0.0 + if d.length_squared() < 1e-8: + d = Vector3(0, 0, -1) + d = d.normalized() + # The view kicks WITH the shove and springs back, rather than jittering like an impact: + # a nudge is one deliberate shove of the whole cabinet, and it should move as one piece. + # Kicking the spring's VELOCITY (not its position) is what gives it the recoil. + _shove_v += d * 0.55 + _hurt(0.20) + _voice("nudge", _lane_pos(), 0.9, randf_range(0.88, 1.14)) + +# ---------------------------------------------------------------- multiball +## Main forwards hit/drained/plunge/nudge but not ball_launched, and multiball is the one +## moment that earns a whole-table wash. Subscribe to the table's public signal directly +## instead of asking the spine to grow another forwarder. Table.build() frees its children +## but not the Table node itself, so this connection survives a table switch. +func _watch_balls() -> void: + var t := _table() + if t == null: + return + if not t.ball_launched.is_connected(_on_ball_launched): + t.ball_launched.connect(_on_ball_launched) + # Drop the high-water mark as balls leave, so the NEXT multiball washes again. + _balls_seen = mini(_balls_seen, t.balls.size()) + +func _on_ball_launched(_ball: RigidBody3D) -> void: + var t := _table() + if t == null: + return + var n := t.balls.size() + # ball_launched also fires for every ordinary plunge, so wash only on a new high-water + # mark of balls in play — that is multiball and nothing else. + if n > _balls_seen and n > 1: + _wash(Color(0.65, 0.85, 1.0), 1.25) + # Two layers, no third voice to write: the fanfare an octave-ish up over a slowed + # ramp swoop reads as "the table just opened up". + _voice("fanfare", _lane_pos(), 1.0, 1.26) + _voice("ramp", _lane_pos(), 0.8, 0.62) + _hurt(0.55) + _balls_seen = maxi(_balls_seen, n) + +# ---------------------------------------------------------------- screenshake +func _hurt(amount: float) -> void: + _trauma = clampf(_trauma + amount, 0.0, 1.0) + +func _shake(delta: float) -> void: + _grab_cam() + _trauma = maxf(0.0, _trauma - TRAUMA_DECAY * delta) + _shove_v += (-_shove * SHOVE_K - _shove_v * SHOVE_D) * delta + _shove += _shove_v * delta + _shove = _shove.limit_length(0.035) + if not is_instance_valid(_cam): + return + # Quadratic falloff: a bumper tap stays a tap while a cleared bank still jolts, which a + # linear curve cannot do — it makes every small hit feel like a big one. + var amt := _trauma * _trauma + if amt < 0.0005 and _shove.length_squared() < 1e-8: + if _cam_moved: + _cam.position = _cam_home + _cam.rotation.z = _cam_home_roll + _cam_moved = false + return + var jitter := Vector3(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0) * 0.55, + randf_range(-1.0, 1.0) * 0.4) * amt * SHAKE_OFFSET + _cam.position = _cam_home + jitter + _shove + _cam.rotation.z = _cam_home_roll + randf_range(-1.0, 1.0) * amt * SHAKE_ROLL + _cam_moved = true + +## Grab the camera lazily and remember where it rests. Captured only while the camera is +## undisplaced, since every later frame writes home + offset rather than reading it back. +func _grab_cam() -> void: + if is_instance_valid(_cam): + return + var vp := get_viewport() + if vp == null: + return + var c := vp.get_camera_3d() + if c == null: + return + _cam = c + _cam_home = c.position + _cam_home_roll = c.rotation.z + _cam_moved = false + +# ---------------------------------------------------------------- lights +func _build_light_pool() -> void: + _light_t.resize(LIGHT_POOL) + _light_span.resize(LIGHT_POOL) + _light_e.resize(LIGHT_POOL) + for i in LIGHT_POOL: + var l := OmniLight3D.new() + # No shadows: fourteen shadow-casting omnis on a table this small costs real frames + # and buys nothing — every flash lasts under half a second. + l.shadow_enabled = false + l.omni_range = 0.2 + l.light_energy = 0.0 + l.visible = false + add_child(l) + _lights.append(l) + _light_t[i] = 0.0 + +func _flash(at: Vector3, col: Color, energy: float, radius: float, life: float) -> void: + var idx := -1 + var least := INF + for i in _lights.size(): + if _light_t[i] <= 0.0: + idx = i + break + if _light_t[i] < least: + least = _light_t[i] + idx = i # all busy: steal the one closest to done + if idx < 0: + return + var l := _lights[idx] + l.light_color = col + l.light_energy = energy + l.omni_range = radius + l.global_position = at + l.visible = true + _light_e[idx] = energy + _light_span[idx] = maxf(life, 0.01) + _light_t[idx] = _light_span[idx] + +func _fade_lights(delta: float) -> void: + for i in _lights.size(): + if _light_t[i] <= 0.0: + continue + _light_t[i] = maxf(0.0, _light_t[i] - delta) + var f := _light_t[i] / _light_span[i] + _lights[i].light_energy = _light_e[i] * f * f # snappy tail, not a dimmer fade + if _light_t[i] <= 0.0: + _lights[i].visible = false + +## A whole-table flood: one big soft light overhead plus a ripple of four down the playfield, +## staggered so the wash travels rather than switching on flat. +func _wash(col: Color, strength: float) -> void: + var t := _table() + var w := 0.52 + var l := 1.10 + var origin := Vector3.ZERO + if t != null: + w = float(t.spec.get("width", w)) + l = float(t.spec.get("length", l)) + origin = t.global_position + _flash(origin + Vector3(0, 0.34, 0), col, 7.0 * strength, maxf(w, l) * 1.1, 0.60) + for i in 4: + var z := -l * 0.36 + l * 0.24 * float(i) + _sched.append({"do": "flash", "t": _clock + 0.05 * float(i), + "at": origin + Vector3(0, 0.09, z), "col": col, + "energy": 4.2 * strength, "range": w * 0.55, "life": 0.34}) + +# ---------------------------------------------------------------- sparks +func _build_spark_pool() -> void: + # Sparks fall along the PROJECT's gravity, which is tilted toward +Z — the whole table is + # built flat and the gravity vector is what leans it (see Table.gd). Debris that fell + # straight down would silently disagree with every ball on the playfield. + var g: Vector3 = ProjectSettings.get_setting("physics/3d/default_gravity_vector", + Vector3(0, -1, 0)) + g *= float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.81)) + for i in SPARK_POOL: + var p := GPUParticles3D.new() + p.one_shot = true + p.explosiveness = 1.0 + p.amount = SPARK_AMOUNT + p.lifetime = 0.45 + p.local_coords = false + p.emitting = false + var pm := ParticleProcessMaterial.new() + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + pm.emission_sphere_radius = 0.008 + pm.direction = Vector3(0, 1, 0) + pm.spread = 62.0 + pm.initial_velocity_min = 0.25 + pm.initial_velocity_max = 0.9 + pm.gravity = g + pm.scale_min = 0.5 + pm.scale_max = 1.4 + pm.damping_min = 0.2 + pm.damping_max = 0.8 + p.process_material = pm + var mesh := BoxMesh.new() + mesh.size = Vector3(0.0035, 0.0035, 0.0035) # a pinball is 27 mm; these are grit + var m := StandardMaterial3D.new() + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + m.emission_enabled = true + m.emission_energy_multiplier = 2.2 # Main's env has glow on: these bloom + mesh.material = m + p.draw_pass_1 = mesh + add_child(p) + _sparks_pool.append(p) + +func _sparks(at: Vector3, col: Color, count: int, speed: float) -> void: + if _sparks_pool.is_empty(): + return + var p := _sparks_pool[_spark_rr] + _spark_rr = (_spark_rr + 1) % _sparks_pool.size() + var pm := p.process_material as ParticleProcessMaterial + if pm != null: + pm.initial_velocity_min = speed * 0.35 + pm.initial_velocity_max = speed + var mesh := p.draw_pass_1 as BoxMesh + if mesh != null: + var m := mesh.material as StandardMaterial3D + if m != null: + m.albedo_color = col + m.emission = col + # amount_ratio rather than amount: writing `amount` reallocates the whole particle buffer, + # and a bumper chain would do that several times a second. + p.amount_ratio = clampf(float(count) / float(SPARK_AMOUNT), 0.05, 1.0) + p.global_position = at + p.restart() + +# ---------------------------------------------------------------- part squash +## Pop the hit part's MESH only. Deliberately not touching its material: Table.set_rollover_lit +## swaps material_override too, and materials come out of Table's shared _mat_cache — tinting +## one bumper would tint every part that happens to share its colour. Scale is per-node, and +## the CollisionShape3D is a sibling, so the physics never sees this. +func _pop(id: String, amount: float, span: float) -> void: + var t := _table() + if t == null: + return + var n := t.part(id) + if n == null or not is_instance_valid(n): + return + for c in n.get_children(): + if not (c is MeshInstance3D): + continue + var mi := c as MeshInstance3D + var key := mi.get_instance_id() + if _pops.has(key): + # Re-hit mid-pop: restart the timer but keep the ORIGINAL rest scale, or a fast + # chain would ratchet the mesh permanently larger. + var e: Dictionary = _pops[key] + e["t"] = span + e["span"] = span + e["amt"] = amount + else: + _pops[key] = {"node": mi, "base": mi.scale, "t": span, "span": span, "amt": amount} + +func _run_pops(delta: float) -> void: + if _pops.is_empty(): + return + var dead: Array = [] + for key in _pops: + var e: Dictionary = _pops[key] + var mi = e["node"] + if not is_instance_valid(mi): + dead.append(key) # the table was rebuilt under us + continue + e["t"] = float(e["t"]) - delta + var base: Vector3 = e["base"] + if float(e["t"]) <= 0.0: + mi.scale = base + dead.append(key) + continue + var f: float = pow(float(e["t"]) / float(e["span"]), 1.5) + mi.scale = base * (1.0 + float(e["amt"]) * f) + for key in dead: + _pops.erase(key) + +# ---------------------------------------------------------------- spinner ratchet +## The signature pinball sound. One tick sample fired many times beats one long rendered +## burst: the rate, pitch and count all follow the actual shot instead of being baked in. +func _ratchet(at: Vector3, col: Color, spins: int) -> void: + var n := clampi(spins, 1, 24) + # Table.gd bleeds `spin_left` at 6.0/s, so the blade stops after spins/6 seconds. End the + # ticking on that same clock or you get a silent blade still visibly turning. + var span := float(n) / 6.0 + for k in n: + var f := float(k) / float(n) + # Ease-out placement: dense at the start, thinning as the blade gives up. Evenly + # spaced ticks sound like a machine gun, not a spinner losing momentum. + var when := span * (1.0 - pow(1.0 - f, 2.0)) + _sched.append({"do": "tick", "t": _clock + when, "at": at, + "pitch": (1.28 - 0.38 * f) * randf_range(0.97, 1.03), + "gain": 0.9 - 0.45 * f}) + if k % 4 == 0: + _sched.append({"do": "flash", "t": _clock + when, "at": at, "col": col, + "energy": 1.8 - 1.0 * f, "range": 0.12, "life": 0.09}) + _trim_sched() + +func _run_sched() -> void: + if _sched.is_empty(): + return + var i := _sched.size() - 1 + while i >= 0: + var e: Dictionary = _sched[i] + if float(e["t"]) <= _clock: + match String(e["do"]): + "tick": + _voice("tick", e["at"], float(e["gain"]), float(e["pitch"])) + "flash": + _flash(e["at"], e["col"], float(e["energy"]), float(e["range"]), + float(e["life"])) + _sched.remove_at(i) + i -= 1 + +## Hard ceiling on pending events. A ball trapped rattling a spinner can queue faster than the +## schedule drains, and an unbounded array is how a feel system becomes a frame-time bug. +func _trim_sched() -> void: + while _sched.size() > 220: + _sched.pop_front() + +# ---------------------------------------------------------------- colour +## Flash colours come from the table's own palette, so the juice changes personality with the +## table for free. Palette entries are ALBEDO though, and some are deliberately near-black (a +## saucer hole is a shadow) — a light that colour illuminates nothing, so floor value and +## saturation before using one as emitted light. +func _tint(kind: String) -> Color: + var key := String(PAL_KEY.get(kind, "lane")) + var col: Color = PAL_FALLBACK.get(key, Color(1, 1, 1)) + var t := _table() + if t != null: + col = (t.spec.get("palette", {}) as Dictionary).get(key, col) + return Color.from_hsv(col.h, maxf(col.s, 0.35), maxf(col.v, 0.78), 1.0) + +# ---------------------------------------------------------------- handles +func _table() -> Table: + if _main == null or not is_instance_valid(_main): + return null + var t := _main.table + return t if is_instance_valid(t) else null + +## Where the plunger lives. Table emits a MIX of local part positions and global ones, but the +## Table node sits at the origin under Main so they coincide today — if a lane ever moves the +## Table node, everything here needs table.to_global() and Table.gd needs to pick one. +func _lane_pos() -> Vector3: + var t := _table() + if t == null: + return Vector3(0.22, 0.02, 0.48) + return Vector3(t.lane_x, 0.02, t.lane_z) + +# ---------------------------------------------------------------- audio pool +func _build_audio_pool() -> void: + for i in AUDIO_VOICES: + var a := AudioStreamPlayer3D.new() + # Positional audio on a table half a metre wide: unit_size is set near the camera's + # working distance so the inverse-distance law lands around 0 dB everywhere on the + # playfield. What 3D audio buys here is the PAN — a left outlane rattle belongs in the + # left ear — not falloff, so the pan is exaggerated and the falloff is flattened. + a.unit_size = 1.2 + a.max_db = 3.0 + a.panning_strength = 1.6 + a.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_DISTANCE + add_child(a) + _voices.append(a) + +func _voice(v: String, at: Vector3, gain := 1.0, pitch := 1.0) -> void: + var w: AudioStreamWAV = _wav.get(v) + if w == null or _voices.is_empty(): + return + var p: AudioStreamPlayer3D = null + for a in _voices: + if not a.playing: + p = a + break + if p == null: + # Every voice busy. Steal round-robin rather than drop: silence would land on exactly + # the loud moments — a bumper chain, a cleared bank — that need to be heard. + p = _voices[_voice_rr] + _voice_rr = (_voice_rr + 1) % _voices.size() + p.stream = w + p.pitch_scale = clampf(pitch, 0.05, 4.0) + p.volume_db = linear_to_db(clampf(gain, 0.02, 4.0)) + p.global_position = at + p.play() + +## Mono 16-bit AudioStreamWAV from a float[-1,1] buffer. Same trick as Destroyulator's Juice. +func _make_wav(samples: PackedFloat32Array) -> AudioStreamWAV: + var wav := AudioStreamWAV.new() + wav.format = AudioStreamWAV.FORMAT_16_BITS + wav.mix_rate = RATE + wav.stereo = false + var bytes := PackedByteArray() + bytes.resize(samples.size() * 2) + for i in samples.size(): + bytes.encode_s16(i * 2, int(clampf(samples[i], -1.0, 1.0) * 32767.0)) + wav.data = bytes + return wav + +# ---------------------------------------------------------------- synthesis +## Every voice is authored here. Two rules run through all of them: +## +## 1. Anything that GLIDES accumulates its phase (`ph += TAU * f * dt`). The obvious +## sin(TAU * f(t) * t) is wrong for a sweep — it glides at twice the intended rate and can +## run backwards through zero — and a pinball cabinet is nothing but glides. Fixed-pitch +## partials use the direct form, where it is exact. +## 2. Every envelope opens with (1 - exp(-t * k)). A buffer that starts at full amplitude +## clicks on the first sample, and twenty of those a second is what makes synthesized audio +## sound cheap. +func _synth(v: String) -> PackedFloat32Array: + match v: + "bumper": return _v_bumper() + "sling": return _v_sling() + "drop": return _v_drop() + "fanfare": return _v_fanfare() + "tick": return _v_tick() + "saucer": return _v_saucer() + "rollover": return _v_rollover() + "ramp": return _v_ramp() + "target": return _v_target() + "drain": return _v_drain() + "plunge": return _v_plunge() + "nudge": return _v_nudge() + return _v_rollover() + +func _alloc(dur: float) -> PackedFloat32Array: + var out := PackedFloat32Array() + out.resize(int(RATE * dur)) + return out + +## Pop bumper: a coil slams a ring skirt down and the whole assembly booms. Fat, resonant, +## and it drops most of an octave in 40 ms — that pitch drop IS the bumper. +func _v_bumper() -> PackedFloat32Array: + var out := _alloc(0.30) + var dt := 1.0 / float(RATE) + var ph := 0.0 + var ph2 := 0.0 + for i in out.size(): + var t := float(i) * dt + var f := 84.0 + 235.0 * exp(-t * 26.0) + ph += TAU * f * dt + ph2 += TAU * f * 2.51 * dt # inharmonic partial: struck hardware, not a note + var env := (1.0 - exp(-t * 900.0)) * exp(-t * 11.0) + var slap := (randf() * 2.0 - 1.0) * exp(-t * 150.0) * 0.50 + out[i] = clampf(((sin(ph) * 0.78 + sin(ph2) * 0.16) * env + slap) * 0.92, -1.0, 1.0) + return out + +## Slingshot: the same mechanism as a bumper but smaller and faster — higher, snappier, over +## before the bumper has finished its first cycle. +func _v_sling() -> PackedFloat32Array: + var out := _alloc(0.18) + var dt := 1.0 / float(RATE) + var ph := 0.0 + for i in out.size(): + var t := float(i) * dt + var f := 195.0 + 520.0 * exp(-t * 44.0) + ph += TAU * f * dt + var env := (1.0 - exp(-t * 2000.0)) * exp(-t * 25.0) + var slap := (randf() * 2.0 - 1.0) * exp(-t * 260.0) * 0.55 + out[i] = clampf((sin(ph) * 0.62 * env + slap) * 0.95, -1.0, 1.0) + return out + +## Drop target: pure mechanism, no tone worth speaking of. Two clacks — the target letting go, +## then 35 ms later the carriage hitting its stop. That second hit is what sells it as a part +## with mass rather than a sound effect. +func _v_drop() -> PackedFloat32Array: + var out := _alloc(0.14) + var dt := 1.0 / float(RATE) + for i in out.size(): + var t := float(i) * dt + var a := exp(-t * 88.0) + var t2 := t - 0.035 + var b := exp(-t2 * 130.0) if t2 > 0.0 else 0.0 + var noise := (randf() * 2.0 - 1.0) + var wood := sin(TAU * 1180.0 * t) * 0.30 + sin(TAU * 2050.0 * t) * 0.14 + out[i] = clampf(noise * (a * 0.58 + b * 0.34) + wood * (a * 0.7 + b * 0.4), -1.0, 1.0) + return out + +## Bank cleared: three notes walking up a major triad, each one left ringing, so by the last +## stab they are sounding together as a chord. Rising = reward, and it has to cut through the +## clack of the final target that triggered it. +func _v_fanfare() -> PackedFloat32Array: + var notes := [523.25, 659.25, 987.77] # C5, E5, B5 + var step := 0.145 + var out := _alloc(step * 2.0 + 0.46) + var dt := 1.0 / float(RATE) + for i in out.size(): + var t := float(i) * dt + var s := 0.0 + for k in notes.size(): + var lt := t - float(k) * step + if lt <= 0.0: + continue + var f: float = notes[k] + var env := (1.0 - exp(-lt * 420.0)) * exp(-lt * 4.6) + s += (sin(TAU * f * lt) * 0.50 + sin(TAU * f * 2.0 * lt) * 0.22 + + sin(TAU * f * 3.0 * lt) * 0.10) * env + out[i] = clampf(s * 0.62, -1.0, 1.0) + return out + +## One spinner tick. 30 ms of nothing but transient — it exists to be fired twenty times in a +## row by _ratchet, so anything with a tail would smear into mush. +func _v_tick() -> PackedFloat32Array: + var out := _alloc(0.030) + var dt := 1.0 / float(RATE) + for i in out.size(): + var t := float(i) * dt + var env := exp(-t * 210.0) + out[i] = clampf(((randf() * 2.0 - 1.0) * 0.55 + sin(TAU * 2900.0 * t) * 0.45) * env, + -1.0, 1.0) + return out + +## Saucer: the ball disappears into a hole. Soft 12 ms attack instead of a transient — nothing +## was struck, something was swallowed — over a descending pitch with a slow wobble. +func _v_saucer() -> PackedFloat32Array: + var out := _alloc(0.36) + var dt := 1.0 / float(RATE) + var ph := 0.0 + for i in out.size(): + var t := float(i) * dt + var f := 150.0 + 640.0 * exp(-t * 7.0) + ph += TAU * f * dt + var env := (1.0 - exp(-t * 85.0)) * exp(-t * 6.2) + var wobble := 0.82 + 0.18 * sin(TAU * 11.0 * t) + out[i] = clampf((sin(ph) * 0.62 + sin(ph * 0.5) * 0.22) * env * wobble, -1.0, 1.0) + return out + +## Rollover: a wire trigger, nothing more. Clean, bright, short — a fifth stacked on the +## fundamental so it reads as a chime rather than a beep. +func _v_rollover() -> PackedFloat32Array: + var out := _alloc(0.10) + var dt := 1.0 / float(RATE) + for i in out.size(): + var t := float(i) * dt + var env := (1.0 - exp(-t * 1400.0)) * exp(-t * 36.0) + out[i] = clampf((sin(TAU * 1560.0 * t) * 0.55 + sin(TAU * 2340.0 * t) * 0.20) * env, + -1.0, 1.0) + return out + +## Ramp made: a swoop. Pitch climbs and settles while the amplitude swells then falls, which +## is the sound of something travelling away from you and arriving. +func _v_ramp() -> PackedFloat32Array: + var dur := 0.46 + var out := _alloc(dur) + var dt := 1.0 / float(RATE) + var ph := 0.0 + for i in out.size(): + var t := float(i) * dt + var f := 230.0 + 1480.0 * (1.0 - exp(-t * 6.0)) + ph += TAU * f * dt + var swell := sin(PI * clampf(t / dur, 0.0, 1.0)) + var air := (randf() * 2.0 - 1.0) * 0.22 * swell * swell + out[i] = clampf((sin(ph) * 0.5 + sin(ph * 2.0) * 0.14) * swell + air, -1.0, 1.0) + return out + +## Standing target: a small bright ping with a metallic third partial. Short enough that the +## six-target array on THE FOUNDRY can be hit in a burst without turning into a drone. +func _v_target() -> PackedFloat32Array: + var out := _alloc(0.14) + var dt := 1.0 / float(RATE) + for i in out.size(): + var t := float(i) * dt + var env := (1.0 - exp(-t * 1500.0)) * exp(-t * 30.0) + var tone := sin(TAU * 1850.0 * t) * 0.48 + sin(TAU * 2770.0 * t) * 0.24 \ + + sin(TAU * 4110.0 * t) * 0.10 + out[i] = clampf(tone * env + (randf() * 2.0 - 1.0) * exp(-t * 400.0) * 0.28, + -1.0, 1.0) + return out + +## Drain: the only sound on the table that is supposed to feel bad. A sagging sawtooth buzz +## with a detuned wobble — the harmonics alias a little at this rate and that is fine, a drain +## should sound slightly wrong. +func _v_drain() -> PackedFloat32Array: + var out := _alloc(0.90) + var dt := 1.0 / float(RATE) + var ph := 0.0 + for i in out.size(): + var t := float(i) * dt + var f := (62.0 + 215.0 * exp(-t * 2.2)) * (1.0 + 0.02 * sin(TAU * 6.0 * t)) + ph += TAU * f * dt + var saw := fmod(ph / TAU, 1.0) * 2.0 - 1.0 + var env := (1.0 - exp(-t * 40.0)) * exp(-t * 2.6) + out[i] = clampf((saw * 0.42 + sin(ph) * 0.40) * env, -1.0, 1.0) + return out + +## Plunger: a steel spring released. The springiness is frequency modulation by a decaying +## LFO — a plain decaying tone reads as a bell, and the boing is the whole point. +func _v_plunge() -> PackedFloat32Array: + var out := _alloc(0.34) + var dt := 1.0 / float(RATE) + var ph := 0.0 + for i in out.size(): + var t := float(i) * dt + var f := (150.0 + 265.0 * exp(-t * 30.0)) * (1.0 + 0.35 * exp(-t * 9.0) + * sin(TAU * 38.0 * t)) + ph += TAU * f * dt + var env := (1.0 - exp(-t * 600.0)) * exp(-t * 7.5) + out[i] = clampf((sin(ph) * 0.60 + sin(ph * 3.0) * 0.12) * env + + (randf() * 2.0 - 1.0) * exp(-t * 120.0) * 0.28, -1.0, 1.0) + return out + +## Nudge: a palm on the cabinet side. Low, dull, no ring — it is a body hit, not a part. +func _v_nudge() -> PackedFloat32Array: + var out := _alloc(0.16) + var dt := 1.0 / float(RATE) + var ph := 0.0 + for i in out.size(): + var t := float(i) * dt + var f := 55.0 + 95.0 * exp(-t * 40.0) + ph += TAU * f * dt + var env := (1.0 - exp(-t * 400.0)) * exp(-t * 22.0) + out[i] = clampf(sin(ph) * 0.80 * env + (randf() * 2.0 - 1.0) * exp(-t * 90.0) * 0.30, + -1.0, 1.0) + return out diff --git a/godot/scripts/Juice.gd.uid b/godot/scripts/Juice.gd.uid new file mode 100644 index 0000000..5e1fb02 --- /dev/null +++ b/godot/scripts/Juice.gd.uid @@ -0,0 +1 @@ +uid://d2c25swplfjvq diff --git a/godot/scripts/Rules.gd b/godot/scripts/Rules.gd new file mode 100644 index 0000000..bb49fb4 --- /dev/null +++ b/godot/scripts/Rules.gd @@ -0,0 +1,509 @@ +extends Node +class_name Rules + +## The game layer: what a shot is worth, when a ball ends, and when the game does. +## +## Table emits, Main forwards, this decides. It owns no geometry — the only things it calls +## back into the table with are reset_bank / set_rollover_lit / add_ball / park_ball, which +## are exactly the devices whose *state* a ruleset is supposed to own. +## +## The shape of the scoring: bumpers and slings are chatter, ramps and saucers are money, +## a whipped spinner is a jackpot and a dribbled one is a rounding error, and the combo +## multiplier is where a good player pulls away from a lucky one. Pops deliberately do NOT +## build combo — if they did, the top of the table would max the multiplier for free and +## aiming would stop mattering. +## +## Godot 4.7. No assets, no scene: pure state. + +## Fired for anything worth putting on a display. Small hits stay silent (see AWARD_FLOOR) +## so a HUD isn't a waterfall of "+250 BUMPER"; poll score() for the running total. +signal awarded(text: String, points: int) + +const BALLS_PER_GAME := 3 + +const COMBO_WINDOW := 2.5 ## seconds of grace before a chain goes cold +const COMBO_STEP := 0.5 ## each chained shot adds this to the multiplier +const COMBO_CAP := 8.0 + +const BALL_SAVE := 7.0 ## grace after launch — an early drain is a robbery otherwise +const DRAIN_DEBOUNCE := 400 ## msec; see on_drained() +const AWARD_FLOOR := 1000 ## below this an award is scored but not announced + +const MB_MULT := 3.0 +const LOCKS_FOR_MB := 3 +const JACKPOT := 10000 +const JACKPOT_STEP := 5000 + +const BANK_BONUS := 5000 ## × how many times that bank has been cleared +const BANK_ESCALATE_CAP := 6 +const BANK_RESET_DELAY := 0.7 ## see _reset_bank_soon() + +const LANE_BONUS := 3000 ## × current lane level, for completing the word +const LANE_LEVEL_CAP := 6 + +## Nudge heat: each shove adds 1.0 and it bleeds off at NUDGE_COOL/sec. Two warnings, then +## the tilt — a tilt with no warning is just the table stealing a ball off you. +const NUDGE_COOL := 0.75 +const TILT_WARN_AT := [2.0, 3.0] +const TILT_AT := 4.0 + +const SCORES_PATH := "user://pinball_scores.cfg" + +## End-of-ball bonus units per hit, paid out at drain × lane level. Deliberately flat and +## unmultiplied: the bonus rewards *playing the ball out*, the score rewards playing it well. +const BONUS_UNITS := { + "bumper": 10, "sling": 10, "target": 50, "drop": 100, + "spinner": 8, "saucer": 500, "rollover": 120, "ramp": 250, +} + +## Which shots keep a chain alive. Aimed shots only. +const COMBO_KINDS := ["target", "drop", "bank_cleared", "spinner", "saucer", "rollover", "ramp"] + +var tilted := false ## Main reads this directly to kill the flippers + +var _main: Node = null +var _table: Table = null + +var _score := 0 +var _ball := 1 +var _game_over := false +var _high := 0 +var _high_key := "table" + +var _combo := 0 +var _combo_left := 0.0 + +var _bonus := 0 +var _lane_level := 1 +var _lane_ids: PackedStringArray = [] + +var _save_left := 0.0 +var _save_used := false + +var _locks := 0 +var _mb_active := false +var _jackpot := JACKPOT + +var _bank_clears: Dictionary = {} # bank name -> times cleared this game + +var _nudge_heat := 0.0 +var _warns_given := 0 + +## instance id -> msec of its last drain. Keyed by id rather than node because a drained +## multiball ball is freed while this is still holding it, and a timestamp rather than a +## coroutine because a cleanup that fails to run would wedge that ball out of the game. +var _draining: Dictionary = {} + +# ---------------------------------------------------------------- lifecycle +func setup(main: Node) -> void: + _main = main + +func start_game(table: Table) -> void: + _table = table + _score = 0 + _ball = 1 + _game_over = false + _combo = 0 + _combo_left = 0.0 + _bonus = 0 + _lane_level = 1 + _locks = 0 + _mb_active = false + _jackpot = JACKPOT + _bank_clears.clear() + _save_left = 0.0 + _save_used = false + _nudge_heat = 0.0 + _warns_given = 0 + _draining.clear() + tilted = false + + _high_key = table.table_name().to_lower().replace(" ", "_") + _high = _read_high(_high_key) + _cache_lanes() + for id in _lane_ids: + table.set_rollover_lit(id, false) + + # Ball save has to arm on the PLUNGE, not on start_game, and Main doesn't forward + # ball_launched to us — so listen to the table directly. Main rebuilds the same Table + # node per layout, hence the is_connected guard rather than a fresh connect. + if not table.ball_launched.is_connected(_on_ball_launched): + table.ball_launched.connect(_on_ball_launched) + + print("[rules] game start — %s · %d balls · high %s" % [ + table.table_name(), BALLS_PER_GAME, commas(_high)]) + awarded.emit("BALL 1", 0) + +func _process(delta: float) -> void: + if _combo_left > 0.0: + _combo_left = maxf(0.0, _combo_left - delta) + if _combo_left == 0.0: + _combo = 0 + if _save_left > 0.0: + _save_left = maxf(0.0, _save_left - delta) + if _save_left == 0.0 and not _save_used: + awarded.emit("SAVE OVER", 0) + if _nudge_heat > 0.0: + _nudge_heat = maxf(0.0, _nudge_heat - delta * NUDGE_COOL) + if _nudge_heat == 0.0: + _warns_given = 0 + +func _on_ball_launched(_b: RigidBody3D) -> void: + # Multiball adds balls through this same signal; those must not re-arm the save. + if _game_over or _mb_active or _save_used or _save_left > 0.0: + return + _save_left = BALL_SAVE + awarded.emit("BALL SAVE", 0) + +# ---------------------------------------------------------------- scoring +func on_hit(kind: String, id: String, _at: Vector3, data: Dictionary) -> void: + if _game_over or tilted or _table == null: + return + + if kind == "bank_cleared": + _bank_cleared(id) + return + + var base := int(data.get("points", 0)) + var label := kind.to_upper() + + match kind: + "spinner": + # The whole point of a spinner: points PER SPIN, and spins come from speed. + var spins := maxi(1, int(data.get("spins", 1))) + base *= spins + label = "SPINNER %d" % spins + "rollover": + label = "LANE %s" % String(data.get("glyph", "?")) + + _bonus += _bonus_for(kind, data) + _award(base, label) + # Combo grows AFTER the award, so the first shot of a chain pays 1x and the reward for + # chaining shows up on the next one. Extending first would silently gift a lone shot 1.5x. + _extend_combo(kind) + + # Device state runs after the award so a lane bonus lands on top of the lane's own value. + match kind: + "rollover": _lane_rolled(id) + "saucer": _saucer_captured() + "ramp": + if _mb_active: + _pay_jackpot("RAMP JACKPOT") + +## Everything that adds to the score goes through here, so the multipliers can never be +## forgotten at a call site. +func _award(base: int, label: String, announce := false) -> int: + if base <= 0: + return 0 + var pts := int(round(float(base) * combo() * _mb_mult())) + _score += pts + if _score > _high: + _high = _score # live, so a HUD can show the record falling as it happens + if announce or pts >= AWARD_FLOOR: + awarded.emit(label, pts) + return pts + +func _extend_combo(kind: String) -> void: + if not COMBO_KINDS.has(kind): + return # pops and slings ride the multiplier but never build it + _combo = mini(_combo + 1, int((COMBO_CAP - 1.0) / COMBO_STEP)) + _combo_left = COMBO_WINDOW + +func _bonus_for(kind: String, data: Dictionary) -> int: + var unit := int(BONUS_UNITS.get(kind, 25)) + if kind == "spinner": + unit *= maxi(1, int(data.get("spins", 1))) + return unit + +func _mb_mult() -> float: + return MB_MULT if _mb_active else 1.0 + +# ---------------------------------------------------------------- devices +## A whole drop bank down. Escalates every time you knock it over again in the same game, +## which is what turns a bank from a chore into a strategy. +func _bank_cleared(bank: String) -> void: + var n := int(_bank_clears.get(bank, 0)) + 1 + _bank_clears[bank] = n + var step := mini(n, BANK_ESCALATE_CAP) + _bonus += 1000 * step + _award(BANK_BONUS * step, "%s BANK x%d" % [bank.to_upper(), step], true) + _extend_combo("bank_cleared") + _reset_bank_soon(bank) + +## Reset on a beat, not instantly: the ball is still sitting inside the last target's +## trigger volume when this fires, and popping the bank up underneath it re-clears the +## whole thing for free — an infinite bonus loop from one lucky shot. +func _reset_bank_soon(bank: String) -> void: + var t := _table + var tree := get_tree() + if tree == null: + t.reset_bank(bank) # no tree, no timers, and nothing to race either + return + await tree.create_timer(BANK_RESET_DELAY).timeout + if is_instance_valid(t): + t.reset_bank(bank) # no-op if the table was swapped out from under us + +func _cache_lanes() -> void: + _lane_ids = PackedStringArray() + if _table == null: + return + for n in _table.parts_of("rollover"): + _lane_ids.append(String((n as Node).get_meta("id", ""))) + +func _lane_rolled(id: String) -> void: + var n := _table.part(id) + if n == null or _lane_ids.is_empty(): + return # an uncached lane set would read as "all lit" and pay out forever + if not bool(n.get_meta("lit", false)): + _table.set_rollover_lit(id, true) + for lid in _lane_ids: + var l := _table.part(lid) + if l == null or not bool(l.get_meta("lit", false)): + return + # every lane lit: pay the word, advance the bonus multiplier, wipe them for the next lap + _award(LANE_BONUS * _lane_level, "%s COMPLETE" % _lane_word(), true) + _lane_level = mini(_lane_level + 1, LANE_LEVEL_CAP) + for lid in _lane_ids: + _table.set_rollover_lit(lid, false) + awarded.emit("BONUS x%d" % _lane_level, 0) + +func _lane_word() -> String: + var s := "" + for lid in _lane_ids: + var n := _table.part(lid) + if n != null: + s += String(n.get_meta("glyph", "")) + return s if s != "" else "LANES" + +## Saucers are the lock device on every table in Tables.gd, so any capture counts. +func _saucer_captured() -> void: + if _mb_active: + _pay_jackpot("SAUCER JACKPOT") + return + _locks += 1 + if _locks < LOCKS_FOR_MB: + awarded.emit("LOCK %d/%d" % [_locks, LOCKS_FOR_MB], 0) + return + _start_multiball() + +func _start_multiball() -> void: + _locks = 0 + _jackpot = JACKPOT + _mb_active = true # set BEFORE add_ball: it emits ball_launched, which must not re-arm the save + awarded.emit("MULTIBALL", 0) + for i in 2: + var b := _table.add_ball() + # add_ball only nudges the new ball at ~0.4 m/s and it spawns in the plunger lane, + # where the tilted gravity vector will just roll it back down. Give it a real plunge. + if is_instance_valid(b): + b.apply_central_impulse(Vector3(0, 0, -0.30)) + +func _pay_jackpot(label: String) -> void: + _award(_jackpot, label, true) + _jackpot += JACKPOT_STEP + +# ---------------------------------------------------------------- drain / ball count +func on_drained(ball: RigidBody3D) -> void: + if _table == null or not is_instance_valid(ball): + return + # The drain Area fires on entry and park_ball's teleport takes a frame to register, so + # the same ball can arrive here twice before it has actually gone anywhere. + var iid := ball.get_instance_id() + var now := Time.get_ticks_msec() + if now - int(_draining.get(iid, -DRAIN_DEBOUNCE)) < DRAIN_DEBOUNCE: + return + _draining[iid] = now + + if _game_over: + _table.park_ball(ball) + return + + # Multiball: any ball but the last just leaves the game quietly. + if _table.balls.size() > 1: + _table.remove_ball(ball) + if _mb_active and _table.balls.size() <= 1: + _mb_active = false + awarded.emit("MULTIBALL OVER", 0) + return + + if _mb_active: + _mb_active = false # defensive: balls vanished some other way + + # Ball save. A tilt forfeits it — that is the price of shoving the table. + if _save_left > 0.0 and not _save_used and not tilted: + _save_used = true + _save_left = 0.0 + _combo = 0 + _combo_left = 0.0 + _table.park_ball(ball) + awarded.emit("BALL SAVED", 0) + return + + _end_ball(ball) + +func _end_ball(ball: RigidBody3D) -> void: + var payout := _bonus * _lane_level + if payout > 0: + _score += payout + if _score > _high: + _high = _score + awarded.emit("BONUS x%d" % _lane_level, payout) + + _ball += 1 + _combo = 0 + _combo_left = 0.0 + _bonus = 0 + _save_left = 0.0 + _save_used = false + _nudge_heat = 0.0 + _warns_given = 0 + tilted = false # the tilt dies with the ball, not with the game + + if _ball > BALLS_PER_GAME: + _game_over = true + _table.park_ball(ball) + _finish() + return + + _table.park_ball(ball) + awarded.emit("BALL %d" % _ball, 0) + +func _finish() -> void: + var record := _score >= _read_high(_high_key) and _score > 0 + _write_high(_high_key, _score) + awarded.emit("GAME OVER", _score) + if record: + awarded.emit("HIGH SCORE", _score) + print("[rules] game over — %s: %s%s" % [ + _table.table_name(), commas(_score), " (NEW HIGH)" if record else ""]) + +# ---------------------------------------------------------------- tilt +func on_nudge() -> void: + if _game_over or tilted: + return + _nudge_heat += 1.0 + if _nudge_heat >= TILT_AT: + _tilt() + return + while _warns_given < TILT_WARN_AT.size() and _nudge_heat >= float(TILT_WARN_AT[_warns_given]): + _warns_given += 1 + awarded.emit("DANGER" if _warns_given > 1 else "WARNING", 0) + +func _tilt() -> void: + tilted = true + _bonus = 0 # the bonus is what a tilt actually costs you + _combo = 0 + _combo_left = 0.0 + _nudge_heat = 0.0 + awarded.emit("TILT", 0) + +# ---------------------------------------------------------------- high scores +func _read_high(key: String) -> int: + var cf := ConfigFile.new() + if cf.load(SCORES_PATH) != OK: + return 0 + return int(cf.get_value("high", key, 0)) + +func _write_high(key: String, value: int) -> void: + var cf := ConfigFile.new() + cf.load(SCORES_PATH) # ignore the error: a missing file is just an empty one + if value > int(cf.get_value("high", key, 0)): + cf.set_value("high", key, value) + cf.save(SCORES_PATH) + +# ---------------------------------------------------------------- getters (the HUD lane's API) +func score() -> int: + return _score + +func ball() -> int: + return mini(_ball, BALLS_PER_GAME) + +func balls_total() -> int: + return BALLS_PER_GAME + +## How many tilt warnings are showing. TILT_WARN_AT.size() is the max before the tilt lands, +## and the Hud lane reads that constant to render "n/max" — keep them in step. +func warnings() -> int: + return _warns_given + +## The live scoring multiplier, e.g. 2.5 — not the chain length. combo_count() is that. +func combo() -> float: + return minf(1.0 + COMBO_STEP * float(_combo), COMBO_CAP) + +func combo_count() -> int: + return _combo + +func combo_left() -> float: + return _combo_left + +func high_score() -> int: + return _high + +func bonus() -> int: + return _bonus + +func lane_level() -> int: + return _lane_level + +func locks() -> int: + return _locks + +func multiball() -> bool: + return _mb_active + +func multiplier() -> float: + return combo() * _mb_mult() + +func ball_save_left() -> float: + return _save_left + +func game_over() -> bool: + return _game_over + +func balls_in_play() -> int: + return _table.balls.size() if _table != null else 0 + +func score_text() -> String: + return commas(_score) + +## Which layout the score belongs to — high scores are kept per table, so a HUD showing +## the record needs this to label it. Empty if Rules was spawned without a Main. +func table_id() -> String: + if _main == null: + return "" + var v: Variant = _main.get("table_id") + return String(v) if v != null else "" + +func status_line() -> String: + if _game_over: + return "GAME OVER · HIGH %s · R FOR A NEW GAME" % commas(_high) + if tilted: + return "TILT · FLIPPERS DEAD UNTIL THE DRAIN" + var bits := PackedStringArray() + bits.append("BALL %d/%d" % [ball(), BALLS_PER_GAME]) + if _mb_active: + bits.append("MULTIBALL x%d" % int(MB_MULT)) + elif _locks > 0: + bits.append("LOCK %d/%d" % [_locks, LOCKS_FOR_MB]) + if _combo > 0: + bits.append("COMBO x%s" % _mult_text(combo())) + if _save_left > 0.0: + bits.append("SAVE %d" % int(ceil(_save_left))) + if _lane_level > 1: + bits.append("BONUS x%d" % _lane_level) + if _warns_given > 0: + bits.append("NUDGE %d/2" % mini(_warns_given, 2)) + return " · ".join(bits) + +func _mult_text(m: float) -> String: + return "%d" % int(m) if is_equal_approx(m, floor(m)) else "%.1f" % m + +## Score displays need thousands separators or a six-figure number reads as noise. +static func commas(n: int) -> String: + var s := str(absi(n)) + var out := "" + for i in s.length(): + if i > 0 and (s.length() - i) % 3 == 0: + out += "," + out += s[i] + return ("-" + out) if n < 0 else out diff --git a/godot/scripts/Rules.gd.uid b/godot/scripts/Rules.gd.uid new file mode 100644 index 0000000..a280eb8 --- /dev/null +++ b/godot/scripts/Rules.gd.uid @@ -0,0 +1 @@ +uid://cym4npefwrg7o diff --git a/godot/scripts/Table.gd b/godot/scripts/Table.gd index ae8797a..f13d9c4 100644 --- a/godot/scripts/Table.gd +++ b/godot/scripts/Table.gd @@ -114,7 +114,11 @@ func _playfield() -> void: ds.shape = db drain.add_child(ds) add_child(drain) - drain.position = Vector3(0, 0.03, l * 0.5 + 0.03) + # INSIDE the slab, not past its lip. Parked just beyond the edge, a ball reaches the + # end of the playfield and falls into the void without ever entering the trigger — + # probe_tables reported "off the edge, drain never fired" on four of five tables, which + # is the difference between a pinball table and a hole in the floor. + drain.position = Vector3(0, 0.03, l * 0.5 - 0.02) drain.body_entered.connect(func(b: Node3D) -> void: if b is RigidBody3D and balls.has(b): drained.emit(b)) diff --git a/godot/scripts/Tables.gd b/godot/scripts/Tables.gd index 1310921..415d716 100644 --- a/godot/scripts/Tables.gd +++ b/godot/scripts/Tables.gd @@ -43,8 +43,8 @@ static func neon() -> Dictionary: "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)}) + parts.append({"kind": "post", "id": "post_l", "at": Vector3(-0.100, 0.0, 0.366)}) + parts.append({"kind": "post", "id": "post_r", "at": Vector3(0.100, 0.0, 0.366)}) # --- mid field: the drop-target bank and a pair of standing targets --- for i in 3: @@ -233,8 +233,8 @@ static func orbital() -> Dictionary: "dir": Vector3(1, 0, -0.6), "yaw": deg_to_rad(-34.0), "kick": 0.140, "points": 130}) parts.append({"kind": "sling", "id": "sling_r", "at": Vector3(0.124, 0.0, 0.340), "dir": Vector3(-1, 0, -0.6), "yaw": deg_to_rad(34.0), "kick": 0.140, "points": 130}) - parts.append({"kind": "post", "id": "op_l", "at": Vector3(-0.106, 0.0, 0.348)}) - parts.append({"kind": "post", "id": "op_r", "at": Vector3(0.106, 0.0, 0.348)}) + parts.append({"kind": "post", "id": "op_l", "at": Vector3(-0.098, 0.0, 0.384)}) + parts.append({"kind": "post", "id": "op_r", "at": Vector3(0.098, 0.0, 0.384)}) # the long central ramp — the spine of the table parts.append({"kind": "ramp", "id": "ramp_c", "at": Vector3(0.0, 0.0, 0.180), @@ -299,21 +299,21 @@ static func thedig() -> Dictionary: "dir": Vector3(1, 0, -0.55), "yaw": deg_to_rad(-33.0), "kick": 0.138, "points": 125}) parts.append({"kind": "sling", "id": "sling_r", "at": Vector3(0.128, 0.0, 0.325), "dir": Vector3(-1, 0, -0.55), "yaw": deg_to_rad(33.0), "kick": 0.138, "points": 125}) - parts.append({"kind": "post", "id": "dp_l", "at": Vector3(-0.110, 0.0, 0.335)}) - parts.append({"kind": "post", "id": "dp_r", "at": Vector3(0.110, 0.0, 0.335)}) + parts.append({"kind": "post", "id": "dp_l", "at": Vector3(-0.102, 0.0, 0.371)}) + parts.append({"kind": "post", "id": "dp_r", "at": Vector3(0.102, 0.0, 0.371)}) # THE CRATES: a six-bank you flip through one sleeve at a time. Clearing it is # "finding the record", which is the entire fantasy of a record shop. for i in 6: parts.append({"kind": "drop", "id": "crate_%d" % i, "bank": "crates", - "at": Vector3(-0.140 + i * 0.056, 0.0, 0.070), "points": 450}) + "at": Vector3(-0.112 + i * 0.050, 0.0, 0.070), "points": 450}) # the turntable: a spinner you can whip, dead centre parts.append({"kind": "spinner", "id": "deck", "at": Vector3(0.0, 0.0, -0.020), "points": 160}) # the counter — a ramp up to the register - parts.append({"kind": "ramp", "id": "counter", "at": Vector3(-0.130, 0.0, 0.200), - "yaw": deg_to_rad(15.0), "length": 0.28, "width": 0.050, "rise": 0.062, + parts.append({"kind": "ramp", "id": "counter", "at": Vector3(-0.163, 0.0, 0.210), + "yaw": deg_to_rad(7.0), "length": 0.28, "width": 0.050, "rise": 0.062, "points": 2200}) # listening booth: capture the ball, hold it a good long while parts.append({"kind": "saucer", "id": "booth", "at": Vector3(0.160, 0.0, -0.150),