Files
feuerwerk/src/runtime/systems/BehaviorSystem.ts
T
rootandClaude Fable 5.1 e7d82bd1a9 State machines: asset, tab, State branch and Set state nodes (#51)
A state machine is an asset whose graph holds one `State` node per state
(no ports, name as title), edited in a new "State machine" tab that reuses
the graph canvas: right-click places "State n", snap toggle shared with the
Behavior tab via SnapToggle. `statemachine` is a value kind like texture
(port, literal node, variable, struct field, tagged list item).

Behaviors use it through `State branch` (one exec output per state) and
`Set state` (one exec input per state; the interpreter now hands `run` the
entry port). States live per behavior instance in NodeContext.states, with
no initial state. Ports derive from the inline picker or a wired
`State machine` literal; BehaviorLookup became GraphLookup with
behavior(id) and machine(id).

The Graph tab is labelled "Behavior" (key unchanged). Unit tests
(state-machine, kinds, catalog, ports), e2e step 26 and README added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 18:00:23 +02:00

342 lines
13 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 {
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<string, number>;
nodes: Map<string, number>;
values: Map<string, Record<string, Value>>;
}
interface Produced {
time: number;
values: Record<string, Value>;
}
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>;
}
/**
* 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<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: {} };
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<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;
}
/** 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;
},
};
}
}