Files
feuerwerk/src/core/graph/stateMachine.ts
T
rootandClaude Opus 5 68c92daaa6
game-maker / suite (push) Successful in 10m22s
A transition carries a name, so a state needs no visible port (#52)
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>
2026-09-14 14:05:18 +02:00

108 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { textOf } from '../model/Properties';
import { flatDef } from '../properties/PropertySchema';
import { edgesInto, type Graph, type GraphNode } from './Graph';
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 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: [{ 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) }),
};
export function isStateNode(node: GraphNode): boolean {
return node.type === STATE;
}
export function stateName(node: GraphNode): string {
return textOf(node.params[STATE_NAME_PARAM] ?? '') || State.label;
}
/** The states of a machine's graph in node order – what its ports and the runtime name. */
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}`;
}
/** The `machine` input; unconnected, it shows the machine picker. */
export const machinePort = (): PortDef => ({
key: MACHINE_PORT,
label: 'machine',
type: 'string',
field: flatDef(MACHINE_PORT, 'statemachine', { label: 'Machine' }),
});
/**
* The machine a node's `machine` input names while the graph is drawn: the inline pick, or the
* `State machine` literal an edge brings in. A port is derived from the graph, not from a running
* value, so a variable or a list item feeding the input yields no state ports.
*/
export function machineOf({ node, graph, lookup }: PortSource): StateMachine | null {
const edge = edgesInto(graph, node.id, MACHINE_PORT)[0];
const source = edge && graph.nodes.find((n) => n.id === edge.from.node);
const id = edge
? source?.type === STATE_MACHINE
? textOf(source.params.value ?? '')
: ''
: textOf(node.params[MACHINE_PORT] ?? '');
return id ? lookup.machine(id) : null;
}
/** One exec port per state, keyed by the state's id – a rename keeps every edge. */
export function statePorts(machine: StateMachine | null): PortDef[] {
return (machine?.states ?? []).map((state) => ({ key: state.id, label: state.name, type: 'exec' }));
}