- commit
- 128202fed9d69912e4c0beac564c1389e334b3af
- parent
- 5865bc4931625741e81ed6ea6351b1d2a03fb990
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 6 | +++++- |
| M | src/ | 17 | +++++++++++------ |
| M | src/ | 5 | +++++ |
| M | src/ | 8 | +++++++- |
| M | src/ | 13 | +++++++++---- |
| M | src/ | 16 | +++++++++++----- |
| M | src/ | 12 | ++++++++---- |
| M | tests/ | 5 | +++++ |
@@ -269,7 +269,9 @@ document.querySelectorAll('[data-dir]').forEach(button=>button.addEventListener(
269269 document.querySelector('#prev-level').addEventListener('click',()=>navigateWithinPack(-1)); document.querySelector('#next-level').addEventListener('click',()=>navigateWithinPack(1));270270 function toggleFullscreen(){if(!document.fullscreenElement) document.documentElement.requestFullscreen?.(); else document.exitFullscreen?.()}271271 document.querySelector('#fullscreen-btn').addEventListener('click',toggleFullscreen);272-undoButton.addEventListener('click', undo); restartButton.addEventListener('click', restart); continueButton.addEventListener('click', () => { const next = nextLevelInPack(); if (next !== undefined && loadLevel(next)) { stage.show('proof'); scheduleInitialWin(); } });272+undoButton.addEventListener('click', () => { stage.noteInput?.({ type: 'undo', source: 'touch' }); undo(); });273+restartButton.addEventListener('click', () => { stage.noteInput?.({ type: 'restart', source: 'touch' }); restart(); });274+continueButton.addEventListener('click', () => { stage.noteInput?.({ type: 'confirm', source: 'touch' }); const next = nextLevelInPack(); if (next !== undefined && loadLevel(next)) { stage.show('proof'); scheduleInitialWin(); } });273275 soundButton.addEventListener('click', () => { soundOn = !soundOn; audio.mute(!soundOn); soundButton.textContent = soundOn ? '◖' : '○'; soundButton.setAttribute('aria-label', soundOn ? 'Mute sound' : 'Enable sound'); stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()}); });274276 stage.setActions({ onWaitAction: waitTurn, onPauseAction: action => { if(action==='restart') restart(); if(action==='sound') soundButton.click(); if(action==='music'){audio.setMusicEnabled(!audio.isMusicEnabled());stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});} } });275277 stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});@@ -288,6 +290,7 @@ document.addEventListener('keydown', event => {
288290 if (event.target?.matches?.('input,textarea,select,[contenteditable]')) return;289291 const key = event.key.toLowerCase();290292 if (stage.scene === 'gift' && (key === 'i' || key === 'c')) {293+ stage.noteInput?.({ type: key === 'i' ? 'details' : 'copy', source: 'keyboard' });291294 const result = giftController.input(key);292295 if (result.ok) {293296 event.preventDefault();@@ -295,6 +298,7 @@ document.addEventListener('keydown', event => {
295298 stage.render();296299 }297300 } else if (stage.scene === 'workshop' && key === 'c' && workshopController.view().note) {301+ stage.noteInput?.({ type: 'copy', source: 'keyboard' });298302 event.preventDefault();299303 Promise.resolve(workshopController.handleInput('copy')).finally(() => stage.render());300304 }@@ -1,4 +1,5 @@
11 import { nativeHorizontalFacing } from './facing.js';2+import { actionMatches, keycapPressFrame } from './motion.js';23 34 /**45 * Pure canvas renderer for the INDEX map.@@ -210,9 +211,9 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
210211 // keycaps. Pointer targets are expanded separately below.211212 const keyHeight = INDEX_KEYCAP_HEIGHT;212213 const groups = [213- { key: 'ENTER', label: 'play', width: INDEX_KEYCAPS.ENTER },214- ...(canvasWidth >= 340 ? [{ key: 'ESC', label: 'menu', width: INDEX_KEYCAPS.ESC }] : []),215- { key: 'SPACE', label: 'workshop', width: INDEX_KEYCAPS.SPACE },214+ { key: 'ENTER', label: 'play', action: 'confirm', width: INDEX_KEYCAPS.ENTER },215+ ...(canvasWidth >= 340 ? [{ key: 'ESC', label: 'menu', action: 'menu', width: INDEX_KEYCAPS.ESC }] : []),216+ { key: 'SPACE', label: 'workshop', action: 'workshop', width: INDEX_KEYCAPS.SPACE },216217 ];217218 const groupGap = Math.max(8, cell * 0.12);218219 const labelGap = Math.max(5, cell * 0.08);@@ -228,6 +229,10 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
228229 let keyX = keyStart;229230 for (const group of groups) {230231 const keycapY = footerY - keyHeight / 2;232+ const press = options.keycapPress && actionMatches(group.action, options.keycapPress.action)233+ ? keycapPressFrame({ now: options.now, startedAt: options.keycapPress.startedAt, reducedMotion: options.reducedMotion })234+ : { offsetY: 0, press: 0 };235+ const offsetY = press.offsetY || 0;231236 const labelWidth = labelWidthFor(group);232237 if (group.label === 'workshop') geometry.footerActions.workshop = {233238 x: keyX,@@ -239,11 +244,11 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
239244 // stage owns pointer dispatch; this geometry only exposes its target.240245 ctx.fillStyle = colors.lip;241246 roundRect(ctx, keyX + 3, keycapY + 3, group.width, keyHeight, keyHeight * 0.18); ctx.fill();242- roundRect(ctx, keyX, keycapY, group.width, keyHeight, keyHeight * 0.18);247+ roundRect(ctx, keyX, keycapY + offsetY, group.width, keyHeight, keyHeight * 0.18);243248 ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();244249 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;245- ctx.font = fitFont(ctx, group.key, group.width * 0.88, Math.max(10, cell * 0.11), 800); ctx.fillText(group.key, keyX + group.width / 2, keycapY + keyHeight / 2);246- ctx.textAlign = 'left'; ctx.font = fitFont(ctx, group.label, labelWidth, Math.max(10, cell * 0.11), 700); ctx.fillText(group.label, keyX + group.width + labelGap, keycapY + keyHeight / 2);250+ ctx.font = fitFont(ctx, group.key, group.width * 0.88, Math.max(10, cell * 0.11), 800); ctx.fillText(group.key, keyX + group.width / 2, keycapY + keyHeight / 2 + offsetY);251+ ctx.textAlign = 'left'; ctx.font = fitFont(ctx, group.label, labelWidth, Math.max(10, cell * 0.11), 700); ctx.fillText(group.label, keyX + group.width + labelGap, keycapY + keyHeight / 2 + offsetY);247252 keyX += group.width + labelGap + labelWidth + groupGap;248253 }249254 }@@ -35,6 +35,11 @@ const ACTION_ALIASES = Object.freeze({
3535 undo: ['undo', 'z'],3636 restart: ['restart', 'r'],3737 menu: ['menu', 'esc', 'escape'],38+ confirm: ['confirm', 'enter', 'select'],39+ workshop: ['workshop', 'space'],40+ details: ['details', 'i'],41+ copy: ['copy', 'c'],42+ navigate: ['navigate', 'up', 'down', 'left', 'right', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'move-up', 'move-down', 'move-left', 'move-right'],3843 'move-left': ['move-left', 'left', 'arrowleft'],3944 'move-up': ['move-up', 'up', 'arrowup'],4045 'move-right': ['move-right', 'right', 'arrowright'],@@ -2,6 +2,7 @@ import { drawBoard } from '../render/board.js';
22 import { createGame } from '../engine.js';33 import { cloneData } from '../data/clone.js';44 import { warningLines } from './warning-lines.js';5+import { actionMatches, keycapPressFrame } from '../render/motion.js';56 67 const FONT = 'Recursive, ui-monospace, monospace';78 const COLORS = Object.freeze({@@ -236,7 +237,12 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
236237 const targetWidth = Math.max(44, keyWidth + 16);237238 hintButtons.push({ action: hintAction, x: hintX - (targetWidth - keyWidth) / 2, y: hintY - 12, width: targetWidth, height: 44 });238239 }239- ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, hintY, keyWidth, rowHeight, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, hintY, keyWidth, rowHeight, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, hintY + rowHeight / 2, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, hintY + rowHeight / 2, { size: labelSize, weight: 700, color: COLORS.muted }); ctx.restore(); hintX += itemWidth + hintGap;240+ const action = key === 'I' ? 'details' : key === 'C' ? 'copy' : key === 'ENTER' ? 'confirm' : key === 'ESC' ? 'menu' : 'navigate';241+ const press = options.keycapPress && actionMatches(action, options.keycapPress.action)242+ ? keycapPressFrame({ now, startedAt: options.keycapPress.startedAt, reducedMotion: options.reducedMotion })243+ : { offsetY: 0 };244+ const offsetY = press.offsetY || 0;245+ ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, hintY + offsetY, keyWidth, rowHeight, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, hintY + offsetY, keyWidth, rowHeight, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, hintY + rowHeight / 2 + offsetY, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, hintY + rowHeight / 2 + offsetY, { size: labelSize, weight: 700, color: COLORS.muted }); ctx.restore(); hintX += itemWidth + hintGap;240246 });241247 });242248 return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, touchLayout, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), noteActions: noteButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };@@ -1,6 +1,7 @@
11 import { createGame } from '../engine.js';22 import { drawBoard } from '../render/board.js';33 import { nativeHorizontalFacing } from '../render/facing.js';4+import { actionMatches, keycapPressFrame } from '../render/motion.js';45 56 /**67 * Playable title scene.@@ -38,9 +39,13 @@ function keycap(ctx, label, x, y, options = {}) {
3839 ctx.save?.(); ctx.font = `800 ${size}px Recursive, monospace`;3940 const width = Math.max(options.minWidth || 24, (ctx.measureText?.(label).width || label.length * size * 0.6) + 14);4041 const height = options.height || 22;42+ const press = options.press && actionMatches(options.action || label, options.press.action)43+ ? keycapPressFrame({ now: options.now, startedAt: options.press.startedAt, reducedMotion: options.reducedMotion })44+ : { offsetY: 0, press: 0 };45+ const offsetY = press.offsetY || 0;4146 ctx.fillStyle = '#000'; ctx.fillRect?.(x + 3, y + 3, width, height);4247 rr(ctx, x, y, width, height, 4); ctx.fillStyle = '#232120'; ctx.fill(); ctx.strokeStyle = '#4A4540'; ctx.lineWidth = 1; ctx.stroke();43- ctx.fillStyle = '#F2EEE6'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, x + width / 2, y + height / 2 + 1); ctx.restore?.();48+ ctx.fillStyle = '#F2EEE6'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, x + width / 2, y + height / 2 + 1 + offsetY); ctx.restore?.();4449 return width;4550 }4651 function headerTile(ctx, label, x, y, width, height, role, progress) {@@ -75,7 +80,7 @@ function drawHeader(ctx, width, height, { now = 0, reducedMotion = false } = {})
7580 });7681 return { top, height: scaledHeight, bottom: top + scaledHeight, progresses };7782 }78-function drawFooter(ctx, width, height) {83+function drawFooter(ctx, width, height, options = {}) {7984 if (!ctx) return [];8085 const items = [['← ↑ → ↓', 'walk', 'walk'], ['ENTER', 'skip to index', 'skip'], ['ESC', 'menu', 'menu'], ['SPACE', 'workshop', 'workshop']];8186 const size = width < 520 ? 8 : 11; const gap = width < 520 ? 6 : 18;@@ -94,7 +99,7 @@ function drawFooter(ctx, width, height) {
9499 row.forEach((itemIndex, rowPosition) => {95100 const [label, verb, action] = items[itemIndex];96101 const keyX = x;97- const w = keycap(ctx, label, keyX, y, { size, minWidth: label.length > 4 ? 42 : 24 });102+ const w = keycap(ctx, label, keyX, y, { size, minWidth: label.length > 4 ? 42 : 24, action: action === 'walk' ? 'navigate' : action, ...options });98103 const verbX = keyX + w + 7;99104 const verbWidth = ctx.measureText?.(verb).width || verb.length * size * 0.55;100105 ctx.fillStyle = '#8E877C'; ctx.font = `700 ${size}px Recursive, monospace`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(verb, verbX, y + 11);@@ -192,7 +197,7 @@ export function createTitleScene(options = {}) {
192197 framePositions.set('title-miau', { ...existingMiau, facing: miauFacing, glyphScaleX: flipScale });193198 const frame = { ...(drawOptions.frame || {}), positions: framePositions };194199 drawBoard(ctx, state, { glyphs: { MIAU: '🐈', DOOR: '🚪', ...(drawOptions.glyphs || {}) }, ...drawOptions, frame, x, y, cell, game });195- footerLayout = drawFooter(ctx, width, height);200+ footerLayout = drawFooter(ctx, width, height, drawOptions);196201 return { state, geometry: { x, y, cell, width: state.width * cell, height: boardHeight }, header: { ...header, footerY: height - (width < 520 ? 48 : 34) }, footer: footerLayout };197202 }198203 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; } });@@ -1,5 +1,6 @@
11 import { cloneData } from '../data/clone.js';22 import { warningLines } from './warning-lines.js';3+import { actionMatches, keycapPressFrame } from '../render/motion.js';34 45 /**56 * Canvas Workshop scene.@@ -42,14 +43,19 @@ function wrapText(ctx, value, width, size, weight = 700) {
4243 if (line) lines.push(line);4344 return lines.length ? lines : [''];4445 }45-function drawKeyHint(ctx, key, verb, right, y, colors) {46+function drawKeyHint(ctx, key, verb, right, y, colors, options = {}) {4647 ctx.font = `800 10px ${FONT}`;4748 const verbWidth = ctx.measureText(verb).width;4849 const keyWidth = Math.max(28, ctx.measureText(key).width + 12);4950 const keyX = right - verbWidth - 8 - keyWidth;50- roundRect(ctx, keyX, y - 11, keyWidth, 22, 3); ctx.fillStyle = colors.raised; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();51- drawText(ctx, key, keyX + keyWidth / 2, y, 10, colors.fg, 'center', 800);52- drawText(ctx, verb, keyX + keyWidth + 8, y, 10, colors.muted, 'left', 650);51+ const action = verb === 'back' ? 'menu' : verb === 'select' ? 'confirm' : 'navigate';52+ const press = options.keycapPress && actionMatches(action, options.keycapPress.action)53+ ? keycapPressFrame({ now: options.now, startedAt: options.keycapPress.startedAt, reducedMotion: options.reducedMotion })54+ : { offsetY: 0 };55+ const offsetY = press.offsetY || 0;56+ roundRect(ctx, keyX, y - 11 + offsetY, keyWidth, 22, 3); ctx.fillStyle = colors.raised; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();57+ drawText(ctx, key, keyX + keyWidth / 2, y + offsetY, 10, colors.fg, 'center', 800);58+ drawText(ctx, verb, keyX + keyWidth + 8, y + offsetY, 10, colors.muted, 'left', 650);5359 return keyX - 18;5460 }5561 @@ -250,7 +256,7 @@ export function createWorkshopScene(options = {}) {
250256 drawText(ctx, "Packs of proofs you've added", margin, 86, 28, colors.fg, 'left', 850);251257 if (!noKeycaps) {252258 let hintX = width - margin;253- for (const [key, verb] of [['ESC', 'back'], ['ENTER', 'select'], ['←→', 'action'], ['↑↓', 'choose']]) hintX = drawKeyHint(ctx, key, verb, hintX, 48, colors);259+ for (const [key, verb] of [['ESC', 'back'], ['ENTER', 'select'], ['←→', 'action'], ['↑↓', 'choose']]) hintX = drawKeyHint(ctx, key, verb, hintX, 48, colors, dimensions);254260 }255261 }256262 const top = compact ? 204 : 130;@@ -275,6 +275,7 @@ export function createStage(options = {}) {
275275 height: h,276276 now: visualNow(),277277 reducedMotion,278+ keycapPress,278279 });279280 const cat = drawn?.state?.objects?.find(280281 (object) => object.noun === "MIAU",@@ -287,13 +288,13 @@ export function createStage(options = {}) {
287288 return;288289 }289290 if (scene === "gift" && gift) {290- gift.draw(ctx, visualNow(), { width: w, height: h });291+ gift.draw(ctx, visualNow(), { width: w, height: h, keycapPress, reducedMotion });291292 canvas.setAttribute("aria-label", gift.accessibilityText?.() || "Miau Is True Gift");292293 drawCopyConfirmation(ctx, w, h);293294 return;294295 }295296 if (scene === "workshop" && workshop) {296- workshop.draw(ctx, { width: w, height: h });297+ workshop.draw(ctx, { width: w, height: h, keycapPress, now: visualNow(), reducedMotion });297298 canvas.setAttribute("aria-label", workshop.accessibilityText?.() || "Miau Is True Workshop");298299 drawCopyConfirmation(ctx, w, h);299300 return;@@ -327,6 +328,7 @@ export function createStage(options = {}) {
327328 : 1,328329 reducedMotion,329330 lockedNode: indexLockedNode,331+ now: visualNow(),330332 });331333 const active = indexGeometry.nodes?.find((node) => node.active);332334 if (active) catCenter = { x: active.centerX, y: active.centerY };@@ -346,6 +348,7 @@ export function createStage(options = {}) {
346348 pressedAt,347349 },348350 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,351+ keycapPress,349352 onIndexGeometry: (geometry) => { indexGeometry = geometry; },350353 });351354 } else if (scene === "win") {@@ -353,6 +356,7 @@ export function createStage(options = {}) {
353356 width: w, height: h, now: visualNow(), boardState, boardGame,354357 boardPalette, boardGlyphs, boardFrame, reducedMotion, ...winFlow.view,355358 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,359+ keycapPress,356360 });357361 } else {358362 const spec = SCENES[scene] || proofScene;@@ -475,9 +479,8 @@ export function createStage(options = {}) {
475479 render();476480 }477481 function noteInput(intent = {}) {478- if (scene !== 'proof') return;479482 const action = intent.type === 'move' ? `move-${intent.dir}` : intent.type;480- if (!['undo', 'restart', 'menu', 'wait', 'move-left', 'move-right', 'move-up', 'move-down'].includes(action)) return;483+ if (!['undo', 'restart', 'menu', 'wait', 'confirm', 'workshop', 'details', 'copy', 'move-left', 'move-right', 'move-up', 'move-down'].includes(action)) return;481484 keycapPress = { action, startedAt: visualNow() };482485 render();483486 if (!reducedMotion) visualFrame(animateVisuals);@@ -646,6 +649,7 @@ export function createStage(options = {}) {
646649 render,647650 setSelected,648651 activatePauseSelection,652+ noteInput,649653 onWaitAction: options.onWaitAction,650654 onSelectLevel: options.onSelectLevel,651655 });@@ -26,6 +26,11 @@ test('keycap actions match keyboard and semantic input names', () => {
2626 assert.equal(actionMatches('move-right', 'ArrowRight'), true);2727 assert.equal(actionMatches('menu', 'escape'), true);2828 assert.equal(actionMatches('wait', 'restart'), false);29+ assert.equal(actionMatches('navigate', 'ArrowDown'), true);30+ assert.equal(actionMatches('confirm', 'Enter'), true);31+ assert.equal(actionMatches('workshop', 'Space'), true);32+ assert.equal(actionMatches('details', 'i'), true);33+ assert.equal(actionMatches('copy', 'c'), true);2934 });3035 3136 test('step digits roll up on forward turns and down on undo', () => {