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
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user