A state can transition to itself: make transition and a second click on the same state connects a self transition, taking it fires On state exited and then On state entered every time, for local and global machines, while Set state into the current state stays silent as before; only states may connect to themselves, every other node is still refused, one self transition per state; the loop is drawn over the middle of the top edge with its arrow pointing down and its name at the apex, the preview already shows it while armed, and the inspector lists it once under Transitions out; new test hook transitionLabelAt, e2e 26e5 (#163)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc285af10a
commit
010b6a7f6c
12 files changed
+355
-33
No files matched your search
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* A transition from a state back into itself (#163): it re-enters the state, so `On state exited`
|
||||
* and `On state entered` fire for it – unlike `Set state` into the state the machine is already in,
|
||||
* which stays silent. Drawn as a loop over the node's top edge.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
SetMachineScopeCommand,
|
||||
SetMachineStartCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { AddNodeCommand, ConnectCommand, NameTransitionCommand } from '../../src/core/commands/graphCommands';
|
||||
import {
|
||||
MACHINE_PORT,
|
||||
STATE,
|
||||
STATE_IN,
|
||||
STATE_NAME_PARAM,
|
||||
STATE_OUT,
|
||||
TRANSITION_PARAM,
|
||||
transitionsOf,
|
||||
transitionTo,
|
||||
} from '../../src/core/graph';
|
||||
import { findAssetOf } from '../../src/core/model/Project';
|
||||
import { arrowCorners, BORDER_GAP, loopPoint, selfLoop } from '../../src/editor/graph/transitions';
|
||||
import {
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeProject,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
type ProjectFixture,
|
||||
roundTrip,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
/** A machine "Fighter" with the states Idle and Attack, starting in Attack. */
|
||||
function makeFighter(f: ProjectFixture) {
|
||||
const create = new CreateAssetCommand('statemachine', 'Fighter');
|
||||
f.stack.execute(create);
|
||||
const [idle, attack] = ['Idle', 'Attack'].map((name) => {
|
||||
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
|
||||
f.stack.execute(add);
|
||||
return add.node.id;
|
||||
}) as [string, string];
|
||||
f.stack.execute(new SetMachineStartCommand(create.asset.id, attack));
|
||||
const asset = () => findAssetOf(f.project, create.asset.id, 'statemachine')!;
|
||||
return { id: create.asset.id, idle, attack, asset };
|
||||
}
|
||||
|
||||
function connect(f: ProjectFixture, machineId: string, from: string, to: string, name = '') {
|
||||
const command = new ConnectCommand(
|
||||
machineId,
|
||||
{ node: from, port: STATE_OUT },
|
||||
{ node: to, port: STATE_IN },
|
||||
);
|
||||
f.stack.execute(command);
|
||||
if (name) f.stack.execute(new NameTransitionCommand(machineId, command.edge.id, name));
|
||||
}
|
||||
|
||||
/** Logs every entry and exit of Attack; `trigger` runs `State transition "again"` on that event. */
|
||||
function fighterBehavior(
|
||||
f: ProjectFixture,
|
||||
machineId: string,
|
||||
attack: string,
|
||||
name: string,
|
||||
trigger?: 'OnStart' | 'OnUpdate',
|
||||
) {
|
||||
const behavior = makeBehavior(f, name);
|
||||
const edit = graphEditor(f, behavior);
|
||||
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machineId });
|
||||
const exited = edit.addNode('OnStateExited', { [MACHINE_PORT]: machineId });
|
||||
const inLog = edit.addNode('Log', { message: `${name}: entered attack` });
|
||||
const outLog = edit.addNode('Log', { message: `${name}: exited attack` });
|
||||
edit.wire([
|
||||
[entered, attack, inLog, 'exec'],
|
||||
[exited, attack, outLog, 'exec'],
|
||||
]);
|
||||
if (trigger) {
|
||||
const event = edit.addNode(trigger);
|
||||
const again = edit.addNode('StateTransition', { [MACHINE_PORT]: machineId, [TRANSITION_PARAM]: 'again' });
|
||||
edit.wire([[event, 'exec', again, 'exec']]);
|
||||
}
|
||||
return behavior;
|
||||
}
|
||||
|
||||
describe('a transition from a state into itself', () => {
|
||||
it('can be drawn in a machine, survives a save, and undo takes it back', () => {
|
||||
const f = makeProject();
|
||||
const m = makeFighter(f);
|
||||
connect(f, m.id, m.attack, m.attack);
|
||||
expect(transitionsOf(m.asset().graph)).toEqual([{ from: m.attack, to: m.attack, name: '' }]);
|
||||
|
||||
const loaded = findAssetOf(roundTrip(f.project), m.id, 'statemachine')!;
|
||||
expect(transitionsOf(loaded.graph)).toEqual([{ from: m.attack, to: m.attack, name: '' }]);
|
||||
|
||||
f.stack.undo();
|
||||
expect(transitionsOf(m.asset().graph)).toEqual([]);
|
||||
});
|
||||
|
||||
it('is one per state, like every other pair', () => {
|
||||
const f = makeProject();
|
||||
const m = makeFighter(f);
|
||||
connect(f, m.id, m.attack, m.attack);
|
||||
expect(() => connect(f, m.id, m.attack, m.attack)).toThrow('Already connected');
|
||||
});
|
||||
|
||||
it('stays refused for every node that is not a state', () => {
|
||||
const f = makeProject();
|
||||
const edit = graphEditor(f, makeBehavior(f, 'B'));
|
||||
const log = edit.addNode('Log');
|
||||
expect(() => edit.connect(log, 'exec', log, 'exec')).toThrow(/itself/);
|
||||
});
|
||||
|
||||
it('is where State transition leads from that state', () => {
|
||||
const machine = {
|
||||
name: 'Fighter',
|
||||
states: [],
|
||||
start: '',
|
||||
transitions: [
|
||||
{ from: 'attack', to: 'attack', name: 'again' },
|
||||
{ from: 'attack', to: 'idle', name: 'done' },
|
||||
],
|
||||
};
|
||||
expect(transitionTo(machine, 'attack', 'again')).toBe('attack');
|
||||
expect(transitionTo(machine, 'idle', 'again')).toBe('');
|
||||
});
|
||||
|
||||
it('fires exited, then entered, each time it is taken, and the machine stays where it was', () => {
|
||||
const f = makeSpriteScene();
|
||||
const m = makeFighter(f);
|
||||
connect(f, m.id, m.attack, m.attack, 'again');
|
||||
const behavior = fighterBehavior(f, m.id, m.attack, 'F', 'OnUpdate');
|
||||
place(f, behavior.id, { x: 0, y: 0 });
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
started.frame();
|
||||
expect(started.world.log).toEqual(['F: entered attack', 'F: exited attack', 'F: entered attack']);
|
||||
// Every frame again – where a Set state into the same state would say nothing at all.
|
||||
started.frame();
|
||||
expect(started.world.log.slice(3)).toEqual(['F: exited attack', 'F: entered attack']);
|
||||
expect(started.behaviors?.machineStates(m.id).map((s) => s.state)).toEqual([m.attack]);
|
||||
});
|
||||
|
||||
it('is heard by every instance once when the machine is global', () => {
|
||||
const f = makeSpriteScene();
|
||||
const m = makeFighter(f);
|
||||
f.stack.execute(new SetMachineScopeCommand(m.id, 'global'));
|
||||
connect(f, m.id, m.attack, m.attack, 'again');
|
||||
const watcher = fighterBehavior(f, m.id, m.attack, 'W');
|
||||
const mover = fighterBehavior(f, m.id, m.attack, 'M', 'OnStart');
|
||||
place(f, watcher.id, { x: 0, y: 0 });
|
||||
place(f, mover.id, { x: 10, y: 0 });
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
started.frame();
|
||||
expect(started.world.log).toEqual([
|
||||
'W: entered attack',
|
||||
'M: entered attack',
|
||||
'W: exited attack',
|
||||
'M: exited attack',
|
||||
'W: entered attack',
|
||||
'M: entered attack',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing from a state that has no transition of the name', () => {
|
||||
const f = makeSpriteScene();
|
||||
const m = makeFighter(f);
|
||||
connect(f, m.id, m.idle, m.idle, 'again');
|
||||
const behavior = fighterBehavior(f, m.id, m.attack, 'F', 'OnUpdate');
|
||||
place(f, behavior.id, { x: 0, y: 0 });
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
started.frame();
|
||||
started.frame();
|
||||
expect(started.world.log).toEqual(['F: entered attack']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the loop a self-transition is drawn as', () => {
|
||||
const box = { x: 100, y: 200, w: 120, h: 40 };
|
||||
const loop = selfLoop(box);
|
||||
|
||||
it('stands on the middle of the top edge, both ends clear of the border', () => {
|
||||
expect(loop.start.y).toBe(box.y - BORDER_GAP);
|
||||
expect(loop.end.y).toBe(box.y - BORDER_GAP);
|
||||
expect((loop.start.x + loop.end.x) / 2).toBe(box.x + box.w / 2);
|
||||
expect(loop.start.x).toBeLessThan(loop.end.x);
|
||||
});
|
||||
|
||||
it('runs from its start to its end and rises above the node on the way', () => {
|
||||
expect(loopPoint(loop, 0)).toEqual(loop.start);
|
||||
expect(loopPoint(loop, 1)).toEqual(loop.end);
|
||||
const apex = loopPoint(loop, 0.5);
|
||||
expect(apex.x).toBe(box.x + box.w / 2);
|
||||
expect(apex.y).toBeLessThan(box.y - 30);
|
||||
// Left to right: the first quarter is still on the side it left from, the last on the side it enters.
|
||||
expect(loopPoint(loop, 0.25).x).toBeLessThan(apex.x);
|
||||
expect(loopPoint(loop, 0.75).x).toBeGreaterThan(apex.x);
|
||||
});
|
||||
|
||||
it('ends in an arrow pointing down into the node', () => {
|
||||
const [tip, a, b] = arrowCorners(loop.c2, loop.end)!;
|
||||
expect(tip).toEqual(loop.end);
|
||||
expect(a.y).toBeLessThan(tip.y);
|
||||
expect(b.y).toBeLessThan(tip.y);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user