commit 56f3e0b265473d20f8fe8747ca556207fa592481
parent 1ad5439617774e2f2137de8fcd5c432417c73195
Author: Kyle Barlow <kb@kylebarlow.com>
Date: Sun, 26 Apr 2026 08:49:34 -0700
Animal spelling game
Diffstat:
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">← 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="../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()
+ );
+});