diff --git a/CHANGELOG.md b/CHANGELOG.md index c1eeaf412..3391ffdf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,14 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added -- +- New property and methods overloads to `ex.Animation` + * `ex.Animation.currentFrameTimeLeft` will return the current time in milliseconds left in the current + * `ex.Animation.goToFrame(frameNumber: number, duration?: number)` now accepts an optional duration for the target frame + * `ex.Animation.speed` can set the speed multiplier on an animation 1 = 1x speed, 2 = 2x speed. ### Fixed -- +- `ex.Animation.reset()` did not properly reset all internal state ### Updates diff --git a/package-lock.json b/package-lock.json index 5d8535137..b77c11ed9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "excalibur", - "version": "0.28.4", + "version": "0.28.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "excalibur", - "version": "0.28.4", + "version": "0.28.5", "license": "BSD-2-Clause", "dependencies": { "core-js": "3.33.3" diff --git a/src/engine/Graphics/Animation.ts b/src/engine/Graphics/Animation.ts index 687f1fb1c..315ffb4da 100644 --- a/src/engine/Graphics/Animation.ts +++ b/src/engine/Graphics/Animation.ts @@ -70,12 +70,18 @@ export interface AnimationOptions { * List of frames in the order you wish to play them */ frames: Frame[]; + /** + * Optionally set a positive speed multiplier on the animation. + * + * By default 1, meaning 1x speed. If set to 2, it will play the animation twice as fast. + */ + speed?: number; /** * Optionally reverse the direction of play */ reverse?: boolean; /** - * Optionally specify a default frame duration in ms (Default is 1000) + * Optionally specify a default frame duration in ms (Default is 100) */ frameDuration?: number; /** @@ -117,6 +123,12 @@ export interface FromSpriteSheetOptions { * Optionally specify a default duration for frames in milliseconds */ durationPerFrameMs?: number; + /** + * Optionally set a positive speed multiplier on the animation. + * + * By default 1, meaning 1x speed. If set to 2, it will play the animation twice as fast. + */ + speed?: number; /** * Optionally specify the animation strategy for this animation, by default animations loop [[AnimationStrategy.Loop]] */ @@ -138,7 +150,6 @@ export class Animation extends Graphic implements HasTick { public frames: Frame[] = []; public strategy: AnimationStrategy = AnimationStrategy.Loop; public frameDuration: number = 100; - public timeScale: number = 1; private _idempotencyToken = -1; @@ -148,10 +159,12 @@ export class Animation extends Graphic implements HasTick { private _pingPongDirection = 1; private _done = false; private _playing = true; + private _speed = 1; constructor(options: GraphicOptions & AnimationOptions) { super(options); this.frames = options.frames; + this.speed = options.speed ?? this.speed; this.strategy = options.strategy ?? this.strategy; this.frameDuration = options.totalDuration ? options.totalDuration / this.frames.length : options.frameDuration ?? this.frameDuration; if (options.reverse) { @@ -164,6 +177,7 @@ export class Animation extends Graphic implements HasTick { return new Animation({ frames: this.frames.map((f) => ({ ...f })), frameDuration: this.frameDuration, + speed: this.speed, reverse: this._reversed, strategy: this.strategy, ...this.cloneGraphicOptions() @@ -248,7 +262,7 @@ export class Animation extends Graphic implements HasTick { * @returns Animation */ public static fromSpriteSheetCoordinates(options: FromSpriteSheetOptions): Animation { - const { spriteSheet, frameCoordinates, durationPerFrameMs, strategy, reverse } = options; + const { spriteSheet, frameCoordinates, durationPerFrameMs, speed, strategy, reverse } = options; const defaultDuration = durationPerFrameMs ?? 100; const frames: Frame[] = []; for (const coord of frameCoordinates) { @@ -269,10 +283,31 @@ export class Animation extends Graphic implements HasTick { return new Animation({ frames, strategy, + speed, reverse }); } + /** + * Current animation speed + * + * 1 meaning normal 1x speed. + * 2 meaning 2x speed and so on. + */ + public get speed(): number { + return this._speed; + } + + /** + * Current animation speed + * + * 1 meaning normal 1x speed. + * 2 meaning 2x speed and so on. + */ + public set speed(val: number) { + this._speed = clamp(Math.abs(val), 0, Infinity); + } + /** * Returns the current Frame of the animation * @@ -295,6 +330,13 @@ export class Animation extends Graphic implements HasTick { return this._currentFrame; } + /** + * Returns the amount of time in milliseconds left in the current frame + */ + public get currentFrameTimeLeft(): number { + return this._timeLeftInFrame; + } + /** * Returns `true` if the animation is playing */ @@ -344,6 +386,11 @@ export class Animation extends Graphic implements HasTick { this._done = false; this._firstTick = true; this._currentFrame = 0; + this._timeLeftInFrame = this.frameDuration; + const maybeFrame = this.frames[this._currentFrame]; + if (maybeFrame) { + this._timeLeftInFrame = (maybeFrame?.duration || this.frameDuration); + } } /** @@ -373,14 +420,18 @@ export class Animation extends Graphic implements HasTick { /** * Jump the animation immediately to a specific frame if it exists + * + * Optionally specify an override for the duration of the frame, useful for + * keeping multiple animations in sync with one another. * @param frameNumber + * @param duration */ - public goToFrame(frameNumber: number) { + public goToFrame(frameNumber: number, duration?: number) { this._currentFrame = frameNumber; - this._timeLeftInFrame = this.frameDuration; + this._timeLeftInFrame = duration ?? this.frameDuration; const maybeFrame = this.frames[this._currentFrame]; if (maybeFrame && !this._done) { - this._timeLeftInFrame = maybeFrame?.duration || this.frameDuration; + this._timeLeftInFrame = duration ?? (maybeFrame?.duration || this.frameDuration); this.events.emit('frame', {...maybeFrame, frameIndex: this.currentFrameIndex }); } } @@ -455,7 +506,7 @@ export class Animation extends Graphic implements HasTick { this.events.emit('frame', {...this.currentFrame, frameIndex: this.currentFrameIndex }); } - this._timeLeftInFrame -= elapsedMilliseconds * this.timeScale; + this._timeLeftInFrame -= elapsedMilliseconds * this._speed; if (this._timeLeftInFrame <= 0) { this.goToFrame(this._nextFrame()); } diff --git a/src/spec/AnimationSpec.ts b/src/spec/AnimationSpec.ts index aba9b51e4..71af1a51c 100644 --- a/src/spec/AnimationSpec.ts +++ b/src/spec/AnimationSpec.ts @@ -547,4 +547,154 @@ describe('A Graphics Animation', () => { anim.tick(expectedFrameDuration, 2); expect(anim.currentFrame).toBe(anim.frames[0]); }); + + it('has a current time left in a frame', () => { + + const rect = new ex.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const frames: ex.Frame[] = [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ]; + const anim = new ex.Animation({ + frames: frames + }); + anim.play(); + expect(anim.currentFrameIndex).toBe(0); + expect(anim.currentFrameTimeLeft).toBe(100); + anim.tick(10, 1); + expect(anim.currentFrameIndex).toBe(0); + expect(anim.currentFrameTimeLeft).toBe(90); + anim.tick(10, 2); + expect(anim.currentFrameIndex).toBe(0); + expect(anim.currentFrameTimeLeft).toBe(80); + anim.tick(80, 3); + expect(anim.currentFrameIndex).toBe(1); + expect(anim.currentFrameTimeLeft).toBe(100); + }); + + it('reset will reset time in frame', () => { + + const rect = new ex.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const frames: ex.Frame[] = [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ]; + const anim = new ex.Animation({ + frames: frames + }); + anim.play(); + expect(anim.currentFrameIndex).toBe(0); + expect(anim.currentFrameTimeLeft).toBe(100); + anim.tick(60, 1); + expect(anim.currentFrameIndex).toBe(0); + expect(anim.currentFrameTimeLeft).toBe(40); + anim.reset(); + expect(anim.currentFrameTimeLeft).toBe(100); + }); + + it('can go to a frame with an overridden duration', () => { + const rect = new ex.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const frames: ex.Frame[] = [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ]; + const anim = new ex.Animation({ + frames: frames + }); + anim.play(); + expect(anim.currentFrameIndex).toBe(0); + expect(anim.currentFrameTimeLeft).toBe(100); + + anim.goToFrame(1, 50); + expect(anim.currentFrameIndex).toBe(1); + expect(anim.currentFrameTimeLeft).toBe(50); + }); + + it('can adjust playback speed', () => { + const rect = new ex.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const frames: ex.Frame[] = [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ]; + const anim = new ex.Animation({ + frames: frames, + speed: 2 + }); + anim.play(); + expect(anim.currentFrameIndex).toBe(0); + expect(anim.currentFrameTimeLeft).toBe(100); + + anim.tick(20, 1); + expect(anim.currentFrameIndex).toBe(0); + expect(anim.currentFrameTimeLeft).toBe(60); + }); + + it('can adjust playback speed (only positive', () => { + const rect = new ex.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const frames: ex.Frame[] = [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ]; + const anim = new ex.Animation({ + frames: frames + }); + anim.speed = -100; + expect(anim.speed).toBe(100); + + anim.speed = 0; + expect(anim.speed).toBe(0); + + anim.speed = 100; + expect(anim.speed).toBe(100); + }); });