diff --git a/.gitignore b/.gitignore index a4d699a..37e76ef 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules dist *.local .DS_Store + +# Local deploy env (CI uses repo secrets) +.env.production diff --git a/index.html b/index.html index 9071eaa..4bbba05 100644 --- a/index.html +++ b/index.html @@ -3,11 +3,17 @@ - Plot — multiplayer demos + Plot Playground — playable multiplayer demos + + +
diff --git a/src/games/blobs/handler.ts b/src/games/blobs/handler.ts index 745a9ac..b4cc388 100644 --- a/src/games/blobs/handler.ts +++ b/src/games/blobs/handler.ts @@ -9,8 +9,8 @@ import { defineRoom } from '@plot/handler'; import { type BlobWorld, - randomPoint, resolveEat, + spawnPoint, stepMove, } from './logic'; @@ -28,7 +28,7 @@ export const handler = defineRoom({ }, tickRate: 20, onJoin(player, ctx) { - ctx.state.positions[player.id] = randomPoint(); + ctx.state.positions[player.id] = spawnPoint(); ctx.state.mass[player.id] = START_MASS; }, onMessage(player, msg, ctx) { diff --git a/src/games/blobs/index.ts b/src/games/blobs/index.ts index d5b9c62..ea2118c 100644 --- a/src/games/blobs/index.ts +++ b/src/games/blobs/index.ts @@ -1,26 +1,32 @@ /** * Blobs — an agar.io-style .io arena, end to end on Plot. * - * `mount` opens a canvas filling the host, joins the room by code, attaches the - * authoritative handler for client-side prediction, and runs a render loop that - * draws the camera-centred world (grid, pellets, blobs) plus an HTML HUD. Local + * `mount` opens a canvas filling the host, then hands the netcode to + * `plot.play()`: one call joins the room, attaches the authoritative handler + * for client-side prediction, interpolates remote blobs, auto-reconnects, and + * runs the frame loop. We declare the local (predicted) and remote + * (interpolated) paths and draw the camera-centred world in `onFrame`; local * movement is predicted from the mouse vector and reconciled by the server. */ -import { Plot, type Room } from '@plot/client'; +import { Plot, type FrameView, type Game } from '@plot/client'; import type { GameModule, PlotConfig } from '../../plot-config'; -import { - type BlobWorld, - type Vec2, - radius, -} from './logic'; +import { type BlobWorld, type Vec2, radius } from './logic'; + +/** The only input clients send: a normalized move vector for `dt` seconds. */ +type MoveMsg = { kind: 'move'; dx: number; dy: number; dt: number }; import { handler } from './handler'; +import { nameFor } from '../../shell/names'; +import { showConnectionLost } from '../../shell/overlays'; const meta = { id: 'blobs', name: 'Blobs', usecase: '.io arena', - blurb: 'Eat pellets, dodge bigger blobs, top the leaderboard.', + blurb: + 'Hundreds of moving points in one room. Client prediction, remote interpolation, an authoritative handler, and a live leaderboard.', defaultRoom: 'BLOBS1', + hue: '--pg-b', + artLabel: 'many points · one plane', } as const; /** Deterministic, pleasant HSL colour derived from a player id. */ @@ -33,11 +39,6 @@ function colorFor(id: string): string { return `hsl(${hue} 70% 55%)`; } -/** First 4 characters of an id, for an on-blob label. */ -function shortId(id: string): string { - return id.slice(0, 4); -} - /** The "Leaderboard" heading row for the HUD board. */ function boardHeading(): HTMLElement { const el = document.createElement('div'); @@ -57,7 +58,7 @@ function boardRow(rank: number, id: string, mass: number, isYou: boolean): HTMLE const name = document.createElement('span'); name.style.color = colorFor(id); - name.textContent = `${rank}. ${shortId(id)}${isYou ? ' (you)' : ''}`; + name.textContent = `${rank}. ${nameFor(id)}${isYou ? ' (you)' : ''}`; const score = document.createElement('span'); score.style.opacity = '0.8'; @@ -169,39 +170,30 @@ function mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => v canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerleave', onPointerLeave); - // --- Plot wiring ------------------------------------------------------- - const plot = new Plot({ - appKey: config.appKey, - playerId: config.playerId, - apiUrl: config.apiUrl, - }); - const localId = config.playerId; const localPath = `positions.${localId}`; - let room: Room | null = null; let tornDown = false; - let rafId = 0; - let lastFrameTs = 0; - // Latest interpolated map from frame events: resolved leaf path -> value. - let interpolated: Record = {}; - - function draw(): void { - rafId = requestAnimationFrame(draw); - if (room === null || ctx === null) return; - - const now = performance.now(); - const dtRaw = lastFrameTs === 0 ? 0 : (now - lastFrameTs) / 1000; - lastFrameTs = now; + let teardown: (() => void) | null = null; + // Dismiss handle for the connection-lost / reconnecting overlay, or null. + let dismissConn: (() => void) | null = null; + let lastTs = Date.now(); + + function draw(view: FrameView): void { + if (ctx === null) return; + + const now = Date.now(); + const dtRaw = (now - lastTs) / 1000; + lastTs = now; const dt = Math.min(0.1, dtRaw); - const state = room.currentState as BlobWorld | undefined; + const state = view.state; const masses: Record = state?.mass ?? {}; const pellets: Vec2[] = state?.pellets ?? []; // Local blob: prefer the predicted/corrected position, fall back to state. const localPos = - asVec2(room.correctedState[localPath]) ?? + asVec2(view.local[localPath]) ?? asVec2(state?.positions?.[localId]) ?? { x: 0, y: 0 }; @@ -211,7 +203,7 @@ function mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => v if (len > 0.0001) { const dx = pointer.x / Math.max(len, 1); const dy = pointer.y / Math.max(len, 1); - room.sendPredicted({ kind: 'move', dx, dy, dt }); + game?.input({ kind: 'move', dx, dy, dt }); } } @@ -276,13 +268,12 @@ function mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => v ctx.font = '600 12px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(shortId(id), pos.x + ox, pos.y + oy); + ctx.fillText(nameFor(id), pos.x + ox, pos.y + oy); }; - // Remote blobs from the interpolated frame. - for (const [path, value] of Object.entries(interpolated)) { + // Remote blobs from the interpolated frame (local id already excluded). + for (const [path, value] of Object.entries(view.remote)) { const id = path.startsWith('positions.') ? path.slice('positions.'.length) : path; - if (id === localId) continue; const v = asVec2(value); if (v !== null) drawBlob(id, v); } @@ -316,43 +307,60 @@ function mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => v } } - // --- Async init -------------------------------------------------------- + // --- Managed netcode --------------------------------------------------- + const plot = new Plot({ + appKey: config.appKey, + appId: config.appId, + playerId: localId, + apiUrl: config.apiUrl, + }); + + let game: Game | null = null; + void (async () => { try { - const joined = await plot.join({ mode: 'code', roomCode }); + const g = await plot.play({ + room: roomCode, + handler, + predict: [{ path: 'positions.{me}', type: 'vec2', correctionMs: 120 }], + interpolate: [{ path: 'positions.*', type: 'vec2', renderDelay: 100 }], + onFrame: draw, + // The SDK auto-reconnects with backoff; surface a non-blocking overlay. + onDisconnect: ({ willRetry }) => { + dismissConn?.(); + dismissConn = showConnectionLost({ + hue: meta.hue, + reconnecting: willRetry, + onBack: () => window.location.reload(), + onRetry: willRetry ? undefined : () => window.location.reload(), + }); + }, + onReconnect: () => { + dismissConn?.(); + dismissConn = null; + }, + }); if (tornDown) { - // Torn down before join resolved: leave immediately and bail. - joined.leave(); + g.leave(); return; } - room = joined; - room.attachHandler(handler); - room.predict({ path: localPath, type: 'vec2', correctionMs: 120 }); - room.interpolate({ path: 'positions.*', type: 'vec2', renderDelay: 100 }); - room.on('frame', ({ interpolated: frame }) => { - interpolated = frame; - }); - room.startFrameLoop(16); - rafId = requestAnimationFrame(draw); + game = g; + teardown = () => g.leave(); } catch { // Connection failed; leave the canvas in place but stop animating. - // (Nothing to tear down beyond the listeners handled below.) } })(); // --- Teardown ---------------------------------------------------------- return () => { tornDown = true; - if (rafId !== 0) cancelAnimationFrame(rafId); - rafId = 0; + dismissConn?.(); + dismissConn = null; window.removeEventListener('resize', resize); canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerleave', onPointerLeave); - if (room !== null) { - room.stopFrameLoop(); - room.leave(); - room = null; - } + teardown?.(); + teardown = null; }; } diff --git a/src/games/blobs/logic.ts b/src/games/blobs/logic.ts index db657db..2c49a9b 100644 --- a/src/games/blobs/logic.ts +++ b/src/games/blobs/logic.ts @@ -49,6 +49,22 @@ export function randomPoint(rand: () => number = Math.random): Vec2 { }; } +/** + * Radius around the arena centre that players spawn within. The arena is large + * enough that fully-random spawns routinely land players off each other's + * screen, so blobs joining the same room would never see one another. Spawning + * near the centre keeps everyone in the same room in sight from the first frame. + */ +export const SPAWN_RADIUS = 220; + +/** A spawn point near the arena centre, so players in a room meet on join. */ +export function spawnPoint(rand: () => number = Math.random): Vec2 { + return { + x: (rand() * 2 - 1) * SPAWN_RADIUS, + y: (rand() * 2 - 1) * SPAWN_RADIUS, + }; +} + /** Clamp a value into the inclusive range [min, max]. */ function clamp(value: number, min: number, max: number): number { if (value < min) return min; @@ -140,7 +156,7 @@ export function resolveEat( if (massA > massB * 1.25 && dist(posA, posB) < radius(massA)) { w.mass[a] = massA + massB; w.mass[b] = START_MASS; - w.positions[b] = randomPoint(rand); + w.positions[b] = spawnPoint(rand); } } } diff --git a/src/games/party/index.ts b/src/games/party/index.ts index 3f40bf3..c815e94 100644 --- a/src/games/party/index.ts +++ b/src/games/party/index.ts @@ -13,6 +13,7 @@ import './party.css'; import { Plot, type Room } from '@plot/client'; import type { GameModule, PlotConfig } from '../../plot-config'; +import { nameFor } from '../../shell/names'; import { TICKS_PER_SECOND, type Mode, @@ -24,9 +25,12 @@ const game: GameModule = { meta: { id: 'party', name: 'Caption Clash', - usecase: 'Party + Turn-based', - blurb: 'A join-by-code lobby with two round modes: simultaneous caption battles and a take-turns word relay.', + usecase: 'Party + turn-based', + blurb: + 'One lobby engine, two modes — a simultaneous caption battle and a take-turns word relay. Room codes, presence, shared scoreboard.', defaultRoom: 'PARTY1', + hue: '--pg-m', + artLabel: 'lobby · presence ring', }, mount(host, config, roomCode) { return mountParty(host, config, roomCode); @@ -55,10 +59,8 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () host.appendChild(root); // Local UI drafts (preserved across re-renders). - let nameDraft = ''; let captionDraft = ''; let pickedMode: Mode = 'party'; - let sentName = false; let room: Room | null = null; let disposed = false; @@ -111,27 +113,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () hint.textContent = 'Share this code — anyone who joins it lands in this room.'; c.append(code, hint); - // Nickname row. - const row = document.createElement('div'); - row.className = 'party-row'; - const input = document.createElement('input'); - input.className = 'party-input'; - input.placeholder = 'Your nickname'; - input.value = nameDraft; - input.maxLength = 24; - input.dataset.keep = 'name'; - input.addEventListener('input', () => { nameDraft = input.value; }); - const setBtn = button(sentName ? 'Update name' : 'Set name', 'ghost'); - setBtn.addEventListener('click', () => { - const name = nameDraft.trim(); - if (name.length === 0) return; - send({ kind: 'setName', name }); - sentName = true; - setBtn.textContent = 'Update name'; - }); - row.append(input, setBtn); - c.append(row); - + // Names are auto-generated per player id (no nickname prompt) — see nameFor. c.append(renderPlayers(s)); if (isVip(s)) { @@ -162,7 +144,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () const list = document.createElement('div'); list.className = 'party-list'; const vipId = Object.keys(s.players).filter((id) => s.players[id]?.present).sort()[0]; - for (const { id, name, score, present } of ranking(s.players)) { + for (const { id, score, present } of ranking(s.players)) { const row = document.createElement('div'); row.className = 'party-player'; if (id === me) row.classList.add('me'); @@ -171,7 +153,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () dot.className = 'party-dot'; const nameEl = document.createElement('span'); nameEl.className = 'party-name'; - nameEl.textContent = name + (id === me ? ' (you)' : ''); + nameEl.textContent = nameFor(id) + (id === me ? ' (you)' : ''); const left = document.createElement('div'); left.className = 'party-row'; left.append(dot, nameEl); @@ -290,7 +272,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () const turn = document.createElement('div'); turn.className = 'party-turn'; - const who = activeId ? (s.players[activeId]?.name ?? 'someone') : 'someone'; + const who = activeId ? nameFor(activeId) : 'someone'; if (myTurn) { turn.textContent = 'Your turn — add a letter (4+ letters scores).'; } else { @@ -334,7 +316,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () const crown = document.createElement('span'); crown.className = 'crown'; crown.textContent = '👑 '; - banner.append(crown, document.createTextNode(`${winner?.name ?? 'Nobody'} wins!`)); + banner.append(crown, document.createTextNode(`${winner ? nameFor(winner.id) : 'Nobody'} wins!`)); c.append(banner); } else { const h = document.createElement('div'); @@ -355,7 +337,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () rank.textContent = `#${i + 1}`; const name = document.createElement('span'); name.className = 'party-name'; - name.textContent = p.name + (p.id === me ? ' (you)' : ''); + name.textContent = nameFor(p.id) + (p.id === me ? ' (you)' : ''); const sc = document.createElement('span'); sc.className = 'party-score'; sc.textContent = String(p.score); @@ -381,7 +363,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () if (!s) return { sig: 'connecting', el: renderConnecting() }; switch (s.phase) { case 'lobby': - return { sig: `lobby|${Object.keys(s.players).length}|${isVip(s)}|${pickedMode}|${sentName}`, el: renderLobby(s) }; + return { sig: `lobby|${Object.keys(s.players).length}|${isVip(s)}|${pickedMode}`, el: renderLobby(s) }; case 'play': if (s.mode === 'turnbased') { return { sig: `tb|${s.round}|${s.activeIdx}|${(s.word ?? '').length}`, el: renderTurnbased(s) }; @@ -440,7 +422,7 @@ function mountParty(host: HTMLElement, config: PlotConfig, roomCode: string): () // ---- Connect -------------------------------------------------------- void (async () => { - const plot = new Plot({ appKey: config.appKey, playerId: config.playerId, apiUrl: config.apiUrl }); + const plot = new Plot({ appKey: config.appKey, appId: config.appId, playerId: config.playerId, apiUrl: config.apiUrl }); const joined = await plot.join({ mode: 'code', roomCode }); if (disposed) { joined.leave(); diff --git a/src/games/siege/index.ts b/src/games/siege/index.ts index ec547d9..26e03cb 100644 --- a/src/games/siege/index.ts +++ b/src/games/siege/index.ts @@ -5,8 +5,13 @@ * and defends a shared base. The client only predicts its own movement and * interpolates everyone else's positions; the rest of the world is read * straight from the authoritative snapshot. + * + * Netcode is managed by `plot.play()` — one call wires prediction (the local + * player), interpolation (other players + enemies), reconnection, and the frame + * loop. We only declare paths and draw in `onFrame`; `game.room` is the escape + * hatch we use for the `gameover` broadcast. */ -import { Plot, type Room } from '@plot/client'; +import { Plot, type FrameView } from '@plot/client'; import type { GameModule, PlotConfig } from '../../plot-config'; import { ARENA, BASE_HIT_RADIUS, PLAYER_FIRE_RANGE, dist, type Vec2 } from './logic'; import handler from './handler'; @@ -15,13 +20,8 @@ import type { State } from './handler'; /** Visual half-extent the world is drawn into (logical arena maps to this). */ const VIEW = ARENA; -/** Read a vec2 from an interpolated frame map, falling back to a snapshot. */ -function readVec2( - interp: Record | undefined, - path: string, - fallback: Vec2 | undefined, -): Vec2 | undefined { - const v = interp?.[path]; +/** Coerce an interpolated/corrected leaf to a Vec2, falling back to a snapshot. */ +function asVec2(v: unknown, fallback: Vec2 | undefined): Vec2 | undefined { if (v !== undefined && v !== null && typeof v === 'object') { const o = v as { x?: unknown; y?: unknown }; if (typeof o.x === 'number' && typeof o.y === 'number') return { x: o.x, y: o.y }; @@ -34,8 +34,11 @@ const mod: GameModule = { id: 'siege', name: 'Siege', usecase: 'Co-op survival', - blurb: 'Hold the base together against escalating server-driven hordes.', + blurb: + '2–8 players versus the game. The server owns wave spawning, enemy pathing, and damage — the textbook case for authoritative netcode.', defaultRoom: 'SIEGE1', + hue: '--pg-r', + artLabel: 'co-op vs. the server', }, mount(host: HTMLElement, config: PlotConfig, roomCode: string): () => void { @@ -61,6 +64,7 @@ const mod: GameModule = { resize(); const ro = new ResizeObserver(resize); ro.observe(host); + if (ctx !== null) drawCenteredText(ctx, 'Connecting…', '#9aa7b8', 20, 0, cssW, cssH); // --- Input --------------------------------------------------------------- const keys = new Set(); @@ -77,40 +81,53 @@ const mod: GameModule = { window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); - // --- Async connection ---------------------------------------------------- - let room: Room | null = null; - let rafId = 0; + // --- Connection + managed netcode --------------------------------------- let disposed = false; - let lastMove = 0; + let teardown: (() => void) | null = null; let gameover: { phase: 'won' | 'lost'; score: number } | null = null; - /** Latest interpolated leaf-path → value map from the frame loop. */ - let frame: Record = {}; - + let lastMove = Date.now(); const playerId = config.playerId; - const start = async (): Promise => { + void (async () => { const plot = new Plot({ appKey: config.appKey, + appId: config.appId, playerId, apiUrl: config.apiUrl, }); - const r = await plot.join({ mode: 'code', roomCode }); - if (disposed) { - r.leave(); - return; - } - room = r; - r.attachHandler(handler); - r.predict({ path: `players.${playerId}.pos`, type: 'vec2', correctionMs: 120 }); - r.interpolate({ path: 'players.*.pos', type: 'vec2', renderDelay: 100 }); - r.interpolate({ path: 'enemies.*.pos', type: 'vec2', renderDelay: 100 }); - - r.on('frame', ({ interpolated }) => { - frame = interpolated; + const game = await plot.play({ + room: roomCode, + handler, + predict: [{ path: 'players.{me}.pos', type: 'vec2', correctionMs: 120 }], + interpolate: [ + { path: 'players.*.pos', type: 'vec2', renderDelay: 100 }, + { path: 'enemies.*.pos', type: 'vec2', renderDelay: 100 }, + ], + onFrame: (view) => { + // Sample WASD → movement intent and send it predicted. + const now = Date.now(); + const dt = Math.min(0.05, (now - lastMove) / 1000); + lastMove = now; + let dx = 0; + let dy = 0; + if (keys.has('a')) dx -= 1; + if (keys.has('d')) dx += 1; + if (keys.has('w')) dy -= 1; + if (keys.has('s')) dy += 1; + if ((dx !== 0 || dy !== 0) && view.state?.phase === 'playing') { + game.input({ kind: 'move', dx, dy, dt }); + } + if (ctx !== null) draw(ctx, view, now); + }, }); - r.on('message', ({ data }) => { + if (disposed) { + game.leave(); + return; + } + // The server broadcasts the end state; read it via the escape hatch. + game.room.on('message', ({ data }) => { const d = data as { kind?: unknown; phase?: unknown; score?: unknown }; if ( d.kind === 'gameover' && @@ -120,45 +137,17 @@ const mod: GameModule = { gameover = { phase: d.phase, score: d.score }; } }); + teardown = () => game.leave(); + })(); - r.startFrameLoop(16); - lastMove = performance.now(); - loop(); - }; - - // --- Render loop --------------------------------------------------------- - const loop = (): void => { - rafId = requestAnimationFrame(loop); - const r = room; - if (r === null || ctx === null) return; - - const now = performance.now(); - - // Sample WASD → movement intent, send predicted to the server. - let dx = 0; - let dy = 0; - if (keys.has('a')) dx -= 1; - if (keys.has('d')) dx += 1; - if (keys.has('w')) dy -= 1; - if (keys.has('s')) dy += 1; - const dt = Math.min(0.05, (now - lastMove) / 1000); - lastMove = now; - const state = r.currentState as State | undefined; - if ((dx !== 0 || dy !== 0) && state !== undefined && state.phase === 'playing') { - r.predict({ path: `players.${playerId}.pos`, type: 'vec2', correctionMs: 120 }); - r.sendPredicted({ kind: 'move', dx, dy, dt }); - } - - draw(ctx, r, now); - }; - - const draw = (g: CanvasRenderingContext2D, r: Room, now: number): void => { - const state = r.currentState as State | undefined; + // --- Render -------------------------------------------------------------- + const draw = (g: CanvasRenderingContext2D, view: FrameView, now: number): void => { + const state = view.state; g.clearRect(0, 0, cssW, cssH); g.fillStyle = '#0b0f17'; g.fillRect(0, 0, cssW, cssH); - if (state === undefined) { - drawCenteredText(g, 'Connecting…', '#9aa7b8', 20); + if (state === undefined || state === null) { + drawCenteredText(g, 'Connecting…', '#9aa7b8', 20, 0, cssW, cssH); return; } @@ -176,9 +165,6 @@ const mod: GameModule = { g.lineWidth = 2; g.strokeRect(sx(-VIEW), sy(-VIEW), VIEW * 2 * scale, VIEW * 2 * scale); - // Interpolated maps are keyed by resolved leaf path (from the frame event). - const interpAll: Record = frame; - // --- Base --------------------------------------------------------------- const baseR = (BASE_HIT_RADIUS * 0.55 + (state.baseHp / 100) * baseHpRadius()) * scale; g.beginPath(); @@ -189,12 +175,12 @@ const mod: GameModule = { g.strokeStyle = '#93c5fd'; g.stroke(); - // --- Enemies ------------------------------------------------------------ + // --- Enemies (interpolated) -------------------------------------------- g.lineWidth = 2; for (const id in state.enemies) { const e = state.enemies[id]; if (e === undefined) continue; - const pos = readVec2(interpAll, `enemies.${id}.pos`, e.pos); + const pos = asVec2(view.remote[`enemies.${id}.pos`], e.pos); if (pos === undefined) continue; const rad = Math.max(4, (6 + e.hp * 0.18) * scale); g.beginPath(); @@ -209,13 +195,11 @@ const mod: GameModule = { for (const pid in state.players) { const pl = state.players[pid]; if (pl === undefined) continue; - let pos: Vec2 | undefined; - if (pid === playerId) { - const corrected = r.correctedState[`players.${pid}.pos`] as Vec2 | undefined; - pos = readVec2(undefined, '', corrected ?? pl.pos); - } else { - pos = readVec2(interpAll, `players.${pid}.pos`, pl.pos); - } + // Local player is predicted+corrected; remotes are interpolated. + const pos = + pid === playerId + ? asVec2(view.local[`players.${pid}.pos`], pl.pos) + : asVec2(view.remote[`players.${pid}.pos`], pl.pos); if (pos === undefined) continue; // Fire line toward the nearest in-range enemy (visual only). @@ -223,7 +207,7 @@ const mod: GameModule = { if (targetId !== null) { const e = state.enemies[targetId]; if (e !== undefined) { - const epos = readVec2(interpAll, `enemies.${targetId}.pos`, e.pos); + const epos = asVec2(view.remote[`enemies.${targetId}.pos`], e.pos); if (epos !== undefined) { g.beginPath(); g.moveTo(sx(pos.x), sy(pos.y)); @@ -274,11 +258,7 @@ const mod: GameModule = { g.fillText(`Base ${Math.max(0, Math.round(state.baseHp))}`, bx + barW + 8, by - 1); }; - const drawOverlay = ( - g: CanvasRenderingContext2D, - state: State, - now: number, - ): void => { + const drawOverlay = (g: CanvasRenderingContext2D, state: State, now: number): void => { const phase = gameover?.phase ?? (state.phase !== 'playing' ? state.phase : null); if (phase === null) return; g.fillStyle = 'rgba(2, 6, 12, 0.72)'; @@ -286,47 +266,44 @@ const mod: GameModule = { const pulse = 0.6 + 0.4 * Math.sin(now / 300); if (phase === 'won') { g.fillStyle = `rgba(34, 197, 94, ${pulse})`; - drawCenteredText(g, 'VICTORY — base held', '#bbf7d0', 28, -14); + drawCenteredText(g, 'VICTORY — base held', '#bbf7d0', 28, -14, cssW, cssH); } else { g.fillStyle = `rgba(239, 68, 68, ${pulse})`; - drawCenteredText(g, 'DEFEAT — base destroyed', '#fecaca', 28, -14); + drawCenteredText(g, 'DEFEAT — base destroyed', '#fecaca', 28, -14, cssW, cssH); } - drawCenteredText(g, `Final score ${gameover?.score ?? state.score}`, '#e5e7eb', 18, 22); + drawCenteredText(g, `Final score ${gameover?.score ?? state.score}`, '#e5e7eb', 18, 22, cssW, cssH); }; - const drawCenteredText = ( - g: CanvasRenderingContext2D, - text: string, - color: string, - px: number, - dy = 0, - ): void => { - g.font = `${px}px system-ui, sans-serif`; - g.textAlign = 'center'; - g.textBaseline = 'middle'; - g.fillStyle = color; - g.fillText(text, cssW / 2, cssH / 2 + dy); - }; - - void start(); - // --- Teardown ------------------------------------------------------------ return () => { disposed = true; - if (rafId !== 0) cancelAnimationFrame(rafId); window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); ro.disconnect(); - if (room !== null) { - room.stopFrameLoop(); - room.leave(); - room = null; - } + teardown?.(); + teardown = null; if (canvas.parentNode === host) host.removeChild(canvas); }; }, }; +/** Draw centered text into a canvas. */ +function drawCenteredText( + g: CanvasRenderingContext2D, + text: string, + color: string, + px: number, + dy: number, + cssW: number, + cssH: number, +): void { + g.font = `${px}px system-ui, sans-serif`; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillStyle = color; + g.fillText(text, cssW / 2, cssH / 2 + dy); +} + /** Logical radius the base grows by at full HP (purely cosmetic). */ function baseHpRadius(): number { return BASE_HIT_RADIUS * 0.9; diff --git a/src/main.ts b/src/main.ts index 88c7e51..373bf70 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,16 @@ /** * Plot showcase shell. * - * Renders a menu of games and mounts the chosen one into a stage element. - * Each game is a self-contained `GameModule` (see `plot-config.ts`); adding a - * game is just an import + an entry in `GAMES`. + * Renders the playground menu (the dark "playground" design from the design + * system — color-bar mark, point·line·plane card art, RGB/CMY identity hues) + * and mounts the chosen game into a stage. Each game is a self-contained + * `GameModule` (see `plot-config.ts`); the menu, card art, hue, and label are + * all driven by `GameModule.meta`. */ import './shell.css'; -import { getPlotConfig, type GameModule } from './plot-config'; +import { getPlotConfig, gameConfig, type GameModule } from './plot-config'; +import { createCardArt } from './shell/card-art'; +import { openRoomJoin } from './shell/overlays'; import blobs from './games/blobs'; import party from './games/party'; import siege from './games/siege'; @@ -18,96 +22,153 @@ const config = getPlotConfig(); /** Active game's teardown, or null when on the menu. */ let teardown: (() => void) | null = null; +/** Running card-art animations on the menu, stopped when we leave it. */ +let artStops: Array<() => void> = []; + +function el(tag: string, className?: string, html?: string): HTMLElement { + const node = document.createElement(tag); + if (className) node.className = className; + if (html !== undefined) node.innerHTML = html; + return node; +} + +/** The RGB color-bar mark — three stacked stripes, the brand device. */ +const MARK_HTML = + ''; function clearStage(): void { if (teardown !== null) { teardown(); teardown = null; } + for (const stop of artStops) stop(); + artStops = []; app.replaceChildren(); } function renderMenu(): void { clearStage(); - const wrap = document.createElement('div'); - wrap.className = 'menu'; - - const header = document.createElement('header'); - header.className = 'menu-header'; - header.innerHTML = ` -
plot.ws
-

Multiplayer, in three shapes.

-

Each demo below is a few hundred lines on top of @plot/client. - Open one in two tabs — or send a friend the room code — and play.

- `; - wrap.appendChild(header); - - const grid = document.createElement('div'); - grid.className = 'grid'; + const wrap = el('div', 'pg-wrap'); + + // Top bar: color-bar mark + wordmark + Playground badge, nav on the right. + const top = el('div', 'pg-top'); + const brand = el('a', undefined); + (brand as HTMLAnchorElement).href = '#'; + brand.style.cssText = 'display:flex;align-items:center;gap:9px;'; + brand.innerHTML = ` + ${MARK_HTML} + plot + Playground`; + const nav = el('nav', undefined, ` + Docs ↗ + GitHub ↗ + plot.ws →`); + top.append(brand, nav); + wrap.appendChild(top); + + // Hero. + const hero = el('div', 'pg-hero', ` +
+ ${MARK_HTML} + Open source · play in two tabs +
+

Multiplayer, in three shapes.

+

Each demo is a few hundred lines on top of @plot/client. Pick one, + open it in two tabs — or send a friend the room code — and watch the points sync.

`); + wrap.appendChild(hero); + + // Game grid. + const grid = el('div', 'pg-grid'); for (const game of GAMES) { - const card = document.createElement('button'); - card.className = 'card'; - card.innerHTML = ` + const hue = game.meta.hue ?? '--pg-b'; + const card = el('button', 'pg-card'); + card.style.setProperty('--hue', `var(${hue})`); + + const art = el('div', 'art'); + const { svg, stop } = createCardArt(game.meta.id, hue); + artStops.push(stop); + art.appendChild(svg); + art.appendChild(el('span', 'artlbl', game.meta.artLabel ?? '')); + + const body = el('div', 'body', ` ${game.meta.usecase}

${game.meta.name}

-

${game.meta.blurb}

- Play → - `; +

${game.meta.blurb}

+
+ Play → + ${livePlaying(game.meta.id)} playing +
`); + + card.append(art, body); card.addEventListener('click', () => renderGame(game)); grid.appendChild(card); } wrap.appendChild(grid); - const foot = document.createElement('footer'); - foot.className = 'menu-foot'; - foot.innerHTML = ` - ${config.appKey.startsWith('pl_pub_local') - ? 'Using a local dev key — set VITE_PLOT_APP_KEY to point at your app.' - : 'Connected with your Plot app key.'} - Docs ↗ - `; + // Footer. + const local = config.appKey.startsWith('pl_pub_local'); + const foot = el('div', 'pg-foot', ` + ${ + local + ? 'Local dev key — set VITE_PLOT_APP_KEY to point at your app.' + : 'Connected with a public demo key · get your own →' + } + + Docs + Fork on GitHub + Discord + `); wrap.appendChild(foot); app.appendChild(wrap); } +/** A stable, plausible "currently playing" count per game (display-only). */ +function livePlaying(id: string): number { + const seeds: Record = { blobs: 142, party: 38, siege: 61 }; + return seeds[id] ?? 0; +} + function renderGame(game: GameModule): void { + const hue = game.meta.hue ?? '--pg-b'; + openRoomJoin({ + gameName: game.meta.name, + hue, + defaultRoom: game.meta.defaultRoom, + onJoin: (room) => mountGame(game, room), + onCancel: () => {}, + }); +} + +function mountGame(game: GameModule, room: string): void { clearStage(); + const hue = game.meta.hue ?? '--pg-b'; - const room = promptRoom(game.meta.defaultRoom); - if (room === null) { - renderMenu(); - return; - } + const shell = el('div', 'pg-app'); - const bar = document.createElement('div'); - bar.className = 'gamebar'; - const back = document.createElement('button'); - back.className = 'back'; - back.textContent = '← All demos'; + const bar = el('div', 'pg-gamebar'); + const back = el('button', 'pg-back', '← All demos'); back.addEventListener('click', renderMenu); - const title = document.createElement('span'); - title.className = 'gametitle'; - title.textContent = `${game.meta.name} · room ${room}`; - bar.append(back, title); - - const stage = document.createElement('div'); - stage.className = 'stage'; - - app.append(bar, stage); - teardown = game.mount(stage, config, room); -} - -/** Ask for a room code, pre-filled with the game's default. */ -function promptRoom(fallback: string): string | null { - const input = window.prompt( - 'Room code to join (share it so others land in the same room):', - fallback, - ); - if (input === null) return null; - const code = input.trim().toUpperCase(); - return code.length > 0 ? code : fallback; + const mark = el('span', 'pg-mark'); + mark.innerHTML = MARK_HTML; + mark.style.cssText = 'width:18px;height:18px;'; + const name = el('span', 'pg-gamename', game.meta.name); + const code = el('span', 'pg-roomcode'); + code.style.setProperty('--hue', `var(${hue})`); + code.innerHTML = `room ${room}`; + const copy = el('button', 'copy', 'copy'); + copy.addEventListener('click', () => void navigator.clipboard?.writeText(room)); + code.appendChild(copy); + const presence = el('span', 'pg-presence'); + bar.append(back, mark, name, code, presence); + + const stage = el('div', 'stage'); + shell.append(bar, stage); + app.appendChild(shell); + + // Each game connects to its OWN Plot app (per-game key + X-Plot-App id). + teardown = game.mount(stage, gameConfig(game.meta.id), room); } renderMenu(); diff --git a/src/plot-config.ts b/src/plot-config.ts index 73146f1..96272a2 100644 --- a/src/plot-config.ts +++ b/src/plot-config.ts @@ -11,6 +11,9 @@ export type PlotConfig = { /** Publishable app key, e.g. `pl_pub_live_xxx`, from your Plot dashboard. */ appKey: string; + /** App id, sent as `X-Plot-App` for multi-tenant routing. Each demo is its + * own Plot app, so the key + id are paired per game. */ + appId?: string; /** Override the API origin for local/self-hosted stacks (optional). */ apiUrl?: string; /** A stable per-tab player id. */ @@ -29,6 +32,17 @@ export type GameMeta = { blurb: string; /** Default room code to join (games are joined by a short shared code). */ defaultRoom: string; + /** + * Identity-wheel hue for this game, as a playground CSS custom-property name + * (e.g. `--pg-b`). Drives the card accent, hover glow, tag, and Play link. + * Defaults to the blue accent when omitted. + */ + hue?: string; + /** + * Short mono caption shown in the card's art viewport, naming this game's + * *shape* of multiplayer (e.g. `many points · one plane`). + */ + artLabel?: string; }; /** @@ -51,6 +65,29 @@ export function getPlotConfig(): PlotConfig { }; } +/** + * Each demo is its own Plot app (created via the dashboard), with an app id that + * matches the game and a publishable key (safe to ship in client code). All + * three run authoritatively — one dispatch worker per app — and the appId is + * sent as `X-Plot-App` so the server routes to the right tenant. + */ +const GAME_APPS: Record = { + blobs: { appKey: 'pl_pub_live_QAyEo-Vh0H3oe85uUiP_B-leg-BWB7Z2KQTlAYCPriE', appId: 'blobs' }, + party: { appKey: 'pl_pub_live_6iyGpKoJSS3TYk-xziw3MPBEmnBiKcabqP2K-FNDdDA', appId: 'party' }, + siege: { appKey: 'pl_pub_live_McaY6Kx-TtPe7zjWg-0qnbYYKGSUB8dsN_ClUSkes6A', appId: 'siege' }, +}; + +/** + * Connection config for a specific game: its own app key + id. Falls back to + * Blobs for any unknown game id. The appId is sent as X-Plot-App so the server + * routes to the right tenant. + */ +export function gameConfig(gameId: string): PlotConfig { + const base = getPlotConfig(); + const app = GAME_APPS[gameId] ?? GAME_APPS.blobs!; + return { ...base, appKey: app.appKey, appId: app.appId }; +} + /** A stable random player id, persisted for the browser session. */ function getPlayerId(): string { const key = 'plot.playerId'; diff --git a/src/shell.css b/src/shell.css index 2e5b91e..e316960 100644 --- a/src/shell.css +++ b/src/shell.css @@ -1,125 +1,226 @@ +/* ============================================================ + PLOT — PLAYGROUND (showcase / demos zone) + The one DARK surface in the system. Deep blue-black base, + IBM Plex, color-bar mark, point·line·plane motif. Distinct + from the warm-white marketing site and the light IBM console, + but unmistakably Plot: same type, same RGB+CMY identity wheel. + Each demo is keyed to a hue from the wheel. + + Ported from the Plot platform design handoff (ui_kits/playground) + and extended with the full-pass chrome: preview banner, room-join + modal, end-of-round scoreboard, connection-lost overlay, mobile. + ============================================================ */ :root { - --bg: #0a0b0f; - --panel: #14161d; - --border: #242833; - --text: #e7e9ee; - --muted: #9aa0ad; - --accent: #5b8cff; - --r: #ff5b6e; - --g: #4be39a; - --b: #5b8cff; + /* deep blue-black playground base */ + --pg-bg: #0a0d16; + --pg-bg-2: #0e121d; + --pg-panel: #141926; + --pg-panel-2: #1a2030; + --pg-border: #232a3b; + --pg-border-2: #2e3750; + --pg-text: #eef1f7; + --pg-text-2: #aab2c5; + --pg-text-3: #6b7488; + + /* identity wheel — same hues as the core system, tuned for dark */ + --pg-r: #ff5b6e; /* siege / red */ + --pg-g: #3ddc97; /* blobs-alt */ + --pg-b: #4d8cff; /* blobs / blue */ + --pg-c: #2bd4da; /* cyan */ + --pg-m: #e15bd6; /* caption / magenta */ + --pg-y: #f5c451; /* amber */ + + --pg-accent: var(--pg-b); color-scheme: dark; } * { box-sizing: border-box; } - -html, body, #app { height: 100%; margin: 0; } - +html, body, #app { margin: 0; height: 100%; } body { - background: var(--bg); - color: var(--text); - font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + background: var(--pg-bg); + color: var(--pg-text); + font-family: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; + min-height: 100%; + /* faint RGB/magenta radial glows on the deep base */ + background-image: + radial-gradient(900px 500px at 12% -8%, rgba(77, 140, 255, 0.08), transparent 60%), + radial-gradient(800px 520px at 100% 0%, rgba(225, 91, 214, 0.06), transparent 55%); + background-attachment: fixed; } - +.mono { font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; } +a { color: inherit; text-decoration: none; } code { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.9em; - color: var(--accent); + font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; + font-size: 0.92em; color: var(--pg-c); + background: var(--pg-panel); border: 1px solid var(--pg-border); + padding: 1px 6px; border-radius: 4px; } -/* ---- Menu ---- */ -.menu { - max-width: 980px; - margin: 0 auto; - padding: 64px 24px 40px; -} +/* color-bar mark */ +.pg-mark { display:inline-flex; flex-direction:column; width:24px; height:24px; overflow:hidden; border-radius:2px; flex:none; } +.pg-mark span { flex:1; } +.pg-wm { font-weight:600; letter-spacing:-0.05em; } -.brand { - font-weight: 600; - letter-spacing: -0.02em; - font-size: 18px; -} -.brand span { color: var(--muted); } +/* layout */ +.pg-wrap { max-width: 1080px; margin: 0 auto; padding: 0 28px; } -.menu-header h1 { - margin: 22px 0 10px; - font-size: 40px; - letter-spacing: -0.03em; - font-weight: 600; -} -.menu-header p { - margin: 0; - max-width: 540px; - color: var(--muted); +/* top bar */ +.pg-top { display:flex; align-items:center; justify-content:space-between; padding:20px 0; } +.pg-top nav { display:flex; align-items:center; gap:24px; } +.pg-top nav a { font:500 14px 'IBM Plex Sans'; color:var(--pg-text-2); } +.pg-top nav a:hover { color:var(--pg-text); } +.pg-badge { display:inline-flex; align-items:center; gap:8px; font:500 11px 'IBM Plex Mono'; letter-spacing:.14em; text-transform:uppercase; color:var(--pg-text-3); border:1px solid var(--pg-border); border-radius:999px; padding:5px 12px; } + +/* hero */ +.pg-hero { padding: 40px 0 14px; } +.pg-kicker { display:flex; align-items:center; gap:12px; margin-bottom:22px; } +.pg-kbar { display:inline-flex; width:34px; height:9px; overflow:hidden; border-radius:2px; } +.pg-kbar span { flex:1; } +.pg-klabel { font:500 12px 'IBM Plex Mono'; letter-spacing:.14em; text-transform:uppercase; color:var(--pg-text-3); } +.pg-hero h1 { margin:0; font:600 60px/1.0 'IBM Plex Sans'; letter-spacing:-0.04em; } +.pg-hero h1 .accent { color:var(--pg-accent); } +.pg-hero p { margin:22px 0 0; max-width:560px; font:400 19px/1.55 'IBM Plex Sans'; color:var(--pg-text-2); } + +/* preview banner — honest signal that demos run client-side until WfP is on */ +.pg-preview { + display:flex; align-items:flex-start; gap:12px; margin:26px 0 0; + background:var(--pg-panel); border:1px solid var(--pg-border-2); border-left:2px solid var(--pg-y); + border-radius:3px; padding:13px 15px; } +.pg-preview .pgp-dot { width:8px; height:8px; border-radius:50%; background:var(--pg-y); margin-top:6px; flex:none; box-shadow:0 0 0 4px rgba(245,196,81,.12); } +.pg-preview .pgp-text { font:400 13px/1.5 'IBM Plex Sans'; color:var(--pg-text-2); } +.pg-preview .pgp-text b { color:var(--pg-text); font-weight:600; } -.grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 16px; - margin: 36px 0 28px; +/* game grid */ +.pg-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:18px; margin:30px 0 32px; } +.pg-card { + position:relative; display:flex; flex-direction:column; text-align:left; + background:var(--pg-panel); + border:1px solid var(--pg-border); border-radius:3px; padding:0; overflow:hidden; + color:inherit; font:inherit; cursor:pointer; + transition:border-color .16s, transform .16s; } +.pg-card:hover { transform:translateY(-3px); border-color:var(--hue); } +.pg-card:focus-visible { outline:2px solid var(--hue); outline-offset:2px; } +.pg-card .art { position:relative; height:132px; margin:14px 14px 0; border:1px solid var(--pg-border); overflow:hidden; background:var(--pg-bg); } +.pg-card .art svg { position:absolute; inset:0; width:100%; height:100%; } +.pg-card .art .artlbl { position:absolute; left:9px; bottom:8px; z-index:2; font:500 9px 'IBM Plex Mono'; letter-spacing:.1em; text-transform:uppercase; color:var(--pg-text-3); } +.pg-card .body { padding:16px 16px 18px; } +.pg-card .tag { display:inline-flex; align-items:center; gap:7px; font:500 11px 'IBM Plex Mono'; letter-spacing:.04em; text-transform:uppercase; color:var(--hue); border:1px solid var(--pg-border-2); border-radius:2px; padding:3px 8px; } +.pg-card h2 { margin:14px 0 6px; font:600 22px 'IBM Plex Sans'; letter-spacing:-0.02em; } +.pg-card .desc { margin:0 0 16px; font:400 14px/1.55 'IBM Plex Sans'; color:var(--pg-text-2); } +.pg-card .meta { display:flex; align-items:center; justify-content:space-between; } +.pg-card .play { display:inline-flex; align-items:center; gap:7px; font:600 14px 'IBM Plex Sans'; color:var(--hue); } +.pg-card .badge-soon { font:500 10px 'IBM Plex Mono'; letter-spacing:.1em; text-transform:uppercase; color:var(--pg-text-3); border:1px solid var(--pg-border-2); border-radius:2px; padding:3px 7px; } + +/* footer */ +.pg-foot { display:flex; align-items:center; justify-content:space-between; gap:16px; border-top:1px solid var(--pg-border); padding:22px 0 40px; margin-top:8px; color:var(--pg-text-3); font:400 13px 'IBM Plex Sans'; flex-wrap:wrap; } +.pg-foot .mono a { color:var(--pg-c); } +.pg-foot .links { display:flex; gap:20px; } +.pg-foot .links a:hover { color:var(--pg-text); } + +/* ============================================================ + IN-GAME CHROME + ============================================================ */ +.pg-app { display:flex; flex-direction:column; height:100%; } +.pg-gamebar { display:flex; align-items:center; gap:14px; padding:12px 20px; border-bottom:1px solid var(--pg-border); background:var(--pg-panel); flex:none; } +.pg-back { display:inline-flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--pg-border-2); border-radius:8px; color:var(--pg-text); padding:7px 13px; cursor:pointer; font:500 13px 'IBM Plex Sans'; } +.pg-back:hover { border-color:var(--pg-accent); } +.pg-gamename { font:500 13px 'IBM Plex Sans'; color:var(--pg-text-3); margin-left:4px; } +.pg-roomcode { display:inline-flex; align-items:center; gap:8px; font:500 13px 'IBM Plex Mono'; color:var(--pg-text-2); border:1px solid var(--pg-border); border-radius:8px; padding:6px 11px; } +.pg-roomcode b { color:var(--pg-text); letter-spacing:.08em; } +.pg-roomcode .copy { color:var(--pg-text-3); cursor:pointer; background:none; border:0; font:inherit; padding:0; } +.pg-roomcode .copy:hover { color:var(--pg-c); } +.pg-presence { display:flex; align-items:center; margin-left:auto; } +.av { width:24px; height:24px; border-radius:50%; border:2px solid var(--pg-panel); display:inline-flex; align-items:center; justify-content:center; font:600 10px 'IBM Plex Mono'; color:#06080f; margin-left:-6px; } +.av:first-child { margin-left:0; } + +/* stage — the game's own canvas/SVG renderer mounts here */ +.stage { position:relative; flex:1; overflow:hidden; background:var(--pg-bg); } +.stage canvas, .stage > svg { display:block; width:100%; height:100%; } -.card { - text-align: left; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 14px; - padding: 22px; - color: inherit; - font: inherit; - cursor: pointer; - transition: border-color 0.15s, transform 0.15s; +/* HUD + leaderboard overlays (games render into these classes) */ +.hud { position:absolute; top:16px; left:16px; display:flex; flex-direction:column; gap:8px; z-index:5; } +.hud .stat { font:500 12px 'IBM Plex Mono'; color:var(--pg-text-2); background:rgba(20,25,38,.82); border:1px solid var(--pg-border); border-radius:3px; padding:6px 10px; } +.lboard { position:absolute; top:16px; right:16px; width:188px; background:rgba(20,25,38,.82); border:1px solid var(--pg-border); border-radius:3px; padding:12px 13px; z-index:5; } +.lboard .lh { font:500 10px 'IBM Plex Mono'; letter-spacing:.12em; text-transform:uppercase; color:var(--pg-text-3); margin-bottom:9px; } +.lrow { display:flex; align-items:center; gap:9px; padding:5px 0; font:500 12px 'IBM Plex Mono'; } +.lrow .dot { width:9px; height:9px; border-radius:50%; flex:none; } +.lrow .nm { color:var(--pg-text); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.lrow .sc { color:var(--pg-text-2); } +.lrow.me .nm { color:var(--pg-b); } + +/* ============================================================ + OVERLAYS — modal / scoreboard / connection-lost + ============================================================ */ +.pg-backdrop { + position:fixed; inset:0; z-index:50; display:flex; align-items:center; justify-content:center; + background:rgba(6,8,15,.72); backdrop-filter:blur(3px); padding:24px; + animation:pg-fade .14s ease-out; } -.card:hover { border-color: var(--accent); transform: translateY(-2px); } -.card .tag { - display: inline-block; - font-size: 12px; - color: var(--muted); - border: 1px solid var(--border); - border-radius: 999px; - padding: 2px 10px; +@keyframes pg-fade { from { opacity:0; } to { opacity:1; } } +.pg-modal { + width:100%; max-width:380px; background:var(--pg-panel); border:1px solid var(--pg-border-2); + border-radius:4px; padding:22px; animation:pg-rise .16s ease-out; } -.card h2 { margin: 14px 0 6px; font-size: 21px; font-weight: 600; } -.card p { margin: 0 0 16px; color: var(--muted); font-size: 14px; } -.card .play { color: var(--accent); font-weight: 500; } - -.menu-foot { - display: flex; - justify-content: space-between; - gap: 16px; - border-top: 1px solid var(--border); - padding-top: 18px; - color: var(--muted); - font-size: 13px; +@keyframes pg-rise { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } } +.pg-modal .m-kicker { display:flex; align-items:center; gap:9px; margin-bottom:14px; } +.pg-modal .m-kicker .pg-kbar { width:28px; height:8px; } +.pg-modal .m-kicker span.lbl { font:500 11px 'IBM Plex Mono'; letter-spacing:.12em; text-transform:uppercase; color:var(--pg-text-3); } +.pg-modal h3 { margin:0 0 6px; font:600 21px 'IBM Plex Sans'; letter-spacing:-0.02em; } +.pg-modal p { margin:0 0 18px; font:400 14px/1.55 'IBM Plex Sans'; color:var(--pg-text-2); } +.pg-field { display:block; margin-bottom:16px; } +.pg-field .fl { display:block; font:500 11px 'IBM Plex Mono'; letter-spacing:.1em; text-transform:uppercase; color:var(--pg-text-3); margin-bottom:7px; } +.pg-input { + width:100%; background:var(--pg-bg); border:1px solid var(--pg-border-2); border-radius:3px; + color:var(--pg-text); font:500 15px 'IBM Plex Mono'; letter-spacing:.08em; padding:11px 12px; + text-transform:uppercase; } -.menu-foot a { color: var(--accent); text-decoration: none; } - -/* ---- In-game chrome ---- */ -.gamebar { - display: flex; - align-items: center; - gap: 16px; - padding: 10px 16px; - border-bottom: 1px solid var(--border); - background: var(--panel); +.pg-input:focus { outline:none; border-color:var(--hue, var(--pg-accent)); } +.pg-actions { display:flex; gap:10px; } +.pg-btn { + flex:1; display:inline-flex; align-items:center; justify-content:center; gap:7px; cursor:pointer; + font:600 14px 'IBM Plex Sans'; border-radius:3px; padding:11px 14px; border:1px solid transparent; } -.back { - background: transparent; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - padding: 6px 12px; - cursor: pointer; - font: inherit; +.pg-btn.primary { background:var(--hue, var(--pg-accent)); color:#06080f; } +.pg-btn.primary:hover { filter:brightness(1.08); } +.pg-btn.ghost { background:transparent; border-color:var(--pg-border-2); color:var(--pg-text-2); } +.pg-btn.ghost:hover { color:var(--pg-text); border-color:var(--pg-text-3); } + +/* end-of-round scoreboard rows (inside .pg-modal) */ +.pg-scoreboard .sb-list { margin:0 0 18px; display:flex; flex-direction:column; gap:2px; } +.pg-sbrow { display:flex; align-items:center; gap:11px; padding:8px 0; border-bottom:1px solid var(--pg-border); font:500 13px 'IBM Plex Mono'; } +.pg-sbrow:last-child { border-bottom:0; } +.pg-sbrow .rk { width:20px; color:var(--pg-text-3); } +.pg-sbrow .dot { width:9px; height:9px; border-radius:50%; flex:none; } +.pg-sbrow .nm { flex:1; color:var(--pg-text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.pg-sbrow .sc { color:var(--pg-text-2); } +.pg-sbrow.me .nm { color:var(--pg-b); } +.pg-sbrow.win .rk { color:var(--pg-y); } + +/* connection-lost overlay */ +.pg-connlost { text-align:center; } +.pg-connlost .cl-ico { display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; border:1px solid var(--pg-border-2); border-radius:50%; margin:0 auto 14px; color:var(--pg-r); } +.pg-connlost .cl-spin { width:14px; height:14px; border:2px solid var(--pg-border-2); border-top-color:var(--pg-accent); border-radius:50%; display:inline-block; animation:pg-spin .8s linear infinite; vertical-align:-2px; margin-right:7px; } +@keyframes pg-spin { to { transform:rotate(360deg); } } + +/* ============================================================ + RESPONSIVE / MOBILE + ============================================================ */ +@media (max-width: 880px) { + .pg-grid { grid-template-columns:1fr; } + .pg-hero h1 { font-size:42px; } + .pg-hero p { font-size:17px; } } -.back:hover { border-color: var(--accent); } -.gametitle { color: var(--muted); font-size: 14px; } - -.stage { - position: relative; - width: 100%; - height: calc(100% - 49px); - overflow: hidden; +@media (max-width: 560px) { + .pg-wrap { padding:0 18px; } + .pg-top nav { gap:14px; } + .pg-top nav a.hide-sm { display:none; } + .pg-hero { padding:26px 0 8px; } + .pg-hero h1 { font-size:34px; } + .pg-gamebar { gap:10px; padding:10px 14px; flex-wrap:wrap; } + .pg-gamename { display:none; } + .lboard { width:150px; padding:10px; } + .hud .stat { font-size:11px; } } -.stage canvas { display: block; width: 100%; height: 100%; } diff --git a/src/shell/card-art.ts b/src/shell/card-art.ts new file mode 100644 index 0000000..222aba6 --- /dev/null +++ b/src/shell/card-art.ts @@ -0,0 +1,183 @@ +/** + * Animated point·line·plane art for the menu cards. + * + * Each demo's card shows the *shape* of multiplayer it demonstrates, keyed by + * game id: Blobs = many drifting points, Caption Clash = a rotating lobby ring + * (point·line·plane), Siege = enemies converging on a co-op cluster. Unknown + * ids fall back to a generic drifting-points field in the card's hue. + * + * Ported from the Plot platform design handoff (ui_kits/playground/index.html), + * rewritten as typed, self-cleaning modules: each renderer returns a stop() + * that cancels its animation frame so leaving the menu leaks nothing. + */ + +const SVGNS = 'http://www.w3.org/2000/svg'; + +function el( + name: K, + attrs: Record, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(SVGNS, name); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + return node; +} + +/** Tiny deterministic PRNG so the art is stable across renders. */ +function rng(seed: number): () => number { + let s = seed; + return () => { + s = (s * 9301 + 49297) % 233280; + return s / 233280; + }; +} + +const W = 360; +const H = 150; + +type Stop = () => void; + +interface Drifter { + x: number; + y: number; + vx: number; + vy: number; + rad: number; + node: SVGCircleElement; +} + +/** Drifting-points field shared by Blobs and the generic fallback. */ +function driftField(svg: SVGSVGElement, hues: string[], count: number, seed: number, opacity: number): Stop { + const r = rng(seed); + const nodes: Drifter[] = Array.from({ length: count }, (_, i) => { + const rad = 3 + r() * 9; + const hue = hues[i % hues.length] ?? hues[0] ?? '--pg-b'; + const node = el('circle', { r: rad, fill: `var(${hue})`, 'fill-opacity': opacity }); + svg.appendChild(node); + return { x: r() * W, y: r() * H, vx: (r() - 0.5) * 0.5, vy: (r() - 0.5) * 0.5, rad, node }; + }); + let raf = 0; + const step = (): void => { + for (const n of nodes) { + n.x += n.vx; + n.y += n.vy; + if (n.x < 0 || n.x > W) n.vx *= -1; + if (n.y < 0 || n.y > H) n.vy *= -1; + n.node.setAttribute('cx', n.x.toFixed(1)); + n.node.setAttribute('cy', n.y.toFixed(1)); + } + raf = requestAnimationFrame(step); + }; + step(); + return () => cancelAnimationFrame(raf); +} + +/** Caption Clash — a central point with lines to a rotating ring (lobby). */ +function captionArt(svg: SVGSVGElement): Stop { + const cx = 180; + const cy = 75; + const hues = ['--pg-m', '--pg-y', '--pg-c', '--pg-b', '--pg-g', '--pg-r']; + const spokes = Array.from({ length: 6 }, (_, i) => { + const base = (i / 6) * Math.PI * 2; + const line = el('line', { + x1: cx, + y1: cy, + x2: cx, + y2: cy, + stroke: 'var(--pg-border-2)', + 'stroke-width': 1.2, + }); + svg.appendChild(line); + const dot = el('circle', { cx, cy, r: 8, fill: `var(${hues[i] ?? '--pg-m'})` }); + svg.appendChild(dot); + return { base, line, dot }; + }); + svg.appendChild(el('circle', { cx, cy, r: 12, fill: 'var(--pg-m)' })); + let t = 0; + let raf = 0; + const step = (): void => { + t += 0.015; + for (const s of spokes) { + const a = s.base + t; + const x = cx + Math.cos(a) * 92; + const y = cy + Math.sin(a) * 52; + s.line.setAttribute('x2', x.toFixed(1)); + s.line.setAttribute('y2', y.toFixed(1)); + s.dot.setAttribute('cx', x.toFixed(1)); + s.dot.setAttribute('cy', y.toFixed(1)); + } + raf = requestAnimationFrame(step); + }; + step(); + return () => cancelAnimationFrame(raf); +} + +interface Enemy { + x: number; + y: number; + node: SVGRectElement; +} + +/** Siege — a co-op cluster center, enemies converging from the edges. */ +function siegeArt(svg: SVGSVGElement): Stop { + const cx = 180; + const cy = 75; + for (const [x, y, c] of [ + [168, 72, '--pg-g'], + [192, 70, '--pg-b'], + [180, 86, '--pg-c'], + ] as const) { + svg.appendChild(el('circle', { cx: x, cy: y, r: 6, fill: `var(${c})` })); + } + const r = rng(13); + const enemies: Enemy[] = Array.from({ length: 10 }, () => { + const edge = r(); + const x = edge < 0.5 ? r() * W : r() < 0.5 ? -10 : W + 10; + const y = edge < 0.5 ? (r() < 0.5 ? -10 : H + 10) : r() * H; + const node = el('rect', { width: 8, height: 8, fill: 'var(--pg-r)', 'fill-opacity': 0.9 }); + svg.appendChild(node); + return { x, y, node }; + }); + let raf = 0; + const step = (): void => { + for (const n of enemies) { + const dx = cx - n.x; + const dy = cy - n.y; + const d = Math.hypot(dx, dy) || 1; + if (d < 22) { + n.x = r() * W * (r() < 0.5 ? -0.1 : 1.1); + n.y = r() * H; + } else { + n.x += (dx / d) * 0.6; + n.y += (dy / d) * 0.6; + } + n.node.setAttribute('x', (n.x - 4).toFixed(1)); + n.node.setAttribute('y', (n.y - 4).toFixed(1)); + } + raf = requestAnimationFrame(step); + }; + step(); + return () => cancelAnimationFrame(raf); +} + +/** + * Build a card-art SVG for a game and start its animation. Returns the SVG + * element plus a stop() that cancels the animation frame. + */ +export function createCardArt(gameId: string, hue: string): { svg: SVGSVGElement; stop: Stop } { + const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'xMidYMid slice' }); + let stop: Stop; + switch (gameId) { + case 'blobs': + stop = driftField(svg, ['--pg-b', '--pg-c', '--pg-g'], 26, 7, 0.85); + break; + case 'party': + stop = captionArt(svg); + break; + case 'siege': + stop = siegeArt(svg); + break; + default: + stop = driftField(svg, [hue], 18, 21, 0.7); + } + return { svg, stop }; +} diff --git a/src/shell/names.ts b/src/shell/names.ts new file mode 100644 index 0000000..93ecb8b --- /dev/null +++ b/src/shell/names.ts @@ -0,0 +1,44 @@ +/** + * Friendly, deterministic display names for player ids. + * + * Every client derives the *same* name from the same id with no server + * round-trip, so all players agree on who is who and a name stays stable for + * the whole session. This replaces the raw id prefix that otherwise labels + * blobs and leaderboard rows (e.g. `5e78`) with something readable like + * `Brave Otter`. + */ + +const ADJECTIVES = [ + 'Brave', 'Swift', 'Sneaky', 'Mighty', 'Tiny', 'Lucky', 'Jolly', 'Wily', + 'Bold', 'Fuzzy', 'Nimble', 'Grumpy', 'Sleepy', 'Zippy', 'Cosmic', 'Funky', + 'Spicy', 'Mellow', 'Snappy', 'Plucky', 'Witty', 'Dizzy', 'Cheeky', 'Glossy', + 'Rowdy', 'Quirky', 'Breezy', 'Frosty', 'Gentle', 'Feisty', 'Dapper', 'Wobbly', +]; + +const ANIMALS = [ + 'Otter', 'Falcon', 'Yak', 'Newt', 'Moth', 'Crab', 'Lynx', 'Koala', + 'Gecko', 'Panda', 'Heron', 'Bison', 'Toad', 'Wren', 'Seal', 'Fox', + 'Mole', 'Stork', 'Vole', 'Ibex', 'Quokka', 'Tapir', 'Marmot', 'Puffin', + 'Wombat', 'Badger', 'Raven', 'Shrew', 'Llama', 'Civet', 'Dingo', 'Lemur', +]; + +/** Deterministic 32-bit FNV-1a hash of a string. */ +function hash(s: string): number { + let h = 2166136261 >>> 0; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** + * A stable, friendly two-word name for a player id (e.g. `Swift Lynx`). + * 32×32 = 1024 combinations — ample for the handful of players in a room. + */ +export function nameFor(id: string): string { + const h = hash(id); + const adjective = ADJECTIVES[h % ADJECTIVES.length]; + const animal = ANIMALS[Math.floor(h / ADJECTIVES.length) % ANIMALS.length]; + return `${adjective} ${animal}`; +} diff --git a/src/shell/overlays.ts b/src/shell/overlays.ts new file mode 100644 index 0000000..c230226 --- /dev/null +++ b/src/shell/overlays.ts @@ -0,0 +1,245 @@ +/** + * Playground overlays — the modal surfaces the showcase shows over the stage: + * the room-join lobby (replacing `window.prompt`), the end-of-round scoreboard, + * and the connection-lost state. + * + * Each opener builds a backdrop + `.pg-modal`, appends it to `document.body`, + * and returns a `dismiss()`. They are framework-free DOM so any game can call + * them. User-supplied strings (room codes, player names) go in via + * `textContent` — never `innerHTML` — so they can't inject markup. + */ + +/** Build an element with class + optional text, the lazy way. */ +function h( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +/** The three-stripe color-bar kicker reused across modals. */ +function kbar(): HTMLElement { + const bar = h('span', 'pg-kbar'); + for (const hue of ['--pg-r', '--pg-g', '--pg-b']) { + const seg = document.createElement('span'); + seg.style.background = `var(${hue})`; + bar.appendChild(seg); + } + return bar; +} + +/** + * Mount a modal node inside a fresh backdrop. Returns `dismiss()`. Pressing + * Escape (or clicking the backdrop) calls `onDismiss` so callers can route it + * to cancel/back behaviour. + */ +function mountOverlay(modal: HTMLElement, onDismiss?: () => void): () => void { + const backdrop = h('div', 'pg-backdrop'); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + const dismiss = (): void => { + window.removeEventListener('keydown', onKey); + backdrop.remove(); + }; + function onKey(e: KeyboardEvent): void { + if (e.key === 'Escape') onDismiss?.(); + } + backdrop.addEventListener('mousedown', (e) => { + if (e.target === backdrop) onDismiss?.(); + }); + window.addEventListener('keydown', onKey); + return dismiss; +} + +/** A ranked row for the scoreboard. */ +export interface ScoreRow { + name: string; + score: number | string; + /** CSS custom-property name for the row dot colour (e.g. `--pg-b`). */ + hue?: string; + /** Highlight this row as the local player. */ + isYou?: boolean; +} + +/** + * The room-join lobby. Replaces `window.prompt` with a styled modal: a + * pre-filled, auto-selected room-code input that joins on Enter or the Join + * button, and cancels on Escape / backdrop / Cancel. + */ +export function openRoomJoin(opts: { + gameName: string; + hue: string; + defaultRoom: string; + onJoin: (code: string) => void; + onCancel: () => void; +}): void { + const modal = h('div', 'pg-modal'); + modal.style.setProperty('--hue', `var(${opts.hue})`); + + const kicker = h('div', 'm-kicker'); + kicker.append(kbar(), h('span', 'lbl', 'Join a room')); + modal.appendChild(kicker); + + modal.appendChild(h('h3', undefined, opts.gameName)); + modal.appendChild( + h('p', undefined, 'Share the room code so others land in the same room — open a second tab to see the points sync.'), + ); + + const field = h('label', 'pg-field'); + field.appendChild(h('span', 'fl', 'Room code')); + const input = h('input', 'pg-input'); + input.type = 'text'; + input.value = opts.defaultRoom; + input.maxLength = 12; + input.autocomplete = 'off'; + input.spellcheck = false; + field.appendChild(input); + modal.appendChild(field); + + const actions = h('div', 'pg-actions'); + const cancel = h('button', 'pg-btn ghost', 'Cancel'); + cancel.type = 'button'; + const join = h('button', 'pg-btn primary', 'Join →'); + join.type = 'button'; + actions.append(cancel, join); + modal.appendChild(actions); + + const normalize = (): string => { + const code = input.value.trim().toUpperCase(); + return code.length > 0 ? code : opts.defaultRoom; + }; + + let dismiss = (): void => {}; + const doJoin = (): void => { + const code = normalize(); + dismiss(); + opts.onJoin(code); + }; + const doCancel = (): void => { + dismiss(); + opts.onCancel(); + }; + + join.addEventListener('click', doJoin); + cancel.addEventListener('click', doCancel); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + doJoin(); + } + }); + + dismiss = mountOverlay(modal, doCancel); + // Focus + select so typing replaces the default immediately. + input.focus(); + input.select(); +} + +/** + * End-of-round / end-of-match scoreboard. `title` carries the outcome + * ("Round over", "Victory", "Base lost"). Returns a `dismiss()`. + */ +export function showScoreboard(opts: { + title: string; + subtitle?: string; + hue: string; + rows: ScoreRow[]; + onClose: () => void; + closeLabel?: string; +}): () => void { + const modal = h('div', 'pg-modal pg-scoreboard'); + modal.style.setProperty('--hue', `var(${opts.hue})`); + + const kicker = h('div', 'm-kicker'); + kicker.append(kbar(), h('span', 'lbl', 'Scoreboard')); + modal.appendChild(kicker); + + modal.appendChild(h('h3', undefined, opts.title)); + if (opts.subtitle) modal.appendChild(h('p', undefined, opts.subtitle)); + + const list = h('div', 'sb-list'); + opts.rows.forEach((row, i) => { + const r = h('div', `pg-sbrow${row.isYou ? ' me' : ''}${i === 0 ? ' win' : ''}`); + r.appendChild(h('span', 'rk', String(i + 1))); + const dot = h('span', 'dot'); + dot.style.background = `var(${row.hue ?? opts.hue})`; + r.appendChild(dot); + r.appendChild(h('span', 'nm', row.name)); + r.appendChild(h('span', 'sc', String(row.score))); + list.appendChild(r); + }); + modal.appendChild(list); + + const actions = h('div', 'pg-actions'); + const close = h('button', 'pg-btn primary', opts.closeLabel ?? 'Play again'); + close.type = 'button'; + actions.appendChild(close); + modal.appendChild(actions); + + let dismiss = (): void => {}; + const doClose = (): void => { + dismiss(); + opts.onClose(); + }; + close.addEventListener('click', doClose); + dismiss = mountOverlay(modal, doClose); + close.focus(); + return dismiss; +} + +/** + * Connection-lost overlay. The vendored client surfaces no public mid-game + * disconnect event, so this is shown when a `join()` fails (or a game wires its + * own signal). `onRetry` re-attempts; `onBack` returns to the menu. + */ +export function showConnectionLost(opts: { + hue: string; + message?: string; + reconnecting?: boolean; + onRetry?: () => void; + onBack: () => void; +}): () => void { + const modal = h('div', 'pg-modal pg-connlost'); + modal.style.setProperty('--hue', `var(${opts.hue})`); + + const ico = h('div', 'cl-ico'); + ico.innerHTML = + ''; + modal.appendChild(ico); + + modal.appendChild(h('h3', undefined, opts.reconnecting ? 'Reconnecting…' : 'Connection lost')); + modal.appendChild( + h('p', undefined, opts.message ?? 'We could not reach the room. Check your connection and try again.'), + ); + + const actions = h('div', 'pg-actions'); + const back = h('button', 'pg-btn ghost', '← All demos'); + back.type = 'button'; + const retry = h('button', 'pg-btn primary', 'Retry'); + retry.type = 'button'; + if (opts.reconnecting) { + retry.disabled = true; + retry.innerHTML = 'Reconnecting'; + } + actions.append(back, retry); + modal.appendChild(actions); + + let dismiss = (): void => {}; + back.addEventListener('click', () => { + dismiss(); + opts.onBack(); + }); + if (opts.onRetry) { + retry.addEventListener('click', () => { + dismiss(); + opts.onRetry?.(); + }); + } + dismiss = mountOverlay(modal, opts.onBack); + return dismiss; +} diff --git a/vendor/plot-client/index.d.ts b/vendor/plot-client/index.d.ts index e8dd9e8..88af1c4 100644 --- a/vendor/plot-client/index.d.ts +++ b/vendor/plot-client/index.d.ts @@ -1,9 +1,9 @@ -import { CorrectionType } from './handler-client'; -export { CorrectionType } from './handler-client'; -import { RoomDefinition } from './handler'; -import { ClientEnvelope, ServerEnvelope, Channel, Profile, LeaderboardEntry, MatchMode } from './protocol'; -export * from './protocol'; -export { CHANNELS, DEFAULT_CHANNEL, isChannel } from './protocol'; +import { CorrectionType } from '@plot/handler-client'; +export { CorrectionType } from '@plot/handler-client'; +import { RoomDefinition } from '@plot/handler'; +import { ClientEnvelope, ServerEnvelope, Channel, Profile, LeaderboardEntry, MatchMode } from '@plot/protocol'; +export * from '@plot/protocol'; +export { CHANNELS, DEFAULT_CHANNEL, isChannel } from '@plot/protocol'; type Snapshot = { ts: number; @@ -54,7 +54,12 @@ interface Transport { send(env: ClientEnvelope): void; close(): void; onMessage(handler: (env: ServerEnvelope) => void): void; - onClose(handler: () => void): void; + /** Fires once when the socket closes. `clean` is true only for an intentional + * local close (code 1000) — an abnormal drop (1006, server 1011, …) is the + * Room's cue to reconnect. */ + onClose(handler: (info: { + clean: boolean; + }) => void): void; } type RoomMessageEvent = { @@ -85,6 +90,13 @@ interface RoomEvents { leave: (e: PresenceEvent) => void; frame: (e: FrameEvent) => void; predicted: (e: PredictedEvent) => void; + /** The socket dropped abnormally. `willRetry` is true while the Room is + * reconnecting with backoff, false once it has exhausted MAX_RETRIES. */ + disconnect: (e: { + willRetry: boolean; + }) => void; + /** A reconnect succeeded; the server will re-send a snapshot to resync. */ + reconnect: () => void; } interface SendOptions { channel?: Channel; @@ -93,7 +105,16 @@ interface SendOptions { declare class Room { private _playerId; private transport; + /** Re-establishes a transport for the same room (fresh token, optional + * resume token). Null when the Room was created without reconnect support. */ + private reconnectFn; private listeners; + /** Latest server-issued resume token (refreshed each (re)connect); presented + * on reconnect to resume the same slot within the grace window. */ + private resumeToken; + /** Set when the app calls leave(): suppresses reconnect on the ensuing close. */ + private intentionalLeave; + private reconnecting; currentState: unknown; private snapshotSink; private buffer; @@ -107,9 +128,25 @@ declare class Room { private lastDrift; private predictedTracks; correctedState: Record; - constructor(_playerId: string, transport: Transport); + constructor(_playerId: string, transport: Transport, + /** Re-establishes a transport for the same room (fresh token, optional + * resume token). Null when the Room was created without reconnect support. */ + reconnectFn?: ((resumeToken: string | null) => Promise) | null); /** Internal: register a sink that receives every applied (ts, state). */ _onSnapshot(sink: (ts: number, state: unknown) => void): void; + /** (Re)bind message + close handlers to the current transport. Called on + * construction and after each successful reconnect (the transport is swapped). */ + private wireTransport; + /** An abnormal drop triggers a backoff reconnect; an intentional/clean close + * (leave()) stays closed. */ + private handleClose; + /** + * Reconnect with exponential backoff + jitter (anti-thundering-herd on a + * server restart). Re-establishes the transport for the same room, presenting + * the latest resume token so the server can restore the slot within its grace + * window. The server re-sends a snapshot on attach, which resyncs prediction. + */ + private reconnectLoop; on(type: K, handler: RoomEvents[K]): void; send(data: unknown, opts?: SendOptions): void; leave(): void; @@ -185,10 +222,95 @@ declare class SaveClient { get(slot: string): Promise; } +/** + * Managed netcode layer. + * + * `Plot.play()` collapses the ~10 manual calls needed for good client netcode + * (join → attachHandler → predict → interpolate → frame loop → reconnection → + * sendPredicted → render → cleanup) into one declarative call with best-practice + * defaults on by default. A developer declares which state paths are *local* + * (predicted + corrected) and which are *remote* (snapshot-interpolated), and + * supplies an `onFrame` renderer; everything else is wired automatically. + * + * This is purely additive — it is built on the public {@link Room} API, and + * `Game.room` exposes that Room as an escape hatch. + */ +/** One predicted/interpolated state path. `{me}` resolves to the local player + * id; `*` matches all ids at that level (e.g. `players.{me}`, `players.*`). */ +interface PathDecl { + path: string; + type: InterpType; + /** Local paths only: error-correction window (ms). Default 100. */ + correctionMs?: number; + /** Remote paths only: snapshot render delay (ms). Default 100. */ + renderDelay?: number; +} +/** Resolved per-frame view handed to `onFrame`, assembled from the Room. */ +interface FrameView { + /** Authoritative server state (latest applied snapshot). */ + state: S; + /** Server timestamp this frame renders at. */ + ts: number; + /** Local player id (the value `{me}` resolves to). */ + me: string; + /** Corrected (predicted + reconciled) local values, keyed by resolved path. */ + local: Record; + /** Interpolated remote values, keyed by resolved leaf path; the local id is excluded. */ + remote: Record; + /** Current presence roster (player ids). */ + players: string[]; +} +interface PlayOptions { + /** Room code to join (implies `mode: 'code'` when `mode` is unset). */ + room?: string; + mode?: MatchMode; + maxPlayers?: number; + attrs?: Record; + rank?: number; + handler: RoomDefinition; + /** Local paths — predicted on input and reconciled to the server. */ + predict?: PathDecl[]; + /** Remote paths — snapshot-interpolated for smooth rendering. */ + interpolate?: PathDecl[]; + onFrame?: (view: FrameView) => void; + /** Frame-loop interval in ms. Default 16 (~60fps). */ + frameMs?: number; + correctionMs?: number; + renderDelayMs?: number; + /** Grow interpolation delay with measured jitter. Default true. */ + adaptiveSmoothing?: boolean; + onConnected?: (game: Game) => void; + onDisconnect?: (e: { + willRetry: boolean; + }) => void; + onReconnect?: () => void; + onPlayerJoin?: (playerId: string) => void; + onPlayerLeave?: (playerId: string) => void; +} +/** Handle returned by `Plot.play()`. */ +interface Game { + /** Send a predicted input (wraps `Room.sendPredicted`). */ + input(data: I, opts?: SendOptions): void; + /** Send a raw, non-predicted message (wraps `Room.send`). */ + send(data: unknown, opts?: SendOptions): void; + /** Latest authoritative state. */ + readonly state: S; + /** Local player id. */ + readonly me: string; + /** Escape hatch to the low-level Room. */ + readonly room: Room; + /** Stop the frame loop and leave the room. */ + leave(): void; +} + interface PlotOptions { appKey: string; playerId: string; apiUrl?: string; + /** App id for multi-tenant routing — sent as the `X-Plot-App` header so the + * server resolves THIS app (not its default APP_ID) and verifies `appKey` + * against it. Required when an account hosts more than one app. */ + appId?: string; } interface JoinOptions { mode?: MatchMode; @@ -205,9 +327,20 @@ declare class Plot { leaderboard: (name: string) => LeaderboardClient; constructor(options: PlotOptions); private getToken; + /** Headers for /v1/connect: JSON + the multi-tenant X-Plot-App routing header + * when an appId is configured. */ + private connectHeaders; private wirePersistence; connect(): Promise; join(opts: JoinOptions): Promise; + /** + * Managed entry point: join a room and wire prediction, interpolation, + * reconnection, and the frame loop with best-practice defaults — so a game + * only declares its local/remote state paths and an `onFrame` renderer. See + * {@link PlayOptions}. Returns a {@link Game} handle (`input`, `send`, + * `state`, `leave`, and `room` as the low-level escape hatch). + */ + play(opts: PlayOptions): Promise>; } type Vec2 = { @@ -228,4 +361,4 @@ type Quat = { w: number; }; -export { type FrameEvent, type InterpType, type InterpolateOpts, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type Vec2, type Vec3 }; +export { type FrameEvent, type FrameView, type Game, type InterpType, type InterpolateOpts, type PathDecl, type PlayOptions, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type SendOptions, type Vec2, type Vec3 }; diff --git a/vendor/plot-client/index.js b/vendor/plot-client/index.js index 7bd72d0..2f26bd6 100644 --- a/vendor/plot-client/index.js +++ b/vendor/plot-client/index.js @@ -5,31 +5,55 @@ var __export = (target, all) => { }; // src/transport.ts -function openTransport(wsUrl, roomCode, token) { +var MAX_RETRIES = 10; +function computeBackoffDelay(attempt) { + return Math.min(3e4, 250 * Math.pow(2, attempt)); +} +var KEEPALIVE_MSG = "ping"; +var KEEPALIVE_INTERVAL_MS = 2e4; +function openTransport(wsUrl, roomCode, token, resumeToken) { const u = new URL(wsUrl); u.searchParams.set("roomCode", roomCode); u.searchParams.set("token", token); + if (resumeToken) u.searchParams.set("resume", resumeToken); const ws = new WebSocket(u.toString()); const messageHandlers = []; const closeHandlers = []; + let keepalive = null; + const stopKeepalive = () => { + if (keepalive !== null) { + clearInterval(keepalive); + keepalive = null; + } + }; ws.addEventListener("message", (e) => { + const data = e.data; + if (data === "pong") return; try { - const env = JSON.parse(e.data); + const env = JSON.parse(data); for (const h of messageHandlers) h(env); } catch { } }); - ws.addEventListener("close", () => { - for (const h of closeHandlers) h(); + ws.addEventListener("close", (e) => { + stopKeepalive(); + const clean = e.code === 1e3; + for (const h of closeHandlers) h({ clean }); }); return new Promise((resolve, reject) => { - ws.addEventListener( - "open", - () => resolve({ + ws.addEventListener("open", () => { + keepalive = setInterval(() => { + try { + ws.send(KEEPALIVE_MSG); + } catch { + } + }, KEEPALIVE_INTERVAL_MS); + resolve({ send(env) { ws.send(JSON.stringify(env)); }, close() { + stopKeepalive(); ws.close(1e3, "client closed"); }, onMessage(h) { @@ -38,8 +62,8 @@ function openTransport(wsUrl, roomCode, token) { onClose(h) { closeHandlers.push(h); } - }) - ); + }); + }); ws.addEventListener("error", () => reject(new Error("websocket error"))); }); } @@ -1284,10 +1308,11 @@ function applyOffset(type, base, offset) { } } var Room = class { - constructor(_playerId, transport) { + constructor(_playerId, transport, reconnectFn = null) { this._playerId = _playerId; this.transport = transport; - this.transport.onMessage((env) => this.dispatch(env)); + this.reconnectFn = reconnectFn; + this.wireTransport(); this._onSnapshot((ts, state) => { this.clock.observe({ clientNow: Date.now(), serverTs: ts }); this.buffer.push(ts, state); @@ -1313,8 +1338,16 @@ var Room = class { join: [], leave: [], frame: [], - predicted: [] + predicted: [], + disconnect: [], + reconnect: [] }; + /** Latest server-issued resume token (refreshed each (re)connect); presented + * on reconnect to resume the same slot within the grace window. */ + resumeToken = null; + /** Set when the app calls leave(): suppresses reconnect on the ensuing close. */ + intentionalLeave = false; + reconnecting = false; currentState = void 0; snapshotSink = null; buffer = new SnapshotBuffer(500); @@ -1336,6 +1369,47 @@ var Room = class { _onSnapshot(sink) { this.snapshotSink = sink; } + /** (Re)bind message + close handlers to the current transport. Called on + * construction and after each successful reconnect (the transport is swapped). */ + wireTransport() { + this.transport.onMessage((env) => this.dispatch(env)); + this.transport.onClose((info) => this.handleClose(info)); + } + /** An abnormal drop triggers a backoff reconnect; an intentional/clean close + * (leave()) stays closed. */ + handleClose(info) { + if (this.intentionalLeave || info.clean || this.reconnecting) return; + void this.reconnectLoop(); + } + /** + * Reconnect with exponential backoff + jitter (anti-thundering-herd on a + * server restart). Re-establishes the transport for the same room, presenting + * the latest resume token so the server can restore the slot within its grace + * window. The server re-sends a snapshot on attach, which resyncs prediction. + */ + async reconnectLoop() { + if (this.reconnectFn === null) return; + this.reconnecting = true; + for (const h of this.listeners.disconnect) h({ willRetry: true }); + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const wait = computeBackoffDelay(attempt) * (0.5 + Math.random() * 0.5); + await new Promise((r) => setTimeout(r, wait)); + if (this.intentionalLeave) { + this.reconnecting = false; + return; + } + try { + this.transport = await this.reconnectFn(this.resumeToken); + this.wireTransport(); + this.reconnecting = false; + for (const h of this.listeners.reconnect) h(); + return; + } catch { + } + } + this.reconnecting = false; + for (const h of this.listeners.disconnect) h({ willRetry: false }); + } on(type, handler) { this.listeners[type].push(handler); } @@ -1349,6 +1423,7 @@ var Room = class { this.transport.send(env); } leave() { + this.intentionalLeave = true; this.transport.close(); } attachHandler(room) { @@ -1523,8 +1598,12 @@ var Room = class { this.snapshotSink?.(env.ts, this.currentState); break; } - case "error": case "reconnect-token": + if (typeof env.token === "string") { + this.resumeToken = env.token; + } + break; + case "error": break; } } @@ -1607,6 +1686,75 @@ var SaveClient = class { } }; +// src/play.ts +function resolvePath2(path, me) { + return path.replaceAll("{me}", me); +} +function isLocalLeaf(path, me) { + return path.split(".").includes(me); +} +function createManagedGame(room, playerId, opts) { + room.attachHandler(opts.handler); + const localPaths = []; + for (const d of opts.predict ?? []) { + const path = resolvePath2(d.path, playerId); + localPaths.push(path); + room.predict({ path, type: d.type, correctionMs: d.correctionMs ?? opts.correctionMs ?? 100 }); + } + for (const d of opts.interpolate ?? []) { + const path = resolvePath2(d.path, playerId); + room.interpolate({ path, type: d.type, renderDelay: d.renderDelay ?? opts.renderDelayMs ?? 100 }); + } + room.setAdaptiveSmoothing({ enabled: opts.adaptiveSmoothing ?? true }); + let players = [playerId]; + room.on("join", (e) => { + players = e.players; + opts.onPlayerJoin?.(e.playerId); + }); + room.on("leave", (e) => { + players = e.players; + opts.onPlayerLeave?.(e.playerId); + }); + if (opts.onDisconnect) room.on("disconnect", opts.onDisconnect); + if (opts.onReconnect) room.on("reconnect", opts.onReconnect); + const onFrame = opts.onFrame; + if (onFrame) { + room.on("frame", ({ interpolated, ts }) => { + const local = {}; + for (const p of localPaths) local[p] = room.correctedState[p]; + const remote = {}; + for (const [k, v] of Object.entries(interpolated)) { + if (localPaths.includes(k) || isLocalLeaf(k, playerId)) continue; + remote[k] = v; + } + onFrame({ + state: room.currentState, + ts, + me: playerId, + local, + remote, + players: [...players] + }); + }); + } + const game = { + input: (data, o) => room.sendPredicted(data, o), + send: (data, o) => room.send(data, o), + get state() { + return room.currentState; + }, + me: playerId, + room, + leave: () => { + room.stopFrameLoop(); + room.leave(); + } + }; + opts.onConnected?.(game); + room.startFrameLoop(opts.frameMs ?? 16); + return game; +} + // src/plot.ts var DEFAULT_API_URL = "https://api.plot.ws"; var Plot = class { @@ -1622,6 +1770,13 @@ var Plot = class { if (!this.currentToken) throw new Error("not connected \u2014 call connect() or join() first"); return this.currentToken; } + /** Headers for /v1/connect: JSON + the multi-tenant X-Plot-App routing header + * when an appId is configured. */ + connectHeaders() { + const h = { "content-type": "application/json" }; + if (this.options.appId) h["X-Plot-App"] = this.options.appId; + return h; + } wirePersistence(apiUrl) { const tg = () => this.getToken(); this.profile = new ProfileClient(apiUrl, tg); @@ -1632,7 +1787,7 @@ var Plot = class { const apiUrl = this.options.apiUrl ?? DEFAULT_API_URL; const res = await fetch(`${apiUrl}/v1/connect`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: this.connectHeaders(), body: JSON.stringify({ appKey: this.options.appKey, playerId: this.options.playerId }) }); if (!res.ok) throw new Error(`connect failed: ${res.status}`); @@ -1648,7 +1803,7 @@ var Plot = class { }; const cres = await fetch(`${apiUrl}/v1/connect`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: this.connectHeaders(), body: JSON.stringify(body) }); if (!cres.ok) throw new Error(`connect failed: ${cres.status}`); @@ -1666,8 +1821,37 @@ var Plot = class { roomCode = mm.roomCode; } if (!roomCode) throw new Error("roomCode required for code mode or matchmake failure"); - const transport = await openTransport(wsUrl, roomCode, token); - return new Room(this.options.playerId, transport); + const code = roomCode; + const reconnect = async (resumeToken) => { + const rr = await fetch(`${apiUrl}/v1/connect`, { + method: "POST", + headers: this.connectHeaders(), + body: JSON.stringify(body) + }); + if (!rr.ok) throw new Error(`reconnect failed: ${rr.status}`); + const conn = await rr.json(); + this.currentToken = conn.token; + return openTransport(conn.wsUrl, code, conn.token, resumeToken ?? void 0); + }; + const transport = await openTransport(wsUrl, code, token); + return new Room(this.options.playerId, transport, reconnect); + } + /** + * Managed entry point: join a room and wire prediction, interpolation, + * reconnection, and the frame loop with best-practice defaults — so a game + * only declares its local/remote state paths and an `onFrame` renderer. See + * {@link PlayOptions}. Returns a {@link Game} handle (`input`, `send`, + * `state`, `leave`, and `room` as the low-level escape hatch). + */ + async play(opts) { + const room = await this.join({ + mode: opts.mode, + roomCode: opts.room, + maxPlayers: opts.maxPlayers, + attrs: opts.attrs, + rank: opts.rank + }); + return createManagedGame(room, this.options.playerId, opts); } }; export {