Set the order in which event nodes run across behaviors (#264)
game-maker / gate (push) Successful in 3m42s
game-maker / suite (push) Failing after 11m7s

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:
adminandClaude Opus 5.5 committed 2026-09-29 09:21:31 +02:00
1 parent 0c41cb97b1
commit 5fbc6eb088
29 files changed
+686 -176

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1549 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1551 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+14 -13
View File
@@ -108,6 +108,14 @@ export class GraphInterpreter {
let fired = 0; let fired = 0;
for (const node of this.eventNodes(type)) { for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue; if (filter && !filter(node)) continue;
this.pulse(node, ctx, outputs, port);
fired++;
}
return fired;
}
/** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */
pulse(node: GraphNode, ctx: NodeContext, outputs: Record<string, Value> = {}, port = 'exec'): void {
this.frame = emptyFrame(this.graph); this.frame = emptyFrame(this.graph);
this.callStack = []; this.callStack = [];
this.callSites = []; this.callSites = [];
@@ -116,24 +124,17 @@ export class GraphInterpreter {
this.trace?.onNode(node.id); this.trace?.onNode(node.id);
this.record(node.id, outputs); this.record(node.id, outputs);
this.follow(node, port, ctx, 0); this.follow(node, port, ctx, 0);
fired++;
}
return fired;
} }
/** /**
* `On state entered` / `On state exited` for one move: only the nodes watching that machine, and * The `On state entered` / `On state exited` nodes watching one machine. A node whose machine
* out of the one output the state stands for. A node whose machine input carries a running value * input carries a running value rather than a pick has no state outputs at all, so it is never
* rather than a pick has no state outputs at all, so it is never among them. * among them.
*/ */
fireStateEvent(type: string, ctx: NodeContext, machineId: string, stateId: string): number { stateEventNodes(type: string, machineId: string): GraphNode[] {
if (!machineId || !stateId) return 0; if (!machineId) return [];
return this.fire( return this.eventNodes(type).filter(
type,
ctx,
{},
(node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId, (node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId,
stateId,
); );
} }
+55
View File
@@ -0,0 +1,55 @@
import type { PropertyBag } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import type { GraphNode } from './Graph';
import { asNumber } from './values';
/*
* When event nodes run within a frame (#264). The frame goes event kind by event kind – every key
* event of every behavior, then every timer, …, every On update last – and within one kind by the
* nodes' `order`: lower first. Equal orders keep the hierarchy, then the order the nodes were made.
*/
/** The setting every event node carries; a node saved without one is `0`. */
export const EVENT_ORDER = 'order';
export const orderParam: PropertyDef = {
key: EVENT_ORDER,
label: 'Order',
kind: 'number',
default: 0,
step: 1,
decimals: 0,
};
export function eventOrder(node: GraphNode): number {
const order = Math.round(asNumber(node.params[EVENT_ORDER] ?? 0));
return Number.isFinite(order) ? order : 0;
}
/** The order row says nothing at `0`, so the node body leaves it off like any setting at rest. */
export function hiddenOrder(params: PropertyBag): string[] {
return Math.round(asNumber(params[EVENT_ORDER] ?? 0)) === 0 ? [EVENT_ORDER] : [];
}
/**
* The pulses of one event kind in the order they run. They arrive in hierarchy order, and within one
* object in node order, so a stable sort by `order` is all the rule there is.
*/
export function inEventOrder<T extends { node: GraphNode }>(pulses: readonly T[]): T[] {
return pulses
.map((pulse) => ({ pulse, order: eventOrder(pulse.node) }))
.sort((a, b) => a.order - b.order)
.map(({ pulse }) => pulse);
}
/**
* Where each object stands top to bottom: by the hierarchy's order for those it lists, the rest after
* them in the order they come.
*/
export function hierarchyRanks<T extends { objectId: string }>(
items: readonly T[],
treeIds: readonly string[],
): Map<T, number> {
const place = new Map(treeIds.map((id, index) => [id, index]));
return new Map(items.map((item, index) => [item, place.get(item.objectId) ?? treeIds.length + index]));
}
+1
View File
@@ -2,6 +2,7 @@
import './nodes'; import './nodes';
export * from './dropTarget'; export * from './dropTarget';
export * from './eventOrder';
export * from './Graph'; export * from './Graph';
export * from './lookup'; export * from './lookup';
export * from './findBlock'; export * from './findBlock';
@@ -1,5 +1,6 @@
import { textOf } from '../../../model/Properties'; import { textOf } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, obj } from '../shared'; import { exec, obj } from '../shared';
export const OnAnimationFinished: NodeDef = { export const OnAnimationFinished: NodeDef = {
@@ -10,6 +11,12 @@ export const OnAnimationFinished: NodeDef = {
'Runs once when an animation played once on the owner, or on an object below it, reaches its end. Leave Animation at None to hear every animation.', 'Runs once when an animation played once on the owner, or on an object below it, reaches its end. Leave Animation at None to hear every animation.',
inputs: [], inputs: [],
outputs: [exec(), obj('object')], outputs: [exec(), obj('object')],
params: [{ key: 'animation', label: 'Animation', kind: 'asset', default: '', assetTypes: ['animation'] }], params: [
hiddenParams: (params) => (textOf(params.animation ?? '') ? [] : ['animation']), { key: 'animation', label: 'Animation', kind: 'asset', default: '', assetTypes: ['animation'] },
orderParam,
],
hiddenParams: (params) => [
...(textOf(params.animation ?? '') ? [] : ['animation']),
...hiddenOrder(params),
],
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared'; import { exec, num } from '../shared';
export const OnClick: NodeDef = { export const OnClick: NodeDef = {
@@ -10,5 +11,6 @@ export const OnClick: NodeDef = {
'Without an owner it runs on a click anywhere in the game view.', 'Without an owner it runs on a click anywhere in the game view.',
inputs: [], inputs: [],
outputs: [exec(), num('x', 'world x'), num('y', 'world y')], outputs: [exec(), num('x', 'world x'), num('y', 'world y')],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs, contactOutputs } from '../shared'; import { collisionOutputs, contactOutputs } from '../shared';
export const OnCollisionEnter: NodeDef = { export const OnCollisionEnter: NodeDef = {
@@ -11,5 +12,6 @@ export const OnCollisionEnter: NodeDef = {
'point x/y where (all 0 for a trigger).', 'point x/y where (all 0 for a trigger).',
inputs: [], inputs: [],
outputs: [...collisionOutputs(), ...contactOutputs()], outputs: [...collisionOutputs(), ...contactOutputs()],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs } from '../shared'; import { collisionOutputs } from '../shared';
export const OnCollisionExit: NodeDef = { export const OnCollisionExit: NodeDef = {
@@ -8,5 +9,6 @@ export const OnCollisionExit: NodeDef = {
description: 'Runs once when a collider of the owner stops overlapping another collider.', description: 'Runs once when a collider of the owner stops overlapping another collider.',
inputs: [], inputs: [],
outputs: collisionOutputs(), outputs: collisionOutputs(),
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { collisionOutputs, contactOutputs } from '../shared'; import { collisionOutputs, contactOutputs } from '../shared';
export const OnCollisionStay: NodeDef = { export const OnCollisionStay: NodeDef = {
@@ -20,5 +21,7 @@ export const OnCollisionStay: NodeDef = {
min: 0, min: 0,
step: 0.1, step: 0.1,
}, },
orderParam,
], ],
hiddenParams: hiddenOrder,
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec } from '../shared'; import { exec } from '../shared';
export const OnDestroy: NodeDef = { export const OnDestroy: NodeDef = {
@@ -8,5 +9,6 @@ export const OnDestroy: NodeDef = {
description: 'Runs once when the object is destroyed – also when the game stops or restarts.', description: 'Runs once when the object is destroyed – also when the game stops or restarts.',
inputs: [], inputs: [],
outputs: [exec()], outputs: [exec()],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec } from '../shared'; import { exec } from '../shared';
export const OnDisable: NodeDef = { export const OnDisable: NodeDef = {
@@ -8,5 +9,6 @@ export const OnDisable: NodeDef = {
description: 'Runs when the behavior is switched off (itself or a parent), and before On destroy.', description: 'Runs when the behavior is switched off (itself or a parent), and before On destroy.',
inputs: [], inputs: [],
outputs: [exec()], outputs: [exec()],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec } from '../shared'; import { exec } from '../shared';
export const OnEnable: NodeDef = { export const OnEnable: NodeDef = {
@@ -8,5 +9,6 @@ export const OnEnable: NodeDef = {
description: 'Runs right after On start, and again each time the behavior is switched back on.', description: 'Runs right after On start, and again each time the behavior is switched back on.',
inputs: [], inputs: [],
outputs: [exec()], outputs: [exec()],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
+6 -1
View File
@@ -1,5 +1,6 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values'; import { asNumber } from '../../values';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, keyParam } from '../shared'; import { exec, keyParam } from '../shared';
export const OnKeyDown: NodeDef = { export const OnKeyDown: NodeDef = {
@@ -31,7 +32,11 @@ export const OnKeyDown: NodeDef = {
step: 10, step: 10,
decimals: 0, decimals: 0,
}, },
orderParam,
], ],
// Both rows go together: with no delay there is no repeat, so the interval is not in play either. // Both rows go together: with no delay there is no repeat, so the interval is not in play either.
hiddenParams: (params) => (asNumber(params.repeatAfter ?? 0) > 0 ? [] : ['repeatAfter', 'repeatInterval']), hiddenParams: (params) => [
...(asNumber(params.repeatAfter ?? 0) > 0 ? [] : ['repeatAfter', 'repeatInterval']),
...hiddenOrder(params),
],
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, keyParam, num } from '../shared'; import { exec, keyParam, num } from '../shared';
export const OnKeyHeld: NodeDef = { export const OnKeyHeld: NodeDef = {
@@ -8,5 +9,6 @@ export const OnKeyHeld: NodeDef = {
description: 'Runs every frame while the key is held.', description: 'Runs every frame while the key is held.',
inputs: [], inputs: [],
outputs: [exec(), num('dt')], outputs: [exec(), num('dt')],
params: [keyParam], params: [keyParam, orderParam],
hiddenParams: hiddenOrder,
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, keyParam } from '../shared'; import { exec, keyParam } from '../shared';
export const OnKeyUp: NodeDef = { export const OnKeyUp: NodeDef = {
@@ -8,5 +9,6 @@ export const OnKeyUp: NodeDef = {
description: 'Runs once when the key is released.', description: 'Runs once when the key is released.',
inputs: [], inputs: [],
outputs: [exec()], outputs: [exec()],
params: [keyParam], params: [keyParam, orderParam],
hiddenParams: hiddenOrder,
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared'; import { exec, num } from '../shared';
export const OnMouseWheel: NodeDef = { export const OnMouseWheel: NodeDef = {
@@ -11,5 +12,6 @@ export const OnMouseWheel: NodeDef = {
'positive downwards; a trackpad gives fractions.', 'positive downwards; a trackpad gives fractions.',
inputs: [], inputs: [],
outputs: [exec(), num('delta', 'notches, positive downwards'), num('x', 'world x'), num('y', 'world y')], outputs: [exec(), num('delta', 'notches, positive downwards'), num('x', 'world x'), num('y', 'world y')],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec } from '../shared'; import { exec } from '../shared';
export const OnStart: NodeDef = { export const OnStart: NodeDef = {
@@ -8,5 +9,6 @@ export const OnStart: NodeDef = {
description: 'Runs once when the game starts.', description: 'Runs once when the game starts.',
inputs: [], inputs: [],
outputs: [exec()], outputs: [exec()],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { machineOf, machinePort, statePorts } from '../../stateMachine'; import { machineOf, machinePort, statePorts } from '../../stateMachine';
/** /**
@@ -16,6 +17,7 @@ export const OnStateEntered: NodeDef = {
'count; a transition drawn from a state back into itself does.', 'count; a transition drawn from a state back into itself does.',
inputs: [machinePort()], inputs: [machinePort()],
outputs: [], outputs: [],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }), derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { machineOf, machinePort, statePorts } from '../../stateMachine'; import { machineOf, machinePort, statePorts } from '../../stateMachine';
/** The other half of the move: the pulse leaves through the state that was left, before `entered`. */ /** The other half of the move: the pulse leaves through the state that was left, before `entered`. */
@@ -11,6 +12,7 @@ export const OnStateExited: NodeDef = {
'state at all has nothing to leave, so nothing runs.', 'state at all has nothing to leave, so nothing runs.',
inputs: [machinePort()], inputs: [machinePort()],
outputs: [], outputs: [],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }), derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
}; };
+6 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared'; import { exec, num } from '../shared';
export const OnTimer: NodeDef = { export const OnTimer: NodeDef = {
@@ -9,5 +10,9 @@ export const OnTimer: NodeDef = {
'Runs every N seconds. It can only run on a frame, so late says how far this frame is past the due time.', '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(), num('late')], 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 },
orderParam,
],
hiddenParams: hiddenOrder,
}; };
+3 -1
View File
@@ -1,4 +1,5 @@
import type { NodeDef } from '../../NodeRegistry'; import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, num } from '../shared'; import { exec, num } from '../shared';
export const OnUpdate: NodeDef = { export const OnUpdate: NodeDef = {
@@ -8,5 +9,6 @@ export const OnUpdate: NodeDef = {
description: 'Runs every frame.', description: 'Runs every frame.',
inputs: [], inputs: [],
outputs: [exec(), num('dt')], outputs: [exec(), num('dt')],
params: [], params: [orderParam],
hiddenParams: hiddenOrder,
}; };
+1 -1
View File
@@ -115,7 +115,7 @@ export class EditorRun {
const world = this.world; const world = this.world;
if (world && world.project === this.state.project && world.sceneId === scene.id) { if (world && world.project === this.state.project && world.sceneId === scene.id) {
world.syncFromModel(); world.syncFromModel();
this.behaviors?.syncDeclarations(); this.behaviors?.syncFromModel();
return; return;
} }
this.stop(); this.stop();
+1 -1
View File
@@ -110,7 +110,7 @@ export class PlaySession {
syncFromModel(): void { syncFromModel(): void {
this.world?.syncFromModel(); this.world?.syncFromModel();
this.behaviors?.syncDeclarations(); this.behaviors?.syncFromModel();
} }
/** The values a Variables asset shares in this run; null when nothing is playing. */ /** The values a Variables asset shares in this run; null when nothing is playing. */
+251 -135
View File
@@ -9,6 +9,8 @@ import {
type Graph, type Graph,
type GraphNode, type GraphNode,
GraphInterpreter, GraphInterpreter,
hierarchyRanks,
inEventOrder,
isHandleEnabled, isHandleEnabled,
type NodeContext, type NodeContext,
type PhysicsControl, type PhysicsControl,
@@ -25,6 +27,8 @@ import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { keyName } from '../../core/keys'; import { keyName } from '../../core/keys';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties'; import { type PropertyBag, textOf, type Vec2 } 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 { findAssetOf, findScene, type Project } from '../../core/model/Project';
import { soundFileOf } from '../../core/model/sound'; import { soundFileOf } from '../../core/model/sound';
import { parentChain } from '../../core/model/walk'; import { parentChain } from '../../core/model/walk';
@@ -81,6 +85,14 @@ interface Side {
other: Entity; other: Entity;
} }
/** One event node about to run for one instance: what the event hands it, and the output it leaves by. */
interface Pulse {
instance: Instance;
node: GraphNode;
outputs?: Record<string, Value>;
port?: string;
}
interface Instance { interface Instance {
entity: Entity; entity: Entity;
interpreter: GraphInterpreter; interpreter: GraphInterpreter;
@@ -178,6 +190,8 @@ export class BehaviorSystem implements System {
private restarted: ReadonlySet<string> = new Set(); private restarted: ReadonlySet<string> = new Set();
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */ /** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
private pending: StateChange[] = []; private pending: StateChange[] = [];
/** 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`). */ /** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
private declared: Record<string, PropertyBag> = {}; private declared: Record<string, PropertyBag> = {};
private readonly collisions: CollisionSource | null; private readonly collisions: CollisionSource | null;
@@ -215,6 +229,12 @@ export class BehaviorSystem implements System {
return this.sharedState.variables[assetId] ?? null; return this.sharedState.variables[assetId] ?? null;
} }
/** An edit to the project while the run is on: declarations and the hierarchy may have changed. */
syncFromModel(): void {
this.syncDeclarations();
this.unranked = true;
}
/** An edit to a declaration while the run is on reaches the running values through here. */ /** An edit to a declaration while the run is on reaches the running values through here. */
syncDeclarations(): void { syncDeclarations(): void {
const now = declaredVariables(this.project); const now = declaredVariables(this.project);
@@ -261,10 +281,7 @@ export class BehaviorSystem implements System {
/** Tearing the world down (stop, restart) destroys every object, so each instance hears it. */ /** Tearing the world down (stop, restart) destroys every object, so each instance hears it. */
stop(): void { stop(): void {
for (const instance of this.instances) { this.finish(this.instances);
this.finish(instance);
this.audio.stopOwner(instance.entity.id);
}
// After the goodbyes: what `On destroy` writes is the last thing the run did. // After the goodbyes: what `On destroy` writes is the last thing the run did.
this.persist(); this.persist();
this.instances = []; this.instances = [];
@@ -293,6 +310,7 @@ export class BehaviorSystem implements System {
this.reap(world); this.reap(world);
// Read once, and only where an instance is actually born: most frames create none at all. // Read once, and only where an instance is actually born: most frames create none at all.
let starts: Record<string, string> | null = null; let starts: Record<string, string> | null = null;
const born = new Set<Instance>();
for (const entity of world.entities) { for (const entity of world.entities) {
if ( if (
entity.type !== 'behavior' || entity.type !== 'behavior' ||
@@ -317,19 +335,32 @@ export class BehaviorSystem implements System {
} satisfies TraceListener; } satisfies TraceListener;
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true }; const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
this.instances.push(instance); this.instances.push(instance);
this.enterStartStates(instance, (starts ??= startStates(this.project))); born.add(instance);
this.run(
instance,
(i, ctx) => {
i.fire('OnStart', ctx);
i.fire('OnEnable', ctx);
},
0,
);
} }
if (born.size > 0 || this.unranked) this.rank(world);
const fresh = this.instances.filter((i) => born.has(i));
for (const instance of fresh) this.enterStartStates(instance, (starts ??= startStates(this.project)));
const contextOf = this.contexts(world, 0);
this.runPulses(pulsesOf(fresh, 'OnStart'), contextOf);
this.runPulses(pulsesOf(fresh, 'OnEnable'), contextOf);
this.flushStateChanges(); this.flushStateChanges();
} }
/**
* Puts the instances in hierarchy order – what equal orders fall back on (#264). Only when one is
* born or the scene was edited, since the hierarchy is walked in full.
*/
private rank(world: World): void {
this.unranked = false;
const scene = findScene(this.project, world.sceneId);
const tree = scene ? treeOrder({ objects: resolveObjects(this.project, scene) }) : [];
const ranks = hierarchyRanks(
world.entities,
tree.map(({ object }) => object.id),
);
this.instances.sort((a, b) => (ranks.get(a.entity) ?? 0) - (ranks.get(b.entity) ?? 0));
}
/** /**
* A fresh instance begins in each local machine's start state, and hears `On state entered` for * A fresh instance begins in each local machine's start state, and hears `On state entered` for
* it – after its `On start`, like every other move. A `global` machine is not entered here: it * it – after its `On start`, like every other move. A `global` machine is not entered here: it
@@ -363,21 +394,26 @@ export class BehaviorSystem implements System {
} }
const batch = this.pending; const batch = this.pending;
this.pending = []; this.pending = [];
for (const change of batch) this.deliver(change); for (const change of batch) this.deliver(change, world);
} }
} }
private deliver(change: StateChange): void { private deliver(change: StateChange, world: World): void {
const global = change.machineId in this.sharedState.states; const global = change.machineId in this.sharedState.states;
const targets = (global ? this.instances : [change.source]).filter( const targets = (global ? this.instances : [change.source]).filter(
(i): i is Instance => i !== null && this.instances.includes(i), (i): i is Instance => i !== null && this.instances.includes(i),
); );
const fire = (type: string, state: string) => { const contextOf = this.contexts(world, 0);
for (const instance of targets) const pulses = (type: string, state: string): Pulse[] =>
this.run(instance, (i, ctx) => i.fireStateEvent(type, ctx, change.machineId, state), 0); state
}; ? targets.flatMap((instance) =>
fire('OnStateExited', change.from); instance.interpreter
fire('OnStateEntered', change.to); .stateEventNodes(type, change.machineId)
.map((node) => ({ instance, node, port: state })),
)
: [];
this.runPulses(pulses('OnStateExited', change.from), contextOf);
this.runPulses(pulses('OnStateEntered', change.to), contextOf);
} }
/** /**
@@ -402,86 +438,102 @@ export class BehaviorSystem implements System {
const gone = this.instances.filter((i) => !world.entities.includes(i.entity)); const gone = this.instances.filter((i) => !world.entities.includes(i.entity));
if (gone.length === 0) return; if (gone.length === 0) return;
this.instances = this.instances.filter((i) => !gone.includes(i)); this.instances = this.instances.filter((i) => !gone.includes(i));
for (const instance of gone) { this.finish(gone);
this.finish(instance);
// After `On destroy`, not before: that graph may still say something, and a sound it wants
// to outlive the object says so itself by being detached.
this.audio.stopOwner(instance.entity.id);
}
} }
private finish(instance: Instance): void { private finish(instances: Instance[]): void {
this.run( const world = this.world;
instance, if (!world) return;
(i, ctx) => { const contextOf = this.contexts(world, 0);
if (instance.active) i.fire('OnDisable', ctx); this.runPulses(
instance.active = false; pulsesOf(
i.fire('OnDestroy', ctx); instances.filter((i) => i.active),
}, 'OnDisable',
0, ),
contextOf,
); );
for (const instance of instances) instance.active = false;
this.runPulses(pulsesOf(instances, 'OnDestroy'), contextOf);
// After `On destroy`, not before: that graph may still say something, and a sound it wants to
// outlive the object says so itself by being detached.
for (const instance of instances) this.audio.stopOwner(instance.entity.id);
} }
/** /**
* Follows the effective switch – off when the behavior or a parent is off, or when "run in editor" * Follows the effective switch – off when the behavior or a parent is off, or when "run in editor"
* was taken back while running – and fires the edge it just crossed. * was taken back while running – and fires the edge each instance just crossed. Returns those on.
*/ */
private switched(instance: Instance): boolean { private switched(instances: Instance[], contextOf: (instance: Instance) => NodeContext): Instance[] {
const on = isHandleEnabled(instance.entity) && this.shouldRun(instance.entity); const flipped = instances.filter(
if (on !== instance.active) { (i) => (isHandleEnabled(i.entity) && this.shouldRun(i.entity)) !== i.active,
instance.active = on; );
this.run(instance, (i, ctx) => i.fire(on ? 'OnEnable' : 'OnDisable', ctx), 0); for (const instance of flipped) instance.active = !instance.active;
} this.runPulses(
return on; pulsesOf(
flipped.filter((i) => !i.active),
'OnDisable',
),
contextOf,
);
this.runPulses(
pulsesOf(
flipped.filter((i) => i.active),
'OnEnable',
),
contextOf,
);
this.flushStateChanges();
return instances.filter((i) => i.active);
} }
/**
* One event kind after the other, each across every behavior in `order` (#264): a lower order runs
* before a higher one whatever behavior it sits in. What a kind hands on is gathered only when its
* turn comes, so a click is hit-tested where the key events of this frame left the objects.
*/
update(world: World, dt: number): void { update(world: World, dt: number): void {
this.syncInstances(world); this.syncInstances(world);
const keyOf = (node: { params: PropertyBag }) => keyName(textOf(node.params.key ?? '')); const keyOf = (node: { params: PropertyBag }) => keyName(textOf(node.params.key ?? ''));
const frozen = world.frozen; const frozen = world.frozen;
for (const instance of this.instances) { const contextOf = this.contexts(world, dt);
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below const ticking = this.switched(
if (frozen && !runsWhilePaused(instance.entity.props)) continue; this.instances.filter((i) => !frozen || runsWhilePaused(i.entity.props)),
if (!this.switched(instance)) continue; contextOf,
this.run(
instance,
(i, ctx) => {
for (const node of i.eventNodes('OnKeyDown')) this.fireKeyDown(i, ctx, node, dt);
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
for (const node of i.eventNodes('OnTimer')) {
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
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, { late: state.elapsed }, (n) => n.id === node.id);
}
}
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
if (!frozen) {
this.fireCollisions(instance, i, ctx, dt);
this.fireAnimationsFinished(instance, i, ctx);
}
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
const pointer = world.pointer;
const owner = instance.entity.parent;
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
if (pointer.wheel !== 0 && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnMouseWheel', ctx, { delta: pointer.wheel, x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
},
dt,
); );
// After this instance's frame, not inside it: a state entered halfway through `On update` const pointer = world.pointer;
// must not push its events between the nodes that are still to run. // With an owner the click has to land on it; a behavior without one has no shape to be hit,
// so it listens to the whole game view (anywhere inside the camera frame).
const pointed = (i: Instance) => {
const owner = i.entity.parent;
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
};
const kinds: (() => Pulse[])[] = [
() =>
ticking.flatMap((i) => i.interpreter.eventNodes('OnKeyDown').flatMap((n) => this.keyDown(i, n, dt))),
() => pulsesOf(ticking, 'OnKeyUp', {}, (n) => this.input.justReleased.has(keyOf(n))),
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => this.input.heldKeys.has(keyOf(n))),
() => ticking.flatMap((i) => this.timers(i, dt)),
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
() => (frozen ? [] : ticking.flatMap((i) => this.collisionEnters(i))),
() => (frozen ? [] : ticking.flatMap((i) => this.collisionStays(i, dt))),
() => (frozen ? [] : ticking.flatMap((i) => this.collisionExits(i))),
() => (frozen ? [] : ticking.flatMap((i) => this.animationsFinished(i))),
() =>
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
() =>
pointer.wheel !== 0
? pulsesOf(ticking.filter(pointed), 'OnMouseWheel', {
delta: pointer.wheel,
x: pointer.x,
y: pointer.y,
})
: [],
() => pulsesOf(ticking, 'OnUpdate', { dt }),
];
for (const kind of kinds) {
this.runPulses(kind(), contextOf, 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(); this.flushStateChanges();
} }
this.reap(world); this.reap(world);
@@ -493,7 +545,8 @@ export class BehaviorSystem implements System {
* The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock * The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock
* in `nodeState`, so two nodes on the same key repeat on their own schedules. * in `nodeState`, so two nodes on the same key repeat on their own schedules.
*/ */
private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void { private keyDown(instance: Instance, node: GraphNode, dt: number): Pulse[] {
const i = instance.interpreter;
const key = keyName(textOf(node.params.key ?? '')); const key = keyName(textOf(node.params.key ?? ''));
const stored = i.nodeState.get(node.id); const stored = i.nodeState.get(node.id);
const { state, fires } = stepKeyRepeat( const { state, fires } = stepKeyRepeat(
@@ -509,51 +562,63 @@ export class BehaviorSystem implements System {
); );
if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats }); if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats });
else i.nodeState.delete(node.id); else i.nodeState.delete(node.id);
for (let fired = 0; fired < fires; fired++) i.fire('OnKeyDown', ctx, {}, (n) => n.id === node.id); return Array.from({ length: fires }, () => ({ instance, node }));
}
private timers(instance: Instance, dt: number): Pulse[] {
const i = instance.interpreter;
const pulses: Pulse[] = [];
for (const node of i.eventNodes('OnTimer')) {
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
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;
pulses.push({ instance, node, outputs: { late: state.elapsed } });
}
}
return pulses;
} }
/** /**
* Collision events for this behavior: every pair in which one collider belongs to the * The collision pairs of one kind that concern this behavior: every pair in which one collider
* behavior's body (its owner, or itself when it has none) fires, with `other` being the body on * belongs to the behavior's body (its owner, or itself when it has none), with `other` being the
* the far side – enter and exit once, stay every frame or, with an interval, once each time that * body on the far side.
* long has passed since the pair met (#149). Each stay node keeps a clock per pair in its node
* state, and a pair that is gone takes its clock with it.
*/ */
private fireCollisions( private sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] {
instance: Instance,
interpreter: GraphInterpreter,
ctx: NodeContext,
dt: number,
): void {
const collisions = this.collisions;
if (!collisions) return;
const body = instance.entity.parent ?? instance.entity; const body = instance.entity.parent ?? instance.entity;
const mine = (pairs: CollisionPair[]) => return pairs.flatMap((pair) => {
pairs.flatMap((pair) => {
const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null; const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null;
return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : []; return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : [];
}); });
const outputsOf = ({ own, other, pair }: Side, contact?: Partial<Contact> | null) => {
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
if (contact !== undefined) {
// The pair's normal points towards `a`; seen from `b` it points the other way.
const sign = own === pair.a ? 1 : -1;
outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0;
outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0;
outputs.impulse = contact?.impulse ?? 0;
outputs.pointX = contact?.point?.x ?? 0;
outputs.pointY = contact?.point?.y ?? 0;
} }
return outputs;
}; private collisionEnters(instance: Instance): Pulse[] {
for (const side of mine(collisions.enters)) const sides = this.sides(instance, this.collisions?.enters ?? []);
interpreter.fire('OnCollisionEnter', ctx, outputsOf(side, side.pair)); return instance.interpreter
const stays = mine(collisions.stays); .eventNodes('OnCollisionEnter')
.flatMap((node) => sides.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) })));
}
/**
* Stay fires every frame or, with an interval, once each time that long has passed since the pair
* met (#149). Each stay node keeps a clock per pair in its node state, and a pair that is gone
* takes its clock with it.
*/
private collisionStays(instance: Instance, dt: number): Pulse[] {
const collisions = this.collisions;
if (!collisions) return [];
const interpreter = instance.interpreter;
const stays = this.sides(instance, collisions.stays);
const contacts = new Map<CollisionPair, Contact | null>(); const contacts = new Map<CollisionPair, Contact | null>();
const contactOf = (pair: CollisionPair) => { const contactOf = (pair: CollisionPair) => {
if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair)); if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair));
return contacts.get(pair)!; return contacts.get(pair)!;
}; };
const pulses: Pulse[] = [];
for (const node of interpreter.eventNodes('OnCollisionStay')) { for (const node of interpreter.eventNodes('OnCollisionStay')) {
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0; const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
const clocks = interpreter.nodeState.get(node.id) ?? {}; const clocks = interpreter.nodeState.get(node.id) ?? {};
@@ -567,28 +632,37 @@ export class BehaviorSystem implements System {
running[key] = elapsed; running[key] = elapsed;
} }
for (let i = 0; i < fires; i++) for (let i = 0; i < fires; i++)
interpreter.fire('OnCollisionStay', ctx, outputsOf(side, contactOf(side.pair)), (n) => n === node); pulses.push({ instance, node, outputs: sideOutputs(side, contactOf(side.pair)) });
} }
if (interval > 0) interpreter.nodeState.set(node.id, running); if (interval > 0) interpreter.nodeState.set(node.id, running);
else interpreter.nodeState.delete(node.id); else interpreter.nodeState.delete(node.id);
} }
for (const side of mine(collisions.exits)) interpreter.fire('OnCollisionExit', ctx, outputsOf(side)); return pulses;
}
private collisionExits(instance: Instance): Pulse[] {
const sides = this.sides(instance, this.collisions?.exits ?? []);
return instance.interpreter
.eventNodes('OnCollisionExit')
.flatMap((node) => sides.map((side) => ({ instance, node, outputs: sideOutputs(side) })));
} }
/** /**
* A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below * A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below
* it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view. * it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view.
*/ */
private fireAnimationsFinished(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void { private animationsFinished(instance: Instance): Pulse[] {
const owner = instance.entity.parent; const owner = instance.entity.parent;
for (const { entity, animationId } of this.animations?.finished ?? []) { const finished = (this.animations?.finished ?? []).filter(
if (owner && ![...parentChain(entity, (e) => e.parent)].includes(owner)) continue; ({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner),
interpreter.fire('OnAnimationFinished', ctx, { object: entity }, (node) => { );
return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => {
const wanted = textOf(node.params.animation ?? ''); const wanted = textOf(node.params.animation ?? '');
return wanted === '' || wanted === animationId; return finished
.filter(({ animationId }) => wanted === '' || wanted === animationId)
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
}); });
} }
}
runningIds(): string[] { runningIds(): string[] {
return this.instances.map((i) => i.entity.objectId); return this.instances.map((i) => i.entity.objectId);
@@ -631,17 +705,18 @@ export class BehaviorSystem implements System {
this.audio.play(asset.file, level, loop ?? loopsOf(props), owner); this.audio.play(asset.file, level, loop ?? loopsOf(props), owner);
} }
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */ /**
private run( * Runs the pulses of one event kind in their order (see `core/graph/eventOrder.ts`). An exception
instance: Instance, * stops play and is logged, never thrown. `living`: a pulse whose object an earlier one destroyed
body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, * this frame is dropped – `reap` gives it its goodbye.
dt: number, */
): void { private runPulses(pulses: Pulse[], contextOf: (instance: Instance) => NodeContext, living = false): void {
const world = this.world; const world = this.world;
if (!world) return; if (!world || pulses.length === 0) return;
const ctx = this.context(instance, world, dt); for (const { instance, node, outputs, port } of inEventOrder(pulses)) {
if (living && !world.entities.includes(instance.entity)) continue;
try { try {
body(instance.interpreter, ctx); instance.interpreter.pulse(node, contextOf(instance), outputs, port);
} catch (error) { } catch (error) {
world.log.push( world.log.push(
`${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`, `${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
@@ -649,6 +724,17 @@ export class BehaviorSystem implements System {
world.stopRequested = true; world.stopRequested = true;
} }
} }
}
/** One context per instance for as long as the answer is used – a frame, or one round of events. */
private contexts(world: World, dt: number): (instance: Instance) => NodeContext {
const made = new Map<Instance, NodeContext>();
return (instance) => {
let ctx = made.get(instance);
if (!ctx) made.set(instance, (ctx = this.context(instance, world, dt)));
return ctx;
};
}
private context(instance: Instance, world: World, dt: number): NodeContext { private context(instance: Instance, world: World, dt: number): NodeContext {
const { entity, states } = instance; const { entity, states } = instance;
@@ -787,6 +873,36 @@ export class BehaviorSystem implements System {
} }
} }
/** Every node of one event type in these instances, in hierarchy and then node order. */
function pulsesOf(
instances: readonly Instance[],
type: string,
outputs: Record<string, Value> = {},
filter?: (node: GraphNode) => boolean,
): Pulse[] {
return instances.flatMap((instance) =>
instance.interpreter
.eventNodes(type)
.filter((node) => !filter || filter(node))
.map((node) => ({ instance, node, outputs })),
);
}
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
if (contact !== undefined) {
// The pair's normal points towards `a`; seen from `b` it points the other way.
const sign = own === pair.a ? 1 : -1;
outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0;
outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0;
outputs.impulse = contact?.impulse ?? 0;
outputs.pointX = contact?.point?.x ?? 0;
outputs.pointY = contact?.point?.y ?? 0;
}
return outputs;
}
/** Where a ray of that length ends; where it has no direction, where it starts. */ /** Where a ray of that length ends; where it has no direction, where it starts. */
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 { function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
const d = Math.hypot(direction.x, direction.y); const d = Math.hypot(direction.x, direction.y);
+6 -5
View File
@@ -462,12 +462,12 @@ describe('lifecycle events', () => {
input.press('x'); input.press('x');
frame(); frame();
// The frame that destroys it still runs to its end – the farewell comes after this frame's update. // 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', 'update', 'disable', 'destroy']); expect(world.log).toEqual(['start', 'enable', 'update', 'disable', 'destroy']);
expect(behaviors!.runningIds()).toEqual([]); expect(behaviors!.runningIds()).toEqual([]);
expect(world.entities.some((e) => e.name === 'Logic')).toBe(false); expect(world.entities.some((e) => e.name === 'Logic')).toBe(false);
frame(); frame();
expect(world.log).toHaveLength(6); expect(world.log).toHaveLength(5);
}); });
it('skips On disable for a behavior destroyed while it was off', () => { 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 }); const { world, frame } = startWorld(f, { behaviors: true });
frame(); frame();
world.destroy(); world.destroy();
// Event kind by event kind (#264): every start before the first enable, every disable before a destroy.
expect(world.log).toEqual([ expect(world.log).toEqual([
'start', 'start',
'enable',
'start', 'start',
'enable', 'enable',
'enable',
'update', 'update',
'update', 'update',
'disable', 'disable',
'disable',
'destroy', 'destroy',
'disable',
'destroy', 'destroy',
]); ]);
}); });
+217
View File
@@ -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);
});
+2 -1
View File
@@ -57,7 +57,8 @@ describe('key repeat', () => {
}); });
describe('the repeat rows on the node body', () => { 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', () => { it('leaves both off while the repeat is switched off', () => {
expect(hidden({ key: 'a' })).toEqual(['repeatAfter', 'repeatInterval']); expect(hidden({ key: 'a' })).toEqual(['repeatAfter', 'repeatInterval']);
+3 -2
View File
@@ -465,8 +465,9 @@ describe('collision events from the engine', () => {
seconds(1); seconds(1);
// Each side once: its normal, then the impulse. // Each side once: its normal, then the impulse.
expect(world.log).toHaveLength(4); expect(world.log).toHaveLength(4);
const [ballNormal, i1, groundNormal, i2] = world.log.map(Number) as [number, number, number, number]; const [groundNormal, i1, ballNormal, 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. // 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(ballNormal).toBe(-1);
expect(groundNormal).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. // The ball of 32 hits at about √(2·980·164) ≈ 567 per second, and its mass is π·0.16² ≈ 0.08.
+71
View File
@@ -1105,6 +1105,7 @@ export async function graph(t) {
await findMatchNodes(); await findMatchNodes();
await splitOutputs(); await splitOutputs();
await pluralLookups(); await pluralLookups();
await eventOrder();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`);
@@ -1655,4 +1656,74 @@ export async function graph(t) {
scene.objects = scene.objects.filter((o) => !o.id.startsWith('pl-')); scene.objects = scene.objects.filter((o) => !o.id.startsWith('pl-'));
`); `);
} }
// 6z-viii. Event order (#264): an event node shows no Order row at 0. Typed into the inspector, the
// order lands on the node and on its row, and a run starts the lower order first, even
// when its behavior stands lower in the hierarchy.
async function eventOrder() {
await mutate(`
const p = __gm.state.project;
const behavior = (id, name, message) => ({
id,
type: 'behavior',
name,
props: { enabled: true, runInEditor: false },
variables: [],
graph: {
nodes: [
{ id: id + '-start', type: 'OnStart', x: 0, y: 0, params: { order: 0 } },
{ id: id + '-log', type: 'Log', x: 260, y: 0, params: { message } },
],
edges: [{ id: id + '-e', from: { node: id + '-start', port: 'exec' }, to: { node: id + '-log', port: 'exec' } }],
},
});
p.assets.push(behavior('ord-a', 'Order A', 'A'), behavior('ord-b', 'Order B', 'B'));
const scene = __gm.state.activeScene;
for (const [id, assetId, name] of [['ord-first', 'ord-a', 'Order first'], ['ord-second', 'ord-b', 'Order second']]) {
const o = structuredClone(scene.objects[0]);
Object.assign(o, { id, assetId, parentId: '', name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete o.groupId;
scene.objects.push(o);
}
__gm.state.select({ kind: 'asset', assetId: 'ord-b' });
`);
await showTab('graph');
await clickText('Fit');
const atRest = await json(`__gm.graph.nodeSettings('ord-b-start')`);
await select({ kind: 'node', assetId: 'ord-b', nodeId: 'ord-b-start' });
const orderField = await json(`__gm.inspectorField('Order')`);
if (orderField) {
await click(orderField.x, orderField.y);
await typeText('-1');
await press('Enter');
}
const params = (await asset('ord-b')).graph.nodes.find((n) => n.id === 'ord-b-start')?.params;
const shown = await json(`__gm.graph.nodeSettings('ord-b-start')`);
check(
'an event node shows no Order row at 0; -1 typed into the inspector lands on the node and its row',
JSON.stringify(atRest) === '[]' &&
params?.order === -1 &&
JSON.stringify(shown) === JSON.stringify(['Order: -1']),
JSON.stringify({ atRest, orderField, params, shown }),
);
await shot('27-event-order');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Order '))
.map((line) => line.split(']').pop().trim());
check(
'a run starts the behavior with Order -1 first, though it stands below the other one',
JSON.stringify(logged) === JSON.stringify(['B', 'A']),
JSON.stringify(logged),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'ord-a' && a.id !== 'ord-b');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('ord-'));
`);
}
} }