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

excaliburjs / Excalibur / 28138031111

25 Jun 2026 12:13AM UTC coverage: 88.906% (-0.03%) from 88.94%
28138031111

push

github

web-flow
fix: double screen resize (#3779)

* fix: double screen resize

* fix lint

* fix tests

* update mock

* fix lint

6878 of 9023 branches covered (76.23%)

2 of 3 new or added lines in 1 file covered. (66.67%)

5 existing lines in 1 file now uncovered.

15515 of 17451 relevant lines covered (88.91%)

24865.74 hits per line

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

92.9
/src/engine/director/loader.ts
1
import { Color } from '../color';
2
import type { Loadable } from '../interfaces/loadable';
3
import * as DrawUtil from '../util/draw-util';
4

5
import logoImg from './Loader.logo.png';
6
import loaderCss from './Loader.css?inline';
7
import type { Vector } from '../math/vector';
8
import { delay } from '../util/util';
9
import { EventEmitter } from '../event-emitter';
10
import type { DefaultLoaderOptions } from './default-loader';
11
import { DefaultLoader } from './default-loader';
12
import type { Engine } from '../engine';
13
import type { Screen } from '../screen';
14
import { Logger } from '../util/log';
15
import { Future } from '../util/future';
16

17
export interface LoaderOptions extends DefaultLoaderOptions {
18
  /**
19
   * Go fullscreen after loading and clicking play
20
   */
21
  fullscreenAfterLoad?: boolean;
22
  /**
23
   * Fullscreen container element or id
24
   */
25
  fullscreenContainer?: HTMLElement | string;
26
}
27

28
/**
29
 * Pre-loading assets
30
 *
31
 * The loader provides a mechanism to preload multiple resources at
32
 * one time. The loader must be passed to the engine in order to
33
 * trigger the loading progress bar.
34
 *
35
 * The {@apilink Loader} itself implements {@apilink Loadable} so you can load loaders.
36
 *
37
 * ## Example: Pre-loading resources for a game
38
 *
39
 * ```js
40
 * // create a loader
41
 * var loader = new ex.Loader();
42
 *
43
 * // create a resource dictionary (best practice is to keep a separate file)
44
 * var resources = {
45
 *   TextureGround: new ex.Texture("/images/textures/ground.png"),
46
 *   SoundDeath: new ex.Sound("/sound/death.wav", "/sound/death.mp3")
47
 * };
48
 *
49
 * // loop through dictionary and add to loader
50
 * for (var loadable in resources) {
51
 *   if (resources.hasOwnProperty(loadable)) {
52
 *     loader.addResource(resources[loadable]);
53
 *   }
54
 * }
55
 *
56
 * // start game
57
 * game.start(loader).then(function () {
58
 *   console.log("Game started!");
59
 * });
60
 * ```
61
 *
62
 * ## Customize the Loader
63
 *
64
 * The loader can be customized to show different, text, logo, background color, and button.
65
 *
66
 * ```typescript
67
 * const loader = new ex.Loader([playerTexture]);
68
 *
69
 * // The loaders button text can simply modified using this
70
 * loader.playButtonText = 'Start the best game ever';
71
 *
72
 * // The logo can be changed by inserting a base64 image string here
73
 *
74
 * loader.logo = 'data:image/png;base64,iVBORw...';
75
 * loader.logoWidth = 15;
76
 * loader.logoHeight = 14;
77
 *
78
 * // The background color can be changed like so by supplying a valid CSS color string
79
 *
80
 * loader.backgroundColor = 'red'
81
 * loader.backgroundColor = '#176BAA'
82
 *
83
 * // To build a completely new button
84
 * loader.startButtonFactory = () => {
85
 *     let myButton = document.createElement('button');
86
 *     myButton.textContent = 'The best button';
87
 *     return myButton;
88
 * };
89
 *
90
 * engine.start(loader).then(() => {});
91
 * ```
92
 */
93
export class Loader extends DefaultLoader {
94
  private _logger = Logger.getInstance();
607✔
95
  private static _DEFAULT_LOADER_OPTIONS: LoaderOptions = {
254✔
96
    loadables: [],
97
    fullscreenAfterLoad: false,
98
    fullscreenContainer: undefined
99
  };
100
  private _originalOptions: LoaderOptions = { loadables: [] };
607✔
101
  public events = new EventEmitter();
607✔
102
  public screen!: Screen;
103
  private _playButtonShown: boolean = false;
607✔
104

105
  // logo drawing stuff
106

107
  // base64 string encoding of the excalibur logo (logo-white.png)
108
  public logo = logoImg;
607✔
109
  public logoWidth = 468;
607✔
110
  public logoHeight = 118;
607✔
111
  /**
112
   * Positions the top left corner of the logo image
113
   * If not set, the loader automatically positions the logo
114
   */
115
  public logoPosition!: Vector | null;
116
  /**
117
   * Positions the top left corner of the play button.
118
   * If not set, the loader automatically positions the play button
119
   */
120
  public playButtonPosition!: Vector | null;
121
  /**
122
   * Positions the top left corner of the loading bar
123
   * If not set, the loader automatically positions the loading bar
124
   */
125
  public loadingBarPosition!: Vector | null;
126

127
  /**
128
   * Gets or sets the color of the loading bar, default is {@apilink Color.White}
129
   */
130
  public loadingBarColor: Color = Color.White;
607✔
131

132
  /**
133
   * Gets or sets the background color of the loader as a hex string
134
   */
135
  public backgroundColor: string = '#176BAA';
607✔
136

137
  protected _imageElement!: HTMLImageElement;
138
  protected _imageLoaded: Future<void> = new Future();
607✔
139
  protected get _image() {
140
    if (!this._imageElement) {
857✔
141
      this._imageElement = new Image();
22✔
142
      this._imageElement.onload = () => this._imageLoaded.resolve();
22✔
143
      this._imageElement.src = this.logo;
22✔
144
    }
145

146
    return this._imageElement;
857✔
147
  }
148

149
  public suppressPlayButton: boolean = false;
607✔
150
  public get playButtonRootElement(): HTMLElement | null {
151
    return this._playButtonRootElement;
7✔
152
  }
153
  public get playButtonElement(): HTMLButtonElement | null {
154
    return this._playButtonElement;
×
155
  }
156
  protected _playButtonRootElement!: HTMLElement;
157
  protected _playButtonElement!: HTMLButtonElement;
158
  protected _styleBlock!: HTMLStyleElement;
159
  /** Loads the css from Loader.css */
160
  protected _playButtonStyles: string = loaderCss;
607✔
161
  protected get _playButton() {
162
    const existingRoot = document.getElementById('excalibur-play-root');
106✔
163
    if (existingRoot) {
106✔
164
      this._playButtonRootElement = existingRoot;
90✔
165
    }
166
    if (!this._playButtonRootElement) {
106✔
167
      this._playButtonRootElement = document.createElement('div');
16✔
168
      this._playButtonRootElement.id = 'excalibur-play-root';
16✔
169
      this._playButtonRootElement.style.position = 'absolute';
16✔
170
      document.body.appendChild(this._playButtonRootElement);
16✔
171
    }
172
    if (!this._styleBlock) {
106✔
173
      this._styleBlock = document.createElement('style');
24✔
174
      this._styleBlock.textContent = this._playButtonStyles;
24✔
175
      document.head.appendChild(this._styleBlock);
24✔
176
    }
177
    if (!this._playButtonElement) {
106✔
178
      this._playButtonElement = this.startButtonFactory();
24✔
179
      this._playButtonRootElement.appendChild(this._playButtonElement);
24✔
180
    }
181
    return this._playButtonElement;
106✔
182
  }
183

184
  /**
185
   * Get/set play button text
186
   */
187
  public playButtonText: string = 'Play game';
607✔
188

189
  /**
190
   * Return a html button element for excalibur to use as a play button
191
   */
192
  public startButtonFactory = () => {
607✔
193
    let buttonElement: HTMLButtonElement = document.getElementById('excalibur-play') as HTMLButtonElement;
24✔
194
    if (!buttonElement) {
24✔
195
      buttonElement = document.createElement('button');
16✔
196
    }
197

198
    buttonElement.id = 'excalibur-play';
24✔
199
    buttonElement.style.display = 'none';
24✔
200

201
    if (buttonElement) {
24!
202
      let [span, text] = buttonElement.getElementsByTagName('span');
24✔
203

204
      if (!span) {
24✔
205
        span ??= document.createElement('span');
16✔
206
        buttonElement.appendChild(span);
16✔
207
      }
208
      span.id = 'excalibur-play-icon';
24✔
209

210
      if (!text) {
24✔
211
        text ??= document.createElement('span');
16✔
212
        buttonElement.appendChild(text);
16✔
213
      }
214
      text.id = 'excalibur-play-text';
24✔
215
      text.textContent = this.playButtonText;
24✔
216
    }
217
    return buttonElement;
24✔
218
  };
219

220
  /**
221
   * @param options Optionally provide options to loader
222
   */
223
  constructor(options?: LoaderOptions);
224
  /**
225
   * @param loadables  Optionally provide the list of resources you want to load at constructor time
226
   */
227
  constructor(loadables?: Loadable<any>[]);
228
  constructor(loadablesOrOptions?: Loadable<any>[] | LoaderOptions) {
229
    const options = Array.isArray(loadablesOrOptions)
607✔
230
      ? {
231
          loadables: loadablesOrOptions
232
        }
233
      : loadablesOrOptions;
234
    super(options);
607✔
235
    this._originalOptions = { ...Loader._DEFAULT_LOADER_OPTIONS, ...options };
607✔
236
  }
237

238
  public override onInitialize(engine: Engine): void {
239
    this.engine = engine;
24✔
240
    this.screen = engine.screen;
24✔
241
    this.canvas.width = this.engine.canvas.width;
24✔
242
    this.canvas.height = this.engine.canvas.height;
24✔
243
    this.screen.events.on('resize', () => {
24✔
UNCOV
244
      this.canvas.width = this.engine.canvas.width;
×
UNCOV
245
      this.canvas.height = this.engine.canvas.height;
×
246
    });
247
  }
248

249
  /**
250
   * Shows the play button and returns a promise that resolves when clicked
251
   */
252
  public async showPlayButton(): Promise<void> {
253
    if (this.suppressPlayButton) {
25✔
254
      this.hidePlayButton();
14✔
255
      // Delay is to give the logo a chance to show, otherwise don't delay
256
      await delay(500, this.engine?.clock);
14!
257
    } else {
258
      const resizeHandler = () => {
11✔
259
        try {
10✔
260
          this._positionPlayButton();
10✔
261
        } catch {
262
          // swallow if can't position
263
        }
264
      };
265
      if (this.engine?.browser) {
11✔
266
        this.engine.browser.window.on('resize', resizeHandler);
6✔
267
      }
268
      this._playButtonShown = true;
11✔
269
      this._playButton.style.display = 'flex';
11✔
270
      document.body.addEventListener('keyup', (evt: KeyboardEvent) => {
11✔
271
        if (evt.key === 'Enter') {
6!
272
          this._playButton.click();
6✔
273
        }
274
      });
275
      this._positionPlayButton();
11✔
276
      const playButtonClicked = new Promise<void>((resolve) => {
11✔
277
        const startButtonHandler = (e: Event) => {
11✔
278
          // We want to stop propagation to keep bubbling to the engine pointer handlers
279
          e.stopPropagation();
25✔
280
          // Hide Button after click
281
          this.hidePlayButton();
25✔
282
          if (this.engine?.browser) {
25✔
283
            this.engine.browser.window.off('resize', resizeHandler);
14✔
284
          }
285

286
          if (this._originalOptions.fullscreenAfterLoad) {
25!
287
            try {
×
288
              this._logger.info('requesting fullscreen');
×
289
              if (this._originalOptions.fullscreenContainer instanceof HTMLElement) {
×
290
                // eslint-disable-next-line @typescript-eslint/no-floating-promises
291
                this._originalOptions.fullscreenContainer.requestFullscreen();
×
292
              } else {
293
                // eslint-disable-next-line @typescript-eslint/no-floating-promises
294
                this.engine.screen.enterFullscreen(this._originalOptions.fullscreenContainer);
×
295
              }
296
            } catch (error) {
297
              this._logger.error('could not go fullscreen', error);
×
298
            }
299
          }
300

301
          resolve();
25✔
302
        };
303
        this._playButton.addEventListener('click', startButtonHandler);
11✔
304
        this._playButton.addEventListener('touchend', startButtonHandler);
11✔
305
        this._playButton.addEventListener('pointerup', startButtonHandler);
11✔
306
        if (this.engine) {
11✔
307
          this.engine.input.gamepads.once('button', () => startButtonHandler(new Event('button')));
6✔
308
        }
309
      });
310

311
      return await playButtonClicked;
11✔
312
    }
313
  }
314

315
  public hidePlayButton() {
316
    this._playButtonShown = false;
39✔
317
    this._playButton.style.display = 'none';
39✔
318
  }
319

320
  /**
321
   * Clean up generated elements for the loader
322
   */
323
  public dispose() {
324
    if (this._playButtonRootElement.parentElement) {
16!
325
      this._playButtonRootElement.removeChild(this._playButtonElement);
16✔
326
      document.body.removeChild(this._playButtonRootElement);
16✔
327
      document.head.removeChild(this._styleBlock);
16✔
328
      this._playButtonRootElement = null as any;
16✔
329
      this._playButtonElement = null as any;
16✔
330
      this._styleBlock = null as any;
16✔
331
    }
332
  }
333

334
  data!: Loadable<any>[];
335

336
  public override async onUserAction(): Promise<void> {
337
    // short delay in showing the button for aesthetics
338
    await delay(200, this.engine?.clock);
15!
339
    this.canvas.flagDirty();
15✔
340
    // show play button
341
    await this.showPlayButton();
15✔
342
  }
343

344
  public override async onBeforeLoad(): Promise<void> {
345
    this.screen.pushResolutionAndViewport();
15✔
346
    this.screen.resolution = { width: this.screen.resolution.width, height: this.screen.resolution.height };
15✔
347
    this.screen.applyResolutionAndViewport();
15✔
348
    const image = this._image;
15✔
349
    await this._imageLoaded.promise;
15✔
350
    await image?.decode(); // decode logo if it exists
15✔
351
  }
352

353
  // eslint-disable-next-line require-await
354
  public override async onAfterLoad(): Promise<void> {
355
    this.screen.popResolutionAndViewport();
14✔
356
    this.screen.applyResolutionAndViewport();
14✔
357
    this.dispose();
14✔
358
  }
359

360
  private _positionPlayButton() {
361
    if (this.engine) {
21✔
362
      const { x: left, y: top, width: screenWidth, height: screenHeight } = this.engine.canvas.getBoundingClientRect();
16✔
363
      if (this._playButtonRootElement && this._playButtonElement) {
8!
364
        const text = this._playButtonElement.querySelector('#excalibur-play-text')! as HTMLElement;
8✔
365
        if (screenWidth < 450) {
8!
UNCOV
366
          text.style.display = 'none';
×
367
        } else {
368
          text.style.display = 'inline-block';
8✔
369
        }
370

371
        const buttonWidth = this._playButton.clientWidth;
8✔
372
        const buttonHeight = this._playButton.clientHeight;
8✔
373
        if (this.playButtonPosition) {
8✔
374
          this._playButtonRootElement.style.left = `${this.playButtonPosition.x}px`;
1✔
375
          this._playButtonRootElement.style.top = `${this.playButtonPosition.y}px`;
1✔
376
        } else {
377
          this._playButtonRootElement.style.left = `${left + screenWidth / 2 - buttonWidth / 2}px`;
7✔
378
          this._playButtonRootElement.style.top = `${top + screenHeight / 2 - buttonHeight / 2 + 100}px`;
7✔
379
        }
380

381
        if (screenWidth < 450) {
8!
UNCOV
382
          this._playButtonRootElement.style.left = `${left + screenWidth / 2 - buttonWidth / 2}px`;
×
UNCOV
383
          this._playButtonRootElement.style.top = `${top + screenHeight / 2 - buttonHeight / 2 + 25}px`;
×
384
        }
385
      }
386
    }
387
  }
388

389
  /**
390
   * Loader draw function. Draws the default Excalibur loading screen.
391
   * Override `logo`, `logoWidth`, `logoHeight` and `backgroundColor` properties
392
   * to customize the drawing, or just override entire method.
393
   */
394
  public override onDraw(ctx: CanvasRenderingContext2D) {
395
    const canvasHeight = this.engine.canvasHeight / this.engine.pixelRatio;
836✔
396
    const canvasWidth = this.engine.canvasWidth / this.engine.pixelRatio;
836✔
397

398
    ctx.fillStyle = this.backgroundColor;
836✔
399
    ctx.fillRect(0, 0, canvasWidth, canvasHeight);
836✔
400

401
    let logoY = canvasHeight / 2;
836✔
402
    const width = Math.min(this.logoWidth, canvasWidth * 0.75);
836✔
403
    let logoX = canvasWidth / 2 - width / 2;
836✔
404

405
    if (this.logoPosition) {
836✔
406
      logoX = this.logoPosition.x;
1✔
407
      logoY = this.logoPosition.y;
1✔
408
    }
409

410
    const imageHeight = Math.floor(width * (this.logoHeight / this.logoWidth)); // OG height/width factor
836✔
411
    const oldAntialias = this.engine.screen.antialiasing;
836✔
412
    this.engine.screen.antialiasing = true;
836✔
413
    if (!this.logoPosition) {
836✔
414
      ctx.drawImage(this._image, 0, 0, this.logoWidth, this.logoHeight, logoX, logoY - imageHeight - 20, width, imageHeight);
835✔
415
    } else {
416
      ctx.drawImage(this._image, 0, 0, this.logoWidth, this.logoHeight, logoX, logoY, width, imageHeight);
1✔
417
    }
418

419
    // loading box
420
    if (!this.suppressPlayButton && this._playButtonShown) {
836✔
421
      this.engine.screen.antialiasing = oldAntialias;
3✔
422
      return;
3✔
423
    }
424

425
    let loadingX = logoX;
833✔
426
    let loadingY = logoY;
833✔
427
    if (this.loadingBarPosition) {
833✔
428
      loadingX = this.loadingBarPosition.x;
1✔
429
      loadingY = this.loadingBarPosition.y;
1✔
430
    }
431

432
    ctx.lineWidth = 2;
833✔
433
    DrawUtil.roundRect(ctx, loadingX, loadingY, width, 20, 10, this.loadingBarColor);
833✔
434
    const progress = width * this.progress;
833✔
435
    const margin = 5;
833✔
436
    const progressWidth = progress - margin * 2;
833✔
437
    const height = 20 - margin * 2;
833✔
438
    DrawUtil.roundRect(
833✔
439
      ctx,
440
      loadingX + margin,
441
      loadingY + margin,
442
      progressWidth > 10 ? progressWidth : 10,
833✔
443
      height,
444
      5,
445
      null,
446
      this.loadingBarColor
447
    );
448
    this.engine.screen.antialiasing = oldAntialias;
833✔
449
  }
450
}
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