Let a behavior be a node in another graph
A behavior that has a Start and an End node can now be dropped into any other graph as a node of its own. What is declared on the start node becomes the inputs of that node, what hangs on the end node its outputs; the port list is derived from the sub graph, not copied, so declaring a port in one graph grows the node in the other without touching it. The node carries the behavior's name and the palette lists behaviors by name, so a call is one grab instead of a node plus a dropdown. An embedded behavior has no object and therefore no place for variables: every call starts on the asset defaults and communicates only through its ports. That is the conservative direction - state can still be added later, and shared state already has a home in a behavior object in the scene. Node ids are unique per graph only, so each call gets its own pulse cache; without that, two graphs would collide. Renaming a port used to silently drop the value typed on the calling node. It is carried along now, project wide, like the edges.
This commit is contained in:
1 parent
64e034ffb9
commit
6702ff8abe
31 files changed
+1402
-107
No files matched your search
@@ -97,7 +97,7 @@ shown on the node and editable in the inspector.
|
|||||||
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
|
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
|
||||||
from any object.
|
from any object.
|
||||||
- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`,
|
- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`,
|
||||||
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · flow `Branch`, `Sequence`, `Repeat` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
|
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
|
||||||
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
|
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
|
||||||
variable`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
|
variable`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
|
||||||
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`,
|
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`,
|
||||||
@@ -117,6 +117,17 @@ shown on the node and editable in the inspector.
|
|||||||
`Object by name` taking the name the round put together (`"c" + index`). Two guards, not one:
|
`Object by name` taking the name the round put together (`"c" + index`). Two guards, not one:
|
||||||
a pulse may take 100 000 steps (a loop legitimately runs long), but exec edges may only nest
|
a pulse may take 100 000 steps (a loop legitimately runs long), but exec edges may only nest
|
||||||
1000 deep – that is what catches a flow feeding back into itself before the call stack does.
|
1000 deep – that is what catches a flow feeding back into itself before the call stack does.
|
||||||
|
- **A behavior as a node** (nesting): a behavior that has a **Start** and an **End** node can be
|
||||||
|
run as a step of another behavior's graph. Every input declared on the start node becomes an
|
||||||
|
input port of the calling node, every output declared on the end node one of its outputs; the
|
||||||
|
node is drawn under the behavior's own name, and the palette lists it there too, so a call is
|
||||||
|
one pick. Declaring a port keeps both sides in step – renaming carries the typed-in value along,
|
||||||
|
removing takes the edges hanging on it in *every* graph that calls the behavior. A call is a
|
||||||
|
**call, not an inlining**: the nested graph runs in its own pulse cache with a fresh copy of its
|
||||||
|
variable defaults, so values travel in and out through the ports and nothing else. That makes a
|
||||||
|
call repeatable and keeps a nested behavior free of hidden state – for state, use a behavior
|
||||||
|
object in the scene (see *Cross-object state* below). A behavior that would run itself raises an
|
||||||
|
error instead of looping; there is one start and one end node per behavior.
|
||||||
- **Cross-object state**: a behavior's variables *are* the properties of its object, so another
|
- **Cross-object state**: a behavior's variables *are* the properties of its object, so another
|
||||||
graph reads and writes them with `Object by name` → `Get property` / `Set property`. That is
|
graph reads and writes them with `Object by name` → `Get property` / `Set property`. That is
|
||||||
how the example game keeps one coin counter for the whole run.
|
how the example game keeps one coin counter for the whole run.
|
||||||
@@ -496,7 +507,7 @@ npm run format # Prettier over src, tests and tools
|
|||||||
npm run check # typecheck + lint + format:check + tests, what CI would run
|
npm run check # typecheck + lint + format:check + tests, what CI would run
|
||||||
npm run build # writes dist/ and regenerates index.html
|
npm run build # writes dist/ and regenerates index.html
|
||||||
export GM_REMOTE=user@host # where the browser runs; see below
|
export GM_REMOTE=user@host # where the browser runs; see below
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 368 editor checks, screenshots in /tmp/gm-e2e-*.png
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # 379 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 17 checks: the Tetris example, played end to end
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 17 checks: the Tetris example, played end to end
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
type GraphNode,
|
type GraphNode,
|
||||||
portsCompatible,
|
portsCompatible,
|
||||||
} from '../graph/Graph';
|
} from '../graph/Graph';
|
||||||
|
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
|
||||||
import { nodeDef } from '../graph/NodeRegistry';
|
import { nodeDef } from '../graph/NodeRegistry';
|
||||||
|
import { behaviorLookup } from '../model/behaviorPrograms';
|
||||||
import { newId } from '../ids';
|
import { newId } from '../ids';
|
||||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
@@ -42,7 +44,12 @@ export class AddNodeCommand implements Command {
|
|||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
requireNodeDef(this.node.type);
|
requireNodeDef(this.node.type);
|
||||||
requireBehavior(project, this.assetId).graph.nodes.push(this.node);
|
const graph = requireBehavior(project, this.assetId).graph;
|
||||||
|
// The two terminals give the behavior its signature; a second one of either would make
|
||||||
|
// "which ports does this behavior have" ambiguous.
|
||||||
|
if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type))
|
||||||
|
throw new CommandError(`This behavior already has a ${nodeDef(this.node.type)?.label} node`);
|
||||||
|
graph.nodes.push(this.node);
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
@@ -153,8 +160,9 @@ export class ConnectCommand implements Command {
|
|||||||
const toNode = findNode(graph, this.edge.to.node);
|
const toNode = findNode(graph, this.edge.to.node);
|
||||||
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
||||||
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
|
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
|
||||||
const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port);
|
const lookup = behaviorLookup(project);
|
||||||
const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
|
const out = resolveDef(fromNode, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
|
||||||
|
const inp = resolveDef(toNode, lookup)?.inputs.find((p) => p.key === this.edge.to.port);
|
||||||
if (!out || !inp) throw new CommandError('Port not found');
|
if (!out || !inp) throw new CommandError('Port not found');
|
||||||
if (!portsCompatible(out.type, inp.type))
|
if (!portsCompatible(out.type, inp.type))
|
||||||
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
BEHAVIOR_PARAM,
|
||||||
|
BEHAVIOR_START,
|
||||||
|
type DeclaredPort,
|
||||||
|
type Graph,
|
||||||
|
type GraphEdge,
|
||||||
|
type GraphNode,
|
||||||
|
isValidPortName,
|
||||||
|
RUN_BEHAVIOR,
|
||||||
|
terminalNode,
|
||||||
|
} from '../graph';
|
||||||
|
import type { PropertyValue } from '../model/Properties';
|
||||||
|
import type { Project } from '../model/Project';
|
||||||
|
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||||
|
import { type Command, CommandError } from './Command';
|
||||||
|
import { requireBehavior } from './require';
|
||||||
|
|
||||||
|
/** Where a declared port shows up: on the terminal node itself, and on every node calling it. */
|
||||||
|
interface PortSite {
|
||||||
|
graph: Graph;
|
||||||
|
nodeId: string;
|
||||||
|
/** Whether the port is an input there – the terminal and the call node face opposite ways. */
|
||||||
|
asInput: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A start node's port is an output there and an input on the calling node; an end node's port the
|
||||||
|
* other way round. Every site has to be kept in step, or an edge would hang on a port that is gone.
|
||||||
|
*/
|
||||||
|
function portSites(project: Project, assetId: string, terminalType: string): PortSite[] {
|
||||||
|
const asset = requireBehavior(project, assetId);
|
||||||
|
const sites: PortSite[] = [];
|
||||||
|
const terminal = terminalNode(asset.graph, terminalType);
|
||||||
|
const onStart = terminalType === BEHAVIOR_START;
|
||||||
|
if (terminal) sites.push({ graph: asset.graph, nodeId: terminal.id, asInput: !onStart });
|
||||||
|
for (const other of project.assets) {
|
||||||
|
if (other.type !== 'behavior') continue;
|
||||||
|
for (const node of other.graph.nodes) {
|
||||||
|
if (node.type !== RUN_BEHAVIOR || node.params[BEHAVIOR_PARAM] !== assetId) continue;
|
||||||
|
sites.push({ graph: other.graph, nodeId: node.id, asInput: onStart });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sites;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean {
|
||||||
|
return (edge) =>
|
||||||
|
site.asInput
|
||||||
|
? edge.to.node === site.nodeId && edge.to.port === key
|
||||||
|
: edge.from.node === site.nodeId && edge.from.port === key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireTerminal(project: Project, assetId: string, terminalType: string): GraphNode {
|
||||||
|
const node = terminalNode(requireBehavior(project, assetId).graph, terminalType);
|
||||||
|
if (!node) throw new CommandError('The behavior has no start or end node');
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeAt(site: PortSite): GraphNode | undefined {
|
||||||
|
return site.graph.nodes.find((n) => n.id === site.nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameParam(site: PortSite, from: string, to: string): void {
|
||||||
|
const node = nodeAt(site);
|
||||||
|
const value = node?.params[from];
|
||||||
|
if (!node || value === undefined) return;
|
||||||
|
node.params[to] = value;
|
||||||
|
delete node.params[from];
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID_NAME = 'Port names must start with a letter and contain only letters, digits or _';
|
||||||
|
|
||||||
|
export class AddPortCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly terminalType: string,
|
||||||
|
private readonly port: DeclaredPort,
|
||||||
|
) {
|
||||||
|
this.label = `Add ${port.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||||
|
if (!isValidPortName(this.port.key)) throw new CommandError(INVALID_NAME);
|
||||||
|
if ((node.ports ?? []).some((p) => p.key === this.port.key))
|
||||||
|
throw new CommandError(`"${this.port.key}" already exists`);
|
||||||
|
node.ports = [...(node.ports ?? []), { ...this.port }];
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||||
|
node.ports = (node.ports ?? []).filter((p) => p.key !== this.port.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes the edges hanging on the port with it – in this graph and in every calling one. */
|
||||||
|
export class RemovePortCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
private removed?: Removed<DeclaredPort>;
|
||||||
|
private edges: { graph: Graph; entries: Removed<GraphEdge>[] }[] = [];
|
||||||
|
private values: { site: PortSite; value: PropertyValue }[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly terminalType: string,
|
||||||
|
private readonly key: string,
|
||||||
|
) {
|
||||||
|
this.label = `Remove ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const sites = portSites(project, this.assetId, this.terminalType);
|
||||||
|
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||||
|
const ports = [...(node.ports ?? [])];
|
||||||
|
const removed = removeFirst(ports, (p) => p.key === this.key);
|
||||||
|
if (!removed) throw new CommandError(`Port "${this.key}" not found`);
|
||||||
|
this.removed = removed;
|
||||||
|
node.ports = ports;
|
||||||
|
this.edges = sites.map((site) => ({
|
||||||
|
graph: site.graph,
|
||||||
|
entries: removeWhere(site.graph.edges, edgesOnPort(site, this.key)),
|
||||||
|
}));
|
||||||
|
this.values = [];
|
||||||
|
for (const site of sites) {
|
||||||
|
const target = site.asInput ? nodeAt(site) : undefined;
|
||||||
|
const value = target?.params[this.key];
|
||||||
|
if (!target || value === undefined) continue;
|
||||||
|
this.values.push({ site, value });
|
||||||
|
delete target.params[this.key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
if (!this.removed) return;
|
||||||
|
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||||
|
const ports = [...(node.ports ?? [])];
|
||||||
|
restoreAt(ports, [this.removed]);
|
||||||
|
node.ports = ports;
|
||||||
|
for (const { graph, entries } of this.edges) restoreAt(graph.edges, entries);
|
||||||
|
for (const { site, value } of this.values) {
|
||||||
|
const target = nodeAt(site);
|
||||||
|
if (target) target.params[this.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */
|
||||||
|
export class RenamePortCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly terminalType: string,
|
||||||
|
private readonly from: string,
|
||||||
|
private readonly to: string,
|
||||||
|
) {
|
||||||
|
this.label = `Rename ${from}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
this.rename(project, this.from, this.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
this.rename(project, this.to, this.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rename(project: Project, from: string, to: string): void {
|
||||||
|
if (from === to) return;
|
||||||
|
const sites = portSites(project, this.assetId, this.terminalType);
|
||||||
|
const node = requireTerminal(project, this.assetId, this.terminalType);
|
||||||
|
if (!isValidPortName(to)) throw new CommandError(INVALID_NAME);
|
||||||
|
const ports = node.ports ?? [];
|
||||||
|
if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`);
|
||||||
|
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
|
||||||
|
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||||||
|
for (const site of sites) {
|
||||||
|
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
|
||||||
|
if (site.asInput) edge.to.port = to;
|
||||||
|
else edge.from.port = to;
|
||||||
|
}
|
||||||
|
// An input keeps its typed-in value under the port key, so a rename has to carry it along
|
||||||
|
// or the value silently falls back to the default.
|
||||||
|
if (site.asInput) renameParam(site, from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
|
||||||
|
export function nextPortName(node: GraphNode, base: string): string {
|
||||||
|
const taken = new Set((node.ports ?? []).map((p) => p.key));
|
||||||
|
for (let i = 1; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
|
||||||
|
}
|
||||||
@@ -18,6 +18,24 @@ export interface GraphNode {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
params: PropertyBag;
|
params: PropertyBag;
|
||||||
|
/**
|
||||||
|
* Ports the node declares itself, on top of the fixed ones of its type. Only the behavior
|
||||||
|
* start/end nodes carry this – they are what gives a nested behavior its signature.
|
||||||
|
*/
|
||||||
|
ports?: DeclaredPort[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */
|
||||||
|
export interface DeclaredPort {
|
||||||
|
key: string;
|
||||||
|
kind: DeclaredPortKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object'] as const;
|
||||||
|
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
|
||||||
|
|
||||||
|
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
||||||
|
return kind === 'text' ? 'string' : kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge {
|
export interface GraphEdge {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||||||
|
import {
|
||||||
|
BEHAVIOR_END,
|
||||||
|
BEHAVIOR_START,
|
||||||
|
type BehaviorLookup,
|
||||||
|
NO_BEHAVIORS,
|
||||||
|
resolveDef,
|
||||||
|
terminalNode,
|
||||||
|
} from './nested';
|
||||||
import type { NodeContext } from './NodeContext';
|
import type { NodeContext } from './NodeContext';
|
||||||
import { hasExecInput, inlineValue, isEventNode, type RepeatResult, requireNodeDef } from './NodeRegistry';
|
import { type CallResult, hasExecInput, inlineValue, isEventNode, type RepeatResult } from './NodeRegistry';
|
||||||
import type { Value } from './values';
|
import type { Value } from './values';
|
||||||
|
|
||||||
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
|
/** How much work one pulse may do. A Repeat over a 10×20 board is 200 rounds, so this is generous. */
|
||||||
@@ -18,6 +26,17 @@ export interface TraceListener {
|
|||||||
onNode(nodeId: string): void;
|
onNode(nodeId: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One graph's pulse state. A nested behavior runs in its own – node ids are unique per graph only. */
|
||||||
|
interface Frame {
|
||||||
|
graph: Graph;
|
||||||
|
outputCache: Map<string, Record<string, Value>>;
|
||||||
|
dataCache: Map<string, Record<string, Value>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyFrame(graph: Graph): Frame {
|
||||||
|
return { graph, outputCache: new Map(), dataCache: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows
|
* Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows
|
||||||
* exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and
|
* exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and
|
||||||
@@ -26,13 +45,19 @@ export interface TraceListener {
|
|||||||
export class GraphInterpreter {
|
export class GraphInterpreter {
|
||||||
/** Per-node state that persists across pulses (timer accumulators etc.). */
|
/** Per-node state that persists across pulses (timer accumulators etc.). */
|
||||||
readonly nodeState = new Map<string, Record<string, number>>();
|
readonly nodeState = new Map<string, Record<string, number>>();
|
||||||
private outputCache = new Map<string, Record<string, Value>>();
|
/** Assets run as a node so far – the graph editor highlights them from this instance's trace. */
|
||||||
/** Kept apart from `outputCache` because a loop round has to forget exactly these again. */
|
readonly calledBehaviors = new Set<string>();
|
||||||
private dataCache = new Map<string, Record<string, Value>>();
|
private frame: Frame;
|
||||||
|
private callStack: string[] = [];
|
||||||
private steps = 0;
|
private steps = 0;
|
||||||
trace: TraceListener | null = null;
|
trace: TraceListener | null = null;
|
||||||
|
|
||||||
constructor(readonly graph: Graph) {}
|
constructor(
|
||||||
|
readonly graph: Graph,
|
||||||
|
private readonly behaviors: BehaviorLookup = NO_BEHAVIORS,
|
||||||
|
) {
|
||||||
|
this.frame = emptyFrame(graph);
|
||||||
|
}
|
||||||
|
|
||||||
eventNodes(type: string): GraphNode[] {
|
eventNodes(type: string): GraphNode[] {
|
||||||
return this.graph.nodes.filter((n) => n.type === type);
|
return this.graph.nodes.filter((n) => n.type === type);
|
||||||
@@ -48,10 +73,10 @@ export class GraphInterpreter {
|
|||||||
let fired = 0;
|
let fired = 0;
|
||||||
for (const node of this.eventNodes(type)) {
|
for (const node of this.eventNodes(type)) {
|
||||||
if (filter && !filter(node)) continue;
|
if (filter && !filter(node)) continue;
|
||||||
this.outputCache = new Map();
|
this.frame = emptyFrame(this.graph);
|
||||||
this.dataCache = new Map();
|
this.callStack = [];
|
||||||
this.steps = 0;
|
this.steps = 0;
|
||||||
this.outputCache.set(node.id, { ...outputs });
|
this.frame.outputCache.set(node.id, { ...outputs });
|
||||||
this.trace?.onNode(node.id);
|
this.trace?.onNode(node.id);
|
||||||
this.follow(node, 'exec', ctx, 0);
|
this.follow(node, 'exec', ctx, 0);
|
||||||
fired++;
|
fired++;
|
||||||
@@ -60,7 +85,7 @@ export class GraphInterpreter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void {
|
private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void {
|
||||||
for (const edge of edgesOutOf(this.graph, node.id, port)) {
|
for (const edge of edgesOutOf(this.frame.graph, node.id, port)) {
|
||||||
const target = this.node(edge.to.node);
|
const target = this.node(edge.to.node);
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
this.trace?.onEdge(edge.id);
|
this.trace?.onEdge(edge.id);
|
||||||
@@ -73,41 +98,68 @@ export class GraphInterpreter {
|
|||||||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||||||
if (depth > MAX_DEPTH)
|
if (depth > MAX_DEPTH)
|
||||||
throw new Error('Behavior graph nested too deeply (a flow feeding back on itself?)');
|
throw new Error('Behavior graph nested too deeply (a flow feeding back on itself?)');
|
||||||
const def = requireNodeDef(node.type);
|
const def = this.def(node);
|
||||||
if (!def.run) return;
|
if (!def?.run) return;
|
||||||
const inputs = this.collectInputs(node, ctx);
|
const inputs = this.collectInputs(node, ctx);
|
||||||
const result = def.run(ctx, inputs, node.params);
|
const result = def.run(ctx, inputs, node.params);
|
||||||
this.outputCache.set(node.id, result.outputs ?? {});
|
const returned = result.call ? this.call(result.call, ctx, depth) : {};
|
||||||
|
this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
|
||||||
this.trace?.onNode(node.id);
|
this.trace?.onNode(node.id);
|
||||||
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
|
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
|
||||||
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||||||
for (const port of next) this.follow(node, port, ctx, depth);
|
for (const port of next) this.follow(node, port, ctx, depth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs another behavior from its start node to its end node and returns what the end node was
|
||||||
|
* handed. The nested graph gets its own frame and a fresh copy of its variable defaults, so it is
|
||||||
|
* a call and not an inlining: nothing of it survives the return but the values it hands back.
|
||||||
|
*/
|
||||||
|
private call(call: CallResult, ctx: NodeContext, depth: number): Record<string, Value> {
|
||||||
|
const program = this.behaviors(call.behaviorId);
|
||||||
|
if (!program) return {};
|
||||||
|
if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`);
|
||||||
|
const start = terminalNode(program.graph, BEHAVIOR_START);
|
||||||
|
if (!start) return {};
|
||||||
|
const outer = this.frame;
|
||||||
|
this.frame = emptyFrame(program.graph);
|
||||||
|
this.frame.outputCache.set(start.id, { ...call.inputs });
|
||||||
|
this.callStack.push(call.behaviorId);
|
||||||
|
this.calledBehaviors.add(call.behaviorId);
|
||||||
|
try {
|
||||||
|
this.trace?.onNode(start.id);
|
||||||
|
this.follow(start, 'exec', { ...ctx, variables: { ...program.defaults } }, depth + 1);
|
||||||
|
const end = terminalNode(program.graph, BEHAVIOR_END);
|
||||||
|
return (end && this.frame.outputCache.get(end.id)) ?? {};
|
||||||
|
} finally {
|
||||||
|
this.callStack.pop();
|
||||||
|
this.frame = outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One round per count, the loop node's `index` output carrying the round number. Data nodes are
|
* One round per count, the loop node's `index` output carrying the round number. Data nodes are
|
||||||
* cached for the whole pulse, so the round has to drop that cache first – otherwise every round
|
* cached for the whole pulse, so the round has to drop that cache first – otherwise every round
|
||||||
* would read the values the first one computed.
|
* would read the values the first one computed.
|
||||||
*/
|
*/
|
||||||
private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void {
|
private loop(node: GraphNode, repeat: RepeatResult, ctx: NodeContext, depth: number): void {
|
||||||
const outputs = this.outputCache.get(node.id) ?? {};
|
const outputs = this.frame.outputCache.get(node.id) ?? {};
|
||||||
for (let round = 0; round < repeat.times; round++) {
|
for (let round = 0; round < repeat.times; round++) {
|
||||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||||||
this.dataCache.clear();
|
this.frame.dataCache.clear();
|
||||||
this.outputCache.set(node.id, { ...outputs, [repeat.index]: round });
|
this.frame.outputCache.set(node.id, { ...outputs, [repeat.index]: round });
|
||||||
this.follow(node, repeat.port, ctx, depth);
|
this.follow(node, repeat.port, ctx, depth);
|
||||||
}
|
}
|
||||||
this.dataCache.clear();
|
this.frame.dataCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
||||||
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
|
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
|
||||||
const def = requireNodeDef(node.type);
|
|
||||||
const inputs: Record<string, Value> = {};
|
const inputs: Record<string, Value> = {};
|
||||||
for (const port of def.inputs) {
|
for (const port of this.def(node)?.inputs ?? []) {
|
||||||
if (port.type === 'exec') continue;
|
if (port.type === 'exec') continue;
|
||||||
const edge = edgesInto(this.graph, node.id, port.key)[0];
|
const edge = edgesInto(this.frame.graph, node.id, port.key)[0];
|
||||||
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
|
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
|
||||||
else inputs[port.key] = inlineValue(node, port);
|
else inputs[port.key] = inlineValue(node, port);
|
||||||
}
|
}
|
||||||
@@ -116,24 +168,29 @@ export class GraphInterpreter {
|
|||||||
|
|
||||||
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
|
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
|
||||||
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
|
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
|
||||||
const cached = this.outputCache.get(nodeId) ?? this.dataCache.get(nodeId);
|
const cached = this.frame.outputCache.get(nodeId) ?? this.frame.dataCache.get(nodeId);
|
||||||
if (cached) return cached[port] ?? null;
|
if (cached) return cached[port] ?? null;
|
||||||
const source = this.node(nodeId);
|
const source = this.node(nodeId);
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
const def = requireNodeDef(source.type);
|
const def = this.def(source);
|
||||||
|
if (!def) return null;
|
||||||
// Exec nodes that have not run in this pulse have no value yet.
|
// Exec nodes that have not run in this pulse have no value yet.
|
||||||
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
|
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
|
||||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||||
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
|
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
|
||||||
// Mark before evaluating so a data cycle reads null instead of recursing forever.
|
// Mark before evaluating so a data cycle reads null instead of recursing forever.
|
||||||
this.dataCache.set(nodeId, {});
|
this.frame.dataCache.set(nodeId, {});
|
||||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
|
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
|
||||||
this.dataCache.set(nodeId, outputs);
|
this.frame.dataCache.set(nodeId, outputs);
|
||||||
this.trace?.onNode(nodeId);
|
this.trace?.onNode(nodeId);
|
||||||
return outputs[port] ?? null;
|
return outputs[port] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private def(node: GraphNode) {
|
||||||
|
return resolveDef(node, this.behaviors);
|
||||||
|
}
|
||||||
|
|
||||||
private node(id: string): GraphNode | undefined {
|
private node(id: string): GraphNode | undefined {
|
||||||
return this.graph.nodes.find((n) => n.id === id);
|
return this.frame.graph.nodes.find((n) => n.id === id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,12 @@ export interface RepeatResult {
|
|||||||
times: number;
|
times: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How a node asks for another behavior's graph to be run in place, `inputs` seeding its start node. */
|
||||||
|
export interface CallResult {
|
||||||
|
behaviorId: string;
|
||||||
|
inputs: Record<string, Value>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunResult {
|
export interface RunResult {
|
||||||
/** Cached for the rest of the pulse. */
|
/** Cached for the rest of the pulse. */
|
||||||
outputs?: Record<string, Value>;
|
outputs?: Record<string, Value>;
|
||||||
@@ -31,6 +37,8 @@ export interface RunResult {
|
|||||||
next?: string | string[];
|
next?: string | string[];
|
||||||
/** Runs `port` that many times before continuing along `next`. */
|
/** Runs `port` that many times before continuing along `next`. */
|
||||||
repeat?: RepeatResult;
|
repeat?: RepeatResult;
|
||||||
|
/** Runs another behavior before continuing along `next`; its results join `outputs`. */
|
||||||
|
call?: CallResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph';
|
import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph';
|
||||||
import { type NodeDef, nodeDef } from './NodeRegistry';
|
import { type BehaviorLookup, resolveDef } from './nested';
|
||||||
|
import type { NodeDef } from './NodeRegistry';
|
||||||
|
|
||||||
/** One end of an edge being dragged: a port on a node, seen from its own side. */
|
/** One end of an edge being dragged: a port on a node, seen from its own side. */
|
||||||
export interface PortSide {
|
export interface PortSide {
|
||||||
@@ -8,18 +9,18 @@ export interface PortSide {
|
|||||||
isOutput: boolean;
|
isOutput: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function portTypeOf(graph: Graph, side: PortSide): PortType | undefined {
|
export function portTypeOf(graph: Graph, side: PortSide, lookup: BehaviorLookup): PortType | undefined {
|
||||||
const node = findNode(graph, side.nodeId);
|
const node = findNode(graph, side.nodeId);
|
||||||
const def = node && nodeDef(node.type);
|
const def = node && resolveDef(node, lookup);
|
||||||
const list = side.isOutput ? def?.outputs : def?.inputs;
|
const list = side.isOutput ? def?.outputs : def?.inputs;
|
||||||
return list?.find((p) => p.key === side.port)?.type;
|
return list?.find((p) => p.key === side.port)?.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */
|
/** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */
|
||||||
export function canConnect(graph: Graph, from: PortSide, to: PortSide): boolean {
|
export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: BehaviorLookup): boolean {
|
||||||
if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false;
|
if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false;
|
||||||
const fromType = portTypeOf(graph, from);
|
const fromType = portTypeOf(graph, from, lookup);
|
||||||
const toType = portTypeOf(graph, to);
|
const toType = portTypeOf(graph, to, lookup);
|
||||||
if (fromType === undefined || toType === undefined) return false;
|
if (fromType === undefined || toType === undefined) return false;
|
||||||
const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType];
|
const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType];
|
||||||
return portsCompatible(out, inp) && !linked(graph, from, to);
|
return portsCompatible(out, inp) && !linked(graph, from, to);
|
||||||
@@ -30,11 +31,16 @@ export function canConnect(graph: Graph, from: PortSide, to: PortSide): boolean
|
|||||||
* the first free compatible one, or – when none is free – the last compatible one, whose edge is
|
* the first free compatible one, or – when none is free – the last compatible one, whose edge is
|
||||||
* then replaced. Returns null when the node offers nothing to connect to.
|
* then replaced. Returns null when the node offers nothing to connect to.
|
||||||
*/
|
*/
|
||||||
export function dropTargetPort(graph: Graph, from: PortSide, nodeId: string): PortSide | null {
|
export function dropTargetPort(
|
||||||
|
graph: Graph,
|
||||||
|
from: PortSide,
|
||||||
|
nodeId: string,
|
||||||
|
lookup: BehaviorLookup,
|
||||||
|
): PortSide | null {
|
||||||
if (nodeId === from.nodeId) return null;
|
if (nodeId === from.nodeId) return null;
|
||||||
const node = findNode(graph, nodeId);
|
const node = findNode(graph, nodeId);
|
||||||
const def = node && nodeDef(node.type);
|
const def = node && resolveDef(node, lookup);
|
||||||
const fromType = portTypeOf(graph, from);
|
const fromType = portTypeOf(graph, from, lookup);
|
||||||
if (!def || fromType === undefined) return null;
|
if (!def || fromType === undefined) return null;
|
||||||
const candidates = compatiblePorts(def, fromType, from.isOutput)
|
const candidates = compatiblePorts(def, fromType, from.isOutput)
|
||||||
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type }))
|
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type }))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import './nodes';
|
|||||||
|
|
||||||
export * from './dropTarget';
|
export * from './dropTarget';
|
||||||
export * from './Graph';
|
export * from './Graph';
|
||||||
|
export * from './nested';
|
||||||
export * from './NodeContext';
|
export * from './NodeContext';
|
||||||
export * from './NodeRegistry';
|
export * from './NodeRegistry';
|
||||||
export * from './values';
|
export * from './values';
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { PropertyBag } from '../model/Properties';
|
||||||
|
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
||||||
|
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||||
|
|
||||||
|
export const BEHAVIOR_START = 'BehaviorStart';
|
||||||
|
export const BEHAVIOR_END = 'BehaviorEnd';
|
||||||
|
export const RUN_BEHAVIOR = 'RunBehavior';
|
||||||
|
|
||||||
|
export const BEHAVIOR_PARAM = 'behavior';
|
||||||
|
|
||||||
|
/** What running another behavior's graph in place needs: its nodes, its variable defaults, its name. */
|
||||||
|
export interface BehaviorProgram {
|
||||||
|
name: string;
|
||||||
|
graph: Graph;
|
||||||
|
defaults: PropertyBag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BehaviorLookup = (assetId: string) => BehaviorProgram | null;
|
||||||
|
|
||||||
|
export const NO_BEHAVIORS: BehaviorLookup = () => null;
|
||||||
|
|
||||||
|
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
|
||||||
|
export function isValidPortName(name: string): boolean {
|
||||||
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single start (or end) node of a graph; a second one is ignored. */
|
||||||
|
export function terminalNode(graph: Graph, type: string): GraphNode | undefined {
|
||||||
|
return graph.nodes.find((n) => n.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalType(type: string): boolean {
|
||||||
|
return type === BEHAVIOR_START || type === BEHAVIOR_END;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalNode(node: GraphNode): boolean {
|
||||||
|
return isTerminalType(node.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portsOf(port: DeclaredPort): PortDef {
|
||||||
|
return { key: port.key, label: port.key, type: declaredPortType(port.kind) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredOf(graph: Graph | undefined, type: string): PortDef[] {
|
||||||
|
const node = graph && terminalNode(graph, type);
|
||||||
|
return (node?.ports ?? []).map(portsOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The node definition as it applies to *this* node. Identical to the registered one for every fixed
|
||||||
|
* type; the three nested-behavior types grow ports from a declaration instead – the start and end
|
||||||
|
* node from their own, the call node from those of the behavior it names.
|
||||||
|
*/
|
||||||
|
export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | undefined {
|
||||||
|
const def = nodeDef(node.type);
|
||||||
|
if (!def) return undefined;
|
||||||
|
const own = (node.ports ?? []).map(portsOf);
|
||||||
|
if (node.type === BEHAVIOR_START) return { ...def, outputs: [...def.outputs, ...own] };
|
||||||
|
if (node.type === BEHAVIOR_END) return { ...def, inputs: [...def.inputs, ...own] };
|
||||||
|
if (node.type !== RUN_BEHAVIOR) return def;
|
||||||
|
const program = lookup(String(node.params[BEHAVIOR_PARAM] ?? ''));
|
||||||
|
return {
|
||||||
|
...def,
|
||||||
|
// The graph reads as what it does, not as a row of identical "Run behavior" boxes.
|
||||||
|
label: program ? program.name : def.label,
|
||||||
|
inputs: [...def.inputs, ...declaredOf(program?.graph, BEHAVIOR_START)],
|
||||||
|
outputs: [...def.outputs, ...declaredOf(program?.graph, BEHAVIOR_END)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that changes a node's ports, as one string. Views and the inspector rebuild when it
|
||||||
|
* changes – a port list that is derived rather than fixed has no other way of being noticed.
|
||||||
|
*/
|
||||||
|
export function portSignature(node: GraphNode, lookup: BehaviorLookup): string {
|
||||||
|
const def = resolveDef(node, lookup);
|
||||||
|
if (!def) return 'missing';
|
||||||
|
const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(',');
|
||||||
|
return `${node.type}|${def.label}|${list(def.inputs)}|${list(def.outputs)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
import { BEHAVIOR_END } from '../../nested';
|
||||||
|
import { exec } from '../shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exit of a behavior used as a node: what arrives at its declared inputs leaves the calling
|
||||||
|
* node as output. Handing the inputs straight back out is what parks the result in the pulse
|
||||||
|
* cache, where the caller reads it once the flow has returned.
|
||||||
|
*/
|
||||||
|
export const BehaviorEnd: NodeDef = {
|
||||||
|
type: BEHAVIOR_END,
|
||||||
|
label: 'End',
|
||||||
|
category: 'flow',
|
||||||
|
description:
|
||||||
|
'Ends this behavior and hands its results back to the graph that ran it. Every output declared ' +
|
||||||
|
'here becomes an output port on that node.',
|
||||||
|
inputs: [exec()],
|
||||||
|
outputs: [],
|
||||||
|
params: [],
|
||||||
|
run: (_ctx, inputs) => ({ outputs: inputs }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
import { BEHAVIOR_START } from '../../nested';
|
||||||
|
import { exec } from '../shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry point of a behavior used as a node. Its declared outputs are the inputs the calling
|
||||||
|
* graph passes in; the interpreter seeds them, so it has no `run` – like an event node.
|
||||||
|
*/
|
||||||
|
export const BehaviorStart: NodeDef = {
|
||||||
|
type: BEHAVIOR_START,
|
||||||
|
label: 'Start',
|
||||||
|
category: 'event',
|
||||||
|
description:
|
||||||
|
'Where this behavior begins when another graph runs it as a node. Every input declared here ' +
|
||||||
|
'becomes an input port on that node.',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
|
import { BEHAVIOR_PARAM, RUN_BEHAVIOR } from '../../nested';
|
||||||
|
import { exec } from '../shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs another behavior's graph in place, from its start node to its end node. The ports come from
|
||||||
|
* those two nodes (see `nested.ts`); the interpreter does the call, because only it can hold a
|
||||||
|
* second graph's pulse.
|
||||||
|
*/
|
||||||
|
export const RunBehavior: NodeDef = {
|
||||||
|
type: RUN_BEHAVIOR,
|
||||||
|
label: 'Run behavior',
|
||||||
|
category: 'flow',
|
||||||
|
description:
|
||||||
|
'Runs another behavior as a step of this one, passing values in and taking results out. The ' +
|
||||||
|
'behavior keeps no state between calls: its variables start at their defaults every time.',
|
||||||
|
inputs: [exec()],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }],
|
||||||
|
run: (_ctx, inputs, params) => ({
|
||||||
|
call: { behaviorId: String(params[BEHAVIOR_PARAM] ?? ''), inputs },
|
||||||
|
next: 'exec',
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -65,8 +65,11 @@ import { OnKeyUp } from './events/OnKeyUp';
|
|||||||
import { OnStart } from './events/OnStart';
|
import { OnStart } from './events/OnStart';
|
||||||
import { OnTimer } from './events/OnTimer';
|
import { OnTimer } from './events/OnTimer';
|
||||||
import { OnUpdate } from './events/OnUpdate';
|
import { OnUpdate } from './events/OnUpdate';
|
||||||
|
import { BehaviorEnd } from './flow/BehaviorEnd';
|
||||||
|
import { BehaviorStart } from './flow/BehaviorStart';
|
||||||
import { Branch } from './flow/Branch';
|
import { Branch } from './flow/Branch';
|
||||||
import { Repeat } from './flow/Repeat';
|
import { Repeat } from './flow/Repeat';
|
||||||
|
import { RunBehavior } from './flow/RunBehavior';
|
||||||
import { Sequence } from './flow/Sequence';
|
import { Sequence } from './flow/Sequence';
|
||||||
import { GetVariable } from './variable/GetVariable';
|
import { GetVariable } from './variable/GetVariable';
|
||||||
import { SetVariable } from './variable/SetVariable';
|
import { SetVariable } from './variable/SetVariable';
|
||||||
@@ -87,10 +90,13 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
OnCollisionStay,
|
OnCollisionStay,
|
||||||
OnCollisionExit,
|
OnCollisionExit,
|
||||||
OnClick,
|
OnClick,
|
||||||
|
BehaviorStart,
|
||||||
// flow
|
// flow
|
||||||
Branch,
|
Branch,
|
||||||
Sequence,
|
Sequence,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
RunBehavior,
|
||||||
|
BehaviorEnd,
|
||||||
// variables
|
// variables
|
||||||
GetVariable,
|
GetVariable,
|
||||||
SetVariable,
|
SetVariable,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { BehaviorLookup } from '../graph/nested';
|
||||||
|
import { resolveAssetProps } from '../properties/resolve';
|
||||||
|
import { findAsset, type Project } from './Project';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a `Run behavior` node needs from the project. `defaults` are the asset's variable values;
|
||||||
|
* every call copies them, so a nested behavior never carries state from one call to the next.
|
||||||
|
*/
|
||||||
|
export function behaviorLookup(project: Project): BehaviorLookup {
|
||||||
|
return (assetId) => {
|
||||||
|
const asset = findAsset(project, assetId);
|
||||||
|
if (!asset || asset.type !== 'behavior') return null;
|
||||||
|
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
// Importing the graph package index is what registers the built-in nodes.
|
// Importing the graph package index is what registers the built-in nodes.
|
||||||
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
|
import {
|
||||||
|
createEmptyGraph,
|
||||||
|
DECLARED_PORT_KINDS,
|
||||||
|
type DeclaredPort,
|
||||||
|
type DeclaredPortKind,
|
||||||
|
type Graph,
|
||||||
|
type GraphEdge,
|
||||||
|
type GraphNode,
|
||||||
|
isValidPortName,
|
||||||
|
nodeDef,
|
||||||
|
} from '../graph';
|
||||||
import type { Asset, TextureFrame } from '../model/Asset';
|
import type { Asset, TextureFrame } from '../model/Asset';
|
||||||
import { isAssetType } from '../model/AssetTypes';
|
import { isAssetType } from '../model/AssetTypes';
|
||||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||||
@@ -166,6 +176,22 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
|||||||
return defs;
|
return defs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePorts(raw: unknown): DeclaredPort[] {
|
||||||
|
const ports: DeclaredPort[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of Array.isArray(raw) ? raw : []) {
|
||||||
|
const p = asRecord(item);
|
||||||
|
const key = typeof p.key === 'string' ? p.key : '';
|
||||||
|
if (!isValidPortName(key) || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind))
|
||||||
|
? (p.kind as DeclaredPortKind)
|
||||||
|
: 'number';
|
||||||
|
ports.push({ key, kind });
|
||||||
|
}
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGraph(raw: unknown): Graph {
|
function normalizeGraph(raw: unknown): Graph {
|
||||||
const g = asRecord(raw);
|
const g = asRecord(raw);
|
||||||
const graph = createEmptyGraph();
|
const graph = createEmptyGraph();
|
||||||
@@ -182,6 +208,8 @@ function normalizeGraph(raw: unknown): Graph {
|
|||||||
params[key] = value;
|
params[key] = value;
|
||||||
}
|
}
|
||||||
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
||||||
|
const ports = normalizePorts(n.ports);
|
||||||
|
if (ports.length > 0) node.ports = ports;
|
||||||
graph.nodes.push(node);
|
graph.nodes.push(node);
|
||||||
}
|
}
|
||||||
const edgeIds = new Set<string>();
|
const edgeIds = new Set<string>();
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||||||
|
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
|
||||||
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
||||||
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
||||||
canvasRect: () => graphEditor.canvas.canvasRect(),
|
canvasRect: () => graphEditor.canvas.canvasRect(),
|
||||||
|
|||||||
@@ -16,14 +16,15 @@ import {
|
|||||||
firstPortFor,
|
firstPortFor,
|
||||||
type GraphEdge,
|
type GraphEdge,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
type NodeDef,
|
type BehaviorLookup,
|
||||||
nodeDef,
|
|
||||||
type PortSide,
|
type PortSide,
|
||||||
type PortType,
|
type PortType,
|
||||||
portsCompatible,
|
portsCompatible,
|
||||||
portTypeOf,
|
portTypeOf,
|
||||||
|
resolveDef,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||||
|
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||||
import { findAsset } from '../../core/model/Project';
|
import { findAsset } from '../../core/model/Project';
|
||||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||||
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
||||||
@@ -31,6 +32,7 @@ import { removeNodes } from '../actions';
|
|||||||
import { type EditorState, nodeSelection } from '../state';
|
import { type EditorState, nodeSelection } from '../state';
|
||||||
import { type Box, boxesInBand, frameBoxes } from './framing';
|
import { type Box, boxesInBand, frameBoxes } from './framing';
|
||||||
import { NodePalette } from './NodePalette';
|
import { NodePalette } from './NodePalette';
|
||||||
|
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||||
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
||||||
|
|
||||||
const GRID_STEP = 24;
|
const GRID_STEP = 24;
|
||||||
@@ -140,8 +142,8 @@ export class GraphCanvas extends Widget {
|
|||||||
addNodeFrom(anchor: Widget): void {
|
addNodeFrom(anchor: Widget): void {
|
||||||
if (!this.assetId || this.state.mode !== 'edit') return;
|
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||||
const centre = this.screenToGraph(this.w / 2, this.h / 2);
|
const centre = this.screenToGraph(this.w / 2, this.h / 2);
|
||||||
new NodePalette((def: NodeDef) =>
|
new NodePalette(this.entries(), (entry) =>
|
||||||
this.addNode(def, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
|
this.addNode(entry, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
|
||||||
).openBelow(anchor);
|
).openBelow(anchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +153,10 @@ export class GraphCanvas extends Widget {
|
|||||||
|
|
||||||
// --- model binding ----------------------------------------------------------------------
|
// --- model binding ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private behaviors(): BehaviorLookup {
|
||||||
|
return behaviorLookup(this.state.project);
|
||||||
|
}
|
||||||
|
|
||||||
private asset(): BehaviorAsset | null {
|
private asset(): BehaviorAsset | null {
|
||||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||||
return asset && asset.type === 'behavior' ? asset : null;
|
return asset && asset.type === 'behavior' ? asset : null;
|
||||||
@@ -168,8 +174,9 @@ export class GraphCanvas extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const lookup = behaviorLookup(this.state.project);
|
||||||
for (const node of asset.graph.nodes) {
|
for (const node of asset.graph.nodes) {
|
||||||
const def = nodeDef(node.type);
|
const def = resolveDef(node, lookup);
|
||||||
if (!def) continue;
|
if (!def) continue;
|
||||||
seen.add(node.id);
|
seen.add(node.id);
|
||||||
let view = this.views.get(node.id);
|
let view = this.views.get(node.id);
|
||||||
@@ -180,7 +187,7 @@ export class GraphCanvas extends Widget {
|
|||||||
this.views.set(node.id, view);
|
this.views.set(node.id, view);
|
||||||
this.nodeLayer.addChild(view);
|
this.nodeLayer.addChild(view);
|
||||||
}
|
}
|
||||||
view.sync(node, asset.graph, asset, this.state.project);
|
view.sync(node, asset.graph, asset, this.state.project, def);
|
||||||
}
|
}
|
||||||
for (const [id, view] of this.views) {
|
for (const [id, view] of this.views) {
|
||||||
if (!seen.has(id)) {
|
if (!seen.has(id)) {
|
||||||
@@ -425,13 +432,14 @@ export class GraphCanvas extends Widget {
|
|||||||
): void {
|
): void {
|
||||||
const graph = this.asset()?.graph;
|
const graph = this.asset()?.graph;
|
||||||
const from = drag.from;
|
const from = drag.from;
|
||||||
const fromType = graph && portTypeOf(graph, from);
|
const fromType = graph && portTypeOf(graph, from, this.behaviors());
|
||||||
if (!graph || fromType === undefined) return;
|
if (!graph || fromType === undefined) return;
|
||||||
const local = this.toLocal({ x: globalX, y: globalY });
|
const local = this.toLocal({ x: globalX, y: globalY });
|
||||||
const at = this.screenToGraph(local.x, local.y);
|
const at = this.screenToGraph(local.x, local.y);
|
||||||
this.pendingDrop = drag;
|
this.pendingDrop = drag;
|
||||||
const palette = new NodePalette(
|
const palette = new NodePalette(
|
||||||
(def: NodeDef) => this.addConnectedNode(def, from, fromType, at),
|
this.entries(),
|
||||||
|
(entry) => this.addConnectedNode(entry, from, fromType, at),
|
||||||
(def) => defAcceptsFrom(def, fromType, from.isOutput),
|
(def) => defAcceptsFrom(def, fromType, from.isOutput),
|
||||||
);
|
);
|
||||||
palette.onClose = () => {
|
palette.onClose = () => {
|
||||||
@@ -449,10 +457,11 @@ export class GraphCanvas extends Widget {
|
|||||||
const graph = this.asset()?.graph;
|
const graph = this.asset()?.graph;
|
||||||
const from = this.connecting?.from;
|
const from = this.connecting?.from;
|
||||||
if (!graph || !from) return null;
|
if (!graph || !from) return null;
|
||||||
|
const lookup = this.behaviors();
|
||||||
const hit = this.portUnder(globalX, globalY);
|
const hit = this.portUnder(globalX, globalY);
|
||||||
if (hit) return canConnect(graph, from, hit) ? hit : null;
|
if (hit) return canConnect(graph, from, hit, lookup) ? hit : null;
|
||||||
const node = this.nodeUnder(globalX, globalY);
|
const node = this.nodeUnder(globalX, globalY);
|
||||||
return node ? dropTargetPort(graph, from, node) : null;
|
return node ? dropTargetPort(graph, from, node, lookup) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private portUnder(globalX: number, globalY: number): PortSide | null {
|
private portUnder(globalX: number, globalY: number): PortSide | null {
|
||||||
@@ -483,25 +492,42 @@ export class GraphCanvas extends Widget {
|
|||||||
// --- menus -------------------------------------------------------------------------------
|
// --- menus -------------------------------------------------------------------------------
|
||||||
|
|
||||||
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
||||||
new NodePalette((def: NodeDef) => this.addNode(def, at)).open(globalX, globalY);
|
new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addNode(def: NodeDef, at: { x: number; y: number }): void {
|
private entries(): PaletteEntry[] {
|
||||||
|
const asset = this.asset();
|
||||||
|
return asset ? paletteEntries(this.state.project, asset) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
|
||||||
if (!this.assetId) return;
|
if (!this.assetId) return;
|
||||||
const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
|
const command = new AddNodeCommand(
|
||||||
|
this.assetId,
|
||||||
|
entry.def.type,
|
||||||
|
Math.round(at.x),
|
||||||
|
Math.round(at.y),
|
||||||
|
entry.params,
|
||||||
|
);
|
||||||
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id]));
|
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
|
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
|
||||||
private addConnectedNode(
|
private addConnectedNode(
|
||||||
def: NodeDef,
|
entry: PaletteEntry,
|
||||||
from: PortSide,
|
from: PortSide,
|
||||||
fromType: PortType,
|
fromType: PortType,
|
||||||
at: { x: number; y: number },
|
at: { x: number; y: number },
|
||||||
): void {
|
): void {
|
||||||
if (!this.assetId) return;
|
if (!this.assetId) return;
|
||||||
const add = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
|
const add = new AddNodeCommand(
|
||||||
const port = firstPortFor(def, fromType, from.isOutput);
|
this.assetId,
|
||||||
|
entry.def.type,
|
||||||
|
Math.round(at.x),
|
||||||
|
Math.round(at.y),
|
||||||
|
entry.params,
|
||||||
|
);
|
||||||
|
const port = firstPortFor(entry.def, fromType, from.isOutput);
|
||||||
const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput };
|
const to = { nodeId: add.node.id, port: port ?? '', isOutput: !from.isOutput };
|
||||||
const [out, inp] = from.isOutput ? [from, to] : [to, from];
|
const [out, inp] = from.isOutput ? [from, to] : [to, from];
|
||||||
const commands: Command[] = [add];
|
const commands: Command[] = [add];
|
||||||
@@ -712,6 +738,14 @@ export class GraphCanvas extends Widget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title a node is drawn with. Worth reaching for from outside because a nested-behavior node
|
||||||
|
* takes its title from the behavior it runs – "which node is this" is otherwise only a guess.
|
||||||
|
*/
|
||||||
|
nodeLabel(nodeId: string): string | null {
|
||||||
|
return this.views.get(nodeId)?.def.label ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Global screen position of a node's title bar centre. */
|
/** Global screen position of a node's title bar centre. */
|
||||||
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
||||||
const view = this.views.get(nodeId);
|
const view = this.views.get(nodeId);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { allNodeDefs, type NodeCategory, type NodeDef } from '../../core/graph';
|
import type { NodeCategory, NodeDef } from '../../core/graph';
|
||||||
import { ListView, Popup, ScrollContainer, TextField, Theme, ui } from '../../ui';
|
import { ListView, Popup, ScrollContainer, TextField, Theme, ui } from '../../ui';
|
||||||
|
import type { PaletteEntry } from './paletteEntries';
|
||||||
|
|
||||||
const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action'];
|
const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action'];
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export class NodePalette extends Popup {
|
|||||||
private readonly search: TextField;
|
private readonly search: TextField;
|
||||||
private readonly list = new ListView();
|
private readonly list = new ListView();
|
||||||
private readonly scroll = new ScrollContainer();
|
private readonly scroll = new ScrollContainer();
|
||||||
private filtered: NodeDef[] = [];
|
private filtered: PaletteEntry[] = [];
|
||||||
/** Guards against a second pick per opening (e.g. Enter and a row click racing). */
|
/** Guards against a second pick per opening (e.g. Enter and a row click racing). */
|
||||||
private picked = false;
|
private picked = false;
|
||||||
|
|
||||||
@@ -17,7 +18,8 @@ export class NodePalette extends Popup {
|
|||||||
* could actually connect to.
|
* could actually connect to.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly onPick: (def: NodeDef) => void,
|
private readonly entries: PaletteEntry[],
|
||||||
|
private readonly onPick: (entry: PaletteEntry) => void,
|
||||||
private readonly accepts: (def: NodeDef) => boolean = () => true,
|
private readonly accepts: (def: NodeDef) => boolean = () => true,
|
||||||
) {
|
) {
|
||||||
super({ padding: 4, gap: 4 });
|
super({ padding: 4, gap: 4 });
|
||||||
@@ -39,8 +41,8 @@ export class NodePalette extends Popup {
|
|||||||
this.list.rowHeight = Theme.rowHeight - 2;
|
this.list.rowHeight = Theme.rowHeight - 2;
|
||||||
this.list.emptyText = 'No matching node';
|
this.list.emptyText = 'No matching node';
|
||||||
this.list.onSelect = (id) => {
|
this.list.onSelect = (id) => {
|
||||||
const def = this.filtered.find((d) => d.type === id);
|
const entry = this.filtered.find((e) => e.id === id);
|
||||||
if (def) this.pick(def);
|
if (entry) this.pick(entry);
|
||||||
};
|
};
|
||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
@@ -53,28 +55,28 @@ export class NodePalette extends Popup {
|
|||||||
|
|
||||||
private refresh(): void {
|
private refresh(): void {
|
||||||
const query = this.search.value.trim().toLowerCase();
|
const query = this.search.value.trim().toLowerCase();
|
||||||
this.filtered = allNodeDefs()
|
this.filtered = this.entries
|
||||||
.filter(this.accepts)
|
.filter((e) => this.accepts(e.def))
|
||||||
.filter(
|
.filter(
|
||||||
(d) =>
|
({ def }) =>
|
||||||
!query ||
|
!query ||
|
||||||
d.label.toLowerCase().includes(query) ||
|
def.label.toLowerCase().includes(query) ||
|
||||||
d.type.toLowerCase().includes(query) ||
|
def.type.toLowerCase().includes(query) ||
|
||||||
d.description.toLowerCase().includes(query),
|
def.description.toLowerCase().includes(query),
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) ||
|
CATEGORY_ORDER.indexOf(a.def.category) - CATEGORY_ORDER.indexOf(b.def.category) ||
|
||||||
a.label.localeCompare(b.label),
|
a.def.label.localeCompare(b.def.label),
|
||||||
);
|
);
|
||||||
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
|
this.list.setItems(this.filtered.map((e) => ({ id: e.id, label: e.def.label, hint: e.hint })));
|
||||||
this.scroll.relayoutContent();
|
this.scroll.relayoutContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private pick(def: NodeDef): void {
|
private pick(entry: PaletteEntry): void {
|
||||||
if (this.picked) return;
|
if (this.picked) return;
|
||||||
this.picked = true;
|
this.picked = true;
|
||||||
this.close();
|
this.close();
|
||||||
this.onPick(def);
|
this.onPick(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,14 +81,21 @@ export class NodeView extends Widget {
|
|||||||
/** Data inputs that currently have a connection (their inline value is hidden). */
|
/** Data inputs that currently have a connection (their inline value is hidden). */
|
||||||
private connected = new Set<string>();
|
private connected = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not fixed at construction: a nested-behavior node's ports come from another graph, so the
|
||||||
|
* definition that applies to this node can change without the node itself changing.
|
||||||
|
*/
|
||||||
|
def: NodeDef;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly node: GraphNode,
|
readonly node: GraphNode,
|
||||||
readonly def: NodeDef,
|
def: NodeDef,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
this.def = def;
|
||||||
this.cursor = 'grab';
|
this.cursor = 'grab';
|
||||||
this.addChild(this.box, this.ports);
|
this.addChild(this.box, this.ports);
|
||||||
this.sync(node, { nodes: [node], edges: [] }, null, null);
|
this.sync(node, { nodes: [node], edges: [] }, null, null, def);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
|
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
|
||||||
@@ -98,10 +105,23 @@ export class NodeView extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `project` resolves asset references (Spawn, Is asset). */
|
/** `project` resolves asset references (Spawn, Is asset). */
|
||||||
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
|
sync(
|
||||||
this.paramLines = this.def.params.map(
|
node: GraphNode,
|
||||||
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
|
graph: Graph,
|
||||||
);
|
asset: BehaviorAsset | null,
|
||||||
|
project: Project | null,
|
||||||
|
def: NodeDef,
|
||||||
|
): void {
|
||||||
|
this.def = def;
|
||||||
|
this.paramLines = this.def.params
|
||||||
|
.map((p) => ({
|
||||||
|
label: p.label,
|
||||||
|
value: formatParam(p, node.params[p.key] ?? p.default, asset, project),
|
||||||
|
}))
|
||||||
|
// A setting the title already states is not worth a row: a nested-behavior node is titled
|
||||||
|
// with the behavior it runs, so "Behavior: Double" under "Double" says nothing new.
|
||||||
|
.filter((p) => p.value !== this.def.label)
|
||||||
|
.map((p) => `${p.label}: ${p.value}`);
|
||||||
this.connected = new Set(
|
this.connected = new Set(
|
||||||
inlineInputs(this.def)
|
inlineInputs(this.def)
|
||||||
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
|
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
allNodeDefs,
|
||||||
|
BEHAVIOR_PARAM,
|
||||||
|
isTerminalType,
|
||||||
|
type NodeDef,
|
||||||
|
resolveDef,
|
||||||
|
RUN_BEHAVIOR,
|
||||||
|
terminalNode,
|
||||||
|
} from '../../core/graph';
|
||||||
|
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||||
|
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||||
|
import type { PropertyBag } from '../../core/model/Properties';
|
||||||
|
import type { Project } from '../../core/model/Project';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the palette. `def` is the definition the node would actually have once placed, so the
|
||||||
|
* drop filter and the port it lands on are right for a behavior entry too.
|
||||||
|
*/
|
||||||
|
export interface PaletteEntry {
|
||||||
|
id: string;
|
||||||
|
hint: string;
|
||||||
|
def: NodeDef;
|
||||||
|
params: PropertyBag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The node types offerable in `asset`'s graph. Every other behavior appears under its own name
|
||||||
|
* instead of behind a generic "Run behavior"; the two terminals drop out once the graph has them.
|
||||||
|
*/
|
||||||
|
export function paletteEntries(project: Project, asset: BehaviorAsset): PaletteEntry[] {
|
||||||
|
const entries: PaletteEntry[] = [];
|
||||||
|
for (const def of allNodeDefs()) {
|
||||||
|
if (def.type === RUN_BEHAVIOR) continue;
|
||||||
|
if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue;
|
||||||
|
entries.push({ id: def.type, hint: def.category, def, params: {} });
|
||||||
|
}
|
||||||
|
const lookup = behaviorLookup(project);
|
||||||
|
for (const other of project.assets) {
|
||||||
|
// A behavior running itself is an endless loop, so it is not on offer.
|
||||||
|
if (other.type !== 'behavior' || other.id === asset.id) continue;
|
||||||
|
const params = { [BEHAVIOR_PARAM]: other.id };
|
||||||
|
const def = resolveDef({ id: '', type: RUN_BEHAVIOR, x: 0, y: 0, params }, lookup);
|
||||||
|
if (def) entries.push({ id: `${RUN_BEHAVIOR}:${other.id}`, hint: 'behavior', def, params });
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -1,26 +1,40 @@
|
|||||||
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||||
import { edgesInto, type GraphNode, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
|
import {
|
||||||
|
edgesInto,
|
||||||
|
type GraphNode,
|
||||||
|
inlineInputDef,
|
||||||
|
inlineInputs,
|
||||||
|
isTerminalNode,
|
||||||
|
portSignature,
|
||||||
|
resolveDef,
|
||||||
|
} from '../../core/graph';
|
||||||
import type { Asset } from '../../core/model/Asset';
|
import type { Asset } from '../../core/model/Asset';
|
||||||
|
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||||
import { findAsset, type Project } from '../../core/model/Project';
|
import { findAsset, type Project } from '../../core/model/Project';
|
||||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||||
import { Button, Label } from '../../ui';
|
import { Button, Label } from '../../ui';
|
||||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||||
|
import { buildPortSection } from './portSection';
|
||||||
import { previewingHandlers } from './previewHandlers';
|
import { previewingHandlers } from './previewHandlers';
|
||||||
|
|
||||||
/** Structure-defining facts of a node selection: type, connected inputs, declared variables. */
|
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
|
||||||
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
|
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
|
||||||
const asset = findAsset(project, assetId);
|
const asset = findAsset(project, assetId);
|
||||||
if (!asset || asset.type !== 'behavior') return 'missing';
|
if (!asset || asset.type !== 'behavior') return 'missing';
|
||||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||||
if (!node) return 'missing';
|
if (!node) return 'missing';
|
||||||
const def = nodeDef(node.type);
|
const lookup = behaviorLookup(project);
|
||||||
|
const def = resolveDef(node, lookup);
|
||||||
const connected = def
|
const connected = def
|
||||||
? inlineInputs(def)
|
? inlineInputs(def)
|
||||||
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
|
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
|
||||||
.map((p) => p.key)
|
.map((p) => p.key)
|
||||||
: [];
|
: [];
|
||||||
return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
|
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}`).join(',');
|
||||||
|
return `${portSignature(node, lookup)}|${declared}|${connected.join(',')}|${asset.variables
|
||||||
|
.map((v) => v.key)
|
||||||
|
.join(',')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Description, settings (params) and the inline values of unconnected data inputs. */
|
/** Description, settings (params) and the inline values of unconnected data inputs. */
|
||||||
@@ -28,12 +42,13 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
|||||||
const asset = findAsset(host.state.project, assetId);
|
const asset = findAsset(host.state.project, assetId);
|
||||||
if (!asset || asset.type !== 'behavior') return;
|
if (!asset || asset.type !== 'behavior') return;
|
||||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||||
const def = node && nodeDef(node.type);
|
const def = node && resolveDef(node, behaviorLookup(host.state.project));
|
||||||
if (!node || !def) return;
|
if (!node || !def) return;
|
||||||
|
|
||||||
addTitle(host, `${def.category.toUpperCase()} NODE`);
|
addTitle(host, `${def.category.toUpperCase()} NODE`);
|
||||||
host.body.add(new Label(def.label, { style: 'bodyBold' }));
|
host.body.add(new Label(def.label, { style: 'bodyBold' }));
|
||||||
addNote(host, def.description);
|
addNote(host, def.description);
|
||||||
|
if (isTerminalNode(node)) buildPortSection(host, assetId, node);
|
||||||
|
|
||||||
if (def.params.length > 0) addTitle(host, 'SETTINGS');
|
if (def.params.length > 0) addTitle(host, 'SETTINGS');
|
||||||
for (const param of def.params) {
|
for (const param of def.params) {
|
||||||
@@ -74,14 +89,15 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
|||||||
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
|
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
|
||||||
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
|
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
|
||||||
const node = currentNode(host.state.project, assetId, nodeId);
|
const node = currentNode(host.state.project, assetId, nodeId);
|
||||||
const def = node && nodeDef(node.type);
|
const def = node && resolveDef(node, behaviorLookup(host.state.project));
|
||||||
if (!node || !def) return false;
|
if (!node || !def) return false;
|
||||||
for (const [key, row] of host.fields) {
|
for (const [key, row] of host.fields) {
|
||||||
const [scope, name] = key.split(':') as ['param' | 'input', string];
|
const [scope, name] = key.split(':') as ['param' | 'input', string];
|
||||||
|
const port = def.inputs.find((p) => p.key === name);
|
||||||
const fallback =
|
const fallback =
|
||||||
scope === 'param'
|
scope === 'param'
|
||||||
? def.params.find((p) => p.key === name)?.default
|
? def.params.find((p) => p.key === name)?.default
|
||||||
: inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
|
: port && inlineInputDef(port).default;
|
||||||
row.setValue(node.params[name] ?? fallback ?? '');
|
row.setValue(node.params[name] ?? fallback ?? '');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
AddPortCommand,
|
||||||
|
nextPortName,
|
||||||
|
RemovePortCommand,
|
||||||
|
RenamePortCommand,
|
||||||
|
} from '../../core/commands/portCommands';
|
||||||
|
import { BEHAVIOR_START, DECLARED_PORT_KINDS, type DeclaredPortKind, type GraphNode } from '../../core/graph';
|
||||||
|
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
|
||||||
|
import { addNote, type InspectorHost } from './host';
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<DeclaredPortKind, string> = {
|
||||||
|
number: 'Number',
|
||||||
|
boolean: 'Boolean',
|
||||||
|
text: 'Text',
|
||||||
|
object: 'Object',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_MARKS: Record<DeclaredPortKind, string> = {
|
||||||
|
number: '#',
|
||||||
|
boolean: '✓',
|
||||||
|
text: 'Aa',
|
||||||
|
object: '◇',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ports a start or end node declares – the signature of the behavior seen as a node. Named from
|
||||||
|
* the caller's side: what the start node hands into the graph is an *input* of the behavior.
|
||||||
|
*/
|
||||||
|
export function buildPortSection(host: InspectorHost, assetId: string, node: GraphNode): void {
|
||||||
|
const onStart = node.type === BEHAVIOR_START;
|
||||||
|
const noun = onStart ? 'Input' : 'Output';
|
||||||
|
|
||||||
|
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
|
||||||
|
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||||||
|
add.onClick = () => {
|
||||||
|
const items = DECLARED_PORT_KINDS.map((kind) => ({
|
||||||
|
label: KIND_LABELS[kind],
|
||||||
|
onSelect: () =>
|
||||||
|
host.state.execute(
|
||||||
|
new AddPortCommand(assetId, node.type, {
|
||||||
|
key: nextPortName(node, onStart ? 'in' : 'out'),
|
||||||
|
kind,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
new Menu(items).openBelow(add);
|
||||||
|
};
|
||||||
|
header.add(add);
|
||||||
|
host.body.add(header);
|
||||||
|
|
||||||
|
const ports = node.ports ?? [];
|
||||||
|
if (ports.length === 0)
|
||||||
|
addNote(
|
||||||
|
host,
|
||||||
|
onStart
|
||||||
|
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.'
|
||||||
|
: 'No outputs yet. Every output added here becomes a result of running this behavior.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const port of ports) {
|
||||||
|
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
row.minHeight = Theme.rowHeight;
|
||||||
|
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
|
||||||
|
mark.minWidth = 18;
|
||||||
|
row.add(mark);
|
||||||
|
row.add(
|
||||||
|
new TextField(port.key, {
|
||||||
|
width: 90,
|
||||||
|
onChange: (next) =>
|
||||||
|
host.state.execute(new RenamePortCommand(assetId, node.type, port.key, next.trim())),
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
row.add(new Spacer());
|
||||||
|
row.add(
|
||||||
|
new Button('×', {
|
||||||
|
variant: 'ghost',
|
||||||
|
width: Theme.controlHeight,
|
||||||
|
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.type, port.key)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
host.body.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type NodeContext,
|
type NodeContext,
|
||||||
type TraceListener,
|
type TraceListener,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
|
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||||
import type { PropertyBag } from '../../core/model/Properties';
|
import type { PropertyBag } from '../../core/model/Properties';
|
||||||
import { findAsset, type Project } from '../../core/model/Project';
|
import { findAsset, type Project } from '../../core/model/Project';
|
||||||
import type { Entity } from '../Entity';
|
import type { Entity } from '../Entity';
|
||||||
@@ -78,7 +79,7 @@ export class BehaviorSystem implements System {
|
|||||||
const asset = findAsset(this.project, entity.assetId);
|
const asset = findAsset(this.project, entity.assetId);
|
||||||
if (!asset || asset.type !== 'behavior') continue;
|
if (!asset || asset.type !== 'behavior') continue;
|
||||||
const trace: Trace = { edges: new Map(), nodes: new Map() };
|
const trace: Trace = { edges: new Map(), nodes: new Map() };
|
||||||
const interpreter = new GraphInterpreter(asset.graph);
|
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project));
|
||||||
interpreter.trace = {
|
interpreter.trace = {
|
||||||
onEdge: (id) => trace.edges.set(id, world.time),
|
onEdge: (id) => trace.edges.set(id, world.time),
|
||||||
onNode: (id) => trace.nodes.set(id, world.time),
|
onNode: (id) => trace.nodes.set(id, world.time),
|
||||||
@@ -153,14 +154,17 @@ export class BehaviorSystem implements System {
|
|||||||
return this.instances.map((i) => i.entity.objectId);
|
return this.instances.map((i) => i.entity.objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live highlight data for the graph editor: merged over all instances of the asset. */
|
/**
|
||||||
|
* Live highlight data for the graph editor: merged over all instances of the asset – and over
|
||||||
|
* those that ran it as a node, which is the only way a nested behavior's graph ever lights up.
|
||||||
|
*/
|
||||||
traceFor(assetId: string): Trace {
|
traceFor(assetId: string): Trace {
|
||||||
const merged: Trace = { edges: new Map(), nodes: new Map() };
|
const merged: Trace = { edges: new Map(), nodes: new Map() };
|
||||||
const latest = (into: Map<string, number>, from: Map<string, number>) => {
|
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));
|
for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity));
|
||||||
};
|
};
|
||||||
for (const instance of this.instances) {
|
for (const instance of this.instances) {
|
||||||
if (instance.entity.assetId !== assetId) continue;
|
if (instance.entity.assetId !== assetId && !instance.interpreter.calledBehaviors.has(assetId)) continue;
|
||||||
latest(merged.edges, instance.trace.edges);
|
latest(merged.edges, instance.trace.edges);
|
||||||
latest(merged.nodes, instance.trace.nodes);
|
latest(merged.nodes, instance.trace.nodes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
dropTargetPort,
|
dropTargetPort,
|
||||||
findNode,
|
findNode,
|
||||||
firstPortFor,
|
firstPortFor,
|
||||||
|
NO_BEHAVIORS,
|
||||||
type PortType,
|
type PortType,
|
||||||
portsCompatible,
|
portsCompatible,
|
||||||
requireNodeDef,
|
requireNodeDef,
|
||||||
@@ -512,7 +513,7 @@ describe('dropping an edge on a node', () => {
|
|||||||
it('picks the first compatible input', () => {
|
it('picks the first compatible input', () => {
|
||||||
const { graph, number, clamp } = fixtureWithClamp();
|
const { graph, number, clamp } = fixtureWithClamp();
|
||||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||||
expect(dropTargetPort(graph, from, clamp.id)).toEqual({
|
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toEqual({
|
||||||
nodeId: clamp.id,
|
nodeId: clamp.id,
|
||||||
port: 'value',
|
port: 'value',
|
||||||
isOutput: false,
|
isOutput: false,
|
||||||
@@ -524,7 +525,7 @@ describe('dropping an edge on a node', () => {
|
|||||||
const other = edit.addNode('Number');
|
const other = edit.addNode('Number');
|
||||||
edit.connect(other, 'value', clamp, 'value');
|
edit.connect(other, 'value', clamp, 'value');
|
||||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||||
expect(dropTargetPort(graph, from, clamp.id)?.port).toBe('min');
|
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('min');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the last compatible input once every one is taken', () => {
|
it('falls back to the last compatible input once every one is taken', () => {
|
||||||
@@ -532,7 +533,7 @@ describe('dropping an edge on a node', () => {
|
|||||||
for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port);
|
for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port);
|
||||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||||
// Not free, but the drop still connects – it replaces the edge, as dropping on the port would.
|
// Not free, but the drop still connects – it replaces the edge, as dropping on the port would.
|
||||||
expect(dropTargetPort(graph, from, clamp.id)?.port).toBe('max');
|
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('max');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps offering an exec input, which is never exclusive', () => {
|
it('keeps offering an exec input, which is never exclusive', () => {
|
||||||
@@ -543,26 +544,26 @@ describe('dropping an edge on a node', () => {
|
|||||||
const move = edit.addNode('SetPosition');
|
const move = edit.addNode('SetPosition');
|
||||||
edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec');
|
edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec');
|
||||||
const from = { nodeId: held.id, port: 'exec', isOutput: true };
|
const from = { nodeId: held.id, port: 'exec', isOutput: true };
|
||||||
expect(dropTargetPort(behavior.graph, from, move.id)?.port).toBe('exec');
|
expect(dropTargetPort(behavior.graph, from, move.id, NO_BEHAVIORS)?.port).toBe('exec');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses a node that offers nothing compatible, and the node itself', () => {
|
it('refuses a node that offers nothing compatible, and the node itself', () => {
|
||||||
const { graph, edit, number, clamp } = fixtureWithClamp();
|
const { graph, edit, number, clamp } = fixtureWithClamp();
|
||||||
const boolean = edit.addNode('Boolean');
|
const boolean = edit.addNode('Boolean');
|
||||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||||
expect(dropTargetPort(graph, from, boolean.id)).toBeNull();
|
expect(dropTargetPort(graph, from, boolean.id, NO_BEHAVIORS)).toBeNull();
|
||||||
expect(dropTargetPort(graph, from, number.id)).toBeNull();
|
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toBeNull();
|
||||||
// A pair that is already wired is not offered again either.
|
// A pair that is already wired is not offered again either.
|
||||||
edit.connect(number, 'value', clamp, 'value');
|
edit.connect(number, 'value', clamp, 'value');
|
||||||
edit.connect(number, 'value', clamp, 'min');
|
edit.connect(number, 'value', clamp, 'min');
|
||||||
edit.connect(number, 'value', clamp, 'max');
|
edit.connect(number, 'value', clamp, 'max');
|
||||||
expect(dropTargetPort(graph, from, clamp.id)).toBeNull();
|
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves a drag started at an input to an output', () => {
|
it('resolves a drag started at an input to an output', () => {
|
||||||
const { graph, number, clamp } = fixtureWithClamp();
|
const { graph, number, clamp } = fixtureWithClamp();
|
||||||
const from = { nodeId: clamp.id, port: 'value', isOutput: false };
|
const from = { nodeId: clamp.id, port: 'value', isOutput: false };
|
||||||
expect(dropTargetPort(graph, from, number.id)).toEqual({
|
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toEqual({
|
||||||
nodeId: number.id,
|
nodeId: number.id,
|
||||||
port: 'value',
|
port: 'value',
|
||||||
isOutput: true,
|
isOutput: true,
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CommandError } from '../../src/core/commands/Command';
|
||||||
|
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
|
||||||
|
import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
|
||||||
|
import {
|
||||||
|
BEHAVIOR_END,
|
||||||
|
BEHAVIOR_PARAM,
|
||||||
|
BEHAVIOR_START,
|
||||||
|
type Graph,
|
||||||
|
GraphInterpreter,
|
||||||
|
RUN_BEHAVIOR,
|
||||||
|
resolveDef,
|
||||||
|
terminalNode,
|
||||||
|
} from '../../src/core/graph';
|
||||||
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||||
|
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||||
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||||
|
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A behavior that doubles what it is given: `in1` → Multiply by 2 → `out1`, reached through its
|
||||||
|
* start and end node. Small on purpose – what the tests are about is the call, not the arithmetic.
|
||||||
|
*/
|
||||||
|
function doubler(fixture: ProjectFixture, name = 'Double'): BehaviorAsset {
|
||||||
|
const asset = makeBehavior(fixture, name);
|
||||||
|
const edit = graphEditor(fixture, asset);
|
||||||
|
const start = edit.addNode(BEHAVIOR_START);
|
||||||
|
const end = edit.addNode(BEHAVIOR_END);
|
||||||
|
fixture.stack.execute(new AddPortCommand(asset.id, BEHAVIOR_START, { key: 'in1', kind: 'number' }));
|
||||||
|
fixture.stack.execute(new AddPortCommand(asset.id, BEHAVIOR_END, { key: 'out1', kind: 'number' }));
|
||||||
|
const times = edit.addNode('Multiply', { b: 2 });
|
||||||
|
edit.wire([
|
||||||
|
[start, 'exec', end, 'exec'],
|
||||||
|
[start, 'in1', times, 'a'],
|
||||||
|
[times, 'result', end, 'out1'],
|
||||||
|
]);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A caller that runs `callee` on start with `input` and stores the result in its own `result` var. */
|
||||||
|
function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number): BehaviorAsset {
|
||||||
|
const asset = makeBehavior(fixture, 'Caller', ['result']);
|
||||||
|
const edit = graphEditor(fixture, asset);
|
||||||
|
const start = edit.addNode('OnStart');
|
||||||
|
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: input });
|
||||||
|
const set = edit.addNode('SetVariable', { variable: 'result' });
|
||||||
|
edit.wire([
|
||||||
|
[start, 'exec', call, 'exec'],
|
||||||
|
[call, 'exec', set, 'exec'],
|
||||||
|
[call, 'out1', set, 'value'],
|
||||||
|
]);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
|
||||||
|
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(fixture.project));
|
||||||
|
const ctx = fakeContext({ variables });
|
||||||
|
interpreter.fire('OnStart', ctx);
|
||||||
|
return { ctx, interpreter };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a behavior used as a node', () => {
|
||||||
|
it('runs the other graph in place and hands its result back', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const caller = callerOf(fixture, doubler(fixture), 21);
|
||||||
|
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes its ports from the other behavior, not from its own type', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const callee = doubler(fixture);
|
||||||
|
const caller = callerOf(fixture, callee, 1);
|
||||||
|
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
|
||||||
|
const def = resolveDef(call, behaviorLookup(fixture.project))!;
|
||||||
|
expect(def.inputs.map((p) => p.key)).toEqual(['exec', 'in1']);
|
||||||
|
expect(def.outputs.map((p) => p.key)).toEqual(['exec', 'out1']);
|
||||||
|
// Named after the behavior it runs: a graph of calls is unreadable as a row of "Run behavior".
|
||||||
|
expect(def.label).toBe('Double');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its variables to itself: the caller’s are untouched and nothing survives the call', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const callee = makeBehavior(fixture, 'Counter', ['count']);
|
||||||
|
const edit = graphEditor(fixture, callee);
|
||||||
|
const start = edit.addNode(BEHAVIOR_START);
|
||||||
|
const end = edit.addNode(BEHAVIOR_END);
|
||||||
|
fixture.stack.execute(new AddPortCommand(callee.id, BEHAVIOR_END, { key: 'out1', kind: 'number' }));
|
||||||
|
const get = edit.addNode('GetVariable', { variable: 'count' });
|
||||||
|
const plus = edit.addNode('Add', { b: 1 });
|
||||||
|
const set = edit.addNode('SetVariable', { variable: 'count' });
|
||||||
|
edit.wire([
|
||||||
|
[start, 'exec', set, 'exec'],
|
||||||
|
[get, 'value', plus, 'a'],
|
||||||
|
[plus, 'result', set, 'value'],
|
||||||
|
[set, 'exec', end, 'exec'],
|
||||||
|
[plus, 'result', end, 'out1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = makeBehavior(fixture, 'Caller', ['result', 'count']);
|
||||||
|
const cEdit = graphEditor(fixture, caller);
|
||||||
|
const onStart = cEdit.addNode('OnStart');
|
||||||
|
const first = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||||||
|
const second = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||||||
|
const store = cEdit.addNode('SetVariable', { variable: 'result' });
|
||||||
|
cEdit.wire([
|
||||||
|
[onStart, 'exec', first, 'exec'],
|
||||||
|
[first, 'exec', second, 'exec'],
|
||||||
|
[second, 'exec', store, 'exec'],
|
||||||
|
[second, 'out1', store, 'value'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { ctx } = runOnStart(fixture, caller, { result: 0, count: 500 });
|
||||||
|
// 1, not 2: the second call starts from the asset default again, not from what the first left.
|
||||||
|
expect(ctx.variables.result).toBe(1);
|
||||||
|
// The caller has a `count` of its own – the nested run must not have written into it.
|
||||||
|
expect(ctx.variables.count).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the nested graph in its own frame, so equal node ids cannot collide', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const callee = makeBehavior(fixture, 'Ninetynine');
|
||||||
|
const cEdit = graphEditor(fixture, callee);
|
||||||
|
const start = cEdit.addNode(BEHAVIOR_START);
|
||||||
|
const end = cEdit.addNode(BEHAVIOR_END);
|
||||||
|
fixture.stack.execute(new AddPortCommand(callee.id, BEHAVIOR_END, { key: 'out1', kind: 'number' }));
|
||||||
|
const theirs = cEdit.addNode('Number', { value: 99 });
|
||||||
|
cEdit.wire([
|
||||||
|
[start, 'exec', end, 'exec'],
|
||||||
|
[theirs, 'value', end, 'out1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = makeBehavior(fixture, 'Caller', ['result']);
|
||||||
|
const edit = graphEditor(fixture, caller);
|
||||||
|
const onStart = edit.addNode('OnStart');
|
||||||
|
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||||||
|
const set = edit.addNode('SetVariable', { variable: 'result' });
|
||||||
|
const mine = edit.addNode('Number', { value: 7 });
|
||||||
|
edit.wire([
|
||||||
|
[onStart, 'exec', call, 'exec'],
|
||||||
|
// Read *after* the call, and from the caller's own node – the point of the test.
|
||||||
|
[call, 'exec', set, 'exec'],
|
||||||
|
[mine, 'value', set, 'value'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ids are unique in practice; forcing a collision is the only way to show the frames are real.
|
||||||
|
// Both nodes have a `value` output, so a shared cache would hand the caller the nested 99.
|
||||||
|
const shared = 'same-id';
|
||||||
|
retarget(callee.graph, theirs.id, shared);
|
||||||
|
retarget(caller.graph, mine.id, shared);
|
||||||
|
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to run itself rather than looping forever', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const asset = doubler(fixture, 'Loop');
|
||||||
|
const edit = graphEditor(fixture, asset);
|
||||||
|
const start = terminalNode(asset.graph, BEHAVIOR_START)!;
|
||||||
|
const self = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id });
|
||||||
|
edit.connect(start, 'exec', self, 'exec');
|
||||||
|
const caller = callerOf(fixture, asset, 1);
|
||||||
|
expect(() => runOnStart(fixture, caller, { result: 0 })).toThrow(/runs itself/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a call into nothing is quiet: no behavior chosen, or one without a start node', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const empty = makeBehavior(fixture, 'Empty');
|
||||||
|
for (const target of ['', empty.id]) {
|
||||||
|
const caller = makeBehavior(fixture, `C${target}`, ['result']);
|
||||||
|
const edit = graphEditor(fixture, caller);
|
||||||
|
const start = edit.addNode('OnStart');
|
||||||
|
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: target });
|
||||||
|
const set = edit.addNode('SetVariable', { variable: 'result' });
|
||||||
|
edit.wire([
|
||||||
|
[start, 'exec', call, 'exec'],
|
||||||
|
// The flow carries on past the call, which is what keeps a dangling reference harmless.
|
||||||
|
[call, 'exec', set, 'exec'],
|
||||||
|
]);
|
||||||
|
expect(runOnStart(fixture, caller, { result: 7 }).ctx.variables.result).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('declared ports', () => {
|
||||||
|
it('renaming one rewrites the edges in this graph and in every calling one', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const callee = doubler(fixture);
|
||||||
|
const caller = callerOf(fixture, callee, 3);
|
||||||
|
fixture.stack.execute(new RenamePortCommand(callee.id, BEHAVIOR_START, 'in1', 'factor'));
|
||||||
|
const start = terminalNode(callee.graph, BEHAVIOR_START)!;
|
||||||
|
expect(start.ports).toEqual([{ key: 'factor', kind: 'number' }]);
|
||||||
|
expect(callee.graph.edges.some((e) => e.from.port === 'factor')).toBe(true);
|
||||||
|
// The value typed into the calling node moves with the port; losing it would be silent.
|
||||||
|
expect(caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!.params).toMatchObject({ factor: 3 });
|
||||||
|
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing one takes the edges hanging on it, in both graphs, and undo brings them back', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const callee = doubler(fixture);
|
||||||
|
const caller = callerOf(fixture, callee, 4);
|
||||||
|
const edgesBefore = { callee: callee.graph.edges.length, caller: caller.graph.edges.length };
|
||||||
|
fixture.stack.execute(new RemovePortCommand(callee.id, BEHAVIOR_END, 'out1'));
|
||||||
|
expect(callee.graph.edges.length).toBe(edgesBefore.callee - 1);
|
||||||
|
expect(caller.graph.edges.length).toBe(edgesBefore.caller - 1);
|
||||||
|
fixture.stack.undo();
|
||||||
|
expect(callee.graph.edges.length).toBe(edgesBefore.callee);
|
||||||
|
expect(caller.graph.edges.length).toBe(edgesBefore.caller);
|
||||||
|
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives saving and loading', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const callee = doubler(fixture);
|
||||||
|
callerOf(fixture, callee, 6);
|
||||||
|
const loaded = deserializeProject(JSON.parse(JSON.stringify(serializeProject(fixture.project))));
|
||||||
|
const reloaded = loaded.assets.find((a) => a.id === callee.id) as BehaviorAsset;
|
||||||
|
expect(terminalNode(reloaded.graph, BEHAVIOR_START)?.ports).toEqual([{ key: 'in1', kind: 'number' }]);
|
||||||
|
const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset;
|
||||||
|
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(loaded));
|
||||||
|
const ctx = fakeContext({ variables: { result: 0 } });
|
||||||
|
interpreter.fire('OnStart', ctx);
|
||||||
|
expect(ctx.variables.result).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a behavior has one start and one end node, never two', () => {
|
||||||
|
const fixture = makeProject();
|
||||||
|
const asset = doubler(fixture);
|
||||||
|
for (const type of [BEHAVIOR_START, BEHAVIOR_END]) {
|
||||||
|
expect(() => fixture.stack.execute(new AddNodeCommand(asset.id, type, 0, 0))).toThrow(CommandError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gives a node a different id, edges included – used to force an id collision across graphs. */
|
||||||
|
function retarget(graph: Graph, from: string, to: string): void {
|
||||||
|
graph.nodes.find((n) => n.id === from)!.id = to;
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (edge.from.node === from) edge.from.node = to;
|
||||||
|
if (edge.to.node === from) edge.to.node = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ interface Case {
|
|||||||
next?: string | string[] | null;
|
next?: string | string[] | null;
|
||||||
/** Expected loop request; `null` asserts the node does not loop. */
|
/** Expected loop request; `null` asserts the node does not loop. */
|
||||||
repeat?: RunResult['repeat'] | null;
|
repeat?: RunResult['repeat'] | null;
|
||||||
|
/** Expected nested-behavior call; `null` asserts the node calls nothing. */
|
||||||
|
call?: RunResult['call'] | null;
|
||||||
/** Assertions on what the node did to the context or to the handles it was given. */
|
/** Assertions on what the node did to the context or to the handles it was given. */
|
||||||
after?(ctx: FakeContext): void;
|
after?(ctx: FakeContext): void;
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,28 @@ const CASES: Record<string, Case[]> = {
|
|||||||
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
|
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
|
||||||
],
|
],
|
||||||
Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }],
|
Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }],
|
||||||
|
BehaviorEnd: [
|
||||||
|
{
|
||||||
|
name: 'hands its inputs back out, which is where the caller reads the result',
|
||||||
|
inputs: { hit: true, damage: 7 },
|
||||||
|
outputs: { hit: true, damage: 7 },
|
||||||
|
next: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
RunBehavior: [
|
||||||
|
{
|
||||||
|
name: 'asks for the named behavior to run, passing its inputs on',
|
||||||
|
inputs: { power: 3 },
|
||||||
|
params: { behavior: 'b1' },
|
||||||
|
call: { behaviorId: 'b1', inputs: { power: 3 } },
|
||||||
|
next: 'exec',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'without a behavior chosen it still continues, calling nothing that exists',
|
||||||
|
call: { behaviorId: '', inputs: {} },
|
||||||
|
next: 'exec',
|
||||||
|
},
|
||||||
|
],
|
||||||
Repeat: [
|
Repeat: [
|
||||||
{
|
{
|
||||||
name: 'asks for as many body rounds as it was given, then continues at done',
|
name: 'asks for as many body rounds as it was given, then continues at done',
|
||||||
@@ -534,6 +558,7 @@ function eqNamesakes(): Record<string, Value> {
|
|||||||
* seeds their outputs. The value names the test that drives each one.
|
* seeds their outputs. The value names the test that drives each one.
|
||||||
*/
|
*/
|
||||||
const EVENT_NODES: Record<string, string> = {
|
const EVENT_NODES: Record<string, string> = {
|
||||||
|
BehaviorStart: 'nested-behavior.test.ts',
|
||||||
OnStart: 'graph.test.ts',
|
OnStart: 'graph.test.ts',
|
||||||
OnUpdate: 'graph.test.ts',
|
OnUpdate: 'graph.test.ts',
|
||||||
OnKeyDown: 'behavior-events.test.ts',
|
OnKeyDown: 'behavior-events.test.ts',
|
||||||
@@ -590,6 +615,7 @@ for (const [type, cases] of Object.entries(CASES)) {
|
|||||||
if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs);
|
if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs);
|
||||||
if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next);
|
if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next);
|
||||||
if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat);
|
if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat);
|
||||||
|
if (c.call !== undefined) expect(result.call ?? null).toEqual(c.call);
|
||||||
c.after?.(ctx);
|
c.after?.(ctx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
## Nested Behavior / Behavior als Node in Graph
|
||||||
|
|
||||||
|
Was damit gemeint ist?
|
||||||
|
=> Ein Behavior Asset als Node innerhalb eines Graph
|
||||||
|
|
||||||
|
Grundsätzlich soll so ein Behavior Node im Graf mit einem Execution Flow ausgestattet sein, sprich wir brauchen einen Start/End Node:
|
||||||
|
- Start Node ist ähnlich wie ein Event Node mit exec output port und dort soll man auch ouput "variablen" festlegen können (quasi wie variablen)
|
||||||
|
- End Node ähnlich, hat als Input ein exec port + inputs die als output gelten, wenn man es als node in einen anderen graphen eines behavior assets einbindet.
|
||||||
|
|
||||||
|
wenn alles verstanden, direkt loslegen, ansonsten warte ich auf rückfragen :)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgesetzt
|
||||||
|
|
||||||
|
Verstanden war alles — gebaut ist genau die beschriebene Form. Drei neue Node-Typen:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **`Start`** | exec-Ausgang plus die Eingaenge, die das Behavior von aussen bekommt. Was hier deklariert wird, ist ein **Eingang des aufrufenden Nodes** |
|
||||||
|
| **`End`** | exec-Eingang plus die Werte, die zurueckgehen. Was hier deklariert wird, ist ein **Ausgang des aufrufenden Nodes** |
|
||||||
|
| **`Run behavior`** | der Aufruf selbst. Seine Ports gibt es nicht in seiner Typdefinition — sie werden aus Start- und End-Node des Ziels **abgeleitet**, bei jedem Zeichnen neu |
|
||||||
|
|
||||||
|
Deklariert wird im Inspector des Start-/End-Nodes: `+ Input` bzw. `+ Output`, Art waehlbar
|
||||||
|
(Number, Boolean, Text, Object), Name direkt editierbar, `×` zum Entfernen — dieselbe Bauform
|
||||||
|
wie die Variablen eines Behaviors.
|
||||||
|
|
||||||
|
**Der Node traegt den Namen des Behaviors**, nicht "Run behavior", und die Palette listet
|
||||||
|
Behaviors ebenfalls namentlich. Ein Aufruf ist damit ein Griff statt Node einfuegen + Dropdown.
|
||||||
|
|
||||||
|
## Die eine Entscheidung, die du mir ueberlassen hast
|
||||||
|
|
||||||
|
Ein eingebettetes Behavior hat kein Objekt in der Szene — also auch keinen Ort fuer seine
|
||||||
|
Variablen. Ich habe **(A) reine Funktion** gewaehlt: jeder Aufruf startet die Variablen auf den
|
||||||
|
Asset-Defaults, rein geht es ueber den Start-Node, raus ueber den End-Node.
|
||||||
|
|
||||||
|
Der Grund ist dein eigener naechster Schritt: du willst den Tetris-Graphen zerlegen. Dabei
|
||||||
|
sollen die Teile *rechnen*, waehrend der Zustand (`grid`, `pieceX`, …) im Haupt-Behavior
|
||||||
|
bleibt — genau das, was du mit "kann man ueber input/output loesen" gemeint hast. (A) ist
|
||||||
|
ausserdem die konservative Wahl: daraus laesst sich spaeter Zustand machen, umgekehrt nicht,
|
||||||
|
ohne bestehende Graphen zu brechen.
|
||||||
|
|
||||||
|
**Die Tuer ist nicht zu:** Zustand gibt es weiterhin ueber ein Behavior-Objekt in der Szene
|
||||||
|
(`Object by name` → `Get/Set property`) — der Weg, den dein Jump and Run fuer den Punktestand
|
||||||
|
schon geht. Lokale Variablen im eingebetteten Graphen funktionieren als Zwischenspeicher
|
||||||
|
innerhalb eines Aufrufs unveraendert; sie werden nur am Ende verworfen.
|
||||||
|
|
||||||
|
## Was ich selbst entschieden habe
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Ein Start, ein End** | ein zweiter machte "welche Ports hat dieses Behavior" mehrdeutig. Der Command lehnt ihn ab, die Palette bietet ihn nicht mehr an |
|
||||||
|
| **Selbstaufruf = Fehler** | mit Namen im Log (`Behavior "X" runs itself`), statt still in die Tiefenbegrenzung zu laufen. Die Palette bietet das eigene Behavior gar nicht erst an |
|
||||||
|
| **`self`/`owner` bleiben** | der eingebettete Graph sieht dasselbe Objekt wie der Aufrufer — sonst waeren `Self`/`Owner` darin wertlos |
|
||||||
|
| **Toter Verweis ist still** | ein Node ohne gewaehltes Behavior (oder eines ohne Start-Node) tut nichts und **laesst den Fluss weiterlaufen**, statt den Lauf abzubrechen |
|
||||||
|
|
||||||
|
## Zwei Dinge, die der Bau erzwungen hat
|
||||||
|
|
||||||
|
**Eigener Pulse-Cache pro Aufruf.** Node-Ids sind nur *pro Graph* eindeutig. Liefe der
|
||||||
|
eingebettete Graph im Cache des Aufrufers, koennte er dessen Werte ueberschreiben. Der
|
||||||
|
Interpreter arbeitet deshalb auf **Frames** — der Aufruf schiebt einen neuen auf und nimmt ihn
|
||||||
|
danach wieder weg. Dafuer gibt es einen eigenen Test, der eine Id-Kollision erzwingt; ohne
|
||||||
|
Frames liefert er 99 statt 7.
|
||||||
|
|
||||||
|
**Ports aufraeumen ueber Graphgrenzen.** Einen Port umzubenennen oder zu entfernen betrifft
|
||||||
|
nicht nur den eigenen Graphen, sondern **jeden anderen, der das Behavior aufruft**. Beides
|
||||||
|
raeumt dort mit auf — und ein Rename nimmt den eingetippten Inline-Wert mit, sonst faellt er
|
||||||
|
still auf den Default zurueck. Das ist mir erst beim Gegenpruefen aufgefallen: der Test stand
|
||||||
|
auf 0 statt auf 6.
|
||||||
|
|
||||||
|
**Live-Highlight:** der Trace eines laufenden Behaviors deckt jetzt auch die Graphen ab, die es
|
||||||
|
als Node aufruft — sonst bliebe ein eingebettetes Behavior beim Zusehen dunkel.
|
||||||
|
|
||||||
|
## Belegt
|
||||||
|
|
||||||
|
**Zehn Unit-Tests** (`tests/core/nested-behavior.test.ts`) und **elf E2E-Checks** in Modul 19.
|
||||||
|
Der staerkste ist der erste: das aufrufende Behavior rechnet **nichts** selbst — die
|
||||||
|
Geschwindigkeit, mit der die Kiste laeuft, *ist* der zurueckgegebene Wert.
|
||||||
|
|
||||||
|
```
|
||||||
|
PASS a behavior used as a node runs, and what it returns is what drives the caller
|
||||||
|
– moved 200.0 px in ~1 s – 2 × 100 means the returned value arrived, 0 would mean nothing came back
|
||||||
|
PASS the calling node grows the ports the other behavior declares
|
||||||
|
PASS and it is drawn under that behavior's name, not as "Run behavior" – title="Double"
|
||||||
|
PASS adding an input declares it on the start node – [{"key":"in1",…},{"key":"in2",…}]
|
||||||
|
PASS and the node in the other graph grew that input without being touched
|
||||||
|
PASS undo removes it in both graphs – [{"key":"in1","kind":"number"}]
|
||||||
|
PASS picking a behavior in the palette drops in a node already set to it
|
||||||
|
– {"type":"RunBehavior","params":{"behavior":"nb-double"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`200.0` statt `100` beweist, dass der **verdoppelte** Wert ankam, nicht bloss irgendeiner;
|
||||||
|
`0` waere "nichts kam zurueck". Die drei Port-Checks in Folge sind der Nachweis, dass die
|
||||||
|
Portliste wirklich *abgeleitet* und nicht kopiert ist: deklariert wird im einen Graphen,
|
||||||
|
gewachsen ist der Node im anderen, ohne dass er angefasst wurde.
|
||||||
|
|
||||||
|
Gegenprobe gefahren: ohne die frische Variablenkopie und ohne die Selbstaufruf-Sperre fallen
|
||||||
|
zwei Unit-Tests; ohne getrennte Frames faellt der Kollisionstest (`expected 99 to be 7`) — der
|
||||||
|
war beim ersten Versuch faelschlich gruen und musste geschaerft werden, bevor er etwas taugte.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| volle E2E-Suite, remote | **ALL PASSED – 379 Checks**, 0 FAIL |
|
||||||
|
| `npm run check` lokal | gruen, **524 Tests** |
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
## Nested Behavior / Behavior als Node in Graph
|
|
||||||
|
|
||||||
Was damit gemeint ist?
|
|
||||||
=> Ein Behavior Asset als Node innerhalb eines Graph
|
|
||||||
|
|
||||||
Grundsätzlich soll so ein Behavior Node im Graf mit einem Execution Flow ausgestattet sein, sprich wir brauchen einen Start/End Node:
|
|
||||||
- Start Node ist ähnlich wie ein Event Node mit exec output port und dort soll man auch ouput "variablen" festlegen können (quasi wie variablen)
|
|
||||||
- End Node ähnlich, hat als Input ein exec port + inputs die als output gelten, wenn man es als node in einen anderen graphen eines behavior assets einbindet.
|
|
||||||
|
|
||||||
wenn alles verstanden, direkt loslegen, ansonsten warte ich auf rückfragen :)
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { edge, node, port } from '../lib/builders.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A behavior used as a node inside another behavior's graph: the ports it grows from the other
|
||||||
|
* graph's start/end nodes, the call itself while playing, and declaring a port across the two.
|
||||||
|
*
|
||||||
|
* The step brings its own box and its own two behaviors; everything it adds is removed at the end.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCALE = 100;
|
||||||
|
|
||||||
|
/** `in1` → ×2 → `out1`, entered at the start node and left at the end node. */
|
||||||
|
const doubleGraph = {
|
||||||
|
nodes: [
|
||||||
|
node('nb-start', 'BehaviorStart', 0, 0, {}, [port('in1', 'number')]),
|
||||||
|
node('nb-times', 'Multiply', 240, 60, { b: 2 }),
|
||||||
|
node('nb-end', 'BehaviorEnd', 520, 0, {}, [port('out1', 'number')]),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge('nb-e1', 'nb-start', 'exec', 'nb-end', 'exec'),
|
||||||
|
edge('nb-e2', 'nb-start', 'in1', 'nb-times', 'a'),
|
||||||
|
edge('nb-e3', 'nb-times', 'result', 'nb-end', 'out1'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `On update → Run(Double, in1 = 1) → Move(owner, dx = out1)`. The caller does no arithmetic of
|
||||||
|
* its own: the speed it moves at *is* the value the nested behavior handed back.
|
||||||
|
*/
|
||||||
|
const callerGraph = {
|
||||||
|
nodes: [
|
||||||
|
node('nb-update', 'OnUpdate', 0, 0),
|
||||||
|
node('nb-owner', 'Owner', 0, 160),
|
||||||
|
node('nb-call', 'RunBehavior', 260, 0, { behavior: 'nb-double', in1: 1 }),
|
||||||
|
node('nb-move', 'Move', 560, 0, { scale: SCALE }),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge('nb-c1', 'nb-update', 'exec', 'nb-call', 'exec'),
|
||||||
|
edge('nb-c2', 'nb-call', 'exec', 'nb-move', 'exec'),
|
||||||
|
edge('nb-c3', 'nb-call', 'out1', 'nb-move', 'dx'),
|
||||||
|
edge('nb-c4', 'nb-update', 'dt', 'nb-move', 'dt'),
|
||||||
|
edge('nb-c5', 'nb-owner', 'object', 'nb-move', 'object'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function nestedBehavior(t) {
|
||||||
|
const {
|
||||||
|
check,
|
||||||
|
sleep,
|
||||||
|
evaluate,
|
||||||
|
json,
|
||||||
|
shot,
|
||||||
|
click,
|
||||||
|
clickText,
|
||||||
|
typeText,
|
||||||
|
locate,
|
||||||
|
press,
|
||||||
|
undo,
|
||||||
|
object,
|
||||||
|
entity,
|
||||||
|
asset,
|
||||||
|
mutate,
|
||||||
|
stop,
|
||||||
|
} = t;
|
||||||
|
const portAt = (nodeId, key, isOutput) =>
|
||||||
|
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
|
||||||
|
const startPorts = async () =>
|
||||||
|
(await asset('nb-double')).graph.nodes.find((n) => n.id === 'nb-start').ports ?? [];
|
||||||
|
|
||||||
|
// 19a. Two behaviors and a box wearing the calling one. Written into the model directly: what
|
||||||
|
// is under test is the call, not another way of drawing a graph.
|
||||||
|
await mutate(`
|
||||||
|
const p = __gm.state.project;
|
||||||
|
const scene = __gm.state.activeScene;
|
||||||
|
const proto = scene.objects[0];
|
||||||
|
const mover = p.assets.find((a) => a.id === 'mover');
|
||||||
|
const behavior = (id, name, graph) => {
|
||||||
|
const a = structuredClone(mover);
|
||||||
|
Object.assign(a, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
|
||||||
|
p.assets.push(a);
|
||||||
|
};
|
||||||
|
behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)});
|
||||||
|
behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)});
|
||||||
|
const make = (id, assetId, parentId, name) => {
|
||||||
|
const o = structuredClone(proto);
|
||||||
|
Object.assign(o, {
|
||||||
|
id, assetId, name,
|
||||||
|
position: { x: 0, y: 700, z: 0 },
|
||||||
|
parentId, overrides: {}, instanceEdits: {},
|
||||||
|
});
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
scene.objects.push(make('nb-box', 'crate', '', 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 'Caller'));
|
||||||
|
`);
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
// 19b. The call really runs, and the value really comes back: the box moves at dx × scale, and
|
||||||
|
// dx is nothing the caller holds – it is what the nested graph returned. Without the result
|
||||||
|
// dx would be 0 and the box would not move at all.
|
||||||
|
const boxX = async () => (await object('nb-box')).position.x;
|
||||||
|
const idleX = await boxX();
|
||||||
|
await t.play();
|
||||||
|
await sleep(300);
|
||||||
|
const before = (await entity('nb-box')).x;
|
||||||
|
await sleep(1000);
|
||||||
|
const after = (await entity('nb-box')).x;
|
||||||
|
const speed = after - before;
|
||||||
|
check(
|
||||||
|
'a behavior used as a node runs, and what it returns is what drives the caller',
|
||||||
|
speed > SCALE * 1.4 && speed < SCALE * 2.6,
|
||||||
|
`moved ${speed.toFixed(1)} px in ~1 s – 2 × ${SCALE} means the returned value arrived, 0 would mean nothing came back`,
|
||||||
|
);
|
||||||
|
await stop();
|
||||||
|
await sleep(300);
|
||||||
|
check(
|
||||||
|
'the stored object is untouched by the run, as in any play session',
|
||||||
|
(await boxX()) === idleX,
|
||||||
|
`x=${await boxX()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 19c. The node in the graph carries the *other* behavior's ports and name – neither is part of
|
||||||
|
// its own type.
|
||||||
|
await evaluate(
|
||||||
|
`__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`,
|
||||||
|
);
|
||||||
|
await sleep(300);
|
||||||
|
const inPort = await portAt('nb-call', 'in1', false);
|
||||||
|
const outPort = await portAt('nb-call', 'out1', true);
|
||||||
|
check(
|
||||||
|
'the calling node grows the ports the other behavior declares',
|
||||||
|
Boolean(inPort) && Boolean(outPort) && outPort.x > inPort.x,
|
||||||
|
JSON.stringify({ inPort, outPort }),
|
||||||
|
);
|
||||||
|
const title = await evaluate(`__gm.graph.nodeLabel('nb-call')`);
|
||||||
|
check(
|
||||||
|
'and it is drawn under that behavior\'s name, not as "Run behavior"',
|
||||||
|
title === 'Double',
|
||||||
|
`title=${JSON.stringify(title)}`,
|
||||||
|
);
|
||||||
|
await shot('37-nested-behavior');
|
||||||
|
|
||||||
|
// 19d. Declaring a port on one behavior reaches the node in the other one, live. This is the
|
||||||
|
// check that a port list derived from another graph is really derived and not copied.
|
||||||
|
check('the calling node has no second input yet', !(await portAt('nb-call', 'in2', false)));
|
||||||
|
await evaluate(
|
||||||
|
`__gm.state.select({ kind: 'node', assetId: 'nb-double', nodeId: 'nb-start', nodeIds: ['nb-start'] })`,
|
||||||
|
);
|
||||||
|
await sleep(300);
|
||||||
|
const addButton = await locate('+ Input');
|
||||||
|
check('the start node offers its inputs in the inspector', Boolean(addButton), JSON.stringify(addButton));
|
||||||
|
await shot('38-behavior-ports');
|
||||||
|
await click(addButton.x, addButton.y);
|
||||||
|
await sleep(200);
|
||||||
|
await clickText('Number');
|
||||||
|
await sleep(300);
|
||||||
|
const declared = await startPorts();
|
||||||
|
check(
|
||||||
|
'adding an input declares it on the start node',
|
||||||
|
declared.length === 2 && declared[1].key === 'in2' && declared[1].kind === 'number',
|
||||||
|
JSON.stringify(declared),
|
||||||
|
);
|
||||||
|
await evaluate(
|
||||||
|
`__gm.state.select({ kind: 'node', assetId: 'nb-caller', nodeId: 'nb-call', nodeIds: ['nb-call'] })`,
|
||||||
|
);
|
||||||
|
await sleep(300);
|
||||||
|
check(
|
||||||
|
'and the node in the other graph grew that input without being touched',
|
||||||
|
Boolean(await portAt('nb-call', 'in2', false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 19e. Undo takes the port back, and with it the port on the calling node.
|
||||||
|
await undo();
|
||||||
|
await sleep(300);
|
||||||
|
const afterUndo = await startPorts();
|
||||||
|
check(
|
||||||
|
'undo removes it in both graphs',
|
||||||
|
afterUndo.length === 1 && !(await portAt('nb-call', 'in2', false)),
|
||||||
|
`${JSON.stringify(afterUndo)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 19f. The palette offers a behavior under its own name, and picking it leaves a node that
|
||||||
|
// already names it – one pick, not a pick plus a dropdown. ("Double" also labels the asset
|
||||||
|
// in the list on the left, so the proof is the node that appears, not the row.)
|
||||||
|
const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id);
|
||||||
|
const idsBefore = await nodeIds();
|
||||||
|
const centre = await json('__gm.graph.center()');
|
||||||
|
await click(centre.x + 250, centre.y + 250, 'right');
|
||||||
|
await sleep(150);
|
||||||
|
check('node palette opens', Boolean(await locate('Search nodes…')));
|
||||||
|
await typeText('Double');
|
||||||
|
await sleep(200);
|
||||||
|
// Enter takes the first match. NOT a click on the label: "Double" also names the asset in the
|
||||||
|
// list on the left, and that is what a click by text would find first.
|
||||||
|
await press('Enter');
|
||||||
|
await sleep(300);
|
||||||
|
const added = (await asset('nb-caller')).graph.nodes.find((n) => !idsBefore.includes(n.id));
|
||||||
|
check(
|
||||||
|
'picking a behavior in the palette drops in a node already set to it',
|
||||||
|
(await nodeIds()).length === idsBefore.length + 1 &&
|
||||||
|
added?.type === 'RunBehavior' &&
|
||||||
|
added?.params.behavior === 'nb-double',
|
||||||
|
JSON.stringify(added && { type: added.type, params: added.params }),
|
||||||
|
);
|
||||||
|
await undo();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// 19g. Clean up: the two behaviors and the box are gone again.
|
||||||
|
await evaluate(`__gm.state.select(null)`);
|
||||||
|
await mutate(`
|
||||||
|
const p = __gm.state.project;
|
||||||
|
const scene = __gm.state.activeScene;
|
||||||
|
p.assets = p.assets.filter((a) => a.id !== 'nb-double' && a.id !== 'nb-caller');
|
||||||
|
scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic');
|
||||||
|
`);
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { aspect } from './15-aspect.mjs';
|
|||||||
import { containerTab } from './16-container.mjs';
|
import { containerTab } from './16-container.mjs';
|
||||||
import { editorRun } from './17-editor-run.mjs';
|
import { editorRun } from './17-editor-run.mjs';
|
||||||
import { pause } from './18-pause.mjs';
|
import { pause } from './18-pause.mjs';
|
||||||
|
import { nestedBehavior } from './19-nested-behavior.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -41,4 +42,5 @@ export const STEPS = [
|
|||||||
containerTab,
|
containerTab,
|
||||||
editorRun,
|
editorRun,
|
||||||
pause,
|
pause,
|
||||||
|
nestedBehavior,
|
||||||
];
|
];
|
||||||
+12
-2
@@ -33,8 +33,18 @@ export const edge = (id, from, fromPort, to, toPort) => ({
|
|||||||
to: { node: to, port: toPort },
|
to: { node: to, port: toPort },
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A graph node; `params` holds settings and inline input values alike. */
|
/** A graph node; `params` holds settings and inline input values alike. `ports` only on start/end. */
|
||||||
export const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params });
|
export const node = (id, type, x, y, params = {}, ports) => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
params,
|
||||||
|
...(ports ? { ports } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A port a behavior's start or end node declares. */
|
||||||
|
export const port = (key, kind) => ({ key, kind });
|
||||||
|
|
||||||
/** A behavior variable declaration. */
|
/** A behavior variable declaration. */
|
||||||
export const variable = (key, kind, value) => ({ key, label: key, kind, default: value });
|
export const variable = (key, kind, value) => ({ key, label: key, kind, default: value });
|
||||||
Reference in new issue
Block a user