Make a call node say why it does nothing, and easier to place

A behavior node that cannot run looked exactly like a healthy one: no
start node, no behavior picked, a deleted behavior or a self-call were
all silently skipped. The call still had to be forgiving at runtime -- a
dangling reference must not abort a game -- so the fix is diagnosis, not
an exception: describeProblem() names the one reason, the node draws it
in red under its title, and the inspector repeats it where you pick the
behavior. A missing end node is deliberately not a problem: a call is
always synchronous, and the end node only decides which values come back.

Two ways in: a behavior dragged from the asset panel lands in the graph
under the pointer, and double-clicking a call node opens the graph it
runs. Both reuse machinery that already existed -- the asset panel
already starts the drag, and list views already report double clicks.

The e2e module needed its assets at the head of the asset list: after 18
modules the list is long enough that a newly appended row scrolls out of
view, and the press then lands on the panel behind it, starting no drag
at all. That looked exactly like a rejected drop and cost two full runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-09 20:40:56 +02:00
1 parent 1d8e6ebea0
commit c6f907b4e0
12 files changed
+427 -24

No files matched your search

+8 -1
View File
@@ -128,6 +128,13 @@ shown on the node and editable in the inspector.
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.
Three ways in and out of a call: **drag** a behavior out of the asset panel onto the graph and it
lands as a call where you let go; **double-click** a call node to open the graph it runs. And a
call that cannot do anything **says so on the node** – no behavior picked, the behavior deleted,
or no start node in it. The last one is the one worth naming: without a start node there is no
way in, so the call is skipped and the flow simply carries on at its `exec` output. A missing
*end* node is not a defect – a call is always synchronous, "done" is the nested flow running out,
and the end node only decides what comes back.
- **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.
@@ -507,7 +514,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 # 379 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 387 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
```
+20 -3
View File
@@ -69,12 +69,29 @@ export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | u
}
/**
* Everything that changes a node's ports, as one string. Views and the inspector rebuild when it
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
* *different* graph, so it is the one node that can look perfectly healthy and still be dead.
*/
export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string | null {
if (node.type !== RUN_BEHAVIOR) return null;
const id = String(node.params[BEHAVIOR_PARAM] ?? '');
if (!id) return 'No behavior picked';
if (id === ownerId) return 'A behavior cannot run itself';
const program = lookup(id);
if (!program) return 'That behavior is gone';
if (!terminalNode(program.graph, BEHAVIOR_START)) return 'No start node – does nothing';
return null;
}
/**
* Everything that changes how a node looks, 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 {
export function portSignature(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): 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)}`;
return `${node.type}|${def.label}|${list(def.inputs)}|${list(def.outputs)}|${
callProblem(node, lookup, ownerId) ?? ''
}`;
}
+4 -1
View File
@@ -1,7 +1,7 @@
import { type Application, Color, type ColorSource, type Container, Rectangle, Text } from 'pixi.js';
import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project';
import { Button, ui, Widget } from '../ui';
import { activeDrag, Button, ui, Widget } from '../ui';
import { textureLibrary } from '../viewport/TextureLibrary';
import type { CentreTab, Editor } from './Editor';
import { currentImagePicker } from './texture/ImagePicker';
@@ -169,6 +169,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
dropPreview: () => viewport.dropInfo(),
/** An asset row by id – two assets can share a name with the container picker's label. */
/** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */
dragPayload: () => activeDrag(),
assetRow: (id: string) => assets.rowGlobal(id),
assetRowColor: (id: string) => assets.rowColor(id),
gameInfo: () => gameView.debugInfo(),
@@ -185,6 +187,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId),
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
canvasRect: () => graphEditor.canvas.canvasRect(),
+97 -2
View File
@@ -8,6 +8,8 @@ import {
MoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
BEHAVIOR_PARAM,
callProblem,
canConnect,
defAcceptsFrom,
dropTargetPort,
@@ -22,12 +24,27 @@ import {
portsCompatible,
portTypeOf,
resolveDef,
RUN_BEHAVIOR,
} 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';
import {
activeDrag,
clamp,
type DragPayload,
drawGrid,
endDrag,
Menu,
onDragChange,
type Size,
Theme,
textStyle,
Widget,
zoomFactor,
} from '../../ui';
import { pressCount } from '../../ui/clicks';
import { removeNodes } from '../actions';
import { type EditorState, nodeSelection } from '../state';
import { type Box, boxesInBand, frameBoxes } from './framing';
@@ -78,6 +95,8 @@ export class GraphCanvas extends Widget {
current: { x: number; y: number };
active: boolean;
} | null = null;
/** A behavior dragged in from the asset panel, and why it may not land. */
private incoming: { assetId: string; at: { x: number; y: number }; rejection: string } | null = null;
private connecting: { from: PortSide; pointer: { x: number; y: number } } | null = null;
/** A finished drag whose palette is still open – drawn like the running one, so the connection
* being made stays visible while the node type is chosen. */
@@ -114,6 +133,11 @@ export class GraphCanvas extends Widget {
e.preventDefault();
});
// A drag can end anywhere; without this the preview would outlive it.
onDragChange(() => {
if (!activeDrag()) this.setIncoming(null);
});
// Only the highlight of the selected node depends on the selection.
state.on('selection', () => this.redraw());
state.on('project', () => this.sync());
@@ -187,7 +211,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, def);
view.sync(node, asset.graph, asset, this.state.project, def, callProblem(node, lookup, asset.id));
}
for (const [id, view] of this.views) {
if (!seen.has(id)) {
@@ -302,6 +326,7 @@ export class GraphCanvas extends Widget {
this.state.toggleNode(this.assetId, view.node.id);
return;
}
if (pressCount(e, view.node.id) === 2 && this.openCalledBehavior(view.node)) return;
// Clicking a selected node keeps the selection so the whole group can be dragged.
if (!this.state.selectedNodeIds.includes(view.node.id))
this.state.select(nodeSelection(this.assetId, [view.node.id]));
@@ -314,6 +339,16 @@ export class GraphCanvas extends Widget {
this.nodeLayer.addChild(view); // raise
}
/** Selecting the asset is what opens it: the graph tab follows the selection. */
private openCalledBehavior(node: GraphNode): boolean {
if (node.type !== RUN_BEHAVIOR) return false;
const id = String(node.params[BEHAVIOR_PARAM] ?? '');
const asset = id ? findAsset(this.state.project, id) : undefined;
if (asset?.type !== 'behavior') return false;
this.state.select({ kind: 'asset', assetId: id });
return true;
}
private selectedNodes(): GraphNode[] {
const graph = this.asset()?.graph;
if (!graph) return [];
@@ -322,6 +357,11 @@ export class GraphCanvas extends Widget {
}
private onPointerMove(e: FederatedPointerEvent): void {
const dragged = activeDrag();
if (dragged) {
this.trackIncoming(dragged, e);
return;
}
if (this.panning) {
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
@@ -357,7 +397,49 @@ export class GraphCanvas extends Widget {
}
}
/**
* Follows a behavior dragged in from the asset panel. The node is centred under the pointer, so
* it lands where the ghost was rather than below and right of it.
*/
private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined;
if (!inside || !this.assetId || this.state.mode !== 'edit' || asset?.type !== 'behavior') {
this.setIncoming(null);
return;
}
const g = this.screenToGraph(local.x, local.y);
this.setIncoming({
assetId: asset.id,
at: { x: Math.round(g.x - NODE_WIDTH / 2), y: Math.round(g.y - TITLE_HEIGHT / 2) },
rejection: asset.id === this.assetId ? 'A behavior cannot run itself' : '',
});
}
private setIncoming(incoming: GraphCanvas['incoming']): void {
if (!incoming && !this.incoming) return;
this.incoming = incoming;
this.redraw();
}
private dropIncoming(drop: NonNullable<GraphCanvas['incoming']>): void {
if (drop.rejection) {
this.state.setNotice(drop.rejection);
return;
}
const entry = this.entries().find((it) => it.id === `${RUN_BEHAVIOR}:${drop.assetId}`);
if (entry) this.addNode(entry, drop.at);
}
private onPointerUp(e: FederatedPointerEvent): void {
const incoming = this.incoming;
if (incoming) {
this.setIncoming(null);
endDrag();
this.dropIncoming(incoming);
return;
}
this.panning = null;
const marquee = this.marquee;
this.marquee = null;
@@ -650,6 +732,15 @@ export class GraphCanvas extends Widget {
.fill({ color: c.marquee, alpha: 0.12 })
.stroke({ width: 1, color: c.marquee });
}
const drop = this.incoming;
if (drop) {
const a = this.graphToScreen(drop.at.x, drop.at.y);
const color = drop.rejection ? c.danger : c.nodeSelected;
this.band
.rect(a.x, a.y, NODE_WIDTH * this.zoom, NODE_HEIGHT_ESTIMATE * this.zoom)
.fill({ color, alpha: 0.15 })
.stroke({ width: 2, color });
}
}
/** A port's centre in graph coordinates. */
@@ -746,6 +837,10 @@ export class GraphCanvas extends Widget {
return this.views.get(nodeId)?.def.label ?? null;
}
nodeProblem(nodeId: string): string | null {
return this.views.get(nodeId)?.problemText ?? 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);
+23 -5
View File
@@ -74,6 +74,8 @@ export class NodeView extends Widget {
private readonly ports = new Graphics();
private readonly texts: Text[] = [];
private paramLines: string[] = [];
/** Why this node can do nothing; see `callProblem`. */
private problem: string | null = null;
selected = false;
hot = false;
/** Port a running edge drag would connect to, drawn with a ring so the drop is predictable. */
@@ -111,8 +113,10 @@ export class NodeView extends Widget {
asset: BehaviorAsset | null,
project: Project | null,
def: NodeDef,
problem: string | null = null,
): void {
this.def = def;
this.problem = problem;
this.paramLines = this.def.params
.map((p) => ({
label: p.label,
@@ -127,8 +131,7 @@ export class NodeView extends Widget {
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
const height =
TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
const height = this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
@@ -138,6 +141,17 @@ export class NodeView extends Widget {
return Math.max(this.def.inputs.length, this.def.outputs.length);
}
/** The warning line as drawn – checks read what the node really shows, not a recomputation. */
get problemText(): string | null {
return this.problem;
}
/** Everything above the first port row. The problem line sits in here, so ports move down with it. */
private headerHeight(): number {
const lines = this.paramLines.length + (this.problem ? 1 : 0);
return TITLE_HEIGHT + PAD + lines * ROW_HEIGHT;
}
/** Local position of a port's centre. */
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
const list = isOutput ? this.def.outputs : this.def.inputs;
@@ -147,7 +161,7 @@ export class NodeView extends Widget {
}
private rowY(index: number): number {
return TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
@@ -190,6 +204,10 @@ export class NodeView extends Widget {
add(line, 'small', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
if (this.problem) {
add(this.problem, 'warning', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
for (let i = 0; i < this.rowCount(); i++) {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
@@ -230,10 +248,10 @@ export class NodeView extends Widget {
this.box
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
.fill(categoryColor(this.def.category));
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder;
this.box
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
.stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
.stroke({ width: this.selected || this.hot || this.problem ? 2 : 1, color: border });
this.ports.clear();
const r = PORT_RADIUS;
+5 -1
View File
@@ -16,7 +16,11 @@ export function addTitle(host: InspectorHost, text: string): void {
host.body.add(new Label(text, { style: 'title' }));
}
export function addNote(host: InspectorHost, text: string, style: 'muted' | 'small' = 'muted'): void {
export function addNote(
host: InspectorHost,
text: string,
style: 'muted' | 'small' | 'warning' = 'muted',
): void {
host.body.add(new Label(text, { style }));
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
callProblem,
edgesInto,
type GraphNode,
inlineInputDef,
@@ -32,7 +33,7 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
.map((p) => p.key)
: [];
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}`).join(',');
return `${portSignature(node, lookup)}|${declared}|${connected.join(',')}|${asset.variables
return `${portSignature(node, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
.map((v) => v.key)
.join(',')}`;
}
@@ -48,6 +49,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
addTitle(host, `${def.category.toUpperCase()} NODE`);
host.body.add(new Label(def.label, { style: 'bodyBold' }));
addNote(host, def.description);
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
if (problem) addNote(host, problem, 'warning');
if (isTerminalNode(node)) buildPortSection(host, assetId, node);
if (def.params.length > 0) addTitle(host, 'SETTINGS');
+4
View File
@@ -73,6 +73,7 @@ export type TextStyleToken =
| 'muted'
| 'override'
| 'small'
| 'warning'
| 'title'
| 'mono'
| 'accent'
@@ -100,6 +101,9 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle {
case 'small':
style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.textMuted });
break;
case 'warning':
style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.danger });
break;
case 'title':
style = new TextStyle({
...base,
+66 -1
View File
@@ -1,13 +1,15 @@
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { AddNodeCommand, RemoveNodeCommand } from '../../src/core/commands/graphCommands';
import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
callProblem,
type Graph,
GraphInterpreter,
portSignature,
RUN_BEHAVIOR,
resolveDef,
terminalNode,
@@ -180,6 +182,69 @@ describe('a behavior used as a node', () => {
});
});
describe('a call that cannot do anything says so', () => {
it('names each of the four silent cases, and stays quiet about a healthy call', () => {
const fixture = makeProject();
const lookup = () => behaviorLookup(fixture.project);
const callee = doubler(fixture);
const caller = makeBehavior(fixture, 'Caller');
const edit = graphEditor(fixture, caller);
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: '' });
const problem = () => callProblem(call, lookup(), caller.id);
expect(problem()).toBe('No behavior picked');
call.params[BEHAVIOR_PARAM] = caller.id;
expect(problem()).toBe('A behavior cannot run itself');
call.params[BEHAVIOR_PARAM] = 'gone';
expect(problem()).toBe('That behavior is gone');
call.params[BEHAVIOR_PARAM] = makeBehavior(fixture, 'Empty').id;
expect(problem()).toBe('No start node – does nothing');
call.params[BEHAVIOR_PARAM] = callee.id;
expect(problem()).toBe(null);
// Only a call can be dead this way; no other node type may be marked.
expect(callProblem(edit.addNode('OnStart'), lookup(), caller.id)).toBe(null);
});
it('shows up in what the node view redraws on, or it would be drawn once and never again', () => {
const fixture = makeProject();
// A callee that declares no ports at all: losing its start node changes nothing about the
// calling node except that it is now dead. Anything with ports would move the signature by
// itself and prove nothing.
const callee = makeBehavior(fixture, 'Bare');
const start = graphEditor(fixture, callee).addNode(BEHAVIOR_START);
const caller = makeBehavior(fixture, 'Caller');
const call = graphEditor(fixture, caller).addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const signature = () => portSignature(call, behaviorLookup(fixture.project), caller.id);
const healthy = signature();
fixture.stack.execute(new RemoveNodeCommand(callee.id, start.id));
expect(resolveDef(call, behaviorLookup(fixture.project))!.label).toBe('Bare');
expect(signature()).not.toBe(healthy);
});
it('without an end node the call is still a complete one: it just returns nothing', () => {
const fixture = makeProject();
const callee = makeBehavior(fixture, 'NoEnd');
const cEdit = graphEditor(fixture, callee);
const start = cEdit.addNode(BEHAVIOR_START);
cEdit.connect(start, 'exec', cEdit.addNode('Log', { message: 'sub' }), 'exec');
const caller = makeBehavior(fixture, 'Caller');
const edit = graphEditor(fixture, caller);
const onStart = edit.addNode('OnStart');
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
edit.wire([
[onStart, 'exec', call, 'exec'],
[call, 'exec', edit.addNode('Log', { message: 'after' }), 'exec'],
]);
// A missing end node is not a defect, so nothing is reported.
expect(callProblem(call, behaviorLookup(fixture.project), caller.id)).toBe(null);
// "Done" is the nested flow running out: the sub graph is finished before the caller goes on.
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']);
});
});
describe('declared ports', () => {
it('renaming one rewrites the edges in this graph and in every calling one', () => {
const fixture = makeProject();
@@ -0,0 +1,109 @@
Ein paar Fragen zu behavior als node in einem graph:
- wenn es keinen Start oder End node gibt, kann man trotztdem das behaviour in einem graph hinzufügen, aber wie läuft dann der flow? (vorallem wenn es keinen start node gibt)
- ohne end node: woher weiß der parent graph (behavior) dass das child (sub behavior) "fertig" ist?
- drag and drop vom asset inspector wäre auch gut als spwan im graph, ist das möglich?
- doppelklick auf node (type behavior) um von dem asset behavior den graph zu öffnen wäre noch gut
---
## Beantwortet und umgesetzt
Deine ersten zwei Punkte waren Fragen, die letzten zwei Wuensche. Die Antworten standen schon
im Code — der Mangel war, dass **du sie nicht sehen konntest**. Genau das ist jetzt behoben.
### 1. Ohne Start-Node: wie laeuft der Flow?
**Gar nicht — der Aufruf wird uebersprungen, und der Fluss laeuft am `exec`-Ausgang weiter.**
Ohne Start-Node gibt es keinen Eingang in den Sub-Graphen; der Interpreter bricht den Aufruf ab
(`if (!start) return {}`) und macht beim Aufrufer weiter. Dasselbe gilt, wenn gar kein Behavior
gewaehlt ist oder das gewaehlte geloescht wurde. Das ist Absicht: ein toter Verweis soll kein
Spiel abbrechen.
**Das war aber still, und still ist hier falsch.** Es ist derselbe Fehlgriff wie damals beim
`Run in editor`-Schalter: der Node sah gesund aus und tat nichts. Ein Aufruf, der nichts tun
*kann*, sagt es jetzt selbst — rote Zeile auf dem Node, roter Rand, dieselbe Zeile im Inspector:
| Lage | was der Node sagt |
|---|---|
| kein Behavior gewaehlt | `No behavior picked` |
| Behavior geloescht | `That behavior is gone` |
| Behavior ohne Start-Node | `No start node – does nothing` |
| Behavior zeigt auf sich selbst | `A behavior cannot run itself` |
Einfuegen kann man ein Behavior ohne Start-Node weiterhin — das ist der normale Weg beim Bauen
(erst den Aufruf setzen, dann den Sub-Graphen fuellen). Es sieht nur nicht mehr fertig aus.
### 2. Ohne End-Node: woher weiss der Parent, dass das Child fertig ist?
**Die Frage stellt sich nicht: ein Aufruf ist immer synchron.** Der aufrufende Node kehrt erst
zurueck, wenn der Fluss im Sub-Graphen ausgelaufen ist — "fertig" ist kein Signal, sondern das
Ende der Ausfuehrung. Der End-Node hat damit nichts zu tun; er entscheidet nur, **welche Werte**
zurueckkommen. Ohne ihn kommt schlicht nichts zurueck, alles andere ist unveraendert.
Deshalb wird ein fehlender End-Node auch **nicht** als Mangel angezeigt — das waere eine
Warnung vor etwas Erlaubtem. Ein eigener Test haelt das fest: der Sub-Graph loggt `sub`, der
Aufrufer danach `after`, und das Ergebnis ist `['sub', 'after']` — nicht umgekehrt und nicht
verschraenkt.
### 3. Drag & Drop aus dem Asset-Panel in den Graphen
**Gebaut.** Ein Behavior aus der Asset-Liste in den Graphen gezogen wird zu einem Aufruf-Node,
und zwar **dort, wo du losgelassen hast** (unter dem Zeiger zentriert, nicht rechts-unter ihm).
Waehrend des Ziehens zeigt ein Umriss die Landestelle. Ins **eigene** Behavior gezogen wird
abgelehnt — mit Meldung, statt einen Node zu erzeugen, der beim Laufen einen Fehler wirft.
Das war billig, weil der Mechanismus schon existierte: `startDrag` feuert im Asset-Panel
laengst fuer jedes platzierbare Asset, und die Szenen-Ansicht ist bereits ein Drop-Ziel. Neu
ist nur, dass der Graph-Canvas jetzt auch eines ist.
### 4. Doppelklick auf einen Behavior-Node oeffnet dessen Graph
**Gebaut.** Doppelklick auf einen Aufruf-Node zeigt den Graphen des Behaviors, das er ausfuehrt.
Auch das nutzt vorhandene Mechanik (`pressCount`, dieselbe Erkennung wie in den Listen); der
Node waehlt einfach das Asset aus, und der Graph-Tab folgt der Selektion.
Zusammen mit Punkt 1 schliesst sich damit ein Kreis: der Node sagt *"No start node"*, ein
Doppelklick bringt dich in den Graphen, und dort legst du den Start-Node an.
## Belegt
```
PASS a behavior dragged from the asset panel becomes a call node
PASS and it lands under the pointer, not at some default spot
PASS a behavior without a start node can be added just the same
PASS and the node says why it does nothing, instead of failing silently – "No start node – does nothing"
PASS a healthy call carries no such warning
PASS dropping a behavior into its own graph is refused, out loud
PASS double-clicking a call node opens the behavior it runs
```
Gegenprobe gefahren: ohne die Diagnose fallen beide neuen Unit-Tests. Der zweite war beim
ersten Versuch wertlos — er wollte zeigen, dass der Node **neu gezeichnet** wird, wenn der
Mangel entsteht, prueft das aber an einem Behavior mit Ports, wo sich die Signatur ohnehin
aendert. Jetzt haengt er an einem Behavior ganz ohne Ports, wo sich wirklich nur der Mangel
aendert.
| | |
|---|---|
| volle Suite, remote | **ALL PASSED – 387 Checks** (379 + 8 neue), 0 FAIL |
| `npm run check` lokal | gruen, **527 Tests** (+3) |
## Ein Fehlschlag, der zweimal dasselbe war
Der erste Drag klappte, die beiden folgenden nicht — und zwar **nur im vollen Lauf**, allein
war das Modul gruen. Ich habe zuerst die Drop-Punkte verdaechtigt (falsch: sie lagen in der
Flaeche) und dann die Reihenfolge der Module (falsch: die letzten drei reichten nicht zum
Reproduzieren). Erst eine Messung in der Check-Nachricht hat es gezeigt: die Asset-Zeile lag
bei **y = 449** statt 293 — nach 18 Modulen ist die Liste so lang, dass die zuletzt
angehaengten Behaviors **aus dem sichtbaren Bereich gescrollt** waren. Der Druck landete auf
dem Panel dahinter, es startete gar kein Drag, und das sah exakt aus wie eine Ablehnung.
Die Testdaten liegen jetzt am **Anfang** der Liste. `__gm.dragPayload()` als neuer Hook
unterscheidet die beiden Faelle kuenftig direkt: *abgelehnt* hat einen Drag, *nie angekommen*
keinen.
Ein Reparaturversuch am Test-Hook selbst (`rowGlobal` sollte `null` liefern, wenn der
Scroll-Container die Zeile abschneidet) habe ich **wieder zurueckgenommen** — meine Pruefung
ueber die Masken-Bounds lieferte `null` auch fuer klar sichtbare Zeilen und hat den Lauf
sofort im ersten Modul zerlegt. Lieber ein Hook, der schweigt, als einer, der falsch redet.
-6
View File
@@ -1,6 +0,0 @@
Ein paar Fragen zu behavior als node in einem graph:
- wenn es keinen Start oder End node gibt, kann man trotztdem das behaviour in einem graph hinzufügen, aber wie läuft dann der flow? (vorallem wenn es keinen start node gibt)
- ohne end node: woher weiß der parent graph (behavior) dass das child (sub behavior) "fertig" ist?
- drag and drop vom asset inspector wäre auch gut als spwan im graph, ist das möglich?
- doppelklick auf node (type behavior) um von dem asset behavior den graph zu öffnen wäre noch gut
+87 -3
View File
@@ -74,13 +74,16 @@ export async function nestedBehavior(t) {
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const mover = p.assets.find((a) => a.id === 'mover');
// At the front of the list, not the end: these get dragged out of the asset panel below, and
// by this point in the suite the list is long enough to scroll a row at the end out of reach.
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);
p.assets.unshift(a);
};
behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)});
behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)});
behavior('nb-empty', 'Empty', { nodes: [], edges: [] });
const make = (id, assetId, parentId, name) => {
const o = structuredClone(proto);
Object.assign(o, {
@@ -204,12 +207,93 @@ export async function nestedBehavior(t) {
await undo();
await sleep(200);
// 19g. Clean up: the two behaviors and the box are gone again.
// 19g. Dragging a behavior out of the asset panel drops a call into the graph, where the
// pointer let go of it.
const newNodeIn = async (before) =>
(await asset('nb-caller')).graph.nodes.find((n) => !before.includes(n.id));
const beforeDrag = await nodeIds();
// Points taken from the drawing area itself: an offset from the centre can fall outside it,
// and a drop that never arrives looks exactly like one that was refused.
const rect = await json('__gm.graph.canvasRect()');
const spot = (fx, fy) => ({
x: Math.round(rect.x + rect.w * fx),
y: Math.round(rect.y + rect.h * fy),
});
const dropAt = spot(0.25, 0.75);
await t.dragAsset('nb-double', dropAt.x, dropAt.y);
await sleep(300);
const dropped = await newNodeIn(beforeDrag);
check(
'a behavior dragged from the asset panel becomes a call node',
dropped?.type === 'RunBehavior' && dropped?.params.behavior === 'nb-double',
JSON.stringify(dropped && { type: dropped.type, params: dropped.params }),
);
const droppedAt = dropped && (await json(`__gm.graph.nodeAt(${JSON.stringify(dropped.id)})`));
check(
'and it lands under the pointer, not at some default spot',
Boolean(droppedAt) && Math.hypot(droppedAt.x - dropAt.x, droppedAt.y - dropAt.y) < 20,
`dropped at ${JSON.stringify(droppedAt)}, let go at ${JSON.stringify(dropAt)}`,
);
// 19h. The question from the note: a behavior with no start node can still be added – and now it
// says why nothing happens instead of sitting there looking healthy.
const beforeEmpty = await nodeIds();
const emptyRow = await json(`__gm.assetRow('nb-empty')`);
await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y);
await sleep(300);
const emptyCall = await newNodeIn(beforeEmpty);
check(
'a behavior without a start node can be added just the same',
emptyCall?.params.behavior === 'nb-empty',
`${JSON.stringify(emptyCall && { type: emptyCall.type, params: emptyCall.params })} row=${JSON.stringify(emptyRow)}`,
);
const problem = await evaluate(`__gm.graph.nodeProblem(${JSON.stringify(emptyCall?.id ?? '')})`);
check(
'and the node says why it does nothing, instead of failing silently',
problem === 'No start node – does nothing',
`problem=${JSON.stringify(problem)}`,
);
check(
'a healthy call carries no such warning',
(await evaluate(`__gm.graph.nodeProblem('nb-call')`)) === null,
);
await shot('39-call-problem');
// 19i. Dropping a behavior into its own graph is refused – and says so, rather than quietly
// adding a node that would throw the moment it ran.
const beforeSelf = await nodeIds();
const selfRow = await json(`__gm.assetRow('nb-caller')`);
await t.dragAsset('nb-caller', spot(0.45, 0.5).x, spot(0.45, 0.5).y);
await sleep(300);
const afterSelf = await nodeIds();
const { notice } = await t.state();
// `dragging` tells a refusal apart from a drag that never arrived – both leave the graph alone.
const dragging = await json('__gm.dragPayload()');
check(
'dropping a behavior into its own graph is refused, out loud',
afterSelf.length === beforeSelf.length && notice === 'A behavior cannot run itself',
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
);
// 19j. Double-clicking a call opens the behavior it runs – the other half of the note.
const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
await t.doubleClick(callNode.x, callNode.y);
await sleep(400);
const opened = await evaluate('__gm.graph.current()');
check(
'double-clicking a call node opens the behavior it runs',
opened === 'nb-double',
`current=${JSON.stringify(opened)}`,
);
// 19k. Clean up: the three 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');
const mine = ['nb-double', 'nb-caller', 'nb-empty'];
p.assets = p.assets.filter((a) => !mine.includes(a.id));
scene.objects = scene.objects.filter((o) => o.id !== 'nb-box' && o.id !== 'nb-logic');
`);
await sleep(300);