tavern: real 2D-RPG game scene from CC0 game tiles (Kenney Tiny Dungeon)

- TavernGame renders an actual tile-based RPG tavern: brick walls, floor,
  bar(吧台)+bartender(酒保)+bottles, tables(圆桌)+patrons, rug, dancer(舞女, spotlit+sway),
  barrels(酒桶), and the cyan amerc screen(屏幕) — animated fire/screen/dust
- tiles downloaded from kenney.nl (CC0), bundled as public/scene2d/dungeon.png
- credit added to README
This commit is contained in:
artheru 2026-06-10 01:57:25 +08:00
parent 564ce38c9c
commit 4d82fc07a4
8 changed files with 180 additions and 26 deletions

View File

@ -47,3 +47,7 @@ npm run build # -> dist/
The pixel sprites in `public/scene2d/` are downscaled, nearest-neighbour-upscaled
versions of the source art (kept out of this repo).
## Credits
Tavern scene tiles: **Kenney Tiny Dungeon** (CC0) — https://kenney.nl/assets/tiny-dungeon

View File

@ -7,7 +7,7 @@
name="description"
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
/>
<meta name="version" content="0.23.0-hero" />
<meta name="version" content="0.24.0-game" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "0.23.0-hero",
"version": "0.24.0-game",
"private": true,
"type": "module",
"scripts": {

View File

@ -0,0 +1,22 @@
Tiny Dungeon (1.0)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 05-07-2022
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
------------------------------
Donate: http://support.kenney.nl
Patreon: http://patreon.com/kenney/
Follow on Twitter for updates:
http://twitter.com/KenneyNL

BIN
public/scene2d/dungeon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -2,9 +2,10 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Menu, X } from 'lucide-react';
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx';
import TavernGame from './TavernGame.jsx';
import { api } from './api.js';
const RELEASE = '0.23.0-hero';
const RELEASE = '0.24.0-game';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -63,28 +64,17 @@ function PixelTopbar({ route, setRoute, auth }) {
);
}
function Home({ setRoute, auth }) {
function Home({ setRoute }) {
const [stats, setStats] = useState(null);
useEffect(() => { api('/agents/stats').then(setStats).catch(() => {}); }, []);
const Hot = ({ s, to, title, glow }) => (
<button className={`hero-hot${glow ? ' hero-glow' : ''}`} style={s} onClick={() => setRoute(to)} title={title} aria-label={title} />
);
return (
<div className="hero-wrap">
<div className="hero-stage">
<img className="hero-img" src="/scene2d/tavern-hero.jpg" alt="amerc — hire agents like mercenaries" />
{/* top nav, over the painted bar */}
<Hot s={{ left: '0.5%', top: '1%', width: '19%', height: '17%' }} to="home" title="amerc home" />
<Hot s={{ left: '34%', top: '3.5%', width: '8.5%', height: '8%' }} to="agents" title="Staff" />
<Hot s={{ left: '41.5%', top: '3.5%', width: '9%', height: '8%' }} to="agents" title="Pricing" />
<Hot s={{ left: '65.5%', top: '3.5%', width: '9.5%', height: '8%' }} to="signin" title="Sign Up" />
<Hot s={{ left: '74%', top: '3.5%', width: '8%', height: '8%' }} to="signin" title="Login" />
<Hot s={{ left: '81.5%', top: '3.5%', width: '11%', height: '8%' }} to="booth" title="My Booth" />
{/* big interactive zones */}
<Hot s={{ left: '27%', top: '31%', width: '38%', height: '25%' }} to="agents" title="Browse Agents" glow />
<Hot s={{ left: '39%', top: '73%', width: '23.5%', height: '13%' }} to="booth" title="Enter My Booth" glow />
<Hot s={{ left: '75%', top: '23%', width: '22%', height: '28%' }} to="agents" title="Capabilities" />
{/* live platform stat chips (subtle, bottom-left — keeps the art clean) */}
<div className="gm-home">
<div className="gm-stage">
<TavernGame onHotspot={setRoute} />
</div>
<div className="gm-caption">
<h1>Hire agents like mercenaries.</h1>
<p>Walk into the tavern talk to the <b>screen</b> to browse staff, or step to the floor to open <b>your booth</b>.</p>
{stats && (
<div className="hero-live">
<span><b>{stats.classes}</b> classes</span>
@ -92,9 +82,6 @@ function Home({ setRoute, auth }) {
<span><b>{stats.sessions}</b> sessions</span>
</div>
)}
{auth?.user
? <div className="hero-acct">{auth.user.handle}{auth.user.role === 'admin' ? ' · admin' : ''} · <button onClick={() => auth.logout()}>log out</button></div>
: null}
</div>
</div>
);
@ -123,7 +110,7 @@ export default function Scene2DApp() {
return (
<main className="px-app td-app">
<h1 className="sr-only">amerc agent mercenary tavern</h1>
{route !== 'home' && <PixelTopbar route={route} setRoute={setRoute} auth={auth} />}
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
<Scene route={route} setRoute={setRoute} auth={auth} />
</main>
);

121
src/TavernGame.jsx Normal file
View File

@ -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 (
<div className="gm-room">
<canvas ref={canvasRef} width={W} height={H} className="gm-canvas" />
{!ready && <div className="gm-loading">loading tavern</div>}
<div className="gm-overlay">
<button className="gm-hot" style={{ left: '40%', top: '3%', width: '20%', height: '15%' }} onClick={() => onHotspot?.('agents')} title="Browse Agents (the screen)" />
<button className="gm-label gm-label-screen" style={{ left: '50%', top: '20%' }} onClick={() => onHotspot?.('agents')}> Browse Agents</button>
<button className="gm-hot" style={{ left: '46%', top: '55%', width: '12%', height: '22%' }} onClick={() => onHotspot?.('booth')} title="My Booth" />
<button className="gm-label gm-label-booth" style={{ left: '50%', top: '90%' }} onClick={() => onHotspot?.('booth')}> Enter My Booth</button>
<button className="gm-label gm-label-bar" style={{ left: '15%', top: '30%' }} onClick={() => onHotspot?.('agents')}>🍺 The Bar</button>
</div>
</div>
);
}

View File

@ -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; }