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:
1 parent
d4b1ffa051
commit
922774778b
36 files changed
+2969
-147
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user