Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
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>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-13 17:25:06 +02:00
1 parent f85606427b
commit 016e83c9b5
33 files changed
+1346 -274

No files matched your search

+2 -21
View File
@@ -1,4 +1,5 @@
import { colourDistance } from '../lib/harness.mjs';
import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
/**
* Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through
@@ -10,22 +11,6 @@ import { colourDistance } from '../lib/harness.mjs';
const BASE = '#4080ff';
const ADJUST = 'adjust-colors';
/** A 32 px picture, red on the left and green on the right, pasted in for the sampling checks (28l). */
const PASTE_HALVES = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 16, 32);
g.fillStyle = '#3cc85a'; g.fillRect(16, 0, 16, 32);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'halves.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
const LEFT = '#e63c3c';
const RIGHT = '#3cc85a';
export async function shader(t) {
const {
check,
@@ -457,11 +442,7 @@ export async function shader(t) {
// with the far half, Mirror with the near one.
await evaluate(PASTE_HALVES);
await sleep(2500);
const picture = (
await json(
"__gm.state.project.assets.filter((a) => a.type === 'texture' && a.width === 32 && a.height === 32).map((a) => ({ id: a.id, file: a.file }))",
)
)[0];
const picture = await json(FIND_HALVES);
check(
'a two-colour picture is pasted in for the sampling checks',
Boolean(picture),
+266
View File
@@ -0,0 +1,266 @@
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,
);
}
+2
View File
@@ -31,6 +31,7 @@ import { animation } from './25-animation.mjs';
import { stateMachine } from './26-state-machine.mjs';
import { consolePanel } from './27-console.mjs';
import { shader } from './28-shader.mjs';
import { transform } from './29-transform.mjs';
export const STEPS = [
hierarchy,
@@ -61,4 +62,5 @@ export const STEPS = [
stateMachine,
consolePanel,
shader,
transform,
];
+19
View File
@@ -0,0 +1,19 @@
/** A 32 px picture, red on the left and green on the right, pasted in for pixel checks (28l, 29e). */
export const PASTE_HALVES = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 16, 32);
g.fillStyle = '#3cc85a'; g.fillRect(16, 0, 16, 32);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'halves.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
export const HALVES_LEFT = '#e63c3c';
export const HALVES_RIGHT = '#3cc85a';
/** The pasted picture's asset, once the paste has landed. */
export const FIND_HALVES =
"__gm.state.project.assets.filter((a) => a.type === 'texture' && a.width === 32 && a.height === 32).map((a) => ({ id: a.id, file: a.file }))[0] ?? null";
+1
View File
@@ -16,6 +16,7 @@ export const LANES = {
keyRepeat: -300,
stateMachine: -700,
console: -1100,
transform: -1500,
};
/**