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),