RPG ツクール MV の戦闘の実装

  • 52
    Like
  • 0
    Comment
More than 1 year has passed since last update.

この記事は「RPG ツクール MV Advent Calendar」 13 日目の記事第 2 のドワンゴ Advent Calendar」 13 日目の記事です。 Qiita の人に怒られたら兼用をやめます。

はじめに

この記事は RPG ツクール MV における戦闘シーンの実装についての各論的なまとめです。自分が戦闘プラグインを作るために欲しかった情報をまとめます。RPG ツクール MV のソースコードは読めばだいたい分かるようにできていますが、さすがに戦闘まわりはかなり複雑であり、クラス関係や状態遷移を把握するのが大変です。戦闘プラグインを作るとき、または大々的に自作戦闘を作るときの資料としてどうぞ。

前提知識として、 JavaScript を読めること、「アクター」などのツクール MV 用語を知っていること、ツクール MV のターン制な戦闘がどういったものかを知っていることを仮定します。

用語などは基本的に英語版ベースです。筆者が日本語版を持っていないためです。適宜読み替えてください。

この記事のライセンスは原則 CC0、スクリプト部分はスクリプトのライセンスに従うものとします。

クイズ

突然ですがクイズです。

  • 戦闘が終了する条件は何か。
  • 敵の行動はどのタイミングで決定するか。
  • スキルのダメージとエフェクトで両方 HP が減るような設定を行った場合、何が起きるか。
  • 魔法の反射は誰に跳ね返るか。
  • アクターが素手で攻撃した場合、属性はどうなるか。
  • 複数属性を持った攻撃の場合、属性によるダメージ変動の計算はどうなるか。

この記事を読むと、このような質問に答えられるようになります。

クラス

戦闘に関係する主なクラスを説明します。

大雑把な依存関係を図示すると次のようになります。 Scene や View などのパッケージ的なものは筆者が便宜的につけたもので、実際にコード上に現れるものではありません。また依存関係の矢印は正確なものではなく、省略しているところもあります (特に View から Object への依存関係は全て省略しています)。

battle_classes.png

Scene_Battle (rpg_scene.js)
戦闘シーンクラス。戦闘の進行状態やビューの状態を更新する。状態は持たない (≒状態を表すフィールドがない)。大雑把に言うと、 RPG ツクール MV においては Scene クラス (Scene_* という名前のクラス) のオブジェクトの update を毎フレーム呼ぶことでゲームが進行する。 Scene_Battle は戦闘シーンにおける Scene クラスである。
BattleManager (rpg_managers.js)
戦闘シーンの状態を管理するクラス。シングルトン。ビューを更新する。 Scene_Battle との大きな違いは、 Scene_Battle は状態を持たないのに対し、 BattleManager は状態を持つ点である。 また、Scene_Battleupdate は毎フレーム呼ばれるのに対し、 BattleManagerupdate はウィンドウがアニメーションしていない状態においてのみ呼ばれ (厳密にはもう少し細かい条件がある)、さらに update 内部ではスプライトやウィンドウのどれがビジー状態の時には何もしない。すなわち論理的な状態の変更可能性がある時にのみ更新を行うメソッドである。 Scene_BattleBattleManager もどちらもビュー (ウィンドウの類) を更新する。
Game_BattlerBase (rpg_objects.js)
Game_ActorGame_Enemy の祖先クラス。戦闘における登場人物。
Game_Battler (rpg_objects.js)
Game_ActorGame_Enemyの親クラスで、 Game_BattlerBase の子クラス。 Game_BattlerBase にアクション (攻撃など) やアニメーション状態を加えたもの。
Game_Actor (rpg_objects.js)
アクターの状態を表すクラス。
Game_Enemy (rpg_objects.js)
エネミーの状態を表すクラス。
Game_Action (rpg_objects.js)
バトラーの行動を表すクラス。ダメージの処理などはこのクラスで行われる。
Game_ActionResult (rpg_objects.js)
バトラーの行動結果を表すクラス。主に Window_BattleLog で参照される。
Game_Unit (rpg_objects.js)
Game_PartyGame_Troop の親クラス。
Game_Party (rpg_objects.js)
パーティー (アクターの集まり) の状態を表すクラス。
Game_Troop (rpg_objects.js)
トループ (エネミーの集まり) の状態を表すクラス。
Spriteset_Battle (rpg_sprites.js)
戦闘シーンにおけるスプライトの集合。
Window_BattleLog (rpg_window.js)
戦闘中のメッセージウィンドウ。実はサイドビューバトルにおけるダメージ表示などもこのクラスを通じて行う。
Window_StatusLog (rpg_window.js)
戦闘中のステータスウィンドウ。

その他多数の Window クラスがありますが、省略します。

フェーズ

BattleManager._phase の値で、戦闘の進行状態を表す文字列値です。戦闘の大まかな状態を表します。

battle_phases.png

init
戦闘開始前の初期状態。 startBattle で start フェーズに遷移する。
start
ゲーム開始状態。戦闘開始直後のいわゆる「敵が現れた!」と表示されているところの状態。ウィンドウが閉じてすぐに input フェーズに遷移する。
input
パーティーコマンド (戦う or 逃げる) およびアクターごとのコマンド入力を行うフェーズ。パーティーコマンドを入力している最中は BattleManager.actor()null、アクターコマンドを入力している最中は BattleManager.actor() が入力中の Game_Actor を返す。パーティーコマンドで「Fight」を選択して、全入力が終了すると turn フェーズに遷移する。パーティーコマンドで「Escape」を選択すると BattleManager.processEspcae が実行される。逃走成功時に BattleManager.processAbort が呼ばれ、 battleEnd フェーズに遷移する。逃走失敗時には turn フェーズに遷移する。
turn
ターン数のインクリメント、バトラーの行動順の決定、行動の処理を行うフェーズ。誰かの行動が開始すると action フェーズに遷移する。全員の行動が終了すると turnEnd フェーズに遷移する。
action
バトラーの行動の処理を行うフェーズ。行動中のバトラーは BattleManager._subject である。行動が終了すると turn フェーズに遷移する。
turnEnd
ターン終了時のステート処理など、細かい処理を行うフェーズ。すぐに input フェーズに遷移する。
battleEnd
戦闘続行不可能になった場合のフェーズ。どのフェーズの時にでも、戦闘が続行できない場合にこのフェーズに遷移する。アクターが全員死亡、エネミーが全員死亡、パーティー逃走成功の他に、タイマーが時間切れした (aborting フェーズ)、イベントによって戦闘が中断した (aborting フェーズ)、アクターが誰もいなくなった場合である。状況によって元のシーンに戻るか、 Scene_GameOver に遷移する。 BattleManager._phasenull になる。
aborting
タイマーの時間切れ、またはイベントコマンドにより戦闘が中断するときのフェーズ。 BattleManager.processAbort を実行し、 battleEnd フェーズへ遷移する。 processAbort というメソッド名は aborting フェーズとは関係ありそうで、実はない (input フェーズの逃走成功時にも呼ばれる)。

コマンド入力についての補足

rpg_managers.js
BattleManager.startInput = function() {
    this._phase = 'input';
    $gameParty.makeActions();
    $gameTroop.makeActions();
    this.clearActor();
    if (this._surprise || !$gameParty.canInput()) {
        this.startTurn();
    }
};

turn フェーズから input フェーズに遷移するときに Game_UnitmakeActions が呼ばれ、結果的に Game_BattlermakeActions が呼ばれますが、これは一体何をしているのでしょうか。 makeActions はアクターとエネミーで若干異なります。 Game_ActormakeActions_actionsGame_Action を新しく作って設定していますが、これは入力への準備に必要な作業です。一方 Game_EnemymakeActions はもうその場でアクションが決定しています。裏方では「ユーザーの入力前に敵の行動は既に決定している」という面白いことになっているというわけです。

さて input フェーズでは Game_Actor_actions それぞれに対して入力を順番に回していきます。このとき _actions 配列が空だと順番が飛ばされます。実際に Game_ActorcanMovefalse の状態で makeActions を呼ぶと _actions は空配列になり、コマンド入力時に順番が飛ばされます。 makeActions という名前は少しわかりにくいですが、要は「入力のためのアクションのスロットの初期化作業」にあたります。

バトラーのアクションの処理

ここでいうアクションとは、バトラー一人の攻撃、防御、アイテム使用、スキル使用などの行動です。大雑把な流れは次の通りです。

  1. アクション準備 (BattleManager.startAction が呼ばれる)
  2. アクション開始 (BattleManager.invokeAction が呼ばれる)
  3. (必要ならば) カウンター、魔法反射の処理を行う
  4. アクション実行 (Game_Actionapply が呼ばれる)
  5. アクション結果の保存 (Game_Battler_result (Game_ActionResult) が変化)
  6. 結果の表示 (Window_BattleLogdisplayActionResult で、バトラーに設定された Game_ActionResult オブジェクトを元にメッセージなどを表示する)

順番に見ていきましょう。

アクション準備 (BattleManager.startAction)

rpg_managers.js
BattleManager.startAction = function() {
    var subject = this._subject;
    var action = subject.currentAction();                                                             
    var targets = action.makeTargets();
    this._phase = 'action';
    this._action = action;
    this._targets = targets;
    subject.useItem(action.item());
    this._action.applyGlobal();
    this.refreshStatus();
    this._logWindow.startAction(subject, action, targets);
};

この関数呼び出し時点では実はまだ action フェーズではなく turn フェーズです。ここでの重要なポイントは this._actionGame_Action オブジェクトをセットしているところです。 this._action はこれから実行するアクションを表します。

this._logWindow.startAction(...) でメッセージの表示やサイドビューの場合はアクターのアニメーションの変化などを処理しています。

アクション開始 (BattleManager.invokeAction)

rpg_managers.js
BattleManager.invokeAction = function(subject, target) {                                                                                                                                                    
    this._logWindow.push('pushBaseLine');                                                                                                                                                                   
    if (Math.random() < this._action.itemCnt(target)) {                                                                                                                                                     
        this.invokeCounterAttack(subject, target);                                                                                                                                                          
    } else if (Math.random() < this._action.itemMrf(target)) {                                                                                                                                              
        this.invokeMagicReflection(subject, target);                                                                                                                                                        
    } else {
        this.invokeNormalAction(subject, target);                                                                                                                                                           
    }
    subject.setLastTarget(target);                                                                                                                                                                          
    this._logWindow.push('popBaseLine');                                                                                                                                                                    
    this.refreshStatus();                                                                                                                                                                                   
};

_actionGame_Action オブジェクトで、使用するアイテムやスキルの情報です。 itemCntitemMrf というアイテムに関わりそうなメソッドが出てきましたが、実はこれはアイテムだけではなくスキルも関係します。スクリプト内部ではアイテム、武器、防具以外に、スキルも Game_Item で表現されています。スキルの使用もアイテムの使用も、 Game_Item を使用するという形で表現されるわけです。通常攻撃はスキル 1 番を使用するのと同じなので、バトラーのあらゆるアクションは結局 Game_Item の使用という形で表現されます。スクリプト中に出てくる item はいわゆるアイテムだけではなくスキルを指すことも多いのです。 itemCnt() はカウンター率を、 itemMrf() は魔法反射確率を表します。

アイテムまたはスキルによってカウンター、魔法反射、通常の処理で割り振っているだけで、基本的には invoke* メソッドが呼ばれ、それぞれの中で Game_Itemapply が呼ばれます。 invoke* メソッドで実際のアクション処理が行われます。

_logWindow (Window_BattleLog) に関しては後述します。

最後の refreshStatusWindow_BattleStatus を更新します。

通常

rpg_managers.js
BattleManager.invokeNormalAction = function(subject, target) {
    var realTarget = this.applySubstitute(target);
    this._action.apply(realTarget);
    this._logWindow.displayActionResults(subject, realTarget);
};

ターゲットがいない時の処理などがありますが、基本的に単に現在の行動 this._action (Game_Action) の apply を呼んでいるだけです。

カウンター

rpg_managers.js
BattleManager.invokeCounterAttack = function(subject, target) {
    var action = new Game_Action(target);
    action.setAttack();
    action.apply(subject);
    this._logWindow.displayCounter(target);
    this._logWindow.displayActionResults(subject, subject);
};

本来のアクション (this._action) を無視しています。新たな通常攻撃アクションを作り、それの apply を呼んでいます。

魔法反射

rpg_managers.js
BattleManager.invokeMagicReflection = function(subject, target) {
    this._logWindow.displayReflection(target);
    this._action.apply(subject);
    this._logWindow.displayActionResults(subject, subject);
};

Game_Action.prototype.apply の引数を target ではなく subject にしているだけです。ツクール MV においては、魔法の反射はかならず詠唱者に跳ね返るという実装になっています。

アクション実行 (Game_Actionapply)

rpg_objects.js
Game_Action.prototype.apply = function(target) {
    var result = target.result();
    this.subject().clearResult();
    result.clear();
    result.used = this.testApply(target);
    result.missed = (result.used && Math.random() >= this.itemHit(target));
    result.evaded = (!result.missed && Math.random() < this.itemEva(target));
    result.physical = this.isPhysical();
    result.drain = this.isDrain();
    if (result.isHit()) {
        if (this.item().damage.type > 0) {
            result.critical = (Math.random() < this.itemCri(target));
            var value = this.makeDamageValue(target, result.critical);
            this.executeDamage(target, value);
        }
        this.item().effects.forEach(function(effect) {
            this.applyItemEffect(target, effect);
        }, this);
        this.applyItemUserEffect(target);
    }
};

見ると分かる通り、以下の順で処理が行われます。

  1. アクション結果の初期設定
  2. ヒットなどの判定 (isHit)
  3. (ヒットした場合) ダメージの計算 (executeDamage)
  4. (ヒットした場合) エフェクトの計算 (applyItemEffect)
  5. (ヒットした場合) 使用者の TP の増加処理 (applyItemUserEffect)

ここで「ダメージ」はエディタでいうと右上の計算式などを書く欄、「エフェクト」はその下の特徴 (Traits) と似たような UI を持つ欄です (下図参照)。

damage_and_effects.png

最終的にアクションによって変化する HP などは GameBattlergainHp などのメソッドが呼ばれ、 GameBattler が持つ _result (Game_ActionResult オブジェクト) に結果がセットされます。この _result はアクションの結果表示に使われます。

ダメージとエフェクトで矛盾する場合

ダメージもエフェクトも両方 HP、 MP などの増減を指定できます。ここで、ダメージとエフェクト両方で HP を減らすようなものを書いた場合、何が起きるでしょうか。実装からいうと「ダメージの表示は後者になるが、実際の HP 増減は 2つを合わせたものになる」です。例えば HP のダメージや回復は GameBattlergainHp メソッドが呼ばれて処理されるのですが、このメソッドは 1 回のアクションで 2 度呼ばれることを想定していません。このような実装のため、同じパラメータをダメージとエフェクトで変化させるのはやめたほうがよいでしょう。

個人的にはダメージとエフェクトを統合してエフェクトだけにすればすっきりするのではないかと思うのですが、どういう意図でこう分かれているのかはよくわかりません。

ダメージの計算 (Game_ActionmakeDamageValue)

rpg_objects.js
Game_Action.prototype.makeDamageValue = function(target, critical) {
    var item = this.item();
    var baseValue = this.evalDamageFormula(target);
    var value = baseValue * this.calcElementRate(target);
    if (this.isPhysical()) {
        value *= target.pdr;
    }
    if (this.isMagical()) {
        value *= target.mdr;
    }
    if (baseValue < 0) {
        value *= target.rec;
    }
    if (critical) {
        value = this.applyCritical(value);
    }
    value = this.applyVariance(value, item.damage.variance);
    value = this.applyGuard(value, target);
    value = Math.round(value);
    return value;
};

スキルやアイテムに設定されている戦闘計算式から、属性の影響 (calcElementRate)、ヒットタイプが物理攻撃や魔法攻撃だった場合の変動、蘇生回復だった場合の回復率、クリティカル (applyCritical)、分散 (applyVariance)、防御 (applyGuard) を考慮して最終的なダメージを算出します。evalDamageFormula は HP Recover または MP Recover の場合に 0 以下の値を、それ以外の場合に 0 以上の値を返します。

属性としての物理とヒットタイプとしての物理

invocation.png

上図のようにスキルやアイテムにはヒットタイプ (Hit Type) という概念があり、「Certain Hit」、「Physical Attack」、「Magic Attack」の 3 種類があります。一方で属性 (Element) の方にも Physical があります。属性 (Element) としての物理 (Physical) とヒットタイプ (Hit Type) としての物理 (Physical) は、名前は同じでも全く別物です。

属性としての物理は、属性によるダメージ変動にのみ関係があります。

ヒットタイプには前述のとおり 3 種類ありますが、それぞれどの命中率や回避率が適用されるのか (もしくはされないのか)、カウンター攻撃の対象になるのか、どうアニメーションするかなどに影響があります。 Game_ActionisPhysical はヒットタイプが物理かどうかを返します。属性は関係ありません。

ところで、デフォルトで入っている武器やエネミーの攻撃には物理属性がつくように設定されていますが、スキルの 1 番には物理属性はついていません。アクターが素手で攻撃をした場合、物理属性はつくのでしょうか。実は、アクターが素手で攻撃した場合に物理属性が自動的につくようになっています (参考: Game_Actor.prototype.attackElements)。

属性によるダメージの変動

rpg_objects.js
Game_Action.prototype.calcElementRate = function(target) {
    if (this.item().damage.elementId < 0) {
        return this.elementsMaxRate(target, this.subject().attackElements());
    } else {
        return target.elementRate(this.item().damage.elementId);
    }
};

Game_Action.prototype.elementsMaxRate = function(target, elements) {
    if (elements.length > 0) {
        return Math.max.apply(null, elements.map(function(elementId) {
            return target.elementRate(elementId);
        }, this));
    } else {
        return 1;
    }
};

攻撃属性集合のそれぞれの属性に対し、対象者のその属性の耐性を掛け算し、それらの値の中で最大の値が採用されます。例えば攻撃属性が「火」と「雷」、対象者の特性が「火 50%」、「火 150%」、「雷 100%」だとすると、火の耐性が 50% × 150% で 75%、雷の耐性が 100% なので、そのうちの大きい方である 100% が採用されます。

通常攻撃 (Normal Attack) の場合は、攻撃者の属性特性がすべて適用されるため、複数属性の攻撃になりえます。通常攻撃ではない場合、スキルやアイテムの属性 1 つだけが使われます。

アクション結果の保存 (Game_ActionResult)

バトラーのアクションの結果は、 HP などのパラメータやステートの変更があったバトラー (Game_Battler) の _result (Game_Action オブジェクト) に格納されます。 HP やステートなどの純粋な現在の状態は Game_Battler の各フィールドに入るのですが、「最後のアクションの結果」は _result に入っているわけです。

結果の表示 (Window_BattleLogdisplayActionResults)

rpg_windows.js
Window_BattleLog.prototype.displayActionResults = function(subject, target) {
    if (target.result().used) {
        this.push('pushBaseLine');
        this.displayCritical(target);
        this.push('popupDamage', target);
        this.push('popupDamage', subject);
        this.displayDamage(target);
        this.displayAffectedStatus(target);
        this.displayFailure(target);
        this.push('waitForNewLine');
        this.push('popBaseLine');
    }
};

subjecttarget は Game_Battler オブジェクトですが、それらの _result (Game_Action) を参照して適切にアニメーションを表示します。呼び出し時点では push によってキュー (_methods) に何を実行するかが貯められ、後で順番に実行されます。

Window_BattleLog は単なるウィンドウではなく、サイドビューだった場合のアクターのアニメーションや、バトラーのダメージポップアップなどの処理も兼ねています。 Window_BattleLog については次節で軽く解説します。

Window_BattleLog

Window_BattleLog はウィンドウクラスのなかでも面白いクラスで、命令を一旦ためて後で実行するということをしています。

rpg_windows.js
Window_BattleLog.prototype.push = function(methodName) {
    var methodArgs = Array.prototype.slice.call(arguments, 1);
    this._methods.push({ name: methodName, params: methodArgs });
};

push メソッドで一旦メソッド名と引数を _methods にプッシュします。 _methods はスタックではなく FIFO なキューで、これを先頭から取り出して順番に実行していきます。

rpg_windows.js
Window_BattleLog.prototype.callNextMethod = function() {
    if (this._methods.length > 0) {
        var method = this._methods.shift();
        if (method.name && this[method.name]) {
            this[method.name].apply(this, method.params);
        } else {
            throw new Error('Method not found: ' + method.name);
        }
    }
};

callNextMethodshift (先頭から dequeue) して、そこに入っていた文字列名をメソッド名としてメソッド呼び出ししています。このように、命令列をあとからインタプリタ的に実行する形になっています。

rpg_windows.js
Window_BattleLog.prototype.displayActionResults = function(subject, target) {
    if (target.result().used) {
        this.push('pushBaseLine');
        this.displayCritical(target);
        this.push('popupDamage', target);
        this.push('popupDamage', subject);
        this.displayDamage(target);
        this.displayAffectedStatus(target);
        this.displayFailure(target);
        this.push('waitForNewLine');
        this.push('popBaseLine');
    }
};

displayActionResults を再掲します。これは subject および targetGame_ActionResult オブジェクトを見て、 push で命令列を突っ込んでいる処理になります。

pushBaseLinepopBaseLine は現在の行を記憶する / 戻すという操作に対応するのですが、これが効いてくるのはアクター一人で二度攻撃する場合などだけだと思われます。ここらへん理解しきれていないので説明は割愛します。

popupDamage はアクションによって変化した HP などをポップアップで表示します。実はこれは Game_BattlerstartDamagePopup に処理を委譲しているだけです。 Game_Battler は純粋なモデルを表すのではなく、ダメージのポップアップ状態やアニメ状態も管理しています。 ビューであるところの Window_BattleLog からモデルであるところの Game_Battler を操作していて、最初見た時にギョッとしてしまったのですが、 Game_Battler は実はモデルではなくモデルとビューを兼ね備えたものだと思えばよいです。

他の処理はダメージやステートの変化をウィンドウに表示したり Game_Battler にアニメの指示をしたりしているだけです。

私見

Scene_BattleBattleManager は責務がだいぶ被っている点が気になります。リファクタリングしてもうちょっと綺麗に分けられるのではないかと考えています。またモデルとビューが兼用になってしまっているクラスなどがあり (Game_Battler)、これらもちゃんと分けたほうがよいのではないかと思いました。

とはいうものの、全体として素直で大変読みやすいコードであり、解析にはさほど困りませんでした。

本当は逃走の処理、イベントの処理、ビューを解説していきたかったのですが時間切れです。それらはまた気が向いたら別の機会にやろうと思います。

発見したことを整理せず各論的に書いたため、まとまりのない記事になってしまいましたが、最後までご覧頂きありがとうございました。