diff --git a/.vscode/settings.json b/.vscode/settings.json index d721f5bc7..670f6ffe5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,6 @@ "typescript.tsdk": "./node_modules/typescript/lib", "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" - } + }, + "deno.enable": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fdf3bd5..cdb561449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added new `ex.BezierCurve` type for drawing cubic bezier curves +- Added 2 new actions `actor.actions.curveTo(...)` and `actor.actions.curveBy(...)` - Added new `ex.lerp(...)`, `ex.inverseLerp(...)`, and `ex.remap(...)` for numbers - Added new `ex.lerpVector(...)`,` ex.inverseLerpVector(...)`, and `ex.remapVector(...)` for `ex.Vector` - Added new `actor.actions.flash(...)` `Action` to flash a color for a period of time diff --git a/sandbox/tests/bezier/index.html b/sandbox/tests/bezier/index.html new file mode 100644 index 000000000..b4648dc7e --- /dev/null +++ b/sandbox/tests/bezier/index.html @@ -0,0 +1,12 @@ + + + + + + Bezier Curve + + + + + + diff --git a/sandbox/tests/bezier/index.ts b/sandbox/tests/bezier/index.ts new file mode 100644 index 000000000..f6f718489 --- /dev/null +++ b/sandbox/tests/bezier/index.ts @@ -0,0 +1,82 @@ +var game = new ex.Engine({ + width: 600, + height: 400, + displayMode: ex.DisplayMode.FillScreen +}); +game.toggleDebug(); + +var curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 700), ex.vec(100, -300), ex.vec(150, 800), ex.vec(500, 100)], + quality: 10 +}); + +var reverseCurve = curve.clone(); +reverseCurve.controlPoints = [...reverseCurve.controlPoints].reverse() as any; + +var actor = new ex.Actor({ + pos: ex.vec(500, 500), + width: 100, + height: 100, + color: ex.Color.Red, + angularVelocity: 1 +}); +game.add(actor); + +actor.actions.repeatForever((ctx) => { + ctx.curveTo({ + controlPoints: [ex.vec(100, -300), ex.vec(150, 800), ex.vec(500, 100)], + durationMs: 6000 + }); + ctx.curveBy({ + controlPoints: [ex.vec(100, 0), ex.vec(-100, 0), ex.vec(0, 300)], + durationMs: 1000 + }); + ctx.curveTo({ + controlPoints: [ex.vec(150, 800), ex.vec(100, -300), ex.vec(0, 700)], + durationMs: 6000 + }); +}); + +var time = 0; +var points: ex.Vector[] = []; +game.onPostDraw = (ctx: ex.ExcaliburGraphicsContext, elapsedMs) => { + if (time < 5000) { + var t = ex.clamp(ex.remap(0, 5000, 0, 1, time), 0, 1); + + var p = curve.getPoint(t); + ctx.drawCircle(p, 20, ex.Color.Red); + var p2 = curve.getUniformPoint(t); + ctx.drawCircle(p2, 20, ex.Color.Purple); + points.push(p2); + + var tangent = curve.getTangent(t); + var normal = curve.getNormal(t); + ex.Debug.drawRay(new ex.Ray(p, tangent), { + distance: 100, + color: ex.Color.Yellow + }); + ex.Debug.drawRay(new ex.Ray(p, normal), { + distance: 100, + color: ex.Color.Green + }); + + var uTangent = curve.getUniformTangent(t); + var uNormal = curve.getUniformNormal(t); + ex.Debug.drawRay(new ex.Ray(p2, uTangent), { + distance: 100, + color: ex.Color.Yellow + }); + ex.Debug.drawRay(new ex.Ray(p2, uNormal), { + distance: 100, + color: ex.Color.Green + }); + + time += elapsedMs; + } + + for (let i = 0; i < points.length - 1; i++) { + ctx.drawLine(points[i], points[i + 1], ex.Color.Purple, 2); + } +}; + +game.start(); diff --git a/src/engine/Actions/Action/CurveBy.ts b/src/engine/Actions/Action/CurveBy.ts new file mode 100644 index 000000000..9a56f7226 --- /dev/null +++ b/src/engine/Actions/Action/CurveBy.ts @@ -0,0 +1,86 @@ +import { Entity, TransformComponent } from '../../EntityComponentSystem'; +import { BezierCurve, clamp, remap, vec, Vector } from '../../Math'; +import { Action, nextActionId } from '../Action'; + +export interface CurveByOptions { + /** + * Curve relative to the current actor position to move + */ + controlPoints: [control1: Vector, control2: Vector, end: Vector]; + /** + * Total duration for the action to run + */ + durationMs: number; + /** + * Dynamic mode will speed up/slow down depending on the curve + * + * Uniform mode will animate at a consistent velocity across the curve + * + * Default: 'dynamic' + */ + mode?: 'dynamic' | 'uniform'; + + quality?: number; +} + +export class CurveBy implements Action { + id: number = nextActionId(); + + private _curve: BezierCurve; + private _durationMs: number; + private _entity: Entity; + private _tx: TransformComponent; + private _currentMs: number; + private _started = false; + private _stopped = false; + private _mode: 'dynamic' | 'uniform' = 'dynamic'; + constructor(entity: Entity, options: CurveByOptions) { + this._entity = entity; + this._tx = this._entity.get(TransformComponent); + if (!this._tx) { + throw new Error(`Entity ${entity.name} has no TransformComponent, can only curveTo on Entities with TransformComponents.`); + } + this._curve = this._curve = new BezierCurve({ + controlPoints: [vec(0, 0), ...options.controlPoints], + quality: options.quality + }); + this._durationMs = options.durationMs; + this._mode = options.mode ?? this._mode; + this._currentMs = this._durationMs; + } + + update(elapsedMs: number): void { + if (!this._started) { + this._curve.setControlPoint(0, this._tx.globalPos); + this._curve.setControlPoint(1, this._curve.controlPoints[1].add(this._tx.globalPos)); + this._curve.setControlPoint(2, this._curve.controlPoints[2].add(this._tx.globalPos)); + this._curve.setControlPoint(3, this._curve.controlPoints[3].add(this._tx.globalPos)); + this._started = true; + } + const t = clamp(remap(0, this._durationMs, 0, 1, this._durationMs - this._currentMs), 0, 1); + if (this._mode === 'dynamic') { + this._tx.pos = this._curve.getPoint(t); + } else { + this._tx.pos = this._curve.getUniformPoint(t); + } + this._currentMs -= elapsedMs; + if (this.isComplete(this._entity)) { + if (this._mode === 'dynamic') { + this._tx.pos = this._curve.getPoint(1); + } else { + this._tx.pos = this._curve.getUniformPoint(1); + } + } + } + isComplete(entity: Entity): boolean { + return this._stopped || this._currentMs < 0; + } + reset(): void { + this._currentMs = this._durationMs; + this._started = false; + this._stopped = false; + } + stop(): void { + this._stopped = true; + } +} diff --git a/src/engine/Actions/Action/CurveTo.ts b/src/engine/Actions/Action/CurveTo.ts new file mode 100644 index 000000000..33236a797 --- /dev/null +++ b/src/engine/Actions/Action/CurveTo.ts @@ -0,0 +1,84 @@ +import { Entity, TransformComponent } from '../../EntityComponentSystem'; +import { BezierCurve, clamp, remap, vec, Vector } from '../../Math'; +import { Action, nextActionId } from '../Action'; + +export interface CurveToOptions { + /** + * Curve in world coordinates to animate towards + * + * The start control point is assumed to be the actor's current position + */ + controlPoints: [control1: Vector, control2: Vector, end: Vector]; + /** + * Total duration for the action to run + */ + durationMs: number; + /** + * Dynamic mode will speed up/slow down depending on the curve + * + * Uniform mode will animate at a consistent velocity across the curve + * + * Default: 'dynamic' + */ + mode?: 'dynamic' | 'uniform'; + quality?: number; +} + +export class CurveTo implements Action { + id: number = nextActionId(); + + private _curve: BezierCurve; + private _durationMs: number; + private _entity: Entity; + private _tx: TransformComponent; + private _currentMs: number; + private _started = false; + private _stopped = false; + private _mode: 'dynamic' | 'uniform' = 'dynamic'; + constructor(entity: Entity, options: CurveToOptions) { + this._entity = entity; + this._tx = this._entity.get(TransformComponent); + if (!this._tx) { + throw new Error(`Entity ${entity.name} has no TransformComponent, can only curveTo on Entities with TransformComponents.`); + } + this._curve = new BezierCurve({ + controlPoints: [vec(0, 0), ...options.controlPoints], + quality: options.quality + }); + this._durationMs = options.durationMs; + this._mode = options.mode ?? this._mode; + this._currentMs = this._durationMs; + } + + update(elapsedMs: number): void { + if (!this._started) { + this._curve.setControlPoint(0, this._tx.globalPos.clone()); + this._started = true; + } + const t = clamp(remap(0, this._durationMs, 0, 1, this._durationMs - this._currentMs), 0, 1); + if (this._mode === 'dynamic') { + this._tx.pos = this._curve.getPoint(t); + } else { + this._tx.pos = this._curve.getUniformPoint(t); + } + this._currentMs -= elapsedMs; + if (this.isComplete(this._entity)) { + if (this._mode === 'dynamic') { + this._tx.pos = this._curve.getPoint(1); + } else { + this._tx.pos = this._curve.getUniformPoint(1); + } + } + } + isComplete(entity: Entity): boolean { + return this._stopped || this._currentMs < 0; + } + reset(): void { + this._currentMs = this._durationMs; + this._started = false; + this._stopped = false; + } + stop(): void { + this._currentMs = 0; + } +} diff --git a/src/engine/Actions/ActionContext.ts b/src/engine/Actions/ActionContext.ts index 0644cde1f..3026688a7 100644 --- a/src/engine/Actions/ActionContext.ts +++ b/src/engine/Actions/ActionContext.ts @@ -24,6 +24,8 @@ import { Entity } from '../EntityComponentSystem/Entity'; import { Action } from './Action'; import { Color } from '../Color'; import { Flash } from './Action/Flash'; +import { CurveTo, CurveToOptions } from './Action/CurveTo'; +import { CurveBy, CurveByOptions } from './Action/CurveBy'; /** * The fluent Action API allows you to perform "actions" on @@ -61,6 +63,26 @@ export class ActionContext { return this; } + /** + * Animates an actor with a specified bezier curve, overrides the first control point + * to be the actor's current position. + * @param options + */ + public curveBy(options: CurveByOptions): ActionContext { + this._queue.add(new CurveBy(this._entity, options)); + return this; + } + + /** + * Animates an actor with a specified bezier curve, overrides the first control point + * to be the actor's current position. + * @param options + */ + public curveTo(options: CurveToOptions): ActionContext { + this._queue.add(new CurveTo(this._entity, options)); + return this; + } + /** * This method will move an actor to the specified `x` and `y` position over the * specified duration using a given {@apilink EasingFunctions} and return back the actor. This diff --git a/src/engine/Actions/ActionsComponent.ts b/src/engine/Actions/ActionsComponent.ts index 44a6c2591..c654a4748 100644 --- a/src/engine/Actions/ActionsComponent.ts +++ b/src/engine/Actions/ActionsComponent.ts @@ -10,6 +10,8 @@ import { ActionQueue } from './ActionQueue'; import { RotationType } from './RotationType'; import { Action } from './Action'; import { Color } from '../Color'; +import { CurveToOptions } from './Action/CurveTo'; +import { CurveByOptions } from './Action/CurveBy'; export interface ActionContextMethods extends Pick {} @@ -65,6 +67,14 @@ export class ActionsComponent extends Component implements ActionContextMethods this._ctx?.clearActions(); } + public curveBy(options: CurveByOptions): ActionContext { + return this._getCtx().curveBy.apply(this._ctx, [options]); + } + + public curveTo(options: CurveToOptions): ActionContext { + return this._getCtx().curveTo.apply(this._ctx, [options]); + } + /** * This method will move an actor to the specified `x` and `y` position over the * specified duration using a given {@apilink EasingFunctions} and return back the actor. This diff --git a/src/engine/Actions/Index.ts b/src/engine/Actions/Index.ts index eff76c4c7..4408e5783 100644 --- a/src/engine/Actions/Index.ts +++ b/src/engine/Actions/Index.ts @@ -23,6 +23,7 @@ export * from './Action/ScaleBy'; export * from './Action/ScaleTo'; export * from './Action/Delay'; export * from './Action/Flash'; - +export * from './Action/CurveTo'; +export * from './Action/CurveBy'; export * from './ActionsComponent'; export * from './ActionsSystem'; diff --git a/src/engine/Math/bezier-curve.ts b/src/engine/Math/bezier-curve.ts new file mode 100644 index 000000000..c7fb1c13a --- /dev/null +++ b/src/engine/Math/bezier-curve.ts @@ -0,0 +1,156 @@ +import { lerpVector, remap } from './lerp'; +import { Vector } from './vector'; + +export interface BezierCurveOptions { + /** + * [start, control1, control2, end] + */ + controlPoints: [start: Vector, control1: Vector, control2: Vector, end: Vector]; + quality?: number; +} + +/** + * BezierCurve that supports cubic Bezier curves. + */ +export class BezierCurve { + // Thanks Freya! https://www.youtube.com/watch?v=aVwxzDHniEw + private _distLookup: number[] = []; + private _controlPoints: [Vector, Vector, Vector, Vector]; + private _arcLength: number; + readonly quality: number = 4; + constructor(options: BezierCurveOptions) { + if (options.controlPoints.length !== 4) { + throw new Error('Only cubic bezier curves are supported'); + } + this._controlPoints = [...options.controlPoints]; + this.quality = options.quality ?? this.quality; + + this._calculateLookup(); + } + + public get arcLength(): number { + return this._arcLength; + } + + public get controlPoints(): readonly [start: Vector, control1: Vector, control2: Vector, end: Vector] { + return this._controlPoints; + } + + public set controlPoints(points: [start: Vector, control1: Vector, control2: Vector, end: Vector]) { + this._controlPoints = [...points]; + this._calculateLookup(); + } + + setControlPoint(index: 0 | 1 | 2 | 3, point: Vector) { + this._controlPoints[index] = point; + this._calculateLookup(); + } + + private _calculateLookup() { + let totalLength = 0; + this._distLookup.length = 0; + let prev = this.controlPoints[0]; + const n = this.controlPoints.length * this.quality; + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const pt = this.getPoint(t); + const diff = prev.distance(pt); + totalLength += diff; + this._distLookup.push(totalLength); + prev = pt; + } + this._arcLength = totalLength; + } + + private _getTimeGivenDistance(distance: number): number { + const n = this._distLookup.length; + const arcLength = this.arcLength; + + if (distance >= 0 && distance < arcLength) { + for (let i = 0; i < n - 1; i++) { + if (this._distLookup[i] <= distance && distance < this._distLookup[i + 1]) { + return remap(this._distLookup[i], this._distLookup[i + 1], i / (n - 1), (i + 1) / (n - 1), distance); + } + } + } + return distance / arcLength; + } + + /** + * Get the point on the Bezier curve at a certain time + * @param time Between 0-1 + */ + getPoint(time: number): Vector { + const points = [...this.controlPoints]; + + for (let r = 1; r < points.length; r++) { + for (let i = 0; i < points.length - r; i++) { + points[i] = lerpVector(points[i], points[i + 1], time); + } + } + + return points[0]; + } + + /** + * Get the tangent of the Bezier curve at a certain time + * @param time Between 0-1 + */ + getTangent(time: number): Vector { + const timeSquared = time * time; + const p0 = this.controlPoints[0]; + const p1 = this.controlPoints[1]; + const p2 = this.controlPoints[2]; + const p3 = this.controlPoints[3]; + + // Derivative of Bernstein polynomial + const pPrime = p0 + .scale(-3 * timeSquared + 6 * time - 3) + .add(p1.scale(9 * timeSquared - 12 * time + 3).add(p2.scale(-9 * timeSquared + 6 * time).add(p3.scale(3 * timeSquared)))); + + return pPrime.normalize(); + } + + /** + * Get the tangent of the Bezier curve where the distance is uniformly distributed over time + * @param time + */ + getUniformTangent(time: number): Vector { + const desiredDistance = time * this.arcLength; + const uniformTime = this._getTimeGivenDistance(desiredDistance); + return this.getTangent(uniformTime); + } + + /** + * Get the normal of the Bezier curve at a certain time + * @param time Between 0-1 + */ + getNormal(time: number): Vector { + return this.getTangent(time).normal(); + } + + /** + * Get the normal of the Bezier curve where the distance is uniformly distributed over time + * @param time + */ + getUniformNormal(time: number): Vector { + return this.getUniformTangent(time).normal(); + } + + /** + * Points are spaced uniformly across the length of the curve over time + * @param time + */ + getUniformPoint(time: number): Vector { + const desiredDistance = time * this.arcLength; + const uniformTime = this._getTimeGivenDistance(desiredDistance); + return this.getPoint(uniformTime); + } + + clone(): BezierCurve { + return new BezierCurve({ + controlPoints: [...this.controlPoints], + quality: this.quality + }); + } +} diff --git a/src/engine/Math/index.ts b/src/engine/Math/index.ts index b75deaeb3..bd6027610 100644 --- a/src/engine/Math/index.ts +++ b/src/engine/Math/index.ts @@ -10,4 +10,5 @@ export * from './line-segment'; export * from './projection'; export * from './ray'; export * from './lerp'; +export * from './bezier-curve'; export * from './util'; diff --git a/src/spec/BezierSpec.ts b/src/spec/BezierSpec.ts new file mode 100644 index 000000000..7c885d5a7 --- /dev/null +++ b/src/spec/BezierSpec.ts @@ -0,0 +1,63 @@ +import * as ex from '@excalibur'; +import { ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A BezierCurve', () => { + beforeAll(() => { + jasmine.addMatchers(ExcaliburMatchers); + }); + it('exists', () => { + expect(ex.BezierCurve).toBeDefined(); + }); + + it('only supports cubic', () => { + const throws = () => { + const curve = new ex.BezierCurve({ controlPoints: [] as any }); + }; + expect(throws).toThrowError('Only cubic bezier curves are supported'); + }); + + it('can build a cubic bezier curve', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(100, 100), ex.vec(0, 200), ex.vec(0, -200), ex.vec(800, 0)] + }); + + expect(curve.getPoint(0)).toBeVector(ex.vec(100, 100)); + expect(curve.getPoint(0.5)).toBeVector(ex.vec(112.5, 12.5)); + expect(curve.getNormal(0.5)).toBeVector(ex.vec(-0.5812, -0.8137)); + expect(curve.getTangent(0.5)).toBeVector(ex.vec(0.8137, -0.5812)); + expect(curve.getPoint(1)).toBeVector(ex.vec(800, 0)); + + expect(curve.getUniformPoint(0)).toBeVector(ex.vec(100, 100)); + expect(curve.getUniformPoint(0.5)).toBeVector(ex.vec(366.77, -56.17)); + expect(curve.getUniformNormal(0.5)).toBeVector(ex.vec(-0.0375, -0.9992)); + expect(curve.getUniformTangent(0.5)).toBeVector(ex.vec(0.9992, -0.0375)); + expect(curve.getUniformPoint(1)).toBeVector(ex.vec(800, 0)); + }); + + it('can get and set control points', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(100, 100), ex.vec(0, 200), ex.vec(0, -200), ex.vec(800, 0)] + }); + + expect(curve.controlPoints).toEqual([ex.vec(100, 100), ex.vec(0, 200), ex.vec(0, -200), ex.vec(800, 0)]); + + curve.setControlPoint(2, ex.vec(99, 99)); + + expect(curve.controlPoints).toEqual([ex.vec(100, 100), ex.vec(0, 200), ex.vec(99, 99), ex.vec(800, 0)]); + + curve.controlPoints = [ex.vec(0, 100), ex.vec(0, 200), ex.vec(0, -200), ex.vec(0, 0)]; + + expect(curve.controlPoints).toEqual([ex.vec(0, 100), ex.vec(0, 200), ex.vec(0, -200), ex.vec(0, 0)]); + }); + + it('can be cloned', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(100, 100), ex.vec(0, 200), ex.vec(0, -200), ex.vec(800, 0)], + quality: 12 + }); + + const clone = curve.clone(); + + expect(clone).toEqual(curve); + }); +}); diff --git a/src/spec/CurveActionSpec.ts b/src/spec/CurveActionSpec.ts new file mode 100644 index 000000000..7d36b504f --- /dev/null +++ b/src/spec/CurveActionSpec.ts @@ -0,0 +1,102 @@ +import * as ex from '@excalibur'; +import { ExcaliburMatchers } from 'excalibur-jasmine'; +import { TestUtils } from './util/TestUtils'; + +describe('A actor can curve', () => { + beforeAll(() => { + jasmine.addMatchers(ExcaliburMatchers); + }); + + let engine: ex.Engine; + let clock: ex.TestClock; + beforeEach(async () => { + engine = TestUtils.engine(); + await TestUtils.runToReady(engine); + await engine.start(); + clock = engine.clock as ex.TestClock; + }); + + afterEach(() => { + engine.stop(); + engine.dispose(); + engine = null; + }); + + it('exists', () => { + expect(ex.CurveTo).toBeDefined(); + }); + + it('an actor can curveTo ', async () => { + const actor = new ex.Actor({ + pos: ex.vec(200, 202) + }); + const scene = engine.currentScene; + scene.add(actor); + + actor.actions.curveTo({ + controlPoints: [ex.vec(100, 100), ex.vec(0, 0), ex.vec(100, 200)], + durationMs: 1000 + }); + + clock.step(0); + clock.run(10, 100); + + expect(actor.pos).toBeVector(ex.vec(100, 200)); + }); + + it('an actor can curveBy ', async () => { + const actor = new ex.Actor({ + pos: ex.vec(200, 202) + }); + const scene = engine.currentScene; + scene.add(actor); + + actor.actions.curveBy({ + controlPoints: [ex.vec(0, 100), ex.vec(0, 100), ex.vec(100, 200)], + durationMs: 1000 + }); + + clock.step(0); + clock.run(10, 100); + + expect(actor.pos).toBeVector(ex.vec(300, 402)); + }); + + it('curveBy can be stopped', () => { + const actor = new ex.Actor({ + pos: ex.vec(200, 202) + }); + const scene = engine.currentScene; + scene.add(actor); + + actor.actions.curveBy({ + controlPoints: [ex.vec(0, 100), ex.vec(0, 100), ex.vec(100, 200)], + durationMs: 1000 + }); + + clock.step(0); + clock.run(5, 100); + const pos = actor.pos.clone(); + actor.actions.getQueue().getActions()[0].stop(); + expect(actor.pos).toBeVector(pos); + }); + + it('curveTo can be stopped', () => { + const actor = new ex.Actor({ + pos: ex.vec(200, 202) + }); + const scene = engine.currentScene; + scene.add(actor); + + actor.actions.curveTo({ + controlPoints: [ex.vec(0, 100), ex.vec(0, 100), ex.vec(100, 200)], + durationMs: 1000 + }); + + clock.step(0); + clock.run(5, 100); + const pos = actor.pos.clone(); + actor.actions.getQueue().getActions()[0].stop(); + expect(actor.pos).toBeVector(pos); + }); +});