BLOBBO/src/demo/lane-f.ts
type-two 1e2e49518f Lane F: procedural WebAudio engine + soundboard demo
Event-driven comedy SFX, all synthesized (no assets, no deps):
- engine.ts: lazy AudioContext (dormant until ui:play / first gesture),
  master chain at ~-12dBFS through a soft limiter, persisted mute
  (blobbo:muted), 10-voice polyphony cap with steal-nearest-to-finish.
- sounds.ts: squish (impact-scaled wet splat), boing (springy sweep),
  splat (per-colour pentatonic bloop, PALETTE order), sparkle (bubbly
  arpeggio), clank+windup (0.5s accelerating ratchet matching the machine
  telegraph), tick (starter blip), fanfare (major arpeggio + new-best
  flourish), optional ambient conveyor hum. All ±10% pitch-randomized,
  all <400ms except fanfare.
- install.ts: subscribes the frozen wire events, returns { engine },
  mounts the bottom-right mute button.
- demos/lane-f.html + src/demo/lane-f.ts: soundboard driving the real
  installAudio wiring; impact slider, colour picker, splat-storm stress.

Verified: tsc strict + vite build pass; 36-check headless harness proves
dormancy pre-unlock, single-context creation, every sound synthesizes,
voice cap holds under a 60-splat storm, and mute persists. Caught+fixed a
clank() infinite loop (geometric tick interval converged below windup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:46:28 +10:00

156 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Lane F demo — audio soundboard.
*
* Verifies every synthesized one-shot in isolation. Buttons emit the SAME frozen
* wire events the game emits, through the real `installAudio()` wiring (so the
* event → sound mapping and the mute button are exercised too, not just the raw
* synth functions). An impact slider drives the squish scaling, a colour picker
* drives the per-colour splat pitch, and a "new best" toggle drives the fanfare
* flourish.
*
* Autoplay: nothing makes sound until the first click (which unlocks the
* AudioContext) — matching the browser policy. There is no title screen here, so
* the first-gesture fallback in installAudio does the unlocking.
*
* Buttons / keys (also handy for integration):
* squish landing → blob:landed {impact} key 1
* boing jump → blob:jumped key 2
* splat (colour) → paint:splatted {blob} key 3
* sparkle cleanse → paint:request-cleanse key 4
* clank + windup → machine:signal key 5
* race start tick → race:started key 6
* fanfare → race:finished key 7
* SPLAT STORM (10) → 10× paint:splatted key 8 (voice-cap stress test)
* ambient hum → toggle conveyor room tone key 9
*/
import type { PaintColor, World } from '../contracts'
import { PALETTE } from '../contracts'
import { installAudio } from '../audio/install'
import { startAmbientHum, type Ambient } from '../audio/sounds'
// --- minimal event bus (same shape as world.ts's, so installAudio is happy) ---
type Handler = (p: unknown) => void
function makeBus(): World['events'] {
const map = new Map<string, Set<Handler>>()
return {
on(name: string, fn: Handler) {
if (!map.has(name)) map.set(name, new Set())
map.get(name)!.add(fn)
return () => map.get(name)!.delete(fn)
},
emit(name: string, payload?: unknown) {
map.get(name)?.forEach((fn) => fn(payload))
},
}
}
const events = makeBus()
// installAudio only ever touches world.events — a partial cast is safe here.
const { engine } = installAudio({ events } as unknown as World)
// ---------------------------------------------------------------- unlock badge
const unlockBadge = document.getElementById('unlock')!
const refreshUnlock = (): void => {
if (engine.ready) {
unlockBadge.textContent = '🔓 AudioContext live — hit the pads.'
unlockBadge.classList.add('on')
}
}
addEventListener('pointerdown', () => setTimeout(refreshUnlock, 0), { once: false })
// ------------------------------------------------------------------ controls UI
const colors = Object.keys(PALETTE) as PaintColor[]
let impact = 6
let color: PaintColor = 'red'
let newBest = false
const pads = document.getElementById('pads')!
interface PadDef {
label: string
sub: string
key: string
fire: () => void
}
let ambient: Ambient | null = null
const defs: PadDef[] = [
{ label: '💥 squish', sub: 'blob:landed', key: '1', fire: () => events.emit('blob:landed', { impact }) },
{ label: '🟢 boing', sub: 'blob:jumped', key: '2', fire: () => events.emit('blob:jumped', {}) },
{ label: '🎨 splat', sub: 'paint:splatted', key: '3', fire: () => events.emit('paint:splatted', { color, target: 'blob' }) },
{ label: '✨ sparkle', sub: 'paint:request-cleanse', key: '4', fire: () => events.emit('paint:request-cleanse', { fraction: 1 }) },
{ label: '⚙️ clank', sub: 'machine:signal', key: '5', fire: () => events.emit('machine:signal', { id: 'demo' }) },
{ label: '🏁 tick', sub: 'race:started', key: '6', fire: () => events.emit('race:started', {}) },
{ label: '🎉 fanfare', sub: 'race:finished', key: '7', fire: () => events.emit('race:finished', newBest ? { time: 9, best: 9 } : { time: 12, best: 9 }) },
{
label: '🌪 splat storm', sub: '10× in 1s (voice cap)', key: '8',
fire: () => {
for (let i = 0; i < 10; i++) {
const c = colors[i % colors.length]
setTimeout(() => events.emit('paint:splatted', { color: c, target: 'blob' }), i * 100)
}
},
},
{
label: '🛗 ambient', sub: 'toggle room tone', key: '9',
fire: () => {
if (ambient) { ambient.stop(); ambient = null }
else ambient = startAmbientHum(engine)
},
},
]
for (const d of defs) {
const b = document.createElement('button')
b.className = 'pad'
b.innerHTML = `${d.label}<small>${d.sub} · key ${d.key}</small>`
b.addEventListener('click', () => { d.fire(); refreshUnlock() })
pads.appendChild(b)
}
addEventListener('keydown', (e) => {
const d = defs.find((x) => x.key === e.key)
if (d) { d.fire(); refreshUnlock() }
})
// ---------------------------------------------------------------- sliders panel
const panel = document.createElement('div')
panel.className = 'ctl'
panel.innerHTML = `
<h2>parameters</h2>
<div class="row">
<label for="impact">impact (squish)</label>
<input type="range" id="impact" min="0.8" max="18" step="0.1" value="${impact}">
<span class="val" id="impactVal">${impact.toFixed(1)}</span>
</div>
<div class="row">
<label for="color">splat colour</label>
<select id="color">${colors.map((c) => `<option value="${c}">${c}</option>`).join('')}</select>
<span class="val" id="colorSwatch">■</span>
</div>
<div class="row">
<label for="best">fanfare = new best</label>
<input type="checkbox" id="best">
</div>
`
document.getElementById('app')!.appendChild(panel)
const impactEl = document.getElementById('impact') as HTMLInputElement
const impactVal = document.getElementById('impactVal')!
impactEl.addEventListener('input', () => {
impact = Number(impactEl.value)
impactVal.textContent = impact.toFixed(1)
})
const colorEl = document.getElementById('color') as HTMLSelectElement
const swatch = document.getElementById('colorSwatch') as HTMLElement
const paintSwatch = (): void => { swatch.style.color = PALETTE[color] }
colorEl.addEventListener('change', () => { color = colorEl.value as PaintColor; paintSwatch() })
paintSwatch()
const bestEl = document.getElementById('best') as HTMLInputElement
bestEl.addEventListener('change', () => { newBest = bestEl.checked })
console.log('[lane-f] soundboard ready — click a pad (or keys 1-9) to fire wire events.')