From 5ecb46424275d1b24dd34ea7ca5eda9b4ed95bba Mon Sep 17 00:00:00 2001 From: isokissa Date: Mon, 9 Dec 2024 06:23:04 +0200 Subject: [PATCH] feat: Make RotateTo+RotationType functionality more available (#3291) Closes #3288 ## Changes: - Introduced `angleBetween` metdhod to `Vector` class. - fixed `canonicalizeAngle` to never return 2PI - `RotationType` moved to `Math` - small documentation improvements - added/modified tests to `Vector.rotate()` to illustrate the conventions --------- Co-authored-by: petar-jr --- CHANGELOG.md | 9 +++ README.md | 6 +- src/engine/Actions/Action/RotateBy.ts | 3 +- src/engine/Actions/Action/RotateTo.ts | 2 +- src/engine/Actions/ActionContext.ts | 4 +- src/engine/Actions/ActionsComponent.ts | 3 +- src/engine/Actions/index.ts | 1 - src/engine/Math/index.ts | 1 + src/engine/Math/lerp.ts | 2 +- .../RotationType.ts => Math/rotation-type.ts} | 0 src/engine/Math/util.ts | 6 +- src/engine/Math/vector.ts | 44 +++++++++++++- src/spec/AlgebraSpec.ts | 59 +++++++++++++++++++ 13 files changed, 122 insertions(+), 18 deletions(-) rename src/engine/{Actions/RotationType.ts => Math/rotation-type.ts} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index dca3f247f..960e7b0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,6 +231,13 @@ This project adheres to [Semantic Versioning](http://semver.org/). - New `ex.SparseHashGridCollisionProcessor` which is a simpler (and faster) implementation for broadphase pair generation. This works by bucketing colliders into uniform sized square buckets and using that to generate pairs. - CollisionContact can be biased toward a collider by using `contact.bias(collider)`. This adjusts the contact so that the given collider is colliderA, and is helpful if you are doing mtv adjustments during precollision. +- `angleBetween` medhod added to Vector class, to find the angle for which a vector needs to be rotated to match some given angle: + ```typescript + const point = vec(100, 100) + const destinationDirection = Math.PI / 4 + const angleToRotate = point.angleBetween(destinationDirection, RotationType.ShortestPath) + expect(point.rotate(angleToRotate).toAngle()).toEqual(destinationDirection) + ``` ### Fixed @@ -266,6 +273,8 @@ are doing mtv adjustments during precollision. - Fixed issue where removing and re-adding an actor would cause subsequent children added not to function properly with regards to their parent/child transforms - Fixed issue where `ex.GraphicsSystem` would crash if a parent entity did not have a `ex.TransformComponent` - Fixed a bug in the new physics config merging, and re-arranged to better match the existing pattern +- Fixed a bug in `canonicalizeAngle`, don't allow the result to be 2PI, now it will be in semi-open range [0..2PI) +- Removed circular dependency between `Actions` and `Math` packages by moving `RotationType` into `Math` package. ### Updates diff --git a/README.md b/README.md index f82c83bae..ec1f72613 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ Please read our [Contributing Guidelines](.github/CONTRIBUTING.md) and our [Code Prerequisites * Docker for Mac https://docs.docker.com/desktop/mac/install/ -* In the root, run `docker-compose build` (setup build environment and installs dependencies, only needed once) -* To run tests in watch mode `docker-compose run --rm dev npm run test:watch` -* To run a build `docker-compose run --rm dev npm run all` +* In the root, run `docker compose build` (setup build environment and installs dependencies, only needed once) +* To run tests in watch mode `docker compose run --rm dev npm run test:watch` +* To run a build `docker compose run --rm dev npm run all` # Writing Documentation diff --git a/src/engine/Actions/Action/RotateBy.ts b/src/engine/Actions/Action/RotateBy.ts index f76f09a2e..8afbe828a 100644 --- a/src/engine/Actions/Action/RotateBy.ts +++ b/src/engine/Actions/Action/RotateBy.ts @@ -1,10 +1,9 @@ import { Action, nextActionId } from '../Action'; -import { RotationType } from '../RotationType'; import { TransformComponent } from '../../EntityComponentSystem/Components/TransformComponent'; import { MotionComponent } from '../../EntityComponentSystem/Components/MotionComponent'; import { Entity } from '../../EntityComponentSystem/Entity'; import { canonicalizeAngle, clamp, TwoPI } from '../../Math/util'; -import { lerpAngle, remap } from '../../Math'; +import { lerpAngle, remap, RotationType } from '../../Math'; export interface RotateByOptions { /** diff --git a/src/engine/Actions/Action/RotateTo.ts b/src/engine/Actions/Action/RotateTo.ts index bc36b490a..bd42b59a4 100644 --- a/src/engine/Actions/Action/RotateTo.ts +++ b/src/engine/Actions/Action/RotateTo.ts @@ -1,5 +1,5 @@ import { Action, nextActionId } from '../Action'; -import { RotationType } from '../RotationType'; +import { RotationType } from '../../Math'; import { TransformComponent } from '../../EntityComponentSystem/Components/TransformComponent'; import { MotionComponent } from '../../EntityComponentSystem/Components/MotionComponent'; import { Entity } from '../../EntityComponentSystem/Entity'; diff --git a/src/engine/Actions/ActionContext.ts b/src/engine/Actions/ActionContext.ts index 6635294e5..e7f140c5b 100644 --- a/src/engine/Actions/ActionContext.ts +++ b/src/engine/Actions/ActionContext.ts @@ -1,5 +1,3 @@ -import { RotationType } from './RotationType'; - import { EasingFunction, EasingFunctions } from '../Util/EasingFunctions'; import { ActionQueue } from './ActionQueue'; import { Repeat } from './Action/Repeat'; @@ -19,7 +17,7 @@ import { Delay } from './Action/Delay'; import { Die } from './Action/Die'; import { Follow } from './Action/Follow'; import { Meet } from './Action/Meet'; -import { Vector } from '../Math/vector'; +import { Vector, RotationType } from '../Math'; import { Entity } from '../EntityComponentSystem/Entity'; import { Action } from './Action'; import { Color } from '../Color'; diff --git a/src/engine/Actions/ActionsComponent.ts b/src/engine/Actions/ActionsComponent.ts index 1c37972b7..8d71f08d8 100644 --- a/src/engine/Actions/ActionsComponent.ts +++ b/src/engine/Actions/ActionsComponent.ts @@ -4,10 +4,9 @@ import { Entity } from '../EntityComponentSystem/Entity'; import { Actor } from '../Actor'; import { MotionComponent } from '../EntityComponentSystem/Components/MotionComponent'; import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; -import { Vector } from '../Math/vector'; +import { Vector, RotationType } from '../Math'; import { EasingFunction } from '../Util/EasingFunctions'; import { ActionQueue } from './ActionQueue'; -import { RotationType } from './RotationType'; import { Action } from './Action'; import { Color } from '../Color'; import { CurveToOptions } from './Action/CurveTo'; diff --git a/src/engine/Actions/index.ts b/src/engine/Actions/index.ts index 4408e5783..947dcfeb0 100644 --- a/src/engine/Actions/index.ts +++ b/src/engine/Actions/index.ts @@ -1,7 +1,6 @@ export * from './ActionContext'; export * from './ActionQueue'; export * from './Actionable'; -export * from './RotationType'; export * from './Action'; export * from './Action/ActionSequence'; diff --git a/src/engine/Math/index.ts b/src/engine/Math/index.ts index bd6027610..28fd92311 100644 --- a/src/engine/Math/index.ts +++ b/src/engine/Math/index.ts @@ -12,3 +12,4 @@ export * from './ray'; export * from './lerp'; export * from './bezier-curve'; export * from './util'; +export * from './rotation-type'; diff --git a/src/engine/Math/lerp.ts b/src/engine/Math/lerp.ts index 80a4a6da9..2aa34933e 100644 --- a/src/engine/Math/lerp.ts +++ b/src/engine/Math/lerp.ts @@ -1,4 +1,4 @@ -import { RotationType } from '../Actions/RotationType'; +import { RotationType } from './rotation-type'; import { TwoPI } from './util'; import { Vector } from './vector'; diff --git a/src/engine/Actions/RotationType.ts b/src/engine/Math/rotation-type.ts similarity index 100% rename from src/engine/Actions/RotationType.ts rename to src/engine/Math/rotation-type.ts diff --git a/src/engine/Math/util.ts b/src/engine/Math/util.ts index d84898cc3..6f61b6d04 100644 --- a/src/engine/Math/util.ts +++ b/src/engine/Math/util.ts @@ -43,12 +43,12 @@ export function approximatelyEqual(val1: number, val2: number, tolerance: number } /** - * Convert an angle to be the equivalent in the range [0, 2PI] + * Convert an angle to be the equivalent in the range [0, 2PI) */ export function canonicalizeAngle(angle: number): number { let tmpAngle = angle; - if (angle > TwoPI) { - while (tmpAngle > TwoPI) { + if (angle >= TwoPI) { + while (tmpAngle >= TwoPI) { tmpAngle -= TwoPI; } } diff --git a/src/engine/Math/vector.ts b/src/engine/Math/vector.ts index b7e281a96..863ddd001 100644 --- a/src/engine/Math/vector.ts +++ b/src/engine/Math/vector.ts @@ -1,5 +1,6 @@ import { Clonable } from '../Interfaces/Clonable'; -import { canonicalizeAngle, clamp } from './util'; +import { RotationType } from './rotation-type'; +import { canonicalizeAngle, clamp, TwoPI } from './util'; /** * A 2D vector on a plane. @@ -372,14 +373,53 @@ export class Vector implements Clonable { } /** - * Returns the angle of this vector. + * Returns the angle of this vector, in range [0, 2*PI) */ public toAngle(): number { return canonicalizeAngle(Math.atan2(this.y, this.x)); } + /** + * Returns the difference in radians between the angle of this vector and given angle, + * using the given rotation type. + * @param angle in radians to which the vector has to be rotated, using {@apilink rotate} + * @param rotationType what {@apilink RotationType} to use for the rotation + * @returns the angle by which the vector needs to be rotated to match the given angle + */ + public angleBetween(angle: number, rotationType: RotationType): number { + const startAngleRadians = this.toAngle(); + const endAngleRadians = canonicalizeAngle(angle); + let rotationClockwise = 0; + let rotationAntiClockwise = 0; + if (endAngleRadians > startAngleRadians) { + rotationClockwise = endAngleRadians - startAngleRadians; + } else { + rotationClockwise = (TwoPI - startAngleRadians + endAngleRadians) % TwoPI; + } + rotationAntiClockwise = (rotationClockwise - TwoPI) % TwoPI; + switch (rotationType) { + case RotationType.ShortestPath: + if (Math.abs(rotationClockwise) < Math.abs(rotationAntiClockwise)) { + return rotationClockwise; + } else { + return rotationAntiClockwise; + } + case RotationType.LongestPath: + if (Math.abs(rotationClockwise) > Math.abs(rotationAntiClockwise)) { + return rotationClockwise; + } else { + return rotationAntiClockwise; + } + case RotationType.Clockwise: + return rotationClockwise; + case RotationType.CounterClockwise: + return rotationAntiClockwise; + } + } + /** * Rotates the current vector around a point by a certain angle in radians. + * Positive angle means rotation clockwise. */ public rotate(angle: number, anchor?: Vector, dest?: Vector): Vector { const result = dest || new Vector(0, 0); diff --git a/src/spec/AlgebraSpec.ts b/src/spec/AlgebraSpec.ts index 18cc465c1..85fbbf0ef 100644 --- a/src/spec/AlgebraSpec.ts +++ b/src/spec/AlgebraSpec.ts @@ -125,6 +125,59 @@ describe('Vectors', () => { expect(ex.Vector.distance(v1, v2)).toBe(20); }); + describe('angleBetween', () => { + type TestCaseParameters = { + description: string; + vector: ex.Vector; + angle: number; + rotationType: ex.RotationType; + expected: number; + }; + const tc = ( + description: string, + vector: ex.Vector, + angle: number, + rotationType: ex.RotationType, + expected: number + ): TestCaseParameters => ({ + vector, + description, + angle, + rotationType, + expected + }); + const description = (tc: TestCaseParameters) => + `${tc.description}: ${tc.vector.toAngle()} -> ${ex.canonicalizeAngle(tc.angle)} ${tc.rotationType} expected: ${tc.expected}`; + const downRight = ex.vec(1, 1); + const downLeft = ex.vec(-1, 1); + const upLeft = ex.vec(-1, -1); + const upRight = ex.vec(1, -1); + const downRightAngle = downRight.toAngle(); + const downLeftAngle = downLeft.toAngle(); + const upLeftAngle = upLeft.toAngle(); + const upRightAngle = upRight.toAngle(); + + const testCases: TestCaseParameters[] = [ + tc('returns 0 when the new angle is the same as vectors angle', ex.Vector.Right, 0, ex.RotationType.Clockwise, 0), + tc('knows that 2*PI is same as 0', ex.Vector.Right, ex.TwoPI, ex.RotationType.Clockwise, 0), + tc('rotates from I to IV quadrant', upRight, downRightAngle, ex.RotationType.Clockwise, Math.PI / 2), + tc('rotates from I to IV quadrant the shortest way', upRight, downRightAngle, ex.RotationType.ShortestPath, Math.PI / 2), + tc('rotates from I to IV quadrant counterclockwise', upRight, downRightAngle, ex.RotationType.CounterClockwise, -(3 * Math.PI) / 2), + tc('rotates from I to IV quadrant the longest way', upRight, downRightAngle, ex.RotationType.LongestPath, -(3 * Math.PI) / 2), + tc('rotates from I to II quadrant', upRight, upLeftAngle, ex.RotationType.Clockwise, (3 * Math.PI) / 2), + tc('rotates from I to II quadrant the shortest way', upRight, upLeftAngle, ex.RotationType.ShortestPath, -Math.PI / 2), + tc('rotates from I to II quadrant counterclockwise', upRight, upLeftAngle, ex.RotationType.CounterClockwise, -Math.PI / 2), + tc('rotates from I to II quadrant the longest way', upRight, upLeftAngle, ex.RotationType.LongestPath, (3 * Math.PI) / 2) + ]; + + testCases.forEach((testCase) => { + it(description(testCase), () => { + const result = testCase.vector.angleBetween(testCase.angle, testCase.rotationType); + expect(result).toBeCloseTo(testCase.expected); + }); + }); + }); + it('can be normalized to a length of 1', () => { const v = new ex.Vector(10, 0); @@ -208,6 +261,12 @@ describe('Vectors', () => { expect(rotated.equals(new ex.Vector(-1, 0))).toBeTruthy(); }); + it('can be rotated by positive angle clockwise', () => { + const v = ex.Vector.Up; + const rotated = v.rotate(Math.PI / 2); + expect(rotated.equals(ex.Vector.Right)).toBeTruthy(); + }); + it('can be rotated by an angle about a point', () => { const v = new ex.Vector(1, 0); const rotate = v.rotate(Math.PI, new ex.Vector(2, 0));