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

+33
View File
@@ -242,6 +242,39 @@ export class FoldEdgeCommand implements Command {
}
}
/** The name a transition answers to. Blank clears it, since an unnamed transition is the normal one. */
export class NameTransitionCommand implements Command {
readonly label = 'Name transition';
private previous = '';
constructor(
private readonly assetId: string,
private readonly edgeId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const edge = this.edge(project);
this.previous = edge.label ?? '';
this.apply(edge, this.name.trim());
}
undo(project: Project): void {
this.apply(this.edge(project), this.previous);
}
private edge(project: Project): GraphEdge {
const edge = requireGraph(project, this.assetId).edges.find((e) => e.id === this.edgeId);
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
return edge;
}
private apply(edge: GraphEdge, name: string): void {
if (name) edge.label = name;
else delete edge.label;
}
}
/** One undo step for the whole selection; every node is found before any of them changes. */
export class CollapseNodesCommand implements Command {
readonly label: string;
+2
View File
@@ -53,6 +53,8 @@ export interface GraphEdge {
to: PortRef;
/** Drawn as a stub at each end instead of a curve across the graph. Purely visual. */
folded?: boolean;
/** What a transition between two states is called – the name `State transition` asks for. */
label?: string;
}
export interface Graph {
+3 -2
View File
@@ -105,7 +105,7 @@ export class GraphInterpreter {
const def = this.def(node);
if (!def?.run) return;
const inputs = this.collectInputs(node, ctx);
const result = def.run(ctx, inputs, node.params, def, entry);
const result = def.run(ctx, inputs, node.params, def, entry, this.lookup);
const returned = result.call ? this.call(result.call, ctx, depth) : {};
const produced = { ...result.outputs, ...returned };
if (!def.read) this.frame.outputCache.set(node.id, produced);
@@ -197,7 +197,8 @@ export class GraphInterpreter {
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// Mark before evaluating so a data cycle reads null instead of recursing forever.
this.frame.dataCache.set(nodeId, {});
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def, '').outputs ?? {};
const outputs =
def.run(ctx, this.collectInputs(source, ctx), source.params, def, '', this.lookup).outputs ?? {};
this.frame.dataCache.set(nodeId, outputs);
this.trace?.onNode(nodeId);
this.record(nodeId, outputs);
+9 -1
View File
@@ -91,6 +91,12 @@ export interface NodeDef {
declaresPorts?: 'inputs' | 'outputs' | 'both';
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
declaredPort?(port: DeclaredPort, node: GraphNode): PortDef;
/**
* Edges between two such nodes are transitions: drawn as a straight arrow instead of the usual
* curve, and hung on ports that are never shown. A transition graph is read as directed steps
* between whole states, not as signal flowing left to right into a particular socket.
*/
transitionEdges?: true;
/**
* Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
@@ -105,7 +111,8 @@ export interface NodeDef {
/**
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
* hand, the only way a node with derived ports knows how many it has. `entry` is the exec input
* the pulse arrived through – what a node with one exec input per meaning reads.
* the pulse arrived through – what a node with one exec input per meaning reads. `lookup` is the
* project around the graph, for a node whose step is written in another asset.
*/
run?(
ctx: NodeContext,
@@ -113,6 +120,7 @@ export interface NodeDef {
params: PropertyBag,
def: NodeDef,
entry: string,
lookup: GraphLookup,
): RunResult;
}
+11
View File
@@ -14,9 +14,20 @@ export interface MachineState {
name: string;
}
/**
* An allowed move between two states, both named by state node id, under the name `State
* transition` asks for. The name is empty until someone gives the transition one.
*/
export interface MachineTransition {
from: string;
to: string;
name: string;
}
export interface StateMachine {
name: string;
states: MachineState[];
transitions: MachineTransition[];
}
/**
@@ -0,0 +1,28 @@
import { textOf } from '../../../model/Properties';
import { flatDef } from '../../../properties/PropertySchema';
import type { NodeDef } from '../../NodeRegistry';
import { machinePort, STATE_TRANSITION, TRANSITION_PARAM, transitionTo } from '../../stateMachine';
import { asString } from '../../values';
import { exec } from '../shared';
/** What the transitions drawn in the machine are for at runtime: one step along a named arrow. */
export const StateTransition: NodeDef = {
type: STATE_TRANSITION,
label: 'State transition',
category: 'action',
description:
"Takes the transition of this name out of the machine's current state, then continues. " +
'Nothing happens when that state has no transition of the name.',
inputs: [exec(), machinePort()],
outputs: [exec()],
params: [flatDef(TRANSITION_PARAM, 'text', { label: 'Transition', value: '' })],
run: (ctx, inputs, params, _def, _entry, lookup) => {
const id = asString(inputs.machine);
const current = ctx.states[id] ?? '';
const machine = current ? lookup.machine(id) : null;
const name = textOf(params[TRANSITION_PARAM] ?? '');
const target = machine ? transitionTo(machine, current, name) : '';
if (target) ctx.states[id] = target;
return { next: 'exec' };
},
};
+2
View File
@@ -89,6 +89,7 @@ import { RunBehavior } from './flow/RunBehavior';
import { Sequence } from './flow/Sequence';
import { SetState } from './flow/SetState';
import { StateBranch } from './flow/StateBranch';
import { StateTransition } from './flow/StateTransition';
import { PARTICLE_NODES } from '../../particles/nodes';
import { SHADER_NODES } from '../../shader/nodes';
import { State } from '../stateMachine';
@@ -120,6 +121,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
RunBehavior,
StateBranch,
SetState,
StateTransition,
BehaviorEnd,
Variables,
Self,
+39 -5
View File
@@ -1,27 +1,36 @@
import { textOf } from '../model/Properties';
import { flatDef } from '../properties/PropertySchema';
import { edgesInto, type Graph, type GraphNode } from './Graph';
import type { MachineState, StateMachine } from './lookup';
import type { MachineState, MachineTransition, StateMachine } from './lookup';
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
export const STATE = 'State';
export const STATE_MACHINE = 'StateMachine';
export const STATE_TRANSITION = 'StateTransition';
export const STATE_NAME_PARAM = 'name';
/** The input naming the machine on `State branch` and `Set state`. */
export const MACHINE_PORT = 'machine';
/** The setting naming the transition on `State transition`. */
export const TRANSITION_PARAM = 'transition';
/** The two ports a transition hangs on. A port is the only anchor an edge has – without one it
* cannot be drawn, selected or undone – so a state carries these, and the editor never draws
* them: a transition joins whole states, not sockets. */
export const STATE_IN = 'in';
export const STATE_OUT = 'out';
/**
* One state of a state machine: a node with no ports whose name is its title. Its graph is the
* machine asset's, never a behavior's – the palette leaves the `state` category out.
* One state of a state machine: a node whose name is its title. Its graph is the machine asset's,
* never a behavior's – the palette leaves the `state` category out.
*/
export const State: NodeDef = {
type: STATE,
label: 'State',
category: 'state',
description: 'One state of a state machine.',
inputs: [],
outputs: [],
inputs: [{ key: STATE_IN, label: '', type: 'exec' }],
outputs: [{ key: STATE_OUT, label: '', type: 'exec' }],
params: [flatDef(STATE_NAME_PARAM, 'text', { label: 'Name', value: 'State' })],
transitionEdges: true,
derivePorts: ({ node }) => ({ label: stateName(node) }),
};
@@ -38,6 +47,31 @@ export function statesOf(graph: Graph): MachineState[] {
return graph.nodes.filter(isStateNode).map((node) => ({ id: node.id, name: stateName(node) }));
}
/** The transitions of a machine's graph: every edge between two states, in edge order. */
export function transitionsOf(graph: Graph): MachineTransition[] {
const states = new Set(graph.nodes.filter(isStateNode).map((n) => n.id));
return graph.edges
.filter((e) => states.has(e.from.node) && states.has(e.to.node))
.map((e) => ({ from: e.from.node, to: e.to.node, name: e.label ?? '' }));
}
/**
* Where `State transition` moves from `current`: along the transition of that name leaving it. The
* name is matched against the current state alone, so one node asking for "alarm" moves every state
* that has an "alarm". Nothing happens when the state has no such transition, or two of the name –
* with nothing drawn to pick between them, choosing either would be a guess the graph never made.
*/
export function transitionTo(machine: StateMachine, current: string, name: string): string {
const out = machine.transitions.filter((t) => t.from === current && t.name === name);
const targets = [...new Set(out.map((t) => t.to))];
return targets.length === 1 ? targets[0]! : '';
}
/** The names a machine's transitions carry, deduped and in edge order – what a node may ask for. */
export function transitionNames(machine: StateMachine | null): string[] {
return [...new Set((machine?.transitions ?? []).map((t) => t.name).filter((name) => name))];
}
/** What a state placed from the palette is called: "State 3" for the third. */
export function nextStateName(graph: Graph): string {
return `${State.label} ${statesOf(graph).length + 1}`;
+2 -2
View File
@@ -83,8 +83,8 @@ export interface AnimationAsset extends AssetBase {
}
/**
* The graph holds one `State` node per state and nothing else; a behavior reads the machine's
* current state through `State branch` and moves it through `Set state`.
* The graph holds one `State` node per state and an edge per transition; a behavior reads the
* machine's current state through `State branch` and moves it through `Set state` or `State transition`.
*/
export interface StateMachineAsset extends AssetBase {
type: 'statemachine';
+7 -2
View File
@@ -1,5 +1,5 @@
import type { GraphLookup } from '../graph/lookup';
import { statesOf } from '../graph/stateMachine';
import { statesOf, transitionsOf } from '../graph/stateMachine';
import { resolveAssetProps } from '../properties/resolve';
import { findAssetOf, type Project } from './Project';
@@ -16,7 +16,12 @@ export function graphLookup(project: Project): GraphLookup {
},
machine: (assetId) => {
const asset = findAssetOf(project, assetId, 'statemachine');
return asset ? { name: asset.name, states: statesOf(asset.graph) } : null;
if (!asset) return null;
return {
name: asset.name,
states: statesOf(asset.graph),
transitions: transitionsOf(asset.graph),
};
},
};
}
+2
View File
@@ -339,6 +339,8 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
to: { node: to.node, port: to.port },
};
if (e.folded === true) edge.folded = true;
const label = typeof e.label === 'string' ? e.label.trim() : '';
if (label) edge.label = label;
graph.edges.push(edge);
}
return graph;
+16
View File
@@ -1,4 +1,5 @@
import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js';
import { NameTransitionCommand } from '../core/commands/graphCommands';
import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture';
@@ -287,6 +288,21 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
center: () => centreOf(editor.stateMachineEditor.canvas),
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
/** Where a state's hidden pin sits. It is no longer grabbable – `portGrabbable` is the check. */
portAt: (nodeId: string, port: string, isOutput: boolean) =>
editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput),
portGrabbable: (nodeId: string, port: string, isOutput: boolean) =>
editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput),
edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId),
transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId),
hint: () => editor.stateMachineEditor.canvas.shownHint(),
/** Names a transition the way the inspector row does, for steps that need one already named. */
nameTransition: (edgeId: string, name: string) =>
state.execute(new NameTransitionCommand(editor.stateMachineEditor.currentAssetId, edgeId, name)),
dropTarget: () => editor.stateMachineEditor.canvas.pendingDropTarget(),
danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(),
emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(),
selected: () => state.selectedNodeIds,
},
particles: {
current: () => editor.particlesEditor.currentAssetId,
+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);
+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()
+7 -2
View File
@@ -199,7 +199,9 @@ export class NodeView extends Widget {
return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 });
}
/** Rows of ports, which set the node's height – none on a state, whose ports are never drawn. */
private rowCount(): number {
if (this.def.transitionEdges === true) return 0;
return Math.max(this.def.inputs.length, this.def.outputs.length);
}
@@ -275,9 +277,12 @@ export class NodeView extends Widget {
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/** Every port with its local centre – none while collapsed, so none can be grabbed. */
/**
* Every port with its local centre – none while collapsed, and none on a state, so neither can
* be drawn or grabbed. `portPosition` stays open either way: the edges still need their anchor.
*/
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
if (this.collapsed) return;
if (this.collapsed || this.def.transitionEdges === true) return;
for (const [list, isOutput] of [
[this.def.inputs, false],
[this.def.outputs, true],
+2
View File
@@ -13,6 +13,7 @@ export function openNodeMenu(
nodes: readonly GraphNode[],
x: number,
y: number,
extras: readonly MenuItem[] = [],
): void {
const ids = state.selectedNodeIds;
const fold = (list: readonly GraphNode[], verb: string, to: boolean): MenuItem[] => {
@@ -24,6 +25,7 @@ export function openNodeMenu(
const expanded = nodes.filter((n) => !n.collapsed);
const collapsed = nodes.filter((n) => n.collapsed);
new Menu([
...extras,
...fold(expanded, 'Collapse', true),
...fold(collapsed, 'Expand', false),
{
+134
View File
@@ -0,0 +1,134 @@
/**
* Geometry of a transition: the straight arrow drawn between two states. It runs border to border
* rather than pin to pin, because a diagonal line into a port would pass under the target node and
* take its arrowhead with it – the usual curve never does, since it swings in horizontally.
*/
export interface Point {
x: number;
y: number;
}
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Length of an arrowhead along the line, and half its width across. */
export const ARROW = 10;
export const ARROW_HALF_WIDTH = 4.5;
/** Air between a node's border and the end of the arrow touching it. */
export const BORDER_GAP = 7;
/** How far apart two transitions between the same pair of states are pushed. */
export const PARALLEL_SEP = 14;
export function centreOf(box: Rect): Point {
return { x: box.x + box.w / 2, y: box.y + box.h / 2 };
}
/** Null when the two points coincide: there is no direction to draw along. */
export function direction(a: Point, b: Point): Point | null {
const length = Math.hypot(b.x - a.x, b.y - a.y);
return length === 0 ? null : { x: (b.x - a.x) / length, y: (b.y - a.y) / length };
}
/** Where a ray leaving the box's centre crosses its border. */
export function boxExit(box: Rect, dir: Point): Point {
const centre = centreOf(box);
const tx = dir.x === 0 ? Infinity : box.w / 2 / Math.abs(dir.x);
const ty = dir.y === 0 ? Infinity : box.h / 2 / Math.abs(dir.y);
const t = Math.min(tx, ty);
return { x: centre.x + dir.x * t, y: centre.y + dir.y * t };
}
/** The normal of a direction, pointing to its right. */
function normal(dir: Point): Point {
return { x: -dir.y, y: dir.x };
}
function shift(point: Point, out: Point, along: number, n: Point, across: number): Point {
return { x: point.x + out.x * along + n.x * across, y: point.y + out.y * along + n.y * across };
}
/**
* The two points a transition between these nodes runs between: each a border crossing, pushed
* `BORDER_GAP` clear of the node and `offset` sideways. Null when the boxes share a centre.
*/
export function transitionEnds(from: Rect, to: Rect, offset = 0): { start: Point; end: Point } | null {
const dir = direction(centreOf(from), centreOf(to));
if (!dir) return null;
const back = { x: -dir.x, y: -dir.y };
const n = normal(dir);
return {
start: shift(boxExit(from, dir), dir, BORDER_GAP, n, offset),
end: shift(boxExit(to, back), back, BORDER_GAP, n, offset),
};
}
/** One transition as the offsets care about it: which states it joins, under which edge id. */
export interface TransitionLink {
id: string;
from: string;
to: string;
}
/**
* How far each transition is pushed sideways, so several between the same two states lie next to
* each other instead of on top. The fan is computed in the canonical direction of the pair, and a
* transition running the other way takes the opposite sign – which is what puts a there-and-back
* pair on either side of the line rather than both on the same one.
*/
export function transitionOffsets(links: readonly TransitionLink[]): Map<string, number> {
const groups = new Map<string, TransitionLink[]>();
for (const link of links) {
const [a, b] = link.from < link.to ? [link.from, link.to] : [link.to, link.from];
const key = `${a}\u0000${b}`;
const group = groups.get(key);
if (group) group.push(link);
else groups.set(key, [link]);
}
const offsets = new Map<string, number>();
for (const group of groups.values()) {
for (const [i, link] of group.entries()) {
const canonical = (i - (group.length - 1) / 2) * PARALLEL_SEP;
offsets.set(link.id, link.from <= link.to ? canonical : -canonical);
}
}
return offsets;
}
/**
* The line drawn while a transition is being dragged: out of the source's border towards the
* pointer, or border to border once the drag has found another state.
*/
export function transitionPreview(
source: Rect,
target: Rect | null,
pointer: Point,
): {
start: Point;
end: Point;
} {
if (target) return transitionEnds(source, target) ?? { start: centreOf(source), end: pointer };
const dir = direction(centreOf(source), pointer);
if (!dir) return { start: centreOf(source), end: pointer };
return { start: shift(boxExit(source, dir), dir, BORDER_GAP, normal(dir), 0), end: pointer };
}
/** Where the line stops: at the head's base, so the stroke does not blunt its tip. */
export function arrowShaft(start: Point, end: Point): Point {
const u = direction(start, end);
return u ? { x: end.x - u.x * ARROW, y: end.y - u.y * ARROW } : end;
}
/** The three corners of the arrowhead at the target end, tip first. */
export function arrowCorners(start: Point, end: Point): [Point, Point, Point] | null {
const u = direction(start, end);
if (!u) return null;
const base = arrowShaft(start, end);
const nx = -u.y * ARROW_HALF_WIDTH;
const ny = u.x * ARROW_HALF_WIDTH;
return [end, { x: base.x + nx, y: base.y + ny }, { x: base.x - nx, y: base.y - ny }];
}
+30 -2
View File
@@ -3,13 +3,22 @@ import {
declaresPropertyPorts,
declaresVariablePorts,
edgesInto,
edgesOfNode,
findNode,
type Graph,
type GraphEdge,
type GraphNode,
inlineInputDef,
inlineInputs,
isStateNode,
MACHINE_PORT,
OBJECT_BEHAVIOR_PARAM,
OBJECT_TYPE_PARAM,
portSignature,
resolveDef,
STATE_TRANSITION,
stateName,
TRANSITION_PARAM,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
@@ -21,6 +30,7 @@ import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
import { buildTransitionPicker, buildTransitionSection } from './transitionSection';
import { currentNode, nodeParamValue } from './valueSpecs';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
@@ -39,11 +49,24 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(',');
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`;
// The transition rows and the picked name are drawn, not filled in by `refreshNodeSection`, so
// every part of them a command can change has to force a rebuild.
const transitions = isStateNode(node)
? edgesOfNode(asset.graph, node.id)
.map((e) => `${e.id}:${e.label ?? ''}:${farStateName(asset.graph, e, node.id)}`)
.join(',')
: `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`;
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf(
asset,
)
.map((v) => v.key)
.join(',')}|${target}`;
.join(',')}|${target}|${transitions}`;
}
/** The name of the state at the other end of an edge – what a transition row is labelled with. */
function farStateName(graph: Graph, edge: GraphEdge, nodeId: string): string {
const far = findNode(graph, edge.from.node === nodeId ? edge.to.node : edge.from.node);
return far && isStateNode(far) ? stateName(far) : '';
}
/** A state machine declares no variables; its one node type has none to name. */
@@ -79,8 +102,13 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
if (!ownSettings && def.params.length > 0) {
addTitle(host, 'SETTINGS');
for (const param of def.params) addSetting(param);
for (const param of def.params) {
if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION)
buildTransitionPicker(host, assetId, node, asset.graph);
else addSetting(param);
}
}
if (isStateNode(node)) buildTransitionSection(host, assetId, node, asset.graph);
const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
+131
View File
@@ -0,0 +1,131 @@
import {
DisconnectCommand,
NameTransitionCommand,
SetNodeParamCommand,
} from '../../core/commands/graphCommands';
import {
findNode,
type Graph,
type GraphEdge,
type GraphNode,
isStateNode,
machineOf,
stateName,
TRANSITION_PARAM,
transitionNames,
} from '../../core/graph';
import { graphLookup } from '../../core/model/graphLookup';
import { textOf } from '../../core/model/Properties';
import { Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
/** The name `State transition` asks for, picked from the machine on its `machine` input – the
* options depend on another field, which no field kind can express. */
export function buildTransitionPicker(
host: InspectorHost,
assetId: string,
node: GraphNode,
graph: Graph,
): void {
const lookup = graphLookup(host.state.project);
const machine = machineOf({ node, graph, lookup });
const names = transitionNames(machine);
const chosen = textOf(node.params[TRANSITION_PARAM] ?? '');
const known = chosen === '' || names.includes(chosen);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(new Label('Transition', { style: 'muted' }));
row.add(new Spacer());
const choose = new Dropdown(chosen || UNNAMED, { variant: known ? 'default' : 'danger' });
choose.onClick = () => {
const items = [UNNAMED, ...names].map((name) => ({
label: name,
onSelect: () =>
host.state.execute(
new SetNodeParamCommand(assetId, node.id, TRANSITION_PARAM, name === UNNAMED ? '' : name),
),
}));
new Menu(items, { searchable: names.length > 8 }).openBelow(choose);
};
row.add(choose);
host.body.add(row);
if (!machine) addNote(host, 'Pick a machine first.');
else if (!known) addNote(host, `"${chosen}" is not a transition of ${machine.name}.`, 'warning');
}
/** How the transition nobody named reads in a picker, where an empty row would look like a bug. */
const UNNAMED = '(unnamed)';
/** The transitions at a state, both ways round – the only handle they have, since a transition
* hangs on ports the canvas never draws and so cannot be clicked out there. */
export function buildTransitionSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
graph: Graph,
): void {
const out = transitionsAt(graph, node.id, true);
const into = transitionsAt(graph, node.id, false);
group(host, assetId, graph, 'TRANSITIONS OUT', out, true);
group(host, assetId, graph, 'TRANSITIONS IN', into, false);
const duplicate = firstDuplicate(out.map((e) => e.label ?? ''));
if (duplicate !== null)
addNote(
host,
duplicate
? `Two transitions out of here are called "${duplicate}" – neither will be taken.`
: 'Two transitions out of here are unnamed – neither will be taken.',
'warning',
);
}
/** Edges joining this state to another one, on the given side. */
function transitionsAt(graph: Graph, nodeId: string, outgoing: boolean): GraphEdge[] {
return graph.edges.filter((e) => {
const [mine, other] = outgoing ? [e.from.node, e.to.node] : [e.to.node, e.from.node];
const far = findNode(graph, other);
return mine === nodeId && far !== undefined && isStateNode(far);
});
}
/** The first name that appears twice, or null. An empty name counts: it is the unnamed transition. */
function firstDuplicate(names: readonly string[]): string | null {
const seen = new Set<string>();
for (const name of names) {
if (seen.has(name)) return name;
seen.add(name);
}
return null;
}
function group(
host: InspectorHost,
assetId: string,
graph: Graph,
title: string,
edges: readonly GraphEdge[],
outgoing: boolean,
): void {
addTitle(host, title);
if (edges.length === 0) {
addNote(host, outgoing ? 'Nothing leads out of this state yet.' : 'Nothing leads here yet.');
return;
}
for (const edge of edges) {
const far = findNode(graph, outgoing ? edge.to.node : edge.from.node);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(new Label(outgoing ? '→' : '←', { style: 'muted' }));
row.add(new Label(far ? stateName(far) : '?', { style: 'muted' }));
row.add(new Spacer());
row.add(
new TextField(edge.label ?? '', {
width: NAME_WIDTH,
onChange: (next) => host.state.execute(new NameTransitionCommand(assetId, edge.id, next)),
}),
);
row.add(removeButton(() => host.state.execute(new DisconnectCommand(assetId, edge.id))));
host.body.add(row);
}
}
@@ -9,9 +9,9 @@ import type { EditorState } from '../state';
const INFO_WIDTH = 90;
/**
* The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node,
* so placing (right-click), moving, snapping, renaming, deleting and undo are the graph's.
* Transitions are a later step.
* The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node and
* a transition an edge, so placing (right-click), moving, snapping, renaming, connecting, deleting
* and undo are all the graph's.
*/
export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphCanvas> {
private readonly snap = new SnapToggle(() => this.canvas);