Files
the-nexus/app.js
Alexander Whitestone dc1994a1b8
Some checks failed
CI / validate (pull_request) Has been cancelled
feat: add animated energy shields around Batcave terminal (#112)
- Build full Three.js 0.183 scene: camera, lighting, star field, floor grid
- Create Batcave terminal: raised platform, desk, main + side monitors with
  flickering screen materials and point lights, keyboard, corner pillars
- Add three-layer shader-based energy shield domes with:
  - Custom GLSL hex-grid pattern, Fresnel rim effect, scan-line and ripple fx
  - AdditiveBlending + DoubleSide transparency for holographic look
  - Per-shield breathing/scale pulse keyed off uTime uniform
- Add three equatorial torus rings that pulse opacity and scale
- Add glowing energy columns on each corner pillar with PointLights
- Wire up OrbitControls, resize handler, WebSocket client

Fixes #112
2026-03-23 23:59:22 -04:00

366 lines
11 KiB
JavaScript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { wsClient } from './ws-client.js';
// === 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();
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());