kgames

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

commit e7bf30e6b3a6a28a20bf432eaa6b84ee10ae0298
parent da2670e596b47f0d619479b8260e91185a286d8b
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Tue,  2 Jun 2026 10:45:25 -0700

work: add pretend word-processor game with Name and Mad Libs subgames

New game "Work" — a light, Word-style page with a ribbon of icons that
launch typing subgames:

- Name: spoken/typed prompt asks the child's name, greets them, then runs
  a 5-second countdown and times how fast they retype it (auto-stops on
  match); confetti + fireworks on a new record (stored in localStorage).
- Mad Libs: kid-friendly word prompts fill a randomly chosen silly story.
- "New": a free-typing blank page (Enter inserts a newline).

Implementation notes:
- All-canvas Phaser scene; pure logic in lib.js (templates, typewriter
  steps, time/record formatting) with unit tests.
- Single _reflow() vertical-flow layout stacks prompt/input/hint by their
  measured heights so wrapped/multi-line text never overlaps.
- Interactive zone hit areas are synced to button size on every layout.
- Added reusable AudioSystem.playKeyClick() (typewriter tick) to shared lib.

Adds thumbnail, portal tile, unit tests, and browser smoke tests.
All 54 unit + 27 browser tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Aassets/thumbnails/work.svg | 22++++++++++++++++++++++
Agames/work/game.js | 552+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/work/index.html | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/work/lib.js | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mjs/main.js | 2+-
Mjs/shared/game-shared.js | 17+++++++++++++++++
Mtests/browser/runner.js | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/work-lib.test.js | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 901 insertions(+), 1 deletion(-)

diff --git a/assets/thumbnails/work.svg b/assets/thumbnails/work.svg @@ -0,0 +1,22 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 130" width="160" height="130"> + <!-- background rounded rect --> + <rect x="8" y="8" width="144" height="114" rx="18" fill="#FFF5F5" stroke="#1D7CF2" stroke-width="4"/> + + <!-- toolbar / ribbon --> + <rect x="24" y="26" width="112" height="16" rx="4" fill="#EDEDF2"/> + <rect x="29" y="29" width="10" height="10" rx="2" fill="#E63946"/> + <rect x="43" y="29" width="10" height="10" rx="2" fill="#1D7CF2"/> + <rect x="57" y="29" width="10" height="10" rx="2" fill="#FFD23F"/> + <rect x="71" y="29" width="10" height="10" rx="2" fill="#2EC4B6"/> + + <!-- white page --> + <rect x="34" y="50" width="92" height="62" rx="5" fill="#ffffff" stroke="#D0D0D8" stroke-width="2"/> + + <!-- text lines --> + <rect x="42" y="60" width="60" height="5" rx="2.5" fill="#C7C7D2"/> + <rect x="42" y="72" width="72" height="5" rx="2.5" fill="#C7C7D2"/> + <rect x="42" y="84" width="48" height="5" rx="2.5" fill="#C7C7D2"/> + + <!-- blinking cursor --> + <rect x="93" y="83" width="3" height="9" fill="#222233"/> +</svg> diff --git a/games/work/game.js b/games/work/game.js @@ -0,0 +1,552 @@ +const PALETTE = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6]; +const PAUSE_MS = 2200; // idle pause before "Press Enter" hint appears + +class WorkScene extends Phaser.Scene { + constructor() { + super({ key: 'WorkScene' }); + this.mode = 'idle'; + this.input_buf = ''; + this.acceptInput = false; + + this.nameState = null; + this.playerName = ''; + this.lastTimeMs = null; + this._raceStart = null; + + this.madState = null; + this.madTemplate = null; + this.madValues = {}; + this.madIndex = 0; + + this._twEvent = null; + this._pauseEvent = null; + this._buttons = []; + } + + preload() { + KGames.bakeFireworksAtlas(this); + } + + create() { + window.__WORK_SCENE__ = this; + const W = this.scale.width; + const H = this.scale.height; + + this.audio = new KGames.AudioSystem({ melody: [], harmony: [], bass: [] }); + + this.pageGfx = this.add.graphics(); + this.ribbonGfx = this.add.graphics(); + + this.promptText = this.add.text(0, 0, '', { + fontFamily: 'Fredoka, sans-serif', color: '#555566', + }).setOrigin(0, 0); + + this.inputText = this.add.text(0, 0, '', { + fontFamily: 'Fredoka, sans-serif', color: '#222233', + }).setOrigin(0, 0); + + this.hintText = this.add.text(0, 0, '', { + fontFamily: 'Fredoka, sans-serif', color: '#1D7CF2', + }).setOrigin(0, 0).setVisible(false); + + this.bigText = this.add.text(0, 0, '', { + fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#E63946', + }).setOrigin(0.5, 0.5).setVisible(false); + + this.stopwatchText = this.add.text(0, 0, '', { + fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#2EC4B6', + }).setOrigin(1, 0).setVisible(false); + + this.cursor = this.add.rectangle(0, 0, 3, 30, 0x222233).setOrigin(0, 0); + this.tweens.add({ targets: this.cursor, alpha: 0, duration: 500, yoyo: true, repeat: -1 }); + + this._buildRibbon(); + + this.scale.on('resize', this.onResize, this); + this.input.keyboard.on('keydown', this.onKey, this); + + const sfxBtn = document.getElementById('btn-sfx'); + if (sfxBtn) sfxBtn.addEventListener('click', () => { + this.audio.sfxMuted = !this.audio.sfxMuted; + sfxBtn.textContent = 'Sound: ' + (this.audio.sfxMuted ? 'OFF' : 'ON'); + sfxBtn.classList.toggle('muted', this.audio.sfxMuted); + }); + + this.enterMode('idle'); + this.applyLayout(W, H); + } + + // ── Layout ────────────────────────────────────────────────────────────────── + + _pageRect(W, H) { + const ribbonH = Math.max(56, Math.floor(H * 0.12)); + const margin = Math.max(20, Math.floor(W * 0.06)); + const top = ribbonH + Math.floor(H * 0.05); + const x = margin; + const y = top; + const w = W - margin * 2; + const h = H - top - Math.floor(H * 0.05); + return { x, y, w, h, ribbonH }; + } + + applyLayout(W, H) { + const r = this._pageRect(W, H); + const fontSize = Math.max(18, Math.floor(H * 0.04)); + const pad = Math.floor(r.w * 0.05); + + // Page with soft shadow + this.pageGfx.clear(); + this.pageGfx.fillStyle(0x000000, 0.08); + this.pageGfx.fillRoundedRect(r.x + 5, r.y + 7, r.w, r.h, 10); + this.pageGfx.fillStyle(0xffffff, 1); + this.pageGfx.fillRoundedRect(r.x, r.y, r.w, r.h, 10); + + const textX = r.x + pad; + const textW = r.w - pad * 2; + + this._textX = textX; this._pageTop = r.y; this._pad = pad; this._baseFont = fontSize; + this.promptText.setStyle({ fontSize: fontSize + 'px', wordWrap: { width: textW } }); + this.inputText.setStyle({ fontSize: (fontSize + 4) + 'px', wordWrap: { width: textW } }); + this.hintText.setStyle({ fontSize: Math.floor(fontSize * 0.8) + 'px' }); + this._reflow(); + this.bigText.setStyle({ fontSize: Math.floor(H * 0.22) + 'px' }) + .setPosition(r.x + r.w / 2, r.y + r.h / 2); + this.stopwatchText.setStyle({ fontSize: Math.floor(fontSize * 1.4) + 'px' }) + .setPosition(r.x + r.w - pad, r.y + pad); + this.cursor.setSize(3, fontSize + 6); + + this._layoutRibbon(W, H, r.ribbonH); + this._positionButtons(W, H); + this._updateCursor(); + } + + onResize(gameSize) { + this.applyLayout(gameSize.width, gameSize.height); + } + + // Single source of truth for vertical layout inside the page. Stacks each + // visible text block below the measured bottom of the previous one, so wrapped + // or multi-line text (long prompts, the "New record!" result) never overlaps. + // Call this after ANY change to prompt/input/hint text or visibility. + _reflow() { + if (this._textX == null) return; + const gap = Math.floor(this._baseFont * 0.6); + let y = this._pageTop + this._pad; + + if (this.promptText.text !== '') { + this.promptText.setPosition(this._textX, y); + y += this.promptText.height + gap; + } + + this.inputText.setPosition(this._textX, y); + if (this.inputText.text !== '') y += this.inputText.height + gap; + + if (this.hintText.visible) this.hintText.setPosition(this._textX, y); + + this._updateCursor(); + } + + // ── Ribbon ────────────────────────────────────────────────────────────────── + + _buildRibbon() { + this.ribbonItems = [ + { key: 'idle', label: 'New', glyph: '📄', color: 0xFFD23F }, + { key: 'name', label: 'Name', glyph: '🙂', color: 0xE63946 }, + { key: 'madlib', label: 'Stories', glyph: '✏️', color: 0x1D7CF2 }, + ]; + this.ribbonObjs = this.ribbonItems.map((item) => { + const g = this.add.graphics(); + const t = this.add.text(0, 0, item.glyph + '\n' + item.label, { + fontFamily: 'Fredoka, sans-serif', fontStyle: '600', color: '#334', + align: 'center', + }).setOrigin(0.5, 0.5); + const z = this.add.zone(0, 0, 10, 10).setOrigin(0.5, 0.5).setInteractive({ useHandCursor: true }); + z.on('pointerover', () => { this._hoverKey = item.key; this._drawRibbonBtn(g, item, true); this.audio.speak(item.label); }); + z.on('pointerout', () => { this._hoverKey = null; this._drawRibbonBtn(g, item, false); }); + z.on('pointerup', () => this.enterMode(item.key)); + return { item, g, t, z }; + }); + } + + _layoutRibbon(W, H, ribbonH) { + this.ribbonGfx.clear(); + this.ribbonGfx.fillStyle(0xF4F4F6, 1); + this.ribbonGfx.fillRect(0, 0, W, ribbonH); + this.ribbonGfx.lineStyle(2, 0xD0D0D8, 1); + this.ribbonGfx.lineBetween(0, ribbonH, W, ribbonH); + + const n = this.ribbonObjs.length; + const btnW = Math.min(120, Math.floor(W / (n + 1))); + const btnH = ribbonH - 12; + const gap = Math.floor(btnW * 0.15); + const totalW = n * btnW + (n - 1) * gap; + let x = (W - totalW) / 2; + const cy = ribbonH / 2; + const fs = Math.max(13, Math.floor(btnH * 0.22)); + + this.ribbonObjs.forEach(({ item, g, t, z }) => { + const cx = x + btnW / 2; + g._rect = { x: x, y: 6, w: btnW, h: btnH }; + this._drawRibbonBtn(g, item, this._hoverKey === item.key); + t.setPosition(cx, cy).setStyle({ fontSize: fs + 'px' }); + z.setPosition(cx, cy).setSize(btnW, btnH); + if (z.input && z.input.hitArea) z.input.hitArea.setTo(0, 0, btnW, btnH); + x += btnW + gap; + }); + } + + _drawRibbonBtn(g, item, hover) { + if (!g._rect) return; + const { x, y, w, h } = g._rect; + g.clear(); + g.fillStyle(item.color, hover ? 0.35 : 0.18); + g.fillRoundedRect(x, y, w, h, 8); + g.lineStyle(2, item.color, 1); + g.strokeRoundedRect(x, y, w, h, 8); + } + + // ── Modes ───────────────────────────────────────────────────────────────── + + enterMode(mode) { + this._cancelTypewriter(); + this._cancelPause(); + this._clearButtons(); + this.input_buf = ''; + this.acceptInput = false; + this.hintText.setVisible(false); + this.bigText.setVisible(false); + this.stopwatchText.setVisible(false); + this.inputText.setText(''); + this.mode = mode; + + if (mode === 'idle') { + this.nameState = null; this.madState = null; + this.promptText.setText(''); + this.acceptInput = true; // blank page: free typing + this.cursor.setVisible(true); + } + this._reflow(); + + if (mode === 'name') { + this.startNameGame(); + } else if (mode === 'madlib') { + this.startMadlib(); + } + } + + // ── Text input ────────────────────────────────────────────────────────────── + + onKey(event) { + if (!this.acceptInput) return; + + const k = event.key; + if (k === 'Backspace') { + event.preventDefault(); + this.input_buf = this.input_buf.slice(0, -1); + } else if (k === 'Enter') { + if (this.mode === 'idle') { + this.input_buf += '\n'; // blank page: newline + } else { + this._onEnter(); + return; + } + } else if (k === ' ' || /^[a-zA-Z]$/.test(k)) { + this.input_buf += k; + } else { + return; + } + + this.audio.playKeyClick(); + this.hintText.setVisible(false); + this.inputText.setText(this.input_buf); + this._reflow(); + this._resetPause(); + this._onInputChanged(); + } + + _onInputChanged() { + if (this.mode === 'name' && this.nameState === 'racing') { + if (WorkLib.normalizeName(this.input_buf) === WorkLib.normalizeName(this.playerName)) { + this._finishRace(); + } + } + } + + _onEnter() { + if (this.mode === 'name') { + if (this.nameState === 'ask' && WorkLib.isNameReady(this.input_buf)) { + this.playerName = this.input_buf.trim(); + this._nameGreet(); + } + } else if (this.mode === 'madlib' && this.madState === 'asking') { + if (this.input_buf.trim().length > 0) { + this.madValues[this.madTemplate.blanks[this.madIndex].key] = this.input_buf.trim(); + this.madIndex++; + this._madNextBlank(); + } + } + } + + _resetPause() { + this._cancelPause(); + this._pauseEvent = this.time.delayedCall(PAUSE_MS, () => this._onPause()); + } + + _cancelPause() { + if (this._pauseEvent) { this._pauseEvent.remove(false); this._pauseEvent = null; } + } + + _onPause() { + const ready = (this.mode === 'name' && this.nameState === 'ask' && WorkLib.isNameReady(this.input_buf)) || + (this.mode === 'madlib' && this.madState === 'asking' && this.input_buf.trim().length > 0); + if (ready) { + this.hintText.setText('Press Enter ⏎').setVisible(true); + this._reflow(); + } + } + + _updateCursor() { + this.cursor.setVisible(this.acceptInput); + if (!this.acceptInput) return; + // Sit at the end of the last line of typed text. + const lines = this.inputText.text.split('\n'); + const lineH = this.inputText.height / Math.max(1, lines.length); + const lastY = this.inputText.y + lineH * (lines.length - 1); + const lastW = this.inputText.text === '' ? 0 : this._measureWidth(lines[lines.length - 1]); + this.cursor.setPosition(this.inputText.x + lastW + 2, lastY); + } + + _measureWidth(str) { + if (str === '') return 0; + const probe = this._cursorProbe || (this._cursorProbe = this.add.text(0, 0, '', { + fontFamily: 'Fredoka, sans-serif', + }).setVisible(false)); + probe.setStyle({ fontSize: this.inputText.style.fontSize }).setText(str); + return probe.width; + } + + // ── Typewriter ────────────────────────────────────────────────────────────── + + _typewriter(target, onDone) { + this._cancelTypewriter(); + const steps = WorkLib.typewriterSteps(this.promptText.text, target); + this.audio.speak(target); + let i = 0; + this._twEvent = this.time.addEvent({ + delay: 45, + repeat: steps.length - 1, + callback: () => { + this.promptText.setText(steps[i++]); + this._reflow(); + if (i >= steps.length) { this._twEvent = null; if (onDone) onDone(); } + }, + }); + } + + _cancelTypewriter() { + if (this._twEvent) { this._twEvent.remove(false); this._twEvent = null; } + } + + // ── Name game ───────────────────────────────────────────────────────────── + + startNameGame() { + this.nameState = 'ask'; + this.playerName = ''; + this.acceptInput = true; // let kids type right away + this._reflow(); + this._typewriter('What is your name?'); + } + + _nameGreet() { + this.nameState = 'greet'; + this.acceptInput = false; + this._cancelPause(); + this.hintText.setVisible(false); + this.inputText.setText(''); + this._reflow(); + this._typewriter('Nice to meet you, ' + this.playerName + '!', () => { + this.time.delayedCall(1400, () => this._nameChallenge()); + }); + } + + _nameChallenge() { + this.nameState = 'challenge'; + this._typewriter('How fast can you type your name?', () => { + this.time.delayedCall(1000, () => this._nameCountdown()); + }); + } + + _nameCountdown() { + this.nameState = 'countdown'; + this.bigText.setVisible(true); + const seq = ['5', '4', '3', '2', '1', 'Go!']; + let i = 0; + const tick = () => { + if (i >= seq.length) { + this.bigText.setVisible(false); + this._nameRace(); + return; + } + const s = seq[i++]; + this.bigText.setText(s).setScale(0.6).setAlpha(1); + this.tweens.add({ targets: this.bigText, scale: 1, duration: 300, ease: 'Back.Out' }); + this.audio.speak(s === 'Go!' ? 'Go' : s); + this.time.delayedCall(900, tick); + }; + tick(); + } + + _nameRace() { + this.nameState = 'racing'; + this._cancelTypewriter(); + this.promptText.setText('Type your name as fast as you can!'); + this.input_buf = ''; + this.inputText.setText(''); + this.acceptInput = true; + this.stopwatchText.setText('0.00 s').setVisible(true); + this._raceStart = performance.now(); + this._reflow(); + } + + _finishRace() { + this.acceptInput = false; + this.nameState = 'result'; + const ms = performance.now() - this._raceStart; + this.lastTimeMs = ms; + this.stopwatchText.setVisible(false); + + let best = null; + try { const v = localStorage.getItem('work.nameBest'); if (v != null) best = parseFloat(v); } catch (_) {} + const isRecord = WorkLib.isNewRecord(ms, best); + if (isRecord) { + try { localStorage.setItem('work.nameBest', String(ms)); } catch (_) {} + KGames.launchFireworks(this); + const r = this._pageRect(this.scale.width, this.scale.height); + KGames.burstConfetti(this, r.x + r.w / 2, r.y + r.h / 2); + this.audio.playVictorySound(); + } else { + this.audio.playSuccess(); + } + + this.inputText.setText(''); + this.promptText.setText( + 'You typed your name in ' + WorkLib.formatTime(ms) + '!' + + (isRecord ? '\nNew record! 🎉' : '') + ); + this._reflow(); + this._showButtons([ + { label: 'Play Again', color: 0x2EC4B6, cb: () => { this.stopwatchText.setVisible(false); this._clearButtons(); this._nameCountdown(); } }, + { label: 'New Page', color: 0xFFD23F, cb: () => this.enterMode('idle') }, + ]); + } + + // ── Mad Libs ───────────────────────────────────────────────────────────── + + startMadlib() { + this.madTemplate = WorkLib.pickTemplate(); + this.madValues = {}; + this.madIndex = 0; + this.madState = 'asking'; + this._madNextBlank(); + } + + _madNextBlank() { + this._cancelPause(); + this.hintText.setVisible(false); + this.input_buf = ''; + this.inputText.setText(''); + this.acceptInput = false; + + if (this.madIndex >= this.madTemplate.blanks.length) { + this._madShowStory(); + return; + } + const blank = this.madTemplate.blanks[this.madIndex]; + this.acceptInput = true; // let kids type right away + this._reflow(); + this._typewriter(blank.label); + } + + _madShowStory() { + this.madState = 'story'; + this.acceptInput = false; + this.cursor.setVisible(false); + const story = WorkLib.fillTemplate(this.madTemplate, this.madValues); + this.promptText.setText(this.madTemplate.title + '\n\n' + story); + this.inputText.setText(''); + this._reflow(); + this.audio.speak(story); + const r = this._pageRect(this.scale.width, this.scale.height); + KGames.burstConfetti(this, r.x + r.w / 2, r.y + r.h / 2); + this._showButtons([ + { label: 'Play Again', color: 0x2EC4B6, cb: () => this.startMadlib() }, + { label: 'New Page', color: 0xFFD23F, cb: () => this.enterMode('idle') }, + ]); + } + + // ── In-page buttons ───────────────────────────────────────────────────────── + + _showButtons(defs) { + this._clearButtons(); + defs.forEach((d) => { + const g = this.add.graphics(); + const t = this.add.text(0, 0, d.label, { + fontFamily: 'Fredoka, sans-serif', fontStyle: 'bold', color: '#ffffff', + }).setOrigin(0.5); + const z = this.add.zone(0, 0, 10, 10).setOrigin(0.5).setInteractive({ useHandCursor: true }); + z.on('pointerup', () => d.cb()); + this._buttons.push({ g, t, z, color: d.color }); + }); + this._positionButtons(this.scale.width, this.scale.height); + } + + _positionButtons(W, H) { + if (!this._buttons.length) return; + const r = this._pageRect(W, H); + const btnW = Math.min(220, r.w * 0.4); + const btnH = Math.max(46, H * 0.08); + const fs = Math.max(18, Math.floor(btnH * 0.4)); + const gap = Math.floor(btnW * 0.12); + const n = this._buttons.length; + const totalW = n * btnW + (n - 1) * gap; + let x = r.x + r.w / 2 - totalW / 2; + const cy = r.y + r.h - btnH; + + this._buttons.forEach(({ g, t, z, color }) => { + const cx = x + btnW / 2; + g.clear(); + g.fillStyle(color, 1); + g.fillRoundedRect(x, cy - btnH / 2, btnW, btnH, 12); + t.setPosition(cx, cy).setStyle({ fontSize: fs + 'px' }); + z.setPosition(cx, cy).setSize(btnW, btnH); + if (z.input && z.input.hitArea) z.input.hitArea.setTo(0, 0, btnW, btnH); + x += btnW + gap; + }); + } + + _clearButtons() { + this._buttons.forEach(({ g, t, z }) => { g.destroy(); t.destroy(); z.destroy(); }); + this._buttons = []; + } + + // ── Update loop ───────────────────────────────────────────────────────────── + + update() { + if (this.mode === 'name' && this.nameState === 'racing' && this._raceStart != null) { + this.stopwatchText.setText(WorkLib.formatTime(performance.now() - this._raceStart)); + } + } +} + +new Phaser.Game({ + type: Phaser.AUTO, + parent: 'game-container', + backgroundColor: '#E8E8EC', + scene: WorkScene, + scale: { + mode: Phaser.Scale.RESIZE, + width: '100%', + height: '100%', + }, + render: { preserveDrawingBuffer: true }, +}); diff --git a/games/work/index.html b/games/work/index.html @@ -0,0 +1,60 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Work — KGames</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600&display=swap" rel="stylesheet"> + <style> + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { height: 100%; overflow: hidden; } + body { + background: #E8E8EC; + font-family: 'Fredoka', sans-serif; + display: flex; + flex-direction: column; + } + nav { width: 100%; padding: 0.6rem 1.25rem; flex-shrink: 0; } + nav a { color: #667; text-decoration: none; font-size: 1rem; } + nav a:hover { color: #223; } + #game-container { flex: 1; width: 100%; } + + #audio-controls { + position: fixed; + top: 0.45rem; + right: 1rem; + display: flex; + gap: 0.4rem; + z-index: 10; + } + #audio-controls button { + background: rgba(0,0,0,0.05); + border: 1px solid rgba(0,0,0,0.15); + color: #556; + font-family: 'Fredoka', sans-serif; + font-size: 0.85rem; + padding: 0.25rem 0.65rem; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + user-select: none; + } + #audio-controls button:hover { background: rgba(0,0,0,0.1); color: #112; } + #audio-controls button.muted { color: #aab; border-color: rgba(0,0,0,0.07); } + </style> +</head> +<body> + <nav><a href="../../index.html">&larr; Back to KGames</a></nav> + <div id="audio-controls"> + <button id="btn-sfx">Sound: ON</button> + </div> + <div id="game-container"></div> + <script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script> + <script src="https://cdn.jsdelivr.net/npm/tone@14.7.77/build/Tone.js"></script> + <script src="../../js/shared/game-shared.js"></script> + <script src="lib.js"></script> + <script src="game.js"></script> +</body> +</html> diff --git a/games/work/lib.js b/games/work/lib.js @@ -0,0 +1,124 @@ +(function (root) { + 'use strict'; + + // ── Mad Libs templates ────────────────────────────────────────────────────── + // Kid-friendly category labels only (never "noun/verb/adjective"). + // `story` uses {key} placeholders matching each blank's `key`. + + const MADLIB_TEMPLATES = [ + { + id: 'zoo', + title: 'A Day at the Zoo', + blanks: [ + { key: 'name', label: 'Tell me your name!', voice: 'Tell me your name!' }, + { key: 'animal', label: 'Tell me an animal!', voice: 'Tell me an animal!' }, + { key: 'color', label: 'Tell me a color!', voice: 'Tell me a color!' }, + { key: 'food', label: 'Tell me a yummy food!', voice: 'Tell me a yummy food!' }, + { key: 'silly', label: 'Tell me a silly word!', voice: 'Tell me a silly word!' }, + ], + story: 'One day, {name} went to the zoo and saw a {color} {animal}. ' + + 'It was eating {food}! When it saw {name}, the {animal} said "{silly}!" ' + + 'and did a happy dance. It was the best day ever.', + }, + { + id: 'space', + title: 'Blast Off!', + blanks: [ + { key: 'name', label: 'Tell me your name!', voice: 'Tell me your name!' }, + { key: 'animal', label: 'Tell me an animal!', voice: 'Tell me an animal!' }, + { key: 'place', label: 'Tell me a place!', voice: 'Tell me a place!' }, + { key: 'number', label: 'Tell me a number!', voice: 'Tell me a number!' }, + { key: 'silly', label: 'Tell me a silly word!', voice: 'Tell me a silly word!' }, + ], + story: 'Captain {name} flew a rocket all the way to {place}. ' + + 'On board was a brave {animal} and {number} jellybeans. ' + + 'When they landed, everyone shouted "{silly}!" and bounced around in space.', + }, + { + id: 'party', + title: 'The Silly Party', + blanks: [ + { key: 'name', label: 'Tell me a friend’s name!', voice: 'Tell me a friend’s name!' }, + { key: 'food', label: 'Tell me a yummy food!', voice: 'Tell me a yummy food!' }, + { key: 'color', label: 'Tell me a color!', voice: 'Tell me a color!' }, + { key: 'animal', label: 'Tell me an animal!', voice: 'Tell me an animal!' }, + { key: 'action', label: 'Tell me something you can do!', voice: 'Tell me something you can do!' }, + ], + story: '{name} had a party with a giant {color} cake made of {food}. ' + + 'A friendly {animal} came too, and everybody started to {action}! ' + + 'It was the silliest party in the whole world.', + }, + { + id: 'breakfast', + title: 'Funny Breakfast', + blanks: [ + { key: 'name', label: 'Tell me your name!', voice: 'Tell me your name!' }, + { key: 'food', label: 'Tell me a yummy food!', voice: 'Tell me a yummy food!' }, + { key: 'animal', label: 'Tell me an animal!', voice: 'Tell me an animal!' }, + { key: 'silly', label: 'Tell me a silly word!', voice: 'Tell me a silly word!' }, + ], + story: 'This morning {name} ate {food} for breakfast. ' + + 'Then a sleepy {animal} hopped onto the table and yelled "{silly}!" ' + + 'It wanted breakfast too, so {name} shared. Yum yum!', + }, + ]; + + // ── Pure helpers ───────────────────────────────────────────────────────────── + + function fillTemplate(template, values) { + return template.story.replace(/\{(\w+)\}/g, function (_, key) { + const v = values[key]; + return (v === undefined || v === null || v === '') ? '___' : String(v); + }); + } + + function pickTemplate(rng) { + const r = (typeof rng === 'function') ? rng : Math.random; + return MADLIB_TEMPLATES[Math.floor(r() * MADLIB_TEMPLATES.length)]; + } + + // Frame-by-frame strings for the live "typing" effect: backspace `from` + // down to the common prefix of `from`/`to`, then type up to `to`. + function typewriterSteps(from, to) { + from = from || ''; + to = to || ''; + let common = 0; + const max = Math.min(from.length, to.length); + while (common < max && from[common] === to[common]) common++; + + const steps = []; + for (let i = from.length - 1; i >= common; i--) steps.push(from.slice(0, i)); + for (let i = common + 1; i <= to.length; i++) steps.push(to.slice(0, i)); + if (steps.length === 0 || steps[steps.length - 1] !== to) steps.push(to); + return steps; + } + + function normalizeName(str) { + return String(str == null ? '' : str).trim().replace(/\s+/g, ' ').toLowerCase(); + } + + function isNameReady(str) { + return normalizeName(str).length >= 2; + } + + function formatTime(ms) { + return (Math.max(0, ms) / 1000).toFixed(2) + ' s'; + } + + function isNewRecord(ms, best) { + return best == null || ms < best; + } + + const api = { + MADLIB_TEMPLATES, + fillTemplate, + pickTemplate, + typewriterSteps, + normalizeName, + isNameReady, + formatTime, + isNewRecord, + }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + root.WorkLib = api; +}(typeof globalThis !== 'undefined' ? globalThis : this)); diff --git a/js/main.js b/js/main.js @@ -3,7 +3,7 @@ const GAMES = [ { slug: 'fire-truck', title: 'Fire Truck', thumb: 'assets/thumbnails/fire-truck.svg', status: 'live' }, { slug: 'animal-letter', title: 'Animal Letter', thumb: 'assets/thumbnails/animal-letter.svg', status: 'live' }, { slug: 'animal-spell', title: 'Animal Spell', thumb: 'assets/thumbnails/animal-spell.svg', status: 'live' }, - { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, + { slug: 'work', title: 'Work', thumb: 'assets/thumbnails/work.svg', status: 'live' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, ]; diff --git a/js/shared/game-shared.js b/js/shared/game-shared.js @@ -325,6 +325,23 @@ } catch (_) {} } + // Short, dry tick — the typewriter keystroke sound. + playKeyClick() { + if (this.sfxMuted) return; + try { + const ctx = this._ctx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); gain.connect(ctx.destination); + osc.type = 'square'; + osc.frequency.setValueAtTime(1200, ctx.currentTime); + gain.gain.setValueAtTime(0.06, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.03); + osc.onended = () => { osc.disconnect(); gain.disconnect(); }; + osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.035); + } catch (_) {} + } + playSuccess() { if (this.sfxMuted) return; try { diff --git a/tests/browser/runner.js b/tests/browser/runner.js @@ -44,10 +44,12 @@ async function main() { const fireTruck = await page.$('a[href*="fire-truck"]'); const animalLetter = await page.$('a[href*="animal-letter"]'); const animalSpell = await page.$('a[href*="animal-spell"]'); + const work = await page.$('a[href*="work"]'); if (!letterFind) throw new Error('Letter Find tile not found'); if (!fireTruck) throw new Error('Fire Truck tile not found'); if (!animalLetter) throw new Error('Animal Letter tile not found'); if (!animalSpell) throw new Error('Animal Spell tile not found'); + if (!work) throw new Error('Work tile not found'); }); // ── Letter Find ─────────────────────────────────────────────────────────── @@ -174,6 +176,66 @@ async function main() { if (after !== (before || 0) + 1) throw new Error(`Letter index did not advance: ${before} -> ${after}`); }); + // ── Work ────────────────────────────────────────────────────────────────── + + await test('work: loads, no JS errors, canvas exists', async (page, url, errors) => { + await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + if (errors.length) throw new Error(errors[0]); + const canvas = await page.$('canvas'); + if (!canvas) throw new Error('No canvas element'); + }); + + await test('work: canvas renders multiple colors', async (page, url) => { + await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + const colorCount = await page.evaluate(() => { + const cv = document.querySelector('canvas'); + if (!cv) return 0; + const seen = new Set(); + const gl = cv.getContext('webgl2') || cv.getContext('webgl'); + if (gl) { + const pixels = new Uint8Array(cv.width * cv.height * 4); + gl.readPixels(0, 0, cv.width, cv.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + for (let i = 0; i < pixels.length; i += 32) seen.add(`${pixels[i]},${pixels[i+1]},${pixels[i+2]}`); + } else { + const ctx = cv.getContext('2d'); + const data = ctx.getImageData(0, 0, cv.width, cv.height).data; + for (let i = 0; i < data.length; i += 32) seen.add(`${data[i]},${data[i+1]},${data[i+2]}`); + } + return seen.size; + }); + if (colorCount < 4) throw new Error(`Only ${colorCount} colors found — canvas may be blank`); + }); + + await test('work: name round captures typed name on Enter', async (page, url) => { + await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1000); + await page.evaluate(() => window.__WORK_SCENE__.enterMode('name')); + await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 }); + await page.keyboard.type('ABC'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + const name = await page.evaluate(() => window.__WORK_SCENE__.playerName); + if (name !== 'ABC') throw new Error(`Expected playerName "ABC", got "${name}"`); + }); + + await test('work: speed round auto-stops when name matches', async (page, url) => { + await page.goto(`${url}/games/work/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1000); + await page.evaluate(() => { + const s = window.__WORK_SCENE__; + s.enterMode('name'); + s.playerName = 'Sam'; + s._nameRace(); + }); + await page.waitForFunction(() => window.__WORK_SCENE__.acceptInput === true, null, { timeout: 5000 }); + await page.keyboard.type('Sam'); + await page.waitForFunction(() => window.__WORK_SCENE__.nameState === 'result', null, { timeout: 5000 }); + const t = await page.evaluate(() => window.__WORK_SCENE__.lastTimeMs); + if (!(t >= 0)) throw new Error(`Expected a finite lastTimeMs, got ${t}`); + }); + // ── Fire Truck ──────────────────────────────────────────────────────────── await test('fire-truck: loads, no JS errors, canvas exists', async (page, url, errors) => { diff --git a/tests/unit/work-lib.test.js b/tests/unit/work-lib.test.js @@ -0,0 +1,63 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const lib = require('../../games/work/lib.js'); + +test('fillTemplate substitutes all blanks and leaves no braces', () => { + lib.MADLIB_TEMPLATES.forEach((tpl) => { + const values = {}; + tpl.blanks.forEach((b) => { values[b.key] = 'X' + b.key; }); + const out = lib.fillTemplate(tpl, values); + assert.ok(!/[{}]/.test(out), `${tpl.id}: leftover braces in "${out}"`); + tpl.blanks.forEach((b) => { + assert.ok(out.includes('X' + b.key), `${tpl.id}: missing value for ${b.key}`); + }); + }); +}); + +test('every story placeholder has a matching blank key', () => { + lib.MADLIB_TEMPLATES.forEach((tpl) => { + const placeholders = new Set((tpl.story.match(/\{(\w+)\}/g) || []).map(s => s.slice(1, -1))); + const keys = new Set(tpl.blanks.map(b => b.key)); + placeholders.forEach((p) => assert.ok(keys.has(p), `${tpl.id}: placeholder {${p}} has no blank`)); + keys.forEach((k) => assert.ok(placeholders.has(k), `${tpl.id}: blank ${k} unused in story`)); + }); +}); + +test('typewriterSteps passes through common prefix then ends at target', () => { + const steps = lib.typewriterSteps('cat', 'car'); + assert.equal(steps[steps.length - 1], 'car'); + assert.ok(steps.includes('ca'), 'should backspace to common prefix "ca"'); +}); + +test('typewriterSteps from empty only grows', () => { + assert.deepEqual(lib.typewriterSteps('', 'hi'), ['h', 'hi']); +}); + +test('normalizeName trims, collapses, lowercases', () => { + assert.equal(lib.normalizeName(' Sam Smith '), 'sam smith'); +}); + +test('isNameReady needs at least 2 chars', () => { + assert.equal(lib.isNameReady('a'), false); + assert.equal(lib.isNameReady(' a '), false); + assert.equal(lib.isNameReady('Bo'), true); +}); + +test('formatTime formats seconds to 2 decimals', () => { + assert.equal(lib.formatTime(3420), '3.42 s'); + assert.equal(lib.formatTime(0), '0.00 s'); +}); + +test('isNewRecord: null best, faster, slower', () => { + assert.equal(lib.isNewRecord(1000, null), true); + assert.equal(lib.isNewRecord(900, 1000), true); + assert.equal(lib.isNewRecord(1000, 1000), false); + assert.equal(lib.isNewRecord(1100, 1000), false); +}); + +test('pickTemplate returns a real template', () => { + const t = lib.pickTemplate(() => 0); + assert.equal(t, lib.MADLIB_TEMPLATES[0]); +});