diff --git a/src/core/graph/nodes/events/OnFixedUpdate.ts b/src/core/graph/nodes/events/OnFixedUpdate.ts new file mode 100644 index 00000000..980cb873 --- /dev/null +++ b/src/core/graph/nodes/events/OnFixedUpdate.ts @@ -0,0 +1,17 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; +import { exec, num } from '../shared'; + +export const OnFixedUpdate: NodeDef = { + type: 'OnFixedUpdate', + label: 'On fixed update', + category: 'event', + description: + 'Runs 60 times a second of game time, with dt always 1/60, however long the frame took: a short ' + + 'frame may have none, a long one several, each before On update. As often as physics steps. ' + + 'Stands still while the game is paused.', + inputs: [], + outputs: [exec(), num('dt')], + params: [orderParam], + hiddenParams: hiddenOrder, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index dcbe6ae5..217ff5ab 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -124,6 +124,7 @@ import { OnCollisionStay } from './events/OnCollisionStay'; import { OnDestroy } from './events/OnDestroy'; import { OnDisable } from './events/OnDisable'; import { OnEnable } from './events/OnEnable'; +import { OnFixedUpdate } from './events/OnFixedUpdate'; import { OnKeyDown } from './events/OnKeyDown'; import { OnKeyHeld } from './events/OnKeyHeld'; import { OnKeyUp } from './events/OnKeyUp'; @@ -159,6 +160,7 @@ import { Variables } from './variable/Variables'; export const BUILT_IN_NODES: readonly NodeDef[] = [ OnStart, OnEnable, + OnFixedUpdate, OnUpdate, OnDisable, OnDestroy, diff --git a/src/core/time.ts b/src/core/time.ts index cba0102d..47e927ae 100644 --- a/src/core/time.ts +++ b/src/core/time.ts @@ -17,6 +17,25 @@ export function effectiveScale(scale: number, paused: boolean): number { 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); diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index d8fb42a6..5c3727a4 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -22,6 +22,7 @@ import { shapesOverlap, } from '../../core/properties/collider'; import { hasTransformProps } from '../../core/properties/PropertySchema'; +import { FIXED_STEP, fixedSteps } from '../../core/time'; import { type BodyType, colliderMaterial, @@ -66,10 +67,8 @@ import { type Rapier, toMetres, toPixels } from './engine'; import { type JointHost, JointSet, type JointState, type RigidOwner } from './joints'; import { passesOneWay, reachOf } from './oneWay'; -/** The engine always steps this long, whatever the frame took. */ -export const PHYSICS_STEP = 1 / 60; -/** After a hitch the world catches up this many steps at most, and forgets the rest. */ -const MAX_STEPS_PER_TICK = 8; +/** The engine always steps this long, whatever the frame took – as often as `On fixed update` runs. */ +export const PHYSICS_STEP = FIXED_STEP; /** * One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below @@ -217,19 +216,16 @@ export class PhysicsSystem implements System, CollisionSource { this.exits = []; this.sync(world); engine.gravity = this.gravity(world); - this.backlog += dt; - let steps = 0; + const { steps, backlog } = fixedSteps(this.backlog, dt); + this.backlog = backlog; const entered = new Set(); - while (this.backlog >= PHYSICS_STEP - 1e-9 && steps < MAX_STEPS_PER_TICK) { + for (let step = 0; step < steps; step++) { for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current; this.decideOneWay(world); engine.step(this.queue!, this.hooks ?? undefined); for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body); this.collect(world, entered); - this.backlog -= PHYSICS_STEP; - steps++; } - this.backlog = steps === MAX_STEPS_PER_TICK ? 0 : Math.max(0, this.backlog); this.writeBack(this.backlog / PHYSICS_STEP); this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair); this.triggers.update(world); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 91636d69..2332c37d 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -31,6 +31,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties'; import { treeOrder } from '../../core/model/hierarchy'; import { resolveObjects } from '../../core/model/instance'; import { findAssetOf, findScene, type Project } from '../../core/model/Project'; +import { FIXED_STEP, fixedSteps } from '../../core/time'; import { type AudioSink, SILENT_AUDIO } from './Audio'; import type { Entity } from '../Entity'; import type { World } from '../World'; @@ -182,6 +183,8 @@ export class BehaviorSystem implements System { private restarted: ReadonlySet = new Set(); /** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */ private pending: StateChange[] = []; + /** Game time not yet spent on a fixed step (see `fixedSteps`); counted like the physics' own. */ + private fixedBacklog = 0; /** Set when the scene may have been edited, so the next sync puts the instances in order again. */ private unranked = true; /** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */ @@ -630,14 +633,22 @@ export class BehaviorSystem implements System { y: pointer.y, }) : [], - () => pulsesOf(ticking, 'OnUpdate', { dt }), ]; - for (const kind of kinds) { - this.runPulses(kind(), contextOf, true); + const run = (pulses: Pulse[], context = contextOf) => { + this.runPulses(pulses, context, true); // After the kind, not inside it: a state entered halfway through `On update` must not push // its events between the nodes that are still to run. this.flushStateChanges(); + }; + for (const kind of kinds) run(kind()); + if (!frozen) { + const fixed = fixedSteps(this.fixedBacklog, dt); + this.fixedBacklog = fixed.backlog; + // A context of its own per step: what a node reads as `dt` inside is the step, not the frame. + for (let step = 0; step < fixed.steps; step++) + run(pulsesOf(ticking, 'OnFixedUpdate', { dt: FIXED_STEP }), this.contexts(world, FIXED_STEP)); } + run(pulsesOf(ticking, 'OnUpdate', { dt })); this.reap(world); this.flushStateChanges(); this.persist(); diff --git a/tests/core/event-order.test.ts b/tests/core/event-order.test.ts index 40142688..5660b71e 100644 --- a/tests/core/event-order.test.ts +++ b/tests/core/event-order.test.ts @@ -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), diff --git a/tests/core/fixed-update.test.ts b/tests/core/fixed-update.test.ts new file mode 100644 index 00000000..7d6073d6 --- /dev/null +++ b/tests/core/fixed-update.test.ts @@ -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
` 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']); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index c1a1c7c9..bdbc037b 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2131,6 +2131,7 @@ const EVENT_NODES: Record = { 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', };