import {
    Container,
    Graphics,
    IsoDepthLayer,
    ParticleEmitter,
    PixelText,
    Sprite,
    Texture,
    IsometricTileMap,
    worldToScreen,
    screenToWorld,
    worldDist,
    worldNorm,
    worldToTile,
    tileToWorld,
    inCircleW,
    findPath,
    findPathToNeighbor,
    checkFinite,
    checkRange,
    checkWalkable,
    mergeInvariants,
    DebugOverlay,
    SpriteDebugView,
    VirtualJoystick,
    type Camera,
    type Entity,
    type Invariant,
    type JsonValue,
    type Scene,
    type SnapshotLayer,
    type Vec2
} from '@rpg/engine';
import { Game } from '../Game';
import { MenuScene, SAVE_VERSION, type SaveData } from './MenuScene';
import { InventoryScene } from './InventoryScene';
import { TILES } from '../data/map';
import {
    areaOf,
    type AreaDef,
    type AreaId,
    type HazardDef,
    type TransitionDef
} from '../data/locations';
import { resolveTransition, type TransitionCtx } from '../data/transitions';
import type { NpcDef } from '../data/npcs';
import { DIALOGUES } from '../data/dialogues';
import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests';
import type { EnemyKindId } from '../data/enemies';
import { PlayerController, type HeroTextures } from '../systems/PlayerController';
import { FaunaSystem } from '../systems/fauna/FaunaSystem';
import { CutsceneRunner } from '@rpg/engine';
import { DialogueSystem } from '../systems/DialogueSystem';
import { CombatWorld } from '../systems/combat/CombatWorld';
import { CombatViews } from '../systems/combat/CombatViews';
import { PlayerCombat } from '../systems/combat/PlayerCombat';
import { HealthBar } from '../systems/combat/HealthBar';
import { PLAYER_COMBAT } from '../systems/combat/stats';
import type {
    HeroSnapshot,
    EnemySnapshot,
    NpcSnapshot,
    TransitionSnapshot,
    LocationSnapshot
} from '../agent/snapshot';

/**
 * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc.
 */
export class LocationScene implements Scene {
    private world = new Container();
    private map: IsometricTileMap;
    private player: PlayerController;
    private dialogue: DialogueSystem;
    private npcs: { def: NpcDef; view: Container }[] = [];
    private actors = new IsoDepthLayer();
    private hint: Container;
    private ash: ParticleEmitter;
    private locationLabel: PixelText;
    private camera: Camera;

    // --- бой ---
    private combat: CombatWorld;
    private combatViews: CombatViews;
    private playerCombat: PlayerCombat;
    private healthBar: HealthBar;
    /** Индикатор заряда резонанса (под героем). */
    private chargeRing: Graphics;
    /** Цель, выбранная кликом по врагу (авто-подход и удар). */
    private target: Entity | null = null;
    private repathTimer = 0;
    /** Отложенное взаимодействие: сработает, когда герой подойдёт. */
    private pendingInteraction:
        | { kind: 'talk'; def: NpcDef }
        | { kind: 'flower'; x: number; y: number }
        | { kind: 'transition'; def: TransitionDef; tile: { x: number; y: number } }
        | null = null;
    /** Таймер мигания при неуязвимости (свой, не боевой). */
    private blinkT = 0;
    /** Оверлей дымки (зоны наката). Виден только в низинах. */
    private fog: Graphics;
    /** Пейзажная фауна (безгласные олени). */
    private fauna: FaunaSystem;
    /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */
    private cutscene = new CutsceneRunner();
    /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */
    private inHazard: HazardDef | null = null;
    /** Тач-джойстик (активен только для касаний). */
    private joystick: VirtualJoystick;
    private debug: DebugOverlay;
    private charDebug: SpriteDebugView;
    /** Последний тост (текст + тик) — канал текста для агентного моста. */
    private lastToast: { text: string; tick: number } | null = null;
    /**
     * Тайл входа в область: step-переходы на нём глушатся, пока герой
     * с него не ушёл (иначе вошёл в дверь — и немедленно вылетел обратно).
     */
    private disarmTile: { x: number; y: number } | null = null;
    /** Предыдущий тайл героя (чтобы тост «заперто» не спамил каждый тик). */
    private prevStepTile: { x: number; y: number } | null = null;

    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, this.tileTextures());
        this.world.addChild(this.map.view, this.actors);
        this.game.renderer.worldRoot.addChild(this.world);

        const startTile = entry ?? save?.pos ?? area.spawn;
        this.disarmTile = entry ? { ...startTile } : null;
        if (save?.state) this.game.state.load(save.state);
        this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => {
            void this.game.audio.play('sfx/step', 0.35);
        });

        // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе).
        this.actors.add(this.player.view, startTile.x, startTile.y);
        for (const def of area.npcs) {
            const view = this.makeNpcView(def);
            this.actors.add(view, def.tile.x, def.tile.y);
            this.npcs.push({ def, view });
        }

        // --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки ---
        this.combat = new CombatWorld(
            {
                map: this.map,
                events: this.game.engine.events,
                audio: this.game.audio,
                getPlayerPos: () => this.player.position,
                damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from)
            },
            (kind) => {
                // счётчики прогресса — в vars GameState
                this.game.state.setVar('kills', this.game.state.getNumber('kills') + 1);
                this.game.state.setVar('motes', this.game.state.getNumber('motes') + kind.motes);
            }
        );
        for (const s of area.enemies) {
            this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y));
        }
        this.combatViews = new CombatViews(this.combat, this.actors, this.enemyTextures(), this.world);

        // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира.
        this.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => {
            const en = this.combat.enemies.get(entity);
            if (!en) return;
            this.combatViews.flashEnemy(entity);
            const s = worldToScreen(en.pos.x, en.pos.y);
            if (en.brain.dead) this.combatViews.deathBurst(s);
            else this.combatViews.hitBurst(s);
        });

        // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя).
        this.fauna = new FaunaSystem(
            this.actors,
            this.game.engine.events,
            data,
            (area.fauna ?? []).map((t) => tileToWorld(t.x, t.y))
        );
        this.fauna.setBlocked(data.blocked);
        this.fauna.setFrames(
            this.game.assets.frames('chars/fauna_sheet.json', 'fauna_deer') as [Texture, Texture]
        );
        // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона.
        this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => {
            const s = worldToScreen(origin.x, origin.y);
            const fx = ParticleEmitter.oneShot(6, {
                color: 0x8a8a96,
                rate: 0,
                lifetime: [1.2, 2.2],
                velocity: { x: [-6, 6], y: [-14, -8] },
                size: 1,
                seed: ((origin.x * 13 + origin.y) | 0) || 1
            });
            fx.position.set(s.x, s.y - 8);
            this.world.addChild(fx);
        });

        const savedHp = this.game.state.getNumber('hp') || PLAYER_COMBAT.maxHp;
        this.playerCombat = new PlayerCombat(savedHp);
        this.healthBar = new HealthBar();
        this.healthBar.setHp(this.playerCombat.hp);
        this.game.renderer.uiRoot.addChild(this.healthBar);

        this.chargeRing = new Graphics();
        this.game.renderer.worldRoot.addChild(this.chargeRing);

        this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, {
            width: 480,
            height: 270,
            margin: 8
        });
        this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id);
        this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5);

        // Атмосфера локации: пепел на лугах, туман над прудами.
        this.ash =
            area.ambience === 'fog'
                ? new ParticleEmitter({
                      color: 0x7a7a88,
                      rate: 6,
                      lifetime: [5, 10],
                      velocity: { x: [-3, 3], y: [-1, 1] },
                      size: 2,
                      spawnArea: { width: 560, height: 340 },
                      seed: 20260906
                  })
                : new ParticleEmitter({
                      color: 0x666677,
                      rate: 5,
                      lifetime: [4, 9],
                      velocity: { x: [-9, -3], y: [-2, 2] },
                      size: 1,
                      spawnArea: { width: 520, height: 300 },
                      seed: 20260905
                  });
        this.ash.position.set(240, 120);
        this.game.renderer.worldRoot.addChild(this.ash);

        // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще).
        this.fog = new Graphics().rect(0, 0, 480, 270).fill(0x5a6a78);
        this.fog.alpha = 0;
        this.game.renderer.uiRoot.addChildAt(this.fog, 0);

        // Название локации в правом верхнем углу.
        const name = new PixelText({ text: area.name, size: 11, color: 0x999988 });
        name.anchor.set(1, 0);
        name.position.set(474, 4);
        this.game.renderer.uiRoot.addChild(name);
        this.locationLabel = name;

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

        this.hint = new Container();
        const text = new PixelText({
            text: 'клик — идти/атаковать · Space — удар (удержать — резонанс) · I — сумка · Esc — меню',
            size: 10,
            color: 0x999988
        });
        // Под сердечками (HealthBar занимает y≈13..18).
        text.position.set(6, 23);
        this.hint.addChild(text);
        this.game.renderer.uiRoot.addChild(this.hint);

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

        // Дебаг-оверлей (F3).
        this.debug = new DebugOverlay(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.playMusic(this.area.music);
    }

    exit(): void {
        this.fauna.exit();
        this.combatViews.exit();
        this.ash.clear();
        this.world.destroy({ children: true });
        this.ash.destroy();
        this.fog.destroy();
        this.healthBar.destroy({ children: true });
        this.hint.destroy({ children: true });
        this.locationLabel.destroy({ children: true });
        this.joystick.destroy({ children: true });
        this.debug.view.destroy({ children: true });
        this.charDebug.view.destroy({ children: true });
    }

    update(dt: number): void {
        this.ash.update(dt);
        this.fauna.update(dt);
        // Кат-сцена: мир на паузе, камера под контролем раннера.
        if (this.cutscene.active) {
            this.cutscene.update(dt);
            return;
        }
        const input = this.game.engine.input;
        if (this.dialogue.active) {
            // Во время диалога клик/пробел только листают реплики.
            if (input.getPointer().justPressed || input.isActionJustPressed('advance')) {
                this.dialogue.advance();
            }
            return;
        }
        if (this.game.engine.scenes.transitioning) return;

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

        if (input.isActionJustPressed('inventory')) {
            void this.game.audio.play('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('attack')) {
            this.playerCombat.startCharge();
        }
        if (input.isActionJustReleased('attack') && this.playerCombat.isCharging()) {
            this.doPlayerRelease();
        }

        const pointer = input.getPointer();
        if (pointer.justPressed) {
            if (pointer.isTouch) {
                // Касание отдаём джойстику (мышь идёт в мир обычным порядком).
                this.joystick.eventMode = 'static';
            } else {
                this.handleWorldClick(pointer.x, pointer.y);
            }
        }

        // --- боевой мир ---
        this.combat.update(dt);
        this.combatViews.sync(dt);
        this.playerCombat.update(dt);
        this.updateTarget(dt);
        this.updateChargeRing();
        this.healthBar.setHp(this.playerCombat.hp);

        // Мигание героя при неуязвимости
        this.blinkT += dt;
        this.player.view.alpha =
            this.playerCombat.invuln && Math.floor(this.blinkT * 8) % 2 === 0 ? 0.4 : 1;

        if (this.joystick.active) {
            this.player.moveFree(this.joystick.getVector(), dt);
        } else {
            this.player.update(dt);
        }
        this.resolvePendingInteraction();
        const tile = this.player.currentTile();
        this.updateHazard(tile);
        this.actors.setDepth(this.player.view, tile.x, tile.y);
        this.updateCameraFollow();
        this.checkTransitions(tile);

        this.debug.setLines([
            `tile ${tile.x},${tile.y}`,
            `pos ${Math.round(this.player.position.x)},${Math.round(this.player.position.y)}`,
            `hp ${this.playerCombat.hp} kills ${this.game.state.getNumber('kills')}`
        ]);
        this.debug.update(dt);
        if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture);
    }

    /**
     * Зона наката под ногами: без маски герой еле идёт и рискует голосом,
     * звон здесь «проверяет воздух» — пепел на секунду видно волной.
     */
    private updateHazard(tile: { x: number; y: number }): void {
        const hazard =
            (this.area.hazards ?? []).find((h) =>
                h.tiles.some((t) => t.x === tile.x && t.y === tile.y)
            ) ?? null;
        const masked = hazard !== null && this.game.inventory.has(hazard.requiresItem ?? 'cloth');
        this.player.speedMul = hazard !== null && !masked ? 0.5 : 1;

        // Вход в низину: предупреждение один раз за вход.
        if (hazard !== null && this.inHazard === null) {
            this.showToast(
                masked ? `${hazard.name}: полотно держит — но пепел у самых губ.` : `${hazard.name}! Дышать поверх — потерять голос.`
            );
            void this.game.audio.play('sfx/ash_hiss', 0.5);
        }
        this.inHazard = hazard;

        // Дымка: плотная без маски, лёгкая с ней, вне низин её нет.
        this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28;
    }

    /** Контекст условий перехода: флаги GameState, сумка героя. */
    private transitionCtx(): TransitionCtx {
        return {
            hasFlag: (f) => this.game.state.hasFlag(f),
            hasItem: (id) => this.game.inventory.has(id)
        };
    }

    /** Куда ведёт переход: область + точка входа ('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);
        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 }
        );
    }

    /**
     * Единый механизм переходов (step-триггер): герой наступил на тайл.
     * На тайле входа step-переходы дисармованы, пока герой с него не ушёл.
     */
    private checkTransitions(tile: { x: number; y: number }): void {
        const fresh =
            this.prevStepTile === null ||
            this.prevStepTile.x !== tile.x ||
            this.prevStepTile.y !== tile.y;
        this.prevStepTile = tile;
        // Ушёл с тайла входа — дисарм снят, переходы снова работают.
        if (this.disarmTile && (tile.x !== this.disarmTile.x || tile.y !== this.disarmTile.y)) {
            this.disarmTile = null;
        }

        const pick = resolveTransition(
            this.area.transitions,
            tile,
            'step',
            this.transitionCtx(),
            this.disarmTile
        );
        if (!pick) return;
        if (!pick.ok) {
            // «Заперто» — не спамим: только при приходе на тайл.
            if (fresh) {
                this.showToast(pick.lockedText);
                void this.game.audio.play('sfx/ui_click', 0.4);
            }
            return;
        }
        this.useTransition(pick.def);
    }

    render(): void {}

    // ---------- бой ----------

    /** Отпускание атаки: резонанс (долгое) или короткий удар. */
    private doPlayerRelease(): void {
        const action = this.playerCombat.release();
        const from = this.player.position;
        if (action === 'resonance') {
            const slept = this.combat.resonancePulse(from);
            const s = worldToScreen(from.x, from.y);
            this.combatViews.resonanceRing(s);
            this.game.camera.addShake(1.5, 0.25);
            this.game.state.setVar('resonances', this.game.state.getNumber('resonances') + (slept > 0 ? 1 : 0));
            // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел.
            if (this.inHazard !== null) {
                this.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 });
                this.combatViews.hitBurst({ x: s.x + 14, y: s.y + 6 });
                void this.game.audio.play('sfx/ash_hiss', 0.7);
            }
        } else if (action === 'attack') {
            this.combat.playerConeAttack(from, this.player.dirVector);
        }
    }

    /** Авто-подход к выбранной цели и удар при входе в конус. */
    private updateTarget(dt: number): void {
        if (this.target === null) return;
        const en = this.combat.enemies.get(this.target);
        if (!en || en.brain.dead) {
            this.target = null;
            return;
        }
        const from = this.player.position;
        const dist = worldDist(from, en.pos);
        if (dist <= PLAYER_COMBAT.attackStop) {
            // В зоне — стоим и бьём по кулдауну
            this.player.stop();
            if (this.playerCombat.attackCd.trigger()) {
                this.combat.playerConeAttack(from, worldNorm(en.pos.x - from.x, en.pos.y - from.y));
            }
            this.target = null; // цель «снята» ударом; дальше игрок решает сам
            return;
        }
        // Перестраиваем путь к цели пару раз в секунду
        this.repathTimer -= dt;
        if (this.repathTimer <= 0) {
            this.repathTimer = 0.5;
            this.player.onWorldClick(en.pos.x, en.pos.y);
        }
    }

    /** Индикатор заряда резонанса под героем. */
    private updateChargeRing(): void {
        this.chargeRing.clear();
        if (!this.playerCombat.isCharging()) return;
        const u = this.player.position;
        const p = worldToScreen(u.x, u.y);
        const k = Math.min(1, this.playerCombat.chargeTime / PLAYER_COMBAT.resonanceCharge);
        const color = k >= 1 ? 0xd99a32 : 0x888899;
        this.chargeRing
            .circle(p.x, p.y - 2, 6 + 4 * k)
            .stroke({ color, width: 1, alpha: 0.9 });
    }

    /** Урон герою: неуязвимость внутри PlayerCombat; здесь отброс, шейк, звук, смерть. */
    private onPlayerDamaged(damage: number, from: Vec2): void {
        const applied = this.playerCombat.takeDamage(damage);
        if (applied <= 0) return;
        this.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp });
        void this.game.audio.play('sfx/hurt', 0.8);
        this.game.camera.addShake(2.5, 0.3);
        // Отброс от источника урона (направление — по метрике проекции)
        const dx = this.player.position.x - from.x;
        const dy = this.player.position.y - from.y;
        this.player.applyKnockback(worldNorm(dx, dy), PLAYER_COMBAT.knockback);

        if (this.playerCombat.dead) {
            this.game.state.setVar('deaths', this.game.state.getNumber('deaths') + 1);
            this.playerCombat.revive();
            // Респаун на стартовом тайле локации
            this.player.teleportTo(this.area.spawn);
            this.updateCameraFollow(true);
            this.healthBar.setHp(this.playerCombat.hp);
        }
    }

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

    /** Контентный слой снапшота — см. apps/game/src/agent/snapshot.ts. */
    agentSnapshot(): SnapshotLayer {
        const hero: HeroSnapshot = {
            tile: this.player.currentTile(),
            pos: this.player.position,
            hp: this.playerCombat.hp,
            maxHp: PLAYER_COMBAT.maxHp,
            facing: this.player.dir,
            moving: this.player.moving,
            invuln: this.playerCombat.invuln,
            inHazard: this.inHazard?.name ?? null
        };
        const enemies: EnemySnapshot[] = [];
        for (const [, en] of this.combat.enemies) {
            enemies.push({
                kind: en.kind.id,
                state: en.brain.state,
                hp: en.hp,
                pos: en.pos,
                asleep: en.brain.asleep,
                dead: en.brain.dead
            });
        }
        const npcs: NpcSnapshot[] = this.npcs.map(({ def }) => ({
            id: def.id,
            name: def.name,
            tile: def.tile,
            met: this.game.state.hasFlag(def.flagKey)
        }));
        const transitions: TransitionSnapshot[] = this.area.transitions.map((t) => ({
            tile: t.tile,
            to: t.target.kind === 'area' ? t.target.area : '<return>',
            trigger: t.trigger ?? 'step',
            label: t.label ?? null
        }));
        const layer: LocationSnapshot = {
            scene: 'location',
            area: this.area.id,
            areaName: this.area.name,
            hero,
            enemies,
            npcs,
            transitions,
            dialogue: this.dialogue.agentState,
            cutscene: { active: this.cutscene.active },
            lastToast: this.lastToast
        };
        return layer as unknown as SnapshotLayer;
    }

    /** Инварианты сцены: валидность контента + целостность героя/врагов. */
    agentInvariants(): Invariant[] {
        const where = 'scene/LocationScene';
        const heroTile = this.player.currentTile();
        const heroPos = this.player.position;
        const enemyPos: Record<string, number> = {};
        const enemyChecks: Invariant[] = [];
        for (const [e, en] of this.combat.enemies) {
            enemyPos[`enemy#${e}.x`] = en.pos.x;
            enemyPos[`enemy#${e}.y`] = en.pos.y;
            if (!en.brain.dead && !this.map.isWalkable(Math.floor(en.pos.x), Math.floor(en.pos.y))) {
                enemyChecks.push({
                    id: 'enemy-in-wall',
                    severity: 'error',
                    message: `${en.kind.id} в непроходимом тайле (${en.pos.x},${en.pos.y})`,
                    where
                });
            }
        }
        return mergeInvariants(
            checkFinite(
                { 'hero.pos.x': heroPos.x, 'hero.pos.y': heroPos.y, ...enemyPos },
                where
            ),
            checkRange('hero.hp', this.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where),
            checkWalkable('герой', heroTile, this.map, where),
            enemyChecks
        );
    }

    /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */
    agentCommand(name: string, args?: JsonValue): JsonValue {
        const a = (args ?? {}) as { x?: number; y?: number; id?: string; value?: number | string | boolean; flag?: string; index?: number };
        switch (name) {
            case 'scene:sleepAll':
                for (const [, en] of this.combat.enemies) en.brain.putToSleep(9999);
                return true;
            case 'scene:give':
                if (typeof a.id !== 'string') return null;
                this.game.inventory.add(a.id);
                return true;
            case 'scene:setVar':
                if (typeof a.id !== 'string') return null;
                this.game.state.setVar(a.id, a.value ?? 0);
                return true;
            case 'scene:setFlag':
                if (typeof a.flag !== 'string') return null;
                this.game.state.setFlag(a.flag);
                return true;
            case 'scene:teleport': {
                if (typeof a.x !== 'number' || typeof a.y !== 'number') return null;
                this.player.teleportTo({ x: a.x, y: a.y });
                this.updateCameraFollow(true);
                return true;
            }
            case 'scene:route': {
                if (typeof a.x !== 'number' || typeof a.y !== 'number') return null;
                const path = findPath(this.map, this.player.currentTile(), { x: a.x, y: a.y }, false);
                return path ?? null;
            }
            case 'scene:pickChoice':
                if (typeof a.index !== 'number') return null;
                this.dialogue.pickChoice(a.index);
                return true;
            case 'scene:skipCutscene': {
                if (!this.cutscene.active) return false;
                while (this.cutscene.active) this.cutscene.update(0.5);
                return true;
            }
            default:
                return null;
        }
    }

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

    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);
    }

    /** Текстуры тайлов из загруженных ассетов (id -> Texture). */
    private tileTextures(): Map<number, Texture> {
        const a = this.game.assets;
        return new Map([
            [TILES.GRASS, a.texture('tiles/grass')],
            [TILES.PATH, a.texture('tiles/path')],
            [TILES.WATER, a.texture('tiles/water')],
            [TILES.ASH, a.texture('tiles/ash')],
            [TILES.BELLFLOWER, a.texture('tiles/bellflower')],
            [TILES.TREE, a.texture('tiles/tree')],
            [TILES.TOWER, a.texture('tiles/tower')],
            [TILES.HOUSE, a.texture('tiles/house')],
            [TILES.FLOOR, a.texture('tiles/floor')],
            [TILES.WALL, a.texture('tiles/wall')],
            [TILES.WELL, a.texture('tiles/well')]
        ]);
    }

    /** Кадры сгустков из атласа chars/clumps_sheet. */
    private enemyTextures(): Map<EnemyKindId, [Texture, Texture]> {
        const frames = (prefix: string) =>
            this.game.assets.frames('chars/clumps_sheet.json', prefix) as [Texture, Texture];
        return new Map([
            ['crawler', frames('clump_crawler')],
            ['spitter', frames('clump_spitter')],
            ['heavy', frames('clump_heavy')]
        ]);
    }

    /** Кадры героя из атласа chars/hero_sheet. */
    private heroTextures(): HeroTextures {
        const frames = (prefix: string) =>
            this.game.assets.frames('chars/hero_sheet.json', prefix) as [Texture, Texture];
        return {
            down: frames('hero_down'),
            up: frames('hero_up'),
            side: frames('hero_side')
        };
    }

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

    private handleWorldClick(px: number, py: number): void {
        // Координаты указателя (виртуальные px) -> мировые юниты (учёт камеры).
        const p = screenToWorld(
            px - this.game.renderer.worldRoot.position.x,
            py - this.game.renderer.worldRoot.position.y
        );
        const worldX = p.x;
        const worldY = p.y;

        const clicked = worldToTile(worldX, worldY, this.map.data.width, this.map.data.height);

        // Клик по тайлу NPC — диалог (в радиусе сразу, издалека — подходим).
        if (clicked) {
            const npc = this.npcs.find(
                (n) => n.def.tile.x === clicked.x && n.def.tile.y === clicked.y
            );
            if (npc) {
                this.requestTalk(npc.def);
                return;
            }

            // Клик-переходы (колодцы, двери) — до маршрутизации движения;
            // издалека герой сначала подходит к тайлу-триггеру.
            const pick = resolveTransition(
                this.area.transitions,
                clicked,
                'click',
                this.transitionCtx(),
                null
            );
            if (pick) {
                if (!pick.ok) {
                    this.showToast(pick.lockedText);
                    void this.game.audio.play('sfx/ui_click', 0.4);
                } else {
                    this.requestTransition(pick.def);
                }
                return;
            }

            // Клик по лунному колокольчику (пруды) — собрать цветок.
            if (this.area.id === 'ponds' && this.tileId(clicked.x, clicked.y) === TILES.BELLFLOWER) {
                this.requestCollect(clicked.x, clicked.y);
                return;
            }
        }

        // Клик по сгустку — выбрать цель (авто-подход и удар).
        const world = { x: worldX, y: worldY };
        for (const [e, en] of this.combat.enemies) {
            if (en.brain.dead) continue;
            if (inCircleW(en.pos, en.kind.radius + 0.15, world)) {
                this.target = e;
                this.repathTimer = 0;
                return;
            }
        }

        this.target = null;
        this.pendingInteraction = null;
        this.player.onWorldClick(worldX, worldY);
    }

    /** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */
    private static readonly INTERACT_RANGE = 1.5;

    private inInteractRange(tx: number, ty: number): boolean {
        return inCircleW(this.player.position, LocationScene.INTERACT_RANGE, tileToWorld(tx, ty));
    }

    /**
     * Взаимодействие с NPC: в радиусе — сразу; издалека — герой идёт к краю тайла,
     * диалог начнётся на месте. force — сюжетное исключение без подхода.
     */
    private requestTalk(def: NpcDef, force = false): void {
        this.pendingInteraction = null;
        if (force || this.inInteractRange(def.tile.x, def.tile.y)) {
            this.talkTo(def);
            return;
        }
        const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile);
        if (path) {
            this.pendingInteraction = { kind: 'talk', def };
            this.player.followPath(path);
        }
    }

    /** Сбор цветка: в радиусе — сразу, издалека — подойти и собрать. */
    private requestCollect(x: number, y: number): void {
        this.pendingInteraction = null;
        if (this.inInteractRange(x, y)) {
            this.collectFlower(x, y);
            return;
        }
        const path = findPathToNeighbor(this.map, this.player.currentTile(), { x, y });
        if (path) {
            this.pendingInteraction = { kind: 'flower', x, y };
            this.player.followPath(path);
        }
    }

    /** Клик-переход: в радиусе — сразу; издалека — подойти и сработать. */
    private requestTransition(def: TransitionDef): void {
        this.pendingInteraction = null;
        if (this.inInteractRange(def.tile.x, def.tile.y)) {
            this.useTransition(def);
            return;
        }
        const path = findPathToNeighbor(this.map, this.player.currentTile(), def.tile);
        if (path) {
            this.pendingInteraction = { kind: 'transition', def, tile: def.tile };
            this.player.followPath(path);
        }
    }

    /** Сработать отложенным взаимодействием, когда герой остановился. */
    private resolvePendingInteraction(): void {
        if (!this.pendingInteraction || this.player.moving) return;
        const p = this.pendingInteraction;
        this.pendingInteraction = null;
        if (p.kind === 'talk') {
            if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) this.talkTo(p.def);
        } else if (p.kind === 'flower') {
            if (this.inInteractRange(p.x, p.y)) this.collectFlower(p.x, p.y);
        } else {
            // Условие могли не выполнить, пока герой шёл — перепроверяем.
            const pick = resolveTransition(
                this.area.transitions,
                p.tile,
                'click',
                this.transitionCtx(),
                null
            );
            if (pick?.ok && this.inInteractRange(p.tile.x, p.tile.y)) this.useTransition(pick.def);
        }
    }

    /** id тайла карты (для кликов по сборным объектам). */
    private tileId(x: number, y: number): number {
        return this.map.data.tiles[y * this.map.data.width + x];
    }

    /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */
    private collectFlower(x: number, y: number): void {
        this.map.setTile(x, y, TILES.GRASS);
        this.game.inventory.add('bellflower');
        const n = this.game.state.getNumber('flowers') + 1;
        this.game.state.setVar('flowers', n);
        this.game.engine.events.emit('quest:flower', { n });
        void this.game.audio.play('sfx/bell_hit', 0.6);
        const c = this.tileCenter(x, y);
        this.combatViews.hitBurst(c);
        this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`);
    }

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

    private onDialogueFinished(id: string): void {
        // Побочные эффекты — из квест-стадий реестра.
        const effect = questEffectFor(id);
        if (effect === 'plant_flowers') this.startHandInCutscene();
        if (effect === 'give_cloth') this.game.inventory.add('cloth');
    }

    /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */
    private plantFlowers(): void {
        const left = this.game.state.getNumber('flowers') - QUEST_FLOWERS;
        this.game.state.setVar('flowers', Math.max(0, left));
        const planted: [number, number][] = [
            [15, 13],
            [16, 14],
            [15, 15]
        ];
        for (const [tx, ty] of planted) {
            this.map.setTile(tx, ty, TILES.BELLFLOWER);
            this.combatViews.hitBurst(this.tileCenter(tx, ty));
        }
        // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»).
        for (const [tx, ty] of planted) {
            for (const [dx, dy] of [
                [1, 0],
                [-1, 0],
                [0, 1],
                [0, -1]
            ]) {
                const id = this.tileId(tx + dx, ty + dy);
                if (id === TILES.GRASS || id === TILES.ASH) this.map.setTile(tx + dx, ty + dy, TILES.BELLFLOWER);
            }
        }
        void this.game.audio.play('sfx/bell_low', 0.8);
        this.showToast('Цветы в земле. Поляна гудит.');
    }

    /** Кат-сцена сдачи: посадка, камера к башне, удар колокола, зелень — финал акта 1. */
    private startHandInCutscene(): void {
        const tower = tileToWorld(14, 7); // башня Звенца (юниты)
        const s = worldToScreen(tower.x, tower.y);
        this.cutscene.play([
            // Пересев: поляна гудит, герой стоит.
            { kind: 'call', fn: () => this.plantFlowers(), seconds: 0.8 },
            // Камера уходит к башне.
            { kind: 'cameraMove', x: tower.x, y: tower.y, seconds: 1.2, camera: this.game.camera },
            // Удар колокола: низкий звон, тряска, кольцо резонанса.
            {
                kind: 'burst',
                fn: () => {
                    void this.game.audio.play('sfx/bell_low', 1);
                    this.game.camera.addShake(2.5, 0.4);
                    this.combatViews.resonanceRing(s);
                },
                seconds: 1
            },
            // Зелень поднимается у подножия башни (визуальный финал акта).
            {
                kind: 'burst',
                fn: () => {
                    for (const [tx, ty] of [
                        [13, 8],
                        [15, 8],
                        [14, 9]
                    ]) {
                        if (this.tileId(tx, ty) === TILES.GRASS || this.tileId(tx, ty) === TILES.ASH) {
                            this.map.setTile(tx, ty, TILES.BELLFLOWER);
                            this.combatViews.hitBurst(this.tileCenter(tx, ty));
                        }
                    }
                },
                seconds: 1.2
            },
            // Тост-финал акта 1.
            { kind: 'call', fn: () => this.showToast('Пепел отступил у тропы. Машина дышит тише... но дышит.') }
        ]);
    }

    /** Всплывающая подсказка: появляется и растворяется над UI. */
    private showToast(text: string): void {
        this.lastToast = { text, tick: this.game.engine.tickCount };
        const toast = new PixelText({ text, size: 11, color: 0xd8c79a });
        toast.anchor.set(0.5);
        toast.position.set(240, 40);
        toast.alpha = 0;
        this.game.renderer.uiRoot.addChild(toast);
        const tweens = this.game.engine.tweens;
        tweens.to(toast, { alpha: 1 }, { duration: 0.25 });
        tweens.delay(1.6, () => {
            tweens.to(toast, { alpha: 0, y: 32 }, { duration: 0.5, onDone: () => toast.destroy() });
        });
    }

    private makeNpcView(def: NpcDef): Container {
        const view = new Container();
        const p = this.tileCenter(def.tile.x, def.tile.y);
        view.position.set(p.x, p.y);

        const sprite = new Sprite(this.game.assets.texture(`chars/${def.sprite}`));
        sprite.anchor.set(0.5, 1); // ноги в центре ромба
        view.addChild(sprite);

        // Маркер «с ним можно говорить»: восклицательный штрих.
        const marker = new Graphics();
        marker.rect(-1, -27, 2, 4).fill(0xf0d878);
        marker.rect(-1, -21, 2, 2).fill(0xf0d878);
        view.addChild(marker);
        return view;
    }

    private saveAndExit(): void {
        const pos = this.player.currentTile();
        this.game.state.setVar('hp', this.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,
            returnTo: this.returnTo ?? null,
            savedAt: Date.now()
        } satisfies SaveData);
        void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 });
    }
}
