game-maker / suite (push) Successful in 9m5s
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>
267 lines
11 KiB
JavaScript
267 lines
11 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,
|
||
} = 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,
|
||
);
|
||
}
|