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
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||
import {
|
||||
MoveSceneObjectCommand,
|
||||
SetOverrideCommand,
|
||||
SetParentCommand,
|
||||
} from '../../src/core/commands/sceneObjectCommands';
|
||||
import { worldPositionOf, worldTransformOf } from '../../src/core/graph';
|
||||
import type { ContainerAsset } from '../../src/core/model/Asset';
|
||||
import {
|
||||
apply,
|
||||
decompose,
|
||||
invert,
|
||||
multiply,
|
||||
scaleOf,
|
||||
transformMatrix,
|
||||
translation,
|
||||
} from '../../src/core/model/matrix';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { footprintContains, footprintCorners, footprintExtent } from '../../src/core/properties/bounds';
|
||||
import {
|
||||
hasTransformProps,
|
||||
isCommonProp,
|
||||
isValidVariableName,
|
||||
schemaFor,
|
||||
} from '../../src/core/properties/PropertySchema';
|
||||
import { carriedTransform, localPositionFor, worldTransform } from '../../src/core/properties/transform';
|
||||
import { fakeHandle, makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
const close = (p: { x: number; y: number }, x: number, y: number) => {
|
||||
expect(p.x).toBeCloseTo(x, 9);
|
||||
expect(p.y).toBeCloseTo(y, 9);
|
||||
};
|
||||
|
||||
describe('matrix', () => {
|
||||
it('turns clockwise on screen, scales about the origin, mirrors before turning', () => {
|
||||
close(apply(transformMatrix(0, 0, 90, 1, false, false), { x: 1, y: 0 }), 0, 1);
|
||||
close(apply(transformMatrix(10, 20, 0, 2, false, false), { x: 1, y: 1 }), 12, 22);
|
||||
close(apply(transformMatrix(0, 0, 0, 1, true, false), { x: 1, y: 2 }), -1, 2);
|
||||
close(apply(transformMatrix(0, 0, 90, 1, true, false), { x: 1, y: 0 }), 0, -1);
|
||||
});
|
||||
|
||||
it('a product undoes through its inverse, and a similarity decomposes back into its rows', () => {
|
||||
const m = multiply(
|
||||
transformMatrix(5, 6, 30, 2, false, false),
|
||||
transformMatrix(1, 2, 15, 0.5, true, false),
|
||||
);
|
||||
close(apply(invert(m), apply(m, { x: 3, y: 4 })), 3, 4);
|
||||
expect(scaleOf(m)).toBeCloseTo(1);
|
||||
expect(decompose(m)).toMatchObject({ mirrorX: true });
|
||||
expect(decompose(m).rotation).toBeCloseTo(45);
|
||||
const plain = decompose(transformMatrix(0, 0, 30, 2, false, false));
|
||||
expect(plain.rotation).toBeCloseTo(30, 9);
|
||||
expect(plain.scale).toBeCloseTo(2, 9);
|
||||
expect(plain.mirrorX).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transform chain', () => {
|
||||
it('a child sits where its turned, sized parent puts it, in the model and in the world', () => {
|
||||
const f = makeSpriteScene();
|
||||
const parent = place(f, f.spriteId, { x: 100, y: 100 });
|
||||
// `place` takes a world position; the local one is set outright.
|
||||
const child = place(f, f.spriteId, { x: 0, y: 0 }, parent);
|
||||
f.stack.execute(new MoveSceneObjectCommand(f.sceneId, child, { x: 50, y: 0, z: 0 }));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'scale', 2));
|
||||
// Local (50, 0) → scaled (100, 0) → turned a quarter clockwise (0, 100) → at (100, 200).
|
||||
close(f.world(child), 100, 200);
|
||||
const chain = worldTransform(f.project, f.sceneOf(), f.object(child));
|
||||
expect(scaleOf(chain.m)).toBeCloseTo(2);
|
||||
expect(decompose(chain.m).rotation).toBeCloseTo(90);
|
||||
|
||||
const { world } = startWorld(f);
|
||||
close(worldPositionOf(world.findByObjectId(child)!), 100, 200);
|
||||
// Behaviors write the rows like any property: the next read of the chain sees it.
|
||||
world.findByObjectId(parent)!.props.rotation = 0;
|
||||
close(worldPositionOf(world.findByObjectId(child)!), 200, 100);
|
||||
});
|
||||
|
||||
it('the rows are reserved names shared by drawn types, absent from cameras and behaviors', () => {
|
||||
expect(schemaFor('sprite').map((d) => d.key)).toEqual(
|
||||
expect.arrayContaining(['scale', 'rotation', 'mirrorX', 'mirrorY']),
|
||||
);
|
||||
expect(hasTransformProps('camera')).toBe(false);
|
||||
expect(schemaFor('camera').some((d) => d.key === 'rotation')).toBe(false);
|
||||
expect(isCommonProp('rotation')).toBe(true);
|
||||
expect(isValidVariableName('scale')).toBe(false);
|
||||
// A camera under a turned parent is still placed by the chain; its own props carry no rows.
|
||||
const parent = fakeHandle({ rotation: 90 });
|
||||
const camera = { ...fakeHandle({}, parent), x: 10, y: 0 };
|
||||
close(worldPositionOf(camera), 0, 10);
|
||||
expect(scaleOf(worldTransformOf(camera).m)).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it('the local position for a world point goes back through the parent transform', () => {
|
||||
const f = makeSpriteScene();
|
||||
const parent = place(f, f.spriteId, { x: 100, y: 100 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
|
||||
const local = localPositionFor(f.project, f.sceneOf(), parent, { x: 100, y: 150, z: 0 });
|
||||
close(local, 50, 0);
|
||||
const child = place(f, f.spriteId, { x: 100, y: 150 }, parent);
|
||||
close(f.world(child), 100, 150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('carried transform', () => {
|
||||
it('re-parenting keeps the look: rows absorb the parent chain, nothing is written between upright parents', () => {
|
||||
const f = makeSpriteScene();
|
||||
const turned = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const upright = place(f, f.spriteId, { x: 300, y: 0 });
|
||||
const box = place(f, f.spriteId, { x: 100, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, turned, 'rotation', 90));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, turned, 'scale', 2));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'mirrorY', true));
|
||||
|
||||
f.stack.execute(new SetParentCommand(f.sceneId, box, upright));
|
||||
expect(f.object(box).overrides).toEqual({ mirrorY: true });
|
||||
close(f.world(box), 100, 0);
|
||||
|
||||
f.stack.execute(new SetParentCommand(f.sceneId, box, turned));
|
||||
close(f.world(box), 100, 0);
|
||||
const world = worldTransform(f.project, f.sceneOf(), f.object(box)).m;
|
||||
expect(decompose(world)).toMatchObject({ mirrorX: true });
|
||||
expect(scaleOf(world)).toBeCloseTo(1);
|
||||
expect(f.object(box).overrides.rotation).toBeCloseTo(-90);
|
||||
expect(f.object(box).overrides.scale).toBeCloseTo(0.5);
|
||||
expect(f.object(box).overrides.mirrorY).toBe(true);
|
||||
|
||||
f.stack.undo();
|
||||
expect(f.object(box).overrides).toEqual({ mirrorY: true });
|
||||
expect(f.object(box).parentId).toBe(upright);
|
||||
});
|
||||
|
||||
it('a mirror in the chain is absorbed as a mirror, whichever way it was written', () => {
|
||||
const own = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false };
|
||||
const before = transformMatrix(0, 0, 0, 1, true, false);
|
||||
const carried = carriedTransform(own, before, translation(0, 0));
|
||||
expect(carried).toMatchObject({ mirrorX: true, mirrorY: false });
|
||||
expect(carried.scale).toBeCloseTo(1);
|
||||
expect(carried.rotation).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('grouping under a turned parent keeps every root where it was, rows included', () => {
|
||||
const f = makeSpriteScene();
|
||||
const parent = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const a = place(f, f.spriteId, { x: 100, y: 0 }, parent);
|
||||
const b = place(f, f.spriteId, { x: 100, y: 50 }, parent);
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
|
||||
const worldsBefore = [f.world(a), f.world(b)];
|
||||
const create = new CreateContainerCommand(f.sceneId, [a, b]);
|
||||
f.stack.execute(create);
|
||||
const asset = findAsset(f.project, create.assetId) as ContainerAsset;
|
||||
// Same parent for both: positions relative to the container in the parent's frame, no rows.
|
||||
expect(asset.objects.map((o) => o.overrides)).toEqual([{}, {}]);
|
||||
const contents = f.viewOf().objects.filter((o) => o.parentId === create.objectId);
|
||||
expect(contents).toHaveLength(2);
|
||||
const worldsAfter = contents.map((o) => f.world(o.id));
|
||||
for (const before of worldsBefore)
|
||||
expect(
|
||||
worldsAfter.some((w) => Math.abs(w.x - before.x) < 1e-9 && Math.abs(w.y - before.y) < 1e-9),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('footprint under a transform', () => {
|
||||
it('corners, extent and hit test follow the turn, symbols stay upright', () => {
|
||||
const m = transformMatrix(100, 100, 90, 2, false, false);
|
||||
const props = { width: 50, height: 10 };
|
||||
const extent = footprintExtent('sprite', props, m);
|
||||
expect(extent.left).toBeCloseTo(90);
|
||||
expect(extent.right).toBeCloseTo(110);
|
||||
expect(extent.top).toBeCloseTo(50);
|
||||
expect(extent.bottom).toBeCloseTo(150);
|
||||
expect(footprintContains('sprite', props, m, { x: 100, y: 145 })).toBe(true);
|
||||
expect(footprintContains('sprite', props, m, { x: 145, y: 100 })).toBe(false);
|
||||
// Two world units of padding around a box drawn at scale 2 is one local unit on every side.
|
||||
const padded = footprintCorners('sprite', props, m, 2);
|
||||
expect(padded[0]!.y).toBeCloseTo(48);
|
||||
// A behavior's diamond keeps its size and stays upright: the frame does not scale by 2.
|
||||
const symbol = footprintExtent('behavior', {}, m);
|
||||
expect(symbol.right - symbol.left).toBe(28);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user