clone

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

Split board rendering into model and entity passes

Dami committed 74c94f1
commit
74c94f1c1a92a9634b0c089e5091127697ebaf0b
parent
308450f03f151b1502baa15e95c6760d74aab45c
author
Dami <dami.etoile@gmail.com>
4 files changed, +350 -256
Msrc/render/board.js284+++---------------------------
Asrc/render/board/entities.js95++++++++++
Asrc/render/board/model.js214+++++++++++++++++++++++
Mtests/board.test.js13+
M src/render/board.js +28 -256
@@ -1,4 +1,16 @@
1-import { nativeHorizontalFacing } from './facing.js';1+import {2+ BOARD_COLORS,3+ FONT,4+ boardGeometry,5+ facingFor,6+ prepareBoardModel,7+ role,8+} from './board/model.js';9+import { drawEntities } from './board/entities.js';10+11+// Preserve the renderer's public color token export while keeping the palette12+// definition in the pure board model.13+export { BOARD_COLORS };214 315 /**416 * Ink and signal board renderer.
@@ -7,48 +19,6 @@ import { nativeHorizontalFacing } from './facing.js';
719 * animation, input, and rule evaluation stay in the engine and scene layer.820 */921 10-export const BOARD_COLORS = Object.freeze({11- stage: "#0F0E0D",12- board: "#191816",13- edge: "#2E2B28",14- lip: "#000000",15- tile: "#F2EEE6",16- ink: "#0F0E0D",17- fg: "#F2EEE6",18- muted: "#8E877C",19- wall: "#252321",20- dot: "#4A4540",21- YOU: "#FF4B3E",22- PUSH: "#FFB020",23- STOP: "#8B91FF",24- WIN: "#2EE6A8",25- SINK: "#3AB8FF",26- DEFEAT: "#FF4FA3",27- TICK: "#C6F432",28-});29-30-const PROPERTIES = new Set([31- "YOU",32- "PUSH",33- "STOP",34- "WIN",35- "SINK",36- "DEFEAT",37- "TICK",38-]);39-const OPERATORS = new Set(["IS", "AND"]);40-const FONT = "'Recursive', ui-monospace, monospace";41-42-function clamp(value, min, max) {43- return Math.max(min, Math.min(max, value));44-}45-function role(text) {46- return OPERATORS.has(text)47- ? "operator"48- : PROPERTIES.has(text)49- ? "property"50- : "noun";51-}5222 function roundRect(ctx, x, y, width, height, radius) {5323 ctx.beginPath();5424 if (typeof ctx.roundRect === "function")
@@ -66,71 +36,6 @@ function fitFont(ctx, text, maxWidth, baseSize, weight = 800, family = FONT) {
6636 }6737 return `${weight} ${Math.max(size, baseSize * 0.55)}px ${family}`;6838 }69-function propertiesFor(object, state, game) {70- if (game?.properties) return new Set(game.properties(object));71- if (object.kind === "word") return new Set(["PUSH"]);72- return new Set(73- (state.rules || [])74- .filter(75- (rule) =>76- rule.subject === object.noun && PROPERTIES.has(rule.predicate),77- )78- .map((rule) => rule.predicate),79- );80-}81-function horizontalFacing(visual) {82- const facing = visual?.facing;83- if (typeof facing === "string")84- return facing.toLowerCase() === "left"85- ? "left"86- : facing.toLowerCase() === "right"87- ? "right"88- : null;89- const dx = Number.isFinite(facing?.x)90- ? facing.x91- : Number.isFinite(visual?.dx)92- ? visual.dx93- : null;94- return dx < 0 ? "left" : dx > 0 ? "right" : null;95-}96-function isEmojiGlyph(glyph) {97- return Array.from(String(glyph || "")).some((character) => {98- const code = character.codePointAt(0);99- return code > 0x1f000 || character === "\uFE0F";100- });101-}102-function boardGeometry(ctx, state, options) {103- const width = state.width || 0;104- const height = state.height || 0;105- const x = options.x ?? options.ox ?? 0;106- const y = options.y ?? options.oy ?? 0;107- const availableWidth =108- options.availableWidth ?? ctx.canvas?.width ?? width * 64;109- const availableHeight =110- options.availableHeight ?? ctx.canvas?.height ?? height * 64;111- const cell =112- options.cell ??113- clamp(114- Math.floor(115- Math.min(116- availableWidth / Math.max(width, 1),117- availableHeight / Math.max(height, 1),118- ),119- ),120- 22,121- 96,122- );123- return {124- x,125- y,126- cell,127- width,128- height,129- boardWidth: width * cell,130- boardHeight: height * cell,131- };132-}133-13439 function drawSlab(ctx, geometry, colors) {13540 const { x, y, cell, boardWidth, boardHeight } = geometry;13641 const pad = Math.round(cell * 0.22);
@@ -395,14 +300,14 @@ function drawAutomataCue(ctx, action, cx, cy, cell, colors) {
395300 ctx.restore();396301 }397302 398-function drawAutomataIndicators(ctx, state, geometry, colors, game, frame) {303+function drawAutomataIndicators(ctx, state, geometry, colors, model, frame) {399304 const systems = Array.isArray(state.automata)400305 ? state.automata.filter(401306 (system) => system?.active && system?.pattern?.length,402307 )403308 : [];404309 if (!systems.length) return;405- const objects = Array.isArray(state.objects) ? state.objects : [];310+ const objects = model.objects;406311 const positions = frame?.positions instanceof Map ? frame.positions : null;407312 for (const system of systems) {408313 const phase = automataPhase(system, frame) % system.pattern.length;
@@ -414,7 +319,7 @@ function drawAutomataIndicators(ctx, state, geometry, colors, game, frame) {
414319 (!system.subject ||415320 String(object.noun).toUpperCase() ===416321 String(system.subject).toUpperCase()) &&417- propertiesFor(object, state, game).has(system.property),322+ model.propertiesById.get(object.id)?.has(system.property),418323 );419324 actors.forEach((object, index) => {420325 const visual = positions?.get(object.id) || object;
@@ -488,159 +393,26 @@ export function drawBoard(ctx, state, options = {}) {
488393 const colors = { ...BOARD_COLORS, ...(options.colors || {}) };489394 const geometry = boardGeometry(ctx, state, options);490395 const frame = options.frame || null;491- const framePositions =492- frame?.positions instanceof Map ? frame.positions : null;493- const visualOf = (object) => {494- const visual = framePositions?.get(object.id);495- return visual ? { ...object, ...visual } : object;496- };497396 const shake = Number(frame?.shake?.amplitude) || 0;498397 if (frame && shake) {499398 ctx.save();500399 ctx.translate(shake * 0.35, -shake * 0.2);501400 }502401 drawSlab(ctx, geometry, colors);503- const objects = Array.isArray(state.objects) ? state.objects : [];504- const objectById = new Map(objects.map((object) => [object.id, object]));505- const walls = new Set(506- objects507- .filter(508- (object) =>509- object.kind === "object" &&510- (options.wallNouns || ["WALL"]).includes(object.noun),511- )512- .map((object) => `${object.x},${object.y}`),513- );514- const stacks = new Map();515- objects.forEach((object) => {516- const key = `${object.x},${object.y}`;517- stacks.set(key, (stacks.get(key) || 0) + 1);518- });519- const game = options.game;520- const sorted = [...objects].sort((a, b) => {521- const rank = (object) =>522- object.kind === "word"523- ? 3524- : walls.has(`${object.x},${object.y}`)525- ? 0526- : propertiesFor(object, state, game).has("YOU")527- ? 2528- : 1;529- return rank(a) - rank(b);530- });531- const activeIds = new Set(532- (state.sentences || []).flatMap((sentence) => sentence.ids || []),402+ const model = prepareBoardModel(state, options, geometry, colors);403+ drawEntities(ctx, state, model, options, { tile: drawTile, wall: drawWall });404+ drawAutomataIndicators(ctx, state, geometry, colors, model, frame);405+ drawSentenceBars(406+ ctx,407+ state,408+ geometry,409+ colors,410+ model.objectById,411+ frame,412+ model.visualOf,533413 );534- const stackIndices = new Map();535- for (const object of sorted) {536- const key = `${object.x},${object.y}`;537- const index = stackIndices.get(key) || 0;538- stackIndices.set(key, index + 1);539- const count = stacks.get(key) || 1;540- const visual = visualOf(object);541- if (542- object.kind === "object" &&543- (options.wallNouns || ["WALL"]).includes(object.noun)544- ) {545- drawWall(ctx, object, geometry, walls, colors, visual);546- continue;547- }548- const offset = count > 1 ? index * 0.09 : 0;549- const cx = geometry.x + (visual.x + 0.5 + offset) * geometry.cell;550- const cy = geometry.y + (visual.y + 0.5 - offset) * geometry.cell;551- const scaleX = Number.isFinite(visual.scaleX) ? visual.scaleX : 1;552- const scaleY = Number.isFinite(visual.scaleY) ? visual.scaleY : 1;553- if (scaleX !== 1 || scaleY !== 1) {554- ctx.save();555- ctx.translate(cx, cy);556- ctx.scale(scaleX, scaleY);557- ctx.translate(-cx, -cy);558- }559- if (object.kind === "word") {560- ctx.save();561- if (count > 1) ctx.rotate((index % 2 ? 1 : -1) * 0.07);562- drawTile(563- ctx,564- object.text,565- cx,566- cy,567- geometry.cell,568- activeIds.has(object.id),569- colors,570- );571- ctx.restore();572- if (scaleX !== 1 || scaleY !== 1) ctx.restore();573- continue;574- }575- const props = propertiesFor(object, state, game);576- const paletteEntry = options.palette?.[object.noun];577- const paletteGlyph =578- paletteEntry && typeof paletteEntry === "object"579- ? paletteEntry.glyph580- : paletteEntry;581- const glyph =582- paletteGlyph || options.glyphs?.[object.noun] || object.noun.slice(0, 3);583- if (props.has("YOU")) {584- ctx.fillStyle = `${colors.YOU}66`;585- ctx.beginPath();586- ctx.ellipse(587- cx,588- geometry.y + (object.y + 0.82) * geometry.cell,589- geometry.cell * 0.26,590- geometry.cell * 0.06,591- 0,592- 0,593- Math.PI * 2,594- );595- ctx.fill();596- }597- ctx.font = `${Math.max(14, geometry.cell * 0.6)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;598- ctx.textAlign = "center";599- ctx.textBaseline = "middle";600- ctx.fillStyle = colors.fg;601- const nativeFacing = nativeHorizontalFacing(602- object.noun,603- glyph,604- paletteEntry,605- );606- const actualFacing = horizontalFacing(visual) || nativeFacing;607- const mirror =608- nativeFacing &&609- actualFacing &&610- nativeFacing !== actualFacing &&611- isEmojiGlyph(glyph);612- const glyphScaleX = Number.isFinite(visual.glyphScaleX)613- ? visual.glyphScaleX614- : 1;615- // Mirror the emoji from its declared native direction; keep the shadow,616- // cell geometry, and text unchanged.617- if (mirror || glyphScaleX !== 1) {618- ctx.save();619- ctx.translate(cx, cy);620- ctx.scale((mirror ? -1 : 1) * glyphScaleX, 1);621- ctx.translate(-cx, -cy);622- ctx.fillText(glyph, cx, cy);623- ctx.restore();624- } else {625- ctx.fillText(glyph, cx, cy);626- }627- if (count > 2 && index === 0) {628- ctx.font = `800 ${Math.max(10, geometry.cell * 0.16)}px ${FONT}`;629- ctx.fillText(630- `×${count}`,631- geometry.x + (object.x + 0.86) * geometry.cell,632- geometry.y + (object.y + 0.86) * geometry.cell,633- );634- }635- if (scaleX !== 1 || scaleY !== 1) ctx.restore();636- }637- drawAutomataIndicators(ctx, state, geometry, colors, game, frame);638- drawSentenceBars(ctx, state, geometry, colors, objectById, frame, visualOf);639414 drawFrameEffects(ctx, frame, geometry, colors);640- const hasYou = objects.some((object) =>641- propertiesFor(object, state, game).has("YOU"),642- );643- if (!hasYou) {415+ if (!model.hasYou) {644416 ctx.save();645417 ctx.globalAlpha = 0.68;646418 ctx.fillStyle = colors.ink;
A src/render/board/entities.js +95 -0
@@ -0,0 +1,95 @@
1+import { facingFor, FONT } from './model.js';2+3+/**4+ * Draw the ordered object pass for a board.5+ *6+ * The pass owns stack offsets, transforms, and glyph facing. Board decoration7+ * and transient effects stay in the parent renderer, so new entity types can8+ * be added without growing the frame and overlay code.9+ */10+export function drawEntities(ctx, state, model, options, draw) {11+ const { geometry, colors, objects, sorted, stacks, activeIds, walls } = model;12+ const stackIndices = new Map();13+ for (const object of sorted) {14+ const key = `${object.x},${object.y}`;15+ const index = stackIndices.get(key) || 0;16+ stackIndices.set(key, index + 1);17+ const count = stacks.get(key) || 1;18+ const visual = model.visualOf(object);19+ if (object.kind === 'object' && model.wallNouns.includes(object.noun)) {20+ draw.wall(ctx, object, geometry, walls, colors, visual);21+ continue;22+ }23+ const offset = count > 1 ? index * 0.09 : 0;24+ const cx = geometry.x + (visual.x + 0.5 + offset) * geometry.cell;25+ const cy = geometry.y + (visual.y + 0.5 - offset) * geometry.cell;26+ const scaleX = Number.isFinite(visual.scaleX) ? visual.scaleX : 1;27+ const scaleY = Number.isFinite(visual.scaleY) ? visual.scaleY : 1;28+ if (scaleX !== 1 || scaleY !== 1) {29+ ctx.save();30+ ctx.translate(cx, cy);31+ ctx.scale(scaleX, scaleY);32+ ctx.translate(-cx, -cy);33+ }34+ if (object.kind === 'word') {35+ ctx.save();36+ if (count > 1) ctx.rotate((index % 2 ? 1 : -1) * 0.07);37+ draw.tile(38+ ctx,39+ object.text,40+ cx,41+ cy,42+ geometry.cell,43+ activeIds.has(object.id),44+ colors,45+ );46+ ctx.restore();47+ if (scaleX !== 1 || scaleY !== 1) ctx.restore();48+ continue;49+ }50+ const props = model.propertiesById.get(object.id) || new Set();51+ const { glyph, mirror } = facingFor(object, visual, options);52+ if (props.has('YOU')) {53+ ctx.fillStyle = `${colors.YOU}66`;54+ ctx.beginPath();55+ ctx.ellipse(56+ cx,57+ geometry.y + (object.y + 0.82) * geometry.cell,58+ geometry.cell * 0.26,59+ geometry.cell * 0.06,60+ 0,61+ 0,62+ Math.PI * 2,63+ );64+ ctx.fill();65+ }66+ ctx.font = `${Math.max(14, geometry.cell * 0.6)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;67+ ctx.textAlign = 'center';68+ ctx.textBaseline = 'middle';69+ ctx.fillStyle = colors.fg;70+ const glyphScaleX = Number.isFinite(visual.glyphScaleX)71+ ? visual.glyphScaleX72+ : 1;73+ // Mirror the emoji from its declared native direction; keep the shadow,74+ // cell geometry, and text unchanged.75+ if (mirror || glyphScaleX !== 1) {76+ ctx.save();77+ ctx.translate(cx, cy);78+ ctx.scale((mirror ? -1 : 1) * glyphScaleX, 1);79+ ctx.translate(-cx, -cy);80+ ctx.fillText(glyph, cx, cy);81+ ctx.restore();82+ } else {83+ ctx.fillText(glyph, cx, cy);84+ }85+ if (count > 2 && index === 0) {86+ ctx.font = `800 ${Math.max(10, geometry.cell * 0.16)}px ${FONT}`;87+ ctx.fillText(88+ `×${count}`,89+ geometry.x + (object.x + 0.86) * geometry.cell,90+ geometry.y + (object.y + 0.86) * geometry.cell,91+ );92+ }93+ if (scaleX !== 1 || scaleY !== 1) ctx.restore();94+ }95+}
A src/render/board/model.js +214 -0
@@ -0,0 +1,214 @@
1+import { nativeHorizontalFacing } from '../facing.js';2+3+/**4+ * Pure data preparation for the board renderer.5+ *6+ * Keeping geometry, visual positions, and draw ordering out of the canvas7+ * pass makes the renderer easier to extend without adding more stateful8+ * branches to drawBoard. The returned model only contains values derived from9+ * the game snapshot and draw options.10+ */11+12+export const BOARD_COLORS = Object.freeze({13+ stage: '#0F0E0D',14+ board: '#191816',15+ edge: '#2E2B28',16+ lip: '#000000',17+ tile: '#F2EEE6',18+ ink: '#0F0E0D',19+ fg: '#F2EEE6',20+ muted: '#8E877C',21+ wall: '#252321',22+ dot: '#4A4540',23+ YOU: '#FF4B3E',24+ PUSH: '#FFB020',25+ STOP: '#8B91FF',26+ WIN: '#2EE6A8',27+ SINK: '#3AB8FF',28+ DEFEAT: '#FF4FA3',29+ TICK: '#C6F432',30+});31+32+export const PROPERTIES = new Set([33+ 'YOU',34+ 'PUSH',35+ 'STOP',36+ 'WIN',37+ 'SINK',38+ 'DEFEAT',39+ 'TICK',40+]);41+const OPERATORS = new Set(['IS', 'AND']);42+export const FONT = "'Recursive', ui-monospace, monospace";43+44+export function role(text) {45+ return OPERATORS.has(text)46+ ? 'operator'47+ : PROPERTIES.has(text)48+ ? 'property'49+ : 'noun';50+}51+52+export function propertiesFor(object, state, game) {53+ if (game?.properties) return new Set(game.properties(object));54+ if (object.kind === 'word') return new Set(['PUSH']);55+ return new Set(56+ (state.rules || [])57+ .filter(58+ (rule) =>59+ rule.subject === object.noun && PROPERTIES.has(rule.predicate),60+ )61+ .map((rule) => rule.predicate),62+ );63+}64+65+export function horizontalFacing(visual) {66+ const facing = visual?.facing;67+ if (typeof facing === 'string') {68+ const direction = facing.toLowerCase();69+ return direction === 'left' || direction === 'right' ? direction : null;70+ }71+ const dx = Number.isFinite(facing?.x)72+ ? facing.x73+ : Number.isFinite(visual?.dx)74+ ? visual.dx75+ : null;76+ return dx < 0 ? 'left' : dx > 0 ? 'right' : null;77+}78+79+export function isEmojiGlyph(glyph) {80+ return Array.from(String(glyph || '')).some((character) => {81+ const code = character.codePointAt(0);82+ return code > 0x1f000 || character === '\uFE0F';83+ });84+}85+86+export function boardGeometry(ctx, state, options) {87+ const width = state.width || 0;88+ const height = state.height || 0;89+ const x = options.x ?? options.ox ?? 0;90+ const y = options.y ?? options.oy ?? 0;91+ const availableWidth =92+ options.availableWidth ?? ctx.canvas?.width ?? width * 64;93+ const availableHeight =94+ options.availableHeight ?? ctx.canvas?.height ?? height * 64;95+ const cell =96+ options.cell ??97+ clamp(98+ Math.floor(99+ Math.min(100+ availableWidth / Math.max(width, 1),101+ availableHeight / Math.max(height, 1),102+ ),103+ ),104+ 22,105+ 96,106+ );107+ return {108+ x,109+ y,110+ cell,111+ width,112+ height,113+ boardWidth: width * cell,114+ boardHeight: height * cell,115+ };116+}117+118+export function clamp(value, min, max) {119+ return Math.max(min, Math.min(max, value));120+}121+122+export function prepareBoardModel(state, options, geometry, colors) {123+ const frame = options.frame || null;124+ const objects = Array.isArray(state.objects) ? state.objects : [];125+ const framePositions =126+ frame?.positions instanceof Map ? frame.positions : null;127+ const visualOf = (object) => {128+ const visual = framePositions?.get(object.id);129+ return visual ? { ...object, ...visual } : object;130+ };131+ const objectById = new Map(objects.map((object) => [object.id, object]));132+ const wallNouns = options.wallNouns || ['WALL'];133+ const walls = new Set(134+ objects135+ .filter(136+ (object) =>137+ object.kind === 'object' && wallNouns.includes(object.noun),138+ )139+ .map((object) => `${object.x},${object.y}`),140+ );141+ const stacks = new Map();142+ objects.forEach((object) => {143+ const key = `${object.x},${object.y}`;144+ stacks.set(key, (stacks.get(key) || 0) + 1);145+ });146+ const game = options.game;147+ const propertiesById = new Map(148+ objects.map((object) => [object.id, propertiesFor(object, state, game)]),149+ );150+ const cachedProperties = (object) =>151+ propertiesById.get(object.id) || new Set();152+ const sorted = [...objects].sort((a, b) => {153+ const rank = (object) =>154+ object.kind === 'word'155+ ? 3156+ : walls.has(`${object.x},${object.y}`)157+ ? 0158+ : cachedProperties(object).has('YOU')159+ ? 2160+ : 1;161+ return rank(a) - rank(b);162+ });163+ const activeIds = new Set(164+ (state.sentences || []).flatMap((sentence) => sentence.ids || []),165+ );166+ const hasYou = objects.some((object) =>167+ cachedProperties(object).has('YOU'),168+ );169+ return {170+ colors,171+ frame,172+ geometry,173+ game,174+ objects,175+ objectById,176+ propertiesById,177+ walls,178+ wallNouns,179+ stacks,180+ sorted,181+ activeIds,182+ hasYou,183+ visualOf,184+ };185+}186+187+export function glyphFor(object, options) {188+ const paletteEntry = options.palette?.[object.noun];189+ const paletteGlyph =190+ paletteEntry && typeof paletteEntry === 'object'191+ ? paletteEntry.glyph192+ : paletteEntry;193+ return {194+ paletteEntry,195+ glyph:196+ paletteGlyph || options.glyphs?.[object.noun] || object.noun.slice(0, 3),197+ };198+}199+200+export function facingFor(object, visual, options) {201+ const { paletteEntry, glyph } = glyphFor(object, options);202+ const nativeFacing = nativeHorizontalFacing(object.noun, glyph, paletteEntry);203+ const actualFacing = horizontalFacing(visual) || nativeFacing;204+ return {205+ glyph,206+ nativeFacing,207+ actualFacing,208+ mirror:209+ nativeFacing &&210+ actualFacing &&211+ nativeFacing !== actualFacing &&212+ isEmojiGlyph(glyph),213+ };214+}
M tests/board.test.js +13 -0