Set the order in which event nodes run across behaviors (#264)
Which of two events ran first depended on hidden lists: the order of the behavior instances and the order in which nodes were created. A graph had no way to say that one On update must run before another, and an On update of one behavior could run before a key event of another. - A frame now runs event kind by event kind over all behaviors: On disable and On enable, key down, key up, key held, timer, collision enter, stay and exit, animation finished, click, mouse wheel and at last On update. - All 17 event nodes have a field Order, a whole number, default 0, negative allowed. Within one kind a lower Order runs earlier, no matter which behavior holds the node. - On a tie the current hierarchy decides from top to bottom, then the order in which the nodes were created. The old rule that a behavior enabled later goes to the end is gone. - The node shows Order: 5 only when the value is not 0, so existing graphs look the same. - The rule lives as pure functions in core/graph/eventOrder.ts. The interpreter can now fire a single event node. Changes in existing projects: - An object destroyed in a frame gets no further event in that frame. On disable and On destroy still come at the end of the frame. - New instances get all their On start first, then all On enable. Teardown runs all On disable first, then all On destroy. - State events follow right after the event kind that caused them. - Four unit tests had fixed the old order and are adjusted: two in behavior-events, one in physics-system, one in key-repeat. Tests: - ./check.sh: 191 files, 2784 tests, CHECK EXIT 0. - GM_STEPS=1-6: ALL PASSED, 280 checks. - Full e2e run (local): ALL PASSED, 1551 checks, measured. - tests/core/event-order.test.ts: 11 tests for the rule, the field on all 17 nodes, order across behaviors, key before update, two nodes in one graph, On start, hierarchy after late enable and reorder at runtime, state events, save and load. - e2e 6z-viii, 2 checks with real mouse and keyboard: Order -1 set in the inspector shows on the node, and a run logs B, A although B is lower in the hierarchy. - Counter-checks: 5 in unit tests and 1 in the browser, all fail as expected. The originals were restored and checked with cmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c41cb97b1
commit
5fbc6eb088
29 files changed
+704
-194
No files matched your search
@@ -462,12 +462,12 @@ describe('lifecycle events', () => {
|
||||
|
||||
input.press('x');
|
||||
frame();
|
||||
// The frame that destroys it still runs to its end – the farewell comes after this frame's update.
|
||||
expect(world.log).toEqual(['start', 'enable', 'update', 'update', 'disable', 'destroy']);
|
||||
// Gone for the rest of the frame: no On update after the key event that destroyed it (#264).
|
||||
expect(world.log).toEqual(['start', 'enable', 'update', 'disable', 'destroy']);
|
||||
expect(behaviors!.runningIds()).toEqual([]);
|
||||
expect(world.entities.some((e) => e.name === 'Logic')).toBe(false);
|
||||
frame();
|
||||
expect(world.log).toHaveLength(6);
|
||||
expect(world.log).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('skips On disable for a behavior destroyed while it was off', () => {
|
||||
@@ -487,16 +487,17 @@ describe('lifecycle events', () => {
|
||||
const { world, frame } = startWorld(f, { behaviors: true });
|
||||
frame();
|
||||
world.destroy();
|
||||
// Event kind by event kind (#264): every start before the first enable, every disable before a destroy.
|
||||
expect(world.log).toEqual([
|
||||
'start',
|
||||
'enable',
|
||||
'start',
|
||||
'enable',
|
||||
'enable',
|
||||
'update',
|
||||
'update',
|
||||
'disable',
|
||||
'disable',
|
||||
'destroy',
|
||||
'disable',
|
||||
'destroy',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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,
|
||||
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', () => {
|
||||
const events = BUILT_IN_NODES.filter((def) => def.category === 'event' && def.type !== BEHAVIOR_START);
|
||||
expect(events).toHaveLength(17);
|
||||
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);
|
||||
});
|
||||
@@ -57,7 +57,8 @@ describe('key repeat', () => {
|
||||
});
|
||||
|
||||
describe('the repeat rows on the node body', () => {
|
||||
const hidden = (params: PropertyBag) => nodeDef('OnKeyDown')?.hiddenParams?.(params);
|
||||
// With an order set, so the order row (#264) stays out of what these look at.
|
||||
const hidden = (params: PropertyBag) => nodeDef('OnKeyDown')?.hiddenParams?.({ order: 1, ...params });
|
||||
|
||||
it('leaves both off while the repeat is switched off', () => {
|
||||
expect(hidden({ key: 'a' })).toEqual(['repeatAfter', 'repeatInterval']);
|
||||
|
||||
@@ -465,8 +465,9 @@ describe('collision events from the engine', () => {
|
||||
seconds(1);
|
||||
// Each side once: its normal, then the impulse.
|
||||
expect(world.log).toHaveLength(4);
|
||||
const [ballNormal, i1, groundNormal, i2] = world.log.map(Number) as [number, number, number, number];
|
||||
// The ball's behavior runs first. The ground pushes the ball up; the ball pushes the ground down.
|
||||
const [groundNormal, i1, ballNormal, i2] = world.log.map(Number) as [number, number, number, number];
|
||||
// The ground's behavior runs first, it stands higher in the hierarchy (#264). The ground pushes
|
||||
// the ball up; the ball pushes the ground down.
|
||||
expect(ballNormal).toBe(-1);
|
||||
expect(groundNormal).toBe(1);
|
||||
// The ball of 32 hits at about √(2·980·164) ≈ 567 per second, and its mass is π·0.16² ≈ 0.08.
|
||||
|
||||
Reference in new issue
Block a user