6aa9769ce5
shared/board.js now transcribes every distinct space label from the hand-drawn sketch (assets/game_board.png) rather than Phase 1's small subset. The sketch's arrows get genuinely ambiguous in a few places (it reads as a mockup, not an engineered spec) and reuses several space names across zones (Start A Business, Family Reunion, Market Crash, ...) — that repetition is kept as intentional flavor rather than deduplicated, and the ambiguous bits are resolved into a clean DAG with the same shape Phase 1 proved out: a fork, a chain() per branch, a convergence — repeated three times (Career/Education/Gap Year, then Relationship/Investment, then High Risk/Safe), into a long shared retirement tail. Branches are built with a small chain() helper that auto-wires each entry's `next` to the following one, since hand-wiring ~107 ids was too error-prone. The reducer, SQLite schema, and rooms/WS layer needed zero changes — the whole point of the pure-reducer/graph-data design from Phase 1. Two things did need generalizing: - public/boardRender.js's lane offset was hardcoded to a 2-way fork; the new "Which Path?" fork is 3-way (Career/Education/Gap Year), so the offset formula is now symmetric for any number of branches. - shared/game.test.js hardcoded Phase 1's specific space ids. Rewritten to be board-structure-agnostic: it always resolves the first offered choice and otherwise rolls the max die value, which reliably makes progress regardless of board shape (walkForward always stops early at the next choice/finish), plus a graph-well-formedness check. Verified: unit tests green; a full two-player game played headlessly end-to-end through all three forks to Finish in 38 turns with zero console/page errors; the rendered board visually confirmed at full scale (5-row snake layout, 3-way fork fans out correctly, all labels legible).
393 lines
16 KiB
HTML
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: auto; max-height: 620px; 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) => (
|
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|