Newer
Older
rpg / apps / game / src / scenes / LocationScene.ts
import {
    Container,
    Graphics,
    IsoDepthLayer,
    Lighting,
    ParticleEmitter,
    PixelText,
    Sprite,
    SpriteAnimator,
    SpriteMotion,
    Texture,
    IsometricTileMap,
    RATE,
    createRng,
    renderSpec,
    renderMusicStereo,
    worldToScreen,
    worldNorm,
    worldToTile,
    screenToWorld,
    tileToWorld,
    unitsToPx,
    DebugOverlay,
    SpriteDebugView,
    VirtualJoystick,
    type Camera,
    type Entity,
    type Invariant,
    type MusicHandle,
    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, DECOR, TALL_OBJECTS } from '../data/map';
import { playSfx, ROOM_TONES } from '../data/sfxSpecs';
import {
    areaOf,
    type AmbienceLayerDef,
    type AreaDef,
    type AreaId,
    type HazardDef,
    type TransitionDef
} from '../data/locations';
import { AudioSystem, type AudioLayerDef } from '../systems/AudioSystem';
import type { StepSurface } from '../systems/StepVariants';
import { InteractionRouter } from '../systems/ClickRouting';
import { SceneAgentView } from '../agent/SceneAgentView';
import type { NpcDef } from '../data/npcs';
import { DIALOGUES } from '../data/dialogues';
import { QUEST_FLOWERS, questDialogueFor } from '../data/quests';
import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects';
import type { ItemId } from '../data/items';
import { TENSION_STEM } from '../data/music';
import { FLAGS, VARS } from '../data/ids';
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 { Interactables } from '../systems/Interactables';
import { GameLighting, MAX_LIGHTS, type HeroLampDef } from '../systems/Lighting';
import { SceneObjects } from '../systems/SceneObjects';
import type { InteractableDef } from '../data/interactables';

/** Лампа героя: тёплый, тихий, слегка мерцает (сумерки мира). */
const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 };

/** Виньетка: цель при низком hp / в накате без маски и фейд перехода. */
const VIGNETTE_LOW_HP = 0.3;
const VIGNETTE_HAZARD = 0.35;
const VIGNETTE_FADE = 0.5;

/**
 * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc.
 */
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();
    private hint: Container;
    /** Атмосферные частицы (в интерьерах их нет). */
    private ash: ParticleEmitter | null;
    /** Тёплые мотыльки над лугами (светящиеся, дрейф по синусу). */
    private moths: ParticleEmitter | null;
    /** Мигание при неуязвимости — процедурный blink (duty живой). */
    private heroMotion: SpriteMotion;
    private locationLabel: PixelText;
    private camera: Camera;

    // --- бой ---
    private combat: CombatWorld;
    private combatViews: CombatViews;
    private playerCombat: PlayerCombat;
    private healthBar: HealthBar;
    /** Счётчик собранных мотов (HUD под сердечками); текст меняется по факту. */
    private moteLabel: PixelText;
    private lastMotes = -1;
    /** Индикатор заряда резонанса (под героем). */
    private chargeRing: Graphics;
    /** Маршрутизация клика/переходов/отложенных взаимодействий. */
    private router: InteractionRouter;
    /** Интерактивные объекты области (сундуки, очаги, прилавки). */
    private interactables: Interactables;
    /** Объекты сцены на движковом реестре (кто на тайле — для кликов/коллизий). */
    private objects: SceneObjects;
    private interactViews: { def: InteractableDef; view: Container; body: Graphics | Sprite; label: PixelText }[] = [];
    /** Оверлей дымки (зоны наката). Виден только в низинах. */
    private fog: Graphics;
    /** Освещение: движковый слой (lightRoot) + игровая обвязка. */
    private lightView: Lighting;
    private lighting: GameLighting;
    /** Пейзажная фауна (безгласные олени). */
    private fauna: FaunaSystem;
    /** Позиционный звук мира + события боя (звук — подпиской). */
    private worldAudio: AudioSystem;
    /** off() подписок worldAudio (снять в exit). */
    private audioOff: () => void;
    /** Tension-стем (враги wary): буфер + хендл + сглаженная громкость. */
    private stemHandle: MusicHandle | null = null;
    private stemBuf: AudioBuffer | null = null;
    private stemLevel = 0;
    /** Стем заводится (промис не вернулся) — не заводить второй. */
    private stemPending = false;
    /** Room tone интерьера (тихий гул на шине ambience). */
    private roomtoneHandle: MusicHandle | null = null;
    /** Дикий мир: редкие дальние шорохи пепла (rng сцены — детерминирован). */
    private ambientRng = createRng(20260908);
    private rustleIn = 6;
    /** Кат-сцены (раннер шагов; на время сцены геймплей на паузе). */
    private cutscene = new CutsceneRunner();
    /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */
    private inHazard: HazardDef | null = null;
    /** Текущая зона наката перекрыта полотном (для виньетки). */
    private hazardMasked = false;
    /** Тач-джойстик (активен только для касаний). */
    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, this.tileTextures());
        // Живая вода: один общий таймлайн на все клетки с 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 p = this.tileCenter(exit.tile.x, exit.tile.y);
            const glow = new Graphics();
            glow
                .moveTo(0, -8)
                .lineTo(16, 0)
                .lineTo(0, 8)
                .lineTo(-16, 0)
                .closePath()
                .stroke({ color: 0x8a6d3a, width: 1 });
            glow.position.set(p.x, p.y);
            this.world.addChildAt(glow, 1); // поверх пола карты, под акторами
            const label = new PixelText({ text: 'выход', size: 9, color: 0xd8c79a });
            label.anchor.set(0.5, 1);
            label.position.set(p.x, p.y - 12);
            this.world.addChildAt(label, 2);
        }
        // Источники амбиент-слоёв: позиции тайлов по 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);
        // Единый реестр объектов сцены: 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, this.heroTextures(), 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 (расталкивание через реестр),
        // пути строятся по grid с занятыми NPC-тайлами — A* не ведёт сквозь тело.
        this.player.setBodies(this.objects.registry);
        this.player.setWalkGrid(this.objects.walkGrid());

        // Мигание героя при неуязвимости — процедурный 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);
        for (const def of area.npcs) {
            const made = this.makeNpcView(def);
            this.actors.add(made.view, def.tile.x, def.tile.y);
            this.npcs.push({ def, view: made.view, body: made.body });
        }

        // Интерактивные объекты: те же вьюхи в 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),
            onConsumed: (def) => this.consumeView(def)
        });
        for (const def of area.interactables ?? []) {
            const made = this.makeInteractableView(def);
            this.actors.add(made.view, def.tile.x, def.tile.y);
            this.interactViews.push({ def, view: made.view, body: made.body, label: made.label });
        }

        // --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки ---
        this.combat = new CombatWorld(
            {
                map: this.map,
                events: this.game.engine.events,
                getPlayerPos: () => this.player.position,
                damagePlayer: (dmg, from) => this.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);
        }
        this.combatViews = new CombatViews(
            this.combat,
            this.actors,
            this.enemyTextures(),
            this.world,
            this.game.engine.fx
        );

        // Вспышка урона и брызги пепла — реакция вьюх на события 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);
            // Световой импульс в точке попадания (lightRoot — экранное пространство).
            const ls = this.camera.toScreen(en.pos.x, en.pos.y);
            this.lighting.pulseLight({
                x: ls.x,
                y: ls.y,
                color: 0xf2b45a,
                intensity: 0.45,
                radius: unitsToPx(1.5),
                spec: { attack: 0.02, decay: 0.2 }
            });
        });

        // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя).
        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.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(VARS.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.moteLabel = new PixelText({ text: '', size: 10, color: 0xd8c79a });
        this.moteLabel.anchor.set(1, 0);
        this.moteLabel.position.set(474, 17); // под названием локации
        this.game.renderer.uiRoot.addChild(this.moteLabel);

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

        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.router = new InteractionRouter({
            game: this.game,
            map: this.map,
            area,
            player: this.player,
            combat: this.combat,
            interactables: this.interactables,
            objects: this.objects,
            disarmTile,
            worldRootOffset: () => this.game.renderer.worldRoot.position,
            pixelTile: (px, py) => this.pixelTile(px, py),
            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.collectFlower(x, y),
                useTransition: (d) => this.useTransition(d),
                attackTarget: (from, dir) => this.attackTarget(from, dir)
            }
        });

        // Агентный мост сцены (снапшот/инварианты/команды).
        this.agentView = new SceneAgentView({
            game: this.game,
            map: this.map,
            area,
            player: this.player,
            playerCombat: this.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,
            inHazard: () => this.inHazard,
            damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from ?? this.player.position),
            lighting: () => this.lighting,
            followCamera: (snap) => this.updateCameraFollow(snap)
        });

        // Атмосфера локации: пепел на лугах, туман над прудами, в интерьере — ничего.
        this.ash =
            area.atmosphere === 'none'
                ? null
                : area.atmosphere === '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
                  });
        if (this.ash) {
            this.ash.position.set(240, 120);
            this.game.renderer.worldRoot.addChild(this.ash);
            // Пепел тикается реестром эффектов (engine.fx) — без ручного update.
            this.game.engine.fx.add(this.ash);
        }

        // Тёплые мотыльки над лугами: светятся (add), дышат по синусу, в интерьере их нет.
        this.moths =
            area.atmosphere === 'ash'
                ? new ParticleEmitter({
                      colors: [0xf0d878, 0xd8b050],
                      blend: 'add',
                      rate: 2,
                      lifetime: [3, 6],
                      velocity: { x: [-4, 4], y: [-2, 2] },
                      size: 1,
                      wobble: 8,
                      fadeIn: 0.6,
                      spawnArea: { width: 520, height: 300 },
                      seed: 20260907
                  })
                : null;
        if (this.moths) {
            this.moths.position.set(240, 120);
            this.game.renderer.worldRoot.addChild(this.moths);
            this.game.engine.fx.add(this.moths);
        }

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

        // Освещение: 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),
            nightFlag: FLAGS.evening,
            lamp: HERO_LAMP
        });

        // Название локации в правом верхнем углу.
        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: Game.VIRTUAL_W, height: Game.VIRTUAL_H } });
        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.playAmbience(this.area.ambience ?? '');
        this.game.playTheme(this.area.theme ?? '');
        this.startRoomTone();
        // Первая подсказка сбора: как брать лунные колокольчики (один раз).
        if (this.area.id === 'ponds' && !this.game.state.hasFlag(FLAGS.hint_bells)) {
            this.game.state.setFlag(FLAGS.hint_bells);
            this.showToast('Лунные колокольчики в земле: подойди и щёлкни по цветку.');
        }
    }

    /** Room tone интерьера (см. ROOM_TONES): тихий луп на шине ambience. */
    private startRoomTone(): void {
        const spec = ROOM_TONES[this.area.id];
        if (!spec) return;
        const buf = this.game.audio.createBuffer(renderSpec(spec));
        if (!buf) return; // до unlock контекста нет — тон появится при следующем входе
        void this.game.audio
            .playLoopBuffer(`roomtone/${this.area.id}`, buf, { bus: 'ambience', fade: 2, volume: 0.6 })
            .then((h) => {
                this.roomtoneHandle = h;
            });
    }

    exit(): void {
        this.audioOff();
        this.roomtoneHandle?.stop(1);
        this.roomtoneHandle = null;
        this.stemHandle?.stop(1);
        this.stemHandle = null;
        this.stemBuf = null;
        this.stemPending = false;
        this.fauna.exit();
        this.combatViews.exit();
        this.ash?.clear();
        this.world.destroy({ children: true });
        this.ash?.destroy();
        this.moths?.destroy();
        this.lighting.destroy();
        this.lightView.destroy({ children: true });
        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.map.update(dt); // анимированные тайлы (вода) — no-op без анимаций
        this.worldAudio.update(dt);
        this.updateAudioLayers();
        this.updateAmbient(dt);
        this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов
        this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам
        this.updateVignette();
        this.fauna.update(dt);
        // Кат-сцена: мир на паузе, камера под контролем раннера.
        if (this.cutscene.active) {
            this.cutscene.update(dt);
            return;
        }
        const input = this.game.engine.input;
        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();
            }
            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('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.router.handleWorldClick(pointer.x, pointer.y);
            }
        }

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

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

        if (this.joystick.active) {
            this.player.moveFree(this.joystick.getVector(), dt);
        } else {
            this.player.update(dt);
        }
        this.router.resolvePending();
        const tile = this.player.currentTile();
        // Счётчик мотов: текст пишем только при изменении (сбор — редкое событие).
        const motes = this.game.state.getNumber(VARS.motes);
        if (motes !== this.lastMotes) {
            this.lastMotes = motes;
            this.moteLabel.text = motes > 0 ? `Пепельные моты: ${motes}` : '';
        }
        this.updateHazard(tile);
        this.actors.setDepth(this.player.view, tile.x, tile.y);
        this.updateCameraFollow();
        this.router.checkTransitions(tile);
        // Подписи объектов: видны вблизи, пока объект не использован (once).
        for (const iv of this.interactViews) {
            iv.label.visible =
                this.router.inInteractRange(iv.def.tile.x, iv.def.tile.y) &&
                !(iv.def.once && this.interactables.isUsed(iv.def.id));
        }

        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(VARS.kills)}`
        ]);
        this.debug.update(dt);
        if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture);
    }

    /** Виньетка: низкий hp или накат без маски — края экрана темнеют. */
    private updateVignette(): void {
        const target = Math.max(
            this.playerCombat.hp <= 2 ? VIGNETTE_LOW_HP : 0,
            this.inHazard !== null && !this.hazardMasked ? VIGNETTE_HAZARD : 0
        );
        if (this.lighting.vignetteLevel !== target) this.lighting.setVignette(target, VIGNETTE_FADE);
    }

    /**
     * Зона наката под ногами: без маски герой еле идёт и рискует голосом,
     * звон здесь «проверяет воздух» — пепел на секунду видно волной.
     */
    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);
        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.hazardMasked = masked;

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

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

    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);
            // Звон вспыхивает: тёплый импульс у героя и лёгкий подъём ambient.
            const ls = this.camera.toScreen(from.x, from.y);
            this.lighting.pulseLight({
                x: ls.x,
                y: ls.y,
                color: 0xd99a32,
                intensity: 0.6,
                radius: unitsToPx(2.5),
                spec: { attack: 0.05, decay: 0.45 }
            });
            this.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } });
            this.game.state.setVar(VARS.resonances, this.game.state.getNumber(VARS.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);
        }
    }

    /** Удар из зоны авто-атаки (кулдаун внутри PlayerCombat). */
    private attackTarget(from: Vec2, dir: Vec2): boolean {
        if (this.playerCombat.attackCd.trigger()) {
            this.combat.playerConeAttack(from, dir);
            return true;
        }
        return false;
    }

    /** Индикатор заряда резонанса под героем. */
    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 });
        this.game.camera.addShake(2.5, 0.3);
        // Красная вспышка на весь экран — экранная реакция на боль.
        this.lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, spec: { attack: 0.02, decay: 0.35 } });
        // Отброс от источника урона (направление — по метрике проекции)
        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(VARS.deaths, this.game.state.getNumber(VARS.deaths) + 1);
            this.playerCombat.revive();
            // Респаун на стартовом тайле локации
            this.player.teleportTo(this.area.spawn);
            this.updateCameraFollow(true);
            this.healthBar.setHp(this.playerCombat.hp);
        }
    }

    // ---------- агентный мост (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);
    }

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

    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.GRASS_V1, a.texture('tiles/grass_2')],
            [TILES.GRASS_V2, a.texture('tiles/grass_3')],
            [TILES.PATH, a.texture('tiles/path')],
            [TILES.PATH_V1, a.texture('tiles/path_2')],
            [TILES.WATER, a.texture('tiles/water_1')],
            [TILES.ASH, a.texture('tiles/ash')],
            [TILES.ASH_V1, a.texture('tiles/ash_2')],
            [DECOR.TUFT, a.texture('tiles/decor_tuft')],
            [DECOR.STONE, a.texture('tiles/decor_stone')],
            [DECOR.BALD, a.texture('tiles/decor_bald')],
            [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 a = this.game.assets;
        return {
            down: a.animation('chars/hero_sheet.json', 'hero_walk_down') as [Texture, Texture],
            up: a.animation('chars/hero_sheet.json', 'hero_walk_up') as [Texture, Texture],
            side: a.animation('chars/hero_sheet.json', 'hero_walk_side') as [Texture, Texture]
        };
    }

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

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

    /** Поверхность под ногами (трава/пепел/вода/пол) — для вариативных шагов. */
    private stepSurface(tile: Vec2): StepSurface {
        const id = this.tileId(tile.x, tile.y);
        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';
    }

    /**
     * Тик амбиент-слоёв: громкость каждого — от дистанции героя до ближайшего
     * источника (тайлы или точка); глобальный слой (без источников) — константа.
     * Пустой набор — setLayers сам снимет лишние.
     */
    private updateAudioLayers(): void {
        const p = this.player.position;
        const defs: AudioLayerDef[] = [];
        for (const layer of this.area.ambienceLayers ?? []) {
            const d = this.layerDistance(layer, p);
            const k = layer.radius === undefined ? 1 : Math.max(0, 1 - d / layer.radius);
            const volume = (layer.volume ?? 0.5) * k;
            if (volume > 0.01) defs.push({ key: layer.key, volume, fade: 0.5 });
        }
        this.worldAudio.setLayers(defs);
    }

    /**
     * Дистанция до ближайшего источника слоя (юниты; тайл — центр ромба).
     * Слой без at и tiles — глобальный (ветер): источников нет, дистанция 0.
     */
    private layerDistance(layer: AmbienceLayerDef, p: Vec2): number {
        if (!layer.at && !(layer.tiles && layer.tiles.length > 0)) return 0;
        if (layer.at) return Math.hypot(p.x - (layer.at.x + 0.5), p.y - (layer.at.y + 0.5));
        let best = Infinity;
        for (const id of layer.tiles ?? []) {
            for (const tile of this.layerTilePos.get(id) ?? []) {
                const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5));
                if (d < best) best = d;
            }
        }
        return best;
    }

    /** Редкие дальние шорохи пепла: панорама и высота — случайные (rng сцены). */
    private updateAmbient(dt: number): void {
        this.rustleIn -= dt;
        if (this.rustleIn > 0) return;
        this.rustleIn = 10 + this.ambientRng.next() * 20;
        playSfx(this.game.audio, 'sfx/rustle', {
            volume: 0.25 + this.ambientRng.next() * 0.15,
            pan: (this.ambientRng.next() * 2 - 1) * 0.7,
            rate: 0.85 + this.ambientRng.next() * 0.4
        });
    }

    /**
     * Динамическая музыка по состояниям врагов: погоня (chase/бой) — боевая
     * тема вместо темы области; настороженность (wary) — тихий tension-стем
     * под темой (громкость по угрозе, сглаженная); спокойствие — тема области.
     */private updateMusic(dt: number): void {
        let threat = 0;
        for (const en of this.combat.enemies.values()) {
            const st = en.brain.state;
            if (st === 'chase' || st === 'windup' || st === 'attack' || st === 'hurt') threat = 1;
            else if (st === 'wary') threat = Math.max(threat, 0.4);
        }
        // Боевая тема вытесняет тему области; обратно — как только погоня кончилась.
        this.game.playTheme(threat >= 1 ? 'combat' : (this.area.theme ?? ''));

        // Стем: тихо в бою (тема боя сама громкая), громче при wary, ноль в тишине.
        const target = threat >= 1 ? 0.15 : threat > 0 ? 0.6 * threat : 0;
        if (!this.stemHandle && !this.stemPending && target > 0) {
            this.stemBuf ??= this.game.audio.createBuffer(renderMusicStereo(TENSION_STEM), RATE, 2);
            if (this.stemBuf) {
                this.stemPending = true;
                void this.game.audio
                    .playLoopBuffer('music/tension', this.stemBuf, { fade: 1, volume: 0 })
                    .then((h) => {
                        this.stemPending = false;
                        if (!h) return;
                        // Сцена сменилась, пока луп заводился, — гасим без хвостов.
                        if (!this.stemBuf) h.stop(1);
                        else this.stemHandle = h;
                    });
            }
        }
        // Громкость ведём плавно (dt-подход), stop в exit.
        this.stemLevel += (target - this.stemLevel) * Math.min(1, dt * 2);
        this.stemHandle?.setVolume(this.stemLevel * 0.8);
    }

    /** Смена тайла с поддержанием индекса layerTilePos (свет по тайлам живой). */
    private setTileTracked(x: number, y: number, id: number): void {
        const prev = this.tileId(x, y);
        this.map.setTile(x, y, id);
        const from = this.layerTilePos.get(prev);
        if (from) {
            const i = from.findIndex((p) => p.x === x && p.y === y);
            if (i >= 0) from.splice(i, 1);
            if (from.length === 0) this.layerTilePos.delete(prev);
        }
        const list = this.layerTilePos.get(id) ?? [];
        list.push({ x, y });
        this.layerTilePos.set(id, list);
    }

    /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */
    private collectFlower(x: number, y: number): void {
        this.setTileTracked(x, y, TILES.GRASS);
        this.game.inventory.add('bellflower');
        const n = this.game.state.getNumber(VARS.flowers) + 1;
        this.game.state.setVar(VARS.flowers, n);
        this.game.engine.events.emit('quest:flower', { n });
        playSfx(this.game.audio, '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 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.startHandInCutscene();
        }
    }

    /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */
    private plantFlowers(): void {
        const left = this.game.state.getNumber(VARS.flowers) - QUEST_FLOWERS;
        this.game.state.setVar(VARS.flowers, Math.max(0, left));
        const planted: [number, number][] = [
            [15, 13],
            [16, 14],
            [15, 15]
        ];
        for (const [tx, ty] of planted) {
            this.setTileTracked(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.setTileTracked(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.setTileTracked(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 });
        // Держим, пока читается: базовые 1.4 с + ~45 мс на символ, не дольше 6 с.
        tweens.delay(Math.min(6, 1.4 + text.length * 0.045), () => {
            tweens.to(toast, { alpha: 0, y: 32 }, { duration: 0.5, onDone: () => toast.destroy() });
        });
    }

    /**
     * Одноразовый объект поднят (sink.onConsumed): вьюха растворяется, из
     * тайла вылетают пеплинки — состояние мира меняется на глазах.
     */
    private consumeView(def: InteractableDef): void {
        const iv = this.interactViews.find((v) => v.def.id === def.id);
        if (!iv) return;
        this.interactViews = this.interactViews.filter((v) => v !== iv);
        const w = tileToWorld(def.tile.x, def.tile.y);
        this.combatViews.hitBurst(worldToScreen(w.x, w.y));
        this.game.engine.tweens.to(iv.view, { alpha: 0 }, {
            duration: 0.5,
            onDone: () => {
                this.actors.removeChild(iv.view);
                iv.view.destroy({ children: true });
            }
        });
    }

    /**
     * Тайл клика «по телу объекта» (RouterDeps.pixelTile): bbox вьюх NPC и
     * интерактивов + экранное тело высокого тайла (дом, дерево, башня).
     * Экранно объект стоит выше своего ромба-пола, и клик по спрайту
     * «проваливается под объект» — отдаём его базовому тайлу.
     */
    private pixelTile(px: number, py: number): { x: number; y: number } | null {
        // Тела вьюх (без подписей — они тянутся на тайл выше): границы в
        // глобальных виртуальных px (рендер всегда 480×270).
        for (const n of this.npcs) {
            const b = n.body.getBounds();
            if (px >= b.x && px <= b.x + b.width && py >= b.y && py <= b.y + b.height) return n.def.tile;
        }
        for (const iv of this.interactViews) {
            const b = iv.body.getBounds();
            if (px >= b.x && px <= b.x + b.width && py >= b.y && py <= b.y + b.height) return iv.def.tile;
        }
        // Высокий тайл: тело стоит на (tx,ty), а занимает на экране (tx-1,ty-1).
        // Ремап — только если на базовом тайле есть что кликать (дверь, объект):
        // иначе клик по «телу дерева» уводил бы героя к непроходимому тайлу.
        const off = this.game.renderer.worldRoot.position;
        const p = screenToWorld(px - off.x, py - off.y);
        const t = worldToTile(p.x, p.y, this.map.data.width, this.map.data.height);
        if (!t || t.x + 1 >= this.map.data.width || t.y + 1 >= this.map.data.height) return null;
        const base = { x: t.x + 1, y: t.y + 1 };
        const above = this.map.data.tiles[(t.y + 1) * this.map.data.width + (t.x + 1)]!;
        if (!TALL_OBJECTS[above]) return null;
        const clickable =
            this.area.transitions.some(
                (tr) => tr.tile.x === base.x && tr.tile.y === base.y && tr.trigger === 'click'
            ) || this.interactables.defAt(base.x, base.y);
        return clickable ? base : null;
    }

    /** Вьюха интерактивного объекта: спрайт из def.view или плейсхолдер + подпись. */
    private makeInteractableView(def: InteractableDef): {
        view: Container;
        body: Graphics | Sprite;
        label: PixelText;
    } {
        const view = new Container();
        const p = this.tileCenter(def.tile.x, def.tile.y);
        view.position.set(p.x, p.y);
        let g: Graphics | Sprite;
        if (def.view?.tex) {
            // Настоящий арт из def.view: якорь низом в центр ромба (или задан).
            const sprite = new Sprite(this.game.assets.texture(def.view.tex));
            sprite.anchor.set(0.5, def.view.anchorY ?? 1);
            g = sprite;
        } else {
            // Плейсхолдер-ящик по роду (пока нет арта конкретного объекта).
            g = new Graphics();
            switch (def.kind) {
                case 'signpost': // столбик с доской
                    g.rect(-1, -10, 2, 10).fill(0x4a3b2a);
                    g.rect(-6, -14, 12, 5).fill(0x5c4a33);
                    g.rect(-6, -14, 12, 1).fill(0x8a6d3a);
                    break;
                case 'lever': // рычаг: основание и наклонная рукоять
                    g.rect(-3, -2, 6, 2).fill(0x4a3b2a);
                    g.rect(0, -8, 2, 7).fill(0x8a6d3a);
                    g.rect(-2, -10, 4, 2).fill(0x8a6d3a);
                    break;
                case 'pickup': // пепельная насыпь, из которой теплится мот
                    g.rect(-6, -2, 12, 3).fill(0x3a3a3a);
                    g.rect(-4, -4, 8, 2).fill(0x4a4a4a);
                    g.rect(-2, -5, 4, 1).fill(0x5a5a5a);
                    break;
                default: // контейнер/предмет осмотра: ящик с бронзовой кромкой
                    g.rect(-5, -12, 10, 12).fill(0x4a3b2a);
                    g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо
            }
        }
        view.addChild(g);
        // У мота — тёплая искра поверх насыпи: заметный пульс видно издалека.
        if (def.kind === 'pickup') {
            const spark = new Graphics();
            spark.rect(-1, -1, 3, 3).fill(0xf0d878);
            spark.position.set(0, -5);
            view.addChild(spark);
            this.game.engine.fx.add(
                new SpriteMotion(spark, { pulse: { min: 0.3, max: 1, period: 1.1 } }, {
                    seed: def.tile.x * 31 + def.tile.y
                })
            );
        }
        const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a });
        label.anchor.set(0.5, 1);
        label.position.set(0, -16);
        label.visible = false;
        view.addChild(label);
        return { view, body: g, label };
    }

    private makeNpcView(def: NpcDef): { view: Container; body: Sprite } {
        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();
        sprite.anchor.set(0.5, 1); // ноги в центре ромба
        // idle-дыхание кадрами из атласа (по умолчанию — общий лист NPC).
        const idle = this.game.assets.animation(
            def.atlas ?? 'chars/npcs_sheet.json',
            `${def.sprite}_idle`
        );
        sprite.texture = idle[0]!;
        const animator = new SpriteAnimator(
            sprite,
            { idle: { frames: idle, fps: 1.2 } },
            'idle',
            { randomPhase: def.tile.y * 64 + def.tile.x }
        );
        this.game.engine.fx.add(animator);
        view.addChild(sprite);

        // Маркер «с ним можно говорить»: восклицательный штрих над головой
        // (высота — от реального кадра атласа, не от захардкоженной).
        const frameH = idle[0]!.height;
        const marker = new Graphics();
        marker.rect(-1, -(frameH + 3), 2, 4).fill(0xf0d878);
        marker.rect(-1, -(frameH - 3), 2, 2).fill(0xf0d878);
        view.addChild(marker);
        return { view, body: sprite };
    }

    private saveAndExit(): void {
        const pos = this.player.currentTile();
        this.game.state.setVar(VARS.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,
            { title: 'Автосохранение', savedAt: Date.now(), version: SAVE_VERSION, extras: { area: this.area.id } }
        );
        void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 });
    }
}