/**
* Игровой цикл: фиксированный шаг обновления (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();
};
}