Edit the graph during play and watch values and flow run through it

The graph was only locked by five edit-mode guards in GraphCanvas. The
running game reads the graph by reference each frame, so a new node, edge,
changed value, disconnect or undo takes effect from the next frame. Stop
discards these edits like everything else changed in play. A new On start
node does not fire for a behavior that has already started.

During play every data output shows the value it last produced, in the
highlight colour and refreshed ten times a second. Values are kept per
graph, because node ids are only unique within one; with several
instances the most recent value wins. The Variables node reads the live
variable bag, so only its own outputs are kept, as a snapshot.

Each time an exec edge fires, a bead runs along it in 0.3 s, at most one
every 0.15 s per edge, following the same curve the edge is drawn with.
Beads run on game time and freeze while paused; the game logic is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 21:19:07 +02:00
1 parent 495756b500
commit 656d61f187
17 files changed
+542 -37

No files matched your search

+31 -7
View File
@@ -7,6 +7,7 @@ import {
hasExecInput,
inlineValue,
isEventNode,
type NodeDef,
type PortDef,
type RepeatResult,
} from './NodeRegistry';
@@ -25,6 +26,8 @@ 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. */
@@ -78,6 +81,7 @@ export class GraphInterpreter {
this.steps = 0;
this.frame.outputCache.set(node.id, { ...outputs });
this.trace?.onNode(node.id);
this.record(node.id, outputs);
this.follow(node, 'exec', ctx, 0);
fired++;
}
@@ -103,8 +107,10 @@ export class GraphInterpreter {
const inputs = this.collectInputs(node, ctx);
const result = def.run(ctx, inputs, node.params, def);
const returned = result.call ? this.call(result.call, ctx, depth) : {};
if (!def.read) this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
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);
@@ -124,6 +130,7 @@ export class GraphInterpreter {
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 {
@@ -148,7 +155,9 @@ export class GraphInterpreter {
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
this.frame.dataCache.clear();
this.frame.outputCache.set(node.id, { ...outputs, [repeat.index]: round });
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();
@@ -177,11 +186,9 @@ export class GraphInterpreter {
if (!def) return null;
if (def.read) {
this.trace?.onNode(nodeId);
const input = (key: string): Value => {
const found = def.inputs.find((p) => p.key === key);
return found ? this.inputValue(source, found, ctx) : null;
};
return def.read(ctx, source.params, input)[port] ?? null;
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;
@@ -192,9 +199,26 @@ export class GraphInterpreter {
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def).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) ?? {};
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.behaviors);
}