clone

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

feat(stage): add full-screen ink scenes and responsive controls

Dami committed e466106
commit
e46610623ee9ca05e6da692ea57e22dc89f1b343
parent
1c8f65cc2214ccf3f77cd29e9f827a4c3e1872da
author
Dami <dami.etoile@gmail.com>
9 files changed, +705 -11
Mindex.html14+-
Msrc/overrides.css7+-
Asrc/scenes/index.js1+
Asrc/scenes/pause.js1+
Asrc/scenes/proof.js1+
Msrc/scenes/title.js4+-
Asrc/scenes/win.js1+
Asrc/stage.js686++++++++++++++++++++++++++++++
Msrc/style.css1-
M index.html +7 -7
@@ -4,29 +4,29 @@
44 <meta charset="UTF-8" />55 <meta name="viewport" content="width=device-width, initial-scale=1" />66 <meta name="theme-color" content="#f5efe4" />7- <title>Miau Is True — rule garden</title>7+ <title>Miau Is True — rule engine</title>88 </head>99 <body>1010 <main class="app-shell">1111 <header class="topbar">12- <div class="brand-mark" aria-label="Miau Is True"><span>✳</span><div><strong>MIAU IS TRUE</strong><small>rule garden / 01</small></div></div>12+ <div class="brand-mark" aria-label="Miau Is True"><span>✳</span><div><strong>MIAU IS TRUE</strong><small>rule engine / 01</small></div></div>1313 <div class="top-actions"><span class="status-dot" id="status-dot"></span><span id="status-label" role="status" aria-live="polite">READY TO EXPLORE</span><button class="icon-button" id="sound-btn" title="Toggle sound" aria-label="Toggle sound">◖</button><button class="icon-button" id="fullscreen-btn" title="Fullscreen (F)" aria-label="Toggle fullscreen">⛶</button></div>1414 </header>1515 <div class="app-layout">1616 <section class="play-column">17- <div class="level-strip"><div><span class="eyebrow">CURRENT GARDEN</span><h1 id="level-title">Loading…</h1></div><div class="level-nav"><button class="soft-button" id="prev-level" aria-label="Previous level">←</button><span id="level-index">01 / 03</span><button class="soft-button" id="next-level" aria-label="Next level">→</button></div></div>18- <div class="board-wrap"><canvas id="game-canvas" width="960" height="720" tabindex="0" aria-label="Puzzle board" aria-describedby="board-description"></canvas><pre id="board-description" class="sr-only"></pre><div class="win-toast" id="win-toast" hidden><span>✦</span><strong>RULES ALIGNED</strong><small>the garden is listening</small><button class="toast-button" id="continue-btn">CONTINUE →</button></div></div>17+ <div class="level-strip"><div><span class="eyebrow">CURRENT PROOF</span><h1 id="level-title">Loading…</h1></div><div class="level-nav"><button class="soft-button" id="prev-level" aria-label="Previous level">←</button><span id="level-index">01 / 03</span><button class="soft-button" id="next-level" aria-label="Next level">→</button></div></div>18+ <div class="board-wrap"><canvas id="game-canvas" width="960" height="720" tabindex="0" aria-label="Puzzle board" aria-describedby="board-description"></canvas><pre id="board-description" class="sr-only"></pre><div class="win-toast" id="win-toast" hidden><span>✦</span><strong>RULES ALIGNED</strong><small>the proof is complete</small><button class="toast-button" id="continue-btn">CONTINUE →</button></div></div>1919 <div class="under-board"><div class="legend"><span><i class="legend-swatch you"></i> YOU</span><span><i class="legend-swatch word"></i> WORD</span><span><i class="legend-swatch object"></i> OBJECT</span></div><div class="move-counter"><span>STEPS</span><strong id="move-count">0</strong><button class="action-button" id="undo-btn" disabled>UNDO <kbd>Z</kbd></button><button class="action-button" id="restart-btn">RESTART <kbd>R</kbd></button></div></div>2020 <div class="mobile-controls" aria-label="Directional controls"><button data-dir="up">↑</button><div><button data-dir="left">←</button><button data-dir="down">↓</button><button data-dir="right">→</button></div></div>2121 </section>2222 <aside class="sidebar">2323 <section class="panel rules-panel"><div class="panel-heading"><div><span class="eyebrow">ACTIVE SYSTEM</span><h2>Rules in play</h2></div><span class="pulse"></span></div><div id="rules-list" class="rules-list"></div><p class="panel-note" id="hint-copy"></p></section>24- <section class="panel controls-panel"><div class="panel-heading"><div><span class="eyebrow">FIELD NOTES</span><h2>Controls</h2></div></div><div class="key-row"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd><span>or arrow keys <em>move</em></span></div><div class="key-row"><kbd>Z</kbd><span>undo last step</span><kbd>R</kbd><span>restart garden</span></div></section>24+ <section class="panel controls-panel"><div class="panel-heading"><div><span class="eyebrow">FIELD NOTES</span><h2>Controls</h2></div></div><div class="key-row"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd><span>or arrow keys <em>move</em></span></div><div class="key-row"><kbd>Z</kbd><span>undo last step</span><kbd>R</kbd><span>restart proof</span></div></section>2525 <section class="panel levels-panel"><div class="panel-heading"><div><span class="eyebrow">CHOOSE A PATH</span><h2>Gardens</h2></div></div><div id="level-list" class="level-list"></div></section>26- <section class="panel mod-panel"><div class="panel-heading"><div><span class="eyebrow">MAKE IT YOURS</span><h2>Mod loader</h2></div><span class="mod-badge">CJS</span></div><p>Load a local CommonJS level pack and grow the vocabulary.</p><label class="file-button">Import module<input id="mod-input" type="file" accept=".js,.cjs" /></label><div id="mod-status" class="mod-status" role="status">No module loaded</div></section>26+ <section class="panel mod-panel"><div class="panel-heading"><div><span class="eyebrow">MAKE IT YOURS</span><h2>Level packs</h2></div><span class="mod-badge">CJS</span></div><p>Chosen CommonJS files run JavaScript in this browser. Import only files you trust. Share URLs carry data only.</p><label class="file-button">Import trusted module<input id="mod-input" type="file" accept=".js,.cjs" /></label><div id="mod-status" class="mod-status" role="status">Bundled packs ready</div><div id="pack-list" class="pack-list" aria-label="Loaded packs"></div><div class="mod-actions"><button class="file-button" id="export-pack">Download .cjs</button><button class="file-button" id="copy-share">Copy share URL</button></div><input id="share-url-input" class="share-url-input" data-share-url aria-label="Share URL" readonly placeholder="Share URL appears here" /></section>2727 </aside>2828 </div>29- <footer><span>move words to rewrite the world</span><span>© 2026 / open garden</span></footer>29+ <footer><span>move words to rewrite the world</span><span>© 2026 / open proofs</span></footer>3030 </main>3131 <script type="module" src="/src/main.js"></script>3232 </body>
M src/overrides.css +6 -1
@@ -1 +1,6 @@
1-.status-dot.blocked{background:#d26e57}.icon-button{margin-left:6px}.move-counter{gap:7px}.action-button{border:1px solid #d2c4b2;background:#f6eee2;color:#706659;border-radius:6px;padding:6px 8px;font:9px 'DM Mono',monospace;letter-spacing:.02em;cursor:pointer}.action-button:hover:not(:disabled){background:#eee1d0}.action-button:disabled{opacity:.42;cursor:not-allowed}.action-button kbd{font:9px 'DM Mono',monospace;color:#a29482}.win-toast[hidden]{display:none}.toast-button{margin-top:9px;border:1px solid #756b5d;background:#e8b34e;color:#302e2a;border-radius:5px;padding:7px 10px;font:10px 'DM Mono',monospace;cursor:pointer}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}1+.status-dot.blocked{background:#d26e57}.icon-button{margin-left:6px}.move-counter{gap:7px}.action-button{border:1px solid #d2c4b2;background:#f6eee2;color:#706659;border-radius:6px;padding:6px 8px;font:9px 'DM Mono',monospace;letter-spacing:.02em;cursor:pointer}.action-button:hover:not(:disabled){background:#eee1d0}.action-button:disabled{opacity:.42;cursor:not-allowed}.action-button kbd{font:9px 'DM Mono',monospace;color:#a29482}.win-toast[hidden]{display:none}.toast-button{margin-top:9px;border:1px solid #756b5d;background:#e8b34e;color:#302e2a;border-radius:5px;padding:7px 10px;font:10px 'DM Mono',monospace;cursor:pointer}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.pack-list{display:grid;gap:4px;margin-top:12px}.pack-choice{display:flex;align-items:baseline;justify-content:space-between;gap:8px;border:1px solid transparent;border-radius:6px;background:transparent;text-align:left;padding:7px;color:#675e52;cursor:pointer}.pack-choice:hover,.pack-choice.active{background:#efe4d4;border-color:#e0d0ba}.pack-choice strong{font-size:11px;font-weight:500}.pack-choice small{font:9px 'DM Mono',monospace;color:#9b8c7b}.mod-actions{display:flex;gap:6px;margin-top:10px}.mod-actions .file-button{font-size:9px;padding:7px 8px}.share-url-input{display:block;width:100%;margin-top:8px;border:1px solid #d6c8b5;background:#fffaf2;color:#8c7e6d;border-radius:5px;padding:7px 8px;font:9px 'DM Mono',monospace;overflow:hidden;text-overflow:ellipsis}2+.stage-shell{position:fixed;inset:0;z-index:20;display:grid;place-items:center;background:rgba(15,14,13,.92);color:#f2eee6;touch-action:none}.stage-shell[hidden]{display:none}.stage-card{min-width:min(86vw,520px);padding:42px 46px;border:1px solid #4a4640;background:#191816;box-shadow:10px 10px 0 #000;text-align:center}.stage-eyebrow{font:11px 'DM Mono',monospace;letter-spacing:.18em;color:#ff4b3e}.stage-title{margin:16px 0 28px;font:600 clamp(27px,5vw,52px)/1.05 'Space Grotesk',sans-serif;letter-spacing:-.05em}.stage-prompt{display:inline-block;padding:10px 15px;border:1px solid #f2eee6;font:600 12px 'DM Mono',monospace;letter-spacing:.08em}.stage-help{margin-top:28px;color:#8e877c;font:10px 'DM Mono',monospace;letter-spacing:.08em}3+.stage-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}4+.stage-player{background:#0F0E0D;overflow:hidden}.stage-player .app-shell{display:none}.stage-player .stage-canvas{display:block;position:fixed;inset:0;width:100vw;height:100vh;max-height:none;border-radius:0;background:#0F0E0D}.stage-player .sidebar{display:none}.stage-player .app-layout{display:block;max-width:1080px;margin:0 auto}.stage-player .play-column{width:100%}.stage-player .board-wrap{background:#191816;border-color:#4a4640}.stage-player .board-wrap canvas{background:#191816}.stage-player .level-strip{color:#f2eee6}.stage-player .level-strip h1{color:#f2eee6}.stage-player .under-board{color:#8e877c}5+@media (max-width:760px){.stage-player .stage-canvas{touch-action:none}.stage-card{min-width:88vw;padding:30px 22px}}6+.stage-choices{display:flex;justify-content:center;gap:10px;margin:16px 0 4px}.stage-choice{border:1px solid #8e877c;background:#191816;color:#f2eee6;padding:10px 12px;font:600 10px 'DM Mono',monospace;letter-spacing:.06em;cursor:pointer}.stage-choice-primary{background:#ff4b3e;border-color:#ff4b3e;color:#0f0e0d}
A src/scenes/index.js +1 -0
A src/scenes/pause.js +1 -0
A src/scenes/proof.js +1 -0
M src/scenes/title.js +2 -2
A src/scenes/win.js +1 -0
A src/stage.js +686 -0
@@ -0,0 +1,686 @@
1+import { titleScene } from "./scenes/title.js";2+import { indexScene } from "./scenes/index.js";3+import { proofScene } from "./scenes/proof.js";4+import { pauseScene } from "./scenes/pause.js";5+import { winScene } from "./scenes/win.js";6+import { drawBoard } from "./render/board.js";7+import { drawIndex } from "./render/index.js";8+import { createGiftScene } from "./scenes/gift.js";9+import { createWorkshopScene } from "./scenes/workshop.js";10+11+const SCENES = {12+ title: titleScene,13+ index: indexScene,14+ proof: proofScene,15+ pause: pauseScene,16+ win: winScene,17+ workshop: {18+ id: "workshop",19+ eyebrow: "WORKSHOP",20+ title: "Packs and proofs",21+ prompt: "IMPORT / PLAY / SHARE / EXPORT",22+ help: "ESC BACK",23+ },24+ gift: {25+ id: "gift",26+ eyebrow: "A GIFT FOR YOU",27+ title: "A proof pack has arrived",28+ prompt: "KEEP AND PLAY",29+ help: "ENTER KEEP / ESC NOT NOW",30+ },31+};32+33+/** Small scene shell. Gameplay remains owned by main.js; this owns scene intent and overlays. */34+export function createStage(options = {}) {35+ const canvas = document.createElement("canvas");36+ canvas.className = "stage-canvas";37+ canvas.tabIndex = 0;38+ canvas.setAttribute("aria-label", "Miau Is True game stage");39+ document.body.append(canvas);40+ const ctx = canvas.getContext("2d");41+ let boardState = null;42+ let boardGame = null;43+ let boardPalette = {};44+ let boardGlyphs = {};45+ let boardFrame = null;46+ let indexLayout = null;47+ let indexNavigator = null;48+ let indexGeometry = null;49+ let title = options.title || null;50+ let gift = options.gift || null;51+ let workshop = options.workshop || null;52+ let dpr = 1;53+ const root = document.createElement("div");54+ root.className = "stage-shell";55+ root.setAttribute("aria-live", "polite");56+ root.hidden = true;57+ const live = document.createElement("div");58+ live.className = "stage-sr";59+ live.setAttribute("role", "status");60+ live.setAttribute("aria-live", "polite");61+ live.setAttribute("aria-atomic", "true");62+ document.body.append(live);63+ const card = document.createElement("div");64+ card.className = "stage-card";65+ const eyebrow = document.createElement("div");66+ eyebrow.className = "stage-eyebrow";67+ const titleEl = document.createElement("h2");68+ titleEl.className = "stage-title";69+ const prompt = document.createElement("div");70+ prompt.className = "stage-prompt";71+ const help = document.createElement("div");72+ help.className = "stage-help";73+ const choices = document.createElement("div");74+ choices.className = "stage-choices";75+ const keepButton = document.createElement("button");76+ keepButton.className = "stage-choice stage-choice-primary";77+ keepButton.textContent = "KEEP AND PLAY";78+ const laterButton = document.createElement("button");79+ laterButton.className = "stage-choice";80+ laterButton.textContent = "NOT NOW";81+ choices.append(keepButton, laterButton);82+ choices.hidden = true;83+ card.append(eyebrow, titleEl, prompt, choices, help);84+ root.append(card);85+ document.body.append(root);86+ let scene = "proof";87+ let selected = 0;88+ const pauseItems = [89+ "RESUME",90+ "RESTART",91+ "INDEX",92+ "WORKSHOP",93+ "SOUND ON",94+ "MUSIC OFF",95+ ];96+ let pauseStatus = { soundOn: true, musicOn: false };97+ let transitionStart = 0;98+ let transitionGeneration = 0;99+ const reducedMotion =100+ globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ??101+ false;102+ function resize() {103+ dpr = Math.max(1, Math.min(2, globalThis.devicePixelRatio || 1));104+ canvas.width = innerWidth * dpr;105+ canvas.height = innerHeight * dpr;106+ canvas.style.width = `${innerWidth}px`;107+ canvas.style.height = `${innerHeight}px`;108+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);109+ render();110+ }111+ function announce(message) {112+ live.textContent = message;113+ }114+ function drawTransition(now, generation) {115+ if (!transitionStart || reducedMotion || generation !== transitionGeneration) return;116+ const progress = Math.max(0, Math.min(1, (now - transitionStart) / 280));117+ const radius = Math.hypot(innerWidth, innerHeight) * progress;118+ render();119+ ctx.save();120+ ctx.fillStyle = "#0F0E0D";121+ ctx.beginPath();122+ ctx.rect(0, 0, innerWidth, innerHeight);123+ ctx.arc(innerWidth / 2, innerHeight / 2, radius, 0, Math.PI * 2, true);124+ ctx.fill("evenodd");125+ ctx.restore();126+ if (progress < 1)127+ requestAnimationFrame((time) => drawTransition(time, generation));128+ else transitionStart = 0;129+ }130+ function drawKeycap(ctx, key, label, x, y, align = "left") {131+ ctx.save();132+ ctx.textAlign = align;133+ ctx.textBaseline = "middle";134+ ctx.font = "800 10px Recursive, monospace";135+ const keyWidth = Math.max(24, ctx.measureText(key).width + 12);136+ const left = align === "right" ? x - keyWidth : x;137+ ctx.fillStyle = "#F2EEE6";138+ ctx.fillRect(left, y - 10, keyWidth, 20);139+ ctx.fillStyle = "#0F0E0D";140+ ctx.fillText(key, align === "right" ? x - 6 : x + 6, y + 1);141+ ctx.font = "600 10px Recursive, monospace";142+ ctx.fillStyle = "#8E877C";143+ ctx.fillText(144+ label,145+ align === "right" ? left - 8 : left + keyWidth + 8,146+ y + 1,147+ );148+ ctx.restore();149+ }150+ function drawTouchControls(ctx, width, height) {151+ if (width > 760) return;152+ const y = height - 30;153+ const controls = [154+ { label: "UNDO", key: "Z", x: 18 },155+ { label: "MOVE", key: "SWIPE", x: width / 2, center: true },156+ { label: "PAUSE", key: "ESC", x: width - 18, right: true },157+ ];158+ ctx.save();159+ ctx.textBaseline = "middle";160+ for (const control of controls) {161+ const text = `${control.key} ${control.label}`;162+ ctx.font = "700 10px Recursive, monospace";163+ const boxWidth = ctx.measureText(text).width + 18;164+ const left = control.center165+ ? control.x - boxWidth / 2166+ : control.right167+ ? control.x - boxWidth168+ : control.x;169+ ctx.fillStyle = "#232120";170+ ctx.strokeStyle = "#4A4640";171+ ctx.lineWidth = 1;172+ ctx.fillRect(left, y - 14, boxWidth, 28);173+ ctx.strokeRect(left + 0.5, y - 13.5, boxWidth - 1, 27);174+ ctx.fillStyle = "#F2EEE6";175+ ctx.textAlign = "center";176+ ctx.fillText(text, left + boxWidth / 2, y + 1);177+ }178+ ctx.restore();179+ }180+ function render() {181+ const w = innerWidth,182+ h = innerHeight;183+ ctx.fillStyle = "#0F0E0D";184+ ctx.fillRect(0, 0, w, h);185+ if (scene === "title" && title) {186+ title.draw(ctx, { width: w, height: h });187+ return;188+ }189+ if (scene === "gift" && gift) {190+ gift.draw(ctx, performance.now(), { width: w, height: h });191+ return;192+ }193+ if (scene === "workshop" && workshop) {194+ workshop.draw(ctx, { width: w, height: h });195+ return;196+ }197+ if (scene === "proof" && boardState) {198+ const cell = Math.min(199+ 96,200+ Math.max(201+ 22,202+ Math.floor(203+ Math.min(204+ (w - 80) / boardState.width,205+ (h - 240) / boardState.height,206+ ),207+ ),208+ ),209+ );210+ const x = Math.floor((w - boardState.width * cell) / 2),211+ y = Math.floor((h - boardState.height * cell) / 2 - 30);212+ drawBoard(ctx, boardState, {213+ x,214+ y,215+ cell,216+ game: boardGame,217+ palette: boardPalette,218+ glyphs: boardGlyphs,219+ frame: boardFrame,220+ });221+ ctx.textAlign = "left";222+ ctx.textBaseline = "top";223+ ctx.fillStyle = "#F2EEE6";224+ ctx.font = "600 13px Recursive, monospace";225+ ctx.fillText("PROOF", 32, 38);226+ ctx.font = "500 12px Recursive, monospace";227+ ctx.fillStyle = "#8E877C";228+ ctx.fillText(229+ `${boardState.title || ""} ${boardState.moves || 0} STEPS`,230+ 32,231+ 60,232+ );233+ const rules = (boardState.rules || [])234+ .map((rule) => rule.text)235+ .join(" ");236+ ctx.fillStyle = "#F2EEE6";237+ ctx.font = "600 12px Recursive, monospace";238+ ctx.fillText(239+ rules || "NO RULES ACTIVE",240+ x,241+ y + boardState.height * cell + 38,242+ );243+ const hint = String(boardState.hint || "").trim();244+ if (hint) {245+ ctx.fillStyle = "#8E877C";246+ ctx.font = "500 11px Recursive, monospace";247+ ctx.fillText(248+ `HINT ${hint}`,249+ x,250+ Math.min(y + boardState.height * cell + 58, h - 58),251+ );252+ }253+ if (w > 760) {254+ drawKeycap(ctx, "Z", "UNDO", 32, h - 26);255+ drawKeycap(ctx, "R", "RESTART", 32, h - 52);256+ drawKeycap(ctx, "ESC", "PAUSE", w - 32, h - 26, "right");257+ }258+ drawTouchControls(ctx, w, h);259+ } else if (scene === "index" && indexLayout && indexNavigator) {260+ indexGeometry = drawIndex(ctx, indexLayout, indexNavigator, {261+ availableWidth: w,262+ availableHeight: h,263+ cat: boardGlyphs.MIAU || "🐱",264+ });265+ } else if (scene === "pause") {266+ if (boardState) {267+ const cell = Math.min(268+ 96,269+ Math.max(270+ 22,271+ Math.floor(272+ Math.min(273+ (w - 80) / boardState.width,274+ (h - 240) / boardState.height,275+ ),276+ ),277+ ),278+ );279+ drawBoard(ctx, boardState, {280+ x: Math.floor((w - boardState.width * cell) / 2),281+ y: Math.floor((h - boardState.height * cell) / 2 - 30),282+ cell,283+ game: boardGame,284+ palette: boardPalette,285+ glyphs: boardGlyphs,286+ frame: boardFrame,287+ });288+ }289+ ctx.fillStyle = "rgba(15,14,13,.84)";290+ ctx.fillRect(0, 0, w, h);291+ ctx.textAlign = "center";292+ ctx.textBaseline = "middle";293+ ctx.fillStyle = "#FF4B3E";294+ ctx.font = "700 13px Recursive, monospace";295+ ctx.fillText("PAUSED", w / 2, Math.max(90, h * 0.18));296+ pauseItems.forEach((item, i) => {297+ const y = h * 0.27 + i * 48;298+ ctx.fillStyle = i === selected ? "#FF4B3E" : "#232120";299+ ctx.fillRect(w / 2 - 160, y - 19, 320, 38);300+ ctx.fillStyle = i === selected ? "#0F0E0D" : "#F2EEE6";301+ ctx.font = "800 13px Recursive, monospace";302+ ctx.fillText(item, w / 2, y + 1);303+ });304+ ctx.textAlign = "left";305+ } else if (scene === "win") {306+ ctx.fillStyle = "rgba(15,14,13,.86)";307+ ctx.fillRect(0, 0, w, h);308+ ctx.textAlign = "center";309+ ctx.fillStyle = "#2EE6A8";310+ ctx.font = "800 13px Recursive, monospace";311+ ctx.fillText("PROOF COMPLETE", w / 2, h * 0.34);312+ ctx.fillStyle = "#F2EEE6";313+ ctx.font = `800 ${Math.min(56, Math.max(30, w * 0.055))}px Recursive, monospace`;314+ ctx.fillText(315+ `PROVEN IN ${boardState?.moves || 0} STEPS`,316+ w / 2,317+ h * 0.47,318+ );319+ ctx.fillStyle = "#8E877C";320+ ctx.font = "600 12px Recursive, monospace";321+ drawKeycap(ctx, "ENTER", "OPEN INDEX", w / 2 - 4, h * 0.67, "right");322+ drawKeycap(ctx, "Z", "UNDO", w / 2 + 18, h * 0.67);323+ drawKeycap(ctx, "R", "RESTART", w / 2 + 74, h * 0.67);324+ if (w <= 760) drawTouchControls(ctx, w, h);325+ ctx.textAlign = "left";326+ } else {327+ const spec = SCENES[scene] || proofScene;328+ ctx.textAlign = "center";329+ ctx.fillStyle = "#FF4B3E";330+ ctx.font = "600 12px Recursive, monospace";331+ ctx.fillText(spec.eyebrow, w / 2, h * 0.38);332+ ctx.fillStyle = "#F2EEE6";333+ ctx.font = `800 ${Math.min(64, Math.max(28, w * 0.06))}px Recursive, monospace`;334+ ctx.fillText(335+ (spec.title || scene).replace("{steps}", boardState?.moves || 0),336+ w / 2,337+ h * 0.48,338+ );339+ ctx.fillStyle = "#8E877C";340+ ctx.font = "500 12px Recursive, monospace";341+ ctx.fillText(spec.prompt || "", w / 2, h * 0.58);342+ ctx.textAlign = "left";343+ }344+ }345+ function show(id, data = {}) {346+ scene = id;347+ const spec = SCENES[id] || proofScene;348+ eyebrow.textContent = spec.eyebrow;349+ titleEl.textContent = (spec.title || "").replace(350+ "{steps}",351+ data.steps ?? "0",352+ );353+ prompt.textContent = data.prompt || spec.prompt;354+ help.textContent = spec.help;355+ choices.hidden = true;356+ root.hidden = true;357+ root.dataset.scene = id;358+ announce(`${spec.eyebrow || ""} ${titleEl.textContent}`.trim());359+ render();360+ transitionStart = performance.now();361+ const generation = ++transitionGeneration;362+ requestAnimationFrame((time) => drawTransition(time, generation));363+ }364+ function hide() {365+ transitionStart = 0;366+ transitionGeneration += 1;367+ root.hidden = true;368+ scene = "proof";369+ root.dataset.scene = scene;370+ render();371+ }372+ function setBoard(state, game, palette = {}, glyphs = {}, frame = null) {373+ boardState = state;374+ boardGame = game;375+ boardPalette = palette;376+ boardGlyphs = glyphs;377+ boardFrame = frame;378+ render();379+ }380+ function setIndex(layout, navigator) {381+ indexLayout = layout;382+ indexNavigator = navigator;383+ render();384+ }385+ function setControllers(value = {}) {386+ title = value.title || title;387+ gift = value.gift || gift;388+ workshop = value.workshop || workshop;389+ render();390+ }391+ function setActions(value = {}) {392+ Object.assign(options, value);393+ return api;394+ }395+ function setPauseStatus(value = {}) {396+ pauseStatus = { ...pauseStatus, ...value };397+ pauseItems[4] = pauseStatus.soundOn ? "SOUND ON" : "SOUND OFF";398+ pauseItems[5] = pauseStatus.musicOn ? "MUSIC ON" : "MUSIC OFF";399+ render();400+ return api;401+ }402+ function setSelected(index) {403+ selected = Math.max(0, index);404+ root.dataset.selected = String(selected);405+ }406+ keepButton.addEventListener("click", () => {407+ options.onConfirm?.("gift");408+ hide();409+ });410+ laterButton.addEventListener("click", () => hide());411+ function handleKey(event) {412+ if (event.target?.matches?.("input,textarea,select,[contenteditable]"))413+ return;414+ // Gameplay intents for title, proof, and win use the shared input router.415+ if (scene === "title" || scene === "proof" || scene === "win") return;416+ if (event.key === "Escape") {417+ if (scene === "workshop") workshop?.handleInput?.("escape");418+ else if (scene === "gift") {419+ gift?.input("escape");420+ show("title");421+ } else if (scene === "index") show("title");422+ else hide();423+ render();424+ event.preventDefault();425+ return;426+ }427+ if (428+ scene !== "pause" &&429+ ["w", "a", "s", "d"].includes(event.key.toLowerCase())430+ ) {431+ handleIntent({432+ type: "move",433+ dir: { w: "up", a: "left", s: "down", d: "right" }[434+ event.key.toLowerCase()435+ ],436+ });437+ event.preventDefault();438+ return;439+ }440+ if (scene === "gift" && gift) {441+ const result = gift.input(442+ event.key === "Enter"443+ ? "confirm"444+ : event.key === "Escape"445+ ? "escape"446+ : event.key === "ArrowUp"447+ ? "up"448+ : event.key === "ArrowDown"449+ ? "down"450+ : event.key === "ArrowLeft"451+ ? "left"452+ : event.key === "ArrowRight"453+ ? "right"454+ : "noop",455+ );456+ if (result.action === "later") show("title");457+ render();458+ event.preventDefault();459+ return;460+ }461+ if (scene === "workshop" && workshop) {462+ workshop.handleInput(463+ event.key === "Enter"464+ ? "enter"465+ : event.key === "Escape"466+ ? "escape"467+ : event.key === "ArrowUp"468+ ? "up"469+ : event.key === "ArrowDown"470+ ? "down"471+ : event.key === "ArrowLeft"472+ ? "left"473+ : event.key === "ArrowRight"474+ ? "right"475+ : "noop",476+ );477+ render();478+ event.preventDefault();479+ return;480+ }481+ if (scene === "pause") {482+ if (event.key === "ArrowUp") {483+ selected = (selected + pauseItems.length - 1) % pauseItems.length;484+ render();485+ event.preventDefault();486+ return;487+ }488+ if (event.key === "ArrowDown") {489+ selected = (selected + 1) % pauseItems.length;490+ render();491+ event.preventDefault();492+ return;493+ }494+ if (event.key === "Enter") {495+ const action = pauseItems[selected];496+ if (action === "RESUME") hide();497+ else if (action === "RESTART") options.onPauseAction?.("restart");498+ else if (action === "INDEX") show("index");499+ else if (action === "WORKSHOP") show("workshop");500+ else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");501+ else options.onPauseAction?.("music");502+ event.preventDefault();503+ return;504+ }505+ }506+ if (scene === "index" && event.key === "Enter") {507+ const choice = indexNavigator?.enter();508+ if (choice?.ok && options.onSelectLevel?.(choice.node.id) !== false)509+ show("proof");510+ else render();511+ event.preventDefault();512+ return;513+ }514+ if (scene === "index" && ["ArrowRight", "ArrowDown"].includes(event.key)) {515+ indexNavigator?.move(event.key === "ArrowRight" ? "right" : "down");516+ selected++;517+ render();518+ event.preventDefault();519+ return;520+ }521+ if (scene === "index" && ["ArrowLeft", "ArrowUp"].includes(event.key)) {522+ indexNavigator?.move(event.key === "ArrowLeft" ? "left" : "up");523+ selected = Math.max(0, selected - 1);524+ render();525+ event.preventDefault();526+ return;527+ }528+ if (scene === "pause" && event.key.toLowerCase() === "w") {529+ show("workshop");530+ event.preventDefault();531+ }532+ }533+ addEventListener("resize", resize);534+ resize();535+ document.addEventListener("keydown", handleKey);536+ canvas.addEventListener("pointerup", (event) => {537+ const rect = canvas.getBoundingClientRect();538+ const x = event.clientX - rect.left,539+ y = event.clientY - rect.top;540+ if (scene === "index" && indexGeometry?.nodes?.length) {541+ const hit = indexGeometry.nodes.find(542+ (node) =>543+ x >= node.x &&544+ x <= node.x + node.width &&545+ y >= node.y &&546+ y <= node.y + node.height,547+ );548+ if (hit && indexNavigator) {549+ const currentId = indexNavigator.current?.node?.id;550+ if (currentId === hit.id) {551+ const choice = indexNavigator.enter();552+ if (choice?.ok && options.onSelectLevel?.(choice.node.id) !== false)553+ show("proof");554+ } else {555+ indexNavigator.setNode?.(hit.id);556+ render();557+ }558+ }559+ }560+ if (scene === "proof" && innerWidth <= 760) {561+ if (y >= innerHeight - 58 && x < innerWidth * 0.32)562+ document.querySelector("#undo-btn")?.click();563+ else if (564+ y >= innerHeight - 84 &&565+ y < innerHeight - 42 &&566+ x < innerWidth * 0.32567+ )568+ document.querySelector("#restart-btn")?.click();569+ else if (y >= innerHeight - 58 && x > innerWidth * 0.68) show("pause");570+ }571+ if (scene === "gift") {572+ const result = gift?.hit(x, y);573+ if (result?.action === "later") show("title");574+ }575+ if (scene === "workshop") workshop?.hit?.(x, y);576+ if (577+ scene === "pause" &&578+ x >= innerWidth / 2 - 160 &&579+ x <= innerWidth / 2 + 160580+ ) {581+ const index = Math.round((y - innerHeight * 0.27) / 48);582+ if (index >= 0 && index < pauseItems.length) {583+ selected = index;584+ const action = pauseItems[index];585+ if (action === "RESUME") hide();586+ else if (action === "INDEX") show("index");587+ else if (action === "WORKSHOP") show("workshop");588+ else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");589+ else if (action.startsWith("MUSIC")) options.onPauseAction?.("music");590+ else options.onPauseAction?.("restart");591+ }592+ }593+ render();594+ });595+ function handleIntent(intent = {}) {596+ const type = intent.type || intent;597+ if (scene === "pause") {598+ if (type === "move") {599+ const dir = intent.dir;600+ if (dir === "up")601+ selected = (selected + pauseItems.length - 1) % pauseItems.length;602+ if (dir === "down") selected = (selected + 1) % pauseItems.length;603+ render();604+ return true;605+ }606+ if (type === "confirm") {607+ const action = pauseItems[selected];608+ if (action === "RESUME") hide();609+ else if (action === "INDEX") show("index");610+ else if (action === "WORKSHOP") show("workshop");611+ else if (action.startsWith("SOUND")) options.onPauseAction?.("sound");612+ else if (action.startsWith("MUSIC")) options.onPauseAction?.("music");613+ else options.onPauseAction?.("restart");614+ return true;615+ }616+ if (type === "menu") hide();617+ }618+ if (scene === "index") {619+ if (type === "move" && indexNavigator) {620+ indexNavigator.move(intent.dir);621+ render();622+ return true;623+ }624+ if (type === "confirm" && indexNavigator) {625+ const choice = indexNavigator.enter();626+ if (choice?.ok && options.onSelectLevel?.(choice.node.id) !== false)627+ show("proof");628+ else render();629+ return true;630+ }631+ if (type === "menu") {632+ hide();633+ return true;634+ }635+ }636+ if (scene === "workshop" && workshop) {637+ workshop.handleInput(638+ type === "confirm"639+ ? "enter"640+ : type === "menu"641+ ? "escape"642+ : intent.dir || type,643+ );644+ render();645+ return true;646+ }647+ if (scene === "win" && type === "confirm") {648+ show("index");649+ return true;650+ }651+ if (scene === "gift" && gift) {652+ const result = gift.input(653+ type === "confirm"654+ ? "confirm"655+ : type === "menu"656+ ? "escape"657+ : intent.dir || type,658+ );659+ if (result.action === "later") show("title");660+ render();661+ return true;662+ }663+ return false;664+ }665+ const api = {666+ show,667+ hide,668+ setSelected,669+ setBoard,670+ setIndex,671+ setControllers,672+ setActions,673+ setPauseStatus,674+ handleIntent,675+ render,676+ canvas,677+ get scene() {678+ return scene;679+ },680+ get selected() {681+ return selected;682+ },683+ root,684+ };685+ return Object.freeze(api);686+}
M src/style.css +0 -1
@@ -1,2 +1 @@
1-@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap');21 :root{font-family:'Space Grotesk',system-ui,sans-serif;color:#302e2a;background:#e9e1d4;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:320px;background:radial-gradient(circle at 12% 8%,#f8f2e9 0 20%,transparent 46%),#e9e1d4}.app-shell{max-width:1480px;margin:auto;padding:28px 38px 22px}.topbar{height:57px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #c9bca9}.brand-mark{display:flex;align-items:center;gap:13px;letter-spacing:.06em}.brand-mark>span{font-size:25px;color:#e76556}.brand-mark strong{display:block;font-size:14px;line-height:1.1}.brand-mark small{display:block;margin-top:3px;font:10px 'DM Mono',monospace;letter-spacing:.13em;color:#817768}.top-actions{display:flex;align-items:center;gap:9px;color:#807464;font:10px 'DM Mono',monospace;letter-spacing:.08em}.status-dot{width:7px;height:7px;border-radius:50%;background:#e6af47;box-shadow:0 0 0 4px #f2e8d3}.status-dot.won{background:#2d9c88}.icon-button,.soft-button{border:1px solid #d0c3b2;background:#f5eee4;color:#776f64;border-radius:8px;cursor:pointer}.icon-button{font-size:17px;width:32px;height:30px;margin-left:14px}.app-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:30px;padding-top:28px}.level-strip{display:flex;align-items:flex-end;justify-content:space-between;padding:0 2px 18px}.eyebrow{font:10px 'DM Mono',monospace;letter-spacing:.13em;color:#9b8c7a}.level-strip h1{font-size:28px;line-height:1;margin:7px 0 0;font-weight:600;letter-spacing:-.04em}.level-nav{display:flex;align-items:center;gap:11px;font:10px 'DM Mono',monospace;color:#847969}.soft-button{width:29px;height:27px;font-size:16px}.board-wrap{position:relative;border:1px solid #cabdac;border-radius:15px;background:#fbf7ef;padding:14px;box-shadow:0 11px 26px rgba(103,80,53,.07)}canvas{display:block;width:100%;height:auto;max-height:calc(100vh - 270px);border-radius:8px}.under-board{display:flex;justify-content:space-between;align-items:center;padding:17px 3px 0;color:#857a6c;font:10px 'DM Mono',monospace;letter-spacing:.08em}.legend{display:flex;gap:18px}.legend span{display:flex;align-items:center;gap:6px}.legend-swatch{width:7px;height:7px;border-radius:50%;display:inline-block;background:#766f64}.legend-swatch.you{background:#e76556}.legend-swatch.word{background:#e5af47}.legend-swatch.object{background:#6f9ca8}.move-counter{display:flex;gap:10px;align-items:center}.move-counter strong{font:500 16px 'DM Mono',monospace;color:#302e2a}.sidebar{display:flex;flex-direction:column;gap:15px}.panel{background:#f6efe5;border:1px solid #d6c8b5;border-radius:12px;padding:19px 19px 18px;box-shadow:0 5px 14px rgba(104,83,59,.035)}.panel-heading{display:flex;justify-content:space-between;align-items:flex-start}.panel h2{font-size:17px;line-height:1;margin:7px 0 0;letter-spacing:-.03em}.pulse{width:8px;height:8px;background:#e6af47;border-radius:50%;box-shadow:0 0 0 5px #f0dfc5;margin:5px}.rules-list{display:flex;flex-wrap:wrap;gap:7px;margin:17px 0 13px}.rule-chip{display:flex;align-items:center;gap:7px;padding:8px 9px;border:1px solid #ded2c1;border-radius:7px;background:#fffaf2;font:11px 'DM Mono',monospace}.rule-chip b{font-size:9px;color:#ab9c89}.rule-predicate{color:var(--rule-color);font-weight:500}.empty-rules{font:11px 'DM Mono',monospace;color:#9e9180;padding:13px 0}.panel-note{margin:0;font-size:12px;line-height:1.55;color:#8c7f70}.key-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-top:14px;font:11px 'DM Mono',monospace;color:#766c60}.key-row span{margin-left:4px}.key-row em{font-style:normal;color:#aa9b88}.key-row kbd{display:inline-flex;align-items:center;justify-content:center;width:24px;height:23px;background:#fffaf2;border:1px solid #d7cab8;border-radius:5px;font-size:10px;color:#6c645a}.level-list{margin-top:12px;display:grid;gap:4px}.level-choice{display:grid;grid-template-columns:28px 1fr;grid-template-rows:16px 15px;column-gap:7px;text-align:left;border:0;border-left:2px solid transparent;background:transparent;padding:8px 7px;color:#5e574d;cursor:pointer;border-radius:0 6px 6px 0}.level-choice span{grid-row:1/3;font:11px 'DM Mono',monospace;color:#b2a28d;padding-top:2px}.level-choice strong{font-size:12px;font-weight:500}.level-choice small{font-size:10px;color:#a19382;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.level-choice.active{background:#efe4d4;border-left-color:#e76556}.level-choice.active span{color:#e76556}.mod-panel p{font-size:11px;line-height:1.5;color:#897d70;margin:13px 0}.file-button{display:inline-flex;align-items:center;justify-content:center;border:1px solid #d1c1ac;background:#fff9ef;border-radius:7px;padding:8px 11px;font:10px 'DM Mono',monospace;color:#6b6257;cursor:pointer}.file-button input{display:none}.mod-badge{font:9px 'DM Mono',monospace;color:#e76556;border:1px solid #ebbfad;border-radius:4px;padding:4px 5px}.mod-status{font:10px 'DM Mono',monospace;color:#a29483;margin-top:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.win-toast{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:grid;gap:3px;text-align:center;background:rgba(48,46,42,.94);color:#fffaf2;border-radius:10px;padding:18px 23px;box-shadow:0 10px 24px rgba(48,46,42,.2)}.win-toast span{font-size:22px;color:#e6b14d}.win-toast strong{font-size:14px;letter-spacing:.05em}.win-toast small{font:10px 'DM Mono',monospace;color:#d5c7b5}.mobile-controls{display:none}footer{display:flex;justify-content:space-between;border-top:1px solid #c9bca9;margin-top:30px;padding-top:14px;color:#9b8e7d;font:9px 'DM Mono',monospace;letter-spacing:.08em}@media(max-width:850px){.app-shell{padding:18px 15px}.topbar{height:48px}.top-actions>span:not(.status-dot){display:none}.app-layout{display:block;padding-top:18px}.sidebar{margin-top:20px}.level-strip h1{font-size:23px}.under-board{padding-top:12px}.mobile-controls{display:flex;flex-direction:column;align-items:center;gap:5px;margin:17px auto 0}.mobile-controls>button,.mobile-controls div button{width:45px;height:37px;border:1px solid #cbbda9;background:#f7efe4;color:#655d51;border-radius:7px;font-size:18px}.mobile-controls div{display:flex;gap:5px}.legend{gap:9px}.legend span{font-size:9px}footer{margin-top:23px}.sidebar{gap:11px}.panel{padding:16px}.controls-panel{display:none}}@media(min-width:851px){.play-column{min-width:0}}