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
@@ -9,6 +9,8 @@ import {
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
GraphInterpreter,
|
||||
hierarchyRanks,
|
||||
inEventOrder,
|
||||
isHandleEnabled,
|
||||
type NodeContext,
|
||||
type PhysicsControl,
|
||||
@@ -25,6 +27,8 @@ import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||
import { keyName } from '../../core/keys';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
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 { soundFileOf } from '../../core/model/sound';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
@@ -81,6 +85,14 @@ interface Side {
|
||||
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 {
|
||||
entity: Entity;
|
||||
interpreter: GraphInterpreter;
|
||||
@@ -178,6 +190,8 @@ export class BehaviorSystem implements System {
|
||||
private restarted: ReadonlySet<string> = new Set();
|
||||
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
|
||||
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`). */
|
||||
private declared: Record<string, PropertyBag> = {};
|
||||
private readonly collisions: CollisionSource | null;
|
||||
@@ -215,6 +229,12 @@ export class BehaviorSystem implements System {
|
||||
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. */
|
||||
syncDeclarations(): void {
|
||||
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. */
|
||||
stop(): void {
|
||||
for (const instance of this.instances) {
|
||||
this.finish(instance);
|
||||
this.audio.stopOwner(instance.entity.id);
|
||||
}
|
||||
this.finish(this.instances);
|
||||
// After the goodbyes: what `On destroy` writes is the last thing the run did.
|
||||
this.persist();
|
||||
this.instances = [];
|
||||
@@ -293,6 +310,7 @@ export class BehaviorSystem implements System {
|
||||
this.reap(world);
|
||||
// Read once, and only where an instance is actually born: most frames create none at all.
|
||||
let starts: Record<string, string> | null = null;
|
||||
const born = new Set<Instance>();
|
||||
for (const entity of world.entities) {
|
||||
if (
|
||||
entity.type !== 'behavior' ||
|
||||
@@ -317,19 +335,32 @@ export class BehaviorSystem implements System {
|
||||
} satisfies TraceListener;
|
||||
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
|
||||
this.instances.push(instance);
|
||||
this.enterStartStates(instance, (starts ??= startStates(this.project)));
|
||||
this.run(
|
||||
instance,
|
||||
(i, ctx) => {
|
||||
i.fire('OnStart', ctx);
|
||||
i.fire('OnEnable', ctx);
|
||||
},
|
||||
0,
|
||||
);
|
||||
born.add(instance);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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;
|
||||
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 targets = (global ? this.instances : [change.source]).filter(
|
||||
(i): i is Instance => i !== null && this.instances.includes(i),
|
||||
);
|
||||
const fire = (type: string, state: string) => {
|
||||
for (const instance of targets)
|
||||
this.run(instance, (i, ctx) => i.fireStateEvent(type, ctx, change.machineId, state), 0);
|
||||
};
|
||||
fire('OnStateExited', change.from);
|
||||
fire('OnStateEntered', change.to);
|
||||
const contextOf = this.contexts(world, 0);
|
||||
const pulses = (type: string, state: string): Pulse[] =>
|
||||
state
|
||||
? targets.flatMap((instance) =>
|
||||
instance.interpreter
|
||||
.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));
|
||||
if (gone.length === 0) return;
|
||||
this.instances = this.instances.filter((i) => !gone.includes(i));
|
||||
for (const instance of 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);
|
||||
}
|
||||
this.finish(gone);
|
||||
}
|
||||
|
||||
private finish(instance: Instance): void {
|
||||
this.run(
|
||||
instance,
|
||||
(i, ctx) => {
|
||||
if (instance.active) i.fire('OnDisable', ctx);
|
||||
instance.active = false;
|
||||
i.fire('OnDestroy', ctx);
|
||||
},
|
||||
0,
|
||||
private finish(instances: Instance[]): void {
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
const contextOf = this.contexts(world, 0);
|
||||
this.runPulses(
|
||||
pulsesOf(
|
||||
instances.filter((i) => i.active),
|
||||
'OnDisable',
|
||||
),
|
||||
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"
|
||||
* 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 {
|
||||
const on = isHandleEnabled(instance.entity) && this.shouldRun(instance.entity);
|
||||
if (on !== instance.active) {
|
||||
instance.active = on;
|
||||
this.run(instance, (i, ctx) => i.fire(on ? 'OnEnable' : 'OnDisable', ctx), 0);
|
||||
}
|
||||
return on;
|
||||
private switched(instances: Instance[], contextOf: (instance: Instance) => NodeContext): Instance[] {
|
||||
const flipped = instances.filter(
|
||||
(i) => (isHandleEnabled(i.entity) && this.shouldRun(i.entity)) !== i.active,
|
||||
);
|
||||
for (const instance of flipped) instance.active = !instance.active;
|
||||
this.runPulses(
|
||||
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 {
|
||||
this.syncInstances(world);
|
||||
const keyOf = (node: { params: PropertyBag }) => keyName(textOf(node.params.key ?? ''));
|
||||
const frozen = world.frozen;
|
||||
for (const instance of this.instances) {
|
||||
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below
|
||||
if (frozen && !runsWhilePaused(instance.entity.props)) continue;
|
||||
if (!this.switched(instance)) continue;
|
||||
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`
|
||||
// must not push its events between the nodes that are still to run.
|
||||
const contextOf = this.contexts(world, dt);
|
||||
const ticking = this.switched(
|
||||
this.instances.filter((i) => !frozen || runsWhilePaused(i.entity.props)),
|
||||
contextOf,
|
||||
);
|
||||
const pointer = world.pointer;
|
||||
// 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.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
|
||||
* 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 stored = i.nodeState.get(node.id);
|
||||
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 });
|
||||
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
|
||||
* behavior's body (its owner, or itself when it has none) fires, with `other` being the body on
|
||||
* the far side – enter and exit once, stay 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.
|
||||
* The collision pairs of one kind that concern this behavior: every pair in which one collider
|
||||
* belongs to the behavior's body (its owner, or itself when it has none), with `other` being the
|
||||
* body on the far side.
|
||||
*/
|
||||
private fireCollisions(
|
||||
instance: Instance,
|
||||
interpreter: GraphInterpreter,
|
||||
ctx: NodeContext,
|
||||
dt: number,
|
||||
): void {
|
||||
const collisions = this.collisions;
|
||||
if (!collisions) return;
|
||||
private sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] {
|
||||
const body = instance.entity.parent ?? instance.entity;
|
||||
const mine = (pairs: CollisionPair[]) =>
|
||||
pairs.flatMap((pair) => {
|
||||
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 }] : [];
|
||||
});
|
||||
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;
|
||||
};
|
||||
for (const side of mine(collisions.enters))
|
||||
interpreter.fire('OnCollisionEnter', ctx, outputsOf(side, side.pair));
|
||||
const stays = mine(collisions.stays);
|
||||
return pairs.flatMap((pair) => {
|
||||
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 }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
private collisionEnters(instance: Instance): Pulse[] {
|
||||
const sides = this.sides(instance, this.collisions?.enters ?? []);
|
||||
return instance.interpreter
|
||||
.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 contactOf = (pair: CollisionPair) => {
|
||||
if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair));
|
||||
return contacts.get(pair)!;
|
||||
};
|
||||
const pulses: Pulse[] = [];
|
||||
for (const node of interpreter.eventNodes('OnCollisionStay')) {
|
||||
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
|
||||
const clocks = interpreter.nodeState.get(node.id) ?? {};
|
||||
@@ -567,27 +632,36 @@ export class BehaviorSystem implements System {
|
||||
running[key] = elapsed;
|
||||
}
|
||||
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);
|
||||
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
|
||||
* 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;
|
||||
for (const { entity, animationId } of this.animations?.finished ?? []) {
|
||||
if (owner && ![...parentChain(entity, (e) => e.parent)].includes(owner)) continue;
|
||||
interpreter.fire('OnAnimationFinished', ctx, { object: entity }, (node) => {
|
||||
const wanted = textOf(node.params.animation ?? '');
|
||||
return wanted === '' || wanted === animationId;
|
||||
});
|
||||
}
|
||||
const finished = (this.animations?.finished ?? []).filter(
|
||||
({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner),
|
||||
);
|
||||
return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => {
|
||||
const wanted = textOf(node.params.animation ?? '');
|
||||
return finished
|
||||
.filter(({ animationId }) => wanted === '' || wanted === animationId)
|
||||
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
|
||||
});
|
||||
}
|
||||
|
||||
runningIds(): string[] {
|
||||
@@ -631,25 +705,37 @@ export class BehaviorSystem implements System {
|
||||
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(
|
||||
instance: Instance,
|
||||
body: (interpreter: GraphInterpreter, ctx: NodeContext) => void,
|
||||
dt: number,
|
||||
): void {
|
||||
/**
|
||||
* Runs the pulses of one event kind in their order (see `core/graph/eventOrder.ts`). An exception
|
||||
* stops play and is logged, never thrown. `living`: a pulse whose object an earlier one destroyed
|
||||
* this frame is dropped – `reap` gives it its goodbye.
|
||||
*/
|
||||
private runPulses(pulses: Pulse[], contextOf: (instance: Instance) => NodeContext, living = false): void {
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
const ctx = this.context(instance, world, dt);
|
||||
try {
|
||||
body(instance.interpreter, ctx);
|
||||
} catch (error) {
|
||||
world.log.push(
|
||||
`${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
world.stopRequested = true;
|
||||
if (!world || pulses.length === 0) return;
|
||||
for (const { instance, node, outputs, port } of inEventOrder(pulses)) {
|
||||
if (living && !world.entities.includes(instance.entity)) continue;
|
||||
try {
|
||||
instance.interpreter.pulse(node, contextOf(instance), outputs, port);
|
||||
} catch (error) {
|
||||
world.log.push(
|
||||
`${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
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 {
|
||||
const { entity, states } = instance;
|
||||
// Handles arriving from the graph are the entities themselves, or test doubles with the same id.
|
||||
@@ -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. */
|
||||
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
|
||||
const d = Math.hypot(direction.x, direction.y);
|
||||
|
||||
Reference in new issue
Block a user