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

+48 -6
View File
@@ -29,6 +29,7 @@ import type { BehaviorAsset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { beadsInFlight, launchBead } from './beads';
import {
activeDrag,
clamp,
@@ -64,6 +65,8 @@ const NODE_HEIGHT_ESTIMATE = 80;
const FIT_MARGIN = 24;
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
const DRAG_THRESHOLD = 3;
/** Live values change every frame; ten updates a second are still readable and cost far less text layout. */
const LIVE_VALUE_INTERVAL_MS = 100;
/**
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
@@ -109,6 +112,11 @@ export class GraphCanvas extends Widget {
private tempEdgeShown = false;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
private valuesShownAt = 0;
/** In game time, so a paused game holds its beads. */
private readonly beadTimes = new Map<string, number[]>();
private readonly beadFired = new Map<string, number>();
private beadAsset = '';
/** Supplies live execution data while playing (null otherwise). */
traceProvider: (assetId: string) => Trace | null = () => null;
timeProvider: () => number = () => 0;
@@ -291,10 +299,10 @@ export class GraphCanvas extends Widget {
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
const mark = this.state.mode === 'edit' ? this.edgeLayer.markAt(g) : null;
const mark = this.edgeLayer.markAt(g);
if (e.button === 2) {
if (mark) this.openLinks((g) => g.edges.filter((it) => mark.edgeIds.includes(it.id)), e.global);
else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g);
else if (this.assetId) this.openPalette(e.global.x, e.global.y, g);
return;
}
if (e.button === 0) {
@@ -311,7 +319,7 @@ export class GraphCanvas extends Widget {
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
// Panning takes precedence over the node: let the middle button bubble up to the canvas.
if (e.button === 1) return;
if (!this.assetId || this.state.mode !== 'edit') return;
if (!this.assetId) return;
e.stopPropagation();
const local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y);
@@ -427,7 +435,7 @@ export class GraphCanvas extends Widget {
const local = this.toLocal(e.global);
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined;
if (!inside || !this.assetId || this.state.mode !== 'edit' || asset?.type !== 'behavior') {
if (!inside || !this.assetId || asset?.type !== 'behavior') {
this.setIncoming(null);
return;
}
@@ -465,7 +473,7 @@ export class GraphCanvas extends Widget {
this.panning = null;
const marquee = this.marquee;
this.marquee = null;
if (marquee && this.assetId && this.state.mode === 'edit') {
if (marquee && this.assetId) {
if (marquee.active) this.selectInMarquee(marquee);
else this.state.select({ kind: 'asset', assetId: this.assetId });
}
@@ -652,21 +660,32 @@ export class GraphCanvas extends Widget {
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
this.launchBeads(asset.id, trace);
const isHot = (map: Map<string, number> | undefined, id: string) => {
const t = map?.get(id);
return t !== undefined && now - t <= TRACE_FADE;
};
const selectedNodes = new Set(this.state.selectedNodeIds);
const target = this.dropTarget;
const wall = performance.now();
const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null;
if (values) this.valuesShownAt = wall;
for (const view of this.views.values()) {
view.selected = selectedNodes.has(view.node.id);
view.hot = isHot(trace?.nodes, view.node.id);
if (values) view.setLiveValues(values.get(view.node.id) ?? null, this.state.project);
view.targetPort =
target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null;
view.redraw();
}
this.edgeLayer.draw(asset.graph.edges, this.views, (id) => isHot(trace?.edges, id), selectedNodes);
this.edgeLayer.draw(
asset.graph.edges,
this.views,
(id) => isHot(trace?.edges, id),
selectedNodes,
(id) => beadsInFlight(this.beadTimes.get(id) ?? [], now),
);
this.tempEdge.clear();
this.tempEdgeShown = false;
@@ -794,6 +813,29 @@ export class GraphCanvas extends Widget {
return this.views.get(nodeId)?.inputLines ?? null;
}
private launchBeads(assetId: string, trace: Trace | null): void {
if (!trace || assetId !== this.beadAsset) {
this.beadTimes.clear();
this.beadFired.clear();
this.beadAsset = assetId;
}
for (const [id, time] of trace?.edges ?? []) {
if (this.beadFired.get(id) === time) continue;
this.beadFired.set(id, time);
const times = this.beadTimes.get(id) ?? [];
this.beadTimes.set(id, times);
launchBead(times, time);
}
}
beadsDrawn(): readonly { edgeId: string; progress: number; x: number; y: number }[] {
return this.edgeLayer.drawnBeads();
}
nodeOutputs(nodeId: string): string[] | null {
return this.views.get(nodeId)?.outputLines ?? null;
}
/** Global screen position of a node's title bar centre. */
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);