← back to Dw War Room

src/scene/WarRoom.ts

199 lines

// ═══════════════════════════════════════════════
// DW War Room 3D — Main Scene Composition
// Assembles all scene components + raycasting
// ═══════════════════════════════════════════════

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { createLighting, pulseEscalationLight } from './Lighting';
import { createCommandTable, CommandTableHandle } from './CommandTable';
import { AgentNodeManager } from './AgentNodes';
import { DataWalls } from './DataWalls';
import { PostProcessingPipeline } from '../effects/PostProcessing';
import { ParticleSystem } from '../effects/Particles';
import { createEnvironment } from './Environment';
import type { AgentNode } from '../types';

export class WarRoom {
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  renderer: THREE.WebGLRenderer;
  controls: OrbitControls;
  agentNodes: AgentNodeManager;
  dataWalls: DataWalls;

  private clock: THREE.Clock;
  private postProcessing: PostProcessingPipeline;
  private particles: ParticleSystem;
  private commandTable: CommandTableHandle;
  private raycaster: THREE.Raycaster;
  private mouse: THREE.Vector2;

  // Callback for agent click
  onAgentClick: ((agent: AgentNode, screenX: number, screenY: number) => void) | null = null;

  constructor(container: HTMLElement) {
    // Scene
    this.scene = new THREE.Scene();
    this.scene.background = new THREE.Color(0x050510);
    this.scene.fog = new THREE.Fog(0x050510, 25, 80);

    // Camera
    this.camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 100);
    this.camera.position.set(0, 12, 18);
    this.camera.lookAt(0, 2, 0);

    // Renderer — physically-based rendering pipeline
    this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    this.renderer.setSize(container.clientWidth, container.clientHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.2;
    this.renderer.outputColorSpace = THREE.SRGBColorSpace;
    this.renderer.useLegacyLights = false;
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    container.appendChild(this.renderer.domElement);

    // Controls
    this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.05;
    this.controls.minDistance = 5;
    this.controls.maxDistance = 40;
    this.controls.maxPolarAngle = Math.PI / 2.1;

    // Clock
    this.clock = new THREE.Clock();

    // Raycasting
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();

    // Build scene
    createLighting(this.scene);
    this.commandTable = createCommandTable(this.scene);
    this.agentNodes = new AgentNodeManager(this.scene);
    this.dataWalls = new DataWalls(this.scene);
    createEnvironment(this.scene);
    this.addEnvMap();
    this.addStarfield();

    // Particle system
    this.particles = new ParticleSystem(this.scene);

    // Post-processing
    this.postProcessing = new PostProcessingPipeline(this.renderer, this.scene, this.camera);

    // Resize handling
    window.addEventListener('resize', () => this.onResize(container));

    // Click handler for agent raycasting
    this.renderer.domElement.addEventListener('click', (e) => this.handleClick(e));
  }

  private addEnvMap(): void {
    // Generate a neutral PMREM environment from a simple gradient scene
    // Gives all MeshStandardMaterial surfaces ambient IBL reflections
    const pmrem = new THREE.PMREMGenerator(this.renderer);
    pmrem.compileEquirectangularShader();

    // Build a tiny gradient sky scene to bake into the env map
    const envScene = new THREE.Scene();
    // Cool dark-blue top gradient, warm dark base — corporate command center tone
    const skyGeo = new THREE.SphereGeometry(50, 16, 8);
    const skyMat = new THREE.MeshBasicMaterial({
      color: 0x0a1520,
      side: THREE.BackSide,
    });
    envScene.add(new THREE.Mesh(skyGeo, skyMat));

    // Add a soft white highlight for IBL top reflection
    const capGeo = new THREE.SphereGeometry(40, 8, 4, 0, Math.PI * 2, 0, Math.PI * 0.25);
    const capMat = new THREE.MeshBasicMaterial({ color: 0x4488aa, side: THREE.BackSide });
    const cap = new THREE.Mesh(capGeo, capMat);
    cap.rotation.x = Math.PI;
    envScene.add(cap);

    const envRenderTarget = pmrem.fromScene(envScene as any, 0.04);
    this.renderer.environment = envRenderTarget.texture;
    pmrem.dispose();
    skyMat.dispose();
    capMat.dispose();
  }

  private addStarfield(): void {
    const starGeo = new THREE.BufferGeometry();
    const positions = new Float32Array(1500);
    for (let i = 0; i < 1500; i += 3) {
      positions[i] = (Math.random() - 0.5) * 80;
      positions[i + 1] = Math.random() * 30 + 10;
      positions[i + 2] = (Math.random() - 0.5) * 80;
    }
    starGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    // Neutral cool-white stars — no neon purple
    const starMat = new THREE.PointsMaterial({ color: 0xc8ddf0, size: 0.05, transparent: true, opacity: 0.5 });
    const stars = new THREE.Points(starGeo, starMat);
    this.scene.add(stars);
  }

  private handleClick(event: MouseEvent): void {
    const rect = this.renderer.domElement.getBoundingClientRect();
    this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

    this.raycaster.setFromCamera(this.mouse, this.camera);
    const meshes = this.agentNodes.getMeshes();
    const intersects = this.raycaster.intersectObjects(meshes);

    if (intersects.length > 0) {
      const agent = this.agentNodes.getNodeByMesh(intersects[0].object);
      if (agent && this.onAgentClick) {
        this.onAgentClick(agent, event.clientX, event.clientY);
      }
    }
  }

  setEscalationPulse(active: boolean): void {
    const intensity = active ? 0.8 : 0.0;
    pulseEscalationLight(this.scene, intensity);
    this.dataWalls.pulsePanel('escalations', active ? 0.2 : 0.05);
  }

  animate(): void {
    requestAnimationFrame(() => this.animate());
    const elapsed = this.clock.getElapsedTime() * 1000;
    this.controls.update();
    this.commandTable.animate(elapsed);
    this.agentNodes.animate(elapsed);
    this.dataWalls.animate(elapsed);
    this.particles.animate(elapsed);
    this.postProcessing.render(this.scene, this.camera);
  }

  private onResize(container: HTMLElement): void {
    const w = container.clientWidth;
    const h = container.clientHeight;
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(w, h);
    this.postProcessing.resize(w, h);
  }

  // Preset camera views
  viewOverview(): void {
    this.camera.position.set(0, 12, 18);
    this.camera.lookAt(0, 2, 0);
  }

  viewMeetingFocus(): void {
    this.camera.position.set(0, 8, 10);
    this.camera.lookAt(0, 3, 0);
  }

  viewAgentFocus(): void {
    this.camera.position.set(5, 6, 8);
    this.camera.lookAt(0, 3, 0);
  }
}