• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 16753712593

05 Aug 2025 02:56PM UTC coverage: 87.801% (-0.2%) from 87.973%
16753712593

Pull #3491

github

web-flow
Merge 2b9f20650 into 64374c364
Pull Request #3491: Feature: Added lerp method to vector class

5130 of 7137 branches covered (71.88%)

5 of 5 new or added lines in 1 file covered. (100.0%)

69 existing lines in 4 files now uncovered.

13927 of 15862 relevant lines covered (87.8%)

24781.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.29
/src/engine/Graphics/Animation.ts
1
import type { GraphicOptions } from './Graphic';
2
import { Graphic } from './Graphic';
3
import type { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext';
4
import type { GetSpriteOptions, SpriteSheet } from './SpriteSheet';
5
import { Logger } from '../Util/Log';
6
import { clamp } from '../Math/util';
7
import { EventEmitter } from '../EventEmitter';
8

9
export interface HasTick {
10
  /**
11
   *
12
   * @param elapsed The amount of real world time in milliseconds that has elapsed that must be updated in the animation
13
   * @param idempotencyToken Optional idempotencyToken prevents a ticking animation from updating twice per frame
14
   */
15
  tick(elapsed: number, idempotencyToken?: number): void;
16
}
17

18
export enum AnimationDirection {
120✔
19
  /**
20
   * Animation is playing forwards
21
   */
22
  Forward = 'forward',
120✔
23
  /**
24
   * Animation is playing backwards
25
   */
26
  Backward = 'backward'
120✔
27
}
28

29
export enum AnimationStrategy {
120✔
30
  /**
31
   * Animation ends without displaying anything
32
   */
33
  End = 'end',
120✔
34
  /**
35
   * Animation loops to the first frame after the last frame
36
   */
37
  Loop = 'loop',
120✔
38
  /**
39
   * Animation plays to the last frame, then backwards to the first frame, then repeats
40
   */
41
  PingPong = 'pingpong',
120✔
42
  /**
43
   * Animation ends stopping on the last frame
44
   */
45
  Freeze = 'freeze'
120✔
46
}
47

48
/**
49
 * Frame of animation
50
 */
51
export interface Frame {
52
  /**
53
   * Optionally specify a graphic to show, no graphic shows an empty frame
54
   */
55
  graphic?: Graphic;
56
  /**
57
   * Optionally specify the number of ms the frame should be visible, overrides the animation duration (default 100 ms)
58
   */
59
  duration?: number;
60
}
61

62
export interface FrameEvent extends Frame {
63
  frameIndex: number;
64
}
65

66
/**
67
 * Animation options for building an animation via constructor.
68
 */
69
export interface AnimationOptions {
70
  /**
71
   * List of frames in the order you wish to play them
72
   */
73
  frames: Frame[];
74
  /**
75
   * Optionally set a positive speed multiplier on the animation.
76
   *
77
   * By default 1, meaning 1x speed. If set to 2, it will play the animation twice as fast.
78
   */
79
  speed?: number;
80
  /**
81
   * Optionally reverse the direction of play
82
   */
83
  reverse?: boolean;
84
  /**
85
   * Optionally specify a default frame duration in ms (Default is 100)
86
   */
87
  frameDuration?: number;
88
  /**
89
   * Optionally specify a total duration of the animation in ms to calculate each frame's duration
90
   */
91
  totalDuration?: number;
92
  /**
93
   * Optionally specify the {@apilink AnimationStrategy} for the Animation
94
   */
95
  strategy?: AnimationStrategy;
96
  /**
97
   * Optionally set arbitrary meta data for the animation
98
   */
99
  data?: Record<string, any>;
100
}
101

102
export type AnimationEvents = {
103
  frame: FrameEvent;
104
  loop: Animation;
105
  end: Animation;
106
};
107

108
export const AnimationEvents = {
120✔
109
  Frame: 'frame',
110
  Loop: 'loop',
111
  End: 'end'
112
};
113

114
export interface FromSpriteSheetOptions {
115
  /**
116
   * {@apilink SpriteSheet} to source the animation frames from
117
   */
118
  spriteSheet: SpriteSheet;
119
  /**
120
   * The list of (x, y) positions of sprites in the {@apilink SpriteSheet} of each frame, for example (0, 0)
121
   * is the the top left sprite, (0, 1) is the sprite directly below that, and so on.
122
   *
123
   * You may optionally specify a duration for the frame in milliseconds as well, this will override
124
   * the default duration.
125
   */
126
  frameCoordinates: { x: number; y: number; duration?: number; options?: GetSpriteOptions }[];
127
  /**
128
   * Optionally specify a default duration for frames in milliseconds
129
   * @deprecated use `durationPerFrame`
130
   */
131
  durationPerFrameMs?: number;
132
  /**
133
   * Optionally specify a default duration for frames in milliseconds
134
   */
135
  durationPerFrame?: number;
136
  /**
137
   * Optionally set a positive speed multiplier on the animation.
138
   *
139
   * By default 1, meaning 1x speed. If set to 2, it will play the animation twice as fast.
140
   */
141
  speed?: number;
142
  /**
143
   * Optionally specify the animation strategy for this animation, by default animations loop {@apilink AnimationStrategy.Loop}
144
   */
145
  strategy?: AnimationStrategy;
146
  /**
147
   * Optionally specify the animation should be reversed
148
   */
149
  reverse?: boolean;
150
  /**
151
   * Optionally set arbitrary meta data for the animation
152
   */
153
  data?: Record<string, any>;
154
}
155

156
/**
157
 * Create an Animation given a list of {@apilink Frame | `frames`} in {@apilink AnimationOptions}
158
 *
159
 * To create an Animation from a {@apilink SpriteSheet}, use {@apilink Animation.fromSpriteSheet}
160
 */
161
export class Animation extends Graphic implements HasTick {
120✔
162
  private static _LOGGER = Logger.getInstance();
163
  public events = new EventEmitter<AnimationEvents>();
47✔
164
  public frames: Frame[] = [];
47✔
165
  public strategy: AnimationStrategy = AnimationStrategy.Loop;
47✔
166
  public frameDuration: number = 100;
47✔
167
  public data: Map<string, any>;
168

169
  private _idempotencyToken = -1;
47✔
170

171
  private _firstTick = true;
47✔
172
  private _currentFrame = 0;
47✔
173
  private _timeLeftInFrame = 0;
47✔
174
  private _pingPongDirection = 1;
47✔
175
  private _done = false;
47✔
176
  private _playing = true;
47✔
177
  private _speed = 1;
47✔
178
  private _wasResetDuringFrameCalc: boolean = false;
47✔
179

180
  constructor(options: GraphicOptions & AnimationOptions) {
181
    super(options);
47✔
182
    this.frames = options.frames;
47✔
183
    this.speed = options.speed ?? this.speed;
47✔
184
    this.strategy = options.strategy ?? this.strategy;
47✔
185
    this.frameDuration = options.totalDuration ? options.totalDuration / this.frames.length : (options.frameDuration ?? this.frameDuration);
47✔
186
    this.data = options.data ? new Map(Object.entries(options.data)) : new Map<string, any>();
47✔
187
    if (options.reverse) {
47✔
188
      this.reverse();
3✔
189
    }
190
    this.goToFrame(0);
47✔
191
  }
192

193
  public clone<T extends typeof Animation>(): InstanceType<T> {
194
    const ctor = this.constructor as T;
2✔
195
    return new ctor({
2✔
196
      frames: this.frames.map((f) => ({ ...f })),
2✔
197
      frameDuration: this.frameDuration,
198
      speed: this.speed,
199
      reverse: this._reversed,
200
      strategy: this.strategy,
201
      ...this.cloneGraphicOptions()
202
    }) as InstanceType<T>;
203
  }
204

205
  public override get width(): number {
206
    const maybeFrame = this.currentFrame;
24✔
207
    if (maybeFrame && maybeFrame.graphic) {
24✔
208
      return Math.abs(maybeFrame.graphic.width * this.scale.x);
18✔
209
    }
210
    return 0;
6✔
211
  }
212

213
  public override get height(): number {
214
    const maybeFrame = this.currentFrame;
26✔
215
    if (maybeFrame && maybeFrame.graphic) {
26✔
216
      return Math.abs(maybeFrame.graphic.height * this.scale.y);
20✔
217
    }
218
    return 0;
6✔
219
  }
220

221
  /**
222
   * Create an Animation from a {@apilink SpriteSheet}, a list of indices into the sprite sheet, a duration per frame
223
   * and optional {@apilink AnimationStrategy}
224
   *
225
   * Example:
226
   * ```typescript
227
   * const spriteSheet = SpriteSheet.fromImageSource({...});
228
   *
229
   * const anim = Animation.fromSpriteSheet(spriteSheet, range(0, 5), 200, AnimationStrategy.Loop);
230
   * ```
231
   * @param spriteSheet ex.SpriteSheet
232
   * @param spriteSheetIndex 0 based index from left to right, top down (row major order) of the ex.SpriteSheet
233
   * @param durationPerFrame duration per frame in milliseconds
234
   * @param strategy Optional strategy, default AnimationStrategy.Loop
235
   */
236
  public static fromSpriteSheet<T extends typeof Animation>(
237
    this: T,
238
    spriteSheet: SpriteSheet,
239
    spriteSheetIndex: number[],
240
    durationPerFrame: number,
241
    strategy: AnimationStrategy = AnimationStrategy.Loop,
2✔
242
    data?: Record<string, any>
243
  ): InstanceType<T> {
244
    const maxIndex = spriteSheet.sprites.length - 1;
9✔
245
    const invalidIndices = spriteSheetIndex.filter((index) => index < 0 || index > maxIndex);
89✔
246
    if (invalidIndices.length) {
9✔
247
      Animation._LOGGER.warn(
2✔
248
        `Indices into SpriteSheet were provided that don\'t exist: ${invalidIndices.join(',')} no frame will be shown`
249
      );
250
    }
251
    return new this({
9✔
252
      frames: spriteSheet.sprites
253
        .filter((_, index) => spriteSheetIndex.indexOf(index) > -1)
760✔
254
        .map((f) => ({
84✔
255
          graphic: f,
256
          duration: durationPerFrame
257
        })),
258
      strategy: strategy,
259
      data
260
    }) as InstanceType<T>;
261
  }
262

263
  /**
264
   * Create an {@apilink Animation} from a {@apilink SpriteSheet} given a list of coordinates
265
   *
266
   * Example:
267
   * ```typescript
268
   * const spriteSheet = SpriteSheet.fromImageSource({...});
269
   *
270
   * const anim = Animation.fromSpriteSheetCoordinates({
271
   *  spriteSheet,
272
   *  frameCoordinates: [
273
   *    {x: 0, y: 5, duration: 100, options { flipHorizontal: true }},
274
   *    {x: 1, y: 5, duration: 200},
275
   *    {x: 2, y: 5},
276
   *    {x: 3, y: 5}
277
   *  ],
278
   *  strategy: AnimationStrategy.PingPong
279
   * });
280
   * ```
281
   * @param options
282
   * @returns Animation
283
   */
284
  public static fromSpriteSheetCoordinates<T extends typeof Animation>(this: T, options: FromSpriteSheetOptions): InstanceType<T> {
285
    const { spriteSheet, frameCoordinates, durationPerFrame, durationPerFrameMs, speed, strategy, reverse, data } = options;
3✔
286
    const defaultDuration = durationPerFrame ?? durationPerFrameMs ?? 100;
3!
287
    const frames: Frame[] = [];
3✔
288
    for (const coord of frameCoordinates) {
3✔
289
      const { x, y, duration, options } = coord;
12✔
290
      const sprite = spriteSheet.getSprite(x, y, options);
12✔
291
      if (sprite) {
12!
292
        frames.push({
12✔
293
          graphic: sprite,
294
          duration: duration ?? defaultDuration
12!
295
        });
296
      } else {
UNCOV
297
        Animation._LOGGER.warn(
×
298
          `Skipping frame! SpriteSheet does not have coordinate (${x}, ${y}), please check your SpriteSheet to confirm that sprite exists`
299
        );
300
      }
301
    }
302

303
    return new this({
3✔
304
      frames,
305
      strategy,
306
      speed,
307
      reverse,
308
      data
309
    }) as InstanceType<T>;
310
  }
311

312
  /**
313
   * Current animation speed
314
   *
315
   * 1 meaning normal 1x speed.
316
   * 2 meaning 2x speed and so on.
317
   */
318
  public get speed(): number {
319
    return this._speed;
49✔
320
  }
321

322
  /**
323
   * Current animation speed
324
   *
325
   * 1 meaning normal 1x speed.
326
   * 2 meaning 2x speed and so on.
327
   */
328
  public set speed(val: number) {
329
    this._speed = clamp(Math.abs(val), 0, Infinity);
50✔
330
  }
331

332
  /**
333
   * Returns the current Frame of the animation
334
   *
335
   * Use {@apilink Animation.currentFrameIndex} to get the frame number and
336
   * {@apilink Animation.goToFrame} to set the current frame index
337
   */
338
  public get currentFrame(): Frame | null {
339
    if (this._currentFrame >= 0 && this._currentFrame < this.frames.length) {
127✔
340
      return this.frames[this._currentFrame];
113✔
341
    }
342
    return null;
14✔
343
  }
344

345
  /**
346
   * Returns the current frame index of the animation
347
   *
348
   * Use {@apilink Animation.currentFrame} to grab the current {@apilink Frame} object
349
   */
350
  public get currentFrameIndex(): number {
351
    return this._currentFrame;
114✔
352
  }
353

354
  /**
355
   * Returns the amount of time in milliseconds left in the current frame
356
   */
357
  public get currentFrameTimeLeft(): number {
358
    return this._timeLeftInFrame;
11✔
359
  }
360

361
  /**
362
   * Returns `true` if the animation is playing
363
   */
364
  public get isPlaying(): boolean {
365
    return this._playing;
1✔
366
  }
367

368
  private _reversed = false;
47✔
369

370
  public get isReversed() {
371
    return this._reversed;
1✔
372
  }
373

374
  /**
375
   * Reverses the play direction of the Animation, this preserves the current frame
376
   */
377
  public reverse(): void {
378
    // Don't mutate with the original frame list, create a copy
379
    this.frames = this.frames.slice().reverse();
4✔
380
    this._reversed = !this._reversed;
4✔
381
  }
382

383
  /**
384
   * Returns the current play direction of the animation
385
   */
386
  public get direction(): AnimationDirection {
387
    // Keep logically consistent with ping-pong direction
388
    // If ping-pong is forward = 1 and reversed is true then we are logically reversed
389
    const reversed = this._reversed && this._pingPongDirection === 1 ? true : false;
4✔
390
    return reversed ? AnimationDirection.Backward : AnimationDirection.Forward;
4✔
391
  }
392

393
  /**
394
   * Plays or resumes the animation from the current frame
395
   */
396
  public play(): void {
397
    this._playing = true;
12✔
398
  }
399

400
  /**
401
   * Pauses the animation on the current frame
402
   */
403
  public pause(): void {
404
    this._playing = false;
1✔
405
    this._firstTick = true; // firstTick must be set to emit the proper frame event
1✔
406
  }
407

408
  /**
409
   * Reset the animation back to the beginning, including if the animation were done
410
   */
411
  public reset(): void {
412
    this._wasResetDuringFrameCalc = true;
4✔
413
    this._done = false;
4✔
414
    this._firstTick = true;
4✔
415
    this._currentFrame = 0;
4✔
416
    this._timeLeftInFrame = this.frameDuration;
4✔
417
    const maybeFrame = this.frames[this._currentFrame];
4✔
418
    if (maybeFrame) {
4!
419
      this._timeLeftInFrame = maybeFrame?.duration || this.frameDuration;
4!
420
    }
421
  }
422

423
  /**
424
   * Returns `true` if the animation can end
425
   */
426
  public get canFinish(): boolean {
427
    switch (this.strategy) {
4✔
428
      case AnimationStrategy.End:
429
      case AnimationStrategy.Freeze: {
430
        return true;
2✔
431
      }
432
      default: {
433
        return false;
2✔
434
      }
435
    }
436
  }
437

438
  /**
439
   * Returns `true` if the animation is done, for looping type animations
440
   * `ex.AnimationStrategy.PingPong` and `ex.AnimationStrategy.Loop` this will always return `false`
441
   *
442
   * See the `ex.Animation.canFinish()` method to know if an animation type can end
443
   */
444
  public get done(): boolean {
445
    return this._done;
2✔
446
  }
447

448
  /**
449
   * Jump the animation immediately to a specific frame if it exists
450
   *
451
   * Optionally specify an override for the duration of the frame, useful for
452
   * keeping multiple animations in sync with one another.
453
   * @param frameNumber
454
   * @param duration
455
   */
456
  public goToFrame(frameNumber: number, duration?: number) {
457
    this._currentFrame = frameNumber;
84✔
458
    this._timeLeftInFrame = duration ?? this.frameDuration;
84✔
459
    const maybeFrame = this.frames[this._currentFrame];
84✔
460
    if (maybeFrame && !this._done) {
84✔
461
      this._timeLeftInFrame = duration ?? (maybeFrame?.duration || this.frameDuration);
74!
462
      this.events.emit('frame', { ...maybeFrame, frameIndex: this.currentFrameIndex });
74✔
463
    }
464
  }
465

466
  private _nextFrame(): number {
467
    this._wasResetDuringFrameCalc = false;
34✔
468
    const currentFrame = this._currentFrame;
34✔
469
    if (this._done) {
34✔
470
      return currentFrame;
1✔
471
    }
472
    let next = -1;
33✔
473

474
    switch (this.strategy) {
33✔
475
      case AnimationStrategy.Loop: {
476
        next = (currentFrame + 1) % this.frames.length;
11✔
477
        if (next === 0) {
11✔
478
          this.events.emit('loop', this);
4✔
479
        }
480
        break;
11✔
481
      }
482
      case AnimationStrategy.End: {
483
        next = currentFrame + 1;
8✔
484
        if (next >= this.frames.length) {
8✔
485
          this._done = true;
3✔
486
          this._currentFrame = this.frames.length;
3✔
487
          this.events.emit('end', this);
3✔
488
        }
489
        break;
8✔
490
      }
491
      case AnimationStrategy.Freeze: {
492
        next = clamp(currentFrame + 1, 0, this.frames.length - 1);
4✔
493
        if (currentFrame + 1 >= this.frames.length) {
4✔
494
          this._done = true;
2✔
495
          this.events.emit('end', this);
2✔
496
        }
497
        break;
4✔
498
      }
499
      case AnimationStrategy.PingPong: {
500
        if (currentFrame + this._pingPongDirection >= this.frames.length) {
10✔
501
          this._pingPongDirection = -1;
3✔
502
          this.events.emit('loop', this);
3✔
503
        }
504

505
        if (currentFrame + this._pingPongDirection < 0) {
10✔
506
          this._pingPongDirection = 1;
1✔
507
          this.events.emit('loop', this);
1✔
508
        }
509

510
        next = currentFrame + (this._pingPongDirection % this.frames.length);
10✔
511
        break;
10✔
512
      }
513
    }
514
    if (this._wasResetDuringFrameCalc) {
33✔
515
      // if reset during frame calculation discard the calc'd next and return the current frame.
516
      this._wasResetDuringFrameCalc = false;
2✔
517
      return this._currentFrame;
2✔
518
    }
519
    return next;
31✔
520
  }
521

522
  /**
523
   * Called internally by Excalibur to update the state of the animation potential update the current frame
524
   * @param elapsed Milliseconds elapsed
525
   * @param idempotencyToken Prevents double ticking in a frame by passing a unique token to the frame
526
   */
527
  public tick(elapsed: number, idempotencyToken: number = 0): void {
1✔
528
    if (this._idempotencyToken === idempotencyToken) {
51✔
529
      return;
3✔
530
    }
531
    this._idempotencyToken = idempotencyToken;
48✔
532
    if (!this._playing) {
48✔
533
      return;
2✔
534
    }
535

536
    // if it's the first frame emit frame event
537
    if (this._firstTick) {
46✔
538
      this._firstTick = false;
21✔
539
      this.events.emit('frame', { ...this.currentFrame, frameIndex: this.currentFrameIndex });
21✔
540
    }
541

542
    this._timeLeftInFrame -= elapsed * this._speed;
46✔
543
    if (this._timeLeftInFrame <= 0) {
46✔
544
      this.goToFrame(this._nextFrame());
34✔
545
    }
546
  }
547

548
  protected _drawImage(ctx: ExcaliburGraphicsContext, x: number, y: number) {
549
    if (this.currentFrame && this.currentFrame.graphic) {
10!
550
      this.currentFrame.graphic.draw(ctx, x, y);
10✔
551
    }
552
  }
553
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc