Newer
Older
rpg / packages / engine / src / core / GameLoop.ts
/**
 * Игровой цикл: фиксированный шаг обновления (60 Гц по умолчанию) + отрисовка
 * после каждого шага (рендер с последним состоянием, без интерполяции —
 * на 2D-пиксель-арте это стандартный компромисс).
 */
export interface LoopCallbacks {
    /** dt в секундах, всегда фиксированный. */
    update: (dt: number) => void;
    render: () => void;
}

export class GameLoop {
    /** Длительность фиксированного шага, сек. */
    readonly step: number;
    /** Максимум шагов за кадр (защита от «догоняния» после лагов). */
    maxStepsPerFrame = 5;

    private callbacks: LoopCallbacks;
    private rafId = 0;
    private lastTime = 0;
    private accumulator = 0;
    private running = false;
    /** Ручной режим: rAF не планирует шаги (их крутит мост/тесты через stepTick). */
    private manual = false;

    constructor(fps: number, callbacks: LoopCallbacks) {
        this.step = 1 / fps;
        this.callbacks = callbacks;
    }

    start(): void {
        if (this.running) return;
        this.running = true;
        this.lastTime = performance.now();
        this.rafId = requestAnimationFrame(this.tick);
    }

    stop(): void {
        this.running = false;
        cancelAnimationFrame(this.rafId);
    }

    /** Ручной режим: кадры rAF не делают ни шагов, ни рендера. */
    setManual(on: boolean): void {
        this.manual = on;
        if (on) this.resetTiming();
    }

    /**
     * Сброс таймингов: аккумулятор и lastTime обнуляются, чтобы следующий
     * реальный кадр не «догонял» пропущенное время (до 5 лишних тиков).
     */
    resetTiming(): void {
        this.accumulator = 0;
        this.lastTime = performance.now();
    }

    private tick = (now: number): void => {
        if (!this.running) return;
        if (this.manual) {
            // Ручной режим: кадр пустой, но rAF и тайминги живут — иначе при
            // выходе из ручного режима цикл «догонит» пропущенное время.
            this.rafId = requestAnimationFrame(this.tick);
            this.accumulator = 0;
            this.lastTime = now;
            return;
        }
        this.rafId = requestAnimationFrame(this.tick);

        this.accumulator += Math.min((now - this.lastTime) / 1000, 0.25);
        this.lastTime = now;

        let steps = 0;
        while (this.accumulator >= this.step && steps < this.maxStepsPerFrame) {
            this.callbacks.update(this.step);
            this.accumulator -= this.step;
            steps++;
        }
        if (steps === this.maxStepsPerFrame) {
            // Отбрасываем накопившийся долг, чтобы не зациклиться.
            this.accumulator = 0;
        }

        this.callbacks.render();
    };
}