Some checks failed
CI / validate (pull_request) Has been cancelled
Right-clicking any inspectable 3D mesh now shows a floating tooltip with its name and description. Uses Three.js Raycaster for hit detection. Also adds initial inspectable objects to the scene: - Nexus Core (central glowing orb) - Portal Alpha, Beta, Gamma (orbiting sentinel cubes) Tooltip auto-positions near cursor and stays within viewport bounds. Dismissed by clicking elsewhere or pressing Escape. Fixes #141 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
307 lines
9.3 KiB
JavaScript
307 lines
9.3 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);
|
|
|
|
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);
|
|
|
|
// === 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;
|
|
});
|
|
|
|
// === 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 core: gentle pulse
|
|
const pulse = 0.8 + Math.sin(elapsed * 1.2) * 0.2;
|
|
coreMat.emissiveIntensity = pulse;
|
|
coreLight.intensity = pulse * 1.5;
|
|
|
|
// Animate sentinels: slow orbit around core
|
|
sentinels.forEach((s, i) => {
|
|
const angle = elapsed * 0.4 + (i * Math.PI * 2) / sentinels.length;
|
|
const r = sentinelDefs[i].pos.map(Math.abs).reduce((a, b) => Math.max(a, b));
|
|
s.position.x = Math.cos(angle) * r;
|
|
s.position.z = Math.sin(angle) * r;
|
|
s.rotation.y = elapsed * 0.8 + i;
|
|
});
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
animate();
|
|
|
|
// === INSPECTABLE 3D OBJECTS ===
|
|
// Objects registered here are raycasted on right-click.
|
|
const inspectableObjects = [];
|
|
|
|
function addInspectable(mesh, name, description) {
|
|
mesh.userData.inspectName = name;
|
|
mesh.userData.inspectDesc = description;
|
|
scene.add(mesh);
|
|
inspectableObjects.push(mesh);
|
|
return mesh;
|
|
}
|
|
|
|
// Nexus Core — central glowing orb
|
|
const coreGeo = new THREE.SphereGeometry(0.4, 32, 32);
|
|
const coreMat = new THREE.MeshStandardMaterial({
|
|
color: NEXUS.colors.accent,
|
|
emissive: NEXUS.colors.accent,
|
|
emissiveIntensity: 0.8,
|
|
roughness: 0.2,
|
|
metalness: 0.6,
|
|
});
|
|
addInspectable(
|
|
new THREE.Mesh(coreGeo, coreMat),
|
|
'Nexus Core',
|
|
'The sovereign heart of this world. All portals radiate from this point.'
|
|
);
|
|
|
|
// Ambient point light to illuminate the core
|
|
const coreLight = new THREE.PointLight(NEXUS.colors.accent, 1.5, 10);
|
|
scene.add(coreLight);
|
|
|
|
// Portal sentinels — three small cubes orbiting the core
|
|
const sentinelDefs = [
|
|
{ pos: [2.5, 0, 0], name: 'Portal Alpha', desc: 'A dormant gateway. Awaiting its destination world.' },
|
|
{ pos: [-1.5, 2, 0], name: 'Portal Beta', desc: 'Shimmers faintly. Something stirs on the other side.' },
|
|
{ pos: [0, -2, 1.5], name: 'Portal Gamma', desc: 'Cold to the touch. Opens only when summoned by name.' },
|
|
];
|
|
|
|
const sentinels = sentinelDefs.map(({ pos, name, desc }) => {
|
|
const geo = new THREE.BoxGeometry(0.25, 0.25, 0.25);
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: 0x88bbff,
|
|
emissive: 0x223366,
|
|
emissiveIntensity: 0.5,
|
|
roughness: 0.4,
|
|
metalness: 0.8,
|
|
});
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.position.set(...pos);
|
|
return addInspectable(mesh, name, desc);
|
|
});
|
|
|
|
// === OBJECT INSPECTION — RIGHT CLICK ===
|
|
const raycaster = new THREE.Raycaster();
|
|
const inspectTooltip = document.getElementById('inspect-tooltip');
|
|
const inspectName = document.getElementById('inspect-name');
|
|
const inspectDesc = document.getElementById('inspect-desc');
|
|
|
|
function hideInspectTooltip() {
|
|
inspectTooltip.classList.remove('visible');
|
|
}
|
|
|
|
renderer.domElement.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
|
|
const mouse = new THREE.Vector2(
|
|
(e.clientX / window.innerWidth) * 2 - 1,
|
|
-(e.clientY / window.innerHeight) * 2 + 1
|
|
);
|
|
|
|
raycaster.setFromCamera(mouse, camera);
|
|
const hits = raycaster.intersectObjects(inspectableObjects, false);
|
|
|
|
if (hits.length > 0) {
|
|
const obj = hits[0].object;
|
|
inspectName.textContent = obj.userData.inspectName;
|
|
inspectDesc.textContent = obj.userData.inspectDesc;
|
|
|
|
// Position tooltip near cursor, keep it inside viewport
|
|
const pad = 12;
|
|
const tw = 240;
|
|
let x = e.clientX + pad;
|
|
let y = e.clientY + pad;
|
|
if (x + tw > window.innerWidth) x = e.clientX - tw - pad;
|
|
if (y + 80 > window.innerHeight) y = e.clientY - 80 - pad;
|
|
|
|
inspectTooltip.style.left = x + 'px';
|
|
inspectTooltip.style.top = y + 'px';
|
|
inspectTooltip.classList.add('visible');
|
|
} else {
|
|
hideInspectTooltip();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', hideInspectTooltip);
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideInspectTooltip(); });
|
|
|
|
// === 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();
|
|
});
|