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
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1085 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1088 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
@@ -187,9 +187,12 @@ export class ConnectCommand implements Command {
|
||||
const fromNode = findNode(graph, this.edge.from.node);
|
||||
const toNode = findNode(graph, this.edge.to.node);
|
||||
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
||||
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
|
||||
const lookup = graphLookup(project);
|
||||
const out = resolveDef(fromNode, graph, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
|
||||
const fromDef = resolveDef(fromNode, graph, lookup);
|
||||
// A transition back into its own state re-enters it; any other edge onto its own node is a loop.
|
||||
if (fromNode === toNode && fromDef?.transitionEdges !== true)
|
||||
throw new CommandError('A node cannot connect to itself');
|
||||
const out = fromDef?.outputs.find((p) => p.key === this.edge.from.port);
|
||||
const inp = resolveDef(toNode, graph, lookup)?.inputs.find((p) => p.key === this.edge.to.port);
|
||||
if (!out || !inp) throw new CommandError('Port not found');
|
||||
if (!portsCompatible(out.type, inp.type))
|
||||
|
||||
@@ -12,7 +12,8 @@ export const OnStateEntered: NodeDef = {
|
||||
category: 'event',
|
||||
description:
|
||||
"Runs when the machine enters a state, along that state's output. The start state counts as " +
|
||||
'entered when the instance begins; entering the state the machine is already in does not.',
|
||||
'entered when the instance begins. Setting the state the machine is already in does not ' +
|
||||
'count; a transition drawn from a state back into itself does.',
|
||||
inputs: [machinePort()],
|
||||
outputs: [],
|
||||
params: [],
|
||||
|
||||
@@ -23,7 +23,7 @@ export const StateTransition: NodeDef = {
|
||||
const machine = current ? lookup.machine(id) : null;
|
||||
const name = textOf(params[TRANSITION_PARAM] ?? '');
|
||||
const target = machine ? transitionTo(machine, current, name) : '';
|
||||
if (target) setState(ctx, id, target);
|
||||
if (target) setState(ctx, id, target, true);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -93,13 +93,14 @@ export function currentState(ctx: NodeContext, machineId: string): string {
|
||||
/**
|
||||
* The one way a machine moves: writes the new state and reports the move. Into the state it is
|
||||
* already in is no move – nothing is written, and nothing fires, which is what keeps a `Set state`
|
||||
* run every frame from firing `entered` every frame.
|
||||
* run every frame from firing `entered` every frame. `reenter` is the exception a transition drawn
|
||||
* back into its own state asks for: the state is left and entered again, both events included.
|
||||
*/
|
||||
export function setState(ctx: NodeContext, machineId: string, next: string): void {
|
||||
export function setState(ctx: NodeContext, machineId: string, next: string, reenter = false): void {
|
||||
if (!machineId) return;
|
||||
const bag = stateBag(ctx, machineId);
|
||||
const previous = bag[machineId] ?? '';
|
||||
if (previous === next) return;
|
||||
if (previous === next && !reenter) return;
|
||||
bag[machineId] = next;
|
||||
ctx.stateChanged(machineId, previous, next);
|
||||
}
|
||||
@@ -384,6 +384,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput),
|
||||
edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId),
|
||||
transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId),
|
||||
transitionLabelAt: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelAt(edgeId),
|
||||
hint: () => editor.stateMachineEditor.canvas.shownHint(),
|
||||
/** Names a transition the way the inspector row does, for steps that need one already named. */
|
||||
nameTransition: (edgeId: string, name: string) =>
|
||||
|
||||
@@ -2,7 +2,16 @@ import { Container, Graphics, Text } from 'pixi.js';
|
||||
import { type GraphEdge, portsCompatible } from '../../core/graph';
|
||||
import { Theme, textStyle, truncate } from '../../ui';
|
||||
import { dashedLine } from '../../viewport/symbols';
|
||||
import { arrowCorners, arrowShaft, type Point, transitionEnds, transitionOffsets } from './transitions';
|
||||
import {
|
||||
arrowCorners,
|
||||
arrowShaft,
|
||||
type Loop,
|
||||
loopPoint,
|
||||
type Point,
|
||||
selfLoop,
|
||||
transitionEnds,
|
||||
transitionOffsets,
|
||||
} from './transitions';
|
||||
import {
|
||||
buildFoldMarks,
|
||||
foldedEdgeShown,
|
||||
@@ -127,18 +136,24 @@ export class EdgeLayer extends Container {
|
||||
start: { x: from.x + a.x, y: from.y + a.y },
|
||||
end: { x: to.x + b.x, y: to.y + b.y },
|
||||
};
|
||||
const transition = isTransition(edge, views)
|
||||
? transitionEnds(from, to, this.offsets.get(edge.id) ?? 0)
|
||||
: null;
|
||||
const loop = from === to && isTransition(edge, views) ? selfLoop(from) : null;
|
||||
const transition = loop
|
||||
? { start: loop.start, end: loop.end }
|
||||
: isTransition(edge, views)
|
||||
? transitionEnds(from, to, this.offsets.get(edge.id) ?? 0)
|
||||
: null;
|
||||
const { start, end } = transition ?? pins;
|
||||
// The arrowhead follows the direction the line arrives in, which on the loop is its last curve.
|
||||
const approach = loop?.c2 ?? start;
|
||||
if (edge.folded) {
|
||||
// The fold mark belongs on the port, whatever the line between the nodes does.
|
||||
this.addFoldEnd(edge, views, pins.start, true, to.def.label, color, hot);
|
||||
this.addFoldEnd(edge, views, pins.end, false, from.def.label, color, hot);
|
||||
if (!foldedEdgeShown(edge, selected, hot)) return;
|
||||
}
|
||||
const shaft = transition ? arrowShaft(start, end) : end;
|
||||
if (ghost && transition)
|
||||
const shaft = transition ? arrowShaft(approach, end) : end;
|
||||
if (loop) loopPath(this.curves, loop);
|
||||
else if (ghost && transition)
|
||||
dashedLine(this.curves, start.x, start.y, shaft.x, shaft.y, GHOST_DASH, GHOST_GAP);
|
||||
else if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
|
||||
else if (transition) this.curves.moveTo(start.x, start.y).lineTo(shaft.x, shaft.y);
|
||||
@@ -149,11 +164,17 @@ export class EdgeLayer extends Container {
|
||||
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
|
||||
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
|
||||
});
|
||||
if (transition) arrowHead(this.curves, start, end, color);
|
||||
if (transition && edge.label) this.drawTransitionLabel(edge, start, end);
|
||||
if (transition) arrowHead(this.curves, approach, end, color);
|
||||
if (transition && edge.label)
|
||||
this.drawTransitionLabel(
|
||||
edge,
|
||||
loop ? loopPoint(loop, 0.5) : { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 },
|
||||
);
|
||||
this.stroked.add(edge.id);
|
||||
for (const progress of beads) {
|
||||
const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress);
|
||||
const { x, y } = loop
|
||||
? loopPoint(loop, progress)
|
||||
: curvePoint(start.x, start.y, end.x, end.y, progress);
|
||||
// Light with a dark rim: a bead in the highlight colour would vanish on the edge it lights up.
|
||||
this.beads
|
||||
.circle(x, y, BEAD_RADIUS)
|
||||
@@ -164,11 +185,11 @@ export class EdgeLayer extends Container {
|
||||
}
|
||||
|
||||
/** The transition's name at the middle of its arrow, on a plate so the line does not run through it. */
|
||||
private drawTransitionLabel(edge: GraphEdge, start: Point, end: Point): void {
|
||||
private drawTransitionLabel(edge: GraphEdge, middle: Point): void {
|
||||
const label = this.nextLabel();
|
||||
label.text = truncate(edge.label ?? '', textStyle('small'), TRANSITION_LABEL_WIDTH);
|
||||
const x = (start.x + end.x) / 2 - label.width / 2;
|
||||
const y = (start.y + end.y) / 2 - label.height / 2;
|
||||
const x = middle.x - label.width / 2;
|
||||
const y = middle.y - label.height / 2;
|
||||
label.position.set(x, y);
|
||||
this.chips
|
||||
.roundRect(x - 3, y - 1, label.width + 6, label.height + 2, 3)
|
||||
@@ -252,6 +273,19 @@ export function arrowHead(g: Graphics, start: Point, end: Point, color: number):
|
||||
g.moveTo(tip.x, tip.y).lineTo(left.x, left.y).lineTo(right.x, right.y).closePath().fill(color);
|
||||
}
|
||||
|
||||
/** The loop of a state's transition into itself, stopped at the arrowhead's base like a straight one. */
|
||||
export function loopPath(g: Graphics, loop: Loop): void {
|
||||
const shaft = arrowShaft(loop.c2, loop.end);
|
||||
g.moveTo(loop.start.x, loop.start.y).bezierCurveTo(
|
||||
loop.c1.x,
|
||||
loop.c1.y,
|
||||
loop.c2.x,
|
||||
loop.c2.y,
|
||||
shaft.x,
|
||||
shaft.y,
|
||||
);
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points. */
|
||||
export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
|
||||
const dx = curveReach(x1, x2);
|
||||
|
||||
@@ -83,8 +83,8 @@ import {
|
||||
type Travellers,
|
||||
} from './annotationDrag';
|
||||
import { ANNOTATION_TITLE_HEIGHT, AnnotationView, RESIZE_HANDLE } from './AnnotationView';
|
||||
import { arrowHead, bezier, EdgeLayer } from './EdgeLayer';
|
||||
import { arrowShaft, type Point, transitionPreview } from './transitions';
|
||||
import { arrowHead, bezier, EdgeLayer, loopPath } from './EdgeLayer';
|
||||
import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions';
|
||||
import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus';
|
||||
import { NodePalette, type PaletteAction } from './NodePalette';
|
||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||
@@ -1008,15 +1008,11 @@ export class GraphCanvas extends Widget {
|
||||
this.armed = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an armed transition would land: any other state under the pointer. A state cannot
|
||||
* transition to itself, which is what `ConnectCommand` says about every node.
|
||||
*/
|
||||
/** Where an armed transition would land: the state under the pointer, its own source included. */
|
||||
private armedTarget(globalX: number, globalY: number): PortSide | null {
|
||||
const from = this.armed?.from;
|
||||
if (!from) return null;
|
||||
if (!this.armed) return null;
|
||||
const node = this.nodeUnder(globalX, globalY);
|
||||
if (!node || node === from.nodeId) return null;
|
||||
if (!node) return null;
|
||||
const view = this.views.get(node);
|
||||
return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null;
|
||||
}
|
||||
@@ -1057,7 +1053,7 @@ export class GraphCanvas extends Widget {
|
||||
const playing = this.state.mode === 'play';
|
||||
this.setHint(
|
||||
this.armed
|
||||
? 'Click the state to transition to – anywhere else, or Escape, cancels'
|
||||
? 'Click the state to transition to, this one to re-enter it – anywhere else, or Escape, cancels'
|
||||
: asset.graph.nodes.length === 0
|
||||
? 'Right-click to add a node'
|
||||
: playing
|
||||
@@ -1107,7 +1103,14 @@ export class GraphCanvas extends Widget {
|
||||
this.tempEdgeShown = false;
|
||||
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
|
||||
const a = drag && this.anchorPoint(drag.from);
|
||||
if (drag && a) {
|
||||
const home = drag && target?.nodeId === drag.from.nodeId ? this.views.get(target.nodeId) : undefined;
|
||||
if (home?.def.transitionEdges === true) {
|
||||
const loop = selfLoop(home);
|
||||
loopPath(this.tempEdge, loop);
|
||||
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||||
arrowHead(this.tempEdge, loop.c2, loop.end, c.textMuted);
|
||||
this.tempEdgeShown = true;
|
||||
} else if (drag && a) {
|
||||
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
|
||||
const b = this.anchorPoint(target) ?? drag.pointer;
|
||||
// Dragged off a state, the line already looks like the transition it is about to become.
|
||||
@@ -1197,6 +1200,12 @@ export class GraphCanvas extends Widget {
|
||||
return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null;
|
||||
}
|
||||
|
||||
/** Where that name is drawn, its top-left corner on screen – null when nothing is drawn. */
|
||||
transitionLabelAt(edgeId: string): { x: number; y: number } | null {
|
||||
const label = this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId);
|
||||
return label ? globalPoint(this.edgeLayer, label) : null;
|
||||
}
|
||||
|
||||
/** Whether a pin can be grabbed on this node at all – false for a state, whose ports are hidden. */
|
||||
portGrabbable(nodeId: string, port: string, isOutput: boolean): boolean {
|
||||
const view = this.views.get(nodeId);
|
||||
|
||||
@@ -67,6 +67,44 @@ export function transitionEnds(from: Rect, to: Rect, offset = 0): { start: Point
|
||||
};
|
||||
}
|
||||
|
||||
/** How far apart a self-loop's two ends sit on the node's top edge, and how high it rises. */
|
||||
const LOOP_SPREAD = 14;
|
||||
const LOOP_RISE = 44;
|
||||
|
||||
/** A transition from a state back into itself: one cubic curve over the node's top edge. */
|
||||
export interface Loop {
|
||||
start: Point;
|
||||
c1: Point;
|
||||
c2: Point;
|
||||
end: Point;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loop over the middle of the box, left end out and right end in, both `BORDER_GAP` clear of
|
||||
* the border like every other transition. The control points lean outwards, so the curve bulges
|
||||
* wider than its feet and reads as a loop rather than a hump.
|
||||
*/
|
||||
export function selfLoop(box: Rect): Loop {
|
||||
const x = box.x + box.w / 2;
|
||||
const y = box.y - BORDER_GAP;
|
||||
return {
|
||||
start: { x: x - LOOP_SPREAD, y },
|
||||
c1: { x: x - LOOP_SPREAD * 2.5, y: y - LOOP_RISE },
|
||||
c2: { x: x + LOOP_SPREAD * 2.5, y: y - LOOP_RISE },
|
||||
end: { x: x + LOOP_SPREAD, y },
|
||||
};
|
||||
}
|
||||
|
||||
/** The point at `t` along the loop, 0 at its start and 1 at the arrow's tip. */
|
||||
export function loopPoint(loop: Loop, t: number): Point {
|
||||
const u = 1 - t;
|
||||
const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
|
||||
return {
|
||||
x: a * loop.start.x + b * loop.c1.x + c * loop.c2.x + d * loop.end.x,
|
||||
y: a * loop.start.y + b * loop.c1.y + c * loop.c2.y + d * loop.end.y,
|
||||
};
|
||||
}
|
||||
|
||||
/** One transition as the offsets care about it: which states it joins, under which edge id. */
|
||||
export interface TransitionLink {
|
||||
id: string;
|
||||
|
||||
@@ -80,12 +80,13 @@ export function buildTransitionSection(
|
||||
);
|
||||
}
|
||||
|
||||
/** Edges joining this state to another one, on the given side. */
|
||||
/** Edges joining this state to a state, on the given side. One back into itself is listed as going
|
||||
* out only – it is one transition, and twice in the panel it would read as two. */
|
||||
function transitionsAt(graph: Graph, nodeId: string, outgoing: boolean): GraphEdge[] {
|
||||
return graph.edges.filter((e) => {
|
||||
const [mine, other] = outgoing ? [e.from.node, e.to.node] : [e.to.node, e.from.node];
|
||||
const far = findNode(graph, other);
|
||||
return mine === nodeId && far !== undefined && isStateNode(far);
|
||||
return mine === nodeId && far !== undefined && isStateNode(far) && (outgoing || other !== nodeId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -294,6 +294,35 @@ export async function stateMachine(t) {
|
||||
await shot('05c-both-ways');
|
||||
await undo();
|
||||
|
||||
// 26e5. (#163) The click that lands on the state the transition started from makes one back into
|
||||
// it – a loop over the node, which re-enters the state when it is taken.
|
||||
const beforeLoop = (await transitions()).length;
|
||||
await makeTransition(first, first);
|
||||
const looped = await transitions();
|
||||
const loop = looped.find((e) => e.from.node === first && e.to.node === first);
|
||||
check(
|
||||
'clicking the state the transition started from makes one back into itself',
|
||||
looped.length === beforeLoop + 1 && loop !== undefined,
|
||||
JSON.stringify(looped),
|
||||
);
|
||||
await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(loop?.id)}, 'again')`);
|
||||
await settle();
|
||||
const loopLabel = await json(`__gm.stateMachine.transitionLabelAt(${JSON.stringify(loop?.id)})`);
|
||||
const firstRect = await json(`__gm.stateMachine.nodeRect(${JSON.stringify(first)})`);
|
||||
check(
|
||||
'it is stroked as a loop over the state: its name sits above the node, within its width',
|
||||
(await json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(loop?.id)})`)) === true &&
|
||||
loopLabel !== null &&
|
||||
loopLabel.y < firstRect.y &&
|
||||
loopLabel.x > firstRect.x &&
|
||||
loopLabel.x < firstRect.x + firstRect.w,
|
||||
JSON.stringify({ loopLabel, firstRect }),
|
||||
);
|
||||
await shot('05d-self');
|
||||
await undo();
|
||||
await undo();
|
||||
check('undo takes the loop back', (await transitions()).length === beforeLoop);
|
||||
|
||||
// 26f. A behavior reads the machine: Set state on start, State branch every frame.
|
||||
const rig = await scaffoldBehavior(t, {
|
||||
assetId: 'sm-logic',
|
||||
|
||||
Reference in new issue
Block a user