From 3b9282756388d2db60624a24f56f7c4ce29923ad Mon Sep 17 00:00:00 2001 From: artheru Date: Thu, 11 Jun 2026 15:43:08 +0800 Subject: [PATCH] chat: player-to-player tavern chat (4.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: in-memory ring buffer POST/GET /agents/tavern-chat {id,name,text}, last ~60, control-char sanitized - RpgTavern: Enter/T (or πŸ’¬) opens a chat bar; your line floats as a canvas speech bubble above your avatar for ~5.5s AND lands in a small chat log; others' bubbles render above their avatars (keyed by presence id); typing freezes movement - verified across two browsers: A's message appeared in B's log and as a bubble over A's avatar Co-Authored-By: Claude Fable 5 --- index.html | 2 +- package.json | 2 +- server/amerc-api.mjs | 16 ++++++++++ src/RpgTavern.jsx | 74 ++++++++++++++++++++++++++++++++++++++++---- src/Scene2D.jsx | 3 +- src/styles.css | 10 ++++++ 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index 92858c7..2c82411 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 85d2179..d95fe49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "4.2.0-world", + "version": "4.3.0-chat", "private": true, "type": "module", "scripts": { diff --git a/server/amerc-api.mjs b/server/amerc-api.mjs index 353f947..1852692 100644 --- a/server/amerc-api.mjs +++ b/server/amerc-api.mjs @@ -133,6 +133,8 @@ function presenceList(exclude) { 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); } +// tavern chat: a tiny in-memory ring buffer of recent player messages. +const tavernChat = []; let chatSeq = 0; // 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; @@ -544,6 +546,20 @@ const server = http.createServer(async (req, res) => { } if (path === '/agents/presence' && method === 'GET') return send(res, 200, { ok: true, players: presenceList(null) }); + // tavern chat (public lobby chat β€” bubbles + a small log) + if (path === '/agents/tavern-chat' && method === 'POST') { + const b = await readBody(req) || {}; + const text = String(b.text || '').replace(/[-]/g, ' ').trim().slice(0, 200); + if (!text) return send(res, 400, { ok: false, error: 'text required' }); + const msg = { seq: ++chatSeq, id: String(b.id || '').slice(0, 40), name: String(b.name || 'wanderer').slice(0, 24), text, at: Date.now() }; + tavernChat.push(msg); if (tavernChat.length > 60) tavernChat.shift(); + return send(res, 200, { ok: true, seq: msg.seq }); + } + if (path === '/agents/tavern-chat' && method === 'GET') { + const after = Number(url.searchParams.get('after')) || 0; + return send(res, 200, { ok: true, messages: tavernChat.filter((m) => m.seq > after).slice(-40), seq: chatSeq }); + } + // 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 dd99268..e6c92a3 100644 --- a/src/RpgTavern.jsx +++ b/src/RpgTavern.jsx @@ -18,6 +18,7 @@ const randomName = () => `${NAME_ADJ[Math.floor(Math.random() * NAME_ADJ.length) const tile = (ctx, s, c, r, dx, dy, tw = T, th = T) => ctx.drawImage(s, c * T, r * T, tw, th, dx, dy, tw, th); const chr = (ctx, sheet, col, row, dx, dy) => ctx.drawImage(sheet, col * 64, row * 64, 64, 64, dx, dy, 64, 64); +const roundRect = (c, x, y, w, h, r) => { c.beginPath(); c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r); c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath(); }; export default function RpgTavern({ onAction, auth, stats }) { const canvasRef = useRef(null); @@ -27,7 +28,16 @@ export default function RpgTavern({ onAction, auth, stats }) { const [ready, setReady] = useState(false); const [online, setOnline] = useState(0); const [myName, setMyName] = useState(''); + const [chatOpen, setChatOpen] = useState(false); + const [chatText, setChatText] = useState(''); + const [chatLog, setChatLog] = useState([]); const nameRef = useRef(''); + const pidRef = useRef(''); + if (!pidRef.current) { try { let v = localStorage.getItem('amerc_pid'); if (!v) { v = 'p_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('amerc_pid', v); } pidRef.current = v; } catch { pidRef.current = 'p_' + Math.random().toString(36).slice(2, 10); } } + const saysRef = useRef(new Map()); // id -> { text, until } + const chatTypingRef = useRef(false); + const lastChatSeqRef = useRef(0); + const chatInputRef = useRef(null); const dialogRef = useRef(null); useEffect(() => { dialogRef.current = dialog; }, [dialog]); const authRef = useRef(auth); useEffect(() => { authRef.current = auth; }, [auth]); const statsRef = useRef(stats); useEffect(() => { statsRef.current = stats; }, [stats]); @@ -38,6 +48,14 @@ export default function RpgTavern({ onAction, auth, stats }) { }, [auth]); const rename = () => { if (auth?.user) return; const v = (window.prompt('Your tavern name', myName) || '').trim().slice(0, 20); if (v) { nameRef.current = v; setMyName(v); try { localStorage.setItem('amerc_name', v); } catch { /* private mode */ } } }; const advanceDialog = () => setDialog((d) => (d && d.i < d.lines.length - 1 ? { ...d, i: d.i + 1 } : null)); + const openChat = () => { const g = gameRef.current; if (g) g.keys.clear(); setChatOpen(true); setTimeout(() => chatInputRef.current?.focus(), 0); }; + const sendChat = async () => { + const t = chatText.trim(); setChatText(''); + if (!t) { setChatOpen(false); chatInputRef.current?.blur(); return; } + saysRef.current.set(pidRef.current, { text: t, until: Date.now() + 5500 }); // optimistic bubble + try { await api('/agents/tavern-chat', { method: 'POST', body: { id: pidRef.current, name: nameRef.current, text: t } }); } catch { /* offline */ } + }; + const onChatKey = (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); sendChat(); } else if (e.key === 'Escape') { e.preventDefault(); setChatOpen(false); chatInputRef.current?.blur(); } }; useEffect(() => { const S = {}; const keys = Object.keys(SRC); let loaded = 0, raf, paused = false, io; @@ -78,7 +96,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, others: new Map() }; + 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(), says: saysRef.current }; gameRef.current = g; const buildStatic = (buf) => { @@ -158,6 +176,19 @@ export default function RpgTavern({ onAction, auth, stats }) { for (const [lx, ly, lr] of [[40, 96, 40], [560, 96, 40], [W / 2, 60, 70]]) { const gg = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr); gg.addColorStop(0, 'rgba(255,180,90,0.13)'); gg.addColorStop(1, 'rgba(255,170,80,0)'); ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(lx, ly, lr, 0, 7); ctx.fill(); } ctx.globalCompositeOperation = 'source-over'; const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.4, W / 2, H / 2, H * 0.85); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(8,4,10,0.42)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); + // chat speech bubbles (crisp, over everything) + const sayNow = Date.now(); + const drawSay = (cx, feetY, text) => { + ctx.font = '10px sans-serif'; + const tx = text.length > 28 ? text.slice(0, 27) + '…' : text; + const tw = ctx.measureText(tx).width, bw = tw + 14, bx = cx - bw / 2, by = feetY - 80; + ctx.fillStyle = 'rgba(250,246,236,0.97)'; roundRect(ctx, bx, by, bw, 18, 6); ctx.fill(); + ctx.beginPath(); ctx.moveTo(cx - 4, by + 17); ctx.lineTo(cx + 4, by + 17); ctx.lineTo(cx, by + 23); ctx.closePath(); ctx.fill(); + ctx.fillStyle = '#241509'; ctx.textAlign = 'center'; ctx.fillText(tx, cx, by + 13); ctx.textAlign = 'left'; + }; + const mine = g.says.get(pidRef.current); + if (mine && mine.until > sayNow) drawSay(p.x, p.y, mine.text); + for (const [oid, o] of g.others) { const s = g.says.get(oid); if (s && s.until > sayNow) drawSay(o.rx, o.ry, s.text); } raf = requestAnimationFrame(render); }; render(); @@ -174,8 +205,10 @@ export default function RpgTavern({ onAction, auth, stats }) { const interact = () => { const g = gameRef.current; if (!g) return; if (dialog) { advanceDialog(); return; } if (g.near) g.near.interact(); }; const onDown = (e) => { const g = gameRef.current; if (!g) return; + if (chatTypingRef.current) return; // typing in chat β€” let the input handle keys if (KEY[e.key]) { e.preventDefault(); g.keys.add(KEY[e.key]); } - else if (e.key === 'e' || e.key === 'E' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); interact(); } + else if (e.key === 'e' || e.key === 'E' || e.key === ' ') { e.preventDefault(); interact(); } + else if (e.key === 'Enter' || e.key === 't' || e.key === 'T') { e.preventDefault(); openChat(); } }; const onUp = (e) => { const g = gameRef.current; if (g && KEY[e.key]) g.keys.delete(KEY[e.key]); }; window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp); @@ -184,9 +217,7 @@ export default function RpgTavern({ onAction, auth, stats }) { // 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); } + const pid = pidRef.current; let alive = true; const tick = async () => { const g = gameRef.current; if (!g) return; @@ -204,6 +235,23 @@ export default function RpgTavern({ onAction, auth, stats }) { return () => { alive = false; clearInterval(iv); }; }, []); + // tavern chat poll β€” fill speech bubbles + the log + useEffect(() => { + let alive = true; + const tick = async () => { + try { + const d = await api(`/agents/tavern-chat?after=${lastChatSeqRef.current}`); + if (!alive || !d.messages?.length) return; + lastChatSeqRef.current = d.seq || lastChatSeqRef.current; + const now = Date.now(); + for (const m of d.messages) saysRef.current.set(m.id, { text: m.text, until: now + 5500 }); + setChatLog((prev) => [...prev, ...d.messages].slice(-40)); + } catch { /* offline */ } + }; + const iv = setInterval(tick, 800); 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) { advanceDialog(); return; } if (g.near) g.near.interact(); }; @@ -219,8 +267,22 @@ export default function RpgTavern({ onAction, auth, stats }) { β–Έ {dialog.i < dialog.lines.length - 1 ? 'E / tap β€” more' : 'E / tap β€” close'} )} -
↑↓←→ / WASD to walk Β· E to interact
+
↑↓←→ / WASD walk Β· E interact Β· Enter chat
πŸ‘₯ {online} in the tavern
+ {chatLog.length > 0 && ( +
+ {chatLog.slice(-5).map((m, i) =>
{m.name}: {m.text}
)} +
+ )} + {chatOpen && ( +
{ e.preventDefault(); sendChat(); }}> + setChatText(e.target.value)} onKeyDown={onChatKey} + onFocus={() => { chatTypingRef.current = true; }} onBlur={() => { chatTypingRef.current = false; setChatOpen(false); }} /> + +
+ )} +