clone

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

Extract D22 win card renderer and timeline

Dami committed 0a66e0a
commit
0a66e0ad2a39035138b71ded8451977c40cb43b4
parent
75723077280e0a45df4e29bf4708b2d691db4ad3
author
Dami <dami.etoile@gmail.com>
2 files changed, +311 -0
Asrc/render/stage-win.js267++++++++++++++++++++++++++++++
Atests/stage-win.test.js44+++++
A src/render/stage-win.js +267 -0
@@ -0,0 +1,267 @@
1+import { drawBoard } from './board.js';2+import {3+ drawInlineActions,4+ drawSentenceTiles,5+ drawStackedAction,6+ drawTouchControls,7+} from './stage-hud.js';8+9+/**10+ * D22's win-card clock. `winStart` is the beginning of the win beat, before11+ * the winning-cell ring. The card enters at `cardDelay`; the remaining beats12+ * are relative to the card's entrance, which matches the design prototype.13+ */14+export const WIN_TIMING = Object.freeze({15+ ring: 320,16+ cardDelay: 260,17+ slam: 260,18+ stamps: Object.freeze([170, 240, 310]),19+ stamp: 110,20+ bar: 420,21+ barDuration: 200,22+ count: 480,23+ countDuration: 360,24+ actions: 800,25+ exit: 170,26+});27+28+function clamp(value, min = 0, max = 1) {29+ return Math.max(min, Math.min(max, value));30+}31+32+function easeOut(value) {33+ const t = clamp(value);34+ return 1 - (1 - t) ** 3;35+}36+37+function easeIn(value) {38+ return clamp(value) ** 3;39+}40+41+function easeBack(value) {42+ const t = clamp(value);43+ const back = 1.70158;44+ return 1 + (back + 1) * (t - 1) ** 3 + back * (t - 1) ** 2;45+}46+47+function phase(now, start, duration) {48+ if (!Number.isFinite(start) || !Number.isFinite(now)) return 1;49+ return clamp((now - start) / Math.max(1, duration));50+}51+52+function settledFrame(tileCount) {53+ return {54+ elapsed: Number.POSITIVE_INFINITY,55+ ringProgress: 1,56+ cardProgress: 1,57+ cardScale: 1,58+ cardOffset: 0,59+ tileProgress: Array.from({ length: tileCount }, () => 1),60+ barProgress: 1,61+ countProgress: 1,62+ actionsProgress: 1,63+ exitProgress: 0,64+ settled: true,65+ };66+}67+68+/**69+ * Derive all visible win phases from one virtual presentation timestamp.70+ * `winSkip` may be a boolean (settle now) or a timestamp at which the first71+ * Enter was pressed; both forms make integration from keyboard/touch simple.72+ */73+export function winMotionFrame(elapsedOrOptions = {}, options = {}) {74+ const input = typeof elapsedOrOptions === 'number'75+ ? { ...options, elapsed: elapsedOrOptions }76+ : elapsedOrOptions;77+ const {78+ now = 0,79+ winStart = 0,80+ elapsed,81+ winSkip = false,82+ skip = false,83+ winExitStart = 0,84+ exitElapsed,85+ reducedMotion = false,86+ tileCount = 3,87+ } = input || {};88+ const count = Math.max(0, Number.isInteger(tileCount) ? tileCount : 3);89+ if (reducedMotion || winSkip === true || skip === true) return settledFrame(count);90+ const started = Number.isFinite(winSkip) && winSkip > winStart ? winSkip : winStart;91+ const virtualElapsed = Number.isFinite(elapsed)92+ ? elapsed93+ : Number.isFinite(now) && Number.isFinite(started)94+ ? now - started95+ : 0;96+ const frameNow = started + virtualElapsed;97+ const cardStart = started + WIN_TIMING.cardDelay;98+ const frame = {99+ elapsed: virtualElapsed,100+ ringProgress: phase(frameNow, started, WIN_TIMING.ring),101+ cardProgress: phase(frameNow, cardStart, WIN_TIMING.slam),102+ cardScale: 1.05 - 0.05 * easeOut(phase(frameNow, cardStart, WIN_TIMING.slam)),103+ cardOffset: 0,104+ tileProgress: Array.from({ length: count }, (_, index) =>105+ phase(frameNow, cardStart + (WIN_TIMING.stamps[index] ?? WIN_TIMING.stamps.at(-1) + (index - 2) * 70), WIN_TIMING.stamp),106+ ),107+ barProgress: phase(frameNow, cardStart + WIN_TIMING.bar, WIN_TIMING.barDuration),108+ countProgress: phase(frameNow, cardStart + WIN_TIMING.count, WIN_TIMING.countDuration),109+ actionsProgress: phase(frameNow, cardStart + WIN_TIMING.actions, 200),110+ exitProgress: 0,111+ settled: false,112+ };113+ if (Number.isFinite(exitElapsed)) {114+ frame.exitProgress = phase(exitElapsed, 0, WIN_TIMING.exit);115+ } else if (winExitStart && Number.isFinite(winExitStart)) {116+ frame.exitProgress = phase(frameNow, winExitStart, WIN_TIMING.exit);117+ frame.cardOffset = 48 * easeIn(frame.exitProgress);118+ }119+ if (frame.exitProgress >= 1) frame.settled = true;120+ return frame;121+}122+123+function winningCell(boardState, boardGame, requested) {124+ if (requested && Number.isFinite(requested.x) && Number.isFinite(requested.y)) return requested;125+ const objects = Array.isArray(boardState?.objects) ? boardState.objects : [];126+ const properties = object => {127+ if (typeof boardGame?.properties === 'function') return new Set(boardGame.properties(object));128+ return new Set((boardState?.rules || []).filter(rule => rule.subject === object.noun).map(rule => rule.predicate));129+ };130+ const byCell = new Map();131+ for (const object of objects) {132+ if (!Number.isFinite(object.x) || !Number.isFinite(object.y)) continue;133+ const key = `${object.x},${object.y}`;134+ const cell = byCell.get(key) || { x: object.x, y: object.y, objects: [] };135+ cell.objects.push(object);136+ byCell.set(key, cell);137+ }138+ for (const cell of byCell.values()) {139+ const traits = cell.objects.map(properties);140+ if (traits.some(set => set.has('YOU')) && traits.some(set => set.has('WIN'))) return cell;141+ }142+ return null;143+}144+145+function sentenceFor(boardState, winningRule) {146+ if (winningRule) return String(winningRule.text || winningRule);147+ return boardState?.rules?.find(rule => String(rule.predicate).toUpperCase() === 'WIN')?.text || 'MIAU IS WIN';148+}149+150+function drawRing(ctx, geometry, cell, progress) {151+ if (!cell || progress >= 1) return;152+ const q = easeOut(progress);153+ const cx = geometry.x + (cell.x + 0.5) * geometry.cell;154+ const cy = geometry.y + (cell.y + 0.5) * geometry.cell;155+ ctx.save();156+ ctx.globalAlpha = 1 - progress;157+ ctx.strokeStyle = '#2EE6A8';158+ ctx.lineWidth = Math.max(1, 5 * (1 - progress) + 1);159+ ctx.beginPath();160+ ctx.arc(cx, cy, geometry.cell * (0.5 + q * 1.4), 0, Math.PI * 2);161+ ctx.stroke();162+ ctx.restore();163+}164+165+function drawCard(ctx, view, frame, winningRule) {166+ const { width: w, height: h } = view;167+ const cardWidth = Math.min(620, w - 48);168+ const cardX = (w - cardWidth) / 2;169+ const cardHeight = 290;170+ const baseY = Math.max(120, h * 0.25);171+ const slam = easeBack(frame.cardProgress);172+ const cardY = baseY + (1 - slam) * -56 + frame.cardOffset;173+ const opacity = 1 - frame.exitProgress;174+ ctx.save();175+ ctx.globalAlpha = opacity * Math.min(1, frame.cardProgress * 3);176+ ctx.translate(w / 2, cardY + cardHeight / 2);177+ ctx.scale(frame.cardScale, frame.cardScale);178+ ctx.translate(-w / 2, -(cardY + cardHeight / 2));179+ ctx.fillStyle = '#191816';180+ ctx.fillRect(cardX + 10, cardY + 10, cardWidth, cardHeight);181+ ctx.fillStyle = '#0F0E0D';182+ ctx.fillRect(cardX, cardY, cardWidth, cardHeight);183+ ctx.strokeStyle = '#2EE6A8';184+ ctx.lineWidth = 2;185+ ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);186+187+ const sentence = sentenceFor(view.boardState, winningRule);188+ const words = sentence.split(/\s+/).filter(Boolean);189+ const sentenceSize = Math.min(22, Math.max(14, cardWidth * 0.035));190+ ctx.font = `800 ${sentenceSize}px Recursive, monospace`;191+ const sentenceWidth = words.reduce((total, word) => total + Math.max(sentenceSize * 1.6, ctx.measureText(word.toUpperCase()).width + sentenceSize), 0) + Math.max(0, words.length - 1) * sentenceSize * 0.4;192+ const sentenceX = cardX + Math.max(32, (cardWidth - sentenceWidth) / 2);193+ const tileY = cardY + 52;194+ let cursor = sentenceX;195+ words.forEach((word, index) => {196+ const progress = frame.tileProgress[index] ?? frame.tileProgress.at(-1) ?? 1;197+ if (progress <= 0) return;198+ const tileWidth = Math.max(sentenceSize * 1.6, ctx.measureText(word.toUpperCase()).width + sentenceSize);199+ const tileScale = 1.4 - 0.4 * easeOut(progress);200+ ctx.save();201+ ctx.translate(cursor + tileWidth / 2, tileY + sentenceSize * 0.78);202+ ctx.scale(tileScale, tileScale);203+ ctx.translate(-(cursor + tileWidth / 2), -(tileY + sentenceSize * 0.78));204+ drawSentenceTiles(ctx, word, cursor, tileY, cardWidth - 64, sentenceSize, Math.min(1, progress * 3));205+ ctx.restore();206+ cursor += tileWidth + sentenceSize * 0.4;207+ });208+ ctx.fillStyle = '#2EE6A8';209+ ctx.fillRect(cardX + 32, cardY + 100, (cardWidth - 64) * easeOut(frame.barProgress), 3);210+211+ ctx.textAlign = 'center';212+ ctx.textBaseline = 'middle';213+ ctx.fillStyle = '#F2EEE6';214+ const steps = Number(view.boardState?.moves || view.steps || 0);215+ const shown = Math.round(steps * easeOut(frame.countProgress));216+ const headline = `PROVEN IN ${shown} STEPS`;217+ let headlineSize = Math.min(44, Math.max(26, w * 0.055));218+ do {219+ ctx.font = `800 ${headlineSize}px Recursive, monospace`;220+ if (ctx.measureText(headline).width <= cardWidth - 64 || headlineSize <= 22) break;221+ headlineSize -= 2;222+ } while (headlineSize > 22);223+ ctx.globalAlpha *= frame.countProgress;224+ ctx.fillText(headline, w / 2, cardY + 154);225+ ctx.globalAlpha *= frame.actionsProgress;226+ ctx.fillStyle = '#8E877C';227+ ctx.font = '600 16px Recursive, monospace';228+ ctx.fillText(`${view.boardState?.completedCount ?? 0} of ${view.boardState?.proofTotal ?? 9} proofs complete`, w / 2, cardY + 190);229+ ctx.globalAlpha *= frame.actionsProgress;230+ const coarsePointer = view.coarsePointer ?? false;231+ if (w <= 760) {232+ const actionY = cardY + 250;233+ drawStackedAction(ctx, 'ENTER', 'continue', cardX + cardWidth / 6, actionY);234+ drawStackedAction(ctx, 'Z', 'undo', cardX + cardWidth / 2, actionY);235+ drawStackedAction(ctx, 'R', 'replay', cardX + (cardWidth * 5) / 6, actionY);236+ } else if (!coarsePointer) {237+ drawInlineActions(ctx, [{ key: 'ENTER', label: 'continue' }, { key: 'Z', label: 'undo' }, { key: 'R', label: 'replay' }], w / 2, cardY + 250);238+ }239+ ctx.restore();240+ if (w <= 760 || coarsePointer) drawTouchControls(ctx, w, h, coarsePointer);241+}242+243+/**244+ * Render the board and D22 Win card. Stage integration passes the same view245+ * shape used by the old overlay plus `winningCell`, `winSkip`, and246+ * `winExitStart`. `winningCell` is `{x, y}` in board coordinates; when absent247+ * it is derived from the settled YOU/WIN contact using `boardGame.properties`.248+ */249+export function renderWinScene(ctx, view = {}) {250+ const { width: w, height: h } = view;251+ const { boardState, boardGame, boardPalette = {}, boardGlyphs = {}, boardFrame, reducedMotion = false } = view;252+ let geometry = null;253+ if (boardState) {254+ const cell = Math.min(96, Math.max(22, Math.floor(Math.min((w - 80) / boardState.width, (h - 320) / boardState.height))));255+ geometry = drawBoard(ctx, boardState, { x: Math.floor((w - boardState.width * cell) / 2), y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)), cell, game: boardGame, palette: boardPalette, glyphs: boardGlyphs, frame: boardFrame });256+ }257+ const frame = winMotionFrame({ now: view.now ?? 0, winStart: view.winStart ?? 0, winSkip: view.winSkip ?? false, winExitStart: view.winExitStart ?? 0, reducedMotion, tileCount: 3 });258+ const opacity = 1 - frame.exitProgress;259+ ctx.fillStyle = `rgba(0,0,0,${0.66 * opacity * Math.min(1, frame.cardProgress * 2)})`;260+ ctx.fillRect(0, 0, w, h);261+ if (geometry) drawRing(ctx, geometry, winningCell(boardState, boardGame, view.winningCell), frame.ringProgress);262+ drawCard(ctx, view, frame, view.winningRule);263+ ctx.textAlign = 'left';264+ return { frame, geometry, winningCell: winningCell(boardState, boardGame, view.winningCell) };265+}266+267+export default renderWinScene;
A tests/stage-win.test.js +44 -0
@@ -0,0 +1,44 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { WIN_TIMING, winMotionFrame } from '../src/render/stage-win.js';4+5+test('win timeline rings first and delays card choreography', () => {6+ const start = 1000;7+ const beforeCard = winMotionFrame({ now: start + 200, winStart: start });8+ assert.equal(beforeCard.ringProgress > 0, true);9+ assert.equal(beforeCard.cardProgress, 0);10+ const card = winMotionFrame({ now: start + WIN_TIMING.cardDelay + 1, winStart: start });11+ assert.equal(card.cardProgress > 0, true);12+ assert.equal(card.tileProgress[0], 0);13+});14+15+test('win timeline stamps tiles, then bar, count, and actions', () => {16+ const start = 1000;17+ const card = start + WIN_TIMING.cardDelay;18+ const stamps = winMotionFrame({ now: card + 280, winStart: start });19+ assert.equal(stamps.tileProgress[0], 1);20+ assert.equal(stamps.tileProgress[1] > 0, true);21+ assert.equal(stamps.tileProgress[2] === 0, true);22+ const late = winMotionFrame({ now: card + WIN_TIMING.actions + 220, winStart: start });23+ assert.equal(late.barProgress, 1);24+ assert.equal(late.countProgress > 0, true);25+ assert.equal(late.actionsProgress, 1);26+});27+28+test('win skip and reduced motion settle all phases, while exit is bounded', () => {29+ const skipped = winMotionFrame({ now: 2000, winStart: 1000, winSkip: true });30+ assert.equal(skipped.settled, true);31+ assert.deepEqual(skipped.tileProgress, [1, 1, 1]);32+ const exiting = winMotionFrame({ now: 1200, winStart: 0, winExitStart: 1100 });33+ assert.equal(exiting.exitProgress > 0, true);34+ assert.equal(exiting.cardOffset > 0, true);35+ const reduced = winMotionFrame({ now: 0, winStart: 1000, reducedMotion: true, tileCount: 4 });36+ assert.deepEqual(reduced.tileProgress, [1, 1, 1, 1]);37+});38+39+test('win timeline also accepts stage-friendly elapsed and exit elapsed values', () => {40+ const frame = winMotionFrame(WIN_TIMING.cardDelay + 1, { tileCount: 3 });41+ assert.equal(frame.cardProgress > 0, true);42+ const exiting = winMotionFrame(0, { exitElapsed: WIN_TIMING.exit });43+ assert.equal(exiting.exitProgress, 1);44+});