diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a736416f..d26a38f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,11 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- New updates to `ex.coroutine(...)` + * New `ex.CoroutineInstance` is returned (still awaitable) + * Control coroutine autostart with `ex.coroutine(function*(){...}, {autostart: false})` + * `.start()` and `.cancel()` coroutines + * Nested coroutines! - Excalibur will now clean up WebGL textures that have not been drawn in a while, which improves stability for long game sessions * If a graphic is drawn again it will be reloaded into the GPU seamlessly - You can now query for colliders on the physics world diff --git a/src/engine/Context.ts b/src/engine/Context.ts index 55c0a8d8c..3ead8c1d2 100644 --- a/src/engine/Context.ts +++ b/src/engine/Context.ts @@ -24,8 +24,15 @@ export interface Context { export function createContext() { const ctx: Context = { scope: (value, cb) => { + const old = ctx.value; ctx.value = value; - return cb(); + try { + return cb(); + } catch (e) { + throw e; + } finally { + ctx.value = old; + } }, value: undefined }; diff --git a/src/engine/Util/Coroutine.ts b/src/engine/Util/Coroutine.ts index 8a21498ea..777a47db1 100644 --- a/src/engine/Util/Coroutine.ts +++ b/src/engine/Util/Coroutine.ts @@ -1,6 +1,10 @@ +import { createContext, useContext } from '../Context'; import { Engine } from '../Engine'; import { ScheduledCallbackTiming } from './Clock'; -export type CoroutineGenerator = () => Generator | undefined, void, number>; +import { Logger } from './Log'; +export type CoroutineGenerator = () => Generator | undefined, void, number>; + +const InsideCoroutineContext = createContext(); const generatorFunctionDeclaration = /^\s*(?:function)?\*/; /** @@ -21,10 +25,24 @@ function isCoroutineGenerator(x: any): x is CoroutineGenerator { export interface CoroutineOptions { timing?: ScheduledCallbackTiming; + autostart?: boolean; +} + +type Thenable = PromiseLike['then']; + +export interface CoroutineInstance extends PromiseLike { + isRunning(): boolean; + isComplete(): boolean; + done: Promise; + generator: Generator | undefined, void, number>; + start: () => CoroutineInstance; + cancel: () => void; + then: Thenable; + [Symbol.iterator]: () => Generator | undefined, void, number>; } /** - * Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update. + * Excalibur coroutine helper, returns a [[CoroutineInstance]] which is promise-like when complete. Coroutines run before frame update by default. * * Each coroutine yield is 1 excalibur frame. Coroutines get passed the elapsed time our of yield. Coroutines * run internally on the excalibur clock. @@ -36,7 +54,12 @@ export interface CoroutineOptions { * @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; +export function coroutine( + thisArg: any, + engine: Engine, + coroutineGenerator: CoroutineGenerator, + options?: CoroutineOptions +): CoroutineInstance; /** * Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update. * @@ -49,7 +72,7 @@ export function coroutine(thisArg: any, engine: Engine, coroutineGenerator: Coro * @param coroutineGenerator coroutine generator function * @param {CoroutineOptions} options optionally schedule coroutine pre/post update */ -export function coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; +export function coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance; /** * Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update. * @@ -61,7 +84,7 @@ export function coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator * @param coroutineGenerator coroutine generator function * @param {CoroutineOptions} options optionally schedule coroutine pre/post update */ -export function coroutine(coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; +export function coroutine(coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance; /** * Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update. * @@ -74,31 +97,32 @@ export function coroutine(coroutineGenerator: CoroutineGenerator, options?: Coro * @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(thisArg: any, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance; /** * */ -export function coroutine(...args: any[]): Promise { +export function coroutine(...args: any[]): CoroutineInstance { + const logger = Logger.getInstance(); let coroutineGenerator: CoroutineGenerator; let thisArg: any; let options: CoroutineOptions | undefined; let passedEngine: Engine | undefined; - // coroutine(coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; + // coroutine(coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance; if (isCoroutineGenerator(args[0])) { thisArg = globalThis; coroutineGenerator = args[0]; options = args[1]; } - // coroutine(thisArg: any, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; + // coroutine(thisArg: any, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance; if (isCoroutineGenerator(args[1])) { thisArg = args[0]; coroutineGenerator = args[1]; options = args[2]; } - // coroutine(thisArg: any, engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; + // coroutine(thisArg: any, engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance; if (args[1] instanceof Engine) { thisArg = args[0]; passedEngine = args[1]; @@ -106,7 +130,7 @@ export function coroutine(...args: any[]): Promise { options = args[3]; } - // coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): Promise; + // coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance; if (args[0] instanceof Engine) { thisArg = globalThis; passedEngine = args[0]; @@ -114,7 +138,9 @@ export function coroutine(...args: any[]): Promise { options = args[2]; } + const inside = useContext(InsideCoroutineContext); const schedule = options?.timing; + const autostart = inside ? false : options?.autostart ?? true; let engine: Engine; try { engine = passedEngine ?? Engine.useEngine(); @@ -124,14 +150,26 @@ export function coroutine(...args: any[]): Promise { 'Pass an engine parameter to ex.coroutine(engine, function * {...})' ); } - const generatorFcn = coroutineGenerator.bind(thisArg); - return new Promise((resolve, reject) => { - const generator = generatorFcn(); - const loop = (elapsedMs: number) => { + + let started = false; + let completed = false; + let cancelled = false; + const generatorFcn = coroutineGenerator.bind(thisArg) as CoroutineGenerator; + const generator = generatorFcn(); + let loop: (elapsedMs: number) => void; + const complete = new Promise((resolve, reject) => { + loop = (elapsedMs: number) => { try { - const { done, value } = generator.next(elapsedMs); - if (done) { + if (cancelled) { + completed = true; resolve(); + return; + } + const { done, value } = InsideCoroutineContext.scope(true, () => generator.next(elapsedMs)); + if (done || cancelled) { + completed = true; + resolve(); + return; } if (value instanceof Promise) { @@ -148,8 +186,46 @@ export function coroutine(...args: any[]): Promise { } } catch (e) { reject(e); + return; } }; - loop(engine.clock.elapsed()); // run first frame immediately + if (autostart) { + started = true; + loop(engine.clock.elapsed()); // run first frame immediately + } }); + + const co: CoroutineInstance = { + isRunning: () => { + return started && !cancelled && !completed; + }, + isComplete: () => { + return completed; + }, + cancel: () => { + cancelled = true; + }, + start: () => { + if (!started) { + started = true; + loop(engine.clock.elapsed()); + } else { + logger.warn( + '.start() was called on a coroutine that was already started, this is probably a bug:\n', + Function.prototype.toString.call(generatorFcn) + ); + } + return co; + }, + generator, + done: complete, + then: complete.then.bind(complete), + [Symbol.iterator]: () => { + // TODO warn if a coroutine is already running + // TODO warn if a coroutine is cancelled + return generator; + } + }; + + return co; } diff --git a/src/spec/CoroutineSpec.ts b/src/spec/CoroutineSpec.ts index 9fbf9c425..16a1191c1 100644 --- a/src/spec/CoroutineSpec.ts +++ b/src/spec/CoroutineSpec.ts @@ -229,7 +229,7 @@ describe('A Coroutine', () => { const elapsed = yield ex.Util.delay(1000, clock); expect(elapsed).toBe(1); yield; - throw Error('error'); + throw Error('error here'); }); // wait 200 ms clock.step(1000); @@ -240,8 +240,139 @@ describe('A Coroutine', () => { // 1 more yield clock.step(100); - await expectAsync(result).toBeRejectedWithError('error'); + await expectAsync(result).toBeRejectedWithError('error here'); engine.dispose(); }); }); + + it('can stop coroutines', 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(function* () { + yield 100; + yield 100; + yield 100; + throw Error('should not error'); + }); + + expect(result.isRunning()).toBe(true); + clock.step(100); + clock.step(100); + result.cancel(); + expect(result.isRunning()).toBe(false); + clock.step(100); + expect(result.isRunning()).toBe(false); + engine.dispose(); + }); + }); + + it('can start coroutines', async () => { + const engine = TestUtils.engine({ width: 100, height: 100 }); + const logger = ex.Logger.getInstance(); + spyOn(logger, 'warn'); + await engine.scope(async () => { + const clock = engine.clock as ex.TestClock; + clock.start(); + const result = ex.coroutine( + function* () { + yield 100; + yield 100; + yield 100; + }, + { autostart: false } + ); + + expect(result.isRunning()).toBe(false); + clock.step(100); + result.start(); + result.start(); + expect(logger.warn).toHaveBeenCalled(); + clock.step(100); + clock.step(100); + expect(result.isRunning()).toBe(true); + clock.step(100); + expect(result.isRunning()).toBe(false); + expect(result.isComplete()).toBe(true); + engine.dispose(); + }); + }); + + it('can have nested coroutines', 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(function* () { + yield 100; + yield* ex.coroutine(function* () { + const elapsed = yield 99; + expect(elapsed).toBe(99); + }); + yield 100; + }); + + clock.step(100); + clock.step(99); + clock.step(100); + + expect(result.isRunning()).toBe(false); + }); + }); + + it('can iterate over coroutines', 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( + function* () { + yield 100; + yield 200; + yield 300; + yield* ex.coroutine(function* () { + yield; + yield 400; + }); + }, + { autostart: false } + ); + + expect(result.generator.next().value).toBe(100); + expect(result.generator.next().value).toBe(200); + expect(result.generator.next().value).toBe(300); + expect(result.generator.next().value).toBe(400); + + expect(result.isRunning()).toBe(false); + }); + }); + + it('can iterate over coroutines', 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( + function* () { + yield 100; + yield 200; + yield 300; + yield* ex.coroutine(function* () { + yield; + yield 400; + }); + }, + { autostart: false } + ); + + let i = 0; + const results = [100, 200, 300, 400]; + for (const val of result) { + expect(val).toBe(results[i++]); + } + + expect(result.isRunning()).toBe(false); + }); + }); });