import {
Graphics,
worldNorm,
worldToScreen,
unitsToPx,
conePath,
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;
/** Сектор замаха (удержание атаки): полигон по conePath, двигается без перерисовки. */
private swing: Graphics;
/** Ключ нарисованного сектора (направление + заряжен) — null сектор скрыт. */
private swingKey: string | null = null;
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);
this.swing = new Graphics();
deps.game.renderer.worldRoot.addChild(this.swing);
// Вспышка урона и брызги пепла — реакция вьюх на события 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);
this.swingFlash(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.updateSwing();
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
this.swing.destroy();
}
/** Резонанс: волна сна, кольцо, звон света, реакция наката. */
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 });
}
}
/**
* Замах при удержании атаки: полупрозрачный сектор конуса по направлению
* взгляда (заряжен резонанс — круг волны). Полигон перерисовывается только
* на смене направления/состояния заряда; позиция следует за героем каждый
* тик (сдвиг вьюхи, без перестройки полигона).
*/
private updateSwing(): void {
const charging = this.playerCombat.isCharging();
if (!charging) {
if (this.swingKey) {
this.swingKey = null;
this.swing.clear();
}
return;
}
const ready = this.playerCombat.chargeTime >= PLAYER_COMBAT.resonanceCharge;
const dir = this.deps.player.dirVector;
const key = `${dir.x},${dir.y}|${ready ? 'r' : 'c'}`;
if (key !== this.swingKey) {
this.swingKey = key;
this.swing.clear();
const d = worldToScreen(dir.x, dir.y); // угол сектора — в экранных осях (как inConeW)
if (ready) {
this.swing
.circle(0, 0, unitsToPx(PLAYER_COMBAT.resonanceRange) - 2)
.fill({ color: 0xd99a32, alpha: 0.12 });
} else {
const pts = conePath({ x: 0, y: 0 }, d, unitsToPx(PLAYER_COMBAT.coneRange), PLAYER_COMBAT.coneHalfAngle);
this.swing.poly(pts.flatMap((p) => [p.x, p.y])).fill({ color: 0xf2b45a, alpha: 0.15 });
}
}
const p = worldToScreen(this.deps.player.position.x, this.deps.player.position.y);
this.swing.position.set(p.x, p.y);
}
/** След удара: яркая вспышка-сектор по конусу, тает за ~0.18 с (FxLayer.destroy). */
private swingFlash(from: Vec2, dir: Vec2): void {
const p = worldToScreen(from.x, from.y);
const d = worldToScreen(dir.x, dir.y);
const pts = conePath({ x: 0, y: 0 }, d, unitsToPx(PLAYER_COMBAT.coneRange), PLAYER_COMBAT.coneHalfAngle);
const flash = new Graphics();
flash.poly(pts.flatMap((q) => [q.x, q.y])).fill({ color: 0xf2cd68, alpha: 0.4 });
flash.position.set(p.x, p.y);
this.deps.game.renderer.worldRoot.addChild(flash);
this.deps.fx.fade(flash, { duration: 0.18 });
}
/** Инкремент боевого счётчика в 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);
}
}