kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

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:
Mgames/fire-truck/game.js | 176++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mgames/fire-truck/index.html | 15++++++++++++++-
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 &nbsp;|&nbsp; S = siren &nbsp;|&nbsp; 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>