art: painted tavern hero replaces the pixel tiles (3.0.0)

The home scene is now a layered hand-painted illustration — a clean
tavern backdrop with an elf host, orc bartender and dwarf patron cutouts
(the painted assets were already in assets/, just unused) — resized to
webp (258KB total, was ~5MB png). The diegetic UI sits on top: the roster
board with live stats, the barkeep sign-in bubble, the elf's tour bubble,
and Legion/Mansion side passages. Dropped the LPC canvas + tile preload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 13:55:55 +08:00
parent 8f00e7668a
commit 610b932140
10 changed files with 112 additions and 229 deletions

View File

@ -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="2.5.0-chatmd" />
<meta name="version" content="3.0.0-painted" />
<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" />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "2.5.0-chatmd",
"version": "3.0.0-painted",
"private": true,
"type": "module",
"scripts": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

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 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 = '2.5.0-chatmd';
const RELEASE = '3.0.0-painted';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -477,6 +477,7 @@ function StatusPage() {
}
const CHANGELOG = [
{ v: '3.0', date: 'Jun 2026', title: 'A painted tavern', notes: ['The home tavern is now a hand-painted scene — an elf host, an orc bartender and a dwarf patron in a lamplit hall — replacing the old pixel tiles', 'Every painted fixture is still a door: the board opens the roster, the barkeep signs you in, the elf pitches the tour, the side passages lead to your Legion and Mansion'] },
{ v: '2.5', date: 'Jun 2026', title: 'Chat speaks markdown', notes: ['Chat messages now render markdown — code blocks, lists, tables and links — so an agent can answer with formatted, clickable replies (it picks its own links, to the netdisk or anywhere)'] },
{ v: '2.4', date: 'Jun 2026', title: 'The Quartermaster command post', notes: ['The admin Quartermaster is now amercs internal management station — members, agents, companies & products, whiteboards and netdisk all in one panel', 'A command-post dashboard with live counts and quick links to git, docs, the PM workspace and the webagent relay'] },
{ v: '2.3', date: 'Jun 2026', title: 'My Mansion gets its villa', notes: ['My Mansion is now a moonlit estate — your villa with glowing windows, and the services (Showcase, File-mapper, Net-disk) are chambers within it'] },

View File

@ -1,232 +1,60 @@
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';
// 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',
};
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); }
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;
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 < 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
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);
}
// 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);
}
// 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);
}
// 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);
}
// Painted tavern hero a layered illustration (backdrop + character cutouts)
// with the diegetic UI on top: the bar signs you in, the elf host pitches the
// tour from her bubble, the board opens the roster, side hotspots lead to your
// Legion and Mansion. No canvas, no tiles just the painted scene.
const P = '/scene2d/painted/';
export default function TavernGame({ onAction, stats, auth }) {
const canvasRef = useRef(null);
const statsRef = useRef(stats);
useEffect(() => { statsRef.current = stats; }, [stats]);
const [ready, setReady] = useState(false);
useEffect(() => {
const keys = Object.keys(SRC); const S = {}; let loaded = 0, raf, f = 0, buf, paused = false, io;
const start = () => {
setReady(true);
buf = document.createElement('canvas'); buf.width = W; buf.height = H; buildStatic(buf, S);
const ctx = canvasRef.current.getContext('2d'); ctx.imageSmoothingEnabled = false;
const render = () => {
if (paused) return;
f++;
ctx.drawImage(buf, 0, 0);
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, 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) * 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(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), 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 [[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 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 < 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();
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(); };
}, []);
const user = auth?.user;
return (
<div className="gm-room">
<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">
{/* 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 className="gm-room pt-room">
<img className="pt-bg" src={`${P}bg.webp`} alt="The amerc mercenary tavern" />
<div className="pt-vignette" aria-hidden="true" />
{/* patrons + bartender */}
<img className="pt-ch pt-dwarf" src={`${P}dwarf.webp`} alt="" aria-hidden="true" />
<img className="pt-ch pt-orc" src={`${P}orc.webp`} alt="" aria-hidden="true" />
{/* the roster board — browse agents, with live stats */}
<button className="pt-board" onClick={() => onAction('agents')} title="Browse Agents">
<span className="pt-board-title"> Browse Agents</span>
<span className="pt-board-stats">
{stats
? <><span><b>{stats.classes}</b> classes</span><span><b>{stats.online}</b> online</span><span><b>{stats.sessions}</b> live</span></>
: <span className="pt-board-muted">opening the roster</span>}
</span>
<span className="pt-board-cta">enter the roster </span>
</button>
{/* the elf host — pitches the tour from her held bubble */}
<button className="pt-elf-wrap" onClick={() => onAction('tour')} aria-label="What this tavern does — open the tour">
<img className="pt-ch pt-elf" src={`${P}elf.webp`} alt="" aria-hidden="true" />
<span className="pt-elf-bub">
<b> what this tavern does</b>
<i>Publish · Run · Deliver</i>
<em>tap me </em>
</span>
</button>
{/* the barkeep — sign in (or back to your legion) */}
<button className="pt-bub pt-bub-orc" onClick={() => onAction(user ? 'legion' : 'login')}>
<strong>🍺 Barkeep</strong>
<span>{user ? `welcome back, ${user.handle}` : 'new blade? sign the ledger'}</span>
<i className="pt-bub-tail" aria-hidden="true" />
</button>
{/* the dwarf patron — a quip into your legion */}
<button className="pt-bub pt-bub-dwarf" onClick={() => onAction('legion')}>
<strong>🍺 Patron</strong>
<span>I field mercenaries from my legion</span>
<i className="pt-bub-tail" aria-hidden="true" />
</button>
{/* side passages */}
<button className="pt-tab pt-tab-legion" onClick={() => onAction('legion')}>🚩 My Legion</button>
<button className="pt-tab pt-tab-mansion" onClick={() => onAction('mansion')}>🏰 My Mansion</button>
</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 (
<button className="gm-npc" style={{ left: x, top: y, width: w, height: h }}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onClick={onClick} aria-label={who}>
{hover && <span className="gm-say"><strong>{who}</strong>{say}</span>}
</button>
);
}

View File

@ -16,8 +16,9 @@ else if (host.startsWith('pm.')) { App = PmApp; document.title = 'PM · amerc';
// soon as the app mounts they download in parallel with the JS chunk instead
// of only after TavernGame runs. Not needed on docs/pm.
if (App === Scene2DApp) {
for (const n of ['inside', 'castlefloors', 'victoria', 'barrel', 'princess', 'soldier', 'soldier_altcolor']) {
const im = new Image(); im.src = `/scene2d/lpc/${n}.png`;
// Preload the painted tavern hero so the scene paints as soon as the app mounts.
for (const n of ['bg', 'orc', 'elf', 'dwarf']) {
const im = new Image(); im.src = `/scene2d/painted/${n}.webp`;
}
// Expose amerc to AI agents as WebMCP tools (no-op where unsupported).
import('./webmcp.js').then((m) => m.registerAmercTools()).catch(() => {});

View File

@ -1875,3 +1875,56 @@ button {
.ch-md th, .ch-md td { border: 1px solid #2a3550; padding: 4px 8px; }
.ch-md img { max-width: 100%; border-radius: 6px; }
.ch-msg.mine .ch-md code { background: rgba(0,0,0,0.3); }
/* ===== painted tavern hero (3.0) ===== */
.pt-room { position: relative; overflow: hidden; background: #0a0712; }
.pt-bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.pt-vignette { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(ellipse at 50% 46%, transparent 52%, rgba(6,4,12,0.5) 100%); }
.pt-ch { position: absolute; bottom: 0; height: auto; pointer-events: none; filter: drop-shadow(0 8px 14px rgba(0,0,0,0.45)); will-change: transform; }
@keyframes ptbob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-1.1%); } }
.pt-orc { left: 30%; bottom: 4%; height: 64%; animation: ptbob 4.6s ease-in-out infinite; }
.pt-dwarf { left: 6.5%; bottom: 3%; height: 45%; animation: ptbob 5.4s ease-in-out infinite 0.6s; }
.pt-elf { position: relative; height: 100%; bottom: auto; left: auto; display: block; }
/* the roster board */
.pt-board { position: absolute; left: 31%; top: 5%; width: 38%; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 14px; cursor: pointer; font-family: inherit; color: #cdeeff;
background: linear-gradient(180deg, rgba(8,34,58,0.82), rgba(6,20,40,0.82)); border: 1.5px solid #46c8e0; border-radius: 12px;
box-shadow: 0 0 22px rgba(70,200,235,0.4), inset 0 0 26px rgba(70,200,235,0.14); backdrop-filter: blur(2px);
background-image: repeating-linear-gradient(0deg, rgba(127,233,255,0.05) 0 1px, transparent 1px 3px); transition: transform 0.16s, box-shadow 0.16s; z-index: 4; }
.pt-board:hover { transform: translateY(-2px); box-shadow: 0 0 32px rgba(70,200,235,0.6), inset 0 0 26px rgba(70,200,235,0.18); }
.pt-board-title { font-family: Georgia, serif; font-size: clamp(15px, 2vw, 22px); color: #9af0ff; text-shadow: 0 0 10px rgba(127,233,255,0.7); letter-spacing: 0.5px; }
.pt-board-stats { display: flex; gap: 14px; font-size: clamp(9px, 1.1vw, 12px); color: #bfe6f5; }
.pt-board-stats b { color: #fff; font-size: 1.25em; }
.pt-board-muted { color: #6f93a8; }
.pt-board-cta { font-size: clamp(9px, 1.1vw, 12px); color: #ffd75e; }
/* the elf host + her held bubble */
.pt-elf-wrap { position: absolute; right: 1.5%; bottom: 0; height: 86%; aspect-ratio: 463 / 760; padding: 0; border: 0; background: none; cursor: pointer; z-index: 5; animation: ptbob 5.8s ease-in-out infinite 0.3s; }
.pt-elf-wrap:hover .pt-elf { filter: drop-shadow(0 0 16px rgba(127,233,255,0.5)); }
.pt-elf-bub { position: absolute; top: 4.5%; left: 1%; width: 45%; height: 20%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; text-align: center; color: #06283c; padding: 4px; }
.pt-elf-bub b { font-size: clamp(8px, 1.05vw, 12px); color: #06324a; }
.pt-elf-bub i { font-size: clamp(7px, 0.95vw, 11px); font-style: normal; color: #0d4a6e; }
.pt-elf-bub em { font-size: clamp(7px, 0.9vw, 10px); font-style: normal; color: #1d6e9e; }
/* floating wood sign bubbles */
.pt-bub { position: absolute; display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 7px 11px; border-radius: 10px; cursor: pointer; font-family: inherit; text-align: left; z-index: 6;
background: linear-gradient(180deg, #241509f2, #170d05f2); border: 2px solid #8a5a32; box-shadow: 0 4px 0 rgba(0,0,0,0.4), 0 10px 22px rgba(0,0,0,0.5); transition: transform 0.15s; max-width: 30%; }
.pt-bub:hover { transform: translateY(-3px); border-color: #ffd75e; }
.pt-bub strong { color: #ffd75e; font-size: clamp(10px, 1.2vw, 13px); }
.pt-bub span { color: #f3e3c2; font-size: clamp(9px, 1.05vw, 12px); line-height: 1.3; }
.pt-bub-tail { position: absolute; left: 26px; bottom: -7px; width: 12px; height: 12px; background: #170d05; border-right: 2px solid #8a5a32; border-bottom: 2px solid #8a5a32; transform: rotate(45deg); }
.pt-bub-orc { left: 33%; top: 30%; animation: ptbob 4.6s ease-in-out infinite; }
.pt-bub-dwarf { left: 2%; top: 40%; animation: ptbob 5.4s ease-in-out infinite 0.6s; }
/* side passages */
.pt-tab { position: absolute; padding: 7px 13px; border-radius: 10px; cursor: pointer; font-family: Georgia, serif; font-size: clamp(11px, 1.3vw, 14px); color: #f3e9d2; z-index: 6;
background: linear-gradient(180deg, #2a1d4ef0, #170f2cf0); border: 1.5px solid #6a5a9a; box-shadow: 0 4px 14px rgba(0,0,0,0.5); transition: transform 0.15s, border-color 0.15s; }
.pt-tab:hover { transform: translateY(-2px); border-color: #ffd75e; }
.pt-tab-legion { left: 3%; top: 7%; }
.pt-tab-mansion { right: 3%; top: 7%; }
@media (max-width: 700px) {
.pt-bub { max-width: 38%; padding: 5px 8px; }
.pt-board { width: 46%; padding: 8px; }
.pt-bub-dwarf { display: none; }
}