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>
156 lines
6.1 KiB
TypeScript
156 lines
6.1 KiB
TypeScript
/**
|
||
* 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.')
|