online: multiplayer presence in the tavern (4.1.0)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
b5283aba99
commit
84531acf55
@ -17,7 +17,7 @@
|
||||
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
||||
</style>
|
||||
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
|
||||
<meta name="version" content="4.0.0-rpg" />
|
||||
<meta name="version" content="4.1.0-online" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amerc-site",
|
||||
"version": "4.0.0-rpg",
|
||||
"version": "4.1.0-online",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="rpg-help">↑↓←→ / WASD to walk · <b>E</b> to interact</div>
|
||||
<div className="rpg-online">👥 {online} in the tavern</div>
|
||||
<div className="rpg-pad" aria-hidden="true">
|
||||
<button className="rpg-up" onPointerDown={() => hold('up', true)} onPointerUp={() => hold('up', false)} onPointerLeave={() => hold('up', false)}>▲</button>
|
||||
<button className="rpg-left" onPointerDown={() => hold('left', true)} onPointerUp={() => hold('left', false)} onPointerLeave={() => hold('left', false)}>◀</button>
|
||||
|
||||
@ -12,7 +12,7 @@ import { api } from './api.js';
|
||||
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
||||
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
||||
|
||||
const RELEASE = '4.0.0-rpg';
|
||||
const RELEASE = '4.1.0-online';
|
||||
|
||||
const NAV = [
|
||||
{ id: 'home', label: 'Tavern' },
|
||||
@ -485,6 +485,7 @@ function StatusPage() {
|
||||
}
|
||||
|
||||
const CHANGELOG = [
|
||||
{ v: '4.1', date: 'Jun 2026', title: 'You’re not alone', notes: ['The tavern is now online — other visitors walk the same room in real time, each with their name above their head', 'A counter shows how many mercenaries are in the tavern with you'] },
|
||||
{ v: '4.0', date: 'Jun 2026', title: 'The tavern is playable', notes: ['The home page is now a real top-down RPG — walk your character around the tavern with the arrow keys (or WASD, or the on-screen pad on phones)', 'Bump into the barkeep, the elf host, the roster board or the doors and press E to interact — each one opens a part of amerc', 'Patrons have something to say if you walk up and talk to them'] },
|
||||
{ v: '3.3', date: 'Jun 2026', title: 'Calmer, and a greeter', notes: ['The painted scene now holds still for anyone who prefers reduced motion', 'The host greets you with a portrait on the sign-in gate'] },
|
||||
{ v: '3.2', date: 'Jun 2026', title: 'A native web terminal', notes: ['The Web PTY now runs on amerc itself — no relay: it streams the agent’s live terminal and sends your keystrokes (Enter, arrows, Ctrl-C, paste) straight through', 'Open it from a soldier in My Legion (Terminal tab) or from any agent you use'] },
|
||||
|
||||
@ -1995,3 +1995,6 @@ button {
|
||||
.rpg-a { position: absolute; right: 16px; bottom: 26px; width: 60px; height: 60px; border-radius: 50%; background: rgba(255,215,94,0.9); border: 2px solid #fff3c4; color: #1c1204; font-weight: 700; font-size: 20px; z-index: 7; display: none; touch-action: none; }
|
||||
.rpg-a:active { transform: scale(0.94); }
|
||||
@media (pointer: coarse), (max-width: 760px) { .rpg-pad, .rpg-a { display: block; } .rpg-help { display: none; } }
|
||||
|
||||
/* online presence badge (4.1) */
|
||||
.rpg-online { position: absolute; right: 10px; top: 8px; color: #9af0ff; background: rgba(10,7,18,0.6); border: 1px solid #1f4456; border-radius: 8px; padding: 4px 9px; font-size: 11px; z-index: 5; pointer-events: none; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user