diff --git a/js/agents.js b/js/agents.js index dcef57c..8c89467 100644 --- a/js/agents.js +++ b/js/agents.js @@ -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 600–1400 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; + } } }