From ec6dc0aa01dbe3771c341d0f43ce38f3df6e8d92 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 23:59:42 -0400 Subject: [PATCH] feat: add lens flare effect for bright light sources (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.js | 393 ++++++++++++++++++++++++++++++++++++++++++++++++++--- index.html | 41 +++++- style.css | 161 ++++++++++++++++++++-- 3 files changed, 560 insertions(+), 35 deletions(-) diff --git a/app.js b/app.js index 051fe82..13c53a4 100644 --- a/app.js +++ b/app.js @@ -1,27 +1,380 @@ -// ... existing code ... - -// === WEBSOCKET CLIENT === +import * as THREE from 'three'; +import { Lensflare, LensflareElement } from 'three/addons/objects/Lensflare.js'; import { wsClient } from './ws-client.js'; -// Initialize WebSocket client +// === 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(); -// Handle WebSocket events -window.addEventListener('player-joined', (event) => { - console.log('Player joined:', event.detail); +window.addEventListener('player-joined', e => { + appendChatMessage(`⚡ ${e.detail.name ?? 'Visitor'} entered the Nexus`); }); - -window.addEventListener('player-left', (event) => { - console.log('Player left:', event.detail); +window.addEventListener('player-left', e => { + appendChatMessage(`↩ ${e.detail.name ?? 'Visitor'} left`); }); - -window.addEventListener('chat-message', (event) => { - console.log('Chat message:', event.detail); +window.addEventListener('chat-message', e => { + appendChatMessage(`${e.detail.name ?? 'Visitor'}: ${e.detail.text ?? ''}`); }); - -// Clean up on page unload -window.addEventListener('beforeunload', () => { - wsClient.disconnect(); -}); - -// ... existing code ... +window.addEventListener('beforeunload', () => wsClient.disconnect()); diff --git a/index.html b/index.html index 34af931..4d27f76 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ Timmy's Nexus - + @@ -14,18 +14,45 @@ + + - + + + +
+
Timmy's Nexus
+
Drag to orbit · Scroll to zoom
+
-
- +
+
- + +
+
+
+ + +
+
+ + +
+
Entering the Nexus…
+
+ + diff --git a/style.css b/style.css index a2aab15..a289c5c 100644 --- a/style.css +++ b/style.css @@ -1,18 +1,163 @@ +/* === DESIGN SYSTEM === */ +:root { + --color-bg: #0a0a0f; + --color-primary: #00ffcc; + --color-secondary: #ff6600; + --color-accent: #8866ff; + --color-text: #e0e0f0; + --color-text-muted: #556677; + --color-panel: rgba(10, 10, 20, 0.85); + --color-panel-border: rgba(0, 255, 204, 0.25); + --font-body: 'Courier New', Courier, monospace; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); + overflow: hidden; + width: 100vw; + height: 100vh; +} + +#nexus-canvas { + display: block; + position: fixed; + inset: 0; + width: 100%; + height: 100%; +} + +/* === HUD === */ +#hud { + position: fixed; + top: 16px; + left: 16px; + pointer-events: none; + z-index: 10; +} + +#hud-title { + font-size: 18px; + font-weight: bold; + color: var(--color-primary); + letter-spacing: 0.1em; + text-shadow: 0 0 12px var(--color-primary); +} + +#hud-hint { + font-size: 11px; + color: var(--color-text-muted); + margin-top: 4px; +} + /* === AUDIO TOGGLE === */ +#audio-control { + position: fixed; + top: 8px; + right: 8px; + z-index: 10; +} + #audio-toggle { font-size: 14px; - background-color: var(--color-primary-primary); - color: var(--color-bg); - padding: 4px 8px; + background: var(--color-panel); + color: var(--color-text); + border: 1px solid var(--color-panel-border); + padding: 4px 10px; border-radius: 4px; font-family: var(--font-body); - transition: background-color 0.3s ease; + cursor: pointer; + transition: border-color 0.2s; } -#audio-toggle:hover { - background-color: var(--color-secondary); +#audio-toggle:hover { border-color: var(--color-primary); } +#audio-toggle.muted { color: var(--color-text-muted); } + +/* === CHAT PANEL === */ +#chat-panel { + position: fixed; + bottom: 16px; + right: 16px; + width: 280px; + background: var(--color-panel); + border: 1px solid var(--color-panel-border); + border-radius: 8px; + display: flex; + flex-direction: column; + z-index: 10; + backdrop-filter: blur(8px); } -#audio-toggle.muted { - background-color: var(--color-text-muted); +#chat-messages { + height: 120px; + overflow-y: auto; + padding: 8px; + font-size: 12px; + color: var(--color-text); + display: flex; + flex-direction: column; + gap: 4px; +} + +#chat-form { + display: flex; + border-top: 1px solid var(--color-panel-border); +} + +#chat-input { + flex: 1; + background: transparent; + border: none; + color: var(--color-text); + font-family: var(--font-body); + font-size: 12px; + padding: 6px 8px; + outline: none; +} + +#chat-form button { + background: transparent; + border: none; + border-left: 1px solid var(--color-panel-border); + color: var(--color-primary); + font-family: var(--font-body); + font-size: 12px; + padding: 6px 10px; + cursor: pointer; + transition: background 0.2s; +} + +#chat-form button:hover { background: rgba(0, 255, 204, 0.08); } + +/* === LOADING SCREEN === */ +#loading-screen { + position: fixed; + inset: 0; + background: var(--color-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + transition: opacity 0.8s ease; +} + +#loading-screen.hidden { + opacity: 0; + pointer-events: none; +} + +#loading-text { + color: var(--color-primary); + font-size: 20px; + letter-spacing: 0.15em; + text-shadow: 0 0 20px var(--color-primary); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } } -- 2.43.0