game.js (14020B)
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 // F major — circus-like leaps and animal energy, extended to a ~1-minute song: 6 // 32-step sections (eighth notes at 128 BPM) concatenated so the loop is long 7 // enough not to grate. 'A' is the original fanfare; B–D are new sections in 8 // the same character. '.' = rest. Harmony is a diatonic third above the 9 // melody (it plays during the success jingle). 10 const N = s => s.trim().split(/\s+/).map(t => (t === '.' ? null : t)); 11 12 const MEL = { 13 A: N(`F4 C5 F5 C5 F4 A4 C5 A4 Bb4 D5 F5 Bb5 F5 D5 Bb4 F4 14 C5 E5 G5 C6 G5 E5 C5 E5 F5 D5 Bb4 F4 C5 F5 A5 F5`), 15 B: N(`F5 E5 D5 C5 D5 E5 F5 A5 G5 F5 E5 D5 C5 D5 E5 C5 16 A5 G5 F5 G5 A5 C6 A5 F5 G5 E5 C5 E5 F5 A5 G5 F5`), 17 C: N(`D5 F5 A5 D6 A5 F5 D5 F5 C5 E5 G5 C6 G5 E5 C5 E5 18 Bb4 D5 F5 Bb5 F5 D5 Bb4 D5 C5 F5 A5 C6 A5 G5 F5 F5`), 19 D: N(`A4 Bb4 C5 D5 C5 Bb4 A4 F4 G4 A4 Bb4 C5 Bb4 A4 G4 E4 20 F4 A4 C5 F5 E5 C5 A4 C5 D5 C5 Bb4 A4 G4 C5 E5 F5`), 21 }; 22 const HAR = { 23 A: N(`A4 E5 A5 E5 A4 C5 E5 C5 D5 F5 A5 D6 A5 F5 D5 A4 24 E5 G5 Bb5 E6 Bb5 G5 E5 G5 A5 F5 D5 A4 E5 A5 C6 A5`), 25 B: N(`A5 G5 F5 E5 F5 G5 A5 C6 Bb5 A5 G5 F5 E5 F5 G5 E5 26 C6 Bb5 A5 Bb5 C6 E6 C6 A5 Bb5 G5 E5 G5 A5 C6 Bb5 A5`), 27 C: N(`F5 A5 C6 F6 C6 A5 F5 A5 E5 G5 Bb5 E6 Bb5 G5 E5 G5 28 D5 F5 A5 D6 A5 F5 D5 F5 E5 A5 C6 E6 C6 Bb5 A5 A5`), 29 D: N(`C5 D5 E5 F5 E5 D5 C5 A4 Bb4 C5 D5 E5 D5 C5 Bb4 G4 30 A4 C5 E5 A5 G5 E5 C5 E5 F5 E5 D5 C5 Bb4 E5 G5 A5`), 31 }; 32 const BAS = { 33 A: N(`F2 . . . . . . . Bb2 . . . C3 . . . F2 . . . . . . . Bb2 . . . C3 . . .`), 34 B: N(`F2 . . . . . . . C3 . . . . . . . F2 . . . . . . . C3 . . . F2 . . .`), 35 C: N(`D3 . . . . . . . C3 . . . . . . . Bb2 . . . . . . . C3 . . . F2 . . .`), 36 D: N(`F2 . . . . . . . C3 . . . . . . . F2 . . . . . . . Bb2 . . . C3 . . .`), 37 }; 38 const ORDER = ['A','B','A','C','D','B','C','A']; 39 const MELODY = ORDER.flatMap(k => MEL[k]); 40 const HARMONY = ORDER.flatMap(k => HAR[k]); 41 const BASS = ORDER.flatMap(k => BAS[k]); 42 43 class AnimalLetterScene extends Phaser.Scene { 44 constructor() { 45 super({ key: 'AnimalLetterScene' }); 46 this.audio = null; 47 this.currentAnimal = null; 48 this.roundColor = null; 49 this.wrongCount = 0; 50 this.accepting = false; 51 this.lettersFound = 0; 52 this.winShowing = false; 53 this.progressGfx = null; 54 this.winElements = []; 55 this.guessTexts = []; 56 this.animalImg = null; 57 this.instrText = null; 58 this.nameFirstLetterText = null; 59 this.nameRestText = null; 60 this.hintText = null; 61 this._imgScale = 1; 62 this._idleTweens = []; 63 this._deck = []; // shuffled queue; drawn without replacement per game 64 } 65 66 preload() { 67 KGames.bakeFireworksAtlas(this); 68 AnimalLetterLib.ANIMALS.forEach(a => { 69 this.load.svg(a.key, CDN + a.codepoint + '.svg', { width: 256, height: 256 }); 70 }); 71 } 72 73 create() { 74 window.__AL_SCENE__ = this; 75 const W = this.scale.width; 76 const H = this.scale.height; 77 78 this.audio = new KGames.AudioSystem({ melody: MELODY, harmony: HARMONY, bass: BASS }); 79 80 this.progressGfx = this.add.graphics(); 81 82 this.instrText = this.add.text(W / 2, 0, '', { 83 fontFamily: 'Fredoka, sans-serif', 84 color: '#888888', 85 }).setOrigin(0.5, 0.5); 86 87 this.animalImg = this.add.image(W / 2, H * 0.43, '__DEFAULT').setVisible(false); 88 89 // First letter of name — shown in accent color 90 this.nameFirstLetterText = this.add.text(0, 0, '', { 91 fontFamily: 'Fredoka, sans-serif', 92 fontStyle: 'bold', 93 color: '#ffffff', 94 }).setOrigin(0, 0.5).setVisible(false); 95 96 // Remainder of name — shown in white 97 this.nameRestText = this.add.text(0, 0, '', { 98 fontFamily: 'Fredoka, sans-serif', 99 fontStyle: 'bold', 100 color: '#ffffff', 101 }).setOrigin(0, 0.5).setVisible(false); 102 103 // Large colored first-letter hint shown at hint level 3 104 this.hintText = this.add.text(W / 2, 0, '', { 105 fontFamily: 'Fredoka, sans-serif', 106 fontStyle: 'bold', 107 }).setOrigin(0.5, 0.5).setVisible(false); 108 109 this.scale.on('resize', this.onResize, this); 110 this.input.keyboard.on('keydown', this.onKey, this); 111 112 document.getElementById('btn-music').addEventListener('click', () => this.audio.toggleMusic()); 113 document.getElementById('btn-sfx').addEventListener('click', () => this.audio.toggleSfx()); 114 115 this.newRound(); 116 this.applyLayout(W, H); 117 } 118 119 applyLayout(W, H) { 120 const instrSize = Math.max(16, Math.floor(H * 0.055)); 121 const hintSize = Math.max(40, Math.floor(H * 0.12)); 122 const imgSize = Math.max(80, Math.floor(H * 0.38)); 123 124 this.instrText.setStyle({ fontSize: instrSize + 'px' }); 125 this.instrText.setPosition(W / 2, H * 0.14); 126 127 const wasAccepting = this.accepting; 128 this._stopIdleAnimation(); 129 130 this._imgScale = imgSize / 256; 131 if (this.animalImg.visible) { 132 this.animalImg.setPosition(W / 2, H * 0.43).setScale(this._imgScale); 133 } 134 135 if (wasAccepting) this._startIdleAnimation(); 136 137 this._reflowNameDisplay(W, H); 138 139 this.hintText.setStyle({ fontSize: hintSize + 'px' }); 140 this.hintText.setPosition(W / 2, H * 0.80); 141 142 KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL); 143 this.reflowGuesses(W, H); 144 } 145 146 onResize(gameSize) { 147 this.applyLayout(gameSize.width, gameSize.height); 148 } 149 150 // Place both name text objects centered together at H*0.70 151 _reflowNameDisplay(W, H) { 152 const nameSize = Math.max(24, Math.floor(H * 0.075)); 153 this.nameFirstLetterText.setStyle({ fontSize: nameSize + 'px' }); 154 this.nameRestText.setStyle({ fontSize: nameSize + 'px' }); 155 156 if (!this.nameFirstLetterText.visible) return; 157 158 const firstW = this.nameFirstLetterText.width; 159 const restW = this.nameRestText.width; 160 const totalW = firstW + restW; 161 const startX = W / 2 - totalW / 2; 162 this.nameFirstLetterText.setPosition(startX, H * 0.70); 163 this.nameRestText.setPosition(startX + firstW, H * 0.70); 164 } 165 166 // Show animal name with first letter in accent color 167 _showNameDisplay(animal, color) { 168 const { width: W, height: H } = this.scale; 169 const nameSize = Math.max(24, Math.floor(H * 0.075)); 170 171 this.nameFirstLetterText 172 .setText(animal.name[0]) 173 .setStyle({ fontSize: nameSize + 'px', fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color }); 174 this.nameRestText 175 .setText(animal.name.slice(1)) 176 .setStyle({ fontSize: nameSize + 'px', fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#ffffff' }); 177 178 this.nameFirstLetterText.setVisible(true); 179 this.nameRestText.setVisible(true); 180 181 this._reflowNameDisplay(W, H); 182 } 183 184 _startIdleAnimation() { 185 const { width: W } = this.scale; 186 const swayAmt = Math.max(6, Math.floor(W * 0.012)); 187 188 this._idleTweens = [ 189 this.tweens.add({ 190 targets: this.animalImg, 191 x: this.animalImg.x + swayAmt, 192 ease: 'Sine.InOut', 193 duration: 1600, 194 yoyo: true, 195 repeat: -1, 196 }), 197 this.tweens.add({ 198 targets: this.animalImg, 199 scaleX: this._imgScale * 1.06, 200 scaleY: this._imgScale * 1.06, 201 ease: 'Sine.InOut', 202 duration: 1100, 203 yoyo: true, 204 repeat: -1, 205 }), 206 ]; 207 } 208 209 _stopIdleAnimation() { 210 this._idleTweens.forEach(t => { try { t.destroy(); } catch (_) {} }); 211 this._idleTweens = []; 212 } 213 214 newRound() { 215 this._stopIdleAnimation(); 216 217 // Refill deck (shuffled, no-repeat) when exhausted 218 if (this._deck.length === 0) { 219 this._deck = AnimalLetterLib.shuffleAnimals(Math.random); 220 } 221 this.currentAnimal = this._deck.pop(); 222 this.wrongCount = 0; 223 224 const available = COLORS.filter(c => c !== this.roundColor); 225 this.roundColor = available[Math.floor(Math.random() * available.length)]; 226 227 this.nameFirstLetterText.setVisible(false); 228 this.nameRestText.setVisible(false); 229 this.hintText.setVisible(false); 230 this.instrText.setText('Press the first letter').setColor('#888888'); 231 232 const { width: W, height: H } = this.scale; 233 const imgSize = Math.max(80, Math.floor(H * 0.38)); 234 this._imgScale = imgSize / 256; 235 236 if (this.textures.exists(this.currentAnimal.key)) { 237 this.animalImg 238 .setTexture(this.currentAnimal.key) 239 .setPosition(W / 2, H * 0.43) 240 .setScale(this._imgScale * 0.3) 241 .setVisible(true); 242 this.tweens.add({ 243 targets: this.animalImg, 244 scaleX: this._imgScale, 245 scaleY: this._imgScale, 246 ease: 'Back.Out', 247 duration: 250, 248 onComplete: () => { 249 this.accepting = true; 250 this._startIdleAnimation(); 251 }, 252 }); 253 } else { 254 this.animalImg.setVisible(false); 255 this.accepting = true; 256 } 257 258 this.audio.speak(this.currentAnimal.name); 259 } 260 261 onKey(event) { 262 if (this.winShowing) return; 263 if (!this.accepting) return; 264 if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return; 265 266 this.audio.initMusic(); 267 268 const pressed = event.key.toUpperCase(); 269 if (pressed === this.currentAnimal.letter) { 270 this.onCorrect(); 271 } else { 272 this.onWrong(pressed); 273 } 274 } 275 276 onCorrect() { 277 this.accepting = false; 278 this._stopIdleAnimation(); 279 280 this.audio.playSuccess(); 281 this.audio.playSuccessJingle(); 282 283 const { width: W, height: H } = this.scale; 284 KGames.burstConfetti(this, W / 2, H * 0.43); 285 286 // Always show the animal name with first letter accented so kids read the word 287 this._showNameDisplay(this.currentAnimal, this.roundColor); 288 this.audio.speak(this.currentAnimal.name); 289 290 this.lettersFound++; 291 KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL); 292 293 this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); }); 294 this.guessTexts = []; 295 296 if (this.lettersFound >= GOAL) { 297 this.time.delayedCall(1500, () => { 298 this.winShowing = true; 299 this.accepting = false; 300 this.winElements = KGames.showWinScreen(this, { 301 title: 'Amazing!', 302 subtitle: 'You know all the animal letters!', 303 audio: this.audio, 304 onPlayAgain: () => this.resetGame(), 305 }); 306 }); 307 } else { 308 this.time.delayedCall(1500, () => { 309 this.newRound(); 310 this.applyLayout(this.scale.width, this.scale.height); 311 }); 312 } 313 } 314 315 onWrong(pressed) { 316 this.audio.playBong(); 317 this.audio.playFailLick(); 318 this.wrongCount++; 319 this.updateHints(); 320 this.addGuessLetter(pressed); 321 } 322 323 updateHints() { 324 const animal = this.currentAnimal; 325 const { width: W, height: H } = this.scale; 326 const hintSize = Math.max(40, Math.floor(H * 0.12)); 327 328 if (this.wrongCount === 1) { 329 // Show name with first letter accented — speak it so child can sound out the first letter 330 this._showNameDisplay(animal, this.roundColor); 331 this.audio.speak(animal.name); 332 } else if (this.wrongCount >= 2) { 333 // Show big colored first letter + update instruction — speak just the letter 334 this.hintText 335 .setText(animal.letter) 336 .setStyle({ fontSize: hintSize + 'px', color: this.roundColor }) 337 .setPosition(W / 2, H * 0.80) 338 .setVisible(true); 339 this.instrText.setText('Press ' + animal.letter).setColor(this.roundColor); 340 this.audio.speak(animal.letter); 341 } 342 } 343 344 addGuessLetter(char) { 345 const { width: W, height: H } = this.scale; 346 const size = Math.max(20, Math.floor(H * 0.10)); 347 348 const text = this.add.text(W / 2, H * 0.88, char, { 349 fontFamily: 'Fredoka, sans-serif', 350 fontSize: size + 'px', 351 fontStyle: 'bold', 352 color: '#cc4444', 353 }).setOrigin(0.5).setAlpha(0).setScale(0.3); 354 355 this.tweens.add({ 356 targets: text, 357 scaleX: 1, scaleY: 1, alpha: 1, 358 ease: 'Back.Out', 359 duration: 150, 360 }); 361 362 this.guessTexts.push(text); 363 this.reflowGuesses(W, H); 364 365 this.time.delayedCall(1200, () => { 366 if (!text.active) return; 367 this.tweens.add({ 368 targets: text, 369 alpha: 0, scaleX: 0.2, scaleY: 0.2, 370 duration: 200, 371 onComplete: () => { 372 text.destroy(); 373 this.guessTexts = this.guessTexts.filter(t => t !== text); 374 this.reflowGuesses(this.scale.width, this.scale.height); 375 }, 376 }); 377 }); 378 } 379 380 reflowGuesses(W, H) { 381 const n = this.guessTexts.length; 382 if (n === 0) return; 383 const size = Math.max(20, Math.floor(H * 0.10)); 384 const spacing = size * 1.15; 385 const startX = W / 2 - ((n - 1) * spacing) / 2; 386 this.guessTexts.forEach((obj, i) => { 387 obj.setPosition(startX + i * spacing, H * 0.88); 388 obj.setStyle({ fontSize: size + 'px' }); 389 }); 390 } 391 392 resetGame() { 393 this._stopIdleAnimation(); 394 395 this.winElements.forEach(el => { if (el.active) el.destroy(); }); 396 this.winElements = []; 397 this.winShowing = false; 398 399 this.guessTexts.forEach(obj => { if (obj.active) obj.destroy(); }); 400 this.guessTexts = []; 401 402 this.lettersFound = 0; 403 this.currentAnimal = null; 404 this.roundColor = null; 405 this.wrongCount = 0; 406 this._deck = []; // fresh shuffle for new game 407 408 const { width: W, height: H } = this.scale; 409 KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL); 410 this.newRound(); 411 this.applyLayout(W, H); 412 } 413 } 414 415 window.__KG_GAME__ = new Phaser.Game({ 416 type: Phaser.AUTO, 417 parent: 'game-container', 418 backgroundColor: '#1A1A2E', 419 scene: AnimalLetterScene, 420 scale: { 421 mode: Phaser.Scale.RESIZE, 422 width: '100%', 423 height: '100%', 424 }, 425 render: { preserveDrawingBuffer: true }, 426 });