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:
+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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user