[claude] Add lens flare effect when looking toward bright light sources (#109) #162

Closed
claude wants to merge 6 commits from claude/the-nexus:claude/issue-109 into main
3 changed files with 560 additions and 35 deletions
Showing only changes of commit ec6dc0aa01 - Show all commits

393
app.js
View File

@@ -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());

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timmy's Nexus</title>
<meta name="description" content="A sovereign 3D world">
<meta name="description" content="A sovereign 3D world — Timmy's home in space">
<meta property="og:title" content="Timmy's Nexus">
<meta property="og:description" content="A sovereign 3D world">
<meta property="og:image" content="https://example.com/og-image.png">
@@ -14,18 +14,45 @@
<meta name="twitter:description" content="A sovereign 3D world">
<meta name="twitter:image" content="https://example.com/og-image.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="style.css">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.163.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.163.0/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- ... existing content ... -->
<canvas id="nexus-canvas"></canvas>
<!-- HUD: Portal labels -->
<div id="hud" aria-label="Nexus HUD">
<div id="hud-title">Timmy's Nexus</div>
<div id="hud-hint">Drag to orbit · Scroll to zoom</div>
</div>
<!-- Top Right: Audio Toggle -->
<div id="audio-control" class="hud-controls" style="position: absolute; top: 8px; right: 8px;">
<button id="audio-toggle" class="chat-toggle-btn" aria-label="Toggle ambient sound" style="background-color: var(--color-primary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
🔊
</button>
<div id="audio-control">
<button id="audio-toggle" aria-label="Toggle ambient sound">🔊</button>
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
</div>
<!-- ... existing content ... -->
<!-- Chat Panel -->
<div id="chat-panel" aria-label="Chat">
<div id="chat-messages"></div>
<form id="chat-form" autocomplete="off">
<input id="chat-input" type="text" placeholder="Message…" maxlength="200">
<button type="submit">Send</button>
</form>
</div>
<!-- Loading Screen -->
<div id="loading-screen" aria-live="polite">
<div id="loading-text">Entering the Nexus…</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

161
style.css
View File

@@ -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; }
}