- commit
- 513e6bfe18d7d6cd48227a7added8716ecae0107
- parent
- 7f2383b7e1ae517e3843b5b8f393a9e61b890202
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 3 | +- |
| M | src/ | 40 | +++++---- |
| D | src/ | 129 | --------------------------- |
| M | src/ | 4 | +- |
| M | src/ | 141 | +++++++++++++++++++++--------- |
| M | src/ | 3 | +- |
| M | tests/ | 4 | +- |
| M | tests/ | 2 | +- |
| M | tests/ | 15 | +++ |
@@ -49,13 +49,14 @@ export function createIntentRouter({
4949 }5050 5151 if (stage.scene === 'proof' || stage.scene === 'win') {52+ if (stage.scene === 'win' && stage.isWinExiting) return true;5253 if (type === 'move' && stage.scene === 'proof') moves[intent.dir]?.();5354 if (type === 'wait' && stage.scene === 'proof') wait();5455 if (type === 'undo') undo();5556 if (type === 'restart') restart();5657 if (type === 'menu' && stage.scene === 'proof') stage.show('pause');5758 if (type === 'mute') mute();58- if (type === 'confirm' && stage.scene === 'win') stage.show('index');59+ if (type === 'confirm' && stage.scene === 'win') stage.handleIntent?.(intent);5960 return true;6061 }6162 @@ -17,6 +17,7 @@ import { createTitleScene } from './scenes/title.js';
1717 import { createTurnActions } from './app/turns.js';1818 import { createDevView } from './app/dev-view.js';1919 import { createIntentRouter } from './app/intent-router.js';20+import { createPresentationClock, parseSlowmo } from './presentation-clock.js';2021 import './style.css';2122 import './overrides.css';2223 import './font.css';@@ -42,6 +43,9 @@ const shareUrlInput = document.querySelector('#share-url-input');
4243 const copyShareButton = document.querySelector('#copy-share');4344 const modStatus = document.querySelector('#mod-status');4445 if (!new URLSearchParams(location.search).has('dev')) document.body.classList.add('stage-player');46+// Review-only visual time. Input repeat and engine actions stay on their raw47+// clocks; only presentation effects and their intentional scene waits use this.48+const presentationClock = createPresentationClock({ slowmo: parseSlowmo(location.search) });4549 let levelNo = 0;4650 let game;4751 let lastState;@@ -50,10 +54,10 @@ registerBundledPacks();
5054 const library = createPackLibrary({ catalog });5155 const packService = createPackService({ catalog, library });5256 const audio = createAudioSystem(); audio.installGestureListeners(document);53-const juice = createJuice({ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches });57+const juice = createJuice({ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches, now: presentationClock.now });5458 const progress = createProgressStore();55-const titleController = createTitleScene({ onSolved: () => stage.show('index'), onSkip: () => stage.show('index'), onWorkshop: () => stage.show('workshop', { origin: 'title' }) });56-const stage = createStage({ levels: () => currentLevels(), title: titleController, onSelectLevel: id => { const ok = loadLevel(currentLevels().findIndex(level => level.id === id)); if (ok) scheduleInitialWin(); return ok; }, onConfirm: scene => { if (scene === 'gift') giftController.input('confirm'); } });59+const titleController = createTitleScene({ startTime: presentationClock.now(), onSolved: () => stage.show('index'), onSkip: () => stage.show('index'), onWorkshop: () => stage.show('workshop', { origin: 'title' }) });60+const stage = createStage({ clock: presentationClock, levels: () => currentLevels(), title: titleController, onSelectLevel: id => { const ok = loadLevel(currentLevels().findIndex(level => level.id === id)); if (ok) scheduleInitialWin(); return ok; }, onConfirm: scene => { if (scene === 'gift') giftController.input('confirm'); } });5761 let giftOrigin = 'url';5862 const giftController = createGiftScene({5963 copyToClipboard: text => {@@ -104,7 +108,7 @@ function loadLevel(index) {
104108 const list = currentLevels();105109 if (typeof index === 'string') index = Math.max(0, list.findIndex(level => level.id === index));106110 if (!list[index]) { showModStatus('Level unavailable'); return false; }107- clearTimeout(winTimer);111+ presentationClock.clearTimeout(winTimer);108112 feedback = '';109113 levelNo = index; catalog.setActiveLevel(list[levelNo].id); game = createGame(list[levelNo]); lastState = game.getState();110114 levelTitle.textContent = lastState.title; levelIndex.textContent = `${String(levelNo + 1).padStart(2,'0')} / ${String(list.length).padStart(2,'0')}`;@@ -114,7 +118,7 @@ function loadLevel(index) {
114118 winToast.hidden = !lastState.won;115119 // A new proof needs fresh positions, sentence animations, and win effects;116120 // authored levels may reuse entity IDs from the previous proof.117- juice.resetLevel(lastState); renderLevelList(); renderPackList(); updateUI(); render(); stage.setBoard(displayState(), game, activePalette(), glyphs, juice.frame());121+ juice.resetLevel(lastState); renderLevelList(); renderPackList(); updateUI(); render(); stage.setBoard(displayState(), game, activePalette(), glyphs, juice.frame()); animateBoard(650);118122 return true;119123 }120124 function packLevelIndexes(packId = activePackId()) {@@ -170,7 +174,7 @@ function playTurnSounds(result) {
170174 if (result.destroyed?.some(entry => entry.cause === 'sink')) playSound('sink');171175 if (result.destroyed?.some(entry => entry.cause === 'defeat')) playSound('defeat');172176 }173-function showFeedback(message) { feedback = message; clearTimeout(feedbackTimer); feedbackTimer = setTimeout(() => { feedback = ''; updateUI(); }, 1000); updateUI(); }177+function showFeedback(message) { feedback = message; presentationClock.clearTimeout(feedbackTimer); feedbackTimer = presentationClock.setTimeout(() => { feedback = ''; updateUI(); }, 1000); updateUI(); }174178 function accepted(result) { return typeof result === 'object' && result !== null ? result.accepted : result; }175179 function announceLive(message) { const copy = String(message || '').trim(); if (!copy) return; if (stage.announce) stage.announce(copy); else { const live = document.querySelector('.stage-sr'); if (live) live.textContent = copy; } }176180 function announceTurn(result) {@@ -197,23 +201,24 @@ let animationRequest = 0;
197201 let animationDeadline = 0;198202 let winTimer = 0;199203 function scheduleInitialWin() {200- clearTimeout(winTimer);204+ presentationClock.clearTimeout(winTimer);201205 if (!game?.getState().won) return;202206 const level = currentLevels()[levelNo];203207 if (level?.packId && level?.localId) {204208 progress.markSolved(level.packId, level.localId);205209 refreshIndex(level.id);206210 }207- winTimer = setTimeout(() => {208- if (game?.getState().won && stage.scene === 'proof') stage.show('win', { steps: game.getState().moves, nodeId: level?.id });211+ winTimer = presentationClock.setTimeout(() => {212+ if (game?.getState().won && stage.scene === 'proof') stage.show('win', { steps: game.getState().moves, nodeId: level?.id, instant: true });209213 }, 0);210214 }211-function paintBoardFrame(now = performance.now()) { stage.setBoard(displayState(),game,activePalette(),glyphs,juice.frame(now)); }212-function animateBoard(duration = 650) { animationDeadline = Math.max(animationDeadline,performance.now()+duration); if (animationRequest) return; const tick = now => { animationRequest=0; if (stage.scene === 'proof' || stage.scene === 'win') paintBoardFrame(now); if (now < animationDeadline) animationRequest=requestAnimationFrame(tick); }; animationRequest=requestAnimationFrame(tick); }215+function paintBoardFrame(now = presentationClock.now()) { stage.setBoard(displayState(),game,activePalette(),glyphs,juice.frame(now)); }216+function animateBoard(duration = 650) { animationDeadline = Math.max(animationDeadline,presentationClock.now()+duration); if (animationRequest) return; const tick = now => { animationRequest=0; if (stage.scene === 'proof' || stage.scene === 'win') paintBoardFrame(now); if (now < animationDeadline) animationRequest=presentationClock.requestAnimationFrame(tick); }; animationRequest=presentationClock.requestAnimationFrame(tick); }213217 const turns = createTurnActions({214218 getGame: () => game,215219 getStage: () => stage,216220 getJuice: () => juice,221+ getNow: presentationClock.now,217222 setFeedback: showFeedback,218223 clearFeedback: () => { feedback = ''; },219224 updateUI,@@ -230,17 +235,17 @@ const turns = createTurnActions({
230235 refreshIndex(level.id);231236 }232237 playSound('win');233- clearTimeout(winTimer);234- const cardAt = juice.frame(now).win?.cardAt ?? now;235- winTimer = setTimeout(() => {238+ presentationClock.clearTimeout(winTimer);239+ const beatStart = juice.frame(now).win?.beatStart ?? now;240+ winTimer = presentationClock.setTimeout(() => {236241 if (game?.getState().won) stage.show('win', { steps: game.getState().moves, nodeId: level?.id });237- }, Math.max(0, cardAt - now));242+ }, Math.max(0, beatStart - now));238243 },239244 onRestart: () => {240- clearTimeout(winTimer);245+ presentationClock.clearTimeout(winTimer);241246 scheduleInitialWin();242247 },243- onUndo: () => clearTimeout(winTimer),248+ onUndo: () => presentationClock.clearTimeout(winTimer),244249 });245250 function move(dx, dy) { turns.move(dx, dy); }246251 function waitTurn() { turns.wait(); }@@ -257,6 +262,7 @@ const intentRouter = createIntentRouter({
257262 onSummary: announceBoardSummary,258263 });259264 function handleIntent(intent) {265+ stage.noteInput?.(intent);260266 intentRouter.route(intent);261267 }262268 document.querySelectorAll('[data-dir]').forEach(button=>button.addEventListener('click',()=>({up:move.bind(null,0,-1),down:move.bind(null,0,1),left:move.bind(null,-1,0),right:move.bind(null,1,0)}[button.dataset.dir])()));@@ -1,129 +0,0 @@
1-export function renderWinScene(ctx, view = {}) {2- const { width: w, height: h } = view;3- const {4- boardState,5- boardGame,6- boardPalette = {},7- boardGlyphs = {},8- boardFrame,9- reducedMotion = false,10- winStart = 0,11- } = view;12- if (boardState) {13- const cell = Math.min(14- 96,15- Math.max(16- 22,17- Math.floor(18- Math.min((w - 80) / boardState.width, (h - 320) / boardState.height),19- ),20- ),21- );22- drawBoard(ctx, boardState, {23- x: Math.floor((w - boardState.width * cell) / 2),24- y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)),25- cell,26- game: boardGame,27- palette: boardPalette,28- glyphs: boardGlyphs,29- frame: boardFrame,30- });31- }32- ctx.fillStyle = "rgba(0,0,0,.66)";33- ctx.fillRect(0, 0, w, h);34- const cardWidth = Math.min(620, w - 48);35- const cardX = (w - cardWidth) / 2;36- const winProgress =37- reducedMotion || !winStart ? 1 : Math.min(1, (view.now - winStart) / 520);38- const back = 1.70158;39- const rawBackEase =40- 1 +41- (back + 1) * Math.pow(winProgress - 1, 3) +42- back * Math.pow(winProgress - 1, 2);43- const winEase = Math.max(0, Math.min(1.1, rawBackEase));44- const cardY = Math.max(120, h * 0.25) + (1 - winEase) * 80;45- const cardHeight = 290;46- ctx.fillStyle = "#191816";47- ctx.fillRect(cardX + 10, cardY + 10, cardWidth, cardHeight);48- ctx.fillStyle = "#F2EEE6";49- ctx.fillRect(cardX, cardY, cardWidth, cardHeight);50- ctx.fillStyle = "#0F0E0D";51- ctx.fillRect(cardX + 2, cardY + 2, cardWidth - 4, cardHeight - 4);52- ctx.strokeStyle = "#2EE6A8";53- ctx.lineWidth = 2;54- ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);55- ctx.textAlign = "left";56- ctx.fillStyle = "#8E877C";57- ctx.font = "600 10px Recursive, monospace";58- const winningRule =59- boardState?.rules?.find((rule) => rule.predicate === "WIN")?.text ||60- boardState?.rules?.[0]?.text ||61- "MIAU IS WIN";62- const sentenceWords = winningRule.split(/\s+/).filter(Boolean);63- const sentenceSize = Math.min(22, Math.max(14, cardWidth * 0.035));64- ctx.font = `800 ${sentenceSize}px Recursive, monospace`;65- const sentenceWidth =66- sentenceWords.reduce(67- (total, word) =>68- total +69- Math.max(70- sentenceSize * 1.6,71- ctx.measureText(word.toUpperCase()).width + sentenceSize,72- ),73- 0,74- ) +75- Math.max(0, sentenceWords.length - 1) * sentenceSize * 0.4;76- drawSentenceTiles(77- ctx,78- winningRule,79- cardX + Math.max(32, (cardWidth - sentenceWidth) / 2),80- cardY + 52,81- cardWidth - 64,82- sentenceSize,83- );84- ctx.fillStyle = "#2EE6A8";85- ctx.fillRect(86- cardX + 32,87- cardY + 100,88- (cardWidth - 64) * (reducedMotion ? 1 : Math.min(1, winProgress * 1.5)),89- 3,90- );91- ctx.textAlign = "center";92- ctx.fillStyle = "#F2EEE6";93- const headline = `PROVEN IN ${boardState?.moves || 0} STEPS`;94- let headlineSize = Math.min(44, Math.max(26, w * 0.055));95- do {96- ctx.font = `800 ${headlineSize}px Recursive, monospace`;97- if (ctx.measureText(headline).width <= cardWidth - 64 || headlineSize <= 22)98- break;99- headlineSize -= 2;100- } while (headlineSize > 22);101- const headlineY = cardY + 154;102- ctx.fillText(headline, w / 2, headlineY);103- ctx.fillStyle = "#8E877C";104- ctx.font = "600 16px Recursive, monospace";105- ctx.fillText(106- `${boardState?.completedCount ?? 0} of ${boardState?.proofTotal ?? 9} proofs complete`,107- w / 2,108- headlineY + headlineSize / 2 + 16 + 8,109- );110- const coarsePointer = view.coarsePointer ?? false;111- if (w <= 760) {112- const actionY = cardY + 250;113- drawStackedAction(ctx, "ENTER", "continue", cardX + cardWidth / 6, actionY);114- drawStackedAction(ctx, "Z", "undo", cardX + cardWidth / 2, actionY);115- drawStackedAction(ctx, "R", "replay", cardX + (cardWidth * 5) / 6, actionY);116- } else if (!coarsePointer)117- drawInlineActions(118- ctx,119- [120- { key: "ENTER", label: "continue" },121- { key: "Z", label: "undo" },122- { key: "R", label: "replay" },123- ],124- w / 2,125- cardY + 250,126- );127- if (w <= 760 || coarsePointer) drawTouchControls(ctx, w, h, coarsePointer);128- ctx.textAlign = "left";129-}@@ -3,7 +3,6 @@ import {
33 drawInlineActions,44 drawSentenceTiles,55 drawStackedAction,6- drawTouchControls,76 } from './stage-hud.js';87 98 /**@@ -236,7 +235,7 @@ function drawCard(ctx, view, frame, winningRule) {
236235 ctx.fillText(`${view.boardState?.completedCount ?? 0} of ${view.boardState?.proofTotal ?? 9} proofs complete`, w / 2, cardY + 190);237236 ctx.globalAlpha *= frame.actionsProgress;238237 const coarsePointer = view.coarsePointer ?? false;239- if (w <= 760) {238+ if (w <= 760 || coarsePointer) {240239 const actionY = cardY + 250;241240 drawStackedAction(ctx, 'ENTER', 'continue', cardX + cardWidth / 6, actionY);242241 drawStackedAction(ctx, 'Z', 'undo', cardX + cardWidth / 2, actionY);@@ -245,7 +244,6 @@ function drawCard(ctx, view, frame, winningRule) {
245244 drawInlineActions(ctx, [{ key: 'ENTER', label: 'continue' }, { key: 'Z', label: 'undo' }, { key: 'R', label: 'replay' }], w / 2, cardY + 250);246245 }247246 ctx.restore();248- if (w <= 760 || coarsePointer) drawTouchControls(ctx, w, h, coarsePointer);249247 }250248 251249 /**@@ -6,10 +6,11 @@ import { winScene } from "./scenes/win.js";
66 import { drawIndex } from "./render/index.js";77 import { renderProofScene } from "./render/stage-proof.js";88 import { nativeHorizontalFacing } from './render/facing.js';9-import { renderWinScene } from './render/stage-overlays.js';9+import { renderWinScene, WIN_TIMING } from './render/stage-win.js';1010 import { renderPauseScene } from './render/stage-pause.js';1111 import { pauseMotionTimings, pauseMotionLayout } from './render/menu-motion.js';1212 import { overlayInputForKey, pauseMenuItems } from "./stage/dispatch.js";13+import { createWinFlow, WIN_ENTRANCE_END } from './stage/win-flow.js';1314 1415 const SCENES = {1516 title: titleScene,@@ -41,6 +42,14 @@ export function copyConfirmationProgress(now, startedAt, duration = 900) {
4142 4243 /** Small scene shell. Gameplay remains owned by main.js; this owns scene intent and overlays. */4344 export function createStage(options = {}) {45+ const clock = options.clock || {46+ now: () => performance.now(),47+ requestAnimationFrame: callback => requestAnimationFrame(callback),48+ setTimeout: (callback, delay) => setTimeout(callback, delay),49+ clearTimeout: handle => clearTimeout(handle),50+ };51+ const visualNow = () => clock.now();52+ const visualFrame = callback => clock.requestAnimationFrame(callback);4453 const canvas = document.createElement("canvas");4554 canvas.className = "stage-canvas";4655 canvas.tabIndex = 0;@@ -114,9 +123,10 @@ export function createStage(options = {}) {
114123 let transitionGeneration = 0;115124 let catCenter = null;116125 let irisCenter = null;117- let winStart = 0;118126 let titleStart = 0;119127 let stepPopStart = 0;128+ let stepRoll = null;129+ let keycapPress = null;120130 let previousMoves = null;121131 let winningNodeId = null;122132 let indexPopStart = 0;@@ -125,6 +135,12 @@ export function createStage(options = {}) {
125135 const reducedMotion =126136 globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ??127137 false;138+ const winFlow = createWinFlow({139+ clock,140+ reducedMotion,141+ onChange: () => { render(); if (!reducedMotion) visualFrame(animateVisuals); },142+ onContinue: () => show('index'),143+ });128144 function resize() {129145 dpr = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1));130146 canvas.width = innerWidth * dpr;@@ -137,7 +153,7 @@ export function createStage(options = {}) {
137153 function announce(message) {138154 live.textContent = message;139155 }140- function drawCopyConfirmation(ctx, width, height, now = performance.now()) {156+ function drawCopyConfirmation(ctx, width, height, now = visualNow()) {141157 if (!copyFloatStart) return;142158 const progress = copyConfirmationProgress(now, copyFloatStart);143159 const labels = ["LINK", "IS", "COPIED"];@@ -173,23 +189,23 @@ export function createStage(options = {}) {
173189 if (progress >= 1) copyFloatStart = 0;174190 }175191 function showCopyConfirmation() {176- copyFloatStart = performance.now();192+ copyFloatStart = visualNow();177193 if (reducedMotion) {178194 copyFloatStart = 0;179195 return api;180196 }181197 render();182- requestAnimationFrame(showCopyConfirmationFrame);198+ visualFrame(showCopyConfirmationFrame);183199 return api;184200 }185201 function showCopyConfirmationFrame() {186202 if (!copyFloatStart) return;187- if (performance.now() - copyFloatStart >= 900 || (scene !== "gift" && scene !== "workshop")) {203+ if (visualNow() - copyFloatStart >= 900 || (scene !== "gift" && scene !== "workshop")) {188204 copyFloatStart = 0;189205 return;190206 }191207 render();192- if (copyFloatStart) requestAnimationFrame(showCopyConfirmationFrame);208+ if (copyFloatStart) visualFrame(showCopyConfirmationFrame);193209 }194210 function drawTransition(now, generation) {195211 if (@@ -210,7 +226,7 @@ export function createStage(options = {}) {
210226 ctx.fill("evenodd");211227 ctx.restore();212228 if (progress < 1)213- requestAnimationFrame((time) => drawTransition(time, generation));229+ visualFrame((time) => drawTransition(time, generation));214230 else transitionStart = 0;215231 }216232 function moveIndex(direction) {@@ -222,10 +238,15 @@ export function createStage(options = {}) {
222238 }223239 function animateVisuals(now) {224240 render();225- const winActive = scene === "win" && winStart && now - winStart < 620;241+ const winView = winFlow.view;242+ const winActive = scene === "win" && winFlow.active &&243+ (winFlow.exiting244+ ? now - winView.winExitStart < WIN_TIMING.exit245+ : !winView.winSkip && now - winView.winStart < WIN_ENTRANCE_END);226246 const titleActive =227247 scene === "title" && titleStart && now - titleStart < 520;228248 const stepActive = stepPopStart && now - stepPopStart < 240;249+ const keycapActive = keycapPress && now - keycapPress.startedAt < 90;229250 const indexActive =230251 scene === "index" && indexPopStart && now - indexPopStart < 520;231252 if (scene === "pause" && pauseMotionPhase === "opening" && now - pauseOpenedAt >= pauseMotionTimings.open)@@ -236,9 +257,9 @@ export function createStage(options = {}) {
236257 now - selectedAt < pauseMotionTimings.slide);237258 if (238259 !reducedMotion &&239- (winActive || titleActive || stepActive || indexActive || pauseActive)260+ (winActive || titleActive || stepActive || indexActive || pauseActive || keycapActive)240261 )241- requestAnimationFrame(animateVisuals);262+ visualFrame(animateVisuals);242263 }243264 function render() {244265 const w = innerWidth,@@ -251,7 +272,7 @@ export function createStage(options = {}) {
251272 const drawn = title.draw(ctx, {252273 width: w,253274 height: h,254- now: performance.now(),275+ now: visualNow(),255276 reducedMotion,256277 });257278 const cat = drawn?.state?.objects?.find(@@ -265,7 +286,7 @@ export function createStage(options = {}) {
265286 return;266287 }267288 if (scene === "gift" && gift) {268- gift.draw(ctx, performance.now(), { width: w, height: h });289+ gift.draw(ctx, visualNow(), { width: w, height: h });269290 canvas.setAttribute("aria-label", gift.accessibilityText?.() || "Miau Is True Gift");270291 drawCopyConfirmation(ctx, w, h);271292 return;@@ -280,12 +301,15 @@ export function createStage(options = {}) {
280301 const proofView = renderProofScene(ctx, boardState, {281302 width: w,282303 height: h,283- now: performance.now(),304+ now: visualNow(),284305 game: boardGame,285306 palette: boardPalette,286307 glyphs: boardGlyphs,287308 frame: boardFrame,288309 stepPopStart,310+ stepRoll,311+ keycapPress,312+ reducedMotion,289313 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,290314 });291315 if (proofView?.ariaLabel) canvas.setAttribute("aria-label", proofView.ariaLabel);@@ -298,7 +322,7 @@ export function createStage(options = {}) {
298322 catFacing: indexFacing,299323 popNodeId: indexPopNodeId,300324 popProgress: indexPopStart301- ? Math.min(1, (performance.now() - indexPopStart) / 520)325+ ? Math.min(1, (visualNow() - indexPopStart) / 520)302326 : 1,303327 reducedMotion,304328 lockedNode: indexLockedNode,@@ -307,7 +331,7 @@ export function createStage(options = {}) {
307331 if (active) catCenter = { x: active.centerX, y: active.centerY };308332 } else if (scene === "pause") {309333 renderPauseScene(ctx, {310- width: w, height: h, now: performance.now(), boardState, boardGame,334+ width: w, height: h, now: visualNow(), boardState, boardGame,311335 boardPalette, boardGlyphs, boardFrame, pausedFrom, indexLayout,312336 indexNavigator, indexFacing, indexPopNodeId, indexPopStart,313337 indexLockedNode, title, pauseItems, selected, reducedMotion,@@ -325,8 +349,8 @@ export function createStage(options = {}) {
325349 });326350 } else if (scene === "win") {327351 renderWinScene(ctx, {328- width: w, height: h, now: performance.now(), boardState, boardGame,329- boardPalette, boardGlyphs, boardFrame, reducedMotion, winStart,352+ width: w, height: h, now: visualNow(), boardState, boardGame,353+ boardPalette, boardGlyphs, boardFrame, reducedMotion, ...winFlow.view,330354 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,331355 });332356 } else {@@ -349,12 +373,12 @@ export function createStage(options = {}) {
349373 }350374 }351375 function show(id, data = {}) {376+ if (id !== 'win') winFlow.cancel();352377 irisCenter = catCenter;353378 if (id === "workshop") workshopOrigin = data.origin || scene;354379 if (id === "gift") giftOrigin = data.origin || scene;355380 if (id !== "index") indexLockedNode = null;356381 if (id === "win") {357- winStart = performance.now();358382 winningNodeId =359383 data.nodeId ||360384 indexNavigator?.current?.node?.id ||@@ -362,27 +386,28 @@ export function createStage(options = {}) {
362386 boardState?.id ||363387 null;364388 }365- if (id === "title") titleStart = performance.now();389+ if (id === "title") titleStart = visualNow();366390 if (id === "index" && winningNodeId) {367391 indexPopNodeId = winningNodeId;368- indexPopStart = performance.now();392+ indexPopStart = visualNow();369393 }370394 if (id === "pause" && scene !== "pause") {371395 pausedFrom = scene;372396 selected = 0;373397 previousSelected = 0;374- selectedAt = performance.now();398+ selectedAt = visualNow();375399 pressedIndex = -1;376400 pressedAt = 0;377401 pauseMotionPhase = "opening";378- pauseOpenedAt = performance.now();402+ pauseOpenedAt = visualNow();379403 pauseExitStartedAt = 0;380404 pauseMotionGeneration += 1;381- if (pauseExitTimer) clearTimeout(pauseExitTimer);405+ if (pauseExitTimer) clock.clearTimeout(pauseExitTimer);382406 pauseExitTimer = null;383407 pauseItems.splice(0, pauseItems.length, ...pauseMenuItems(pausedFrom, pauseStatus));384408 }385409 scene = id;410+ if (id === 'win') winFlow.enter({ instant: Boolean(data.instant) });386411 if (id !== "pause") pauseMotionPhase = "idle";387412 const spec = SCENES[id] || proofScene;388413 eyebrow.textContent = spec.eyebrow;@@ -400,10 +425,15 @@ export function createStage(options = {}) {
400425 : `${spec.eyebrow || ""} ${titleEl.textContent}`.trim());401426 render();402427 if (!reducedMotion && (id === "title" || id === "win" || id === "index" || id === "pause"))403- requestAnimationFrame(animateVisuals);404- transitionStart = performance.now();405- const generation = ++transitionGeneration;406- requestAnimationFrame((time) => drawTransition(time, generation));428+ visualFrame(animateVisuals);429+ if (id === 'win' || id === 'pause') {430+ transitionStart = 0;431+ transitionGeneration += 1;432+ } else {433+ transitionStart = visualNow();434+ const generation = ++transitionGeneration;435+ visualFrame((time) => drawTransition(time, generation));436+ }407437 }408438 function hide() {409439 if (scene === "pause") {@@ -428,8 +458,13 @@ export function createStage(options = {}) {
428458 return api;429459 }430460 function setBoard(state, game, palette = {}, glyphs = {}, frame = null) {431- if (previousMoves !== null && state?.moves !== previousMoves)432- stepPopStart = performance.now();461+ if (game === boardGame && previousMoves !== null && state?.moves !== previousMoves) {462+ stepPopStart = visualNow();463+ stepRoll = { from: previousMoves, to: state.moves, startedAt: stepPopStart, duration: 120 };464+ } else if (game !== boardGame) {465+ stepRoll = null;466+ keycapPress = null;467+ }433468 previousMoves = state?.moves ?? null;434469 boardState = state;435470 boardGame = game;@@ -438,6 +473,14 @@ export function createStage(options = {}) {
438473 boardFrame = frame;439474 render();440475 }476+ function noteInput(intent = {}) {477+ if (scene !== 'proof') return;478+ const action = intent.type === 'move' ? `move-${intent.dir}` : intent.type;479+ if (!['undo', 'restart', 'menu', 'wait', 'move-left', 'move-right', 'move-up', 'move-down'].includes(action)) return;480+ keycapPress = { action, startedAt: visualNow() };481+ render();482+ if (!reducedMotion) visualFrame(animateVisuals);483+ }441484 function setIndex(layout, navigator) {442485 indexLayout = layout;443486 indexNavigator = navigator;@@ -464,7 +507,7 @@ export function createStage(options = {}) {
464507 if (next !== selected) {465508 previousSelected = selected;466509 selected = next;467- selectedAt = performance.now();510+ selectedAt = visualNow();468511 }469512 root.dataset.selected = String(selected);470513 render();@@ -489,28 +532,28 @@ export function createStage(options = {}) {
489532 function beginPauseExit(action) {490533 if (pauseMotionPhase === "exiting") return true;491534 pauseMotionPhase = "exiting";492- pauseExitStartedAt = performance.now();535+ pauseExitStartedAt = visualNow();493536 const generation = ++pauseMotionGeneration;494537 if (reducedMotion) {495538 runPauseAction(action);496539 return true;497540 }498- pauseExitTimer = setTimeout(() => {541+ pauseExitTimer = clock.setTimeout(() => {499542 if (generation !== pauseMotionGeneration || scene !== "pause") return;500543 pauseExitTimer = null;501544 runPauseAction(action);502545 }, pauseMotionTimings.exit);503- requestAnimationFrame(animateVisuals);546+ visualFrame(animateVisuals);504547 return true;505548 }506549 function activatePauseSelection() {507550 const action = pauseItems[selected]?.id;508551 if (action === "sound" || action === "music") {509552 pressedIndex = selected;510- pressedAt = performance.now();553+ pressedAt = visualNow();511554 options.onPauseAction?.(action);512555 render();513- requestAnimationFrame(animateVisuals);556+ visualFrame(animateVisuals);514557 } else if (action) beginPauseExit(action);515558 return Boolean(action);516559 }@@ -568,10 +611,7 @@ export function createStage(options = {}) {
568611 render();569612 return true;570613 }571- if (scene === "win" && type === "confirm") {572- show("index");573- return true;574- }614+ if (scene === "win" && type === "confirm") return winFlow.confirm();575615 return false;576616 }577617 keepButton.addEventListener("click", () => {@@ -753,6 +793,23 @@ export function createStage(options = {}) {
753793 document.querySelector("#restart-btn")?.click();754794 else if (y >= innerHeight - 50 && x > innerWidth * 0.68) show("pause");755795 }796+ if (scene === 'win') {797+ if (winFlow.exiting) return;798+ const cardWidth = Math.min(620, innerWidth - 48);799+ const cardX = (innerWidth - cardWidth) / 2;800+ const cardY = Math.max(120, innerHeight * 0.25);801+ if (x >= cardX && x <= cardX + cardWidth && y >= cardY && y <= cardY + 290) {802+ const actionRow = y >= cardY + 220;803+ if (!winFlow.view.winSkip && visualNow() - winFlow.view.winStart < WIN_ENTRANCE_END)804+ winFlow.confirm();805+ else if (actionRow && (innerWidth <= 760 || globalThis.matchMedia?.('(pointer: coarse)').matches)) {806+ const slot = Math.min(2, Math.floor((x - cardX) / (cardWidth / 3)));807+ if (slot === 1) document.querySelector('#undo-btn')?.click();808+ else if (slot === 2) document.querySelector('#restart-btn')?.click();809+ else winFlow.confirm();810+ } else winFlow.confirm();811+ }812+ }756813 if (scene === "gift") {757814 const result = gift?.hit(x, y);758815 if (result?.action === "later") closeGift();@@ -788,6 +845,7 @@ export function createStage(options = {}) {
788845 hide,789846 setSelected,790847 setBoard,848+ noteInput,791849 setIndex,792850 setControllers,793851 setActions,@@ -805,6 +863,9 @@ export function createStage(options = {}) {
805863 get selected() {806864 return selected;807865 },866+ get isWinExiting() {867+ return winFlow.exiting;868+ },808869 root,809870 };810871 return Object.freeze(api);@@ -39,7 +39,8 @@ export function createWinFlow({ clock, reducedMotion = false, onChange = () => {
3939 onChange();4040 return true;4141 }42- exitStart = clock.now();42+ // Zero is the renderer's "no exit" sentinel, including on the first frame.43+ exitStart = Math.max(Number.EPSILON, clock.now());4344 if (reducedMotion) {4445 cancel();4546 onContinue();@@ -398,7 +398,9 @@ try {
398398 for (const key of ['ArrowRight', 'ArrowRight', 'ArrowUp', 'ArrowUp', ...Array(4).fill('ArrowDown'), ...Array(7).fill('ArrowRight')]) await stagePage.keyboard.press(key);399399 await stagePage.waitForFunction(() => JSON.parse(window.render_game_to_text()).mode === 'won');400400 await stagePage.waitForFunction(() => document.querySelector('.stage-shell')?.dataset.scene === 'win');401- await stagePage.waitForTimeout(700);401+ // D22 reveals the count and progress after the ring, slam, and tile stamps.402+ // Check the settled card rather than an intermediate animation frame.403+ await stagePage.waitForTimeout(1400);402404 await stagePage.screenshot({ path: `${stageScreenshotDir}/win.png` });403405 await assertCanvasRound3Typography(stagePage, { win: true });404406 assert.equal(await stagePage.locator('.stage-shell').getAttribute('data-scene'), 'win', 'winning a proof should open the win scene');@@ -47,7 +47,7 @@ test('maps title intents to the title controller', () => {
4747 test('routes win confirmation and non-keyboard scene intents', () => {4848 const win = harness('win');4949 win.router.route({ type: 'confirm' });50- assert.deepEqual(win.events, ['show:index']);50+ assert.deepEqual(win.events, ['delegate:confirm']);5151 5252 const workshop = harness('workshop');5353 workshop.router.route({ type: 'confirm', source: 'gamepad' });@@ -66,3 +66,18 @@ test('reduced motion continues immediately from its settled card', () => {
6666 h.flow.confirm();6767 assert.equal(h.continues, 1);6868 });69+70+test('an exit begun at clock zero is still treated as active', () => {71+ let scheduled = 0;72+ const clock = {73+ now: () => 0,74+ setTimeout: () => { scheduled += 1; return 1; },75+ clearTimeout: () => {},76+ };77+ const flow = createWinFlow({ clock });78+ flow.enter({ instant: true });79+ flow.confirm();80+ flow.confirm();81+ assert.equal(flow.exiting, true);82+ assert.equal(scheduled, 1);83+});