The knob on its stem turned and scaled only sprites and colliders. Every other type with Scale and Rotation had to be typed into the inspector. - Text, tilemap and particle source get the knob above their box. Edge and corner grips stay with sprite and collider, the only types with a width and a height of their own. - A container gets a thin frame round all of its content, turned and scaled with it, and the knob sits above that frame. It turns and scales round the container's origin, so the content swings with it. A container with no content gets the knob above its symbol. - contentFrame in sizeGizmo.ts is the pure rule for that frame. sizeGizmoEditor.ts wires it in. - One pull is one undo step, as for sprites. Not clicked in the browser: scaling (as opposed to turning) of a container by the knob. Only the shared code path covers it. Tests: - ./check.sh: 178 files, 2573 tests, CHECK EXIT 0 - 2 new unit tests for contentFrame - e2e 29k, 4 checks: knob on text, tilemap and particles, frame round the container's content, a real pull turns the container by 20.9 degrees round its origin with the content on the computed pixel, one undo takes it back - 4 counter-probes over #242 and #243 bite (2 unit, 2 browser) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
448 lines
19 KiB
JavaScript
448 lines
19 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']);
|
||
|
||
// 29k. The knob on everything else with a turn and a scale (#242): a text, a tilemap, a particle
|
||
// source and a container get the knob, but no edges or corners. The container's knob stands
|
||
// over a frame round its content – a 100×40 sprite 150 to the right of its origin – and
|
||
// pulling it across turns the container round that origin, the content swinging with it.
|
||
const OTHERS = ['otf-text', 'otf-map', 'otf-pt', 'otf-box'];
|
||
const INSIDE = 'otf-box~tf-in';
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const make = (id, assetId, parentId, position) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, instanceEdits: {} });
|
||
p.assets.push(
|
||
{ id: 'tf-text', type: 'text', name: 'tf-text', props: { text: 'Knob', fontSize: 32 } },
|
||
{ id: 'tf-map', type: 'tilemap', name: 'tf-map', props: { columns: 3, rows: 2, tileSize: 32 }, layers: [{ id: 'tf-l', name: 'Ground', cells: {} }] },
|
||
{ id: 'tf-pt', type: 'particles', name: 'tf-pt', props: {}, graph: { nodes: [], edges: [] } },
|
||
{ id: 'tf-box', type: 'container', name: 'tf-box', props: { enabled: true }, objects: [make('tf-in', ${JSON.stringify(SPRITE)}, '', { x: 150, y: 0, z: 0 })] },
|
||
);
|
||
scene.objects.push(
|
||
make('otf-text', 'tf-text', '', { x: -250, y: ${LANE - 120}, z: 960 }),
|
||
make('otf-map', 'tf-map', '', { x: -250, y: ${LANE + 120}, z: 960 }),
|
||
make('otf-pt', 'tf-pt', '', { x: 250, y: ${LANE - 120}, z: 960 }),
|
||
make('otf-box', 'tf-box', '', { x: 0, y: ${LANE + 120}, z: 960 }),
|
||
)`);
|
||
const knobs = {};
|
||
for (const id of ['otf-text', 'otf-map', 'otf-pt']) {
|
||
await selectObject(id);
|
||
const g = await gizmo();
|
||
const r = await rect(id);
|
||
knobs[id] = { g, r };
|
||
}
|
||
check(
|
||
'a text, a tilemap and a particle source get the knob above their box, without edges or corners',
|
||
Object.entries(knobs).every(
|
||
([id, { g, r }]) =>
|
||
g?.objectId === id &&
|
||
g.corners.length === 0 &&
|
||
g.edges.length === 0 &&
|
||
Math.abs(g.knob.x - r.x) < 2 &&
|
||
g.knob.y < r.y - r.h / 2 - 10,
|
||
),
|
||
JSON.stringify(knobs),
|
||
);
|
||
|
||
await selectObject('otf-box');
|
||
const b0 = await gizmo();
|
||
const in0 = await rect(INSIDE);
|
||
const xs = (b0?.box ?? []).map((c) => c.x);
|
||
const ys = (b0?.box ?? []).map((c) => c.y);
|
||
check(
|
||
"a container's knob stands over a frame round its content, not over its icon",
|
||
b0?.objectId === 'otf-box' &&
|
||
b0.corners.length === 0 &&
|
||
b0.edges.length === 0 &&
|
||
Math.abs(Math.min(...xs) - (in0.x - in0.w / 2)) < 2 &&
|
||
Math.abs(Math.max(...xs) - (in0.x + in0.w / 2)) < 2 &&
|
||
Math.abs(Math.min(...ys) - (in0.y - in0.h / 2)) < 2 &&
|
||
Math.abs(Math.max(...ys) - (in0.y + in0.h / 2)) < 2 &&
|
||
Math.abs(b0.knob.x - in0.x) < 2 &&
|
||
b0.knob.y < in0.y - in0.h / 2 - 10,
|
||
JSON.stringify({ b0, in0 }),
|
||
);
|
||
|
||
if (!b0) return;
|
||
const origin = { x: in0.x - 150 * s, y: in0.y };
|
||
await path(line(b0.knob, { x: b0.knob.x + 60, y: b0.knob.y + 60 }));
|
||
const turnedBox = (await object('otf-box')).overrides;
|
||
const theta = (Number(turnedBox.rotation) * Math.PI) / 180;
|
||
const in1 = await rect(INSIDE);
|
||
const swung = { x: origin.x + 150 * s * Math.cos(theta), y: origin.y + 150 * s * Math.sin(theta) };
|
||
check(
|
||
'pulling the knob across turns the container round its origin, and its content swings with it',
|
||
Math.abs(Number(turnedBox.rotation)) > 5 &&
|
||
turnedBox.scale === undefined &&
|
||
Math.abs(in1.x - swung.x) < 3 &&
|
||
Math.abs(in1.y - swung.y) < 3,
|
||
JSON.stringify({ turnedBox, in1, swung }),
|
||
);
|
||
await shot('29-container-knob');
|
||
await undo();
|
||
check(
|
||
'one undo takes the turn of the container back',
|
||
(await object('otf-box')).overrides.rotation === undefined,
|
||
);
|
||
await evaluate('__gm.state.select(null)');
|
||
await remove(OTHERS);
|
||
await mutate(`
|
||
const gone = new Set(['tf-text', 'tf-map', 'tf-pt', 'tf-box']);
|
||
__gm.state.project.assets = __gm.state.project.assets.filter((a) => !gone.has(a.id))`);
|
||
|
||
// 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,
|
||
);
|
||
}
|