206 lines
7.7 KiB
TypeScript
206 lines
7.7 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|