- Event horizon: pure black sphere in the far distance - Gravitational lensing sphere: Fresnel-based shader with sharp Einstein ring at photon sphere edge and animated secondary ring shimmer - Accretion disk: RingGeometry with custom shader featuring Doppler brightening, turbulent swirl animation, and hot inner / cool outer color gradient (white-blue → orange → purple) Fixes #118
432 lines
14 KiB
JavaScript
432 lines
14 KiB
JavaScript
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
|
|
|
|
// === 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
|
|
/**
|
|
* 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);
|
|
|
|
// === BLACK HOLE with GRAVITATIONAL LENSING ===
|
|
const BH_POS = new THREE.Vector3(-120, 30, -250);
|
|
const BH_RADIUS = 14;
|
|
|
|
// Event horizon — pure black sphere
|
|
const eventHorizonGeo = new THREE.SphereGeometry(BH_RADIUS, 64, 64);
|
|
const eventHorizonMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
|
const eventHorizon = new THREE.Mesh(eventHorizonGeo, eventHorizonMat);
|
|
eventHorizon.position.copy(BH_POS);
|
|
scene.add(eventHorizon);
|
|
|
|
// Gravitational lensing sphere — custom Fresnel-based shader simulating light bending
|
|
const lensRadius = BH_RADIUS * 2.6;
|
|
const lensGeo = new THREE.SphereGeometry(lensRadius, 128, 128);
|
|
const lensMat = new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
time: { value: 0.0 },
|
|
},
|
|
vertexShader: /* glsl */`
|
|
varying vec3 vViewNormal;
|
|
void main() {
|
|
vViewNormal = normalize(normalMatrix * normal);
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: /* glsl */`
|
|
uniform float time;
|
|
varying vec3 vViewNormal;
|
|
|
|
void main() {
|
|
// Fresnel: 0 at face-on, 1 at grazing angle (edge of sphere)
|
|
float fresnel = 1.0 - abs(vViewNormal.z);
|
|
|
|
// Einstein ring — sharp bright ring at the edge (photon sphere)
|
|
float einsteinRing = pow(fresnel, 10.0);
|
|
|
|
// Secondary photon ring — fainter inner ring
|
|
float ring2 = smoothstep(0.55, 0.60, fresnel) * smoothstep(0.72, 0.67, fresnel) * 0.5;
|
|
|
|
// Shimmer animation
|
|
float shimmer = sin(time * 1.8) * 0.08 + 0.92;
|
|
|
|
// Blue-white for Einstein ring, warmer for secondary
|
|
vec3 einsteinColor = mix(vec3(0.6, 0.8, 1.0), vec3(1.0, 0.9, 0.7), ring2);
|
|
float alpha = (einsteinRing * 1.8 + ring2) * shimmer;
|
|
|
|
gl_FragColor = vec4(einsteinColor, clamp(alpha, 0.0, 1.0));
|
|
}
|
|
`,
|
|
transparent: true,
|
|
side: THREE.FrontSide,
|
|
depthWrite: false,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
const lensMesh = new THREE.Mesh(lensGeo, lensMat);
|
|
lensMesh.position.copy(BH_POS);
|
|
scene.add(lensMesh);
|
|
|
|
// Accretion disk — glowing ring with Doppler-shifted animated shader
|
|
const diskInner = BH_RADIUS * 1.25;
|
|
const diskOuter = BH_RADIUS * 4.2;
|
|
const diskGeo = new THREE.RingGeometry(diskInner, diskOuter, 128, 8);
|
|
const diskMat = new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
time: { value: 0.0 },
|
|
},
|
|
vertexShader: /* glsl */`
|
|
varying vec2 vUv;
|
|
varying vec3 vLocalPos;
|
|
void main() {
|
|
vUv = uv;
|
|
vLocalPos = position;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: /* glsl */`
|
|
uniform float time;
|
|
varying vec2 vUv;
|
|
varying vec3 vLocalPos;
|
|
|
|
float hash(float n) { return fract(sin(n) * 43758.5453); }
|
|
float noise(float x) {
|
|
float i = floor(x);
|
|
float f = fract(x);
|
|
return mix(hash(i), hash(i + 1.0), smoothstep(0.0, 1.0, f));
|
|
}
|
|
|
|
void main() {
|
|
// vUv.x: 0=inner edge, 1=outer edge (RingGeometry convention)
|
|
float r = vUv.x;
|
|
float angle = atan(vLocalPos.y, vLocalPos.x);
|
|
|
|
// Animated turbulent swirl
|
|
float swirl = noise(angle * 4.0 - time * 0.8 + r * 6.0) * 0.5 + 0.5;
|
|
|
|
// Color: white-blue inner (hottest), orange mid, dim purple outer
|
|
vec3 innerColor = vec3(1.0, 0.95, 0.85);
|
|
vec3 midColor = vec3(1.0, 0.45, 0.08);
|
|
vec3 outerColor = vec3(0.4, 0.08, 0.35);
|
|
vec3 diskColor = r < 0.35
|
|
? mix(innerColor, midColor, r / 0.35)
|
|
: mix(midColor, outerColor, (r - 0.35) / 0.65);
|
|
|
|
// Relativistic Doppler brightening: approaching side brightens
|
|
float doppler = sin(angle - time * 0.25) * 0.35 + 0.75;
|
|
|
|
float turbulence = swirl * 0.55 + 0.45;
|
|
float opacity = (1.0 - r * 0.65) * turbulence * doppler;
|
|
opacity = clamp(opacity, 0.0, 1.0);
|
|
|
|
gl_FragColor = vec4(diskColor * (0.8 + doppler * 0.6), opacity * 0.95);
|
|
}
|
|
`,
|
|
transparent: true,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
const diskMesh = new THREE.Mesh(diskGeo, diskMat);
|
|
diskMesh.position.copy(BH_POS);
|
|
diskMesh.rotation.x = Math.PI * 0.18; // slight tilt for 3D depth
|
|
scene.add(diskMesh);
|
|
|
|
// === 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;
|
|
});
|
|
|
|
// === 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');
|
|
}
|
|
}
|
|
});
|
|
|
|
// === PHOTO MODE ===
|
|
let photoMode = false;
|
|
|
|
// Post-processing composer for depth of field
|
|
const composer = new EffectComposer(renderer);
|
|
composer.addPass(new RenderPass(scene, camera));
|
|
|
|
const bokehPass = new BokehPass(scene, camera, {
|
|
focus: 5.0,
|
|
aperture: 0.0003,
|
|
maxblur: 0.008,
|
|
});
|
|
bokehPass.enabled = false;
|
|
composer.addPass(bokehPass);
|
|
|
|
// Orbit controls for free camera movement in photo mode
|
|
const orbitControls = new OrbitControls(camera, renderer.domElement);
|
|
orbitControls.enableDamping = true;
|
|
orbitControls.dampingFactor = 0.05;
|
|
orbitControls.enabled = false;
|
|
|
|
const photoIndicator = document.getElementById('photo-indicator');
|
|
const photoFocusDisplay = document.getElementById('photo-focus');
|
|
|
|
/**
|
|
* Updates the photo mode focus distance display.
|
|
*/
|
|
function updateFocusDisplay() {
|
|
if (photoFocusDisplay) {
|
|
photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'p' || e.key === 'P') {
|
|
photoMode = !photoMode;
|
|
document.body.classList.toggle('photo-mode', photoMode);
|
|
bokehPass.enabled = photoMode;
|
|
orbitControls.enabled = photoMode;
|
|
if (photoIndicator) {
|
|
photoIndicator.classList.toggle('visible', photoMode);
|
|
}
|
|
if (photoMode) {
|
|
// Sync orbit target to current look-at
|
|
orbitControls.target.set(0, 0, 0);
|
|
orbitControls.update();
|
|
updateFocusDisplay();
|
|
}
|
|
}
|
|
|
|
// Adjust focus with [ ] while in photo mode
|
|
if (photoMode) {
|
|
const focusStep = 0.5;
|
|
if (e.key === '[') {
|
|
bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - focusStep);
|
|
updateFocusDisplay();
|
|
} else if (e.key === ']') {
|
|
bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + focusStep);
|
|
updateFocusDisplay();
|
|
}
|
|
}
|
|
});
|
|
|
|
// === RESIZE HANDLER ===
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
composer.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 and photo mode
|
|
const rotationScale = photoMode ? 0 : (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;
|
|
|
|
// Animate black hole shaders
|
|
lensMat.uniforms.time.value = elapsed;
|
|
diskMat.uniforms.time.value = elapsed;
|
|
|
|
if (photoMode) {
|
|
orbitControls.update();
|
|
composer.render();
|
|
} else {
|
|
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();
|
|
});
|