import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import {
  isNodeInMainStoryline,
  isNodeInSideStoryline,
  listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage';
import type {
  GraphNodeId,
  MaterialId,
  NpcDisposition,
  Scene,
  SceneId,
  SceneViewCamera,
} from '../../shared/types';
import {
  DEFAULT_NPC_TOKEN_SESSION_SCALE,
  DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
  listVisibleSceneNpcTokens,
  NPC_TOKEN_SESSION_SCALE_MAX,
  NPC_TOKEN_SESSION_SCALE_MIN,
} from '../../shared/types/appPlayers';
import { otherNpcDispositions } from '../../shared/types/npcDisposition';
import { snapNormToGridCell } from '../../shared/types/sceneGridSnap';
import { DEFAULT_SCENE_VIEW_CAMERA, sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import {
  resolveNpcTokenDisposition,
  SceneNpcTokensOverlay,
} from '../shared/playerToken/SceneNpcTokensOverlay';
import { ScenePlayerTokensOverlay } from '../shared/playerToken/ScenePlayerTokensOverlay';
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { useScenePlayerTokensSession } from '../shared/playerToken/useScenePlayerTokensSession';
import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
import { useTokenGridSnapSession } from '../shared/tokens/useTokenGridSnapSession';
import { useTokenPathLivePoses } from '../shared/tokens/useTokenPathLivePoses';
import { useTokenPathSession } from '../shared/tokens/useTokenPathSession';
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { resumeFromPose, computePathPlaybackSample } from '../../shared/types/tokenPathPlayback';
import { tokenPathKey } from '../../shared/types/tokenPathSession';
import { sampleTokenPathAtDistance, tokenPathTotalLength } from '../../shared/types/tokenPath';

import styles from './ControlApp.module.css';
import { ControlAudioCard } from './ControlAudioCard';
import { ControlScenePreview } from './ControlScenePreview';
import { clampEffectsSfxGain, getEffectsSfxGain, setEffectsSfxGain } from './effectsSfxGain';
import { setFireAmbientActive, syncFireAmbientVolume } from './fireAmbientSfx';
import { setRainAmbientActive, syncRainAmbientVolume } from './rainAmbientSfx';
import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx';
import { getExplosionEffectLifeMs, playExplosionEffectSound } from './explosionSfx';
import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx';
import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';

/** Длительность молнии: быстрый удар + акцент в точке попадания. */
const LIGHTNING_EFFECT_MS = 840;

function dispositionMakeKey(d: NpcDisposition): string {
  if (d === 'hostile') return 'npcs.makeHostile';
  if (d === 'friendly') return 'npcs.makeFriendly';
  return 'npcs.makeNeutral';
}

function clampAudioGain(v: number): number {
  if (!Number.isFinite(v)) return 1;
  return Math.max(0, Math.min(1, v));
}

function readAudioGain(gains: Map<string, number>, assetId: string): number {
  return gains.get(assetId) ?? 1;
}

/** Применяет пользовательскую громкость; `factor` — для fade in/out (0…1). */
function applyAudioGain(el: HTMLAudioElement, gains: Map<string, number>, assetId: string, factor = 1): void {
  el.volume = clampAudioGain(readAudioGain(gains, assetId) * factor);
}

/** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */
function lightningEffectSoundUrl(): string {
  return new URL('molniya.mp3', window.location.href).href;
}

function playLightningEffectSound(): void {
  try {
    const el = new Audio(lightningEffectSoundUrl());
    el.volume = Math.max(0, Math.min(1, 0.88 * getEffectsSfxGain()));
    void el.play().catch(() => undefined);
  } catch {
    /* ignore */
  }
}

function SideStoryTile({ scene, title, onClick }: { scene: Scene; title: string; onClick: () => void }) {
  const thumbUrl = useAssetUrl(scene.previewThumbAssetId ?? scene.previewAssetId);
  const previewUrl = useAssetUrl(scene.previewAssetId);
  const imageUrl = thumbUrl ?? (scene.previewAssetType === 'image' ? previewUrl : null);
  return (
    <button type="button" className={styles.sideStoryTile} onClick={onClick}>
      <div className={styles.sideStoryPreview}>
        {imageUrl ? (
          <RotatedImage
            url={imageUrl}
            rotationDeg={scene.previewRotationDeg}
            mode="cover"
            loading="lazy"
            decoding="async"
            style={{ width: '100%', height: '100%' }}
          />
        ) : previewUrl && scene.previewAssetType === 'video' ? (
          <video src={previewUrl} muted playsInline preload="metadata" className={styles.sideStoryVideo} />
        ) : (
          <div className={styles.sideStoryPlaceholder} aria-hidden />
        )}
      </div>
      <div className={styles.sideStoryTitle}>{title}</div>
    </button>
  );
}

export function ControlApp() {
  const api = getDndApi();
  const { t } = useEditorI18n();
  const tRef = useRef(t);
  tRef.current = t;
  const [fxState, fx] = useEffectsState();
  const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
  const [sdState, sd] = useSceneDarknessState();
  const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
  const appTokens = useAppTokens();
  const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
  const [sceneNpcTokensSession, sceneNpcTokensApi] = useSceneNpcTokensSession();
  const [npcMapSpawnDrag, npcMapSpawnDragApi] = useNpcMapSpawnDrag();
  const [scenePlayerTokensSession, scenePlayerTokensApi] = useScenePlayerTokensSession();
  const [tokenPathSession, tokenPathApi] = useTokenPathSession();
  const [tokenGridSnap, tokenGridSnapApi] = useTokenGridSnapSession();
  const { players: appPlayers } = useAppPlayers();
  const [npcSessionCtxMenu, setNpcSessionCtxMenu] = useState<{
    x: number;
    y: number;
    placementId: string;
  } | null>(null);
  const [tokenSessionCtxMenu, setTokenSessionCtxMenu] = useState<{
    x: number;
    y: number;
    placementId: string;
  } | null>(null);
  const [sceneView, sceneViewApi] = useSceneViewState();
  const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
  const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
  const [npcsOverlay, npcsApi] = useNpcsOverlayState();
  const [session, setSession] = useState<SessionState | null>(null);
  const historyRef = useRef<GraphNodeId[]>([]);
  const [history, setHistory] = useState<GraphNodeId[]>([]);
  /** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
  const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
  const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
  // Сюжетная линия — только UI-состояние пульта. Не меняет граф, сцены и связи проекта.
  const sceneAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
  const sceneAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
  /** Пользовательская громкость 0…1 по assetId (сохраняется между сменами сцен для того же трека). */
  const sceneAudioGainRef = useRef<Map<string, number>>(new Map());
  const [sceneAudioStateTick, setSceneAudioStateTick] = useState(0);
  const sceneAudioLoadRunRef = useRef(0);

  const campaignAudioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
  const campaignAudioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
  const campaignAudioGainRef = useRef<Map<string, number>>(new Map());
  const [campaignAudioStateTick, setCampaignAudioStateTick] = useState(0);
  const campaignAudioLoadRunRef = useRef(0);
  /** Snapshot of `!el.paused` per assetId when scene music takes over; used to resume when `allowCampaignAudio` is true again. */
  const campaignResumeAfterSceneRef = useRef<Map<string, boolean> | null>(null);
  const allowCampaignAudioRef = useRef<boolean>(true);
  const audioUnmountRef = useRef(false);
  const previewHostRef = useRef<HTMLDivElement | null>(null);
  const previewFrameRef = useRef<HTMLDivElement | null>(null);
  const previewVideoRef = useRef<HTMLVideoElement | null>(null);
  const sceneViewRef = useRef<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
  const scenePanRef = useRef<{ lastX: number; lastY: number; pointerId: number } | null>(null);
  const spaceDownRef = useRef(false);
  const sceneViewPublishRafRef = useRef(0);
  const pendingSceneViewRef = useRef<SceneViewCamera | null>(null);
  const brushRef = useRef<{
    tool:
      | 'fog'
      | 'fire'
      | 'rain'
      | 'water'
      | 'darkness'
      | 'lightning'
      | 'sunbeam'
      | 'poisonCloud'
      | 'explosion'
      | 'freeze'
      | 'exploreBrush'
      | 'closeBrush'
      | 'eraser';
    startN?: { x: number; y: number };
    points?: { x: number; y: number; tMs: number }[];
  } | null>(null);
  const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
  const [previewContentRect, setPreviewContentRect] = useState<{
    x: number;
    y: number;
    w: number;
    h: number;
  } | null>(null);
  const [presentationContentSize, setPresentationContentSize] = useState<{
    width: number;
    height: number;
  } | null>(null);
  const previewContentRectRef = useRef(previewContentRect);
  previewContentRectRef.current = previewContentRect;
  const previewSizeRef = useRef(previewSize);
  previewSizeRef.current = previewSize;

  const presentationScreenRect = useMemo(() => {
    if (!presentationContentSize) return null;
    if (previewSize.w <= 1 || previewSize.h <= 1) return null;
    return fitAspectRect(
      previewSize.w,
      previewSize.h,
      presentationContentSize.width,
      presentationContentSize.height,
    );
  }, [presentationContentSize, previewSize.h, previewSize.w]);
  const brushCursorElRef = useRef<HTMLDivElement | null>(null);
  const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
  const draftPaintRafRef = useRef(0);
  const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
  const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null);
  const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);

  useEffect(() => {
    void api.invoke(ipcChannels.project.get, {}).then((res) => {
      const next: SessionState = {
        project: res.project,
        currentSceneId: res.project?.currentSceneId ?? null,
      };
      setSession(next);
      historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : [];
      setHistory(historyRef.current);
    });
    return api.on(ipcChannels.session.stateChanged, ({ state }) => {
      setSession(state);
      const cur = state.project?.currentGraphNodeId ?? null;
      if (!cur) {
        mainStoryReturnRef.current = null;
        setMainStoryReturnGraphNodeId(null);
        return;
      }
      const arr = historyRef.current;
      if (arr[arr.length - 1] !== cur) {
        historyRef.current = [...arr, cur];
        setHistory(historyRef.current);
      }
    });
  }, [api]);

  useEffect(() => {
    const refreshPresentationSize = () => {
      void api.invoke(ipcChannels.windows.getPresentationContentSize, {}).then((size) => {
        if (size.width == null || size.height == null) {
          setPresentationContentSize(null);
          return;
        }
        setPresentationContentSize({ width: size.width, height: size.height });
      });
    };
    refreshPresentationSize();
    const offSize = api.on(ipcChannels.windows.presentationContentSizeChanged, (size) => {
      setPresentationContentSize({ width: size.width, height: size.height });
    });
    const offMw = api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
      if (!open) {
        mainStoryReturnRef.current = null;
        setMainStoryReturnGraphNodeId(null);
        setPresentationContentSize(null);
        return;
      }
      refreshPresentationSize();
    });
    return () => {
      offSize();
      offMw();
    };
  }, [api]);

  useEffect(() => {
    audioUnmountRef.current = false;
    return () => {
      audioUnmountRef.current = true;
    };
  }, []);

  const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820);
  const freezeDraftLifeMsRef = useRef(freezeDraftLifeMs);
  freezeDraftLifeMsRef.current = freezeDraftLifeMs;
  useEffect(() => {
    void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs);
  }, []);

  const [darknessDraftLifeMs, setDarknessDraftLifeMs] = useState(820);
  const darknessDraftLifeMsRef = useRef(darknessDraftLifeMs);
  darknessDraftLifeMsRef.current = darknessDraftLifeMs;
  useEffect(() => {
    void getFreezeEffectLifeMs().then(setDarknessDraftLifeMs);
  }, []);

  const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600);
  const sunbeamDraftLifeMsRef = useRef(sunbeamDraftLifeMs);
  sunbeamDraftLifeMsRef.current = sunbeamDraftLifeMs;
  useEffect(() => {
    void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs);
  }, []);

  const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600);
  const poisonDraftLifeMsRef = useRef(poisonDraftLifeMs);
  poisonDraftLifeMsRef.current = poisonDraftLifeMs;
  const [explosionDraftLifeMs, setExplosionDraftLifeMs] = useState(4200);
  const explosionDraftLifeMsRef = useRef(explosionDraftLifeMs);
  explosionDraftLifeMsRef.current = explosionDraftLifeMs;
  useEffect(() => {
    void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs);
  }, []);
  useEffect(() => {
    void getExplosionEffectLifeMs().then(setExplosionDraftLifeMs);
  }, []);

  const hasFireOnScene = useMemo(
    () => Boolean(fxState?.instances.some((i) => i.type === 'fire' && i.points.length > 0)),
    [fxState?.instances],
  );
  const hasRainOnScene = useMemo(
    () => Boolean(fxState?.instances.some((i) => i.type === 'rain' && i.points.length > 0)),
    [fxState?.instances],
  );

  useEffect(() => {
    setFireAmbientActive(hasFireOnScene);
    return () => {
      setFireAmbientActive(false);
    };
  }, [hasFireOnScene]);

  useEffect(() => {
    setRainAmbientActive(hasRainOnScene);
    return () => {
      setRainAmbientActive(false);
    };
  }, [hasRainOnScene]);

  const project = session?.project ?? null;
  const currentGraphNodeId = project?.currentGraphNodeId ?? null;
  const currentHistoryIdx = currentGraphNodeId != null ? history.lastIndexOf(currentGraphNodeId) : -1;
  const currentScene =
    project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
  const visibleNpcTokens = useMemo(
    () =>
      listVisibleSceneNpcTokens(
        currentScene?.npcTokens,
        sceneNpcTokensSession.spawned,
        session?.currentSceneId,
      ),
    [currentScene?.npcTokens, sceneNpcTokensSession.spawned, session?.currentSceneId],
  );
  const isDarkenScene = Boolean(currentScene?.darkenScene);

  const snapNormActive = useCallback(
    (nx: number, ny: number) => {
      const grid = currentScene?.grid;
      if (!tokenGridSnap || !grid?.enabled || !previewContentRect) return { nx, ny };
      return snapNormToGridCell(nx, ny, grid, previewContentRect.w, previewContentRect.h);
    },
    [currentScene?.grid, previewContentRect, tokenGridSnap],
  );

  const snapAllTokensToGrid = useCallback(async () => {
    const grid = currentScene?.grid;
    const rect = previewContentRect;
    if (!grid?.enabled || !rect) return;

    for (const placement of currentScene?.tokens ?? []) {
      const key = String(placement.id);
      const override = sceneTokensSession?.byPlacementId[key];
      const raw = { nx: override?.nx ?? placement.nx, ny: override?.ny ?? placement.ny };
      const snapped = snapNormToGridCell(raw.nx, raw.ny, grid, rect.w, rect.h);
      if (snapped.nx === raw.nx && snapped.ny === raw.ny) continue;
      await sceneTokensApi.dispatch({
        kind: 'move',
        placementId: key,
        nx: snapped.nx,
        ny: snapped.ny,
      });
    }

    for (const placement of visibleNpcTokens) {
      const key = String(placement.id);
      const override = sceneNpcTokensSession?.byPlacementId[key];
      const raw = {
        nx: override?.nx ?? placement.nx,
        ny: override?.ny ?? placement.ny,
      };
      const snapped = snapNormToGridCell(raw.nx, raw.ny, grid, rect.w, rect.h);
      if (snapped.nx === raw.nx && snapped.ny === raw.ny) continue;
      await sceneNpcTokensApi.dispatch({
        kind: 'move',
        placementId: key,
        nx: snapped.nx,
        ny: snapped.ny,
      });
    }

    if (scenePlayerTokensSession?.visible) {
      for (const playerId of scenePlayerTokensSession.selectedPlayerIds) {
        const placement = scenePlayerTokensSession.byPlayerId[playerId];
        if (!placement) continue;
        const snapped = snapNormToGridCell(placement.nx, placement.ny, grid, rect.w, rect.h);
        if (snapped.nx === placement.nx && snapped.ny === placement.ny) continue;
        await scenePlayerTokensApi.dispatch({
          kind: 'move',
          playerId,
          nx: snapped.nx,
          ny: snapped.ny,
        });
      }
    }
  }, [
    currentScene?.grid,
    currentScene?.tokens,
    visibleNpcTokens,
    previewContentRect,
    sceneNpcTokensApi,
    sceneNpcTokensSession?.byPlacementId,
    scenePlayerTokensApi,
    scenePlayerTokensSession?.byPlayerId,
    scenePlayerTokensSession?.selectedPlayerIds,
    scenePlayerTokensSession?.visible,
    sceneTokensApi,
    sceneTokensSession?.byPlacementId,
  ]);

  const sceneDescription = currentScene?.description ?? '';
  const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription);
  const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
  // Keep this memo as narrow as possible: project changes on scene switch,
  // but campaign audio list/config often does not.
  const campaignAudioRefs = useMemo(() => project?.campaignAudios ?? [], [project?.campaignAudios]);
  const allowCampaignAudio = !sceneAudioRefs.some((a) => a.autoplay);
  allowCampaignAudioRef.current = allowCampaignAudio;

  const campaignAudioSpecKey = useMemo(
    () =>
      campaignAudioRefs.map((r) => `${r.assetId}:${r.loop ? '1' : '0'}:${r.autoplay ? '1' : '0'}`).join('|'),
    [campaignAudioRefs],
  );

  const sceneAudios = useMemo(() => {
    if (!project) return [];
    return sceneAudioRefs
      .map((r) => {
        const a = project.assets[r.assetId];
        return a?.type === 'audio' ? { ref: r, asset: a } : null;
      })
      .filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
        Boolean(x),
      );
  }, [project, sceneAudioRefs]);

  const campaignAudios = useMemo(() => {
    if (!project) return [];
    return campaignAudioRefs
      .map((r) => {
        const a = project.assets[r.assetId];
        return a?.type === 'audio' ? { ref: r, asset: a } : null;
      })
      .filter((x): x is { ref: (typeof campaignAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
        Boolean(x),
      );
  }, [campaignAudioRefs, project]);

  useEffect(() => {
    sceneAudioLoadRunRef.current += 1;
    const runId = sceneAudioLoadRunRef.current;

    const oldEls = new Map(sceneAudioElsRef.current);
    sceneAudioElsRef.current = new Map();
    sceneAudioMetaRef.current.clear();
    setSceneAudioStateTick((x) => x + 1);

    const FADE_OUT_MS = 450;
    const fadeOutCtl = { raf: 0, cancelled: false };
    const finishFadeOut = (): void => {
      for (const [id, el] of oldEls) {
        try {
          el.pause();
          el.currentTime = 0;
          applyAudioGain(el, sceneAudioGainRef.current, id);
        } catch {
          // ignore
        }
      }
    };
    if (oldEls.size > 0) {
      const startVol = new Map<string, number>();
      for (const [id, el] of oldEls) {
        startVol.set(id, el.volume);
      }
      const t0 = performance.now();
      const tickOut = (now: number): void => {
        if (fadeOutCtl.cancelled || audioUnmountRef.current) {
          finishFadeOut();
          return;
        }
        const u = Math.min(1, (now - t0) / FADE_OUT_MS);
        for (const [id, el] of oldEls) {
          try {
            const v0 = startVol.get(id) ?? 1;
            el.volume = v0 * (1 - u);
          } catch {
            // ignore
          }
        }
        if (u < 1) {
          fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
        } else {
          finishFadeOut();
        }
      };
      fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
    }

    if (!project || !currentScene) {
      return () => {
        fadeOutCtl.cancelled = true;
        window.cancelAnimationFrame(fadeOutCtl.raf);
      };
    }

    const FADE_IN_MS = 550;
    void (async () => {
      const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
      for (const item of sceneAudioRefs) {
        const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
        if (sceneAudioLoadRunRef.current !== runId) return;
        if (!r.url) continue;
        const el = new Audio(r.url);
        el.loop = item.loop;
        el.preload = 'auto';
        if (item.autoplay) el.volume = 0;
        else applyAudioGain(el, sceneAudioGainRef.current, item.assetId);
        sceneAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
        el.addEventListener('play', () => setSceneAudioStateTick((x) => x + 1));
        el.addEventListener('pause', () => setSceneAudioStateTick((x) => x + 1));
        el.addEventListener('ended', () => setSceneAudioStateTick((x) => x + 1));
        el.addEventListener('canplay', () => setSceneAudioStateTick((x) => x + 1));
        el.addEventListener('error', () => setSceneAudioStateTick((x) => x + 1));
        loaded.push({ ref: item, el });
        sceneAudioElsRef.current.set(item.assetId, el);
      }
      setSceneAudioStateTick((x) => x + 1);
      for (const { ref, el } of loaded) {
        if (sceneAudioLoadRunRef.current !== runId) {
          try {
            el.pause();
            el.currentTime = 0;
            applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
          } catch {
            // ignore
          }
          continue;
        }
        if (!ref.autoplay) continue;
        try {
          await el.play();
        } catch {
          const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
          sceneAudioMetaRef.current.set(ref.assetId, {
            ...m,
            lastPlayError: tRef.current('control.audioAutoplayBlocked'),
          });
          setSceneAudioStateTick((x) => x + 1);
          try {
            applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
          } catch {
            // ignore
          }
          continue;
        }
        if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
          try {
            applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
          } catch {
            // ignore
          }
          continue;
        }
        const tIn0 = performance.now();
        const tickIn = (now: number): void => {
          if (sceneAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
            try {
              applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
            } catch {
              // ignore
            }
            return;
          }
          const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
          try {
            applyAudioGain(el, sceneAudioGainRef.current, ref.assetId, u);
          } catch {
            // ignore
          }
          if (u < 1) window.requestAnimationFrame(tickIn);
        };
        window.requestAnimationFrame(tickIn);
      }
    })();

    return () => {
      fadeOutCtl.cancelled = true;
      window.cancelAnimationFrame(fadeOutCtl.raf);
    };
  }, [api, currentScene, project, sceneAudioRefs]);

  // Campaign elements: lifecycle depends only on campaign track list/config, not scene or allowCampaignAudio
  // (scene music uses allowCampaignAudioRef + separate pause/resume effect).
  // Spec is encoded in campaignAudioSpecKey; campaignAudioRefs is intentionally omitted to avoid scene-switch churn.
  useEffect(() => {
    campaignAudioLoadRunRef.current += 1;
    const runId = campaignAudioLoadRunRef.current;

    const oldEls = new Map(campaignAudioElsRef.current);
    campaignAudioElsRef.current = new Map();
    campaignAudioMetaRef.current.clear();
    setCampaignAudioStateTick((x) => x + 1);

    for (const [id, el] of oldEls) {
      try {
        el.pause();
        applyAudioGain(el, campaignAudioGainRef.current, id);
      } catch {
        // ignore
      }
    }

    if (campaignAudioSpecKey === '') {
      return;
    }

    void (async () => {
      const loaded: { ref: (typeof campaignAudioRefs)[number]; el: HTMLAudioElement }[] = [];
      for (const item of campaignAudioRefs) {
        const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
        if (campaignAudioLoadRunRef.current !== runId) return;
        if (!r.url) continue;
        const el = new Audio(r.url);
        el.loop = item.loop;
        el.preload = 'auto';
        if (item.autoplay) el.volume = 0;
        else applyAudioGain(el, campaignAudioGainRef.current, item.assetId);
        campaignAudioMetaRef.current.set(item.assetId, { lastPlayError: null });
        el.addEventListener('play', () => setCampaignAudioStateTick((x) => x + 1));
        el.addEventListener('pause', () => setCampaignAudioStateTick((x) => x + 1));
        el.addEventListener('ended', () => setCampaignAudioStateTick((x) => x + 1));
        el.addEventListener('canplay', () => setCampaignAudioStateTick((x) => x + 1));
        el.addEventListener('error', () => setCampaignAudioStateTick((x) => x + 1));
        loaded.push({ ref: item, el });
        campaignAudioElsRef.current.set(item.assetId, el);
      }
      setCampaignAudioStateTick((x) => x + 1);

      if (!allowCampaignAudioRef.current) return;

      for (const { ref, el } of loaded) {
        if (campaignAudioLoadRunRef.current !== runId) return;
        if (!ref.autoplay) continue;
        try {
          await el.play();
        } catch {
          const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
          campaignAudioMetaRef.current.set(ref.assetId, {
            ...m,
            lastPlayError: tRef.current('control.audioAutoplayBlocked'),
          });
          setCampaignAudioStateTick((x) => x + 1);
          try {
            applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
          } catch {
            // ignore
          }
          continue;
        }
        if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
          try {
            applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
          } catch {
            // ignore
          }
          continue;
        }
        const tIn0 = performance.now();
        const tickIn = (now: number): void => {
          if (campaignAudioLoadRunRef.current !== runId || audioUnmountRef.current) {
            try {
              applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
            } catch {
              // ignore
            }
            return;
          }
          const u = Math.min(1, (now - tIn0) / 550);
          try {
            applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, u);
          } catch {
            // ignore
          }
          if (u < 1) window.requestAnimationFrame(tickIn);
        };
        window.requestAnimationFrame(tickIn);
      }
    })();
    // Deps: api + campaignAudioSpecKey only; list iteration uses current campaignAudioRefs (stable while spec is stable).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [api, campaignAudioSpecKey]);

  useEffect(() => {
    if (allowCampaignAudio) {
      const snap = campaignResumeAfterSceneRef.current;
      campaignResumeAfterSceneRef.current = null;
      if (snap && snap.size > 0) {
        void (async () => {
          for (const [assetId, wasPlaying] of snap) {
            if (!wasPlaying) continue;
            const el = campaignAudioElsRef.current.get(assetId) ?? null;
            if (!el) continue;
            try {
              // If a track was created with autoplay volume ramp but never started yet,
              // ensure it is audible on resume.
              if (el.volume === 0) applyAudioGain(el, campaignAudioGainRef.current, assetId);
              await el.play();
            } catch {
              // ignore; user can press play
            }
          }
          setCampaignAudioStateTick((x) => x + 1);
        })();
      }
      // If we entered a scene that allows campaign audio and there was no "resume snapshot"
      // (e.g. first scene had autoplay scene music so campaign autoplay was blocked),
      // start campaign tracks that have autoplay enabled.
      if (!snap || snap.size === 0) {
        void (async () => {
          for (const ref of campaignAudioRefs) {
            if (!ref.autoplay) continue;
            const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
            if (!el) continue;
            if (!el.paused) continue;
            try {
              el.volume = 0;
            } catch {
              // ignore
            }
            try {
              await el.play();
            } catch {
              // ignore; user can press play
              continue;
            }
            const tIn0 = performance.now();
            const tickIn = (now: number): void => {
              if (!allowCampaignAudioRef.current || audioUnmountRef.current) return;
              const u = Math.min(1, (now - tIn0) / 550);
              try {
                applyAudioGain(el, campaignAudioGainRef.current, ref.assetId, u);
              } catch {
                // ignore
              }
              if (u < 1) window.requestAnimationFrame(tickIn);
            };
            window.requestAnimationFrame(tickIn);
          }
          setCampaignAudioStateTick((x) => x + 1);
        })();
      }
      return;
    }
    // Scene has its own audio: remember what was playing, then pause campaign. (keep currentTime)
    const snap = new Map<string, boolean>();
    for (const [assetId, el] of campaignAudioElsRef.current) {
      snap.set(assetId, !el.paused);
    }
    campaignResumeAfterSceneRef.current = snap;
    for (const el of campaignAudioElsRef.current.values()) {
      try {
        el.pause();
      } catch {
        // ignore
      }
    }
    setCampaignAudioStateTick((x) => x + 1);
    // Intentionally not depending on campaignAudioRefs: this effect is about scene-driven pausing/resuming.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allowCampaignAudio]);

  useEffect(() => {
    const host = previewHostRef.current;
    if (!host) return;
    const update = () => {
      const r = host.getBoundingClientRect();
      setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) });
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(host);
    return () => ro.disconnect();
  }, []);

  const activeSceneViewCamera: SceneViewCamera =
    sceneViewDraft ??
    (sceneView ? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy } : DEFAULT_SCENE_VIEW_CAMERA);
  sceneViewRef.current = activeSceneViewCamera;

  useEffect(() => {
    if (scenePanRef.current) return;
    setSceneViewDraft(null);
  }, [sceneView?.revision]);

  function publishSceneViewCamera(camera: SceneViewCamera): void {
    setSceneViewDraft(camera);
    sceneViewRef.current = camera;
    pendingSceneViewRef.current = camera;
    if (sceneViewPublishRafRef.current !== 0) return;
    sceneViewPublishRafRef.current = requestAnimationFrame(() => {
      sceneViewPublishRafRef.current = 0;
      const next = pendingSceneViewRef.current;
      if (!next) return;
      void sceneViewApi.dispatch({ kind: 'set', camera: next });
    });
  }
  const publishSceneViewCameraRef = useRef(publishSceneViewCamera);
  publishSceneViewCameraRef.current = publishSceneViewCamera;

  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.code === 'Space') spaceDownRef.current = true;
    };
    const onKeyUp = (e: KeyboardEvent) => {
      if (e.code === 'Space') spaceDownRef.current = false;
    };
    window.addEventListener('keydown', onKeyDown);
    window.addEventListener('keyup', onKeyUp);
    return () => {
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
    };
  }, []);

  useEffect(() => {
    const frame = previewFrameRef.current;
    if (!frame) return;
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      const host = previewHostRef.current;
      const cr = previewContentRectRef.current;
      if (!host || !cr) return;
      const r = host.getBoundingClientRect();
      const cam = sceneViewRef.current;
      const containW = cr.w / Math.max(1e-6, cam.scale);
      const containH = cr.h / Math.max(1e-6, cam.scale);
      const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
      const next = sceneViewZoomAt(cam, {
        hostW: r.width,
        hostH: r.height,
        containW,
        containH,
        hostX: e.clientX - r.left,
        hostY: e.clientY - r.top,
        factor,
      });
      publishSceneViewCameraRef.current(next);
    };
    frame.addEventListener('wheel', onWheel, { passive: false });
    return () => frame.removeEventListener('wheel', onWheel);
  }, []);

  useEffect(() => {
    return () => {
      if (sceneViewPublishRafRef.current !== 0) {
        cancelAnimationFrame(sceneViewPublishRafRef.current);
        sceneViewPublishRafRef.current = 0;
      }
    };
  }, []);

  function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
    const el =
      group === 'scene'
        ? (sceneAudioElsRef.current.get(assetId) ?? null)
        : (campaignAudioElsRef.current.get(assetId) ?? null);
    if (!el) return { label: t('control.audioNoUrl'), detail: t('control.audioNoUrlDetail') };
    const meta =
      group === 'scene'
        ? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null })
        : (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null });
    if (meta.lastPlayError) return { label: t('control.audioBlocked'), detail: meta.lastPlayError };
    if (el.error)
      return {
        label: t('control.audioError'),
        detail: t('control.audioMediaError', { code: String(el.error.code) }),
      };
    if (el.readyState < 2) return { label: t('control.audioLoading') };
    if (!el.paused) return { label: t('control.audioPlaying') };
    if (el.currentTime > 0) return { label: t('control.audioPaused') };
    return { label: t('control.audioStopped') };
  }
  const nextScenes = useMemo(() => {
    if (!project) return [];
    if (!currentGraphNodeId) return [];
    const outgoing = project.sceneGraphEdges
      .filter((e) => e.sourceGraphNodeId === currentGraphNodeId)
      .map((e) => {
        const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId);
        return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null;
      })
      .filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x));
    return outgoing
      .map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] }))
      .filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
  }, [currentGraphNodeId, project]);

  const isInSideStoryline = useMemo(() => {
    if (!project || !currentGraphNodeId) return false;
    return isNodeInSideStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId);
  }, [currentGraphNodeId, project]);

  const sideStoryLines = useMemo(() => {
    if (!project) return [];
    return listSideStoryStarts(project.sceneGraphNodes).map((gn) => {
      const scene = project.scenes[gn.sceneId];
      return {
        graphNodeId: gn.id,
        title: gn.sideStoryLineTitle.trim() || scene?.title || t('control.unnamed'),
        scene,
      };
    });
  }, [project, t]);

  const returnSceneTitle = useMemo(() => {
    if (!project || !mainStoryReturnGraphNodeId) return '';
    const gn = project.sceneGraphNodes.find((n) => n.id === mainStoryReturnGraphNodeId);
    if (!gn) return '';
    return project.scenes[gn.sceneId]?.title || t('control.unnamed');
  }, [mainStoryReturnGraphNodeId, project, t]);

  const enterSideStoryline = (startGraphNodeId: GraphNodeId) => {
    if (!project) return;
    if (
      currentGraphNodeId &&
      mainStoryReturnRef.current === null &&
      isNodeInMainStoryline(project.sceneGraphNodes, project.sceneGraphEdges, currentGraphNodeId)
    ) {
      mainStoryReturnRef.current = currentGraphNodeId;
      setMainStoryReturnGraphNodeId(currentGraphNodeId);
    }
    void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: startGraphNodeId });
  };

  const returnToMainStoryline = () => {
    const ret = mainStoryReturnRef.current;
    if (!ret) return;
    mainStoryReturnRef.current = null;
    setMainStoryReturnGraphNodeId(null);
    void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: ret });
  };

  const showReturnToMain = isInSideStoryline && mainStoryReturnGraphNodeId !== null;
  const branchOptionOffset = showReturnToMain ? 1 : 0;

  const tool = fxState?.tool ?? { tool: 'none' as const, radiusN: 0.08, intensity: 0.6 };
  const toolRef = useRef(tool);
  /** Действия с токенами/ловушками только без активной кисти эффектов. */
  const markersInteractive = tool.tool === 'none';

  const pathLivePoses = useTokenPathLivePoses({
    tokens: currentScene?.tokens ?? [],
    npcTokens: visibleNpcTokens,
    pathSession: tokenPathSession,
    enabled: Boolean(currentScene),
    onMarkDone: (kind, placementId) => {
      void tokenPathApi.dispatch({ kind: 'markDone', target: { kind, placementId } });
      const placement =
        kind === 'token'
          ? (currentScene?.tokens ?? []).find((t) => String(t.id) === placementId)
          : visibleNpcTokens.find((t) => String(t.id) === placementId);
      if (!placement?.path) return;
      const end = sampleTokenPathAtDistance(placement.path, tokenPathTotalLength(placement.path));
      if (!end) return;
      if (kind === 'token') {
        void sceneTokensApi.dispatch({
          kind: 'move',
          placementId,
          nx: end.nx,
          ny: end.ny,
        });
      } else {
        sceneNpcTokensApi.dispatch({
          kind: 'move',
          placementId,
          nx: end.nx,
          ny: end.ny,
        });
      }
    },
  });

  const tokenPathDragEnabled = useMemo(() => {
    const out: Record<string, boolean> = {};
    for (const t of currentScene?.tokens ?? []) {
      const key = String(t.id);
      const entry = tokenPathSession?.playback[tokenPathKey('token', key)];
      out[key] = !entry || entry.phase === 'stopped';
    }
    return out;
  }, [currentScene?.tokens, tokenPathSession?.playback]);

  const npcPathDragEnabled = useMemo(() => {
    const out: Record<string, boolean> = {};
    for (const t of visibleNpcTokens) {
      const key = String(t.id);
      const entry = tokenPathSession?.playback[tokenPathKey('npcToken', key)];
      out[key] = !entry || entry.phase === 'stopped';
    }
    return out;
  }, [visibleNpcTokens, tokenPathSession?.playback]);

  useEffect(() => {
    if (!npcMapSpawnDrag.dragging) return;
    const toNorm = (clientX: number, clientY: number) => {
      const host = previewHostRef.current;
      const cr = previewContentRectRef.current;
      if (!host || !cr) return null;
      const r = host.getBoundingClientRect();
      const nx = (clientX - (r.left + cr.x)) / Math.max(1e-6, cr.w);
      const ny = (clientY - (r.top + cr.y)) / Math.max(1e-6, cr.h);
      if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return null;
      return snapNormActive(nx, ny);
    };
    const onMove = (e: MouseEvent) => {
      const p = toNorm(e.clientX, e.clientY);
      void npcMapSpawnDragApi.setHover(p ? p.nx : null, p ? p.ny : null);
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') void npcMapSpawnDragApi.cancel();
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('keydown', onKey);
    };
  }, [npcMapSpawnDrag.dragging, npcMapSpawnDragApi, snapNormActive]);

  useEffect(() => {
    if (!markersInteractive) {
      setNpcSessionCtxMenu(null);
      setTokenSessionCtxMenu(null);
    }
  }, [markersInteractive]);
  toolRef.current = tool;

  /** Повторный клик по активному инструменту снимает выбор. */
  function selectEffectTool(next: Exclude<EffectToolType, 'none'>): void {
    const toolName: EffectToolType = tool.tool === next ? 'none' : next;
    void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } });
  }

  function layoutBrushCursor(): void {
    const el = brushCursorElRef.current;
    const p = cursorPosRef.current;
    const cr = previewContentRectRef.current;
    const ps = previewSizeRef.current;
    const t = toolRef.current;
    if (!el) return;
    if (!p || t.tool === 'none') {
      el.style.visibility = 'hidden';
      return;
    }
    el.style.visibility = 'visible';
    const ox = cr ? cr.x : 0;
    const oy = cr ? cr.y : 0;
    const cw = cr ? cr.w : ps.w;
    const ch = cr ? cr.h : ps.h;
    const minDim = Math.min(cw, ch);
    const size = Math.max(2, t.radiusN * minDim * 2);
    el.style.left = `${String(ox + p.x * cw)}px`;
    el.style.top = `${String(oy + p.y * ch)}px`;
    el.style.width = `${String(size)}px`;
    el.style.height = `${String(size)}px`;
  }

  function buildDraftEffectInstance(): EffectInstance | null {
    const b = brushRef.current;
    if (!b) return null;
    const meta = draftMetaRef.current ?? { createdAtMs: Date.now(), seed: 12345 };
    const { createdAtMs, seed } = meta;
    const t = toolRef.current;
    if (b.tool === 'fog' && b.points && b.points.length > 0) {
      return {
        id: '__draft__',
        type: 'fog',
        seed,
        createdAtMs,
        points: b.points,
        radiusN: t.radiusN,
        opacity: Math.max(0.05, Math.min(0.6, t.intensity * 0.7)),
        lifetimeMs: null,
      };
    }
    if (b.tool === 'fire' && b.points && b.points.length > 0) {
      return {
        id: '__draft__',
        type: 'fire',
        seed,
        createdAtMs,
        points: b.points,
        radiusN: t.radiusN,
        opacity: 1,
        lifetimeMs: null,
      };
    }
    if (b.tool === 'rain' && b.points && b.points.length > 0) {
      return {
        id: '__draft__',
        type: 'rain',
        seed,
        createdAtMs,
        points: b.points,
        radiusN: t.radiusN,
        opacity: Math.max(0.08, Math.min(0.65, t.intensity * 0.85)),
        lifetimeMs: null,
      };
    }
    if (b.tool === 'water' && b.points && b.points.length > 0) {
      return {
        id: '__draft__',
        type: 'water',
        seed,
        createdAtMs,
        points: b.points,
        radiusN: t.radiusN,
        opacity: Math.max(0.06, Math.min(0.55, t.intensity * 0.72)),
        lifetimeMs: null,
      };
    }
    if (b.tool === 'darkness' && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return null;
      return {
        id: '__draft__',
        type: 'darkness',
        seed,
        createdAtMs,
        at: { x: last.x, y: last.y },
        intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)),
        lifetimeMs: darknessDraftLifeMsRef.current,
      };
    }
    if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return null;
      return {
        id: '__draft__',
        type: 'lightning',
        seed,
        createdAtMs,
        start: { x: last.x, y: 0 },
        end: { x: last.x, y: last.y },
        widthN: Math.max(0.014, t.radiusN * 1.15),
        intensity: Math.max(1, Math.min(1.4, t.intensity * 1.45)),
        lifetimeMs: LIGHTNING_EFFECT_MS,
      };
    }
    if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return null;
      return {
        id: '__draft__',
        type: 'sunbeam',
        seed,
        createdAtMs,
        start: { x: last.x, y: 0 },
        end: { x: last.x, y: last.y },
        widthN: Math.max(0.012, t.radiusN * 0.95),
        intensity: Math.max(0.95, Math.min(1.25, t.intensity * 1.4)),
        lifetimeMs: sunbeamDraftLifeMsRef.current,
      };
    }
    if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return null;
      return {
        id: '__draft__',
        type: 'poisonCloud',
        seed,
        createdAtMs,
        at: { x: last.x, y: last.y },
        radiusN: Math.max(0.03, t.radiusN * 0.95),
        intensity: Math.max(0.75, Math.min(1.2, t.intensity * 1.15)),
        lifetimeMs: poisonDraftLifeMsRef.current,
      };
    }
    if (b.tool === 'explosion' && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return null;
      return {
        id: '__draft__',
        type: 'explosion',
        seed,
        createdAtMs,
        at: { x: last.x, y: last.y },
        radiusN: Math.max(0.03, t.radiusN * 0.95),
        intensity: Math.max(0.75, Math.min(1.2, t.intensity * 1.15)),
        lifetimeMs: explosionDraftLifeMsRef.current,
      };
    }
    if (b.tool === 'freeze' && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return null;
      return {
        id: '__draft__',
        type: 'freeze',
        seed,
        createdAtMs,
        at: { x: last.x, y: last.y },
        intensity: Math.max(0.8, Math.min(1.25, t.intensity * 1.15)),
        lifetimeMs: freezeDraftLifeMsRef.current,
      };
    }
    return null;
  }

  function pushDraftToPixi(): void {
    const draft = buildDraftEffectInstance();
    effectsOverlayRef.current?.setDraft(draft);
    setExplosionDraft(draft?.type === 'explosion' ? draft : null);
  }

  function clearDraftFromPixi(): void {
    draftMetaRef.current = null;
    effectsOverlayRef.current?.setDraft(null);
    setExplosionDraft(null);
  }

  function scheduleDraftRepaint(): void {
    if (draftPaintRafRef.current !== 0) return;
    draftPaintRafRef.current = requestAnimationFrame(() => {
      draftPaintRafRef.current = 0;
      pushDraftToPixi();
      const b = brushRef.current;
      if (
        (b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') &&
        b.points
      ) {
        void sd.dispatch({
          kind: 'draft.set',
          draft: {
            points: b.points,
            radiusN: toolRef.current.radiusN,
            mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
          },
        });
      }
    });
  }

  useLayoutEffect(() => {
    layoutBrushCursor();
  }, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);

  useEffect(() => {
    return () => {
      if (draftPaintRafRef.current !== 0) {
        cancelAnimationFrame(draftPaintRafRef.current);
      }
    };
  }, []);

  function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
    const host = previewHostRef.current;
    if (!host) return null;
    const r = host.getBoundingClientRect();
    const cr = previewContentRectRef.current;
    const ox = cr ? cr.x : 0;
    const oy = cr ? cr.y : 0;
    const cw = cr ? cr.w : r.width;
    const ch = cr ? cr.h : r.height;
    const x = (e.clientX - (r.left + ox)) / Math.max(1, cw);
    const y = (e.clientY - (r.top + oy)) / Math.max(1, ch);
    return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
  }

  function dispatchFieldEraserPoints(
    points: { x: number; y: number; tMs: number }[],
    opts?: {
      types?: readonly ('fog' | 'fire' | 'rain' | 'water')[];
      hitMode?: 'inclusive' | 'brush';
    },
  ): void {
    if (points.length === 0) return;
    void fx.dispatch({
      kind: 'field.erase',
      points,
      radiusN: toolRef.current.radiusN,
      ...(opts?.types ? { types: opts.types } : {}),
      ...(opts?.hitMode ? { hitMode: opts.hitMode } : {}),
    });
  }

  /** Вода/дождь гасят огонь только под самой кистью (не «аура» радиуса огня). */
  function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void {
    dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' });
  }

  function tryEraseActionEffect(p: { x: number; y: number }): void {
    const id = pickEraseTargetId(fxState?.instances ?? [], p, toolRef.current.radiusN);
    if (id) void fx.dispatch({ kind: 'instance.remove', id });
  }

  async function commitStroke(): Promise<void> {
    if (!fxState) return;
    const b = brushRef.current;
    if (!b) return;
    if (b.tool === 'eraser') {
      brushRef.current = null;
      return;
    }
    const createdAtMs = Date.now();
    const seed = Math.floor(Math.random() * 1_000_000_000);

    if (b.tool === 'fog' && b.points && b.points.length > 0) {
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `fog_${String(createdAtMs)}_${String(seed)}`,
          type: 'fog',
          seed,
          createdAtMs,
          points: b.points,
          radiusN: tool.radiusN,
          opacity: Math.max(0.05, Math.min(0.9, tool.intensity)),
          lifetimeMs: null,
        },
      });
    }
    if (b.tool === 'fire' && b.points && b.points.length > 0) {
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `fire_${String(createdAtMs)}_${String(seed)}`,
          type: 'fire',
          seed,
          createdAtMs,
          points: b.points,
          radiusN: tool.radiusN,
          opacity: 1,
          lifetimeMs: null,
        },
      });
    }
    if (b.tool === 'rain' && b.points && b.points.length > 0) {
      await fx.dispatch({
        kind: 'field.erase',
        points: b.points,
        radiusN: tool.radiusN,
        types: ['fire'],
        hitMode: 'brush',
      });
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `rain_${String(createdAtMs)}_${String(seed)}`,
          type: 'rain',
          seed,
          createdAtMs,
          points: b.points,
          radiusN: tool.radiusN,
          opacity: Math.max(0.08, Math.min(0.9, tool.intensity)),
          lifetimeMs: null,
        },
      });
    }
    if (b.tool === 'water' && b.points && b.points.length > 0) {
      await fx.dispatch({
        kind: 'field.erase',
        points: b.points,
        radiusN: tool.radiusN,
        types: ['fire'],
        hitMode: 'brush',
      });
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `water_${String(createdAtMs)}_${String(seed)}`,
          type: 'water',
          seed,
          createdAtMs,
          points: b.points,
          radiusN: tool.radiusN,
          opacity: Math.max(0.06, Math.min(0.72, tool.intensity * 0.85)),
          lifetimeMs: null,
        },
      });
    }
    if (
      (b.tool === 'exploreBrush' || b.tool === 'closeBrush') &&
      b.points &&
      b.points.length > 0
    ) {
      await sd.dispatch({
        kind: 'stroke.add',
        stroke: {
          id: `sd_${String(createdAtMs)}_${String(seed)}`,
          seed,
          createdAtMs,
          points: b.points,
          radiusN: tool.radiusN,
          mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
        },
      });
    }
    if (b.tool === 'darkness' && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return;
      const at = { x: last.x, y: last.y };
      const darknessLifeMs = await getFreezeEffectLifeMs();
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `dk_${String(createdAtMs)}_${String(seed)}`,
          type: 'darkness',
          seed,
          createdAtMs,
          at,
          intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
          lifetimeMs: darknessLifeMs,
        },
      });
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `sh_${String(createdAtMs)}_${String(seed)}`,
          type: 'shadow',
          seed: seed ^ 0x0d4a0001,
          createdAtMs,
          at,
          radiusN: Math.max(0.03, tool.radiusN * 0.9),
          opacity: 1,
          lifetimeMs: null,
        },
      });
    }
    if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return;
      const end = { x: last.x, y: last.y };
      const start = { x: end.x, y: 0 };
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `lt_${String(createdAtMs)}_${String(seed)}`,
          type: 'lightning',
          seed,
          createdAtMs,
          start,
          end,
          widthN: Math.max(0.014, tool.radiusN * 1.15),
          intensity: Math.max(1, Math.min(1.4, tool.intensity * 1.45)),
          lifetimeMs: LIGHTNING_EFFECT_MS,
        },
      });
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `sc_${String(createdAtMs)}_${String(seed)}`,
          type: 'scorch',
          seed: seed ^ 0x7a7a7a,
          createdAtMs,
          at: end,
          radiusN: Math.max(0.04, tool.radiusN * 0.82),
          opacity: 0.96,
          lifetimeMs: 90_000,
        },
      });
      playLightningEffectSound();
    }
    if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return;
      const end = { x: last.x, y: last.y };
      const start = { x: end.x, y: 0 };
      const sunbeamLifeMs = await getSunbeamEffectLifeMs();
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `sb_${String(createdAtMs)}_${String(seed)}`,
          type: 'sunbeam',
          seed,
          createdAtMs,
          start,
          end,
          widthN: Math.max(0.012, tool.radiusN * 0.95),
          intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)),
          lifetimeMs: sunbeamLifeMs,
        },
      });
      playSunbeamEffectSound();
    }
    if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return;
      const at = { x: last.x, y: last.y };
      const poisonLifeMs = await getPoisonCloudEffectLifeMs();
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `pc_${String(createdAtMs)}_${String(seed)}`,
          type: 'poisonCloud',
          seed,
          createdAtMs,
          at,
          radiusN: Math.max(0.03, tool.radiusN * 0.95),
          intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
          lifetimeMs: poisonLifeMs,
        },
      });
      void playPoisonCloudEffectSound(poisonLifeMs);
    }
    if (b.tool === 'explosion' && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return;
      const at = { x: last.x, y: last.y };
      const explosionLifeMs = await getExplosionEffectLifeMs();
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `ex_${String(createdAtMs)}_${String(seed)}`,
          type: 'explosion',
          seed,
          createdAtMs,
          at,
          radiusN: Math.max(0.03, tool.radiusN * 0.95),
          intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)),
          lifetimeMs: explosionLifeMs,
        },
      });
      void playExplosionEffectSound(explosionLifeMs);
    }
    if (b.tool === 'freeze' && b.points && b.points.length > 0) {
      const last = b.points[b.points.length - 1];
      if (last === undefined) return;
      const at = { x: last.x, y: last.y };
      const freezeLifeMs = await getFreezeEffectLifeMs();
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `fr_${String(createdAtMs)}_${String(seed)}`,
          type: 'freeze',
          seed,
          createdAtMs,
          at,
          intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
          // Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life).
          lifetimeMs: freezeLifeMs,
        },
      });
      await fx.dispatch({
        kind: 'instance.add',
        instance: {
          id: `ice_${String(createdAtMs)}_${String(seed)}`,
          type: 'ice',
          seed: seed ^ 0x33cc99,
          createdAtMs,
          at,
          radiusN: Math.max(0.03, tool.radiusN * 0.9),
          opacity: 0.85,
          lifetimeMs: null,
        },
      });
      playFreezeEffectSound();
    }
    brushRef.current = null;
    clearDraftFromPixi();
  }

  return (
    <div className={styles.page}>
      <Surface className={styles.remote}>
        <div className={styles.remoteTitle}>{t('control.remoteTitle')}</div>
        <div className={styles.spacer12} />
        <div className={styles.sectionLabel}>{t('control.instruments')}</div>
        <div className={styles.spacer8} />
        <div className={styles.iconRow}>
          <Button
            variant="ghost"
            iconOnly
            disabled={!hasSceneDescription}
            title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
            ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
            onClick={() => {
              void api
                .invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription })
                .catch((err) => {
                  console.error('[control] openSceneDescription failed', err);
                });
            }}
          >
            <span className={styles.iconGlyph} aria-hidden>
              📖
            </span>
          </Button>
          <Button
            variant="ghost"
            iconOnly
            title={t('control.materialsTool')}
            ariaLabel={t('control.materialsTool')}
            onClick={() => {
              void api.invoke(ipcChannels.windows.openMaterials, {}).catch((err) => {
                console.error('[control] openMaterials failed', err);
              });
            }}
          >
            <span className={styles.iconGlyph} aria-hidden>
              <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
                <path
                  fill="#c9a227"
                  d="M4.2 5.4c1.6-.9 3.5-.7 5 .5l1.3 1.1c.4.3 1 .3 1.4 0L13.2 5.9c1.5-1.2 3.4-1.4 5-.5l1.4.8c.8.5 1.3 1.4 1.3 2.3v8.6c0 1.4-1.4 2.3-2.7 1.8l-2.3-.9c-.7-.3-1.5-.2-2.1.2l-1.3.9c-.6.4-1.4.4-2 0l-1.3-.9c-.6-.4-1.4-.5-2.1-.2l-2.3.9c-1.3.5-2.7-.4-2.7-1.8V8.5c0-.9.5-1.8 1.3-2.3l1.5-.8z"
                />
                <path
                  fill="#7a4e1d"
                  d="M8.2 10.2c1.3-.4 2.5.2 3.3 1.1.3.3.8.3 1.1 0 .8-.9 2-1.5 3.3-1.1.5.2.8.7.6 1.2-.5 1.4-1.7 2.5-3.1 3.1-.5.2-1 .2-1.4 0-1.4-.6-2.6-1.7-3.1-3.1-.2-.5.1-1 .6-1.2z"
                />
                <circle cx="12" cy="12.2" r="1.15" fill="#e8c547" />
                <path
                  fill="none"
                  stroke="#5c3a16"
                  strokeWidth="1.1"
                  strokeLinecap="round"
                  d="M7.5 15.8c1.2.7 2.7 1.1 4.5 1.1s3.3-.4 4.5-1.1"
                />
              </svg>
            </span>
          </Button>
          <Button
            variant="ghost"
            iconOnly
            title={t('control.npcsTool')}
            ariaLabel={t('control.npcsTool')}
            onClick={() => {
              void api.invoke(ipcChannels.windows.openNpcs, {}).catch((err) => {
                console.error('[control] openNpcs failed', err);
              });
            }}
          >
            <span className={styles.iconGlyph} aria-hidden>
              <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
                <circle cx="12" cy="8" r="3.4" fill="#3b82f6" />
                <path
                  fill="#22c55e"
                  d="M5.2 19.2c.6-3.4 3.2-5.2 6.8-5.2s6.2 1.8 6.8 5.2c.1.5-.3 1-.8 1H6c-.5 0-.9-.5-.8-1z"
                />
                <circle cx="12" cy="8" r="1.4" fill="#93c5fd" />
              </svg>
            </span>
          </Button>
        </div>
        <div className={styles.spacer12} />
        <>
            <div className={styles.sectionLabel}>{t('control.effects')}</div>
            <div className={styles.spacer8} />
            <div className={styles.effectsStack}>
              <div className={styles.effectsGroup}>
                <div className={styles.subsectionLabel}>{t('control.tools')}</div>
                <div className={styles.iconRow}>
                  <Button
                    variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.eraser')}
                    ariaLabel={t('control.eraser')}
                    onClick={() => selectEffectTool('eraser')}
                  >
                    <span className={styles.iconGlyph}>🧹</span>
                  </Button>
                  <Button
                    variant="ghost"
                    iconOnly
                    title={t('control.clearEffects')}
                    ariaLabel={t('control.clearEffects')}
                    onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
                  >
                    <span className={styles.clearIcon}>
                      <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
                        <circle cx="12" cy="12" r="8" fill="none" stroke="#e5484d" strokeWidth="2" />
                        <line
                          x1="7"
                          y1="17"
                          x2="17"
                          y2="7"
                          stroke="#e5484d"
                          strokeWidth="2"
                          strokeLinecap="round"
                        />
                      </svg>
                    </span>
                  </Button>
                </div>
              </div>
              <div className={styles.effectsGroup}>
                <div className={styles.subsectionLabel}>{t('control.fieldEffects')}</div>
                <div className={styles.iconRow}>
                  <Button
                    variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.fog')}
                    ariaLabel={t('control.fog')}
                    onClick={() => selectEffectTool('fog')}
                  >
                    <span className={styles.iconGlyph}>🌫️</span>
                  </Button>
                  <Button
                    variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.rain')}
                    ariaLabel={t('control.rain')}
                    onClick={() => selectEffectTool('rain')}
                  >
                    <span className={styles.iconGlyph}>🌧️</span>
                  </Button>
                  <Button
                    variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.fire')}
                    ariaLabel={t('control.fire')}
                    onClick={() => selectEffectTool('fire')}
                  >
                    <span className={styles.iconGlyph}>🔥</span>
                  </Button>
                  <Button
                    variant={tool.tool === 'water' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.water')}
                    ariaLabel={t('control.water')}
                    onClick={() => selectEffectTool('water')}
                  >
                    <span className={styles.iconGlyph}>💧</span>
                  </Button>
                </div>
              </div>
              <div className={styles.effectsGroup}>
                <div className={styles.subsectionLabel}>{t('control.actionEffects')}</div>
                <div className={styles.iconRow}>
                  <Button
                    variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.lightning')}
                    ariaLabel={t('control.lightning')}
                    onClick={() => selectEffectTool('lightning')}
                  >
                    <span className={styles.iconGlyph}>⚡</span>
                  </Button>
                  <Button
                    variant={tool.tool === 'sunbeam' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.sunbeam')}
                    ariaLabel={t('control.sunbeam')}
                    onClick={() => selectEffectTool('sunbeam')}
                  >
                    <span className={styles.iconGlyph}>☀️</span>
                  </Button>
                  <Button
                    variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.freeze')}
                    ariaLabel={t('control.freeze')}
                    onClick={() => selectEffectTool('freeze')}
                  >
                    <span className={styles.iconGlyph}>❄️</span>
                  </Button>
                  <Button
                    variant={tool.tool === 'darkness' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.darkness')}
                    ariaLabel={t('control.darkness')}
                    onClick={() => selectEffectTool('darkness')}
                  >
                    <span className={styles.iconGlyph}>🌑</span>
                  </Button>
                  <Button
                    variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.poisonCloud')}
                    ariaLabel={t('control.poisonCloud')}
                    onClick={() => selectEffectTool('poisonCloud')}
                  >
                    <span className={styles.iconGlyph}>☣️</span>
                  </Button>
                  <Button
                    variant={tool.tool === 'explosion' ? 'primary' : 'ghost'}
                    iconOnly
                    title={t('control.explosion')}
                    ariaLabel={t('control.explosion')}
                    onClick={() => selectEffectTool('explosion')}
                  >
                    <span className={styles.iconGlyph}>💥</span>
                  </Button>
                </div>
              </div>
              {isDarkenScene ? (
                <div className={styles.effectsGroup}>
                  <div className={styles.subsectionLabel}>{t('control.darknessControl')}</div>
                  <div className={styles.iconRow}>
                    <Button
                      variant={tool.tool === 'exploreBrush' ? 'primary' : 'ghost'}
                      iconOnly
                      title={t('control.explorerBrush')}
                      ariaLabel={t('control.explorerBrush')}
                      onClick={() => selectEffectTool('exploreBrush')}
                    >
                      <span className={styles.iconGlyph}>🔦</span>
                    </Button>
                    <Button
                      variant={tool.tool === 'closeBrush' ? 'primary' : 'ghost'}
                      iconOnly
                      title={t('control.closerBrush')}
                      ariaLabel={t('control.closerBrush')}
                      onClick={() => selectEffectTool('closeBrush')}
                    >
                      <span className={styles.iconGlyph}>⬛</span>
                    </Button>
                  </div>
                </div>
              ) : null}
              <div className={styles.radiusRow}>
                <div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
                <input
                  type="range"
                  min={0.015}
                  max={0.18}
                  step={0.001}
                  value={tool.radiusN}
                  onChange={(e) => {
                    const v = Number((e.currentTarget as HTMLInputElement).value);
                    const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
                    void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
                  }}
                  className={styles.range}
                  aria-label={t('control.brushRadius')}
                />
                <div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
              </div>
              <div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
                <div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
                <input
                  type="range"
                  min={0}
                  max={1}
                  step={0.01}
                  value={effectsSfxGainUi}
                  onChange={(e) => {
                    const v = Number((e.currentTarget as HTMLInputElement).value);
                    const next = setEffectsSfxGain(clampEffectsSfxGain(v));
                    setEffectsSfxGainUi(next);
                    syncFireAmbientVolume();
                    syncRainAmbientVolume();
                  }}
                  className={styles.range}
                  aria-label={t('control.effectsSound')}
                />
                <div className={styles.radiusValue}>{Math.round(effectsSfxGainUi * 100)}</div>
              </div>
            </div>
            <div className={styles.spacer12} />
          </>
        <div className={styles.storyWrap}>
          <div className={styles.sectionLabel}>{t('control.storyLine')}</div>
          <div className={styles.spacer10} />
          <div className={styles.storyScroll}>
            {history.map((gnId, idx) => {
              const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
              const s = gn ? project?.scenes[gn.sceneId] : undefined;
              const isCurrent = idx === currentHistoryIdx;
              return (
                <button
                  type="button"
                  key={`${gnId}_${String(idx)}`}
                  disabled={!project || isCurrent}
                  className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
                    .filter(Boolean)
                    .join(' ')}
                  title={project && !isCurrent ? t('control.gotoScene') : undefined}
                  onClick={() => {
                    if (!project) return;
                    if (isCurrent) return;
                    void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
                  }}
                >
                  {isCurrent ? (
                    <div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
                  ) : (
                    <div className={styles.historyMuted}>{t('control.passed')}</div>
                  )}
                  <EllipsisText
                    text={s?.title ?? (gn ? String(gn.sceneId) : gnId)}
                    className={[styles.historyTitle, ellipsisStyles.root].join(' ')}
                  />
                </button>
              );
            })}
            {history.length === 0 ? (
              <div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
            ) : null}
          </div>
        </div>
      </Surface>

      <div className={styles.rightStack}>
        <Surface className={styles.surfacePad}>
          <div className={styles.previewHeader}>
            <div className={styles.previewTitle}>{t('control.screenPreview')}</div>
            <div className={styles.previewActions}>
              <label
                className={styles.snapToGrid}
                title={
                  currentScene?.grid?.enabled
                    ? t('control.snapTokensToGrid')
                    : t('control.snapTokensToGridNoGrid')
                }
              >
                <input
                  type="checkbox"
                  data-testid="token-grid-snap"
                  checked={tokenGridSnap}
                  onChange={(e) => {
                    const next = e.currentTarget.checked;
                    void (async () => {
                      const enabled = await tokenGridSnapApi.setEnabled(next);
                      if (enabled) await snapAllTokensToGrid();
                    })();
                  }}
                />
                <span className={styles.snapToGridLabel}>{t('control.snapTokensToGrid')}</span>
              </label>
              {USERS_BRANCH_FEATURES_ENABLED ? (
                <>
              <label className={styles.npcTokenScale}>
                <span className={styles.npcTokenScaleLabel}>{t('control.npcTokenScale')}</span>
                <input
                  type="range"
                  className={styles.npcTokenScaleRange}
                  min={NPC_TOKEN_SESSION_SCALE_MIN}
                  max={NPC_TOKEN_SESSION_SCALE_MAX}
                  step={0.05}
                  value={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
                  aria-label={t('control.npcTokenScale')}
                  onChange={(e) => {
                    sceneNpcTokensApi.dispatch({
                      kind: 'setScale',
                      scale: Number(e.currentTarget.value),
                    });
                  }}
                />
              </label>
              {scenePlayerTokensSession.selectedPlayerIds.length > 0 ? (
                <Button
                  data-testid="toggle-session-players"
                  onClick={() => {
                    if (scenePlayerTokensSession.visible) {
                      scenePlayerTokensApi.dispatch({ kind: 'setVisible', visible: false });
                      return;
                    }
                    const grid = currentScene?.grid;
                    const sizeN =
                      grid?.enabled && Number.isFinite(grid.sizeN)
                        ? grid.sizeN
                        : DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
                    scenePlayerTokensApi.dispatch({ kind: 'show', sizeN });
                    if (tokenGridSnap && grid?.enabled && previewContentRect) {
                      void (async () => {
                        const { state } = await api.invoke(
                          ipcChannels.scenePlayerTokensSession.getState,
                          {},
                        );
                        for (const playerId of state.selectedPlayerIds) {
                          const placement = state.byPlayerId[playerId];
                          if (!placement) continue;
                          const snapped = snapNormToGridCell(
                            placement.nx,
                            placement.ny,
                            grid,
                            previewContentRect.w,
                            previewContentRect.h,
                          );
                          if (snapped.nx === placement.nx && snapped.ny === placement.ny) continue;
                          scenePlayerTokensApi.dispatch({
                            kind: 'move',
                            playerId,
                            nx: snapped.nx,
                            ny: snapped.ny,
                          });
                        }
                      })();
                    }
                  }}
                >
                  {scenePlayerTokensSession.visible
                    ? t('control.hidePlayers')
                    : t('control.showPlayers')}
                </Button>
              ) : null}
                </>
              ) : null}
              <Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
                {t('control.stopPresentation')}
              </Button>
            </div>
          </div>
          <div className={styles.spacer10} />
          <div className={styles.spacer10} />
          <div
            ref={previewFrameRef}
            className={styles.previewFrame}
            title="Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан"
          >
            <div
              ref={previewHostRef}
              className={[
                styles.previewHost,
                npcMapSpawnDrag.dragging ? styles.previewHostSpawnDrag : '',
              ]
                .filter(Boolean)
                .join(' ')}
            >
              <ControlScenePreview
                session={session}
                videoRef={previewVideoRef}
                viewCamera={activeSceneViewCamera}
                onContentRectChange={setPreviewContentRect}
              />
            </div>
            {npcMapSpawnDrag.dragging &&
            previewContentRect &&
            npcMapSpawnDrag.hoverNx != null &&
            npcMapSpawnDrag.hoverNy != null ? (
              <div
                className={styles.npcSpawnGhost}
                style={{
                  left: previewContentRect.x + npcMapSpawnDrag.hoverNx * previewContentRect.w,
                  top: previewContentRect.y + npcMapSpawnDrag.hoverNy * previewContentRect.h,
                }}
              />
            ) : null}
            <>
                <SceneGridOverlay grid={currentScene?.grid} viewport={previewContentRect} />
                <PixiEffectsOverlay
                  ref={effectsOverlayRef}
                  state={fxState}
                  style={{ zIndex: 6 }}
                  viewport={
                    previewContentRect
                      ? {
                          x: previewContentRect.x,
                          y: previewContentRect.y,
                          w: previewContentRect.w,
                          h: previewContentRect.h,
                        }
                      : undefined
                  }
                />
                <ExplosionVideoOverlay
                  state={fxState}
                  draft={explosionDraft}
                  viewport={previewContentRect}
                />
                <div
                  ref={brushCursorElRef}
                  className={styles.brushCursor}
                  style={{ visibility: 'hidden' }}
                  aria-hidden
                />
                <div
                  className={styles.brushLayer}
                  onContextMenu={(e) => {
                    e.preventDefault();
                  }}
                  onPointerEnter={(e) => {
                    const p = toNPoint(e);
                    if (!p) return;
                    cursorPosRef.current = p;
                    layoutBrushCursor();
                  }}
                  onPointerLeave={() => {
                    cursorPosRef.current = null;
                    layoutBrushCursor();
                  }}
                  onPointerDown={(e) => {
                    const panWithPrimary = e.button === 0 && (spaceDownRef.current || e.altKey);
                    const isPan = e.button === 1 || e.button === 2 || panWithPrimary;
                    if (isPan) {
                      e.preventDefault();
                      (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                      scenePanRef.current = {
                        lastX: e.clientX,
                        lastY: e.clientY,
                        pointerId: e.pointerId,
                      };
                      return;
                    }
                    if (e.button !== 0) return;
                    if (tool.tool === 'none') return;
                    const p = toNPoint(e);
                    if (!p) return;
                    cursorPosRef.current = p;
                    layoutBrushCursor();
                    (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
                    if (tool.tool === 'eraser') {
                      const pt = { x: p.x, y: p.y, tMs: Date.now() };
                      brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
                      dispatchFieldEraserPoints([pt]);
                      tryEraseActionEffect(p);
                      return;
                    }
                    draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
                    const startPt = { x: p.x, y: p.y, tMs: Date.now() };
                    brushRef.current = {
                      tool: tool.tool,
                      startN: p,
                      points: [startPt],
                    };
                    if (tool.tool === 'water' || tool.tool === 'rain') {
                      extinguishFireAlong([startPt]);
                    }
                    if (tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') {
                      void sd.dispatch({
                        kind: 'draft.set',
                        draft: {
                          points: [startPt],
                          radiusN: tool.radiusN,
                          mode: tool.tool === 'closeBrush' ? 'cover' : 'reveal',
                        },
                      });
                    }
                    pushDraftToPixi();
                  }}
                  onPointerMove={(e) => {
                    const pan = scenePanRef.current;
                    if (pan && pan.pointerId === e.pointerId) {
                      const cr = previewContentRectRef.current;
                      if (!cr) return;
                      const cam = sceneViewRef.current;
                      const containW = cr.w / Math.max(1e-6, cam.scale);
                      const containH = cr.h / Math.max(1e-6, cam.scale);
                      const dx = e.clientX - pan.lastX;
                      const dy = e.clientY - pan.lastY;
                      pan.lastX = e.clientX;
                      pan.lastY = e.clientY;
                      publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy }));
                      return;
                    }
                    const p = toNPoint(e);
                    if (!p) return;
                    cursorPosRef.current = p;
                    layoutBrushCursor();
                    if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
                      const b = brushRef.current;
                      const pt = { x: p.x, y: p.y, tMs: Date.now() };
                      if (b?.tool !== 'eraser' || !b.points) {
                        brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
                        dispatchFieldEraserPoints([pt]);
                      } else {
                        const last = b.points[b.points.length - 1];
                        if (last) {
                          const dx = p.x - last.x;
                          const dy = p.y - last.y;
                          const minStep = Math.max(0.004, tool.radiusN * 0.25);
                          if (dx * dx + dy * dy >= minStep * minStep) {
                            b.points.push(pt);
                            dispatchFieldEraserPoints([last, pt]);
                          }
                        }
                      }
                      tryEraseActionEffect(p);
                      return;
                    }
                    const b = brushRef.current;
                    if (!b?.points) return;
                    const last = b.points[b.points.length - 1];
                    if (!last) return;
                    const dx = p.x - last.x;
                    const dy = p.y - last.y;
                    const minStep = Math.max(0.004, tool.radiusN * 0.25);
                    if (dx * dx + dy * dy < minStep * minStep) return;
                    const pt = { x: p.x, y: p.y, tMs: Date.now() };
                    b.points.push(pt);
                    if (b.tool === 'water' || b.tool === 'rain') {
                      extinguishFireAlong([last, pt]);
                    }
                    scheduleDraftRepaint();
                  }}
                  onPointerUp={(e) => {
                    if (scenePanRef.current?.pointerId === e.pointerId) {
                      scenePanRef.current = null;
                      const pending = pendingSceneViewRef.current;
                      if (pending) {
                        void sceneViewApi.dispatch({ kind: 'set', camera: pending });
                      }
                      return;
                    }
                    void commitStroke();
                  }}
                  onPointerCancel={(e) => {
                    if (scenePanRef.current?.pointerId === e.pointerId) {
                      scenePanRef.current = null;
                      return;
                    }
                    if (
                      brushRef.current?.tool === 'exploreBrush' ||
                      brushRef.current?.tool === 'closeBrush'
                    ) {
                      void sd.dispatch({ kind: 'draft.set', draft: null });
                    }
                    brushRef.current = null;
                    clearDraftFromPixi();
                  }}
                />
                {previewContentRect ? (
                  <TokenPathsOverlay
                    tokens={currentScene?.tokens ?? []}
                    npcTokens={USERS_BRANCH_FEATURES_ENABLED ? visibleNpcTokens : []}
                    viewport={previewContentRect}
                    mode="always"
                    pathSession={tokenPathSession}
                  />
                ) : null}
                {previewContentRect ? (
                  <SceneTokensOverlay
                    placements={currentScene?.tokens ?? []}
                    library={appTokens}
                    session={sceneTokensSession}
                    viewport={previewContentRect}
                    editable={markersInteractive}
                    snapNorm={snapNormActive}
                    poseOverrides={pathLivePoses.tokenPoses}
                    dragEnabledById={tokenPathDragEnabled}
                    onMove={(placementId, nx, ny) => {
                      const snapped = snapNormActive(nx, ny);
                      void sceneTokensApi.dispatch({
                        kind: 'move',
                        placementId,
                        nx: snapped.nx,
                        ny: snapped.ny,
                      });
                    }}
                    onResize={(placementId, sizeN) => {
                      void sceneTokensApi.dispatch({ kind: 'setSize', placementId, sizeN });
                    }}
                    resizeTitle={t('control.resizeToken')}
                    {...(markersInteractive
                      ? {
                          onContextMenu: (
                            e: React.MouseEvent,
                            placement: { id: string },
                          ) => {
                            setTokenSessionCtxMenu({
                              x: e.clientX,
                              y: e.clientY,
                              placementId: String(placement.id),
                            });
                          },
                        }
                      : {})}
                  />
                ) : null}
                {USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
                  <SceneNpcTokensOverlay
                    placements={visibleNpcTokens}
                    library={session?.project?.npcs ?? []}
                    session={sceneNpcTokensSession}
                    viewport={previewContentRect}
                    grid={currentScene?.grid ?? null}
                    editable={markersInteractive}
                    snapNorm={snapNormActive}
                    poseOverrides={pathLivePoses.npcPoses}
                    dragEnabledById={npcPathDragEnabled}
                    onMove={(placementId, nx, ny) => {
                      const snapped = snapNormActive(nx, ny);
                      sceneNpcTokensApi.dispatch({
                        kind: 'move',
                        placementId,
                        nx: snapped.nx,
                        ny: snapped.ny,
                      });
                    }}
                    onResize={(placementId, sizeN) => {
                      sceneNpcTokensApi.dispatch({ kind: 'setSize', placementId, sizeN });
                    }}
                    resizeTitle={t('control.resizeToken')}
                    {...(markersInteractive
                      ? {
                          onContextMenu: (
                            e: React.MouseEvent,
                            placement: { id: string },
                          ) => {
                            setNpcSessionCtxMenu({
                              x: e.clientX,
                              y: e.clientY,
                              placementId: String(placement.id),
                            });
                          },
                        }
                      : {})}
                  />
                ) : null}
                {USERS_BRANCH_FEATURES_ENABLED && previewContentRect ? (
                  <ScenePlayerTokensOverlay
                    library={appPlayers}
                    session={scenePlayerTokensSession}
                    displayScale={
                      sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE
                    }
                    viewport={previewContentRect}
                    grid={currentScene?.grid ?? null}
                    editable={markersInteractive}
                    snapNorm={snapNormActive}
                    onMove={(playerId, nx, ny) => {
                      const snapped = snapNormActive(nx, ny);
                      scenePlayerTokensApi.dispatch({
                        kind: 'move',
                        playerId,
                        nx: snapped.nx,
                        ny: snapped.ny,
                      });
                    }}
                    onResize={(playerId, sizeN) => {
                      scenePlayerTokensApi.dispatch({ kind: 'setSize', playerId, sizeN });
                    }}
                    resizeTitle={t('control.resizeToken')}
                  />
                ) : null}
                {previewContentRect ? (
                  <SceneTrapsOverlay
                    traps={currentScene?.traps ?? []}
                    session={sceneTraps}
                    viewport={previewContentRect}
                    mode="control"
                    interactive={markersInteractive}
                    onReveal={(trapId) => void sceneTrapsApi.dispatch({ kind: 'reveal', trapId })}
                    onActivate={(trapId) => {
                      void (async () => {
                        await sceneTrapsApi.dispatch({ kind: 'activate', trapId });
                        const trap = (currentScene?.traps ?? []).find((t) => t.id === trapId);
                        if (trap?.type === 'poison') {
                          // Тот же VFX/SFX, что у инструмента «Облако яда» на пульте эффектов.
                          const createdAtMs = Date.now();
                          const seed = Math.floor(Math.random() * 1_000_000_000);
                          const poisonLifeMs = await getPoisonCloudEffectLifeMs();
                          await fx.dispatch({
                            kind: 'instance.add',
                            instance: {
                              id: `trap_pc_${trapId}_${String(createdAtMs)}`,
                              type: 'poisonCloud',
                              seed,
                              createdAtMs,
                              at: { x: trap.nx, y: trap.ny },
                              radiusN: Math.max(0.04, trap.sizeN * 1.15),
                              intensity: 1.05,
                              lifetimeMs: poisonLifeMs,
                            },
                          });
                          void playPoisonCloudEffectSound(poisonLifeMs);
                        }
                        if (trap?.type === 'explosion') {
                          const createdAtMs = Date.now();
                          const seed = Math.floor(Math.random() * 1_000_000_000);
                          const explosionLifeMs = await getExplosionEffectLifeMs();
                          await fx.dispatch({
                            kind: 'instance.add',
                            instance: {
                              id: `trap_ex_${trapId}_${String(createdAtMs)}`,
                              type: 'explosion',
                              seed,
                              createdAtMs,
                              at: { x: trap.nx, y: trap.ny },
                              radiusN: Math.max(0.05, trap.sizeN * 1.35),
                              intensity: 1.15,
                              lifetimeMs: explosionLifeMs,
                            },
                          });
                          void playExplosionEffectSound(explosionLifeMs);
                        }
                      })();
                    }}
                    onDisarm={(trapId) => void sceneTrapsApi.dispatch({ kind: 'disarm', trapId })}
                  />
                ) : null}
              </>
            {previewContentRect && currentScene?.darkenScene ? (
              <SceneDarknessOverlay
                state={sdState}
                overlayAlpha={0.5}
                viewport={previewContentRect}
                style={{ zIndex: 30 }}
              />
            ) : null}
            {(() => {
              const project = session?.project;
              const materialIds = materialsOverlay?.activeMaterialIds ?? [];
              const materialItems =
                project && materialIds.length > 0
                  ? materialIds
                      .map((id) => {
                        const material = (project.materials ?? []).find((m) => m.id === id);
                        if (!material) return null;
                        return {
                          material,
                          layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
                          legendLayout:
                            materialsOverlay?.legendLayouts[id] ?? {
                              ...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
                              cx: 0.82,
                              cy: 0.5,
                              scale: 0.85,
                            },
                        };
                      })
                      .filter((x): x is NonNullable<typeof x> => x !== null)
                  : [];
              const activeIds = npcsOverlay?.activeNpcIds ?? [];
              const npcItems =
                project && activeIds.length > 0
                  ? activeIds
                      .map((id) => {
                        const npc = (project.npcs ?? []).find((n) => n.id === id);
                        if (!npc) return null;
                        return {
                          npcId: npc.id,
                          assetId: npc.avatarAssetId,
                          layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
                        };
                      })
                      .filter((x): x is NonNullable<typeof x> => x !== null)
                  : [];
              const showMaterial = materialItems.length > 0;
              const showNpcs = npcItems.length > 0;
              const screenRect = presentationScreenRect;
              const showGuide = Boolean(screenRect);
              if (!showMaterial && !showNpcs && !showGuide) return null;
              const closes = [
                ...(showMaterial
                  ? [
                      {
                        key: 'materials',
                        label: t('materials.closeOverlay'),
                        onClose: () => {
                          void materialsApi.dispatch({ kind: 'hide' });
                        },
                      },
                    ]
                  : []),
                ...(showNpcs
                  ? [
                      {
                        key: 'npcs',
                        label: t('npcs.closeOverlay'),
                        onClose: () => {
                          void npcsApi.dispatch({ kind: 'hide' });
                        },
                      },
                    ]
                  : []),
              ];
              const materialsZoom = materialsOverlay?.zoomTool ?? null;
              const materialIdFromTarget = (target: EventTarget | null) => {
                if (!(target instanceof Element)) return undefined;
                const frame = target.closest('[data-material-id]');
                const raw = frame?.getAttribute('data-material-id');
                return raw ? (raw as MaterialId) : undefined;
              };
              return (
                <>
                <SceneOverlayHost
                  active={showMaterial || showNpcs}
                  viewport={screenRect}
                  showViewportGuide={showGuide}
                  zoomTool={materialsZoom}
                  {...(materialsZoom
                    ? {
                        onZoomAt: (nx: number, ny: number, evTarget?: EventTarget | null) => {
                          const mid = materialIdFromTarget(evTarget ?? null);
                          void materialsApi.dispatch({
                            kind: 'zoomAt',
                            nx,
                            ny,
                            ...(mid ? { materialId: mid } : {}),
                          });
                        },
                      }
                    : {})}
                  closes={closes}
                >
                  {materialItems.map(({ material, layout, legendLayout }) => (
                    <React.Fragment key={material.id}>
                      <MaterialOverlay
                        embedded
                        assetId={material.assetId}
                        materialId={material.id}
                        layout={layout}
                        editable
                        zoomTool={materialsZoom}
                        rotateLabel={t('materials.rotateOverlay')}
                        onLayoutChange={(nextLayout) => {
                          void materialsApi.dispatch({
                            kind: 'layout.set',
                            materialId: material.id,
                            layout: nextLayout,
                          });
                        }}
                        {...(material.legend?.enabled
                          ? { legendMarkers: material.legend.markers ?? [] }
                          : {})}
                      />
                      {material.legend?.enabled ? (
                        <MaterialLegendPanel
                          legend={material.legend}
                          layout={legendLayout}
                          editable
                          onLayoutChange={(nextLayout) => {
                            void materialsApi.dispatch({
                              kind: 'legendLayout.set',
                              materialId: material.id,
                              layout: nextLayout,
                            });
                          }}
                        />
                      ) : null}
                    </React.Fragment>
                  ))}
                  {showNpcs ? (
                    <NpcsSceneOverlay
                      embedded
                      items={npcItems}
                      editable
                      rotateLabel={t('npcs.rotateOverlay')}
                      onLayoutChange={(npcId, layout) => {
                        void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
                      }}
                    />
                  ) : null}
                </SceneOverlayHost>
                </>
              );
            })()}
          </div>
        </Surface>

        <Surface className={styles.surfacePad}>
          <div className={styles.branchTitle}>{t('control.branches')}</div>
          <div className={styles.branchGrid}>
            {showReturnToMain ? (
              <div className={[styles.branchCard, styles.branchCardReturn].join(' ')}>
                <div className={styles.branchCardHeader}>
                  <div className={styles.branchOption}>{t('control.option', { n: '1' })}</div>
                </div>
                <EllipsisText
                  text={returnSceneTitle}
                  className={[styles.branchName, ellipsisStyles.root].join(' ')}
                />
                <Button variant="primary" onClick={returnToMainStoryline}>
                  {t('control.returnToMainStory')}
                </Button>
              </div>
            ) : null}
            {nextScenes.map((o, i) => (
              <div key={o.graphNodeId} className={styles.branchCard}>
                <div className={styles.branchCardHeader}>
                  <div className={styles.branchOption}>
                    {t('control.option', { n: String(i + 1 + branchOptionOffset) })}
                  </div>
                </div>
                <EllipsisText
                  text={o.scene.title || t('control.unnamed')}
                  className={[styles.branchName, ellipsisStyles.root].join(' ')}
                />
                <Button
                  variant="primary"
                  onClick={() =>
                    void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
                  }
                >
                  {t('control.switchScene')}
                </Button>
              </div>
            ))}
            {nextScenes.length === 0 && !showReturnToMain ? (
              <div className={styles.branchEmpty}>
                <div>{t('control.noBranches')}</div>
                <Button
                  variant="primary"
                  disabled={!session?.project?.currentGraphNodeId}
                  onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
                >
                  {t('control.endPresentation')}
                </Button>
              </div>
            ) : null}
          </div>
        </Surface>

        <Surface className={styles.surfacePad}>
          <div className={styles.musicHeader}>
            <div className={styles.previewTitle}>{t('control.music')}</div>
          </div>
          <div className={styles.spacer10} />
          <div className={styles.sectionLabel}>{t('control.sceneMusic')}</div>
          <div className={styles.spacer10} />
          {sceneAudios.length === 0 ? (
            <div className={styles.musicEmpty}>{t('control.noSceneAudio')}</div>
          ) : null}
          {sceneAudios.length > 0 ? (
            <div className={styles.audioList}>
              {sceneAudios.map(({ ref, asset }) => {
                const el = sceneAudioElsRef.current.get(ref.assetId) ?? null;
                const st = audioStatus('scene', ref.assetId);
                return (
                  <ControlAudioCard
                    key={ref.assetId}
                    assetId={ref.assetId}
                    name={asset.originalName}
                    autoplay={ref.autoplay}
                    loop={ref.loop}
                    statusLabel={st.label}
                    {...(st.detail ? { statusDetail: st.detail } : {})}
                    audioEl={el}
                    initialGain={readAudioGain(sceneAudioGainRef.current, ref.assetId)}
                    gainMap={sceneAudioGainRef.current}
                    playTitle={t('control.transportPlay')}
                    playLabel={t('control.transportPlay')}
                    pauseLabel={t('control.transportPause')}
                    stopLabel={t('control.transportStop')}
                    volumeLabel={t('control.volume')}
                    modeAutoLabel={t('control.modeAuto')}
                    modeManualLabel={t('control.modeManual')}
                    loopLabel={t('control.loop')}
                    onceLabel={t('control.once')}
                    scrubSeekLabel={t('control.scrubSeek')}
                    durationUnknownLabel={t('control.durationUnknown')}
                    onStatusChange={() => setSceneAudioStateTick((x) => x + 1)}
                    onPlay={() => {
                      if (!el) return;
                      const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
                      sceneAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
                      try {
                        applyAudioGain(el, sceneAudioGainRef.current, ref.assetId);
                      } catch {
                        // ignore
                      }
                      void el.play().catch(() => {
                        const mm =
                          sceneAudioMetaRef.current.get(ref.assetId) ?? ({ lastPlayError: null } as const);
                        sceneAudioMetaRef.current.set(ref.assetId, {
                          ...mm,
                          lastPlayError: t('control.playFailed'),
                        });
                        setSceneAudioStateTick((x) => x + 1);
                      });
                    }}
                    onPause={() => {
                      if (!el) return;
                      el.pause();
                    }}
                    onStop={() => {
                      if (!el) return;
                      el.pause();
                      el.currentTime = 0;
                      setSceneAudioStateTick((x) => x + 1);
                    }}
                  />
                );
              })}
            </div>
          ) : null}

          <div className={styles.spacer12} />
          <div className={styles.sectionLabel}>{t('control.gameMusic')}</div>
          <div className={styles.spacer10} />
          {campaignAudios.length === 0 ? (
            <div className={styles.musicEmpty}>{t('control.noGameAudio')}</div>
          ) : (
            <div className={styles.audioList}>
              {campaignAudios.map(({ ref, asset }) => {
                const el = campaignAudioElsRef.current.get(ref.assetId) ?? null;
                const st = audioStatus('campaign', ref.assetId);
                return (
                  <ControlAudioCard
                    key={ref.assetId}
                    assetId={ref.assetId}
                    name={asset.originalName}
                    autoplay={ref.autoplay}
                    loop={ref.loop}
                    statusLabel={st.label}
                    {...(st.detail ? { statusDetail: st.detail } : {})}
                    {...(!allowCampaignAudio
                      ? {
                          extraBadge: (
                            <div title={t('control.pauseSceneMusicTitle')}>
                              {t('control.pauseSceneMusic')}
                            </div>
                          ),
                        }
                      : {})}
                    audioEl={el}
                    initialGain={readAudioGain(campaignAudioGainRef.current, ref.assetId)}
                    gainMap={campaignAudioGainRef.current}
                    playTitle={
                      !allowCampaignAudio ? t('control.pauseCampaignTitle') : t('control.transportPlay')
                    }
                    playLabel={t('control.transportPlay')}
                    pauseLabel={t('control.transportPause')}
                    stopLabel={t('control.transportStop')}
                    volumeLabel={t('control.volume')}
                    modeAutoLabel={t('control.modeAuto')}
                    modeManualLabel={t('control.modeManual')}
                    loopLabel={t('control.loop')}
                    onceLabel={t('control.once')}
                    scrubSeekLabel={t('control.scrubSeek')}
                    durationUnknownLabel={t('control.durationUnknown')}
                    onStatusChange={() => setCampaignAudioStateTick((x) => x + 1)}
                    onPlay={() => {
                      if (!el) return;
                      const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
                      campaignAudioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
                      try {
                        if (el.volume === 0) {
                          applyAudioGain(el, campaignAudioGainRef.current, ref.assetId);
                        }
                      } catch {
                        // ignore
                      }
                      void el.play().catch(() => {
                        const mm =
                          campaignAudioMetaRef.current.get(ref.assetId) ?? ({ lastPlayError: null } as const);
                        campaignAudioMetaRef.current.set(ref.assetId, {
                          ...mm,
                          lastPlayError: t('control.playFailed'),
                        });
                        setCampaignAudioStateTick((x) => x + 1);
                      });
                    }}
                    onPause={() => {
                      if (!el) return;
                      el.pause();
                    }}
                    onStop={() => {
                      if (!el) return;
                      el.pause();
                      el.currentTime = 0;
                      setCampaignAudioStateTick((x) => x + 1);
                    }}
                  />
                );
              })}
            </div>
          )}
        </Surface>

        {sideStoryLines.length > 0 ? (
          <Surface className={styles.surfacePad}>
            <div className={styles.previewTitle}>{t('control.sideStoryLines')}</div>
            <div className={styles.spacer10} />
            <div className={styles.sideStoryGrid}>
              {sideStoryLines.map((line) =>
                line.scene ? (
                  <SideStoryTile
                    key={line.graphNodeId}
                    scene={line.scene}
                    title={line.title}
                    onClick={() => enterSideStoryline(line.graphNodeId)}
                  />
                ) : null,
              )}
            </div>
          </Surface>
        ) : null}
      </div>

      {tokenSessionCtxMenu
        ? createPortal(
            <>
              <button
                type="button"
                className={styles.ctxMenuBackdrop}
                aria-label={t('common.close')}
                onClick={() => setTokenSessionCtxMenu(null)}
              />
              <div
                className={styles.ctxMenu}
                style={{ left: tokenSessionCtxMenu.x, top: tokenSessionCtxMenu.y }}
                role="menu"
              >
                {(() => {
                  const placement = (currentScene?.tokens ?? []).find(
                    (item) => String(item.id) === tokenSessionCtxMenu.placementId,
                  );
                  if (!placement?.path || placement.path.points.length < 2) {
                    return (
                      <div className={styles.ctxItem} style={{ opacity: 0.55, cursor: 'default' }}>
                        Нет пути движения
                      </div>
                    );
                  }
                  const path = placement.path;
                  const target = {
                    kind: 'token' as const,
                    placementId: tokenSessionCtxMenu.placementId,
                  };
                  const key = tokenPathKey(target.kind, target.placementId);
                  const entry = tokenPathSession?.playback[key];
                  const visible = Boolean(tokenPathSession?.presentationVisible[key]);
                  const override =
                    sceneTokensSession?.byPlacementId[tokenSessionCtxMenu.placementId];
                  return (
                    <>
                      <button
                        type="button"
                        className={styles.ctxItem}
                        role="menuitem"
                        onClick={() => {
                          void tokenPathApi.dispatch({
                            kind: visible ? 'hidePresentation' : 'showPresentation',
                            target,
                          });
                          setTokenSessionCtxMenu(null);
                        }}
                      >
                        {visible ? 'Скрыть путь' : 'Показать путь'}
                      </button>
                      {entry && entry.phase !== 'stopped' ? (
                        <button
                          type="button"
                          className={styles.ctxItem}
                          role="menuitem"
                          onClick={() => {
                            const sample = computePathPlaybackSample({
                              path,
                              entry,
                              nowMs: Date.now(),
                            });
                            if (sample) {
                              void sceneTokensApi.dispatch({
                                kind: 'move',
                                placementId: target.placementId,
                                nx: sample.sample.nx,
                                ny: sample.sample.ny,
                              });
                            }
                            void tokenPathApi.dispatch({
                              kind: 'stop',
                              target,
                              atDist: sample?.sample.dist ?? entry.baseDist,
                            });
                            setTokenSessionCtxMenu(null);
                          }}
                        >
                          Остановить движение
                        </button>
                      ) : null}
                      {entry && entry.phase === 'stopped' ? (
                        <button
                          type="button"
                          className={styles.ctxItem}
                          role="menuitem"
                          onClick={() => {
                            const nx = override?.nx ?? placement.nx;
                            const ny = override?.ny ?? placement.ny;
                            const fromDist = resumeFromPose(path, nx, ny, entry.baseDist);
                            void tokenPathApi.dispatch({
                              kind: 'resume',
                              target,
                              nx,
                              ny,
                              fromDist,
                            });
                            setTokenSessionCtxMenu(null);
                          }}
                        >
                          Продолжить движение
                        </button>
                      ) : null}
                      <button
                        type="button"
                        className={styles.ctxItem}
                        role="menuitem"
                        onClick={() => {
                          const len = tokenPathTotalLength(path);
                          void tokenPathApi.dispatch({
                            kind: 'seedPlayback',
                            entry: {
                              kind: 'token',
                              placementId: target.placementId,
                              phase: path.startMode === 'delayed' ? 'delay' : 'moving',
                              baseDist: 0,
                              direction: 1,
                              rejoinDist: null,
                              durationSec: path.durationSec,
                              pathLength: len,
                            },
                          });
                          const start = sampleTokenPathAtDistance(path, 0);
                          if (start) {
                            void sceneTokensApi.dispatch({
                              kind: 'move',
                              placementId: target.placementId,
                              nx: start.nx,
                              ny: start.ny,
                            });
                          }
                          setTokenSessionCtxMenu(null);
                        }}
                      >
                        Сбросить на старт пути
                      </button>
                    </>
                  );
                })()}
              </div>
            </>,
            document.body,
          )
        : null}

      {USERS_BRANCH_FEATURES_ENABLED && npcSessionCtxMenu
        ? createPortal(
            <>
              <button
                type="button"
                className={styles.ctxMenuBackdrop}
                aria-label={t('common.close')}
                onClick={() => setNpcSessionCtxMenu(null)}
              />
              <div
                className={styles.ctxMenu}
                style={{ left: npcSessionCtxMenu.x, top: npcSessionCtxMenu.y }}
                role="menu"
              >
                {(() => {
                  const placement = visibleNpcTokens.find(
                    (item) => String(item.id) === npcSessionCtxMenu.placementId,
                  );
                  const npc = placement
                    ? (session?.project?.npcs ?? []).find((item) => item.id === placement.npcId)
                    : undefined;
                  if (!placement || !npc) return null;
                  const override =
                    sceneNpcTokensSession.byPlacementId[npcSessionCtxMenu.placementId];
                  const current = resolveNpcTokenDisposition(
                    placement,
                    npc,
                    override?.disposition,
                  );
                  const inactive = Boolean(override?.inactive);
                  const path = placement.path;
                  const pathTarget = {
                    kind: 'npcToken' as const,
                    placementId: npcSessionCtxMenu.placementId,
                  };
                  const pathKey = tokenPathKey(pathTarget.kind, pathTarget.placementId);
                  const pathEntry = tokenPathSession?.playback[pathKey];
                  const pathVisible = Boolean(tokenPathSession?.presentationVisible[pathKey]);
                  if (inactive) {
                    return (
                      <button
                        type="button"
                        className={styles.ctxItem}
                        role="menuitem"
                        onClick={() => {
                          sceneNpcTokensApi.dispatch({
                            kind: 'setInactive',
                            placementId: npcSessionCtxMenu.placementId,
                            inactive: false,
                          });
                          setNpcSessionCtxMenu(null);
                        }}
                      >
                        {t('npcs.makeActive')}
                      </button>
                    );
                  }
                  const isSessionSpawn = Boolean(
                    sceneNpcTokensSession.spawned[npcSessionCtxMenu.placementId],
                  );
                  return (
                    <>
                      {isSessionSpawn ? (
                        <button
                          type="button"
                          className={styles.ctxItem}
                          role="menuitem"
                          onClick={() => {
                            sceneNpcTokensApi.dispatch({
                              kind: 'despawn',
                              placementId: npcSessionCtxMenu.placementId,
                            });
                            setNpcSessionCtxMenu(null);
                          }}
                        >
                          {t('npcs.removeFromMap')}
                        </button>
                      ) : null}
                      {path && path.points.length >= 2 ? (
                        <>
                          <button
                            type="button"
                            className={styles.ctxItem}
                            role="menuitem"
                            onClick={() => {
                              void tokenPathApi.dispatch({
                                kind: pathVisible ? 'hidePresentation' : 'showPresentation',
                                target: pathTarget,
                              });
                              setNpcSessionCtxMenu(null);
                            }}
                          >
                            {pathVisible ? 'Скрыть путь' : 'Показать путь'}
                          </button>
                          {pathEntry && pathEntry.phase !== 'stopped' ? (
                            <button
                              type="button"
                              className={styles.ctxItem}
                              role="menuitem"
                              onClick={() => {
                                const sample = computePathPlaybackSample({
                                  path,
                                  entry: pathEntry,
                                  nowMs: Date.now(),
                                });
                                if (sample) {
                                  sceneNpcTokensApi.dispatch({
                                    kind: 'move',
                                    placementId: pathTarget.placementId,
                                    nx: sample.sample.nx,
                                    ny: sample.sample.ny,
                                  });
                                }
                                void tokenPathApi.dispatch({
                                  kind: 'stop',
                                  target: pathTarget,
                                  atDist: sample?.sample.dist ?? pathEntry.baseDist,
                                });
                                setNpcSessionCtxMenu(null);
                              }}
                            >
                              Остановить движение
                            </button>
                          ) : null}
                          {pathEntry && pathEntry.phase === 'stopped' ? (
                            <button
                              type="button"
                              className={styles.ctxItem}
                              role="menuitem"
                              onClick={() => {
                                const nx = override?.nx ?? placement.nx;
                                const ny = override?.ny ?? placement.ny;
                                const fromDist = resumeFromPose(path, nx, ny, pathEntry.baseDist);
                                void tokenPathApi.dispatch({
                                  kind: 'resume',
                                  target: pathTarget,
                                  nx,
                                  ny,
                                  fromDist,
                                });
                                setNpcSessionCtxMenu(null);
                              }}
                            >
                              Продолжить движение
                            </button>
                          ) : null}
                          <button
                            type="button"
                            className={styles.ctxItem}
                            role="menuitem"
                            onClick={() => {
                              const len = tokenPathTotalLength(path);
                              void tokenPathApi.dispatch({
                                kind: 'seedPlayback',
                                entry: {
                                  kind: 'npcToken',
                                  placementId: pathTarget.placementId,
                                  phase: path.startMode === 'delayed' ? 'delay' : 'moving',
                                  baseDist: 0,
                                  direction: 1,
                                  rejoinDist: null,
                                  durationSec: path.durationSec,
                                  pathLength: len,
                                },
                              });
                              const start = sampleTokenPathAtDistance(path, 0);
                              if (start) {
                                sceneNpcTokensApi.dispatch({
                                  kind: 'move',
                                  placementId: pathTarget.placementId,
                                  nx: start.nx,
                                  ny: start.ny,
                                });
                              }
                              setNpcSessionCtxMenu(null);
                            }}
                          >
                            Сбросить на старт пути
                          </button>
                        </>
                      ) : null}
                      <button
                        type="button"
                        className={styles.ctxItem}
                        role="menuitem"
                        onClick={() => {
                          setNpcSessionCtxMenu(null);
                          void api
                            .invoke(ipcChannels.windows.openNpcs, { npcId: npc.id })
                            .catch((err) => {
                              console.error('[control] openNpcs failed', err);
                            });
                        }}
                      >
                        {t('npcs.openInfo')}
                      </button>
                      {otherNpcDispositions(current).map((d) => (
                        <button
                          key={d}
                          type="button"
                          className={styles.ctxItem}
                          role="menuitem"
                          onClick={() => {
                            sceneNpcTokensApi.dispatch({
                              kind: 'setDisposition',
                              placementId: npcSessionCtxMenu.placementId,
                              disposition: d,
                            });
                            setNpcSessionCtxMenu(null);
                          }}
                        >
                          {t(dispositionMakeKey(d))}
                        </button>
                      ))}
                      <button
                        type="button"
                        className={styles.ctxItem}
                        role="menuitem"
                        onClick={() => {
                          sceneNpcTokensApi.dispatch({
                            kind: 'setInactive',
                            placementId: npcSessionCtxMenu.placementId,
                            inactive: true,
                          });
                          setNpcSessionCtxMenu(null);
                        }}
                      >
                        {t('npcs.inactive')}
                      </button>
                    </>
                  );
                })()}
              </div>
            </>,
            document.body,
          )
        : null}
    </div>
  );
}
