• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

excaliburjs / Excalibur / 15354777440

30 May 2025 08:03PM UTC coverage: 87.858% (-1.5%) from 89.344%
15354777440

Pull #3385

github

web-flow
Merge a00f57733 into e6ec66358
Pull Request #3385: updated Meet action to add tolerance

5002 of 6948 branches covered (71.99%)

3 of 5 new or added lines in 2 files covered. (60.0%)

872 existing lines in 83 files now uncovered.

13661 of 15549 relevant lines covered (87.86%)

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

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

98
export type AnimationEvents = {
99
  frame: FrameEvent;
100
  loop: Animation;
101
  end: Animation;
102
};
103

104
export const AnimationEvents = {
117✔
105
  Frame: 'frame',
106
  Loop: 'loop',
107
  End: 'end'
108
};
109

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

148
/**
149
 * Create an Animation given a list of {@apilink Frame | `frames`} in {@apilink AnimationOptions}
150
 *
151
 * To create an Animation from a {@apilink SpriteSheet}, use {@apilink Animation.fromSpriteSheet}
152
 */
153
export class Animation extends Graphic implements HasTick {
117✔
154
  private static _LOGGER = Logger.getInstance();
155
  public events = new EventEmitter<AnimationEvents>();
36✔
156
  public frames: Frame[] = [];
36✔
157
  public strategy: AnimationStrategy = AnimationStrategy.Loop;
36✔
158
  public frameDuration: number = 100;
36✔
159

160
  private _idempotencyToken = -1;
36✔
161

162
  private _firstTick = true;
36✔
163
  private _currentFrame = 0;
36✔
164
  private _timeLeftInFrame = 0;
36✔
165
  private _pingPongDirection = 1;
36✔
166
  private _done = false;
36✔
167
  private _playing = true;
36✔
168
  private _speed = 1;
36✔
169

170
  constructor(options: GraphicOptions & AnimationOptions) {
171
    super(options);
36✔
172
    this.frames = options.frames;
36✔
173
    this.speed = options.speed ?? this.speed;
36✔
174
    this.strategy = options.strategy ?? this.strategy;
36✔
175
    this.frameDuration = options.totalDuration ? options.totalDuration / this.frames.length : options.frameDuration ?? this.frameDuration;
36✔
176
    if (options.reverse) {
36✔
177
      this.reverse();
3✔
178
    }
179
    this.goToFrame(0);
36✔
180
  }
181

182
  public clone(): Animation {
183
    return new Animation({
1✔
184
      frames: this.frames.map((f) => ({ ...f })),
1✔
185
      frameDuration: this.frameDuration,
186
      speed: this.speed,
187
      reverse: this._reversed,
188
      strategy: this.strategy,
189
      ...this.cloneGraphicOptions()
190
    });
191
  }
192

193
  public override get width(): number {
194
    const maybeFrame = this.currentFrame;
23✔
195
    if (maybeFrame && maybeFrame.graphic) {
23✔
196
      return Math.abs(maybeFrame.graphic.width * this.scale.x);
17✔
197
    }
198
    return 0;
6✔
199
  }
200

201
  public override get height(): number {
202
    const maybeFrame = this.currentFrame;
25✔
203
    if (maybeFrame && maybeFrame.graphic) {
25✔
204
      return Math.abs(maybeFrame.graphic.height * this.scale.y);
19✔
205
    }
206
    return 0;
6✔
207
  }
208

209
  /**
210
   * Create an Animation from a {@apilink SpriteSheet}, a list of indices into the sprite sheet, a duration per frame
211
   * and optional {@apilink AnimationStrategy}
212
   *
213
   * Example:
214
   * ```typescript
215
   * const spriteSheet = SpriteSheet.fromImageSource({...});
216
   *
217
   * const anim = Animation.fromSpriteSheet(spriteSheet, range(0, 5), 200, AnimationStrategy.Loop);
218
   * ```
219
   * @param spriteSheet ex.SpriteSheet
220
   * @param spriteSheetIndex 0 based index from left to right, top down (row major order) of the ex.SpriteSheet
221
   * @param durationPerFrame duration per frame in milliseconds
222
   * @param strategy Optional strategy, default AnimationStrategy.Loop
223
   */
224
  public static fromSpriteSheet(
225
    spriteSheet: SpriteSheet,
226
    spriteSheetIndex: number[],
227
    durationPerFrame: number,
228
    strategy: AnimationStrategy = AnimationStrategy.Loop
2✔
229
  ): Animation {
230
    const maxIndex = spriteSheet.sprites.length - 1;
7✔
231
    const invalidIndices = spriteSheetIndex.filter((index) => index < 0 || index > maxIndex);
81✔
232
    if (invalidIndices.length) {
7✔
233
      Animation._LOGGER.warn(
2✔
234
        `Indices into SpriteSheet were provided that don\'t exist: ${invalidIndices.join(',')} no frame will be shown`
235
      );
236
    }
237
    return new Animation({
7✔
238
      frames: spriteSheet.sprites
239
        .filter((_, index) => spriteSheetIndex.indexOf(index) > -1)
560✔
240
        .map((f) => ({
76✔
241
          graphic: f,
242
          duration: durationPerFrame
243
        })),
244
      strategy: strategy
245
    });
246
  }
247

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

288
    return new Animation({
1✔
289
      frames,
290
      strategy,
291
      speed,
292
      reverse
293
    });
294
  }
295

296
  /**
297
   * Current animation speed
298
   *
299
   * 1 meaning normal 1x speed.
300
   * 2 meaning 2x speed and so on.
301
   */
302
  public get speed(): number {
303
    return this._speed;
38✔
304
  }
305

306
  /**
307
   * Current animation speed
308
   *
309
   * 1 meaning normal 1x speed.
310
   * 2 meaning 2x speed and so on.
311
   */
312
  public set speed(val: number) {
313
    this._speed = clamp(Math.abs(val), 0, Infinity);
39✔
314
  }
315

316
  /**
317
   * Returns the current Frame of the animation
318
   *
319
   * Use {@apilink Animation.currentFrameIndex} to get the frame number and
320
   * {@apilink Animation.goToFrame} to set the current frame index
321
   */
322
  public get currentFrame(): Frame | null {
323
    if (this._currentFrame >= 0 && this._currentFrame < this.frames.length) {
123✔
324
      return this.frames[this._currentFrame];
109✔
325
    }
326
    return null;
14✔
327
  }
328

329
  /**
330
   * Returns the current frame index of the animation
331
   *
332
   * Use {@apilink Animation.currentFrame} to grab the current {@apilink Frame} object
333
   */
334
  public get currentFrameIndex(): number {
335
    return this._currentFrame;
88✔
336
  }
337

338
  /**
339
   * Returns the amount of time in milliseconds left in the current frame
340
   */
341
  public get currentFrameTimeLeft(): number {
342
    return this._timeLeftInFrame;
11✔
343
  }
344

345
  /**
346
   * Returns `true` if the animation is playing
347
   */
348
  public get isPlaying(): boolean {
349
    return this._playing;
1✔
350
  }
351

352
  private _reversed = false;
36✔
353

354
  public get isReversed() {
355
    return this._reversed;
1✔
356
  }
357

358
  /**
359
   * Reverses the play direction of the Animation, this preserves the current frame
360
   */
361
  public reverse(): void {
362
    // Don't mutate with the original frame list, create a copy
363
    this.frames = this.frames.slice().reverse();
4✔
364
    this._reversed = !this._reversed;
4✔
365
  }
366

367
  /**
368
   * Returns the current play direction of the animation
369
   */
370
  public get direction(): AnimationDirection {
371
    // Keep logically consistent with ping-pong direction
372
    // If ping-pong is forward = 1 and reversed is true then we are logically reversed
373
    const reversed = this._reversed && this._pingPongDirection === 1 ? true : false;
4✔
374
    return reversed ? AnimationDirection.Backward : AnimationDirection.Forward;
4✔
375
  }
376

377
  /**
378
   * Plays or resumes the animation from the current frame
379
   */
380
  public play(): void {
381
    this._playing = true;
10✔
382
  }
383

384
  /**
385
   * Pauses the animation on the current frame
386
   */
387
  public pause(): void {
388
    this._playing = false;
1✔
389
    this._firstTick = true; // firstTick must be set to emit the proper frame event
1✔
390
  }
391

392
  /**
393
   * Reset the animation back to the beginning, including if the animation were done
394
   */
395
  public reset(): void {
396
    this._done = false;
2✔
397
    this._firstTick = true;
2✔
398
    this._currentFrame = 0;
2✔
399
    this._timeLeftInFrame = this.frameDuration;
2✔
400
    const maybeFrame = this.frames[this._currentFrame];
2✔
401
    if (maybeFrame) {
2!
402
      this._timeLeftInFrame = maybeFrame?.duration || this.frameDuration;
2!
403
    }
404
  }
405

406
  /**
407
   * Returns `true` if the animation can end
408
   */
409
  public get canFinish(): boolean {
410
    switch (this.strategy) {
4✔
411
      case AnimationStrategy.End:
412
      case AnimationStrategy.Freeze: {
413
        return true;
2✔
414
      }
415
      default: {
416
        return false;
2✔
417
      }
418
    }
419
  }
420

421
  /**
422
   * Returns `true` if the animation is done, for looping type animations
423
   * `ex.AnimationStrategy.PingPong` and `ex.AnimationStrategy.Loop` this will always return `false`
424
   *
425
   * See the `ex.Animation.canFinish()` method to know if an animation type can end
426
   */
427
  public get done(): boolean {
428
    return this._done;
2✔
429
  }
430

431
  /**
432
   * Jump the animation immediately to a specific frame if it exists
433
   *
434
   * Optionally specify an override for the duration of the frame, useful for
435
   * keeping multiple animations in sync with one another.
436
   * @param frameNumber
437
   * @param duration
438
   */
439
  public goToFrame(frameNumber: number, duration?: number) {
440
    this._currentFrame = frameNumber;
69✔
441
    this._timeLeftInFrame = duration ?? this.frameDuration;
69✔
442
    const maybeFrame = this.frames[this._currentFrame];
69✔
443
    if (maybeFrame && !this._done) {
69✔
444
      this._timeLeftInFrame = duration ?? (maybeFrame?.duration || this.frameDuration);
58!
445
      this.events.emit('frame', { ...maybeFrame, frameIndex: this.currentFrameIndex });
58✔
446
    }
447
  }
448

449
  private _nextFrame(): number {
450
    const currentFrame = this._currentFrame;
30✔
451
    if (this._done) {
30✔
452
      return currentFrame;
2✔
453
    }
454
    let next = -1;
28✔
455

456
    switch (this.strategy) {
28✔
457
      case AnimationStrategy.Loop: {
458
        next = (currentFrame + 1) % this.frames.length;
11✔
459
        if (next === 0) {
11✔
460
          this.events.emit('loop', this);
4✔
461
        }
462
        break;
11✔
463
      }
464
      case AnimationStrategy.End: {
465
        next = currentFrame + 1;
6✔
466
        if (next >= this.frames.length) {
6✔
467
          this._done = true;
2✔
468
          this._currentFrame = this.frames.length;
2✔
469
          this.events.emit('end', this);
2✔
470
        }
471
        break;
6✔
472
      }
473
      case AnimationStrategy.Freeze: {
474
        next = clamp(currentFrame + 1, 0, this.frames.length - 1);
1✔
475
        if (next >= this.frames.length - 1) {
1!
476
          this._done = true;
1✔
477
          this.events.emit('end', this);
1✔
478
        }
479
        break;
1✔
480
      }
481
      case AnimationStrategy.PingPong: {
482
        if (currentFrame + this._pingPongDirection >= this.frames.length) {
10✔
483
          this._pingPongDirection = -1;
3✔
484
          this.events.emit('loop', this);
3✔
485
        }
486

487
        if (currentFrame + this._pingPongDirection < 0) {
10✔
488
          this._pingPongDirection = 1;
1✔
489
          this.events.emit('loop', this);
1✔
490
        }
491

492
        next = currentFrame + (this._pingPongDirection % this.frames.length);
10✔
493
        break;
10✔
494
      }
495
    }
496
    return next;
28✔
497
  }
498

499
  /**
500
   * Called internally by Excalibur to update the state of the animation potential update the current frame
501
   * @param elapsed Milliseconds elapsed
502
   * @param idempotencyToken Prevents double ticking in a frame by passing a unique token to the frame
503
   */
504
  public tick(elapsed: number, idempotencyToken: number = 0): void {
1✔
505
    if (this._idempotencyToken === idempotencyToken) {
47✔
506
      return;
3✔
507
    }
508
    this._idempotencyToken = idempotencyToken;
44✔
509
    if (!this._playing) {
44✔
510
      return;
2✔
511
    }
512

513
    // if it's the first frame emit frame event
514
    if (this._firstTick) {
42✔
515
      this._firstTick = false;
19✔
516
      this.events.emit('frame', { ...this.currentFrame, frameIndex: this.currentFrameIndex });
19✔
517
    }
518

519
    this._timeLeftInFrame -= elapsed * this._speed;
42✔
520
    if (this._timeLeftInFrame <= 0) {
42✔
521
      this.goToFrame(this._nextFrame());
30✔
522
    }
523
  }
524

525
  protected _drawImage(ctx: ExcaliburGraphicsContext, x: number, y: number) {
526
    if (this.currentFrame && this.currentFrame.graphic) {
10!
527
      this.currentFrame.graphic.draw(ctx, x, y);
10✔
528
    }
529
  }
530
}
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