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

excaliburjs / Excalibur / 14804036802

02 May 2025 09:58PM UTC coverage: 5.927% (-83.4%) from 89.28%
14804036802

Pull #3404

github

web-flow
Merge 5c103d7f8 into 0f2ccaeb2
Pull Request #3404: feat: added Graph module to Math

234 of 8383 branches covered (2.79%)

229 of 246 new or added lines in 1 file covered. (93.09%)

13145 existing lines in 208 files now uncovered.

934 of 15759 relevant lines covered (5.93%)

4.72 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
159
  private _idempotencyToken = -1;
×
160

UNCOV
161
  private _firstTick = true;
×
UNCOV
162
  private _currentFrame = 0;
×
UNCOV
163
  private _timeLeftInFrame = 0;
×
UNCOV
164
  private _pingPongDirection = 1;
×
UNCOV
165
  private _done = false;
×
UNCOV
166
  private _playing = true;
×
UNCOV
167
  private _speed = 1;
×
168

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

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

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

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

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

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

UNCOV
287
    return new Animation({
×
288
      frames,
289
      strategy,
290
      speed,
291
      reverse
292
    });
293
  }
294

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

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

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

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

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

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

UNCOV
351
  private _reversed = false;
×
352

353
  public get isReversed() {
UNCOV
354
    return this._reversed;
×
355
  }
356

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

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

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

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

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

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

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

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

448
  private _nextFrame(): number {
UNCOV
449
    const currentFrame = this._currentFrame;
×
UNCOV
450
    if (this._done) {
×
UNCOV
451
      return currentFrame;
×
452
    }
UNCOV
453
    let next = -1;
×
454

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

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

UNCOV
491
        next = currentFrame + (this._pingPongDirection % this.frames.length);
×
UNCOV
492
        break;
×
493
      }
494
    }
UNCOV
495
    return next;
×
496
  }
497

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

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

UNCOV
518
    this._timeLeftInFrame -= elapsed * this._speed;
×
UNCOV
519
    if (this._timeLeftInFrame <= 0) {
×
UNCOV
520
      this.goToFrame(this._nextFrame());
×
521
    }
522
  }
523

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