kgames

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

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