Files
lifegame/public/index.html
T
Kevin 8df4859696 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.
2026-07-21 18:12:01 -07:00

393 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Life Journey</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--page: #163a30; --page-2: #0f2a23; --paper: #f6edd8;
--ink: #2a2018; --ink-soft: #6b5d4a; --marigold: #e8a12a;
--good: #3f8f5f; --bad: #c1452f; --line: #d8c7a2;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; padding: 24px 16px;
font-family: "Nunito Sans", system-ui, sans-serif; color: var(--ink);
background: radial-gradient(1200px 600px at 50% -10%, #1d493c 0%, var(--page) 45%, var(--page-2) 100%);
display: flex; align-items: flex-start; justify-content: center;
}
.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; 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; }
input[type="text"] {
width: 100%; padding: 10px 12px; border: 1.5px solid var(--line); border-radius: 10px;
font-family: inherit; font-size: 14px; background: #fffaf0; color: var(--ink);
}
button {
font-family: "Baloo 2"; font-weight: 700; font-size: 14px; color: var(--ink);
background: var(--marigold); border: none; border-radius: 10px; padding: 9px 16px;
cursor: pointer; box-shadow: 0 3px 0 #b97f18; margin-top: 10px; margin-right: 8px;
}
button:active { transform: translateY(3px); box-shadow: 0 0 0 #b97f18; }
button:disabled { opacity: .5; cursor: not-allowed; }
button.secondary { background: #efe5cc; box-shadow: 0 3px 0 var(--line); }
hr { border: none; border-top: 1.5px dashed var(--line); margin: 20px 0; }
.invite-row { display: flex; gap: 8px; margin-top: 8px; }
.invite-row input { flex: 1; }
ul.player-list { list-style: none; padding: 0; margin: 12px 0; }
ul.player-list li {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border: 1.5px solid var(--line); border-radius: 10px; background: #fffaf0; margin-bottom: 6px; font-size: 14px;
}
ul.player-list li.active { border-color: var(--marigold); background: #fff3dc; }
.dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
.hint { color: var(--ink-soft); font-size: 13px; }
.turn-banner { font-family: "Baloo 2"; font-weight: 700; font-size: 18px; margin-bottom: 10px; }
.choice-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.log {
max-height: 160px; overflow-y: auto; font-size: 12px; color: var(--ink-soft);
border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 10px;
}
.log div { padding: 2px 0; }
.win-banner {
font-family: "Baloo 2"; font-weight: 800; font-size: 20px; color: var(--good);
text-align: center; margin: 14px 0;
}
.error-note {
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>
<div class="card" id="app">
<h1>Life Journey</h1>
<p class="sub" id="subtitle">Async multiplayer — create a room or join one via invite link.</p>
<section id="panel-entry">
<label for="name-input">Your name</label>
<input type="text" id="name-input" maxlength="40" placeholder="e.g. Alice" />
<div id="create-section">
<button id="create-btn">Create Game</button>
</div>
<hr />
<label for="join-code-input">Game code</label>
<input type="text" id="join-code-input" maxlength="6" placeholder="e.g. AB3XQ9" style="text-transform:uppercase" />
<button id="join-btn" class="secondary">Join Game</button>
</section>
<section id="panel-lobby" hidden>
<label>Invite link</label>
<div class="invite-row">
<input type="text" id="invite-link" readonly />
<button id="copy-link-btn" class="secondary">Copy</button>
</div>
<hr />
<label>Players</label>
<ul class="player-list" id="player-list"></ul>
<button id="start-btn" hidden>Start Game</button>
<p class="hint" id="lobby-hint"></p>
</section>
<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><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>
<div class="log" id="log-feed"></div>
</section>
<p class="error-note" id="error-note" hidden></p>
</div>
<script type="module">
import {
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'),
panelGame: document.getElementById('panel-game'),
nameInput: document.getElementById('name-input'),
createSection: document.getElementById('create-section'),
createBtn: document.getElementById('create-btn'),
joinCodeInput: document.getElementById('join-code-input'),
joinBtn: document.getElementById('join-btn'),
inviteLink: document.getElementById('invite-link'),
copyLinkBtn: document.getElementById('copy-link-btn'),
playerList: document.getElementById('player-list'),
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'),
errorNote: document.getElementById('error-note'),
};
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) {
els.errorNote.textContent = message;
els.errorNote.hidden = false;
clearTimeout(errorTimer);
errorTimer = setTimeout(() => { els.errorNote.hidden = true; }, 4000);
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
async function connect() {
transport = new NetworkTransport();
transport.onState((msg) => render(msg.state));
transport.onError((msg) => showError(msg.message));
try {
await transport.connect(self.sessionToken);
} catch (err) {
showError(`Could not connect: ${err.message}`);
}
}
function render(state) {
if (state.status === 'lobby') renderLobby(state);
else renderGame(state);
}
function renderLobby(state) {
showPanel('lobby');
els.inviteLink.value = `${location.origin}/join/${self.code}`;
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
els.playerList.innerHTML = '';
for (const p of players) {
const li = document.createElement('li');
li.innerHTML = `<span class="dot" style="background:${p.color}"></span> ${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}`;
els.playerList.appendChild(li);
}
const legal = getLegalIntents(state, self.playerId);
els.startBtn.hidden = !legal.includes('REQUEST_START');
els.lobbyHint.textContent = players.length < state.config.minPlayers
? `Waiting for at least ${state.config.minPlayers} players…`
: 'Ready to start!';
}
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) {
const li = document.createElement('li');
li.className = state.currentTurn === p.id ? 'active' : '';
const spaceLabel = board.spaces[p.position]?.label ?? p.position;
li.innerHTML = `<span class="dot" style="background:${p.color}"></span>
<strong>${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}</strong>
${escapeHtml(spaceLabel)} · $${p.cash}`;
els.gamePlayerList.appendChild(li);
}
const legal = getLegalIntents(state, self.playerId);
const me = state.players[self.playerId];
els.turnBanner.textContent = state.status === 'finished'
? ''
: (state.currentTurn === self.playerId
? 'Your turn'
: `Waiting for ${state.players[state.currentTurn]?.name ?? '…'}`);
els.rollBtn.hidden = !legal.includes('REQUEST_ROLL');
els.choiceButtons.innerHTML = '';
if (legal.includes('REQUEST_CHOOSE') && me?.pendingChoice) {
for (const optionId of me.pendingChoice.options) {
const btn = document.createElement('button');
btn.textContent = board.spaces[optionId]?.label ?? optionId;
btn.onclick = () => transport.sendIntent({ type: 'REQUEST_CHOOSE', spaceId: optionId });
els.choiceButtons.appendChild(btn);
}
} else if (me?.pendingChoice) {
const waitingName = state.players[state.currentTurn]?.name ?? 'them';
els.choiceButtons.innerHTML = `<p class="hint">Waiting for ${escapeHtml(waitingName)} to choose…</p>`;
}
els.logFeed.innerHTML = state.log.slice().reverse().map((entry) => {
const p = state.players[entry.playerId];
const sign = entry.cashDelta > 0 ? '+' : '';
const cashPart = entry.cashDelta ? ` (${sign}${entry.cashDelta})` : '';
return `<div>${escapeHtml(p?.name ?? '?')}${escapeHtml(entry.label)}${cashPart}</div>`;
}).join('');
if (state.status === 'finished') {
els.winBanner.hidden = false;
els.winBanner.textContent = state.winnerId === self.playerId
? '🎉 You win!'
: `🏁 ${state.players[state.winnerId]?.name ?? 'Someone'} wins!`;
els.rollBtn.hidden = true;
els.choiceButtons.innerHTML = '';
} else {
els.winBanner.hidden = true;
}
}
function afterAuth(code, session) {
self = { code, ...session };
saveSession(code, session);
history.replaceState(null, '', `/join/${code}`);
return connect();
}
els.createBtn.onclick = async () => {
try {
const hostName = els.nameInput.value;
const result = await createGame(hostName);
await afterAuth(result.code, {
gameId: result.gameId, playerId: result.playerId, sessionToken: result.sessionToken,
});
} catch (err) {
showError(err.message);
}
};
els.joinBtn.onclick = async () => {
try {
const code = els.joinCodeInput.value.trim().toUpperCase();
const name = els.nameInput.value;
if (!code) throw new Error('Enter a game code');
const result = await joinGame(code, name);
await afterAuth(code, {
gameId: result.gameId, playerId: result.playerId, sessionToken: result.sessionToken,
});
} catch (err) {
showError(err.message);
}
};
els.startBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_START' });
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(() => {});
};
async function boot() {
const match = location.pathname.match(/^\/join\/([^/]+)/);
const codeFromUrl = match ? decodeURIComponent(match[1]) : null;
if (codeFromUrl) {
const existing = loadSession(codeFromUrl);
if (existing) {
self = { code: codeFromUrl, ...existing };
await connect();
return;
}
els.joinCodeInput.value = codeFromUrl;
els.joinCodeInput.readOnly = true;
els.createSection.hidden = true;
els.subtitle.textContent = `Joining game ${codeFromUrl} — enter your name below.`;
}
showPanel('entry');
}
boot();
</script>
</body>
</html>