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:
1 parent
1d8e6ebea0
commit
c6f907b4e0
12 files changed
+427
-24
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user