はじめに
ゲームを MVC/MVP 作ると「データ」「振る舞い」「見た目」を分離して実装できメリットが多いように思えます。
- ゲームは見た目の変更が多いが View だけに変更を閉じ込められる
- View に依存しないのでコンソール実行が可能。それによって戦闘部分だけ何万回も試行してバランス調整等が出来る
- 移植で描画環境が大きく変わったときも View だけの変更でゲームロジックはそのまま使える
しかし、ゲームでは「Model」と「View」に時間のズレがあるため、それを仲介する仕組みが必要になります。
どういう問題か?
RPG の攻撃処理を単純な MVC で作ってみる
- Controller が、プレイヤーからの入力(攻撃)を受け取る
- Controller が、Model にある敵 HP を減少させる
- View が、Model を監視して、敵 HP バー表示を更新
疑似コードで書くと次のような感じです。
class EnemyModel
{
int _hp;
public Subject<int> HP { get; }
public void ApplyDamage(int val)
{
_hp -= val;
HP.OnNext(_hp);
}
}
class Controller
{
EnemyModel _enemy;
PlayerModel _player;
void OnAttack()
{
int atk = _player.ATK;
_enemy.ApplyDamage(atk);
}
}
class HPBar
{
void Start()
{
_enemy.HP.Subscribe(val => {
Draw(val); // HPバーの表示を更新
});
}
}
この実装だと攻撃を実行後、即座に敵の HP バーが減少してしまう
- WEB サイトなどであれば即レスポンスが返ることは好ましいです
- しかしゲームにおいては「攻撃を実行」→ 「攻撃演出」 →「ダメージ演出」→「HPバー減少」のように演出をはさみたい事が多々あります
- つまり Model が変更されて HP が 10 → 5 になったとしても、View は HP 10 を表示したいタイミングがあるのです
ではどうするか?
対策案1. MVC で工夫する
- HPBar に演出中フラグを渡す
- 演出中フラグが OFF になってから HP バーの描画を更新する
class HPBar
{
void Start()
{
_enemy.HP.Subscribe(async val => {
while (_isAnimationPlaying)
{
await UniTask.Yield(); // アニメーションが終わるまで待機
}
Draw(val);
});
}
}
メリット
- MVC なので Model, Controller は表示を気にする必要がない
デメリット
- 表示物や演出が増えると単なる bool 値だけでは状況をとらえられなくなってくる
- 演出中にも HPバーを更新したいような演出がくると苦しくなる
- 例えば、連続攻撃で、攻撃中はずっと演出が再生されるが、その演出中に HP バーを 1 撃ごとに更新したい、等
- View が受け取れるのは「最終的な HP」だけなので、途中経過を復元できない
対策案2. MVP にする
MVC だと View のタイミングを管理する事が難しそうなので、Presenter が演出の順序を握る MVP にしてみます。
Presenter が「Model の更新」と「演出の待機」を交互に進める事で、1 撃ごとに HP バーを減らす事ができます。
class Presenter
{
async UniTask OnRenzokuAttack()
{
List<int> atks = _player.RenzokuAttack();
_view.StartPlayerAttack(); // 連続攻撃演出開始
for (int i = 0; i < atks.Count; i++)
{
await _view.WaitPlayerAttackHit(i); // i撃目のヒットまで演出を待つ
_enemy.ApplyDamage(atks[i]);
_hpBar.Refresh(); // HPバーを更新
}
}
}
class HPBar
{
EnemyModel _enemy;
HPBar(EnemyModel enemy)
{
_enemy = enemy;
}
// Model を参照して再描画する
public void Refresh()
{
Draw(_enemy.Hp, _enemy.MaxHp);
}
}
メリット
- 演出が複雑になっても Presenter で吸収できる
- Model と View の更新タイミングが合っているので、View から Model を参照させて描画ができる
デメリット
- ゲームロジック(何撃目でいくつダメージが入るか、等)が Presenter に書かれてしまっている
- なのでコンソールで戦闘だけ実行したい時に、Model だけでは動かせず、Presenter や疑似 View まで用意する必要が出てしまう
- View が Model を参照してしまうので、Model と View の更新タイミングを常に合わせる事が必須になってしまう
対策案3. MVP だが Presenter にロジックを書かない
- Model 側に戦闘ロジックを解決する BattleLogic を作成
- BattleLogic だけで戦闘の処理が最後まで動くように
- BattleLogic は戦闘で何が起きたのかを
List<BattleEvent>として返す - View は Model を参照しない。描画に必要な情報は BattleEvent に全て埋め込まれており、それのみを参照する
// 「戦闘で起きたこと」を表す不変データ
abstract record BattleEvent;
record AttackStarted(int HitIndex) : BattleEvent;
record BlockDamaged(int Value, int Remain) : BattleEvent;
record HpDamaged(int Value, int Remain) : BattleEvent;
record EnemyDied(int EnemyId) : BattleEvent;
class BattleLogic
{
public List<BattleEvent> ExecuteAttack(PlayerModel player, EnemyModel enemy)
{
var events = new List<BattleEvent>();
for (int i = 0; i < player.AttackCount; i++)
{
events.Add(new AttackStarted(i));
int atk = player.Attack;
int blocked = Math.Min(enemy.Block, atk);
if (blocked > 0)
{
enemy.Block -= blocked;
events.Add(new BlockDamaged(blocked, enemy.Block));
}
int damage = atk - blocked;
if (damage > 0)
{
enemy.Hp -= damage;
events.Add(new HpDamaged(damage, enemy.Hp));
}
if (enemy.Hp <= 0)
{
events.Add(new EnemyDied(enemy.Id));
break;
}
}
return events;
}
}
class Presenter
{
async UniTask OnAttack()
{
// ロジックはこの1行で完結する
var events = _battleLogic.ExecuteAttack(_player, _enemy);
// View はイベント列を再生するだけ
await _view.PlayAttackEvents(events);
}
}
class BattleView
{
public async UniTask PlayAttackEvents(List<BattleEvent> events)
{
foreach (var ev in events)
{
switch (ev)
{
case AttackStarted e: await PlayAttackEffect(e.HitIndex); break;
case BlockDamaged e: await _blockView.PlayDamage(e.Value, e.Remain); break;
case HpDamaged e: await _hpBar.PlayDamage(e.Value, e.Remain); break;
case EnemyDied e: await _enemyView.PlayDie(e.EnemyId); break;
}
}
}
}
Event にその時点の残り HP が記録されています。
BattleLogic がイベント列を返した時点で計算は全て終わっているため、View が再生中に Model を見に行くと「最終状態」しか見えません(1 撃目の演出中でも、Model の HP はもう全弾分減っています)。View が Model を参照せずイベントだけを見る事で、正しい途中経過を描画できます。
メリット
- ゲームロジックと描画を、時間軸ごと分離できる
- BattleLogic は単体テストやコンソールでの大量シミュレーションがしやすい
- 「何がどの順で起きたか」がデータとして残るので、リプレイやバグ調査に使える
デメリット
- View に必要なデータを Event に全て入れないといけないので、Event の定義量が増える。また View で描画したい内容が変わると Event 側の変更も必要になる (Event が View に依存)
- 攻撃中に入力があるとコンボ攻撃に変化するなどの仕様に対応しづらい
どの方法を選べばいいのか?
自分の所感では、ゲームによる事がまたは実装箇所によりハイブリッドになるのかと思います。(本当はもっといい別の方法があるのかもしれない。Model と View を分離しコンポーネント型の実装とか)
- 案1はインゲームには使いづらいが、メニューUIなどでは有用
- 自分の経験ではインゲームは案2の実装になってしまうのかと思う。(仕様変更に強くや複数メンバーで実装しやすい) オートプレーは作れるので、シミュレータは頑張って何とか作る。ただし Model はなるべく参照しないで案3のようにイベント貰う形で
- 案3はスロットゲームのようなロジック部分だけ何億回も試行して率が収束するか調べる必要があって、かつスピン後にプレイヤーの介入が無いなら相性が良いと思います。ただし複数人で作ると Event に View のための情報を追加されてしまったりするので注意が必要
まとめ
ゲームと MVC/MVP との相性について見てきました
- MVC/MVP は View を Model の状態と同期させる力が強いため、Model と View に時間差が必要なゲーム開発では、その時間差をどう扱うかが大事
- またゲームでは Model は View で演出するデータを全て公開する必要も出てくる。例えば連続攻撃での都度のダメージやHPの減少値やブロック値の減少など
- View から Model への直接参照を許可するか/禁止するかの方式もある
