Files
feuerwerk/src/runtime/systems/BehaviorSystem.ts
T
admin 5671a38044
game-maker / suite (push) Successful in 12m1s
A game that stays silent is only half a game, so a sound is an asset like any other (#45)
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
2026-09-15 07:54:28 +02:00

545 lines
22 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,
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<string, number>;
nodes: Map<string, number>;
values: Map<string, Record<string, Value>>;
}
interface Produced {
time: number;
values: 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;
interface Instance {
entity: Entity;
interpreter: GraphInterpreter;
trace: Omit<Trace, 'values'>;
values: Map<Graph, Map<string, Produced>>;
/** Current state per state machine – this instance's alone, like its variables. */
states: Record<string, string>;
/** 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<string, PropertyBag> = {};
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<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 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<string, string> | 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<string, number>(), nodes: new Map<string, number>() };
const values = new Map<Graph, Map<string, Produced>>();
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<string, Produced>()));
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<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);
}
}
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<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) {
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;
},
};
}
}