import { adoptDeclarations, asNumber, buildSharedState, carriedForward, declaredVariables, emptySharedState, type EntityHandle, type Graph, type GraphNode, GraphInterpreter, isHandleEnabled, type NodeContext, type PhysicsControl, restartedAssets, restoreSaved, type SaveArea, savedSnapshot, type SharedState, startStates, type TraceListener, type Value, } from '../../core/graph'; 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 { findAssetOf, findScene, 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 type { Rapier } from '../physics/engine'; import { PhysicsSystem } from '../physics/PhysicsSystem'; import { CollisionSystem } from './CollisionSystem'; import { bodyOf, type CollisionPair, type CollisionSource, type Contact, NOT_BLOCKED } from './solids'; 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; } const NO_DYNAMIC_BODY = 'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.'; /** How often state events may cause the next move before the run is called endless. */ const MAX_STATE_ROUNDS = 100; /** A collision pair seen from one behavior's body: its own collider and the one across. */ interface Side { pair: CollisionPair; own: Entity; other: Entity; } 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'; 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; /** * 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; } export interface TimeScaled { scale: number; } /** 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, 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); 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)); } 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[] = []; /** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */ private declared: Record = {}; 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 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); } start(world: World): void { this.world = world; this.instances = []; 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 { for (const instance of this.instances) { this.finish(instance); this.audio.stopOwner(instance.entity.id); } // 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; 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); // 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); } } 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 }) => 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 }); } 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(); this.persist(); } /** * 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 = keyName(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, 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. */ private fireCollisions( instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext, dt: number, ): void { const collisions = this.collisions; if (!collisions) return; 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 | null) => { const outputs: Record = { 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); const contacts = new Map(); const contactOf = (pair: CollisionPair) => { if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair)); return contacts.get(pair)!; }; 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) ?? {}; const running: Record = {}; for (const side of stays) { let fires = 1; if (interval > 0) { const key = `${side.pair.a.id}|${side.pair.b.id}`; let elapsed = (clocks[key] ?? 0) + dt; for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval; running[key] = elapsed; } for (let i = 0; i < fires; i++) interpreter.fire('OnCollisionStay', ctx, outputsOf(side, contactOf(side.pair)), (n) => n === node); } 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)); } /** * 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, owner: string): 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), 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 { 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, // By identity, not by id: a restarted scene builds new entities under the same ids. isAlive: (target) => world.entities.some((e) => e === target), childByName: (parent, name) => { const found = resolve(parent); return (found && world.childByName(found, 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 NOT_BLOCKED; if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy); found.x += dx; found.y += dy; return NOT_BLOCKED; }, raycast: (from, direction, length, { ignore, triggers, layers }) => { const hit = this.collisions?.raycast(world, from, direction, length, { ignore: resolve(ignore) ?? null, triggers, layers, }); world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit)); return hit ? { ...hit, object: bodyOf(hit.collider) } : null; }, raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => { const hits = this.collisions?.raycastAll( world, from, direction, length, { ignore: resolve(ignore) ?? null, triggers, layers }, maxHits, ) ?? []; // Drawn to the last thing it met, not to the first: that is how far this ray looked. const last = hits[hits.length - 1]; const end = last ? last.point : pointAlong(from, direction, length); world.noteRay(from, end, hits.length > 0); return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) })); }, ignoreOneWay: (target, seconds) => { const found = resolve(target); if (found) world.ignoreOneWay(found, seconds); }, isTilemap: (target) => { const found = resolve(target); return found ? world.isTilemap(found) : false; }, tileAt: (target, layer, cell) => { const found = resolve(target); return found ? world.tileAt(found, layer, cell) : null; }, setTile: (target, layer, cell, tileId) => { const found = resolve(target); return found ? world.setTile(found, layer, cell, tileId) : false; }, physics: this.collisions instanceof PhysicsSystem ? physicsControl(world, this.collisions, resolve) : undefined, 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, detached) => this.playSound(assetId, volume, loop, detached ? '' : entity.id), // 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, keepChildren) => { const found = resolve(target); if (!found) return []; if (keepChildren) return world.removeKeepingChildren(found); world.remove(found); return []; }, loadScene: (sceneId) => { // An id naming no scene is dropped here rather than half way through the swap, where the // run would already have torn down the world it is still standing in. if (findScene(this.project, sceneId)) world.loadSceneRequested = sceneId; else world.log.push(`Load scene: no scene ${sceneId}`); }, stop: () => { world.stopRequested = true; }, restart: () => { world.restartRequested = true; }, timeScale: () => this.clock?.scale ?? 1, setTimeScale: (scale) => { if (this.clock) this.clock.scale = scale; else world.log.push(`Set time scale is ignored in the editor run (${scale})`); }, isPaused: () => world.paused, setPaused: (paused) => { world.paused = paused; }, }; } } /** 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); if (!(d > 0) || !(length > 0)) return from; return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length }; } /** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */ function physicsControl( world: World, physics: PhysicsSystem, resolve: (handle: EntityHandle | null) => Entity | undefined, ): PhysicsControl { const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => { const found = resolve(handle); if (!found) return false; if (action(found)) return true; physics.hint(world, found, NO_DYNAMIC_BODY); return false; }; return { velocityOf: (handle) => { const found = resolve(handle); const v = found ? physics.velocityOf(found) : null; return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null; }, setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })), setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)), applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })), setGravity: (x, y) => physics.setGravity(world, { x, y }), }; }