Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
game-maker / suite (push) Successful in 9m5s
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:
1 parent
f85606427b
commit
016e83c9b5
33 files changed
+1346
-274
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user