Skip to content

Commit

Permalink
feat: Coroutine instances (#3154)
Browse files Browse the repository at this point in the history
This PR gives users greater flexibility with Excalibur Coroutines!
* Optionally ask coroutines not to start immediately
  ```typescript
  ex.coroutine(function* () { .. }, { autostart: false });
  ```
* New `CoroutineInstance` is returned that is also awaitable
  ```typescript
  export interface CoroutineOptions {
    timing?: ScheduledCallbackTiming;
    autostart?: boolean;
  }
   
  export interface CoroutineInstance extends PromiseLike<void> {
    isRunning(): boolean;
    isComplete(): boolean;
    done: Promise<void>;
    generator: Generator<CoroutineInstance | number | Promise<any> | undefined, void, number>;
    start: () => CoroutineInstance;
    cancel: () => void;
    then: Thenable;
    [Symbol.iterator]: () => Generator<CoroutineInstance | number | Promise<any> | undefined, void, number>;
  }
  ```
  ```typescript
  const co = ex.coroutine(function* () { 
    yield 100;
  });

  await co; // wait for coroutine to finish
  ```

* `start()`/`cancel()`coroutines 

  ```typescript
  const co = ex.coroutine(function* () { 
    yield 100;
  });

  co.start();
  co.cancel();

  await co;
  ```

* nested coroutines! (Nested coroutines do not start running, they are run with their parent)
   
  ```typescript
  const result = ex.coroutine(function* () {
      yield 100;
      yield* ex.coroutine(function* () {
        const elapsed = yield 99;
      });
      yield 100;
  });
  ```
  • Loading branch information
eonarheim authored Aug 3, 2024
1 parent eddff5d commit 98ea167
Show file tree
Hide file tree
Showing 4 changed files with 240 additions and 21 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/engine/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,15 @@ export interface Context<TValue> {
export function createContext<TValue>() {
const ctx: Context<TValue> = {
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
};
Expand Down
112 changes: 94 additions & 18 deletions src/engine/Util/Coroutine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createContext, useContext } from '../Context';
import { Engine } from '../Engine';
import { ScheduledCallbackTiming } from './Clock';
export type CoroutineGenerator = () => Generator<number | Promise<any> | undefined, void, number>;
import { Logger } from './Log';
export type CoroutineGenerator = () => Generator<any | number | Promise<any> | undefined, void, number>;

const InsideCoroutineContext = createContext<boolean>();

const generatorFunctionDeclaration = /^\s*(?:function)?\*/;
/**
Expand All @@ -21,10 +25,24 @@ function isCoroutineGenerator(x: any): x is CoroutineGenerator {

export interface CoroutineOptions {
timing?: ScheduledCallbackTiming;
autostart?: boolean;
}

type Thenable = PromiseLike<void>['then'];

export interface CoroutineInstance extends PromiseLike<void> {
isRunning(): boolean;
isComplete(): boolean;
done: Promise<void>;
generator: Generator<CoroutineInstance | number | Promise<any> | undefined, void, number>;
start: () => CoroutineInstance;
cancel: () => void;
then: Thenable;
[Symbol.iterator]: () => Generator<CoroutineInstance | number | Promise<any> | 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.
Expand All @@ -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<void>;
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.
*
Expand All @@ -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<void>;
export function coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance;
/**
* Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update.
*
Expand All @@ -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<void>;
export function coroutine(coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance;
/**
* Excalibur coroutine helper, returns a promise when complete. Coroutines run before frame update.
*
Expand All @@ -74,47 +97,50 @@ 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<void>;
export function coroutine(thisArg: any, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance;
/**
*
*/
export function coroutine(...args: any[]): Promise<void> {
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<void>;
// 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<void>;
// 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<void>;
// coroutine(thisArg: any, engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance;
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<void>;
// coroutine(engine: Engine, coroutineGenerator: CoroutineGenerator, options?: CoroutineOptions): CoroutineInstance;
if (args[0] instanceof Engine) {
thisArg = globalThis;
passedEngine = args[0];
coroutineGenerator = args[1];
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();
Expand All @@ -124,14 +150,26 @@ export function coroutine(...args: any[]): Promise<void> {
'Pass an engine parameter to ex.coroutine(engine, function * {...})'
);
}
const generatorFcn = coroutineGenerator.bind(thisArg);
return new Promise<void>((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<void>((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) {
Expand All @@ -148,8 +186,46 @@ export function coroutine(...args: any[]): Promise<void> {
}
} 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;
}
135 changes: 133 additions & 2 deletions src/spec/CoroutineSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {

Check warning on line 250 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build

Async arrow function has no 'await' expression

Check warning on line 250 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build / build

Async arrow function has no 'await' expression
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 () => {

Check warning on line 275 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build

Async arrow function has no 'await' expression

Check warning on line 275 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build / build

Async arrow function has no 'await' expression
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 () => {

Check warning on line 304 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build

Async arrow function has no 'await' expression

Check warning on line 304 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build / build

Async arrow function has no 'await' expression
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 () => {

Check warning on line 326 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build

Async arrow function has no 'await' expression

Check warning on line 326 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build / build

Async arrow function has no 'await' expression
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 () => {

Check warning on line 353 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build

Async arrow function has no 'await' expression

Check warning on line 353 in src/spec/CoroutineSpec.ts

View workflow job for this annotation

GitHub Actions / build / build

Async arrow function has no 'await' expression
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);
});
});
});

0 comments on commit 98ea167

Please sign in to comment.