kgames

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

commit 56f3e0b265473d20f8fe8747ca556207fa592481
parent 1ad5439617774e2f2137de8fcd5c432417c73195
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Sun, 26 Apr 2026 08:49:34 -0700

Animal spelling game

Diffstat:
Aassets/thumbnails/animal-spell.svg | 35+++++++++++++++++++++++++++++++++++
Agames/animal-spell/game.js | 461+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/animal-spell/index.html | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/animal-spell/lib.js | 20++++++++++++++++++++
Mjs/main.js | 2+-
Mjs/shared/game-shared.js | 22++++++++++++++++++++++
Mtests/browser/runner.js | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/animal-spell-lib.test.js | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 725 insertions(+), 1 deletion(-)

diff --git a/assets/thumbnails/animal-spell.svg b/assets/thumbnails/animal-spell.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="#FFD23F" stroke-width="4"/> + + <!-- cat face --> + <circle cx="80" cy="54" r="24" fill="#FFCC4D"/> + <!-- ears --> + <polygon points="61,37 68,22 75,37" fill="#FFCC4D"/> + <polygon points="85,37 92,22 99,37" fill="#FFCC4D"/> + <polygon points="63,36 68,26 73,36" fill="#F4900C"/> + <polygon points="87,36 92,26 97,36" fill="#F4900C"/> + <!-- eyes --> + <ellipse cx="71" cy="50" rx="4" ry="5" fill="#292F33"/> + <ellipse cx="89" cy="50" rx="4" ry="5" fill="#292F33"/> + <circle cx="72.5" cy="48.5" r="1.5" fill="#fff"/> + <circle cx="90.5" cy="48.5" r="1.5" fill="#fff"/> + <!-- nose --> + <polygon points="80,57 77,61 83,61" fill="#F4900C"/> + <!-- mouth --> + <path d="M77,61 Q74,65 70,64" stroke="#292F33" stroke-width="1.2" fill="none" stroke-linecap="round"/> + <path d="M83,61 Q86,65 90,64" stroke="#292F33" stroke-width="1.2" fill="none" stroke-linecap="round"/> + + <!-- letter tiles spelling "CAT" --> + <rect x="26" y="87" width="30" height="28" rx="7" fill="#E63946"/> + <text x="41" y="108" font-family="Fredoka, sans-serif" font-size="18" font-weight="600" + fill="#ffffff" text-anchor="middle">C</text> + + <rect x="65" y="87" width="30" height="28" rx="7" fill="#1D7CF2"/> + <text x="80" y="108" font-family="Fredoka, sans-serif" font-size="18" font-weight="600" + fill="#ffffff" text-anchor="middle">A</text> + + <rect x="104" y="87" width="30" height="28" rx="7" fill="#2EC4B6"/> + <text x="119" y="108" font-family="Fredoka, sans-serif" font-size="18" font-weight="600" + fill="#ffffff" text-anchor="middle">T</text> +</svg> diff --git a/games/animal-spell/game.js b/games/animal-spell/game.js @@ -0,0 +1,461 @@ +const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6']; +const GOAL = 10; +const CDN = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/svg/'; + +const MELODY = [ + 'F4','C5','F5','C5', 'F4','A4','C5','A4', + 'Bb4','D5','F5','Bb5', 'F5','D5','Bb4','F4', + 'C5','E5','G5','C6', 'G5','E5','C5','E5', + 'F5','D5','Bb4','F4', 'C5','F5','A5','F5', +]; +const HARMONY = [ + '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 = [ + 'F2', null, null, null, null, null, null, null, + 'Bb2',null, null, null, 'C3', null, null, null, +]; + +class AnimalSpellScene extends Phaser.Scene { + constructor() { + super({ key: 'AnimalSpellScene' }); + this.audio = null; + this.currentAnimal = null; + this.roundColor = null; + this.wordLetters = []; + this.letterIndex = 0; + this.wrongCount = 0; + this.accepting = false; + this.wordsSpelled = 0; + this.winShowing = false; + this.progressGfx = null; + this.winElements = []; + this.guessTexts = []; + this.animalImg = null; + this.instrText = null; + this.hintText = null; + this._imgScale = 1; + this._idleTweens = []; + this._deck = []; + this._slotTexts = []; + this._slotGfx = null; + } + + preload() { + KGames.bakeFireworksAtlas(this); + AnimalSpellLib.ANIMALS.forEach(a => { + this.load.svg(a.key, CDN + a.codepoint + '.svg', { width: 256, height: 256 }); + }); + } + + create() { + window.__AS_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._slotGfx = this.add.graphics(); + + this.instrText = this.add.text(W / 2, H * 0.14, '', { + fontFamily: 'Fredoka, sans-serif', + color: '#888888', + }).setOrigin(0.5, 0.5); + + this.animalImg = this.add.image(W / 2, H * 0.36, '__DEFAULT').setVisible(false); + + this.hintText = this.add.text(W / 2, H * 0.80, '', { + 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 hintSize = Math.max(40, Math.floor(H * 0.12)); + const imgSize = Math.max(80, Math.floor(H * 0.32)); + + this.instrText.setStyle({ fontSize: instrSize + 'px' }).setPosition(W / 2, H * 0.14); + + const wasAccepting = this.accepting; + this._stopIdleAnimation(); + + this._imgScale = imgSize / 256; + if (this.animalImg.visible) { + this.animalImg.setPosition(W / 2, H * 0.36).setScale(this._imgScale); + } + + if (wasAccepting) this._startIdleAnimation(); + + this.hintText.setStyle({ fontSize: hintSize + 'px' }).setPosition(W / 2, H * 0.80); + + KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL); + this._redrawSlots(W, H); + this.reflowGuesses(W, H); + } + + onResize(gameSize) { + this.applyLayout(gameSize.width, gameSize.height); + } + + _slotMetrics(W, H) { + const n = this.wordLetters.length; + if (n === 0) return { startX: 0, slotW: 40, y: H * 0.67, fontSize: 24 }; + const maxSlotW = Math.floor(W / (n + 1)); + const slotW = Math.min(maxSlotW, Math.floor(H * 0.09)); + const startX = (W - slotW * n) / 2 + slotW / 2; + const y = H * 0.67; + const fontSize = Math.max(20, Math.min(Math.floor(slotW * 0.75), Math.floor(H * 0.07))); + return { startX, slotW, y, fontSize }; + } + + _buildSlots(W, H) { + this._slotTexts.forEach(t => { if (t.active) t.destroy(); }); + this._slotTexts = []; + + const { startX, slotW, y, fontSize } = this._slotMetrics(W, H); + this.wordLetters.forEach((_, i) => { + const x = startX + i * slotW; + const t = this.add.text(x, y, '', { + fontFamily: 'Fredoka, sans-serif', + fontSize: fontSize + 'px', + fontStyle: 'bold', + color: '#ffffff', + }).setOrigin(0.5, 0.5).setVisible(false); + this._slotTexts.push(t); + }); + } + + _redrawSlots(W, H) { + if (this._slotTexts.length === 0) return; + const { startX, slotW, y, fontSize } = this._slotMetrics(W, H); + const underY = y + Math.floor(fontSize * 0.6); + const lineLen = Math.floor(slotW * 0.72); + const lineW = Math.max(2, Math.floor(fontSize * 0.07)); + + this._slotGfx.clear(); + this._slotGfx.lineStyle(lineW, 0x555577, 1); + + this._slotTexts.forEach((t, i) => { + const x = startX + i * slotW; + t.setPosition(x, y).setStyle({ fontSize: fontSize + 'px' }); + + if (i < this.letterIndex) { + t.setText(this.wordLetters[i]).setColor('#ffffff').setVisible(true); + } else if (i === this.letterIndex) { + if (this.wrongCount === 0) { + t.setVisible(false); + this._slotGfx.beginPath(); + this._slotGfx.moveTo(x - lineLen / 2, underY); + this._slotGfx.lineTo(x + lineLen / 2, underY); + this._slotGfx.strokePath(); + } else { + // Hint: show letter in muted color, underline disappears + t.setText(this.wordLetters[i]).setColor('#888888').setVisible(true); + } + } else { + t.setVisible(false); + this._slotGfx.beginPath(); + this._slotGfx.moveTo(x - lineLen / 2, underY); + this._slotGfx.lineTo(x + lineLen / 2, underY); + this._slotGfx.strokePath(); + } + }); + } + + _startIdleAnimation() { + const { width: W } = this.scale; + const swayAmt = Math.max(6, Math.floor(W * 0.012)); + this._idleTweens = [ + this.tweens.add({ + targets: this.animalImg, + x: this.animalImg.x + swayAmt, + ease: 'Sine.InOut', + duration: 1600, + yoyo: true, + repeat: -1, + }), + this.tweens.add({ + targets: this.animalImg, + scaleX: this._imgScale * 1.06, + scaleY: this._imgScale * 1.06, + ease: 'Sine.InOut', + duration: 1100, + yoyo: true, + repeat: -1, + }), + ]; + } + + _stopIdleAnimation() { + this._idleTweens.forEach(t => { try { t.destroy(); } catch (_) {} }); + this._idleTweens = []; + } + + newRound() { + this._stopIdleAnimation(); + + if (this._deck.length === 0) { + this._deck = AnimalSpellLib.shuffleAnimals(Math.random); + } + this.currentAnimal = this._deck.pop(); + this.wordLetters = AnimalSpellLib.getWordLetters(this.currentAnimal); + this.letterIndex = 0; + this.wrongCount = 0; + + const available = COLORS.filter(c => c !== this.roundColor); + this.roundColor = available[Math.floor(Math.random() * available.length)]; + + this.hintText.setVisible(false); + this.instrText.setText('Spell the animal!').setColor('#888888'); + + const { width: W, height: H } = this.scale; + const imgSize = Math.max(80, Math.floor(H * 0.32)); + this._imgScale = imgSize / 256; + + this._buildSlots(W, H); + this._redrawSlots(W, H); + + if (this.textures.exists(this.currentAnimal.key)) { + this.animalImg + .setTexture(this.currentAnimal.key) + .setPosition(W / 2, H * 0.36) + .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; + this._startIdleAnimation(); + }, + }); + } 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.wordLetters[this.letterIndex]) { + this.onLetterCorrect(); + } else { + this.onLetterWrong(pressed); + } + } + + onLetterCorrect() { + this.accepting = false; + this.audio.playPing(); + + this.hintText.setVisible(false); + this.instrText.setText('Spell the animal!').setColor('#888888'); + this.wrongCount = 0; + + const { width: W, height: H } = this.scale; + const { startX, slotW, y } = this._slotMetrics(W, H); + const slotX = startX + this.letterIndex * slotW; + + // Pop the confirmed letter into the slot + const slot = this._slotTexts[this.letterIndex]; + slot.setText(this.wordLetters[this.letterIndex]) + .setColor('#ffffff') + .setVisible(true) + .setScale(0.3) + .setAlpha(0); + this.tweens.add({ + targets: slot, + scaleX: 1, scaleY: 1, alpha: 1, + ease: 'Back.Out', + duration: 180, + }); + + KGames.burstConfetti(this, slotX, y); + + this.letterIndex++; + this._redrawSlots(W, H); + + this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); }); + this.guessTexts = []; + + if (this.letterIndex >= this.wordLetters.length) { + this.time.delayedCall(400, () => this.onWordComplete()); + } else { + this.time.delayedCall(200, () => { this.accepting = true; }); + } + } + + onWordComplete() { + this._stopIdleAnimation(); + this.audio.playSuccess(); + this.audio.playSuccessJingle(); + + const { width: W, height: H } = this.scale; + KGames.burstConfetti(this, W / 2, H * 0.36); + + // Flash all slots in the round color + this._slotTexts.forEach(t => t.setColor(this.roundColor)); + this.audio.speak(this.currentAnimal.name); + + this.wordsSpelled++; + KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL); + + if (this.wordsSpelled >= GOAL) { + this.time.delayedCall(1500, () => { + this.winShowing = true; + this.accepting = false; + this.winElements = KGames.showWinScreen(this, { + title: 'Amazing!', + subtitle: 'You can spell all the animals!', + audio: this.audio, + onPlayAgain: () => this.resetGame(), + }); + }); + } else { + this.time.delayedCall(1500, () => { + this.newRound(); + this.applyLayout(this.scale.width, this.scale.height); + }); + } + } + + onLetterWrong(pressed) { + this.audio.playBong(); + this.audio.playFailLick(); + this.wrongCount++; + this.updateHints(); + this.addGuessLetter(pressed); + } + + updateHints() { + const { width: W, height: H } = this.scale; + const hintSize = Math.max(40, Math.floor(H * 0.12)); + const currentLetter = this.wordLetters[this.letterIndex]; + + if (this.wrongCount === 1) { + // Reveal the current letter in the slot as a hint + this._redrawSlots(W, H); + this.audio.speak(this.currentAnimal.name); + } else if (this.wrongCount >= 2) { + // Big letter hint below + instruction update + this.hintText + .setText(currentLetter) + .setStyle({ fontSize: hintSize + 'px', color: this.roundColor }) + .setPosition(W / 2, H * 0.80) + .setVisible(true); + this.instrText.setText('Press ' + currentLetter).setColor(this.roundColor); + this.audio.speak(currentLetter); + } + } + + 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._stopIdleAnimation(); + + this.winElements.forEach(el => { if (el.active) el.destroy(); }); + this.winElements = []; + this.winShowing = false; + + this._slotTexts.forEach(t => { if (t.active) t.destroy(); }); + this._slotTexts = []; + + this.guessTexts.forEach(obj => { if (obj.active) obj.destroy(); }); + this.guessTexts = []; + + this.wordsSpelled = 0; + this.currentAnimal = null; + this.roundColor = null; + this.letterIndex = 0; + this.wrongCount = 0; + this._deck = []; + + const { width: W, height: H } = this.scale; + KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL); + this.newRound(); + this.applyLayout(W, H); + } +} + +new Phaser.Game({ + type: Phaser.AUTO, + parent: 'game-container', + backgroundColor: '#1A1A2E', + scene: AnimalSpellScene, + scale: { + mode: Phaser.Scale.RESIZE, + width: '100%', + height: '100%', + }, + render: { preserveDrawingBuffer: true }, +}); diff --git a/games/animal-spell/index.html b/games/animal-spell/index.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Animal Spell — 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="../animal-letter/lib.js"></script> + <script src="lib.js"></script> + <script src="game.js"></script> +</body> +</html> diff --git a/games/animal-spell/lib.js b/games/animal-spell/lib.js @@ -0,0 +1,20 @@ +(function (root) { + 'use strict'; + + const AnimalLib = (typeof AnimalLetterLib !== 'undefined') + ? AnimalLetterLib + : require('../animal-letter/lib'); + + function getWordLetters(animal) { + return animal.name.toUpperCase().split(''); + } + + const api = { + ANIMALS: AnimalLib.ANIMALS, + shuffleAnimals: AnimalLib.shuffleAnimals, + pickAnimal: AnimalLib.pickAnimal, + getWordLetters, + }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + root.AnimalSpellLib = api; +}(typeof globalThis !== 'undefined' ? globalThis : this)); diff --git a/js/main.js b/js/main.js @@ -2,7 +2,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: '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: 'animal-spell', title: 'Animal Spell', thumb: 'assets/thumbnails/animal-spell.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' }, ]; diff --git a/js/shared/game-shared.js b/js/shared/game-shared.js @@ -302,10 +302,29 @@ 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.onended = () => { osc.disconnect(); gain.disconnect(); }; osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.4); } catch (_) {} } + // Short single-tone ding — use for rapid per-letter feedback to keep the audio graph lean. + playPing() { + 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(880, ctx.currentTime); + gain.gain.setValueAtTime(0, ctx.currentTime); + gain.gain.linearRampToValueAtTime(0.22, ctx.currentTime + 0.01); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.14); + osc.onended = () => { osc.disconnect(); gain.disconnect(); }; + osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.15); + } catch (_) {} + } + playSuccess() { if (this.sfxMuted) return; try { @@ -320,6 +339,7 @@ gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.3, t + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28); + osc.onended = () => { osc.disconnect(); gain.disconnect(); }; osc.start(t); osc.stop(t + 0.3); }); } catch (_) {} @@ -340,6 +360,7 @@ gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.28, t + 0.03); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5); + osc.onended = () => { osc.disconnect(); gain.disconnect(); }; osc.start(t); osc.stop(t + 0.55); }); [523, 659, 784, 1047].forEach(freq => { @@ -352,6 +373,7 @@ gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.18, t + 0.05); gain.gain.exponentialRampToValueAtTime(0.001, t + 1.5); + osc.onended = () => { osc.disconnect(); gain.disconnect(); }; osc.start(t); osc.stop(t + 1.6); }); } catch (_) {} diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -43,9 +43,11 @@ async function main() { const letterFind = await page.$('a[href*="letter-find"]'); const fireTruck = await page.$('a[href*="fire-truck"]'); const animalLetter = await page.$('a[href*="animal-letter"]'); + const animalSpell = await page.$('a[href*="animal-spell"]'); 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'); + if (!animalSpell) throw new Error('Animal Spell tile not found'); }); // ── Letter Find ─────────────────────────────────────────────────────────── @@ -125,6 +127,53 @@ async function main() { if (after !== (before || 0) + 1) throw new Error(`Score did not advance: ${before} -> ${after}`); }); + // ── Animal Spell ────────────────────────────────────────────────────────── + + await test('animal-spell: loads, no JS errors, canvas exists', async (page, url, errors) => { + await page.goto(`${url}/games/animal-spell/`, { 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-spell: canvas renders multiple colors', async (page, url) => { + await page.goto(`${url}/games/animal-spell/`, { 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-spell: correct key press advances letter index', async (page, url) => { + await page.goto(`${url}/games/animal-spell/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + const before = await page.evaluate(() => window.__AS_SCENE__ && window.__AS_SCENE__.letterIndex); + const letter = await page.evaluate(() => { + const s = window.__AS_SCENE__; + return s && s.wordLetters && s.wordLetters[0]; + }); + if (!letter) throw new Error('Scene not ready'); + await page.keyboard.press(letter); + await page.waitForTimeout(400); + const after = await page.evaluate(() => window.__AS_SCENE__.letterIndex); + if (after !== (before || 0) + 1) throw new Error(`Letter index 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-spell-lib.test.js b/tests/unit/animal-spell-lib.test.js @@ -0,0 +1,54 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +require('../../games/animal-letter/lib.js'); +const lib = require('../../games/animal-spell/lib.js'); + +test('getWordLetters splits name into uppercase letters', () => { + const cat = lib.ANIMALS.find(a => a.key === 'cat'); + assert.ok(cat, 'cat should be in ANIMALS'); + assert.deepEqual(lib.getWordLetters(cat), ['C', 'A', 'T']); +}); + +test('getWordLetters works for longer animal names', () => { + const alligator = lib.ANIMALS.find(a => a.key === 'alligator'); + assert.ok(alligator); + assert.deepEqual(lib.getWordLetters(alligator), ['A', 'L', 'L', 'I', 'G', 'A', 'T', 'O', 'R']); +}); + +test('getWordLetters length matches animal name length', () => { + lib.ANIMALS.forEach(a => { + assert.equal(lib.getWordLetters(a).length, a.name.length, + `${a.name}: letter count mismatch`); + }); +}); + +test('all getWordLetters results are uppercase single letters', () => { + lib.ANIMALS.forEach(a => { + lib.getWordLetters(a).forEach(ch => { + assert.match(ch, /^[A-Z]$/, `${a.name} produced non-letter: ${ch}`); + }); + }); +}); + +test('ANIMALS list is shared with AnimalLetterLib', () => { + assert.equal(lib.ANIMALS.length, 51); + lib.ANIMALS.forEach(a => { + assert.ok(a.key); + assert.ok(a.letter); + assert.ok(a.name); + assert.ok(a.codepoint); + }); +}); + +test('shuffleAnimals returns same animals in different order', () => { + let n = 0; + const rng = () => ((n++ * 137) % 97) / 97; + const shuffled = lib.shuffleAnimals(rng); + assert.equal(shuffled.length, lib.ANIMALS.length); + assert.deepEqual( + shuffled.map(a => a.key).sort(), + lib.ANIMALS.map(a => a.key).sort() + ); +});