この記事では、Unityプログラミングをしているとよく発生する問題
「別ゲームオブジェクトにスクリプトからアクセスしたい」
に対する解決策をまとめて解説していきます。
通常のUnity機能はもちろん、シングルトンやサービスロケータなどを用いた応用的かつ実践的なテクニックも扱っていきます。
解説の題材として、前回作成した汎用サウンドマネージャ機能を用いていきますね。
前回記事を読んでいない場合は参考にしてみてください。
参考記事:
特定ゲームオブジェクト(Managerオブジェクト)へのアクセス方法まとめ
別記事で作ったSoundManagerに限らずですが、ゲーム全体で何かしらの処理を一括して管理する役割を持つManagerオブジェクトは当然、使用する側が存在します。(別記事でテストで作ったSoundTestなど)。
他オブジェクトから使用する場合に、Managerオブジェクト(ゲームオブジェクト)にどのようにしてアクセスするのか、が問題になります。
色々なケースがありますが、その中でもよく使われる方法を紹介します。
Inspectorから指定
一つは今回作ったSoundTestのように
1 2 |
[SerializeField] private SoundManager soundManager; //サウンドマネージャー |
Inspectorから指定出来るようにする方法ですね。
これが出来るならこれが一番です。
しかし、これは、「最初からSceneに置いてあるオブジェクト」でないと、当然Inspectorから指定は出来ないので、「InstantiateしたPrefabについているスクリプトから音を鳴らしたい」といったケースには使えません。
FindObjectOfType
次に、FindObjectOfTypeを使う方法です。
1 2 3 4 5 6 |
private SoundManager soundManager; //サウンドマネージャー private void Start() { soundManager = FindObjectOfType<SoundManager>(); //SoundManager型で検索 } |
この方法でしたら、Startメソッド内でManagerオブジェクトを取得できるので、Instantiateなどで生成されたオブジェクトからでもManagerが使用出来るようになります。
ただし、このFindObjectOfTypeという処理はScene上にある全てのオブジェクトが持っているComponentを全て総当りして目的のものを探すイメージに近く、非常に重い処理になります。
そのため、毎フレーム呼ばれるような処理(Updateメソッド内等)で使用するのはもってのほかですし、Startメソッド内で確保する方法にしてもPrefabが一気に100や200作られるようなケースには向きません(動くとは思いますが・・・)
GameObject.Find & GetComponent
FindObjectOfType はFind系メソッドの中でも1・2を争う遅さ(負荷)なので、それを使うならGameObject.Findの名前によるオブジェクトの探索+GetComponentによるComponentの取得の2段階にした方が速いです。
1 2 3 4 5 6 |
private SoundManager soundManager; //サウンドマネージャー private void Start() { soundManager = GameObject.Find("SoundManager").GetComponent<SoundManager>(); //"SoundManager"という名前で検索したあとGetComponent } |
ただしこの方法はSoundManasgerオブジェクトの名前が「SoundManager」であることが条件となります。タイピングミスも許されないのでちょっと不安ですね。
GameObject.FindWithTag & GetComponent
GameObject.FindWithTagという、Tag(タグ)によって探索+GetComponent です。
このGameObject.FindWithTagは名前による探索(GameObject.Find)よりも速い(らしい)です
1 2 3 4 5 6 |
private SoundManager soundManager; //サウンドマネージャー private void Start() { soundManager = GameObject.FindWithTag("SoundManager")?.GetComponent<SoundManager>(); //SoundManagerタグで探索した後GetComponent } |
ただし、下準備が必要です。
まず、Tagを自分で一つ定義する必要があります(Layers→EditLayers→Tagsに追加)
ここでは「SoundManager」というTag名にしました。
こうして、定義したTagを、SoundManagerオブジェクトのTagに指定します。
こうすることで、GameObject.FindWithTagによる高速な探索が出来るようになります。
が、やはり文字列を指定して探索する仕組み上、タイピングミスは怖いですね。
ここまでは、Unityの最初からある機能を使っての解決方法でしたが、その他にも自前でスクリプトを組む色々な方法があります。
今回はその中でも特に有力な
- Singleton
- サービスロケータ
を紹介したいと思います。
Singletonパターンを使ってUnityでオブジェクトにアクセスする
Singleton(シングルトン)パターンとは、
「そのオブジェクトが一つであることを保証し、オブジェクトへグローバルにアクセス出来るようにする」
という、デザインパターンの一つです。
実のところ今までに紹介したFind系の方法もオブジェクトが一つである前提の処理でした。
これを「保証」させます。(ついでにどこからでもアクセス出来るようにします)
実装方法としてよく使われるのは、自分自身以外のマネージャー(ここではSoundManager)が既に居るなら自身をDestroyさせる、という方法です。
なお、「既にあるかどうか」を管理するためにstatic で宣言された プロパティを使い、さながら椅子取りゲームの最後の一つの椅子のように、速いもの勝ちで取り合う事になります。
最低限実装すると以下のようなコードになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
//1つであることを保証するため&グローバルアクセス用 public static SoundManager Instance { private set; get; } private void Awake() { if (Instance == null) { Instance = this; } else if (Instance != this) //自身が他にもあるようなら { Destroy(gameObject); //削除 return; } //Awake内での処理 以下略 |
Awake内で、誰も椅子に座っていないなら(Instance == null
)、自分が座り(Instance = this
)、既に自分以外の誰かが座っているなら(Instance != this
)、自分は座れない=>死(Destroy
) としています。(座れない=死とは、とんだ椅子取りゲームですね)
この、Singleton化したSoundManagerを使う場合は
1 2 3 4 5 6 |
private SoundManager soundManager; //サウンドマネージャー private void Start() { soundManager = SoundManager.Instance; //Singletonインスタンスを取得 } |
このように今までのFind系メソッド使用の例と同じくメンバ変数へと取得する事もありますが、今までのFind系メソッド等に比べると十二分に高速なため、
1 2 3 4 5 6 7 |
void Update() { if (Input.GetMouseButtonDown(0)) //左クリック { SoundManager.Instance.Play("右クリック"); //サウンドマネージャーを使用して効果音再生 } } |
このようにUpdate処理の中でもSingletonインスタンスを取得しつつ使用、という方法もよく取られます。
なお、マネージャー系(基本一つしかなく、どこからでもアクセスさせたい)=Singleton という風潮があります。
と、いうのもこのSingletonを組み込んだジェネリックなMonoBehaviourとして「SingletonMonobehaviour<T>クラス」を作っておくことで、継承するだけで非常に簡単にグローバルアクセスが出来るようになるためです。
具体的にはこのようなクラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
using UnityEngine; public class SingletonMonoBehaviour<T> : MonoBehaviour where T : SingletonMonoBehaviour<T> { //グローバルアクセス用 public static T Instance { private set; get; } protected void Awake() { if (Instance == null) { Instance = this.GetComponent<T>(); } else if (Instance != this) //自身が他にもあるようなら { Destroy(gameObject); //削除 } } } |
※大分簡略化しています。 「SingletonMonobehaviour」で検索するともっと多機能なものも出てくると思います
このような基底クラスを用意しておくと、
「よし、作った**ManagerをSingletonにしたい!」
といった場合に
今まで
1 |
public class SoundManager : Monobehaviour |
だったところを
1 |
public class SoundManager : SingletonMonoBehaviour<SoundManager> |
SingletonMonoBehaviour継承に変更し(<>の中にはSingleton化したいクラス名を入れます)
1 2 3 4 |
private void Awake() { base.Awake(); //親クラスのAwakeは呼んであげる //その他Awake処理 |
既にマネージャー内でAwakeメソッドが宣言されている場合は、親クラスのAwakeメソッドが呼ばれなくなってしまうので、明示的にbase.Awake();
を実行して親クラスのAwakeメソッドを呼んであげます。
この2ステップだけでSingleton化され、どこからでもアクセス出来るようになります。
この手っ取り早さもウケている要因の一つだと思います。
ただ、本来「一つである」ということと「どこからでもアクセス出来る」ということは別で考えるべき事のはずなのですが、一緒くたになってしまっているところや、テストがしづらい等で物議を醸しがちなのもSingletonの特徴です。(もちろん、無理の無い範囲で使用する分にはまったく問題ありません)
サービスロケータパターンでUnityでオブジェクトにアクセスする
Singletonパターンの対抗馬としてにわかにささやかれているのがサービスロケータパターンです。
Singletonパターンは、マネージャーオブジェクトそのものの形を変異させてしまう(Singletonにしてしまう)のに対し、サービスロケータは、作ったマネージャーの形はそのままに「サービス(ゲームのどこからでも必要とされるもの)」として捉え、管理する方法になります。
下準備
まず、準備として今回のSoundManagerのサウンド再生部分をサービス化するにあたり、外から使用される機能だけをインタフェースとして抽出します。
今回のSoundManagerは「Playメソッドによって再生が出来る」 という機能しかありませんので、Playメソッドをインタフェースとして宣言しておきます。
1 2 3 4 5 |
public interface ISoundService { void Play(AudioClip clip); void Play(string name); } |
そしてSoundManagerは、このISoundServiceを実装したことにします。
1 |
public class SoundManager : MonoBehaviour,ISoundService |
SoundManagerスクリプトの修正は以上になります。最終的に以下のようになりました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public interface ISoundService { void Play(AudioClip clip); void Play(string name); } public class SoundManager : MonoBehaviour,ISoundService { [System.Serializable] public class SoundData { public string name; public AudioClip audioClip; public float playedTime; //前回再生した時間 } [SerializeField] private SoundData[] soundDatas; //AudioSource(スピーカー)を同時に鳴らしたい音の数だけ用意 private AudioSource[] audioSourceList = new AudioSource[20]; //別名(name)をキーとした管理用Dictionary private Dictionary<string, SoundData> soundDictionary = new Dictionary<string, SoundData>(); //一度再生してから、次再生出来るまでの間隔(秒) [SerializeField] private float playableDistance = 0.2f; private void Awake() { //auidioSourceList配列の数だけAudioSourceを自分自身に生成して配列に格納 for (var i = 0; i < audioSourceList.Length; ++i) { audioSourceList[i] = gameObject.AddComponent<AudioSource>(); } //soundDictionaryにセット foreach (var soundData in soundDatas) { soundDictionary.Add(soundData.name, soundData); } } //未使用のAudioSourceの取得 全て使用中の場合はnullを返却 private AudioSource GetUnusedAudioSource() { for (var i = 0; i < audioSourceList.Length; ++i) { if (audioSourceList[i].isPlaying == false) return audioSourceList[i]; } return null; //未使用のAudioSourceは見つかりませんでした } //指定されたAudioClipを未使用のAudioSourceで再生 public void Play(AudioClip clip) { var audioSource = GetUnusedAudioSource(); if (audioSource == null) return; //再生できませんでした audioSource.clip = clip; audioSource.Play(); } //指定された別名で登録されたAudioClipを再生 public void Play(string name) { if (soundDictionary.TryGetValue(name, out var soundData)) //管理用Dictionary から、別名で探索 { if (Time.realtimeSinceStartup - soundData.playedTime < playableDistance) return; //まだ再生するには早い soundData.playedTime = Time.realtimeSinceStartup;//次回用に今回の再生時間の保持 Play(soundData.audioClip); //見つかったら、再生 } else { Debug.LogWarning($"その別名は登録されていません:{name}"); } } } |
サービスロケータクラス
サービスロケータも色々な書き方があるのですが、ものすごくシンプルに書くとこのようなスクリプトになります(以下で<T>の使い方がわからない方向けの参考記事:https://blog.websandbag.com/entry/2018/08/11/230001)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public static class ServiceLocator<T> where T : class { //サービスの保持・取得 public static T Instance {private set; get; } //サービスの登録 public static void Bind(T instance) { Instance = instance; } //サービスの開放 public static void UnBind() { Instance = null; } } |
サービスの保持用のプロパティ:Instance
と
サービスの登録・開放用のメソッド:Bind,UnBind
という非常にシンプルな構成です。
使用する時はSingletonパターンと同じく
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
private ISoundService soundService; //サウンドサービス private void Start() { soundService = ServiceLocator<ISoundService>.Instance; //サービスロケータからInstance取得 } void Update() { if (Input.GetMouseButtonDown(0)) //左クリック { soundService.Play("右クリック"); } } |
メンバ変数へ取得してから使っても良いですし、
1 2 3 4 5 6 7 |
void Update() { if (Input.GetMouseButtonDown(0)) //左クリック { ServiceLocator<ISoundService>.Instance.Play("右クリック"); } } |
このようにUpdate処理の中でサービスロケータからサービスを取得しつつ使用、という方法でも構いません。
Singletonパターンに似ていますね。
しかし、実はサービスロケータを使う場合はこのままでは動きません。
サービスロケータにサービスを登録する必要があります。
サービスの登録
サービスを登録するためのオブジェクトをHierarchyに作成し、サービス登録のためのスクリプトを用意します。 例ではどちらも名前は「ServiceInstaller」としました。
ServiceInstallerスクリプトでは、SoundManagerオブジェクトをInspectorで受け取れるようにしておき、Awakeメソッド内でサービスの登録、Destroy時にはサービスの開放を呼ぶようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class ServiceInstaller : MonoBehaviour { [SerializeField] private SoundManager soundManager; void Awake() { ServiceLocator<ISoundService>.Bind(soundManager); //サービスの登録 } private void OnDestroy() { ServiceLocator<ISoundService>.UnBind(); //サービスの開放 } } |
スクリプトを保存したら、忘れずにUnityEditorのInspectorでSoundManagerを指定するようにします。
これで、サービスロケータにSoundManagerが登録され、他スクリプトからサービスロケータ越しにISoundServiceを取得して音が鳴らせるようになりました。
「なんだSingletonより手間が多いじゃないか」となってしまいがちですが、このサービスの登録を自分で行う。というのがむしろ利点になります。
例えば、
「テスト実行中は、どの効果音を再生しようとしたかログを出したい。が、製品版にはログはでてほしくない」
と言った場合、このようなクラスが役立ちます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
using UnityEngine; public class LoggedWrapSoundService : ISoundService { private ISoundService soundService; public LoggedWrapSoundService(ISoundService soundService) { this.soundService = soundService; } public void Play(AudioClip clip) { Debug.Log($"Play:{clip.name}"); soundService.Play(clip); } public void Play(string name) { Debug.Log($"Play:{name}"); soundService.Play(name); } } |
SoundManagerと同じく、ISoundServiceを実装した、LoggedWrapSoundServiceクラスです。
中身は非常にシンプルで、コンストラクタで別のISoundServiceを受け取り、Playメソッド内ではDebug.Logによるログ出力をしてから、コンストラクタで受け取ったISoundServiceのPlayメソッドを呼ぶようにしています。
では、ServiceInstallerスクリプトをちょっと修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class ServiceInstaller : MonoBehaviour { [SerializeField] private SoundManager soundManager; [SerializeField] private bool isSoundLogEnable; //Soundのログ機能のON/OFF void Awake() { if (isSoundLogEnable) { ServiceLocator<ISoundService>.Bind(new LoggedWrapSoundService(soundManager)); //LoggedWrapされたサービスの登録 } else { ServiceLocator<ISoundService>.Bind(soundManager); //サービスの登録 } } private void OnDestroy() { ServiceLocator<ISoundService>.UnBind(); //サービスの開放 } } |
まず、ログ機能のON/OFFをInspectorで指定できるようにメンバ変数 isSoundLogEnable
を追加し
1 2 |
[SerializeField] private bool isSoundLogEnable; //Soundのログ機能のON/OFF |
この変数のtrue/falseで、Awakeメソッドでのサービスロケータへの登録を分岐させています。
true=ログが有効 な場合は サービスロケータへの登録は先程作った LoggedWrapSoundService にしていますね(コンストラクタにsoundManagerを渡しているので、音もちゃんと鳴ります)
また、falseの場合は、今まで通り、SoundManagerをそのまま登録しています。
ではスクリプトを保存してUnityEditorで挙動を確認してみましょう。
IsSoundLogEnableにチェックが入っていない状態では今まで通り、ログには何も出力されませんが
IsSoundLogEnableにチェックを入れて実行をすると、ログが出力されるようになっています。
このように、サービスの登録処理を分岐させることによって、デバッグ用の処理と・リリース用の処理を分けたりできますし、場合によってはログ出力だけするISoundServiceの実装クラスを作成して、
「まだサウンドマネージャは完成してないけれど、Soundを再生する処理は書きたい。」
と言った要望にも答えることが出来ます。便利ですね。
このサービスロケータは発展させていくと、DI(Dependency Injection:依存性注入)や、そのDIのフレームワークであるZenject(Extenject)の理解等にも一役買う技術です。
覚えておいて損は無いと思いますよ。
コメント