rpg: full-window 2x camera world + click-to-move + prominent bubbles (4.5.0)
Visual overhaul per user direction: - canvas fills the whole window (under the topbar); render at 2x through a camera that follows the player across a bigger (28x18), more decorated hall (more tables/rugs/paintings/fireplace/shelves/props) - click-to-move: click the floor and the avatar walks there (keyboard still works); click an adjacent NPC/fixture to interact - prominent glowing interaction bubble above the nearest target + clickable bottom prompt - names over every head (NPCs gold, visitors cyan, you teal) + drop-shadows - serious value-prop + door CTAs overlaid on the game (it looks like a game, but stays a cloud agent tool) - verified: full-window sizing, click-to-move, camera follow, the '✦ What is amerc?' bubble over the host Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9bc77fcc57
commit
3ba0c9c47e
@ -17,7 +17,7 @@
|
|||||||
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
||||||
</style>
|
</style>
|
||||||
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
|
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
|
||||||
<meta name="version" content="4.4.0-emotes" />
|
<meta name="version" content="4.5.0-overhaul" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amerc-site",
|
"name": "amerc-site",
|
||||||
"version": "4.4.0-emotes",
|
"version": "4.5.0-overhaul",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
|
|
||||||
// A playable top-down RPG tavern (Pokémon-style): walk your character around
|
// A top-down RPG tavern that skins the amerc home — walk (arrows/WASD or
|
||||||
// with arrows/WASD (or the on-screen pad), bump up to NPCs and fixtures and
|
// click-to-move), bump NPCs & fixtures and press E to open a part of amerc.
|
||||||
// press E to interact — each one opens a part of amerc. LPC tiles + 64px
|
// LPC tiles + 64px chibi sheets (9 frames × 4 dirs), rendered at 2× through a
|
||||||
// chibi character sheets (9 frames × 4 dirs; rows up/left/down/right).
|
// camera that follows the player. It looks like a game; it's a serious tool.
|
||||||
const T = 32, COLS = 21, ROWS = 12, W = COLS * T, H = ROWS * T;
|
const T = 32, COLS = 28, ROWS = 18, W = COLS * T, H = ROWS * T, ZOOM = 2;
|
||||||
const L = '/scene2d/lpc/';
|
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 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 WALL = [0, 4], FLOOR = [0, 7];
|
||||||
@ -24,8 +24,8 @@ const roundRect = (c, x, y, w, h, r) => { c.beginPath(); c.moveTo(x + r, y); c.a
|
|||||||
export default function RpgTavern({ onAction, auth, stats }) {
|
export default function RpgTavern({ onAction, auth, stats }) {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const gameRef = useRef(null);
|
const gameRef = useRef(null);
|
||||||
const [prompt, setPrompt] = useState(null); // current interactable label
|
const [prompt, setPrompt] = useState(null);
|
||||||
const [dialog, setDialog] = useState(null); // { who, line }
|
const [dialog, setDialog] = useState(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [online, setOnline] = useState(0);
|
const [online, setOnline] = useState(0);
|
||||||
const [myName, setMyName] = useState('');
|
const [myName, setMyName] = useState('');
|
||||||
@ -35,7 +35,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
|
|||||||
const nameRef = useRef('');
|
const nameRef = useRef('');
|
||||||
const pidRef = useRef('');
|
const pidRef = useRef('');
|
||||||
if (!pidRef.current) { try { let v = localStorage.getItem('amerc_pid'); if (!v) { v = 'p_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('amerc_pid', v); } pidRef.current = v; } catch { pidRef.current = 'p_' + Math.random().toString(36).slice(2, 10); } }
|
if (!pidRef.current) { try { let v = localStorage.getItem('amerc_pid'); if (!v) { v = 'p_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('amerc_pid', v); } pidRef.current = v; } catch { pidRef.current = 'p_' + Math.random().toString(36).slice(2, 10); } }
|
||||||
const saysRef = useRef(new Map()); // id -> { text, until }
|
const saysRef = useRef(new Map());
|
||||||
const emoteRef = useRef({ char: '', until: 0 });
|
const emoteRef = useRef({ char: '', until: 0 });
|
||||||
const chatTypingRef = useRef(false);
|
const chatTypingRef = useRef(false);
|
||||||
const lastChatSeqRef = useRef(0);
|
const lastChatSeqRef = useRef(0);
|
||||||
@ -48,191 +48,215 @@ export default function RpgTavern({ onAction, auth, stats }) {
|
|||||||
if (!n) { try { n = localStorage.getItem('amerc_name') || ''; if (!n) { n = randomName(); localStorage.setItem('amerc_name', n); } } catch { n = randomName(); } }
|
if (!n) { try { n = localStorage.getItem('amerc_name') || ''; if (!n) { n = randomName(); localStorage.setItem('amerc_name', n); } } catch { n = randomName(); } }
|
||||||
nameRef.current = n; setMyName(n);
|
nameRef.current = n; setMyName(n);
|
||||||
}, [auth]);
|
}, [auth]);
|
||||||
const rename = () => { if (auth?.user) return; const v = (window.prompt('Your tavern name', myName) || '').trim().slice(0, 20); if (v) { nameRef.current = v; setMyName(v); try { localStorage.setItem('amerc_name', v); } catch { /* private mode */ } } };
|
const rename = () => { if (auth?.user) return; const v = (window.prompt('Your tavern name', myName) || '').trim().slice(0, 20); if (v) { nameRef.current = v; setMyName(v); try { localStorage.setItem('amerc_name', v); } catch { /* private */ } } };
|
||||||
const advanceDialog = () => setDialog((d) => (d && d.i < d.lines.length - 1 ? { ...d, i: d.i + 1 } : null));
|
const advanceDialog = () => setDialog((d) => (d && d.i < d.lines.length - 1 ? { ...d, i: d.i + 1 } : null));
|
||||||
const openChat = () => { const g = gameRef.current; if (g) g.keys.clear(); setChatOpen(true); setTimeout(() => chatInputRef.current?.focus(), 0); };
|
const openChat = () => { const g = gameRef.current; if (g) g.keys.clear(); setChatOpen(true); setTimeout(() => chatInputRef.current?.focus(), 0); };
|
||||||
const doEmote = (char) => { emoteRef.current = { char, until: Date.now() + 2400 }; };
|
const doEmote = (char) => { emoteRef.current = { char, until: Date.now() + 2400 }; };
|
||||||
|
const interactNear = () => { const g = gameRef.current; if (!g) return; if (dialogRef.current) { advanceDialog(); return; } if (g.near) g.near.interact(); };
|
||||||
const sendChat = async () => {
|
const sendChat = async () => {
|
||||||
const t = chatText.trim(); setChatText('');
|
const t = chatText.trim(); setChatText('');
|
||||||
if (!t) { setChatOpen(false); chatInputRef.current?.blur(); return; }
|
if (!t) { setChatOpen(false); chatInputRef.current?.blur(); return; }
|
||||||
saysRef.current.set(pidRef.current, { text: t, until: Date.now() + 5500 }); // optimistic bubble
|
saysRef.current.set(pidRef.current, { text: t, until: Date.now() + 5500 });
|
||||||
try { await api('/agents/tavern-chat', { method: 'POST', body: { id: pidRef.current, name: nameRef.current, text: t } }); } catch { /* offline */ }
|
try { await api('/agents/tavern-chat', { method: 'POST', body: { id: pidRef.current, name: nameRef.current, text: t } }); } catch { /* offline */ }
|
||||||
};
|
};
|
||||||
const onChatKey = (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); sendChat(); } else if (e.key === 'Escape') { e.preventDefault(); setChatOpen(false); chatInputRef.current?.blur(); } };
|
const onChatKey = (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); sendChat(); } else if (e.key === 'Escape') { e.preventDefault(); setChatOpen(false); chatInputRef.current?.blur(); } };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const S = {}; const keys = Object.keys(SRC); let loaded = 0, raf, paused = false, io;
|
const S = {}; const keys = Object.keys(SRC); let loaded = 0, raf, paused = false, io, ro;
|
||||||
const reduceMotion = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reduceMotion = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
// collision grid + interactables
|
// ---- collision + interactables ----
|
||||||
const solid = new Set();
|
const solid = new Set();
|
||||||
const mark = (tx, ty) => solid.add(tx + ',' + ty);
|
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 x = 0; x < COLS; x++) { mark(x, 0); mark(x, 1); mark(x, 2); mark(x, ROWS - 1); }
|
||||||
for (let y = 0; y < ROWS; y++) { mark(0, y); mark(COLS - 1, y); } // side walls
|
for (let y = 0; y < ROWS; y++) { mark(0, y); mark(COLS - 1, y); }
|
||||||
const bar = { x0: 2, x1: 7, y: 3 }; for (let x = bar.x0; x <= bar.x1; x++) mark(x, bar.y); // bar counter
|
const bar = { x0: 2, x1: 10, y: 4 }; for (let x = bar.x0; x <= bar.x1; x++) mark(x, bar.y);
|
||||||
[[2, 8], [3, 8], [16, 8], [17, 8]].forEach(([x, y]) => mark(x, y)); // tables
|
const tableTiles = [[3, 10], [4, 10], [9, 13], [10, 13], [18, 8], [19, 8], [21, 13], [22, 13], [6, 14], [7, 14]];
|
||||||
[[18, 9], [19, 9]].forEach(([x, y]) => mark(x, y)); // barrels
|
tableTiles.forEach(([x, y]) => mark(x, y));
|
||||||
|
[[25, 13], [26, 13], [25, 5], [2, 14]].forEach(([x, y]) => mark(x, y)); // barrels/crates
|
||||||
|
|
||||||
const px = (tx) => tx * T + T / 2, py = (ty) => ty * T + T / 2;
|
const px = (tx) => tx * T + T / 2, py = (ty) => ty * T + T / 2;
|
||||||
const user = () => authRef.current?.user;
|
const user = () => authRef.current?.user;
|
||||||
const npcs = [
|
const npcs = [
|
||||||
{ sheet: 'soldier', dir: 'down', tx: 4, ty: 2, name: 'Barkeep', face: 'down',
|
{ sheet: 'soldier', tx: 6, ty: 3, name: 'Barkeep', face: 'down', interact: () => (user() ? onAction('legion') : onAction('login')), label: () => (user() ? '🍺 To your Legion' : '🍺 Sign in') },
|
||||||
interact: () => { const u = user(); if (u) onAction('legion'); else onAction('login'); }, label: () => (user() ? '🍺 To your Legion' : '🍺 Sign in') },
|
{ sheet: 'princess', tx: 14, ty: 9, name: 'Elowen the Host', face: 'down', interact: () => onAction('tour'), label: () => '✦ What is amerc?' },
|
||||||
{ sheet: 'princess', dir: 'down', tx: 11, ty: 6, name: 'Elf host', face: 'down',
|
{ sheet: 'soldier2', tx: 4, ty: 11, name: 'Borin', face: 'up', interact: () => setDialog({ who: 'Borin the Dwarf', i: 0, lines: ['I field three mercenaries straight from my Legion.', 'Costs me nothing — my own broker brings the runtime, no servers to rent.', 'Walk up to the barkeep if you mean to raise your own band.'] }), label: () => '💬 Talk' },
|
||||||
interact: () => onAction('tour'), label: () => '✦ What is amerc?' },
|
{ sheet: 'soldier2', tx: 19, ty: 9, name: 'Mira', face: 'up', interact: () => setDialog({ who: 'Mira the Ranger', i: 0, lines: ['Showcase put my little app on the open internet.', 'A local port, tunnelled to a public URL through my broker — TLS and all.', 'The estate door is south. Go have a look.'] }), label: () => '💬 Talk' },
|
||||||
{ sheet: 'soldier2', dir: 'up', tx: 3, ty: 9, name: 'Dwarf patron', face: 'up',
|
{ sheet: 'princess', tx: 22, ty: 12, name: 'Sable', face: 'up', interact: () => setDialog({ who: 'Sable the Mage', i: 0, lines: ['Mind the board on the north wall — that\'s the roster.', 'Every blade up there can be hired in a breath.', 'Bring your own and it joins them.'] }), label: () => '💬 Talk' },
|
||||||
interact: () => setDialog({ who: 'Borin the Dwarf', i: 0, lines: ['I field three mercenaries straight from my Legion.', 'Costs me nothing — my own broker brings the runtime, no servers to rent.', 'Walk up to the barkeep if you mean to raise your own band.'] }), label: () => '💬 Talk' },
|
|
||||||
{ sheet: 'soldier2', dir: 'up', tx: 16, ty: 9, name: 'Patron', face: 'up',
|
|
||||||
interact: () => setDialog({ who: 'Mira the Ranger', i: 0, lines: ['Showcase put my little app on the open internet.', 'A local port, tunnelled to a public URL through my broker — TLS and all.', 'The estate door is on the east wall. Go have a look.'] }), label: () => '💬 Talk' },
|
|
||||||
];
|
];
|
||||||
// wall fixtures (drawn in static buffer) with interaction zones
|
|
||||||
const fixtures = [
|
const fixtures = [
|
||||||
{ tx: 9.5, ty: 1.6, name: 'Roster board', interact: () => onAction('agents'), label: () => '⚔ Browse Agents' },
|
{ tx: 13.5, ty: 1.7, name: 'Roster board', interact: () => onAction('agents'), label: () => '⚔ Browse Agents' },
|
||||||
{ tx: 18, ty: 2, name: 'Legion banner', interact: () => onAction('legion'), label: () => '🚩 My Legion' },
|
{ tx: 24, ty: 2.4, name: 'Legion banner', interact: () => onAction('legion'), label: () => '🚩 My Legion' },
|
||||||
{ tx: 19.4, ty: 6, name: 'Estate door', interact: () => onAction('mansion'), label: () => '🏰 My Mansion' },
|
{ tx: 13.5, ty: 16.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 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 isSolid = (tx, ty) => solid.has(tx + ',' + ty);
|
||||||
const blocked = (fx, fy) => { // feet box ~22x12 centred on (fx,fy-2)
|
const blocked = (fx, fy) => [[fx - 10, fy - 10], [fx + 10, fy - 10], [fx - 10, fy], [fx + 10, fy]].some(([cx, cy]) => isSolid(Math.floor(cx / T), Math.floor(cy / T)));
|
||||||
const corners = [[fx - 10, fy - 10], [fx + 10, fy - 10], [fx - 10, fy], [fx + 10, fy]];
|
const walkClamp = (x, y) => ({ x: Math.max(T + 6, Math.min(W - T - 6, x)), y: Math.max(3 * T + 6, Math.min((ROWS - 1) * T - 6, y)) });
|
||||||
return corners.some(([cx, cy]) => isSolid(Math.floor(cx / T), Math.floor(cy / T)));
|
|
||||||
};
|
|
||||||
|
|
||||||
let sx = px(10), sy = py(9);
|
let sx = px(13), sy = py(13);
|
||||||
try { const sp = JSON.parse(localStorage.getItem('amerc_pos') || 'null'); if (Array.isArray(sp) && sp.length === 2 && isFinite(sp[0]) && isFinite(sp[1])) { sx = Math.max(T, Math.min(W - T, sp[0])); sy = Math.max(3 * T, Math.min((ROWS - 1) * T - 4, sp[1])); } } catch { /* default spawn */ }
|
try { const sp = JSON.parse(localStorage.getItem('amerc_pos') || 'null'); if (Array.isArray(sp) && sp.length === 2 && isFinite(sp[0]) && isFinite(sp[1])) { const c = walkClamp(sp[0], sp[1]); sx = c.x; sy = c.y; } } catch { /* default */ }
|
||||||
const g = { player: { x: sx, y: sy, dir: 'down', moving: false, frame: 0, ft: 0 }, keys: new Set(), near: null, f: 0, others: new Map(), says: saysRef.current };
|
const g = { player: { x: sx, y: sy, dir: 'up', moving: false, frame: 0, ft: 0 }, keys: new Set(), near: null, target: null, stuck: 0, f: 0, others: new Map(), says: saysRef.current, camX: 0, camY: 0, vw: 800, vh: 500 };
|
||||||
gameRef.current = g;
|
gameRef.current = g;
|
||||||
|
|
||||||
|
// light sources (world coords)
|
||||||
|
const lights = [[px(6), py(4) + 6, 90, 'rgba(255,180,90,'], [px(25), py(6), 80, 'rgba(255,150,70,'], [px(13.5), py(2), 90, 'rgba(80,200,255,'], [px(2), py(8), 60, 'rgba(255,180,90,'], [px(24), py(2.4), 60, 'rgba(255,200,120,']];
|
||||||
|
|
||||||
|
// ---- static world buffer (drawn once) ----
|
||||||
const buildStatic = (buf) => {
|
const buildStatic = (buf) => {
|
||||||
const ctx = buf.getContext('2d'); ctx.imageSmoothingEnabled = false;
|
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 = 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);
|
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);
|
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); }
|
// windows + curtains along the top wall
|
||||||
// rug centre
|
for (const wx of [3 * T, 9 * T, 19 * T]) { 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); }
|
||||||
const rx = 8 * T, ry = 6 * T, rc = 4, rr = 3;
|
// paintings on the top wall
|
||||||
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); }
|
for (const [pxw, hue] of [[6 * T, 30], [16 * T, 200], [22 * T, 280]]) { ctx.fillStyle = '#3a2a1a'; ctx.fillRect(pxw, 8, 30, 40); ctx.fillStyle = `hsl(${hue} 40% 30%)`; ctx.fillRect(pxw + 3, 11, 24, 34); ctx.fillStyle = `hsl(${hue} 55% 55%)`; ctx.fillRect(pxw + 7, 16, 16, 10); }
|
||||||
|
// rugs
|
||||||
|
const rug = (rx, ry, rc, rr) => { 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); } };
|
||||||
|
rug(11 * T, 8 * T, 5, 4); rug(3 * T, 13 * T, 3, 2); rug(20 * T, 11 * T, 3, 3);
|
||||||
// bar counter
|
// 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);
|
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 = '#c79155'; ctx.fillRect(bar.x0 * T, bar.y * T + 10, (bar.x1 - bar.x0 + 1) * T, 1); 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);
|
for (let i = bar.x0; i <= bar.x1; i++) { ctx.fillStyle = '#3a2417'; ctx.fillRect(i * T + T - 1, bar.y * T + 12, 1, 18); }
|
||||||
|
// shelves + bottles behind the bar
|
||||||
|
ctx.fillStyle = '#3a2414'; ctx.fillRect(bar.x0 * T, bar.y * T - 18, (bar.x1 - bar.x0 + 1) * T, 4);
|
||||||
|
for (let i = 0; i < 16; i++) { ctx.fillStyle = `hsl(${(i * 47) % 360} 45% 45%)`; ctx.fillRect(bar.x0 * T + 6 + i * 20, bar.y * T - 30, 7, 13); }
|
||||||
|
for (const bxp of [3, 6, 9]) tile(ctx, S.vic, VASE[0], VASE[1], bxp * T + 6, bar.y * T - 14, 20, 20);
|
||||||
// tables + chairs
|
// 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); }
|
const table = (tx, ty) => { tile(ctx, S.vic, CHAIR[0], CHAIR[1], tx * T - 4, ty * T - 6); tile(ctx, S.vic, CHAIR_R[0], CHAIR_R[1], tx * T + 36, ty * T - 6); tile(ctx, S.vic, TABLE[0], TABLE[1], tx * T + 16, ty * T + 2); };
|
||||||
// barrels + plants + lamps
|
table(3, 10); table(9, 13); table(18, 8); table(21, 13); table(6, 14);
|
||||||
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);
|
// fireplace on the right wall
|
||||||
|
ctx.fillStyle = '#2a1810'; ctx.fillRect(25 * T, 4 * T, 3 * T, 4 * T); ctx.fillStyle = '#1a0e08'; ctx.fillRect(25 * T + 8, 4 * T + 10, 3 * T - 16, 4 * T - 16);
|
||||||
|
ctx.fillStyle = '#ff7a30'; ctx.beginPath(); ctx.ellipse(25 * T + (3 * T) / 2, 4 * T + 3 * T, 22, 14, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#ffd05a'; ctx.beginPath(); ctx.ellipse(25 * T + (3 * T) / 2, 4 * T + 3 * T + 2, 12, 8, 0, 0, 7); ctx.fill();
|
||||||
|
// props
|
||||||
|
tile(ctx, S.barrel, 0, 0, 25 * T, 13 * T - 4); tile(ctx, S.barrel, 1, 0, 26 * T, 13 * T); tile(ctx, S.barrel, 2, 0, 25 * T, 5 * T);
|
||||||
|
tile(ctx, S.vic, PLANT[0], PLANT[1], 1 * T, 14 * T); tile(ctx, S.vic, PLANT[0], PLANT[1], 26 * T, 9 * T); tile(ctx, S.vic, PLANT[0], PLANT[1], 2 * T, 8 * T);
|
||||||
|
tile(ctx, S.vic, LAMP[0], LAMP[1], 1 * T, 5 * T); tile(ctx, S.vic, LAMP[0], LAMP[1], 12 * T, 13 * T);
|
||||||
|
// crate
|
||||||
|
ctx.fillStyle = '#6b4326'; ctx.fillRect(2 * T, 14 * T, T - 4, T - 4); ctx.strokeStyle = '#3a2414'; ctx.lineWidth = 2; ctx.strokeRect(2 * T, 14 * T, T - 4, T - 4); ctx.beginPath(); ctx.moveTo(2 * T, 14 * T); ctx.lineTo(2 * T + T - 4, 14 * T + T - 4); ctx.stroke();
|
||||||
// roster board (glowing screen) on the top wall
|
// 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 = '#0c2030'; ctx.fillRect(11 * T, 4, 5 * T, 46); ctx.strokeStyle = '#46c8e0'; ctx.lineWidth = 2; ctx.strokeRect(11 * T + 1, 5, 5 * T - 2, 44); ctx.fillStyle = '#16415e'; ctx.fillRect(11 * T + 4, 9, 5 * T - 8, 38);
|
||||||
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';
|
ctx.fillStyle = '#9af0ff'; ctx.font = 'bold 12px Georgia, serif'; ctx.textAlign = 'center'; ctx.fillText('⚔ BROWSE AGENTS', 13.5 * T, 26); ctx.font = '8px Georgia'; ctx.fillStyle = '#7fd9ea'; ctx.fillText('the mercenary roster', 13.5 * T, 40); ctx.textAlign = 'left';
|
||||||
// legion banner (right wall)
|
// legion banner (top-right)
|
||||||
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);
|
const bnx = 23.5 * T; ctx.fillStyle = '#2a1d4e'; ctx.beginPath(); ctx.moveTo(bnx, 2 * T - 8); ctx.lineTo(bnx + 34, 2 * T - 8); ctx.lineTo(bnx + 34, 2 * T + 30); ctx.lineTo(bnx + 17, 2 * T + 40); ctx.lineTo(bnx, 2 * T + 30); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#ffd75e'; ctx.fillRect(bnx + 13, 2 * T + 2, 8, 16); ctx.fillRect(bnx + 8, 2 * T + 6, 18, 6);
|
||||||
// estate door (right wall)
|
// estate door (bottom 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 dx = 13 * T; ctx.fillStyle = '#241a12'; ctx.fillRect(dx, (ROWS - 1) * T - 2, T, T + 2); ctx.fillStyle = '#5a3a1c'; ctx.fillRect(dx + 4, (ROWS - 1) * T + 2, T - 8, T - 6); ctx.fillStyle = 'rgba(127,233,255,0.55)'; ctx.fillRect(dx + 7, (ROWS - 1) * T + 4, T - 14, T - 10); ctx.fillStyle = '#ffd75e'; ctx.fillRect(dx + T - 12, (ROWS - 1) * T + T / 2, 3, 3);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawChar = (ctx, sheetKey, dir, frameCol, cx, cy) => chr(ctx, S[sheetKey], frameCol, DIR[dir], cx - 32, cy - 56);
|
const drawActor = (ctx, sheetKey, dir, frameCol, cx, cy, name, nameColor) => {
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(cx, cy - 3, 13, 5.5, 0, 0, 7); ctx.fill();
|
||||||
|
chr(ctx, S[sheetKey], frameCol, DIR[dir], cx - 32, cy - 56);
|
||||||
|
if (name) { ctx.font = 'bold 8px sans-serif'; ctx.textAlign = 'center'; const tw = ctx.measureText(name).width; ctx.fillStyle = 'rgba(8,6,14,0.78)'; roundRect(ctx, cx - tw / 2 - 4, cy - 74, tw + 8, 12, 4); ctx.fill(); ctx.fillStyle = nameColor; ctx.fillText(name, cx, cy - 65); ctx.textAlign = 'left'; }
|
||||||
|
};
|
||||||
|
|
||||||
const start = () => {
|
const start = () => {
|
||||||
setReady(true);
|
setReady(true);
|
||||||
const buf = document.createElement('canvas'); buf.width = W; buf.height = H; buildStatic(buf);
|
const buf = document.createElement('canvas'); buf.width = W; buf.height = H; buildStatic(buf);
|
||||||
const ctx = canvasRef.current.getContext('2d'); ctx.imageSmoothingEnabled = false;
|
const canvas = canvasRef.current; const ctx = canvas.getContext('2d');
|
||||||
|
const fit = () => { const r = canvas.parentElement.getBoundingClientRect(); canvas.width = Math.max(2, Math.floor(r.width)); canvas.height = Math.max(2, Math.floor(r.height)); g.vw = canvas.width; g.vh = canvas.height; ctx.imageSmoothingEnabled = false; };
|
||||||
|
fit(); ro = new ResizeObserver(fit); ro.observe(canvas.parentElement);
|
||||||
let lastNear = '__';
|
let lastNear = '__';
|
||||||
const render = () => {
|
const render = () => {
|
||||||
if (paused) return;
|
if (paused) return;
|
||||||
g.f++; const p = g.player;
|
g.f++; const p = g.player;
|
||||||
// movement
|
// movement — keyboard overrides click-target
|
||||||
let dx = 0, dy = 0;
|
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;
|
if (g.keys.size) { 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; g.target = null; }
|
||||||
p.moving = (dx || dy) && !dialogRef.current;
|
else if (g.target && !dialogRef.current) { const tdx = g.target.x - p.x, tdy = g.target.y - p.y, dd = Math.hypot(tdx, tdy); if (dd < 5) g.target = null; else { dx = tdx / dd; dy = tdy / dd; } }
|
||||||
if (p.moving) {
|
const moving = (dx || dy) && !dialogRef.current; p.moving = 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';
|
if (moving) {
|
||||||
const sp = 1.7, nx = p.x + dx * sp, ny = p.y + dy * sp;
|
if (Math.abs(dx) > Math.abs(dy)) p.dir = dx < 0 ? 'left' : 'right'; else p.dir = dy < 0 ? 'up' : 'down';
|
||||||
if (!blocked(nx, p.y)) p.x = nx;
|
const sp = 1.85, bx = p.x, by = p.y, nx = p.x + dx * sp, ny = p.y + dy * sp;
|
||||||
if (!blocked(p.x, ny)) p.y = ny;
|
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); }
|
p.ft++; if (p.ft >= 6) { p.ft = 0; p.frame = 1 + (p.frame % 8); }
|
||||||
|
if (g.target) { if (Math.hypot(p.x - bx, p.y - by) < 0.3) { if (++g.stuck > 18) { g.target = null; g.stuck = 0; } } else g.stuck = 0; }
|
||||||
} else { p.frame = 0; }
|
} else { p.frame = 0; }
|
||||||
|
// camera
|
||||||
|
const vw = g.vw, vh = g.vh, halfW = vw / ZOOM / 2, halfH = vh / ZOOM / 2;
|
||||||
|
let camX = W <= vw / ZOOM ? (W - vw / ZOOM) / 2 : Math.max(0, Math.min(W - vw / ZOOM, p.x - halfW));
|
||||||
|
let camY = H <= vh / ZOOM ? (H - vh / ZOOM) / 2 : Math.max(0, Math.min(H - vh / ZOOM, p.y - halfH));
|
||||||
|
g.camX = camX; g.camY = camY;
|
||||||
// nearest interactable
|
// nearest interactable
|
||||||
let near = null, best = 60 * 60;
|
let near = null, best = 66 * 66;
|
||||||
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; } }
|
for (const t of targets) { const d = (t.cx - p.x) ** 2 + (t.cy - (p.y - 16)) ** 2; if (d < best) { best = d; near = t; } }
|
||||||
g.near = near;
|
g.near = near; const lbl = near ? near.label() : null;
|
||||||
const lbl = near ? near.label() : null;
|
|
||||||
if ((lbl || '__') !== lastNear) { lastNear = lbl || '__'; setPrompt(lbl); }
|
if ((lbl || '__') !== lastNear) { lastNear = lbl || '__'; setPrompt(lbl); }
|
||||||
// draw
|
// --- draw ---
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#0a0712'; ctx.fillRect(0, 0, vw, vh);
|
||||||
|
ctx.setTransform(ZOOM, 0, 0, ZOOM, -camX * ZOOM, -camY * ZOOM);
|
||||||
ctx.drawImage(buf, 0, 0);
|
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, (!reduceMotion && Math.sin(g.f * 0.05 + n.tx) > 0.7 ? 1 : 0), px(n.tx), py(n.ty)) }));
|
|
||||||
// other online players (smooth-interpolated) with name labels
|
|
||||||
for (const [, o] of g.others) {
|
|
||||||
o.rx += (o.tx - o.rx) * 0.25; o.ry += (o.ty - o.ry) * 0.25;
|
|
||||||
const sk = SRC[o.sheet] ? o.sheet : 'soldier';
|
|
||||||
actors.push({ y: o.ry, draw: () => {
|
|
||||||
drawChar(ctx, sk, o.dir, o.frame || 0, o.rx, o.ry);
|
|
||||||
ctx.font = '9px sans-serif'; ctx.textAlign = 'center';
|
|
||||||
const tw = ctx.measureText(o.name).width;
|
|
||||||
ctx.fillStyle = 'rgba(10,7,18,0.74)'; ctx.fillRect(o.rx - tw / 2 - 4, o.ry - 70, tw + 8, 13);
|
|
||||||
ctx.fillStyle = '#9af0ff'; ctx.fillText(o.name, o.rx, o.ry - 60); ctx.textAlign = 'left';
|
|
||||||
} });
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
// chat speech bubbles (crisp, over everything)
|
|
||||||
const sayNow = Date.now();
|
const sayNow = Date.now();
|
||||||
const drawSay = (cx, feetY, text) => {
|
const actors = [];
|
||||||
ctx.font = '10px sans-serif';
|
npcs.forEach((n) => actors.push({ y: py(n.ty), draw: () => drawActor(ctx, n.sheet, n.face, (!reduceMotion && Math.sin(g.f * 0.05 + n.tx) > 0.7 ? 1 : 0), px(n.tx), py(n.ty), n.name, '#ffd75e') }));
|
||||||
const tx = text.length > 28 ? text.slice(0, 27) + '…' : text;
|
for (const [, o] of g.others) { o.rx += (o.tx - o.rx) * 0.25; o.ry += (o.ty - o.ry) * 0.25; const sk = SRC[o.sheet] ? o.sheet : 'soldier'; actors.push({ y: o.ry, draw: () => drawActor(ctx, sk, o.dir, o.frame || 0, o.rx, o.ry, o.name, '#9af0ff') }); }
|
||||||
const tw = ctx.measureText(tx).width, bw = tw + 14, bx = cx - bw / 2, by = feetY - 80;
|
actors.push({ y: p.y, draw: () => drawActor(ctx, 'soldier2', p.dir, p.frame, p.x, p.y, nameRef.current || 'you', '#7fffd0') });
|
||||||
ctx.fillStyle = 'rgba(250,246,236,0.97)'; roundRect(ctx, bx, by, bw, 18, 6); ctx.fill();
|
actors.sort((a, b) => a.y - b.y).forEach((a) => a.draw());
|
||||||
ctx.beginPath(); ctx.moveTo(cx - 4, by + 17); ctx.lineTo(cx + 4, by + 17); ctx.lineTo(cx, by + 23); ctx.closePath(); ctx.fill();
|
// prominent interaction bubble above the nearest target
|
||||||
ctx.fillStyle = '#241509'; ctx.textAlign = 'center'; ctx.fillText(tx, cx, by + 13); ctx.textAlign = 'left';
|
if (near) {
|
||||||
};
|
const t2 = near.label(); ctx.font = 'bold 11px Georgia, serif'; const tw = ctx.measureText(t2).width; const bw = tw + 24, bx = near.cx - bw / 2, by = near.cy - 92 - Math.sin(g.f * 0.12) * 2;
|
||||||
const mine = g.says.get(pidRef.current);
|
ctx.save(); ctx.shadowColor = 'rgba(255,215,94,0.55)'; ctx.shadowBlur = 12; ctx.fillStyle = 'rgba(26,17,7,0.96)'; roundRect(ctx, bx, by, bw, 27, 8); ctx.fill(); ctx.restore();
|
||||||
if (mine && mine.until > sayNow) drawSay(p.x, p.y, mine.text);
|
ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 1.5; roundRect(ctx, bx, by, bw, 27, 8); ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(26,17,7,0.96)'; ctx.beginPath(); ctx.moveTo(near.cx - 6, by + 27); ctx.lineTo(near.cx + 6, by + 27); ctx.lineTo(near.cx, by + 34); ctx.closePath(); ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffe9b0'; ctx.textAlign = 'center'; ctx.fillText(t2, near.cx, by + 14); ctx.font = '7px sans-serif'; ctx.fillStyle = '#b29a6a'; ctx.fillText('click · E', near.cx, by + 23); ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
// chat bubbles
|
||||||
|
const drawSay = (cx, feetY, text) => { ctx.font = '9px sans-serif'; const tx = text.length > 30 ? text.slice(0, 29) + '…' : text; const tw = ctx.measureText(tx).width, bw = tw + 12, bx = cx - bw / 2, by = feetY - 84; ctx.fillStyle = 'rgba(250,246,236,0.97)'; roundRect(ctx, bx, by, bw, 16, 6); ctx.fill(); ctx.beginPath(); ctx.moveTo(cx - 4, by + 16); ctx.lineTo(cx + 4, by + 16); ctx.lineTo(cx, by + 21); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#241509'; ctx.textAlign = 'center'; ctx.fillText(tx, cx, by + 12); ctx.textAlign = 'left'; };
|
||||||
|
const mine = g.says.get(pidRef.current); if (mine && mine.until > sayNow) drawSay(p.x, p.y, mine.text);
|
||||||
for (const [oid, o] of g.others) { const s = g.says.get(oid); if (s && s.until > sayNow) drawSay(o.rx, o.ry, s.text); }
|
for (const [oid, o] of g.others) { const s = g.says.get(oid); if (s && s.until > sayNow) drawSay(o.rx, o.ry, s.text); }
|
||||||
// emotes — a big emoji popping above the head
|
const drawEmote = (cx, feetY, ch) => { ctx.font = '20px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(ch, cx, feetY - 66 - Math.sin(sayNow / 130) * 2); ctx.textAlign = 'left'; };
|
||||||
const drawEmote = (cx, feetY, ch) => { ctx.font = '22px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(ch, cx, feetY - 62 - Math.sin(sayNow / 130) * 2); ctx.textAlign = 'left'; };
|
|
||||||
if (emoteRef.current.until > sayNow) drawEmote(p.x, p.y, emoteRef.current.char);
|
if (emoteRef.current.until > sayNow) drawEmote(p.x, p.y, emoteRef.current.char);
|
||||||
for (const [, o] of g.others) { if (o.emote) drawEmote(o.rx, o.ry, o.emote); }
|
for (const [, o] of g.others) { if (o.emote) drawEmote(o.rx, o.ry, o.emote); }
|
||||||
|
// warm lighting (world space)
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
for (const [lx, ly, lr, col] of lights) { const a = 0.16 * (0.85 + 0.15 * Math.sin(g.f * 0.1 + lx)); const gg = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr); gg.addColorStop(0, col + a + ')'); gg.addColorStop(1, col + '0)'); ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(lx, ly, lr, 0, 7); ctx.fill(); }
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
// screen vignette
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
const vg = ctx.createRadialGradient(vw / 2, vh / 2, Math.min(vw, vh) * 0.34, vw / 2, vh / 2, Math.max(vw, vh) * 0.72); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(6,4,10,0.5)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, vw, vh);
|
||||||
raf = requestAnimationFrame(render);
|
raf = requestAnimationFrame(render);
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { if (paused) { paused = false; render(); } } else if (!paused) { paused = true; cancelAnimationFrame(raf); } }, { threshold: 0 });
|
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);
|
io.observe(canvas);
|
||||||
};
|
};
|
||||||
keys.forEach((k) => { const im = new Image(); im.onload = () => { if (++loaded === keys.length) start(); }; im.src = SRC[k]; S[k] = im; });
|
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(); };
|
return () => { cancelAnimationFrame(raf); if (io) io.disconnect(); if (ro) ro.disconnect(); };
|
||||||
}, [onAction]);
|
}, [onAction]);
|
||||||
|
|
||||||
|
// click-to-move (and click-to-interact when adjacent)
|
||||||
|
const onCanvasClick = (e) => {
|
||||||
|
const g = gameRef.current, canvas = canvasRef.current; if (!g || !canvas) return;
|
||||||
|
if (dialogRef.current) { advanceDialog(); return; }
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const wx = (e.clientX - r.left) * (canvas.width / r.width) / ZOOM + g.camX;
|
||||||
|
const wy = (e.clientY - r.top) * (canvas.height / r.height) / ZOOM + g.camY;
|
||||||
|
if (g.near && (g.near.cx - g.player.x) ** 2 + (g.near.cy - (g.player.y - 16)) ** 2 < 66 * 66 && (g.near.cx - wx) ** 2 + (g.near.cy - wy) ** 2 < 80 * 80) { g.near.interact(); return; }
|
||||||
|
g.target = { x: Math.max(40, Math.min(W - 40, wx)), y: Math.max(100, Math.min(H - 30, wy)) }; g.stuck = 0;
|
||||||
|
};
|
||||||
|
|
||||||
// keyboard
|
// keyboard
|
||||||
useEffect(() => {
|
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 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) { advanceDialog(); return; } if (g.near) g.near.interact(); };
|
|
||||||
const onDown = (e) => {
|
const onDown = (e) => {
|
||||||
const g = gameRef.current; if (!g) return;
|
const g = gameRef.current; if (!g) return;
|
||||||
if (chatTypingRef.current) return; // typing in chat — let the input handle keys
|
if (chatTypingRef.current) return;
|
||||||
if (KEY[e.key]) { e.preventDefault(); g.keys.add(KEY[e.key]); }
|
if (KEY[e.key]) { e.preventDefault(); g.keys.add(KEY[e.key]); g.target = null; }
|
||||||
else if (e.key === 'e' || e.key === 'E' || e.key === ' ') { e.preventDefault(); interact(); }
|
else if (e.key === 'e' || e.key === 'E' || e.key === ' ') { e.preventDefault(); interactNear(); }
|
||||||
else if (e.key === 'Enter' || e.key === 't' || e.key === 'T') { e.preventDefault(); openChat(); }
|
else if (e.key === 'Enter' || e.key === 't' || e.key === 'T') { e.preventDefault(); openChat(); }
|
||||||
else if (e.key >= '1' && e.key <= '4') { e.preventDefault(); doEmote(EMOTES[+e.key - 1]); }
|
else if (e.key >= '1' && e.key <= '4') { e.preventDefault(); doEmote(EMOTES[+e.key - 1]); }
|
||||||
};
|
};
|
||||||
const onUp = (e) => { const g = gameRef.current; if (g && KEY[e.key]) g.keys.delete(KEY[e.key]); };
|
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);
|
window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp);
|
||||||
return () => { window.removeEventListener('keydown', onDown); window.removeEventListener('keyup', onUp); };
|
return () => { window.removeEventListener('keydown', onDown); window.removeEventListener('keyup', onUp); };
|
||||||
}, [dialog]);
|
}, [dialog]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// online presence — broadcast my position, see other visitors
|
// online presence
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pid = pidRef.current;
|
const pid = pidRef.current; let alive = true;
|
||||||
let alive = true;
|
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
const g = gameRef.current; if (!g) return;
|
const g = gameRef.current; if (!g) return;
|
||||||
const p = g.player; const name = nameRef.current || authRef.current?.user?.handle || 'wanderer';
|
const p = g.player; const name = nameRef.current || authRef.current?.user?.handle || 'wanderer';
|
||||||
try { localStorage.setItem('amerc_pos', JSON.stringify([Math.round(p.x), Math.round(p.y)])); } catch { /* private mode */ }
|
try { localStorage.setItem('amerc_pos', JSON.stringify([Math.round(p.x), Math.round(p.y)])); } catch { /* private */ }
|
||||||
const em = emoteRef.current.until > Date.now() ? emoteRef.current.char : '';
|
const em = emoteRef.current.until > Date.now() ? emoteRef.current.char : '';
|
||||||
try {
|
try {
|
||||||
const d = await api('/agents/presence', { method: 'POST', body: { id: pid, name, x: Math.round(p.x), y: Math.round(p.y), dir: p.dir, frame: p.frame, sheet: 'soldier2', room: 'tavern', emote: em, emoteAt: emoteRef.current.until } });
|
const d = await api('/agents/presence', { method: 'POST', body: { id: pid, name, x: Math.round(p.x), y: Math.round(p.y), dir: p.dir, frame: p.frame, sheet: 'soldier2', room: 'tavern', emote: em, emoteAt: emoteRef.current.until } });
|
||||||
@ -241,13 +265,13 @@ export default function RpgTavern({ onAction, auth, stats }) {
|
|||||||
for (const o of d.players || []) { seen.add(o.id); let e = m.get(o.id); if (!e) e = { rx: o.x, ry: o.y }; e.tx = o.x; e.ty = o.y; e.dir = o.dir; e.frame = o.frame; e.name = o.name; e.sheet = o.sheet; e.emote = o.emote; e.emoteAt = o.emoteAt; m.set(o.id, e); }
|
for (const o of d.players || []) { seen.add(o.id); let e = m.get(o.id); if (!e) e = { rx: o.x, ry: o.y }; e.tx = o.x; e.ty = o.y; e.dir = o.dir; e.frame = o.frame; e.name = o.name; e.sheet = o.sheet; e.emote = o.emote; e.emoteAt = o.emoteAt; m.set(o.id, e); }
|
||||||
for (const k of [...m.keys()]) if (!seen.has(k)) m.delete(k);
|
for (const k of [...m.keys()]) if (!seen.has(k)) m.delete(k);
|
||||||
setOnline((d.players || []).length + 1);
|
setOnline((d.players || []).length + 1);
|
||||||
} catch { /* offline-tolerant */ }
|
} catch { /* offline */ }
|
||||||
};
|
};
|
||||||
const iv = setInterval(tick, 600); tick();
|
const iv = setInterval(tick, 600); tick();
|
||||||
return () => { alive = false; clearInterval(iv); };
|
return () => { alive = false; clearInterval(iv); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// tavern chat poll — fill speech bubbles + the log
|
// tavern chat poll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
@ -264,27 +288,23 @@ export default function RpgTavern({ onAction, auth, stats }) {
|
|||||||
return () => { alive = false; clearInterval(iv); };
|
return () => { alive = false; clearInterval(iv); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// on-screen controls (mobile)
|
const hold = (d, on) => { const g = gameRef.current; if (!g) return; if (on) { g.keys.add(d); g.target = null; } else g.keys.delete(d); };
|
||||||
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) { advanceDialog(); return; } if (g.near) g.near.interact(); };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rpg gm-room">
|
<div className="rpg gm-room">
|
||||||
<canvas ref={canvasRef} width={W} height={H} className="rpg-canvas" role="img" aria-label="Playable tavern — walk with arrow keys and press E to interact" />
|
<canvas ref={canvasRef} className="rpg-canvas" onClick={onCanvasClick} role="img" aria-label="The amerc tavern — walk (arrows/WASD or click) and press E to interact" />
|
||||||
{!ready && <div className="gm-loading">loading tavern…</div>}
|
{!ready && <div className="gm-loading">entering the tavern…</div>}
|
||||||
{prompt && !dialog && <div className="rpg-prompt"><b>E</b> {prompt}</div>}
|
{prompt && !dialog && <button className="rpg-prompt" onClick={interactNear}><b>E</b> {prompt}</button>}
|
||||||
{dialog && (
|
{dialog && (
|
||||||
<div className="rpg-dialog" onClick={advanceDialog}>
|
<div className="rpg-dialog" onClick={advanceDialog}>
|
||||||
<strong>{dialog.who}</strong><p>{dialog.lines[dialog.i]}</p>
|
<strong>{dialog.who}</strong><p>{dialog.lines[dialog.i]}</p>
|
||||||
<span className="rpg-dialog-x">▸ {dialog.i < dialog.lines.length - 1 ? 'E / tap — more' : 'E / tap — close'}</span>
|
<span className="rpg-dialog-x">▸ {dialog.i < dialog.lines.length - 1 ? 'E / tap — more' : 'E / tap — close'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="rpg-help">↑↓←→ / WASD walk · <b>E</b> interact · <b>Enter</b> chat</div>
|
<div className="rpg-help">click or ↑↓←→ to walk · <b>E</b> interact · <b>Enter</b> chat</div>
|
||||||
<div className="rpg-online">👥 {online} in the tavern</div>
|
<div className="rpg-online">👥 {online} in the tavern</div>
|
||||||
{chatLog.length > 0 && (
|
{chatLog.length > 0 && (
|
||||||
<div className="rpg-chatlog">
|
<div className="rpg-chatlog">{chatLog.slice(-5).map((m, i) => <div key={`${m.seq}-${i}`}><b>{m.name}:</b> {m.text}</div>)}</div>
|
||||||
{chatLog.slice(-5).map((m, i) => <div key={`${m.seq}-${i}`}><b>{m.name}:</b> {m.text}</div>)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{chatOpen && (
|
{chatOpen && (
|
||||||
<form className="rpg-chatbar" onSubmit={(e) => { e.preventDefault(); sendChat(); }}>
|
<form className="rpg-chatbar" onSubmit={(e) => { e.preventDefault(); sendChat(); }}>
|
||||||
@ -303,7 +323,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
|
|||||||
<button className="rpg-right" onPointerDown={() => hold('right', true)} onPointerUp={() => hold('right', false)} onPointerLeave={() => hold('right', false)}>▶</button>
|
<button className="rpg-right" onPointerDown={() => hold('right', true)} onPointerUp={() => hold('right', false)} onPointerLeave={() => hold('right', false)}>▶</button>
|
||||||
<button className="rpg-down" onPointerDown={() => hold('down', true)} onPointerUp={() => hold('down', false)} onPointerLeave={() => hold('down', false)}>▼</button>
|
<button className="rpg-down" onPointerDown={() => hold('down', true)} onPointerUp={() => hold('down', false)} onPointerLeave={() => hold('down', false)}>▼</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="rpg-a" onClick={tapInteract} aria-label="Interact">E</button>
|
<button className="rpg-a" onClick={interactNear} aria-label="Interact">E</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { api } from './api.js';
|
|||||||
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
|
||||||
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
|
||||||
|
|
||||||
const RELEASE = '4.4.0-emotes';
|
const RELEASE = '4.5.0-overhaul';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ id: 'home', label: 'Tavern' },
|
{ id: 'home', label: 'Tavern' },
|
||||||
@ -121,21 +121,17 @@ function Home({ setRoute, auth }) {
|
|||||||
return (
|
return (
|
||||||
<div className="gm-home gm-game">
|
<div className="gm-home gm-game">
|
||||||
<div className="gm-stage3"><RpgTavern onAction={act} stats={stats} auth={auth} /></div>
|
<div className="gm-stage3"><RpgTavern onAction={act} stats={stats} auth={auth} /></div>
|
||||||
<div className="gm-bottombar">
|
<div className="gm-overlaybar">
|
||||||
<h1 className="gm-tagline">Hire agents like mercenaries<span className="gm-cursor">_</span></h1>
|
<div className="gm-overlay-l">
|
||||||
<p className="gm-tagsub">every fixture in the tavern is a door — walk in, or read the <button className="gm-link" onClick={() => setDlg('tour')}>tour</button></p>
|
<h1 className="gm-tagline">Hire agents like mercenaries<span className="gm-cursor">_</span></h1>
|
||||||
|
<p className="gm-tagsub">a serious cloud layer for hiring, running & delivering AI agents — walk the tavern, or use the doors below.</p>
|
||||||
|
</div>
|
||||||
<div className="gm-cta-row">
|
<div className="gm-cta-row">
|
||||||
<button className="gm-cta-btn gm-cta-primary" onClick={() => setRoute('agents')}>⚔ Browse Agents</button>
|
<button className="gm-cta-btn gm-cta-primary" onClick={() => setRoute('agents')}>⚔ Browse Agents</button>
|
||||||
<button className="gm-cta-btn gm-cta-secondary" onClick={() => setRoute('legion')}>🚩 My Legion</button>
|
<button className="gm-cta-btn gm-cta-secondary" onClick={() => setRoute('legion')}>🚩 My Legion</button>
|
||||||
<button className="gm-cta-btn gm-cta-secondary" onClick={() => setRoute('mansion')}>🏰 My Mansion</button>
|
<button className="gm-cta-btn gm-cta-secondary" onClick={() => setRoute('mansion')}>🏰 My Mansion</button>
|
||||||
|
<button className="gm-cta-btn gm-cta-ghost" onClick={() => setDlg('tour')}>How it works</button>
|
||||||
</div>
|
</div>
|
||||||
{stats && (
|
|
||||||
<div className="hero-live">
|
|
||||||
<span><b><Count n={stats.classes} /></b> classes</span>
|
|
||||||
<span><b><Count n={stats.online} /></b> online</span>
|
|
||||||
<span><b><Count n={stats.sessions} /></b> sessions</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{dlg === 'tour' && (
|
{dlg === 'tour' && (
|
||||||
<GameDialog title="How amerc works" tag="✦ THE TOUR" wide onClose={() => setDlg(null)}>
|
<GameDialog title="How amerc works" tag="✦ THE TOUR" wide onClose={() => setDlg(null)}>
|
||||||
@ -485,6 +481,7 @@ function StatusPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CHANGELOG = [
|
const CHANGELOG = [
|
||||||
|
{ v: '4.5', date: 'Jun 2026', title: 'A bigger, richer tavern', notes: ['The tavern fills the whole window now, drawn 2× bigger with a camera that follows you through a larger, more decorated hall', 'Click anywhere on the floor and your character walks there; the interaction bubble pops up bright and clear', 'Everyone — patrons and visitors alike — has their name above their head, and characters cast a soft shadow'] },
|
||||||
{ v: '4.4', date: 'Jun 2026', title: 'Wave hello', notes: ['Quick emotes — press 1–4 (or the side buttons) to pop a 👋 ❤️ ❗ ✨ above your head for the room to see', 'The tavern remembers where you were standing and drops you back there'] },
|
{ v: '4.4', date: 'Jun 2026', title: 'Wave hello', notes: ['Quick emotes — press 1–4 (or the side buttons) to pop a 👋 ❤️ ❗ ✨ above your head for the room to see', 'The tavern remembers where you were standing and drops you back there'] },
|
||||||
{ v: '4.3', date: 'Jun 2026', title: 'Say something', notes: ['Press Enter (or tap 💬) to chat in the tavern — your words float above your head and everyone in the room sees them', 'A small chat log keeps the last few things said'] },
|
{ v: '4.3', date: 'Jun 2026', title: 'Say something', notes: ['Press Enter (or tap 💬) to chat in the tavern — your words float above your head and everyone in the room sees them', 'A small chat log keeps the last few things said'] },
|
||||||
{ v: '4.2', date: 'Jun 2026', title: 'A livelier tavern', notes: ['Every wanderer gets their own name now — pick your own, or roll a random one (click the name badge)', 'The patrons have more to say if you walk up and talk', 'The idle bustle holds still for anyone who prefers reduced motion'] },
|
{ v: '4.2', date: 'Jun 2026', title: 'A livelier tavern', notes: ['Every wanderer gets their own name now — pick your own, or roll a random one (click the name badge)', 'The patrons have more to say if you walk up and talk', 'The idle bustle holds still for anyone who prefers reduced motion'] },
|
||||||
|
|||||||
@ -2018,3 +2018,33 @@ button {
|
|||||||
.rpg-emotes { position: absolute; right: 12px; bottom: 148px; display: flex; flex-direction: column; gap: 5px; z-index: 7; }
|
.rpg-emotes { position: absolute; right: 12px; bottom: 148px; display: flex; flex-direction: column; gap: 5px; z-index: 7; }
|
||||||
.rpg-emotes button { width: 36px; height: 36px; border-radius: 9px; background: rgba(42,36,64,0.82); border: 1.5px solid #6a5a9a; font-size: 17px; cursor: pointer; line-height: 1; padding: 0; }
|
.rpg-emotes button { width: 36px; height: 36px; border-radius: 9px; background: rgba(42,36,64,0.82); border: 1.5px solid #6a5a9a; font-size: 17px; cursor: pointer; line-height: 1; padding: 0; }
|
||||||
.rpg-emotes button:hover { border-color: #ffd75e; }
|
.rpg-emotes button:hover { border-color: #ffd75e; }
|
||||||
|
|
||||||
|
/* ===== full-window RPG + camera (4.5) ===== */
|
||||||
|
.gm-game { height: calc(100dvh - 52px); position: relative; display: block; padding: 0; overflow: hidden; }
|
||||||
|
.gm-stage3 { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; display: block; }
|
||||||
|
.gm-stage3 .gm-room, .rpg.gm-room { position: absolute; inset: 0; width: 100%; height: 100%; aspect-ratio: auto; max-width: none; min-height: 0; border-radius: 0; box-shadow: none; overflow: hidden; }
|
||||||
|
.rpg-canvas { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: pointer; }
|
||||||
|
/* serious value-prop + door CTAs overlaid on the game */
|
||||||
|
.gm-overlaybar { position: absolute; left: 0; right: 0; bottom: 0; z-index: 9; display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; flex-wrap: wrap; padding: 16px 22px 18px; background: linear-gradient(180deg, transparent, rgba(8,5,14,0.82) 58%); pointer-events: none; }
|
||||||
|
.gm-overlaybar > * { pointer-events: auto; }
|
||||||
|
.gm-overlay-l { min-width: 0; }
|
||||||
|
.gm-overlaybar .gm-tagline { margin: 0; font-size: clamp(18px, 2.6vw, 30px); text-shadow: 0 2px 16px rgba(0,0,0,0.7); }
|
||||||
|
.gm-overlaybar .gm-tagsub { margin: 4px 0 0; color: #c3b6dd; font-size: clamp(11px, 1.4vw, 13.5px); max-width: 560px; text-shadow: 0 1px 6px rgba(0,0,0,0.8); }
|
||||||
|
.gm-overlaybar .gm-cta-row { margin: 0; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.gm-cta-ghost { background: rgba(20,16,34,0.72); border: 1px solid #3a3354; color: #cbb8e8; }
|
||||||
|
.gm-cta-ghost:hover { border-color: #6a5a9a; color: #fff; }
|
||||||
|
/* prominent, clickable interaction prompt */
|
||||||
|
.rpg-prompt { position: absolute; left: 50%; bottom: 104px; transform: translateX(-50%); z-index: 8; background: linear-gradient(180deg, #2a1a08f5, #170d05f5); border: 2px solid #ffd75e; color: #ffe9b0; border-radius: 11px; padding: 10px 20px; font-size: clamp(13px, 1.7vw, 17px); font-family: inherit; cursor: pointer; box-shadow: 0 0 22px rgba(255,215,94,0.35), 0 6px 16px rgba(0,0,0,0.5); white-space: nowrap; }
|
||||||
|
.rpg-prompt b { display: inline-block; background: #ffd75e; color: #1c1204; border-radius: 5px; padding: 0 7px; margin-right: 8px; font-size: 0.82em; }
|
||||||
|
.rpg-prompt:hover { filter: brightness(1.12); }
|
||||||
|
/* side controls clear of the overlay bar */
|
||||||
|
.rpg-emotes { top: 50%; bottom: auto; transform: translateY(-60%); }
|
||||||
|
.rpg-chat-btn { bottom: auto; top: calc(50% + 86px); }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.gm-overlaybar { flex-direction: column; align-items: stretch; gap: 8px; padding: 8px 10px 10px; }
|
||||||
|
.gm-overlaybar .gm-overlay-l { text-align: center; }
|
||||||
|
.gm-overlaybar .gm-cta-row { justify-content: center; }
|
||||||
|
.rpg-prompt { bottom: 150px; }
|
||||||
|
.rpg-pad { bottom: auto; top: calc(50% - 66px); }
|
||||||
|
.rpg-a { bottom: auto; top: calc(50% + 90px); }
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user