RPGツクールMVのElectron対応化

先日、[RPGツクールMVのプロジェクトをnw.jsで実行する](http://qiita.com/RaTTiE/items/6cd640ce1f3cc0a08d98)という記事を書いた際に、「Electronで起動することはできないか？」というリプがありました。
Electronというのはnw.jsと同様、Node.js上でマルチプラットフォームのデスクトップアプリを作るためのフレームワークで、[Atom](https://atom.io/)のバックエンドに使われていることで有名です。

Electron
http://electron.atom.io/

そのときはElectronの方が内部で利用されているChromiumのバージョンが新しいとのことで、現状のメモリ問題が多少緩和されるのでは？　という話だったのですが、確かにnw.jsは開発が停滞しており(stableの最新版は2015年の8月くらい)、今後のことを考えるとElectron対応はしておいても良いのかもなぁと思い、やってみることにしました。以下がその成果物になります。

RPGツクールMV　Electron対応プラグイン
https://github.com/RaTTiE/rpgmv-electron

ツクールMVのプラグイン形式をとっているので、プラグインとして組み込んでもらうとElectronでもゲームが起動できるようになります。詳しい使い方はリポジトリのreadmeを見てください。

以下は上記プラグインでの対応内容となります。内部解析の参考になれば幸いです。

# とりあえず動かしてみる
まずはElectronでざっくり起動してみるところから始めましょう。
Electronは事前にnpmからインストールしておいてください。

まずはnw.jsのときと同様に、Node.js上でゲームを動かす環境を作ります。
途中まではまったく同じ作業になるので、以下の記事を参考に環境を作ってください。

RPGツクールMVのプロジェクトをnw.jsで実行する
http://qiita.com/RaTTiE/items/6cd640ce1f3cc0a08d98

nw.jsのエントリポイントはhtmlファイル(index.html)になりますが、Electronの場合はjsファイルになります。
package.jsonのmainをindex.htmlからindex.jsに変更してください。

```json:package.json
{
  "name": "rpgmv-electron",
  "version": "0.0.1",
  "description": "",
  "main": "www/index.js"
}
```

次にindex.jsを作成します。
Electronの[QuickStart](https://github.com/atom/electron-quick-start)を参考に、index.jsを作成します。

```js:index.js
'use strict';
const electron = require('electron');
const app = electron.app;  // Module to control application life.
const BrowserWindow = electron.BrowserWindow;  // Module to create native browser window.

// Report crashes to our server.
electron.crashReporter.start();

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow;

// Quit when all windows are closed.
app.on('window-all-closed', function() {
  // On OS X it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  app.quit();
});

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
app.on('ready', function() {
  // Create the browser window.
  mainWindow = new BrowserWindow({width: 800, height: 600});

  // and load the index.html of the app.
  mainWindow.loadURL(`file://${__dirname}/index.html?test`);

  // Open the DevTools.
  mainWindow.webContents.openDevTools();

  // Emitted when the window is closed.
  mainWindow.on('closed', function() {
    // Dereference the window object, usually you would store windows
    // in an array if your app supports multi windows, this is the time
    // when you should delete the corresponding element.
    mainWindow = null;
  });
});
```

だいたいサンプルと同じですが、テストプレイ用にloadURLへパラメータを追加しているのと、Macでもウィンドウを閉じたときにアプリを終了するよう、window-all-closedの条件を外してます。
index.jsを作成したら、package.jsonのある階層でelectronを起動します。

```bash
electron .
```

ウィンドウは開きますが、エラーになってコケます。

## nw.js依存処理の振り分け
RPGツクールMVのゲームエンジンでは、内部の処理でnw.jsのAPIを呼び出している箇所がいくつかあります。
当然、ElectronにはそれらのAPIは実装されていないため、nw.js用の処理を呼び出している箇所でエラーになったという訳です。
実はrpg_core.jsの以下のメソッドを書き換えると、動作するようになります。

```js:rpg_core.js
/**
 * Checks whether the platform is NW.js.
 *
 * @static
 * @method isNwjs
 * @return {Boolean} True if the platform is NW.js
 */
Utils.isNwjs = function() {
    return false;
};
```

上記のメソッドはnw.jsで動作しているかどうか(ブラウザ上で起動しているか、ローカルで起動しているか)を判定するメソッドで、これを常にfalseで返すようにすると、nw.js依存の処理は呼ばれなくなります。
とはいえ、これをfalseにするとローカルで起動してもセーブデータはファイルに吐かれずにWebStorageに吐かれるようになったりデバッグ機能が利用できなくなったりと、一応解決自体はするもののあまりスマートではないので、もう少し気合を入れて対策を行っていきます。

まず、nw.jsに依存している処理は以下の通りです。

* Input._wrapNwjsAlertでalertメソッドをラップしており、alert実行後にメインウィンドウがフォーカスされるようにしている
* ゲーム画面ではメニューバーを隠しているが、Macの場合はメニューが必須となるため、SceneManager.initNwjsでMac用に標準メニューを作成、表示を行っている
* SceneManager.onKeyDownでテストモード時にF8を押したとき、開発者ツールを開いている

上記の処理はnw.jsのAPI(nw.gui)に依存しているため、Electron用に処理を分岐、実装する必要があります。

### Utils.isNwjsの拡張
まず、現状だとnw.js用の処理はUtils.isNwjsという判定を呼び出しており、それによって処理を分岐していますが、この処理はrequireを利用できるかどうかで判定しているため、isNodejsというメソッドとして再定義します。その上でisNwjsとisElectronというメソッドを定義し、それぞれの分岐判定で利用します。
isNwjs、isElectronは、それぞれに依存するライブラリがrequireできるかどうかで判定しています。

```js:rpg_core.js
/**
 * Checks whether the platform is Node.js.
 *
 * @static
 * @method isNodejs
 * @return {Boolean} True if the platform is Node.js
 */
Utils.isNodejs = function() {
    return typeof require === 'function' && typeof process === 'object';
};

/**
 * Checks whether the platform is NW.js.
 *
 * @static
 * @method isNwjs
 * @return {Boolean} True if the platform is NW.js
 */
Utils.isNwjs = function() {
    if (!Utils.isNodejs()) return false;

    try {
        var gui = require('nw.gui');

        return true;
    } catch (e) {
        return false;
    }
};

/**
 * Checks whether the platform is Electron.
 *
 * @static
 * @method isElectron
 * @return {Boolean} True if the platform is Electron
 */
Utils.isElectron = function() {
    if (!Utils.isNodejs()) return false;

        try {
            var electron = require('electron');

            return true;
        } catch (e) {
            return false;
        }
};
```

Utils.isNwjsを使って、ローカル起動とブラウザ起動を分けている処理があるため、それらの判定をisNodejsへ変更します。

```js:rpg_core.js
/**
 * @static
 * @method _defaultStretchMode
 * @private
 */
Graphics._defaultStretchMode = function() {
    return Utils.isNodejs() || Utils.isMobileDevice();
};
```

```js:rpg_managers.js
StorageManager.isLocalMode = function() {
    return Utils.isNodejs();
}
```

### Input.wrapNwjsAlertの修正
ではまず、InputNwjsAlertを修正します。

```js:rpg_core.js
/**
 * @static
 * @method _wrapNodejsAlert
 * @private
 */
Input._wrapNodejsAlert = function() {
    if (Utils.isNwjs()) {
        var _alert = window.alert;
        window.alert = function() {
            var gui = require('nw.gui');
            var win = gui.Window.get();
            _alert.apply(this, arguments);
            win.focus();
            Input.clear();
        };
    } else if (Utils.isElectron()) {
        var _alert = window.alert;
        window.alert = function() {
            _alert.apply(this, arguments);
            require('electron').ipcRenderer.send('focusMainWindow');
            Input.clear();
        };
    }
};
```

ざっくりと言ってしまえば、Electron側でもalert発生時にメインウィンドウにフォーカスすればよいのですが、ここでnw.jsとElectronの実装の差が問題になります。

nw.jsではメインウィンドウのインスタンスを直接取得することができますが、Electronはメインプロセスとブラウザのプロセスが完全に別れており、メインプロセス側にあるブラウザウィンドウを直接操作することはできません。一応、BrowserWindowにgetFocusedWindowというメソッドがあるのですが、ゲームウィンドウにフォーカスが当たってる保証がないので却下で。

ではどうするかというと、IPCという機能を使います。
IPCとはInter-process communicationの略で、Electronのプロセス間通信を行うモジュールです。
これを使用し、alert終了後にメインプロセスにイベントを送信します。
送信したイベントはメインプロセス側で受ける必要があるため、index.jsに処理を追加します。

```js:index.js
electron.ipcMain.on('focusMainWindow', function(e) {
    mainWindow.focus();
});
```

ブラウザ側で送信したイベントはメインプロセスで受信され、このタイミングでメインウィンドウのフォーカス処理が走ります。

### SceneManager.onKeyDownの修正

```js:rpg_managers.js
SceneManager.onKeyDown = function(event) {
    if (!event.ctrlKey && !event.altKey) {
        switch (event.keyCode) {
        case 116:   // F5
            if (Utils.isNodejs()) {
                location.reload();
            }
            break;
        case 119:   // F8
            if (Utils.isOptionValid('test')) {
                if (Utils.isNwjs()) {
                    require('nw.gui').Window.get().showDevTools();
                } else if (Utils.isElectron()) {
                    require('electron').ipcRenderer.send('openDevTools');
                }
            }
            break;
        }
    }
};
```

ここでもやることはあまり変わりません。
まず、F5キーはUtils.isNwjsメソッドを参照しているので、isNodejsメソッドに変更しておきます。
F8キーは開発者ツールを開いていますが、ここもnw.jsとElectronで処理を分岐し、Electron側はIPC経由でイベントをメインプロセスへ送信します。

```js:index.js
electron.ipcMain.on('openDevTools', function(e) {
    mainWindow.openDevTools();
});
```

あとはindex.jsでメインウィンドウから開発者ツールを開けばOKです。

### SceneManager.initNwjsの修正
ここの修正は少し特殊です。

まず、本来initNwjsでやっている処理は、前述した通りMacではメニューが必須となるため、SceneManager.initNwjsでMac用に標準メニューを作成、表示を行っているというものです。
nw.jsにはcreateMacBuiltinというメソッドがあり、それを使うとMac用の標準メニューを作ることができるのですが、Electronにはそれがありません。
仕方がないので、手でせこせこと作るしかないです。[^1]

SceneManagerでやる必要は特にないし、やろうとするとまたIPCのお世話にならないといけないため、この処理はindex.jsの中でやってしまいます。
あと、nw.jsではpackage.jsonのオプションでアイコンの指定とメニューバーの非表示も行っていましたが、その処理もここでやってしまいます。
メニューバーの非表示化はmainWindow.setMenu(null)で、アイコンはBrowserWindowのコンストラクタで指定します。~~コンストラクタでshow: falseとしている理由は後述します。~~

```js:index.js
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
app.on('ready', function() {
    // Create the browser window.
    mainWindow = new BrowserWindow({
        width: 816,
        height: 624,
        icon: 'www/icon/icon.png',
        useContentSize: true,
        autoHideMenuBar: true
    });

    if (process.platform !== 'darwin') {
        mainWindow.setMenu(null);
    } else {
        // init menu for Mac
        var Menu = require('menu');

        var menu = Menu.buildFromTemplate([
            {
                label: 'Electron',
                submenu: [
                    {
                        label: 'Game について',
                        selector: 'orderFrontStandardAboutPanel:'
                    },
                    {type: 'separator'},
                    {
                        label: 'Game を隠す',
                        accelerator: 'Command+H',
                        selector: 'hide:'
                    },
                    {
                        label: 'ほかを隠す',
                        accelerator: 'Command+Alt+H',
                        selector: 'hideOtherApplications:'
                    },
                    {
                        label: 'すべてを表示',
                        selector: 'unhideAllApplications:'
                    },
                    {
                        type: 'separator'
                    },
                    {
                        label: 'Game を終了',
                        accelerator: 'Command+Q',
                        click: function() { app.quit(); }
                    }
                ]
            }
        ]);

        Menu.setApplicationMenu(menu);
    }

  :
```

### ~~ウィンドウサイズの調整~~
~~ここまでの修正でとりあえず起動自体はするんですが、実はまだ一つ問題が残されています。~~
~~というのも、nw.jsのウィンドウサイズ指定はウィンドウ内部のサイズが基準になっているのですが、Electronのウィンドウサイズ指定はどうやら枠やタイトルバーを含んだサイズ指定になっているようなのです。[^2]~~
~~仕方がないので、ブラウザ側からウィンドウの内部サイズを通知し、内部サイズと実ウィンドウのサイズの差からウィンドウのサイズと位置を再設定することにします。この処理は、SceneManager.initNodejsで初期化を行う際についでに行ってしまいます。~~
~~また、ウィンドウリサイズ時にちらつくのを防ぐため、初期表示ではウィンドウを隠し、リサイズ完了時にウィンドウを表示しています。例によってIPCでウィンドウサイズをメインプロセスへ通知し、リサイズ処理はメインプロセスで行います。~~

(2016/02/23追記)
この処理はElectron Ver 0.36.8現在、不要になったようです。useContentSize=trueを指定することで、ウィンドウサイズが正常に設定されるようになりました。


#まとめ
これで、一通りの対応が完了となります。
プラグインは上記の対応をクラス定義を上書きする形に書き換えていますが、やっていることは概ね同じはずです。

…まあ、正味のところツクールMVはこのままnw.jsが使われ、次期バージョンが出たタイミングでElectronか他のフレームワークに変わるとかそういう風になりそうな気はしますが、このままnw.jsのバージョンが上がらない場合とかはある程度有効なこともあるかもしれないので、もしよろしければ使ってみてください。

[^1]: 一応、アプリの挙動としてはselectorというタグのようなものが用意されており、ウィンドウを隠したりElectronのAboutを出したりするのはそれを使うことで実現できます。
[^2]: ~~BrowserWindowのコンストラクタにuseContentSizeというオプションがあるようですが、それを指定してもサイズが上手く設定されません。~~