RMMV Bind and async/await: how to sequence long events in pure javascript

_neverGiveUp

Warper
Member
Joined
Apr 11, 2021
Messages
4
Reaction score
7
First Language
English
Primarily Uses
RMMV
I've been learning RPG Maker MV scripting and I must say the power of bind and async/await cannot be overstated.

I should note that none of the code below is well tested; it's only for example purposes. I have reason to believe it works, as it was taken from working code and variable and function names were adapted to clarify their purposes as examples, but keep in mind this code is provided only to demonstrate the principles behind it, and is not intended as an out-of-the-box solution. Maybe I'll develop it more and turn it into a plugin one day, but today is not that day. For now, these are just my thoughts in code form.

Bind

The following code demonstrates the usefulness of bind.

Code:
var MP = {}; // stands for MyPlugin

// (but conveniently also stands for monkeyPatch)
// [EDIT: Fixed this method; persistently binding newImplementation to object was not a good idea.]
MP.monkeyPatch = function (object, methodName, newImplementation) {
    let oldImplementation = object[methodName];
    object[methodName] = function () {
        return newImplementation.bind(this, oldImplementation.bind(this)).apply(this, arguments);
    }
};

MP.protoPatch = function (klass, methodName, newImplementation) {
    MP.monkeyPatch(klass.prototype, methodName, newImplementation);
};

This greatly simplifies monkey-patching methods. You know that thing plugins always do that looks like this?

Code:
// example of how it's usually done
MP.Game_Actor_foo = Game_Actor.prototype.foo;
Game_Actor.prototype.foo = function (bar, baz) {
    MP.Game_Actor_foo.call(this, bar, baz);
    // insert override-specific code here
};

Dedicated monkeyPatch and protoPatch methods can simplify that code to this:
Code:
MP.protoPatch(Game_Actor, 'foo', function (oldFoo, bar, baz) {
    oldFoo(bar, baz); // don't need .call(this)
    // insert override-specific code here
});

Async/await

A recent MV JS engine upgrade incidentally introduced async/await support. If you're like me, you were somewhat disappointed that the move from Ruby to JS saw MV do away with Fiber.yield, in favor of Game_Interpreter's much less powerful wait mode feature, that can only make Game_Interpreter itself wait, and cannot induce a wait in the already-eval'd script that you're making Game_Interpreter wait from. Well, async/await basically brings Fiber.yield back.

First we need to introduce a new manually controlled wait mode that will make Game_Interpreter wait indefinitely until setWaitMode is called again from an external source.
Code:
MP.protoPatch(
    Game_Interpreter, 'updateWaitMode',
    function (old) {
        return this._waitMode == 'manual' || old();
    }
);

Add simple async utility methods...
... for waiting for a number of ticks:
Code:
MP.wait = function (n) {
    return new Promise((x) => setTimeout(x, n*1000/60));
};

... for waiting for an arbitrary condition:
Code:
MP.waitFor = async function (f, n = 1) {
    while (!f()) {
        await MP.wait(n);
    }
};

... for waiting on async code from an event page context:
Code:
Game_Interpreter.prototype.await = async function (p) {
    this.setWaitMode('manual');
    await p;
    this.setWaitMode('');
};

Add some methods for doing simple eventing from within a script or plugin, such as...
... displaying messages:
Code:
MP.msg = async function (text) {
    // allow text or array of text
    if (text instanceof Array) {
        text = text.join("\n");
    } else {
        // remove indentation from multiline strings
        text = text.split("\n").map((s) => s.trim()).join("\n");
    }
    // show it
    $gameMessage.add(text);
    // THE CRUCIAL PIECE:
    // IF CALLED WITH AWAIT,
    // do not return UNTIL the message is dismissed
    await MP.waitFor(() => !$gameMessage.isBusy());
};

... displaying menus:
Code:
MP.menu = async function (choices) {
    // 0: put cursor on first option
    // -2: branch on cancel
    $gameMessage.setChoices(choices, 0, -2);
    // return the option chosen
    return choices[
        // THE CRUCIAL PIECE:
        // IF CALLED WITH AWAIT,
        // do not return the option chosen
        // UNTIL the option is chosen
        await new Promise(
            (x) => $gameMessage.setChoiceCallback(x)
        )
    ] || false; // false will be returned on cancel
};

... setting move routes:
Code:
MP.setMoveRoute = async function (e, route) {
    // allow text or array of text
    if (!(route instanceof Array)) {
        route = route.split("\n");
    }
    // convert array of command strings
    // into array of actual commands
    let wait = true; // wait for move completion (default yes)
    let skip = false; // skip if cannot move (default no)
    route = route.concat(['end']).map((command) => {
        // extract params if there are any
        command = command.split(':');
        command = {
            code: command[0],
            params: command[1] || ''
        };
        // convert the command code to underscores + all caps
        command.code = command.code
            .split(' ').filter((s) => !!s).join('_').toUpperCase();
        // identify commands that are actually move route flags
        if (command.code == "[DON'T_WAIT]") {
            wait = false;
            return null;
        } else if (command.code == "[SKIP]") {
            skip = true;
            return null;
        }
        // lookup the command code
        command.code = Game_Character['ROUTE_' + command.code];
        // skip invalid commands
        if (command.code === undefined) {
            return null;
        }
        // split and eval params
        command.params = command.params
            .split(' ').filter((s) => !!s).map(eval);
        // finally force move route
        e.forceMoveRoute({
            list: route,
            wait: wait,
            repeat: false,
            skippable: skip
        });
        // THE CRUCIAL PIECE:
        // IF CALLED WITH AWAIT,
        // do not return UNTIL the character is finished
        // executing the move route
        if (wait) {
            await MP.waitFor(() => !e.isMoveRouteForcing());
        }
    }).filter((command) => !!command);
};

And now we have magic!
Code:
MP.myComplexEventSequence = function (interpreter) {
    // ^ call from an event page with MP.myComplexEventSequence(this)
    let thisEvent = $gameMap.event(interpreter._eventId);
    interpreter.await(async () => {
        await MP.message("I will wait for 60 frames after saying this");
        await MP.wait(60);
        let choice;
        do {
            await MP.message("How should I move?");
            choice = await MP.menu([
                "Go in a circle",
                "Go in a different circle",
                "Let's dance"
            ]);
            if (choice == "Go in a circle") {
                await MP.setMoveRoute(thisEvent, `
                    through on
                    move up
                    move left
                    move down
                    move right
                    through off
                    turn toward
                `);
            } else if (choice == "Go in a different circle") {
                await MP.setMoveRoute(thisEvent, `
                    through on
                    move up
                    move right
                    move down
                    move left
                    through off
                    turn toward
                `);
            } else if (choice == "Let's dance") {
                await MP.setMoveRoute($gamePlayer, `
                    through on
                    move forward
                    move right
                    move down
                    turn left
                `);
                await MP.setMoveRoute(thisEvent, `
                    turn right
                `);
                await MP.wait(30);
                await MP.setMoveRoute($gamePlayer, `
                    [don't wait]
                    dir fix on
                    move up
                    dir fix off
                    turn down
                    dir fix on
                    move left
                    dir fix off
                    turn right
                    dir fix on
                    move down
                    dir fix off
                    turn up
                    dir fix on
                    move right
                    dir fix off
                    turn left
                `);
                await MP.setMoveRoute(thisEvent, `
                    dir fix on
                    move down
                    dir fix off
                    turn up
                    dir fix on
                    move right
                    dir fix off
                    turn left
                    dir fix on
                    move up
                    dir fix off
                    turn down
                    dir fix on
                    move left
                    dir fix off
                    turn right
                `);
                await MP.setMoveRoute($gamePlayer, `
                    move up
                    move left
                    through off
                `);
                await MP.msg(
                    "Now we are stacked on top of each other."
                );
                await MP.msg(
                    `There was not a more convenient way
                    to make sure you didn't wind up
                    stuck on an impassable tile.`
                );
                await MP.msg(
                    `From the context it kinda looks
                    like we're kissing lol`
                );
                break;
            }
        } while (choice);
    });
};
 
Last edited:

Jragyn

JABS codemonkey
Veteran
Joined
Aug 14, 2012
Messages
175
Reaction score
151
First Language
English
Primarily Uses
RMMZ
Dedicated monkeyPatch and protoPatch methods can simplify that code to this:
Code:
MP.protoPatch(Game_Actor, 'foo', function (oldFoo, bar, baz) {
oldFoo(bar, baz); // don't need .call(this)
// insert override-specific code here
});
Regarding this alternative monkeypatch method, I've seen stuff like this when rooting through the Cyclone-Movement plugin trying to understand what the heck they were doing (though I'm familiar with ES6+, after working in what you showed as the "what usually we do" forever, seeing a plugin written like that was jarring). That actually looks pretty nifty. I'll have to see how I can integrate that cleaner approach.

Regarding async/await, well... typically when one async/await is introduced to the project, it has to be propagated up to the root. Does this somehow not have such a problem? I suppose it may be limited to only the scope of what you write into your own plugins that would require the new stuff... Still, interesting nonetheless, though I don't know if I'd make use of such a system in an otherwise mostly-synchronous codebase. (there are some things that are "async" in a pubsub sort of way... but those don't count!)

Either way, this is pretty cool! I wonder if this'll impact how others start writing their plugins?
 

_neverGiveUp

Warper
Member
Joined
Apr 11, 2021
Messages
4
Reaction score
7
First Language
English
Primarily Uses
RMMV
Regarding async/await, well... typically when one async/await is introduced to the project, it has to be propagated up to the root. Does this somehow not have such a problem? I suppose it may be limited to only the scope of what you write into your own plugins that would require the new stuff... Still, interesting nonetheless, though I don't know if I'd make use of such a system in an otherwise mostly-synchronous codebase. (there are some things that are "async" in a pubsub sort of way... but those don't count!)
You're probably more knowledgeable than I am about the potential problems of introducing async/await without making the whole codebase async/await. For instance, I had to look up what pubsub was.

That being said, I think I have a general idea of the potential problems. Namely, async functions return promises, but only async functions can await promises, so if you call an async function from a normal function, there's no way to get the normal function to wait for the async function to return -- am I right, is this what you're talking about?

My approach sort of addresses that problem. At least it tries to with this code snippet:
Code:
MP.protoPatch(
    Game_Interpreter, 'updateWaitMode',
    function (old) {
        return this._waitMode == 'manual' || old();
    }
);
Game_Interpreter.prototype.await = async function (p) {
    this.setWaitMode('manual');
    await p;
    this.setWaitMode('');
};

The idea is that if you're working from within a common event, troop event page, or map event page, and you have a game-specific plugin that exposes async code that needs to basically "take event sequencing into its own hands" for awhile, then instead of await myAsyncFunction() -- which you can't do, because Game_Interpreter is synchronous -- you can call this.await (myAsyncFunction()).

The method Game_Interpreter#await that I've defined does run as async, so in theory, the Game_Interpreter code will march on after calling the method, since it's not awaiting the call. However, Game_Interpreter has its own way of waiting for termination of commands in the event page it's processing: it has a _waitCount, for simple frame-based waits, and a _waitMode, for condition-based waits, and as long as its _waitCount is above zero and/or its _waitMode is populated with a value, it won't progress along its event page. Here we define a custom _waitMode whose condition is simply always true, and we leverage it to make the Game_Interpreter instance suspend itself when it receives the promise to await, and not clear its wait mode until the promise resolves.

There is a potential problem here. It's inherent to the synchronous design of the MV engine, as you observed. Namely, though our custom method Game_Interpreter#await does block at event page level, it does not block at JS level. Therefore, Game_Interpreter#await should not be called more than once in a single event page script eval command, or else the calls will coincide.

This is in fact the very problem the workaround set out to sidestep. Rather than completely solve the problem, the workaround isolates the problem to an easily avoidable situation. We wind up with a codebase where a script eval command in an event page can only do as much work as one sequence of event page commands can do, and if we try to make it do as much work as two or three such sequences then they'll occur simultaneously and we may run into problems, but this is still a major upgrade from the stock codebase where a script eval command in an event page can only do as much work as one individual event page command before the jobs start occurring simultaneously and causing problems.

Particularly, this is a major boon to us because even though we can only do as much work as one command sequence could do, one command sequence can do unlimited work if its length is unbounded; which is to say, there is never a reason to call Game_Interpreter#await multiple times in a single script eval command, when you could just do this.await((async () => {...})()) and await your multiple async calls in sequence inside the body of the async lambda.

EDIT (again)

Particularly, this is a major boon to us because even though we can only do as much work as one command sequence could do, one command sequence can do unlimited work if its length is unbounded; which is to say, there is never a reason to call Game_Interpreter#await multiple times in a single script eval command, when you could just do this.await((async () => {...})()) and await your multiple async calls in sequence inside the body of the async lambda.

Oh my god i just realized something.
CONSIDER:
Code:
Game_Interpreter.prototype.command355 = function () {
    // unmodified from stock codebase
    var script = this.currentCommand().parameters[0] + '\n';
    while (this.nextEventCode() === 655) {
        this._index++;
        script += this.currentCommand().parameters[0] + '\n';
    }
    // modifications start here
    this.setWaitMode('manual');
    (async (interpreter, script) => {
        await eval(`async () => {${script}}`)();
        interpreter.setWaitMode('');
    })(this, script);
};

By patching the Game_Interpreter#await implementation into the code for processing script eval commands, we can fully allow and support awaiting async functions directly in script eval commands, without affecting the behavior of Game_Interpreter for cases where async functions do not need to be called. This entirely bridges the gap between the synchronous stock codebase and async plugin code, at least for the case of script eval commands. Can't think of where else it might matter, actually, so that might be enough.

EDIT (again)

Actually that might not be enough. Any plugin function that needs to do something across multiple in-game frames will generally have to be async (or be really hard to code), and there are, come to think of it, situations -- many situations, actually -- where we might want to do something across multiple frames other than in event commands.

I have a stupid idea for a crazy mega-plugin. It would probably break compatibility with virtually all other plugins and only be able to run vanilla games. People who wanted to use other plugins would have to port them to this plugin. But it might all be worth it just for being able to do what I have in mind.

What if I took.
The entire stock codebase.

And wrote an async overhaul so extensive it would basically be a completely different runtime that happens to process the same game data files.

... It sounds painful. Really painful. I'll see how far what I have now can get me, and bear this plugin idea in mind as a possible last resort or distant future project.
 
Last edited:

caethyril

^_^
Veteran
Joined
Feb 21, 2018
Messages
2,419
Reaction score
1,830
First Language
EN
Primarily Uses
RMMZ
I don't much want to get involved in this discussion, but...
Any plugin function that needs to do something across multiple in-game frames will generally have to be async (or be really hard to code)
I wouldn't call hooking into the existing game loop "really hard". :kaoswt:
 

Latest Threads

Latest Profile Posts

here's another RPG Maker remake idea: "Plumbers Don't Wear Ties". I'll salute to whomever does this
I won't finish it today, I only will have time to work on that next Saturday, what a shame... :(
I made something for @LittenDev [as he requested]
It's been a weird while. I've been so torn down that even playing video games has been something I avoided. But I shouldn't forget two things. One, I love RPG Maker for a reason. Two, only way to eat an elephant is a bite at a time.

Forum statistics

Threads
111,385
Messages
1,060,722
Members
144,728
Latest member
Izaya_Nozomu
Top