clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

Animate keycap feedback across stage scenes

Dami committed 128202f
commit
128202fed9d69912e4c0beac564c1389e334b3af
parent
5865bc4931625741e81ed6ea6351b1d2a03fb990
author
Dami <dami.etoile@gmail.com>
8 files changed, +61 -21
Msrc/main.js6+++++-
Msrc/render/index.js17+++++++++++------
Msrc/render/motion.js5+++++
Msrc/scenes/gift.js8+++++++-
Msrc/scenes/title.js13+++++++++----
Msrc/scenes/workshop.js16+++++++++++-----
Msrc/stage.js12++++++++----
Mtests/stage-motion.test.js5+++++
M src/main.js +5 -1
@@ -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 }
M src/render/index.js +11 -6
@@ -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 }
M src/render/motion.js +5 -0
M src/scenes/gift.js +7 -1
@@ -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 };
M src/scenes/title.js +9 -4
@@ -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; } });
M src/scenes/workshop.js +11 -5
@@ -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;
M src/stage.js +8 -4
@@ -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 });
M tests/stage-motion.test.js +5 -0