1import { drawBoard } from './board.js';2import {3 drawInlineActions,4 drawSentenceTiles,5 drawStackedAction,6} from './stage-hud.js';7import { fitSceneTitle } from './title-fitting.js';89/**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 */14export 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});2728export const WIN_CARD = Object.freeze({29 widthMax: 620,30 desktopHeight: 290,31 mobileHeight: 348,32 mobileBreakpoint: 760,33 mobileInset: 24,34 mobileContinueTop: 228,35 mobileContinueHeight: 48,36 mobileSecondaryTop: 288,37 mobileSecondaryHeight: 44,38});3940function clamp(value, min = 0, max = 1) {41 return Math.max(min, Math.min(max, value));42}4344function easeOut(value) {45 const t = clamp(value);46 return 1 - (1 - t) ** 3;47}4849function easeIn(value) {50 return clamp(value) ** 3;51}5253function easeBack(value) {54 const t = clamp(value);55 const back = 1.70158;56 return 1 + (back + 1) * (t - 1) ** 3 + back * (t - 1) ** 2;57}5859function phase(now, start, duration) {60 if (!Number.isFinite(start) || !Number.isFinite(now)) return 1;61 return clamp((now - start) / Math.max(1, duration));62}6364function settledFrame(tileCount, exitProgress = 0) {65 return {66 elapsed: Number.POSITIVE_INFINITY,67 ringProgress: 1,68 cardProgress: 1,69 cardScale: 1,70 cardAlpha: 1,71 cardOffset: 48 * easeIn(exitProgress),72 tileProgress: Array.from({ length: tileCount }, () => 1),73 barProgress: 1,74 countProgress: 1,75 actionsProgress: 1,76 exitProgress,77 settled: exitProgress === 0 || exitProgress >= 1,78 };79}8081/**82 * Derive all visible win phases from one virtual presentation timestamp.83 * `winSkip` may be a boolean (settle now) or a timestamp at which the first84 * Enter was pressed; both forms make integration from keyboard/touch simple.85 */86export function winMotionFrame(elapsedOrOptions = {}, options = {}) {87 const input = typeof elapsedOrOptions === 'number'88 ? { ...options, elapsed: elapsedOrOptions }89 : elapsedOrOptions;90 const {91 now = 0,92 winStart = 0,93 elapsed,94 winSkip = false,95 skip = false,96 winExitStart = 0,97 exitElapsed,98 reducedMotion = false,99 tileCount = 3,100 } = input || {};101 const count = Math.max(0, Number.isInteger(tileCount) ? tileCount : 3);102 const entranceSkip = skip === true || winSkip === true || Number.isFinite(winSkip);103 const started = winStart;104 const virtualElapsed = Number.isFinite(elapsed)105 ? elapsed106 : Number.isFinite(now) && Number.isFinite(started)107 ? now - started108 : 0;109 const frameNow = started + virtualElapsed;110 const requestedExit = Number.isFinite(exitElapsed) || (winExitStart && Number.isFinite(winExitStart));111 const exitProgress = Number.isFinite(exitElapsed)112 ? phase(exitElapsed, 0, WIN_TIMING.exit)113 : requestedExit114 ? phase(frameNow, winExitStart, WIN_TIMING.exit)115 : 0;116 // Reduced motion resolves every requested primitive to its end state. A117 // normal Enter skip settles the entrance but still lets the 170ms exit play.118 if (reducedMotion) return settledFrame(count, requestedExit ? 1 : 0);119 if (entranceSkip) return settledFrame(count, exitProgress);120 const cardStart = started + WIN_TIMING.cardDelay;121 const frame = {122 elapsed: virtualElapsed,123 ringProgress: phase(frameNow, started, WIN_TIMING.ring),124 cardProgress: phase(frameNow, cardStart, WIN_TIMING.slam),125 cardScale: 1.05 - 0.05 * easeOut(phase(frameNow, cardStart, WIN_TIMING.slam)),126 cardAlpha: phase(frameNow, cardStart, 50),127 cardOffset: 0,128 tileProgress: Array.from({ length: count }, (_, index) =>129 phase(frameNow, cardStart + (WIN_TIMING.stamps[index] ?? WIN_TIMING.stamps.at(-1) + (index - 2) * 70), WIN_TIMING.stamp),130 ),131 barProgress: phase(frameNow, cardStart + WIN_TIMING.bar, WIN_TIMING.barDuration),132 countProgress: phase(frameNow, cardStart + WIN_TIMING.count, WIN_TIMING.countDuration),133 actionsProgress: phase(frameNow, cardStart + WIN_TIMING.actions, 200),134 exitProgress: 0,135 settled: false,136 };137 if (requestedExit) {138 frame.exitProgress = exitProgress;139 frame.cardOffset = 48 * easeIn(frame.exitProgress);140 }141 if (frame.exitProgress >= 1) frame.settled = true;142 return frame;143}144145function winningCell(boardState, boardGame, requested) {146 if (requested && Number.isFinite(requested.x) && Number.isFinite(requested.y)) return requested;147 const objects = Array.isArray(boardState?.objects) ? boardState.objects : [];148 const properties = object => {149 if (typeof boardGame?.properties === 'function') return new Set(boardGame.properties(object));150 return new Set((boardState?.rules || []).filter(rule => rule.subject === object.noun).map(rule => rule.predicate));151 };152 const byCell = new Map();153 for (const object of objects) {154 if (!Number.isFinite(object.x) || !Number.isFinite(object.y)) continue;155 const key = `${object.x},${object.y}`;156 const cell = byCell.get(key) || { x: object.x, y: object.y, objects: [] };157 cell.objects.push(object);158 byCell.set(key, cell);159 }160 for (const cell of byCell.values()) {161 const traits = cell.objects.map(properties);162 if (traits.some(set => set.has('YOU')) && traits.some(set => set.has('WIN'))) return cell;163 }164 return null;165}166167function sentenceFor(boardState, winningRule) {168 if (winningRule) return String(winningRule.text || winningRule);169 return boardState?.rules?.find(rule => String(rule.predicate).toUpperCase() === 'WIN')?.text || 'MIAU IS WIN';170}171172/** Shared card and touch-target geometry for the renderer and stage events. */173export function winCardLayout({ width, height, cardY = Math.max(120, height * 0.25), coarsePointer = false } = {}) {174 const mobile = width <= WIN_CARD.mobileBreakpoint || coarsePointer;175 const cardWidth = Math.min(WIN_CARD.widthMax, Math.max(1, width - 48));176 const cardX = (width - cardWidth) / 2;177 const cardHeight = mobile ? WIN_CARD.mobileHeight : WIN_CARD.desktopHeight;178 const card = { x: cardX, y: cardY, width: cardWidth, height: cardHeight };179 if (!mobile) return { mobile, card, continue: null, undo: null, replay: null };180 const innerWidth = Math.max(1, cardWidth - WIN_CARD.mobileInset * 2);181 const continueButton = {182 x: cardX + WIN_CARD.mobileInset,183 y: cardY + WIN_CARD.mobileContinueTop,184 width: innerWidth,185 height: WIN_CARD.mobileContinueHeight,186 };187 const secondaryWidth = Math.max(1, (innerWidth - 16) / 2);188 return {189 mobile,190 card,191 continue: continueButton,192 undo: { x: continueButton.x, y: cardY + WIN_CARD.mobileSecondaryTop, width: secondaryWidth, height: WIN_CARD.mobileSecondaryHeight },193 replay: { x: continueButton.x + secondaryWidth + 16, y: cardY + WIN_CARD.mobileSecondaryTop, width: secondaryWidth, height: WIN_CARD.mobileSecondaryHeight },194 };195}196197function contains(rect, x, y) {198 return Boolean(rect && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height);199}200201/** Return the semantic Win pointer target, or null outside the card/actions. */202export function winPointerTarget({ width, height, x, y, coarsePointer = false } = {}) {203 const layout = winCardLayout({ width, height, coarsePointer });204 if (!contains(layout.card, x, y)) return null;205 if (!layout.mobile) return 'card';206 if (contains(layout.continue, x, y)) return 'continue';207 if (contains(layout.undo, x, y)) return 'undo';208 if (contains(layout.replay, x, y)) return 'replay';209 return 'card';210}211212export function winStepLabel(steps, countProgress) {213 const value = Math.round(Number(steps) * easeOut(countProgress));214 return `PROVEN IN ${value} ${value === 1 && countProgress >= 1 ? 'STEP' : 'STEPS'}`;215}216217function drawRing(ctx, geometry, cell, progress) {218 if (!cell || progress >= 1) return;219 const q = easeOut(progress);220 const cx = geometry.x + (cell.x + 0.5) * geometry.cell;221 const cy = geometry.y + (cell.y + 0.5) * geometry.cell;222 ctx.save();223 ctx.globalAlpha = 1 - progress;224 ctx.strokeStyle = '#2EE6A8';225 ctx.lineWidth = Math.max(1, 5 * (1 - progress) + 1);226 ctx.beginPath();227 ctx.arc(cx, cy, geometry.cell * (0.5 + q * 1.4), 0, Math.PI * 2);228 ctx.stroke();229 ctx.restore();230}231232function drawCard(ctx, view, frame, winningRule) {233 const { width: w, height: h } = view;234 const baseY = Math.max(120, h * 0.25);235 const slam = easeBack(frame.cardProgress);236 const cardY = baseY + (1 - slam) * -56 + frame.cardOffset;237 const layout = winCardLayout({ width: w, height: h, cardY, coarsePointer: view.coarsePointer ?? false });238 const { cardWidth, cardX, cardHeight } = { cardWidth: layout.card.width, cardX: layout.card.x, cardHeight: layout.card.height };239 const opacity = 1 - frame.exitProgress;240 ctx.save();241 // The face, lip, and signal stroke are one physical card and share this242 // alpha. It reaches full ink before the early slam review frame, rather than243 // fading each layer apart.244 ctx.globalAlpha = opacity * frame.cardAlpha;245 ctx.translate(w / 2, cardY + cardHeight / 2);246 ctx.scale(frame.cardScale, frame.cardScale);247 ctx.translate(-w / 2, -(cardY + cardHeight / 2));248 ctx.fillStyle = '#191816';249 ctx.fillRect(cardX + 10, cardY + 10, cardWidth, cardHeight);250 ctx.fillStyle = '#0F0E0D';251 ctx.fillRect(cardX, cardY, cardWidth, cardHeight);252 ctx.strokeStyle = '#2EE6A8';253 ctx.lineWidth = 2;254 ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);255256 const proofTitle = String(view.boardState?.title || '').trim();257 let titleFit = fitSceneTitle(ctx, proofTitle, { maxWidth: cardWidth - 64, maxSize: 22, minSize: 16, maxLines: 2 });258 if (w <= 760 && titleFit.overflow)259 titleFit = fitSceneTitle(ctx, proofTitle, { maxWidth: cardWidth - 32, maxSize: 16, minSize: 16, maxLines: 3 });260 const titleTop = cardY + 20;261 ctx.fillStyle = '#F2EEE6';262 ctx.font = `800 ${titleFit.size}px Recursive, monospace`;263 ctx.textAlign = 'center';264 ctx.textBaseline = 'top';265 titleFit.lines.filter(Boolean).forEach((line, index) => ctx.fillText(line, w / 2, titleTop + index * titleFit.lineHeight));266267 const sentence = sentenceFor(view.boardState, winningRule);268 const words = sentence.split(/\s+/).filter(Boolean);269 const sentenceSize = Math.min(22, Math.max(14, cardWidth * 0.035));270 ctx.font = `800 ${sentenceSize}px Recursive, monospace`;271 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;272 const sentenceX = cardX + Math.max(32, (cardWidth - sentenceWidth) / 2);273 const tileY = cardY + 52 + Math.max(0, titleFit.lines.length - 1) * titleFit.lineHeight;274 let cursor = sentenceX;275 words.forEach((word, index) => {276 const progress = frame.tileProgress[index] ?? frame.tileProgress.at(-1) ?? 1;277 if (progress <= 0) return;278 const tileWidth = Math.max(sentenceSize * 1.6, ctx.measureText(word.toUpperCase()).width + sentenceSize);279 const tileScale = 1.4 - 0.4 * easeOut(progress);280 ctx.save();281 ctx.translate(cursor + tileWidth / 2, tileY + sentenceSize * 0.78);282 ctx.scale(tileScale, tileScale);283 ctx.translate(-(cursor + tileWidth / 2), -(tileY + sentenceSize * 0.78));284 drawSentenceTiles(ctx, word, cursor, tileY, cardWidth - 64, sentenceSize, Math.min(1, progress * 3));285 ctx.restore();286 cursor += tileWidth + sentenceSize * 0.4;287 });288 ctx.fillStyle = '#2EE6A8';289 ctx.fillRect(cardX + 32, cardY + 100, (cardWidth - 64) * easeOut(frame.barProgress), 3);290291 ctx.textAlign = 'center';292 ctx.textBaseline = 'middle';293 ctx.fillStyle = '#F2EEE6';294 const steps = Number(view.boardState?.moves || view.steps || 0);295 const headline = winStepLabel(steps, frame.countProgress);296 let headlineSize = Math.min(44, Math.max(26, w * 0.055));297 do {298 ctx.font = `800 ${headlineSize}px Recursive, monospace`;299 if (ctx.measureText(headline).width <= cardWidth - 64 || headlineSize <= 22) break;300 headlineSize -= 2;301 } while (headlineSize > 22);302 ctx.globalAlpha *= frame.countProgress;303 ctx.fillText(headline, w / 2, cardY + 154);304 ctx.globalAlpha *= frame.actionsProgress;305 ctx.fillStyle = '#8E877C';306 ctx.font = '600 16px Recursive, monospace';307 ctx.fillText(`${view.boardState?.completedCount ?? 0} of ${view.boardState?.proofTotal ?? 9} proofs complete`, w / 2, cardY + 202);308 ctx.globalAlpha *= frame.actionsProgress;309 const coarsePointer = view.coarsePointer ?? false;310 if (layout.mobile) {311 ctx.fillStyle = '#2EE6A8';312 ctx.fillRect(layout.continue.x, layout.continue.y, layout.continue.width, layout.continue.height);313 ctx.fillStyle = '#0F0E0D';314 ctx.font = '800 13px Recursive, monospace';315 ctx.fillText('CONTINUE', layout.continue.x + layout.continue.width / 2, layout.continue.y + layout.continue.height / 2);316 ctx.fillStyle = '#F2EEE6';317 ctx.font = '800 12px Recursive, monospace';318 ctx.fillText('UNDO', layout.undo.x + layout.undo.width / 2, layout.undo.y + layout.undo.height / 2);319 ctx.fillText('REPLAY', layout.replay.x + layout.replay.width / 2, layout.replay.y + layout.replay.height / 2);320 } else if (!coarsePointer) {321 drawInlineActions(ctx, [{ key: 'ENTER', label: 'continue', action: 'confirm' }, { key: 'Z', label: 'undo', action: 'undo' }, { key: 'R', label: 'replay', action: 'restart' }], w / 2, cardY + 250, { now: view.now, keycapPress: view.keycapPress, reducedMotion: view.reducedMotion });322 }323 ctx.restore();324}325326/**327 * Render the board and D22 Win card. Stage integration passes the same view328 * shape used by the old overlay plus `winningCell`, `winSkip`, and329 * `winExitStart`. `winningCell` is `{x, y}` in board coordinates; when absent330 * it is derived from the settled YOU/WIN contact using `boardGame.properties`.331 */332export function renderWinScene(ctx, view = {}) {333 const { width: w, height: h } = view;334 const { boardState, boardGame, boardPalette = {}, boardGlyphs = {}, boardFrame, reducedMotion = false } = view;335 let geometry = null;336 if (boardState) {337 const cell = Math.min(96, Math.max(22, Math.floor(Math.min((w - 80) / boardState.width, (h - 320) / boardState.height))));338 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 });339 }340 const frame = winMotionFrame({ now: view.now ?? 0, winStart: view.winStart ?? 0, winSkip: view.winSkip ?? false, winExitStart: view.winExitStart ?? 0, reducedMotion, tileCount: 3 });341 const opacity = 1 - frame.exitProgress;342 ctx.fillStyle = `rgba(0,0,0,${0.66 * opacity * frame.cardAlpha})`;343 ctx.fillRect(0, 0, w, h);344 if (geometry) drawRing(ctx, geometry, winningCell(boardState, boardGame, view.winningCell), frame.ringProgress);345 drawCard(ctx, view, frame, view.winningRule);346 ctx.textAlign = 'left';347 return { frame, geometry, winningCell: winningCell(boardState, boardGame, view.winningCell), layout: winCardLayout({ width: w, height: h, coarsePointer: view.coarsePointer ?? false }) };348}349350export default renderWinScene;