From 84531acf553cfd6133070953e308ac3c16e67ed3 Mon Sep 17 00:00:00 2001 From: artheru Date: Thu, 11 Jun 2026 15:05:44 +0800 Subject: [PATCH] online: multiplayer presence in the tavern (4.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: in-memory public presence lobby — POST/GET /agents/presence (id,name,x,y,dir,frame,sheet; 6s TTL; POST returns other live players) - RpgTavern: broadcast my position ~600ms, render other visitors' chibi avatars (smooth-interpolated) with name labels, depth-sorted; a '👥 N in the tavern' counter - verified: two browsers each show the other's named avatar walking, both report 2 online Co-Authored-By: Claude Fable 5 --- index.html | 2 +- package.json | 2 +- server/amerc-api.mjs | 18 ++++++++++++++++++ src/RpgTavern.jsx | 39 ++++++++++++++++++++++++++++++++++++++- src/Scene2D.jsx | 3 ++- src/styles.css | 3 +++ 6 files changed, 63 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index 28d2794..8762c30 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@ @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } - + diff --git a/package.json b/package.json index 3f526fc..596a306 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "4.0.0-rpg", + "version": "4.1.0-online", "private": true, "type": "module", "scripts": { diff --git a/server/amerc-api.mjs b/server/amerc-api.mjs index db43dcb..353f947 100644 --- a/server/amerc-api.mjs +++ b/server/amerc-api.mjs @@ -125,6 +125,14 @@ function waitInput(instId, ms) { const t = setTimeout(fn, ms); }); } +// online presence: a public, in-memory lobby of players walking the tavern. +const presence = new Map(); // id -> { id, name, x, y, dir, frame, sheet, room, at } +const PRESENCE_TTL = 6000; +function presenceList(exclude) { + const now = Date.now(); const out = []; + for (const [k, v] of presence) { if (now - v.at > PRESENCE_TTL) { presence.delete(k); continue; } if (k !== exclude) out.push(v); } + return out.slice(0, 50); +} // file rendezvous: producer's request stream is piped to the consumer's // response when it claims the token. The server never writes the bytes. const FILE_CLAIM_MS = 5 * 60 * 1000; @@ -526,6 +534,16 @@ const server = http.createServer(async (req, res) => { return send(res, 200, { ok: true, activity: items.slice(0, 12) }); } + // online presence (public lobby — no auth; visitors see each other in the tavern) + if (path === '/agents/presence' && method === 'POST') { + const b = await readBody(req) || {}; + const pid = String(b.id || '').slice(0, 40); if (!pid) return send(res, 400, { ok: false, error: 'id required' }); + const dir = ['up', 'down', 'left', 'right'].includes(b.dir) ? b.dir : 'down'; + presence.set(pid, { id: pid, name: String(b.name || 'wanderer').slice(0, 24), x: Math.round(Number(b.x) || 0), y: Math.round(Number(b.y) || 0), dir, frame: Math.max(0, Math.min(8, Number(b.frame) || 0)), sheet: String(b.sheet || 'soldier2').slice(0, 16), room: String(b.room || 'tavern').slice(0, 16), at: Date.now() }); + return send(res, 200, { ok: true, players: presenceList(pid) }); + } + if (path === '/agents/presence' && method === 'GET') return send(res, 200, { ok: true, players: presenceList(null) }); + // browse classes (public + own private) if (path === '/agents/classes' && method === 'GET') { const tag = url.searchParams.get('tag'); const q = (url.searchParams.get('q') || '').toLowerCase(); const mine = url.searchParams.get('mine'); diff --git a/src/RpgTavern.jsx b/src/RpgTavern.jsx index 4042972..a7dbd3b 100644 --- a/src/RpgTavern.jsx +++ b/src/RpgTavern.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { api } from './api.js'; // A playable top-down RPG tavern (Pokémon-style): walk your character around // with arrows/WASD (or the on-screen pad), bump up to NPCs and fixtures and @@ -21,6 +22,7 @@ export default function RpgTavern({ onAction, auth, stats }) { const [prompt, setPrompt] = useState(null); // current interactable label const [dialog, setDialog] = useState(null); // { who, line } const [ready, setReady] = useState(false); + const [online, setOnline] = useState(0); const authRef = useRef(auth); useEffect(() => { authRef.current = auth; }, [auth]); const statsRef = useRef(stats); useEffect(() => { statsRef.current = stats; }, [stats]); @@ -62,7 +64,7 @@ export default function RpgTavern({ onAction, auth, stats }) { return corners.some(([cx, cy]) => isSolid(Math.floor(cx / T), Math.floor(cy / T))); }; - const g = { player: { x: px(10), y: py(9), dir: 'up', moving: false, frame: 0, ft: 0 }, keys: new Set(), near: null, f: 0 }; + const g = { player: { x: px(10), y: py(9), dir: 'up', moving: false, frame: 0, ft: 0 }, keys: new Set(), near: null, f: 0, others: new Map() }; gameRef.current = g; const buildStatic = (buf) => { @@ -121,6 +123,18 @@ export default function RpgTavern({ onAction, auth, stats }) { ctx.drawImage(buf, 0, 0); // depth-sorted actors (npcs + player) by y const actors = npcs.map((n) => ({ y: py(n.ty), draw: () => drawChar(ctx, n.sheet, n.face, (Math.sin(g.f * 0.05 + n.tx) > 0.7 ? 1 : 0), px(n.tx), py(n.ty)) })); + // other online players (smooth-interpolated) with name labels + for (const [, o] of g.others) { + o.rx += (o.tx - o.rx) * 0.25; o.ry += (o.ty - o.ry) * 0.25; + const sk = SRC[o.sheet] ? o.sheet : 'soldier'; + actors.push({ y: o.ry, draw: () => { + drawChar(ctx, sk, o.dir, o.frame || 0, o.rx, o.ry); + ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; + const tw = ctx.measureText(o.name).width; + ctx.fillStyle = 'rgba(10,7,18,0.74)'; ctx.fillRect(o.rx - tw / 2 - 4, o.ry - 70, tw + 8, 13); + ctx.fillStyle = '#9af0ff'; ctx.fillText(o.name, o.rx, o.ry - 60); ctx.textAlign = 'left'; + } }); + } actors.push({ y: p.y, draw: () => drawChar(ctx, 'soldier2', p.dir, p.frame, p.x, p.y) }); actors.sort((a, b) => a.y - b.y).forEach((a) => a.draw()); // interaction marker over the nearest target @@ -154,6 +168,28 @@ export default function RpgTavern({ onAction, auth, stats }) { return () => { window.removeEventListener('keydown', onDown); window.removeEventListener('keyup', onUp); }; }, [dialog]); + // online presence — broadcast my position, see other visitors + useEffect(() => { + let pid = ''; + try { pid = localStorage.getItem('amerc_pid') || ''; if (!pid) { pid = 'p_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('amerc_pid', pid); } } + catch { pid = 'p_' + Math.random().toString(36).slice(2, 10); } + let alive = true; + const tick = async () => { + const g = gameRef.current; if (!g) return; + const p = g.player; const name = authRef.current?.user?.handle || 'wanderer'; + try { + const d = await api('/agents/presence', { method: 'POST', body: { id: pid, name, x: Math.round(p.x), y: Math.round(p.y), dir: p.dir, frame: p.frame, sheet: 'soldier2', room: 'tavern' } }); + if (!alive) return; + const m = g.others; const seen = new Set(); + for (const o of d.players || []) { seen.add(o.id); let e = m.get(o.id); if (!e) e = { rx: o.x, ry: o.y }; e.tx = o.x; e.ty = o.y; e.dir = o.dir; e.frame = o.frame; e.name = o.name; e.sheet = o.sheet; m.set(o.id, e); } + for (const k of [...m.keys()]) if (!seen.has(k)) m.delete(k); + setOnline((d.players || []).length + 1); + } catch { /* offline-tolerant */ } + }; + const iv = setInterval(tick, 600); tick(); + return () => { alive = false; clearInterval(iv); }; + }, []); + // on-screen controls (mobile) const hold = (d, on) => { const g = gameRef.current; if (!g) return; if (on) g.keys.add(d); else g.keys.delete(d); }; const tapInteract = () => { const g = gameRef.current; if (!g) return; if (dialog) { setDialog(null); return; } if (g.near) g.near.interact(); }; @@ -169,6 +205,7 @@ export default function RpgTavern({ onAction, auth, stats }) { )}
↑↓←→ / WASD to walk · E to interact
+
👥 {online} in the tavern