[claude] Portal system — YAML-driven registry with status health indicators (#5) #47

Closed
claude wants to merge 2 commits from claude/the-nexus:claude/issue-5 into main
4 changed files with 460 additions and 38 deletions

302
app.js
View File

@@ -27,8 +27,13 @@ let camera, scene, renderer, composer;
let clock, playerPos, playerRot;
let keys = {};
let mouseDown = false;
let mouseDragged = false;
let batcaveTerminals = [];
let portalMesh, portalGlow;
let portalRegistry = []; // [{config, group, swirlMat, ringMesh, particles}]
let nearbyPortal = null;
let portalActivating = false;
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let particles, dustParticles;
let debugOverlay;
let frameCount = 0, lastFPSTime = 0, fps = 0;
@@ -36,7 +41,7 @@ let chatOpen = true;
let loadProgress = 0;
// ═══ INIT ═══
function init() {
async function init() {
clock = new THREE.Clock();
playerPos = new THREE.Vector3(0, 2, 12);
playerRot = new THREE.Euler(0, 0, 0, 'YXZ');
@@ -71,7 +76,7 @@ function init() {
updateLoad(55);
createBatcaveTerminal();
updateLoad(70);
createPortal();
await loadPortals();
updateLoad(80);
createParticles();
createDustParticles();
@@ -526,32 +531,70 @@ function createHoloPanel(parent, opts) {
batcaveTerminals.push({ group, scanMat, borderMat });
}
// ═══ PORTAL ═══
function createPortal() {
const portalGroup = new THREE.Group();
portalGroup.position.set(15, 0, -10);
portalGroup.rotation.y = -0.5;
// ═══ PORTAL REGISTRY ═══
async function loadPortals() {
let configs;
try {
const res = await fetch('./portals.json');
const data = await res.json();
configs = data.portals;
} catch (e) {
console.warn('portals.json not found, using defaults', e);
configs = [
{
id: 'morrowind', label: '◈ MORROWIND',
description: 'The Elder Scrolls III — Vvardenfell awaits',
position: [15, 0, -10], rotationY: -0.5,
ringColor: '#ff6600', ringEmissive: '#ff4400',
swirlInner: [1.0, 0.3, 0.0], swirlOuter: [1.0, 0.8, 0.3],
particleColor: '#ff8844',
destination: { url: null, type: 'world', harness: null, params: {} },
},
];
}
configs.forEach(cfg => createPortalMesh(cfg));
}
// Portal ring
function createPortalMesh(cfg) {
const ringCol = new THREE.Color(cfg.ringColor);
const ringEmit = new THREE.Color(cfg.ringEmissive);
const pCol = new THREE.Color(cfg.particleColor);
const isOnline = !cfg.status || cfg.status === 'online';
const emissiveStrength = isOnline ? 1.5 : 0.3;
const portalGroup = new THREE.Group();
portalGroup.position.set(...cfg.position);
portalGroup.rotation.y = cfg.rotationY;
portalGroup.userData.portalId = cfg.id;
// Portal ring (torus)
const torusGeo = new THREE.TorusGeometry(3, 0.15, 16, 64);
const torusMat = new THREE.MeshStandardMaterial({
color: 0xff6600,
emissive: 0xff4400,
emissiveIntensity: 1.5,
color: ringCol,
emissive: ringEmit,
emissiveIntensity: emissiveStrength,
roughness: 0.2,
metalness: 0.8,
});
portalMesh = new THREE.Mesh(torusGeo, torusMat);
portalMesh.position.y = 3.5;
portalGroup.add(portalMesh);
const ringMesh = new THREE.Mesh(torusGeo, torusMat);
ringMesh.position.y = 3.5;
ringMesh.userData.isPortal = true;
ringMesh.userData.portalId = cfg.id;
portalGroup.add(ringMesh);
// Inner swirl
// Inner swirl disc
const swirlGeo = new THREE.CircleGeometry(2.8, 64);
const si = cfg.swirlInner || [1, 0.3, 0];
const so = cfg.swirlOuter || [1, 0.8, 0.3];
const swirlMat = new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
uniforms: {
uTime: { value: 0 },
uInner: { value: new THREE.Vector3(si[0], si[1], si[2]) },
uOuter: { value: new THREE.Vector3(so[0], so[1], so[2]) },
},
vertexShader: `
varying vec2 vUv;
@@ -562,49 +605,51 @@ function createPortal() {
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uInner;
uniform vec3 uOuter;
varying vec2 vUv;
void main() {
vec2 c = vUv - 0.5;
float r = length(c);
float a = atan(c.y, c.x);
float swirl = sin(a * 3.0 + r * 10.0 - uTime * 3.0) * 0.5 + 0.5;
float swirl2 = sin(a * 5.0 - r * 8.0 + uTime * 2.0) * 0.5 + 0.5;
float swirl = sin(a * 3.0 + r * 10.0 - uTime * 3.0) * 0.5 + 0.5;
float swirl2 = sin(a * 5.0 - r * 8.0 + uTime * 2.0) * 0.5 + 0.5;
float mask = smoothstep(0.5, 0.1, r);
vec3 col = mix(vec3(1.0, 0.3, 0.0), vec3(1.0, 0.6, 0.1), swirl);
col = mix(col, vec3(1.0, 0.8, 0.3), swirl2 * 0.3);
vec3 col = mix(uInner, uOuter, swirl);
col = mix(col, uOuter, swirl2 * 0.3);
float alpha = mask * (0.5 + 0.3 * swirl);
gl_FragColor = vec4(col, alpha);
}
`,
});
portalGlow = new THREE.Mesh(swirlGeo, swirlMat);
portalGlow.position.y = 3.5;
portalGroup.add(portalGlow);
const swirlMesh = new THREE.Mesh(swirlGeo, swirlMat);
swirlMesh.position.y = 3.5;
portalGroup.add(swirlMesh);
// Label
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 512;
labelCanvas.height = 64;
const lctx = labelCanvas.getContext('2d');
lctx.font = 'bold 32px "Orbitron", sans-serif';
lctx.fillStyle = '#ff8844';
lctx.font = 'bold 30px "Orbitron", sans-serif';
lctx.fillStyle = cfg.ringColor;
lctx.textAlign = 'center';
lctx.fillText('◈ MORROWIND', 256, 42);
lctx.fillText(cfg.label, 256, 42);
const labelTex = new THREE.CanvasTexture(labelCanvas);
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, side: THREE.DoubleSide });
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.5), labelMat);
labelMesh.position.y = 7;
portalGroup.add(labelMesh);
// Base pillars
for (let side of [-1, 1]) {
// Pillars
for (const side of [-1, 1]) {
const pillarGeo = new THREE.CylinderGeometry(0.2, 0.3, 7, 8);
const pillarMat = new THREE.MeshStandardMaterial({
color: 0x1a1a2e,
roughness: 0.5,
metalness: 0.7,
emissive: 0xff4400,
emissiveIntensity: 0.1,
emissive: ringEmit,
emissiveIntensity: 0.15,
});
const pillar = new THREE.Mesh(pillarGeo, pillarMat);
pillar.position.set(side * 3, 3.5, 0);
@@ -612,7 +657,82 @@ function createPortal() {
portalGroup.add(pillar);
}
// Portal-specific orbital particles
const pCount = 120;
const pGeo = new THREE.BufferGeometry();
const pPos = new Float32Array(pCount * 3);
const pCol2 = new Float32Array(pCount * 3);
const pSizes = new Float32Array(pCount);
for (let i = 0; i < pCount; i++) {
const angle = (i / pCount) * Math.PI * 2;
const spread = (Math.random() - 0.5) * 0.8;
const radius = 3.2 + spread;
pPos[i * 3] = Math.cos(angle) * radius;
pPos[i * 3 + 1] = 3.5 + (Math.random() - 0.5) * 0.6;
pPos[i * 3 + 2] = Math.sin(angle) * radius;
pCol2[i * 3] = pCol.r;
pCol2[i * 3 + 1] = pCol.g;
pCol2[i * 3 + 2] = pCol.b;
pSizes[i] = 0.015 + Math.random() * 0.04;
}
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
pGeo.setAttribute('color', new THREE.BufferAttribute(pCol2, 3));
pGeo.setAttribute('size', new THREE.BufferAttribute(pSizes, 1));
const pMat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 }, uOffset: { value: Math.random() * 100 } },
vertexShader: `
attribute float size;
attribute vec3 color;
varying vec3 vColor;
uniform float uTime;
uniform float uOffset;
void main() {
vColor = color;
vec3 pos = position;
float a = atan(pos.z, pos.x) + uTime * 0.8 + uOffset;
float r = length(vec2(pos.x, pos.z));
pos.x = cos(a) * r;
pos.z = sin(a) * r;
pos.y += sin(uTime * 2.0 + a * 3.0) * 0.15;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = size * 300.0 / -mv.z;
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: `
varying vec3 vColor;
void main() {
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard;
float alpha = smoothstep(0.5, 0.05, d);
gl_FragColor = vec4(vColor, alpha * 0.9);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexColors: true,
});
const portalParticles = new THREE.Points(pGeo, pMat);
portalGroup.add(portalParticles);
// Point light inside portal
const pLight = new THREE.PointLight(ringCol, 2.5, 18, 1.5);
pLight.position.set(0, 3.5, 0);
portalGroup.add(pLight);
scene.add(portalGroup);
portalRegistry.push({
config: cfg,
group: portalGroup,
swirlMat,
ringMesh,
portalParticles,
pMat,
pLight,
});
}
// ═══ PARTICLES ═══
@@ -791,6 +911,9 @@ function createAmbientStructures() {
function setupControls() {
document.addEventListener('keydown', (e) => {
keys[e.key.toLowerCase()] = true;
if (e.key.toLowerCase() === 'f' && nearbyPortal && !portalActivating) {
activatePortal(nearbyPortal);
}
if (e.key === 'Enter') {
e.preventDefault();
const input = document.getElementById('chat-input');
@@ -811,17 +934,33 @@ function setupControls() {
// Mouse look
const canvas = document.getElementById('nexus-canvas');
canvas.addEventListener('mousedown', (e) => {
if (e.target === canvas) mouseDown = true;
if (e.target === canvas) { mouseDown = true; mouseDragged = false; }
});
document.addEventListener('mouseup', () => { mouseDown = false; });
document.addEventListener('mousemove', (e) => {
if (!mouseDown) return;
if (document.activeElement === document.getElementById('chat-input')) return;
if (Math.abs(e.movementX) > 1 || Math.abs(e.movementY) > 1) mouseDragged = true;
playerRot.y -= e.movementX * 0.003;
playerRot.x -= e.movementY * 0.003;
playerRot.x = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, playerRot.x));
});
// Portal click (raycast against portal ring meshes)
canvas.addEventListener('click', (e) => {
if (portalActivating || mouseDragged) return;
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const portalMeshes = portalRegistry.map(p => p.ringMesh);
const hits = raycaster.intersectObjects(portalMeshes, false);
if (hits.length > 0) {
const hitId = hits[0].object.userData.portalId;
const entry = portalRegistry.find(p => p.config.id === hitId);
if (entry) activatePortal(entry);
}
});
// Chat toggle
document.getElementById('chat-toggle').addEventListener('click', () => {
chatOpen = !chatOpen;
@@ -872,6 +1011,80 @@ function addChatMessage(type, text) {
container.scrollTop = container.scrollHeight;
}
// ═══ PORTAL ACTIVATION ═══
function updatePortalProximityHUD(entry) {
const hint = document.getElementById('portal-hint');
if (!hint) return;
if (entry) {
hint.style.display = 'block';
hint.innerHTML = `<span class="portal-hint-name">${entry.config.label}</span><br><span class="portal-hint-key">[F]</span> Enter portal &nbsp; <span class="portal-hint-key">[Click]</span> Activate`;
hint.style.borderColor = entry.config.ringColor;
hint.style.color = entry.config.ringColor;
} else {
hint.style.display = 'none';
}
}
function activatePortal(entry) {
if (portalActivating) return;
portalActivating = true;
const overlay = document.getElementById('portal-overlay');
const name = document.getElementById('portal-overlay-name');
const desc = document.getElementById('portal-overlay-desc');
const status = document.getElementById('portal-overlay-status');
const btn = document.getElementById('portal-overlay-close');
name.textContent = entry.config.label;
name.style.color = entry.config.ringColor;
desc.textContent = entry.config.description;
overlay.style.setProperty('--portal-color', entry.config.ringColor);
overlay.style.display = 'flex';
overlay.classList.remove('fade-out');
const portalStatus = entry.config.status || 'online';
const statusColors = { online: '#4af0c0', offline: '#ff4466', maintenance: '#ffd700' };
const statusColor = statusColors[portalStatus] || entry.config.ringColor;
status.style.color = statusColor;
if (portalStatus === 'offline') {
status.textContent = '● OFFLINE — destination unreachable';
return;
}
if (portalStatus === 'maintenance') {
status.textContent = '● MAINTENANCE — portal temporarily closed';
return;
}
const url = entry.config.destination?.url;
if (url) {
status.textContent = '● ONLINE';
let countdown = 3;
status.textContent = `● ONLINE — Opening portal in ${countdown}s…`;
const iv = setInterval(() => {
countdown--;
if (countdown <= 0) {
clearInterval(iv);
status.textContent = '● ONLINE — Entering…';
window.location.href = url;
} else {
status.textContent = `● ONLINE — Opening portal in ${countdown}s…`;
}
}, 1000);
btn.dataset.intervalId = iv;
} else {
status.textContent = '● ONLINE — destination not yet linked';
}
btn.onclick = () => {
if (btn.dataset.intervalId) clearInterval(Number(btn.dataset.intervalId));
overlay.classList.add('fade-out');
setTimeout(() => { overlay.style.display = 'none'; portalActivating = false; }, 400);
};
addChatMessage('system', `Portal ${entry.config.label} activated.`);
}
// ═══ GAME LOOP ═══
function gameLoop() {
requestAnimationFrame(gameLoop);
@@ -912,13 +1125,28 @@ function gameLoop() {
if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed;
});
// Animate portal
if (portalMesh) {
portalMesh.rotation.z = elapsed * 0.3;
portalMesh.rotation.x = Math.sin(elapsed * 0.5) * 0.1;
// Animate portals from registry
let foundNearby = null;
for (const entry of portalRegistry) {
const { group, swirlMat, ringMesh, pMat, pLight, config } = entry;
ringMesh.rotation.z = elapsed * 0.3;
ringMesh.rotation.x = Math.sin(elapsed * 0.5 + group.userData.portalId.length) * 0.1;
if (swirlMat?.uniforms) swirlMat.uniforms.uTime.value = elapsed;
if (pMat?.uniforms) pMat.uniforms.uTime.value = elapsed;
// Pulse the light
if (pLight) pLight.intensity = 2.0 + Math.sin(elapsed * 2.5 + group.userData.portalId.length) * 0.6;
// Proximity check (horizontal distance to portal base)
const dx = playerPos.x - group.position.x;
const dz = playerPos.z - group.position.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < 4.5) foundNearby = entry;
}
if (portalGlow?.material?.uniforms) {
portalGlow.material.uniforms.uTime.value = elapsed;
// Update nearby portal UI
if (foundNearby !== nearbyPortal) {
nearbyPortal = foundNearby;
updatePortalProximityHUD(nearbyPortal);
}
// Animate particles

View File

@@ -95,9 +95,23 @@
</div>
</div>
<!-- Portal proximity hint -->
<div id="portal-hint" class="portal-hint" style="display:none;"></div>
<!-- Minimap / Controls hint -->
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp; <span>F</span> portal
</div>
</div>
<!-- Portal Activation Overlay -->
<div id="portal-overlay" class="portal-overlay" style="display:none;">
<div class="portal-overlay-frame">
<div class="portal-overlay-sigil"></div>
<h2 id="portal-overlay-name" class="portal-overlay-name"></h2>
<p id="portal-overlay-desc" class="portal-overlay-desc"></p>
<p id="portal-overlay-status" class="portal-overlay-status"></p>
<button id="portal-overlay-close" class="portal-overlay-close">✕ Close Portal</button>
</div>
</div>

61
portals.json Normal file
View File

@@ -0,0 +1,61 @@
{
"portals": [
{
"id": "morrowind",
"label": "◈ MORROWIND",
"description": "The Elder Scrolls III — Vvardenfell awaits",
"status": "online",
"position": [15, 0, -10],
"rotationY": -0.5,
"ringColor": "#ff6600",
"ringEmissive": "#ff4400",
"swirlInner": [1.0, 0.3, 0.0],
"swirlOuter": [1.0, 0.8, 0.3],
"particleColor": "#ff8844",
"destination": {
"url": null,
"type": "world",
"harness": null,
"params": {}
}
},
{
"id": "bannerlord",
"label": "⚔ BANNERLORD",
"description": "Mount & Blade II — The Calradic Empire calls",
"status": "online",
"position": [-15, 0, -10],
"rotationY": 0.5,
"ringColor": "#cc7700",
"ringEmissive": "#aa5500",
"swirlInner": [0.8, 0.45, 0.0],
"swirlOuter": [1.0, 0.85, 0.2],
"particleColor": "#ffaa22",
"destination": {
"url": null,
"type": "world",
"harness": null,
"params": {}
}
},
{
"id": "workshop",
"label": "⚙ WORKSHOP",
"description": "The Forge — Build, create, iterate without limits",
"status": "online",
"position": [0, 0, 18],
"rotationY": 3.14159,
"ringColor": "#00ccaa",
"ringEmissive": "#008877",
"swirlInner": [0.0, 0.8, 0.65],
"swirlOuter": [0.3, 1.0, 0.85],
"particleColor": "#00ffcc",
"destination": {
"url": null,
"type": "workshop",
"harness": null,
"params": {}
}
}
]
}

119
style.css
View File

@@ -330,6 +330,125 @@ canvas#nexus-canvas {
background: rgba(74, 240, 192, 0.1);
}
/* === PORTAL PROXIMITY HINT === */
.portal-hint {
position: fixed;
bottom: 120px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
padding: var(--space-3) var(--space-6);
background: rgba(5, 5, 16, 0.88);
border: 1px solid;
border-radius: var(--panel-radius);
font-family: var(--font-body);
font-size: var(--text-sm);
text-align: center;
backdrop-filter: blur(8px);
pointer-events: none;
animation: hintPulse 2s ease-in-out infinite;
}
.portal-hint-name {
font-family: var(--font-display);
font-size: var(--text-base);
font-weight: 700;
display: block;
margin-bottom: 4px;
}
.portal-hint-key {
background: rgba(255,255,255,0.1);
border: 1px solid currentColor;
border-radius: 3px;
padding: 1px 6px;
font-size: var(--text-xs);
font-weight: 600;
}
@keyframes hintPulse {
0%, 100% { opacity: 0.85; }
50% { opacity: 1; }
}
/* === PORTAL OVERLAY === */
.portal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5, 5, 16, 0.75);
backdrop-filter: blur(12px);
animation: fadeIn 0.3s ease;
}
.portal-overlay.fade-out {
animation: fadeOut 0.4s ease forwards;
}
.portal-overlay-frame {
background: rgba(5, 8, 22, 0.92);
border: 1px solid var(--portal-color, var(--color-primary));
border-radius: 12px;
padding: var(--space-8) 48px;
text-align: center;
max-width: 440px;
width: 90%;
box-shadow: 0 0 60px -10px var(--portal-color, var(--color-primary));
position: relative;
}
.portal-overlay-sigil {
font-size: 48px;
opacity: 0.4;
margin-bottom: var(--space-4);
animation: rotateSigil 8s linear infinite;
display: block;
color: var(--portal-color, var(--color-primary));
}
@keyframes rotateSigil {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.portal-overlay-name {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
margin-bottom: var(--space-3);
letter-spacing: 0.08em;
}
.portal-overlay-desc {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
line-height: 1.6;
}
.portal-overlay-status {
font-size: var(--text-sm);
color: var(--color-text-bright);
margin-bottom: var(--space-6);
font-style: italic;
min-height: 1.5em;
}
.portal-overlay-close {
background: rgba(255,255,255,0.05);
border: 1px solid var(--portal-color, var(--color-primary));
color: var(--portal-color, var(--color-primary));
font-family: var(--font-body);
font-size: var(--text-sm);
padding: var(--space-3) var(--space-6);
border-radius: 6px;
cursor: pointer;
transition: background var(--transition-ui), opacity var(--transition-ui);
}
.portal-overlay-close:hover {
background: rgba(255,255,255,0.1);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* === FOOTER === */
.nexus-footer {
position: fixed;