- commit
- 44c5b432e50ca7b91f2f13ec651305c915dca919
- parent
- 1e756ac9437ce14c7d0566da357e981f6b0513b0
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 196 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 57 | +++++++++ |
@@ -0,0 +1,196 @@
1+/**2+ * Ink and signal board renderer.3+ *4+ * This module only reads state and draws to the supplied 2D context. Movement,5+ * animation, input, and rule evaluation stay in the engine and scene layer.6+ */7+8+export const BOARD_COLORS = Object.freeze({9+ stage: '#0F0E0D', board: '#191816', edge: '#2E2B28', lip: '#000000',10+ tile: '#F2EEE6', ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C',11+ wall: '#252321', dot: '#4A4540',12+ YOU: '#FF4B3E', PUSH: '#FFB020', STOP: '#8B91FF', WIN: '#2EE6A8', SINK: '#3AB8FF', DEFEAT: '#FF4FA3',13+});14+15+const PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);16+const OPERATORS = new Set(['IS', 'AND']);17+18+function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }19+function role(text) { return OPERATORS.has(text) ? 'operator' : PROPERTIES.has(text) ? 'property' : 'noun'; }20+function roundRect(ctx, x, y, width, height, radius) {21+ ctx.beginPath();22+ if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius);23+ else ctx.rect(x, y, width, height);24+}25+function fitFont(ctx, text, maxWidth, baseSize, weight = 800, family = 'ui-monospace, monospace') {26+ let size = baseSize;27+ if (typeof ctx.measureText !== 'function') return `${weight} ${size}px ${family}`;28+ while (size > baseSize * 0.55) {29+ ctx.font = `${weight} ${size}px ${family}`;30+ if (ctx.measureText(text).width <= maxWidth) break;31+ size *= 0.93;32+ }33+ return `${weight} ${Math.max(size, baseSize * 0.55)}px ${family}`;34+}35+function propertiesFor(object, state, game) {36+ if (game?.properties) return new Set(game.properties(object));37+ if (object.kind === 'word') return new Set(['PUSH']);38+ return new Set((state.rules || []).filter(rule => rule.subject === object.noun && PROPERTIES.has(rule.predicate)).map(rule => rule.predicate));39+}40+function boardGeometry(ctx, state, options) {41+ const width = state.width || 0;42+ const height = state.height || 0;43+ const x = options.x ?? options.ox ?? 0;44+ const y = options.y ?? options.oy ?? 0;45+ const availableWidth = options.availableWidth ?? ctx.canvas?.width ?? width * 64;46+ const availableHeight = options.availableHeight ?? ctx.canvas?.height ?? height * 64;47+ const cell = options.cell ?? clamp(Math.floor(Math.min(availableWidth / Math.max(width, 1), availableHeight / Math.max(height, 1))), 22, 96);48+ return { x, y, cell, width, height, boardWidth: width * cell, boardHeight: height * cell };49+}50+51+function drawSlab(ctx, geometry, colors) {52+ const { x, y, cell, boardWidth, boardHeight } = geometry;53+ const pad = Math.round(cell * 0.22);54+ ctx.fillStyle = colors.lip;55+ ctx.fillRect(x - pad + 8, y - pad + 8, boardWidth + pad * 2, boardHeight + pad * 2);56+ roundRect(ctx, x - pad, y - pad, boardWidth + pad * 2, boardHeight + pad * 2, 4);57+ ctx.fillStyle = colors.board; ctx.fill();58+ ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();59+ ctx.fillStyle = colors.dot;60+ for (let iy = 0; iy <= geometry.height; iy++) for (let ix = 0; ix <= geometry.width; ix++) {61+ ctx.beginPath(); ctx.arc(x + ix * cell, y + iy * cell, Math.max(1.2, cell * 0.022), 0, Math.PI * 2); ctx.fill();62+ }63+}64+65+function drawTile(ctx, text, cx, cy, cell, active, colors) {66+ const kind = role(text);67+ const width = cell * 0.74;68+ const height = kind === 'operator' ? cell * 0.48 : cell * 0.74;69+ const left = cx - width / 2;70+ const top = cy - height / 2;71+ const lip = cell * 0.04;72+ const shape = (dx, dy) => {73+ if (kind === 'operator') { roundRect(ctx, left + dx, top + dy, width, height, height / 2); return; }74+ if (kind === 'property') {75+ const chamfer = cell * 0.14;76+ ctx.beginPath(); ctx.moveTo(left + dx, top + dy); ctx.lineTo(left + dx + width - chamfer, top + dy);77+ ctx.lineTo(left + dx + width, top + dy + chamfer); ctx.lineTo(left + dx + width, top + dy + height);78+ ctx.lineTo(left + dx, top + dy + height); ctx.closePath(); return;79+ }80+ roundRect(ctx, left + dx, top + dy, width, height, cell * 0.04);81+ };82+ ctx.save();83+ ctx.globalAlpha *= active ? 1 : 0.4;84+ if (kind !== 'operator') { shape(lip, lip); ctx.fillStyle = colors.lip; ctx.fill(); }85+ shape(0, 0);86+ if (kind === 'operator') { ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.fg; ctx.lineWidth = Math.max(2, cell * 0.035); ctx.stroke(); }87+ else { ctx.fillStyle = kind === 'property' ? (colors[text] || colors.ink) : colors.tile; ctx.fill(); }88+ ctx.font = fitFont(ctx, text, width * 0.84, cell * 0.25, active ? 800 : 600);89+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = kind === 'operator' ? colors.fg : colors.ink;90+ ctx.fillText(text, cx, cy + cell * 0.012);91+ ctx.restore();92+}93+94+function drawWall(ctx, object, geometry, walls, colors) {95+ const { x, y, cell } = geometry;96+ const left = x + object.x * cell;97+ const top = y + object.y * cell;98+ ctx.fillStyle = colors.wall; ctx.fillRect(left, top, cell, cell);99+ ctx.save(); ctx.beginPath(); ctx.rect(left, top, cell, cell); ctx.clip();100+ ctx.strokeStyle = 'rgba(242,238,230,.08)'; ctx.lineWidth = 2;101+ for (let offset = -cell; offset < cell * 2; offset += cell / 4) {102+ ctx.beginPath(); ctx.moveTo(left + offset, top); ctx.lineTo(left + offset - cell, top + cell); ctx.stroke();103+ }104+ ctx.restore();105+}106+107+function drawSentenceBars(ctx, state, geometry, colors, objectById) {108+ const { x, y, cell } = geometry;109+ const sentences = Array.isArray(state.sentences) ? state.sentences : [];110+ for (const sentence of sentences) {111+ const objects = sentence.ids.map(id => objectById.get(id)).filter(Boolean);112+ const words = objects.filter(object => object.kind === 'word');113+ if (!words.length) continue;114+ const predicate = words[words.length - 1]?.text;115+ ctx.fillStyle = colors[predicate] || colors.ink;116+ if (sentence.axis === 'v') {117+ const px = Math.min(...words.map(word => word.x));118+ const y0 = Math.min(...words.map(word => word.y));119+ const y1 = Math.max(...words.map(word => word.y));120+ ctx.fillRect(x + px * cell + cell * 0.045, y + y0 * cell + cell * 0.16, Math.max(3, cell * 0.04), (y1 - y0 + 0.68) * cell);121+ } else {122+ const py = Math.min(...words.map(word => word.y));123+ const x0 = Math.min(...words.map(word => word.x));124+ const x1 = Math.max(...words.map(word => word.x));125+ roundRect(ctx, x + x0 * cell + cell * 0.08, y + (py + 0.935) * cell, (x1 - x0 + 0.84) * cell, Math.max(3, cell * 0.04), cell * 0.02);126+ ctx.fill();127+ }128+ }129+}130+131+function drawLost(ctx, geometry, colors) {132+ const { x, y, cell, boardWidth } = geometry;133+ const words = ['NOTHING', 'IS', 'YOU'];134+ const height = Math.max(18, cell * 0.34);135+ const widths = words.map(text => { ctx.font = `800 ${height * 0.44}px ui-monospace, monospace`; return Math.max(height, ctx.measureText(text).width + height * 0.6); });136+ const gap = 6;137+ const total = widths.reduce((sum, width) => sum + width, 0) + gap * 2;138+ let left = x + (boardWidth - total) / 2;139+ const cy = y + geometry.boardHeight / 2;140+ words.forEach((word, index) => {141+ const width = widths[index];142+ roundRect(ctx, left, cy - height / 2, width, height, word === 'IS' ? height / 2 : height * 0.1);143+ ctx.fillStyle = word === 'YOU' ? colors.YOU : word === 'IS' ? colors.board : colors.tile; ctx.fill();144+ ctx.fillStyle = word === 'IS' ? colors.fg : colors.ink; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';145+ ctx.fillText(word, left + width / 2, cy); left += width + gap;146+ });147+ ctx.fillStyle = colors.YOU; ctx.font = `700 ${Math.max(11, cell * 0.15)}px ui-monospace, monospace`; ctx.textAlign = 'center';148+ ctx.fillText('Z TO UNDO | R TO RESTART', x + boardWidth / 2, cy + height * 1.55);149+}150+151+/** Draw a board and return its computed geometry. */152+export function drawBoard(ctx, state, options = {}) {153+ if (!ctx || !state || !Number.isInteger(state.width) || !Number.isInteger(state.height)) throw new TypeError('drawBoard(ctx, state): state needs integer width and height');154+ const colors = { ...BOARD_COLORS, ...(options.colors || {}) };155+ const geometry = boardGeometry(ctx, state, options);156+ drawSlab(ctx, geometry, colors);157+ const objects = Array.isArray(state.objects) ? state.objects : [];158+ const objectById = new Map(objects.map(object => [object.id, object]));159+ const walls = new Set(objects.filter(object => object.kind === 'object' && (options.wallNouns || ['WALL']).includes(object.noun)).map(object => `${object.x},${object.y}`));160+ const stacks = new Map();161+ objects.forEach(object => { const key = `${object.x},${object.y}`; stacks.set(key, (stacks.get(key) || 0) + 1); });162+ const game = options.game;163+ const sorted = [...objects].sort((a, b) => {164+ const rank = object => object.kind === 'word' ? 3 : walls.has(`${object.x},${object.y}`) ? 0 : propertiesFor(object, state, game).has('YOU') ? 2 : 1;165+ return rank(a) - rank(b);166+ });167+ const activeIds = new Set((state.sentences || []).flatMap(sentence => sentence.ids || []));168+ const stackIndices = new Map();169+ for (const object of sorted) {170+ const key = `${object.x},${object.y}`;171+ const index = stackIndices.get(key) || 0; stackIndices.set(key, index + 1);172+ const count = stacks.get(key) || 1;173+ if (object.kind === 'object' && (options.wallNouns || ['WALL']).includes(object.noun)) { drawWall(ctx, object, geometry, walls, colors); continue; }174+ const offset = count > 1 ? index * 0.09 : 0;175+ const cx = geometry.x + (object.x + 0.5 + offset) * geometry.cell;176+ const cy = geometry.y + (object.y + 0.5 - offset) * geometry.cell;177+ if (object.kind === 'word') {178+ ctx.save(); if (count > 1) ctx.rotate((index % 2 ? 1 : -1) * 0.07); drawTile(ctx, object.text, cx, cy, geometry.cell, activeIds.has(object.id), colors); ctx.restore();179+ continue;180+ }181+ const props = propertiesFor(object, state, game);182+ const glyph = options.palette?.[object.noun] || options.glyphs?.[object.noun] || object.noun.slice(0, 3);183+ if (props.has('YOU')) {184+ ctx.fillStyle = `${colors.YOU}66`; ctx.beginPath(); ctx.ellipse(cx, geometry.y + (object.y + 0.82) * geometry.cell, geometry.cell * 0.26, geometry.cell * 0.06, 0, 0, Math.PI * 2); ctx.fill();185+ }186+ ctx.font = `${Math.max(14, geometry.cell * 0.6)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;187+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg; ctx.fillText(glyph, cx, cy);188+ if (count > 2 && index === 0) { ctx.font = `800 ${Math.max(10, geometry.cell * 0.16)}px ui-monospace, monospace`; ctx.fillText(`×${count}`, geometry.x + (object.x + 0.86) * geometry.cell, geometry.y + (object.y + 0.86) * geometry.cell); }189+ }190+ drawSentenceBars(ctx, state, geometry, colors, objectById);191+ const hasYou = objects.some(object => propertiesFor(object, state, game).has('YOU'));192+ if (!hasYou) { ctx.save(); ctx.globalAlpha = 0.68; ctx.fillStyle = colors.ink; ctx.fillRect(geometry.x, geometry.y, geometry.boardWidth, geometry.boardHeight); ctx.restore(); drawLost(ctx, geometry, colors); }193+ return geometry;194+}195+196+export default drawBoard;@@ -0,0 +1,57 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { drawBoard } from '../src/render/board.js';4+5+function context() {6+ const calls = [];7+ const ctx = {8+ canvas: { width: 320, height: 240 }, calls,9+ save() { calls.push(['save']); }, restore() { calls.push(['restore']); },10+ beginPath() { calls.push(['beginPath']); }, closePath() { calls.push(['closePath']); }, moveTo(...args) { calls.push(['moveTo', ...args]); }, lineTo(...args) { calls.push(['lineTo', ...args]); },11+ rect(...args) { calls.push(['rect', ...args]); }, roundRect(...args) { calls.push(['roundRect', ...args]); },12+ fill() { calls.push(['fill']); }, stroke() { calls.push(['stroke']); }, clip() { calls.push(['clip']); },13+ fillRect(...args) { calls.push(['fillRect', ...args]); },14+ arc(...args) { calls.push(['arc', ...args]); }, ellipse(...args) { calls.push(['ellipse', ...args]); },15+ fillText(...args) { calls.push(['fillText', ...args]); },16+ measureText(text) { return { width: String(text).length * 8 }; },17+ };18+ return ctx;19+}20+21+const state = {22+ width: 4, height: 3,23+ objects: [24+ { id: 'miau', kind: 'word', text: 'MIAU', x: 0, y: 0 },25+ { id: 'is', kind: 'word', text: 'IS', x: 1, y: 0 },26+ { id: 'you', kind: 'word', text: 'YOU', x: 2, y: 0 },27+ { id: 'cat', kind: 'object', noun: 'CAT', x: 1, y: 1 },28+ { id: 'wall', kind: 'object', noun: 'WALL', x: 3, y: 2 },29+ ],30+ rules: [{ subject: 'MIAU', predicate: 'YOU', text: 'MIAU IS YOU' }],31+ sentences: [{ ids: ['miau', 'is', 'you'], axis: 'h' }],32+};33+34+test('drawBoard computes geometry and keeps word tiles inside a 74% footprint', () => {35+ const ctx = context();36+ const geometry = drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' } });37+ assert.deepEqual(geometry, { x: 20, y: 10, cell: 50, width: 4, height: 3, boardWidth: 200, boardHeight: 150 });38+ assert.ok(ctx.calls.some(call => call[0] === 'roundRect' && call[3] === 37));39+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));40+});41+42+test('drawBoard draws sentence bars from physical sentence IDs and merges walls flush', () => {43+ const ctx = context();44+ drawBoard(ctx, state, { x: 20, y: 10, cell: 50 });45+ const horizontalBars = ctx.calls.filter(call => call[0] === 'roundRect' && call[1] >= 20 && call[2] > 50);46+ assert.ok(horizontalBars.length >= 1);47+ assert.ok(ctx.calls.some(call => call[0] === 'fillRect' && call[1] === 170 && call[2] === 110 && call[3] === 50 && call[4] === 50));48+});49+50+test('drawBoard dims a no-YOU board and shows recovery copy', () => {51+ const ctx = context();52+ const lost = { ...state, rules: [], sentences: [] };53+ drawBoard(ctx, lost, { x: 0, y: 0, cell: 40 });54+ assert.ok(ctx.calls.some(call => call[0] === 'fillRect' && call[1] === 0 && call[2] === 0 && call[3] === 160 && call[4] === 120));55+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'NOTHING'));56+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Z TO UNDO | R TO RESTART'));57+});