clone

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

Split stage scenes and unify overlay action routing

Dami committed 60fa0c0
commit
60fa0c017c61d20d13845ca00f0fc6cf6e77f5a6
parent
74c94f1c1a92a9634b0c089e5091127697ebaf0b
author
Dami <dami.etoile@gmail.com>
6 files changed, +993 -984
Asrc/render/stage-hud.js427++++++++++++
Asrc/render/stage-overlays.js242+++++++
Asrc/render/stage-proof.js173+++++
Msrc/stage.js1090+++---------------------------
Asrc/stage/dispatch.js34+
Atests/stage-dispatch.test.js11+
A src/render/stage-hud.js +427 -0
@@ -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+}
A src/render/stage-overlays.js +242 -0
@@ -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+}
A src/render/stage-proof.js +173 -0
@@ -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;
M src/stage.js +106 -984
@@ -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,
A src/stage/dispatch.js +34 -0
A tests/stage-dispatch.test.js +11 -0