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

+1 -33
View File
@@ -1,7 +1,6 @@
import { type Removed, removeWhere } from '../util/list';
import type { Position } from './Properties';
import type { SceneObject } from './SceneObject';
import { descendantsBelow, parentChain, sumAlongChain } from './walk';
import { descendantsBelow, parentChain } from './walk';
/**
* `objects` stays a flat list in creation order; the tree is implied by `SceneObject.parentId`.
@@ -78,37 +77,6 @@ export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[]
return ids.filter((id) => !ancestorIdsOf(tree, id).some((ancestorId) => set.has(ancestorId)));
}
function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position {
return sumAlongChain(
object,
(o) => parentOf(tree, o),
(o) =>
withEditorOffset
? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z }
: o.position,
);
}
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
return positionAlongChain(tree, object, false);
}
/**
* World position plus the editor offsets along the chain. A view aid only – the game, collisions
* and the graph all use `worldPosition`.
*/
export function editorPosition(tree: ObjectTree, object: SceneObject): Position {
return positionAlongChain(tree, object, true);
}
/** The local position that puts an object at `world` under the given parent ('' = top level). */
export function localPositionFor(tree: ObjectTree, parentId: string, world: Position): Position {
const parent = parentId ? findObject(tree, parentId) : undefined;
if (!parent) return { ...world };
const origin = worldPosition(tree, parent);
return { x: world.x - origin.x, y: world.y - origin.y, z: world.z - origin.z };
}
export interface TreeEntry {
object: SceneObject;
depth: number;
+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;
}
+17 -11
View File
@@ -1,4 +1,4 @@
import type { Position } from './Properties';
import { IDENTITY, type Mat, multiply } from './matrix';
/**
* `start` and every ancestor above it, nearest first. A revisited item ends the walk: a cyclic
@@ -23,20 +23,26 @@ export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | un
return Boolean(parentOf(last));
}
/** Summing local positions gives the world position only because the hierarchy has no rotation or scale. */
export function sumAlongChain<T>(
/** A world transform: the matrix places, turns and sizes; `z` is draw order and simply adds up. */
export interface ChainTransform {
m: Mat;
z: number;
}
/** Each item's local transform applied inside its parent's, root outermost. */
export function composeAlongChain<T>(
start: T,
parentOf: (item: T) => T | null | undefined,
positionOf: (item: T) => Position,
): Position {
const total: Position = { x: 0, y: 0, z: 0 };
localOf: (item: T) => ChainTransform,
): ChainTransform {
let m = IDENTITY;
let z = 0;
for (const item of parentChain(start, parentOf)) {
const position = positionOf(item);
total.x += position.x;
total.y += position.y;
total.z += position.z;
const local = localOf(item);
m = multiply(local.m, m);
z += local.z;
}
return total;
return { m, z };
}
/** Everything below `rootKey`, depth-first, parents before children. */