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

+67 -21
View File
@@ -3,16 +3,26 @@ import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { findAsset } from '../../src/core/model/Project';
import { transformMatrix, translation } from '../../src/core/model/matrix';
import {
axisCorrection,
axisSeparation,
colliderShape,
exitDistance,
shapeContains,
shapesOverlap,
} from '../../src/core/properties/collider';
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
const box = (x: number, y: number, w: number, h: number, degrees = 0) => ({
kind: 'box' as const,
x,
y,
w,
h,
angle: (degrees * Math.PI) / 180,
});
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
const LEFT = { x: -1, y: 0 };
const UP = { x: 0, y: -1 };
describe('collider geometry', () => {
it('box vs box: overlapping yes, touching edges no', () => {
@@ -21,6 +31,19 @@ describe('collider geometry', () => {
expect(shapesOverlap(box(0, 0, 100, 100), box(0, 150, 100, 100))).toBe(false);
});
it('a turned box reaches where its corners are, not where its upright frame would be', () => {
// A 100×20 bar turned by 45° reaches ≈ 42.4 along x; upright it would reach 50.
expect(shapesOverlap(box(0, 0, 100, 20, 45), box(95, 0, 100, 100))).toBe(false);
expect(shapesOverlap(box(0, 0, 100, 20), box(95, 0, 100, 100))).toBe(true);
// A diamond (square turned by 45°, corners at ±70.7) and a square at (110, 110): their upright
// frames overlap (120.7 > 110 on both axes) but the square's corner (60, 60) lies outside the
// diamond's edge x + y = 70.7.
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(110, 110, 100, 100))).toBe(false);
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(95, 0, 100, 100))).toBe(true);
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 0, y: 45 })).toBe(true);
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 45, y: 0 })).toBe(false);
});
it('circle vs circle and box vs circle (corner case: the closest point is a box corner)', () => {
expect(shapesOverlap(circle(0, 0, 10), circle(15, 0, 10))).toBe(true);
expect(shapesOverlap(circle(0, 0, 10), circle(20, 0, 10))).toBe(false);
@@ -29,28 +52,51 @@ describe('collider geometry', () => {
// Corner at (50,50): a circle at (60,60) with r=10 has distance √200 ≈ 14.1 → no overlap; r=15 → overlap.
expect(shapesOverlap(circle(60, 60, 10), box(0, 0, 100, 100))).toBe(false);
expect(shapesOverlap(circle(60, 60, 15), box(0, 0, 100, 100))).toBe(true);
// The same corner turned to (0, 70.7): the circle at (0, 80) with r=10 just misses it.
expect(shapesOverlap(circle(0, 80, 10), box(0, 0, 100, 100, 45))).toBe(true);
expect(shapesOverlap(circle(0, 81, 10), box(0, 0, 100, 100, 45))).toBe(false);
});
it('builds shapes from properties with defaults', () => {
expect(colliderShape({}, { x: 1, y: 2 })).toEqual(box(1, 2, 100, 100));
expect(colliderShape({ shape: 'circle', radius: 7 }, { x: 0, y: 0 })).toEqual(circle(0, 0, 7));
it('builds shapes from properties with defaults, sized and turned by the transform', () => {
expect(colliderShape({}, translation(1, 2))).toEqual(box(1, 2, 100, 100));
expect(colliderShape({ shape: 'circle', radius: 7 }, translation(0, 0))).toEqual(circle(0, 0, 7));
const turned = colliderShape({ width: 40, height: 20 }, transformMatrix(5, 6, 30, 2, false, false));
expect(turned).toMatchObject({ kind: 'box', x: 5, y: 6, w: 80, h: 40 });
expect(turned.kind === 'box' && (turned.angle * 180) / Math.PI).toBeCloseTo(30);
// A mirror keeps a box a box: the same shape, whichever way its x axis points.
const mirrored = colliderShape({ width: 40, height: 20 }, transformMatrix(0, 0, 0, 1, true, false));
expect(shapeContains(mirrored, { x: 19, y: 9 })).toBe(true);
expect(shapeContains(mirrored, { x: 21, y: 9 })).toBe(false);
expect(colliderShape({ shape: 'circle', radius: 7 }, transformMatrix(0, 0, 0, 3, false, false))).toEqual(
circle(0, 0, 21),
);
});
it('axis separation: the centre distance at which shapes touch along one axis', () => {
expect(axisSeparation(box(0, 0, 100, 60), box(0, 10, 40, 60), 'x')).toBe(70);
expect(axisSeparation(box(0, 0, 100, 60), box(0, 100, 40, 60), 'x')).toBe(0); // apart on y
expect(axisSeparation(circle(0, 0, 10), circle(0, 12, 10), 'x')).toBeCloseTo(16);
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 20, 10), 'x')).toBe(60); // flat side
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 56, 10), 'x')).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 70, 10), 'x')).toBe(0);
});
it('axis correction: how far a mover must back off to just touch', () => {
// Mover moved right into a box: centres 80 apart, need 100 → push back 20.
expect(axisCorrection(box(0, 0, 100, 100), box(80, 0, 100, 100), 'x', 1)).toBe(20);
expect(axisCorrection(box(80, 0, 100, 100), box(0, 0, 100, 100), 'x', -1)).toBe(20);
expect(axisCorrection(box(0, 0, 100, 100), box(120, 0, 100, 100), 'x', 1)).toBe(0);
expect(axisCorrection(box(0, 0, 100, 100), box(0, 90, 100, 100), 'y', 1)).toBe(10);
it('exit distance: how far a mover must travel to just clear an obstacle', () => {
// Mover moved right into a box: centres 80 apart, need 100 → 20 back to the left.
expect(exitDistance(box(80, 0, 100, 100), box(0, 0, 100, 100), { x: 1, y: 0 })).toBe(20);
expect(exitDistance(box(0, 0, 100, 100), box(80, 0, 100, 100), LEFT)).toBe(20);
expect(exitDistance(box(0, 0, 100, 100), box(120, 0, 100, 100), LEFT)).toBe(0);
expect(exitDistance(box(0, 0, 100, 100), box(0, 90, 100, 100), UP)).toBe(10);
expect(exitDistance(box(0, 10, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(70);
expect(exitDistance(box(0, 100, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(0); // apart on y
expect(exitDistance(circle(0, 12, 10), circle(0, 0, 10), LEFT)).toBeCloseTo(16);
expect(exitDistance(circle(0, 20, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(60); // flat side
expect(exitDistance(circle(0, 56, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
expect(exitDistance(circle(0, 70, 10), box(0, 0, 100, 100), LEFT)).toBe(0);
// Moving away diagonally clears a corner sooner than along one axis would.
expect(exitDistance(box(80, 80, 100, 100), box(0, 0, 100, 100), { x: 1, y: 1 })).toBeCloseTo(
20 * Math.SQRT2,
);
// A wall turned by 45°, a box moving left into its slanted face: level with the corner it must
// back off to the corner (70.7 + 10 − 60), higher up the face is only half a unit deep along
// its normal – an upright frame would still demand the 20.7.
expect(exitDistance(box(-60, 0, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
50 * Math.SQRT2 + 10 - 60,
);
expect(exitDistance(box(-60, 30, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
(50 + 10 * Math.SQRT2 - 90 * Math.SQRT1_2) * Math.SQRT2,
);
});
});
+4
View File
@@ -24,6 +24,10 @@ describe('asset commands', () => {
expect(project.activeSceneId).toBe(sceneId);
expect(findAsset(project, spriteId)?.props).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 100,
height: 100,
color: 0xffffff,
+3 -3
View File
@@ -11,12 +11,12 @@ import {
ancestorIdsOf,
descendantsOf,
findObject,
localPositionFor,
type ObjectTree,
treeOrder,
visibleTreeOrder,
} from '../../src/core/model/hierarchy';
import { findScene } from '../../src/core/model/Project';
import { localPositionFor } from '../../src/core/properties/transform';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import {
fakeContext,
@@ -39,7 +39,7 @@ function threeBoxes() {
describe('scene hierarchy', () => {
it('re-parenting keeps the world position and moves children along with the parent', () => {
const { stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes();
const { project, stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes();
stack.execute(new SetParentCommand(sceneId, b, a));
// b stays at world (10,0): local becomes (-90,-100,-1).
expect(object(b).parentId).toBe(a);
@@ -63,7 +63,7 @@ describe('scene hierarchy', () => {
expect(object(b).parentId).toBe('');
expect(object(b).position).toEqual({ x: 10, y: 0, z: 0 });
expect(localPositionFor(sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 });
expect(localPositionFor(project, sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 });
});
it('refuses cycles and self-parenting', () => {
+12
View File
@@ -25,6 +25,10 @@ describe('PropertySchema', () => {
it('provides defaults for every key', () => {
expect(defaultsFor('sprite')).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 100,
height: 100,
color: 0xffffff,
@@ -51,6 +55,10 @@ describe('resolve', () => {
sprite.props = { width: 50, bogus: 1 };
expect(resolveAssetProps(project, sprite)).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 50,
height: 100,
color: 0xffffff,
@@ -65,6 +73,10 @@ describe('resolve', () => {
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
expect(resolveSceneObjectProps(project, sprite, object)).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 100,
height: 100,
color: 0x00ff00,
+4
View File
@@ -61,6 +61,10 @@ describe('serialize', () => {
expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 10,
height: 100,
color: 0xffffff,
+18 -3
View File
@@ -458,7 +458,18 @@ describe('a sprite wearing a shader', () => {
it('has the shader picker and, once one is picked, that shader’s rows', () => {
const f = wearing();
const keys = () => schemaForAsset(f.project, f.sprite()).map((d) => d.key);
expect(keys()).toEqual(['enabled', 'width', 'height', 'color', 'texture', 'shader']);
expect(keys()).toEqual([
'enabled',
'scale',
'rotation',
'mirrorX',
'mirrorY',
'width',
'height',
'color',
'texture',
'shader',
]);
expect(schemaFor('sprite').find((d) => d.key === SHADER_KEY)).toMatchObject({
kind: 'asset',
assetTypes: ['shader'],
@@ -467,6 +478,10 @@ describe('a sprite wearing a shader', () => {
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, f.s.id));
expect(keys()).toEqual([
'enabled',
'scale',
'rotation',
'mirrorX',
'mirrorY',
'width',
'height',
'color',
@@ -479,14 +494,14 @@ describe('a sprite wearing a shader', () => {
expect(assetDeviates(f.project, f.sprite())).toBe(true);
// A dangling id adds no rows and draws nothing – the picker still shows it.
f.sprite().props[SHADER_KEY] = 'gone';
expect(keys()).toHaveLength(6);
expect(keys()).toHaveLength(10);
});
it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => {
const f = wearing();
const id = place(f, f.spriteId, { x: 0, y: 0 });
const object = () => f.object(id);
expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(6);
expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(10);
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id));
expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation');
expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({
+4
View File
@@ -50,6 +50,10 @@ describe('CreateAssetCommand with starting props', () => {
expect(findAsset(project, sprite.asset.id)?.props).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 64,
height: 32,
color: 0xffffff,
+184
View File
@@ -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);
});
});