clone

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

main
miau-is-true / src / render / stage-proof.js
stage-proof.js 7.6K
1import { drawBoard } from "./board.js";2import {3 drawAutomataStrip,4 drawHint,5 drawKeycap,6 drawLedger,7 drawStepCounter,8 drawTouchControls,9} from "./stage-hud.js";10import { fitSceneTitle } from './title-fitting.js';1112/**13 * Draws the playable proof scene. All inputs are supplied in the view object;14 * this renderer does not retain stage state or read from the DOM.15 */16export function renderProofScene(ctx, state, view = {}) {17 if (!ctx || !state) return null;18 const width = view.width ?? ctx.canvas?.clientWidth ?? ctx.canvas?.width ?? 0;19 const height =20 view.height ?? ctx.canvas?.clientHeight ?? ctx.canvas?.height ?? 0;21 const now =22 view.now ?? (typeof performance === "undefined" ? 0 : performance.now());23 const palette = view.palette || {};24 const glyphs = view.glyphs || {};25 const automata = Array.isArray(state.automata)26 ? state.automata.filter((system) => system?.pattern?.length)27 : [];28 const automataRows = Math.min(width <= 760 ? 2 : 3, automata.length);29 const automataRowHeight = width <= 760 ? 24 : 30;30 const automataReserve = automataRows31 ? Math.min(120, automataRows * automataRowHeight + 18)32 : 0;33 const cell = Math.min(34 96,35 Math.max(36 22,37 Math.floor(38 Math.min(39 (width - 80) / state.width,40 (height - 320 - automataReserve) / state.height,41 ),42 ),43 ),44 );45 const x = Math.floor((width - state.width * cell) / 2);46 const baseY =47 width <= 76048 ? 12049 : Math.max(118, Math.floor((height - state.height * cell) / 2 - 30));50 const controlled = state.objects?.find(51 (object) =>52 object.kind === "object" &&53 view.game?.properties(object)?.includes("YOU"),54 );55 ctx.textAlign = "left";56 ctx.textBaseline = "top";57 const proofNumber = String(state.proofNumber ?? 1).padStart(2, "0");58 ctx.fillStyle = "#F2EEE6";59 ctx.font = "800 11px Recursive, monospace";60 ctx.fillRect(32, 30, 34, 26);61 ctx.fillStyle = "#0F0E0D";62 ctx.textAlign = "center";63 ctx.textBaseline = "middle";64 ctx.fillText(proofNumber, 49, 44);65 ctx.textAlign = "left";66 const fullProofTitle = String(state.title || "Proof");67 const titleX = 82;68 const counterRight = width - 32;69 const titleToCounterGap = 16;70 // Reserve the measured counter width plus the 16px gutter required by the71 // title rule; a fixed percentage let long titles paint under the digits.72 ctx.font = '800 40px Recursive, monospace';73 const counterDigits = Math.max(74 2,75 String(Math.max(0, Number(state.moves) || 0)).length,76 String(Math.max(0, Number(view.stepRoll?.to) || 0)).length,77 );78 const counterWidth = counterDigits * Math.max(20, ctx.measureText('0').width);79 const titleWidth = Math.max(1, counterRight - counterWidth - titleToCounterGap - titleX);80 let titleFit = fitSceneTitle(ctx, fullProofTitle, { maxWidth: titleWidth, maxSize: 22, minSize: 16, maxLines: 2 });81 let inlineCounter = false;82 // At 390px a 51–64 character title can require three 16px lines. Give it83 // the full title band and move the compact odometer into the subline row.84 if (width <= 760 && titleFit.overflow) {85 titleFit = fitSceneTitle(ctx, fullProofTitle, {86 maxWidth: Math.max(1, width - titleX - 16), maxSize: 16, minSize: 16, maxLines: 3,87 });88 inlineCounter = true;89 }90 const titleTop = 30;91 const sublineTop = titleTop + titleFit.lines.length * titleFit.lineHeight + 3;92 const hudBottom = sublineTop + 12;93 const y = Math.max(baseY, hudBottom + 40);94 ctx.fillStyle = "#F2EEE6";95 ctx.font = `800 ${titleFit.size}px Recursive, monospace`;96 titleFit.lines.forEach((line, index) => ctx.fillText(line, titleX, titleTop + index * titleFit.lineHeight));97 ctx.font = "600 10px Recursive, monospace";98 ctx.fillStyle = "#8E877C";99 ctx.fillText(100 `PROOF ${proofNumber} OF ${String(state.proofTotal ?? 9).padStart(2, "0")}`,101 titleX,102 sublineTop,103 );104 ctx.fillStyle = "#F2EEE6";105 if (inlineCounter) {106 ctx.textAlign = "right";107 ctx.font = "800 22px Recursive, monospace";108 const compactDigitWidth = Math.max(20, ctx.measureText("0").width);109 const compactCounterWidth = counterDigits * compactDigitWidth;110 ctx.font = "600 10px Recursive, monospace";111 ctx.fillStyle = "#8E877C";112 ctx.fillText("STEPS", counterRight - compactCounterWidth - 8, sublineTop + 8);113 drawStepCounter(ctx, state.moves || 0, counterRight, sublineTop + 8, {114 now, size: 22, reducedMotion: view.reducedMotion, roll: view.stepRoll,115 });116 } else {117 drawStepCounter(ctx, state.moves || 0, counterRight, 39, {118 now, reducedMotion: view.reducedMotion, roll: view.stepRoll,119 });120 ctx.textAlign = "right";121 ctx.font = "600 10px Recursive, monospace";122 ctx.fillStyle = "#8E877C";123 ctx.fillText("STEPS", counterRight, 70);124 }125 ctx.textAlign = "left";126 const geometry = drawBoard(ctx, state, {127 x,128 y,129 cell,130 game: view.game,131 palette,132 glyphs,133 frame: view.frame,134 });135 const controlledCenter = controlled136 ? {137 x: geometry.x + (controlled.x + 0.5) * geometry.cell,138 y: geometry.y + (controlled.y + 0.5) * geometry.cell,139 }140 : null;141 const ledger = drawLedger(142 ctx,143 state.rules,144 x,145 Math.min(y + state.height * cell + 46, height - 112),146 width - x - 32,147 { ledger: view.frame?.ledger },148 );149 const hint = String(state.hint || "").trim();150 const mobileAutomata = width <= 760 && automata.length > 0;151 const automataHeight = automataRows * automataRowHeight;152 const automataY = automata.length153 ? mobileAutomata154 ? Math.min(ledger.bottom + 14, height - automataHeight - 98)155 : height - (automataHeight + 90)156 : height - 80;157 const hintY = mobileAutomata158 ? Math.min(automataY + automataHeight + 12, height - 100)159 : Math.min(ledger.bottom + 12, height - 78);160 if (mobileAutomata && automata.length)161 drawAutomataStrip(162 ctx,163 automata,164 x,165 automataY,166 width - x - 32,167 state.rules,168 {169 viewportWidth: width,170 palette,171 glyphs,172 },173 );174 if (hint)175 drawHint(176 ctx,177 hint,178 x,179 hintY,180 width - x - 32,181 mobileAutomata ? height - 100 : automata.length ? automataY - 8 : height - 60,182 );183 if (!mobileAutomata && automata.length)184 drawAutomataStrip(185 ctx,186 automata,187 x,188 automataY,189 width - x - 32,190 state.rules,191 {192 viewportWidth: width,193 palette,194 glyphs,195 },196 );197 const coarsePointer = view.coarsePointer ?? false;198 if (width > 760 && !coarsePointer) {199 const proofKeyY = height - 26;200 const motion = {201 now,202 reducedMotion: view.reducedMotion,203 press: view.keycapPress,204 pressAction: view.keycapPressAction,205 pressStart: view.keycapPressStart,206 };207 if (automata.some((system) => system.active))208 drawKeycap(ctx, "SPACE", "wait", width - 400, proofKeyY, "left", { ...motion, action: "wait" });209 drawKeycap(ctx, "Z", "undo", width - 290, proofKeyY, "left", { ...motion, action: "undo" });210 drawKeycap(ctx, "R", "restart", width - 185, proofKeyY, "left", { ...motion, action: "restart" });211 drawKeycap(ctx, "ESC", "menu", width - 32, proofKeyY, "right", { ...motion, action: "menu" });212 }213 drawTouchControls(214 ctx,215 width,216 height,217 coarsePointer,218 automata.some((system) => system.active),219 );220 return {221 geometry,222 titleLayout: { ...titleFit, x: titleX, y: titleTop, width: inlineCounter ? Math.max(1, width - titleX - 16) : titleWidth, sublineTop, hudBottom, boardTop: geometry.y, inlineCounter },223 controlledCenter,224 ariaLabel: `Miau Is True proof: ${fullProofTitle}`,225 };226}227228export default renderProofScene;