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

+3
View File
@@ -17,5 +17,8 @@ export interface Entity {
y: number;
/** Draw order; higher is drawn on top. */
z: number;
/** For behaviors: the resolved variables (mutable state). For others: resolved properties. */
props: PropertyBag;
/** Scene object id of the owner (behaviors), empty when none. */
ownerObjectId: string;
}
+14
View File
@@ -10,6 +10,10 @@ import type { System } from './systems/System';
export class World {
readonly entities: Entity[] = [];
readonly backgroundColor: number;
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
stopRequested = false;
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
private readonly systems: System[] = [];
private elapsed = 0;
@@ -36,6 +40,7 @@ export class World {
y: object.position.y,
z: object.position.z,
props: resolveSceneObjectProps(asset, object),
ownerObjectId: object.ownerId,
});
}
return world;
@@ -61,10 +66,19 @@ export class World {
return this.entities.find((e) => e.objectId === objectId);
}
findByName(name: string): Entity | undefined {
return this.entities.find((e) => e.name === name);
}
firstOfType(type: Entity['type']): Entity | undefined {
return this.entities.find((e) => e.type === type);
}
remove(entity: Entity): void {
const index = this.entities.indexOf(entity);
if (index >= 0) this.entities.splice(index, 1);
}
destroy(): void {
for (const system of this.systems) system.stop?.(this);
this.systems.length = 0;
+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),
};
}
}
+36 -3
View File
@@ -1,13 +1,24 @@
/** Keyboard state tracker for play mode. Only records keys; systems interpret them. */
/**
* Keyboard state tracker for play mode. Records which keys are held and which went down /
* up since the last `endFrame()`, so systems can react to edges as well as to held keys.
*/
export class Input {
private readonly down = new Set<string>();
private readonly pressed = new Set<string>();
private readonly released = new Set<string>();
private readonly onKeyDown = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
if (!this.down.has(e.key)) this.pressed.add(e.key); // ignore auto-repeat
this.down.add(e.key);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
private readonly onKeyUp = (e: KeyboardEvent) => this.down.delete(e.key);
private readonly onBlur = () => this.down.clear();
private readonly onKeyUp = (e: KeyboardEvent) => {
if (this.down.delete(e.key)) this.released.add(e.key);
};
private readonly onBlur = () => {
for (const key of this.down) this.released.add(key);
this.down.clear();
};
attach(): void {
window.addEventListener('keydown', this.onKeyDown);
@@ -20,12 +31,34 @@ export class Input {
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
this.down.clear();
this.pressed.clear();
this.released.clear();
}
isDown(key: string): boolean {
return this.down.has(key);
}
/** Keys that went down since the last frame. */
get justPressed(): ReadonlySet<string> {
return this.pressed;
}
/** Keys that went up since the last frame. */
get justReleased(): ReadonlySet<string> {
return this.released;
}
get heldKeys(): ReadonlySet<string> {
return this.down;
}
/** Clears the per-frame edge sets; call once after all systems consumed them. */
endFrame(): void {
this.pressed.clear();
this.released.clear();
}
/** -1, 0 or 1 for a pair of keys. */
axis(negative: string, positive: string): number {
return (this.isDown(positive) ? 1 : 0) - (this.isDown(negative) ? 1 : 0);