clone

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

main
miau-is-true / src / render / board.js
board.js 13.1K
1import {2 BOARD_COLORS,3 FONT,4 boardGeometry,5 facingFor,6 prepareBoardModel,7 role,8} from './board/model.js';9import { drawEntities } from './board/entities.js';1011// Preserve the renderer's public color token export while keeping the palette12// definition in the pure board model.13export { BOARD_COLORS };1415/**16 * Ink and signal board renderer.17 *18 * This module only reads state and draws to the supplied 2D context. Movement,19 * animation, input, and rule evaluation stay in the engine and scene layer.20 */2122function roundRect(ctx, x, y, width, height, radius) {23 ctx.beginPath();24 if (typeof ctx.roundRect === "function")25 ctx.roundRect(x, y, width, height, radius);26 else ctx.rect(x, y, width, height);27}28function fitFont(ctx, text, maxWidth, baseSize, weight = 800, family = FONT) {29 let size = baseSize;30 if (typeof ctx.measureText !== "function")31 return `${weight} ${size}px ${family}`;32 while (size > baseSize * 0.55) {33 ctx.font = `${weight} ${size}px ${family}`;34 if (ctx.measureText(text).width <= maxWidth) break;35 size *= 0.93;36 }37 return `${weight} ${Math.max(size, baseSize * 0.55)}px ${family}`;38}39function drawSlab(ctx, geometry, colors) {40 const { x, y, cell, boardWidth, boardHeight } = geometry;41 const pad = Math.round(cell * 0.22);42 ctx.fillStyle = colors.lip;43 ctx.fillRect(44 x - pad + 8,45 y - pad + 8,46 boardWidth + pad * 2,47 boardHeight + pad * 2,48 );49 roundRect(50 ctx,51 x - pad,52 y - pad,53 boardWidth + pad * 2,54 boardHeight + pad * 2,55 4,56 );57 ctx.fillStyle = colors.board;58 ctx.fill();59 ctx.strokeStyle = colors.edge;60 ctx.lineWidth = 1;61 ctx.stroke();62 ctx.fillStyle = colors.dot;63 for (let iy = 0; iy <= geometry.height; iy++)64 for (let ix = 0; ix <= geometry.width; ix++) {65 ctx.beginPath();66 ctx.arc(67 x + ix * cell,68 y + iy * cell,69 Math.max(1.2, cell * 0.022),70 0,71 Math.PI * 2,72 );73 ctx.fill();74 }75}7677function drawTile(ctx, text, cx, cy, cell, active, colors) {78 const kind = role(text);79 const width = cell * 0.74;80 const height = kind === "operator" ? cell * 0.48 : cell * 0.74;81 const left = cx - width / 2;82 const top = cy - height / 2;83 const lip = cell * 0.04;84 const shape = (dx, dy) => {85 if (kind === "operator") {86 roundRect(ctx, left + dx, top + dy, width, height, height / 2);87 return;88 }89 if (kind === "property") {90 const chamfer = cell * 0.14;91 ctx.beginPath();92 ctx.moveTo(left + dx, top + dy);93 ctx.lineTo(left + dx + width - chamfer, top + dy);94 ctx.lineTo(left + dx + width, top + dy + chamfer);95 ctx.lineTo(left + dx + width, top + dy + height);96 ctx.lineTo(left + dx, top + dy + height);97 ctx.closePath();98 return;99 }100 roundRect(ctx, left + dx, top + dy, width, height, cell * 0.04);101 };102 ctx.save();103 ctx.globalAlpha *= active ? 1 : 0.4;104 if (kind !== "operator") {105 shape(lip, lip);106 ctx.fillStyle = colors.lip;107 ctx.fill();108 }109 shape(0, 0);110 if (kind === "operator") {111 ctx.fillStyle = colors.board;112 ctx.fill();113 ctx.strokeStyle = colors.fg;114 ctx.lineWidth = Math.max(2, cell * 0.035);115 ctx.stroke();116 } else {117 ctx.fillStyle =118 kind === "property" ? colors[text] || colors.ink : colors.tile;119 ctx.fill();120 }121 ctx.font = fitFont(ctx, text, width * 0.84, cell * 0.25, active ? 800 : 600);122 ctx.textAlign = "center";123 ctx.textBaseline = "middle";124 ctx.fillStyle = kind === "operator" ? colors.fg : colors.ink;125 ctx.fillText(text, cx, cy + cell * 0.012);126 ctx.restore();127}128129function drawWall(ctx, object, geometry, walls, colors, visual = object) {130 const { x, y, cell } = geometry;131 const left = x + visual.x * cell;132 const top = y + visual.y * cell;133 ctx.fillStyle = colors.wall;134 ctx.fillRect(left, top, cell, cell);135 ctx.save();136 ctx.beginPath();137 ctx.rect(left, top, cell, cell);138 ctx.clip();139 ctx.strokeStyle = "rgba(242,238,230,.08)";140 ctx.lineWidth = 2;141 for (let offset = -cell; offset < cell * 2; offset += cell / 4) {142 ctx.beginPath();143 ctx.moveTo(left + offset, top);144 ctx.lineTo(left + offset - cell, top + cell);145 ctx.stroke();146 }147 ctx.restore();148}149150function drawSentenceBars(151 ctx,152 state,153 geometry,154 colors,155 objectById,156 frame = null,157 visualOf = (object) => object,158) {159 const { x, y, cell } = geometry;160 const sentences =161 frame && Array.isArray(frame.sentences) && frame.sentences.length162 ? frame.sentences163 : Array.isArray(state.sentences)164 ? state.sentences165 : [];166 for (const sentence of sentences) {167 const objects = sentence.ids168 .map((id) => objectById.get(id))169 .filter(Boolean);170 const words = objects.filter((object) => object.kind === "word");171 const cells = sentence.frozenCells?.length172 ? sentence.frozenCells.map((cell) => ({ x: cell.x, y: cell.y }))173 : words.map((word) => {174 const visual = visualOf(word);175 return { x: visual.x, y: visual.y };176 });177 if (!cells.length) continue;178 const progress = Number.isFinite(sentence.progress)179 ? Math.max(0, Math.min(1, sentence.progress))180 : 1;181 // Sentence bars are persistent structure signals, so they stay neutral;182 // property colors belong to the tiles and transient rule effects.183 ctx.fillStyle = colors.fg;184 if (sentence.axis === "v") {185 const px = Math.min(...cells.map((cellPosition) => cellPosition.x));186 const y0 = Math.min(...cells.map((cellPosition) => cellPosition.y));187 const y1 = Math.max(...cells.map((cellPosition) => cellPosition.y));188 ctx.fillRect(189 x + px * cell + cell * 0.045,190 y + y0 * cell + cell * 0.16,191 Math.max(3, cell * 0.04),192 (y1 - y0 + 0.68) * cell * progress,193 );194 } else {195 const py = Math.min(...cells.map((cellPosition) => cellPosition.y));196 const x0 = Math.min(...cells.map((cellPosition) => cellPosition.x));197 const x1 = Math.max(...cells.map((cellPosition) => cellPosition.x));198 roundRect(199 ctx,200 x + x0 * cell + cell * 0.13,201 y + (py + 0.935) * cell,202 (x1 - x0 + 0.78) * cell * progress,203 Math.max(3, cell * 0.04),204 cell * 0.02,205 );206 ctx.fill();207 }208 }209}210211function drawFrameEffects(ctx, frame, geometry, colors) {212 if (!frame) return;213 const { x, y, cell, boardWidth, boardHeight } = geometry;214 const effects = Array.isArray(frame.effects) ? frame.effects : [];215 for (const effect of effects) {216 const cellX = effect.cell?.x ?? effect.x;217 const cellY = effect.cell?.y ?? effect.y;218 if (!Number.isFinite(cellX) || !Number.isFinite(cellY)) continue;219 const progress = Math.max(0, Math.min(1, Number(effect.progress) || 0));220 const cx = x + (cellX + 0.5) * cell;221 const cy = y + (cellY + 0.5) * cell;222 const color = colors[effect.color] || colors.fg;223 ctx.save();224 ctx.globalAlpha = 1 - progress;225 ctx.fillStyle = color;226 ctx.strokeStyle = color;227 ctx.textAlign = "center";228 ctx.textBaseline = "middle";229 if (effect.type === "transform") {230 ctx.font = `800 ${Math.max(12, cell * 0.22)}px ${FONT}`;231 ctx.fillText(effect.text || "", cx, cy - cell * progress * 0.3);232 } else {233 ctx.font = `800 ${Math.max(15, cell * 0.34)}px ${FONT}`;234 ctx.fillText(235 effect.text || (effect.type === "sink" ? "SINK" : "DEFEAT"),236 cx,237 cy - cell * progress * 0.22,238 );239 }240 ctx.restore();241 }242 if (frame.tint?.progress > 0) {243 ctx.save();244 ctx.globalAlpha = Math.max(0, Math.min(1, frame.tint.progress)) * 0.18;245 ctx.fillStyle = "#6CA9D8";246 ctx.fillRect(x, y, boardWidth, boardHeight);247 ctx.restore();248 }249 if (frame.wipe?.progress != null && frame.wipe.progress < 1) {250 ctx.save();251 ctx.globalAlpha = 1 - Math.max(0, Math.min(1, frame.wipe.progress));252 ctx.fillStyle = colors.stage;253 ctx.fillRect(x, y, boardWidth, boardHeight);254 ctx.restore();255 }256}257258function automataPhase(system, frame) {259 const transition = frame?.automata?.find?.((item) => item.id === system.id);260 if (!transition || !Number.isInteger(transition.toPhase))261 return system.phase || 0;262 return Number(transition.progress) >= 0.5263 ? transition.toPhase264 : (transition.fromPhase ?? system.phase ?? 0);265}266267function drawAutomataCue(ctx, action, cx, cy, cell, colors) {268 if (!action) return;269 ctx.save();270 ctx.fillStyle = colors.TICK;271 if (action.action === "wait") {272 const gap = Math.max(1.5, cell * 0.035);273 const height = Math.max(2, cell * 0.08);274 for (const offset of [-gap, gap]) {275 ctx.beginPath();276 ctx.rect(cx + offset - Math.max(1, cell * 0.012), cy - height / 2, Math.max(2, cell * 0.024), height);277 ctx.fill();278 }279 } else {280 const dx = action.dx || 0;281 const dy = action.dy || 0;282 const size = Math.min(10, cell * 0.13);283 const edgeX = cx + dx * cell * 0.435;284 const edgeY = cy + dy * cell * 0.435;285 const tipX = edgeX + dx * size / 2;286 const tipY = edgeY + dy * size / 2;287 const tailX = edgeX - dx * size / 2;288 const tailY = edgeY - dy * size / 2;289 const wingX = dy * size / 2;290 const wingY = dx * size / 2;291 ctx.beginPath();292 ctx.moveTo(tailX, tailY);293 ctx.lineTo(tailX + wingX, tailY + wingY);294 ctx.lineTo(tipX, tipY);295 ctx.lineTo(tailX - wingX, tailY - wingY);296 ctx.closePath();297 ctx.fill();298 }299 ctx.restore();300}301302function drawAutomataIndicators(ctx, state, geometry, colors, model, frame) {303 const systems = Array.isArray(state.automata)304 ? state.automata.filter(305 (system) => system?.active && system?.pattern?.length,306 )307 : [];308 if (!systems.length) return;309 const objects = model.objects;310 const positions = frame?.positions instanceof Map ? frame.positions : null;311 for (const system of systems) {312 const phase = automataPhase(system, frame) % system.pattern.length;313 const action = system.pattern[phase];314 if (!action) continue;315 const actors = objects.filter(316 (object) =>317 object.kind === "object" &&318 (!system.subject ||319 String(object.noun).toUpperCase() ===320 String(system.subject).toUpperCase()) &&321 model.propertiesById.get(object.id)?.has(system.property),322 );323 actors.forEach((object, index) => {324 const visual = positions?.get(object.id) || object;325 const stackOffset = Math.min(index, 2) * 0.09;326 const cellX = geometry.x + (visual.x + 0.5 + stackOffset) * geometry.cell;327 const cellY = geometry.y + (visual.y + 0.5 - stackOffset) * geometry.cell;328 drawAutomataCue(329 ctx,330 action,331 cellX,332 action.action === "wait" ? cellY + geometry.cell * 0.435 : cellY,333 geometry.cell,334 colors,335 );336 });337 }338}339340function drawLost(ctx, geometry, colors) {341 const { x, y, cell, boardWidth } = geometry;342 const words = ["NOTHING", "IS", "YOU"];343 const height = Math.max(18, cell * 0.34);344 const widths = words.map((text) => {345 ctx.font = `800 ${height * 0.44}px ${FONT}`;346 return Math.max(height, ctx.measureText(text).width + height * 0.6);347 });348 const gap = 6;349 const total = widths.reduce((sum, width) => sum + width, 0) + gap * 2;350 let left = x + (boardWidth - total) / 2;351 const cy = y + geometry.boardHeight / 2;352 words.forEach((word, index) => {353 const width = widths[index];354 roundRect(355 ctx,356 left,357 cy - height / 2,358 width,359 height,360 word === "IS" ? height / 2 : height * 0.1,361 );362 ctx.fillStyle =363 word === "YOU" ? colors.YOU : word === "IS" ? colors.board : colors.tile;364 ctx.fill();365 ctx.fillStyle = word === "IS" ? colors.fg : colors.ink;366 ctx.textAlign = "center";367 ctx.textBaseline = "middle";368 ctx.fillText(word, left + width / 2, cy);369 left += width + gap;370 });371 ctx.fillStyle = colors.YOU;372 ctx.font = `700 ${Math.max(11, cell * 0.15)}px ${FONT}`;373 ctx.textAlign = "center";374 ctx.fillText(375 "Z TO UNDO | R TO RESTART",376 x + boardWidth / 2,377 cy + height * 1.55,378 );379}380381/** Draw a board and return its computed geometry. */382export function drawBoard(ctx, state, options = {}) {383 if (384 !ctx ||385 !state ||386 !Number.isInteger(state.width) ||387 !Number.isInteger(state.height)388 )389 throw new TypeError(390 "drawBoard(ctx, state): state needs integer width and height",391 );392 const colors = { ...BOARD_COLORS, ...(options.colors || {}) };393 const geometry = boardGeometry(ctx, state, options);394 const frame = options.frame || null;395 const shake = Number(frame?.shake?.amplitude) || 0;396 if (frame && shake) {397 ctx.save();398 ctx.translate(shake * 0.35, -shake * 0.2);399 }400 drawSlab(ctx, geometry, colors);401 const model = prepareBoardModel(state, options, geometry, colors);402 drawEntities(ctx, state, model, options, { tile: drawTile, wall: drawWall });403 drawAutomataIndicators(ctx, state, geometry, colors, model, frame);404 drawSentenceBars(405 ctx,406 state,407 geometry,408 colors,409 model.objectById,410 frame,411 model.visualOf,412 );413 drawFrameEffects(ctx, frame, geometry, colors);414 if (!model.hasYou) {415 ctx.save();416 ctx.globalAlpha = 0.68;417 ctx.fillStyle = colors.ink;418 ctx.fillRect(419 geometry.x,420 geometry.y,421 geometry.boardWidth,422 geometry.boardHeight,423 );424 ctx.restore();425 drawLost(ctx, geometry, colors);426 }427 if (frame && shake) ctx.restore();428 return geometry;429}430431export default drawBoard;