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:
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();