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>
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
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,
|
||
type FoldMarkEnd,
|
||
foldMarkAt,
|
||
foldMarkKey,
|
||
foldMarkLabel,
|
||
foldMarkRadius,
|
||
} from './folding';
|
||
import { curvePoint, curveReach } from './beads';
|
||
import { type NodeView, portColor } from './NodeView';
|
||
|
||
/** How far a folded edge's stub reaches out of its port. */
|
||
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;
|
||
const GHOST_GAP = 5;
|
||
const DASH_STEPS = 80;
|
||
|
||
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
|
||
export interface FoldMark {
|
||
edgeIds: string[];
|
||
/** The nodes at the other ends, deduped – clicking selects them all. */
|
||
others: string[];
|
||
isOutput: boolean;
|
||
x: number;
|
||
y: number;
|
||
text: string;
|
||
}
|
||
|
||
/** The curves between nodes and the marks of the hidden ones, in graph coordinates. */
|
||
export class EdgeLayer extends Container {
|
||
private readonly curves = new Graphics();
|
||
private readonly beads = new Graphics();
|
||
private readonly chips = new Graphics();
|
||
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
|
||
private readonly labelLayer = new Container();
|
||
private readonly labels: Text[] = [];
|
||
private labelCount = 0;
|
||
private ends: FoldMarkEnd[] = [];
|
||
private marks: FoldMark[] = [];
|
||
/** 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();
|
||
this.addChild(this.curves, this.beads, this.chips, this.labelLayer);
|
||
}
|
||
|
||
draw(
|
||
edges: readonly GraphEdge[],
|
||
views: ReadonlyMap<string, NodeView>,
|
||
isHot: (edgeId: string) => boolean,
|
||
selected: Set<string>,
|
||
beads: (edgeId: string) => readonly number[] = () => [],
|
||
): void {
|
||
this.curves.clear();
|
||
this.beads.clear();
|
||
this.beadsDrawn = [];
|
||
this.chips.clear();
|
||
this.marks = [];
|
||
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();
|
||
for (let i = this.labelCount; i < this.labels.length; i++) this.labels[i]!.visible = false;
|
||
}
|
||
|
||
markAt(g: { x: number; y: number }): FoldMark | null {
|
||
return foldMarkAt(this.marks, g, FOLD_MARK_RADIUS);
|
||
}
|
||
|
||
drawnMarks(): readonly FoldMark[] {
|
||
return this.marks;
|
||
}
|
||
|
||
wasStroked(edgeId: string): boolean {
|
||
return this.stroked.has(edgeId);
|
||
}
|
||
|
||
drawnBeads(): readonly { edgeId: string; progress: number; x: number; y: number }[] {
|
||
return this.beadsDrawn;
|
||
}
|
||
|
||
private drawEdge(
|
||
edge: GraphEdge,
|
||
views: ReadonlyMap<string, NodeView>,
|
||
hot: boolean,
|
||
selected: Set<string>,
|
||
beads: readonly number[],
|
||
): void {
|
||
const from = views.get(edge.from.node);
|
||
const to = views.get(edge.to.node);
|
||
const a = from?.portPosition(edge.from.port, true);
|
||
const b = to?.portPosition(edge.to.port, false);
|
||
if (!from || !to || !a || !b) return;
|
||
const out = from.def.outputs.find((p) => p.key === edge.from.port);
|
||
const inp = to.def.inputs.find((p) => p.key === edge.to.port);
|
||
const type = out?.type ?? 'any';
|
||
const inType = inp?.type ?? 'any';
|
||
// One end on a port that is gone: the edge is drawn, but never as a working link.
|
||
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 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) {
|
||
// 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;
|
||
}
|
||
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,
|
||
color,
|
||
// 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);
|
||
// Light with a dark rim: a bead in the highlight colour would vanish on the edge it lights up.
|
||
this.beads
|
||
.circle(x, y, BEAD_RADIUS)
|
||
.fill(Theme.colors.text)
|
||
.stroke({ width: 1.5, color: Theme.colors.graphBackground });
|
||
this.beadsDrawn.push({ edgeId: edge.id, progress, x, y });
|
||
}
|
||
}
|
||
|
||
/** 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>,
|
||
port: { x: number; y: number },
|
||
isOutput: boolean,
|
||
otherLabel: string,
|
||
color: number,
|
||
hot: boolean,
|
||
): void {
|
||
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
|
||
this.ends.push({
|
||
edgeId: edge.id,
|
||
key: foldMarkKey(side, isOutput, views.get(side.node)?.collapsed),
|
||
other,
|
||
otherLabel,
|
||
isOutput,
|
||
portX: port.x,
|
||
portY: port.y,
|
||
color,
|
||
hot,
|
||
});
|
||
}
|
||
|
||
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
|
||
private drawFoldMarks(): void {
|
||
for (const mark of buildFoldMarks(this.ends, FOLD_STUB)) {
|
||
const { x, y, color, isOutput } = mark;
|
||
this.curves.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
|
||
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
|
||
this.curves.circle(x, y, radius).fill(color);
|
||
this.curves.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
|
||
const label = this.nextLabel();
|
||
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
|
||
const gap = radius + 4;
|
||
const lx = isOutput ? x + gap : x - gap - label.width;
|
||
label.position.set(lx, y - label.height / 2);
|
||
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
|
||
this.chips
|
||
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
|
||
.fill({ color: Theme.colors.node, alpha: 0.85 });
|
||
this.marks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
|
||
}
|
||
}
|
||
|
||
private nextLabel(): Text {
|
||
let label = this.labels[this.labelCount];
|
||
if (!label) {
|
||
label = new Text({ text: '', style: textStyle('small') });
|
||
this.labels.push(label);
|
||
this.labelLayer.addChild(label);
|
||
}
|
||
label.visible = true;
|
||
this.labelCount++;
|
||
return label;
|
||
}
|
||
}
|
||
|
||
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);
|
||
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
|
||
}
|
||
|
||
/**
|
||
* The same curve as dashes. Pixi has no dash style, and unlike a straight line a bezier cannot be
|
||
* stepped by length, so it is sampled and the walked distance decides which steps are drawn.
|
||
*/
|
||
export function dashedBezier(
|
||
g: Graphics,
|
||
x1: number,
|
||
y1: number,
|
||
x2: number,
|
||
y2: number,
|
||
dash: number,
|
||
gap: number,
|
||
): void {
|
||
const dx = curveReach(x1, x2);
|
||
const at = (t: number) => {
|
||
const u = 1 - t;
|
||
return {
|
||
x: u * u * u * x1 + 3 * u * u * t * (x1 + dx) + 3 * u * t * t * (x2 - dx) + t * t * t * x2,
|
||
y: u * u * u * y1 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y2,
|
||
};
|
||
};
|
||
let prev = at(0);
|
||
let walked = 0;
|
||
for (let i = 1; i <= DASH_STEPS; i++) {
|
||
const point = at(i / DASH_STEPS);
|
||
if (walked % (dash + gap) < dash) g.moveTo(prev.x, prev.y).lineTo(point.x, point.y);
|
||
walked += Math.hypot(point.x - prev.x, point.y - prev.y);
|
||
prev = point;
|
||
}
|
||
}
|