- 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>
235 lines
18 KiB
JavaScript
235 lines
18 KiB
JavaScript
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>
|
||
);
|
||
}
|