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>
This commit is contained in:
1 parent
c7e9769f90
commit
ded0022903
8 files changed
+140
-14
No files matched your search
@@ -104,7 +104,7 @@ describe('the order rule', () => {
|
||||
const events = BUILT_IN_NODES.filter(
|
||||
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
|
||||
);
|
||||
expect(events).toHaveLength(17);
|
||||
expect(events).toHaveLength(18);
|
||||
for (const def of events) {
|
||||
expect(
|
||||
def.params.map((p) => p.key),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `On fixed update`: 60 steps a second of game time, each with dt 1/60, however the frames fall –
|
||||
* counted by the same rule the physics steps by.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { FIXED_STEP, fixedSteps, MAX_FIXED_STEPS } from '../../src/core/time';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
describe('fixed steps', () => {
|
||||
it('spends a frame in steps of 1/60 and keeps the rest for the next', () => {
|
||||
expect(fixedSteps(0, FIXED_STEP).steps).toBe(1);
|
||||
expect(fixedSteps(0, 1 / 30).steps).toBe(2);
|
||||
expect(fixedSteps(0, 0).steps).toBe(0);
|
||||
const half = fixedSteps(0, 1 / 120);
|
||||
expect(half.steps).toBe(0);
|
||||
expect(fixedSteps(half.backlog, 1 / 120).steps).toBe(1);
|
||||
});
|
||||
|
||||
it('adds up to the time that passed, however uneven the frames', () => {
|
||||
let backlog = 0;
|
||||
let steps = 0;
|
||||
for (let i = 0; i < 20; i++)
|
||||
for (const dt of [0.01, 0.02, 0.005, 0.015]) {
|
||||
const next = fixedSteps(backlog, dt);
|
||||
backlog = next.backlog;
|
||||
steps += next.steps;
|
||||
}
|
||||
expect(steps).toBe(60);
|
||||
});
|
||||
|
||||
it('catches up a hitch only so far and forgets the rest', () => {
|
||||
expect(fixedSteps(0, 1)).toEqual({ steps: MAX_FIXED_STEPS, backlog: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
/** A behavior logging `fixed <dt of the event> <dt a node reads>` per step and `update` per frame. */
|
||||
function stepped(runWhilePaused = false) {
|
||||
const f = makeSpriteScene('Box');
|
||||
const behavior = makeBehavior(f, 'Engine');
|
||||
if (runWhilePaused) f.stack.execute(new SetAssetPropertyCommand(behavior.id, 'runWhilePaused', true));
|
||||
const edit = graphEditor(f, behavior);
|
||||
const fixed = edit.addNode('OnFixedUpdate');
|
||||
const eventDt = edit.addNode('Log');
|
||||
const nodeDt = edit.addNode('Log');
|
||||
const update = edit.addNode('OnUpdate');
|
||||
edit.wire([
|
||||
[fixed, 'exec', eventDt, 'exec'],
|
||||
[fixed, 'dt', eventDt, 'message'],
|
||||
[eventDt, 'exec', nodeDt, 'exec'],
|
||||
[edit.addNode('Time'), 'dt', nodeDt, 'message'],
|
||||
[update, 'exec', edit.addNode('Log', { message: 'update' }), 'exec'],
|
||||
]);
|
||||
place(f, behavior.id, { x: 0, y: 0 });
|
||||
return startWorld(f, { behaviors: true });
|
||||
}
|
||||
|
||||
const STEP = String(FIXED_STEP);
|
||||
|
||||
describe('On fixed update', () => {
|
||||
it('runs once per 1/60 of game time, before On update, and dt inside is the step', () => {
|
||||
const { world, frame } = stepped();
|
||||
frame(1 / 30);
|
||||
expect(world.log).toEqual([STEP, STEP, STEP, STEP, 'update']);
|
||||
|
||||
world.log.length = 0;
|
||||
frame(1 / 120);
|
||||
expect(world.log).toEqual(['update']);
|
||||
frame(1 / 120);
|
||||
expect(world.log).toEqual(['update', STEP, STEP, 'update']);
|
||||
});
|
||||
|
||||
it('stands still while the game is paused, also for a behavior that runs then', () => {
|
||||
const { world, frame } = stepped(true);
|
||||
world.paused = true;
|
||||
frame(1 / 30);
|
||||
frame(1 / 30);
|
||||
expect(world.log).toEqual(['update', 'update']);
|
||||
});
|
||||
});
|
||||
@@ -2131,6 +2131,7 @@ const EVENT_NODES: Record<string, string> = {
|
||||
OnClick: 'nodes.test.ts',
|
||||
OnMouseWheel: 'clip-areas.test.ts',
|
||||
OnAnimationFinished: 'animation.test.ts',
|
||||
OnFixedUpdate: 'fixed-update.test.ts',
|
||||
OnStateEntered: 'state-machine.test.ts',
|
||||
OnStateExited: 'state-machine.test.ts',
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user