Several transitions between the same pair of states: make transition onto a state that is already connected in that direction adds another transition, which gets its own name right away (Transition 2, Transition 3, skipping names that already leave the state), so two unnamed transitions between one pair never exist; renaming is refused when another transition between the same pair in the same direction already carries the name, the empty name included, while equal names to other targets are only warned about as before; the behavior graph keeps Already connected; self transitions may repeat too, each further loop drawn as a ring around the previous one with the preview showing the next ring, and transition labels that would cover another one slide along their own edge until they are free (pure function freeLabelSpot); test hook transitionLabelAt also returns width and height, e2e 26e6 (#162)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
010b6a7f6c
commit
25ec7879a0
9 files changed
+402
-34
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, 1088 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1092 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
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
portsCompatible,
|
||||
} from '../graph/Graph';
|
||||
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
|
||||
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { graphLookup } from '../model/graphLookup';
|
||||
import { newId } from '../ids';
|
||||
@@ -193,11 +194,17 @@ export class ConnectCommand implements Command {
|
||||
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);
|
||||
const toDef = resolveDef(toNode, graph, lookup);
|
||||
const inp = toDef?.inputs.find((p) => p.key === this.edge.to.port);
|
||||
if (!out || !inp) throw new CommandError('Port not found');
|
||||
if (!portsCompatible(out.type, inp.type))
|
||||
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
||||
if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected');
|
||||
if (graph.edges.some((e) => sameEnds(e, this.edge))) {
|
||||
// Transitions are told apart by name, so a further one between the same states gets its own.
|
||||
if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true)
|
||||
throw new CommandError('Already connected');
|
||||
this.edge.label = nextTransitionName(graph, this.edge);
|
||||
}
|
||||
const occupied = inp.type === 'exec' ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
|
||||
this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e));
|
||||
graph.edges.push(this.edge);
|
||||
@@ -252,8 +259,16 @@ export class NameTransitionCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const edge = requireEdge(project, this.assetId, this.edgeId);
|
||||
const name = this.name.trim();
|
||||
// Between the same two states the name is all that tells transitions apart.
|
||||
if (parallelTransitions(requireGraph(project, this.assetId), edge).some((e) => (e.label ?? '') === name))
|
||||
throw new CommandError(
|
||||
name
|
||||
? `Another transition between these states is already called "${name}"`
|
||||
: 'Another transition between these states is already unnamed',
|
||||
);
|
||||
this.previous = edge.label ?? '';
|
||||
this.apply(edge, this.name.trim());
|
||||
this.apply(edge, name);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { textOf } from '../model/Properties';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import { edgesInto, type Graph, type GraphNode, type PortType } from './Graph';
|
||||
import { edgesInto, type Graph, type GraphEdge, type GraphNode, type PortType } from './Graph';
|
||||
import type { MachineState, MachineTransition, StateMachine } from './lookup';
|
||||
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
|
||||
|
||||
@@ -99,6 +99,25 @@ export function nextStateName(graph: Graph): string {
|
||||
return `${State.label} ${statesOf(graph).length + 1}`;
|
||||
}
|
||||
|
||||
/** Transition edges joining the same two states in the same direction as `edge`, itself left out. */
|
||||
export function parallelTransitions(graph: Graph, edge: GraphEdge): GraphEdge[] {
|
||||
return graph.edges.filter(
|
||||
(e) => e.id !== edge.id && e.from.node === edge.from.node && e.to.node === edge.to.node,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a further transition between the same two states is called: "Transition 3" for the third,
|
||||
* counting up past any name already leaving that state – two of one name out of a state would
|
||||
* both be dead, and two unnamed ones between the same pair could not be told apart.
|
||||
*/
|
||||
export function nextTransitionName(graph: Graph, edge: GraphEdge): string {
|
||||
const taken = new Set(graph.edges.filter((e) => e.from.node === edge.from.node).map((e) => e.label));
|
||||
let n = parallelTransitions(graph, edge).length + 1;
|
||||
while (taken.has(`Transition ${n}`)) n++;
|
||||
return `Transition ${n}`;
|
||||
}
|
||||
|
||||
/** The `machine` input; unconnected, it shows the machine picker. */
|
||||
export const machinePort = (): PortDef => ({
|
||||
key: MACHINE_PORT,
|
||||
|
||||
@@ -5,9 +5,13 @@ import { dashedLine } from '../../viewport/symbols';
|
||||
import {
|
||||
arrowCorners,
|
||||
arrowShaft,
|
||||
direction,
|
||||
freeLabelSpot,
|
||||
type Loop,
|
||||
loopPoint,
|
||||
loopRings,
|
||||
type Point,
|
||||
type Rect,
|
||||
selfLoop,
|
||||
transitionEnds,
|
||||
transitionOffsets,
|
||||
@@ -46,6 +50,12 @@ export interface FoldMark {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A transition's name as drawn: its text and the plate behind it. */
|
||||
export interface TransitionLabel extends Rect {
|
||||
edgeId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The curves between nodes and the marks of the hidden ones, in graph coordinates. */
|
||||
export class EdgeLayer extends Container {
|
||||
private readonly curves = new Graphics();
|
||||
@@ -62,7 +72,9 @@ export class EdgeLayer extends Container {
|
||||
private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = [];
|
||||
/** Sideways shift per transition, so two between the same states do not lie on top of each other. */
|
||||
private offsets = new Map<string, number>();
|
||||
private transitionLabels: { edgeId: string; text: string; x: number; y: number }[] = [];
|
||||
/** Which ring each loop back into its own state takes, so several nest instead of overlapping. */
|
||||
private rings = new Map<string, number>();
|
||||
private transitionLabels: TransitionLabel[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -85,11 +97,11 @@ export class EdgeLayer extends Container {
|
||||
this.labelCount = 0;
|
||||
this.stroked.clear();
|
||||
this.transitionLabels = [];
|
||||
this.offsets = transitionOffsets(
|
||||
edges
|
||||
.filter((e) => isTransition(e, views))
|
||||
.map((e) => ({ id: e.id, from: e.from.node, to: e.to.node })),
|
||||
);
|
||||
const links = edges
|
||||
.filter((e) => isTransition(e, views))
|
||||
.map((e) => ({ id: e.id, from: e.from.node, to: e.to.node }));
|
||||
this.offsets = transitionOffsets(links);
|
||||
this.rings = loopRings(links);
|
||||
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected, beads(edge.id));
|
||||
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
|
||||
this.drawFoldMarks();
|
||||
@@ -136,7 +148,8 @@ 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 loop = from === to && isTransition(edge, views) ? selfLoop(from) : null;
|
||||
const loop =
|
||||
from === to && isTransition(edge, views) ? selfLoop(from, this.rings.get(edge.id) ?? 0) : null;
|
||||
const transition = loop
|
||||
? { start: loop.start, end: loop.end }
|
||||
: isTransition(edge, views)
|
||||
@@ -169,6 +182,7 @@ export class EdgeLayer extends Container {
|
||||
this.drawTransitionLabel(
|
||||
edge,
|
||||
loop ? loopPoint(loop, 0.5) : { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 },
|
||||
loop ? { x: 1, y: 0 } : (direction(start, end) ?? { x: 1, y: 0 }),
|
||||
);
|
||||
this.stroked.add(edge.id);
|
||||
for (const progress of beads) {
|
||||
@@ -184,20 +198,21 @@ 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, middle: Point): void {
|
||||
/** The transition's name at the middle of its arrow, on a plate so the line does not run through it,
|
||||
* slid along the arrow where another name already stands there. */
|
||||
private drawTransitionLabel(edge: GraphEdge, middle: Point, along: Point): void {
|
||||
const label = this.nextLabel();
|
||||
label.text = truncate(edge.label ?? '', textStyle('small'), TRANSITION_LABEL_WIDTH);
|
||||
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)
|
||||
.fill({ color: Theme.colors.node, alpha: 0.85 });
|
||||
this.transitionLabels.push({ edgeId: edge.id, text: label.text, x, y });
|
||||
const [w, h] = [label.width + 6, label.height + 2];
|
||||
const spot = freeLabelSpot(middle, w, h, along, this.transitionLabels);
|
||||
const plate = { x: spot.x - w / 2, y: spot.y - h / 2, w, h };
|
||||
label.position.set(plate.x + 3, plate.y + 1);
|
||||
this.chips.roundRect(plate.x, plate.y, w, h, 3).fill({ color: Theme.colors.node, alpha: 0.85 });
|
||||
this.transitionLabels.push({ edgeId: edge.id, text: label.text, ...plate });
|
||||
}
|
||||
|
||||
drawnTransitionLabels(): readonly { edgeId: string; text: string; x: number; y: number }[] {
|
||||
/** The names as drawn, each with the plate behind it. */
|
||||
drawnTransitionLabels(): readonly TransitionLabel[] {
|
||||
return this.transitionLabels;
|
||||
}
|
||||
|
||||
|
||||
@@ -1105,7 +1105,10 @@ export class GraphCanvas extends Widget {
|
||||
const a = drag && this.anchorPoint(drag.from);
|
||||
const home = drag && target?.nodeId === drag.from.nodeId ? this.views.get(target.nodeId) : undefined;
|
||||
if (home?.def.transitionEdges === true) {
|
||||
const loop = selfLoop(home);
|
||||
const ring = asset.graph.edges.filter(
|
||||
(e) => e.from.node === home.node.id && e.to.node === home.node.id,
|
||||
).length;
|
||||
const loop = selfLoop(home, ring);
|
||||
loopPath(this.tempEdge, loop);
|
||||
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||||
arrowHead(this.tempEdge, loop.c2, loop.end, c.textMuted);
|
||||
@@ -1200,10 +1203,13 @@ 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 {
|
||||
/** Where that name's plate is drawn on screen, top-left corner and size – null when nothing is drawn. */
|
||||
transitionLabelAt(edgeId: string): { x: number; y: number; w: number; h: number } | null {
|
||||
const label = this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId);
|
||||
return label ? globalPoint(this.edgeLayer, label) : null;
|
||||
if (!label) return null;
|
||||
const corner = globalPoint(this.edgeLayer, label);
|
||||
const far = globalPoint(this.edgeLayer, { x: label.x + label.w, y: label.y + label.h });
|
||||
return { ...corner, w: far.x - corner.x, h: far.y - corner.y };
|
||||
}
|
||||
|
||||
/** Whether a pin can be grabbed on this node at all – false for a state, whose ports are hidden. */
|
||||
|
||||
@@ -70,6 +70,9 @@ 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;
|
||||
/** What each further loop on the same state adds, so they nest with room for a name between them. */
|
||||
const LOOP_SPREAD_STEP = 6;
|
||||
const LOOP_RISE_STEP = 24;
|
||||
|
||||
/** A transition from a state back into itself: one cubic curve over the node's top edge. */
|
||||
export interface Loop {
|
||||
@@ -82,19 +85,35 @@ export interface Loop {
|
||||
/**
|
||||
* 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.
|
||||
* wider than its feet and reads as a loop rather than a hump. `ring` counts the loops already on the
|
||||
* state: each further one sits outside the last.
|
||||
*/
|
||||
export function selfLoop(box: Rect): Loop {
|
||||
export function selfLoop(box: Rect, ring = 0): Loop {
|
||||
const x = box.x + box.w / 2;
|
||||
const y = box.y - BORDER_GAP;
|
||||
const spread = LOOP_SPREAD + ring * LOOP_SPREAD_STEP;
|
||||
const rise = LOOP_RISE + ring * LOOP_RISE_STEP;
|
||||
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 },
|
||||
start: { x: x - spread, y },
|
||||
c1: { x: x - spread * 2.5, y: y - rise },
|
||||
c2: { x: x + spread * 2.5, y: y - rise },
|
||||
end: { x: x + spread, y },
|
||||
};
|
||||
}
|
||||
|
||||
/** Which ring each loop takes: the loops on one state count up from 0 in edge order. */
|
||||
export function loopRings(links: readonly TransitionLink[]): Map<string, number> {
|
||||
const count = new Map<string, number>();
|
||||
const rings = new Map<string, number>();
|
||||
for (const link of links) {
|
||||
if (link.from !== link.to) continue;
|
||||
const ring = count.get(link.from) ?? 0;
|
||||
rings.set(link.id, ring);
|
||||
count.set(link.from, ring + 1);
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -105,6 +124,36 @@ export function loopPoint(loop: Loop, t: number): Point {
|
||||
};
|
||||
}
|
||||
|
||||
/** How far a name may slide along its arrow looking for room, and in which steps. */
|
||||
const LABEL_REACH = 120;
|
||||
const LABEL_STEP = 2;
|
||||
|
||||
function overlaps(a: Rect, b: Rect): boolean {
|
||||
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a transition's name goes, as the centre of a `w` by `h` plate: at `centre` where that plate
|
||||
* overlaps none already placed, else at the nearest free spot along `dir`, its own arrow – arrows
|
||||
* side by side are closer than a name is wide, and a name slid sideways would read as the
|
||||
* neighbour's. Stays at `centre` when nothing within reach is free.
|
||||
*/
|
||||
export function freeLabelSpot(
|
||||
centre: Point,
|
||||
w: number,
|
||||
h: number,
|
||||
dir: Point,
|
||||
placed: readonly Rect[],
|
||||
): Point {
|
||||
for (let s = 0; s <= LABEL_REACH; s += LABEL_STEP) {
|
||||
for (const sign of s === 0 ? [1] : [1, -1]) {
|
||||
const spot = { x: centre.x + dir.x * s * sign, y: centre.y + dir.y * s * sign };
|
||||
if (!placed.some((p) => overlaps(p, { x: spot.x - w / 2, y: spot.y - h / 2, w, h }))) return spot;
|
||||
}
|
||||
}
|
||||
return centre;
|
||||
}
|
||||
|
||||
/** One transition as the offsets care about it: which states it joins, under which edge id. */
|
||||
export interface TransitionLink {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Several transitions between the same two states (#162): they are told apart by name, so a further
|
||||
* one gets a name of its own when it is drawn, and a rename may never make two of them alike.
|
||||
* In a behavior graph a second edge between the same ports stays refused.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, 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 {
|
||||
freeLabelSpot,
|
||||
loopPoint,
|
||||
loopRings,
|
||||
selfLoop,
|
||||
transitionOffsets,
|
||||
} from '../../src/editor/graph/transitions';
|
||||
import {
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeProject,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
type ProjectFixture,
|
||||
roundTrip,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
/** A machine "Guard" with the states Patrol, Chase and Search, starting in Patrol. */
|
||||
function makeGuard(f: ProjectFixture) {
|
||||
const create = new CreateAssetCommand('statemachine', 'Guard');
|
||||
f.stack.execute(create);
|
||||
const [patrol, chase, search] = ['Patrol', 'Chase', 'Search'].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, string];
|
||||
f.stack.execute(new SetMachineStartCommand(create.asset.id, patrol));
|
||||
const asset = () => findAssetOf(f.project, create.asset.id, 'statemachine')!;
|
||||
const names = () => transitionsOf(asset().graph).map((t) => t.name);
|
||||
return { id: create.asset.id, patrol, chase, search, asset, names };
|
||||
}
|
||||
|
||||
function connect(f: ProjectFixture, machineId: string, from: string, to: string): string {
|
||||
const command = new ConnectCommand(
|
||||
machineId,
|
||||
{ node: from, port: STATE_OUT },
|
||||
{ node: to, port: STATE_IN },
|
||||
);
|
||||
f.stack.execute(command);
|
||||
return command.edge.id;
|
||||
}
|
||||
|
||||
function rename(f: ProjectFixture, machineId: string, edgeId: string, name: string): void {
|
||||
f.stack.execute(new NameTransitionCommand(machineId, edgeId, name));
|
||||
}
|
||||
|
||||
describe('several transitions between the same two states', () => {
|
||||
it('gives the second and third one their own names', () => {
|
||||
const f = makeProject();
|
||||
const g = makeGuard(f);
|
||||
connect(f, g.id, g.patrol, g.chase);
|
||||
connect(f, g.id, g.patrol, g.chase);
|
||||
connect(f, g.id, g.patrol, g.chase);
|
||||
expect(g.names()).toEqual(['', 'Transition 2', 'Transition 3']);
|
||||
});
|
||||
|
||||
it('skips a name already leaving the state, towards any state', () => {
|
||||
const f = makeProject();
|
||||
const g = makeGuard(f);
|
||||
rename(f, g.id, connect(f, g.id, g.patrol, g.search), 'Transition 2');
|
||||
connect(f, g.id, g.patrol, g.chase);
|
||||
connect(f, g.id, g.patrol, g.chase);
|
||||
expect(g.names()).toEqual(['Transition 2', '', 'Transition 3']);
|
||||
});
|
||||
|
||||
it('leaves the way back alone: it joins the pair in the other direction', () => {
|
||||
const f = makeProject();
|
||||
const g = makeGuard(f);
|
||||
connect(f, g.id, g.patrol, g.chase);
|
||||
connect(f, g.id, g.chase, g.patrol);
|
||||
expect(g.names()).toEqual(['', '']);
|
||||
});
|
||||
|
||||
it('counts only transitions towards the same state when a rename is checked', () => {
|
||||
const f = makeProject();
|
||||
const g = makeGuard(f);
|
||||
connect(f, g.id, g.patrol, g.search);
|
||||
const chase = connect(f, g.id, g.patrol, g.chase);
|
||||
rename(f, g.id, chase, 'seen');
|
||||
// Two unnamed ones out of Patrol are only warned about in the inspector, not refused.
|
||||
rename(f, g.id, chase, '');
|
||||
expect(g.names()).toEqual(['', '']);
|
||||
});
|
||||
|
||||
it('survives a save, and undo takes back the second one only, redo brings it back by the same name', () => {
|
||||
const f = makeProject();
|
||||
const g = makeGuard(f);
|
||||
rename(f, g.id, connect(f, g.id, g.patrol, g.chase), 'seen');
|
||||
connect(f, g.id, g.patrol, g.chase);
|
||||
const loaded = findAssetOf(roundTrip(f.project), g.id, 'statemachine')!;
|
||||
expect(transitionsOf(loaded.graph).map((t) => t.name)).toEqual(['seen', 'Transition 2']);
|
||||
|
||||
f.stack.undo();
|
||||
expect(g.names()).toEqual(['seen']);
|
||||
f.stack.redo();
|
||||
expect(g.names()).toEqual(['seen', 'Transition 2']);
|
||||
});
|
||||
|
||||
it('refuses a rename that would make two of them alike, the empty name included', () => {
|
||||
const f = makeProject();
|
||||
const g = makeGuard(f);
|
||||
const first = connect(f, g.id, g.patrol, g.chase);
|
||||
const second = connect(f, g.id, g.patrol, g.chase);
|
||||
expect(() => rename(f, g.id, second, '')).toThrow('already unnamed');
|
||||
rename(f, g.id, first, 'seen');
|
||||
expect(() => rename(f, g.id, second, ' seen ')).toThrow('already called "seen"');
|
||||
// Once the first has a name, the second may go without one.
|
||||
rename(f, g.id, second, '');
|
||||
expect(g.names()).toEqual(['seen', '']);
|
||||
});
|
||||
|
||||
it('lets State transition pick one of them by name', () => {
|
||||
const machine = {
|
||||
name: 'Guard',
|
||||
states: [],
|
||||
start: '',
|
||||
transitions: [
|
||||
{ from: 'patrol', to: 'chase', name: 'seen' },
|
||||
{ from: 'patrol', to: 'chase', name: 'alarm' },
|
||||
],
|
||||
};
|
||||
expect(transitionTo(machine, 'patrol', 'seen')).toBe('chase');
|
||||
expect(transitionTo(machine, 'patrol', 'alarm')).toBe('chase');
|
||||
expect(transitionTo(machine, 'patrol', '')).toBe('');
|
||||
});
|
||||
|
||||
it('is taken at runtime under the name it was given', () => {
|
||||
const f = makeSpriteScene();
|
||||
const g = makeGuard(f);
|
||||
rename(f, g.id, connect(f, g.id, g.patrol, g.chase), 'seen');
|
||||
rename(f, g.id, connect(f, g.id, g.patrol, g.chase), 'alarm');
|
||||
const behavior = makeBehavior(f, 'Guarding');
|
||||
const edit = graphEditor(f, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const go = edit.addNode('StateTransition', { [MACHINE_PORT]: g.id, [TRANSITION_PARAM]: 'alarm' });
|
||||
edit.wire([[start, 'exec', go, 'exec']]);
|
||||
place(f, behavior.id, { x: 0, y: 0 });
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
started.frame();
|
||||
expect(started.behaviors?.machineStates(g.id).map((s) => s.state)).toEqual([g.chase]);
|
||||
});
|
||||
|
||||
it('stays one edge per pair of ports in a behavior graph', () => {
|
||||
const f = makeProject();
|
||||
const edit = graphEditor(f, makeBehavior(f, 'B'));
|
||||
const start = edit.addNode('OnStart');
|
||||
const log = edit.addNode('Log');
|
||||
edit.connect(start, 'exec', log, 'exec');
|
||||
expect(() => edit.connect(start, 'exec', log, 'exec')).toThrow('Already connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('how several of them are drawn', () => {
|
||||
it('lays the ones in the same direction side by side', () => {
|
||||
const offsets = transitionOffsets([
|
||||
{ id: 'a', from: 'p', to: 'c' },
|
||||
{ id: 'b', from: 'p', to: 'c' },
|
||||
{ id: 'c', from: 'p', to: 'c' },
|
||||
]);
|
||||
expect(new Set(offsets.values()).size).toBe(3);
|
||||
expect(Math.abs(offsets.get('b')!)).toBe(0);
|
||||
});
|
||||
|
||||
it('puts a name at the middle of its arrow while nothing is there yet', () => {
|
||||
const spot = freeLabelSpot({ x: 50, y: 50 }, 40, 16, { x: 0, y: 1 }, [{ x: 0, y: 0, w: 10, h: 10 }]);
|
||||
expect(spot).toEqual({ x: 50, y: 50 });
|
||||
});
|
||||
|
||||
it('slides a name along its own arrow until its plate is clear of the one already there', () => {
|
||||
// Two vertical arrows 14 apart: the names are far wider than that, so they would cover each other.
|
||||
const first = { x: 50 - 30, y: 100 - 8, w: 60, h: 16 };
|
||||
const spot = freeLabelSpot({ x: 64, y: 100 }, 80, 16, { x: 0, y: 1 }, [first]);
|
||||
expect(spot.x).toBe(64);
|
||||
expect(spot.y).toBe(116);
|
||||
});
|
||||
|
||||
it('stays at the middle when nothing within reach is free', () => {
|
||||
const wall = { x: -1000, y: -1000, w: 2000, h: 2000 };
|
||||
expect(freeLabelSpot({ x: 5, y: 5 }, 10, 10, { x: 1, y: 0 }, [wall])).toEqual({ x: 5, y: 5 });
|
||||
});
|
||||
|
||||
it('nests the loops on one state, each outside the last, counting per state', () => {
|
||||
const rings = loopRings([
|
||||
{ id: 'a', from: 'p', to: 'p' },
|
||||
{ id: 'x', from: 'p', to: 'c' },
|
||||
{ id: 'b', from: 'p', to: 'p' },
|
||||
{ id: 'c', from: 'c', to: 'c' },
|
||||
]);
|
||||
expect([...rings]).toEqual([
|
||||
['a', 0],
|
||||
['b', 1],
|
||||
['c', 0],
|
||||
]);
|
||||
const box = { x: 0, y: 100, w: 120, h: 40 };
|
||||
const [inner, outer] = [selfLoop(box, 0), selfLoop(box, 1)];
|
||||
expect(outer.start.x).toBeLessThan(inner.start.x);
|
||||
expect(outer.end.x).toBeGreaterThan(inner.end.x);
|
||||
// Room for a name between the two apexes, where each loop carries its own.
|
||||
expect(loopPoint(inner, 0.5).y - loopPoint(outer, 0.5).y).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
});
|
||||
@@ -97,11 +97,12 @@ describe('a transition from a state into itself', () => {
|
||||
expect(transitionsOf(m.asset().graph)).toEqual([]);
|
||||
});
|
||||
|
||||
it('is one per state, like every other pair', () => {
|
||||
it('can come twice, the second under a name of its own, like every other pair (#162)', () => {
|
||||
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');
|
||||
connect(f, m.id, m.attack, m.attack);
|
||||
expect(transitionsOf(m.asset().graph).map((t) => t.name)).toEqual(['', 'Transition 2']);
|
||||
});
|
||||
|
||||
it('stays refused for every node that is not a state', () => {
|
||||
|
||||
@@ -323,6 +323,48 @@ export async function stateMachine(t) {
|
||||
await undo();
|
||||
check('undo takes the loop back', (await transitions()).length === beforeLoop);
|
||||
|
||||
// 26e6. (#162) A second transition between the same pair, in the same direction: it gets a name
|
||||
// of its own, is drawn beside the first, and cannot be renamed into the first one's name.
|
||||
await makeTransition(first, second);
|
||||
const pair = (await transitions()).filter((e) => e.from.node === first && e.to.node === second);
|
||||
const extra = pair.find((e) => e.label !== 'alarm');
|
||||
check(
|
||||
'a second transition between the same states is made, under a name of its own',
|
||||
pair.length === 2 && extra?.label === 'Transition 2',
|
||||
JSON.stringify(pair),
|
||||
);
|
||||
const pairLabels = await Promise.all(
|
||||
pair.map((e) => json(`__gm.stateMachine.transitionLabelAt(${JSON.stringify(e.id)})`)),
|
||||
);
|
||||
check(
|
||||
'both are stroked, their names side by side rather than on top of each other',
|
||||
pairLabels.every((l) => l !== null) &&
|
||||
!(
|
||||
pairLabels[0].x < pairLabels[1].x + pairLabels[1].w &&
|
||||
pairLabels[1].x < pairLabels[0].x + pairLabels[0].w &&
|
||||
pairLabels[0].y < pairLabels[1].y + pairLabels[1].h &&
|
||||
pairLabels[1].y < pairLabels[0].y + pairLabels[0].h
|
||||
),
|
||||
JSON.stringify(pairLabels),
|
||||
);
|
||||
await shot('05e-parallel');
|
||||
await evaluate("__gm.state.notice = ''");
|
||||
const extraField = await locate('Transition 2', true);
|
||||
await doubleClick(extraField.x, extraField.y);
|
||||
await typeText('alarm');
|
||||
await press('Enter');
|
||||
check(
|
||||
"renaming it into the first one's name is refused with a notice",
|
||||
(await transitions()).find((e) => e.id === extra?.id)?.label === 'Transition 2' &&
|
||||
(await json('__gm.state.notice')).includes('already called "alarm"'),
|
||||
JSON.stringify({ edges: await transitions(), notice: await json('__gm.state.notice') }),
|
||||
);
|
||||
await undo();
|
||||
check(
|
||||
'undo takes the second one back and leaves the first',
|
||||
(await transitions()).filter((e) => e.from.node === first && e.to.node === second).length === 1,
|
||||
);
|
||||
|
||||
// 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