-

- {/* top nav, over the painted bar */}
-
-
-
-
-
-
- {/* big interactive zones */}
-
-
-
- {/* live platform stat chips (subtle, bottom-left — keeps the art clean) */}
+
+
+
+
+
+
Hire agents like mercenaries.
+
Walk into the tavern — talk to the screen to browse staff, or step to the floor to open your booth.
{stats && (
{stats.classes} classes
@@ -92,9 +82,6 @@ function Home({ setRoute, auth }) {
{stats.sessions} sessions
)}
- {auth?.user
- ?
{auth.user.handle}{auth.user.role === 'admin' ? ' · admin' : ''} ·
- : null}
);
@@ -123,7 +110,7 @@ export default function Scene2DApp() {
return (
amerc agent mercenary tavern
- {route !== 'home' && }
+
);
diff --git a/src/TavernGame.jsx b/src/TavernGame.jsx
new file mode 100644
index 0000000..cdb114f
--- /dev/null
+++ b/src/TavernGame.jsx
@@ -0,0 +1,121 @@
+import React, { useEffect, useRef, useState } from 'react';
+
+// 2D RPG tavern built from Kenney "Tiny Dungeon" (CC0) tiles — Pokémon/Stardew style.
+const T = 16, COLS = 22, ROWS = 14, W = COLS * T, H = ROWS * T;
+const SHEET = '/scene2d/dungeon.png';
+
+// tile picks [col,row] from the 12-wide sheet
+const FLOOR = [0, 0], WALL = [4, 3], FIRE = [5, 2], DOOR1 = [10, 3], DOOR2 = [11, 3];
+const BARREL_A = [7, 5], BARREL_B = [8, 5], CRATE = [1, 6], TABLE = [0, 6], STOOL = [1, 6];
+const BOTTLES = [[5, 9], [6, 9], [7, 9], [8, 9]];
+// characters [col,row]
+const BARTENDER = [2, 8], DANCER = [3, 8];
+const PATRONS = [[0, 7], [3, 7], [0, 8], [3, 9], [4, 9], [4, 8]];
+
+function blit(ctx, sheet, c, r, x, y, scale = 1) { ctx.drawImage(sheet, c * T, r * T, T, T, x, y, T * scale, T * scale); }
+
+function buildScene(buf, sheet) {
+ const ctx = buf.getContext('2d'); ctx.imageSmoothingEnabled = false;
+ // floor
+ for (let y = 3; y < ROWS; y++) for (let x = 0; x < COLS; x++) blit(ctx, sheet, FLOOR[0], FLOOR[1], x * T, y * T);
+ // walls
+ for (let y = 0; y < 3; y++) for (let x = 0; x < COLS; x++) blit(ctx, sheet, WALL[0], WALL[1], x * T, y * T);
+ // wood baseboard between wall and floor
+ ctx.fillStyle = '#7c4e2c'; ctx.fillRect(0, 3 * T - 3, W, 3); ctx.fillStyle = '#a06a3c'; ctx.fillRect(0, 3 * T - 3, W, 1); ctx.fillStyle = '#1a0f08'; ctx.fillRect(0, 3 * T, W, 2);
+ // red rug (center)
+ const rx = 7 * T, ry = 7 * T, rw = 9 * T, rh = 6 * T;
+ ctx.fillStyle = '#6e2030'; ctx.fillRect(rx, ry, rw, rh);
+ ctx.fillStyle = '#8a2c3c'; ctx.fillRect(rx + 4, ry + 4, rw - 8, rh - 8);
+ ctx.fillStyle = '#d6a846'; ctx.fillRect(rx, ry, rw, 2); ctx.fillRect(rx, ry + rh - 2, rw, 2); ctx.fillRect(rx, ry, 2, rh); ctx.fillRect(rx + rw - 2, ry, 2, rh);
+ ctx.strokeStyle = '#d6a846'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(rx + rw / 2, ry + 8); ctx.lineTo(rx + rw - 10, ry + rh / 2); ctx.lineTo(rx + rw / 2, ry + rh - 8); ctx.lineTo(rx + 10, ry + rh / 2); ctx.closePath(); ctx.stroke();
+ // wall decor: banners, door, torches, hearth
+ blit(ctx, sheet, DOOR1[0], DOOR1[1], 19 * T, 0); blit(ctx, sheet, DOOR2[0], DOOR2[1], 20 * T, 0);
+ for (const [bx, c1, c2] of [[4, '#3cc8f0', '#16607e'], [16, '#e0b040', '#7c5a1e']]) { ctx.fillStyle = c2; ctx.fillRect(bx * T + 5, 2, 6, 2 * T + 4); ctx.fillStyle = c1; ctx.fillRect(bx * T + 6, 2, 4, 2 * T); ctx.beginPath(); ctx.moveTo(bx * T + 5, 2 * T + 4); ctx.lineTo(bx * T + 8, 2 * T + 8); ctx.lineTo(bx * T + 11, 2 * T + 4); ctx.fill(); }
+ // hearth (left) + wall torches: fire tiles (animated glow added in loop)
+ blit(ctx, sheet, FIRE[0], FIRE[1], 1 * T, 1 * T);
+ for (const tx of [7, 13]) blit(ctx, sheet, FIRE[0], FIRE[1], tx * T, 1 * T);
+ // BAR counter (吧台): solid wood, left side, cols 1-7 row 4
+ const bx0 = 1 * T, bw = 7 * T, byA = 4 * T + 4;
+ ctx.fillStyle = '#5e3a22'; ctx.fillRect(bx0, byA, bw, T + 2);
+ ctx.fillStyle = '#a06a3c'; ctx.fillRect(bx0, byA, bw, 3); ctx.fillStyle = '#c79155'; ctx.fillRect(bx0, byA + 3, bw, 1);
+ ctx.fillStyle = '#1a0f08'; ctx.fillRect(bx0, byA + T + 1, bw, 3);
+ ctx.fillStyle = '#3a2417'; for (let i = 1; i < 7; i++) ctx.fillRect(bx0 + i * T, byA + 5, 1, T - 4);
+ // bottles on the shelf behind the bar (row 3)
+ BOTTLES.forEach(([c, r], i) => blit(ctx, sheet, c, r, (2 + i * 1.4) * T | 0, 3 * T - 2));
+ // bartender behind bar
+ blit(ctx, sheet, BARTENDER[0], BARTENDER[1], 4 * T, 3 * T + 2);
+ // round tables + stools + patrons around the rug
+ const seats = [[3, 9], [3, 12], [18, 9], [18, 12], [9, 13], [13, 13]];
+ seats.forEach(([tx, ty], i) => {
+ blit(ctx, sheet, TABLE[0], TABLE[1], tx * T, ty * T);
+ blit(ctx, sheet, STOOL[0], STOOL[1], (tx - 1) * T, ty * T + 4);
+ const p = PATRONS[i % PATRONS.length];
+ blit(ctx, sheet, p[0], p[1], (tx + 1) * T, ty * T);
+ });
+ // barrels (酒桶) right corner + crates left corner
+ blit(ctx, sheet, BARREL_A[0], BARREL_A[1], 20 * T, 11 * T); blit(ctx, sheet, BARREL_B[0], BARREL_B[1], 19 * T, 11 * T); blit(ctx, sheet, BARREL_A[0], BARREL_A[1], 20 * T, 12 * T);
+ blit(ctx, sheet, CRATE[0], CRATE[1], 0, 12 * T); blit(ctx, sheet, CRATE[0], CRATE[1], 1 * T, 13 * T);
+}
+
+export default function TavernGame({ onHotspot }) {
+ const canvasRef = useRef(null);
+ const [ready, setReady] = useState(false);
+ useEffect(() => {
+ const sheet = new Image(); sheet.src = SHEET;
+ let raf, f = 0, buf;
+ sheet.onload = () => {
+ setReady(true);
+ buf = document.createElement('canvas'); buf.width = W; buf.height = H; buildScene(buf, sheet);
+ const ctx = canvasRef.current.getContext('2d'); ctx.imageSmoothingEnabled = false;
+ const render = () => {
+ f++;
+ ctx.drawImage(buf, 0, 0);
+ // spotlight on the dancer
+ const cxp = 11 * T, cyp = 9 * T;
+ const sg = ctx.createRadialGradient(cxp + 8, cyp, 4, cxp + 8, cyp + 6, 40);
+ sg.addColorStop(0, 'rgba(255,240,200,0.22)'); sg.addColorStop(1, 'rgba(255,240,200,0)');
+ ctx.fillStyle = sg; ctx.beginPath(); ctx.ellipse(cxp + 8, cyp + 8, 26, 30, 0, 0, 7); ctx.fill();
+ // dancer (舞女): sway + bob + periodic flip
+ const sway = Math.sin(f * 0.14) * 3, bob = Math.abs(Math.sin(f * 0.14)) * 2, flip = Math.sin(f * 0.07) < 0;
+ ctx.save(); ctx.translate(cxp + 8 + sway, cyp - bob);
+ if (flip) ctx.scale(-1, 1); ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(sheet, DANCER[0] * T, DANCER[1] * T, T, T, -8, 0, T, T); ctx.restore();
+ // animated fire glow over hearth + torches
+ ctx.globalCompositeOperation = 'lighter';
+ for (const [tx, ty, lr] of [[1, 1, 40], [7, 1, 26], [13, 1, 26]]) {
+ const a = 0.22 + 0.12 * Math.sin(f * 0.3 + tx);
+ const g = ctx.createRadialGradient(tx * T + 8, ty * T + 8, 0, tx * T + 8, ty * T + 8, lr);
+ g.addColorStop(0, `rgba(255,150,60,${a})`); g.addColorStop(1, 'rgba(255,150,60,0)');
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(tx * T + 8, ty * T + 8, lr, 0, 7); ctx.fill();
+ }
+ // screen content (chart + scanline + cyan glow)
+ const sx = 9 * T, sy = 6;
+ for (let i = 0; i < 11; i++) { const bh = 3 + ((Math.sin(f * 0.09 + i) + 1) * 4 + (i % 3)) | 0; ctx.fillStyle = i % 2 ? '#7fe9ff' : '#2f9fd0'; ctx.fillRect(sx + 3 + i * 2.4 | 0, sy + 22 - bh, 2, bh); }
+ ctx.fillStyle = 'rgba(127,233,255,0.5)'; ctx.fillRect(sx, sy + (f % 26), 3 * T, 1);
+ const cg = ctx.createRadialGradient(10.5 * T, T, 0, 10.5 * T, T, 40); cg.addColorStop(0, 'rgba(70,200,235,0.16)'); cg.addColorStop(1, 'rgba(70,200,235,0)'); ctx.fillStyle = cg; ctx.beginPath(); ctx.arc(10.5 * T, T, 40, 0, 7); ctx.fill();
+ ctx.globalCompositeOperation = 'source-over';
+ // vignette + dust
+ const vg = ctx.createRadialGradient(W / 2, H * 0.52, H * 0.34, W / 2, H * 0.5, H * 0.8); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(8,4,10,0.55)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);
+ ctx.fillStyle = 'rgba(255,225,170,0.45)';
+ for (let i = 0; i < 12; i++) { const dx = (i * 47 + f * 0.18) % W, dy = (i * 59 + Math.sin(f * 0.02 + i) * 8 + 64) % H; ctx.fillRect(dx | 0, dy | 0, 1, 1); }
+ raf = requestAnimationFrame(render);
+ };
+ render();
+ };
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ return (
+
+
+ {!ready &&
loading tavern…
}
+
+
+
+ );
+}
diff --git a/src/styles.css b/src/styles.css
index 6e707db..d97f7cb 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1026,3 +1026,23 @@ button {
.hero-acct { position: absolute; right: 2.5%; bottom: 3.5%; font-family: Georgia, serif; font-size: clamp(10px, 1vw, 13px); color: #dfe8ff; background: rgba(8,16,30,0.66); border: 1px solid rgba(120,150,200,0.35); border-radius: 8px; padding: 6px 11px; }
.hero-acct button { color: #5fe0ff; background: none; cursor: pointer; font-family: inherit; font-size: inherit; text-decoration: underline; }
@media (max-width: 640px) { .hero-live span:nth-child(3) { display: none; } }
+
+/* ===================================================================== */
+/* 0.24 — 2D RPG game tavern (Kenney Tiny Dungeon CC0 tiles) */
+/* ===================================================================== */
+.gm-home { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 12px; background: radial-gradient(circle at 50% 30%, #16100e, #070506); overflow: hidden; }
+.gm-stage { position: relative; aspect-ratio: 352 / 224; width: min(96vw, calc((100vh - 210px) * 1.571)); box-shadow: 0 14px 44px rgba(0,0,0,0.75), 0 0 0 3px #2a1a10, 0 0 0 7px #120a06, 0 0 0 9px #000; }
+.gm-room { position: absolute; inset: 0; }
+.gm-canvas { width: 100%; height: 100%; display: block; image-rendering: pixelated; image-rendering: crisp-edges; }
+.gm-overlay { position: absolute; inset: 0; }
+.gm-hot { position: absolute; background: transparent; border: 0; padding: 0; cursor: pointer; border-radius: 6px; transition: box-shadow .15s ease, background .15s ease; }
+.gm-hot:hover { background: rgba(70,200,255,0.12); box-shadow: 0 0 0 2px rgba(80,205,255,0.55), 0 0 22px rgba(70,200,255,0.4); }
+.gm-label { position: absolute; transform: translateX(-50%); font-family: Georgia, "Times New Roman", serif; font-size: clamp(10px, 1.25vw, 15px); color: #cfeaff; background: rgba(10,20,34,0.84); border: 1px solid rgba(80,200,255,0.45); border-radius: 7px; padding: 5px 12px; cursor: pointer; white-space: nowrap; box-shadow: 0 2px 9px rgba(0,0,0,0.55); }
+.gm-label:hover { background: rgba(16,34,54,0.96); color: #fff; transform: translateX(-50%) translateY(-1px); }
+.gm-label-booth { color: #7af0b8; border-color: rgba(80,240,180,0.5); }
+.gm-label-bar { color: #f0c070; border-color: rgba(240,190,90,0.45); }
+.gm-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #9a86b8; font-family: Georgia, serif; }
+.gm-caption { text-align: center; font-family: Georgia, "Times New Roman", serif; max-width: 760px; padding: 0 12px; }
+.gm-caption h1 { font-size: clamp(18px, 2.6vw, 30px); color: #f3d27a; margin: 0 0 4px; text-shadow: 0 2px 7px #000; letter-spacing: 0.5px; }
+.gm-caption p { font-size: clamp(11px, 1.3vw, 14px); color: #d8c4a0; margin: 0 0 8px; line-height: 1.5; }
+.gm-caption .hero-live { position: static; justify-content: center; }