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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user