diff --git a/README.md b/README.md index 65447e57..e08d9451 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 620fe992..773a0d1d 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -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 { diff --git a/src/core/graph/stateMachine.ts b/src/core/graph/stateMachine.ts index d0158e5f..50c08ba5 100644 --- a/src/core/graph/stateMachine.ts +++ b/src/core/graph/stateMachine.ts @@ -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, diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index 5e4d73a6..65efbaf9 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -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(); - 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(); + 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; } diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 7e373b8e..00b2f371 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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. */ diff --git a/src/editor/graph/transitions.ts b/src/editor/graph/transitions.ts index 73692da4..023906c4 100644 --- a/src/editor/graph/transitions.ts +++ b/src/editor/graph/transitions.ts @@ -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 { + const count = new Map(); + const rings = new Map(); + 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; diff --git a/tests/core/parallel-transitions.test.ts b/tests/core/parallel-transitions.test.ts new file mode 100644 index 00000000..9f8c84ab --- /dev/null +++ b/tests/core/parallel-transitions.test.ts @@ -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); + }); +}); diff --git a/tests/core/self-transition.test.ts b/tests/core/self-transition.test.ts index 737423c7..8e78f1b0 100644 --- a/tests/core/self-transition.test.ts +++ b/tests/core/self-transition.test.ts @@ -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', () => { diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index 3028c410..ebfaf40a 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -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',