Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
game-maker / suite (push) Successful in 9m5s
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:
1 parent
f85606427b
commit
016e83c9b5
33 files changed
+1346
-274
No files matched your search
+2
-21
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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";
|
||||
@@ -16,6 +16,7 @@ export const LANES = {
|
||||
keyRepeat: -300,
|
||||
stateMachine: -700,
|
||||
console: -1100,
|
||||
transform: -1500,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user