kgames

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

commit 9f8aae03c3ed6b82ab37b0e80c93c11a84a5fc34
parent 2da226aa27cee1a14e3e3f978712e1f53b42c19f
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Sat, 25 Apr 2026 16:34:06 -0700

Add progress bar, win screen, and fireworks to letter-find

Track letters found toward a goal of 10 with a dot progress bar.
On completion, play a victory fanfare, launch fireworks, and show
Play Again / Main Menu buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Diffstat:
Mgames/letter-find/game.js | 248++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 226 insertions(+), 22 deletions(-)

diff --git a/games/letter-find/game.js b/games/letter-find/game.js @@ -1,6 +1,7 @@ const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6']; const TINTS = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6]; const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const GOAL = 10; class LetterFindScene extends Phaser.Scene { constructor() { @@ -12,6 +13,10 @@ class LetterFindScene extends Phaser.Scene { this.guessTexts = []; this.accepting = false; this.firstRound = true; + this.lettersFound = 0; + this.winShowing = false; + this.progressGfx = null; + this.winElements = []; this.audioCtx = null; this.musicMuted = false; this.sfxMuted = false; @@ -37,6 +42,8 @@ class LetterFindScene extends Phaser.Scene { const W = this.scale.width; const H = this.scale.height; + this.progressGfx = this.add.graphics(); + this.instrText = this.add.text(W / 2, 0, 'Find and press the matching letter', { fontFamily: 'Fredoka, sans-serif', color: '#888888', @@ -63,11 +70,12 @@ class LetterFindScene extends Phaser.Scene { const targetSize = Math.max(48, Math.floor(H * 0.44)); this.instrText.setStyle({ fontSize: instrSize + 'px' }); - this.instrText.setPosition(W / 2, H * 0.11); + this.instrText.setPosition(W / 2, H * 0.15); this.targetText.setStyle({ fontSize: targetSize + 'px' }); - this.targetText.setPosition(W / 2, H * 0.44); + this.targetText.setPosition(W / 2, H * 0.47); + this.drawProgressBar(W, H); this.reflowGuesses(W, H); } @@ -75,6 +83,28 @@ class LetterFindScene extends Phaser.Scene { this.applyLayout(gameSize.width, gameSize.height); } + drawProgressBar(W, H) { + const gfx = this.progressGfx; + gfx.clear(); + + const dotR = Math.max(8, Math.floor(H * 0.022)); + const spacing = dotR * 3; + const totalW = spacing * (GOAL - 1); + const startX = W / 2 - totalW / 2; + const y = H * 0.07; + + for (let i = 0; i < GOAL; i++) { + const x = startX + i * spacing; + if (i < this.lettersFound) { + gfx.fillStyle(0xFFD23F, 1); + gfx.fillCircle(x, y, dotR); + } else { + gfx.lineStyle(Math.max(2, Math.floor(dotR * 0.3)), 0x555577, 1); + gfx.strokeCircle(x, y, dotR); + } + } + } + newTarget() { let char, color; do { char = CHARS[Phaser.Math.Between(0, CHARS.length - 1)]; } @@ -104,6 +134,7 @@ class LetterFindScene extends Phaser.Scene { } onKey(event) { + if (this.winShowing) return; if (!this.accepting) return; if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return; @@ -123,17 +154,24 @@ class LetterFindScene extends Phaser.Scene { this.playSuccessJingle(); this.burstConfetti(); + this.lettersFound++; + this.drawProgressBar(this.scale.width, this.scale.height); + this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); }); this.guessTexts = []; - this.time.delayedCall(950, () => { - this.newTarget(); - const { width: W, height: H } = this.scale; - this.applyLayout(W, H); - }); + if (this.lettersFound >= GOAL) { + this.time.delayedCall(700, () => this.showWinScreen()); + } else { + this.time.delayedCall(950, () => { + this.newTarget(); + const { width: W, height: H } = this.scale; + this.applyLayout(W, H); + }); + } } onWrong(char) { @@ -146,7 +184,7 @@ class LetterFindScene extends Phaser.Scene { const { width: W, height: H } = this.scale; const size = Math.max(20, Math.floor(H * 0.12)); - const text = this.add.text(W / 2, H * 0.76, char, { + const text = this.add.text(W / 2, H * 0.78, char, { fontFamily: 'Fredoka, sans-serif', fontSize: size + 'px', fontStyle: 'bold', @@ -185,14 +223,14 @@ class LetterFindScene extends Phaser.Scene { const spacing = size * 1.15; const startX = W / 2 - ((n - 1) * spacing) / 2; this.guessTexts.forEach((obj, i) => { - obj.setPosition(startX + i * spacing, H * 0.76); + obj.setPosition(startX + i * spacing, H * 0.78); obj.setStyle({ fontSize: size + 'px' }); }); } burstConfetti() { const { width: W, height: H } = this.scale; - const emitter = this.add.particles(W / 2, H * 0.44, 'sq', { + const emitter = this.add.particles(W / 2, H * 0.47, 'sq', { speed: { min: 100, max: 350 }, angle: { min: 0, max: 360 }, scale: { start: 1.2, end: 0 }, @@ -205,49 +243,181 @@ class LetterFindScene extends Phaser.Scene { this.time.delayedCall(1050, () => { if (emitter.active) emitter.destroy(); }); } - // ── Music ──────────────────────────────────────────────────────────── + // ── Win Screen ──────────────────────────────────────────────────────── + + showWinScreen() { + this.winShowing = true; + this.accepting = false; + + const { width: W, height: H } = this.scale; + + this.playVictorySound(); + this.launchFireworks(); + + const bg = this.add.graphics(); + bg.fillStyle(0x000000, 0.78); + bg.fillRect(0, 0, W, H); + this.winElements.push(bg); + + const titleSize = Math.max(32, Math.floor(H * 0.1)); + const title = this.add.text(W / 2, H * 0.28, 'You did it!', { + fontFamily: 'Fredoka, sans-serif', + fontSize: titleSize + 'px', + fontStyle: 'bold', + color: '#FFD23F', + }).setOrigin(0.5).setAlpha(0); + this.winElements.push(title); + this.tweens.add({ targets: title, alpha: 1, duration: 400, ease: 'Sine.In' }); + + const subSize = Math.max(16, Math.floor(H * 0.055)); + const sub = this.add.text(W / 2, H * 0.38, 'You found all 10 letters!', { + fontFamily: 'Fredoka, sans-serif', + fontSize: subSize + 'px', + color: '#ffffff', + }).setOrigin(0.5).setAlpha(0); + this.winElements.push(sub); + this.tweens.add({ targets: sub, alpha: 1, duration: 400, delay: 200, ease: 'Sine.In' }); + + const btnW = Math.min(300, W * 0.55); + const btnH = Math.max(52, H * 0.09); + const btnSz = Math.max(20, Math.floor(H * 0.06)); + + // Play Again button + const y1 = H * 0.57; + const btn1 = this.add.graphics(); + const drawBtn1 = (color) => { + btn1.clear(); + btn1.fillStyle(color, 1); + btn1.fillRoundedRect(W / 2 - btnW / 2, y1 - btnH / 2, btnW, btnH, 14); + }; + drawBtn1(0x2EC4B6); + this.winElements.push(btn1); + + const t1 = this.add.text(W / 2, y1, 'Play Again', { + fontFamily: 'Fredoka, sans-serif', + fontSize: btnSz + 'px', + fontStyle: 'bold', + color: '#ffffff', + }).setOrigin(0.5); + this.winElements.push(t1); + + const z1 = this.add.zone(W / 2, y1, btnW, btnH).setInteractive({ useHandCursor: true }); + z1.on('pointerover', () => drawBtn1(0x3ED4CE)); + z1.on('pointerout', () => drawBtn1(0x2EC4B6)); + z1.on('pointerup', () => this.resetGame()); + this.winElements.push(z1); + + // Main Menu button + const y2 = H * 0.72; + const btn2 = this.add.graphics(); + const drawBtn2 = (color) => { + btn2.clear(); + btn2.fillStyle(color, 1); + btn2.fillRoundedRect(W / 2 - btnW / 2, y2 - btnH / 2, btnW, btnH, 14); + }; + drawBtn2(0x333355); + this.winElements.push(btn2); + + const t2 = this.add.text(W / 2, y2, 'Main Menu', { + fontFamily: 'Fredoka, sans-serif', + fontSize: btnSz + 'px', + fontStyle: 'bold', + color: '#cccccc', + }).setOrigin(0.5); + this.winElements.push(t2); + + const z2 = this.add.zone(W / 2, y2, btnW, btnH).setInteractive({ useHandCursor: true }); + z2.on('pointerover', () => drawBtn2(0x44446A)); + z2.on('pointerout', () => drawBtn2(0x333355)); + z2.on('pointerup', () => { window.location.href = '../../index.html'; }); + this.winElements.push(z2); + } + + launchFireworks() { + const { width: W, height: H } = this.scale; + const bursts = [ + [W * 0.3, H * 0.30, 0], + [W * 0.7, H * 0.25, 350], + [W * 0.5, H * 0.20, 700], + [W * 0.2, H * 0.35, 1050], + [W * 0.8, H * 0.30, 1400], + [W * 0.5, H * 0.40, 1750], + [W * 0.35, H * 0.22, 2100], + [W * 0.65, H * 0.32, 2450], + ]; + bursts.forEach(([x, y, delay]) => { + this.time.delayedCall(delay, () => { + if (!this.scene.isActive()) return; + const em = this.add.particles(x, y, 'sq', { + speed: { min: 60, max: 280 }, + angle: { min: 0, max: 360 }, + scale: { start: 1.6, end: 0 }, + gravityY: 120, + lifespan: 1400, + tint: TINTS, + explode: true, + quantity: 55, + }); + this.time.delayedCall(1600, () => { if (em.active) em.destroy(); }); + }); + }); + } + + resetGame() { + this.winElements.forEach(el => { if (el.active) el.destroy(); }); + this.winElements = []; + this.winShowing = false; + + this.guessTexts.forEach(obj => { if (obj.active) obj.destroy(); }); + this.guessTexts = []; + + this.lettersFound = 0; + this.firstRound = true; + this.targetChar = null; + this.targetColor = null; + + const { width: W, height: H } = this.scale; + this.drawProgressBar(W, H); + this.newTarget(); + this.applyLayout(W, H); + } + + // ── Music ───────────────────────────────────────────────────────────── initMusic() { if (this.musicReady) return; this.musicReady = true; - // Single gain node controls all Tone.js audio (the music mute target) this.bgGain = new Tone.Gain(1).toDestination(); - // Background melody: square wave = chiptune feel this.melodySynth = new Tone.PolySynth(Tone.Synth, { oscillator: { type: 'square' }, envelope: { attack: 0.01, decay: 0.05, sustain: 0.25, release: 0.08 }, }).connect(this.bgGain); this.melodySynth.volume.value = -18; - // Bass: softer triangle under the melody this.bassSynth = new Tone.Synth({ oscillator: { type: 'triangle' }, envelope: { attack: 0.02, decay: 0.12, sustain: 0.4, release: 0.2 }, }).connect(this.bgGain); this.bassSynth.volume.value = -22; - // Jingle synth: triangle for a bell-like overlay on success this.jingleSynth = new Tone.Synth({ oscillator: { type: 'triangle' }, envelope: { attack: 0.01, decay: 0.08, sustain: 0.3, release: 0.15 }, }).connect(this.bgGain); this.jingleSynth.volume.value = -20; - // Fail synth: square, brief chromatic stumble on wrong guess this.failSynth = new Tone.Synth({ oscillator: { type: 'square' }, envelope: { attack: 0.01, decay: 0.06, sustain: 0.15, release: 0.06 }, }).connect(this.bgGain); this.failSynth.volume.value = -18; - // Happy 2-bar melody loop in C major (8th notes, 16 steps) const melody = [ 'C5','E5','G5','A5','G5','E5','C5','E5', 'F5','A5','C6','A5','G5','B4','C5','G4', ]; - // Diatonic thirds above each melody note — played as harmony on success const harmony = [ 'E5','G5','B5','C6','B5','G5','E5','G5', 'A5','C6','E6','C6','B5','D5','E5','B4', @@ -262,7 +432,6 @@ class LetterFindScene extends Phaser.Scene { } }, melody, '8n').start(0); - // Bass roots on beats 1 of each bar const bass = [ 'C3',null,null,null,null,null,null,null, 'F2',null,null,null,'G2',null,null,null, @@ -277,13 +446,11 @@ class LetterFindScene extends Phaser.Scene { Tone.start().then(() => Tone.Transport.start()); } - // Arms the harmony layer for the next 8 melody steps playSuccessJingle() { if (!this.musicReady) return; this.successStepsLeft = 8; } - // Descending chromatic stumble — contrasts with the low bong playFailLick() { if (!this.musicReady) return; const start = Tone.Transport.nextSubdivision('8n'); @@ -308,7 +475,7 @@ class LetterFindScene extends Phaser.Scene { btn.classList.toggle('muted', this.sfxMuted); } - // ── WebAudio SFX ───────────────────────────────────────────────────── + // ── WebAudio SFX ────────────────────────────────────────────────────── getAudioCtx() { if (!this.audioCtx) { @@ -357,6 +524,43 @@ class LetterFindScene extends Phaser.Scene { } catch (_) {} } + playVictorySound() { + if (this.sfxMuted) return; + try { + const ctx = this.getAudioCtx(); + const notes = [523, 659, 784, 1047, 1319, 1568]; // C5 E5 G5 C6 E6 G6 + notes.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + const t = ctx.currentTime + i * 0.1; + osc.frequency.setValueAtTime(freq, t); + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(0.28, t + 0.03); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5); + osc.start(t); + osc.stop(t + 0.55); + }); + // Sustained final chord + [523, 659, 784, 1047].forEach(freq => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + const t = ctx.currentTime + notes.length * 0.1 + 0.15; + osc.frequency.setValueAtTime(freq, t); + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(0.18, t + 0.05); + gain.gain.exponentialRampToValueAtTime(0.001, t + 1.5); + osc.start(t); + osc.stop(t + 1.6); + }); + } catch (_) {} + } + speak(char) { if (!window.speechSynthesis) return; window.speechSynthesis.cancel();