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>
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
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;
|
||
},
|
||
};
|
||
}
|
||
}
|