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; nodes: Map; values: Map>; } /** * 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; } 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 | 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 = 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 = {}; /** 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 { 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): 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 | null = null; const born = new Set(); 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(), nodes: new Map() }; const values = new Map>(); 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): void { const held = new Map(); 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(); 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())); 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): 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, from: Map) => { 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(); 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(); 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 }), }); } }