collin/mahjong · 51bd1b32
Sound on by default, and don't let a stale preference bury it
Collin Richards · 2026-08-07 09:25 UTC · 51bd1b32a04354194241950c143f586d520cee5d · parent 2f620211 · browse files
modifiedREADME.md+4 −2
| ⋯ 62 unchanged lines | |||
| 63 | 63 | Snapshots live outside the saved state, so an undo does not survive a | |
| 64 | 64 | refresh: what you can take back is what happened while everyone was still | |
| 65 | 65 | watching it happen. | |
| 66 | - | - **Sound** — off by default. A dry clack as a tile goes down, and a distinct | |
| 66 | + | - **Sound** — on by default, muted from the 🔊 button in the middle of the | |
| 67 | + | table or from settings. A dry clack as a tile goes down, and a distinct | |
| 67 | 68 | two-note chime when a claim window opens, which is how a slow player notices | |
| 68 | 69 | their 碰 is available before the next player draws it shut. Everything is | |
| 69 | 70 | synthesised with a few oscillators (`src/game/sound.ts`) rather than sampled, | |
| 70 | 71 | so there is nothing to load and the cue lands on the same frame as the tile. | |
| 71 | - | Toggle it from the 🔊 button next to 復原, or from the settings screen. | |
| 72 | + | Browsers refuse to start audio before the page has been clicked, so the first | |
| 73 | + | sound anyone hears is the deal — triggered by the button that starts it. | |
| 72 | 74 | - **報牌 (voice)** — a second switch under sound calls the game out loud: 碰, | |
| 73 | 75 | 吃, 槓, 胡了, 自摸, and the name of every tile as it is discarded — 三條, | |
| 74 | 76 | 五萬, 東風. A flower says 補花 and then which one. A new call cuts off one | |
| ⋯ 124 unchanged lines | |||
addedsrc/game/prefs.test.ts+57 −0
| 1 | + | import { beforeEach, describe, expect, it } from 'vitest'; | |
| 2 | + | import { DEFAULT_PREFS, loadPrefs, savePrefs } from './prefs'; | |
| 3 | + | ||
| 4 | + | const KEY = 'taiwanese-mahjong/prefs'; | |
| 5 | + | ||
| 6 | + | /** Just enough localStorage to exercise the load/save path under node. */ | |
| 7 | + | function stubStorage() { | |
| 8 | + | const map = new Map<string, string>(); | |
| 9 | + | Object.defineProperty(globalThis, 'localStorage', { | |
| 10 | + | configurable: true, | |
| 11 | + | value: { | |
| 12 | + | getItem: (k: string) => map.get(k) ?? null, | |
| 13 | + | setItem: (k: string, v: string) => void map.set(k, v), | |
| 14 | + | removeItem: (k: string) => void map.delete(k), | |
| 15 | + | }, | |
| 16 | + | }); | |
| 17 | + | return map; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | describe('preferences', () => { | |
| 21 | + | let store: Map<string, string>; | |
| 22 | + | beforeEach(() => { | |
| 23 | + | store = stubStorage(); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | it('round-trips what was saved', () => { | |
| 27 | + | const mine = { ...DEFAULT_PREFS, sound: false, voice: false, volume: 0.2, names: ['a', 'b', 'c', 'd'] }; | |
| 28 | + | savePrefs(mine); | |
| 29 | + | expect(loadPrefs()).toEqual(mine); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | it('starts a stored blob from before the voice pack on the new audio defaults', () => { | |
| 33 | + | // What an older build wrote: no audioGeneration, and voice off. | |
| 34 | + | store.set( | |
| 35 | + | KEY, | |
| 36 | + | JSON.stringify({ | |
| 37 | + | sound: false, | |
| 38 | + | voice: false, | |
| 39 | + | volume: 0.9, | |
| 40 | + | names: ['甲', '乙', '丙', '丁'], | |
| 41 | + | rules: { ...DEFAULT_PREFS.rules, base: 5 }, | |
| 42 | + | }), | |
| 43 | + | ); | |
| 44 | + | const p = loadPrefs(); | |
| 45 | + | expect(p.sound).toBe(DEFAULT_PREFS.sound); | |
| 46 | + | expect(p.voice).toBe(DEFAULT_PREFS.voice); | |
| 47 | + | // ...but what somebody actually chose is theirs, and survives. | |
| 48 | + | expect(p.names).toEqual(['甲', '乙', '丙', '丁']); | |
| 49 | + | expect(p.rules.base).toBe(5); | |
| 50 | + | expect(p.volume).toBe(0.9); | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | it('falls back to defaults on junk', () => { | |
| 54 | + | store.set(KEY, '{not json'); | |
| 55 | + | expect(loadPrefs()).toEqual(DEFAULT_PREFS); | |
| 56 | + | }); | |
| 57 | + | }); |
modifiedsrc/game/prefs.ts+20 −4
| ⋯ 2 unchanged lines | |||
| 3 | 3 | const KEY = 'taiwanese-mahjong/prefs'; | |
| 4 | 4 | ||
| 5 | 5 | /** | |
| 6 | + | * Bump when a new switch should start from its default rather than from | |
| 7 | + | * whatever an older build happened to write. Preferences are saved on load, so | |
| 8 | + | * a switch that shipped off and was then turned on by default would otherwise | |
| 9 | + | * stay off forever for anyone who had already opened the game once. | |
| 10 | + | * | |
| 11 | + | * Only the audio settings are reset; names and house rules are things somebody | |
| 12 | + | * actually chose, and they carry across. | |
| 13 | + | */ | |
| 14 | + | const AUDIO_GENERATION = 2; | |
| 15 | + | ||
| 16 | + | /** | |
| 6 | 17 | * Preferences belong to the table, not to a hand: they outlive a save, survive | |
| 7 | 18 | * 開新局, and are what a fresh `Game` is built from. | |
| 8 | 19 | */ | |
| 9 | 20 | export interface Prefs { | |
| 10 | - | /** Master mute. Off by default — a shared screen should not surprise a room. */ | |
| 21 | + | /** Which set of audio defaults this was last saved against. */ | |
| 22 | + | audioGeneration: number; | |
| 23 | + | /** Master mute. */ | |
| 11 | 24 | sound: boolean; | |
| 12 | 25 | /** 0..1 */ | |
| 13 | 26 | volume: number; | |
| ⋯ 4 unchanged lines | |||
| 18 | 31 | } | |
| 19 | 32 | ||
| 20 | 33 | export const DEFAULT_PREFS: Prefs = { | |
| 21 | - | sound: false, | |
| 34 | + | audioGeneration: AUDIO_GENERATION, | |
| 35 | + | sound: true, | |
| 22 | 36 | volume: 0.7, | |
| 23 | 37 | voice: true, | |
| 24 | 38 | names: DEFAULT_NAMES, | |
| ⋯ 5 unchanged lines | |||
| 30 | 44 | const raw = localStorage.getItem(KEY); | |
| 31 | 45 | if (!raw) return DEFAULT_PREFS; | |
| 32 | 46 | const p = JSON.parse(raw) as Partial<Prefs>; | |
| 47 | + | const stale = p.audioGeneration !== AUDIO_GENERATION; | |
| 33 | 48 | return { | |
| 34 | - | sound: typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound, | |
| 49 | + | audioGeneration: AUDIO_GENERATION, | |
| 50 | + | sound: !stale && typeof p.sound === 'boolean' ? p.sound : DEFAULT_PREFS.sound, | |
| 35 | 51 | volume: clamp(typeof p.volume === 'number' ? p.volume : DEFAULT_PREFS.volume, 0, 1), | |
| 36 | - | voice: typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice, | |
| 52 | + | voice: !stale && typeof p.voice === 'boolean' ? p.voice : DEFAULT_PREFS.voice, | |
| 37 | 53 | names: | |
| 38 | 54 | Array.isArray(p.names) && p.names.length === 4 && p.names.every((n) => typeof n === 'string') | |
| 39 | 55 | ? p.names | |
| ⋯ 17 unchanged lines | |||
modifiedsrc/game/sound.ts+42 −14
| ⋯ 68 unchanged lines | |||
| 69 | 69 | }; | |
| 70 | 70 | ||
| 71 | 71 | const VOICE_DIR = `${import.meta.env.BASE_URL}voice/`; | |
| 72 | + | const encoded = new Map<string, ArrayBuffer>(); | |
| 72 | 73 | const clips = new Map<string, AudioBuffer>(); | |
| 73 | 74 | let voiceOn = false; | |
| 74 | - | let loading: Promise<void> | null = null; | |
| 75 | + | let fetching: Promise<void> | null = null; | |
| 76 | + | let decoding = false; | |
| 75 | 77 | let voiceGain: GainNode | null = null; | |
| 76 | 78 | let speaking: AudioBufferSourceNode[] = []; | |
| 77 | 79 | ||
| 78 | - | /** Fetch and decode the whole pack. Small enough (~300 kB) to do in one go. */ | |
| 79 | - | function loadVoicePack(): Promise<void> { | |
| 80 | - | if (loading) return loading; | |
| 81 | - | loading = (async () => { | |
| 82 | - | const c = audio(); | |
| 83 | - | if (!c) return; | |
| 80 | + | /** | |
| 81 | + | * Fetching needs no AudioContext, which matters: sound is on from the start, | |
| 82 | + | * but a browser will not let the audio hardware wake until the page has been | |
| 83 | + | * clicked. So the bytes come down straight away and are decoded later, on the | |
| 84 | + | * first gesture — by which time the whole pack (~330 kB) is already in hand. | |
| 85 | + | */ | |
| 86 | + | function fetchVoicePack(): Promise<void> { | |
| 87 | + | if (fetching) return fetching; | |
| 88 | + | fetching = (async () => { | |
| 84 | 89 | const names: string[] = await fetch(`${VOICE_DIR}manifest.json`).then((r) => r.json()); | |
| 85 | 90 | await Promise.all( | |
| 86 | 91 | names.map(async (name) => { | |
| 87 | 92 | try { | |
| 88 | - | const buf = await fetch(`${VOICE_DIR}${name}.mp3`).then((r) => r.arrayBuffer()); | |
| 89 | - | clips.set(name, await c.decodeAudioData(buf)); | |
| 93 | + | encoded.set(name, await fetch(`${VOICE_DIR}${name}.mp3`).then((r) => r.arrayBuffer())); | |
| 90 | 94 | } catch { | |
| 91 | 95 | // One missing clip just means that one call stays silent. | |
| 92 | 96 | } | |
| 93 | 97 | }), | |
| 94 | 98 | ); | |
| 95 | 99 | })().catch(() => { | |
| 96 | - | loading = null; // let a later toggle try again | |
| 100 | + | fetching = null; // let a later toggle try again | |
| 97 | 101 | }); | |
| 98 | - | return loading; | |
| 102 | + | return fetching; | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /** Decode what was fetched. Runs once, the first time there is a live context. */ | |
| 106 | + | function decodeVoicePack(c: AudioContext) { | |
| 107 | + | if (decoding || clips.size || !encoded.size) return; | |
| 108 | + | decoding = true; | |
| 109 | + | for (const [name, bytes] of encoded) { | |
| 110 | + | c.decodeAudioData(bytes) | |
| 111 | + | .then((buf) => clips.set(name, buf)) | |
| 112 | + | .catch(() => {}); | |
| 113 | + | } | |
| 114 | + | encoded.clear(); // decodeAudioData takes ownership of the buffers | |
| 99 | 115 | } | |
| 100 | 116 | ||
| 101 | 117 | export function setVoiceEnabled(on: boolean) { | |
| 102 | 118 | voiceOn = on; | |
| 103 | - | if (on) void loadVoicePack(); | |
| 119 | + | if (on) void fetchVoicePack(); | |
| 104 | 120 | } | |
| 105 | 121 | ||
| 106 | 122 | /** | |
| ⋯ 4 unchanged lines | |||
| 111 | 127 | function say(...names: string[]) { | |
| 112 | 128 | const c = audio(); | |
| 113 | 129 | if (!voiceOn || !enabled || !c || !master) return; | |
| 114 | - | if (!clips.size) return void loadVoicePack(); | |
| 130 | + | if (!clips.size) { | |
| 131 | + | // Nothing to say yet: either still coming down the wire, or waiting for | |
| 132 | + | // this very gesture to be allowed to decode. | |
| 133 | + | decodeVoicePack(c); | |
| 134 | + | void fetchVoicePack(); | |
| 135 | + | return; | |
| 136 | + | } | |
| 115 | 137 | ||
| 116 | 138 | for (const src of speaking) { | |
| 117 | 139 | try { | |
| ⋯ 26 unchanged lines | |||
| 144 | 166 | ||
| 145 | 167 | export function setSoundEnabled(on: boolean) { | |
| 146 | 168 | enabled = on; | |
| 147 | - | if (on) audio(); // unlock now, while we are still inside the click that toggled it | |
| 169 | + | // Reach for the hardware only once the page has been interacted with — | |
| 170 | + | // sound is on from the start, and a context built before that is refused | |
| 171 | + | // anyway. When this runs from the click that toggled it, it unlocks there. | |
| 172 | + | if (on && navigator.userActivation?.hasBeenActive !== false) { | |
| 173 | + | const c = audio(); | |
| 174 | + | if (c) decodeVoicePack(c); | |
| 175 | + | } | |
| 148 | 176 | } | |
| 149 | 177 | ||
| 150 | 178 | export function setVolume(v: number) { | |
| ⋯ 130 unchanged lines | |||