Newer
Older
rpg / packages / engine / src / render / fx / FxLayer.ts
import { Container, Sprite } from 'pixi.js';
import { Updatables, type Updatable } from '../../core/Updatables';
import type { Vec2 } from '../../math/Vec2';
import type { PixelTextOptions } from '../../ui/PixelText';
import { ParticleEmitter, type EmitterOptions } from '../Particles';
import { SpriteFlash } from '../spriteFx';
import { FxRing } from './FxRing';
import { FxFloatText } from './FxFloatText';
import { FxFade, type FxFadeOptions } from './FxFade';
import { FxPop } from './FxPop';
import { FxTrail } from './FxTrail';
import type { FxRingSpec, FxFloatSpec, FxPopSpec } from './fxSim';

/** Опции слоя. */
export interface FxLayerOptions {
    /** Seed по умолчанию для разовых частиц (burst), если opts не передал свой. */
    seed?: number;
}

/** Опции floatText: математика + стиль текста + родитель (для depth-слоёв). */
export interface FxLayerFloatOptions extends Partial<FxFloatSpec> {
    style?: Partial<PixelTextOptions>;
    parent?: Container;
}

/** Родитель вьюхи эффекта вместо самого слоя (для IsoDepthLayer.addFx). */
export interface FxParentOptions {
    parent?: Container;
}

/**
 * Слой визуальных эффектов: сам тикается (реализует Updatable — добавить один
 * раз в engine.fx), спавнит one-shot пресеты и сам вычищает закончившиеся.
 * Позиции — локальные px контейнера, куда добавлен слой (worldRoot — мировые
 * эффекты через проекцию сцены, uiRoot — тосты/HUD). Изометрию слой не знает.
 */
export class FxLayer extends Container implements Updatable {
    private readonly items = new Updatables();
    private readonly flashes = new Map<Sprite, SpriteFlash>();
    private readonly defaultSeed: number;
    private seedCounter = 0;

    constructor(opts?: FxLayerOptions) {
        super();
        this.defaultSeed = opts?.seed ?? 0x5eed;
    }

    /** Ручное добавление своего эффекта (эмиттер, SpriteMotion, FxTrail…). */
    add<T extends Updatable>(fx: T): T {
        this.items.add(fx);
        return fx;
    }

    /** Погасить и уничтожить все активные эффекты (exit сцены, смена тоста). */
    clear(): void {
        this.items.forEach((u) => {
            const cancellable = u as { cancel?: () => void };
            if (cancellable.cancel) cancellable.cancel();
            else if (!u.destroyed && u instanceof Container) {
                u.destroy({ children: true });
            }
        });
        this.items.clear();
        this.flashes.clear();
    }

    update(dt: number): void {
        this.items.update(dt);
        for (const [sprite, flash] of this.flashes) {
            if (sprite.destroyed) {
                this.flashes.delete(sprite);
                continue;
            }
            flash.update(dt);
        }
    }

    /** Расходящееся/сходящееся кольцо; repeat > 1 — телеграф зоны. */
    ring(spec: FxRingSpec, at: Vec2, opts?: FxParentOptions): FxRing {
        const fx = new FxRing(spec);
        fx.position.set(at.x, at.y);
        return this.mount(fx, opts);
    }

    /** Всплывающий текст (числа урона, тосты, подборы). */
    floatText(text: string, at: Vec2, opts?: FxLayerFloatOptions): FxFloatText {
        const { style, parent, ...spec } = opts ?? {};
        const fx = new FxFloatText(text, at, { duration: 1, ...spec }, style);
        return this.mount(fx, { parent });
    }

    /** Растворение (to < 1) или проявление произвольной вьюхи + авто-destroy. */
    fade(view: Container, spec: FxFadeOptions): FxFade {
        return this.items.add(new FxFade(view, spec)) as FxFade;
    }

    /** Появление с овершутом масштаба. */
    pop(view: Container, spec?: FxPopSpec): FxPop {
        return this.items.add(new FxPop(view, spec)) as FxPop;
    }

    /** Хит-флэш: один SpriteFlash на спрайт, повторный вызов перезапускает. */
    flash(sprite: Sprite, color: number, seconds?: number): void {
        let flash = this.flashes.get(sprite);
        if (!flash) {
            flash = new SpriteFlash(sprite, color, seconds);
            this.flashes.set(sprite, flash);
        }
        flash.start();
    }

    /** Разовые частицы в точке (ParticleEmitter.oneShot) с самоуничтожением. */
    burst(count: number, at: Vec2, opts: EmitterOptions, parent?: Container): ParticleEmitter {
        const seed = opts.seed ?? (this.defaultSeed + this.seedCounter++);
        const fx = ParticleEmitter.oneShot(count, { ...opts, seed });
        fx.position.set(at.x, at.y);
        (parent ?? this).addChild(fx);
        return this.items.add(fx) as ParticleEmitter;
    }

    /** Шлейф за движущейся вьюхой (снаряды, герой). */
    trail(view: Container, opts: EmitterOptions, parent?: Container): FxTrail {
        const fx = new FxTrail(view, opts);
        (parent ?? this).addChild(fx.view);
        return this.items.add(fx) as FxTrail;
    }

    /** Повесить вьюху эффекта на слой или переданного родителя. */
    private mount<T extends Container & Updatable>(fx: T, opts?: FxParentOptions): T {
        (opts?.parent ?? this).addChild(fx);
        return this.items.add(fx) as T;
    }

    /** Разрушение слоя гасит все эффекты (exit сцены — одна строка). */
    override destroy(options?: Parameters<Container['destroy']>[0]): void {
        this.clear();
        super.destroy(options);
    }
}