clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

feat(audio): buffer Kenney effects with persistent mute

Dami committed 6e3be10
commit
6e3be105a3be4960e332fdae2036b3ed040aa93c
parent
0616901233bf0bf0885d92d6e2f7a741817e2040
author
Dami <dami.etoile@gmail.com>
57 files changed, +258 -22
Mpublic/audio/LICENSE.txt55++++---
Apublic/audio/back.oggbin
Apublic/audio/blocked-bounds-0.oggbin
Apublic/audio/blocked-bounds-1.oggbin
Apublic/audio/blocked-bounds-2.oggbin
Apublic/audio/blocked-bounds-3.oggbin
Apublic/audio/blocked-bounds-4.oggbin
Apublic/audio/blocked-stop-0.oggbin
Apublic/audio/blocked-stop-1.oggbin
Apublic/audio/blocked-stop-2.oggbin
Apublic/audio/blocked-stop-3.oggbin
Apublic/audio/blocked-stop-4.oggbin
Apublic/audio/confirm.oggbin
Apublic/audio/defeat-0.oggbin
Apublic/audio/defeat-1.oggbin
Apublic/audio/defeat-2.oggbin
Apublic/audio/defeat-3.oggbin
Apublic/audio/defeat-4.oggbin
Apublic/audio/move-0.oggbin
Apublic/audio/move-1.oggbin
Apublic/audio/move-2.oggbin
Apublic/audio/move-3.oggbin
Apublic/audio/move-4.oggbin
Apublic/audio/push-0.oggbin
Apublic/audio/push-1.oggbin
Apublic/audio/push-2.oggbin
Apublic/audio/push-3.oggbin
Apublic/audio/push-4.oggbin
Apublic/audio/restart-0.oggbin
Apublic/audio/restart-1.oggbin
Apublic/audio/restart-2.oggbin
Apublic/audio/restart-3.oggbin
Apublic/audio/restart-4.oggbin
Apublic/audio/rule-broken-0.oggbin
Apublic/audio/rule-broken-1.oggbin
Apublic/audio/rule-broken-2.oggbin
Apublic/audio/rule-broken-3.oggbin
Apublic/audio/rule-formed-0.oggbin
Apublic/audio/rule-formed-1.oggbin
Apublic/audio/rule-formed-2.oggbin
Apublic/audio/rule-formed-3.oggbin
Apublic/audio/scene.oggbin
Apublic/audio/select.oggbin
Apublic/audio/sink-0.oggbin
Apublic/audio/sink-1.oggbin
Apublic/audio/sink-2.oggbin
Apublic/audio/sink-3.oggbin
Apublic/audio/transform-0.oggbin
Apublic/audio/transform-1.oggbin
Apublic/audio/undo-0.oggbin
Apublic/audio/undo-1.oggbin
Apublic/audio/undo-2.oggbin
Apublic/audio/undo-empty-0.oggbin
Apublic/audio/undo-empty-1.oggbin
Apublic/audio/undo-empty-2.oggbin
Apublic/audio/undo-empty-3.oggbin
Asrc/audio.js225++++++++++++++++++++++++++++++
M public/audio/LICENSE.txt +33 -22
@@ -1,22 +1,33 @@
1- 2- 3- UI Pack (2.0) 4- 5- Created/distributed by Kenney (www.kenney.nl) 6- Creation date: 12-06-2024 7- 8- ------------------------------ 9- 10- License: (Creative Commons Zero, CC0) 11- http://creativecommons.org/publicdomain/zero/1.0/ 12- 13- This content is free to use in personal, educational and commercial projects. 14- Support us by crediting Kenney or www.kenney.nl (this is not mandatory) 15- 16- ------------------------------ 17- 18- Donate: http://support.kenney.nl 19- Patreon: http://patreon.com/kenney/ 20- 21- Follow on Twitter for updates: 22- http://twitter.com/KenneyNL\ No newline at end of file1+2+3+ UI Pack (2.0)4+5+ Created/distributed by Kenney (www.kenney.nl)6+ Creation date: 12-06-20247+8+ ------------------------------9+10+ License: (Creative Commons Zero, CC0)11+ http://creativecommons.org/publicdomain/zero/1.0/12+13+ This content is free to use in personal, educational and commercial projects.14+ Support us by crediting Kenney or www.kenney.nl (this is not mandatory)15+16+ ------------------------------17+18+ Donate: http://support.kenney.nl19+ Patreon: http://patreon.com/kenney/20+21+ Follow on Twitter for updates:22+ http://twitter.com/KenneyNL23+24+ Additional clips used by Miau Is True25+26+ The move, push, blocked, and defeat clips are from Kenney's Impact Sounds27+ (1.0) pack. The rule, transform, sink, undo, restart, menu, and scene28+ clips are from Kenney's Interface Sounds (1.0) pack. Both packs are CC0:29+ https://creativecommons.org/publicdomain/zero/1.0/30+31+ Source packs:32+ https://kenney.nl/assets/impact-sounds33+ https://kenney.nl/assets/interface-sounds
A public/audio/back.ogg +0 -0

Binary file.

A public/audio/blocked-bounds-0.ogg +0 -0

Binary file.

A public/audio/blocked-bounds-1.ogg +0 -0

Binary file.

A public/audio/blocked-bounds-2.ogg +0 -0

Binary file.

A public/audio/blocked-bounds-3.ogg +0 -0

Binary file.

A public/audio/blocked-bounds-4.ogg +0 -0

Binary file.

A public/audio/blocked-stop-0.ogg +0 -0

Binary file.

A public/audio/blocked-stop-1.ogg +0 -0

Binary file.

A public/audio/blocked-stop-2.ogg +0 -0

Binary file.

A public/audio/blocked-stop-3.ogg +0 -0

Binary file.

A public/audio/blocked-stop-4.ogg +0 -0

Binary file.

A public/audio/confirm.ogg +0 -0

Binary file.

A public/audio/defeat-0.ogg +0 -0

Binary file.

A public/audio/defeat-1.ogg +0 -0

Binary file.

A public/audio/defeat-2.ogg +0 -0

Binary file.

A public/audio/defeat-3.ogg +0 -0

Binary file.

A public/audio/defeat-4.ogg +0 -0

Binary file.

A public/audio/move-0.ogg +0 -0

Binary file.

A public/audio/move-1.ogg +0 -0

Binary file.

A public/audio/move-2.ogg +0 -0

Binary file.

A public/audio/move-3.ogg +0 -0

Binary file.

A public/audio/move-4.ogg +0 -0

Binary file.

A public/audio/push-0.ogg +0 -0

Binary file.

A public/audio/push-1.ogg +0 -0

Binary file.

A public/audio/push-2.ogg +0 -0

Binary file.

A public/audio/push-3.ogg +0 -0

Binary file.

A public/audio/push-4.ogg +0 -0

Binary file.

A public/audio/restart-0.ogg +0 -0

Binary file.

A public/audio/restart-1.ogg +0 -0

Binary file.

A public/audio/restart-2.ogg +0 -0

Binary file.

A public/audio/restart-3.ogg +0 -0

Binary file.

A public/audio/restart-4.ogg +0 -0

Binary file.

A public/audio/rule-broken-0.ogg +0 -0

Binary file.

A public/audio/rule-broken-1.ogg +0 -0

Binary file.

A public/audio/rule-broken-2.ogg +0 -0

Binary file.

A public/audio/rule-broken-3.ogg +0 -0

Binary file.

A public/audio/rule-formed-0.ogg +0 -0

Binary file.

A public/audio/rule-formed-1.ogg +0 -0

Binary file.

A public/audio/rule-formed-2.ogg +0 -0

Binary file.

A public/audio/rule-formed-3.ogg +0 -0

Binary file.

A public/audio/scene.ogg +0 -0

Binary file.

A public/audio/select.ogg +0 -0

Binary file.

A public/audio/sink-0.ogg +0 -0

Binary file.

A public/audio/sink-1.ogg +0 -0

Binary file.

A public/audio/sink-2.ogg +0 -0

Binary file.

A public/audio/sink-3.ogg +0 -0

Binary file.

A public/audio/transform-0.ogg +0 -0

Binary file.

A public/audio/transform-1.ogg +0 -0

Binary file.

A public/audio/undo-0.ogg +0 -0

Binary file.

A public/audio/undo-1.ogg +0 -0

Binary file.

A public/audio/undo-2.ogg +0 -0

Binary file.

A public/audio/undo-empty-0.ogg +0 -0

Binary file.

A public/audio/undo-empty-1.ogg +0 -0

Binary file.

A public/audio/undo-empty-2.ogg +0 -0

Binary file.

A public/audio/undo-empty-3.ogg +0 -0

Binary file.

A src/audio.js +225 -0
@@ -0,0 +1,225 @@
1+/**2+ * Small Web Audio facade for the game shell.3+ *4+ * Audio files are fetched as soon as the module is created, but the5+ * AudioContext is created only from a user gesture. This keeps browsers from6+ * rejecting the context while still having decoded buffers ready for play.7+ */8+9+const DEFAULT_CLIPS = Object.freeze({10+ move: ['move-0.ogg', 'move-1.ogg', 'move-2.ogg', 'move-3.ogg', 'move-4.ogg'],11+ push: ['push-0.ogg', 'push-1.ogg', 'push-2.ogg', 'push-3.ogg', 'push-4.ogg'],12+ blockedStop: ['blocked-stop-0.ogg', 'blocked-stop-1.ogg', 'blocked-stop-2.ogg', 'blocked-stop-3.ogg', 'blocked-stop-4.ogg'],13+ blockedBounds: ['blocked-bounds-0.ogg', 'blocked-bounds-1.ogg', 'blocked-bounds-2.ogg', 'blocked-bounds-3.ogg', 'blocked-bounds-4.ogg'],14+ ruleFormed: ['rule-formed-0.ogg', 'rule-formed-1.ogg', 'rule-formed-2.ogg', 'rule-formed-3.ogg'],15+ ruleBroken: ['rule-broken-0.ogg', 'rule-broken-1.ogg', 'rule-broken-2.ogg', 'rule-broken-3.ogg'],16+ transform: ['transform-0.ogg', 'transform-1.ogg'],17+ sink: ['sink-0.ogg', 'sink-1.ogg', 'sink-2.ogg', 'sink-3.ogg'],18+ defeat: ['defeat-0.ogg', 'defeat-1.ogg', 'defeat-2.ogg', 'defeat-3.ogg', 'defeat-4.ogg'],19+ win: ['win.ogg'],20+ undo: ['undo-0.ogg', 'undo-1.ogg', 'undo-2.ogg'],21+ undoEmpty: ['undo-empty-0.ogg', 'undo-empty-1.ogg', 'undo-empty-2.ogg', 'undo-empty-3.ogg'],22+ restart: ['restart-0.ogg', 'restart-1.ogg', 'restart-2.ogg', 'restart-3.ogg', 'restart-4.ogg'],23+ select: ['select.ogg'],24+ confirm: ['confirm.ogg'],25+ back: ['back.ogg'],26+ scene: ['scene.ogg'],27+});28+29+const ALIASES = Object.freeze({30+ rule: 'ruleFormed',31+ blocked: 'blockedStop',32+ bounds: 'blockedBounds',33+ undoEmpty: 'undoEmpty',34+});35+36+const DEFAULT_VOLUMES = Object.freeze({37+ move: 0.18,38+ push: 0.22,39+ blockedStop: 0.2,40+ blockedBounds: 0.2,41+ ruleFormed: 0.24,42+ ruleBroken: 0.2,43+ transform: 0.24,44+ sink: 0.22,45+ defeat: 0.24,46+ win: 0.3,47+ undo: 0.16,48+ undoEmpty: 0.12,49+ restart: 0.18,50+ select: 0.16,51+ confirm: 0.22,52+ back: 0.16,53+ scene: 0.2,54+});55+56+function readEnabled(storage, key) {57+ try {58+ return storage?.getItem(key) !== '0';59+ } catch {60+ return true;61+ }62+}63+64+function writeEnabled(storage, key, enabled) {65+ try {66+ if (enabled) storage?.removeItem(key);67+ else storage?.setItem(key, '0');68+ } catch {69+ // Storage may be unavailable in private browsing or embedded previews.70+ }71+}72+73+function choose(list, random = Math.random) {74+ return list[Math.floor(random() * list.length)] || list[0];75+}76+77+function availableStorage() {78+ try { return globalThis.localStorage; } catch { return null; }79+}80+81+export { DEFAULT_CLIPS };82+83+export function createAudioSystem({84+ basePath = '/audio/',85+ storage = availableStorage(),86+ storageKey = 'miau.sound.enabled',87+ clips = DEFAULT_CLIPS,88+ AudioContextCtor = globalThis.AudioContext || globalThis.webkitAudioContext,89+ fetchImpl = globalThis.fetch,90+ random = Math.random,91+} = {}) {92+ let enabled = readEnabled(storage, storageKey);93+ let musicEnabled = false;94+ let context = null;95+ let masterGain = null;96+ let unlocking = null;97+ let listenersTarget = null;98+ let removeGestureListeners = null;99+ let disposed = false;100+ const rawBuffers = new Map();101+ const buffers = new Map();102+ const loadErrors = new Map();103+104+ const preload = fetchImpl105+ ? Promise.all(Object.entries(clips).flatMap(([name, files]) => files.map(async file => {106+ try {107+ const response = await fetchImpl(`${basePath.replace(/\/$/, '')}/${file}`);108+ if (!response.ok) throw new Error(`Audio request failed (${response.status})`);109+ rawBuffers.set(file, await response.arrayBuffer());110+ } catch (error) {111+ loadErrors.set(name, error);112+ }113+ }))).then(() => rawBuffers)114+ : Promise.resolve(rawBuffers);115+116+ function setMasterGain() {117+ if (masterGain && context) masterGain.gain.setTargetAtTime(enabled ? 1 : 0, context.currentTime, 0.015);118+ }119+120+ async function unlock() {121+ if (disposed || !AudioContextCtor) return false;122+ if (unlocking) return unlocking;123+ unlocking = (async () => {124+ context ||= new AudioContextCtor();125+ masterGain ||= context.createGain();126+ masterGain.connect(context.destination);127+ setMasterGain();128+ if (context.state === 'suspended') await context.resume();129+ await preload;130+ for (const [name, files] of Object.entries(clips)) {131+ const decoded = [];132+ for (const file of files) {133+ if (buffers.has(file)) {134+ decoded.push(buffers.get(file));135+ continue;136+ }137+ const source = rawBuffers.get(file);138+ if (!source) continue;139+ try {140+ const buffer = await context.decodeAudioData(source.slice(0));141+ buffers.set(file, buffer);142+ decoded.push(buffer);143+ } catch (error) {144+ loadErrors.set(name, error);145+ }146+ }147+ if (decoded.length) buffers.set(name, decoded);148+ }149+ return true;150+ })().catch(() => false).finally(() => { unlocking = null; });151+ return unlocking;152+ }153+154+ function installGestureListeners(target = globalThis.document || globalThis) {155+ if (listenersTarget || !target?.addEventListener) return () => {};156+ listenersTarget = target;157+ const onGesture = () => {158+ void unlock().then(() => {159+ if (!listenersTarget) return;160+ for (const event of ['pointerdown', 'touchstart', 'keydown']) listenersTarget.removeEventListener(event, onGesture, true);161+ listenersTarget = null;162+ removeGestureListeners = null;163+ });164+ };165+ for (const event of ['pointerdown', 'touchstart', 'keydown']) target.addEventListener(event, onGesture, { capture: true, passive: true });166+ removeGestureListeners = () => {167+ if (listenersTarget !== target) return;168+ for (const event of ['pointerdown', 'touchstart', 'keydown']) target.removeEventListener(event, onGesture, true);169+ listenersTarget = null;170+ removeGestureListeners = null;171+ };172+ return removeGestureListeners;173+ }174+175+ function play(name, { volume = DEFAULT_VOLUMES[name] ?? 0.2, rate = 1, loop = false, offset = 0 } = {}) {176+ const resolved = ALIASES[name] || name;177+ if (!enabled || disposed) return false;178+ if (!context || !masterGain) {179+ void unlock().then(() => play(name, { volume, rate, loop, offset }));180+ return false;181+ }182+ const available = buffers.get(resolved);183+ if (!available?.length) return false;184+ const source = context.createBufferSource();185+ const gain = context.createGain();186+ source.buffer = choose(available, random);187+ source.playbackRate.value = rate;188+ source.loop = loop;189+ gain.gain.value = volume;190+ source.connect(gain).connect(masterGain);191+ source.start(0, offset);192+ return true;193+ }194+195+ function mute(value = true) {196+ enabled = !value;197+ writeEnabled(storage, storageKey, enabled);198+ setMasterGain();199+ return !enabled;200+ }201+202+ function toggleMute() {203+ return mute(enabled);204+ }205+206+ function dispose() {207+ disposed = true;208+ removeGestureListeners?.();209+ return context?.close?.();210+ }211+212+ return {213+ preload,214+ unlock,215+ installGestureListeners,216+ play,217+ mute,218+ toggleMute,219+ isMuted: () => !enabled,220+ isMusicEnabled: () => musicEnabled,221+ setMusicEnabled: value => { musicEnabled = Boolean(value); return musicEnabled; },222+ getStatus: () => ({ enabled, muted: !enabled, unlocked: Boolean(context), ready: buffers.size > 0, errors: [...loadErrors.keys()] }),223+ dispose,224+ };225+}