db82b531e0
boardRender.js: TYPE_STYLE now covers the 9 real tile types instead of the
old 5 (start/finish/choice/money/event) — payday/cash_bonus/action_space/
dice_space/roll_table_ref/inline_table each get their own icon, color-coded
by mechanic (green=income, blue=rolls-against-a-real-table, dashed
light-blue=rolls-against-a-synthesized-placeholder, red=decision point).
The old per-space cash badge is gone (tiles no longer carry a static cash
amount — effects resolve dynamically via tables) and replaced with a die
badge ("D20") wherever a tile involves a roll. Also fixes a real bug the
headless verification run caught: computeLayout assumed every 'choice'-type
space has a populated `choices` array, which none do yet post-rebuild
(`for (const t of space.choices)` on undefined) — now falls back to `next`
like everything else, and the lane fan-out is keyed off "does this space
have more than one outgoing edge" generically rather than off `type`, so it
keeps working whenever choices/stop tiles do get real branches later.
index.html: player list shows all five stats (cash/love/education/wealth/
age) instead of just cash; the log feed shows each tile's actual resolution
description (already human-readable from tileEffects.js) with a small ⚠️
marker on placeholder/invented resolutions, instead of a bare cash delta.
computeLayout's colsPerRow bumped to 20 for the much larger (212-space)
board.
404 lines
15 KiB
JavaScript
404 lines
15 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.
|
|
*
|
|
* 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 = {
|
|
finish: { icon: '🏁', shape: 'circle' },
|
|
choice: { icon: '🔀', shape: 'diamond' },
|
|
stop: { icon: '🛑', shape: 'diamond' },
|
|
payday: { icon: '💵', shape: 'rect' },
|
|
cash_bonus: { icon: '💰', shape: 'rect' },
|
|
action_space: { icon: '🎯', shape: 'rect' },
|
|
dice_space: { icon: '🎲', shape: 'rect' },
|
|
roll_table_ref: { icon: '📋', shape: 'rect' },
|
|
inline_table: { icon: '🎰', shape: 'rect' },
|
|
event: { icon: '✨', shape: 'rect' },
|
|
};
|
|
|
|
/** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */
|
|
export function computeLayout(board, colsPerRow = 20) {
|
|
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?.length ? 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));
|
|
|
|
// Any space with more than one outgoing edge fans its branches out
|
|
// symmetrically around the parent's lane (works for 2-way, 3-way, ...
|
|
// forks alike, whatever type the space is — not just 'choice'); a
|
|
// single child 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 = children.length > 1 ? 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 -> <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._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 isStart = space.id === this.board.startSpaceId;
|
|
const node = svgEl('g', {
|
|
class: `space space-${space.type}${isStart ? ' space-start-marker' : ''}`,
|
|
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' || space.type === 'stop') {
|
|
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';
|
|
const icon = isStart ? '🚩' : style.icon;
|
|
div.innerHTML = `<span class="space-icon">${icon}</span><span class="space-text">${escapeHtml(space.label)}</span>`;
|
|
fo.appendChild(div);
|
|
node.appendChild(fo);
|
|
|
|
if (space.die) {
|
|
const badge = svgEl('text', { class: 'die-badge', y: NODE_H / 2 + 17, 'text-anchor': 'middle' });
|
|
badge.textContent = space.die;
|
|
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, '\\$&');
|
|
}
|