[JS] Making use of coroutines

3

Features on these Courses

Stats

2,601 visits, 4,026 views

Tools

Translations

This tutorial hasn't been translated.

License

This tutorial is licensed under CC BY 4.0. Please refer to the license text if you wish to reuse, share or remix the content contained within this tutorial.

Published on 5 Jul, 2019. Last updated 25 Jan, 2023

If you ever used Unity, you might be familiar with the concept of coroutines. Well, you might or might not know that Javascript supports coroutines as well since EcmaScript 6.

Coroutines

Coroutines are special functions that can stop midway, return an intermediary value, and get more values when called again. I recommend you try and read through this to understand what it is: https://x.st/javascript-coroutines/ or just look at the interactive demo

These are the two coroutines we're going to test with:

function* test() {
    console.log('Hello from coroutine 1!');
    yield null;
    console.log('back after a frame');
    yield new WaitForSeconds(1);
    console.log('back after a second');
    yield runtime.startCoroutine(test2());
    console.log('back after coroutine 2');
    console.log('end of coroutine 1');
}
function* test2() {
    console.log('---Hello from coroutine 2!');
    yield new WaitForSeconds(3);
    console.log('---back after three second');
    console.log('---end of coroutine 2');
}

This was designed to look closely to Unity's coroutine system. We still need to defined how coroutines are ran though.

First let's add a startCoroutine method to runtime using a mixin (check this tutorial to know how to do that)

let tickCallbacks = {}
let tickId = 0
let RuntimeMixin = {
	startCoroutine(coroutine){
	  	const coroutineId = tickId++

		return new Promise((resolve, reject) => {
		function progress(){
			let ret = coroutine.next()
		  	if(ret.done){
				resolve()
				delete tickCallbacks[coroutineId]
		  	} else {
				let promise;
				if(ret.value instanceof Promise){
					promise = ret.value
				} else if(ret.value instanceof CoroutineYield) {
					promise = ret.value.process()
				}

				if(promise != undefined){
					delete tickCallbacks[coroutineId]
			  		promise.then(()=>{
						tickCallbacks[coroutineId] = progress
						progress()
			  		})
				}
		  	}
		}
		tickCallbacks[coroutineId] = progress
		progress()
	  })
	}
}

In order for the method to work properly, we need to define 2 global variables and add some code to the global Tick function

let tickCallbacks = {}
let tickId = 0

function Tick(runtime){
  //console.log('Tick')
  Object.values(tickCallbacks).forEach(fn => {
    fn()
  })
}

tickId is a unique Id counter. Each time tickCallbacks is assigned a new object, it increments that variable, that way each function added to tickCallbacks has a unique number assigned to it. For the same reason, tickCallbacks is an object and not an array, as removing data from an array shifts every id by one and might break the system. Then, tick runs through every function in that object and executes it. This is going to allow us to run coroutines on every tick.

The basic idea

The basic idea of that coroutines mangement system is that when you yield a coroutine, the manager either tries to understand of a yield should wait for a specific event, or if it should just wait for one tick.

In case it should wait for a specific event, there are two cases. Either the yield returned a promise and it will wait until that promise is resolved before coming back, or the yield return a subclass of a custom class I named CoroutineYield.

Introducing a new js file: CoroutinesYield.js. Here is the content of mine

class CoroutineYield {
  constructor(){
  	
  }
  process(){
  	return new Promise((resolve, reject)=>{
    	resolve()
    })
  }
}

class WaitForSeconds extends CoroutineYield{
  constructor(time){
    super()
    this.time = time
  }
  
  process(){
    return new Promise((resolve, reject)=>{
      setTimeout(()=>{
        resolve()
      }, this.time*1000)
    })
  }
}

CoroutinesYield is a class that contains one method: process. That method returns a promise that the manager can use to know when to start the yielded coroutine again. Here the subclass WaitForSeconds's process function returns a promise that resolved after a number of seconds defined when constructing the instance.

Note that startCoroutine also returns a promise that resolves once the coroutine ends, so it can be used to pause a coroutine while waiting for another one to complete.

let RuntimeMixin = {
	startCoroutine(coroutine){
	  	const coroutineId = tickId++

		return new Promise((resolve, reject) => {
		function progress(){
			let ret = coroutine.next()
		  	if(ret.done){
				resolve()
				delete tickCallbacks[coroutineId]
		  	} else {
				let promise;
				if(ret.value instanceof Promise){
					promise = ret.value
				} else if(ret.value instanceof CoroutineYield) {
					promise = ret.value.process()
				}

				if(promise != undefined){
					delete tickCallbacks[coroutineId]
			  		promise.then(()=>{
						tickCallbacks[coroutineId] = progress
						progress()
			  		})
				}
		  	}
		}
		tickCallbacks[coroutineId] = progress
		progress()
	  })
	}
}

So basically, the startCoroutine method adds a progress function to the tickCallbacks object. That progress function calls next() on the coroutine and checks the returned value. If it's a Promise or a subclass of CoroutineYield, it removes the function from Tick, waits for the promise to resolve, then adds it back and calls it again. In case yield has any other value, it is ignored and the coroutine will yield for a frame, and start again.

Note that to start a coroutine, you must do it like so:

runtime.startCoroutine(test())

This means that you can pass any arguments you want to the coroutine before starting it.

You can also run code when the coroutine ends, like so

runtime.startCoroutine(test()).then(()=>{
  console.log('end of the initial call');
})

Remember these are the two coroutines we will use:

function* test() {
    console.log('Hello from coroutine 1!');
    yield null;
    console.log('back after a frame');
    yield new WaitForSeconds(1);
    console.log('back after a second');
    yield runtime.startCoroutine(test2());
    console.log('back after coroutine 2');
    console.log('end of coroutine 1');
}
function* test2() {
    console.log('---Hello from coroutine 2!');
    yield new WaitForSeconds(3);
    console.log('---back after three second');
    console.log('---end of coroutine 2');
}

And when we run test, this is the console output:

>Hello from coroutine 1!
>back after a frame
>back after a second
>---Hello from coroutine 2!
>---back after three second
>---end of coroutine 2
>back after coroutine 2
>end of coroutine 1
>end of the initial call

That's it for coroutines. Hope you'll make good use of them 😄

  • 0 Comments

Want to leave a comment? Login or Register an account!