explodorama / files / src / ui / feedback.ts
feedback.ts
| 1 | import { Sound, time } from 'littlejsengine' |
| 2 | import type { Animation } from '../render/animate' |
| 3 | import { WALK_SECONDS } from '../render/effects' |
| 4 | import { storageKey } from '../channel' |
| 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 | |
| 49 | const SETTINGS_KEY = storageKey('feedback') |
| 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 |
| 143 | case 'powerUpTaken': |
| 144 | case 'gemQueued': play('gem', 1, 1, arrive); vibrate(15, arrive); break |
| 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 |
| 150 | case 'flameBlocked': play('bump', 0.8, 1.3, boom); break |
| 151 | case 'frozen': play('magic', 0.8, 0.7); break |
| 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 |
| 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 |
| 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 | } |