A transition carries a name, so a state needs no visible port (#52)
game-maker / suite (push) Successful in 10m22s

A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.

Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.

Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.

`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.

The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.

One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-14 14:05:18 +02:00
1 parent a041322a0e
commit 68c92daaa6
27 files changed
+1186 -38

No files matched your search

+136 -6
View File
@@ -27,6 +27,8 @@ import {
portTypeOf,
resolveDef,
RUN_BEHAVIOR,
STATE_IN,
STATE_OUT,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
@@ -44,16 +46,19 @@ import {
endDrag,
onDragChange,
pressCount,
type MenuItem,
type Size,
Theme,
textStyle,
truncate,
ui,
Widget,
zoomFactor,
} from '../../ui';
import { type EditorState, nodeSelection } from '../state';
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
import { bezier, EdgeLayer } from './EdgeLayer';
import { arrowHead, bezier, EdgeLayer } from './EdgeLayer';
import { arrowShaft, type Point, transitionPreview } from './transitions';
import { openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
@@ -112,6 +117,10 @@ export class GraphCanvas extends Widget {
/** 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. */
private pendingDrop: { from: PortSide; pointer: { x: number; y: number } } | null = null;
/** A transition begun from a state's menu, waiting for the click that names its target. It takes
* the shape of a drag because it is one in everything but the held button – that way the running
* preview draws it without a second code path. `release` takes the Escape handler back off. */
private armed: { from: PortSide; pointer: { x: number; y: number }; release: () => void } | null = null;
/** Where the running drag would land, recomputed on every move so the drop holds no surprise. */
private dropTarget: PortSide | null = null;
/** Whether the last redraw actually stroked the dangling edge. */
@@ -173,6 +182,8 @@ export class GraphCanvas extends Widget {
/** Switching refits the view, since the next graph is a new subject. */
show(assetId: string): void {
if (this.assetId === assetId) return;
// Its source node is in the graph being left, so the click that finished it would land nowhere.
this.cancelTransition();
this.assetId = assetId;
this.sync();
this.needsFit = true;
@@ -216,6 +227,7 @@ export class GraphCanvas extends Widget {
if (!asset) {
// An asset that was deleted (or undone away) must not linger as a graph.
this.assetId = '';
this.cancelTransition();
for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear();
this.redraw();
@@ -317,6 +329,11 @@ export class GraphCanvas extends Widget {
return;
}
if (e.target !== this) return;
// Away from every state, so there is nothing to transition to: the click drops the attempt.
if (this.armed && (e.button === 0 || e.button === 2)) {
this.finishTransition(e.global.x, e.global.y, false);
return;
}
// Shift-drag on empty space pans as well, as it does in the scene view.
if (e.button === 0 && e.shiftKey) {
startPan();
@@ -346,6 +363,12 @@ export class GraphCanvas extends Widget {
if (e.button === 1) return;
if (!this.assetId) return;
e.stopPropagation();
// An armed transition eats the next click whole: on another state it lands, anywhere else it
// is dropped. Selecting or dragging the node underneath would be a second meaning for one click.
if (this.armed && (e.button === 0 || e.button === 2)) {
this.finishTransition(e.global.x, e.global.y, e.button === 0);
return;
}
const local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y);
if (e.button === 2) {
@@ -356,7 +379,13 @@ export class GraphCanvas extends Widget {
} else {
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id]));
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y);
const at = this.toLocal(e.global);
const graphPoint = this.screenToGraph(at.x, at.y);
const extras: MenuItem[] =
view.def.transitionEdges === true
? [{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }]
: [];
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras);
}
return;
}
@@ -446,6 +475,10 @@ export class GraphCanvas extends Widget {
this.connecting.pointer = g;
this.dropTarget = this.resolveDrop(e.global.x, e.global.y);
this.redraw();
} else if (this.armed) {
this.armed.pointer = g;
this.dropTarget = this.armedTarget(e.global.x, e.global.y);
this.redraw();
} else if (this.marquee) {
this.marquee.current = g;
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom;
@@ -607,6 +640,18 @@ export class GraphCanvas extends Widget {
return null;
}
/** Null unless the drag started on a state: only those draw as a straight arrow. */
private transitionPreviewOf(
from: PortSide,
target: PortSide | null,
pointer: Point,
): { start: Point; end: Point } | null {
const source = this.views.get(from.nodeId);
if (source?.def.transitionEdges !== true) return null;
const to = target ? this.views.get(target.nodeId) : undefined;
return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer);
}
private nodeUnder(globalX: number, globalY: number): string | null {
for (const view of this.topDownViews()) {
const p = view.toLocal({ x: globalX, y: globalY });
@@ -666,6 +711,59 @@ export class GraphCanvas extends Widget {
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
}
/** Begin a transition out of this state; the next click on another one completes it. */
private armTransition(nodeId: string, pointer: { x: number; y: number }): void {
this.cancelTransition();
const handlers = ui().focus.globalHandlers;
const onKey = (event: KeyboardEvent): boolean => {
if (event.key !== 'Escape' || !this.armed) return false;
this.cancelTransition();
this.redraw();
return true;
};
handlers.push(onKey);
this.armed = {
from: { nodeId, port: STATE_OUT, isOutput: true },
pointer,
release: () => {
const at = handlers.indexOf(onKey);
if (at >= 0) handlers.splice(at, 1);
},
};
this.dropTarget = null;
this.redraw();
}
private cancelTransition(): void {
this.armed?.release();
this.armed = null;
}
/**
* Where an armed transition would land: any other state under the pointer. A state cannot
* transition to itself, which is what `ConnectCommand` says about every node.
*/
private armedTarget(globalX: number, globalY: number): PortSide | null {
const from = this.armed?.from;
if (!from) return null;
const node = this.nodeUnder(globalX, globalY);
if (!node || node === from.nodeId) return null;
const view = this.views.get(node);
return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null;
}
/** Finish or abandon an armed transition. True when the click belonged to it. */
private finishTransition(globalX: number, globalY: number, connect: boolean): boolean {
const armed = this.armed;
if (!armed) return false;
const target = connect ? this.armedTarget(globalX, globalY) : null;
this.cancelTransition();
this.dropTarget = null;
if (target) this.connect(armed.from, target);
this.redraw();
return true;
}
/** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */
private setHint(text: string): void {
this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2);
@@ -688,7 +786,15 @@ export class GraphCanvas extends Widget {
return;
}
const playing = this.state.mode === 'play';
this.setHint(asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '');
this.setHint(
this.armed
? 'Click the state to transition to – anywhere else, or Escape, cancels'
: asset.graph.nodes.length === 0
? 'Right-click to add a node'
: playing
? 'live'
: '',
);
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
@@ -721,14 +827,21 @@ export class GraphCanvas extends Widget {
this.tempEdge.clear();
this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop;
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
const a = drag && this.anchorPoint(drag.from);
if (drag && a) {
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
// Dragged off a state, the line already looks like the transition it is about to become.
const preview = this.transitionPreviewOf(drag.from, target, drag.pointer);
const ends = preview ? [preview.start, preview.end] : [a, b];
const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point];
if (preview) {
const shaft = arrowShaft(from, to);
this.tempEdge.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y);
} else bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
if (preview) arrowHead(this.tempEdge, from, to, c.textMuted);
this.tempEdgeShown = true;
}
@@ -799,6 +912,23 @@ export class GraphCanvas extends Widget {
return this.edgeLayer.wasStroked(edgeId);
}
/** The name drawn on a transition, or null when nothing is drawn on it. */
transitionLabelDrawn(edgeId: string): string | null {
return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null;
}
/** Whether a pin can be grabbed on this node at all – false for a state, whose ports are hidden. */
portGrabbable(nodeId: string, port: string, isOutput: boolean): boolean {
const view = this.views.get(nodeId);
const p = view?.portPosition(port, isOutput);
return view !== undefined && p !== null && p !== undefined && view.portAt(p.x, p.y) !== null;
}
/** The line at the bottom of the canvas – what tells the user an armed transition is waiting. */
shownHint(): string {
return this.hint.text;
}
foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
return this.edgeLayer
.drawnMarks()