Newer
Older
rpg / apps / game / src / scenes / LocationScene.ts
import {
    Container,
    Graphics,
    IsoDepthLayer,
    ParticleEmitter,
    PixelText,
    Sprite,
    Texture,
    IsometricTileMap,
    isoToScreen,
    screenToIsoExact,
    inCircle,
    DEFAULT_ISO,
    type Camera,
    type Entity,
    type Scene,
    type Vec2
} from '@rpg/engine';
import { Game } from '../Game';
import { MenuScene, type SaveData } from './MenuScene';
import { buildMeadowsMap, TILES } from '../data/map';
import { NPCS, type NpcDef } from '../data/npcs';
import { DIALOGUES } from '../data/dialogues';
import { ENEMY_SPAWNS_MEADOWS, type EnemyKindId } from '../data/enemies';
import { PlayerController, type HeroTextures } from '../systems/PlayerController';
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';

/**
 * Локация «Выжженные луга»: карта, герой, 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 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 blinkT = 0;

    constructor(private game: Game, save: SaveData | null) {
        this.camera = game.engine.camera;
        const data = buildMeadowsMap();
        this.map = new IsometricTileMap(data, this.tileTextures(), DEFAULT_ISO);
        this.world.addChild(this.map.view, this.actors);
        this.game.renderer.worldRoot.addChild(this.world);

        const startTile = save?.pos ?? { x: 14, y: 14 };
        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 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 ENEMY_SPAWNS_MEADOWS) {
            this.combat.spawnEnemy(s.kind, this.tileCenter(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);
            if (en.brain.dead) this.combatViews.deathBurst(en.pos);
            else this.combatViews.hitBurst(en.pos);
        });

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

        // Камера: следим за героем; ромб карты уходит в минус по X —
        // границы начинаются от его западного угла.
        const size = this.map.screenSize;
        this.camera.bounds = {
            x: -size.width / 2,
            y: 0,
            width: size.width,
            height: size.height
        };
        this.updateCameraFollow();

        this.hint = new Container();
        const text = new PixelText({
            text: 'клик — идти · клик по сгустку — атаковать · Space — удар (удержать — резонанс) · Esc — меню',
            size: 8,
            color: 0x999988
        });
        text.position.set(6, 4);
        this.hint.addChild(text);
        this.game.renderer.uiRoot.addChild(this.hint);
    }

    enter(): void {
        // Амбиент локации (кроссфейд; тот же трек не перезапускается).
        this.game.playMusic('music/meadows');
    }

    exit(): void {
        this.combatViews.exit();
        this.ash.clear();
        this.world.destroy({ children: true });
        this.ash.destroy();
        this.healthBar.destroy({ children: true });
        this.hint.destroy({ children: true });
    }

    update(dt: number): void {
        this.ash.update(dt);
        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('attack')) {
            this.playerCombat.startCharge();
        }
        if (input.isActionJustReleased('attack') && this.playerCombat.isCharging()) {
            this.doPlayerRelease();
        }

        const pointer = input.getPointer();
        if (pointer.justPressed) {
            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;

        this.player.update(dt);
        const tile = this.player.currentTile();
        this.actors.setDepth(this.player.view, tile.x, tile.y);
        this.updateCameraFollow();
    }

    render(): void {}

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

    /** Отпускание атаки: резонанс (долгое) или короткий удар. */
    private doPlayerRelease(): void {
        const action = this.playerCombat.release();
        const from = this.player.position;
        if (action === 'resonance') {
            const slept = this.combat.resonancePulse(from);
            this.combatViews.resonanceRing(from);
            this.game.camera.addShake(1.5, 0.25);
            this.game.state.setVar('resonances', this.game.state.getNumber('resonances') + (slept > 0 ? 1 : 0));
        } 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 = Math.hypot(en.pos.x - from.x, en.pos.y - from.y);
        if (dist <= PLAYER_COMBAT.coneRange - 6) {
            // В зоне — стоим и бьём по кулдауну
            this.player.stop();
            if (this.playerCombat.attackCd.trigger()) {
                this.combat.playerConeAttack(from, normalize({ x: en.pos.x - from.x, y: 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 p = this.player.position;
        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;
        const len = Math.hypot(dx, dy) || 1;
        this.player.applyKnockback({ x: dx / len, y: dy / len }, PLAYER_COMBAT.knockback);

        if (this.playerCombat.dead) {
            this.game.state.setVar('deaths', this.game.state.getNumber('deaths') + 1);
            this.playerCombat.revive();
            // Респаун на стартовом тайле
            this.player.teleportTo({ x: 14, y: 14 });
            this.healthBar.setHp(this.playerCombat.hp);
        }
    }

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

    private updateCameraFollow(): void {
        const tile = this.player.currentTile();
        const c = this.tileCenter(tile.x, tile.y);
        this.camera.follow(c.x, c.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')]
        ]);
    }

    /** Кадры сгустков из атласа 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 p = isoToScreen(tx, ty, DEFAULT_ISO);
        return { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 };
    }

    private handleWorldClick(px: number, py: number): void {
        // Из координат экрана в мировые (учёт позиции камеры).
        const worldX = px - this.game.renderer.worldRoot.position.x;
        const worldY = py - this.game.renderer.worldRoot.position.y;

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

        // Клик по тайлу NPC — диалог.
        if (clicked) {
            const npc = this.npcs.find(
                (n) => n.def.tile.x === clicked.x && n.def.tile.y === clicked.y
            );
            if (npc) {
                this.talkTo(npc.def);
                return;
            }
        }

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

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

    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.dialogueRepeat : def.dialogueFirst;
        this.dialogue.start(DIALOGUES[id], id);
    }

    private onDialogueFinished(_id: string): void {
        // Триггеры сюжета вешаются на id завершённого диалога (квесты — по флагам GameState).
    }

    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', {
            pos,
            state: this.game.state.serialize(),
            savedAt: Date.now()
        } satisfies SaveData);
        void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 });
    }
}

function normalize(v: Vec2): Vec2 {
    const len = Math.hypot(v.x, v.y) || 1;
    return { x: v.x / len, y: v.y / len };
}