- commit
- 60444516c7994283d1c6df82d431f87f46655f3c
- parent
- 4a2a019ec2cd1b0ebb0a2ca4ef14acca037b6c07
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 68 | ++++++++++++++++++++++++++---- |
| M | tests/ | 42 | +++++++++++++++++++ |
@@ -75,21 +75,56 @@ function drawHeader(ctx, width, height, { now = 0, reducedMotion = false } = {})
7575 return { top, height: scaledHeight, bottom: top + scaledHeight, progresses };7676 }7777 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;78+ if (!ctx) return [];79+ const items = [['← ↑ → ↓', 'walk', 'walk'], ['ENTER', 'skip to index', 'skip'], ['ESC', 'menu', 'menu'], ['SPACE', 'workshop', 'workshop']];80+ const size = width < 520 ? 8 : 11; const gap = width < 520 ? 6 : 18;8181 ctx.save?.(); ctx.font = `700 ${size}px Recursive, monospace`;8282 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- ctx.fillStyle = '#F2EEE6'; ctx.font = `700 ${width < 520 ? 11 : 13}px Recursive, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('walk the cat to the door', width / 2, y - 18);85- 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?.();83+ const total = widths.reduce((sum, value) => sum + value, 0) + gap * (items.length - 1);84+ const rows = total > width - 32 ? [[0, 1], [2, 3]] : [items.map((_, index) => index)];85+ const baseY = height - (width < 520 ? 48 : 34);86+ const rowGap = rows.length > 1 ? 38 : 0;87+ ctx.fillStyle = '#F2EEE6'; ctx.font = `700 ${width < 520 ? 11 : 13}px Recursive, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('walk the cat to the door', width / 2, baseY - 18 - rowGap);88+ const targets = [];89+ rows.forEach((row, rowIndex) => {90+ const rowTotal = row.reduce((sum, itemIndex) => sum + widths[itemIndex], 0) + gap * (row.length - 1);91+ let x = Math.max(16, (width - rowTotal) / 2);92+ const y = baseY - (rows.length - 1 - rowIndex) * rowGap;93+ row.forEach((itemIndex, rowPosition) => {94+ const [label, verb, action] = items[itemIndex];95+ const keyX = x;96+ const w = keycap(ctx, label, keyX, y, { size, minWidth: label.length > 4 ? 42 : 24 });97+ const verbX = keyX + w + 7;98+ const verbWidth = ctx.measureText?.(verb).width || verb.length * size * 0.55;99+ ctx.fillStyle = '#8E877C'; ctx.font = `700 ${size}px Recursive, monospace`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(verb, verbX, y + 11);100+ targets.push({ action, x: keyX, y, width: w + 7 + verbWidth, height: 34 });101+ x = verbX + verbWidth + (rowPosition < row.length - 1 ? gap : 0);102+ });103+ });104+ ctx.restore?.();105+ return targets;86106 }87107 88108 export function createTitleScene(options = {}) {89109 const game = createGame(TITLE_LEVEL);90110 const titleEpoch = options.startTime ?? (typeof performance === 'undefined' ? 0 : performance.now());91111 let solvedNotified = false;112+ let footerLayout = [];113+ // Apple Color Emoji's native cat faces left. Keep the last accepted114+ // horizontal direction separately from the engine so blocked and idle115+ // inputs do not make the title character turn.116+ let miauFacing = 'left';117+ function updateFacing(event) {118+ if (!event?.accepted) return;119+ if (event.kind === 'reset') { miauFacing = 'left'; return; }120+ const movement = event.moved?.find(entry => entry.id === 'title-miau');121+ if (!movement) return;122+ const dx = movement.to.x - movement.from.x;123+ if (dx > 0) miauFacing = 'right';124+ else if (dx < 0) miauFacing = 'left';125+ }92126 function settle(event) {127+ updateFacing(event);93128 if (event.accepted && event.won && !solvedNotified) { solvedNotified = true; options.onSolved?.(event); }94129 return event;95130 }@@ -108,8 +143,17 @@ export function createTitleScene(options = {}) {
108143 if (key === 'r' || key === 'reset' || key === 'restart') return reset();109144 if (key === 'escape' || key === 'menu') { options.onMenu?.(); return { accepted: true, kind: 'menu', won: game.getState().won }; }110145 if (key === 'enter' || key === 'confirm') { options.onSkip?.(); return { accepted: true, kind: 'skip', won: game.getState().won }; }146+ if (key === ' ' || key === 'space' || key === 'spacebar' || key === 'workshop') { options.onWorkshop?.(); return { accepted: true, kind: 'workshop', won: game.getState().won }; }111147 return { accepted: false, kind: 'input', won: game.getState().won };112148 }149+ function hit(pointerX, pointerY) {150+ const target = footerLayout.find(item => pointerX >= item.x && pointerX <= item.x + item.width && pointerY >= item.y && pointerY <= item.y + item.height);151+ if (!target) return { accepted: false, kind: 'hit' };152+ if (target.action === 'workshop') options.onWorkshop?.();153+ else if (target.action === 'skip') options.onSkip?.();154+ else if (target.action === 'menu') options.onMenu?.();155+ return { accepted: true, kind: target.action, won: game.getState().won };156+ }113157 function draw(ctx, drawOptions = {}) {114158 const width = drawOptions.width ?? ctx.canvas?.clientWidth ?? ctx.canvas?.width ?? 800;115159 const height = drawOptions.height ?? ctx.canvas?.clientHeight ?? ctx.canvas?.height ?? 600;@@ -124,11 +168,15 @@ export function createTitleScene(options = {}) {
124168 const boardTop = Math.max((header?.bottom || 130) + 42, 160);125169 const boardBottom = height - (width < 520 ? 100 : 78);126170 const y = drawOptions.y ?? Math.floor(boardTop + Math.max(0, (boardBottom - boardTop - boardHeight) / 2));127- drawBoard(ctx, state, { glyphs: { MIAU: '🐈', DOOR: '🚪', ...(drawOptions.glyphs || {}) }, ...drawOptions, x, y, cell, game });128- drawFooter(ctx, width, height);129- return { state, geometry: { x, y, cell, width: state.width * cell, height: boardHeight }, header: { ...header, footerY: height - (width < 520 ? 48 : 34) } };171+ const framePositions = drawOptions.frame?.positions instanceof Map ? new Map(drawOptions.frame.positions) : new Map();172+ const existingMiau = framePositions.get('title-miau') || {};173+ framePositions.set('title-miau', { ...existingMiau, facing: miauFacing });174+ const frame = { ...(drawOptions.frame || {}), positions: framePositions };175+ drawBoard(ctx, state, { glyphs: { MIAU: '🐈', DOOR: '🚪', ...(drawOptions.glyphs || {}) }, ...drawOptions, frame, x, y, cell, game });176+ footerLayout = drawFooter(ctx, width, height);177+ return { state, geometry: { x, y, cell, width: state.width * cell, height: boardHeight }, header: { ...header, footerY: height - (width < 520 ? 48 : 34) }, footer: footerLayout };130178 }131- return Object.freeze({ id: 'title', game, get state() { return game.getState(); }, move, undo, reset, input, draw, get canUndo() { return game.canUndo; } });179+ return Object.freeze({ id: 'title', game, get state() { return game.getState(); }, get facing() { return miauFacing; }, move, undo, reset, input, hit, draw, get canUndo() { return game.canUndo; } });132180 }133181 134182 export default titleScene;@@ -26,6 +26,48 @@ test('title controller exposes movement, undo, reset, skip, and solved callback'
2626 assert.equal(skipped, 1);2727 });2828 29+test('title MIAU keeps accepted horizontal facing through idle and blocked input', () => {30+ const scene = createTitleScene();31+ assert.equal(scene.facing, 'left');32+ assert.equal(scene.input('right').accepted, true);33+ assert.equal(scene.facing, 'right');34+ assert.equal(scene.input('up').accepted, true);35+ assert.equal(scene.facing, 'right');36+ assert.equal(scene.input('up').accepted, false);37+ assert.equal(scene.facing, 'right');38+ scene.reset();39+ scene.input('right');40+ scene.undo();41+ assert.equal(scene.facing, 'left');42+ scene.input('right');43+ const scales = [];44+ const ctx = {45+ canvas: { width: 800, height: 600 },46+ save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {},47+ moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {}, translate() {}, scale(x, y) { scales.push([x, y]); },48+ fillText() {}, measureText(value) { return { width: value.length * 8 }; },49+ };50+ scene.draw(ctx);51+ assert.ok(scales.some(([x, y]) => x === -1 && y === 1));52+});53+54+test('title exposes Workshop in the footer and through keyboard and pointer input', () => {55+ let opened = 0;56+ const scene = createTitleScene({ onWorkshop: () => { opened++; } });57+ const ctx = {58+ canvas: { width: 390, height: 844 },59+ save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {},60+ moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {}, translate() {}, scale() {},61+ fillText() {}, measureText(value) { return { width: value.length * 6 }; },62+ };63+ const result = scene.draw(ctx, { width: 390, height: 844, reducedMotion: true });64+ assert.equal(scene.input('space').kind, 'workshop');65+ const target = result.footer.find(item => item.action === 'workshop');66+ assert.ok(target);67+ assert.equal(scene.hit(target.x + 2, target.y + 2).kind, 'workshop');68+ assert.equal(opened, 2);69+});70+2971 test('title footer menu key remains available to the controller', () => {3072 let opened = 0;3173 const scene = createTitleScene({ onMenu: () => { opened++; } });