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