commit d92b512da587404a0a86a6b76a896589b7fb8720
parent 4d77e6f239b086436d5f77522cc9cd3b80248e6e
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Tue, 15 Sep 2026 09:04:34 -0700
Fire truck: make music and siren opt-in so they actually play
Music kept failing to start for some players (most recently Edge on
macOS). The old flow created Tone.js's AudioContext at script load and
tried to resume it opportunistically from whatever gesture came along;
a browser that refuses to revive a context created outside an activating
event left the game silent for the whole session, with nothing the
player could do about it.
Turn it around: nothing makes a sound until asked.
- Music and siren both start OFF. M toggles music, S the siren, so the
first audio work a browser ever sees happens inside a real keypress.
- Instructions where they'll be seen: the HUD hint, a live on-canvas
status line (OFF -> starting... -> ON), and "Press M for music" on
the end screen.
- A once-a-second watchdog re-nudges the Tone transport if the player
asked for music but it isn't running, so a late context resume still
ends in sound rather than silence.
- Settings-panel Music checkbox starts unchecked and drives the same
path; the touch bar gains a Music button; the end scene inherits the
player's choice instead of starting the party music on its own.
Also fixes a pre-existing flaky browser test (it failed ~70% of runs,
independently of this change): story-stamp's review test served one
book and then expected that word back, but pickNext never serves the
immediately-previous word. It only passed when a fallback branch that
skips that check happened to fire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
5 files changed, 237 insertions(+), 51 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -174,7 +174,7 @@ unchanged — game logic never references this module.
`<script>KGames.initTouchControls({ layout: 'alphabet' });</script>`
- Layouts: `alphabet` (A–Z), `text` (A–Z + Space/Backspace/Enter, e.g. work),
`text-nav` (text + arrow row, e.g. story-stamp), `drive` (arrows +
- Siren/Lights/Spray, e.g. fire-truck).
+ Music/Siren/Lights/Spray, e.g. fire-truck).
- Shown only on touch / coarse-pointer devices. Force with `?kbd=1` / `?kbd=0`
or `window.__FORCE_TOUCH_CONTROLS__` (tests use the latter).
- The bar is a flex child below `#game-container`, so the canvas shrinks to fit
@@ -287,6 +287,13 @@ Tint equivalents: `0xE63946 · 0x1D7CF2 · 0xFFD23F · 0x2EC4B6`
auto-resumes). Hand every AudioContext to `KGames.installAudioUnlock(...)`
and do the "audio is up" work in its callback; the `audio:` browser tests
emulate the Safari policy and will catch regressions.
+- **Music is opt-in.** Background music and the fire-truck siren start OFF and
+ are switched on by the player (`M` / `S`, mirrored in the settings panel and
+ the touch bar), so the first audio work a browser ever sees happens inside a
+ real keypress — the one moment every browser (Edge on macOS included) accepts.
+ Fire-truck also runs a once-a-second watchdog (`_audioWatchdog`) that re-nudges
+ a Tone transport that failed to start, and the on-canvas HUD line says whether
+ music is off, starting, or on.
- **Font**: Fredoka, 400 + 600 weight (Google Fonts)
- **Scale**: `Phaser.Scale.RESIZE` + `applyLayout(W, H)` / `onResize` pattern
- **No `console.log` in production code** — browser tests assert zero console errors
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -56,6 +56,7 @@ const ftChd4 = (a, b, c, d) =>
[a, null, null, null, b, null, null, null, c, null, null, null, d, null, null, null];
const ftChd8 = (...cs) => cs.flatMap(c => [c, null]);
+const MUSIC_GAIN = 0.8;
const FT_BPM = 158;
const FT_MEL = {
A: ftN('E5 G5 A5 B5 G5 E5 D5 E5 A4 C5 D5 E5 C5 A4 G4 A4'),
@@ -471,13 +472,23 @@ class FireTruckScene extends Phaser.Scene {
this.manualHintHeading = null;
this.fireDistances = null; // Map road cell -> graph distance to current fire
this._manualLastDist = null;
- this.musicMuted = false;
+ // Music and siren both start OFF and are switched on by the player (M / S).
+ // Browsers only let audio start from a real user gesture, and some (Edge on
+ // macOS, Safari everywhere) never revive a context that was created or
+ // resumed outside one. Starting silent and letting the player ask for sound
+ // means the very first audio work happens inside a keypress, which is the
+ // one moment every browser accepts.
+ this.musicOn = false;
this.sfxMuted = false;
this.sirenOn = false;
this.lightsOn = false;
this._audioInited = false;
this._audioReady = false;
this._audioUnlock = null;
+ this._musicUnlock = null;
+ this._musicStarted = false;
+ this._audioCheckAt = 0;
+ this.audioHudText = null;
this.bgGain = null;
this.melodySynth = null;
this.bassSynth = null;
@@ -553,6 +564,17 @@ class FireTruckScene extends Phaser.Scene {
align: 'center',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(50);
+ // Sound is opt-in: this line is the only instruction the player needs.
+ this.audioHudText = this.add.text(this.scale.width / 2, 112, '', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: '18px',
+ color: '#ffffff',
+ stroke: '#2f2f2f',
+ strokeThickness: 5,
+ align: 'center',
+ }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(50);
+ this._refreshAudioHud();
+
this.uiCamera = this.cameras.add(0, 0, this.scale.width, this.scale.height);
this._addOcean();
@@ -560,7 +582,7 @@ class FireTruckScene extends Phaser.Scene {
this.createTruck();
this.uiCamera.ignore([...this.cityChunks, this.truck]);
- this.cameras.main.ignore([this.overlay, this.successOverlay, this.promptText, this.statusText, this.fpsText, this.fireCountText]);
+ this.cameras.main.ignore([this.overlay, this.successOverlay, this.promptText, this.statusText, this.fpsText, this.fireCountText, this.audioHudText]);
this.currentStep = this.route[0];
const start = this.worldPoint(this.island.routeStart.x, this.island.routeStart.y);
@@ -1349,6 +1371,7 @@ class FireTruckScene extends Phaser.Scene {
this.promptText.setPosition(gameSize.width / 2, gameSize.height - 74);
this.statusText.setPosition(gameSize.width / 2, gameSize.height - 34);
this.fireCountText.setPosition(gameSize.width / 2, 78);
+ if (this.audioHudText) this.audioHudText.setPosition(gameSize.width / 2, 112);
if (this.uiCamera) this.uiCamera.setSize(gameSize.width, gameSize.height);
if (this.ocean) this.ocean.setSize(gameSize.width, gameSize.height);
}
@@ -1374,6 +1397,10 @@ class FireTruckScene extends Phaser.Scene {
this.toggleLights();
return;
}
+ if (event.key === 'm' || event.key === 'M') {
+ this.toggleMusic();
+ return;
+ }
const held = KEY_TO_HEADING[event.key];
if (held && !event.repeat) {
this.heldHeadings = this.heldHeadings.filter((h) => h !== held);
@@ -1406,28 +1433,98 @@ class FireTruckScene extends Phaser.Scene {
if (held) this.heldHeadings = this.heldHeadings.filter((h) => h !== held);
}
- // Creates the audio contexts and arms the shared unlock helper. On phones the
- // first key press arrives from the touch bar's pointerdown, which does not
- // grant user activation, so the contexts start suspended; the helper resumes
- // them from the pointerup/touchend that follows and only then starts the
- // music and any siren the player has already switched on.
+ // Creates the SFX context and arms the shared unlock helper. Called from the
+ // first key press. Nothing is audible yet: music waits for M and the siren
+ // for S, so a browser that refuses audio outside a gesture has nothing to
+ // refuse until the player actually asks for sound.
initAudio() {
if (this._audioInited) return;
this._audioInited = true;
try {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (_) {}
- const toneCtx = KGames.toneContext();
- this._audioUnlock = KGames.installAudioUnlock(() => [this.audioCtx, toneCtx], () => {
+ this._audioUnlock = KGames.installAudioUnlock(() => [this.audioCtx], () => {
this._audioReady = true;
- this.initMusic();
if (this.sirenOn) this._startSiren();
+ this._refreshAudioHud();
});
}
+ // ── Music (opt-in, key M) ─────────────────────────────────────────────────
+ //
+ // Tone.js builds its AudioContext when the script loads — long before any
+ // gesture — so on a strict browser it is born suspended and stays that way
+ // unless something resumes it from inside an activating event. toggleMusic()
+ // is always called from a keydown (or the settings checkbox's click), so it
+ // resumes there and arms a second unlock helper that keeps retrying from
+ // every later gesture. _audioWatchdog() then catches the case where the
+ // context came up but the transport did not.
+ toggleMusic() {
+ this.setMusicEnabled(!this.musicOn);
+ }
+
+ setMusicEnabled(on) {
+ on = !!on;
+ this.musicOn = on;
+ this.initAudio();
+ if (on) {
+ // Inside the gesture: ask Tone directly, then arm the retry helper.
+ try { const p = Tone.start(); if (p && p.catch) p.catch(() => {}); } catch (_) {}
+ if (!this._musicUnlock) {
+ this._musicUnlock = KGames.installAudioUnlock(() => [KGames.toneContext()], () => {
+ this._musicStarted = true;
+ this.initMusic();
+ this._refreshAudioHud();
+ });
+ } else {
+ this._musicUnlock.attempt();
+ }
+ if (this.bgGain) {
+ this.bgGain.gain.value = MUSIC_GAIN;
+ try { Tone.Transport.start(); } catch (_) {}
+ }
+ } else {
+ if (this.bgGain) this.bgGain.gain.value = 0;
+ try { Tone.Transport.pause(); } catch (_) {}
+ }
+ const box = document.getElementById('music-toggle');
+ if (box && box.checked !== on) box.checked = on;
+ this._refreshAudioHud();
+ }
+
+ // Runs ~once a second from update(). If the player asked for music but the
+ // transport is not actually running, nudge it again — cheap insurance against
+ // a browser that resumed the context late or suspended it behind our back.
+ _audioWatchdog(now) {
+ if (now < this._audioCheckAt) return;
+ this._audioCheckAt = now + 1000;
+ if (!this.musicOn) return;
+ if (this._musicUnlock) this._musicUnlock.attempt();
+ if (!this._musicStarted) return;
+ try {
+ if (Tone.Transport.state !== 'started') Tone.Transport.start();
+ } catch (_) {}
+ this._refreshAudioHud();
+ }
+
+ _musicAudible() {
+ if (!this.musicOn || !this._musicStarted) return false;
+ try {
+ return KGames.toneContext().state === 'running' && Tone.Transport.state === 'started';
+ } catch (_) { return false; }
+ }
+
+ _refreshAudioHud() {
+ if (!this.audioHudText) return;
+ const music = this.musicOn ? (this._musicAudible() ? '🎵 Music ON (M)' : '🎵 Music starting… (M)')
+ : '🔇 Music OFF — press M';
+ const siren = this.sirenOn ? '🚨 Siren ON (S)' : '🔕 Siren OFF — press S';
+ this.audioHudText.setText(music + ' · ' + siren);
+ }
+
initMusic() {
if (this.bgGain) return;
- this.bgGain = new Tone.Gain(this.musicMuted ? 0 : 0.8).toDestination();
+ this.bgGain = new Tone.Gain(this.musicOn ? MUSIC_GAIN : 0).toDestination();
this.bassSynth = new Tone.Synth({
oscillator: { type: 'sawtooth' },
@@ -1457,16 +1554,18 @@ class FireTruckScene extends Phaser.Scene {
}, FT_MELODY, '8n').start(0);
Tone.Transport.bpm.value = FT_BPM;
- Tone.Transport.start();
+ if (this.musicOn) Tone.Transport.start();
}
toggleSiren() {
this.sirenOn = !this.sirenOn;
if (this.sirenOn) {
+ if (this._audioUnlock) this._audioUnlock.attempt();
this._startSiren();
} else {
this._stopSiren();
}
+ this._refreshAudioHud();
}
_startSiren() {
@@ -1531,11 +1630,6 @@ class FireTruckScene extends Phaser.Scene {
this.truckLight.setFillStyle(phase === 0 ? 0xe63946 : 0x1d7cf2);
}
- setMusicMuted(muted) {
- this.musicMuted = muted;
- if (this.bgGain) this.bgGain.gain.value = muted ? 0 : 0.8;
- }
-
setSfxMuted(muted) {
this.sfxMuted = muted;
if (this.sirenGainNode && this.audioCtx) {
@@ -1613,6 +1707,7 @@ class FireTruckScene extends Phaser.Scene {
this.ocean.tilePositionY += dt * 4;
}
if (this.ambientReady) this._updateAmbient(dt);
+ this._audioWatchdog(now);
}
step(dt) {
@@ -2175,7 +2270,7 @@ class FireTruckScene extends Phaser.Scene {
// browsers can starve the scene clock, and these transitions must happen.
if (this.firesExtinguished >= FIRES_TO_WIN) {
setTimeout(() => {
- if (this.scene && this.scene.isActive()) this.scene.start('FireTruckEndScene');
+ if (this.scene && this.scene.isActive()) this.scene.start('FireTruckEndScene', { musicOn: this.musicOn });
}, 900);
return;
}
@@ -2223,6 +2318,7 @@ class FireTruckScene extends Phaser.Scene {
this._stopSiren();
this._stopSmoke();
if (this._audioUnlock) { this._audioUnlock.dispose(); this._audioUnlock = null; }
+ if (this._musicUnlock) { this._musicUnlock.dispose(); this._musicUnlock = null; }
if (this.bgGain) { try { Tone.Transport.stop(); } catch (_) {} }
if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; }
if (this.fireGlow) { this.fireGlow.destroy(); this.fireGlow = null; }
@@ -2890,9 +2986,12 @@ class FireTruckEndScene extends Phaser.Scene {
this.load.svg('dolphin-emoji', CDN + '1f42c.svg', { width: 128, height: 128 });
}
- create() {
+ create(data) {
const W = this._W = this.scale.width;
const H = this._H = this.scale.height;
+ // Music stays whatever the player chose in the driving scene — the party
+ // does not get to switch the speakers back on by itself.
+ this._musicOn = !!(data && data.musicOn);
bakePeopleTextures(this);
bakeSideTruckTexture(this);
@@ -3155,7 +3254,57 @@ class FireTruckEndScene extends Phaser.Scene {
btnBg.on('pointerout', () => btnBg.setFillStyle(0xe63946));
btnBg.on('pointerdown', () => window.location.reload());
- // Celebration music — starts immediately since user has already been playing
+ this.add.text(W / 2, btnY + 44, 'Press M for music', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: '16px',
+ color: '#ffffff',
+ stroke: '#2f2f2f',
+ strokeThickness: 4,
+ }).setOrigin(0.5).setDepth(62);
+
+ // Celebration music, but only if the player switched music on while driving
+ // (M toggles it here too).
+ this.input.keyboard.on('keydown-M', () => this._toggleEndMusic());
+ if (this._musicOn) this._startEndMusic();
+
+ // Celebration fireworks + confetti (guarded so a shared-lib load failure
+ // can never throw into the console-error tests).
+ if (window.KGames && KGames.launchFireworks) {
+ try {
+ KGames.bakeFireworksAtlas(this);
+ KGames.launchFireworks(this);
+ KGames.burstConfetti(this, W / 2, H * 0.3);
+ this._fwTimer = this.time.addEvent({
+ delay: 4000, loop: true,
+ callback: () => {
+ if (!this.scene.isActive()) return;
+ KGames.launchFireworks(this);
+ },
+ });
+ } catch (_) {}
+ }
+
+ window.__FT_END_SCENE__ = this;
+ this.events.on('shutdown', this._shutdown, this);
+ this.events.on('destroy', this._shutdown, this);
+ }
+
+ _toggleEndMusic() {
+ this._musicOn = !this._musicOn;
+ if (this._musicOn) {
+ try { const p = Tone.start(); if (p && p.catch) p.catch(() => {}); } catch (_) {}
+ if (!this._endGain) this._startEndMusic();
+ else {
+ this._endGain.gain.value = 0.7;
+ try { Tone.Transport.start(); } catch (_) {}
+ }
+ } else {
+ if (this._endGain) this._endGain.gain.value = 0;
+ try { Tone.Transport.pause(); } catch (_) {}
+ }
+ }
+
+ _startEndMusic() {
try {
this._endGain = new Tone.Gain(0.7).toDestination();
@@ -3189,27 +3338,6 @@ class FireTruckEndScene extends Phaser.Scene {
Tone.Transport.bpm.value = FT_END_BPM;
Tone.Transport.start();
} catch (_) {}
-
- // Celebration fireworks + confetti (guarded so a shared-lib load failure
- // can never throw into the console-error tests).
- if (window.KGames && KGames.launchFireworks) {
- try {
- KGames.bakeFireworksAtlas(this);
- KGames.launchFireworks(this);
- KGames.burstConfetti(this, W / 2, H * 0.3);
- this._fwTimer = this.time.addEvent({
- delay: 4000, loop: true,
- callback: () => {
- if (!this.scene.isActive()) return;
- KGames.launchFireworks(this);
- },
- });
- } catch (_) {}
- }
-
- window.__FT_END_SCENE__ = this;
- this.events.on('shutdown', this._shutdown, this);
- this.events.on('destroy', this._shutdown, this);
}
_shutdown() {
diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html
@@ -132,15 +132,16 @@
</select>
</div>
<div class="setting-row">
- <label><input type="checkbox" id="music-toggle" checked> Music</label>
+ <label><input type="checkbox" id="music-toggle"> Music (key: M)</label>
</div>
<div class="setting-row">
- <label><input type="checkbox" id="sfx-toggle" checked> SFX</label>
+ <label><input type="checkbox" id="sfx-toggle" checked> Sound effects</label>
</div>
</div>
<div id="hud">
<div class="title">Arrow Key Fire Truck</div>
- <div class="hint">Arrows = drive (and aim at the fire!) | SPACE = spray | S = siren | L = lights</div>
+ <div class="hint">Arrows = drive (and aim at the fire!) | SPACE = spray | L = lights</div>
+ <div class="hint"><strong>Sound starts off:</strong> press <strong>M</strong> for music, <strong>S</strong> for the siren.</div>
</div>
<div id="game-container"></div>
<script>
@@ -168,7 +169,8 @@
e.target.blur();
});
document.getElementById('music-toggle').addEventListener('change', (e) => {
- if (window.__FT_SCENE__) window.__FT_SCENE__.setMusicMuted(!e.target.checked);
+ // Fires from a real click, so this is a valid moment to start audio.
+ if (window.__FT_SCENE__) window.__FT_SCENE__.setMusicEnabled(e.target.checked);
});
document.getElementById('sfx-toggle').addEventListener('change', (e) => {
if (window.__FT_SCENE__) window.__FT_SCENE__.setSfxMuted(!e.target.checked);
diff --git a/js/shared/touch-controls.js b/js/shared/touch-controls.js
@@ -12,7 +12,7 @@
//
// Layouts: 'alphabet' (A–Z), 'text' (A–Z + Space/Backspace/Enter), 'text-nav'
// (text + an arrow row, for menu-driven games like sound-soup), 'drive'
-// (arrows + Siren/Lights/Spray for fire-truck).
+// (arrows + Music/Siren/Lights/Spray for fire-truck).
//
// Shown only on touch / coarse-pointer devices. Force on/off with the URL
// param ?kbd=1 / ?kbd=0 or window.__FORCE_TOUCH_CONTROLS__ (used by tests).
@@ -103,6 +103,7 @@
{ label: '▶', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, grow: 1, kind: 'arrow', hold: true, aria: 'Turn right / aim right' },
],
[
+ { label: '🎵 Music', key: 'm', code: 'KeyM', keyCode: 77, grow: 1, kind: 'special', aria: 'Music on/off' },
{ label: '🔊 Siren', key: 's', code: 'KeyS', keyCode: 83, grow: 1, kind: 'special', aria: 'Siren' },
{ label: '💡 Lights', key: 'l', code: 'KeyL', keyCode: 76, grow: 1, kind: 'special', aria: 'Lights' },
{ label: '💦 Spray', key: ' ', code: 'Space', keyCode: 32, grow: 1, kind: 'special', hold: true, aria: 'Spray water' },
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -1078,6 +1078,10 @@ async function main() {
const review = await page.evaluate((w) => {
const g = window.__SS_GAME__, SS = window.SS;
window.__SS_NOW__ = Date.now() + 2 * SS.DAY;
+ // pickNext never serves the immediately-previous word, and after exactly
+ // one book that is the word we want back. Stand in for "some other book
+ // has been served since" so the review itself is what's under test.
+ g.profile.lastWord = null;
let queued = false;
for (let seed = 1; seed <= 12 && !queued; seed++) {
const q = SS.buildQueue(g.profile, g.now(), SS.mulberry32(seed), 3);
@@ -1232,6 +1236,7 @@ async function main() {
transport: Tone.Transport.state,
sirenOn: s.sirenOn,
sirenOsc: !!s.sirenOsc,
+ musicOn: s.musicOn,
};
});
}
@@ -1264,16 +1269,34 @@ async function main() {
await page.waitForSelector('#kg-touch');
await page.waitForFunction(() => !!window.__FT_SCENE__);
await page.waitForTimeout(300);
+ // Nothing may be playing before the player asks for it.
+ const idle = await fireTruckAudioState(page);
+ if (idle.musicOn || idle.music || idle.sirenOn) {
+ throw new Error(`audio should start off: ${JSON.stringify(idle)}`);
+ }
const siren = await touchKey(page, '🔊 Siren');
await siren.tap(); // touchstart/pointerdown (key fires here) -> pointerup/touchend (activation)
await page.waitForFunction(() => window.__FT_SCENE__._audioReady === true, null, { timeout: 4000 })
.catch(async () => { throw new Error(`audio never became ready: ${JSON.stringify(await fireTruckAudioState(page))}`); });
+ const music = await touchKey(page, '🎵 Music');
+ await music.tap();
+ await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 })
+ .catch(async () => { throw new Error(`music never started: ${JSON.stringify(await fireTruckAudioState(page))}`); });
const st = await fireTruckAudioState(page);
if (st.ctxState !== 'running') throw new Error(`SFX context not running: ${JSON.stringify(st)}`);
if (st.toneState !== 'running') throw new Error(`Tone context not running: ${JSON.stringify(st)}`);
if (!st.music) throw new Error(`music not initialised: ${JSON.stringify(st)}`);
if (st.transport !== 'started') throw new Error(`Tone.Transport not started: ${JSON.stringify(st)}`);
if (!st.sirenOn || !st.sirenOsc) throw new Error(`siren not sounding after tap: ${JSON.stringify(st)}`);
+ // A second music tap silences it again.
+ await music.tap();
+ await page.waitForTimeout(100);
+ const quiet = await fireTruckAudioState(page);
+ if (quiet.musicOn || quiet.transport === 'started') {
+ throw new Error(`music did not stop on second tap: ${JSON.stringify(quiet)}`);
+ }
+ await music.tap();
+ await page.waitForTimeout(100);
// A second tap turns the siren off again.
await siren.tap();
await page.waitForTimeout(100);
@@ -1281,16 +1304,41 @@ async function main() {
if (off.sirenOn || off.sirenOsc) throw new Error(`siren did not stop on second tap: ${JSON.stringify(off)}`);
}, { hasTouch: true });
- await test('audio: fire-truck music starts from a physical key press under Safari policy', async (page, url) => {
+ await test('audio: fire-truck music is off until M, then starts under Safari policy', async (page, url) => {
await page.addInitScript(emulateSafariAutoplayPolicy);
await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__FT_SCENE__);
await page.waitForTimeout(300);
+ // Driving around must not start music on its own.
await page.keyboard.press('ArrowUp');
- await page.waitForFunction(() => window.__FT_SCENE__._audioReady === true, null, { timeout: 4000 })
- .catch(async () => { throw new Error(`audio never became ready: ${JSON.stringify(await fireTruckAudioState(page))}`); });
+ await page.waitForTimeout(300);
+ const before = await fireTruckAudioState(page);
+ if (before.musicOn || before.music || before.transport === 'started') {
+ throw new Error(`music started without being asked: ${JSON.stringify(before)}`);
+ }
+ await page.keyboard.press('m');
+ await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 })
+ .catch(async () => { throw new Error(`music never started after M: ${JSON.stringify(await fireTruckAudioState(page))}`); });
const st = await fireTruckAudioState(page);
- if (!st.music || st.transport !== 'started') throw new Error(`music not running: ${JSON.stringify(st)}`);
+ if (!st.musicOn || !st.music || st.transport !== 'started') throw new Error(`music not running: ${JSON.stringify(st)}`);
+ // M again turns it back off.
+ await page.keyboard.press('m');
+ await page.waitForTimeout(100);
+ const off = await fireTruckAudioState(page);
+ if (off.musicOn || off.transport === 'started') throw new Error(`M did not stop music: ${JSON.stringify(off)}`);
+ });
+
+ await test('audio: fire-truck settings checkbox starts music from its click', async (page, url) => {
+ await page.addInitScript(emulateSafariAutoplayPolicy);
+ await page.goto(`${url}/games/fire-truck/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => !!window.__FT_SCENE__);
+ await page.waitForTimeout(300);
+ const checked = await page.$eval('#music-toggle', (el) => el.checked);
+ if (checked) throw new Error('music checkbox should start unchecked');
+ await page.click('#settings-btn');
+ await page.click('#music-toggle');
+ await page.waitForFunction(() => window.__FT_SCENE__._musicStarted === true, null, { timeout: 4000 })
+ .catch(async () => { throw new Error(`music never started from checkbox: ${JSON.stringify(await fireTruckAudioState(page))}`); });
});
await test('audio: letter-find music starts from a touch-bar tap under Safari policy', async (page, url) => {