diff --git a/app.js b/app.js index 6e85528..e5c3a3a 100644 --- a/app.js +++ b/app.js @@ -12,6 +12,61 @@ const NEXUS = { } }; +// === PORTAL PREVIEW === +const portalPreview = document.getElementById('portal-preview'); +const portalPreviewImg = document.getElementById('portal-preview-img'); +const portalPreviewPlaceholder = document.getElementById('portal-preview-placeholder'); +const portalPreviewName = document.getElementById('portal-preview-name'); +const portalPreviewDesc = document.getElementById('portal-preview-desc'); +const portalPreviewUrl = document.getElementById('portal-preview-url'); + +let previewMouseX = 0; +let previewMouseY = 0; + +function showPortalPreview(portal, mx, my) { + portalPreviewName.textContent = portal.name; + portalPreviewDesc.textContent = portal.description; + portalPreviewUrl.textContent = portal.destination.url.replace(/^https?:\/\//, ''); + + // Try screenshot image; fall back to colored placeholder + const imgSrc = `screenshots/${portal.id}.png`; + portalPreviewImg.onload = () => { + portalPreviewImg.style.display = 'block'; + portalPreviewPlaceholder.style.display = 'none'; + }; + portalPreviewImg.onerror = () => { + portalPreviewImg.style.display = 'none'; + portalPreviewPlaceholder.style.display = 'flex'; + portalPreviewPlaceholder.style.background = + `radial-gradient(ellipse at center, ${portal.color}44 0%, ${portal.color}11 70%, transparent 100%)`; + portalPreviewPlaceholder.style.borderColor = portal.color; + portalPreviewPlaceholder.textContent = portal.name; + portalPreviewPlaceholder.style.color = portal.color; + }; + portalPreviewImg.src = imgSrc; + + portalPreview.style.borderColor = portal.color; + portalPreview.style.boxShadow = `0 0 20px ${portal.color}55`; + portalPreview.classList.remove('hidden'); + positionPreview(mx, my); +} + +function hidePortalPreview() { + portalPreview.classList.add('hidden'); +} + +function positionPreview(mx, my) { + const pad = 16; + const w = portalPreview.offsetWidth || 220; + const h = portalPreview.offsetHeight || 180; + let left = mx + pad; + let top = my + pad; + if (left + w > window.innerWidth) left = mx - w - pad; + if (top + h > window.innerHeight) top = my - h - pad; + portalPreview.style.left = `${left}px`; + portalPreview.style.top = `${top}px`; +} + // === SCENE SETUP === const scene = new THREE.Scene(); scene.background = new THREE.Color(NEXUS.colors.bg); @@ -108,6 +163,58 @@ function buildConstellationLines() { const constellationLines = buildConstellationLines(); scene.add(constellationLines); +// === PORTAL SYSTEM === +const portalObjects = []; // { group, ring, inner, portal } +const raycaster = new THREE.Raycaster(); +const mouseNDC = new THREE.Vector2(); +let activePortal = null; + +async function initPortals() { + let portals; + try { + const res = await fetch('./portals.json'); + portals = await res.json(); + } catch { + return; + } + + for (const portal of portals) { + const color = new THREE.Color(portal.color); + + // Outer glowing ring + const ringGeo = new THREE.RingGeometry(1.2, 1.6, 48); + const ringMat = new THREE.MeshBasicMaterial({ + color, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.85, + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + + // Semi-transparent inner fill for hover hit area + const innerGeo = new THREE.CircleGeometry(1.2, 48); + const innerMat = new THREE.MeshBasicMaterial({ + color, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.12, + }); + const inner = new THREE.Mesh(innerGeo, innerMat); + + const group = new THREE.Group(); + group.add(ring); + group.add(inner); + group.position.set(portal.position.x, portal.position.y, portal.position.z); + if (portal.rotation?.y) group.rotation.y = portal.rotation.y; + group.userData = { portal }; + + scene.add(group); + portalObjects.push({ group, ring, inner, portal }); + } +} + +initPortals(); + // === MOUSE-DRIVEN ROTATION === let mouseX = 0; let mouseY = 0; @@ -117,6 +224,35 @@ let targetRotY = 0; document.addEventListener('mousemove', (e) => { mouseX = (e.clientX / window.innerWidth - 0.5) * 2; mouseY = (e.clientY / window.innerHeight - 0.5) * 2; + previewMouseX = e.clientX; + previewMouseY = e.clientY; + + // Raycast against portal meshes + mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1; + mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1; + raycaster.setFromCamera(mouseNDC, camera); + + const hitMeshes = portalObjects.flatMap(p => [p.ring, p.inner]); + const hits = raycaster.intersectObjects(hitMeshes); + + if (hits.length > 0) { + // Find which portal was hit + const hitMesh = hits[0].object; + const found = portalObjects.find(p => p.ring === hitMesh || p.inner === hitMesh); + if (found) { + if (activePortal !== found.portal) { + activePortal = found.portal; + showPortalPreview(found.portal, e.clientX, e.clientY); + } else { + positionPreview(e.clientX, e.clientY); + } + } + } else { + if (activePortal !== null) { + activePortal = null; + hidePortalPreview(); + } + } }); // === RESIZE HANDLER === @@ -146,6 +282,14 @@ function animate() { // Subtle pulse on constellation opacity constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; + // Animate portals — slow spin + opacity pulse + for (const { group, ring, inner, portal } of portalObjects) { + group.rotation.z = elapsed * 0.3 + (portal.rotation?.y ?? 0); + const pulse = 0.7 + Math.sin(elapsed * 1.2 + group.position.x) * 0.15; + ring.material.opacity = pulse; + inner.material.opacity = 0.08 + Math.sin(elapsed * 0.8) * 0.05; + } + renderer.render(scene, camera); } diff --git a/index.html b/index.html index 2006413..2695365 100644 --- a/index.html +++ b/index.html @@ -36,6 +36,19 @@ + + + diff --git a/style.css b/style.css index 948c916..eb61b1a 100644 --- a/style.css +++ b/style.css @@ -56,6 +56,76 @@ canvas { background-color: var(--color-text-muted); } +/* === PORTAL PREVIEW === */ +.portal-preview { + position: fixed; + z-index: 100; + background: rgba(0, 0, 20, 0.93); + border: 1px solid var(--color-primary); + border-radius: 6px; + padding: 10px; + width: 224px; + pointer-events: none; + transition: opacity 0.18s ease; + box-shadow: 0 0 20px rgba(68, 136, 255, 0.3); +} + +.portal-preview.hidden { + opacity: 0; +} + +.portal-preview-screenshot { + width: 204px; + height: 120px; + border-radius: 4px; + overflow: hidden; + margin-bottom: 8px; + background: var(--color-secondary); +} + +.portal-preview-screenshot img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.portal-preview-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid transparent; + border-radius: 4px; +} + +.portal-preview-name { + font-size: 13px; + font-weight: bold; + color: var(--color-text); + margin-bottom: 4px; +} + +.portal-preview-desc { + font-size: 11px; + color: var(--color-text-muted); + margin-bottom: 6px; + line-height: 1.4; +} + +.portal-preview-url { + font-size: 10px; + color: var(--color-primary); + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* === DEBUG MODE === */ #debug-toggle { margin-left: 8px;