1import { nativeHorizontalFacing } from '../facing.js';23/**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 */1112export 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});3132export const PROPERTIES = new Set([33 'YOU',34 'PUSH',35 'STOP',36 'WIN',37 'SINK',38 'DEFEAT',39 'TICK',40]);41const OPERATORS = new Set(['IS', 'AND']);42export const FONT = "'Recursive', ui-monospace, monospace";4344export function role(text) {45 return OPERATORS.has(text)46 ? 'operator'47 : PROPERTIES.has(text)48 ? 'property'49 : 'noun';50}5152export 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}6465export 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}7879export 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}8586export 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}117118export function clamp(value, min, max) {119 return Math.max(min, Math.min(max, value));120}121122export 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}186187export 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}199200export 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}