diff --git a/README.md b/README.md index 1fb245cc..55965ffc 100644 --- a/README.md +++ b/README.md @@ -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: ```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-tetris.mjs && node tools/smoke-tetris.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 | | [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 | -| [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 | | [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 | diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 7ef661a5..8fe4aca2 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -242,6 +242,39 @@ export class FoldEdgeCommand implements Command { } } +/** The name a transition answers to. Blank clears it, since an unnamed transition is the normal one. */ +export class NameTransitionCommand implements Command { + readonly label = 'Name transition'; + private previous = ''; + + constructor( + private readonly assetId: string, + private readonly edgeId: string, + private readonly name: string, + ) {} + + execute(project: Project): void { + const edge = this.edge(project); + this.previous = edge.label ?? ''; + this.apply(edge, this.name.trim()); + } + + undo(project: Project): void { + this.apply(this.edge(project), this.previous); + } + + private edge(project: Project): GraphEdge { + const edge = requireGraph(project, this.assetId).edges.find((e) => e.id === this.edgeId); + if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`); + return edge; + } + + private apply(edge: GraphEdge, name: string): void { + if (name) edge.label = name; + else delete edge.label; + } +} + /** One undo step for the whole selection; every node is found before any of them changes. */ export class CollapseNodesCommand implements Command { readonly label: string; diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 529e7113..12149ada 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -53,6 +53,8 @@ export interface GraphEdge { to: PortRef; /** Drawn as a stub at each end instead of a curve across the graph. Purely visual. */ folded?: boolean; + /** What a transition between two states is called – the name `State transition` asks for. */ + label?: string; } export interface Graph { diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 5337491a..8fa0a5e6 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -105,7 +105,7 @@ export class GraphInterpreter { const def = this.def(node); if (!def?.run) return; const inputs = this.collectInputs(node, ctx); - const result = def.run(ctx, inputs, node.params, def, entry); + const result = def.run(ctx, inputs, node.params, def, entry, this.lookup); const returned = result.call ? this.call(result.call, ctx, depth) : {}; const produced = { ...result.outputs, ...returned }; if (!def.read) this.frame.outputCache.set(node.id, produced); @@ -197,7 +197,8 @@ export class GraphInterpreter { throw new Error('Behavior graph exceeded the step limit (data cycle?)'); // Mark before evaluating so a data cycle reads null instead of recursing forever. this.frame.dataCache.set(nodeId, {}); - const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def, '').outputs ?? {}; + const outputs = + def.run(ctx, this.collectInputs(source, ctx), source.params, def, '', this.lookup).outputs ?? {}; this.frame.dataCache.set(nodeId, outputs); this.trace?.onNode(nodeId); this.record(nodeId, outputs); diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 7309aa26..65ddb185 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -91,6 +91,12 @@ export interface NodeDef { declaresPorts?: 'inputs' | 'outputs' | 'both'; /** How a declared port becomes a port; without it, named after its key and typed by its kind. */ declaredPort?(port: DeclaredPort, node: GraphNode): PortDef; + /** + * Edges between two such nodes are transitions: drawn as a straight arrow instead of the usual + * curve, and hung on ports that are never shown. A transition graph is read as directed steps + * between whole states, not as signal flowing left to right into a particular socket. + */ + transitionEdges?: true; /** * Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence * offers, which values the behavior a call node names takes. `resolveDef` applies it. @@ -105,7 +111,8 @@ export interface NodeDef { /** * Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at * hand, the only way a node with derived ports knows how many it has. `entry` is the exec input - * the pulse arrived through – what a node with one exec input per meaning reads. + * the pulse arrived through – what a node with one exec input per meaning reads. `lookup` is the + * project around the graph, for a node whose step is written in another asset. */ run?( ctx: NodeContext, @@ -113,6 +120,7 @@ export interface NodeDef { params: PropertyBag, def: NodeDef, entry: string, + lookup: GraphLookup, ): RunResult; } diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index 5462302c..ce244dd9 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -14,9 +14,20 @@ export interface MachineState { name: string; } +/** + * An allowed move between two states, both named by state node id, under the name `State + * transition` asks for. The name is empty until someone gives the transition one. + */ +export interface MachineTransition { + from: string; + to: string; + name: string; +} + export interface StateMachine { name: string; states: MachineState[]; + transitions: MachineTransition[]; } /** diff --git a/src/core/graph/nodes/flow/StateTransition.ts b/src/core/graph/nodes/flow/StateTransition.ts new file mode 100644 index 00000000..1095b5ae --- /dev/null +++ b/src/core/graph/nodes/flow/StateTransition.ts @@ -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' }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index dd7dc973..7ec17741 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -89,6 +89,7 @@ import { RunBehavior } from './flow/RunBehavior'; import { Sequence } from './flow/Sequence'; import { SetState } from './flow/SetState'; import { StateBranch } from './flow/StateBranch'; +import { StateTransition } from './flow/StateTransition'; import { PARTICLE_NODES } from '../../particles/nodes'; import { SHADER_NODES } from '../../shader/nodes'; import { State } from '../stateMachine'; @@ -120,6 +121,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ RunBehavior, StateBranch, SetState, + StateTransition, BehaviorEnd, Variables, Self, diff --git a/src/core/graph/stateMachine.ts b/src/core/graph/stateMachine.ts index 398c5bd9..85a1c541 100644 --- a/src/core/graph/stateMachine.ts +++ b/src/core/graph/stateMachine.ts @@ -1,27 +1,36 @@ import { textOf } from '../model/Properties'; import { flatDef } from '../properties/PropertySchema'; import { edgesInto, type Graph, type GraphNode } from './Graph'; -import type { MachineState, StateMachine } from './lookup'; +import type { MachineState, MachineTransition, StateMachine } from './lookup'; import type { NodeDef, PortDef, PortSource } from './NodeRegistry'; export const STATE = 'State'; export const STATE_MACHINE = 'StateMachine'; +export const STATE_TRANSITION = 'StateTransition'; export const STATE_NAME_PARAM = 'name'; /** The input naming the machine on `State branch` and `Set state`. */ export const MACHINE_PORT = 'machine'; +/** The setting naming the transition on `State transition`. */ +export const TRANSITION_PARAM = 'transition'; +/** The two ports a transition hangs on. A port is the only anchor an edge has – without one it + * cannot be drawn, selected or undone – so a state carries these, and the editor never draws + * them: a transition joins whole states, not sockets. */ +export const STATE_IN = 'in'; +export const STATE_OUT = 'out'; /** - * One state of a state machine: a node with no ports whose name is its title. Its graph is the - * machine asset's, never a behavior's – the palette leaves the `state` category out. + * One state of a state machine: a node whose name is its title. Its graph is the machine asset's, + * never a behavior's – the palette leaves the `state` category out. */ export const State: NodeDef = { type: STATE, label: 'State', category: 'state', description: 'One state of a state machine.', - inputs: [], - outputs: [], + inputs: [{ key: STATE_IN, label: '', type: 'exec' }], + outputs: [{ key: STATE_OUT, label: '', type: 'exec' }], params: [flatDef(STATE_NAME_PARAM, 'text', { label: 'Name', value: 'State' })], + transitionEdges: true, derivePorts: ({ node }) => ({ label: stateName(node) }), }; @@ -38,6 +47,31 @@ export function statesOf(graph: Graph): MachineState[] { return graph.nodes.filter(isStateNode).map((node) => ({ id: node.id, name: stateName(node) })); } +/** The transitions of a machine's graph: every edge between two states, in edge order. */ +export function transitionsOf(graph: Graph): MachineTransition[] { + const states = new Set(graph.nodes.filter(isStateNode).map((n) => n.id)); + return graph.edges + .filter((e) => states.has(e.from.node) && states.has(e.to.node)) + .map((e) => ({ from: e.from.node, to: e.to.node, name: e.label ?? '' })); +} + +/** + * Where `State transition` moves from `current`: along the transition of that name leaving it. The + * name is matched against the current state alone, so one node asking for "alarm" moves every state + * that has an "alarm". Nothing happens when the state has no such transition, or two of the name – + * with nothing drawn to pick between them, choosing either would be a guess the graph never made. + */ +export function transitionTo(machine: StateMachine, current: string, name: string): string { + const out = machine.transitions.filter((t) => t.from === current && t.name === name); + const targets = [...new Set(out.map((t) => t.to))]; + return targets.length === 1 ? targets[0]! : ''; +} + +/** The names a machine's transitions carry, deduped and in edge order – what a node may ask for. */ +export function transitionNames(machine: StateMachine | null): string[] { + return [...new Set((machine?.transitions ?? []).map((t) => t.name).filter((name) => name))]; +} + /** What a state placed from the palette is called: "State 3" for the third. */ export function nextStateName(graph: Graph): string { return `${State.label} ${statesOf(graph).length + 1}`; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 864ef665..4c1713c8 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -83,8 +83,8 @@ export interface AnimationAsset extends AssetBase { } /** - * The graph holds one `State` node per state and nothing else; a behavior reads the machine's - * current state through `State branch` and moves it through `Set state`. + * The graph holds one `State` node per state and an edge per transition; a behavior reads the + * machine's current state through `State branch` and moves it through `Set state` or `State transition`. */ export interface StateMachineAsset extends AssetBase { type: 'statemachine'; diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index 48e852de..6ba0592c 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -1,5 +1,5 @@ import type { GraphLookup } from '../graph/lookup'; -import { statesOf } from '../graph/stateMachine'; +import { statesOf, transitionsOf } from '../graph/stateMachine'; import { resolveAssetProps } from '../properties/resolve'; import { findAssetOf, type Project } from './Project'; @@ -16,7 +16,12 @@ export function graphLookup(project: Project): GraphLookup { }, machine: (assetId) => { const asset = findAssetOf(project, assetId, 'statemachine'); - return asset ? { name: asset.name, states: statesOf(asset.graph) } : null; + if (!asset) return null; + return { + name: asset.name, + states: statesOf(asset.graph), + transitions: transitionsOf(asset.graph), + }; }, }; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index e9b9cb11..ad4a81ad 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -339,6 +339,8 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph { to: { node: to.node, port: to.port }, }; if (e.folded === true) edge.folded = true; + const label = typeof e.label === 'string' ? e.label.trim() : ''; + if (label) edge.label = label; graph.edges.push(edge); } return graph; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 5ed94818..dc6f5ed5 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -1,4 +1,5 @@ import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js'; +import { NameTransitionCommand } from '../core/commands/graphCommands'; import { resolveObjects } from '../core/model/instance'; import { findObjectHolder } from '../core/model/Project'; import type { FrameRect } from '../core/model/texture'; @@ -287,6 +288,21 @@ export function installDebugHook(app: Application, state: EditorState, editor: E canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(), center: () => centreOf(editor.stateMachineEditor.canvas), snapping: () => editor.stateMachineEditor.canvas.snapEnabled, + /** Where a state's hidden pin sits. It is no longer grabbable – `portGrabbable` is the check. */ + portAt: (nodeId: string, port: string, isOutput: boolean) => + editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput), + portGrabbable: (nodeId: string, port: string, isOutput: boolean) => + editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput), + edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId), + transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId), + hint: () => editor.stateMachineEditor.canvas.shownHint(), + /** Names a transition the way the inspector row does, for steps that need one already named. */ + nameTransition: (edgeId: string, name: string) => + state.execute(new NameTransitionCommand(editor.stateMachineEditor.currentAssetId, edgeId, name)), + dropTarget: () => editor.stateMachineEditor.canvas.pendingDropTarget(), + danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(), + emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(), + selected: () => state.selectedNodeIds, }, particles: { current: () => editor.particlesEditor.currentAssetId, diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index 2e637c9a..c25c74ba 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -1,6 +1,8 @@ import { Container, Graphics, Text } from 'pixi.js'; import { type GraphEdge, portsCompatible } from '../../core/graph'; import { Theme, textStyle, truncate } from '../../ui'; +import { dashedLine } from '../../viewport/symbols'; +import { arrowCorners, arrowShaft, type Point, transitionEnds, transitionOffsets } from './transitions'; import { buildFoldMarks, foldedEdgeShown, @@ -17,6 +19,7 @@ import { type NodeView, portColor } from './NodeView'; const FOLD_STUB = 16; const FOLD_MARK_RADIUS = 5; const FOLD_LABEL_WIDTH = 96; +const TRANSITION_LABEL_WIDTH = 110; const BEAD_RADIUS = 4.5; /** Dash pattern and sampling of an edge hanging on a port that is gone. */ const GHOST_DASH = 7; @@ -48,6 +51,9 @@ export class EdgeLayer extends Container { /** Edges the last draw really stroked as a curve – a hidden one is missing from it. */ private readonly stroked = new Set(); 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(); + private transitionLabels: { edgeId: string; text: string; x: number; y: number }[] = []; constructor() { super(); @@ -69,6 +75,12 @@ export class EdgeLayer extends Container { this.ends = []; this.labelCount = 0; this.stroked.clear(); + this.transitionLabels = []; + this.offsets = transitionOffsets( + edges + .filter((e) => isTransition(e, views)) + .map((e) => ({ id: e.id, from: e.from.node, to: e.to.node })), + ); for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected, beads(edge.id)); // After the curves, so no line drawn later crosses a mark that stands for a hidden one. this.drawFoldMarks(); @@ -111,14 +123,25 @@ export class EdgeLayer extends Container { const ghost = out?.ghost === true || inp?.ghost === true; const valid = !ghost && portsCompatible(type, inType); const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger; - const start = { x: from.x + a.x, y: from.y + a.y }; - const end = { x: to.x + b.x, y: to.y + b.y }; + const pins = { + start: { x: from.x + a.x, y: from.y + a.y }, + end: { x: to.x + b.x, y: to.y + b.y }, + }; + const transition = isTransition(edge, views) + ? transitionEnds(from, to, this.offsets.get(edge.id) ?? 0) + : null; + const { start, end } = transition ?? pins; if (edge.folded) { - this.addFoldEnd(edge, views, start, true, to.def.label, color, hot); - this.addFoldEnd(edge, views, end, false, from.def.label, color, hot); + // The fold mark belongs on the port, whatever the line between the nodes does. + this.addFoldEnd(edge, views, pins.start, true, to.def.label, color, hot); + this.addFoldEnd(edge, views, pins.end, false, from.def.label, color, hot); if (!foldedEdgeShown(edge, selected, hot)) return; } - if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP); + const shaft = transition ? arrowShaft(start, end) : end; + if (ghost && transition) + dashedLine(this.curves, start.x, start.y, shaft.x, shaft.y, GHOST_DASH, GHOST_GAP); + else if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP); + else if (transition) this.curves.moveTo(start.x, start.y).lineTo(shaft.x, shaft.y); else bezier(this.curves, start.x, start.y, end.x, end.y); this.curves.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, @@ -126,6 +149,8 @@ export class EdgeLayer extends Container { // Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one. alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1), }); + if (transition) arrowHead(this.curves, start, end, color); + if (transition && edge.label) this.drawTransitionLabel(edge, start, end); this.stroked.add(edge.id); for (const progress of beads) { const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress); @@ -138,6 +163,23 @@ export class EdgeLayer extends Container { } } + /** The transition's name at the middle of its arrow, on a plate so the line does not run through it. */ + private drawTransitionLabel(edge: GraphEdge, start: Point, end: Point): void { + const label = this.nextLabel(); + label.text = truncate(edge.label ?? '', textStyle('small'), TRANSITION_LABEL_WIDTH); + const x = (start.x + end.x) / 2 - label.width / 2; + const y = (start.y + end.y) / 2 - label.height / 2; + label.position.set(x, y); + this.chips + .roundRect(x - 3, y - 1, label.width + 6, label.height + 2, 3) + .fill({ color: Theme.colors.node, alpha: 0.85 }); + this.transitionLabels.push({ edgeId: edge.id, text: label.text, x, y }); + } + + drawnTransitionLabels(): readonly { edgeId: string; text: string; x: number; y: number }[] { + return this.transitionLabels; + } + private addFoldEnd( edge: GraphEdge, views: ReadonlyMap, @@ -195,6 +237,21 @@ export class EdgeLayer extends Container { } } +function isTransition(edge: GraphEdge, views: ReadonlyMap): boolean { + return ( + views.get(edge.from.node)?.def.transitionEdges === true && + views.get(edge.to.node)?.def.transitionEdges === true + ); +} + +/** The filled triangle at the target end of a transition. */ +export function arrowHead(g: Graphics, start: Point, end: Point, color: number): void { + const corners = arrowCorners(start, end); + if (!corners) return; + const [tip, left, right] = corners; + g.moveTo(tip.x, tip.y).lineTo(left.x, left.y).lineTo(right.x, right.y).closePath().fill(color); +} + /** Horizontal S-curve between two points. */ export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void { const dx = curveReach(x1, x2); diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index e5afeab5..7c17946b 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -27,6 +27,8 @@ import { portTypeOf, resolveDef, RUN_BEHAVIOR, + STATE_IN, + STATE_OUT, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; @@ -44,16 +46,19 @@ import { endDrag, onDragChange, pressCount, + type MenuItem, type Size, Theme, textStyle, truncate, + ui, Widget, zoomFactor, } from '../../ui'; import { type EditorState, nodeSelection } from '../state'; import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing'; -import { bezier, EdgeLayer } from './EdgeLayer'; +import { arrowHead, bezier, EdgeLayer } from './EdgeLayer'; +import { arrowShaft, type Point, transitionPreview } from './transitions'; import { openLinkMenu, openNodeMenu } from './graphMenus'; import { NodePalette } from './NodePalette'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; @@ -112,6 +117,10 @@ export class GraphCanvas extends Widget { /** A finished drag whose palette is still open – drawn like the running one, so the connection * being made stays visible while the node type is chosen. */ private pendingDrop: { from: PortSide; pointer: { x: number; y: number } } | null = null; + /** A transition begun from a state's menu, waiting for the click that names its target. It takes + * the shape of a drag because it is one in everything but the held button – that way the running + * preview draws it without a second code path. `release` takes the Escape handler back off. */ + private armed: { from: PortSide; pointer: { x: number; y: number }; release: () => void } | null = null; /** Where the running drag would land, recomputed on every move so the drop holds no surprise. */ private dropTarget: PortSide | null = null; /** Whether the last redraw actually stroked the dangling edge. */ @@ -173,6 +182,8 @@ export class GraphCanvas extends Widget { /** Switching refits the view, since the next graph is a new subject. */ show(assetId: string): void { if (this.assetId === assetId) return; + // Its source node is in the graph being left, so the click that finished it would land nowhere. + this.cancelTransition(); this.assetId = assetId; this.sync(); this.needsFit = true; @@ -216,6 +227,7 @@ export class GraphCanvas extends Widget { if (!asset) { // An asset that was deleted (or undone away) must not linger as a graph. this.assetId = ''; + this.cancelTransition(); for (const view of this.views.values()) view.destroy({ children: true }); this.views.clear(); this.redraw(); @@ -317,6 +329,11 @@ export class GraphCanvas extends Widget { return; } if (e.target !== this) return; + // Away from every state, so there is nothing to transition to: the click drops the attempt. + if (this.armed && (e.button === 0 || e.button === 2)) { + this.finishTransition(e.global.x, e.global.y, false); + return; + } // Shift-drag on empty space pans as well, as it does in the scene view. if (e.button === 0 && e.shiftKey) { startPan(); @@ -346,6 +363,12 @@ export class GraphCanvas extends Widget { if (e.button === 1) return; if (!this.assetId) return; e.stopPropagation(); + // An armed transition eats the next click whole: on another state it lands, anywhere else it + // is dropped. Selecting or dragging the node underneath would be a second meaning for one click. + if (this.armed && (e.button === 0 || e.button === 2)) { + this.finishTransition(e.global.x, e.global.y, e.button === 0); + return; + } const local = view.toLocal(e.global); const hit = view.portAt(local.x, local.y); if (e.button === 2) { @@ -356,7 +379,13 @@ export class GraphCanvas extends Widget { } else { // Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone. if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id])); - openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y); + const at = this.toLocal(e.global); + const graphPoint = this.screenToGraph(at.x, at.y); + const extras: MenuItem[] = + view.def.transitionEdges === true + ? [{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }] + : []; + openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras); } return; } @@ -446,6 +475,10 @@ export class GraphCanvas extends Widget { this.connecting.pointer = g; this.dropTarget = this.resolveDrop(e.global.x, e.global.y); this.redraw(); + } else if (this.armed) { + this.armed.pointer = g; + this.dropTarget = this.armedTarget(e.global.x, e.global.y); + this.redraw(); } else if (this.marquee) { this.marquee.current = g; const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom; @@ -607,6 +640,18 @@ export class GraphCanvas extends Widget { return null; } + /** Null unless the drag started on a state: only those draw as a straight arrow. */ + private transitionPreviewOf( + from: PortSide, + target: PortSide | null, + pointer: Point, + ): { start: Point; end: Point } | null { + const source = this.views.get(from.nodeId); + if (source?.def.transitionEdges !== true) return null; + const to = target ? this.views.get(target.nodeId) : undefined; + return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer); + } + private nodeUnder(globalX: number, globalY: number): string | null { for (const view of this.topDownViews()) { const p = view.toLocal({ x: globalX, y: globalY }); @@ -666,6 +711,59 @@ export class GraphCanvas extends Widget { if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y); } + /** Begin a transition out of this state; the next click on another one completes it. */ + private armTransition(nodeId: string, pointer: { x: number; y: number }): void { + this.cancelTransition(); + const handlers = ui().focus.globalHandlers; + const onKey = (event: KeyboardEvent): boolean => { + if (event.key !== 'Escape' || !this.armed) return false; + this.cancelTransition(); + this.redraw(); + return true; + }; + handlers.push(onKey); + this.armed = { + from: { nodeId, port: STATE_OUT, isOutput: true }, + pointer, + release: () => { + const at = handlers.indexOf(onKey); + if (at >= 0) handlers.splice(at, 1); + }, + }; + this.dropTarget = null; + this.redraw(); + } + + private cancelTransition(): void { + this.armed?.release(); + this.armed = null; + } + + /** + * Where an armed transition would land: any other state under the pointer. A state cannot + * transition to itself, which is what `ConnectCommand` says about every node. + */ + private armedTarget(globalX: number, globalY: number): PortSide | null { + const from = this.armed?.from; + if (!from) return null; + const node = this.nodeUnder(globalX, globalY); + if (!node || node === from.nodeId) return null; + const view = this.views.get(node); + return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null; + } + + /** Finish or abandon an armed transition. True when the click belonged to it. */ + private finishTransition(globalX: number, globalY: number, connect: boolean): boolean { + const armed = this.armed; + if (!armed) return false; + const target = connect ? this.armedTarget(globalX, globalY) : null; + this.cancelTransition(); + this.dropTarget = null; + if (target) this.connect(armed.from, target); + this.redraw(); + return true; + } + /** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */ private setHint(text: string): void { this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2); @@ -688,7 +786,15 @@ export class GraphCanvas extends Widget { return; } const playing = this.state.mode === 'play'; - this.setHint(asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : ''); + this.setHint( + this.armed + ? 'Click the state to transition to – anywhere else, or Escape, cancels' + : asset.graph.nodes.length === 0 + ? 'Right-click to add a node' + : playing + ? 'live' + : '', + ); const trace = playing ? this.traceProvider(asset.id) : null; const now = this.timeProvider(); @@ -721,14 +827,21 @@ export class GraphCanvas extends Widget { this.tempEdge.clear(); this.tempEdgeShown = false; - const drag = this.connecting ?? this.pendingDrop; + const drag = this.connecting ?? this.pendingDrop ?? this.armed; const a = drag && this.anchorPoint(drag.from); if (drag && a) { // Snapping to the resolved port makes the prediction the ring shows unmistakable. const b = this.anchorPoint(target) ?? drag.pointer; - const [from, to] = drag.from.isOutput ? [a, b] : [b, a]; - bezier(this.tempEdge, from.x, from.y, to.x, to.y); + // Dragged off a state, the line already looks like the transition it is about to become. + const preview = this.transitionPreviewOf(drag.from, target, drag.pointer); + const ends = preview ? [preview.start, preview.end] : [a, b]; + const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point]; + if (preview) { + const shaft = arrowShaft(from, to); + this.tempEdge.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y); + } else bezier(this.tempEdge, from.x, from.y, to.x, to.y); this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); + if (preview) arrowHead(this.tempEdge, from, to, c.textMuted); this.tempEdgeShown = true; } @@ -799,6 +912,23 @@ export class GraphCanvas extends Widget { return this.edgeLayer.wasStroked(edgeId); } + /** The name drawn on a transition, or null when nothing is drawn on it. */ + transitionLabelDrawn(edgeId: string): string | null { + return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null; + } + + /** Whether a pin can be grabbed on this node at all – false for a state, whose ports are hidden. */ + portGrabbable(nodeId: string, port: string, isOutput: boolean): boolean { + const view = this.views.get(nodeId); + const p = view?.portPosition(port, isOutput); + return view !== undefined && p !== null && p !== undefined && view.portAt(p.x, p.y) !== null; + } + + /** The line at the bottom of the canvas – what tells the user an armed transition is waiting. */ + shownHint(): string { + return this.hint.text; + } + foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] { return this.edgeLayer .drawnMarks() diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 56982e10..8c1a7530 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -199,7 +199,9 @@ export class NodeView extends Widget { return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 }); } + /** Rows of ports, which set the node's height – none on a state, whose ports are never drawn. */ private rowCount(): number { + if (this.def.transitionEdges === true) return 0; return Math.max(this.def.inputs.length, this.def.outputs.length); } @@ -275,9 +277,12 @@ export class NodeView extends Widget { return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2; } - /** Every port with its local centre – none while collapsed, so none can be grabbed. */ + /** + * Every port with its local centre – none while collapsed, and none on a state, so neither can + * be drawn or grabbed. `portPosition` stays open either way: the edges still need their anchor. + */ private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> { - if (this.collapsed) return; + if (this.collapsed || this.def.transitionEdges === true) return; for (const [list, isOutput] of [ [this.def.inputs, false], [this.def.outputs, true], diff --git a/src/editor/graph/graphMenus.ts b/src/editor/graph/graphMenus.ts index da3b2db4..eab09cf2 100644 --- a/src/editor/graph/graphMenus.ts +++ b/src/editor/graph/graphMenus.ts @@ -13,6 +13,7 @@ export function openNodeMenu( nodes: readonly GraphNode[], x: number, y: number, + extras: readonly MenuItem[] = [], ): void { const ids = state.selectedNodeIds; const fold = (list: readonly GraphNode[], verb: string, to: boolean): MenuItem[] => { @@ -24,6 +25,7 @@ export function openNodeMenu( const expanded = nodes.filter((n) => !n.collapsed); const collapsed = nodes.filter((n) => n.collapsed); new Menu([ + ...extras, ...fold(expanded, 'Collapse', true), ...fold(collapsed, 'Expand', false), { diff --git a/src/editor/graph/transitions.ts b/src/editor/graph/transitions.ts new file mode 100644 index 00000000..926dec83 --- /dev/null +++ b/src/editor/graph/transitions.ts @@ -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 { + const groups = new Map(); + 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(); + 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 }]; +} diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index a7a2faaa..55143c97 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -3,13 +3,22 @@ import { declaresPropertyPorts, declaresVariablePorts, edgesInto, + edgesOfNode, + findNode, + type Graph, + type GraphEdge, type GraphNode, inlineInputDef, inlineInputs, + isStateNode, + MACHINE_PORT, OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM, portSignature, resolveDef, + STATE_TRANSITION, + stateName, + TRANSITION_PARAM, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; @@ -21,6 +30,7 @@ import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection'; import { listOpener, previewingHandlers } from './previewHandlers'; +import { buildTransitionPicker, buildTransitionSection } from './transitionSection'; import { currentNode, nodeParamValue } from './valueSpecs'; /** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */ @@ -39,11 +49,24 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string) const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(','); // They decide which rows the inspector builds, and two behaviors may share a name in the title. const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`; + // The transition rows and the picked name are drawn, not filled in by `refreshNodeSection`, so + // every part of them a command can change has to force a rebuild. + const transitions = isStateNode(node) + ? edgesOfNode(asset.graph, node.id) + .map((e) => `${e.id}:${e.label ?? ''}:${farStateName(asset.graph, e, node.id)}`) + .join(',') + : `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`; return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf( asset, ) .map((v) => v.key) - .join(',')}|${target}`; + .join(',')}|${target}|${transitions}`; +} + +/** The name of the state at the other end of an edge – what a transition row is labelled with. */ +function farStateName(graph: Graph, edge: GraphEdge, nodeId: string): string { + const far = findNode(graph, edge.from.node === nodeId ? edge.to.node : edge.from.node); + return far && isStateNode(far) ? stateName(far) : ''; } /** A state machine declares no variables; its one node type has none to name. */ @@ -79,8 +102,13 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s if (!ownSettings && def.params.length > 0) { addTitle(host, 'SETTINGS'); - for (const param of def.params) addSetting(param); + for (const param of def.params) { + if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION) + buildTransitionPicker(host, assetId, node, asset.graph); + else addSetting(param); + } } + if (isStateNode(node)) buildTransitionSection(host, assetId, node, asset.graph); const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0); if (free.length > 0) addTitle(host, 'INPUTS (unconnected)'); diff --git a/src/editor/inspector/transitionSection.ts b/src/editor/inspector/transitionSection.ts new file mode 100644 index 00000000..1e53d0d0 --- /dev/null +++ b/src/editor/inspector/transitionSection.ts @@ -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(); + 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); + } +} diff --git a/src/editor/statemachine/StateMachineEditor.ts b/src/editor/statemachine/StateMachineEditor.ts index 0fac4bf2..b37475eb 100644 --- a/src/editor/statemachine/StateMachineEditor.ts +++ b/src/editor/statemachine/StateMachineEditor.ts @@ -9,9 +9,9 @@ import type { EditorState } from '../state'; const INFO_WIDTH = 90; /** - * The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node, - * so placing (right-click), moving, snapping, renaming, deleting and undo are the graph's. - * Transitions are a later step. + * The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node and + * a transition an edge, so placing (right-click), moving, snapping, renaming, connecting, deleting + * and undo are all the graph's. */ export class StateMachineEditor extends AssetTabEditor { private readonly snap = new SnapToggle(() => this.canvas); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 0560984a..9140f308 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -72,8 +72,10 @@ describe('node registry', () => { 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.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. - if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0); + // An event is seeded by the runtime, so nothing flows into it. + 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. else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true); else expect(def.run).toBeTypeOf('function'); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index d08ebf10..6e849e3f 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -8,6 +8,7 @@ import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { createEmptyGraph, type DeclaredPort, + type GraphLookup, NO_LOOKUP, type NodeContext, resolveDef, @@ -38,10 +39,19 @@ interface Case { repeat?: RunResult['repeat'] | null; /** Expected nested-behavior call; `null` asserts the node calls nothing. */ 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. */ 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 ctx = fakeContext(c.context); 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], 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 }); @@ -140,6 +151,76 @@ const CASES: Record = { 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: [ { name: 'asks for as many body rounds as it was given, then continues at done', diff --git a/tests/core/state-machine.test.ts b/tests/core/state-machine.test.ts index 32f9f214..e2719bd1 100644 --- a/tests/core/state-machine.test.ts +++ b/tests/core/state-machine.test.ts @@ -5,7 +5,13 @@ */ import { describe, expect, it } from 'vitest'; 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 { BEHAVIOR_PARAM, @@ -15,8 +21,13 @@ import { nextStateName, resolveDef, STATE, + STATE_IN, STATE_NAME_PARAM, + STATE_OUT, statesOf, + transitionNames, + transitionsOf, + transitionTo, } from '../../src/core/graph'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import type { StateMachineAsset } from '../../src/core/model/Asset'; @@ -289,3 +300,122 @@ describe('a running state machine', () => { ).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']); + }); +}); diff --git a/tests/editor/transitions.test.ts b/tests/editor/transitions.test.ts new file mode 100644 index 00000000..d9efaf9b --- /dev/null +++ b/tests/editor/transitions.test.ts @@ -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 }); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 277fd157..da966538 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -9,7 +9,9 @@ import { type DeclaredPort, ENTITY_HANDLE, type EntityHandle, + type GraphLookup, type GraphNode, + NO_LOOKUP, type NodeContext, type PointerState, type Value, @@ -216,9 +218,10 @@ export function runDef( inputs: Record, params: PropertyBag = {}, entry = 'exec', + lookup: GraphLookup = NO_LOOKUP, ): RunResult { 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. */ diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index af7eb229..681a0e45 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -191,8 +191,138 @@ export async function stateMachine(t) { await press('Escape'); 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 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, { assetId: 'sm-logic', name: 'Doors logic', @@ -248,6 +378,50 @@ export async function stateMachine(t) { ); 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. await mutate(` const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic');