108 lines
3.1 KiB
TypeScript
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()));
|
|
}
|
|
}
|