Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Coroutine instances #3154

Merged
merged 3 commits into from
Aug 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@

// 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
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
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
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
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
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);
});
});
});
Loading