バトルプログラムの設計で失敗しないために

バトルプログラムの設計で失敗しないために

こんにちは! サーバーエンジニアの吉田です。

私は今まで、いろんなゲームのバトルシステムに携わってきました。そして、たくさんの失敗も経験しました。
今回はその失敗した話と、その反省からこうした方が良いよ、という話をしてみたいと思います。

ここでは、言語としてはC++を想定して書いていますが、オブジェクト指向で書けるプログラミング言語なら、同じ考え方を適用できるはずです。

こんなバトルをつくりたい

まず、本題に入る前に、今回の記事で想定するバトルシステムについて、前提条件を書いておきます。

  • 複数のキャラが同時に、リアルタイムで行動する
  • 時間とともに、キャラの状態が変化する(例えば毒ダメージなど)
  • あるキャラの行動が、他のキャラの状態を変化させる(攻撃、回復など)
  • 敵キャラのHPが0になり倒されると、スコアが加算される

よくある、リアルタイムバトルですね。

このようなバトルをプログラミングする時、どのような事に注意をしなければならないでしょうか?

多くの場合、次のような形になるかと思います。
1つずつ、設計を進めてみましょう。

プログラムの構成

一般的なオブジェクト指向の考え方で設計してみます。

クラス設計

・Battleクラス --- バトルに参加しているキャラを管理し、バトルルールの処理をする
・BattleObjectクラス --- 1つのオブジェクト(操作キャラ、味方キャラ、敵キャラ等)を表す
図1 クラス設計

普通ですね。

オブジェクトの更新処理

一般にゲームは、時間の経過とともに状態が変化します。
その時間経過による状態の更新を、BattleクラスのUpdate()メソッドで処理することとします。
そして、BattleクラスのUpdate()の中で、各BattleObjectのUpdate()を呼びます。

図2 オブジェクトの更新処理

BattleObjectの実装

BattleObjectクラスの中身は、どうなるでしょうか。

・HP、攻撃力、防御力などのデータ
・時間経過による変化を処理するUpdate()メソッド
・ダメージを受けた際の処理をするRecvDamage()メソッド
図3 BattleObjectクラス

こんな感じでしょうか。

Update()の中では、下記の2つの処理をします。

BattleObject::Update()
・毒などの状態異常の時、一定時間置きに、RecvDamage()を呼ぶ
・攻撃判定→ヒットした場合、攻撃相手のRecvDamageを呼ぶ

そして、RecvDamage()の中では、

BattleObject::RecvDamage()
・HP減算
・死亡判定→死亡したら、スコア加算

このような処理をします。

図4 BattleObjectクラスの実装

実際に動かしてみたところ、特に問題なく動作します。
デバッガーさんによるテストでも問題なし!OK!
リリースです!

問題発生

・・・ところが!

リリースした後、ユーザーさんから、「スコアが余計に加算されることがある!」と報告が来ました。
テストでは問題なかったのに、どうしてこんな事が起こってしまうのでしょうか?

何が起こっているか?

いろいろと検証した結果、どうやら、キャラが多数出現し、処理に負荷がかかっている時に、報告されている様な現象が、たまに起こる様です。
負荷がかかった時にしか発現しないので、デバッグでもチェックしきれなかった様ですね。

図4を見て、考えてみましょう。

AのUpdate()
 攻撃判定
  → BのRecvDamage()が呼ばれる
  → 死亡(ただし、オブジェクト自体は次のBattle::Update()まで消えない)
  → スコアが入る。
BのUpdate()
 状態異常判定
  → 毒ダメージにより、BのRecvDamage()が呼ばれる
  → 死亡
  → スコアが入る。

おわかりでしょうか。
Aの攻撃でBが死亡することになるのですが、その後、B自体がBattleクラスから削除されるのは、次のUpdateの時なのです。
よって、死亡しているにもかかわらずBの毒ダメージが処理されてしまい、死亡処理が2回走って、スコアも2回加算される、という状況なのでした。

じゃあ、RecvDamage()の最初で、HPが0かどうかをチェックして、死亡処理が2回走らないようにすればいいね!

解決!

・・しかし、この時、私達は知りませんでした。
この設計の中に、もっと根の深い問題が潜んでいる事を・・・

ラスボス出現

時は過ぎ、リリース後のアップデートで、様々機能が入りました。

  • カウンター攻撃
  • HP1で耐えるスキル
  • 死亡と同時に別のキャラを召喚するスキル
  • 死亡と同時に相手に状態異常をかけるスキル
  • 死亡したキャラを復活させるスキル

などなど・・・
時とともに、プログラムはどんどん大規模に、複雑になっていきます。

図5 新しいBattleObjectクラス

そしてある時また、ユーザーさんから報告が入ったのです。

「バトル中にアプリがクラッシュする!」

今度は一体なにが起こっているのか・・・・
調査をするのですが、再現率が低い上、クラッシュログ等を見てみても、どうやらメモリ破壊が起きていることはわかるのですが、クラッシュするタイミングや状況もいろいろ。
メモリ破壊がおきてしまう根本の原因が何なのか、わかりません。
こうなってしまうと、調査は困難を極めます。

ソースコードとにらめっこをしながら、想像するに、最初に起こったスコアの2重加算バグの様に、「何かと何かが同時に起こる」といった時に、プログラマの意図しない状況が発生し、メモリ破壊がおきそうな気がします。

プログラマはコーディングを行っている時、この様な状況は、普通想像できません。
「カウンター攻撃を実装して」と言われたら、ダメージ処理の中にカウンターの判定を入れて、攻撃を発動させる。
それで実装完了と思ってしまうものです。
「ここでこれが起こって、同じタイミングでこれが起こったら、どうなるだろう?」とはなかなか考えません。

そして、デバッグでも問題は発見されず、リリース後に、お客様からの報告で発覚する、といったことになります。

最初の問題が起こった段階では、
「RecvDamage()の最初で、HPが0かどうかをチェックする」
この対応で、不具合を回避できました。

しかし、「あるオブジェクトのUpdate()が、他のオブジェクトの状態をも更新する」この様な設計である限り、今後、新規機能を実装する度に、オブジェクトとオブジェクトの影響の仕方が複雑になり、潜在的な問題が、徐々に大きくなって顕在化して来ることになります。

どこで誤ってしまったのか

今回経験したような、「本番環境でしか発生しない謎の落ちバグ」の様な不具合を、できるだけ起こさせないようにするには、どうしたら良いのでしょうか?
前章で確認した通り、「あるオブジェクトのUpdate()が、他のオブジェクトの状態をも更新する」この設計が、良くないと考えられます。
これがあるおかげで、処理が複雑になり、ソースの可読性も悪くなって・・・いつか、致命的なバグとして現れてくるんですね。

それなら、各オブジェクトの状態の更新は、全てそのオブジェクト自身のUpdate()メソッドのみで行うこととすればどうでしょう?
そうすれば、処理がスッキリし、わかりやすくなりそうです。

では、攻撃など、他のオブジェクトへ影響のある出来事があった場合はどうするかと言うと、「イベントキュー」というものを用意し、ここに情報を積んでおく様にします。
そして次のUpdate()で、各オブジェクトがイベントキューから情報を取り出し、自分に関係のある出来事だった場合は、それに従って自分の状態を更新する、とします。

図6 新しいバトル設計

1回目のUpdate()で起こった出来事は、キューに積まれ、2回目のUpdate()で処理します。
同様に、2回目のUpdate()で起こった出来事は、またキューに積まれ、3回目のUpdate()で処理します。

このようにすることで、プログラマは、常に1つのオブジェクトの状態だけを気にするだけで良くなりますね。
なにかバグが発生した時も、イベントキューに何が積まれているか、というところからスタートして、1つのオブジェクトのUpdate()処理だけを追って行けばよい、という訳です。

まとめ

今回紹介した問題点の難しいところは、プログラムの規模が小さいうちは、それほど問題とならず、機能追加を繰り返していくうちに、徐々に大きな問題となり、致命的な問題となった時には、もう取り返しがつかない状態になっている、ということです。

取り返しがつかない、というのは、ソースの規模が大きすぎて、リファクタするには、デバッグ含めて工数がかかりすぎる。
仕方がないので、場当たり的な対処でバグをつぶすのですが、またアップデートをしていくと、似たようなことでバグが発生し、悩む・・・の繰り返しになる、ということです。

オンラインゲームというのは、リリース後も次々とアップデートを繰り返し、機能が増えていきます。
よって、一番最初の設計で、どれだけ未来の変化を予測して、準備しておけるかが大事になってきます。

後になって苦しまないために、最初は多少面倒でも、柔軟でわかりやすい、変化に耐えうる設計を心がけたいものです。