Adding up the frame's dt in On update makes anything that counts in steps depend on the frame rate. A pitch sweep sounds a little different on every run. On fixed update runs 60 times per second of game time with dt exactly 1/60, so the same graph gives the same result every time. - fixedSteps in src/core/time.ts is the one rule for the fixed step: spend the frame in steps of 1/60, keep the rest, catch up 8 steps at most after a hitch. - The physics now counts its steps with fixedSteps too. It behaves as before, and On fixed update runs exactly as often as the engine steps. - The steps run after On mouse wheel and before On update, each as a kind of its own with the usual Order setting. - Each step gets its own context, so a node inside that reads dt (Time, Move, Apply force) gets 1/60 rather than the frame. - A paused game has no steps, also for a behavior that runs while paused. - The engine still takes all of a frame's steps before the behaviors run. A force set in On fixed update acts from the next frame on. Tests: - fixed-update.test.ts (new): 5 tests. Step counting, uneven frames adding up to 60 steps a second, the hitch cap, two steps at 30 Hz before On update with dt 1/60 for the event and for Time, one step every other frame at 120 Hz, none while paused. - event-order, node-catalog, physics-system, game-pause: 430 tests together with the new file, all green. - Counter-check: with the frame's dt inside a step and without the pause guard both world tests fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72 lines
2.5 KiB
TypeScript
72 lines
2.5 KiB
TypeScript
/**
|
||
* The one clock everything time-based reads: the world's delta, particle age and shader time. It
|
||
* accumulates scaled frames instead of reading the wall clock, which is what makes a global time
|
||
* scale a single multiplication – a paused game is scale 0, and nothing underneath has to be
|
||
* rebased because the clock itself stands still.
|
||
*/
|
||
|
||
/** A frame longer than this (a breakpoint, a hidden tab) is treated as this long. */
|
||
export const MAX_FRAME = 0.1;
|
||
|
||
/**
|
||
* The scale that actually applies: a pause is a scale of 0, and so is anything that is not a
|
||
* positive number – time never runs backwards.
|
||
*/
|
||
export function effectiveScale(scale: number, paused: boolean): number {
|
||
if (paused || !Number.isFinite(scale) || scale <= 0) return 0;
|
||
return scale;
|
||
}
|
||
|
||
/** The fixed step: physics and `On fixed update` move this far at a time, whatever the frame took. */
|
||
export const FIXED_STEP = 1 / 60;
|
||
/** After a hitch a fixed clock catches up this many steps at most, and forgets the rest. */
|
||
export const MAX_FIXED_STEPS = 8;
|
||
|
||
/**
|
||
* How many fixed steps a frame of `dt` brings, and what it leaves for the next frame. A short frame
|
||
* may bring none, a long one several; over time the steps add up to the scaled time that passed.
|
||
*/
|
||
export function fixedSteps(backlog: number, dt: number): { steps: number; backlog: number } {
|
||
let left = backlog + Math.max(0, dt);
|
||
let steps = 0;
|
||
while (left >= FIXED_STEP - 1e-9 && steps < MAX_FIXED_STEPS) {
|
||
left -= FIXED_STEP;
|
||
steps++;
|
||
}
|
||
return { steps, backlog: steps === MAX_FIXED_STEPS ? 0 : Math.max(0, left) };
|
||
}
|
||
|
||
/** How far the clock moves in one frame. */
|
||
export function timeStep(dt: number, scale: number, paused: boolean): number {
|
||
return Math.min(Math.max(dt, 0), MAX_FRAME) * effectiveScale(scale, paused);
|
||
}
|
||
|
||
export class GameClock {
|
||
private seconds = 0;
|
||
/** The global time scale: 1 is real time, 2 twice as fast, 0 frozen. */
|
||
scale = 1;
|
||
|
||
/** Seconds the clock has run, in its own scaled time. */
|
||
get now(): number {
|
||
return this.seconds;
|
||
}
|
||
|
||
/** Moves the clock on and returns the step, which is the delta every ticked world gets. */
|
||
advance(dt: number, paused = false): number {
|
||
const step = timeStep(dt, this.scale, paused);
|
||
this.seconds += step;
|
||
return step;
|
||
}
|
||
|
||
/**
|
||
* Back to second zero and real time – a run starts there and leaves nothing behind (#167).
|
||
* Anything holding an absolute second of this clock has to start over.
|
||
*/
|
||
reset(): void {
|
||
this.seconds = 0;
|
||
this.scale = 1;
|
||
}
|
||
}
|
||
|
||
export const gameClock = new GameClock();
|