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

excaliburjs / Excalibur / 16102459346

06 Jul 2025 07:31PM UTC coverage: 87.937%. Remained the same
16102459346

push

github

web-flow
Merge pull request #3340 from excaliburjs/renovate/major-eslint-monorepo

chore: Update dependency eslint to v9

5145 of 7132 branches covered (72.14%)

13931 of 15842 relevant lines covered (87.94%)

24810.62 hits per line

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

95.42
/src/engine/Director/Loader.ts
1
import { Color } from '../Color';
2
import type { Loadable } from '../Interfaces/Loadable';
3
import * as DrawUtil from '../Util/DrawUtil';
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 '../EventEmitter';
10
import type { DefaultLoaderOptions } from './DefaultLoader';
11
import { DefaultLoader } from './DefaultLoader';
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 {
119✔
94
  private _logger = Logger.getInstance();
587✔
95
  private static _DEFAULT_LOADER_OPTIONS: LoaderOptions = {
96
    loadables: [],
97
    fullscreenAfterLoad: false,
98
    fullscreenContainer: undefined
99
  };
100
  private _originalOptions: LoaderOptions = { loadables: [] };
587✔
101
  public events = new EventEmitter();
587✔
102
  public screen!: Screen;
103
  private _playButtonShown: boolean = false;
587✔
104

105
  // logo drawing stuff
106

107
  // base64 string encoding of the excalibur logo (logo-white.png)
108
  public logo = logoImg;
587✔
109
  public logoWidth = 468;
587✔
110
  public logoHeight = 118;
587✔
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;
587✔
131

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

137
  protected _imageElement!: HTMLImageElement;
138
  protected _imageLoaded: Future<void> = new Future();
587✔
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;
587✔
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;
587✔
161
  protected get _playButton() {
162
    const existingRoot = document.getElementById('excalibur-play-root');
140✔
163
    if (existingRoot) {
140✔
164
      this._playButtonRootElement = existingRoot;
124✔
165
    }
166
    if (!this._playButtonRootElement) {
140✔
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) {
140✔
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) {
140✔
178
      this._playButtonElement = this.startButtonFactory();
24✔
179
      this._playButtonRootElement.appendChild(this._playButtonElement);
24✔
180
    }
181
    return this._playButtonElement;
140✔
182
  }
183

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

189
  /**
190
   * Return a html button element for excalibur to use as a play button
191
   */
192
  public startButtonFactory = () => {
587✔
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.textContent = this.playButtonText;
24✔
200
    buttonElement.style.display = 'none';
24✔
201
    return buttonElement;
24✔
202
  };
203

204
  /**
205
   * @param options Optionally provide options to loader
206
   */
207
  constructor(options?: LoaderOptions);
208
  /**
209
   * @param loadables  Optionally provide the list of resources you want to load at constructor time
210
   */
211
  constructor(loadables?: Loadable<any>[]);
212
  constructor(loadablesOrOptions?: Loadable<any>[] | LoaderOptions) {
213
    const options = Array.isArray(loadablesOrOptions)
587✔
214
      ? {
215
          loadables: loadablesOrOptions
216
        }
217
      : loadablesOrOptions;
218
    super(options);
587✔
219
    this._originalOptions = { ...Loader._DEFAULT_LOADER_OPTIONS, ...options };
587✔
220
  }
221

222
  public override onInitialize(engine: Engine): void {
223
    this.engine = engine;
24✔
224
    this.screen = engine.screen;
24✔
225
    this.canvas.width = this.engine.canvas.width;
24✔
226
    this.canvas.height = this.engine.canvas.height;
24✔
227
    this.screen.events.on('resize', () => {
24✔
228
      this.canvas.width = this.engine.canvas.width;
2✔
229
      this.canvas.height = this.engine.canvas.height;
2✔
230
    });
231
  }
232

233
  /**
234
   * Shows the play button and returns a promise that resolves when clicked
235
   */
236
  public async showPlayButton(): Promise<void> {
237
    if (this.suppressPlayButton) {
25✔
238
      this.hidePlayButton();
14✔
239
      // Delay is to give the logo a chance to show, otherwise don't delay
240
      await delay(500, this.engine?.clock);
14!
241
    } else {
242
      const resizeHandler = () => {
11✔
243
        try {
10✔
244
          this._positionPlayButton();
10✔
245
        } catch {
246
          // swallow if can't position
247
        }
248
      };
249
      if (this.engine?.browser) {
11✔
250
        this.engine.browser.window.on('resize', resizeHandler);
6✔
251
      }
252
      this._playButtonShown = true;
11✔
253
      this._playButton.style.display = 'block';
11✔
254
      document.body.addEventListener('keyup', (evt: KeyboardEvent) => {
11✔
255
        if (evt.key === 'Enter') {
6!
256
          this._playButton.click();
6✔
257
        }
258
      });
259
      this._positionPlayButton();
11✔
260
      const playButtonClicked = new Promise<void>((resolve) => {
11✔
261
        const startButtonHandler = (e: Event) => {
11✔
262
          // We want to stop propagation to keep bubbling to the engine pointer handlers
263
          e.stopPropagation();
25✔
264
          // Hide Button after click
265
          this.hidePlayButton();
25✔
266
          if (this.engine?.browser) {
25✔
267
            this.engine.browser.window.off('resize', resizeHandler);
14✔
268
          }
269

270
          if (this._originalOptions.fullscreenAfterLoad) {
25!
271
            try {
×
272
              this._logger.info('requesting fullscreen');
×
273
              if (this._originalOptions.fullscreenContainer instanceof HTMLElement) {
×
274
                // eslint-disable-next-line @typescript-eslint/no-floating-promises
275
                this._originalOptions.fullscreenContainer.requestFullscreen();
×
276
              } else {
277
                // eslint-disable-next-line @typescript-eslint/no-floating-promises
278
                this.engine.screen.enterFullscreen(this._originalOptions.fullscreenContainer);
×
279
              }
280
            } catch (error) {
281
              this._logger.error('could not go fullscreen', error);
×
282
            }
283
          }
284

285
          resolve();
25✔
286
        };
287
        this._playButton.addEventListener('click', startButtonHandler);
11✔
288
        this._playButton.addEventListener('touchend', startButtonHandler);
11✔
289
        this._playButton.addEventListener('pointerup', startButtonHandler);
11✔
290
        if (this.engine) {
11✔
291
          this.engine.input.gamepads.once('button', () => startButtonHandler(new Event('button')));
6✔
292
        }
293
      });
294

295
      return await playButtonClicked;
11✔
296
    }
297
  }
298

299
  public hidePlayButton() {
300
    this._playButtonShown = false;
39✔
301
    this._playButton.style.display = 'none';
39✔
302
  }
303

304
  /**
305
   * Clean up generated elements for the loader
306
   */
307
  public dispose() {
308
    if (this._playButtonRootElement.parentElement) {
16!
309
      this._playButtonRootElement.removeChild(this._playButtonElement);
16✔
310
      document.body.removeChild(this._playButtonRootElement);
16✔
311
      document.head.removeChild(this._styleBlock);
16✔
312
      this._playButtonRootElement = null as any;
16✔
313
      this._playButtonElement = null as any;
16✔
314
      this._styleBlock = null as any;
16✔
315
    }
316
  }
317

318
  data!: Loadable<any>[];
319

320
  public override async onUserAction(): Promise<void> {
321
    // short delay in showing the button for aesthetics
322
    await delay(200, this.engine?.clock);
15!
323
    this.canvas.flagDirty();
15✔
324
    // show play button
325
    await this.showPlayButton();
15✔
326
  }
327

328
  public override async onBeforeLoad(): Promise<void> {
329
    this.screen.pushResolutionAndViewport();
15✔
330
    this.screen.resolution = { width: this.canvas.width, height: this.canvas.height };
15✔
331
    this.screen.applyResolutionAndViewport();
15✔
332
    const image = this._image;
15✔
333
    await this._imageLoaded.promise;
15✔
334
    await image?.decode(); // decode logo if it exists
15!
335
  }
336

337
  // eslint-disable-next-line require-await
338
  public override async onAfterLoad(): Promise<void> {
339
    this.screen.popResolutionAndViewport();
14✔
340
    this.screen.applyResolutionAndViewport();
14✔
341
    this.dispose();
14✔
342
  }
343

344
  private _positionPlayButton() {
345
    if (this.engine) {
857✔
346
      const { x: left, y: top, width: screenWidth, height: screenHeight } = this.engine.canvas.getBoundingClientRect();
852✔
347
      if (this._playButtonRootElement) {
844✔
348
        const buttonWidth = this._playButton.clientWidth;
25✔
349
        const buttonHeight = this._playButton.clientHeight;
25✔
350
        if (this.playButtonPosition) {
25✔
351
          this._playButtonRootElement.style.left = `${this.playButtonPosition.x}px`;
2✔
352
          this._playButtonRootElement.style.top = `${this.playButtonPosition.y}px`;
2✔
353
        } else {
354
          this._playButtonRootElement.style.left = `${left + screenWidth / 2 - buttonWidth / 2}px`;
23✔
355
          this._playButtonRootElement.style.top = `${top + screenHeight / 2 - buttonHeight / 2 + 100}px`;
23✔
356
        }
357
      }
358
    }
359
  }
360

361
  /**
362
   * Loader draw function. Draws the default Excalibur loading screen.
363
   * Override `logo`, `logoWidth`, `logoHeight` and `backgroundColor` properties
364
   * to customize the drawing, or just override entire method.
365
   */
366
  public override onDraw(ctx: CanvasRenderingContext2D) {
367
    const canvasHeight = this.engine.canvasHeight / this.engine.pixelRatio;
836✔
368
    const canvasWidth = this.engine.canvasWidth / this.engine.pixelRatio;
836✔
369

370
    this._positionPlayButton();
836✔
371

372
    ctx.fillStyle = this.backgroundColor;
836✔
373
    ctx.fillRect(0, 0, canvasWidth, canvasHeight);
836✔
374

375
    let logoY = canvasHeight / 2;
836✔
376
    const width = Math.min(this.logoWidth, canvasWidth * 0.75);
836✔
377
    let logoX = canvasWidth / 2 - width / 2;
836✔
378

379
    if (this.logoPosition) {
836✔
380
      logoX = this.logoPosition.x;
1✔
381
      logoY = this.logoPosition.y;
1✔
382
    }
383

384
    const imageHeight = Math.floor(width * (this.logoHeight / this.logoWidth)); // OG height/width factor
836✔
385
    const oldAntialias = this.engine.screen.antialiasing;
836✔
386
    this.engine.screen.antialiasing = true;
836✔
387
    if (!this.logoPosition) {
836✔
388
      ctx.drawImage(this._image, 0, 0, this.logoWidth, this.logoHeight, logoX, logoY - imageHeight - 20, width, imageHeight);
835✔
389
    } else {
390
      ctx.drawImage(this._image, 0, 0, this.logoWidth, this.logoHeight, logoX, logoY, width, imageHeight);
1✔
391
    }
392

393
    // loading box
394
    if (!this.suppressPlayButton && this._playButtonShown) {
836✔
395
      this.engine.screen.antialiasing = oldAntialias;
3✔
396
      return;
3✔
397
    }
398

399
    let loadingX = logoX;
833✔
400
    let loadingY = logoY;
833✔
401
    if (this.loadingBarPosition) {
833✔
402
      loadingX = this.loadingBarPosition.x;
1✔
403
      loadingY = this.loadingBarPosition.y;
1✔
404
    }
405

406
    ctx.lineWidth = 2;
833✔
407
    DrawUtil.roundRect(ctx, loadingX, loadingY, width, 20, 10, this.loadingBarColor);
833✔
408
    const progress = width * this.progress;
833✔
409
    const margin = 5;
833✔
410
    const progressWidth = progress - margin * 2;
833✔
411
    const height = 20 - margin * 2;
833✔
412
    DrawUtil.roundRect(
833✔
413
      ctx,
414
      loadingX + margin,
415
      loadingY + margin,
416
      progressWidth > 10 ? progressWidth : 10,
833✔
417
      height,
418
      5,
419
      null,
420
      this.loadingBarColor
421
    );
422
    this.engine.screen.antialiasing = oldAntialias;
833✔
423
  }
424
}
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