この記事は「RPG ツクール MV Advent Calendar」 13 日目の記事兼「第 2 のドワンゴ Advent Calendar」 13 日目の記事です。 Qiita の人に怒られたら兼用をやめます。
はじめに
この記事は RPG ツクール MV における戦闘シーンの実装についての各論的なまとめです。自分が戦闘プラグインを作るために欲しかった情報をまとめます。RPG ツクール MV のソースコードは読めばだいたい分かるようにできていますが、さすがに戦闘まわりはかなり複雑であり、クラス関係や状態遷移を把握するのが大変です。戦闘プラグインを作るとき、または大々的に自作戦闘を作るときの資料としてどうぞ。
前提知識として、 JavaScript を読めること、「アクター」などのツクール MV 用語を知っていること、ツクール MV のターン制な戦闘がどういったものかを知っていることを仮定します。
用語などは基本的に英語版ベースです。筆者が日本語版を持っていないためです。適宜読み替えてください。
この記事のライセンスは原則 CC0、スクリプト部分はスクリプトのライセンスに従うものとします。
クイズ
突然ですがクイズです。
- 戦闘が終了する条件は何か。
- 敵の行動はどのタイミングで決定するか。
- スキルのダメージとエフェクトで両方 HP が減るような設定を行った場合、何が起きるか。
- 魔法の反射は誰に跳ね返るか。
- アクターが素手で攻撃した場合、属性はどうなるか。
- 複数属性を持った攻撃の場合、属性によるダメージ変動の計算はどうなるか。
この記事を読むと、このような質問に答えられるようになります。
クラス
戦闘に関係する主なクラスを説明します。
大雑把な依存関係を図示すると次のようになります。 Scene や View などのパッケージ的なものは筆者が便宜的につけたもので、実際にコード上に現れるものではありません。また依存関係の矢印は正確なものではなく、省略しているところもあります (特に View から Object への依存関係は全て省略しています)。
-
Scene_Battle
(rpg_scene.js) - 戦闘シーンクラス。戦闘の進行状態やビューの状態を更新する。状態は持たない (≒状態を表すフィールドがない)。大雑把に言うと、 RPG ツクール MV においては Scene クラス (
Scene_*
という名前のクラス) のオブジェクトのupdate
を毎フレーム呼ぶことでゲームが進行する。Scene_Battle
は戦闘シーンにおける Scene クラスである。 -
BattleManager
(rpg_managers.js) - 戦闘シーンの状態を管理するクラス。シングルトン。ビューを更新する。
Scene_Battle
との大きな違いは、Scene_Battle
は状態を持たないのに対し、BattleManager
は状態を持つ点である。 また、Scene_Battle
のupdate
は毎フレーム呼ばれるのに対し、BattleManager
のupdate
はウィンドウがアニメーションしていない状態においてのみ呼ばれ (厳密にはもう少し細かい条件がある)、さらにupdate
内部ではスプライトやウィンドウのどれがビジー状態の時には何もしない。すなわち論理的な状態の変更可能性がある時にのみ更新を行うメソッドである。Scene_Battle
もBattleManager
もどちらもビュー (ウィンドウの類) を更新する。 -
Game_BattlerBase
(rpg_objects.js) -
Game_Actor
とGame_Enemy
の祖先クラス。戦闘における登場人物。 -
Game_Battler
(rpg_objects.js) -
Game_Actor
とGame_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_Party
とGame_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
の値で、戦闘の進行状態を表す文字列値です。戦闘の大まかな状態を表します。
- 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._phase
はnull
になる。 - aborting
- タイマーの時間切れ、またはイベントコマンドにより戦闘が中断するときのフェーズ。
BattleManager.processAbort
を実行し、 battleEnd フェーズへ遷移する。processAbort
というメソッド名は aborting フェーズとは関係ありそうで、実はない (input フェーズの逃走成功時にも呼ばれる)。
コマンド入力についての補足
BattleManager.startInput = function() {
this._phase = 'input';
$gameParty.makeActions();
$gameTroop.makeActions();
this.clearActor();
if (this._surprise || !$gameParty.canInput()) {
this.startTurn();
}
};
turn フェーズから input フェーズに遷移するときに Game_Unit
の makeActions
が呼ばれ、結果的に Game_Battler
の makeActions
が呼ばれますが、これは一体何をしているのでしょうか。 makeActions
はアクターとエネミーで若干異なります。 Game_Actor
の makeActions
は _actions
に Game_Action
を新しく作って設定していますが、これは入力への準備に必要な作業です。一方 Game_Enemy
の makeActions
はもうその場でアクションが決定しています。裏方では「ユーザーの入力前に敵の行動は既に決定している」という面白いことになっているというわけです。
さて input フェーズでは Game_Actor
の _actions
それぞれに対して入力を順番に回していきます。このとき _actions
配列が空だと順番が飛ばされます。実際に Game_Actor
の canMove
が false
の状態で makeActions
を呼ぶと _actions
は空配列になり、コマンド入力時に順番が飛ばされます。 makeActions
という名前は少しわかりにくいですが、要は「入力のためのアクションのスロットの初期化作業」にあたります。
バトラーのアクションの処理
ここでいうアクションとは、バトラー一人の攻撃、防御、アイテム使用、スキル使用などの行動です。大雑把な流れは次の通りです。
- アクション準備 (
BattleManager.startAction
が呼ばれる) - アクション開始 (
BattleManager.invokeAction
が呼ばれる) - (必要ならば) カウンター、魔法反射の処理を行う
- アクション実行 (
Game_Action
のapply
が呼ばれる) - アクション結果の保存 (
Game_Battler
の_result
(Game_ActionResult
) が変化) - 結果の表示 (
Window_BattleLog
のdisplayActionResult
で、バトラーに設定されたGame_ActionResult
オブジェクトを元にメッセージなどを表示する)
順番に見ていきましょう。
アクション準備 (BattleManager.startAction
)
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._action
に Game_Action
オブジェクトをセットしているところです。 this._action
はこれから実行するアクションを表します。
this._logWindow.startAction(...)
でメッセージの表示やサイドビューの場合はアクターのアニメーションの変化などを処理しています。
アクション開始 (BattleManager.invokeAction
)
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();
};
_action
は Game_Action
オブジェクトで、使用するアイテムやスキルの情報です。 itemCnt
や itemMrf
というアイテムに関わりそうなメソッドが出てきましたが、実はこれはアイテムだけではなくスキルも関係します。**スクリプト内部ではアイテム、武器、防具以外に、スキルも Game_Item
で表現されています。**スキルの使用もアイテムの使用も、 Game_Item
を使用するという形で表現されるわけです。通常攻撃はスキル 1 番を使用するのと同じなので、バトラーのあらゆるアクションは結局 Game_Item
の使用という形で表現されます。スクリプト中に出てくる item
はいわゆるアイテムだけではなくスキルを指すことも多いのです。 itemCnt()
はカウンター率を、 itemMrf()
は魔法反射確率を表します。
アイテムまたはスキルによってカウンター、魔法反射、通常の処理で割り振っているだけで、基本的には invoke*
メソッドが呼ばれ、それぞれの中で Game_Item
の apply
が呼ばれます。 invoke*
メソッドで実際のアクション処理が行われます。
_logWindow
(Window_BattleLog
) に関しては後述します。
最後の refreshStatus
は Window_BattleStatus
を更新します。
通常
BattleManager.invokeNormalAction = function(subject, target) {
var realTarget = this.applySubstitute(target);
this._action.apply(realTarget);
this._logWindow.displayActionResults(subject, realTarget);
};
ターゲットがいない時の処理などがありますが、基本的に単に現在の行動 this._action
(Game_Action
) の apply
を呼んでいるだけです。
カウンター
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
を呼んでいます。
魔法反射
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_Action
の apply
)
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);
}
};
見ると分かる通り、以下の順で処理が行われます。
- アクション結果の初期設定
- ヒットなどの判定 (
isHit
) - (ヒットした場合) ダメージの計算 (
executeDamage
) - (ヒットした場合) エフェクトの計算 (
applyItemEffect
) - (ヒットした場合) 使用者の TP の増加処理 (
applyItemUserEffect
)
ここで「ダメージ」はエディタでいうと右上の計算式などを書く欄、「エフェクト」はその下の特徴 (Traits) と似たような UI を持つ欄です (下図参照)。
最終的にアクションによって変化する HP などは GameBattler
の gainHp
などのメソッドが呼ばれ、 GameBattler
が持つ _result
(Game_ActionResult
オブジェクト) に結果がセットされます。この _result
はアクションの結果表示に使われます。
ダメージとエフェクトで矛盾する場合
ダメージもエフェクトも両方 HP、 MP などの増減を指定できます。ここで、ダメージとエフェクト両方で HP を減らすようなものを書いた場合、何が起きるでしょうか。実装からいうと「ダメージの表示は後者になるが、実際の HP 増減は 2つを合わせたものになる」です。例えば HP のダメージや回復は GameBattler
の gainHp
メソッドが呼ばれて処理されるのですが、このメソッドは 1 回のアクションで 2 度呼ばれることを想定していません。このような実装のため、同じパラメータをダメージとエフェクトで変化させるのはやめたほうがよいでしょう。
個人的にはダメージとエフェクトを統合してエフェクトだけにすればすっきりするのではないかと思うのですが、どういう意図でこう分かれているのかはよくわかりません。
ダメージの計算 (Game_Action
の makeDamageValue
)
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 以上の値を返します。
属性としての物理とヒットタイプとしての物理
上図のようにスキルやアイテムにはヒットタイプ (Hit Type) という概念があり、「Certain Hit」、「Physical Attack」、「Magic Attack」の 3 種類があります。一方で属性 (Element) の方にも Physical があります。属性 (Element) としての物理 (Physical) とヒットタイプ (Hit Type) としての物理 (Physical) は、名前は同じでも全く別物です。
属性としての物理は、属性によるダメージ変動にのみ関係があります。
ヒットタイプには前述のとおり 3 種類ありますが、それぞれどの命中率や回避率が適用されるのか (もしくはされないのか)、カウンター攻撃の対象になるのか、どうアニメーションするかなどに影響があります。 Game_Action
の isPhysical
はヒットタイプが物理かどうかを返します。属性は関係ありません。
ところで、デフォルトで入っている武器やエネミーの攻撃には物理属性がつくように設定されていますが、スキルの 1 番には物理属性はついていません。アクターが素手で攻撃をした場合、物理属性はつくのでしょうか。実は、アクターが素手で攻撃した場合に物理属性が自動的につくようになっています (参考: Game_Actor.prototype.attackElements
)。
属性によるダメージの変動
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_BattleLog
の displayActionResults
)
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');
}
};
subject
や target
は Game_Battler オブジェクトですが、それらの _result
(Game_Action
) を参照して適切にアニメーションを表示します。呼び出し時点では push
によってキュー (_methods
) に何を実行するかが貯められ、後で順番に実行されます。
Window_BattleLog
は単なるウィンドウではなく、サイドビューだった場合のアクターのアニメーションや、バトラーのダメージポップアップなどの処理も兼ねています。 Window_BattleLog
については次節で軽く解説します。
Window_BattleLog
Window_BattleLog
はウィンドウクラスのなかでも面白いクラスで、命令を一旦ためて後で実行するということをしています。
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 なキューで、これを先頭から取り出して順番に実行していきます。
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);
}
}
};
callNextMethod
で shift
(先頭から dequeue) して、そこに入っていた文字列名をメソッド名としてメソッド呼び出ししています。このように、命令列をあとからインタプリタ的に実行する形になっています。
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
および target
の Game_ActionResult
オブジェクトを見て、 push
で命令列を突っ込んでいる処理になります。
pushBaseLine
と popBaseLine
は現在の行を記憶する / 戻すという操作に対応するのですが、これが効いてくるのはアクター一人で二度攻撃する場合などだけだと思われます。ここらへん理解しきれていないので説明は割愛します。
popupDamage
はアクションによって変化した HP などをポップアップで表示します。実はこれは Game_Battler
の startDamagePopup
に処理を委譲しているだけです。 Game_Battler
は純粋なモデルを表すのではなく、ダメージのポップアップ状態やアニメ状態も管理しています。 ビューであるところの Window_BattleLog
からモデルであるところの Game_Battler
を操作していて、最初見た時にギョッとしてしまったのですが、 Game_Battler
は実はモデルではなくモデルとビューを兼ね備えたものだと思えばよいです。
他の処理はダメージやステートの変化をウィンドウに表示したり Game_Battler
にアニメの指示をしたりしているだけです。
私見
Scene_Battle
と BattleManager
は責務がだいぶ被っている点が気になります。リファクタリングしてもうちょっと綺麗に分けられるのではないかと考えています。またモデルとビューが兼用になってしまっているクラスなどがあり (Game_Battler
)、これらもちゃんと分けたほうがよいのではないかと思いました。
とはいうものの、全体として素直で大変読みやすいコードであり、解析にはさほど困りませんでした。
本当は逃走の処理、イベントの処理、ビューを解説していきたかったのですが時間切れです。それらはまた気が向いたら別の機会にやろうと思います。
発見したことを整理せず各論的に書いたため、まとまりのない記事になってしまいましたが、最後までご覧頂きありがとうございました。