Files
feuerwerk/src/core/properties/transform.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

152 lines
5.6 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.
/**
* Where an object is in the world: its position, rotation, scale and mirror applied inside its
* parent's, all the way up (`composeAlongChain`). The model side reads the transform rows off the
* asset and the object's overrides; the running world reads them off the entity's props.
*/
import type { Asset } from '../model/Asset';
import { findObject, type ObjectTree } from '../model/hierarchy';
import {
apply,
decompose,
determinant,
IDENTITY,
invert,
linearOf,
type Mat,
multiply,
sameLinear,
scaleOf,
angleOf,
transformMatrix,
} from '../model/matrix';
import { findAsset, type Project } from '../model/Project';
import type { Position, PropertyBag, Vec2 } from '../model/Properties';
import type { SceneObject } from '../model/SceneObject';
import { type ChainTransform, composeAlongChain } from '../model/walk';
import { hasTransformProps, TRANSFORM_PROPS } from './PropertySchema';
export interface LocalTransform {
rotation: number;
scale: number;
mirrorX: boolean;
mirrorY: boolean;
}
export const NEUTRAL: LocalTransform = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false };
export function transformOf(props: PropertyBag): LocalTransform {
return {
rotation: finiteOr(props.rotation, 0),
scale: finiteOr(props.scale, 1),
mirrorX: props.mirrorX === true,
mirrorY: props.mirrorY === true,
};
}
function finiteOr(value: unknown, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
/** The object's own rows without resolving the whole bag: an override, else the asset's value. */
export function transformOfObject(asset: Asset, object: SceneObject): LocalTransform {
if (!hasTransformProps(asset.type)) return NEUTRAL;
const bag: PropertyBag = {};
for (const { key } of TRANSFORM_PROPS) {
const value = object.overrides[key] ?? asset.props[key];
if (value !== undefined) bag[key] = value;
}
return transformOf(bag);
}
export function localMatrixOf(position: Vec2, t: LocalTransform): Mat {
return transformMatrix(position.x, position.y, t.rotation, t.scale, t.mirrorX, t.mirrorY);
}
function localOf(project: Project, object: SceneObject, withEditorOffset: boolean): ChainTransform {
const asset = findAsset(project, object.assetId);
const t = asset ? transformOfObject(asset, object) : NEUTRAL;
const offset = withEditorOffset ? object.editorOffset : { x: 0, y: 0 };
const at = { x: object.position.x + offset.x, y: object.position.y + offset.y };
return { m: localMatrixOf(at, t), z: object.position.z };
}
function chain(project: Project, tree: ObjectTree, object: SceneObject, withEditorOffset: boolean) {
return composeAlongChain(
object,
(o) => (o.parentId ? findObject(tree, o.parentId) : undefined),
(o) => localOf(project, o, withEditorOffset),
);
}
export function worldTransform(project: Project, tree: ObjectTree, object: SceneObject): ChainTransform {
return chain(project, tree, object, false);
}
/**
* The world transform plus the editor offsets along the chain. A view aid only – the game,
* collisions and the graph all use `worldTransform`.
*/
export function editorTransform(project: Project, tree: ObjectTree, object: SceneObject): ChainTransform {
return chain(project, tree, object, true);
}
export function positionOf({ m, z }: ChainTransform): Position {
return { x: m.tx, y: m.ty, z };
}
export function worldPosition(project: Project, tree: ObjectTree, object: SceneObject): Position {
return positionOf(worldTransform(project, tree, object));
}
export function editorPosition(project: Project, tree: ObjectTree, object: SceneObject): Position {
return positionOf(editorTransform(project, tree, object));
}
/** The world transform of a parent ('' = top level, the identity). */
export function parentTransform(project: Project, tree: ObjectTree, parentId: string): ChainTransform {
const parent = parentId ? findObject(tree, parentId) : undefined;
return parent ? worldTransform(project, tree, parent) : { m: IDENTITY, z: 0 };
}
/** The local position that puts an object at `world` under the given parent ('' = top level). */
export function localPositionFor(
project: Project,
tree: ObjectTree,
parentId: string,
world: Position,
): Position {
const parent = parentTransform(project, tree, parentId);
const local = apply(invert(parent.m), world);
return { x: local.x, y: local.y, z: world.z - parent.z };
}
/**
* The rows that keep an object looking the same after the linear part above it changed from
* `before` to `after` (re-parenting, grouping). Nothing changes when nothing has to: an object
* moved between two upright parents keeps its own rows, mirror flags included.
*/
export function carriedTransform(own: LocalTransform, before: Mat, after: Mat): LocalTransform {
const carry = multiply(invert(linearOf(after)), linearOf(before));
if (sameLinear(carry, IDENTITY)) return own;
if (determinant(carry) > 0)
return {
...own,
rotation: own.rotation + (angleOf(carry) * 180) / Math.PI,
scale: own.scale * scaleOf(carry),
};
return { ...decompose(multiply(carry, localMatrixOf({ x: 0, y: 0 }, own))), mirrorY: false };
}
/** Writes the carried rows as overrides of a stored object; a type without the rows is left alone. */
export function carryTransform(asset: Asset, object: SceneObject, before: Mat, after: Mat): void {
if (!hasTransformProps(asset.type)) return;
const own = transformOfObject(asset, object);
const carried = carriedTransform(own, before, after);
if (carried === own) return;
object.overrides.scale = carried.scale;
object.overrides.rotation = carried.rotation;
object.overrides.mirrorX = carried.mirrorX;
object.overrides.mirrorY = carried.mirrorY;
}