feat: bootstrap walking skeleton
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { createGameState, startAction, tickGame } from '../game';
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }],
|
||||
actions: [
|
||||
{ id: 'forage', name: 'Forage', durationMs: 300, yields: { resourceId: 'gold', amount: 2 } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('createGameState()', () => {
|
||||
it('seeds resource amounts from their start amounts with no active action', () => {
|
||||
const state = createGameState(testContent());
|
||||
expect(state.resources.gold).toBe(5);
|
||||
expect(state.activeActionId).toBeNull();
|
||||
expect(state.actionElapsedMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startAction()', () => {
|
||||
it('activates the action and resets its progress', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
state.actionElapsedMs = 999;
|
||||
startAction(state, content, 'forage');
|
||||
expect(state.activeActionId).toBe('forage');
|
||||
expect(state.actionElapsedMs).toBe(0);
|
||||
});
|
||||
|
||||
it('throws on an unknown action id', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
expect(() => startAction(state, content, 'nope')).toThrow(/unknown action/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickGame()', () => {
|
||||
it('does nothing when no action is active', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
tickGame(state, content, 100);
|
||||
expect(state.resources.gold).toBe(5);
|
||||
});
|
||||
|
||||
it('advances action progress without yielding before completion', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
tickGame(state, content, 100); // 100 of 300ms
|
||||
expect(state.resources.gold).toBe(5);
|
||||
expect(state.actionElapsedMs).toBe(100);
|
||||
});
|
||||
|
||||
it('grants the yield on completion and repeats, carrying the remainder', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
tickGame(state, content, 100);
|
||||
tickGame(state, content, 100);
|
||||
tickGame(state, content, 100); // 300ms -> one completion, +2 gold
|
||||
expect(state.resources.gold).toBe(7);
|
||||
expect(state.actionElapsedMs).toBe(0);
|
||||
});
|
||||
|
||||
it('handles multiple completions within a single large tick (offline catch-up)', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
tickGame(state, content, 1000); // 3 completions (900ms) + 100ms remainder
|
||||
expect(state.resources.gold).toBe(11); // 5 + 3*2
|
||||
expect(state.actionElapsedMs).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatNum, num } from '../num';
|
||||
|
||||
describe('num()', () => {
|
||||
it('returns the same numeric value, branded', () => {
|
||||
expect(num(42)).toBe(42);
|
||||
expect(num(0)).toBe(0);
|
||||
expect(num(-7.5)).toBe(-7.5);
|
||||
});
|
||||
|
||||
it('rejects non-finite values', () => {
|
||||
expect(() => num(Number.NaN)).toThrow(RangeError);
|
||||
expect(() => num(Number.POSITIVE_INFINITY)).toThrow(RangeError);
|
||||
expect(() => num(Number.NEGATIVE_INFINITY)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNum()', () => {
|
||||
it('formats values under 1000 without a suffix', () => {
|
||||
expect(formatNum(0)).toBe('0');
|
||||
expect(formatNum(42)).toBe('42');
|
||||
expect(formatNum(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('trims fractional values to at most two decimals', () => {
|
||||
expect(formatNum(12.5)).toBe('12.5');
|
||||
expect(formatNum(12.3456)).toBe('12.35');
|
||||
expect(formatNum(7.0)).toBe('7');
|
||||
});
|
||||
|
||||
it('uses K / M / B suffixes for larger magnitudes', () => {
|
||||
expect(formatNum(1000)).toBe('1K');
|
||||
expect(formatNum(1234)).toBe('1.23K');
|
||||
expect(formatNum(12_000)).toBe('12K');
|
||||
expect(formatNum(1_500_000)).toBe('1.5M');
|
||||
expect(formatNum(2_000_000_000)).toBe('2B');
|
||||
});
|
||||
|
||||
it('never uses scientific notation, even past a billion', () => {
|
||||
const s = formatNum(1_500_000_000_000);
|
||||
expect(s).not.toMatch(/e/i);
|
||||
expect(s).toBe('1,500B');
|
||||
});
|
||||
|
||||
it('preserves sign for negative values', () => {
|
||||
expect(formatNum(-42)).toBe('-42');
|
||||
expect(formatNum(-1500)).toBe('-1.5K');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { createGameState, startAction } from '../game';
|
||||
import {
|
||||
applyOfflineProgress,
|
||||
createSave,
|
||||
deserializeSave,
|
||||
fromExportString,
|
||||
SAVE_VERSION,
|
||||
serializeSave,
|
||||
toExportString,
|
||||
} from '../save';
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||
actions: [
|
||||
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function sampleState() {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
state.resources.gold = 12;
|
||||
startAction(state, content, 'forage');
|
||||
state.actionElapsedMs = 500;
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('createSave()', () => {
|
||||
it('stamps the current version and timestamp around a snapshot of state', () => {
|
||||
const save = createSave(sampleState(), 1700);
|
||||
expect(save.version).toBe(SAVE_VERSION);
|
||||
expect(save.savedAt).toBe(1700);
|
||||
expect(save.state.resources.gold).toBe(12);
|
||||
expect(save.state.activeActionId).toBe('forage');
|
||||
});
|
||||
|
||||
it('snapshots state so later mutation does not affect the save', () => {
|
||||
const state = sampleState();
|
||||
const save = createSave(state, 1700);
|
||||
state.resources.gold = 999;
|
||||
expect(save.state.resources.gold).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize / deserialize round-trip', () => {
|
||||
it('survives JSON serialization unchanged', () => {
|
||||
const save = createSave(sampleState(), 1700);
|
||||
const restored = deserializeSave(serializeSave(save));
|
||||
expect(restored).toEqual(save);
|
||||
});
|
||||
|
||||
it('survives the compressed export string unchanged', () => {
|
||||
const save = createSave(sampleState(), 1700);
|
||||
const restored = fromExportString(toExportString(save));
|
||||
expect(restored).toEqual(save);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid / tampered saves', () => {
|
||||
it('rejects a non-JSON export string cleanly', () => {
|
||||
expect(() => fromExportString('@@@not-a-valid-payload@@@')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects JSON that does not match the save schema', () => {
|
||||
expect(() => deserializeSave('{"version":1,"savedAt":1,"state":{}}')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unsupported save version', () => {
|
||||
const future = JSON.stringify({
|
||||
version: 999,
|
||||
savedAt: 1,
|
||||
state: { resources: {}, activeActionId: null, actionElapsedMs: 0 },
|
||||
});
|
||||
expect(() => deserializeSave(future)).toThrow(/version/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOfflineProgress()', () => {
|
||||
it('credits whole ticks of elapsed time to the active action', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage'); // 3000ms per gold
|
||||
const credited = applyOfflineProgress(state, content, 1000, 1000 + 9000); // 9s
|
||||
expect(credited).toBe(9000);
|
||||
expect(state.resources.gold).toBe(3); // 9000 / 3000
|
||||
});
|
||||
|
||||
it('credits nothing when the clock did not advance', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
expect(applyOfflineProgress(state, content, 5000, 4000)).toBe(0);
|
||||
expect(state.resources.gold).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps credited time to the offline cap', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
const credited = applyOfflineProgress(state, content, 0, 10_000_000, 6000);
|
||||
expect(credited).toBe(6000);
|
||||
expect(state.resources.gold).toBe(2); // 6000 / 3000
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { advance, createTickLoop } from '../tickLoop';
|
||||
|
||||
describe('createTickLoop()', () => {
|
||||
it('defaults to the standard tick rate and an empty accumulator', () => {
|
||||
const loop = createTickLoop();
|
||||
expect(loop.tickMs).toBe(100);
|
||||
expect(loop.tickCount).toBe(0);
|
||||
expect(loop.accumulatorMs).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts a custom tick length and start timestamp', () => {
|
||||
const loop = createTickLoop({ tickMs: 250, startNow: 1000 });
|
||||
expect(loop.tickMs).toBe(250);
|
||||
expect(loop.lastNow).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advance()', () => {
|
||||
it('establishes a baseline without running ticks on the first call when no startNow given', () => {
|
||||
const loop = createTickLoop({ tickMs: 100 });
|
||||
expect(advance(loop, 5000)).toBe(0);
|
||||
expect(loop.lastNow).toBe(5000);
|
||||
expect(advance(loop, 5100)).toBe(1);
|
||||
});
|
||||
|
||||
it('runs one tick per whole tickMs elapsed and keeps the remainder', () => {
|
||||
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
|
||||
expect(advance(loop, 350)).toBe(3);
|
||||
expect(loop.tickCount).toBe(3);
|
||||
expect(loop.accumulatorMs).toBe(50);
|
||||
});
|
||||
|
||||
it('invokes onTick once per tick with an increasing tick index', () => {
|
||||
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
|
||||
const seen: number[] = [];
|
||||
advance(loop, 300, (i) => seen.push(i));
|
||||
expect(seen).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('ignores backward clock movement', () => {
|
||||
const loop = createTickLoop({ tickMs: 100, startNow: 1000 });
|
||||
expect(advance(loop, 500)).toBe(0);
|
||||
expect(loop.lastNow).toBe(500);
|
||||
expect(loop.tickCount).toBe(0);
|
||||
});
|
||||
|
||||
it('yields the same total tick count for the same elapsed time regardless of chunking', () => {
|
||||
const tickMs = 100;
|
||||
const elapsedMs = 10_000;
|
||||
const expected = 100;
|
||||
|
||||
const whole = createTickLoop({ tickMs, startNow: 0 });
|
||||
expect(advance(whole, elapsedMs)).toBe(expected);
|
||||
|
||||
const chunked = createTickLoop({ tickMs, startNow: 0 });
|
||||
for (let t = 37; t < elapsedMs; t += 37) {
|
||||
advance(chunked, t);
|
||||
}
|
||||
advance(chunked, elapsedMs);
|
||||
expect(chunked.tickCount).toBe(expected);
|
||||
});
|
||||
|
||||
it('caps ticks per advance and defers the remainder so none are lost (offline catch-up path)', () => {
|
||||
const loop = createTickLoop({ tickMs: 100, startNow: 0 });
|
||||
// 10s of elapsed = 100 ticks owed, but cap at 10 per call.
|
||||
expect(advance(loop, 10_000, undefined, 10)).toBe(10);
|
||||
expect(loop.tickCount).toBe(10);
|
||||
|
||||
let guard = 0;
|
||||
while (advance(loop, 10_000, undefined, 10) > 0 && guard < 1000) {
|
||||
guard += 1;
|
||||
}
|
||||
expect(loop.tickCount).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Content } from '../content/schema';
|
||||
|
||||
/**
|
||||
* Core game state and per-tick simulation.
|
||||
*
|
||||
* Pure TS — no React, no DOM, no wall clock. The tick loop (tickLoop.ts) drives
|
||||
* `tickGame` once per tick; `now`/scheduling lives entirely outside this module.
|
||||
*/
|
||||
|
||||
export interface GameState {
|
||||
/** resourceId -> current amount. */
|
||||
resources: Record<string, number>;
|
||||
/** The action currently running, or null. */
|
||||
activeActionId: string | null;
|
||||
/** Progress of the active action, in milliseconds. */
|
||||
actionElapsedMs: number;
|
||||
}
|
||||
|
||||
export function createGameState(content: Content): GameState {
|
||||
const resources: Record<string, number> = {};
|
||||
for (const resource of content.resources) {
|
||||
resources[resource.id] = resource.startAmount;
|
||||
}
|
||||
return { resources, activeActionId: null, actionElapsedMs: 0 };
|
||||
}
|
||||
|
||||
/** Begin running an action, resetting its progress. Throws on an unknown id. */
|
||||
export function startAction(state: GameState, content: Content, actionId: string): void {
|
||||
if (!content.actionsById[actionId]) {
|
||||
throw new Error(`Unknown action "${actionId}"`);
|
||||
}
|
||||
state.activeActionId = actionId;
|
||||
state.actionElapsedMs = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the active action by `tickMs`. Each time it reaches its duration it
|
||||
* grants its yield and repeats, carrying the remainder — so one large tick (the
|
||||
* offline catch-up path) can complete an action many times.
|
||||
*/
|
||||
export function tickGame(state: GameState, content: Content, tickMs: number): void {
|
||||
if (!state.activeActionId) {
|
||||
return;
|
||||
}
|
||||
const action = content.actionsById[state.activeActionId];
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.actionElapsedMs += tickMs;
|
||||
while (state.actionElapsedMs >= action.durationMs) {
|
||||
state.actionElapsedMs -= action.durationMs;
|
||||
state.resources[action.yields.resourceId] += action.yields.amount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Numbers, engine-side.
|
||||
*
|
||||
* The design keeps values human-readable (≤ billions, K/M/B suffixes, never
|
||||
* scientific notation — see GDD D-0009). Plain JS numbers suffice; the `Num`
|
||||
* brand documents "this number is a game quantity" without runtime cost. If a
|
||||
* late prestige layer ever needs arbitrary precision, swap the brand for a
|
||||
* Decimal wrapper here — call sites that use `num()`/`formatNum()` stay put.
|
||||
*/
|
||||
|
||||
declare const numBrand: unique symbol;
|
||||
export type Num = number & { readonly [numBrand]: true };
|
||||
|
||||
/** Brand a finite number as a game quantity. Throws on NaN/Infinity. */
|
||||
export function num(value: number): Num {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new RangeError(`num() requires a finite value, got ${value}`);
|
||||
}
|
||||
return value as Num;
|
||||
}
|
||||
|
||||
const SUFFIX_TIERS = [
|
||||
{ limit: 1e9, div: 1e9, suffix: 'B' },
|
||||
{ limit: 1e6, div: 1e6, suffix: 'M' },
|
||||
{ limit: 1e3, div: 1e3, suffix: 'K' },
|
||||
] as const;
|
||||
|
||||
/** Round to ≤2 decimals, trim trailing zeros, add thousands separators. */
|
||||
function formatScaled(value: number): string {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
const raw = Number.isInteger(rounded)
|
||||
? String(rounded)
|
||||
: rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
|
||||
const [intPart, fracPart] = raw.split('.');
|
||||
const withSeparators = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return fracPart ? `${withSeparators}.${fracPart}` : withSeparators;
|
||||
}
|
||||
|
||||
/** Human-readable rendering: `1.23K`, `1.5M`, `2B`, `1,500B`. No `e` notation. */
|
||||
export function formatNum(value: Num | number): string {
|
||||
const n = value as number;
|
||||
if (!Number.isFinite(n)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const sign = n < 0 ? '-' : '';
|
||||
const abs = Math.abs(n);
|
||||
|
||||
for (const tier of SUFFIX_TIERS) {
|
||||
if (abs >= tier.limit) {
|
||||
return `${sign}${formatScaled(abs / tier.div)}${tier.suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${sign}${formatScaled(abs)}`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
|
||||
import { z } from 'zod';
|
||||
import type { Content } from '../content/schema';
|
||||
import type { GameState } from './game';
|
||||
import { tickGame } from './game';
|
||||
import { TICK_MS } from './tickLoop';
|
||||
|
||||
/**
|
||||
* Save format, serialization, and offline crediting.
|
||||
*
|
||||
* Pure TS — no IndexedDB, no DOM. The IO adapter (idb-keyval + localStorage
|
||||
* fallback) lives in src/state/persistence.ts; this module only turns game state
|
||||
* into a validated, versioned, compressed string and back. Saves carry a
|
||||
* `version` so future migrations have a hook; for now a version mismatch is
|
||||
* rejected cleanly rather than silently coerced.
|
||||
*/
|
||||
|
||||
export const SAVE_VERSION = 1;
|
||||
|
||||
/** Default cap on credited offline time: 24 hours. */
|
||||
export const DEFAULT_MAX_OFFLINE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const gameStateSchema = z.object({
|
||||
resources: z.record(z.string(), z.number()),
|
||||
activeActionId: z.string().nullable(),
|
||||
actionElapsedMs: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
export const saveSchema = z.object({
|
||||
version: z.number().int().nonnegative(),
|
||||
savedAt: z.number().nonnegative(),
|
||||
state: gameStateSchema,
|
||||
});
|
||||
|
||||
export type SaveData = z.infer<typeof saveSchema>;
|
||||
|
||||
/** Snapshot the current state into a versioned, timestamped save. */
|
||||
export function createSave(state: GameState, now: number): SaveData {
|
||||
return {
|
||||
version: SAVE_VERSION,
|
||||
savedAt: now,
|
||||
state: {
|
||||
resources: { ...state.resources },
|
||||
activeActionId: state.activeActionId,
|
||||
actionElapsedMs: state.actionElapsedMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeSave(save: SaveData): string {
|
||||
return JSON.stringify(save);
|
||||
}
|
||||
|
||||
/** Parse + validate a save from JSON. Throws on malformed or unsupported saves. */
|
||||
export function deserializeSave(json: string): SaveData {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
const save = saveSchema.parse(parsed);
|
||||
if (save.version !== SAVE_VERSION) {
|
||||
throw new Error(
|
||||
`Unsupported save version ${save.version} (this build expects ${SAVE_VERSION})`,
|
||||
);
|
||||
}
|
||||
return save;
|
||||
}
|
||||
|
||||
export function toExportString(save: SaveData): string {
|
||||
return compressToEncodedURIComponent(serializeSave(save));
|
||||
}
|
||||
|
||||
/** Decompress + validate an export string. Throws cleanly on tampered input. */
|
||||
export function fromExportString(compressed: string): SaveData {
|
||||
const json = decompressFromEncodedURIComponent(compressed);
|
||||
if (json === null || json === '') {
|
||||
throw new Error('Save string is corrupt or empty');
|
||||
}
|
||||
return deserializeSave(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit elapsed wall-clock time to the running simulation on load, using the
|
||||
* same per-tick path the live loop uses. Elapsed is clamped to `maxOfflineMs`
|
||||
* and floored to whole ticks. Returns the milliseconds actually credited.
|
||||
*/
|
||||
export function applyOfflineProgress(
|
||||
state: GameState,
|
||||
content: Content,
|
||||
savedAt: number,
|
||||
now: number,
|
||||
maxOfflineMs: number = DEFAULT_MAX_OFFLINE_MS,
|
||||
): number {
|
||||
const elapsed = Math.max(0, now - savedAt);
|
||||
const credited = Math.min(elapsed, maxOfflineMs);
|
||||
const ticks = Math.floor(credited / TICK_MS);
|
||||
for (let i = 0; i < ticks; i += 1) {
|
||||
tickGame(state, content, TICK_MS);
|
||||
}
|
||||
return credited;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Fixed-timestep tick loop with an accumulator.
|
||||
*
|
||||
* The engine advances in whole ticks of `tickMs`. `advance(loop, now)` folds the
|
||||
* elapsed wall-clock time into an accumulator and drains it one tick at a time,
|
||||
* keeping the sub-tick remainder so results are independent of how the caller
|
||||
* chunks calls (determinism — see the test suite).
|
||||
*
|
||||
* Offline catch-up is the *same* code path: a large `now` delta simply owes many
|
||||
* ticks. `maxTicks` caps how many run per call so a huge delta can't lock the
|
||||
* thread; the remainder stays in the accumulator and drains on the next call,
|
||||
* so no ticks are ever lost. Clamping the *credited* offline window lives in the
|
||||
* save layer (T3.3), not here.
|
||||
*/
|
||||
|
||||
export const TICK_HZ = 10;
|
||||
export const TICK_MS = 1000 / TICK_HZ;
|
||||
export const DEFAULT_MAX_TICKS_PER_ADVANCE = 100_000;
|
||||
|
||||
export interface TickLoop {
|
||||
/** Milliseconds of simulated time per tick. */
|
||||
readonly tickMs: number;
|
||||
/** Total ticks run over this loop's lifetime. */
|
||||
tickCount: number;
|
||||
/** Sub-tick wall-clock remainder, in milliseconds. */
|
||||
accumulatorMs: number;
|
||||
/** Last timestamp seen, or null until the first `advance` establishes a baseline. */
|
||||
lastNow: number | null;
|
||||
}
|
||||
|
||||
export function createTickLoop(opts?: { tickMs?: number; startNow?: number }): TickLoop {
|
||||
const tickMs = opts?.tickMs ?? TICK_MS;
|
||||
if (!(tickMs > 0)) {
|
||||
throw new RangeError(`tickMs must be > 0, got ${tickMs}`);
|
||||
}
|
||||
return {
|
||||
tickMs,
|
||||
tickCount: 0,
|
||||
accumulatorMs: 0,
|
||||
lastNow: opts?.startNow ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the loop to `now`, running owed ticks (capped at `maxTicks`).
|
||||
* Returns the number of ticks run this call.
|
||||
*/
|
||||
export function advance(
|
||||
loop: TickLoop,
|
||||
now: number,
|
||||
onTick?: (tickIndex: number) => void,
|
||||
maxTicks: number = DEFAULT_MAX_TICKS_PER_ADVANCE,
|
||||
): number {
|
||||
if (loop.lastNow === null) {
|
||||
loop.lastNow = now;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const elapsed = now - loop.lastNow;
|
||||
loop.lastNow = now;
|
||||
if (elapsed > 0) {
|
||||
loop.accumulatorMs += elapsed;
|
||||
}
|
||||
|
||||
let ran = 0;
|
||||
while (loop.accumulatorMs >= loop.tickMs && ran < maxTicks) {
|
||||
loop.accumulatorMs -= loop.tickMs;
|
||||
loop.tickCount += 1;
|
||||
ran += 1;
|
||||
onTick?.(loop.tickCount);
|
||||
}
|
||||
return ran;
|
||||
}
|
||||
Reference in New Issue
Block a user