diff --git a/CHANGELOG.md b/CHANGELOG.md index 2783cf70..c6878db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,24 @@ This project adheres to [Semantic Versioning](http://semver.org/). - ### Added +- Added new `ex.Future` type which is a convenient way of wrapping a native browser promise and resolving/rejecting later + ```typescript + const future = new ex.Future(); + const promise = future.promise; // returns promise + promise.then(() => { + console.log('Resolved!'); + }); + future.resolve(); // resolved promise + ``` +- Added new `ex.Semaphore` type to limit the number of concurrent cans in a section of code, this is used internally to work around a chrome browser limitation, but can be useful for throttling network calls or even async game events. + ```typescript + const semaphore = new ex.Semaphore(10); // Only allow 10 concurrent between enter() and exit() + ... + + await semaphore.enter(); + await methodToBeLimited(); + semaphore.exit(); + ``` - Added new `ex.WatchVector` type that can observe changes to x/y more efficiently than `ex.watch()` - Added performance improvements * `ex.Vector.distance` improvement @@ -90,6 +108,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Add target element id to `ex.Screen.goFullScreen('some-element-id')` to influence the fullscreen element in the fullscreen browser API. ### Fixed +- Fixed bug in `Clock.schedule` where callbacks would not fire at the correct time, this was because it was scheduling using browser time and not the clock's internal time. +- Fixed issue in Chromium browsers where Excalibur crashes if more than 256 `Image.decode()` calls are happening in the same frame. - Fixed issue where `ex.EdgeCollider` were not working properly in `ex.CompositeCollider` for `ex.TileMap`'s - Fixed issue where `ex.BoundingBox` overlap return false due to floating point rounding error causing multiple collisions to be evaluated sometimes - Fixed issue with `ex.EventDispatcher` where removing a handler that didn't already exist would remove another handler by mistake diff --git a/sandbox/index.html b/sandbox/index.html index 5f9ca938..f16036e7 100644 --- a/sandbox/index.html +++ b/sandbox/index.html @@ -11,6 +11,7 @@
  • Sandbox Platformer
  • Parallel Actions
  • Isometric Map
  • +
  • Decode Many Images without failure (Chrome)
  • Arcade: Sliding on Floor
  • Arcade: No clipping divergent collisions
  • Edge colliders work in a tilemap
  • diff --git a/sandbox/tests/decode-many/index.html b/sandbox/tests/decode-many/index.html new file mode 100644 index 00000000..ad24b824 --- /dev/null +++ b/sandbox/tests/decode-many/index.html @@ -0,0 +1,14 @@ + + + + + + + Decode Images + + +

    There should be no errors in the console relating to loading images

    + + + + \ No newline at end of file diff --git a/sandbox/tests/decode-many/index.ts b/sandbox/tests/decode-many/index.ts new file mode 100644 index 00000000..5e6b7a37 --- /dev/null +++ b/sandbox/tests/decode-many/index.ts @@ -0,0 +1,63 @@ +var game = new ex.Engine({ + width: 800, + height: 600 +}); + +var loader = new ex.Loader(); + +function generate() { + let srcs = []; + for (let i = 0; i < 800; i++) { + srcs.push(generateRandomImage()); + } + let images = srcs.map(src => new ex.ImageSource(src)); + loader.addResources(images); + + let sprites = images.map(i => i.toSprite()); + + game.currentScene.onPostDraw = ctx => { + ctx.save(); + ctx.scale(.25, .25); + for (let i = 0; i < sprites.length; i++) { + sprites[i].draw(ctx, (i * 100) % (800 * 4) + 10, Math.floor((i * 100) / (800 * 4)) * 100 + 10); + } + ctx.restore(); + }; +} + +function drawRandomCircleOnContext(ctx) { + const x = Math.floor(Math.random() * 100); + const y = Math.floor(Math.random() * 100); + const radius = Math.floor(Math.random() * 20); + + const r = Math.floor(Math.random() * 255); + const g = Math.floor(Math.random() * 255); + const b = Math.floor(Math.random() * 255); + + ctx.beginPath(); + ctx.arc(x, y, radius, Math.PI * 2, 0, false); + ctx.fillStyle = "rgba(" + r + "," + g + "," + b + ",1)"; + ctx.fill(); + ctx.closePath(); +} + +function generateRandomImage() { + const canvas = document.createElement("canvas"); + canvas.width = 100; + canvas.height = 100; + + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, 100, 100); + + for (let i = 0; i < 20; i++) { + drawRandomCircleOnContext(ctx); + } + return canvas.toDataURL("image/png"); +} + + +generate(); + + + +game.start(loader); \ No newline at end of file diff --git a/src/engine/Graphics/ImageSource.ts b/src/engine/Graphics/ImageSource.ts index c9a3947a..bffa428d 100644 --- a/src/engine/Graphics/ImageSource.ts +++ b/src/engine/Graphics/ImageSource.ts @@ -4,6 +4,7 @@ import { Loadable } from '../Interfaces/Index'; import { Logger } from '../Util/Log'; import { TextureLoader } from '.'; import { ImageFiltering } from './Filtering'; +import { Future } from '../Util/Future'; export class ImageSource implements Loadable { private _logger = Logger.getInstance(); @@ -45,11 +46,11 @@ export class ImageSource implements Loadable { return this.data; } + private _readyFuture = new Future(); /** * Promise the resolves when the image is loaded and ready for use, does not initiate loading */ - public ready: Promise; - private _loadedResolve: (value?: HTMLImageElement | PromiseLike) => void; + public ready: Promise = this._readyFuture.promise; /** * The path to the image, can also be a data url like 'data:image/' @@ -63,9 +64,6 @@ export class ImageSource implements Loadable { if (path.endsWith('.svg') || path.endsWith('.gif')) { this._logger.warn(`Image type is not fully supported, you may have mixed results ${path}. Fully supported: jpg, bmp, and png`); } - this.ready = new Promise((resolve) => { - this._loadedResolve = resolve; - }); } /** @@ -87,9 +85,15 @@ export class ImageSource implements Loadable { // Decode the image const image = new Image(); + // Use Image.onload over Image.decode() + // https://bugs.chromium.org/p/chromium/issues/detail?id=1055828#c7 + // Otherwise chrome will throw still Image.decode() failures for large textures + const loadedFuture = new Future(); + image.onload = () => loadedFuture.resolve(); image.src = url; image.setAttribute('data-original-src', this.path); - await image.decode(); + + await loadedFuture.promise; // Set results this.data = image; @@ -98,7 +102,7 @@ export class ImageSource implements Loadable { } TextureLoader.load(this.data, this._filtering); // todo emit complete - this._loadedResolve(this.data); + this._readyFuture.resolve(this.data); return this.data; } diff --git a/src/engine/Loader.ts b/src/engine/Loader.ts index ac4418c8..19129103 100644 --- a/src/engine/Loader.ts +++ b/src/engine/Loader.ts @@ -13,6 +13,7 @@ import { delay } from './Util/Util'; import { ImageFiltering } from './Graphics/Filtering'; import { clamp } from './Math/util'; import { Sound } from './Resources/Sound/Sound'; +import { Future } from './Util/Future'; /** * Pre-loading assets @@ -310,12 +311,9 @@ export class Loader extends Class implements Loadable[]> { data: Loadable[]; - private _isLoadedResolve: () => any; - private _isLoadedPromise = new Promise(resolve => { - this._isLoadedResolve = resolve; - }); + private _loadingFuture = new Future; public areResourcesLoaded() { - return this._isLoadedPromise; + return this._loadingFuture.promise; } /** @@ -324,15 +322,16 @@ export class Loader extends Class implements Loadable[]> { */ public async load(): Promise[]> { await this._image?.decode(); // decode logo if it exists + this.canvas.flagDirty(); await Promise.all( - this._resourceList.map((r) => - r.load().finally(() => { + this._resourceList.map(async (r) => { + await r.load().finally(() => { // capture progress this._numLoaded++; this.canvas.flagDirty(); - }) - ) + }); + }) ); // Wire all sound to the engine for (const resource of this._resourceList) { @@ -341,7 +340,7 @@ export class Loader extends Class implements Loadable[]> { } } - this._isLoadedResolve(); + this._loadingFuture.resolve(); // short delay in showing the button for aesthetics await delay(200, this._engine?.clock); diff --git a/src/engine/Util/Clock.ts b/src/engine/Util/Clock.ts index 3a7bef0c..6edaa0cb 100644 --- a/src/engine/Util/Clock.ts +++ b/src/engine/Util/Clock.ts @@ -93,7 +93,8 @@ export abstract class Clock { * @param timeoutMs Optionally specify a timeout in milliseconds from now, default is 0ms which means the next possible tick */ public schedule(cb: () => any, timeoutMs: number = 0) { - const scheduledTime = this.now() + timeoutMs; + // Scheduled based on internal elapsed time + const scheduledTime = this._totalElapsed + timeoutMs; this._scheduledCbs.push([cb, scheduledTime]); } diff --git a/src/engine/Util/Future.ts b/src/engine/Util/Future.ts new file mode 100644 index 00000000..95292310 --- /dev/null +++ b/src/engine/Util/Future.ts @@ -0,0 +1,39 @@ + +/** + * Future is a wrapper around a native browser Promise to allow resolving/rejecting at any time + */ +export class Future { + // Code from StephenCleary https://gist.github.com/StephenCleary/ba50b2da419c03b9cba1d20cb4654d5e + private _resolver: (value: T) => void; + private _rejecter: (error: Error) => void; + private _isCompleted: boolean = false; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this._resolver = resolve; + this._rejecter = reject; + }); + } + + public readonly promise: Promise; + + public get isCompleted(): boolean { + return this._isCompleted; + } + + public resolve(value: T): void { + if (this._isCompleted) { + return; + } + this._isCompleted = true; + this._resolver(value); + } + + public reject(error: Error): void { + if (this._isCompleted) { + return; + } + this._isCompleted = true; + this._rejecter(error); + } +} \ No newline at end of file diff --git a/src/engine/Util/Semaphore.ts b/src/engine/Util/Semaphore.ts new file mode 100644 index 00000000..0c73e9f9 --- /dev/null +++ b/src/engine/Util/Semaphore.ts @@ -0,0 +1,59 @@ +import { Future } from './Future'; + +class AsyncWaitQueue { + // Code from StephenCleary https://gist.github.com/StephenCleary/ba50b2da419c03b9cba1d20cb4654d5e + private _queue: Future[] = []; + + public get length(): number { + return this._queue.length; + } + + public enqueue(): Promise { + const future = new Future(); + this._queue.push(future); + return future.promise; + } + + public dequeue(value: T): void { + const future = this._queue.shift(); + future.resolve(value); + } +} + +/** + * Semaphore allows you to limit the amount of async calls happening between `enter()` and `exit()` + * + * This can be useful when limiting the number of http calls, browser api calls, etc either for performance or to work + * around browser limitations like max Image.decode() calls in chromium being 256. + */ +export class Semaphore { + private _waitQueue = new AsyncWaitQueue(); + constructor(private _count: number) { } + + public get count() { + return this._count; + } + + public get waiting() { + return this._waitQueue.length; + } + + public async enter() { + if (this._count !== 0) { + this._count--; + return Promise.resolve(); + } + return this._waitQueue.enqueue(); + } + + public exit(count: number = 1) { + if (count === 0) { + return; + } + while (count !== 0 && this._waitQueue.length !== 0) { + this._waitQueue.dequeue(null); + count--; + } + this._count += count; + } +} \ No newline at end of file diff --git a/src/engine/Util/Util.ts b/src/engine/Util/Util.ts index f4520d24..208017dc 100644 --- a/src/engine/Util/Util.ts +++ b/src/engine/Util/Util.ts @@ -1,5 +1,6 @@ import { Vector } from '../Math/vector'; import { Clock } from './Clock'; +import { Future } from './Future'; /** * Find the screen position of an HTML element @@ -82,10 +83,10 @@ export function fail(message: never): never { * @param clock */ export function delay(milliseconds: number, clock?: Clock): Promise { + const future = new Future(); const schedule = clock?.schedule.bind(clock) ?? setTimeout; - return new Promise(resolve => { - schedule(() => { - resolve(); - }, milliseconds); - }); + schedule(() => { + future.resolve(); + }, milliseconds); + return future.promise; } diff --git a/src/engine/index.ts b/src/engine/index.ts index 9048d9b4..a4abe03c 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -72,6 +72,8 @@ export * from './Util/Clock'; export * from './Util/WebAudio'; export * from './Util/Toaster'; export * from './Util/StateMachine'; +export * from './Util/Future'; +export * from './Util/Semaphore'; // ex.Deprecated // import * as deprecated from './Deprecated'; diff --git a/src/spec/FutureSpec.ts b/src/spec/FutureSpec.ts new file mode 100644 index 00000000..53bcd5c8 --- /dev/null +++ b/src/spec/FutureSpec.ts @@ -0,0 +1,73 @@ +import * as ex from '@excalibur'; + +describe('A Future', () => { + it('exists', () => { + expect(ex.Future).toBeDefined(); + }); + + it('it can be constructed', () => { + const future = new ex.Future(); + + expect(future).toBeDefined(); + }); + + it('can be resolved', (done) => { + const future = new ex.Future(); + + expect(future.isCompleted).toBe(false); + + future.promise.then(() => { + expect(future.isCompleted).toBe(true); + done(); + }); + future.resolve(); + }); + + it('can be resolved multiple times without error', (done) => { + const future = new ex.Future(); + + expect(future.isCompleted).toBe(false); + + future.promise.then(() => { + expect(future.isCompleted).toBe(true); + done(); + }); + expect(() => { + future.resolve(); + future.resolve(); + future.resolve(); + future.resolve(); + }).not.toThrow(); + }); + + it('can be rejected', (done) => { + const future = new ex.Future(); + + expect(future.isCompleted).toBe(false); + + future.promise.catch((err) => { + expect(err).toEqual(new Error('Some error')); + expect(future.isCompleted).toBe(true); + done(); + }); + future.reject(new Error('Some error')); + }); + + it('can be rejected multiple times without error', (done) => { + const future = new ex.Future(); + + expect(future.isCompleted).toBe(false); + + future.promise.catch(() => { + expect(future.isCompleted).toBe(true); + done(); + }); + expect(() => { + future.reject(new Error('Some error')); + future.reject(new Error('Some error')); + future.reject(new Error('Some error')); + future.reject(new Error('Some error')); + future.reject(new Error('Some error')); + }).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/spec/LoaderSpec.ts b/src/spec/LoaderSpec.ts index 062ed549..88b52e64 100644 --- a/src/spec/LoaderSpec.ts +++ b/src/spec/LoaderSpec.ts @@ -266,4 +266,72 @@ describe('A loader', () => { expect(oldPos).not.toEqual(newPos); }); + + it('does not throw when more than 256 images are being loaded', (done) => { + /** + * + */ + function drawRandomCircleOnContext(ctx) { + const x = Math.floor(Math.random() * 100); + const y = Math.floor(Math.random() * 100); + const radius = Math.floor(Math.random() * 20); + + const r = Math.floor(Math.random() * 255); + const g = Math.floor(Math.random() * 255); + const b = Math.floor(Math.random() * 255); + + ctx.beginPath(); + ctx.arc(x, y, radius, Math.PI * 2, 0, false); + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',1)'; + ctx.fill(); + ctx.closePath(); + } + + /** + * + */ + function generateRandomImage() { + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 100; + + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, 100, 100); + + for (let i = 0; i < 20; i++) { + drawRandomCircleOnContext(ctx); + } + return canvas.toDataURL('image/png'); + } + + const logger = ex.Logger.getInstance(); + spyOn(logger, 'error').and.callThrough(); + const game = TestUtils.engine({ + width: 100, + height: 100 + }); + const testClock = game.clock as ex.TestClock; + + const loader = new ex.Loader(); + + const srcs = []; + for (let i = 0; i < 800; i++) { + srcs.push(generateRandomImage()); + } + const images = srcs.map(src => new ex.ImageSource(src)); + images.forEach((image) => { + image.ready.then(() => { + testClock.step(1); + }); + }); + loader.addResources(images); + + const ready = TestUtils.runToReady(game, loader).then(() => { + expect(logger.error).not.toHaveBeenCalled(); + done(); + }) + .catch(() => { + fail(); + }); + }); }); diff --git a/src/spec/SemaphoreSpec.ts b/src/spec/SemaphoreSpec.ts new file mode 100644 index 00000000..b5a9d9a2 --- /dev/null +++ b/src/spec/SemaphoreSpec.ts @@ -0,0 +1,84 @@ +import * as ex from '@excalibur'; +import { delay } from '../engine/Util/Util'; + +describe('A Semaphore', () => { + it('should exist', () => { + expect(ex.Semaphore).toBeDefined(); + }); + + it('can be constructed with a count', () => { + const semaphore = new ex.Semaphore(10); + expect(semaphore.count).toBe(10); + }); + + it('a trivial exit does not decrement semaphor', () => { + const semaphore = new ex.Semaphore(10); + for (let i = 0; i < 20; i++) { + semaphore.enter(); + } + expect(semaphore.waiting).toBe(10); + expect(semaphore.count).toBe(0); + semaphore.exit(0); + expect(semaphore.waiting).toBe(10); + expect(semaphore.count).toBe(0); + }); + + it('can limit async calls', () => { + const semaphore = new ex.Semaphore(10); + for (let i = 0; i < 20; i++) { + semaphore.enter(); + } + expect(semaphore.waiting).toBe(10); + semaphore.exit(); + semaphore.exit(); + semaphore.exit(); + expect(semaphore.waiting).toBe(7); + }); + + + it('can block async calls', (done) => { + const mockFuture1 = new ex.Future(); + const mockMethod1 = () => { + return mockFuture1.promise; + }; + + const mockFuture2 = new ex.Future(); + const mockMethod2 = () => { + return mockFuture2.promise; + }; + const semaphore = new ex.Semaphore(1); + + const spy1 = jasmine.createSpy(); + const spy2 = jasmine.createSpy(); + + semaphore.enter().then(() => { + expect(semaphore.count).toBe(0); + expect(semaphore.waiting).toBe(0); + mockMethod1().then(() => { + spy1(); + semaphore.exit(); + }); + const final = semaphore.enter().then(() => { + mockMethod2().then(() => { + spy2(); + semaphore.exit(); + }); + }); + expect(semaphore.count).toBe(0); + expect(semaphore.waiting).toBe(1); + return final; + }).finally(() => { + expect(spy1).toHaveBeenCalled(); + expect(spy2).toHaveBeenCalled(); + expect(spy1).toHaveBeenCalledBefore(spy2); + done(); + }); + + expect(spy1).not.toHaveBeenCalled(); + expect(spy2).not.toHaveBeenCalled(); + + mockFuture1.resolve(); + mockFuture2.resolve(); + }); + +}); \ No newline at end of file