/**
* Игровой цикл 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 {
// ручной режим обязателен: поверх живого цикла шаги удвоятся
if (!this.manual) return 0;
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();
};
}