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

+60 -4
View File
@@ -98,6 +98,10 @@ export class NodeView extends Widget {
private inlineTexts = new Map<string, string>();
/** Which sides carry a link – a collapsed node marks those with one dot each. */
private linked = { inputs: false, outputs: false };
private liveTexts = new Map<string, string>();
private readonly valueTexts = new Map<string, Text>();
/** Where an output's value may be drawn: right-aligned before its label, clear of the input text. */
private valueSlots = new Map<string, { left: number; right: number; top: number }>();
/**
* Not fixed at construction: a nested-behavior node's ports come from another graph, so the
@@ -202,6 +206,41 @@ export class NodeView extends Widget {
return this.def.inputs.map((input) => this.inputText(input));
}
get outputLines(): string[] {
return this.def.outputs.map((output) => {
const value = this.valueTexts.get(output.key)?.text;
return value === undefined ? output.label : `${value} ${output.label}`;
});
}
/** Nothing clears them when play ends: the views are rebuilt then. */
setLiveValues(values: Record<string, Value> | null, project: Project | null): void {
const next = new Map<string, string>();
for (const output of this.def.outputs) {
const value = values?.[output.key];
if (output.type === 'exec' || value === undefined) continue;
next.set(output.key, formatLive(output, value, project));
}
if (next.size === this.liveTexts.size && [...next].every(([k, v]) => this.liveTexts.get(k) === v)) return;
this.liveTexts = next;
this.placeValues();
}
private placeValues(): void {
for (const text of this.valueTexts.values()) text.destroy();
this.valueTexts.clear();
if (this.collapsed) return;
const style = textStyle('live');
for (const [key, value] of this.liveTexts) {
const slot = this.valueSlots.get(key);
if (!slot || slot.right - slot.left < 12) continue;
const text = new Text({ text: truncate(value, style, slot.right - slot.left), style });
text.position.set(slot.right - text.width, slot.top);
this.addChild(text);
this.valueTexts.set(key, text);
}
}
private inputText(input: PortDef): string {
const inline = this.inlineTexts.get(input.key);
return inline === undefined ? input.label : `${input.label} ${inline}`;
@@ -247,6 +286,7 @@ export class NodeView extends Widget {
private rebuildTexts(): void {
for (const t of this.texts) t.destroy();
this.texts.length = 0;
this.valueSlots = new Map();
const add = (
text: string,
style: TextStyleToken,
@@ -259,6 +299,7 @@ export class NodeView extends Widget {
node.position.set(align === 'left' ? x : x - node.width, y);
this.addChild(node);
this.texts.push(node);
return node;
};
add(
this.def.label,
@@ -268,7 +309,10 @@ export class NodeView extends Widget {
'left',
this.w - TITLE_X - (this.collapsed ? COLLAPSED_TITLE_END : PAD),
);
if (this.collapsed) return;
if (this.collapsed) {
this.placeValues();
return;
}
let y = TITLE_HEIGHT + PAD;
for (const line of this.paramLines) {
add(line, 'small', PAD + 2, y + 2);
@@ -282,8 +326,9 @@ export class NodeView extends Widget {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2;
let inputEnd = PAD;
if (input) {
add(
const text = add(
this.inputText(input),
input.type === 'exec' ? 'body' : 'muted',
PAD + PORT_RADIUS + 4,
@@ -291,9 +336,10 @@ export class NodeView extends Widget {
'left',
NODE_WIDTH / 2 + 20,
);
inputEnd = text.x + text.width;
}
if (output)
add(
if (output) {
const text = add(
output.label,
output.type === 'exec' ? 'body' : 'muted',
this.w - PAD - PORT_RADIUS - 4,
@@ -301,7 +347,11 @@ export class NodeView extends Widget {
'right',
NODE_WIDTH / 2 - 10,
);
if (output.type !== 'exec')
this.valueSlots.set(output.key, { left: inputEnd + 6, right: text.x - 6, top: rowTop + 1 });
}
}
this.placeValues();
}
override redraw(): void {
@@ -388,6 +438,12 @@ function formatField(def: PropertyDef, value: Value, project: Project | null): s
return formatValue(value);
}
function formatLive(output: PortDef, value: Value, project: Project | null): string {
if (value === null) return '—';
if (value === '') return '""';
return output.field ? formatField(output.field, value, project) : formatValue(value);
}
function formatValue(value: Value): string {
// Whole numbers stay bare to keep nodes narrow; fractions match the field that edits them.
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value);