commit b49f541d1a70eb5554ac1a4905ec050c68782106
parent 61a492078f769639adb94e5628161816a49714a9
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Sat, 25 Apr 2026 18:51:25 -0700
Add animal-letter game with shared library extraction
- Extract audio (Tone.js + Web Audio), win screen, fireworks, confetti, and
progress-bar into js/shared/game-shared.js (window.KGames namespace) so both
letter-find and animal-letter share the code without duplication
- Refactor letter-find to consume the shared library; behavior unchanged
- New animal-letter game: 50 Twemoji animals across 17 letters, deck-shuffled
so no animal repeats within a single 10-round game (letters may repeat)
- Three-level hint escalation: image only → image + name (speak name) →
image + name + big colored first letter + "Press X" (speak letter)
- TTS reliability: debounced speak() with 80ms cancel-to-speak delay; exclusive
if/else-if branches in updateHints() so only one thing is spoken per miss
- F-major 32-note melody with big octave leaps for an animal/circus feel
- Twemoji CC-BY 4.0 attribution fixed at bottom-center of screen
- Portal updated; browser + unit tests added and passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat:
10 files changed, 1115 insertions(+), 415 deletions(-)
diff --git a/assets/thumbnails/animal-letter.svg b/assets/thumbnails/animal-letter.svg
@@ -0,0 +1,35 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 130" width="160" height="130">
+ <!-- background rounded rect -->
+ <rect x="8" y="8" width="144" height="114" rx="18" fill="#FFF5F5" stroke="#2EC4B6" stroke-width="4"/>
+
+ <!-- simple cat face (circles + triangles) -->
+ <!-- head -->
+ <circle cx="80" cy="65" r="30" fill="#FFCC4D"/>
+ <!-- ears -->
+ <polygon points="55,42 62,28 69,42" fill="#FFCC4D"/>
+ <polygon points="91,42 98,28 105,42" fill="#FFCC4D"/>
+ <!-- inner ears -->
+ <polygon points="57,41 62,32 67,41" fill="#F4900C"/>
+ <polygon points="93,41 98,32 103,41" fill="#F4900C"/>
+ <!-- eyes -->
+ <ellipse cx="70" cy="60" rx="5" ry="6" fill="#292F33"/>
+ <ellipse cx="90" cy="60" rx="5" ry="6" fill="#292F33"/>
+ <!-- eye shine -->
+ <circle cx="72" cy="58" r="2" fill="#fff"/>
+ <circle cx="92" cy="58" r="2" fill="#fff"/>
+ <!-- nose -->
+ <polygon points="80,68 77,72 83,72" fill="#F4900C"/>
+ <!-- mouth -->
+ <path d="M77,72 Q74,76 70,75" stroke="#292F33" stroke-width="1.5" fill="none" stroke-linecap="round"/>
+ <path d="M83,72 Q86,76 90,75" stroke="#292F33" stroke-width="1.5" fill="none" stroke-linecap="round"/>
+ <!-- whiskers -->
+ <line x1="50" y1="67" x2="68" y2="69" stroke="#292F33" stroke-width="1.2"/>
+ <line x1="50" y1="72" x2="68" y2="71" stroke="#292F33" stroke-width="1.2"/>
+ <line x1="92" y1="69" x2="110" y2="67" stroke="#292F33" stroke-width="1.2"/>
+ <line x1="92" y1="71" x2="110" y2="72" stroke="#292F33" stroke-width="1.2"/>
+
+ <!-- letter A badge -->
+ <circle cx="126" cy="24" r="14" fill="#2EC4B6"/>
+ <text x="126" y="30" font-family="Fredoka, sans-serif" font-size="17" font-weight="600"
+ fill="#ffffff" text-anchor="middle">A</text>
+</svg>
diff --git a/games/animal-letter/game.js b/games/animal-letter/game.js
@@ -0,0 +1,326 @@
+const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6'];
+const GOAL = 10;
+const CDN = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/svg/';
+
+// F major — circus-like leaps and animal energy (32-note loop, ~7.5s cycle at 128 BPM)
+const MELODY = [
+ // bar 1: fanfare — big octave leaps
+ 'F4','C5','F5','C5', 'F4','A4','C5','A4',
+ // bar 2: bounding up to Bb
+ 'Bb4','D5','F5','Bb5', 'F5','D5','Bb4','F4',
+ // bar 3: pivot to C — chase scene
+ 'C5','E5','G5','C6', 'G5','E5','C5','E5',
+ // bar 4: swinging close
+ 'F5','D5','Bb4','F4', 'C5','F5','A5','F5',
+];
+const HARMONY = [
+ // diatonic 3rds above in F major
+ 'A4','E5','A5','E5', 'A4','C5','E5','C5',
+ 'D5','F5','A5','D6', 'A5','F5','D5','A4',
+ 'E5','G5','Bb5','E6', 'Bb5','G5','E5','G5',
+ 'A5','F5','D5','A4', 'E5','A5','C6','A5',
+];
+const BASS = [
+ // 2-bar ostinato (loops independently of 4-bar melody)
+ 'F2', null, null, null, null, null, null, null,
+ 'Bb2',null, null, null, 'C3', null, null, null,
+];
+
+class AnimalLetterScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'AnimalLetterScene' });
+ this.audio = null;
+ this.currentAnimal = null;
+ this.roundColor = null;
+ this.wrongCount = 0;
+ this.accepting = false;
+ this.lettersFound = 0;
+ this.winShowing = false;
+ this.progressGfx = null;
+ this.winElements = [];
+ this.guessTexts = [];
+ this.animalImg = null;
+ this.instrText = null;
+ this.nameText = null;
+ this.hintText = null;
+ this._imgScale = 1;
+ this._deck = []; // shuffled queue; drawn without replacement per game
+ }
+
+ preload() {
+ KGames.bakeFireworksAtlas(this);
+ AnimalLetterLib.ANIMALS.forEach(a => {
+ this.load.svg(a.key, CDN + a.codepoint + '.svg', { width: 256, height: 256 });
+ });
+ }
+
+ create() {
+ window.__AL_SCENE__ = this;
+ const W = this.scale.width;
+ const H = this.scale.height;
+
+ this.audio = new KGames.AudioSystem({ melody: MELODY, harmony: HARMONY, bass: BASS });
+
+ this.progressGfx = this.add.graphics();
+
+ this.instrText = this.add.text(W / 2, 0, '', {
+ fontFamily: 'Fredoka, sans-serif',
+ color: '#888888',
+ }).setOrigin(0.5, 0.5);
+
+ this.animalImg = this.add.image(W / 2, H * 0.43, '__DEFAULT').setVisible(false);
+
+ this.nameText = this.add.text(W / 2, 0, '', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontStyle: 'bold',
+ color: '#ffffff',
+ }).setOrigin(0.5, 0.5).setVisible(false);
+
+ // Large colored first-letter hint shown at hint level 3
+ this.hintText = this.add.text(W / 2, 0, '', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontStyle: 'bold',
+ }).setOrigin(0.5, 0.5).setVisible(false);
+
+ this.scale.on('resize', this.onResize, this);
+ this.input.keyboard.on('keydown', this.onKey, this);
+
+ document.getElementById('btn-music').addEventListener('click', () => this.audio.toggleMusic());
+ document.getElementById('btn-sfx').addEventListener('click', () => this.audio.toggleSfx());
+
+ this.newRound();
+ this.applyLayout(W, H);
+ }
+
+ applyLayout(W, H) {
+ const instrSize = Math.max(16, Math.floor(H * 0.055));
+ const nameSize = Math.max(24, Math.floor(H * 0.075));
+ const hintSize = Math.max(40, Math.floor(H * 0.12));
+ const imgSize = Math.max(80, Math.floor(H * 0.38));
+
+ this.instrText.setStyle({ fontSize: instrSize + 'px' });
+ this.instrText.setPosition(W / 2, H * 0.14);
+
+ this._imgScale = imgSize / 256;
+ if (this.animalImg.visible) {
+ this.animalImg.setPosition(W / 2, H * 0.43).setScale(this._imgScale);
+ }
+
+ this.nameText.setStyle({ fontSize: nameSize + 'px' });
+ this.nameText.setPosition(W / 2, H * 0.70);
+
+ this.hintText.setStyle({ fontSize: hintSize + 'px' });
+ this.hintText.setPosition(W / 2, H * 0.80);
+
+ KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
+ this.reflowGuesses(W, H);
+ }
+
+ onResize(gameSize) {
+ this.applyLayout(gameSize.width, gameSize.height);
+ }
+
+ newRound() {
+ // Refill deck (shuffled, no-repeat) when exhausted
+ if (this._deck.length === 0) {
+ this._deck = AnimalLetterLib.shuffleAnimals(Math.random);
+ }
+ this.currentAnimal = this._deck.pop();
+ this.wrongCount = 0;
+
+ const available = COLORS.filter(c => c !== this.roundColor);
+ this.roundColor = available[Math.floor(Math.random() * available.length)];
+
+ this.nameText.setVisible(false);
+ this.hintText.setVisible(false);
+ this.instrText.setText('Press the first letter').setColor('#888888');
+
+ const { width: W, height: H } = this.scale;
+ const imgSize = Math.max(80, Math.floor(H * 0.38));
+ this._imgScale = imgSize / 256;
+
+ if (this.textures.exists(this.currentAnimal.key)) {
+ this.animalImg
+ .setTexture(this.currentAnimal.key)
+ .setPosition(W / 2, H * 0.43)
+ .setScale(this._imgScale * 0.3)
+ .setVisible(true);
+ this.tweens.add({
+ targets: this.animalImg,
+ scaleX: this._imgScale,
+ scaleY: this._imgScale,
+ ease: 'Back.Out',
+ duration: 250,
+ onComplete: () => { this.accepting = true; },
+ });
+ } else {
+ this.animalImg.setVisible(false);
+ this.accepting = true;
+ }
+
+ this.audio.speak(this.currentAnimal.name);
+ }
+
+ onKey(event) {
+ if (this.winShowing) return;
+ if (!this.accepting) return;
+ if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return;
+
+ this.audio.initMusic();
+
+ const pressed = event.key.toUpperCase();
+ if (pressed === this.currentAnimal.letter) {
+ this.onCorrect();
+ } else {
+ this.onWrong(pressed);
+ }
+ }
+
+ onCorrect() {
+ this.accepting = false;
+ this.audio.playSuccess();
+ this.audio.playSuccessJingle();
+
+ const { width: W, height: H } = this.scale;
+ KGames.burstConfetti(this, W / 2, H * 0.43);
+
+ this.lettersFound++;
+ KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
+
+ this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); });
+ this.guessTexts = [];
+
+ if (this.lettersFound >= GOAL) {
+ this.time.delayedCall(700, () => {
+ this.winShowing = true;
+ this.accepting = false;
+ this.winElements = KGames.showWinScreen(this, {
+ title: 'Amazing!',
+ subtitle: 'You know all the animal letters!',
+ audio: this.audio,
+ onPlayAgain: () => this.resetGame(),
+ });
+ });
+ } else {
+ this.time.delayedCall(950, () => {
+ this.newRound();
+ this.applyLayout(this.scale.width, this.scale.height);
+ });
+ }
+ }
+
+ onWrong(pressed) {
+ this.audio.playBong();
+ this.audio.playFailLick();
+ this.wrongCount++;
+ this.updateHints();
+ this.addGuessLetter(pressed);
+ }
+
+ updateHints() {
+ const animal = this.currentAnimal;
+ const { width: W, height: H } = this.scale;
+ const nameSize = Math.max(24, Math.floor(H * 0.075));
+ const hintSize = Math.max(40, Math.floor(H * 0.12));
+
+ if (this.wrongCount === 1) {
+ // Show name — speak it so the child can sound out the first letter
+ this.nameText
+ .setText(animal.name)
+ .setStyle({ fontSize: nameSize + 'px', color: '#ffffff' })
+ .setPosition(W / 2, H * 0.70)
+ .setVisible(true);
+ this.audio.speak(animal.name);
+ } else if (this.wrongCount >= 2) {
+ // Show big colored first letter + update instruction — speak just the letter
+ this.hintText
+ .setText(animal.letter)
+ .setStyle({ fontSize: hintSize + 'px', color: this.roundColor })
+ .setPosition(W / 2, H * 0.80)
+ .setVisible(true);
+ this.instrText.setText('Press ' + animal.letter).setColor(this.roundColor);
+ this.audio.speak(animal.letter);
+ }
+ }
+
+ addGuessLetter(char) {
+ const { width: W, height: H } = this.scale;
+ const size = Math.max(20, Math.floor(H * 0.10));
+
+ const text = this.add.text(W / 2, H * 0.88, char, {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: size + 'px',
+ fontStyle: 'bold',
+ color: '#cc4444',
+ }).setOrigin(0.5).setAlpha(0).setScale(0.3);
+
+ this.tweens.add({
+ targets: text,
+ scaleX: 1, scaleY: 1, alpha: 1,
+ ease: 'Back.Out',
+ duration: 150,
+ });
+
+ this.guessTexts.push(text);
+ this.reflowGuesses(W, H);
+
+ this.time.delayedCall(1200, () => {
+ if (!text.active) return;
+ this.tweens.add({
+ targets: text,
+ alpha: 0, scaleX: 0.2, scaleY: 0.2,
+ duration: 200,
+ onComplete: () => {
+ text.destroy();
+ this.guessTexts = this.guessTexts.filter(t => t !== text);
+ this.reflowGuesses(this.scale.width, this.scale.height);
+ },
+ });
+ });
+ }
+
+ reflowGuesses(W, H) {
+ const n = this.guessTexts.length;
+ if (n === 0) return;
+ const size = Math.max(20, Math.floor(H * 0.10));
+ 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.88);
+ obj.setStyle({ fontSize: size + 'px' });
+ });
+ }
+
+ 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.currentAnimal = null;
+ this.roundColor = null;
+ this.wrongCount = 0;
+ this._deck = []; // fresh shuffle for new game
+
+ const { width: W, height: H } = this.scale;
+ KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
+ this.newRound();
+ this.applyLayout(W, H);
+ }
+}
+
+new Phaser.Game({
+ type: Phaser.AUTO,
+ parent: 'game-container',
+ backgroundColor: '#1A1A2E',
+ scene: AnimalLetterScene,
+ scale: {
+ mode: Phaser.Scale.RESIZE,
+ width: '100%',
+ height: '100%',
+ },
+ render: { preserveDrawingBuffer: true },
+});
diff --git a/games/animal-letter/index.html b/games/animal-letter/index.html
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Animal Letter — KGames</title>
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600&display=swap" rel="stylesheet">
+ <style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { height: 100%; overflow: hidden; }
+ body {
+ background: #1A1A2E;
+ font-family: 'Fredoka', sans-serif;
+ display: flex;
+ flex-direction: column;
+ }
+ nav { width: 100%; padding: 0.6rem 1.25rem; flex-shrink: 0; }
+ nav a { color: #aaa; text-decoration: none; font-size: 1rem; }
+ nav a:hover { color: #fff; }
+ #game-container { flex: 1; width: 100%; }
+
+ #audio-controls {
+ position: fixed;
+ top: 0.45rem;
+ right: 1rem;
+ display: flex;
+ gap: 0.4rem;
+ z-index: 10;
+ }
+ #audio-controls button {
+ background: rgba(255,255,255,0.08);
+ border: 1px solid rgba(255,255,255,0.18);
+ color: #bbb;
+ font-family: 'Fredoka', sans-serif;
+ font-size: 0.85rem;
+ padding: 0.25rem 0.65rem;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ user-select: none;
+ }
+ #audio-controls button:hover { background: rgba(255,255,255,0.16); color: #fff; }
+ #audio-controls button.muted { color: #555; border-color: rgba(255,255,255,0.08); }
+
+ #attribution {
+ position: fixed;
+ bottom: 0.35rem;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 0.65rem;
+ color: #333;
+ white-space: nowrap;
+ z-index: 5;
+ pointer-events: none;
+ }
+ #attribution a {
+ color: #445;
+ text-decoration: none;
+ pointer-events: auto;
+ }
+ #attribution a:hover { color: #778; }
+ </style>
+</head>
+<body>
+ <nav><a href="../../index.html">← Back to KGames</a></nav>
+ <div id="audio-controls">
+ <button id="btn-music">Music: ON</button>
+ <button id="btn-sfx">SFX: ON</button>
+ </div>
+ <div id="game-container"></div>
+ <div id="attribution">
+ Animal art: <a href="https://github.com/jdecked/twemoji" target="_blank" rel="noopener">Twemoji</a> © Twitter, CC-BY 4.0
+ </div>
+ <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="../../js/shared/game-shared.js"></script>
+ <script src="lib.js"></script>
+ <script src="game.js"></script>
+</body>
+</html>
diff --git a/games/animal-letter/lib.js b/games/animal-letter/lib.js
@@ -0,0 +1,96 @@
+(function (root) {
+ 'use strict';
+
+ const ANIMALS = [
+ // A
+ { key: 'alligator', letter: 'A', name: 'Alligator', codepoint: '1f40a' },
+ { key: 'ant', letter: 'A', name: 'Ant', codepoint: '1f41c' },
+ // B
+ { key: 'bear', letter: 'B', name: 'Bear', codepoint: '1f43b' },
+ { key: 'bee', letter: 'B', name: 'Bee', codepoint: '1f41d' },
+ { key: 'butterfly', letter: 'B', name: 'Butterfly', codepoint: '1f98b' },
+ // C
+ { key: 'camel', letter: 'C', name: 'Camel', codepoint: '1f42a' },
+ { key: 'cat', letter: 'C', name: 'Cat', codepoint: '1f431' },
+ { key: 'chicken', letter: 'C', name: 'Chicken', codepoint: '1f414' },
+ { key: 'cow', letter: 'C', name: 'Cow', codepoint: '1f404' },
+ { key: 'crab', letter: 'C', name: 'Crab', codepoint: '1f980' },
+ // D
+ { key: 'deer', letter: 'D', name: 'Deer', codepoint: '1f98c' },
+ { key: 'dog', letter: 'D', name: 'Dog', codepoint: '1f436' },
+ { key: 'dolphin', letter: 'D', name: 'Dolphin', codepoint: '1f42c' },
+ { key: 'duck', letter: 'D', name: 'Duck', codepoint: '1f986' },
+ // E
+ { key: 'eagle', letter: 'E', name: 'Eagle', codepoint: '1f985' },
+ { key: 'elephant', letter: 'E', name: 'Elephant', codepoint: '1f418' },
+ // F
+ { key: 'fish', letter: 'F', name: 'Fish', codepoint: '1f41f' },
+ { key: 'flamingo', letter: 'F', name: 'Flamingo', codepoint: '1f9a9' },
+ { key: 'fox', letter: 'F', name: 'Fox', codepoint: '1f98a' },
+ { key: 'frog', letter: 'F', name: 'Frog', codepoint: '1f438' },
+ // G
+ { key: 'giraffe', letter: 'G', name: 'Giraffe', codepoint: '1f992' },
+ { key: 'goat', letter: 'G', name: 'Goat', codepoint: '1f410' },
+ { key: 'gorilla', letter: 'G', name: 'Gorilla', codepoint: '1f98d' },
+ // H
+ { key: 'hamster', letter: 'H', name: 'Hamster', codepoint: '1f439' },
+ { key: 'hippo', letter: 'H', name: 'Hippo', codepoint: '1f99b' },
+ { key: 'horse', letter: 'H', name: 'Horse', codepoint: '1f434' },
+ // K
+ { key: 'kangaroo', letter: 'K', name: 'Kangaroo', codepoint: '1f998' },
+ { key: 'koala', letter: 'K', name: 'Koala', codepoint: '1f428' },
+ // L
+ { key: 'ladybug', letter: 'L', name: 'Ladybug', codepoint: '1f41e' },
+ { key: 'lion', letter: 'L', name: 'Lion', codepoint: '1f981' },
+ // M
+ { key: 'monkey', letter: 'M', name: 'Monkey', codepoint: '1f435' },
+ { key: 'mouse', letter: 'M', name: 'Mouse', codepoint: '1f42d' },
+ // O
+ { key: 'octopus', letter: 'O', name: 'Octopus', codepoint: '1f419' },
+ { key: 'otter', letter: 'O', name: 'Otter', codepoint: '1f9a6' },
+ { key: 'owl', letter: 'O', name: 'Owl', codepoint: '1f989' },
+ // P
+ { key: 'panda', letter: 'P', name: 'Panda', codepoint: '1f43c' },
+ { key: 'parrot', letter: 'P', name: 'Parrot', codepoint: '1f99c' },
+ { key: 'penguin', letter: 'P', name: 'Penguin', codepoint: '1f427' },
+ { key: 'pig', letter: 'P', name: 'Pig', codepoint: '1f437' },
+ // R
+ { key: 'rabbit', letter: 'R', name: 'Rabbit', codepoint: '1f430' },
+ { key: 'raccoon', letter: 'R', name: 'Raccoon', codepoint: '1f99d' },
+ // S
+ { key: 'shark', letter: 'S', name: 'Shark', codepoint: '1f988' },
+ { key: 'snail', letter: 'S', name: 'Snail', codepoint: '1f40c' },
+ { key: 'snake', letter: 'S', name: 'Snake', codepoint: '1f40d' },
+ { key: 'swan', letter: 'S', name: 'Swan', codepoint: '1f9a2' },
+ // T
+ { key: 'tiger', letter: 'T', name: 'Tiger', codepoint: '1f42f' },
+ { key: 'turtle', letter: 'T', name: 'Turtle', codepoint: '1f422' },
+ // U
+ { key: 'unicorn', letter: 'U', name: 'Unicorn', codepoint: '1f984' },
+ // W
+ { key: 'whale', letter: 'W', name: 'Whale', codepoint: '1f433' },
+ { key: 'wolf', letter: 'W', name: 'Wolf', codepoint: '1f43a' },
+ // Z
+ { key: 'zebra', letter: 'Z', name: 'Zebra', codepoint: '1f993' },
+ ];
+
+ // Fisher-Yates shuffle — returns a new shuffled array, does not mutate ANIMALS
+ function shuffleAnimals(rngFn) {
+ const arr = ANIMALS.slice();
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(rngFn() * (i + 1));
+ const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
+ }
+ return arr;
+ }
+
+ // Kept for unit-test compatibility
+ function pickAnimal(rng, excludeKey) {
+ const pool = excludeKey ? ANIMALS.filter(a => a.key !== excludeKey) : ANIMALS;
+ return pool[Math.floor(rng() * pool.length)];
+ }
+
+ const api = { ANIMALS, shuffleAnimals, pickAnimal };
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ root.AnimalLetterLib = api;
+}(typeof globalThis !== 'undefined' ? globalThis : this));
diff --git a/games/letter-find/game.js b/games/letter-find/game.js
@@ -1,61 +1,47 @@
const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6'];
-const TINTS = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6];
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const GOAL = 10;
+const MELODY = [
+ 'C5','E5','G5','A5','G5','E5','C5','E5',
+ 'F5','A5','C6','A5','G5','B4','C5','G4',
+];
+const HARMONY = [
+ 'E5','G5','B5','C6','B5','G5','E5','G5',
+ 'A5','C6','E6','C6','B5','D5','E5','B4',
+];
+const BASS = [
+ 'C3',null,null,null,null,null,null,null,
+ 'F2',null,null,null,'G2',null,null,null,
+];
+
class LetterFindScene extends Phaser.Scene {
constructor() {
super({ key: 'LetterFindScene' });
- this.targetChar = null;
- this.targetColor = null;
- this.targetText = null;
- this.instrText = null;
- this.guessTexts = [];
- this.accepting = false;
- this.firstRound = true;
+ this.audio = null;
+ this.targetChar = null;
+ this.targetColor = null;
+ this.targetText = null;
+ this.instrText = null;
+ 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;
- this.musicReady = false;
- this.bgGain = null;
- this.melodySynth = null;
- this.bassSynth = null;
- this.jingleSynth = null;
- this.failSynth = null;
- this.successStepsLeft = 0;
- this.seqStep = 0;
+ this.winShowing = false;
+ this.progressGfx = null;
+ this.winElements = [];
}
preload() {
- const g = this.make.graphics({ add: false });
- g.fillStyle(0xffffff);
- g.fillRect(0, 0, 8, 8);
- g.generateTexture('sq', 8, 8);
- g.destroy();
-
- // Pre-baked 4-color atlas — avoids per-particle tinting at render time
- const fw = this.make.graphics({ add: false });
- fw.fillStyle(0xE63946); fw.fillRect(0, 0, 6, 6);
- fw.fillStyle(0x1D7CF2); fw.fillRect(6, 0, 6, 6);
- fw.fillStyle(0xFFD23F); fw.fillRect(12, 0, 6, 6);
- fw.fillStyle(0x2EC4B6); fw.fillRect(18, 0, 6, 6);
- fw.generateTexture('fwsq', 24, 6);
- fw.destroy();
- const tex = this.textures.get('fwsq');
- tex.add(0, 0, 0, 0, 6, 6);
- tex.add(1, 0, 6, 0, 6, 6);
- tex.add(2, 0, 12, 0, 6, 6);
- tex.add(3, 0, 18, 0, 6, 6);
+ KGames.bakeFireworksAtlas(this);
}
create() {
const W = this.scale.width;
const H = this.scale.height;
+ this.audio = new KGames.AudioSystem({ melody: MELODY, harmony: HARMONY, bass: BASS });
+
this.progressGfx = this.add.graphics();
this.instrText = this.add.text(W / 2, 0, 'Find and press the matching letter', {
@@ -72,8 +58,8 @@ class LetterFindScene extends Phaser.Scene {
this.scale.on('resize', this.onResize, this);
this.input.keyboard.on('keydown', this.onKey, this);
- document.getElementById('btn-music').addEventListener('click', () => this.toggleMusic());
- document.getElementById('btn-sfx').addEventListener('click', () => this.toggleSfx());
+ document.getElementById('btn-music').addEventListener('click', () => this.audio.toggleMusic());
+ document.getElementById('btn-sfx').addEventListener('click', () => this.audio.toggleSfx());
this.newTarget();
this.applyLayout(W, H);
@@ -89,7 +75,7 @@ class LetterFindScene extends Phaser.Scene {
this.targetText.setStyle({ fontSize: targetSize + 'px' });
this.targetText.setPosition(W / 2, H * 0.47);
- this.drawProgressBar(W, H);
+ KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
this.reflowGuesses(W, H);
}
@@ -97,28 +83,6 @@ 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)]; }
@@ -140,10 +104,10 @@ class LetterFindScene extends Phaser.Scene {
});
if (this.firstRound) {
- this.speak('find the letter. ' + char);
+ this.audio.speak('find the letter. ' + char);
this.firstRound = false;
} else {
- this.speak(char);
+ this.audio.speak(char);
}
}
@@ -152,7 +116,7 @@ class LetterFindScene extends Phaser.Scene {
if (!this.accepting) return;
if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return;
- this.initMusic();
+ this.audio.initMusic();
const pressed = event.key.toUpperCase();
if (pressed === this.targetChar) {
@@ -164,33 +128,38 @@ class LetterFindScene extends Phaser.Scene {
onCorrect() {
this.accepting = false;
- this.playSuccess();
- this.playSuccessJingle();
- this.burstConfetti();
+ this.audio.playSuccess();
+ this.audio.playSuccessJingle();
+ KGames.burstConfetti(this, this.scale.width / 2, this.scale.height * 0.47);
this.lettersFound++;
- this.drawProgressBar(this.scale.width, this.scale.height);
+ KGames.drawProgressBar(this.progressGfx, this.scale.width, this.scale.height, this.lettersFound, GOAL);
- this.guessTexts.forEach(obj => {
- this.tweens.killTweensOf(obj);
- obj.destroy();
- });
+ this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); });
this.guessTexts = [];
if (this.lettersFound >= GOAL) {
- this.time.delayedCall(700, () => this.showWinScreen());
+ this.time.delayedCall(700, () => {
+ this.winShowing = true;
+ this.accepting = false;
+ this.winElements = KGames.showWinScreen(this, {
+ title: 'You did it!',
+ subtitle: 'You found all 10 letters!',
+ audio: this.audio,
+ onPlayAgain: () => this.resetGame(),
+ });
+ });
} else {
this.time.delayedCall(950, () => {
this.newTarget();
- const { width: W, height: H } = this.scale;
- this.applyLayout(W, H);
+ this.applyLayout(this.scale.width, this.scale.height);
});
}
}
onWrong(char) {
- this.playBong();
- this.playFailLick();
+ this.audio.playBong();
+ this.audio.playFailLick();
this.addGuessLetter(char);
}
@@ -200,9 +169,9 @@ class LetterFindScene extends Phaser.Scene {
const text = this.add.text(W / 2, H * 0.78, char, {
fontFamily: 'Fredoka, sans-serif',
- fontSize: size + 'px',
- fontStyle: 'bold',
- color: '#cc4444',
+ fontSize: size + 'px',
+ fontStyle: 'bold',
+ color: '#cc4444',
}).setOrigin(0.5).setAlpha(0).setScale(0.3);
this.tweens.add({
@@ -242,140 +211,6 @@ class LetterFindScene extends Phaser.Scene {
});
}
- burstConfetti() {
- const { width: W, height: H } = this.scale;
- const emitter = this.add.particles(W / 2, H * 0.47, 'fwsq', {
- frame: [0, 1, 2, 3],
- speed: { min: 100, max: 350 },
- angle: { min: 0, max: 360 },
- scale: { start: 1.2, end: 0 },
- gravityY: 500,
- lifespan: 750,
- explode: true,
- quantity: 30,
- });
- this.time.delayedCall(850, () => { if (emitter.active) emitter.destroy(); });
- }
-
- // ── 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;
- // 6 bursts at 500ms intervals; 800ms lifespan means at most 2 bursts alive at once
- const bursts = [
- [W * 0.3, H * 0.30, 0],
- [W * 0.7, H * 0.25, 500],
- [W * 0.5, H * 0.20, 1000],
- [W * 0.2, H * 0.35, 1500],
- [W * 0.78, H * 0.28, 2000],
- [W * 0.5, H * 0.32, 2500],
- ];
- bursts.forEach(([x, y, delay]) => {
- this.time.delayedCall(delay, () => {
- if (!this.scene.isActive()) return;
- const em = this.add.particles(x, y, 'fwsq', {
- frame: [0, 1, 2, 3],
- speed: { min: 60, max: 240 },
- angle: { min: 0, max: 360 },
- scale: { start: 1.2, end: 0 },
- gravityY: 150,
- lifespan: 800,
- explode: true,
- quantity: 28,
- });
- this.time.delayedCall(900, () => { if (em.active) em.destroy(); });
- });
- });
- }
-
resetGame() {
this.winElements.forEach(el => { if (el.active) el.destroy(); });
this.winElements = [];
@@ -390,205 +225,20 @@ class LetterFindScene extends Phaser.Scene {
this.targetColor = null;
const { width: W, height: H } = this.scale;
- this.drawProgressBar(W, H);
+ KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL);
this.newTarget();
this.applyLayout(W, H);
}
-
- // ── Music ─────────────────────────────────────────────────────────────
-
- initMusic() {
- if (this.musicReady) return;
- this.musicReady = true;
-
- this.bgGain = new Tone.Gain(1).toDestination();
-
- 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;
-
- 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;
-
- 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;
-
- 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;
-
- const melody = [
- 'C5','E5','G5','A5','G5','E5','C5','E5',
- 'F5','A5','C6','A5','G5','B4','C5','G4',
- ];
- const harmony = [
- 'E5','G5','B5','C6','B5','G5','E5','G5',
- 'A5','C6','E6','C6','B5','D5','E5','B4',
- ];
- new Tone.Sequence((time, note) => {
- const step = this.seqStep % melody.length;
- this.seqStep++;
- this.melodySynth.triggerAttackRelease(note, '16n', time);
- if (this.successStepsLeft > 0) {
- this.jingleSynth.triggerAttackRelease(harmony[step], '16n', time);
- this.successStepsLeft--;
- }
- }, melody, '8n').start(0);
-
- const bass = [
- 'C3',null,null,null,null,null,null,null,
- 'F2',null,null,null,'G2',null,null,null,
- ];
- new Tone.Sequence((time, note) => {
- if (note) this.bassSynth.triggerAttackRelease(note, '4n', time);
- }, bass, '8n').start(0);
-
- Tone.Transport.bpm.value = 128;
- this.bgGain.gain.value = this.musicMuted ? 0 : 1;
-
- Tone.start().then(() => Tone.Transport.start());
- }
-
- playSuccessJingle() {
- if (!this.musicReady) return;
- this.successStepsLeft = 8;
- }
-
- playFailLick() {
- if (!this.musicReady) return;
- const start = Tone.Transport.nextSubdivision('8n');
- const s = 60 / 128 / 4;
- ['E4','Eb4','D4','Db4'].forEach((note, i) => {
- this.failSynth.triggerAttackRelease(note, '16n', start + i * s);
- });
- }
-
- toggleMusic() {
- this.musicMuted = !this.musicMuted;
- if (this.bgGain) this.bgGain.gain.value = this.musicMuted ? 0 : 1;
- const btn = document.getElementById('btn-music');
- btn.textContent = 'Music: ' + (this.musicMuted ? 'OFF' : 'ON');
- btn.classList.toggle('muted', this.musicMuted);
- }
-
- toggleSfx() {
- this.sfxMuted = !this.sfxMuted;
- const btn = document.getElementById('btn-sfx');
- btn.textContent = 'SFX: ' + (this.sfxMuted ? 'OFF' : 'ON');
- btn.classList.toggle('muted', this.sfxMuted);
- }
-
- // ── WebAudio SFX ──────────────────────────────────────────────────────
-
- getAudioCtx() {
- if (!this.audioCtx) {
- this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
- }
- return this.audioCtx;
- }
-
- playBong() {
- if (this.sfxMuted) return;
- try {
- const ctx = this.getAudioCtx();
- const osc = ctx.createOscillator();
- const gain = ctx.createGain();
- osc.connect(gain);
- gain.connect(ctx.destination);
- osc.type = 'sine';
- osc.frequency.setValueAtTime(160, ctx.currentTime);
- osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.3);
- gain.gain.setValueAtTime(0.35, ctx.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
- osc.start(ctx.currentTime);
- osc.stop(ctx.currentTime + 0.4);
- } catch (_) {}
- }
-
- playSuccess() {
- if (this.sfxMuted) return;
- try {
- const ctx = this.getAudioCtx();
- const notes = [523, 659, 784]; // C5, E5, G5
- 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.08;
- osc.frequency.setValueAtTime(freq, t);
- gain.gain.setValueAtTime(0, t);
- gain.gain.linearRampToValueAtTime(0.3, t + 0.02);
- gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
- osc.start(t);
- osc.stop(t + 0.3);
- });
- } 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();
- window.speechSynthesis.speak(new SpeechSynthesisUtterance(char.toLowerCase()));
- }
}
new Phaser.Game({
- type: Phaser.AUTO,
+ type: Phaser.AUTO,
parent: 'game-container',
backgroundColor: '#1A1A2E',
- scene: LetterFindScene,
+ scene: LetterFindScene,
scale: {
- mode: Phaser.Scale.RESIZE,
- width: '100%',
+ mode: Phaser.Scale.RESIZE,
+ width: '100%',
height: '100%',
},
render: { preserveDrawingBuffer: true },
diff --git a/games/letter-find/index.html b/games/letter-find/index.html
@@ -54,6 +54,7 @@
<div id="game-container"></div>
<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="../../js/shared/game-shared.js"></script>
<script src="game.js"></script>
</body>
</html>
diff --git a/js/main.js b/js/main.js
@@ -1,7 +1,7 @@
const GAMES = [
{ slug: 'letter-find', title: 'Letter Find', thumb: 'assets/thumbnails/letter-find.svg', status: 'live' },
{ slug: 'fire-truck', title: 'Fire Truck', thumb: 'assets/thumbnails/fire-truck.svg', status: 'live' },
- { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' },
+ { slug: 'animal-letter', title: 'Animal Letter', thumb: 'assets/thumbnails/animal-letter.svg', status: 'live' },
{ slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' },
{ slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' },
{ slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' },
diff --git a/js/shared/game-shared.js b/js/shared/game-shared.js
@@ -0,0 +1,378 @@
+(function (root) {
+ 'use strict';
+
+ root.KGames = root.KGames || {};
+
+ // ── Fireworks atlas (baked once per scene) ────────────────────────────────
+
+ function bakeFireworksAtlas(scene) {
+ if (scene.textures.exists('fwsq')) return;
+ const fw = scene.make.graphics({ add: false });
+ fw.fillStyle(0xE63946); fw.fillRect(0, 0, 6, 6);
+ fw.fillStyle(0x1D7CF2); fw.fillRect(6, 0, 6, 6);
+ fw.fillStyle(0xFFD23F); fw.fillRect(12, 0, 6, 6);
+ fw.fillStyle(0x2EC4B6); fw.fillRect(18, 0, 6, 6);
+ fw.generateTexture('fwsq', 24, 6);
+ fw.destroy();
+ const tex = scene.textures.get('fwsq');
+ tex.add(0, 0, 0, 0, 6, 6);
+ tex.add(1, 0, 6, 0, 6, 6);
+ tex.add(2, 0, 12, 0, 6, 6);
+ tex.add(3, 0, 18, 0, 6, 6);
+ }
+
+ // ── Particles ─────────────────────────────────────────────────────────────
+
+ function burstConfetti(scene, x, y) {
+ const emitter = scene.add.particles(x, y, 'fwsq', {
+ frame: [0, 1, 2, 3],
+ speed: { min: 100, max: 350 },
+ angle: { min: 0, max: 360 },
+ scale: { start: 1.2, end: 0 },
+ gravityY: 500,
+ lifespan: 750,
+ explode: true,
+ quantity: 30,
+ });
+ scene.time.delayedCall(850, () => { if (emitter.active) emitter.destroy(); });
+ }
+
+ function launchFireworks(scene) {
+ const { width: W, height: H } = scene.scale;
+ const bursts = [
+ [W * 0.3, H * 0.30, 0],
+ [W * 0.7, H * 0.25, 500],
+ [W * 0.5, H * 0.20, 1000],
+ [W * 0.2, H * 0.35, 1500],
+ [W * 0.78, H * 0.28, 2000],
+ [W * 0.5, H * 0.32, 2500],
+ ];
+ bursts.forEach(([x, y, delay]) => {
+ scene.time.delayedCall(delay, () => {
+ if (!scene.scene.isActive()) return;
+ const em = scene.add.particles(x, y, 'fwsq', {
+ frame: [0, 1, 2, 3],
+ speed: { min: 60, max: 240 },
+ angle: { min: 0, max: 360 },
+ scale: { start: 1.2, end: 0 },
+ gravityY: 150,
+ lifespan: 800,
+ explode: true,
+ quantity: 28,
+ });
+ scene.time.delayedCall(900, () => { if (em.active) em.destroy(); });
+ });
+ });
+ }
+
+ // ── Progress bar ──────────────────────────────────────────────────────────
+
+ function drawProgressBar(gfx, W, H, found, goal) {
+ 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 < found) {
+ 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);
+ }
+ }
+ }
+
+ // ── Win screen ────────────────────────────────────────────────────────────
+ // Returns the array of Phaser display objects created (for later cleanup).
+
+ function showWinScreen(scene, opts) {
+ const { title, subtitle, audio, onPlayAgain } = opts;
+ const { width: W, height: H } = scene.scale;
+ const elements = [];
+
+ if (audio) audio.playVictorySound();
+ launchFireworks(scene);
+
+ const bg = scene.add.graphics();
+ bg.fillStyle(0x000000, 0.78);
+ bg.fillRect(0, 0, W, H);
+ elements.push(bg);
+
+ const titleSize = Math.max(32, Math.floor(H * 0.1));
+ const titleObj = scene.add.text(W / 2, H * 0.28, title, {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: titleSize + 'px',
+ fontStyle: 'bold',
+ color: '#FFD23F',
+ }).setOrigin(0.5).setAlpha(0);
+ elements.push(titleObj);
+ scene.tweens.add({ targets: titleObj, alpha: 1, duration: 400, ease: 'Sine.In' });
+
+ const subSize = Math.max(16, Math.floor(H * 0.055));
+ const subObj = scene.add.text(W / 2, H * 0.38, subtitle, {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: subSize + 'px',
+ color: '#ffffff',
+ }).setOrigin(0.5).setAlpha(0);
+ elements.push(subObj);
+ scene.tweens.add({ targets: subObj, 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));
+
+ const y1 = H * 0.57;
+ const btn1 = scene.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);
+ elements.push(btn1);
+
+ const t1 = scene.add.text(W / 2, y1, 'Play Again', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: btnSz + 'px',
+ fontStyle: 'bold',
+ color: '#ffffff',
+ }).setOrigin(0.5);
+ elements.push(t1);
+
+ const z1 = scene.add.zone(W / 2, y1, btnW, btnH).setInteractive({ useHandCursor: true });
+ z1.on('pointerover', () => drawBtn1(0x3ED4CE));
+ z1.on('pointerout', () => drawBtn1(0x2EC4B6));
+ z1.on('pointerup', () => { if (onPlayAgain) onPlayAgain(); });
+ elements.push(z1);
+
+ const y2 = H * 0.72;
+ const btn2 = scene.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);
+ elements.push(btn2);
+
+ const t2 = scene.add.text(W / 2, y2, 'Main Menu', {
+ fontFamily: 'Fredoka, sans-serif',
+ fontSize: btnSz + 'px',
+ fontStyle: 'bold',
+ color: '#cccccc',
+ }).setOrigin(0.5);
+ elements.push(t2);
+
+ const z2 = scene.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'; });
+ elements.push(z2);
+
+ return elements;
+ }
+
+ // ── Audio system ──────────────────────────────────────────────────────────
+ // Encapsulates Tone.js background music and Web Audio SFX.
+ // Pass melody/harmony/bass note arrays and bpm to constructor;
+ // call initMusic() lazily on first user interaction.
+
+ class AudioSystem {
+ constructor({ melody, harmony, bass, bpm }) {
+ this._melody = melody;
+ this._harmony = harmony;
+ this._bass = bass;
+ this._bpm = bpm || 128;
+
+ this.musicMuted = false;
+ this.sfxMuted = false;
+ this.musicReady = false;
+ this.bgGain = null;
+ this.melodySynth = null;
+ this.bassSynth = null;
+ this.jingleSynth = null;
+ this.failSynth = null;
+ this.successStepsLeft = 0;
+ this._seqStep = 0;
+ this._audioCtx = null;
+ this._speakTimer = null;
+ }
+
+ initMusic() {
+ if (this.musicReady) return;
+ this.musicReady = true;
+
+ this.bgGain = new Tone.Gain(1).toDestination();
+
+ 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;
+
+ 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;
+
+ 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;
+
+ 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;
+
+ const melody = this._melody;
+ const harmony = this._harmony;
+
+ new Tone.Sequence((time, note) => {
+ const step = this._seqStep % melody.length;
+ this._seqStep++;
+ this.melodySynth.triggerAttackRelease(note, '16n', time);
+ if (this.successStepsLeft > 0) {
+ this.jingleSynth.triggerAttackRelease(harmony[step], '16n', time);
+ this.successStepsLeft--;
+ }
+ }, melody, '8n').start(0);
+
+ new Tone.Sequence((time, note) => {
+ if (note) this.bassSynth.triggerAttackRelease(note, '4n', time);
+ }, this._bass, '8n').start(0);
+
+ Tone.Transport.bpm.value = this._bpm;
+ this.bgGain.gain.value = this.musicMuted ? 0 : 1;
+ Tone.start().then(() => Tone.Transport.start());
+ }
+
+ playSuccessJingle() {
+ if (!this.musicReady) return;
+ this.successStepsLeft = 8;
+ }
+
+ playFailLick() {
+ if (!this.musicReady) return;
+ const start = Tone.Transport.nextSubdivision('8n');
+ const s = 60 / this._bpm / 4;
+ ['E4', 'Eb4', 'D4', 'Db4'].forEach((note, i) => {
+ this.failSynth.triggerAttackRelease(note, '16n', start + i * s);
+ });
+ }
+
+ toggleMusic(btnId) {
+ this.musicMuted = !this.musicMuted;
+ if (this.bgGain) this.bgGain.gain.value = this.musicMuted ? 0 : 1;
+ const btn = document.getElementById(btnId || 'btn-music');
+ btn.textContent = 'Music: ' + (this.musicMuted ? 'OFF' : 'ON');
+ btn.classList.toggle('muted', this.musicMuted);
+ }
+
+ toggleSfx(btnId) {
+ this.sfxMuted = !this.sfxMuted;
+ const btn = document.getElementById(btnId || 'btn-sfx');
+ btn.textContent = 'SFX: ' + (this.sfxMuted ? 'OFF' : 'ON');
+ btn.classList.toggle('muted', this.sfxMuted);
+ }
+
+ _ctx() {
+ if (!this._audioCtx) {
+ this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ return this._audioCtx;
+ }
+
+ playBong() {
+ if (this.sfxMuted) return;
+ try {
+ const ctx = this._ctx();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain); gain.connect(ctx.destination);
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(160, ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.3);
+ gain.gain.setValueAtTime(0.35, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
+ osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.4);
+ } catch (_) {}
+ }
+
+ playSuccess() {
+ if (this.sfxMuted) return;
+ try {
+ const ctx = this._ctx();
+ [523, 659, 784].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.08;
+ osc.frequency.setValueAtTime(freq, t);
+ gain.gain.setValueAtTime(0, t);
+ gain.gain.linearRampToValueAtTime(0.3, t + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
+ osc.start(t); osc.stop(t + 0.3);
+ });
+ } catch (_) {}
+ }
+
+ playVictorySound() {
+ if (this.sfxMuted) return;
+ try {
+ const ctx = this._ctx();
+ const notes = [523, 659, 784, 1047, 1319, 1568];
+ 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);
+ });
+ [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(text) {
+ if (!window.speechSynthesis) return;
+ // Cancel + immediate speak races on some browsers; debounce with clearTimeout
+ window.speechSynthesis.cancel();
+ clearTimeout(this._speakTimer);
+ this._speakTimer = setTimeout(() => {
+ window.speechSynthesis.speak(new SpeechSynthesisUtterance(text.toLowerCase()));
+ }, 80);
+ }
+ }
+
+ root.KGames.AudioSystem = AudioSystem;
+ root.KGames.bakeFireworksAtlas = bakeFireworksAtlas;
+ root.KGames.burstConfetti = burstConfetti;
+ root.KGames.launchFireworks = launchFireworks;
+ root.KGames.drawProgressBar = drawProgressBar;
+ root.KGames.showWinScreen = showWinScreen;
+
+}(window));
diff --git a/tests/browser/runner.js b/tests/browser/runner.js
@@ -38,12 +38,14 @@ async function main() {
// ── Portal ────────────────────────────────────────────────────────────────
- await test('portal loads and contains both game tiles', async (page, url) => {
+ await test('portal loads and contains all game tiles', async (page, url) => {
await page.goto(url, { waitUntil: 'domcontentloaded' });
- const letterFind = await page.$('a[href*="letter-find"]');
- const fireTruck = await page.$('a[href*="fire-truck"]');
- if (!letterFind) throw new Error('Letter Find tile not found');
- if (!fireTruck) throw new Error('Fire Truck tile not found');
+ const letterFind = await page.$('a[href*="letter-find"]');
+ const fireTruck = await page.$('a[href*="fire-truck"]');
+ const animalLetter = await page.$('a[href*="animal-letter"]');
+ if (!letterFind) throw new Error('Letter Find tile not found');
+ if (!fireTruck) throw new Error('Fire Truck tile not found');
+ if (!animalLetter) throw new Error('Animal Letter tile not found');
});
// ── Letter Find ───────────────────────────────────────────────────────────
@@ -79,6 +81,50 @@ async function main() {
if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
});
+ // ── Animal Letter ─────────────────────────────────────────────────────────
+
+ await test('animal-letter: loads, no JS errors, canvas exists', async (page, url, errors) => {
+ await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2000);
+ if (errors.length) throw new Error(errors[0]);
+ const canvas = await page.$('canvas');
+ if (!canvas) throw new Error('No canvas element');
+ });
+
+ await test('animal-letter: canvas renders multiple colors', async (page, url) => {
+ await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2000);
+ const colorCount = await page.evaluate(() => {
+ const cv = document.querySelector('canvas');
+ if (!cv) return 0;
+ const seen = new Set();
+ const gl = cv.getContext('webgl2') || cv.getContext('webgl');
+ if (gl) {
+ const pixels = new Uint8Array(cv.width * cv.height * 4);
+ gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
+ for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`);
+ } else {
+ const ctx = cv.getContext('2d');
+ const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
+ for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`);
+ }
+ return seen.size;
+ });
+ if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`);
+ });
+
+ await test('animal-letter: correct key press advances score', async (page, url) => {
+ await page.goto(`${url}/games/animal-letter/`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2000);
+ const before = await page.evaluate(() => window.__AL_SCENE__ && window.__AL_SCENE__.lettersFound);
+ const letter = await page.evaluate(() => window.__AL_SCENE__ && window.__AL_SCENE__.currentAnimal && window.__AL_SCENE__.currentAnimal.letter);
+ if (!letter) throw new Error('Scene not ready');
+ await page.keyboard.press(letter);
+ await page.waitForTimeout(300);
+ const after = await page.evaluate(() => window.__AL_SCENE__.lettersFound);
+ if (after !== (before || 0) + 1) throw new Error(`Score did not advance: ${before} -> ${after}`);
+ });
+
// ── Fire Truck ────────────────────────────────────────────────────────────
await test('fire-truck: loads, no JS errors, canvas exists', async (page, url, errors) => {
diff --git a/tests/unit/animal-letter.test.js b/tests/unit/animal-letter.test.js
@@ -0,0 +1,86 @@
+const { ANIMALS, shuffleAnimals, pickAnimal } = require('../../games/animal-letter/lib.js');
+const assert = require('node:assert/strict');
+const { test } = require('node:test');
+
+test('every animal name starts with its declared letter', () => {
+ for (const a of ANIMALS) {
+ assert.equal(
+ a.name[0].toUpperCase(),
+ a.letter,
+ `${a.name} should start with ${a.letter}`,
+ );
+ }
+});
+
+test('every codepoint is a valid 4-5 char lowercase hex string', () => {
+ for (const a of ANIMALS) {
+ assert.match(
+ a.codepoint,
+ /^[0-9a-f]{4,5}$/,
+ `${a.key} has invalid codepoint: ${a.codepoint}`,
+ );
+ }
+});
+
+test('all animal keys are unique', () => {
+ const keys = ANIMALS.map(a => a.key);
+ assert.equal(new Set(keys).size, keys.length, 'Duplicate key found');
+});
+
+test('all animal letters are uppercase single characters A-Z', () => {
+ for (const a of ANIMALS) {
+ assert.match(a.letter, /^[A-Z]$/, `${a.key} letter invalid: ${a.letter}`);
+ }
+});
+
+test('pool is large enough to play a full game without repeats', () => {
+ assert.ok(ANIMALS.length >= 10, `Only ${ANIMALS.length} animals — need at least 10 for one game`);
+});
+
+test('shuffleAnimals returns all animals in a different order', () => {
+ const result = shuffleAnimals(Math.random);
+ assert.equal(result.length, ANIMALS.length, 'Length must match');
+ // Same keys, possibly different order
+ const origKeys = ANIMALS.map(a => a.key).sort().join(',');
+ const resultKeys = result.map(a => a.key).sort().join(',');
+ assert.equal(origKeys, resultKeys, 'Shuffled array must contain same animals');
+});
+
+test('shuffleAnimals does not mutate original ANIMALS array', () => {
+ const firstBefore = ANIMALS[0].key;
+ shuffleAnimals(Math.random);
+ assert.equal(ANIMALS[0].key, firstBefore, 'ANIMALS[0] should be unchanged after shuffle');
+});
+
+test('shuffleAnimals produces varied orderings', () => {
+ const orders = new Set();
+ for (let i = 0; i < 20; i++) {
+ orders.add(shuffleAnimals(Math.random).map(a => a.key).join(','));
+ }
+ assert.ok(orders.size > 1, 'shuffleAnimals should produce different orderings');
+});
+
+test('deck drawn without replacement covers all animals once', () => {
+ const deck = shuffleAnimals(Math.random);
+ const seen = new Set();
+ while (deck.length) {
+ const a = deck.pop();
+ assert.ok(!seen.has(a.key), `${a.key} appeared twice in one deck`);
+ seen.add(a.key);
+ }
+ assert.equal(seen.size, ANIMALS.length);
+});
+
+test('pickAnimal returns a different animal when exclusion matches', () => {
+ const first = ANIMALS[0];
+ const result = pickAnimal(() => 0, first.key);
+ assert.notEqual(result.key, first.key, 'Should not return excluded animal');
+});
+
+test('pickAnimal with no exclusion can return any animal', () => {
+ const seen = new Set();
+ for (let i = 0; i < 10000; i++) {
+ seen.add(pickAnimal(Math.random).key);
+ }
+ assert.equal(seen.size, ANIMALS.length, 'All animals should be reachable via pickAnimal');
+});