diff --git a/README.md b/README.md index e879f31..94f7573 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/index.html b/index.html index 9c968f6..d0c825a 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ name="description" content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries." /> - + diff --git a/package.json b/package.json index 3727b6a..3cb9810 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "0.23.0-hero", + "version": "0.24.0-game", "private": true, "type": "module", "scripts": { diff --git a/public/scene2d/dungeon-LICENSE.txt b/public/scene2d/dungeon-LICENSE.txt new file mode 100644 index 0000000..1b269f6 --- /dev/null +++ b/public/scene2d/dungeon-LICENSE.txt @@ -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 \ No newline at end of file diff --git a/public/scene2d/dungeon.png b/public/scene2d/dungeon.png new file mode 100644 index 0000000..f6e8b93 Binary files /dev/null and b/public/scene2d/dungeon.png differ diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index 2b89a5e..d1bfd5b 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -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 }) => ( - - : 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; }