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