Files
feuerwerk/src/core/model/matrix.ts
T
rootandClaude Fable 5.1 016e83c9b5
game-maker / suite (push) Successful in 9m5s
Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
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>
2026-09-13 17:25:06 +02:00

144 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}