Behaviors with Blueprint-style node graph, graph editor tab and BehaviorSystem

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 12:44:43 +02:00
1 parent d4b1ffa051
commit 922774778b
36 files changed
+2969 -147

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
import type { PropertyBag } from '../model/Properties';
/**
* Port data types. `exec` carries control flow (Blueprint-style white arrows); the others
* carry values. `any` accepts every value type (variables, logging).
*/
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'any';
export interface PortRef {
node: string;
port: string;
}
/**
* One node of a behavior graph. `type` names a NodeDef in the registry; `params` holds
* the node's own settings (e.g. which key) and inline values of unconnected data inputs,
* both keyed by the param / port key.
*/
export interface GraphNode {
id: string;
type: string;
x: number;
y: number;
params: PropertyBag;
}
/** A connection from an output port to an input port. */
export interface GraphEdge {
id: string;
from: PortRef;
to: PortRef;
}
export interface Graph {
nodes: GraphNode[];
edges: GraphEdge[];
}
export function createEmptyGraph(): Graph {
return { nodes: [], edges: [] };
}
export function findNode(graph: Graph, nodeId: string): GraphNode | undefined {
return graph.nodes.find((n) => n.id === nodeId);
}
export function findEdge(graph: Graph, edgeId: string): GraphEdge | undefined {
return graph.edges.find((e) => e.id === edgeId);
}
/** Edges arriving at one input port (data inputs allow at most one, exec inputs any number). */
export function edgesInto(graph: Graph, nodeId: string, port: string): GraphEdge[] {
return graph.edges.filter((e) => e.to.node === nodeId && e.to.port === port);
}
/** Edges leaving one output port. */
export function edgesOutOf(graph: Graph, nodeId: string, port: string): GraphEdge[] {
return graph.edges.filter((e) => e.from.node === nodeId && e.from.port === port);
}
/** Every edge touching a node (used when removing it). */
export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] {
return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId);
}
/** Whether a value may flow from an output of type `from` into an input of type `to`. */
export function portsCompatible(from: PortType, to: PortType): boolean {
if (from === 'exec' || to === 'exec') return from === to;
return from === to || from === 'any' || to === 'any';
}
+112
View File
@@ -0,0 +1,112 @@
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import { hasExecInput, isEventNode, type NodeContext, requireNodeDef, type Value } from './NodeRegistry';
/**
* Guards against runaway flows (a Sequence looping back into itself, …). The interpreter
* recurses along exec edges, so the limit also keeps the call stack shallow.
*/
const MAX_STEPS_PER_FIRE = 1000;
export interface TraceListener {
/** An exec edge was followed. */
onEdge(edgeId: string): void;
/** A node ran (exec) or was evaluated (data). */
onNode(nodeId: string): void;
}
/**
* Runs one behavior graph for one instance. Blueprint semantics: an event node starts a
* *pulse*; the interpreter follows exec edges, and whenever a node needs a data input it
* pulls the value from the connected output – evaluating pure data nodes lazily and
* reading exec nodes' outputs from the pulse cache. Per-node state (timers) lives here,
* so every instance has its own.
*/
export class GraphInterpreter {
/** Per-node persistent state across pulses (timer accumulators etc.). */
readonly nodeState = new Map<string, Record<string, number>>();
private outputCache = new Map<string, Record<string, Value>>();
private steps = 0;
trace: TraceListener | null = null;
constructor(readonly graph: Graph) {}
/** Nodes of one event type, in graph order. */
eventNodes(type: string): GraphNode[] {
return this.graph.nodes.filter((n) => n.type === type);
}
/**
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
*/
fire(type: string, ctx: NodeContext, outputs: Record<string, Value> = {}, filter?: (node: GraphNode) => boolean): number {
let fired = 0;
for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue;
this.outputCache = new Map();
this.steps = 0;
this.outputCache.set(node.id, { ...outputs });
this.trace?.onNode(node.id);
this.follow(node, 'exec', ctx);
fired++;
}
return fired;
}
/** Follows the exec edges leaving `port` of `node`, running each target in order. */
private follow(node: GraphNode, port: string, ctx: NodeContext): void {
for (const edge of edgesOutOf(this.graph, node.id, port)) {
const target = this.node(edge.to.node);
if (!target) continue;
this.trace?.onEdge(edge.id);
this.execute(target, ctx);
}
}
private execute(node: GraphNode, ctx: NodeContext): void {
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
const def = requireNodeDef(node.type);
if (!def.run) return;
const inputs = this.collectInputs(node, ctx);
const result = def.run(ctx, inputs, node.params);
this.outputCache.set(node.id, result.outputs ?? {});
this.trace?.onNode(node.id);
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
for (const port of next) this.follow(node, port, ctx);
}
/** Resolves every data input: connected → pulled from the source, else the inline param. */
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
const def = requireNodeDef(node.type);
const inputs: Record<string, Value> = {};
for (const port of def.inputs) {
if (port.type === 'exec') continue;
const edge = edgesInto(this.graph, node.id, port.key)[0];
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
else inputs[port.key] = node.params[port.key] ?? null;
}
return inputs;
}
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
const cached = this.outputCache.get(nodeId);
if (cached) return cached[port] ?? null;
const source = this.node(nodeId);
if (!source) return null;
const def = requireNodeDef(source.type);
// Exec nodes that have not run in this pulse have no value yet.
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// Mark before evaluating so a data cycle reads null instead of recursing forever.
this.outputCache.set(nodeId, {});
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
this.outputCache.set(nodeId, outputs);
this.trace?.onNode(nodeId);
return outputs[port] ?? null;
}
private node(id: string): GraphNode | undefined {
return this.graph.nodes.find((n) => n.id === id);
}
}
+154
View File
@@ -0,0 +1,154 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import type { PortType } from './Graph';
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
export type Value = PropertyValue | EntityHandle | null;
/**
* What the graph sees of a running entity. The runtime's Entity satisfies this shape; unit
* tests use plain objects. Mutating `x`/`y`/`props` is how actions take effect.
*/
export interface EntityHandle {
id: string;
name: string;
x: number;
y: number;
z: number;
props: PropertyBag;
}
/**
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
* by test doubles. Everything the graph does to the world goes through here, so the core
* stays free of runtime imports.
*/
export interface NodeContext {
/** The behavior entity itself. */
self: EntityHandle;
/** The scene object the behavior was attached to, if any. */
owner: EntityHandle | null;
/** Mutable variable state of this behavior instance. */
variables: PropertyBag;
/** Seconds since the world started and since the last tick. */
time: number;
dt: number;
findByName(name: string): EntityHandle | null;
isKeyDown(key: string): boolean;
log(message: string): void;
destroy(target: EntityHandle): void;
stop(): void;
}
export interface PortDef {
key: string;
label: string;
type: PortType;
}
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
export interface RunResult {
/** Values for the node's data outputs (cached for the rest of the pulse). */
outputs?: Record<string, Value>;
/** Exec output(s) to continue along; omitted = the node ends the flow. */
next?: string | string[];
}
/**
* Describes one node type: its ports, its inline settings and how it runs. The editor
* palette, port rendering, connection validation and the inspector fields are all derived
* from this – adding a node type means adding one definition, no UI code.
*/
export interface NodeDef {
type: string;
label: string;
category: NodeCategory;
/** Shown in the palette. */
description: string;
inputs: PortDef[];
outputs: PortDef[];
/** Node settings that are not ports (key name, operator, variable). */
params: PropertyDef[];
/**
* Executes the node. Pure data nodes (no exec pins) are evaluated on demand whenever an
* input downstream needs their value; exec nodes run when the flow reaches them.
* Event nodes have no `run`; the interpreter seeds their outputs when it fires them.
*/
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
}
const defs = new Map<string, NodeDef>();
export function registerNode(def: NodeDef): NodeDef {
if (defs.has(def.type)) throw new Error(`Node type "${def.type}" registered twice`);
defs.set(def.type, def);
return def;
}
export function nodeDef(type: string): NodeDef | undefined {
return defs.get(type);
}
export function requireNodeDef(type: string): NodeDef {
const def = defs.get(type);
if (!def) throw new Error(`Unknown node type "${type}"`);
return def;
}
export function allNodeDefs(): NodeDef[] {
return [...defs.values()];
}
export function isEventNode(def: NodeDef): boolean {
return def.category === 'event';
}
export function hasExecInput(def: NodeDef): boolean {
return def.inputs.some((p) => p.type === 'exec');
}
/** Data inputs that may carry an inline value when unconnected (numbers, strings, booleans, any). */
export function inlineInputs(def: NodeDef): PortDef[] {
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object');
}
/** A PropertyDef for editing an inline input value of the given port. */
export function inlineInputDef(port: PortDef): PropertyDef {
switch (port.type) {
case 'boolean':
return { key: port.key, label: port.label, kind: 'boolean', default: false };
case 'string':
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: '' };
default:
return { key: port.key, label: port.label, kind: 'number', default: 0, step: 1 };
}
}
// --- value helpers for node implementations ---------------------------------------------
export function asNumber(value: Value | undefined): number {
if (typeof value === 'number') return value;
if (typeof value === 'boolean') return value ? 1 : 0;
if (typeof value === 'string') {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
export function asBoolean(value: Value | undefined): boolean {
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
return Boolean(value);
}
export function asString(value: Value | undefined): string {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return value.name;
return String(value);
}
export function asEntity(value: Value | undefined): EntityHandle | null {
return value && typeof value === 'object' ? value : null;
}
+6
View File
@@ -0,0 +1,6 @@
// Importing the graph package registers the built-in node set as a side effect.
import './nodes';
export * from './Graph';
export * from './NodeRegistry';
export { GraphInterpreter, type TraceListener } from './GraphInterpreter';
+256
View File
@@ -0,0 +1,256 @@
import type { PropertyDef } from '../properties/PropertySchema';
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode } from './NodeRegistry';
/**
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
* ports, inspector fields, validation) is derived. Importing this module registers them.
*/
const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** Placeholder: the graph inspector replaces the options with the asset's variables. */
const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] };
// --- events -------------------------------------------------------------------------------
registerNode({ type: 'OnStart', label: 'On start', category: 'event', description: 'Runs once when the game starts.', inputs: [], outputs: [exec()], params: [] });
registerNode({ type: 'OnUpdate', label: 'On update', category: 'event', description: 'Runs every frame.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [] });
registerNode({ type: 'OnKeyDown', label: 'On key down', category: 'event', description: 'Runs once when the key is pressed.', inputs: [], outputs: [exec()], params: [keyParam] });
registerNode({ type: 'OnKeyUp', label: 'On key up', category: 'event', description: 'Runs once when the key is released.', inputs: [], outputs: [exec()], params: [keyParam] });
registerNode({ type: 'OnKeyHeld', label: 'On key held', category: 'event', description: 'Runs every frame while the key is held.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [keyParam] });
registerNode({
type: 'OnTimer',
label: 'On timer',
category: 'event',
description: 'Runs every N seconds.',
inputs: [],
outputs: [exec()],
params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }],
});
// --- flow ---------------------------------------------------------------------------------
registerNode({
type: 'Branch',
label: 'Branch',
category: 'flow',
description: 'Continues along "then" or "else" depending on the condition.',
inputs: [exec(), bool('condition')],
outputs: [exec('then', 'then'), exec('else', 'else')],
params: [],
run: (_ctx, inputs) => ({ next: asBoolean(inputs.condition) ? 'then' : 'else' }),
});
registerNode({
type: 'Sequence',
label: 'Sequence',
category: 'flow',
description: 'Runs its outputs one after another.',
inputs: [exec()],
outputs: [exec('a', '1'), exec('b', '2'), exec('c', '3')],
params: [],
run: () => ({ next: ['a', 'b', 'c'] }),
});
// --- data: references --------------------------------------------------------------------
registerNode({ type: 'Self', label: 'Self', category: 'data', description: 'This behavior object.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.self } }) });
registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is attached to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) });
registerNode({
type: 'ObjectByName',
label: 'Object by name',
category: 'data',
description: 'Finds a scene object by its name.',
inputs: [],
outputs: [obj('object')],
params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }],
run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }),
});
// --- variables ----------------------------------------------------------------------------
registerNode({
type: 'GetVariable',
label: 'Get variable',
category: 'variable',
description: 'Reads one of this behavior\'s variables.',
inputs: [],
outputs: [any('value')],
params: [variableParam],
run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }),
});
registerNode({
type: 'SetVariable',
label: 'Set variable',
category: 'variable',
description: 'Writes one of this behavior\'s variables.',
inputs: [exec(), any('value')],
outputs: [exec()],
params: [variableParam],
run: (ctx, inputs, params) => {
const key = String(params.variable ?? '');
const current = ctx.variables[key];
const value = inputs.value;
if (key) {
if (typeof current === 'number') ctx.variables[key] = asNumber(value);
else if (typeof current === 'boolean') ctx.variables[key] = asBoolean(value);
else ctx.variables[key] = asString(value);
}
return { next: 'exec' };
},
});
// --- data: properties and positions --------------------------------------------------------
registerNode({
type: 'GetProperty',
label: 'Get property',
category: 'data',
description: 'Reads a property (width, color, …) of an object.',
inputs: [obj('object')],
outputs: [any('value')],
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
run: (_ctx, inputs, params) => ({ outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null } }),
});
registerNode({
type: 'SetProperty',
label: 'Set property',
category: 'action',
description: 'Writes a property (width, color, …) of an object.',
inputs: [exec(), obj('object'), any('value')],
outputs: [exec()],
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
run: (_ctx, inputs, params) => {
const target = asEntity(inputs.object);
const key = String(params.property ?? '');
if (target && key) {
const current = target.props[key];
const value = inputs.value;
if (typeof current === 'number') target.props[key] = asNumber(value);
else if (typeof current === 'boolean') target.props[key] = asBoolean(value);
else target.props[key] = asString(value);
}
return { next: 'exec' };
},
});
registerNode({
type: 'GetPosition',
label: 'Get position',
category: 'data',
description: 'The x/y position of an object.',
inputs: [obj('object')],
outputs: [num('x'), num('y')],
params: [],
run: (_ctx, inputs) => {
const target = asEntity(inputs.object);
return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0 } };
},
});
// --- data: literals and math ---------------------------------------------------------------
registerNode({ type: 'Number', label: 'Number', category: 'data', description: 'A constant number.', inputs: [], outputs: [num('value')], params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }], run: (_c, _i, p) => ({ outputs: { value: asNumber(p.value) } }) });
registerNode({ type: 'Boolean', label: 'Boolean', category: 'data', description: 'A constant true/false.', inputs: [], outputs: [bool('value')], params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }], run: (_c, _i, p) => ({ outputs: { value: asBoolean(p.value) } }) });
registerNode({ type: 'String', label: 'String', category: 'data', description: 'A constant text.', inputs: [], outputs: [{ key: 'value', label: 'value', type: 'string' }], params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }], run: (_c, _i, p) => ({ outputs: { value: asString(p.value) } }) });
const MATH: [string, string, string, (a: number, b: number) => number][] = [
['Add', 'Add', 'a + b', (a, b) => a + b],
['Subtract', 'Subtract', 'a − b', (a, b) => a - b],
['Multiply', 'Multiply', 'a × b', (a, b) => a * b],
['Divide', 'Divide', 'a ÷ b (0 when b is 0)', (a, b) => (b === 0 ? 0 : a / b)],
];
for (const [type, label, description, fn] of MATH) {
registerNode({ type, label, category: 'data', description, inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: fn(asNumber(i.a), asNumber(i.b)) } }) });
}
const COMPARE: Record<string, (a: number, b: number) => boolean> = {
'==': (a, b) => a === b,
'!=': (a, b) => a !== b,
'<': (a, b) => a < b,
'<=': (a, b) => a <= b,
'>': (a, b) => a > b,
'>=': (a, b) => a >= b,
};
registerNode({
type: 'Compare',
label: 'Compare',
category: 'data',
description: 'Compares two numbers.',
inputs: [num('a'), num('b')],
outputs: [bool('result')],
params: [{ key: 'op', label: 'Operator', kind: 'enum', default: '<', options: Object.keys(COMPARE).map((op) => ({ value: op, label: op })) }],
run: (_c, i, p) => ({ outputs: { result: (COMPARE[String(p.op)] ?? COMPARE['=='])!(asNumber(i.a), asNumber(i.b)) } }),
});
registerNode({ type: 'And', label: 'And', category: 'data', description: 'Both true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) && asBoolean(i.b) } }) });
registerNode({ type: 'Or', label: 'Or', category: 'data', description: 'Either true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) || asBoolean(i.b) } }) });
registerNode({ type: 'Not', label: 'Not', category: 'data', description: 'Inverts a boolean.', inputs: [bool('value')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: !asBoolean(i.value) } }) });
registerNode({ type: 'KeyIsDown', label: 'Key is down', category: 'data', description: 'Whether a key is currently held.', inputs: [], outputs: [bool('down')], params: [keyParam], run: (ctx, _i, p) => ({ outputs: { down: ctx.isKeyDown(String(p.key ?? '')) } }) });
// --- actions --------------------------------------------------------------------------------
registerNode({
type: 'Move',
label: 'Move',
category: 'action',
description: 'Moves an object by dx/dy.',
inputs: [exec(), obj('object'), num('dx'), num('dy')],
outputs: [exec()],
params: [],
run: (_c, i) => {
const target = asEntity(i.object);
if (target) {
target.x += asNumber(i.dx);
target.y += asNumber(i.dy);
}
return { next: 'exec' };
},
});
registerNode({
type: 'SetPosition',
label: 'Set position',
category: 'action',
description: 'Places an object at x/y.',
inputs: [exec(), obj('object'), num('x'), num('y')],
outputs: [exec()],
params: [],
run: (_c, i) => {
const target = asEntity(i.object);
if (target) {
target.x = asNumber(i.x);
target.y = asNumber(i.y);
}
return { next: 'exec' };
},
});
registerNode({
type: 'Destroy',
label: 'Destroy',
category: 'action',
description: 'Removes an object from the running game.',
inputs: [exec(), obj('object')],
outputs: [exec()],
params: [],
run: (ctx, i) => {
const target = asEntity(i.object);
if (target) ctx.destroy(target);
return { next: 'exec' };
},
});
registerNode({
type: 'Log',
label: 'Log',
category: 'action',
description: 'Prints a message to the console.',
inputs: [exec(), any('message')],
outputs: [exec()],
params: [],
run: (ctx, i) => {
ctx.log(asString(i.message));
return { next: 'exec' };
},
});
registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } });