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:
2026-07-21 18:12:01 -07:00
parent d40bc09867
commit 8df4859696
4 changed files with 353 additions and 5 deletions
+71 -3
View File
@@ -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(() => {});