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

+2
View File
@@ -187,6 +187,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
shownLabel,
tabBounds: () => editor.tabBounds(),
viewportCenter: () => centreOf(viewport),
/** Pans the Scene tab to a world point – a step's own lane, away from what earlier steps left. */
lookAt: (x: number, y: number) => viewport.lookAt(x, y),
/** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */
viewportRect: () => {
const p = viewport.getGlobalPosition();
+6
View File
@@ -28,6 +28,7 @@ import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
isCommonProp,
isTransformProp,
type PropertyDef,
schemaFor,
schemaForObject,
@@ -155,6 +156,11 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
});
}
// Turn, size and mirror sit with the position: together they are where the object is.
const transform = schemaForObject(project, asset, object).filter((def) => isTransformProp(def.key));
if (transform.length > 0) addTitle(host, 'TRANSFORM');
for (const def of transform) propertyRow(host, holderId, objectId, asset, def, resolved);
// Where the object is *drawn* in the viewport, nothing else – for pulling stacked objects apart.
addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)');
for (const axis of OFFSET_AXES) {
+41 -33
View File
@@ -9,10 +9,12 @@ import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/s
import { isSymbolType } from '../../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../../core/model/instance';
import { applyVector, type Extent, invert, linearOf, type Mat } from '../../core/model/matrix';
import { placementRejection } from '../../core/model/placement';
import type { Position, Vec2 } from '../../core/model/Properties';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { footprintOf } from '../../core/properties/bounds';
import { footprintCorners, footprintExtent } from '../../core/properties/bounds';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { boundsOf } from '../framing';
import { type EditorState, objectSelection } from '../state';
import {
@@ -22,7 +24,6 @@ import {
drawGrid,
endDrag,
onDragChange,
type Size,
Theme,
textStyle,
zoomFactor,
@@ -45,7 +46,8 @@ const FIT_MARGIN = 40;
interface ObjectDrag {
holderId: string;
items: { objectId: string; start: Position }[];
/** `toLocal` turns a world delta into one in the object's parent frame (the parent may be turned or sized). */
items: { objectId: string; start: Position; toLocal: Mat }[];
pointer: Vec2;
moved: boolean;
}
@@ -135,8 +137,8 @@ export class SceneViewport extends WorldViewport {
fit(): void {
const { items } = this.collectFrame();
const boxes = items.map((item) => {
const { w, h } = footprintOf(item.type, item.props);
return { id: item.id, x: item.x - w / 2, y: item.y - h / 2, w, h };
const e = footprintExtent(item.type, item.props, item.m);
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
});
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
const width = Math.max(maxX - minX, 1);
@@ -153,6 +155,13 @@ export class SceneViewport extends WorldViewport {
this.redraw();
}
/** Centres the view on a world point, keeping the zoom (test hook). */
lookAt(x: number, y: number): void {
this.view.x = x;
this.view.y = y;
this.redraw();
}
protected override currentView(): View {
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
}
@@ -210,11 +219,11 @@ export class SceneViewport extends WorldViewport {
this.dragging.moved = true;
const holder = this.holder();
if (!holder) return;
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
for (const item of this.dragging.items) {
const local = applyVector(item.toLocal, { x: dx, y: dy });
objectRef(this.state.project, holder, item.objectId)?.setPosition({
x: Math.round(item.start.x + dx),
y: Math.round(item.start.y + dy),
x: Math.round(item.start.x + local.x),
y: Math.round(item.start.y + local.y),
z: item.start.z,
});
}
@@ -331,7 +340,11 @@ export class SceneViewport extends WorldViewport {
const items = subtreeRoots(holder, this.state.selectedObjectIds)
.map((id) => findObject(holder, id))
.filter((o): o is NonNullable<typeof o> => Boolean(o))
.map((o) => ({ objectId: o.id, start: { ...o.position } }));
.map((o) => ({
objectId: o.id,
start: { ...o.position },
toLocal: invert(linearOf(parentTransform(this.state.project, holder, o.parentId).m)),
}));
const local = this.toLocal(e.global);
this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
}
@@ -368,8 +381,8 @@ export class SceneViewport extends WorldViewport {
this.drawGizmos(items, view);
}
protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, size: Size): void {
this.syncLabel(item, onScreen, scale, size);
protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void {
this.syncLabel(item, onScreen, scale, extent);
}
protected override disposeDecorations(id: string): void {
@@ -411,7 +424,7 @@ export class SceneViewport extends WorldViewport {
}
}
private syncLabel(item: DrawItem, show: boolean, scale: number, size: Size): void {
private syncLabel(item: DrawItem, show: boolean, scale: number, extent: Extent): void {
let label = this.labels.get(item.id);
if (!show || !isSymbolType(item.type)) {
if (label) label.visible = false;
@@ -427,8 +440,8 @@ export class SceneViewport extends WorldViewport {
label.text = item.name;
label.scale.set(1 / scale);
if (item.type === 'camera' || item.type === 'collider')
label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
label.position.set(extent.left, extent.top - 16 / scale);
else label.position.set(extent.right + 4 / scale, item.y - 8 / scale);
}
/**
@@ -440,12 +453,10 @@ export class SceneViewport extends WorldViewport {
const asset = preview && findAsset(this.state.project, preview.assetId);
if (!preview || !asset) return;
const color = preview.rejection ? Theme.colors.danger : Theme.colors.accent;
const { w, h } = footprintOf(asset.type, asset.props);
const m = localMatrixOf(preview.at, transformOf(asset.props));
const centre = worldToScreen(view, preview.at.x, preview.at.y);
const sw = Math.max(w * view.scale, 8);
const sh = Math.max(h * view.scale, 8);
this.gizmos
.rect(centre.x - sw / 2, centre.y - sh / 2, sw, sh)
.poly(this.screenCorners(footprintCorners(asset.type, asset.props, m), view), true)
.fill({ color, alpha: 0.12 })
.stroke({ width: 2, color });
this.gizmos
@@ -456,6 +467,10 @@ export class SceneViewport extends WorldViewport {
.stroke({ width: 1, color });
}
private screenCorners(corners: readonly Vec2[], view: View): Vec2[] {
return corners.map((c) => worldToScreen(view, c.x, c.y));
}
/** Selection outlines – only the primary object gets corner handles – and the rubber band. */
private drawGizmos(items: DrawItem[], view: View): void {
this.lastGizmos = [];
@@ -464,27 +479,20 @@ export class SceneViewport extends WorldViewport {
for (const id of s.objectIds) {
const item = items.find((i) => i.id === id);
if (!item) continue;
const { w, h } = footprintOf(item.type, item.props);
const tl = worldToScreen(view, item.x - w / 2, item.y - h / 2);
const sw = w * view.scale;
const sh = h * view.scale;
// Two screen pixels outside the footprint, whichever way it is turned.
const corners = this.screenCorners(
footprintCorners(item.type, item.props, item.m, 2 / view.scale),
view,
);
// A switched-off object draws nothing, so its outline is the only way to see where it
// sits – dimmed, because the empty frame would otherwise read as a drawing that failed.
const alpha = item.enabled ? 1 : OFF_GIZMO_ALPHA;
const primary = id === s.objectId;
this.lastGizmos.push({ id, primary, alpha });
this.gizmos
.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4)
.stroke({ width: 2, color: Theme.colors.gizmo, alpha });
this.gizmos.poly(corners, true).stroke({ width: 2, color: Theme.colors.gizmo, alpha });
if (!primary) continue;
for (const [cx, cy] of [
[tl.x, tl.y],
[tl.x + sw, tl.y],
[tl.x, tl.y + sh],
[tl.x + sw, tl.y + sh],
] as const) {
this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
}
for (const c of corners)
this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
}
}
this.drawDropPreview(view);
+25 -14
View File
@@ -1,14 +1,21 @@
import { Container, type FederatedPointerEvent, Graphics, type Mesh, Text, TextStyle } from 'pixi.js';
import { isSymbolType } from '../../core/model/AssetTypes';
import { type Extent, scaleOf } from '../../core/model/matrix';
import { sortByZ, type Vec2, textOf } from '../../core/model/Properties';
import { footprintOf, textAnchor } from '../../core/properties/bounds';
import { rectAround, rectsIntersect } from '../../core/properties/camera';
import { footprintExtent, footprintOf, placementOf, textAnchor } from '../../core/properties/bounds';
import { rectsIntersect } from '../../core/properties/camera';
import type { EditorState } from '../state';
import { type Size, Theme, Widget } from '../../ui';
import type { DrawItem } from '../../viewport/items';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { type ShaderSource, wornShader } from '../../viewport/shaders';
import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols';
import {
disposeSpriteMesh,
drawItem,
newSpriteMesh,
placeNode,
syncSpriteMesh,
} from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
@@ -78,7 +85,8 @@ export abstract class WorldViewport extends Widget {
protected afterItems(_items: DrawItem[], _view: View): void {}
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _size: Size): void {}
/** `extent` is the world rectangle around the item as drawn, turn and size included. */
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _extent: Extent): void {}
/** An item disappeared: drop whatever `syncDecorations` created for it. */
protected disposeDecorations(_id: string): void {}
@@ -120,19 +128,22 @@ export abstract class WorldViewport extends Widget {
return { x: origin.x + local.x, y: origin.y + local.y };
}
/** The screen rectangle an object occupies, so tests can sample the pixels inside it. */
/**
* The screen rectangle around an object, so tests can sample the pixels inside it: centre plus
* size, and for a turned object the box around its corners.
*/
objectRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null;
const local = worldToScreen(view, item.x, item.y);
const extent = footprintExtent(item.type, item.props, item.m);
const local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2);
const origin = this.getGlobalPosition();
const size = footprintOf(item.type, item.props);
return {
x: origin.x + local.x,
y: origin.y + local.y,
w: size.w * view.scale,
h: size.h * view.scale,
w: (extent.right - extent.left) * view.scale,
h: (extent.bottom - extent.top) * view.scale,
};
}
@@ -164,9 +175,10 @@ export abstract class WorldViewport extends Widget {
for (const item of items) {
seen.add(item.id);
const size = footprintOf(item.type, item.props);
const extent = footprintExtent(item.type, item.props, item.m);
// A switched-off object is not drawn at all, in the scene view as in the game; it is
// selected and switched back on from the hierarchy panel.
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
const onScreen = item.enabled && rectsIntersect(extent, bounds);
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
@@ -178,13 +190,12 @@ export abstract class WorldViewport extends Widget {
}
node.visible = onScreen;
node.eventMode = this.itemsInteractive && onScreen ? 'static' : 'none';
this.syncDecorations(item, onScreen, view.scale, size);
this.syncDecorations(item, onScreen, view.scale, extent);
this.syncText(item, onScreen, view.scale);
this.syncSprite(item, onScreen, size, shaders);
if (!onScreen) continue;
this.visibleCount++;
node.position.set(item.x, item.y);
drawItem(node, item, size, view.scale);
drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m)));
}
this.afterItems(items, view);
for (const [id, node] of this.nodes) {
@@ -253,7 +264,7 @@ export abstract class WorldViewport extends Widget {
this.objects.addChild(text);
}
text.visible = true;
text.position.set(item.x, item.y);
placeNode(text, item.m);
text.text = textOf(item.props.text ?? '');
const size = Math.max(1, Number(item.props.fontSize ?? 32));
if (text.style.fontSize !== size) text.style.fontSize = size;