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

excaliburjs / Excalibur / 20275897578

16 Dec 2025 04:52PM UTC coverage: 88.649% (+0.02%) from 88.63%
20275897578

Pull #3622

github

web-flow
Merge 493498a8e into 97bb68b86
Pull Request #3622: feat: Add better debug settings

5324 of 7269 branches covered (73.24%)

19 of 20 new or added lines in 4 files covered. (95.0%)

1 existing line in 1 file now uncovered.

14729 of 16615 relevant lines covered (88.65%)

24677.91 hits per line

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

99.31
/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 {
248✔
19
  /**
20
   * Animation is playing forwards
21
   */
22
  Forward = 'forward',
248✔
23
  /**
24
   * Animation is playing backwards
25
   */
26
  Backward = 'backward'
248✔
27
}
28

29
export enum AnimationStrategy {
248✔
30
  /**
31
   * Animation ends without displaying anything
32
   */
33
  End = 'end',
248✔
34
  /**
35
   * Animation loops to the first frame after the last frame
36
   */
37
  Loop = 'loop',
248✔
38
  /**
39
   * Animation plays to the last frame, then backwards to the first frame, then repeats
40
   */
41
  PingPong = 'pingpong',
248✔
42
  /**
43
   * Animation ends stopping on the last frame
44
   */
45
  Freeze = 'freeze'
248✔
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 interface AnimationEvents {
103
  frame: FrameEvent;
104
  loop: Animation;
105
  end: Animation;
106
}
107

108
export const AnimationEvents = {
248✔
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 {
248✔
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 validIndices: number[] = [];
9✔
246
    const invalidIndices: number[] = [];
9✔
247
    spriteSheetIndex.forEach((index) => {
9✔
248
      if (index < 0 || index > maxIndex) {
91✔
249
        invalidIndices.push(index);
5✔
250
      } else {
251
        validIndices.push(index);
86✔
252
      }
253
    });
254

255
    if (invalidIndices.length) {
9✔
256
      Animation._LOGGER.warn(
2✔
257
        `Indices into SpriteSheet were provided that don\'t exist: frames ${invalidIndices.join(',')} will not be shown`
258
      );
259
    }
260
    return new this({
9✔
261
      frames: validIndices.map((validIndex) => ({
86✔
262
        graphic: spriteSheet.sprites[validIndex],
263
        duration: durationPerFrame
264
      })),
265
      strategy: strategy,
266
      data
267
    }) as InstanceType<T>;
268
  }
269

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

310
    return new this({
3✔
311
      frames,
312
      strategy,
313
      speed,
314
      reverse,
315
      data
316
    }) as InstanceType<T>;
317
  }
318

319
  /**
320
   * Current animation speed
321
   *
322
   * 1 meaning normal 1x speed.
323
   * 2 meaning 2x speed and so on.
324
   */
325
  public get speed(): number {
326
    return this._speed;
49✔
327
  }
328

329
  /**
330
   * Current animation speed
331
   *
332
   * 1 meaning normal 1x speed.
333
   * 2 meaning 2x speed and so on.
334
   */
335
  public set speed(val: number) {
336
    this._speed = clamp(Math.abs(val), 0, Infinity);
50✔
337
  }
338

339
  /**
340
   * Returns the current Frame of the animation
341
   *
342
   * Use {@apilink Animation.currentFrameIndex} to get the frame number and
343
   * {@apilink Animation.goToFrame} to set the current frame index
344
   */
345
  public get currentFrame(): Frame | null {
346
    if (this._currentFrame >= 0 && this._currentFrame < this.frames.length) {
127✔
347
      return this.frames[this._currentFrame];
113✔
348
    }
349
    return null;
14✔
350
  }
351

352
  /**
353
   * Returns the current frame index of the animation
354
   *
355
   * Use {@apilink Animation.currentFrame} to grab the current {@apilink Frame} object
356
   */
357
  public get currentFrameIndex(): number {
358
    return this._currentFrame;
114✔
359
  }
360

361
  /**
362
   * Returns the amount of time in milliseconds left in the current frame
363
   */
364
  public get currentFrameTimeLeft(): number {
365
    return this._timeLeftInFrame;
11✔
366
  }
367

368
  /**
369
   * Returns `true` if the animation is playing
370
   */
371
  public get isPlaying(): boolean {
372
    return this._playing;
1✔
373
  }
374

375
  private _reversed = false;
47✔
376

377
  public get isReversed() {
378
    return this._reversed;
1✔
379
  }
380

381
  /**
382
   * Reverses the play direction of the Animation, this preserves the current frame
383
   */
384
  public reverse(): void {
385
    // Don't mutate with the original frame list, create a copy
386
    this.frames = this.frames.slice().reverse();
4✔
387
    this._reversed = !this._reversed;
4✔
388
  }
389

390
  /**
391
   * Returns the current play direction of the animation
392
   */
393
  public get direction(): AnimationDirection {
394
    // Keep logically consistent with ping-pong direction
395
    // If ping-pong is forward = 1 and reversed is true then we are logically reversed
396
    const reversed = this._reversed && this._pingPongDirection === 1 ? true : false;
4✔
397
    return reversed ? AnimationDirection.Backward : AnimationDirection.Forward;
4✔
398
  }
399

400
  /**
401
   * Plays or resumes the animation from the current frame
402
   */
403
  public play(): void {
404
    this._playing = true;
12✔
405
  }
406

407
  /**
408
   * Pauses the animation on the current frame
409
   */
410
  public pause(): void {
411
    this._playing = false;
1✔
412
    this._firstTick = true; // firstTick must be set to emit the proper frame event
1✔
413
  }
414

415
  /**
416
   * Reset the animation back to the beginning, including if the animation were done
417
   */
418
  public reset(): void {
419
    this._wasResetDuringFrameCalc = true;
4✔
420
    this._done = false;
4✔
421
    this._firstTick = true;
4✔
422
    this._currentFrame = 0;
4✔
423
    this._timeLeftInFrame = this.frameDuration;
4✔
424
    const maybeFrame = this.frames[this._currentFrame];
4✔
425
    if (maybeFrame) {
4!
426
      this._timeLeftInFrame = maybeFrame?.duration || this.frameDuration;
4!
427
    }
428
  }
429

430
  /**
431
   * Returns `true` if the animation can end
432
   */
433
  public get canFinish(): boolean {
434
    switch (this.strategy) {
4✔
435
      case AnimationStrategy.End:
436
      case AnimationStrategy.Freeze: {
437
        return true;
2✔
438
      }
439
      default: {
440
        return false;
2✔
441
      }
442
    }
443
  }
444

445
  /**
446
   * Returns `true` if the animation is done, for looping type animations
447
   * `ex.AnimationStrategy.PingPong` and `ex.AnimationStrategy.Loop` this will always return `false`
448
   *
449
   * See the `ex.Animation.canFinish()` method to know if an animation type can end
450
   */
451
  public get done(): boolean {
452
    return this._done;
2✔
453
  }
454

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

473
  private _nextFrame(): number {
474
    this._wasResetDuringFrameCalc = false;
34✔
475
    const currentFrame = this._currentFrame;
34✔
476
    if (this._done) {
34✔
477
      return currentFrame;
1✔
478
    }
479
    let next = -1;
33✔
480

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

512
        if (currentFrame + this._pingPongDirection < 0) {
10✔
513
          this._pingPongDirection = 1;
1✔
514
          this.events.emit('loop', this);
1✔
515
        }
516

517
        next = currentFrame + (this._pingPongDirection % this.frames.length);
10✔
518
        break;
10✔
519
      }
520
    }
521
    if (this._wasResetDuringFrameCalc) {
33✔
522
      // if reset during frame calculation discard the calc'd next and return the current frame.
523
      this._wasResetDuringFrameCalc = false;
2✔
524
      return this._currentFrame;
2✔
525
    }
526
    return next;
31✔
527
  }
528

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

543
    // if it's the first frame emit frame event
544
    if (this._firstTick) {
46✔
545
      this._firstTick = false;
21✔
546
      this.events.emit('frame', { ...this.currentFrame, frameIndex: this.currentFrameIndex });
21✔
547
    }
548

549
    this._timeLeftInFrame -= elapsed * this._speed;
46✔
550
    if (this._timeLeftInFrame <= 0) {
46✔
551
      this.goToFrame(this._nextFrame());
34✔
552
    }
553
  }
554

555
  protected _drawImage(ctx: ExcaliburGraphicsContext, x: number, y: number) {
556
    if (this.currentFrame && this.currentFrame.graphic) {
10!
557
      this.currentFrame.graphic.draw(ctx, x, y);
10✔
558
    }
559
  }
560
}
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