kgames

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

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:
Aassets/thumbnails/animal-letter.svg | 35+++++++++++++++++++++++++++++++++++
Agames/animal-letter/game.js | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/animal-letter/index.html | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/animal-letter/lib.js | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mgames/letter-find/game.js | 468++++++++++---------------------------------------------------------------------
Mgames/letter-find/index.html | 1+
Mjs/main.js | 2+-
Ajs/shared/game-shared.js | 378+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/browser/runner.js | 56+++++++++++++++++++++++++++++++++++++++++++++++++++-----
Atests/unit/animal-letter.test.js | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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">&larr; 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> &copy; 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'); +});