import {
    Container,
    IsoDepthLayer,
    Lighting,
    Sprite,
    SpriteMotion,
    Texture,
    IsometricTileMap,
    FxLayer,
    emberPreset,
    readSeconds,
    worldToScreen,
    tileToWorld,
    DebugOverlay,
    SpriteDebugView,
    VirtualJoystick,
    CutsceneRunner,
    type Camera,
    type Invariant,
    type JsonValue,
    type Scene,
    type SnapshotLayer,
    type Vec2,
    type DialogueWorld
} from '@rpg/engine';
import { Game } from '../Game';
import { MenuScene } from './MenuScene';
import { SAVE_VERSION, type SaveData } from './saveData';
import { InventoryScene } from './InventoryScene';
import { TILES } from '../data/map';
import { areaAudioKeys } from '../data/audio';
import { playSfx } from '../data/sfxSpecs';
import { areaOf, type AreaDef, type AreaId, type TransitionDef } from '../data/locations';
import { AudioSystem } from '../systems/AudioSystem';
import type { StepSurface } from '../systems/StepVariants';
import { InteractionRouter } from '../systems/InteractRouting';
import { SceneAgentView } from '../agent/SceneAgentView';
import type { NpcDef } from '../data/npcs';
import { npcsForArea } from '../data/npcs';
import { ActorWalker } from '../systems/cutscene/CutsceneActors';
import { DIALOGUES } from '../data/dialogues';
import { questDialogueFor, questTracker } from '../data/quests';
import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects';
import type { ItemId } from '../data/items';
import { FLAGS, VARS } from '../data/ids';
import { PlayerController } from '../systems/PlayerController';
import { FaunaSystem } from '../systems/fauna/FaunaSystem';
import { DialogueSystem } from '../systems/DialogueSystem';
import { CombatWorld } from '../systems/combat/CombatWorld';
import { CombatViews } from '../systems/combat/CombatViews';
import { CombatFlow } from '../systems/combat/CombatFlow';
import { PLAYER_COMBAT } from '../systems/combat/stats';
import { Interactables } from '../systems/Interactables';
import { GameLighting, MAX_LIGHTS, type HeroLampDef } from '../systems/Lighting';
import { SceneObjects } from '../systems/SceneObjects';
import { InteractHint } from '../systems/InteractHint';
import { LocationAudio } from '../systems/LocationAudio';
import { InteractableViews, makeExitGlow } from '../systems/InteractableViews';
import { LocationHud } from '../systems/LocationHud';
import { Atmosphere } from '../systems/Atmosphere';
import { FlowerQuest } from '../systems/quest/FlowerQuest';
import { ResonatorQuest } from '../systems/quest/ResonatorQuest';
import { enemyTextures, heroTextures, tileTextures } from '../data/assetSets';

/** Лампа героя: тёплый, компактный, слегка мерцает (прорезает ночную тьму). */
const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 1.6, intensity: 0.85, flicker: 0.12 };

/**
 * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc.
 * Сцена — оркестратор вьюх: аудио/атмосфера/квест/вьюхи объектов живут
 * своими подсистемами (systems/), а здесь только их сборка и тик.
 */
export class LocationScene implements Scene {
    private world = new Container();
    private map: IsometricTileMap;
    private player: PlayerController;
    private dialogue: DialogueSystem;
    /** Сюжетные custom-эффекты, отложенные до конца диалога. */
    private pendingCustom = new Set<DialogueCustomId>();
    private npcs: { def: NpcDef; view: Container; body: Sprite }[] = [];
    private actors = new IsoDepthLayer();
    /** Аудио локации: слои/roomtone/шорохи/музыка угрозы. */
    private locationAudio: LocationAudio;
    /** Атмосфера: пепел/мотыли/дымка, зоны наката, виньетка. */
    private atmosphere: Atmosphere;
    /** Квест «Три цветка»: сбор/посадка/кат-сцена сдачи. */
    private flowers: FlowerQuest;
    /** Квест резонатора (акт 2): кат-сцена настройки у Клинта. */
    private resonator: ResonatorQuest;
    /** Вьюхи интерактивных объектов и NPC (фабрики + растворение). */
    private interactViews: InteractableViews;
    /** Мигание при неуязвимости — процедурный blink (duty живой). */
    private heroMotion: SpriteMotion;
    /** Название локации, подсказка, счётчик мотов (одна вьюха в uiRoot). */
    private hud: LocationHud;
    private camera: Camera;

    // --- бой ---
    private combat: CombatWorld;
    private combatViews: CombatViews;
    /** Боевая обвязка героя: ввод/резонанс/урон/респаун/hp-бар/кольцо заряда. */
    private flow: CombatFlow;
    /** Слой мировых эффектов (кольца, частицы, растворения) — тикается engine.fx. */
    private fx: FxLayer;
    /** Слой UI-эффектов (тосты): clear() не гасит мировые эффекты. */
    private uiFx: FxLayer;
    /** Маршрутизация клика/переходов/отложенных взаимодействий. */
    private router: InteractionRouter;
    /** Подпись «[E] Имя» над ближайшей целью кнопки действия. */
    private hint: InteractHint;
    /** Интерактивные объекты области (сундуки, очаги, прилавки). */
    private interactables: Interactables;
    /** Объекты сцены на движковом реестре (кто на тайле — для кликов/коллизий). */
    private objects: SceneObjects;
    /** Освещение: движковый слой (lightRoot) + игровая обвязка. */
    private lightView: Lighting;
    private lighting: GameLighting;
    /** Пейзажная фауна (безгласные олени). */
    private fauna: FaunaSystem;
    /** Позиционный звук мира + события боя (звук — подпиской). */
    private worldAudio: AudioSystem;
    /** off() подписок worldAudio (снять в exit). */
    private audioOff: () => void;
    /** off() подписок на шину событий боя (снять в exit). */
    private eventOffs: (() => void)[] = [];
    /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */
    private cutscene = new CutsceneRunner();
    /** Ходоки актёров кат-сцен, по def NPC (создаются лениво). */
    private walkers = new Map<NpcDef, ActorWalker>();
    /** Тач-джойстик (активен только для касаний). */
    private joystick: VirtualJoystick;
    private debug: DebugOverlay;
    private charDebug: SpriteDebugView;
    /** Последний тост (текст + тик) — канал текста для агентного моста. */
    private lastToast: { text: string; tick: number } | null = null;
    /** Агентный мост сцены (снапшот/инварианты/команды). */
    private agentView: SceneAgentView;
    /** Позиции тайлов-источников амбиент-слоёв, по id (собираются один раз). */
    private layerTilePos = new Map<number, Vec2[]>();

    constructor(
        private game: Game,
        save: SaveData | null,
        private area: AreaDef,
        /** Точка входа при переходе из другой области (приоритетнее save). */
        entry?: { x: number; y: number },
        /** Откуда вошли (для target kind 'return'): область + тайл триггера. */
        private returnTo?: { area: AreaId; entry: { x: number; y: number } }
    ) {
        this.camera = game.engine.camera;
        const data = this.game.mapFiles.get(area.id)!;
        this.map = new IsometricTileMap(data, tileTextures(this.game.assets));
        // Живая вода: один общий таймлайн на все клетки с id WATER (2 кадра).
        this.map.setTileAnimation(
            TILES.WATER,
            [this.game.assets.texture('tiles/water_1'), this.game.assets.texture('tiles/water_2')],
            2
        );
        this.world.addChild(this.map.view, this.actors);
        // Выход интерьера: бронзовый контур проёма + подпись (у kind 'return').
        const exit = area.transitions.find((t) => t.target.kind === 'return');
        if (exit) {
            const glow = makeExitGlow(this.tileCenter(exit.tile.x, exit.tile.y));
            this.world.addChildAt(glow, 1); // поверх пола карты, под акторами
        }
        // Источники амбиент-слоёв: позиции тайлов по id — один проход по карте.
        for (let y = 0; y < data.height; y++) {
            for (let x = 0; x < data.width; x++) {
                const id = data.tiles[y * data.width + x]!;
                const list = this.layerTilePos.get(id) ?? [];
                list.push({ x, y });
                this.layerTilePos.set(id, list);
            }
        }
        // Крупные объекты карты (дома) — в общий depth-слой по footprint'у.
        for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h);
        this.game.renderer.worldRoot.addChild(this.world);
        // Слой мировых эффектов (раньше вьюх объектов: consume растворяет через него).
        this.fx = new FxLayer();
        this.world.addChild(this.fx);
        this.game.engine.fx.add(this.fx);
        // Единый реестр объектов сцены: NPC, интерактивы, пропы (враги/фауна
        // добавляются позже своими системами).
        this.objects = new SceneObjects(area, this.map);

        const startTile = entry ?? save?.pos ?? area.spawn;
        // Тайл входа: step-переходы на нём глушатся, пока герой с него не ушёл
        // (иначе вошёл в дверь — и немедленно вылетел обратно).
        const disarmTile = entry ? { ...startTile } : null;
        if (save?.state) this.game.state.load(save.state);
        // Позиционный звук: слушатель — герой.
        this.worldAudio = new AudioSystem({
            audio: this.game.audio,
            events: this.game.engine.events,
            getListener: () => this.player.position
        });
        this.audioOff = this.worldAudio.attach();
        this.player = new PlayerController(this.map, heroTextures(this.game.assets), startTile, () => {
            this.worldAudio.playStep(this.stepSurface(this.player.currentTile()), this.player.position, 0.35);
            // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит.
            this.combat.noise(this.player.position, 0.35);
        });
        // Тела: герой не проходит сквозь NPC (расталкивание через реестр).
        this.player.setBodies(this.objects.registry);

        // Мигание героя при неуязвимости — процедурный blink (duty меняется на лету).
        this.heroMotion = new SpriteMotion(this.player.view, { blink: { period: 0.25, duty: 1 } });
        this.game.engine.fx.add(this.heroMotion);

        // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе).
        this.actors.add(this.player.view, startTile.x, startTile.y);
        this.interactViews = new InteractableViews({
            assets: this.game.assets,
            addFx: (u) => this.game.engine.fx.add(u),
            fx: this.fx
        });
        // NPC области: статичные из контента + размещения по флагам (акт 2).
        // Снапшот/клики читают this.npcs — размещения подхватываются сами.
        const placedNpcs = [...area.npcs, ...npcsForArea(area.id, (f) => this.game.state.hasFlag(f))];
        for (const def of placedNpcs) {
            const made = this.interactViews.createNpcView(def, (tx, ty) => this.tileCenter(tx, ty));
            this.actors.add(made.view, def.tile.x, def.tile.y);
            this.npcs.push({ def, view: made.view, body: made.body });
            if (!area.npcs.includes(def)) this.objects.addNpc(def);
        }

        // Интерактивные объекты: те же вьюхи в actors-слое, реакции — через Interactables.
        this.interactables = new Interactables(area.interactables ?? [], this.game.state, {
            hasItem: (id) => this.game.inventory.has(id),
            give: (id, n) => this.game.inventory.add(id, n),
            playSound: (k) => this.worldAudio.playAt(k, this.player.position, 0.6),
            showToast: (t) => this.showToast(t),
            onCustom: (name) => this.onSceneCustom(name),
            onConsumed: (def) => this.interactViews.consume(def, this.tileCenter, (s) => this.combatViews.hitBurst(s))
        });
        for (const def of area.interactables ?? []) {
            const made = this.interactViews.createInteractableView(def, (tx, ty) => this.tileCenter(tx, ty));
            this.actors.add(made.view, def.tile.x, def.tile.y);
        }

        // --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки ---
        this.combat = new CombatWorld(
            {
                map: this.map,
                events: this.game.engine.events,
                getPlayerPos: () => this.player.position,
                damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from),
                registry: this.objects.registry
            },
            (kind) => {
                // счётчики прогресса — в vars GameState
                this.game.state.setVar(VARS.kills, this.game.state.getNumber(VARS.kills) + 1);
                this.game.state.setVar(VARS.motes, this.game.state.getNumber(VARS.motes) + kind.motes);
            }
        );
        for (const s of area.enemies) {
            this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y), s.patrol ? { patrol: s.patrol } : undefined);
        }
        // Слой UI-эффектов (тосты) поверх uiRoot.
        this.uiFx = new FxLayer();
        this.game.renderer.uiRoot.addChild(this.uiFx);
        this.game.engine.fx.add(this.uiFx);
        this.combatViews = new CombatViews(
            this.combat,
            this.actors,
            enemyTextures(this.game.assets),
            this.world,
            this.fx
        );

        // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира (в CombatFlow).

        // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя).
        this.fauna = new FaunaSystem(
            this.actors,
            this.game.engine.events,
            data,
            (area.fauna ?? []).map((t) => tileToWorld(t.x, t.y)),
            this.objects.registry
        );
        this.fauna.setFrames(
            this.game.assets.animation('chars/fauna_sheet.json', 'fauna_deer_walk') as [Texture, Texture]
        );
        // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона
        // (через слой эффектов: тик и зачистка — сами; раньше эмиттер не тикался).
        this.eventOffs.push(
            this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => {
                const s = worldToScreen(origin.x, origin.y);
                this.fx.burst(6, { x: s.x, y: s.y - 8 }, emberPreset(0x8a8a96, {
                    blend: 'normal',
                    fadeIn: 0,
                    lifetime: [1.2, 2.2],
                    velocity: { x: [-6, 6], y: [-14, -8] },
                    seed: ((origin.x * 13 + origin.y) | 0) || 1
                }));
            })
        );

        this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, {
            width: Game.VIRTUAL_W,
            height: Game.VIRTUAL_H,
            margin: 8,
            typewriter: 40,
            moodColors: { sad: 0x9aaad8, angry: 0xd89a9a, warm: 0xd8c79a }
        });
        this.dialogue.onDialogueFinished = () => this.onDialogueFinished();
        this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5);
        this.dialogue.registerSink({
            giveItem: (id, count) => this.game.inventory.add(id, count),
            takeItem: (id, count) => this.game.inventory.remove(id, count),
            playSound: (key) => void this.game.audio.play(key, 0.8),
            showToast: (text) => this.showToast(text),
            custom: (name) => {
                // Сюжетные эффекты — по завершении диалога (кат-сцена не рвёт реплику).
                this.pendingCustom.add(name);
            }
        });

        // Квест «Три цветка»: сбор/посадка/кат-сцена — свои изменения тайлов.
        this.flowers = new FlowerQuest(
            this.game,
            this.map,
            this.layerTilePos,
            (tx, ty) => this.tileCenter(tx, ty),
            (s) => this.combatViews.hitBurst(s),
            (t) => this.showToast(t),
            this.cutscene
        );

        // Квест резонатора (акт 2): сбор тона — интерактивы рощи, сдача — катсцена.
        this.resonator = new ResonatorQuest(
            this.game,
            (tx, ty) => this.tileCenter(tx, ty),
            (s) => this.combatViews.hitBurst(s),
            (t) => this.showToast(t),
            this.cutscene
        );

        // Маршрутизация взаимодействия (кнопка действия) и step-переходов.
        this.router = new InteractionRouter({
            game: this.game,
            map: this.map,
            area,
            player: this.player,
            interactables: this.interactables,
            objects: this.objects,
            disarmTile,
            callbacks: {
                showToast: (t) => this.showToast(t),
                playUiClick: () => playSfx(this.game.audio, 'sfx/ui_click', 0.4),
                talkTo: (d) => this.talkTo(d),
                collectFlower: (x, y) => this.flowers.collectFlower(x, y),
                useTransition: (d) => this.useTransition(d)
            }
        });

        // Атмосфера локации: пепел/мотыли/дымка, зоны наката, виньетка.
        this.atmosphere = new Atmosphere({
            game: this.game,
            area,
            worldRoot: this.game.renderer.worldRoot,
            uiRoot: this.game.renderer.uiRoot,
            addFx: (u) => this.game.engine.fx.add(u),
            player: this.player,
            heroHp: () => this.flow.playerCombat.hp,
            vignette: {
                level: () => this.lighting.vignetteLevel,
                set: (level, fade) => this.lighting.setVignette(level, fade)
            },
            showToast: (t) => this.showToast(t)
        });

        // Аудио локации: roomtone, слои, шорохи, музыка угрозы.
        this.locationAudio = new LocationAudio({
            audio: this.game.audio,
            worldAudio: this.worldAudio,
            area,
            layerTilePos: this.layerTilePos,
            heroPos: () => this.player.position,
            playTheme: (key) => this.game.playTheme(key),
            threat: () => this.flow.threat()
        });

        // Освещение: ambient области + статические источники (очаг, окна, лампа).
        this.lightView = new Lighting({
            width: Game.VIRTUAL_W,
            height: Game.VIRTUAL_H,
            renderer: this.game.renderer,
            maxLights: MAX_LIGHTS
        });
        this.game.renderer.lightRoot.addChild(this.lightView);
        this.lighting = new GameLighting({
            area,
            lighting: this.lightView,
            layerTilePos: this.layerTilePos,
            camera: this.camera,
            heroPos: () => this.player.position,
            hasFlag: (f) => this.game.state.hasFlag(f),
            timeHours: () => this.game.clock.hours,
            lamp: HERO_LAMP
        });

        // HUD: название локации, подсказка управления, счётчик мотов.
        this.hud = new LocationHud(area.name);
        this.game.renderer.uiRoot.addChild(this.hud.view);

        // Боевая обвязка героя: ввод атаки, резонанс, урон/респаун, hp-бар.
        this.flow = new CombatFlow(
            {
                game: this.game,
                combat: this.combat,
                combatViews: this.combatViews,
                player: this.player,
                lighting: this.lighting,
                camera: this.camera,
                fx: this.fx,
                spawn: area.spawn,
                inHazard: () => this.atmosphere.inHazard !== null,
                snapCamera: () => this.updateCameraFollow(true)
            },
            this.game.state.getNumber(VARS.hp) || PLAYER_COMBAT.maxHp
        );
        this.game.renderer.uiRoot.addChild(this.flow.healthBar);

        // Подпись «[E] Имя» над ближайшей целью действия: поверх акторов мира.
        this.hint = new InteractHint();
        this.world.addChild(this.hint.view);

        // Агентный мост сцены (снапшот/инварианты/команды).
        this.agentView = new SceneAgentView({
            game: this.game,
            map: this.map,
            area,
            player: this.player,
            playerCombat: this.flow.playerCombat,
            combat: this.combat,
            npcs: this.npcs.map((n) => n.def),
            interactables: this.interactables,
            registry: this.objects.registry,
            walkGrid: () => this.objects.walkGrid(),
            dialogue: this.dialogue,
            cutscene: this.cutscene,
            lastToast: () => this.lastToast,
            interactHint: () => this.router.interactTarget(),
            inHazard: () => this.atmosphere.inHazard,
            damagePlayer: (dmg, from) => this.flow.onPlayerDamaged(dmg, from ?? this.player.position),
            lighting: () => this.lighting,
            followCamera: (snap) => this.updateCameraFollow(snap)
        });

        // Камера: следим за героем; границы — мировой прямоугольник карты (юниты).
        this.camera.bounds = this.map.worldBounds;
        // Камера-«окно»: герой ходит в центральной зоне свободно, у виртуальной
        // границы экрана толкает камеру (движение остаётся плавным).
        this.camera.deadZonePx = { width: 180, height: 120 };
        this.updateCameraFollow(true);

        // Тач-джойстик: перехватывает касания (мышь проходит насквозь).
        this.joystick = new VirtualJoystick({ screen: { width: Game.VIRTUAL_W, height: Game.VIRTUAL_H } });
        this.joystick.eventMode = 'none'; // включается при первом касании
        this.game.renderer.uiRoot.addChild(this.joystick);

        // Дебаг-оверлей (F3).
        this.debug = new DebugOverlay(this.game.engine.fpsMeter, false);
        this.game.renderer.uiRoot.addChild(this.debug.view);

        // Дебаг спрайта героя (P): текущий кадр, увеличенный с пиксельной сеткой.
        this.charDebug = new SpriteDebugView({ zoom: 8 });
        this.charDebug.view.position.set(390, 120);
        this.game.renderer.uiRoot.addChild(this.charDebug.view);
    }

    enter(): void {
        console.log('[location]', this.area.id); // маяк для смоук-тестов
        // Амбиент локации (кроссфейд; тот же ключ не перезапускается, '' — тишина).
        this.game.playAmbience(this.area.ambience ?? '');
        this.game.playTheme(this.area.theme ?? '');
        // Файловые лупы области — в фоне (сцена загрузки декодировала только стартовую).
        this.game.preloadAudio(areaAudioKeys(this.area));
        this.locationAudio.startRoomTone();
        // Первая подсказка сбора: как брать лунные колокольчики (один раз).
        if (this.area.id === 'ponds' && !this.game.state.hasFlag(FLAGS.hint_bells)) {
            this.game.state.setFlag(FLAGS.hint_bells);
            this.showToast('Лунные колокольчики в земле: подойди и нажми E.');
        }
    }

    exit(): void {
        for (const off of this.eventOffs) off();
        this.eventOffs = [];
        this.audioOff();
        this.worldAudio.setLayers([]); // лупы локальных слоёв: иначе звучат поверх следующей сцены
        this.locationAudio.exit();
        this.dialogue.destroy();
        this.fauna.exit();
        this.combatViews.exit();
        this.flow.exit();
        this.fx.destroy({ children: true });
        this.uiFx.destroy({ children: true });
        this.world.destroy({ children: true });
        this.atmosphere.destroy();
        this.lighting.destroy();
        this.lightView.destroy({ children: true });
        this.hud.destroy();
        this.hint.destroy();
        this.joystick.destroy({ children: true });
        this.debug.view.destroy({ children: true });
        this.charDebug.view.destroy({ children: true });
    }

    update(dt: number): void {
        this.map.update(dt); // анимированные тайлы (вода) — no-op без анимаций
        this.worldAudio.update(dt);
        this.locationAudio.updateLayers();
        this.locationAudio.updateAmbient(dt);
        this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется
        this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов
        this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам
        this.atmosphere.updateVignette();
        this.fauna.update(dt);
        // Ходоки актёров: тикаются всегда (исполнители until в кат-сценах).
        if (this.walkers.size > 0) for (const w of this.walkers.values()) w.update(dt);
        // Кат-сцена и диалог живут в одном тике: сцена может вести диалог
        // посреди кат-сцены (call(talkTo) + until(диалог закрыт)). Геймплей
        // на паузе, пока активна хоть одна из них; камера при кат-сцене —
        // под контролем раннера (follow не тикаем).
        const input = this.game.engine.input;
        // Гвард «было занято до тика»: клавиша, закрывшая диалог (advance/attack
        // мульти-биндинг), не должна в тот же тик ударить/переоткрыть разговор.
        const wasBusy = this.cutscene.active || this.dialogue.active;
        if (this.cutscene.active) this.cutscene.update(dt);
        if (this.dialogue.active) {
            // Во время диалога up/down листают варианты, клик/пробел — дальше/выбор.
            this.dialogue.update(dt);
            if (input.isActionJustPressed('up')) this.dialogue.moveCursor(-1);
            if (input.isActionJustPressed('down')) this.dialogue.moveCursor(1);
            if (input.getPointer().justPressed || input.isActionJustPressed('advance')) {
                this.dialogue.advance();
            }
        }
        if (this.cutscene.active || this.dialogue.active) return;
        if (this.game.engine.scenes.transitioning) return;

        if (input.isActionJustPressed('menu')) {
            this.saveAndExit();
            return;
        }

        if (input.isActionJustPressed('inventory')) {
            playSfx(this.game.audio, 'sfx/ui_click');
            void this.game.scenes.push(
                new InventoryScene(this.game, () => void this.game.scenes.pop()),
                { duration: 0.2 }
            );
            return;
        }

        if (input.isActionJustPressed('debug')) {
            this.debug.view.visible = !this.debug.view.visible;
        }
        if (input.isActionJustPressed('debugChar')) {
            this.charDebug.view.visible = !this.charDebug.view.visible;
        }
        if (input.isActionJustPressed('debugZoom')) {
            // Отладка зума: целые уровни 1 <-> 2; snap перекрывает dead zone.
            this.camera.setZoom(this.camera.zoomLevel > 1 ? 1 : 2);
            this.updateCameraFollow(true);
        }

        // --- ввод боя: тап = короткий удар, удержание = заряд резонанса ---
        if (!wasBusy && input.isActionJustPressed('attack')) {
            this.flow.onAttackPressed();
        }
        if (!wasBusy && input.isActionJustReleased('attack')) {
            this.flow.onAttackReleased();
        }
        // --- кнопка действия: ближайшая цель в радиусе (E / геймпад A) ---
        if (!wasBusy && input.isActionJustPressed('interact')) {
            this.router.resolveInteract();
        }

        // Касание отдаём джойстику (тач-устройство: движение стиком).
        if (input.getPointer().justPressed && input.getPointer().isTouch) {
            this.joystick.eventMode = 'static';
        }

        // --- боевой мир ---
        this.combat.update(dt);
        this.combatViews.sync(dt);
        this.locationAudio.updateMusic(dt);
        this.flow.update(dt);

        // Мигание героя при неуязвимости: duty blink-оживителя живой.
        this.heroMotion.params.blink!.duty = this.flow.playerCombat.invuln ? 0.5 : 1;

        const mv = this.moveVector();
        if (mv.x !== 0 || mv.y !== 0) {
            this.player.moveFree(mv, dt); // нулевой вектор в moveFree = stop() — не вызываем
        } else {
            this.player.update(dt);
        }
        const tile = this.player.currentTile();
        this.hud.setMotes(this.game.state.getNumber(VARS.motes));
        this.hud.setQuest(questTracker(this.game.state));
        this.atmosphere.updateHazard(tile);
        this.actors.setDepth(this.player.view, tile.x, tile.y);
        this.updateCameraFollow();
        this.router.checkTransitions(tile);
        // Подпись кнопки действия: одна вьюха над ближайшей целью (или скрыта).
        this.hint.show(this.router.interactTarget());

        // Отладочные строки собираются только при видимой панели (F3): иначе —
        // три template-строки и массив каждый тик впустую.
        if (this.debug.view.visible) {
            this.debug.setLines([
                `tile ${tile.x},${tile.y}`,
                `pos ${Math.round(this.player.position.x)},${Math.round(this.player.position.y)}`,
                `hp ${this.flow.playerCombat.hp} kills ${this.game.state.getNumber(VARS.kills)}`
            ]);
        }
        this.debug.update(dt);
        if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture);
    }

    render(): void {}

    /** Вектор прямого движения: тач-джойстик приоритетнее, иначе клавиши + стик геймпада. */
    private moveVector(): Vec2 {
        if (this.joystick.active) return this.joystick.getVector();
        const input = this.game.engine.input;
        const stick = input.getLeftStick();
        const x = (input.isActionActive('right') ? 1 : 0) - (input.isActionActive('left') ? 1 : 0);
        const y = (input.isActionActive('down') ? 1 : 0) - (input.isActionActive('up') ? 1 : 0);
        return {
            x: Math.max(-1, Math.min(1, (stick?.x ?? 0) + x)),
            y: Math.max(-1, Math.min(1, (stick?.y ?? 0) + y))
        };
    }

    // ---------- агентный мост (SceneAgent → SceneAgentView) ----------

    /** Контентный слой снапшота — см. apps/game/src/agent/snapshot.ts. */
    agentSnapshot(): SnapshotLayer {
        return this.agentView.agentSnapshot();
    }

    /** Инварианты сцены: валидность контента + целостность героя/врагов. */
    agentInvariants(): Invariant[] {
        return this.agentView.agentInvariants();
    }

    /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */
    agentCommand(name: string, args?: JsonValue): JsonValue {
        return this.agentView.agentCommand(name, args);
    }

    // ---------- остальное ----------

    /** Ходок актёра по def (лениво); depth-слой обновляется при смене тайла. */
    walkerFor(def: NpcDef): ActorWalker {
        let walker = this.walkers.get(def);
        if (!walker) {
            const record = this.npcs.find((n) => n.def === def);
            walker = new ActorWalker(this.map, record!.view, def.tile, (t) =>
                this.actors.setDepth(record!.view, t.x, t.y)
            );
            this.walkers.set(def, walker);
        }
        return walker;
    }

    private updateCameraFollow(snap = false): void {
        // Непрерывное следование за ногами героя; snap — телепорты/спавн.
        const p = this.player.position;
        if (snap) this.camera.snap(p.x, p.y);
        else this.camera.follow(p.x, p.y);
        this.cullMap();
    }

    /** Culling тайлов: окно камеры в px слоя карты (экран − позиция мира, /зум). */
    private cullMap(): void {
        const root = this.game.renderer.worldRoot;
        const z = root.scale.x;
        this.map.cullToRect({
            x: -root.position.x / z,
            y: -root.position.y / z,
            width: Game.VIRTUAL_W / z,
            height: Game.VIRTUAL_H / z
        });
    }

    /** Экранные координаты центра ромба тайла (с Origin карты). */
    private tileCenter(tx: number, ty: number): { x: number; y: number } {
        const u = tileToWorld(tx, ty);
        return worldToScreen(u.x, u.y);
    }

    /** Поверхность под ногами (трава/пепел/вода/пол) — для вариативных шагов. */
    private stepSurface(tile: Vec2): StepSurface {
        const d = this.map.data;
        const id = d.tiles[tile.y * d.width + tile.x]!;
        if (id === TILES.WATER) return 'water';
        if (id === TILES.ASH || id === TILES.HOUSE || id === TILES.TOWER) return 'ash';
        if (id === TILES.FLOOR || id === TILES.PATH) return 'floor';
        return 'grass';
    }

    /** Предикаты мира для условий (сумка героя). */
    private dialogueWorld(): DialogueWorld {
        return { hasItem: (id) => this.game.inventory.count(id as ItemId) > 0 };
    }

    private talkTo(def: NpcDef): void {
        // Флаг знакомства поднимает сам граф dialogueFirst; сцена только читает.
        const met = this.game.state.hasFlag(def.flagKey);
        // Сюжетная ветка — из квест-стадии; иначе обычный диалог.
        const id = !met
            ? def.dialogueFirst
            : (questDialogueFor(this.game.state, def.id, this.dialogueWorld()) ?? def.dialogueRepeat);
        this.dialogue.start(DIALOGUES[id], id);
    }

    private onDialogueFinished(): void {
        // Сюжетные custom-эффекты графа — только когда реплики доскажены.
        if (this.pendingCustom.has(DIALOGUE_CUSTOM.plant_flowers)) {
            this.pendingCustom.delete(DIALOGUE_CUSTOM.plant_flowers);
            this.flowers.startHandInCutscene();
        }
        if (this.pendingCustom.has(DIALOGUE_CUSTOM.tune_resonator)) {
            this.pendingCustom.delete(DIALOGUE_CUSTOM.tune_resonator);
            this.resonator.startTuneCutscene();
        }
    }

    /** Сюжетные custom-эффекты интерактивов (вне диалогов). */
    private onSceneCustom(name: string): void {
        if (name === DIALOGUE_CUSTOM.lull_machine) this.startMachineLullCutscene();
    }

    /**
     * Финал акта 2 — «правильный звон»: камера спускается к устью, резонатор
     * держит верный тон, Машина засыпает. Резонанс — тот же пульс CombatWorld,
     * что и удержание атаки героя (догоняет бодрых сгустков зала).
     */
    private startMachineLullCutscene(): void {
        const at = tileToWorld(6, 2); // устье печи (свет machine_mouth)
        const s = worldToScreen(at.x, at.y);
        this.cutscene.play([
            // Спуск камеры к Машине, наезд на устье.
            { kind: 'cameraMove', x: at.x, y: at.y, seconds: 1.2, camera: this.game.camera },
            { kind: 'cameraZoom', camera: this.game.camera, z: 2, seconds: 0.8 },
            // Правильный звон: бронза держит тон, тряска, кольцо резонанса.
            {
                kind: 'burst',
                fn: () => {
                    void this.game.audio.play('sfx/bell_low', 1);
                    this.game.camera.addShake(2.5, 0.6);
                    this.combatViews.hitBurst(s);
                },
                seconds: 1
            },
            // Тот же резонанс, что у героя: доспать всем, кто не спал.
            { kind: 'call', fn: () => this.combat.resonancePulse(at) },
            // Дыхание обрывается: угольки гаснут.
            {
                kind: 'burst',
                fn: () => {
                    void this.game.audio.play('sfx/ash_die', 0.8);
                    this.combatViews.hitBurst(s);
                },
                seconds: 1.2
            },
            // Отъезд и тост-финал акта.
            { kind: 'cameraZoom', camera: this.game.camera, z: 1, seconds: 0.8 },
            { kind: 'call', fn: () => this.showToast('Машина дышит тише. Впервые за годы — тише.') }
        ]);
    }

    /** Всплывающая подсказка: появляется, держится, растворяется вверх (FxLayer). */
    private showToast(text: string): void {
        this.lastToast = { text, tick: this.game.engine.tickCount };
        // Новый тост вытесняет старый (раньше наслаивались).
        this.uiFx.clear();
        const hold = readSeconds(text); // базовые 1.4 с + ~45 мс на символ, не дольше 6
        // y ниже строки «Цель:» (LocationHud) — тост и трекер не накладываются.
        this.uiFx.floatText(text, { x: 240, y: 56 }, {
            duration: hold + 0.75,
            hold,
            fadeIn: 0.25,
            fadeOut: 0.5,
            rise: 8,
            // Перенос по ширине экрана: длинный тост в 2 строки, не за края.
            style: { size: 11, color: 0xd8c79a, wordWrapWidth: 440 }
        });
    }

    /** Куда ведёт переход: область + точка входа ('return' — откуда вошли). */
    private targetEntry(def: TransitionDef): { area: AreaDef; entry: { x: number; y: number } } {
        const t = def.target;
        if (t.kind === 'area') return { area: areaOf(t.area), entry: t.entry };
        return {
            area: areaOf(this.returnTo?.area),
            entry: this.returnTo?.entry ?? this.area.spawn
        };
    }

    /** Выполнить переход: новая сцена с fade (длительность — из определения). */
    private useTransition(def: TransitionDef): void {
        const next = this.targetEntry(def);
        playSfx(this.game.audio, 'sfx/whoosh', 0.5);
        void this.game.scenes.replace(
            new LocationScene(this.game, null, next.area, next.entry, {
                area: this.area.id,
                entry: this.player.currentTile()
            }),
            { duration: def.duration ?? 0.4 }
        );
    }

    private saveAndExit(): void {
        const pos = this.player.currentTile();
        this.game.state.setVar(VARS.hp, this.flow.playerCombat.hp);
        this.game.saves.save(
            'autosave',
            {
                version: SAVE_VERSION,
                area: this.area.id,
                pos,
                state: this.game.state.serialize(),
                items: this.game.inventory.serialize().items,
                clock: { minutes: this.game.clock.minutes },
                returnTo: this.returnTo ?? null,
                savedAt: Date.now()
            } satisfies SaveData,
            { title: 'Автосохранение', savedAt: Date.now(), version: SAVE_VERSION, extras: { area: this.area.id } }
        );
        void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 });
    }
}