Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection, duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own contract. The three Create*Command share ImportFileCommand, the three renames share RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in portSection.ts replace in-file clones, and three comments that told history now state the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||
import { type GraphLookup, NO_LOOKUP } from './lookup';
|
||
import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested';
|
||
import type { NodeContext } from './NodeContext';
|
||
import {
|
||
type CallResult,
|
||
hasExecInput,
|
||
inlineValue,
|
||
isEventNode,
|
||
type NodeDef,
|
||
type PortDef,
|
||
type RepeatResult,
|
||
} from './NodeRegistry';
|
||
import { machineIdOf } from './stateMachine';
|
||
import type { Value } from './values';
|
||
|
||
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
|
||
const MAX_STEPS_PER_FIRE = 100_000;
|
||
|
||
/**
|
||
* How far exec edges may nest. The interpreter recurses along them, so this is what keeps a flow
|
||
* feeding back into itself off the call stack – the step budget cannot, since a legitimate pulse
|
||
* may run tens of thousands of steps.
|
||
*/
|
||
const MAX_DEPTH = 1000;
|
||
|
||
export interface TraceListener {
|
||
onEdge(edgeId: string): void;
|
||
onNode(nodeId: string): void;
|
||
/** What a node's outputs held when it last produced them, in the graph it belongs to. */
|
||
onValues?(graph: Graph, nodeId: string, values: Record<string, Value>): void;
|
||
}
|
||
|
||
/** One graph's pulse state. A nested behavior runs in its own – node ids are unique per graph only. */
|
||
interface Frame {
|
||
graph: Graph;
|
||
outputCache: Map<string, Record<string, Value>>;
|
||
dataCache: Map<string, Record<string, Value>>;
|
||
}
|
||
|
||
function emptyFrame(graph: Graph): Frame {
|
||
return { graph, outputCache: new Map(), dataCache: new Map() };
|
||
}
|
||
|
||
/**
|
||
* Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows
|
||
* exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and
|
||
* reading exec nodes' outputs from the pulse cache.
|
||
*/
|
||
export class GraphInterpreter {
|
||
/** Per-node state that persists across pulses (timer accumulators etc.). */
|
||
readonly nodeState = new Map<string, Record<string, number>>();
|
||
/** Assets run as a node so far – the graph editor highlights them from this instance's trace. */
|
||
readonly calledBehaviors = new Set<string>();
|
||
private frame: Frame;
|
||
private callStack: string[] = [];
|
||
private steps = 0;
|
||
trace: TraceListener | null = null;
|
||
|
||
constructor(
|
||
readonly graph: Graph,
|
||
private readonly lookup: GraphLookup = NO_LOOKUP,
|
||
) {
|
||
this.frame = emptyFrame(graph);
|
||
}
|
||
|
||
eventNodes(type: string): GraphNode[] {
|
||
return this.graph.nodes.filter((n) => n.type === type);
|
||
}
|
||
|
||
fire(
|
||
type: string,
|
||
ctx: NodeContext,
|
||
outputs: Record<string, Value> = {},
|
||
filter?: (node: GraphNode) => boolean,
|
||
/** Which of the event node's exec outputs the pulse leaves through – a state event names one. */
|
||
port = 'exec',
|
||
): number {
|
||
let fired = 0;
|
||
for (const node of this.eventNodes(type)) {
|
||
if (filter && !filter(node)) continue;
|
||
this.frame = emptyFrame(this.graph);
|
||
this.callStack = [];
|
||
this.steps = 0;
|
||
this.frame.outputCache.set(node.id, { ...outputs });
|
||
this.trace?.onNode(node.id);
|
||
this.record(node.id, outputs);
|
||
this.follow(node, port, ctx, 0);
|
||
fired++;
|
||
}
|
||
return fired;
|
||
}
|
||
|
||
/**
|
||
* `On state entered` / `On state exited` for one move: only the nodes watching that machine, and
|
||
* out of the one output the state stands for. A node whose machine input carries a running value
|
||
* rather than a pick has no state outputs at all, so it is never among them.
|
||
*/
|
||
fireStateEvent(type: string, ctx: NodeContext, machineId: string, stateId: string): number {
|
||
if (!machineId || !stateId) return 0;
|
||
return this.fire(
|
||
type,
|
||
ctx,
|
||
{},
|
||
(node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId,
|
||
stateId,
|
||
);
|
||
}
|
||
|
||
private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void {
|
||
for (const edge of edgesOutOf(this.frame.graph, node.id, port)) {
|
||
const target = this.node(edge.to.node);
|
||
if (!target) continue;
|
||
this.trace?.onEdge(edge.id);
|
||
this.execute(target, ctx, depth + 1, edge.to.port);
|
||
}
|
||
}
|
||
|
||
private execute(node: GraphNode, ctx: NodeContext, depth: number, entry: string): void {
|
||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||
if (depth > MAX_DEPTH)
|
||
throw new Error('Behavior graph nested too deeply (a flow feeding back on itself?)');
|
||
const def = this.def(node);
|
||
if (!def?.run) return;
|
||
const inputs = this.collectInputs(node, ctx);
|
||
const result = def.run(ctx, inputs, node.params, def, entry, this.lookup);
|
||
const returned = result.call ? this.call(result.call, ctx, depth) : {};
|
||
const produced = { ...result.outputs, ...returned };
|
||
if (!def.read) this.frame.outputCache.set(node.id, produced);
|
||
this.trace?.onNode(node.id);
|
||
if (this.trace?.onValues) this.record(node.id, def.read ? this.readAll(node, def, ctx) : produced);
|
||
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
|
||
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||
for (const port of next) this.follow(node, port, ctx, depth);
|
||
}
|
||
|
||
/**
|
||
* Runs another behavior from its start node to its end node and returns what the end node was
|
||
* handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is
|
||
* a call and not an inlining: nothing of it survives the return but the values it hands back.
|
||
*/
|
||
private call(call: CallResult, ctx: NodeContext, depth: number): Record<string, Value> {
|
||
const program = this.lookup.behavior(call.behaviorId);
|
||
if (!program) return {};
|
||
if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`);
|
||
const start = terminalNode(program.graph, BEHAVIOR_START);
|
||
if (!start) return {};
|
||
const outer = this.frame;
|
||
this.frame = emptyFrame(program.graph);
|
||
this.frame.outputCache.set(start.id, { ...call.inputs });
|
||
this.record(start.id, call.inputs);
|
||
this.callStack.push(call.behaviorId);
|
||
this.calledBehaviors.add(call.behaviorId);
|
||
try {
|
||
this.trace?.onNode(start.id);
|
||
// Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the
|
||
// caller's. What it does to a local machine therefore dies with the call and is not reported;
|
||
// a `global` machine is the run's, so moving that one is a move like any other.
|
||
this.follow(
|
||
start,
|
||
'exec',
|
||
{
|
||
...ctx,
|
||
variables: { ...program.defaults },
|
||
states: {},
|
||
stateChanged: (machineId, from, to) => {
|
||
if (machineId in ctx.shared.states) ctx.stateChanged(machineId, from, to);
|
||
},
|
||
},
|
||
depth + 1,
|
||
);
|
||
const end = terminalNode(program.graph, BEHAVIOR_END);
|
||
return (end && this.frame.outputCache.get(end.id)) ?? {};
|
||
} finally {
|
||
this.callStack.pop();
|
||
this.frame = outer;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* One round per count, the loop node's `index` output carrying the round number. Data nodes are
|
||
* cached for the whole pulse, so the round has to drop that cache first – otherwise every round
|
||
* would read the values the first one computed.
|
||
*/
|
||
private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void {
|
||
const outputs = this.frame.outputCache.get(node.id) ?? {};
|
||
for (let round = 0; round < repeat.times; round++) {
|
||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||
this.frame.dataCache.clear();
|
||
const roundOutputs = { ...outputs, [repeat.index]: round };
|
||
this.frame.outputCache.set(node.id, roundOutputs);
|
||
this.record(node.id, roundOutputs);
|
||
this.follow(node, repeat.port, ctx, depth);
|
||
}
|
||
this.frame.dataCache.clear();
|
||
}
|
||
|
||
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
||
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
|
||
const inputs: Record<string, Value> = {};
|
||
for (const port of this.def(node)?.inputs ?? [])
|
||
if (port.type !== 'exec') inputs[port.key] = this.inputValue(node, port, ctx);
|
||
return inputs;
|
||
}
|
||
|
||
private inputValue(node: GraphNode, port: PortDef, ctx: NodeContext): Value {
|
||
const edge = edgesInto(this.frame.graph, node.id, port.key)[0];
|
||
return edge ? this.readOutput(edge.from.node, edge.from.port, ctx) : inlineValue(node, port);
|
||
}
|
||
|
||
/** 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.frame.outputCache.get(nodeId) ?? this.frame.dataCache.get(nodeId);
|
||
if (cached) return cached[port] ?? null;
|
||
const source = this.node(nodeId);
|
||
if (!source) return null;
|
||
const def = this.def(source);
|
||
if (!def) return null;
|
||
if (def.read) {
|
||
this.trace?.onNode(nodeId);
|
||
const values = this.readAll(source, def, ctx);
|
||
this.record(nodeId, values);
|
||
return values[port] ?? null;
|
||
}
|
||
// 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.frame.dataCache.set(nodeId, {});
|
||
const outputs =
|
||
def.run(ctx, this.collectInputs(source, ctx), source.params, def, '', this.lookup).outputs ?? {};
|
||
this.frame.dataCache.set(nodeId, outputs);
|
||
this.trace?.onNode(nodeId);
|
||
this.record(nodeId, outputs);
|
||
return outputs[port] ?? null;
|
||
}
|
||
|
||
private readAll(node: GraphNode, def: NodeDef, ctx: NodeContext): Record<string, Value> {
|
||
const input = (key: string): Value => {
|
||
const found = def.inputs.find((p) => p.key === key);
|
||
return found ? this.inputValue(node, found, ctx) : null;
|
||
};
|
||
// `read` may hand back a whole bag (the Variables node returns the object's variables, live).
|
||
const all = def.read?.(ctx, node.params, input, def, this.lookup) ?? {};
|
||
const own: Record<string, Value> = {};
|
||
for (const port of def.outputs) if (port.key in all) own[port.key] = all[port.key] ?? null;
|
||
return own;
|
||
}
|
||
|
||
private record(nodeId: string, values: Record<string, Value>): void {
|
||
this.trace?.onValues?.(this.frame.graph, nodeId, values);
|
||
}
|
||
|
||
private def(node: GraphNode) {
|
||
return resolveDef(node, this.frame.graph, this.lookup);
|
||
}
|
||
|
||
private node(id: string): GraphNode | undefined {
|
||
return this.frame.graph.nodes.find((n) => n.id === id);
|
||
}
|
||
}
|