From faa4a37fa5383894b3d0f53a464516af5ad21e6f Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Wed, 13 Mar 2024 18:47:48 -0500 Subject: [PATCH] feat: Allow coroutine lifecycles + support thisArg + remove engine requirement (#2965) This PR adds an optional `ex.coroutine` timing parameter as an option bag to schedule when they are updated ```typescript const result = ex.coroutine(engine, function * () {...}, { timing: 'postupdate' }) ``` This PR also adds a way to set the `this` parameter for a generator ```typescript const result = ex.coroutine({myThis: 'value'}, engine, function * () {...}) ``` Additionally this PR removes the requirement to pass engine if done so under an Excalibur lifecycle ```typescript const result = ex.coroutine(function * () {...}); ``` --------- Co-authored-by: Matt Jennings --- CHANGELOG.md | 12 ++ src/engine/Context.ts | 41 ++++++ src/engine/Engine.ts | 189 ++++++++++++++----------- src/engine/Util/Clock.ts | 21 ++- src/engine/Util/Coroutine.ts | 125 ++++++++++++++++- src/spec/ContextSpec.ts | 60 ++++++++ src/spec/CoroutineSpec.ts | 260 ++++++++++++++++++++++++++++------- src/spec/FadeInOutSpec.ts | 2 +- 8 files changed, 565 insertions(+), 145 deletions(-) create mode 100644 src/engine/Context.ts create mode 100644 src/spec/ContextSpec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5988426..a111d362a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added simplified `ex.coroutine` overloads, you need not pass engine as long as you are in an Excalibur lifecycle + ```typescript + const result = ex.coroutine(function* () {...}); + ``` +- Added way to bind 'this' to `ex.coroutine` overloads, you need not pass engine as long as you are in an Excalibur lifecycle + ```typescript + const result = ex.coroutine({myThis: 'cool'}, function* () {...}); + ``` +- Added optional `ex.coroutine` timing parameter to schedule when they are updated + ```typescript + const result = ex.coroutine(engine, function * () {...}, { timing: 'postupdate' }) + ``` - Added `GraphicsComponent.bounds` which will report the world bounds of the graphic if applicable! - Added `ex.Vector.EQUALS_EPSILON` to configure the `ex.Vector.equals(v)` threshold - Added way to add custom WebGL context lost/recovered handlers for your game diff --git a/src/engine/Context.ts b/src/engine/Context.ts new file mode 100644 index 000000000..c05bddf70 --- /dev/null +++ b/src/engine/Context.ts @@ -0,0 +1,41 @@ +export interface Context { + /** + * Run the callback before popping the context value + * @param value + * @param cb + */ + scope: (value: TValue, cb: () => TReturn) => TReturn; + value: TValue; +} + + +/** + * Creates a injectable context that can be retrieved later with `useContext(context)` + * + * Example + * ```typescript + * + * const AppContext = createContext({some: 'value'}); + * context.scope(val, () => { + * const value = useContext(AppContext); + * }) + * + * ``` + */ +export function createContext() { + const ctx: Context = { + scope: (value, cb) => { + ctx.value = value; + return cb(); + }, + value: undefined + }; + return ctx; +} + +/** + * Retrieves the value from the current context + */ +export function useContext(context: Context): TValue { + return context.value; +} \ No newline at end of file diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 230e22c0c..4075f1204 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -52,6 +52,7 @@ import { GoToOptions, SceneMap, Director, StartOptions, SceneWithOptions, WithRo import { InputHost } from './Input/InputHost'; import { DefaultPhysicsConfig, DeprecatedStaticToConfig, PhysicsConfig } from './Collision/PhysicsConfig'; import { DeepRequired } from './Util/Required'; +import { Context, createContext, useContext } from './Context'; export type EngineEvents = { fallbackgraphicscontext: ExcaliburGraphicsContext2DCanvas, @@ -354,6 +355,23 @@ export interface EngineOptions { * loading resources, and managing the scene. */ export class Engine implements CanInitialize, CanUpdate, CanDraw { + static Context: Context = createContext(); + static useEngine(): Engine { + const value = useContext(Engine.Context); + + if (!value) { + throw new Error('Cannot inject engine with `useEngine()`, `useEngine()` was called outside of Engine lifecycle scope.'); + } + + return value; + } + + /** + * Anything run under scope can use `useEngine()` to inject the current engine + * @param cb + */ + scope = (cb: () => TReturn) => Engine.Context.scope(this, cb); + /** * Current Excalibur version string * @@ -1314,7 +1332,9 @@ O|===|* >________________>\n\ * @deprecated use goToScene, it now behaves the same as goto */ public async goto(destinationScene: WithRoot, options?: GoToOptions) { - await this.director.goto(destinationScene, options); + await this.scope(async () => { + await this.director.goto(destinationScene, options); + }); } /** @@ -1348,7 +1368,9 @@ O|===|* >________________>\n\ * @param options */ public async goToScene(destinationScene: WithRoot, options?: GoToOptions): Promise { - await this.director.goto(destinationScene, options); + await this.scope(async () => { + await this.director.goto(destinationScene, options); + }); } /** @@ -1459,6 +1481,7 @@ O|===|* >________________>\n\ } // Publish preupdate events + this.clock.__runScheduledCbs('preupdate'); this._preupdate(delta); // process engine level events @@ -1468,6 +1491,7 @@ O|===|* >________________>\n\ this.graphicsContext.updatePostProcessors(delta); // Publish update event + this.clock.__runScheduledCbs('postupdate'); this._postupdate(delta); // Update input listeners @@ -1505,12 +1529,14 @@ O|===|* >________________>\n\ private _draw(delta: number) { this.graphicsContext.beginDrawLifecycle(); this.graphicsContext.clear(); + this.clock.__runScheduledCbs('predraw'); this._predraw(this.graphicsContext, delta); // Drawing nothing else while loading if (this._isLoading) { if (!this._hideLoader) { this._loader?.canvas.draw(this.graphicsContext, 0, 0); + this.clock.__runScheduledCbs('postdraw'); this.graphicsContext.flush(); this.graphicsContext.endDrawLifecycle(); } @@ -1522,6 +1548,7 @@ O|===|* >________________>\n\ this.currentScene.draw(this.graphicsContext, delta); + this.clock.__runScheduledCbs('postdraw'); this._postdraw(this.graphicsContext, delta); // Flush any pending drawings @@ -1612,32 +1639,34 @@ O|===|* >________________>\n\ */ public async start(loader?: DefaultLoader): Promise; public async start(sceneNameOrLoader?: WithRoot | DefaultLoader, options?: StartOptions): Promise { - if (!this._compatible) { - throw new Error('Excalibur is incompatible with your browser'); - } - this._isLoading = true; - let loader: DefaultLoader; - if (sceneNameOrLoader instanceof DefaultLoader) { - loader = sceneNameOrLoader; - } else if (typeof sceneNameOrLoader === 'string') { - this.director.configureStart(sceneNameOrLoader, options); - loader = this.director.mainLoader; - } + await this.scope(async () => { + if (!this._compatible) { + throw new Error('Excalibur is incompatible with your browser'); + } + this._isLoading = true; + let loader: DefaultLoader; + if (sceneNameOrLoader instanceof DefaultLoader) { + loader = sceneNameOrLoader; + } else if (typeof sceneNameOrLoader === 'string') { + this.director.configureStart(sceneNameOrLoader, options); + loader = this.director.mainLoader; + } - // Start the excalibur clock which drives the mainloop - this._logger.debug('Starting game clock...'); - this.browser.resume(); - this.clock.start(); - this._logger.debug('Game clock started'); + // Start the excalibur clock which drives the mainloop + this._logger.debug('Starting game clock...'); + this.browser.resume(); + this.clock.start(); + this._logger.debug('Game clock started'); - await this.load(loader ?? new Loader()); + await this.load(loader ?? new Loader()); - // Initialize before ready - await this._overrideInitialize(this); + // Initialize before ready + await this._overrideInitialize(this); - this._isReadyFuture.resolve(); - this.emit('start', new GameStartEvent(this)); - return this._isReadyFuture.promise; + this._isReadyFuture.resolve(); + this.emit('start', new GameStartEvent(this)); + return this._isReadyFuture.promise; + }); } /** @@ -1652,43 +1681,45 @@ O|===|* >________________>\n\ private _lagMs = 0; private _mainloop(elapsed: number) { - this.emit('preframe', new PreFrameEvent(this, this.stats.prevFrame)); - const delta = elapsed * this.timescale; - this.currentFrameElapsedMs = delta; - - // reset frame stats (reuse existing instances) - const frameId = this.stats.prevFrame.id + 1; - this.stats.currFrame.reset(); - this.stats.currFrame.id = frameId; - this.stats.currFrame.delta = delta; - this.stats.currFrame.fps = this.clock.fpsSampler.fps; - GraphicsDiagnostics.clear(); - - const beforeUpdate = this.clock.now(); - const fixedTimestepMs = 1000 / this.fixedUpdateFps; - if (this.fixedUpdateFps) { - this._lagMs += delta; - while (this._lagMs >= fixedTimestepMs) { - this._update(fixedTimestepMs); - this._lagMs -= fixedTimestepMs; + this.scope(() => { + this.emit('preframe', new PreFrameEvent(this, this.stats.prevFrame)); + const delta = elapsed * this.timescale; + this.currentFrameElapsedMs = delta; + + // reset frame stats (reuse existing instances) + const frameId = this.stats.prevFrame.id + 1; + this.stats.currFrame.reset(); + this.stats.currFrame.id = frameId; + this.stats.currFrame.delta = delta; + this.stats.currFrame.fps = this.clock.fpsSampler.fps; + GraphicsDiagnostics.clear(); + + const beforeUpdate = this.clock.now(); + const fixedTimestepMs = 1000 / this.fixedUpdateFps; + if (this.fixedUpdateFps) { + this._lagMs += delta; + while (this._lagMs >= fixedTimestepMs) { + this._update(fixedTimestepMs); + this._lagMs -= fixedTimestepMs; + } + } else { + this._update(delta); } - } else { - this._update(delta); - } - const afterUpdate = this.clock.now(); - this.currentFrameLagMs = this._lagMs; - this._draw(delta); - const afterDraw = this.clock.now(); + const afterUpdate = this.clock.now(); + this.currentFrameLagMs = this._lagMs; + this._draw(delta); + const afterDraw = this.clock.now(); - this.stats.currFrame.duration.update = afterUpdate - beforeUpdate; - this.stats.currFrame.duration.draw = afterDraw - afterUpdate; - this.stats.currFrame.graphics.drawnImages = GraphicsDiagnostics.DrawnImagesCount; - this.stats.currFrame.graphics.drawCalls = GraphicsDiagnostics.DrawCallCount; + this.stats.currFrame.duration.update = afterUpdate - beforeUpdate; + this.stats.currFrame.duration.draw = afterDraw - afterUpdate; + this.stats.currFrame.graphics.drawnImages = GraphicsDiagnostics.DrawnImagesCount; + this.stats.currFrame.graphics.drawCalls = GraphicsDiagnostics.DrawCallCount; - this.emit('postframe', new PostFrameEvent(this, this.stats.currFrame)); - this.stats.prevFrame.reset(this.stats.currFrame); + this.emit('postframe', new PostFrameEvent(this, this.stats.currFrame)); + this.stats.prevFrame.reset(this.stats.currFrame); - this._monitorPerformanceThresholdAndTriggerFallback(); + this._monitorPerformanceThresholdAndTriggerFallback(); + }); } /** @@ -1755,28 +1786,30 @@ O|===|* >________________>\n\ * @param loader Some [[Loadable]] such as a [[Loader]] collection, [[Sound]], or [[Texture]]. */ public async load(loader: DefaultLoader, hideLoader = false): Promise { - try { - // early exit if loaded - if (loader.isLoaded()) { - return; - } - this._loader = loader; - this._isLoading = true; - this._hideLoader = hideLoader; + await this.scope(async () => { + try { + // early exit if loaded + if (loader.isLoaded()) { + return; + } + this._loader = loader; + this._isLoading = true; + this._hideLoader = hideLoader; + + if (loader instanceof Loader) { + loader.suppressPlayButton = this._suppressPlayButton; + } + this._loader.onInitialize(this); - if (loader instanceof Loader) { - loader.suppressPlayButton = this._suppressPlayButton; + await loader.load(); + } catch (e) { + this._logger.error('Error loading resources, things may not behave properly', e); + await Promise.resolve(); + } finally { + this._isLoading = false; + this._hideLoader = false; + this._loader = null; } - this._loader.onInitialize(this); - - await loader.load(); - } catch (e) { - this._logger.error('Error loading resources, things may not behave properly', e); - await Promise.resolve(); - } finally { - this._isLoading = false; - this._hideLoader = false; - this._loader = null; - } + }); } } \ No newline at end of file diff --git a/src/engine/Util/Clock.ts b/src/engine/Util/Clock.ts index b090504f3..27047891a 100644 --- a/src/engine/Util/Clock.ts +++ b/src/engine/Util/Clock.ts @@ -1,6 +1,8 @@ import { Logger } from '../Util/Log'; import { FpsSampler } from './Fps'; +export type ScheduledCallbackTiming = 'preframe' | 'postframe' | 'preupdate' | 'postupdate' | 'predraw' | 'postdraw'; + export interface ClockOptions { /** * Define the function you'd like the clock to tick when it is started @@ -35,7 +37,7 @@ export abstract class Clock { public fpsSampler: FpsSampler; private _options: ClockOptions; private _elapsed: number = 1; - private _scheduledCbs: [cb: (elapsedMs: number) => any, scheduledTime: number][] = []; + private _scheduledCbs: [cb: (elapsedMs: number) => any, scheduledTime: number, timing: ScheduledCallbackTiming][] = []; private _totalElapsed: number = 0; constructor(options: ClockOptions) { this._options = options; @@ -90,17 +92,23 @@ export abstract class Clock { * stopped or paused. * @param cb callback to fire * @param timeoutMs Optionally specify a timeout in milliseconds from now, default is 0ms which means the next possible tick + * @param timing Optionally specify a timeout in milliseconds from now, default is 0ms which means the next possible tick */ - public schedule(cb: (elapsedMs: number) => any, timeoutMs: number = 0) { + public schedule(cb: (elapsedMs: number) => any, timeoutMs: number = 0, timing: ScheduledCallbackTiming = 'preframe') { // Scheduled based on internal elapsed time const scheduledTime = this._totalElapsed + timeoutMs; - this._scheduledCbs.push([cb, scheduledTime]); + this._scheduledCbs.push([cb, scheduledTime, timing]); } - private _runScheduledCbs() { + /** + * Called internally to trigger scheduled callbacks in the clock + * @param timing + * @internal + */ + public __runScheduledCbs(timing: ScheduledCallbackTiming = 'preframe') { // walk backwards to delete items as we loop for (let i = this._scheduledCbs.length - 1; i > -1; i--) { - if (this._scheduledCbs[i][1] <= this._totalElapsed) { + if (timing === this._scheduledCbs[i][2] && this._scheduledCbs[i][1] <= this._totalElapsed) { this._scheduledCbs[i][0](this._elapsed); this._scheduledCbs.splice(i, 1); } @@ -136,8 +144,9 @@ export abstract class Clock { // tick the mainloop and run scheduled callbacks this._elapsed = overrideUpdateMs || elapsed; this._totalElapsed += this._elapsed; - this._runScheduledCbs(); + this.__runScheduledCbs('preframe'); this.tick(overrideUpdateMs || elapsed); + this.__runScheduledCbs('postframe'); if (fpsInterval !== 0) { this._lastTime = now - leftover; diff --git a/src/engine/Util/Coroutine.ts b/src/engine/Util/Coroutine.ts index 9fbf84334..f51be03ac 100644 --- a/src/engine/Util/Coroutine.ts +++ b/src/engine/Util/Coroutine.ts @@ -1,6 +1,67 @@ import { Engine } from '../Engine'; +import { ScheduledCallbackTiming } from './Clock'; export type CoroutineGenerator = () => Generator | undefined, void, number>; +const generatorFunctionDeclaration = /^\s*(?:function)?\*/; +/** + * + */ +function isCoroutineGenerator(x: any): x is CoroutineGenerator { + if (typeof x !== 'function') { + return false; + } + if (generatorFunctionDeclaration.test(Function.prototype.toString.call(x))) { + return true; + } + if (!Object.getPrototypeOf) { + return false; + } + return Object.getPrototypeOf(x) === Object.getPrototypeOf(new Function('return function * () {}')()); +} + +export interface CoroutineOptions { + timing?: ScheduledCallbackTiming; +} + +/** + * Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update. + * + * Each coroutine yield is 1 excalibur frame. Coroutines get passed the elapsed time our of yield. Coroutines + * run internally on the excalibur clock. + * + * If you yield a promise it will be awaited before resumed + * If you yield a number it will wait that many ms before resumed + * @param thisArg set the "this" context of the generator, by default is globalThis + * @param engine pass a specific engine to use for running the coroutine + * @param coroutineGenerator coroutine generator function + * @param {CoroutineOptions} options optionally schedule coroutine pre/post update + */ +export function coroutine(thisArg: any, engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; +/** + * Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update. + * + * Each coroutine yield is 1 excalibur frame. Coroutines get passed the elapsed time our of yield. Coroutines + * run internally on the excalibur clock. + * + * If you yield a promise it will be awaited before resumed + * If you yield a number it will wait that many ms before resumed + * @param engine pass a specific engine to use for running the coroutine + * @param coroutineGenerator coroutine generator function + * @param {CoroutineOptions} options optionally schedule coroutine pre/post update + */ +export function coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; +/** + * Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update. + * + * Each coroutine yield is 1 excalibur frame. Coroutines get passed the elapsed time our of yield. Coroutines + * run internally on the excalibur clock. + * + * If you yield a promise it will be awaited before resumed + * If you yield a number it will wait that many ms before resumed + * @param coroutineGenerator coroutine generator function + * @param {CoroutineOptions} options optionally schedule coroutine pre/post update + */ +export function coroutine(coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; /** * Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update. * @@ -9,12 +70,62 @@ export type CoroutineGenerator = () => Generator | undefin * * If you yield a promise it will be awaited before resumed * If you yield a number it will wait that many ms before resumed - * @param engine - * @param coroutineGenerator + * @param thisArg set the "this" context of the generator, by default is globalThis + * @param coroutineGenerator coroutine generator function + * @param {CoroutineOptions} options optionally schedule coroutine pre/post update + */ +export function coroutine(thisArg: any, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; +/** + * */ -export function coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator): Promise { +export function coroutine(...args: any[]): Promise { + let coroutineGenerator: CoroutineGenerator; + let thisArg: any; + let options: CoroutineOptions | undefined; + let passedEngine: Engine | undefined; + + // coroutine(coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; + if (isCoroutineGenerator(args[0])) { + thisArg = globalThis; + coroutineGenerator = args[0]; + options = args[1]; + } + + // coroutine(thisArg: any, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; + if (isCoroutineGenerator(args[1])) { + thisArg = args[0]; + coroutineGenerator = args[1]; + options = args[2]; + } + + // coroutine(thisArg: any, engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; + if (args[1] instanceof Engine) { + thisArg = args[0]; + passedEngine = args[1]; + coroutineGenerator = args[2]; + options = args[3]; + } + + // coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; + if (args[0] instanceof Engine) { + thisArg = globalThis; + passedEngine = args[0]; + coroutineGenerator = args[1]; + options = args[2]; + } + + const schedule = options?.timing; + let engine: Engine; + try { + engine = passedEngine ?? Engine.useEngine(); + } catch (_) { + throw Error( + 'Cannot run coroutine without engine parameter outside of an excalibur lifecycle method.\n' + + 'Pass an engine parameter to ex.coroutine(engine, function * {...})'); + } + const generatorFcn = coroutineGenerator.bind(thisArg); return new Promise((resolve, reject) => { - const generator = coroutineGenerator(); + const generator = generatorFcn(); const loop = (elapsedMs: number) => { try { const { done, value } = generator.next(elapsedMs); @@ -25,14 +136,14 @@ export function coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator if (value instanceof Promise) { value.then(() => { // schedule next loop - engine.clock.schedule(loop); + engine.clock.schedule(loop, 0, schedule); }); } else if (value === undefined || value === (void 0)) { // schedule next frame - engine.clock.schedule(loop); + engine.clock.schedule(loop, 0, schedule); } else { // schedule value milliseconds from now - engine.clock.schedule(loop, value || 0); + engine.clock.schedule(loop, value || 0, schedule); } } catch (e) { reject(e); diff --git a/src/spec/ContextSpec.ts b/src/spec/ContextSpec.ts new file mode 100644 index 000000000..6a281353a --- /dev/null +++ b/src/spec/ContextSpec.ts @@ -0,0 +1,60 @@ +import { createContext, useContext } from '../../src/engine/Context'; + +describe('A Context', () => { + it('creates a context', () => { + expect(createContext()).toBeDefined(); + }); + + describe('useContext', () => { + it('returns undefined outside of scope', () => { + const ctx = createContext(); + expect(useContext(ctx)).toBeUndefined(); + }); + + it('returns scoped value', () => { + const ctx = createContext(); + ctx.scope('value', () => { + expect(useContext(ctx)).toBe('value'); + }); + }); + + it('returns scoped value in sequential scopes', () => { + const ctx = createContext(); + ctx.scope('value', () => { + expect(useContext(ctx)).toBe('value'); + }); + ctx.scope('value2', () => { + expect(useContext(ctx)).toBe('value2'); + }); + }); + + it('returns scoped value in nested scopes', () => { + const ctx = createContext(); + ctx.scope('value', () => { + expect(useContext(ctx)).toBe('value'); + ctx.scope('value2', () => { + expect(useContext(ctx)).toBe('value2'); + }); + }); + }); + + it('persists value reference after a nested context', () => { + const ctx = createContext(); + ctx.scope([], () => { + const value = useContext(ctx); + expect(value).toEqual([]); + ctx.scope([1], () => null); + expect(value).toEqual([]); + }); + }); + + it('returns value in an async callback', async () => { + const ctx = createContext(); + await ctx.scope('value', async () => { + const value = useContext(ctx); + await Promise.resolve(); + expect(value).toBe('value'); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/spec/CoroutineSpec.ts b/src/spec/CoroutineSpec.ts index c5225b63b..409ab46b7 100644 --- a/src/spec/CoroutineSpec.ts +++ b/src/spec/CoroutineSpec.ts @@ -7,76 +7,230 @@ describe('A Coroutine', () => { }); it('can be run', async () => { - const engine = TestUtils.engine({ width: 100, height: 100}); - const clock = engine.clock as ex.TestClock; - clock.start(); - const result = ex.coroutine(engine, function * () { - const elapsed = yield; - expect(elapsed).toBe(100); - yield; + const engine = TestUtils.engine({ width: 100, height: 100 }); + await engine.scope(async () => { + const clock = engine.clock as ex.TestClock; + clock.start(); + const result = ex.coroutine(function* () { + const elapsed = yield; + expect(this).toBe(globalThis); + expect(elapsed).toBe(100); + yield; + }); + clock.step(100); + clock.step(100); + await expectAsync(result).toBeResolved(); + engine.dispose(); + }); + }); + + it('can should throw without engine outside scope ', () => { + ex.Engine.Context.scope(null, () => { + expect(() => { + const result = ex.coroutine(function* () { + const elapsed = yield; + expect(this).toBe(globalThis); + expect(elapsed).toBe(100); + yield; + }); + }).toThrowError( + 'Cannot run coroutine without engine parameter outside of an excalibur lifecycle method.\n' + + 'Pass an engine parameter to ex.coroutine(engine, function * {...})' + ); + }); + }); + + it('can bind a this arg overload 1', async () => { + const engine = TestUtils.engine({ width: 100, height: 100 }); + await engine.scope(async () => { + const clock = engine.clock as ex.TestClock; + clock.start(); + const myThis = { super: 'cool' }; + const result = ex.coroutine(myThis, function* () { + const elapsed = yield; + expect(this).toBe(myThis); + expect(elapsed).toBe(100); + yield; + }); + clock.step(100); + clock.step(100); + await expectAsync(result).toBeResolved(); + engine.dispose(); + }); + }); + + it('can bind a this arg overload 2', async () => { + const engine = TestUtils.engine({ width: 100, height: 100 }); + await engine.scope(async () => { + const clock = engine.clock as ex.TestClock; + clock.start(); + const myThis = { super: 'cool' }; + const result = ex.coroutine(myThis, engine, function* () { + const elapsed = yield; + expect(this).toBe(myThis); + expect(elapsed).toBe(100); + yield; + }); + clock.step(100); + clock.step(100); + await expectAsync(result).toBeResolved(); + engine.dispose(); }); - clock.step(100); - clock.step(100); - await expectAsync(result).toBeResolved(); }); + + it('can run overload 2', async () => { + const engine = TestUtils.engine({ width: 100, height: 100 }); + await engine.scope(async () => { + const clock = engine.clock as ex.TestClock; + clock.start(); + const result = ex.coroutine(engine, function* () { + const elapsed = yield; + expect(this).toBe(globalThis); + expect(elapsed).toBe(100); + yield; + }); + clock.step(100); + clock.step(100); + await expectAsync(result).toBeResolved(); + engine.dispose(); + }); + }); + + it('can be run on scheduled timing', async () => { + const engine = TestUtils.engine({ width: 100, height: 100 }); + await engine.scope(async () => { + const clock = engine.clock as ex.TestClock; + clock.start(); + + const calls = jasmine.createSpy('calls'); + + const preframe = ex.coroutine(function* () { + const elapsed = yield; + calls('preframe'); + expect(elapsed).toBe(100); + yield; + }, { timing: 'preframe' }); + + const postframe = ex.coroutine(function* () { + const elapsed = yield; + calls('postframe'); + expect(elapsed).toBe(100); + yield; + }, { timing: 'postframe' }); + + const preupdate = ex.coroutine(function* () { + const elapsed = yield; + calls('preupdate'); + expect(elapsed).toBe(100); + yield; + }, { timing: 'preupdate' }); + + const postupdate = ex.coroutine(function* () { + const elapsed = yield; + calls('postupdate'); + expect(elapsed).toBe(100); + yield; + }, { timing: 'postupdate' }); + + const predraw = ex.coroutine(function* () { + const elapsed = yield; + calls('predraw'); + expect(elapsed).toBe(100); + yield; + }, { timing: 'predraw' }); + + const postdraw = ex.coroutine(function* () { + const elapsed = yield; + calls('postdraw'); + expect(elapsed).toBe(100); + yield; + }, { timing: 'postdraw' }); + + clock.step(100); + clock.step(100); + expect(calls.calls.allArgs()).toEqual([ + ['preframe'], + ['preupdate'], + ['postupdate'], + ['predraw'], + ['postdraw'], + ['postframe'] + ]); + await expectAsync(preframe).toBeResolved(); + await expectAsync(preupdate).toBeResolved(); + await expectAsync(postupdate).toBeResolved(); + await expectAsync(predraw).toBeResolved(); + await expectAsync(postdraw).toBeResolved(); + await expectAsync(postframe).toBeResolved(); + }); + }); + + it('can wait for given ms', async () => { - const engine = TestUtils.engine({ width: 100, height: 100}); + const engine = TestUtils.engine({ width: 100, height: 100 }); const clock = engine.clock as ex.TestClock; clock.start(); - const result = ex.coroutine(engine, function * () { - const elapsed = yield 200; - expect(elapsed).toBe(200); - yield; + await engine.scope(async () => { + const result = ex.coroutine(function* () { + const elapsed = yield 200; + expect(elapsed).toBe(200); + yield; + }); + // wait 200 ms + clock.step(200); + // 1 more yield + clock.step(100); + await expectAsync(result).toBeResolved(); }); - // wait 200 ms - clock.step(200); - // 1 more yield - clock.step(100); - await expectAsync(result).toBeResolved(); }); it('can wait for a promise', async () => { - const engine = TestUtils.engine({ width: 100, height: 100}); - const clock = engine.clock as ex.TestClock; - clock.start(); - const result = ex.coroutine(engine, function * () { - const elapsed = yield ex.Util.delay(1000, clock); - expect(elapsed).toBe(1); - yield; - }); - // wait 200 ms - clock.step(1000); + const engine = TestUtils.engine({ width: 100, height: 100 }); + await engine.scope(async () => { + const clock = engine.clock as ex.TestClock; + clock.start(); + const result = ex.coroutine(function* () { + const elapsed = yield ex.Util.delay(1000, clock); + expect(elapsed).toBe(1); + yield; + }); + // wait 200 ms + clock.step(1000); - // flush - await Promise.resolve(); - clock.step(0); + // flush + await Promise.resolve(); + clock.step(0); - // 1 more yield - clock.step(100); - await expectAsync(result).toBeResolved(); + // 1 more yield + clock.step(100); + await expectAsync(result).toBeResolved(); + }); }); it('can throw error', async () => { - const engine = TestUtils.engine({ width: 100, height: 100}); - const clock = engine.clock as ex.TestClock; - clock.start(); - const result = ex.coroutine(engine, function * () { - const elapsed = yield ex.Util.delay(1000, clock); - expect(elapsed).toBe(1); - yield; - throw Error('error'); - }); - // wait 200 ms - clock.step(1000); + const engine = TestUtils.engine({ width: 100, height: 100 }); + await engine.scope(async () => { + const clock = engine.clock as ex.TestClock; + clock.start(); + const result = ex.coroutine(function* () { + const elapsed = yield ex.Util.delay(1000, clock); + expect(elapsed).toBe(1); + yield; + throw Error('error'); + }); + // wait 200 ms + clock.step(1000); - // flush - await Promise.resolve(); - clock.step(0); + // flush + await Promise.resolve(); + clock.step(0); - // 1 more yield - clock.step(100); - await expectAsync(result).toBeRejectedWithError('error'); + // 1 more yield + clock.step(100); + await expectAsync(result).toBeRejectedWithError('error'); + engine.dispose(); + }); }); }); \ No newline at end of file diff --git a/src/spec/FadeInOutSpec.ts b/src/spec/FadeInOutSpec.ts index 5151b1506..55411102d 100644 --- a/src/spec/FadeInOutSpec.ts +++ b/src/spec/FadeInOutSpec.ts @@ -45,7 +45,7 @@ describe('A FadeInOut transition', () => { engine.addScene('newScene', { scene, transitions: { in: sut } }); const goto = engine.goto('newScene'); - await TestUtils.flushMicrotasks(clock, 13); + await TestUtils.flushMicrotasks(clock, 15); clock.step(500); await Promise.resolve(); expect(onDeactivateSpy).toHaveBeenCalledTimes(1);