clone

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

Animate in-play keycaps and step counter

Dami committed f2ce04b
commit
f2ce04bd8ac19e4b1f6429359f36060d585a735d
parent
ebb2c834c3b46942dfb3a2053317e0438e73cb8a
author
Dami <dami.etoile@gmail.com>
4 files changed, +205 -21
Asrc/render/motion.js74+++++++++++++++++++++++++++++
Msrc/render/stage-hud.js76++++++++++++++++++++++++++++--
Msrc/render/stage-proof.js33+++++++------
Atests/stage-motion.test.js43+++++++++++++++++
A src/render/motion.js +74 -0
@@ -0,0 +1,74 @@
1+/** Shared, deterministic presentation math for the in-play HUD. */2+export const HUD_MOTION = Object.freeze({3+ keycapPress: 90,4+ stepRoll: 120,5+});6+7+export function easeOutCubic(progress) {8+ const t = Math.max(0, Math.min(1, Number(progress) || 0));9+ return 1 - (1 - t) ** 3;10+}11+12+export function motionProgress({ now = 0, startedAt = 0, duration, reducedMotion = false } = {}) {13+ if (reducedMotion) return 1;14+ if (!Number.isFinite(now) || !Number.isFinite(startedAt) || !Number.isFinite(duration) || duration <= 0)15+ return 1;16+ return Math.max(0, Math.min(1, (now - startedAt) / duration));17+}18+19+export function keycapPressFrame({ now, startedAt, duration = HUD_MOTION.keycapPress, reducedMotion = false } = {}) {20+ const progress = motionProgress({ now, startedAt, duration, reducedMotion });21+ // The press is a short settle onto the lip, then a slightly longer spring back.22+ const press = progress < 0.4523+ ? easeOutCubic(progress / 0.45)24+ : 1 - easeOutCubic((progress - 0.45) / 0.55);25+ return {26+ progress,27+ press: reducedMotion ? 0 : Math.max(0, Math.min(1, press)),28+ offsetY: reducedMotion ? 0 : 4 * Math.max(0, Math.min(1, press)),29+ pressed: !reducedMotion && progress < 1,30+ };31+}32+33+const ACTION_ALIASES = Object.freeze({34+ wait: ['wait', 'space'],35+ undo: ['undo', 'z'],36+ restart: ['restart', 'r'],37+ menu: ['menu', 'esc', 'escape'],38+ 'move-left': ['move-left', 'left', 'arrowleft'],39+ 'move-up': ['move-up', 'up', 'arrowup'],40+ 'move-right': ['move-right', 'right', 'arrowright'],41+ 'move-down': ['move-down', 'down', 'arrowdown'],42+});43+44+function actionToken(value) {45+ if (value && typeof value === 'object') return actionToken(value.action || value.name || value.key);46+ return String(value || '').trim().toLowerCase().replace(/[ _]/g, '-');47+}48+49+export function actionMatches(binding, action) {50+ const left = actionToken(binding);51+ const right = actionToken(action);52+ if (!left || !right) return false;53+ const aliases = ACTION_ALIASES[left] || [left];54+ return aliases.includes(right) || (ACTION_ALIASES[right] || [right]).includes(left);55+}56+57+export function stepRollFrame({ now, startedAt, from = 0, to = from, duration = HUD_MOTION.stepRoll, reducedMotion = false } = {}) {58+ const oldValue = Math.max(0, Number.isFinite(Number(from)) ? Math.trunc(Number(from)) : 0);59+ const newValue = Math.max(0, Number.isFinite(Number(to)) ? Math.trunc(Number(to)) : oldValue);60+ const progress = motionProgress({ now, startedAt, duration, reducedMotion });61+ const direction = newValue >= oldValue ? 1 : -1;62+ const oldDigits = String(oldValue).padStart(2, '0');63+ const newDigits = String(newValue).padStart(2, '0');64+ return {65+ progress,66+ eased: easeOutCubic(progress),67+ direction,68+ oldValue,69+ newValue,70+ oldDigits,71+ newDigits,72+ changed: [...newDigits].map((digit, index) => digit !== oldDigits[index]),73+ };74+}
M src/render/stage-hud.js +70 -6
@@ -1,23 +1,87 @@
1-export function drawKeycap(ctx, key, label, x, y, align = "left") {1+import { actionMatches, keycapPressFrame, stepRollFrame } from './motion.js';2+3+function keycapOptions(align, options) {4+ if (align && typeof align === 'object') return { ...align, align: align.align || 'left' };5+ return { ...(options || {}), align: align || 'left' };6+}7+8+function keycapPress(options, action) {9+ const press = options?.press || options?.keycapPress;10+ if (press && typeof press === 'object' && !actionMatches(action, press.action)) return { press: 0, offsetY: 0 };11+ if (typeof press === 'number') return { press: press, offsetY: press * 4 };12+ if (press && typeof press === 'object') return keycapPressFrame({13+ now: options.now,14+ startedAt: press.startedAt ?? press.start,15+ reducedMotion: options.reducedMotion,16+ });17+ if (options.pressStart !== undefined && actionMatches(action, options.pressAction)) return keycapPressFrame({18+ now: options.now,19+ startedAt: options.pressStart,20+ reducedMotion: options.reducedMotion,21+ });22+ return { press: 0, offsetY: 0 };23+}24+25+export function drawKeycap(ctx, key, label, x, y, align = "left", options = {}) {26+ const settings = keycapOptions(align, options);27+ const action = settings.action || label;28+ const frame = keycapPress(settings, action);229 ctx.save();330 ctx.textBaseline = "middle";431 ctx.font = "800 10px Recursive, monospace";532 const keyWidth = Math.max(24, ctx.measureText(key).width + 12);633 ctx.font = "600 10px Recursive, monospace";734 const labelWidth = ctx.measureText(label).width;8- const left = align === "right" ? x - keyWidth - 8 - labelWidth : x;35+ const left = settings.align === "right" ? x - keyWidth - 8 - labelWidth : x;36+ const pressY = frame.offsetY || 0;937 ctx.fillStyle = "#000000";10- ctx.fillRect(left + 4, y - 6, keyWidth, 20);38+ if (!frame.press) ctx.fillRect(left + 4, y - 6, keyWidth, 20);1139 ctx.fillStyle = "#F2EEE6";12- ctx.fillRect(left, y - 10, keyWidth, 20);40+ ctx.fillRect(left, y - 10 + pressY, keyWidth, 20);1341 ctx.fillStyle = "#0F0E0D";1442 ctx.textAlign = "left";1543 ctx.font = "800 10px Recursive, monospace";16- ctx.fillText(key, left + 6, y + 1);44+ ctx.fillText(key, left + 6, y + 1 + pressY);1745 ctx.font = "600 10px Recursive, monospace";1846 ctx.fillStyle = "#8E877C";19- ctx.fillText(label, left + keyWidth + 8, y + 1);47+ ctx.fillText(label, left + keyWidth + 8, y + 1 + pressY);48+ ctx.restore();49+}50+51+export function drawStepCounter(ctx, value, x, y, options = {}) {52+ const roll = options.roll || null;53+ const frame = roll54+ ? stepRollFrame({ ...roll, now: options.now, reducedMotion: options.reducedMotion })55+ : stepRollFrame({ from: value, to: value, reducedMotion: true });56+ const digits = frame.newDigits;57+ ctx.save();58+ ctx.textAlign = 'right';59+ ctx.textBaseline = 'middle';60+ ctx.font = '800 40px Recursive, monospace';61+ const digitWidth = Math.max(20, ctx.measureText('0').width);62+ const right = x;63+ const top = y - 20;64+ for (let index = 0; index < digits.length; index += 1) {65+ const digitX = right - (digits.length - index - 0.5) * digitWidth;66+ ctx.save();67+ ctx.beginPath();68+ ctx.rect(digitX - digitWidth / 2, top, digitWidth, 42);69+ ctx.clip();70+ if (frame.changed[index] && frame.progress < 1) {71+ const travel = digitWidth * frame.eased;72+ const oldY = y - frame.direction * travel;73+ const newY = y + frame.direction * (digitWidth - travel);74+ ctx.textAlign = 'center';75+ ctx.fillText(frame.oldDigits[index], digitX, oldY);76+ ctx.fillText(digits[index], digitX, newY);77+ } else {78+ ctx.textAlign = 'center';79+ ctx.fillText(digits[index], digitX, y);80+ }81+ ctx.restore();82+ }2083 ctx.restore();84+ return frame;2185 }2286 2387 export function drawInlineActions(ctx, items, centerX, y) {
M src/render/stage-proof.js +18 -15
@@ -4,6 +4,7 @@ import {
44 drawHint,55 drawKeycap,66 drawLedger,7+ drawStepCounter,78 drawTouchControls,89 } from "./stage-hud.js";910
@@ -97,18 +98,13 @@ export function renderProofScene(ctx, state, view = {}) {
9798 78,9899 58,99100 );100- ctx.textAlign = "right";101101 ctx.fillStyle = "#F2EEE6";102- ctx.font = "800 40px Recursive, monospace";103- const stepProgress = view.stepPopStart104- ? Math.min(1, (now - view.stepPopStart) / 220)105- : 1;106- const stepScale = 1 + (stepProgress < 1 ? 0.14 * (1 - stepProgress) : 0);107- ctx.save();108- ctx.translate(width - 32, 39);109- ctx.scale(stepScale, stepScale);110- ctx.fillText(String(state.moves || 0).padStart(2, "0"), 0, 0);111- ctx.restore();102+ drawStepCounter(ctx, state.moves || 0, width - 32, 39, {103+ now,104+ reducedMotion: view.reducedMotion,105+ roll: view.stepRoll,106+ });107+ ctx.textAlign = "right";112108 ctx.font = "600 10px Recursive, monospace";113109 ctx.fillStyle = "#8E877C";114110 ctx.fillText("STEPS", width - 32, 70);
@@ -150,11 +146,18 @@ export function renderProofScene(ctx, state, view = {}) {
150146 const coarsePointer = view.coarsePointer ?? false;151147 if (width > 760 && !coarsePointer) {152148 const proofKeyY = height - 26;149+ const motion = {150+ now,151+ reducedMotion: view.reducedMotion,152+ press: view.keycapPress,153+ pressAction: view.keycapPressAction,154+ pressStart: view.keycapPressStart,155+ };153156 if (automata.some((system) => system.active))154- drawKeycap(ctx, "SPACE", "wait", width - 400, proofKeyY);155- drawKeycap(ctx, "Z", "undo", width - 290, proofKeyY);156- drawKeycap(ctx, "R", "restart", width - 185, proofKeyY);157- drawKeycap(ctx, "ESC", "menu", width - 32, proofKeyY, "right");157+ drawKeycap(ctx, "SPACE", "wait", width - 400, proofKeyY, "left", { ...motion, action: "wait" });158+ drawKeycap(ctx, "Z", "undo", width - 290, proofKeyY, "left", { ...motion, action: "undo" });159+ drawKeycap(ctx, "R", "restart", width - 185, proofKeyY, "left", { ...motion, action: "restart" });160+ drawKeycap(ctx, "ESC", "menu", width - 32, proofKeyY, "right", { ...motion, action: "menu" });158161 }159162 drawTouchControls(160163 ctx,
A tests/stage-motion.test.js +43 -0
@@ -0,0 +1,43 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import {4+ HUD_MOTION,5+ actionMatches,6+ keycapPressFrame,7+ motionProgress,8+ stepRollFrame,9+} from '../src/render/motion.js';10+11+test('HUD motion uses settled end states and bounded press phases', () => {12+ assert.equal(motionProgress({ now: 50, startedAt: 0, duration: 100 }), 0.5);13+ assert.equal(motionProgress({ now: 50, startedAt: 0, duration: 100, reducedMotion: true }), 1);14+ const start = keycapPressFrame({ now: 0, startedAt: 0 });15+ const middle = keycapPressFrame({ now: HUD_MOTION.keycapPress * 0.45, startedAt: 0 });16+ const end = keycapPressFrame({ now: HUD_MOTION.keycapPress, startedAt: 0 });17+ assert.equal(start.offsetY, 0);18+ assert.equal(middle.pressed, true);19+ assert.ok(middle.offsetY > 0);20+ assert.equal(end.pressed, false);21+ assert.equal(end.offsetY, 0);22+});23+24+test('keycap actions match keyboard and semantic input names', () => {25+ assert.equal(actionMatches('undo', 'z'), true);26+ assert.equal(actionMatches('move-right', 'ArrowRight'), true);27+ assert.equal(actionMatches('menu', 'escape'), true);28+ assert.equal(actionMatches('wait', 'restart'), false);29+});30+31+test('step digits roll up on forward turns and down on undo', () => {32+ const forward = stepRollFrame({ now: 1060, startedAt: 1000, from: 7, to: 8 });33+ assert.equal(forward.direction, 1);34+ assert.equal(forward.changed[1], true);35+ assert.ok(forward.eased > 0);36+ assert.equal(forward.newDigits, '08');37+ const undo = stepRollFrame({ now: 1060, startedAt: 1000, from: 8, to: 7 });38+ assert.equal(undo.direction, -1);39+ assert.equal(undo.oldDigits, '08');40+ assert.equal(undo.newDigits, '07');41+ const reduced = stepRollFrame({ now: 0, startedAt: 1000, from: 8, to: 7, reducedMotion: true });42+ assert.equal(reduced.progress, 1);43+});