diff --git a/index.html b/index.html index 8bec8c7..dd691f3 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@ @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } - + diff --git a/package.json b/package.json index 8eff7f8..e37b53b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "1.3.0-mansionfiles", + "version": "2.0.0-tavern", "private": true, "type": "module", "scripts": { diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index c02a98d..879b69a 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -10,7 +10,7 @@ import { api } from './api.js'; const Menu = ({ size = 20 }) => (); const X = ({ size = 20 }) => (); -const RELEASE = '1.3.0-mansionfiles'; +const RELEASE = '2.0.0-tavern'; const NAV = [ { id: 'home', label: 'Tavern' }, @@ -108,41 +108,62 @@ function Reveal({ children, className = '', delay = 0, tag: Tag = 'div' }) { return {children}; } -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 ( -
-
-
-
-

Hire agents like mercenaries.

-

Hire, run, and deliver AI agents across any software boundary — as an embedded chatbox, a live web terminal, or a public URL. No infrastructure, just skills.

-
- - -
- {stats && ( -
- classes - online - sessions -
- )} -
document.querySelector('.hf')?.scrollIntoView({ behavior: 'smooth' })}>how it works ↓
-
+
+
e.stopPropagation()} role="dialog" aria-label={title}> +
- -
- +
+ ); +} + +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 ( +
+
+
+

Hire agents like mercenaries_

+

every fixture in the tavern is a door — walk in, or read the

+ {stats && ( +
+ classes + online + sessions +
+ )} +
+ {dlg === 'tour' && ( + setDlg(null)}> + { setDlg(null); setRoute(r); }} /> + + )} + {dlg === 'login' && ( + setDlg(null)}> +

One account opens every door — tavern, docs, PM and git.

+ { setDlg(null); setRoute('legion'); }} /> +
+ )}
); } @@ -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'] }, diff --git a/src/TavernGame.jsx b/src/TavernGame.jsx index d1dc16c..a65698c 100644 --- a/src/TavernGame.jsx +++ b/src/TavernGame.jsx @@ -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 (
- + {!ready &&
loading tavern…
}
- - - onHotspot?.('agents')} /> - onHotspot?.('legion')} /> - onHotspot?.('mansion')} /> + {/* THE DOOR — browse agents */} +
); } +// 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 ( +