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

excaliburjs / Excalibur / 18878934919

28 Oct 2025 02:51PM UTC coverage: 88.617% (+0.02%) from 88.6%
18878934919

push

github

web-flow
fix: [#3524] Loader runs twice if included in scene and default (#3551)


Closes #3524 

## Changes:

- Adds quick exit if loader is already loaded

5262 of 7160 branches covered (73.49%)

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

1 existing line in 1 file now uncovered.

14441 of 16296 relevant lines covered (88.62%)

24359.27 hits per line

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

91.95
/src/engine/Director/DefaultLoader.ts
1
import { WebAudio } from '../Util/WebAudio';
2
import type { Engine } from '../Engine';
3
import type { Loadable } from '../Interfaces/Loadable';
4
import { Canvas } from '../Graphics/Canvas';
5
import { ImageFiltering } from '../Graphics/Filtering';
6
import { clamp } from '../Math/util';
7
import { Sound } from '../Resources/Sound/Sound';
8
import { Future } from '../Util/Future';
9
import type { EventKey, Handler, Subscription } from '../EventEmitter';
10
import { EventEmitter } from '../EventEmitter';
11
import { Color } from '../Color';
12
import { delay } from '../Util/Util';
13

14
export interface DefaultLoaderOptions {
15
  /**
16
   * List of loadables
17
   */
18
  loadables?: Loadable<any>[];
19
}
20

21
export type LoaderEvents = {
22
  // Add event types here
23
  beforeload: void;
24
  afterload: void;
25
  useraction: void;
26
  loadresourcestart: Loadable<any>;
27
  loadresourceend: Loadable<any>;
28
};
29

30
export const LoaderEvents = {
248✔
31
  // Add event types here
32
  BeforeLoad: 'beforeload',
33
  AfterLoad: 'afterload',
34
  UserAction: 'useraction',
35
  LoadResourceStart: 'loadresourcestart',
36
  LoadResourceEnd: 'loadresourceend'
37
};
38

39
export type LoaderConstructor = new (...args: any[]) => DefaultLoader;
40
/**
41
 * Returns true if the constructor is for an Excalibur Loader
42
 */
43
export function isLoaderConstructor(x: any): x is LoaderConstructor {
44
  return !!x?.prototype && !!x?.prototype?.constructor?.name;
21!
45
}
46

47
export class DefaultLoader implements Loadable<Loadable<any>[]> {
48
  public data!: Loadable<any>[];
49
  public events = new EventEmitter<LoaderEvents>();
1,358✔
50
  public canvas: Canvas = new Canvas({
1,358✔
51
    filtering: ImageFiltering.Blended,
52
    smoothing: true,
53
    cache: false,
54
    draw: this.onDraw.bind(this)
55
  });
56
  private _resources: Loadable<any>[] = [];
1,358✔
57
  public get resources(): readonly Loadable<any>[] {
58
    return this._resources;
2✔
59
  }
60
  private _numLoaded: number = 0;
1,358✔
61
  public engine!: Engine;
62

63
  /**
64
   * @param options Optionally provide the list of resources you want to load at constructor time
65
   */
66
  constructor(options?: DefaultLoaderOptions) {
67
    if (options && options.loadables?.length) {
1,358!
68
      this.addResources(options.loadables);
31✔
69
    }
70
  }
71

72
  /**
73
   * Called by the engine before loading
74
   * @param engine
75
   */
76
  public onInitialize(engine: Engine) {
77
    this.engine = engine;
5✔
78
    this.canvas.width = this.engine.screen.resolution.width;
5✔
79
    this.canvas.height = this.engine.screen.resolution.height;
5✔
80
  }
81

82
  /**
83
   * Return a promise that resolves when the user interacts with the loading screen in some way, usually a click.
84
   *
85
   * It's important to implement this in order to unlock the audio context in the browser. Browsers automatically prevent
86
   * audio from playing until the user performs an action.
87
   *
88
   */
89
  public async onUserAction(): Promise<void> {
90
    return await Promise.resolve();
×
91
  }
92

93
  /**
94
   * Overridable lifecycle method, called directly before loading starts
95
   */
96
  public async onBeforeLoad() {
97
    // override me
98
  }
99

100
  /**
101
   * Overridable lifecycle method, called after loading has completed
102
   */
103
  public async onAfterLoad() {
104
    // override me
105
    await delay(500, this.engine.clock); // avoid a flicker
×
106
  }
107

108
  /**
109
   * Add a resource to the loader to load
110
   * @param loadable  Resource to add
111
   */
112
  public addResource(loadable: Loadable<any>) {
113
    this._resources.push(loadable);
875✔
114
    this._loaded = false;
875✔
115
  }
116

117
  /**
118
   * Add a list of resources to the loader to load
119
   * @param loadables  The list of resources to load
120
   */
121
  public addResources(loadables: Loadable<any>[]) {
122
    let i = 0;
32✔
123
    const len = loadables.length;
32✔
124

125
    for (i; i < len; i++) {
32✔
126
      this.addResource(loadables[i]);
874✔
127
    }
128
    this._loaded = false;
32✔
129
  }
130

131
  public markResourceComplete(): void {
132
    this._numLoaded++;
35✔
133
  }
134

135
  /**
136
   * Returns the progress of the loader as a number between [0, 1] inclusive.
137
   */
138
  public get progress(): number {
139
    const total = this._resources.length;
842✔
140
    return total > 0 ? clamp(this._numLoaded, 0, total) / total : 1;
842✔
141
  }
142

143
  private _loaded = false;
1,358✔
144
  private _isLoading = false;
1,358✔
145
  /**
146
   * Returns true if the loader has completely loaded all resources
147
   */
148
  public isLoaded() {
149
    return this._loaded || this._resources.length === 0;
1,029✔
150
  }
151

152
  private _totalTimeMs = 0;
1,358✔
153

154
  /**
155
   * Optionally override the onUpdate
156
   * @param engine
157
   * @param elapsed
158
   */
159
  onUpdate(engine: Engine, elapsed: number): void {
160
    this._totalTimeMs += elapsed;
830✔
161
    // override me
162
  }
163

164
  /**
165
   * Optionally override the onDraw
166
   */
167
  onDraw(ctx: CanvasRenderingContext2D) {
168
    const seconds = this._totalTimeMs / 1000;
1✔
169

170
    ctx.fillStyle = Color.Black.toRGBA();
1✔
171
    ctx.fillRect(0, 0, this.engine.screen.resolution.width, this.engine.screen.resolution.height);
1✔
172

173
    ctx.save();
1✔
174
    ctx.translate(this.engine.screen.resolution.width / 2, this.engine.screen.resolution.height / 2);
1✔
175
    const speed = seconds * 10;
1✔
176
    ctx.strokeStyle = 'white';
1✔
177
    ctx.lineWidth = 10;
1✔
178
    ctx.lineCap = 'round';
1✔
179
    ctx.arc(0, 0, 40, speed, speed + (Math.PI * 3) / 2);
1✔
180
    ctx.stroke();
1✔
181

182
    ctx.fillStyle = 'white';
1✔
183
    ctx.font = '16px sans-serif';
1✔
184
    const text = (this.progress * 100).toFixed(0) + '%';
1✔
185
    const textbox = ctx.measureText(text);
1✔
186
    const width = Math.abs(textbox.actualBoundingBoxLeft) + Math.abs(textbox.actualBoundingBoxRight);
1✔
187
    const height = Math.abs(textbox.actualBoundingBoxAscent) + Math.abs(textbox.actualBoundingBoxDescent);
1✔
188
    ctx.fillText(text, -width / 2, height / 2); // center
1✔
189
    ctx.restore();
1✔
190
  }
191

192
  private _resourcesLoadedFuture = new Future<void>();
1,358✔
193
  public areResourcesLoaded() {
194
    if (this._resources.length === 0) {
17✔
195
      // special case no resources mean loaded;
196
      return Promise.resolve();
1✔
197
    }
198
    return this._resourcesLoadedFuture.promise;
16✔
199
  }
200

201
  private _loaderCompleteFuture = new Future<Loadable<any>[]>();
1,358✔
202

203
  /**
204
   * Not meant to be overridden
205
   *
206
   * Begin loading all of the supplied resources, returning a promise
207
   * that resolves when loading of all is complete AND the user has interacted with the loading screen
208
   */
209
  public async load(): Promise<Loadable<any>[]> {
210
    if (this._isLoading) {
18✔
211
      return this._loaderCompleteFuture.promise;
1✔
212
    }
213
    if (this.isLoaded()) {
17!
214
      // Already loaded quick exit
NEW
215
      return (this.data = this._resources);
×
216
    }
217
    this._isLoading = true;
17✔
218
    this._loaderCompleteFuture = new Future();
17✔
219
    await this.onBeforeLoad();
17✔
220
    this.events.emit('beforeload');
17✔
221
    this.canvas.flagDirty();
17✔
222

223
    await Promise.all(
17✔
224
      this._resources
225
        .filter((r) => {
226
          return !r.isLoaded();
817✔
227
        })
228
        .map(async (r) => {
229
          this.events.emit('loadresourcestart', r);
817✔
230
          await r.load().finally(() => {
817✔
231
            // capture progress
232
            this._numLoaded++;
817✔
233
            this.canvas.flagDirty();
817✔
234
            this.events.emit('loadresourceend', r);
817✔
235
          });
236
        })
237
    );
238

239
    // Wire all sound to the engine
240
    for (const resource of this._resources) {
17✔
241
      if (resource instanceof Sound) {
817!
242
        resource.wireEngine(this.engine);
×
243
      }
244
    }
245

246
    this._resourcesLoadedFuture.resolve();
17✔
247
    this.canvas.flagDirty();
17✔
248
    // Unlock browser AudioContext in after user gesture
249
    // See: https://github.com/excaliburjs/Excalibur/issues/262
250
    // See: https://github.com/excaliburjs/Excalibur/issues/1031
251
    await this.onUserAction();
17✔
252
    this.events.emit('useraction');
16✔
253
    await WebAudio.unlock();
16✔
254

255
    await this.onAfterLoad();
16✔
256
    this.events.emit('afterload');
16✔
257
    this._isLoading = false;
16✔
258
    this._loaded = true;
16✔
259
    this._loaderCompleteFuture.resolve(this._resources);
16✔
260
    return (this.data = this._resources);
16✔
261
  }
262

263
  public emit<TEventName extends EventKey<LoaderEvents>>(eventName: TEventName, event: LoaderEvents[TEventName]): void;
264
  public emit(eventName: string, event?: any): void;
265
  public emit<TEventName extends EventKey<LoaderEvents> | string>(eventName: TEventName, event?: any): void {
266
    this.events.emit(eventName, event);
×
267
  }
268

269
  public on<TEventName extends EventKey<LoaderEvents>>(eventName: TEventName, handler: Handler<LoaderEvents[TEventName]>): Subscription;
270
  public on(eventName: string, handler: Handler<unknown>): Subscription;
271
  public on<TEventName extends EventKey<LoaderEvents> | string>(eventName: TEventName, handler: Handler<any>): Subscription {
272
    return this.events.on(eventName, handler);
2✔
273
  }
274

275
  public once<TEventName extends EventKey<LoaderEvents>>(eventName: TEventName, handler: Handler<LoaderEvents[TEventName]>): Subscription;
276
  public once(eventName: string, handler: Handler<unknown>): Subscription;
277
  public once<TEventName extends EventKey<LoaderEvents> | string>(eventName: TEventName, handler: Handler<any>): Subscription {
278
    return this.events.once(eventName, handler);
×
279
  }
280

281
  public off<TEventName extends EventKey<LoaderEvents>>(eventName: TEventName, handler: Handler<LoaderEvents[TEventName]>): void;
282
  public off(eventName: string, handler: Handler<unknown>): void;
283
  public off(eventName: string): void;
284
  public off<TEventName extends EventKey<LoaderEvents> | string>(eventName: TEventName, handler?: Handler<any>): void {
285
    (this.events as any).off(eventName, handler);
×
286
  }
287
}
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