【Unity】複数オブジェクトをキレイに管理する方法
はじめに
結論としては
- 生成されるHogeインスタンスを管理するHogeManagerは、自作Init関数ないしコンストラクタに渡す。
- 例えば死ぬなどしてHogeの数を更新したくなった場合は、引数で受け取ったManager内のメソッドを呼んで、Hogeで管理している情報を管理する。
という方法で、おかしな参照をぐちゃぐちゃ増やさないようにすることが目的です。
いわゆるSOLID原則のD、「依存性逆転の法則」と数々の失敗経験をヒントに言語化した俺なりのコーディング流儀...といったところです。みんな当たり前にやってる事なのかもしれないですが、そこは、まぁ...多少は、ね。
前提
敵キャラを表現するEnemyクラスがあるとします。
これは、ランタイムで生成されるインスタンスです。
using UnityEngine;
public class Enemy : MonoBehaviour
{
public int HP = 100;
private void Update()
{
if (0 >= HP)
{
//死亡処理
}
}
}
さて、ゲームの仕様が新たに追加されたりすると、例えばEnemyの数に応じてゲームクリア...みたいな、新しい振る舞いやEnemyの数や状態に応じた制御などを新たに実装したくなりますが、
それを実装するにはこいつらを無秩序に生成しているだけだと、後々そういう実装を作るのがめんどくさくなります。いわゆるスパゲティコードはこういうところから生まれてしまいます。
なので、生成したEnemyを管理する、少なくとも彼らへの参照を持っているクラスが必要です。
そこでEnemyManagerクラスを作りましょう。(Managerというぼんやりとした言葉をすると神化しかねない、という懸念もありますが、話を単純にする為に、ここではよしとします。てかこれ管理する奴なんだし、他にいい名前思いつかんからManagerでも良いような気もするな)
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
public List<Enemy> EnemyList = new List<Enemy>();
public void Init()
{
// 適当に敵を10体生成
for(int i = 0; i < 10; i++)
{
CreateEnemy(new Vector3(Random.Range(-10, 10), Random.Range(-10, 10), 0));
}
}
public void CreateEnemy(Vector3 pos)
{
// Cubeを然るべき場所に生成
var enemy = GameObject.CreatePrimitive(PrimitiveType.Cube).AddComponent<Enemy>();
enemy.transform.SetParent(transform);
enemy.transform.position = pos;
// 生成後リストに追加
EnemyList.Add(enemy);
}
}
とまぁ、なんちゃらManagerみたいなの作る、みたいなことはなんとなく皆さん経験あることかなと思います。
問題はこいつをどう扱うかでしょう。パブリック変数のEnemyListを持っているので、外部のクラスから引っ張ってきて、例えば死亡時EnemyListからRemoveしたりすることができます。
しかしまぁ、こういうPublic変数に無邪気にアクセスして色々いじると将来的には「いつ、どこで、なにされてるかわからない変数」と化すのは間違いがありません。こうなってくると触るに触れない地雷と化してしまうでしょう(IDEの機能によってはここら辺の追跡をラクにしてくれるものがありますが)
そもそも、
パブリック変数のEnemyListを持っているので、外部のクラスから引っ張ってきて
この「外部のクラスから引っ張ってきて」ってところがかなりやっかいで、どうManagerクラスへの参照を解決するかがクソコード化への分水嶺だよなぁと思ってます。悪知恵をつけた初心者はここでStaticおじさんに変貌したり、Staticはなんか気がひけるなつってSingletonにしたりするポイントだったりします(適切な場面・適切な理由を持ってこれらを使うのは全然アリだと思ってます。「納期やばくて細かいこと気にしてられん!」あたりも適切な理由に当たると思ってます)
そこで僕が最近になって辿り着いた方法が、冒頭で挙げた
- 生成されるHogeインスタンスを管理するHogeManagerは、自作Init関数ないしコンストラクタに渡す。
- 例えば死ぬなどしてHogeの数を更新したくなった場合は、引数で受け取ったManager内のメソッドを呼んで、Hogeで管理している情報を管理する。
になります。
実装
これまでに挙げたコードを修正します。
using UnityEngine;
using UnityEngine;
public class Enemy : MonoBehaviour
{
public int HP;
private EnemyManager enemyManager;
// Init関数にManagerクラスを渡す。このインスタンスと管理クラスや経由で他のインスタンスとの関係性を構築する。
public void Init(EnemyManager _enemyManager)
{
this.enemyManager = _enemyManager;
HP = 100;
}
// このインスタンスが死んだ時に呼ばれる関数
private void dead()
{
enemyManager.Dead(this);
}
////////////////////////////////////// UnityEvents //////////////////////////////////////
private void Update()
{
if (0 >= HP)
{
dead();
}
}
}
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
[SerializeField] private List<Enemy> EnemyList = new List<Enemy>();
public void Init()
{
// 適当に敵を10体生成
for(int i = 0; i < 10; i++)
{
CreateEnemy(new Vector3(Random.Range(-10, 10), Random.Range(-10, 10), 0));
}
}
public void CreateEnemy(Vector3 pos)
{
var enemy = GameObject.CreatePrimitive(PrimitiveType.Cube).AddComponent<Enemy>();
enemy.transform.SetParent(transform);
enemy.transform.position = pos;
enemy.Init(this);
EnemyList.Add(enemy);
}
public void Dead(Enemy deadEnemy)
{
EnemyList.Remove(deadEnemy);
Destroy(deadEnemy.gameObject);
}
}
EnemyManagerを空のGameObjectにつけといて、こんな感じでInit呼んでやれば動くはずです。
using UnityEngine;
public class EntryPoint : MonoBehaviour
{
[SerializeField] private EnemyManager enemyManager;
void Start()
{
enemyManager.Init();
}
}
- あるインスタンスが生成された時点で管理用のツールを渡しとく
- 管理の方法はManagerが提供する。その外では書かない
こうすることで、だいぶ変なSingleton作りたい欲求は薄れるんじゃないかな?という気がしてます。
あと、Managerクラスが肥大化するようなら、中の処理はクラス小分けにしてあげとけばいいかなと思います(Managerクラスに具体的な処理を書くのは本当はあまり良くないとは思ってます。今回は説明しやすいように余計なクラス少なくしたかったので)
発展、というか、おまけ
「Managerを引数に渡せ」ということを書きましたが、実践の中ではこれちょっと違くて
「各種Managerの参照をまとめたManagerContainorを引数に渡せ」が正解になると思ってます。
というのも、仕様が固まっていない以上、Managerの数はほぼ間違いなくどしどし増えていくからです。
コード載せときます。
using UnityEngine;
public class Enemy : MonoBehaviour
{
public int HP;
private ManagerContainer managerContainer;
private EnemyManager enemyManager;
// Init関数にManagerクラスを渡す。このインスタンスと管理クラスや経由で他のインスタンスとの関係性を構築する。
// Update:Manager単体ではなく、各種Managerの参照をManagerContainerを渡す
public void Init(ManagerContainer _managerContainer)
{
this.managerContainer = _managerContainer;
this.enemyManager = _managerContainer.EnemyManager;
HP = 100;
}
// このインスタンスが死んだ時に呼ばれる関数
private void dead()
{
enemyManager.Dead(this);
}
////////////////////////////////////// UnityEvents //////////////////////////////////////
private void Update()
{
if (0 >= HP)
{
dead();
}
}
}
using UnityEngine;
public class ManagerContainer : MonoBehaviour
{
public EnemyManager EnemyManager;
// 例えばこんな感じで、なんちゃらManagerを追加していく
//public SoundManager SoundManager;
public void Init()
{
EnemyManager.Init(this);
}
}
例えば死亡時にSEを鳴らしたい!という段に来た時に、じゃあSoundManager作って鳴らすぞとなるわけですが、そんなノリで追加の引数が増えていくとしんどくなってゆくので、ManagerContainorにManagerの参照全部持たせて、代わりにこいつを引数で渡す、ということをすれば少しはラクになるかなと思います。
あんまよくわかってないけど、たぶんFacadeパターン的な感じですかね。
もう一つのメリットとして、引数の型と個数を固定することでInit関数を抽象クラスないしInterfaceで共通化できるというのがあるか思います。
using UnityEngine;
public abstract class ManagerBase : MonoBehaviour
{
public abstract void Init(ManagerContainer managerContainer);
}
こいつを各種Managerに継承させてInit関数をOverrideします。
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : ManagerBase
{
[SerializeField] private List<Enemy> EnemyList = new List<Enemy>();
private ManagerContainer managerContainer;
//Update:ManagerContainerを引数で受け取る
public override void Init(ManagerContainer managerContainer)
{
this.managerContainer = managerContainer;
// 適当に敵を10体生成
for(int i = 0; i < 10; i++)
{
CreateEnemy(new Vector3(Random.Range(-10, 10), Random.Range(-10, 10), 0));
}
}
public void CreateEnemy(Vector3 pos)
{
var enemy = GameObject.CreatePrimitive(PrimitiveType.Cube).AddComponent<Enemy>();
enemy.transform.SetParent(transform);
enemy.transform.position = pos;
// ManagerContainerを渡す
enemy.Init(managerContainer);
EnemyList.Add(enemy);
}
// 死亡時にListから削除、GameObjectを破棄
public void Dead(Enemy deadEnemy)
{
EnemyList.Remove(deadEnemy);
Destroy(deadEnemy.gameObject);
}
}
処理のエントリーポイント付近の上流でInit関数を呼ぶ、ということになると思いますが、それを抽象化して配列にしておけば、Managerを追加するたびにInit呼び出しを書く必要もなくなります。
うっかり忘れて変なバグ踏んで時間をロスする、なんてことは起きませんね。
using System.Collections.Generic;
using UnityEngine;
public class ManagerContainer : MonoBehaviour
{
public List<ManagerBase> Managers;
public void Init()
{
foreach (var m in Managers)
{
m.Init(this);
}
}
}
一方、めんどくさいことも書いてる途中でわかって、このように抽象クラスのListから欲しいManagerを手に入れる為に取得側でループ回す必要も出てきてしまい、かなりだるくなってしまった。
対策案としては、ManagerTypeみたいなEnum作って、それをキーにしたDictionaryや引数にしたメソッドをManagerContainer側で持っておいて、外部クラスからの読み取り用とする、とかがあるかなと思いましたが、他に何かいい方法がありますかねぇ、、
抽象クラスのリストを作ると、まとめて関数呼べるメリットがあるが、ぼんやりした中から特定のものを引っ張り出す必要がある、というのは一つ覚えておいた方がいい前提かもと気づいた。
いずれにせよ、こういうコードは呼び出される側に押し付けるべきで、少なくとも呼び出し側はただ呼び出す為だけで済むくらい可能な限りシンプルにするべきですね。なぜかというと、これを放置するとManagerごとにこれを毎回書く必要が生まれて、DRY原則を破ることにも繋がるからです。
以下のクソコードは副産物の自戒として残しておこう...
using UnityEngine;
public class Enemy : MonoBehaviour
{
public int HP;
private ManagerContainer managerContainer;
private EnemyManager enemyManager;
// Init関数にManagerクラスを渡す。このインスタンスと管理クラスや経由で他のインスタンスとの関係性を構築する。
// Update:Manager単体ではなく、各種Managerの参照をManagerContainerを渡す
public void Init(ManagerContainer _managerContainer)
{
// 抽象クラスのListから具象を取り出さなければいけないので、foreachで回す
// クソだるいので、なんか良い方法はないものか、、、
this.managerContainer = _managerContainer;
foreach (var m in _managerContainer.Managers)
{
if (m is EnemyManager)
{
this.enemyManager = (EnemyManager) m;
}
}
HP = 100;
}
// このインスタンスが死んだ時に呼ばれる関数
private void dead()
{
enemyManager.Dead(this);
}
////////////////////////////////////// UnityEvents //////////////////////////////////////
private void Update()
{
if (0 >= HP)
{
dead();
}
}
}
あとがき
普段、業務でいわゆるハイパーカジュアルゲームを作っているのですが、その性質上「ペライチのアイデアをなんとなく仕様を作りながら詰めつつ速度優先で実装する」を優先する作り方になることが多いです。
なので、なんとなくJazzyに臨機応変に...言ってしまえばテキトーな作りになってしまいがちになります。
(慎重に検討を重ねて精緻に設計して作ることのメリット以上にデメリットが多い環境なのでしゃーない)
こういうのって、小さめのアプリケーションを0から作る人はあるあるなのではないでしょうか。
その中でも、小回りを失わずに、読みやすくて追加仕様にも耐え得る作り方、というのを少しずつ知見として貯めていきたいですね。
Discussion