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:
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">← 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]);
+});