From caf44eda21e786615c5baa0a88f87a190e65cbd4 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:08:34 -0400 Subject: [PATCH] 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 --- app.js | 198 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 193 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 5a028f6..6f4706c 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { wsClient } from './ws-client.js'; // === COLOR PALETTE === const NEXUS = { @@ -9,6 +10,9 @@ const NEXUS = { constellationLine: 0x334488, constellationFade: 0x112244, accent: 0x4488ff, + island: 0x3d2b1a, + grass: 0x2a5220, + rock: 0x5a4535, } }; @@ -17,7 +21,7 @@ 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, 0, 5); +camera.position.set(0, 10, 50); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); @@ -112,6 +116,184 @@ function buildConstellationLines() { 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; @@ -127,7 +309,7 @@ document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => { let overviewMode = false; let overviewT = 0; // 0 = normal view, 1 = overview -const NORMAL_CAM = new THREE.Vector3(0, 0, 5); +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'); @@ -166,7 +348,7 @@ function animate() { const targetT = overviewMode ? 1 : 0; overviewT += (targetT - overviewT) * 0.04; camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT); - camera.lookAt(0, 0, 0); + camera.lookAt(0, 5, 0); // Slow auto-rotation — suppressed during overview so the map stays readable const rotationScale = 1 - overviewT; @@ -182,6 +364,14 @@ function animate() { // 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); } @@ -210,8 +400,6 @@ document.getElementById('debug-toggle').addEventListener('click', () => { }); // === WEBSOCKET CLIENT === -import { wsClient } from './ws-client.js'; - wsClient.connect(); window.addEventListener('player-joined', (/** @type {CustomEvent} */ event) => { -- 2.43.0