deadhead-space-freight-tycoon/packages/engine/src/loop.ts

66 lines
2.2 KiB
TypeScript

import type { RealTime } from "@deadhead/sim";
import type { GameEngine } from "./game/engine";
/**
* What the loop needs from the platform (CR-014 Step 14.6): a timer, and a way
* to hear that the app is back in front (browser `visibilitychange`, Capacitor
* `resume`). Injected so the engine stays testable.
*/
export interface LoopHost {
setInterval(fn: () => void, ms: number): unknown;
clearInterval(handle: unknown): void;
/** Calls `fn` whenever the app comes back to the foreground; returns an unsubscribe. */
onResume(fn: () => void): () => void;
}
export const RESOLUTION_INTERVAL_MS = 5_000;
/**
* Keeps the game current while it's open: on start (the app has just loaded),
* on every resume, and every few seconds, it resolves due arrivals (the same
* idempotent resolution every command runs first) and reports when anything
* changed, including a rest or a shop job ending, so the screen can refresh.
*/
export class ResolutionLoop {
#handle: unknown = null;
#unsubscribe: (() => void) | null = null;
#since: RealTime | null = null;
#running: Promise<void> | null = null;
constructor(
private readonly engine: GameEngine,
private readonly host: LoopHost,
private readonly onChange: () => void,
private readonly intervalMs = RESOLUTION_INTERVAL_MS,
) {}
/** Resolves once now, then keeps going until `stop`. */
async start(): Promise<void> {
this.stop();
this.#handle = this.host.setInterval(() => void this.tick(), this.intervalMs);
this.#unsubscribe = this.host.onResume(() => void this.tick());
await this.tick();
}
stop(): void {
if (this.#handle !== null) this.host.clearInterval(this.#handle);
this.#handle = null;
this.#unsubscribe?.();
this.#unsubscribe = null;
}
/** One pass (ticks never overlap: a tick that is still running is reused). */
tick(): Promise<void> {
this.#running ??= (async () => {
try {
const result = await this.engine.resolveDue(this.#since);
this.#since = result.now;
if (result.resolved > 0 || result.eventsPassed > 0) this.onChange();
} finally {
this.#running = null;
}
})();
return this.#running;
}
}