Skip to content

Commit

Permalink
feat: Animation current time + speed methods (#2874)
Browse files Browse the repository at this point in the history
This PR adds 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
  • Loading branch information
eonarheim authored Jan 9, 2024
1 parent 9622479 commit dca55c2
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 11 deletions.
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 58 additions & 7 deletions src/engine/Graphics/Animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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]]
*/
Expand All @@ -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;

Expand All @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand All @@ -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
*
Expand All @@ -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
*/
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -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());
}
Expand Down
150 changes: 150 additions & 0 deletions src/spec/AnimationSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

0 comments on commit dca55c2

Please sign in to comment.