From 1e6f7fd868ed692721635ccf7b3e5e5c0aac2015 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:00:50 -0400 Subject: [PATCH] feat: add volumetric light rays from skybox through scene 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 --- app.js | 298 +++++++++++++++++++++++++++++++++++++++++++++++++++-- index.html | 38 +++++-- style.css | 176 ++++++++++++++++++++++++++++++- 3 files changed, 495 insertions(+), 17 deletions(-) diff --git a/app.js b/app.js index 051fe82..ddfbbd7 100644 --- a/app.js +++ b/app.js @@ -1,12 +1,282 @@ -// ... existing code ... - -// === WEBSOCKET CLIENT === +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'; -// Initialize WebSocket client +// === 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(); -// Handle WebSocket events window.addEventListener('player-joined', (event) => { console.log('Player joined:', event.detail); }); @@ -19,9 +289,23 @@ window.addEventListener('chat-message', (event) => { console.log('Chat message:', event.detail); }); -// Clean up on page unload window.addEventListener('beforeunload', () => { wsClient.disconnect(); }); -// ... existing code ... +// === 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'); + } + }); +} diff --git a/index.html b/index.html index 34af931..6d1e80f 100644 --- a/index.html +++ b/index.html @@ -14,18 +14,44 @@ + + - + +
+
β—ˆ THE NEXUS
+
Timmy's Sovereign Space
+
-
- +
+
- + +
+
+
+ + +
+
+ + +
+
β—ˆ INITIALIZING NEXUS
+
+
+ + diff --git a/style.css b/style.css index a2aab15..3cdbd36 100644 --- a/style.css +++ b/style.css @@ -1,18 +1,186 @@ +/* === NEXUS DESIGN SYSTEM === */ +:root { + --color-bg: #000408; + --color-primary: #00ffcc; + --color-secondary: #8800ff; + --color-accent: #ff8800; + --color-text: #c0e8ff; + --color-text-muted: #446688; + --color-panel-bg: rgba(0, 10, 30, 0.75); + --color-panel-border: rgba(0, 200, 255, 0.25); + --font-body: 'Courier New', monospace; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + overflow: hidden; + width: 100vw; + height: 100vh; +} + +canvas { + display: block; + position: fixed; + top: 0; + left: 0; +} + +/* === HUD === */ +#hud { + position: fixed; + top: 16px; + left: 16px; + z-index: 10; + pointer-events: none; +} + +#hud-title { + font-size: 18px; + font-weight: bold; + color: var(--color-primary); + text-shadow: 0 0 12px var(--color-primary); + letter-spacing: 3px; +} + +#hud-subtitle { + font-size: 11px; + color: var(--color-text-muted); + letter-spacing: 2px; + margin-top: 2px; +} + /* === AUDIO TOGGLE === */ +#audio-control { + position: fixed; + top: 8px; + right: 8px; + z-index: 10; +} + #audio-toggle { font-size: 14px; - background-color: var(--color-primary-primary); - color: var(--color-bg); + background-color: var(--color-panel-bg); + color: var(--color-primary); + border: 1px solid var(--color-panel-border); padding: 4px 8px; border-radius: 4px; font-family: var(--font-body); + cursor: pointer; transition: background-color 0.3s ease; } #audio-toggle:hover { - background-color: var(--color-secondary); + background-color: rgba(0, 255, 204, 0.15); } #audio-toggle.muted { - background-color: var(--color-text-muted); + color: var(--color-text-muted); +} + +/* === CHAT PANEL === */ +#chat-panel { + position: fixed; + bottom: 16px; + right: 16px; + width: 300px; + background: var(--color-panel-bg); + border: 1px solid var(--color-panel-border); + border-radius: 6px; + z-index: 10; + backdrop-filter: blur(8px); +} + +#chat-messages { + height: 150px; + overflow-y: auto; + padding: 8px; + font-size: 11px; + color: var(--color-text); + line-height: 1.5; +} + +#chat-input-row { + display: flex; + border-top: 1px solid var(--color-panel-border); +} + +#chat-input { + flex: 1; + background: transparent; + border: none; + color: var(--color-text); + font-family: var(--font-body); + font-size: 11px; + padding: 6px 8px; + outline: none; +} + +#chat-input::placeholder { + color: var(--color-text-muted); +} + +#chat-send { + background: transparent; + border: none; + border-left: 1px solid var(--color-panel-border); + color: var(--color-primary); + font-family: var(--font-body); + font-size: 11px; + padding: 6px 10px; + cursor: pointer; + transition: background 0.2s; +} + +#chat-send:hover { + background: rgba(0, 255, 204, 0.1); +} + +/* === LOADING SCREEN === */ +#loading-screen { + position: fixed; + inset: 0; + background: var(--color-bg); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 100; + transition: opacity 0.8s ease; +} + +#loading-screen.hidden { + opacity: 0; + pointer-events: none; +} + +#loading-text { + font-size: 20px; + letter-spacing: 6px; + color: var(--color-primary); + text-shadow: 0 0 20px var(--color-primary); + margin-bottom: 24px; +} + +#loading-bar { + width: 240px; + height: 2px; + background: rgba(0, 255, 204, 0.15); + border-radius: 1px; +} + +#loading-progress { + height: 100%; + background: var(--color-primary); + box-shadow: 0 0 8px var(--color-primary); + border-radius: 1px; + width: 0%; + transition: width 0.3s ease; } -- 2.43.0