Render the board as SVG, laid out from the graph data
Adds public/boardRender.js: a layout algorithm that derives every space's position purely from shared/board.js (column = longest-path distance from Start, lane = branch offset that fans out at a choice space and re-centers wherever branches rejoin), so it keeps working unmodified as the board data grows — no hand-placed coordinates to maintain. Renders spaces as styled SVG nodes (color/shape by type), connects them with curved path lines, and animates player tokens between positions. Pending choices glow and are clickable directly on the board, in addition to the existing text buttons. Game view widens the card on desktop for the board and scrolls horizontally on narrow viewports. Also repicks the player color palette (server/index.js) to avoid the board's own semantic colors (green/gold/red), after a token nearly disappeared into the same-colored Start space during visual testing. Verified with a headless-browser run (Playwright, off-screen — not a desktop screenshot): two players through lobby -> live join update -> board -> a fork choice, no console errors, reducer tests still green.
This commit is contained in:
@@ -29,7 +29,8 @@ lifegame/
|
||||
│ └── ids.js # id/token/join-code generation
|
||||
└── public/
|
||||
├── index.html # lobby / waiting room / game UI
|
||||
└── client.js # REST wrappers + NetworkTransport (WebSocket)
|
||||
├── client.js # REST wrappers + NetworkTransport (WebSocket)
|
||||
└── boardRender.js # SVG board, laid out from board.js graph data
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 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 = 52;
|
||||
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 = 9) {
|
||||
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 two branches out (-1/+1 lanes); 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 === 0 ? -1 : 1) : 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]
|
||||
));
|
||||
}
|
||||
|
||||
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, '\\$&');
|
||||
}
|
||||
+71
-3
@@ -23,8 +23,9 @@
|
||||
.card {
|
||||
background: var(--paper); border: 1px solid var(--line); border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 520px; width: 100%;
|
||||
margin-top: 24px;
|
||||
margin-top: 24px; transition: max-width .25s ease;
|
||||
}
|
||||
.card.wide { max-width: 1100px; }
|
||||
h1 { font-family: "Baloo 2"; color: var(--marigold); margin: 0 0 2px; font-size: 30px; font-weight: 800; }
|
||||
.sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; }
|
||||
label { display: block; font-weight: 700; font-family: "Baloo 2"; font-size: 13px; margin: 14px 0 4px; }
|
||||
@@ -66,6 +67,58 @@
|
||||
background: #fbe3dc; border: 1.5px solid var(--bad); color: var(--bad);
|
||||
border-radius: 10px; padding: 8px 12px; font-size: 13px; margin-top: 14px;
|
||||
}
|
||||
|
||||
/* --- Board --- */
|
||||
.board-container {
|
||||
overflow-x: auto; border-radius: 14px; margin-bottom: 14px;
|
||||
background: radial-gradient(900px 500px at 30% 0%, #2a5f4c 0%, #1d493c 55%, #163a30 100%);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.board-svg { display: block; width: 100%; height: auto; min-width: 680px; }
|
||||
.tree-blob { fill: #2f6b52; opacity: .55; }
|
||||
.edge-path { fill: none; stroke: #caa15a; stroke-width: 7; stroke-linecap: round; opacity: .9; }
|
||||
.space-shape {
|
||||
fill: #fffaf0; stroke: var(--line); stroke-width: 2.5;
|
||||
filter: drop-shadow(0 3px 3px rgba(0,0,0,.35));
|
||||
}
|
||||
.space-start .space-shape { fill: var(--good); stroke: #2c6b44; }
|
||||
.space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }
|
||||
.space-choice .space-shape { fill: var(--bad); stroke: #8f3120; }
|
||||
.space-event .space-shape { fill: #e9d9f7; stroke: #b79bd6; }
|
||||
.space-label {
|
||||
width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; text-align: center; font-family: "Baloo 2"; font-weight: 700;
|
||||
font-size: 10.5px; line-height: 1.15; color: var(--ink); pointer-events: none; gap: 1px;
|
||||
}
|
||||
.space-start .space-label, .space-choice .space-label { color: #fff; }
|
||||
.space-icon { font-size: 15px; }
|
||||
.space-text { max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cash-badge { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; }
|
||||
.cash-badge.pos { fill: var(--good); }
|
||||
.cash-badge.neg { fill: var(--bad); }
|
||||
.space.highlight .space-shape {
|
||||
stroke: #fff2c4; stroke-width: 4; animation: pulseGlow 1.1s ease-in-out infinite;
|
||||
}
|
||||
.space.clickable { cursor: pointer; }
|
||||
.space.clickable:hover .space-shape { filter: drop-shadow(0 0 10px #fff2c4); }
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { filter: drop-shadow(0 0 2px #fff2c4); }
|
||||
50% { filter: drop-shadow(0 0 12px #ffdc73); }
|
||||
}
|
||||
.token { pointer-events: none; transition: transform .5s cubic-bezier(.34,1.4,.64,1); }
|
||||
.token-dot { stroke: #fff; stroke-width: 2; filter: drop-shadow(0 2px 2px rgba(0,0,0,.4)); }
|
||||
.token-label { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; fill: #fff; }
|
||||
.token.current-turn .token-dot { animation: tokenBounce 1s ease-in-out infinite; }
|
||||
@keyframes tokenBounce {
|
||||
0%, 100% { r: 12; } 50% { r: 14.5; }
|
||||
}
|
||||
#dice-icon { display: inline-block; }
|
||||
#dice-icon.spin { animation: diceSpin .55s ease-out; }
|
||||
@keyframes diceSpin {
|
||||
0% { transform: rotate(0deg) scale(1); }
|
||||
50% { transform: rotate(200deg) scale(1.25); }
|
||||
100% { transform: rotate(360deg) scale(1); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -105,9 +158,10 @@
|
||||
|
||||
<section id="panel-game" hidden>
|
||||
<div class="turn-banner" id="turn-banner"></div>
|
||||
<div class="board-container" id="board-container"></div>
|
||||
<ul class="player-list" id="game-player-list"></ul>
|
||||
<div>
|
||||
<button id="roll-btn" hidden>Roll</button>
|
||||
<button id="roll-btn" hidden><span id="dice-icon">🎲</span> Roll</button>
|
||||
<div class="choice-buttons" id="choice-buttons"></div>
|
||||
</div>
|
||||
<div class="win-banner" id="win-banner" hidden></div>
|
||||
@@ -122,8 +176,10 @@
|
||||
createGame, joinGame, saveSession, loadSession,
|
||||
NetworkTransport, getLegalIntents, board,
|
||||
} from '/client.js';
|
||||
import { BoardView } from '/boardRender.js';
|
||||
|
||||
const els = {
|
||||
app: document.getElementById('app'),
|
||||
subtitle: document.getElementById('subtitle'),
|
||||
panelEntry: document.getElementById('panel-entry'),
|
||||
panelLobby: document.getElementById('panel-lobby'),
|
||||
@@ -139,8 +195,10 @@
|
||||
startBtn: document.getElementById('start-btn'),
|
||||
lobbyHint: document.getElementById('lobby-hint'),
|
||||
turnBanner: document.getElementById('turn-banner'),
|
||||
boardContainer: document.getElementById('board-container'),
|
||||
gamePlayerList: document.getElementById('game-player-list'),
|
||||
rollBtn: document.getElementById('roll-btn'),
|
||||
diceIcon: document.getElementById('dice-icon'),
|
||||
choiceButtons: document.getElementById('choice-buttons'),
|
||||
winBanner: document.getElementById('win-banner'),
|
||||
logFeed: document.getElementById('log-feed'),
|
||||
@@ -150,11 +208,13 @@
|
||||
let transport = null;
|
||||
let self = { code: null, gameId: null, playerId: null, sessionToken: null };
|
||||
let errorTimer = null;
|
||||
const boardView = new BoardView(els.boardContainer, board);
|
||||
|
||||
function showPanel(name) {
|
||||
els.panelEntry.hidden = name !== 'entry';
|
||||
els.panelLobby.hidden = name !== 'lobby';
|
||||
els.panelGame.hidden = name !== 'game';
|
||||
els.app.classList.toggle('wide', name === 'game');
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
@@ -205,6 +265,9 @@
|
||||
|
||||
function renderGame(state) {
|
||||
showPanel('game');
|
||||
boardView.update(state, self, {
|
||||
onChoose: (spaceId) => transport.sendIntent({ type: 'REQUEST_CHOOSE', spaceId }),
|
||||
});
|
||||
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
|
||||
els.gamePlayerList.innerHTML = '';
|
||||
for (const p of players) {
|
||||
@@ -293,7 +356,12 @@
|
||||
};
|
||||
|
||||
els.startBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_START' });
|
||||
els.rollBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_ROLL' });
|
||||
els.rollBtn.onclick = () => {
|
||||
els.diceIcon.classList.remove('spin');
|
||||
void els.diceIcon.offsetWidth; // restart the animation even on rapid re-clicks
|
||||
els.diceIcon.classList.add('spin');
|
||||
transport.sendIntent({ type: 'REQUEST_ROLL' });
|
||||
};
|
||||
els.copyLinkBtn.onclick = () => {
|
||||
els.inviteLink.select();
|
||||
navigator.clipboard?.writeText(els.inviteLink.value).catch(() => {});
|
||||
|
||||
+3
-1
@@ -29,7 +29,9 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
db.initDb(path.join(DATA_DIR, 'lifegame.db'));
|
||||
|
||||
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
|
||||
const PLAYER_COLORS = ['#e8a12a', '#3f8f5f', '#2f6f9f', '#c1452f', '#7a4fae', '#2f8f8f'];
|
||||
// Deliberately avoids the board's own semantic colors (green=start,
|
||||
// marigold=finish, red=choice) so a player's token never blends into a space.
|
||||
const PLAYER_COLORS = ['#2f6f9f', '#7a4fae', '#2f8f8f', '#c15fa0', '#a6752c', '#5a6b8c'];
|
||||
const AUTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const app = express();
|
||||
|
||||
Reference in New Issue
Block a user