エフアンダーバー

個人開発の記録

TypeScriptでRPGツクールMVプラグイン

RPGツクールMVになってからプラグインの言語がJavaScriptになりました。 しかし、JavaScriptは静的解析がしづらく、開発ツールによるサポートが効きづらいのは誰もが知るところ。 そこで、JavaScriptの代替言語AltJSの中でも人気の高いTypeScriptでツクールプラグイン開発をする環境を整えてみました。

ついでにツクールのシリアライザを置き換えて、より多様な形式のオブジェクトをシリアライズできるようにしました。

TypeScriptとは?

f:id:fspace:20161202181938p:plain

TypeScriptはJavaScriptの代替言語AltJS(Altanative JavaScript)のうちの一つです。

JavaScriptはすべてのブラウザがサポートする、Web上唯一のプログラミング言語です。 しかし、現在主流のプログラミング言語の内ではやや特殊な仕様を持つことや動的型付け言語でツールのサポートが受けづらいこともあり、 JavaScriptは大規模Webアプリケーション開発には不向きでした。 そんな中、JavaScriptを置き換える言語として開発されたプログラミング言語群をAltJSと言います。

初めのうちは各ブラウザに直接実行されることを目指したAltJS言語もあったのですが、 各主要ブラウザが息を合わせて新しい言語をサポートするというのはなかなか難しかったようで、 現在はJavaScriptにコンパイルする形式が主流となっています。 そのような経緯の中、まったく別の言語ではなく、 JavaScriptを拡張したような言語仕様であったTypeScriptは比較的高い人気を誇っています。

TypeScriptはMicrosoftが開発した言語で、同じくMicrosoftが開発した言語であるC#の生みの親が開発に関わっていることで有名です。 文法もC#erにとって馴染みやすいものとなっており、それもまた人気の理由の一つです。

一方、JavaScriptに対する歩み寄りの姿勢も強く、ES6やES7の仕様を早いうちから取り込んでいます。 うれしいことに、TypeScriptではES6やES7の仕様に沿った文法で書いたコードを対応するES5のコードにコンパイルすることができます。 ツクールMVの環境はES5相当なので、プラグイン開発ではこの機能を活用します。

名前空間とモジュール

TypeScriptには大きく二つの開発形式があります。 ひとつは名前空間を用いたもの、もうひとつはモジュールを用いたものです。

名前空間は昔から使われているプログラムの管理方法です。 JavaScript自体には名前空間の機能はありませんが、オブジェクトを用いて同等の機能を得る手法は一般的によく用いられています。 TypeScriptではこの名前空間が言語仕様に取り込まれています。 当然ですが、コンパイル結果は従来JavaScriptで用いられてきた手法によるものになります。

モジュールは最近(というほどでもないが)JavaScriptで流行りの管理方法で、ES6の仕様にも採用された方式です。 名前空間が道具を整理して保管するためのものなら、モジュールは道具箱から逐一取り出して使うイメージです。 モジュールはTypeScriptの文法に取り入れられているものの、 ES5以前で使うためにはモジュールローダと呼ばれるスクリプトが必要となります。

ツクールMVの環境はES5相当なので、基本的には名前空間の方が楽なのですが、 今回はモジュールを扱えるようにしてみます。 理由は、モジュールでは依存関係が扱えるためです。 プラグインという形態である以上、スクリプトの配置はユーザに委ねるしかなく、 ここにプラグイン同士の順番についての制約を与えることはできる限り避けたいのです。 そのため、配置順に依らず依存先のプラグインが先に実行されるようにモジュールによる実装を採用します。 ただし、モジュールローダが必要となるため、この部分だけは配置順に制約がかかります。

また、現時点ではプラグインとして配置されていないスクリプトの動的読み込みには対応していません。 これはユーザに対して実行されるスクリプトの存在を明確に示すためにそうしたのですが、 単なるライブラリをプラグインとして配置する手間がかかるので、変更すべきかもしれません。

開発環境の構築

TypeScriptによる開発環境の構築例について説明していきます。

あくまでも一例なので、TypeScriptの設定くらい自分でできるよ、という人は適宜飛ばしながら読んでください。

TypeScriptとVSCodeの設定

TypeScriptがMicrosoft製なので、 Microsoft製のテキストエディタであるVSCode(Visual Studio Code)はTypeScriptに対するサポートが厚いです。 ということで、ここではVSCodeの使用を前提に話を進めていきます。 無料なので、インストールしていない方は公式サイトよりダウンロードしてください。

フォルダ構成

ここではRPGツクールのプロジェクトフォルダ下にtsフォルダを作成し、その内部に開発用のファイルを配置します。

フォルダ構成は次の通り。

  • プロジェクトフォルダ
    • ts
      • (.vscode)
        • 自動生成されるVSCodeの設定フォルダ
      • decl
        • 型定義ファイル(*.d.ts)
      • src
        • TypeScriptソースファイル(*.ts)
      • tsconfig.json
        • TypeScriptの設定ファイル
      • tsconfig-debug.json
        • TypeScriptの設定ファイル(デバッグ用)
    • js
    • その他(*.rpgproject, index.html, 各種フォルダ)

tsconfig.json

tsconfig.jsonはTypeScriptのコンパイラ(tsc)に対する引数をまとめた設定ファイルです。 コンパイル時にこのファイルを指定することでまとめて設定できるようにします。

{
    "compilerOptions": {
        "target": "es5",
        "module": "system",
        "baseUrl": "src",
        "outDir": "../js/plugins",
        "inlineSourceMap": true,
        "noFallthroughCasesInSwitch": true,
        "noImplicitAny": true,
        "noImplicitThis": true,
        "strictNullChecks": true,
        "removeComments": true,
        "experimentalDecorators": true
    },
    "include": [
        "src/**/*",
        "decl/**/*"
    ]
}

設定の意味は次の通り。

設定項目 重要度 説明
target 必須 出力のJavaScriptバージョン。
module 必須 出力のモジュール仕様。
baseUrl 必須 モジュール探索のルートURL。
outDir 必須 出力先のディレクトリ。
(inlineSourceMap) 推奨 ソースマップ(入力-TypeScript-と出力-JavaScript-のコード位置の対応表)を出力ファイル内に埋め込むかどうか。
noFallthroughCases
InSwitch
任意 switch文にてフォールスルー(breakせずに後続のcaseに移行すること)をエラーとするかどうか。
noImplicitAny 任意 暗黙的なany型(型チェックされない型)をエラーとするかどうか。
noImplicitThis 任意 関数(メソッドでない)内にて型指定なしのthisをエラーとするかどうか。
strictNullChecks 任意 undefinedとnullを型として厳密にチェックするかどうか。
removeComments 任意 コメントを自動的に削除するかどうか。
experimentalDecorators 推奨 試験的に導入されているデコレータの機能を有効にするかどうか。

tsconfig.jsonとtsconfig-debug.jsonの設定内容はほぼ同じですが、 tsconfig-debug.jsonの方でのみinlineSourceMapなどのソースマップに関する設定を有効にしておくと、 TypeScriptのソースコードでデバッグができるようになります。

「任意」と書いたオプションについて、もし意味がわからないのであればすべて無効にしておくといいと思います。 これらはそれぞれ厳密な書き方を強制するためのもので、意図を知らずに遵守するのはなかなかに苦痛ですので。

removeCommentsはソースコード中のコメントを削除しますが、/*!...*/と!をつけた形式で書かれたブロックコメントは削除されません。 これを利用して/*!/*:...*/という形式でプラグインコメントを書くと、削除されずにプラグインの説明を書くことができます (ツクールの雑な寛容な処理のおかげで助かった)。

experimentalDecoratorsは必須ではありませんが、 いくつかの機能をデコレータできれいに書けるようにしているため、 基本的には有効にしておいてください。

tasks.json

TypeScriptのコンパイラ(tsc)を直接実行してもいいのですが、 VSCode上からより簡単に実行するためにタスクを定義します。 そのための定義ファイルがtasks.jsonで、.vscodeフォルダ内に配置されます。

tsフォルダをVSCodeで開いた状態で、Ctrl+Shift+P(Windowsの場合)でコマンドパレットを開き、 Configure Task Runnerコマンドを実行すると、自動的にtasks.jsonが生成されます。 これを次のように書き換えます。

{
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "0.1.0",
    "command": "tsc",
    "isShellCommand": true,
    "showOutput": "silent",
    "tasks": [
        {
            "taskName": "debug",
            "args": ["-p", "./tsconfig-debug.json"],
            "suppressTaskName": true,
            "problemMatcher": "$tsc",
            "isBuildCommand": true
        },
        {
            "taskName": "release",
            "args": ["-p", "./tsconfig.json"],
            "suppressTaskName": true,
            "problemMatcher": "$tsc"
        }
    ]
}

あとはコマンドパレットでRun Taskコマンドを実行し、debugかreleaseかを選択すれば、 それぞれの設定でソースコードがコンパイルされます。 また、コマンドパレットかCtrl+Shift+B(Windowsの場合)でRun Build Taskコマンドを実行すると、 debug設定でコンパイルされます。これはisBuildCommandオプションがdebugに設定されているためです。

コアスクリプトの型定義ファイル

コアスクリプトの型定義ファイルについて、 とりあえずWeb上を探してみたところ公開してくれている方がいたのですが、 入れてみると設定が悪かったのかエラーが大量に発生したので、TypeScriptの学習も兼ねて自分で作成しました。 さすがにコードをがっつり追うのは厳しかったのでnullabilityあたりはかなり怪しいですが、公開しておくのでご自由にお使いください。

github.com

注意点として、lib.d.tsに変更を加えたものが含まれるため、その他のlib系ファイルは使えません。 これはRPGツクールのWindowクラスとグローバルオブジェクトとしてのWindowクラスの競合を防ぐためです。 どうしても他の定義ファイルを使いたい場合には同様の変更を施して置き換えてください。

前述の方法でTypeScriptの環境構築をした方は、declフォルダ内に型定義ファイル(*.d.ts)をすべて放り込んでください。 declフォルダ内の構成はどのようになっていてもかまいません。

モジュールローダ

モジュールローダはES6 Micro Loaderの実装を参考に簡易的なものを自作しました。 モジュールを動的に読み込むことはしないので、元ソースコードからロード機能を削って、TypeScriptで書き直したような代物です (ロードしてないのでモジュールローダというよりモジュールマネージャですね)。

Gist: PluginSystem.ts

System.register形式なので、TypeScriptコンパイラのmodule設定をsystemにしておく必要があります。 動的な依存解決ができる形式としてAMDにしようかとも思ったのですが、 AMDだと関数定義を文字列として解析する必要があるようなのでやめました。

モジュールローダプラグインは他のどのプラグインよりも先に実行される必要があります。 ツクールのプラグイン設定で必ず最初に配置するようにしてください。

前述の方法でTypeScriptの環境構築をした方は、srcフォルダ内にPluginSystem.tsファイルを配置すると型情報を使うことができます。 PluginSystem.ts自体をコンパイル対象にしたくない場合には、TypeScriptコンパイラを使用して型定義ファイルを生成し、 それをdeclフォルダ内に配置してください。

プラグインの記述

ここまでの設定でTypeScriptによるプラグイン開発は可能ですが、 より簡単に書くために、以降では、モジュールローダプラグインのプラグイン開発支援機能について説明します。

TypeScriptの構文に関する説明はここではしないので、公式のドキュメントやWeb上の記事を参照してください。

プラグイン情報の取得

各種プラグイン情報にアクセスするためのプラグインオブジェクトを取得するにはMVPlugin.get静的メソッドを使用します。 MVPlugin.getメソッドはプラグイン名を引数に、その名前のプラグインオブジェクトを返します。

let plugin = MVPlugin.get(__moduleName);

__moduleNameというのはモジュール内からそのモジュールの名前を取得するための変数で、 特別な設定がなされていない限りはプラグイン名が格納されています。

パラメータの取得

プラグインパラメータの取得にはプラグインオブジェクトを使用します。

let plugin = MVPlugin.get(__moduleName);
let params = plugin.parameters.value;

parametersプロパティで取得できるのはパラメータオブジェクトで、 単なる連想配列を得たい場合にはさらにvalueプロパティを参照します。

パラメータオブジェクトには手軽に値を取得するためのメソッドがいくつか用意されています。

let intValue = plugin.parameters.int('XXXCount');

プラグインオブジェクトのvalidateメソッドを使うと、パラメータのバリデーションがシンプルに書けます。

let plugin = MVPlugin.get(__moduleName);
let params = plugin.validate($ => {
    return {
        intValue: $.int('IntParam'),
        floatValue: $.float('FloatParam'),
        boolValue: $.bool('BoolParam'),
        stringValue: $.string('StringParam'),
        UPPER_VALUE: $.upper('UpperParam'),
        lower_value: $.lower('LowerParam'),
    };
});

コアスクリプトの書き換え

ツクールの基本的な拡張スタイルはメソッドの置き換えなので、次のような形式のコードを書くことが多いと思います。

var _Foo_bar = Foo.prototype.bar;
Foo.prototype.bar = function() {
    _Foo_bar.call(this);

    ...
};

TypeScriptでもこの書き方はできるのですが、 せっかくクラスベースで書いている中にprototypeが出てくるのは嫌なので、デコレータを使った代替記法を用意しました。 ただし、デコレータを利用するにはクラスを定義する必要があるため、コード量は少し増えます。

拡張クラスの定義

まずは拡張のためのクラスを定義します。 "~Extensions"のような名前をつけたクラスにMVPlugin.extensionデコレータを付けます。

@MVPlugin.extension(Foo)
class FooExtensions {}

MVPlugin.extensionの第一引数は拡張対象クラスのコンストラクタです。 このため、拡張クラスは対象クラスごとにひとつずつ定義する必要があります。 また、静的メソッドを置き換えたい場合にはMVPlugin.extensionの第二引数にtrueを設定します。

@MVPlugin.extension(Foo, true)
class FooStaticExtensions {}

特定条件下でのみ置き換えたい場合にはMVPlugin.extensionIfを使用してください。

@MVPlugin.extensionIf(Foo, plugin.parameters.bool('Enabled'))
class FooStaticExtensions {}

メソッドを置き換えるには拡張クラス内に次のようなメソッドを定義します。

@MVPlugin.method
public static bar(base: Function) {
    return function(this: Foo) : void {
        base.call(this);

        ...
    };
}

注意すべき点は四つ。

  • @MVPlugin.methodデコレータを付けること。
  • 静的メソッドであること。
  • 置き換える対象と同じ名前のメソッドであること。
  • 置き換える処理の関数を返すこと。

メソッドの引数baseはもともと定義されていた関数です。 これに対してcallapplyを呼ぶことで、元の処理を実行することができます。 拡張対象クラス内に指定した名前のメソッドが存在しない場合には、この値はundefinedとなり、 単純に新しいメソッドが定義されることになります。

プロパティを置き換えたい場合にはMVPlugin.propertyデコレータを使用します。

@MVPlugin.property
public static get baz(this: Foo): number { return this._baz; }
public static set baz(this: Foo, value: number) { this._baz = value; }

プロパティの場合には元の処理を参照することはできません。

また特定の条件下でのみ置き換えたい場合には、MVPlugin.methodIfデコレータやMVPlugin.propertyIfデコレータも使用できます。

型定義の変更

拡張のために新しいメンバーを定義した場合には、型定義を変更する必要があります。 型定義の変更は次のようなコードで行います。

declare global {
    interface Foo {
        _baz: number;
        baz: number;
    }
}

モジュール内でグローバル空間の型定義を変更する場合には、 declare globalでグローバル空間に定義されていることを明示する必要があります。 また、定義済みクラスのインターフェースを拡張する場合にはclassキーワードではなくinterfaceキーワードを使います。

シリアライズ

ツクール標準のシリアライズ機能では、グローバル空間に定義されたクラス以外はプロトタイプオブジェクトが保持されません。 そのため、モジュールに書いたクラスをシリアライズしようとすると型情報が抜け落ちてしまいます。

この問題を解決するため、モジュールローダプラグインを実行すると、 自動的にシリアライズ処理が独自のものに切り替わります。 グローバル空間以外のクラスの型情報が復元できるほか、 循環参照の保持やシリアライズ・デシリアライズイベントの取得ができるようになります。

なお、元のシリアライズ方式との互換性はないためご注意ください。

型情報の登録

グローバル空間以外に定義されたクラスの型情報を復元するには、 あらかじめ型情報を登録しておく必要があります。 登録にはSystem.Typeクラスのregister静的メソッドを使用します。

class Foo {}
System.Type.register(Foo, 'HogeModule');

第一引数がクラスのコンストラクタで、第二引数がモジュール名です。 通常はコンストラクタの関数名がIDとして用いられますが、第三引数にIDを指定するとそちらが優先されます。

また、プラグインオブジェクトとデコレータを用いた指定もできます。

@plugin.type
class Foo {}

IDを指定したい場合にはtypeAsデコレータを使用します。

@plugin.typeAs('FOO')
class Foo {}

イベントハンドラ

クラスに特定の名前のメソッドを定義しておくと、対応するタイミングで呼び出されます。

シグネチャ 説明
onSerialize():void シリアライズ直前に呼び出されます。
onDeserialize():void デシリアライズ直後に呼び出されます。
toJSON():any シリアライズ時に呼び出され、戻り値をオブジェクトの代わりにシリアライズします。
fromJSON(data:any):void デシリアライズ時に呼び出され、引数にデシリアライズしたデータが渡されます。

fromJSONが定義されている場合、オブジェクトはクラスのプロトタイプオブジェクトがセットされただけの状態で初期化されます。 fromJSONにてthisに値を代入し、オブジェクトを復元してください。

シリアライズ形式

各オブジェクトはオブジェクトテーブルに分離して保存されることで参照を保持します。

シリアライズ後のJSONデータは次のような形式となります。

{
    root: { "@ref": 0 },
    table: [
        {
            "@": "@HogeModule.Foo",
            "_": {
                "_baz": 100,
                "_fuga": { "@ref": 1 }
            }
        },
        ...
    ]
}

記述例

記述例。処理に特に意味はないです。

/*!
/*:
 * @plugindesc サンプルプラグイン
 * @author F_
 *
 * @param MyParam
 * @desc 私のパラム。
 * @default True
 */

import { Foo } from 'HogeModule';

let plugin = MVPlugin.get(__moduleName);
let params = plugin.validate($ => {
  return {
    mine: $.bool('MyParam')
  };
});

declare global {
  interface Game_Map {
    readonly mine: MyClass;
  }
}

@plugin.type
export class MyClass {
  public constructor(public mine?: boolean);
  public sweep():void { delete this.mine; }
}

@MVPlugin.extension(Game_Map)
class GameMapExtensions {
  @MVPlugin.property
  public static get mine(this: Game_Map): MyClass { return new MyClass(params.mine); }

  @MVPlugin.method
  public static checkPassage(base: Function) {
    return function(this: Game_Map, x: number, y: number, bit: number): boolean {
      this.mine.sweep();
      return base.call(this, x, y, bit);
    };
  }
}

let foo = new Foo();
foo.bar();

前回記事のプラグインでも利用しているので、そちらもご参照ください。

www.f-sp.com

おわりに

せっかく環境整えたのでとりあえず公開してみたのですが、 こうまでしてTypeScript使いたい人ってどれくらいいるんでしょう? 他人のスクリプト利用してまで使おうなんて人は限りなく0に近いような・・・。

まあ、読み物として楽しんでもらえたなら幸いです。



執筆時のRPGツクールMVのバージョン:1.3.4