Files
feuerwerk/src/runtime/systems/BehaviorSystem.ts
T
adminandClaude Opus 5.5 ded0022903 Run behaviors on a fixed step with an On fixed update node
Adding up the frame's dt in On update makes anything that counts in
steps depend on the frame rate. A pitch sweep sounds a little
different on every run. On fixed update runs 60 times per second of
game time with dt exactly 1/60, so the same graph gives the same
result every time.

- fixedSteps in src/core/time.ts is the one rule for the fixed step:
  spend the frame in steps of 1/60, keep the rest, catch up 8 steps
  at most after a hitch.
- The physics now counts its steps with fixedSteps too. It behaves
  as before, and On fixed update runs exactly as often as the engine
  steps.
- The steps run after On mouse wheel and before On update, each as a
  kind of its own with the usual Order setting.
- Each step gets its own context, so a node inside that reads dt
  (Time, Move, Apply force) gets 1/60 rather than the frame.
- A paused game has no steps, also for a behavior that runs while
  paused.
- The engine still takes all of a frame's steps before the behaviors
  run. A force set in On fixed update acts from the next frame on.

Tests:
- fixed-update.test.ts (new): 5 tests. Step counting, uneven frames
  adding up to 60 steps a second, the hitch cap, two steps at 30 Hz
  before On update with dt 1/60 for the event and for Time, one step
  every other frame at 120 Hz, none while paused.
- event-order, node-catalog, physics-system, game-pause: 430 tests
  together with the new file, all green.
- Counter-check: with the frame's dt inside a step and without the
  pause guard both world tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:46:15 +02:00

758 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
adoptDeclarations,
asBoolean,
BEHAVIOR_PARAM,
buildSharedState,
carriedForward,
declaredVariables,
emptySharedState,
type Graph,
GraphInterpreter,
type GraphNode,
hierarchyRanks,
inEventOrder,
isHandleEnabled,
type NodeContext,
ON_BEHAVIOR,
restartedAssets,
restoreSaved,
type SaveArea,
savedSnapshot,
type SharedState,
startStates,
type TraceListener,
type Value,
} from '../../core/graph';
import { GraphCache } from '../../core/graph/graphIndex';
import { PER_CELL_INPUT } from '../../core/graph/nodes/shared';
import { keyName } from '../../core/keys';
import { graphLookup } from '../../core/model/graphLookup';
import { type PropertyBag, textOf } from '../../core/model/Properties';
import { treeOrder } from '../../core/model/hierarchy';
import { resolveObjects } from '../../core/model/instance';
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
import { FIXED_STEP, fixedSteps } from '../../core/time';
import { type AudioSink, SILENT_AUDIO } from './Audio';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { Rapier } from '../physics/engine';
import { type BodyMotion, PhysicsSystem } from '../physics/PhysicsSystem';
import { behaviorContext, type TimeScaled } from './behaviorContext';
import {
animationFinishedPulses,
collisionEnterPulses,
collisionExitPulses,
collisionStayPulses,
type Instance,
keyDownPulses,
type PerCell,
type Produced,
type Pulse,
pulsesOf,
timerPulses,
} from './behaviorPulses';
import { CollisionSystem } from './CollisionSystem';
import type { CollisionSource } from './solids';
import { AnimationSystem } from './AnimationSystem';
import type { Input } from './Input';
import type { System } from './System';
export type { TimeScaled } from './behaviorContext';
/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
export const TRACE_FADE = 0.35;
/** Recency of exec edges and nodes, keyed by id, in world time. */
export interface Trace {
edges: Map<string, number>;
nodes: Map<string, number>;
values: Map<string, Record<string, Value>>;
}
/**
* A move one graph made, waiting for its events. `source` is the instance that caused it, and who
* hears it when the machine is local; a `global` machine is heard by every instance, so the move it
* reports has no owner (`null` for the start state entered before any instance existed).
*/
interface StateChange {
machineId: string;
from: string;
to: string;
source: Instance | null;
}
/** How often state events may cause the next move before the run is called endless. */
const MAX_STATE_ROUNDS = 100;
const ERROR_PREFIX = 'Error in behavior';
/**
* What the kept objects of a scene take into the world of the next one besides themselves (#165):
* their running behaviors, the index of the graphs those run on, and how their bodies were moving.
*/
export interface KeptState {
instances: Instance[];
graphs: GraphCache;
motion: ReadonlyMap<Entity, BodyMotion>;
}
export interface GameSystemOptions {
shouldRun?: (entity: Entity) => boolean;
audio?: AudioSink;
/** What a scene swap hands over from the world before it (see `BehaviorSystem.carryOver`). */
carried?: Readonly<SharedState> | null;
/** What the kept objects of the world before bring along (see `BehaviorSystem.release`). */
kept?: KeptState | null;
/**
* The loaded engine: a played game simulates its rigidbodies on it (#146). Without one the
* collisions are detected only, which is what the editor run still does.
*/
physics?: Rapier | null;
/**
* The clock whose scale `Set time scale` turns (#167). Only a played game hands it over: the editor
* run shares the clock with the previews, and a behavior there must not slow the editor down.
*/
clock?: TimeScaled | null;
/** Where persistent Variables assets are read from at the start and written to as they change (#166). */
saves?: SaveArea | null;
}
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
export function addGameSystems(
world: World,
project: Project,
input: Input,
{
shouldRun,
audio = SILENT_AUDIO,
carried = null,
kept = null,
physics = null,
clock = null,
saves = null,
}: GameSystemOptions = {},
): BehaviorSystem {
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
const animations = new AnimationSystem();
const sources = { collisions, animations, audio, clock, saves };
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
if (carried) behaviors.carryOver(carried);
if (kept) {
behaviors.adopt(kept);
if (collisions instanceof PhysicsSystem) collisions.resume(kept.motion);
}
world.addSystem(collisions);
world.addSystem(animations);
world.addSystem(behaviors);
return behaviors;
}
/** The systems whose per-frame results become events; a missing one means no such events. */
export interface EventSources {
collisions?: (CollisionSource & System) | null;
animations?: AnimationSystem | null;
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
audio?: AudioSink | null;
clock?: TimeScaled | null;
saves?: SaveArea | null;
}
/** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */
function runsWhilePaused(props: PropertyBag): boolean {
return props.runWhilePaused === true;
}
/** The behavior failure that stopped a run, if that is why it stopped. */
export function behaviorError(world: World): string | undefined {
return world.log.find((line) => line.startsWith(ERROR_PREFIX));
}
/**
* Runs the graph of every behavior entity. Each instance has its own interpreter (and
* therefore its own timers and variable state); the project is only read to find graphs.
*/
export class BehaviorSystem implements System {
/** A paused frame still ticks the behaviors that run while paused (see `update`). */
readonly runsWhilePaused = true;
private instances: Instance[] = [];
private world: World | null = null;
private sharedState: SharedState = emptySharedState();
/** Handed over by the previous world of the same run; consumed by the next `start`. */
private carried: SharedState | null = null;
/** Which of the carried machines this scene starts over, so their entry is announced again. */
private restarted: ReadonlySet<string> = new Set();
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
private pending: StateChange[] = [];
/** Game time not yet spent on a fixed step (see `fixedSteps`); counted like the physics' own. */
private fixedBacklog = 0;
/** Set when the scene may have been edited, so the next sync puts the instances in order again. */
private unranked = true;
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
private declared: Record<string, PropertyBag> = {};
/** Shared by every instance; an edit to the project empties it (see `syncFromModel`). */
private graphs = new GraphCache();
/** Running behaviors of kept objects, handed over by the world before; consumed by the next `start`. */
private adopted: Instance[] = [];
private readonly collisions: CollisionSource | null;
private readonly animations: AnimationSystem | null;
private readonly audio: AudioSink;
private readonly clock: TimeScaled | null;
private readonly saves: SaveArea | null;
/** What was last written to `saves`, so a frame that changed nothing writes nothing. */
private lastSaved = '';
constructor(
private readonly project: Project,
private readonly input: Input,
sources: EventSources = {},
/**
* Which behaviors this system is responsible for. Playing runs them all; the editor run
* takes only those marked "run in editor" (see `properties/editorRun.ts`).
*/
private readonly shouldRun: (entity: Entity) => boolean = () => true,
) {
this.collisions = sources.collisions ?? null;
this.animations = sources.animations ?? null;
this.audio = sources.audio ?? SILENT_AUDIO;
this.clock = sources.clock ?? null;
this.saves = sources.saves ?? null;
}
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
get shared(): Readonly<SharedState> {
return this.sharedState;
}
/** The values a Variables asset shares this run; null when this run holds none for it. */
liveVariables(assetId: string): PropertyBag | 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.graphs.clear();
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);
adoptDeclarations(this.sharedState.variables, this.declared, now);
this.declared = now;
}
/**
* Takes over the shared state of the world before it – the values of the Variables assets and
* where the global machines stand – instead of building a fresh one. That is what belongs to the
* run rather than to the scene, and it is what makes a score survive the next level. Call before
* the world is started.
*/
carryOver(state: Readonly<SharedState>): void {
// The bags of what is `global` travel as they are – a running value is the point – while what is
// `scene` is built again from the declarations, so a level counts from zero (#164).
this.carried = carriedForward(this.project, state);
this.restarted = restartedAssets(this.project);
}
/**
* Hands out the running behaviors on these entities, so the end of this world is not theirs: no
* `On destroy`, their sounds play on, and the next world's system carries them on where they are
* (see `adopt`). Call before the world is destroyed.
*/
release(entities: readonly Entity[]): KeptState {
const leaving = new Set(entities);
const instances = this.instances.filter((i) => leaving.has(i.entity));
this.instances = this.instances.filter((i) => !leaving.has(i.entity));
const physics = this.collisions instanceof PhysicsSystem ? this.collisions : null;
return { instances, graphs: this.graphs, motion: physics?.motionOf(entities) ?? new Map() };
}
/**
* Takes over behaviors that ran in the world before. They are no new instances, so they hear no
* `On start`; their timers, variables and local machines stand where they stood. Call before the
* world is started.
*/
adopt(kept: KeptState): void {
this.adopted = kept.instances;
// Their interpreters read this index, and an edit to the project has to reach it through us.
this.graphs = kept.graphs;
}
start(world: World): void {
this.world = world;
this.instances = this.adopted;
this.adopted = [];
// The trace is timed by the world, and this one starts at second zero.
for (const instance of this.instances) {
instance.trace.edges.clear();
instance.trace.nodes.clear();
for (const byNode of instance.values.values())
for (const produced of byNode.values()) produced.time = 0;
this.listen(instance, world);
}
this.pending = [];
const carried = this.carried;
this.carried = null;
// Built fresh for every run: a shared variable starts at its declared value each time Play does.
this.sharedState = carried ?? buildSharedState(this.project);
// A persistent asset picks up where the last run left it; after a scene swap it already has.
if (!carried && this.saves) restoreSaved(this.project, this.sharedState.variables, this.saves.read());
this.lastSaved = JSON.stringify(savedSnapshot(this.project, this.sharedState.variables));
this.declared = declaredVariables(this.project);
// A `global` machine is entered once for the whole run, before the first instance exists – so
// the move is queued here and every instance the first sync creates hears that one entry.
// Not after a scene swap: the machine is already standing where it stands, and announcing the
// entry again would be a move that never happened.
// A `scene` machine is the exception after a swap: it really was entered again, just now.
for (const [machineId, start] of Object.entries(this.sharedState.states))
if (start && (!carried || this.restarted.has(machineId)))
this.pending.push({ machineId, from: '', to: start, source: null });
this.restarted = new Set();
this.syncInstances(world);
}
/** Tearing the world down (stop, restart) destroys every object, so each instance hears it. */
stop(): void {
this.finish(this.instances);
// After the goodbyes: what `On destroy` writes is the last thing the run did.
this.persist();
this.instances = [];
this.pending = [];
this.world = null;
}
/**
* Writes the persistent values when they differ from the last write. Every frame rather than at
* the end, because a tab closed mid-game has no end to wait for.
*/
private persist(): void {
if (!this.saves) return;
const snapshot = savedSnapshot(this.project, this.sharedState.variables);
const text = JSON.stringify(snapshot);
if (text === this.lastSaved) return;
this.lastSaved = text;
this.saves.write(snapshot);
}
/**
* One instance per behavior entity. A switched-off behavior gets its instance and `On start` only when
* switched on, which is what lets a hidden screen wake up when shown.
*/
private syncInstances(world: World): void {
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' ||
!isHandleEnabled(entity) ||
!this.shouldRun(entity) ||
this.instances.some((i) => i.entity === entity)
)
continue;
const asset = findAssetOf(this.project, entity.assetId, 'behavior');
if (!asset) continue;
const trace = { edges: new Map<string, number>(), nodes: new Map<string, number>() };
const values = new Map<Graph, Map<string, Produced>>();
const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project), this.graphs);
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
this.listen(instance, world);
this.instances.push(instance);
born.add(instance);
}
if (born.size > 0 || this.unranked) this.syncHeard(world, born);
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();
}
/**
* One further instance per `On behavior` node (#294), on the entity of the behavior holding it and
* for as long as that one runs – and so on down, since the behavior heard may listen in turn. An
* edit to a graph while it runs adds and drops them like objects coming and going.
*/
private syncHeard(world: World, born: Set<Instance>): void {
const held = new Map<Instance, Instance[]>();
for (const instance of this.instances) {
const host = instance.host?.instance;
if (host) held.set(host, [...(held.get(host) ?? []), instance]);
}
const dropped: Instance[] = [];
const drop = (instance: Instance) => {
dropped.push(instance);
for (const below of held.get(instance) ?? []) drop(below);
};
const visit = (host: Instance, chain: readonly string[]) => {
const below = held.get(host) ?? [];
const kept = new Set<Instance>();
for (const node of host.interpreter.eventNodes(ON_BEHAVIOR)) {
const behaviorId = textOf(node.params[BEHAVIOR_PARAM] ?? '');
// A behavior hearing itself, however far round, would be built without end.
if (chain.includes(behaviorId) || !findAssetOf(this.project, behaviorId, 'behavior')) continue;
const heard =
below.find((i) => i.host?.nodeId === node.id && i.host.behaviorId === behaviorId) ??
this.hear(host, node.id, behaviorId, world);
if (!heard) continue;
if (!below.includes(heard)) born.add(heard);
kept.add(heard);
visit(heard, [...chain, behaviorId]);
}
for (const instance of below) if (!kept.has(instance)) drop(instance);
};
for (const instance of [...this.instances])
if (!instance.host) visit(instance, [instance.entity.assetId]);
if (dropped.length === 0) return;
this.instances = this.instances.filter((i) => !dropped.includes(i));
this.finish(dropped);
}
/** A running copy of that behavior for one `On behavior` node, with variables of its own. */
private hear(host: Instance, nodeId: string, behaviorId: string, world: World): Instance | null {
const lookup = graphLookup(this.project);
const program = lookup.behavior(behaviorId);
if (!program) return null;
const heard: Instance = {
entity: host.entity,
interpreter: new GraphInterpreter(program.graph, lookup, this.graphs),
trace: { edges: new Map(), nodes: new Map() },
values: new Map(),
states: {},
active: true,
host: { instance: host, nodeId, behaviorId },
variables: structuredClone(program.defaults),
};
this.listen(heard, world);
this.instances.push(heard);
return heard;
}
/** Has the instance note what it runs, timed by the world it runs in. */
private listen({ interpreter, trace, values }: Instance, world: World): void {
interpreter.trace = {
onEdge: (id) => trace.edges.set(id, world.time),
onNode: (id) => trace.nodes.set(id, world.time),
onValues: (graph, id, produced) => {
let byNode = values.get(graph);
if (!byNode) values.set(graph, (byNode = new Map<string, Produced>()));
byNode.set(id, { time: world.time, values: produced });
},
} satisfies TraceListener;
}
/**
* 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
* was entered once when the run began, and a later instance joins it where it already is.
*/
private enterStartStates(instance: Instance, starts: Record<string, string>): void {
for (const [machineId, start] of Object.entries(starts)) {
if (machineId in this.sharedState.states) continue;
instance.states[machineId] = start;
this.pending.push({ machineId, from: '', to: start, source: instance });
}
}
/**
* The events every queued move owes, and those the events themselves cause, until none are left.
* Each move is delivered whole – `exited` to everyone who hears it, then `entered` – so the two
* never interleave across instances.
*/
private flushStateChanges(): void {
const world = this.world;
if (!world) {
this.pending = [];
return;
}
for (let round = 0; this.pending.length > 0; round++) {
if (round >= MAX_STATE_ROUNDS) {
this.pending = [];
world.log.push(`${ERROR_PREFIX} graphs: state changes keep causing each other (endless loop?)`);
world.stopRequested = true;
return;
}
const batch = this.pending;
this.pending = [];
for (const change of batch) this.deliver(change, world);
}
}
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 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);
}
/**
* Which state each instance holds of one machine, for the State machine tab's live highlight.
* A `global` machine answers even with no instance running: its state belongs to the run.
*/
machineStates(machineId: string): { state: string; objectId: string; ownerId: string }[] {
const shared = this.sharedState.states[machineId];
const held = this.instances
.map((i) => ({
state: shared ?? i.states[machineId] ?? '',
objectId: i.entity.objectId,
ownerId: i.entity.parent?.objectId ?? '',
}))
.filter((entry) => entry.state !== '');
if (held.length === 0 && shared) return [{ state: shared, objectId: '', ownerId: '' }];
return held;
}
/** Instances whose entity left the world get their last events and are dropped. */
private reap(world: World): void {
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));
this.finish(gone);
}
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.
// Its sounds are the entity's, so they end with the behavior placed there and not with one it hears.
for (const instance of instances) if (!instance.host) 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 each instance just crossed. Returns those 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;
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 { input, collisions, animations } = this;
const perCell: PerCell = (i, node) =>
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
const kinds: (() => Pulse[])[] = [
() =>
ticking.flatMap((i) =>
i.interpreter.eventNodes('OnKeyDown').flatMap((n) => keyDownPulses(i, n, input, dt)),
),
() => pulsesOf(ticking, 'OnKeyUp', {}, (n) => input.justReleased.has(keyOf(n))),
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => input.heldKeys.has(keyOf(n))),
() => ticking.flatMap((i) => timerPulses(i, dt)),
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
() => (frozen ? [] : ticking.flatMap((i) => collisionEnterPulses(i, collisions, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
() =>
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,
})
: [],
];
const run = (pulses: Pulse[], context = contextOf) => {
this.runPulses(pulses, context, true);
// After the kind, not inside it: a state entered halfway through `On update` must not push
// its events between the nodes that are still to run.
this.flushStateChanges();
};
for (const kind of kinds) run(kind());
if (!frozen) {
const fixed = fixedSteps(this.fixedBacklog, dt);
this.fixedBacklog = fixed.backlog;
// A context of its own per step: what a node reads as `dt` inside is the step, not the frame.
for (let step = 0; step < fixed.steps; step++)
run(pulsesOf(ticking, 'OnFixedUpdate', { dt: FIXED_STEP }), this.contexts(world, FIXED_STEP));
}
run(pulsesOf(ticking, 'OnUpdate', { dt }));
this.reap(world);
this.flushStateChanges();
this.persist();
}
runningIds(): string[] {
return this.instances.filter((i) => !i.host).map((i) => i.entity.objectId);
}
/**
* Live highlight data for the graph editor: merged over all instances of the asset – and over
* those that ran it as a node, which is the only way a nested behavior's graph ever lights up.
*/
traceFor(assetId: string): Trace {
const merged: Trace = { edges: new Map(), nodes: new Map(), values: new Map() };
const latest = (into: Map<string, number>, from: Map<string, number>) => {
for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity));
};
const graph = findAssetOf(this.project, assetId, 'behavior')?.graph;
const newest = new Map<string, Produced>();
for (const instance of this.instances) {
const own = instance.host?.behaviorId ?? instance.entity.assetId;
if (own !== assetId && !instance.interpreter.calledBehaviors.has(assetId)) continue;
latest(merged.edges, instance.trace.edges);
latest(merged.nodes, instance.trace.nodes);
for (const [id, produced] of (graph && instance.values.get(graph)) ?? []) {
const known = newest.get(id);
if (!known || produced.time >= known.time) newest.set(id, produced);
}
}
for (const [id, produced] of newest) merged.values.set(id, produced.values);
return merged;
}
/**
* 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 || pulses.length === 0) return;
for (const { instance, node, outputs, port } of inEventOrder(pulses)) {
if (living && !world.entities.includes(instance.entity)) continue;
try {
this.give(instance, contextOf);
instance.interpreter.pulse(node, contextOf(instance), outputs, port);
this.surface(instance, contextOf);
} catch (error) {
world.log.push(
`${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
);
world.stopRequested = true;
}
}
}
/** The `On behavior` node an instance is heard by, as the graph holds it now. */
private hearing(instance: Instance): { host: Instance; node: GraphNode } | null {
const host = instance.host;
const node = host?.instance.interpreter.eventNodes(ON_BEHAVIOR).find((n) => n.id === host.nodeId);
return host && node ? { host: host.instance, node } : null;
}
/** A heard behavior's start node hands out the inputs of the node hearing it, read anew for each pulse. */
private give(instance: Instance, contextOf: (instance: Instance) => NodeContext): void {
const heard = this.hearing(instance);
if (heard)
instance.interpreter.given = heard.host.interpreter.eventInputs(heard.node, contextOf(heard.host));
}
/**
* A pulse of a heard behavior that reached its end node goes on at the node hearing it, through
* the output named like the way it took – and from there further up, if that graph ends in turn.
*/
private surface(instance: Instance, contextOf: (instance: Instance) => NodeContext): void {
for (let inner = instance; inner.host; inner = inner.host.instance) {
const ending = inner.interpreter.ending;
const heard = this.hearing(inner);
if (!ending || !heard) return;
heard.host.interpreter.pulse(heard.node, contextOf(heard.host), ending.values, ending.port);
}
}
/** 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 {
return behaviorContext(instance, world, dt, {
project: this.project,
input: this.input,
collisions: this.collisions,
audio: this.audio,
clock: this.clock,
shared: this.sharedState,
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
});
}
}