- commit
- 24c4db3e1aee2015a1de0c48b47fabe8544eddf7
- parent
- ebec06b5e9ec1598cd500a75cf24b8ec641bac62
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 78 | ++++++++++++++++++++++++++---- |
| M | tests/ | 41 | ++++++++++++++++ |
@@ -23,21 +23,70 @@ export const TITLE_LEVEL = Object.freeze({
2323 2424 export const titleScene = Object.freeze({2525 id: 'title', eyebrow: 'MIAU IS TRUE', title: 'A world made of sentences',26- prompt: 'WALK THE CAT TO THE DOOR', help: 'ARROWS / WASD MOVE / Z UNDO / R RESTART / ENTER INDEX',26+ prompt: 'walk the cat to the door', help: '',2727 });2828 2929 const directions = Object.freeze({ up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] });3030 31-function drawHeader(ctx, width, height) {31+function rr(ctx, x, y, width, height, radius = 4) {32+ ctx.beginPath();33+ if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius); else ctx.rect(x, y, width, height);34+}35+function keycap(ctx, label, x, y, options = {}) {36+ const size = options.size || 10;37+ ctx.save?.(); ctx.font = `800 ${size}px Recursive, monospace`;38+ const width = Math.max(options.minWidth || 24, (ctx.measureText?.(label).width || label.length * size * 0.6) + 14);39+ const height = options.height || 22;40+ ctx.fillStyle = '#000'; ctx.fillRect?.(x + 3, y + 3, width, height);41+ rr(ctx, x, y, width, height, 4); ctx.fillStyle = '#232120'; ctx.fill(); ctx.strokeStyle = '#4A4540'; ctx.lineWidth = 1; ctx.stroke();42+ ctx.fillStyle = '#F2EEE6'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, x + width / 2, y + height / 2 + 1); ctx.restore?.();43+ return width;44+}45+function headerTile(ctx, label, x, y, width, height, role, progress) {46+ const drop = 1 - progress;47+ const offsetY = -height * 0.9 * drop;48+ const scale = 0.94 + progress * 0.06;49+ const lip = Math.max(3, height * 0.07);50+ ctx.save?.(); ctx.translate?.(x + width / 2, y + height / 2 + offsetY); ctx.scale?.(scale, scale);51+ ctx.fillStyle = '#000'; rr(ctx, -width / 2 + lip, -height / 2 + lip, width, height, role === 'operator' ? height / 2 : 4); ctx.fill();52+ rr(ctx, -width / 2, -height / 2, width, height, role === 'operator' ? height / 2 : 4);53+ if (role === 'operator') { ctx.fillStyle = '#191816'; ctx.fill(); ctx.strokeStyle = '#F2EEE6'; ctx.lineWidth = 2; ctx.stroke(); }54+ else { ctx.fillStyle = role === 'property' ? '#FF4B3E' : '#F2EEE6'; ctx.fill(); }55+ ctx.fillStyle = role === 'operator' ? '#F2EEE6' : '#0F0E0D'; ctx.font = `850 ${Math.max(13, height * 0.36)}px Recursive, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, 0, 1);56+ ctx.restore?.();57+}58+function drawHeader(ctx, width, height, { now = 0, reducedMotion = false } = {}) {3259 if (!ctx || typeof ctx.fillText !== 'function') return;33- ctx.save?.(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle';34- ctx.fillStyle = '#F2EEE6'; ctx.font = `800 ${Math.max(28, Math.min(76, width * 0.065))}px Recursive, monospace`; ctx.fillText('MIAU', width * 0.38, height * 0.1);35- ctx.fillStyle = '#8E877C'; ctx.font = `800 ${Math.max(20, Math.min(52, width * 0.045))}px Recursive, monospace`; ctx.fillText('IS', width * 0.5, height * 0.1);36- ctx.fillStyle = '#FF4B3E'; ctx.fillText('TRUE', width * 0.63, height * 0.1); ctx.restore?.();60+ const labels = [['MIAU', 'noun'], ['IS', 'operator'], ['TRUE', 'property']];61+ const gap = Math.max(7, Math.min(14, width * 0.018));62+ const tileHeight = Math.max(44, Math.min(82, width * 0.13));63+ const widths = labels.map(([label]) => Math.max(tileHeight * 1.25, label.length * tileHeight * 0.31 + tileHeight * 0.42));64+ const available = width - 32;65+ const ratio = Math.min(1, available / (widths.reduce((sum, value) => sum + value, 0) + gap * 2));66+ const scaledWidths = widths.map(value => value * ratio); const scaledHeight = tileHeight * ratio;67+ const total = scaledWidths.reduce((sum, value) => sum + value, 0) + gap * 2;68+ let x = (width - total) / 2; const top = Math.max(26, height * 0.06);69+ const progresses = [];70+ labels.forEach(([label, role], index) => {71+ const duration = reducedMotion ? 0 : 320; const start = index * 80; const progress = duration ? Math.max(0, Math.min(1, (now - start) / duration)) : 1;72+ progresses.push(progress);73+ headerTile(ctx, label, x, top + index * 3, scaledWidths[index], scaledHeight, role, progress); x += scaledWidths[index] + gap;74+ });75+ return { top, height: scaledHeight, bottom: top + scaledHeight, progresses };76+}77+function drawFooter(ctx, width, height) {78+ if (!ctx) return;79+ const items = [['← ↑ → ↓', 'walk'], ['ENTER', 'skip to index'], ['ESC', 'menu']];80+ const size = width < 520 ? 9 : 11; const gap = width < 520 ? 12 : 24;81+ ctx.save?.(); ctx.font = `700 ${size}px Recursive, monospace`;82+ const widths = items.map(([label, verb]) => Math.max(24, (ctx.measureText?.(label).width || label.length * size * 0.6) + 14) + 7 + (ctx.measureText?.(verb).width || verb.length * size * 0.55));83+ const total = widths.reduce((sum, value) => sum + value, 0) + gap * (items.length - 1); let x = Math.max(16, (width - total) / 2); const y = height - (width < 520 ? 48 : 34);84+ items.forEach(([label, verb], index) => { const w = keycap(ctx, label, x, y, { size, minWidth: label.length > 4 ? 42 : 24 }); x += w + 7; ctx.fillStyle = '#8E877C'; ctx.font = `700 ${size}px Recursive, monospace`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(verb, x, y + 11); x += (ctx.measureText?.(verb).width || verb.length * size * 0.55) + (index < items.length - 1 ? gap : 0); }); ctx.restore?.();3785 }3886 3987 export function createTitleScene(options = {}) {4088 const game = createGame(TITLE_LEVEL);89+ const titleEpoch = options.startTime ?? (typeof performance === 'undefined' ? 0 : performance.now());4190 let solvedNotified = false;4291 function settle(event) {4392 if (event.accepted && event.won && !solvedNotified) { solvedNotified = true; options.onSolved?.(event); }@@ -56,6 +105,7 @@ export function createTitleScene(options = {}) {
56105 if (key === 'arrowright' || key === 'd') return move(1, 0);57106 if (key === 'z' || key === 'undo') return undo();58107 if (key === 'r' || key === 'reset' || key === 'restart') return reset();108+ if (key === 'escape' || key === 'menu') { options.onMenu?.(); return { accepted: true, kind: 'menu', won: game.getState().won }; }59109 if (key === 'enter' || key === 'confirm') { options.onSkip?.(); return { accepted: true, kind: 'skip', won: game.getState().won }; }60110 return { accepted: false, kind: 'input', won: game.getState().won };61111 }@@ -63,11 +113,19 @@ export function createTitleScene(options = {}) {
63113 const width = drawOptions.width ?? ctx.canvas?.clientWidth ?? ctx.canvas?.width ?? 800;64114 const height = drawOptions.height ?? ctx.canvas?.clientHeight ?? ctx.canvas?.height ?? 600;65115 const state = game.getState();66- const cell = drawOptions.cell ?? Math.min(84, Math.max(30, Math.floor(Math.min((width - 80) / state.width, (height - 190) / state.height))));116+ const reducedMotion = Boolean(drawOptions.reducedMotion ?? options.reducedMotion);117+ const now = drawOptions.now ?? (typeof performance === 'undefined' ? titleEpoch : performance.now());118+ const elapsed = Math.max(0, now - titleEpoch);119+ const header = drawHeader(ctx, width, height, { now: elapsed, reducedMotion });120+ const cell = drawOptions.cell ?? Math.min(84, Math.max(30, Math.floor(Math.min((width - 64) / state.width, (height - 330) / state.height))));67121 const x = drawOptions.x ?? Math.floor((width - state.width * cell) / 2);68- const y = drawOptions.y ?? Math.floor(height * 0.22);69- drawHeader(ctx, width, height); drawBoard(ctx, state, { glyphs: { MIAU: '🐱', DOOR: '🚪', ...(drawOptions.glyphs || {}) }, ...drawOptions, x, y, cell, game });70- return { state, geometry: { x, y, cell, width: state.width * cell, height: state.height * cell } };122+ const boardHeight = state.height * cell;123+ const boardTop = Math.max((header?.bottom || 130) + 42, 160);124+ const boardBottom = height - (width < 520 ? 100 : 78);125+ const y = drawOptions.y ?? Math.floor(boardTop + Math.max(0, (boardBottom - boardTop - boardHeight) / 2));126+ drawBoard(ctx, state, { glyphs: { MIAU: '🐈', DOOR: '🚪', ...(drawOptions.glyphs || {}) }, ...drawOptions, x, y, cell, game });127+ drawFooter(ctx, width, height);128+ return { state, geometry: { x, y, cell, width: state.width * cell, height: boardHeight }, header: { ...header, footerY: height - (width < 520 ? 48 : 34) } };71129 }72130 return Object.freeze({ id: 'title', game, get state() { return game.getState(); }, move, undo, reset, input, draw, get canUndo() { return game.canUndo; } });73131 }@@ -26,12 +26,20 @@ test('title controller exposes movement, undo, reset, skip, and solved callback'
2626 assert.equal(skipped, 1);2727 });2828 29+test('title footer menu key remains available to the controller', () => {30+ let opened = 0;31+ const scene = createTitleScene({ onMenu: () => { opened++; } });32+ assert.equal(scene.input('escape').kind, 'menu');33+ assert.equal(opened, 1);34+});35+2936 test('title draw renders decorative header and delegates board geometry', () => {3037 const labels = [];3138 const ctx = {3239 canvas: { width: 800, height: 600 },3340 save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {},3441 moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},42+ translate() {}, scale() {},3543 fillText(text) { labels.push(text); }, measureText(text) { return { width: text.length * 8 }; },3644 };3745 const scene = createTitleScene();@@ -39,3 +47,36 @@ test('title draw renders decorative header and delegates board geometry', () =>
3947 assert.equal(result.geometry.cell > 0, true);4048 assert.equal(labels.includes('TRUE'), true);4149 });50+51+test('title uses hard-edged title tiles, keycap footer, and centered mobile board', () => {52+ const labels = [];53+ const ctx = {54+ canvas: { width: 390, height: 844 },55+ save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {},56+ moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {}, translate() {}, scale() {},57+ fillText(value) { labels.push(value); }, measureText(value) { return { width: value.length * 6 }; },58+ };59+ const scene = createTitleScene({ reducedMotion: true });60+ const result = scene.draw(ctx, { width: 390, height: 844, now: 0, reducedMotion: true });61+ assert.ok(labels.includes('MIAU'));62+ assert.ok(labels.includes('IS'));63+ assert.ok(labels.includes('TRUE'));64+ assert.ok(labels.includes('walk'));65+ assert.ok(labels.includes('skip to index'));66+ assert.ok(labels.includes('menu'));67+ assert.ok(labels.includes('🐈'));68+ assert.ok(result.geometry.y > 150);69+ assert.ok(result.geometry.y + result.geometry.height < result.header.footerY - 12);70+});71+72+test('title drop starts from its scene epoch and advances across frames', () => {73+ const ctx = {74+ canvas: { width: 800, height: 600 },75+ save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {},76+ moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {}, translate() {}, scale() {},77+ fillText() {}, measureText(value) { return { width: value.length * 8 }; },78+ };79+ const scene = createTitleScene({ startTime: 1000 });80+ assert.deepEqual(scene.draw(ctx, { now: 1000 }).header.progresses, [0, 0, 0]);81+ assert.ok(scene.draw(ctx, { now: 1500 }).header.progresses.every(progress => progress > 0));82+});