[claude] Portal system — YAML-driven registry with status health indicators (#5) #47
302
app.js
302
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 = `<span class="portal-hint-name">${entry.config.label}</span><br><span class="portal-hint-key">[F]</span> Enter portal <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
|
||||
|
||||
16
index.html
16
index.html
@@ -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 <span>Mouse</span> look <span>Enter</span> chat
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat <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
61
portals.json
Normal 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
119
style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user