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
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user