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

+62 -5
View File
@@ -1,6 +1,8 @@
import { Container, Graphics, Text } from 'pixi.js';
import { type GraphEdge, portsCompatible } from '../../core/graph';
import { Theme, textStyle, truncate } from '../../ui';
import { dashedLine } from '../../viewport/symbols';
import { arrowCorners, arrowShaft, type Point, transitionEnds, transitionOffsets } from './transitions';
import {
buildFoldMarks,
foldedEdgeShown,
@@ -17,6 +19,7 @@ import { type NodeView, portColor } from './NodeView';
const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
const TRANSITION_LABEL_WIDTH = 110;
const BEAD_RADIUS = 4.5;
/** Dash pattern and sampling of an edge hanging on a port that is gone. */
const GHOST_DASH = 7;
@@ -48,6 +51,9 @@ export class EdgeLayer extends Container {
/** Edges the last draw really stroked as a curve – a hidden one is missing from it. */
private readonly stroked = new Set<string>();
private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = [];
/** Sideways shift per transition, so two between the same states do not lie on top of each other. */
private offsets = new Map<string, number>();
private transitionLabels: { edgeId: string; text: string; x: number; y: number }[] = [];
constructor() {
super();
@@ -69,6 +75,12 @@ export class EdgeLayer extends Container {
this.ends = [];
this.labelCount = 0;
this.stroked.clear();
this.transitionLabels = [];
this.offsets = transitionOffsets(
edges
.filter((e) => isTransition(e, views))
.map((e) => ({ id: e.id, from: e.from.node, to: e.to.node })),
);
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected, beads(edge.id));
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
@@ -111,14 +123,25 @@ export class EdgeLayer extends Container {
const ghost = out?.ghost === true || inp?.ghost === true;
const valid = !ghost && portsCompatible(type, inType);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
const start = { x: from.x + a.x, y: from.y + a.y };
const end = { x: to.x + b.x, y: to.y + b.y };
const pins = {
start: { x: from.x + a.x, y: from.y + a.y },
end: { x: to.x + b.x, y: to.y + b.y },
};
const transition = isTransition(edge, views)
? transitionEnds(from, to, this.offsets.get(edge.id) ?? 0)
: null;
const { start, end } = transition ?? pins;
if (edge.folded) {
this.addFoldEnd(edge, views, start, true, to.def.label, color, hot);
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot);
// The fold mark belongs on the port, whatever the line between the nodes does.
this.addFoldEnd(edge, views, pins.start, true, to.def.label, color, hot);
this.addFoldEnd(edge, views, pins.end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
const shaft = transition ? arrowShaft(start, end) : end;
if (ghost && transition)
dashedLine(this.curves, start.x, start.y, shaft.x, shaft.y, GHOST_DASH, GHOST_GAP);
else if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
else if (transition) this.curves.moveTo(start.x, start.y).lineTo(shaft.x, shaft.y);
else bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
@@ -126,6 +149,8 @@ export class EdgeLayer extends Container {
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
});
if (transition) arrowHead(this.curves, start, end, color);
if (transition && edge.label) this.drawTransitionLabel(edge, start, end);
this.stroked.add(edge.id);
for (const progress of beads) {
const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress);
@@ -138,6 +163,23 @@ export class EdgeLayer extends Container {
}
}
/** The transition's name at the middle of its arrow, on a plate so the line does not run through it. */
private drawTransitionLabel(edge: GraphEdge, start: Point, end: Point): void {
const label = this.nextLabel();
label.text = truncate(edge.label ?? '', textStyle('small'), TRANSITION_LABEL_WIDTH);
const x = (start.x + end.x) / 2 - label.width / 2;
const y = (start.y + end.y) / 2 - label.height / 2;
label.position.set(x, y);
this.chips
.roundRect(x - 3, y - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.transitionLabels.push({ edgeId: edge.id, text: label.text, x, y });
}
drawnTransitionLabels(): readonly { edgeId: string; text: string; x: number; y: number }[] {
return this.transitionLabels;
}
private addFoldEnd(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
@@ -195,6 +237,21 @@ export class EdgeLayer extends Container {
}
}
function isTransition(edge: GraphEdge, views: ReadonlyMap<string, NodeView>): boolean {
return (
views.get(edge.from.node)?.def.transitionEdges === true &&
views.get(edge.to.node)?.def.transitionEdges === true
);
}
/** The filled triangle at the target end of a transition. */
export function arrowHead(g: Graphics, start: Point, end: Point, color: number): void {
const corners = arrowCorners(start, end);
if (!corners) return;
const [tip, left, right] = corners;
g.moveTo(tip.x, tip.y).lineTo(left.x, left.y).lineTo(right.x, right.y).closePath().fill(color);
}
/** Horizontal S-curve between two points. */
export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = curveReach(x1, x2);