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

excaliburjs / Excalibur / 16698057991

02 Aug 2025 09:47PM UTC coverage: 87.968% (+0.001%) from 87.967%
16698057991

Pull #3490

github

web-flow
Merge fef7de342 into b876a5023
Pull Request #3490: feat: Support meta data for Animation

5159 of 7147 branches covered (72.18%)

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

1 existing line in 1 file now uncovered.

13950 of 15858 relevant lines covered (87.97%)

24788.8 hits per line

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

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

29
export enum AnimationStrategy {
119✔
30
  /**
31
   * Animation ends without displaying anything
32
   */
33
  End = 'end',
119✔
34
  /**
35
   * Animation loops to the first frame after the last frame
36
   */
37
  Loop = 'loop',
119✔
38
  /**
39
   * Animation plays to the last frame, then backwards to the first frame, then repeats
40
   */
41
  PingPong = 'pingpong',
119✔
42
  /**
43
   * Animation ends stopping on the last frame
44
   */
45
  Freeze = 'freeze'
119✔
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 = {
119✔
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 {
119✔
162
  private static _LOGGER = Logger.getInstance();
163
  public events = new EventEmitter<AnimationEvents>();
41✔
164
  public frames: Frame[] = [];
41✔
165
  public strategy: AnimationStrategy = AnimationStrategy.Loop;
41✔
166
  public frameDuration: number = 100;
41✔
167
  public data: Map<string, any>;
168

169
  private _idempotencyToken = -1;
41✔
170

171
  private _firstTick = true;
41✔
172
  private _currentFrame = 0;
41✔
173
  private _timeLeftInFrame = 0;
41✔
174
  private _pingPongDirection = 1;
41✔
175
  private _done = false;
41✔
176
  private _playing = true;
41✔
177
  private _speed = 1;
41✔
178

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

192
  public clone(): Animation {
193
    return new Animation({
1✔
194
      frames: this.frames.map((f) => ({ ...f })),
1✔
195
      frameDuration: this.frameDuration,
196
      speed: this.speed,
197
      reverse: this._reversed,
198
      strategy: this.strategy,
199
      ...this.cloneGraphicOptions()
200
    });
201
  }
202

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

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

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

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

300
    return new Animation({
2✔
301
      frames,
302
      strategy,
303
      speed,
304
      reverse,
305
      data
306
    });
307
  }
308

309
  /**
310
   * Current animation speed
311
   *
312
   * 1 meaning normal 1x speed.
313
   * 2 meaning 2x speed and so on.
314
   */
315
  public get speed(): number {
316
    return this._speed;
43✔
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 set speed(val: number) {
326
    this._speed = clamp(Math.abs(val), 0, Infinity);
44✔
327
  }
328

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

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

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

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

365
  private _reversed = false;
41✔
366

367
  public get isReversed() {
368
    return this._reversed;
1✔
369
  }
370

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

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

390
  /**
391
   * Plays or resumes the animation from the current frame
392
   */
393
  public play(): void {
394
    this._playing = true;
10✔
395
  }
396

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

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

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

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

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

462
  private _nextFrame(): number {
463
    const currentFrame = this._currentFrame;
30✔
464
    if (this._done) {
30✔
465
      return currentFrame;
2✔
466
    }
467
    let next = -1;
28✔
468

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

500
        if (currentFrame + this._pingPongDirection < 0) {
10✔
501
          this._pingPongDirection = 1;
1✔
502
          this.events.emit('loop', this);
1✔
503
        }
504

505
        next = currentFrame + (this._pingPongDirection % this.frames.length);
10✔
506
        break;
10✔
507
      }
508
    }
509
    return next;
28✔
510
  }
511

512
  /**
513
   * Called internally by Excalibur to update the state of the animation potential update the current frame
514
   * @param elapsed Milliseconds elapsed
515
   * @param idempotencyToken Prevents double ticking in a frame by passing a unique token to the frame
516
   */
517
  public tick(elapsed: number, idempotencyToken: number = 0): void {
1✔
518
    if (this._idempotencyToken === idempotencyToken) {
47✔
519
      return;
3✔
520
    }
521
    this._idempotencyToken = idempotencyToken;
44✔
522
    if (!this._playing) {
44✔
523
      return;
2✔
524
    }
525

526
    // if it's the first frame emit frame event
527
    if (this._firstTick) {
42✔
528
      this._firstTick = false;
19✔
529
      this.events.emit('frame', { ...this.currentFrame, frameIndex: this.currentFrameIndex });
19✔
530
    }
531

532
    this._timeLeftInFrame -= elapsed * this._speed;
42✔
533
    if (this._timeLeftInFrame <= 0) {
42✔
534
      this.goToFrame(this._nextFrame());
30✔
535
    }
536
  }
537

538
  protected _drawImage(ctx: ExcaliburGraphicsContext, x: number, y: number) {
539
    if (this.currentFrame && this.currentFrame.graphic) {
10!
540
      this.currentFrame.graphic.draw(ctx, x, y);
10✔
541
    }
542
  }
543
}
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