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>
222 lines
7.8 KiB
TypeScript
222 lines
7.8 KiB
TypeScript
/**
|
|
* When event nodes run within a frame (#264): event kind by event kind across every behavior, within a
|
|
* kind by the nodes' `order`, equal orders by the hierarchy and then by the order the nodes were made.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
CreateAssetCommand,
|
|
SetMachineScopeCommand,
|
|
SetMachineStartCommand,
|
|
} from '../../src/core/commands/assetCommands';
|
|
import { AddNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
|
import { ReorderObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
|
import {
|
|
BEHAVIOR_START,
|
|
ON_BEHAVIOR,
|
|
EVENT_ORDER,
|
|
eventOrder,
|
|
type GraphNode,
|
|
hiddenOrder,
|
|
hierarchyRanks,
|
|
inEventOrder,
|
|
MACHINE_PORT,
|
|
STATE,
|
|
STATE_NAME_PARAM,
|
|
} from '../../src/core/graph';
|
|
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
|
import { findAssetOf } from '../../src/core/model/Project';
|
|
import {
|
|
graphEditor,
|
|
makeBehavior,
|
|
makeSpriteScene,
|
|
place,
|
|
roundTrip,
|
|
type SceneFixture,
|
|
startWorld,
|
|
} from '../helpers';
|
|
|
|
const node = (id: string, order?: unknown): GraphNode =>
|
|
({
|
|
id,
|
|
type: 'OnUpdate',
|
|
x: 0,
|
|
y: 0,
|
|
params: order === undefined ? {} : { [EVENT_ORDER]: order },
|
|
}) as GraphNode;
|
|
|
|
/** Wires `<event> → Log(message)` into the behavior and returns the event node. */
|
|
function logOn(
|
|
f: SceneFixture,
|
|
behavior: BehaviorAsset,
|
|
event: string,
|
|
message: string,
|
|
params: Record<string, string | number> = {},
|
|
): GraphNode {
|
|
const edit = graphEditor(f, behavior);
|
|
const fired = edit.addNode(event, params);
|
|
edit.connect(fired, 'exec', edit.addNode('Log', { message }), 'exec');
|
|
return fired;
|
|
}
|
|
|
|
/** Two behaviors placed at the top level, `A` above `B`. */
|
|
function twoBehaviors(): SceneFixture & {
|
|
a: BehaviorAsset;
|
|
b: BehaviorAsset;
|
|
placedA: string;
|
|
placedB: string;
|
|
} {
|
|
const f = makeSpriteScene('Box');
|
|
const a = makeBehavior(f, 'A');
|
|
const b = makeBehavior(f, 'B');
|
|
const placedA = place(f, a.id, { x: 0, y: 0 });
|
|
const placedB = place(f, b.id, { x: 0, y: 0 });
|
|
return { ...f, a, b, placedA, placedB };
|
|
}
|
|
|
|
describe('the order rule', () => {
|
|
it('reads a missing, broken or fractional order as a whole number, 0 when there is none', () => {
|
|
expect(eventOrder(node('n'))).toBe(0);
|
|
expect(eventOrder(node('n', 'soon'))).toBe(0);
|
|
expect(eventOrder(node('n', -3))).toBe(-3);
|
|
expect(eventOrder(node('n', 2.4))).toBe(2);
|
|
});
|
|
|
|
it('sorts by order and keeps the arrival order among equals', () => {
|
|
const pulses = [
|
|
{ node: node('a', 5) },
|
|
{ node: node('b') },
|
|
{ node: node('c', -1) },
|
|
{ node: node('d') },
|
|
{ node: node('e', 5) },
|
|
];
|
|
expect(inEventOrder(pulses).map((p) => p.node.id)).toEqual(['c', 'b', 'd', 'a', 'e']);
|
|
});
|
|
|
|
it('ranks by the hierarchy and puts what it does not list after it, in the order given', () => {
|
|
const [x, y, z] = [{ objectId: 'x' }, { objectId: 'y' }, { objectId: 'z' }];
|
|
const ranks = hierarchyRanks([x, y, z], ['z', 'x']);
|
|
expect([x, y, z].sort((p, q) => ranks.get(p)! - ranks.get(q)!)).toEqual([z, x, y]);
|
|
});
|
|
|
|
it('gives every event node the setting, and leaves the row off the node at 0', () => {
|
|
// `On behavior` fires inside a pulse of the behavior it hears, whose event nodes carry the order.
|
|
const events = BUILT_IN_NODES.filter(
|
|
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
|
|
);
|
|
expect(events).toHaveLength(18);
|
|
for (const def of events) {
|
|
expect(
|
|
def.params.map((p) => p.key),
|
|
def.type,
|
|
).toContain(EVENT_ORDER);
|
|
expect(def.hiddenParams?.({ [EVENT_ORDER]: 0 }), def.type).toContain(EVENT_ORDER);
|
|
expect(def.hiddenParams?.({ [EVENT_ORDER]: 4 }), def.type).not.toContain(EVENT_ORDER);
|
|
}
|
|
expect(hiddenOrder({})).toEqual([EVENT_ORDER]);
|
|
});
|
|
});
|
|
|
|
describe('the frame', () => {
|
|
it('runs a lower order first across behaviors, and the hierarchy among equals', () => {
|
|
const f = twoBehaviors();
|
|
logOn(f, f.a, 'OnUpdate', 'A');
|
|
const late = logOn(f, f.b, 'OnUpdate', 'B');
|
|
const { world, frame } = startWorld(f, { behaviors: true });
|
|
frame();
|
|
expect(world.log).toEqual(['A', 'B']);
|
|
|
|
f.stack.execute(new SetNodeParamCommand(f.b.id, late.id, EVENT_ORDER, -1));
|
|
world.log.length = 0;
|
|
frame();
|
|
expect(world.log).toEqual(['B', 'A']);
|
|
});
|
|
|
|
it('goes event kind by event kind: a key event of any behavior before every On update', () => {
|
|
const f = twoBehaviors();
|
|
logOn(f, f.a, 'OnUpdate', 'A update');
|
|
logOn(f, f.b, 'OnKeyDown', 'B key', { key: 'x' });
|
|
const { world, input, frame } = startWorld(f, { behaviors: true });
|
|
input.press('x');
|
|
frame();
|
|
expect(world.log).toEqual(['B key', 'A update']);
|
|
});
|
|
|
|
it('runs two nodes of one graph in the order they were made, unless their order says otherwise', () => {
|
|
const f = twoBehaviors();
|
|
logOn(f, f.a, 'OnUpdate', 'first');
|
|
const second = logOn(f, f.a, 'OnUpdate', 'second');
|
|
const { world, frame } = startWorld(f, { behaviors: true });
|
|
frame();
|
|
expect(world.log).toEqual(['first', 'second']);
|
|
|
|
f.stack.execute(new SetNodeParamCommand(f.a.id, second.id, EVENT_ORDER, -5));
|
|
world.log.length = 0;
|
|
frame();
|
|
expect(world.log).toEqual(['second', 'first']);
|
|
});
|
|
|
|
it('orders On start the same way', () => {
|
|
const f = twoBehaviors();
|
|
logOn(f, f.a, 'OnStart', 'A');
|
|
logOn(f, f.b, 'OnStart', 'B', { [EVENT_ORDER]: -2 });
|
|
const { world, frame } = startWorld(f, { behaviors: true });
|
|
frame();
|
|
expect(world.log).toEqual(['B', 'A']);
|
|
});
|
|
|
|
it('follows the hierarchy as it is now, also for a behavior switched on later', () => {
|
|
const f = twoBehaviors();
|
|
logOn(f, f.a, 'OnUpdate', 'A');
|
|
logOn(f, f.b, 'OnUpdate', 'B');
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, f.placedA, 'enabled', false));
|
|
const { world, frame, behaviors } = startWorld(f, { behaviors: true });
|
|
frame();
|
|
expect(world.log).toEqual(['B']);
|
|
|
|
// Switched on after B started: it still runs first, since it stands above B.
|
|
world.entities.find((e) => e.objectId === f.placedA)!.props.enabled = true;
|
|
world.log.length = 0;
|
|
frame();
|
|
expect(world.log).toEqual(['A', 'B']);
|
|
|
|
// Moved below B while running: the next frame after the edit reaches the run has it second.
|
|
f.stack.execute(new ReorderObjectCommand(f.sceneId, f.placedA, ''));
|
|
behaviors!.syncFromModel();
|
|
world.log.length = 0;
|
|
frame();
|
|
expect(world.log).toEqual(['B', 'A']);
|
|
});
|
|
|
|
it('delivers a state event of a shared machine to its listeners by order', () => {
|
|
const f = twoBehaviors();
|
|
const machine = new CreateAssetCommand('statemachine', 'Doors');
|
|
f.stack.execute(machine);
|
|
const state = new AddNodeCommand(machine.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' });
|
|
f.stack.execute(state);
|
|
f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global'));
|
|
f.stack.execute(new SetMachineStartCommand(machine.asset.id, state.node.id));
|
|
for (const [behavior, message, order] of [
|
|
[f.a, 'A', 0],
|
|
[f.b, 'B', -1],
|
|
] as const) {
|
|
const edit = graphEditor(f, behavior);
|
|
const entered = edit.addNode('OnStateEntered', {
|
|
[MACHINE_PORT]: machine.asset.id,
|
|
[EVENT_ORDER]: order,
|
|
});
|
|
edit.connect(entered, state.node.id, edit.addNode('Log', { message }), 'exec');
|
|
}
|
|
const { world, frame } = startWorld(f, { behaviors: true });
|
|
frame();
|
|
expect(world.log).toEqual(['B', 'A']);
|
|
});
|
|
});
|
|
|
|
it('keeps the order through saving and loading', () => {
|
|
const f = twoBehaviors();
|
|
const fired = logOn(f, f.a, 'OnUpdate', 'A', { [EVENT_ORDER]: 7 });
|
|
const loaded = findAssetOf(roundTrip(f.project), f.a.id, 'behavior')!;
|
|
expect(eventOrder(loaded.graph.nodes.find((n) => n.id === fired.id)!)).toBe(7);
|
|
});
|