Files
rootandClaude Opus 5 68c92daaa6
game-maker / suite (push) Successful in 10m22s
A transition carries a name, so a state needs no visible port (#52)
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.

Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.

Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.

`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.

The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.

One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:05:18 +02:00

130 lines
5.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The straight arrow between two states. It runs border to border because a diagonal line into a
* port passes under the target node and hides its own arrowhead there – the failure that made this
* geometry necessary, and the one these checks hold shut.
*/
import { describe, expect, it } from 'vitest';
import {
ARROW,
arrowCorners,
arrowShaft,
BORDER_GAP,
boxExit,
centreOf,
direction,
PARALLEL_SEP,
transitionEnds,
transitionOffsets,
transitionPreview,
} from '../../src/editor/graph/transitions';
/** Two states side by side, 100 x 40 each, 200 apart on x. */
const left = { x: 0, y: 0, w: 100, h: 40 };
const right = { x: 300, y: 0, w: 100, h: 40 };
/** Directly below the first, which is where a pin-to-pin line would cut through a node. */
const below = { x: 0, y: 200, w: 100, h: 40 };
describe('transition geometry', () => {
it('leaves a box on the side the ray points at', () => {
expect(boxExit(left, { x: 1, y: 0 })).toEqual({ x: 100, y: 20 });
expect(boxExit(left, { x: -1, y: 0 })).toEqual({ x: 0, y: 20 });
expect(boxExit(left, { x: 0, y: 1 })).toEqual({ x: 50, y: 40 });
expect(boxExit(left, { x: 0, y: -1 })).toEqual({ x: 50, y: 0 });
});
it('runs border to border, so neither end sits inside a node', () => {
const ends = transitionEnds(left, right)!;
expect(ends.start).toEqual({ x: 100 + BORDER_GAP, y: 20 });
expect(ends.end).toEqual({ x: 300 - BORDER_GAP, y: 20 });
});
it('leaves through the bottom and the top when one state sits under the other', () => {
const ends = transitionEnds(left, below)!;
// The whole point: straight down, out of the lower border and into the upper one – never
// across the body of either node.
expect(ends.start).toEqual({ x: 50, y: 40 + BORDER_GAP });
expect(ends.end).toEqual({ x: 50, y: 200 - BORDER_GAP });
});
it('keeps both ends clear of the borders they leave', () => {
const ends = transitionEnds(left, right)!;
expect(ends.start.x).toBeGreaterThan(left.x + left.w);
expect(ends.end.x).toBeLessThan(right.x);
});
it('pushes a there-and-back pair to opposite sides of the line', () => {
const offsets = transitionOffsets([
{ id: 'there', from: 'a', to: 'b' },
{ id: 'back', from: 'b', to: 'a' },
]);
const there = offsets.get('there')!;
const back = offsets.get('back')!;
// Same sign in each arrow's own frame, which is opposite sides on screen, since the normal
// turns with the direction.
expect(there).toBe(back);
expect(Math.abs(there)).toBe(PARALLEL_SEP / 2);
const a = transitionEnds(left, right, there)!;
const b = transitionEnds(right, left, back)!;
expect(a.start.y).not.toBe(b.end.y);
expect(Math.abs(a.start.y - b.end.y)).toBeCloseTo(PARALLEL_SEP);
});
it('leaves a lone transition on the line between the centres', () => {
const offsets = transitionOffsets([{ id: 'only', from: 'a', to: 'b' }]);
expect(offsets.get('only')).toBe(0);
expect(transitionEnds(left, right, 0)).toEqual(transitionEnds(left, right));
});
it('fans three between the same pair evenly around the centre line', () => {
const offsets = transitionOffsets([
{ id: 'one', from: 'a', to: 'b' },
{ id: 'two', from: 'a', to: 'b' },
{ id: 'three', from: 'a', to: 'b' },
]);
expect([...offsets.values()]).toEqual([-PARALLEL_SEP, 0, PARALLEL_SEP]);
});
it('keeps transitions between different pairs on their own centre lines', () => {
const offsets = transitionOffsets([
{ id: 'ab', from: 'a', to: 'b' },
{ id: 'cd', from: 'c', to: 'd' },
]);
expect(offsets.get('ab')).toBe(0);
expect(offsets.get('cd')).toBe(0);
});
it('has no ends when the two boxes share a centre', () => {
expect(transitionEnds(left, { ...left })).toBeNull();
expect(direction({ x: 5, y: 5 }, { x: 5, y: 5 })).toBeNull();
});
it('stops the line one arrowhead short of the tip', () => {
const shaft = arrowShaft({ x: 0, y: 0 }, { x: 100, y: 0 });
expect(shaft).toEqual({ x: 100 - ARROW, y: 0 });
});
it('points the head at the target, across the line', () => {
const [tip, a, b] = arrowCorners({ x: 0, y: 0 }, { x: 100, y: 0 })!;
expect(tip).toEqual({ x: 100, y: 0 });
// Both base corners sit at the shaft's end, one on each side of the line.
expect(a.x).toBeCloseTo(100 - ARROW);
expect(b.x).toBeCloseTo(100 - ARROW);
expect(Math.sign(a.y)).toBe(-Math.sign(b.y));
expect(Math.abs(a.y)).toBeCloseTo(Math.abs(b.y));
});
it('previews from the source border to the pointer until it finds a state', () => {
const loose = transitionPreview(left, null, { x: 300, y: 20 });
expect(loose.start).toEqual({ x: 100 + BORDER_GAP, y: 20 });
expect(loose.end).toEqual({ x: 300, y: 20 });
const snapped = transitionPreview(left, right, { x: 280, y: 20 });
expect(snapped).toEqual(transitionEnds(left, right));
});
it('centres a box where both halves meet', () => {
expect(centreOf(left)).toEqual({ x: 50, y: 20 });
});
});