game.js (15435B)
1 const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6']; 2 const GOAL = 10; 3 const CDN = 'https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/svg/'; 4 5 // ~1-minute song: 32-step sections (eighth notes at 128 BPM) concatenated so 6 // the loop is long enough not to grate. 'A' is the original circus fanfare; 7 // B–D are new sections in the same F-major character. '.' = rest. Harmony is 8 // a diatonic third above the melody (it plays during the success jingle). 9 const N = s => s.trim().split(/\s+/).map(t => (t === '.' ? null : t)); 10 11 const MEL = { 12 A: N(`F4 C5 F5 C5 F4 A4 C5 A4 Bb4 D5 F5 Bb5 F5 D5 Bb4 F4 13 C5 E5 G5 C6 G5 E5 C5 E5 F5 D5 Bb4 F4 C5 F5 A5 F5`), 14 B: N(`F5 E5 D5 C5 D5 E5 F5 A5 G5 F5 E5 D5 C5 D5 E5 C5 15 A5 G5 F5 G5 A5 C6 A5 F5 G5 E5 C5 E5 F5 A5 G5 F5`), 16 C: N(`D5 F5 A5 D6 A5 F5 D5 F5 C5 E5 G5 C6 G5 E5 C5 E5 17 Bb4 D5 F5 Bb5 F5 D5 Bb4 D5 C5 F5 A5 C6 A5 G5 F5 F5`), 18 D: N(`A4 Bb4 C5 D5 C5 Bb4 A4 F4 G4 A4 Bb4 C5 Bb4 A4 G4 E4 19 F4 A4 C5 F5 E5 C5 A4 C5 D5 C5 Bb4 A4 G4 C5 E5 F5`), 20 }; 21 const HAR = { 22 A: N(`A4 E5 A5 E5 A4 C5 E5 C5 D5 F5 A5 D6 A5 F5 D5 A4 23 E5 G5 Bb5 E6 Bb5 G5 E5 G5 A5 F5 D5 A4 E5 A5 C6 A5`), 24 B: N(`A5 G5 F5 E5 F5 G5 A5 C6 Bb5 A5 G5 F5 E5 F5 G5 E5 25 C6 Bb5 A5 Bb5 C6 E6 C6 A5 Bb5 G5 E5 G5 A5 C6 Bb5 A5`), 26 C: N(`F5 A5 C6 F6 C6 A5 F5 A5 E5 G5 Bb5 E6 Bb5 G5 E5 G5 27 D5 F5 A5 D6 A5 F5 D5 F5 E5 A5 C6 E6 C6 Bb5 A5 A5`), 28 D: N(`C5 D5 E5 F5 E5 D5 C5 A4 Bb4 C5 D5 E5 D5 C5 Bb4 G4 29 A4 C5 E5 A5 G5 E5 C5 E5 F5 E5 D5 C5 Bb4 E5 G5 A5`), 30 }; 31 const BAS = { 32 A: N(`F2 . . . . . . . Bb2 . . . C3 . . . F2 . . . . . . . Bb2 . . . C3 . . .`), 33 B: N(`F2 . . . . . . . C3 . . . . . . . F2 . . . . . . . C3 . . . F2 . . .`), 34 C: N(`D3 . . . . . . . C3 . . . . . . . Bb2 . . . . . . . C3 . . . F2 . . .`), 35 D: N(`F2 . . . . . . . C3 . . . . . . . F2 . . . . . . . Bb2 . . . C3 . . .`), 36 }; 37 const ORDER = ['A','B','A','C','D','B','C','A']; 38 const MELODY = ORDER.flatMap(k => MEL[k]); 39 const HARMONY = ORDER.flatMap(k => HAR[k]); 40 const BASS = ORDER.flatMap(k => BAS[k]); 41 42 class AnimalSpellScene extends Phaser.Scene { 43 constructor() { 44 super({ key: 'AnimalSpellScene' }); 45 this.audio = null; 46 this.currentAnimal = null; 47 this.roundColor = null; 48 this.wordLetters = []; 49 this.letterIndex = 0; 50 this.wrongCount = 0; 51 this.accepting = false; 52 this.wordsSpelled = 0; 53 this.winShowing = false; 54 this.progressGfx = null; 55 this.winElements = []; 56 this.guessTexts = []; 57 this.animalImg = null; 58 this.instrText = null; 59 this.hintText = null; 60 this._imgScale = 1; 61 this._idleTweens = []; 62 this._deck = []; 63 this._slotTexts = []; 64 this._slotGfx = null; 65 } 66 67 preload() { 68 KGames.bakeFireworksAtlas(this); 69 AnimalSpellLib.ANIMALS.forEach(a => { 70 this.load.svg(a.key, CDN + a.codepoint + '.svg', { width: 256, height: 256 }); 71 }); 72 } 73 74 create() { 75 window.__AS_SCENE__ = this; 76 const W = this.scale.width; 77 const H = this.scale.height; 78 79 this.audio = new KGames.AudioSystem({ melody: MELODY, harmony: HARMONY, bass: BASS }); 80 this.progressGfx = this.add.graphics(); 81 this._slotGfx = this.add.graphics(); 82 83 this.instrText = this.add.text(W / 2, H * 0.14, '', { 84 fontFamily: 'Fredoka, sans-serif', 85 color: '#888888', 86 }).setOrigin(0.5, 0.5); 87 88 this.animalImg = this.add.image(W / 2, H * 0.36, '__DEFAULT').setVisible(false); 89 90 this.hintText = this.add.text(W / 2, H * 0.80, '', { 91 fontFamily: 'Fredoka, sans-serif', 92 fontStyle: 'bold', 93 }).setOrigin(0.5, 0.5).setVisible(false); 94 95 this.scale.on('resize', this.onResize, this); 96 this.input.keyboard.on('keydown', this.onKey, this); 97 98 document.getElementById('btn-music').addEventListener('click', () => this.audio.toggleMusic()); 99 document.getElementById('btn-sfx').addEventListener('click', () => this.audio.toggleSfx()); 100 101 this.newRound(); 102 this.applyLayout(W, H); 103 } 104 105 applyLayout(W, H) { 106 const instrSize = Math.max(16, Math.floor(H * 0.055)); 107 const hintSize = Math.max(40, Math.floor(H * 0.12)); 108 const imgSize = Math.max(80, Math.floor(H * 0.32)); 109 110 this.instrText.setStyle({ fontSize: instrSize + 'px' }).setPosition(W / 2, H * 0.14); 111 112 const wasAccepting = this.accepting; 113 this._stopIdleAnimation(); 114 115 this._imgScale = imgSize / 256; 116 if (this.animalImg.visible) { 117 this.animalImg.setPosition(W / 2, H * 0.36).setScale(this._imgScale); 118 } 119 120 if (wasAccepting) this._startIdleAnimation(); 121 122 this.hintText.setStyle({ fontSize: hintSize + 'px' }).setPosition(W / 2, H * 0.80); 123 124 KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL); 125 this._redrawSlots(W, H); 126 this.reflowGuesses(W, H); 127 } 128 129 onResize(gameSize) { 130 this.applyLayout(gameSize.width, gameSize.height); 131 } 132 133 _slotMetrics(W, H) { 134 const n = this.wordLetters.length; 135 if (n === 0) return { startX: 0, slotW: 40, y: H * 0.67, fontSize: 24 }; 136 const maxSlotW = Math.floor(W / (n + 1)); 137 const slotW = Math.min(maxSlotW, Math.floor(H * 0.09)); 138 const startX = (W - slotW * n) / 2 + slotW / 2; 139 const y = H * 0.67; 140 const fontSize = Math.max(20, Math.min(Math.floor(slotW * 0.75), Math.floor(H * 0.07))); 141 return { startX, slotW, y, fontSize }; 142 } 143 144 _buildSlots(W, H) { 145 this._slotTexts.forEach(t => { if (t.active) t.destroy(); }); 146 this._slotTexts = []; 147 148 const { startX, slotW, y, fontSize } = this._slotMetrics(W, H); 149 this.wordLetters.forEach((_, i) => { 150 const x = startX + i * slotW; 151 const t = this.add.text(x, y, '', { 152 fontFamily: 'Fredoka, sans-serif', 153 fontSize: fontSize + 'px', 154 fontStyle: 'bold', 155 color: '#ffffff', 156 }).setOrigin(0.5, 0.5).setVisible(false); 157 this._slotTexts.push(t); 158 }); 159 } 160 161 _redrawSlots(W, H) { 162 if (this._slotTexts.length === 0) return; 163 const { startX, slotW, y, fontSize } = this._slotMetrics(W, H); 164 const underY = y + Math.floor(fontSize * 0.6); 165 const lineLen = Math.floor(slotW * 0.72); 166 const lineW = Math.max(2, Math.floor(fontSize * 0.07)); 167 168 this._slotGfx.clear(); 169 this._slotGfx.lineStyle(lineW, 0x555577, 1); 170 171 this._slotTexts.forEach((t, i) => { 172 const x = startX + i * slotW; 173 t.setPosition(x, y).setStyle({ fontSize: fontSize + 'px' }); 174 175 if (i < this.letterIndex) { 176 t.setText(this.wordLetters[i]).setColor('#ffffff').setVisible(true); 177 } else if (i === this.letterIndex) { 178 if (this.wrongCount === 0) { 179 t.setVisible(false); 180 this._slotGfx.beginPath(); 181 this._slotGfx.moveTo(x - lineLen / 2, underY); 182 this._slotGfx.lineTo(x + lineLen / 2, underY); 183 this._slotGfx.strokePath(); 184 } else { 185 // Hint: show letter in muted color, underline disappears 186 t.setText(this.wordLetters[i]).setColor('#888888').setVisible(true); 187 } 188 } else { 189 t.setVisible(false); 190 this._slotGfx.beginPath(); 191 this._slotGfx.moveTo(x - lineLen / 2, underY); 192 this._slotGfx.lineTo(x + lineLen / 2, underY); 193 this._slotGfx.strokePath(); 194 } 195 }); 196 } 197 198 _startIdleAnimation() { 199 const { width: W } = this.scale; 200 const swayAmt = Math.max(6, Math.floor(W * 0.012)); 201 this._idleTweens = [ 202 this.tweens.add({ 203 targets: this.animalImg, 204 x: this.animalImg.x + swayAmt, 205 ease: 'Sine.InOut', 206 duration: 1600, 207 yoyo: true, 208 repeat: -1, 209 }), 210 this.tweens.add({ 211 targets: this.animalImg, 212 scaleX: this._imgScale * 1.06, 213 scaleY: this._imgScale * 1.06, 214 ease: 'Sine.InOut', 215 duration: 1100, 216 yoyo: true, 217 repeat: -1, 218 }), 219 ]; 220 } 221 222 _stopIdleAnimation() { 223 this._idleTweens.forEach(t => { try { t.destroy(); } catch (_) {} }); 224 this._idleTweens = []; 225 } 226 227 newRound() { 228 this._stopIdleAnimation(); 229 230 if (this._deck.length === 0) { 231 this._deck = AnimalSpellLib.shuffleAnimals(Math.random); 232 } 233 this.currentAnimal = this._deck.pop(); 234 this.wordLetters = AnimalSpellLib.getWordLetters(this.currentAnimal); 235 this.letterIndex = 0; 236 this.wrongCount = 0; 237 238 const available = COLORS.filter(c => c !== this.roundColor); 239 this.roundColor = available[Math.floor(Math.random() * available.length)]; 240 241 this.hintText.setVisible(false); 242 this.instrText.setText('Spell the animal!').setColor('#888888'); 243 244 const { width: W, height: H } = this.scale; 245 const imgSize = Math.max(80, Math.floor(H * 0.32)); 246 this._imgScale = imgSize / 256; 247 248 this._buildSlots(W, H); 249 this._redrawSlots(W, H); 250 251 if (this.textures.exists(this.currentAnimal.key)) { 252 this.animalImg 253 .setTexture(this.currentAnimal.key) 254 .setPosition(W / 2, H * 0.36) 255 .setScale(this._imgScale * 0.3) 256 .setVisible(true); 257 this.tweens.add({ 258 targets: this.animalImg, 259 scaleX: this._imgScale, 260 scaleY: this._imgScale, 261 ease: 'Back.Out', 262 duration: 250, 263 onComplete: () => { 264 this.accepting = true; 265 this._startIdleAnimation(); 266 }, 267 }); 268 } else { 269 this.animalImg.setVisible(false); 270 this.accepting = true; 271 } 272 273 this.audio.speak(this.currentAnimal.name); 274 } 275 276 onKey(event) { 277 if (this.winShowing) return; 278 if (!this.accepting) return; 279 if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return; 280 281 this.audio.initMusic(); 282 283 const pressed = event.key.toUpperCase(); 284 if (pressed === this.wordLetters[this.letterIndex]) { 285 this.onLetterCorrect(); 286 } else { 287 this.onLetterWrong(pressed); 288 } 289 } 290 291 onLetterCorrect() { 292 this.accepting = false; 293 this.audio.playPing(); 294 295 this.hintText.setVisible(false); 296 this.instrText.setText('Spell the animal!').setColor('#888888'); 297 this.wrongCount = 0; 298 299 const { width: W, height: H } = this.scale; 300 const { startX, slotW, y } = this._slotMetrics(W, H); 301 const slotX = startX + this.letterIndex * slotW; 302 303 // Pop the confirmed letter into the slot 304 const slot = this._slotTexts[this.letterIndex]; 305 slot.setText(this.wordLetters[this.letterIndex]) 306 .setColor('#ffffff') 307 .setVisible(true) 308 .setScale(0.3) 309 .setAlpha(0); 310 this.tweens.add({ 311 targets: slot, 312 scaleX: 1, scaleY: 1, alpha: 1, 313 ease: 'Back.Out', 314 duration: 180, 315 }); 316 317 KGames.burstConfetti(this, slotX, y); 318 319 this.letterIndex++; 320 this._redrawSlots(W, H); 321 322 this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); }); 323 this.guessTexts = []; 324 325 if (this.letterIndex >= this.wordLetters.length) { 326 this.time.delayedCall(400, () => this.onWordComplete()); 327 } else { 328 this.time.delayedCall(200, () => { this.accepting = true; }); 329 } 330 } 331 332 onWordComplete() { 333 this._stopIdleAnimation(); 334 this.audio.playSuccess(); 335 this.audio.playSuccessJingle(); 336 337 const { width: W, height: H } = this.scale; 338 KGames.burstConfetti(this, W / 2, H * 0.36); 339 340 // Flash all slots in the round color 341 this._slotTexts.forEach(t => t.setColor(this.roundColor)); 342 this.audio.speak(this.currentAnimal.name); 343 344 this.wordsSpelled++; 345 KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL); 346 347 if (this.wordsSpelled >= GOAL) { 348 this.time.delayedCall(1500, () => { 349 this.winShowing = true; 350 this.accepting = false; 351 this.winElements = KGames.showWinScreen(this, { 352 title: 'Amazing!', 353 subtitle: 'You can spell all the animals!', 354 audio: this.audio, 355 onPlayAgain: () => this.resetGame(), 356 }); 357 }); 358 } else { 359 this.time.delayedCall(1500, () => { 360 this.newRound(); 361 this.applyLayout(this.scale.width, this.scale.height); 362 }); 363 } 364 } 365 366 onLetterWrong(pressed) { 367 this.audio.playBong(); 368 this.audio.playFailLick(); 369 this.wrongCount++; 370 this.updateHints(); 371 this.addGuessLetter(pressed); 372 } 373 374 updateHints() { 375 const { width: W, height: H } = this.scale; 376 const hintSize = Math.max(40, Math.floor(H * 0.12)); 377 const currentLetter = this.wordLetters[this.letterIndex]; 378 379 if (this.wrongCount === 1) { 380 // Reveal the current letter in the slot as a hint 381 this._redrawSlots(W, H); 382 this.audio.speak(this.currentAnimal.name); 383 } else if (this.wrongCount >= 2) { 384 // Big letter hint below + instruction update 385 this.hintText 386 .setText(currentLetter) 387 .setStyle({ fontSize: hintSize + 'px', color: this.roundColor }) 388 .setPosition(W / 2, H * 0.80) 389 .setVisible(true); 390 this.instrText.setText('Press ' + currentLetter).setColor(this.roundColor); 391 this.audio.speak(currentLetter); 392 } 393 } 394 395 addGuessLetter(char) { 396 const { width: W, height: H } = this.scale; 397 const size = Math.max(20, Math.floor(H * 0.10)); 398 399 const text = this.add.text(W / 2, H * 0.88, char, { 400 fontFamily: 'Fredoka, sans-serif', 401 fontSize: size + 'px', 402 fontStyle: 'bold', 403 color: '#cc4444', 404 }).setOrigin(0.5).setAlpha(0).setScale(0.3); 405 406 this.tweens.add({ 407 targets: text, 408 scaleX: 1, scaleY: 1, alpha: 1, 409 ease: 'Back.Out', 410 duration: 150, 411 }); 412 413 this.guessTexts.push(text); 414 this.reflowGuesses(W, H); 415 416 this.time.delayedCall(1200, () => { 417 if (!text.active) return; 418 this.tweens.add({ 419 targets: text, 420 alpha: 0, scaleX: 0.2, scaleY: 0.2, 421 duration: 200, 422 onComplete: () => { 423 text.destroy(); 424 this.guessTexts = this.guessTexts.filter(t => t !== text); 425 this.reflowGuesses(this.scale.width, this.scale.height); 426 }, 427 }); 428 }); 429 } 430 431 reflowGuesses(W, H) { 432 const n = this.guessTexts.length; 433 if (n === 0) return; 434 const size = Math.max(20, Math.floor(H * 0.10)); 435 const spacing = size * 1.15; 436 const startX = W / 2 - ((n - 1) * spacing) / 2; 437 this.guessTexts.forEach((obj, i) => { 438 obj.setPosition(startX + i * spacing, H * 0.88); 439 obj.setStyle({ fontSize: size + 'px' }); 440 }); 441 } 442 443 resetGame() { 444 this._stopIdleAnimation(); 445 446 this.winElements.forEach(el => { if (el.active) el.destroy(); }); 447 this.winElements = []; 448 this.winShowing = false; 449 450 this._slotTexts.forEach(t => { if (t.active) t.destroy(); }); 451 this._slotTexts = []; 452 453 this.guessTexts.forEach(obj => { if (obj.active) obj.destroy(); }); 454 this.guessTexts = []; 455 456 this.wordsSpelled = 0; 457 this.currentAnimal = null; 458 this.roundColor = null; 459 this.letterIndex = 0; 460 this.wrongCount = 0; 461 this._deck = []; 462 463 const { width: W, height: H } = this.scale; 464 KGames.drawProgressBar(this.progressGfx, W, H, this.wordsSpelled, GOAL); 465 this.newRound(); 466 this.applyLayout(W, H); 467 } 468 } 469 470 window.__KG_GAME__ = new Phaser.Game({ 471 type: Phaser.AUTO, 472 parent: 'game-container', 473 backgroundColor: '#1A1A2E', 474 scene: AnimalSpellScene, 475 scale: { 476 mode: Phaser.Scale.RESIZE, 477 width: '100%', 478 height: '100%', 479 }, 480 render: { preserveDrawingBuffer: true }, 481 });