game-shared.js (19295B)
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 unlock ────────────────────────────────────────────────────────── 180 // Browsers create AudioContexts "suspended" until the page has user 181 // activation, and only some input events grant it: keydown, mousedown, 182 // pointerup, touchend, click. A *touch* pointerdown does not — and that is 183 // the event the on-screen control bar dispatches its synthetic keys from. 184 // So on a phone the "start audio on first key press" path runs before 185 // activation exists: the contexts stay suspended and their resume() 186 // promises never settle. Chrome quietly auto-resumes on the next real 187 // gesture; Safari never does, so music and SFX stay silent for the whole 188 // session. iOS also flips a running context to "interrupted" on a phone 189 // call or app switch. 190 // 191 // installAudioUnlock(getContexts, onReady) calls resume() on every context 192 // returned by getContexts() from each activating gesture (and on return to 193 // the foreground) until all report "running", then calls onReady once. 194 // The listeners stay installed so an interrupted context is revived by the 195 // next tap. Returns { attempt, dispose, ready }. 196 const UNLOCK_EVENTS = ['pointerup', 'touchend', 'mouseup', 'click', 'keydown']; 197 198 function installAudioUnlock(getContexts, onReady) { 199 let ready = false; 200 let disposed = false; 201 202 function contexts() { 203 try { return (getContexts() || []).filter(Boolean); } catch (_) { return []; } 204 } 205 206 function check() { 207 if (ready || disposed) return; 208 const ctxs = contexts(); 209 if (!ctxs.length || !ctxs.every((c) => c.state === 'running')) return; 210 ready = true; 211 if (onReady) onReady(); 212 } 213 214 function attempt() { 215 if (disposed) return; 216 contexts().forEach((c) => { 217 if (c.state === 'running') return; 218 try { 219 const p = c.resume(); 220 if (p && typeof p.then === 'function') p.then(check, () => {}); 221 } catch (_) {} 222 }); 223 check(); 224 } 225 226 const onVisible = () => { if (document.visibilityState !== 'hidden') attempt(); }; 227 UNLOCK_EVENTS.forEach((t) => root.addEventListener(t, attempt, { capture: true, passive: true })); 228 document.addEventListener('visibilitychange', onVisible); 229 attempt(); 230 231 return { 232 attempt, 233 get ready() { return ready; }, 234 dispose() { 235 disposed = true; 236 UNLOCK_EVENTS.forEach((t) => root.removeEventListener(t, attempt, { capture: true })); 237 document.removeEventListener('visibilitychange', onVisible); 238 }, 239 }; 240 } 241 242 // Tone.js's own context. Tone.start() / Tone.getContext().resume() only act 243 // when state === 'suspended', which skips iOS's 'interrupted' state, so hand 244 // the unlock helper the underlying context instead. 245 function toneContext() { 246 try { 247 const c = Tone.getContext(); 248 return c.rawContext || c; 249 } catch (_) { return null; } 250 } 251 252 // ── Audio system ────────────────────────────────────────────────────────── 253 // Encapsulates Tone.js background music and Web Audio SFX. 254 // Pass melody/harmony/bass note arrays and bpm to constructor; 255 // call initMusic() lazily on first user interaction. 256 257 class AudioSystem { 258 constructor({ melody, harmony, bass, bpm }) { 259 this._melody = melody; 260 this._harmony = harmony; 261 this._bass = bass; 262 this._bpm = bpm || 128; 263 264 this.musicMuted = false; 265 this.sfxMuted = false; 266 this.musicReady = false; 267 this.bgGain = null; 268 this.melodySynth = null; 269 this.bassSynth = null; 270 this.jingleSynth = null; 271 this.failSynth = null; 272 this.successStepsLeft = 0; 273 this._seqStep = 0; 274 this._audioCtx = null; 275 this._speakTimer = null; 276 this._lastFailTime = null; 277 } 278 279 initMusic() { 280 if (this.musicReady) return; 281 this.musicReady = true; 282 283 this.bgGain = new Tone.Gain(1).toDestination(); 284 285 this.melodySynth = new Tone.PolySynth(Tone.Synth, { 286 oscillator: { type: 'square' }, 287 envelope: { attack: 0.01, decay: 0.05, sustain: 0.25, release: 0.08 }, 288 }).connect(this.bgGain); 289 this.melodySynth.volume.value = -18; 290 291 this.bassSynth = new Tone.Synth({ 292 oscillator: { type: 'triangle' }, 293 envelope: { attack: 0.02, decay: 0.12, sustain: 0.4, release: 0.2 }, 294 }).connect(this.bgGain); 295 this.bassSynth.volume.value = -22; 296 297 this.jingleSynth = new Tone.Synth({ 298 oscillator: { type: 'triangle' }, 299 envelope: { attack: 0.01, decay: 0.08, sustain: 0.3, release: 0.15 }, 300 }).connect(this.bgGain); 301 this.jingleSynth.volume.value = -20; 302 303 this.failSynth = new Tone.Synth({ 304 oscillator: { type: 'square' }, 305 envelope: { attack: 0.01, decay: 0.06, sustain: 0.15, release: 0.06 }, 306 }).connect(this.bgGain); 307 this.failSynth.volume.value = -18; 308 309 const melody = this._melody; 310 const harmony = this._harmony; 311 312 new Tone.Sequence((time, note) => { 313 const step = this._seqStep % melody.length; 314 this._seqStep++; 315 this.melodySynth.triggerAttackRelease(note, '16n', time); 316 if (this.successStepsLeft > 0) { 317 this.jingleSynth.triggerAttackRelease(harmony[step], '16n', time); 318 this.successStepsLeft--; 319 } 320 }, melody, '8n').start(0); 321 322 new Tone.Sequence((time, note) => { 323 if (note) this.bassSynth.triggerAttackRelease(note, '4n', time); 324 }, this._bass, '8n').start(0); 325 326 Tone.Transport.bpm.value = this._bpm; 327 this.bgGain.gain.value = this.musicMuted ? 0 : 1; 328 // Create the SFX context now so the same gesture unlocks it alongside 329 // Tone's, then start the transport once both are actually running. 330 let sfxCtx = null; 331 try { sfxCtx = this._ctx(); } catch (_) {} 332 const toneCtx = toneContext(); 333 this._unlock = installAudioUnlock(() => [toneCtx, sfxCtx], () => Tone.Transport.start()); 334 } 335 336 playSuccessJingle() { 337 if (!this.musicReady) return; 338 this.successStepsLeft = 8; 339 } 340 341 playFailLick() { 342 if (!this.musicReady) return; 343 const s = 60 / this._bpm / 4; 344 // Two near-simultaneous wrong inputs (e.g. a two-finger tap on the touch 345 // keyboard) can resolve to the same subdivision; the monophonic failSynth 346 // then throws "start time must be strictly greater". Keep starts increasing. 347 let start = Tone.Transport.nextSubdivision('8n'); 348 if (this._lastFailTime != null && start <= this._lastFailTime) { 349 start = this._lastFailTime + s; 350 } 351 this._lastFailTime = start + 3 * s; 352 ['E4', 'Eb4', 'D4', 'Db4'].forEach((note, i) => { 353 this.failSynth.triggerAttackRelease(note, '16n', start + i * s); 354 }); 355 } 356 357 toggleMusic(btnId) { 358 this.musicMuted = !this.musicMuted; 359 if (this.bgGain) this.bgGain.gain.value = this.musicMuted ? 0 : 1; 360 const btn = document.getElementById(btnId || 'btn-music'); 361 btn.textContent = 'Music: ' + (this.musicMuted ? 'OFF' : 'ON'); 362 btn.classList.toggle('muted', this.musicMuted); 363 } 364 365 toggleSfx(btnId) { 366 this.sfxMuted = !this.sfxMuted; 367 const btn = document.getElementById(btnId || 'btn-sfx'); 368 btn.textContent = 'SFX: ' + (this.sfxMuted ? 'OFF' : 'ON'); 369 btn.classList.toggle('muted', this.sfxMuted); 370 } 371 372 _ctx() { 373 if (!this._audioCtx) { 374 this._audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 375 } 376 return this._audioCtx; 377 } 378 379 playBong() { 380 if (this.sfxMuted) return; 381 try { 382 const ctx = this._ctx(); 383 const osc = ctx.createOscillator(); 384 const gain = ctx.createGain(); 385 osc.connect(gain); gain.connect(ctx.destination); 386 osc.type = 'sine'; 387 osc.frequency.setValueAtTime(160, ctx.currentTime); 388 osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.3); 389 gain.gain.setValueAtTime(0.35, ctx.currentTime); 390 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); 391 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 392 osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.4); 393 } catch (_) {} 394 } 395 396 // Short single-tone ding — use for rapid per-letter feedback to keep the audio graph lean. 397 playPing() { 398 if (this.sfxMuted) return; 399 try { 400 const ctx = this._ctx(); 401 const osc = ctx.createOscillator(); 402 const gain = ctx.createGain(); 403 osc.connect(gain); gain.connect(ctx.destination); 404 osc.type = 'sine'; 405 osc.frequency.setValueAtTime(880, ctx.currentTime); 406 gain.gain.setValueAtTime(0, ctx.currentTime); 407 gain.gain.linearRampToValueAtTime(0.22, ctx.currentTime + 0.01); 408 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.14); 409 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 410 osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.15); 411 } catch (_) {} 412 } 413 414 // Short, dry tick — the typewriter keystroke sound. 415 playKeyClick() { 416 if (this.sfxMuted) return; 417 try { 418 const ctx = this._ctx(); 419 const osc = ctx.createOscillator(); 420 const gain = ctx.createGain(); 421 osc.connect(gain); gain.connect(ctx.destination); 422 osc.type = 'square'; 423 osc.frequency.setValueAtTime(1200, ctx.currentTime); 424 gain.gain.setValueAtTime(0.06, ctx.currentTime); 425 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03); 426 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 427 osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.035); 428 } catch (_) {} 429 } 430 431 playSuccess() { 432 if (this.sfxMuted) return; 433 try { 434 const ctx = this._ctx(); 435 [523, 659, 784].forEach((freq, i) => { 436 const osc = ctx.createOscillator(); 437 const gain = ctx.createGain(); 438 osc.connect(gain); gain.connect(ctx.destination); 439 osc.type = 'sine'; 440 const t = ctx.currentTime + i * 0.08; 441 osc.frequency.setValueAtTime(freq, t); 442 gain.gain.setValueAtTime(0, t); 443 gain.gain.linearRampToValueAtTime(0.3, t + 0.02); 444 gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28); 445 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 446 osc.start(t); osc.stop(t + 0.3); 447 }); 448 } catch (_) {} 449 } 450 451 playVictorySound() { 452 if (this.sfxMuted) return; 453 try { 454 const ctx = this._ctx(); 455 const notes = [523, 659, 784, 1047, 1319, 1568]; 456 notes.forEach((freq, i) => { 457 const osc = ctx.createOscillator(); 458 const gain = ctx.createGain(); 459 osc.connect(gain); gain.connect(ctx.destination); 460 osc.type = 'sine'; 461 const t = ctx.currentTime + i * 0.1; 462 osc.frequency.setValueAtTime(freq, t); 463 gain.gain.setValueAtTime(0, t); 464 gain.gain.linearRampToValueAtTime(0.28, t + 0.03); 465 gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5); 466 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 467 osc.start(t); osc.stop(t + 0.55); 468 }); 469 [523, 659, 784, 1047].forEach(freq => { 470 const osc = ctx.createOscillator(); 471 const gain = ctx.createGain(); 472 osc.connect(gain); gain.connect(ctx.destination); 473 osc.type = 'sine'; 474 const t = ctx.currentTime + notes.length * 0.1 + 0.15; 475 osc.frequency.setValueAtTime(freq, t); 476 gain.gain.setValueAtTime(0, t); 477 gain.gain.linearRampToValueAtTime(0.18, t + 0.05); 478 gain.gain.exponentialRampToValueAtTime(0.001, t + 1.5); 479 osc.onended = () => { osc.disconnect(); gain.disconnect(); }; 480 osc.start(t); osc.stop(t + 1.6); 481 }); 482 } catch (_) {} 483 } 484 485 speak(text) { 486 if (!window.speechSynthesis) return; 487 // Cancel + immediate speak races on some browsers; debounce with clearTimeout 488 window.speechSynthesis.cancel(); 489 clearTimeout(this._speakTimer); 490 this._speakTimer = setTimeout(() => { 491 window.speechSynthesis.speak(new SpeechSynthesisUtterance(text.toLowerCase())); 492 }, 80); 493 } 494 } 495 496 root.KGames.AudioSystem = AudioSystem; 497 root.KGames.installAudioUnlock = installAudioUnlock; 498 root.KGames.toneContext = toneContext; 499 root.KGames.bakeFireworksAtlas = bakeFireworksAtlas; 500 root.KGames.burstConfetti = burstConfetti; 501 root.KGames.launchFireworks = launchFireworks; 502 root.KGames.drawProgressBar = drawProgressBar; 503 root.KGames.showWinScreen = showWinScreen; 504 505 }(window));