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 (
{!ready &&
loading tavern…
}
onHotspot?.('agents')} /> onHotspot?.('booth')} /> onHotspot?.('mansion')} />
); } function Npc({ x, y, w, h, who, say, onClick }) { const [hover, setHover] = useState(false); return ( ); }