import { adoptDeclarations, asNumber, buildSharedState, declaredVariables, emptySharedState, type EntityHandle, type Graph, type GraphNode, GraphInterpreter, isHandleEnabled, type NodeContext, type SharedState, startStates, type TraceListener, type Value, } from '../../core/graph'; import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { graphLookup } from '../../core/model/graphLookup'; import { type PropertyBag, textOf } from '../../core/model/Properties'; import { findAssetOf, type Project } from '../../core/model/Project'; import { soundFileOf } from '../../core/model/sound'; import { parentChain } from '../../core/model/walk'; import { resolveAssetProps } from '../../core/properties/resolve'; import { loopsOf, volumeOf } from '../../core/properties/sounds'; import { type AudioSink, SILENT_AUDIO } from './Audio'; import type { Entity } from '../Entity'; import type { World } from '../World'; import { bodyOf, type CollisionPair, CollisionSystem } from './CollisionSystem'; import { AnimationSystem } from './AnimationSystem'; import type { Input } from './Input'; import type { System } from './System'; /** 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>; } interface Produced { time: number; values: Record; } /** * 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; interface Instance { entity: Entity; interpreter: GraphInterpreter; trace: Omit; values: Map>; /** Current state per state machine – this instance's alone, like its variables. */ states: Record; /** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */ active: boolean; } /** * 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. */ const ERROR_PREFIX = 'Error in behavior'; /** Collisions and animations are ticked first, so behaviors see this frame's events. */ export function addGameSystems( world: World, project: Project, input: Input, shouldRun?: (entity: Entity) => boolean, audio: AudioSink = SILENT_AUDIO, ): BehaviorSystem { const collisions = new CollisionSystem(); const animations = new AnimationSystem(); const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio }, shouldRun); 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?: CollisionSystem | 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; } /** 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)); } export class BehaviorSystem implements System { private instances: Instance[] = []; private world: World | null = null; private sharedState: SharedState = emptySharedState(); /** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */ private pending: StateChange[] = []; /** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */ private declared: Record = {}; private readonly collisions: CollisionSystem | null; private readonly animations: AnimationSystem | null; private readonly audio: AudioSink; 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; } /** 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 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; } start(world: World): void { this.world = world; this.instances = []; this.pending = []; // Built fresh for every run: a shared variable starts at its declared value each time Play does. this.sharedState = buildSharedState(this.project); 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. for (const [machineId, start] of Object.entries(this.sharedState.states)) if (start) this.pending.push({ machineId, from: '', to: start, source: null }); this.syncInstances(world); } /** 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.instances = []; this.pending = []; this.world = null; } /** * 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; 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)); 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; 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, ); } this.flushStateChanges(); } /** * 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); } } private deliver(change: StateChange): 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); } /** * 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)); for (const instance of gone) this.finish(instance); } 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, ); } /** * 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. */ 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; } update(world: World, dt: number): void { this.syncInstances(world); const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? ''); for (const instance of this.instances) { if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below 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; while (state.elapsed >= interval) { state.elapsed -= interval; i.fire('OnTimer', ctx, {}, (n) => n.id === node.id); } } this.fireCollisions(instance, i, ctx); 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 }); } 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. this.flushStateChanges(); } this.reap(world); this.flushStateChanges(); } /** * 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 { const key = textOf(node.params.key ?? ''); const stored = i.nodeState.get(node.id); const { state, fires } = stepKeyRepeat( stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null, { pressed: this.input.justPressed.has(key), down: this.input.heldKeys.has(key), dt, // The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds. after: asNumber(node.params.repeatAfter ?? 0) / 1000, interval: asNumber(node.params.repeatInterval ?? 0) / 1000, }, ); 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); } /** * 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 once, with `other` being * the body on the far side. */ private fireCollisions(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void { if (!this.collisions) return; const body = instance.entity.parent ?? instance.entity; const fire = (type: string, pairs: CollisionPair[]) => { for (const { a, b } of pairs) { const own = bodyOf(a) === body ? a : bodyOf(b) === body ? b : null; if (!own) continue; const other = own === a ? b : a; interpreter.fire(type, ctx, { other: bodyOf(other), otherCollider: other, collider: own }); } }; fire('OnCollisionEnter', this.collisions.enters); fire('OnCollisionStay', this.collisions.stays); fire('OnCollisionExit', this.collisions.exits); } /** * 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 { 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; }); } } runningIds(): string[] { return this.instances.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) { if (instance.entity.assetId !== 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; } /** * What the graph asked for, as the sink understands it: the asset's own volume scaled by the * node's, and its loop flag unless the node overrules it either way. The asset is what a sound * *is* – a node that says nothing therefore plays it as the project describes it. */ private playSound(assetId: string, volume: number, loop: boolean | undefined): void { const asset = findAssetOf(this.project, assetId, 'sound'); if (!asset?.file) return; const props = resolveAssetProps(this.project, asset); const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); this.audio.play(asset.file, level, loop ?? loopsOf(props)); } /** 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 { 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; } } 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. const resolve = (handle: EntityHandle | null): Entity | undefined => handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined; return { self: entity, owner: entity.parent, variables: entity.props, states, shared: this.sharedState, stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }), time: world.time, dt, findByName: (name) => world.findByName(name) ?? null, isKeyDown: (key) => this.input.isDown(key), pointer: world.pointer, hitTest: (target, x, y) => { const found = resolve(target); return Boolean(found && world.hitTest(found, x, y)); }, isTouching: (a, b) => { const ea = resolve(a); const eb = resolve(b); return Boolean(this.collisions && ea && eb && this.collisions.touching(world, ea, eb)); }, moveAndSlide: (target, dx, dy) => { const found = resolve(target); if (!found) return { blockedX: false, blockedY: false }; if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy); found.x += dx; found.y += dy; return { blockedX: false, blockedY: false }; }, log: (message) => { world.log.push(message); // The `Log` node's whole purpose: the author wants this in the browser console. // eslint-disable-next-line no-console console.log(`[behavior ${entity.name}]`, message); }, playSound: (assetId, volume, loop) => this.playSound(assetId, volume, loop), // An id nothing resolves stops nothing, rather than silencing the whole run by accident; // only the empty id means "everything", which is what the node promises. stopSound: (assetId) => { const file = assetId === '' ? '' : soundFileOf(this.project, assetId); if (assetId === '' || file) this.audio.stop(file); }, spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), destroy: (target) => { const found = resolve(target); if (found) world.remove(found); }, stop: () => { world.stopRequested = true; }, restart: () => { world.restartRequested = true; }, }; } }