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

+143
View File
@@ -0,0 +1,143 @@
import type { Vec2 } from './Properties';
/**
* A 2D affine transform in Pixi's layout: x' = a·x + c·y + tx, y' = b·x + d·y + ty. Every transform
* in the hierarchy is a similarity (uniform scale, rotation, an optional mirror), so their products
* are too – which is what lets `decompose` recover rotation and scale from one.
*/
export interface Mat {
a: number;
b: number;
c: number;
d: number;
tx: number;
ty: number;
}
export const IDENTITY: Mat = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
export function translation(x: number, y: number): Mat {
return { a: 1, b: 0, c: 0, d: 1, tx: x, ty: y };
}
/**
* Translate ∘ rotate ∘ scale: a point is scaled (a mirror flips the axis first), then turned, then
* moved. Rotation in degrees, positive clockwise on screen because y grows downwards.
*/
export function transformMatrix(
x: number,
y: number,
rotation: number,
scale: number,
mirrorX: boolean,
mirrorY: boolean,
): Mat {
const r = (rotation * Math.PI) / 180;
const cos = Math.cos(r);
const sin = Math.sin(r);
const sx = mirrorX ? -scale : scale;
const sy = mirrorY ? -scale : scale;
return { a: cos * sx, b: sin * sx, c: -sin * sy, d: cos * sy, tx: x, ty: y };
}
/** `outer ∘ inner`: apply `inner` first. */
export function multiply(outer: Mat, inner: Mat): Mat {
return {
a: outer.a * inner.a + outer.c * inner.b,
b: outer.b * inner.a + outer.d * inner.b,
c: outer.a * inner.c + outer.c * inner.d,
d: outer.b * inner.c + outer.d * inner.d,
tx: outer.a * inner.tx + outer.c * inner.ty + outer.tx,
ty: outer.b * inner.tx + outer.d * inner.ty + outer.ty,
};
}
export function apply(m: Mat, p: Vec2): Vec2 {
return { x: m.a * p.x + m.c * p.y + m.tx, y: m.b * p.x + m.d * p.y + m.ty };
}
/** The linear part only – for directions and deltas, which no translation touches. */
export function applyVector(m: Mat, v: Vec2): Vec2 {
return { x: m.a * v.x + m.c * v.y, y: m.b * v.x + m.d * v.y };
}
export function linearOf(m: Mat): Mat {
return { ...m, tx: 0, ty: 0 };
}
export function determinant(m: Mat): number {
return m.a * m.d - m.b * m.c;
}
/** A transform with scale 0 has no inverse; its translation is undone and the rest left alone. */
export function invert(m: Mat): Mat {
const det = determinant(m);
if (Math.abs(det) < 1e-12) return translation(-m.tx, -m.ty);
const a = m.d / det;
const b = -m.b / det;
const c = -m.c / det;
const d = m.a / det;
return { a, b, c, d, tx: -(a * m.tx + c * m.ty), ty: -(b * m.tx + d * m.ty) };
}
/** The uniform scale factor of a similarity. */
export function scaleOf(m: Mat): number {
return Math.hypot(m.a, m.b);
}
/** The angle the x axis was turned by, in radians. */
export function angleOf(m: Mat): number {
return Math.atan2(m.b, m.a);
}
export function isMirrored(m: Mat): boolean {
return determinant(m) < 0;
}
/**
* Rotation (degrees), scale and mirror that rebuild the linear part of a similarity. A mirror
* always comes out as `mirrorX`: mirroring y is mirroring x turned by a half circle.
*/
export function decompose(m: Mat): { rotation: number; scale: number; mirrorX: boolean } {
const mirrorX = isMirrored(m);
const a = mirrorX ? -m.a : m.a;
const b = mirrorX ? -m.b : m.b;
return { rotation: (Math.atan2(b, a) * 180) / Math.PI, scale: scaleOf(m), mirrorX };
}
export function sameLinear(p: Mat, q: Mat, epsilon = 1e-9): boolean {
return (
Math.abs(p.a - q.a) < epsilon &&
Math.abs(p.b - q.b) < epsilon &&
Math.abs(p.c - q.c) < epsilon &&
Math.abs(p.d - q.d) < epsilon
);
}
/** The four corners of a `w`×`h` box centred at (`cx`, `cy`) in local units, taken through `m`. */
export function boxCorners(m: Mat, w: number, h: number, cx = 0, cy = 0): Vec2[] {
return [
apply(m, { x: cx - w / 2, y: cy - h / 2 }),
apply(m, { x: cx + w / 2, y: cy - h / 2 }),
apply(m, { x: cx + w / 2, y: cy + h / 2 }),
apply(m, { x: cx - w / 2, y: cy + h / 2 }),
];
}
export interface Extent {
left: number;
top: number;
right: number;
bottom: number;
}
export function extentOfPoints(points: readonly Vec2[]): Extent {
const extent = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity };
for (const p of points) {
extent.left = Math.min(extent.left, p.x);
extent.top = Math.min(extent.top, p.y);
extent.right = Math.max(extent.right, p.x);
extent.bottom = Math.max(extent.bottom, p.y);
}
return extent;
}