home: the tavern becomes the interface (2.0.0)
Full-screen diegetic game home — the page IS the tavern: - grand glowing door = Browse Agents; bar/barkeep = sign-in (greets a logged-in user by name); wall banners = My Legion; villa side-exit = My Mansion; dancer pitches the tour in a holographic panel - persistent clickable speech bubbles (wood / holo / door variants), hover quips on patrons, live stats on the wall screen - GameDialog: ornate gem-cornered dialog frame; the tour (HomeFeatures) and login (THE LEDGER) open in-game instead of navigating away - stage scales to fill the viewport (768x432 buffer, pixelated upscale) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
27927879f0
commit
a350ae6884
@ -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="1.3.0-mansionfiles" />
|
||||
<meta name="version" content="2.0.0-tavern" />
|
||||
<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": "1.3.0-mansionfiles",
|
||||
"version": "2.0.0-tavern",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -10,7 +10,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 = '1.3.0-mansionfiles';
|
||||
const RELEASE = '2.0.0-tavern';
|
||||
|
||||
const NAV = [
|
||||
{ id: 'home', label: 'Tavern' },
|
||||
@ -108,28 +108,43 @@ function Reveal({ children, className = '', delay = 0, tag: Tag = 'div' }) {
|
||||
return <Tag ref={ref} className={`${className} ${armed ? 'reveal' : ''} ${shown ? 'in' : ''}`.replace(/\s+/g, ' ').trim()} style={delay && armed ? { transitionDelay: `${delay}ms` } : undefined}>{children}</Tag>;
|
||||
}
|
||||
|
||||
function Home({ setRoute }) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const homeRef = useRef(null);
|
||||
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
|
||||
// Ornate in-game dialog — every "detail view" opens in one of these.
|
||||
export function GameDialog({ title, tag, onClose, children, wide }) {
|
||||
useEffect(() => {
|
||||
const el = homeRef.current; if (!el) return;
|
||||
const onScroll = () => setScrolled(el.scrollTop > 500);
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div className="gm-home" ref={homeRef}>
|
||||
<div className="gm-hero">
|
||||
<div className="gm-stage"><TavernGame onHotspot={setRoute} stats={stats} /></div>
|
||||
<div className="gm-caption">
|
||||
<h1>Hire agents like mercenaries.</h1>
|
||||
<p>Hire, run, and deliver AI agents across any software boundary — as an embedded chatbox, a live web terminal, or a public URL. <b>No infrastructure, just skills.</b></p>
|
||||
<div className="gm-cta">
|
||||
<button className="gm-cta-btn gm-cta-primary" onClick={() => setRoute('agents')}>Browse Agents →</button>
|
||||
<button className="gm-cta-btn gm-cta-secondary" onClick={() => setRoute('legion')}>Publish yours</button>
|
||||
<div className="gd-backdrop" onClick={onClose}>
|
||||
<div className={`gd${wide ? ' gd-wide' : ''}`} onClick={(e) => e.stopPropagation()} role="dialog" aria-label={title}>
|
||||
<span className="gd-gem tl" aria-hidden="true" /><span className="gd-gem tr" aria-hidden="true" />
|
||||
<span className="gd-gem bl" aria-hidden="true" /><span className="gd-gem br" aria-hidden="true" />
|
||||
<header className="gd-head">
|
||||
{tag && <span className="gd-tag">{tag}</span>}
|
||||
<h3>{title}</h3>
|
||||
<button className="gd-x" onClick={onClose} type="button" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div className="gd-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Home({ setRoute, auth }) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [dlg, setDlg] = useState(null); // 'tour' | 'login' | null
|
||||
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); const t = setInterval(() => api('/agents/stats').then(setStats).catch(() => {}), 30000); return () => clearInterval(t); }, []);
|
||||
const act = useCallback((a) => {
|
||||
if (a === 'tour' || a === 'login') setDlg(a);
|
||||
else setRoute(a);
|
||||
}, [setRoute]);
|
||||
return (
|
||||
<div className="gm-home gm-game">
|
||||
<div className="gm-stage3"><TavernGame onAction={act} stats={stats} auth={auth} /></div>
|
||||
<div className="gm-bottombar">
|
||||
<h1 className="gm-tagline">Hire agents like mercenaries<span className="gm-cursor">_</span></h1>
|
||||
<p className="gm-tagsub">every fixture in the tavern is a door — walk in, or read the <button className="gm-link" onClick={() => setDlg('tour')}>tour</button></p>
|
||||
{stats && (
|
||||
<div className="hero-live">
|
||||
<span><b><Count n={stats.classes} /></b> classes</span>
|
||||
@ -137,12 +152,18 @@ function Home({ setRoute }) {
|
||||
<span><b><Count n={stats.sessions} /></b> sessions</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="gm-scrollcue" onClick={() => document.querySelector('.hf')?.scrollIntoView({ behavior: 'smooth' })}>how it works ↓</div>
|
||||
</div>
|
||||
</div>
|
||||
<HomeFeatures setRoute={setRoute} />
|
||||
<Footer setRoute={setRoute} />
|
||||
<button className={`gm-totop${scrolled ? ' show' : ''}`} onClick={() => homeRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} aria-label="Back to top" type="button">↑</button>
|
||||
{dlg === 'tour' && (
|
||||
<GameDialog title="How amerc works" tag="✦ THE TOUR" wide onClose={() => setDlg(null)}>
|
||||
<HomeFeatures setRoute={(r) => { setDlg(null); setRoute(r); }} />
|
||||
</GameDialog>
|
||||
)}
|
||||
{dlg === 'login' && (
|
||||
<GameDialog title="Welcome, mercenary" tag="🍺 THE LEDGER" onClose={() => setDlg(null)}>
|
||||
<p className="gd-lead">One account opens every door — tavern, docs, PM and git.</p>
|
||||
<AuthPanel auth={auth} onDone={() => { setDlg(null); setRoute('legion'); }} />
|
||||
</GameDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -477,6 +498,7 @@ function StatusPage() {
|
||||
}
|
||||
|
||||
const CHANGELOG = [
|
||||
{ v: '2.0', date: 'Jun 2026', title: 'The tavern becomes the interface', notes: ['The home page is now a full-screen game scene — every fixture is a door: the grand door is the roster, the bar signs you in, the banners are your Legion, the side passage leads to My Mansion', 'Speech bubbles everywhere: the dancer pitches the tour in a glowing holo-panel, the barkeep greets you by name', 'Details open in ornate in-game dialogs instead of plain pages'] },
|
||||
{ v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link — every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again — say hello to Kebab Webagent'] },
|
||||
{ v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] },
|
||||
{ v: '1.1', date: 'Jun 2026', title: 'Chat, rebuilt native', notes: ['Agent chat now runs on amerc itself — no external relay, so it works whenever the site is up', 'Send files and images both ways: relayed live with a progress bar, never stored on the server', 'The chat shows which skills the agent has loaded', 'Every session keeps its chatlog — reopen any conversation from My Legion'] },
|
||||
|
||||
@ -1,57 +1,102 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// High-res 2D RPG tavern — 32px LPC "Base Assets" tiles (CC-BY-SA) + animated 64px characters.
|
||||
const T = 32, W = 512, H = 352;
|
||||
// Full-screen 2D RPG tavern — the tavern IS the interface (diegetic UI):
|
||||
// the door is Browse Agents, the bar is sign-in, banners are your Legion,
|
||||
// the side exit leads to My Mansion. 32px LPC tiles + 64px characters on a
|
||||
// 768x432 stage, CSS-scaled to fill the viewport (pixelated).
|
||||
const T = 32, W = 768, H = 432;
|
||||
const L = '/scene2d/lpc/';
|
||||
const SRC = {
|
||||
inside: L + 'inside.png', floors: L + 'castlefloors.png', vic: L + 'victoria.png', barrel: L + 'barrel.png',
|
||||
princess: L + 'princess.png', soldier: L + 'soldier.png', soldier2: L + 'soldier_altcolor.png',
|
||||
};
|
||||
// tile picks [col,row] (32px)
|
||||
const WALL = [0, 4], FLOOR = [0, 7], DOOR = [[8, 1], [9, 1]];
|
||||
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];
|
||||
|
||||
function tile(ctx, s, c, r, dx, dy, tw = T, th = T) { ctx.drawImage(s, c * T, r * T, tw, th, dx, dy, tw, th); }
|
||||
// LPC character: down-facing row=2 (64px frames), frameCol 0=idle, 1..8 walk
|
||||
function chr(ctx, sheet, frameCol, dx, dy) { ctx.drawImage(sheet, frameCol * 64, 2 * 64, 64, 64, dx, dy, 64, 64); }
|
||||
function chr(ctx, sheet, frameCol, dx, dy, scale = 1) { ctx.drawImage(sheet, frameCol * 64, 2 * 64, 64, 64, dx, dy, 64 * scale, 64 * scale); }
|
||||
|
||||
// the grand door to the roster, drawn as architecture (diegetic Browse Agents)
|
||||
function drawDoor(ctx, f) {
|
||||
const x = 336, w = 96, h = 78;
|
||||
ctx.fillStyle = '#241a12'; ctx.fillRect(x - 10, 0, w + 20, h + 8); // arch frame
|
||||
ctx.fillStyle = '#3a2a1a'; ctx.fillRect(x - 6, 0, w + 12, h + 4);
|
||||
// glowing opening
|
||||
const pulse = 0.55 + 0.18 * Math.sin(f * 0.04);
|
||||
const g = ctx.createLinearGradient(0, 0, 0, h);
|
||||
g.addColorStop(0, `rgba(64, 200, 255, ${pulse})`); g.addColorStop(1, `rgba(20, 60, 120, ${pulse * 0.5})`);
|
||||
ctx.fillStyle = g; ctx.fillRect(x, 4, w, h - 4);
|
||||
// door planks ajar
|
||||
ctx.fillStyle = '#5a3a1c'; ctx.fillRect(x, 4, 16, h - 4); ctx.fillRect(x + w - 16, 4, 16, h - 4);
|
||||
ctx.fillStyle = '#2a1810'; ctx.fillRect(x + 14, 4, 2, h - 4); ctx.fillRect(x + w - 16, 4, 2, h - 4);
|
||||
// steps
|
||||
ctx.fillStyle = '#4a4a55'; ctx.fillRect(x - 4, h + 2, w + 8, 5);
|
||||
ctx.fillStyle = '#33333d'; ctx.fillRect(x - 8, h + 7, w + 16, 5);
|
||||
// sparkles drifting out of the door
|
||||
ctx.fillStyle = `rgba(170, 240, 255, ${pulse})`;
|
||||
for (let i = 0; i < 7; i++) { const sx = x + 10 + ((i * 37 + f * 0.6) % (w - 20)); const sy = 8 + ((i * 23 + f * 0.9) % (h - 14)); ctx.fillRect(sx | 0, sy | 0, 2, 2); }
|
||||
}
|
||||
|
||||
// legion banners on the right wall (diegetic My Legion)
|
||||
function drawBanner(ctx, x, hue, f) {
|
||||
ctx.fillStyle = '#3a2a1a'; ctx.fillRect(x - 3, 2, 38, 4); // rod
|
||||
const sway = Math.sin(f * 0.03 + x) * 1.5;
|
||||
ctx.fillStyle = `hsl(${hue} 55% 38%)`;
|
||||
ctx.beginPath(); ctx.moveTo(x, 6); ctx.lineTo(x + 32, 6); ctx.lineTo(x + 32, 54); ctx.lineTo(x + 16 + sway, 66); ctx.lineTo(x, 54); ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = `hsl(${hue} 70% 60%)`; ctx.fillRect(x + 4, 10, 24, 3);
|
||||
ctx.fillStyle = '#ffd75e'; // emblem
|
||||
ctx.fillRect(x + 13, 24, 6, 14); ctx.fillRect(x + 9, 28, 14, 5);
|
||||
}
|
||||
|
||||
// villa exit on the right (diegetic My Mansion)
|
||||
function drawVillaExit(ctx) {
|
||||
const x = 712, y = 150;
|
||||
ctx.fillStyle = '#241a12'; ctx.fillRect(x, y, 56, 86);
|
||||
ctx.fillStyle = '#102a18'; ctx.fillRect(x + 5, y + 5, 51, 76);
|
||||
// little villa silhouette through the passage
|
||||
ctx.fillStyle = '#2c4a2e'; ctx.fillRect(x + 10, y + 42, 40, 34);
|
||||
ctx.fillStyle = '#d8c79a'; ctx.fillRect(x + 18, y + 50, 26, 26);
|
||||
ctx.fillStyle = '#7a3326'; ctx.beginPath(); ctx.moveTo(x + 14, y + 52); ctx.lineTo(x + 31, y + 38); ctx.lineTo(x + 48, y + 52); ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = '#2c2417'; ctx.fillRect(x + 27, y + 62, 8, 14);
|
||||
ctx.fillStyle = 'rgba(255, 230, 150, 0.8)'; ctx.fillRect(x + 20, y + 56, 5, 5); ctx.fillRect(x + 38, y + 56, 5, 5);
|
||||
}
|
||||
|
||||
function buildStatic(buf, S) {
|
||||
const ctx = buf.getContext('2d'); ctx.imageSmoothingEnabled = false;
|
||||
const cols = W / T, rows = H / T;
|
||||
// floor
|
||||
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);
|
||||
// brick wall (rows 0-1)
|
||||
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);
|
||||
// wood beam trim at wall/floor seam
|
||||
for (let y = 0; y < 3; y++) for (let x = 0; x < cols; x++) if (y < 2 || x < 2 || x > cols - 3) tile(ctx, S.inside, WALL[0], WALL[1], x * T, y * T);
|
||||
// beam trim
|
||||
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);
|
||||
// windows + curtains on the wall
|
||||
for (const wx of [40, 432]) {
|
||||
// windows + curtains
|
||||
for (const wx of [56, 240, 520]) {
|
||||
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); ctx.fillRect(wx + 18, 8, 3, 44);
|
||||
tile(ctx, S.vic, CURTAIN[0], CURTAIN[1], wx - 18, 2); tile(ctx, S.vic, CURTAIN2[0], CURTAIN2[1], wx + 28, 2);
|
||||
}
|
||||
// red rug (9-slice), center
|
||||
const rx = 176, ry = 160, rcols = 5, rrows = 4;
|
||||
// grand rug
|
||||
const rx = 304, ry = 220, rcols = 6, rrows = 4;
|
||||
for (let j = 0; j < rrows; j++) for (let i = 0; i < rcols; i++) {
|
||||
const k = i === 0 ? 'l' : i === rcols - 1 ? 'r' : 'c'; const kk = j === 0 ? (k === 'l' ? 'tl' : k === 'r' ? 'tr' : 't') : j === rrows - 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);
|
||||
}
|
||||
// round tables + chairs (sides)
|
||||
for (const tx of [96, 400]) {
|
||||
tile(ctx, S.vic, CHAIR[0], CHAIR[1], tx - 30, 232); tile(ctx, S.vic, CHAIR_R[0], CHAIR_R[1], tx + 18, 232);
|
||||
tile(ctx, S.vic, TABLE[0], TABLE[1], tx - 16, 244);
|
||||
// tables + chairs
|
||||
for (const tx of [150, 620]) {
|
||||
tile(ctx, S.vic, CHAIR[0], CHAIR[1], tx - 30, 296); tile(ctx, S.vic, CHAIR_R[0], CHAIR_R[1], tx + 18, 296);
|
||||
tile(ctx, S.vic, TABLE[0], TABLE[1], tx - 16, 308);
|
||||
}
|
||||
// barrels (corner) + plants + lamps
|
||||
tile(ctx, S.barrel, 0, 0, 444, 286); tile(ctx, S.barrel, 1, 0, 470, 292); tile(ctx, S.barrel, 2, 0, 452, 312);
|
||||
tile(ctx, S.vic, PLANT[0], PLANT[1], 8, 286); tile(ctx, S.vic, PLANT[0], PLANT[1], 484, 252);
|
||||
tile(ctx, S.vic, LAMP[0], LAMP[1], 8, 250);
|
||||
// screen frame (cyan) on the wall
|
||||
ctx.fillStyle = '#0c2030'; ctx.fillRect(198, 6, 116, 50); ctx.strokeStyle = '#46c8e0'; ctx.lineWidth = 2; ctx.strokeRect(199, 7, 114, 48); ctx.fillStyle = '#16415e'; ctx.fillRect(202, 10, 108, 42);
|
||||
// props
|
||||
tile(ctx, S.barrel, 0, 0, 700, 366); tile(ctx, S.barrel, 1, 0, 726, 372); tile(ctx, S.barrel, 2, 0, 708, 392);
|
||||
tile(ctx, S.vic, PLANT[0], PLANT[1], 10, 366); tile(ctx, S.vic, PLANT[0], PLANT[1], 740, 300);
|
||||
tile(ctx, S.vic, LAMP[0], LAMP[1], 10, 320); tile(ctx, S.vic, LAMP[0], LAMP[1], 290, 96);
|
||||
// stats screen on the wall (left of door)
|
||||
ctx.fillStyle = '#0c2030'; ctx.fillRect(120, 6, 116, 50); ctx.strokeStyle = '#46c8e0'; ctx.lineWidth = 2; ctx.strokeRect(121, 7, 114, 48); ctx.fillStyle = '#16415e'; ctx.fillRect(124, 10, 108, 42);
|
||||
drawVillaExit(ctx);
|
||||
}
|
||||
|
||||
export default function TavernGame({ onHotspot, stats }) {
|
||||
export default function TavernGame({ onAction, stats, auth }) {
|
||||
const canvasRef = useRef(null);
|
||||
const statsRef = useRef(stats);
|
||||
useEffect(() => { statsRef.current = stats; }, [stats]);
|
||||
@ -66,49 +111,52 @@ export default function TavernGame({ onHotspot, stats }) {
|
||||
if (paused) return;
|
||||
f++;
|
||||
ctx.drawImage(buf, 0, 0);
|
||||
// bartender (a clothed guard tending bar) behind the bar (idle bob)
|
||||
drawDoor(ctx, f);
|
||||
for (const [bx, hue] of [[560, 265], [620, 195], [680, 25]]) drawBanner(ctx, bx, hue, f);
|
||||
// bartender behind the bar
|
||||
const bb = Math.round(Math.sin(f * 0.06) * 1);
|
||||
chr(ctx, S.soldier, 0, 56, 18 + bb);
|
||||
// bar counter (drawn over bartender's lower half) + bottles
|
||||
ctx.fillStyle = '#5e3a22'; ctx.fillRect(24, 92, 168, 26); ctx.fillStyle = '#8a5a32'; ctx.fillRect(24, 92, 168, 4); ctx.fillStyle = '#c79155'; ctx.fillRect(24, 96, 168, 1); ctx.fillStyle = '#2a1810'; ctx.fillRect(24, 116, 168, 4);
|
||||
for (let i = 1; i < 6; i++) { ctx.fillStyle = '#3a2417'; ctx.fillRect(24 + i * 28, 98, 1, 18); }
|
||||
for (const bxp of [44, 96, 150]) { tile(ctx, S.vic, VASE[0], VASE[1], bxp, 76, 20, 20); }
|
||||
// patrons at the side tables (idle bob)
|
||||
chr(ctx, S.soldier, 0, 64, 188 + (Math.sin(f * 0.05) > 0.6 ? -2 : 0));
|
||||
chr(ctx, S.soldier2, 0, 368, 188 + (Math.sin(f * 0.05 + 2) > 0.6 ? -2 : 0));
|
||||
// DANCER (舞女): princess cycling walk frames = dancing, on the rug
|
||||
chr(ctx, S.soldier, 0, 60, 52 + bb);
|
||||
// bar counter + bottles
|
||||
ctx.fillStyle = '#5e3a22'; ctx.fillRect(24, 126, 196, 28); ctx.fillStyle = '#8a5a32'; ctx.fillRect(24, 126, 196, 4); ctx.fillStyle = '#c79155'; ctx.fillRect(24, 130, 196, 1); ctx.fillStyle = '#2a1810'; ctx.fillRect(24, 150, 196, 4);
|
||||
for (let i = 1; i < 7; i++) { ctx.fillStyle = '#3a2417'; ctx.fillRect(24 + i * 28, 132, 1, 18); }
|
||||
for (const bxp of [48, 104, 162]) { tile(ctx, S.vic, VASE[0], VASE[1], bxp, 108, 20, 20); }
|
||||
// patrons
|
||||
chr(ctx, S.soldier, 0, 118, 252 + (Math.sin(f * 0.05) > 0.6 ? -2 : 0));
|
||||
chr(ctx, S.soldier2, 0, 588, 252 + (Math.sin(f * 0.05 + 2) > 0.6 ? -2 : 0));
|
||||
// greeter by the door
|
||||
chr(ctx, S.soldier2, 0, 452, 76 + (Math.sin(f * 0.045 + 1) > 0.7 ? -2 : 0));
|
||||
// DANCER: cycling walk frames on the rug, spotlight
|
||||
const dframe = 1 + (Math.floor(f / 7) % 8);
|
||||
const sway = Math.round(Math.sin(f * 0.12) * 4);
|
||||
// spotlight
|
||||
const g = ctx.createRadialGradient(256, 230, 6, 256, 236, 56); g.addColorStop(0, 'rgba(255,244,210,0.26)'); g.addColorStop(1, 'rgba(255,244,210,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(256, 244, 40, 44, 0, 0, 7); ctx.fill();
|
||||
chr(ctx, S.princess, dframe, 224 + sway, 184);
|
||||
// screen content: LIVE platform stats, cycling, over a dim sparkline
|
||||
const sway = Math.round(Math.sin(f * 0.12) * 5);
|
||||
const g = ctx.createRadialGradient(400, 290, 6, 400, 296, 66); g.addColorStop(0, 'rgba(255,244,210,0.26)'); g.addColorStop(1, 'rgba(255,244,210,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(400, 304, 48, 52, 0, 0, 7); ctx.fill();
|
||||
chr(ctx, S.princess, dframe, 368 + sway, 244);
|
||||
// wall screen: live platform stats
|
||||
const st = statsRef.current;
|
||||
const items = st ? [['CLASSES', st.classes], ['ONLINE', st.online], ['SESSIONS', st.sessions]] : [['LIVE', '·']];
|
||||
const phase = Math.floor(f / 150) % items.length;
|
||||
const [label, val] = items[phase];
|
||||
for (let i = 0; i < 13; i++) { const bh = 3 + ((Math.sin(f * 0.09 + i) + 1) * 4 + (i % 3)) | 0; ctx.fillStyle = 'rgba(70,170,210,0.30)'; ctx.fillRect(206 + i * 8, 50 - bh, 5, bh); }
|
||||
for (let i = 0; i < 13; i++) { const bh = 3 + ((Math.sin(f * 0.09 + i) + 1) * 4 + (i % 3)) | 0; ctx.fillStyle = 'rgba(70,170,210,0.30)'; ctx.fillRect(128 + i * 8, 50 - bh, 5, bh); }
|
||||
const pf = (f % 150) / 150, fade = Math.min(1, Math.min(pf, 1 - pf) * 8 + 0.25);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = `rgba(174,242,255,${fade})`; ctx.font = 'bold 18px Georgia, serif';
|
||||
ctx.fillText(String(val), 256, 33);
|
||||
ctx.fillStyle = `rgba(95,208,232,${fade})`; ctx.font = '7px Georgia, serif';
|
||||
ctx.fillText(label, 256, 45);
|
||||
ctx.textAlign = 'left'; ctx.font = '10px serif';
|
||||
ctx.fillStyle = 'rgba(127,233,255,0.4)'; ctx.fillRect(202, 10 + (f % 42), 108, 1);
|
||||
// warm lighting + screen glow
|
||||
ctx.fillStyle = `rgba(174,242,255,${fade})`; ctx.font = 'bold 18px Georgia, serif'; ctx.fillText(String(val), 178, 33);
|
||||
ctx.fillStyle = `rgba(95,208,232,${fade})`; ctx.font = '7px Georgia, serif'; ctx.fillText(label, 178, 45);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(127,233,255,0.4)'; ctx.fillRect(124, 10 + (f % 42), 108, 1);
|
||||
// warm light pools + door glow
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (const [lx, ly, lr, a] of [[256, 30, 64, 0.16], [40, 40, 30, 0.18], [432, 40, 30, 0.18]]) { const aa = a * (0.85 + 0.15 * Math.sin(f * 0.1 + lx)); const gg = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr); gg.addColorStop(0, `rgba(255,180,90,${aa})`); gg.addColorStop(1, 'rgba(255,170,80,0)'); ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(lx, ly, lr, 0, 7); ctx.fill(); }
|
||||
const cg = ctx.createRadialGradient(256, 30, 0, 256, 30, 56); cg.addColorStop(0, 'rgba(70,200,235,0.14)'); cg.addColorStop(1, 'rgba(70,200,235,0)'); ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(256, 30, 56, 0, 7); ctx.fill();
|
||||
for (const [lx, ly, lr, a] of [[384, 40, 80, 0.15], [56, 40, 30, 0.18], [240, 40, 28, 0.16], [520, 40, 28, 0.16], [26, 330, 34, 0.14], [306, 110, 30, 0.14]]) {
|
||||
const aa = a * (0.85 + 0.15 * Math.sin(f * 0.1 + lx)); const gg = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr);
|
||||
gg.addColorStop(0, `rgba(255,180,90,${aa})`); gg.addColorStop(1, 'rgba(255,170,80,0)'); ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(lx, ly, lr, 0, 7); ctx.fill();
|
||||
}
|
||||
const cg = ctx.createRadialGradient(384, 44, 0, 384, 44, 90); cg.addColorStop(0, 'rgba(70,200,235,0.16)'); cg.addColorStop(1, 'rgba(70,200,235,0)'); ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(384, 44, 90, 0, 7); ctx.fill();
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
// vignette + dust
|
||||
const vg = ctx.createRadialGradient(W / 2, H * 0.52, H * 0.36, W / 2, H * 0.5, H * 0.82); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(8,4,10,0.5)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);
|
||||
// vignette + dust motes
|
||||
const vg = ctx.createRadialGradient(W / 2, H * 0.52, H * 0.4, W / 2, H * 0.5, H * 0.92); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(8,4,10,0.52)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = 'rgba(255,225,170,0.4)';
|
||||
for (let i = 0; i < 12; i++) { const dx = (i * 43 + f * 0.16) % W, dy = (i * 57 + Math.sin(f * 0.02 + i) * 8 + 64) % H; ctx.fillRect(dx | 0, dy | 0, 1, 1); }
|
||||
for (let i = 0; i < 16; i++) { const dx = (i * 53 + f * 0.16) % W, dy = (i * 67 + Math.sin(f * 0.02 + i) * 8 + 64) % H; ctx.fillRect(dx | 0, dy | 0, 1, 1); }
|
||||
raf = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
// pause the 60fps loop while the tavern is scrolled out of view (CPU/battery)
|
||||
io = new IntersectionObserver(([e]) => {
|
||||
if (e.isIntersecting) { if (paused) { paused = false; render(); } }
|
||||
else if (!paused) { paused = true; cancelAnimationFrame(raf); }
|
||||
@ -119,23 +167,60 @@ export default function TavernGame({ onHotspot, stats }) {
|
||||
return () => { cancelAnimationFrame(raf); if (io) io.disconnect(); };
|
||||
}, []);
|
||||
|
||||
const user = auth?.user;
|
||||
return (
|
||||
<div className="gm-room">
|
||||
<canvas ref={canvasRef} width={W} height={H} className="gm-canvas" role="img" aria-label="Animated tavern scene" />
|
||||
<canvas ref={canvasRef} width={W} height={H} className="gm-canvas" role="img" aria-label="The amerc tavern — every fixture is a door into the platform" />
|
||||
{!ready && <div className="gm-loading">loading tavern…</div>}
|
||||
<div className="gm-overlay">
|
||||
<button className="gm-hot" style={{ left: '38%', top: '1%', width: '24%', height: '16%' }} onClick={() => onHotspot?.('agents')} title="Browse Agents (screen)" />
|
||||
<button className="gm-label gm-label-screen" style={{ left: '50%', top: '18%' }} onClick={() => onHotspot?.('agents')}>▸ Browse Agents</button>
|
||||
<button className="gm-hot" style={{ left: '44%', top: '50%', width: '14%', height: '24%' }} onClick={() => onHotspot?.('legion')} title="My Legion" />
|
||||
<button className="gm-label gm-label-booth" style={{ left: '50%', top: '90%' }} onClick={() => onHotspot?.('legion')}>⌂ Enter My Legion</button>
|
||||
<Npc x="11%" y="4%" w="13%" h="20%" who="Barkeep" say="What'll it be? The staff roster is up on the board." onClick={() => onHotspot?.('agents')} />
|
||||
<Npc x="11%" y="52%" w="13%" h="22%" who="Patron" say="I field three mercenaries straight from my legion." onClick={() => onHotspot?.('legion')} />
|
||||
<Npc x="72%" y="52%" w="13%" h="22%" who="Patron" say="Showcase put my little app on the open internet!" onClick={() => onHotspot?.('mansion')} />
|
||||
{/* THE DOOR — browse agents */}
|
||||
<button className="gm-hot" style={{ left: '42.5%', top: '0%', width: '15%', height: '21%' }} onClick={() => onAction?.('agents')} title="Browse Agents" />
|
||||
<Bubble x="50%" y="23%" variant="door" onClick={() => onAction?.('agents')} float>
|
||||
<strong>⚔ THE ROSTER</strong><span>open the door — the mercenaries come to you</span>
|
||||
</Bubble>
|
||||
{/* BARTENDER — sign in / your account */}
|
||||
<button className="gm-hot" style={{ left: '4%', top: '10%', width: '14%', height: '26%' }} onClick={() => onAction?.(user ? 'legion' : 'login')} title={user ? 'My Legion' : 'Sign in'} />
|
||||
<Bubble x="12%" y="9%" variant="wood" onClick={() => onAction?.(user ? 'legion' : 'login')} float>
|
||||
<strong>🍺 Barkeep</strong>
|
||||
<span>{user ? `welcome back, ${user.handle} — your legion is mustered` : 'new blade? sign the ledger here'}</span>
|
||||
</Bubble>
|
||||
{/* THE GIRL — holo tour panel, like a quest-giver */}
|
||||
<button className="gm-hot" style={{ left: '45%', top: '54%', width: '12%', height: '26%' }} onClick={() => onAction?.('tour')} title="What is amerc?" />
|
||||
<Bubble x="66.5%" y="48%" variant="holo" onClick={() => onAction?.('tour')} float>
|
||||
<strong>✦ what this tavern does</strong>
|
||||
<i>◆ Publish Agent</i><i>◆ Remote Use</i><i>◆ Port Mapping</i><i>◆ Delivery Relay</i><i>◆ Run Directly</i>
|
||||
<em>tap to open the tour ▸</em>
|
||||
</Bubble>
|
||||
{/* BANNERS — my legion */}
|
||||
<button className="gm-hot" style={{ left: '71%', top: '0%', width: '20%', height: '17%' }} onClick={() => onAction?.('legion')} title="My Legion" />
|
||||
<Bubble x="81%" y="18.5%" variant="wood" small onClick={() => onAction?.('legion')}>
|
||||
<strong>🚩 MY LEGION</strong>
|
||||
</Bubble>
|
||||
{/* VILLA EXIT — my mansion */}
|
||||
<button className="gm-hot" style={{ left: '91.5%', top: '33%', width: '8.5%', height: '24%' }} onClick={() => onAction?.('mansion')} title="My Mansion" />
|
||||
<Bubble x="89%" y="60%" variant="wood" small onClick={() => onAction?.('mansion')}>
|
||||
<strong>🏰 MY MANSION</strong>
|
||||
</Bubble>
|
||||
{/* patrons: hover quips */}
|
||||
<Npc x="12.5%" y="54%" w="11%" h="20%" who="Patron" say="I field three mercenaries straight from my legion." onClick={() => onAction?.('legion')} />
|
||||
<Npc x="73.5%" y="54%" w="11%" h="20%" who="Patron" say="Showcase put my little app on the open internet!" onClick={() => onAction?.('mansion')} />
|
||||
<Npc x="56%" y="14%" w="9%" h="18%" who="Greeter" say="The roster is right through this door." onClick={() => onAction?.('agents')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A diegetic speech bubble pinned over the scene. Variants: wood (tavern sign),
|
||||
// holo (the glowing panel, like the elf's pitch), door (roster call).
|
||||
function Bubble({ x, y, variant = 'wood', small, float, onClick, children }) {
|
||||
return (
|
||||
<button type="button" className={`gm-bub gm-bub-${variant}${small ? ' gm-bub-sm' : ''}${float ? ' gm-bub-float' : ''}`} style={{ left: x, top: y }} onClick={onClick}>
|
||||
{children}
|
||||
<i className="gm-bub-tail" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Npc({ x, y, w, h, who, say, onClick }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
|
||||
@ -1648,3 +1648,67 @@ button {
|
||||
.mn-quota-label { color: #8fa3b8; font-size: 11.5px; flex: 0 0 auto; }
|
||||
.mn-fm-meta { color: #6e7e94; font-size: 11px; }
|
||||
.mn-nd { margin-top: 10px; background: #0b0f1a; border: 1px solid #20283c; border-radius: 10px; padding: 8px; }
|
||||
|
||||
/* ===== the game IS the page (1.4) — full-screen diegetic tavern ===== */
|
||||
.gm-game { height: calc(100dvh - 52px); display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; overflow: hidden; padding: 0; background: radial-gradient(ellipse at 50% 30%, #171028 0%, #0a0712 70%); }
|
||||
.gm-stage3 { flex: 1; width: 100%; display: flex; align-items: center; justify-content: center; min-height: 0; padding: 6px 8px 0; }
|
||||
.gm-bottombar { width: 100%; }
|
||||
.gm-stage3 .gm-room { position: relative; aspect-ratio: 16 / 9; width: min(100%, calc((100dvh - 196px) * 1.7778)); box-shadow: 0 0 0 2px #2a2440, 0 0 60px rgba(70, 40, 140, 0.35), 0 24px 80px rgba(0, 0, 0, 0.6); border-radius: 6px; overflow: hidden; }
|
||||
.gm-stage3 .gm-canvas { width: 100%; height: 100%; display: block; image-rendering: pixelated; }
|
||||
.gm-bottombar { flex: 0 0 auto; text-align: center; padding: 10px 14px 14px; display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||
.gm-tagline { margin: 0; font-family: Georgia, 'Times New Roman', serif; font-size: clamp(20px, 3.4vw, 34px); color: #f3e9d2; letter-spacing: 0.5px; text-shadow: 0 2px 18px rgba(255, 200, 120, 0.25); }
|
||||
.gm-cursor { color: #46c8e0; animation: gmblink 1.1s steps(1) infinite; }
|
||||
@keyframes gmblink { 50% { opacity: 0; } }
|
||||
.gm-tagsub { margin: 0; color: #8a7bb0; font-size: clamp(11px, 1.5vw, 13.5px); }
|
||||
.gm-link { background: none; border: none; color: #7fd9ea; cursor: pointer; font: inherit; text-decoration: underline; padding: 0; }
|
||||
.gm-link:hover { color: #cfeff7; }
|
||||
|
||||
/* diegetic speech bubbles */
|
||||
.gm-bub { position: absolute; transform: translate(-50%, -100%); display: flex; flex-direction: column; gap: 2px; align-items: flex-start; text-align: left; padding: 8px 12px; border-radius: 10px; cursor: pointer; font-family: inherit; z-index: 4; max-width: 220px; transition: transform 0.15s, box-shadow 0.15s; }
|
||||
.gm-bub:hover { transform: translate(-50%, -104%) scale(1.04); z-index: 6; }
|
||||
.gm-bub strong { font-size: 12px; letter-spacing: 0.6px; }
|
||||
.gm-bub span { font-size: 11px; line-height: 1.45; }
|
||||
.gm-bub i, .gm-bub em { font-style: normal; font-size: 11px; line-height: 1.5; }
|
||||
.gm-bub em { margin-top: 3px; opacity: 0.85; }
|
||||
.gm-bub-tail { position: absolute; left: 50%; bottom: -7px; width: 12px; height: 12px; transform: translateX(-50%) rotate(45deg); border-radius: 2px; }
|
||||
.gm-bub-float { animation: gmfloat 3.4s ease-in-out infinite; }
|
||||
@keyframes gmfloat { 0%, 100% { margin-top: 0; } 50% { margin-top: -5px; } }
|
||||
.gm-bub-wood { background: linear-gradient(180deg, #241509f0, #170d05f0); border: 2px solid #8a5a32; color: #f3e3c2; box-shadow: 0 4px 0 rgba(0, 0, 0, 0.45), 0 10px 26px rgba(0, 0, 0, 0.5); }
|
||||
.gm-bub-wood strong { color: #ffd75e; }
|
||||
.gm-bub-wood .gm-bub-tail { background: #1d1107; border-right: 2px solid #8a5a32; border-bottom: 2px solid #8a5a32; }
|
||||
.gm-bub-holo { background: linear-gradient(180deg, rgba(10, 40, 64, 0.88), rgba(8, 24, 44, 0.88)); border: 1.5px solid #46c8e0; color: #bfeefb; box-shadow: 0 0 18px rgba(70, 200, 235, 0.35), inset 0 0 24px rgba(70, 200, 235, 0.12); backdrop-filter: blur(2px); background-image: repeating-linear-gradient(0deg, rgba(127, 233, 255, 0.05) 0 1px, transparent 1px 3px); }
|
||||
.gm-bub-holo strong { color: #7fe9ff; text-shadow: 0 0 8px rgba(127, 233, 255, 0.6); }
|
||||
.gm-bub-holo em { color: #ffd75e; }
|
||||
.gm-bub-holo .gm-bub-tail { background: rgba(9, 30, 52, 0.95); border-right: 1.5px solid #46c8e0; border-bottom: 1.5px solid #46c8e0; }
|
||||
.gm-bub-door { background: linear-gradient(180deg, rgba(40, 28, 6, 0.92), rgba(26, 16, 4, 0.92)); border: 2px solid #ffd75e; color: #ffe9b0; box-shadow: 0 0 20px rgba(255, 215, 94, 0.3); }
|
||||
.gm-bub-door strong { color: #ffd75e; letter-spacing: 1.5px; }
|
||||
.gm-bub-door .gm-bub-tail { background: #1c1204; border-right: 2px solid #ffd75e; border-bottom: 2px solid #ffd75e; }
|
||||
.gm-bub-sm { padding: 5px 10px; }
|
||||
.gm-bub-sm strong { font-size: 10.5px; }
|
||||
|
||||
/* ornate in-game dialog */
|
||||
.gd-backdrop { position: fixed; inset: 0; background: rgba(5, 3, 12, 0.72); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; z-index: 60; padding: 18px; animation: gdfade 0.18s ease-out; }
|
||||
@keyframes gdfade { from { opacity: 0; } }
|
||||
.gd { position: relative; width: min(520px, 94vw); max-height: 86vh; display: flex; flex-direction: column; background: linear-gradient(180deg, #14102a, #0c0918); border: 2px solid #46c8e0; border-radius: 14px; box-shadow: 0 0 0 1px #2a2440, 0 0 50px rgba(70, 200, 235, 0.25), 0 30px 90px rgba(0, 0, 0, 0.7); animation: gdpop 0.22s cubic-bezier(0.2, 1.4, 0.4, 1); }
|
||||
.gd-wide { width: min(960px, 96vw); }
|
||||
@keyframes gdpop { from { transform: scale(0.92) translateY(10px); opacity: 0; } }
|
||||
.gd-gem { position: absolute; width: 11px; height: 11px; background: linear-gradient(135deg, #7fe9ff, #2787c8); transform: rotate(45deg); box-shadow: 0 0 10px rgba(127, 233, 255, 0.8); border-radius: 2px; }
|
||||
.gd-gem.tl { top: -6px; left: -6px; } .gd-gem.tr { top: -6px; right: -6px; }
|
||||
.gd-gem.bl { bottom: -6px; left: -6px; } .gd-gem.br { bottom: -6px; right: -6px; }
|
||||
.gd-head { display: flex; align-items: center; gap: 10px; padding: 14px 18px 10px; border-bottom: 1px solid #2a2440; }
|
||||
.gd-tag { font-size: 10px; letter-spacing: 1.4px; color: #7fd9ea; background: rgba(70, 200, 235, 0.08); border: 1px solid #1f4456; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
|
||||
.gd-head h3 { margin: 0; flex: 1; font-family: Georgia, serif; font-size: 19px; color: #f3e9d2; }
|
||||
.gd-x { background: none; border: 1px solid #3a3354; color: #b6a7d4; border-radius: 7px; padding: 4px 10px; cursor: pointer; font-family: inherit; }
|
||||
.gd-x:hover { border-color: #6a5a9a; color: #fff; }
|
||||
.gd-body { overflow-y: auto; padding: 14px 18px 18px; }
|
||||
.gd-lead { color: #b6a7d4; font-size: 13px; margin: 0 0 12px; }
|
||||
.gd-body .hf { padding: 6px 2px 10px; }
|
||||
.gd-body .hf-title { font-size: 22px; margin-top: 0; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.gm-bub { max-width: 150px; padding: 6px 9px; }
|
||||
.gm-bub i { display: none; }
|
||||
.gm-bub span { font-size: 10px; }
|
||||
.gm-stage3 { padding: 4px; }
|
||||
.gm-tagline { font-size: 19px; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user