diff --git a/README.md b/README.md index dfce92a..1b3aef0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/public/boardRender.js b/public/boardRender.js new file mode 100644 index 0000000..f531ce4 --- /dev/null +++ b/public/boardRender.js @@ -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 -> + 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 = `${style.icon}${escapeHtml(space.label)}`; + 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, '\\$&'); +} diff --git a/public/index.html b/public/index.html index ac7da69..34c0be4 100644 --- a/public/index.html +++ b/public/index.html @@ -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); } + } @@ -105,9 +158,10 @@