game-shared.js (15876B)
1 (function (root) { 2 'use strict'; 3 4 root.KGames = root.KGames || {}; 5 6 // ── Fireworks atlas (baked once per scene) ──────────────────────────────── 7 8 function bakeFireworksAtlas(scene) { 9 if (scene.textures.exists('fwsq')) return; 10 const fw = scene.make.graphics({ add: false }); 11 fw.fillStyle(0xE63946); fw.fillRect(0, 0, 6, 6); 12 fw.fillStyle(0x1D7CF2); fw.fillRect(6, 0, 6, 6); 13 fw.fillStyle(0xFFD23F); fw.fillRect(12, 0, 6, 6); 14 fw.fillStyle(0x2EC4B6); fw.fillRect(18, 0, 6, 6); 15 fw.generateTexture('fwsq', 24, 6); 16 fw.destroy(); 17 const tex = scene.textures.get('fwsq'); 18 tex.add(0, 0, 0, 0, 6, 6); 19 tex.add(1, 0, 6, 0, 6, 6); 20 tex.add(2, 0, 12, 0, 6, 6); 21 tex.add(3, 0, 18, 0, 6, 6); 22 } 23 24 // ── Particles ───────────────────────────────────────────────────────────── 25 26 function burstConfetti(scene, x, y) { 27 const emitter = scene.add.particles(x, y, 'fwsq', { 28 frame: [0, 1, 2, 3], 29 speed: { min: 100, max: 350 }, 30 angle: { min: 0, max: 360 }, 31 scale: { start: 1.2, end: 0 }, 32 gravityY: 500, 33 lifespan: 750, 34 explode: true, 35 quantity: 30, 36 }); 37 scene.time.delayedCall(850, () => { if (emitter.active) emitter.destroy(); }); 38 } 39 40 function launchFireworks(scene) { 41 const { width: W, height: H } = scene.scale; 42 const bursts = [ 43 [W * 0.3, H * 0.30, 0], 44 [W * 0.7, H * 0.25, 500], 45 [W * 0.5, H * 0.20, 1000], 46 [W * 0.2, H * 0.35, 1500], 47 [W * 0.78, H * 0.28, 2000], 48 [W * 0.5, H * 0.32, 2500], 49 ]; 50 bursts.forEach(([x, y, delay]) => { 51 scene.time.delayedCall(delay, () => { 52 if (!scene.scene.isActive()) return; 53 const em = scene.add.particles(x, y, 'fwsq', { 54 frame: [0, 1, 2, 3], 55 speed: { min: 60, max: 240 }, 56 angle: { min: 0, max: 360 }, 57 scale: { start: 1.2, end: 0 }, 58 gravityY: 150, 59 lifespan: 800, 60 explode: true, 61 quantity: 28, 62 }); 63 scene.time.delayedCall(900, () => { if (em.active) em.destroy(); }); 64 }); 65 }); 66 } 67 68 // ── Progress bar ────────────────────────────────────────────────────────── 69 70 function drawProgressBar(gfx, W, H, found, goal) { 71 gfx.clear(); 72 const dotR = Math.max(8, Math.floor(H * 0.022)); 73 const spacing = dotR * 3; 74 const totalW = spacing * (goal - 1); 75 const startX = W / 2 - totalW / 2; 76 const y = H * 0.07; 77 for (let i = 0; i < goal; i++) { 78 const x = startX + i * spacing; 79 if (i < found) { 80 gfx.fillStyle(0xFFD23F, 1); 81 gfx.fillCircle(x, y, dotR); 82 } else { 83 gfx.lineStyle(Math.max(2, Math.floor(dotR * 0.3)), 0x555577, 1); 84 gfx.strokeCircle(x, y, dotR); 85 } 86 } 87 } 88 89 // ── Win screen ──────────────────────────────────────────────────────────── 90 // Returns the array of Phaser display objects created (for later cleanup). 91 92 function showWinScreen(scene, opts) { 93 const { title, subtitle, audio, onPlayAgain } = opts; 94 const { width: W, height: H } = scene.scale; 95 const elements = []; 96 97 if (audio) audio.playVictorySound(); 98 launchFireworks(scene); 99 100 const bg = scene.add.graphics(); 101 bg.fillStyle(0x000000, 0.78); 102 bg.fillRect(0, 0, W, H); 103 elements.push(bg); 104 105 const titleSize = Math.max(32, Math.floor(H * 0.1)); 106 const titleObj = scene.add.text(W / 2, H * 0.28, title, { 107 fontFamily: 'Fredoka, sans-serif', 108 fontSize: titleSize + 'px', 109 fontStyle: 'bold', 110 color: '#FFD23F', 111 }).setOrigin(0.5).setAlpha(0); 112 elements.push(titleObj); 113 scene.tweens.add({ targets: titleObj, alpha: 1, duration: 400, ease: 'Sine.In' }); 114 115 const subSize = Math.max(16, Math.floor(H * 0.055)); 116 const subObj = scene.add.text(W / 2, H * 0.38, subtitle, { 117 fontFamily: 'Fredoka, sans-serif', 118 fontSize: subSize + 'px', 119 color: '#ffffff', 120 }).setOrigin(0.5).setAlpha(0); 121 elements.push(subObj); 122 scene.tweens.add({ targets: subObj, alpha: 1, duration: 400, delay: 200, ease: 'Sine.In' }); 123 124 const btnW = Math.min(300, W * 0.55); 125 const btnH = Math.max(52, H * 0.09); 126 const btnSz = Math.max(20, Math.floor(H * 0.06)); 127 128 const y1 = H * 0.57; 129 const btn1 = scene.add.graphics(); 130 const drawBtn1 = (color) => { 131 btn1.clear(); 132 btn1.fillStyle(color, 1); 133 btn1.fillRoundedRect(W / 2 - btnW / 2, y1 - btnH / 2, btnW, btnH, 14); 134 }; 135 drawBtn1(0x2EC4B6); 136 elements.push(btn1); 137 138 const t1 = scene.add.text(W / 2, y1, 'Play Again', { 139 fontFamily: 'Fredoka, sans-serif', 140 fontSize: btnSz + 'px', 141 fontStyle: 'bold', 142 color: '#ffffff', 143 }).setOrigin(0.5); 144 elements.push(t1); 145 146 const z1 = scene.add.zone(W / 2, y1, btnW, btnH).setInteractive({ useHandCursor: true }); 147 z1.on('pointerover', () => drawBtn1(0x3ED4CE)); 148 z1.on('pointerout', () => drawBtn1(0x2EC4B6)); 149 z1.on('pointerup', () => { if (onPlayAgain) onPlayAgain(); }); 150 elements.push(z1); 151 152 const y2 = H * 0.72; 153 const btn2 = scene.add.graphics(); 154 const drawBtn2 = (color) => { 155 btn2.clear(); 156 btn2.fillStyle(color, 1); 157 btn2.fillRoundedRect(W / 2 - btnW / 2, y2 - btnH / 2, btnW, btnH, 14); 158 }; 159 drawBtn2(0x333355); 160 elements.push(btn2); 161 162 const t2 = scene.add.text(W / 2, y2, 'Main Menu', { 163 fontFamily: 'Fredoka, sans-serif', 164 fontSize: btnSz + 'px', 165 fontStyle: 'bold', 166 color: '#cccccc', 167 }).setOrigin(0.5); 168 elements.push(t2); 169 170 const z2 = scene.add.zone(W / 2, y2, btnW, btnH).setInteractive({ useHandCursor: true }); 171 z2.on('pointerover', () => drawBtn2(0x44446A)); 172 z2.on('pointerout', () => drawBtn2(0x333355)); 173 z2.on('pointerup', () => { window.location.href = '../../index.html'; }); 174 elements.push(z2); 175 176 return elements; 177 } 178 179 // ── Audio system ────────────────────────────────────────────────────────── 180 // Encapsulates Tone.js background music and Web Audio SFX. 181 // Pass melody/harmony/bass note arrays and bpm to constructor; 182 // call initMusic() lazily on first user interaction. 183 184 class AudioSystem { 185 constructor({ melody, harmony, bass, bpm }) { 186 this._melody = melody; 187 this._harmony = harmony; 188 this._bass = bass; 189 this._bpm = bpm || 128; 190 191 this.musicMuted = false; 192 this.sfxMuted = false; 193 this.musicReady = false; 194 this.bgGain = null; 195 this.melodySynth = null; 196 this.bassSynth = null; 197 this.jingleSynth = null; 198 this.failSynth = null; 199 this.successStepsLeft = 0; 200 this._seqStep = 0; 201 this._audioCtx = null; 202 this._speakTimer = null; 203 this._lastFailTime = null; 204 } 205 206 initMusic() { 207 if (this.musicReady) return; 208 this.musicReady = true; 209 210 this.bgGain = new Tone.Gain(1).toDestination(); 211 212 this.melodySynth = new Tone.PolySynth(Tone.Synth, { 213 oscillator: { type: 'square' }, 214 envelope: { attack: 0.01, decay: 0.05, sustain: 0.25, release: 0.08 }, 215 }).connect(this.bgGain); 216 this.melodySynth.volume.value = -18; 217 218 this.bassSynth = new Tone.Synth({ 219 oscillator: { type: 'triangle' }, 220 envelope: { attack: 0.02, decay: 0.12, sustain: 0.4, release: 0.2 }, 221 }).connect(this.bgGain); 222 this.bassSynth.volume.value = -22; 223 224 this.jingleSynth = new Tone.Synth({ 225 oscillator: { type: 'triangle' }, 226 envelope: { attack: 0.01, decay: 0.08, sustain: 0.3, release: 0.15 }, 227 }).connect(this.bgGain); 228 this.jingleSynth.volume.value = -20; 229 230 this.failSynth = new Tone.Synth({ 231 oscillator: { type: 'square' }, 232 envelope: { attack: 0.01, decay: 0.06, sustain: 0.15, release: 0.06 }, 233 }).connect(this.bgGain); 234 this.failSynth.volume.value = -18; 235 236 const melody = this._melody; 237 const harmony = this._harmony; 238 239 new Tone.Sequence((time, note) => { 240 const step = this._seqStep % melody.length; 241 this._seqStep++; 242 this.melodySynth.triggerAttackRelease(note, '16n', time); 243 if (this.successStepsLeft > 0) { 244 this.jingleSynth.triggerAttackRelease(harmony[step], '16n', time); 245 this.successStepsLeft--; 246 } 247 }, melody, '8n').start(0); 248 249 new Tone.Sequence((time, note) => { 250 if (note) this.bassSynth.triggerAttackRelease(note, '4n', time); 251 }, this._bass, '8n').start(0); 252 253 Tone.Transport.bpm.value = this._bpm; 254 this.bgGain.gain.value = this.musicMuted ? 0 : 1; 255 Tone.start().then(() => Tone.Transport.start()); 256 } 257 258 playSuccessJingle() { 259 if (!this.musicReady) return; 260 this.successStepsLeft = 8; 261 } 262 263 playFailLick() { 264 if (!this.musicReady) return; 265 const s = 60 / this._bpm / 4; 266 // Two near-simultaneous wrong inputs (e.g. a two-finger tap on the touch 267 // keyboard) can resolve to the same subdivision; the monophonic failSynth 268 // then throws "start time must be strictly greater". Keep starts increasing. 269 let start = Tone.Transport.nextSubdivision('8n'); 270 if (this._lastFailTime != null && start <= this._lastFailTime) { 271 start = this._lastFailTime + s; 272 } 273 this._lastFailTime = start + 3 * s; 274 ['E4', 'Eb4', 'D4', 'Db4'].forEach((note, i) => { 275 this.failSynth.triggerAttackRelease(note, '16n', start + i * s); 276 }); 277 } 278 279 toggleMusic(btnId) { 280 this.musicMuted = !this.musicMuted; 281 if (this.bgGain) this.bgGain.gain.value = this.musicMuted ? 0 : 1; 282 const btn = document.getElementById(btnId || 'btn-music'); 283 btn.textContent = 'Music: ' + (this.musicMuted ? 'OFF' : 'ON'); 284 btn.classList.toggle('muted', this.musicMuted); 285 } 286 287 toggleSfx(btnId) { 288 this.sfxMuted = !this.sfxMuted; 289 const btn = document.getElementById(btnId || 'btn-sfx'); 290 btn.textContent = 'SFX: ' + (this.sfxMuted ? 'OFF' : 'ON'); 291 btn.classList.toggle('muted', this.sfxMuted); 292 } 293 294 _ctx() { 295 if (!this._audioCtx) { 296 this._audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 297 } 298 return this._audioCtx; 299 } 300 301 playBong() { 302 if (this.sfxMuted) return; 303 try { 304 const ctx = this._ctx(); 305 const osc = ctx.createOscillator(); 306 const gain = ctx.createGain(); 307 osc.connect(gain); gain.connect(ctx.destination); 308 osc.type = 'sine'; 309 osc.frequency.setValueAtTime(160, ctx.currentTime); 310 osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.3); 311 gain.gain.setValueAtTime(0.35, ctx.currentTime); 312 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); 313 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 314 osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.4); 315 } catch (_) {} 316 } 317 318 // Short single-tone ding — use for rapid per-letter feedback to keep the audio graph lean. 319 playPing() { 320 if (this.sfxMuted) return; 321 try { 322 const ctx = this._ctx(); 323 const osc = ctx.createOscillator(); 324 const gain = ctx.createGain(); 325 osc.connect(gain); gain.connect(ctx.destination); 326 osc.type = 'sine'; 327 osc.frequency.setValueAtTime(880, ctx.currentTime); 328 gain.gain.setValueAtTime(0, ctx.currentTime); 329 gain.gain.linearRampToValueAtTime(0.22, ctx.currentTime + 0.01); 330 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.14); 331 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 332 osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.15); 333 } catch (_) {} 334 } 335 336 // Short, dry tick — the typewriter keystroke sound. 337 playKeyClick() { 338 if (this.sfxMuted) return; 339 try { 340 const ctx = this._ctx(); 341 const osc = ctx.createOscillator(); 342 const gain = ctx.createGain(); 343 osc.connect(gain); gain.connect(ctx.destination); 344 osc.type = 'square'; 345 osc.frequency.setValueAtTime(1200, ctx.currentTime); 346 gain.gain.setValueAtTime(0.06, ctx.currentTime); 347 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03); 348 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 349 osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.035); 350 } catch (_) {} 351 } 352 353 playSuccess() { 354 if (this.sfxMuted) return; 355 try { 356 const ctx = this._ctx(); 357 [523, 659, 784].forEach((freq, i) => { 358 const osc = ctx.createOscillator(); 359 const gain = ctx.createGain(); 360 osc.connect(gain); gain.connect(ctx.destination); 361 osc.type = 'sine'; 362 const t = ctx.currentTime + i * 0.08; 363 osc.frequency.setValueAtTime(freq, t); 364 gain.gain.setValueAtTime(0, t); 365 gain.gain.linearRampToValueAtTime(0.3, t + 0.02); 366 gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28); 367 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 368 osc.start(t); osc.stop(t + 0.3); 369 }); 370 } catch (_) {} 371 } 372 373 playVictorySound() { 374 if (this.sfxMuted) return; 375 try { 376 const ctx = this._ctx(); 377 const notes = [523, 659, 784, 1047, 1319, 1568]; 378 notes.forEach((freq, i) => { 379 const osc = ctx.createOscillator(); 380 const gain = ctx.createGain(); 381 osc.connect(gain); gain.connect(ctx.destination); 382 osc.type = 'sine'; 383 const t = ctx.currentTime + i * 0.1; 384 osc.frequency.setValueAtTime(freq, t); 385 gain.gain.setValueAtTime(0, t); 386 gain.gain.linearRampToValueAtTime(0.28, t + 0.03); 387 gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5); 388 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 389 osc.start(t); osc.stop(t + 0.55); 390 }); 391 [523, 659, 784, 1047].forEach(freq => { 392 const osc = ctx.createOscillator(); 393 const gain = ctx.createGain(); 394 osc.connect(gain); gain.connect(ctx.destination); 395 osc.type = 'sine'; 396 const t = ctx.currentTime + notes.length * 0.1 + 0.15; 397 osc.frequency.setValueAtTime(freq, t); 398 gain.gain.setValueAtTime(0, t); 399 gain.gain.linearRampToValueAtTime(0.18, t + 0.05); 400 gain.gain.exponentialRampToValueAtTime(0.001, t + 1.5); 401 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 402 osc.start(t); osc.stop(t + 1.6); 403 }); 404 } catch (_) {} 405 } 406 407 speak(text) { 408 if (!window.speechSynthesis) return; 409 // Cancel + immediate speak races on some browsers; debounce with clearTimeout 410 window.speechSynthesis.cancel(); 411 clearTimeout(this._speakTimer); 412 this._speakTimer = setTimeout(() => { 413 window.speechSynthesis.speak(new SpeechSynthesisUtterance(text.toLowerCase())); 414 }, 80); 415 } 416 } 417 418 root.KGames.AudioSystem = AudioSystem; 419 root.KGames.bakeFireworksAtlas = bakeFireworksAtlas; 420 root.KGames.burstConfetti = burstConfetti; 421 root.KGames.launchFireworks = launchFireworks; 422 root.KGames.drawProgressBar = drawProgressBar; 423 root.KGames.showWinScreen = showWinScreen; 424 425 }(window));