kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

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));