commit ec87ac35dbc46c70c22b81192caa056f01c7c7b8
parent ebdfc6c300fdb1df587bed1308a8ab371f3857da
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Sat, 2 May 2026 11:46:08 -0700
fire-truck: background music, siren (S), flashing lights (L), music/sfx settings
Adds Tone.js background music in E minor at 158 BPM with a driving
sawtooth bass, sparse chord hits, and an urgent square-wave melody.
S toggles a triangle-wave siren (A4–E5 wail, consonant with the key).
L toggles red/blue flashing on the truck's light bar. Settings panel
gains Music and SFX checkboxes (both on by default).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat:
2 files changed, 184 insertions(+), 7 deletions(-)
diff --git a/games/fire-truck/game.js b/games/fire-truck/game.js
@@ -19,6 +19,22 @@ const ASPHALT_COLOR = 0x2a2d34;
const STRIPE_COLOR = 0xffd23f;
const BUILDING_PALETTE = [0xf8d4a5, 0xb8dfe1, 0xf7b4b4, 0xffefb0, 0xcad6f9, 0xdcc7eb];
+const FT_BPM = 158;
+const FT_MELODY = [
+ 'E5','G5','A5','B5','G5','E5','D5','E5',
+ 'A4','C5','D5','E5','C5','A4','G4','A4',
+];
+const FT_BASS = [
+ 'E2','B2','E2','B2','A2','C3','E3','A2',
+ 'G2','B2','G2','D3','A2','E2','A2','E2',
+];
+const FT_CHORDS = [
+ ['E3','G3','B3'], null, null, null,
+ ['A2','C3','E3'], null, null, null,
+ ['G2','B2','D3'], null, null, null,
+ ['A2','C3','E3'], null, null, null,
+];
+
function arrowLabel(dir) {
const ARROWS = { left: '←', right: '→', straight: '↑' };
const NAMES = { left: 'LEFT', right: 'RIGHT', straight: 'STRAIGHT' };
@@ -59,6 +75,19 @@ class FireTruckScene extends Phaser.Scene {
this._stallSince = 0;
this.waterSoundSource = null;
this.waterSoundGain = null;
+ this.musicMuted = false;
+ this.sfxMuted = false;
+ this.sirenOn = false;
+ this.lightsOn = false;
+ this._audioInited = false;
+ this.bgGain = null;
+ this.melodySynth = null;
+ this.bassSynth = null;
+ this.chordSynth = null;
+ this.sirenOsc = null;
+ this.sirenLfo = null;
+ this.sirenGainNode = null;
+ this.truckLight = null;
}
create() {
@@ -274,8 +303,8 @@ class FireTruckScene extends Phaser.Scene {
const body = this.add.rectangle(0, 0, 40, 22, 0xe63946, 1).setStrokeStyle(3, 0x9b1c25, 1);
const cab = this.add.rectangle(11, 0, 14, 16, 0xf6f7fb, 1).setStrokeStyle(2, 0xbcc6d4, 1);
const ladder = this.add.rectangle(-4, 0, 14, 4, 0xffd23f, 1);
- const light = this.add.rectangle(-14, 0, 8, 8, 0x1d7cf2, 1);
- this.truck = this.add.container(0, 0, [body, cab, ladder, light]);
+ this.truckLight = this.add.rectangle(-14, 0, 8, 8, 0x1d7cf2, 1);
+ this.truck = this.add.container(0, 0, [body, cab, ladder, this.truckLight]);
this.truck.setDepth(35);
this.truck.setScale(SCALE);
}
@@ -303,6 +332,15 @@ class FireTruckScene extends Phaser.Scene {
if (this.state === 'firefighting') this.spaceHeld = true;
return;
}
+ if (event.key === 's' || event.key === 'S') {
+ this.initAudio();
+ this.toggleSiren();
+ return;
+ }
+ if (event.key === 'l' || event.key === 'L') {
+ this.toggleLights();
+ return;
+ }
const move = event.key === 'ArrowLeft' ? 'left' : event.key === 'ArrowRight' ? 'right' : event.key === 'ArrowUp' ? 'straight' : null;
if (!move || !this.promptDir) return;
this.initAudio();
@@ -330,14 +368,136 @@ class FireTruckScene extends Phaser.Scene {
}
initAudio() {
- if (this.audioCtx) return;
+ if (this._audioInited) return;
+ this._audioInited = true;
try {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (_) {}
+ Tone.start().then(() => {
+ this.initMusic();
+ if (this.sirenOn) this._startSiren();
+ }).catch(() => {});
+ }
+
+ initMusic() {
+ if (this.bgGain) return;
+ this.bgGain = new Tone.Gain(this.musicMuted ? 0 : 0.8).toDestination();
+
+ this.bassSynth = new Tone.Synth({
+ oscillator: { type: 'sawtooth' },
+ envelope: { attack: 0.005, decay: 0.12, sustain: 0.18, release: 0.08 },
+ }).connect(this.bgGain);
+ this.bassSynth.volume.value = -14;
+
+ this.chordSynth = new Tone.PolySynth(Tone.Synth, {
+ oscillator: { type: 'square' },
+ envelope: { attack: 0.008, decay: 0.1, sustain: 0.12, release: 0.05 },
+ }).connect(this.bgGain);
+ this.chordSynth.volume.value = -22;
+
+ this.melodySynth = new Tone.Synth({
+ oscillator: { type: 'square' },
+ envelope: { attack: 0.005, decay: 0.07, sustain: 0.18, release: 0.04 },
+ }).connect(this.bgGain);
+ this.melodySynth.volume.value = -18;
+
+ let step = 0;
+ new Tone.Sequence((time, note) => {
+ const i = step % FT_MELODY.length;
+ step++;
+ this.melodySynth.triggerAttackRelease(note, '8n', time);
+ this.bassSynth.triggerAttackRelease(FT_BASS[i], '8n', time);
+ if (FT_CHORDS[i]) this.chordSynth.triggerAttackRelease(FT_CHORDS[i], '8n', time);
+ }, FT_MELODY, '8n').start(0);
+
+ Tone.Transport.bpm.value = FT_BPM;
+ Tone.Transport.start();
+ }
+
+ toggleSiren() {
+ this.sirenOn = !this.sirenOn;
+ if (this.sirenOn) {
+ this._startSiren();
+ } else {
+ this._stopSiren();
+ }
+ }
+
+ _startSiren() {
+ if (this.sirenOsc || !this.audioCtx) return;
+ try {
+ const ctx = this.audioCtx;
+ const osc = ctx.createOscillator();
+ osc.type = 'triangle';
+ osc.frequency.value = 550;
+
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.value = 0.7;
+
+ const lfoGain = ctx.createGain();
+ lfoGain.gain.value = 110;
+ lfo.connect(lfoGain);
+ lfoGain.connect(osc.frequency);
+
+ const gainNode = ctx.createGain();
+ gainNode.gain.value = this.sfxMuted ? 0 : 0.12;
+ osc.connect(gainNode);
+ gainNode.connect(ctx.destination);
+
+ lfo.start();
+ osc.start();
+
+ this.sirenOsc = osc;
+ this.sirenLfo = lfo;
+ this.sirenGainNode = gainNode;
+ } catch (_) {}
+ }
+
+ _stopSiren() {
+ if (!this.sirenOsc) return;
+ try {
+ const ctx = this.audioCtx;
+ if (ctx) {
+ this.sirenGainNode.gain.setValueAtTime(this.sirenGainNode.gain.value, ctx.currentTime);
+ this.sirenGainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
+ this.sirenOsc.stop(ctx.currentTime + 0.31);
+ this.sirenLfo.stop(ctx.currentTime + 0.31);
+ }
+ } catch (_) {}
+ this.sirenOsc = null;
+ this.sirenLfo = null;
+ this.sirenGainNode = null;
+ }
+
+ toggleLights() {
+ this.lightsOn = !this.lightsOn;
+ if (!this.lightsOn && this.truckLight) {
+ this.truckLight.setFillStyle(0x1d7cf2);
+ }
+ }
+
+ _updateLightsEffect() {
+ if (!this.truckLight || !this.lightsOn) return;
+ const phase = Math.floor(this.time.now / 166) % 2;
+ 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) {
+ this.sirenGainNode.gain.setValueAtTime(muted ? 0.001 : 0.12, this.audioCtx.currentTime);
+ }
+ if (muted) this._stopWaterSound();
}
playSuccessSound() {
- if (!this.audioCtx) return;
+ if (!this.audioCtx || this.sfxMuted) return;
try {
const ctx = this.audioCtx;
const osc = ctx.createOscillator();
@@ -355,7 +515,7 @@ class FireTruckScene extends Phaser.Scene {
}
playFailSound() {
- if (!this.audioCtx) return;
+ if (!this.audioCtx || this.sfxMuted) return;
try {
const ctx = this.audioCtx;
const osc = ctx.createOscillator();
@@ -463,6 +623,7 @@ class FireTruckScene extends Phaser.Scene {
}
this._updateFireAnimation(this.time.now);
this._updateWaterSpray();
+ this._updateLightsEffect();
this.refreshDebug();
return;
}
@@ -502,6 +663,7 @@ class FireTruckScene extends Phaser.Scene {
this.successOverlay.setAlpha(newAlpha);
}
+ this._updateLightsEffect();
this.refreshDebug();
}
@@ -695,7 +857,7 @@ class FireTruckScene extends Phaser.Scene {
}
_startWaterSound() {
- if (this.waterSoundSource || !this.audioCtx) return;
+ if (this.waterSoundSource || !this.audioCtx || this.sfxMuted) return;
try {
const ctx = this.audioCtx;
const bufferSize = ctx.sampleRate;
@@ -794,6 +956,8 @@ class FireTruckScene extends Phaser.Scene {
this.fallbackTimer = null;
}
this._stopWaterSound();
+ this._stopSiren();
+ if (this.bgGain) { try { Tone.Transport.stop(); } catch (_) {} }
if (this.fireGraphics) { this.fireGraphics.destroy(); this.fireGraphics = null; }
if (this.waterGraphics) { this.waterGraphics.destroy(); this.waterGraphics = null; }
}
diff --git a/games/fire-truck/index.html b/games/fire-truck/index.html
@@ -120,10 +120,16 @@
<option value="50">50 blocks</option>
</select>
</div>
+ <div class="setting-row">
+ <label><input type="checkbox" id="music-toggle" checked> Music</label>
+ </div>
+ <div class="setting-row">
+ <label><input type="checkbox" id="sfx-toggle" checked> SFX</label>
+ </div>
</div>
<div id="hud">
<div class="title">Arrow Key Fire Truck</div>
- <div class="hint">Left arrow = left, up arrow = straight, right arrow = right</div>
+ <div class="hint">Left arrow = left, up arrow = straight, right arrow = right | S = siren | L = lights</div>
</div>
<div id="game-container"></div>
<script>
@@ -145,8 +151,15 @@
window.GAME_FIRE_DISTANCE = parseInt(e.target.value, 10);
e.target.blur();
});
+ document.getElementById('music-toggle').addEventListener('change', (e) => {
+ if (window.__FT_SCENE__) window.__FT_SCENE__.setMusicMuted(!e.target.checked);
+ });
+ document.getElementById('sfx-toggle').addEventListener('change', (e) => {
+ if (window.__FT_SCENE__) window.__FT_SCENE__.setSfxMuted(!e.target.checked);
+ });
</script>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
+ <script src="https://cdn.jsdelivr.net/npm/tone@14.7.77/build/Tone.js"></script>
<script src="lib.js"></script>
<script src="game.js"></script>
</body>