A transition carries a name, so a state needs no visible port (#52)
game-maker / suite (push) Successful in 10m22s
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:
1 parent
a041322a0e
commit
68c92daaa6
27 files changed
+1186
-38
No files matched your search
@@ -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 |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }];
|
||||||
|
}
|
||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user