← 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;
  }
}