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

excaliburjs / Excalibur / 20722503662

05 Jan 2026 04:48PM UTC coverage: 88.806% (+0.06%) from 88.745%
20722503662

Pull #3635

github

web-flow
Merge 40c841144 into 2c7b0c4fa
Pull Request #3635: remove!: deprecations for v1

5333 of 7246 branches covered (73.6%)

24 of 25 new or added lines in 9 files covered. (96.0%)

6 existing lines in 3 files now uncovered.

14653 of 16500 relevant lines covered (88.81%)

24848.14 hits per line

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

92.42
/src/engine/camera.ts
1
import type { Engine } from './engine';
2
import type { Screen } from './screen';
3
import { Vector, vec } from './math/vector';
4
import type { Actor } from './actor';
5
import { removeItemFromArray } from './util/util';
6
import type { CanUpdate, CanInitialize } from './interfaces/lifecycle-events';
7
import { PreUpdateEvent, PostUpdateEvent, InitializeEvent } from './events';
8
import { BoundingBox } from './collision/bounding-box';
9
import { Logger } from './util/log';
10
import type { ExcaliburGraphicsContext } from './graphics/context/excalibur-graphics-context';
11
import { AffineMatrix } from './math/affine-matrix';
12
import type { EventKey, Handler, Subscription } from './event-emitter';
13
import { EventEmitter } from './event-emitter';
14
import { pixelSnapEpsilon } from './graphics';
15
import { sign } from './math/util';
16
import { WatchVector } from './math/watch-vector';
17
import type { Easing } from './math';
18
import { easeInOutCubic, lerp } from './math';
19

20
/**
21
 * Interface that describes a custom camera strategy for tracking targets
22
 */
23
export interface CameraStrategy<T> {
24
  /**
25
   * Target of the camera strategy that will be passed to the action
26
   */
27
  target: T;
28

29
  /**
30
   * Camera strategies perform an action to calculate a new focus returned out of the strategy
31
   * @param target The target object to apply this camera strategy (if any)
32
   * @param camera The current camera implementation in excalibur running the game
33
   * @param engine The current engine running the game
34
   * @param elapsed The elapsed time in milliseconds since the last frame
35
   */
36
  action: (target: T, camera: Camera, engine: Engine, elapsed: number) => Vector;
37
}
38

39
/**
40
 * Container to house convenience strategy methods
41
 * @internal
42
 */
43
export class StrategyContainer {
44
  constructor(public camera: Camera) {}
1,364✔
45

46
  /**
47
   * Creates and adds the {@apilink LockCameraToActorStrategy} on the current camera.
48
   * @param actor The actor to lock the camera to
49
   */
50
  public lockToActor(actor: Actor) {
51
    this.camera.addStrategy(new LockCameraToActorStrategy(actor));
2✔
52
  }
53

54
  /**
55
   * Creates and adds the {@apilink LockCameraToActorAxisStrategy} on the current camera
56
   * @param actor The actor to lock the camera to
57
   * @param axis The axis to follow the actor on
58
   */
59
  public lockToActorAxis(actor: Actor, axis: Axis) {
60
    this.camera.addStrategy(new LockCameraToActorAxisStrategy(actor, axis));
2✔
61
  }
62

63
  /**
64
   * Creates and adds the {@apilink ElasticToActorStrategy} on the current camera
65
   * If cameraElasticity < cameraFriction < 1.0, the behavior will be a dampened spring that will slowly end at the target without bouncing
66
   * If cameraFriction < cameraElasticity < 1.0, the behavior will be an oscillating spring that will over
67
   * correct and bounce around the target
68
   * @param actor Target actor to elastically follow
69
   * @param cameraElasticity [0 - 1.0] The higher the elasticity the more force that will drive the camera towards the target
70
   * @param cameraFriction [0 - 1.0] The higher the friction the more that the camera will resist motion towards the target
71
   */
72
  public elasticToActor(actor: Actor, cameraElasticity: number, cameraFriction: number) {
73
    this.camera.addStrategy(new ElasticToActorStrategy(actor, cameraElasticity, cameraFriction));
1✔
74
  }
75

76
  /**
77
   * Creates and adds the {@apilink RadiusAroundActorStrategy} on the current camera
78
   * @param actor Target actor to follow when it is "radius" pixels away
79
   * @param radius Number of pixels away before the camera will follow
80
   */
81
  public radiusAroundActor(actor: Actor, radius: number) {
82
    this.camera.addStrategy(new RadiusAroundActorStrategy(actor, radius));
1✔
83
  }
84

85
  /**
86
   * Creates and adds the {@apilink LimitCameraBoundsStrategy} on the current camera
87
   * @param box The bounding box to limit the camera to.
88
   */
89
  public limitCameraBounds(box: BoundingBox) {
90
    this.camera.addStrategy(new LimitCameraBoundsStrategy(box));
1✔
91
  }
92
}
93

94
/**
95
 * Camera axis enum
96
 */
97
export enum Axis {
244✔
98
  X,
244✔
99
  Y
244✔
100
}
101

102
/**
103
 * Lock a camera to the exact x/y position of an actor.
104
 */
105
export class LockCameraToActorStrategy implements CameraStrategy<Actor> {
106
  constructor(public target: Actor) {}
7✔
107
  public action = (target: Actor, camera: Camera, engine: Engine, elapsed: number) => {
7✔
108
    const center = target.center;
3✔
109
    return center;
3✔
110
  };
111
}
112

113
/**
114
 * Lock a camera to a specific axis around an actor.
115
 */
116
export class LockCameraToActorAxisStrategy implements CameraStrategy<Actor> {
117
  constructor(
118
    public target: Actor,
2✔
119
    public axis: Axis
2✔
120
  ) {}
121
  public action = (target: Actor, cam: Camera, _eng: Engine, elapsed: number) => {
2✔
122
    const center = target.center;
6✔
123
    const currentFocus = cam.getFocus();
6✔
124
    if (this.axis === Axis.X) {
6✔
125
      return new Vector(center.x, currentFocus.y);
3✔
126
    } else {
127
      return new Vector(currentFocus.x, center.y);
3✔
128
    }
129
  };
130
}
131

132
/**
133
 * Using [Hook's law](https://en.wikipedia.org/wiki/Hooke's_law), elastically move the camera towards the target actor.
134
 */
135
export class ElasticToActorStrategy implements CameraStrategy<Actor> {
136
  /**
137
   * If cameraElasticity < cameraFriction < 1.0, the behavior will be a dampened spring that will slowly end at the target without bouncing
138
   * If cameraFriction < cameraElasticity < 1.0, the behavior will be an oscillating spring that will over
139
   * correct and bounce around the target
140
   * @param target Target actor to elastically follow
141
   * @param cameraElasticity [0 - 1.0] The higher the elasticity the more force that will drive the camera towards the target
142
   * @param cameraFriction [0 - 1.0] The higher the friction the more that the camera will resist motion towards the target
143
   */
144
  constructor(
145
    public target: Actor,
3✔
146
    public cameraElasticity: number,
3✔
147
    public cameraFriction: number
3✔
148
  ) {}
149
  public action = (target: Actor, cam: Camera, _eng: Engine, elapsed: number) => {
3✔
150
    const position = target.center;
8✔
151
    let focus = cam.getFocus();
8✔
152
    let cameraVel = cam.vel.clone();
8✔
153

154
    // Calculate the stretch vector, using the spring equation
155
    // F = kX
156
    // https://en.wikipedia.org/wiki/Hooke's_law
157
    // Apply to the current camera velocity
158
    const stretch = position.sub(focus).scale(this.cameraElasticity); // stretch is X
8✔
159
    cameraVel = cameraVel.add(stretch);
8✔
160

161
    // Calculate the friction (-1 to apply a force in the opposition of motion)
162
    // Apply to the current camera velocity
163
    const friction = cameraVel.scale(-1).scale(this.cameraFriction);
8✔
164
    cameraVel = cameraVel.add(friction);
8✔
165

166
    // Update position by velocity deltas
167
    focus = focus.add(cameraVel);
8✔
168

169
    return focus;
8✔
170
  };
171
}
172

173
export class RadiusAroundActorStrategy implements CameraStrategy<Actor> {
174
  /**
175
   *
176
   * @param target Target actor to follow when it is "radius" pixels away
177
   * @param radius Number of pixels away before the camera will follow
178
   */
179
  constructor(
180
    public target: Actor,
1✔
181
    public radius: number
1✔
182
  ) {}
183
  public action = (target: Actor, cam: Camera, _eng: Engine, elapsed: number) => {
1✔
184
    const position = target.center;
3✔
185
    const focus = cam.getFocus();
3✔
186

187
    const direction = position.sub(focus);
3✔
188
    const distance = direction.magnitude;
3✔
189
    if (distance >= this.radius) {
3✔
190
      const offset = distance - this.radius;
1✔
191
      return focus.add(direction.normalize().scale(offset));
1✔
192
    }
193
    return focus;
2✔
194
  };
195
}
196

197
/**
198
 * Prevent a camera from going beyond the given camera dimensions.
199
 */
200
export class LimitCameraBoundsStrategy implements CameraStrategy<BoundingBox> {
201
  /**
202
   * Useful for limiting the camera to a {@apilink TileMap}'s dimensions, or a specific area inside the map.
203
   *
204
   * Note that this strategy does not perform any movement by itself.
205
   * It only sets the camera position to within the given bounds when the camera has gone beyond them.
206
   * Thus, it is a good idea to combine it with other camera strategies and set this strategy as the last one.
207
   *
208
   * Make sure that the camera bounds are at least as large as the viewport size.
209
   * @param target The bounding box to limit the camera to
210
   */
211

212
  boundSizeChecked: boolean = false; // Check and warn only once
5✔
213

214
  constructor(public target: BoundingBox) {}
5✔
215

216
  public action = (target: BoundingBox, cam: Camera, _eng: Engine, elapsed: number) => {
5✔
217
    const focus = cam.getFocus();
3✔
218

219
    if (!this.boundSizeChecked) {
3✔
220
      if (target.bottom - target.top < _eng.drawHeight || target.right - target.left < _eng.drawWidth) {
1!
221
        Logger.getInstance().warn('Camera bounds should not be smaller than the engine viewport');
×
222
      }
223
      this.boundSizeChecked = true;
1✔
224
    }
225

226
    let focusX = focus.x;
3✔
227
    let focusY = focus.y;
3✔
228
    if (focus.x < target.left + _eng.halfDrawWidth) {
3✔
229
      focusX = target.left + _eng.halfDrawWidth;
1✔
230
    } else if (focus.x > target.right - _eng.halfDrawWidth) {
2✔
231
      focusX = target.right - _eng.halfDrawWidth;
1✔
232
    }
233

234
    if (focus.y < target.top + _eng.halfDrawHeight) {
3✔
235
      focusY = target.top + _eng.halfDrawHeight;
1✔
236
    } else if (focus.y > target.bottom - _eng.halfDrawHeight) {
2✔
237
      focusY = target.bottom - _eng.halfDrawHeight;
1✔
238
    }
239

240
    return vec(focusX, focusY);
3✔
241
  };
242
}
243

244
export interface CameraEvents {
245
  preupdate: PreUpdateEvent<Camera>;
246
  postupdate: PostUpdateEvent<Camera>;
247
  initialize: InitializeEvent<Camera>;
248
}
249

250
export const CameraEvents = {
244✔
251
  Initialize: 'initialize',
252
  PreUpdate: 'preupdate',
253
  PostUpdate: 'postupdate'
254
};
255

256
/**
257
 * Cameras
258
 *
259
 * {@apilink Camera} is the base class for all Excalibur cameras. Cameras are used
260
 * to move around your game and set focus. They are used to determine
261
 * what is "off screen" and can be used to scale the game.
262
 *
263
 */
264
export class Camera implements CanUpdate, CanInitialize {
265
  public events = new EventEmitter<CameraEvents>();
1,364✔
266
  public transform: AffineMatrix = AffineMatrix.identity();
1,364✔
267
  public inverse: AffineMatrix = AffineMatrix.identity();
1,364✔
268

269
  protected _follow: Actor;
270

271
  private _cameraStrategies: CameraStrategy<any>[] = [];
1,364✔
272
  public get strategies(): CameraStrategy<any>[] {
273
    return this._cameraStrategies;
5✔
274
  }
275

276
  public strategy: StrategyContainer = new StrategyContainer(this);
1,364✔
277

278
  /**
279
   * Get or set current zoom of the camera, defaults to 1
280
   */
281
  private _z = 1;
1,364✔
282
  public get zoom(): number {
283
    return this._z;
23,319✔
284
  }
285

286
  public set zoom(val: number) {
287
    this._z = val;
1,905✔
288
    if (this._engine) {
1,905✔
289
      this._halfWidth = this._engine.halfDrawWidth;
1,897✔
290
      this._halfHeight = this._engine.halfDrawHeight;
1,897✔
291
    }
292
  }
293
  /**
294
   * Get or set rate of change in zoom, defaults to 0
295
   */
296
  public dz: number = 0;
1,364✔
297
  /**
298
   * Get or set zoom acceleration
299
   */
300
  public az: number = 0;
1,364✔
301

302
  /**
303
   * Current rotation of the camera
304
   */
305
  public rotation: number = 0;
1,364✔
306

307
  private _angularVelocity: number = 0;
1,364✔
308

309
  /**
310
   * Get or set the camera's angular velocity
311
   */
312
  public get angularVelocity(): number {
313
    return this._angularVelocity;
1,896✔
314
  }
315

316
  public set angularVelocity(value: number) {
317
    this._angularVelocity = value;
×
318
  }
319

320
  private _posChanged = false;
1,364✔
321
  private _pos: Vector = new WatchVector(Vector.Zero, () => {
1,364✔
322
    this._posChanged = true;
×
323
  });
324
  /**
325
   * Get or set the camera's position
326
   */
327
  public get pos(): Vector {
328
    return this._pos;
23,927✔
329
  }
330
  public set pos(vec: Vector) {
331
    this._posChanged = true;
2,744✔
332
    this._pos = new WatchVector(vec, () => {
2,744✔
333
      this._posChanged = true;
83✔
334
    });
335
  }
336

337
  /**
338
   * Has the position changed since the last update
339
   */
340
  public hasChanged(): boolean {
341
    return this._posChanged;
5,528✔
342
  }
343
  /**
344
   * Interpolated camera position if more draws are running than updates
345
   *
346
   * Enabled when `Engine.fixedUpdateFps` is enabled, in all other cases this will be the same as pos
347
   */
348
  public drawPos: Vector = this.pos.clone();
1,364✔
349

350
  private _oldPos = this.pos.clone();
1,364✔
351

352
  /**
353
   * Get or set the camera's velocity
354
   */
355
  public vel: Vector = Vector.Zero;
1,364✔
356

357
  /**
358
   * Get or set the camera's acceleration
359
   */
360
  public acc: Vector = Vector.Zero;
1,364✔
361

362
  private _cameraMoving: boolean = false;
1,364✔
363
  private _currentLerpTime: number = 0;
1,364✔
364
  private _lerpDuration: number = 1000; // 1 second
1,364✔
365
  private _lerpStart: Vector = null;
1,364✔
366
  private _lerpEnd: Vector = null;
1,364✔
367
  private _lerpResolve: (value: Vector) => void;
368
  private _lerpPromise: Promise<Vector>;
369

370
  //camera effects
371
  protected _isShaking: boolean = false;
1,364✔
372
  private _shakeMagnitudeX: number = 0;
1,364✔
373
  private _shakeMagnitudeY: number = 0;
1,364✔
374
  private _shakeDuration: number = 0;
1,364✔
375
  private _elapsedShakeTime: number = 0;
1,364✔
376
  private _xShake: number = 0;
1,364✔
377
  private _yShake: number = 0;
1,364✔
378

379
  protected _isZooming: boolean = false;
1,364✔
380
  private _zoomStart: number = 1;
1,364✔
381
  private _zoomEnd: number = 1;
1,364✔
382
  private _currentZoomTime: number = 0;
1,364✔
383
  private _zoomDuration: number = 0;
1,364✔
384

385
  private _zoomResolve: (val: boolean) => void;
386
  private _zoomPromise: Promise<boolean>;
387
  private _zoomEasing: Easing = easeInOutCubic;
1,364✔
388
  private _easing: Easing = easeInOutCubic;
1,364✔
389

390
  private _halfWidth: number = 0;
1,364✔
391
  private _halfHeight: number = 0;
1,364✔
392

393
  /**
394
   * Get the camera's x position
395
   */
396
  public get x() {
397
    return this.pos.x;
5,286✔
398
  }
399

400
  /**
401
   * Set the camera's x position (cannot be set when following an {@apilink Actor} or when moving)
402
   */
403
  public set x(value: number) {
404
    if (!this._follow && !this._cameraMoving) {
12!
405
      this.pos = vec(value, this.pos.y);
12✔
406
    }
407
  }
408

409
  /**
410
   * Get the camera's y position
411
   */
412
  public get y() {
413
    return this.pos.y;
5,286✔
414
  }
415

416
  /**
417
   * Set the camera's y position (cannot be set when following an {@apilink Actor} or when moving)
418
   */
419
  public set y(value: number) {
420
    if (!this._follow && !this._cameraMoving) {
11!
421
      this.pos = vec(this.pos.x, value);
11✔
422
    }
423
  }
424

425
  /**
426
   * Get or set the camera's x velocity
427
   */
428
  public get dx() {
429
    return this.vel.x;
2✔
430
  }
431

432
  public set dx(value: number) {
433
    this.vel = vec(value, this.vel.y);
1✔
434
  }
435

436
  /**
437
   * Get or set the camera's y velocity
438
   */
439
  public get dy() {
440
    return this.vel.y;
2✔
441
  }
442

443
  public set dy(value: number) {
444
    this.vel = vec(this.vel.x, value);
1✔
445
  }
446

447
  /**
448
   * Get or set the camera's x acceleration
449
   */
450
  public get ax() {
451
    return this.acc.x;
2✔
452
  }
453

454
  public set ax(value: number) {
455
    this.acc = vec(value, this.acc.y);
1✔
456
  }
457

458
  /**
459
   * Get or set the camera's y acceleration
460
   */
461
  public get ay() {
462
    return this.acc.y;
2✔
463
  }
464

465
  public set ay(value: number) {
466
    this.acc = vec(this.acc.x, value);
1✔
467
  }
468

469
  /**
470
   * Returns the focal point of the camera, a new point giving the x and y position of the camera
471
   */
472
  public getFocus() {
473
    return this.pos;
38✔
474
  }
475

476
  /**
477
   * This moves the camera focal point to the specified position using specified easing function. Cannot move when following an Actor.
478
   * @param pos The target position to move to
479
   * @param duration The duration in milliseconds the move should last
480
   * @param [easingFn] An optional easing function ({@apilink EasingFunctions.EaseInOutCubic} by default)
481
   * @returns A {@apilink Promise} that resolves when movement is finished, including if it's interrupted.
482
   *          The {@apilink Promise} value is the {@apilink Vector} of the target position. It will be rejected if a move cannot be made.
483
   */
484
  public move(pos: Vector, duration: number, easingFn: Easing = easeInOutCubic): Promise<Vector> {
4✔
485
    if (typeof easingFn !== 'function') {
6!
486
      throw 'Please specify an EasingFunction';
×
487
    }
488

489
    // cannot move when following an actor
490
    if (this._follow) {
6!
491
      return Promise.reject(pos);
×
492
    }
493

494
    // resolve existing promise, if any
495
    if (this._lerpPromise && this._lerpResolve) {
6✔
496
      this._lerpResolve(pos);
3✔
497
    }
498

499
    this._lerpPromise = new Promise<Vector>((resolve) => {
6✔
500
      this._lerpResolve = resolve;
6✔
501
    });
502
    this._lerpStart = this.getFocus().clone();
6✔
503
    this._lerpDuration = duration;
6✔
504
    this._lerpEnd = pos;
6✔
505
    this._currentLerpTime = 0;
6✔
506
    this._cameraMoving = true;
6✔
507
    this._easing = easingFn;
6✔
508

509
    return this._lerpPromise;
6✔
510
  }
511

512
  /**
513
   * Sets the camera to shake at the specified magnitudes for the specified duration
514
   * @param magnitudeX  The x magnitude of the shake
515
   * @param magnitudeY  The y magnitude of the shake
516
   * @param duration    The duration of the shake in milliseconds
517
   */
518
  public shake(magnitudeX: number, magnitudeY: number, duration: number) {
519
    this._isShaking = true;
1✔
520
    this._shakeMagnitudeX = magnitudeX;
1✔
521
    this._shakeMagnitudeY = magnitudeY;
1✔
522
    this._shakeDuration = duration;
1✔
523
  }
524

525
  /**
526
   * Zooms the camera in or out by the specified scale over the specified duration.
527
   * If no duration is specified, it take effect immediately.
528
   * @param scale    The scale of the zoom
529
   * @param duration The duration of the zoom in milliseconds
530
   */
531
  public zoomOverTime(scale: number, duration: number = 0, easingFn: Easing = easeInOutCubic): Promise<boolean> {
1!
532
    this._zoomPromise = new Promise<boolean>((resolve) => {
1✔
533
      this._zoomResolve = resolve;
1✔
534
    });
535

536
    if (duration) {
1!
537
      this._isZooming = true;
1✔
538
      this._easing = easingFn;
1✔
539
      this._currentZoomTime = 0;
1✔
540
      this._zoomDuration = duration;
1✔
541
      this._zoomStart = this.zoom;
1✔
542
      this._zoomEnd = scale;
1✔
543
    } else {
544
      this._isZooming = false;
×
545
      this.zoom = scale;
×
546
      return Promise.resolve(true);
×
547
    }
548

549
    return this._zoomPromise;
1✔
550
  }
551

552
  private _viewport: BoundingBox = null;
1,364✔
553
  /**
554
   * Gets the bounding box of the viewport of this camera in world coordinates
555
   */
556
  public get viewport(): BoundingBox {
557
    if (this._viewport) {
2✔
558
      return this._viewport;
1✔
559
    }
560

561
    return new BoundingBox(0, 0, 0, 0);
1✔
562
  }
563

564
  /**
565
   * Adds one or more new camera strategies to this camera
566
   * @param cameraStrategy Instance of an {@apilink CameraStrategy}
567
   */
568
  public addStrategy<T extends CameraStrategy<any>[]>(...cameraStrategies: T) {
569
    this._cameraStrategies.push(...cameraStrategies);
10✔
570
  }
571

572
  /**
573
   * Sets the strategies of this camera, replacing all existing strategies
574
   * @param cameraStrategies Array of {@apilink CameraStrategy}
575
   */
576
  public setStrategies<T extends CameraStrategy<any>[]>(cameraStrategies: T) {
577
    this._cameraStrategies = [...cameraStrategies];
3✔
578
  }
579

580
  /**
581
   * Removes a camera strategy by reference
582
   * @param cameraStrategy Instance of an {@apilink CameraStrategy}
583
   */
584
  public removeStrategy<T>(cameraStrategy: CameraStrategy<T>) {
585
    removeItemFromArray(cameraStrategy, this._cameraStrategies);
1✔
586
  }
587

588
  /**
589
   * Clears all camera strategies from the camera
590
   */
591
  public clearAllStrategies() {
592
    this._cameraStrategies.length = 0;
1✔
593
  }
594

595
  /**
596
   * It is not recommended that internal excalibur methods be overridden, do so at your own risk.
597
   *
598
   * Internal _preupdate handler for {@apilink onPreUpdate} lifecycle event
599
   * @param engine The reference to the current game engine
600
   * @param elapsed  The time elapsed since the last update in milliseconds
601
   * @internal
602
   */
603
  public _preupdate(engine: Engine, elapsed: number): void {
604
    this.events.emit('preupdate', new PreUpdateEvent(engine, elapsed, this));
1,896✔
605
    this.onPreUpdate(engine, elapsed);
1,896✔
606
  }
607

608
  /**
609
   * Safe to override onPreUpdate lifecycle event handler. Synonymous with `.on('preupdate', (evt) =>{...})`
610
   *
611
   * `onPreUpdate` is called directly before a scene is updated.
612
   * @param engine The reference to the current game engine
613
   * @param elapsed  The time elapsed since the last update in milliseconds
614
   */
615
  public onPreUpdate(engine: Engine, elapsed: number): void {
616
    // Overridable
617
  }
618

619
  /**
620
   *  It is not recommended that internal excalibur methods be overridden, do so at your own risk.
621
   *
622
   * Internal _preupdate handler for {@apilink onPostUpdate} lifecycle event
623
   * @param engine The reference to the current game engine
624
   * @param elapsed  The time elapsed since the last update in milliseconds
625
   * @internal
626
   */
627
  public _postupdate(engine: Engine, elapsed: number): void {
628
    this.events.emit('postupdate', new PostUpdateEvent(engine, elapsed, this));
1,896✔
629
    this.onPostUpdate(engine, elapsed);
1,896✔
630
  }
631

632
  /**
633
   * Safe to override onPostUpdate lifecycle event handler. Synonymous with `.on('preupdate', (evt) =>{...})`
634
   *
635
   * `onPostUpdate` is called directly after a scene is updated.
636
   * @param engine The reference to the current game engine
637
   * @param elapsed  The time elapsed since the last update in milliseconds
638
   */
639
  public onPostUpdate(engine: Engine, elapsed: number): void {
640
    // Overridable
641
  }
642

643
  private _engine: Engine;
644
  private _screen: Screen;
645
  private _isInitialized = false;
1,364✔
646
  public get isInitialized() {
647
    return this._isInitialized;
2,627✔
648
  }
649

650
  public _initialize(engine: Engine) {
651
    if (!this.isInitialized) {
2,627✔
652
      this._engine = engine;
740✔
653
      this._screen = engine.screen;
740✔
654

655
      const currentRes = this._screen.contentArea;
740✔
656
      let center = vec(currentRes.width / 2, currentRes.height / 2);
740✔
657
      if (!this._engine.loadingComplete) {
740✔
658
        // If there was a loading screen, we peek the configured resolution
659
        const res = this._screen.peekResolution();
5✔
660
        if (res) {
5✔
661
          center = vec(res.width / 2, res.height / 2);
1✔
662
        }
663
      }
664
      this._halfWidth = center.x;
740✔
665
      this._halfHeight = center.y;
740✔
666

667
      // If the user has not set the camera pos, apply default center screen position
668
      if (!this._posChanged) {
740✔
669
        this.pos = center;
731✔
670
      }
671
      this.pos.clone(this.drawPos);
740✔
672
      // First frame bootstrap
673

674
      // Ensure camera tx is correct
675
      // Run update twice to ensure properties are init'd
676
      this.updateTransform(this.pos);
740✔
677

678
      // Run strategies for first frame
679
      this.runStrategies(engine, engine.clock.elapsed());
740✔
680

681
      // Setup the first frame viewport
682
      this.updateViewport();
740✔
683

684
      // It's important to update the camera after strategies
685
      // This prevents jitter
686
      this.updateTransform(this.pos);
740✔
687
      this.pos.clone(this._oldPos);
740✔
688

689
      this.onInitialize(engine);
740✔
690
      this.events.emit('initialize', new InitializeEvent(engine, this));
740✔
691
      this._isInitialized = true;
740✔
692
    }
693
  }
694

695
  /**
696
   * Safe to override onPostUpdate lifecycle event handler. Synonymous with `.on('preupdate', (evt) =>{...})`
697
   *
698
   * `onPostUpdate` is called directly after a scene is updated.
699
   */
700
  public onInitialize(engine: Engine) {
701
    // Overridable
702
  }
703

704
  public emit<TEventName extends EventKey<CameraEvents>>(eventName: TEventName, event: CameraEvents[TEventName]): void;
705
  public emit(eventName: string, event?: any): void;
706
  public emit<TEventName extends EventKey<CameraEvents> | string>(eventName: TEventName, event?: any): void {
707
    this.events.emit(eventName, event);
×
708
  }
709

710
  public on<TEventName extends EventKey<CameraEvents>>(eventName: TEventName, handler: Handler<CameraEvents[TEventName]>): Subscription;
711
  public on(eventName: string, handler: Handler<unknown>): Subscription;
712
  public on<TEventName extends EventKey<CameraEvents> | string>(eventName: TEventName, handler: Handler<any>): Subscription {
713
    return this.events.on(eventName, handler);
1✔
714
  }
715

716
  public once<TEventName extends EventKey<CameraEvents>>(eventName: TEventName, handler: Handler<CameraEvents[TEventName]>): Subscription;
717
  public once(eventName: string, handler: Handler<unknown>): Subscription;
718
  public once<TEventName extends EventKey<CameraEvents> | string>(eventName: TEventName, handler: Handler<any>): Subscription {
719
    return this.events.once(eventName, handler);
×
720
  }
721

722
  public off<TEventName extends EventKey<CameraEvents>>(eventName: TEventName, handler: Handler<CameraEvents[TEventName]>): void;
723
  public off(eventName: string, handler: Handler<unknown>): void;
724
  public off(eventName: string): void;
725
  public off<TEventName extends EventKey<CameraEvents> | string>(eventName: TEventName, handler?: Handler<any>): void {
726
    this.events.off(eventName, handler);
×
727
  }
728

729
  public runStrategies(engine: Engine, elapsed: number) {
730
    for (const s of this._cameraStrategies) {
2,636✔
731
      this.pos = s.action.call(s, s.target, this, engine, elapsed);
23✔
732
    }
733
  }
734

735
  public updateViewport() {
736
    // recalculate viewport
737
    this._viewport = new BoundingBox(
2,636✔
738
      this.x - this._halfWidth,
739
      this.y - this._halfHeight,
740
      this.x + this._halfWidth,
741
      this.y + this._halfHeight
742
    );
743
  }
744

745
  public update(engine: Engine, elapsed: number) {
746
    this._initialize(engine);
1,896✔
747
    this._preupdate(engine, elapsed);
1,896✔
748
    this.pos.clone(this._oldPos);
1,896✔
749

750
    // Update placements based on linear algebra
751
    this.pos = this.pos.add(this.vel.scale(elapsed / 1000));
1,896✔
752
    this.zoom += (this.dz * elapsed) / 1000;
1,896✔
753

754
    this.vel = this.vel.add(this.acc.scale(elapsed / 1000));
1,896✔
755
    this.dz += (this.az * elapsed) / 1000;
1,896✔
756

757
    this.rotation += (this.angularVelocity * elapsed) / 1000;
1,896✔
758

759
    if (this._isZooming) {
1,896!
760
      if (this._currentZoomTime < this._zoomDuration) {
×
NEW
761
        this.zoom = lerp(this._zoomStart, this._zoomEnd, this._zoomEasing(this._currentZoomTime / this._zoomDuration));
×
762
        this._currentZoomTime += elapsed;
×
763
      } else {
764
        this._isZooming = false;
×
765
        this.zoom = this._zoomEnd;
×
766
        this._currentZoomTime = 0;
×
767
        this._zoomResolve(true);
×
768
      }
769
    }
770

771
    if (this._cameraMoving) {
1,896✔
772
      if (this._currentLerpTime < this._lerpDuration) {
50✔
773
        const lerpPoint = this.pos;
44✔
774
        lerpPoint.x = lerp(this._lerpStart.x, this._lerpEnd.x, this._easing(this._currentLerpTime / this._lerpDuration));
44✔
775
        lerpPoint.y = lerp(this._lerpStart.y, this._lerpEnd.y, this._easing(this._currentLerpTime / this._lerpDuration));
44✔
776

777
        this.pos = lerpPoint;
44✔
778

779
        this._currentLerpTime += elapsed;
44✔
780
      } else {
781
        this.pos = this._lerpEnd;
6✔
782
        const end = this._lerpEnd.clone();
6✔
783

784
        this._lerpStart = null;
6✔
785
        this._lerpEnd = null;
6✔
786
        this._currentLerpTime = 0;
6✔
787
        this._cameraMoving = false;
6✔
788
        // Order matters here, resolve should be last so any chain promises have a clean slate
789
        this._lerpResolve(end);
6✔
790
      }
791
    }
792

793
    if (this._isDoneShaking()) {
1,896!
794
      this._isShaking = false;
1,896✔
795
      this._elapsedShakeTime = 0;
1,896✔
796
      this._shakeMagnitudeX = 0;
1,896✔
797
      this._shakeMagnitudeY = 0;
1,896✔
798
      this._shakeDuration = 0;
1,896✔
799
      this._xShake = 0;
1,896✔
800
      this._yShake = 0;
1,896✔
801
    } else {
802
      this._elapsedShakeTime += elapsed;
×
803
      this._xShake = ((Math.random() * this._shakeMagnitudeX) | 0) + 1;
×
804
      this._yShake = ((Math.random() * this._shakeMagnitudeY) | 0) + 1;
×
805
    }
806

807
    this.runStrategies(engine, elapsed);
1,896✔
808

809
    this.updateViewport();
1,896✔
810

811
    // It's important to update the camera after strategies
812
    // This prevents jitter
813
    this.updateTransform(this.pos);
1,896✔
814
    this._postupdate(engine, elapsed);
1,896✔
815
    this._posChanged = false;
1,896✔
816
  }
817

818
  private _snapPos = vec(0, 0);
1,364✔
819
  /**
820
   * Applies the relevant transformations to the game canvas to "move" or apply effects to the Camera
821
   * @param ctx Canvas context to apply transformations
822
   */
823
  public draw(ctx: ExcaliburGraphicsContext): void {
824
    // default to the current position
825
    this.pos.clone(this.drawPos);
1,224✔
826

827
    // interpolation if fixed update is on
828
    // must happen on the draw, because more draws are potentially happening than updates
829
    if (this._engine.fixedUpdateTimestep) {
1,224✔
830
      const blend = this._engine.currentFrameLagMs / this._engine.fixedUpdateTimestep;
47✔
831
      const interpolatedPos = this.pos.scale(blend).add(this._oldPos.scale(1.0 - blend));
47✔
832
      interpolatedPos.clone(this.drawPos);
47✔
833
      this.updateTransform(interpolatedPos);
47✔
834
    }
835
    // Snap camera to pixel
836
    if (ctx.snapToPixel) {
1,224✔
837
      const snapPos = this.drawPos.clone(this._snapPos);
3✔
838
      snapPos.x = ~~(snapPos.x + pixelSnapEpsilon * sign(snapPos.x));
3✔
839
      snapPos.y = ~~(snapPos.y + pixelSnapEpsilon * sign(snapPos.y));
3✔
840
      snapPos.clone(this.drawPos);
3✔
841
      this.updateTransform(snapPos);
3✔
842
    }
843
    ctx.multiply(this.transform);
1,224✔
844
  }
845

846
  public updateTransform(pos: Vector) {
847
    // center the camera
848
    const newCanvasWidth = this._screen.resolution.width / this.zoom;
3,965✔
849
    const newCanvasHeight = this._screen.resolution.height / this.zoom;
3,965✔
850
    const cameraPos = vec(-pos.x + newCanvasWidth / 2 + this._xShake, -pos.y + newCanvasHeight / 2 + this._yShake);
3,965✔
851

852
    // Calculate camera transform
853
    this.transform.reset();
3,965✔
854

855
    this.transform.scale(this.zoom, this.zoom);
3,965✔
856

857
    // rotate about the focus
858
    this.transform.translate(newCanvasWidth / 2, newCanvasHeight / 2);
3,965✔
859
    this.transform.rotate(this.rotation);
3,965✔
860
    this.transform.translate(-newCanvasWidth / 2, -newCanvasHeight / 2);
3,965✔
861

862
    this.transform.translate(cameraPos.x, cameraPos.y);
3,965✔
863
    this.transform.inverse(this.inverse);
3,965✔
864
  }
865

866
  private _isDoneShaking(): boolean {
867
    return !this._isShaking || this._elapsedShakeTime >= this._shakeDuration;
1,896!
868
  }
869
}
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