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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user