- commit
- a67f26c7fbca57fc8c7388afae64efb26c345241
- parent
- 55cd31ab6802c8097bf87cdb4e9997fdfbcb73f2
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 564 | +++++++++++++++++++++++++++--- |
@@ -84,6 +84,7 @@ export function createStage(options = {}) {
8484 root.append(card);8585 document.body.append(root);8686 let scene = "proof";87+ let pausedFrom = "proof";8788 let selected = 0;8889 const pauseItems = [8990 "RESUME",@@ -96,6 +97,15 @@ export function createStage(options = {}) {
9697 let pauseStatus = { soundOn: true, musicOn: false };9798 let transitionStart = 0;9899 let transitionGeneration = 0;100+ let catCenter = null;101+ let irisCenter = null;102+ let winStart = 0;103+ let titleStart = 0;104+ let stepPopStart = 0;105+ let previousMoves = null;106+ let winningNodeId = null;107+ let indexPopStart = 0;108+ let indexPopNodeId = null;99109 const reducedMotion =100110 globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ??101111 false;@@ -112,15 +122,21 @@ export function createStage(options = {}) {
112122 live.textContent = message;113123 }114124 function drawTransition(now, generation) {115- if (!transitionStart || reducedMotion || generation !== transitionGeneration) return;125+ if (126+ !transitionStart ||127+ reducedMotion ||128+ generation !== transitionGeneration129+ )130+ return;116131 const progress = Math.max(0, Math.min(1, (now - transitionStart) / 280));117132 const radius = Math.hypot(innerWidth, innerHeight) * progress;133+ const center = irisCenter || { x: innerWidth / 2, y: innerHeight / 2 };118134 render();119135 ctx.save();120136 ctx.fillStyle = "#0F0E0D";121137 ctx.beginPath();122138 ctx.rect(0, 0, innerWidth, innerHeight);123- ctx.arc(innerWidth / 2, innerHeight / 2, radius, 0, Math.PI * 2, true);139+ ctx.arc(center.x, center.y, radius, 0, Math.PI * 2, true);124140 ctx.fill("evenodd");125141 ctx.restore();126142 if (progress < 1)@@ -147,13 +163,28 @@ export function createStage(options = {}) {
147163 );148164 ctx.restore();149165 }166+ function drawStackedAction(ctx, key, label, centerX, y) {167+ ctx.save();168+ ctx.font = "800 10px Recursive, monospace";169+ const keyWidth = Math.max(24, ctx.measureText(key).width + 12);170+ ctx.fillStyle = "#F2EEE6";171+ ctx.fillRect(centerX - keyWidth / 2, y - 10, keyWidth, 20);172+ ctx.fillStyle = "#0F0E0D";173+ ctx.textAlign = "center";174+ ctx.textBaseline = "middle";175+ ctx.fillText(key, centerX, y + 1);176+ ctx.fillStyle = "#8E877C";177+ ctx.font = "600 9px Recursive, monospace";178+ ctx.fillText(label, centerX, y + 27);179+ ctx.restore();180+ }150181 function drawTouchControls(ctx, width, height) {151182 if (width > 760) return;152183 const y = height - 30;153184 const controls = [154- { label: "UNDO", key: "Z", x: 18 },155- { label: "MOVE", key: "SWIPE", x: width / 2, center: true },156- { label: "PAUSE", key: "ESC", x: width - 18, right: true },185+ { label: "UNDO", key: "↶", x: 18 },186+ { label: "RESTART", key: "⟲", x: width / 2, center: true },187+ { label: "PAUSE", key: "☰", x: width - 18, right: true },157188 ];158189 ctx.save();159190 ctx.textBaseline = "middle";@@ -175,7 +206,168 @@ export function createStage(options = {}) {
175206 ctx.textAlign = "center";176207 ctx.fillText(text, left + boxWidth / 2, y + 1);177208 }209+ ctx.fillStyle = "#8E877C";210+ ctx.font = "500 10px Recursive, monospace";211+ ctx.textAlign = "center";212+ ctx.fillText("SWIPE TO WALK", width / 2, y - 22);213+ ctx.restore();214+ }215+ function drawMiniTile(216+ ctx,217+ text,218+ x,219+ y,220+ role = "noun",221+ size = 18,222+ opacity = 1,223+ ) {224+ const fills = {225+ noun: "#F2EEE6",226+ YOU: "#FF4B3E",227+ WIN: "#2EE6A8",228+ PUSH: "#C6B7FF",229+ STOP: "#F2C14E",230+ };231+ ctx.save();232+ ctx.globalAlpha = opacity;233+ ctx.font = `800 ${size}px Recursive, monospace`;234+ const width = Math.max(size * 1.6, ctx.measureText(text).width + size);235+ ctx.fillStyle = fills[role] || fills.noun;236+ ctx.fillRect(x, y, width, size * 1.5);237+ ctx.fillStyle = "#0F0E0D";238+ ctx.textAlign = "center";239+ ctx.textBaseline = "middle";240+ ctx.fillText(text, x + width / 2, y + size * 0.78);178241 ctx.restore();242+ return width;243+ }244+ function drawSentenceTiles(245+ ctx,246+ sentence,247+ x,248+ y,249+ maxWidth,250+ size = 16,251+ opacity = 1,252+ ) {253+ const words = String(sentence || "")254+ .split(/\s+/)255+ .filter(Boolean);256+ let cursor = x;257+ for (const word of words) {258+ const role = ["YOU", "WIN", "PUSH", "STOP"].includes(word.toUpperCase())259+ ? word.toUpperCase()260+ : "noun";261+ const width = drawMiniTile(262+ ctx,263+ word.toUpperCase(),264+ cursor,265+ y,266+ role,267+ size,268+ opacity,269+ );270+ cursor += width + Math.max(5, size * 0.4);271+ if (cursor > x + maxWidth) break;272+ }273+ return cursor;274+ }275+ function drawLedger(ctx, rules, x, y, maxWidth) {276+ const lineHeight = 26;277+ const sentenceGap = 26;278+ const tileGap = 7;279+ let cursor = x;280+ let lineY = y;281+ for (const rule of rules || []) {282+ const words = String(rule.text || rule)283+ .split(/\s+/)284+ .filter(Boolean);285+ ctx.font = "800 10px Recursive, monospace";286+ const sentenceWidth =287+ words.reduce(288+ (total, word) =>289+ total +290+ Math.max(16, ctx.measureText(word.toUpperCase()).width + 10),291+ 0,292+ ) +293+ Math.max(0, words.length - 1) * tileGap;294+ if (295+ cursor !== x &&296+ sentenceWidth <= maxWidth &&297+ cursor + sentenceWidth > x + maxWidth298+ ) {299+ lineY += lineHeight;300+ cursor = x;301+ }302+ for (const word of words) {303+ ctx.font = "800 10px Recursive, monospace";304+ const tileWidth = Math.max(305+ 16,306+ ctx.measureText(word.toUpperCase()).width + 10,307+ );308+ if (cursor !== x && cursor + tileWidth > x + maxWidth) {309+ lineY += lineHeight;310+ cursor = x;311+ }312+ const role = ["YOU", "WIN", "PUSH", "STOP"].includes(word.toUpperCase())313+ ? word.toUpperCase()314+ : "noun";315+ drawMiniTile(ctx, word.toUpperCase(), cursor, lineY, role, 10);316+ cursor += tileWidth + tileGap;317+ }318+ cursor += sentenceGap;319+ }320+ lineY += lineHeight;321+ cursor = x;322+ for (const word of ["TEXT", "IS", "PUSH"]) {323+ ctx.font = "800 10px Recursive, monospace";324+ const tileWidth = Math.max(16, ctx.measureText(word).width + 10);325+ drawMiniTile(326+ ctx,327+ word,328+ cursor,329+ lineY,330+ word === "PUSH" ? "PUSH" : "noun",331+ 10,332+ 0.42,333+ );334+ cursor += tileWidth + tileGap;335+ }336+ return { bottom: lineY + 15 };337+ }338+ function drawHint(ctx, hint, x, y, maxWidth, maxBottom) {339+ const words = `HINT ${hint}`.split(/\s+/);340+ ctx.save();341+ ctx.fillStyle = "#8E877C";342+ ctx.font = "500 11px Recursive, monospace";343+ ctx.textAlign = "left";344+ ctx.textBaseline = "top";345+ let line = "";346+ let lineY = y;347+ for (const word of words) {348+ const next = line ? `${line} ${word}` : word;349+ if (line && ctx.measureText(next).width > maxWidth) {350+ ctx.fillText(line, x, lineY);351+ line = word;352+ lineY += 16;353+ } else line = next;354+ }355+ if (line && lineY <= maxBottom) ctx.fillText(line, x, lineY);356+ ctx.restore();357+ }358+ function animateVisuals(now) {359+ render();360+ const winActive = scene === "win" && winStart && now - winStart < 620;361+ const titleActive =362+ scene === "title" && titleStart && now - titleStart < 520;363+ const stepActive = stepPopStart && now - stepPopStart < 240;364+ const indexActive =365+ scene === "index" && indexPopStart && now - indexPopStart < 520;366+ if (367+ !reducedMotion &&368+ (winActive || titleActive || stepActive || indexActive)369+ )370+ requestAnimationFrame(animateVisuals);179371 }180372 function render() {181373 const w = innerWidth,@@ -183,7 +375,20 @@ export function createStage(options = {}) {
183375 ctx.fillStyle = "#0F0E0D";184376 ctx.fillRect(0, 0, w, h);185377 if (scene === "title" && title) {186- title.draw(ctx, { width: w, height: h });378+ const drawn = title.draw(ctx, {379+ width: w,380+ height: h,381+ now: performance.now(),382+ reducedMotion,383+ });384+ const cat = drawn?.state?.objects?.find(385+ (object) => object.noun === "MIAU",386+ );387+ if (cat && drawn.geometry)388+ catCenter = {389+ x: drawn.geometry.x + (cat.x + 0.5) * drawn.geometry.cell,390+ y: drawn.geometry.y + (cat.y + 0.5) * drawn.geometry.cell,391+ };187392 return;188393 }189394 if (scene === "gift" && gift) {@@ -202,14 +407,14 @@ export function createStage(options = {}) {
202407 Math.floor(203408 Math.min(204409 (w - 80) / boardState.width,205- (h - 240) / boardState.height,410+ (h - 320) / boardState.height,206411 ),207412 ),208413 ),209414 );210415 const x = Math.floor((w - boardState.width * cell) / 2),211- y = Math.floor((h - boardState.height * cell) / 2 - 30);212- drawBoard(ctx, boardState, {416+ y = Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30));417+ const geometry = drawBoard(ctx, boardState, {213418 x,214419 y,215420 cell,@@ -218,36 +423,69 @@ export function createStage(options = {}) {
218423 glyphs: boardGlyphs,219424 frame: boardFrame,220425 });426+ const controlled = boardState.objects?.find(427+ (object) =>428+ object.kind === "object" &&429+ boardGame?.properties(object)?.includes("YOU"),430+ );431+ if (controlled)432+ catCenter = {433+ x: geometry.x + (controlled.x + 0.5) * geometry.cell,434+ y: geometry.y + (controlled.y + 0.5) * geometry.cell,435+ };221436 ctx.textAlign = "left";222437 ctx.textBaseline = "top";438+ const proofNumber = String(boardState.proofNumber ?? 1).padStart(2, "0");223439 ctx.fillStyle = "#F2EEE6";224- ctx.font = "600 13px Recursive, monospace";225- ctx.fillText("PROOF", 32, 38);226- ctx.font = "500 12px Recursive, monospace";440+ ctx.font = "800 11px Recursive, monospace";441+ ctx.fillRect(32, 30, 34, 26);442+ ctx.fillStyle = "#0F0E0D";443+ ctx.textAlign = "center";444+ ctx.textBaseline = "middle";445+ ctx.fillText(proofNumber, 49, 44);446+ ctx.textAlign = "left";447+ ctx.fillStyle = "#F2EEE6";448+ ctx.font = "800 22px Recursive, monospace";449+ ctx.fillText(boardState.title || "Proof", 78, 31);450+ ctx.font = "600 10px Recursive, monospace";227451 ctx.fillStyle = "#8E877C";228452 ctx.fillText(229- `${boardState.title || ""} ${boardState.moves || 0} STEPS`,230- 32,231- 60,453+ `PROOF ${proofNumber} OF ${String(boardState.proofTotal ?? 9).padStart(2, "0")}`,454+ 78,455+ 58,232456 );233- const rules = (boardState.rules || [])234- .map((rule) => rule.text)235- .join(" ");457+ ctx.textAlign = "right";236458 ctx.fillStyle = "#F2EEE6";237- ctx.font = "600 12px Recursive, monospace";238- ctx.fillText(239- rules || "NO RULES ACTIVE",459+ ctx.font = "800 40px Recursive, monospace";460+ const stepProgress = stepPopStart461+ ? Math.min(1, (performance.now() - stepPopStart) / 220)462+ : 1;463+ const stepScale = 1 + (stepProgress < 1 ? 0.14 * (1 - stepProgress) : 0);464+ ctx.save();465+ ctx.translate(w - 32, 39);466+ ctx.scale(stepScale, stepScale);467+ ctx.fillText(String(boardState.moves || 0).padStart(2, "0"), 0, 0);468+ ctx.restore();469+ ctx.font = "600 10px Recursive, monospace";470+ ctx.fillStyle = "#8E877C";471+ ctx.fillText("STEPS", w - 32, 70);472+ ctx.textAlign = "left";473+ const ledger = drawLedger(474+ ctx,475+ boardState.rules,240476 x,241- y + boardState.height * cell + 38,477+ Math.min(y + boardState.height * cell + 46, h - 112),478+ w - x - 32,242479 );243480 const hint = String(boardState.hint || "").trim();244481 if (hint) {245- ctx.fillStyle = "#8E877C";246- ctx.font = "500 11px Recursive, monospace";247- ctx.fillText(248- `HINT ${hint}`,482+ drawHint(483+ ctx,484+ hint,249485 x,250- Math.min(y + boardState.height * cell + 58, h - 58),486+ Math.min(ledger.bottom + 12, h - 78),487+ w - x - 32,488+ h - 60,251489 );252490 }253491 if (w > 760) {@@ -261,9 +499,34 @@ export function createStage(options = {}) {
261499 availableWidth: w,262500 availableHeight: h,263501 cat: boardGlyphs.MIAU || "🐱",502+ popNodeId: indexPopNodeId,503+ popProgress: indexPopStart504+ ? Math.min(1, (performance.now() - indexPopStart) / 520)505+ : 1,506+ reducedMotion,264507 });508+ const active = indexGeometry.nodes?.find((node) => node.active);509+ if (active) catCenter = { x: active.centerX, y: active.centerY };265510 } else if (scene === "pause") {266- if (boardState) {511+ if (pausedFrom === "index" && indexLayout && indexNavigator) {512+ indexGeometry = drawIndex(ctx, indexLayout, indexNavigator, {513+ availableWidth: w,514+ availableHeight: h,515+ cat: boardGlyphs.MIAU || "🐱",516+ popNodeId: indexPopNodeId,517+ popProgress: indexPopStart518+ ? Math.min(1, (performance.now() - indexPopStart) / 520)519+ : 1,520+ reducedMotion,521+ });522+ } else if (pausedFrom === "title" && title) {523+ title.draw(ctx, {524+ width: w,525+ height: h,526+ now: performance.now(),527+ reducedMotion,528+ });529+ } else if (boardState) {267530 const cell = Math.min(268531 96,269532 Math.max(@@ -271,14 +534,14 @@ export function createStage(options = {}) {
271534 Math.floor(272535 Math.min(273536 (w - 80) / boardState.width,274- (h - 240) / boardState.height,537+ (h - 320) / boardState.height,275538 ),276539 ),277540 ),278541 );279542 drawBoard(ctx, boardState, {280543 x: Math.floor((w - boardState.width * cell) / 2),281- y: Math.floor((h - boardState.height * cell) / 2 - 30),544+ y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)),282545 cell,283546 game: boardGame,284547 palette: boardPalette,@@ -286,41 +549,156 @@ export function createStage(options = {}) {
286549 frame: boardFrame,287550 });288551 }289- ctx.fillStyle = "rgba(15,14,13,.84)";552+ ctx.fillStyle = "rgba(0,0,0,.74)";290553 ctx.fillRect(0, 0, w, h);291554 ctx.textAlign = "center";292555 ctx.textBaseline = "middle";293556 ctx.fillStyle = "#FF4B3E";294557 ctx.font = "700 13px Recursive, monospace";295- ctx.fillText("PAUSED", w / 2, Math.max(90, h * 0.18));558+ ctx.fillText("PAUSED", w / 2, Math.max(75, h * 0.12));296559 pauseItems.forEach((item, i) => {297- const y = h * 0.27 + i * 48;560+ const y = h * 0.24 + i * 54;561+ ctx.fillStyle = "#000000";562+ ctx.fillRect(w / 2 - 154, y - 21, 320, 54);298563 ctx.fillStyle = i === selected ? "#FF4B3E" : "#232120";299- ctx.fillRect(w / 2 - 160, y - 19, 320, 38);564+ ctx.fillRect(w / 2 - 160, y - 27, 320, 54);565+ ctx.fillStyle = "#0F0E0D";566+ ctx.font = "800 20px 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif";567+ ctx.textAlign = "right";568+ ctx.fillText(i === selected ? "🐾" : "", w / 2 - 176, y + 1);300569 ctx.fillStyle = i === selected ? "#0F0E0D" : "#F2EEE6";301570 ctx.font = "800 13px Recursive, monospace";571+ ctx.textAlign = "center";302572 ctx.fillText(item, w / 2, y + 1);303573 });304574 ctx.textAlign = "left";575+ ctx.fillStyle = "#8E877C";576+ ctx.font = "500 11px Recursive, monospace";577+ ctx.fillText(578+ `HINT ${String(boardState?.hint || "Choose an action").trim()}`,579+ 32,580+ h - 86,581+ );582+ drawKeycap(ctx, "↑↓", "CHOOSE", 32, h - 46);583+ drawKeycap(ctx, "ENTER", "SELECT", w / 2 - 4, h - 46, "right");584+ drawKeycap(ctx, "ESC", "BACK", w - 32, h - 46, "right");305585 } else if (scene === "win") {306- ctx.fillStyle = "rgba(15,14,13,.86)";586+ if (boardState) {587+ const cell = Math.min(588+ 96,589+ Math.max(590+ 22,591+ Math.floor(592+ Math.min(593+ (w - 80) / boardState.width,594+ (h - 320) / boardState.height,595+ ),596+ ),597+ ),598+ );599+ drawBoard(ctx, boardState, {600+ x: Math.floor((w - boardState.width * cell) / 2),601+ y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)),602+ cell,603+ game: boardGame,604+ palette: boardPalette,605+ glyphs: boardGlyphs,606+ frame: boardFrame,607+ });608+ }609+ ctx.fillStyle = "rgba(0,0,0,.66)";307610 ctx.fillRect(0, 0, w, h);308- ctx.textAlign = "center";611+ const cardWidth = Math.min(620, w - 48);612+ const cardX = (w - cardWidth) / 2;613+ const winProgress =614+ reducedMotion || !winStart615+ ? 1616+ : Math.min(1, (performance.now() - winStart) / 520);617+ const back = 1.70158;618+ const rawBackEase =619+ 1 +620+ (back + 1) * Math.pow(winProgress - 1, 3) +621+ back * Math.pow(winProgress - 1, 2);622+ const winEase = Math.max(0, Math.min(1.1, rawBackEase));623+ const cardY = Math.max(120, h * 0.25) + (1 - winEase) * 80;624+ ctx.fillStyle = "#191816";625+ ctx.fillRect(cardX + 8, cardY + 8, cardWidth, 250);626+ ctx.fillStyle = "#F2EEE6";627+ ctx.fillRect(cardX, cardY, cardWidth, 250);628+ ctx.fillStyle = "#0F0E0D";629+ ctx.fillRect(cardX + 2, cardY + 2, cardWidth - 4, 246);630+ ctx.textAlign = "left";631+ ctx.fillStyle = "#8E877C";632+ ctx.font = "600 10px Recursive, monospace";633+ const winningRule =634+ boardState?.rules?.find((rule) => rule.predicate === "WIN")?.text ||635+ boardState?.rules?.[0]?.text ||636+ "MIAU IS WIN";637+ drawSentenceTiles(638+ ctx,639+ winningRule,640+ cardX + 24,641+ cardY + 54,642+ cardWidth - 48,643+ Math.min(22, Math.max(14, cardWidth * 0.035)),644+ );309645 ctx.fillStyle = "#2EE6A8";310- ctx.font = "800 13px Recursive, monospace";311- ctx.fillText("PROOF COMPLETE", w / 2, h * 0.34);646+ ctx.fillRect(647+ cardX + 24,648+ cardY + 100,649+ (cardWidth - 48) * (reducedMotion ? 1 : Math.min(1, winProgress * 1.5)),650+ 3,651+ );652+ ctx.textAlign = "center";312653 ctx.fillStyle = "#F2EEE6";313- ctx.font = `800 ${Math.min(56, Math.max(30, w * 0.055))}px Recursive, monospace`;654+ const headline = `PROVEN IN ${boardState?.moves || 0} STEPS`;655+ let headlineSize = Math.min(56, Math.max(26, w * 0.055));656+ do {657+ ctx.font = `800 ${headlineSize}px Recursive, monospace`;658+ if (659+ ctx.measureText(headline).width <= cardWidth - 40 ||660+ headlineSize <= 22661+ )662+ break;663+ headlineSize -= 2;664+ } while (headlineSize > 22);665+ ctx.fillText(headline, w / 2, cardY + 154);666+ ctx.fillStyle = "#8E877C";667+ ctx.font = "600 12px Recursive, monospace";314668 ctx.fillText(315- `PROVEN IN ${boardState?.moves || 0} STEPS`,669+ `${boardState?.completedCount ?? 0} of ${boardState?.proofTotal ?? 9} proofs complete`,316670 w / 2,317- h * 0.47,671+ cardY + 182,318672 );319- ctx.fillStyle = "#8E877C";320- ctx.font = "600 12px Recursive, monospace";321- drawKeycap(ctx, "ENTER", "OPEN INDEX", w / 2 - 4, h * 0.67, "right");322- drawKeycap(ctx, "Z", "UNDO", w / 2 + 18, h * 0.67);323- drawKeycap(ctx, "R", "RESTART", w / 2 + 74, h * 0.67);673+ if (w <= 760) {674+ const actionY = cardY + 218;675+ drawStackedAction(676+ ctx,677+ "ENTER",678+ "CONTINUE",679+ cardX + cardWidth / 6,680+ actionY,681+ );682+ drawStackedAction(ctx, "Z", "UNDO", cardX + cardWidth / 2, actionY);683+ drawStackedAction(684+ ctx,685+ "R",686+ "RESTART",687+ cardX + (cardWidth * 5) / 6,688+ actionY,689+ );690+ } else {691+ drawKeycap(ctx, "ENTER", "CONTINUE", cardX + 24, cardY + 220);692+ drawKeycap(ctx, "Z", "UNDO", w / 2 - 4, cardY + 220, "right");693+ drawKeycap(694+ ctx,695+ "R",696+ "RESTART",697+ cardX + cardWidth - 24,698+ cardY + 220,699+ "right",700+ );701+ }324702 if (w <= 760) drawTouchControls(ctx, w, h);325703 ctx.textAlign = "left";326704 } else {@@ -343,6 +721,54 @@ export function createStage(options = {}) {
343721 }344722 }345723 function show(id, data = {}) {724+ irisCenter = catCenter;725+ if (id === "win") {726+ winStart = performance.now();727+ winningNodeId =728+ data.nodeId ||729+ indexNavigator?.current?.node?.id ||730+ boardState?.levelId ||731+ boardState?.id ||732+ null;733+ }734+ if (id === "title") titleStart = performance.now();735+ if (id === "index" && winningNodeId) {736+ indexPopNodeId = winningNodeId;737+ indexPopStart = performance.now();738+ }739+ if (id === "pause" && scene !== "pause") {740+ pausedFrom = scene;741+ selected = 0;742+ pauseItems.splice(743+ 0,744+ pauseItems.length,745+ ...(pausedFrom === "index" || pausedFrom === "title"746+ ? ["RESUME", "SOUND ON", "MUSIC OFF", "TITLE"]747+ : [748+ "RESUME",749+ "RESTART",750+ "INDEX",751+ "WORKSHOP",752+ "SOUND ON",753+ "MUSIC OFF",754+ ]),755+ );756+ pauseItems.splice(757+ 0,758+ pauseItems.length,759+ ...pauseItems.map((item) =>760+ item.startsWith("SOUND")761+ ? pauseStatus.soundOn762+ ? "SOUND ON"763+ : "SOUND OFF"764+ : item.startsWith("MUSIC")765+ ? pauseStatus.musicOn766+ ? "MUSIC ON"767+ : "MUSIC OFF"768+ : item,769+ ),770+ );771+ }346772 scene = id;347773 const spec = SCENES[id] || proofScene;348774 eyebrow.textContent = spec.eyebrow;@@ -357,6 +783,8 @@ export function createStage(options = {}) {
357783 root.dataset.scene = id;358784 announce(`${spec.eyebrow || ""} ${titleEl.textContent}`.trim());359785 render();786+ if (!reducedMotion && (id === "title" || id === "win" || id === "index"))787+ requestAnimationFrame(animateVisuals);360788 transitionStart = performance.now();361789 const generation = ++transitionGeneration;362790 requestAnimationFrame((time) => drawTransition(time, generation));@@ -365,11 +793,14 @@ export function createStage(options = {}) {
365793 transitionStart = 0;366794 transitionGeneration += 1;367795 root.hidden = true;368- scene = "proof";796+ scene = scene === "pause" ? pausedFrom : "proof";369797 root.dataset.scene = scene;370798 render();371799 }372800 function setBoard(state, game, palette = {}, glyphs = {}, frame = null) {801+ if (previousMoves !== null && state?.moves !== previousMoves)802+ stepPopStart = performance.now();803+ previousMoves = state?.moves ?? null;373804 boardState = state;374805 boardGame = game;375806 boardPalette = palette;@@ -394,8 +825,12 @@ export function createStage(options = {}) {
394825 }395826 function setPauseStatus(value = {}) {396827 pauseStatus = { ...pauseStatus, ...value };397- pauseItems[4] = pauseStatus.soundOn ? "SOUND ON" : "SOUND OFF";398- pauseItems[5] = pauseStatus.musicOn ? "MUSIC ON" : "MUSIC OFF";828+ for (let i = 0; i < pauseItems.length; i++) {829+ if (pauseItems[i].startsWith("SOUND"))830+ pauseItems[i] = pauseStatus.soundOn ? "SOUND ON" : "SOUND OFF";831+ if (pauseItems[i].startsWith("MUSIC"))832+ pauseItems[i] = pauseStatus.musicOn ? "MUSIC ON" : "MUSIC OFF";833+ }399834 render();400835 return api;401836 }@@ -413,12 +848,23 @@ export function createStage(options = {}) {
413848 return;414849 // Gameplay intents for title, proof, and win use the shared input router.415850 if (scene === "title" || scene === "proof" || scene === "win") return;851+ if (852+ [853+ "Escape",854+ "Enter",855+ "ArrowUp",856+ "ArrowDown",857+ "ArrowLeft",858+ "ArrowRight",859+ ].includes(event.key)860+ )861+ event.stopPropagation();416862 if (event.key === "Escape") {417863 if (scene === "workshop") workshop?.handleInput?.("escape");418864 else if (scene === "gift") {419865 gift?.input("escape");420866 show("title");421- } else if (scene === "index") show("title");867+ } else if (scene === "index") show("pause");422868 else hide();423869 render();424870 event.preventDefault();@@ -497,6 +943,7 @@ export function createStage(options = {}) {
497943 else if (action === "RESTART") options.onPauseAction?.("restart");498944 else if (action === "INDEX") show("index");499945 else if (action === "WORKSHOP") show("workshop");946+ else if (action === "TITLE") show("title");500947 else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");501948 else options.onPauseAction?.("music");502949 event.preventDefault();@@ -558,15 +1005,15 @@ export function createStage(options = {}) {
5581005 }5591006 }5601007 if (scene === "proof" && innerWidth <= 760) {561- if (y >= innerHeight - 58 && x < innerWidth * 0.32)1008+ if (y >= innerHeight - 50 && x < innerWidth * 0.32)5621009 document.querySelector("#undo-btn")?.click();5631010 else if (564- y >= innerHeight - 84 &&565- y < innerHeight - 42 &&566- x < innerWidth * 0.321011+ y >= innerHeight - 50 &&1012+ x >= innerWidth * 0.32 &&1013+ x <= innerWidth * 0.685671014 )5681015 document.querySelector("#restart-btn")?.click();569- else if (y >= innerHeight - 58 && x > innerWidth * 0.68) show("pause");1016+ else if (y >= innerHeight - 50 && x > innerWidth * 0.68) show("pause");5701017 }5711018 if (scene === "gift") {5721019 const result = gift?.hit(x, y);@@ -578,13 +1025,14 @@ export function createStage(options = {}) {
5781025 x >= innerWidth / 2 - 160 &&5791026 x <= innerWidth / 2 + 1605801027 ) {581- const index = Math.round((y - innerHeight * 0.27) / 48);1028+ const index = Math.round((y - innerHeight * 0.24) / 54);5821029 if (index >= 0 && index < pauseItems.length) {5831030 selected = index;5841031 const action = pauseItems[index];5851032 if (action === "RESUME") hide();5861033 else if (action === "INDEX") show("index");5871034 else if (action === "WORKSHOP") show("workshop");1035+ else if (action === "TITLE") show("title");5881036 else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");5891037 else if (action.startsWith("MUSIC")) options.onPauseAction?.("music");5901038 else options.onPauseAction?.("restart");@@ -608,6 +1056,7 @@ export function createStage(options = {}) {
6081056 if (action === "RESUME") hide();6091057 else if (action === "INDEX") show("index");6101058 else if (action === "WORKSHOP") show("workshop");1059+ else if (action === "TITLE") show("title");6111060 else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");6121061 else if (action.startsWith("MUSIC")) options.onPauseAction?.("music");6131062 else options.onPauseAction?.("restart");@@ -629,7 +1078,7 @@ export function createStage(options = {}) {
6291078 return true;6301079 }6311080 if (type === "menu") {632- hide();1081+ show("pause");6331082 return true;6341083 }6351084 }@@ -671,6 +1120,7 @@ export function createStage(options = {}) {
6711120 setControllers,6721121 setActions,6731122 setPauseStatus,1123+ announce,6741124 handleIntent,6751125 render,6761126 canvas,