Files
lifegame/public/boardRender.js
T
Kevin 6aa9769ce5 Expand the board to the full sketch (~107 spaces, three forks)
shared/board.js now transcribes every distinct space label from the
hand-drawn sketch (assets/game_board.png) rather than Phase 1's small
subset. The sketch's arrows get genuinely ambiguous in a few places (it
reads as a mockup, not an engineered spec) and reuses several space names
across zones (Start A Business, Family Reunion, Market Crash, ...) — that
repetition is kept as intentional flavor rather than deduplicated, and the
ambiguous bits are resolved into a clean DAG with the same shape Phase 1
proved out: a fork, a chain() per branch, a convergence — repeated three
times (Career/Education/Gap Year, then Relationship/Investment, then High
Risk/Safe), into a long shared retirement tail. Branches are built with a
small chain() helper that auto-wires each entry's `next` to the following
one, since hand-wiring ~107 ids was too error-prone.

The reducer, SQLite schema, and rooms/WS layer needed zero changes — the
whole point of the pure-reducer/graph-data design from Phase 1. Two things
did need generalizing:
- public/boardRender.js's lane offset was hardcoded to a 2-way fork; the
  new "Which Path?" fork is 3-way (Career/Education/Gap Year), so the
  offset formula is now symmetric for any number of branches.
- shared/game.test.js hardcoded Phase 1's specific space ids. Rewritten to
  be board-structure-agnostic: it always resolves the first offered choice
  and otherwise rolls the max die value, which reliably makes progress
  regardless of board shape (walkForward always stops early at the next
  choice/finish), plus a graph-well-formedness check.

Verified: unit tests green; a full two-player game played headlessly
end-to-end through all three forks to Finish in 38 turns with zero
console/page errors; the rendered board visually confirmed at full scale
(5-row snake layout, 3-way fork fans out correctly, all labels legible).
2026-07-21 18:56:27 -07:00

280 lines
9.5 KiB
JavaScript

/**
* Renders the board graph as SVG, laid out entirely from board.spaces data —
* no hand-placed coordinates. Column = longest-path distance from Start;
* lane = branch offset that fans out at a 'choice' space and re-centers
* (averages) at whichever space its branches rejoin. This means the layout
* keeps working unmodified as shared/board.js grows.
*/
const SVG_NS = 'http://www.w3.org/2000/svg';
const COL_WIDTH = 108;
const ROW_HEIGHT = 168;
const LANE_HEIGHT = 64;
const MARGIN_X = 70;
const MARGIN_Y = 80;
const NODE_W = 88;
const NODE_H = 60;
const PLAYER_TOKEN_R = 12;
const TYPE_STYLE = {
start: { icon: '🚩', shape: 'circle' },
finish: { icon: '🏁', shape: 'circle' },
choice: { icon: '🔀', shape: 'diamond' },
money: { icon: '💰', shape: 'rect' },
event: { icon: '✨', shape: 'rect' },
};
/** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */
export function computeLayout(board, colsPerRow = 14) {
const ids = Object.keys(board.spaces);
const outEdges = new Map(ids.map((id) => [id, []]));
const predecessors = new Map(ids.map((id) => [id, []]));
for (const id of ids) {
const space = board.spaces[id];
const targets = space.type === 'choice' ? space.choices : (space.next ? [space.next] : []);
for (const t of targets) {
outEdges.get(id).push(t);
predecessors.get(t).push(id);
}
}
const col = new Map([[board.startSpaceId, 0]]);
const laneSum = new Map([[board.startSpaceId, 0]]);
const remaining = new Map(ids.map((id) => [id, predecessors.get(id).length]));
const queue = [board.startSpaceId];
while (queue.length) {
const id = queue.shift();
const space = board.spaces[id];
const children = outEdges.get(id);
children.forEach((childId, i) => {
col.set(childId, Math.max(col.get(childId) ?? -Infinity, col.get(id) + 1));
// Choice spaces fan their N branches out symmetrically around the
// parent's lane (works for 2-way, 3-way, ... forks alike); anything
// else just inherits the parent's lane unchanged. A join (>1
// predecessor) accumulates every incoming contribution and averages
// once all of its predecessors have been processed (guaranteed by the
// time `remaining` hits 0, since that only happens after every
// in-edge is visited).
const offset = space.type === 'choice' ? i - (children.length - 1) / 2 : 0;
const contribution = laneSum.get(id) + offset;
laneSum.set(childId, (laneSum.get(childId) ?? 0) + contribution);
remaining.set(childId, remaining.get(childId) - 1);
if (remaining.get(childId) === 0) {
const n = predecessors.get(childId).length || 1;
laneSum.set(childId, laneSum.get(childId) / n);
queue.push(childId);
}
});
}
const positions = new Map();
for (const id of ids) {
const c = col.get(id) ?? 0;
const lane = laneSum.get(id) ?? 0;
const row = Math.floor(c / colsPerRow);
let colInRow = c % colsPerRow;
if (row % 2 === 1) colInRow = colsPerRow - 1 - colInRow; // snake/boustrophedon
positions.set(id, {
x: MARGIN_X + colInRow * COL_WIDTH,
y: MARGIN_Y + row * ROW_HEIGHT + lane * LANE_HEIGHT,
col: c,
row,
});
}
const maxRow = Math.max(0, ...[...positions.values()].map((p) => p.row));
return {
positions,
edges: outEdges,
width: MARGIN_X * 2 + (colsPerRow - 1) * COL_WIDTH,
height: MARGIN_Y * 2 + maxRow * ROW_HEIGHT + 3 * LANE_HEIGHT,
};
}
function svgEl(tag, attrs = {}) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
return el;
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
export class BoardView {
constructor(container, board) {
this.board = board;
this.layout = computeLayout(board);
this.container = container;
this.tokenEls = new Map(); // playerId -> <g>
this._build();
}
_build() {
const { width, height } = this.layout;
const svg = svgEl('svg', {
viewBox: `0 0 ${width} ${height}`,
class: 'board-svg',
role: 'img',
'aria-label': 'Life Journey board',
});
svg.appendChild(this._buildBackdrop(width, height));
svg.appendChild(this._buildEdges());
svg.appendChild(this._buildNodes());
this.tokenLayer = svgEl('g', { class: 'token-layer' });
svg.appendChild(this.tokenLayer);
this.svg = svg;
this.container.innerHTML = '';
this.container.appendChild(svg);
}
_buildBackdrop(width, height) {
const g = svgEl('g', { class: 'backdrop' });
let seed = 7; // deterministic — stable across re-renders, not flickering
const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
const count = Math.max(6, Math.floor((width * height) / 11000));
for (let i = 0; i < count; i++) {
g.appendChild(svgEl('circle', {
cx: rand() * width, cy: rand() * height, r: 16 + rand() * 26, class: 'tree-blob',
}));
}
return g;
}
_buildEdges() {
const g = svgEl('g', { class: 'edges' });
for (const [id, children] of this.layout.edges) {
const from = this.layout.positions.get(id);
for (const childId of children) {
const to = this.layout.positions.get(childId);
const cx = from.x + (to.x - from.x) * 0.5;
const d = `M ${from.x} ${from.y} C ${cx} ${from.y}, ${cx} ${to.y}, ${to.x} ${to.y}`;
g.appendChild(svgEl('path', { d, class: 'edge-path' }));
}
}
return g;
}
_buildNodes() {
const g = svgEl('g', { class: 'nodes' });
for (const id of Object.keys(this.board.spaces)) {
g.appendChild(this._buildNode(this.board.spaces[id], this.layout.positions.get(id)));
}
return g;
}
_buildNode(space, pos) {
const style = TYPE_STYLE[space.type] ?? TYPE_STYLE.event;
const node = svgEl('g', {
class: `space space-${space.type}`,
transform: `translate(${pos.x}, ${pos.y})`,
'data-space-id': space.id,
});
if (style.shape === 'circle') {
node.appendChild(svgEl('circle', { r: NODE_H / 2, class: 'space-shape' }));
} else if (style.shape === 'diamond') {
const r = NODE_H / 2 + 6;
node.appendChild(svgEl('rect', {
x: -r, y: -r, width: r * 2, height: r * 2, rx: 8,
transform: 'rotate(45)', class: 'space-shape',
}));
} else {
node.appendChild(svgEl('rect', {
x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: 'space-shape',
}));
}
const fo = svgEl('foreignObject', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H });
const div = document.createElement('div');
div.className = 'space-label';
div.innerHTML = `<span class="space-icon">${style.icon}</span><span class="space-text">${escapeHtml(space.label)}</span>`;
fo.appendChild(div);
node.appendChild(fo);
if (space.cash) {
const badge = svgEl('text', {
class: `cash-badge ${space.cash > 0 ? 'pos' : 'neg'}`, y: NODE_H / 2 + 17, 'text-anchor': 'middle',
});
badge.textContent = `${space.cash > 0 ? '+' : ''}${space.cash}`;
node.appendChild(badge);
}
return node;
}
/** Move tokens to current positions, highlight+wire up any pending choice. */
update(state, self, { onChoose } = {}) {
this.svg.querySelectorAll('.space.highlight').forEach((el) => {
el.classList.remove('highlight', 'clickable');
el.onclick = null;
});
const grouped = new Map(); // spaceId -> player[]
for (const p of Object.values(state.players)) {
if (!grouped.has(p.position)) grouped.set(p.position, []);
grouped.get(p.position).push(p);
}
const seen = new Set();
for (const [position, occupants] of grouped) {
const pos = this.layout.positions.get(position);
if (!pos) continue;
occupants.forEach((p, i) => {
seen.add(p.id);
const angle = (i / occupants.length) * Math.PI * 2;
const spread = occupants.length > 1 ? 16 : 0;
const tx = pos.x + Math.cos(angle) * spread;
const ty = pos.y + NODE_H / 2 + 16 + Math.sin(angle) * (spread / 2);
let el = this.tokenEls.get(p.id);
if (!el) {
el = this._buildToken(p);
this.tokenLayer.appendChild(el);
this.tokenEls.set(p.id, el);
}
el.setAttribute('transform', `translate(${tx}, ${ty})`);
el.classList.toggle('current-turn', state.currentTurn === p.id);
});
}
for (const [id, el] of this.tokenEls) {
if (!seen.has(id)) { el.remove(); this.tokenEls.delete(id); }
}
const decidingPlayer = Object.values(state.players).find((p) => p.pendingChoice);
if (decidingPlayer) {
for (const optionId of decidingPlayer.pendingChoice.options) {
const el = this.svg.querySelector(`.space[data-space-id="${cssEscape(optionId)}"]`);
if (!el) continue;
el.classList.add('highlight');
if (decidingPlayer.id === self?.playerId) {
el.classList.add('clickable');
el.onclick = () => onChoose?.(optionId);
}
}
}
}
_buildToken(player) {
const g = svgEl('g', { class: 'token' });
g.appendChild(svgEl('circle', { r: PLAYER_TOKEN_R, class: 'token-dot', fill: player.color }));
const text = svgEl('text', { class: 'token-label', 'text-anchor': 'middle', dy: '0.35em' });
text.textContent = (player.name || '?').trim().charAt(0).toUpperCase();
g.appendChild(text);
return g;
}
}
function cssEscape(str) {
return String(str).replace(/["\\]/g, '\\$&');
}