import { asNumber, type EntityHandle, type Graph, type GraphNode, GraphInterpreter, isHandleEnabled, type NodeContext, 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 { parentChain } from '../../core/model/walk'; 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; } interface Instance { entity: Entity; interpreter: GraphInterpreter; trace: Omit; values: Map>; /** Current state per state machine – this instance's alone, like its variables. */ states: Record; } /** * 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, ): BehaviorSystem { const collisions = new CollisionSystem(); const animations = new AnimationSystem(); const behaviors = new BehaviorSystem(project, input, { collisions, animations }, 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; } /** 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 readonly collisions: CollisionSystem | null; private readonly animations: AnimationSystem | null; 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; } start(world: World): void { this.world = world; this.instances = []; this.syncInstances(world); } stop(): void { this.instances = []; 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.instances = this.instances.filter((i) => world.entities.includes(i.entity)); 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: {} }; this.instances.push(instance); this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0); } } 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 during play if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent) if (!this.shouldRun(instance.entity)) continue; // "run in editor" taken back while running 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, ); } } /** * 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; } /** 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({ entity, states }: Instance, world: World, dt: number): NodeContext { // 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, 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); }, 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; }, }; } }