diff --git a/README.md b/README.md index 5ac82978..a3bb4b12 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 4bae243c..777cc3c9 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -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); diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index e949fee2..89a9bbbb 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -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 }; } } diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index af47906e..a1f88519 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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( diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index 44c0c97b..205addd4 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -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; diff --git a/src/core/model/matrix.ts b/src/core/model/matrix.ts new file mode 100644 index 00000000..b6140a39 --- /dev/null +++ b/src/core/model/matrix.ts @@ -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; +} diff --git a/src/core/model/walk.ts b/src/core/model/walk.ts index dcdfde4d..4851df48 100644 --- a/src/core/model/walk.ts +++ b/src/core/model/walk.ts @@ -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(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( +/** 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( 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. */ diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index f9af0d3c..b2a00d1c 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = 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 = { const FULL_SCHEMAS: Record = 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; diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index b42ec73a..9c3933fc 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -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; } diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index 5a2804ed..d01a1083 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -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; @@ -17,20 +21,23 @@ type Circle = Extract; /** 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; -} diff --git a/src/core/properties/transform.ts b/src/core/properties/transform.ts new file mode 100644 index 00000000..5146d959 --- /dev/null +++ b/src/core/properties/transform.ts @@ -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; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 0860a7b2..d14d18dd 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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(); diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 2d31327c..4e26e3cb 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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) { diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index c5acd75c..a43d7cf9 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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 => 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); diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 934c7d88..25fd126a 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -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; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 483d047f..5bb2d64c 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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), ); } diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index c7e824dc..1e6cab48 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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; diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 6d6529de..ec422c98 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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, diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 59d0970e..999702d8 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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; diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts index 52aeeeae..7b2c6530 100644 --- a/tests/core/collider.test.ts +++ b/tests/core/collider.test.ts @@ -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, + ); }); }); diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts index a91476c8..9d64c7de 100644 --- a/tests/core/commands.test.ts +++ b/tests/core/commands.test.ts @@ -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, diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index e338c397..9d287dea 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -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', () => { diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts index 99ee7985..46a7e304 100644 --- a/tests/core/resolve.test.ts +++ b/tests/core/resolve.test.ts @@ -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, diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index e5718e8b..45f2fc5a 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -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, diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index d6586519..8310ab5e 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -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({ diff --git a/tests/core/texture-commands.test.ts b/tests/core/texture-commands.test.ts index 200a35dc..2c8ded4b 100644 --- a/tests/core/texture-commands.test.ts +++ b/tests/core/texture-commands.test.ts @@ -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, diff --git a/tests/core/transform.test.ts b/tests/core/transform.test.ts new file mode 100644 index 00000000..d8a728ec --- /dev/null +++ b/tests/core/transform.test.ts @@ -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); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 790a6203..277fd157 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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)), }; } diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 177124f2..0cbbdee5 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -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), diff --git a/tools/e2e/29-transform.mjs b/tools/e2e/29-transform.mjs new file mode 100644 index 00000000..958ddb3c --- /dev/null +++ b/tools/e2e/29-transform.mjs @@ -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, + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index b57af495..eb2a54d7 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ]; diff --git a/tools/e2e/lib/pictures.mjs b/tools/e2e/lib/pictures.mjs new file mode 100644 index 00000000..0e782dc2 --- /dev/null +++ b/tools/e2e/lib/pictures.mjs @@ -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"; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index ce6c66d7..a3d7af2d 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -16,6 +16,7 @@ export const LANES = { keyRepeat: -300, stateMachine: -700, console: -1100, + transform: -1500, }; /**