Files
the-nexus/app.js
Alexander Whitestone b28490e8fe
Some checks failed
CI / validate (pull_request) Has been cancelled
feat: interactive portal previews on hover (#136)
- Load portals from portals.json and render as glowing 3D rings in scene
- Raycast on mousemove to detect portal hover
- Show tooltip card with portal name, description, destination URL, and screenshot
- Screenshot falls back to a colored radial-gradient placeholder if image missing
- Portals animate with slow Z-spin and opacity pulse each frame

Fixes #136
2026-03-24 00:05:25 -04:00

340 lines
10 KiB
JavaScript

import * as THREE from 'three';
// === COLOR PALETTE ===
const NEXUS = {
colors: {
bg: 0x000008,
starCore: 0xffffff,
starDim: 0x8899cc,
constellationLine: 0x334488,
constellationFade: 0x112244,
accent: 0x4488ff,
}
};
// === 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);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 0, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// === STAR FIELD ===
const STAR_COUNT = 800;
const STAR_SPREAD = 400;
const CONSTELLATION_DISTANCE = 30; // max distance to draw a line between stars
const starPositions = [];
const starGeo = new THREE.BufferGeometry();
const posArray = new Float32Array(STAR_COUNT * 3);
const sizeArray = new Float32Array(STAR_COUNT);
for (let i = 0; i < STAR_COUNT; i++) {
const x = (Math.random() - 0.5) * STAR_SPREAD;
const y = (Math.random() - 0.5) * STAR_SPREAD;
const z = (Math.random() - 0.5) * STAR_SPREAD;
posArray[i * 3] = x;
posArray[i * 3 + 1] = y;
posArray[i * 3 + 2] = z;
sizeArray[i] = Math.random() * 2.5 + 0.5;
starPositions.push(new THREE.Vector3(x, y, z));
}
starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1));
const starMaterial = new THREE.PointsMaterial({
color: NEXUS.colors.starCore,
size: 0.6,
sizeAttenuation: true,
transparent: true,
opacity: 0.9,
});
const stars = new THREE.Points(starGeo, starMaterial);
scene.add(stars);
// === CONSTELLATION LINES ===
// Connect nearby stars with faint lines, limited to avoid clutter
function buildConstellationLines() {
const linePositions = [];
const MAX_CONNECTIONS_PER_STAR = 3;
const connectionCount = new Array(STAR_COUNT).fill(0);
for (let i = 0; i < STAR_COUNT; i++) {
if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue;
// Find nearest neighbors
const neighbors = [];
for (let j = i + 1; j < STAR_COUNT; j++) {
if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue;
const dist = starPositions[i].distanceTo(starPositions[j]);
if (dist < CONSTELLATION_DISTANCE) {
neighbors.push({ j, dist });
}
}
// Sort by distance and connect closest ones
neighbors.sort((a, b) => a.dist - b.dist);
const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]);
for (const { j } of toConnect) {
linePositions.push(
starPositions[i].x, starPositions[i].y, starPositions[i].z,
starPositions[j].x, starPositions[j].y, starPositions[j].z
);
connectionCount[i]++;
connectionCount[j]++;
}
}
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3));
const lineMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.constellationLine,
transparent: true,
opacity: 0.18,
});
return new THREE.LineSegments(lineGeo, lineMat);
}
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;
let targetRotX = 0;
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 ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
// Slow auto-rotation
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
stars.rotation.x = targetRotX + elapsed * 0.01;
stars.rotation.y = targetRotY + elapsed * 0.015;
constellationLines.rotation.x = stars.rotation.x;
constellationLines.rotation.y = stars.rotation.y;
// 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);
}
animate();
// === DEBUG MODE ===
let debugMode = false;
document.getElementById('debug-toggle').addEventListener('click', () => {
debugMode = !debugMode;
document.getElementById('debug-toggle').style.backgroundColor = debugMode
? 'var(--color-text-muted)'
: 'var(--color-secondary)';
console.log(`Debug mode ${debugMode ? 'enabled' : 'disabled'}`);
if (debugMode) {
// Example: Visualize all collision boxes and light sources
// Replace with actual logic when available
document.querySelectorAll('.collision-box').forEach(el => el.style.outline = '2px solid red');
document.querySelectorAll('.light-source').forEach(el => el.style.outline = '2px dashed yellow');
} else {
document.querySelectorAll('.collision-box, .light-source').forEach(el => {
el.style.outline = 'none';
});
}
});
// === WEBSOCKET CLIENT ===
import { wsClient } from './ws-client.js';
wsClient.connect();
window.addEventListener('player-joined', (event) => {
console.log('Player joined:', event.detail);
});
window.addEventListener('player-left', (event) => {
console.log('Player left:', event.detail);
});
window.addEventListener('chat-message', (event) => {
console.log('Chat message:', event.detail);
});
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});