diff --git a/index.html b/index.html index 9b61d08..28d2794 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@ @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } - + diff --git a/package.json b/package.json index 34a3d64..3f526fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "3.3.0-polish", + "version": "4.0.0-rpg", "private": true, "type": "module", "scripts": { diff --git a/src/RpgTavern.jsx b/src/RpgTavern.jsx new file mode 100644 index 0000000..4042972 --- /dev/null +++ b/src/RpgTavern.jsx @@ -0,0 +1,181 @@ +import React, { useEffect, useRef, useState } from 'react'; + +// A playable top-down RPG tavern (Pokémon-style): walk your character around +// with arrows/WASD (or the on-screen pad), bump up to NPCs and fixtures and +// press E to interact — each one opens a part of amerc. LPC tiles + 64px +// chibi character sheets (9 frames × 4 dirs; rows up/left/down/right). +const T = 32, COLS = 21, ROWS = 12, W = COLS * T, H = ROWS * T; +const L = '/scene2d/lpc/'; +const SRC = { inside: L + 'inside.png', floors: L + 'castlefloors.png', vic: L + 'victoria.png', barrel: L + 'barrel.png', soldier: L + 'soldier.png', soldier2: L + 'soldier_altcolor.png', princess: L + 'princess.png' }; +const WALL = [0, 4], FLOOR = [0, 7]; +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]; +const DIR = { up: 0, left: 1, down: 2, right: 3 }; + +const tile = (ctx, s, c, r, dx, dy, tw = T, th = T) => ctx.drawImage(s, c * T, r * T, tw, th, dx, dy, tw, th); +const chr = (ctx, sheet, col, row, dx, dy) => ctx.drawImage(sheet, col * 64, row * 64, 64, 64, dx, dy, 64, 64); + +export default function RpgTavern({ onAction, auth, stats }) { + const canvasRef = useRef(null); + const gameRef = useRef(null); + const [prompt, setPrompt] = useState(null); // current interactable label + const [dialog, setDialog] = useState(null); // { who, line } + const [ready, setReady] = useState(false); + const authRef = useRef(auth); useEffect(() => { authRef.current = auth; }, [auth]); + const statsRef = useRef(stats); useEffect(() => { statsRef.current = stats; }, [stats]); + + useEffect(() => { + const S = {}; const keys = Object.keys(SRC); let loaded = 0, raf, paused = false, io; + + // collision grid + interactables + const solid = new Set(); + const mark = (tx, ty) => solid.add(tx + ',' + ty); + for (let x = 0; x < COLS; x++) { mark(x, 0); mark(x, 1); mark(x, 2); mark(x, ROWS - 1); } // top wall + seam, bottom + for (let y = 0; y < ROWS; y++) { mark(0, y); mark(COLS - 1, y); } // side walls + const bar = { x0: 2, x1: 7, y: 3 }; for (let x = bar.x0; x <= bar.x1; x++) mark(x, bar.y); // bar counter + [[2, 8], [3, 8], [16, 8], [17, 8]].forEach(([x, y]) => mark(x, y)); // tables + [[18, 9], [19, 9]].forEach(([x, y]) => mark(x, y)); // barrels + + const px = (tx) => tx * T + T / 2, py = (ty) => ty * T + T / 2; + const user = () => authRef.current?.user; + const npcs = [ + { sheet: 'soldier', dir: 'down', tx: 4, ty: 2, name: 'Barkeep', face: 'down', + interact: () => { const u = user(); if (u) onAction('legion'); else onAction('login'); }, label: () => (user() ? '🍺 To your Legion' : '🍺 Sign in') }, + { sheet: 'princess', dir: 'down', tx: 11, ty: 6, name: 'Elf host', face: 'down', + interact: () => onAction('tour'), label: () => '✦ What is amerc?' }, + { sheet: 'soldier2', dir: 'up', tx: 3, ty: 9, name: 'Dwarf patron', face: 'up', + interact: () => setDialog({ who: 'Patron', line: 'I field three mercenaries straight from my Legion. Talk to the barkeep to raise yours.' }), label: () => '💬 Chat' }, + { sheet: 'soldier2', dir: 'up', tx: 16, ty: 9, name: 'Patron', face: 'up', + interact: () => setDialog({ who: 'Patron', line: 'Showcase put my little app on the open internet — ask about the Mansion.' }), label: () => '💬 Chat' }, + ]; + // wall fixtures (drawn in static buffer) with interaction zones + const fixtures = [ + { tx: 9.5, ty: 1.6, name: 'Roster board', interact: () => onAction('agents'), label: () => '⚔ Browse Agents' }, + { tx: 18, ty: 2, name: 'Legion banner', interact: () => onAction('legion'), label: () => '🚩 My Legion' }, + { tx: 19.4, ty: 6, name: 'Estate door', interact: () => onAction('mansion'), label: () => '🏰 My Mansion' }, + ]; + const targets = [...npcs.map((n) => ({ ...n, cx: px(n.tx), cy: py(n.ty) })), ...fixtures.map((f) => ({ ...f, cx: px(f.tx), cy: py(f.ty) }))]; + + const isSolid = (tx, ty) => solid.has(tx + ',' + ty); + const blocked = (fx, fy) => { // feet box ~22x12 centred on (fx,fy-2) + const corners = [[fx - 10, fy - 10], [fx + 10, fy - 10], [fx - 10, fy], [fx + 10, fy]]; + return corners.some(([cx, cy]) => isSolid(Math.floor(cx / T), Math.floor(cy / T))); + }; + + const g = { player: { x: px(10), y: py(9), dir: 'up', moving: false, frame: 0, ft: 0 }, keys: new Set(), near: null, f: 0 }; + gameRef.current = g; + + const buildStatic = (buf) => { + const ctx = buf.getContext('2d'); ctx.imageSmoothingEnabled = false; + 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); + 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); + 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); + for (const wx of [40, 560]) { 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); tile(ctx, S.vic, CURTAIN[0], CURTAIN[1], wx - 18, 2); tile(ctx, S.vic, CURTAIN2[0], CURTAIN2[1], wx + 28, 2); } + // rug centre + const rx = 8 * T, ry = 6 * T, rc = 4, rr = 3; + for (let j = 0; j < rr; j++) for (let i = 0; i < rc; i++) { const k = i === 0 ? 'l' : i === rc - 1 ? 'r' : 'c'; const kk = j === 0 ? (k === 'l' ? 'tl' : k === 'r' ? 'tr' : 't') : j === rr - 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); } + // bar counter + ctx.fillStyle = '#5e3a22'; ctx.fillRect(bar.x0 * T, bar.y * T + 6, (bar.x1 - bar.x0 + 1) * T, 26); ctx.fillStyle = '#8a5a32'; ctx.fillRect(bar.x0 * T, bar.y * T + 6, (bar.x1 - bar.x0 + 1) * T, 4); ctx.fillStyle = '#2a1810'; ctx.fillRect(bar.x0 * T, bar.y * T + 30, (bar.x1 - bar.x0 + 1) * T, 4); + for (const bxp of [3, 5, 7]) tile(ctx, S.vic, VASE[0], VASE[1], bxp * T + 6, bar.y * T - 14, 20, 20); + // tables + chairs + for (const tx of [2, 16]) { tile(ctx, S.vic, CHAIR[0], CHAIR[1], tx * T - 4, 8 * T - 6); tile(ctx, S.vic, CHAIR_R[0], CHAIR_R[1], tx * T + 36, 8 * T - 6); tile(ctx, S.vic, TABLE[0], TABLE[1], tx * T + 16, 8 * T + 2); } + // barrels + plants + lamps + tile(ctx, S.barrel, 0, 0, 18 * T, 9 * T - 4); tile(ctx, S.barrel, 1, 0, 19 * T, 9 * T); tile(ctx, S.vic, PLANT[0], PLANT[1], 1 * T, 9 * T); tile(ctx, S.vic, LAMP[0], LAMP[1], 1 * T, 3 * T); + // roster board (glowing screen) on the top wall + ctx.fillStyle = '#0c2030'; ctx.fillRect(9 * T - 4, 6, 4 * T, 44); ctx.strokeStyle = '#46c8e0'; ctx.lineWidth = 2; ctx.strokeRect(9 * T - 3, 7, 4 * T - 2, 42); ctx.fillStyle = '#16415e'; ctx.fillRect(9 * T, 10, 4 * T - 8, 36); + ctx.fillStyle = '#9af0ff'; ctx.font = 'bold 11px Georgia, serif'; ctx.textAlign = 'center'; ctx.fillText('BROWSE AGENTS', 11 * T, 26); ctx.font = '8px Georgia'; ctx.fillStyle = '#7fd9ea'; ctx.fillText('the roster', 11 * T, 38); ctx.textAlign = 'left'; + // legion banner (right wall) + ctx.fillStyle = '#2a1d4e'; ctx.beginPath(); ctx.moveTo(18 * T, 2 * T - 8); ctx.lineTo(18 * T + 28, 2 * T - 8); ctx.lineTo(18 * T + 28, 2 * T + 26); ctx.lineTo(18 * T + 14, 2 * T + 36); ctx.lineTo(18 * T, 2 * T + 26); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#ffd75e'; ctx.fillRect(18 * T + 11, 2 * T + 2, 6, 12); ctx.fillRect(18 * T + 7, 2 * T + 5, 14, 5); + // estate door (right wall) + ctx.fillStyle = '#241a12'; ctx.fillRect(20 * T - 8, 5 * T, 12, 3 * T); ctx.fillStyle = '#5a3a1c'; ctx.fillRect(20 * T - 6, 5 * T + 4, 8, 3 * T - 8); ctx.fillStyle = 'rgba(127,233,255,0.5)'; ctx.fillRect(20 * T - 5, 5 * T + 6, 6, 3 * T - 12); + }; + + const drawChar = (ctx, sheetKey, dir, frameCol, cx, cy) => chr(ctx, S[sheetKey], frameCol, DIR[dir], cx - 32, cy - 56); + + const start = () => { + setReady(true); + const buf = document.createElement('canvas'); buf.width = W; buf.height = H; buildStatic(buf); + const ctx = canvasRef.current.getContext('2d'); ctx.imageSmoothingEnabled = false; + let lastNear = '__'; + const render = () => { + if (paused) return; + g.f++; const p = g.player; + // movement + let dx = 0, dy = 0; + if (g.keys.has('left')) dx -= 1; if (g.keys.has('right')) dx += 1; if (g.keys.has('up')) dy -= 1; if (g.keys.has('down')) dy += 1; + p.moving = (dx || dy) && !dialog; + if (p.moving) { + if (dy < 0) p.dir = 'up'; else if (dy > 0) p.dir = 'down'; else if (dx < 0) p.dir = 'left'; else if (dx > 0) p.dir = 'right'; + const sp = 1.7, nx = p.x + dx * sp, ny = p.y + dy * sp; + if (!blocked(nx, p.y)) p.x = nx; + if (!blocked(p.x, ny)) p.y = ny; + p.ft++; if (p.ft >= 6) { p.ft = 0; p.frame = 1 + ((p.frame) % 8); } + } else { p.frame = 0; } + // nearest interactable + let near = null, best = 60 * 60; + for (const t of targets) { const d = (t.cx - p.x) ** 2 + (t.cy - (p.y - 18)) ** 2; if (d < best) { best = d; near = t; } } + g.near = near; + const lbl = near ? near.label() : null; + if ((lbl || '__') !== lastNear) { lastNear = lbl || '__'; setPrompt(lbl); } + // draw + ctx.drawImage(buf, 0, 0); + // depth-sorted actors (npcs + player) by y + const actors = npcs.map((n) => ({ y: py(n.ty), draw: () => drawChar(ctx, n.sheet, n.face, (Math.sin(g.f * 0.05 + n.tx) > 0.7 ? 1 : 0), px(n.tx), py(n.ty)) })); + actors.push({ y: p.y, draw: () => drawChar(ctx, 'soldier2', p.dir, p.frame, p.x, p.y) }); + actors.sort((a, b) => a.y - b.y).forEach((a) => a.draw()); + // interaction marker over the nearest target + if (near) { const bob = Math.sin(g.f * 0.18) * 2; ctx.fillStyle = '#ffd75e'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('▾', near.cx, near.cy - 40 + bob); ctx.textAlign = 'left'; } + // warm light + vignette + ctx.globalCompositeOperation = 'lighter'; + for (const [lx, ly, lr] of [[40, 96, 40], [560, 96, 40], [W / 2, 60, 70]]) { const gg = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr); gg.addColorStop(0, 'rgba(255,180,90,0.13)'); gg.addColorStop(1, 'rgba(255,170,80,0)'); ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(lx, ly, lr, 0, 7); ctx.fill(); } + ctx.globalCompositeOperation = 'source-over'; + const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.4, W / 2, H / 2, H * 0.85); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(8,4,10,0.42)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); + raf = requestAnimationFrame(render); + }; + render(); + io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { if (paused) { paused = false; render(); } } else if (!paused) { paused = true; cancelAnimationFrame(raf); } }, { threshold: 0 }); + io.observe(canvasRef.current); + }; + keys.forEach((k) => { const im = new Image(); im.onload = () => { if (++loaded === keys.length) start(); }; im.src = SRC[k]; S[k] = im; }); + return () => { cancelAnimationFrame(raf); if (io) io.disconnect(); }; + }, [onAction]); + + // keyboard + useEffect(() => { + const KEY = { ArrowLeft: 'left', a: 'left', A: 'left', ArrowRight: 'right', d: 'right', D: 'right', ArrowUp: 'up', w: 'up', W: 'up', ArrowDown: 'down', s: 'down', S: 'down' }; + const interact = () => { const g = gameRef.current; if (!g) return; if (dialog) { setDialog(null); return; } if (g.near) g.near.interact(); }; + const onDown = (e) => { + const g = gameRef.current; if (!g) return; + if (KEY[e.key]) { e.preventDefault(); g.keys.add(KEY[e.key]); } + else if (e.key === 'e' || e.key === 'E' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); interact(); } + }; + const onUp = (e) => { const g = gameRef.current; if (g && KEY[e.key]) g.keys.delete(KEY[e.key]); }; + window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp); + return () => { window.removeEventListener('keydown', onDown); window.removeEventListener('keyup', onUp); }; + }, [dialog]); + + // on-screen controls (mobile) + const hold = (d, on) => { const g = gameRef.current; if (!g) return; if (on) g.keys.add(d); else g.keys.delete(d); }; + const tapInteract = () => { const g = gameRef.current; if (!g) return; if (dialog) { setDialog(null); return; } if (g.near) g.near.interact(); }; + + return ( +
+ + {!ready &&
loading tavern…
} + {prompt && !dialog &&
E {prompt}
} + {dialog && ( +
setDialog(null)}> + {dialog.who}

{dialog.line}

▸ press E / tap +
+ )} +
↑↓←→ / WASD to walk · E to interact
+ + +
+ ); +} diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index 4ce78f8..372d43b 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -4,7 +4,7 @@ import Quartermaster from './Quartermaster.jsx'; import { AgentBrowse, LegionDashboard } from './AgentPlatform.jsx'; import Mansion from './Mansion.jsx'; import Account from './Account.jsx'; -import TavernGame from './TavernGame.jsx'; +import RpgTavern from './RpgTavern.jsx'; import { GameDialog } from './GameDialog.jsx'; import { api } from './api.js'; @@ -12,7 +12,7 @@ import { api } from './api.js'; const Menu = ({ size = 20 }) => (); const X = ({ size = 20 }) => (); -const RELEASE = '3.3.0-polish'; +const RELEASE = '4.0.0-rpg'; const NAV = [ { id: 'home', label: 'Tavern' }, @@ -120,7 +120,7 @@ function Home({ setRoute, auth }) { }, [setRoute]); return (
-
+

Hire agents like mercenaries_

every fixture in the tavern is a door — walk in, or read the

@@ -485,6 +485,7 @@ function StatusPage() { } const CHANGELOG = [ + { v: '4.0', date: 'Jun 2026', title: 'The tavern is playable', notes: ['The home page is now a real top-down RPG — walk your character around the tavern with the arrow keys (or WASD, or the on-screen pad on phones)', 'Bump into the barkeep, the elf host, the roster board or the doors and press E to interact — each one opens a part of amerc', 'Patrons have something to say if you walk up and talk to them'] }, { v: '3.3', date: 'Jun 2026', title: 'Calmer, and a greeter', notes: ['The painted scene now holds still for anyone who prefers reduced motion', 'The host greets you with a portrait on the sign-in gate'] }, { v: '3.2', date: 'Jun 2026', title: 'A native web terminal', notes: ['The Web PTY now runs on amerc itself — no relay: it streams the agent’s live terminal and sends your keystrokes (Enter, arrows, Ctrl-C, paste) straight through', 'Open it from a soldier in My Legion (Terminal tab) or from any agent you use'] }, { v: '3.1', date: 'Jun 2026', title: 'The tavern breathes', notes: ['Lantern glow flickers and the bar light pulses over the painted scene', 'On phones the tavern reads cleanly with big tap targets for Browse, Legion and Mansion'] }, diff --git a/src/styles.css b/src/styles.css index 920a08b..640b518 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1975,3 +1975,23 @@ button { /* painted host greeter on the sign-in gate (3.3) */ .gate-greeter { display: block; width: 84px; height: 84px; border-radius: 50%; margin: 0 auto 10px; border: 2px solid #6a5a9a; box-shadow: 0 0 20px rgba(127,233,255,0.32); background-color: #15102a; background-image: url(/scene2d/painted/elf.webp); background-repeat: no-repeat; background-size: 240% auto; background-position: 50% 5%; } + +/* ===== playable RPG tavern (4.0) ===== */ +.rpg { position: relative; overflow: hidden; background: #0a0712; } +.rpg-canvas { width: 100%; height: 100%; display: block; image-rendering: pixelated; } +.rpg-prompt { position: absolute; left: 50%; bottom: 14%; transform: translateX(-50%); background: linear-gradient(180deg, #241509f2, #170d05f2); border: 2px solid #ffd75e; color: #f3e3c2; border-radius: 10px; padding: 7px 14px; font-size: clamp(12px, 1.5vw, 15px); white-space: nowrap; box-shadow: 0 4px 14px rgba(0,0,0,0.5); z-index: 5; } +.rpg-prompt b { display: inline-block; background: #ffd75e; color: #1c1204; border-radius: 5px; padding: 0 6px; margin-right: 6px; font-size: 0.85em; } +.rpg-dialog { position: absolute; left: 4%; right: 4%; bottom: 5%; background: linear-gradient(180deg, #1a1228f5, #0e0a18f5); border: 2px solid #6a5a9a; border-radius: 12px; padding: 12px 16px; z-index: 6; cursor: pointer; box-shadow: 0 8px 26px rgba(0,0,0,0.6); } +.rpg-dialog strong { color: #ffd75e; font-family: Georgia, serif; font-size: clamp(13px, 1.6vw, 16px); } +.rpg-dialog p { margin: 4px 0 6px; color: #e8eef8; font-size: clamp(12px, 1.5vw, 14.5px); line-height: 1.45; } +.rpg-dialog-x { color: #8a7bb0; font-size: 11px; } +.rpg-help { position: absolute; left: 10px; top: 8px; color: #cbb8e8; background: rgba(10,7,18,0.6); border: 1px solid #2a2440; border-radius: 8px; padding: 4px 9px; font-size: 11px; z-index: 5; pointer-events: none; } +.rpg-help b { color: #ffd75e; } +/* on-screen controls */ +.rpg-pad { position: absolute; left: 14px; bottom: 14px; width: 132px; height: 132px; z-index: 7; display: none; } +.rpg-pad button { position: absolute; width: 44px; height: 44px; border-radius: 10px; background: rgba(42,36,64,0.82); border: 1.5px solid #6a5a9a; color: #cbb8e8; font-size: 16px; touch-action: none; user-select: none; } +.rpg-pad button:active { background: rgba(106,90,154,0.9); } +.rpg-up { left: 44px; top: 0; } .rpg-down { left: 44px; bottom: 0; } .rpg-left { left: 0; top: 44px; } .rpg-right { right: 0; top: 44px; } +.rpg-a { position: absolute; right: 16px; bottom: 26px; width: 60px; height: 60px; border-radius: 50%; background: rgba(255,215,94,0.9); border: 2px solid #fff3c4; color: #1c1204; font-weight: 700; font-size: 20px; z-index: 7; display: none; touch-action: none; } +.rpg-a:active { transform: scale(0.94); } +@media (pointer: coarse), (max-width: 760px) { .rpg-pad, .rpg-a { display: block; } .rpg-help { display: none; } }