• 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

90.74
/src/engine/screen.ts
1
import { vec, Vector } from './math/vector';
2
import { Logger } from './util/log';
3
import type { Camera } from './camera';
4
import type { BrowserEvents } from './util/browser';
5
import { BoundingBox } from './collision/index';
6
import type { ExcaliburGraphicsContext } from './graphics/context/excalibur-graphics-context';
7
import { getPosition } from './util/util';
8
import { ExcaliburGraphicsContextWebGL } from './graphics/context/excalibur-graphics-context-webgl';
9
import { ExcaliburGraphicsContext2DCanvas } from './graphics/context/excalibur-graphics-context-2d-canvas';
10
import { EventEmitter } from './event-emitter';
11

12
/**
13
 * Enum representing the different display modes available to Excalibur.
14
 */
15
export enum DisplayMode {
244✔
16
  /**
17
   * Default, use a specified resolution for the game. Like 800x600 pixels for example.
18
   */
19
  Fixed = 'Fixed',
244✔
20

21
  /**
22
   * Fit the aspect ratio given by the game resolution within the container at all times will fill any gaps with canvas.
23
   * The displayed area outside the aspect ratio is not guaranteed to be on the screen, only the {@apilink Screen.contentArea}
24
   * is guaranteed to be on screen.
25
   */
26
  FitContainerAndFill = 'FitContainerAndFill',
244✔
27

28
  /**
29
   * Fit the aspect ratio given by the game resolution the screen at all times will fill the screen.
30
   * This displayed area outside the aspect ratio is not guaranteed to be on the screen, only the {@apilink Screen.contentArea}
31
   * is guaranteed to be on screen.
32
   */
33
  FitScreenAndFill = 'FitScreenAndFill',
244✔
34

35
  /**
36
   * Fit the viewport to the parent element maintaining aspect ratio given by the game resolution, but zooms in to avoid the black bars
37
   * (letterbox) that would otherwise be present in {@apilink FitContainer}.
38
   *
39
   * **warning** This will clip some drawable area from the user because of the zoom,
40
   * use {@apilink Screen.contentArea} to know the safe to draw area.
41
   */
42
  FitContainerAndZoom = 'FitContainerAndZoom',
244✔
43

44
  /**
45
   * Fit the viewport to the device screen maintaining aspect ratio given by the game resolution, but zooms in to avoid the black bars
46
   * (letterbox) that would otherwise be present in {@apilink FitScreen}.
47
   *
48
   * **warning** This will clip some drawable area from the user because of the zoom,
49
   * use {@apilink Screen.contentArea} to know the safe to draw area.
50
   */
51
  FitScreenAndZoom = 'FitScreenAndZoom',
244✔
52

53
  /**
54
   * Fit to screen using as much space as possible while maintaining aspect ratio and resolution.
55
   * This is not the same as {@apilink Screen.enterFullscreen} but behaves in a similar way maintaining aspect ratio.
56
   *
57
   * You may want to center your game here is an example
58
   * ```html
59
   * <!-- html -->
60
   * <body>
61
   * <main>
62
   *   <canvas id="game"></canvas>
63
   * </main>
64
   * </body>
65
   * ```
66
   *
67
   * ```css
68
   * // css
69
   * main {
70
   *   display: flex;
71
   *   align-items: center;
72
   *   justify-content: center;
73
   *   height: 100%;
74
   *   width: 100%;
75
   * }
76
   * ```
77
   */
78
  FitScreen = 'FitScreen',
244✔
79

80
  /**
81
   * Fill the entire screen's css width/height for the game resolution dynamically. This means the resolution of the game will
82
   * change dynamically as the window is resized. This is not the same as {@apilink Screen.enterFullscreen}
83
   */
84
  FillScreen = 'FillScreen',
244✔
85

86
  /**
87
   * Fit to parent element width/height using as much space as possible while maintaining aspect ratio and resolution.
88
   */
89
  FitContainer = 'FitContainer',
244✔
90

91
  /**
92
   * Use the parent DOM container's css width/height for the game resolution dynamically
93
   */
94
  FillContainer = 'FillContainer'
244✔
95
}
96

97
/**
98
 * Convenience class for quick resolutions
99
 * Mostly sourced from https://emulation.gametechwiki.com/index.php/Resolution
100
 */
101
export class Resolution {
102
  /* istanbul ignore next */
103
  public static get SVGA(): Resolution {
104
    return { width: 800, height: 600 };
105
  }
106

107
  /* istanbul ignore next */
108
  public static get Standard(): Resolution {
109
    return { width: 1920, height: 1080 };
110
  }
111

112
  /* istanbul ignore next */
113
  public static get Atari2600(): Resolution {
114
    return { width: 160, height: 192 };
115
  }
116

117
  /* istanbul ignore next */
118
  public static get GameBoy(): Resolution {
119
    return { width: 160, height: 144 };
120
  }
121

122
  /* istanbul ignore next */
123
  public static get GameBoyAdvance(): Resolution {
124
    return { width: 240, height: 160 };
125
  }
126

127
  /* istanbul ignore next */
128
  public static get NintendoDS(): Resolution {
129
    return { width: 256, height: 192 };
130
  }
131

132
  /* istanbul ignore next */
133
  public static get NES(): Resolution {
134
    return { width: 256, height: 224 };
135
  }
136

137
  /* istanbul ignore next */
138
  public static get SNES(): Resolution {
139
    return { width: 256, height: 244 };
140
  }
141
}
142

143
export type ViewportUnit = 'pixel' | 'percent';
144

145
export interface Resolution {
146
  width: number;
147
  height: number;
148
}
149

150
export interface ViewportDimension {
151
  widthUnit?: ViewportUnit;
152
  heightUnit?: ViewportUnit;
153
  width: number;
154
  height: number;
155
}
156

157
export interface ScreenOptions {
158
  /**
159
   * Canvas element to build a screen on
160
   */
161
  canvas: HTMLCanvasElement;
162

163
  /**
164
   * Graphics context for the screen
165
   */
166
  context: ExcaliburGraphicsContext;
167

168
  /**
169
   * Browser abstraction
170
   */
171
  browser: BrowserEvents;
172
  /**
173
   * Optionally set antialiasing, defaults to true. If set to true, images will be smoothed
174
   */
175
  antialiasing?: boolean;
176

177
  /**
178
   * Optionally set the image rendering CSS hint on the canvas element, default is auto
179
   */
180
  canvasImageRendering?: 'auto' | 'pixelated';
181
  /**
182
   * Optionally override the pixel ratio to use for the screen, otherwise calculated automatically from the browser
183
   */
184
  pixelRatio?: number;
185
  /**
186
   * Optionally specify the actual pixel resolution in width/height pixels (also known as logical resolution), by default the
187
   * resolution will be the same as the viewport. Resolution will be overridden by {@apilink DisplayMode.FillContainer} and
188
   * {@apilink DisplayMode.FillScreen}.
189
   */
190
  resolution?: Resolution;
191
  /**
192
   * Visual viewport size in css pixel, if resolution is not specified it will be the same as the viewport
193
   */
194
  viewport: ViewportDimension;
195
  /**
196
   * Set the display mode of the screen, by default DisplayMode.Fixed.
197
   */
198
  displayMode?: DisplayMode;
199
}
200

201
/**
202
 * Fires when the screen resizes, useful if you have logic that needs to be aware of resolution/viewport constraints
203
 */
204
export interface ScreenResizeEvent {
205
  /**
206
   * Current viewport in css pixels of the screen
207
   */
208
  viewport: ViewportDimension;
209
  /**
210
   * Current resolution in world pixels of the screen
211
   */
212
  resolution: Resolution;
213
}
214

215
/**
216
 * Fires when the pixel ratio changes, useful to know if you've moved to a hidpi screen or back
217
 */
218
export interface PixelRatioChangeEvent {
219
  /**
220
   * Current pixel ratio of the screen
221
   */
222
  pixelRatio: number;
223
}
224

225
/**
226
 * Fires when the browser fullscreen api is successfully engaged or disengaged
227
 */
228
export interface FullScreenChangeEvent {
229
  /**
230
   * Current fullscreen state
231
   */
232
  fullscreen: boolean;
233
}
234

235
/**
236
 * Built in events supported by all entities
237
 */
238
export interface ScreenEvents {
239
  /**
240
   * Fires when the screen resizes, useful if you have logic that needs to be aware of resolution/viewport constraints
241
   */
242
  resize: ScreenResizeEvent;
243
  /**
244
   * Fires when the pixel ratio changes, useful to know if you've moved to a hidpi screen or back
245
   */
246
  pixelratio: PixelRatioChangeEvent;
247
  /**
248
   * Fires when the browser fullscreen api is successfully engaged or disengaged
249
   */
250
  fullscreen: FullScreenChangeEvent;
251
}
252

253
export const ScreenEvents = {
244✔
254
  ScreenResize: 'resize',
255
  PixelRatioChange: 'pixelratio',
256
  FullScreenChange: 'fullscreen'
257
} as const;
258

259
/**
260
 * The Screen handles all aspects of interacting with the screen for Excalibur.
261
 */
262
export class Screen {
263
  public graphicsContext: ExcaliburGraphicsContext;
264
  /**
265
   * Listen to screen events {@apilink ScreenEvents}
266
   */
267
  public events = new EventEmitter<ScreenEvents>();
791✔
268
  private _canvas: HTMLCanvasElement;
269
  private _antialiasing: boolean = true;
791✔
270
  private _canvasImageRendering: 'auto' | 'pixelated' = 'auto';
791✔
271
  private _contentResolution: Resolution;
272
  private _browser: BrowserEvents;
273
  private _camera: Camera;
274
  private _resolution: Resolution;
275
  private _resolutionStack: Resolution[] = [];
791✔
276
  private _viewport: ViewportDimension;
277
  private _viewportStack: ViewportDimension[] = [];
791✔
278
  private _pixelRatioOverride: number | null = null;
791✔
279
  private _displayMode: DisplayMode;
280
  private _isFullscreen = false;
791✔
281
  private _mediaQueryList: MediaQueryList;
282
  private _isDisposed = false;
791✔
283
  private _logger = Logger.getInstance();
791✔
284
  private _resizeObserver: ResizeObserver;
285

286
  constructor(options: ScreenOptions) {
287
    this.viewport = options.viewport;
791✔
288
    this.resolution = options.resolution ?? { ...this.viewport };
791✔
289
    this._contentResolution = this.resolution;
791✔
290
    this._displayMode = options.displayMode ?? DisplayMode.Fixed;
791✔
291
    this._canvas = options.canvas;
791✔
292
    this.graphicsContext = options.context;
791✔
293
    this._antialiasing = options.antialiasing ?? this._antialiasing;
791✔
294
    this._canvasImageRendering = options.canvasImageRendering ?? this._canvasImageRendering;
791✔
295
    this._browser = options.browser;
791✔
296
    this._pixelRatioOverride = options.pixelRatio;
791✔
297

298
    this._applyDisplayMode();
791✔
299

300
    this._listenForPixelRatio();
791✔
301

302
    this._canvas.addEventListener('fullscreenchange', this._fullscreenChangeHandler);
791✔
303
    this.applyResolutionAndViewport();
791✔
304
  }
305

306
  private _listenForPixelRatio() {
307
    if (this._mediaQueryList && !this._mediaQueryList.addEventListener) {
791!
308
      // Safari <=13.1 workaround, remove any existing handlers
309
      this._mediaQueryList.removeListener(this._pixelRatioChangeHandler);
×
310
    }
311
    this._mediaQueryList = this._browser.window.nativeComponent.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
791✔
312

313
    // Safari <=13.1 workaround
314
    if (this._mediaQueryList.addEventListener) {
791!
315
      this._mediaQueryList.addEventListener('change', this._pixelRatioChangeHandler, { once: true });
791✔
316
    } else {
317
      this._mediaQueryList.addListener(this._pixelRatioChangeHandler);
×
318
    }
319
  }
320

321
  public dispose(): void {
322
    if (!this._isDisposed) {
746!
323
      // Clean up handlers
324
      this._isDisposed = true;
746✔
325
      this.events.clear();
746✔
326
      this._browser.window.off('resize', this._resizeHandler);
746✔
327
      this._browser.window.clear();
746✔
328
      if (this._resizeObserver) {
746!
329
        this._resizeObserver.disconnect();
×
330
      }
331
      this.parent.removeEventListener('resize', this._resizeHandler);
746✔
332
      // Safari <=13.1 workaround
333
      if (this._mediaQueryList.removeEventListener) {
746!
334
        this._mediaQueryList.removeEventListener('change', this._pixelRatioChangeHandler);
746✔
335
      } else {
336
        this._mediaQueryList.removeListener(this._pixelRatioChangeHandler);
×
337
      }
338
      this._canvas.removeEventListener('fullscreenchange', this._fullscreenChangeHandler);
746✔
339
      this._canvas = null;
746✔
340
    }
341
  }
342

343
  private _fullscreenChangeHandler = () => {
791✔
344
    if (this._isDisposed) {
2!
345
      return;
×
346
    }
347
    this._isFullscreen = !this._isFullscreen;
2✔
348
    this._logger.debug('Fullscreen Change', this._isFullscreen);
2✔
349
    this.events.emit('fullscreen', {
2✔
350
      fullscreen: this.isFullscreen
351
    } satisfies FullScreenChangeEvent);
352
  };
353

354
  private _pixelRatioChangeHandler = () => {
791✔
355
    if (this._isDisposed) {
×
356
      return;
×
357
    }
358
    this._logger.debug('Pixel Ratio Change', window.devicePixelRatio);
×
359
    this._listenForPixelRatio();
×
360
    this._devicePixelRatio = this._calculateDevicePixelRatio();
×
361
    this.applyResolutionAndViewport();
×
362

363
    this.events.emit('pixelratio', {
×
364
      pixelRatio: this.pixelRatio
365
    } satisfies PixelRatioChangeEvent);
366
  };
367

368
  private _resizeHandler = () => {
791✔
369
    if (this._isDisposed) {
126✔
370
      return;
28✔
371
    }
372
    const parent = this.parent;
98✔
373
    this._logger.debug('View port resized');
98✔
374
    this._setResolutionAndViewportByDisplayMode(parent);
98✔
375
    this.applyResolutionAndViewport();
98✔
376

377
    // Emit resize event
378
    this.events.emit('resize', {
98✔
379
      resolution: this.resolution,
380
      viewport: this.viewport
381
    } satisfies ScreenResizeEvent);
382
  };
383

384
  private _calculateDevicePixelRatio() {
385
    if (window.devicePixelRatio < 1) {
791!
386
      return 1;
×
387
    }
388

389
    const devicePixelRatio = window.devicePixelRatio || 1;
791!
390

391
    return devicePixelRatio;
791✔
392
  }
393

394
  // Asking the window.devicePixelRatio is expensive we do it once
395
  private _devicePixelRatio = this._calculateDevicePixelRatio();
791✔
396

397
  /**
398
   * Returns the computed pixel ratio, first using any override, then the device pixel ratio
399
   */
400
  public get pixelRatio(): number {
401
    if (this._pixelRatioOverride) {
5,440✔
402
      return this._pixelRatioOverride;
4,899✔
403
    }
404

405
    return this._devicePixelRatio;
541✔
406
  }
407

408
  /**
409
   * This calculates the ratio between excalibur pixels and the HTML pixels.
410
   *
411
   * This is useful for scaling HTML UI so that it matches your game.
412
   */
413
  public get worldToPagePixelRatio(): number {
414
    if (this._canvas) {
937!
415
      const pageOrigin = this.worldToPageCoordinates(Vector.Zero);
937✔
416
      const pageDistance = this.worldToPageCoordinates(vec(1, 0)).sub(pageOrigin);
937✔
417
      const pixelConversion = pageDistance.x;
937✔
418
      return pixelConversion;
937✔
419
    } else {
420
      return 1;
×
421
    }
422
  }
423

424
  /**
425
   * Get or set the pixel ratio override
426
   *
427
   * You will need to call applyResolutionAndViewport() affect change on the screen
428
   */
429
  public get pixelRatioOverride(): number | undefined {
430
    return this._pixelRatioOverride;
2✔
431
  }
432

433
  public set pixelRatioOverride(value: number | undefined) {
434
    this._pixelRatioOverride = value;
1✔
435
  }
436

437
  public get isHiDpi() {
438
    return this.pixelRatio !== 1;
3✔
439
  }
440

441
  public get displayMode(): DisplayMode {
442
    return this._displayMode;
10,340✔
443
  }
444

445
  public get canvas(): HTMLCanvasElement {
446
    return this._canvas;
1,892✔
447
  }
448

449
  public get parent(): HTMLElement | Window {
450
    switch (this.displayMode) {
3,227!
451
      case DisplayMode.FillContainer:
452
      case DisplayMode.FitContainer:
453
      case DisplayMode.FitContainerAndFill:
454
      case DisplayMode.FitContainerAndZoom:
455
        return this.canvas.parentElement || document.body;
40!
456
      default:
457
        return window;
3,187✔
458
    }
459
  }
460

461
  public get resolution(): Resolution {
462
    return this._resolution;
28,230✔
463
  }
464

465
  public set resolution(resolution: Resolution) {
466
    this._resolution = resolution;
871✔
467
  }
468

469
  /**
470
   * Returns screen dimensions in pixels or percentage
471
   */
472
  public get viewport(): ViewportDimension {
473
    if (this._viewport) {
8,212!
474
      return this._viewport;
8,212✔
475
    }
476
    return this._resolution;
×
477
  }
478

479
  public set viewport(viewport: ViewportDimension) {
480
    this._viewport = viewport;
911✔
481
  }
482

483
  public get aspectRatio() {
484
    return this._resolution.width / this._resolution.height;
73✔
485
  }
486

487
  public get scaledWidth() {
488
    return this._resolution.width * this.pixelRatio;
1,719✔
489
  }
490

491
  public get scaledHeight() {
492
    return this._resolution.height * this.pixelRatio;
1,719✔
493
  }
494

495
  public setCurrentCamera(camera: Camera) {
496
    this._camera = camera;
700✔
497
  }
498

499
  public pushResolutionAndViewport() {
500
    this._resolutionStack.push(this.resolution);
17✔
501
    this._viewportStack.push(this.viewport);
17✔
502

503
    this.resolution = { ...this.resolution };
17✔
504
    this.viewport = { ...this.viewport };
17✔
505
  }
506

507
  public peekViewport(): ViewportDimension {
508
    return this._viewportStack[this._viewportStack.length - 1];
×
509
  }
510

511
  public peekResolution(): Resolution {
512
    return this._resolutionStack[this._resolutionStack.length - 1];
5✔
513
  }
514

515
  public popResolutionAndViewport() {
516
    if (this._resolutionStack.length && this._viewportStack.length) {
15!
517
      this.resolution = this._resolutionStack.pop();
15✔
518
      this.viewport = this._viewportStack.pop();
15✔
519
    }
520
  }
521

522
  public applyResolutionAndViewport() {
523
    if (this.graphicsContext instanceof ExcaliburGraphicsContextWebGL) {
934✔
524
      const scaledResolutionSupported = this.graphicsContext.checkIfResolutionSupported({
780✔
525
        width: this.scaledWidth,
526
        height: this.scaledHeight
527
      });
528
      if (!scaledResolutionSupported) {
780✔
529
        this._logger.warnOnce(
2✔
530
          `The currently configured resolution (${this.resolution.width}x${this.resolution.height}) and pixel ratio (${this.pixelRatio})` +
531
            ' are too large for the platform WebGL implementation, this may work but cause WebGL rendering to behave oddly.' +
532
            ' Try reducing the resolution or disabling Hi DPI scaling to avoid this' +
533
            ' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).'
534
        );
535

536
        // Attempt to recover if the user hasn't configured a specific ratio for up scaling
537
        if (!this.pixelRatioOverride) {
2✔
538
          let currentPixelRatio = Math.max(1, this.pixelRatio - 0.5);
1✔
539
          let newResolutionSupported = false;
1✔
540
          while (currentPixelRatio > 1 && !newResolutionSupported) {
1✔
541
            currentPixelRatio = Math.max(1, currentPixelRatio - 0.5);
1✔
542
            const width = this._resolution.width * currentPixelRatio;
1✔
543
            const height = this._resolution.height * currentPixelRatio;
1✔
544
            newResolutionSupported = this.graphicsContext.checkIfResolutionSupported({ width, height });
1✔
545
          }
546
          this.pixelRatioOverride = currentPixelRatio;
1✔
547
          this._logger.warnOnce(
1✔
548
            'Scaled resolution too big attempted recovery!' +
549
              ` Pixel ratio was automatically reduced to (${this.pixelRatio}) to avoid 4k texture limit.` +
550
              ' Setting `ex.Engine({pixelRatio: ...}) will override any automatic recalculation, do so at your own risk.` ' +
551
              ' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).'
552
          );
553
        }
554
      }
555
    }
556

557
    this._canvas.width = this.scaledWidth;
934✔
558
    this._canvas.height = this.scaledHeight;
934✔
559

560
    if (this._canvasImageRendering === 'auto') {
934✔
561
      this._canvas.style.imageRendering = 'auto';
164✔
562
    } else {
563
      this._canvas.style.imageRendering = 'pixelated';
770✔
564
      // Fall back to 'crisp-edges' if 'pixelated' is not supported
565
      // Currently for firefox https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering
566
      if (this._canvas.style.imageRendering === '') {
770✔
567
        this._canvas.style.imageRendering = 'crisp-edges';
2✔
568
      }
569
    }
570

571
    const widthUnit = this.viewport.widthUnit === 'percent' ? '%' : 'px';
934✔
572
    const heightUnit = this.viewport.heightUnit === 'percent' ? '%' : 'px';
934✔
573

574
    this._canvas.style.width = this.viewport.width + widthUnit;
934✔
575
    this._canvas.style.height = this.viewport.height + heightUnit;
934✔
576

577
    // After messing with the canvas width/height the graphics context is invalidated and needs to have some properties reset
578
    this.graphicsContext.updateViewport(this.resolution);
934✔
579
    this.graphicsContext.resetTransform();
934✔
580
    this.graphicsContext.smoothing = this._antialiasing;
934✔
581
    if (this.graphicsContext instanceof ExcaliburGraphicsContext2DCanvas) {
934✔
582
      this.graphicsContext.scale(this.pixelRatio, this.pixelRatio);
154✔
583
    }
584
    // Add the excalibur world pixel to page pixel
585
    document.documentElement.style.setProperty('--ex-pixel-ratio', this.worldToPagePixelRatio.toString());
934✔
586
  }
587

588
  /**
589
   * Get or set screen antialiasing,
590
   *
591
   * If true smoothing is applied
592
   */
593
  public get antialiasing() {
594
    return this._antialiasing;
847✔
595
  }
596

597
  /**
598
   * Get or set screen antialiasing
599
   */
600
  public set antialiasing(isSmooth: boolean) {
601
    this._antialiasing = isSmooth;
1,673✔
602
    this.graphicsContext.smoothing = this._antialiasing;
1,673✔
603
  }
604

605
  /**
606
   * Returns true if excalibur is fullscreen using the browser fullscreen api
607
   */
608
  public get isFullscreen() {
609
    return this._isFullscreen;
7✔
610
  }
611

612
  /**
613
   * Requests to enter fullscreen using the browser fullscreen api, requires user interaction to be successful.
614
   * For example, wire this to a user click handler.
615
   *
616
   * Optionally specify a target element id to go fullscreen, by default the game canvas is used
617
   * @param elementId
618
   */
619
  public enterFullscreen(elementId?: string): Promise<void> {
620
    if (elementId) {
2✔
621
      const maybeElement = document.getElementById(elementId);
1✔
622
      // workaround for safari partial support
623
      if (maybeElement?.requestFullscreen || (maybeElement as any)?.webkitRequestFullscreen) {
1!
624
        if (!maybeElement.getAttribute('ex-fullscreen-listener')) {
1!
625
          maybeElement.setAttribute('ex-fullscreen-listener', 'true');
1✔
626
          maybeElement.addEventListener('fullscreenchange', this._fullscreenChangeHandler);
1✔
627
        }
628
        if (maybeElement.requestFullscreen) {
1!
629
          return maybeElement.requestFullscreen() ?? Promise.resolve();
1!
630
        } else if ((maybeElement as any).webkitRequestFullscreen) {
×
631
          return (maybeElement as any).webkitRequestFullscreen() ?? Promise.resolve();
×
632
        }
633
      }
634
    }
635
    if (this._canvas?.requestFullscreen) {
1!
636
      return this._canvas?.requestFullscreen() ?? Promise.resolve();
1!
637
    } else if ((this._canvas as any).webkitRequestFullscreen) {
×
638
      return (this._canvas as any).webkitRequestFullscreen() ?? Promise.resolve();
×
639
    }
640
    this._logger.warnOnce('Could not go fullscreen, is this an iPhone? Currently Apple does not support fullscreen on iPhones');
×
641
    return Promise.resolve();
×
642
  }
643

644
  public exitFullscreen(): Promise<void> {
UNCOV
645
    return document.exitFullscreen();
×
646
  }
647

648
  private _viewportToPixels(viewport: ViewportDimension) {
649
    return {
3,333✔
650
      width: viewport.widthUnit === 'percent' ? this.canvas.offsetWidth : viewport.width,
3,333✔
651
      height: viewport.heightUnit === 'percent' ? this.canvas.offsetHeight : viewport.height
3,333✔
652
    } satisfies ViewportDimension;
653
  }
654

655
  /**
656
   * Takes a coordinate in normal html page space, for example from a pointer move event, and translates it to
657
   * Excalibur screen space.
658
   *
659
   * Excalibur screen space starts at the top left (0, 0) corner of the viewport, and extends to the
660
   * bottom right corner (resolutionX, resolutionY). When using *AndFill suffixed display modes screen space
661
   * (0, 0) is the top left of the safe content area bounding box not the viewport.
662
   * @param point
663
   */
664
  public pageToScreenCoordinates(point: Vector): Vector {
665
    let newX = point.x;
1,425✔
666
    let newY = point.y;
1,425✔
667

668
    if (!this._isFullscreen) {
1,425✔
669
      newX -= getPosition(this._canvas).x;
1,423✔
670
      newY -= getPosition(this._canvas).y;
1,423✔
671
    }
672

673
    const viewport = this._viewportToPixels(this.viewport);
1,425✔
674

675
    // if fullscreen api on it centers with black bars
676
    // we need to adjust the screen to world coordinates in this case
677
    if (this._isFullscreen) {
1,425✔
678
      if (window.innerWidth / this.aspectRatio < window.innerHeight) {
2✔
679
        const screenHeight = window.innerWidth / this.aspectRatio;
1✔
680
        const screenMarginY = (window.innerHeight - screenHeight) / 2;
1✔
681
        newY = ((newY - screenMarginY) / screenHeight) * viewport.height;
1✔
682
        newX = (newX / window.innerWidth) * viewport.width;
1✔
683
      } else {
684
        const screenWidth = window.innerHeight * this.aspectRatio;
1✔
685
        const screenMarginX = (window.innerWidth - screenWidth) / 2;
1✔
686
        newX = ((newX - screenMarginX) / screenWidth) * viewport.width;
1✔
687
        newY = (newY / window.innerHeight) * viewport.height;
1✔
688
      }
689
    }
690

691
    newX = (newX / viewport.width) * this.resolution.width;
1,425✔
692
    newY = (newY / viewport.height) * this.resolution.height;
1,425✔
693

694
    // offset by content area
695
    newX = newX - this.contentArea.left;
1,425✔
696
    newY = newY - this.contentArea.top;
1,425✔
697

698
    return new Vector(newX, newY);
1,425✔
699
  }
700

701
  /**
702
   * Takes a coordinate in Excalibur screen space, and translates it to normal html page space. For example,
703
   * this is where html elements might live if you want to position them relative to Excalibur.
704
   *
705
   * Excalibur screen space starts at the top left (0, 0) corner of the viewport, and extends to the
706
   * bottom right corner (resolutionX, resolutionY)
707
   * @param point
708
   */
709
  public screenToPageCoordinates(point: Vector): Vector {
710
    let newX = point.x;
1,908✔
711
    let newY = point.y;
1,908✔
712

713
    // no need to offset by content area, drawing is already offset by this
714

715
    const viewport = this._viewportToPixels(this.viewport);
1,908✔
716

717
    newX = (newX / this.resolution.width) * viewport.width;
1,908✔
718
    newY = (newY / this.resolution.height) * viewport.height;
1,908✔
719

720
    if (this._isFullscreen) {
1,908✔
721
      if (window.innerWidth / this.aspectRatio < window.innerHeight) {
2✔
722
        const screenHeight = window.innerWidth / this.aspectRatio;
1✔
723
        const screenMarginY = (window.innerHeight - screenHeight) / 2;
1✔
724
        newY = (newY / viewport.height) * screenHeight + screenMarginY;
1✔
725
        newX = (newX / viewport.width) * window.innerWidth;
1✔
726
      } else {
727
        const screenWidth = window.innerHeight * this.aspectRatio;
1✔
728
        const screenMarginX = (window.innerWidth - screenWidth) / 2;
1✔
729
        newX = (newX / viewport.width) * screenWidth + screenMarginX;
1✔
730
        newY = (newY / viewport.height) * window.innerHeight;
1✔
731
      }
732
    }
733

734
    if (!this._isFullscreen) {
1,908✔
735
      newX += getPosition(this._canvas).x;
1,906✔
736
      newY += getPosition(this._canvas).y;
1,906✔
737
    }
738

739
    return new Vector(newX, newY);
1,908✔
740
  }
741

742
  /**
743
   * Takes a coordinate in Excalibur screen space, and translates it to Excalibur world space.
744
   *
745
   * World space is where {@apilink Entity | `entities`} in Excalibur live by default {@apilink CoordPlane.World}
746
   * and extends infinitely out relative from the {@apilink Camera}.
747
   * @param point  Screen coordinate to convert
748
   */
749
  public screenToWorldCoordinates(point: Vector): Vector {
750
    // offset by content area
751
    point = point.add(vec(this.contentArea.left, this.contentArea.top));
1,433✔
752

753
    // the only difference between screen & world is the camera transform
754
    if (this._camera) {
1,433✔
755
      return this._camera.inverse.multiply(point);
1,426✔
756
    }
757
    return point.sub(vec(this.resolution.width / 2, this.resolution.height / 2));
7✔
758
  }
759

760
  /**
761
   * Takes a coordinate in Excalibur world space, and translates it to Excalibur screen space.
762
   *
763
   * Screen space is where {@apilink ScreenElement | `screen elements`} and {@apilink Entity | `entities`} with {@apilink CoordPlane.Screen} live.
764
   * @param point  World coordinate to convert
765
   */
766
  public worldToScreenCoordinates(point: Vector): Vector {
767
    if (this._camera) {
1,915✔
768
      return this._camera.transform.multiply(point);
42✔
769
    }
770
    return point.add(vec(this.resolution.width / 2, this.resolution.height / 2));
1,873✔
771
  }
772

773
  public pageToWorldCoordinates(point: Vector): Vector {
774
    const screen = this.pageToScreenCoordinates(point);
1✔
775
    return this.screenToWorldCoordinates(screen);
1✔
776
  }
777

778
  public worldToPageCoordinates(point: Vector): Vector {
779
    const screen = this.worldToScreenCoordinates(point);
1,901✔
780
    return this.screenToPageCoordinates(screen);
1,901✔
781
  }
782

783
  /**
784
   * Returns a BoundingBox of the top left corner of the screen
785
   * and the bottom right corner of the screen.
786
   *
787
   * World bounds are in world coordinates, useful for culling objects offscreen that are in world space
788
   */
789
  public getWorldBounds(): BoundingBox {
790
    const bounds = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Half)
960✔
791
      .scale(vec(1 / this._camera.zoom, 1 / this._camera.zoom))
792
      .rotate(this._camera.rotation)
793
      .translate(this._camera.drawPos);
794
    return bounds;
960✔
795
  }
796

797
  /**
798
   * Returns a BoundingBox of the top left corner of the screen and the bottom right corner of the screen.
799
   *
800
   * Screen bounds are in screen coordinates, useful for culling objects offscreen that are in screen space
801
   */
802
  public getScreenBounds(): BoundingBox {
803
    const bounds = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero, Vector.Zero);
×
804
    return bounds;
×
805
  }
806

807
  /**
808
   * The width of the game canvas in pixels (physical width component of the
809
   * resolution of the canvas element)
810
   */
811
  public get canvasWidth(): number {
812
    return this.canvas.width;
837✔
813
  }
814

815
  /**
816
   * Returns half width of the game canvas in pixels (half physical width component)
817
   */
818
  public get halfCanvasWidth(): number {
819
    return this.canvas.width / 2;
3✔
820
  }
821

822
  /**
823
   * The height of the game canvas in pixels, (physical height component of
824
   * the resolution of the canvas element)
825
   */
826
  public get canvasHeight(): number {
827
    return this.canvas.height;
837✔
828
  }
829

830
  /**
831
   * Returns half height of the game canvas in pixels (half physical height component)
832
   */
833
  public get halfCanvasHeight(): number {
834
    return this.canvas.height / 2;
3✔
835
  }
836

837
  /**
838
   * Returns the width of the engine's visible drawing surface in pixels including zoom and device pixel ratio.
839
   */
840
  public get drawWidth(): number {
841
    if (this._camera) {
1,927✔
842
      return this.resolution.width / this._camera.zoom;
1,821✔
843
    }
844
    return this.resolution.width;
106✔
845
  }
846

847
  /**
848
   * Returns the width of the engine's visible drawing surface in pixels including zoom and device pixel ratio.
849
   */
850
  public get width(): number {
851
    if (this._camera) {
×
852
      return this.resolution.width / this._camera.zoom;
×
853
    }
854
    return this.resolution.width;
×
855
  }
856

857
  /**
858
   * Returns half the width of the engine's visible drawing surface in pixels including zoom and device pixel ratio.
859
   */
860
  public get halfDrawWidth(): number {
861
    return this.drawWidth / 2;
1,922✔
862
  }
863

864
  /**
865
   * Returns the height of the engine's visible drawing surface in pixels including zoom and device pixel ratio.
866
   */
867
  public get drawHeight(): number {
868
    if (this._camera) {
1,927✔
869
      return this.resolution.height / this._camera.zoom;
1,821✔
870
    }
871
    return this.resolution.height;
106✔
872
  }
873

874
  public get height(): number {
875
    if (this._camera) {
×
876
      return this.resolution.height / this._camera.zoom;
×
877
    }
878
    return this.resolution.height;
×
879
  }
880

881
  /**
882
   * Returns half the height of the engine's visible drawing surface in pixels including zoom and device pixel ratio.
883
   */
884
  public get halfDrawHeight(): number {
885
    return this.drawHeight / 2;
1,922✔
886
  }
887

888
  /**
889
   * Returns screen center coordinates including zoom and device pixel ratio.
890
   */
891
  public get center(): Vector {
892
    return vec(this.halfDrawWidth, this.halfDrawHeight);
16✔
893
  }
894

895
  /**
896
   * Returns the content area in screen space where it is safe to place content
897
   */
898
  public get contentArea(): BoundingBox {
899
    return this._contentArea;
6,523✔
900
  }
901

902
  /**
903
   * Returns the unsafe area in screen space, this is the full screen and some space may not be onscreen.
904
   */
905
  public get unsafeArea(): BoundingBox {
906
    return this._unsafeArea;
10✔
907
  }
908

909
  private _contentArea: BoundingBox = new BoundingBox();
791✔
910
  private _unsafeArea: BoundingBox = new BoundingBox();
791✔
911

912
  private _computeFit() {
913
    document.body.style.margin = '0px';
34✔
914
    document.body.style.overflow = 'hidden';
34✔
915
    const aspect = this.aspectRatio;
34✔
916
    let adjustedWidth = 0;
34✔
917
    let adjustedHeight = 0;
34✔
918
    if (window.innerWidth / aspect < window.innerHeight) {
34✔
919
      adjustedWidth = window.innerWidth;
15✔
920
      adjustedHeight = window.innerWidth / aspect;
15✔
921
    } else {
922
      adjustedWidth = window.innerHeight * aspect;
19✔
923
      adjustedHeight = window.innerHeight;
19✔
924
    }
925

926
    this.viewport = {
34✔
927
      width: adjustedWidth,
928
      height: adjustedHeight
929
    };
930
    this._contentArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero);
34✔
931
    this._unsafeArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero);
34✔
932
    this.events.emit('resize', {
34✔
933
      resolution: this.resolution,
934
      viewport: this.viewport
935
    } satisfies ScreenResizeEvent);
936
  }
937

938
  private _computeFitScreenAndFill() {
939
    document.body.style.margin = '0px';
20✔
940
    document.body.style.overflow = 'hidden';
20✔
941
    const vw = window.innerWidth;
20✔
942
    const vh = window.innerHeight;
20✔
943
    this._computeFitAndFill(vw, vh);
20✔
944
    this.events.emit('resize', {
20✔
945
      resolution: this.resolution,
946
      viewport: this.viewport
947
    } satisfies ScreenResizeEvent);
948
  }
949

950
  private _computeFitContainerAndFill() {
951
    this.canvas.style.width = '100%';
6✔
952
    this.canvas.style.height = '100%';
6✔
953

954
    this._computeFitAndFill(this.canvas.offsetWidth, this.canvas.offsetHeight, {
6✔
955
      width: 100,
956
      widthUnit: 'percent',
957
      height: 100,
958
      heightUnit: 'percent'
959
    });
960
    this.events.emit('resize', {
6✔
961
      resolution: this.resolution,
962
      viewport: this.viewport
963
    } satisfies ScreenResizeEvent);
964
  }
965

966
  private _computeFitAndFill(vw: number, vh: number, viewport?: ViewportDimension) {
967
    this.viewport = viewport ?? {
26✔
968
      width: vw,
969
      height: vh
970
    };
971
    // if the current screen aspectRatio is less than the original aspectRatio
972
    if (vw / vh <= this._contentResolution.width / this._contentResolution.height) {
26✔
973
      // compute new resolution to match the original aspect ratio
974
      this.resolution = {
8✔
975
        width: (vw * this._contentResolution.width) / vw,
976
        height: (((vw * this._contentResolution.width) / vw) * vh) / vw
977
      };
978
      const clip = (this.resolution.height - this._contentResolution.height) / 2;
8✔
979
      this._contentArea = new BoundingBox({
8✔
980
        top: clip,
981
        left: 0,
982
        right: this._contentResolution.width,
983
        bottom: this.resolution.height - clip
984
      });
985
      this._unsafeArea = new BoundingBox({
8✔
986
        top: -clip,
987
        left: 0,
988
        right: this._contentResolution.width,
989
        bottom: this.resolution.height + clip
990
      });
991
    } else {
992
      this.resolution = {
18✔
993
        width: (((vh * this._contentResolution.height) / vh) * vw) / vh,
994
        height: (vh * this._contentResolution.height) / vh
995
      };
996
      const clip = (this.resolution.width - this._contentResolution.width) / 2;
18✔
997
      this._contentArea = new BoundingBox({
18✔
998
        top: 0,
999
        left: clip,
1000
        right: this.resolution.width - clip,
1001
        bottom: this._contentResolution.height
1002
      });
1003
      this._unsafeArea = new BoundingBox({
18✔
1004
        top: 0,
1005
        left: -clip,
1006
        right: this.resolution.width + clip,
1007
        bottom: this._contentResolution.height
1008
      });
1009
    }
1010
  }
1011

1012
  private _computeFitScreenAndZoom() {
1013
    document.body.style.margin = '0px';
12✔
1014
    document.body.style.overflow = 'hidden';
12✔
1015
    this.canvas.style.position = 'absolute';
12✔
1016

1017
    const vw = window.innerWidth;
12✔
1018
    const vh = window.innerHeight;
12✔
1019

1020
    this._computeFitAndZoom(vw, vh);
12✔
1021
    this.events.emit('resize', {
12✔
1022
      resolution: this.resolution,
1023
      viewport: this.viewport
1024
    } satisfies ScreenResizeEvent);
1025
  }
1026

1027
  private _computeFitContainerAndZoom() {
1028
    this.canvas.style.width = '100%';
6✔
1029
    this.canvas.style.height = '100%';
6✔
1030
    this.canvas.style.position = 'relative';
6✔
1031
    const parent = this.canvas.parentElement;
6✔
1032
    parent.style.overflow = 'hidden';
6✔
1033
    const { offsetWidth: vw, offsetHeight: vh } = this.canvas;
6✔
1034

1035
    this._computeFitAndZoom(vw, vh);
6✔
1036
    this.events.emit('resize', {
6✔
1037
      resolution: this.resolution,
1038
      viewport: this.viewport
1039
    } satisfies ScreenResizeEvent);
1040
  }
1041

1042
  private _computeFitAndZoom(vw: number, vh: number) {
1043
    const aspect = this.aspectRatio;
18✔
1044
    let adjustedWidth = 0;
18✔
1045
    let adjustedHeight = 0;
18✔
1046
    if (vw / aspect < vh) {
18✔
1047
      adjustedWidth = vw;
5✔
1048
      adjustedHeight = vw / aspect;
5✔
1049
    } else {
1050
      adjustedWidth = vh * aspect;
13✔
1051
      adjustedHeight = vh;
13✔
1052
    }
1053

1054
    const scaleX = vw / adjustedWidth;
18✔
1055
    const scaleY = vh / adjustedHeight;
18✔
1056

1057
    const maxScaleFactor = Math.max(scaleX, scaleY);
18✔
1058

1059
    const zoomedWidth = adjustedWidth * maxScaleFactor;
18✔
1060
    const zoomedHeight = adjustedHeight * maxScaleFactor;
18✔
1061

1062
    // Center zoomed dimension if bigger than the screen
1063
    if (zoomedWidth > vw) {
18✔
1064
      this.canvas.style.left = -(zoomedWidth - vw) / 2 + 'px';
5✔
1065
    } else {
1066
      this.canvas.style.left = '';
13✔
1067
    }
1068

1069
    if (zoomedHeight > vh) {
18✔
1070
      this.canvas.style.top = -(zoomedHeight - vh) / 2 + 'px';
11✔
1071
    } else {
1072
      this.canvas.style.top = '';
7✔
1073
    }
1074

1075
    this.viewport = {
18✔
1076
      width: zoomedWidth,
1077
      height: zoomedHeight
1078
    };
1079

1080
    const bounds = BoundingBox.fromDimension(this.viewport.width, this.viewport.height, Vector.Zero);
18✔
1081
    // return safe area
1082
    if (this.viewport.width > vw) {
18✔
1083
      const clip = ((this.viewport.width - vw) / this.viewport.width) * this.resolution.width;
5✔
1084
      bounds.top = 0;
5✔
1085
      bounds.left = clip / 2;
5✔
1086
      bounds.right = this.resolution.width - clip / 2;
5✔
1087
      bounds.bottom = this.resolution.height;
5✔
1088
    }
1089

1090
    if (this.viewport.height > vh) {
18✔
1091
      const clip = ((this.viewport.height - vh) / this.viewport.height) * this.resolution.height;
11✔
1092
      bounds.top = clip / 2;
11✔
1093
      bounds.left = 0;
11✔
1094
      bounds.bottom = this.resolution.height - clip / 2;
11✔
1095
      bounds.right = this.resolution.width;
11✔
1096
    }
1097
    this._contentArea = bounds;
18✔
1098
  }
1099

1100
  private _computeFitContainer() {
1101
    const aspect = this.aspectRatio;
6✔
1102
    let adjustedWidth = 0;
6✔
1103
    let adjustedHeight = 0;
6✔
1104
    let widthUnit: ViewportUnit = 'pixel';
6✔
1105
    let heightUnit: ViewportUnit = 'pixel';
6✔
1106
    const parent = this.canvas.parentElement;
6✔
1107
    if (parent.clientWidth / aspect < parent.clientHeight) {
6✔
1108
      this.canvas.style.width = '100%';
1✔
1109
      adjustedWidth = 100;
1✔
1110
      widthUnit = 'percent';
1✔
1111
      adjustedHeight = this.canvas.offsetWidth / aspect;
1✔
1112
    } else {
1113
      this.canvas.style.height = '100%';
5✔
1114
      adjustedHeight = 100;
5✔
1115
      heightUnit = 'percent';
5✔
1116
      adjustedWidth = this.canvas.offsetHeight * aspect;
5✔
1117
    }
1118

1119
    this.viewport = {
6✔
1120
      width: adjustedWidth,
1121
      widthUnit,
1122
      height: adjustedHeight,
1123
      heightUnit
1124
    };
1125
    this._contentArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero);
6✔
1126
    this.events.emit('resize', {
6✔
1127
      resolution: this.resolution,
1128
      viewport: this.viewport
1129
    } satisfies ScreenResizeEvent);
1130
  }
1131

1132
  private _applyDisplayMode() {
1133
    this._setResolutionAndViewportByDisplayMode(this.parent);
791✔
1134

1135
    // watch resizing
1136
    if (this.parent instanceof Window) {
791✔
1137
      this._browser.window.on('resize', this._resizeHandler);
785✔
1138
    } else {
1139
      this._resizeObserver = new ResizeObserver(() => {
6✔
1140
        this._resizeHandler();
6✔
1141
      });
1142
      this._resizeObserver.observe(this.parent);
6✔
1143
    }
1144
    this.parent.addEventListener('resize', this._resizeHandler);
791✔
1145
  }
1146

1147
  /**
1148
   * Sets the resolution and viewport based on the selected display mode.
1149
   */
1150
  private _setResolutionAndViewportByDisplayMode(parent: HTMLElement | Window) {
1151
    if (this.displayMode === DisplayMode.FillContainer) {
889!
1152
      this.canvas.style.width = '100%';
×
1153
      this.canvas.style.height = '100%';
×
1154
      this.viewport = {
×
1155
        width: 100,
1156
        widthUnit: 'percent',
1157
        height: 100,
1158
        heightUnit: 'percent'
1159
      };
1160
      this.resolution = {
×
1161
        width: this.canvas.offsetWidth,
1162
        height: this.canvas.offsetHeight
1163
      };
1164
    }
1165

1166
    if (this.displayMode === DisplayMode.FillScreen) {
889✔
1167
      document.body.style.margin = '0px';
3✔
1168
      document.body.style.overflow = 'hidden';
3✔
1169
      this.resolution = {
3✔
1170
        width: (<Window>parent).innerWidth,
1171
        height: (<Window>parent).innerHeight
1172
      };
1173

1174
      this.viewport = this.resolution;
3✔
1175
    }
1176

1177
    this._contentArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero);
889✔
1178

1179
    if (this.displayMode === DisplayMode.FitScreen) {
889✔
1180
      this._computeFit();
34✔
1181
    }
1182

1183
    if (this.displayMode === DisplayMode.FitContainer) {
889✔
1184
      this._computeFitContainer();
6✔
1185
    }
1186

1187
    if (this.displayMode === DisplayMode.FitScreenAndFill) {
889✔
1188
      this._computeFitScreenAndFill();
20✔
1189
    }
1190

1191
    if (this.displayMode === DisplayMode.FitContainerAndFill) {
889✔
1192
      this._computeFitContainerAndFill();
6✔
1193
    }
1194

1195
    if (this.displayMode === DisplayMode.FitScreenAndZoom) {
889✔
1196
      this._computeFitScreenAndZoom();
12✔
1197
    }
1198

1199
    if (this.displayMode === DisplayMode.FitContainerAndZoom) {
889✔
1200
      this._computeFitContainerAndZoom();
6✔
1201
    }
1202
  }
1203
}
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