import React, { useEffect, useMemo, useRef } from 'react';

import type { EffectsState, EffectInstance } from '../../../shared/types/effects';

import styles from './PxiEffectsOverlay.module.css';

type PoisonParticleFx = {
  s: any;
  kind: 'stem' | 'cap';
  uStem: number;
  side: number;
  capAng: number;
  capDist: number;
  sz: number;
  flick: number;
};

type Props = {
  state: EffectsState | null;
  interactive?: boolean;
  style?: React.CSSProperties;
  /** Область “контента” внутри контейнера (например при object-fit: contain). */
  viewport?: { x: number; y: number; w: number; h: number } | undefined;
};

/**
 * Учебная идея:
 * - Pixi `Application` — это WebGL-рендерер + тикер.
 * - Мы держим один `Application` на компонент, и при изменении `state` просто перерисовываем сцену.
 * - Вариант A: рисуем "инстансы эффектов" (данные), а не пиксели.
 */
export function PixiEffectsOverlay({ state, interactive = false, style, viewport }: Props) {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const appRef = useRef<any>(null);
  const rootRef = useRef<any>(null);
  const pixiRef = useRef<any>(null);
  const nodesRef = useRef<Map<string, any>>(new Map());
  const stateRef = useRef<EffectsState | null>(null);
  const timeOffsetRef = useRef(0);
  const sizeRef = useRef<{ w: number; h: number }>({ w: 1, h: 1 });
  const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 });
  const viewportProvidedRef = useRef(false);

  const dpr = useMemo(() => Math.min(2, window.devicePixelRatio || 1), []);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    let destroyed = false;
    let app: any = null;
    let cleanup: (() => void) | null = null;
    void (async () => {
      try {
        const pixi = await import('pixi.js');
        pixiRef.current = pixi;
        if (destroyed) return;
        app = new pixi.Application();
        await app.init({
          backgroundAlpha: 0,
          antialias: true,
          resolution: dpr,
          autoDensity: true,
        });
        if (destroyed) return;
        host.appendChild(app.canvas);
        // Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
        app.canvas.style.pointerEvents = interactive ? 'auto' : 'none';
        appRef.current = app;
        const root = new pixi.Container();
        rootRef.current = root;
        app.stage.addChild(root);

        const ro = new ResizeObserver(() => {
          const r = host.getBoundingClientRect();
          app.renderer.resize(Math.max(1, Math.floor(r.width)), Math.max(1, Math.floor(r.height)));
          sizeRef.current = { w: app.renderer.width, h: app.renderer.height };
          if (!viewportProvidedRef.current) {
            viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
          }
          syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
        });
        ro.observe(host);

        const r = host.getBoundingClientRect();
        app.renderer.resize(Math.max(1, Math.floor(r.width)), Math.max(1, Math.floor(r.height)));
        sizeRef.current = { w: app.renderer.width, h: app.renderer.height };
        if (!viewportProvidedRef.current) {
          viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
        }
        syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);

        // Animation loop: на каждом кадре обновляем свойства инстансов (alpha/дрейф/фликер).
        app.ticker.add(() => {
          const s = stateRef.current;
          if (!s) return;
          const nowMs = Date.now() + timeOffsetRef.current;
          animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);

          // Лёгкое “потряхивание” сцены в момент удара молнии.
          // Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
          const root = rootRef.current;
          if (root) {
            const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
            root.x = x;
            root.y = y;
          }
        });

        cleanup = () => ro.disconnect();
      } catch (e) {
        // Если Pixi/WebGL не поднялись, не ломаем всё приложение.
        // Эффекты просто не будут отображаться, но UI останется живым.
        console.error('[effects] Pixi init failed', e);
      }
    })();

    return () => {
      destroyed = true;
      cleanup?.();
      const a = appRef.current;
      appRef.current = null;
      rootRef.current = null;
      if (a) {
        a.destroy(true, { children: true });
      }
      host.replaceChildren();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;
    const canvas = host.querySelector('canvas');
    if (canvas instanceof HTMLCanvasElement) {
      canvas.style.pointerEvents = interactive ? 'auto' : 'none';
    }
  }, [interactive]);

  useEffect(() => {
    const app = appRef.current;
    const root = rootRef.current;
    if (!app || !root) return;
    stateRef.current = state;
    if (state) {
      timeOffsetRef.current = state.serverNowMs - Date.now();
    }
    const pixi = pixiRef.current;
    if (!pixi) return;
    syncNodes(pixi, root, nodesRef.current, state, sizeRef.current, viewportRef.current);
  }, [state]);

  useEffect(() => {
    // Если viewport не задан — используем “весь холст”.
    viewportProvidedRef.current = Boolean(viewport);
    if (viewport) viewportRef.current = viewport;
    else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
    const pixi = pixiRef.current;
    const root = rootRef.current;
    if (!pixi || !root) return;
    syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
  }, [viewport]);

  const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');

  return <div ref={hostRef} className={hostClass} style={style} />;
}

function syncNodes(
  pixi: any,
  root: any,
  nodes: Map<string, any>,
  state: EffectsState | null,
  size: { w: number; h: number },
  viewport: { x: number; y: number; w: number; h: number },
) {
  const desired = new Set<string>((state?.instances ?? []).map((i) => i.id));
  for (const [id, node] of nodes.entries()) {
    if (desired.has(id)) continue;
    root.removeChild(node);
    node.destroy?.({ children: true });
    nodes.delete(id);
  }
  if (!state) return;
  for (const inst of state.instances) {
    const sig = instanceSig(inst, viewport);
    const existing = nodes.get(inst.id);
    if (existing && (existing as any).__sig === sig) continue;
    if (existing) {
      root.removeChild(existing);
      existing.destroy?.({ children: true });
      nodes.delete(inst.id);
    }
    const node = createInstanceNode(pixi, inst, size, viewport);
    if (!node) continue;
    (node as any).__sig = sig;
    nodes.set(inst.id, node);
    root.addChild(node);
  }
}

function createInstanceNode(
  pixi: any,
  inst: EffectInstance,
  _size: { w: number; h: number },
  viewport: { x: number; y: number; w: number; h: number },
): any | null {
  const { x: vx, y: vy, w, h } = viewport;
  if (inst.type === 'fog') {
    const base = inst.opacity;
    const r = inst.radiusN * Math.min(w, h);
    const tex = getFogTexture(pixi);
    // Вместо кругов используем Sprite — визуально мягче и быстрее.
    const c = new pixi.Container();
    for (let i = 0; i < inst.points.length; i += 1) {
      const p = inst.points[i];
      if (!p) continue;
      const s = new pixi.Sprite(tex);
      s.anchor?.set?.(0.5, 0.5);
      s.x = vx + p.x * w;
      s.y = vy + p.y * h;
      const scale = r / Math.max(1, tex.width * 0.5);
      s.scale?.set?.(scale, scale);
      s.rotation = hash01(inst.seed ^ 0x3141592, i) * Math.PI * 2;
      s.alpha = base;
      s.blendMode = pixi.BLEND_MODES?.SCREEN ?? 2;
      // Сохраним параметры анимации прямо на объекте (простая практика для Pixi).
      (s as any).__fx = {
        bx: s.x,
        by: s.y,
        phase: hash01(inst.seed, i) * Math.PI * 2,
        drift: 6 + 26 * hash01(inst.seed ^ 0x9e3779b9, i),
        rot0: s.rotation,
        rotSpeed: (hash01(inst.seed ^ 0x2718281, i) - 0.5) * 0.35,
      };
      c.addChild(s);
    }
    (c as any).__fx = { id: inst.id, type: inst.type };
    c.alpha = Math.max(0, Math.min(1, inst.opacity));
    return c;
  }
  if (inst.type === 'fire') {
    const base = inst.opacity;
    const r = inst.radiusN * Math.min(w, h);
    const tex = getFireTexture(pixi);
    const c = new pixi.Container();
    for (let i = 0; i < inst.points.length; i += 1) {
      const p = inst.points[i];
      if (!p) continue;
      const s = new pixi.Sprite(tex);
      s.anchor?.set?.(0.5, 0.5);
      s.x = vx + p.x * w;
      s.y = vy + p.y * h;
      const scale = r / Math.max(1, tex.width * 0.5);
      s.scale?.set?.(scale, scale);
      s.rotation = (hash01(inst.seed ^ 0x1f1f1f1f, i) - 0.5) * 0.35;
      s.alpha = base;
      // Огонь “подсвечивает” — используем аддитивное смешивание.
      s.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
      // Тёплый тинт: от оранжевого к жёлтому.
      const warm = hash01(inst.seed ^ 0x2222222, i);
      s.tint = warm > 0.5 ? 0xffa000 : 0xff5a00;
      (s as any).__fx = {
        bx: s.x,
        by: s.y,
        phase: hash01(inst.seed, i) * Math.PI * 2,
        phase2: hash01(inst.seed ^ 0x1234567, i) * Math.PI * 2,
        drift: 4 + 18 * hash01(inst.seed ^ 0x9e3779b9, i),
        flicker: 0.7 + 0.6 * hash01(inst.seed ^ 0x3141592, i),
        flickerSpeed: 0.0022 + 0.0038 * hash01(inst.seed ^ 0x7777777, i),
        flickerAmp: 0.35 + 0.55 * hash01(inst.seed ^ 0x8888888, i),
        tongue: 0.55 + 0.65 * hash01(inst.seed ^ 0x9999999, i), // “высота языка” (индивидуально для точки)
        rot0: s.rotation,
        rotSpeed: (hash01(inst.seed ^ 0x2718281, i) - 0.5) * 0.65,
        sy0: s.scale?.y ?? 1,
      };
      c.addChild(s);
    }
    (c as any).__fx = { id: inst.id, type: inst.type };
    c.alpha = Math.max(0, Math.min(1, inst.opacity));
    return c;
  }
  if (inst.type === 'rain') {
    const base = inst.opacity;
    const r = inst.radiusN * Math.min(w, h);
    const tex = getRainTexture(pixi);
    const c = new pixi.Container();
    for (let i = 0; i < inst.points.length; i += 1) {
      const p = inst.points[i];
      if (!p) continue;
      const s = new pixi.Sprite(tex);
      s.anchor?.set?.(0.5, 0.5);
      s.x = vx + p.x * w;
      s.y = vy + p.y * h;
      const scale = r / Math.max(1, tex.width * 0.5);
      s.scale?.set?.(scale, scale);
      s.rotation = (-12 * Math.PI) / 180; // лёгкий наклон “ветра”
      s.alpha = base;
      // Тёмные линии дождя должны быть заметны на карте — рисуем обычным смешиванием.
      s.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
      // Цвет капли: рандом (детерминированный) с более заметным разбегом,
      // чтобы было видно, что линии разные (тёмно-синий -> водянистый голубовато-синий).
      const ct = hash01(inst.seed ^ 0x6d2b79f5, i);
      const r0 = 0x08;
      const g0 = 0x1a;
      const b0 = 0x2e;
      const r1 = 0x78;
      const g1 = 0xb8;
      const b1 = 0xf0;
      const rr = Math.round(r0 + (r1 - r0) * ct);
      const gg = Math.round(g0 + (g1 - g0) * ct);
      const bb = Math.round(b0 + (b1 - b0) * ct);
      s.tint = (rr << 16) | (gg << 8) | bb;
      (s as any).__fx = {
        bx: s.x,
        by: s.y,
        phase: hash01(inst.seed ^ 0x55aa55aa, i) * Math.PI * 2,
        drift: 3 + 10 * hash01(inst.seed ^ 0x9e3779b9, i),
        fallSpeed: 0.35 + 1.25 * hash01(inst.seed ^ 0x1020304, i), // норм. к радиусу
        spread: 0.45 + 0.75 * hash01(inst.seed ^ 0x2030405, i),
        alphaMul: 1.05 + 0.7 * hash01(inst.seed ^ 0x3040506, i),
        rot0: s.rotation,
      };
      c.addChild(s);
    }
    (c as any).__fx = { id: inst.id, type: inst.type };
    c.alpha = Math.max(0, Math.min(1, inst.opacity));
    return c;
  }
  if (inst.type === 'water') {
    const c = new pixi.Container();
    const halfW = Math.max(1.5, inst.radiusN * Math.min(w, h));
    if (inst.id === '__draft__') {
      const g = new pixi.Graphics();
      c.addChild(g);
      redrawWaterDraft(g, inst, viewport, halfW);
      (c as any).__fx = { kind: 'waterDraft', g };
      c.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
      c.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
      return c;
    }
    const built = buildWaterFillSprite(pixi, inst, viewport, halfW);
    if (!built) return null;
    c.addChild(built.sprite);
    (c as any).__fx = { kind: 'waterSolid' };
    c.alpha = Math.max(0, Math.min(1, inst.opacity));
    c.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
    return c;
  }
  if (inst.type === 'lightning') {
    const g = new pixi.Graphics();
    g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
    (g as any).__fx = {
      id: inst.id,
      type: inst.type,
      start: inst.start,
      end: inst.end,
      widthN: inst.widthN,
    };
    // Первичная отрисовка, дальше будет обновляться в animateNodes.
    redrawLightning(pixi, g, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
    return g;
  }
  if (inst.type === 'sunbeam') {
    const g = new pixi.Graphics();
    g.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
    (g as any).__fx = {
      id: inst.id,
      type: inst.type,
      start: inst.start,
      end: inst.end,
      widthN: inst.widthN,
    };
    redrawSunbeam(pixi, g, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
    return g;
  }
  if (inst.type === 'poisonCloud') {
    const cont = new pixi.Container();
    const tex = getPoisonParticleTexture(pixi);
    const particleCount = 520;
    const particles: PoisonParticleFx[] = [];
    for (let i = 0; i < particleCount; i += 1) {
      const s = new pixi.Sprite(tex);
      s.anchor?.set?.(0.5, 0.5);
      const kind: 'stem' | 'cap' = hash01(inst.seed ^ 0x50f3757f, i) < 0.44 ? 'stem' : 'cap';
      const gVar = hash01(inst.seed ^ 0xdead00, i);
      const tint = gVar < 0.33 ? 0x33ff99 : gVar < 0.66 ? 0x22ee77 : 0x44ffaa;
      s.tint = tint;
      s.blendMode = pixi.BLEND_MODES?.ADD ?? 1;
      particles.push({
        s,
        kind,
        uStem: hash01(inst.seed ^ 0x111111, i),
        side: hash01(inst.seed ^ 0x222222, i) * 2 - 1,
        capAng: hash01(inst.seed ^ 0x333333, i) * Math.PI * 2,
        capDist: Math.sqrt(hash01(inst.seed ^ 0x444444, i)),
        // Мелкие точки: узкий разброс размера (раньше до ~3.7 давало гигантские блики).
        sz: 0.22 + hash01(inst.seed ^ 0x555555, i) * 0.75,
        flick: hash01(inst.seed ^ 0x666666, i) * Math.PI * 2,
      });
      cont.addChild(s);
    }
    const TextApi = (pixi as { Text?: new (opts: object) => any }).Text;
    let skull: any = null;
    if (typeof TextApi === 'function') {
      skull = new TextApi({
        text: '☠',
        style: {
          fontFamily: 'Arial, "Segoe UI Emoji", sans-serif',
          fontSize: 40,
          fill: 0x66ff99,
          align: 'center',
        },
      });
      skull.anchor?.set?.(0.5, 1);
      skull.visible = false;
      cont.addChild(skull);
    }
    (cont as any).__fx = { particles, skull };
    cont.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
    redrawPoisonCloud(pixi, cont, inst, viewport, 0, Math.max(1, inst.lifetimeMs));
    return cont;
  }
  if (inst.type === 'freeze') {
    const tex = getFreezeScreenTexture(pixi, inst.seed, viewport);
    const s = new pixi.Sprite(tex);
    s.anchor?.set?.(0, 0);
    s.x = viewport.x;
    s.y = viewport.y;
    s.width = viewport.w;
    s.height = viewport.h;
    s.blendMode = pixi.BLEND_MODES?.NORMAL ?? 0;
    s.alpha = 0;
    (s as any).__fx = { id: inst.id, type: inst.type, seed: inst.seed, vw: viewport.w, vh: viewport.h };
    return s;
  }
  if (inst.type === 'scorch') {
    const tex = getScorchTexture(pixi);
    const s = new pixi.Sprite(tex);
    s.anchor?.set?.(0.5, 0.5);
    s.x = vx + inst.at.x * w;
    s.y = vy + inst.at.y * h;
    const r = inst.radiusN * Math.min(w, h);
    const scale = r / Math.max(1, tex.width * 0.5);
    s.scale?.set?.(scale, scale);
    s.rotation = hash01(inst.seed ^ 0xfeed, 0) * Math.PI * 2;
    s.alpha = Math.max(0, Math.min(1, inst.opacity));
    s.tint = 0x000000;
    s.blendMode = pixi.BLEND_MODES?.MULTIPLY ?? 3;
    (s as any).__fx = { id: inst.id, type: inst.type };
    return s;
  }
  if (inst.type === 'ice') {
    const tex = getIceTexture(pixi);
    const s = new pixi.Sprite(tex);
    s.anchor?.set?.(0.5, 0.5);
    s.x = vx + inst.at.x * w;
    s.y = vy + inst.at.y * h;
    const r = inst.radiusN * Math.min(w, h);
    const scale = r / Math.max(1, tex.width * 0.5);
    s.scale?.set?.(scale, scale);
    s.rotation = hash01(inst.seed ^ 0x1ced, 0) * Math.PI * 2;
    s.alpha = Math.max(0, Math.min(1, inst.opacity));
    // Цвет задаём в текстуре: белый прозрачный лёд + белые трещины.
    s.tint = 0xffffff;
    s.blendMode = pixi.BLEND_MODES?.SCREEN ?? 2;
    (s as any).__fx = { id: inst.id, type: inst.type };
    return s;
  }
  return null;
}

function animateNodes(
  pixi: any,
  nodes: Map<string, any>,
  state: EffectsState,
  nowMs: number,
  size: { w: number; h: number },
  viewport: { x: number; y: number; w: number; h: number },
) {
  const instById = new Map(state.instances.map((i) => [i.id, i]));
  void pixi;
  void size;
  const { w, h } = viewport;

  for (const [id, node] of nodes.entries()) {
    const inst = instById.get(id);
    if (!inst) continue;
    const t = Math.max(0, nowMs - inst.createdAtMs);

    if (inst.type === 'fog') {
      const cont = node;
      const pulse = 0.88 + 0.14 * Math.sin(0.0012 * t + hash01(inst.seed, 0) * 6.28);
      cont.alpha = Math.max(0, Math.min(1, inst.opacity * pulse));
      for (const child of cont.children ?? []) {
        const fx = (child as any).__fx;
        if (!fx) continue;
        const wob = fx.drift;
        child.x = fx.bx + Math.sin(0.0009 * t + fx.phase) * wob;
        child.y = fx.by + Math.cos(0.0011 * t + fx.phase) * wob;
        child.alpha = cont.alpha;
        if (typeof fx.rot0 === 'number' && typeof fx.rotSpeed === 'number') {
          child.rotation = fx.rot0 + fx.rotSpeed * (t / 1000);
        }
      }
    }

    if (inst.type === 'fire') {
      const cont = node;
      // Огонь: общий фон + per-sprite “языки” и неравномерная пульсация.
      const baseFlick = 0.78 + 0.22 * Math.sin(0.0062 * t + hash01(inst.seed ^ 0xabcdef, 0) * 6.28);
      // Не даём “пламени” уходить в почти ноль — всегда остаётся базовое свечение.
      cont.alpha = Math.max(0, Math.min(1, inst.opacity * Math.max(0.55, baseFlick)));
      for (const child of cont.children ?? []) {
        const fx = (child as any).__fx;
        if (!fx) continue;
        const wob = fx.drift;
        // Пламя чуть “тянется” вверх, но без изменения конечной позиции кисти.
        // Лифт делаем “языками”: сильнее при вспышках конкретного спрайта.
        const sF = typeof fx.flickerSpeed === 'number' ? fx.flickerSpeed * 1.75 : 0.0058;
        const sA = typeof fx.flickerAmp === 'number' ? fx.flickerAmp : 0.6;
        const tongue = typeof fx.tongue === 'number' ? fx.tongue : 1;
        const local =
          0.55 +
          0.45 * Math.sin(sF * t + (fx.phase ?? 0)) * sA +
          0.25 * Math.sin(sF * 1.9 * t + (fx.phase2 ?? 0)) * (0.35 + 0.65 * tongue);
        const local01 = Math.max(0, Math.min(1.2, local));
        // Без накопления: язык “дышит” вверх-вниз, но не уплывает со временем.
        const lift =
          -(4 + 10 * local01) * (0.55 + 0.65 * tongue) -
          (1.5 + 2.5 * tongue) * Math.sin(0.0045 * t + (fx.phase ?? 0));
        child.x = fx.bx + Math.sin(0.0018 * t + fx.phase) * wob;
        child.y = fx.by + Math.cos(0.0022 * t + fx.phase) * wob + lift;
        // Неравномерная пульсация без ступенчатого шума (ступени давали заметное моргание).
        const nPhase = hash01(inst.seed ^ 0xf00baa, (fx.bx | 0) + (fx.by | 0)) * Math.PI * 2;
        const noise = 0.11 * Math.sin(0.0031 * t + nPhase) + 0.07 * Math.sin(0.0077 * t + nPhase * 1.7);
        child.alpha = Math.max(0, Math.min(1, cont.alpha * (0.85 + 0.45 * local01 + noise)));
        // “Языки” виднее, если чуть пульсировать высоту спрайта.
        const sy0 = typeof fx.sy0 === 'number' ? fx.sy0 : 1;
        const stretch = 0.85 + 0.35 * local01 * (0.7 + 0.3 * tongue);
        child.scale?.set?.(child.scale.x, sy0 * stretch);
        if (typeof fx.rot0 === 'number' && typeof fx.rotSpeed === 'number') {
          child.rotation = fx.rot0 + fx.rotSpeed * (t / 1000);
        }
      }
    }

    if (inst.type === 'water') {
      const cont = node;
      const pulse = 0.94 + 0.06 * Math.sin(0.0011 * t + hash01(inst.seed ^ 0xc001d00d, 0) * 6.28);
      cont.alpha = Math.max(0, Math.min(1, inst.opacity * pulse));
    }

    if (inst.type === 'rain') {
      const cont = node;
      // Дождь: стабильная “шторка” + движение капель вниз с лёгким дрейфом.
      const base = 0.85 + 0.15 * Math.sin(0.0018 * t + hash01(inst.seed ^ 0xdeadbeef, 0) * 6.28);
      cont.alpha = Math.max(0, Math.min(1, inst.opacity * Math.max(1.35, base * 1.28)));
      const rPx = inst.radiusN * Math.min(w, h);
      const wrap = Math.max(18, rPx * 1.8);
      for (const child of cont.children ?? []) {
        const fx = (child as any).__fx;
        if (!fx) continue;
        const fall = typeof fx.fallSpeed === 'number' ? fx.fallSpeed : 1;
        const spread = typeof fx.spread === 'number' ? fx.spread : 1;
        const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
        const wob = typeof fx.drift === 'number' ? fx.drift : 0;

        // Позиция: вокруг базовой точки, капля “перезапускается” по кругу вниз.
        const yOff = ((t * 0.04 * fall + (fx.phase ?? 0) * 120) % wrap) - wrap / 2;
        const xOff = Math.sin(0.0022 * t + (fx.phase ?? 0)) * wob * 0.6;
        const gust = Math.sin(0.00055 * t + hash01(inst.seed ^ 0xabcdef01, fx.bx | 0) * 6.28) * wob;
        child.x = fx.bx + xOff + gust;
        child.y = fx.by + yOff * spread;

        // Альфа: немного “мерцает”, но не пропадает полностью.
        const a = cont.alpha * (0.72 + 0.28 * Math.sin(0.0032 * t + (fx.phase ?? 0))) * alphaMul;
        child.alpha = Math.max(0.68, Math.min(1, a));
      }
    }

    if (inst.type === 'lightning') {
      const g = node;
      const life = Math.max(1, inst.lifetimeMs);
      if (t >= life) {
        g.visible = false;
        continue;
      }
      g.visible = true;
      redrawLightning(pixi, g, inst, viewport, t, life);
    }

    if (inst.type === 'sunbeam') {
      const g = node;
      const life = Math.max(1, inst.lifetimeMs);
      if (t >= life) {
        g.visible = false;
        continue;
      }
      g.visible = true;
      redrawSunbeam(pixi, g, inst, viewport, t, life);
    }

    if (inst.type === 'poisonCloud') {
      const cont = node;
      const life = Math.max(1, inst.lifetimeMs);
      if (t >= life) {
        cont.visible = false;
        continue;
      }
      cont.visible = true;
      redrawPoisonCloud(pixi, cont, inst, viewport, t, life);
    }

    if (inst.type === 'freeze') {
      const s = node;
      const life = Math.max(1, inst.lifetimeMs);
      if (t >= life) {
        s.visible = false;
        continue;
      }
      s.visible = true;
      const fx = (s as any).__fx ?? {};
      if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
        s.texture = getFreezeScreenTexture(pixi, inst.seed, viewport);
        fx.vw = viewport.w;
        fx.vh = viewport.h;
        (s as any).__fx = fx;
      }
      s.x = viewport.x;
      s.y = viewport.y;
      s.width = viewport.w;
      s.height = viewport.h;
      s.alpha = freezeAlpha(t, life) * Math.max(0, Math.min(1.2, inst.intensity));
    }

    if (inst.type === 'scorch') {
      const s = node;
      const life = Math.max(1, inst.lifetimeMs);
      const fade = 1 - t / life;
      s.visible = fade > 0;
      s.alpha = Math.max(0, Math.min(1, inst.opacity * fade));
    }

    if (inst.type === 'ice') {
      const s = node;
      // Пятно льда: сразу на полную яркость и остаётся на сцене (не синхронизируем с длительностью «замершего экрана»).
      s.visible = true;
      s.alpha = Math.max(0, Math.min(1, inst.opacity));
    }
  }
}

function hashWaterStroke(inst: Extract<EffectInstance, { type: 'water' }>): number {
  let h = 2166136261 >>> 0;
  for (const p of inst.points) {
    if (!p) continue;
    h ^= Math.round(p.x * 10000);
    h = Math.imul(h, 16777619);
    h ^= Math.round(p.y * 10000);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

function redrawWaterDraft(
  g: any,
  inst: Extract<EffectInstance, { type: 'water' }>,
  viewport: { x: number; y: number; w: number; h: number },
  halfW: number,
) {
  const { x: vx, y: vy, w, h } = viewport;
  g.clear();
  const pts = inst.points;
  if (pts.length === 0) return;
  const px = pts.map((p) => ({ x: vx + p.x * w, y: vy + p.y * h }));
  // Толщина как у итоговой заливки: диаметр = 2 × радиус кисти (см. buildWaterFillSprite).
  const lineW = Math.max(2, halfW * 2);
  if (px.length === 1) {
    const p0 = px[0];
    if (!p0) return;
    g.circle(p0.x, p0.y, Math.max(2, halfW));
    g.fill({ color: 0x5fc3ff, alpha: 0.42 });
    return;
  }
  const pStart = px[0];
  if (!pStart) return;
  g.moveTo(pStart.x, pStart.y);
  for (let i = 1; i < px.length; i += 1) {
    const pi = px[i];
    if (!pi) continue;
    g.lineTo(pi.x, pi.y);
  }
  g.stroke({ width: lineW, color: 0x5fc3ff, alpha: 0.48, cap: 'round', join: 'round' });
}

function buildWaterFillSprite(
  pixi: any,
  inst: Extract<EffectInstance, { type: 'water' }>,
  viewport: { x: number; y: number; w: number; h: number },
  halfW: number,
): { sprite: any } | null {
  const pts = inst.points;
  if (pts.length === 0) return null;
  const { x: vx, y: vy, w, h } = viewport;
  const pxPts = pts.map((p) => ({ x: vx + p.x * w, y: vy + p.y * h }));
  const firstPt = pxPts[0];
  if (!firstPt) return null;
  let minX = firstPt.x;
  let maxX = firstPt.x;
  let minY = firstPt.y;
  let maxY = firstPt.y;
  for (const p of pxPts) {
    minX = Math.min(minX, p.x);
    maxX = Math.max(maxX, p.x);
    minY = Math.min(minY, p.y);
    maxY = Math.max(maxY, p.y);
  }
  const pad = halfW * 2 + 8;
  const cw = Math.max(1, Math.ceil(maxX - minX + pad));
  const ch = Math.max(1, Math.ceil(maxY - minY + pad));
  const canvas = document.createElement('canvas');
  canvas.width = cw;
  canvas.height = ch;
  const ctx = canvas.getContext('2d');
  if (!ctx) return null;
  const ox = -minX + pad / 2;
  const oy = -minY + pad / 2;
  const alpha = Math.max(0.08, Math.min(0.82, inst.opacity));
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.strokeStyle = `rgba(95, 195, 255, ${String(alpha)})`;
  ctx.fillStyle = `rgba(95, 195, 255, ${String(alpha)})`;
  ctx.lineWidth = halfW * 2;

  if (pxPts.length === 1) {
    ctx.beginPath();
    ctx.arc(firstPt.x + ox, firstPt.y + oy, halfW, 0, Math.PI * 2);
    ctx.fill();
  } else {
    ctx.beginPath();
    ctx.moveTo(firstPt.x + ox, firstPt.y + oy);
    for (let i = 1; i < pxPts.length; i += 1) {
      const pi = pxPts[i];
      if (!pi) continue;
      ctx.lineTo(pi.x + ox, pi.y + oy);
    }
    ctx.stroke();
  }

  const texture = pixi.Texture.from(canvas);
  const sprite = new pixi.Sprite(texture);
  sprite.x = minX - pad / 2;
  sprite.y = minY - pad / 2;
  return { sprite };
}

function redrawPoisonCloud(
  pixi: any,
  cont: any,
  inst: Extract<EffectInstance, { type: 'poisonCloud' }>,
  viewport: { x: number; y: number; w: number; h: number },
  t: number,
  life: number,
) {
  void pixi;
  const fx = (cont as any).__fx as { particles: PoisonParticleFx[]; skull: any };
  const particles = fx.particles;
  const skull = fx.skull;
  const { x: vx, y: vy, w, h } = viewport;
  const cx = vx + inst.at.x * w;
  const cy = vy + inst.at.y * h;
  const R = Math.max(14, inst.radiusN * Math.min(w, h) * 1.15);
  const inten = Math.max(0, Math.min(1.2, inst.intensity));

  const u = t / Math.max(1e-6, life);
  const stemPhase = smoothstep01(Math.min(1, u / 0.34));
  const capPhase = u < 0.14 ? 0 : smoothstep01((u - 0.14) / Math.max(1e-6, 0.44));

  let alphaM = 1;
  if (u >= 0.66) {
    alphaM = 1 - smoothstep01((u - 0.66) / Math.max(1e-6, 0.34));
  }
  const aGlobal = Math.max(0, Math.min(1, inten * alphaM));
  if (aGlobal < 0.003) {
    if (skull) skull.visible = false;
    for (const p of particles) p.s.visible = false;
    return;
  }

  const stemH = R * 1.1;
  const baseHalfW = R * 0.72;
  const topHalfW = R * 0.1;
  const stemTopY = cy - stemH * stemPhase;
  const capCenterY = stemTopY - R * 0.12 * Math.max(0.2, capPhase);
  const capRad = R * 1.25 * capPhase;
  const wobble = 0.04 * Math.sin(t * 0.014 + inst.seed * 0.01);
  const particleR = 0.32;

  for (const p of particles) {
    const s = p.s;
    if (p.kind === 'stem') {
      s.visible = stemPhase > 0.02;
      if (!s.visible) continue;
      const frac = p.uStem;
      const z = frac * stemH * stemPhase;
      const y = cy - z;
      const mix = frac;
      const halfAtY = baseHalfW * (1 - mix) + topHalfW * mix;
      const spread = halfAtY * (0.82 + 0.18 * Math.sin(p.flick + t * 0.008));
      s.x = cx + p.side * spread + wobble * R * 0.08;
      s.y = y + Math.sin(p.flick * 1.7 + t * 0.01) * 2.5;
      const pr = stemPhase * (0.2 + 0.8 * smoothstep01(frac * 1.15));
      s.alpha = Math.max(0, Math.min(1, aGlobal * 0.55 * pr * (0.65 + 0.35 * stemPhase)));
      s.scale?.set?.(p.sz * (0.75 + 0.35 * stemPhase) * particleR);
    } else {
      const vis = capPhase;
      if (vis < 0.02) {
        s.visible = false;
        continue;
      }
      s.visible = true;
      const ang = p.capAng + wobble * 0.4;
      const rd = capRad * Math.min(1, p.capDist * 1.02 + 0.02);
      s.x = cx + Math.cos(ang) * rd + Math.sin(t * 0.011 + p.flick) * 3;
      s.y = capCenterY + Math.sin(ang) * rd * 0.48;
      s.alpha = Math.max(
        0,
        Math.min(1, aGlobal * 0.52 * vis * (0.75 + 0.25 * Math.sin(t * 0.019 + p.flick))),
      );
      s.scale?.set?.(p.sz * (0.55 + 0.55 * vis) * particleR);
    }
  }

  if (skull) {
    const show = stemPhase > 0.72 && capPhase > 0.28;
    skull.visible = show;
    skull.alpha = aGlobal * (show ? 0.94 : 0);
    skull.x = cx + wobble * R * 0.05;
    skull.y = capCenterY - capRad * 0.72 - R * 0.28;
    const fs = Math.max(16, Math.min(86, R * 0.48 + capRad * 0.38));
    if (skull.style && typeof skull.style === 'object' && 'fontSize' in skull.style) {
      (skull.style as { fontSize: number }).fontSize = fs;
    }
  }
}

function redrawSunbeam(
  pixi: any,
  g: any,
  inst: Extract<EffectInstance, { type: 'sunbeam' }>,
  viewport: { x: number; y: number; w: number; h: number },
  t: number,
  life: number,
) {
  void pixi;
  const { x: vx, y: vy, w, h } = viewport;
  const sx = vx + inst.start.x * w;
  const sy = vy + inst.start.y * h;
  const ex = vx + inst.end.x * w;
  const ey = vy + inst.end.y * h;
  const width = Math.max(1, inst.widthN * Math.min(w, h));
  const inten = Math.max(0, Math.min(1.2, inst.intensity));
  g.clear();

  const lifePos = Math.max(1e-6, life);
  // Фаза 1: рост сверху вниз; фаза 2: полный луч + лёгкая пульсация; фаза 3: затухание.
  const revealMs = lifePos * 0.28;
  const pulseMs = lifePos * 0.47;
  const fadeMs = lifePos * 0.25;
  const tRevealEnd = revealMs;
  const tPulseEnd = revealMs + pulseMs;

  const revealLin = Math.min(1, t / Math.max(1e-6, revealMs));
  const revealEase = t < tRevealEnd ? smoothstep01(revealLin) : 1;

  let pulseMul = 1;
  if (t >= tRevealEnd && t < tPulseEnd) {
    const u = t - tRevealEnd;
    const pulses = 5;
    const omega = (pulses * 2 * Math.PI) / Math.max(1e-6, pulseMs);
    const phase = (inst.seed & 4095) * 0.00153;
    pulseMul = 1 + 0.1 * Math.sin(u * omega + phase);
  }

  const vis = t < tPulseEnd ? 1 : 1 - smoothstep01((t - tPulseEnd) / Math.max(1e-6, fadeMs));
  if (vis < 0.002) return;

  const bx = sx + (ex - sx) * revealEase;
  const by = sy + (ey - sy) * revealEase;
  const intenVis = inten * vis * pulseMul;

  // Засветка кадра: нарастает с лучом; в фазе пульса слегка «дышит»; затем гаснет.
  const veilMod = t >= tRevealEnd && t < tPulseEnd ? pulseMul : 1;
  const veilA = Math.max(0, Math.min(0.24, 0.22 * revealEase * vis * inten * veilMod));
  if (veilA > 0.002) {
    g.rect(vx, vy, w, h);
    g.fill({ color: 0xfff4b8, alpha: veilA });
  }

  const minDim = Math.min(w, h);
  const segLen = Math.hypot(bx - sx, by - sy);
  if (segLen < 0.8) return;

  const outerW = Math.max(width * 26, minDim * 0.9);
  g.moveTo(sx, sy);
  g.lineTo(bx, by);
  g.stroke({
    color: 0xffec78,
    width: outerW,
    alpha: Math.min(1, 0.034 * intenVis),
    cap: 'round',
  });

  const haloLayers: { mult: number; color: number; a: number }[] = [
    { mult: 18, color: 0xffe040, a: 0.048 * intenVis },
    { mult: 12, color: 0xffea65, a: 0.075 * intenVis },
    { mult: 7, color: 0xfff090, a: 0.11 * intenVis },
    { mult: 4, color: 0xfffacd, a: 0.16 * intenVis },
    { mult: 2.2, color: 0xffffee, a: 0.22 * intenVis },
  ];
  for (const layer of haloLayers) {
    const lw = Math.max(width * layer.mult, width + minDim * 0.025);
    const a = Math.min(1, layer.a);
    if (a < 0.004) continue;
    g.moveTo(sx, sy);
    g.lineTo(bx, by);
    g.stroke({ color: layer.color, width: lw, alpha: a, cap: 'round' });
  }

  const coreLayers: { wmul: number; color: number; a: number }[] = [
    { wmul: 2.4, color: 0xffffcc, a: Math.min(1, 0.45 * intenVis) },
    { wmul: 1.05, color: 0xffffaa, a: Math.min(1, 0.78 * intenVis) },
    { wmul: 0.4, color: 0xffffff, a: Math.min(1, 0.98 * intenVis) },
  ];
  for (const cl of coreLayers) {
    const lw = Math.max(1.2, width * cl.wmul);
    g.moveTo(sx, sy);
    g.lineTo(bx, by);
    g.stroke({ color: cl.color, width: lw, alpha: cl.a, cap: 'round' });
  }
}

function redrawLightning(
  pixi: any,
  g: any,
  inst: Extract<EffectInstance, { type: 'lightning' }>,
  viewport: { x: number; y: number; w: number; h: number },
  t: number,
  life: number,
) {
  void pixi;
  const { x: vx, y: vy, w, h } = viewport;
  const ex = vx + inst.end.x * w;
  const ey = vy + inst.end.y * h;
  const width = Math.max(1, inst.widthN * Math.min(w, h));
  g.clear();

  // Фаза удара: короткая яркая вспышка, которая на мгновение “заслоняет” весь экран.
  const flashMs = Math.min(140, Math.max(60, Math.floor(life * 0.8)));
  const flashT = Math.max(0, Math.min(1, 1 - t / flashMs));
  const overlayAlpha = Math.max(0, Math.min(0.5, 0.5 * flashT * inst.intensity));
  if (overlayAlpha > 0.001) {
    g.rect(vx, vy, w, h);
    g.fill({ color: 0xffe066, alpha: overlayAlpha });
  }

  // Тонкая “молния”: ломаная сверху -> середина -> отвод вправо -> точка удара.
  const sx = ex;
  const sy = vy;
  const coreAlpha = Math.max(0, Math.min(1, (0.9 + 0.1 * flashT) * inst.intensity));

  // Небольшой дрожащий джиттер, чтобы молния выглядела живой.
  const n = Math.floor(t / 16);
  const jx0 = (hash01(inst.seed ^ 0x111, n) - 0.5) * width * 0.35;
  const jy0 = (hash01(inst.seed ^ 0x222, n) - 0.5) * width * 0.25;
  const jx1 = (hash01(inst.seed ^ 0x333, n + 1) - 0.5) * width * 0.35;
  const jy1 = (hash01(inst.seed ^ 0x444, n + 1) - 0.5) * width * 0.25;

  const midY = ey * 0.5;
  const midX = sx + (hash01(inst.seed ^ 0x777, 0) - 0.5) * width * 0.35;
  const kinkY = ey * 0.62;
  const kinkDx = (0.12 + 0.08 * hash01(inst.seed ^ 0x888, 0)) * Math.min(w, h);
  const kinkX = midX + kinkDx; // “сворачиваем вправо”

  // Рисуем ломаную как одну линию.
  g.moveTo(sx + jx0, sy + jy0);
  g.lineTo(midX + jx1, midY + jy1);
  g.lineTo(kinkX + jx0, kinkY + jy0);
  g.lineTo(ex - jx1, ey - jy1);

  // Тонкое белое ядро + жёлтый ореол (тоже тонкий), чтобы выглядело как молния, а не маркер.
  const thin = Math.max(1, width * 0.28);
  g.stroke({ color: 0xffffff, width: thin, alpha: Math.min(1, coreAlpha) });
  g.stroke({ color: 0xffd000, width: Math.max(1.5, thin * 1.9), alpha: Math.min(1, coreAlpha * 0.28) });

  // Сеть разрядов от точки удара: короткие “ветки”, которые быстро исчезают.
  // Оставляем только scorch-инстанс (он живёт отдельно), поэтому здесь всё затухает по flashT.
  const branchAlpha = Math.min(1, coreAlpha) * flashT;
  if (branchAlpha > 0.01) {
    const baseR = (0.06 + 0.08 * hash01(inst.seed ^ 0x999, 0)) * Math.min(w, h);
    const branches = 10;
    for (let i = 0; i < branches; i += 1) {
      const a0 = (i / branches) * Math.PI * 2 + (hash01(inst.seed ^ 0x5151, i) - 0.5) * 0.45;
      const r0 = baseR * (0.55 + 0.65 * hash01(inst.seed ^ 0x6161, i));
      const r1 = r0 * (0.55 + 0.4 * hash01(inst.seed ^ 0x7171, i));

      const x1 = ex + Math.cos(a0) * r0;
      const y1 = ey + Math.sin(a0) * r0;
      // маленький “зубчик” посередине, чтобы ветка была ломаной
      const a1 = a0 + (hash01(inst.seed ^ 0x8181, i) - 0.5) * 1.0;
      const x2 = ex + Math.cos(a1) * r1;
      const y2 = ey + Math.sin(a1) * r1;

      g.moveTo(ex, ey);
      g.lineTo(x2, y2);
      g.lineTo(x1, y1);
      const bw = Math.max(1, thin * 0.85);
      g.stroke({ color: 0xffffff, width: bw, alpha: Math.min(1, branchAlpha * 0.75) });
      g.stroke({ color: 0xffd000, width: Math.max(1.5, bw * 1.8), alpha: Math.min(1, branchAlpha * 0.18) });
    }
  }
}

function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w: number; h: number }): string {
  if (inst.type === 'fog') {
    const last = inst.points[inst.points.length - 1];
    const lx = last ? Math.round(last.x * 1000) : 0;
    const ly = last ? Math.round(last.y * 1000) : 0;
    return `fog:${inst.points.length}:${lx}:${ly}:${Math.round(inst.radiusN * 1000)}`;
  }
  if (inst.type === 'fire') {
    const last = inst.points[inst.points.length - 1];
    const lx = last ? Math.round(last.x * 1000) : 0;
    const ly = last ? Math.round(last.y * 1000) : 0;
    return `fire:${inst.points.length}:${lx}:${ly}:${Math.round(inst.radiusN * 1000)}`;
  }
  if (inst.type === 'rain') {
    const last = inst.points[inst.points.length - 1];
    const lx = last ? Math.round(last.x * 1000) : 0;
    const ly = last ? Math.round(last.y * 1000) : 0;
    return `rain:${inst.points.length}:${lx}:${ly}:${Math.round(inst.radiusN * 1000)}`;
  }
  if (inst.type === 'water') {
    const hp = hashWaterStroke(inst);
    return `water:${inst.points.length}:${hp}:${Math.round(inst.radiusN * 1000)}:${Math.round(inst.opacity * 1000)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`;
  }
  if (inst.type === 'lightning') {
    return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
  }
  if (inst.type === 'sunbeam') {
    return `sb:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
  }
  if (inst.type === 'poisonCloud') {
    return `pc:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
  }
  if (inst.type === 'freeze') {
    return `fr:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.intensity * 1000)}`;
  }
  if (inst.type === 'scorch') {
    return `sc:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
  }
  if (inst.type === 'ice') {
    return `ice:${Math.round(inst.at.x * 1000)}:${Math.round(inst.at.y * 1000)}:${Math.round(inst.radiusN * 1000)}`;
  }
  // Exhaustive guard — на случай, если добавим новый тип инстанса и забудем сюда.
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const _exhaustive: never = inst;
  return 'unknown';
}

function hash01(seed: number, n: number): number {
  // Дешёвый детерминированный шум 0..1 (без Math.random).
  let x = (seed ^ (n * 374761393)) >>> 0;
  x = (x ^ (x >>> 13)) >>> 0;
  x = (x * 1274126177) >>> 0;
  return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
}

function computeSceneShake(
  state: EffectsState,
  nowMs: number,
  size: { w: number; h: number },
): { x: number; y: number } {
  // Ищем самую “активную” молнию в фазе вспышки и строим shake от неё.
  let best = 0;
  let bestSeed = 0;
  const { w, h } = size;
  for (const inst of state.instances) {
    if (inst.type !== 'lightning') continue;
    const t = Math.max(0, nowMs - inst.createdAtMs);
    const life = Math.max(1, inst.lifetimeMs);
    const flashMs = Math.min(140, Math.max(60, Math.floor(life * 0.8)));
    if (t > flashMs) continue;
    const flashT = Math.max(0, Math.min(1, 1 - t / flashMs));
    const k = flashT * Math.max(0, inst.intensity);
    if (k > best) {
      best = k;
      bestSeed = inst.seed;
    }
  }
  if (best <= 0.0001) return { x: 0, y: 0 };

  // Амплитуда небольшая: 0..~8px, зависит от размера экрана и интенсивности.
  const amp = Math.min(9, Math.max(2, 0.012 * Math.min(w, h))) * Math.min(1, best);
  const n = Math.floor(nowMs / 16); // примерно 60fps-ступенька, чтобы “дребезжало”
  const ox = (hash01(bestSeed ^ 0x13579bdf, n) - 0.5) * 2 * amp;
  const oy = (hash01(bestSeed ^ 0x2468ace, n) - 0.5) * 2 * amp;
  return { x: ox, y: oy };
}

let fogTextureCache: { key: string; texture: any } | null = null;
let fireTextureCache: { key: string; texture: any } | null = null;
let rainTextureCache: { key: string; texture: any } | null = null;
let poisonParticleTextureCache: { key: string; texture: any } | null = null;
let scorchTextureCache: { key: string; texture: any } | null = null;
let iceTextureCache: { key: string; texture: any } | null = null;
let freezeScreenTextureCache: Map<string, any> | null = null;

/** Круглая мягкая точка для частиц яда (не квадрат `Texture.WHITE`). */
function getPoisonParticleTexture(pixi: any): any {
  const key = 'poison_particle_round_v1';
  if (poisonParticleTextureCache?.key === key) return poisonParticleTextureCache.texture;

  const size = 48;
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  if (!ctx) {
    const t = pixi.Texture.WHITE;
    poisonParticleTextureCache = { key, texture: t };
    return t;
  }
  const cx = size / 2;
  const cy = size / 2;
  const r = size / 2 - 0.5;
  ctx.clearRect(0, 0, size, size);
  const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
  grd.addColorStop(0, 'rgba(255,255,255,1)');
  grd.addColorStop(0.88, 'rgba(255,255,255,0.95)');
  grd.addColorStop(1, 'rgba(255,255,255,0)');
  ctx.fillStyle = grd;
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.fill();

  const tex = pixi.Texture.from(canvas);
  poisonParticleTextureCache = { key, texture: tex };
  return tex;
}

function getFogTexture(pixi: any): any {
  // Кэшируем один раз на процесс. Текстура — “дымка” с мягкой альфой.
  const key = 'fog_v1';
  if (fogTextureCache?.key === key) return fogTextureCache.texture;

  const size = 256;
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  if (!ctx) {
    const t = pixi.Texture.WHITE;
    fogTextureCache = { key, texture: t };
    return t;
  }

  ctx.clearRect(0, 0, size, size);
  // База: мягкий радиальный градиент.
  const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
  g.addColorStop(0, 'rgba(255,255,255,0.85)');
  g.addColorStop(0.55, 'rgba(255,255,255,0.35)');
  g.addColorStop(1, 'rgba(255,255,255,0.00)');
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
  ctx.fill();

  // Деталь: лёгкий шум, чтобы туман не выглядел “идеальным”.
  const img = ctx.getImageData(0, 0, size, size);
  for (let i = 0; i < img.data.length; i += 4) {
    const a = img.data[i + 3] ?? 0;
    if (a === 0) continue;
    const n = (Math.random() - 0.5) * 28;
    img.data[i] = clamp255((img.data[i] ?? 0) + n);
    img.data[i + 1] = clamp255((img.data[i + 1] ?? 0) + n);
    img.data[i + 2] = clamp255((img.data[i + 2] ?? 0) + n);
  }
  ctx.putImageData(img, 0, 0);

  const tex = pixi.Texture.from(canvas);
  fogTextureCache = { key, texture: tex };
  return tex;
}

function getFireTexture(pixi: any): any {
  const key = 'fire_v2';
  if (fireTextureCache?.key === key) return fireTextureCache.texture;

  const size = 256;
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  if (!ctx) {
    const t = pixi.Texture.WHITE;
    fireTextureCache = { key, texture: t };
    return t;
  }

  ctx.clearRect(0, 0, size, size);

  // Градиент “пламени”: ярче внизу, мягко исчезает вверх/по краям.
  const g = ctx.createRadialGradient(size / 2, size * 0.68, size * 0.04, size / 2, size * 0.62, size * 0.55);
  g.addColorStop(0, 'rgba(255,255,255,0.95)');
  g.addColorStop(0.18, 'rgba(255,210,120,0.90)');
  g.addColorStop(0.42, 'rgba(255,140,40,0.70)');
  g.addColorStop(0.72, 'rgba(255,60,0,0.25)');
  g.addColorStop(1, 'rgba(255,0,0,0.00)');
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
  ctx.fill();

  // “Языки” пламени: рисуем полупрозрачные лепестки, сильнее внизу.
  // Делается до шума, чтобы шум потом “рвал” край и делал пламя живым.
  ctx.save();
  ctx.globalCompositeOperation = 'source-atop';
  for (let k = 0; k < 22; k += 1) {
    const cx = size * (0.22 + 0.56 * Math.random());
    const baseY = size * (0.62 + 0.22 * Math.random());
    const height = size * (0.18 + 0.36 * Math.random());
    const width = size * (0.03 + 0.06 * Math.random());
    const bend = (Math.random() - 0.5) * width * 3.0;
    const tipX = cx + bend;
    const tipY = baseY - height;

    const grad = ctx.createLinearGradient(cx, baseY, tipX, tipY);
    grad.addColorStop(0, 'rgba(255,255,255,0.32)');
    grad.addColorStop(0.35, 'rgba(255,190,90,0.22)');
    grad.addColorStop(1, 'rgba(255,50,0,0.00)');
    ctx.fillStyle = grad;

    ctx.beginPath();
    ctx.moveTo(cx - width, baseY);
    ctx.quadraticCurveTo(cx - width * 0.4, baseY - height * 0.55, tipX, tipY);
    ctx.quadraticCurveTo(cx + width * 0.45, baseY - height * 0.45, cx + width, baseY);
    ctx.closePath();
    ctx.fill();
  }
  ctx.restore();

  // Немного “рваности” — шум по альфе + вертикальные полосы.
  const img = ctx.getImageData(0, 0, size, size);
  for (let y = 0; y < size; y += 1) {
    for (let x = 0; x < size; x += 1) {
      const i = (y * size + x) * 4;
      const a = img.data[i + 3] ?? 0;
      if (a === 0) continue;
      const vy = 1 - y / (size - 1);
      const stripe = Math.sin((x / size) * Math.PI * 10 + (y / size) * Math.PI * 1.5) * 0.5 + 0.5;
      const n = (Math.random() - 0.5) * 110;
      const boost = (0.25 + 0.75 * stripe) * (0.18 + 0.82 * vy);
      img.data[i] = clamp255((img.data[i] ?? 0) + n * 0.28);
      img.data[i + 1] = clamp255((img.data[i + 1] ?? 0) + n * 0.2);
      img.data[i + 2] = clamp255((img.data[i + 2] ?? 0) + n * 0.07);
      img.data[i + 3] = clamp255(a * boost + (Math.random() - 0.5) * 22);
    }
  }
  ctx.putImageData(img, 0, 0);

  const tex = pixi.Texture.from(canvas);
  fireTextureCache = { key, texture: tex };
  return tex;
}

function getRainTexture(pixi: any): any {
  const key = 'rain_v2';
  if (rainTextureCache?.key === key) return rainTextureCache.texture;

  const w = 64;
  const h = 256;
  const canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext('2d');
  if (!ctx) {
    const t = pixi.Texture.WHITE;
    rainTextureCache = { key, texture: t };
    return t;
  }

  ctx.clearRect(0, 0, w, h);

  // Несколько “полос” разной толщины и длины — одна текстура на спрайт.
  ctx.lineCap = 'round';
  for (let i = 0; i < 42; i += 1) {
    const x = w * (0.1 + 0.8 * Math.random());
    const y0 = h * (0.05 + 0.9 * Math.random());
    const len = h * (0.12 + 0.32 * Math.random());
    // Прозрачность каждой линии — случайно 0.5..1 (множитель), чтобы дождь был разнообразнее.
    const a = (0.22 + 0.45 * Math.random()) * (0.5 + 0.5 * Math.random());
    const lw = 1.6 + Math.random() * 2.4;
    const grad = ctx.createLinearGradient(x, y0, x, y0 + len);
    // Цвет задаём tint’ом на спрайте (пер-капля), поэтому текстура — нейтрально-белая.
    grad.addColorStop(0, `rgba(255,255,255,${String(a * 0.0)})`);
    grad.addColorStop(0.2, `rgba(255,255,255,${String(a)})`);
    grad.addColorStop(1, `rgba(255,255,255,${String(a * 0.0)})`);
    ctx.strokeStyle = grad;
    ctx.lineWidth = lw;
    ctx.beginPath();
    ctx.moveTo(x, y0);
    ctx.lineTo(x, y0 + len);
    ctx.stroke();
  }

  // Чуть зерна по альфе — чтобы дождь не был слишком “компьютерным”.
  const img = ctx.getImageData(0, 0, w, h);
  for (let i = 0; i < img.data.length; i += 4) {
    const a = img.data[i + 3] ?? 0;
    if (a === 0) continue;
    img.data[i + 3] = clamp255(a + (Math.random() - 0.5) * 65);
  }
  ctx.putImageData(img, 0, 0);

  const tex = pixi.Texture.from(canvas);
  rainTextureCache = { key, texture: tex };
  return tex;
}

function getIceTexture(pixi: any): any {
  const key = 'ice_v3';
  if (iceTextureCache?.key === key) return iceTextureCache.texture;

  const size = 256;
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  if (!ctx) {
    const t = pixi.Texture.WHITE;
    iceTextureCache = { key, texture: t };
    return t;
  }

  ctx.clearRect(0, 0, size, size);

  // “Пятно” льда на земле: хаотичный кусок льда (не круг),
  // центр заполнен, сверху белые “трещины”/кромка инея.
  const cx = size / 2;
  const cy = size / 2;
  const radius = size * 0.48;
  const amp = Math.max(10, radius * 0.12);

  const img = ctx.createImageData(size, size);
  const data = img.data;
  const n2 = (x: number, y: number) => {
    const xi = x | 0;
    const yi = y | 0;
    const a = hash01(0x1ced ^ 0x9e3779b9, xi * 17 + yi * 131);
    const b = hash01(0x1ced ^ 0x7f4a7c15, xi * 53 + yi * 97);
    return a * 0.65 + b * 0.35;
  };

  for (let y = 0; y < size; y += 1) {
    for (let x = 0; x < size; x += 1) {
      const i = (y * size + x) * 4;
      const dx = x - cx;
      const dy = y - cy;
      const dist = Math.sqrt(dx * dx + dy * dy);

      // Неровная граница “куска льда”: радиус искажаем шумом.
      const nn = n2(x * 0.55, y * 0.55);
      const rr = radius + (nn - 0.5) * 2 * amp;
      if (dist > rr) {
        data[i + 3] = 0;
        continue;
      }

      // Центр обязательно заполнен: базовая заливка сильнее в центре, мягче к краю.
      const t = Math.max(0, Math.min(1, 1 - dist / Math.max(1, rr))); // 1 в центре, 0 на краю
      const fill = 0.35 + 0.65 * Math.pow(t, 0.7);

      // Лёгкая “молочность” и глубина (вариация прозрачности).
      const depth = 0.75 + 0.25 * n2(x * 0.12 + 13, y * 0.12 + 7);
      const a = Math.round(255 * fill * depth);

      // Белый прозрачный лёд: цвет почти белый, “структура” читается в альфе.
      const ice = 0.45 + 0.55 * n2(x * 0.22 + 3, y * 0.22 + 11);
      const edge = Math.pow(1 - t, 1.1);
      data[i] = Math.round(242 + 10 * ice - 6 * edge);
      data[i + 1] = Math.round(248 + 6 * ice - 5 * edge);
      data[i + 2] = Math.round(255 - 4 * edge);
      // Сделаем пятно менее прозрачным (но не “пустым”).
      data[i + 3] = Math.min(255, Math.round(a * 0.72));
    }
  }
  ctx.putImageData(img, 0, 0);

  // Белые трещины и “иней” сверху (чтобы не было ощущения идеально гладкого пятна).
  ctx.save();
  ctx.globalCompositeOperation = 'source-over';
  ctx.shadowColor = 'rgba(200,245,255,0.35)';
  ctx.shadowBlur = 4;
  ctx.lineCap = 'round';
  // Трещины (полилинии)
  for (let k = 0; k < 28; k += 1) {
    const ang = hash01(0x1ced ^ 0x91e10da, k) * Math.PI * 2;
    const r0 = radius * (0.05 + 0.22 * hash01(0x1ced ^ 0x1234567, k));
    const r1 = radius * (0.65 + 0.35 * hash01(0x1ced ^ 0x2345678, k));
    const sx = cx + Math.cos(ang) * r0;
    const sy = cy + Math.sin(ang) * r0;
    const ex = cx + Math.cos(ang + (hash01(0x1ced ^ 0x3456789, k) - 0.5) * 0.55) * r1;
    const ey = cy + Math.sin(ang + (hash01(0x1ced ^ 0x456789a, k) - 0.5) * 0.55) * r1;
    const midx = (sx + ex) / 2 + (hash01(0x1ced ^ 0x56789ab, k) - 0.5) * 18;
    const midy = (sy + ey) / 2 + (hash01(0x1ced ^ 0x6789abc, k) - 0.5) * 18;
    ctx.lineWidth = 1.2 + 1.8 * hash01(0x1ced ^ 0x789abcd, k);
    ctx.strokeStyle = `rgba(255,255,255,${String(0.22 + 0.35 * hash01(0x1ced ^ 0x89abcde, k))})`;
    ctx.beginPath();
    ctx.moveTo(sx, sy);
    ctx.lineTo(midx, midy);
    ctx.lineTo(ex, ey);
    ctx.stroke();
  }
  // Иней по границе (короткие штрихи внутрь)
  for (let k = 0; k < 140; k += 1) {
    const u = hash01(0x1ced ^ 0x51c0ffee, k);
    const ang = u * Math.PI * 2;
    const jitter = (hash01(0x1ced ^ 0x44aa77cc, k) - 0.5) * amp;
    const sx = cx + Math.cos(ang) * (radius + jitter);
    const sy = cy + Math.sin(ang) * (radius + jitter);
    const dx = -Math.cos(ang);
    const dy = -Math.sin(ang);
    const len = radius * (0.06 + 0.22 * hash01(0x1ced ^ 0xabcddcba, k));
    ctx.lineWidth = 0.6 + 1.1 * hash01(0x1ced ^ 0x12121212, k);
    ctx.strokeStyle = `rgba(255,255,255,${String(0.1 + 0.22 * hash01(0x1ced ^ 0x1c3d5e7, k))})`;
    ctx.beginPath();
    ctx.moveTo(sx, sy);
    ctx.lineTo(sx + dx * len, sy + dy * len);
    ctx.stroke();
  }
  ctx.restore();

  const tex = pixi.Texture.from(canvas);
  iceTextureCache = { key, texture: tex };
  return tex;
}

function getScorchTexture(pixi: any): any {
  const key = 'scorch_v1';
  if (scorchTextureCache?.key === key) return scorchTextureCache.texture;

  const size = 256;
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  if (!ctx) {
    const t = pixi.Texture.WHITE;
    scorchTextureCache = { key, texture: t };
    return t;
  }

  ctx.clearRect(0, 0, size, size);

  // “Рыхлый” ожог: более резкая форма + сильный шум по альфе.
  const g = ctx.createRadialGradient(size / 2, size / 2, size * 0.06, size / 2, size / 2, size / 2);
  g.addColorStop(0, 'rgba(255,255,255,0.98)');
  g.addColorStop(0.35, 'rgba(255,255,255,0.80)');
  g.addColorStop(0.7, 'rgba(255,255,255,0.25)');
  g.addColorStop(1, 'rgba(255,255,255,0.00)');
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
  ctx.fill();

  const img = ctx.getImageData(0, 0, size, size);
  for (let i = 0; i < img.data.length; i += 4) {
    const a = img.data[i + 3] ?? 0;
    if (a === 0) continue;
    // Шум по альфе и чуть по яркости: делает край “рваным”.
    const n = (Math.random() - 0.5) * 90;
    const na = (Math.random() - 0.5) * 140;
    img.data[i] = clamp255((img.data[i] ?? 0) + n);
    img.data[i + 1] = clamp255((img.data[i + 1] ?? 0) + n);
    img.data[i + 2] = clamp255((img.data[i + 2] ?? 0) + n);
    img.data[i + 3] = clamp255(a + na);
  }
  ctx.putImageData(img, 0, 0);

  const tex = pixi.Texture.from(canvas);
  scorchTextureCache = { key, texture: tex };
  return tex;
}

function smoothstep01(x: number): number {
  const t = Math.max(0, Math.min(1, x));
  return t * t * (3 - 2 * t);
}

function freezeAlpha(t: number, life: number): number {
  const L = Math.max(1, life);
  // Те же доли, что при life=820 (180 / 220 / остаток), масштабируются под длину звука.
  const inMs = Math.max(60, (L * 180) / 820);
  const holdMs = Math.max(40, (L * 220) / 820);
  const outMs = Math.max(80, L - inMs - holdMs);
  if (t <= inMs) return smoothstep01(t / inMs);
  if (t <= inMs + holdMs) return 1;
  return 1 - smoothstep01((t - inMs - holdMs) / outMs);
}

function getFreezeScreenTexture(
  pixi: any,
  seed: number,
  viewport: { x: number; y: number; w: number; h: number },
): any {
  const w = Math.max(1, Math.floor(viewport.w));
  const h = Math.max(1, Math.floor(viewport.h));
  const key = `freeze_v1:${String(w)}x${String(h)}:${String(seed >>> 0)}`;
  if (!freezeScreenTextureCache) freezeScreenTextureCache = new Map();
  const cached = freezeScreenTextureCache.get(key);
  if (cached) return cached;

  const canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext('2d');
  if (!ctx) {
    const t = pixi.Texture.WHITE;
    freezeScreenTextureCache.set(key, t);
    return t;
  }

  ctx.clearRect(0, 0, w, h);

  const thickness = 0.35 * Math.min(w, h); // чистый центр ~30%
  const amp = Math.max(10, thickness * 0.14); // неровная внутренняя граница

  const img = ctx.createImageData(w, h);
  const data = img.data;

  const n2 = (x: number, y: number) => {
    const xi = x | 0;
    const yi = y | 0;
    const a = hash01(seed ^ 0x9e3779b9, xi * 17 + yi * 131);
    const b = hash01(seed ^ 0x7f4a7c15, xi * 53 + yi * 97);
    return a * 0.65 + b * 0.35;
  };

  for (let y = 0; y < h; y += 1) {
    for (let x = 0; x < w; x += 1) {
      const i = (y * w + x) * 4;
      const d = Math.min(x, y, w - 1 - x, h - 1 - y);
      const nn = n2(x * 0.55, y * 0.55);
      const dd = d + (nn - 0.5) * 2 * amp;
      const t = 1 - Math.max(0, Math.min(1, dd / thickness)); // 1 у края, 0 внутри
      if (t <= 0) {
        data[i + 3] = 0;
        continue;
      }

      const soft = Math.pow(t, 1.7);
      const depth = 0.65 + 0.35 * n2(x * 0.12 + 13, y * 0.12 + 7);
      const a = Math.round(255 * soft * depth);

      const ice = 0.55 + 0.45 * n2(x * 0.22 + 3, y * 0.22 + 11);
      const r = Math.round(210 + 40 * ice);
      const g = Math.round(232 + 20 * ice);
      const b = Math.round(245 + 10 * ice);

      data[i] = r;
      data[i + 1] = g;
      data[i + 2] = b;
      data[i + 3] = Math.min(255, a);
    }
  }
  ctx.putImageData(img, 0, 0);

  // Игольчатые кристаллы + лёгкий blur (глубина).
  ctx.save();
  ctx.globalCompositeOperation = 'source-over';
  for (let pass = 0; pass < 2; pass += 1) {
    ctx.shadowColor = 'rgba(180,240,255,0.35)';
    ctx.shadowBlur = pass === 0 ? 7 : 2;
    const count = pass === 0 ? 140 : 220;
    for (let k = 0; k < count; k += 1) {
      const u = hash01(seed ^ 0x51c0ffee, k);
      const side = Math.floor(hash01(seed ^ 0x1cedf00d, k) * 4);
      const baseLen = thickness * (0.18 + 0.55 * hash01(seed ^ 0xabcddcba, k));
      const branches = 2 + Math.floor(hash01(seed ^ 0x12121212, k) * 3);
      ctx.lineWidth = pass === 0 ? 1.4 : 0.9;
      ctx.strokeStyle = pass === 0 ? 'rgba(230,252,255,0.18)' : 'rgba(245,255,255,0.22)';
      ctx.lineCap = 'round';

      let sx = 0;
      let sy = 0;
      let dx = 0;
      let dy = 0;
      if (side === 0) {
        sx = u * w;
        sy = 0;
        dx = (hash01(seed ^ 0x33aa55, k) - 0.5) * 0.35;
        dy = 1;
      } else if (side === 2) {
        sx = u * w;
        sy = h;
        dx = (hash01(seed ^ 0x33aa55, k) - 0.5) * 0.35;
        dy = -1;
      } else if (side === 3) {
        sx = 0;
        sy = u * h;
        dx = 1;
        dy = (hash01(seed ^ 0x55aa33, k) - 0.5) * 0.35;
      } else {
        sx = w;
        sy = u * h;
        dx = -1;
        dy = (hash01(seed ^ 0x55aa33, k) - 0.5) * 0.35;
      }

      const ex = sx + dx * baseLen;
      const ey = sy + dy * baseLen;
      ctx.beginPath();
      ctx.moveTo(sx, sy);
      ctx.lineTo(ex, ey);
      ctx.stroke();

      for (let b = 0; b < branches; b += 1) {
        const tt = 0.25 + 0.6 * hash01(seed ^ 0x778899, k * 7 + b);
        const bx = sx + (ex - sx) * tt;
        const by = sy + (ey - sy) * tt;
        const ang = (hash01(seed ^ 0x998877, k * 11 + b) - 0.5) * 1.25;
        const bl = baseLen * (0.15 + 0.35 * hash01(seed ^ 0x445566, k * 13 + b));
        const ndx = dx * Math.cos(ang) - dy * Math.sin(ang);
        const ndy = dx * Math.sin(ang) + dy * Math.cos(ang);
        ctx.beginPath();
        ctx.moveTo(bx, by);
        ctx.lineTo(bx + ndx * bl, by + ndy * bl);
        ctx.stroke();
      }
    }
  }
  ctx.restore();

  const tex = pixi.Texture.from(canvas);
  freezeScreenTextureCache.set(key, tex);
  if (freezeScreenTextureCache.size > 18) {
    const first = freezeScreenTextureCache.keys().next().value as string | undefined;
    if (first) freezeScreenTextureCache.delete(first);
  }
  return tex;
}

function clamp255(v: number): number {
  if (!Number.isFinite(v)) return 0;
  if (v < 0) return 0;
  if (v > 255) return 255;
  return Math.round(v);
}
