Files
2026-09-25 13:25:25 -03:00

108 lines
3.1 KiB
TypeScript

// runs the game at a fixed rate. a slow tick makes the next ones run back to back until the game catches up,
// so game time keeps up with real time. if it falls too far behind, it skips ahead instead and says so
import { TICKS_PER_SECOND } from "$/common/constants.ts";
// at most this many ticks in a row to catch up, then skip the rest
const MAX_CATCH_UP_TICKS = 10;
// don't repeat the can't keep up warning more often than this
const WARN_INTERVAL_MS = 15_000;
export interface LoopClock {
now(): number;
// run fn after ms, returns something clear() takes
schedule(fn: () => void, ms: number): unknown;
clear(handle: unknown): void;
}
const real_clock: LoopClock = {
now: () => performance.now(),
schedule: (fn, ms) => setTimeout(fn, ms),
clear: (handle) => clearTimeout(handle as number),
};
export interface LoopStats {
// ticks that ran in the last second
tps: number;
// average milliseconds a tick took over the last second
mspt: number;
// ticks skipped because the game couldn't keep up, since it started
skipped: number;
}
export class GameLoop {
readonly tick_ms: number;
#tick: () => void;
#clock: LoopClock;
#timer: unknown;
#running = false;
#next_tick = 0;
#last_warning = -Infinity;
// ticks in the last second, as [when it started, how long it took]
#recent: [number, number][] = [];
#skipped = 0;
constructor(tick: () => void, tps = TICKS_PER_SECOND, clock: LoopClock = real_clock) {
this.#tick = tick;
this.tick_ms = 1000 / tps;
this.#clock = clock;
}
start() {
if (this.#running) return;
this.#running = true;
this.#next_tick = this.#clock.now();
this.#run();
}
stop() {
this.#running = false;
this.#clock.clear(this.#timer);
}
get stats(): LoopStats {
const now = this.#clock.now();
const recent = this.#recent.filter(([at]) => now - at < 1000);
const total = recent.reduce((sum, [, took]) => sum + took, 0);
return { tps: recent.length, mspt: recent.length ? total / recent.length : 0, skipped: this.#skipped };
}
#run() {
if (!this.#running) return;
let ran = 0;
while (this.#clock.now() >= this.#next_tick && ran < MAX_CATCH_UP_TICKS) {
const started = this.#clock.now();
try {
this.#tick();
} catch (e) {
// one bad tick shouldn't stop the world
console.error("Tick failed:", e);
}
const took = this.#clock.now() - started;
this.#recent.push([started, took]);
this.#next_tick += this.tick_ms;
ran++;
}
const now = this.#clock.now();
this.#recent = this.#recent.filter(([at]) => now - at < 1000);
const behind = Math.floor((now - this.#next_tick) / this.tick_ms);
if (behind > 0) {
// catching up would mean running the game in fast forward for a while, skip instead
this.#skipped += behind;
this.#next_tick += behind * this.tick_ms;
if (now - this.#last_warning > WARN_INTERVAL_MS) {
this.#last_warning = now;
console.warn(
`Can't keep up, skipped ${behind} ticks (${Math.round(behind * this.tick_ms)} ms). ` +
`Ticks take ${this.stats.mspt.toFixed(1)} ms on average, ${this.tick_ms} ms is the budget.`,
);
}
}
this.#timer = this.#clock.schedule(() => this.#run(), Math.max(0, this.#next_tick - this.#clock.now()));
}
}