Newer
Older
rpg / packages / engine / src / core / Engine.ts
import { Application, Graphics } from 'pixi.js';
import { Renderer, RendererOptions } from '../render/Renderer';
import { Camera } from '../render/Camera';
import { computeScale } from '../render/scale';
import { GameLoop } from './GameLoop';
import { EventBus } from './EventBus';
import { TweenManager } from './Tween';
import { InputManager } from '../input/InputManager';
import { SceneManager } from '../scene/SceneManager';

/**
 * Собирает подсистемы движка. Приложение создаёт Engine, добавляет стартовую
 * сцену через scenes.replace() и вызывает start().
 */
export interface EngineOptions extends RendererOptions {
    /** FPS фиксированного шага. */
    fixedFps: number;
    /** Пересчитывать масштаб при ресайзе окна (по умолчанию true). */
    autoResize?: boolean;
}

export class Engine {
    readonly renderer: Renderer;
    readonly camera: Camera;
    readonly events = new EventBus();
    readonly scenes = new SceneManager();
    /** Твины и таймеры: обновляются каждым фиксированным шагом. */
    readonly tweens = new TweenManager();
    input!: InputManager;
    private loop: GameLoop;
    private started = false;
    private ticks = 0;
    private readonly autoResize: boolean;
    private onWindowResize = (): void => {
        this.renderer.resize(
            computeScale(
                this.renderer.virtualWidth,
                this.renderer.virtualHeight,
                window.innerWidth,
                window.innerHeight
            )
        );
    };

    constructor(options: EngineOptions) {
        this.renderer = new Renderer(options);
        this.camera = new Camera(options.virtualWidth, options.virtualHeight);
        this.autoResize = options.autoResize ?? true;
        this.loop = new GameLoop(options.fixedFps, {
            update: (dt) => this.tick(dt),
            render: () => this.render()
        });
    }

    /**
     * Инициализация WebGL и ввода. Вызвать до scenes.replace()/start().
     * Возвращает готовый Application (для прямого доступа к Pixi при необходимости).
     */
    async init(): Promise<Application> {
        await this.renderer.setup();
        this.input = new InputManager(
            this.renderer.app.canvas,
            this.renderer.virtualWidth,
            this.renderer.virtualHeight
        );

        // Оверлей затемнения для переходов между сценами — верхний слой UI.
        const fade = new Graphics();
        this.renderer.uiRoot.addChild(fade);
        this.scenes.setOverlay(fade, this.renderer.virtualWidth, this.renderer.virtualHeight);

        if (this.autoResize) {
            window.addEventListener('resize', this.onWindowResize);
        }
        return this.renderer.app;
    }

    async start(): Promise<void> {
        if (this.started) return;
        this.started = true;
        this.loop.start();
    }

    stop(): void {
        this.loop.stop();
        this.input?.dispose();
        window.removeEventListener('resize', this.onWindowResize);
        this.events.clear();
    }

    /** Счётчик фиксированных шагов с запуска (для агента и отладки). */
    get tickCount(): number {
        return this.ticks;
    }

    /** Длительность фиксированного шага, сек. */
    get fixedStep(): number {
        return this.loop.step;
    }

    /** Виртуальное разрешение (для пересчёта виртуальных px в CSS). */
    get virtualWidth(): number {
        return this.renderer.virtualWidth;
    }

    get virtualHeight(): number {
        return this.renderer.virtualHeight;
    }

    /** Один фиксированный шаг (без рендера) — мост/тесты крутят шаги сами. */
    stepTick(): number {
        this.tick(this.loop.step);
        this.ticks++;
        this.events.emit('engine:tick', { tick: this.ticks });
        return this.ticks;
    }

    /** Один кадр рендера без шага логики. */
    renderFrame(): void {
        this.render();
    }

    /**
     * n фиксированных шагов детерминированно: цикл на время переводится в ручной
     * режим, после — тайминги сбрасываются, чтобы rAF не «догонял» пропущенное.
     */
    stepTicks(n = 1, render = false): number {
        this.loop.setManual(true);
        for (let i = 0; i < n; i++) {
            this.stepTick();
            if (render) this.render();
        }
        this.loop.setManual(false);
        return this.ticks;
    }

    /** Один фиксированный шаг. */
    private tick(dt: number): void {
        this.input?.update(); // опрос геймпадов до логики
        this.camera.update(dt); // тряска и прочее «живое» поведение камеры
        this.tweens.update(dt);
        this.scenes.update(dt);
        this.input?.endTick();
    }

    private render(): void {
        this.camera.apply(this.renderer.worldRoot);
        this.scenes.render();
        this.renderer.render();
    }
}