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:
1 parent
495756b500
commit
656d61f187
17 files changed
+542
-37
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user