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

+128
View File
@@ -0,0 +1,128 @@
import { type EntityHandle, GraphInterpreter, type NodeContext, type TraceListener } from '../../core/graph';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
import type { World } from '../World';
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>;
}
interface Instance {
entity: Entity;
interpreter: GraphInterpreter;
trace: Trace;
}
/**
* 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.
*/
export class BehaviorSystem implements System {
private instances: Instance[] = [];
private world: World | null = null;
constructor(private readonly project: Project, private readonly input: Input) {}
start(world: World): void {
this.world = world;
this.instances = [];
for (const entity of world.entities) {
if (entity.type !== 'behavior') continue;
const asset = findAsset(this.project, entity.assetId);
if (!asset || asset.type !== 'behavior') continue;
const trace: Trace = { edges: new Map(), nodes: new Map() };
const interpreter = new GraphInterpreter(asset.graph);
interpreter.trace = this.traceListener(trace, world);
this.instances.push({ entity, interpreter, trace });
}
for (const instance of this.instances) this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0);
}
update(world: World, dt: number): void {
const keyOf = (node: { params: Record<string, unknown> }) => String(node.params.key ?? '');
for (const instance of this.instances) {
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
this.run(instance, (i, ctx) => {
for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
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);
}
}
i.fire('OnUpdate', ctx, { dt });
}, dt);
}
}
stop(): void {
this.instances = [];
this.world = null;
}
/** Live highlight data for the graph editor: merged over all instances of the asset. */
traceFor(assetId: string): Trace {
const merged: Trace = { edges: new Map(), nodes: new Map() };
for (const instance of this.instances) {
if (instance.entity.assetId !== assetId) continue;
for (const [id, t] of instance.trace.edges) merged.edges.set(id, Math.max(t, merged.edges.get(id) ?? -Infinity));
for (const [id, t] of instance.trace.nodes) merged.nodes.set(id, Math.max(t, merged.nodes.get(id) ?? -Infinity));
}
return merged;
}
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.entity, world, dt);
try {
body(instance.interpreter, ctx);
} catch (error) {
world.log.push(`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`);
world.stopRequested = true;
}
}
private context(entity: Entity, world: World, dt: number): NodeContext {
return {
self: entity,
owner: entity.ownerObjectId ? world.findByObjectId(entity.ownerObjectId) ?? null : null,
variables: entity.props,
time: world.time,
dt,
findByName: (name) => world.findByName(name) ?? null,
isKeyDown: (key) => this.input.isDown(key),
log: (message) => {
world.log.push(message);
console.log(`[behavior ${entity.name}]`, message);
},
destroy: (target: EntityHandle) => {
const found = world.entities.find((e) => e === target || e.id === target.id);
if (found) world.remove(found);
},
stop: () => {
world.stopRequested = true;
},
};
}
private traceListener(trace: Trace, world: World): TraceListener {
return {
onEdge: (id) => trace.edges.set(id, world.time),
onNode: (id) => trace.nodes.set(id, world.time),
};
}
}