設計 - 抽象化を考えよう
ここでは、プログラムを書き始める前、設計を考える前に、まず頭を整理することを考えよう、という話をします。
設計で大事なのは、拡張性
バリエーションがたくさんあるから、バリエーション別に処理を用意しなきゃ…
というのはゲームを作りこむときによく発生することだと思います。
ただし、この時1つのクラスに処理を全部書いてしまうと大変なことになってしまいます。
このとき、抽象化、という考え方をします。
どう大変になるの?
まず第一に、「見づらく」なります。
100パターンの攻撃処理があったとして、パターン1つに対して平均80~100行程度の処理が必要だとしたら…そのソースコードは1万行を軽く超えることになるわけです。
1万行のソースコードから、目的の処理を見つけるのはかなり大変です。
ただし、過去のゲームプログラムでは、早さのためにあえて汚いコードを書くことがありました。
しかしいまは21世紀。アプリケーションレベルでそんなことをして早くなるスピードなんて、グラフィックにかけるコストに比べればミジンコみたいなものです。
次に、1クラスに処理をまとめると、いろんなことが必要になってしまい、クラスに必要な変数が肥大化していきます。「神クラス」とよばれるアンチパターンで有名です。
たとえば、敵クラスがこの1万行のクラスで記述されているとします。
ゲーム的には、敵はたくさん生成される可能性があるわけです。
1つの敵で使用する攻撃パターンなんて、たかが知れてますよね。
なので、100パターンをもつクラスを持つことは、とても「非効率」な使い方になることが懸念されます。
抽象化ってどういうもの?
人間と動物で抽象化について考えてみましょう。
地球上にはいろいろな動物がいます。
地球的に考えると、人間は、地球上に存在する動物の1種であるといえます。
ここでいう「人間」の実装を「動物」のレベルで考えることが、抽象化にあたります。
動物の実装を考えてみましょう。
動物の定義は、ここによると、
- 細胞によってできている(DNA構造を持っている)
- 栄養を取る必要がある
- 酸素を使って呼吸をする
- 運動する
のようです。
これは人間も当然満たしています。
ちなみに、魚も動物ですから、動物の定義を満たしています。
このことから、「動物」という定義は、人と魚を同じように扱うために必要な構成であると考えることができます。
メリットはあるのか?
「動物」を定義出来たから何なんだ、と思った人もいるでしょう。
抽象化をして何がうれしいか? を簡単に説明すると、管理が楽になります。
たとえば、「地球上に存在する動物をすべて消したい」という処理を地球が望んたとします。
このとき、抽象クラスがないと、それぞれの生き物に対して「お前は動物なのか?」と聞いて回る必要があり、その実装が増えます。また、実装を増やすということは、ミスをする(動物じゃないよと嘘をつく奴がいる)かもしれませんよね。
しかし、抽象化しておくことで、地球上の動物すべてを「どうぶつリスト」に入れて管理することができます。
ソースコードで示すと、
List<Animal> animals = new List<Animal>(); //どうぶつーズ
class Human : Animal
{
...
}
class Fish : Animal
{
...
}
こんな感じです。
「地球上に存在する動物をすべて消したい」場合、このリストに入っているオブジェクトを消していけば簡単に実現ができます。
ゲームでほかに多い処理といえば、「相手に1ダメージを与えたい。」みたいな処理でしょう。
この処理も、相手のクラスが1つでシンプルであれば問題はないですけど、たくさん増えると処理が大変だったり、神クラスになったりしますよね。
ゲームでは、こういった「〇〇の要素を△△する」処理を頻繁に記述します。
そういった場面で、オブジェクトを抽象化しておくことで楽に処理ができるのです。
「モデル図」から「抽象化すべきオブジェクト」を導びく
モデル図を書くとき、
- ゲーム内にどんなオブジェクトがあるんだっけ?
- ゲーム内のオブジェクト間でどういった処理をすればいいんだろう?
を考えました。
ここから、バリエーションが必要なオブジェクトや処理に目星が付くと思います。
たとえば、多くのゲームに存在する「抽象的に考えられるオブジェクト」には
- 敵や、アイテムなどのオブジェクト
- 攻撃スキル
- カードゲームのカード効果
が考えられます。
これらをまずは洗い出しましょう。
「抽象化」によって「共通化」を実装する
抽象化は、主に「継承」を使用して実装します。
(interfaceや関数ポインタ実装など他のやり方もありますが、継承はすべてのメジャーなゲーム開発環境で使用できます)
このときによく考えるやり方として、
- オブジェクトを置き換えることができるか?
- 他のオブジェクトも同じ処理をするか?
があります。
たとえば、チェスの実装を考えてみましょう。
プレイヤーと敵は似たようなコマであり、プレイヤーはユーザの操作、敵はAIルーチンによって移動します。
移動処理に関連して、移動可能な範囲、コマをとる判定などの必要な処理も考えられます。
ただし、操作する処理が違えど、関連する実装についてはほとんど共通化できるはずです。
敵とプレイヤーのコマ処理で、それぞれ別に移動先を返すルーチンを組み立てる意味はありませんね。ただし、移動先はコマの種類によって変わります。
なので、
abstract class Piece
{
public abstract List<Cell> GetMoveArea();
public void Pick();
public void Move(Cell targetCell);
}
public class Pawn : Piece
{
public override List<Cell> GetMoveArea();
}
......
//プレイヤーの操作ルーチン
if(Click(x,y))
{
Piece SelectPiece = PlayerPieces.GetPiece(x,y); //クリック座標にあるプレイヤーのコマを返す関数
if(SelectPiece != null)
{
List<Cell> moveCells = SelectPiece.GetMoveArea();
SelectCellUI.SetView(moveCells); //UIに移動可能なセルを反映
}
}
......
//敵のAIルーチン
Piece SelectPiece = EnemyPieces[i]; //動かしてみたいコマ
List<Cell> moveCells = SelectPiece.GetMoveArea();
AICore.GetScore(moveCells); //AIに移動可能なセルを渡して、コマを取れるポイントを評価してもらう
のように、実装を考えることができますね。
もちろん、これが唯一の正解ではありません。
チェスの移動パターンは限定されていますし、継承を活用すべき処理がなければ、継承せず内部でswitchを書いて返す、という実装でもよいと思います。
また、AIの処理も移動可能マスだけで考えるものではないと思います。
ただしこれが何らかのチェスを応用したゲームで、バリエーションが増えそうな場合においては、この移動エリアも変わる可能性があります。
また、継承が必要な処理も多くなると考えられます。
そういった場合にクラスを分けておくと見やすいですよね。
抽象的実装と関連キーワードの解説
interfaceはinterfaceの項で解説をやります。
継承に関連するキーワード
クラスを継承する
//ベースクラス
public class Piece
{
private int id; //これは派生先ではアクセスできない。外からもアクセスできない。
protected int life; //これは派生先でもアクセスできる。外からはアクセスできない。
public int message; //外からアクセスできる。もちろん派生先からもアクセスできる。
}
//派生クラス(継承する)
//Pawnクラスは、Pieceで定義された変数と関数をすべて引き継ぎます。
public class Pawn : Piece
{
}
継承すると、継承元の変数と引数をすべて引き継ぎます。
GameObjectにつけるスクリプトは、MonoBehaviourを継承しています。
そのため、MonoBehaviourが持っているほとんどの変数や関数にアクセスができる、という仕組みなのです。
virtual / 仮想関数
virtual void Move(Vector3 v)
{
this.transform.position += v; //とりあえず動く
}
ベースクラスで↑のように記述します。
virtualは、「この関数は、派生クラスで変えてもいいよ」という意味合いを持ちます。
派生先で特に変える必要が無ければ、そのまま動かすことができます。
なので、「とりあえずこういう実装にしておくので、変えたくなったら変えてください」という処理で使用することが多いです。
※移動させたくないオブジェクトがあったら、実装を消すとか。
呼び出し元は、関数の実装を気にすることなくMoveを呼び出すことができます。
abstract / 純粋仮想関数
abstract void Move(Vector3 v);
ベースクラスで↑のように記述します。
abstractとは、まさに抽象的という意味を指します。
これは、クラスを派生させた際に実装が絶対変わるので、ベースクラスで実装ができない処理(行動する必要がある攻撃処理など)を記述するときに指定します。
呼び出し元は、関数の実装を気にすることなくMoveを呼び出すことができます。
interface
複雑な抽象化を表現したいときに便利です。
interfaceの話題で詳しく触れます。
クラス設計図を書いてみましょう
これは、前に書いたモデル図とはやや異なり、
どのクラスが、どのクラスをもとにして拡張されるべきか、というのを書いたものです。
参考図
これの「クラス設計」シートを参考にしてください