Zum Inhalt

src/engine/modes/actors.ts

// Combat actors — meshes with a path + lifecycle, owned by the engine's persistent
// actorsGroup (survives scene swaps). Cinematic: they react/emit, no damage model.
// Actors build their OWN materials (NOT the shared MaterialPool) so loadScene's
// disposeAll() never kills a persistent actor's material.
import * as THREE from 'three';
import { mkRng } from '../engine/rng';

export interface Actor {
  readonly obj: THREE.Object3D;
  alive: boolean;
  /** advance; return false when finished (engine culls + disposes). */
  update(t: number, dt: number, cam: THREE.PerspectiveCamera, threat: number): boolean;
  dispose(): void;
}

const disposeTree = (o: THREE.Object3D) => o.traverse((c: any) => {
  c.geometry?.dispose?.(); ([] as any[]).concat(c.material || []).forEach((m) => m.dispose?.());
});

const wire = (hex: number, opacity = 1) =>
  new THREE.MeshBasicMaterial({ color: hex, wireframe: true, transparent: true, opacity });

const FWD = new THREE.Vector3(0, 0, -1);
const RIGHT = new THREE.Vector3(1, 0, 0);
const UP = new THREE.Vector3(0, 1, 0);

/** Lurking hunter: weaves at the edge of view, fires tracers toward the camera. */
export class AntagonistDrone implements Actor {
  readonly obj = new THREE.Group();
  alive = true;
  private rng: () => number;
  private t0 = -1;
  private nextFire: number;
  private tracer: { mesh: THREE.Mesh; born: number } | null = null;
  private side: number;
  private mat: THREE.MeshBasicMaterial;

  constructor(hex: number, seed: number, private onFire: () => void) {
    this.rng = mkRng((seed ^ 0xbad5eed) >>> 0);
    this.side = this.rng() < 0.5 ? -1 : 1;
    this.mat = wire(hex);
    const body = new THREE.Mesh(new THREE.IcosahedronGeometry(1.1, 0), this.mat);
    const fin = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 0), this.mat);
    fin.position.set(0, -0.9, 0);
    this.obj.add(body, fin);
    this.nextFire = 2 + this.rng() * 2;
  }

  update(t: number, _dt: number, cam: THREE.PerspectiveCamera, threat: number): boolean {
    if (this.t0 < 0) this.t0 = t;
    const lt = t - this.t0;
    if (threat < 0.05) { this.alive = false; return false; }      // exhale at the crash
    // anchor at the edge of view, relative to the camera; closer as threat rises.
    const dist = 26 + (1 - threat) * 18;
    const lat = this.side * (10 + Math.sin(lt * 0.5) * 3);
    const vert = 5 + Math.sin(lt * 0.37) * 2.5;
    const fwd = FWD.clone().applyQuaternion(cam.quaternion);
    const right = RIGHT.clone().applyQuaternion(cam.quaternion);
    const up = UP.clone().applyQuaternion(cam.quaternion);
    this.obj.position.copy(cam.position).addScaledVector(fwd, dist).addScaledVector(right, lat).addScaledVector(up, vert);
    this.obj.lookAt(cam.position);          // local -Z now points at the camera
    this.obj.rotateZ(lt * 0.6);             // spin about the look axis (keeps -Z on camera)
    if (lt > this.nextFire) {
      this.nextFire = lt + (4 - threat * 3) + this.rng() * 1.5;    // cadence shortens with threat
      this.fire(t, this.obj.position.distanceTo(cam.position));
      this.onFire();
    }
    if (this.tracer) {                      // fade the tracer out over 0.3s
      const age = t - this.tracer.born;
      if (age > 0.3) { this.clearTracer(); }
      else (this.tracer.mesh.material as THREE.Material).opacity = 1 - age / 0.3;
    }
    return true;
  }

  private fire(t: number, len: number) {
    this.clearTracer();
    const geo = new THREE.CylinderGeometry(0.05, 0.05, Math.max(1, len), 5);
    const m = new THREE.Mesh(geo, wire(this.mat.color.getHex()));
    m.position.set(0, 0, -len / 2);         // along local -Z (drone faces the camera)
    m.quaternion.setFromUnitVectors(UP, new THREE.Vector3(0, 0, -1));
    this.obj.add(m);
    this.tracer = { mesh: m, born: t };
  }

  private clearTracer() {
    if (!this.tracer) return;
    this.obj.remove(this.tracer.mesh);
    this.tracer.mesh.geometry.dispose();
    (this.tracer.mesh.material as THREE.Material).dispose();
    this.tracer = null;
  }

  dispose() { disposeTree(this.obj); }
}

/** CORP support unit: arrive → hold → flee or crash. */
export class SupportUnit implements Actor {
  readonly obj = new THREE.Group();
  alive = true;
  private t0 = -1;
  private side: number;
  private crashFired = false;
  private mat: THREE.MeshBasicMaterial;

  constructor(hex: number, seed: number, private crash: boolean, private onCrash: () => void) {
    const rng = mkRng((seed ^ 0x600d) >>> 0);
    this.side = rng() < 0.5 ? -1 : 1;
    this.mat = wire(hex);
    this.obj.add(new THREE.Mesh(new THREE.OctahedronGeometry(1.0, 0), this.mat));
  }

  update(t: number, dt: number, cam: THREE.PerspectiveCamera, _threat: number): boolean {
    if (this.t0 < 0) this.t0 = t;
    const lt = t - this.t0;
    const fwd = FWD.clone().applyQuaternion(cam.quaternion);
    const right = RIGHT.clone().applyQuaternion(cam.quaternion);
    const up = UP.clone().applyQuaternion(cam.quaternion);
    const arrive = Math.min(1, lt / 2.5);
    const ease = 1 - (1 - arrive) * (1 - arrive);
    let lateral = this.side * (28 * (1 - ease) + 12 * ease);    // slide in to a holding offset
    let along = 30;
    let rise = 6;
    if (lt > 6) {                                                // depart
      const d = lt - 6;
      if (this.crash) {
        rise -= d * d * 1.4;                                     // dive
        lateral += this.side * d * 2;
        this.obj.rotateZ(dt * 7);                               // spin
        if (d > 2.5) { if (!this.crashFired) { this.crashFired = true; this.onCrash(); } this.alive = false; return false; }
      } else {
        along -= d * 16;                                         // flee toward + past the camera
        if (d > 3) { this.alive = false; return false; }
      }
    }
    this.obj.position.copy(cam.position).addScaledVector(fwd, along).addScaledVector(right, lateral).addScaledVector(up, rise);
    return true;
  }

  dispose() { disposeTree(this.obj); }
}