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, ); }