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:
root committed 2026-09-09 20:01:49 +02:00
1 parent 64e034ffb9
commit 6702ff8abe
31 files changed
+1402 -107

No files matched your search

+13 -2
View File
@@ -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
from any object.
- **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
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`,
@@ -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:
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.
- **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
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.
@@ -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 build # writes dist/ and regenerates index.html
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-tetris.mjs && node tools/smoke-tetris.mjs # 17 checks: the Tetris example, played end to end
```
+11 -3
View File
@@ -6,7 +6,9 @@ import {
type GraphNode,
portsCompatible,
} from '../graph/Graph';
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
import { nodeDef } from '../graph/NodeRegistry';
import { behaviorLookup } from '../model/behaviorPrograms';
import { newId } from '../ids';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
@@ -42,7 +44,12 @@ export class AddNodeCommand implements Command {
execute(project: Project): void {
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 {
@@ -153,8 +160,9 @@ export class ConnectCommand implements Command {
const toNode = findNode(graph, this.edge.to.node);
if (!fromNode || !toNode) throw new CommandError('Node not found');
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 inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
const lookup = behaviorLookup(project);
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 (!portsCompatible(out.type, inp.type))
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
+195
View File
@@ -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
View File
@@ -18,6 +18,24 @@ export interface GraphNode {
x: number;
y: number;
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 {
+81 -24
View File
@@ -1,6 +1,14 @@
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 { hasExecInput, inlineValue, isEventNode, type RepeatResult, requireNodeDef } from './NodeRegistry';
import { type CallResult, hasExecInput, inlineValue, isEventNode, type RepeatResult } from './NodeRegistry';
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. */
@@ -18,6 +26,17 @@ export interface TraceListener {
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
* 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 {
/** Per-node state that persists across pulses (timer accumulators etc.). */
readonly nodeState = new Map<string, Record<string, number>>();
private outputCache = new Map<string, Record<string, Value>>();
/** Kept apart from `outputCache` because a loop round has to forget exactly these again. */
private dataCache = new Map<string, Record<string, Value>>();
/** Assets run as a node so far – the graph editor highlights them from this instance's trace. */
readonly calledBehaviors = new Set<string>();
private frame: Frame;
private callStack: string[] = [];
private steps = 0;
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[] {
return this.graph.nodes.filter((n) => n.type === type);
@@ -48,10 +73,10 @@ export class GraphInterpreter {
let fired = 0;
for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue;
this.outputCache = new Map();
this.dataCache = new Map();
this.frame = emptyFrame(this.graph);
this.callStack = [];
this.steps = 0;
this.outputCache.set(node.id, { ...outputs });
this.frame.outputCache.set(node.id, { ...outputs });
this.trace?.onNode(node.id);
this.follow(node, 'exec', ctx, 0);
fired++;
@@ -60,7 +85,7 @@ export class GraphInterpreter {
}
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);
if (!target) continue;
this.trace?.onEdge(edge.id);
@@ -73,41 +98,68 @@ export class GraphInterpreter {
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
if (depth > MAX_DEPTH)
throw new Error('Behavior graph nested too deeply (a flow feeding back on itself?)');
const def = requireNodeDef(node.type);
if (!def.run) return;
const def = this.def(node);
if (!def?.run) return;
const inputs = this.collectInputs(node, ctx);
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);
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
for (const port of next) this.follow(node, port, ctx, depth);
}
/**
* 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
* 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.
*/
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++) {
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
this.dataCache.clear();
this.outputCache.set(node.id, { ...outputs, [repeat.index]: round });
this.frame.dataCache.clear();
this.frame.outputCache.set(node.id, { ...outputs, [repeat.index]: round });
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. */
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
const def = requireNodeDef(node.type);
const inputs: Record<string, Value> = {};
for (const port of def.inputs) {
for (const port of this.def(node)?.inputs ?? []) {
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);
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. */
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;
const source = this.node(nodeId);
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.
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
if (++this.steps > MAX_STEPS_PER_FIRE)
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// 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 ?? {};
this.dataCache.set(nodeId, outputs);
this.frame.dataCache.set(nodeId, outputs);
this.trace?.onNode(nodeId);
return outputs[port] ?? null;
}
private def(node: GraphNode) {
return resolveDef(node, this.behaviors);
}
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);
}
}
+8
View File
@@ -24,6 +24,12 @@ export interface RepeatResult {
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 {
/** Cached for the rest of the pulse. */
outputs?: Record<string, Value>;
@@ -31,6 +37,8 @@ export interface RunResult {
next?: string | string[];
/** Runs `port` that many times before continuing along `next`. */
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. */
+15 -9
View File
@@ -1,5 +1,6 @@
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. */
export interface PortSide {
@@ -8,18 +9,18 @@ export interface PortSide {
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 def = node && nodeDef(node.type);
const def = node && resolveDef(node, lookup);
const list = side.isOutput ? def?.outputs : def?.inputs;
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. */
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;
const fromType = portTypeOf(graph, from);
const toType = portTypeOf(graph, to);
const fromType = portTypeOf(graph, from, lookup);
const toType = portTypeOf(graph, to, lookup);
if (fromType === undefined || toType === undefined) return false;
const [out, inp] = from.isOutput ? [fromType, toType] : [toType, fromType];
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
* 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;
const node = findNode(graph, nodeId);
const def = node && nodeDef(node.type);
const fromType = portTypeOf(graph, from);
const def = node && resolveDef(node, lookup);
const fromType = portTypeOf(graph, from, lookup);
if (!def || fromType === undefined) return null;
const candidates = compatiblePorts(def, fromType, from.isOutput)
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type }))
+1
View File
@@ -3,6 +3,7 @@ import './nodes';
export * from './dropTarget';
export * from './Graph';
export * from './nested';
export * from './NodeContext';
export * from './NodeRegistry';
export * from './values';
+80
View File
@@ -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)}`;
}
+21
View File
@@ -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: [],
};
+24
View File
@@ -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',
}),
};
+6
View File
@@ -65,8 +65,11 @@ import { OnKeyUp } from './events/OnKeyUp';
import { OnStart } from './events/OnStart';
import { OnTimer } from './events/OnTimer';
import { OnUpdate } from './events/OnUpdate';
import { BehaviorEnd } from './flow/BehaviorEnd';
import { BehaviorStart } from './flow/BehaviorStart';
import { Branch } from './flow/Branch';
import { Repeat } from './flow/Repeat';
import { RunBehavior } from './flow/RunBehavior';
import { Sequence } from './flow/Sequence';
import { GetVariable } from './variable/GetVariable';
import { SetVariable } from './variable/SetVariable';
@@ -87,10 +90,13 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnCollisionStay,
OnCollisionExit,
OnClick,
BehaviorStart,
// flow
Branch,
Sequence,
Repeat,
RunBehavior,
BehaviorEnd,
// variables
GetVariable,
SetVariable,
+15
View File
@@ -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) };
};
}
+29 -1
View File
@@ -1,5 +1,15 @@
// 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 { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
@@ -166,6 +176,22 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
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 {
const g = asRecord(raw);
const graph = createEmptyGraph();
@@ -182,6 +208,8 @@ function normalizeGraph(raw: unknown): Graph {
params[key] = value;
}
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);
}
const edgeIds = new Set<string>();
+1
View File
@@ -184,6 +184,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
portAt: (nodeId: string, port: string, isOutput: boolean) =>
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
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`. */
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
canvasRect: () => graphEditor.canvas.canvasRect(),
+50 -16
View File
@@ -16,14 +16,15 @@ import {
firstPortFor,
type GraphEdge,
type GraphNode,
type NodeDef,
nodeDef,
type BehaviorLookup,
type PortSide,
type PortType,
portsCompatible,
portTypeOf,
resolveDef,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset } from '../../core/model/Project';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { 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 Box, boxesInBand, frameBoxes } from './framing';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
const GRID_STEP = 24;
@@ -140,8 +142,8 @@ export class GraphCanvas extends Widget {
addNodeFrom(anchor: Widget): void {
if (!this.assetId || this.state.mode !== 'edit') return;
const centre = this.screenToGraph(this.w / 2, this.h / 2);
new NodePalette((def: NodeDef) =>
this.addNode(def, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
new NodePalette(this.entries(), (entry) =>
this.addNode(entry, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
).openBelow(anchor);
}
@@ -151,6 +153,10 @@ export class GraphCanvas extends Widget {
// --- model binding ----------------------------------------------------------------------
private behaviors(): BehaviorLookup {
return behaviorLookup(this.state.project);
}
private asset(): BehaviorAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'behavior' ? asset : null;
@@ -168,8 +174,9 @@ export class GraphCanvas extends Widget {
return;
}
const seen = new Set<string>();
const lookup = behaviorLookup(this.state.project);
for (const node of asset.graph.nodes) {
const def = nodeDef(node.type);
const def = resolveDef(node, lookup);
if (!def) continue;
seen.add(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.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) {
if (!seen.has(id)) {
@@ -425,13 +432,14 @@ export class GraphCanvas extends Widget {
): void {
const graph = this.asset()?.graph;
const from = drag.from;
const fromType = graph && portTypeOf(graph, from);
const fromType = graph && portTypeOf(graph, from, this.behaviors());
if (!graph || fromType === undefined) return;
const local = this.toLocal({ x: globalX, y: globalY });
const at = this.screenToGraph(local.x, local.y);
this.pendingDrop = drag;
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),
);
palette.onClose = () => {
@@ -449,10 +457,11 @@ export class GraphCanvas extends Widget {
const graph = this.asset()?.graph;
const from = this.connecting?.from;
if (!graph || !from) return null;
const lookup = this.behaviors();
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);
return node ? dropTargetPort(graph, from, node) : null;
return node ? dropTargetPort(graph, from, node, lookup) : null;
}
private portUnder(globalX: number, globalY: number): PortSide | null {
@@ -483,25 +492,42 @@ export class GraphCanvas extends Widget {
// --- menus -------------------------------------------------------------------------------
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;
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]));
}
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
private addConnectedNode(
def: NodeDef,
entry: PaletteEntry,
from: PortSide,
fromType: PortType,
at: { x: number; y: number },
): void {
if (!this.assetId) return;
const add = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
const port = firstPortFor(def, fromType, from.isOutput);
const add = new AddNodeCommand(
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 [out, inp] = from.isOutput ? [from, to] : [to, from];
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. */
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);
+18 -16
View File
@@ -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 type { PaletteEntry } from './paletteEntries';
const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action'];
@@ -8,7 +9,7 @@ export class NodePalette extends Popup {
private readonly search: TextField;
private readonly list = new ListView();
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). */
private picked = false;
@@ -17,7 +18,8 @@ export class NodePalette extends Popup {
* could actually connect to.
*/
constructor(
private readonly onPick: (def: NodeDef) => void,
private readonly entries: PaletteEntry[],
private readonly onPick: (entry: PaletteEntry) => void,
private readonly accepts: (def: NodeDef) => boolean = () => true,
) {
super({ padding: 4, gap: 4 });
@@ -39,8 +41,8 @@ export class NodePalette extends Popup {
this.list.rowHeight = Theme.rowHeight - 2;
this.list.emptyText = 'No matching node';
this.list.onSelect = (id) => {
const def = this.filtered.find((d) => d.type === id);
if (def) this.pick(def);
const entry = this.filtered.find((e) => e.id === id);
if (entry) this.pick(entry);
};
this.refresh();
}
@@ -53,28 +55,28 @@ export class NodePalette extends Popup {
private refresh(): void {
const query = this.search.value.trim().toLowerCase();
this.filtered = allNodeDefs()
.filter(this.accepts)
this.filtered = this.entries
.filter((e) => this.accepts(e.def))
.filter(
(d) =>
({ def }) =>
!query ||
d.label.toLowerCase().includes(query) ||
d.type.toLowerCase().includes(query) ||
d.description.toLowerCase().includes(query),
def.label.toLowerCase().includes(query) ||
def.type.toLowerCase().includes(query) ||
def.description.toLowerCase().includes(query),
)
.sort(
(a, b) =>
CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) ||
a.label.localeCompare(b.label),
CATEGORY_ORDER.indexOf(a.def.category) - CATEGORY_ORDER.indexOf(b.def.category) ||
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();
}
private pick(def: NodeDef): void {
private pick(entry: PaletteEntry): void {
if (this.picked) return;
this.picked = true;
this.close();
this.onPick(def);
this.onPick(entry);
}
}
+26 -6
View File
@@ -81,14 +81,21 @@ export class NodeView extends Widget {
/** Data inputs that currently have a connection (their inline value is hidden). */
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(
readonly node: GraphNode,
readonly def: NodeDef,
def: NodeDef,
) {
super();
this.def = def;
this.cursor = 'grab';
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. */
@@ -98,10 +105,23 @@ export class NodeView extends Widget {
}
/** `project` resolves asset references (Spawn, Is asset). */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
this.paramLines = this.def.params.map(
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
);
sync(
node: GraphNode,
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(
inlineInputs(this.def)
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
+46
View File
@@ -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;
}
+23 -7
View File
@@ -1,26 +1,40 @@
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 { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection } from './portSection';
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 {
const asset = findAsset(project, assetId);
if (!asset || asset.type !== 'behavior') return 'missing';
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const def = nodeDef(node.type);
const lookup = behaviorLookup(project);
const def = resolveDef(node, lookup);
const connected = def
? inlineInputs(def)
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
.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. */
@@ -28,12 +42,13 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
const asset = findAsset(host.state.project, assetId);
if (!asset || asset.type !== 'behavior') return;
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;
addTitle(host, `${def.category.toUpperCase()} NODE`);
host.body.add(new Label(def.label, { style: 'bodyBold' }));
addNote(host, def.description);
if (isTerminalNode(node)) buildPortSection(host, assetId, node);
if (def.params.length > 0) addTitle(host, 'SETTINGS');
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). */
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
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;
for (const [key, row] of host.fields) {
const [scope, name] = key.split(':') as ['param' | 'input', string];
const port = def.inputs.find((p) => p.key === name);
const fallback =
scope === 'param'
? 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 ?? '');
}
return true;
+85
View File
@@ -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);
}
}
+7 -3
View File
@@ -5,6 +5,7 @@ import {
type NodeContext,
type TraceListener,
} from '../../core/graph';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import type { Entity } from '../Entity';
@@ -78,7 +79,7 @@ export class BehaviorSystem implements System {
const asset = findAsset(this.project, entity.assetId);
if (!asset || asset.type !== 'behavior') continue;
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 = {
onEdge: (id) => trace.edges.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);
}
/** 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 {
const merged: Trace = { edges: new Map(), nodes: new Map() };
const latest = (into: Map<string, number>, from: Map<string, number>) => {
for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity));
};
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.nodes, instance.trace.nodes);
}
+9 -8
View File
@@ -18,6 +18,7 @@ import {
dropTargetPort,
findNode,
firstPortFor,
NO_BEHAVIORS,
type PortType,
portsCompatible,
requireNodeDef,
@@ -512,7 +513,7 @@ describe('dropping an edge on a node', () => {
it('picks the first compatible input', () => {
const { graph, number, clamp } = fixtureWithClamp();
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,
port: 'value',
isOutput: false,
@@ -524,7 +525,7 @@ describe('dropping an edge on a node', () => {
const other = edit.addNode('Number');
edit.connect(other, 'value', clamp, 'value');
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', () => {
@@ -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);
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.
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', () => {
@@ -543,26 +544,26 @@ describe('dropping an edge on a node', () => {
const move = edit.addNode('SetPosition');
edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec');
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', () => {
const { graph, edit, number, clamp } = fixtureWithClamp();
const boolean = edit.addNode('Boolean');
const from = { nodeId: number.id, port: 'value', isOutput: true };
expect(dropTargetPort(graph, from, boolean.id)).toBeNull();
expect(dropTargetPort(graph, from, number.id)).toBeNull();
expect(dropTargetPort(graph, from, boolean.id, NO_BEHAVIORS)).toBeNull();
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toBeNull();
// A pair that is already wired is not offered again either.
edit.connect(number, 'value', clamp, 'value');
edit.connect(number, 'value', clamp, 'min');
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', () => {
const { graph, number, clamp } = fixtureWithClamp();
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,
port: 'value',
isOutput: true,
+241
View File
@@ -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;
}
}
+26
View File
@@ -20,6 +20,8 @@ interface Case {
next?: string | string[] | null;
/** Expected loop request; `null` asserts the node does not loop. */
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. */
after?(ctx: FakeContext): void;
}
@@ -45,6 +47,28 @@ const CASES: Record<string, Case[]> = {
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
],
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: [
{
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.
*/
const EVENT_NODES: Record<string, string> = {
BehaviorStart: 'nested-behavior.test.ts',
OnStart: 'graph.test.ts',
OnUpdate: 'graph.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.next !== undefined) expect(result.next ?? null).toEqual(c.next);
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);
});
}
+104
View File
@@ -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** |
-10
View File
@@ -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 :)
+216
View File
@@ -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);
}
+2
View File
@@ -21,6 +21,7 @@ import { aspect } from './15-aspect.mjs';
import { containerTab } from './16-container.mjs';
import { editorRun } from './17-editor-run.mjs';
import { pause } from './18-pause.mjs';
import { nestedBehavior } from './19-nested-behavior.mjs';
export const STEPS = [
hierarchy,
@@ -41,4 +42,5 @@ export const STEPS = [
containerTab,
editorRun,
pause,
nestedBehavior,
];
+12 -2
View File
@@ -33,8 +33,18 @@ export const edge = (id, from, fromPort, to, toPort) => ({
to: { node: to, port: toPort },
});
/** A graph node; `params` holds settings and inline input values alike. */
export const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params });
/** A graph node; `params` holds settings and inline input values alike. `ports` only on start/end. */
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. */
export const variable = (key, kind, value) => ({ key, label: key, kind, default: value });