Four lanes built in parallel onto the data-driven spine: - Rules.gd (509) — score with a decaying combo multiplier, 3 balls, ball save, escalating drop-bank bonuses, lit rollover lanes that level up when swept, saucer-lock multiball, two-warning tilt, end-of-ball bonus, per-table high scores in user://pinball_scores.cfg. - Juice.gd (784) — 12 procedural voices synthesized at boot, no assets. The spinner ratchet is timed to Table.gd bleeding spin_left at 6.0/s so the ticking stops exactly when the blade does. Pooled voices/lights/sparks, quadratic-falloff shake, and a damped spring for nudge. Palette-tinted lights with a value floor, because a light the colour of a near-black saucer illuminates nothing. - Hud.gd (935) — score that ROLLS UP rather than snapping, combo, award callouts, plunger meter, tilt banner, ball save, game over panel. - dev/probe_tables.gd (480) — the gate: builds every table and checks parts, part overlaps, playfield bounds, flipper swing, that the ball actually DRAINS, and that everything settles. The probe immediately found three real bugs the flipper autotest could not see: 1. The drain sat 3 cm PAST the playfield lip, so on four of five tables the ball rolled off the end into the void and the trigger never fired. A table you cannot lose a ball on is not a table. Moved inside the slab. 2. Inlane posts were level with the slingshots and interpenetrated them on three tables — on a real table the posts sit below. Moved down-table. 3. THE DIG's counter ramp ran through the crate bank, and a 15 deg yaw over a 28 cm ramp swings its far end 7 cm sideways, which then walked it through the left rail. Less yaw, tucked in, crates slid right. PROBE_TABLES: PASS 5/5. AUTOTEST: ALL TABLES OK. Still open, and it is an ENGINE finding not a table one: godot-box3d does not enforce HingeJoint3D angular limits — flippers swing 147-160 deg against a 62 deg limit. The probe reports it as a NOTE per table. Worth an upstream issue alongside the motor-sign inversion already in the README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
785 lines
30 KiB
GDScript
785 lines
30 KiB
GDScript
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
|