Files
feuerwerk/src/core/time.ts
T
adminandClaude Opus 5.5 ded0022903 Run behaviors on a fixed step with an On fixed update node
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>
2026-10-05 21:46:15 +02:00

72 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();