// Pooled effects: particles, rings, decals, projectile visuals, per-unit status visuals, floating combat text.
import * as THREE from "three";
import type { AuraKind } from "./combat/sim";

function canvasTex(size: number, draw: (g: CanvasRenderingContext2D, s: number) => void) {
  const c = document.createElement("canvas");
  c.width = c.height = size;
  draw(c.getContext("2d")!, size);
  const t = new THREE.CanvasTexture(c);
  t.colorSpace = THREE.SRGBColorSpace;
  return t;
}

const dotTex = () =>
  canvasTex(64, (g, s) => {
    const gr = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
    gr.addColorStop(0, "rgba(255,255,255,1)");
    gr.addColorStop(0.4, "rgba(255,255,255,0.5)");
    gr.addColorStop(1, "rgba(255,255,255,0)");
    g.fillStyle = gr;
    g.fillRect(0, 0, s, s);
  });

const frostDecalTex = () =>
  canvasTex(256, (g, s) => {
    const gr = g.createRadialGradient(s / 2, s / 2, 10, s / 2, s / 2, s / 2);
    gr.addColorStop(0, "rgba(190,240,255,0.55)");
    gr.addColorStop(0.7, "rgba(120,210,255,0.35)");
    gr.addColorStop(1, "rgba(95,224,255,0)");
    g.fillStyle = gr;
    g.fillRect(0, 0, s, s);
    g.strokeStyle = "rgba(235,250,255,0.6)";
    g.lineWidth = 2;
    for (let i = 0; i < 14; i++) {
      const a = (i / 14) * Math.PI * 2 + (i % 3) * 0.1;
      g.beginPath();
      g.moveTo(s / 2, s / 2);
      g.lineTo(s / 2 + Math.cos(a) * s * 0.46, s / 2 + Math.sin(a) * s * 0.46);
      g.stroke();
    }
  });

const targetRingTex = () =>
  canvasTex(256, (g, s) => {
    g.strokeStyle = "#ff4d63";
    g.lineWidth = 14;
    g.lineCap = "round";
    for (let i = 0; i < 8; i++) {
      const a0 = (i / 8) * Math.PI * 2 + 0.12;
      g.beginPath();
      g.arc(s / 2, s / 2, s * 0.44, a0, a0 + 0.62);
      g.stroke();
    }
    g.lineWidth = 4;
    g.beginPath();
    g.arc(s / 2, s / 2, s * 0.35, 0, Math.PI * 2);
    g.stroke();
  });

const silenceTex = () =>
  canvasTex(128, (g, s) => {
    g.fillStyle = "rgba(20,26,51,0.9)";
    g.beginPath();
    g.arc(s / 2, s / 2, s * 0.46, 0, Math.PI * 2);
    g.fill();
    g.strokeStyle = "#5fe0ff";
    g.lineWidth = 6;
    g.beginPath();
    g.arc(s / 2, s / 2, s * 0.44, 0, Math.PI * 2);
    g.stroke();
    g.strokeStyle = "#ffffff";
    g.lineWidth = 8;
    g.lineCap = "round";
    g.beginPath();
    g.moveTo(s * 0.3, s * 0.58);
    g.quadraticCurveTo(s / 2, s * 0.5, s * 0.7, s * 0.58);
    g.stroke();
    g.strokeStyle = "#ff4d63";
    g.beginPath();
    g.moveTo(s * 0.28, s * 0.28);
    g.lineTo(s * 0.72, s * 0.72);
    g.stroke();
  });

interface Ring {
  m: THREE.Mesh;
  mat: THREE.MeshBasicMaterial;
  t: number;
  dur: number;
  max: number;
  min: number;
}
const NUM_POOL = 40;
interface Pop {
  el: HTMLElement;
  on: boolean;
  t: number;
  dur: number;
  wx: number;
  wy: number;
  wz: number;
  ox: number;
  oy: number;
  vx: number;
  vy: number;
  size: number;
  crit: boolean;
  pop: number;
  born: number;
}

export class Fx {
  private pts: THREE.Points;
  private pos: Float32Array;
  private col: Float32Array;
  private vel: Float32Array;
  private life: Float32Array;
  private maxLife: Float32Array;
  private base: Float32Array;
  private grav: Float32Array;
  private head = 0;
  private N = 900;
  private rings: Ring[] = [];
  private decals: { m: THREE.Mesh; mat: THREE.MeshBasicMaterial; t: number; dur: number }[] = [];
  readonly orbG: THREE.Group;
  private orbShell: THREE.Mesh;
  private targetM: THREE.Mesh;
  private chargeM: THREE.Mesh;
  private tmp = new THREE.Color();
  private active = 0;
  readonly dot = dotTex();
  private disposables: { dispose(): void }[] = [];
  reduced = false;

  constructor(private scene: THREE.Scene, private camera: THREE.PerspectiveCamera, private host: HTMLElement) {
    this.initNums();
    const geo = new THREE.BufferGeometry();
    this.pos = new Float32Array(this.N * 3).fill(-1000);
    this.col = new Float32Array(this.N * 3);
    this.vel = new Float32Array(this.N * 3);
    this.life = new Float32Array(this.N);
    this.maxLife = new Float32Array(this.N).fill(1);
    this.base = new Float32Array(this.N * 3);
    this.grav = new Float32Array(this.N);
    geo.setAttribute("position", new THREE.BufferAttribute(this.pos, 3));
    geo.setAttribute("color", new THREE.BufferAttribute(this.col, 3));
    const mat = new THREE.PointsMaterial({ size: 0.3, map: this.dot, vertexColors: true, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, sizeAttenuation: true });
    this.pts = new THREE.Points(geo, mat);
    this.pts.frustumCulled = false;
    this.pts.name = "fx-particles";
    scene.add(this.pts);
    this.disposables.push(geo, mat);

    const ringGeo = new THREE.RingGeometry(0.9, 1, 64);
    ringGeo.rotateX(-Math.PI / 2);
    this.disposables.push(ringGeo);
    for (let i = 0; i < 14; i++) {
      const mat2 = new THREE.MeshBasicMaterial({ color: "#5fe0ff", transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide });
      const m = new THREE.Mesh(ringGeo, mat2);
      m.visible = false;
      scene.add(m);
      this.rings.push({ m, mat: mat2, t: 0, dur: 1, max: 1, min: 0 });
      this.disposables.push(mat2);
    }
    const dgeo = new THREE.CircleGeometry(1, 48);
    dgeo.rotateX(-Math.PI / 2);
    const dtex = frostDecalTex();
    this.disposables.push(dgeo, dtex);
    for (let i = 0; i < 4; i++) {
      const mat2 = new THREE.MeshBasicMaterial({ map: dtex, transparent: true, opacity: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2 });
      const m = new THREE.Mesh(dgeo, mat2);
      m.visible = false;
      m.position.y = 0.07;
      scene.add(m);
      this.decals.push({ m, mat: mat2, t: 0, dur: 1 });
      this.disposables.push(mat2);
    }
    // frozen orb
    this.orbG = new THREE.Group();
    const orbCore = new THREE.Mesh(new THREE.SphereGeometry(0.5, 20, 14), new THREE.MeshBasicMaterial({ color: new THREE.Color("#5fe0ff").multiplyScalar(2.2) }));
    this.orbShell = new THREE.Mesh(new THREE.IcosahedronGeometry(0.8, 1), new THREE.MeshBasicMaterial({ color: "#bff4ff", wireframe: true, transparent: true, opacity: 0.55, depthWrite: false, blending: THREE.AdditiveBlending }));
    this.orbG.add(orbCore, this.orbShell);
    this.orbG.visible = false;
    scene.add(this.orbG);
    // target ring
    const trGeo = new THREE.PlaneGeometry(2.1, 2.1);
    trGeo.rotateX(-Math.PI / 2);
    this.targetM = new THREE.Mesh(trGeo, new THREE.MeshBasicMaterial({ map: targetRingTex(), transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -3, color: new THREE.Color("#ff6b7d").multiplyScalar(1.4) }));
    this.targetM.position.y = 0.09;
    this.targetM.visible = false;
    this.targetM.name = "target-ring";
    scene.add(this.targetM);
    // charge tell line
    const cg = new THREE.PlaneGeometry(1, 1);
    cg.rotateX(-Math.PI / 2);
    this.chargeM = new THREE.Mesh(cg, new THREE.MeshBasicMaterial({ color: "#d2283c", transparent: true, opacity: 0.55, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -3 }));
    this.chargeM.visible = false;
    this.chargeM.position.y = 0.1;
    scene.add(this.chargeM);
  }

  // ------------------------------------------------------------ particles
  burst(x: number, y: number, z: number, color: string, count: number, speed: number, life: number, grav = 0, spreadY = 1) {
    if (this.reduced && count > 12) count = Math.ceil(count / 2);
    this.tmp.set(color).multiplyScalar(1.7);
    for (let n = 0; n < count; n++) {
      const i = this.head;
      this.head = (this.head + 1) % this.N;
      const th = Math.random() * Math.PI * 2, ph = Math.acos(2 * Math.random() - 1);
      const sp = speed * (0.35 + Math.random() * 0.65);
      this.pos[i * 3] = x;
      this.pos[i * 3 + 1] = y;
      this.pos[i * 3 + 2] = z;
      this.vel[i * 3] = Math.sin(ph) * Math.cos(th) * sp;
      this.vel[i * 3 + 1] = Math.cos(ph) * sp * spreadY + (grav > 0 ? sp * 0.3 : 0);
      this.vel[i * 3 + 2] = Math.sin(ph) * Math.sin(th) * sp;
      this.life[i] = this.maxLife[i] = life * (0.6 + Math.random() * 0.4);
      this.base[i * 3] = this.tmp.r;
      this.base[i * 3 + 1] = this.tmp.g;
      this.base[i * 3 + 2] = this.tmp.b;
      this.grav[i] = grav;
    }
  }
  trail(x: number, y: number, z: number, color: string) {
    this.burst(x, y, z, color, 2, 0.6, 0.45);
  }
  line(x0: number, z0: number, x1: number, z1: number, color: string, n: number) {
    for (let i = 0; i < n; i++) {
      const k = i / n;
      this.burst(x0 + (x1 - x0) * k, 0.4 + Math.random() * 1.4, z0 + (z1 - z0) * k, color, 1, 0.5, 0.6);
    }
  }

  ring(x: number, z: number, color: string, max: number, dur: number, y = 0.12, min = 0.2) {
    const r = this.rings.find((q) => !q.m.visible) ?? this.rings[0];
    r.t = 0;
    r.dur = dur;
    r.max = max;
    r.min = min;
    r.mat.color.set(color).multiplyScalar(1.6);
    r.m.position.set(x, y, z);
    r.m.visible = true;
  }
  decal(x: number, z: number, radius: number, dur: number) {
    const d = this.decals.find((q) => !q.m.visible) ?? this.decals[0];
    d.t = 0;
    d.dur = dur;
    d.m.position.set(x, 0.07 + Math.random() * 0.004, z);
    d.m.scale.setScalar(radius);
    d.m.visible = true;
  }
  pillar(x: number, y: number, z: number, color: string) {
    this.burst(x, y, z, color, 18, 1.6, 0.9, 0);
  }

  // ------------------------------------------------------------ orb, target ring, charge line
  orbSet(o: { x: number; z: number } | null, t: number) {
    if (!o) {
      this.orbG.visible = false;
      return;
    }
    this.orbG.visible = true;
    this.orbG.position.set(o.x, 1.0 + Math.sin(t * 3) * 0.1, o.z);
    this.orbShell.rotation.set(t * 1.6, t * 2.1, 0);
  }
  targetSet(x: number | null, z: number, t: number) {
    if (x === null) {
      this.targetM.visible = false;
      return;
    }
    this.targetM.visible = true;
    this.targetM.position.set(x, 0.09, z);
    this.targetM.rotation.y = t * 0.5;
  }
  chargeSet(l: { x0: number; z0: number; x1: number; z1: number } | null, t: number) {
    if (!l) {
      this.chargeM.visible = false;
      return;
    }
    const dx = l.x1 - l.x0, dz = l.z1 - l.z0;
    const len = Math.hypot(dx, dz);
    this.chargeM.visible = true;
    this.chargeM.position.set((l.x0 + l.x1) / 2, 0.1, (l.z0 + l.z1) / 2);
    this.chargeM.rotation.y = Math.atan2(dx, dz);
    this.chargeM.scale.set(1.1, 1, len);
    (this.chargeM.material as THREE.MeshBasicMaterial).opacity = 0.35 + 0.25 * Math.sin(t * 30);
  }

  // ------------------------------------------------------------ floating text (pooled DOM, driven here each frame)
  private nums: Pop[] = [];
  private numHost: HTMLElement | null = null;
  private numStack = 0;
  private numStackT = 0;
  private readonly numV = new THREE.Vector3();
  private numW = 1;
  private numH = 1;

  private initNums() {
    this.numHost = this.host;
    for (let i = 0; i < NUM_POOL; i++) {
      const el = document.createElement("div");
      el.className = "dn";
      el.style.display = "none";
      el.setAttribute("aria-hidden", "true");
      this.host.appendChild(el);
      this.nums.push({ el, on: false, t: 0, dur: 1, wx: 0, wy: 0, wz: 0, ox: 0, oy: 0, vx: 0, vy: 0, size: 24, crit: false, pop: 1, born: 0 });
    }
  }

  /** One popup. kind is the colour class (frost, fire, taken, heal, crit ...); size in px at 1280 wide. */
  num(wx: number, wy: number, wz: number, msg: string, kind: string, o: { size?: number; crit?: boolean; dir?: number; dur?: number; spread?: number } = {}) {
    if (!this.numHost) this.initNums();
    // oldest or free slot: a full pool recycles the oldest popup instead of allocating
    let slot: Pop | null = null;
    for (let i = 0; i < this.nums.length; i++) {
      const p = this.nums[i];
      if (!p.on) {
        slot = p;
        break;
      }
      if (!slot || p.t / p.dur > slot.t / slot.dur) slot = p;
    }
    if (!slot) return;
    const w = this.host.clientWidth || 1280;
    const scale = Math.max(0.78, Math.min(1.15, w / 1280 + 0.25));
    const crit = !!o.crit;
    // stagger popups that land together so they stack instead of overlapping
    const now = performance.now();
    if (now - this.numStackT > 350) this.numStack = 0;
    this.numStackT = now;
    const st = this.numStack++ % 4;
    const dir = o.dir ?? (Math.random() < 0.5 ? -1 : 1);
    slot.on = true;
    slot.t = 0;
    slot.dur = o.dur ?? (crit ? 1.35 : 1.05);
    slot.wx = wx;
    slot.wy = wy;
    slot.wz = wz;
    slot.ox = (Math.random() - 0.5) * (o.spread ?? 30);
    slot.oy = -st * 17 * scale;
    slot.vx = dir * (28 + Math.random() * 26);
    slot.vy = -(crit ? 120 : 92) * scale;
    slot.crit = crit;
    slot.size = Math.round((o.size ?? 24) * scale);
    slot.pop = crit ? 1.9 : 1.4;
    const el = slot.el;
    el.className = `dn dn-${kind}${crit ? " dn-crit" : ""}`;
    el.textContent = msg;
    el.style.fontSize = `${slot.size}px`;
    el.style.display = "block";
    el.style.opacity = "0";
    this.positionNum(slot, 0);
  }

  /** Legacy entry used by the event handler for words (Miss, Immune, Rooted ...). */
  text(wx: number, wy: number, wz: number, msg: string, cls: string, opts?: { px?: number; color?: string }) {
    const kind = cls === "fct-heal" ? "heal" : cls === "fct-gold" ? "gold" : cls === "fct-cc" || cls === "fct-interrupt" ? "cc" : "word";
    this.num(wx, wy, wz, msg, kind, { size: opts?.px ?? (kind === "cc" ? 22 : 18), dur: 1.25, spread: 16, dir: 0 });
  }

  private positionNum(p: Pop, k: number) {
    const v = this.numV.set(p.wx, p.wy, p.wz).project(this.camera);
    const el = p.el;
    if (v.z > 1) {
      el.style.opacity = "0";
      return;
    }
    const x = ((v.x + 1) / 2) * this.numW + p.ox;
    const y = ((1 - v.y) / 2) * this.numH + p.oy;
    // punchy scale-in with overshoot (first 18%), settle, then a small shrink while fading
    const a = Math.min(1, k / 0.16);
    const settled = k < 0.16 ? 0.35 + (p.pop - 0.35) * (1 - (1 - a) * (1 - a)) : 1 + (p.pop - 1) * Math.exp(-(k - 0.16) * 18) - (k > 0.7 ? (k - 0.7) * 0.4 : 0);
    el.style.transform = `translate3d(${x.toFixed(1)}px,${y.toFixed(1)}px,0) translate(-50%,-50%) scale(${settled.toFixed(3)})`;
    el.style.opacity = String(k > 0.62 ? Math.max(0, 1 - (k - 0.62) / 0.38) : 1);
    if (p.crit) el.style.setProperty("--bang", String(Math.max(0, 1 - k / 0.3)));
  }

  private updateNums(dt: number) {
    if (!this.numHost) return;
    this.numW = this.host.clientWidth || 1;
    this.numH = this.host.clientHeight || 1;
    for (let i = 0; i < this.nums.length; i++) {
      const p = this.nums[i];
      if (!p.on) continue;
      p.t += dt;
      const k = p.t / p.dur;
      if (k >= 1) {
        p.on = false;
        p.el.style.display = "none";
        continue;
      }
      // drift: fast at first, easing out (up and sideways)
      const ease = Math.exp(-p.t * 3.2);
      p.ox += p.vx * ease * dt;
      p.oy += p.vy * ease * dt;
      this.positionNum(p, k);
    }
  }

  // ------------------------------------------------------------ frame
  update(dt: number) {
    this.updateNums(dt);
    const N = this.N;
    for (let i = 0; i < N; i++) {
      const l = this.life[i];
      if (l <= 0) continue;
      const nl = l - dt;
      this.life[i] = nl;
      if (nl <= 0) {
        this.pos[i * 3 + 1] = -1000;
        this.col[i * 3] = this.col[i * 3 + 1] = this.col[i * 3 + 2] = 0;
        continue;
      }
      this.vel[i * 3 + 1] -= this.grav[i] * dt;
      this.pos[i * 3] += this.vel[i * 3] * dt;
      this.pos[i * 3 + 1] += this.vel[i * 3 + 1] * dt;
      this.pos[i * 3 + 2] += this.vel[i * 3 + 2] * dt;
      const k = nl / this.maxLife[i];
      this.col[i * 3] = this.base[i * 3] * k;
      this.col[i * 3 + 1] = this.base[i * 3 + 1] * k;
      this.col[i * 3 + 2] = this.base[i * 3 + 2] * k;
    }
    (this.pts.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;
    (this.pts.geometry.attributes.color as THREE.BufferAttribute).needsUpdate = true;
    for (const r of this.rings) {
      if (!r.m.visible) continue;
      r.t += dt;
      const k = r.t / r.dur;
      if (k >= 1) {
        r.m.visible = false;
        continue;
      }
      const e = 1 - Math.pow(1 - k, 3);
      r.m.scale.setScalar(r.min + (r.max - r.min) * e);
      r.mat.opacity = (1 - k) * 0.9;
    }
    for (const d of this.decals) {
      if (!d.m.visible) continue;
      d.t += dt;
      const k = d.t / d.dur;
      if (k >= 1) d.m.visible = false;
      else d.mat.opacity = Math.min(1, (1 - k) * 1.4);
    }
    this.active = 0;
  }

  dispose() {
    for (const d of this.disposables) d.dispose();
    for (const p of this.nums) p.el.remove();
    this.nums = [];
    this.numHost = null;
    this.scene.remove(this.pts, this.orbG, this.targetM, this.chargeM);
    for (const r of this.rings) this.scene.remove(r.m);
    for (const d of this.decals) this.scene.remove(d.m);
    void this.active;
  }
}

/** Status visuals that follow one unit: crystals, barrier, ice block, stars, wisps, silence icon, the polymorph sheep. */
export class UnitFx {
  readonly group = new THREE.Group();
  private crystals = new THREE.Group();
  private barrier: THREE.Mesh;
  private block: THREE.Mesh;
  private stars = new THREE.Group();
  private wisps = new THREE.Group();
  private silence: THREE.Sprite;
  readonly sheep = new THREE.Group();
  private heal: THREE.Mesh;
  private healT = 0;
  private kinds = new Set<AuraKind>();
  private moteT = 0;
  private flashT = 0;

  constructor(scene: THREE.Scene, private height: number, private fx: Fx) {
    const cm = new THREE.MeshStandardMaterial({ color: "#8fe8ff", emissive: "#5fe0ff", emissiveIntensity: 0.8, transparent: true, opacity: 0.6, roughness: 0.15 });
    const cg = new THREE.ConeGeometry(0.1, 0.65, 5);
    for (let i = 0; i < 6; i++) {
      const a = (i / 6) * Math.PI * 2;
      const c = new THREE.Mesh(cg, cm);
      c.position.set(Math.cos(a) * 0.42, 0.3, Math.sin(a) * 0.42);
      c.rotation.set(Math.sin(a) * 0.35, 0, -Math.cos(a) * 0.35);
      this.crystals.add(c);
    }
    this.crystals.visible = false;
    this.barrier = new THREE.Mesh(
      new THREE.IcosahedronGeometry(1.15, 2),
      new THREE.ShaderMaterial({
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        uniforms: { c: { value: new THREE.Color("#5fe0ff").multiplyScalar(1.5) }, flash: { value: 0 }, time: { value: 0 } },
        vertexShader: "varying vec3 vN; varying vec3 vV; varying vec3 vP; void main(){ vP = position; vec4 mv = modelViewMatrix*vec4(position,1.0); vN = normalize(normalMatrix*normal); vV = normalize(-mv.xyz); gl_Position = projectionMatrix*mv; }",
        fragmentShader:
          "uniform vec3 c; uniform float flash; uniform float time; varying vec3 vN; varying vec3 vV; varying vec3 vP; void main(){ float f = pow(1.0-abs(dot(vN,vV)), 2.2); vec3 q = vP*5.0; float g = max(abs(fract(q.x+q.y*0.5)-0.5), max(abs(fract(q.y+q.z*0.5)-0.5), abs(fract(q.z+q.x*0.5)-0.5))); float lines = smoothstep(0.42, 0.5, g); float sweep = 0.5+0.5*sin(vP.y*4.0-time*2.5); vec3 col = c*(f*1.1 + lines*(0.25+0.25*sweep)) + vec3(0.7,0.95,1.0)*flash*(0.5+f); gl_FragColor = vec4(col, f*0.9 + lines*0.25 + 0.05 + flash*0.5); }",
      }),
    );
    this.barrier.position.y = 1.0;
    this.barrier.visible = false;
    this.block = new THREE.Mesh(new THREE.CylinderGeometry(1.0, 1.0, 2.3, 6), new THREE.MeshStandardMaterial({ color: "#bfefff", emissive: "#5fe0ff", emissiveIntensity: 0.25, transparent: true, opacity: 0.5, roughness: 0.08, metalness: 0.1 }));
    this.block.name = "glass-iceblock";
    this.block.position.y = 1.1;
    this.block.add(new THREE.LineSegments(new THREE.EdgesGeometry(this.block.geometry), new THREE.LineBasicMaterial({ color: new THREE.Color("#e8fbff").multiplyScalar(1.8), transparent: true, opacity: 0.9 })));
    this.block.visible = false;
    const sg = new THREE.OctahedronGeometry(0.1);
    const sm = new THREE.MeshBasicMaterial({ color: new THREE.Color("#f2c14e").multiplyScalar(2) });
    for (let i = 0; i < 3; i++) this.stars.add(new THREE.Mesh(sg, sm));
    this.stars.visible = false;
    const wm = new THREE.SpriteMaterial({ map: fx.dot, color: new THREE.Color("#9b4dff").multiplyScalar(1.8), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
    for (let i = 0; i < 5; i++) {
      const s = new THREE.Sprite(wm);
      s.scale.setScalar(0.3);
      this.wisps.add(s);
    }
    this.wisps.visible = false;
    this.silence = new THREE.Sprite(new THREE.SpriteMaterial({ map: silenceTex(), transparent: true, depthWrite: false }));
    this.silence.scale.setScalar(0.55);
    this.silence.visible = false;
    // polymorph sheep: five fleece lumps, a dark head, four legs
    const fleece = new THREE.MeshStandardMaterial({ color: "#f3f1ea", roughness: 1 });
    const body = [[0, 0.55, 0], [0.25, 0.6, 0.1], [-0.25, 0.6, 0.1], [0.12, 0.7, -0.2], [-0.12, 0.7, -0.2]];
    for (const [x, y, z] of body) {
      const m = new THREE.Mesh(new THREE.IcosahedronGeometry(0.3, 1), fleece);
      m.position.set(x, y, z);
      m.castShadow = true;
      this.sheep.add(m);
    }
    const head = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.24, 0.3), new THREE.MeshStandardMaterial({ color: "#141a33", roughness: 0.8 }));
    head.position.set(0, 0.7, 0.42);
    this.sheep.add(head);
    const lg = new THREE.CylinderGeometry(0.04, 0.04, 0.35, 6);
    const lm = new THREE.MeshStandardMaterial({ color: "#141a33" });
    for (const [x, z] of [[0.18, 0.2], [-0.18, 0.2], [0.18, -0.2], [-0.18, -0.2]]) {
      const l = new THREE.Mesh(lg, lm);
      l.position.set(x, 0.17, z);
      this.sheep.add(l);
    }
    this.sheep.visible = false;
    this.heal = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.55, 3.2, 20, 1, true), new THREE.MeshBasicMaterial({ color: new THREE.Color("#f2c14e").multiplyScalar(1.6), transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide }));
    this.heal.position.y = 1.6;
    this.heal.visible = false;
    this.group.add(this.crystals, this.barrier, this.block, this.stars, this.wisps, this.silence, this.sheep, this.heal);
    scene.add(this.group);
  }

  setKinds(k: Set<AuraKind>) {
    this.kinds = k;
  }
  barrierHit() {
    this.flashT = 1;
  }
  healPulse() {
    this.healT = 0.9;
  }

  update(t: number, dt: number, x: number, z: number) {
    this.group.position.set(x, 0, z);
    const k = this.kinds;
    this.crystals.visible = k.has("root");
    this.barrier.visible = k.has("absorb");
    this.block.visible = k.has("immune");
    this.stars.visible = k.has("stun");
    this.wisps.visible = k.has("fear");
    this.silence.visible = k.has("silence");
    this.sheep.visible = k.has("poly");
    const bu = (this.barrier.material as THREE.ShaderMaterial).uniforms;
    this.flashT = Math.max(0, this.flashT - dt * 4);
    bu.flash.value = this.flashT;
    bu.time.value = t;
    if (this.barrier.visible) {
      this.barrier.scale.setScalar(1 + Math.sin(t * 4) * 0.025 + this.flashT * 0.06);
      this.barrier.rotation.y = t * 0.3;
    }
    if (this.block.visible) (this.block.material as THREE.MeshStandardMaterial).emissiveIntensity = 0.3 + 0.12 * Math.sin(t * 3);
    if (this.stars.visible)
      this.stars.children.forEach((s, i) => {
        const a = t * 4 + (i / 3) * Math.PI * 2;
        s.position.set(Math.cos(a) * 0.35, this.height + 0.2 + Math.sin(t * 6 + i) * 0.04, Math.sin(a) * 0.35);
        s.rotation.y = t * 5;
      });
    if (this.wisps.visible)
      this.wisps.children.forEach((s, i) => {
        const a = t * 3 + i * 1.26;
        s.position.set(Math.cos(a) * 0.5, this.height * (0.5 + (i / 5) * 0.55), Math.sin(a) * 0.5);
      });
    if (this.silence.visible) this.silence.position.set(0, this.height + 0.55, 0);
    this.moteT -= dt;
    if (this.moteT <= 0) {
      this.moteT = 0.08;
      if (k.has("slow")) this.fx.burst(x + (Math.random() - 0.5) * 0.6, 0.15, z + (Math.random() - 0.5) * 0.6, "#bff4ff", 1, 0.5, 0.7);
      if (k.has("dot")) this.fx.burst(x + (Math.random() - 0.5) * 0.5, 0.6 + Math.random(), z + (Math.random() - 0.5) * 0.5, "#9b4dff", 1, 0.5, 0.8, -0.6);
    }
    if (this.healT > 0) {
      this.healT -= dt;
      this.heal.visible = true;
      (this.heal.material as THREE.MeshBasicMaterial).opacity = Math.max(0, this.healT / 0.9) * 0.55;
      this.heal.scale.set(1, 1, 1);
    } else this.heal.visible = false;
  }

  dispose(scene: THREE.Scene) {
    scene.remove(this.group);
  }
}
