Some checks failed
CI / validate (pull_request) Has been cancelled
Implements god ray / crepuscular ray effect using cone-shaped meshes with custom GLSL shaders and additive blending. Nine rays emanate from the upper skybox at varied angles, colors (gold, blue, cyan), and phases. Each ray has animated shimmer rippling down its length. Also establishes the full Three.js scene foundation: - Renderer with ACESFilmic tone mapping + UnrealBloom post-processing - Star field (4000 points) distributed on a sphere - Nexus Core: animated wireframe icosahedron with glow halo - OrbitControls with damping - Loading screen with progress animation - Audio toggle wired up Fixes #111 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
312 lines
9.6 KiB
JavaScript
312 lines
9.6 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 { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
import { wsClient } from './ws-client.js';
|
|
|
|
// === COLOR PALETTE ===
|
|
const NEXUS = {
|
|
colors: {
|
|
primary: 0x00ffcc,
|
|
secondary: 0x8800ff,
|
|
accent: 0xff8800,
|
|
bg: 0x000408,
|
|
nebula1: 0x0a0025,
|
|
nebula2: 0x00050f,
|
|
rayGold: 0xffaa44,
|
|
rayBlue: 0x88aaff,
|
|
rayCyan: 0x44ddff,
|
|
star: 0xffffff,
|
|
}
|
|
};
|
|
|
|
// === RENDERER ===
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 0.9;
|
|
document.body.appendChild(renderer.domElement);
|
|
|
|
// === SCENE ===
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(NEXUS.colors.bg);
|
|
scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.006);
|
|
|
|
// === CAMERA ===
|
|
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 800);
|
|
camera.position.set(0, 8, 28);
|
|
|
|
// === CONTROLS ===
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.05;
|
|
controls.target.set(0, 4, 0);
|
|
controls.maxDistance = 120;
|
|
controls.minDistance = 2;
|
|
|
|
// === LIGHTING ===
|
|
const ambient = new THREE.AmbientLight(0x112233, 0.6);
|
|
scene.add(ambient);
|
|
|
|
const coreLight = new THREE.PointLight(NEXUS.colors.primary, 3, 60);
|
|
coreLight.position.set(0, 8, 0);
|
|
scene.add(coreLight);
|
|
|
|
// === STAR FIELD ===
|
|
function createStarField() {
|
|
const count = 4000;
|
|
const positions = new Float32Array(count * 3);
|
|
const sizes = new Float32Array(count);
|
|
for (let i = 0; i < count; i++) {
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const phi = Math.acos(2 * Math.random() - 1);
|
|
const r = 200 + Math.random() * 200;
|
|
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
|
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
|
positions[i * 3 + 2] = r * Math.cos(phi);
|
|
sizes[i] = 0.3 + Math.random() * 0.7;
|
|
}
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
|
const mat = new THREE.PointsMaterial({
|
|
color: NEXUS.colors.star,
|
|
size: 0.4,
|
|
sizeAttenuation: true,
|
|
transparent: true,
|
|
opacity: 0.85,
|
|
});
|
|
return new THREE.Points(geo, mat);
|
|
}
|
|
scene.add(createStarField());
|
|
|
|
// === VOLUMETRIC LIGHT RAYS ===
|
|
// Cone-shaped shafts with additive blending simulating god rays from the skybox
|
|
|
|
const LIGHT_RAY_VERT = /* glsl */`
|
|
varying vec2 vUv;
|
|
varying float vLocalY;
|
|
|
|
void main() {
|
|
vUv = uv;
|
|
// Normalize local Y: 0.0 = bottom (wide base), 1.0 = top (tip/source)
|
|
vLocalY = clamp((position.y + 15.0) / 30.0, 0.0, 1.0);
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`;
|
|
|
|
const LIGHT_RAY_FRAG = /* glsl */`
|
|
uniform float time;
|
|
uniform vec3 rayColor;
|
|
uniform float rayOpacity;
|
|
uniform float rayPhase;
|
|
|
|
varying vec2 vUv;
|
|
varying float vLocalY;
|
|
|
|
void main() {
|
|
// Soft radial fade using U coordinate around the circumference
|
|
// sin() creates a smooth bright-center / dark-edge per face
|
|
float radialFade = sin(vUv.x * 3.14159);
|
|
radialFade = pow(max(radialFade, 0.0), 0.6);
|
|
|
|
// Length fade: bright at source (top/tip, vLocalY=1), fades at base
|
|
float lengthFade = pow(vLocalY, 0.35) * (1.0 - pow(vLocalY, 6.0) * 0.05);
|
|
|
|
// Animated shimmer — ripples travel down the ray
|
|
float shimmer = 0.72 + 0.28 * sin(time * 1.6 + vLocalY * 7.0 + rayPhase);
|
|
|
|
float alpha = radialFade * lengthFade * shimmer * rayOpacity;
|
|
|
|
// Brighten core color slightly with shimmer
|
|
vec3 finalColor = rayColor * (1.0 + shimmer * 0.4);
|
|
|
|
gl_FragColor = vec4(finalColor, alpha);
|
|
}
|
|
`;
|
|
|
|
const lightRays = [];
|
|
|
|
function createLightRay(pos, rotX, rotZ, color, opacity, phase) {
|
|
// Cone: tip (radiusTop≈0) at top (y=+15), base (radiusBottom=2.8) at bottom (y=-15)
|
|
const geo = new THREE.CylinderGeometry(0.05, 2.8, 30, 32, 8, true);
|
|
const mat = new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
time: { value: 0.0 },
|
|
rayColor: { value: new THREE.Color(color) },
|
|
rayOpacity: { value: opacity },
|
|
rayPhase: { value: phase },
|
|
},
|
|
vertexShader: LIGHT_RAY_VERT,
|
|
fragmentShader: LIGHT_RAY_FRAG,
|
|
transparent: true,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.position.copy(pos);
|
|
mesh.rotation.x = rotX;
|
|
mesh.rotation.z = rotZ;
|
|
scene.add(mesh);
|
|
lightRays.push({ mesh, mat });
|
|
}
|
|
|
|
// Rays emanating from the upper skybox at varied angles and colors
|
|
const rayDefs = [
|
|
// pos (x, y, z), rotX, rotZ, color, opacity, phase
|
|
{ p: [-10, 26, -4], rx: 0.18, rz: 0.10, c: NEXUS.colors.rayGold, o: 0.20, ph: 0.0 },
|
|
{ p: [ 4, 30, -9], rx: -0.08, rz: -0.22, c: NEXUS.colors.rayGold, o: 0.16, ph: 1.1 },
|
|
{ p: [ 14, 24, 2], rx: 0.22, rz: 0.14, c: NEXUS.colors.rayBlue, o: 0.17, ph: 2.3 },
|
|
{ p: [-18, 28, 5], rx: -0.06, rz: 0.28, c: NEXUS.colors.rayGold, o: 0.13, ph: 0.7 },
|
|
{ p: [ 1, 32, -14], rx: 0.10, rz: -0.08, c: NEXUS.colors.rayCyan, o: 0.18, ph: 1.8 },
|
|
{ p: [ -6, 25, 12], rx: -0.20, rz: -0.10, c: NEXUS.colors.rayBlue, o: 0.15, ph: 3.1 },
|
|
{ p: [ 20, 29, -7], rx: 0.14, rz: 0.24, c: NEXUS.colors.rayGold, o: 0.12, ph: 0.4 },
|
|
{ p: [-12, 27, -9], rx: 0.05, rz: -0.16, c: NEXUS.colors.rayCyan, o: 0.14, ph: 2.6 },
|
|
{ p: [ 8, 23, 9], rx: -0.12, rz: 0.06, c: NEXUS.colors.rayGold, o: 0.11, ph: 1.5 },
|
|
];
|
|
|
|
rayDefs.forEach(({ p, rx, rz, c, o, ph }) => {
|
|
createLightRay(new THREE.Vector3(...p), rx, rz, c, o, ph);
|
|
});
|
|
|
|
// === NEXUS CORE (floating icosahedron) ===
|
|
const coreGeo = new THREE.IcosahedronGeometry(2.2, 1);
|
|
const coreMat = new THREE.MeshPhongMaterial({
|
|
color: NEXUS.colors.primary,
|
|
emissive: NEXUS.colors.primary,
|
|
emissiveIntensity: 0.6,
|
|
wireframe: true,
|
|
transparent: true,
|
|
opacity: 0.85,
|
|
});
|
|
const core = new THREE.Mesh(coreGeo, coreMat);
|
|
core.position.set(0, 5, 0);
|
|
scene.add(core);
|
|
|
|
// Glow halo around core
|
|
const haloGeo = new THREE.SphereGeometry(3.2, 16, 16);
|
|
const haloMat = new THREE.MeshBasicMaterial({
|
|
color: NEXUS.colors.primary,
|
|
transparent: true,
|
|
opacity: 0.04,
|
|
side: THREE.BackSide,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
scene.add(new THREE.Mesh(haloGeo, haloMat)).position.set(0, 5, 0);
|
|
|
|
// === GROUND PLANE ===
|
|
const groundGeo = new THREE.CircleGeometry(40, 64);
|
|
const groundMat = new THREE.MeshPhongMaterial({
|
|
color: 0x060618,
|
|
emissive: 0x010110,
|
|
shininess: 10,
|
|
});
|
|
const ground = new THREE.Mesh(groundGeo, groundMat);
|
|
ground.rotation.x = -Math.PI / 2;
|
|
scene.add(ground);
|
|
|
|
// === POST-PROCESSING (Unreal Bloom) ===
|
|
const composer = new EffectComposer(renderer);
|
|
composer.addPass(new RenderPass(scene, camera));
|
|
const bloom = new UnrealBloomPass(
|
|
new THREE.Vector2(window.innerWidth, window.innerHeight),
|
|
0.9, // strength
|
|
0.5, // radius
|
|
0.82 // threshold
|
|
);
|
|
composer.addPass(bloom);
|
|
|
|
// === 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);
|
|
bloom.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
|
|
// === LOADING SCREEN ===
|
|
const loadingScreen = document.getElementById('loading-screen');
|
|
const loadingProgress = document.getElementById('loading-progress');
|
|
|
|
function simulateLoad() {
|
|
let pct = 0;
|
|
const interval = setInterval(() => {
|
|
pct += Math.random() * 18;
|
|
if (pct >= 100) {
|
|
pct = 100;
|
|
clearInterval(interval);
|
|
setTimeout(() => loadingScreen.classList.add('hidden'), 300);
|
|
}
|
|
loadingProgress.style.width = pct + '%';
|
|
}, 80);
|
|
}
|
|
simulateLoad();
|
|
|
|
// === ANIMATION LOOP ===
|
|
const clock = new THREE.Clock();
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
const t = clock.getElapsedTime();
|
|
|
|
// Update volumetric ray shaders
|
|
lightRays.forEach(({ mat }) => {
|
|
mat.uniforms.time.value = t;
|
|
});
|
|
|
|
// Rotate Nexus Core
|
|
core.rotation.y = t * 0.28;
|
|
core.rotation.x = t * 0.11;
|
|
|
|
// Pulse the core point light
|
|
coreLight.intensity = 2.5 + 0.8 * Math.sin(t * 1.8);
|
|
|
|
controls.update();
|
|
composer.render();
|
|
}
|
|
|
|
animate();
|
|
|
|
// === WEBSOCKET INTEGRATION ===
|
|
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();
|
|
});
|
|
|
|
// === AUDIO TOGGLE ===
|
|
const audioToggle = document.getElementById('audio-toggle');
|
|
const ambientSound = document.getElementById('ambient-sound');
|
|
if (audioToggle && ambientSound) {
|
|
audioToggle.addEventListener('click', () => {
|
|
if (ambientSound.paused) {
|
|
ambientSound.play().catch(() => {});
|
|
audioToggle.textContent = '🔊';
|
|
audioToggle.classList.remove('muted');
|
|
} else {
|
|
ambientSound.pause();
|
|
audioToggle.textContent = '🔇';
|
|
audioToggle.classList.add('muted');
|
|
}
|
|
});
|
|
}
|