diff --git a/app.js b/app.js
index 60689a0..de36a58 100644
--- a/app.js
+++ b/app.js
@@ -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 = `${entry.config.label}
[F] Enter portal [Click] 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
diff --git a/index.html b/index.html
index 3a2c6ea..ea713db 100644
--- a/index.html
+++ b/index.html
@@ -95,9 +95,23 @@
+
+