From 87486d2c7f027c5689fcf9adf4accda601d06f9f Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 14:06:37 -0400 Subject: [PATCH] feat: add day/night cycle driven by real UTC time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New js/daynight.js module: computes sun elevation from UTC time, drives a DirectionalLight sun arc (east → zenith → west), adjusts ambient color/intensity, fog color, and star opacity each frame. - Sunrise ~06:00 UTC, sunset ~18:00 UTC with smooth transitions. - Stars (existing starfield) fade during day and glow green at night. - Agent emissive glow and point-light intensity boost at night via nightFactor passed to updateAgents(). - WebGL context-loss recovery: daynight refs disposed in disposeWorld / disposeEffects and re-initialised on rebuild. Fixes #4 Co-Authored-By: Claude Sonnet 4.6 --- js/agents.js | 17 +++++-- js/daynight.js | 127 +++++++++++++++++++++++++++++++++++++++++++++++++ js/effects.js | 8 +++- js/main.js | 4 +- js/world.js | 10 +++- 5 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 js/daynight.js diff --git a/js/agents.js b/js/agents.js index dcef57c..6812e60 100644 --- a/js/agents.js +++ b/js/agents.js @@ -90,19 +90,26 @@ class Agent { this.group.add(this.sprite); } - update(time) { + update(time, nightFactor = 0) { const pulse = Math.sin(time * 0.002 + this.pulsePhase); const active = this.state === 'active'; - const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1; + // Agents glow brighter at night + const nightBoost = nightFactor * 0.45; + const intensity = active ? 0.6 + pulse * 0.4 + nightBoost : 0.2 + pulse * 0.1 + nightBoost; this.core.material.emissiveIntensity = intensity; - this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3; + + const lightBase = active ? 2 + pulse : 0.8 + pulse * 0.3; + this.light.intensity = lightBase * (1 + nightFactor * 0.6); const scale = active ? 1 + pulse * 0.08 : 1 + pulse * 0.03; this.core.scale.setScalar(scale); this.ring.rotation.y += active ? 0.03 : 0.008; this.ring.material.opacity = 0.3 + pulse * 0.2; + // Glow shell more visible at night + this.glow.material.opacity = 0.04 + nightFactor * 0.12 + (active ? pulse * 0.04 : 0); + this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15; } @@ -161,8 +168,8 @@ function buildConnectionLines() { } } -export function updateAgents(time) { - agents.forEach(agent => agent.update(time)); +export function updateAgents(time, nightFactor = 0) { + agents.forEach(agent => agent.update(time, nightFactor)); } export function getAgentCount() { diff --git a/js/daynight.js b/js/daynight.js new file mode 100644 index 0000000..3f26337 --- /dev/null +++ b/js/daynight.js @@ -0,0 +1,127 @@ +/** + * Day/night cycle based on real UTC time. + * + * Sunrise ~06:00 UTC, Sunset ~18:00 UTC. + * Drives: sun directional light, ambient light, fog color, star visibility. + * Returns a nightFactor (0=day, 1=night) for consumers (e.g. agent glow). + */ +import * as THREE from 'three'; + +let sunLight = null; +let ambientRef = null; +let sceneRef = null; +let starMatRef = null; + +/** Call once during world init. */ +export function initDayNight(scene, ambientLight) { + sceneRef = scene; + ambientRef = ambientLight; + + sunLight = new THREE.DirectionalLight(0xffffff, 0); + sunLight.position.set(0, 80, 0); + scene.add(sunLight); +} + +/** Called by effects.js after it creates the star PointsMaterial. */ +export function setStarMaterial(mat) { + starMatRef = mat; +} + +/** Returns seconds elapsed since UTC midnight. */ +function utcSeconds() { + const n = new Date(); + return n.getUTCHours() * 3600 + n.getUTCMinutes() * 60 + n.getUTCSeconds(); +} + +/** + * Sun elevation angle: 1 at UTC noon, -1 at UTC midnight. + * Zero-crossings at 06:00 (dawn) and 18:00 (dusk). + */ +function sunElevation() { + const frac = utcSeconds() / 86400; // 0–1 + return Math.sin(2 * Math.PI * (frac - 0.25)); +} + +/** + * Smooth step from a to b. + * @param {number} edge0 + * @param {number} edge1 + * @param {number} x + */ +function smoothstep(edge0, edge1, x) { + const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +/** + * Update all day/night-driven scene properties. + * Called every animation frame. + * @returns {number} nightFactor — 0 (full day) to 1 (full night) + */ +export function updateDayNight() { + const elev = sunElevation(); + + // dayFactor: 1 at full day (elev > 0.15), 0 at full night (elev < -0.15) + const dayFactor = smoothstep(-0.15, 0.15, elev); + const nightFactor = 1 - dayFactor; + + // ── Sun directional light ───────────────────────────────────────────────── + if (sunLight) { + // Arc: east (positive X) at dawn, overhead at noon, west at dusk + const frac = utcSeconds() / 86400; + const angle = 2 * Math.PI * (frac - 0.25); + const sx = -Math.cos(angle) * 60; + const sy = Math.sin(angle) * 80; + const sz = -30; + sunLight.position.set(sx, Math.max(sy, 0.1), sz); + + // Color: warm orange near horizon, muted matrix-green at zenith + const horizonBlend = 1 - Math.min(1, Math.abs(elev) / 0.4); + const r = (0.3 + horizonBlend * 0.7) * dayFactor; + const g = (0.6 - horizonBlend * 0.2) * dayFactor; + const b = (0.3 - horizonBlend * 0.25) * dayFactor; + sunLight.color.setRGB(r, g, b); + sunLight.intensity = dayFactor * 1.2; + } + + // ── Ambient light ───────────────────────────────────────────────────────── + if (ambientRef) { + // Night: very dark greenish. Day: slightly brighter, cyan tint. + ambientRef.intensity = 0.35 + dayFactor * 0.35; + const ar = dayFactor * 0.04; + const ag = 0.07 + dayFactor * 0.05; + const ab = dayFactor * 0.06; + ambientRef.color.setRGB(ar, ag, ab); + } + + // ── Fog color ───────────────────────────────────────────────────────────── + if (sceneRef && sceneRef.fog) { + const fr = dayFactor * 0.008; + const fg = dayFactor * 0.016; + const fb = dayFactor * 0.010; + sceneRef.fog.color.setRGB(fr, fg, fb); + } + + // ── Stars ───────────────────────────────────────────────────────────────── + if (starMatRef) { + starMatRef.opacity = 0.05 + nightFactor * 0.80; + // Night: bright green. Day: nearly invisible dark green. + const sg = 0.05 + nightFactor * 0.55; + starMatRef.color.setRGB(0, sg, 0); + starMatRef.needsUpdate = true; + } + + return nightFactor; +} + +/** Remove sun light from scene and clear module refs. */ +export function disposeDayNight() { + if (sunLight && sceneRef) { + sceneRef.remove(sunLight); + sunLight.dispose(); + } + sunLight = null; + ambientRef = null; + sceneRef = null; + starMatRef = null; +} diff --git a/js/effects.js b/js/effects.js index 15e30bb..223321f 100644 --- a/js/effects.js +++ b/js/effects.js @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { getQualityTier } from './quality.js'; +import { setStarMaterial } from './daynight.js'; let rainParticles; let rainPositions; @@ -70,11 +71,14 @@ function initStarfield(scene, tier) { color: 0x003300, size: 0.08, transparent: true, - opacity: 0.5, + opacity: 0.05, // daynight.js will drive this each frame }); const stars = new THREE.Points(geo, mat); scene.add(stars); + + // Register with day/night system so it can control star visibility + setStarMaterial(mat); } export function updateEffects(_time) { @@ -114,4 +118,6 @@ export function disposeEffects() { rainVelocities = null; rainCount = 0; frameCounter = 0; + // Clear star material ref in daynight (daynight.js handles its own cleanup) + setStarMaterial(null); } diff --git a/js/main.js b/js/main.js index a9185e2..9003762 100644 --- a/js/main.js +++ b/js/main.js @@ -4,6 +4,7 @@ import { disposeAgents, getAgentStates, applyAgentStates, } from './agents.js'; import { initEffects, updateEffects, disposeEffects } from './effects.js'; +import { updateDayNight } from './daynight.js'; import { initUI, updateUI } from './ui.js'; import { initInteraction, updateControls, disposeInteraction } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; @@ -73,7 +74,8 @@ function buildWorld(firstInit, stateSnapshot) { updateControls(); updateEffects(now); - updateAgents(now); + const nightFactor = updateDayNight(); + updateAgents(now, nightFactor); updateUI({ fps: currentFps, agentCount: getAgentCount(), diff --git a/js/world.js b/js/world.js index abfc724..cc7c935 100644 --- a/js/world.js +++ b/js/world.js @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { getMaxPixelRatio, getQualityTier } from './quality.js'; +import { initDayNight, disposeDayNight } from './daynight.js'; let scene, camera, renderer; const _worldObjects = []; @@ -30,14 +31,16 @@ export function initWorld(existingCanvas) { document.body.prepend(renderer.domElement); } - addLights(scene); + const { ambient } = addLights(scene); addGrid(scene, tier); + initDayNight(scene, ambient); + return { scene, camera, renderer }; } function addLights(scene) { - const ambient = new THREE.AmbientLight(0x001a00, 0.6); + const ambient = new THREE.AmbientLight(0x001a00, 0.35); scene.add(ambient); const point = new THREE.PointLight(0x00ff41, 2, 80); @@ -47,6 +50,8 @@ function addLights(scene) { const fill = new THREE.DirectionalLight(0x003300, 0.4); fill.position.set(-10, 10, 10); scene.add(fill); + + return { ambient }; } function addGrid(scene, tier) { @@ -85,6 +90,7 @@ export function disposeWorld(disposeRenderer, _scene) { } } _worldObjects.length = 0; + disposeDayNight(); disposeRenderer.dispose(); } -- 2.43.0