- hover a character (barkeep / two patrons) -> parchment speech bubble with flavor - clicking routes to Browse / My Booth / Mansion — adds life + feature discovery - verified bubble renders on hover
128 lines
8.8 KiB
JavaScript
128 lines
8.8 KiB
JavaScript
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;
|
|
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', bartender: L + 'bartender.png',
|
|
};
|
|
// tile picks [col,row] (32px)
|
|
const WALL = [0, 4], FLOOR = [0, 7], DOOR = [[8, 1], [9, 1]];
|
|
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 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
|
|
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]) {
|
|
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;
|
|
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);
|
|
}
|
|
// 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);
|
|
}
|
|
|
|
export default function TavernGame({ onHotspot }) {
|
|
const canvasRef = useRef(null);
|
|
const [ready, setReady] = useState(false);
|
|
useEffect(() => {
|
|
const keys = Object.keys(SRC); const S = {}; let loaded = 0, raf, f = 0, buf;
|
|
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 = () => {
|
|
f++;
|
|
ctx.drawImage(buf, 0, 0);
|
|
// bartender (a clothed guard tending bar) behind the bar (idle bob)
|
|
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
|
|
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 (chart + scanline)
|
|
for (let i = 0; i < 13; i++) { const bh = 4 + ((Math.sin(f * 0.09 + i) + 1) * 6 + (i % 3)) | 0; ctx.fillStyle = i % 2 ? '#7fe9ff' : '#2f9fd0'; ctx.fillRect(206 + i * 8, 48 - bh, 5, bh); }
|
|
ctx.fillStyle = 'rgba(127,233,255,0.5)'; ctx.fillRect(202, 10 + (f % 42), 108, 1);
|
|
// warm lighting + screen 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();
|
|
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);
|
|
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); }
|
|
raf = requestAnimationFrame(render);
|
|
};
|
|
render();
|
|
};
|
|
keys.forEach((k) => { const im = new Image(); im.onload = () => { if (++loaded === keys.length) start(); }; im.src = SRC[k]; S[k] = im; });
|
|
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: '38%', top: '1%', width: '24%', height: '16%' }} onClick={() => onHotspot?.('agents')} title="Browse Agents (screen)" />
|
|
<button className="gm-label gm-label-screen" style={{ left: '50%', top: '18%' }} onClick={() => onHotspot?.('agents')}>▸ Browse Agents</button>
|
|
<button className="gm-hot" style={{ left: '44%', top: '50%', width: '14%', height: '24%' }} 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>
|
|
<Npc x="11%" y="4%" w="13%" h="20%" who="Barkeep" say="What'll it be? The staff roster is up on the board." onClick={() => onHotspot?.('agents')} />
|
|
<Npc x="11%" y="52%" w="13%" h="22%" who="Patron" say="I run three mercenaries straight from my booth." onClick={() => onHotspot?.('booth')} />
|
|
<Npc x="72%" y="52%" w="13%" h="22%" who="Patron" say="Showcase put my little app on the open internet!" onClick={() => onHotspot?.('mansion')} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|