Files
the-nexus/app.js
Alexander Whitestone caf44eda21
Some checks failed
CI / validate (pull_request) Failing after 18s
feat: add reflective water plane beneath floating island (#107)
- Add ambient + directional lighting (sun + fill) for island materials
- Add floating island: organic CylinderGeometry rock body with distorted
  lower vertices, grass top surface, scattered accent rocks, and a glowing
  central beacon tower with PointLight
- Add animated reflective water plane via custom ShaderMaterial:
  - Vertex: layered sine-wave displacement along world-up axis with
    gradient-based surface normals (PlaneGeometry rotated -PI/2)
  - Fragment: Fresnel-based deep/shallow/sky color mixing, Blinn-Phong
    sun specular, soft glow, and micro-caustic shimmer pattern
- Move camera to (0,10,50) / lookAt (0,5,0) to frame island + water
- Add island float animation and beacon pulse in the render loop
- Move wsClient import to file top (was mid-file on main)
- Add island + water colors to NEXUS.colors palette

Fixes #107

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:08:34 -04:00

420 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { wsClient } from './ws-client.js';
// === COLOR PALETTE ===
const NEXUS = {
colors: {
bg: 0x000008,
starCore: 0xffffff,
starDim: 0x8899cc,
constellationLine: 0x334488,
constellationFade: 0x112244,
accent: 0x4488ff,
island: 0x3d2b1a,
grass: 0x2a5220,
rock: 0x5a4535,
}
};
// === SCENE SETUP ===
const scene = new THREE.Scene();
scene.background = new THREE.Color(NEXUS.colors.bg);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 10, 50);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// === STAR FIELD ===
const STAR_COUNT = 800;
const STAR_SPREAD = 400;
const CONSTELLATION_DISTANCE = 30; // max distance to draw a line between stars
const starPositions = [];
const starGeo = new THREE.BufferGeometry();
const posArray = new Float32Array(STAR_COUNT * 3);
const sizeArray = new Float32Array(STAR_COUNT);
for (let i = 0; i < STAR_COUNT; i++) {
const x = (Math.random() - 0.5) * STAR_SPREAD;
const y = (Math.random() - 0.5) * STAR_SPREAD;
const z = (Math.random() - 0.5) * STAR_SPREAD;
posArray[i * 3] = x;
posArray[i * 3 + 1] = y;
posArray[i * 3 + 2] = z;
sizeArray[i] = Math.random() * 2.5 + 0.5;
starPositions.push(new THREE.Vector3(x, y, z));
}
starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(sizeArray, 1));
const starMaterial = new THREE.PointsMaterial({
color: NEXUS.colors.starCore,
size: 0.6,
sizeAttenuation: true,
transparent: true,
opacity: 0.9,
});
const stars = new THREE.Points(starGeo, starMaterial);
scene.add(stars);
// === CONSTELLATION LINES ===
// Connect nearby stars with faint lines, limited to avoid clutter
/**
* Builds constellation line segments connecting nearby stars.
* @returns {THREE.LineSegments}
*/
function buildConstellationLines() {
const linePositions = [];
const MAX_CONNECTIONS_PER_STAR = 3;
const connectionCount = new Array(STAR_COUNT).fill(0);
for (let i = 0; i < STAR_COUNT; i++) {
if (connectionCount[i] >= MAX_CONNECTIONS_PER_STAR) continue;
// Find nearest neighbors
const neighbors = [];
for (let j = i + 1; j < STAR_COUNT; j++) {
if (connectionCount[j] >= MAX_CONNECTIONS_PER_STAR) continue;
const dist = starPositions[i].distanceTo(starPositions[j]);
if (dist < CONSTELLATION_DISTANCE) {
neighbors.push({ j, dist });
}
}
// Sort by distance and connect closest ones
neighbors.sort((/** @type {{j: number, dist: number}} */ a, /** @type {{j: number, dist: number}} */ b) => a.dist - b.dist);
const toConnect = neighbors.slice(0, MAX_CONNECTIONS_PER_STAR - connectionCount[i]);
for (const { j } of toConnect) {
linePositions.push(
starPositions[i].x, starPositions[i].y, starPositions[i].z,
starPositions[j].x, starPositions[j].y, starPositions[j].z
);
connectionCount[i]++;
connectionCount[j]++;
}
}
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(linePositions), 3));
const lineMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.constellationLine,
transparent: true,
opacity: 0.18,
});
return new THREE.LineSegments(lineGeo, lineMat);
}
const constellationLines = buildConstellationLines();
scene.add(constellationLines);
// === LIGHTING ===
const ambientLight = new THREE.AmbientLight(0x223355, 0.7);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xfff0cc, 1.8);
sunLight.position.set(40, 80, 30);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(1024, 1024);
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 300;
sunLight.shadow.camera.left = sunLight.shadow.camera.bottom = -60;
sunLight.shadow.camera.right = sunLight.shadow.camera.top = 60;
scene.add(sunLight);
const fillLight = new THREE.DirectionalLight(0x4488ff, 0.3);
fillLight.position.set(-30, 10, -40);
scene.add(fillLight);
// === FLOATING ISLAND ===
const islandGroup = new THREE.Group();
const rockMat = new THREE.MeshStandardMaterial({ color: NEXUS.colors.island, roughness: 0.92, metalness: 0.06 });
const grassMat = new THREE.MeshStandardMaterial({ color: NEXUS.colors.grass, roughness: 0.85, metalness: 0.0 });
// Organic rock body — distort lower vertices for an irregular silhouette
const bodyGeo = new THREE.CylinderGeometry(14, 9, 10, 10, 2);
(function distortBody() {
const pos = bodyGeo.attributes.position;
for (let i = 0; i < pos.count; i++) {
if (pos.getY(i) < 3) {
pos.setX(i, pos.getX(i) + (Math.random() - 0.5) * 4);
pos.setZ(i, pos.getZ(i) + (Math.random() - 0.5) * 4);
pos.setY(i, pos.getY(i) + (Math.random() - 0.5) * 2);
}
}
pos.needsUpdate = true;
bodyGeo.computeVertexNormals();
})();
const islandBody = new THREE.Mesh(bodyGeo, rockMat);
islandBody.position.y = 12;
islandBody.castShadow = true;
islandBody.receiveShadow = true;
islandGroup.add(islandBody);
// Grass top
const topMesh = new THREE.Mesh(new THREE.CylinderGeometry(13.5, 14, 2.5, 10), grassMat);
topMesh.position.y = 18.5;
topMesh.castShadow = true;
topMesh.receiveShadow = true;
islandGroup.add(topMesh);
// Scattered rocks
const accentMat = new THREE.MeshStandardMaterial({ color: NEXUS.colors.rock, roughness: 0.9, metalness: 0.05 });
for (let i = 0; i < 6; i++) {
const size = 0.8 + Math.random() * 2.2;
const mesh = new THREE.Mesh(new THREE.DodecahedronGeometry(size, 0), accentMat);
const angle = (i / 6) * Math.PI * 2 + Math.random() * 0.5;
mesh.position.set(Math.cos(angle) * (2 + Math.random() * 9), 20.5, Math.sin(angle) * (2 + Math.random() * 9));
mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
mesh.castShadow = true;
islandGroup.add(mesh);
}
// Central beacon tower
const beaconMat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.accent, roughness: 0.25, metalness: 0.85,
emissive: new THREE.Color(NEXUS.colors.accent), emissiveIntensity: 0.35,
});
const beacon = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 1.0, 7, 8), beaconMat);
beacon.position.y = 24.5;
beacon.castShadow = true;
islandGroup.add(beacon);
const beaconLight = new THREE.PointLight(NEXUS.colors.accent, 1.2, 40);
beaconLight.position.y = 29;
islandGroup.add(beaconLight);
scene.add(islandGroup);
// === REFLECTIVE WATER PLANE ===
//
// PlaneGeometry lies in local XY; after rotation.x = -PI/2:
// local X → world X, local Y → world -Z, local Z → world Y (up)
// We displace pos.z to move vertices up/down; wave coords use pos.x & pos.y.
const WATER_VERT = `
uniform float uTime;
varying vec3 vWorldPos;
varying vec3 vWorldNormal;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 pos = position;
// Layered wave displacement along local Z (= world Y after -PI/2 X rotation)
float w1 = sin(pos.x * 0.05 + uTime * 0.70) * cos(pos.y * 0.04 + uTime * 0.50) * 0.70;
float w2 = sin(pos.x * 0.03 - uTime * 0.45) * sin(pos.y * 0.07 + uTime * 0.60) * 0.45;
float w3 = cos((pos.x + pos.y) * 0.04 + uTime * 0.80) * 0.30;
pos.z += w1 + w2 + w3;
// Partial derivatives for approximate surface normal
float dzdx = cos(pos.x * 0.05 + uTime * 0.70) * cos(pos.y * 0.04 + uTime * 0.50) * 0.05 * 0.70
+ cos(pos.x * 0.03 - uTime * 0.45) * sin(pos.y * 0.07 + uTime * 0.60) * 0.03 * 0.45
- sin((pos.x + pos.y) * 0.04 + uTime * 0.80) * 0.04 * 0.30;
float dzdy = -sin(pos.x * 0.05 + uTime * 0.70) * sin(pos.y * 0.04 + uTime * 0.50) * 0.04 * 0.70
+ sin(pos.x * 0.03 - uTime * 0.45) * cos(pos.y * 0.07 + uTime * 0.60) * 0.07 * 0.45
- sin((pos.x + pos.y) * 0.04 + uTime * 0.80) * 0.04 * 0.30;
// Local normal N = T×B = (-dzdx, -dzdy, 1) then transform to world space
vWorldNormal = normalize(mat3(modelMatrix) * normalize(vec3(-dzdx, -dzdy, 1.0)));
vec4 wp = modelMatrix * vec4(pos, 1.0);
vWorldPos = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const WATER_FRAG = `
uniform float uTime;
uniform vec3 uSunDir;
uniform vec3 uCamPos;
varying vec3 vWorldPos;
varying vec3 vWorldNormal;
varying vec2 vUv;
void main() {
vec3 n = normalize(vWorldNormal);
vec3 viewDir = normalize(uCamPos - vWorldPos);
// Fresnel: more reflection at grazing angles
float fresnel = pow(1.0 - clamp(dot(n, viewDir), 0.0, 1.0), 3.5);
vec3 deepColor = vec3(0.00, 0.04, 0.16);
vec3 midColor = vec3(0.01, 0.18, 0.44);
vec3 skyColor = vec3(0.03, 0.27, 0.62);
vec3 color = mix(deepColor, midColor, fresnel * 0.75);
color = mix(color, skyColor, fresnel * 0.50);
// Sun specular (Blinn-Phong)
vec3 halfDir = normalize(uSunDir + viewDir);
float spec = pow(clamp(dot(n, halfDir), 0.0, 1.0), 300.0);
color += vec3(1.0, 0.94, 0.82) * spec * 1.4;
// Soft broad glow
float spec2 = pow(clamp(dot(n, halfDir), 0.0, 1.0), 18.0);
color += vec3(0.07, 0.25, 0.55) * spec2 * 0.22;
// Surface shimmer (micro-caustic pattern)
float s = sin(vUv.x * 55.0 + uTime * 1.8) * sin(vUv.y * 45.0 + uTime * 1.4);
color += smoothstep(0.88, 1.0, s) * 0.12 * vec3(0.45, 0.75, 1.0);
gl_FragColor = vec4(color, mix(0.78, 0.94, fresnel));
}
`;
const waterUniforms = {
uTime: { value: 0.0 },
uSunDir: { value: new THREE.Vector3(40, 80, 30).normalize() },
uCamPos: { value: camera.position.clone() },
};
const waterMesh = new THREE.Mesh(
new THREE.PlaneGeometry(400, 400, 70, 70),
new THREE.ShaderMaterial({
vertexShader: WATER_VERT,
fragmentShader: WATER_FRAG,
uniforms: waterUniforms,
transparent: true,
side: THREE.FrontSide,
})
);
waterMesh.rotation.x = -Math.PI / 2;
waterMesh.position.y = 0;
scene.add(waterMesh);
// === MOUSE-DRIVEN ROTATION ===
let mouseX = 0;
let mouseY = 0;
let targetRotX = 0;
let targetRotY = 0;
document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
});
// === OVERVIEW MODE (Tab — bird's-eye view of the whole Nexus) ===
let overviewMode = false;
let overviewT = 0; // 0 = normal view, 1 = overview
const NORMAL_CAM = new THREE.Vector3(0, 10, 50);
const OVERVIEW_CAM = new THREE.Vector3(0, 200, 0.1); // overhead; tiny Z offset avoids gimbal lock
const overviewIndicator = document.getElementById('overview-indicator');
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
overviewMode = !overviewMode;
if (overviewMode) {
overviewIndicator.classList.add('visible');
} else {
overviewIndicator.classList.remove('visible');
}
}
});
// === RESIZE HANDLER ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
/**
* Main animation loop — called each frame via requestAnimationFrame.
* @returns {void}
*/
function animate() {
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
// Smooth camera transition for overview mode
const targetT = overviewMode ? 1 : 0;
overviewT += (targetT - overviewT) * 0.04;
camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
camera.lookAt(0, 5, 0);
// Slow auto-rotation — suppressed during overview so the map stays readable
const rotationScale = 1 - overviewT;
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
stars.rotation.x = (targetRotX + elapsed * 0.01) * rotationScale;
stars.rotation.y = (targetRotY + elapsed * 0.015) * rotationScale;
constellationLines.rotation.x = stars.rotation.x;
constellationLines.rotation.y = stars.rotation.y;
// Subtle pulse on constellation opacity
constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06;
// Animate water
waterUniforms.uTime.value = elapsed;
waterUniforms.uCamPos.value.copy(camera.position);
// Gently float the island
islandGroup.position.y = Math.sin(elapsed * 0.28) * 1.2;
beaconLight.intensity = 1.0 + Math.sin(elapsed * 2.1) * 0.35;
renderer.render(scene, camera);
}
animate();
// === DEBUG MODE ===
let debugMode = false;
document.getElementById('debug-toggle').addEventListener('click', () => {
debugMode = !debugMode;
document.getElementById('debug-toggle').style.backgroundColor = debugMode
? 'var(--color-text-muted)'
: 'var(--color-secondary)';
console.log(`Debug mode ${debugMode ? 'enabled' : 'disabled'}`);
if (debugMode) {
// Example: Visualize all collision boxes and light sources
// Replace with actual logic when available
document.querySelectorAll('.collision-box').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px solid red');
document.querySelectorAll('.light-source').forEach((/** @type {HTMLElement} */ el) => el.style.outline = '2px dashed yellow');
} else {
document.querySelectorAll('.collision-box, .light-source').forEach((/** @type {HTMLElement} */ el) => {
el.style.outline = 'none';
});
}
});
// === WEBSOCKET CLIENT ===
wsClient.connect();
window.addEventListener('player-joined', (/** @type {CustomEvent} */ event) => {
console.log('Player joined:', event.detail);
});
window.addEventListener('player-left', (/** @type {CustomEvent} */ event) => {
console.log('Player left:', event.detail);
});
window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => {
console.log('Chat message:', event.detail);
});
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});