ツクールMVのメインループに少し手をくわえた

任天堂やサンリオは LOVOT(ラボット) を見て「楽勝だな」とか思わなかったのだろうかとふと思う。LOVOT(ラボット)を作った人達はなぜ任天堂やサンリオに話を持ちかけないのだろうかとも思う。どちらもなんらかの理由によりポシャってるだけかもしれない。

閑話休題
Chrome のアクティブか否かのチェックが厳しくなったような気がする。というのもこれまでバックグラウンドで動いていたゲームが最大化した別窓を開くと動かなくなってしまったからだ。事象がそんなだから、当該ウィンドウ以外で最大化されているウィンドウが存在する場合は非アクティブと判定するようになったのでは?と予想した。
で、非アクティブだと再描画しないのでメインループで requestAnimationFrame を用いているゲームは完全に沈黙することになる。
で、ツクールMVのメインループは requestAnimationFrame を用いているのでツクールMVの基礎部分を流用している私にも影響がある。なんてこった。

requestAnimationFrame 方式から setTimeout 方式に変えればバックグラウンドでも動くようになると思うけど、有名なクリッカーゲームである Cookie Clicker が何をやっているのか確認してみる。
どうやらメインループと描画を異なるタイミングで実行しているようだ。メインループと思われる Game.Loop は setTimeout で回している。しかし描画処理と思われる Game.Draw は requestAnimationFrame で呼び出している。こうしておけばブラウザとゲームの描画タイミングが合わないことによって生じる問題とやらがなくなるのかな?よく分からないけどこれを真似る方向で考えておこう。

修正する前に、そもそも予想が当たっているのか確認する。
軽く試したところ以下のことが確認できた。
・requestAnimationFrame 方式だとバックグラウンドでは完全に沈黙する
・setTimeout 方式だとバックグラウンドでは 1 秒に 1 回しか実行しない
VSCodeデバッグ実行の場合、どちらの方式でもバックグラウンドで 60FPS で動いていた
requestAnimationFrame 方式だとバックグラウンドで動かないことは間違いないようなので setTimeout 方式に切り替える。あとは前回実行時との時間差に応じて演算部をぶん回すようにすればいいのかな?VSCodeデバッグ実行がバックグラウンドでサクサク動く件は、今はどうでもいいので深追いしない。

ちなみに確認のためにいじったのは SceneManager.requestUpdate 。

SceneManager.requestUpdate = function() {
    if (!this._stopped) {
        // requestAnimationFrame(this.update.bind(this));
        setTimeout(this.update.bind(this), 1000 / 60);
        console.log(performance.now());
    }
};

JavaScript は関数の上書きが簡単なので基本的に直接編集は避けている。今は debug.js というのを main.js の直前に読み込むようにしておいて、何か試すときは debug.js で試すようにしている。

時間差取って回す処理作るのめんどくさいと思ったけど、ツクールMVには既にその機構が備わっていた。今日もツイてる。

SceneManager.updateMain = function() {
    if (Utils.isMobileSafari()) {
        this.changeScene();
        this.updateScene();
    } else {
        var newTime = this._getTimeInMsWithoutMobileSafari();
        var fTime = (newTime - this._currentTime) / 1000;
        if (fTime > 0.25) fTime = 0.25;
        this._currentTime = newTime;
        this._accumulator += fTime;
        while (this._accumulator >= this._deltaTime) {
            this.updateInputData();
            this.changeScene();
            this.updateScene();
            this._accumulator -= this._deltaTime;
        }
    }
    this.renderScene();
    this.requestUpdate();
};

fTime というのが前回処理時刻との差分。そいつを _accumulator に加算して _deltaTime(1/60) 未満になるまでぐるぐる。でもなぜか fTime の上限が 0.25 になっている。理由は分からないけど今の私には必要ない制限だと思うので 1 まで増やしておく。MobileSafari については見なかったことにしよう。クロスプラットフォーム対応て大変なんだな。

そんなわけで少し手を加えたのが以下のソース。

SceneManager.animationBasedLoop = false;
SceneManager._requested         = false;
SceneManager.processCount       = 0;

SceneManager.updateMain = function() {
    if (Utils.isMobileSafari()) {
        this.changeScene();
        this.updateScene();
    } else {
        var newTime = this._getTimeInMsWithoutMobileSafari();
        var fTime = (newTime - this._currentTime) / 1000;
        if (fTime > 1) fTime = 1;
        this._currentTime = newTime;
        this._accumulator += fTime;
        while (this._accumulator >= this._deltaTime) {
            this.updateInputData();
            this.changeScene();
            this.updateScene();
            this._accumulator -= this._deltaTime;
            this.processCount++;
        }
    }
    if (this.animationBasedLoop) {
        this.renderScene();
    } else {
        this.requestAnimation();
    }
    this.requestUpdate();
};

SceneManager.requestUpdate = function() {
    if (this._stopped) return;

    if (this.animationBasedLoop) {
        requestAnimationFrame(this.update.bind(this));
    } else {
        setTimeout(this.update.bind(this), this._deltaTime / 2);
    }
};

SceneManager.requestAnimation = function() {
    if (this._stopped) return;
    if (this._requested) return;

    this._requested = true;
    requestAnimationFrame(this.renderScene.bind(this));
};

SceneManager.renderScene = function() {
    this._requested = false;
    if (this.isCurrentSceneStarted()) {
        Graphics.render(this._scene);
    } else if (this._scene) {
        this.onSceneLoading();
    }
};

やったことはだいたい次のような感じ。必要ないこともやってる。
・フラグの ON/OFF で requestAnimationFrame 方式と setTimeout 方式を切り替えられるようにしてやった。
・Graphics.frameCount の処理回数版に相当する SceneManager.processCount を追加してやった。
・私の勘では renderScene というのが描画処理なので、こいつだけ requestAnimationFrame で呼び出すようにしてやった。
・長期放置後の復帰時の待機時間が長かったので原因はリクエストの重複に違いないと決めつけてリクエストが重複しないようにしてやった。
・setTimeout の時間設定はなんとなくで決めてやった。

renderScene のように前処理や後処理を挟むだけの場合はプラグインで良く見かける次のような書き方もできるはずだけど一身上の都合によりやめた。

let _SceneManager_renderScene = SceneManager.renderScene;
SceneManager.renderScene = function() {
    this._requested = false;
    _SceneManager_renderScene.apply(this);
};

最初はメインループに手を加えるの嫌だ面倒くさいと思っていたけど、やってみたらウィンドウを最小化しても動くようになって以前よりもユーザー離脱率が低下するかもしれないので、むしろよかったんじゃないかと思った。やはりツイている。歴史が私の方に傾きつつあるのかもしれない。