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

+2 -2
View File
@@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 844 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 862 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs
@@ -57,7 +57,7 @@ touches and leave the full run to CI. Details, the GPU requirement and the detac
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Collision events and blocking movement | | [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Collision events and blocking movement |
| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, the filter, upload limits | | [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, the filter, upload limits |
| [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab | | [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab |
| [State machines](https://git.nockal.com/meleki/game-maker/wiki/State-machines) | The asset, `State branch` and `Set state` | | [State machines](https://git.nockal.com/meleki/game-maker/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` |
| [Containers](https://git.nockal.com/meleki/game-maker/wiki/Containers) | Templates, instances, screens | | [Containers](https://git.nockal.com/meleki/game-maker/wiki/Containers) | Templates, instances, screens |
| [Keyboard](https://git.nockal.com/meleki/game-maker/wiki/Keyboard) | Every shortcut | | [Keyboard](https://git.nockal.com/meleki/game-maker/wiki/Keyboard) | Every shortcut |
| [Architecture](https://git.nockal.com/meleki/game-maker/wiki/Architecture) | Stack, source layout, the rules that keep it organised | | [Architecture](https://git.nockal.com/meleki/game-maker/wiki/Architecture) | Stack, source layout, the rules that keep it organised |
+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. */ /** One undo step for the whole selection; every node is found before any of them changes. */
export class CollapseNodesCommand implements Command { export class CollapseNodesCommand implements Command {
readonly label: string; readonly label: string;
+2
View File
@@ -53,6 +53,8 @@ export interface GraphEdge {
to: PortRef; to: PortRef;
/** Drawn as a stub at each end instead of a curve across the graph. Purely visual. */ /** Drawn as a stub at each end instead of a curve across the graph. Purely visual. */
folded?: boolean; folded?: boolean;
/** What a transition between two states is called – the name `State transition` asks for. */
label?: string;
} }
export interface Graph { export interface Graph {
+3 -2
View File
@@ -105,7 +105,7 @@ export class GraphInterpreter {
const def = this.def(node); const def = this.def(node);
if (!def?.run) return; if (!def?.run) return;
const inputs = this.collectInputs(node, ctx); 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 returned = result.call ? this.call(result.call, ctx, depth) : {};
const produced = { ...result.outputs, ...returned }; const produced = { ...result.outputs, ...returned };
if (!def.read) this.frame.outputCache.set(node.id, produced); 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?)'); throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// Mark before evaluating so a data cycle reads null instead of recursing forever. // Mark before evaluating so a data cycle reads null instead of recursing forever.
this.frame.dataCache.set(nodeId, {}); 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.frame.dataCache.set(nodeId, outputs);
this.trace?.onNode(nodeId); this.trace?.onNode(nodeId);
this.record(nodeId, outputs); this.record(nodeId, outputs);
+9 -1
View File
@@ -91,6 +91,12 @@ export interface NodeDef {
declaresPorts?: 'inputs' | 'outputs' | 'both'; declaresPorts?: 'inputs' | 'outputs' | 'both';
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */ /** How a declared port becomes a port; without it, named after its key and typed by its kind. */
declaredPort?(port: DeclaredPort, node: GraphNode): PortDef; 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 * 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. * 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 * 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 * 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?( run?(
ctx: NodeContext, ctx: NodeContext,
@@ -113,6 +120,7 @@ export interface NodeDef {
params: PropertyBag, params: PropertyBag,
def: NodeDef, def: NodeDef,
entry: string, entry: string,
lookup: GraphLookup,
): RunResult; ): RunResult;
} }
+11
View File
@@ -14,9 +14,20 @@ export interface MachineState {
name: string; 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 { export interface StateMachine {
name: string; name: string;
states: MachineState[]; 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 { Sequence } from './flow/Sequence';
import { SetState } from './flow/SetState'; import { SetState } from './flow/SetState';
import { StateBranch } from './flow/StateBranch'; import { StateBranch } from './flow/StateBranch';
import { StateTransition } from './flow/StateTransition';
import { PARTICLE_NODES } from '../../particles/nodes'; import { PARTICLE_NODES } from '../../particles/nodes';
import { SHADER_NODES } from '../../shader/nodes'; import { SHADER_NODES } from '../../shader/nodes';
import { State } from '../stateMachine'; import { State } from '../stateMachine';
@@ -120,6 +121,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
RunBehavior, RunBehavior,
StateBranch, StateBranch,
SetState, SetState,
StateTransition,
BehaviorEnd, BehaviorEnd,
Variables, Variables,
Self, Self,
+39 -5
View File
@@ -1,27 +1,36 @@
import { textOf } from '../model/Properties'; import { textOf } from '../model/Properties';
import { flatDef } from '../properties/PropertySchema'; import { flatDef } from '../properties/PropertySchema';
import { edgesInto, type Graph, type GraphNode } from './Graph'; 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'; import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
export const STATE = 'State'; export const STATE = 'State';
export const STATE_MACHINE = 'StateMachine'; export const STATE_MACHINE = 'StateMachine';
export const STATE_TRANSITION = 'StateTransition';
export const STATE_NAME_PARAM = 'name'; export const STATE_NAME_PARAM = 'name';
/** The input naming the machine on `State branch` and `Set state`. */ /** The input naming the machine on `State branch` and `Set state`. */
export const MACHINE_PORT = 'machine'; 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 * One state of a state machine: a node whose name is its title. Its graph is the machine asset's,
* machine asset's, never a behavior's – the palette leaves the `state` category out. * never a behavior's – the palette leaves the `state` category out.
*/ */
export const State: NodeDef = { export const State: NodeDef = {
type: STATE, type: STATE,
label: 'State', label: 'State',
category: 'state', category: 'state',
description: 'One state of a state machine.', description: 'One state of a state machine.',
inputs: [], inputs: [{ key: STATE_IN, label: '', type: 'exec' }],
outputs: [], outputs: [{ key: STATE_OUT, label: '', type: 'exec' }],
params: [flatDef(STATE_NAME_PARAM, 'text', { label: 'Name', value: 'State' })], params: [flatDef(STATE_NAME_PARAM, 'text', { label: 'Name', value: 'State' })],
transitionEdges: true,
derivePorts: ({ node }) => ({ label: stateName(node) }), 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) })); 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. */ /** What a state placed from the palette is called: "State 3" for the third. */
export function nextStateName(graph: Graph): string { export function nextStateName(graph: Graph): string {
return `${State.label} ${statesOf(graph).length + 1}`; 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 * The graph holds one `State` node per state and an edge per transition; a behavior reads the
* current state through `State branch` and moves it through `Set state`. * machine's current state through `State branch` and moves it through `Set state` or `State transition`.
*/ */
export interface StateMachineAsset extends AssetBase { export interface StateMachineAsset extends AssetBase {
type: 'statemachine'; type: 'statemachine';
+7 -2
View File
@@ -1,5 +1,5 @@
import type { GraphLookup } from '../graph/lookup'; import type { GraphLookup } from '../graph/lookup';
import { statesOf } from '../graph/stateMachine'; import { statesOf, transitionsOf } from '../graph/stateMachine';
import { resolveAssetProps } from '../properties/resolve'; import { resolveAssetProps } from '../properties/resolve';
import { findAssetOf, type Project } from './Project'; import { findAssetOf, type Project } from './Project';
@@ -16,7 +16,12 @@ export function graphLookup(project: Project): GraphLookup {
}, },
machine: (assetId) => { machine: (assetId) => {
const asset = findAssetOf(project, assetId, 'statemachine'); 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 }, to: { node: to.node, port: to.port },
}; };
if (e.folded === true) edge.folded = true; 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); graph.edges.push(edge);
} }
return graph; return graph;
+16
View File
@@ -1,4 +1,5 @@
import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js'; 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 { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project'; import { findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture'; 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(), canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
center: () => centreOf(editor.stateMachineEditor.canvas), center: () => centreOf(editor.stateMachineEditor.canvas),
snapping: () => editor.stateMachineEditor.canvas.snapEnabled, 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: { particles: {
current: () => editor.particlesEditor.currentAssetId, current: () => editor.particlesEditor.currentAssetId,
+62 -5
View File
@@ -1,6 +1,8 @@
import { Container, Graphics, Text } from 'pixi.js'; import { Container, Graphics, Text } from 'pixi.js';
import { type GraphEdge, portsCompatible } from '../../core/graph'; import { type GraphEdge, portsCompatible } from '../../core/graph';
import { Theme, textStyle, truncate } from '../../ui'; import { Theme, textStyle, truncate } from '../../ui';
import { dashedLine } from '../../viewport/symbols';
import { arrowCorners, arrowShaft, type Point, transitionEnds, transitionOffsets } from './transitions';
import { import {
buildFoldMarks, buildFoldMarks,
foldedEdgeShown, foldedEdgeShown,
@@ -17,6 +19,7 @@ import { type NodeView, portColor } from './NodeView';
const FOLD_STUB = 16; const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5; const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96; const FOLD_LABEL_WIDTH = 96;
const TRANSITION_LABEL_WIDTH = 110;
const BEAD_RADIUS = 4.5; const BEAD_RADIUS = 4.5;
/** Dash pattern and sampling of an edge hanging on a port that is gone. */ /** Dash pattern and sampling of an edge hanging on a port that is gone. */
const GHOST_DASH = 7; 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. */ /** Edges the last draw really stroked as a curve – a hidden one is missing from it. */
private readonly stroked = new Set<string>(); private readonly stroked = new Set<string>();
private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = []; 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() { constructor() {
super(); super();
@@ -69,6 +75,12 @@ export class EdgeLayer extends Container {
this.ends = []; this.ends = [];
this.labelCount = 0; this.labelCount = 0;
this.stroked.clear(); 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)); 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. // After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks(); this.drawFoldMarks();
@@ -111,14 +123,25 @@ export class EdgeLayer extends Container {
const ghost = out?.ghost === true || inp?.ghost === true; const ghost = out?.ghost === true || inp?.ghost === true;
const valid = !ghost && portsCompatible(type, inType); const valid = !ghost && portsCompatible(type, inType);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger; 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 pins = {
const end = { x: to.x + b.x, y: to.y + b.y }; 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) { if (edge.folded) {
this.addFoldEnd(edge, views, start, true, to.def.label, color, hot); // The fold mark belongs on the port, whatever the line between the nodes does.
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot); 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 (!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); else bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({ this.curves.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2, 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. // 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), 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); this.stroked.add(edge.id);
for (const progress of beads) { for (const progress of beads) {
const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress); 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( private addFoldEnd(
edge: GraphEdge, edge: GraphEdge,
views: ReadonlyMap<string, NodeView>, 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. */ /** Horizontal S-curve between two points. */
export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void { export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = curveReach(x1, x2); const dx = curveReach(x1, x2);
+136 -6
View File
@@ -27,6 +27,8 @@ import {
portTypeOf, portTypeOf,
resolveDef, resolveDef,
RUN_BEHAVIOR, RUN_BEHAVIOR,
STATE_IN,
STATE_OUT,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset'; import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
@@ -44,16 +46,19 @@ import {
endDrag, endDrag,
onDragChange, onDragChange,
pressCount, pressCount,
type MenuItem,
type Size, type Size,
Theme, Theme,
textStyle, textStyle,
truncate, truncate,
ui,
Widget, Widget,
zoomFactor, zoomFactor,
} from '../../ui'; } from '../../ui';
import { type EditorState, nodeSelection } from '../state'; import { type EditorState, nodeSelection } from '../state';
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing'; 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 { openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette } from './NodePalette'; import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries'; 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 /** 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. */ * being made stays visible while the node type is chosen. */
private pendingDrop: { from: PortSide; pointer: { x: number; y: number } } | null = null; 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. */ /** Where the running drag would land, recomputed on every move so the drop holds no surprise. */
private dropTarget: PortSide | null = null; private dropTarget: PortSide | null = null;
/** Whether the last redraw actually stroked the dangling edge. */ /** 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. */ /** Switching refits the view, since the next graph is a new subject. */
show(assetId: string): void { show(assetId: string): void {
if (this.assetId === assetId) return; 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.assetId = assetId;
this.sync(); this.sync();
this.needsFit = true; this.needsFit = true;
@@ -216,6 +227,7 @@ export class GraphCanvas extends Widget {
if (!asset) { if (!asset) {
// An asset that was deleted (or undone away) must not linger as a graph. // An asset that was deleted (or undone away) must not linger as a graph.
this.assetId = ''; this.assetId = '';
this.cancelTransition();
for (const view of this.views.values()) view.destroy({ children: true }); for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear(); this.views.clear();
this.redraw(); this.redraw();
@@ -317,6 +329,11 @@ export class GraphCanvas extends Widget {
return; return;
} }
if (e.target !== this) 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. // Shift-drag on empty space pans as well, as it does in the scene view.
if (e.button === 0 && e.shiftKey) { if (e.button === 0 && e.shiftKey) {
startPan(); startPan();
@@ -346,6 +363,12 @@ export class GraphCanvas extends Widget {
if (e.button === 1) return; if (e.button === 1) return;
if (!this.assetId) return; if (!this.assetId) return;
e.stopPropagation(); 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 local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y); const hit = view.portAt(local.x, local.y);
if (e.button === 2) { if (e.button === 2) {
@@ -356,7 +379,13 @@ export class GraphCanvas extends Widget {
} else { } else {
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone. // 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])); 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; return;
} }
@@ -446,6 +475,10 @@ export class GraphCanvas extends Widget {
this.connecting.pointer = g; this.connecting.pointer = g;
this.dropTarget = this.resolveDrop(e.global.x, e.global.y); this.dropTarget = this.resolveDrop(e.global.x, e.global.y);
this.redraw(); 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) { } else if (this.marquee) {
this.marquee.current = g; this.marquee.current = g;
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom; 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; 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 { private nodeUnder(globalX: number, globalY: number): string | null {
for (const view of this.topDownViews()) { for (const view of this.topDownViews()) {
const p = view.toLocal({ x: globalX, y: globalY }); 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); 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. */ /** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */
private setHint(text: string): void { private setHint(text: string): void {
this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2); this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2);
@@ -688,7 +786,15 @@ export class GraphCanvas extends Widget {
return; return;
} }
const playing = this.state.mode === 'play'; 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 trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider(); const now = this.timeProvider();
@@ -721,14 +827,21 @@ export class GraphCanvas extends Widget {
this.tempEdge.clear(); this.tempEdge.clear();
this.tempEdgeShown = false; this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop; const drag = this.connecting ?? this.pendingDrop ?? this.armed;
const a = drag && this.anchorPoint(drag.from); const a = drag && this.anchorPoint(drag.from);
if (drag && a) { if (drag && a) {
// Snapping to the resolved port makes the prediction the ring shows unmistakable. // Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer; const b = this.anchorPoint(target) ?? drag.pointer;
const [from, to] = drag.from.isOutput ? [a, b] : [b, a]; // Dragged off a state, the line already looks like the transition it is about to become.
bezier(this.tempEdge, from.x, from.y, to.x, to.y); 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 }); this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
if (preview) arrowHead(this.tempEdge, from, to, c.textMuted);
this.tempEdgeShown = true; this.tempEdgeShown = true;
} }
@@ -799,6 +912,23 @@ export class GraphCanvas extends Widget {
return this.edgeLayer.wasStroked(edgeId); 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 }[] { foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
return this.edgeLayer return this.edgeLayer
.drawnMarks() .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 }); 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 { private rowCount(): number {
if (this.def.transitionEdges === true) return 0;
return Math.max(this.def.inputs.length, this.def.outputs.length); 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; 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 }> { 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 [ for (const [list, isOutput] of [
[this.def.inputs, false], [this.def.inputs, false],
[this.def.outputs, true], [this.def.outputs, true],
+2
View File
@@ -13,6 +13,7 @@ export function openNodeMenu(
nodes: readonly GraphNode[], nodes: readonly GraphNode[],
x: number, x: number,
y: number, y: number,
extras: readonly MenuItem[] = [],
): void { ): void {
const ids = state.selectedNodeIds; const ids = state.selectedNodeIds;
const fold = (list: readonly GraphNode[], verb: string, to: boolean): MenuItem[] => { 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 expanded = nodes.filter((n) => !n.collapsed);
const collapsed = nodes.filter((n) => n.collapsed); const collapsed = nodes.filter((n) => n.collapsed);
new Menu([ new Menu([
...extras,
...fold(expanded, 'Collapse', true), ...fold(expanded, 'Collapse', true),
...fold(collapsed, 'Expand', false), ...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, declaresPropertyPorts,
declaresVariablePorts, declaresVariablePorts,
edgesInto, edgesInto,
edgesOfNode,
findNode,
type Graph,
type GraphEdge,
type GraphNode, type GraphNode,
inlineInputDef, inlineInputDef,
inlineInputs, inlineInputs,
isStateNode,
MACHINE_PORT,
OBJECT_BEHAVIOR_PARAM, OBJECT_BEHAVIOR_PARAM,
OBJECT_TYPE_PARAM, OBJECT_TYPE_PARAM,
portSignature, portSignature,
resolveDef, resolveDef,
STATE_TRANSITION,
stateName,
TRANSITION_PARAM,
} from '../../core/graph'; } from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset'; import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup'; import { graphLookup } from '../../core/model/graphLookup';
@@ -21,6 +30,7 @@ import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host'; import { addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection'; import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers'; import { listOpener, previewingHandlers } from './previewHandlers';
import { buildTransitionPicker, buildTransitionSection } from './transitionSection';
import { currentNode, nodeParamValue } from './valueSpecs'; import { currentNode, nodeParamValue } from './valueSpecs';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */ /** 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(','); 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. // 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] ?? '')}`; 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( return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf(
asset, asset,
) )
.map((v) => v.key) .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. */ /** 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) { if (!ownSettings && def.params.length > 0) {
addTitle(host, 'SETTINGS'); 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); const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)'); 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; const INFO_WIDTH = 90;
/** /**
* The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node, * The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node and
* so placing (right-click), moving, snapping, renaming, deleting and undo are the graph's. * a transition an edge, so placing (right-click), moving, snapping, renaming, connecting, deleting
* Transitions are a later step. * and undo are all the graph's.
*/ */
export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphCanvas> { export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphCanvas> {
private readonly snap = new SnapToggle(() => this.canvas); private readonly snap = new SnapToggle(() => this.canvas);
+4 -2
View File
@@ -72,8 +72,10 @@ describe('node registry', () => {
const keys = [...def.inputs, ...def.outputs].map((p) => p.key); const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length); expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length); expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
// An event is seeded by the runtime, a state is a name in a machine: neither runs. // An event is seeded by the runtime, so nothing flows into it.
if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0); if (def.category === 'event') expect(def.inputs).toHaveLength(0);
// A state is a name in a machine; its ports only anchor transitions, so it never runs.
else if (def.category === 'state') expect(def.run).toBeUndefined();
// A shader node is compiled to GLSL and a particle node to a function: neither is ever run. // A shader node is compiled to GLSL and a particle node to a function: neither is ever run.
else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true); else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true);
else expect(def.run).toBeTypeOf('function'); else expect(def.run).toBeTypeOf('function');
+82 -1
View File
@@ -8,6 +8,7 @@ import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { import {
createEmptyGraph, createEmptyGraph,
type DeclaredPort, type DeclaredPort,
type GraphLookup,
NO_LOOKUP, NO_LOOKUP,
type NodeContext, type NodeContext,
resolveDef, resolveDef,
@@ -38,10 +39,19 @@ interface Case {
repeat?: RunResult['repeat'] | null; repeat?: RunResult['repeat'] | null;
/** Expected nested-behavior call; `null` asserts the node calls nothing. */ /** Expected nested-behavior call; `null` asserts the node calls nothing. */
call?: RunResult['call'] | null; call?: RunResult['call'] | null;
/** The project around the graph – for a node whose step is written in another asset. */
lookup?: GraphLookup;
/** Assertions on what the node did to the context or to the handles it was given. */ /** Assertions on what the node did to the context or to the handles it was given. */
after?(ctx: FakeContext): void; after?(ctx: FakeContext): void;
} }
/** A lookup holding one machine "m" with the given transitions – what `State transition` reads. */
const machineLookup = (transitions: { from: string; to: string; name?: string }[]): GraphLookup => ({
...NO_LOOKUP,
machine: (id) =>
id === 'm' ? { name: 'M', states: [], transitions: transitions.map((t) => ({ name: '', ...t })) } : null,
});
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => { const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
const ctx = fakeContext(c.context); const ctx = fakeContext(c.context);
const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports }; const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports };
@@ -52,7 +62,8 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } =>
inputs: [...execPorts(c.derivedInputs), ...registered.inputs], inputs: [...execPorts(c.derivedInputs), ...registered.inputs],
outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)], outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)],
}; };
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec'), ctx }; const lookup = c.lookup ?? NO_LOOKUP;
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec', lookup), ctx };
}; };
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
@@ -140,6 +151,76 @@ const CASES: Record<string, Case[]> = {
after: (ctx) => expect(ctx.states).toEqual({}), after: (ctx) => expect(ctx.states).toEqual({}),
}, },
], ],
StateTransition: [
{
name: 'follows the transition of the asked-for name out of the current state',
inputs: { machine: 'm' },
params: { transition: 'alarm' },
context: { states: { m: 's1' } },
lookup: machineLookup([{ from: 's1', to: 's2', name: 'alarm' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
},
{
// The point of naming transitions: one node works from every state that has that name.
name: 'picks the same name out of whichever state the machine is in',
inputs: { machine: 'm' },
params: { transition: 'alarm' },
context: { states: { m: 'searching' } },
lookup: machineLookup([
{ from: 'patrolling', to: 'chasing', name: 'alarm' },
{ from: 'chasing', to: 'searching', name: 'lost' },
{ from: 'searching', to: 'chasing', name: 'alarm' },
]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 'chasing' }),
},
{
name: 'a name the current state does not carry moves nothing',
inputs: { machine: 'm' },
params: { transition: 'lost' },
context: { states: { m: 's1' } },
lookup: machineLookup([{ from: 's1', to: 's2', name: 'alarm' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }),
},
{
name: 'two of the name out of one state is no move: the graph never picked between them',
inputs: { machine: 'm' },
params: { transition: 'alarm' },
context: { states: { m: 's1' } },
lookup: machineLookup([
{ from: 's1', to: 's2', name: 'alarm' },
{ from: 's1', to: 's3', name: 'alarm' },
]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }),
},
{
// An unnamed transition is a name too, so a machine nobody named still steps.
name: 'with no name asked for, follows the one unnamed transition',
inputs: { machine: 'm' },
context: { states: { m: 's1' } },
lookup: machineLookup([{ from: 's1', to: 's2' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
},
{
name: 'a state with nothing drawn out of it stays put',
inputs: { machine: 'm' },
context: { states: { m: 's2' } },
lookup: machineLookup([{ from: 's1', to: 's2' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
},
{
name: 'in no state yet there is nothing to move from',
inputs: { machine: 'm' },
lookup: machineLookup([{ from: 's1', to: 's2' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({}),
},
],
Repeat: [ Repeat: [
{ {
name: 'asks for as many body rounds as it was given, then continues at done', name: 'asks for as many body rounds as it was given, then continues at done',
+131 -1
View File
@@ -5,7 +5,13 @@
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; import {
AddNodeCommand,
ConnectCommand,
NameTransitionCommand,
RemoveNodeCommand,
SetNodeParamCommand,
} from '../../src/core/commands/graphCommands';
import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { import {
BEHAVIOR_PARAM, BEHAVIOR_PARAM,
@@ -15,8 +21,13 @@ import {
nextStateName, nextStateName,
resolveDef, resolveDef,
STATE, STATE,
STATE_IN,
STATE_NAME_PARAM, STATE_NAME_PARAM,
STATE_OUT,
statesOf, statesOf,
transitionNames,
transitionsOf,
transitionTo,
} from '../../src/core/graph'; } from '../../src/core/graph';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import type { StateMachineAsset } from '../../src/core/model/Asset'; import type { StateMachineAsset } from '../../src/core/model/Asset';
@@ -289,3 +300,122 @@ describe('a running state machine', () => {
).toThrow('Port not found'); ).toThrow('Port not found');
}); });
}); });
describe('transitions between states', () => {
/** Draws a transition the way the canvas does: out of one state's `out`, into the other's `in`. */
const connect = (f: ProjectFixture, machineId: string, from: string, to: string) =>
f.stack.execute(
new ConnectCommand(machineId, { node: from, port: STATE_OUT }, { node: to, port: STATE_IN }),
);
it('hangs on the two exec ports every state carries', () => {
const f = makeProject();
const machine = makeMachine(f);
const node = machine.asset().graph.nodes[0]!;
const def = resolveDef(node, machine.asset().graph, graphLookup(f.project))!;
expect(def.inputs.map((p) => p.key)).toEqual([STATE_IN]);
expect(def.outputs.map((p) => p.key)).toEqual([STATE_OUT]);
// Without a port there is no anchor to draw, drag or right-click an edge on.
expect(def.inputs[0]?.type).toBe('exec');
expect(def.outputs[0]?.type).toBe('exec');
});
it('is an ordinary edge, so drawing, undo and a save round trip come for free', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
connect(f, machine.id, open, closed);
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine')!;
expect(transitionsOf(loaded.graph)).toEqual([{ from: open, to: closed, name: '' }]);
f.stack.undo();
expect(transitionsOf(machine.asset().graph)).toEqual([]);
});
it('goes away with the state it hangs on, because both live in the one graph', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
connect(f, machine.id, open, closed);
f.stack.execute(new RemoveNodeCommand(machine.id, closed));
expect(transitionsOf(machine.asset().graph)).toEqual([]);
f.stack.undo();
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
});
it('is what the lookup hands a running graph', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
connect(f, machine.id, open, closed);
expect(graphLookup(f.project).machine(machine.id)?.transitions).toEqual([
{ from: open, to: closed, name: '' },
]);
});
it('moves the machine one step when a behavior runs State transition', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
connect(f, machine.id, open, closed);
const behavior = makeBehavior(f, 'B');
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
const next = edit.addNode('StateTransition', { [MACHINE_PORT]: machine.id });
edit.wire([
[start, 'exec', set, open],
[set, 'exec', next, 'exec'],
]);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: closed });
});
it('carries the name it was given through a save, and undo takes it back', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
connect(f, machine.id, open, closed);
const edge = machine.asset().graph.edges[0]!;
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, ' alarm '));
// Trimmed on the way in, so a stray space never makes a second name that looks the same.
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: 'alarm' }]);
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine')!;
expect(transitionsOf(loaded.graph)).toEqual([{ from: open, to: closed, name: 'alarm' }]);
f.stack.undo();
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
});
it('clears a name back to unnamed rather than keeping an empty one', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
connect(f, machine.id, open, closed);
const edge = machine.asset().graph.edges[0]!;
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, 'alarm'));
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, ' '));
expect(edge.label).toBeUndefined();
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
});
it('reads the same name out of whichever state the machine is in', () => {
const machine = {
name: 'Guard',
states: [],
transitions: [
{ from: 'patrolling', to: 'chasing', name: 'alarm' },
{ from: 'searching', to: 'chasing', name: 'alarm' },
{ from: 'chasing', to: 'searching', name: 'lost' },
],
};
expect(transitionTo(machine, 'patrolling', 'alarm')).toBe('chasing');
expect(transitionTo(machine, 'searching', 'alarm')).toBe('chasing');
expect(transitionTo(machine, 'chasing', 'alarm')).toBe('');
expect(transitionNames(machine)).toEqual(['alarm', 'lost']);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* The straight arrow between two states. It runs border to border because a diagonal line into a
* port passes under the target node and hides its own arrowhead there – the failure that made this
* geometry necessary, and the one these checks hold shut.
*/
import { describe, expect, it } from 'vitest';
import {
ARROW,
arrowCorners,
arrowShaft,
BORDER_GAP,
boxExit,
centreOf,
direction,
PARALLEL_SEP,
transitionEnds,
transitionOffsets,
transitionPreview,
} from '../../src/editor/graph/transitions';
/** Two states side by side, 100 x 40 each, 200 apart on x. */
const left = { x: 0, y: 0, w: 100, h: 40 };
const right = { x: 300, y: 0, w: 100, h: 40 };
/** Directly below the first, which is where a pin-to-pin line would cut through a node. */
const below = { x: 0, y: 200, w: 100, h: 40 };
describe('transition geometry', () => {
it('leaves a box on the side the ray points at', () => {
expect(boxExit(left, { x: 1, y: 0 })).toEqual({ x: 100, y: 20 });
expect(boxExit(left, { x: -1, y: 0 })).toEqual({ x: 0, y: 20 });
expect(boxExit(left, { x: 0, y: 1 })).toEqual({ x: 50, y: 40 });
expect(boxExit(left, { x: 0, y: -1 })).toEqual({ x: 50, y: 0 });
});
it('runs border to border, so neither end sits inside a node', () => {
const ends = transitionEnds(left, right)!;
expect(ends.start).toEqual({ x: 100 + BORDER_GAP, y: 20 });
expect(ends.end).toEqual({ x: 300 - BORDER_GAP, y: 20 });
});
it('leaves through the bottom and the top when one state sits under the other', () => {
const ends = transitionEnds(left, below)!;
// The whole point: straight down, out of the lower border and into the upper one – never
// across the body of either node.
expect(ends.start).toEqual({ x: 50, y: 40 + BORDER_GAP });
expect(ends.end).toEqual({ x: 50, y: 200 - BORDER_GAP });
});
it('keeps both ends clear of the borders they leave', () => {
const ends = transitionEnds(left, right)!;
expect(ends.start.x).toBeGreaterThan(left.x + left.w);
expect(ends.end.x).toBeLessThan(right.x);
});
it('pushes a there-and-back pair to opposite sides of the line', () => {
const offsets = transitionOffsets([
{ id: 'there', from: 'a', to: 'b' },
{ id: 'back', from: 'b', to: 'a' },
]);
const there = offsets.get('there')!;
const back = offsets.get('back')!;
// Same sign in each arrow's own frame, which is opposite sides on screen, since the normal
// turns with the direction.
expect(there).toBe(back);
expect(Math.abs(there)).toBe(PARALLEL_SEP / 2);
const a = transitionEnds(left, right, there)!;
const b = transitionEnds(right, left, back)!;
expect(a.start.y).not.toBe(b.end.y);
expect(Math.abs(a.start.y - b.end.y)).toBeCloseTo(PARALLEL_SEP);
});
it('leaves a lone transition on the line between the centres', () => {
const offsets = transitionOffsets([{ id: 'only', from: 'a', to: 'b' }]);
expect(offsets.get('only')).toBe(0);
expect(transitionEnds(left, right, 0)).toEqual(transitionEnds(left, right));
});
it('fans three between the same pair evenly around the centre line', () => {
const offsets = transitionOffsets([
{ id: 'one', from: 'a', to: 'b' },
{ id: 'two', from: 'a', to: 'b' },
{ id: 'three', from: 'a', to: 'b' },
]);
expect([...offsets.values()]).toEqual([-PARALLEL_SEP, 0, PARALLEL_SEP]);
});
it('keeps transitions between different pairs on their own centre lines', () => {
const offsets = transitionOffsets([
{ id: 'ab', from: 'a', to: 'b' },
{ id: 'cd', from: 'c', to: 'd' },
]);
expect(offsets.get('ab')).toBe(0);
expect(offsets.get('cd')).toBe(0);
});
it('has no ends when the two boxes share a centre', () => {
expect(transitionEnds(left, { ...left })).toBeNull();
expect(direction({ x: 5, y: 5 }, { x: 5, y: 5 })).toBeNull();
});
it('stops the line one arrowhead short of the tip', () => {
const shaft = arrowShaft({ x: 0, y: 0 }, { x: 100, y: 0 });
expect(shaft).toEqual({ x: 100 - ARROW, y: 0 });
});
it('points the head at the target, across the line', () => {
const [tip, a, b] = arrowCorners({ x: 0, y: 0 }, { x: 100, y: 0 })!;
expect(tip).toEqual({ x: 100, y: 0 });
// Both base corners sit at the shaft's end, one on each side of the line.
expect(a.x).toBeCloseTo(100 - ARROW);
expect(b.x).toBeCloseTo(100 - ARROW);
expect(Math.sign(a.y)).toBe(-Math.sign(b.y));
expect(Math.abs(a.y)).toBeCloseTo(Math.abs(b.y));
});
it('previews from the source border to the pointer until it finds a state', () => {
const loose = transitionPreview(left, null, { x: 300, y: 20 });
expect(loose.start).toEqual({ x: 100 + BORDER_GAP, y: 20 });
expect(loose.end).toEqual({ x: 300, y: 20 });
const snapped = transitionPreview(left, right, { x: 280, y: 20 });
expect(snapped).toEqual(transitionEnds(left, right));
});
it('centres a box where both halves meet', () => {
expect(centreOf(left)).toEqual({ x: 50, y: 20 });
});
});
+4 -1
View File
@@ -9,7 +9,9 @@ import {
type DeclaredPort, type DeclaredPort,
ENTITY_HANDLE, ENTITY_HANDLE,
type EntityHandle, type EntityHandle,
type GraphLookup,
type GraphNode, type GraphNode,
NO_LOOKUP,
type NodeContext, type NodeContext,
type PointerState, type PointerState,
type Value, type Value,
@@ -216,9 +218,10 @@ export function runDef(
inputs: Record<string, Value>, inputs: Record<string, Value>,
params: PropertyBag = {}, params: PropertyBag = {},
entry = 'exec', entry = 'exec',
lookup: GraphLookup = NO_LOOKUP,
): RunResult { ): RunResult {
const def = requireNodeDef(type); const def = requireNodeDef(type);
return def.run!(ctx, inputs, params, def, entry); return def.run!(ctx, inputs, params, def, entry, lookup);
} }
/** Runs one node definition in isolation and returns its data outputs. */ /** Runs one node definition in isolation and returns its data outputs. */
+175 -1
View File
@@ -191,8 +191,138 @@ export async function stateMachine(t) {
await press('Escape'); await press('Escape');
await sleep(200); await sleep(200);
// 26f. A behavior reads the machine: Set state on start, State branch every frame. // 26e2. A transition is an ordinary edge, but it hangs on ports the editor never draws: a
// transition joins two whole states, so there is no socket to aim at and none to grab.
const [first, second] = (await states()).map((s) => s.id); const [first, second] = (await states()).map((s) => s.id);
const outPin = await json(`__gm.stateMachine.portAt(${JSON.stringify(first)}, 'out', true)`);
const inPin = await json(`__gm.stateMachine.portAt(${JSON.stringify(second)}, 'in', false)`);
check(
'every state still carries the out and in port an edge needs to exist at all',
outPin !== null && inPin !== null,
JSON.stringify({ outPin, inPin }),
);
const grabbable = async (id, port, isOutput) =>
json(`__gm.stateMachine.portGrabbable(${JSON.stringify(id)}, ${JSON.stringify(port)}, ${isOutput})`);
check(
'but neither can be grabbed, because neither is drawn',
(await grabbable(first, 'out', true)) === false && (await grabbable(second, 'in', false)) === false,
);
const transitions = () =>
json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).graph.edges`);
const centreOfState = async (id) => {
const r = await json(`__gm.stateMachine.nodeRect(${JSON.stringify(id)})`);
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
};
const makeTransition = async (fromId, toId) => {
const from = await centreOfState(fromId);
await click(from.x, from.y, 'right');
await sleep(300);
await clickLast('Make transition');
await sleep(250);
const to = await centreOfState(toId);
await click(to.x, to.y);
await sleep(350);
};
// The right-click way in, since there is no pin to drag from any more.
const firstCentre = await centreOfState(first);
await click(firstCentre.x, firstCentre.y, 'right');
await sleep(300);
check(
"a state's menu offers Make transition",
(await json('__gm.overlayTexts()')).includes('Make transition'),
JSON.stringify(await json('__gm.overlayTexts()')),
);
await clickLast('Make transition');
await sleep(250);
check(
'armed, the canvas says which click it is waiting for',
(await json('__gm.stateMachine.hint()')).includes('Click the state'),
JSON.stringify(await json('__gm.stateMachine.hint()')),
);
check(
'and the line to the pointer is already on screen',
(await json('__gm.stateMachine.danglingEdge()')) === true,
);
await shot('05a-armed');
await press('Escape');
await sleep(250);
check(
'Escape drops the attempt, drawing nothing',
(await transitions()).length === 0 && (await json('__gm.stateMachine.danglingEdge()')) === false,
);
await makeTransition(first, second);
const drawn = await transitions();
check(
'clicking the other state connects them',
drawn.length === 1 && drawn[0]?.from.node === first && drawn[0]?.to.node === second,
JSON.stringify(drawn),
);
check(
'the transition is really stroked, not dropped for want of an anchor',
(await json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(drawn[0]?.id)})`)) === true,
);
await undo();
await sleep(300);
check('undo takes the transition back', (await transitions()).length === 0);
await redo();
await sleep(300);
check('redo puts it back', (await transitions()).length === 1);
await shot('05-transition');
// 26e3. Naming a transition, from the list the inspector shows on the state it leaves.
const edgeId = (await transitions())[0]?.id;
await evaluate(
`__gm.state.select({ kind: 'node', assetId: ${JSON.stringify(made.id)}, nodeId: ${JSON.stringify(first)}, nodeIds: [${JSON.stringify(first)}] })`,
);
await sleep(400);
check(
'the inspector lists the transitions of the selected state, both ways round',
(await has('TRANSITIONS OUT')) && (await has('TRANSITIONS IN')),
);
await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`);
await sleep(400);
check(
'a named transition carries the name on the edge itself',
(await transitions())[0]?.label === 'alarm',
JSON.stringify(await transitions()),
);
check(
'and the name is drawn on the arrow, not only listed in the panel',
(await json(`__gm.stateMachine.transitionLabel(${JSON.stringify(edgeId)})`)) === 'alarm',
);
// The field is only findable once it holds text, so the rename is what proves it is really a field.
const field = await locate('alarm', true);
// Double-click, so the name is selected and replaced – a single click only parks the caret in it.
await doubleClick(field.x, field.y);
await sleep(200);
await typeText('lost');
await press('Enter');
await sleep(400);
check(
'and the panel row is a real field: typing in it renames the transition',
(await transitions())[0]?.label === 'lost',
JSON.stringify(await transitions()),
);
await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`);
await sleep(300);
await shot('05b-named');
// 26e4. Both ways round between the same pair: the two arrows must not lie on top of each other.
await makeTransition(second, first);
const both = await transitions();
check('a transition back is allowed alongside the one there', both.length === 2, JSON.stringify(both));
const drawnBoth = await Promise.all(
both.map((e) => json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(e.id)})`)),
);
check('both arrows are stroked', drawnBoth.every(Boolean), JSON.stringify(drawnBoth));
await shot('05c-both-ways');
await undo();
await sleep(300);
// 26f. A behavior reads the machine: Set state on start, State branch every frame.
const rig = await scaffoldBehavior(t, { const rig = await scaffoldBehavior(t, {
assetId: 'sm-logic', assetId: 'sm-logic',
name: 'Doors logic', name: 'Doors logic',
@@ -248,6 +378,50 @@ export async function stateMachine(t) {
); );
await stop(); await stop();
// 26f2. What a named transition is for at runtime: State transition takes the step called "alarm".
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.push({
id: 'sm-next', type: 'StateTransition', x: 600, y: 0,
params: { machine: ${JSON.stringify(made.id)}, transition: 'alarm' },
});
b.graph.edges.push({
id: 'sm-e5', from: { node: 'sm-set', port: 'exec' }, to: { node: 'sm-next', port: 'exec' },
});
`);
await sleep(300);
await play();
await sleep(600);
const moved = (await world())?.log ?? [];
check(
'State transition follows the transition of that name, so the branch takes the state it points at',
moved.includes('second state') && !moved.includes('first state'),
JSON.stringify([...new Set(moved)]),
);
await stop();
// A name the current state does not carry moves nothing – the machine stays where it was put.
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'no such transition';
`);
await sleep(300);
await play();
await sleep(600);
const stayed = (await world())?.log ?? [];
check(
'a name the state does not carry leaves the machine where it was',
stayed.includes('first state') && !stayed.includes('second state'),
JSON.stringify([...new Set(stayed)]),
);
await stop();
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'alarm';
`);
await sleep(200);
await sleep(200);
// 26g. An unconnected machine input shows a picker; picking a machine grows the state ports. // 26g. An unconnected machine input shows a picker; picking a machine grows the state ports.
await mutate(` await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic'); const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic');