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