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 @@ + + +
- WASD move   Mouse look   Enter chat + WASD move   Mouse look   Enter chat   F portal +
+ + + + diff --git a/portals.json b/portals.json new file mode 100644 index 0000000..5ae6053 --- /dev/null +++ b/portals.json @@ -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": {} + } + } + ] +} diff --git a/style.css b/style.css index 519b05e..c93a2aa 100644 --- a/style.css +++ b/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;