explodorama / files / src / ui / feedback.ts
feedback.ts
0b63d158 2026-10-25 Pavel Safronov | 1 | import { Sound, time } from 'littlejsengine' |
| 2 | import type { Animation } from '../render/animate' | |
| 3 | import { WALK_SECONDS } from '../render/effects' | |
bb3823d2 2026-10-31 Pavel Safronov | 4 | import { storageKey } from '../channel' |
0b63d158 2026-10-25 Pavel Safronov | 5 | import type { Action, GameEvent } from '../sim/types' |
| 6 | ||
| 7 | /** | |
| 8 | * Sounds and vibration for the game's turns and buttons (PLAN.md §13; user request, 2026-10-24). | |
| 9 | * Sounds from Kenney's New Platformer Pack and UI Pack (CC0), converted from the packs' .ogg to | |
| 10 | * .mp3 so every browser plays them, under the packs' own names; ZzFX (LittleJS `Sound` with an | |
| 11 | * array) for what the packs have no sound for: the blast, a bomb set down, the ICBM's launch. | |
| 12 | * Vibration only where the browser has it (`navigator.vibrate`: Android, not iPhone). Both can | |
| 13 | * be turned off in the menu, remembered in this browser. Only the game makes them: the demo and | |
| 14 | * the clips play silent. | |
| 15 | */ | |
| 16 | const URLS = import.meta.glob<string>(['../../assets/kenney/sounds/*.mp3', '../../assets/kenney-ui/Sounds/*.mp3'], { | |
| 17 | eager: true, | |
| 18 | query: '?url', | |
| 19 | import: 'default', | |
| 20 | }) | |
| 21 | ||
| 22 | function file(name: string): string { | |
| 23 | const url = URLS[`../../assets/kenney/sounds/${name}.mp3`] ?? URLS[`../../assets/kenney-ui/Sounds/${name}.mp3`] | |
| 24 | if (!url) throw new Error(`Missing sound: ${name}.mp3`) | |
| 25 | return url | |
| 26 | } | |
| 27 | ||
| 28 | type SoundName = keyof typeof SOUND_SOURCES | |
| 29 | ||
| 30 | const SOUND_SOURCES = { | |
| 31 | coin: () => file('sfx_coin'), | |
| 32 | gem: () => file('sfx_gem'), | |
| 33 | charge: () => file('sfx_select'), | |
| 34 | enemyDied: () => file('sfx_disappear'), | |
| 35 | hurt: () => file('sfx_hurt'), | |
| 36 | bump: () => file('sfx_bump'), | |
| 37 | jump: () => file('sfx_jump'), | |
| 38 | cleared: () => file('sfx_jump-high'), | |
| 39 | magic: () => file('sfx_magic'), | |
| 40 | thrown: () => file('sfx_throw'), | |
| 41 | tap: () => file('tap-a'), | |
| 42 | switch: () => file('switch-a'), | |
| 43 | // ZzFX: volume, randomness, frequency, attack, sustain, release, shape (4 noise), shape curve, slide… | |
| 44 | boom: () => [1.2, 0.1, 60, 0.01, 0.12, 0.7, 4, 1.4, 0, 0, 0, 0, 0, 1, 0, 0.5, 0, 0.5, 0.1], | |
| 45 | bombPlaced: () => [0.6, 0, 160, 0, 0.02, 0.09, 1, 2, -12], | |
| 46 | launch: () => [0.7, 0.05, 220, 0.05, 0.25, 0.3, 4, 1, 12], | |
| 47 | } satisfies Record<string, () => string | number[]> | |
| 48 | ||
bb3823d2 2026-10-31 Pavel Safronov | 49 | const SETTINGS_KEY = storageKey('feedback') |
0b63d158 2026-10-25 Pavel Safronov | 50 | |
| 51 | export interface FeedbackSettings { | |
| 52 | sound: boolean | |
| 53 | vibration: boolean | |
| 54 | } | |
| 55 | ||
| 56 | let settings: FeedbackSettings = loadSettings() | |
| 57 | let sounds: Partial<Record<SoundName, Sound>> = {} | |
| 58 | ||
| 59 | function loadSettings(): FeedbackSettings { | |
| 60 | try { | |
| 61 | const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}') as Partial<FeedbackSettings> | |
| 62 | return { sound: saved.sound !== false, vibration: saved.vibration !== false } | |
| 63 | } catch { | |
| 64 | return { sound: true, vibration: true } | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | export function feedbackSettings(): FeedbackSettings { | |
| 69 | return { ...settings } | |
| 70 | } | |
| 71 | ||
| 72 | export function setFeedbackSettings(next: FeedbackSettings): void { | |
| 73 | settings = { ...next } | |
| 74 | try { | |
| 75 | localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) | |
| 76 | } catch { | |
| 77 | // Not remembered (private browsing): it still holds until the page closes. | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /** Whether this browser can vibrate at all; the menu only offers the switch where it can. */ | |
| 82 | export function canVibrate(): boolean { | |
| 83 | return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function' | |
| 84 | } | |
| 85 | ||
| 86 | /** Loads every sound; after LittleJS has started (it makes the audio context). */ | |
| 87 | export function initFeedback(): void { | |
| 88 | sounds = Object.fromEntries(Object.entries(SOUND_SOURCES).map(([name, source]) => [name, new Sound(source())])) as typeof sounds | |
| 89 | // Every button of the UI clicks (the action bar, the dialogs, the banner). | |
| 90 | document.addEventListener('click', (e) => { | |
| 91 | if ((e.target as Element | null)?.closest?.('button')) play('tap', 0.5) | |
| 92 | }) | |
| 93 | } | |
| 94 | ||
| 95 | export function play(name: SoundName, volume = 1, pitch = 1, delay = 0): void { | |
| 96 | if (!settings.sound) return | |
| 97 | const sound = sounds[name] | |
| 98 | if (!sound) return | |
| 99 | if (delay > 0) window.setTimeout(() => settings.sound && sound.play(undefined, volume, pitch), delay * 1000) | |
| 100 | else sound.play(undefined, volume, pitch) | |
| 101 | } | |
| 102 | ||
| 103 | export function vibrate(pattern: number | number[], delay = 0): void { | |
| 104 | if (!settings.vibration || !canVibrate()) return | |
| 105 | const buzz = () => { | |
| 106 | // False: the browser refused (no tap on the page yet, or vibration blocked); said in the | |
| 107 | // console, which `npm run dev` prints in its terminal, since a phone shows nothing. | |
| 108 | if (settings.vibration && !navigator.vibrate(pattern)) console.warn(`navigator.vibrate(${JSON.stringify(pattern)}) was refused`) | |
| 109 | } | |
| 110 | if (delay > 0) window.setTimeout(buzz, delay * 1000) | |
| 111 | else buzz() | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * The sounds and buzzes of a turn the player played, each timed with its animation: a blast, and | |
| 116 | * what it kills, when it shows (an ICBM's when it lands: `fx.blastAt`); a pickup when the walk | |
| 117 | * there ends. Several blasts in one turn (a chain) make one louder boom, not a pile of them. | |
| 118 | */ | |
| 119 | export function turnFeedback(events: readonly GameEvent[], action: Action, anim: Animation): void { | |
| 120 | const boom = Math.max(0, anim.fx.blastAt - time) | |
| 121 | const arrive = action.id === 'move' || action.id === 'diagonal' || action.id === 'kick' ? WALK_SECONDS * 0.8 : 0 | |
| 122 | const blasts = events.filter((e) => e.type === 'exploded').length | |
| 123 | if (blasts) { | |
| 124 | play('boom', Math.min(1.6, 0.9 + 0.15 * blasts), 1, boom) | |
| 125 | vibrate(Math.min(90, 30 + 15 * blasts), boom) | |
| 126 | } | |
| 127 | if (events.some((e) => e.type === 'enemyDied')) play('enemyDied', 0.7, 1, boom + 0.05) | |
| 128 | let pickedCoins = false | |
| 129 | let pickedCharge = false | |
| 130 | for (const e of events) { | |
| 131 | switch (e.type) { | |
| 132 | case 'bombPlaced': play('bombPlaced'); break | |
| 133 | case 'bombKicked': play('bump'); break | |
| 134 | case 'bombFired': play(action.id === 'icbm' ? 'launch' : 'thrown'); break | |
| 135 | case 'moved': | |
| 136 | if (action.id === 'run' || action.id === 'slide') play('jump', 0.5) | |
| 137 | else if (action.id === 'teleport') play('magic', 0.7, 1.25) | |
| 138 | break | |
| 139 | case 'pickedUp': | |
| 140 | if (e.drop.kind === 'coins') pickedCoins = true | |
| 141 | else pickedCharge = true | |
| 142 | break | |
8edb5cb9 2026-11-01 Pavel Safronov | 143 | case 'powerUpTaken': |
| 144 | case 'gemQueued': play('gem', 1, 1, arrive); vibrate(15, arrive); break | |
0b63d158 2026-10-25 Pavel Safronov | 145 | case 'powerUpChosen': play('switch'); break |
| 146 | case 'bought': play('coin', 1, 0.8); break | |
| 147 | case 'rerolled': play('switch'); break | |
| 148 | case 'exitOpened': play('magic', 0.8, 0.8, boom); break | |
| 149 | case 'shieldBroke': play('bump', 1, 0.7, boom); vibrate(40, boom); break | |
fcf44f32 2026-10-25 Pavel Safronov | 150 | case 'flameBlocked': play('bump', 0.8, 1.3, boom); break |
d3e4ed26 2026-10-25 Pavel Safronov | 151 | case 'frozen': play('magic', 0.8, 0.7); break |
0b63d158 2026-10-25 Pavel Safronov | 152 | case 'enemiesSpawned': play('hurt', 0.8, 0.5, boom); break |
| 153 | case 'decoyPlaced': play('charge'); break | |
| 154 | case 'playerDied': play('hurt', 1, 1, boom); vibrate([60, 40, 120], boom); break | |
| 155 | case 'levelCleared': play('cleared', 1, 1, arrive); vibrate([20, 40, 20], arrive); break | |
| 156 | case 'runWon': play('gem', 1, 1.2, arrive + 0.35); break | |
5f066960 2026-10-26 Pavel Safronov | 157 | // Board objects (PLAN.md §19.3, §19.4). |
| 158 | case 'fireSpread': if (!blasts) play('boom', 0.6, 0.7, boom); break | |
| 159 | case 'barrelLit': play('bump', 0.9, 0.6, boom); break | |
| 160 | case 'pillarCracked': play('bump', 0.7, 1.4, boom); break | |
| 161 | case 'pillarBroke': play('hurt', 0.7, 0.4, boom); vibrate(50, boom); break | |
| 162 | case 'ceilingFell': play('boom', 1.4, 0.45); vibrate([80, 30, 80]); break | |
0b63d158 2026-10-25 Pavel Safronov | 163 | } |
| 164 | } | |
| 165 | if (pickedCoins) { play('coin', 1, 1, arrive); vibrate(10, arrive) } | |
| 166 | if (pickedCharge) { play('charge', 1, 1, arrive); vibrate(10, arrive) } | |
| 167 | } |