State machines: asset, tab, State branch and Set state nodes (#51)
A state machine is an asset whose graph holds one `State` node per state (no ports, name as title), edited in a new "State machine" tab that reuses the graph canvas: right-click places "State n", snap toggle shared with the Behavior tab via SnapToggle. `statemachine` is a value kind like texture (port, literal node, variable, struct field, tagged list item). Behaviors use it through `State branch` (one exec output per state) and `Set state` (one exec input per state; the interpreter now hands `run` the entry port). States live per behavior instance in NodeContext.states, with no initial state. Ports derive from the inline picker or a wired `State machine` literal; BehaviorLookup became GraphLookup with behavior(id) and machine(id). The Graph tab is labelled "Behavior" (key unchanged). Unit tests (state-machine, kinds, catalog, ports), e2e step 26 and README added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce42d16657
commit
e7d82bd1a9
60 files changed
+1269
-213
No files matched your search
@@ -34,7 +34,7 @@ export async function graph(t) {
|
||||
};
|
||||
const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(',');
|
||||
|
||||
// 6a. Selecting the Mover asset binds the graph; the Graph tab shows it.
|
||||
// 6a. Selecting the Mover asset binds the graph; the Behavior tab shows it.
|
||||
await clickText('Mover');
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(250);
|
||||
|
||||
@@ -88,7 +88,8 @@ export async function listTab(t) {
|
||||
await sleep(150);
|
||||
check(
|
||||
'a list of its own offers every kind, the struct included',
|
||||
(await json('__gm.list.info()')).offers.join(',') === 'number,text,boolean,color,texture,struct',
|
||||
(await json('__gm.list.info()')).offers.join(',') ===
|
||||
'number,text,boolean,color,texture,statemachine,struct',
|
||||
JSON.stringify((await json('__gm.list.info()')).offers),
|
||||
);
|
||||
await pickKind('Text');
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* State machines: an asset made from the + menu, its states edited as nodes in the State machine
|
||||
* tab, read by a `State branch` and moved by a `Set state` in a behavior. The step removes
|
||||
* everything it made.
|
||||
*/
|
||||
|
||||
export async function stateMachine(t) {
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
json,
|
||||
evaluate,
|
||||
shot,
|
||||
mutate,
|
||||
press,
|
||||
click,
|
||||
clickText,
|
||||
locate,
|
||||
has,
|
||||
doubleClick,
|
||||
drag,
|
||||
} = t;
|
||||
const { typeText, undo, redo, world, play, stop } = t;
|
||||
const machines = () =>
|
||||
json(`__gm.state.project.assets
|
||||
.filter((a) => a.type === 'statemachine')
|
||||
.map((a) => ({ id: a.id, name: a.name, states: a.graph.nodes.map((n) => ({ id: n.id, name: n.params.name, x: n.x, y: n.y })) }))`);
|
||||
const states = async () => (await machines())[0]?.states ?? [];
|
||||
/** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */
|
||||
const clickLast = async (text) => {
|
||||
const p = await locate(text, true);
|
||||
await click(p.x, p.y);
|
||||
await sleep(200);
|
||||
};
|
||||
const stateLabel = (id) => json(`__gm.stateMachine.nodeLabel(${JSON.stringify(id)})`);
|
||||
const selection = () => json('__gm.state.selection');
|
||||
const outputsOf = async (nodeId) => (await json(`__gm.graph.nodeOutputs(${JSON.stringify(nodeId)})`)) ?? [];
|
||||
const inputsOf = async (nodeId) => (await json(`__gm.graph.nodeInputs(${JSON.stringify(nodeId)})`)) ?? [];
|
||||
const selectNode = async (nodeId) => {
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'node', assetId: 'sm-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
|
||||
);
|
||||
await sleep(400);
|
||||
};
|
||||
|
||||
await press('Escape');
|
||||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await sleep(200);
|
||||
|
||||
// 26a. The + menu creates a state machine; selected, it opens its own tab.
|
||||
await clickText('+');
|
||||
await sleep(200);
|
||||
await clickLast('State machine');
|
||||
await sleep(400);
|
||||
const made = (await machines())[0];
|
||||
check(
|
||||
'the + menu creates a state machine asset',
|
||||
Boolean(made) && (await machines()).length === 1,
|
||||
JSON.stringify(await machines()),
|
||||
);
|
||||
const picked = await selection();
|
||||
check(
|
||||
'the new machine is selected and its tab opens on it',
|
||||
picked?.kind === 'asset' &&
|
||||
picked.assetId === made?.id &&
|
||||
(await json('__gm.tab()')) === 'statemachine' &&
|
||||
(await json('__gm.stateMachine.current()')) === made?.id,
|
||||
JSON.stringify({
|
||||
picked,
|
||||
tab: await json('__gm.tab()'),
|
||||
current: await json('__gm.stateMachine.current()'),
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'the tab button has room for its label',
|
||||
(await json("__gm.shownLabel('State machine')")) === 'State machine',
|
||||
String(await json("__gm.shownLabel('State machine')")),
|
||||
);
|
||||
|
||||
// 26b. Right-click places a state where the pointer is, numbered and selected; no header button.
|
||||
const canvas = await json('__gm.stateMachine.canvasRect()');
|
||||
const addState = async (dy) => {
|
||||
await click(canvas.x + 80, canvas.y + dy, 'right');
|
||||
await sleep(300);
|
||||
await clickLast('State');
|
||||
await sleep(300);
|
||||
};
|
||||
await addState(60);
|
||||
await addState(220);
|
||||
const two = await states();
|
||||
check(
|
||||
'right-click → State adds a state each time, named by its number',
|
||||
two.map((s) => s.name).join(',') === 'State 1,State 2',
|
||||
JSON.stringify(two),
|
||||
);
|
||||
check('the tab header has no + State button', !(await has('+ State')));
|
||||
check(
|
||||
'the new state is selected and its node shows its name',
|
||||
(await selection())?.nodeId === two[1]?.id && (await stateLabel(two[1]?.id)) === 'State 2',
|
||||
JSON.stringify({ selection: await selection(), label: await stateLabel(two[1]?.id) }),
|
||||
);
|
||||
const rects = await Promise.all(
|
||||
two.map((s) => json(`__gm.stateMachine.nodeRect(${JSON.stringify(s.id)})`)),
|
||||
);
|
||||
check(
|
||||
'each state sits where its right-click was, the second below the first',
|
||||
rects[0] && rects[1] && rects[0].y + rects[0].h <= rects[1].y,
|
||||
JSON.stringify(rects),
|
||||
);
|
||||
await shot('01-two-states');
|
||||
|
||||
// 26b2. The header's Snap checkbox is the graph's: on, a dragged state lands on the 24 px grid.
|
||||
const GRID = 24;
|
||||
const stateXY = async (id) => (await states()).find((s) => s.id === id) ?? { x: NaN, y: NaN };
|
||||
check('snapping is off until it is asked for', (await json('__gm.stateMachine.snapping()')) === false);
|
||||
const snapLabel = await locate('Snap');
|
||||
const preSnap = JSON.stringify(await states());
|
||||
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
|
||||
await click(snapLabel.x + 30, snapLabel.y);
|
||||
await sleep(150);
|
||||
check(
|
||||
'the Snap toggle switches it on without moving a state',
|
||||
(await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap,
|
||||
JSON.stringify(await states()),
|
||||
);
|
||||
// The selected state: dragging another would move the selection off it, and 26c renames the selected one.
|
||||
const dragged = two[1]?.id;
|
||||
const from = await json(`__gm.stateMachine.nodeAt(${JSON.stringify(dragged)})`);
|
||||
const wasAt = await stateXY(dragged);
|
||||
await drag(from.x, from.y, from.x + 50, from.y + 50);
|
||||
await sleep(200);
|
||||
const nowAt = await stateXY(dragged);
|
||||
check(
|
||||
'a dragged state lands on the grid, not where the pointer let go',
|
||||
nowAt.x % GRID === 0 && nowAt.y % GRID === 0 && (nowAt.x !== wasAt.x || nowAt.y !== wasAt.y),
|
||||
`${wasAt.x},${wasAt.y} → ${nowAt.x},${nowAt.y}`,
|
||||
);
|
||||
await undo();
|
||||
const backAt = await stateXY(dragged);
|
||||
check(
|
||||
'one undo takes the snapped drag back',
|
||||
backAt.x === wasAt.x && backAt.y === wasAt.y,
|
||||
`${backAt.x},${backAt.y}`,
|
||||
);
|
||||
await click(snapLabel.x + 30, snapLabel.y);
|
||||
await sleep(150);
|
||||
check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false);
|
||||
|
||||
// 26c. The inspector renames the selected state; the node follows.
|
||||
const nameField = await json("__gm.inspectorField('Name', true)");
|
||||
check('the inspector shows a Name field for the state', Boolean(nameField), JSON.stringify(nameField));
|
||||
await doubleClick(nameField.x, nameField.y);
|
||||
await typeText('Open');
|
||||
await press('Enter');
|
||||
await sleep(300);
|
||||
const renamed = (await states())[1];
|
||||
check(
|
||||
'typing in the Name field renames the state, on the node too',
|
||||
renamed?.name === 'Open' && (await stateLabel(renamed?.id)) === 'Open',
|
||||
JSON.stringify({ renamed, label: await stateLabel(renamed?.id) }),
|
||||
);
|
||||
|
||||
// 26d. Undo and redo walk the rename and the additions back and forth.
|
||||
await undo();
|
||||
const afterOne = (await states()).map((s) => s.name).join(',');
|
||||
await undo();
|
||||
const afterTwo = (await states()).map((s) => s.name).join(',');
|
||||
await redo();
|
||||
await redo();
|
||||
const back = (await states()).map((s) => s.name).join(',');
|
||||
check(
|
||||
'undo takes the rename and then the state back, redo brings both',
|
||||
afterOne === 'State 1,State 2' && afterTwo === 'State 1' && back === 'State 1,Open',
|
||||
JSON.stringify({ afterOne, afterTwo, back }),
|
||||
);
|
||||
|
||||
// 26e. The canvas palette offers a state and nothing else.
|
||||
await click(canvas.x + canvas.w - 40, canvas.y + canvas.h - 40, 'right');
|
||||
await sleep(300);
|
||||
const offered = await json('__gm.overlayTexts()');
|
||||
check(
|
||||
'right-click offers a State and no behavior node',
|
||||
offered.includes('State') && !offered.some((text) => /branch|Set state|Number|On start/i.test(text)),
|
||||
JSON.stringify(offered),
|
||||
);
|
||||
await press('Escape');
|
||||
await sleep(200);
|
||||
|
||||
// 26f. A behavior reads the machine: Set state on start, State branch every frame.
|
||||
const [first, second] = (await states()).map((s) => s.id);
|
||||
const rig = await scaffoldBehavior(t, {
|
||||
assetId: 'sm-logic',
|
||||
name: 'Doors logic',
|
||||
boxId: 'sm-box',
|
||||
boxName: 'State box',
|
||||
logicId: 'sm-brain',
|
||||
y: LANES.stateMachine,
|
||||
graph: {
|
||||
nodes: [
|
||||
node('sm-start', 'OnStart', 0, 0),
|
||||
node('sm-set', 'SetState', 300, 0, { machine: made.id }),
|
||||
node('sm-update', 'OnUpdate', 0, 240),
|
||||
node('sm-branch', 'StateBranch', 300, 240, { machine: made.id }),
|
||||
node('sm-log', 'Log', 600, 200, { message: 'first state' }),
|
||||
node('sm-log2', 'Log', 600, 360, { message: 'second state' }),
|
||||
],
|
||||
edges: [
|
||||
edge('sm-e1', 'sm-start', 'exec', 'sm-set', first),
|
||||
edge('sm-e2', 'sm-update', 'exec', 'sm-branch', 'exec'),
|
||||
edge('sm-e3', 'sm-branch', first, 'sm-log', 'exec'),
|
||||
edge('sm-e4', 'sm-branch', second, 'sm-log2', 'exec'),
|
||||
],
|
||||
},
|
||||
});
|
||||
await selectNode('sm-branch');
|
||||
check(
|
||||
'selecting a behavior node opens the Behavior tab on that behavior',
|
||||
(await json('__gm.tab()')) === 'graph' && (await json('__gm.graph.current()')) === 'sm-logic',
|
||||
JSON.stringify({ tab: await json('__gm.tab()'), current: await json('__gm.graph.current()') }),
|
||||
);
|
||||
const branchOutputs = await outputsOf('sm-branch');
|
||||
const setInputs = await inputsOf('sm-set');
|
||||
const names = (lines) => lines.map((line) => line.trim());
|
||||
check(
|
||||
'State branch grows one output per state, named after it',
|
||||
names(branchOutputs).includes('State 1') && names(branchOutputs).includes('Open'),
|
||||
JSON.stringify(branchOutputs),
|
||||
);
|
||||
check(
|
||||
'Set state grows one input per state, named after it',
|
||||
names(setInputs).includes('State 1') && names(setInputs).includes('Open'),
|
||||
JSON.stringify(setInputs),
|
||||
);
|
||||
await shot('02-behavior-ports');
|
||||
|
||||
await play();
|
||||
await sleep(600);
|
||||
const log = (await world())?.log ?? [];
|
||||
check(
|
||||
'in play the branch follows the state Set state put the machine in',
|
||||
log.includes('first state') && !log.includes('second state'),
|
||||
JSON.stringify([...new Set(log)]),
|
||||
);
|
||||
await stop();
|
||||
|
||||
// 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');
|
||||
a.graph.nodes.push(${JSON.stringify(node('sm-fresh', 'StateBranch', 300, 520))});
|
||||
`);
|
||||
await selectNode('sm-fresh');
|
||||
check('without a machine a State branch has no state outputs', (await outputsOf('sm-fresh')).length === 0);
|
||||
// The row is named after the port, as the node body names it.
|
||||
const machineField = await json("__gm.inspectorField('machine', true)");
|
||||
await click(machineField.x, machineField.y);
|
||||
await sleep(300);
|
||||
const choices = await json('__gm.overlayTexts()');
|
||||
check(
|
||||
'the Machine picker offers the machine by name',
|
||||
choices.includes(made.name),
|
||||
JSON.stringify(choices),
|
||||
);
|
||||
await clickLast(made.name);
|
||||
await sleep(400);
|
||||
check(
|
||||
'picking it grows the state outputs',
|
||||
names(await outputsOf('sm-fresh')).includes('Open'),
|
||||
JSON.stringify(await outputsOf('sm-fresh')),
|
||||
);
|
||||
await shot('03-machine-picked');
|
||||
|
||||
// 26h. Everything the step made goes away again.
|
||||
const left = await rig.remove();
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(made.id)});
|
||||
`);
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await sleep(300);
|
||||
check(
|
||||
'the step leaves nothing behind',
|
||||
left.objects === 0 && left.assets === 0 && (await machines()).length === 0,
|
||||
JSON.stringify({ left, machines: await machines() }),
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { structVariable } from './22-struct.mjs';
|
||||
import { variableNodes } from './23-variable-nodes.mjs';
|
||||
import { propertyNodes } from './24-property-nodes.mjs';
|
||||
import { animation } from './25-animation.mjs';
|
||||
import { stateMachine } from './26-state-machine.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -55,4 +56,5 @@ export const STEPS = [
|
||||
variableNodes,
|
||||
propertyNodes,
|
||||
animation,
|
||||
stateMachine,
|
||||
];
|
||||
@@ -14,6 +14,7 @@ export const LANES = {
|
||||
pause: 300,
|
||||
nested: 700,
|
||||
keyRepeat: -300,
|
||||
stateMachine: -700,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user