Newer
Older
rpg / apps / game / src / systems / combat / CombatFlow.ts
import {
    Graphics,
    worldNorm,
    worldToScreen,
    unitsToPx,
    type Camera,
    type Entity,
    type FxLayer,
    type Vec2
} from '@rpg/engine';
import type { Game } from '../../Game';
import type { VarId } from '../../data/ids';
import { VARS } from '../../data/ids';
import { GameLighting } from '../Lighting';
import type { PlayerController } from '../PlayerController';
import { CombatWorld } from './CombatWorld';
import { CombatViews } from './CombatViews';
import { HealthBar } from './HealthBar';
import { PlayerCombat } from './PlayerCombat';
import { PLAYER_COMBAT } from './stats';

/** Боевые счётчики GameState (инкрементируются здесь). */
const BUMP: Record<'kills' | 'motes' | 'deaths' | 'resonances', VarId> = {
    kills: VARS.kills,
    motes: VARS.motes,
    deaths: VARS.deaths,
    resonances: VARS.resonances
};

/**
 * Боевая обвязка героя поверх ECS-мира CombatWorld: ввод атаки (тап — короткий
 * удар, удержание — резонанс), реакции на урон (вспышка, шейк, отброс,
 * смерть-респаун), hp-бар, индикатор заряда, уровень угрозы для музыки.
 * Сцена только собирает систему, тикает и передаёт ей ввод.
 */
export interface CombatFlowDeps {
    game: Game;
    combat: CombatWorld;
    combatViews: CombatViews;
    player: PlayerController;
    lighting: GameLighting;
    camera: Camera;
    /** Слой мировых эффектов (флэш урона на герое). */
    fx: FxLayer;
    /** Стартовый тайл локации (респаун после смерти). */
    spawn: Vec2;
    /** Зона пепельного наката (резонанс вспыхивает пепел). */
    inHazard(): boolean;
    /** Респаун/телепорт: камера мгновенно на героя. */
    snapCamera(): void;
}

export class CombatFlow {
    /** Боевое состояние героя (читают агентный мост и мигание неуязвимости). */
    readonly playerCombat: PlayerCombat;
    /** Полоска сердец (сцена добавляет в uiRoot). */
    readonly healthBar: HealthBar;
    private ring: Graphics;
    /** Кольцо сейчас нарисовано: clear/перерисовка только на переходах. */
    private ringVisible = false;
    private offs: (() => void)[] = [];

    constructor(private deps: CombatFlowDeps, savedHp: number) {
        this.playerCombat = new PlayerCombat(savedHp);
        this.healthBar = new HealthBar();
        this.healthBar.setHp(this.playerCombat.hp);
        this.ring = new Graphics();
        deps.game.renderer.worldRoot.addChild(this.ring);
        // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира.
        this.offs.push(
            deps.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => {
                const en = deps.combat.enemies.get(entity);
                if (!en) return;
                deps.combatViews.flashEnemy(entity);
                const s = worldToScreen(en.pos.x, en.pos.y);
                if (en.brain.dead) deps.combatViews.deathBurst(s);
                else deps.combatViews.hitBurst(s);
                // Световой импульс в точке попадания (lightRoot — экранное пространство).
                const ls = deps.camera.toScreen(en.pos.x, en.pos.y);
                deps.lighting.pulseLight({
                    x: ls.x,
                    y: ls.y,
                    color: 0xf2b45a,
                    intensity: 0.45,
                    radius: unitsToPx(1.5) * deps.camera.zoomLevel,
                    spec: { attack: 0.02, decay: 0.2 }
                });
            })
        );
    }

    /** Нажатие атаки: начало зарядки резонанса. */
    onAttackPressed(): void {
        this.playerCombat.startCharge();
    }

    /** Отпускание атаки: резонанс (долгое) или короткий удар. */
    onAttackReleased(): void {
        if (!this.playerCombat.isCharging()) return;
        const action = this.playerCombat.release();
        const from = this.deps.player.position;
        if (action === 'resonance') {
            this.doResonance(from);
        } else if (action === 'attack') {
            this.deps.combat.playerConeAttack(from, this.deps.player.dirVector);
        }
    }

    /** Уровень угрозы 0..1 по состояниям врагов (музыка: бой/настороженность). */
    threat(): number {
        let threat = 0;
        for (const en of this.deps.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);
        }
        return threat;
    }

    /** Тик: таймеры боя, hp-бар, кольцо заряда под героем. */
    update(dt: number): void {
        this.playerCombat.update(dt);
        this.updateRing();
        this.healthBar.setHp(this.playerCombat.hp);
    }

    /** Урон герою: неуязвимость внутри PlayerCombat; здесь отброс, шейк, звук, смерть. */
    onPlayerDamaged(damage: number, from: Vec2): void {
        const d = this.deps;
        const applied = this.playerCombat.takeDamage(damage);
        if (applied <= 0) return;
        d.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp });
        d.game.camera.addShake(2.5, 0.3);
        // Красная вспышка на весь экран — экранная реакция на боль.
        d.lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, spec: { attack: 0.02, decay: 0.35 } });
        // Симметричный врагам флэш урона на герое.
        d.fx.flash(d.player.sprite, 0xd05a5a, 0.18);
        // Отброс от источника урона (направление — по метрике проекции)
        const dx = d.player.position.x - from.x;
        const dy = d.player.position.y - from.y;
        d.player.applyKnockback(worldNorm(dx, dy), PLAYER_COMBAT.knockback);

        if (this.playerCombat.dead) {
            this.bump('deaths', 1);
            this.playerCombat.revive();
            d.player.teleportTo(d.spawn);
            d.snapCamera();
        }
    }

    exit(): void {
        for (const off of this.offs) off();
        this.offs = [];
        this.ring.destroy(); // в worldRoot — не вычищается с this.world
    }

    /** Резонанс: волна сна, кольцо, звон света, реакция наката. */
    private doResonance(from: Vec2): void {
        const d = this.deps;
        const slept = d.combat.resonancePulse(from);
        const s = worldToScreen(from.x, from.y);
        d.combatViews.resonanceRing(s);
        d.game.camera.addShake(1.5, 0.25);
        // Звон вспыхивает: тёплый импульс у героя и лёгкий подъём ambient.
        const ls = d.camera.toScreen(from.x, from.y);
        d.lighting.pulseLight({
            x: ls.x,
            y: ls.y,
            color: 0xd99a32,
            intensity: 0.6,
            radius: unitsToPx(2.5) * d.camera.zoomLevel,
            spec: { attack: 0.05, decay: 0.45 }
        });
        d.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } });
        this.bump('resonances', slept > 0 ? 1 : 0);
        // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел.
        if (d.inHazard()) {
            d.combatViews.hitBurst({ x: s.x - 14, y: s.y + 6 });
            d.combatViews.hitBurst({ x: s.x + 14, y: s.y + 6 });
            void d.game.audio.play('sfx/ash_hiss', 0.7);
        }
    }

    /** Индикатор заряда резонанса под героем (Graphics трогаем только на переходах). */
    private updateRing(): void {
        const charging = this.playerCombat.isCharging();
        if (!charging) {
            if (this.ringVisible) {
                this.ringVisible = false;
                this.ring.clear();
            }
            return;
        }
        const u = this.deps.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;
        if (!this.ringVisible) {
            this.ringVisible = true;
            this.ring
                .circle(p.x, p.y - 2, 6 + 4 * k)
                .stroke({ color, width: 1, alpha: 0.9 });
        }
    }

    /** Инкремент боевого счётчика в vars GameState. */
    private bump(name: keyof typeof BUMP, by: number): void {
        const key = BUMP[name];
        this.deps.game.state.setVar(key, this.deps.game.state.getNumber(key) + by);
    }
}