clone

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

Integrate slow motion and staged Win controls

Dami committed 513e6bf
commit
513e6bfe18d7d6cd48227a7added8716ecae0107
parent
7f2383b7e1ae517e3843b5b8f393a9e61b890202
author
Dami <dami.etoile@gmail.com>
9 files changed, +148 -193
Msrc/app/intent-router.js3+-
Msrc/main.js40+++++----
Dsrc/render/stage-overlays.js129---------------------------
Msrc/render/stage-win.js4+-
Msrc/stage.js141+++++++++++++++++++++---------
Msrc/stage/win-flow.js3+-
Mtests/browser/share-flow.mjs4+-
Mtests/intent-router.test.js2+-
Mtests/win-flow.test.js15+++
M src/app/intent-router.js +2 -1
M src/main.js +23 -17
@@ -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])()));
D src/render/stage-overlays.js +0 -129
@@ -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-}
M src/render/stage-win.js +1 -3
M src/stage.js +101 -40
@@ -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);
M src/stage/win-flow.js +2 -1
M tests/browser/share-flow.mjs +3 -1
M tests/intent-router.test.js +1 -1
M tests/win-flow.test.js +15 -0