diff --git a/app.js b/app.js index 051fe82..bf9d26d 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,5 @@ -// ... existing code ... +// === NEXUS APP === +import * as THREE from 'three'; // === WEBSOCKET CLIENT === import { wsClient } from './ws-client.js'; @@ -24,4 +25,195 @@ window.addEventListener('beforeunload', () => { wsClient.disconnect(); }); -// ... existing code ... +// === COLOR PALETTE === +const NEXUS = { + colors: { + bg: 0x0a0a0f, + primary: 0x00ffcc, + secondary: 0x7c3aed, + accent: 0xf59e0b, + star: 0xffffff, + meteor: 0xffffff, + meteorGlow: 0x88ccff, + } +}; + +// === SCENE SETUP === +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.setClearColor(NEXUS.colors.bg); +document.body.appendChild(renderer.domElement); + +const scene = new THREE.Scene(); +const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); +camera.position.set(0, 5, 20); +camera.lookAt(0, 0, 0); + +// Ambient light +const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); +scene.add(ambientLight); + +// === STARFIELD === +function createStarfield(count = 3000) { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + const sizes = new Float32Array(count); + + for (let i = 0; i < count; i++) { + positions[i * 3] = (Math.random() - 0.5) * 600; + positions[i * 3 + 1] = (Math.random() - 0.5) * 400; + positions[i * 3 + 2] = (Math.random() - 0.5) * 600; + sizes[i] = Math.random() * 1.5 + 0.5; + } + + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + const material = new THREE.PointsMaterial({ + color: NEXUS.colors.star, + size: 0.5, + sizeAttenuation: true, + transparent: true, + opacity: 0.85, + }); + + return new THREE.Points(geometry, material); +} + +const starfield = createStarfield(); +scene.add(starfield); + +// === METEOR SHOWER === +class MeteorShower { + constructor(scene) { + this.scene = scene; + this.meteors = []; + this.scheduleNext(); + } + + // Schedule next shower: random interval 2–5 minutes + scheduleNext() { + const delay = (120 + Math.random() * 180) * 1000; + setTimeout(() => this.trigger(), delay); + } + + trigger() { + const count = 15 + Math.floor(Math.random() * 25); + for (let i = 0; i < count; i++) { + const spawnDelay = i * 150 + Math.random() * 200; + setTimeout(() => this.spawnMeteor(), spawnDelay); + } + this.scheduleNext(); + } + + spawnMeteor() { + const trailLength = 3 + Math.random() * 5; + const speed = 40 + Math.random() * 30; + + // Direction: streaking diagonally across sky (top-right to bottom-left) + const dir = new THREE.Vector3( + -0.5 - Math.random() * 0.5, + -0.8 - Math.random() * 0.2, + -0.1 + Math.random() * 0.2 + ).normalize(); + + // Start high in the sky, spread across x/z + const origin = new THREE.Vector3( + 60 + Math.random() * 80, + 60 + Math.random() * 40, + -100 + Math.random() * 80 + ); + + // Trail: two vertices (head + tail) + const positions = new Float32Array(6); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + + const material = new THREE.LineBasicMaterial({ + color: NEXUS.colors.meteor, + transparent: true, + opacity: 0.9, + }); + + const line = new THREE.Line(geometry, material); + this.scene.add(line); + + const maxLife = (80 + Math.random() * 40) / speed; + + this.meteors.push({ + mesh: line, + pos: origin.clone(), + dir, + speed, + trailLength, + life: 0, + maxLife, + }); + } + + update(delta) { + for (let i = this.meteors.length - 1; i >= 0; i--) { + const m = this.meteors[i]; + m.life += delta; + + // Move meteor forward + m.pos.addScaledVector(m.dir, m.speed * delta); + + // Update head (pos) and tail (pos - dir * trailLength) + const attr = m.mesh.geometry.attributes.position; + attr.array[0] = m.pos.x; + attr.array[1] = m.pos.y; + attr.array[2] = m.pos.z; + attr.array[3] = m.pos.x - m.dir.x * m.trailLength; + attr.array[4] = m.pos.y - m.dir.y * m.trailLength; + attr.array[5] = m.pos.z - m.dir.z * m.trailLength; + attr.needsUpdate = true; + + // Fade out in last 30% of life + const fadeStart = m.maxLife * 0.7; + if (m.life > fadeStart) { + m.mesh.material.opacity = 0.9 * (1 - (m.life - fadeStart) / (m.maxLife - fadeStart)); + } + + // Remove expired or out-of-bounds meteors + if (m.life >= m.maxLife || m.pos.y < -60) { + this.scene.remove(m.mesh); + m.mesh.geometry.dispose(); + m.mesh.material.dispose(); + this.meteors.splice(i, 1); + } + } + } +} + +const meteorShower = new MeteorShower(scene); + +// Expose for manual triggering in dev console +window.triggerMeteorShower = () => meteorShower.trigger(); + +// === ANIMATION LOOP === +const clock = new THREE.Clock(); + +function animate() { + requestAnimationFrame(animate); + + const delta = clock.getDelta(); + + // Slow starfield rotation for ambient motion + starfield.rotation.y += delta * 0.005; + + // Update meteor shower + meteorShower.update(delta); + + renderer.render(scene, camera); +} + +animate(); + +// === RESIZE HANDLER === +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); diff --git a/index.html b/index.html index 34af931..538f619 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,15 @@ + +
@@ -27,5 +36,7 @@ + +