A sprite or collider could only be sized, turned and scaled through
the inspector. The scene view now draws a size gizmo on the selection,
so the common edits happen where the object is.
- Edges and corners set Width and Height. The opposite side stays
where it was, so the position moves by half the change. Sizes land
on whole units, at least 1.
- A circle collider has edge handles only and stays round. A polygon
or frame collider keeps its own corner editing and gets no size
handles.
- One knob on a stem above the top edge turns or scales. The first
5 px of the drag decide which: out from the centre scales, sideways
turns. The choice holds until the release, so one drag never does
both. Turning and scaling share one handle by the user's choice.
- Shift snaps a turn to 15 degrees. The turn adds up the swept angle,
so it goes on past a half circle instead of jumping back.
- The values are overrides of this placement, like "Fit to sprite".
A value equal to the asset's own clears the override. One drag is
one undo step.
- A handle reaches 7 px outwards but only half that into the object.
Otherwise a press near the edge of a small sprite sizes it instead
of moving it.
- The rules live in src/editor/sizeGizmo.ts without Pixi. The handles
are drawn and driven by src/editor/viewport/sizeGizmoEditor.ts. The
test hook __gm.sizeGizmo() reports them in screen pixels.
Found on the way:
- The first e2e run failed the play-mode drag check in 41b. It grabs
the 40 px crate 6 px inside its bottom edge, and the full grip zone
took that as a resize. The half zone inside fixes it.
- A first gizmo check failed because the sprite's right edge lay
outside the scene view (x 940, view ends at 925). The test was
wrong, the sprite now sits further left.
Tests:
- ./check.sh: 156 files, 2283 tests, CHECK EXIT 0 (before 2273).
- 10 unit tests in tests/editor/size-gizmo.test.ts.
- 6 new e2e checks in 29j: handles present, edge resize with the
opposite edge fixed, undo, knob turn, knob scale that ignores a
later sideways pull, circle radius.
- e2e 1-16,29,39-43 on the final state: ALL PASSED, 663 checks.
- Counter-checks: deciding the knob mode on every move fails the
"does not turn it" check. Not moving the centre on an edge drag
fails both edge checks. A full grip zone inside fails the new unit
test. Originals restored.
- Check total 1357 + 6 = 1363, computed. CI measured 1357 at d257298.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
360 lines
15 KiB
JavaScript
360 lines
15 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { FIND_HALVES, HALVES_LEFT, HALVES_RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
|
||
import { LANES } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Scale, rotation and mirror (#76): rows of every drawn object, cascading down the hierarchy like
|
||
* a position. Read in the pixels of the scene view, in where a child lands under a turned parent,
|
||
* in a drag that goes back through the parent's frame, and in a turned collider stopping a mover.
|
||
* Everything lives in its own lane, looked at through `__gm.lookAt`: the origin is crowded by
|
||
* what the earlier steps left, and symbols draw above objects.
|
||
*/
|
||
const RED = '#ff0000';
|
||
const SPRITE = 'tf-sprite';
|
||
const BOX = 'otf';
|
||
const CHILD = 'otf-child';
|
||
const CAMERA = 'otf-cam';
|
||
const RIG = ['otf-wall', 'otf-wallc', 'otf-body', 'otf-bodyc', 'otf-logic'];
|
||
const LANE = LANES.transform;
|
||
|
||
export async function transform(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
json,
|
||
evaluate,
|
||
mutate,
|
||
select,
|
||
drag,
|
||
undo,
|
||
press,
|
||
holdKey,
|
||
object,
|
||
entity,
|
||
stop,
|
||
shot,
|
||
showTab,
|
||
} = t;
|
||
const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`);
|
||
const rect = (id) => json(`__gm.objectRect(${JSON.stringify(id)})`);
|
||
const screen = (id) => json(`__gm.objectScreen(${JSON.stringify(id)})`);
|
||
const override = (id, patch) =>
|
||
mutate(
|
||
`Object.assign(__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)}).overrides, ${JSON.stringify(patch)})`,
|
||
);
|
||
const spriteProps = (patch) =>
|
||
mutate(
|
||
`Object.assign(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SPRITE)}).props, ${JSON.stringify(patch)})`,
|
||
);
|
||
const selectObject = async (id) => {
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [id], objectId: id });
|
||
};
|
||
const remove = (ids) =>
|
||
mutate(`
|
||
const gone = new Set(${JSON.stringify(ids)});
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !gone.has(o.id))`);
|
||
|
||
await press('Escape');
|
||
await evaluate('__gm.state.select(null)');
|
||
await showTab('scene');
|
||
|
||
// 29a. A plain red 100×40 sprite in its lane, and a second camera to read the inspector off;
|
||
// a sprite object has the transform rows, a camera object has not.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
|
||
sprite.id = ${JSON.stringify(SPRITE)};
|
||
sprite.name = 'Turned';
|
||
sprite.props = { ...sprite.props, width: 100, height: 40, color: 0xff0000, texture: '', shader: '' };
|
||
project.assets.push(sprite);
|
||
const scene = __gm.state.activeScene;
|
||
const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
|
||
scene.objects.push(
|
||
make(${JSON.stringify(BOX)}, ${JSON.stringify(SPRITE)}, '', { x: 0, y: ${LANE}, z: 960 }),
|
||
make(${JSON.stringify(CAMERA)}, 'cam', '', { x: 0, y: ${LANE - 400}, z: 0 }, { enabled: false }),
|
||
);
|
||
__gm.lookAt(0, ${LANE});
|
||
__gm.state.select(null)`);
|
||
await sleep(500);
|
||
await selectObject(BOX);
|
||
const rows = {
|
||
title: await t.has('TRANSFORM'),
|
||
scale: await field('Scale'),
|
||
rotation: await field('Rotation'),
|
||
mirrorX: await field('Mirror X'),
|
||
mirrorY: await field('Mirror Y'),
|
||
};
|
||
check(
|
||
'a sprite object has a TRANSFORM section with Scale, Rotation, Mirror X and Mirror Y',
|
||
rows.title && rows.scale && rows.rotation && rows.mirrorX && rows.mirrorY,
|
||
JSON.stringify(rows),
|
||
);
|
||
await selectObject(CAMERA);
|
||
check(
|
||
'a camera object has no transform rows',
|
||
(await field('Rotation')) === null && (await field('Scale')) === null && !(await t.has('TRANSFORM')),
|
||
);
|
||
await selectObject(BOX);
|
||
|
||
// 29b. Pixels: world units from the sprite's centre, at the view's scale.
|
||
const base = await rect(BOX);
|
||
check(
|
||
'the sprite is on screen',
|
||
Boolean(base) && base.w > 8,
|
||
JSON.stringify({ base, viewport: await json('__gm.viewportRect()') }),
|
||
);
|
||
if (!base) return;
|
||
const s = base.w / 100;
|
||
const px = (dx, dy) => json(`__gm.pixelAt(${Math.round(base.x + dx * s)}, ${Math.round(base.y + dy * s)})`);
|
||
const isRed = (c) => colourDistance(c, RED) < 12;
|
||
const upright = { right: await px(40, 0), below: await px(0, 40) };
|
||
check(
|
||
'upright: red 40 to the right of the centre, not red 40 below (the box is 40 tall)',
|
||
isRed(upright.right) && !isRed(upright.below),
|
||
JSON.stringify(upright),
|
||
);
|
||
|
||
// 29c. Rotation 90: the box stands up – its frame swaps sides, the pixels move with it.
|
||
await override(BOX, { rotation: 90 });
|
||
const turned = await rect(BOX);
|
||
const turnedPx = { right: await px(40, 0), below: await px(0, 40) };
|
||
check(
|
||
'rotation 90 turns the box: frame 40 wide and 100 tall, red below the centre, not to the right',
|
||
Math.abs(turned.w - 40 * s) < 2 &&
|
||
Math.abs(turned.h - 100 * s) < 2 &&
|
||
isRed(turnedPx.below) &&
|
||
!isRed(turnedPx.right),
|
||
JSON.stringify({ turned, turnedPx }),
|
||
);
|
||
|
||
// 29d. Scale 2 on top: twice the frame, red where the upright box would have ended long ago.
|
||
await override(BOX, { scale: 2 });
|
||
const grown = await rect(BOX);
|
||
const far = await px(0, 90);
|
||
check(
|
||
'scale 2 doubles the turned box: frame 80 by 200, red 90 below the centre',
|
||
Math.abs(grown.w - 80 * s) < 2 && Math.abs(grown.h - 200 * s) < 2 && isRed(far),
|
||
JSON.stringify({ grown, far }),
|
||
);
|
||
await shot('29-turned-scaled');
|
||
|
||
// 29e. Mirror, read on a two-colour picture: red left, green right; Mirror X swaps them, Mirror Y
|
||
// does not (the halves are side by side).
|
||
await override(BOX, { rotation: 0, scale: 1 });
|
||
await evaluate(PASTE_HALVES);
|
||
await sleep(2500);
|
||
const picture = await json(FIND_HALVES);
|
||
check('a two-colour picture is pasted in for the mirror checks', Boolean(picture), JSON.stringify(picture));
|
||
await evaluate('__gm.state.select(null)');
|
||
await spriteProps({ texture: picture?.id ?? '', color: 0xffffff });
|
||
await sleep(600);
|
||
const plain = { left: await px(-30, 0), right: await px(30, 0) };
|
||
check(
|
||
'the picture is on: red left, green right',
|
||
colourDistance(plain.left, HALVES_LEFT) < 12 && colourDistance(plain.right, HALVES_RIGHT) < 12,
|
||
JSON.stringify(plain),
|
||
);
|
||
await override(BOX, { mirrorX: true });
|
||
const mirrored = { left: await px(-30, 0), right: await px(30, 0) };
|
||
check(
|
||
'Mirror X swaps the halves: green left, red right',
|
||
colourDistance(mirrored.left, HALVES_RIGHT) < 12 && colourDistance(mirrored.right, HALVES_LEFT) < 12,
|
||
JSON.stringify(mirrored),
|
||
);
|
||
await override(BOX, { mirrorY: true });
|
||
const both = { left: await px(-30, 0), right: await px(30, 0) };
|
||
check(
|
||
'Mirror Y on top leaves the halves where Mirror X put them',
|
||
colourDistance(both.left, HALVES_RIGHT) < 12 && colourDistance(both.right, HALVES_LEFT) < 12,
|
||
JSON.stringify(both),
|
||
);
|
||
await override(BOX, { mirrorX: false, mirrorY: false });
|
||
await spriteProps({ texture: '', color: 0xff0000 });
|
||
|
||
// 29f. A child 100 to the right of its parent: once the parent turns a quarter, the child hangs
|
||
// 100 below it – and is drawn turned itself.
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects.push({ id: ${JSON.stringify(CHILD)}, assetId: ${JSON.stringify(SPRITE)}, name: '', position: { x: 100, y: 0, z: 1 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: ${JSON.stringify(BOX)}, instanceEdits: {} });`);
|
||
const flat = { parent: await screen(BOX), child: await screen(CHILD) };
|
||
await override(BOX, { rotation: 90 });
|
||
const hung = { parent: await screen(BOX), child: await screen(CHILD), childRect: await rect(CHILD) };
|
||
check(
|
||
'a child follows its parent’s turn: 100 to the right becomes 100 below, drawn turned',
|
||
Math.abs(flat.child.x - (flat.parent.x + 100 * s)) < 2 &&
|
||
Math.abs(flat.child.y - flat.parent.y) < 2 &&
|
||
Math.abs(hung.child.x - hung.parent.x) < 2 &&
|
||
Math.abs(hung.child.y - (hung.parent.y + 100 * s)) < 2 &&
|
||
Math.abs(hung.childRect.w - 40 * s) < 2 &&
|
||
Math.abs(hung.childRect.h - 100 * s) < 2,
|
||
JSON.stringify({ flat, hung, s }),
|
||
);
|
||
|
||
// 29g. Dragging the child 30 screen pixels down moves it along the parent's x axis – which now
|
||
// points down – so its local x grows by 30 / scale and y stays.
|
||
await drag(hung.child.x, hung.child.y, hung.child.x, hung.child.y + 30, { steps: 8 });
|
||
const dragged = await object(CHILD);
|
||
check(
|
||
'a drag under a turned parent lands in the parent’s frame: local x + 30 / scale, y unchanged',
|
||
Math.abs(dragged.position.x - (100 + 30 / s)) <= 1 && Math.abs(dragged.position.y) <= 1,
|
||
JSON.stringify({ position: dragged.position, expected: 100 + 30 / s }),
|
||
);
|
||
await undo();
|
||
check('undo puts the child back', (await object(CHILD)).position.x === 100);
|
||
await selectObject(CHILD);
|
||
const gizmos = await json('__gm.gizmos()');
|
||
check(
|
||
'the turned child is outlined like any selection',
|
||
gizmos.some((g) => g.id === CHILD && g.primary && g.alpha === 1),
|
||
JSON.stringify(gizmos),
|
||
);
|
||
await shot('29-turned-child');
|
||
|
||
// 29h. In the game a turned collider blocks where it really is. A crate body with the fixture's
|
||
// Mover walks right into a wall whose 140-wide collider stands on end (60 wide): it parks
|
||
// flush at −80 – upright, the wall would stop it at −120 (see 8c).
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
|
||
scene.objects.push(
|
||
make('otf-wall', 'crate', '', { x: 0, y: ${LANE + 300}, z: 0 }, { rotation: 90 }),
|
||
make('otf-wallc', 'collider', 'otf-wall', { x: 0, y: 0, z: 0 }, { width: 140 }),
|
||
make('otf-body', 'crate', '', { x: -200, y: ${LANE + 300}, z: 0 }),
|
||
make('otf-bodyc', 'collider', 'otf-body', { x: 0, y: 0, z: 0 }),
|
||
make('otf-logic', 'mover', 'otf-body', { x: 0, y: 0, z: 0 }),
|
||
);
|
||
__gm.state.select(null)`);
|
||
await press('Enter', { ctrl: true });
|
||
await holdKey('ArrowRight', 1000);
|
||
const body = await entity('otf-body');
|
||
await stop();
|
||
check(
|
||
'a turned collider stops the mover where the turned box is: flush at x = −80',
|
||
body && Math.abs(body.x - -80) < 0.5,
|
||
`world x=${body?.x}`,
|
||
);
|
||
await remove(RIG);
|
||
|
||
// 29j. The size gizmo (#223) on an upright 100×40 sprite: an edge sizes it with the opposite edge
|
||
// staying put, the knob above the top edge turns it when pulled across and scales it when
|
||
// pulled out – and a drag that starts one way stays that way.
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
|
||
scene.objects.push(
|
||
make('otf-size', ${JSON.stringify(SPRITE)}, '', { x: 150, y: ${LANE}, z: 960 }),
|
||
make('otf-sizec', 'collider', 'otf-size', { x: 0, y: 0, z: 1 }, { shape: 'circle', radius: 20 }),
|
||
)`);
|
||
await selectObject('otf-size');
|
||
const gizmo = () => json('__gm.sizeGizmo()');
|
||
// A press, moves through every point, a release: `drag` only goes in a straight line.
|
||
const path = async (points) => {
|
||
const [first, ...rest] = points;
|
||
await t.mouse('mouseMoved', first.x, first.y);
|
||
await t.mouse('mousePressed', first.x, first.y);
|
||
for (const p of rest) {
|
||
await t.mouse('mouseMoved', p.x, p.y, 'left', { buttons: 1 });
|
||
await sleep(16);
|
||
}
|
||
const last = rest.at(-1) ?? first;
|
||
await t.mouse('mouseReleased', last.x, last.y);
|
||
await t.settle();
|
||
};
|
||
const line = (from, to, steps = 10) =>
|
||
Array.from({ length: steps + 1 }, (_, i) => ({
|
||
x: from.x + ((to.x - from.x) * i) / steps,
|
||
y: from.y + ((to.y - from.y) * i) / steps,
|
||
}));
|
||
const sized = await object('otf-size');
|
||
const g0 = await gizmo();
|
||
const r0 = await rect('otf-size');
|
||
check(
|
||
'a selected sprite gets the size gizmo: four corners, four edges, and the knob above the top edge',
|
||
g0?.objectId === 'otf-size' &&
|
||
g0.corners.length === 4 &&
|
||
g0.edges.length === 4 &&
|
||
Math.abs(g0.knob.x - r0.x) < 2 &&
|
||
g0.knob.y < r0.y - r0.h / 2 - 10,
|
||
JSON.stringify({ g0, r0 }),
|
||
);
|
||
|
||
const right = g0.edges[1];
|
||
await path(line(right, { x: right.x + 30, y: right.y + 5 }));
|
||
const wide = { object: await object('otf-size'), rect: await rect('otf-size') };
|
||
const grow = 30 / s;
|
||
check(
|
||
'pulling the right edge 30 px out widens the sprite by 30 / scale and keeps its left edge where it was',
|
||
Math.abs(Number(wide.object.overrides.width) - Math.round(100 + grow)) < 0.01 &&
|
||
wide.object.overrides.height === undefined &&
|
||
Math.abs(wide.rect.x - wide.rect.w / 2 - (r0.x - r0.w / 2)) < 2 &&
|
||
wide.object.position.y === sized.position.y,
|
||
JSON.stringify({ wide, r0, grow }),
|
||
);
|
||
await undo();
|
||
check(
|
||
'one undo takes the whole resize back',
|
||
(await object('otf-size')).overrides.width === undefined && (await object('otf-size')).position.x === 150,
|
||
);
|
||
|
||
const k = (await gizmo()).knob;
|
||
const reach = k.y - r0.y;
|
||
await path(line(k, { x: k.x - reach, y: k.y }));
|
||
const spun = (await object('otf-size')).overrides;
|
||
check(
|
||
'pulling the knob across turns the sprite by the angle swept round its centre (45°) and leaves its scale',
|
||
Math.abs(Number(spun.rotation) - 45) < 1.5 && spun.scale === undefined,
|
||
JSON.stringify({ spun, reach }),
|
||
);
|
||
await undo();
|
||
|
||
const k2 = (await gizmo()).knob;
|
||
await path([
|
||
...line(k2, { x: k2.x, y: k2.y - 40 }),
|
||
...line({ x: k2.x, y: k2.y - 40 }, { x: k2.x + 60, y: k2.y - 40 }),
|
||
]);
|
||
const pulled = (await object('otf-size')).overrides;
|
||
const expected = Math.hypot(60, -reach + 40) / -reach;
|
||
check(
|
||
'pulling the knob out scales from the centre, and turning sideways afterwards does not turn it',
|
||
Math.abs(Number(pulled.scale) - expected) < 0.03 && pulled.rotation === undefined,
|
||
JSON.stringify({ pulled, expected }),
|
||
);
|
||
await undo();
|
||
await shot('29-size-gizmo');
|
||
|
||
await selectObject('otf-sizec');
|
||
const c0 = await gizmo();
|
||
const cRect = await rect('otf-sizec');
|
||
const cRight = c0.edges[1];
|
||
await path(line(cRight, { x: cRight.x + 20 * s, y: cRight.y }));
|
||
const circle = { object: await object('otf-sizec'), rect: await rect('otf-sizec') };
|
||
check(
|
||
'a circle collider has edges but no corners, and an edge pulled 20 out makes its radius 30',
|
||
c0.corners.length === 0 &&
|
||
c0.edges.length === 4 &&
|
||
circle.object.overrides.radius === 30 &&
|
||
Math.abs(circle.rect.x - circle.rect.w / 2 - (cRect.x - cRect.w / 2)) < 2,
|
||
JSON.stringify({ c0, cRect, circle }),
|
||
);
|
||
await undo();
|
||
await evaluate('__gm.state.select(null)');
|
||
await remove(['otf-size', 'otf-sizec']);
|
||
|
||
// 29i. Cleanup, and the view back where the earlier steps left the scene.
|
||
await remove([BOX, CHILD, CAMERA]);
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const gone = new Set([${JSON.stringify(SPRITE)}, ${JSON.stringify(picture?.id ?? '')}]);
|
||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||
__gm.lookAt(0, 0)`);
|
||
await evaluate('__gm.state.select(null)');
|
||
check(
|
||
'the step leaves nothing behind',
|
||
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(SPRITE)})`)) === false &&
|
||
(await json(`__gm.state.activeScene.objects.some((o) => o.id.startsWith('otf'))`)) === false &&
|
||
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(picture?.id ?? '')})`)) ===
|
||
false,
|
||
);
|
||
}
|