// 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 } from "@/lib/fluidkit/index.mjs";
import { ENEMIES } from "./data";
import type { Proj, SimEvent, Sim, Unit } from "./combat/sim";
import type { Fx, UnitFx } from "./fx";

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"];
}
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 {
  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();

  constructor(private o: VfxOptions) {
    this.fx = createFx(o.scene, o.camera);
    this.fx.setElement("frost");
    this.pools = this.fx.core.pools;
    if (!o.low) {
      try {
        this.fluid = createFluidHost({ renderer: o.renderer, scene: o.scene, camera: o.camera });
        this.fire = this.fluid.add(new FireFX(this.fluid, { quality: "low", maxFires: 3 }));
      } 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);
    }
  }

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

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

  dispose() {
    this.fx.dispose?.();
    this.fluid?.dispose();
  }

  clear() {
    this.projs.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 });
  }

  // ---------------------------------------------------------------- projectiles
  private projColor(k: Proj["kind"]): Elem {
    return k === "fireball" ? "fire" : k === "shadowbolt" ? "arcane" : "frost";
  }
  /** 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;
    for (const p of list) {
      let v = this.projs.get(p.id);
      if (!v) {
        v = { x: p.x, y: p.y, z: p.z, kind: p.kind };
        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] : [0, 0, 1];
      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) {
            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.8), c1: rgba(P.orange, 0.8), s0: 0.9, s1: 0.9, 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) {
            add.emit({ p: q, life: 0.08, c0: rgba(P.orange, 0.9), c1: rgba(P.orange, 0.9), s0: 2.0, s1: 2.0, cell: CELL.GLOW });
            add.emit({ p: q, life: 0.08, c0: rgba(P.white), c1: rgba(P.white), s0: 0.6, s1: 0.6, spin: 8, cell: CELL.STAR6 });
          }
          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 === "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) return;
    const P = this.elP("frost");
    const c: V3 = [o.x, 1.05, o.z];
    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") {
          if (e.id === "frostbolt" || e.id === "polymorph") fx.runeCircle([u.x, 0, u.z], { element: el, radius: 1.5, duration: e.total + 0.3, intensity: 0.8 });
        } else if (e.id === "charge" || e.id === "whirl") {
          fx.dust([u.x, 0.1, u.z], 1.2);
        } 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 === "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 "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], 2);
        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.8);
        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], 2.4);
        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], 1.1);
        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 });
        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], 1.6);
        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], 2.6);
        this.shake(e.hit ? 0.45 : 0.25);
        break;
      }
      case "proj": {
        const p: Proj = e.proj;
        this.projs.set(p.id, { x: p.x, y: p.y, z: p.z, kind: p.kind });
        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.projs.delete(e.proj.id);
        break;
      case "projHit": {
        const p: Proj = e.proj;
        this.projs.delete(p.id);
        this.projHit(p, e.dst);
        break;
      }
      case "projBlock": {
        const p: Proj = e.proj;
        this.projs.delete(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 });
        fx.dust([p.x, 0.5, p.z], 0.8);
        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");
        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" });
        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.dust([u.x, 0.4, u.z], 1.4, "arcane");
        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":
        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 = [p.x + f[0] * 0.7, 1.45, p.z + f[2] * 0.7];
    switch (e.id) {
      case "frostbolt":
        this.glow(hand, "frost", 1.5, 0.3);
        fx.hitSpark(hand, f, 0.9, false, "frost");
        break;
      case "icelance":
        this.glow(hand, "frost", 1.2, 0.2);
        fx.shieldFlash(hand, h, { element: "frost", size: 0.7 });
        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);
        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.6, false, "frost");
        this.glow(hand, "frost", 1.3, 0.3);
        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;
      }
    }
  }

  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;
    fx.fireBurst(pos, 0.9 + power * 0.6, "fire");
    fx.hitSpark(pos, [0, 0.5, 0], 2 + power, true, "fire");
    fx.shockwave([pos[0], 0.1, pos[2]], { element: "fire" });
    if (this.fire && this.fluid) {
      this.tmpV.set(pos[0], 0, pos[2]);
      try {
        this.fire.fireball(this.tmpV, 0.5 + power * 0.5);
      } catch {
        /* the fluid fire is decoration */
      }
    }
  }

  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);
      fx.dust([p.x, 0.3, p.z], 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");
      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.6, p.z], 0.8, "frost");
      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", el);
    } else if (!e.crit && amount < 150) {
      fx.hitSpark(c, d, Math.min(size, 1.3), school === "fire", el);
    }
    if (school === "physical") {
      fx.dust([dst.x, 0.2, dst.z], 0.7);
      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.2, u.z], 1.8);
    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);
  }
}
