- Scene2D pixel tavern (replaces three.js 3D scene) - amerc-api: node:http+node:sqlite+node:crypto, auth/admin/docs/files/boards - docs.amerc.ai + pm.amerc.ai (whiteboard mindmap + netdisk) apps - agent API keys for fleet read/write Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1501 lines
56 KiB
JavaScript
1501 lines
56 KiB
JavaScript
import { useEffect, useRef, useState } from 'react';
|
|
import * as THREE from 'three';
|
|
import { Menu, X } from 'lucide-react';
|
|
import gemhallBackdropUrl from '../assets/generated/gemhall-tavern-backdrop-070.png';
|
|
|
|
const RELEASE = '0.7.0-gemhall';
|
|
const SCREEN_ASPECT = 1280 / 540;
|
|
const BOOTH_ASPECT = 920 / 230;
|
|
const SPEECH_ASPECT = 820 / 560;
|
|
|
|
const NAV_ROUTES = [
|
|
{ id: 'home', label: 'Solution' },
|
|
{ id: 'agents', label: 'Staff' },
|
|
{ id: 'contracts', label: 'Pricing' },
|
|
{ id: 'signin', label: 'Sign Up' },
|
|
{ id: 'signin', label: 'Login' },
|
|
{ id: 'booth', label: 'My Booth' },
|
|
];
|
|
|
|
const WANTED_AGENTS = [
|
|
{ name: 'Velvet Hex', role: 'consent guide', skill: 'BYOA links', bounty: '14c/h', initials: 'VH', tint: '#b989ff' },
|
|
{ name: 'Socket Wraith', role: 'tinybox bridge', skill: 'port mapping', bounty: '16c/h', initials: 'SW', tint: '#54d9ff' },
|
|
{ name: 'Iron Tusk', role: 'ops bruiser', skill: 'publish agent', bounty: '18c/h', initials: 'IT', tint: '#7be36d' },
|
|
{ name: 'Amber Ledger', role: 'contract clerk', skill: 'pricing audit', bounty: '12c/h', initials: 'AL', tint: '#ffc45f' },
|
|
{ name: 'Northstar Fixer', role: 'merchant ops', skill: 'session scope', bounty: '20c/h', initials: 'NF', tint: '#a7ecff' },
|
|
{ name: 'Cinder Relay', role: 'delivery relay', skill: 'remote use', bounty: '15c/h', initials: 'CR', tint: '#ff8564' },
|
|
];
|
|
|
|
const SPEECH_STEPS = {
|
|
home: ['Browse the staff board', 'Compare scoped bounties', 'Enter your private booth', 'Publish from the tavern'],
|
|
agents: ['Pick a scoped mercenary', 'Check skills before hire', 'Open a session contract', 'Keep budget visible'],
|
|
booth: ['Private booth ready', 'Attach skills and tools', 'Review session scope', 'Launch when signed'],
|
|
contracts: ['Pricing stays scoped', 'No hidden backend claim', 'Bring your execution lane', 'Audit each handoff'],
|
|
signin: ['Create a tavern pass', 'Connect your API key', 'Bring your own agent', 'Return to your booth'],
|
|
admin: ['Maintainer gate only', 'Deploy static front-end', 'Keep release mnemonic', 'Review screenshots first'],
|
|
};
|
|
|
|
const ASSET_AUDIT = [
|
|
{ area: 'backdrop', asset: 'assets/generated/gemhall-tavern-backdrop-070.png', source: 'imagegen reference-guided low-poly tavern render', rating: '8.4/10' },
|
|
{ area: 'orc bartender', asset: 'Gemhall procedural mesh rig', source: 'new 0.7 Three.js low-poly rig, not reused from 0.6', rating: '7.9/10' },
|
|
{ area: 'elf guide', asset: 'Gemhall procedural mesh rig', source: 'new 0.7 tall rig with cape, face planes, staff, rune base', rating: '8.0/10' },
|
|
{ area: 'dwarf patron', asset: 'Gemhall procedural mesh rig', source: 'new 0.7 seated patron rig with mug/pack/hammer', rating: '7.7/10' },
|
|
{ area: 'browse screen', asset: 'animated CanvasTexture wanted board', source: 'new 0.7 canvas roster texture', rating: '8.2/10' },
|
|
{ area: 'booth sign', asset: 'bar-mounted neon CanvasTexture', source: 'new 0.7 transparent sign texture on the bar face', rating: '8.0/10' },
|
|
{ area: 'topbar', asset: 'CSS carved plank/gem nav', source: 'new 0.7 layered wood, bolts, bevels, gemstone mounts', rating: '8.1/10' },
|
|
];
|
|
|
|
const IDLE_ANIMATIONS = [
|
|
'orc bartender: breathing, head turn, mug toast, shoulder armor bob, eye glow blink',
|
|
'elf guide: staff sway, hand presentation, cape flutter, gem pulse, rune-floor shimmer',
|
|
'dwarf patron: seated mug lift, hammer tap, backpack blue core pulse',
|
|
'browse agents screen: wanted roster scroll, active card changes, coin/icon motion',
|
|
'speech bubble: pulse border, rotating action checklist, animated pointer tail',
|
|
'enter booth sign: neon tube flicker, cyan scanline sweep, floor ring pulse',
|
|
'environment: lantern flicker, sparks drift, crystal glows, parallax depth on drag',
|
|
];
|
|
|
|
export default function GemhallSceneApp() {
|
|
const [route, setRoute] = useHashRoute();
|
|
|
|
return (
|
|
<main className="amerc-app gemhall-app">
|
|
<h1 className="sr-only">amerc agent mercenary tavern</h1>
|
|
<WoodTopbar route={route} setRoute={setRoute} />
|
|
<GemhallScene route={route} setRoute={setRoute} />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function useHashRoute() {
|
|
const normalize = () => {
|
|
const raw = window.location.hash.replace(/^#\/?/, '');
|
|
return ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'].includes(raw) ? raw : 'home';
|
|
};
|
|
const [route, setRouteState] = useState(normalize);
|
|
|
|
useEffect(() => {
|
|
const onHash = () => setRouteState(normalize());
|
|
window.addEventListener('hashchange', onHash);
|
|
return () => window.removeEventListener('hashchange', onHash);
|
|
}, []);
|
|
|
|
const setRoute = (nextRoute) => {
|
|
window.location.hash = `/${nextRoute}`;
|
|
setRouteState(nextRoute);
|
|
};
|
|
|
|
return [route, setRoute];
|
|
}
|
|
|
|
function WoodTopbar({ route, setRoute }) {
|
|
const [open, setOpen] = useState(false);
|
|
const nav = (
|
|
<>
|
|
{NAV_ROUTES.map((item, index) => (
|
|
<button
|
|
className={route === item.id ? 'active' : ''}
|
|
key={`${item.id}-${item.label}-${index}`}
|
|
onClick={() => {
|
|
setRoute(item.id);
|
|
setOpen(false);
|
|
}}
|
|
type="button"
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
<button
|
|
className={route === 'admin' ? 'active quartermaster-link' : 'quartermaster-link'}
|
|
onClick={() => {
|
|
setRoute('admin');
|
|
setOpen(false);
|
|
}}
|
|
type="button"
|
|
>
|
|
Quartermaster
|
|
</button>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<header className="wood-topbar">
|
|
<button className="logo-plaque" onClick={() => setRoute('home')} type="button" aria-label="amerc home">
|
|
<span className="logo-gem" aria-hidden="true" />
|
|
<strong>amerc</strong>
|
|
<small>agent mercenary</small>
|
|
<em>v{RELEASE}</em>
|
|
</button>
|
|
<nav className="wood-nav" aria-label="Primary navigation">
|
|
{nav}
|
|
</nav>
|
|
<button className="wood-menu" onClick={() => setOpen((value) => !value)} type="button" aria-label="Toggle menu">
|
|
{open ? <X size={21} /> : <Menu size={21} />}
|
|
</button>
|
|
{open && <div className="wood-mobile">{nav}</div>}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function GemhallScene({ route, setRoute }) {
|
|
const mountRef = useRef(null);
|
|
const [fallback, setFallback] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const mount = mountRef.current;
|
|
if (!mount) return undefined;
|
|
|
|
let renderer;
|
|
try {
|
|
renderer = new THREE.WebGLRenderer({
|
|
antialias: true,
|
|
alpha: false,
|
|
preserveDrawingBuffer: true,
|
|
powerPreference: 'high-performance',
|
|
});
|
|
} catch {
|
|
setFallback(true);
|
|
return undefined;
|
|
}
|
|
|
|
setFallback(false);
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.08;
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
renderer.domElement.setAttribute('data-version', RELEASE);
|
|
renderer.domElement.style.cursor = 'grab';
|
|
renderer.domElement.style.touchAction = 'none';
|
|
mount.appendChild(renderer.domElement);
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x030202);
|
|
scene.fog = new THREE.Fog(0x090504, 12, 26);
|
|
|
|
const camera = new THREE.PerspectiveCamera(39, 16 / 9, 0.1, 80);
|
|
const cameraTarget = new THREE.Vector3(0, -0.32, -1.55);
|
|
|
|
scene.add(new THREE.HemisphereLight(0xcde8ff, 0x2e1105, 1.42));
|
|
const key = new THREE.DirectionalLight(0xffc47c, 2.35);
|
|
key.position.set(-3.8, 6.4, 5.2);
|
|
key.castShadow = true;
|
|
key.shadow.mapSize.set(2048, 2048);
|
|
key.shadow.camera.near = 0.2;
|
|
key.shadow.camera.far = 24;
|
|
key.shadow.camera.left = -8;
|
|
key.shadow.camera.right = 8;
|
|
key.shadow.camera.top = 7;
|
|
key.shadow.camera.bottom = -7;
|
|
scene.add(key);
|
|
|
|
const cyan = new THREE.PointLight(0x29dfff, 5.2, 11, 1.7);
|
|
cyan.position.set(0.2, 1.2, -1.25);
|
|
const amber = new THREE.PointLight(0xffa13a, 4.2, 9, 1.8);
|
|
amber.position.set(-3.8, 1.5, 0.2);
|
|
const violet = new THREE.PointLight(0xa775ff, 3.2, 8, 1.8);
|
|
violet.position.set(2.75, 0.75, 0.5);
|
|
scene.add(cyan, amber, violet);
|
|
|
|
const stage = new THREE.Group();
|
|
scene.add(stage);
|
|
|
|
const built = buildGemhall(route);
|
|
stage.add(built.root);
|
|
|
|
const interactives = [built.dashboard, built.boothSign, built.speech];
|
|
const tracked = {
|
|
dashboard: built.dashboard,
|
|
booth: built.boothSign,
|
|
speech: built.speech,
|
|
orc: built.orc,
|
|
elf: built.elf,
|
|
dwarf: built.dwarf,
|
|
title: built.title,
|
|
};
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
const pointer = new THREE.Vector2();
|
|
const drag = { active: false, moved: false, startX: 0, startY: 0, pointerId: null };
|
|
let hoverRoute = '';
|
|
let frame = 0;
|
|
let disposed = false;
|
|
let layout = 'desktop';
|
|
let currentYaw = 0;
|
|
let currentPitch = 0;
|
|
let targetYaw = 0;
|
|
let targetPitch = 0;
|
|
let meshCount = 0;
|
|
|
|
const dynamicMetrics = () => ({
|
|
assetMode: '0.7 gemhall generated backdrop plus new procedural mesh rigs',
|
|
assetAudit: ASSET_AUDIT,
|
|
idleAnimations: IDLE_ANIMATIONS,
|
|
generatedBackdrop: gemhallBackdropUrl,
|
|
dynamicFrames: {
|
|
wantedList: built.dashboard.material.map?.userData?.frame || 0,
|
|
speech: built.speech.material.map?.userData?.frame || 0,
|
|
boothNeon: built.boothSign.material.map?.userData?.frame || 0,
|
|
},
|
|
rosterScroll: round(built.dashboard.material.map?.userData?.scroll || 0),
|
|
rosterActiveAgent: built.dashboard.material.map?.userData?.activeAgent || WANTED_AGENTS[0].name,
|
|
visualTarget: 'reference-guided polished low-poly cyber tavern',
|
|
});
|
|
|
|
const setLayout = () => {
|
|
const rect = mount.getBoundingClientRect();
|
|
const width = Math.max(1, Math.floor(rect.width || window.innerWidth || 1));
|
|
const height = Math.max(1, Math.floor(rect.height || window.innerHeight || 1));
|
|
const aspect = width / height;
|
|
const portrait = height > width * 1.05;
|
|
layout = width < 760 && portrait ? 'mobile' : width < 1100 || aspect < 1.25 ? 'tablet' : 'desktop';
|
|
|
|
renderer.setSize(width, height, false);
|
|
camera.aspect = aspect;
|
|
if (layout === 'mobile') {
|
|
camera.fov = 50;
|
|
camera.position.set(0, 2.72, 8.15);
|
|
cameraTarget.set(0, -0.46, -1.42);
|
|
stage.scale.setScalar(0.94);
|
|
stage.position.set(0, -0.24, 0);
|
|
placeMobile(built);
|
|
} else if (layout === 'tablet') {
|
|
camera.fov = 43;
|
|
camera.position.set(0, 2.82, 8.55);
|
|
cameraTarget.set(0, -0.36, -1.5);
|
|
stage.scale.setScalar(1);
|
|
stage.position.set(0, -0.1, 0);
|
|
placeTablet(built);
|
|
} else {
|
|
camera.fov = 39;
|
|
camera.position.set(0, 2.92, 8.25);
|
|
cameraTarget.set(0, -0.34, -1.55);
|
|
stage.scale.setScalar(1);
|
|
stage.position.set(0, 0, 0);
|
|
placeDesktop(built);
|
|
}
|
|
camera.updateProjectionMatrix();
|
|
camera.lookAt(cameraTarget);
|
|
meshCount = countMeshes(stage);
|
|
publishStageDebug({ width, height, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount, ...dynamicMetrics() }, tracked, camera);
|
|
};
|
|
|
|
const resizeObserver = new ResizeObserver(setLayout);
|
|
resizeObserver.observe(mount);
|
|
setLayout();
|
|
|
|
const pickRoute = (event) => {
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
pointer.x = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * 2 - 1;
|
|
pointer.y = -((event.clientY - rect.top) / Math.max(rect.height, 1)) * 2 + 1;
|
|
raycaster.setFromCamera(pointer, camera);
|
|
const hit = raycaster.intersectObjects(interactives, false)[0];
|
|
return hit?.object?.userData?.route || '';
|
|
};
|
|
|
|
const onPointerDown = (event) => {
|
|
drag.active = true;
|
|
drag.moved = false;
|
|
drag.startX = event.clientX;
|
|
drag.startY = event.clientY;
|
|
drag.pointerId = event.pointerId;
|
|
renderer.domElement.setPointerCapture?.(event.pointerId);
|
|
renderer.domElement.style.cursor = 'grabbing';
|
|
};
|
|
|
|
const onPointerMove = (event) => {
|
|
if (drag.active) {
|
|
const dx = event.clientX - drag.startX;
|
|
const dy = event.clientY - drag.startY;
|
|
if (Math.hypot(dx, dy) > 4) drag.moved = true;
|
|
targetYaw = clamp(dx * 0.00145, -0.24, 0.24);
|
|
targetPitch = clamp(dy * 0.0009, -0.085, 0.085);
|
|
return;
|
|
}
|
|
hoverRoute = pickRoute(event);
|
|
renderer.domElement.style.cursor = hoverRoute ? 'pointer' : 'grab';
|
|
};
|
|
|
|
const onPointerUp = (event) => {
|
|
renderer.domElement.releasePointerCapture?.(event.pointerId);
|
|
drag.active = false;
|
|
drag.pointerId = null;
|
|
if (!drag.moved) {
|
|
const nextRoute = pickRoute(event);
|
|
if (nextRoute) setRoute(nextRoute);
|
|
}
|
|
hoverRoute = pickRoute(event);
|
|
renderer.domElement.style.cursor = hoverRoute ? 'pointer' : 'grab';
|
|
};
|
|
|
|
const onPointerLeave = () => {
|
|
if (!drag.active) {
|
|
hoverRoute = '';
|
|
renderer.domElement.style.cursor = 'grab';
|
|
}
|
|
};
|
|
|
|
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
|
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
|
renderer.domElement.addEventListener('pointerup', onPointerUp);
|
|
renderer.domElement.addEventListener('pointercancel', onPointerUp);
|
|
renderer.domElement.addEventListener('pointerleave', onPointerLeave);
|
|
|
|
const startedAt = performance.now();
|
|
const animate = () => {
|
|
if (disposed) return;
|
|
frame = requestAnimationFrame(animate);
|
|
const t = (performance.now() - startedAt) / 1000;
|
|
currentYaw += (targetYaw - currentYaw) * 0.12;
|
|
currentPitch += (targetPitch - currentPitch) * 0.12;
|
|
stage.rotation.y = currentYaw + Math.sin(t * 0.22) * 0.006;
|
|
stage.rotation.x = currentPitch;
|
|
|
|
animateGemhallRig(built, t);
|
|
built.updaters.forEach((update) => update(t));
|
|
built.lanterns.forEach((light, index) => {
|
|
light.intensity = light.userData.baseIntensity + Math.sin(t * 1.8 + index * 1.9) * 0.42;
|
|
});
|
|
|
|
interactives.forEach((object) => {
|
|
const base = object.userData.baseScale || object.scale;
|
|
const targetScale = object.userData.route === hoverRoute ? 1.045 : 1;
|
|
object.scale.lerp(new THREE.Vector3(base.x * targetScale, base.y * targetScale, base.z), 0.16);
|
|
});
|
|
|
|
if (Math.floor(t * 10) % 3 === 0) {
|
|
publishStageDebug(
|
|
{ width: renderer.domElement.clientWidth, height: renderer.domElement.clientHeight, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount, ...dynamicMetrics() },
|
|
tracked,
|
|
camera,
|
|
);
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
};
|
|
animate();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
resizeObserver.disconnect();
|
|
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
|
|
renderer.domElement.removeEventListener('pointermove', onPointerMove);
|
|
renderer.domElement.removeEventListener('pointerup', onPointerUp);
|
|
renderer.domElement.removeEventListener('pointercancel', onPointerUp);
|
|
renderer.domElement.removeEventListener('pointerleave', onPointerLeave);
|
|
cancelAnimationFrame(frame);
|
|
disposeScene(scene);
|
|
renderer.dispose();
|
|
if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
|
|
};
|
|
}, [route, setRoute]);
|
|
|
|
return (
|
|
<div className="tavern-stage gemhall-stage" ref={mountRef} aria-label="Draggable polished low-poly Three.js AI mercenary tavern">
|
|
{fallback && <div className="webgl-fallback" aria-hidden="true" />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildGemhall(route) {
|
|
const root = new THREE.Group();
|
|
const staticSet = new THREE.Group();
|
|
const characterSet = new THREE.Group();
|
|
const uiSet = new THREE.Group();
|
|
root.add(staticSet, characterSet, uiSet);
|
|
|
|
const backdrop = makeBackdropPlane();
|
|
staticSet.add(backdrop);
|
|
buildForegroundBar(staticSet);
|
|
const lanterns = addSceneLightsAndProps(staticSet);
|
|
|
|
const orc = makeGemhallOrc();
|
|
const elf = makeGemhallElf();
|
|
const dwarf = makeGemhallDwarf();
|
|
characterSet.add(orc, elf, dwarf);
|
|
|
|
const titleTexture = makeTitleTexture(route);
|
|
const wantedTexture = makeWantedTexture(route);
|
|
const speechTexture = makeSpeechTexture(route);
|
|
const boothTexture = makeBoothTexture();
|
|
|
|
const title = makeCanvasPlane(titleTexture.texture, { renderOrder: 28, depthTest: false });
|
|
const dashboard = makeCanvasPlane(wantedTexture.texture, { renderOrder: 25, depthTest: true, route: 'agents' });
|
|
const dashboardFrame = makeScreenFrame();
|
|
const boothSign = makeCanvasPlane(boothTexture.texture, { renderOrder: 32, depthTest: true, route: 'booth' });
|
|
const speech = makeCanvasPlane(speechTexture.texture, { renderOrder: 36, depthTest: true, route: route === 'signin' ? 'booth' : 'agents' });
|
|
const boothRing = makeBoothRing();
|
|
const elfRune = makeElfRune();
|
|
uiSet.add(title, dashboard, dashboardFrame, boothSign, speech, boothRing, elfRune);
|
|
|
|
return {
|
|
root,
|
|
backdrop,
|
|
title,
|
|
dashboard,
|
|
dashboardFrame,
|
|
boothSign,
|
|
speech,
|
|
boothRing,
|
|
elfRune,
|
|
orc,
|
|
elf,
|
|
dwarf,
|
|
lanterns,
|
|
updaters: [titleTexture.update, wantedTexture.update, speechTexture.update, boothTexture.update],
|
|
};
|
|
}
|
|
|
|
function makeBackdropPlane() {
|
|
const texture = new THREE.TextureLoader().load(gemhallBackdropUrl);
|
|
prepareTexture(texture);
|
|
const mesh = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(13.15, 7.4),
|
|
new THREE.MeshBasicMaterial({ map: texture, depthWrite: false, depthTest: true }),
|
|
);
|
|
mesh.position.set(0, 0.15, -4.1);
|
|
mesh.renderOrder = -10;
|
|
mesh.userData.asset = 'gemhall generated low-poly tavern backdrop';
|
|
return mesh;
|
|
}
|
|
|
|
function buildForegroundBar(group) {
|
|
const richWood = woodMaterial('#5e2c14', '#d0873d', '#160805');
|
|
const darkWood = woodMaterial('#30120a', '#6b2f17', '#080302');
|
|
const stone = mat(0x3c3740, { roughness: 0.85 });
|
|
const brass = mat(0xb47634, { metalness: 0.38, roughness: 0.42 });
|
|
const cyan = mat(0x18dfff, { emissive: 0x18dfff, emissiveIntensity: 1.5, roughness: 0.32 });
|
|
|
|
const floor = box(12.5, 0.08, 5.2, woodMaterial('#342013', '#8b5630', '#0b0503'));
|
|
floor.position.set(0, -2.28, 1.08);
|
|
floor.receiveShadow = true;
|
|
group.add(floor);
|
|
|
|
const rug = box(2.45, 0.025, 1.05, mat(0x172b69, { roughness: 0.78, emissive: 0x00143b, emissiveIntensity: 0.45 }));
|
|
rug.position.set(0.95, -2.22, 1.42);
|
|
rug.rotation.y = -0.03;
|
|
group.add(rug);
|
|
|
|
for (let i = 0; i < 11; i += 1) {
|
|
const tile = box(0.7 + (i % 2) * 0.14, 0.035, 0.26, stone);
|
|
const angle = -Math.PI * 0.88 + i * (Math.PI * 0.78) / 10;
|
|
tile.position.set(Math.cos(angle) * 2.75, -2.18, 1.14 + Math.sin(angle) * 0.82);
|
|
tile.rotation.y = -angle * 0.28;
|
|
group.add(tile);
|
|
}
|
|
|
|
const barTop = box(8.9, 0.34, 1.03, richWood);
|
|
barTop.position.set(0, -0.82, -0.9);
|
|
barTop.castShadow = true;
|
|
barTop.receiveShadow = true;
|
|
group.add(barTop);
|
|
|
|
const barLip = box(9.15, 0.18, 0.22, brass);
|
|
barLip.position.set(0, -0.62, -0.38);
|
|
barLip.castShadow = true;
|
|
group.add(barLip);
|
|
|
|
const barFront = box(8.75, 0.88, 0.28, darkWood);
|
|
barFront.position.set(0, -1.36, -0.33);
|
|
barFront.castShadow = true;
|
|
barFront.receiveShadow = true;
|
|
group.add(barFront);
|
|
|
|
const cyanStrip = box(7.45, 0.08, 0.05, cyan);
|
|
cyanStrip.position.set(0, -1.0, -0.17);
|
|
group.add(cyanStrip);
|
|
|
|
[-4.05, -2.95, 2.95, 4.05].forEach((x) => {
|
|
const brace = box(0.12, 0.86, 0.36, brass);
|
|
brace.position.set(x, -1.36, -0.18);
|
|
group.add(brace);
|
|
});
|
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
const stool = new THREE.Group();
|
|
const seat = cylinder(0.28, 0.34, 0.14, richWood, 8);
|
|
const stem = cylinder(0.07, 0.1, 0.62, darkWood, 6);
|
|
const foot = cylinder(0.22, 0.28, 0.06, brass, 8);
|
|
seat.position.y = 0.5;
|
|
stem.position.y = 0.18;
|
|
foot.position.y = -0.14;
|
|
stool.add(seat, stem, foot);
|
|
stool.position.set(-3.65 + i * 2.42, -2.24, 0.72);
|
|
stool.rotation.y = (i % 2 ? -1 : 1) * 0.16;
|
|
markShadows(stool);
|
|
group.add(stool);
|
|
}
|
|
|
|
for (let i = 0; i < 7; i += 1) {
|
|
const mug = cylinder(0.08, 0.1, 0.18, i % 2 ? brass : mat(0x8e5d37), 8);
|
|
mug.position.set(-3.6 + i * 1.12, -0.47, -0.58 + (i % 3) * 0.12);
|
|
mug.castShadow = true;
|
|
group.add(mug);
|
|
}
|
|
|
|
const leftCrystal = makeCrystalCluster(0.7);
|
|
leftCrystal.position.set(-4.7, -1.55, 0.6);
|
|
group.add(leftCrystal);
|
|
|
|
const rightCrystal = makeCrystalCluster(0.52);
|
|
rightCrystal.position.set(4.6, -1.74, 0.55);
|
|
group.add(rightCrystal);
|
|
}
|
|
|
|
function addSceneLightsAndProps(group) {
|
|
const lights = [];
|
|
[
|
|
[-4.4, 1.35, -1.15, 0xffa64a],
|
|
[4.1, 1.45, -1.3, 0xffbd63],
|
|
[0, 1.75, -2.45, 0x28dbff],
|
|
].forEach(([x, y, z, color], index) => {
|
|
const lantern = new THREE.Group();
|
|
const cage = cylinder(0.16, 0.18, 0.38, mat(0x7a5a3a, { metalness: 0.42, roughness: 0.4 }), 6);
|
|
const core = cylinder(0.1, 0.12, 0.28, mat(color, { emissive: color, emissiveIntensity: 1.6 }), 6);
|
|
lantern.add(cage, core);
|
|
lantern.position.set(x, y, z);
|
|
lantern.userData.core = core;
|
|
group.add(lantern);
|
|
|
|
const point = new THREE.PointLight(color, index === 2 ? 2.6 : 2.3, index === 2 ? 6.5 : 5.6, 1.7);
|
|
point.position.set(x, y, z + 0.28);
|
|
point.userData.baseIntensity = point.intensity;
|
|
group.add(point);
|
|
lights.push(point);
|
|
});
|
|
|
|
const sparks = new THREE.Group();
|
|
const sparkMaterial = mat(0xffc36e, { emissive: 0xffa546, emissiveIntensity: 1.1 });
|
|
for (let i = 0; i < 34; i += 1) {
|
|
const spark = sphere(0.012 + (i % 3) * 0.006, sparkMaterial, 5, 4);
|
|
spark.position.set(-5.7 + ((i * 1.91) % 11.4), -0.45 + ((i * 0.41) % 2.4), -0.7 - ((i * 0.29) % 2.7));
|
|
spark.userData.seed = i * 0.47;
|
|
sparks.add(spark);
|
|
}
|
|
sparks.userData.animate = true;
|
|
group.add(sparks);
|
|
group.userData.sparks = sparks;
|
|
|
|
return lights;
|
|
}
|
|
|
|
function makeGemhallOrc() {
|
|
const group = new THREE.Group();
|
|
const skin = mat(0x6da83b, { roughness: 0.72 });
|
|
const darkSkin = mat(0x426b25, { roughness: 0.78 });
|
|
const leather = mat(0x5a321d, { roughness: 0.72 });
|
|
const darkLeather = mat(0x2c160d, { roughness: 0.8 });
|
|
const armor = mat(0x8d7259, { metalness: 0.34, roughness: 0.42 });
|
|
const cyan = mat(0x18e2ff, { emissive: 0x18e2ff, emissiveIntensity: 1.65, roughness: 0.25 });
|
|
const bone = mat(0xf0dfc4, { roughness: 0.6 });
|
|
|
|
const pelvis = box(0.92, 0.36, 0.44, darkLeather);
|
|
pelvis.position.y = 0.38;
|
|
const torso = cylinder(0.52, 0.72, 1.22, leather, 8);
|
|
torso.position.y = 1.18;
|
|
torso.scale.x = 1.12;
|
|
const chest = box(1.16, 0.62, 0.16, armor);
|
|
chest.position.set(0, 1.25, 0.43);
|
|
const badge = new THREE.Mesh(new THREE.OctahedronGeometry(0.14, 0), cyan);
|
|
badge.position.set(0, 1.3, 0.54);
|
|
|
|
const neck = cylinder(0.23, 0.28, 0.18, darkSkin, 7);
|
|
neck.position.y = 1.88;
|
|
const head = new THREE.Mesh(new THREE.DodecahedronGeometry(0.48, 0), skin);
|
|
head.position.y = 2.24;
|
|
head.scale.set(1.1, 0.92, 0.96);
|
|
const jaw = box(0.58, 0.22, 0.3, darkSkin);
|
|
jaw.position.set(0, 2.05, 0.38);
|
|
const brow = box(0.82, 0.13, 0.12, darkSkin);
|
|
brow.position.set(0, 2.32, 0.43);
|
|
const eyeL = box(0.12, 0.065, 0.04, cyan);
|
|
eyeL.position.set(-0.19, 2.25, 0.57);
|
|
const eyeR = eyeL.clone();
|
|
eyeR.position.x = 0.19;
|
|
const tuskL = cone(0.06, 0.36, bone, 5);
|
|
tuskL.position.set(-0.23, 1.9, 0.56);
|
|
tuskL.rotation.x = Math.PI / 2;
|
|
tuskL.rotation.z = 0.16;
|
|
const tuskR = tuskL.clone();
|
|
tuskR.position.x = 0.23;
|
|
tuskR.rotation.z = -0.16;
|
|
const earL = cone(0.12, 0.48, darkSkin, 5);
|
|
earL.position.set(-0.56, 2.23, 0.02);
|
|
earL.rotation.z = Math.PI / 2;
|
|
const earR = earL.clone();
|
|
earR.position.x = 0.56;
|
|
earR.rotation.z = -Math.PI / 2;
|
|
|
|
const shoulderL = cylinder(0.32, 0.38, 0.28, armor, 7);
|
|
shoulderL.position.set(-0.78, 1.62, 0.08);
|
|
shoulderL.rotation.z = Math.PI / 2;
|
|
const shoulderR = shoulderL.clone();
|
|
shoulderR.position.x = 0.78;
|
|
const armL = makeSegmentedArm(skin, darkSkin, armor);
|
|
armL.position.set(-0.78, 1.22, 0.15);
|
|
armL.rotation.z = -0.58;
|
|
const armR = makeSegmentedArm(skin, darkSkin, armor);
|
|
armR.position.set(0.83, 1.18, 0.18);
|
|
armR.rotation.z = 0.6;
|
|
const mug = cylinder(0.18, 0.22, 0.36, armor, 8);
|
|
mug.position.set(-1.22, 1.42, 0.42);
|
|
mug.rotation.z = -0.18;
|
|
const mugGlow = cylinder(0.12, 0.14, 0.04, cyan, 8);
|
|
mugGlow.position.set(-1.22, 1.62, 0.42);
|
|
|
|
const pack = box(0.58, 0.82, 0.3, armor);
|
|
pack.position.set(0.58, 1.03, -0.48);
|
|
const coil = new THREE.Mesh(new THREE.TorusGeometry(0.23, 0.025, 6, 18), cyan);
|
|
coil.position.set(0.58, 1.04, -0.67);
|
|
coil.rotation.x = Math.PI / 2;
|
|
const legL = makeLeg(darkSkin, darkLeather);
|
|
legL.position.set(-0.28, -0.02, 0.04);
|
|
const legR = makeLeg(darkSkin, darkLeather);
|
|
legR.position.set(0.28, -0.02, 0.04);
|
|
|
|
group.add(pelvis, torso, chest, badge, neck, head, jaw, brow, eyeL, eyeR, tuskL, tuskR, earL, earR, shoulderL, shoulderR, armL, armR, mug, mugGlow, pack, coil, legL, legR);
|
|
group.userData = { head, torso, chest, badge, armL, armR, mug, mugGlow, coil, eyeL, eyeR, baseRotY: 0, baseY: 0 };
|
|
markShadows(group);
|
|
return group;
|
|
}
|
|
|
|
function makeGemhallElf() {
|
|
const group = new THREE.Group();
|
|
const skin = mat(0xf1c7ad, { roughness: 0.62 });
|
|
const purple = mat(0x6b37c8, { roughness: 0.52 });
|
|
const darkPurple = mat(0x27183e, { roughness: 0.68 });
|
|
const cyan = mat(0x27e5ff, { emissive: 0x27e5ff, emissiveIntensity: 1.6, roughness: 0.24 });
|
|
const gold = mat(0xd99b45, { metalness: 0.28, roughness: 0.4 });
|
|
const hair = mat(0xe9edf4, { roughness: 0.42 });
|
|
|
|
const dress = cone(0.48, 1.45, purple, 6);
|
|
dress.position.y = 0.84;
|
|
dress.scale.z = 0.72;
|
|
const bodice = box(0.62, 0.74, 0.26, darkPurple);
|
|
bodice.position.set(0, 1.47, 0.05);
|
|
const beltGem = new THREE.Mesh(new THREE.OctahedronGeometry(0.12, 0), cyan);
|
|
beltGem.position.set(0, 1.13, 0.24);
|
|
const cape = cone(0.58, 1.55, mat(0x1d7e9b, { transparent: true, opacity: 0.78, roughness: 0.55 }), 5);
|
|
cape.position.set(0, 0.78, -0.2);
|
|
cape.rotation.x = -0.08;
|
|
|
|
const head = new THREE.Mesh(new THREE.IcosahedronGeometry(0.24, 0), skin);
|
|
head.position.y = 2.07;
|
|
head.scale.set(0.86, 1.08, 0.92);
|
|
const hairCap = cone(0.3, 0.48, hair, 6);
|
|
hairCap.position.y = 2.31;
|
|
hairCap.rotation.z = 0.1;
|
|
const hairTail = cone(0.18, 0.82, hair, 5);
|
|
hairTail.position.set(-0.05, 1.84, -0.18);
|
|
hairTail.rotation.x = -0.18;
|
|
const eyeL = box(0.052, 0.024, 0.03, cyan);
|
|
eyeL.position.set(-0.08, 2.08, 0.23);
|
|
const eyeR = eyeL.clone();
|
|
eyeR.position.x = 0.08;
|
|
const earL = cone(0.055, 0.34, skin, 5);
|
|
earL.position.set(-0.26, 2.08, 0.02);
|
|
earL.rotation.z = Math.PI / 2;
|
|
const earR = earL.clone();
|
|
earR.position.x = 0.26;
|
|
earR.rotation.z = -Math.PI / 2;
|
|
|
|
const armL = makeSlenderArm(skin, gold);
|
|
armL.position.set(-0.36, 1.48, 0.05);
|
|
armL.rotation.z = -0.88;
|
|
const armR = makeSlenderArm(skin, gold);
|
|
armR.position.set(0.36, 1.52, 0.05);
|
|
armR.rotation.z = 0.42;
|
|
armR.rotation.x = -0.18;
|
|
const legL = makeSlenderLeg(darkPurple, gold);
|
|
legL.position.set(-0.14, 0.22, 0.02);
|
|
const legR = makeSlenderLeg(darkPurple, gold);
|
|
legR.position.set(0.14, 0.22, 0.02);
|
|
|
|
const staff = cylinder(0.025, 0.03, 1.8, gold, 6);
|
|
staff.position.set(0.64, 0.88, 0.12);
|
|
staff.rotation.z = -0.16;
|
|
const gem = new THREE.Mesh(new THREE.OctahedronGeometry(0.16, 0), cyan);
|
|
gem.position.set(0.78, 1.75, 0.16);
|
|
const halo = new THREE.Mesh(new THREE.TorusGeometry(0.26, 0.012, 5, 28), cyan);
|
|
halo.position.set(0.78, 1.75, 0.16);
|
|
halo.rotation.x = Math.PI / 2;
|
|
|
|
group.add(dress, bodice, beltGem, cape, head, hairCap, hairTail, eyeL, eyeR, earL, earR, armL, armR, legL, legR, staff, gem, halo);
|
|
group.userData = { dress, bodice, cape, head, hairCap, hairTail, armL, armR, staff, gem, halo, baseRotY: 0, baseY: 0 };
|
|
markShadows(group);
|
|
return group;
|
|
}
|
|
|
|
function makeGemhallDwarf() {
|
|
const group = new THREE.Group();
|
|
const skin = mat(0xc98a5d, { roughness: 0.62 });
|
|
const beard = mat(0xd86a2c, { roughness: 0.7 });
|
|
const armor = mat(0x71675f, { metalness: 0.32, roughness: 0.48 });
|
|
const leather = mat(0x4b2515, { roughness: 0.78 });
|
|
const cyan = mat(0x19d9ff, { emissive: 0x19d9ff, emissiveIntensity: 1.45 });
|
|
const body = cylinder(0.48, 0.56, 0.92, armor, 8);
|
|
body.position.y = 0.72;
|
|
body.scale.z = 0.82;
|
|
const head = new THREE.Mesh(new THREE.DodecahedronGeometry(0.31, 0), skin);
|
|
head.position.y = 1.35;
|
|
const nose = cone(0.05, 0.15, skin, 5);
|
|
nose.position.set(0, 1.34, 0.3);
|
|
nose.rotation.x = Math.PI / 2;
|
|
const beardMesh = cone(0.33, 0.55, beard, 7);
|
|
beardMesh.position.set(0, 1.08, 0.18);
|
|
beardMesh.rotation.x = Math.PI;
|
|
const helm = cylinder(0.3, 0.36, 0.22, armor, 8);
|
|
helm.position.y = 1.62;
|
|
const hornL = cone(0.06, 0.34, mat(0xe9d9bc), 5);
|
|
hornL.position.set(-0.28, 1.65, 0);
|
|
hornL.rotation.z = Math.PI / 2;
|
|
const hornR = hornL.clone();
|
|
hornR.position.x = 0.28;
|
|
hornR.rotation.z = -Math.PI / 2;
|
|
const pack = box(0.5, 0.62, 0.28, leather);
|
|
pack.position.set(0, 0.76, -0.38);
|
|
const core = box(0.17, 0.17, 0.04, cyan);
|
|
core.position.set(0, 0.78, -0.55);
|
|
const armL = makeSegmentedArm(skin, leather, armor);
|
|
armL.scale.setScalar(0.72);
|
|
armL.position.set(-0.48, 0.83, 0.08);
|
|
armL.rotation.z = -0.72;
|
|
const armR = makeSegmentedArm(skin, leather, armor);
|
|
armR.scale.setScalar(0.72);
|
|
armR.position.set(0.5, 0.85, 0.08);
|
|
armR.rotation.z = 0.55;
|
|
const mug = cylinder(0.13, 0.16, 0.27, armor, 8);
|
|
mug.position.set(-0.78, 1.02, 0.28);
|
|
const hammerHandle = cylinder(0.035, 0.04, 0.9, leather, 5);
|
|
hammerHandle.position.set(0.72, 0.86, 0.1);
|
|
hammerHandle.rotation.z = -0.65;
|
|
const hammerHead = box(0.4, 0.2, 0.22, armor);
|
|
hammerHead.position.set(0.93, 1.17, 0.1);
|
|
const legL = makeLeg(leather, leather);
|
|
legL.scale.setScalar(0.7);
|
|
legL.position.set(-0.22, 0.02, 0.05);
|
|
const legR = makeLeg(leather, leather);
|
|
legR.scale.setScalar(0.7);
|
|
legR.position.set(0.22, 0.02, 0.05);
|
|
group.add(body, head, nose, beardMesh, helm, hornL, hornR, pack, core, armL, armR, mug, hammerHandle, hammerHead, legL, legR);
|
|
group.userData = { head, body, beard: beardMesh, armL, armR, mug, hammerHandle, hammerHead, core, baseY: 0, baseRotY: 0 };
|
|
markShadows(group);
|
|
return group;
|
|
}
|
|
|
|
function makeSegmentedArm(skin, sleeve, metal) {
|
|
const group = new THREE.Group();
|
|
const upper = cylinder(0.11, 0.15, 0.55, sleeve, 6);
|
|
upper.position.y = -0.22;
|
|
const elbow = sphere(0.14, metal, 6, 5);
|
|
elbow.position.y = -0.52;
|
|
const lower = cylinder(0.09, 0.12, 0.5, skin, 6);
|
|
lower.position.y = -0.76;
|
|
const hand = sphere(0.13, skin, 6, 5);
|
|
hand.position.y = -1.05;
|
|
group.add(upper, elbow, lower, hand);
|
|
return group;
|
|
}
|
|
|
|
function makeSlenderArm(skin, cuff) {
|
|
const group = new THREE.Group();
|
|
const upper = cylinder(0.045, 0.065, 0.52, skin, 5);
|
|
upper.position.y = -0.22;
|
|
const band = cylinder(0.07, 0.075, 0.07, cuff, 5);
|
|
band.position.y = -0.49;
|
|
const lower = cylinder(0.04, 0.055, 0.46, skin, 5);
|
|
lower.position.y = -0.7;
|
|
const hand = sphere(0.07, skin, 5, 4);
|
|
hand.position.y = -0.96;
|
|
group.add(upper, band, lower, hand);
|
|
return group;
|
|
}
|
|
|
|
function makeSlenderLeg(boot, trim) {
|
|
const group = new THREE.Group();
|
|
const leg = cylinder(0.045, 0.06, 0.62, boot, 5);
|
|
leg.position.y = -0.28;
|
|
const bootMesh = box(0.13, 0.09, 0.28, trim);
|
|
bootMesh.position.set(0, -0.62, 0.08);
|
|
group.add(leg, bootMesh);
|
|
return group;
|
|
}
|
|
|
|
function makeLeg(skin, bootMaterial) {
|
|
const group = new THREE.Group();
|
|
const leg = cylinder(0.12, 0.16, 0.6, skin, 6);
|
|
leg.position.y = -0.3;
|
|
const boot = box(0.28, 0.16, 0.42, bootMaterial);
|
|
boot.position.set(0, -0.68, 0.12);
|
|
group.add(leg, boot);
|
|
return group;
|
|
}
|
|
|
|
function makeCrystalCluster(scale = 1) {
|
|
const group = new THREE.Group();
|
|
const cyan = mat(0x18dfff, { emissive: 0x18dfff, emissiveIntensity: 1.28, transparent: true, opacity: 0.82 });
|
|
const base = cylinder(0.36, 0.42, 0.2, mat(0x4a2a19), 7);
|
|
base.position.y = -0.12;
|
|
group.add(base);
|
|
[0, 1, 2].forEach((index) => {
|
|
const crystal = cone(0.15 - index * 0.03, 0.72 - index * 0.12, cyan, 5);
|
|
crystal.position.set((index - 1) * 0.18, 0.26 + index * 0.05, (index % 2) * 0.08);
|
|
crystal.rotation.z = (index - 1) * 0.12;
|
|
group.add(crystal);
|
|
});
|
|
group.scale.setScalar(scale);
|
|
markShadows(group);
|
|
return group;
|
|
}
|
|
|
|
function makeScreenFrame() {
|
|
const group = new THREE.Group();
|
|
const frame = mat(0x0c2430, { metalness: 0.4, roughness: 0.34 });
|
|
const brass = mat(0xb77834, { metalness: 0.45, roughness: 0.38 });
|
|
const glow = mat(0x18dfff, { emissive: 0x18dfff, emissiveIntensity: 1.25, roughness: 0.2 });
|
|
const top = box(1.05, 0.06, 0.15, frame);
|
|
top.position.y = 0.535;
|
|
const bottom = top.clone();
|
|
bottom.position.y = -0.535;
|
|
const left = box(0.04, 1.13, 0.14, frame);
|
|
left.position.x = -0.525;
|
|
const right = left.clone();
|
|
right.position.x = 0.525;
|
|
const strip = box(0.94, 0.018, 0.035, glow);
|
|
strip.position.set(0, -0.465, 0.09);
|
|
group.add(top, bottom, left, right, strip);
|
|
[
|
|
[-0.49, 0.48],
|
|
[0.49, 0.48],
|
|
[-0.49, -0.48],
|
|
[0.49, -0.48],
|
|
].forEach(([x, y]) => {
|
|
const bolt = cylinder(0.026, 0.026, 0.052, brass, 6);
|
|
bolt.position.set(x, y, 0.1);
|
|
bolt.rotation.x = Math.PI / 2;
|
|
group.add(bolt);
|
|
});
|
|
return group;
|
|
}
|
|
|
|
function makeBoothRing() {
|
|
const group = new THREE.Group();
|
|
const glow = mat(0x1ce5ff, { emissive: 0x1ce5ff, emissiveIntensity: 1.45, transparent: true, opacity: 0.72 });
|
|
[0.72, 1.0, 1.28].forEach((radius) => {
|
|
const ring = new THREE.Mesh(new THREE.TorusGeometry(radius, 0.012, 5, 64), glow);
|
|
ring.rotation.x = Math.PI / 2;
|
|
group.add(ring);
|
|
});
|
|
group.userData.baseScale = new THREE.Vector3(1, 1, 1);
|
|
return group;
|
|
}
|
|
|
|
function makeElfRune() {
|
|
const group = new THREE.Group();
|
|
const glow = mat(0x1ce5ff, { emissive: 0x1ce5ff, emissiveIntensity: 1.5, transparent: true, opacity: 0.68 });
|
|
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.54, 0.014, 5, 56), glow);
|
|
ring.rotation.x = Math.PI / 2;
|
|
const diamond = new THREE.Mesh(new THREE.OctahedronGeometry(0.12, 0), glow);
|
|
diamond.position.y = 0.02;
|
|
group.add(ring, diamond);
|
|
return group;
|
|
}
|
|
|
|
function makeTitleTexture(route) {
|
|
return makeAnimatedTexture(1500, 330, (ctx, width, height, time) => {
|
|
const pulse = 0.65 + Math.sin(time * 1.4) * 0.22;
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.shadowColor = `rgba(58, 219, 255, ${pulse})`;
|
|
ctx.shadowBlur = 22;
|
|
ctx.fillStyle = '#fff1d8';
|
|
ctx.font = '900 78px Georgia, Times New Roman, serif';
|
|
const title = route === 'agents' ? 'Browse agent mercenaries.' : route === 'booth' ? 'Enter your private booth.' : 'Hire agents like mercenaries.';
|
|
fitText(ctx, title, width / 2, 104, width - 130, 78);
|
|
ctx.shadowBlur = 12;
|
|
ctx.fillStyle = '#f1d3a0';
|
|
ctx.font = '800 32px Georgia, Times New Roman, serif';
|
|
fitText(ctx, 'Hire, run, publish, and remotely deliver AI agents through amerc.', width / 2, 170, width - 240, 32);
|
|
ctx.fillStyle = '#ffbd54';
|
|
ctx.font = '900 24px Inter, Arial, sans-serif';
|
|
fitText(ctx, 'Static front-end concept, v0.7.0-gemhall.', width / 2, 218, width - 220, 24);
|
|
return { titleFrame: Math.floor(time * 18) };
|
|
});
|
|
}
|
|
|
|
function makeWantedTexture(route) {
|
|
return makeAnimatedTexture(1280, 540, (ctx, width, height, time) => {
|
|
const scroll = (time * 58) % 68;
|
|
const activeIndex = Math.floor(time / 2.4) % WANTED_AGENTS.length;
|
|
const active = WANTED_AGENTS[activeIndex];
|
|
ctx.clearRect(0, 0, width, height);
|
|
drawPanel(ctx, 18, 18, width - 36, height - 36, 30, 'rgba(2, 18, 32, 0.92)', '#22dfff');
|
|
drawGrid(ctx, width, height);
|
|
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.shadowColor = '#28dfff';
|
|
ctx.shadowBlur = 18;
|
|
ctx.fillStyle = '#6eeaff';
|
|
ctx.font = '900 64px Georgia, Times New Roman, serif';
|
|
fitText(ctx, route === 'contracts' ? 'Scoped Pricing Board' : 'Browse Agents', 88, 82, 650, 64, 'left');
|
|
ctx.shadowBlur = 8;
|
|
ctx.fillStyle = '#ffd67d';
|
|
ctx.font = '900 27px Inter, Arial, sans-serif';
|
|
fitText(ctx, 'WANTED LIST / SCOPED MERCENARIES', 92, 132, 700, 27, 'left');
|
|
|
|
const rows = WANTED_AGENTS.concat(WANTED_AGENTS.slice(0, 3));
|
|
rows.forEach((agent, index) => {
|
|
const y = 204 + index * 68 - scroll;
|
|
if (y < 154 || y > 500) return;
|
|
const selected = agent.name === active.name;
|
|
drawRound(ctx, 72, y - 26, 710, 48, 14);
|
|
ctx.fillStyle = selected ? 'rgba(45, 225, 255, .28)' : 'rgba(38, 209, 255, .13)';
|
|
ctx.fill();
|
|
ctx.strokeStyle = selected ? agent.tint : 'rgba(81, 230, 255, .36)';
|
|
ctx.lineWidth = selected ? 4 : 2;
|
|
ctx.stroke();
|
|
drawAgentIcon(ctx, 104, y - 2, agent, selected);
|
|
ctx.fillStyle = '#e7fbff';
|
|
ctx.font = '900 27px Georgia, Times New Roman, serif';
|
|
fitText(ctx, agent.name, 154, y - 10, 280, 27, 'left');
|
|
ctx.fillStyle = '#a7c9d1';
|
|
ctx.font = '800 18px Inter, Arial, sans-serif';
|
|
fitText(ctx, agent.role, 154, y + 16, 240, 18, 'left');
|
|
ctx.fillStyle = agent.tint;
|
|
fitText(ctx, agent.skill, 474, y - 8, 180, 18, 'left');
|
|
ctx.fillStyle = '#ffd46e';
|
|
ctx.font = '900 24px Inter, Arial, sans-serif';
|
|
fitText(ctx, agent.bounty, 668, y - 2, 92, 24, 'left');
|
|
});
|
|
|
|
ctx.save();
|
|
ctx.translate(910, 204);
|
|
drawPanel(ctx, 0, 0, 300, 236, 24, 'rgba(7, 24, 42, .82)', active.tint);
|
|
ctx.shadowColor = active.tint;
|
|
ctx.shadowBlur = 18;
|
|
drawAgentIcon(ctx, 68, 64, active, true, 48);
|
|
ctx.fillStyle = '#effcff';
|
|
ctx.font = '900 31px Georgia, Times New Roman, serif';
|
|
fitText(ctx, active.name, 30, 126, 240, 31, 'left');
|
|
ctx.fillStyle = '#bcd7df';
|
|
ctx.font = '800 19px Inter, Arial, sans-serif';
|
|
fitText(ctx, active.skill, 30, 164, 240, 19, 'left');
|
|
ctx.fillStyle = '#ffd46e';
|
|
ctx.font = '900 26px Inter, Arial, sans-serif';
|
|
fitText(ctx, `Bounty ${active.bounty}`, 30, 204, 240, 26, 'left');
|
|
ctx.restore();
|
|
|
|
for (let i = 0; i < 28; i += 1) {
|
|
const x = 526 + Math.cos(time * 1.8 + i * 0.71) * (44 + (i % 4) * 16);
|
|
const y = 330 + Math.sin(time * 2.2 + i * 0.47) * (26 + (i % 5) * 7);
|
|
ctx.fillStyle = i % 2 ? '#ffcd64' : '#f49f38';
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 8 + (i % 3), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = 'rgba(255,255,255,.35)';
|
|
ctx.stroke();
|
|
}
|
|
|
|
return { scroll, activeAgent: active.name };
|
|
});
|
|
}
|
|
|
|
function makeSpeechTexture(route) {
|
|
return makeAnimatedTexture(820, 560, (ctx, width, height, time) => {
|
|
const actions = SPEECH_STEPS[route] || SPEECH_STEPS.home;
|
|
const active = Math.floor(time * 1.4) % actions.length;
|
|
const pulse = 0.62 + Math.sin(time * 2.4) * 0.22;
|
|
ctx.clearRect(0, 0, width, height);
|
|
drawPanel(ctx, 18, 18, width - 62, height - 70, 28, 'rgba(18, 16, 47, .9)', '#aa74ff', pulse);
|
|
ctx.beginPath();
|
|
ctx.moveTo(width - 180, height - 70);
|
|
ctx.lineTo(width - 86, height - 24);
|
|
ctx.lineTo(width - 124, height - 98);
|
|
ctx.closePath();
|
|
ctx.fillStyle = 'rgba(18, 16, 47, .9)';
|
|
ctx.fill();
|
|
ctx.strokeStyle = `rgba(185, 128, 255, ${pulse})`;
|
|
ctx.lineWidth = 7;
|
|
ctx.stroke();
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.shadowColor = '#b784ff';
|
|
ctx.shadowBlur = 16;
|
|
ctx.fillStyle = '#decfff';
|
|
ctx.font = '900 34px Georgia, Times New Roman, serif';
|
|
fitText(ctx, 'Velvet Hex says', 56, 68, width - 130, 34, 'left');
|
|
ctx.shadowBlur = 6;
|
|
actions.forEach((action, index) => {
|
|
const y = 146 + index * 70;
|
|
const selected = index === active;
|
|
ctx.strokeStyle = selected ? '#42e6ff' : 'rgba(129, 220, 255, .36)';
|
|
ctx.lineWidth = selected ? 6 : 3;
|
|
ctx.beginPath();
|
|
ctx.arc(70, y, selected ? 14 : 11, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
if (selected) {
|
|
ctx.fillStyle = '#42e6ff';
|
|
ctx.beginPath();
|
|
ctx.arc(70, y, 5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.fillStyle = selected ? '#73f0ff' : '#d7e2ff';
|
|
ctx.font = selected ? '900 29px Inter, Arial, sans-serif' : '800 27px Inter, Arial, sans-serif';
|
|
fitText(ctx, action, 102, y, width - 180, selected ? 29 : 27, 'left');
|
|
});
|
|
return { speechActive: actions[active] };
|
|
});
|
|
}
|
|
|
|
function makeBoothTexture() {
|
|
return makeAnimatedTexture(920, 230, (ctx, width, height, time) => {
|
|
const pulse = 0.72 + Math.sin(time * 5.2) * 0.2 + (Math.sin(time * 23.0) > 0.92 ? 0.18 : 0);
|
|
ctx.clearRect(0, 0, width, height);
|
|
const gradient = ctx.createLinearGradient(0, 0, width, 0);
|
|
gradient.addColorStop(0, `rgba(20, 219, 255, ${0.08 + pulse * 0.08})`);
|
|
gradient.addColorStop(0.48, `rgba(20, 219, 255, ${0.32 + pulse * 0.18})`);
|
|
gradient.addColorStop(1, `rgba(20, 219, 255, ${0.08 + pulse * 0.08})`);
|
|
drawRound(ctx, 30, 42, width - 60, height - 84, 30);
|
|
ctx.fillStyle = 'rgba(3, 17, 25, .24)';
|
|
ctx.fill();
|
|
ctx.strokeStyle = `rgba(54, 231, 255, ${pulse})`;
|
|
ctx.lineWidth = 13;
|
|
ctx.shadowColor = '#20ddff';
|
|
ctx.shadowBlur = 30;
|
|
ctx.stroke();
|
|
ctx.fillStyle = gradient;
|
|
ctx.fill();
|
|
ctx.shadowBlur = 26;
|
|
ctx.fillStyle = '#d8fbff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.font = '900 72px Georgia, Times New Roman, serif';
|
|
fitText(ctx, 'Enter My Booth', width / 2, height / 2 - 2, width - 145, 72);
|
|
ctx.shadowBlur = 10;
|
|
ctx.fillStyle = 'rgba(255, 230, 175, .85)';
|
|
ctx.font = '900 22px Inter, Arial, sans-serif';
|
|
fitText(ctx, 'private agent command room', width / 2, 164, width - 220, 22);
|
|
const sweepX = 70 + ((time * 180) % (width - 140));
|
|
ctx.fillStyle = 'rgba(255,255,255,.45)';
|
|
ctx.fillRect(sweepX, 58, 8, 112);
|
|
return { neonPulse: pulse };
|
|
});
|
|
}
|
|
|
|
function animateGemhallRig(built, time) {
|
|
built.root.traverse((object) => {
|
|
if (object.userData?.animate) object.rotation.y = time * 0.04;
|
|
if (object.userData?.seed !== undefined) {
|
|
object.position.y += Math.sin(time * 0.9 + object.userData.seed) * 0.0008;
|
|
}
|
|
});
|
|
|
|
const orc = built.orc.userData;
|
|
built.orc.position.y = orc.baseY + Math.sin(time * 0.95) * 0.028;
|
|
built.orc.rotation.y = orc.baseRotY + Math.sin(time * 0.62) * 0.04;
|
|
orc.head.rotation.y = Math.sin(time * 0.88) * 0.12;
|
|
orc.head.rotation.x = Math.sin(time * 0.7 + 1.5) * 0.035;
|
|
orc.torso.scale.y = 1 + Math.sin(time * 1.3) * 0.018;
|
|
orc.chest.position.y = 1.25 + Math.sin(time * 1.3) * 0.012;
|
|
orc.armL.rotation.z = -0.58 + Math.sin(time * 1.2) * 0.12;
|
|
orc.armR.rotation.z = 0.6 + Math.sin(time * 2.2) * 0.16;
|
|
orc.armR.rotation.x = Math.sin(time * 1.7) * 0.1;
|
|
orc.mug.position.y = 1.42 + Math.sin(time * 2.2 + 0.5) * 0.08;
|
|
orc.mug.rotation.x = Math.sin(time * 1.8) * 0.13;
|
|
orc.mugGlow.position.y = orc.mug.position.y + 0.2;
|
|
const blink = Math.sin(time * 4.6) > 0.94 ? 0.28 : 1;
|
|
orc.eyeL.scale.y = blink;
|
|
orc.eyeR.scale.y = blink;
|
|
const coilPulse = 1 + Math.sin(time * 3.5) * 0.09;
|
|
orc.coil.scale.set(coilPulse, coilPulse, coilPulse);
|
|
orc.badge.rotation.y = time * 1.6;
|
|
|
|
const elf = built.elf.userData;
|
|
built.elf.position.y = elf.baseY + Math.sin(time * 1.05 + 0.8) * 0.045;
|
|
built.elf.rotation.y = elf.baseRotY + Math.sin(time * 0.7) * 0.06;
|
|
elf.head.rotation.z = Math.sin(time * 1.15) * 0.065;
|
|
elf.hairCap.rotation.z = 0.1 + Math.sin(time * 1.2) * 0.05;
|
|
elf.hairTail.rotation.z = Math.sin(time * 1.45) * 0.08;
|
|
elf.cape.rotation.z = Math.sin(time * 0.92) * 0.04;
|
|
elf.armL.rotation.z = -0.88 + Math.sin(time * 1.8) * 0.12;
|
|
elf.armR.rotation.z = 0.42 + Math.sin(time * 1.55 + 0.8) * 0.18;
|
|
elf.staff.rotation.z = -0.16 + Math.sin(time * 1.35) * 0.1;
|
|
elf.gem.rotation.y = time * 2.2;
|
|
elf.gem.position.y = 1.75 + Math.sin(time * 2.3) * 0.07;
|
|
elf.halo.rotation.z = time * 1.4;
|
|
built.elfRune.rotation.z = time * 0.58;
|
|
const runeScale = 1 + Math.sin(time * 2.2) * 0.055;
|
|
built.elfRune.scale.set(runeScale, runeScale, runeScale);
|
|
|
|
const dwarf = built.dwarf.userData;
|
|
built.dwarf.position.y = dwarf.baseY + Math.sin(time * 1.1 + 2.1) * 0.025;
|
|
built.dwarf.rotation.y = dwarf.baseRotY + Math.sin(time * 0.8) * 0.035;
|
|
dwarf.head.rotation.x = Math.sin(time * 1.35) * 0.055;
|
|
dwarf.beard.rotation.z = Math.sin(time * 1.9) * 0.04;
|
|
dwarf.armL.rotation.z = -0.72 + Math.sin(time * 2.1) * 0.12;
|
|
dwarf.mug.position.y = 1.02 + Math.sin(time * 2.1) * 0.075;
|
|
const tap = Math.max(0, Math.sin(time * 2.7));
|
|
dwarf.armR.rotation.z = 0.55 + tap * 0.16;
|
|
dwarf.hammerHandle.rotation.z = -0.65 + tap * 0.18;
|
|
dwarf.hammerHead.position.y = 1.17 - tap * 0.055;
|
|
const corePulse = 1 + Math.sin(time * 3.2) * 0.08;
|
|
dwarf.core.scale.set(corePulse, corePulse, corePulse);
|
|
|
|
const ringScale = 1 + Math.sin(time * 1.9) * 0.045;
|
|
built.boothRing.scale.set(ringScale, ringScale, ringScale);
|
|
built.boothRing.rotation.z = time * 0.22;
|
|
}
|
|
|
|
function placeDesktop(built) {
|
|
placePlane(built.title, 5.8, 0.92, 0, 1.88, -1.5, 0);
|
|
placeScreen(built, 4.65, 1.96, -0.2, 0.62, -2.12, 0.02);
|
|
placePlane(built.boothSign, 3.28, 0.82, 0, -1.31, 0.05, 0);
|
|
placePlane(built.speech, 1.95, 1.33, 3.22, 0.56, 0.42, -0.23);
|
|
placeGroup(built.orc, -0.08, -2.0, -0.96, 1.04, 0.02);
|
|
placeGroup(built.elf, 2.32, -2.08, 0.5, 1.04, -0.28);
|
|
placeGroup(built.dwarf, -2.85, -2.08, 0.68, 0.92, 0.42);
|
|
placeFlat(built.boothRing, 0, -2.12, 1.02, 1.05, 0);
|
|
placeFlat(built.elfRune, 2.32, -2.07, 0.78, 0.95, 0);
|
|
}
|
|
|
|
function placeTablet(built) {
|
|
placePlane(built.title, 4.55, 0.78, 0, 1.92, -1.5, 0);
|
|
placeScreen(built, 4.0, 1.68, -0.12, 0.7, -2.08, 0);
|
|
placePlane(built.boothSign, 2.85, 0.7, -0.15, -1.38, 0.05, 0);
|
|
placePlane(built.speech, 1.7, 1.16, 1.95, 0.12, 0.6, -0.16);
|
|
placeGroup(built.orc, -0.18, -2.0, -0.94, 0.96, 0);
|
|
placeGroup(built.elf, 1.48, -2.14, 0.62, 0.92, -0.22);
|
|
placeGroup(built.dwarf, -2.05, -2.16, 0.78, 0.8, 0.35);
|
|
placeFlat(built.boothRing, -0.12, -2.12, 1.02, 0.92, 0);
|
|
placeFlat(built.elfRune, 1.5, -2.1, 0.84, 0.84, 0);
|
|
}
|
|
|
|
function placeMobile(built) {
|
|
placePlane(built.title, 3.35, 0.64, 0, 2.08, -1.36, 0);
|
|
placeScreen(built, 3.4, 1.44, 0, 1.04, -2.08, 0);
|
|
placePlane(built.boothSign, 2.15, 0.54, -0.5, -1.5, 0.08, 0);
|
|
placePlane(built.speech, 1.28, 0.88, 1.05, -0.58, 0.88, -0.08);
|
|
placeGroup(built.orc, -0.06, -2.02, -0.84, 0.88, 0);
|
|
placeGroup(built.elf, 0.92, -2.2, 0.82, 0.76, -0.16);
|
|
placeGroup(built.dwarf, -1.28, -2.26, 0.9, 0.58, 0.28);
|
|
placeFlat(built.boothRing, -0.54, -2.14, 1.05, 0.72, 0);
|
|
placeFlat(built.elfRune, 0.92, -2.18, 0.96, 0.66, 0);
|
|
}
|
|
|
|
function placeScreen(built, width, height, x, y, z, rotationY = 0) {
|
|
placePlane(built.dashboard, width, height, x, y, z, rotationY);
|
|
built.dashboardFrame.scale.set(width, height, 1);
|
|
built.dashboardFrame.position.set(x, y, z + 0.04);
|
|
built.dashboardFrame.rotation.set(0, rotationY, 0);
|
|
built.dashboardFrame.userData.baseScale = built.dashboardFrame.scale.clone();
|
|
}
|
|
|
|
function placePlane(mesh, width, height, x, y, z, rotationY = 0) {
|
|
mesh.scale.set(width, height, 1);
|
|
mesh.position.set(x, y, z);
|
|
mesh.rotation.set(0, rotationY, 0);
|
|
mesh.userData.baseScale = mesh.scale.clone();
|
|
}
|
|
|
|
function placeGroup(group, x, y, z, scale, rotationY = 0) {
|
|
group.position.set(x, y, z);
|
|
group.scale.setScalar(scale);
|
|
group.rotation.y = rotationY;
|
|
group.userData.baseY = y;
|
|
group.userData.baseRotY = rotationY;
|
|
group.userData.baseScale = scale;
|
|
}
|
|
|
|
function placeFlat(group, x, y, z, scale, rotationY = 0) {
|
|
group.position.set(x, y, z);
|
|
group.scale.setScalar(scale);
|
|
group.rotation.set(-Math.PI / 2, rotationY, 0);
|
|
group.userData.baseScale = new THREE.Vector3(scale, scale, scale);
|
|
}
|
|
|
|
function makeCanvasPlane(texture, { renderOrder = 1, route = '', depthTest = false } = {}) {
|
|
const mesh = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(1, 1),
|
|
new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthTest,
|
|
depthWrite: false,
|
|
side: THREE.DoubleSide,
|
|
}),
|
|
);
|
|
mesh.renderOrder = renderOrder;
|
|
mesh.userData.route = route;
|
|
return mesh;
|
|
}
|
|
|
|
function makeAnimatedTexture(width, height, draw) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
prepareTexture(texture);
|
|
texture.userData = { canvas, ctx, dynamic: true, frame: -1, scroll: 0, activeAgent: WANTED_AGENTS[0].name };
|
|
|
|
const update = (time = 0) => {
|
|
const frame = Math.floor(time * 18);
|
|
if (frame === texture.userData.frame) return;
|
|
texture.userData.frame = frame;
|
|
const state = draw(ctx, width, height, time) || {};
|
|
Object.assign(texture.userData, state);
|
|
texture.needsUpdate = true;
|
|
};
|
|
|
|
update(0);
|
|
return { texture, update };
|
|
}
|
|
|
|
function drawPanel(ctx, x, y, width, height, radius, fill, tint, pulse = 0.86) {
|
|
drawRound(ctx, x, y, width, height, radius);
|
|
ctx.fillStyle = fill;
|
|
ctx.fill();
|
|
ctx.lineWidth = 7;
|
|
ctx.strokeStyle = tint;
|
|
ctx.shadowColor = tint;
|
|
ctx.shadowBlur = 24 * pulse;
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeStyle = 'rgba(255,255,255,.22)';
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawGrid(ctx, width, height) {
|
|
ctx.fillStyle = 'rgba(61, 216, 255, .08)';
|
|
for (let y = 70; y < height - 40; y += 42) ctx.fillRect(52, y, width - 104, 2);
|
|
for (let x = 74; x < width - 60; x += 72) ctx.fillRect(x, 64, 2, height - 118);
|
|
}
|
|
|
|
function drawAgentIcon(ctx, x, y, agent, selected, radius = 28) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.beginPath();
|
|
for (let i = 0; i < 6; i += 1) {
|
|
const angle = Math.PI / 6 + (i * Math.PI * 2) / 6;
|
|
const px = Math.cos(angle) * radius;
|
|
const py = Math.sin(angle) * radius;
|
|
if (i === 0) ctx.moveTo(px, py);
|
|
else ctx.lineTo(px, py);
|
|
}
|
|
ctx.closePath();
|
|
ctx.fillStyle = selected ? 'rgba(10, 30, 42, .96)' : 'rgba(8, 25, 38, .88)';
|
|
ctx.fill();
|
|
ctx.strokeStyle = agent.tint;
|
|
ctx.lineWidth = selected ? 5 : 3;
|
|
ctx.stroke();
|
|
ctx.fillStyle = agent.tint;
|
|
ctx.font = `900 ${Math.floor(radius * 0.55)}px Inter, Arial, sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(agent.initials, 0, 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawRound(ctx, x, y, width, height, radius) {
|
|
const r = Math.min(radius, width / 2, height / 2);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + r, y);
|
|
ctx.arcTo(x + width, y, x + width, y + height, r);
|
|
ctx.arcTo(x + width, y + height, x, y + height, r);
|
|
ctx.arcTo(x, y + height, x, y, r);
|
|
ctx.arcTo(x, y, x + width, y, r);
|
|
ctx.closePath();
|
|
}
|
|
|
|
function fitText(ctx, text, x, y, maxWidth, baseSize, align = 'center') {
|
|
const font = ctx.font;
|
|
const match = font.match(/(\d+)px/);
|
|
const originalSize = match ? Number(match[1]) : baseSize;
|
|
let size = originalSize;
|
|
while (ctx.measureText(text).width > maxWidth && size > 12) {
|
|
size -= 2;
|
|
ctx.font = font.replace(`${originalSize}px`, `${size}px`);
|
|
}
|
|
ctx.textAlign = align;
|
|
ctx.fillText(text, x, y);
|
|
ctx.font = font;
|
|
}
|
|
|
|
function mat(color, options = {}) {
|
|
return new THREE.MeshStandardMaterial({
|
|
color,
|
|
roughness: options.roughness ?? 0.58,
|
|
metalness: options.metalness ?? 0.08,
|
|
emissive: options.emissive ?? 0x000000,
|
|
emissiveIntensity: options.emissiveIntensity ?? 0,
|
|
transparent: options.transparent ?? false,
|
|
opacity: options.opacity ?? 1,
|
|
flatShading: true,
|
|
});
|
|
}
|
|
|
|
function woodMaterial(base, grain, dark) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 256;
|
|
canvas.height = 256;
|
|
const ctx = canvas.getContext('2d');
|
|
const gradient = ctx.createLinearGradient(0, 0, 0, 256);
|
|
gradient.addColorStop(0, grain);
|
|
gradient.addColorStop(0.42, base);
|
|
gradient.addColorStop(1, dark);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, 256, 256);
|
|
for (let y = 8; y < 256; y += 18) {
|
|
ctx.strokeStyle = 'rgba(255, 218, 148, .18)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
for (let x = 0; x <= 256; x += 16) ctx.lineTo(x, y + Math.sin(x * 0.05 + y) * 4);
|
|
ctx.stroke();
|
|
}
|
|
for (let i = 0; i < 7; i += 1) {
|
|
const x = (i * 47 + 28) % 240;
|
|
const y = (i * 61 + 44) % 240;
|
|
ctx.strokeStyle = 'rgba(31, 9, 3, .35)';
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.ellipse(x, y, 16 + (i % 3) * 5, 7 + (i % 2) * 4, i * 0.2, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
prepareTexture(texture);
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
texture.repeat.set(2.2, 1.1);
|
|
return new THREE.MeshStandardMaterial({ map: texture, color: 0xffffff, roughness: 0.78, metalness: 0.04, flatShading: true });
|
|
}
|
|
|
|
function box(width, height, depth, material) {
|
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), material);
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
return mesh;
|
|
}
|
|
|
|
function cylinder(radiusTop, radiusBottom, height, material, radialSegments = 8) {
|
|
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radiusTop, radiusBottom, height, radialSegments, 1, false), material);
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
return mesh;
|
|
}
|
|
|
|
function cone(radius, height, material, radialSegments = 6) {
|
|
const mesh = new THREE.Mesh(new THREE.ConeGeometry(radius, height, radialSegments, 1), material);
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
return mesh;
|
|
}
|
|
|
|
function sphere(radius, material, widthSegments = 8, heightSegments = 6) {
|
|
const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, widthSegments, heightSegments), material);
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
return mesh;
|
|
}
|
|
|
|
function prepareTexture(texture) {
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.anisotropy = 8;
|
|
texture.needsUpdate = true;
|
|
}
|
|
|
|
function markShadows(object) {
|
|
object.traverse((child) => {
|
|
if (child.isMesh) {
|
|
child.castShadow = true;
|
|
child.receiveShadow = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
function countMeshes(object) {
|
|
let count = 0;
|
|
object.traverse((child) => {
|
|
if (child.isMesh) count += 1;
|
|
});
|
|
return count;
|
|
}
|
|
|
|
function publishStageDebug(metrics, tracked, camera) {
|
|
if (typeof window === 'undefined') return;
|
|
const bounds = {};
|
|
let allVisible = true;
|
|
Object.entries(tracked).forEach(([name, object]) => {
|
|
const boxBounds = projectBounds(object, camera);
|
|
bounds[name] = boxBounds;
|
|
if (['dashboard', 'booth', 'speech', 'orc', 'elf', 'dwarf'].includes(name)) {
|
|
const visible =
|
|
boxBounds.right > -1.08 &&
|
|
boxBounds.left < 1.08 &&
|
|
boxBounds.bottom > -1.1 &&
|
|
boxBounds.top < 1.1 &&
|
|
boxBounds.width > 0.02 &&
|
|
boxBounds.height > 0.02;
|
|
allVisible = allVisible && visible;
|
|
}
|
|
});
|
|
|
|
window.__AMERC_STAGE = {
|
|
version: RELEASE,
|
|
release: RELEASE,
|
|
fallback: false,
|
|
lowPoly: true,
|
|
draggable: true,
|
|
allVisible,
|
|
bounds,
|
|
...metrics,
|
|
};
|
|
}
|
|
|
|
function projectBounds(object, camera) {
|
|
object.updateWorldMatrix(true, true);
|
|
const box3 = new THREE.Box3().setFromObject(object);
|
|
if (!Number.isFinite(box3.min.x) || !Number.isFinite(box3.max.x)) return { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
|
|
const points = [
|
|
new THREE.Vector3(box3.min.x, box3.min.y, box3.min.z),
|
|
new THREE.Vector3(box3.min.x, box3.min.y, box3.max.z),
|
|
new THREE.Vector3(box3.min.x, box3.max.y, box3.min.z),
|
|
new THREE.Vector3(box3.min.x, box3.max.y, box3.max.z),
|
|
new THREE.Vector3(box3.max.x, box3.min.y, box3.min.z),
|
|
new THREE.Vector3(box3.max.x, box3.min.y, box3.max.z),
|
|
new THREE.Vector3(box3.max.x, box3.max.y, box3.min.z),
|
|
new THREE.Vector3(box3.max.x, box3.max.y, box3.max.z),
|
|
].map((point) => point.project(camera));
|
|
const xs = points.map((point) => point.x);
|
|
const ys = points.map((point) => point.y);
|
|
const left = Math.min(...xs);
|
|
const right = Math.max(...xs);
|
|
const bottom = Math.min(...ys);
|
|
const top = Math.max(...ys);
|
|
return { left: round(left), right: round(right), top: round(top), bottom: round(bottom), width: round(right - left), height: round(top - bottom) };
|
|
}
|
|
|
|
function disposeScene(scene) {
|
|
scene.traverse((object) => {
|
|
if (!object.isMesh) return;
|
|
object.geometry?.dispose?.();
|
|
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
|
materials.forEach((material) => {
|
|
material?.map?.dispose?.();
|
|
material?.dispose?.();
|
|
});
|
|
});
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function round(value) {
|
|
return Math.round(value * 1000) / 1000;
|
|
}
|