// Spell and hit VFX for Frostcall Arena. One class wraps the vendored spellfx (Winter's Wrath bolts, lightning, ice spikes,
// frost nova, runes, slashes) and a fluidkit fire host (fire mage explosions). Game.ts feeds it every SimEvent through
// onEvents(), updates it once a frame, and never touches a particle itself. Everything is built at load and warmed there;
// nothing creates a material or a shader mid-fight.
import * as THREE from "three";
import { createFx, CELL, MODE, ELEMENTS } from "@/lib/spellfx/index.mjs";
import { createFluidHost, FireFX } from "@/lib/fluidkit/index.mjs";
import type { FluidHost, FireHandle } from "@/lib/fluidkit/index.mjs";
import { ENEMIES } from "./data";
import type { Proj, SimEvent, Sim, Unit } from "./combat/sim";
import type { Fx, UnitFx } from "./fx";
import { OrbShell, RuneDisc, SpearPool } from "./vfxkit";

type V3 = [number, number, number];
type Elem = "frost" | "arcane" | "fire" | "storm";

const rnd = (a: number, b: number) => a + Math.random() * (b - a);
const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x));
const rgba = (c: number[], a = 1): [number, number, number, number] => [c[0], c[1], c[2], a];

const SCHOOL_ELEM: Record<string, Elem> = { frost: "frost", arcane: "arcane", fire: "fire", shadow: "arcane", holy: "fire", physical: "fire" };
const SCHOOL_COLOR: Record<string, string> = { frost: "#bff4ff", arcane: "#e6b8ff", fire: "#ffb066", shadow: "#c9a0ff", holy: "#fff0b0", physical: "#ffffff" };

interface ProjVis {
  x: number;
  y: number;
  z: number;
  kind: Proj["kind"];
  /** The fluid flame riding a Fireball (null: fall back to the sprite tongues). */
  flame: FireHandle | null;
  /** Ice Lance body slot in the spear pool, -1 = none. */
  spear: number;
  spin: number;
  glint: number;
  dx: number;
  dy: number;
  dz: number;
}
interface VfxOptions {
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  renderer: THREE.WebGLRenderer;
  /** Phone or weak GPU: fewer particles, no fluid fire. */
  low: boolean;
  /** Camera shake request, 0..1 (Game applies it, honours reduced motion, never pauses). */
  shake: (n: number) => void;
  /** The unit visual that follows a sim unit (barrier flash, heal pulse). */
  ufx: (id: string) => UnitFx | undefined;
  /** Old particle layer, used for the floating damage numbers. */
  text: Fx;
}

export class Vfx {
  /** The live Blizzard weather (stopped when the channel ends early). */
  private blizzardFx: { stop(): void } | null = null;
  readonly fx: any;
  private fluid: FluidHost | null = null;
  private fire: FireFX | null = null;
  private pools: any;
  private projs = new Map<number, ProjVis>();
  private lastDmg = { dst: "", amount: 0, crit: false };
  private orbAcc = 0;
  private orbArc = 0;
  private stormT = -9;
  private clock = 0;
  private reduced = false;
  private tmpV = new THREE.Vector3();
  private tmpA = new THREE.Vector3();
  private tmpB = new THREE.Vector3();
  // the hero's staff head and off hand (bound by Game after the hero is built); the cast gather effect reads their world position
  private head: THREE.Object3D | null = null;
  private hand: THREE.Object3D | null = null;
  private rune: RuneDisc;
  private spears: SpearPool;
  private orbShell: OrbShell;
  private gatherAcc = 0;
  private handAcc = 0;
  private swirlAcc = 0;
  private castP = 0;
  private projClock = 0;
  private bliz: { x: number; z: number; r: number; left: number } | null = null;
  private mistAcc = 0;
  /** A Fireball charging in an enemy's hand (unit id -> fluid flame). */
  private handFlames = new Map<string, FireHandle>();
  private simRef: Sim | null = null;
  // scratch for the hot emit paths: the pools copy the values straight into their buffers, so one object is reused
  private sP: V3 = [0, 0, 0];
  private sV: V3 = [0, 0, 0];
  private sC0: [number, number, number, number] = [0, 0, 0, 0];
  private sC1: [number, number, number, number] = [0, 0, 0, 0];
  private sE: Record<string, unknown> = {};

  constructor(private o: VfxOptions) {
    // test hook: headless software GL always starts at quality level 2 (sprite path); ?fluid=1 forces the desktop fluid path there
    if (typeof location !== "undefined" && new URLSearchParams(location.search).get("fluid") === "1") o.low = false;
    this.fx = createFx(o.scene, o.camera);
    this.fx.setElement("frost");
    this.pools = this.fx.core.pools;
    this.rune = new RuneDisc(o.scene, o.renderer);
    this.spears = new SpearPool(o.scene, o.low ? 3 : 5);
    this.orbShell = new OrbShell(o.scene);
    if (!o.low) {
      try {
        this.fluid = createFluidHost({ renderer: o.renderer, scene: o.scene, camera: o.camera });
        // One high fire (the kit's budget rule): bolt flame + hand flame + two or three blasts at the busiest moment.
        this.fire = this.fluid.add(new FireFX(this.fluid, { quality: "high", maxFires: 5 }));
      } catch (e) {
        console.error("[arena] fluid fire unavailable", e);
        this.fluid = null;
        this.fire = null;
      }
    }
  }

  /** Compile every effect shader before the first visible frame. */
  warm() {
    try {
      this.fluid?.warm(2);
    } catch (e) {
      console.error("[arena] fluid warm failed", e);
    }
    // the pooled meshes were visible (parked under the floor) through the scene warm-up so their programs exist: hide them now
    this.rune.park();
    this.spears.park();
    this.orbShell.park();
  }

  /** Game hands over the staff head and the off hand so the cast gather can sit on them. */
  bindHero(head: THREE.Object3D | null, hand: THREE.Object3D | null) {
    this.head = head;
    this.hand = hand;
  }

  setReduced(b: boolean) {
    this.reduced = b;
  }

  update(dt: number) {
    this.clock += dt;
    this.fx.update(dt);
    this.fluid?.update(dt);
    this.updateHandFlames();
    this.blizzMist(dt);
  }
  beforeRender() {
    this.fluid?.beforeRender();
  }

  dispose() {
    this.fx.dispose?.();
    this.fluid?.dispose();
    this.rune.dispose(this.o.scene);
    this.spears.dispose(this.o.scene);
    this.orbShell.dispose(this.o.scene);
  }

  clear() {
    this.blizzardFx?.stop();
    this.blizzardFx = null;
    this.bliz = null;
    for (const v of this.projs.values()) this.stopFlame(v.flame);
    this.projs.clear();
    this.spears.freeAll();
    this.orbShell.hide();
    this.rune.hide();
    for (const h of this.handFlames.values()) this.stopFlame(h);
    this.handFlames.clear();
  }

  // ---------------------------------------------------------------- helpers
  private shake(n: number) {
    if (!this.reduced) this.o.shake(n);
  }
  private chest(u: { x: number; z: number }, y = 1.2): V3 {
    return [u.x, y, u.z];
  }
  private elemOf(school: string): Elem {
    return SCHOOL_ELEM[school] ?? "fire";
  }
  private glow(p: V3, el: Elem, size: number, life = 0.2) {
    const P = this.elP(el);
    this.pools.add.emit({ p, life, c0: rgba(P.orange, 0.8), c1: rgba(P.crimson, 0), s0: size * 0.6, s1: size * 1.6, cell: CELL.GLOW });
    this.pools.add.emit({ p, life: life * 0.6, c0: rgba(P.white, 0.9), c1: rgba(P.gold, 0), s0: size * 0.5, s1: size * 1.3, cell: CELL.STAR6 });
  }
  private elP(el: Elem): any {
    return (ELEMENTS as any)[el].p;
  }
  private dirTo(a: { x: number; z: number }, b: { x: number; z: number }): V3 {
    const dx = b.x - a.x, dz = b.z - a.z, l = Math.hypot(dx, dz) || 1;
    return [dx / l, 0.1, dz / l];
  }
  private storm(p: V3, el: Elem, opts: { radius?: number; strikes?: number } = {}) {
    if (this.o.low && this.clock - this.stormT < 1.2) return;
    if (this.clock - this.stormT < 0.45) return;
    this.stormT = this.clock;
    this.fx.stormPillar(p, { element: el, radius: opts.radius ?? 0.8, strikes: opts.strikes ?? 2 });
  }

  // ---------------------------------------------------------------- fluid flames (the NinjaFluid kit)
  /** Light a fluid flame that travels with something (a bolt, a hand). null when the kit is off (phone) or every slot is busy. */
  private flameOn(x: number, y: number, z: number, size: number): FireHandle | null {
    if (!this.fire) return null;
    try {
      const h = this.fire.ignite(this.tmpV.set(x, y, z), size, { duration: 90 });
      if (!h) return null;
      // a moving flame leaves no scorch mark and casts no ground shadow
      const s = h.slot as any;
      if (s.scorch) s.scorch.opacity = 0;
      if (s.plume) s.plume.shadow.visible = false;
      return h;
    } catch {
      return null;
    }
  }
  /** Carry the flame to a new spot; vx/vz is the way it travels, so the fluid leans back and trails behind. */
  private flameTo(h: FireHandle | null, x: number, y: number, z: number, vx: number, vz: number) {
    if (!h || !this.fire) return;
    const s = h.slot as any;
    if (s.generation !== h.generation || !s.active) return;
    s.pos.set(x, y, z);
    s.mesh.position.set(x, y, z);
    s.light.position.set(x, y + s.size * 0.6, z);
    const p = s.plume;
    if (p) {
      p.mesh.position.set(x, y, z);
      const cam = this.o.camera.position, a = Math.atan2(cam.x - x, cam.z - z);
      p.right.set(Math.cos(a), 0, -Math.sin(a)); // keep the fluid sheet facing the camera
      p.shadow.visible = false;
    }
    this.fire.wake(this.tmpV.set(x, y, z), -vx, -vz, 1.6);
  }
  private stopFlame(h: FireHandle | null) {
    if (!h || !this.fire) return;
    this.fire.extinguish(h);
    const s = h.slot as any;
    if (s.generation === h.generation) s.offAge = Math.max(s.offAge, 4.2); // fade in about two seconds instead of seven
  }
  private updateHandFlames() {
    const sim = this.simRef;
    if (!sim || this.handFlames.size === 0) return;
    for (const [id, h] of this.handFlames) {
      const u = sim.unit(id);
      if (!u || u.dead) {
        this.stopFlame(h);
        this.handFlames.delete(id);
        continue;
      }
      this.flameTo(h, u.x + Math.sin(u.yaw) * 0.55, 1.55, u.z + Math.cos(u.yaw) * 0.55, 0, 0);
    }
  }

  // ---------------------------------------------------------------- cast gather (hero)
  /** One additive sprite through the reusable scratch object (no per-call arrays). */
  private spr(pool: any, px: number, py: number, pz: number, vx: number, vy: number, vz: number, life: number, c0: number[], a0: number, c1: number[], a1: number, s0: number, s1: number, cell: number, extra?: { mode?: number; spin?: number; rot?: number; g?: number; drag?: number }) {
    const e = this.sE as any;
    this.sP[0] = px;
    this.sP[1] = py;
    this.sP[2] = pz;
    this.sV[0] = vx;
    this.sV[1] = vy;
    this.sV[2] = vz;
    this.sC0[0] = c0[0];
    this.sC0[1] = c0[1];
    this.sC0[2] = c0[2];
    this.sC0[3] = a0;
    this.sC1[0] = c1[0];
    this.sC1[1] = c1[1];
    this.sC1[2] = c1[2];
    this.sC1[3] = a1;
    e.p = this.sP;
    e.v = this.sV;
    e.life = life;
    e.c0 = this.sC0;
    e.c1 = this.sC1;
    e.s0 = s0;
    e.s1 = s1;
    e.cell = cell;
    e.mode = extra?.mode ?? 0;
    e.spin = extra?.spin ?? 0;
    e.rot = extra?.rot ?? 0;
    e.g = extra?.g ?? 0;
    e.drag = extra?.drag ?? 0;
    pool.emit(e);
  }

  /** Per frame (playing): frost energy gathers at the staff head and the off hand while a cast-time spell charges. */
  castUpdate(sim: Sim, dt: number) {
    this.simRef = sim;
    const p = sim.player, c = p.cast;
    const charging = !!c && c.kind === "cast" && !p.dead;
    this.rune.update(dt, charging);
    if (!charging || !c) {
      this.castP = 0;
      return;
    }
    const k = clamp(1 - c.left / c.total, 0, 1);
    this.castP = k;
    const arcane = c.school === "arcane";
    const P = this.elP(arcane ? "arcane" : "frost");
    if (arcane) this.rune.tint(0.95, 0.5, 1.5);
    else this.rune.tint(0.35, 1.0, 1.5);
    this.rune.charge(p.x, p.z, k, dt);
    const head = this.head, hand = this.hand;
    if (!head) return;
    head.updateWorldMatrix(true, false);
    const hp = head.getWorldPosition(this.tmpA);
    const hx = hp.x, hy = hp.y + 0.12, hz = hp.z;
    const low = this.o.low;
    // inward motes: born on a shell around the head, drawn into it over their life
    this.gatherAcc += dt * (low ? 22 : 46 + 70 * k);
    while (this.gatherAcc >= 1) {
      this.gatherAcc -= 1;
      const a = rnd(0, 6.283), b = rnd(-1, 1), r = rnd(0.55, 1.0), rr = Math.sqrt(1 - b * b), life = rnd(0.32, 0.5);
      const ox = Math.cos(a) * rr * r, oy = b * r, oz = Math.sin(a) * rr * r;
      this.spr(this.pools.add, hx + ox, hy + oy, hz + oz, -ox / life, -oy / life, -oz / life, life, P.white, 0.9, P.gold, 0, rnd(0.09, 0.15), 0.03, CELL.EMBER, { mode: MODE.FLICKER });
    }
    // swirling motes: a ring that turns around the crystal and rises
    this.swirlAcc += dt * (low ? 8 : 16 + 22 * k);
    while (this.swirlAcc >= 1) {
      this.swirlAcc -= 1;
      this.spr(this.pools.add, hx, hy - 0.2, hz, rnd(0.32, 0.5) * (0.8 + k * 0.5), rnd(0.5, 1.2), (Math.random() < 0.5 ? -1 : 1) * rnd(7, 11), rnd(0.45, 0.7), P.white, 0.8, P.orange, 0, 0.1, 0.03, CELL.EMBER, { mode: MODE.ORBIT, rot: rnd(0, 6.28) });
    }
    // soft glow + a small star that swell with the bar (additive, capped so it never washes the frame)
    this.spr(this.pools.add, hx, hy, hz, 0, 0, 0, 0.06, P.orange, 0.5 + 0.35 * k, P.orange, 0.5 + 0.35 * k, 0.95 + 1.3 * k, 0.95 + 1.3 * k, CELL.GLOW);
    this.spr(this.pools.add, hx, hy, hz, 0, 0, 0, 0.06, P.white, 0.55, P.white, 0.55, 0.22 + 0.34 * k, 0.22 + 0.34 * k, CELL.STAR6, { spin: 5 });
    if (hand) {
      hand.updateWorldMatrix(true, false);
      const op = hand.getWorldPosition(this.tmpB);
      this.handAcc += dt * (low ? 8 : 20 + 26 * k);
      while (this.handAcc >= 1) {
        this.handAcc -= 1;
        const a = rnd(0, 6.283), r = rnd(0.35, 0.6), life = rnd(0.28, 0.4);
        const ox = Math.cos(a) * r, oy = rnd(-0.3, 0.3), oz = Math.sin(a) * r;
        this.spr(this.pools.add, op.x + ox, op.y + oy, op.z + oz, -ox / life, -oy / life, -oz / life, life, P.white, 0.85, P.gold, 0, rnd(0.07, 0.12), 0.03, CELL.EMBER, { mode: MODE.FLICKER });
      }
      this.spr(this.pools.add, op.x, op.y, op.z, 0, 0, 0, 0.06, P.orange, 0.3 + 0.28 * k, P.orange, 0.3 + 0.28 * k, 0.4 + 0.6 * k, 0.4 + 0.6 * k, CELL.GLOW);
    }
  }
  /** Where the spell leaves the hero: the staff head when bound, else a point in front of the chest. */
  private muzzle(sim: Sim, f: V3): V3 {
    if (this.head) {
      this.head.updateWorldMatrix(true, false);
      const w = this.head.getWorldPosition(this.tmpA);
      return [w.x, w.y + 0.12, w.z];
    }
    const p = sim.player;
    return [p.x + f[0] * 0.7, 1.45, p.z + f[2] * 0.7];
  }

  // ---------------------------------------------------------------- blizzard mist
  /** A cold low mist rolling inside the Blizzard circle (soft smoke puffs, cool-tinted, never opaque). */
  private blizzMist(dt: number) {
    const b = this.bliz;
    if (!b) return;
    b.left -= dt;
    if (b.left <= 0) {
      this.bliz = null;
      return;
    }
    const P = this.elP("frost");
    this.mistAcc += dt * (this.o.low ? 5 : 15);
    while (this.mistAcc >= 1) {
      this.mistAcc -= 1;
      const a = rnd(0, 6.283), r = Math.sqrt(Math.random()) * b.r * 0.95;
      this.spr(this.pools.smoke, b.x + Math.cos(a) * r, rnd(0.15, 0.6), b.z + Math.sin(a) * r, rnd(-0.5, 0.5), rnd(0.05, 0.3), rnd(-0.5, 0.5), rnd(1.5, 2.2), P.smokeCelHot, 0.26, P.smokeCel, 0, 1.0, rnd(2.0, 2.8), CELL.SMOKE, { rot: rnd(0, 6.28), g: -0.05, drag: 1 });
    }
  }

  // ---------------------------------------------------------------- projectiles
  private projColor(k: Proj["kind"]): Elem {
    return k === "fireball" ? "fire" : k === "shadowbolt" ? "arcane" : "frost";
  }
  private newVis(p: Proj): ProjVis {
    const v: ProjVis = { x: p.x, y: p.y, z: p.z, kind: p.kind, flame: null, spear: -1, spin: 0, glint: 0, dx: 0, dy: 0, dz: 1 };
    if (p.kind === "fireball") v.flame = this.flameOn(p.x, p.y, p.z, 0.46);
    else if (p.kind === "icelance") v.spear = this.spears.take();
    return v;
  }
  private dropVis(id: number) {
    const v = this.projs.get(id);
    if (!v) return;
    this.stopFlame(v.flame);
    this.spears.free(v.spear);
    this.projs.delete(id);
  }
  /** Per frame: draw each live projectile as a glowing head with a trail, filled in along the path it just travelled. */
  projUpdate(list: Proj[]) {
    const { add, streak, flame, smoke } = this.pools;
    const pdt = Math.min(0.1, this.clock - this.projClock);
    this.projClock = this.clock;
    for (const p of list) {
      let v = this.projs.get(p.id);
      if (!v) {
        v = this.newVis(p);
        this.projs.set(p.id, v);
      }
      const el = this.projColor(p.kind), P = this.elP(el);
      const dx = p.x - v.x, dy = p.y - v.y, dz = p.z - v.z, len = Math.hypot(dx, dy, dz);
      const d: V3 = len > 1e-5 ? [dx / len, dy / len, dz / len] : [v.dx, v.dy, v.dz];
      if (len > 1e-5) {
        v.dx = d[0];
        v.dy = d[1];
        v.dz = d[2];
      }
      if (v.flame) this.flameTo(v.flame, p.x, p.y, p.z, d[0] * 14, d[2] * 14);
      if (v.spear >= 0) {
        v.spin += pdt * 15;
        this.spears.place(v.spear, p.x, p.y, p.z, d[0], d[1], d[2], v.spin);
        v.glint -= pdt;
        if (v.glint <= 0) {
          v.glint = 0.11;
          this.spr(add, p.x + d[0] * 0.75, p.y + d[1] * 0.75, p.z + d[2] * 0.75, 0, 0, 0, 0.09, P.white, 0.95, P.white, 0, 0.7, 0.2, CELL.STAR6, { spin: 6 });
        }
      }
      const steps = clamp(Math.ceil(len / 0.3), 1, 6);
      const big = p.kind === "fireball" ? 1.4 : p.kind === "icelance" ? 0.8 : 1;
      for (let k = 1; k <= steps; k++) {
        const t = k / steps;
        const q: V3 = [v.x + dx * t, v.y + dy * t, v.z + dz * t];
        const last = k === steps;
        if (p.kind === "icelance") {
          if (last) {
            if (v.spear < 0) streak.emit({ p: q, v: [d[0] * 40, d[1] * 40, d[2] * 40], life: 0.06, c0: rgba(P.white), c1: rgba(P.white), s0: 0.34, s1: 0.34, stretch: 0.1, cell: CELL.STREAK });
            add.emit({ p: q, life: 0.07, c0: rgba(P.orange, 0.6), c1: rgba(P.orange, 0.6), s0: 1.0, s1: 1.0, cell: CELL.GLOW });
          }
          streak.emit({ p: q, v: [d[0] * 4, d[1] * 4, d[2] * 4], life: 0.22, c0: rgba(P.gold, 0.9), c1: rgba(P.crimson, 0), s0: 0.14, s1: 0.03, stretch: 0.06, cell: CELL.STREAK });
          if (k % 2 === 0) add.emit({ p: q, v: [rnd(-0.6, 0.6), rnd(-0.2, 0.6), rnd(-0.6, 0.6)], life: rnd(0.25, 0.5), c0: rgba(P.ember), c1: rgba(P.emberEnd, 0), s0: 0.09, s1: 0.02, cell: CELL.EMBER, mode: MODE.FLICKER });
        } else if (p.kind === "fireball") {
          if (last) {
            // with a fluid flame riding the bolt the head is just a hot core; without it the sprite ball stays
            const hot = v.flame ? 0.55 : 1;
            add.emit({ p: q, life: 0.08, c0: rgba(P.orange, 0.9 * hot), c1: rgba(P.orange, 0.9 * hot), s0: 2.0 * hot, s1: 2.0 * hot, cell: CELL.GLOW });
            add.emit({ p: q, life: 0.08, c0: rgba(P.white), c1: rgba(P.white), s0: 0.6 * hot, s1: 0.6 * hot, spin: 8, cell: CELL.STAR6 });
          }
          if (!v.flame) flame.emit({ p: q, v: [-d[0] * 1.5 + rnd(-0.4, 0.4), rnd(0.2, 1), -d[2] * 1.5 + rnd(-0.4, 0.4)], life: rnd(0.3, 0.5), c0: [1, 1, 1, 1], c1: [1, 1, 1, 0.5], s0: 0.55 * big, s1: 0.2, rot: rnd(-0.3, 0.3), spin: rnd(-1, 1), g: -1.2, drag: 1.5, cell: CELL.TONGUE, elem: 0 });
          if (k % 2 === 0) smoke.emit({ p: q, v: [rnd(-0.3, 0.3), rnd(0.2, 0.8), rnd(-0.3, 0.3)], life: rnd(0.7, 1.1), c0: rgba(P.smokeCel, 0.7), c1: rgba(P.smokeCelHot, 0), s0: 0.3, s1: 1.1, rot: rnd(0, 6.28), spin: rnd(-0.5, 0.5), g: -0.6, drag: 2, cell: CELL.SMOKE });
          if (k % 3 === 0) add.emit({ p: q, v: [rnd(-1, 1), rnd(0, 1.5), rnd(-1, 1)], life: rnd(0.4, 0.8), c0: rgba(P.ember), c1: rgba(P.emberEnd, 0), s0: 0.09, s1: 0.02, g: -0.4, cell: CELL.EMBER, mode: MODE.FLICKER });
        } else {
          if (last) {
            add.emit({ p: q, life: 0.08, c0: rgba(P.orange, 0.85), c1: rgba(P.orange, 0.85), s0: 1.5, s1: 1.5, cell: CELL.GLOW });
            add.emit({ p: q, life: 0.08, c0: rgba(P.white), c1: rgba(P.white), s0: 0.55, s1: 0.55, spin: 9, cell: CELL.STAR6 });
            add.emit({ p: q, life: 0.08, c0: rgba(P.gold, 0.7), c1: rgba(P.gold, 0.7), s0: 0.8, s1: 0.8, spin: -6, cell: CELL.STAR8 });
          }
          add.emit({ p: q, v: [-d[0] * 1.2 + rnd(-0.5, 0.5), rnd(-0.3, 0.6), -d[2] * 1.2 + rnd(-0.5, 0.5)], life: rnd(0.25, 0.45), c0: rgba(P.orange, 0.7), c1: rgba(P.crimson, 0), s0: 0.55, s1: 0.1, cell: CELL.GLOW });
          if (k % 2 === 0) streak.emit({ p: q, v: [-d[0] * 2, 0, -d[2] * 2], life: 0.16, c0: rgba(P.gold), c1: rgba(P.crimson, 0), s0: 0.1, s1: 0.03, drag: 3, stretch: 0.05, cell: CELL.STREAK });
          if (k % 2 === 1) add.emit({ p: q, v: [rnd(-0.6, 0.6), rnd(-0.4, 0.8), rnd(-0.6, 0.6)], life: rnd(0.5, 0.9), c0: rgba(P.ember), c1: rgba(P.emberEnd, 0), s0: 0.08, s1: 0.02, g: p.kind === "shadowbolt" ? -0.6 : 0.3, cell: CELL.EMBER, mode: MODE.FLICKER });
          if (p.kind === "frostbolt") {
            // frost mist: soft cold puffs left along the path, plus a pale core that outshines the trail
            this.spr(smoke, q[0], q[1] - 0.05, q[2], rnd(-0.3, 0.3) - d[0] * 0.4, rnd(0.05, 0.35), rnd(-0.3, 0.3) - d[2] * 0.4, rnd(0.6, 0.95), P.smokeCelHot, 0.5, P.smokeCel, 0, 0.45, rnd(1.2, 1.7), CELL.SMOKE, { rot: rnd(0, 6.28), drag: 2 });
            if (last) {
              this.spr(add, q[0], q[1], q[2], 0, 0, 0, 0.08, P.orange, 0.9, P.orange, 0.9, 1.9, 1.9, CELL.GLOW);
              this.spr(add, q[0], q[1], q[2], 0, 0, 0, 0.08, P.white, 0.95, P.white, 0.95, 0.9, 0.9, CELL.FLARE);
              this.spr(streak, q[0], q[1], q[2], d[0] * 30, d[1] * 30, d[2] * 30, 0.07, P.orange, 0.8, P.orange, 0.8, 0.4, 0.4, CELL.STREAK);
            }
          }
          if (p.kind === "shadowbolt" && k % 2 === 0) smoke.emit({ p: q, v: [rnd(-0.2, 0.2), rnd(0.2, 0.6), rnd(-0.2, 0.2)], life: rnd(0.6, 0.9), c0: rgba(P.smokeCelHot, 0.8), c1: rgba(P.smokeCel, 0), s0: 0.25, s1: 0.9, rot: rnd(0, 6.28), cell: CELL.SMOKE });
        }
      }
      v.x = p.x;
      v.y = p.y;
      v.z = p.z;
    }
  }

  /** Frozen orb: swirling frost flakes, shed shards and frost arcs. Called every frame with the sim orb (or null). */
  orb(o: { x: number; z: number } | null, dt: number) {
    if (!o) {
      this.orbShell.hide();
      return;
    }
    const P = this.elP("frost");
    const c: V3 = [o.x, 1.05, o.z];
    this.orbShell.place(o.x, 1.05, o.z, this.clock);
    // a trail of cold mist hugging the floor behind the orb
    if (Math.random() < dt * (this.o.low ? 6 : 16)) this.spr(this.pools.smoke, o.x + rnd(-0.5, 0.5), 0.25, o.z + rnd(-0.5, 0.5), rnd(-0.3, 0.3), rnd(0.05, 0.25), rnd(-0.3, 0.3), rnd(1.1, 1.6), P.smokeCelHot, 0.28, P.smokeCel, 0, 0.8, rnd(1.8, 2.4), CELL.SMOKE, { rot: rnd(0, 6.28), drag: 1.5 });
    this.pools.add.emit({ p: c, life: 0.06, c0: rgba(P.orange, 0.9), c1: rgba(P.orange, 0.9), s0: 3.2, s1: 3.2, cell: CELL.GLOW });
    this.pools.add.emit({ p: c, life: 0.06, c0: rgba(P.white, 0.9), c1: rgba(P.white, 0.9), s0: 1.6, s1: 1.6, spin: 4, cell: CELL.STAR8 });
    this.orbAcc += dt * (this.o.low ? 40 : 90);
    while (this.orbAcc >= 1) {
      this.orbAcc -= 1;
      const r = rnd(0.7, 1.6);
      this.pools.add.emit({ p: c, v: [r, rnd(-0.4, 0.5), rnd(2.2, 4.2)], rot: rnd(0, 6.28), life: rnd(0.5, 0.9), c0: rgba(P.white, 0.85), c1: rgba(P.gold, 0), s0: rnd(0.1, 0.17), s1: 0.04, cell: CELL.EMBER, mode: MODE.ORBIT });
      if (Math.random() < 0.35) this.pools.streak.emit({ p: [c[0] + rnd(-0.4, 0.4), c[1] + rnd(-0.4, 0.4), c[2] + rnd(-0.4, 0.4)], v: [rnd(-3, 3), rnd(-1, 2), rnd(-3, 3)], life: rnd(0.2, 0.4), c0: rgba(P.gold), c1: rgba(P.crimson, 0), s0: 0.07, s1: 0.02, drag: 2, stretch: 0.05, cell: CELL.STREAK });
    }
    this.orbArc -= dt;
    if (this.orbArc <= 0) {
      this.orbArc = this.o.low ? 0.5 : 0.22;
      const a = rnd(0, 6.28), r = rnd(2.5, 4.5);
      this.fx.lightning(c, [c[0] + Math.sin(a) * r, 0.5 + rnd(0, 1.2), c[2] + Math.cos(a) * r], { element: "frost", life: 0.22, width: 0.12, forks: 1 });
      for (let k = 0; k < 3; k++) {
        const a2 = rnd(0, 6.28), out = rnd(1.5, 3.5);
        this.pools.shard.spawn({ p: [c[0], c[1], c[2]], r: rnd(0.05, 0.1), h: rnd(0.2, 0.4), life: rnd(0.6, 1), ballistic: true, v: [Math.sin(a2) * out, rnd(1, 3.5), Math.cos(a2) * out], spin: rnd(-9, 9), yaw: rnd(0, 6.28), tiltX: rnd(0, 6.28), tiltZ: rnd(0, 6.28), elem: 1 });
      }
    }
  }

  // ---------------------------------------------------------------- events
  onEvents(evts: SimEvent[], sim: Sim) {
    for (const e of evts) this.one(e, sim);
  }

  private one(e: SimEvent, sim: Sim) {
    const fx = this.fx;
    const unit = (id: string): Unit | undefined => sim.unit(id) ?? undefined;
    switch (e.t) {
      case "whistle": {
        const boss = sim.round === 5;
        const n = this.o.low ? 3 : boss ? 8 : 6;
        fx.stormRing([0, 0, 0], { element: "frost", count: n, radius: boss ? 11 : 9, strikes: 1, pillarRadius: boss ? 1.1 : 0.9 });
        fx.shockwave([0, 0.1, 0], { element: "frost" });
        this.shake(boss ? 0.5 : 0.3);
        break;
      }
      case "castStart": {
        const u = unit(e.unit);
        if (!u || e.id === "swing" || e.id === "ham" || e.id === "conc") break;
        const school: string = e.school ?? "frost";
        const el = this.elemOf(school);
        if (u.kind === "player") {
          // the growing rune disc and the gather at the staff are driven per frame by castUpdate()
        } else if (e.id === "charge" || e.id === "whirl") {
          fx.dust([u.x, 0.1, u.z], 0.66);
        } else {
          fx.runeCircle([u.x, 0, u.z], { element: e.id === "heal" || e.id === "bheal" ? "fire" : el, radius: e.id === "scream" ? 3.4 : 2.1, duration: e.total + 0.3, intensity: 0.85 });
          if (e.id === "fireball" && this.fire) {
            // the Fire Mage charges a fluid flame in his hand until the bolt leaves
            this.stopFlame(this.handFlames.get(e.unit) ?? null);
            const h = this.flameOn(u.x + Math.sin(u.yaw) * 0.55, 1.55, u.z + Math.cos(u.yaw) * 0.55, 0.24);
            if (h) this.handFlames.set(e.unit, h);
          }
          if (e.id === "heal" || e.id === "bheal") fx.runeCircle([u.x, 0, u.z], { element: "fire", radius: 1.2, duration: e.total + 0.3, rotation: 1, intensity: 0.7 });
        }
        break;
      }
      case "release":
        this.release(e, sim);
        break;
      case "castEnd": {
        const h = this.handFlames.get(e.unit);
        if (h) {
          this.stopFlame(h);
          this.handFlames.delete(e.unit);
        }
        if (!e.ok && e.unit === "player") this.rune.hide();
        break;
      }
      case "blizzardStart": {
        this.blizzardFx?.stop();
        const c: V3 = [e.x, 0, e.z];
        fx.runeCircle(c, { element: "frost", radius: e.radius * 0.95, duration: e.dur, intensity: 0.9 });
        this.blizzardFx = fx.blizzard(c, { element: "frost", radius: e.radius, duration: e.dur + 0.4 }) ?? null;
        this.bliz = { x: e.x, z: e.z, r: e.radius, left: e.dur + 0.4 };
        this.o.text.decal(e.x, e.z, e.radius * 1.05, e.dur + 0.6, 1);
        fx.frostNova(c, { element: "frost", radius: e.radius * 0.6 });
        this.shake(0.15);
        break;
      }
      case "blizzardTick":
        fx.shockwave([e.x, 0.1, e.z], { element: "frost" });
        break;
      case "blizzardEnd":
        if (!e.ok) this.blizzardFx?.stop();
        this.blizzardFx = null;
        this.bliz = null;
        break;
      case "enemyAct": {
        const u = unit(e.unit);
        if (!u) break;
        const f: V3 = [Math.sin(u.yaw), 0, Math.cos(u.yaw)];
        if (e.act === "swing" || e.act === "pummel" || e.act === "throat") {
          fx.slash([u.x + f[0] * 0.9, 1.15, u.z + f[2] * 0.9], u.yaw + rnd(-0.25, 0.25), { radius: 2.3, arc: 2.5, tilt: rnd(-0.6, 0.6), color: 0xff4a2a, core: 0xfff0d8, duration: 0.24, element: "fire" });
        } else if (e.act === "fireblast") {
          const p = sim.player;
          this.fireHit([p.x, 1.2, p.z], 0.6);
          fx.boltFan([u.x + f[0] * 0.6, 1.4, u.z + f[2] * 0.6], u.yaw, { element: "fire", count: 3, spread: 0.35, speed: 28, range: 3, size: 0.6 });
        } else if (e.act === "pain") {
          const p = sim.player;
          fx.soulWisps([p.x, 0.7, p.z], { element: "arcane", count: 3 });
          fx.lightning([u.x, 1.4, u.z], [p.x, 1.2, p.z], { element: "arcane", life: 0.25, width: 0.14, forks: 2 });
        } else if (e.act === "windup") {
          fx.telegraph?.([u.x + f[0], 0.05, u.z + f[2]], 1.4, 0.4);
        }
        break;
      }
      case "chargeHit": {
        const p = sim.player;
        fx.shockwave([p.x, 0.1, p.z], { element: "fire" });
        fx.dust([p.x, 0.1, p.z], 1.1);
        fx.hitSpark([p.x, 1.1, p.z], [0, 0.4, 1], 2.4, false, "fire");
        this.shake(0.35);
        break;
      }
      case "whirlTick": {
        const u = unit(e.unit);
        if (!u) break;
        fx.slash([u.x, 1.1, u.z], this.clock * 9, { radius: 3.4, arc: Math.PI * 2, startAngle: 0, width: 1.3, tilt: 0, color: 0xff6a3a, core: 0xfff0d8, duration: 0.3, element: "fire" });
        fx.dust([u.x, 0.1, u.z], 0.44);
        break;
      }
      case "shockwave": {
        const u = unit(e.unit);
        if (!u) break;
        fx.groundCrack([u.x, 0.05, u.z], 8, "fire");
        fx.shockwave([u.x, 0.1, u.z], { element: "fire" });
        fx.dust([u.x, 0.1, u.z], 1.32);
        this.shake(0.35);
        break;
      }
      // Combat-feel events (dash, dodge, shatter, enemy leap): the fast action moments get their own effects.
      case "dash": {
        const el: Elem = "frost";
        fx.dust([e.from.x, 0.1, e.from.z], 0.61);
        fx.shockwave([e.from.x, 0.1, e.from.z], { element: el });
        fx.beam([e.from.x, 0.9, e.from.z], [e.to.x, 0.9, e.to.z], { element: el, duration: 0.22, width: 0.4 });
        fx.soulWisps([e.from.x, 0.9, e.from.z], { element: el, count: this.o.low ? 1 : 2 });
        break;
      }
      case "dodge": {
        const u = unit(e.unit);
        if (u) this.glow(this.chest(u, 1.1), "frost", 1.2, 0.25);
        break;
      }
      case "shatter": {
        const c: V3 = [e.x, 1.1, e.z];
        fx.iceSpikes([e.x, 0, e.z], 0, { element: "frost", mode: "ring", count: this.o.low ? 6 : 10, radius: 1.2, size: 0.7, life: 0.8 });
        this.o.text.decal(e.x, e.z, 2.6, 2.4, 0);
        fx.boltImpact(c, { element: "frost", scale: 2.2 });
        fx.shockwave([e.x, 0.1, e.z], { element: "frost" });
        this.storm([e.x, 0, e.z], "frost", { radius: 1, strikes: 3 });
        this.shake(0.4);
        break;
      }
      case "enemyLeapTell": {
        fx.telegraph?.([e.to.x, 0.05, e.to.z], 2.6, e.tell);
        fx.runeCircle([e.to.x, 0, e.to.z], { element: "fire", radius: 2.6, duration: e.tell + 0.2, intensity: 0.9 });
        break;
      }
      case "enemyLeap": {
        fx.dust([e.from.x, 0.1, e.from.z], 0.88);
        break;
      }
      case "enemyLand": {
        fx.groundCrack([e.x, 0.05, e.z], 6, "fire");
        fx.shockwave([e.x, 0.1, e.z], { element: "fire" });
        fx.dust([e.x, 0.1, e.z], 1.43);
        this.shake(e.hit ? 0.45 : 0.25);
        break;
      }
      case "proj": {
        const p: Proj = e.proj;
        this.dropVis(p.id);
        this.projs.set(p.id, this.newVis(p));
        const s = unit(p.src);
        if (s) {
          const el = this.projColor(p.kind);
          this.glow([s.x + Math.sin(s.yaw) * 0.6, 1.4, s.z + Math.cos(s.yaw) * 0.6], el, 1.1, 0.25);
        }
        break;
      }
      case "projGone":
        this.dropVis(e.proj.id);
        break;
      case "projHit": {
        const p: Proj = e.proj;
        this.dropVis(p.id);
        this.projHit(p, e.dst);
        break;
      }
      case "projBlock": {
        const p: Proj = e.proj;
        this.dropVis(p.id);
        const el = this.projColor(p.kind), pos: V3 = [p.x, 1.4, p.z];
        fx.hitSpark(pos, [0, 0.3, 0], 1.6, el === "fire", el);
        if (el === "fire") fx.fireBurst(pos, 0.8, "fire");
        else fx.boltImpact(pos, { element: el, scale: 0.9 });
        // no cel dust puff at body height here: it read as a crude brown blob (operator, 25 Oct)
        break;
      }
      case "nova": {
        const R = this.o.low ? 6 : 8;
        fx.frostNova([e.x, 0, e.z], { element: "frost", radius: R });
        fx.groundCrack([e.x, 0.05, e.z], 6, "frost");
        // an inner crown of tall spikes erupts a beat later, so the ring reads as two waves that then shatter
        fx.iceSpikes([e.x, 0, e.z], 0, { element: "frost", mode: "ring", count: this.o.low ? 6 : 10, radius: 1.9, size: 1.05, delay: 0.16, life: 1.0 });
        this.o.text.decal(e.x, e.z, R * 0.85, 2.6, 0);
        this.shake(0.3);
        break;
      }
      case "blink":
        this.blink(e.from, e.to, "arcane");
        break;
      case "enemyBlink":
        this.blink(e.from, e.to, "fire");
        break;
      case "orbPulse": {
        const c: V3 = [e.x, 1, e.z];
        fx.shieldFlash(c, 0, { element: "frost", size: 1.6 });
        fx.shockwave([e.x, 0.1, e.z], { element: "frost" });
        this.shardBurst(c, "frost", 6, [2, 5]);
        break;
      }
      case "damage":
        this.damage(e, sim);
        break;
      case "heal": {
        const u = unit(e.unit);
        if (!u) break;
        if (u.kind === "player") {
          fx.soulWisps([u.x, 0.5, u.z], { element: "nature", count: 2 });
        } else {
          fx.stormPillar([u.x, 0, u.z], { element: "fire", radius: 0.8, strikes: 1 });
          fx.runeCircle([u.x, 0, u.z], { element: "fire", radius: 2, duration: 0.8 });
          fx.soulWisps([u.x, 0.6, u.z], { element: "fire", count: 4 });
          fx.embers([u.x, 0.3, u.z], this.o.low ? 12 : 26, 0.8, "fire");
        }
        break;
      }
      case "cc": {
        const u = unit(e.unit);
        if (!u) break;
        if (e.cat === "root") {
          fx.iceSpikes([u.x, 0, u.z], 0, { element: "frost", mode: "ring", count: 5, radius: 0.9, size: 0.45, life: Math.min(2.4, e.dur) });
        } else if (e.cat === "disorient") {
          fx.soulWisps([u.x, 0.4, u.z], { element: "arcane", count: 5 });
          fx.shockwave([u.x, 0.1, u.z], { element: "arcane" });
          if (u.kind === "player") this.shake(0.25);
        } else if (e.cat === "stun") {
          fx.hitSpark([u.x, 2, u.z], [0, 1, 0], 1.4, false, "fire");
        }
        break;
      }
      case "polyOn": {
        const u = unit(e.unit);
        if (!u) break;
        fx.runeCircle([u.x, 0, u.z], { element: "arcane", radius: 2.4, duration: 1.1, spin: 2, intensity: 1 });
        fx.boltImpact([u.x, 1, u.z], { element: "arcane", scale: 1.3 });
        fx.soulWisps([u.x, 1, u.z], { element: "arcane", count: 3 });
        fx.embers([u.x, 0.5, u.z], 24, 0.7, "arcane");
        fx.soulWisps([u.x, 0.8, u.z], { element: "arcane", count: 3 });
        break;
      }
      case "polyBreak": {
        const u = unit(e.unit);
        if (u) fx.hitSpark([u.x, 0.9, u.z], [0, 1, 0], 1.2, false, "arcane");
        break;
      }
      case "interrupt": {
        const u = unit(e.unit), by = unit(e.by);
        if (!u || by?.kind !== "player") break;
        const c: V3 = [u.x, 1.5, u.z];
        const d = this.dirTo(by, u);
        fx.shieldFlash(c, Math.atan2(-d[0], -d[2]), { element: "arcane", size: 1.8 });
        fx.runeCircle([u.x, 0, u.z], { element: "arcane", radius: 1.8, duration: 0.45, tail: 0.3, spin: 6, intensity: 1 });
        fx.boneShatter(c, { color: [1.3, 0.45, 1.9], scale: 0.9, dir: [d[0], 0, d[2]] });
        fx.hitSpark(c, d, 2.4, false, "arcane");
        this.shake(0.25);
        break;
      }
      case "kill": {
        const h = this.handFlames.get(e.unit);
        if (h) {
          this.stopFlame(h);
          this.handFlames.delete(e.unit);
        }
        this.death(e.unit, sim);
        break;
      }
      case "coinDrop":
        fx.hitSpark([e.x, 0.6, e.z], [0, 1, 0], 0.5, false, "fire");
        break;
      case "coinTake":
        fx.hitSpark([e.x, 0.8, e.z], [0, 1, 0], 0.9, false, "fire");
        fx.embers([e.x, 0.6, e.z], 10, 0.3, "fire");
        break;
    }
  }

  // ---------------------------------------------------------------- player spells on release
  private release(e: SimEvent, sim: Sim) {
    const fx = this.fx;
    const p = sim.player;
    const h = sim.heading;
    const f: V3 = [Math.sin(h), 0, Math.cos(h)];
    const hand: V3 = this.muzzle(sim, f);
    if (e.id === "frostbolt" || e.id === "polymorph") this.rune.release();
    switch (e.id) {
      case "frostbolt":
        this.glow(hand, "frost", 1.0, 0.22);
        fx.hitSpark(hand, f, 0.8, false, "frost");
        this.releaseFlash(hand, f, "frost");
        break;
      case "icelance":
        this.glow(hand, "frost", 0.9, 0.18);
        fx.shieldFlash(hand, h, { element: "frost", size: 0.7 });
        this.releaseFlash(hand, f, "frost");
        break;
      case "polymorph":
        fx.runeCircle([p.x, 0, p.z], { element: "arcane", radius: 1.6, duration: 0.7, intensity: 0.9 });
        this.glow(hand, "arcane", 1.4, 0.3);
        this.releaseFlash(hand, f, "arcane");
        break;
      case "counterspell":
        fx.lightning(hand, e.target ? this.chest(sim.unit(e.target) ?? p, 1.5) : [hand[0] + f[0] * 6, hand[1], hand[2] + f[2] * 6], { element: "arcane", life: 0.25, width: 0.2, forks: 2 });
        break;
      case "barrier": {
        fx.shieldFlash([p.x, 1.1, p.z], h, { element: "frost", size: 1.1 });
        fx.shockwave([p.x, 0.1, p.z], { element: "frost" });
        fx.iceSpikes([p.x, 0, p.z], 0, { element: "frost", mode: "ring", count: 6, radius: 1.5, size: 0.5, life: 0.9 });
        fx.embers([p.x, 1, p.z], this.o.low ? 10 : 24, 0.9, "frost");
        break;
      }
      case "iceblock": {
        fx.iceSpikes([p.x, 0, p.z], 0, { element: "frost", mode: "ring", count: 7, radius: 1.35, size: 0.9, life: 5 });
        fx.frostNova([p.x, 0, p.z], { element: "frost", radius: 3 });
        fx.boltImpact([p.x, 1.1, p.z], { element: "frost", scale: 1.2 });
        this.shake(0.3);
        break;
      }
      case "orb":
        fx.runeCircle([p.x, 0, p.z], { element: "frost", radius: 2.2, duration: 0.9, intensity: 0.9 });
        fx.hitSpark(hand, f, 1.0, false, "frost");
        this.glow(hand, "frost", 1.0, 0.25);
        this.releaseFlash(hand, f, "frost");
        this.shardBurst(hand, "frost", 7, [3, 6]);
        break;
      case "coldsnap": {
        fx.shockwave([p.x, 0.1, p.z], { element: "frost" });
        fx.frostNova([p.x, 0, p.z], { element: "frost", radius: 5 });
        fx.blizzard([p.x, 0, p.z], { element: "frost", radius: 4, duration: this.o.low ? 0.8 : 1.6 });
        fx.stormPillar([p.x, 0, p.z], { element: "frost", radius: 1.2, strikes: 3 });
        this.shake(0.45);
        break;
      }
    }
  }

  /** The staff flash on release: a bright star, a few forward streaks and a small cold ring. Brief and capped. */
  private releaseFlash(at: V3, f: V3, el: Elem) {
    const P = this.elP(el);
    this.spr(this.pools.add, at[0], at[1], at[2], 0, 0, 0, 0.14, P.white, 0.9, P.gold, 0, 0.5, 1.5, CELL.STAR8, { spin: 7 });
    this.spr(this.pools.add, at[0], at[1], at[2], 0, 0, 0, 0.2, P.orange, 0.32, P.crimson, 0, 0.4, 1.2, CELL.FLARE);
    const n = this.o.low ? 4 : 8;
    for (let k = 0; k < n; k++) {
      const a = rnd(-0.7, 0.7);
      const c = Math.cos(a), s = Math.sin(a), sp = rnd(5, 11);
      this.spr(this.pools.streak, at[0], at[1], at[2], (f[0] * c - f[2] * s) * sp, rnd(-0.5, 1.2), (f[0] * s + f[2] * c) * sp, rnd(0.14, 0.26), P.white, 0.9, P.crimson, 0, 0.09, 0.02, CELL.STREAK, { drag: 3 });
    }
  }
  /** A handful of ice shards thrown from a point (ballistic crystals from the spellfx shard pool). */
  private shardBurst(p: V3, el: Elem, n: number, speed: [number, number], up: [number, number] = [2, 5]) {
    const shard = this.pools.shard;
    const count = this.o.low ? Math.ceil(n / 2) : n;
    for (let k = 0; k < count; k++) {
      const a = rnd(0, 6.283), out = rnd(speed[0], speed[1]);
      shard.spawn({ p: [p[0], p[1], p[2]], r: rnd(0.04, 0.09), h: rnd(0.16, 0.34), life: rnd(0.5, 0.85), ballistic: true, v: [Math.sin(a) * out, rnd(up[0], up[1]), Math.cos(a) * out], spin: rnd(-10, 10), yaw: rnd(0, 6.28), tiltX: rnd(0, 6.28), tiltZ: rnd(0, 6.28), elem: el === "frost" ? 1 : 0, color: el === "frost" ? undefined : [1, 0.6, 0.3] });
    }
  }

  private blink(from: { x: number; z: number }, to: { x: number; z: number }, el: Elem) {
    const fx = this.fx, P = this.elP(el);
    const a: V3 = [from.x, 1.1, from.z], b: V3 = [to.x, 1.1, to.z];
    for (const q of [a, b]) {
      fx.boltImpact(q, { element: el, scale: 1.4 });
      fx.shockwave([q[0], 0.1, q[2]], { element: el });
      fx.runeCircle([q[0], 0, q[2]], { element: el, radius: 1.6, duration: 0.5, spin: 4, intensity: 0.9 });
    }
    fx.beam(a, b, { element: el, duration: 0.28, width: 0.55 });
    const n = this.o.low ? 6 : 12;
    for (let k = 0; k < n; k++) {
      const t = (k + 0.5) / n;
      const q: V3 = [a[0] + (b[0] - a[0]) * t, 0.5 + rnd(0, 1.4), a[2] + (b[2] - a[2]) * t];
      this.pools.add.emit({ p: q, v: [rnd(-0.4, 0.4), rnd(0, 0.8), rnd(-0.4, 0.4)], life: rnd(0.35, 0.6), c0: rgba(P.orange, 0.7), c1: rgba(P.crimson, 0), s0: 0.7, s1: 0.15, cell: CELL.GLOW, delay: t * 0.12 });
      this.pools.add.emit({ p: q, life: 0.3, c0: rgba(P.white, 0.8), c1: rgba(P.gold, 0), s0: 0.5, s1: 0.1, spin: 6, cell: CELL.STAR6, delay: t * 0.12 });
    }
    fx.soulWisps(a, { element: el, count: 2 });
    this.shake(el === "arcane" ? 0.2 : 0.1);
  }

  // ---------------------------------------------------------------- impacts
  private fireHit(pos: V3, power: number) {
    const fx = this.fx;
    let fluid = false;
    if (this.fire && this.fluid) {
      try {
        // a real fluid explosion on the ground (burst fire + smoke plume), flying fire trails from the kit, then a short fire patch
        this.tmpV.set(pos[0], 0, pos[2]);
        const h = this.fire.ignite(this.tmpV, 0.62 + power * 0.3, { burst: 1, duration: 0 });
        if (h) {
          fluid = true;
          this.tmpA.set(pos[0], Math.min(pos[1], 1.2), pos[2]);
          (this.fire as any).blastTrails?.burst(this.tmpA, 0.5 + power * 0.4);
          if (power >= 0.9) this.fire.ignite(this.tmpV.set(pos[0] + rnd(-0.4, 0.4), 0, pos[2] + rnd(-0.4, 0.4)), 0.4, { duration: 1.8 });
        }
      } catch {
        /* the fluid fire is decoration */
      }
    }
    if (!fluid) fx.fireBurst(pos, 0.9 + power * 0.6, "fire");
    fx.hitSpark(pos, [0, 0.5, 0], 2 + power, !fluid, "fire");
    fx.shockwave([pos[0], 0.1, pos[2]], { element: "fire" });
    if (fluid) fx.embers([pos[0], 0.6, pos[2]], this.o.low ? 10 : 22, 0.9, "fire");
  }

  private projHit(p: Proj, dstId: string) {
    const fx = this.fx;
    const big = this.lastDmg.dst === dstId && (this.lastDmg.amount >= 150 || this.lastDmg.crit);
    const pos: V3 = [p.x, 1.25, p.z];
    if (p.kind === "fireball") {
      this.fireHit(pos, 1);
      this.shake(0.18);
    } else if (p.kind === "shadowbolt") {
      fx.boltImpact(pos, { element: "arcane", scale: 1.3 });
      fx.soulWisps(pos, { element: "arcane", count: 3 });
      this.shake(0.12);
    } else if (p.kind === "icelance") {
      fx.boltImpact(pos, { element: "frost", scale: big ? 2.2 : 1.1 });
      fx.hitSpark(pos, [0, 0.4, 0], big ? 3.2 : 1.8, false, "frost");
      this.shardBurst(pos, "frost", big ? 12 : 7, [3, 7]);
      this.o.text.decal(p.x, p.z, big ? 2.2 : 1.4, 1.8, 0);
      if (big) {
        fx.iceSpikes([p.x, 0, p.z], 0, { element: "frost", mode: "ring", count: 6, radius: 1.2, size: 0.8, life: 0.8 });
        fx.chainLightning(pos, [[p.x + rnd(-2, 2), 0, p.z + rnd(-2, 2)], [p.x + rnd(-3, 3), 0, p.z + rnd(-3, 3)]], { element: "frost", up: 1 });
        this.storm([p.x, 0, p.z], "frost", { radius: 0.9, strikes: 2 });
        this.shake(0.35);
      }
    } else {
      fx.boltImpact(pos, { element: "frost", scale: big ? 1.9 : 1.4 });
      fx.dust([p.x, 0.3, p.z], 0.4, "frost");
      this.shardBurst(pos, "frost", big ? 12 : 8, [2.5, 6]);
      this.o.text.decal(p.x, p.z, big ? 2.0 : 1.5, 2.0, 0);
      if (big) {
        this.storm([p.x, 0, p.z], "frost", { radius: 0.9, strikes: 2 });
        this.shake(0.3);
      } else this.shake(0.1);
    }
  }

  private damage(e: SimEvent, sim: Sim) {
    const fx = this.fx;
    const dst = sim.unit(e.dst);
    if (!dst) return;
    const toPlayer = dst.kind === "player";
    const amount: number = e.amount;
    const school: string = e.school;
    this.lastDmg = { dst: e.dst, amount, crit: !!e.crit };
    const c: V3 = [dst.x, 1.2, dst.z];
    const src = e.src ? sim.unit(e.src) : null;
    if (e.absorbed > 0) {
      const ufx = this.o.ufx(e.dst);
      ufx?.barrierHit();
      const d = src ? this.dirTo(dst, src) : ([0, 0, 1] as V3);
      fx.shieldFlash([dst.x + d[0] * 0.9, 1.2, dst.z + d[2] * 0.9], Math.atan2(d[0], d[2]), { element: "frost", size: 1.1 });
    }
    if (amount <= 0) return;
    // size: a tick is a puff, a normal hit a spark, a big or critical hit a burst
    const size = clamp(amount / 70, 0.5, 2.6) + (e.crit ? 0.9 : 0);
    const px = Math.round(clamp(17 + amount * 0.13, 17, 40) * (e.crit ? 1.35 : 1));
    this.o.text.text(dst.x, toPlayer ? 2.3 : 2.5, dst.z, String(amount) + (e.crit ? "!" : ""), toPlayer ? "fct-taken" : e.crit ? "fct-crit" : "fct-dmg", {
      px: toPlayer || e.crit ? Math.round(px * 1.0) : px,
      color: toPlayer || e.crit ? undefined : SCHOOL_COLOR[school],
    });
    if (e.dot) {
      fx.hitSpark(c, [0, 1, 0], 0.45, false, this.elemOf(school));
      return;
    }
    const d = src ? this.dirTo(src, dst) : ([0, 0.3, 1] as V3);
    const el = this.elemOf(school);
    const isProj = school !== "physical" && !!src && Math.hypot(src.x - dst.x, src.z - dst.z) > 3.2;
    if (!isProj || school === "physical") {
      fx.hitSpark(c, d, size, school === "fire" && !this.fire, el);
    } else if (!e.crit && amount < 150) {
      fx.hitSpark(c, d, Math.min(size, 1.3), school === "fire" && !this.fire, el);
    }
    if (school === "physical") {
      fx.dust([dst.x, 0.1, dst.z], 0.35);
      if (toPlayer) fx.slash([dst.x, 1.2, dst.z], Math.atan2(d[0], d[2]) + Math.PI, { radius: 1.4, arc: 2.2, tilt: rnd(-0.8, 0.8), color: 0xffd0a0, core: 0xffffff, duration: 0.16, element: "fire" });
    }
    if (e.crit && !toPlayer) {
      fx.hitSpark(c, d, 3.4, false, el);
      if (school === "frost") this.storm([dst.x, 0, dst.z], "frost", { radius: 0.9, strikes: 2 });
    }
    if (toPlayer) this.shake(amount >= 120 ? 0.28 : amount >= 60 ? 0.16 : 0.08);
    else if (e.crit) this.shake(0.2);
  }

  private death(id: string, sim: Sim) {
    const fx = this.fx;
    const u = sim.unit(id);
    if (!u) return;
    const c: V3 = [u.x, 1.1, u.z];
    if (u.kind === "player") {
      fx.boltImpact(c, { element: "frost", scale: 2 });
      fx.soulWisps(c, { element: "frost", count: 4 });
      return;
    }
    const def = ENEMIES[u.kind];
    const tint = new THREE.Color(def.tint);
    const s = def.boss ? 2.2 : 1.5;
    fx.boneShatter(c, { color: [tint.r * 1.6 + 0.3, tint.g * 1.6 + 0.3, tint.b * 1.6 + 0.3], scale: s, dir: [0, 0.4, 0] });
    fx.boltImpact(c, { element: "frost", scale: 2.2 * (def.boss ? 1.5 : 1) });
    fx.soulWisps(c, { element: this.elemOf(u.kind === "pyromancer" ? "fire" : u.kind === "priestess" ? "shadow" : "frost"), count: def.boss ? 8 : 4 });
    fx.shockwave([u.x, 0.1, u.z], { element: "frost" });
    fx.dust([u.x, 0.1, u.z], 1.0);
    this.storm(c, "frost", { radius: def.boss ? 1.6 : 1, strikes: 3 });
    if (def.boss) fx.stormRing([u.x, 0, u.z], { element: "frost", count: this.o.low ? 3 : 6, radius: 5, strikes: 1 });
    this.shake(def.boss ? 0.6 : 0.3);
  }
}
