kgames

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

game.js (19759B)


      1 const PALETTE = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6];
      2 const PAUSE_MS = 2200; // idle pause before "Press Enter" hint appears
      3 
      4 class WorkScene extends Phaser.Scene {
      5   constructor() {
      6     super({ key: 'WorkScene' });
      7     this.mode        = 'idle';
      8     this.input_buf   = '';
      9     this.acceptInput = false;
     10 
     11     this.nameState   = null;
     12     this.playerName  = '';
     13     this.lastTimeMs  = null;
     14     this._raceStart  = null;
     15 
     16     this.madState    = null;
     17     this.madTemplate = null;
     18     this.madValues   = {};
     19     this.madIndex    = 0;
     20 
     21     this._twEvent    = null;
     22     this._pauseEvent = null;
     23     this._buttons    = [];
     24   }
     25 
     26   preload() {
     27     KGames.bakeFireworksAtlas(this);
     28   }
     29 
     30   create() {
     31     window.__WORK_SCENE__ = this;
     32     const W = this.scale.width;
     33     const H = this.scale.height;
     34 
     35     this.audio = new KGames.AudioSystem({ melody: [], harmony: [], bass: [] });
     36 
     37     this.pageGfx   = this.add.graphics();
     38     this.ribbonGfx = this.add.graphics();
     39 
     40     this.promptText = this.add.text(0, 0, '', {
     41       fontFamily: 'Fredoka, sans-serif', color: '#555566',
     42     }).setOrigin(0, 0);
     43 
     44     this.inputText = this.add.text(0, 0, '', {
     45       fontFamily: 'Fredoka, sans-serif', color: '#222233',
     46     }).setOrigin(0, 0);
     47 
     48     this.hintText = this.add.text(0, 0, '', {
     49       fontFamily: 'Fredoka, sans-serif', color: '#1D7CF2',
     50     }).setOrigin(0, 0).setVisible(false);
     51 
     52     this.bigText = this.add.text(0, 0, '', {
     53       fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#E63946',
     54     }).setOrigin(0.5, 0.5).setVisible(false);
     55 
     56     this.stopwatchText = this.add.text(0, 0, '', {
     57       fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#2EC4B6',
     58     }).setOrigin(1, 0).setVisible(false);
     59 
     60     this.cursor = this.add.rectangle(0, 0, 3, 30, 0x222233).setOrigin(0, 0);
     61     this.tweens.add({ targets: this.cursor, alpha: 0, duration: 500, yoyo: true, repeat: -1 });
     62 
     63     this._buildRibbon();
     64 
     65     this.scale.on('resize', this.onResize, this);
     66     this.input.keyboard.on('keydown', this.onKey, this);
     67 
     68     const sfxBtn = document.getElementById('btn-sfx');
     69     if (sfxBtn) sfxBtn.addEventListener('click', () => {
     70       this.audio.sfxMuted = !this.audio.sfxMuted;
     71       sfxBtn.textContent = 'Sound: ' + (this.audio.sfxMuted ? 'OFF' : 'ON');
     72       sfxBtn.classList.toggle('muted', this.audio.sfxMuted);
     73     });
     74 
     75     this.enterMode('idle');
     76     this.applyLayout(W, H);
     77   }
     78 
     79   // ── Layout ──────────────────────────────────────────────────────────────────
     80 
     81   _pageRect(W, H) {
     82     const ribbonH = Math.max(56, Math.floor(H * 0.12));
     83     const margin  = Math.max(20, Math.floor(W * 0.06));
     84     const top     = ribbonH + Math.floor(H * 0.05);
     85     const x = margin;
     86     const y = top;
     87     const w = W - margin * 2;
     88     const h = H - top - Math.floor(H * 0.05);
     89     return { x, y, w, h, ribbonH };
     90   }
     91 
     92   applyLayout(W, H) {
     93     const r = this._pageRect(W, H);
     94     const fontSize = Math.max(18, Math.floor(H * 0.04));
     95     const pad = Math.floor(r.w * 0.05);
     96 
     97     // Page with soft shadow
     98     this.pageGfx.clear();
     99     this.pageGfx.fillStyle(0x000000, 0.08);
    100     this.pageGfx.fillRoundedRect(r.x + 5, r.y + 7, r.w, r.h, 10);
    101     this.pageGfx.fillStyle(0xffffff, 1);
    102     this.pageGfx.fillRoundedRect(r.x, r.y, r.w, r.h, 10);
    103 
    104     const textX = r.x + pad;
    105     const textW = r.w - pad * 2;
    106 
    107     this._textX = textX; this._pageTop = r.y; this._pad = pad; this._baseFont = fontSize;
    108     this.promptText.setStyle({ fontSize: fontSize + 'px', wordWrap: { width: textW } });
    109     this.inputText.setStyle({ fontSize: (fontSize + 4) + 'px', wordWrap: { width: textW } });
    110     this.hintText.setStyle({ fontSize: Math.floor(fontSize * 0.8) + 'px' });
    111     this._reflow();
    112     this.bigText.setStyle({ fontSize: Math.floor(H * 0.22) + 'px' })
    113       .setPosition(r.x + r.w / 2, r.y + r.h / 2);
    114     this.stopwatchText.setStyle({ fontSize: Math.floor(fontSize * 1.4) + 'px' })
    115       .setPosition(r.x + r.w - pad, r.y + pad);
    116     this.cursor.setSize(3, fontSize + 6);
    117 
    118     this._layoutRibbon(W, H, r.ribbonH);
    119     this._positionButtons(W, H);
    120     this._updateCursor();
    121   }
    122 
    123   onResize(gameSize) {
    124     this.applyLayout(gameSize.width, gameSize.height);
    125   }
    126 
    127   // Single source of truth for vertical layout inside the page. Stacks each
    128   // visible text block below the measured bottom of the previous one, so wrapped
    129   // or multi-line text (long prompts, the "New record!" result) never overlaps.
    130   // Call this after ANY change to prompt/input/hint text or visibility.
    131   _reflow() {
    132     if (this._textX == null) return;
    133     const gap = Math.floor(this._baseFont * 0.6);
    134     let y = this._pageTop + this._pad;
    135 
    136     if (this.promptText.text !== '') {
    137       this.promptText.setPosition(this._textX, y);
    138       y += this.promptText.height + gap;
    139     }
    140 
    141     this.inputText.setPosition(this._textX, y);
    142     if (this.inputText.text !== '') y += this.inputText.height + gap;
    143 
    144     if (this.hintText.visible) this.hintText.setPosition(this._textX, y);
    145 
    146     this._updateCursor();
    147   }
    148 
    149   // ── Ribbon ──────────────────────────────────────────────────────────────────
    150 
    151   _buildRibbon() {
    152     this.ribbonItems = [
    153       { key: 'idle',   label: 'New',     glyph: '📄', color: 0xFFD23F },
    154       { key: 'name',   label: 'Name',    glyph: '🙂', color: 0xE63946 },
    155       { key: 'madlib', label: 'Stories', glyph: '✏️', color: 0x1D7CF2 },
    156     ];
    157     this.ribbonObjs = this.ribbonItems.map((item) => {
    158       const g = this.add.graphics();
    159       const t = this.add.text(0, 0, item.glyph + '\n' + item.label, {
    160         fontFamily: 'Fredoka, sans-serif', fontStyle: '600', color: '#334',
    161         align: 'center',
    162       }).setOrigin(0.5, 0.5);
    163       const z = this.add.zone(0, 0, 10, 10).setOrigin(0.5, 0.5).setInteractive({ useHandCursor: true });
    164       z.on('pointerover', () => { this._hoverKey = item.key; this._drawRibbonBtn(g, item, true); this.audio.speak(item.label); });
    165       z.on('pointerout',  () => { this._hoverKey = null; this._drawRibbonBtn(g, item, false); });
    166       z.on('pointerup',   () => this.enterMode(item.key));
    167       return { item, g, t, z };
    168     });
    169   }
    170 
    171   _layoutRibbon(W, H, ribbonH) {
    172     this.ribbonGfx.clear();
    173     this.ribbonGfx.fillStyle(0xF4F4F6, 1);
    174     this.ribbonGfx.fillRect(0, 0, W, ribbonH);
    175     this.ribbonGfx.lineStyle(2, 0xD0D0D8, 1);
    176     this.ribbonGfx.lineBetween(0, ribbonH, W, ribbonH);
    177 
    178     const n = this.ribbonObjs.length;
    179     const btnW = Math.min(120, Math.floor(W / (n + 1)));
    180     const btnH = ribbonH - 12;
    181     const gap  = Math.floor(btnW * 0.15);
    182     const totalW = n * btnW + (n - 1) * gap;
    183     let x = (W - totalW) / 2;
    184     const cy = ribbonH / 2;
    185     const fs = Math.max(13, Math.floor(btnH * 0.22));
    186 
    187     this.ribbonObjs.forEach(({ item, g, t, z }) => {
    188       const cx = x + btnW / 2;
    189       g._rect = { x: x, y: 6, w: btnW, h: btnH };
    190       this._drawRibbonBtn(g, item, this._hoverKey === item.key);
    191       t.setPosition(cx, cy).setStyle({ fontSize: fs + 'px' });
    192       z.setPosition(cx, cy).setSize(btnW, btnH);
    193       if (z.input && z.input.hitArea) z.input.hitArea.setTo(0, 0, btnW, btnH);
    194       x += btnW + gap;
    195     });
    196   }
    197 
    198   _drawRibbonBtn(g, item, hover) {
    199     if (!g._rect) return;
    200     const { x, y, w, h } = g._rect;
    201     g.clear();
    202     g.fillStyle(item.color, hover ? 0.35 : 0.18);
    203     g.fillRoundedRect(x, y, w, h, 8);
    204     g.lineStyle(2, item.color, 1);
    205     g.strokeRoundedRect(x, y, w, h, 8);
    206   }
    207 
    208   // ── Modes ─────────────────────────────────────────────────────────────────
    209 
    210   // Parent-facing attribution below the canvas: visible only while a story is
    211   // being played, and only for that story.
    212   _showStoryCredit(source) {
    213     if (typeof window.KGamesShowStoryCredit === 'function') {
    214       window.KGamesShowStoryCredit(source || null);
    215     }
    216   }
    217 
    218   enterMode(mode) {
    219     this._cancelTypewriter();
    220     this._cancelPause();
    221     this._clearButtons();
    222     this.input_buf = '';
    223     this.acceptInput = false;
    224     this.hintText.setVisible(false);
    225     this.bigText.setVisible(false);
    226     this.stopwatchText.setVisible(false);
    227     this.inputText.setText('');
    228     this.mode = mode;
    229     if (mode !== 'madlib') this._showStoryCredit(null);
    230 
    231     if (mode === 'idle') {
    232       this.nameState = null; this.madState = null;
    233       this.promptText.setText('');
    234       this.acceptInput = true;          // blank page: free typing
    235       this.cursor.setVisible(true);
    236     }
    237     this._reflow();
    238 
    239     if (mode === 'name') {
    240       this.startNameGame();
    241     } else if (mode === 'madlib') {
    242       this.startMadlib();
    243     }
    244   }
    245 
    246   // ── Text input ──────────────────────────────────────────────────────────────
    247 
    248   onKey(event) {
    249     if (!this.acceptInput) return;
    250 
    251     const k = event.key;
    252     if (k === 'Backspace') {
    253       event.preventDefault();
    254       this.input_buf = this.input_buf.slice(0, -1);
    255     } else if (k === 'Enter') {
    256       if (this.mode === 'idle') {
    257         this.input_buf += '\n';        // blank page: newline
    258       } else {
    259         this._onEnter();
    260         return;
    261       }
    262     } else if (k === ' ' || /^[a-zA-Z]$/.test(k)) {
    263       this.input_buf += k;
    264     } else {
    265       return;
    266     }
    267 
    268     this.audio.playKeyClick();
    269     this.hintText.setVisible(false);
    270     this.inputText.setText(this.input_buf);
    271     this._reflow();
    272     this._resetPause();
    273     this._onInputChanged();
    274   }
    275 
    276   _onInputChanged() {
    277     if (this.mode === 'name' && this.nameState === 'racing') {
    278       if (WorkLib.normalizeName(this.input_buf) === WorkLib.normalizeName(this.playerName)) {
    279         this._finishRace();
    280       }
    281     }
    282   }
    283 
    284   _onEnter() {
    285     if (this.mode === 'name') {
    286       if (this.nameState === 'ask' && WorkLib.isNameReady(this.input_buf)) {
    287         this.playerName = this.input_buf.trim();
    288         this._nameGreet();
    289       }
    290     } else if (this.mode === 'madlib' && this.madState === 'asking') {
    291       if (this.input_buf.trim().length > 0) {
    292         this.madValues[this.madTemplate.blanks[this.madIndex].key] = this.input_buf.trim();
    293         this.madIndex++;
    294         this._madNextBlank();
    295       }
    296     }
    297   }
    298 
    299   _resetPause() {
    300     this._cancelPause();
    301     this._pauseEvent = this.time.delayedCall(PAUSE_MS, () => this._onPause());
    302   }
    303 
    304   _cancelPause() {
    305     if (this._pauseEvent) { this._pauseEvent.remove(false); this._pauseEvent = null; }
    306   }
    307 
    308   _onPause() {
    309     const ready = (this.mode === 'name'   && this.nameState === 'ask'     && WorkLib.isNameReady(this.input_buf)) ||
    310                   (this.mode === 'madlib' && this.madState  === 'asking'  && this.input_buf.trim().length > 0);
    311     if (ready) {
    312       this.hintText.setText('Press Enter ⏎').setVisible(true);
    313       this._reflow();
    314     }
    315   }
    316 
    317   _updateCursor() {
    318     this.cursor.setVisible(this.acceptInput);
    319     if (!this.acceptInput) return;
    320     // Sit at the end of the last line of typed text.
    321     const lines  = this.inputText.text.split('\n');
    322     const lineH  = this.inputText.height / Math.max(1, lines.length);
    323     const lastY  = this.inputText.y + lineH * (lines.length - 1);
    324     const lastW  = this.inputText.text === '' ? 0 : this._measureWidth(lines[lines.length - 1]);
    325     this.cursor.setPosition(this.inputText.x + lastW + 2, lastY);
    326   }
    327 
    328   _measureWidth(str) {
    329     if (str === '') return 0;
    330     const probe = this._cursorProbe || (this._cursorProbe = this.add.text(0, 0, '', {
    331       fontFamily: 'Fredoka, sans-serif',
    332     }).setVisible(false));
    333     probe.setStyle({ fontSize: this.inputText.style.fontSize }).setText(str);
    334     return probe.width;
    335   }
    336 
    337   // ── Typewriter ──────────────────────────────────────────────────────────────
    338 
    339   _typewriter(target, onDone) {
    340     this._cancelTypewriter();
    341     const steps = WorkLib.typewriterSteps(this.promptText.text, target);
    342     this.audio.speak(target);
    343     let i = 0;
    344     this._twEvent = this.time.addEvent({
    345       delay: 45,
    346       repeat: steps.length - 1,
    347       callback: () => {
    348         this.promptText.setText(steps[i++]);
    349         this._reflow();
    350         if (i >= steps.length) { this._twEvent = null; if (onDone) onDone(); }
    351       },
    352     });
    353   }
    354 
    355   _cancelTypewriter() {
    356     if (this._twEvent) { this._twEvent.remove(false); this._twEvent = null; }
    357   }
    358 
    359   // ── Name game ─────────────────────────────────────────────────────────────
    360 
    361   startNameGame() {
    362     this.nameState = 'ask';
    363     this.playerName = '';
    364     this.acceptInput = true;            // let kids type right away
    365     this._reflow();
    366     this._typewriter('What is your name?');
    367   }
    368 
    369   _nameGreet() {
    370     this.nameState = 'greet';
    371     this.acceptInput = false;
    372     this._cancelPause();
    373     this.hintText.setVisible(false);
    374     this.inputText.setText('');
    375     this._reflow();
    376     this._typewriter('Nice to meet you, ' + this.playerName + '!', () => {
    377       this.time.delayedCall(1400, () => this._nameChallenge());
    378     });
    379   }
    380 
    381   _nameChallenge() {
    382     this.nameState = 'challenge';
    383     this._typewriter('How fast can you type your name?', () => {
    384       this.time.delayedCall(1000, () => this._nameCountdown());
    385     });
    386   }
    387 
    388   _nameCountdown() {
    389     this.nameState = 'countdown';
    390     this.bigText.setVisible(true);
    391     const seq = ['5', '4', '3', '2', '1', 'Go!'];
    392     let i = 0;
    393     const tick = () => {
    394       if (i >= seq.length) {
    395         this.bigText.setVisible(false);
    396         this._nameRace();
    397         return;
    398       }
    399       const s = seq[i++];
    400       this.bigText.setText(s).setScale(0.6).setAlpha(1);
    401       this.tweens.add({ targets: this.bigText, scale: 1, duration: 300, ease: 'Back.Out' });
    402       this.audio.speak(s === 'Go!' ? 'Go' : s);
    403       this.time.delayedCall(900, tick);
    404     };
    405     tick();
    406   }
    407 
    408   _nameRace() {
    409     this.nameState = 'racing';
    410     this._cancelTypewriter();
    411     this.promptText.setText('Type your name as fast as you can!');
    412     this.input_buf = '';
    413     this.inputText.setText('');
    414     this.acceptInput = true;
    415     this.stopwatchText.setText('0.00 s').setVisible(true);
    416     this._raceStart = performance.now();
    417     this._reflow();
    418   }
    419 
    420   _finishRace() {
    421     this.acceptInput = false;
    422     this.nameState = 'result';
    423     const ms = performance.now() - this._raceStart;
    424     this.lastTimeMs = ms;
    425     this.stopwatchText.setVisible(false);
    426 
    427     let best = null;
    428     try { const v = localStorage.getItem('work.nameBest'); if (v != null) best = parseFloat(v); } catch (_) {}
    429     const isRecord = WorkLib.isNewRecord(ms, best);
    430     if (isRecord) {
    431       try { localStorage.setItem('work.nameBest', String(ms)); } catch (_) {}
    432       KGames.launchFireworks(this);
    433       const r = this._pageRect(this.scale.width, this.scale.height);
    434       KGames.burstConfetti(this, r.x + r.w / 2, r.y + r.h / 2);
    435       this.audio.playVictorySound();
    436     } else {
    437       this.audio.playSuccess();
    438     }
    439 
    440     this.inputText.setText('');
    441     this.promptText.setText(
    442       'You typed your name in ' + WorkLib.formatTime(ms) + '!' +
    443       (isRecord ? '\nNew record! 🎉' : '')
    444     );
    445     this._reflow();
    446     this._showButtons([
    447       { label: 'Play Again', color: 0x2EC4B6, cb: () => { this.stopwatchText.setVisible(false); this._clearButtons(); this._nameCountdown(); } },
    448       { label: 'New Page',   color: 0xFFD23F, cb: () => this.enterMode('idle') },
    449     ]);
    450   }
    451 
    452   // ── Mad Libs ─────────────────────────────────────────────────────────────
    453 
    454   startMadlib() {
    455     this.madTemplate = WorkLib.pickTemplate();
    456     this._showStoryCredit(this.madTemplate.source);
    457     this.madValues = {};
    458     this.madIndex = 0;
    459     this.madState = 'asking';
    460     this._madNextBlank();
    461   }
    462 
    463   _madNextBlank() {
    464     this._cancelPause();
    465     this.hintText.setVisible(false);
    466     this.input_buf = '';
    467     this.inputText.setText('');
    468     this.acceptInput = false;
    469 
    470     if (this.madIndex >= this.madTemplate.blanks.length) {
    471       this._madShowStory();
    472       return;
    473     }
    474     const blank = this.madTemplate.blanks[this.madIndex];
    475     this.acceptInput = true;            // let kids type right away
    476     this._reflow();
    477     this._typewriter(blank.label);
    478   }
    479 
    480   _madShowStory() {
    481     this.madState = 'story';
    482     this.acceptInput = false;
    483     this.cursor.setVisible(false);
    484     const story = WorkLib.fillTemplate(this.madTemplate, this.madValues);
    485     this.promptText.setText(this.madTemplate.title + '\n\n' + story);
    486     this.inputText.setText('');
    487     this._reflow();
    488     this.audio.speak(story);
    489     const r = this._pageRect(this.scale.width, this.scale.height);
    490     KGames.burstConfetti(this, r.x + r.w / 2, r.y + r.h / 2);
    491     this._showButtons([
    492       { label: 'Play Again', color: 0x2EC4B6, cb: () => this.startMadlib() },
    493       { label: 'New Page',   color: 0xFFD23F, cb: () => this.enterMode('idle') },
    494     ]);
    495   }
    496 
    497   // ── In-page buttons ─────────────────────────────────────────────────────────
    498 
    499   _showButtons(defs) {
    500     this._clearButtons();
    501     defs.forEach((d) => {
    502       const g = this.add.graphics();
    503       const t = this.add.text(0, 0, d.label, {
    504         fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#ffffff',
    505       }).setOrigin(0.5);
    506       const z = this.add.zone(0, 0, 10, 10).setOrigin(0.5).setInteractive({ useHandCursor: true });
    507       z.on('pointerup', () => d.cb());
    508       this._buttons.push({ g, t, z, color: d.color });
    509     });
    510     this._positionButtons(this.scale.width, this.scale.height);
    511   }
    512 
    513   _positionButtons(W, H) {
    514     if (!this._buttons.length) return;
    515     const r = this._pageRect(W, H);
    516     const btnW = Math.min(220, r.w * 0.4);
    517     const btnH = Math.max(46, H * 0.08);
    518     const fs   = Math.max(18, Math.floor(btnH * 0.4));
    519     const gap  = Math.floor(btnW * 0.12);
    520     const n = this._buttons.length;
    521     const totalW = n * btnW + (n - 1) * gap;
    522     let x = r.x + r.w / 2 - totalW / 2;
    523     const cy = r.y + r.h - btnH;
    524 
    525     this._buttons.forEach(({ g, t, z, color }) => {
    526       const cx = x + btnW / 2;
    527       g.clear();
    528       g.fillStyle(color, 1);
    529       g.fillRoundedRect(x, cy - btnH / 2, btnW, btnH, 12);
    530       t.setPosition(cx, cy).setStyle({ fontSize: fs + 'px' });
    531       z.setPosition(cx, cy).setSize(btnW, btnH);
    532       if (z.input && z.input.hitArea) z.input.hitArea.setTo(0, 0, btnW, btnH);
    533       x += btnW + gap;
    534     });
    535   }
    536 
    537   _clearButtons() {
    538     this._buttons.forEach(({ g, t, z }) => { g.destroy(); t.destroy(); z.destroy(); });
    539     this._buttons = [];
    540   }
    541 
    542   // ── Update loop ─────────────────────────────────────────────────────────────
    543 
    544   update() {
    545     if (this.mode === 'name' && this.nameState === 'racing' && this._raceStart != null) {
    546       this.stopwatchText.setText(WorkLib.formatTime(performance.now() - this._raceStart));
    547     }
    548   }
    549 }
    550 
    551 window.__KG_GAME__ = new Phaser.Game({
    552   type:   Phaser.AUTO,
    553   parent: 'game-container',
    554   backgroundColor: '#E8E8EC',
    555   scene:  WorkScene,
    556   scale: {
    557     mode:   Phaser.Scale.RESIZE,
    558     width:  '100%',
    559     height: '100%',
    560   },
    561   render: { preserveDrawingBuffer: true },
    562 });