diff --git a/CHANGELOG.md b/CHANGELOG.md index 738402d45..ea03487b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Breaking Changes +- `ex.TransformComponent.posChanged$` has been removed, it incurs a steep performance cost - `ex.EventDispatcher` meta events 'subscribe' and 'unsubscribe' were unused and undocumented and have been removed ### Deprecated @@ -14,6 +15,13 @@ This project adheres to [Semantic Versioning](http://semver.org/). - ### Added +- Added new `ex.WatchVector` type that can observe changes to x/y more efficiently than `ex.watch()` +- Added performance improvements + * `ex.Vector.distance` improvement + * `ex.BoundingBox.transform` improvement +- Added ability to clone `ex.Vector.clone(destVector)` into a destination vector +- Added new `ex.Transform` type that is a light weight container for transformation data. This logic has been extracted from the `ex.TransformComponent`, this makes it easy to pass `ex.Transform`s around. Additionally the extracted `ex.Transform` logic has been refactored for performance. +- Added new `ex.AffineMatrix` that is meant for 2D affine transformations, it uses less memory and performs less calculations than the `ex.Matrix` which uses a 4x4 Float32 matrix. - Added new fixed update step to Excalibur! This allows developers to configure a fixed FPS for the update loop. One advantage of setting a fix update is that you will have a more consistent and predictable physics simulation. Excalibur graphics will be interpolated automatically to avoid any jitter in the fixed update. * If the fixed update FPS is greater than the display FPS, excalibur will run multiple updates in a row (at the configured update elapsed) to catch up, for example there could be X updates and 1 draw each clock step. * If the fixed update FPS is less than the display FPS, excalibur will skip updates until it meets the desired FPS, for example there could be no update for 1 draw each clock step. @@ -82,9 +90,12 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Add target element id to `ex.Screen.goFullScreen('some-element-id')` to influence the fullscreen element in the fullscreen browser API. ### Fixed - +- Fixed issue where `ex.BoundingBox` overlap return false due to floating point rounding error causing multiple collisions to be evaluated sometimes - Fixed issue with `ex.EventDispatcher` where removing a handler that didn't already exist would remove another handler by mistake - Fixed issue with `ex.EventDispatcher` where concurrent modifications of the handler list where handlers would or would not fire correctly and throw +- Tweak to the `ex.ArcadeSolver` to produce more stable results by adjusting by an infinitesimal epsilon + - Contacts with overlap smaller than the epsilon are ignored + - Colliders with bounds that overlap smaller than the epsilon are ignored - Fixed issue with `ex.ArcadeSolver` based collisions where colliders were catching on seams when sliding along a floor of multiple colliders. This was by sorting contacts by distance between bodies. ![sorted-collisions](https://user-images.githubusercontent.com/612071/172401390-9e9c3490-3566-47bf-b258-6a7da86a3464.gif) @@ -99,7 +110,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- +- Most places where `ex.Matrix` was used have been switched to `ex.AffineMatrix` +- Most places where `ex.TransformComponent` was used have been switched to `ex.Transform` ## [0.26.0] - 2022-05-20 diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index 99fba4956..5c0f76136 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -84,7 +84,7 @@ game.on('fallbackgraphicscontext', (ctx) => { console.log('fallback triggered', ctx); }); //@ts-ignore For some reason ts doesn't like the /// slash import -const devtool = new ex.DevTools.DevTool(game); +// const devtool = new ex.DevTools.DevTool(game); // var colorblind = new ex.ColorBlindnessPostProcessor(ex.ColorBlindnessMode.Deuteranope); diff --git a/sandbox/tests/side-collision/index.ts b/sandbox/tests/side-collision/index.ts index c266f5e49..1489a2138 100644 --- a/sandbox/tests/side-collision/index.ts +++ b/sandbox/tests/side-collision/index.ts @@ -30,7 +30,7 @@ class Player2 extends ex.Actor { onPostCollision(evt) { if (evt.side === "Left" || evt.side === "Right") { - console.log(evt.side); + console.error(evt.side); } if (evt.side === ex.Side.Bottom) { diff --git a/src/engine/Actor.ts b/src/engine/Actor.ts index dc6601698..4e5991867 100644 --- a/src/engine/Actor.ts +++ b/src/engine/Actor.ts @@ -28,7 +28,7 @@ import { PointerEvents } from './Interfaces/PointerEventHandlers'; import { CollisionType } from './Collision/CollisionType'; import { Entity } from './EntityComponentSystem/Entity'; -import { CoordPlane, TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; +import { TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; import { MotionComponent } from './EntityComponentSystem/Components/MotionComponent'; import { GraphicsComponent } from './Graphics/GraphicsComponent'; import { Rectangle } from './Graphics/Rectangle'; @@ -43,6 +43,7 @@ import { PointerComponent } from './Input/PointerComponent'; import { ActionsComponent } from './Actions/ActionsComponent'; import { Raster } from './Graphics/Raster'; import { Text } from './Graphics/Text'; +import { CoordPlane } from './Math/coord-plane'; /** * Type guard for checking if something is an Actor diff --git a/src/engine/Camera.ts b/src/engine/Camera.ts index 3d3b46d3b..e4c2b2991 100644 --- a/src/engine/Camera.ts +++ b/src/engine/Camera.ts @@ -11,7 +11,7 @@ import { BoundingBox } from './Collision/BoundingBox'; import { Logger } from './Util/Log'; import { ExcaliburGraphicsContext } from './Graphics/Context/ExcaliburGraphicsContext'; import { watchAny } from './Util/Watch'; -import { Matrix } from './Math/matrix'; +import { AffineMatrix } from './Math/affine-matrix'; /** * Interface that describes a custom camera strategy for tracking targets @@ -239,8 +239,8 @@ export class LimitCameraBoundsStrategy implements CameraStrategy { * */ export class Camera extends Class implements CanUpdate, CanInitialize { - public transform: Matrix = Matrix.identity(); - public inverse: Matrix = Matrix.identity(); + public transform: AffineMatrix = AffineMatrix.identity(); + public inverse: AffineMatrix = AffineMatrix.identity(); protected _follow: Actor; @@ -771,7 +771,7 @@ export class Camera extends Class implements CanUpdate, CanInitialize { this.transform.reset(); this.transform.scale(this.zoom, this.zoom); this.transform.translate(cameraPos.x, cameraPos.y); - this.transform.getAffineInverse(this.inverse); + this.transform.inverse(this.inverse); } private _isDoneShaking(): boolean { diff --git a/src/engine/Collision/BodyComponent.ts b/src/engine/Collision/BodyComponent.ts index 758c762c9..bcd2f9106 100644 --- a/src/engine/Collision/BodyComponent.ts +++ b/src/engine/Collision/BodyComponent.ts @@ -10,7 +10,7 @@ import { EventDispatcher } from '../EventDispatcher'; import { createId, Id } from '../Id'; import { clamp } from '../Math/util'; import { ColliderComponent } from './ColliderComponent'; -import { Matrix } from '../Math/matrix'; +import { Transform } from '../Math/transform'; export interface BodyComponentOptions { type?: CollisionType; @@ -35,7 +35,7 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable = createId('body', BodyComponent._ID++); public events = new EventDispatcher(); - private _oldTransform = Matrix.identity(); + private _oldTransform = new Transform(); /** * Indicates whether the old transform has been captured at least once for interpolation @@ -57,6 +57,10 @@ export class BodyComponent extends Component<'ex.body'> implements Clonable implements Clonable implements Clonable implements Clonable implements Clonable { if (this._collider) { this._collider.owner = this.owner; if (tx) { - this._collider.update(tx); + this._collider.update(tx.get()); } } } diff --git a/src/engine/Collision/Colliders/CircleCollider.ts b/src/engine/Collision/Colliders/CircleCollider.ts index b2cfccc25..b93389e51 100644 --- a/src/engine/Collision/Colliders/CircleCollider.ts +++ b/src/engine/Collision/Colliders/CircleCollider.ts @@ -12,8 +12,8 @@ import { Color } from '../../Color'; import { Collider } from './Collider'; import { ClosestLineJumpTable } from './ClosestLineJumpTable'; -import { Transform, TransformComponent } from '../../EntityComponentSystem'; import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext'; +import { Transform } from '../../Math/transform'; export interface CircleColliderOptions { /** @@ -36,7 +36,7 @@ export class CircleCollider extends Collider { public offset: Vector = Vector.Zero; public get worldPos(): Vector { - const tx = this._transform as TransformComponent; + const tx = this._transform; const scale = tx?.globalScale ?? Vector.One; const rotation = tx?.globalRotation ?? 0; const pos = (tx?.globalPos ?? Vector.Zero); @@ -49,7 +49,7 @@ export class CircleCollider extends Collider { * Get the radius of the circle */ public get radius(): number { - const tx = this._transform as TransformComponent; + const tx = this._transform; const scale = tx?.globalScale ?? Vector.One; // This is a trade off, the alternative is retooling circles to support ellipse collisions return this._naturalRadius * Math.min(scale.x, scale.y); @@ -59,7 +59,7 @@ export class CircleCollider extends Collider { * Set the radius of the circle */ public set radius(val: number) { - const tx = this._transform as TransformComponent; + const tx = this._transform; const scale = tx?.globalScale ?? Vector.One; // This is a trade off, the alternative is retooling circles to support ellipse collisions this._naturalRadius = val / Math.min(scale.x, scale.y); @@ -87,7 +87,7 @@ export class CircleCollider extends Collider { * Get the center of the collider in world coordinates */ public get center(): Vector { - const tx = this._transform as TransformComponent; + const tx = this._transform; const scale = tx?.globalScale ?? Vector.One; const rotation = tx?.globalRotation ?? 0; const pos = (tx?.globalPos ?? Vector.Zero); @@ -199,7 +199,7 @@ export class CircleCollider extends Collider { * Get the axis aligned bounding box for the circle collider in world coordinates */ public get bounds(): BoundingBox { - const tx = this._transform as TransformComponent; + const tx = this._transform; const scale = tx?.globalScale ?? Vector.One; const rotation = tx?.globalRotation ?? 0; const pos = (tx?.globalPos ?? Vector.Zero); @@ -257,7 +257,7 @@ export class CircleCollider extends Collider { } public debug(ex: ExcaliburGraphicsContext, color: Color) { - const tx = this._transform as TransformComponent; + const tx = this._transform; const scale = tx?.globalScale ?? Vector.One; const rotation = tx?.globalRotation ?? 0; const pos = (tx?.globalPos ?? Vector.Zero); diff --git a/src/engine/Collision/Colliders/Collider.ts b/src/engine/Collision/Colliders/Collider.ts index 21de6197b..84c4ee6f4 100644 --- a/src/engine/Collision/Colliders/Collider.ts +++ b/src/engine/Collision/Colliders/Collider.ts @@ -6,10 +6,11 @@ import { LineSegment } from '../../Math/line-segment'; import { Vector } from '../../Math/vector'; import { Ray } from '../../Math/ray'; import { Clonable } from '../../Interfaces/Clonable'; -import { Entity, Transform } from '../../EntityComponentSystem'; +import { Entity } from '../../EntityComponentSystem'; import { createId, Id } from '../../Id'; import { EventDispatcher } from '../../EventDispatcher'; import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext'; +import { Transform } from '../../Math/transform'; /** * A collision collider specifies the geometry that can detect when other collision colliders intersect diff --git a/src/engine/Collision/Colliders/CollisionJumpTable.ts b/src/engine/Collision/Colliders/CollisionJumpTable.ts index bb635c22a..e04688ccd 100644 --- a/src/engine/Collision/Colliders/CollisionJumpTable.ts +++ b/src/engine/Collision/Colliders/CollisionJumpTable.ts @@ -200,7 +200,7 @@ export const CollisionJumpTable = { linePoly.owner = edge.owner; const tx = edge.owner?.get(TransformComponent); if (tx) { - linePoly.update(edge.owner.get(TransformComponent)); + linePoly.update(edge.owner.get(TransformComponent).get()); } // Gross hack but poly-poly works well const contact = this.CollidePolygonPolygon(polygon, linePoly); diff --git a/src/engine/Collision/Colliders/CompositeCollider.ts b/src/engine/Collision/Colliders/CompositeCollider.ts index a85d169c1..f536091ea 100644 --- a/src/engine/Collision/Colliders/CompositeCollider.ts +++ b/src/engine/Collision/Colliders/CompositeCollider.ts @@ -1,7 +1,6 @@ import { Util } from '../..'; import { Pair } from '../Detection/Pair'; import { Color } from '../../Color'; -import { Transform } from '../../EntityComponentSystem'; import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext'; import { LineSegment } from '../../Math/line-segment'; import { Projection } from '../../Math/projection'; @@ -12,6 +11,7 @@ import { CollisionContact } from '../Detection/CollisionContact'; import { DynamicTree } from '../Detection/DynamicTree'; import { DynamicTreeCollisionProcessor } from '../Detection/DynamicTreeCollisionProcessor'; import { Collider } from './Collider'; +import { Transform } from '../../Math/transform'; export class CompositeCollider extends Collider { private _transform: Transform; diff --git a/src/engine/Collision/Colliders/EdgeCollider.ts b/src/engine/Collision/Colliders/EdgeCollider.ts index 6f3316b7f..0b083b9d2 100644 --- a/src/engine/Collision/Colliders/EdgeCollider.ts +++ b/src/engine/Collision/Colliders/EdgeCollider.ts @@ -11,8 +11,8 @@ import { Ray } from '../../Math/ray'; import { Color } from '../../Color'; import { Collider } from './Collider'; import { ClosestLineJumpTable } from './ClosestLineJumpTable'; -import { Transform, TransformComponent } from '../../EntityComponentSystem/Components/TransformComponent'; import { ExcaliburGraphicsContext } from '../../Graphics/Context/ExcaliburGraphicsContext'; +import { Transform } from '../../Math/transform'; export interface EdgeColliderOptions { /** @@ -57,7 +57,7 @@ export class EdgeCollider extends Collider { } public get worldPos(): Vector { - const tx = this._transform as TransformComponent; + const tx = this._transform; return tx?.globalPos.add(this.offset) ?? this.offset; } @@ -70,19 +70,19 @@ export class EdgeCollider extends Collider { } private _getBodyPos(): Vector { - const tx = this._transform as TransformComponent; + const tx = this._transform; const bodyPos = tx?.globalPos ?? Vector.Zero; return bodyPos; } private _getTransformedBegin(): Vector { - const tx = this._transform as TransformComponent; + const tx = this._transform; const angle = tx ? tx.globalRotation : 0; return this.begin.rotate(angle).add(this._getBodyPos()); } private _getTransformedEnd(): Vector { - const tx = this._transform as TransformComponent; + const tx = this._transform; const angle = tx ? tx.globalRotation : 0; return this.end.rotate(angle).add(this._getBodyPos()); } diff --git a/src/engine/Collision/Colliders/PolygonCollider.ts b/src/engine/Collision/Colliders/PolygonCollider.ts index 7ce7181fd..f94dd9d18 100644 --- a/src/engine/Collision/Colliders/PolygonCollider.ts +++ b/src/engine/Collision/Colliders/PolygonCollider.ts @@ -7,14 +7,14 @@ import { CollisionContact } from '../Detection/CollisionContact'; import { Projection } from '../../Math/projection'; import { LineSegment } from '../../Math/line-segment'; import { Vector } from '../../Math/vector'; -import { Matrix } from '../../Math/matrix'; +import { AffineMatrix } from '../../Math/affine-matrix'; import { Ray } from '../../Math/ray'; import { ClosestLineJumpTable } from './ClosestLineJumpTable'; -import { Transform, TransformComponent } from '../../EntityComponentSystem'; import { Collider } from './Collider'; import { ExcaliburGraphicsContext, Logger, range } from '../..'; import { CompositeCollider } from './CompositeCollider'; import { Shape } from './Shape'; +import { Transform } from '../../Math/transform'; export interface PolygonColliderOptions { /** @@ -276,7 +276,7 @@ export class PolygonCollider extends Collider { return this.bounds.center; } - private _globalMatrix: Matrix = Matrix.identity(); + private _globalMatrix: AffineMatrix = AffineMatrix.identity(); /** * Calculates the underlying transformation from the body relative space to world space @@ -397,9 +397,8 @@ export class PolygonCollider extends Collider { this._sides.length = 0; this._localSides.length = 0; this._axes.length = 0; - const tx = this._transform as TransformComponent; // This change means an update must be performed in order for geometry to update - const globalMat = tx?.getGlobalMatrix() ?? this._globalMatrix; + const globalMat = transform.matrix ?? this._globalMatrix; globalMat.clone(this._globalMatrix); this._globalMatrix.translate(this.offset.x, this.offset.y); this.getTransformedPoints(); diff --git a/src/engine/Collision/CollisionSystem.ts b/src/engine/Collision/CollisionSystem.ts index ef1aa2218..6e7ad1ff8 100644 --- a/src/engine/Collision/CollisionSystem.ts +++ b/src/engine/Collision/CollisionSystem.ts @@ -51,20 +51,21 @@ export class CollisionSystem extends System { public systemType = SystemType.Update; public priority = -1; - update(_entities: Entity[], elapsedMs: number): void { + update(entities: Entity[], elapsedMs: number): void { let transform: TransformComponent; let motion: MotionComponent; - for (const entity of _entities) { - transform = entity.get(TransformComponent); - motion = entity.get(MotionComponent); + for (let i = 0; i < entities.length; i++) { + transform = entities[i].get(TransformComponent); + motion = entities[i].get(MotionComponent); - const optionalBody = entity.get(BodyComponent); + const optionalBody = entities[i].get(BodyComponent); if (optionalBody?.sleeping) { continue; } diff --git a/src/engine/Collision/Solver/ArcadeSolver.ts b/src/engine/Collision/Solver/ArcadeSolver.ts index d0fc9a6e2..a2e3d4f64 100644 --- a/src/engine/Collision/Solver/ArcadeSolver.ts +++ b/src/engine/Collision/Solver/ArcadeSolver.ts @@ -35,7 +35,7 @@ export class ArcadeSolver implements CollisionSolver { // Solve position first in arcade this.solvePosition(contact); - // Solve velocity first + // Solve velocity second in arcade this.solveVelocity(contact); } @@ -90,13 +90,21 @@ export class ArcadeSolver implements CollisionSolver { } public solvePosition(contact: CollisionContact) { + const epsilon = .0001; // if bounds no longer intersect skip to the next // this removes jitter from overlapping/stacked solid tiles or a wall of solid tiles - if (!contact.colliderA.bounds.overlaps(contact.colliderB.bounds)) { + if (!contact.colliderA.bounds.overlaps(contact.colliderB.bounds, epsilon)) { // Cancel the contact to prevent and solving contact.cancel(); return; } + + if (Math.abs(contact.mtv.x) < epsilon && Math.abs(contact.mtv.y) < epsilon) { + // Cancel near 0 mtv collisions + contact.cancel(); + return; + } + let mtv = contact.mtv; const colliderA = contact.colliderA; const colliderB = contact.colliderB; @@ -116,17 +124,18 @@ export class ArcadeSolver implements CollisionSolver { if (bodyA.collisionType === CollisionType.Active) { bodyA.pos.x -= mtv.x; bodyA.pos.y -= mtv.y; - colliderA.update(bodyA.transform); + colliderA.update(bodyA.transform.get()); } if (bodyB.collisionType === CollisionType.Active) { bodyB.pos.x += mtv.x; bodyB.pos.y += mtv.y; - colliderB.update(bodyB.transform); + colliderB.update(bodyB.transform.get()); } } } + public solveVelocity(contact: CollisionContact) { if (contact.isCanceled()) { return; diff --git a/src/engine/Debug/DebugSystem.ts b/src/engine/Debug/DebugSystem.ts index 7a5c012e4..40f49f466 100644 --- a/src/engine/Debug/DebugSystem.ts +++ b/src/engine/Debug/DebugSystem.ts @@ -3,13 +3,14 @@ import { Scene } from '../Scene'; import { Camera } from '../Camera'; import { MotionComponent } from '../EntityComponentSystem/Components/MotionComponent'; import { ColliderComponent } from '../Collision/ColliderComponent'; -import { CoordPlane, Entity, TransformComponent } from '../EntityComponentSystem'; +import { Entity, TransformComponent } from '../EntityComponentSystem'; import { System, SystemType } from '../EntityComponentSystem/System'; import { ExcaliburGraphicsContext } from '../Graphics/Context/ExcaliburGraphicsContext'; import { vec, Vector } from '../Math/vector'; import { toDegrees } from '../Math/util'; import { BodyComponent, CollisionSystem, CompositeCollider, GraphicsComponent, Particle } from '..'; import { DebugGraphicsComponent } from '../Graphics/DebugGraphicsComponent'; +import { CoordPlane } from '../Math/coord-plane'; export class DebugSystem extends System { public readonly types = ['ex.transform'] as const; @@ -106,7 +107,7 @@ export class DebugSystem extends System { } if (entitySettings.showAll || entitySettings.showId) { - this._graphicsContext.debug.drawText(`id(${id}) ${tx.parent ? 'child of id(' + tx.parent?.owner?.id + ')' : ''}`, cursor); + this._graphicsContext.debug.drawText(`id(${id}) ${entity.parent ? 'child of id(' + entity.parent?.id + ')' : ''}`, cursor); cursor = cursor.add(lineHeight); } diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 48906b6f7..ded2a243d 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -1225,7 +1225,7 @@ O|===|* >________________>\n\ * Draws the entire game * @param delta Number of milliseconds elapsed since the last draw. */ - private _draw(delta: number, _lag: number) { + private _draw(delta: number) { this.graphicsContext.beginDrawLifecycle(); this.graphicsContext.clear(); this._predraw(this.graphicsContext, delta); @@ -1402,9 +1402,8 @@ O|===|* >________________>\n\ this._update(delta); } const afterUpdate = this.clock.now(); - // TODO interpolate offset this.currentFrameLagMs = this._lagMs; - this._draw(delta, this._lagMs); + this._draw(delta); const afterDraw = this.clock.now(); this.stats.currFrame.duration.update = afterUpdate - beforeUpdate; diff --git a/src/engine/EntityComponentSystem/Components/TransformComponent.ts b/src/engine/EntityComponentSystem/Components/TransformComponent.ts index eaf929ee6..4299865f9 100644 --- a/src/engine/EntityComponentSystem/Components/TransformComponent.ts +++ b/src/engine/EntityComponentSystem/Components/TransformComponent.ts @@ -1,218 +1,39 @@ -import { Matrix, MatrixLocations } from '../../Math/matrix'; -import { VectorView } from '../../Math/vector-view'; -import { Vector, vec } from '../../Math/vector'; +import { Vector } from '../../Math/vector'; +import { CoordPlane } from '../../Math/coord-plane'; +import { Transform } from '../../Math/transform'; import { Component } from '../Component'; +import { Entity } from '../Entity'; import { Observable } from '../../Util/Observable'; -import { watch } from '../../Util/Watch'; -export interface Transform { - /** - * The [[CoordPlane|coordinate plane]] for this transform for the entity. - */ - coordPlane: CoordPlane; - - /** - * The current position of the entity in world space or in screen space depending on the the [[CoordPlane|coordinate plane]]. - * - * If the entity has a parent this position is relative to the parent entity. - */ - pos: Vector; - - /** - * The z-index ordering of the entity, a higher values are drawn on top of lower values. - * For example z=99 would be drawn on top of z=0. - */ - z: number; - - /** - * The rotation of the entity in radians. For example `Math.PI` radians is the same as 180 degrees. - * - * If the entity has a parent this rotation is relative to the parent. - */ - rotation: number; - - /** - * The scale of the entity. If the entity has a parent this scale is relative to the parent. - */ - scale: Vector; -} - -const createPosView = (matrix: Matrix) => { - const source = matrix; - return new VectorView({ - setX: (x) => { - source.data[MatrixLocations.X] = x; - }, - setY: (y) => { - source.data[MatrixLocations.Y] = y; - }, - getX: () => { - return source.data[MatrixLocations.X]; - }, - getY: () => { - return source.data[MatrixLocations.Y]; - } - }); -}; - -const createScaleView = (matrix: Matrix) => { - const source = matrix; - return new VectorView({ - setX: (x) => { - source.setScaleX(x); - }, - setY: (y) => { - source.setScaleY(y); - }, - getX: () => { - return source.getScaleX(); - }, - getY: () => { - return source.getScaleY(); - } - }); -}; - -/** - * Enum representing the coordinate plane for the position 2D vector in the [[TransformComponent]] - */ -export enum CoordPlane { - /** - * The world coordinate plane (default) represents world space, any entities drawn with world - * space move when the camera moves. - */ - World = 'world', - /** - * The screen coordinate plane represents screen space, entities drawn in screen space are pinned - * to screen coordinates ignoring the camera. - */ - Screen = 'screen' -} -export class TransformComponent extends Component<'ex.transform'> implements Transform { +export class TransformComponent extends Component<'ex.transform'> { public readonly type = 'ex.transform'; - private _dirty = false; - - public readonly matrix = Matrix.identity().translate(0, 0).rotate(0).scale(1, 1); - private _position = watch(createPosView(this.matrix), (v) => { - this.posChanged$.notifyAll(v); - }); - private _rotation = 0; - private _scale = createScaleView(this.matrix); - - private _recalculate() { - this._rotation = this.matrix.getRotation(); - this._dirty = false; - } - - public getGlobalMatrix(): Matrix { - if (!this.parent) { - return this.matrix; - } else { - return this.parent.getGlobalMatrix().multiply(this.matrix); - } - } - - public getGlobalTransform(): Transform { - return { - pos: this.globalPos, - scale: this.globalScale, - rotation: this.globalRotation, - z: this.z, - coordPlane: this.coordPlane - }; - } - - public get parent(): TransformComponent | null { - return this?.owner?.parent?.get(TransformComponent); - } - - /** - * The [[CoordPlane|coordinate plane|]] for this transform for the entity. - */ - public coordPlane = CoordPlane.World; - - /** - * Observable that notifies when the position changes - */ - public posChanged$ = new Observable(); - /** - * The current position of the entity in world space or in screen space depending on the the [[CoordPlane|coordinate plane]]. - * - * If a parent entity exists coordinates are local to the parent. - */ - public get pos(): Vector { - if (this._dirty) { - this._recalculate(); - } - return this._position; + private _transform = new Transform(); + public get() { + return this._transform; } - public set pos(val: Vector) { - const oldPos = this.matrix.getPosition(); - this.matrix.setPosition(val.x, val.y); - this._dirty = true; - if (!oldPos.equals(val)) { - this.posChanged$.notifyAll(this._position); + private _addChildTransform = (child: Entity) => { + const childTxComponent = child.get(TransformComponent); + if (childTxComponent) { + childTxComponent._transform.parent = this._transform; } - } - - // Dirty flag check up the chain - public get dirty(): boolean { - if (this?.owner?.parent) { - const parent = this.parent; - return parent.dirty || this._dirty; + }; + onAdd(owner: Entity): void { + for (const child of owner.children) { + this._addChildTransform(child); } - return this._dirty; - } - - /** - * The current world position calculated - */ - public get globalPos(): Vector { - const source = this.getGlobalMatrix(); - return new VectorView({ - getX: () => source.data[MatrixLocations.X], - getY: () => source.data[MatrixLocations.Y], - setX: (x) => { - const oldX = this.matrix.data[MatrixLocations.X]; - if (this.parent) { - const { x: newX } = this.parent?.getGlobalMatrix().getAffineInverse().multiply(vec(x, source.data[MatrixLocations.Y])); - this.matrix.data[MatrixLocations.X] = newX; - } else { - this.matrix.data[MatrixLocations.X] = x; - } - if (oldX !== this.matrix.data[MatrixLocations.X]) { - this.posChanged$.notifyAll(this._position); - } - }, - setY: (y) => { - const oldY = this.matrix.data[MatrixLocations.Y]; - if (this.parent) { - const { y: newY } = this.parent?.getGlobalMatrix().getAffineInverse().multiply(vec(source.data[MatrixLocations.X], y)); - this.matrix.data[MatrixLocations.Y] = newY; - } else { - this.matrix.data[MatrixLocations.Y] = y; - } - if (oldY !== this.matrix.data[MatrixLocations.Y]) { - this.posChanged$.notifyAll(this._position); - } + owner.childrenAdded$.subscribe(child => this._addChildTransform(child)); + owner.childrenRemoved$.subscribe(child => { + const childTxComponent = child.get(TransformComponent); + if (childTxComponent) { + childTxComponent._transform.parent = null; } }); } - - public set globalPos(val: Vector) { - const oldPos = this.pos; - const parentTransform = this.parent; - if (!parentTransform) { - this.pos = val; - } else { - this.pos = parentTransform.getGlobalMatrix().getAffineInverse().multiply(val); - } - if (!oldPos.equals(val)) { - this.posChanged$.notifyAll(this.pos); - } + onRemove(_previousOwner: Entity): void { + this._transform.parent = null; } /** @@ -238,94 +59,57 @@ export class TransformComponent extends Component<'ex.transform'> implements Tra } /** - * The rotation of the entity in radians. For example `Math.PI` radians is the same as 180 degrees. + * The [[CoordPlane|coordinate plane|]] for this transform for the entity. */ - public get rotation(): number { - if (this._dirty) { - this._recalculate(); - } - return this._rotation; - } + public coordPlane = CoordPlane.World; - public set rotation(val: number) { - this.matrix.setRotation(val); - this._dirty = true; + get pos() { + return this._transform.pos; } - - public get globalRotation(): number { - return this.getGlobalMatrix().getRotation(); + set pos(v: Vector) { + this._transform.pos = v; } - public set globalRotation(val: number) { - const parentTransform = this.parent; - if (!parentTransform) { - this.rotation = val; - } else { - this.rotation = val - parentTransform.globalRotation; - } + get globalPos() { + return this._transform.globalPos; + } + set globalPos(v: Vector) { + this._transform.globalPos = v; } - /** - * The scale of the entity. - */ - public get scale(): Vector { - if (this._dirty) { - this._recalculate(); - } - return this._scale; + get rotation() { + return this._transform.rotation; + } + set rotation(rotation) { + this._transform.rotation = rotation; } - public set scale(val: Vector) { - this.matrix.setScale(val); - this._dirty = true; + get globalRotation() { + return this._transform.globalRotation; + } + set globalRotation(rotation) { + this._transform.globalRotation = rotation; } - public get globalScale(): Vector { - const source = this.getGlobalMatrix(); - return new VectorView({ - getX: () => source.getScaleX(), - getY: () => source.getScaleY(), - setX: (x) => { - if (this.parent) { - const globalScaleX = this.parent.globalScale.x; - this.matrix.setScaleX(x / globalScaleX); - } else { - this.matrix.setScaleX(x); - } - }, - setY: (y) => { - if (this.parent) { - const globalScaleY = this.parent.globalScale.y; - this.matrix.setScaleY(y / globalScaleY); - } else { - this.matrix.setScaleY(y); - } - } - }); + get scale() { + return this._transform.scale; + } + set scale(v: Vector) { + this._transform.scale = v; } - public set globalScale(val: Vector) { - const parentTransform = this.parent; - if (!parentTransform) { - this.scale = val; - } else { - this.scale = vec(val.x / parentTransform.globalScale.x, val.y / parentTransform.globalScale.y); - } + get globalScale() { + return this._transform.globalScale; + } + set globalScale(v: Vector) { + this._transform.globalScale = v; } - /** - * Apply the transform to a point - * @param point - */ - public apply(point: Vector): Vector { - return this.matrix.multiply(point); + applyInverse(v: Vector) { + return this._transform.applyInverse(v); } - /** - * Apply the inverse transform to a point - * @param point - */ - public applyInverse(point: Vector): Vector { - return this.matrix.getAffineInverse().multiply(point); + apply(v: Vector) { + return this._transform.apply(v); } -} +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts index 2608a4494..6fac4e227 100644 --- a/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts @@ -1,8 +1,8 @@ import { Vector } from '../../Math/vector'; -import { Matrix } from '../../Math/matrix'; import { Color } from '../../Color'; import { ScreenDimension } from '../../Screen'; import { PostProcessor } from '../PostProcessor/PostProcessor'; +import { AffineMatrix } from '../../Math/affine-matrix'; export type HTMLImageSource = HTMLImageElement | HTMLCanvasElement; @@ -120,13 +120,13 @@ export interface ExcaliburGraphicsContext { /** * Gets the current transform */ - getTransform(): Matrix; + getTransform(): AffineMatrix; /** * Multiplies the current transform by a matrix * @param m */ - multiply(m: Matrix): void; + multiply(m: AffineMatrix): void; /** * Update the context with the current viewport dimensions (used in resizing) diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts index 71cd491e4..4b36d3128 100644 --- a/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts @@ -11,9 +11,9 @@ import { Color } from '../../Color'; import { StateStack } from './state-stack'; import { GraphicsDiagnostics } from '../GraphicsDiagnostics'; import { DebugText } from './debug-text'; -import { Matrix } from '../../Math/matrix'; import { ScreenDimension } from '../../Screen'; import { PostProcessor } from '../PostProcessor/PostProcessor'; +import { AffineMatrix } from '../../Math/affine-matrix'; class ExcaliburGraphicsContext2DCanvasDebug implements DebugDraw { private _debugText = new DebugText(); @@ -280,11 +280,11 @@ export class ExcaliburGraphicsContext2DCanvas implements ExcaliburGraphicsContex this.__ctx.scale(x, y); } - public getTransform(): Matrix { + public getTransform(): AffineMatrix { throw new Error('Not implemented'); } - public multiply(_m: Matrix): void { + public multiply(_m: AffineMatrix): void { this.__ctx.setTransform(this.__ctx.getTransform().multiply(_m.toDOMMatrix())); } diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts index 52e4a382e..2eda267da 100644 --- a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts @@ -31,6 +31,7 @@ import { RectangleRenderer } from './rectangle-renderer/rectangle-renderer'; import { CircleRenderer } from './circle-renderer/circle-renderer'; import { Pool } from '../../Util/Pool'; import { DrawCall } from './draw-call'; +import { AffineMatrix } from '../../Math/affine-matrix'; class ExcaliburGraphicsContextWebGLDebug implements DebugDraw { private _debugText = new DebugText(); @@ -95,7 +96,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext { instance.renderer = undefined; instance.args = undefined; return instance; - }); + }, 4000); private _drawCalls: DrawCall[] = []; // Main render target @@ -192,6 +193,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext { this.backgroundColor = backgroundColor ?? this.backgroundColor; this.useDrawSorting = useDrawSorting ?? this.useDrawSorting; this._drawCallPool.disableWarnings = true; + this._drawCallPool.preallocate(); this._init(); } @@ -314,7 +316,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext { } public resetTransform(): void { - this._transform.current = Matrix.identity(); + this._transform.current = AffineMatrix.identity(); } public updateViewport(resolution: ScreenDimension): void { @@ -406,16 +408,16 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext { this._transform.scale(x, y); } - public transform(matrix: Matrix) { + public transform(matrix: AffineMatrix) { this._transform.current = matrix; } - public getTransform(): Matrix { + public getTransform(): AffineMatrix { return this._transform.current; } - public multiply(m: Matrix) { - this._transform.current = this._transform.current.multiply(m); + public multiply(m: AffineMatrix) { + this._transform.current.multiply(m, this._transform.current); } public addPostProcessor(postprocessor: PostProcessor) { diff --git a/src/engine/Graphics/Context/draw-call.ts b/src/engine/Graphics/Context/draw-call.ts index 5c7c5e47c..77ed120f2 100644 --- a/src/engine/Graphics/Context/draw-call.ts +++ b/src/engine/Graphics/Context/draw-call.ts @@ -1,12 +1,12 @@ +import { AffineMatrix } from '../../Math/affine-matrix'; import { Color } from '../../Color'; -import { Matrix } from '../../Math/matrix'; import { ExcaliburGraphicsContextState } from './ExcaliburGraphicsContext'; export class DrawCall { public z: number = 0; public priority: number = 0; public renderer: string; - public transform: Matrix = Matrix.identity(); + public transform: AffineMatrix = AffineMatrix.identity(); public state: ExcaliburGraphicsContextState = { z: 0, opacity: 1, diff --git a/src/engine/Graphics/Context/transform-stack.ts b/src/engine/Graphics/Context/transform-stack.ts index 420c0cd4d..7137ba8d5 100644 --- a/src/engine/Graphics/Context/transform-stack.ts +++ b/src/engine/Graphics/Context/transform-stack.ts @@ -1,8 +1,8 @@ -import { Matrix } from '../../Math/matrix'; +import { AffineMatrix } from '../../Math/affine-matrix'; export class TransformStack { - private _transforms: Matrix[] = []; - private _currentTransform: Matrix = Matrix.identity(); + private _transforms: AffineMatrix[] = []; + private _currentTransform: AffineMatrix = AffineMatrix.identity(); public save(): void { this._transforms.push(this._currentTransform); @@ -13,23 +13,23 @@ export class TransformStack { this._currentTransform = this._transforms.pop(); } - public translate(x: number, y: number): Matrix { + public translate(x: number, y: number): AffineMatrix { return this._currentTransform.translate(x, y); } - public rotate(angle: number): Matrix { + public rotate(angle: number): AffineMatrix { return this._currentTransform.rotate(angle); } - public scale(x: number, y: number): Matrix { + public scale(x: number, y: number): AffineMatrix { return this._currentTransform.scale(x, y); } - public set current(matrix: Matrix) { + public set current(matrix: AffineMatrix) { this._currentTransform = matrix; } - public get current(): Matrix { + public get current(): AffineMatrix { return this._currentTransform; } } diff --git a/src/engine/Graphics/Graphic.ts b/src/engine/Graphics/Graphic.ts index 28ac9ad81..412e66218 100644 --- a/src/engine/Graphics/Graphic.ts +++ b/src/engine/Graphics/Graphic.ts @@ -1,8 +1,9 @@ import { Vector, vec } from '../Math/vector'; import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; import { BoundingBox } from '../Collision/BoundingBox'; -import { Color, Matrix } from '..'; +import { Color } from '../Color'; import { watch } from '../Util/Watch'; +import { AffineMatrix } from '../Math/affine-matrix'; export interface GraphicOptions { /** @@ -55,9 +56,9 @@ export abstract class Graphic { private static _ID: number = 0; readonly id = Graphic._ID++; + public transform: AffineMatrix = AffineMatrix.identity(); public tint: Color = null; - public transform: Matrix = Matrix.identity(); private _transformStale = true; public isStale() { return this._transformStale; @@ -246,7 +247,7 @@ export abstract class Graphic { } } - protected _rotate(ex: ExcaliburGraphicsContext | Matrix) { + protected _rotate(ex: ExcaliburGraphicsContext | AffineMatrix) { const scaleDirX = this.scale.x > 0 ? 1 : -1; const scaleDirY = this.scale.y > 0 ? 1 : -1; const origin = this.origin ?? vec(this.width / 2, this.height / 2); @@ -257,7 +258,7 @@ export abstract class Graphic { ex.translate(-origin.x, -origin.y); } - protected _flip(ex: ExcaliburGraphicsContext | Matrix) { + protected _flip(ex: ExcaliburGraphicsContext | AffineMatrix) { if (this.flipHorizontal) { ex.translate(this.width / this.scale.x, 0); ex.scale(-1, 1); diff --git a/src/engine/Graphics/GraphicsSystem.ts b/src/engine/Graphics/GraphicsSystem.ts index 97d0255a3..d751d0859 100644 --- a/src/engine/Graphics/GraphicsSystem.ts +++ b/src/engine/Graphics/GraphicsSystem.ts @@ -2,7 +2,7 @@ import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; import { Scene } from '../Scene'; import { GraphicsComponent } from './GraphicsComponent'; import { vec, Vector } from '../Math/vector'; -import { CoordPlane, TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; +import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; import { Entity } from '../EntityComponentSystem/Entity'; import { Camera } from '../Camera'; import { AddedEntity, isAddedSystemEntity, RemovedEntity, System, SystemType } from '../EntityComponentSystem'; @@ -10,6 +10,7 @@ import { Engine } from '../Engine'; import { GraphicsGroup } from '.'; import { Particle } from '../Particles'; import { ParallaxComponent } from './ParallaxComponent'; +import { CoordPlane } from '../Math/coord-plane'; import { BodyComponent } from '../Collision/BodyComponent'; export class GraphicsSystem extends System { diff --git a/src/engine/Graphics/OffscreenSystem.ts b/src/engine/Graphics/OffscreenSystem.ts index 6cc975a58..0edb3e93d 100644 --- a/src/engine/Graphics/OffscreenSystem.ts +++ b/src/engine/Graphics/OffscreenSystem.ts @@ -2,11 +2,12 @@ import { GraphicsComponent } from './GraphicsComponent'; import { EnterViewPortEvent, ExitViewPortEvent } from '../Events'; import { Scene } from '../Scene'; import { Entity } from '../EntityComponentSystem/Entity'; -import { TransformComponent, CoordPlane } from '../EntityComponentSystem/Components/TransformComponent'; +import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; import { Camera } from '../Camera'; import { System, SystemType } from '../EntityComponentSystem/System'; import { ParallaxComponent } from './ParallaxComponent'; import { Vector } from '../Math/vector'; +import { CoordPlane } from '../Math/coord-plane'; export class OffscreenSystem extends System { public readonly types = ['ex.transform', 'ex.graphics'] as const; @@ -57,7 +58,7 @@ export class OffscreenSystem extends System // Check graphics contains pointer graphics = entity.get(GraphicsComponent); if (graphics && (pointer.useGraphicsBounds || this.overrideUseGraphicsBounds)) { - const graphicBounds = graphics.localBounds.transform(transform.getGlobalMatrix()); + const graphicBounds = graphics.localBounds.transform(transform.get().matrix); for (const [pointerId, pos] of this._receiver.currentFramePointerCoords.entries()) { if (graphicBounds.contains(transform.coordPlane === CoordPlane.World ? pos.worldPos : pos.screenPos)) { this.addPointerToEntity(entity, pointerId); diff --git a/src/engine/Math/Index.ts b/src/engine/Math/Index.ts index 981764c2b..b76fd2b8b 100644 --- a/src/engine/Math/Index.ts +++ b/src/engine/Math/Index.ts @@ -1,6 +1,9 @@ export * from './vector'; export * from './vector-view'; export * from './matrix'; +export * from './affine-matrix'; +export * from './transform'; +export * from './coord-plane'; export * from './Random'; export * from './global-coordinates'; export * from './line-segment'; diff --git a/src/engine/Math/affine-matrix.ts b/src/engine/Math/affine-matrix.ts new file mode 100644 index 000000000..8cc8475d8 --- /dev/null +++ b/src/engine/Math/affine-matrix.ts @@ -0,0 +1,409 @@ +import { Matrix } from './matrix'; +import { canonicalizeAngle, sign } from './util'; +import { vec, Vector } from './vector'; + + +export class AffineMatrix { + /** + * | | | | + * | ------- | ------- | -------- | + * | data[0] | data[2] | data[4] | + * | data[1] | data[3] | data[5] | + * | 0 | 0 | 1 | + */ + public data = new Float64Array(6); + + /** + * Converts the current matrix into a DOMMatrix + * + * This is useful when working with the browser Canvas context + * @returns {DOMMatrix} DOMMatrix + */ + public toDOMMatrix(): DOMMatrix { + return new DOMMatrix([...this.data]); + } + + public static identity(): AffineMatrix { + const mat = new AffineMatrix(); + mat.data[0] = 1; + mat.data[1] = 0; + + mat.data[2] = 0; + mat.data[3] = 1; + + mat.data[4] = 0; + mat.data[5] = 0; + return mat; + } + + /** + * Creates a brand new translation matrix at the specified 3d point + * @param x + * @param y + */ + public static translation(x: number, y: number): AffineMatrix { + const mat = AffineMatrix.identity(); + mat.data[4] = x; + mat.data[5] = y; + return mat; + } + + /** + * Creates a brand new scaling matrix with the specified scaling factor + * @param sx + * @param sy + */ + public static scale(sx: number, sy: number): AffineMatrix { + const mat = AffineMatrix.identity(); + mat.data[0] = sx; + mat.data[3] = sy; + mat._scale[0] = sx; + mat._scale[1] = sy; + return mat; + } + + /** + * Creates a brand new rotation matrix with the specified angle + * @param angleRadians + */ + public static rotation(angleRadians: number): AffineMatrix { + const mat = AffineMatrix.identity(); + mat.data[0] = Math.cos(angleRadians); + mat.data[1] = Math.sin(angleRadians); + mat.data[2] = -Math.sin(angleRadians); + mat.data[3] = Math.cos(angleRadians); + return mat; + } + + public setPosition(x: number, y: number) { + this.data[4] = x; + this.data[5] = y; + } + + public getPosition(): Vector { + return vec(this.data[4], this.data[5]); + } + + /** + * Applies rotation to the current matrix mutating it + * @param angle in Radians + */ + rotate(angle: number) { + const a11 = this.data[0]; + const a21 = this.data[1]; + + const a12 = this.data[2]; + const a22 = this.data[3]; + + const sine = Math.sin(angle); + const cosine = Math.cos(angle); + + this.data[0] = cosine * a11 + sine * a12; + this.data[1] = cosine * a21 + sine * a22; + + this.data[2] = cosine * a12 - sine * a11; + this.data[3] = cosine * a22 - sine * a21; + + return this; + } + + /** + * Applies translation to the current matrix mutating it + * @param x + * @param y + */ + translate(x: number, y: number) { + const a11 = this.data[0]; + const a21 = this.data[1]; + // const a31 = 0; + + const a12 = this.data[2]; + const a22 = this.data[3]; + // const a32 = 0; + + const a13 = this.data[4]; + const a23 = this.data[5]; + // const a33 = 1; + + // Doesn't change z + this.data[4] = a11 * x + a12 * y + a13; + this.data[5] = a21 * x + a22 * y + a23; + + return this; + } + + /** + * Applies scaling to the current matrix mutating it + * @param x + * @param y + */ + scale(x: number, y: number) { + const a11 = this.data[0]; + const a21 = this.data[1]; + + const a12 = this.data[2]; + const a22 = this.data[3]; + + this.data[0] = a11 * x; + this.data[1] = a21 * x; + + this.data[2] = a12 * y; + this.data[3] = a22 * y; + + this._scale[0] = x; + this._scale[1] = y; + return this; + } + + public determinant() { + return this.data[0] * this.data[3] - this.data[1] * this.data[2]; + } + + /** + * Return the affine inverse, optionally store it in a target matrix. + * + * It's recommended you call .reset() the target unless you know what you're doing + * @param target + */ + public inverse(target?: AffineMatrix): AffineMatrix { + // See http://negativeprobability.blogspot.com/2011/11/affine-transformations-and-their.html + // See https://www.mathsisfun.com/algebra/matrix-inverse.html + // Since we are actually only doing 2D transformations we can use this hack + // We don't actually use the 3rd or 4th dimension + + const det = this.determinant(); + const inverseDet = 1 / det; // TODO zero check + const a = this.data[0]; + const b = this.data[2]; + const c = this.data[1]; + const d = this.data[3]; + + const m = target || AffineMatrix.identity(); + // inverts rotation and scale + m.data[0] = d * inverseDet; + m.data[1] = -c * inverseDet; + m.data[2] = -b * inverseDet; + m.data[3] = a * inverseDet; + + const tx = this.data[4]; + const ty = this.data[5]; + // invert translation + // transform translation into the matrix basis created by rot/scale + m.data[4] = -(tx * m.data[0] + ty * m.data[2]); + m.data[5] = -(tx * m.data[1] + ty * m.data[3]); + + return m; + } + + /** + * Multiply the current matrix by a vector producing a new vector + * @param vector + * @param dest + */ + multiply(vector: Vector, dest?: Vector): Vector; + /** + * Multiply the current matrix by another matrix producing a new matrix + * @param matrix + * @param dest + */ + multiply(matrix: AffineMatrix, dest?: AffineMatrix): AffineMatrix; + multiply(vectorOrMatrix: Vector | AffineMatrix, dest?: Vector | AffineMatrix): Vector | AffineMatrix { + if (vectorOrMatrix instanceof Vector) { + const result = (dest as Vector) || new Vector(0, 0); + const vector = vectorOrMatrix; + // these shenanigans are to allow dest and vector to be the same instance + const resultX = vector.x * this.data[0] + vector.y * this.data[2] + this.data[4]; + const resultY = vector.x * this.data[1] + vector.y * this.data[3] + this.data[5]; + + result.x = resultX; + result.y = resultY; + return result; + } else { + const result = (dest as AffineMatrix) || new AffineMatrix(); + const other = vectorOrMatrix; + const a11 = this.data[0]; + const a21 = this.data[1]; + // const a31 = 0; + + const a12 = this.data[2]; + const a22 = this.data[3]; + // const a32 = 0; + + const a13 = this.data[4]; + const a23 = this.data[5]; + // const a33 = 1; + + const b11 = other.data[0]; + const b21 = other.data[1]; + // const b31 = 0; + + const b12 = other.data[2]; + const b22 = other.data[3]; + // const b32 = 0; + + const b13 = other.data[4]; + const b23 = other.data[5]; + // const b33 = 1; + + + result.data[0] = a11 * b11 + a12 * b21;// + a13 * b31; // zero + result.data[1] = a21 * b11 + a22 * b21;// + a23 * b31; // zero + + result.data[2] = a11 * b12 + a12 * b22;// + a13 * b32; // zero + result.data[3] = a21 * b12 + a22 * b22;// + a23 * b32; // zero + + result.data[4] = a11 * b13 + a12 * b23 + a13;// * b33; // one + result.data[5] = a21 * b13 + a22 * b23 + a23;// * b33; // one + + const s = this.getScale(); + result._scaleSignX = sign(s.x) * sign(result._scaleSignX); + result._scaleSignY = sign(s.y) * sign(result._scaleSignY); + + return result; + } + } + + to4x4() { + const mat = new Matrix(); + mat.data[0] = this.data[0]; + mat.data[1] = this.data[1]; + mat.data[2] = 0; + mat.data[3] = 0; + + mat.data[4] = this.data[2]; + mat.data[5] = this.data[3]; + mat.data[6] = 0; + mat.data[7] = 0; + + mat.data[8] = 0; + mat.data[9] = 0; + mat.data[10] = 1; + mat.data[11] = 0; + + mat.data[12] = this.data[4]; + mat.data[13] = this.data[5]; + mat.data[14] = 0; + mat.data[15] = 1; + return mat; + } + + public setRotation(angle: number) { + const currentScale = this.getScale(); + const sine = Math.sin(angle); + const cosine = Math.cos(angle); + + this.data[0] = cosine * currentScale.x; + this.data[1] = sine * currentScale.y; + this.data[2] = -sine * currentScale.x; + this.data[3] = cosine * currentScale.y; + } + + public getRotation(): number { + const angle = Math.atan2(this.data[1] / this.getScaleY(), this.data[0] / this.getScaleX()); + return canonicalizeAngle(angle); + } + + public getScaleX(): number { + // absolute scale of the matrix (we lose sign so need to add it back) + const xscale = vec(this.data[0], this.data[2]).distance(); + return this._scaleSignX * xscale; + } + + public getScaleY(): number { + // absolute scale of the matrix (we lose sign so need to add it back) + const yscale = vec(this.data[1], this.data[3]).distance(); + return this._scaleSignY * yscale; + } + + /** + * Get the scale of the matrix + */ + public getScale(): Vector { + return vec(this.getScaleX(), this.getScaleY()); + } + + private _scale = new Float64Array([1, 1]); + private _scaleSignX = 1; + public setScaleX(val: number) { + if (val === this._scale[0]) { + return; + } + this._scaleSignX = sign(val); + // negative scale acts like a 180 rotation, so flip + const xscale = vec(this.data[0] * this._scaleSignX, this.data[2] * this._scaleSignX).normalize(); + this.data[0] = xscale.x * val; + this.data[2] = xscale.y * val; + this._scale[0] = val; + } + + private _scaleSignY = 1; + public setScaleY(val: number) { + if (val === this._scale[1]) { + return; + } + this._scaleSignY = sign(val); + // negative scale acts like a 180 rotation, so flip + const yscale = vec(this.data[1] * this._scaleSignY, this.data[3] * this._scaleSignY).normalize(); + this.data[1] = yscale.x * val; + this.data[3] = yscale.y * val; + this._scale[1] = val; + } + + public setScale(scale: Vector) { + this.setScaleX(scale.x); + this.setScaleY(scale.y); + } + + public isIdentity(): boolean { + return ( + this.data[0] === 1 && + this.data[1] === 0 && + this.data[2] === 0 && + this.data[3] === 1 && + this.data[4] === 0 && + this.data[5] === 0 + ); + } + + /** + * Resets the current matrix to the identity matrix, mutating it + * @returns {AffineMatrix} Current matrix as identity + */ + public reset(): AffineMatrix { + const mat = this; + mat.data[0] = 1; + mat.data[1] = 0; + + mat.data[2] = 0; + mat.data[3] = 1; + + mat.data[4] = 0; + mat.data[5] = 0; + return mat; + } + + /** + * Creates a new Matrix with the same data as the current 4x4 + */ + public clone(dest?: AffineMatrix): AffineMatrix { + const mat = dest || new AffineMatrix(); + mat.data[0] = this.data[0]; + mat.data[1] = this.data[1]; + + mat.data[2] = this.data[2]; + mat.data[3] = this.data[3]; + + mat.data[4] = this.data[4]; + mat.data[5] = this.data[5]; + return mat; + } + + public toString() { + return ` +[${this.data[0]} ${this.data[2]} ${this.data[4]}] +[${this.data[1]} ${this.data[3]} ${this.data[5]}] +[0 0 1] +`; + } + +} \ No newline at end of file diff --git a/src/engine/Math/coord-plane.ts b/src/engine/Math/coord-plane.ts new file mode 100644 index 000000000..c458f4bef --- /dev/null +++ b/src/engine/Math/coord-plane.ts @@ -0,0 +1,15 @@ +/** + * Enum representing the coordinate plane for the position 2D vector in the [[TransformComponent]] + */ +export enum CoordPlane { + /** + * The world coordinate plane (default) represents world space, any entities drawn with world + * space move when the camera moves. + */ + World = 'world', + /** + * The screen coordinate plane represents screen space, entities drawn in screen space are pinned + * to screen coordinates ignoring the camera. + */ + Screen = 'screen' +} \ No newline at end of file diff --git a/src/engine/Math/matrix.ts b/src/engine/Math/matrix.ts index ea73388d2..f845f951a 100644 --- a/src/engine/Math/matrix.ts +++ b/src/engine/Math/matrix.ts @@ -17,7 +17,7 @@ export class Matrix { * 4x4 matrix in column major order * * | | | | | - * | ------- | ------- | -------- | | + * | ------- | ------- | -------- | -------- | * | data[0] | data[4] | data[8] | data[12] | * | data[1] | data[5] | data[9] | data[13] | * | data[2] | data[6] | data[10] | data[14] | @@ -97,6 +97,12 @@ export class Matrix { return new DOMMatrix([...this.data]); } + public static fromFloat32Array(data: Float32Array) { + const matrix = new Matrix(); + matrix.data = data; + return matrix; + } + /** * Creates a new identity matrix (a matrix that when applied does nothing) */ @@ -427,22 +433,33 @@ export class Matrix { return vec(this.getScaleX(), this.getScaleY()); } + private _scaleX = 1; private _scaleSignX = 1; public setScaleX(val: number) { + if (this._scaleX === val) { + return; + } + this._scaleSignX = sign(val); // negative scale acts like a 180 rotation, so flip const xscale = vec(this.data[0] * this._scaleSignX, this.data[4] * this._scaleSignX).normalize(); this.data[0] = xscale.x * val; this.data[4] = xscale.y * val; + this._scaleX = val; } + private _scaleY = 1; private _scaleSignY = 1; public setScaleY(val: number) { + if (this._scaleY === val) { + return; + } this._scaleSignY = sign(val); // negative scale acts like a 180 rotation, so flip const yscale = vec(this.data[1] * this._scaleSignY, this.data[5] * this._scaleSignY).normalize(); this.data[1] = yscale.x * val; this.data[5] = yscale.y * val; + this._scaleY = val; } public setScale(scale: Vector) { diff --git a/src/engine/Math/transform.ts b/src/engine/Math/transform.ts new file mode 100644 index 000000000..50e3e4b7e --- /dev/null +++ b/src/engine/Math/transform.ts @@ -0,0 +1,229 @@ +import { AffineMatrix } from './affine-matrix'; +import { canonicalizeAngle } from './util'; +import { vec, Vector } from './vector'; +import { VectorView } from './vector-view'; +import { WatchVector } from './watch-vector'; + +export class Transform { + private _parent: Transform | null = null; + get parent() { + return this._parent; + } + set parent(transform: Transform) { + if (this._parent) { + const index = this._parent._children.indexOf(this); + if (index > -1) { + this._parent._children.splice(index, 1); + } + } + this._parent = transform; + if (this._parent) { + this._parent._children.push(this); + } + this.flagDirty(); + } + get children(): readonly Transform[] { + return this._children; + } + private _children: Transform[] = []; + + private _pos: Vector = vec(0, 0); + set pos(v: Vector) { + if (!v.equals(this._pos)) { + this._pos.x = v.x; + this._pos.y = v.y; + this.flagDirty(); + } + } + get pos() { + return new WatchVector(this._pos, (x, y) => { + if (x !== this._pos.x || y !== this._pos.y) { + this.flagDirty(); + } + }); + } + + set globalPos(v: Vector) { + let localPos = v.clone(); + if (this.parent) { + localPos = this.parent.inverse.multiply(v); + } + if (!localPos.equals(this._pos)) { + this._pos = localPos; + this.flagDirty(); + } + } + get globalPos() { + return new VectorView({ + getX: () => this.matrix.data[4], + getY: () => this.matrix.data[5], + setX: (x) => { + if (this.parent) { + const { x: newX } = this.parent.inverse.multiply(vec(x, this.pos.y)); + this.pos.x = newX; + } else { + this.pos.x = x; + } + if (x !== this.matrix.data[4]) { + this.flagDirty(); + } + }, + setY: (y) => { + if (this.parent) { + const { y: newY } = this.parent.inverse.multiply(vec(this.pos.x, y)); + this.pos.y = newY; + } else { + this.pos.y = y; + } + if (y !== this.matrix.data[5]) { + this.flagDirty(); + } + } + }); + } + + private _rotation: number = 0; + set rotation(rotation: number) { + const canonRotation = canonicalizeAngle(rotation); + if (canonRotation !== this._rotation) { + this.flagDirty(); + } + this._rotation = canonRotation; + } + get rotation() { + return this._rotation; + } + + set globalRotation(rotation: number) { + let inverseRotation = 0; + if (this.parent) { + inverseRotation = this.parent.globalRotation; + } + const canonRotation = canonicalizeAngle(rotation + inverseRotation); + if (canonRotation !== this._rotation) { + this.flagDirty(); + } + this._rotation = canonRotation; + } + + get globalRotation() { + if (this.parent) { + return this.matrix.getRotation(); + } + return this.rotation; + } + + private _scale: Vector = vec(1, 1); + set scale(v: Vector) { + if (!v.equals(this._scale)) { + this._scale.x = v.x; + this._scale.y = v.y; + this.flagDirty(); + } + } + get scale() { + return new WatchVector(this._scale, (x, y) => { + if (x !== this._scale.x || y !== this._scale.y) { + this.flagDirty(); + } + }); + } + + set globalScale(v: Vector) { + let inverseScale = vec(1, 1); + if (this.parent) { + inverseScale = this.parent.globalScale; + } + this.scale = v.scale(vec(1 / inverseScale.x, 1 / inverseScale.y)); + } + + get globalScale() { + return new VectorView({ + getX: () => this.parent ? this.matrix.getScaleX() : this.scale.x, + getY: () => this.parent ? this.matrix.getScaleY() : this.scale.y, + setX: (x) => { + if (this.parent) { + const globalScaleX = this.parent.globalScale.x; + this.scale.x = x / globalScaleX; + } else { + this.scale.x = x; + } + }, + setY: (y) => { + if (this.parent) { + const globalScaleY = this.parent.globalScale.y; + this.scale.y = y / globalScaleY; + } else { + this.scale.y = y; + } + } + }); + } + + private _isDirty = false; + private _isInverseDirty = false; + private _matrix = AffineMatrix.identity(); + private _inverse = AffineMatrix.identity(); + + public get matrix() { + if (this._isDirty) { + if (this.parent === null) { + this._matrix = this._calculateMatrix(); + } else { + this._matrix = this.parent.matrix.multiply(this._calculateMatrix()); + } + this._isDirty = false; + } + return this._matrix; + } + + public get inverse() { + if (this._isInverseDirty) { + this._inverse = this.matrix.inverse(); + this._isInverseDirty = false; + } + return this._inverse; + } + + private _calculateMatrix(): AffineMatrix { + const matrix = AffineMatrix.identity() + .translate(this.pos.x, this.pos.y) + .rotate(this.rotation) + .scale(this.scale.x, this.scale.y); + return matrix; + } + + + public flagDirty() { + this._isDirty = true; + this._isInverseDirty = true; + for (let i = 0; i < this._children.length; i ++) { + this._children[i].flagDirty(); + } + } + + public apply(point: Vector): Vector { + return this.matrix.multiply(point); + } + + public applyInverse(point: Vector): Vector { + return this.inverse.multiply(point); + } + + public setTransform(pos: Vector, rotation: number, scale: Vector) { + this._pos.x = pos.x; + this._pos.y = pos.y; + this._rotation = canonicalizeAngle(rotation); + this._scale.x = scale.x; + this._scale.y = scale.y; + this.flagDirty(); + } + + public clone(dest?: Transform) { + const target = dest ?? new Transform(); + this._pos.clone(target._pos); + target._rotation = this._rotation; + this._scale.clone(target._scale); + target.flagDirty(); + } +} \ No newline at end of file diff --git a/src/engine/Math/vector.ts b/src/engine/Math/vector.ts index 860ac4553..777e86b4c 100644 --- a/src/engine/Math/vector.ts +++ b/src/engine/Math/vector.ts @@ -162,16 +162,20 @@ export class Vector implements Clonable { */ public distance(v?: Vector): number { if (!v) { - v = Vector.Zero; + return Math.sqrt(this.x * this.x + this.y * this.y); } - return Math.sqrt(Math.pow(this.x - v.x, 2) + Math.pow(this.y - v.y, 2)); + const deltaX = this.x - v.x; + const deltaY = this.y - v.y; + return Math.sqrt(deltaX * deltaX + deltaY * deltaY); } public squareDistance(v?: Vector): number { if (!v) { v = Vector.Zero; } - return Math.pow(this.x - v.x, 2) + Math.pow(this.y - v.y, 2); + const deltaX = this.x - v.x; + const deltaY = this.y - v.y; + return deltaX * deltaX + deltaY * deltaY; } /** @@ -213,22 +217,33 @@ export class Vector implements Clonable { /** * Scales a vector's by a factor of size * @param size The factor to scale the magnitude by + * @param dest Optionally provide a destination vector for the result */ - public scale(scale: Vector): Vector; - public scale(size: number): Vector; - public scale(sizeOrScale: number | Vector): Vector { + public scale(scale: Vector, dest?: Vector): Vector; + public scale(size: number, dest?: Vector): Vector; + public scale(sizeOrScale: number | Vector, dest?: Vector): Vector { + const result = dest || new Vector(0, 0); if (sizeOrScale instanceof Vector) { - return new Vector(this.x * sizeOrScale.x, this.y * sizeOrScale.y); + result.x = this.x * sizeOrScale.x; + result.y = this.y * sizeOrScale.y; } else { - return new Vector(this.x * sizeOrScale, this.y * sizeOrScale); + result.x = this.x * sizeOrScale; + result.y = this.y * sizeOrScale; } + return result; } /** * Adds one vector to another * @param v The vector to add + * @param dest Optionally copy the result into a provided vector */ - public add(v: Vector): Vector { + public add(v: Vector, dest?: Vector): Vector { + if (dest) { + dest.x = this.x + v.x; + dest.y = this.y + v.y; + return dest; + } return new Vector(this.x + v.x, this.y + v.y); } @@ -345,8 +360,11 @@ export class Vector implements Clonable { /** * Creates new vector that has the same values as the previous. */ - public clone(): Vector { - return new Vector(this.x, this.y); + public clone(dest?: Vector): Vector { + const v = dest ?? new Vector(0, 0); + v.x = this.x; + v.y = this.y; + return v; } /** diff --git a/src/engine/Math/watch-vector.ts b/src/engine/Math/watch-vector.ts new file mode 100644 index 000000000..e1fbb7412 --- /dev/null +++ b/src/engine/Math/watch-vector.ts @@ -0,0 +1,27 @@ +import { Vector } from './vector'; + +/** + * Wraps a vector and watches for changes in the x/y, modifies the original vector. + */ +export class WatchVector extends Vector { + constructor(public original: Vector, public change: (x: number, y: number) => any) { + super(original.x, original.y); + } + public get x() { + return this._x = this.original.x; + } + + public set x(newX: number) { + this.change(newX, this._y); + this._x = this.original.x = newX; + } + + public get y() { + return this._y = this.original.y; + } + + public set y(newY: number) { + this.change(this._x, newY); + this._y = this.original.y = newY; + } +} \ No newline at end of file diff --git a/src/engine/ScreenElement.ts b/src/engine/ScreenElement.ts index 1e8a735a4..27a3fa392 100644 --- a/src/engine/ScreenElement.ts +++ b/src/engine/ScreenElement.ts @@ -1,8 +1,9 @@ import { Vector, vec } from './Math/vector'; import { Engine } from './Engine'; import { Actor, ActorArgs } from './Actor'; -import { CoordPlane, TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; +import { TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; import { CollisionType } from './Collision/CollisionType'; +import { CoordPlane } from './Math/coord-plane'; /** * Type guard to detect a screen element diff --git a/src/engine/TileMap/TileMap.ts b/src/engine/TileMap/TileMap.ts index 77d8bb24f..6f357252b 100644 --- a/src/engine/TileMap/TileMap.ts +++ b/src/engine/TileMap/TileMap.ts @@ -69,6 +69,11 @@ export class TileMap extends Entity { private _collidersDirty = true; public flagCollidersDirty() { this._collidersDirty = true; + for (let i = 0; i < this.tiles.length; i++) { + if (this.tiles[i]) { + this.tiles[i].flagDirty(); + } + } } private _transform: TransformComponent; private _motion: MotionComponent; @@ -126,6 +131,7 @@ export class TileMap extends Entity { } } + private _oldPos: Vector; public get pos(): Vector { return this._transform.pos; } @@ -178,7 +184,7 @@ export class TileMap extends Entity { this._composite = this._collider.useCompositeCollider([]); this._transform.pos = options.pos ?? Vector.Zero; - this._transform.posChanged$.subscribe(() => this.flagCollidersDirty()); + this._oldPos = this._transform.pos; this.tileWidth = options.tileWidth; this.tileHeight = options.tileHeight; this.rows = options.rows; @@ -333,6 +339,9 @@ export class TileMap extends Entity { public update(engine: Engine, delta: number) { this.onPreUpdate(engine, delta); this.emit('preupdate', new Events.PreUpdateEvent(engine, delta, this)); + if (!this._oldPos.equals(this.pos)) { + this.flagCollidersDirty(); + } if (this._collidersDirty) { this._collidersDirty = false; this._updateColliders(); @@ -453,7 +462,7 @@ export class Tile extends Entity { private _bounds: BoundingBox; private _pos: Vector; private _posDirty = false; - private _transform: TransformComponent; + // private _transform: TransformComponent; /** * Return the world position of the top left corner of the tile @@ -597,10 +606,10 @@ export class Tile extends Entity { this.solid = options.solid ?? this.solid; this._graphics = options.graphics ?? []; this._recalculate(); - this._transform = options.map.get(TransformComponent); - this._transform.posChanged$.subscribe(() => { - this._posDirty = true; - }); + } + + public flagDirty() { + return this._posDirty = true; } private _recalculate() { @@ -609,17 +618,20 @@ export class Tile extends Entity { this.x * this.map.tileWidth, this.y * this.map.tileHeight)); this._bounds = new BoundingBox(this._pos.x, this._pos.y, this._pos.x + this.width, this._pos.y + this.height); + this._posDirty = false; } public get bounds() { if (this._posDirty) { this._recalculate(); - this._posDirty = false; } return this._bounds; } public get center(): Vector { + if (this._posDirty) { + this._recalculate(); + } return new Vector(this._pos.x + this.width / 2, this._pos.y + this.height / 2); } } \ No newline at end of file diff --git a/src/engine/Util/Pool.ts b/src/engine/Util/Pool.ts index 3833c9314..dee3b5e9d 100644 --- a/src/engine/Util/Pool.ts +++ b/src/engine/Util/Pool.ts @@ -12,6 +12,12 @@ export class Pool { public maxObjects: number = 100 ) {} + preallocate() { + for (let i = 0; i < this.maxObjects; i++) { + this.objects[i] = this.builder(); + } + } + /** * Use many instances out of the in the context and return all to the pool. * diff --git a/src/engine/files.d.ts b/src/engine/files.d.ts index 5b6857163..6724cd198 100644 --- a/src/engine/files.d.ts +++ b/src/engine/files.d.ts @@ -14,4 +14,4 @@ declare module '*.css' { declare module '*.glsl' { const value: string; export default value; -} +} \ No newline at end of file diff --git a/src/spec/AffineMatrixSpec.ts b/src/spec/AffineMatrixSpec.ts new file mode 100644 index 000000000..d0df9eb3e --- /dev/null +++ b/src/spec/AffineMatrixSpec.ts @@ -0,0 +1,206 @@ +import * as ex from '@excalibur'; +import { ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A AffineMatrix', () => { + beforeAll(() => { + jasmine.addMatchers(ExcaliburMatchers); + }); + + it('exists', () => { + expect(ex.AffineMatrix).toBeDefined(); + }); + + it('can be constructed', () => { + const matrix = new ex.AffineMatrix(); + expect(matrix).toBeDefined(); + }); + + it('can make an identity matrix', () => { + const identity = ex.AffineMatrix.identity(); + expect(identity.isIdentity()).toBeTrue(); + }); + + it('can make a translation matrix', () => { + const mat = ex.AffineMatrix.translation(100, -200); + expect(mat.getPosition()).toBeVector(ex.vec(100, -200)); + expect(mat.data[4]).toBe(100); + expect(mat.data[5]).toBe(-200); + }); + + it('can make a rotation matrix', () => { + const mat = ex.AffineMatrix.rotation(Math.PI / 4); + expect(mat.getRotation()).toBe(Math.PI / 4); + }); + + it('can make a scale matrix', () => { + const mat = ex.AffineMatrix.scale(2, 3); + expect(mat.getScale()).toBeVector(ex.vec(2, 3)); + expect(mat.getScaleX()).toBe(2); + expect(mat.getScaleY()).toBe(3); + }); + + it('can rotate a matrix', () => { + const mat = ex.AffineMatrix.identity().rotate(Math.PI / 4); + const rotation = ex.AffineMatrix.rotation(Math.PI / 4); + expect(rotation.getRotation()).toBe(mat.getRotation()); + }); + + it('can translate a matrix', () => { + const mat = ex.AffineMatrix.identity().translate(100, -200); + const translation = ex.AffineMatrix.translation(100, -200); + expect(translation.getPosition()).toBeVector(mat.getPosition()); + }); + + it('can scale a matrix', () => { + const mat = ex.AffineMatrix.identity().scale(2, 3); + const scale = ex.AffineMatrix.scale(2, 3); + expect(scale.getScale()).toBeVector(mat.getScale()); + }); + + it('can transform a point', () => { + const mat = ex.AffineMatrix.rotation(Math.PI / 4); + const newPoint = mat.multiply(ex.vec(0, 1)); + expect(newPoint.x).toBeCloseTo(-Math.cos(Math.PI / 4)); + expect(newPoint.y).toBeCloseTo(Math.sin(Math.PI / 4)); + }); + + it('can set a rotation and preserve scale', () => { + const mat = ex.AffineMatrix.identity(); + mat.setScale(ex.vec(3, 5)); + mat.setRotation(Math.PI / 2); + expect(mat.getScaleX()).toBe(3); + expect(mat.getScaleY()).toBe(5); + }); + + it('can set scale with rotation in the mix', () => { + const mat = ex.AffineMatrix.identity(); + mat.setScale(ex.vec(-1, -1)); + expect(mat.getScaleX()).toBe(-1, 'getScaleX()'); + expect(mat.getScaleY()).toBe(-1, 'getScaleY()'); + mat.setScale(ex.vec(-2, -5)); + expect(mat.getScaleX()).toBe(-2, 'getScaleX()'); + expect(mat.getScaleY()).toBe(-5, 'getScaleY()'); + + mat.setScale(ex.vec(5, 11)); + expect(mat.getScaleX()).toBe(5, 'getScaleX()'); + expect(mat.getScaleY()).toBe(11, 'getScaleY()'); + expect(mat.data[0]).toBe(5); + expect(mat.data[3]).toBe(11); + + mat.setScale(ex.vec(1, -1)); + expect(mat.getScaleX()).toBe(1, 'getScaleX()'); + expect(mat.getScaleY()).toBe(-1, 'getScaleY()'); + + mat.setRotation(Math.PI / 3); + expect(mat.getScaleX()).toBeCloseTo(1, 2, 'rotated PI/3 getScaleX()'); + expect(mat.getScaleY()).toBeCloseTo(-1, 2, 'rotated PI/3 getScaleY()'); + expect(mat.getRotation()).toBeCloseTo(Math.PI / 3, 2, 'rotated PI/3 getRotation()'); + + mat.setRotation(Math.PI); + expect(mat.getScaleX()).toBeCloseTo(1, 2, 'rotated PI getScaleX()'); + expect(mat.getScaleY()).toBeCloseTo(-1, 2, 'rotated PI getScaleY()'); + expect(mat.getRotation()).toBeCloseTo(Math.PI, 2, 'rotated PI getRotation()'); + + mat.setRotation(Math.PI * 2); + expect(mat.getScaleX()).toBeCloseTo(1, 2, 'rotated 2 PI getScaleX()'); + expect(mat.getScaleY()).toBeCloseTo(-1, 2, 'rotated 2 PI getScaleY()'); + expect(mat.getRotation()).toBeCloseTo(Math.PI * 2, 2, 'rotated 2 PI getRotation()'); + + mat.setRotation(Math.PI * 3); + expect(mat.getScaleX()).toBeCloseTo(1, 2, 'rotated 3 PI getScaleX()'); + expect(mat.getScaleY()).toBeCloseTo(-1, 2, 'rotated 3 PI getScaleY()'); + expect(mat.getRotation()).toBeCloseTo(Math.PI, 2, 'rotated 3 * PI getRotation()'); + }); + + it('can set the rotation', () => { + const mat = ex.AffineMatrix.identity(); + mat.setRotation(0); + expect(mat.getRotation()).toBeCloseTo(0, 2, 'rotation 0'); + + mat.setRotation(Math.PI / 3); + expect(mat.getRotation()).toBeCloseTo(Math.PI / 3, 2, 'rotation PI/3'); + + mat.setRotation(Math.PI); + expect(mat.getRotation()).toBeCloseTo(Math.PI, 2, 'rotation PI'); + + mat.setRotation(Math.PI * 2); + expect(mat.getRotation()).toBeCloseTo(Math.PI * 2, 2, 'rotation 2 PI'); + + mat.setRotation(Math.PI * 3); + expect(mat.getRotation()).toBeCloseTo(Math.PI, 2, 'roation 3 PI'); + }); + + it('can find the affine inverse', () => { + const mat = ex.AffineMatrix.identity() + .translate(100, -200) + .scale(2, 4); + const inv = mat.inverse(); + expect(mat.multiply(inv).isIdentity()).toBeTrue(); + expect(inv.multiply(mat).isIdentity()).toBeTrue(); + }); + + it('can find the affine inverse and store it into a target', () => { + const target = ex.AffineMatrix.identity(); + const mat = ex.AffineMatrix.identity() + .translate(100, -200) + .scale(2, 4); + + spyOn(ex.AffineMatrix, 'identity'); + const inv = mat.inverse(target); + expect(mat.multiply(inv).isIdentity()).toBeTrue(); + expect(inv.multiply(mat).isIdentity()).toBeTrue(); + expect(target).toBe(inv); + expect(ex.AffineMatrix.identity).withContext('using a target doesnt create a new mat') + .not.toHaveBeenCalledWith(); + }); + + it('can clone into a target matrix', () => { + const source = ex.AffineMatrix.identity().scale(5, 5); + const destination = ex.AffineMatrix.identity(); + + source.clone(destination); + + expect(destination.data).toEqual(new Float64Array([ + 5, 0, + 0, 5, + 0, 0 + ])); + }); + + it('can set position', () => { + const mat = new ex.AffineMatrix(); + expect(mat.getPosition()).toBeVector(ex.vec(0, 0)); + mat.setPosition(10, 43); + expect(mat.getPosition()).toBeVector(ex.vec(10, 43)); + }); + + it('can convert to a 4x4 matrix', () => { + const mat = ex.AffineMatrix.identity().translate(1,2).rotate(Math.PI).scale(3, 4); + expect(mat.getPosition()).toBeVector(ex.vec(1, 2)); + expect(mat.getRotation()).toBe(Math.PI); + expect(mat.getScale()).toBeVector(ex.vec(3, 4)); + + const mat4x4 = mat.to4x4(); + expect(mat4x4.getPosition()).toBeVector(ex.vec(1, 2)); + expect(mat4x4.getRotation()).toBe(Math.PI); + expect(mat4x4.getScale()).toBeVector(ex.vec(3, 4)); + }); + + it('can reset to identity', () => { + const mat = ex.AffineMatrix.identity() + .translate(100, -200) + .scale(2, 4); + + mat.reset(); + expect(mat.isIdentity()).toBe(true); + }); + + it('can print a matrix', () => { + const mat = ex.AffineMatrix.identity(); + expect(mat.toString()).toBe(` +[1 0 0] +[0 1 0] +[0 0 1] +`); + }); +}); \ No newline at end of file diff --git a/src/spec/ArcadeSolverSpec.ts b/src/spec/ArcadeSolverSpec.ts index 887aeb1a4..2f5d5aba8 100644 --- a/src/spec/ArcadeSolverSpec.ts +++ b/src/spec/ArcadeSolverSpec.ts @@ -164,4 +164,70 @@ describe('An ArcadeSolver', () => { expect(contact2.isCanceled()).toBeFalse(); expect(player.vel).toBeVector(ex.Vector.Zero); }); + + it('should cancel near zero mtv collisions', () => { + const arcadeSolver = new ex.ArcadeSolver(); + + const player = new ex.Actor({ + x: 0, + y: 0, + width: 40, + height: 40, + collisionType: ex.CollisionType.Active, + color: ex.Color.Red + }); + + const block = new ex.Actor({ + x: 39.9999, + y: 39.9999, + width: 40, + height: 40, + collisionType: ex.CollisionType.Fixed, + color: ex.Color.Green + }); + + const contact = new ex.CollisionContact( + player.collider.get(), + block.collider.get(), + ex.Vector.Down.scale(.00009), + ex.Vector.Down, + ex.Vector.Up.perpendicular(), + [], + [], + null + ); + + arcadeSolver.solvePosition(contact); + + expect(contact.isCanceled()).toBe(true); + }); + + it('should cancel near zero overlap collisions', () => { + const arcadeSolver = new ex.ArcadeSolver(); + + const player = new ex.Actor({ + x: 0, + y: 0, + width: 40, + height: 40, + collisionType: ex.CollisionType.Active, + color: ex.Color.Red + }); + + const block = new ex.Actor({ + x: 40, + y: 0, + width: 40 + .00005, + height: 40, + collisionType: ex.CollisionType.Fixed, + color: ex.Color.Green + }); + + const contact = new ex.CollisionContact( + player.collider.get(), block.collider.get(), ex.Vector.Down, ex.Vector.Down, ex.Vector.Up.perpendicular(), [], [], null); + + arcadeSolver.solvePosition(contact); + // Considers infinitesimally overlapping to no longer be overlapping and thus cancels the contact + expect(contact.isCanceled()).toBe(true); + }); }); \ No newline at end of file diff --git a/src/spec/BoundingBoxSpec.ts b/src/spec/BoundingBoxSpec.ts index c709f69fc..1c91c6ddb 100644 --- a/src/spec/BoundingBoxSpec.ts +++ b/src/spec/BoundingBoxSpec.ts @@ -153,6 +153,25 @@ function runBoundingBoxTests(creationType: string, createBoundingBox: Function) }); + it('can overlap with a margin for floating point rounding to consider no longer overlap', () => { + const bb1 = new ex.BoundingBox({ + left: 0, + right: 100, + top: 0, + bottom: 100 + }); + + // bb2 is very technically overlapping, but very slightly and we want to consider it not really overlapping + const bb2 = new ex.BoundingBox({ + left: 100 - 0.00001, + right: 200 - 0.00001, + top: 0, + bottom: 100 + }); + expect(bb1.overlaps(bb2, 0.0001)).toBe(false); + expect(bb1.overlaps(bb2)).toBe(true); + }); + it('can collide with other bounding boxes', () => { const b2 = new ex.BoundingBox(2, 0, 20, 10); const b3 = new ex.BoundingBox(12, 0, 28, 10); @@ -186,7 +205,7 @@ function runBoundingBoxTests(creationType: string, createBoundingBox: Function) it('can be transformed be a matrix', () => { const bb = ex.BoundingBox.fromDimension(10, 10); - const matrix = ex.Matrix.identity() + const matrix = ex.AffineMatrix.identity() .scale(2, 2) .rotate(Math.PI / 4); const newBB = bb.transform(matrix); diff --git a/src/spec/CollisionShapeSpec.ts b/src/spec/CollisionShapeSpec.ts index 73649bc5f..70daab224 100644 --- a/src/spec/CollisionShapeSpec.ts +++ b/src/spec/CollisionShapeSpec.ts @@ -72,7 +72,7 @@ describe('Collision Shape', () => { expect(sut.radius).toBe(10); actor.transform.scale = ex.vec(2, 2); - sut.update(actor.transform); + sut.update(actor.transform.get()); expect(sut.radius).toBe(20); sut.radius = 40; @@ -80,7 +80,7 @@ describe('Collision Shape', () => { sut.radius = 20; actor.transform.scale = ex.vec(1, 3); - sut.update(actor.transform); + sut.update(actor.transform.get()); expect(sut.radius).withContext('Uneven scale take the smallest').toBe(10); }); @@ -90,7 +90,7 @@ describe('Collision Shape', () => { sut.offset = ex.vec(100, 0); actor.transform.scale = ex.vec(2, 2); actor.transform.rotation = Math.PI / 2; - sut.update(actor.transform); + sut.update(actor.transform.get()); const expected = new ex.BoundingBox({ left: -20, @@ -117,7 +117,7 @@ describe('Collision Shape', () => { sut.offset = ex.vec(100, 0); actor.transform.rotation = Math.PI / 2; - sut.update(actor.transform); + sut.update(actor.transform.get()); expect(sut.center).toBeVector(ex.vec(0, 100)); }); @@ -223,7 +223,7 @@ describe('Collision Shape', () => { const actor2 = new ex.Actor({ x: 21, y: 0, width: 10, height: 10 }); const circle2 = actor2.collider.useCircleCollider(10); actor2.collider.update(); - circle2.update(actor2.transform); + circle2.update(actor2.transform.get()); const contact = circle.collide(circle2); @@ -235,7 +235,7 @@ describe('Collision Shape', () => { const actor2 = new ex.Actor({ x: 14.99, y: 0, width: 10, height: 10 }); // meh close enough const poly = actor2.collider.usePolygonCollider(actor2.collider.localBounds.getPoints()); actor2.collider.update(); - poly.update(actor2.transform); + poly.update(actor2.transform.get()); const directionOfBodyB = poly.center.sub(circle.center); const contact = circle.collide(poly)[0]; @@ -256,7 +256,7 @@ describe('Collision Shape', () => { const actor2 = new ex.Actor({ x: 16, y: 0, width: 10, height: 10 }); const poly = actor2.collider.usePolygonCollider(actor2.collider.localBounds.getPoints()); actor2.collider.update(); - poly.update(actor2.transform); + poly.update(actor2.transform.get()); const contact = circle.collide(poly); // there should not be a collision contact formed @@ -350,7 +350,7 @@ describe('Collision Shape', () => { ctx.clear(); actor.transform.scale = ex.vec(2, 2); - circle.update(actor.transform); + circle.update(actor.transform.get()); circle.debug(ctx, ex.Color.Red); diff --git a/src/spec/CompositeColliderSpec.ts b/src/spec/CompositeColliderSpec.ts index fec39dd6a..1b576cfe6 100644 --- a/src/spec/CompositeColliderSpec.ts +++ b/src/spec/CompositeColliderSpec.ts @@ -1,5 +1,5 @@ import * as ex from '@excalibur'; -import { BoundingBox, GameEvent, LineSegment, Projection, Ray, TransformComponent, vec, Vector } from '@excalibur'; +import { BoundingBox, GameEvent, LineSegment, Projection, Ray, vec, Vector } from '@excalibur'; import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; describe('A CompositeCollider', () => { beforeAll(() => { @@ -12,7 +12,7 @@ describe('A CompositeCollider', () => { it('can be created from multiple colliders', () => { const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10)]); - const xf = new TransformComponent(); + const xf = new ex.Transform(); xf.pos = vec(100, 100); compCollider.update(xf); @@ -74,7 +74,7 @@ describe('A CompositeCollider', () => { const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); const circle = ex.Shape.Circle(50); - const xf = new TransformComponent(); + const xf = new ex.Transform(); xf.pos = vec(300, 0); circle.update(xf); @@ -102,7 +102,7 @@ describe('A CompositeCollider', () => { const compCollider2 = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); - const xf = new TransformComponent(); + const xf = new ex.Transform(); xf.pos = vec(500, 0); compCollider2.update(xf); @@ -120,7 +120,7 @@ describe('A CompositeCollider', () => { const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); const circle = ex.Shape.Circle(50); - const xf = new TransformComponent(); + const xf = new ex.Transform(); xf.pos = vec(149, 0); circle.update(xf); @@ -139,7 +139,7 @@ describe('A CompositeCollider', () => { const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); const circle = ex.Shape.Circle(50); - const xf = new TransformComponent(); + const xf = new ex.Transform(); xf.pos = vec(149, 0); circle.update(xf); @@ -156,7 +156,7 @@ describe('A CompositeCollider', () => { const compCollider2 = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); - const xf = new TransformComponent(); + const xf = new ex.Transform(); xf.pos = vec(200, 0); compCollider2.update(xf); @@ -171,7 +171,7 @@ describe('A CompositeCollider', () => { const compCollider1 = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); const circle = ex.Shape.Circle(50); - const xf = new TransformComponent(); + const xf = new ex.Transform(); xf.pos = vec(300, 0); circle.update(xf); @@ -237,7 +237,7 @@ describe('A CompositeCollider', () => { const ctx = new ex.ExcaliburGraphicsContext2DCanvas({ canvasElement }); const compCollider = new ex.CompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(200, 10, Vector.Half)]); - const tx = new TransformComponent(); + const tx = new ex.Transform(); tx.pos = ex.vec(150, 150); compCollider.update(tx); diff --git a/src/spec/MatrixSpec.ts b/src/spec/MatrixSpec.ts index 11159b16d..e7d85682e 100644 --- a/src/spec/MatrixSpec.ts +++ b/src/spec/MatrixSpec.ts @@ -155,6 +155,20 @@ describe('A Matrix', () => { .not.toHaveBeenCalledWith(); }); + it('can be created from a float32array', () => { + const mat = ex.Matrix.identity().translate(1,2).rotate(Math.PI).scale(3, 4); + const newData = new Float32Array(mat.data); + const mat2 = ex.Matrix.fromFloat32Array(newData); + expect(mat.toString()).toEqual(mat2.toString()); + }); + + it('can set position', () => { + const mat = new ex.Matrix(); + expect(mat.getPosition()).toBeVector(ex.vec(0, 0)); + mat.setPosition(10, 43); + expect(mat.getPosition()).toBeVector(ex.vec(10, 43)); + }); + it('can clone into a target matrix', () => { const source = ex.Matrix.identity().scale(5, 5); const destination = ex.Matrix.identity(); diff --git a/src/spec/TransformComponentSpec.ts b/src/spec/TransformComponentSpec.ts index 79fba5931..ebcbd9147 100644 --- a/src/spec/TransformComponentSpec.ts +++ b/src/spec/TransformComponentSpec.ts @@ -1,5 +1,7 @@ import * as ex from '@excalibur'; +import { TransformComponent } from '@excalibur'; import { ExcaliburMatchers } from 'excalibur-jasmine'; +import { EulerIntegrator } from '../engine/Collision/Integrator'; describe('A TransformComponent', () => { beforeAll(() => { @@ -66,6 +68,9 @@ describe('A TransformComponent', () => { const parentTx = parent.get(ex.TransformComponent); const childTx = child.get(ex.TransformComponent); + // Internal transform parentage is correct + expect(childTx.get().parent).toBe(parentTx.get()); + // Changing a parent position influences the child global position parentTx.pos = ex.vec(100, 200); expect(childTx.pos).toBeVector(ex.vec(0, 0)); @@ -76,9 +81,18 @@ describe('A TransformComponent', () => { expect(parentTx.pos).toBeVector(ex.vec(100, 200)); expect(childTx.pos).toBeVector(ex.vec(-100, -200)); - // can change pos by prop + // Can change pos by prop childTx.globalPos.x = 200; childTx.globalPos.y = 300; + expect(childTx.globalPos.x).toBe(200); + expect(childTx.globalPos.y).toBe(300); + expect(parentTx.pos).toBeVector(ex.vec(100, 200)); + expect(childTx.pos).toBeVector(ex.vec(100, 100)); + + // Can change global pos by vec + childTx.globalPos = ex.vec(200, 300); + expect(childTx.globalPos.x).toBe(200); + expect(childTx.globalPos.y).toBe(300); expect(parentTx.pos).toBeVector(ex.vec(100, 200)); expect(childTx.pos).toBeVector(ex.vec(100, 100)); }); @@ -106,9 +120,14 @@ describe('A TransformComponent', () => { childTx.globalScale.y = 4; expect(parentTx.scale).toBeVector(ex.vec(2, 3)); expect(childTx.scale).toBeVector(ex.vec(3 / 2, 4 / 3)); + + // Can change scale by vec + childTx.globalScale = ex.vec(3, 4); + expect(parentTx.scale).toBeVector(ex.vec(2, 3)); + expect(childTx.scale).toBeVector(ex.vec(3 / 2, 4 / 3)); }); - it('can have parent/child relations with rotation', () => { + it('can have parent/child relationships with rotation', () => { const parent = new ex.Entity([new ex.TransformComponent()]); const child = new ex.Entity([new ex.TransformComponent()]); parent.addChild(child); @@ -141,9 +160,9 @@ describe('A TransformComponent', () => { parentTx.scale = ex.vec(2, 3); expect(childTx.pos).toBeVector(ex.vec(0, 0)); - expect(childTx.getGlobalTransform().pos).toBeVector(ex.vec(100, 200)); - expect(childTx.getGlobalTransform().rotation).toBe(Math.PI); - expect(childTx.getGlobalTransform().scale).toBeVector(ex.vec(2, 3)); + expect(childTx.get().globalPos).toBeVector(ex.vec(100, 200)); + expect(childTx.get().globalRotation).toBe(Math.PI); + expect(childTx.get().globalScale).toBeVector(ex.vec(2, 3)); }); it('can observe a z index change', () => { @@ -157,25 +176,94 @@ describe('A TransformComponent', () => { expect(tx.z).toBe(19); }); - it('can observe position change', () => { - const tx = new ex.TransformComponent(); - const posChanged = jasmine.createSpy('posChanged'); - tx.posChanged$.subscribe(posChanged); + it('will only flag the transform dirty once during integration', () => { + const transform = new ex.TransformComponent(); + spyOn(transform.get(), 'flagDirty').and.callThrough(); + const motion = new ex.MotionComponent(); + + EulerIntegrator.integrate(transform, motion, ex.Vector.Zero, 16); + + expect(transform.get().flagDirty).toHaveBeenCalledTimes(1); + }); + + it('will only flag dirty if the pos coord is different', () => { + const transform = new ex.TransformComponent(); + spyOn(transform.get(), 'flagDirty').and.callThrough(); + + expect(transform.globalPos).toBeVector(ex.vec(0, 0)); + transform.pos.x = 0; + transform.pos.y = 0; + transform.globalPos.x = 0; + transform.globalPos.y = 0; + expect(transform.get().flagDirty).not.toHaveBeenCalled(); + + transform.globalPos.x = 1; + expect(transform.get().flagDirty).toHaveBeenCalled(); + }); + + it('will only flag dirty if the rotation coord is different', () => { + const transform = new ex.TransformComponent(); + spyOn(transform.get(), 'flagDirty').and.callThrough(); + + expect(transform.globalRotation).toBe(0); + transform.rotation = 0; + transform.globalRotation = 0; + expect(transform.get().flagDirty).not.toHaveBeenCalled(); + + transform.globalRotation = 1; + expect(transform.get().flagDirty).toHaveBeenCalled(); + }); + + it('will only flag dirty if the scale coord is different', () => { + const transform = new ex.TransformComponent(); + spyOn(transform.get(), 'flagDirty').and.callThrough(); + + expect(transform.globalScale).toBeVector(ex.vec(1, 1)); + transform.scale.x = 1; + transform.scale.y = 1; + transform.globalScale.x = 1; + transform.globalScale.y = 1; + expect(transform.get().flagDirty).not.toHaveBeenCalled(); + + transform.globalScale.x = 2; + expect(transform.get().flagDirty).toHaveBeenCalled(); + }); + + it('can be parented/unparented as a Transform', () => { + const child1 = new ex.Transform(); + const child2 = new ex.Transform(); + const parent = new ex.Transform(); + const grandParent = new ex.Transform(); + + child1.parent = parent; + child2.parent = parent; + parent.parent = grandParent; + + expect(child1.children).toEqual([]); + expect(parent.children).toEqual([child1, child2]); + expect(grandParent.children).toEqual([parent]); + + child2.parent = null; + expect(parent.children).toEqual([child1]); + }); - tx.pos = ex.vec(10, 10); - tx.pos = ex.vec(10, 10); // second vec is the same - expect(posChanged).toHaveBeenCalledTimes(1); + it('can be parented/unparented as a TransformComponent', () => { + const child1 = new ex.Entity([new TransformComponent]); + const child2 = new ex.Entity([new TransformComponent]); + const parent = new ex.Entity([new TransformComponent]); + const grandParent = new ex.Entity([new TransformComponent]); - tx.globalPos = ex.vec(1, 1); - tx.globalPos = ex.vec(1, 1); // second vec is the same - expect(posChanged).toHaveBeenCalledTimes(2); + parent.addChild(child1); + parent.addChild(child2); + grandParent.addChild(parent); - tx.pos.x = 20; - tx.pos.x = 20; - expect(posChanged).toHaveBeenCalledTimes(3); + expect(child1.children).toEqual([]); + expect(parent.children).toEqual([child1, child2]); + expect(grandParent.children).toEqual([parent]); - tx.globalPos.x = 40; - tx.globalPos.x = 40; - expect(posChanged).toHaveBeenCalledTimes(4); + parent.removeChild(child2); + expect(parent.children).toEqual([child1]); + expect(parent.get(TransformComponent).get().children).toEqual([child1.get(TransformComponent).get()]); + expect(child2.get(TransformComponent).get().parent).toBe(null); }); });