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

+42 -23
View File
@@ -1,11 +1,13 @@
import { isHandleEnabled, worldPositionOf } from '../../core/graph';
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { applyVector, IDENTITY } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { parentChain } from '../../core/model/walk';
import {
axisCorrection,
colliderShape,
colliderSize,
exitDistance,
isTrigger,
type PlaneAxis,
shapeExtent,
shapesOverlap,
} from '../../core/properties/collider';
import type { Entity } from '../Entity';
@@ -78,6 +80,7 @@ export class CollisionSystem implements System {
/**
* Moves one axis at a time, stopping each at contact so the object slides along walls. An existing
* overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
* The axes are the entity's own: under a turned parent, "x" runs wherever that parent points it.
*/
moveAndSlide(
world: World,
@@ -107,12 +110,13 @@ export class CollisionSystem implements System {
delta: number,
): boolean {
if (delta === 0 || own.length === 0) return false;
const travel = axisTravel(entity, axis);
const steps = Math.min(
MAX_STEPS,
Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))),
Math.max(1, Math.ceil((Math.abs(delta) * travel.length) / stepLimit(own, obstacles, travel.unit))),
);
for (let i = 0; i < steps; i++)
if (this.stepAxis(entity, own, obstacles, axis, delta / steps)) return true;
if (this.stepAxis(entity, own, obstacles, axis, delta / steps, travel)) return true;
return false;
}
@@ -123,8 +127,10 @@ export class CollisionSystem implements System {
obstacles: Entity[],
axis: PlaneAxis,
delta: number,
travel: AxisTravel,
): boolean {
const dir: 1 | -1 = delta > 0 ? 1 : -1;
const forward = { x: dir * travel.unit.x, y: dir * travel.unit.y };
// How deep each pair already sits in the other before the step. A step may never deepen
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
// Overlaps below the skin count as plain contact – that is where rounding leftovers of a
@@ -132,36 +138,51 @@ export class CollisionSystem implements System {
const before: [Entity, Entity, number][] = [];
for (const a of own) {
for (const b of obstacles) {
const p = penetration(a, b, axis, dir);
const p = penetration(a, b, forward);
before.push([a, b, p > SKIN ? p : 0]);
}
}
entity[axis] += delta;
let correction = 0;
for (const [a, b, was] of before) {
const t = penetration(a, b, axis, dir) - was;
const t = penetration(a, b, forward) - was;
if (t > correction) correction = t;
}
if (correction <= 0) return false;
entity[axis] -= dir * Math.min(correction, Math.abs(delta));
entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta));
return true;
}
}
/** Where one unit along an entity's local axis goes in the world, and how far. */
interface AxisTravel {
unit: Vec2;
length: number;
}
function axisTravel(entity: Entity, axis: PlaneAxis): AxisTravel {
const parent = entity.parent ? worldMatrixOf(entity.parent) : IDENTITY;
const v = applyVector(parent, axis === 'x' ? { x: 1, y: 0 } : { x: 0, y: 1 });
const length = Math.hypot(v.x, v.y);
return length > 0
? { unit: { x: v.x / length, y: v.y / length }, length }
: { unit: { x: 1, y: 0 }, length: 1 };
}
function shapeOf(collider: Entity) {
return colliderShape(collider.props, worldPositionOf(collider));
return colliderShape(collider.props, worldMatrixOf(collider));
}
/**
* How far `a` would have to be pushed back against `dir` to just clear `b` (0 when they are
* apart). Once a's centre has passed b's centre along `dir` the shorter way out is forwards,
* How far `a` would have to be pushed back against its travel to just clear `b` (0 when they are
* apart). Once a's centre has passed b's centre along the travel the shorter way out is forwards,
* so nothing blocks – that keeps an object that ended up inside something able to leave.
*/
function penetration(a: Entity, b: Entity, axis: PlaneAxis, dir: 1 | -1): number {
function penetration(a: Entity, b: Entity, forward: Vec2): number {
const mover = shapeOf(a);
const obstacle = shapeOf(b);
if (dir * (mover[axis] - obstacle[axis]) >= 0) return 0;
return axisCorrection(mover, obstacle, axis, dir);
if ((mover.x - obstacle.x) * forward.x + (mover.y - obstacle.y) * forward.y >= 0) return 0;
return exitDistance(mover, obstacle, { x: -forward.x, y: -forward.y });
}
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
@@ -170,19 +191,17 @@ function travelsWith(collider: Entity, entity: Entity): boolean {
return false;
}
/** Longest safe step along `axis`: half of the thinnest collider involved (never below 1). */
function stepLimit(own: Entity[], obstacles: Entity[], axis: PlaneAxis): number {
/**
* Longest safe step in world units: half of the thinnest collider involved, measured along the
* travel as the colliders sit in the world (never below 1).
*/
function stepLimit(own: Entity[], obstacles: Entity[], along: Vec2): number {
let min = Infinity;
for (const e of own) min = Math.min(min, extentOf(e, axis));
for (const e of obstacles) min = Math.min(min, extentOf(e, axis));
for (const e of own) min = Math.min(min, shapeExtent(shapeOf(e), along));
for (const e of obstacles) min = Math.min(min, shapeExtent(shapeOf(e), along));
return Math.max(1, min / 2);
}
function extentOf(collider: Entity, axis: PlaneAxis): number {
const size = colliderSize(collider.props);
return axis === 'x' ? size.w : size.h;
}
/** The object a collider belongs to: its parent, or the collider itself when top-level. */
export function bodyOf(collider: Entity): Entity {
return collider.parent ?? collider;