- commit
- 74c94f1c1a92a9634b0c089e5091127697ebaf0b
- parent
- 308450f03f151b1502baa15e95c6760d74aab45c
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 284 | +++--------------------------- |
| A | src/ | 95 | ++++++++++ |
| A | src/ | 214 | +++++++++++++++++++++++ |
| M | tests/ | 13 | + |
@@ -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;@@ -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+}@@ -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+}@@ -41,6 +41,19 @@ test('drawBoard computes geometry and keeps word tiles inside a 74% footprint',
4141 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));4242 });4343 44+test('drawBoard caches custom properties once per object for all render passes', () => {45+ const ctx = context();46+ let calls = 0;47+ const game = {48+ properties() {49+ calls += 1;50+ return [];51+ },52+ };53+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50, game });54+ assert.equal(calls, state.objects.length);55+});56+4457 test('drawBoard draws sentence bars from physical sentence IDs and merges walls flush', () => {4558 const ctx = context();4659 drawBoard(ctx, state, { x: 20, y: 10, cell: 50 });