From 6fecb2e84115a4c5c50270bfb8abb49231a5ebb4 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 07:44:53 +0200 Subject: [PATCH] Tell On timer how late its frame is, so time minus late is exact (#224) 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 --- src/core/graph/nodes/events/OnTimer.ts | 7 ++++--- src/runtime/systems/BehaviorSystem.ts | 4 +++- tests/core/behavior-events.test.ts | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/core/graph/nodes/events/OnTimer.ts b/src/core/graph/nodes/events/OnTimer.ts index a400b59e..740a3f8e 100644 --- a/src/core/graph/nodes/events/OnTimer.ts +++ b/src/core/graph/nodes/events/OnTimer.ts @@ -1,12 +1,13 @@ import type { NodeDef } from '../../NodeRegistry'; -import { exec } from '../shared'; +import { exec, num } from '../shared'; export const OnTimer: NodeDef = { type: 'OnTimer', label: 'On timer', 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: [], - outputs: [exec()], + outputs: [exec(), num('late')], params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }], }; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 6359ce9f..2fdfed75 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -453,9 +453,11 @@ export class BehaviorSystem implements System { i.nodeState.set(node.id, state); const interval = Math.max(0.01, Number(node.params.interval ?? 1)); 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) { 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. diff --git a/tests/core/behavior-events.test.ts b/tests/core/behavior-events.test.ts index 464794f9..a50a05b4 100644 --- a/tests/core/behavior-events.test.ts +++ b/tests/core/behavior-events.test.ts @@ -262,6 +262,24 @@ describe('On timer', () => { 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', () => { const f = scene(); logOn(f, 'OnTimer', { interval: 1 }, 'slow');