Files
the-nexus/app.js
Alexander Whitestone 49fbeec29d
Some checks failed
CI / validate (pull_request) Failing after 21s
feat: add clickable crystals with info tooltips (#133)
Place five named crystals in the Nexus 3D scene. Each crystal slowly
spins and pulses. Clicking a crystal reveals an overlay tooltip with
its name and lore description. Hovering shows a pointer cursor via
raycaster intersection.

Fixes #133
2026-03-24 00:06:37 -04:00

306 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,
}
};
// === SCENE SETUP ===
const scene = new THREE.Scene();
scene.background = new THREE.Color(NEXUS.colors.bg);
// Lights for crystal rendering
const ambientLight = new THREE.AmbientLight(0x112244, 1.5);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(0x4488ff, 2, 50);
pointLight.position.set(0, 5, 5);
scene.add(pointLight);
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
/**
* Builds constellation line segments connecting nearby stars.
* @returns {THREE.LineSegments}
*/
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((/** @type {{j: number, dist: number}} */ a, /** @type {{j: number, dist: number}} */ 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);
// === CRYSTALS ===
const CRYSTAL_DATA = [
{ name: 'Sovereign Core', desc: 'The heart of the Nexus. All portals flow from here.', color: 0x4488ff, pos: [0, 0, 0] },
{ name: 'Memory Shard', desc: "Stores fragments of past sessions. Timmy's long-term recall.", color: 0x44ffcc, pos: [3, 1, -2] },
{ name: 'Portal Seed', desc: 'Latent gateway to undiscovered worlds.', color: 0xff44aa, pos: [-3, -1, -1] },
{ name: 'Signal Stone', desc: 'Resonates with the Nostr network. Broadcasts presence.', color: 0xffaa44, pos: [2, -2, 1] },
{ name: 'Void Prism', desc: 'Absorbs entropy. Keeps the Nexus stable.', color: 0xaa44ff, pos: [-2, 2, 0] },
];
const crystals = CRYSTAL_DATA.map((data) => {
const geo = new THREE.OctahedronGeometry(0.28, 0);
const mat = new THREE.MeshStandardMaterial({
color: data.color,
emissive: data.color,
emissiveIntensity: 0.5,
metalness: 0.3,
roughness: 0.2,
transparent: true,
opacity: 0.88,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(...data.pos);
mesh.userData = { crystalName: data.name, crystalDesc: data.desc };
scene.add(mesh);
return mesh;
});
// === RAYCASTER FOR CRYSTAL INTERACTION ===
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const crystalTooltip = document.getElementById('crystal-tooltip');
const crystalTooltipName = document.getElementById('crystal-tooltip-name');
const crystalTooltipDesc = document.getElementById('crystal-tooltip-desc');
document.addEventListener('click', (/** @type {MouseEvent} */ e) => {
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(crystals);
if (hits.length > 0) {
const obj = hits[0].object;
crystalTooltipName.textContent = obj.userData.crystalName;
crystalTooltipDesc.textContent = obj.userData.crystalDesc;
// Position tooltip near cursor, clamped to viewport
const tx = Math.min(e.clientX + 14, window.innerWidth - 240);
const ty = Math.max(e.clientY - 10, 8);
crystalTooltip.style.left = tx + 'px';
crystalTooltip.style.top = ty + 'px';
crystalTooltip.classList.add('visible');
} else {
crystalTooltip.classList.remove('visible');
}
});
// === MOUSE-DRIVEN ROTATION ===
let mouseX = 0;
let mouseY = 0;
let targetRotX = 0;
let targetRotY = 0;
document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
// Update cursor when hovering over a crystal
pointer.x = (e.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
renderer.domElement.style.cursor = raycaster.intersectObjects(crystals).length > 0 ? 'pointer' : 'default';
});
// === OVERVIEW MODE (Tab — bird's-eye view of the whole Nexus) ===
let overviewMode = false;
let overviewT = 0; // 0 = normal view, 1 = overview
const NORMAL_CAM = new THREE.Vector3(0, 0, 5);
const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1); // overhead; tiny Z offset avoids gimbal lock
const overviewIndicator = document.getElementById('overview-indicator');
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
overviewMode = !overviewMode;
if (overviewMode) {
overviewIndicator.classList.add('visible');
} else {
overviewIndicator.classList.remove('visible');
}
}
});
// === 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();
/**
* Main animation loop — called each frame via requestAnimationFrame.
* @returns {void}
*/
function animate() {
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
// Smooth camera transition for overview mode
const targetT = overviewMode ? 1 : 0;
overviewT += (targetT - overviewT) * 0.04;
camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
camera.lookAt(0, 0, 0);
// Slow auto-rotation — suppressed during overview so the map stays readable
const rotationScale = 1 - overviewT;
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale;
stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale;
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;
// Slowly spin and pulse crystals
crystals.forEach((crystal, i) => {
crystal.rotation.y = elapsed * 0.4 + i * 1.2;
crystal.rotation.x = elapsed * 0.2 + i * 0.8;
crystal.material.emissiveIntensity = 0.4 + Math.sin(elapsed * 1.2 + i) * 0.2;
});
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((/** @type {HTMLElement} */ el) => el.style.outline = '2px solid red');
document.querySelectorAll('.light-source').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px dashed yellow');
} else {
document.querySelectorAll('.collision-box, .light-source').forEach((/** @type {HTMLElement} */ el) => {
el.style.outline = 'none';
});
}
});
// === WEBSOCKET CLIENT ===
import { wsClient } from './ws-client.js';
wsClient.connect();
window.addEventListener('player-joined', (/** @type {CustomEvent} */ event) => {
console.log('Player joined:', event.detail);
});
window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => {
console.log('Player left:', event.detail);
});
window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => {
console.log('Chat message:', event.detail);
});
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});