Files
feuerwerk/tools/e2e/29-transform.mjs
T
rootandClaude Fable 5.1 016e83c9b5
game-maker / suite (push) Successful in 9m5s
Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
Four new rows on sprite, text, collider and container (block TRANSFORM):
scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they
get override/reset/apply, loading of old projects, write-back after play and
the Properties node for free; camera and behavior have none.

The hierarchy composes T*R*S per level (composeAlongChain replaces
sumAlongChain), so children rotate and scale with their parent. Reparenting
and grouping keep the look (carriedTransform). Drawing goes through
placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags
are computed in the parent's system.

Colliders are oriented boxes: SAT for box/box, box-circle in the box frame;
exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along
the object's own axes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 17:25:06 +02:00

267 lines
11 KiB
JavaScript
Raw 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.
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,
} = 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 });
await sleep(300);
};
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 evaluate("__gm.showTab('scene')");
await sleep(200);
// 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 });
await sleep(300);
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 });
await sleep(300);
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 });
await sleep(300);
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 });
await sleep(300);
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 });
await sleep(300);
// 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: {} });`);
await sleep(300);
const flat = { parent: await screen(BOX), child: await screen(CHILD) };
await override(BOX, { rotation: 90 });
await sleep(300);
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 });
await sleep(300);
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();
await sleep(200);
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 sleep(300);
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);
// 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)');
await sleep(200);
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,
);
}