AmusementCreators AdventCalender2017 7日目の記事です。担当は1年のzamakaです。
まだまだプログラミング、C#、Altseed初心者ですが、ゲーム制作における敵の設計について、Gofデザインパターンの一つであるストラテジーパターンを使った設計を、自分なりにまとめてみようと思います。
見当違いなことを書いている個所や、改善点、誤字脱字等ありましたら、コメント等で優しくアドバイスしていただければ幸いです。
はじめに
この記事は「ゲームの基本は見様見真似で作れるようになったけど、クラス設計とかどうすればいいのかわからない。」
もしくは「オブジェクト指向について、継承とかインターフェースとか習ったけど、何の役に立つの?」という人向けの記事です。
実際にコードを書く前に、敵の仕様について考えるわけですが、敵にはいくつか、あるいは無数の種類がいるはずです。移動方法だけ考えても、真っ直ぐ進む、後ろに下がっていく、プレイヤーに向かってくる、プレイヤーの回りをぐるぐる回るなど。さらに弾の撃ち方や、体力、撃破条件など、考え始めればきりがありません。この、様々な種類の敵の問題をどうやって解決しようかというのが今回のテーマです。
そして、様々な種類の敵を実装する方法にも、いくつかの方法があります。個人で書くプログラムならば、究極、動けばいいわけですが、誰かに見せるプログラムならば当然、そうでなくともできる限り、わかりやすく、管理・拡張がしやすい、優れた設計をしたいわけですね。ゲームの規模等に応じて、適切な設計は異なると思いますが、とりあえず今回は3つの例をあげます。
注意点
「敵」といっても、ゲームジャンルによって、その挙動は様々です。今回作成する例は、縦スクロール2Dシューティングゲームにおける敵を基本としています。また、下記のコード例はあくまでイメージをつかむための例です。コピペしても動かないのでご注意ください(Vectorクラスとか、それっぽいのを定義せずに使ってるので)。
switch文を使った方法
オブジェクト指向がよくわからず敬遠している場合や、ちょっとした処理なのにクラス分けるのめんどくせぇ!って場合に、とりあえず使うのがこの方法だと思います。
Enum型で敵の行動パターンを管理し、switch文で処理を分岐させます。単純であるがゆえに、わかりやすいといえばわかりやすいです。しかし、新しく行動パターンを増やすたびに、Enemyクラスを書き換える必要があり、いくつかの行動パターンで共通する処理があった場合に、そのすべてを書き直す必要もあるなど、拡張のしにくさが問題となります。
public class Enemy
{
public Enum MovementType{
GoStraight,
GoBack
};
protected MovementType Movement;
protected Vector Position;
public Enemy(Vector position, MovementType movement)
{
Movement = movement;
Position = position;
}
public void Update()
{
switch(Movement){
case GoStraight:
Position += new Vector(0, 1);
break;
case GoBack:
Position += new Vector(0, -1);
break;
}
}
}
継承を使った方法
次に、オブジェクト指向が分かってきて、試してみるのが、継承を使う方法です。
すべての敵に共通する要素だけ持ったEnemyクラスを親クラスとして作成し、それを継承した子クラスとして、GoStraightEnemyクラスやGoBackEnemyを作成します。
後はEnemyクラス型の配列やリストに対して、子クラスのインスタンスを追加してやれば、EnemyクラスとしてまとめてUpdateメソッドを呼ぶだけで、個々の移動方法に従って動いてくれるわけです。
public class Enemy
{
protected Vector Position;
public Enemy(Vector position)
{
Position = position;
}
public virtual void Update()
{
}
}
public class GoStraightEnemy : Enemy
{
public override void Update()
{
Position += new Vector(0, 1);
}
}
public class GoBackEnemy : Enemy
{
public override void Update()
{
Position += new Vector(0, -1);
}
}
しかし、この継承を使った方法では、移動方法だけでなく、弾の出し方にもいくつか種類を用意したいとなった時に大変です。インターフェースを利用して、多重継承のようなことをすれば、移動方法と弾の出し方を別々にすることはできますが、移動方法と弾の出し方がそれぞれn,m種類あるとすると、組み合わせによって生成されるEnemyの子クラスの数はn*m通りとなり、ゲームの規模が大きくなるにつれて、Enemyクラスの数も比例してどんどん増えていきます。もちろん、敵の種類があらかじめ決まっていて、そんなに多くないのであればこの方法でも十分ですが、ゲームデザインなんて、作りながら決めていくことも多いわけで、組み合わせを変える度にクラスを継承して、新たなクラスを作成するのは少々手間だと思います。
この問題を解決するには、要するに、移動方法と、弾の出し方等をEnemyクラスから別々に独立させて、組み合わせが簡単にできればいいわけです。ここで登場するのがストラテジーパターンになります。
ストラテジーパターンを使った方法
インターフェースを利用することで特定の機能に関する定義だけをクラスから独立させることができます。特定の機能というのは、今回の例で言えば「移動する」という機能を持ったMoveメソッドになります。IMovementというインターフェースを作成し、それを継承してGoStraightMovementクラス、GoBackMovementクラスを作成します。
そしてEnemyクラスにはIMovementの参照を持たせることで、その参照からメソッドを呼び出して利用するようにします。
と言いたいところなのですが、今回はインターフェースではなく、抽象クラスを使っています。理由など詳しくは後述します。それに伴ってクラス名もIMovementではなくMovementBaseに変更しております。
public abstract class MovementBase
{
public abstract Vector Move(Vector position);
}
public class GoStraightMovement : MovementBase
{
public GoStraightMovement()
{
}
override Vector Move(Vector position)
{
return position + new Vector(0, 1);
}
}
public class GoBackMovement : MovementBase
{
public GoBackMovement()
{
}
public override Vector Move(Vector position)
{
return position + new Vector(0, -1);
}
}
public class Enemy
{
protected Vector Position;
protected MovementBase Movement;
Enemy(Vector position, MovmentBase movement)
{
Position = position;
Movement = movement;
}
void Update()
{
Position = Movement.Move(Position);
}
}
Enemyクラスの子クラスをつくる必要がなくなり、代わりに「MovementBase」の子クラスをつくることでパターンを増やせます。この方法だと、弾の出し方や消滅の条件等、他の機能も種類を増やしたい場合でも、それぞれの機能ごとに抽象クラスをつくるだけです。その組み合わせがいくらあっても、Enemyクラスはひとつで済みます。実際にステージデザインをしようと、Enemyクラスのインスタンスを定義する際に、コンストラクタで行動パターンごとのインスタンスを与えてやるだけで、いくらでもパターンを増やすことができるのです。
この設計手法がGofデザインパターンの一つであるストラテジーパターンと呼ばれるものです。
(デザインパターンについての詳細は割愛しますが、簡単に説明すれば、クラス設計において頻出するパターンを分類し、名付けたものです。オブジェクト指向を活用する具体的な設計がまとまっていますので、知らなかった人は調べてみると面白いかもしれません。)
このようにストラテジーパターンを使うことで、利用する側のクラスはメソッド呼び出しを一行書くだけで、インスタンスごとに異なる動作をすることが可能になります。
抽象クラスとインターフェース
筆者がストラテジーパターンについて調べたところ、インターフェースを用いる例が出てきたので、初めは何も考えずインターフェースを利用した実装をしました。インターフェースを使った場合でも、Enemyクラスの設計自体は全く問題なく動作します。しかし、XmlSerializerを利用してxml形式のステージデータごと敵データを読み込もうとした際にエラーが発生しました。インターフェースは実体を持たないがために、XmlSerializerでは、インターフェースはシリアライズできなかったのです。(ちなみに「DataContractSerializer」なるものを使えばインターフェースをシリアライズすることもできるらしいです。)
そこで仕方なく抽象クラスに書き換えてみたところエラーは消えたので、とりあえず、というのが現状です。いつかちゃんとリファクタリングしたい…
一応両社の違いについて軽く言及しておくと、抽象クラスとインターフェースはおおよそ似たような使い方ができますが、インターフェースは複数を実装して多重継承のようなことができるのに対し、抽象クラスは単一の継承しかできない。また、抽象クラスには通常のメソッドとして、デフォルトの処理などを書いておけるのに対し、インターフェースでは通常のメソッドの実装はできない。という特徴があるようです。
(二つ目に紹介した方法のような使い方(組み合わせごとにクラスを作成する)ならインターフェース、三つ目の方法のような使い方をしたいときは抽象クラスを使う、という使い分けで良い…のかな?)
あとがき
今回説明するのはここまでとしますが、ストラテジーパターンを用いることで、様々な応用的な使い方ができます。
例えば、MovementBaseを継承しながら、MovementBaseの参照を二つ持つ「ChangeMovement」クラスを作成することで、特定の条件に応じて二つの移動パターンを切り替えることができますし、さらに複雑にするならリストで参照を保持しておくことも可能で、ボス敵などの複雑な行動パターンを持つ敵の柔軟な実装もできます。
さらに、少し触れましたが、移動方法や弾の出し方だけでなく、出現方法や、ゲームシステムごとの特徴的な行動にもストラテジーパターンを用いることで、より多彩な敵を生み出すこともできます。
皆さんもこれをきっかけに、デザインパターン等をうまく活用したゲーム設計をしてみてはいかがでしょうか?