fuzzyworld

explodorama / files / src / ui / feedback.ts

feedback.ts

7.2 KB · history · blame

1import { Sound, time } from 'littlejsengine'
2import type { Animation } from '../render/animate'
3import { WALK_SECONDS } from '../render/effects'
4import { storageKey } from '../channel'
5import 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 */
16const 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
22function 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
28type SoundName = keyof typeof SOUND_SOURCES
29
30const 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
49const SETTINGS_KEY = storageKey('feedback')
50
51export interface FeedbackSettings {
52 sound: boolean
53 vibration: boolean
54}
55
56let settings: FeedbackSettings = loadSettings()
57let sounds: Partial<Record<SoundName, Sound>> = {}
58
59function 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
68export function feedbackSettings(): FeedbackSettings {
69 return { ...settings }
70}
71
72export 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. */
82export 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). */
87export 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
95export 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
103export 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 */
119export 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}