1 Commits

Author SHA1 Message Date
Alexander Whitestone
87486d2c7f feat: add day/night cycle driven by real UTC time
- 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 <noreply@anthropic.com>
2026-03-23 14:06:37 -04:00
5 changed files with 157 additions and 9 deletions

View File

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

127
js/daynight.js Normal file
View File

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

8
js/effects.js vendored
View File

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

View File

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

View File

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