[READ-ONLY] Mirror of https://github.com/excaliburjs/Excalibur. 🎮 Your friendly TypeScript 2D game engine for the web 🗡️ excaliburjs.com
excalibur excaliburjs game-development game-engine game-framework gamedev games html5-canvas typescript
2

Configure Feed

Select the types of activity you want to include in your feed.

fix: [#2359] work around image decode limits in chromium (#2361)

Closes #2359

This PR switches away from `Image.decode()` to use the older method of `Image.onload` this method is more reliable for large or numerous images due to a chromium bug around decode.

@HxShard provided a great codesandbox illustrating the issue https://codesandbox.io/s/happy-gagarin-j1vjr

## Changes:

- Creates a new Future type for working with native Promises' resolve/reject at any time
- Creates a new Semaphore type that can limit async calls in between `enter` and `exit`
- Fixes a small clock schedule bug as well

authored by

Erik Onarheim and committed by
GitHub
(Jun 25, 2022, 1:37 PM -0500) a374da87 b285ede6

+451 -23
+20
CHANGELOG.md
··· 15 15 - 16 16 17 17 ### Added 18 + - Added new `ex.Future` type which is a convenient way of wrapping a native browser promise and resolving/rejecting later 19 + ```typescript 20 + const future = new ex.Future(); 21 + const promise = future.promise; // returns promise 22 + promise.then(() => { 23 + console.log('Resolved!'); 24 + }); 25 + future.resolve(); // resolved promise 26 + ``` 27 + - 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. 28 + ```typescript 29 + const semaphore = new ex.Semaphore(10); // Only allow 10 concurrent between enter() and exit() 30 + ... 31 + 32 + await semaphore.enter(); 33 + await methodToBeLimited(); 34 + semaphore.exit(); 35 + ``` 18 36 - Added new `ex.WatchVector` type that can observe changes to x/y more efficiently than `ex.watch()` 19 37 - Added performance improvements 20 38 * `ex.Vector.distance` improvement ··· 90 108 - Add target element id to `ex.Screen.goFullScreen('some-element-id')` to influence the fullscreen element in the fullscreen browser API. 91 109 92 110 ### Fixed 111 + - 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. 112 + - Fixed issue in Chromium browsers where Excalibur crashes if more than 256 `Image.decode()` calls are happening in the same frame. 93 113 - Fixed issue where `ex.EdgeCollider` were not working properly in `ex.CompositeCollider` for `ex.TileMap`'s 94 114 - Fixed issue where `ex.BoundingBox` overlap return false due to floating point rounding error causing multiple collisions to be evaluated sometimes 95 115 - Fixed issue with `ex.EventDispatcher` where removing a handler that didn't already exist would remove another handler by mistake
+1
sandbox/index.html
··· 11 11 <li><a href="html/index.html">Sandbox Platformer</a></li> 12 12 <li><a href="tests/parallel/">Parallel Actions</a></li> 13 13 <li><a href="tests/isometric/">Isometric Map</a></li> 14 + <li><a href="tests/decode-many/">Decode Many Images without failure (Chrome)</a></li> 14 15 <li><a href="tests/side-collision/">Arcade: Sliding on Floor</a></li> 15 16 <li><a href="tests/side-collision2/">Arcade: No clipping divergent collisions</a></li> 16 17 <li><a href="tests/tilemap-custom-edge-collider/">Edge colliders work in a tilemap</a></li>
+14
sandbox/tests/decode-many/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta http-equiv="X-UA-Compatible" content="IE=edge"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Decode Images</title> 8 + </head> 9 + <body> 10 + <p>There should be no errors in the console relating to loading images</p> 11 + <script src="../../lib/excalibur.js"></script> 12 + <script src="./index.js"></script> 13 + </body> 14 + </html>
+63
sandbox/tests/decode-many/index.ts
··· 1 + var game = new ex.Engine({ 2 + width: 800, 3 + height: 600 4 + }); 5 + 6 + var loader = new ex.Loader(); 7 + 8 + function generate() { 9 + let srcs = []; 10 + for (let i = 0; i < 800; i++) { 11 + srcs.push(generateRandomImage()); 12 + } 13 + let images = srcs.map(src => new ex.ImageSource(src)); 14 + loader.addResources(images); 15 + 16 + let sprites = images.map(i => i.toSprite()); 17 + 18 + game.currentScene.onPostDraw = ctx => { 19 + ctx.save(); 20 + ctx.scale(.25, .25); 21 + for (let i = 0; i < sprites.length; i++) { 22 + sprites[i].draw(ctx, (i * 100) % (800 * 4) + 10, Math.floor((i * 100) / (800 * 4)) * 100 + 10); 23 + } 24 + ctx.restore(); 25 + }; 26 + } 27 + 28 + function drawRandomCircleOnContext(ctx) { 29 + const x = Math.floor(Math.random() * 100); 30 + const y = Math.floor(Math.random() * 100); 31 + const radius = Math.floor(Math.random() * 20); 32 + 33 + const r = Math.floor(Math.random() * 255); 34 + const g = Math.floor(Math.random() * 255); 35 + const b = Math.floor(Math.random() * 255); 36 + 37 + ctx.beginPath(); 38 + ctx.arc(x, y, radius, Math.PI * 2, 0, false); 39 + ctx.fillStyle = "rgba(" + r + "," + g + "," + b + ",1)"; 40 + ctx.fill(); 41 + ctx.closePath(); 42 + } 43 + 44 + function generateRandomImage() { 45 + const canvas = document.createElement("canvas"); 46 + canvas.width = 100; 47 + canvas.height = 100; 48 + 49 + const ctx = canvas.getContext("2d"); 50 + ctx.clearRect(0, 0, 100, 100); 51 + 52 + for (let i = 0; i < 20; i++) { 53 + drawRandomCircleOnContext(ctx); 54 + } 55 + return canvas.toDataURL("image/png"); 56 + } 57 + 58 + 59 + generate(); 60 + 61 + 62 + 63 + game.start(loader);
+11 -7
src/engine/Graphics/ImageSource.ts
··· 4 4 import { Logger } from '../Util/Log'; 5 5 import { TextureLoader } from '.'; 6 6 import { ImageFiltering } from './Filtering'; 7 + import { Future } from '../Util/Future'; 7 8 8 9 export class ImageSource implements Loadable<HTMLImageElement> { 9 10 private _logger = Logger.getInstance(); ··· 45 46 return this.data; 46 47 } 47 48 49 + private _readyFuture = new Future<HTMLImageElement>(); 48 50 /** 49 51 * Promise the resolves when the image is loaded and ready for use, does not initiate loading 50 52 */ 51 - public ready: Promise<HTMLImageElement>; 52 - private _loadedResolve: (value?: HTMLImageElement | PromiseLike<HTMLImageElement>) => void; 53 + public ready: Promise<HTMLImageElement> = this._readyFuture.promise; 53 54 54 55 /** 55 56 * The path to the image, can also be a data url like 'data:image/' ··· 63 64 if (path.endsWith('.svg') || path.endsWith('.gif')) { 64 65 this._logger.warn(`Image type is not fully supported, you may have mixed results ${path}. Fully supported: jpg, bmp, and png`); 65 66 } 66 - this.ready = new Promise<HTMLImageElement>((resolve) => { 67 - this._loadedResolve = resolve; 68 - }); 69 67 } 70 68 71 69 /** ··· 87 85 88 86 // Decode the image 89 87 const image = new Image(); 88 + // Use Image.onload over Image.decode() 89 + // https://bugs.chromium.org/p/chromium/issues/detail?id=1055828#c7 90 + // Otherwise chrome will throw still Image.decode() failures for large textures 91 + const loadedFuture = new Future<void>(); 92 + image.onload = () => loadedFuture.resolve(); 90 93 image.src = url; 91 94 image.setAttribute('data-original-src', this.path); 92 - await image.decode(); 95 + 96 + await loadedFuture.promise; 93 97 94 98 // Set results 95 99 this.data = image; ··· 98 102 } 99 103 TextureLoader.load(this.data, this._filtering); 100 104 // todo emit complete 101 - this._loadedResolve(this.data); 105 + this._readyFuture.resolve(this.data); 102 106 return this.data; 103 107 } 104 108
+9 -10
src/engine/Loader.ts
··· 13 13 import { ImageFiltering } from './Graphics/Filtering'; 14 14 import { clamp } from './Math/util'; 15 15 import { Sound } from './Resources/Sound/Sound'; 16 + import { Future } from './Util/Future'; 16 17 17 18 /** 18 19 * Pre-loading assets ··· 310 311 311 312 data: Loadable<any>[]; 312 313 313 - private _isLoadedResolve: () => any; 314 - private _isLoadedPromise = new Promise<void>(resolve => { 315 - this._isLoadedResolve = resolve; 316 - }); 314 + private _loadingFuture = new Future<void>; 317 315 public areResourcesLoaded() { 318 - return this._isLoadedPromise; 316 + return this._loadingFuture.promise; 319 317 } 320 318 321 319 /** ··· 324 322 */ 325 323 public async load(): Promise<Loadable<any>[]> { 326 324 await this._image?.decode(); // decode logo if it exists 325 + this.canvas.flagDirty(); 327 326 328 327 await Promise.all( 329 - this._resourceList.map((r) => 330 - r.load().finally(() => { 328 + this._resourceList.map(async (r) => { 329 + await r.load().finally(() => { 331 330 // capture progress 332 331 this._numLoaded++; 333 332 this.canvas.flagDirty(); 334 - }) 335 - ) 333 + }); 334 + }) 336 335 ); 337 336 // Wire all sound to the engine 338 337 for (const resource of this._resourceList) { ··· 341 340 } 342 341 } 343 342 344 - this._isLoadedResolve(); 343 + this._loadingFuture.resolve(); 345 344 346 345 // short delay in showing the button for aesthetics 347 346 await delay(200, this._engine?.clock);
+2 -1
src/engine/Util/Clock.ts
··· 93 93 * @param timeoutMs Optionally specify a timeout in milliseconds from now, default is 0ms which means the next possible tick 94 94 */ 95 95 public schedule(cb: () => any, timeoutMs: number = 0) { 96 - const scheduledTime = this.now() + timeoutMs; 96 + // Scheduled based on internal elapsed time 97 + const scheduledTime = this._totalElapsed + timeoutMs; 97 98 this._scheduledCbs.push([cb, scheduledTime]); 98 99 } 99 100
+39
src/engine/Util/Future.ts
··· 1 + 2 + /** 3 + * Future is a wrapper around a native browser Promise to allow resolving/rejecting at any time 4 + */ 5 + export class Future<T> { 6 + // Code from StephenCleary https://gist.github.com/StephenCleary/ba50b2da419c03b9cba1d20cb4654d5e 7 + private _resolver: (value: T) => void; 8 + private _rejecter: (error: Error) => void; 9 + private _isCompleted: boolean = false; 10 + 11 + constructor() { 12 + this.promise = new Promise((resolve, reject) => { 13 + this._resolver = resolve; 14 + this._rejecter = reject; 15 + }); 16 + } 17 + 18 + public readonly promise: Promise<T>; 19 + 20 + public get isCompleted(): boolean { 21 + return this._isCompleted; 22 + } 23 + 24 + public resolve(value: T): void { 25 + if (this._isCompleted) { 26 + return; 27 + } 28 + this._isCompleted = true; 29 + this._resolver(value); 30 + } 31 + 32 + public reject(error: Error): void { 33 + if (this._isCompleted) { 34 + return; 35 + } 36 + this._isCompleted = true; 37 + this._rejecter(error); 38 + } 39 + }
+59
src/engine/Util/Semaphore.ts
··· 1 + import { Future } from './Future'; 2 + 3 + class AsyncWaitQueue<T> { 4 + // Code from StephenCleary https://gist.github.com/StephenCleary/ba50b2da419c03b9cba1d20cb4654d5e 5 + private _queue: Future<T>[] = []; 6 + 7 + public get length(): number { 8 + return this._queue.length; 9 + } 10 + 11 + public enqueue(): Promise<T> { 12 + const future = new Future<T>(); 13 + this._queue.push(future); 14 + return future.promise; 15 + } 16 + 17 + public dequeue(value: T): void { 18 + const future = this._queue.shift(); 19 + future.resolve(value); 20 + } 21 + } 22 + 23 + /** 24 + * Semaphore allows you to limit the amount of async calls happening between `enter()` and `exit()` 25 + * 26 + * This can be useful when limiting the number of http calls, browser api calls, etc either for performance or to work 27 + * around browser limitations like max Image.decode() calls in chromium being 256. 28 + */ 29 + export class Semaphore { 30 + private _waitQueue = new AsyncWaitQueue(); 31 + constructor(private _count: number) { } 32 + 33 + public get count() { 34 + return this._count; 35 + } 36 + 37 + public get waiting() { 38 + return this._waitQueue.length; 39 + } 40 + 41 + public async enter() { 42 + if (this._count !== 0) { 43 + this._count--; 44 + return Promise.resolve(); 45 + } 46 + return this._waitQueue.enqueue(); 47 + } 48 + 49 + public exit(count: number = 1) { 50 + if (count === 0) { 51 + return; 52 + } 53 + while (count !== 0 && this._waitQueue.length !== 0) { 54 + this._waitQueue.dequeue(null); 55 + count--; 56 + } 57 + this._count += count; 58 + } 59 + }
+6 -5
src/engine/Util/Util.ts
··· 1 1 import { Vector } from '../Math/vector'; 2 2 import { Clock } from './Clock'; 3 + import { Future } from './Future'; 3 4 4 5 /** 5 6 * Find the screen position of an HTML element ··· 82 83 * @param clock 83 84 */ 84 85 export function delay(milliseconds: number, clock?: Clock): Promise<void> { 86 + const future = new Future<void>(); 85 87 const schedule = clock?.schedule.bind(clock) ?? setTimeout; 86 - return new Promise<void>(resolve => { 87 - schedule(() => { 88 - resolve(); 89 - }, milliseconds); 90 - }); 88 + schedule(() => { 89 + future.resolve(); 90 + }, milliseconds); 91 + return future.promise; 91 92 }
+2
src/engine/index.ts
··· 72 72 export * from './Util/WebAudio'; 73 73 export * from './Util/Toaster'; 74 74 export * from './Util/StateMachine'; 75 + export * from './Util/Future'; 76 + export * from './Util/Semaphore'; 75 77 76 78 // ex.Deprecated 77 79 // import * as deprecated from './Deprecated';
+73
src/spec/FutureSpec.ts
··· 1 + import * as ex from '@excalibur'; 2 + 3 + describe('A Future', () => { 4 + it('exists', () => { 5 + expect(ex.Future).toBeDefined(); 6 + }); 7 + 8 + it('it can be constructed', () => { 9 + const future = new ex.Future(); 10 + 11 + expect(future).toBeDefined(); 12 + }); 13 + 14 + it('can be resolved', (done) => { 15 + const future = new ex.Future<void>(); 16 + 17 + expect(future.isCompleted).toBe(false); 18 + 19 + future.promise.then(() => { 20 + expect(future.isCompleted).toBe(true); 21 + done(); 22 + }); 23 + future.resolve(); 24 + }); 25 + 26 + it('can be resolved multiple times without error', (done) => { 27 + const future = new ex.Future<void>(); 28 + 29 + expect(future.isCompleted).toBe(false); 30 + 31 + future.promise.then(() => { 32 + expect(future.isCompleted).toBe(true); 33 + done(); 34 + }); 35 + expect(() => { 36 + future.resolve(); 37 + future.resolve(); 38 + future.resolve(); 39 + future.resolve(); 40 + }).not.toThrow(); 41 + }); 42 + 43 + it('can be rejected', (done) => { 44 + const future = new ex.Future<void>(); 45 + 46 + expect(future.isCompleted).toBe(false); 47 + 48 + future.promise.catch((err) => { 49 + expect(err).toEqual(new Error('Some error')); 50 + expect(future.isCompleted).toBe(true); 51 + done(); 52 + }); 53 + future.reject(new Error('Some error')); 54 + }); 55 + 56 + it('can be rejected multiple times without error', (done) => { 57 + const future = new ex.Future<void>(); 58 + 59 + expect(future.isCompleted).toBe(false); 60 + 61 + future.promise.catch(() => { 62 + expect(future.isCompleted).toBe(true); 63 + done(); 64 + }); 65 + expect(() => { 66 + future.reject(new Error('Some error')); 67 + future.reject(new Error('Some error')); 68 + future.reject(new Error('Some error')); 69 + future.reject(new Error('Some error')); 70 + future.reject(new Error('Some error')); 71 + }).not.toThrow(); 72 + }); 73 + });
+68
src/spec/LoaderSpec.ts
··· 266 266 267 267 expect(oldPos).not.toEqual(newPos); 268 268 }); 269 + 270 + it('does not throw when more than 256 images are being loaded', (done) => { 271 + /** 272 + * 273 + */ 274 + function drawRandomCircleOnContext(ctx) { 275 + const x = Math.floor(Math.random() * 100); 276 + const y = Math.floor(Math.random() * 100); 277 + const radius = Math.floor(Math.random() * 20); 278 + 279 + const r = Math.floor(Math.random() * 255); 280 + const g = Math.floor(Math.random() * 255); 281 + const b = Math.floor(Math.random() * 255); 282 + 283 + ctx.beginPath(); 284 + ctx.arc(x, y, radius, Math.PI * 2, 0, false); 285 + ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',1)'; 286 + ctx.fill(); 287 + ctx.closePath(); 288 + } 289 + 290 + /** 291 + * 292 + */ 293 + function generateRandomImage() { 294 + const canvas = document.createElement('canvas'); 295 + canvas.width = 100; 296 + canvas.height = 100; 297 + 298 + const ctx = canvas.getContext('2d'); 299 + ctx.clearRect(0, 0, 100, 100); 300 + 301 + for (let i = 0; i < 20; i++) { 302 + drawRandomCircleOnContext(ctx); 303 + } 304 + return canvas.toDataURL('image/png'); 305 + } 306 + 307 + const logger = ex.Logger.getInstance(); 308 + spyOn(logger, 'error').and.callThrough(); 309 + const game = TestUtils.engine({ 310 + width: 100, 311 + height: 100 312 + }); 313 + const testClock = game.clock as ex.TestClock; 314 + 315 + const loader = new ex.Loader(); 316 + 317 + const srcs = []; 318 + for (let i = 0; i < 800; i++) { 319 + srcs.push(generateRandomImage()); 320 + } 321 + const images = srcs.map(src => new ex.ImageSource(src)); 322 + images.forEach((image) => { 323 + image.ready.then(() => { 324 + testClock.step(1); 325 + }); 326 + }); 327 + loader.addResources(images); 328 + 329 + const ready = TestUtils.runToReady(game, loader).then(() => { 330 + expect(logger.error).not.toHaveBeenCalled(); 331 + done(); 332 + }) 333 + .catch(() => { 334 + fail(); 335 + }); 336 + }); 269 337 });
+84
src/spec/SemaphoreSpec.ts
··· 1 + import * as ex from '@excalibur'; 2 + import { delay } from '../engine/Util/Util'; 3 + 4 + describe('A Semaphore', () => { 5 + it('should exist', () => { 6 + expect(ex.Semaphore).toBeDefined(); 7 + }); 8 + 9 + it('can be constructed with a count', () => { 10 + const semaphore = new ex.Semaphore(10); 11 + expect(semaphore.count).toBe(10); 12 + }); 13 + 14 + it('a trivial exit does not decrement semaphor', () => { 15 + const semaphore = new ex.Semaphore(10); 16 + for (let i = 0; i < 20; i++) { 17 + semaphore.enter(); 18 + } 19 + expect(semaphore.waiting).toBe(10); 20 + expect(semaphore.count).toBe(0); 21 + semaphore.exit(0); 22 + expect(semaphore.waiting).toBe(10); 23 + expect(semaphore.count).toBe(0); 24 + }); 25 + 26 + it('can limit async calls', () => { 27 + const semaphore = new ex.Semaphore(10); 28 + for (let i = 0; i < 20; i++) { 29 + semaphore.enter(); 30 + } 31 + expect(semaphore.waiting).toBe(10); 32 + semaphore.exit(); 33 + semaphore.exit(); 34 + semaphore.exit(); 35 + expect(semaphore.waiting).toBe(7); 36 + }); 37 + 38 + 39 + it('can block async calls', (done) => { 40 + const mockFuture1 = new ex.Future<void>(); 41 + const mockMethod1 = () => { 42 + return mockFuture1.promise; 43 + }; 44 + 45 + const mockFuture2 = new ex.Future<void>(); 46 + const mockMethod2 = () => { 47 + return mockFuture2.promise; 48 + }; 49 + const semaphore = new ex.Semaphore(1); 50 + 51 + const spy1 = jasmine.createSpy(); 52 + const spy2 = jasmine.createSpy(); 53 + 54 + semaphore.enter().then(() => { 55 + expect(semaphore.count).toBe(0); 56 + expect(semaphore.waiting).toBe(0); 57 + mockMethod1().then(() => { 58 + spy1(); 59 + semaphore.exit(); 60 + }); 61 + const final = semaphore.enter().then(() => { 62 + mockMethod2().then(() => { 63 + spy2(); 64 + semaphore.exit(); 65 + }); 66 + }); 67 + expect(semaphore.count).toBe(0); 68 + expect(semaphore.waiting).toBe(1); 69 + return final; 70 + }).finally(() => { 71 + expect(spy1).toHaveBeenCalled(); 72 + expect(spy2).toHaveBeenCalled(); 73 + expect(spy1).toHaveBeenCalledBefore(spy2); 74 + done(); 75 + }); 76 + 77 + expect(spy1).not.toHaveBeenCalled(); 78 + expect(spy2).not.toHaveBeenCalled(); 79 + 80 + mockFuture1.resolve(); 81 + mockFuture2.resolve(); 82 + }); 83 + 84 + });