diff --git a/README.md b/README.md index 5babe1ae..65447e57 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, 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 diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index ac2ea0ef..620fe992 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -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)) diff --git a/src/core/graph/nodes/events/OnStateEntered.ts b/src/core/graph/nodes/events/OnStateEntered.ts index 61745a6c..5f8e377c 100644 --- a/src/core/graph/nodes/events/OnStateEntered.ts +++ b/src/core/graph/nodes/events/OnStateEntered.ts @@ -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: [], diff --git a/src/core/graph/nodes/flow/StateTransition.ts b/src/core/graph/nodes/flow/StateTransition.ts index e005f800..e0c8f334 100644 --- a/src/core/graph/nodes/flow/StateTransition.ts +++ b/src/core/graph/nodes/flow/StateTransition.ts @@ -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' }; }, }; diff --git a/src/core/graph/sharedState.ts b/src/core/graph/sharedState.ts index 0e1a81f6..cbff5db5 100644 --- a/src/core/graph/sharedState.ts +++ b/src/core/graph/sharedState.ts @@ -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); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 9838d684..f793cd4d 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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) => diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index c25c74ba..5e4d73a6 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -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); diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 5740b02e..7e373b8e 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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); diff --git a/src/editor/graph/transitions.ts b/src/editor/graph/transitions.ts index 926dec83..73692da4 100644 --- a/src/editor/graph/transitions.ts +++ b/src/editor/graph/transitions.ts @@ -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; diff --git a/src/editor/inspector/transitionSection.ts b/src/editor/inspector/transitionSection.ts index 1e53d0d0..71e9190f 100644 --- a/src/editor/inspector/transitionSection.ts +++ b/src/editor/inspector/transitionSection.ts @@ -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); }); } diff --git a/tests/core/self-transition.test.ts b/tests/core/self-transition.test.ts new file mode 100644 index 00000000..737423c7 --- /dev/null +++ b/tests/core/self-transition.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index 042ece96..3028c410 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -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',