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);
}
}
/** Удар из зоны авто-атаки (кулдаун внутри PlayerCombat). false — кулдаун. */
attackTarget(from: Vec2, dir: Vec2): boolean {
if (this.playerCombat.attackCd.trigger()) {
this.deps.combat.playerConeAttack(from, dir);
return true;
}
return false;
}
/** Уровень угрозы 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);
}
}