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 (
amerc agent mercenary tavern
);
}
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) => (
))}
>
);
return (
{open && {nav}
}
);
}
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 (
);
}
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;
}