Some checks failed
CI / validate (pull_request) Failing after 18s
- 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>
420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
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();
|
||
});
|