/** * 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. * * Visual style is illustrated-map-ish rather than flowchart-ish: a * procedurally scattered terrain backdrop (river, mountains, trees) that * doesn't need to align to any specific tile, a wide dashed "road" between * spaces, and tiles drawn as beveled signs with a small deterministic tilt * per space so they read as hand-placed rather than machine-stamped. */ 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) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } /** Small stable hash so per-tile tilt/detail is deterministic, not random-per-render. */ function hashString(str) { let h = 0; for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0; return Math.abs(h); } function makeRand(seed) { let s = seed; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; } export class BoardView { constructor(container, board) { this.board = board; this.layout = computeLayout(board); this.container = container; this.tokenEls = new Map(); // playerId -> 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._buildDefs()); 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); } _buildDefs() { const defs = svgEl('defs'); const field = svgEl('radialGradient', { id: 'fieldGradient', cx: '30%', cy: '0%', r: '85%' }); field.appendChild(svgEl('stop', { offset: '0%', 'stop-color': '#2a5f4c' })); field.appendChild(svgEl('stop', { offset: '55%', 'stop-color': '#1d493c' })); field.appendChild(svgEl('stop', { offset: '100%', 'stop-color': '#163a30' })); defs.appendChild(field); const gloss = svgEl('linearGradient', { id: 'tileGloss', x1: '0', y1: '0', x2: '0', y2: '1' }); gloss.appendChild(svgEl('stop', { offset: '0%', 'stop-color': '#ffffff', 'stop-opacity': '0.38' })); gloss.appendChild(svgEl('stop', { offset: '60%', 'stop-color': '#ffffff', 'stop-opacity': '0' })); defs.appendChild(gloss); // Subtle hand-drawn wobble applied to the road paths only. const wobble = svgEl('filter', { id: 'roadWobble', x: '-20%', y: '-20%', width: '140%', height: '140%' }); wobble.appendChild(svgEl('feTurbulence', { type: 'fractalNoise', baseFrequency: '0.012 0.02', numOctaves: '2', seed: '3', result: 'noise', })); wobble.appendChild(svgEl('feDisplacementMap', { in: 'SourceGraphic', in2: 'noise', scale: '5', xChannelSelector: 'R', yChannelSelector: 'G', })); defs.appendChild(wobble); return defs; } _buildBackdrop(width, height) { const g = svgEl('g', { class: 'backdrop' }); g.appendChild(svgEl('rect', { x: 0, y: 0, width, height, class: 'field-bg' })); const rand = makeRand(7); // deterministic — stable across re-renders, not flickering const mountainCount = Math.max(3, Math.floor(width / 460)); for (let i = 0; i < mountainCount; i++) { g.appendChild(this._buildMountainCluster(rand() * width, rand() * height, 70 + rand() * 55, rand)); } g.appendChild(this._buildRiver(width, height, rand)); const treeClusterCount = Math.max(14, Math.floor((width * height) / 15000)); for (let i = 0; i < treeClusterCount; i++) { g.appendChild(this._buildTreeCluster(rand() * width, rand() * height, rand)); } return g; } _buildMountainCluster(cx, cy, size, rand) { const g = svgEl('g', { class: 'mountain-cluster', transform: `translate(${cx.toFixed(1)},${cy.toFixed(1)})` }); const peaks = 3; for (let i = 0; i < peaks; i++) { const w = size * (0.55 + rand() * 0.3); const h = size * (0.75 + rand() * 0.25); const x = (i - 1) * size * 0.5; g.appendChild(svgEl('polygon', { points: `${x},0 ${x - w / 2},${h} ${x + w / 2},${h}`, class: 'mountain-body', })); g.appendChild(svgEl('polygon', { points: `${x},0 ${x - w * 0.18},${h * 0.3} ${x + w * 0.18},${h * 0.3}`, class: 'mountain-snow', })); } return g; } _buildRiver(width, height, rand) { const steps = Math.max(4, Math.round(height / 260)); const points = []; for (let i = 0; i <= steps; i++) { points.push([width * 0.15 + rand() * width * 0.7, (height / steps) * i]); } let d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`; for (let i = 1; i < points.length; i++) { const [px, py] = points[i - 1]; const [x, y] = points[i]; const cy = (py + y) / 2; d += ` C ${px.toFixed(1)} ${cy.toFixed(1)}, ${x.toFixed(1)} ${cy.toFixed(1)}, ${x.toFixed(1)} ${y.toFixed(1)}`; } const g = svgEl('g', { class: 'river' }); g.appendChild(svgEl('path', { d, class: 'river-body' })); g.appendChild(svgEl('path', { d, class: 'river-shine' })); return g; } _buildTreeCluster(cx, cy, rand) { const g = svgEl('g', { class: 'tree-cluster', transform: `translate(${cx.toFixed(1)},${cy.toFixed(1)})` }); const count = 1 + Math.floor(rand() * 2); for (let i = 0; i < count; i++) { const ox = (rand() - 0.5) * 20; const oy = (rand() - 0.5) * 12; const r = 10 + rand() * 9; g.appendChild(svgEl('circle', { cx: ox.toFixed(1), cy: (oy - r * 0.6).toFixed(1), r: r.toFixed(1), class: rand() > 0.5 ? 'tree-canopy-a' : 'tree-canopy-b', })); g.appendChild(svgEl('rect', { x: (ox - 2).toFixed(1), y: oy.toFixed(1), width: 4, height: 8, class: 'tree-trunk' })); } 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: 'road-shadow' })); g.appendChild(svgEl('path', { d, class: 'road-base' })); g.appendChild(svgEl('path', { d, class: 'road-centerline' })); } } 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; } /** One of {circle, diamond, rect}, offset by (dx,dy) — used for the shadow, * main, and gloss copies of a tile so all three share exact geometry. */ _shapeEl(shapeType, dx, dy, cls) { const t = shapeType === 'diamond' ? `translate(${dx},${dy}) rotate(45)` : `translate(${dx},${dy})`; if (shapeType === 'circle') return svgEl('circle', { r: NODE_H / 2, class: cls, transform: t }); if (shapeType === 'diamond') { const r = NODE_H / 2 + 6; return svgEl('rect', { x: -r, y: -r, width: r * 2, height: r * 2, rx: 8, class: cls, transform: t }); } return svgEl('rect', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: cls, transform: t }); } _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, }); // A few degrees of deterministic tilt per tile — hand-placed, not // machine-stamped — applied only to the tile's shape, never its label. const tilt = ((hashString(space.id) % 700) / 100 - 3.5).toFixed(2); const shapeGroup = svgEl('g', { class: 'tile-shape-group', transform: `rotate(${tilt})` }); shapeGroup.appendChild(this._shapeEl(style.shape, 3, 4, 'space-shadow')); shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-shape')); shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-gloss')); if (space.type === 'choice') { shapeGroup.appendChild(svgEl('rect', { x: -4, y: NODE_H / 2 + 2, width: 8, height: 22, class: 'choice-post' })); } node.appendChild(shapeGroup); 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 = `${style.icon}${escapeHtml(space.label)}`; 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, '\\$&'); }