Tell On timer how late its frame is, so time minus late is exact (#224)
game-maker / gate (push) Successful in 2m55s
game-maker / suite (push) Failing after 9m22s

A timer can only fire on a frame. It fires on the first frame past each
due time, so Time.time at that moment is the due time plus up to one
frame. On a display that is not exactly 60 Hz this delay creeps up by
a millisecond every few seconds and falls back to zero once it reaches
a whole frame. The issue saw 82.009 after 82 ticks. The timer itself
does not drift, it keeps the remainder when it fires.

- On timer gets a number output late. It is what is left of the
  timer's clock after taking one interval, so time - late is exactly
  the due time.
- When one long frame catches up several ticks, each gets its own
  late. The first one was due longest ago and is the latest.

Tests:
- New unit test in behavior-events: late 0.125 after a frame past
  1.0 s, 0 on the dot, 1.5 and 0.5 for two ticks caught up in one
  frame.
- Counter-check: late taken before the interval is subtracted fails
  exactly that test. Original restored with cmp.
- ./check.sh: 156 files, 2300 tests, CHECK EXIT 0 (was 2299).
- No e2e change. The e2e modules only wire the exec of On timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-24 07:44:53 +02:00
1 parent aef1eba917
commit 6fecb2e841
3 files changed
+25 -4

No files matched your search

+4 -3
View File
@@ -1,12 +1,13 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { exec } from '../shared'; import { exec, num } from '../shared';
export const OnTimer: NodeDef = { export const OnTimer: NodeDef = {
type: 'OnTimer', type: 'OnTimer',
label: 'On timer', label: 'On timer',
category: 'event', category: 'event',
description: 'Runs every N seconds.', description:
'Runs every N seconds. It can only run on a frame, so late says how far this frame is past the due time.',
inputs: [], inputs: [],
outputs: [exec()], outputs: [exec(), num('late')],
params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }], params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }],
}; };
+3 -1
View File
@@ -453,9 +453,11 @@ export class BehaviorSystem implements System {
i.nodeState.set(node.id, state); i.nodeState.set(node.id, state);
const interval = Math.max(0.01, Number(node.params.interval ?? 1)); const interval = Math.max(0.01, Number(node.params.interval ?? 1));
state.elapsed = (state.elapsed ?? 0) + dt; state.elapsed = (state.elapsed ?? 0) + dt;
// What is left after taking one interval is how long ago this tick was due. When one
// frame catches up several ticks, the first one was due longest ago and is the latest.
while (state.elapsed >= interval) { while (state.elapsed >= interval) {
state.elapsed -= interval; state.elapsed -= interval;
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id); i.fire('OnTimer', ctx, { late: state.elapsed }, (n) => n.id === node.id);
} }
} }
// Neither system ticked in a paused frame: what they hold is last frame's, already told. // Neither system ticked in a paused frame: what they hold is last frame's, already told.
+18
View File
@@ -262,6 +262,24 @@ describe('On timer', () => {
expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']); expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']);
}); });
it('late is how far the firing frame is past the due time, so time minus late is exact', () => {
const f = scene();
const edit = graphEditor(f, f.behavior);
const timer = edit.addNode('OnTimer', { interval: 1 });
const log = edit.addNode('Log', {});
edit.connect(timer, 'exec', log, 'exec');
edit.connect(timer, 'late', log, 'message');
const { world, frame } = startWorld(f, { behaviors: true });
frame(0.75);
frame(0.375); // 1.125 s: due at 1.0
expect(world.log).toEqual(['0.125']);
frame(0.875); // 2.0 s: due exactly now
expect(world.log).toEqual(['0.125', '0']);
frame(2.5); // 4.5 s: catches up 3.0 and 4.0, the first one longer ago
expect(world.log).toEqual(['0.125', '0', '1.5', '0.5']);
});
it('two timers keep separate clocks', () => { it('two timers keep separate clocks', () => {
const f = scene(); const f = scene();
logOn(f, 'OnTimer', { interval: 1 }, 'slow'); logOn(f, 'OnTimer', { interval: 1 }, 'slow');