← back to Dw War Room
src/effects/Particles.ts
309 lines
// ═══════════════════════════════════════════════
// DW War Room 3D — Ambient Particle System
// Data flow visualization: dust motes, data
// streams, and connection pulse dots
// ═══════════════════════════════════════════════
import * as THREE from 'three';
// ─── Constants ────────────────────────────────
const DUST_COUNT = 300;
const STREAM_COUNT = 150;
const PULSE_COUNT = 50;
// ─── Internal state per particle group ────────
interface DustMote {
baseX: number;
baseY: number;
baseZ: number;
phaseX: number;
phaseY: number;
phaseZ: number;
speed: number;
amplitude: number;
}
interface StreamParticle {
x: number;
z: number;
y: number;
speed: number;
spiralRadius: number;
spiralPhase: number;
spiralSpeed: number;
}
interface PulseDot {
startX: number;
startZ: number;
endX: number;
endZ: number;
progress: number; // 0 → 1 → 0 (ping-pong toward center and back)
speed: number;
direction: number; // +1 = toward center, -1 = away from center
}
// ─── ParticleSystem ───────────────────────────
export class ParticleSystem {
private scene: THREE.Scene;
// Group 1 — Ambient dust motes
private dustPoints: THREE.Points;
private dustPositions: Float32Array;
private dustMeta: DustMote[] = [];
// Group 2 — Data stream particles
private streamPoints: THREE.Points;
private streamPositions: Float32Array;
private streamMeta: StreamParticle[] = [];
// Group 3 — Connection pulse dots
private pulsePoints: THREE.Points;
private pulsePositions: Float32Array;
private pulseMeta: PulseDot[] = [];
constructor(scene: THREE.Scene) {
this.scene = scene;
this.dustPoints = this.buildDustMotes();
this.streamPoints = this.buildDataStreams();
this.pulsePoints = this.buildPulseDots();
this.scene.add(this.dustPoints);
this.scene.add(this.streamPoints);
this.scene.add(this.pulsePoints);
}
// ── Group 1: Ambient dust motes ──────────────
private buildDustMotes(): THREE.Points {
const positions = new Float32Array(DUST_COUNT * 3);
for (let i = 0; i < DUST_COUNT; i++) {
const x = (Math.random() - 0.5) * 40; // -20 to 20
const y = Math.random() * 15; // 0 to 15
const z = (Math.random() - 0.5) * 40; // -20 to 20
positions[i * 3] = x;
positions[i * 3 + 1] = y;
positions[i * 3 + 2] = z;
this.dustMeta.push({
baseX: x,
baseY: y,
baseZ: z,
phaseX: Math.random() * Math.PI * 2,
phaseY: Math.random() * Math.PI * 2,
phaseZ: Math.random() * Math.PI * 2,
speed: 0.0003 + Math.random() * 0.0004,
amplitude: 0.15 + Math.random() * 0.25,
});
}
this.dustPositions = positions;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0x8b5cf6,
size: 0.03,
sizeAttenuation: true,
transparent: true,
opacity: 0.3,
depthWrite: false,
});
return new THREE.Points(geo, mat);
}
// ── Group 2: Data stream particles ───────────
private buildDataStreams(): THREE.Points {
const positions = new Float32Array(STREAM_COUNT * 3);
for (let i = 0; i < STREAM_COUNT; i++) {
// Random angle and radius within a circle of radius 6 around origin
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * 6;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const y = 0.5 + Math.random() * 7.5; // spread from 0.5 to 8
positions[i * 3] = x;
positions[i * 3 + 1] = y;
positions[i * 3 + 2] = z;
this.streamMeta.push({
x,
z,
y,
speed: 0.003 + Math.random() * 0.008,
spiralRadius: 0.1 + Math.random() * 0.4,
spiralPhase: Math.random() * Math.PI * 2,
spiralSpeed: 0.5 + Math.random() * 1.0,
});
}
this.streamPositions = positions;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0x93c5fd,
size: 0.05,
sizeAttenuation: true,
transparent: true,
opacity: 0.7,
depthWrite: false,
});
return new THREE.Points(geo, mat);
}
// ── Group 3: Connection pulse dots ───────────
private buildPulseDots(): THREE.Points {
const positions = new Float32Array(PULSE_COUNT * 3);
for (let i = 0; i < PULSE_COUNT; i++) {
const { startX, startZ, endX, endZ } = this.randomPulsePath();
const progress = Math.random(); // stagger initial positions
positions[i * 3] = THREE.MathUtils.lerp(startX, endX, progress);
positions[i * 3 + 1] = 3.5;
positions[i * 3 + 2] = THREE.MathUtils.lerp(startZ, endZ, progress);
this.pulseMeta.push({
startX,
startZ,
endX,
endZ,
progress,
speed: 0.004 + Math.random() * 0.006,
direction: Math.random() > 0.5 ? 1 : -1,
});
}
this.pulsePositions = positions;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0x22c55e,
size: 0.06,
sizeAttenuation: true,
transparent: true,
opacity: 0.4,
depthWrite: false,
});
return new THREE.Points(geo, mat);
}
// Pick an edge start and a center-region end for a pulse path.
// "Edge" = between radius 8 and 14 on the horizontal plane.
private randomPulsePath(): { startX: number; startZ: number; endX: number; endZ: number } {
const edgeAngle = Math.random() * Math.PI * 2;
const edgeRadius = 8 + Math.random() * 6; // 8–14
const startX = Math.cos(edgeAngle) * edgeRadius;
const startZ = Math.sin(edgeAngle) * edgeRadius;
// End near the center table (within radius 3)
const centerAngle = Math.random() * Math.PI * 2;
const centerRadius = Math.random() * 3;
const endX = Math.cos(centerAngle) * centerRadius;
const endZ = Math.sin(centerAngle) * centerRadius;
return { startX, startZ, endX, endZ };
}
// ── animate ──────────────────────────────────
//
// elapsed: milliseconds from THREE.Clock.getElapsedTime() * 1000
// (matches the convention used in WarRoom.ts)
animate(elapsed: number): void {
const t = elapsed * 0.001; // convert to seconds for trig functions
this.animateDust(t);
this.animateStreams(t);
this.animatePulse();
}
private animateDust(t: number): void {
for (let i = 0; i < DUST_COUNT; i++) {
const m = this.dustMeta[i];
const base = i * 3;
this.dustPositions[base] = m.baseX + Math.cos(t * m.speed * 1000 + m.phaseX) * m.amplitude;
this.dustPositions[base + 1] = m.baseY + Math.sin(t * m.speed * 700 + m.phaseY) * m.amplitude * 0.5;
this.dustPositions[base + 2] = m.baseZ + Math.cos(t * m.speed * 800 + m.phaseZ) * m.amplitude;
}
(this.dustPoints.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;
}
private animateStreams(t: number): void {
for (let i = 0; i < STREAM_COUNT; i++) {
const m = this.streamMeta[i];
const base = i * 3;
// Rise upward
m.y += m.speed;
// Respawn at bottom when reaching ceiling
if (m.y >= 8) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * 6;
m.x = Math.cos(angle) * radius;
m.z = Math.sin(angle) * radius;
m.y = 0.5;
m.spiralPhase = Math.random() * Math.PI * 2;
}
// Spiral offset around the base x/z position
const spiralAngle = t * m.spiralSpeed + m.spiralPhase;
this.streamPositions[base] = m.x + Math.cos(spiralAngle) * m.spiralRadius;
this.streamPositions[base + 1] = m.y;
this.streamPositions[base + 2] = m.z + Math.sin(spiralAngle) * m.spiralRadius;
}
(this.streamPoints.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;
}
private animatePulse(): void {
for (let i = 0; i < PULSE_COUNT; i++) {
const m = this.pulseMeta[i];
const base = i * 3;
m.progress += m.speed * m.direction;
// Bounce: reverse direction at each end, then pick a new path
if (m.progress >= 1) {
m.progress = 1;
m.direction = -1;
} else if (m.progress <= 0) {
m.progress = 0;
m.direction = 1;
// Assign a fresh path so pulses don't all follow the same lines
const fresh = this.randomPulsePath();
m.startX = fresh.startX;
m.startZ = fresh.startZ;
m.endX = fresh.endX;
m.endZ = fresh.endZ;
}
this.pulsePositions[base] = THREE.MathUtils.lerp(m.startX, m.endX, m.progress);
this.pulsePositions[base + 1] = 3.5;
this.pulsePositions[base + 2] = THREE.MathUtils.lerp(m.startZ, m.endZ, m.progress);
}
(this.pulsePoints.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;
}
}