1 Commits

Author SHA1 Message Date
Alexander Whitestone
21caa2ad5e feat: add particle spark effects for active agent interactions (#3)
When an agent enters the 'active' state, a Three.js Points particle
system emits additive-blended sparks that drift outward and upward from
the agent core. Particles have a 600–1400 ms lifetime and fade once the
agent returns to idle.

Particle counts are scaled by quality tier (20 / 40 / 64) so mobile
devices stay performant. Resources are properly disposed on agent
removal and WebGL context loss recovery.

Fixes #3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:07:13 -04:00
5 changed files with 116 additions and 157 deletions

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { getQualityTier } from './quality.js';
const agents = new Map();
let scene;
@@ -66,6 +67,105 @@ class Agent {
const light = new THREE.PointLight(this.color, 1.5, 10);
this.group.add(light);
this.light = light;
this._initParticles();
}
_initParticles() {
const tier = getQualityTier();
// Particle counts scaled by quality tier — fewer on mobile for performance
const count = tier === 'low' ? 20 : tier === 'medium' ? 40 : 64;
this._pCount = count;
this._pPos = new Float32Array(count * 3); // local-space positions
this._pVel = new Float32Array(count * 3); // velocity in units/ms
this._pLife = new Float32Array(count); // remaining lifetime in ms (0 = dead)
this._pEmit = 0; // fractional emit accumulator
// Particles emitted per ms when active
this._pRate = tier === 'low' ? 0.005 : tier === 'medium' ? 0.01 : 0.016;
this._pLastT = null;
// Start all particles hidden
for (let i = 0; i < count; i++) {
this._pPos[i * 3 + 1] = -1000;
this._pLife[i] = 0;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(this._pPos, 3));
const mat = new THREE.PointsMaterial({
color: this.color,
size: tier === 'low' ? 0.2 : 0.14,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
this._pMesh = new THREE.Points(geo, mat);
this._pMesh.frustumCulled = false;
this.group.add(this._pMesh);
}
_spawnParticle() {
for (let i = 0; i < this._pCount; i++) {
if (this._pLife[i] > 0) continue;
// Spawn near agent core
const spread = 0.5;
this._pPos[i * 3] = (Math.random() - 0.5) * spread;
this._pPos[i * 3 + 1] = (Math.random() - 0.5) * spread;
this._pPos[i * 3 + 2] = (Math.random() - 0.5) * spread;
// Outward + upward drift (units per ms)
const angle = Math.random() * Math.PI * 2;
const radial = 0.0008 + Math.random() * 0.0018;
const rise = 0.001 + Math.random() * 0.003;
this._pVel[i * 3] = Math.cos(angle) * radial;
this._pVel[i * 3 + 1] = rise;
this._pVel[i * 3 + 2] = Math.sin(angle) * radial;
// Lifetime 6001400 ms
this._pLife[i] = 600 + Math.random() * 800;
return;
}
}
_updateParticles(time) {
if (!this._pMesh) return;
const active = this.state === 'active';
const dt = this._pLastT === null ? 16 : Math.min(time - this._pLastT, 100);
this._pLastT = time;
// Emit sparks while active
if (active) {
this._pEmit += dt * this._pRate;
while (this._pEmit >= 1) {
this._pEmit -= 1;
this._spawnParticle();
}
} else {
this._pEmit = 0;
}
// Integrate existing particles
let anyAlive = false;
for (let i = 0; i < this._pCount; i++) {
if (this._pLife[i] <= 0) continue;
this._pLife[i] -= dt;
if (this._pLife[i] <= 0) {
this._pLife[i] = 0;
this._pPos[i * 3 + 1] = -1000; // move off-screen
continue;
}
this._pPos[i * 3] += this._pVel[i * 3] * dt;
this._pPos[i * 3 + 1] += this._pVel[i * 3 + 1] * dt;
this._pPos[i * 3 + 2] += this._pVel[i * 3 + 2] * dt;
anyAlive = true;
}
this._pMesh.geometry.attributes.position.needsUpdate = true;
// Fade opacity: visible when active or particles still coasting
this._pMesh.material.opacity = (active || anyAlive) ? 0.9 : 0;
}
_buildLabel() {
@@ -90,27 +190,22 @@ class Agent {
this.group.add(this.sprite);
}
update(time, nightFactor = 0) {
update(time) {
const pulse = Math.sin(time * 0.002 + this.pulsePhase);
const active = this.state === 'active';
// 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;
const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1;
this.core.material.emissiveIntensity = intensity;
const lightBase = active ? 2 + pulse : 0.8 + pulse * 0.3;
this.light.intensity = lightBase * (1 + nightFactor * 0.6);
this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3;
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;
this._updateParticles(time);
}
setState(state) {
@@ -127,6 +222,11 @@ class Agent {
this.glow.material.dispose();
this.sprite.material.map.dispose();
this.sprite.material.dispose();
if (this._pMesh) {
this._pMesh.geometry.dispose();
this._pMesh.material.dispose();
this._pMesh = null;
}
}
}
@@ -168,8 +268,8 @@ function buildConnectionLines() {
}
}
export function updateAgents(time, nightFactor = 0) {
agents.forEach(agent => agent.update(time, nightFactor));
export function updateAgents(time) {
agents.forEach(agent => agent.update(time));
}
export function getAgentCount() {

View File

@@ -1,127 +0,0 @@
/**
* 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,6 +1,5 @@
import * as THREE from 'three';
import { getQualityTier } from './quality.js';
import { setStarMaterial } from './daynight.js';
let rainParticles;
let rainPositions;
@@ -71,14 +70,11 @@ function initStarfield(scene, tier) {
color: 0x003300,
size: 0.08,
transparent: true,
opacity: 0.05, // daynight.js will drive this each frame
opacity: 0.5,
});
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) {
@@ -118,6 +114,4 @@ 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,7 +4,6 @@ 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';
@@ -74,8 +73,7 @@ function buildWorld(firstInit, stateSnapshot) {
updateControls();
updateEffects(now);
const nightFactor = updateDayNight();
updateAgents(now, nightFactor);
updateAgents(now);
updateUI({
fps: currentFps,
agentCount: getAgentCount(),

View File

@@ -1,6 +1,5 @@
import * as THREE from 'three';
import { getMaxPixelRatio, getQualityTier } from './quality.js';
import { initDayNight, disposeDayNight } from './daynight.js';
let scene, camera, renderer;
const _worldObjects = [];
@@ -31,16 +30,14 @@ export function initWorld(existingCanvas) {
document.body.prepend(renderer.domElement);
}
const { ambient } = addLights(scene);
addLights(scene);
addGrid(scene, tier);
initDayNight(scene, ambient);
return { scene, camera, renderer };
}
function addLights(scene) {
const ambient = new THREE.AmbientLight(0x001a00, 0.35);
const ambient = new THREE.AmbientLight(0x001a00, 0.6);
scene.add(ambient);
const point = new THREE.PointLight(0x00ff41, 2, 80);
@@ -50,8 +47,6 @@ 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) {
@@ -90,7 +85,6 @@ export function disposeWorld(disposeRenderer, _scene) {
}
}
_worldObjects.length = 0;
disposeDayNight();
disposeRenderer.dispose();
}