diff --git a/app.js b/app.js index 051fe82..5b44146 100644 --- a/app.js +++ b/app.js @@ -1,27 +1,365 @@ -// ... existing code ... - -// === WEBSOCKET CLIENT === +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { wsClient } from './ws-client.js'; -// Initialize WebSocket client +// === NEXUS COLOR PALETTE === +const NEXUS = { + colors: { + bg: 0x050510, + primary: 0x4af0c0, + secondary: 0x7b2fff, + accent: 0xff6600, + warn: 0xffd700, + textMuted: 0x888888, + shield: { + inner: 0x00ffff, + mid: 0x0088ff, + outer: 0x7b2fff, + }, + terminal: { + body: 0x0a0a1a, + screen: 0x00ffff, + side: 0x0055aa, + }, + }, +}; + +// === RENDERER === +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.shadowMap.enabled = true; +renderer.shadowMap.type = THREE.PCFSoftShadowMap; +document.body.insertBefore(renderer.domElement, document.body.firstChild); + +// === SCENE === +const scene = new THREE.Scene(); +scene.background = new THREE.Color(NEXUS.colors.bg); +scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.018); + +// === CAMERA === +const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 1000); +camera.position.set(0, 6, 16); + +const controls = new OrbitControls(camera, renderer.domElement); +controls.target.set(0, 2, 0); +controls.enableDamping = true; +controls.dampingFactor = 0.05; +controls.minDistance = 4; +controls.maxDistance = 40; + +// === LIGHTING === +scene.add(new THREE.AmbientLight(0x111133, 0.6)); + +const sunLight = new THREE.DirectionalLight(0x8899ff, 0.8); +sunLight.position.set(10, 20, 10); +sunLight.castShadow = true; +sunLight.shadow.mapSize.set(1024, 1024); +scene.add(sunLight); + +// === STARS === +(function buildStars() { + const count = 2000; + const pos = new Float32Array(count * 3); + for (let i = 0; i < count * 3; i++) pos[i] = (Math.random() - 0.5) * 500; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + scene.add(new THREE.Points(geo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.25, sizeAttenuation: true }))); +}()); + +// === FLOOR GRID === +scene.add(new THREE.GridHelper(40, 40, 0x222244, 0x111133)); + +// === BATCAVE TERMINAL === +const batcave = new THREE.Group(); +scene.add(batcave); + +// Raised platform +const platform = new THREE.Mesh( + new THREE.BoxGeometry(9, 0.25, 7), + new THREE.MeshStandardMaterial({ color: 0x12122a, roughness: 0.6, metalness: 0.5 }), +); +platform.position.y = 0.125; +platform.receiveShadow = true; +batcave.add(platform); + +// Platform edge trim (glowing) +const trimGeo = new THREE.EdgesGeometry(new THREE.BoxGeometry(9, 0.25, 7)); +const trimMat = new THREE.LineBasicMaterial({ color: NEXUS.colors.shield.inner, transparent: true, opacity: 0.5 }); +const trim = new THREE.LineSegments(trimGeo, trimMat); +trim.position.y = 0.125; +batcave.add(trim); + +// Terminal desk +batcave.add(Object.assign( + new THREE.Mesh( + new THREE.BoxGeometry(5.5, 0.12, 2.2), + new THREE.MeshStandardMaterial({ color: 0x08081a, roughness: 0.4, metalness: 0.7 }), + ), + { position: new THREE.Vector3(0, 0.31, 0.4) }, +)); + +// Helper: build a monitor at a given position/rotation +function addMonitor(x, y, z, ry, w, h, screenColor) { + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x080818, roughness: 0.3, metalness: 0.8 }); + + const body = new THREE.Mesh(new THREE.BoxGeometry(w + 0.15, h + 0.1, 0.12), bodyMat); + body.position.set(x, y, z); + body.rotation.y = ry; + body.castShadow = true; + batcave.add(body); + + const screenMesh = new THREE.Mesh( + new THREE.PlaneGeometry(w, h), + new THREE.MeshBasicMaterial({ color: screenColor, transparent: true, opacity: 0.85 }), + ); + screenMesh.position.set(x, y, z); + screenMesh.rotation.y = ry; + screenMesh.translateZ(0.07); + batcave.add(screenMesh); + + const glow = new THREE.PointLight(screenColor, 0.6, 4); + glow.position.set(x, y, z + 0.5 * Math.cos(ry)); + batcave.add(glow); + + return { screenMesh, glow }; +} + +const mainMon = addMonitor(0, 2.2, -0.55, 0, 2.8, 1.8, NEXUS.colors.terminal.screen); +const leftMon = addMonitor(-2.8, 1.8, -0.3, 0.35, 1.4, 1.0, NEXUS.colors.terminal.side); +const rightMon = addMonitor( 2.8, 1.8, -0.3, -0.35, 1.4, 1.0, NEXUS.colors.terminal.side); + +// Monitor stands +for (const mx of [-2.8, 0, 2.8]) { + batcave.add(Object.assign( + new THREE.Mesh( + new THREE.CylinderGeometry(0.06, 0.1, 0.9, 8), + new THREE.MeshStandardMaterial({ color: 0x080818, roughness: 0.5, metalness: 0.6 }), + ), + { position: new THREE.Vector3(mx, 0.8, -0.5) }, + )); +} + +// Keyboard +batcave.add(Object.assign( + new THREE.Mesh( + new THREE.BoxGeometry(1.6, 0.05, 0.6), + new THREE.MeshStandardMaterial({ color: 0x080818, roughness: 0.5, metalness: 0.6 }), + ), + { position: new THREE.Vector3(0, 0.4, 0.8) }, +)); + +// Corner pillars (structural, receive shield glow) +const pillarPositions = [[-4, -3], [-4, 3], [4, -3], [4, 3]]; +const pillarMeshes = []; +const pillarLights = []; + +for (const [px, pz] of pillarPositions) { + const pillar = new THREE.Mesh( + new THREE.CylinderGeometry(0.18, 0.22, 5, 8), + new THREE.MeshStandardMaterial({ color: 0x0a0a20, roughness: 0.7, metalness: 0.4 }), + ); + pillar.position.set(px, 2.5, pz); + pillar.castShadow = true; + batcave.add(pillar); + + // Energy column running up the pillar + const column = new THREE.Mesh( + new THREE.CylinderGeometry(0.04, 0.04, 4.6, 8), + new THREE.MeshBasicMaterial({ + color: NEXUS.colors.shield.inner, + transparent: true, + opacity: 0.6, + blending: THREE.AdditiveBlending, + depthWrite: false, + }), + ); + column.position.set(px, 2.5, pz); + batcave.add(column); + pillarMeshes.push(column); + + const pl = new THREE.PointLight(NEXUS.colors.shield.inner, 0.7, 4); + pl.position.set(px, 2.5, pz); + scene.add(pl); + pillarLights.push(pl); +} + +// === ENERGY SHIELD SHADERS === +const shieldVert = /* glsl */` + varying vec3 vNormal; + varying vec3 vPosition; + varying vec2 vUv; + void main() { + vNormal = normalize(normalMatrix * normal); + vPosition = position; + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; + +const shieldFrag = /* glsl */` + uniform float uTime; + uniform vec3 uColor; + uniform float uOpacity; + + varying vec3 vNormal; + varying vec3 vPosition; + varying vec2 vUv; + + // Hexagonal tiling + vec2 hexGrid(vec2 p) { + vec2 r = vec2(1.0, 1.732); + vec2 h = r * 0.5; + vec2 a = mod(p, r) - h; + vec2 b = mod(p - h, r) - h; + return dot(a, a) < dot(b, b) ? a : b; + } + + void main() { + // Fresnel rim + vec3 viewDir = normalize(cameraPosition - vPosition); + float fresnel = 1.0 - abs(dot(vNormal, viewDir)); + fresnel = pow(fresnel, 1.8); + + // Scrolling hex grid + vec2 uv = vUv * 10.0; + uv.x += uTime * 0.04; + uv.y += uTime * 0.025; + vec2 hex = hexGrid(uv); + float dist = length(hex); + float grid = smoothstep(0.44, 0.48, dist); + + // Scan line + float scan = 0.5 + 0.5 * sin(vPosition.y * 4.0 - uTime * 3.0); + scan = pow(scan, 6.0) * 0.4; + + // Impact ripple from origin + float ripple = fract(length(vPosition.xz) * 0.4 - uTime * 0.6); + ripple = pow(1.0 - abs(ripple - 0.5) * 2.0, 4.0) * 0.3; + + float alpha = (fresnel * 0.55 + grid * 0.25 + scan + ripple) * uOpacity; + vec3 color = uColor * (1.0 + 0.4 * scan); + + gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0)); + } +`; + +// Build shield domes +const shieldDefs = [ + { r: 5.8, color: NEXUS.colors.shield.inner, opacity: 0.22, speed: 1.0, phase: 0.0 }, + { r: 6.6, color: NEXUS.colors.shield.mid, opacity: 0.15, speed: 0.65, phase: 1.2 }, + { r: 7.4, color: NEXUS.colors.shield.outer, opacity: 0.10, speed: 0.4, phase: 2.5 }, +]; + +const shieldMeshes = []; + +for (const def of shieldDefs) { + const uniforms = { + uTime: { value: def.phase }, + uColor: { value: new THREE.Color(def.color) }, + uOpacity: { value: def.opacity }, + }; + const mat = new THREE.ShaderMaterial({ + vertexShader: shieldVert, + fragmentShader: shieldFrag, + uniforms, + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + // Hemisphere dome (open bottom) + const geo = new THREE.SphereGeometry(def.r, 48, 48, 0, Math.PI * 2, 0, Math.PI * 0.62); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(0, 0, 0); + mesh.userData.speed = def.speed; + scene.add(mesh); + shieldMeshes.push(mesh); +} + +// Equatorial rings +const ringDefs = [ + { y: 0.15, r: 5.8, color: NEXUS.colors.shield.inner, phase: 0.0 }, + { y: 2.2, r: 5.5, color: NEXUS.colors.shield.mid, phase: 1.5 }, + { y: 4.0, r: 4.8, color: NEXUS.colors.shield.outer, phase: 3.0 }, +]; + +const ringMeshes = []; + +for (const rd of ringDefs) { + const ring = new THREE.Mesh( + new THREE.TorusGeometry(rd.r, 0.035, 8, 80), + new THREE.MeshBasicMaterial({ + color: rd.color, + transparent: true, + opacity: 0.55, + blending: THREE.AdditiveBlending, + depthWrite: false, + }), + ); + ring.rotation.x = Math.PI / 2; + ring.position.y = rd.y; + ring.userData = { baseY: rd.y, phase: rd.phase }; + scene.add(ring); + ringMeshes.push(ring); +} + +// === ANIMATION LOOP === +const clock = new THREE.Clock(); + +function animate() { + requestAnimationFrame(animate); + const t = clock.getElapsedTime(); + + // Shield domes: time uniform + subtle breathing + for (const s of shieldMeshes) { + s.material.uniforms.uTime.value = t * s.userData.speed; + const breathe = 1.0 + 0.012 * Math.sin(t * 1.3 * s.userData.speed); + s.scale.setScalar(breathe); + } + + // Equatorial rings: pulse opacity + scale + for (const r of ringMeshes) { + const phase = r.userData.phase; + r.material.opacity = 0.3 + 0.35 * Math.abs(Math.sin(t * 1.4 + phase)); + const rs = 1.0 + 0.04 * Math.sin(t * 2.0 + phase); + r.scale.set(rs, rs, 1); + } + + // Pillar columns + lights + for (let i = 0; i < pillarMeshes.length; i++) { + const tOffset = i * 0.8; + pillarMeshes[i].material.opacity = 0.35 + 0.35 * Math.sin(t * 2.5 + tOffset); + pillarLights[i].intensity = 0.4 + 0.5 * Math.sin(t * 2.0 + tOffset); + } + + // Monitor screen flicker + const flicker = 0.82 + 0.12 * (Math.sin(t * 11.3) * Math.sin(t * 2.7)); + mainMon.screenMesh.material.opacity = flicker; + mainMon.glow.intensity = 0.5 + 0.3 * flicker; + leftMon.screenMesh.material.opacity = 0.7 + 0.15 * Math.sin(t * 3.1 + 1.0); + rightMon.screenMesh.material.opacity = 0.7 + 0.15 * Math.sin(t * 3.1 + 2.0); + + // Trim pulse + trimMat.opacity = 0.3 + 0.2 * Math.sin(t * 1.8); + + controls.update(); + renderer.render(scene, camera); +} + +animate(); + +// === RESIZE HANDLER === +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); + +// === WEBSOCKET CLIENT === wsClient.connect(); -// Handle WebSocket events -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); -}); - -// Clean up on page unload -window.addEventListener('beforeunload', () => { - wsClient.disconnect(); -}); - -// ... existing code ... +window.addEventListener('player-joined', (e) => console.log('Player joined:', e.detail)); +window.addEventListener('player-left', (e) => console.log('Player left:', e.detail)); +window.addEventListener('chat-message', (e) => console.log('Chat message:', e.detail)); +window.addEventListener('beforeunload', () => wsClient.disconnect()); diff --git a/index.html b/index.html index 34af931..2274dd0 100644 --- a/index.html +++ b/index.html @@ -14,18 +14,30 @@ + +
- + - -