diff --git a/CHANGELOG.md b/CHANGELOG.md index a37df1dba..fe6558b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Add color blind mode simulation and correction in debug object. ([#390](https://github.com/excaliburjs/Excalibur/issues/390)) - Add `LimitCameraBoundsStrategy`, which always keeps the camera locked to within the given bounds. ([#1498](https://github.com/excaliburjs/Excalibur/issues/1498)) +- Add mechanisms to manipulate the `Loader` screen. ([#1417](https://github.com/excaliburjs/Excalibur/issues/1417)) + - Logo position `Loader.logoPosition` + - Play button position `Loader.playButtonPosition` + - Loading bar position `Loader.loadingBarPosition` + - Loading bar color `Loader.loadingBarColor` by default is white, but can be any excalibur `ex.Color` ### Changed @@ -24,6 +29,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed +- Fixed Loader play button markup and styles are now cleaned up after clicked ([#1431](https://github.com/excaliburjs/Excalibur/issues/1431)) - Fixed Excalibur crashing when embedded within a cross-origin IFrame ([#1151](https://github.com/excaliburjs/Excalibur/issues/1151)) - Fixed issue when loading images from a base64 strings that would crash the loader ([#1543](https://github.com/excaliburjs/Excalibur/issues/1543)) diff --git a/package.json b/package.json index 4815b5ff6..eae91d98c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "sandbox": "serve ./sandbox -l 3001 -n", "legacy-visual": "npm run sandbox:copy && npm run sandbox:build", "test": "karma start", + "test:watch": "karma start --auto-watch --single-run=false", "coveralls": "coveralls < coverage/lcov.info", "apidocs": "git submodule update && node apidocs.js", "pretty": "prettier --write \"{src,sandbox/src,sandbox/tests}/**/*.{ts,js,json,css,md}\" --config prettier.config.js", diff --git a/src/engine/Loader.ts b/src/engine/Loader.ts index bd5250219..d6ad379c3 100644 --- a/src/engine/Loader.ts +++ b/src/engine/Loader.ts @@ -10,6 +10,7 @@ import * as DrawUtil from './Util/DrawUtil'; import logoImg from './Loader.logo.png'; import loaderCss from './Loader.css'; +import { Vector } from './Algebra'; /** * Pre-loading assets @@ -93,7 +94,31 @@ export class Loader extends Class implements CanLoad { public logo = logoImg; public logoWidth = 468; public logoHeight = 118; - public backgroundColor = '#176BAA'; + /** + * Positions the top left corner of the logo image + * If not set, the loader automatically positions the logo + */ + public logoPosition: Vector | null; + /** + * Positions the top left corner of the play button. + * If not set, the loader automatically positions the play button + */ + public playButtonPosition: Vector | null; + /** + * Positions the top left corner of the loading bar + * If not set, the loader automatically positions the loading bar + */ + public loadingBarPosition: Vector | null; + + /** + * Gets or sets the color of the loading bar, default is [[Color.White]] + */ + public loadingBarColor: Color = Color.White; + + /** + * Gets or sets the background color of the loader as a hex string + */ + public backgroundColor: string = '#176BAA'; protected _imageElement: HTMLImageElement; protected get _image() { @@ -106,6 +131,12 @@ export class Loader extends Class implements CanLoad { } public suppressPlayButton: boolean = false; + public get playButtonRootElement(): HTMLElement | null { + return this._playButtonRootElement; + } + public get playButtonElement(): HTMLButtonElement | null { + return this._playButtonElement; + } protected _playButtonRootElement: HTMLElement; protected _playButtonElement: HTMLButtonElement; protected _styleBlock: HTMLStyleElement; @@ -114,6 +145,7 @@ export class Loader extends Class implements CanLoad { protected get _playButton() { if (!this._playButtonRootElement) { this._playButtonRootElement = document.createElement('div'); + this._playButtonRootElement.id = 'excalibur-play-root'; this._playButtonRootElement.style.position = 'absolute'; document.body.appendChild(this._playButtonRootElement); } @@ -216,60 +248,61 @@ export class Loader extends Class implements CanLoad { this._playButton.style.display = 'none'; } + /** + * Clean up generated elements for the loader + */ + public dispose() { + if (this._playButtonRootElement.parentElement) { + this._playButtonRootElement.removeChild(this._playButtonElement); + document.body.removeChild(this._playButtonRootElement); + document.head.removeChild(this._styleBlock); + this._playButtonRootElement = null; + this._playButtonElement = null; + this._styleBlock = null; + } + } + /** * Begin loading all of the supplied resources, returning a promise * that resolves when loading of all is complete */ public load(): Promise { const complete = new Promise(); - const me = this; if (this._resourceList.length === 0) { - me.showPlayButton().then(() => { + this.showPlayButton().then(() => { // Unlock audio context in chrome after user gesture // https://github.com/excaliburjs/Excalibur/issues/262 // https://github.com/excaliburjs/Excalibur/issues/1031 WebAudio.unlock().then(() => { - me.hidePlayButton(); - me.oncomplete.call(me); + this.hidePlayButton(); + this.oncomplete.call(this); complete.resolve(); + this.dispose(); }); }); return complete; } - const progressArray = new Array(this._resourceList.length); - const progressChunks = this._resourceList.length; - - this._resourceList.forEach((r, i) => { + this._resourceList.forEach((resource) => { if (this._engine) { - r.wireEngine(this._engine); + resource.wireEngine(this._engine); } - r.onprogress = function(e) { - const total = e.total; - const loaded = e.loaded; - progressArray[i] = { loaded: (loaded / total) * (100 / progressChunks), total: 100 }; - - const progressResult: any = progressArray.reduce( - function(accum, next) { - return { loaded: accum.loaded + next.loaded, total: 100 }; - }, - { loaded: 0, total: 100 } - ); - - me.onprogress.call(me, progressResult); + resource.onprogress = (e: ProgressEvent) => { + this.updateResourceProgress(e.loaded, e.total); }; - r.oncomplete = r.onerror = function() { - me._numLoaded++; - if (me._numLoaded === me._resourceCount) { + resource.oncomplete = resource.onerror = () => { + this.markResourceComplete(); + if (this.isLoaded()) { setTimeout(() => { - me.showPlayButton().then(() => { + this.showPlayButton().then(() => { // Unlock audio context in chrome after user gesture // https://github.com/excaliburjs/Excalibur/issues/262 // https://github.com/excaliburjs/Excalibur/issues/1031 WebAudio.unlock().then(() => { - me.hidePlayButton(); - me.oncomplete.call(me); + this.hidePlayButton(); + this.oncomplete.call(this); complete.resolve(); + this.dispose(); }); }); }, 200); // short delay in showing the button for aesthetics @@ -281,7 +314,7 @@ export class Loader extends Class implements CanLoad { if (!list[index]) { return; } - list[index].load().then(function() { + list[index].load().then(function () { loadNext(list, index + 1); }); } @@ -290,6 +323,25 @@ export class Loader extends Class implements CanLoad { return complete; } + public updateResourceProgress(loadedBytes: number, totalBytes: number) { + const chunkSize = 100 / this._resourceCount; + const resourceProgress = loadedBytes / totalBytes; + // This only works if we load 1 resource at a time + const totalProgress = resourceProgress * chunkSize + this.progress * 100; + this.onprogress({ loaded: totalProgress, total: 100 }); + } + + public markResourceComplete() { + this._numLoaded++; + } + + /** + * Returns the progess of the loader as a number between [0, 1] inclusive. + */ + public get progress(): number { + return this._resourceCount > 0 ? this._numLoaded / this._resourceCount : 1; + } + /** * Loader draw function. Draws the default Excalibur loading screen. * Override `logo`, `logoWidth`, `logoHeight` and `backgroundColor` properties @@ -304,21 +356,35 @@ export class Loader extends Class implements CanLoad { const top = ctx.canvas.offsetTop; const buttonWidth = this._playButton.clientWidth; const buttonHeight = this._playButton.clientHeight; - this._playButtonRootElement.style.left = `${left + canvasWidth / 2 - buttonWidth / 2}px`; - this._playButtonRootElement.style.top = `${top + canvasHeight / 2 - buttonHeight / 2 + 100}px`; + if (this.playButtonPosition) { + this._playButtonRootElement.style.left = `${this.playButtonPosition.x}px`; + this._playButtonRootElement.style.top = `${this.playButtonPosition.y}px`; + } else { + this._playButtonRootElement.style.left = `${left + canvasWidth / 2 - buttonWidth / 2}px`; + this._playButtonRootElement.style.top = `${top + canvasHeight / 2 - buttonHeight / 2 + 100}px`; + } } ctx.fillStyle = this.backgroundColor; ctx.fillRect(0, 0, canvasWidth, canvasHeight); - const y = canvasHeight / 2; + let logoY = canvasHeight / 2; const width = Math.min(this.logoWidth, canvasWidth * 0.75); - const x = canvasWidth / 2 - width / 2; + let logoX = canvasWidth / 2 - width / 2; + + if (this.logoPosition) { + logoX = this.logoPosition.x; + logoY = this.logoPosition.y; + } const imageHeight = Math.floor(width * (this.logoHeight / this.logoWidth)); // OG height/width factor const oldAntialias = this._engine.getAntialiasing(); this._engine.setAntialiasing(true); - ctx.drawImage(this._image, 0, 0, this.logoWidth, this.logoHeight, x, y - imageHeight - 20, width, imageHeight); + if (!this.logoPosition) { + ctx.drawImage(this._image, 0, 0, this.logoWidth, this.logoHeight, logoX, logoY - imageHeight - 20, width, imageHeight); + } else { + ctx.drawImage(this._image, 0, 0, this.logoWidth, this.logoHeight, logoX, logoY, width, imageHeight); + } // loading box if (!this.suppressPlayButton && this._playButtonShown) { @@ -326,13 +392,29 @@ export class Loader extends Class implements CanLoad { return; } + let loadingX = logoX; + let loadingY = logoY; + if (this.loadingBarPosition) { + loadingX = this.loadingBarPosition.x; + loadingY = this.loadingBarPosition.y; + } + ctx.lineWidth = 2; - DrawUtil.roundRect(ctx, x, y, width, 20, 10); - const progress = width * (this._numLoaded / this._resourceCount); + DrawUtil.roundRect(ctx, loadingX, loadingY, width, 20, 10, this.loadingBarColor); + const progress = width * this.progress; const margin = 5; const progressWidth = progress - margin * 2; const height = 20 - margin * 2; - DrawUtil.roundRect(ctx, x + margin, y + margin, progressWidth > 0 ? progressWidth : 0, height, 5, null, Color.White); + DrawUtil.roundRect( + ctx, + loadingX + margin, + loadingY + margin, + progressWidth > 10 ? progressWidth : 10, + height, + 5, + null, + this.loadingBarColor + ); this._engine.setAntialiasing(oldAntialias); } diff --git a/src/spec/LoaderSpec.ts b/src/spec/LoaderSpec.ts new file mode 100644 index 000000000..4226ee507 --- /dev/null +++ b/src/spec/LoaderSpec.ts @@ -0,0 +1,175 @@ +import * as ex from '@excalibur'; +import { ExcaliburMatchers, ensureImagesLoaded } from 'excalibur-jasmine'; +import { TestUtils } from './util/TestUtils'; + +describe('A loader', () => { + let engine: ex.Engine; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + engine = TestUtils.engine(); + }); + + it('exists', () => { + expect(ex.Loader).toBeDefined(); + }); + + it('can be constructed', () => { + const loader = new ex.Loader(); + expect(loader).toBeTruthy(); + }); + + it('can report progress, empty loaders are done', () => { + const loader = new ex.Loader(); + expect(loader.progress).toBe(1); + }); + + it('can report progress, loader start at 0', () => { + const loader = new ex.Loader([, , ,]); + expect(loader.progress).toBe(0); + }); + + it('can report progress', () => { + const loader = new ex.Loader([, , , ,]); + expect(loader.progress).toBe(0); + loader.markResourceComplete(); + expect(loader.progress).toBe(0.25); + loader.markResourceComplete(); + expect(loader.progress).toBe(0.5); + loader.markResourceComplete(); + expect(loader.progress).toBe(0.75); + loader.markResourceComplete(); + expect(loader.progress).toBe(1); + }); + + it('can be drawn at 0', (done) => { + const loader = new ex.Loader([, , , ,]); + (loader as any)._image.onload = () => { + loader.wireEngine(engine); + loader.draw(engine.ctx); + ensureImagesLoaded(engine.canvas, 'src/spec/images/LoaderSpec/zero.png').then(([canvas, image]) => { + expect(canvas).toEqualImage(image); + done(); + }); + }; + }); + + it('can be drawn at 50', (done) => { + const loader = new ex.Loader([, , , ,]); + (loader as any)._image.onload = () => { + loader.markResourceComplete(); + loader.markResourceComplete(); + + loader.wireEngine(engine); + loader.draw(engine.ctx); + ensureImagesLoaded(engine.canvas, 'src/spec/images/LoaderSpec/fifty.png').then(([canvas, image]) => { + expect(canvas).toEqualImage(image); + done(); + }); + }; + }); + + it('can be drawn at 100', (done) => { + const loader = new ex.Loader([, , , ,]); + (loader as any)._image.onload = () => { + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + + loader.wireEngine(engine); + loader.draw(engine.ctx); + ensureImagesLoaded(engine.canvas, 'src/spec/images/LoaderSpec/100.png').then(([canvas, image]) => { + expect(canvas).toEqualImage(image); + done(); + }); + }; + }); + + it('does not show progress when the play button is shown', (done) => { + const loader = new ex.Loader([, , , ,]); + (loader as any)._image.onload = () => { + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.wireEngine(engine); + loader.showPlayButton(); + + loader.draw(engine.ctx); + ensureImagesLoaded(engine.canvas, 'src/spec/images/LoaderSpec/playbuttonshown-noprogressbar.png').then(([canvas, image]) => { + expect(canvas).toEqualImage(image); + done(); + }); + }; + }); + + it('can have the play button position customized', () => { + const loader = new ex.Loader([, , , ,]); + loader.wireEngine(engine); + loader.playButtonPosition = ex.vec(42, 77); + loader.showPlayButton(); + loader.draw(engine.ctx); + // there is some dom pollution want to be sure we get the RIGHT root element + const playbutton = (loader as any)._playButtonRootElement as HTMLDivElement; + expect(playbutton.style.left).toBe('42px'); + expect(playbutton.style.top).toBe('77px'); + }); + + it('can have the logo position customized', (done) => { + const loader = new ex.Loader([, , , ,]); + (loader as any)._image.onload = () => { + loader.wireEngine(engine); + loader.logoPosition = ex.vec(0, 0); + loader.showPlayButton(); + loader.draw(engine.ctx); + ensureImagesLoaded(engine.canvas, 'src/spec/images/LoaderSpec/logo-position.png').then(([canvas, image]) => { + expect(canvas).toEqualImage(image); + done(); + }); + }; + }); + + it('can have the loader customized', (done) => { + const loader = new ex.Loader([, , , ,]); + loader.loadingBarPosition = ex.vec(0, 0); + loader.loadingBarColor = ex.Color.Red; + (loader as any)._image.onload = () => { + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.wireEngine(engine); + loader.draw(engine.ctx); + ensureImagesLoaded(engine.canvas, 'src/spec/images/LoaderSpec/loader-position-color.png').then(([canvas, image]) => { + expect(canvas).toEqualImage(image); + done(); + }); + }; + }); + + it('play button shows up after done loading', () => { + const loader = new ex.Loader([, , , ,]); + loader.loadingBarPosition = ex.vec(0, 0); + loader.loadingBarColor = ex.Color.Red; + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.showPlayButton(); + + expect(loader.playButtonRootElement).toBeTruthy(); + }); + + it('play button is cleaned up on dispose', () => { + const loader = new ex.Loader([, , , ,]); + loader.loadingBarPosition = ex.vec(0, 0); + loader.loadingBarColor = ex.Color.Red; + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.markResourceComplete(); + loader.showPlayButton(); + loader.dispose(); + expect(loader.playButtonRootElement).toBeFalsy(); + }); +}); diff --git a/src/spec/images/LoaderSpec/100.png b/src/spec/images/LoaderSpec/100.png new file mode 100644 index 000000000..60154f01d Binary files /dev/null and b/src/spec/images/LoaderSpec/100.png differ diff --git a/src/spec/images/LoaderSpec/fifty.png b/src/spec/images/LoaderSpec/fifty.png new file mode 100644 index 000000000..16516b9aa Binary files /dev/null and b/src/spec/images/LoaderSpec/fifty.png differ diff --git a/src/spec/images/LoaderSpec/loader-position-color.png b/src/spec/images/LoaderSpec/loader-position-color.png new file mode 100644 index 000000000..ffc0eac3d Binary files /dev/null and b/src/spec/images/LoaderSpec/loader-position-color.png differ diff --git a/src/spec/images/LoaderSpec/logo-position.png b/src/spec/images/LoaderSpec/logo-position.png new file mode 100644 index 000000000..4024c7856 Binary files /dev/null and b/src/spec/images/LoaderSpec/logo-position.png differ diff --git a/src/spec/images/LoaderSpec/playbuttonshown-noprogressbar.png b/src/spec/images/LoaderSpec/playbuttonshown-noprogressbar.png new file mode 100644 index 000000000..318a709c5 Binary files /dev/null and b/src/spec/images/LoaderSpec/playbuttonshown-noprogressbar.png differ diff --git a/src/spec/images/LoaderSpec/zero.png b/src/spec/images/LoaderSpec/zero.png new file mode 100644 index 000000000..b55184175 Binary files /dev/null and b/src/spec/images/LoaderSpec/zero.png differ