- commit
- 0a66e0ad2a39035138b71ded8451977c40cb43b4
- parent
- 75723077280e0a45df4e29bf4708b2d691db4ad3
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 267 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 44 | +++++ |
@@ -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;@@ -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+});