一昔前にCanvasが実用段階になった頃、JSのゲームエンジンが大量に出てきたことがありました。それらは大抵DOM/CanvasのFallbackを持っていたのですが、今現在の状況は、実際には非効率なメモリ消費やモバイルのブラウザのフラグメント化で実用に足るものがなかった、という辛い現状があります。
そんな中pixi.jsという描画ライブラリが台頭してきました。このエンジンは webglとcanvasの fallbackを持ち、(いくらかのバグはありつつも)DOMを切ったことで現実的なパフォーマンスの課題をクリアできるのでは?という期待感が高まっています。
Pixi.js - 2D webGL renderer with canvas fallback http://www.pixijs.com/
そして 2015年、RPGツクールMVが発表され、ブラウザ吐き出し対応がアナウンスされました。日本では12/18発売ですが、海外版はSteamで既に買うことが出来ます。
で、僕はJSでツクールでゲームエンジンというものがどうなってるかをみたくて、Steam版を買ってExportしてみたんですが、ひと目でわかりました、これはヤバイということが。
なぜ今RPGツクールMVが熱いか
- フルスタックの今までどおりのツクールのゲームが、ブラウザで動く
- プラグイン作成のためか、ランタイムのJavaScriptが読める!
- コメント付き
- 難読化無し
- ツクールというオーサリングツールの長年のノウハウが読めて最高
- pixi.jsベースのWebGL/Canvasレンダリング
- 比較的モダンな継承イディオムでES Classesと共存可能
- エディタ部分はJSではないが、基本的にはフォーマットが決まったJSONを吐き出すだけ
- 勝手エディタを作ることもできるし、勝手ランタイムを作ることもできる
個人的に、昔はツクールのゲームをよくやってたし(好きなフリーゲームはイストワールと魔王物語物語です)、仕事でJSのゲームエンジンみたいなものを書いたことがあります。ツクールもどきも作ったことがあります(公開してませんが)。なのでこういう風に公開されるとなかなか胸が熱くなります。
というわけでコードリーディングしてきました。
元のファイル構成
モバイル版としてexportすると次のようなファイルが吐き出されます。
$ tree -L 2 // 2階層まで
.
├── audio
│ ├── bgm
│ ├── bgs
│ ├── me
│ └── se
├── data
│ ├── Actors.json
│ ├── Animations.json
│ ├── Armors.json
│ ├── Classes.json
│ ├── CommonEvents.json
│ ├── Enemies.json
│ ├── Items.json
│ ├── Map001.json
│ ├── MapInfos.json
│ ├── Skills.json
│ ├── States.json
│ ├── System.json
│ ├── Tilesets.json
│ ├── Troops.json
│ └── Weapons.json
├── fonts
│ ├── gamefont.css
│ └── mplus-1m-regular.ttf
├── icon
│ └── icon.png
├── img
│ ├── animations
│ ├── battlebacks1
│ ├── battlebacks2
│ ├── characters
│ ├── enemies
│ ├── faces
│ ├── parallaxes
│ ├── sv_actors
│ ├── sv_enemies
│ ├── system
│ ├── tilesets
│ ├── titles1
│ └── titles2
├── index.html
└── js
├── libs
├── main.js
├── plugins
├── plugins.js
├── rpg_core.js
├── rpg_managers.js
├── rpg_objects.js
├── rpg_scenes.js
├── rpg_sprites.js
└── rpg_windows.js
25 directories, 27 files
index.html を開くと起動します。ツクールのエディタ側は、基本的に data/ 以下の json と、各種アセットを吐き出すことに特化してます。
js部分でエディタ側が生成するのは js/plugins/*.js
と js/plugin.js
です。他は静的なランタイムです。
勝手リファクタ版
MVのランタイムはプラグインエコシステムのためにコメント除去も難読化もされておらず、そのまま読むことが出来ます。
というわけで、自分の勉強がてら、勝手にbabel でリファクタリングしてみました。
リファクタ後の構成
src/
├── $globals.js
├── core.js
├── index.js
├── managers
│ ├── AudioManager.js
│ ├── BattleManager.js
│ ├── ConfigManager.js
│ ├── DataManager.js
│ ├── ImageManager.js
│ ├── PluginManager.js
│ ├── SceneManager.js
│ ├── SoundManager.js
│ ├── StorageManager.js
│ └── TextManager.js
├── objects
│ ├── Action.js
│ ├── ActionResult.js
│ ├── Actor.js
│ ├── Actors.js
│ ├── Battler.js
│ ├── BattlerBase.js
│ ├── Character.js
│ ├── CharacterBase.js
│ ├── CommonEvent.js
│ ├── Enemy.js
│ ├── Event.js
│ ├── Follower.js
│ ├── Followers.js
│ ├── Intercepter.js
│ ├── Item.js
│ ├── Map.js
│ ├── Message.js
│ ├── Party.js
│ ├── Picture.js
│ ├── Player.js
│ ├── Screen.js
│ ├── SelfSwitches.js
│ ├── Switches.js
│ ├── System.js
│ ├── Temp.js
│ ├── Timer.js
│ ├── Troop.js
│ ├── Unit.js
│ ├── Variables.js
│ └── Vehicle.js
├── plugins.js
├── scenes
│ ├── Base.js
│ ├── Battle.js
│ ├── Boot.js
│ ├── Debug.js
│ ├── Equip.js
│ ├── File.js
│ ├── GameEnd.js
│ ├── Gameover.js
│ ├── Item.js
│ ├── ItemBase.js
│ ├── Load.js
│ ├── Map.js
│ ├── Menu.js
│ ├── MenuBase.js
│ ├── Name.js
│ ├── Options.js
│ ├── Save.js
│ ├── Shop.js
│ ├── Skill.js
│ ├── Status.js
│ └── Title.js
├── sprites
│ ├── Actor.js
│ ├── Animation.js
│ ├── Baloon.js
│ ├── Base.js
│ ├── Battler.js
│ ├── Button.js
│ ├── Character.js
│ ├── Damage.js
│ ├── Enemy.js
│ ├── Picture.js
│ ├── SpritesetBase.js
│ ├── SpritesetBattle.js
│ ├── SpritesetMap.js
│ ├── StateIcon.js
│ ├── StateOverlay.js
│ └── Weapon.js
└── windows
├── ActorCommand.js
├── Base.js
├── BattleActor.js
├── BattleEnemy.js
├── BattleItem.js
├── BattleLog.js
├── BattleSkill.js
├── BattleStatus.js
├── ChoiceList.js
├── Command.js
├── DebugRange.js
├── EquipCommand.js
├── EquipItem.js
├── EquipSlot.js
├── EquipStatus.js
├── EventItem.js
├── GameEnd.js
├── Gold.js
├── Help.js
├── HorzCommand.js
├── ItemCategory.js
├── ItemList.js
├── MapName.js
├── MenuActor.js
├── MenuCommand.js
├── MenuStatus.js
├── Message.js
├── NameEdit.js
├── NameInput.js
├── NumberInput.js
├── Options.js
├── PartyCommand.js
├── SaveFileList.js
├── ScrollText.js
├── Selectable.js
├── ShopBuy.js
├── ShopCommand.js
├── ShopNumber.js
├── ShopSell.js
├── ShopStatus.js
├── SkillList.js
├── SkillStatus.js
├── SkillType.js
├── Status.js
└── TitleCommand.js
5 directories, 126 files
このコードベースを用いて、commonjs require の依存グラフからモジュール間の依存性をgraphvizで可視化してみました。
見てみたい人はこのファイルを開いてみてください(4MB)。 https://dl.dropboxusercontent.com/u/135170/tkool-dep.png
… まああんまり役にたたないですね
(https://github.com/pahen/madge が便利だった)
リポジトリはここ https://github.com/mizchi-sandbox/tkool-sandbox
注意: リファクタ中の不安定な状態で、まだ完全に動いてないです。このリポジトリはカスタムエンジンを作るのが目的ではなく、プラグイン作成のためのdependency の明示とその過程の僕自身の学習が主目的です。
何をしたか・問題意識
- モジュール間のグローバル参照を避けたい
- -> babel の import / export ですべての参照を解決するようにした
- npm モジュールをつかいたい
- browserifyでビルドするようにした
- とはいえ本体更新に追従できるようにしておきたい
- 名前解決以外のロジックはいじらない
じゃあ実際どのようなコードだったか追ってみましょうか
core.js (元のrpg_core.js)
このファイルによって提供されるもの
- pixi.js をラップした Point, Rectangle, Sprite, Bitmap, Graphics等の描画基盤
- ↑を使ったWindow, WindowLayer のGUI管理系
- HTML5Audioを抽象化した 各種 Audioクラス
- Util, JsonEx等のユーティリティクラス
基本的にPixiを明示的に使うのはここだけです。他の場所ではcoreで定義されたクラスを使います。
名前空間の管理は空オブジェクトを作るのではなく、XXX_YYY と アンダースコアで区切ることを意図しているように見えます。
エントリポイント(元main.js)
各画面はSceneという単位で管理されています。起動時はSceneManagerがScene_Bootを呼び出します。
import SceneManager from './managers/SceneManager';
import PluginManager from './managers/PluginManager';
import Scene_Boot from './scenes/Boot';
PluginManager.setup($plugins);
window.onload = function() {
SceneManager.run(Scene_Boot);
};
Scene_Boot
Scene_Boot は画面としての実体を持たず、基本的には各種モジュールを初期化を行ってから Scene_Title を呼び出します。デーン、デデデーン!って音がなるやつです。
// (略)
Scene_Boot.prototype.start = function() {
Scene_Base.prototype.start.call(this);
SoundManager.preloadImportantSounds();
if (DataManager.isBattleTest()) {
DataManager.setupBattleTest();
SceneManager.goto(Scene_Battle);
} else if (DataManager.isEventTest()) {
DataManager.setupEventTest();
SceneManager.goto(Scene_Map);
} else {
this.checkPlayerLocation();
DataManager.setupNewGame();
SceneManager.goto(Scene_Title);
Window_TitleCommand.initCommandPosition();
}
this.updateDocumentTitle();
};
基本的にはSceneManagerがメインループを管理しているようです。
それっぽい部分のコード
SceneManager.updateInputData = function() {
Input.update();
TouchInput.update();
};
// 略
SceneManager.updateMain = function() {
this.changeScene();
this.updateScene();
this.renderScene();
this.requestUpdate();
};
// 略
SceneManager.updateScene = function() {
if (this._scene) {
if (!this._sceneStarted && this._scene.isReady()) {
this._scene.start();
this._sceneStarted = true;
this.onSceneStart();
}
if (this.isCurrentSceneStarted()) {
this._scene.update();
}
}
};
Sceneの概要
各Sceneは Scene_Baseを継承して実装され、Scene_Base.prototype.createWindowLayer で ルートとなるWindowLayerを構築して画面を生成します。まだちゃんと読んでないですが、 WindowLayer が の下にWindowかWindowLayer が入れ子になっています
Window
実際に描画される実体。継承関係は Window_XXX <- Window_Base <- core/Window <- PIXI.DisplayObjectContainer となっています。
各Winodowはcore/Spriteや sprites/xxx を呼んで描画ツリーを構成します。
中でも特長的なクラスが Window_Command で、各種入力を受け取って、選択肢によるコマンド選択などを行うベースクラス。 Window_HorzCommand は水平に選択肢が追加されるやつです。
ほとんどの画面 Scene_X はルート要素となる Window_XXX をもつのですが、 Scene_Map だけは 対応する Window_Map というクラスを持たず、sprites/SpritesetMap クラスを使って直接描画しているよう模様。パフォーマンスセンシティブなので。
Sprite
ツクール仕様でスライスされた画像からアクターのパターンやアニメーションを構築します。
継承関係は Sprite_XXX <- Sprite_Base <- core/Sprite <- PIXI.Sprite 。
Object
Window が描画されるものだとしたら、こちらはゲームロジックにまつわるデータ的な実体。
これらは DataManagerによってセーブ時にJSONにシリアライズされることがあります。シリアライズできない関数などをプロパティとして持ってしまうとおそらく保存に失敗します。
DataManager
ツクールのエディタが吐き出すJSONを読み込んで、Objectを返すモジュールです。いくらかは グローバルの名前空間にexport されます。
global変数の例
window.$dataActors = null;
window.$dataClasses = null;
window.$dataSkills = null;
window.$dataItems = null;
window.$dataWeapons = null;
window.$dataArmors = null;
window.$dataEnemies = null;
window.$dataTroops = null;
window.$dataStates = null;
window.$dataAnimations = null;
window.$dataTilesets = null;
window.$dataCommonEvents = null;
window.$dataSystem = null;
window.$dataMapInfos = null;
window.$dataMap = null;
window.$gameTemp = null;
window.$gameSystem = null;
window.$gameScreen = null;
window.$gameTimer = null;
window.$gameMessage = null;
window.$gameSwitches = null;
window.$gameVariables = null;
window.$gameSelfSwitches = null;
window.$gameActors = null;
window.$gameParty = null;
window.$gameTroop = null;
window.$gameMap = null;
window.$gamePlayer = null;
window.$testEvent = null;
それらを初期化する部分。
DataManager.createGameObjects = function() {
$gameTemp = new Game_Temp();
$gameSystem = new Game_System();
$gameScreen = new Game_Screen();
$gameTimer = new Game_Timer();
$gameMessage = new Game_Message();
$gameSwitches = new Game_Switches();
$gameVariables = new Game_Variables();
$gameSelfSwitches = new Game_SelfSwitches();
$gameActors = new Game_Actors();
$gameParty = new Game_Party();
$gameTroop = new Game_Troop();
$gameMap = new Game_Map();
$gamePlayer = new Game_Player();
};
SceneはDataManagerとWindowを参照して自分自身を構築することが多いです。
シリアライズ / デシリアライズ
セーブ時に行われる処理はここ。
DataManager.makeSaveContents = function() {
// A save data does not contain $gameTemp, $gameMessage, and $gameTroop.
var contents = {};
contents.system = $gameSystem;
contents.screen = $gameScreen;
contents.timer = $gameTimer;
contents.switches = $gameSwitches;
contents.variables = $gameVariables;
contents.selfSwitches = $gameSelfSwitches;
contents.actors = $gameActors;
contents.party = $gameParty;
contents.map = $gameMap;
contents.player = $gamePlayer;
return contents;
};
ゲームロジックを追加したければDataManagerに管理されるデータを追加して、ここで永続化すると良さそう。
ちなみにJsonExのシリアライズ/デシリアライズがなかなか豪快で、シリアライズ時はクラス名を@
プロパティに保存し、デシリアライズ時はグローバル空間からクラス名を探して、そのクラスをコンストラクタに使って初期化しつつ、プロパティを埋める、という実装です。
/**
* @static
* @method _encode
* @param {Object} value
* @param {Number} depth
* @return {Object}
* @private
*/
JsonEx._encode = function(value, depth) {
depth = depth || 0;
if (++depth >= this.maxDepth) {
throw new Error('Object too deep');
}
var type = Object.prototype.toString.call(value);
if (type === '[object Object]' || type === '[object Array]') {
var constructorName = this._getConstructorName(value);
if (constructorName !== 'Object' && constructorName !== 'Array') {
value['@'] = constructorName;
}
for (var key in value) {
if (value.hasOwnProperty(key)) {
value[key] = this._encode(value[key], depth + 1);
}
}
}
depth--;
return value;
};
/**
* @static
* @method _decode
* @param {Object} value
* @return {Object}
* @private
*/
JsonEx._decode = function(value) {
var type = Object.prototype.toString.call(value);
if (type === '[object Object]' || type === '[object Array]') {
if (value['@']) {
var constructor = window[value['@']];
if (constructor) {
value = this._resetPrototype(value, constructor.prototype);
}
}
for (var key in value) {
if (value.hasOwnProperty(key)) {
value[key] = this._decode(value[key]);
}
}
}
return value;
};
今現在認識されてる問題
- 標準でpreload機構を持たない
- モバイルで落ちることがあるのはそのせいでは?
- TilingSpriteが非効率でモバイルのFPSが出ない
- ImageManagerが画像のキャッシュを手放さず、GCが効かない(リークする)
ここらへん本体エンジンに手を入れることで解決できる可能性があります。
実際にどうプラグインを作るか
ゲームロジックを拡張する際は、
- データ実体はDataManagerの拡張
- 画面実体はWindowクラスの追加
- Scene のstart等の初期化フックからWindowを追加し、発生したイベントからDataManagerを操作
となる気がします。
戦闘画面をスクラッチで自作などする場合、元のコードをフォークしてScene_Battleを上書きしてしまったほうがいいかもしれません。
まとめ
色々と問題があることはわかっていて、mixinではなくメソッドオーバーライドしか既存の処理を上書きする方法がないので、プラグイン同士が干渉する可能性がかなり高いのではないか、という懸念があります。大量にプラグインを読み込んだ状態の動作保証は困難です。またランタイムのコードは読めますが、ドキュメント化された仕様はないので、バージョンアップ時の追従が可能か判断できません。
コミュニティ的にもここの解決策はまだ手探りで、どう解決するかは難しいところがあります。そこらへんみんなで考えていけたらな、という気持ちがあります。
このカレンダー以外にもエンジニアリング以外の話をする RPGツクールMV(なんでもあり) Advent Calendar 2015 もあります。この気に是非触ってみてはどうでしょうか。
僕自身が実際にゲームを作るかはわかりませんが、しばらくこのコードベースで遊べそうです。
それではよきゲーム作成ライフを。