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
@@ -313,7 +313,14 @@ shown on the node and editable in the inspector.
|
||||
falls back to another one – the picker is how you switch on purpose.
|
||||
- **Live view**: Play always switches to the Game tab; switch to Graph while playing and
|
||||
the exec edges and nodes that just ran light up – the same canvas draws editor and game, so
|
||||
this costs nothing extra.
|
||||
this costs nothing extra. Each fire of an exec edge also sends a bead along it (0.3 s, at most
|
||||
one per 0.15 s and edge): a key press is a single bead, `On update` an even chain. Beads run
|
||||
on game time, so a paused game holds them where they are. Every data output shows, in the same colour, the value it last
|
||||
carried (`0.017 dt`, `Pause box object`), refreshed ten times a second. With several objects
|
||||
running the same behavior it is the one that produced last; in a loop, the last round. An
|
||||
output nothing has asked for yet shows nothing. The graph stays editable meanwhile: the interpreter reads the runtime
|
||||
copy's graph on every pulse, so an edit takes effect on the next frame and is discarded with
|
||||
the run. A new `On start` node does not fire for a behavior that has already started.
|
||||
- **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only
|
||||
touch world entities. A runaway pulse throws (see *Loops* for the two limits) and stops play
|
||||
with a notice.
|
||||
@@ -670,7 +677,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 578 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 588 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||
./e2e.sh status # live progress, or the last run's result
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddPortCommand } from '../../src/core/commands/portCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import {
|
||||
BEHAVIOR_END,
|
||||
BEHAVIOR_PARAM,
|
||||
BEHAVIOR_START,
|
||||
type Graph,
|
||||
GraphInterpreter,
|
||||
RUN_BEHAVIOR,
|
||||
type TraceListener,
|
||||
type Value,
|
||||
} from '../../src/core/graph';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
|
||||
import { Input } from '../../src/runtime/systems/Input';
|
||||
import { World } from '../../src/runtime/World';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
function recorder() {
|
||||
const seen = new Map<Graph, Map<string, Record<string, Value>>>();
|
||||
const listener: TraceListener = {
|
||||
onEdge: () => {},
|
||||
onNode: () => {},
|
||||
onValues: (graph, id, values) => {
|
||||
const byNode = seen.get(graph) ?? new Map<string, Record<string, Value>>();
|
||||
seen.set(graph, byNode.set(id, values));
|
||||
},
|
||||
};
|
||||
return { seen, listener };
|
||||
}
|
||||
|
||||
describe('live values', () => {
|
||||
it('record what each node last produced, a read node after its own write', () => {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B', ['count']);
|
||||
const edit = graphEditor(fixture, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const count = edit.variables(['count'], { write: true });
|
||||
const add = edit.addNode('Add', { b: 1 });
|
||||
edit.wire([
|
||||
[start, 'exec', count, 'exec'],
|
||||
[count, 'count', add, 'a'],
|
||||
[add, 'result', count, 'count'],
|
||||
]);
|
||||
const { seen, listener } = recorder();
|
||||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||||
interpreter.trace = listener;
|
||||
const ctx = fakeContext({ variables: { count: 5, other: 9 } });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
ctx.variables.count = 99;
|
||||
|
||||
const values = seen.get(behavior.graph)!;
|
||||
expect(values.get(add.id)).toEqual({ result: 6 });
|
||||
expect(values.get(count.id)).toEqual({ count: 6 });
|
||||
});
|
||||
|
||||
it('keep a nested behavior apart, under its own graph', () => {
|
||||
const fixture = makeProject();
|
||||
const callee = makeBehavior(fixture, 'Double');
|
||||
const inner = graphEditor(fixture, callee);
|
||||
const start = inner.addNode(BEHAVIOR_START);
|
||||
const end = inner.addNode(BEHAVIOR_END);
|
||||
fixture.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'in1', kind: 'number' }));
|
||||
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' }));
|
||||
const times = inner.addNode('Multiply', { b: 2 });
|
||||
inner.wire([
|
||||
[start, 'exec', end, 'exec'],
|
||||
[start, 'in1', times, 'a'],
|
||||
[times, 'result', end, 'out1'],
|
||||
]);
|
||||
const caller = makeBehavior(fixture, 'Caller');
|
||||
const outer = graphEditor(fixture, caller);
|
||||
const onStart = outer.addNode('OnStart');
|
||||
const call = outer.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: 21 });
|
||||
outer.connect(onStart, 'exec', call, 'exec');
|
||||
const { seen, listener } = recorder();
|
||||
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(fixture.project));
|
||||
interpreter.trace = listener;
|
||||
interpreter.fire('OnStart', fakeContext());
|
||||
|
||||
expect(seen.get(callee.graph)?.get(start.id)).toEqual({ in1: 21 });
|
||||
expect(seen.get(callee.graph)?.get(times.id)).toEqual({ result: 42 });
|
||||
expect(seen.get(caller.graph)?.get(call.id)).toEqual({ out1: 42 });
|
||||
expect(seen.get(caller.graph)?.has(times.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('show the instance that produced last, not the last one in the list', () => {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'Counter', ['count']);
|
||||
const edit = graphEditor(f, behavior);
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const count = edit.variables(['count'], { write: true });
|
||||
const add = edit.addNode('Add', { b: 1 });
|
||||
edit.wire([
|
||||
[update, 'exec', count, 'exec'],
|
||||
[count, 'count', add, 'a'],
|
||||
[add, 'result', count, 'count'],
|
||||
]);
|
||||
place(f, behavior.id, { x: 0, y: 0 });
|
||||
const second = place(f, behavior.id, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, second, 'count', 10));
|
||||
const world = World.instantiate(f.project, f.sceneId);
|
||||
const system = new BehaviorSystem(f.project, new Input());
|
||||
world.addSystem(system);
|
||||
|
||||
world.tick(1 / 60);
|
||||
expect(system.traceFor(behavior.id).values.get(count.id)).toEqual({ count: 11 });
|
||||
|
||||
world.entities.find((e) => e.objectId === second)!.props.enabled = false;
|
||||
world.tick(1 / 60);
|
||||
expect(system.traceFor(behavior.id).values.get(count.id)).toEqual({ count: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BEAD_SPACING,
|
||||
BEAD_TRAVEL,
|
||||
beadsInFlight,
|
||||
curvePoint,
|
||||
launchBead,
|
||||
} from '../../src/editor/graph/beads';
|
||||
|
||||
describe('beads on a firing edge', () => {
|
||||
it('a single fire sends one bead from the source to the target and then it is gone', () => {
|
||||
const times: number[] = [];
|
||||
launchBead(times, 2);
|
||||
expect(beadsInFlight(times, 2)).toEqual([0]);
|
||||
expect(beadsInFlight(times, 2 + BEAD_TRAVEL / 2)[0]).toBeCloseTo(0.5);
|
||||
expect(beadsInFlight(times, 2 + BEAD_TRAVEL)).toHaveLength(1);
|
||||
expect(beadsInFlight(times, 2 + BEAD_TRAVEL + 0.01)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a fire closer than the spacing to the last bead sends none', () => {
|
||||
const times: number[] = [];
|
||||
launchBead(times, 1);
|
||||
launchBead(times, 1 + BEAD_SPACING / 2);
|
||||
expect(times).toEqual([1]);
|
||||
launchBead(times, 1 + BEAD_SPACING);
|
||||
expect(times).toEqual([1, 1 + BEAD_SPACING]);
|
||||
});
|
||||
|
||||
it('an edge firing every frame shows an evenly spaced chain that does not pile up', () => {
|
||||
const times: number[] = [];
|
||||
let now = 0;
|
||||
for (let frame = 0; frame < 600; frame++) {
|
||||
now = frame / 60;
|
||||
launchBead(times, now);
|
||||
}
|
||||
const flying = beadsInFlight(times, now);
|
||||
expect(flying.length).toBeGreaterThanOrEqual(2);
|
||||
expect(times.length).toBeLessThanOrEqual(Math.ceil(BEAD_TRAVEL / BEAD_SPACING) + 1);
|
||||
for (let i = 1; i < flying.length; i++)
|
||||
expect(flying[i - 1]! - flying[i]!).toBeGreaterThanOrEqual(BEAD_SPACING / BEAD_TRAVEL - 1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the curve a bead follows', () => {
|
||||
it('starts in the source port and ends in the target port', () => {
|
||||
expect(curvePoint(10, 20, 300, 80, 0)).toEqual({ x: 10, y: 20 });
|
||||
expect(curvePoint(10, 20, 300, 80, 1)).toEqual({ x: 300, y: 80 });
|
||||
});
|
||||
|
||||
it('passes the middle of a curve half way, since the curve is symmetric', () => {
|
||||
const mid = curvePoint(0, 0, 200, 100, 0.5);
|
||||
expect(mid.x).toBeCloseTo(100);
|
||||
expect(mid.y).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it('bows out to the right between two ports stacked above each other, as the drawn curve does', () => {
|
||||
expect(curvePoint(0, 0, 0, 100, 0.25).x).toBeCloseTo(11.25);
|
||||
});
|
||||
|
||||
it('leaves its port horizontally, as the drawn curve does', () => {
|
||||
const near = curvePoint(0, 0, 200, 100, 0.01);
|
||||
expect(near.x).toBeGreaterThan(0);
|
||||
expect(Math.abs(near.y / near.x)).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
graph während dem playmode lässt sich nicht editieren.
|
||||
|
||||
- abklären was zu machen wäre um das zu ermöglichen
|
||||
- abklären ob es möglich wäre aktuelle werte anzuzeigen (nicht nur den wie der flow läuft sondern auch was)
|
||||
|
||||
---
|
||||
Umgesetzt (User-Wahl): Änderungen im Play wirken ab dem nächsten Frame und werden beim Stop mit verworfen, wie alles im Play. Ein neuer `On start` feuert für ein schon gestartetes Behavior nicht mehr.
|
||||
Werte: vor jedem Daten-Ausgang der zuletzt gelieferte Wert, 10× pro Sekunde erneuert; bei mehreren Instanzen zählt die mit dem neuesten Wert.
|
||||
@@ -0,0 +1,5 @@
|
||||
eine simple animation die den flow symbolisiert. vielleicht edge circles die durch fließen, jedes mal wen ein event ausgelöst wird oder so?
|
||||
mach vorschläge wie das dargestellt werden könnte.
|
||||
|
||||
---
|
||||
Umgesetzt (User-Wahl): Perlen auf Exec-Kanten, 0,3 s pro Durchlauf, höchstens eine alle 0,15 s je Kante. Laufen auf Spielzeit, stehen also in der Pause.
|
||||
@@ -1,4 +0,0 @@
|
||||
graph während dem playmode lässt sich nicht editieren.
|
||||
|
||||
- abklären was zu machen wäre um das zu ermöglichen
|
||||
- abklären ob es möglich wäre aktuelle werte anzuzeigen (nicht nur den wie der flow läuft sondern auch was)
|
||||
+101
-3
@@ -31,9 +31,24 @@ const pauseGraph = {
|
||||
};
|
||||
|
||||
export async function pause(t) {
|
||||
const { check, sleep, json, has, shot, clickText, press, entity, world, stop } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
json,
|
||||
has,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
press,
|
||||
entity,
|
||||
world,
|
||||
stop,
|
||||
select,
|
||||
evaluate,
|
||||
asset,
|
||||
} = t;
|
||||
const playInfo = () => json('__gm.playInfo()');
|
||||
const boxX = async () => (await entity('p-box')).x;
|
||||
const boxX = async () => (await entity('p-box'))?.x ?? NaN;
|
||||
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
|
||||
|
||||
// 19a. A box with a drifting behavior below it, written into the model directly: what is under
|
||||
@@ -143,10 +158,93 @@ export async function pause(t) {
|
||||
(await boxX()) > restartedX,
|
||||
`after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`,
|
||||
);
|
||||
|
||||
// 19f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
|
||||
// selects the Move node, Delete removes it, and the running world stops drifting the box.
|
||||
await select({ kind: 'asset', assetId: 'pauser' });
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(150);
|
||||
const outputs = (id) => json(`__gm.graph.nodeOutputs(${JSON.stringify(id)})`);
|
||||
const live = {
|
||||
owner: await outputs('p-owner'),
|
||||
update: await outputs('p-update'),
|
||||
move: await outputs('p-move'),
|
||||
};
|
||||
check(
|
||||
'while playing, each output shows the value it last carried in front of its name',
|
||||
live.owner[0] === 'Pause box object' &&
|
||||
/^0\.\d{3} {2}dt$/.test(live.update[1]) &&
|
||||
live.move[1] === 'false blocked x',
|
||||
JSON.stringify(live),
|
||||
);
|
||||
await shot('36b-graph-live-values');
|
||||
const beadsOn = async (edgeId) =>
|
||||
(await json('__gm.graph.beads()')).filter((b) => b.edgeId === edgeId).map((b) => b.progress);
|
||||
const chain = await beadsOn('p-e1');
|
||||
const gaps = chain.slice(1).map((p, i) => chain[i] - p);
|
||||
check(
|
||||
'an exec edge firing every frame carries an evenly spaced chain of beads',
|
||||
chain.length >= 1 && chain.length <= 3 && gaps.every((g) => Math.abs(g - 0.5) < 0.1),
|
||||
JSON.stringify(chain),
|
||||
);
|
||||
check(
|
||||
'data edges carry no beads',
|
||||
(await beadsOn('p-e2')).length === 0 && (await beadsOn('p-e3')).length === 0,
|
||||
);
|
||||
const beforeKey = await beadsOn('p-e4');
|
||||
await press('ArrowRight');
|
||||
await sleep(80);
|
||||
const onKey = await beadsOn('p-e4');
|
||||
await sleep(500);
|
||||
const afterKey = await beadsOn('p-e4');
|
||||
check(
|
||||
'a single key press sends exactly one bead, and it arrives',
|
||||
beforeKey.length === 0 && onKey.length === 1 && afterKey.length === 0,
|
||||
JSON.stringify({ beforeKey, onKey, afterKey }),
|
||||
);
|
||||
const movePos = await json(`__gm.graph.nodeAt('p-move')`);
|
||||
await click(movePos.x, movePos.y);
|
||||
await sleep(100);
|
||||
check(
|
||||
'a click selects a node while playing',
|
||||
JSON.stringify(await json('__gm.graph.selected()')) === '["p-move"]',
|
||||
JSON.stringify(await json('__gm.graph.selected()')),
|
||||
);
|
||||
const area = await json('__gm.graph.canvasRect()');
|
||||
await click(area.x + area.w - 15, area.y + area.h - 15, 'right');
|
||||
await sleep(100);
|
||||
check('right-clicking the canvas opens the node palette while playing', await has('Search nodes…'));
|
||||
await press('Escape');
|
||||
await sleep(100);
|
||||
check(
|
||||
'Escape closes the palette and leaves the game running',
|
||||
!(await has('Search nodes…')) && (await playInfo()).playing === true,
|
||||
);
|
||||
await click(movePos.x, movePos.y);
|
||||
await press('Delete');
|
||||
await sleep(150);
|
||||
const stoppedAt = await boxX();
|
||||
await sleep(500);
|
||||
check(
|
||||
'deleting the Move node while playing stops the drift on the next frames',
|
||||
!(await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move') &&
|
||||
Math.abs((await boxX()) - stoppedAt) < 0.001,
|
||||
`x ${stoppedAt} → ${await boxX()}`,
|
||||
);
|
||||
await stop();
|
||||
await sleep(200);
|
||||
check(
|
||||
'stopping takes the values off the nodes',
|
||||
JSON.stringify(await outputs('p-owner')) === '["object"]',
|
||||
JSON.stringify(await outputs('p-owner')),
|
||||
);
|
||||
check(
|
||||
'stopping discards the graph edit with the rest of the run',
|
||||
(await asset('pauser')).graph.nodes.some((n) => n.id === 'p-move'),
|
||||
JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)),
|
||||
);
|
||||
|
||||
// 19f. Play discarded its copy, so the box is back at the position the model holds.
|
||||
// 19g. Play discarded its copy, so the box is back at the position the model holds.
|
||||
const left = await rig.remove();
|
||||
check(
|
||||
'the step leaves nothing of its own behind',
|
||||
|
||||
@@ -276,7 +276,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
const storedObjects = () => json('__gm.state.activeScene.objects');
|
||||
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`);
|
||||
const world = () => json('__gm.world()');
|
||||
const entity = async (objectId) => (await world()).entities.find((e) => e.objectId === objectId);
|
||||
const entity = async (objectId) => (await world())?.entities.find((e) => e.objectId === objectId);
|
||||
const select = (selection) => evaluate(`__gm.state.select(${JSON.stringify(selection)})`);
|
||||
/**
|
||||
* Runs `body` (JavaScript source) against the page's model and previews the result, the way a
|
||||
|
||||
Reference in new issue
Block a user