unity1week「あける」でクラス設計してみた
はじめに
いつもはてなブログにて記事をあげているのですが、unityちゃんのフィギュアが貰えるプレゼントキャンペーンのために初note投稿をしてみました。
初めてnoteを使うのであまり書き方が分かってないのもありますが、良かったらお付き合いください。
作ったゲーム
今回のunity1week「あける」で作成したゲームはこちら。
正直なところまだまだ改善したい箇所がたくさんありますが、現在開発中のゲーム「クローズドサークルからの脱出」に時間を割きたかったので最低限のボリュームでのリリースとなってしまいました。
ただし個人的にはちゃんと設計->コーディングの順番で初めてできたゲームジャムだったので満足です。
今回はその設計について少し触れていきたいと思います。
クラス図
このゲームで用いたクラス設計はこんな感じ。
私自身色々と模索している段階でもっと改善の余地があると思いますが、とりあえずこの設計でゲームの完成までスムーズに行けました。(一部変更した箇所もある)
それぞれどのような思惑があったか紹介させてください。
Weapon名前空間
まず一番シンプルな箇所からみていくと、Weaponの箇所でしょう。
クラス図に度々登場しているConcrete○○は具体的な〇〇という意味でつけています。
例えばConcreteWeaponならSwordとかknife,Shotgunとかですね。
色々な武器が増えても良いようにこのような設計にしていて、ここらへんは特筆すべきことはないでしょう。
次にWeaponクラスはIDamagableインターフェイスを通してプレイヤーもしくは敵にダメージを与えます。
// Weapon内部の攻撃する箇所のイメージ
gameObject.OnCollisionEnterAsObservable()
.Where(target => target.collider.CompareTag("Enemy") || target.collider.CompareTag("Player"))
.Subscribe(target =>
{
var damagable = target.gameObject.GetComponent<IDamagable>();
if (damagable != null)
damagable.Damage(power);
})
.AddTo(this);
このクラス図ではIDamagableインターフェイスがPerson名前空間に入っていますが、Weapon名前空間でも良かったかなと後から感じました。
というのも実際にコーディングしていてPerson名前空間とWeapon名前空間で相互参照している状態になってしまったからです。
余談
IDamagableインターフェイスをWeapon名前空間でも良かったかなと書きましたが,こちらのスライドの依存性逆転の原則では上位モジュールが下位モジュールの仕様を決めた方が良いとのことで正しそうです。
ただこのスライドで紹介されているインターフェイスの置き方が常に成り立つのかは私はやや懐疑的です。
今回の場合はもし名前空間毎にアセンブリを分けたと仮定するとPerson -> Weaponという依存関係になるので自然のような気がします。
しかし以下のような場合はどうでしょうか。(本当にインターフェイスを用いるべきかの議論がありますが)
過去Kinectを使って現在のポーズを取得するライブラリを作ったことがあり,このクラス図のように作成しました。
軽く説明すると,複数人でプロジェクトを作成していてKinectを所持している人が少なかったのでKinectがなくても動作するようにポリモーフィズムを用いて対処しています。
しかし先程のスライドで言うところの使う側のモジュール(クラス)が使われる側のモジュール(クラス)の仕様を決めるとしたら以下のようなクラス図になることが予想できます。
この場合アセンブリはKinectWrapper -> Mainの参照関係になりますが,これは明らかにおかしいと思います。
まず前提として「アセンブリ(DLL)はある種のまとまった機能を提供すべき」だと思いますし,KinectWrapperが他のアセンブリに依存関係があるのは良い設計だとは私には思えません。
おそらくスライドで書かれている依存性逆転の原則において,必ずしもモジュール=クラスではなのではないかと私は考えてます。
追記. このようなアドバイスをいただきました。ありがとうございます!(2020/1/1)
Item名前空間
まず先程のWeaponのようにHP回復や移動速度向上などの色々なアイテムがあるだろうと考え(時間がなく実際にはHP回復のみでしたが)、Item,ConcreteItemを作成しています。
さらに取得できるIGettableインターフェイスを作成しています。
// Itemを取得する側のコードイメージ
gameObject.OnCollisionEnterAsObservable()
.Where(target => target.collider.CompareTag("Item"))
.Subscribe(target =>
{
var gettable = target.gameObject.GetComponent<IGettable>();
if (gettable != null)
gettable.Get();
})
.AddTo(this);
そしてこのゲームはWave制になっていまして、Waveが開始すると同時にランダムにアイテムがリスポンする仕様になっています。(時間が足りなく実装できませんでしたが)
Wave開始の合図があったときにどのアイテムをどれくらい生成するかを書いてあるScriptableObjectをみて実行するのがItemProviderの役割です。
// どのアイテムをどれくらい生成するかを書いたデータベースの例
[CreateAssetMenu()]
public class ItemSpawnDB : ScriptableObject
{
public List<WaveSpawnItems> elements;
}
[Serializable]
public class WaveSpawnItems
{
public List<SpawnItem> item;
}
[Serializable]
public class SpawnItem
{
public ItemType type;
public int count;
}
public enum ItemType
{
ItemA,
ItemB,
ItemC,
}
public class ItemProvider : MonoBehaviour
{
[SerializeField] private ItemSpawnDB _db;
private IWaveStart _waveStart;
private IEnumerator _enumerator;
private void Awake()
{
// サービスロケーター経由でインスタンスを取得(DIフレームワークを使えればそちらの方が良い)
_waveStart = ServiceLocator.Instance.Resolve<IWaveStart>();
}
private void Start()
{
// Waveの開始を購読
_waveStart.WaveStartAsObservable
.Where(_ => _enumerator.MoveNext())
.Subscribe(_ => OnWaveStart())
.AddTo(this);
_enumerator = _db.elements.GetEnumerator();
}
private void OnWaveStart()
{
// アイテム生成
var spawnItems = _enumerator.Current as List<SpawnItem>;
foreach (var item in spawnItems)
for (int i = 0; i < item.count; i++)
ItemFactory.Create(item.type);
}
}
Utils名前空間
先程コードの中にちらっとServiceLocatorが含まれていましたが,以下の記事で書いたものと全く同じものを利用しています。(追記. staticクラスではなくシングルトンに変更しています)
Director名前空間
次にWaveの管理をしているDirector名前空間をみていきましょう。
Directorクラスは一見名前から神クラスっぽく見えますが、基本的にIWaveEndインターフェイス内のWaveEndメソッドが実行されたらWaveStartのストリームを発火するだけです。
またGameInitializerはServiceLocatorにインスタンスを登録する役割がメインになります。
Person名前空間
Person名前空間は,PlayerとEnemyの関係性やキー入力との関係を記述した抽象的なクラス群です。
キー入力の箇所は以下の記事に詳細が記述されているので良かったら参照してみてください。
またConcretePerson名前空間内でPlayerクラスを細分化するために移動関連とアニメーション関連の処理を別クラスに委譲(転送)しています。※詳細は後述
ただこの箇所をPerson名前空間で記述しとけば良かったなと若干思っています。というか敵にも同じようにした方が良かった気が。
ConcretePerson名前空間
Person名前空間の具象クラス達を集めた名前空間です。
EnemyProviderとEnemyFactoryはItem名前空間で述べた処理とほぼ同じです。
加えてEnemyCounterは敵の数をカウントしていて,数が0になったら次のWaveになることをDirectorに要求します。
特筆すべきことは,一つ前のトピックで軽く触れましたがPlayerの処理を細分化したところだと思います。UserPlayerMoverとUserPlayerAnimator,IUserPlayerMediatorの箇所ですね。
前述の通りPlayerクラスを細分化するために移動関連とアニメーション関連の処理を別クラスに委譲(転送)しています。
public class UserPlayer : Player, IUserPlayerMediator
{
private UserPlayerMover _mover;
private UserPlayerAnimator _playerAnimator;
....
}
public class UserPlayerMover
{
private IUserPlayerMediator _mediator;
private Rigidbody _player;
public UserPlayerMover(IUserPlayerMediator mediator, Rigidbody player)
{
_mediator = mediator;
_player = player;
}
...
}
public class UserPlayerAnimator
{
private IUserPlayerMediator _mediator;
private Animator _animator;
public UserPlayerAnimator(IUserPlayerMediator mediator, Animator animator)
{
_mediator = mediator;
_animator = animator;
}
...
}
またMoverとAnimator同士でタイミングを合わせたいなどのサブシステム同士の密結合が予想されたので,Mediatorパターンを用いています。(結果的に定義していても用いませんでしたが……)
後ここらへんのサブシステムにMonoBehaviorを継承させる必要はないと思います。MonoBehavior自体大きなクラスですし,どうせ必要なのは一部なのでコンストラクタなりメソッドの引数なりで渡した方がいいのではないでしょうか。(この箇所に限らず不必要なMonoBehaviorの継承は削除すべし)
さいごに
想像以上の長い記事になってしまい書いている側も疲れてきてしまったのでこれくらいで終わりにしたいと思います。
ぶっちゃけ小規模かつ時間に猶予がない、なんなら開発者が一人しかいないので設計をする必要があるのかと言われるとなんとも言えないです。
ただ複数人でプロジェクトを作成する場合には時間に猶予がなくともなるべく設計をした方が良いとは思います。
また今回の設計に関して,これがベストだとは微塵も思っていないので、もしこうした方が良かったんじゃない?みたいな意見がありましたらコメント等で教えてくださると嬉しいです。
ではまた。
追記 (2020/1/4)
この記事が気に入ったらサポートをしてみませんか?