world: distinct names, richer NPC dialog, reduced-motion (4.2.0)

- each logged-out player gets a random fantasy name (Bold Dwarf, Lucky
  Elf...) stored in localStorage; click the name badge to set your own
- NPC dialog is now multi-line, advanced with E/tap (the patrons have
  flavor to share); movement freezes while a dialog is open
- NPC idle bustle holds still under prefers-reduced-motion
- verified: random name badge, 3-line dwarf dialog advancing then closing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 15:17:59 +08:00
parent 84531acf55
commit a22e5407ab
5 changed files with 34 additions and 12 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style> </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="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.1.0-online" /> <meta name="version" content="4.2.0-world" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" /> <link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "4.1.0-online", "version": "4.2.0-world",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -12,6 +12,9 @@ const WALL = [0, 4], FLOOR = [0, 7];
const RUG = { tl: [1, 6], t: [2, 6], tr: [3, 6], l: [1, 7], c: [2, 7], r: [3, 7], bl: [1, 8], b: [2, 8], br: [3, 8] }; const RUG = { tl: [1, 6], t: [2, 6], tr: [3, 6], l: [1, 7], c: [2, 7], r: [3, 7], bl: [1, 8], b: [2, 8], br: [3, 8] };
const TABLE = [4, 3], CHAIR = [6, 0], CHAIR_R = [7, 0], PLANT = [2, 3], LAMP = [0, 3], VASE = [3, 3], CURTAIN = [8, 0], CURTAIN2 = [9, 0]; const TABLE = [4, 3], CHAIR = [6, 0], CHAIR_R = [7, 0], PLANT = [2, 3], LAMP = [0, 3], VASE = [3, 3], CURTAIN = [8, 0], CURTAIN2 = [9, 0];
const DIR = { up: 0, left: 1, down: 2, right: 3 }; const DIR = { up: 0, left: 1, down: 2, right: 3 };
const NAME_ADJ = ['Bold', 'Sly', 'Iron', 'Swift', 'Grim', 'Lucky', 'Wise', 'Wild', 'Brave', 'Crimson', 'Shadow', 'Stout', 'Keen', 'Wandering', 'Ember'];
const NAME_NOUN = ['Dwarf', 'Ranger', 'Mage', 'Orc', 'Elf', 'Knight', 'Rogue', 'Bard', 'Smith', 'Scout', 'Brawler', 'Merc', 'Sage', 'Goblin', 'Wayfarer'];
const randomName = () => `${NAME_ADJ[Math.floor(Math.random() * NAME_ADJ.length)]} ${NAME_NOUN[Math.floor(Math.random() * NAME_NOUN.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 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 chr = (ctx, sheet, col, row, dx, dy) => ctx.drawImage(sheet, col * 64, row * 64, 64, 64, dx, dy, 64, 64);
@ -23,11 +26,22 @@ export default function RpgTavern({ onAction, auth, stats }) {
const [dialog, setDialog] = useState(null); // { who, line } const [dialog, setDialog] = useState(null); // { who, line }
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [online, setOnline] = useState(0); const [online, setOnline] = useState(0);
const [myName, setMyName] = useState('');
const nameRef = useRef('');
const dialogRef = useRef(null); useEffect(() => { dialogRef.current = dialog; }, [dialog]);
const authRef = useRef(auth); useEffect(() => { authRef.current = auth; }, [auth]); const authRef = useRef(auth); useEffect(() => { authRef.current = auth; }, [auth]);
const statsRef = useRef(stats); useEffect(() => { statsRef.current = stats; }, [stats]); const statsRef = useRef(stats); useEffect(() => { statsRef.current = stats; }, [stats]);
useEffect(() => {
let n = auth?.user?.handle;
if (!n) { try { n = localStorage.getItem('amerc_name') || ''; if (!n) { n = randomName(); localStorage.setItem('amerc_name', n); } } catch { n = randomName(); } }
nameRef.current = n; setMyName(n);
}, [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));
useEffect(() => { useEffect(() => {
const S = {}; const keys = Object.keys(SRC); let loaded = 0, raf, paused = false, io; const S = {}; const keys = Object.keys(SRC); let loaded = 0, raf, paused = false, io;
const reduceMotion = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// collision grid + interactables // collision grid + interactables
const solid = new Set(); const solid = new Set();
@ -46,9 +60,9 @@ export default function RpgTavern({ onAction, auth, stats }) {
{ sheet: 'princess', dir: 'down', tx: 11, ty: 6, name: 'Elf host', face: 'down', { sheet: 'princess', dir: 'down', tx: 11, ty: 6, name: 'Elf host', face: 'down',
interact: () => onAction('tour'), label: () => '✦ What is amerc?' }, interact: () => onAction('tour'), label: () => '✦ What is amerc?' },
{ sheet: 'soldier2', dir: 'up', tx: 3, ty: 9, name: 'Dwarf patron', face: 'up', { sheet: 'soldier2', dir: 'up', tx: 3, ty: 9, name: 'Dwarf patron', face: 'up',
interact: () => setDialog({ who: 'Patron', line: 'I field three mercenaries straight from my Legion. Talk to the barkeep to raise yours.' }), label: () => '💬 Chat' }, interact: () => setDialog({ who: 'Borin the Dwarf', i: 0, lines: ['I field three mercenaries straight from my Legion.', 'Costs me nothing — my own broker brings the runtime, no servers to rent.', 'Walk up to the barkeep if you mean to raise your own band.'] }), label: () => '💬 Talk' },
{ sheet: 'soldier2', dir: 'up', tx: 16, ty: 9, name: 'Patron', face: 'up', { sheet: 'soldier2', dir: 'up', tx: 16, ty: 9, name: 'Patron', face: 'up',
interact: () => setDialog({ who: 'Patron', line: 'Showcase put my little app on the open internet — ask about the Mansion.' }), label: () => '💬 Chat' }, interact: () => setDialog({ who: 'Mira the Ranger', i: 0, lines: ['Showcase put my little app on the open internet.', 'A local port, tunnelled to a public URL through my broker — TLS and all.', 'The estate door is on the east wall. Go have a look.'] }), label: () => '💬 Talk' },
]; ];
// wall fixtures (drawn in static buffer) with interaction zones // wall fixtures (drawn in static buffer) with interaction zones
const fixtures = [ const fixtures = [
@ -105,7 +119,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
// movement // movement
let dx = 0, dy = 0; let dx = 0, dy = 0;
if (g.keys.has('left')) dx -= 1; if (g.keys.has('right')) dx += 1; if (g.keys.has('up')) dy -= 1; if (g.keys.has('down')) dy += 1; if (g.keys.has('left')) dx -= 1; if (g.keys.has('right')) dx += 1; if (g.keys.has('up')) dy -= 1; if (g.keys.has('down')) dy += 1;
p.moving = (dx || dy) && !dialog; p.moving = (dx || dy) && !dialogRef.current;
if (p.moving) { if (p.moving) {
if (dy < 0) p.dir = 'up'; else if (dy > 0) p.dir = 'down'; else if (dx < 0) p.dir = 'left'; else if (dx > 0) p.dir = 'right'; if (dy < 0) p.dir = 'up'; else if (dy > 0) p.dir = 'down'; else if (dx < 0) p.dir = 'left'; else if (dx > 0) p.dir = 'right';
const sp = 1.7, nx = p.x + dx * sp, ny = p.y + dy * sp; const sp = 1.7, nx = p.x + dx * sp, ny = p.y + dy * sp;
@ -122,7 +136,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
// draw // draw
ctx.drawImage(buf, 0, 0); ctx.drawImage(buf, 0, 0);
// depth-sorted actors (npcs + player) by y // 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)) })); const actors = npcs.map((n) => ({ y: py(n.ty), draw: () => drawChar(ctx, n.sheet, n.face, (!reduceMotion && 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 // other online players (smooth-interpolated) with name labels
for (const [, o] of g.others) { for (const [, o] of g.others) {
o.rx += (o.tx - o.rx) * 0.25; o.ry += (o.ty - o.ry) * 0.25; o.rx += (o.tx - o.rx) * 0.25; o.ry += (o.ty - o.ry) * 0.25;
@ -157,7 +171,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
// keyboard // keyboard
useEffect(() => { useEffect(() => {
const KEY = { ArrowLeft: 'left', a: 'left', A: 'left', ArrowRight: 'right', d: 'right', D: 'right', ArrowUp: 'up', w: 'up', W: 'up', ArrowDown: 'down', s: 'down', S: 'down' }; const KEY = { ArrowLeft: 'left', a: 'left', A: 'left', ArrowRight: 'right', d: 'right', D: 'right', ArrowUp: 'up', w: 'up', W: 'up', ArrowDown: 'down', s: 'down', S: 'down' };
const interact = () => { const g = gameRef.current; if (!g) return; if (dialog) { setDialog(null); return; } if (g.near) g.near.interact(); }; const interact = () => { const g = gameRef.current; if (!g) return; if (dialog) { advanceDialog(); return; } if (g.near) g.near.interact(); };
const onDown = (e) => { const onDown = (e) => {
const g = gameRef.current; if (!g) return; const g = gameRef.current; if (!g) return;
if (KEY[e.key]) { e.preventDefault(); g.keys.add(KEY[e.key]); } if (KEY[e.key]) { e.preventDefault(); g.keys.add(KEY[e.key]); }
@ -176,7 +190,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
let alive = true; let alive = true;
const tick = async () => { const tick = async () => {
const g = gameRef.current; if (!g) return; const g = gameRef.current; if (!g) return;
const p = g.player; const name = authRef.current?.user?.handle || 'wanderer'; const p = g.player; const name = nameRef.current || authRef.current?.user?.handle || 'wanderer';
try { 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' } }); 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; if (!alive) return;
@ -192,7 +206,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
// on-screen controls (mobile) // 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 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(); }; const tapInteract = () => { const g = gameRef.current; if (!g) return; if (dialog) { advanceDialog(); return; } if (g.near) g.near.interact(); };
return ( return (
<div className="rpg gm-room"> <div className="rpg gm-room">
@ -200,12 +214,14 @@ export default function RpgTavern({ onAction, auth, stats }) {
{!ready && <div className="gm-loading">loading tavern</div>} {!ready && <div className="gm-loading">loading tavern</div>}
{prompt && !dialog && <div className="rpg-prompt"><b>E</b> {prompt}</div>} {prompt && !dialog && <div className="rpg-prompt"><b>E</b> {prompt}</div>}
{dialog && ( {dialog && (
<div className="rpg-dialog" onClick={() => setDialog(null)}> <div className="rpg-dialog" onClick={advanceDialog}>
<strong>{dialog.who}</strong><p>{dialog.line}</p><span className="rpg-dialog-x"> press E / tap</span> <strong>{dialog.who}</strong><p>{dialog.lines[dialog.i]}</p>
<span className="rpg-dialog-x"> {dialog.i < dialog.lines.length - 1 ? 'E / tap — more' : 'E / tap — close'}</span>
</div> </div>
)} )}
<div className="rpg-help"> / WASD to walk · <b>E</b> to interact</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-online">👥 {online} in the tavern</div>
<button className="rpg-name" onClick={rename} title={auth?.user ? 'signed in' : 'click to rename'}>you: <b>{myName}</b>{!auth?.user && ' ✎'}</button>
<div className="rpg-pad" aria-hidden="true"> <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-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> <button className="rpg-left" onPointerDown={() => hold('left', true)} onPointerUp={() => hold('left', false)} onPointerLeave={() => hold('left', false)}></button>

View File

@ -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 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 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.1.0-online'; const RELEASE = '4.2.0-world';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },
@ -485,6 +485,7 @@ function StatusPage() {
} }
const CHANGELOG = [ const CHANGELOG = [
{ v: '4.2', date: 'Jun 2026', title: 'A livelier tavern', notes: ['Every wanderer gets their own name now — pick your own, or roll a random one (click the name badge)', 'The patrons have more to say if you walk up and talk', 'The idle bustle holds still for anyone who prefers reduced motion'] },
{ v: '4.1', date: 'Jun 2026', title: 'Youre 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.1', date: 'Jun 2026', title: 'Youre 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: '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.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'] },

View File

@ -1998,3 +1998,8 @@ button {
/* online presence badge (4.1) */ /* 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; } .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; }
/* player name badge (4.2) */
.rpg-name { position: absolute; right: 10px; top: 34px; color: #cbb8e8; background: rgba(10,7,18,0.6); border: 1px solid #2a2440; border-radius: 8px; padding: 4px 9px; font-size: 11px; z-index: 5; cursor: pointer; font-family: inherit; }
.rpg-name b { color: #9af0ff; }
.rpg-name:hover { border-color: #6a5a9a; }