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);
}
+4
View File
@@ -198,6 +198,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId),
/** Each input row as drawn: its name, and the inline value beside it while unconnected. */
nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId),
/** Each output row as drawn: while playing, the value it last carried in front of its name. */
nodeOutputs: (nodeId: string) => graphEditor.canvas.nodeOutputs(nodeId),
/** Beads in flight on exec edges as drawn, in graph coordinates; `progress` 0 = source, 1 = target. */
beads: () => graphEditor.canvas.beadsDrawn(),
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
canvasRect: () => graphEditor.canvas.canvasRect(),
+24 -3
View File
@@ -10,12 +10,14 @@ import {
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { curvePoint, curveReach } from './beads';
import { type NodeView, portColor } from './NodeView';
/** How far a folded edge's stub reaches out of its port. */
const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
const BEAD_RADIUS = 4.5;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
export interface FoldMark {
@@ -31,6 +33,7 @@ export interface FoldMark {
/** The curves between nodes and the marks of the hidden ones, in graph coordinates. */
export class EdgeLayer extends Container {
private readonly curves = new Graphics();
private readonly beads = new Graphics();
private readonly chips = new Graphics();
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
private readonly labelLayer = new Container();
@@ -40,10 +43,11 @@ export class EdgeLayer extends Container {
private marks: FoldMark[] = [];
/** Edges the last draw really stroked as a curve – a hidden one is missing from it. */
private readonly stroked = new Set<string>();
private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = [];
constructor() {
super();
this.addChild(this.curves, this.chips, this.labelLayer);
this.addChild(this.curves, this.beads, this.chips, this.labelLayer);
}
draw(
@@ -51,14 +55,17 @@ export class EdgeLayer extends Container {
views: ReadonlyMap<string, NodeView>,
isHot: (edgeId: string) => boolean,
selected: Set<string>,
beads: (edgeId: string) => readonly number[] = () => [],
): void {
this.curves.clear();
this.beads.clear();
this.beadsDrawn = [];
this.chips.clear();
this.marks = [];
this.ends = [];
this.labelCount = 0;
this.stroked.clear();
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected);
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected, beads(edge.id));
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
for (let i = this.labelCount; i < this.labels.length; i++) this.labels[i]!.visible = false;
@@ -76,11 +83,16 @@ export class EdgeLayer extends Container {
return this.stroked.has(edgeId);
}
drawnBeads(): readonly { edgeId: string; progress: number; x: number; y: number }[] {
return this.beadsDrawn;
}
private drawEdge(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
hot: boolean,
selected: Set<string>,
beads: readonly number[],
): void {
const from = views.get(edge.from.node);
const to = views.get(edge.to.node);
@@ -106,6 +118,15 @@ export class EdgeLayer extends Container {
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
});
this.stroked.add(edge.id);
for (const progress of beads) {
const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress);
// Light with a dark rim: a bead in the highlight colour would vanish on the edge it lights up.
this.beads
.circle(x, y, BEAD_RADIUS)
.fill(Theme.colors.text)
.stroke({ width: 1.5, color: Theme.colors.graphBackground });
this.beadsDrawn.push({ edgeId: edge.id, progress, x, y });
}
}
private addFoldEnd(
@@ -167,6 +188,6 @@ export class EdgeLayer extends Container {
/** Horizontal S-curve between two points. */
export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
const dx = curveReach(x1, x2);
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
}
+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);
+2 -3
View File
@@ -25,7 +25,7 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
this.breadcrumb,
);
this.canvas.onOpenBehavior = (id) => this.open(id, descend(this.trail, this.currentAssetId, id));
// Edits made while playing would be thrown away with the run.
// Play swaps in the runtime copy of the project, and back.
state.on('mode', () => this.refresh());
}
@@ -69,7 +69,6 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
}
protected override syncExtras(asset: BehaviorAsset | null): void {
const editable = Boolean(asset) && this.state.mode === 'edit';
this.snapBox.setEnabled(editable);
this.snapBox.setEnabled(Boolean(asset));
}
}
+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);
+40
View File
@@ -0,0 +1,40 @@
/** Seconds a bead takes from the source port to the target port. */
export const BEAD_TRAVEL = 0.3;
/** Shortest gap between two beads on one edge: an edge firing every frame shows a chain, not a solid line. */
export const BEAD_SPACING = 0.15;
/** Launch times of the beads on one edge, oldest first; drops those that have arrived. */
export function launchBead(times: number[], now: number): void {
const last = times[times.length - 1];
// The tolerance keeps a fire exactly one spacing later (nine frames at 60 fps) from rounding below it.
if (last !== undefined && now - last < BEAD_SPACING - 1e-9) return;
while (times.length > 0 && now - times[0]! > BEAD_TRAVEL) times.shift();
times.push(now);
}
/** How far each bead has come, 0 at the source and 1 at the target. */
export function beadsInFlight(times: readonly number[], now: number): number[] {
return times.map((t) => (now - t) / BEAD_TRAVEL).filter((p) => p >= 0 && p <= 1);
}
/** How far an edge's curve reaches out of its ports before it bends towards the other end. */
export function curveReach(x1: number, x2: number): number {
return Math.max(40, Math.abs(x2 - x1) * 0.5);
}
/** The point at `t` on the curve `bezier` draws from (x1, y1) to (x2, y2). */
export function curvePoint(
x1: number,
y1: number,
x2: number,
y2: number,
t: number,
): { x: number; y: number } {
const dx = curveReach(x1, x2);
const u = 1 - t;
const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
return {
x: a * x1 + b * (x1 + dx) + c * (x2 - dx) + d * x2,
y: a * y1 + b * y1 + c * y2 + d * y2,
};
}
+26 -4
View File
@@ -1,11 +1,13 @@
import {
asNumber,
type EntityHandle,
type Graph,
type GraphNode,
GraphInterpreter,
isHandleEnabled,
type NodeContext,
type TraceListener,
type Value,
} from '../../core/graph';
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
@@ -24,12 +26,19 @@ export const TRACE_FADE = 0.35;
export interface Trace {
edges: Map<string, number>;
nodes: Map<string, number>;
values: Map<string, Record<string, Value>>;
}
interface Produced {
time: number;
values: Record<string, Value>;
}
interface Instance {
entity: Entity;
interpreter: GraphInterpreter;
trace: Trace;
trace: Omit<Trace, 'values'>;
values: Map<Graph, Map<string, Produced>>;
}
/**
@@ -100,13 +109,19 @@ export class BehaviorSystem implements System {
continue;
const asset = findAssetOf(this.project, entity.assetId, 'behavior');
if (!asset) continue;
const trace: Trace = { edges: new Map(), nodes: new Map() };
const trace = { edges: new Map<string, number>(), nodes: new Map<string, number>() };
const values = new Map<Graph, Map<string, Produced>>();
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project));
interpreter.trace = {
onEdge: (id) => trace.edges.set(id, world.time),
onNode: (id) => trace.nodes.set(id, world.time),
onValues: (graph, id, produced) => {
let byNode = values.get(graph);
if (!byNode) values.set(graph, (byNode = new Map<string, Produced>()));
byNode.set(id, { time: world.time, values: produced });
},
} satisfies TraceListener;
const instance = { entity, interpreter, trace };
const instance = { entity, interpreter, trace, values };
this.instances.push(instance);
this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0);
}
@@ -203,15 +218,22 @@ export class BehaviorSystem implements System {
* those that ran it as a node, which is the only way a nested behavior's graph ever lights up.
*/
traceFor(assetId: string): Trace {
const merged: Trace = { edges: new Map(), nodes: new Map() };
const merged: Trace = { edges: new Map(), nodes: new Map(), values: new Map() };
const latest = (into: Map<string, number>, from: Map<string, number>) => {
for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity));
};
const graph = findAssetOf(this.project, assetId, 'behavior')?.graph;
const newest = new Map<string, Produced>();
for (const instance of this.instances) {
if (instance.entity.assetId !== assetId && !instance.interpreter.calledBehaviors.has(assetId)) continue;
latest(merged.edges, instance.trace.edges);
latest(merged.nodes, instance.trace.nodes);
for (const [id, produced] of (graph && instance.values.get(graph)) ?? []) {
const known = newest.get(id);
if (!known || produced.time >= known.time) newest.set(id, produced);
}
}
for (const [id, produced] of newest) merged.values.set(id, produced.values);
return merged;
}
+4
View File
@@ -77,6 +77,7 @@ export type TextStyleToken =
| 'override'
| 'small'
| 'warning'
| 'live'
| 'title'
| 'mono'
| 'accent'
@@ -107,6 +108,9 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle {
case 'warning':
style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.danger });
break;
case 'live':
style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.hot });
break;
case 'title':
style = new TextStyle({
...base,