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