Files
the-nexus/app.js
Alexander Whitestone ec6dc0aa01
Some checks failed
CI / validate (pull_request) Has been cancelled
feat: add lens flare effect for bright light sources (#109)
Implement Three.js scene with procedural lens flare using CanvasTexture:
- Build radial glow, star-burst streak, ring, dot, and hex aperture
  textures from canvas draws — no image assets required
- Attach Lensflare + LensflareElement chain to the sun PointLight;
  Three.js handles screen-space projection and occlusion automatically
- Sun arcs slowly across sky, so flare angle and ghost positions shift
  over time giving a live optical effect
- Add starfield (4 000 points), grid floor, and portal rings loaded
  from portals.json to give the flare context in a real 3D scene
- Wire audio toggle, chat panel, and WebSocket events

Fixes #109

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:42 -04:00

381 lines
13 KiB
JavaScript

import * as THREE from 'three';
import { Lensflare, LensflareElement } from 'three/addons/objects/Lensflare.js';
import { wsClient } from './ws-client.js';
// === COLOR PALETTE ===
const NEXUS = {
colors: {
bg: 0x0a0a0f,
primary: 0x00ffcc,
secondary: 0xff6600,
accent: 0x8866ff,
sun: 0xffd4a0,
text: 0xe0e0f0,
muted: 0x334455,
}
};
// === RENDERER ===
const canvas = document.getElementById('nexus-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
// === SCENE / CAMERA ===
const scene = new THREE.Scene();
scene.background = new THREE.Color(NEXUS.colors.bg);
scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.0018);
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 8, 40);
// === AMBIENT LIGHTING ===
scene.add(new THREE.AmbientLight(0x111122, 0.6));
// === SUN LIGHT + LENS FLARE ===
const sunLight = new THREE.PointLight(NEXUS.colors.sun, 3.0, 800, 1.2);
sunLight.position.set(120, 90, -220);
scene.add(sunLight);
// Build procedural canvas textures for flare elements
function makeFlareTexture(size, drawFn) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
drawFn(canvas.getContext('2d'), size);
return new THREE.CanvasTexture(canvas);
}
function hexToRgb(hex) {
return [(hex >> 16) & 0xff, (hex >> 8) & 0xff, hex & 0xff];
}
// Radial glow — main sun disc
const texGlow = makeFlareTexture(256, (ctx, s) => {
const [r, g, b] = hexToRgb(NEXUS.colors.sun);
const grad = ctx.createRadialGradient(s/2, s/2, 0, s/2, s/2, s/2);
grad.addColorStop(0.0, `rgba(${r},${g},${b},1.0)`);
grad.addColorStop(0.15, `rgba(${r},${g},${b},0.9)`);
grad.addColorStop(0.5, `rgba(${r},${g},${b},0.3)`);
grad.addColorStop(1.0, `rgba(${r},${g},${b},0.0)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, s, s);
});
// Star-burst cross streaks
const texStreak = makeFlareTexture(256, (ctx, s) => {
const c = s / 2;
const drawLine = (angle, alpha) => {
ctx.save();
ctx.translate(c, c);
ctx.rotate(angle);
const g = ctx.createLinearGradient(-c, 0, c, 0);
g.addColorStop(0.0, 'rgba(255,255,255,0)');
g.addColorStop(0.35, `rgba(255,255,255,${alpha * 0.6})`);
g.addColorStop(0.5, `rgba(255,255,255,${alpha})`);
g.addColorStop(0.65, `rgba(255,255,255,${alpha * 0.6})`);
g.addColorStop(1.0, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(-c, -1.5, s, 3);
ctx.restore();
};
drawLine(0, 0.9);
drawLine(Math.PI / 2, 0.9);
drawLine(Math.PI / 4, 0.5);
drawLine(-Math.PI / 4, 0.5);
});
// Ring — lens ghost ring
const texRing = makeFlareTexture(128, (ctx, s) => {
const [r, g, b] = hexToRgb(NEXUS.colors.primary);
const c = s / 2;
const grad = ctx.createRadialGradient(c, c, c * 0.55, c, c, c);
grad.addColorStop(0.0, `rgba(${r},${g},${b},0.0)`);
grad.addColorStop(0.5, `rgba(${r},${g},${b},0.8)`);
grad.addColorStop(0.8, `rgba(${r},${g},${b},0.4)`);
grad.addColorStop(1.0, `rgba(${r},${g},${b},0.0)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, s, s);
});
// Small soft dot — lens ghosts
const texDot = makeFlareTexture(64, (ctx, s) => {
const c = s / 2;
const grad = ctx.createRadialGradient(c, c, 0, c, c, c);
grad.addColorStop(0.0, 'rgba(255,255,255,1.0)');
grad.addColorStop(0.4, 'rgba(255,200,150,0.6)');
grad.addColorStop(1.0, 'rgba(255,200,150,0.0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, s, s);
});
// Hexagonal aperture ghost
const texHex = makeFlareTexture(64, (ctx, s) => {
const c = s / 2;
const r = c * 0.85;
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (i / 6) * Math.PI * 2 - Math.PI / 6;
const x = c + r * Math.cos(angle);
const y = c + r * Math.sin(angle);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
const [r2, g2, b2] = hexToRgb(NEXUS.colors.accent);
const grad = ctx.createRadialGradient(c, c, 0, c, c, r);
grad.addColorStop(0.0, `rgba(${r2},${g2},${b2},0.0)`);
grad.addColorStop(0.7, `rgba(${r2},${g2},${b2},0.5)`);
grad.addColorStop(1.0, `rgba(${r2},${g2},${b2},0.0)`);
ctx.fillStyle = grad;
ctx.fill();
});
// Build the Lensflare object and attach to sun light
const lensflare = new Lensflare();
// Main glow at the source (distance = 0)
lensflare.addElement(new LensflareElement(texGlow, 600, 0.0, new THREE.Color(NEXUS.colors.sun)));
// Star-burst streaks at source
lensflare.addElement(new LensflareElement(texStreak, 300, 0.0, new THREE.Color(0xffffff)));
// Cyan ring ghost — 60% of the way from center to light
lensflare.addElement(new LensflareElement(texRing, 180, 0.6, new THREE.Color(NEXUS.colors.primary)));
// Orange dot ghost
lensflare.addElement(new LensflareElement(texDot, 80, 0.7, new THREE.Color(NEXUS.colors.secondary)));
// Purple hex ghost
lensflare.addElement(new LensflareElement(texHex, 70, 0.82, new THREE.Color(NEXUS.colors.accent)));
// Small white ghost
lensflare.addElement(new LensflareElement(texDot, 50, 0.9, new THREE.Color(0xffffff)));
// Small secondary ring
lensflare.addElement(new LensflareElement(texRing, 90, 1.0, new THREE.Color(NEXUS.colors.secondary)));
sunLight.add(lensflare);
// Second smaller fill light (no flare)
const fillLight = new THREE.DirectionalLight(0x223355, 0.4);
fillLight.position.set(-1, 0.5, 1);
scene.add(fillLight);
// === STARFIELD ===
(function buildStarfield() {
const count = 4000;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const phi = Math.acos(2 * Math.random() - 1);
const theta = Math.random() * Math.PI * 2;
const r = 700 + Math.random() * 500;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
const brightness = 0.5 + Math.random() * 0.5;
// Slight blue/white tint variation
colors[i * 3] = brightness * (0.85 + Math.random() * 0.15);
colors[i * 3 + 1] = brightness * (0.85 + Math.random() * 0.15);
colors[i * 3 + 2] = brightness;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({
size: 0.9,
vertexColors: true,
sizeAttenuation: false,
})));
}());
// === NEXUS FLOOR ===
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(300, 300),
new THREE.MeshStandardMaterial({ color: 0x050510, roughness: 0.9, metalness: 0.1 })
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
scene.add(new THREE.GridHelper(300, 60, NEXUS.colors.primary, NEXUS.colors.muted));
// === PORTALS (from portals.json) ===
async function loadPortals() {
let portals;
try {
const res = await fetch('./portals.json');
portals = await res.json();
} catch {
return;
}
const ringGeo = new THREE.TorusGeometry(2.2, 0.12, 16, 64);
const innerGeo = new THREE.CircleGeometry(2.0, 64);
const pillarGeo = new THREE.CylinderGeometry(0.18, 0.18, 6, 8);
portals.forEach(portal => {
const color = new THREE.Color(portal.color || '#00ffcc');
const group = new THREE.Group();
group.position.set(
portal.position.x,
portal.position.y + 3,
portal.position.z
);
if (portal.rotation?.y) group.rotation.y = portal.rotation.y;
// Portal ring
const ring = new THREE.Mesh(ringGeo, new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.6,
roughness: 0.3,
metalness: 0.8,
}));
group.add(ring);
// Inner shimmer plane
const inner = new THREE.Mesh(innerGeo, new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.12,
side: THREE.DoubleSide,
}));
group.add(inner);
// Pillars
const pillarMat = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.2,
roughness: 0.4,
metalness: 0.7,
});
[-2.5, 2.5].forEach(dx => {
const pillar = new THREE.Mesh(pillarGeo, pillarMat);
pillar.position.set(dx, -3, 0);
group.add(pillar);
});
// Point light in portal
const pLight = new THREE.PointLight(color, 1.2, 18, 2);
group.add(pLight);
scene.add(group);
});
}
loadPortals();
// === CAMERA ORBIT CONTROLS ===
let isPointerDown = false;
let lastX = 0, lastY = 0;
let cameraYaw = 0;
let cameraPitch = 0.15;
let cameraRadius = 40;
const cameraTarget = new THREE.Vector3(0, 2, 0);
function onPointerDown(x, y) { isPointerDown = true; lastX = x; lastY = y; }
function onPointerUp() { isPointerDown = false; }
function onPointerMove(x, y) {
if (!isPointerDown) return;
cameraYaw -= (x - lastX) * 0.005;
cameraPitch -= (y - lastY) * 0.005;
cameraPitch = Math.max(-Math.PI / 2.5, Math.min(Math.PI / 3, cameraPitch));
lastX = x; lastY = y;
}
renderer.domElement.addEventListener('mousedown', e => onPointerDown(e.clientX, e.clientY));
renderer.domElement.addEventListener('mouseup', onPointerUp);
renderer.domElement.addEventListener('mousemove', e => onPointerMove(e.clientX, e.clientY));
renderer.domElement.addEventListener('touchstart', e => onPointerDown(e.touches[0].clientX, e.touches[0].clientY), { passive: true });
renderer.domElement.addEventListener('touchend', onPointerUp);
renderer.domElement.addEventListener('touchmove', e => { onPointerMove(e.touches[0].clientX, e.touches[0].clientY); e.preventDefault(); }, { passive: false });
renderer.domElement.addEventListener('wheel', e => {
cameraRadius = Math.max(6, Math.min(160, cameraRadius + e.deltaY * 0.06));
}, { passive: true });
// === RESIZE ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// === AUDIO TOGGLE ===
const audioEl = document.getElementById('ambient-sound');
const audioBtn = document.getElementById('audio-toggle');
if (audioBtn && audioEl) {
audioBtn.addEventListener('click', () => {
if (audioEl.paused) {
audioEl.play().catch(() => {});
audioBtn.textContent = '🔊';
audioBtn.classList.remove('muted');
} else {
audioEl.pause();
audioBtn.textContent = '🔇';
audioBtn.classList.add('muted');
}
});
}
// === CHAT ===
const chatMessages = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
function appendChatMessage(text, cssClass = '') {
if (!chatMessages) return;
const div = document.createElement('div');
div.textContent = text;
if (cssClass) div.className = cssClass;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
if (chatForm) {
chatForm.addEventListener('submit', e => {
e.preventDefault();
const msg = chatInput.value.trim();
if (!msg) return;
wsClient.send({ type: 'chat-message', text: msg });
appendChatMessage(`You: ${msg}`);
chatInput.value = '';
});
}
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// Slow sun arc across the sky — changes the flare angle over time
sunLight.position.x = 120 * Math.cos(t * 0.015);
sunLight.position.y = 90 + 20 * Math.sin(t * 0.008);
sunLight.position.z = -220 + 30 * Math.sin(t * 0.011);
// Orbit camera
camera.position.x = cameraTarget.x + cameraRadius * Math.sin(cameraYaw) * Math.cos(cameraPitch);
camera.position.y = cameraTarget.y + cameraRadius * Math.sin(cameraPitch) + 2;
camera.position.z = cameraTarget.z + cameraRadius * Math.cos(cameraYaw) * Math.cos(cameraPitch);
camera.lookAt(cameraTarget);
renderer.render(scene, camera);
}
animate();
// Hide loading screen after first frame
requestAnimationFrame(() => {
const loading = document.getElementById('loading-screen');
if (loading) loading.classList.add('hidden');
});
// === WEBSOCKET ===
wsClient.connect();
window.addEventListener('player-joined', e => {
appendChatMessage(`${e.detail.name ?? 'Visitor'} entered the Nexus`);
});
window.addEventListener('player-left', e => {
appendChatMessage(`${e.detail.name ?? 'Visitor'} left`);
});
window.addEventListener('chat-message', e => {
appendChatMessage(`${e.detail.name ?? 'Visitor'}: ${e.detail.text ?? ''}`);
});
window.addEventListener('beforeunload', () => wsClient.disconnect());