amerc/src/RpgTavern.jsx
artheru a22e5407ab 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>
2026-06-11 15:17:59 +08:00

235 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// press E to interact — each one opens a part of amerc. LPC tiles + 64px
// chibi character sheets (9 frames × 4 dirs; rows up/left/down/right).
const T = 32, COLS = 21, ROWS = 12, W = COLS * T, H = ROWS * T;
const L = '/scene2d/lpc/';
const SRC = { inside: L + 'inside.png', floors: L + 'castlefloors.png', vic: L + 'victoria.png', barrel: L + 'barrel.png', soldier: L + 'soldier.png', soldier2: L + 'soldier_altcolor.png', princess: L + 'princess.png' };
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 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 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 chr = (ctx, sheet, col, row, dx, dy) => ctx.drawImage(sheet, col * 64, row * 64, 64, 64, dx, dy, 64, 64);
export default function RpgTavern({ onAction, auth, stats }) {
const canvasRef = useRef(null);
const gameRef = useRef(null);
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 [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 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(() => {
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
const solid = new Set();
const mark = (tx, ty) => solid.add(tx + ',' + ty);
for (let x = 0; x < COLS; x++) { mark(x, 0); mark(x, 1); mark(x, 2); mark(x, ROWS - 1); } // top wall + seam, bottom
for (let y = 0; y < ROWS; y++) { mark(0, y); mark(COLS - 1, y); } // side walls
const bar = { x0: 2, x1: 7, y: 3 }; for (let x = bar.x0; x <= bar.x1; x++) mark(x, bar.y); // bar counter
[[2, 8], [3, 8], [16, 8], [17, 8]].forEach(([x, y]) => mark(x, y)); // tables
[[18, 9], [19, 9]].forEach(([x, y]) => mark(x, y)); // barrels
const px = (tx) => tx * T + T / 2, py = (ty) => ty * T + T / 2;
const user = () => authRef.current?.user;
const npcs = [
{ sheet: 'soldier', dir: 'down', tx: 4, ty: 2, name: 'Barkeep', face: 'down',
interact: () => { const u = user(); if (u) onAction('legion'); else onAction('login'); }, label: () => (user() ? '🍺 To your Legion' : '🍺 Sign in') },
{ sheet: 'princess', dir: 'down', tx: 11, ty: 6, name: 'Elf host', face: 'down',
interact: () => onAction('tour'), label: () => '✦ What is amerc?' },
{ sheet: 'soldier2', dir: 'up', tx: 3, ty: 9, name: 'Dwarf patron', face: 'up',
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',
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
const fixtures = [
{ tx: 9.5, ty: 1.6, name: 'Roster board', interact: () => onAction('agents'), label: () => '⚔ Browse Agents' },
{ tx: 18, ty: 2, name: 'Legion banner', interact: () => onAction('legion'), label: () => '🚩 My Legion' },
{ tx: 19.4, ty: 6, name: 'Estate door', interact: () => onAction('mansion'), label: () => '🏰 My Mansion' },
];
const targets = [...npcs.map((n) => ({ ...n, cx: px(n.tx), cy: py(n.ty) })), ...fixtures.map((f) => ({ ...f, cx: px(f.tx), cy: py(f.ty) }))];
const isSolid = (tx, ty) => solid.has(tx + ',' + ty);
const blocked = (fx, fy) => { // feet box ~22x12 centred on (fx,fy-2)
const corners = [[fx - 10, fy - 10], [fx + 10, fy - 10], [fx - 10, fy], [fx + 10, fy]];
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() };
gameRef.current = g;
const buildStatic = (buf) => {
const ctx = buf.getContext('2d'); ctx.imageSmoothingEnabled = false;
for (let y = 2; y < ROWS; y++) for (let x = 0; x < COLS; x++) tile(ctx, S.floors, FLOOR[0], FLOOR[1], x * T, y * T);
for (let y = 0; y < 2; y++) for (let x = 0; x < COLS; x++) tile(ctx, S.inside, WALL[0], WALL[1], x * T, y * T);
ctx.fillStyle = '#6b4326'; ctx.fillRect(0, 2 * T - 5, W, 7); ctx.fillStyle = '#8a5a32'; ctx.fillRect(0, 2 * T - 5, W, 2); ctx.fillStyle = '#2a1810'; ctx.fillRect(0, 2 * T + 2, W, 2);
for (const wx of [40, 560]) { ctx.fillStyle = '#13233f'; ctx.fillRect(wx, 8, 40, 44); ctx.fillStyle = '#cfe0ff'; for (let i = 0; i < 8; i++) ctx.fillRect(wx + 4 + (i * 11) % 36, 10 + (i * 7) % 40, 2, 2); ctx.fillStyle = '#5a3a1c'; ctx.fillRect(wx - 3, 6, 46, 3); tile(ctx, S.vic, CURTAIN[0], CURTAIN[1], wx - 18, 2); tile(ctx, S.vic, CURTAIN2[0], CURTAIN2[1], wx + 28, 2); }
// rug centre
const rx = 8 * T, ry = 6 * T, rc = 4, rr = 3;
for (let j = 0; j < rr; j++) for (let i = 0; i < rc; i++) { const k = i === 0 ? 'l' : i === rc - 1 ? 'r' : 'c'; const kk = j === 0 ? (k === 'l' ? 'tl' : k === 'r' ? 'tr' : 't') : j === rr - 1 ? (k === 'l' ? 'bl' : k === 'r' ? 'br' : 'b') : k; tile(ctx, S.floors, RUG[kk][0], RUG[kk][1], rx + i * T, ry + j * T); }
// bar counter
ctx.fillStyle = '#5e3a22'; ctx.fillRect(bar.x0 * T, bar.y * T + 6, (bar.x1 - bar.x0 + 1) * T, 26); ctx.fillStyle = '#8a5a32'; ctx.fillRect(bar.x0 * T, bar.y * T + 6, (bar.x1 - bar.x0 + 1) * T, 4); ctx.fillStyle = '#2a1810'; ctx.fillRect(bar.x0 * T, bar.y * T + 30, (bar.x1 - bar.x0 + 1) * T, 4);
for (const bxp of [3, 5, 7]) tile(ctx, S.vic, VASE[0], VASE[1], bxp * T + 6, bar.y * T - 14, 20, 20);
// tables + chairs
for (const tx of [2, 16]) { tile(ctx, S.vic, CHAIR[0], CHAIR[1], tx * T - 4, 8 * T - 6); tile(ctx, S.vic, CHAIR_R[0], CHAIR_R[1], tx * T + 36, 8 * T - 6); tile(ctx, S.vic, TABLE[0], TABLE[1], tx * T + 16, 8 * T + 2); }
// barrels + plants + lamps
tile(ctx, S.barrel, 0, 0, 18 * T, 9 * T - 4); tile(ctx, S.barrel, 1, 0, 19 * T, 9 * T); tile(ctx, S.vic, PLANT[0], PLANT[1], 1 * T, 9 * T); tile(ctx, S.vic, LAMP[0], LAMP[1], 1 * T, 3 * T);
// roster board (glowing screen) on the top wall
ctx.fillStyle = '#0c2030'; ctx.fillRect(9 * T - 4, 6, 4 * T, 44); ctx.strokeStyle = '#46c8e0'; ctx.lineWidth = 2; ctx.strokeRect(9 * T - 3, 7, 4 * T - 2, 42); ctx.fillStyle = '#16415e'; ctx.fillRect(9 * T, 10, 4 * T - 8, 36);
ctx.fillStyle = '#9af0ff'; ctx.font = 'bold 11px Georgia, serif'; ctx.textAlign = 'center'; ctx.fillText('BROWSE AGENTS', 11 * T, 26); ctx.font = '8px Georgia'; ctx.fillStyle = '#7fd9ea'; ctx.fillText('the roster', 11 * T, 38); ctx.textAlign = 'left';
// legion banner (right wall)
ctx.fillStyle = '#2a1d4e'; ctx.beginPath(); ctx.moveTo(18 * T, 2 * T - 8); ctx.lineTo(18 * T + 28, 2 * T - 8); ctx.lineTo(18 * T + 28, 2 * T + 26); ctx.lineTo(18 * T + 14, 2 * T + 36); ctx.lineTo(18 * T, 2 * T + 26); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#ffd75e'; ctx.fillRect(18 * T + 11, 2 * T + 2, 6, 12); ctx.fillRect(18 * T + 7, 2 * T + 5, 14, 5);
// estate door (right wall)
ctx.fillStyle = '#241a12'; ctx.fillRect(20 * T - 8, 5 * T, 12, 3 * T); ctx.fillStyle = '#5a3a1c'; ctx.fillRect(20 * T - 6, 5 * T + 4, 8, 3 * T - 8); ctx.fillStyle = 'rgba(127,233,255,0.5)'; ctx.fillRect(20 * T - 5, 5 * T + 6, 6, 3 * T - 12);
};
const drawChar = (ctx, sheetKey, dir, frameCol, cx, cy) => chr(ctx, S[sheetKey], frameCol, DIR[dir], cx - 32, cy - 56);
const start = () => {
setReady(true);
const buf = document.createElement('canvas'); buf.width = W; buf.height = H; buildStatic(buf);
const ctx = canvasRef.current.getContext('2d'); ctx.imageSmoothingEnabled = false;
let lastNear = '__';
const render = () => {
if (paused) return;
g.f++; const p = g.player;
// movement
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;
p.moving = (dx || dy) && !dialogRef.current;
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';
const sp = 1.7, nx = p.x + dx * sp, ny = p.y + dy * sp;
if (!blocked(nx, p.y)) p.x = nx;
if (!blocked(p.x, ny)) p.y = ny;
p.ft++; if (p.ft >= 6) { p.ft = 0; p.frame = 1 + ((p.frame) % 8); }
} else { p.frame = 0; }
// nearest interactable
let near = null, best = 60 * 60;
for (const t of targets) { const d = (t.cx - p.x) ** 2 + (t.cy - (p.y - 18)) ** 2; if (d < best) { best = d; near = t; } }
g.near = near;
const lbl = near ? near.label() : null;
if ((lbl || '__') !== lastNear) { lastNear = lbl || '__'; setPrompt(lbl); }
// draw
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, (!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
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
if (near) { const bob = Math.sin(g.f * 0.18) * 2; ctx.fillStyle = '#ffd75e'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('▾', near.cx, near.cy - 40 + bob); ctx.textAlign = 'left'; }
// warm light + vignette
ctx.globalCompositeOperation = 'lighter';
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);
raf = requestAnimationFrame(render);
};
render();
io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { if (paused) { paused = false; render(); } } else if (!paused) { paused = true; cancelAnimationFrame(raf); } }, { threshold: 0 });
io.observe(canvasRef.current);
};
keys.forEach((k) => { const im = new Image(); im.onload = () => { if (++loaded === keys.length) start(); }; im.src = SRC[k]; S[k] = im; });
return () => { cancelAnimationFrame(raf); if (io) io.disconnect(); };
}, [onAction]);
// keyboard
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 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 (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(); }
};
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);
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 = nameRef.current || 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) { advanceDialog(); return; } if (g.near) g.near.interact(); };
return (
<div className="rpg gm-room">
<canvas ref={canvasRef} width={W} height={H} className="rpg-canvas" role="img" aria-label="Playable tavern — walk with arrow keys and press E to interact" />
{!ready && <div className="gm-loading">loading tavern</div>}
{prompt && !dialog && <div className="rpg-prompt"><b>E</b> {prompt}</div>}
{dialog && (
<div className="rpg-dialog" onClick={advanceDialog}>
<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 className="rpg-help"> / WASD to walk · <b>E</b> to interact</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">
<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-right" onPointerDown={() => hold('right', true)} onPointerUp={() => hold('right', false)} onPointerLeave={() => hold('right', false)}></button>
<button className="rpg-down" onPointerDown={() => hold('down', true)} onPointerUp={() => hold('down', false)} onPointerLeave={() => hold('down', false)}></button>
</div>
<button className="rpg-a" onClick={tapInteract} aria-label="Interact">E</button>
</div>
);
}