Newer
Older
rpg / v2 / packages / engine / src / core / loop.ts
/**
 * Игровой цикл v2: фиксированный шаг логики (по умолчанию 60 Гц) + рендер
 * после пачки шагов (проверенная схема v1). Ручной режим (setManual) —
 * rAF живёт, но шаги не идут: их крутит мост/гейт через stepTick — так
 * агентные проверки детерминированы (урок v1: мост — часть ядра).
 * now/schedule инъектируются для юнит-тестов без реального времени.
 */
export interface LoopCallbacks {
    /** Один шаг логики; dt всегда равен step. */
    update(dt: number): void;
    render(): void;
}

export interface LoopOptions {
    /** now() в миллисекундах (по умолчанию performance.now). */
    now?: () => number;
    /** Планировщик кадра (по умолчанию requestAnimationFrame). */
    schedule?: (cb: (t: number) => void) => void;
    /** Лимит отрисовки, кадров/с (0 — без лимита). */
    maxFps?: number;
}

export class GameLoop {
    /** Длительность фиксированного шага, сек. */
    readonly step: number;
    /** Максимум шагов за кадр (защита от «догоняния» после лагов). */
    maxStepsPerFrame = 5;
    private readonly callbacks: LoopCallbacks;
    private readonly now: () => number;
    private readonly schedule: (cb: (t: number) => void) => void;
    private readonly minFrameMs: number;
    private rafId = 0;
    private lastTime = 0;
    private lastFrame = 0;
    private accumulator = 0;
    private running = false;
    private manual = false;

    constructor(fps: number, callbacks: LoopCallbacks, opts: LoopOptions = {}) {
        this.step = 1 / fps;
        this.callbacks = callbacks;
        this.now = opts.now ?? (() => performance.now());
        this.schedule =
            opts.schedule ??
            ((cb) => {
                this.rafId = requestAnimationFrame(cb);
            });
        this.minFrameMs = opts.maxFps && opts.maxFps > 0 ? 1000 / opts.maxFps : 0;
    }

    start(): void {
        if (this.running) return;
        this.running = true;
        this.lastTime = this.now();
        this.lastFrame = this.lastTime;
        this.schedule(this.tick);
    }

    stop(): void {
        this.running = false;
        // cancelAnimationFrame нет в Node (юнит-тесты с инъекцией schedule)
        if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.rafId);
    }

    /** Ручной режим: кадры rAF пустые, шаги крутит stepTick. */
    setManual(on: boolean): void {
        this.manual = on;
        this.accumulator = 0;
        this.lastTime = this.now();
        this.lastFrame = this.lastTime;
    }

    /** Шаги логики вручную (мост/гейт); render — отрисовать после пачки. */
    stepTick(n = 1, render = false): number {
        for (let i = 0; i < n; i++) this.callbacks.update(this.step);
        if (render) this.callbacks.render();
        return n;
    }

    private tick = (t: number): void => {
        if (!this.running) return;
        this.schedule(this.tick);
        if (this.manual) {
            this.accumulator = 0; // время не копим — иначе «догоним» пропущенное
            this.lastTime = t;
            return;
        }
        if (t - this.lastFrame < this.minFrameMs - 1) return;
        this.lastFrame = t;
        this.accumulator += Math.min((t - this.lastTime) / 1000, 0.25);
        this.lastTime = t;
        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();
    };
}