diff --git a/index.html b/index.html index c842d2a..27140c0 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 b74ecc8..e92c04e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "0.21.1-pool", + "version": "0.22.1-tavern", "private": true, "type": "module", "scripts": { diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index 09b1317..d2f045b 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -5,7 +5,7 @@ import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx'; import TopDownTavern from './TopDownTavern.jsx'; import { api } from './api.js'; -const RELEASE = '0.21.0-pool'; +const RELEASE = '0.22.1-tavern'; const NAV = [ { id: 'home', label: 'Tavern' }, diff --git a/src/TopDownTavern.jsx b/src/TopDownTavern.jsx index df1befa..22a3335 100644 --- a/src/TopDownTavern.jsx +++ b/src/TopDownTavern.jsx @@ -1,114 +1,130 @@ import React, { useEffect, useRef, useState } from 'react'; -// 3/4 top-down RPG tavern, drawn procedurally on a canvas at a fixed virtual size. -// Pixel-chunky: rendered at TILE px then scaled up with image-rendering:pixelated. -const COLS = 20, ROWS = 13, TILE = 16; // virtual room = 320x208, scaled up -const W = COLS * TILE, H = ROWS * TILE; +// Painterly 3/4 top-down RPG tavern — smooth high-res canvas, warm lighting, +// cohesive with the painted character sprites. +const W = 1200, H = 760; +const rnd = (s) => { let x = Math.sin(s * 127.1) * 43758.5453; return x - Math.floor(x); }; -// tile map legend: . floor # wall = bar T table o stool b barrel r rug f fireplace s shelf c crate p plant w window -const MAP = [ - '####################', - '#wwww##ffff##wwww##s#', - '#..................s#', - '#..bb....rr....cc...#', - '#..bb..rrrrrr..cc...#', - '#......rrrrrr.......#', - '#..T...rrrrrr...T...#', - '#.ooo..........ooo..#', - '#..................##', - '#===================#', - '#...=...=...=...=...##', - '#..................##', - '####################', -]; +function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } +function ellipse(ctx, x, y, rx, ry, c) { ctx.fillStyle = c; ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, 7); ctx.fill(); } -const PAL = { - floorA: '#6b4a2b', floorB: '#5e4126', grain: '#4f361f', - wall: '#3a2a3f', wallTop: '#52405a', mortar: '#241a28', - rug: '#7a1f2b', rugEdge: '#d8a843', rugMid: '#9c2a38', - bar: '#7a4a24', barTop: '#a06a36', barEdge: '#3c220f', - wood: '#8a5a2e', woodHi: '#a87a44', woodLo: '#4a2e16', - barrel: '#6b4524', barrelBand: '#caa54a', barrelTop: '#a8732f', - stone: '#4a4452', stoneHi: '#6a6276', - fire: '#ff9f38', fireHot: '#ffd66b', glass: '#2aa9d8', -}; - -function px(ctx, x, y, w, h, c) { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); } - -function drawFloor(ctx, x, y) { - const a = ((x / TILE) + (y / TILE)) % 2 === 0; - px(ctx, x, y, TILE, TILE, a ? PAL.floorA : PAL.floorB); - ctx.fillStyle = PAL.grain; - ctx.fillRect(x, y + 5, TILE, 1); ctx.fillRect(x, y + 11, TILE, 1); - ctx.fillRect(x + (a ? 4 : 11), y, 1, TILE); -} -function drawWall(ctx, x, y) { - px(ctx, x, y, TILE, TILE, PAL.wall); - px(ctx, x, y, TILE, 4, PAL.wallTop); // 3/4 top cap - ctx.fillStyle = PAL.mortar; - ctx.fillRect(x, y + 9, TILE, 1); - ctx.fillRect(x + ((y / TILE) % 2 ? 8 : 0), y + 4, 1, 5); - ctx.fillRect(x + ((y / TILE) % 2 ? 0 : 8), y + 9, 1, 7); -} -function drawRug(ctx, x, y, edge, midOnly) { - px(ctx, x, y, TILE, TILE, PAL.rug); - if (midOnly) { px(ctx, x + 3, y + 3, TILE - 6, TILE - 6, PAL.rugMid); } - ctx.fillStyle = PAL.rugEdge; - if (edge.t) ctx.fillRect(x, y, TILE, 2); - if (edge.b) ctx.fillRect(x, y + TILE - 2, TILE, 2); - if (edge.l) ctx.fillRect(x, y, 2, TILE); - if (edge.r) ctx.fillRect(x + TILE - 2, y, 2, TILE); -} - -function drawTile(ctx, ch, x, y, neigh) { - switch (ch) { - case '#': drawWall(ctx, x, y); break; - case 'w': drawWall(ctx, x, y); px(ctx, x + 3, y + 5, TILE - 6, TILE - 8, '#0a1830'); px(ctx, x + 4, y + 6, TILE - 8, TILE - 10, PAL.glass); px(ctx, x + 7, y + 6, 1, TILE - 10, '#0a1830'); break; - case 's': drawWall(ctx, x, y); px(ctx, x + 2, y + 4, TILE - 4, 2, PAL.wood); px(ctx, x + 2, y + 8, TILE - 4, 2, PAL.wood); px(ctx, x + 2, y + 12, TILE - 4, 2, PAL.wood); px(ctx, x + 4, y + 1, 2, 3, PAL.woodHi); px(ctx, x + 9, y + 5, 2, 3, PAL.fire); break; - case 'f': drawWall(ctx, x, y); px(ctx, x + 2, y + 6, TILE - 4, TILE - 6, '#1a1014'); break; - default: drawFloor(ctx, x, y); - if (ch === 'r') { - const e = { t: neigh.t !== 'r', b: neigh.b !== 'r', l: neigh.l !== 'r', r: neigh.r !== 'r' }; - drawRug(ctx, x, y, e, true); - } - if (ch === '=') { // bar counter - px(ctx, x, y + 3, TILE, TILE - 3, PAL.bar); px(ctx, x, y, TILE, 4, PAL.barTop); px(ctx, x, y + 3, TILE, 1, PAL.barEdge); px(ctx, x, y + TILE - 1, TILE, 1, PAL.barEdge); - } - if (ch === 'T') { px(ctx, x + 1, y + 2, TILE - 2, TILE - 4, PAL.woodLo); px(ctx, x + 1, y + 1, TILE - 2, 4, PAL.wood); px(ctx, x + 3, y + 2, TILE - 6, 1, PAL.woodHi); } - if (ch === 'o') { px(ctx, x + 5, y + 6, 6, 6, PAL.woodLo); px(ctx, x + 5, y + 5, 6, 2, PAL.wood); } - if (ch === 'b') { px(ctx, x + 3, y + 2, TILE - 6, TILE - 3, PAL.barrel); px(ctx, x + 3, y + 1, TILE - 6, 3, PAL.barrelTop); px(ctx, x + 3, y + 6, TILE - 6, 1, PAL.barrelBand); px(ctx, x + 3, y + 11, TILE - 6, 1, PAL.barrelBand); } - if (ch === 'c') { px(ctx, x + 2, y + 3, TILE - 4, TILE - 4, PAL.wood); px(ctx, x + 2, y + 2, TILE - 4, 2, PAL.woodHi); ctx.fillStyle = PAL.woodLo; ctx.fillRect(x + 2, y + 8, TILE - 4, 1); ctx.fillRect(x + 8, y + 3, 1, TILE - 5); } +function buildScene(buf) { + const ctx = buf.getContext('2d'); + // ---- floor: warm wood with radial light + planks ---- + const fg = ctx.createLinearGradient(0, 180, 0, H); + fg.addColorStop(0, '#5a3a20'); fg.addColorStop(0.5, '#6e4727'); fg.addColorStop(1, '#4a2e18'); + ctx.fillStyle = fg; ctx.fillRect(0, 180, W, H - 180); + for (let y = 200; y < H; y += 46) { // plank seams (perspective: wider toward bottom) + ctx.strokeStyle = 'rgba(40,24,12,0.55)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + ctx.strokeStyle = 'rgba(150,100,60,0.10)'; ctx.beginPath(); ctx.moveTo(0, y + 2); ctx.lineTo(W, y + 2); ctx.stroke(); + for (let x = 0; x < W; x += 150) { ctx.strokeStyle = 'rgba(40,24,12,0.4)'; ctx.beginPath(); ctx.moveTo(x + rnd(x + y) * 60, y); ctx.lineTo(x + rnd(x + y) * 60, y + 46); ctx.stroke(); } + } + // ---- rug (center, patterned) ---- + const cx = W / 2, ry = 470; + for (const [rw, rh, col] of [[420, 220, '#5a1a24'], [400, 205, '#7a2330'], [300, 150, '#5a1a24']]) { ctx.fillStyle = col; ctx.beginPath(); ctx.ellipse(cx, ry, rw / 2, rh / 2, 0, 0, 7); ctx.fill(); } + ctx.strokeStyle = '#d6a846'; ctx.lineWidth = 4; ctx.beginPath(); ctx.ellipse(cx, ry, 195, 98, 0, 0, 7); ctx.stroke(); + ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(cx, ry - 70); ctx.lineTo(cx + 150, ry); ctx.lineTo(cx, ry + 70); ctx.lineTo(cx - 150, ry); ctx.closePath(); ctx.stroke(); + // ---- back wall (top) stone with gradient + rail ---- + const wg = ctx.createLinearGradient(0, 0, 0, 200); + wg.addColorStop(0, '#241c30'); wg.addColorStop(1, '#3a2f48'); + ctx.fillStyle = wg; ctx.fillRect(0, 0, W, 190); + for (let y = 0; y < 180; y += 36) for (let x = (Math.floor(y / 36) % 2 ? 0 : -40); x < W; x += 80) { ctx.strokeStyle = 'rgba(18,12,26,0.7)'; ctx.lineWidth = 2; ctx.strokeRect(x, y, 80, 36); ctx.strokeStyle = 'rgba(120,108,140,0.15)'; ctx.beginPath(); ctx.moveTo(x + 2, y + 2); ctx.lineTo(x + 78, y + 2); ctx.stroke(); } + // wainscot rail + const rg = ctx.createLinearGradient(0, 175, 0, 200); rg.addColorStop(0, '#8a5a2e'); rg.addColorStop(1, '#5a3a1c'); + ctx.fillStyle = rg; ctx.fillRect(0, 175, W, 22); ctx.fillStyle = 'rgba(180,130,80,0.5)'; ctx.fillRect(0, 175, W, 3); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(0, 197, W, 8); // shadow below rail + // windows (night sky) + for (const wx of [150, W - 230]) { + ctx.fillStyle = '#5a3a1c'; roundRect(ctx, wx - 6, 30, 92, 110, 8); ctx.fill(); + const sg = ctx.createLinearGradient(0, 36, 0, 132); sg.addColorStop(0, '#16243f'); sg.addColorStop(1, '#0c1428'); + ctx.fillStyle = sg; ctx.fillRect(wx, 36, 80, 98); + ctx.fillStyle = '#cfe0ff'; for (let i = 0; i < 9; i++) ctx.fillRect(wx + rnd(i + wx) * 76, 38 + rnd(i * 3) * 92, 2, 2); + ctx.fillStyle = '#5a3a1c'; ctx.fillRect(wx + 38, 36, 4, 98); ctx.fillRect(wx, 82, 80, 4); + } + // hearth (center back) + const hx = cx - 110; + ctx.fillStyle = '#2a2333'; roundRect(ctx, hx, 8, 220, 188, 10); ctx.fill(); + ctx.fillStyle = '#1a1422'; roundRect(ctx, hx + 24, 50, 172, 130, 8); ctx.fill(); + ctx.fillStyle = '#120a08'; roundRect(ctx, hx + 36, 66, 148, 110, 6); ctx.fill(); // fire cavity (fire animated) + const mg = ctx.createLinearGradient(0, 188, 0, 210); mg.addColorStop(0, '#9a6a36'); mg.addColorStop(1, '#5a3a1c'); + ctx.fillStyle = mg; ctx.fillRect(hx - 14, 188, 248, 18); + // back-bar shelf with bottles (under rail, sides of hearth) + for (const sx of [40, hx + 250]) { + ctx.fillStyle = '#4a3018'; ctx.fillRect(sx, 150, 230, 30); + ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(sx, 176, 230, 5); + const cols = ['#4a9a64', '#b46a52', '#6a82c8', '#caa54a', '#8a5ab0']; + for (let i = 0; i < 11; i++) { const bx = sx + 8 + i * 20, bc = cols[i % 5]; ctx.fillStyle = bc; roundRect(ctx, bx, 124, 9, 30, 3); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.fillRect(bx + 1, 126, 2, 22); } + } + // ---- bar counter (front of bartender) ---- + const barY = 360, barW = 520, barX = cx - barW / 2; + ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.beginPath(); ctx.ellipse(cx, barY + 70, barW / 2 + 10, 30, 0, 0, 7); ctx.fill(); + const bg = ctx.createLinearGradient(0, barY, 0, barY + 64); bg.addColorStop(0, '#a06a38'); bg.addColorStop(0.18, '#7a4a24'); bg.addColorStop(1, '#5a3418'); + ctx.fillStyle = bg; roundRect(ctx, barX, barY, barW, 64, 10); ctx.fill(); + ctx.fillStyle = 'rgba(200,150,90,0.6)'; roundRect(ctx, barX + 6, barY + 4, barW - 12, 8, 4); ctx.fill(); // top sheen + for (let i = 1; i < 7; i++) { ctx.strokeStyle = 'rgba(40,24,12,0.5)'; ctx.beginPath(); ctx.moveTo(barX + i * barW / 7, barY + 16); ctx.lineTo(barX + i * barW / 7, barY + 60); ctx.stroke(); } + for (const mx of [barX + 70, barX + barW - 90]) { ctx.fillStyle = '#caa54a'; roundRect(ctx, mx, barY + 14, 22, 26, 4); ctx.fill(); ctx.fillStyle = '#e8dca0'; roundRect(ctx, mx + 2, barY + 10, 18, 8, 3); ctx.fill(); } + // ---- tables + stools (sides) ---- + for (const [tx, ty] of [[230, 600], [W - 230, 600]]) { + ctx.fillStyle = 'rgba(0,0,0,0.4)'; ellipse(ctx, tx, ty + 22, 70, 24, 'rgba(0,0,0,0.4)'); + ellipse(ctx, tx, ty, 64, 40, '#5a3418'); ellipse(ctx, tx, ty - 4, 60, 36, '#7a4a24'); ellipse(ctx, tx - 12, ty - 10, 30, 16, '#9a6a3a'); + ctx.fillStyle = '#e8dca0'; ctx.fillRect(tx - 4, ty - 14, 8, 14); // candle + } + // ---- barrels (left) + crates (right) ---- + for (const [bx, by] of [[70, 580], [120, 612], [92, 660]]) { + ellipse(ctx, bx + 22, by + 50, 26, 8, 'rgba(0,0,0,0.4)'); + const bgr = ctx.createLinearGradient(bx, 0, bx + 44, 0); bgr.addColorStop(0, '#6a4422'); bgr.addColorStop(0.5, '#8a5a30'); bgr.addColorStop(1, '#5a3618'); + ctx.fillStyle = bgr; roundRect(ctx, bx, by, 44, 54, 8); ctx.fill(); + ctx.fillStyle = '#c8a040'; ctx.fillRect(bx, by + 10, 44, 5); ctx.fillRect(bx, by + 38, 44, 5); + ellipse(ctx, bx + 22, by + 4, 22, 7, '#9a6a3a'); + } + for (const [cx2, cy2] of [[W - 130, 600], [W - 78, 632]]) { + ellipse(ctx, cx2 + 26, cy2 + 52, 30, 8, 'rgba(0,0,0,0.4)'); + ctx.fillStyle = '#7a4a24'; roundRect(ctx, cx2, cy2, 52, 50, 4); ctx.fill(); + ctx.fillStyle = 'rgba(180,130,80,0.5)'; ctx.fillRect(cx2 + 3, cy2 + 3, 46, 4); + ctx.strokeStyle = '#5a3418'; ctx.lineWidth = 3; ctx.strokeRect(cx2 + 3, cy2 + 3, 46, 44); ctx.beginPath(); ctx.moveTo(cx2 + 4, cy2 + 4); ctx.lineTo(cx2 + 48, cy2 + 46); ctx.stroke(); + } + // banners + for (const [bx, col] of [[60, '#3cc8f0'], [W - 96, '#d6a846']]) { + ctx.fillStyle = col; ctx.globalAlpha = 0.9; ctx.beginPath(); ctx.moveTo(bx, 6); ctx.lineTo(bx + 36, 6); ctx.lineTo(bx + 36, 110); ctx.lineTo(bx + 18, 124); ctx.lineTo(bx, 110); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1; + ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.fillRect(bx + 24, 6, 12, 104); + ctx.fillStyle = '#d6a846'; roundRect(ctx, bx + 12, 48, 12, 12, 2); ctx.fill(); } } -export default function TopDownTavern({ stage = {}, onHotspot }) { +const LIGHTS = [[W / 2, 90, 150, 0.55, '255,170,90'], [W / 2, 360, 220, 0.22, '255,180,100'], [150, 60, 80, 0.3, '255,200,120'], [W - 190, 60, 80, 0.3, '255,200,120'], [230, 586, 70, 0.32, '255,190,110'], [W - 230, 586, 70, 0.32, '255,190,110']]; + +export default function TopDownTavern({ onHotspot }) { const canvasRef = useRef(null); - const [t, setT] = useState(0); useEffect(() => { + const buf = document.createElement('canvas'); buf.width = W; buf.height = H; buildScene(buf); const ctx = canvasRef.current.getContext('2d'); - ctx.imageSmoothingEnabled = false; - let raf, frame = 0; - const at = (c, r) => (MAP[r] && MAP[r][c]) || '.'; + let raf, f = 0; const render = () => { - frame++; - // base map - for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { - const ch = at(c, r); - drawTile(ctx, ch, c * TILE, r * TILE, { t: at(c, r - 1), b: at(c, r + 1), l: at(c - 1, r), r: at(c + 1, r) }); + f++; + ctx.drawImage(buf, 0, 0); + // hearth fire + const hx = W / 2 - 74; + for (let i = 0; i < 6; i++) { + const fl = 0.55 + 0.45 * Math.sin(f * 0.16 + i * 1.3), bx = hx + 8 + i * 22; + const fgr = ctx.createLinearGradient(0, 176, 0, 100); fgr.addColorStop(0, '#ff8a2a'); fgr.addColorStop(1, 'rgba(255,210,90,0)'); + ctx.fillStyle = fgr; ctx.beginPath(); ctx.moveTo(bx, 178); ctx.quadraticCurveTo(bx + 8, 150 - fl * 30, bx + 11, 120 - fl * 26); ctx.quadraticCurveTo(bx + 14, 150 - fl * 30, bx + 22, 178); ctx.fill(); } - // fireplace fire (animated) under the 'f' wall tiles row1 cols6-9 - const flick = 0.6 + 0.4 * Math.sin(frame * 0.2); - for (let c = 6; c <= 9; c++) { - const x = c * TILE, y = 1 * TILE; - px(ctx, x + 3, y + 8 - flick * 3, TILE - 6, 6 + flick * 3, PAL.fire); - px(ctx, x + 5, y + 10 - flick * 2, TILE - 10, 4, PAL.fireHot); + // warm lighting + ctx.globalCompositeOperation = 'lighter'; + for (const [lx, ly, lr, la, rgb] of LIGHTS) { + const a = la * (0.85 + 0.15 * Math.sin(f * 0.09 + lx)); + const g = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr); g.addColorStop(0, `rgba(${rgb},${a})`); g.addColorStop(1, `rgba(${rgb},0)`); + ctx.fillStyle = g; ctx.beginPath(); ctx.arc(lx, ly, lr, 0, 7); ctx.fill(); } - // wall lantern glow (animated) near windows - ctx.globalAlpha = 0.10 + 0.05 * Math.sin(frame * 0.15); - [[3, 2], [16, 2], [10, 1]].forEach(([c, r]) => { ctx.fillStyle = PAL.fire; ctx.beginPath(); ctx.arc(c * TILE + 8, r * TILE + 10, 14, 0, 7); ctx.fill(); }); - ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + // vignette + const vg = ctx.createRadialGradient(W / 2, H * 0.5, H * 0.28, W / 2, H * 0.5, H * 0.82); + vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(8,4,12,0.7)'); + ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); + // dust + ctx.fillStyle = 'rgba(255,220,160,0.45)'; + for (let i = 0; i < 22; i++) { const dx = (i * 67 + f * 0.35) % W, dy = (i * 53 + Math.sin(f * 0.018 + i) * 24 + 220) % H; ctx.fillRect(dx, dy, 2, 2); } raf = requestAnimationFrame(render); - if (frame % 30 === 0) setT(frame); }; render(); return () => cancelAnimationFrame(raf); @@ -118,16 +134,13 @@ export default function TopDownTavern({ stage = {}, onHotspot }) {