[claude] Add particle effects for agent interactions (#3) #13

Closed
claude wants to merge 1 commits from claude/the-matrix:claude/issue-3 into main

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() {
@@ -104,6 +204,8 @@ class Agent {
this.ring.material.opacity = 0.3 + pulse * 0.2;
this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15;
this._updateParticles(time);
}
setState(state) {
@@ -120,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;
}
}
}