game.js (8172B)
1 const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6']; 2 const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 3 const GOAL = 10; 4 5 // ~1-minute song: 16-step phrases (eighth notes at 128 BPM) concatenated so the 6 // loop is long enough not to grate. 'A' is the original theme; B–F are new 7 // sections in the same C-major character. '.' = rest. Harmony is a diatonic 8 // 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('C5 E5 G5 A5 G5 E5 C5 E5 F5 A5 C6 A5 G5 B4 C5 G4'), 13 B: N('E5 D5 C5 D5 E5 G5 E5 C5 D5 F5 A5 F5 E5 D5 C5 G4'), 14 C: N('G5 A5 B5 C6 B5 A5 G5 E5 A5 G5 F5 E5 D5 E5 F5 D5'), 15 D: N('C5 D5 E5 F5 E5 D5 C5 A4 B4 C5 D5 E5 D5 C5 B4 G4'), 16 E: N('C6 B5 A5 G5 A5 B5 C6 E6 D6 C6 B5 A5 G5 F5 E5 D5'), 17 F: N('E5 G5 C6 G5 E5 C5 D5 E5 F5 E5 D5 C5 D5 B4 C5 C5'), 18 }; 19 const HAR = { 20 A: N('E5 G5 B5 C6 B5 G5 E5 G5 A5 C6 E6 C6 B5 D5 E5 B4'), 21 B: N('G5 F5 E5 F5 G5 B5 G5 E5 F5 A5 C6 A5 G5 F5 E5 B4'), 22 C: N('B5 C6 D6 E6 D6 C6 B5 G5 C6 B5 A5 G5 F5 G5 A5 F5'), 23 D: N('E5 F5 G5 A5 G5 F5 E5 C5 D5 E5 F5 G5 F5 E5 D5 B4'), 24 E: N('E6 D6 C6 B5 C6 D6 E6 G6 F6 E6 D6 C6 B5 A5 G5 F5'), 25 F: N('G5 B5 E6 B5 G5 E5 F5 G5 A5 G5 F5 E5 F5 D5 E5 E5'), 26 }; 27 const BAS = { 28 A: N('C3 . . . . . . . F2 . . . G2 . . .'), 29 B: N('C3 . . . . . . . D3 . . . G2 . . .'), 30 C: N('G2 . . . . . . . F2 . . . G2 . . .'), 31 D: N('C3 . . . A2 . . . G2 . . . G2 . . .'), 32 E: N('C3 . . . F2 . . . G2 . . . G2 . . .'), 33 F: N('C3 . . . F2 . . . G2 . C3 . C3 . . .'), 34 }; 35 const ORDER = ['A','B','A','C','D','B','E','F','A','C','D','B','E','C','F','A']; 36 const MELODY = ORDER.flatMap(k => MEL[k]); 37 const HARMONY = ORDER.flatMap(k => HAR[k]); 38 const BASS = ORDER.flatMap(k => BAS[k]); 39 40 class LetterFindScene extends Phaser.Scene { 41 constructor() { 42 super({ key: 'LetterFindScene' }); 43 this.audio = null; 44 this.targetChar = null; 45 this.targetColor = null; 46 this.targetText = null; 47 this.instrText = null; 48 this.guessTexts = []; 49 this.accepting = false; 50 this.firstRound = true; 51 this.lettersFound = 0; 52 this.winShowing = false; 53 this.progressGfx = null; 54 this.winElements = []; 55 } 56 57 preload() { 58 KGames.bakeFireworksAtlas(this); 59 } 60 61 create() { 62 window.__LF_SCENE__ = this; 63 const W = this.scale.width; 64 const H = this.scale.height; 65 66 this.audio = new KGames.AudioSystem({ melody: MELODY, harmony: HARMONY, bass: BASS }); 67 68 this.progressGfx = this.add.graphics(); 69 70 this.instrText = this.add.text(W / 2, 0, 'Find and press the matching letter', { 71 fontFamily: 'Fredoka, sans-serif', 72 color: '#888888', 73 }).setOrigin(0.5, 0.5); 74 75 this.targetText = this.add.text(W / 2, 0, '', { 76 fontFamily: 'Fredoka, sans-serif', 77 fontStyle: 'bold', 78 color: '#ffffff', 79 }).setOrigin(0.5, 0.5); 80 81 this.scale.on('resize', this.onResize, this); 82 this.input.keyboard.on('keydown', this.onKey, this); 83 84 document.getElementById('btn-music').addEventListener('click', () => this.audio.toggleMusic()); 85 document.getElementById('btn-sfx').addEventListener('click', () => this.audio.toggleSfx()); 86 87 this.newTarget(); 88 this.applyLayout(W, H); 89 } 90 91 applyLayout(W, H) { 92 const instrSize = Math.max(16, Math.floor(H * 0.055)); 93 const targetSize = Math.max(48, Math.floor(H * 0.44)); 94 95 this.instrText.setStyle({ fontSize: instrSize + 'px' }); 96 this.instrText.setPosition(W / 2, H * 0.15); 97 98 this.targetText.setStyle({ fontSize: targetSize + 'px' }); 99 this.targetText.setPosition(W / 2, H * 0.47); 100 101 KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL); 102 this.reflowGuesses(W, H); 103 } 104 105 onResize(gameSize) { 106 this.applyLayout(gameSize.width, gameSize.height); 107 } 108 109 newTarget() { 110 let char, color; 111 do { char = CHARS[Phaser.Math.Between(0, CHARS.length - 1)]; } 112 while (char === this.targetChar); 113 114 const available = COLORS.filter(c => c !== this.targetColor); 115 color = Phaser.Math.RND.pick(available); 116 117 this.targetChar = char; 118 this.targetColor = color; 119 this.targetText.setText(char).setColor(color).setScale(0.3); 120 121 this.tweens.add({ 122 targets: this.targetText, 123 scaleX: 1, scaleY: 1, 124 ease: 'Back.Out', 125 duration: 200, 126 onComplete: () => { this.accepting = true; }, 127 }); 128 129 if (this.firstRound) { 130 this.audio.speak('find the letter. ' + char); 131 this.firstRound = false; 132 } else { 133 this.audio.speak(char); 134 } 135 } 136 137 onKey(event) { 138 if (this.winShowing) return; 139 if (!this.accepting) return; 140 if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return; 141 142 this.audio.initMusic(); 143 144 const pressed = event.key.toUpperCase(); 145 if (pressed === this.targetChar) { 146 this.onCorrect(); 147 } else { 148 this.onWrong(pressed); 149 } 150 } 151 152 onCorrect() { 153 this.accepting = false; 154 this.audio.playSuccess(); 155 this.audio.playSuccessJingle(); 156 KGames.burstConfetti(this, this.scale.width / 2, this.scale.height * 0.47); 157 158 this.lettersFound++; 159 KGames.drawProgressBar(this.progressGfx, this.scale.width, this.scale.height, this.lettersFound, GOAL); 160 161 this.guessTexts.forEach(obj => { this.tweens.killTweensOf(obj); obj.destroy(); }); 162 this.guessTexts = []; 163 164 if (this.lettersFound >= GOAL) { 165 this.time.delayedCall(700, () => { 166 this.winShowing = true; 167 this.accepting = false; 168 this.winElements = KGames.showWinScreen(this, { 169 title: 'You did it!', 170 subtitle: 'You found all 10 letters!', 171 audio: this.audio, 172 onPlayAgain: () => this.resetGame(), 173 }); 174 }); 175 } else { 176 this.time.delayedCall(950, () => { 177 this.newTarget(); 178 this.applyLayout(this.scale.width, this.scale.height); 179 }); 180 } 181 } 182 183 onWrong(char) { 184 this.audio.playBong(); 185 this.audio.playFailLick(); 186 this.addGuessLetter(char); 187 } 188 189 addGuessLetter(char) { 190 const { width: W, height: H } = this.scale; 191 const size = Math.max(20, Math.floor(H * 0.12)); 192 193 const text = this.add.text(W / 2, H * 0.78, char, { 194 fontFamily: 'Fredoka, sans-serif', 195 fontSize: size + 'px', 196 fontStyle: 'bold', 197 color: '#cc4444', 198 }).setOrigin(0.5).setAlpha(0).setScale(0.3); 199 200 this.tweens.add({ 201 targets: text, 202 scaleX: 1, scaleY: 1, alpha: 1, 203 ease: 'Back.Out', 204 duration: 150, 205 }); 206 207 this.guessTexts.push(text); 208 this.reflowGuesses(W, H); 209 210 this.time.delayedCall(1200, () => { 211 if (!text.active) return; 212 this.tweens.add({ 213 targets: text, 214 alpha: 0, scaleX: 0.2, scaleY: 0.2, 215 duration: 200, 216 onComplete: () => { 217 text.destroy(); 218 this.guessTexts = this.guessTexts.filter(t => t !== text); 219 this.reflowGuesses(this.scale.width, this.scale.height); 220 }, 221 }); 222 }); 223 } 224 225 reflowGuesses(W, H) { 226 const n = this.guessTexts.length; 227 if (n === 0) return; 228 const size = Math.max(20, Math.floor(H * 0.12)); 229 const spacing = size * 1.15; 230 const startX = W / 2 - ((n - 1) * spacing) / 2; 231 this.guessTexts.forEach((obj, i) => { 232 obj.setPosition(startX + i * spacing, H * 0.78); 233 obj.setStyle({ fontSize: size + 'px' }); 234 }); 235 } 236 237 resetGame() { 238 this.winElements.forEach(el => { if (el.active) el.destroy(); }); 239 this.winElements = []; 240 this.winShowing = false; 241 242 this.guessTexts.forEach(obj => { if (obj.active) obj.destroy(); }); 243 this.guessTexts = []; 244 245 this.lettersFound = 0; 246 this.firstRound = true; 247 this.targetChar = null; 248 this.targetColor = null; 249 250 const { width: W, height: H } = this.scale; 251 KGames.drawProgressBar(this.progressGfx, W, H, this.lettersFound, GOAL); 252 this.newTarget(); 253 this.applyLayout(W, H); 254 } 255 } 256 257 window.__KG_GAME__ = new Phaser.Game({ 258 type: Phaser.AUTO, 259 parent: 'game-container', 260 backgroundColor: '#1A1A2E', 261 scene: LetterFindScene, 262 scale: { 263 mode: Phaser.Scale.RESIZE, 264 width: '100%', 265 height: '100%', 266 }, 267 render: { preserveDrawingBuffer: true }, 268 });