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, showTab, } = 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 }); }; 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 showTab('scene'); // 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 }); 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 }); 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 }); 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 }); 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 }); // 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: {} });`); const flat = { parent: await screen(BOX), child: await screen(CHILD) }; await override(BOX, { rotation: 90 }); 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 }); 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(); 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 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); // 29j. The size gizmo (#223) on an upright 100×40 sprite: an edge sizes it with the opposite edge // staying put, the knob above the top edge turns it when pulled across and scales it when // pulled out – and a drag that starts one way stays that way. 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-size', ${JSON.stringify(SPRITE)}, '', { x: 150, y: ${LANE}, z: 960 }), make('otf-sizec', 'collider', 'otf-size', { x: 0, y: 0, z: 1 }, { shape: 'circle', radius: 20 }), )`); await selectObject('otf-size'); const gizmo = () => json('__gm.sizeGizmo()'); // A press, moves through every point, a release: `drag` only goes in a straight line. const path = async (points) => { const [first, ...rest] = points; await t.mouse('mouseMoved', first.x, first.y); await t.mouse('mousePressed', first.x, first.y); for (const p of rest) { await t.mouse('mouseMoved', p.x, p.y, 'left', { buttons: 1 }); await sleep(16); } const last = rest.at(-1) ?? first; await t.mouse('mouseReleased', last.x, last.y); await t.settle(); }; const line = (from, to, steps = 10) => Array.from({ length: steps + 1 }, (_, i) => ({ x: from.x + ((to.x - from.x) * i) / steps, y: from.y + ((to.y - from.y) * i) / steps, })); const sized = await object('otf-size'); const g0 = await gizmo(); const r0 = await rect('otf-size'); check( 'a selected sprite gets the size gizmo: four corners, four edges, and the knob above the top edge', g0?.objectId === 'otf-size' && g0.corners.length === 4 && g0.edges.length === 4 && Math.abs(g0.knob.x - r0.x) < 2 && g0.knob.y < r0.y - r0.h / 2 - 10, JSON.stringify({ g0, r0 }), ); const right = g0.edges[1]; await path(line(right, { x: right.x + 30, y: right.y + 5 })); const wide = { object: await object('otf-size'), rect: await rect('otf-size') }; const grow = 30 / s; check( 'pulling the right edge 30 px out widens the sprite by 30 / scale and keeps its left edge where it was', Math.abs(Number(wide.object.overrides.width) - Math.round(100 + grow)) < 0.01 && wide.object.overrides.height === undefined && Math.abs(wide.rect.x - wide.rect.w / 2 - (r0.x - r0.w / 2)) < 2 && wide.object.position.y === sized.position.y, JSON.stringify({ wide, r0, grow }), ); await undo(); check( 'one undo takes the whole resize back', (await object('otf-size')).overrides.width === undefined && (await object('otf-size')).position.x === 150, ); const k = (await gizmo()).knob; const reach = k.y - r0.y; await path(line(k, { x: k.x - reach, y: k.y })); const spun = (await object('otf-size')).overrides; check( 'pulling the knob across turns the sprite by the angle swept round its centre (45°) and leaves its scale', Math.abs(Number(spun.rotation) - 45) < 1.5 && spun.scale === undefined, JSON.stringify({ spun, reach }), ); await undo(); const k2 = (await gizmo()).knob; await path([ ...line(k2, { x: k2.x, y: k2.y - 40 }), ...line({ x: k2.x, y: k2.y - 40 }, { x: k2.x + 60, y: k2.y - 40 }), ]); const pulled = (await object('otf-size')).overrides; const expected = Math.hypot(60, -reach + 40) / -reach; check( 'pulling the knob out scales from the centre, and turning sideways afterwards does not turn it', Math.abs(Number(pulled.scale) - expected) < 0.03 && pulled.rotation === undefined, JSON.stringify({ pulled, expected }), ); await undo(); await shot('29-size-gizmo'); await selectObject('otf-sizec'); const c0 = await gizmo(); const cRect = await rect('otf-sizec'); const cRight = c0.edges[1]; await path(line(cRight, { x: cRight.x + 20 * s, y: cRight.y })); const circle = { object: await object('otf-sizec'), rect: await rect('otf-sizec') }; check( 'a circle collider has edges but no corners, and an edge pulled 20 out makes its radius 30', c0.corners.length === 0 && c0.edges.length === 4 && circle.object.overrides.radius === 30 && Math.abs(circle.rect.x - circle.rect.w / 2 - (cRect.x - cRect.w / 2)) < 2, JSON.stringify({ c0, cRect, circle }), ); await undo(); await evaluate('__gm.state.select(null)'); await remove(['otf-size', 'otf-sizec']); // 29k. The knob on everything else with a turn and a scale (#242): a text, a tilemap, a particle // source and a container get the knob, but no edges or corners. The container's knob stands // over a frame round its content – a 100×40 sprite 150 to the right of its origin – and // pulling it across turns the container round that origin, the content swinging with it. const OTHERS = ['otf-text', 'otf-map', 'otf-pt', 'otf-box']; const INSIDE = 'otf-box~tf-in'; await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const make = (id, assetId, parentId, position) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, instanceEdits: {} }); p.assets.push( { id: 'tf-text', type: 'text', name: 'tf-text', props: { text: 'Knob', fontSize: 32 } }, { id: 'tf-map', type: 'tilemap', name: 'tf-map', props: { columns: 3, rows: 2, tileSize: 32 }, layers: [{ id: 'tf-l', name: 'Ground', cells: {} }] }, { id: 'tf-pt', type: 'particles', name: 'tf-pt', props: {}, graph: { nodes: [], edges: [] } }, { id: 'tf-box', type: 'container', name: 'tf-box', props: { enabled: true }, objects: [make('tf-in', ${JSON.stringify(SPRITE)}, '', { x: 150, y: 0, z: 0 })] }, ); scene.objects.push( make('otf-text', 'tf-text', '', { x: -250, y: ${LANE - 120}, z: 960 }), make('otf-map', 'tf-map', '', { x: -250, y: ${LANE + 120}, z: 960 }), make('otf-pt', 'tf-pt', '', { x: 250, y: ${LANE - 120}, z: 960 }), make('otf-box', 'tf-box', '', { x: 0, y: ${LANE + 120}, z: 960 }), )`); const knobs = {}; for (const id of ['otf-text', 'otf-map', 'otf-pt']) { await selectObject(id); const g = await gizmo(); const r = await rect(id); knobs[id] = { g, r }; } check( 'a text, a tilemap and a particle source get the knob above their box, without edges or corners', Object.entries(knobs).every( ([id, { g, r }]) => g?.objectId === id && g.corners.length === 0 && g.edges.length === 0 && Math.abs(g.knob.x - r.x) < 2 && g.knob.y < r.y - r.h / 2 - 10, ), JSON.stringify(knobs), ); await selectObject('otf-box'); const b0 = await gizmo(); const in0 = await rect(INSIDE); const xs = (b0?.box ?? []).map((c) => c.x); const ys = (b0?.box ?? []).map((c) => c.y); check( "a container's knob stands over a frame round its content, not over its icon", b0?.objectId === 'otf-box' && b0.corners.length === 0 && b0.edges.length === 0 && Math.abs(Math.min(...xs) - (in0.x - in0.w / 2)) < 2 && Math.abs(Math.max(...xs) - (in0.x + in0.w / 2)) < 2 && Math.abs(Math.min(...ys) - (in0.y - in0.h / 2)) < 2 && Math.abs(Math.max(...ys) - (in0.y + in0.h / 2)) < 2 && Math.abs(b0.knob.x - in0.x) < 2 && b0.knob.y < in0.y - in0.h / 2 - 10, JSON.stringify({ b0, in0 }), ); if (!b0) return; const origin = { x: in0.x - 150 * s, y: in0.y }; await path(line(b0.knob, { x: b0.knob.x + 60, y: b0.knob.y + 60 })); const turnedBox = (await object('otf-box')).overrides; const theta = (Number(turnedBox.rotation) * Math.PI) / 180; const in1 = await rect(INSIDE); const swung = { x: origin.x + 150 * s * Math.cos(theta), y: origin.y + 150 * s * Math.sin(theta) }; check( 'pulling the knob across turns the container round its origin, and its content swings with it', Math.abs(Number(turnedBox.rotation)) > 5 && turnedBox.scale === undefined && Math.abs(in1.x - swung.x) < 3 && Math.abs(in1.y - swung.y) < 3, JSON.stringify({ turnedBox, in1, swung }), ); await shot('29-container-knob'); await undo(); check( 'one undo takes the turn of the container back', (await object('otf-box')).overrides.rotation === undefined, ); await evaluate('__gm.state.select(null)'); await remove(OTHERS); await mutate(` const gone = new Set(['tf-text', 'tf-map', 'tf-pt', 'tf-box']); __gm.state.project.assets = __gm.state.project.assets.filter((a) => !gone.has(a.id))`); // 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)'); 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, ); }