- commit
- 60fa0c017c61d20d13845ca00f0fc6cf6e77f5a6
- parent
- 74c94f1c1a92a9634b0c089e5091127697ebaf0b
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 427 | ++++++++++++ |
| A | src/ | 242 | +++++++ |
| A | src/ | 173 | +++++ |
| M | src/ | 1090 | +++--------------------------- |
| A | src/ | 34 | + |
| A | tests/ | 11 | + |
@@ -0,0 +1,427 @@
1+export function drawKeycap(ctx, key, label, x, y, align = "left") {2+ ctx.save();3+ ctx.textBaseline = "middle";4+ ctx.font = "800 10px Recursive, monospace";5+ const keyWidth = Math.max(24, ctx.measureText(key).width + 12);6+ ctx.font = "600 10px Recursive, monospace";7+ const labelWidth = ctx.measureText(label).width;8+ const left = align === "right" ? x - keyWidth - 8 - labelWidth : x;9+ ctx.fillStyle = "#000000";10+ ctx.fillRect(left + 4, y - 6, keyWidth, 20);11+ ctx.fillStyle = "#F2EEE6";12+ ctx.fillRect(left, y - 10, keyWidth, 20);13+ ctx.fillStyle = "#0F0E0D";14+ ctx.textAlign = "left";15+ ctx.font = "800 10px Recursive, monospace";16+ ctx.fillText(key, left + 6, y + 1);17+ ctx.font = "600 10px Recursive, monospace";18+ ctx.fillStyle = "#8E877C";19+ ctx.fillText(label, left + keyWidth + 8, y + 1);20+ ctx.restore();21+}22+23+export function drawInlineActions(ctx, items, centerX, y) {24+ ctx.save();25+ ctx.font = "800 10px Recursive, monospace";26+ const metrics = items.map(({ key, label }) => {27+ const keyWidth = Math.max(24, ctx.measureText(key).width + 12);28+ ctx.font = "600 10px Recursive, monospace";29+ const labelWidth = ctx.measureText(label).width;30+ ctx.font = "800 10px Recursive, monospace";31+ return {32+ key,33+ label,34+ keyWidth,35+ labelWidth,36+ width: keyWidth + 8 + labelWidth,37+ };38+ });39+ const gap = 18;40+ const total =41+ metrics.reduce((sum, item) => sum + item.width, 0) +42+ gap * (metrics.length - 1);43+ let x = centerX - total / 2;44+ for (const item of metrics) {45+ drawKeycap(ctx, item.key, item.label, x, y);46+ x += item.width + gap;47+ }48+ ctx.restore();49+}50+51+export function drawStackedAction(ctx, key, label, centerX, y) {52+ ctx.save();53+ ctx.font = "800 10px Recursive, monospace";54+ const keyWidth = Math.max(24, ctx.measureText(key).width + 12);55+ ctx.fillStyle = "#F2EEE6";56+ ctx.fillRect(centerX - keyWidth / 2, y - 10, keyWidth, 20);57+ ctx.fillStyle = "#0F0E0D";58+ ctx.textAlign = "center";59+ ctx.textBaseline = "middle";60+ ctx.fillText(key, centerX, y + 1);61+ ctx.fillStyle = "#8E877C";62+ ctx.font = "600 9px Recursive, monospace";63+ ctx.fillText(label, centerX, y + 27);64+ ctx.restore();65+}66+67+export function drawTouchControls(68+ ctx,69+ width,70+ height,71+ force = false,72+ hasAutomata = false,73+) {74+ if (width > 760 && !force) return;75+ const y = height - 30;76+ const controls = [77+ { label: "UNDO", key: "↶", x: 18 },78+ { label: "RESTART", key: "⟲", x: width / 2, center: true },79+ { label: "PAUSE", key: "☰", x: width - 18, right: true },80+ ];81+ ctx.save();82+ ctx.textBaseline = "middle";83+ for (const control of controls) {84+ const text = `${control.key} ${control.label}`;85+ ctx.font = "700 10px Recursive, monospace";86+ const boxWidth = ctx.measureText(text).width + 18;87+ const left = control.center88+ ? control.x - boxWidth / 289+ : control.right90+ ? control.x - boxWidth91+ : control.x;92+ ctx.fillStyle = "#232120";93+ ctx.strokeStyle = "#4A4640";94+ ctx.lineWidth = 1;95+ ctx.fillRect(left, y - 14, boxWidth, 28);96+ ctx.strokeRect(left + 0.5, y - 13.5, boxWidth - 1, 27);97+ ctx.fillStyle = "#F2EEE6";98+ ctx.textAlign = "center";99+ ctx.fillText(text, left + boxWidth / 2, y + 1);100+ }101+ ctx.fillStyle = "#8E877C";102+ ctx.font = "500 10px Recursive, monospace";103+ ctx.textAlign = "center";104+ if (!hasAutomata) ctx.fillText("SWIPE TO WALK", width / 2, y - 22);105+ if (hasAutomata) {106+ const waitText = "WAIT";107+ ctx.font = "700 10px Recursive, monospace";108+ const waitWidth = Math.max(96, ctx.measureText(waitText).width + 18);109+ const waitLeft = width / 2 - waitWidth / 2;110+ ctx.fillStyle = "#232120";111+ ctx.strokeStyle = "#4A4640";112+ ctx.fillRect(waitLeft, height - 84, waitWidth, 32);113+ ctx.strokeRect(waitLeft + 0.5, height - 83.5, waitWidth - 1, 31);114+ ctx.fillStyle = "#F2EEE6";115+ ctx.fillText(waitText, width / 2, height - 68);116+ }117+ ctx.restore();118+}119+120+function automataGlyph(subject, palette = {}, glyphs = {}) {121+ const entry = palette?.[String(subject || "").toUpperCase()];122+ if (entry && typeof entry === "object" && entry.glyph) return entry.glyph;123+ if (typeof entry === "string" && entry.length) return entry;124+ return glyphs?.[String(subject || "").toUpperCase()] || "⚙️";125+}126+127+function automataCue(action) {128+ if (typeof action?.cue === "string" && action.cue.trim())129+ return action.cue.trim().slice(0, 16);130+ if (action?.action === "wait") return "WAIT";131+ if (action?.dx === 1) return "→";132+ if (action?.dx === -1) return "←";133+ if (action?.dy === 1) return "↓";134+ if (action?.dy === -1) return "↑";135+ return "?";136+}137+138+function isEmojiCue(value) {139+ return Array.from(String(value || "")).some((character) => {140+ const code = character.codePointAt(0);141+ return code > 0x1f000 || character === "\uFE0F";142+ });143+}144+145+export function drawAutomataStrip(146+ ctx,147+ automata,148+ x,149+ y,150+ maxWidth,151+ rules = [],152+ options = {},153+) {154+ const viewportWidth = options.viewportWidth ?? maxWidth;155+ const palette = options.palette || {};156+ const glyphs = options.glyphs || {};157+ const rows = Array.isArray(automata)158+ ? automata.filter((item) => item?.pattern?.length)159+ : [];160+ if (!rows.length) return { bottom: y, height: 0 };161+ const visibleRows = rows.slice(0, viewportWidth <= 760 ? 2 : 3);162+ const rowHeight = viewportWidth <= 760 ? 24 : 30;163+ const labelWidth = 76;164+ const beatGap = 5;165+ const maxBeats = Math.max(4, Math.floor((maxWidth - labelWidth) / 22));166+ ctx.save();167+ ctx.textBaseline = "middle";168+ visibleRows.forEach((system, rowIndex) => {169+ const rowY = y + rowIndex * rowHeight;170+ const phase = Number.isInteger(system.phase)171+ ? system.phase % system.pattern.length172+ : 0;173+ const derivedSubject =174+ system.subject ||175+ rules.find(176+ (rule) =>177+ String(rule.predicate || "").toUpperCase() ===178+ String(system.property || "").toUpperCase(),179+ )?.subject;180+ const beatIndexes =181+ system.pattern.length <= maxBeats182+ ? system.pattern.map((_, index) => index)183+ : [184+ ...new Set([185+ phase,186+ (phase + 1) % system.pattern.length,187+ (phase + system.pattern.length - 1) % system.pattern.length,188+ (phase + 2) % system.pattern.length,189+ ]),190+ ];191+ const omitted = system.pattern.length - beatIndexes.length;192+ const beatWidth = Math.max(193+ 14,194+ Math.min(195+ 34,196+ (maxWidth - labelWidth - beatGap * (beatIndexes.length - 1)) /197+ beatIndexes.length,198+ ),199+ );200+ const glyph = automataGlyph(derivedSubject, palette, glyphs);201+ ctx.fillStyle = "#F2EEE6";202+ ctx.font = `${Math.min(22, beatWidth)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;203+ ctx.textAlign = "left";204+ ctx.fillText(glyph, x, rowY + rowHeight / 2);205+ ctx.fillStyle = "#8E877C";206+ ctx.font = "700 9px Recursive, monospace";207+ ctx.fillText(208+ String(system.property || "").toUpperCase() || "AUTOMATA",209+ x + 28,210+ rowY + 10,211+ );212+ ctx.font = "600 9px Recursive, monospace";213+ ctx.fillText(214+ String(system.active ? "ACTIVE" : "DORMANT"),215+ x + 28,216+ rowY + 21,217+ );218+ beatIndexes.forEach((patternIndex, beatIndex) => {219+ const action = system.pattern[patternIndex];220+ const beatX = x + labelWidth + beatIndex * (beatWidth + beatGap);221+ const current = patternIndex === phase;222+ const next = patternIndex === (phase + 1) % system.pattern.length;223+ ctx.globalAlpha = current || next ? 1 : 0.4;224+ ctx.fillStyle = current ? "#C6F432" : "#191816";225+ ctx.strokeStyle = current || next ? "#C6F432" : "#4A4640";226+ ctx.lineWidth = current ? 2 : 1;227+ ctx.fillRect(beatX, rowY + 4, beatWidth, 22);228+ if (!current) ctx.strokeRect(beatX + 0.5, rowY + 4.5, beatWidth - 1, 21);229+ const cue =230+ action.action === "wait" && beatWidth < 24 ? "○" : automataCue(action);231+ const emojiCue = isEmojiCue(cue);232+ const cueSize = emojiCue233+ ? Math.min(16, beatWidth * 0.58)234+ : Math.min(235+ cue.length > 4 || action.action === "wait" ? 7 : 13,236+ beatWidth * 0.8,237+ );238+ ctx.fillStyle = current ? "#0F0E0D" : next ? "#0F0E0D" : "#8E877C";239+ ctx.font = `${current ? "800" : "600"} ${cueSize}px ${emojiCue ? '"Apple Color Emoji", "Segoe UI Emoji", sans-serif' : "Recursive, monospace"}`;240+ ctx.textAlign = "center";241+ ctx.fillText(cue, beatX + beatWidth / 2, rowY + 16);242+ ctx.globalAlpha = 1;243+ if (omitted && beatIndex === beatIndexes.length - 1) {244+ ctx.fillStyle = "#8E877C";245+ ctx.font = "600 8px Recursive, monospace";246+ ctx.fillText(247+ `+${omitted}`,248+ beatX + beatWidth / 2,249+ rowY + rowHeight - 4,250+ );251+ }252+ });253+ });254+ if (rows.length > visibleRows.length) {255+ ctx.fillStyle = "#8E877C";256+ ctx.font = "600 9px Recursive, monospace";257+ ctx.textAlign = "right";258+ ctx.fillText(259+ `+${rows.length - visibleRows.length} automata`,260+ x + maxWidth,261+ y + visibleRows.length * rowHeight - 4,262+ );263+ }264+ ctx.restore();265+ return {266+ bottom: y + visibleRows.length * rowHeight,267+ height: visibleRows.length * rowHeight,268+ };269+}270+271+export function drawMiniTile(272+ ctx,273+ text,274+ x,275+ y,276+ role = "noun",277+ size = 18,278+ opacity = 1,279+) {280+ const fills = {281+ noun: "#F2EEE6",282+ YOU: "#FF4B3E",283+ WIN: "#2EE6A8",284+ PUSH: "#F2C14E",285+ STOP: "#C6B7FF",286+ TICK: "#C6F432",287+ IS: "#F2EEE6",288+ AND: "#F2EEE6",289+ };290+ ctx.save();291+ ctx.globalAlpha = opacity;292+ ctx.font = `800 ${size}px Recursive, monospace`;293+ const width = Math.max(size * 1.6, ctx.measureText(text).width + size);294+ if (role === "IS" || role === "AND") {295+ ctx.strokeStyle = "#8E877C";296+ ctx.lineWidth = 1;297+ ctx.beginPath();298+ if (ctx.roundRect) ctx.roundRect(x, y + 1, width, size * 1.2, size * 0.6);299+ else ctx.rect(x, y + 1, width, size * 1.2);300+ ctx.stroke();301+ } else {302+ ctx.fillStyle = "#000000";303+ ctx.fillRect(x + 3, y + 3, width, size * 1.5);304+ ctx.fillStyle = fills[role] || fills.noun;305+ ctx.fillRect(x, y, width, size * 1.5);306+ }307+ ctx.fillStyle = role === "IS" || role === "AND" ? "#F2EEE6" : "#0F0E0D";308+ ctx.textAlign = "center";309+ ctx.textBaseline = "middle";310+ ctx.fillText(text, x + width / 2, y + size * 0.78);311+ ctx.restore();312+ return width;313+}314+315+export function drawSentenceTiles(316+ ctx,317+ sentence,318+ x,319+ y,320+ maxWidth,321+ size = 16,322+ opacity = 1,323+) {324+ const words = String(sentence || "")325+ .split(/\s+/)326+ .filter(Boolean);327+ let cursor = x;328+ for (const word of words) {329+ const value = word.toUpperCase();330+ const role = ["YOU", "WIN", "PUSH", "STOP", "TICK", "IS", "AND"].includes(331+ value,332+ )333+ ? value334+ : "noun";335+ const width = drawMiniTile(ctx, value, cursor, y, role, size, opacity);336+ cursor += width + Math.max(5, size * 0.4);337+ if (cursor > x + maxWidth) break;338+ }339+ return cursor;340+}341+342+export function drawLedger(ctx, rules, x, y, maxWidth) {343+ const lineHeight = 26;344+ const sentenceGap = 26;345+ const tileGap = 7;346+ let cursor = x;347+ let lineY = y;348+ for (const rule of rules || []) {349+ const words = String(rule.text || rule)350+ .split(/\s+/)351+ .filter(Boolean);352+ ctx.font = "800 10px Recursive, monospace";353+ const sentenceWidth =354+ words.reduce(355+ (total, word) =>356+ total + Math.max(16, ctx.measureText(word.toUpperCase()).width + 10),357+ 0,358+ ) +359+ Math.max(0, words.length - 1) * tileGap;360+ if (361+ cursor !== x &&362+ sentenceWidth <= maxWidth &&363+ cursor + sentenceWidth > x + maxWidth364+ ) {365+ lineY += lineHeight;366+ cursor = x;367+ }368+ for (const word of words) {369+ ctx.font = "800 10px Recursive, monospace";370+ const tileWidth = Math.max(371+ 16,372+ ctx.measureText(word.toUpperCase()).width + 10,373+ );374+ if (cursor !== x && cursor + tileWidth > x + maxWidth) {375+ lineY += lineHeight;376+ cursor = x;377+ }378+ const value = word.toUpperCase();379+ const role = ["YOU", "WIN", "PUSH", "STOP", "TICK", "IS", "AND"].includes(380+ value,381+ )382+ ? value383+ : "noun";384+ drawMiniTile(ctx, value, cursor, lineY, role, 10);385+ cursor += tileWidth + tileGap;386+ }387+ cursor += sentenceGap;388+ }389+ lineY += lineHeight;390+ cursor = x;391+ for (const word of ["TEXT", "IS", "PUSH"]) {392+ ctx.font = "800 10px Recursive, monospace";393+ const tileWidth = Math.max(16, ctx.measureText(word).width + 10);394+ drawMiniTile(395+ ctx,396+ word,397+ cursor,398+ lineY,399+ word === "PUSH" ? "PUSH" : word === "IS" ? "IS" : "noun",400+ 10,401+ 0.42,402+ );403+ cursor += tileWidth + tileGap;404+ }405+ return { bottom: lineY + 15 };406+}407+408+export function drawHint(ctx, hint, x, y, maxWidth, maxBottom) {409+ const words = String(hint).split(/\s+/);410+ ctx.save();411+ ctx.fillStyle = "#8E877C";412+ ctx.font = "500 11px Recursive, monospace";413+ ctx.textAlign = "left";414+ ctx.textBaseline = "top";415+ let line = "";416+ let lineY = y;417+ for (const word of words) {418+ const next = line ? `${line} ${word}` : word;419+ if (line && ctx.measureText(next).width > maxWidth) {420+ ctx.fillText(line, x, lineY);421+ line = word;422+ lineY += 16;423+ } else line = next;424+ }425+ if (line && lineY <= maxBottom) ctx.fillText(line, x, lineY);426+ ctx.restore();427+}@@ -0,0 +1,242 @@
1+import { drawBoard } from "./board.js";2+import { drawIndex } from "./index.js";3+import {4+ drawInlineActions,5+ drawKeycap,6+ drawSentenceTiles,7+ drawStackedAction,8+ drawTouchControls,9+} from "./stage-hud.js";10+11+export function renderPauseScene(ctx, view = {}) {12+ const { width: w, height: h } = view;13+ const {14+ boardState,15+ boardGame,16+ boardPalette = {},17+ boardGlyphs = {},18+ boardFrame,19+ pausedFrom,20+ indexLayout,21+ indexNavigator,22+ indexFacing,23+ indexPopNodeId,24+ indexPopStart,25+ indexLockedNode,26+ title,27+ pauseItems = [],28+ selected = 0,29+ reducedMotion = false,30+ } = view;31+ if (pausedFrom === "index" && indexLayout && indexNavigator) {32+ view.onIndexGeometry?.(33+ drawIndex(ctx, indexLayout, indexNavigator, {34+ availableWidth: w,35+ availableHeight: h,36+ cat: boardGlyphs.MIAU || "🐈",37+ catFacing: indexFacing,38+ popNodeId: indexPopNodeId,39+ popProgress: indexPopStart40+ ? Math.min(1, (view.now - indexPopStart) / 520)41+ : 1,42+ reducedMotion,43+ hideBanner: true,44+ hideFooter: true,45+ lockedNode: indexLockedNode,46+ }),47+ );48+ } else if (pausedFrom === "title" && title) {49+ title.draw(ctx, { width: w, height: h, now: view.now, reducedMotion });50+ } else if (boardState) {51+ const cell = Math.min(52+ 96,53+ Math.max(54+ 22,55+ Math.floor(56+ Math.min((w - 80) / boardState.width, (h - 320) / boardState.height),57+ ),58+ ),59+ );60+ drawBoard(ctx, boardState, {61+ x: Math.floor((w - boardState.width * cell) / 2),62+ y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)),63+ cell,64+ game: boardGame,65+ palette: boardPalette,66+ glyphs: boardGlyphs,67+ frame: boardFrame,68+ });69+ }70+ if (pausedFrom === "index") {71+ ctx.fillStyle = "#0F0E0D";72+ ctx.fillRect(0, 0, w, Math.max(96, h * 0.16));73+ ctx.fillRect(0, h - Math.max(72, h * 0.12), w, Math.max(72, h * 0.12));74+ }75+ ctx.fillStyle = "rgba(0,0,0,.74)";76+ ctx.fillRect(0, 0, w, h);77+ ctx.textAlign = "center";78+ ctx.textBaseline = "middle";79+ ctx.fillStyle = "#FF4B3E";80+ ctx.font = "700 13px Recursive, monospace";81+ ctx.fillText("PAUSED", w / 2, Math.max(75, h * 0.12));82+ pauseItems.forEach((item, i) => {83+ const y = h * 0.24 + i * 54;84+ ctx.fillStyle = "#000000";85+ ctx.fillRect(w / 2 - 154, y - 21, 320, 54);86+ ctx.fillStyle = i === selected ? "#FF4B3E" : "#232120";87+ ctx.fillRect(w / 2 - 160, y - 27, 320, 54);88+ ctx.fillStyle = "#0F0E0D";89+ ctx.font = "800 20px 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif";90+ ctx.textAlign = "right";91+ ctx.fillText(i === selected ? "🐾" : "", w / 2 - 176, y + 1);92+ ctx.fillStyle = i === selected ? "#0F0E0D" : "#F2EEE6";93+ ctx.font = "800 13px Recursive, monospace";94+ ctx.textAlign = "center";95+ ctx.fillText(item.label, w / 2, y + 1);96+ });97+ ctx.textAlign = "left";98+ ctx.fillStyle = "#8E877C";99+ ctx.font = "500 11px Recursive, monospace";100+ if (pausedFrom !== "index" && pausedFrom !== "title")101+ ctx.fillText(102+ String(boardState?.hint || "Choose an action").trim(),103+ 32,104+ h - 86,105+ );106+ const coarsePointer = view.coarsePointer ?? false;107+ if (w > 760 && !coarsePointer) {108+ drawKeycap(ctx, "↑↓", "choose", 32, h - 46);109+ drawKeycap(ctx, "ENTER", "select", w / 2 - 4, h - 46, "right");110+ drawKeycap(ctx, "ESC", "back", w - 32, h - 46, "right");111+ } else drawTouchControls(ctx, w, h, coarsePointer);112+}113+114+export function renderWinScene(ctx, view = {}) {115+ const { width: w, height: h } = view;116+ const {117+ boardState,118+ boardGame,119+ boardPalette = {},120+ boardGlyphs = {},121+ boardFrame,122+ reducedMotion = false,123+ winStart = 0,124+ } = view;125+ if (boardState) {126+ const cell = Math.min(127+ 96,128+ Math.max(129+ 22,130+ Math.floor(131+ Math.min((w - 80) / boardState.width, (h - 320) / boardState.height),132+ ),133+ ),134+ );135+ drawBoard(ctx, boardState, {136+ x: Math.floor((w - boardState.width * cell) / 2),137+ y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)),138+ cell,139+ game: boardGame,140+ palette: boardPalette,141+ glyphs: boardGlyphs,142+ frame: boardFrame,143+ });144+ }145+ ctx.fillStyle = "rgba(0,0,0,.66)";146+ ctx.fillRect(0, 0, w, h);147+ const cardWidth = Math.min(620, w - 48);148+ const cardX = (w - cardWidth) / 2;149+ const winProgress =150+ reducedMotion || !winStart ? 1 : Math.min(1, (view.now - winStart) / 520);151+ const back = 1.70158;152+ const rawBackEase =153+ 1 +154+ (back + 1) * Math.pow(winProgress - 1, 3) +155+ back * Math.pow(winProgress - 1, 2);156+ const winEase = Math.max(0, Math.min(1.1, rawBackEase));157+ const cardY = Math.max(120, h * 0.25) + (1 - winEase) * 80;158+ const cardHeight = 290;159+ ctx.fillStyle = "#191816";160+ ctx.fillRect(cardX + 10, cardY + 10, cardWidth, cardHeight);161+ ctx.fillStyle = "#F2EEE6";162+ ctx.fillRect(cardX, cardY, cardWidth, cardHeight);163+ ctx.fillStyle = "#0F0E0D";164+ ctx.fillRect(cardX + 2, cardY + 2, cardWidth - 4, cardHeight - 4);165+ ctx.strokeStyle = "#2EE6A8";166+ ctx.lineWidth = 2;167+ ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);168+ ctx.textAlign = "left";169+ ctx.fillStyle = "#8E877C";170+ ctx.font = "600 10px Recursive, monospace";171+ const winningRule =172+ boardState?.rules?.find((rule) => rule.predicate === "WIN")?.text ||173+ boardState?.rules?.[0]?.text ||174+ "MIAU IS WIN";175+ const sentenceWords = winningRule.split(/\s+/).filter(Boolean);176+ const sentenceSize = Math.min(22, Math.max(14, cardWidth * 0.035));177+ ctx.font = `800 ${sentenceSize}px Recursive, monospace`;178+ const sentenceWidth =179+ sentenceWords.reduce(180+ (total, word) =>181+ total +182+ Math.max(183+ sentenceSize * 1.6,184+ ctx.measureText(word.toUpperCase()).width + sentenceSize,185+ ),186+ 0,187+ ) +188+ Math.max(0, sentenceWords.length - 1) * sentenceSize * 0.4;189+ drawSentenceTiles(190+ ctx,191+ winningRule,192+ cardX + Math.max(32, (cardWidth - sentenceWidth) / 2),193+ cardY + 52,194+ cardWidth - 64,195+ sentenceSize,196+ );197+ ctx.fillStyle = "#2EE6A8";198+ ctx.fillRect(199+ cardX + 32,200+ cardY + 100,201+ (cardWidth - 64) * (reducedMotion ? 1 : Math.min(1, winProgress * 1.5)),202+ 3,203+ );204+ ctx.textAlign = "center";205+ ctx.fillStyle = "#F2EEE6";206+ const headline = `PROVEN IN ${boardState?.moves || 0} STEPS`;207+ let headlineSize = Math.min(44, Math.max(26, w * 0.055));208+ do {209+ ctx.font = `800 ${headlineSize}px Recursive, monospace`;210+ if (ctx.measureText(headline).width <= cardWidth - 64 || headlineSize <= 22)211+ break;212+ headlineSize -= 2;213+ } while (headlineSize > 22);214+ const headlineY = cardY + 154;215+ ctx.fillText(headline, w / 2, headlineY);216+ ctx.fillStyle = "#8E877C";217+ ctx.font = "600 16px Recursive, monospace";218+ ctx.fillText(219+ `${boardState?.completedCount ?? 0} of ${boardState?.proofTotal ?? 9} proofs complete`,220+ w / 2,221+ headlineY + headlineSize / 2 + 16 + 8,222+ );223+ const coarsePointer = view.coarsePointer ?? false;224+ if (w <= 760) {225+ const actionY = cardY + 250;226+ drawStackedAction(ctx, "ENTER", "continue", cardX + cardWidth / 6, actionY);227+ drawStackedAction(ctx, "Z", "undo", cardX + cardWidth / 2, actionY);228+ drawStackedAction(ctx, "R", "replay", cardX + (cardWidth * 5) / 6, actionY);229+ } else if (!coarsePointer)230+ drawInlineActions(231+ ctx,232+ [233+ { key: "ENTER", label: "continue" },234+ { key: "Z", label: "undo" },235+ { key: "R", label: "replay" },236+ ],237+ w / 2,238+ cardY + 250,239+ );240+ if (w <= 760 || coarsePointer) drawTouchControls(ctx, w, h, coarsePointer);241+ ctx.textAlign = "left";242+}@@ -0,0 +1,173 @@
1+import { drawBoard } from "./board.js";2+import {3+ drawAutomataStrip,4+ drawHint,5+ drawKeycap,6+ drawLedger,7+ drawTouchControls,8+} from "./stage-hud.js";9+10+/**11+ * Draws the playable proof scene. All inputs are supplied in the view object;12+ * this renderer does not retain stage state or read from the DOM.13+ */14+export function renderProofScene(ctx, state, view = {}) {15+ if (!ctx || !state) return null;16+ const width = view.width ?? ctx.canvas?.clientWidth ?? ctx.canvas?.width ?? 0;17+ const height =18+ view.height ?? ctx.canvas?.clientHeight ?? ctx.canvas?.height ?? 0;19+ const now =20+ view.now ?? (typeof performance === "undefined" ? 0 : performance.now());21+ const palette = view.palette || {};22+ const glyphs = view.glyphs || {};23+ const automata = Array.isArray(state.automata)24+ ? state.automata.filter((system) => system?.pattern?.length)25+ : [];26+ const automataRows = Math.min(width <= 760 ? 2 : 3, automata.length);27+ const automataRowHeight = width <= 760 ? 24 : 30;28+ const automataReserve = automataRows29+ ? Math.min(120, automataRows * automataRowHeight + 18)30+ : 0;31+ const cell = Math.min(32+ 96,33+ Math.max(34+ 22,35+ Math.floor(36+ Math.min(37+ (width - 80) / state.width,38+ (height - 320 - automataReserve) / state.height,39+ ),40+ ),41+ ),42+ );43+ const x = Math.floor((width - state.width * cell) / 2);44+ const y =45+ width <= 76046+ ? 12047+ : Math.max(118, Math.floor((height - state.height * cell) / 2 - 30));48+ const geometry = drawBoard(ctx, state, {49+ x,50+ y,51+ cell,52+ game: view.game,53+ palette,54+ glyphs,55+ frame: view.frame,56+ });57+ const controlled = state.objects?.find(58+ (object) =>59+ object.kind === "object" &&60+ view.game?.properties(object)?.includes("YOU"),61+ );62+ const controlledCenter = controlled63+ ? {64+ x: geometry.x + (controlled.x + 0.5) * geometry.cell,65+ y: geometry.y + (controlled.y + 0.5) * geometry.cell,66+ }67+ : null;68+ ctx.textAlign = "left";69+ ctx.textBaseline = "top";70+ const proofNumber = String(state.proofNumber ?? 1).padStart(2, "0");71+ ctx.fillStyle = "#F2EEE6";72+ ctx.font = "800 11px Recursive, monospace";73+ ctx.fillRect(32, 30, 34, 26);74+ ctx.fillStyle = "#0F0E0D";75+ ctx.textAlign = "center";76+ ctx.textBaseline = "middle";77+ ctx.fillText(proofNumber, 49, 44);78+ ctx.textAlign = "left";79+ const fullProofTitle = String(state.title || "Proof");80+ ctx.fillStyle = "#F2EEE6";81+ ctx.font = "800 22px Recursive, monospace";82+ const titleMaxWidth = Math.max(96, width - 190);83+ let visibleProofTitle = fullProofTitle;84+ if (ctx.measureText(visibleProofTitle).width > titleMaxWidth) {85+ while (86+ visibleProofTitle.length > 4 &&87+ ctx.measureText(`${visibleProofTitle}…`).width > titleMaxWidth88+ )89+ visibleProofTitle = visibleProofTitle.slice(0, -1);90+ visibleProofTitle = `${visibleProofTitle.trimEnd()}…`;91+ }92+ ctx.fillText(visibleProofTitle, 78, 31);93+ ctx.font = "600 10px Recursive, monospace";94+ ctx.fillStyle = "#8E877C";95+ ctx.fillText(96+ `PROOF ${proofNumber} OF ${String(state.proofTotal ?? 9).padStart(2, "0")}`,97+ 78,98+ 58,99+ );100+ ctx.textAlign = "right";101+ ctx.fillStyle = "#F2EEE6";102+ ctx.font = "800 40px Recursive, monospace";103+ const stepProgress = view.stepPopStart104+ ? Math.min(1, (now - view.stepPopStart) / 220)105+ : 1;106+ const stepScale = 1 + (stepProgress < 1 ? 0.14 * (1 - stepProgress) : 0);107+ ctx.save();108+ ctx.translate(width - 32, 39);109+ ctx.scale(stepScale, stepScale);110+ ctx.fillText(String(state.moves || 0).padStart(2, "0"), 0, 0);111+ ctx.restore();112+ ctx.font = "600 10px Recursive, monospace";113+ ctx.fillStyle = "#8E877C";114+ ctx.fillText("STEPS", width - 32, 70);115+ ctx.textAlign = "left";116+ const ledger = drawLedger(117+ ctx,118+ state.rules,119+ x,120+ Math.min(y + state.height * cell + 46, height - 112),121+ width - x - 32,122+ );123+ const hint = String(state.hint || "").trim();124+ const automataY = automata.length125+ ? height - (automataRows * automataRowHeight + 90)126+ : height - 80;127+ if (hint)128+ drawHint(129+ ctx,130+ hint,131+ x,132+ Math.min(ledger.bottom + 12, height - 78),133+ width - x - 32,134+ automata.length ? automataY - 8 : height - 60,135+ );136+ if (automata.length)137+ drawAutomataStrip(138+ ctx,139+ automata,140+ x,141+ automataY,142+ width - x - 32,143+ state.rules,144+ {145+ viewportWidth: width,146+ palette,147+ glyphs,148+ },149+ );150+ const coarsePointer = view.coarsePointer ?? false;151+ if (width > 760 && !coarsePointer) {152+ const proofKeyY = height - 26;153+ if (automata.some((system) => system.active))154+ drawKeycap(ctx, "SPACE", "wait", width - 400, proofKeyY);155+ drawKeycap(ctx, "Z", "undo", width - 290, proofKeyY);156+ drawKeycap(ctx, "R", "restart", width - 185, proofKeyY);157+ drawKeycap(ctx, "ESC", "menu", width - 32, proofKeyY, "right");158+ }159+ drawTouchControls(160+ ctx,161+ width,162+ height,163+ coarsePointer,164+ automata.some((system) => system.active),165+ );166+ return {167+ geometry,168+ controlledCenter,169+ ariaLabel: `Miau Is True proof: ${fullProofTitle}`,170+ };171+}172+173+export default renderProofScene;@@ -3,11 +3,11 @@ import { indexScene } from "./scenes/index.js";
33 import { proofScene } from "./scenes/proof.js";44 import { pauseScene } from "./scenes/pause.js";55 import { winScene } from "./scenes/win.js";6-import { drawBoard } from "./render/board.js";76 import { drawIndex } from "./render/index.js";7+import { renderProofScene } from "./render/stage-proof.js";88 import { nativeHorizontalFacing } from './render/facing.js';9-import { createGiftScene } from "./scenes/gift.js";10-import { createWorkshopScene } from "./scenes/workshop.js";9+import { renderPauseScene, renderWinScene } from './render/stage-overlays.js';10+import { overlayInputForKey, pauseMenuItems } from "./stage/dispatch.js";1111 1212 const SCENES = {1313 title: titleScene,@@ -91,15 +91,8 @@ export function createStage(options = {}) {
9191 let workshopOrigin = "pause";9292 let giftOrigin = "title";9393 let selected = 0;94- const pauseItems = [95- "RESUME",96- "RESTART",97- "INDEX",98- "WORKSHOP",99- "SOUND ON",100- "MUSIC OFF",101- ];10294 let pauseStatus = { soundOn: true, musicOn: false };95+ const pauseItems = pauseMenuItems("proof", pauseStatus);10396 let transitionStart = 0;10497 let transitionGeneration = 0;10598 let catCenter = null;@@ -148,427 +141,6 @@ export function createStage(options = {}) {
148141 requestAnimationFrame((time) => drawTransition(time, generation));149142 else transitionStart = 0;150143 }151- function drawKeycap(ctx, key, label, x, y, align = "left") {152- ctx.save();153- ctx.textBaseline = "middle";154- ctx.font = "800 10px Recursive, monospace";155- const keyWidth = Math.max(24, ctx.measureText(key).width + 12);156- ctx.font = "600 10px Recursive, monospace";157- const labelWidth = ctx.measureText(label).width;158- const left = align === "right" ? x - keyWidth - 8 - labelWidth : x;159- ctx.fillStyle = "#000000";160- ctx.fillRect(left + 4, y - 6, keyWidth, 20);161- ctx.fillStyle = "#F2EEE6";162- ctx.fillRect(left, y - 10, keyWidth, 20);163- ctx.fillStyle = "#0F0E0D";164- ctx.textAlign = "left";165- ctx.font = "800 10px Recursive, monospace";166- ctx.fillText(key, left + 6, y + 1);167- ctx.font = "600 10px Recursive, monospace";168- ctx.fillStyle = "#8E877C";169- ctx.fillText(label, left + keyWidth + 8, y + 1);170- ctx.restore();171- }172- function drawInlineActions(ctx, items, centerX, y) {173- ctx.save();174- ctx.font = "800 10px Recursive, monospace";175- const metrics = items.map(({ key, label }) => {176- const keyWidth = Math.max(24, ctx.measureText(key).width + 12);177- ctx.font = "600 10px Recursive, monospace";178- const labelWidth = ctx.measureText(label).width;179- ctx.font = "800 10px Recursive, monospace";180- return {181- key,182- label,183- keyWidth,184- labelWidth,185- width: keyWidth + 8 + labelWidth,186- };187- });188- const gap = 18;189- const total =190- metrics.reduce((sum, item) => sum + item.width, 0) +191- gap * (metrics.length - 1);192- let x = centerX - total / 2;193- for (const item of metrics) {194- drawKeycap(ctx, item.key, item.label, x, y);195- x += item.width + gap;196- }197- ctx.restore();198- }199- function drawStackedAction(ctx, key, label, centerX, y) {200- ctx.save();201- ctx.font = "800 10px Recursive, monospace";202- const keyWidth = Math.max(24, ctx.measureText(key).width + 12);203- ctx.fillStyle = "#F2EEE6";204- ctx.fillRect(centerX - keyWidth / 2, y - 10, keyWidth, 20);205- ctx.fillStyle = "#0F0E0D";206- ctx.textAlign = "center";207- ctx.textBaseline = "middle";208- ctx.fillText(key, centerX, y + 1);209- ctx.fillStyle = "#8E877C";210- ctx.font = "600 9px Recursive, monospace";211- ctx.fillText(label, centerX, y + 27);212- ctx.restore();213- }214- function drawTouchControls(215- ctx,216- width,217- height,218- force = false,219- hasAutomata = false,220- ) {221- if (width > 760 && !force) return;222- const y = height - 30;223- const controls = [224- { label: "UNDO", key: "↶", x: 18 },225- { label: "RESTART", key: "⟲", x: width / 2, center: true },226- { label: "PAUSE", key: "☰", x: width - 18, right: true },227- ];228- ctx.save();229- ctx.textBaseline = "middle";230- for (const control of controls) {231- const text = `${control.key} ${control.label}`;232- ctx.font = "700 10px Recursive, monospace";233- const boxWidth = ctx.measureText(text).width + 18;234- const left = control.center235- ? control.x - boxWidth / 2236- : control.right237- ? control.x - boxWidth238- : control.x;239- ctx.fillStyle = "#232120";240- ctx.strokeStyle = "#4A4640";241- ctx.lineWidth = 1;242- ctx.fillRect(left, y - 14, boxWidth, 28);243- ctx.strokeRect(left + 0.5, y - 13.5, boxWidth - 1, 27);244- ctx.fillStyle = "#F2EEE6";245- ctx.textAlign = "center";246- ctx.fillText(text, left + boxWidth / 2, y + 1);247- }248- ctx.fillStyle = "#8E877C";249- ctx.font = "500 10px Recursive, monospace";250- ctx.textAlign = "center";251- if (!hasAutomata) ctx.fillText("SWIPE TO WALK", width / 2, y - 22);252- if (hasAutomata) {253- const waitText = "WAIT";254- ctx.font = "700 10px Recursive, monospace";255- const waitWidth = Math.max(96, ctx.measureText(waitText).width + 18);256- const waitLeft = width / 2 - waitWidth / 2;257- ctx.fillStyle = "#232120";258- ctx.strokeStyle = "#4A4640";259- ctx.fillRect(waitLeft, height - 84, waitWidth, 32);260- ctx.strokeRect(waitLeft + 0.5, height - 83.5, waitWidth - 1, 31);261- ctx.fillStyle = "#F2EEE6";262- ctx.fillText(waitText, width / 2, height - 68);263- }264- ctx.restore();265- }266- function automataGlyph(subject) {267- const entry = boardPalette?.[String(subject || "").toUpperCase()];268- if (entry && typeof entry === "object" && entry.glyph) return entry.glyph;269- if (typeof entry === "string" && entry.length) return entry;270- return boardGlyphs?.[String(subject || "").toUpperCase()] || "⚙️";271- }272- function automataCue(action) {273- if (typeof action?.cue === "string" && action.cue.trim())274- return action.cue.trim().slice(0, 16);275- if (action?.action === "wait") return "WAIT";276- if (action?.dx === 1) return "→";277- if (action?.dx === -1) return "←";278- if (action?.dy === 1) return "↓";279- if (action?.dy === -1) return "↑";280- return "?";281- }282- function isEmojiCue(value) {283- return Array.from(String(value || "")).some((character) => {284- const code = character.codePointAt(0);285- return code > 0x1f000 || character === "\uFE0F";286- });287- }288- function drawAutomataStrip(ctx, automata, x, y, maxWidth, rules = []) {289- const rows = Array.isArray(automata)290- ? automata.filter((item) => item?.pattern?.length)291- : [];292- if (!rows.length) return { bottom: y, height: 0 };293- const visibleRows = rows.slice(0, innerWidth <= 760 ? 2 : 3);294- const rowHeight = innerWidth <= 760 ? 24 : 30;295- const labelWidth = 76;296- const beatGap = 5;297- const maxBeats = Math.max(4, Math.floor((maxWidth - labelWidth) / 22));298- ctx.save();299- ctx.textBaseline = "middle";300- visibleRows.forEach((system, rowIndex) => {301- const rowY = y + rowIndex * rowHeight;302- const phase = Number.isInteger(system.phase)303- ? system.phase % system.pattern.length304- : 0;305- const derivedSubject =306- system.subject ||307- rules.find(308- (rule) =>309- String(rule.predicate || "").toUpperCase() ===310- String(system.property || "").toUpperCase(),311- )?.subject;312- const beatIndexes =313- system.pattern.length <= maxBeats314- ? system.pattern.map((_, index) => index)315- : [316- ...new Set([317- phase,318- (phase + 1) % system.pattern.length,319- (phase + system.pattern.length - 1) % system.pattern.length,320- (phase + 2) % system.pattern.length,321- ]),322- ];323- const omitted = system.pattern.length - beatIndexes.length;324- const beatWidth = Math.max(325- 14,326- Math.min(327- 34,328- (maxWidth - labelWidth - beatGap * (beatIndexes.length - 1)) /329- beatIndexes.length,330- ),331- );332- const glyph = automataGlyph(derivedSubject);333- ctx.fillStyle = "#F2EEE6";334- ctx.font = `${Math.min(22, beatWidth)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;335- ctx.textAlign = "left";336- ctx.fillText(glyph, x, rowY + rowHeight / 2);337- ctx.fillStyle = "#8E877C";338- ctx.font = "700 9px Recursive, monospace";339- ctx.fillText(340- String(system.property || "").toUpperCase() || "AUTOMATA",341- x + 28,342- rowY + 10,343- );344- ctx.font = "600 9px Recursive, monospace";345- ctx.fillText(346- String(system.active ? "ACTIVE" : "DORMANT"),347- x + 28,348- rowY + 21,349- );350- beatIndexes.forEach((patternIndex, beatIndex) => {351- const action = system.pattern[patternIndex];352- const beatX = x + labelWidth + beatIndex * (beatWidth + beatGap);353- const current = patternIndex === phase;354- const next = patternIndex === (phase + 1) % system.pattern.length;355- ctx.globalAlpha = current || next ? 1 : 0.4;356- ctx.fillStyle = current ? "#C6F432" : "#191816";357- ctx.strokeStyle = current || next ? "#C6F432" : "#4A4640";358- ctx.lineWidth = current ? 2 : 1;359- ctx.fillRect(beatX, rowY + 4, beatWidth, 22);360- if (!current)361- ctx.strokeRect(beatX + 0.5, rowY + 4.5, beatWidth - 1, 21);362- const cue =363- action.action === "wait" && beatWidth < 24364- ? "○"365- : automataCue(action);366- const emojiCue = isEmojiCue(cue);367- const cueSize = emojiCue368- ? Math.min(16, beatWidth * 0.58)369- : Math.min(370- cue.length > 4 || action.action === "wait" ? 7 : 13,371- beatWidth * 0.8,372- );373- ctx.fillStyle = current ? "#0F0E0D" : next ? "#0F0E0D" : "#8E877C";374- ctx.font = `${current ? "800" : "600"} ${cueSize}px ${emojiCue ? '"Apple Color Emoji", "Segoe UI Emoji", sans-serif' : "Recursive, monospace"}`;375- ctx.textAlign = "center";376- ctx.fillText(cue, beatX + beatWidth / 2, rowY + 16);377- ctx.globalAlpha = 1;378- if (omitted && beatIndex === beatIndexes.length - 1) {379- ctx.fillStyle = "#8E877C";380- ctx.font = "600 8px Recursive, monospace";381- ctx.fillText(382- `+${omitted}`,383- beatX + beatWidth / 2,384- rowY + rowHeight - 4,385- );386- }387- });388- });389- if (rows.length > visibleRows.length) {390- ctx.fillStyle = "#8E877C";391- ctx.font = "600 9px Recursive, monospace";392- ctx.textAlign = "right";393- ctx.fillText(394- `+${rows.length - visibleRows.length} automata`,395- x + maxWidth,396- y + visibleRows.length * rowHeight - 4,397- );398- }399- ctx.restore();400- return {401- bottom: y + visibleRows.length * rowHeight,402- height: visibleRows.length * rowHeight,403- };404- }405- function drawMiniTile(406- ctx,407- text,408- x,409- y,410- role = "noun",411- size = 18,412- opacity = 1,413- ) {414- const fills = {415- noun: "#F2EEE6",416- YOU: "#FF4B3E",417- WIN: "#2EE6A8",418- PUSH: "#F2C14E",419- STOP: "#C6B7FF",420- TICK: "#C6F432",421- IS: "#F2EEE6",422- AND: "#F2EEE6",423- };424- ctx.save();425- ctx.globalAlpha = opacity;426- ctx.font = `800 ${size}px Recursive, monospace`;427- const width = Math.max(size * 1.6, ctx.measureText(text).width + size);428- if (role === "IS" || role === "AND") {429- ctx.strokeStyle = "#8E877C";430- ctx.lineWidth = 1;431- ctx.beginPath();432- if (ctx.roundRect) ctx.roundRect(x, y + 1, width, size * 1.2, size * 0.6);433- else ctx.rect(x, y + 1, width, size * 1.2);434- ctx.stroke();435- } else {436- ctx.fillStyle = "#000000";437- ctx.fillRect(x + 3, y + 3, width, size * 1.5);438- ctx.fillStyle = fills[role] || fills.noun;439- ctx.fillRect(x, y, width, size * 1.5);440- }441- ctx.fillStyle = role === "IS" || role === "AND" ? "#F2EEE6" : "#0F0E0D";442- ctx.textAlign = "center";443- ctx.textBaseline = "middle";444- ctx.fillText(text, x + width / 2, y + size * 0.78);445- ctx.restore();446- return width;447- }448- function drawSentenceTiles(449- ctx,450- sentence,451- x,452- y,453- maxWidth,454- size = 16,455- opacity = 1,456- ) {457- const words = String(sentence || "")458- .split(/\s+/)459- .filter(Boolean);460- let cursor = x;461- for (const word of words) {462- const role = ["YOU", "WIN", "PUSH", "STOP", "TICK", "IS", "AND"].includes(463- word.toUpperCase(),464- )465- ? word.toUpperCase()466- : "noun";467- const width = drawMiniTile(468- ctx,469- word.toUpperCase(),470- cursor,471- y,472- role,473- size,474- opacity,475- );476- cursor += width + Math.max(5, size * 0.4);477- if (cursor > x + maxWidth) break;478- }479- return cursor;480- }481- function drawLedger(ctx, rules, x, y, maxWidth) {482- const lineHeight = 26;483- const sentenceGap = 26;484- const tileGap = 7;485- let cursor = x;486- let lineY = y;487- for (const rule of rules || []) {488- const words = String(rule.text || rule)489- .split(/\s+/)490- .filter(Boolean);491- ctx.font = "800 10px Recursive, monospace";492- const sentenceWidth =493- words.reduce(494- (total, word) =>495- total +496- Math.max(16, ctx.measureText(word.toUpperCase()).width + 10),497- 0,498- ) +499- Math.max(0, words.length - 1) * tileGap;500- if (501- cursor !== x &&502- sentenceWidth <= maxWidth &&503- cursor + sentenceWidth > x + maxWidth504- ) {505- lineY += lineHeight;506- cursor = x;507- }508- for (const word of words) {509- ctx.font = "800 10px Recursive, monospace";510- const tileWidth = Math.max(511- 16,512- ctx.measureText(word.toUpperCase()).width + 10,513- );514- if (cursor !== x && cursor + tileWidth > x + maxWidth) {515- lineY += lineHeight;516- cursor = x;517- }518- const role = [519- "YOU",520- "WIN",521- "PUSH",522- "STOP",523- "TICK",524- "IS",525- "AND",526- ].includes(word.toUpperCase())527- ? word.toUpperCase()528- : "noun";529- drawMiniTile(ctx, word.toUpperCase(), cursor, lineY, role, 10);530- cursor += tileWidth + tileGap;531- }532- cursor += sentenceGap;533- }534- lineY += lineHeight;535- cursor = x;536- for (const word of ["TEXT", "IS", "PUSH"]) {537- ctx.font = "800 10px Recursive, monospace";538- const tileWidth = Math.max(16, ctx.measureText(word).width + 10);539- drawMiniTile(540- ctx,541- word,542- cursor,543- lineY,544- word === "PUSH" ? "PUSH" : word === "IS" ? "IS" : "noun",545- 10,546- 0.42,547- );548- cursor += tileWidth + tileGap;549- }550- return { bottom: lineY + 15 };551- }552- function drawHint(ctx, hint, x, y, maxWidth, maxBottom) {553- const words = String(hint).split(/\s+/);554- ctx.save();555- ctx.fillStyle = "#8E877C";556- ctx.font = "500 11px Recursive, monospace";557- ctx.textAlign = "left";558- ctx.textBaseline = "top";559- let line = "";560- let lineY = y;561- for (const word of words) {562- const next = line ? `${line} ${word}` : word;563- if (line && ctx.measureText(next).width > maxWidth) {564- ctx.fillText(line, x, lineY);565- line = word;566- lineY += 16;567- } else line = next;568- }569- if (line && lineY <= maxBottom) ctx.fillText(line, x, lineY);570- ctx.restore();571- }572144 function moveIndex(direction) {573145 const result = indexNavigator?.move(direction);574146 indexLockedNode = result?.locked ? result.node : null;@@ -621,152 +193,19 @@ export function createStage(options = {}) {
621193 return;622194 }623195 if (scene === "proof" && boardState) {624- const automata = Array.isArray(boardState.automata)625- ? boardState.automata.filter((system) => system?.pattern?.length)626- : [];627- const automataRows = Math.min(w <= 760 ? 2 : 3, automata.length);628- const automataRowHeight = w <= 760 ? 24 : 30;629- const automataReserve = automataRows630- ? Math.min(120, automataRows * automataRowHeight + 18)631- : 0;632- const cell = Math.min(633- 96,634- Math.max(635- 22,636- Math.floor(637- Math.min(638- (w - 80) / boardState.width,639- (h - 320 - automataReserve) / boardState.height,640- ),641- ),642- ),643- );644- const x = Math.floor((w - boardState.width * cell) / 2),645- y =646- w <= 760647- ? 120648- : Math.max(649- 118,650- Math.floor((h - boardState.height * cell) / 2 - 30),651- );652- const geometry = drawBoard(ctx, boardState, {653- x,654- y,655- cell,196+ const proofView = renderProofScene(ctx, boardState, {197+ width: w,198+ height: h,199+ now: performance.now(),656200 game: boardGame,657201 palette: boardPalette,658202 glyphs: boardGlyphs,659203 frame: boardFrame,204+ stepPopStart,205+ coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,660206 });661- const controlled = boardState.objects?.find(662- (object) =>663- object.kind === "object" &&664- boardGame?.properties(object)?.includes("YOU"),665- );666- if (controlled)667- catCenter = {668- x: geometry.x + (controlled.x + 0.5) * geometry.cell,669- y: geometry.y + (controlled.y + 0.5) * geometry.cell,670- };671- ctx.textAlign = "left";672- ctx.textBaseline = "top";673- const proofNumber = String(boardState.proofNumber ?? 1).padStart(2, "0");674- ctx.fillStyle = "#F2EEE6";675- ctx.font = "800 11px Recursive, monospace";676- ctx.fillRect(32, 30, 34, 26);677- ctx.fillStyle = "#0F0E0D";678- ctx.textAlign = "center";679- ctx.textBaseline = "middle";680- ctx.fillText(proofNumber, 49, 44);681- ctx.textAlign = "left";682- const fullProofTitle = String(boardState.title || "Proof");683- canvas.setAttribute(684- "aria-label",685- `Miau Is True proof: ${fullProofTitle}`,686- );687- ctx.fillStyle = "#F2EEE6";688- ctx.font = "800 22px Recursive, monospace";689- const titleMaxWidth = Math.max(96, w - 190);690- let visibleProofTitle = fullProofTitle;691- if (ctx.measureText(visibleProofTitle).width > titleMaxWidth) {692- while (693- visibleProofTitle.length > 4 &&694- ctx.measureText(`${visibleProofTitle}…`).width > titleMaxWidth695- )696- visibleProofTitle = visibleProofTitle.slice(0, -1);697- visibleProofTitle = `${visibleProofTitle.trimEnd()}…`;698- }699- ctx.fillText(visibleProofTitle, 78, 31);700- ctx.font = "600 10px Recursive, monospace";701- ctx.fillStyle = "#8E877C";702- ctx.fillText(703- `PROOF ${proofNumber} OF ${String(boardState.proofTotal ?? 9).padStart(2, "0")}`,704- 78,705- 58,706- );707- ctx.textAlign = "right";708- ctx.fillStyle = "#F2EEE6";709- ctx.font = "800 40px Recursive, monospace";710- const stepProgress = stepPopStart711- ? Math.min(1, (performance.now() - stepPopStart) / 220)712- : 1;713- const stepScale = 1 + (stepProgress < 1 ? 0.14 * (1 - stepProgress) : 0);714- ctx.save();715- ctx.translate(w - 32, 39);716- ctx.scale(stepScale, stepScale);717- ctx.fillText(String(boardState.moves || 0).padStart(2, "0"), 0, 0);718- ctx.restore();719- ctx.font = "600 10px Recursive, monospace";720- ctx.fillStyle = "#8E877C";721- ctx.fillText("STEPS", w - 32, 70);722- ctx.textAlign = "left";723- const ledger = drawLedger(724- ctx,725- boardState.rules,726- x,727- Math.min(y + boardState.height * cell + 46, h - 112),728- w - x - 32,729- );730- const hint = String(boardState.hint || "").trim();731- const automataY = automata.length732- ? h - (automataRows * automataRowHeight + 90)733- : h - 80;734- if (hint) {735- drawHint(736- ctx,737- hint,738- x,739- Math.min(ledger.bottom + 12, h - 78),740- w - x - 32,741- automata.length ? automataY - 8 : h - 60,742- );743- }744- if (automata.length)745- drawAutomataStrip(746- ctx,747- automata,748- x,749- automataY,750- w - x - 32,751- boardState.rules,752- );753- const coarsePointer =754- globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;755- if (w > 760 && !coarsePointer) {756- const proofKeyY = h - 26;757- if (automata.some((system) => system.active))758- drawKeycap(ctx, "SPACE", "wait", w - 400, proofKeyY);759- drawKeycap(ctx, "Z", "undo", w - 290, proofKeyY);760- drawKeycap(ctx, "R", "restart", w - 185, proofKeyY);761- drawKeycap(ctx, "ESC", "menu", w - 32, proofKeyY, "right");762- }763- drawTouchControls(764- ctx,765- w,766- h,767- coarsePointer,768- automata.some((system) => system.active),769- );207+ if (proofView?.ariaLabel) canvas.setAttribute("aria-label", proofView.ariaLabel);208+ if (proofView?.controlledCenter) catCenter = proofView.controlledCenter;770209 } else if (scene === "index" && indexLayout && indexNavigator) {771210 indexGeometry = drawIndex(ctx, indexLayout, indexNavigator, {772211 availableWidth: w,@@ -783,239 +222,20 @@ export function createStage(options = {}) {
783222 const active = indexGeometry.nodes?.find((node) => node.active);784223 if (active) catCenter = { x: active.centerX, y: active.centerY };785224 } else if (scene === "pause") {786- if (pausedFrom === "index" && indexLayout && indexNavigator) {787- indexGeometry = drawIndex(ctx, indexLayout, indexNavigator, {788- availableWidth: w,789- availableHeight: h,790- cat: boardGlyphs.MIAU || "🐈",791- catFacing: indexFacing,792- popNodeId: indexPopNodeId,793- popProgress: indexPopStart794- ? Math.min(1, (performance.now() - indexPopStart) / 520)795- : 1,796- reducedMotion,797- hideBanner: true,798- hideFooter: true,799- lockedNode: indexLockedNode,800- });801- } else if (pausedFrom === "title" && title) {802- title.draw(ctx, {803- width: w,804- height: h,805- now: performance.now(),806- reducedMotion,807- });808- } else if (boardState) {809- const cell = Math.min(810- 96,811- Math.max(812- 22,813- Math.floor(814- Math.min(815- (w - 80) / boardState.width,816- (h - 320) / boardState.height,817- ),818- ),819- ),820- );821- drawBoard(ctx, boardState, {822- x: Math.floor((w - boardState.width * cell) / 2),823- y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)),824- cell,825- game: boardGame,826- palette: boardPalette,827- glyphs: boardGlyphs,828- frame: boardFrame,829- });830- }831- if (pausedFrom === "index") {832- ctx.fillStyle = "#0F0E0D";833- ctx.fillRect(0, 0, w, Math.max(96, h * 0.16));834- ctx.fillRect(0, h - Math.max(72, h * 0.12), w, Math.max(72, h * 0.12));835- }836- ctx.fillStyle = "rgba(0,0,0,.74)";837- ctx.fillRect(0, 0, w, h);838- ctx.textAlign = "center";839- ctx.textBaseline = "middle";840- ctx.fillStyle = "#FF4B3E";841- ctx.font = "700 13px Recursive, monospace";842- ctx.fillText("PAUSED", w / 2, Math.max(75, h * 0.12));843- pauseItems.forEach((item, i) => {844- const y = h * 0.24 + i * 54;845- ctx.fillStyle = "#000000";846- ctx.fillRect(w / 2 - 154, y - 21, 320, 54);847- ctx.fillStyle = i === selected ? "#FF4B3E" : "#232120";848- ctx.fillRect(w / 2 - 160, y - 27, 320, 54);849- ctx.fillStyle = "#0F0E0D";850- ctx.font = "800 20px 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif";851- ctx.textAlign = "right";852- ctx.fillText(i === selected ? "🐾" : "", w / 2 - 176, y + 1);853- ctx.fillStyle = i === selected ? "#0F0E0D" : "#F2EEE6";854- ctx.font = "800 13px Recursive, monospace";855- ctx.textAlign = "center";856- ctx.fillText(item, w / 2, y + 1);225+ renderPauseScene(ctx, {226+ width: w, height: h, now: performance.now(), boardState, boardGame,227+ boardPalette, boardGlyphs, boardFrame, pausedFrom, indexLayout,228+ indexNavigator, indexFacing, indexPopNodeId, indexPopStart,229+ indexLockedNode, title, pauseItems, selected, reducedMotion,230+ coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,231+ onIndexGeometry: (geometry) => { indexGeometry = geometry; },857232 });858- ctx.textAlign = "left";859- ctx.fillStyle = "#8E877C";860- ctx.font = "500 11px Recursive, monospace";861- if (pausedFrom !== "index" && pausedFrom !== "title") {862- ctx.fillText(863- String(boardState?.hint || "Choose an action").trim(),864- 32,865- h - 86,866- );867- }868- const coarsePointer =869- globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;870- if (w > 760 && !coarsePointer) {871- drawKeycap(ctx, "↑↓", "choose", 32, h - 46);872- drawKeycap(ctx, "ENTER", "select", w / 2 - 4, h - 46, "right");873- drawKeycap(ctx, "ESC", "back", w - 32, h - 46, "right");874- } else {875- drawTouchControls(ctx, w, h, coarsePointer);876- }877233 } else if (scene === "win") {878- if (boardState) {879- const cell = Math.min(880- 96,881- Math.max(882- 22,883- Math.floor(884- Math.min(885- (w - 80) / boardState.width,886- (h - 320) / boardState.height,887- ),888- ),889- ),890- );891- drawBoard(ctx, boardState, {892- x: Math.floor((w - boardState.width * cell) / 2),893- y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)),894- cell,895- game: boardGame,896- palette: boardPalette,897- glyphs: boardGlyphs,898- frame: boardFrame,899- });900- }901- ctx.fillStyle = "rgba(0,0,0,.66)";902- ctx.fillRect(0, 0, w, h);903- const cardWidth = Math.min(620, w - 48);904- const cardX = (w - cardWidth) / 2;905- const winProgress =906- reducedMotion || !winStart907- ? 1908- : Math.min(1, (performance.now() - winStart) / 520);909- const back = 1.70158;910- const rawBackEase =911- 1 +912- (back + 1) * Math.pow(winProgress - 1, 3) +913- back * Math.pow(winProgress - 1, 2);914- const winEase = Math.max(0, Math.min(1.1, rawBackEase));915- const cardY = Math.max(120, h * 0.25) + (1 - winEase) * 80;916- const cardHeight = 290;917- ctx.fillStyle = "#191816";918- ctx.fillRect(cardX + 10, cardY + 10, cardWidth, cardHeight);919- ctx.fillStyle = "#F2EEE6";920- ctx.fillRect(cardX, cardY, cardWidth, cardHeight);921- ctx.fillStyle = "#0F0E0D";922- ctx.fillRect(cardX + 2, cardY + 2, cardWidth - 4, cardHeight - 4);923- ctx.strokeStyle = "#2EE6A8";924- ctx.lineWidth = 2;925- ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);926- ctx.textAlign = "left";927- ctx.fillStyle = "#8E877C";928- ctx.font = "600 10px Recursive, monospace";929- const winningRule =930- boardState?.rules?.find((rule) => rule.predicate === "WIN")?.text ||931- boardState?.rules?.[0]?.text ||932- "MIAU IS WIN";933- const sentenceWords = winningRule.split(/\s+/).filter(Boolean);934- const sentenceSize = Math.min(22, Math.max(14, cardWidth * 0.035));935- ctx.font = `800 ${sentenceSize}px Recursive, monospace`;936- const sentenceWidth =937- sentenceWords.reduce(938- (total, word) =>939- total +940- Math.max(941- sentenceSize * 1.6,942- ctx.measureText(word.toUpperCase()).width + sentenceSize,943- ),944- 0,945- ) +946- Math.max(0, sentenceWords.length - 1) * sentenceSize * 0.4;947- drawSentenceTiles(948- ctx,949- winningRule,950- cardX + Math.max(32, (cardWidth - sentenceWidth) / 2),951- cardY + 52,952- cardWidth - 64,953- sentenceSize,954- );955- ctx.fillStyle = "#2EE6A8";956- ctx.fillRect(957- cardX + 32,958- cardY + 100,959- (cardWidth - 64) * (reducedMotion ? 1 : Math.min(1, winProgress * 1.5)),960- 3,961- );962- ctx.textAlign = "center";963- ctx.fillStyle = "#F2EEE6";964- const headline = `PROVEN IN ${boardState?.moves || 0} STEPS`;965- let headlineSize = Math.min(44, Math.max(26, w * 0.055));966- do {967- ctx.font = `800 ${headlineSize}px Recursive, monospace`;968- if (969- ctx.measureText(headline).width <= cardWidth - 64 ||970- headlineSize <= 22971- )972- break;973- headlineSize -= 2;974- } while (headlineSize > 22);975- const headlineY = cardY + 154;976- ctx.fillText(headline, w / 2, headlineY);977- ctx.fillStyle = "#8E877C";978- ctx.font = "600 16px Recursive, monospace";979- const progressY = headlineY + headlineSize / 2 + 16 + 8;980- ctx.fillText(981- `${boardState?.completedCount ?? 0} of ${boardState?.proofTotal ?? 9} proofs complete`,982- w / 2,983- progressY,984- );985- const coarsePointer =986- globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;987- if (w <= 760) {988- const actionY = cardY + 250;989- drawStackedAction(990- ctx,991- "ENTER",992- "continue",993- cardX + cardWidth / 6,994- actionY,995- );996- drawStackedAction(ctx, "Z", "undo", cardX + cardWidth / 2, actionY);997- drawStackedAction(998- ctx,999- "R",1000- "replay",1001- cardX + (cardWidth * 5) / 6,1002- actionY,1003- );1004- } else if (!coarsePointer) {1005- drawInlineActions(1006- ctx,1007- [1008- { key: "ENTER", label: "continue" },1009- { key: "Z", label: "undo" },1010- { key: "R", label: "replay" },1011- ],1012- w / 2,1013- cardY + 250,1014- );1015- }1016- if (w <= 760 || coarsePointer)1017- drawTouchControls(ctx, w, h, coarsePointer);1018- ctx.textAlign = "left";234+ renderWinScene(ctx, {235+ width: w, height: h, now: performance.now(), boardState, boardGame,236+ boardPalette, boardGlyphs, boardFrame, reducedMotion, winStart,237+ coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,238+ });1019239 } else {1020240 const spec = SCENES[scene] || proofScene;1021241 ctx.textAlign = "center";@@ -1057,35 +277,7 @@ export function createStage(options = {}) {
1057277 if (id === "pause" && scene !== "pause") {1058278 pausedFrom = scene;1059279 selected = 0;1060- pauseItems.splice(1061- 0,1062- pauseItems.length,1063- ...(pausedFrom === "index" || pausedFrom === "title"1064- ? ["RESUME", "SOUND ON", "MUSIC OFF", "TITLE"]1065- : [1066- "RESUME",1067- "RESTART",1068- "INDEX",1069- "WORKSHOP",1070- "SOUND ON",1071- "MUSIC OFF",1072- ]),1073- );1074- pauseItems.splice(1075- 0,1076- pauseItems.length,1077- ...pauseItems.map((item) =>1078- item.startsWith("SOUND")1079- ? pauseStatus.soundOn1080- ? "SOUND ON"1081- : "SOUND OFF"1082- : item.startsWith("MUSIC")1083- ? pauseStatus.musicOn1084- ? "MUSIC ON"1085- : "MUSIC OFF"1086- : item,1087- ),1088- );280+ pauseItems.splice(0, pauseItems.length, ...pauseMenuItems(pausedFrom, pauseStatus));1089281 }1090282 scene = id;1091283 const spec = SCENES[id] || proofScene;@@ -1153,12 +345,7 @@ export function createStage(options = {}) {
1153345 }1154346 function setPauseStatus(value = {}) {1155347 pauseStatus = { ...pauseStatus, ...value };1156- for (let i = 0; i < pauseItems.length; i++) {1157- if (pauseItems[i].startsWith("SOUND"))1158- pauseItems[i] = pauseStatus.soundOn ? "SOUND ON" : "SOUND OFF";1159- if (pauseItems[i].startsWith("MUSIC"))1160- pauseItems[i] = pauseStatus.musicOn ? "MUSIC ON" : "MUSIC OFF";1161- }348+ pauseItems.splice(0, pauseItems.length, ...pauseMenuItems(pausedFrom, pauseStatus));1162349 render();1163350 return api;1164351 }@@ -1166,6 +353,74 @@ export function createStage(options = {}) {
1166353 selected = Math.max(0, index);1167354 root.dataset.selected = String(selected);1168355 }356+ function activatePauseSelection() {357+ const action = pauseItems[selected]?.id;358+ if (action === "resume") hide();359+ else if (action === "restart") options.onPauseAction?.("restart");360+ else if (action === "index") show("index");361+ else if (action === "workshop") show("workshop");362+ else if (action === "title") show("title");363+ else if (action === "sound") options.onPauseAction?.("sound");364+ else if (action === "music") options.onPauseAction?.("music");365+ return Boolean(action);366+ }367+ function dispatchOverlayIntent(intent = {}) {368+ const type = intent.type || intent;369+ if (scene === "pause") {370+ if (type === "move") {371+ if (intent.dir === "up")372+ selected = (selected + pauseItems.length - 1) % pauseItems.length;373+ if (intent.dir === "down") selected = (selected + 1) % pauseItems.length;374+ render();375+ return true;376+ }377+ if (type === "confirm") return activatePauseSelection();378+ if (type === "menu") {379+ hide();380+ return true;381+ }382+ return false;383+ }384+ if (scene === "index") {385+ if (type === "move" && indexNavigator) {386+ moveIndex(intent.dir);387+ render();388+ return true;389+ }390+ if (type === "confirm" && indexNavigator) {391+ const choice = indexNavigator.enter();392+ if (choice?.ok && options.onSelectLevel?.(choice.node.id) !== false)393+ show("proof");394+ else render();395+ return true;396+ }397+ if (type === "menu") {398+ show("pause");399+ return true;400+ }401+ return false;402+ }403+ if (scene === "workshop" && workshop) {404+ workshop.handleInput(405+ type === "confirm" ? "enter" : type === "menu" ? "escape" : intent.dir || type,406+ );407+ render();408+ return true;409+ }410+ if (scene === "gift" && gift) {411+ const result = gift.input(412+ type === "confirm" ? "confirm" : type === "menu" ? "escape" : intent.dir || type,413+ );414+ if (result.action === "later") closeGift();415+ render();416+ return true;417+ }418+ if (scene === "win" && type === "confirm") {419+ show("index");420+ return true;421+ }422+ return false;423+ }1169424 keepButton.addEventListener("click", () => {1170425 options.onConfirm?.("gift");1171426 hide();@@ -1188,13 +443,7 @@ export function createStage(options = {}) {
1188443 )1189444 event.stopPropagation();1190445 if (event.key === "Escape") {1191- if (scene === "workshop") workshop?.handleInput?.("escape");1192- else if (scene === "gift") {1193- gift?.input("escape");1194- closeGift();1195- } else if (scene === "index") show("pause");1196- else hide();1197- render();446+ dispatchOverlayIntent({ type: "menu" });1198447 event.preventDefault();1199448 return;1200449 }@@ -1212,91 +461,44 @@ export function createStage(options = {}) {
1212461 return;1213462 }1214463 if (scene === "gift" && gift) {1215- const result = gift.input(1216- event.key === "Enter"1217- ? "confirm"1218- : event.key === "Escape"1219- ? "escape"1220- : event.key === "ArrowUp"1221- ? "up"1222- : event.key === "ArrowDown"1223- ? "down"1224- : event.key === "ArrowLeft"1225- ? "left"1226- : event.key === "ArrowRight"1227- ? "right"1228- : "noop",1229- );1230- if (result.action === "later") closeGift();1231- render();464+ dispatchOverlayIntent({ type: overlayInputForKey(event.key) });1232465 event.preventDefault();1233466 return;1234467 }1235468 if (scene === "workshop" && workshop) {1236- workshop.handleInput(1237- event.key === "Enter"1238- ? "enter"1239- : event.key === "Escape"1240- ? "escape"1241- : event.key === "ArrowUp"1242- ? "up"1243- : event.key === "ArrowDown"1244- ? "down"1245- : event.key === "ArrowLeft"1246- ? "left"1247- : event.key === "ArrowRight"1248- ? "right"1249- : "noop",1250- );1251- render();469+ dispatchOverlayIntent({ type: overlayInputForKey(event.key) });1252470 event.preventDefault();1253471 return;1254472 }1255473 if (scene === "pause") {1256474 if (event.key === "ArrowUp") {1257- selected = (selected + pauseItems.length - 1) % pauseItems.length;1258- render();475+ dispatchOverlayIntent({ type: "move", dir: "up" });1259476 event.preventDefault();1260477 return;1261478 }1262479 if (event.key === "ArrowDown") {1263- selected = (selected + 1) % pauseItems.length;1264- render();480+ dispatchOverlayIntent({ type: "move", dir: "down" });1265481 event.preventDefault();1266482 return;1267483 }1268484 if (event.key === "Enter") {1269- const action = pauseItems[selected];1270- if (action === "RESUME") hide();1271- else if (action === "RESTART") options.onPauseAction?.("restart");1272- else if (action === "INDEX") show("index");1273- else if (action === "WORKSHOP") show("workshop");1274- else if (action === "TITLE") show("title");1275- else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");1276- else options.onPauseAction?.("music");485+ dispatchOverlayIntent({ type: "confirm" });1277486 event.preventDefault();1278487 return;1279488 }1280489 }1281490 if (scene === "index" && event.key === "Enter") {1282- const choice = indexNavigator?.enter();1283- if (choice?.ok && options.onSelectLevel?.(choice.node.id) !== false)1284- show("proof");1285- else render();491+ dispatchOverlayIntent({ type: "confirm" });1286492 event.preventDefault();1287493 return;1288494 }1289495 if (scene === "index" && ["ArrowRight", "ArrowDown"].includes(event.key)) {1290- const result = moveIndex(event.key === "ArrowRight" ? "right" : "down");1291- selected++;1292- render();496+ dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowRight" ? "right" : "down" });1293497 event.preventDefault();1294498 return;1295499 }1296500 if (scene === "index" && ["ArrowLeft", "ArrowUp"].includes(event.key)) {1297- const result = moveIndex(event.key === "ArrowLeft" ? "left" : "up");1298- selected = Math.max(0, selected - 1);1299- render();501+ dispatchOverlayIntent({ type: "move", dir: event.key === "ArrowLeft" ? "left" : "up" });1300502 event.preventDefault();1301503 return;1302504 }@@ -1325,12 +527,7 @@ export function createStage(options = {}) {
1325527 if (!direction) return;1326528 event.preventDefault();1327529 event.stopImmediatePropagation();1328- moveIndex(direction);1329- selected =1330- direction === "left" || direction === "up"1331- ? Math.max(0, selected - 1)1332- : selected + 1;1333- render();530+ dispatchOverlayIntent({ type: "move", dir: direction });1334531 }1335532 addEventListener("resize", resize);1336533 resize();@@ -1416,88 +613,13 @@ export function createStage(options = {}) {
1416613 const index = Math.round((y - innerHeight * 0.24) / 54);1417614 if (index >= 0 && index < pauseItems.length) {1418615 selected = index;1419- const action = pauseItems[index];1420- if (action === "RESUME") hide();1421- else if (action === "INDEX") show("index");1422- else if (action === "WORKSHOP") show("workshop");1423- else if (action === "TITLE") show("title");1424- else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");1425- else if (action.startsWith("MUSIC")) options.onPauseAction?.("music");1426- else options.onPauseAction?.("restart");616+ activatePauseSelection();1427617 }1428618 }1429619 render();1430620 });1431621 function handleIntent(intent = {}) {1432- const type = intent.type || intent;1433- if (scene === "pause") {1434- if (type === "move") {1435- const dir = intent.dir;1436- if (dir === "up")1437- selected = (selected + pauseItems.length - 1) % pauseItems.length;1438- if (dir === "down") selected = (selected + 1) % pauseItems.length;1439- render();1440- return true;1441- }1442- if (type === "confirm") {1443- const action = pauseItems[selected];1444- if (action === "RESUME") hide();1445- else if (action === "INDEX") show("index");1446- else if (action === "WORKSHOP") show("workshop");1447- else if (action === "TITLE") show("title");1448- else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");1449- else if (action.startsWith("MUSIC")) options.onPauseAction?.("music");1450- else options.onPauseAction?.("restart");1451- return true;1452- }1453- if (type === "menu") hide();1454- }1455- if (scene === "index") {1456- if (type === "move" && indexNavigator) {1457- const result = moveIndex(intent.dir);1458- render();1459- return true;1460- }1461- if (type === "confirm" && indexNavigator) {1462- const choice = indexNavigator.enter();1463- if (choice?.ok && options.onSelectLevel?.(choice.node.id) !== false)1464- show("proof");1465- else render();1466- return true;1467- }1468- if (type === "menu") {1469- show("pause");1470- return true;1471- }1472- }1473- if (scene === "workshop" && workshop) {1474- workshop.handleInput(1475- type === "confirm"1476- ? "enter"1477- : type === "menu"1478- ? "escape"1479- : intent.dir || type,1480- );1481- render();1482- return true;1483- }1484- if (scene === "win" && type === "confirm") {1485- show("index");1486- return true;1487- }1488- if (scene === "gift" && gift) {1489- const result = gift.input(1490- type === "confirm"1491- ? "confirm"1492- : type === "menu"1493- ? "escape"1494- : intent.dir || type,1495- );1496- if (result.action === "later") closeGift();1497- render();1498- return true;1499- }1500- return false;622+ return dispatchOverlayIntent(intent);1501623 }1502624 const api = {1503625 show,@@ -0,0 +1,34 @@
1+/**2+ * Shared translation for the canvas stage's non-gameplay controls.3+ *4+ * Gameplay keys are owned by src/input.js. This module only describes the5+ * vocabulary used by the stage overlays so keyboard, pointer, and public6+ * intent dispatch can share the same semantic names.7+ */8+9+const KEY_INPUTS = Object.freeze({10+ Enter: "confirm",11+ Escape: "menu",12+ ArrowUp: "up",13+ ArrowDown: "down",14+ ArrowLeft: "left",15+ ArrowRight: "right",16+});17+18+export function overlayInputForKey(key) {19+ return KEY_INPUTS[key] || "noop";20+}21+22+export function pauseMenuItems(origin, status = {}) {23+ const ids = origin === "index" || origin === "title"24+ ? ["resume", "sound", "music", "title"]25+ : ["resume", "restart", "index", "workshop", "sound", "music"];26+ return ids.map((id) => ({27+ id,28+ label: id === "sound"29+ ? `SOUND ${status.soundOn ? "ON" : "OFF"}`30+ : id === "music"31+ ? `MUSIC ${status.musicOn ? "ON" : "OFF"}`32+ : id.toUpperCase(),33+ }));34+}@@ -0,0 +1,11 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { pauseMenuItems } from '../src/stage/dispatch.js';4+5+test('pause actions keep stable IDs when sound and music labels change', () => {6+ const before = pauseMenuItems('proof', { soundOn: true, musicOn: false });7+ const after = pauseMenuItems('proof', { soundOn: false, musicOn: true });8+ assert.deepEqual(before.map(item => item.id), after.map(item => item.id));9+ assert.deepEqual(after.filter(item => ['sound', 'music'].includes(item.id)).map(item => item.label), ['SOUND OFF', 'MUSIC ON']);10+ assert.deepEqual(pauseMenuItems('index', { soundOn: true, musicOn: false }).map(item => item.id), ['resume', 'sound', 'music', 'title']);11+});