Some checks failed
CI / validate (pull_request) Has been cancelled
- 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
366 lines
11 KiB
JavaScript
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());
|