From 49fbeec29d7524d3eb2429b91adc2648e4acdbf8 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:06:37 -0400 Subject: [PATCH] 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 --- app.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 5 ++++ style.css | 29 +++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/app.js b/app.js index 5a028f6..6b95d46 100644 --- a/app.js +++ b/app.js @@ -16,6 +16,13 @@ const NEXUS = { 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); @@ -112,6 +119,60 @@ function buildConstellationLines() { 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; @@ -121,6 +182,12 @@ 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) === @@ -182,6 +249,13 @@ function animate() { // 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); } diff --git a/index.html b/index.html index 26344f3..86bdca1 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,11 @@ [Tab] to exit +
+
+
+
+ diff --git a/style.css b/style.css index 1d78e52..628181b 100644 --- a/style.css +++ b/style.css @@ -106,3 +106,32 @@ canvas { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } } + +/* === CRYSTAL TOOLTIP === */ +.crystal-tooltip { + display: none; + position: fixed; + background: rgba(0, 0, 20, 0.92); + border: 1px solid var(--color-primary); + color: var(--color-text); + font-family: var(--font-body); + font-size: 12px; + padding: 8px 12px; + border-radius: 4px; + pointer-events: none; + z-index: 30; + max-width: 220px; + line-height: 1.5; +} + +.crystal-tooltip.visible { + display: block; +} + +.crystal-tooltip-name { + color: var(--color-primary); + font-size: 11px; + letter-spacing: 0.15em; + text-transform: uppercase; + margin-bottom: 4px; +} -- 2.43.0