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

+1 -1
View File
@@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 742 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 758 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+33 -23
View File
@@ -1,18 +1,18 @@
import { newId } from '../ids';
import type { ContainerAsset } from '../model/Asset';
import {
findObject,
localPositionFor,
removeSubtrees,
subtreeIds,
subtreeRoots,
treeOrder,
worldPosition,
} from '../model/hierarchy';
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
import { type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
import { apply, invert, multiply, translation } from '../model/matrix';
import type { Position } from '../model/Properties';
import { findObjectHolder, type Project } from '../model/Project';
import { findAsset, findObjectHolder, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import {
carryTransform,
localPositionFor,
parentTransform,
worldPosition,
worldTransform,
} from '../properties/transform';
import { removeById, type Removed, restoreAt } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
@@ -42,7 +42,7 @@ export class CreateContainerCommand implements Command {
const selected = ids.map((id) => requireStructuralObject(project, holder, id));
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
const worlds = selected.map((o) => worldPosition(holder, o));
const worlds = selected.map((o) => worldPosition(project, holder, o));
const centre: Position = {
x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2),
y: Math.round((Math.min(...worlds.map((p) => p.y)) + Math.max(...worlds.map((p) => p.y))) / 2),
@@ -68,31 +68,41 @@ export class CreateContainerCommand implements Command {
nextAssetName(project, 'Container'),
this.assetId,
) as ContainerAsset;
const containerLocal = localPositionFor(project, holder, containerParentId, centre);
// The template is the container's frame: a root keeps its place and look inside it, which
// under a turned or sized parent means transform rows of its own (see `carryTransform`).
const containerWorld = multiply(
parentTransform(project, holder, containerParentId).m,
translation(containerLocal.x, containerLocal.y),
);
const templateIds = this.templateIds;
for (const object of members) if (!templateIds.has(object.id)) templateIds.set(object.id, newId());
for (const object of members) {
const isRoot = roots.has(object.id);
const world = worldPosition(holder, object);
asset.objects.push({
const world = worldTransform(project, holder, object);
const inside = apply(invert(containerWorld), { x: world.m.tx, y: world.m.ty });
const template: SceneObject = {
...object,
id: templateIds.get(object.id)!,
position: isRoot
? { x: world.x - centre.x, y: world.y - centre.y, z: world.z }
: { ...object.position },
position: isRoot ? { x: inside.x, y: inside.y, z: world.z } : { ...object.position },
// A view-only offset belongs to the placed object, not to the template.
editorOffset: { x: 0, y: 0 },
overrides: { ...object.overrides },
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
instanceEdits: { ...object.instanceEdits },
});
};
const objectAsset = findAsset(project, object.assetId);
if (isRoot && objectAsset)
carryTransform(
objectAsset,
template,
parentTransform(project, holder, object.parentId).m,
containerWorld,
);
asset.objects.push(template);
}
const container = createSceneObject(
this.objectId,
this.assetId,
localPositionFor(holder, containerParentId, centre),
containerParentId,
);
const container = createSceneObject(this.objectId, this.assetId, containerLocal, containerParentId);
this.removed = removeSubtrees(holder, [...roots]);
project.assets.push(asset);
holder.objects.push(container);
+35 -7
View File
@@ -1,5 +1,5 @@
import { newId } from '../ids';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import { isSelfOrAncestor, removeSubtrees } from '../model/hierarchy';
import {
captureObject,
isLiveInstance,
@@ -8,10 +8,23 @@ import {
restoreObject,
} from '../model/instance';
import { placementRejection } from '../model/placement';
import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import {
type Position,
type PropertyBag,
type PropertyValue,
toPosition,
type Vec2,
} from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
import {
carryTransform,
localPositionFor,
parentTransform,
positionOf,
worldTransform,
} from '../properties/transform';
import { type Removed, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import {
@@ -75,7 +88,7 @@ export class PlaceAssetCommand implements Command {
const rejection = placementRejection(project, holder, asset);
if (rejection) throw new CommandError(rejection);
if (this.parentId) requireStructuralObject(project, holder, this.parentId);
const local = localPositionFor(holder, this.parentId, toPosition(this.position));
const local = localPositionFor(project, holder, this.parentId, toPosition(this.position));
holder.objects.push(createSceneObject(this.objectId, this.assetId, local, this.parentId));
}
@@ -256,9 +269,10 @@ export class ReorderObjectCommand implements Command {
*/
export class SetParentCommand implements Command {
readonly label = 'Set parent';
private previous: { parentId: string; position: Position } = {
private previous: { parentId: string; position: Position; overrides: PropertyBag } = {
parentId: '',
position: { x: 0, y: 0, z: 0 },
overrides: {},
};
constructor(
@@ -277,15 +291,29 @@ export class SetParentCommand implements Command {
if (isSelfOrAncestor(holder, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
this.previous = { parentId: object.parentId, position: { ...object.position } };
const world = worldPosition(holder, object);
this.previous = {
parentId: object.parentId,
position: { ...object.position },
overrides: { ...object.overrides },
};
// The object stays where it is and how it looks: the new local position and, when the parent
// chain's turn or size changed, transform rows that absorb the difference.
const world = worldTransform(project, holder, object);
const before = parentTransform(project, holder, object.parentId).m;
object.parentId = this.parentId;
object.position = localPositionFor(holder, this.parentId, world);
object.position = localPositionFor(project, holder, this.parentId, positionOf(world));
carryTransform(
requireAsset(project, object.assetId),
object,
before,
parentTransform(project, holder, this.parentId).m,
);
}
undo(project: Project): void {
const object = requireObject(project, this.holderId, this.objectId);
object.parentId = this.previous.parentId;
object.position = { ...this.previous.position };
object.overrides = { ...this.previous.overrides };
}
}
+16 -5
View File
@@ -1,6 +1,8 @@
import type { PropertyBag } from '../model/Properties';
import { sumAlongChain } from '../model/walk';
import type { Mat } from '../model/matrix';
import type { Position, PropertyBag } from '../model/Properties';
import { type ChainTransform, composeAlongChain } from '../model/walk';
import { enabledAlongChain } from '../properties/enabled';
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
/**
* Marks a live entity apart from a struct value: both reach a data edge as an object. A symbol
@@ -26,14 +28,23 @@ export interface EntityHandle {
parent: EntityHandle | null;
}
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
return sumAlongChain(
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
export function worldTransformOf(handle: EntityHandle): ChainTransform {
return composeAlongChain(
handle,
(h) => h.parent,
(h) => h,
(h) => ({ m: localMatrixOf(h, transformOf(h.props)), z: h.z }),
);
}
export function worldMatrixOf(handle: EntityHandle): Mat {
return worldTransformOf(handle).m;
}
export function worldPositionOf(handle: EntityHandle): Position {
return positionOf(worldTransformOf(handle));
}
/** Whether a handle and every ancestor of it are switched on. */
export function isHandleEnabled(handle: EntityHandle): boolean {
return enabledAlongChain(
+1 -33
View File
@@ -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;
+143
View File
@@ -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
View File
@@ -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. */
+29 -2
View File
@@ -96,8 +96,33 @@ export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
];
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
/**
* How a drawn or grouping object is placed inside its parent, on top of its position. They cascade
* like a position does (see `properties/transform.ts`), so a child turns and grows with its parent.
*/
export const TRANSFORM_PROPS: PropertySchema = [
{ key: 'scale', label: 'Scale', kind: 'number', default: 1, min: 0, step: 0.1 },
{ key: 'rotation', label: 'Rotation', kind: 'number', default: 0, step: 1 },
{ key: 'mirrorX', label: 'Mirror X', kind: 'boolean', default: false },
{ key: 'mirrorY', label: 'Mirror Y', kind: 'boolean', default: false },
];
/** Cameras and behaviors are placed by the chain but have no look of their own to turn or size. */
const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'collider', 'container']);
export function hasTransformProps(type: AssetType): boolean {
return TRANSFORM_TYPES.has(type);
}
const TRANSFORM_KEYS = new Set(TRANSFORM_PROPS.map((d) => d.key));
export function isTransformProp(key: string): boolean {
return TRANSFORM_KEYS.has(key);
}
const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS].map((d) => d.key));
/** The switch and the transform rows: reserved names, shown apart from the type's own properties. */
export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key);
}
@@ -203,7 +228,9 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type,
isPlaceableType(type) ? [...COMMON_PROPS, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type],
isPlaceableType(type)
? [...COMMON_PROPS, ...(hasTransformProps(type) ? TRANSFORM_PROPS : []), ...ASSET_SCHEMAS[type]]
: ASSET_SCHEMAS[type],
]),
) as Record<AssetType, PropertySchema>;
+42 -6
View File
@@ -1,8 +1,19 @@
/**
* Footprints in world units, shared by the viewport, by pointer hit tests in the running game and by
* "Add collider", so all three agree on how big a thing is.
* "Add collider", so all three agree on how big a thing is – and, through the object's transform, on
* where its corners are.
*/
import type { AssetType } from '../model/AssetTypes';
import { type AssetType, isSymbolType } from '../model/AssetTypes';
import {
apply,
boxCorners,
type Extent,
extentOfPoints,
invert,
type Mat,
scaleOf,
translation,
} from '../model/matrix';
import { type PropertyBag, textOf, type Vec2 } from '../model/Properties';
import { cameraFrame } from './camera';
import { colliderSize } from './collider';
@@ -53,9 +64,34 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
}
}
export function footprintContains(type: AssetType, props: PropertyBag, centre: Vec2, point: Vec2): boolean {
/**
* How an item of this type is drawn: with its whole transform, or – for the icons of behaviors,
* containers and the camera frame – upright and unscaled at its position, since an icon that turns
* with its parent would only be harder to read. Colliders are symbols too but follow their body.
*/
export function placementOf(type: AssetType, m: Mat): Mat {
return isSymbolType(type) && type !== 'collider' ? translation(m.tx, m.ty) : m;
}
function anchorOffset(type: AssetType, props: PropertyBag, w: number): number {
return type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
}
/** The footprint's corners in world units, grown by `pad` world units on every side. */
export function footprintCorners(type: AssetType, props: PropertyBag, m: Mat, pad = 0): Vec2[] {
const placed = placementOf(type, m);
const { w, h } = footprintOf(type, props);
const offset = type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
const x = centre.x + offset;
return Math.abs(point.x - x) <= w / 2 && Math.abs(point.y - centre.y) <= h / 2;
const local = pad / (scaleOf(placed) || 1);
return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0);
}
/** The world rectangle around the drawn footprint – what culling, framing and labels need. */
export function footprintExtent(type: AssetType, props: PropertyBag, m: Mat): Extent {
return extentOfPoints(footprintCorners(type, props, m));
}
export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean {
const { w, h } = footprintOf(type, props);
const local = apply(invert(placementOf(type, m)), point);
return Math.abs(local.x - anchorOffset(type, props, w)) <= w / 2 && Math.abs(local.y) <= h / 2;
}
+126 -51
View File
@@ -1,14 +1,18 @@
/**
* Collider geometry in world units, shared by the editor and the runtime so both agree on what
* "overlaps" means.
* "overlaps" means. A box is oriented: it turns and grows with the body it sits on.
*/
import { angleOf, type Mat, scaleOf } from '../model/matrix';
import type { PropertyBag, Vec2 } from '../model/Properties';
import { defaultsFor } from './PropertySchema';
const DEFAULTS = defaultsFor('collider');
/** Overlaps this shallow count as touching, so two rotated boxes at rest do not jitter. */
const EPSILON = 1e-9;
export type ColliderShape =
| { kind: 'box'; x: number; y: number; w: number; h: number }
| { kind: 'box'; x: number; y: number; w: number; h: number; angle: number }
| { kind: 'circle'; x: number; y: number; r: number };
type Box = Extract<ColliderShape, { kind: 'box' }>;
@@ -17,20 +21,23 @@ type Circle = Extract<ColliderShape, { kind: 'circle' }>;
/** A movement axis in the plane (`z` is draw order, not geometry). */
export type PlaneAxis = 'x' | 'y';
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
/** The shape as it sits in the world: sized by the transform's scale, a box turned by its angle. */
export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
const scale = scaleOf(m);
if (props.shape === 'circle')
return {
kind: 'circle',
x: centre.x,
y: centre.y,
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)),
x: m.tx,
y: m.ty,
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * scale,
};
return {
kind: 'box',
x: centre.x,
y: centre.y,
w: Math.max(0, Number(props.width ?? DEFAULTS.width)),
h: Math.max(0, Number(props.height ?? DEFAULTS.height)),
x: m.tx,
y: m.ty,
w: Math.max(0, Number(props.width ?? DEFAULTS.width)) * scale,
h: Math.max(0, Number(props.height ?? DEFAULTS.height)) * scale,
angle: angleOf(m),
};
}
@@ -39,75 +46,143 @@ export function isTrigger(props: PropertyBag): boolean {
return Boolean(props.isTrigger ?? DEFAULTS.isTrigger);
}
/** The size in the collider's own units, before any transform. */
export function colliderSize(props: PropertyBag): { w: number; h: number } {
const shape = colliderShape(props, { x: 0, y: 0 });
return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h };
if (props.shape === 'circle') {
const d = Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * 2;
return { w: d, h: d };
}
return {
w: Math.max(0, Number(props.width ?? DEFAULTS.width)),
h: Math.max(0, Number(props.height ?? DEFAULTS.height)),
};
}
function axesOf(box: Box): [Vec2, Vec2] {
const cos = Math.cos(box.angle);
const sin = Math.sin(box.angle);
return [
{ x: cos, y: sin },
{ x: -sin, y: cos },
];
}
function dot(a: Vec2, b: Vec2): number {
return a.x * b.x + a.y * b.y;
}
/** A world point in the box's own frame: origin at its centre, axes along its sides. */
function toBoxFrame(box: Box, p: Vec2): Vec2 {
const [ux, uy] = axesOf(box);
const d = { x: p.x - box.x, y: p.y - box.y };
return { x: dot(d, ux), y: dot(d, uy) };
}
/** Half of the box's projection onto the unit direction `n`. */
function reach(box: Box, n: Vec2): number {
const [ux, uy] = axesOf(box);
return (box.w / 2) * Math.abs(dot(ux, n)) + (box.h / 2) * Math.abs(dot(uy, n));
}
/** How wide the shape is along the unit direction `n` – a turned box measures its own slant. */
export function shapeExtent(shape: ColliderShape, n: Vec2): number {
return shape.kind === 'circle' ? shape.r * 2 : reach(shape, n) * 2;
}
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle')
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
const local = toBoxFrame(shape, point);
return Math.abs(local.x) * 2 <= shape.w && Math.abs(local.y) * 2 <= shape.h;
}
/** Distance from a point to an axis-aligned box centred at the origin; 0 inside. */
function distanceToBox(p: Vec2, hw: number, hh: number): number {
const dx = Math.max(Math.abs(p.x) - hw, 0);
const dy = Math.max(Math.abs(p.y) - hh, 0);
return Math.hypot(dx, dy);
}
/** Shapes that merely touch along an edge do not count as overlapping. */
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
if (a.kind === 'box' && b.kind === 'box') {
return Math.abs(a.x - b.x) * 2 < a.w + b.w && Math.abs(a.y - b.y) * 2 < a.h + b.h;
// Separating axes: the sides of both boxes. Apart along any one of them is apart.
const centres = { x: a.x - b.x, y: a.y - b.y };
for (const n of [...axesOf(a), ...axesOf(b)])
if (reach(a, n) + reach(b, n) - Math.abs(dot(centres, n)) <= EPSILON) return false;
return true;
}
if (a.kind === 'circle' && b.kind === 'circle') {
const r = a.r + b.r;
return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 < r * r;
}
const { box, circle } = boxAndCircle(a, b);
// Closest point of the box to the circle centre, then a plain distance check.
const cx = Math.max(box.x - box.w / 2, Math.min(circle.x, box.x + box.w / 2));
const cy = Math.max(box.y - box.h / 2, Math.min(circle.y, box.y + box.h / 2));
return (circle.x - cx) ** 2 + (circle.y - cy) ** 2 < circle.r * circle.r;
return distanceToBox(toBoxFrame(box, circle), box.w / 2, box.h / 2) < circle.r - EPSILON;
}
/**
* The centre distance along `axis` at which the shapes exactly touch, given their current offset on
* the other axis; 0 when they do not overlap on that other axis and so cannot collide along `axis`.
* How far `mover` has to travel along `direction` until it just clears `obstacle`; 0 when they are
* apart already. This is what stops a moving object at a wall: it is pushed back against its
* travel by exactly this much.
*/
export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAxis): number {
const other: PlaneAxis = axis === 'x' ? 'y' : 'x';
const offset = Math.abs(a[other] - b[other]);
if (a.kind === 'box' && b.kind === 'box') {
return offset * 2 < extent(a, other) + extent(b, other) ? (extent(a, axis) + extent(b, axis)) / 2 : 0;
export function exitDistance(mover: ColliderShape, obstacle: ColliderShape, direction: Vec2): number {
const length = Math.hypot(direction.x, direction.y);
if (length === 0 || !shapesOverlap(mover, obstacle)) return 0;
const d = { x: direction.x / length, y: direction.y / length };
if (mover.kind === 'box' && obstacle.kind === 'box') return boxesExit(mover, obstacle, d);
if (mover.kind === 'circle' && obstacle.kind === 'circle') {
const sep = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
const r = mover.r + obstacle.r;
const along = dot(sep, d);
return -along + Math.sqrt(Math.max(0, along * along - dot(sep, sep) + r * r));
}
if (a.kind === 'circle' && b.kind === 'circle') {
const r = a.r + b.r;
return offset < r ? Math.sqrt(r * r - offset * offset) : 0;
}
const { box, circle } = boxAndCircle(a, b);
const halfOther = extent(box, other) / 2;
const halfAxis = extent(box, axis) / 2;
// Circle centre within the box's span on the other axis: the flat side is hit.
if (offset <= halfOther) return halfAxis + circle.r;
// Otherwise the corner is the closest point: a circle-vs-point solution.
const corner = offset - halfOther;
return corner < circle.r ? halfAxis + Math.sqrt(circle.r * circle.r - corner * corner) : 0;
const { box, circle } = boxAndCircle(mover, obstacle);
// Seen from the box: the circle's centre travels along `d` (or against it when the box moves).
const [ux, uy] = axesOf(box);
const sign = mover.kind === 'circle' ? 1 : -1;
const v = { x: sign * dot(d, ux), y: sign * dot(d, uy) };
const start = toBoxFrame(box, circle);
return circleExit(start, v, box.w / 2, box.h / 2, circle.r);
}
/**
* How far `mover` has to be pushed back against its travel direction `dir` (±1) so it just touches
* `obstacle`. Only valid when `mover` did not already overlap before its move.
* The travel along `d` at which the first separating axis opens – for two boxes that is the exact
* moment they stop overlapping. Along an axis the boxes may be moving apart or, when the mover is
* already past the obstacle's centre, deeper in and out the far side.
*/
export function axisCorrection(
mover: ColliderShape,
obstacle: ColliderShape,
axis: PlaneAxis,
dir: 1 | -1,
): number {
if (!shapesOverlap(mover, obstacle)) return 0;
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
function boxesExit(mover: Box, obstacle: Box, d: Vec2): number {
const centres = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
let best = Infinity;
for (const n of [...axesOf(mover), ...axesOf(obstacle)]) {
const speed = dot(d, n);
if (Math.abs(speed) < 1e-12) continue;
const span = reach(mover, n) + reach(obstacle, n);
const offset = dot(centres, n);
const apart = offset === 0 || Math.sign(speed) === Math.sign(offset);
const t = (apart ? span - Math.abs(offset) : span + Math.abs(offset)) / Math.abs(speed);
if (t < best) best = t;
}
return Number.isFinite(best) ? best : 0;
}
/**
* A circle's centre starting at `start` (inside the box grown by `r`) moving along `v` in the box's
* frame: the travel at which it is `r` away from the box. The distance to a box is convex along a
* line, so the crossing is found by bisection – exact for sides and corners alike.
*/
function circleExit(start: Vec2, v: Vec2, hw: number, hh: number, r: number): number {
const outside = (t: number) => distanceToBox({ x: start.x + v.x * t, y: start.y + v.y * t }, hw, hh) - r;
let hi = 1;
for (let i = 0; i < 64 && outside(hi) <= 0; i++) hi *= 2;
let lo = 0;
for (let i = 0; i < 60; i++) {
const mid = (lo + hi) / 2;
if (outside(mid) <= 0) lo = mid;
else hi = mid;
}
return hi;
}
function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } {
return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a };
}
function extent(box: Box, axis: PlaneAxis): number {
return axis === 'x' ? box.w : box.h;
}
+151
View File
@@ -0,0 +1,151 @@
/**
* 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;
}
+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;
+3 -3
View File
@@ -1,4 +1,4 @@
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph';
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import { isPlaceableType } from '../core/model/AssetTypes';
@@ -253,11 +253,11 @@ export class World {
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0)
return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point));
return colliders.some((c) => shapeContains(colliderShape(c.props, worldMatrixOf(c)), point));
return parts.some(
(e) =>
(e.type === 'sprite' || e.type === 'text') &&
footprintContains(e.type, e.props, worldPositionOf(e), point),
footprintContains(e.type, e.props, worldMatrixOf(e), point),
);
}
+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;
+20 -5
View File
@@ -1,12 +1,14 @@
import { isHandleEnabled, worldPositionOf } from '../core/graph';
import { isHandleEnabled, worldTransformOf } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { editorPosition, treeOrder } from '../core/model/hierarchy';
import { treeOrder } from '../core/model/hierarchy';
import { resolvedHolder } from '../core/model/instance';
import type { Mat } from '../core/model/matrix';
import type { PropertyBag } from '../core/model/Properties';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import { editorTransform } from '../core/properties/transform';
import { animationTime } from '../runtime/systems/AnimationSystem';
import type { World } from '../runtime/World';
@@ -19,6 +21,8 @@ export interface DrawItem {
x: number;
y: number;
z: number;
/** The whole world transform the position is part of: how the item is turned, sized, mirrored. */
m: Mat;
props: PropertyBag;
/** Id of the parent item, empty when top-level. */
parentId: string;
@@ -50,11 +54,15 @@ export function collectHolderItems(
const asset = findAsset(project, object.assetId);
if (!asset) continue;
const props = resolveSceneObjectProps(project, asset, object);
const { m, z } = editorTransform(project, view, object);
items.push({
id: object.id,
type: asset.type,
name: object.name || asset.name,
...editorPosition(view, object),
x: m.tx,
y: m.ty,
z,
m,
props,
parentId: object.parentId,
enabled: true,
@@ -75,11 +83,18 @@ export function collectHolderItems(
export function collectWorldItems(world: World): DrawList {
const items = world.entities
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
.map((e) => ({
.map((e) => {
const { m, z } = worldTransformOf(e);
return { e, m, z };
})
.map(({ e, m, z }) => ({
id: e.objectId,
type: e.type,
name: e.name,
...worldPositionOf(e),
x: m.tx,
y: m.ty,
z,
m,
props: e.props,
parentId: e.parent?.objectId ?? '',
enabled: true,
+24 -7
View File
@@ -1,6 +1,7 @@
/** Everything here draws in world units at the item's origin. */
import { type Graphics, Mesh, MeshGeometry, Rectangle, Texture } from 'pixi.js';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
/** Everything here draws in the item's own units at its origin; `placeNode` puts it into the world. */
import { type Container, type Graphics, Mesh, MeshGeometry, Rectangle, Texture } from 'pixi.js';
import { determinant, IDENTITY, type Mat } from '../core/model/matrix';
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, placementOf, textAnchor } from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { DEFAULT_EDGE } from '../core/shader/nodes';
import { Theme } from '../ui';
@@ -11,6 +12,19 @@ import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the grab band along a collider's outline. */
const COLLIDER_GRAB_BAND = 6;
/**
* Gives a Pixi node the transform `m`, its scale multiplied by `w`×`h` (a unit quad becomes the
* sprite's box). A similarity is a rotation and two scales whose signs carry the mirror, which is
* exactly what a node has – no skew needed.
*/
export function placeNode(node: Container, m: Mat, w = 1, h = 1): void {
const sx = Math.hypot(m.a, m.b);
const sy = Math.hypot(m.c, m.d) * (determinant(m) < 0 ? -1 : 1);
node.position.set(m.tx, m.ty);
node.rotation = Math.atan2(m.b, m.a);
node.scale.set(sx * w, sy * h);
}
/**
* One quad for every sprite, sized by the mesh's scale. A frame is a texture of its own
* (`TextureLibrary`), so the 0..1 UVs cover exactly the frame.
@@ -44,8 +58,7 @@ export function syncSpriteMesh(
// the picture is there the colour tints it.
const edge = wornShader(item.props, shaders)?.edge ?? DEFAULT_EDGE;
const texture = item.texture ? textures.get(item.texture, edge) : null;
mesh.position.set(item.x, item.y);
mesh.scale.set(size.w, size.h);
placeNode(mesh, item.m, size.w, size.h);
mesh.texture = texture ?? Texture.WHITE;
mesh.tint = Number(item.props.color ?? 0xffffff);
syncShader(mesh, item.props, shaders);
@@ -57,13 +70,17 @@ export function disposeSpriteMesh(mesh: Mesh): void {
mesh.destroy();
}
/** `scale` is the view scale in pixels per world unit, so strokes stay constant on screen. */
/**
* Draws in the node's own units after `placeNode` put it where the item is. `scale` is the
* on-screen pixels per unit of the node, so strokes stay constant on screen.
*/
export function drawItem(
node: Graphics,
item: DrawItem,
size: { w: number; h: number },
scale: number,
): void {
placeNode(node, placementOf(item.type, item.m));
node.clear();
node.hitArea = null;
switch (item.type) {
@@ -116,7 +133,7 @@ export function drawItem(
case 'collider': {
// Only the outline band is hit-testable, so clicks inside still reach the sprite that the
// collider sits on.
const shape = colliderShape(item.props, { x: 0, y: 0 });
const shape = colliderShape(item.props, IDENTITY);
if (shape.kind === 'circle') node.circle(0, 0, shape.r);
else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h);
const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider;
+67 -21
View File
@@ -3,16 +3,26 @@ import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { findAsset } from '../../src/core/model/Project';
import { transformMatrix, translation } from '../../src/core/model/matrix';
import {
axisCorrection,
axisSeparation,
colliderShape,
exitDistance,
shapeContains,
shapesOverlap,
} from '../../src/core/properties/collider';
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
const box = (x: number, y: number, w: number, h: number, degrees = 0) => ({
kind: 'box' as const,
x,
y,
w,
h,
angle: (degrees * Math.PI) / 180,
});
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
const LEFT = { x: -1, y: 0 };
const UP = { x: 0, y: -1 };
describe('collider geometry', () => {
it('box vs box: overlapping yes, touching edges no', () => {
@@ -21,6 +31,19 @@ describe('collider geometry', () => {
expect(shapesOverlap(box(0, 0, 100, 100), box(0, 150, 100, 100))).toBe(false);
});
it('a turned box reaches where its corners are, not where its upright frame would be', () => {
// A 100×20 bar turned by 45° reaches ≈ 42.4 along x; upright it would reach 50.
expect(shapesOverlap(box(0, 0, 100, 20, 45), box(95, 0, 100, 100))).toBe(false);
expect(shapesOverlap(box(0, 0, 100, 20), box(95, 0, 100, 100))).toBe(true);
// A diamond (square turned by 45°, corners at ±70.7) and a square at (110, 110): their upright
// frames overlap (120.7 > 110 on both axes) but the square's corner (60, 60) lies outside the
// diamond's edge x + y = 70.7.
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(110, 110, 100, 100))).toBe(false);
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(95, 0, 100, 100))).toBe(true);
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 0, y: 45 })).toBe(true);
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 45, y: 0 })).toBe(false);
});
it('circle vs circle and box vs circle (corner case: the closest point is a box corner)', () => {
expect(shapesOverlap(circle(0, 0, 10), circle(15, 0, 10))).toBe(true);
expect(shapesOverlap(circle(0, 0, 10), circle(20, 0, 10))).toBe(false);
@@ -29,28 +52,51 @@ describe('collider geometry', () => {
// Corner at (50,50): a circle at (60,60) with r=10 has distance √200 ≈ 14.1 → no overlap; r=15 → overlap.
expect(shapesOverlap(circle(60, 60, 10), box(0, 0, 100, 100))).toBe(false);
expect(shapesOverlap(circle(60, 60, 15), box(0, 0, 100, 100))).toBe(true);
// The same corner turned to (0, 70.7): the circle at (0, 80) with r=10 just misses it.
expect(shapesOverlap(circle(0, 80, 10), box(0, 0, 100, 100, 45))).toBe(true);
expect(shapesOverlap(circle(0, 81, 10), box(0, 0, 100, 100, 45))).toBe(false);
});
it('builds shapes from properties with defaults', () => {
expect(colliderShape({}, { x: 1, y: 2 })).toEqual(box(1, 2, 100, 100));
expect(colliderShape({ shape: 'circle', radius: 7 }, { x: 0, y: 0 })).toEqual(circle(0, 0, 7));
it('builds shapes from properties with defaults, sized and turned by the transform', () => {
expect(colliderShape({}, translation(1, 2))).toEqual(box(1, 2, 100, 100));
expect(colliderShape({ shape: 'circle', radius: 7 }, translation(0, 0))).toEqual(circle(0, 0, 7));
const turned = colliderShape({ width: 40, height: 20 }, transformMatrix(5, 6, 30, 2, false, false));
expect(turned).toMatchObject({ kind: 'box', x: 5, y: 6, w: 80, h: 40 });
expect(turned.kind === 'box' && (turned.angle * 180) / Math.PI).toBeCloseTo(30);
// A mirror keeps a box a box: the same shape, whichever way its x axis points.
const mirrored = colliderShape({ width: 40, height: 20 }, transformMatrix(0, 0, 0, 1, true, false));
expect(shapeContains(mirrored, { x: 19, y: 9 })).toBe(true);
expect(shapeContains(mirrored, { x: 21, y: 9 })).toBe(false);
expect(colliderShape({ shape: 'circle', radius: 7 }, transformMatrix(0, 0, 0, 3, false, false))).toEqual(
circle(0, 0, 21),
);
});
it('axis separation: the centre distance at which shapes touch along one axis', () => {
expect(axisSeparation(box(0, 0, 100, 60), box(0, 10, 40, 60), 'x')).toBe(70);
expect(axisSeparation(box(0, 0, 100, 60), box(0, 100, 40, 60), 'x')).toBe(0); // apart on y
expect(axisSeparation(circle(0, 0, 10), circle(0, 12, 10), 'x')).toBeCloseTo(16);
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 20, 10), 'x')).toBe(60); // flat side
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 56, 10), 'x')).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 70, 10), 'x')).toBe(0);
});
it('axis correction: how far a mover must back off to just touch', () => {
// Mover moved right into a box: centres 80 apart, need 100 → push back 20.
expect(axisCorrection(box(0, 0, 100, 100), box(80, 0, 100, 100), 'x', 1)).toBe(20);
expect(axisCorrection(box(80, 0, 100, 100), box(0, 0, 100, 100), 'x', -1)).toBe(20);
expect(axisCorrection(box(0, 0, 100, 100), box(120, 0, 100, 100), 'x', 1)).toBe(0);
expect(axisCorrection(box(0, 0, 100, 100), box(0, 90, 100, 100), 'y', 1)).toBe(10);
it('exit distance: how far a mover must travel to just clear an obstacle', () => {
// Mover moved right into a box: centres 80 apart, need 100 → 20 back to the left.
expect(exitDistance(box(80, 0, 100, 100), box(0, 0, 100, 100), { x: 1, y: 0 })).toBe(20);
expect(exitDistance(box(0, 0, 100, 100), box(80, 0, 100, 100), LEFT)).toBe(20);
expect(exitDistance(box(0, 0, 100, 100), box(120, 0, 100, 100), LEFT)).toBe(0);
expect(exitDistance(box(0, 0, 100, 100), box(0, 90, 100, 100), UP)).toBe(10);
expect(exitDistance(box(0, 10, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(70);
expect(exitDistance(box(0, 100, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(0); // apart on y
expect(exitDistance(circle(0, 12, 10), circle(0, 0, 10), LEFT)).toBeCloseTo(16);
expect(exitDistance(circle(0, 20, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(60); // flat side
expect(exitDistance(circle(0, 56, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
expect(exitDistance(circle(0, 70, 10), box(0, 0, 100, 100), LEFT)).toBe(0);
// Moving away diagonally clears a corner sooner than along one axis would.
expect(exitDistance(box(80, 80, 100, 100), box(0, 0, 100, 100), { x: 1, y: 1 })).toBeCloseTo(
20 * Math.SQRT2,
);
// A wall turned by 45°, a box moving left into its slanted face: level with the corner it must
// back off to the corner (70.7 + 10 − 60), higher up the face is only half a unit deep along
// its normal – an upright frame would still demand the 20.7.
expect(exitDistance(box(-60, 0, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
50 * Math.SQRT2 + 10 - 60,
);
expect(exitDistance(box(-60, 30, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
(50 + 10 * Math.SQRT2 - 90 * Math.SQRT1_2) * Math.SQRT2,
);
});
});
+4
View File
@@ -24,6 +24,10 @@ describe('asset commands', () => {
expect(project.activeSceneId).toBe(sceneId);
expect(findAsset(project, spriteId)?.props).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 100,
height: 100,
color: 0xffffff,
+3 -3
View File
@@ -11,12 +11,12 @@ import {
ancestorIdsOf,
descendantsOf,
findObject,
localPositionFor,
type ObjectTree,
treeOrder,
visibleTreeOrder,
} from '../../src/core/model/hierarchy';
import { findScene } from '../../src/core/model/Project';
import { localPositionFor } from '../../src/core/properties/transform';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import {
fakeContext,
@@ -39,7 +39,7 @@ function threeBoxes() {
describe('scene hierarchy', () => {
it('re-parenting keeps the world position and moves children along with the parent', () => {
const { stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes();
const { project, stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes();
stack.execute(new SetParentCommand(sceneId, b, a));
// b stays at world (10,0): local becomes (-90,-100,-1).
expect(object(b).parentId).toBe(a);
@@ -63,7 +63,7 @@ describe('scene hierarchy', () => {
expect(object(b).parentId).toBe('');
expect(object(b).position).toEqual({ x: 10, y: 0, z: 0 });
expect(localPositionFor(sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 });
expect(localPositionFor(project, sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 });
});
it('refuses cycles and self-parenting', () => {
+12
View File
@@ -25,6 +25,10 @@ describe('PropertySchema', () => {
it('provides defaults for every key', () => {
expect(defaultsFor('sprite')).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 100,
height: 100,
color: 0xffffff,
@@ -51,6 +55,10 @@ describe('resolve', () => {
sprite.props = { width: 50, bogus: 1 };
expect(resolveAssetProps(project, sprite)).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 50,
height: 100,
color: 0xffffff,
@@ -65,6 +73,10 @@ describe('resolve', () => {
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
expect(resolveSceneObjectProps(project, sprite, object)).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 100,
height: 100,
color: 0x00ff00,
+4
View File
@@ -61,6 +61,10 @@ describe('serialize', () => {
expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 10,
height: 100,
color: 0xffffff,
+18 -3
View File
@@ -458,7 +458,18 @@ describe('a sprite wearing a shader', () => {
it('has the shader picker and, once one is picked, that shader’s rows', () => {
const f = wearing();
const keys = () => schemaForAsset(f.project, f.sprite()).map((d) => d.key);
expect(keys()).toEqual(['enabled', 'width', 'height', 'color', 'texture', 'shader']);
expect(keys()).toEqual([
'enabled',
'scale',
'rotation',
'mirrorX',
'mirrorY',
'width',
'height',
'color',
'texture',
'shader',
]);
expect(schemaFor('sprite').find((d) => d.key === SHADER_KEY)).toMatchObject({
kind: 'asset',
assetTypes: ['shader'],
@@ -467,6 +478,10 @@ describe('a sprite wearing a shader', () => {
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, f.s.id));
expect(keys()).toEqual([
'enabled',
'scale',
'rotation',
'mirrorX',
'mirrorY',
'width',
'height',
'color',
@@ -479,14 +494,14 @@ describe('a sprite wearing a shader', () => {
expect(assetDeviates(f.project, f.sprite())).toBe(true);
// A dangling id adds no rows and draws nothing – the picker still shows it.
f.sprite().props[SHADER_KEY] = 'gone';
expect(keys()).toHaveLength(6);
expect(keys()).toHaveLength(10);
});
it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => {
const f = wearing();
const id = place(f, f.spriteId, { x: 0, y: 0 });
const object = () => f.object(id);
expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(6);
expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(10);
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id));
expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation');
expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({
+4
View File
@@ -50,6 +50,10 @@ describe('CreateAssetCommand with starting props', () => {
expect(findAsset(project, sprite.asset.id)?.props).toEqual({
enabled: true,
scale: 1,
rotation: 0,
mirrorX: false,
mirrorY: false,
width: 64,
height: 32,
color: 0xffffff,
+184
View File
@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import {
MoveSceneObjectCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { worldPositionOf, worldTransformOf } from '../../src/core/graph';
import type { ContainerAsset } from '../../src/core/model/Asset';
import {
apply,
decompose,
invert,
multiply,
scaleOf,
transformMatrix,
translation,
} from '../../src/core/model/matrix';
import { findAsset } from '../../src/core/model/Project';
import { footprintContains, footprintCorners, footprintExtent } from '../../src/core/properties/bounds';
import {
hasTransformProps,
isCommonProp,
isValidVariableName,
schemaFor,
} from '../../src/core/properties/PropertySchema';
import { carriedTransform, localPositionFor, worldTransform } from '../../src/core/properties/transform';
import { fakeHandle, makeSpriteScene, place, startWorld } from '../helpers';
const close = (p: { x: number; y: number }, x: number, y: number) => {
expect(p.x).toBeCloseTo(x, 9);
expect(p.y).toBeCloseTo(y, 9);
};
describe('matrix', () => {
it('turns clockwise on screen, scales about the origin, mirrors before turning', () => {
close(apply(transformMatrix(0, 0, 90, 1, false, false), { x: 1, y: 0 }), 0, 1);
close(apply(transformMatrix(10, 20, 0, 2, false, false), { x: 1, y: 1 }), 12, 22);
close(apply(transformMatrix(0, 0, 0, 1, true, false), { x: 1, y: 2 }), -1, 2);
close(apply(transformMatrix(0, 0, 90, 1, true, false), { x: 1, y: 0 }), 0, -1);
});
it('a product undoes through its inverse, and a similarity decomposes back into its rows', () => {
const m = multiply(
transformMatrix(5, 6, 30, 2, false, false),
transformMatrix(1, 2, 15, 0.5, true, false),
);
close(apply(invert(m), apply(m, { x: 3, y: 4 })), 3, 4);
expect(scaleOf(m)).toBeCloseTo(1);
expect(decompose(m)).toMatchObject({ mirrorX: true });
expect(decompose(m).rotation).toBeCloseTo(45);
const plain = decompose(transformMatrix(0, 0, 30, 2, false, false));
expect(plain.rotation).toBeCloseTo(30, 9);
expect(plain.scale).toBeCloseTo(2, 9);
expect(plain.mirrorX).toBe(false);
});
});
describe('transform chain', () => {
it('a child sits where its turned, sized parent puts it, in the model and in the world', () => {
const f = makeSpriteScene();
const parent = place(f, f.spriteId, { x: 100, y: 100 });
// `place` takes a world position; the local one is set outright.
const child = place(f, f.spriteId, { x: 0, y: 0 }, parent);
f.stack.execute(new MoveSceneObjectCommand(f.sceneId, child, { x: 50, y: 0, z: 0 }));
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'scale', 2));
// Local (50, 0) → scaled (100, 0) → turned a quarter clockwise (0, 100) → at (100, 200).
close(f.world(child), 100, 200);
const chain = worldTransform(f.project, f.sceneOf(), f.object(child));
expect(scaleOf(chain.m)).toBeCloseTo(2);
expect(decompose(chain.m).rotation).toBeCloseTo(90);
const { world } = startWorld(f);
close(worldPositionOf(world.findByObjectId(child)!), 100, 200);
// Behaviors write the rows like any property: the next read of the chain sees it.
world.findByObjectId(parent)!.props.rotation = 0;
close(worldPositionOf(world.findByObjectId(child)!), 200, 100);
});
it('the rows are reserved names shared by drawn types, absent from cameras and behaviors', () => {
expect(schemaFor('sprite').map((d) => d.key)).toEqual(
expect.arrayContaining(['scale', 'rotation', 'mirrorX', 'mirrorY']),
);
expect(hasTransformProps('camera')).toBe(false);
expect(schemaFor('camera').some((d) => d.key === 'rotation')).toBe(false);
expect(isCommonProp('rotation')).toBe(true);
expect(isValidVariableName('scale')).toBe(false);
// A camera under a turned parent is still placed by the chain; its own props carry no rows.
const parent = fakeHandle({ rotation: 90 });
const camera = { ...fakeHandle({}, parent), x: 10, y: 0 };
close(worldPositionOf(camera), 0, 10);
expect(scaleOf(worldTransformOf(camera).m)).toBeCloseTo(1);
});
it('the local position for a world point goes back through the parent transform', () => {
const f = makeSpriteScene();
const parent = place(f, f.spriteId, { x: 100, y: 100 });
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
const local = localPositionFor(f.project, f.sceneOf(), parent, { x: 100, y: 150, z: 0 });
close(local, 50, 0);
const child = place(f, f.spriteId, { x: 100, y: 150 }, parent);
close(f.world(child), 100, 150);
});
});
describe('carried transform', () => {
it('re-parenting keeps the look: rows absorb the parent chain, nothing is written between upright parents', () => {
const f = makeSpriteScene();
const turned = place(f, f.spriteId, { x: 0, y: 0 });
const upright = place(f, f.spriteId, { x: 300, y: 0 });
const box = place(f, f.spriteId, { x: 100, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, turned, 'rotation', 90));
f.stack.execute(new SetOverrideCommand(f.sceneId, turned, 'scale', 2));
f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'mirrorY', true));
f.stack.execute(new SetParentCommand(f.sceneId, box, upright));
expect(f.object(box).overrides).toEqual({ mirrorY: true });
close(f.world(box), 100, 0);
f.stack.execute(new SetParentCommand(f.sceneId, box, turned));
close(f.world(box), 100, 0);
const world = worldTransform(f.project, f.sceneOf(), f.object(box)).m;
expect(decompose(world)).toMatchObject({ mirrorX: true });
expect(scaleOf(world)).toBeCloseTo(1);
expect(f.object(box).overrides.rotation).toBeCloseTo(-90);
expect(f.object(box).overrides.scale).toBeCloseTo(0.5);
expect(f.object(box).overrides.mirrorY).toBe(true);
f.stack.undo();
expect(f.object(box).overrides).toEqual({ mirrorY: true });
expect(f.object(box).parentId).toBe(upright);
});
it('a mirror in the chain is absorbed as a mirror, whichever way it was written', () => {
const own = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false };
const before = transformMatrix(0, 0, 0, 1, true, false);
const carried = carriedTransform(own, before, translation(0, 0));
expect(carried).toMatchObject({ mirrorX: true, mirrorY: false });
expect(carried.scale).toBeCloseTo(1);
expect(carried.rotation).toBeCloseTo(0);
});
it('grouping under a turned parent keeps every root where it was, rows included', () => {
const f = makeSpriteScene();
const parent = place(f, f.spriteId, { x: 0, y: 0 });
const a = place(f, f.spriteId, { x: 100, y: 0 }, parent);
const b = place(f, f.spriteId, { x: 100, y: 50 }, parent);
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
const worldsBefore = [f.world(a), f.world(b)];
const create = new CreateContainerCommand(f.sceneId, [a, b]);
f.stack.execute(create);
const asset = findAsset(f.project, create.assetId) as ContainerAsset;
// Same parent for both: positions relative to the container in the parent's frame, no rows.
expect(asset.objects.map((o) => o.overrides)).toEqual([{}, {}]);
const contents = f.viewOf().objects.filter((o) => o.parentId === create.objectId);
expect(contents).toHaveLength(2);
const worldsAfter = contents.map((o) => f.world(o.id));
for (const before of worldsBefore)
expect(
worldsAfter.some((w) => Math.abs(w.x - before.x) < 1e-9 && Math.abs(w.y - before.y) < 1e-9),
).toBe(true);
});
});
describe('footprint under a transform', () => {
it('corners, extent and hit test follow the turn, symbols stay upright', () => {
const m = transformMatrix(100, 100, 90, 2, false, false);
const props = { width: 50, height: 10 };
const extent = footprintExtent('sprite', props, m);
expect(extent.left).toBeCloseTo(90);
expect(extent.right).toBeCloseTo(110);
expect(extent.top).toBeCloseTo(50);
expect(extent.bottom).toBeCloseTo(150);
expect(footprintContains('sprite', props, m, { x: 100, y: 145 })).toBe(true);
expect(footprintContains('sprite', props, m, { x: 145, y: 100 })).toBe(false);
// Two world units of padding around a box drawn at scale 2 is one local unit on every side.
const padded = footprintCorners('sprite', props, m, 2);
expect(padded[0]!.y).toBeCloseTo(48);
// A behavior's diamond keeps its size and stays upright: the frame does not scale by 2.
const symbol = footprintExtent('behavior', {}, m);
expect(symbol.right - symbol.left).toBe(28);
});
});
+3 -2
View File
@@ -19,7 +19,8 @@ import {
VARIABLES,
} from '../src/core/graph';
import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset';
import { findObject, worldPosition } from '../src/core/model/hierarchy';
import { findObject } from '../src/core/model/hierarchy';
import { worldPosition } from '../src/core/properties/transform';
import { resolvedHolder } from '../src/core/model/instance';
import type { Position, PropertyBag, Vec2 } from '../src/core/model/Properties';
import { createEmptyProject, findScene, type Project } from '../src/core/model/Project';
@@ -70,7 +71,7 @@ export function makeSpriteScene(spriteName = 'Box'): SceneFixture {
sceneOf,
viewOf,
object,
world: (id) => worldPosition(viewOf(), object(id)),
world: (id) => worldPosition(fixture.project, viewOf(), object(id)),
};
}
+2 -21
View File
@@ -1,4 +1,5 @@
import { colourDistance } from '../lib/harness.mjs';
import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
/**
* Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through
@@ -10,22 +11,6 @@ import { colourDistance } from '../lib/harness.mjs';
const BASE = '#4080ff';
const ADJUST = 'adjust-colors';
/** A 32 px picture, red on the left and green on the right, pasted in for the sampling checks (28l). */
const PASTE_HALVES = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 16, 32);
g.fillStyle = '#3cc85a'; g.fillRect(16, 0, 16, 32);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'halves.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
const LEFT = '#e63c3c';
const RIGHT = '#3cc85a';
export async function shader(t) {
const {
check,
@@ -457,11 +442,7 @@ export async function shader(t) {
// with the far half, Mirror with the near one.
await evaluate(PASTE_HALVES);
await sleep(2500);
const picture = (
await json(
"__gm.state.project.assets.filter((a) => a.type === 'texture' && a.width === 32 && a.height === 32).map((a) => ({ id: a.id, file: a.file }))",
)
)[0];
const picture = await json(FIND_HALVES);
check(
'a two-colour picture is pasted in for the sampling checks',
Boolean(picture),
+266
View File
@@ -0,0 +1,266 @@
import { colourDistance } from '../lib/harness.mjs';
import { FIND_HALVES, HALVES_LEFT, HALVES_RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
import { LANES } from './lib/scaffold.mjs';
/**
* Scale, rotation and mirror (#76): rows of every drawn object, cascading down the hierarchy like
* a position. Read in the pixels of the scene view, in where a child lands under a turned parent,
* in a drag that goes back through the parent's frame, and in a turned collider stopping a mover.
* Everything lives in its own lane, looked at through `__gm.lookAt`: the origin is crowded by
* what the earlier steps left, and symbols draw above objects.
*/
const RED = '#ff0000';
const SPRITE = 'tf-sprite';
const BOX = 'otf';
const CHILD = 'otf-child';
const CAMERA = 'otf-cam';
const RIG = ['otf-wall', 'otf-wallc', 'otf-body', 'otf-bodyc', 'otf-logic'];
const LANE = LANES.transform;
export async function transform(t) {
const {
check,
sleep,
json,
evaluate,
mutate,
select,
drag,
undo,
press,
holdKey,
object,
entity,
stop,
shot,
} = t;
const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`);
const rect = (id) => json(`__gm.objectRect(${JSON.stringify(id)})`);
const screen = (id) => json(`__gm.objectScreen(${JSON.stringify(id)})`);
const override = (id, patch) =>
mutate(
`Object.assign(__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)}).overrides, ${JSON.stringify(patch)})`,
);
const spriteProps = (patch) =>
mutate(
`Object.assign(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SPRITE)}).props, ${JSON.stringify(patch)})`,
);
const selectObject = async (id) => {
await select({ kind: 'object', holderId: 'scene1', objectIds: [id], objectId: id });
await sleep(300);
};
const remove = (ids) =>
mutate(`
const gone = new Set(${JSON.stringify(ids)});
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !gone.has(o.id))`);
await press('Escape');
await evaluate('__gm.state.select(null)');
await evaluate("__gm.showTab('scene')");
await sleep(200);
// 29a. A plain red 100×40 sprite in its lane, and a second camera to read the inspector off;
// a sprite object has the transform rows, a camera object has not.
await mutate(`
const project = __gm.state.project;
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
sprite.id = ${JSON.stringify(SPRITE)};
sprite.name = 'Turned';
sprite.props = { ...sprite.props, width: 100, height: 40, color: 0xff0000, texture: '', shader: '' };
project.assets.push(sprite);
const scene = __gm.state.activeScene;
const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
scene.objects.push(
make(${JSON.stringify(BOX)}, ${JSON.stringify(SPRITE)}, '', { x: 0, y: ${LANE}, z: 960 }),
make(${JSON.stringify(CAMERA)}, 'cam', '', { x: 0, y: ${LANE - 400}, z: 0 }, { enabled: false }),
);
__gm.lookAt(0, ${LANE});
__gm.state.select(null)`);
await sleep(500);
await selectObject(BOX);
const rows = {
title: await t.has('TRANSFORM'),
scale: await field('Scale'),
rotation: await field('Rotation'),
mirrorX: await field('Mirror X'),
mirrorY: await field('Mirror Y'),
};
check(
'a sprite object has a TRANSFORM section with Scale, Rotation, Mirror X and Mirror Y',
rows.title && rows.scale && rows.rotation && rows.mirrorX && rows.mirrorY,
JSON.stringify(rows),
);
await selectObject(CAMERA);
check(
'a camera object has no transform rows',
(await field('Rotation')) === null && (await field('Scale')) === null && !(await t.has('TRANSFORM')),
);
await selectObject(BOX);
// 29b. Pixels: world units from the sprite's centre, at the view's scale.
const base = await rect(BOX);
check(
'the sprite is on screen',
Boolean(base) && base.w > 8,
JSON.stringify({ base, viewport: await json('__gm.viewportRect()') }),
);
if (!base) return;
const s = base.w / 100;
const px = (dx, dy) => json(`__gm.pixelAt(${Math.round(base.x + dx * s)}, ${Math.round(base.y + dy * s)})`);
const isRed = (c) => colourDistance(c, RED) < 12;
const upright = { right: await px(40, 0), below: await px(0, 40) };
check(
'upright: red 40 to the right of the centre, not red 40 below (the box is 40 tall)',
isRed(upright.right) && !isRed(upright.below),
JSON.stringify(upright),
);
// 29c. Rotation 90: the box stands up – its frame swaps sides, the pixels move with it.
await override(BOX, { rotation: 90 });
await sleep(300);
const turned = await rect(BOX);
const turnedPx = { right: await px(40, 0), below: await px(0, 40) };
check(
'rotation 90 turns the box: frame 40 wide and 100 tall, red below the centre, not to the right',
Math.abs(turned.w - 40 * s) < 2 &&
Math.abs(turned.h - 100 * s) < 2 &&
isRed(turnedPx.below) &&
!isRed(turnedPx.right),
JSON.stringify({ turned, turnedPx }),
);
// 29d. Scale 2 on top: twice the frame, red where the upright box would have ended long ago.
await override(BOX, { scale: 2 });
await sleep(300);
const grown = await rect(BOX);
const far = await px(0, 90);
check(
'scale 2 doubles the turned box: frame 80 by 200, red 90 below the centre',
Math.abs(grown.w - 80 * s) < 2 && Math.abs(grown.h - 200 * s) < 2 && isRed(far),
JSON.stringify({ grown, far }),
);
await shot('29-turned-scaled');
// 29e. Mirror, read on a two-colour picture: red left, green right; Mirror X swaps them, Mirror Y
// does not (the halves are side by side).
await override(BOX, { rotation: 0, scale: 1 });
await evaluate(PASTE_HALVES);
await sleep(2500);
const picture = await json(FIND_HALVES);
check('a two-colour picture is pasted in for the mirror checks', Boolean(picture), JSON.stringify(picture));
await evaluate('__gm.state.select(null)');
await spriteProps({ texture: picture?.id ?? '', color: 0xffffff });
await sleep(600);
const plain = { left: await px(-30, 0), right: await px(30, 0) };
check(
'the picture is on: red left, green right',
colourDistance(plain.left, HALVES_LEFT) < 12 && colourDistance(plain.right, HALVES_RIGHT) < 12,
JSON.stringify(plain),
);
await override(BOX, { mirrorX: true });
await sleep(300);
const mirrored = { left: await px(-30, 0), right: await px(30, 0) };
check(
'Mirror X swaps the halves: green left, red right',
colourDistance(mirrored.left, HALVES_RIGHT) < 12 && colourDistance(mirrored.right, HALVES_LEFT) < 12,
JSON.stringify(mirrored),
);
await override(BOX, { mirrorY: true });
await sleep(300);
const both = { left: await px(-30, 0), right: await px(30, 0) };
check(
'Mirror Y on top leaves the halves where Mirror X put them',
colourDistance(both.left, HALVES_RIGHT) < 12 && colourDistance(both.right, HALVES_LEFT) < 12,
JSON.stringify(both),
);
await override(BOX, { mirrorX: false, mirrorY: false });
await spriteProps({ texture: '', color: 0xff0000 });
await sleep(300);
// 29f. A child 100 to the right of its parent: once the parent turns a quarter, the child hangs
// 100 below it – and is drawn turned itself.
await mutate(`
const scene = __gm.state.activeScene;
scene.objects.push({ id: ${JSON.stringify(CHILD)}, assetId: ${JSON.stringify(SPRITE)}, name: '', position: { x: 100, y: 0, z: 1 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: ${JSON.stringify(BOX)}, instanceEdits: {} });`);
await sleep(300);
const flat = { parent: await screen(BOX), child: await screen(CHILD) };
await override(BOX, { rotation: 90 });
await sleep(300);
const hung = { parent: await screen(BOX), child: await screen(CHILD), childRect: await rect(CHILD) };
check(
'a child follows its parent’s turn: 100 to the right becomes 100 below, drawn turned',
Math.abs(flat.child.x - (flat.parent.x + 100 * s)) < 2 &&
Math.abs(flat.child.y - flat.parent.y) < 2 &&
Math.abs(hung.child.x - hung.parent.x) < 2 &&
Math.abs(hung.child.y - (hung.parent.y + 100 * s)) < 2 &&
Math.abs(hung.childRect.w - 40 * s) < 2 &&
Math.abs(hung.childRect.h - 100 * s) < 2,
JSON.stringify({ flat, hung, s }),
);
// 29g. Dragging the child 30 screen pixels down moves it along the parent's x axis – which now
// points down – so its local x grows by 30 / scale and y stays.
await drag(hung.child.x, hung.child.y, hung.child.x, hung.child.y + 30, { steps: 8 });
await sleep(300);
const dragged = await object(CHILD);
check(
'a drag under a turned parent lands in the parent’s frame: local x + 30 / scale, y unchanged',
Math.abs(dragged.position.x - (100 + 30 / s)) <= 1 && Math.abs(dragged.position.y) <= 1,
JSON.stringify({ position: dragged.position, expected: 100 + 30 / s }),
);
await undo();
await sleep(200);
check('undo puts the child back', (await object(CHILD)).position.x === 100);
await selectObject(CHILD);
const gizmos = await json('__gm.gizmos()');
check(
'the turned child is outlined like any selection',
gizmos.some((g) => g.id === CHILD && g.primary && g.alpha === 1),
JSON.stringify(gizmos),
);
await shot('29-turned-child');
// 29h. In the game a turned collider blocks where it really is. A crate body with the fixture's
// Mover walks right into a wall whose 140-wide collider stands on end (60 wide): it parks
// flush at −80 – upright, the wall would stop it at −120 (see 8c).
await mutate(`
const scene = __gm.state.activeScene;
const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
scene.objects.push(
make('otf-wall', 'crate', '', { x: 0, y: ${LANE + 300}, z: 0 }, { rotation: 90 }),
make('otf-wallc', 'collider', 'otf-wall', { x: 0, y: 0, z: 0 }, { width: 140 }),
make('otf-body', 'crate', '', { x: -200, y: ${LANE + 300}, z: 0 }),
make('otf-bodyc', 'collider', 'otf-body', { x: 0, y: 0, z: 0 }),
make('otf-logic', 'mover', 'otf-body', { x: 0, y: 0, z: 0 }),
);
__gm.state.select(null)`);
await press('Enter', { ctrl: true });
await sleep(300);
await holdKey('ArrowRight', 1000);
const body = await entity('otf-body');
await stop();
check(
'a turned collider stops the mover where the turned box is: flush at x = −80',
body && Math.abs(body.x - -80) < 0.5,
`world x=${body?.x}`,
);
await remove(RIG);
// 29i. Cleanup, and the view back where the earlier steps left the scene.
await remove([BOX, CHILD, CAMERA]);
await mutate(`
const project = __gm.state.project;
const gone = new Set([${JSON.stringify(SPRITE)}, ${JSON.stringify(picture?.id ?? '')}]);
project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.lookAt(0, 0)`);
await evaluate('__gm.state.select(null)');
await sleep(200);
check(
'the step leaves nothing behind',
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(SPRITE)})`)) === false &&
(await json(`__gm.state.activeScene.objects.some((o) => o.id.startsWith('otf'))`)) === false &&
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(picture?.id ?? '')})`)) ===
false,
);
}
+2
View File
@@ -31,6 +31,7 @@ import { animation } from './25-animation.mjs';
import { stateMachine } from './26-state-machine.mjs';
import { consolePanel } from './27-console.mjs';
import { shader } from './28-shader.mjs';
import { transform } from './29-transform.mjs';
export const STEPS = [
hierarchy,
@@ -61,4 +62,5 @@ export const STEPS = [
stateMachine,
consolePanel,
shader,
transform,
];
+19
View File
@@ -0,0 +1,19 @@
/** A 32 px picture, red on the left and green on the right, pasted in for pixel checks (28l, 29e). */
export const PASTE_HALVES = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 16, 32);
g.fillStyle = '#3cc85a'; g.fillRect(16, 0, 16, 32);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'halves.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
export const HALVES_LEFT = '#e63c3c';
export const HALVES_RIGHT = '#3cc85a';
/** The pasted picture's asset, once the paste has landed. */
export const FIND_HALVES =
"__gm.state.project.assets.filter((a) => a.type === 'texture' && a.width === 32 && a.height === 32).map((a) => ({ id: a.id, file: a.file }))[0] ?? null";
+1
View File
@@ -16,6 +16,7 @@ export const LANES = {
keyRepeat: -300,
stateMachine: -700,
console: -1100,
transform: -1500,
};
/**