tomo_manaのブログ

tomo_manaのブログ

Unityで一からゲームを作る方法を紹介しています。

Unity学習#21-1 (Unity 2019.4.1f1) 独自のイベントを定義する(ExecuteEvents.Execute と IEventSystemHandler)

第21回で、これまでEventSystemで使用していた SetSelectedGameObject() ではどうしても解消し切れなかった要求のために、自作イベントを作ることにしました。Unityでは、独自のイベントを定義するためのインターフェースとして ExecuteEvents.Execute() 関数が用意されています。

ExecuteEvents.Execute() は、直接他のスクリプトの関数を呼び出すのに比べて、以下の点でメリットがあります。

<特徴>
スクリプトへの直接参照が不要(ゲームオブジェクトにメッセージを送る)スクリプト(MonoBehaviour継承クラス)への参照を持たなくても、ゲームオブジェクトへの参照だけで関数をコールできます。(ゲームオブジェクトの中にメッセージを送れる対象がいなくてもエラーになりません)
自由度が高い:この関数自体がメッセージを飛ばしてくれるのではなく、この関数はコールバックを呼ぶだけ。メッセージの型は自由に定義できます。

しかし反面、自由度が高すぎるせいで、実装する上で少し困惑する部分があります。とっつきにくく感じるのは、この関数の使い方が Unity のマニュアルだけだと分かりにくい点です。また、この関数の動作についての説明が少ないことも、とっつきにくい原因になっていると思います。

今回は、混乱しにくい実装方法と、この関数の動作イメージ、また、混乱の元になっている様々な拡張ポイントについてまとめます。


先に実装方法をまとめ、その後に動作イメージをまとめます。

実装方法

作成したいイベントと、イベントを発行するためのコールバックを定義します。

以下に、送信側と受信側のコードをまとめます。図の赤文字が形として決まっているもの、黒文字が自分で定義するもの、青文字が自分で定義した名前(黒字)に合わせる必要があるものです。

f:id:tomo_mana:20201123223649p:plain
クラス関係

以下の順に実装すると、あまり混乱せずに実装できる印象です。
(1) 送信側:基本となる形を定義(名前を先に決めてしまう)
(2) インターフェースの作成
(3) 送信側:コールバックの実装
(4) 受信側:インターフェースの実装
(5) 送信側:1で作ったものをコールする

実装手順

送信側:基本形の定義

ExecuteEvents.Execute() の基本形は少し複雑です。先に、用意すべきものを明確にする目的で、ExecuteEvents.Execute() の基本形を書き、ここでインターフェース名とコールバック名を決めてしまいます。この実装を入れた時点ではインターフェースとコールバックの実物が存在しないのでコンパイルエラーになります。

    ExecuteEvents.Execute<インターフェース名>(
        target: 受信側ゲームオブジェクト
        eventData: null
        functor: コールバック名
    );

eventData は BaseEventData型 になりますが、少し複雑なのでここでは仮にnullを入れます(BaseEventData型の活用については後述します)。
ポイントは target ですが、これはMonoBehaviourではなく、GameObjectを指定します。これもメカニズムが少し分かりづらいですが、受信側のMonoBehaiourが属するゲームオブジェクトを指定します。

インターフェースの作成

次に、先ほど定義したインターフェース名に合うように、新しいインターフェースのソースコードをProjectフォルダに作ります。飛ばしたいメッセージを合わせて定義します。
インターフェース名.cs

using UnityEngine.EventSystems;
public interface インターフェース名 : IEventSystemHandler
{
    // 飛ばしたいメッセージをここに定義
    void メッセージA();
    :
}

送信側:コールバックの実装

ExecuteEvents.Executeは、実際はfunctorに指定したコールバックを呼ぶだけの処理になります(キューイングなども一切せず、同一スタック上でコールバックを実行します)。コールバックのインターフェースは以下の形式になります。

    void コールバック名 (インターフェース名 receiver, BaseEventData eventData){
        // メッセージを飛ばす処理(自作する)
        receiver.メッセージA();
    }

ここは少し混乱する点ですが、とりあえずインターフェースを継承したオブジェクトをreceiverという変数名にしておいて、飛ばしたいメッセージをコールする処理を実装します。

C#には、コールバックに関数名と型定義を付けない記法(ラムダ式)もあるようです。

    ExecuteEvents.Execute<インターフェース名>(
        target: 受信側ゲームオブジェクト
        eventData: null
        functor: (receiver, eventData) => receiver.メッセージA();
    );

(分岐がある or 複数行にわたる場合)

    ExecuteEvents.Execute<インターフェース名>(
        target: 受信側ゲームオブジェクト
        eventData: null
        functor: (receiver, eventData) => {
                receiver.メッセージA();
                // さらに何か処理・・・
        }
    );

受信側:インターフェースの実装

ここまで実装したところで、送信側の実装は中断して、今度は受信側にインターフェースを実装します。受信側にインターフェースを実装した後で、メッセージを受信した時の処理を記述します。最初は動作を確認するために Debug.Log() だけでも良いかもしれません。

using UnityEngine.EventSystems;
public class 受信クラス名 : MonoBehaviour, インターフェース名
{
    public void メッセージA()
    {
        // 受信時の処理;
    }
}

送信側:基本形をコールする部分の実装

最後に、送信側の処理に、一通り揃った状態の ExecuteEvents.Execute() を組み込みます。

using UnityEngine.EventSystems;
public class 送信側コンポーネント名 : MonoBehaviour
{
    // 先ほど作ったコールバック
    void コールバック名 (インターフェース名 receiver, BaseEventData eventData){
        receiver.メッセージA();
    }

    // 処理に組み込む
    void xxx ()
    {
        if( xx ){
            ExecuteEvents.Execute<インターフェース名>(
                target: 受信側ゲームオブジェクト
                eventData: null
                functor: コールバック名
            );
        }
    }
}

コンパイルエラーが解消されたことを確認して、動作テストします。

動作イメージ

ExecuteEvents.Execute() に対して、メッセージとかイベントとかといった言葉を使うと、少し違和感があるかもしれません。メッセージというと、異なるシステムスタックで動作する異なるタスクまたはスレッドに対して、(特に受信側が)非同期に実行するために送信側が実行予約を行う目的で使うケースも多いためです。ExecuteEvents.Execute() は、イベントを送ると書かれていますが、していることは呼び出し元のスタック上でコールバックを実行しているだけです(ソースコードの抜粋を巻末に掲載)。Unityでは、このイベント送信を非同期に行うところまではサポートしていません。しかし、非同期なメッセージ送信に活用することはできるのではないかと思います。新規スレッドの立ち上げについてはそのうちに調べたいですが、メッセージ処理の中で、受信側のフラグを変更する、または受信キューに登録するだけの処理をして、受信側の Update() または自分のスレッド上でフラグまたは受信キューを確認して処理を行うようにすれば、非同期な処理も実現できると思います。

以下に、簡単に ExecuteEvents.Execute() の動作イメージについてまとめます。

メッセージ

一般的にメッセージの送信は、送信側から受信側に何らかの情報を送ることが目的です。

f:id:tomo_mana:20201123223719p:plain
メッセージ(イメージ)

実装

ExecuteEvents での実装では、以下のようなイメージになると思います。一般的なメッセージ送信と比べ、実装上は送信側はExecuteEvents に処理を委任するような形に見えます。実際は ExecuteEvents.Execute() が受信側のゲームオブジェクトを勝手に探してくれるなどのマッチング処理をしてくれるわけではありません。

ポイントは、送信側が受信側のスクリプトを直接参照しなくても、一般的に使用されているGameObject型と、IEventHandlerを実装したインターフェースだけでメッセージを送信できることで、これは保守性・拡張性が高い実装です。それは、複数のゲームオブジェクトがメッセージの送信先だったとして、それぞれが異なるMonoBehaviourスクリプトだったとしても、同じインターフェースとして扱えることです。イベントの送信先をコレクション的に扱えることになります。

なお、1回のコールで1つのゲームオブジェクトにしかメッセージを送ることはできません(複数のゲームオブジェクトにブロードキャストとかはしてくれません)。

f:id:tomo_mana:20201123223743p:plain
メッセージ(実装イメージ)

実際の動き

ExecuteEvents.Execute() を実際にコールした時の動きは、以下のようになります。ExecuteEvents.Execute() はゲームオブジェクトからインターフェース型を実装したコンポーネント毎に、コールバックを呼んでくれるだけのことをしています。(ポイントは、GameObject.GetComponentsで受け取るコンポーネントは1つとは限らないことです。これも後述します。)

f:id:tomo_mana:20201123223804p:plain
メッセージ(実際の動き)

ソースコードとのマッピング

ソースコードマッピングすると、以下のようになります(これは最初に示した図と同じものになります)

f:id:tomo_mana:20201125214324p:plain
クラス関係

拡張性

以下は、ExecuteEvents.Execute() 関数の拡張性についてまとめます。拡張性が高いといっても、使いやすい点と、かえって使いづらい点とがあり、後者は注意が必要です。

メッセージに変数を渡す

メッセージに変数を渡す時は、メッセージに引数を定義して、コールバック処理の中でメッセージに引数を渡す処理を実装します。

void Callback(インターフェース名 receiver, BaseEventData eventData){
  int val = 2;
  receiver.メッセージA( val );
}

コールバックに指定する引数を、関数の外から持ってくることも可能です(同一クラス内であれば)。

同一のIEventHandler型に対して複数のコールバックを使い分ける

ExecuteEvents.Execute インターフェースを単純に眺めると、同一のIEventHandler型に対して、コールバックを複数用意することで、状況に応じて動作を入れ替えられることが分かります。例えば、メッセージA、B の2つをインターフェースに登録したとして、ある時はメッセージAだけ、ある時はメッセージA と B の両方をコールしたい場合、状況に応じて2つのコールバックを呼び分ければ良いことになります。(以下の例くらいなら、コールバックの中で場合分けした方が良さそうですが・・・)

void Callback1(インターフェース名 receiver, BaseEventData eventData){
    receiver.メッセージA();
}
void Callback2(インターフェース名 receiver, BaseEventData eventData){
    receiver.メッセージA();
    receiver.メッセージB();
}
if( xx ){
    ExecuteEvents.Execute<インターフェース名>( gameObject, null, Callback1 );
} else {
    ExecuteEvents.Execute<インターフェース名>( gameObject, null, Callback2 );
}

同一のIEventHandler型を持つスクリプトを2つ以上、同じゲームオブジェクトに持たせる

用途があるかは別ですが、同一のIEventHandler型を持つスクリプトを2つ以上、同じゲームオブジェクトに持たせることもできます。この場合、同じIEventHandler型を持つスクリプトの数だけコールバックが呼ばれ、それぞれのスクリプトに順々にメッセージコールが行われることになります。

f:id:tomo_mana:20201125215801p:plain
1つのゲームオブジェクト内に同一イベントハンドラが2つ以上ある場合の動き

BaseEventData型 を使用する

前半では、BaseEventData型はnullで良いと書きましたが、このBaseEventData型は、マルチプレイの場合に、どのプレイヤーが行った操作かを通知する目的のインターフェースのように見えます。ただ、このインターフェースを使用しなくても、先述の通りIEventHandler継承インターフェースの中でメッセージの引数を自由に定義できるため、BaseEventData型をわざわざ使わなくても何とかなる気がします。
ExecuteEvents.Execute() は、このBaseEventData型の中身は気にせず、単純にコールバック関数に引き渡すだけです。つまりBaseEventData型の中身は、すべて自分で設定してあげる必要があります。

BaseEventData とは

f:id:tomo_mana:20201125223705p:plain
BaseEventDataクラスの中身はキー入力に関係するBaseInputModuleと、現在選択されているゲームオブジェクトへの参照です。送信側と受信側で、メッセージ受信時しか必要としないゲームオブジェクト(どれが選択されているか)があった場合は、ここで共有します。ただしその中身は全て、自分で設定しなければなりません。

BaseInputModule とは

f:id:tomo_mana:20201125222815p:plain
BaseInputModule関連クラス図

BaseInputModuleは、InputManagerのキー入力を管理するStandaloneInputModuleや、同じくInputSystem用のInputSystemUIInputModuleの親クラスになります。

StandaloneInputModule (InputManager)

InputManager を使用している場合は、BaseInputModule には StandaloneInputModule を入れます。StandaloneInputModule は EventSystemゲームオブジェクトのコンポーネントです。

BaseInputModule baseInputModule = gameObject.GetComponent<StandaloneInputModule>();
InputSystemUIInputModule (InputSystem)

InputSystem を使用している場合は、BaseInputModule には InputSystemUIInputModule を入れます。
InputSystemUIInputModule は EventSystemゲームオブジェクトのコンポーネントです。

BaseInputModule baseInputModule = gameObject.GetComponent<InputSystemUIInputModule>();

2つのGameObject

ExecuteEvents.Execute() のインターフェースをよく眺めると、2つのGameObjectが指定できてしまうことが分かります。

void コールバック名( インターフェース名 receiver, BaseEventData eventData );

GameObject 意味
receiver <必須>メッセージを飛ばす対象となるゲームオブジェクト。ExecuteEventsのtargetに含まれる全てのIEventHandler実装インターフェース型を満たすMonoBehaviourにメッセージを飛ばします。
eventData.selectedObject <任意>今選択されているゲームオブジェクトを入れる箱。ただの箱なので何を入れても構わない。(何度も書きますが、このゲームオブジェクトはExecuteEvents.Execute関数の外で、自分で設定します)

eventData.selectedObject は、BaseInputModule を指定する必要がある場合に、必要に応じて設定するものと思われます。

ExecuteEventsクラス のソースコード(抜粋)

最後に、ExecuteEvents のコードの抜粋を載せておきます。

ExecuteEvents

using System;
using System.Collections.Generic;
using UnityEngine.UI;

namespace UnityEngine.EventSystems
{
    public static class ExecuteEvents
    {
        private static readonly ObjectPool<List<IEventSystemHandler>> s_HandlerListPool = new ObjectPool<List<IEventSystemHandler>>(null, l => l.Clear());
        
        public static bool Execute<T>(
            GameObject target, 
            BaseEventData eventData, 
            EventFunction<T> functor
        ) where T : IEventSystemHandler
        {
            // internalHandlers: GameObject に含まれるすべての IEventHandler 実装 Component
            var internalHandlers = s_HandlerListPool.Get();
            GetEventList<T>(target, internalHandlers);

            for (var i = 0; i < internalHandlers.Count; i++)
            {
                functor( (T)internalHandlers[i], eventData);
                // 説明のためtry/catch省略
            }
            // 以降省略:送信可能なイベントが一つでもあれば true を返す
        }

        // GameObject に含まれる IEventHandler を含む全ての Component を取得(Behaviourであるかは問わない)
        private static void GetEventList<T>(GameObject go, IList<IEventSystemHandler> results) where T : IEventSystemHandler
        {
            // 中略(nullチェック)
            
            // GameObjectのすべてのComponentを取得 (ListPoolを使用)
            var components = ListPool<Component>.Get();
            go.GetComponents(components);
            
            // Component が IEventHandler か?(MonoBehaviour の場合は、Active/Enable の場合のみ Yes)
            for (var i = 0; i < components.Count; i++)
            {
                if (ShouldSendToComponent<T>(components[i])) {
                    results.Add(components[i] as IEventSystemHandler);
                }
            }
            ListPool<Component>.Release(components);
        }

        private static bool ShouldSendToComponent<T>(Component component) where T : IEventSystemHandler
        {
            // IEventHandler か?
            var valid = component is T;
            if (!valid)
                return false;
            
            // Active/Enable か?
            var behaviour = component as Behaviour;
            if (behaviour != null)
                return behaviour.isActiveAndEnabled;
            return true;
        }
    }
}

以上です。

Unity学習#21 (Unity 2019.4.1f1) リストにカーソル矢印を追加する(#13, 16, 17 不具合修正)

第16回で実装した「リストにカーソル矢印を追加する」処理について、第17回で複数リストに対応した時に、以下の不具合が出ていました。
(1) リストを表示した時に、最初の1個目が選択状態にならない(NullReferenceException)
(2) 2つ目のリストを表示した時に、1つ目のリストのカーソルが消える

この不具合の修正についてまとめます。

概要

第16回、17回の内容は以下の通りです。
tomo-mana.hatenablog.com
tomo-mana.hatenablog.com

原因と対策

出ていた不具合は以下の通りで、それぞれ以下のように修正します。

(1) リストを表示した時に、最初の1個目が選択状態にならない(NullReferenceException)
原因:初期化タイミングの問題でした。
対策:以前AwakeやOnEnableなどのコールタイミングを調べた結果を基に、初期化タイミングを見直します。

(2) 2つ目のリストを表示した時に、1つ目のリストのカーソルが消える
原因:第13課のWindowスタックと第17課のEventSystemの組み合わせで発生していました。
 a) 2つ目のリストにOnSelect() が飛ぶとき、1つ目のリストにOnDeselect() が飛びます。そのため1つ目のリストのカーソルが消えます。
 b) 2つ目のリストを非表示にする時、1つ目のリストに何のメッセージも飛びません。

これらは、2つのリストを一つの選択管理(EventSystem.SetSelectedGameObject)で管理していたために発生していました。

(キー入力でウィンドウを表示する仕組み)

f:id:tomo_mana:20201121201027p:plain
EventSystem.SetSelectedGameObject() 実行

(次のリストにフォーカスが移る時に、カーソルが消える)

f:id:tomo_mana:20201121201200p:plain
次のリストにフォーカスを移した時

(前のリストにフォーカスが移る時に、前のリストにメッセージが飛ばないのでカーソルが消えたまま)
図の赤字のメッセージが来ることを期待しているメッセージですが、当然トリガが無いのでイベントは来ません。

f:id:tomo_mana:20201121201229p:plain
前のリストにフォーカスを戻した時

対策:選択管理にEventSystemを使わず、リスト単位で選択管理をするように修正します(独自イベントの追加:ExecuteEvents の活用)。

修正の概要・手順

最初に初期化タイミングについて見直し、その後リストにイベントを追加する処理を行います。
(1) 初期化タイミング(参照エラー):描画を伴わない初期化・関連付けは Awake() で実行できます。また、リストを表示する度に初期化する状態を OnEnable() で初期化します。
(2) リストへのイベント追加:ExecuteEvents.Execute() を準備し、EventSystem.SetSelectedGameObject() とインターフェースを合わせます。

f:id:tomo_mana:20201120231004p:plain
修正の概要

先に修正後のコードを載せます。その後、それぞれの修正についてまとめます。

修正コード

Window

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ListItemWindow : Window, ItemPrefabAdapter
{
    public override void Awake()
    {
        base.Awake();
        child.SetAdapter( this );
    }
    
    // 長さを取得
    public int GetItemMax()
    {
        if(gameObject.name == "ListItem"){
            return context.itemContext.itembag.Length;
        } else
        if(gameObject.name == "QueryUse"){
            int i, c = 0;
            int s = context.itemContext.itemId;
            for(i = 0; i < 3; i++){
                
                if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){
                    c++;
                }
            }
            return c;
        } else {
            return 0;
        }
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            if(gameObject.name == "ListItem"){
                return context.itemContext.itembag[no].name;
            } else
            if(gameObject.name == "QueryUse"){
                int i, c = 0;
                int s = context.itemContext.itemId;
                for(i = 0; i < 3; i++){
                    if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){
                        if( c == no ){
                            return context.itemContext.queryUseString[i];
                        } else {
                            c++;
                        }
                    }
                }
            }
        }
        return string.Empty;
    }
    
    public void SetSelected(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            if(gameObject.name == "ListItem"){
                context.itemContext.itemId = no;
            }
        }
    }
}

ScrollView

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // ScrollRect

public class ScrollViewController : MonoBehaviour
{
    [SerializeField] string[] itemString;
    
    protected ContentManager content;
    protected MenuContext context;
    
    private bool resized = false;
    private ItemPrefabAdapter adapter;
    
    void Awake()
    {
        content = gameObject.transform.GetChild(0).GetChild(0).GetComponent<ContentManager>();
        content.SetAdapter( adapter );
    }
    
    void OnEnable()
    {
        resized = false;
    }
    
    public void SetContext(MenuContext context)
    {
        this.context = context;
    }
    
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
    }
    
    public void Update()
    {
        if( resized == false ){
            Vector2 contentSize = content.GetContentSize();
            
            if(contentSize != Vector2.zero){
                // ScrollView を Content と同じサイズにする
                RectTransform viewRect = gameObject.transform as RectTransform;
                Vector2 view = viewRect.sizeDelta;
                view.x = contentSize.x;
                view.y = contentSize.y;
                viewRect.sizeDelta = view;
                resized = true;
            }
        }
    }
    
    public void OnDisable()
    {
        resized = false;
    }
    
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        content.Navigate(value);
    }
}

ContentManager

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;            // TextMeshProUGUI
using UnityEngine.EventSystems;

public class ContentManager : MonoBehaviour
{
    // リストの子要素のプレハブ
    [SerializeField] GameObject scrollNodePrefab;
    
    // 表示用のリストの子要素 GameObject のプール
    // リストは最大8個まで表示するとする。
    private List<GameObject> itemPool;
    private int itemMax = 8;
    
    // リストに表示する内容
    private string[] itemString;
    private int max;
    private ItemPrefabAdapter adapter;
    
    // 現在選択されているリストの要素
    private int itemSelectedNo;
    private INodeSelectable selected;
    
    // リスト更新リクエスト
    private bool listUpdateRequest = false;
    
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
    }
    
    // アイテムサイズ取得
    public Vector2 GetContentSize()
    {
        if( (adapter != null) && (itemPool != null) ){
            max = adapter.GetItemMax();
            
            RectTransform rectTransform = itemPool[0].transform as RectTransform;
            
            // max がゼロの場合、必ず1つはデータを作って返すこととする
            // デフォルト文字列「この操作はできません」「できる操作がありません」など
            if( max == 0 ){
                max = 1;
                Debug.Log(gameObject + ".GetContentSize zero");
            } else
            if( max > itemMax ){
                max = itemMax;
                Debug.Log(gameObject + ".GetContentSize");
            }
            return new Vector2(
                rectTransform.rect.width,
                rectTransform.rect.height * max
            );
        } else {
            Debug.Log(gameObject + ".GetContentSize Adapter is not Initialized");
            return Vector2.zero;
        }
    }
    
    void Awake()
    {
        InstanceListPrefabs();
    }
    
    private void InstanceListPrefabs()
    {
        // リスト表示数を制限
        if( itemMax > 8 ){
            itemMax = 0;
        } else if( itemMax < 2 ){
            itemMax = 2;
        }
        
        // リストの子要素のプールを作成
        itemPool = new List<GameObject>();
        
        // プレハブのインスタンス化
        int i;
        for(i = 0; i < itemMax; i++){
            GameObject itemObj = Object.Instantiate( scrollNodePrefab ) as GameObject;
            itemPool.Add( itemObj );
            itemObj.transform.SetParent( gameObject.transform, false );
        }
    }
    
    private void UpdateListItems()
    {
        GetContentSize();
        
        int i = 0;
        for ( ; i < max; i++ ){
            TextMeshProUGUI itemText = itemPool[i].transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
            itemText.text = adapter.GetItem(i);
            itemPool[i].SetActive(true);
        }
        for ( ; i < itemMax; i++ ){
            itemPool[i].SetActive(false);
        }
        
        if ( itemSelectedNo >= max ){
            itemSelectedNo = max - 1;
        }
        SetSelectedGameObject( itemPool[itemSelectedNo] );
    }
    
    void OnEnable()
    {
        itemSelectedNo = 0;
        listUpdateRequest = true;
        selected = null;
    }
    
    void Update()
    {
        if( listUpdateRequest == true ){
            UpdateListItems();
            listUpdateRequest = false;
        }
    }
    
    void Callback( INodeSelectable receiver, BaseEventData eventData )
    {
        if( selected != null ){
            selected.OnDeselect(eventData);
        }
        selected = receiver;
        
        if( selected != null ){
            selected.OnSelect(eventData);
        }
    }
    
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        // 座標系は上がプラスだが、リストは下に行くほど大きい値になる
        if(value.y < 0){
            // 座標上は下=リストの要素番号は増える
            itemSelectedNo++;
            if( itemSelectedNo >= max ){
                itemSelectedNo = 0;
            }
        } else if(value.y > 0){
            // 座標上は上=リストの要素番号は減る
            itemSelectedNo--;
            if( itemSelectedNo < 0 ){
                itemSelectedNo = max - 1;
            }
        }
        adapter.SetSelected( itemSelectedNo );
        
        SetSelectedGameObject( itemPool[itemSelectedNo] );
        // 上記関数内で OnDeselect(), OnSelect() がコールされます
    }
    
    void SetSelectedGameObject( GameObject go ){
        ExecuteEvents.Execute<INodeSelectable>(
            target: itemPool[itemSelectedNo],
            eventData: null,
            functor: Callback
        );
    }
    
}

ChangeSprite

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // Image
using UnityEngine.EventSystems; // INodeSelectable(IEventSystemHandler), BaseEventData

public class ChangeSprite : MonoBehaviour, INodeSelectable
{
    [SerializeField] Sprite sourceImageSelected;
    private Image image;
    private Sprite sourceImageDefault;
    private bool selected;
    private bool started = false;
    private bool keepSelectedOnce = false;
    
    void Awake()
    {
        image = gameObject.GetComponent<Image>();
        sourceImageDefault = image.sprite;
    }
    
    void OnEnable()
    {
        selected = false;
        keepSelectedOnce = false;
        image.sprite = sourceImageDefault;
    }
    
    public void OnSelect(BaseEventData eventData)
    {
        if( sourceImageSelected ){
            selected = true;
            image.sprite = sourceImageSelected;
        }
    }
    
    public void OnDeselect(BaseEventData eventData)
    {
        if( selected ){
            image.sprite = sourceImageDefault;
            selected = false;
        }
    }
}

参照エラーの修正

以下に示す表の赤字の処理を、Start() から Awake()、OnEnable() に移動しました。

f:id:tomo_mana:20201120232421p:plain

transform.getChild() は Awake() の時点で使用できるため、親ゲームオブジェクトから子ゲームオブジェクトに参照渡しする関数はAwake() に移動できました。また、リストを表示する度に初期化する、たとえばリストの大きさを更新するためのフラグ(ここではresizedなど)はOnEnable() か Update() しかないのですが、OnEnable() の方が管理しやすいため、OnEnable() に移しました。

新しいリストを表示すると古いリスト側のカーソルが消える不具合の修正

ゲーム画面上のすべてのオブジェクトを一元管理する EventSystem の代わりに、ExecuteEvents を使用することでもう少しスコープを狭めた管理ができることに気が付きました。今回の場合はWindow単位で(もっというとContent→リストの子要素プレハブの間)で限定的にイベントを発行することで、リストが複数表示されている状態でもカーソルが消えなくなります。

(修正後のイメージ)

f:id:tomo_mana:20201122120226p:plain
修正後イメージ(シーケンス図)

ExecuteEventsの使い方は、Unityのマニュアルではいまいちわかりにくかったのですが、すでに試している方がいましたので、参考にさせていただきました。

qiita.com

(以下のページにもまとめてあります)
tomo-mana.hatenablog.com

以下の順に実装します。

送信側:
1) ExecuteEvents.Execute を実装する。
2) インターフェースを作る。
3) コールバックを作る。
受信側:
4) インターフェースをイベント受信側に実装する。

ExecuteEvents.Execute を実装する

 イベントを受信する側が実装すべき関数群を定めたインターフェース型名と、その関数群を呼ぶ処理をまとめたfunctorコールバック名をここで先に決めます。この時点ではインターフェース型とコールバックの実体をまだ定義していないのでコンパイルエラーになりますが、先に定義ラベルを決めておく方がこの後の作業がぶれなくなります。

ExecuteEvents.Execute<INodeSelectable>(
    target: itemPool[itemSelectedNo],
    eventData: null,
    functor: Callback
);

インターフェースを定義する(IEventSystemHandler 継承)

次に、先ほど定義したインターフェースを作ります。通常のソースコードと同じ要領でProjectウィンドウ上にインターフェース名のファイル(.cs)を作成して、その中を以下のように修正します。このインターフェースは、IEventSystemHandlerを継承している必要があります。ここでは EventSystem の OnSelect() と OnDeselect() を置き替えるために定義したインターフェース INodeSelectable.cs を作成しました。

INodeSelectable.cs

using UnityEngine.EventSystems;

public interface INodeSelectable : IEventSystemHandler
{
	void OnSelect(BaseEventData eventData);
	void OnDeselect(BaseEventData eventData);
}

functor を定義する

次に、functor に入れるコールバックを定義します。このコールバックの第一引数には、ExecuteEvents.Execute<>(target, eventData, functor) 第一引数であるreceiver は、target (GameObject型) を インターフェース型にキャストしたものが渡されてきます。この receiver に対して、先ほどのインターフェース型で定義した関数を呼び出します。コールバックの第二引数には、ExecuteEvents.Execute() の第二引数がそのまま渡されます。今回は、このコールバックの中で、OnDeselect() と OnSelect() をコールします。

INodeSelectable selected;    // 現在選択されているリストの子要素

void Callback( INodeSelectable receiver, BaseEventData eventData )
{
    if( selected != null ){
        selected.OnDeselect(eventData);
    }
    selected = receiver;
    
    if( selected != null ){
        selected.OnSelect(eventData);
    }
}

EventSystem → ExecuteEvents.Execute に置き換える

EventSystem.SetSelectedGameObject() のインターフェースに合わせるため、ExecuteEvents.Execute のラッパー関数として SetSelectedGameObject() を定義します。そして、EventSystem.SetSelectedGameObject() を、先ほど定義した内部変数 SetSelectedGameObject() に置き換えます。

public class ContentManager : MonoBehaviour
{
    // リストの子要素への選択・非選択通知
//  private EventSystem eventSystem;
    
    void SetSelectedGameObject( GameObject go ){
        ExecuteEvents.Execute<INodeSelectable>(
            target: go,   // itemPool[itemSelectedNo],
            eventData: null,
            functor: Callback
        );
    }
    
    public void Navigate(Vector2 value)
    {
//      eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
        SetSelectedGameObject( itemPool[itemSelectedNo] );
    }
}

IEventSystemHandler継承インターフェースを受信側に実装する

リストの子要素側には、ISelectHandler, IDeselectHandler の代わりに、新しく定義した INodeSelectable を実装させます。今回はISelectHandler, IDeselectHandlerと同じインターフェースで、呼ばれるタイミングも同じのため、実装部だけ修正したら完了です。

ChangeSprite

//public class ChangeSprite : MonoBehaviour, ISelectHandler, IDeselectHandler
public class ChangeSprite : MonoBehaviour, INodeSelectable

(備忘)失敗例

最初、既存のEventSystemを使って実装しようとしましたが、以下の壁にぶつかりました。備忘のため以下も残します。

(1) 次のリストにフォーカスが移る時に、カーソルが消える:

f:id:tomo_mana:20201121201200p:plain
次のリストにフォーカスを移した時

リストの子要素(Node)に ISubmitHandler も実装させて、OnSubmit() が来たら、OnDeselect() を1回だけ無視する → OnSubmit() が OnDeselect() よりも後に来ることがある(解決策見つからない)。

(2) 前のリストにフォーカスが移る時に、前のリストにメッセージが飛ばないのでカーソルが消えたまま:

f:id:tomo_mana:20201121201229p:plain
前のリストにフォーカスを戻した時

リストへのフォーカスを設定/解除する関数 OnFocus()/OnLostFocus() を Window に持たせる → これ自体はうまくいきましたが、(1) が解消できずに断念しました。なお、OnFocus()/OnLostFocus() を実装する場合、引数に reason を入れる必要があります。以下の例では、自分が一番上なら true、そうでなければfalse が来るようにしています。少し分かりづらいです。

//  @param reason
//  = true:アクティブ(OnEnable)になった
//  = false:自分のスタックより上に表示されていたWindowが非アクティブ(OnDisable)になった
void OnFocus( bool reason );

// @param reason
//  = true:自分が非アクティブになる(OnDisable)
//  = false:自分のスタックより上にWindowが表示された
void OnLostFocus( bool reason );

以上です。

Unity学習#20 (Unity 2019.4.1f1) リストからアイテムを使えるようにする#2 ~アイテムによって表示する選択肢を変える~

今回は、第13回から作っているアイテムリストに、アイテムによって表示される選択肢を変える方法に挑戦しました。この修正の一環で、リストをUpdate() 中の任意のタイミングで更新するように修正します。またこの時、リストの要素数も変更できるようにしました。

要約

前回は、リストの中身をScrollViewよりも上位のゲームオブジェクトから渡すように修正しました。アイテムはセーブデータに含まれるため、リストをセーブデータから持ってこられるようにすることを想定しました。その最初の段階として、アイテムをContext(ゲームの状態を把握)に、リストの要素をstring配列からAdapterパターン(リストのデータ型をインターフェースに変える)に置き換えました。

今回は、アイテムに応じて、使う、装備する、捨てる、の表示/非表示を切り替えます。リストの要素を増減するために、ゲームオブジェクトの表示・非表示GameObject.SetActive()を使います。(最初はTransform.SetParent()/DetachChildren()を使う予定でしたが、うまくいきませんでした。)ScrollViewにぶら下がるContentは、更新要求を受けたらUpdate()の中でリストの要素数に応じて、ScrollViewにサイズ変更を伝えます。重要なのは、特定のアイテムや選択肢を入れたゲームオブジェクトを非表示にするのではなく、ゲームオブジェクトは表示数するための箱として扱い、表示するアイテムの順番は別の場所で管理することです。

修正の順序

f:id:tomo_mana:20201101223255p:plain
修正の順序

修正コード

先に、一連の修正を入れた後のコードを以下にまとめます。かなり機能が追加されて、コードも長くなってきました。

Item(新規)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public enum ItemUsage
{
    USE = 0,
    EQUIP,
    DISPOSE,
};

public class Item
{
    // アイテム名
    public string name;
    // 許可された操作(フラグ)
    public int usage;
    
    public Item( string name, int usage )
    {
        this.name = name;
        this.usage = usage;
    }
}

Context

public class ItemContext
{
    // ListItem(アイテム一覧)
    public Item[] itembag;
    
    // QueryUse(アイテムの用途一覧)
    public string[] queryUseString = {
        "Use",    // 使うテスト
        "Equip",    // 装備テスト
        "Throw Away"    // 捨てるテスト
    };
    // 選択された番号
    public int itemId;//  // アイテムNo
    
    // アイテムフラグ
    public const int NOTUSE = 0;
    public const int USABLE = 1 << (int)ItemUsage.USE;
    public const int EQUIPABLE = 1 << (int)ItemUsage.EQUIP;
    public const int DISPOSABLE = 1 << (int)ItemUsage.DISPOSE;
    
    // コンストラクタ
    public ItemContext()
    {
        // クラス配列はコンストラクタで初期化する
        itembag = new Item[]
        {
            new Item( "Wooden Stick", DISPOSABLE | EQUIPABLE ),  // 装備テスト
            new Item( "Bread", DISPOSABLE | USABLE ),  // 回復テスト
            new Item( "Seed of Power", DISPOSABLE | USABLE ),  // 付与テスト
            new Item( "Key of Last Dungeon", NOTUSE ),  // 捨てられない・アイテム欄で使えない・装備できないテスト
        };
    }
}

Adapterインタフェース

public interface ItemPrefabAdapter
{
    // 長さを取得
    int GetItemMax();
    // 要素を取得
    string GetItem(int no);
    // 選択されたIDを渡す
    void SetSelected(int no);
}

Window

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ListItemWindow : Window, ItemPrefabAdapter
{
    void Start()
    {
        child.SetAdapter( this );
    }
    
    // 長さを取得
    public int GetItemMax()
    {
        if(gameObject.name == "ListItem"){
            return context.itemContext.itembag.Length;
        } else
        if(gameObject.name == "QueryUse"){
            int i, c = 0;
            int s = context.itemContext.itemId;
            for(i = 0; i < 3; i++){
                if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){
                    c++;
                }
            }
            return c;
        } else {
            return 0;
        }
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            Debug.Log("gameObject.name = " + gameObject.name);
            if(gameObject.name == "ListItem"){
                return context.itemContext.itembag[no].name;
            } else
            if(gameObject.name == "QueryUse"){
                int i, c = 0;
                int s = context.itemContext.itemId;
                for(i = 0; i < 3; i++){
                    if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){
                        if( c == no ){
                            return context.itemContext.queryUseString[i];
                        } else {
                            c++;
                        }
                    }
                }
                return string.Empty;
            } else {
                return string.Empty;
            }
        } else {
            return string.Empty;
        }
    }
    
    public void SetSelected(int no)
    {
        Debug.Log(gameObject + " SetSelected " + no + " is Selected");
        if( 0 <= no && no < GetItemMax() ){
            Debug.Log(no + " is Selected");
            if(gameObject.name == "ListItem"){
                context.itemContext.itemId = no;
            } else
            if(gameObject.name == "QueryUse"){
                
            } else {
                
            }
        }
    }
}

ScrollView

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // ScrollRect

public class ScrollViewController : MonoBehaviour
{
    [SerializeField] string[] itemString;
    protected ContentManager content;
    
    protected MenuContext context;
    
    private bool resized;
    private ItemPrefabAdapter adapter;
    
    // Start is called before the first frame update
    void Awake()
    {
        content = gameObject.transform.GetChild(0).GetChild(0).GetComponent<ContentManager>();
        resized = false;
    }
    
    public void SetContext(MenuContext context)
    {
        this.context = context;
    }
    
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
    }
    
    public void Start()
    {
        content.SetAdapter( adapter );
    }
    
    public void Update()
    {
        if( resized == false ){
            Vector2 contentSize = content.GetContentSize();
            
            if(contentSize != Vector2.zero){
                // ScrollView を Content と同じサイズにする
                RectTransform viewRect = gameObject.transform as RectTransform;
                Vector2 view = viewRect.sizeDelta;
                view.x = contentSize.x;
                view.y = contentSize.y;
                viewRect.sizeDelta = view;
                resized = true;
            }
        }
    }
    
    public void OnDisable()
    {
        resized = false;
    }
    
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        content.Navigate(value);
    }
}

Content

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;            // TextMeshProUGUI
using UnityEngine.EventSystems;

public class ContentManager : MonoBehaviour
{
    [SerializeField] GameObject scrollNodePrefab;
    
    // 表示用のリストの子要素 GameObject のプール
    // リストは最大8個まで表示するとする。
    private List<GameObject> itemPool;
    private int itemMax = 8;
    
    // リストに表示する内容
    private string[] itemString;
    private int max;
    private ItemPrefabAdapter adapter;
    
    // 現在選択されているリストの要素番号
    private int itemSelectedNo;
    
    // リストの子要素への選択・非選択通知
    private EventSystem eventSystem;
    
    // 順番を入れ替えても動くかどうかのテスト
    private bool listUpdateRequest = false;
    
    private void UpdateListItems()
    {
        GetContentSize();
        
        int i = 0;
        for ( ; i < max; i++ ){
            TextMeshProUGUI itemText = itemPool[i].transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
            itemText.text = adapter.GetItem(i);

            // オブジェクトをActiveにする
            itemPool[i].SetActive(true);
        }
        for ( ; i < itemMax; i++ ){
            // オブジェクトを非Activeにする
            itemPool[i].SetActive(false);
        }
        
        itemSelectedNo = 0;
        eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
    }

    private void InstanceListPrefabs()
    {
        // リスト表示数を制限
        if( itemMax > 8 ){
            itemMax = 0;
        } else if( itemMax < 2 ){
            itemMax = 2;
        }
        
        // リストの子要素のプールを作成
        itemPool = new List<GameObject>();
        
        // プレハブのインスタンス化
        int i;
        for(i = 0; i < itemMax; i++){
            GameObject itemObj = Object.Instantiate( scrollNodePrefab ) as GameObject;
            itemPool.Add( itemObj );
            itemObj.transform.SetParent( gameObject.transform, false );
        }
    }
    
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
    }
    
    // アイテムサイズ取得
    public Vector2 GetContentSize()
    {
        // Adapter が設定済であればアイテム数は分かる
        if( (adapter != null) && (itemPool != null) ){
            max = adapter.GetItemMax();
            
            RectTransform rectTransform = itemPool[0].transform as RectTransform;
            
            // max がゼロの場合、必ず1つはデータを作って返すこととする
            // デフォルト文字列「この操作はできません」「できる操作がありません」など
            if( max == 0 ){
                max = 1;
                Debug.Log(gameObject + ".GetContentSize zero");
            } else
            if( max > itemMax ){
                max = itemMax;
            }
            return new Vector2(
                rectTransform.rect.width,
                rectTransform.rect.height * max
            );
        } else {
            // Adapter が未設定であれば、未初期化であることを通知する
            return Vector2.zero;
        }
    }
    
    // Start is called before the first frame update
    void Start()
    {
        InstanceListPrefabs();
        
        // イベントシステムへの参照を初期化
        eventSystem = EventSystem.current;
    }
    
    void Update()
    {
        if( listUpdateRequest == true ){
            UpdateListItems();
            listUpdateRequest = false;
        }
    }
    
    void OnEnable()
    {
        listUpdateRequest = true;
    }
    
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        Debug.Log("Navigate" + value);
        
        // 座標系は上がプラスだが、リストは下に行くほど大きい値になる
        if(value.y < 0){
            // 座標上は下=リストの要素番号は増える
            itemSelectedNo++;
            if( itemSelectedNo >= max ){
                itemSelectedNo = 0;
            }
        } else if(value.y > 0){
            // 座標上は上=リストの要素番号は減る
            itemSelectedNo--;
            if( itemSelectedNo < 0 ){
                itemSelectedNo = max - 1;
            }
        }
        adapter.SetSelected( itemSelectedNo );
        Debug.Log(itemSelectedNo + " is Selected");
        
        eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
        // 上記関数内で OnDeselect(), OnSelect() がコールされます
    }
}

以下、各修正の詳細をまとめます。

Adapter に選択番号を通知する

SetSelected() の定義

(1) Adapter に リストの選択番号を通知するためのインターフェースSetSelected() を追加します。
Adapter

public interface ItemPrefabAdapter
{
    // 選択されたIDを渡す
    void SetSelected(int no);
}

SetSelected() の実装

(2) Adapterを実装するクラス(Window) に SetSelected() を実装します。
Window

public class ListItemWindow : Window, ItemPrefabAdapter
{
    public void SetSelected(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            if(gameObject.name == "ListItem"){
                context.itemContext.itemId = no;
            }
        }
    }
}

SetSelected() に選択番号を渡す

(3) Content から SetSelected() を呼び出します。
Content

public class ContentManager : MonoBehaviour
{
    public void Navigate(Vector2 value)
    {
        // リストの選択番号を変える処理
        if(value.y < 0){
            itemSelectedNo++;
            if( itemSelectedNo >= max ){
                itemSelectedNo = 0;
            }
        } else if(value.y > 0){
            itemSelectedNo--;
            if( itemSelectedNo < 0 ){
                itemSelectedNo = max - 1;
            }
        }
        adapter.SetSelected( itemSelectedNo );    // ← 追加
        Debug.Log(itemSelectedNo + " is Selected");
        
        eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
        // OnDeselect(), OnSelect()
    }
}

(4) ScrollView に Adapter を付けたままになっていましたので、ScrollView 側は Adapter を外しました。
ScrollView

public class ScrollViewController : MonoBehaviour // , ItemPrefabAdapter
{
    GetItemMax(){    // ← 削除
        :
    }
    GetItem(){    // ← 削除
        :
    }
}

ListItem(アイテム一覧):アイテムごとの操作を追加(string配列 → クラス配列)

これまで文字列だけで扱っていたアイテムの一覧に、「アイテムへの操作」を追加して、構造化します。

Itemクラスの作成

(1) アイテムへの操作を列挙体(enum)で定義し、アイテム構造(Itemクラス)の中に文字(string)と用途フラグ(int型)を追加します。この変数はboolでもいいのですが、今回はビットフラグにします。

Item

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public enum ItemUsage
{
    USE = 0,   // bit 0
    EQUIP,      // bit 1
    DISPOSE,  // bit 2
};

public class Item
{
    // アイテム名
    public string name;
    // 許可された操作(フラグ)
    public int usage;
    
    public Item( string name, int usage )
    {
        this.name = name;
        this.usage = usage;
    }
}

string配列をItemクラスに置き換え

(2) ItemContext側も、文字列stringをItemに置き換えます。
構造の初期化は、string配列のような=記述でなく、コンストラクタを使用する必要があります(これはオペランドを使えば改善できるかもですが、今はこのままにします)。また、ビットフラグは、先ほどアイテムで定義した列挙体を各フラグビットに割り当てます。列挙体をフラグに変換するにはint型にキャストした上でシフト演算子を使います。C/C++では列挙体をそのまま数値として使用できたので、最初このC#の厳密さに気がつくのに少し時間がかかりました。

ItemContext

public class ItemContext
{
    // ListItem(アイテム一覧)→ この後コメント化されます
    public string[] listItemString = {
        "Wooden Stick",  // 装備テスト
        "Bread",   // 回復テスト
        "Seed of Power",  // 付与テスト
        "Key of Last Dungeon"    // 捨てられない・アイテム欄で使えない・装備できないテスト
    };
    public Item[] itembag;
    
    // QueryUse(アイテムの用途一覧)
    public string[] queryUseString = {
        "Use",    // 使うテスト
        "Equip",    // 装備テスト
        "Throw Away"    // 捨てるテスト
    };
    // 選択された番号
    public int itemId;//  // アイテムNo
    
    // アイテムフラグ
    public const int NOTUSE = 0;
    public const int USABLE = 1 << (int)ItemUsage.USE;
    public const int EQUIPABLE = 1 << (int)ItemUsage.EQUIP;
    public const int DISPOSABLE = 1 << (int)ItemUsage.DISPOSE;
    
    // コンストラクタ
    public ItemContext()
    {
        // クラス配列はコンストラクタで初期化する
        itembag = new Item[]
        {
            new Item( "Wooden Stick", DISPOSABLE | EQUIPABLE ),  // 装備テスト
            new Item( "Bread", DISPOSABLE | USABLE ),  // 回復テスト
            new Item( "Seed of Power", DISPOSABLE | USABLE ),  // 付与テスト
            new Item( "Key of Last Dungeon", NOTUSE )  // 捨てられない・アイテム欄で使えない・装備できないテスト
        };
    }
}

Adapter.GetItem()、GetItemMax() の戻り値をItemから生成

(3) Adapterを実装するクラスは、リスト数と内容を、string でなく Item から返すようにします。
ListItemWindow

public class ListItemWindow : Window, ItemPrefabAdapter
{
    // 長さを取得
    public int GetItemMax()
    {
        if(gameObject.name == "ListItem"){
//            return context.itemContext.listItemString.Length;
            return context.itemContext.itembag.Length;
        } else {
            return 0;
        }
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            if(gameObject.name == "ListItem"){
//                return context.itemContext.listItemString[no];
                return context.itemContext.itembag[no].name;
            }
        } else {
            return string.Empty;
        }
    }
}

ここまでで表示を確認します。修正前と同じ見た目であれば正しく動作しています。

QueryUse(アイテム選択肢)の実装

次に、アイテムに応じて選択肢を表示して、その選択肢の数でリストの大きさを変える処理を実装します。

Adapter.GetItemMax()、GetItem()の実装

(1) GetItemMax() を、選択されたアイテムのビットがいくつ立っているかで値を返すようにします。
(2) 次に、GetItem()を、フラグが立っている場所を下から数えて、ヒットした場所の内容を返すように実装します。
このタイミングでは、デバッグを簡単にするために、常に一番目のアイテムの操作を返すように実装しておき、正しく表示されるかを確認します。

Adapter.SetSelected() で渡された値(選択番号)を参照する

(3) 最後に、リストを作るために使用するアイテム番号を、Adapter.SetSelected() で渡された値(ItemContext.ItemId)に置き換えます。

(1)~(3) をまとめると以下のような修正になります。

ListItemWindow

public class ListItemWindow : Window, ItemPrefabAdapter
{
    // 長さを取得
    public int GetItemMax()
    {
        Debug.Log("gameObject.name = " + gameObject.name);
        if(gameObject.name == "ListItem"){
            return context.itemContext.itembag.Length;
        } else
        if(gameObject.name == "QueryUse"){
            // (1)追加ここから
            int i, c = 0;
            int s = context.itemContext.itemId;    // ← (3)追加
            for(i = 0; i < 3; i++){
                if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){    // ← (3)修正
                    c++;
                }
            }
            // (1)追加ここまで
        }
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            if(gameObject.name == "ListItem"){
                return context.itemContext.itembag[no].name;
            } else
            if(gameObject.name == "QueryUse"){
                // (2) 追加ここから
                int i, c = 0;
                int s = context.itemContext.itemId;    // ← (3) 追加
                for(i = 0; i < 3; i++){
                    if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){    // ← (3) 修正
                        if( c == no ){
                            return context.itemContext.queryUseString[i];
                        } else {
                            c++;
                        }
                    }
                }
                // (2) 追加ここまで
                return string.Empty;
            } else {
                return string.Empty;
            }
        } else {
            return string.Empty;
        }
    }
}

ここで表示テストをします。

この修正によって、最初の1回目に表示するタイミングでリストの大きさが決まるようになります。ただし、これだけでは2回目からのリスト表示には効果がありません。続けて、リストの更新タイミングを1回(SetAdapter)だけでなく表示の都度(Update)に変えます。そのためContentを修正しますが、これはタイミングに関わる修正のため、慎重に作業が必要でした。(何度もシーケンス図を描きました)

リスト更新タイミングの変更 SetAdapter() → Update()

処理の抽出と分割

(1) 処理タイミングを変えるために、まず処理の内容を分割します。

Start()からプレハブのインスタンス化処理をInstanceListItems()に、SetAdapter()からリストの中身を作る処理をUpdateListItems()に、それぞれ分離します。安全に分離するために、以下の5段階でそれぞれコンパイルを通しました。
1)分割する処理を固める
2)分離する関数を定義する
3)分離する処理を新しい関数にコピーする
4)分離共関数の処理を新しいインターフェースに置き換えて、移行した処理をまとめてコメントアウトする
5)コメントアウトした処理を削除する

プレハブの初期化処理 InstanceListPrefabs() に分離

Content Start() → InstanceListPrefabs()を分離

void Start()
{
    if( initf == true ){
        return;
    }
    // プレハブをゲームオブジェクト化する処理を分離
    InstanceListPrefabs();
    
    // イベントシステムへの参照を初期化
    eventSystem = EventSystem.current;
    
    initf = true;
}

// プレハブをゲームオブジェクト化
private void InstanceListPrefabs()
{
    // リスト表示数を制限
    if( itemMax > 8 ){
        itemMax = 0;
    } else if( itemMax < 2 ){
        itemMax = 2;
    }
    
    // リストの子要素のプールを作成
    itemPool = new List<GameObject>();
    
    // プレハブのインスタンス化
    int i;
    for(i = 0; i < itemMax; i++){
        GameObject itemObj = Object.Instantiate( scrollNodePrefab ) as GameObject;
        itemPool.Add( itemObj );
    }
}
リスト更新処理 UpdateListItems() に分離

Content SetAdapter() → UpdateListItems()を分離

public void SetAdapter(ItemPrefabAdapter adapter)
{
    this.adapter = adapter;
    
    if( initf == false ){
        Start();
    }
    // リストを更新する処理を分離
    UpdateListItems();
}

// リストを更新する処理
private void UpdateListItems()
{
    max = adapter.GetItemMax();
    if( max > itemMax ){
        max = itemMax;
    }
    
    for ( int i = 0; i < max; i++ ){
        // 子オブジェクトの取得には、GameObject から Transform 経由で 子Transform を取得し、そのGameObject を取得
        // オブジェクトのコンポーネントを取得するには、GameObject.GetComponent<T>(); を使う
        TextMeshProUGUI itemText = itemPool[i].transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
        
        // TextMeshPro も内部のテキスト要素は .text でアクセスする
        // string は = で代入できる
        itemText.text = adapter.GetItem(i);
        
        // オブジェクトを親に登録するためにも、Transform を使用する
        itemPool[i].transform.SetParent( gameObject.transform, false );
    }
    
    itemSelectedNo = 0;
    eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
}

分離後にコードを眺めると、初期化でSizeMaxが決まり、UpdateListItems() するたびにリストサイズ max が更新されるように実装していたことに気づきました(自分で作っておきながら再発見)。リストの大きさは Adapter.GetItemMax() で取得しているため、Adapter側の修正が気になると思います。しかし、Adapter側はすでにItemContextの情報を返すだけになっているので、正しく数字を返してくれると予想して次に進みます。

タイミングを考慮したGetContentSize() の修正

(3) ゲームオブジェクトのサイズが決まるタイミングを理解した上で、できるだけ任意のタイミングに呼ばれても良いようにGetContentSize() を修正します。Contentの大きさを決定するプレハブは、Awake()した時点からサイズが取得できるので、GetContentSize() は、比較的起動の初期段階から要素数さえ分かれば正確な大きさを返答出来ます。要素数は Adapter() がセットされていれば取得できます。

Content

public Vector2 GetContentSize()
{
    // Adapter が設定済であればアイテム数は分かる
    if( adapter != null ){
        max = adapter.GetItemMax();
        
        RectTransform rectTransform = itemPool[0].transform as RectTransform;
        
        // max がゼロの場合、必ず1つはデータを作って返すこととする
        // デフォルト文字列「この操作はできません」「できる操作がありません」など
        if( max == 0 ){
            max = 1;
            Debug.Log(gameObject + ".GetContentSize zero");
        } else
        if( max > itemMax ){
            max = itemMax;
            Debug.Log(gameObject + ".GetContentSize");
        }
        return new Vector2(
            rectTransform.rect.width,
            rectTransform.rect.height * max
        );
    } else {
        // Adapter が未設定であれば、未初期化であることを通知する
        Debug.Log(gameObject + ".GetContentSize Adapter is not Initialized");
        return Vector2.zero;
    }
}

Update() にリスト更新処理を移動

(4) SetAdapter() で行っていた処理 UpdateListItems() を Update() で処理するようにします。リスト更新依頼 listUpdateRequest を追加して、このリクエストを受けた時にリストを更新するようにします。このlistUpdateRequest は OnEnable() の度に設定されるようにします。

Content

private bool listUpdateRequest = false;

void OnEnable()
{
    listUpdateRequest = true;
}

void Update()
{
    if( listUpdateRequest == false ){
        UpdateListItems();
        listUpdateRequest = true;
    }
}

更新リクエスト毎にリストの数と要素を再構築する

(5) ここは処理の詳細に目を凝らしながら慎重に修正します。UpdateListItems() 処理の一部を InstanceListPrefabs() に委譲し、リストの要素数を書き換える処理を GameObject.SetActive() を使って書き換えます。
最初はリスト表示するゲームオブジェクトをTransformで階層化することを考えましたが、階層化したゲームオブジェクトを開放するDetachChildren() の動作が何かおかしいので断念しました。その代わりに、GameObject.SetActive() で必要な数だけ表示して、残りを非表示にすると、意図した動作ができることが分かりました。また、有効になった箱に対して、フラグが立っているものだけを入れるようにすることにしました。こうすることで、先ほど実装したQueryUse.GetItem() で渡されてくるデータと処理が一致し、完全に意図した動作ができあがります。(ここに気が付くのに少し時間がかかりました)

<b>Content</b>
private void InstanceListPrefabs()
{
    // リスト表示数を制限
    if( itemMax > 8 ){
        itemMax = 0;
    } else if( itemMax < 2 ){
        itemMax = 2;
    }
    
    // リストの子要素のプールを作成
    itemPool = new List<GameObject>();
    
    // プレハブのインスタンス化
    int i;
    for(i = 0; i < itemMax; i++){
        GameObject itemObj = Object.Instantiate( scrollNodePrefab ) as GameObject;
        itemPool.Add( itemObj );
        
        // オブジェクトを親に登録するためにも、Transform を使用する
        itemObj.transform.SetParent( gameObject.transform, false );    // ← UpdateListItems() のこの処理は起動時1回で良い
    }
}

private void UpdateListItems()
{
    max = adapter.GetItemMax();
    if( max > itemMax ){
        max = itemMax;
    }
    
    int i = 0;
    for ( ; i < max; i++ ){
        // 子オブジェクトの取得には、GameObject から Transform 経由で 子Transform を取得し、そのGameObject を取得
        // オブジェクトのコンポーネントを取得するには、GameObject.GetComponent<T>(); を使う
        TextMeshProUGUI itemText = itemPool[i].transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
        
        // TextMeshPro も内部のテキスト要素は .text でアクセスする
        // string は = で代入できる
        itemText.text = adapter.GetItem(i);
        
        // オブジェクトをActiveにする
        itemPool[i].SetActive(true);
        // オブジェクトを親に登録するためにも、Transform を使用する
        // itemPool[i].transform.SetParent( gameObject.transform, false );
    }
    for ( ; i < itemMax; i++ ){
        // オブジェクトを非Activeにする
        itemPool[i].SetActive(false);
    }
    
    itemSelectedNo = 0;
    eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
}

(6) 最後に、初期化タイミングによって表示が崩れるのを防ぐために入れていた initf (パッチ処理)をコメント化します。
(コード省略)

表示テストします。リストによって選択肢が変わるようになりました。

次回やること

Description へのテキスト渡しと、アイテムを使うことによる疑似ステータスの増減

Unity学習#19 (Unity 2019.4.1f1) リストからアイテムを使えるようにする#1

今回から、いよいよ第13回から延々と作ってきたメニューで、アイテムを使えるようにしていきます。今回は最初の段階として、リストに必要なデータをリストの外から持ってこられるようにします。今回も、いくつか落とし穴があって時間がかかりました。本当はアイテムを使う処理まで実装したかったのですが、思いのほか作業量が多くなりました。そのため、これまでの作業を一旦まとめます。

前回までに作ったリストは、テストのため表示文字は固定で、ScrollView に [SerializeField] string配列を定義して、Awake() の時点でアクセスできるようにしていました。なのでリストの表示数でサイズを変えるのはあまりタイミングを要求されませんでした。しかし今回はゲーム途中でアイテムが増減するため、サイズを外から与えて作ることになります。サイズを与えるタイミングが重要になります。

先に、今回の一連の修正を入れたコードとその背景となるクラス分割を示し、それから各処理を実装する時につまづいた点をまとめます。

今回も実装単位を細かくして、こまめにテストしながらコードを修正します。タイミングが関わる制御は、手を入れる時に混乱しやすいと思います。タイミングを要求しない処理と共に修正して、どの時点で不具合を作り込んでしまったか分からなくなってしまい、コードも修正しすぎてしまって、前にも後ろにも行けない状態に陥りやすい箇所でもあります。

要約

メニューの選択状態を保持するコンテキストを作成し、アイテムリストの取得をSerializeField(静的)から関数(動的)に置き換えます。この変更が潜在的なタイミング回りのバグを顕在化してくれるため、この時点でタイミング制御のバグを潰します。最後に、作成したコンテキストから関数の戻り値を作成して、データを外から持ってこられるようにします。
上の順番でやることに気づかないまま進めて、何回もロールバックしました。また、上の順番に気付いてからも、どこがつまづきやすいかを調べながら、何回もロールバックしました。最終的なコードも大事ですが、この過程を見つけ、つまづきやすいポイントに差し掛かったという勘を磨くことも大事なんだろう、と感じました。

修正の順序

f:id:tomo_mana:20201014225937p:plain
修正の順序

リストの子要素に必要なパラメータを Adapter パターンで定義します。また、メニューの選択された状態を管理するクラス Context を定義して、Context から Adapter に必要な情報を取得できるようにします。アイテムやステータスなど、リストの外から情報を持って来なければならないものは、オブジェクト間での受け渡しがどうしても必要になります。

(つまづきやすいと感じた点)

  • Adapter:一気に変更しない(単純な置き換えだけで実現する)。
  • Context:必要な要素だけ実装する。
  • Context-Adapter:ここが難しいです。2段階の修正が必要です。

 1) デバッグログを入れて、例外が出ないようになるまで起動順を修正する(何度もシーケンス図を書き直しました)
 2) 起動順が修正されてから Context を Adapter に通す修正をする。

  • Adapter のファイル分離:継承することで使えなくなるメッセージに注意する。

修正のイメージ(ファイル分割)

f:id:tomo_mana:20201015220314p:plain
ファイル分割

修正コード

MenuContext.cs(追加)

public class MenuContext
{
    // アイテム情報
    public ItemContext itemContext;
}

ItemContext.cs(追加)

public class ItemContext
{
    // ListItem(アイテム一覧)
    public string[] listItemString = {
        "Wooden Stick",  // 装備テスト
        "Bread",   // 回復テスト
        "Seed of Power",  // 付与テスト
        "Key of Last Door"    // 捨てられない・アイテム欄で使えない・装備できないテスト
    };
    // QueryUse(アイテムの用途一覧)
    public string[] queryUseString = {
        "Use",    // 使うテスト
        "Equip",    // 装備テスト
        "Throw Away"    // 捨てるテスト
    };
    // 選択された番号
    public int itemId;

ItemPrefabAdapter.cs(追加)

public interface ItemPrefabAdapter
{
    // 長さを取得
    int GetItemMax();
    
    // 要素を取得
    string GetItem(int no);
}

InputUIManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class InputUIManager : MonoBehaviour
{
    // ターゲットとなる操作キャラ
    [SerializeField] PlayerControl2 player;
    
    // 一番最初のメニュー
    [SerializeField] Window root;
    
    // 一番最後のメニュー(ターミネータ)
    [SerializeField] Window terminate;
    
    // メニューのスタック
    private Stack<Window> windows;
    
    // キー入力
    private Vector2 input;
    
    // メニューコンテキスト
    private MenuContext context;
    
    // Start is called before the first frame update
    void Start()
    {
        // 操作キャラの初期化
        if(player == null){
            player = GetComponent<PlayerControl2>();
        }
        
        // スタックの初期化
        windows = new Stack<Window>();
        windows.Clear();
        
        // メニューコンテキストの初期化
        context = new MenuContext();
        context.itemContext = new ItemContext();
    }

    void AddWindow(Window w)
    {
        windows.Push( w );
        w.SetActive( true );
        w.SetContext( context );
    }
    
    void RemoveWindow(Window w)
    {
        w.SetActive( false );
        windows.Pop();
    }
    
    void ClearAllWindows()
    {
        Window w;
        while( windows.Contains(root) ){
            w = windows.Pop();
            w.SetActive(false);
        }
    }
    
    // 新Input System (入力イベントで取得)
    void OnNavigate(InputValue value)
    {
        input = value.Get<Vector2>();
        
        if( windows.Contains(root) ){
            // メニュー状態
            Window w = windows.Peek();
            w.Navigate(input);
        } else {
            // キャラ操作状態
            if(player != null){
                player.Move(input);
            }
        }
    }
    
    void OnSubmit()
    {
        if( windows.Contains(root) ){
            // メニュー状態
            Window w = windows.Peek();
            
            if( w == terminate ){
                ClearAllWindows();
            } else {
                AddWindow( w.Next() );
            }
        } else {
            // フィールドでのキャラ操作(未実装)
            Debug.Log("Window does not contains root");
        }
    }
    
    void OnCancel()
    {
        if( windows.Contains(root) ){
            // メニュー状態
            Window w = windows.Peek();
            
            if( w == terminate ){
                ClearAllWindows();
            } else {
                RemoveWindow(w);
            }
        } else {
            // キャラ操作→メニュー(Root)
            AddWindow( root );
        }
    }
}

Window.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Window : MonoBehaviour
{
    // 次のウィンドウ
    [SerializeField] Window windowNext;
    
    protected ScrollViewController child;
    protected MenuContext context;
    
    // Start is called before the first frame update
    void Awake()
    {
        child = gameObject.transform.GetChild(0).gameObject.GetComponent<ScrollViewController>();
    }
    
    public void Navigate(Vector2 value)
    {
        if( child != null ){
            child.Navigate(value);
        }
    }
    
    public void SetActive(bool b){
        gameObject.SetActive(b);
    }
    
    public void SetContext(MenuContext context){
        if( child != null ){
            child.SetContext(context);
            this.context = context;
        }
    }
    
    public Window Next(){
        // リスト要素毎にNextを持つ。その種類のWindowNextを返す。
        // 今はwindowのテストのため、windowNextをそのまま返す
        return windowNext;
    }
}

ListItemWindow.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ListItemWindow : Window, ItemPrefabAdapter
{
    void Start()
    {
        child.SetAdapter( this );
    }
    
    // 長さを取得
    public int GetItemMax()
    {
        if(gameObject.name == "ListItem"){
            return context.itemContext.listItemString.Length;
        } else
        if(gameObject.name == "QueryUse"){
            return context.itemContext.queryUseString.Length;
        } else {
            return 0;
        }
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            if(gameObject.name == "ListItem"){
                return context.itemContext.listItemString[no];
            } else
            if(gameObject.name == "QueryUse"){
                return context.itemContext.queryUseString[no];
            } else {
                return string.Empty;
            }
        } else {
            return string.Empty;
        }
    }
}

ScrollViewController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // ScrollRect

public class ScrollViewController : MonoBehaviour
{
    [SerializeField] string[] itemString;
    protected ContentManager content;
    
    protected MenuContext context;
    
    private bool resized;
    private ItemPrefabAdapter adapter;
    
    // Start is called before the first frame update
    void Awake()
    {
        content = gameObject.transform.GetChild(0).GetChild(0).GetComponent<ContentManager>();
        resized = false;
    }
    
    public void SetContext(MenuContext context)
    {
        this.context = context;
    }
    
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
    }
    
    public void Start()
    {
        content.SetAdapter( adapter );
    }
    
    public void Update()
    {
        if( resized == false ){
            Vector2 contentSize = content.GetContentSize();
        
            // ScrollView を Content と同じサイズにする
            RectTransform viewRect = gameObject.transform as RectTransform;
            Vector2 view = viewRect.sizeDelta;
            view.x = contentSize.x;
            view.y = contentSize.y;
            viewRect.sizeDelta = view;
            resized = true;
        }
    }
    
    public void OnDisable()
    {
        resized = false;
    }
    
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        content.Navigate(value);
    }
}


(以下、長いので必要に応じて)

ここからは、それぞれの修正について詳細をまとめます。
今回の一連の修正にあたり、C#での継承とインターフェースの使い方と、主要なメッセージのコールタイミングについては記事を分けました。

●継承について
tomo-mana.hatenablog.com
●初期化タイミングについて
tomo-mana.hatenablog.com


string配列をAdapterに置き換える

前回リスト表示に使ったstringの配列を、Adapterパターンで置き換えます。stringの配列が持つインターフェースは以下の2つです。

  • 素数の取得
  • 要素の取得

Adapterインタフェースを定義する

stringの配列をAdapterパターンで置き換えます。後々、リストの子要素にアイコン画像やサブメッセージを追加しても、Adapter にしておけば実装が楽になると思います。リストにバリエーションを持たせる場合は、プレハブの名前とアダプタインタフェースの名前を関連付けることで探しやすくなります(I+プレハブ名+Adapterなど)。

// ItemPrefabAdapter.cs
public interface ItemPrefabAdapter
{
    // string型を置き換えるので、string型と同じようなインタフェースにする
    // 長さを取得
    int GetItemMax();
    // 要素を取得
    string GetItem(int no);
}

string配列をAdapterインターフェースに置き換える

Contentは、Adapterインターフェースへの参照を追加し、stringをAdapterインターフェースに置き換えます。
ScrollView からstringを受け取る関数だった SetContent() は、Adapterへの参照を受け取る SetAdapter() に置き換えます。

(修正部分のみ、第16回からコード抜粋)
Content(修正箇所のみ抜粋)

public class ContentManager : MonoBehaviour
{
    // リストに表示する内容
    private string[] itemString;
    private int max;
    private ItemPrefabAdapter adapter;    // ←追加

    // CS0051: Interfaceを関数の引数として使うには、Interface を public にする必要がある
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
        
        max = adapter.GetItemMax();    // ← itemString.Length(string配列の要素数)を置換
        if( max > itemMax ){
            max = itemMax;
        }
        
        for ( int i = 0; i < max; i++ ){
            // 子オブジェクトの取得には、GameObject から Transform 経由で 子Transform を取得し、そのGameObject を取得
            // オブジェクトのコンポーネントを取得するには、GameObject.GetComponent<T>(); を使う
            TextMeshProUGUI itemText = itemPool[i].transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
            
            // TextMeshPro も内部のテキスト要素は .text でアクセスする
            // string は = で代入できる
            itemText.text = adapter.GetItem(i);    // ← itemString[i] を置換
            
            // オブジェクトを親に登録するためにも、Transform を使用する
            itemPool[i].transform.SetParent( gameObject.transform, false );
        }
    }
}

ScrollView(修正箇所のみ抜粋)

public class ScrollViewController : MonoBehaviour, ItemPrefabAdapter    // ← インターフェース追加
{
    // 注:OnEnable() ではなく、自作の関数です
    void Enable()
    {
        content.SetAdapter( this );    // ← SetContent() から置換
    }

    // 以下、Adapterインタフェース の実装

    // 長さを取得
    public int GetItemMax()
    {
        return itemString.Length;
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < itemString.Length ){
            return itemString[no];
        } else {
            return string.Empty;
        }
    }

Contextの定義

コンテキストは文脈の意味で、メニュー上の一連の操作を記憶(状態として保持)するために定義します。

空のクラス Context を作成する

今はアイテム画面しかないので、メニュー全体とアイテム用のコンテキストの2つだけを定義します。

  • MenuContext.cs(どのメニューが選択されているか)
  • ItemContext.cs(アイテムへの制御全般)

String配列をコンテキストに集約する

先ほど作成した2つのクラスを実装しますが、ここでは ScrollView が持っていた string配列を コンテキストにも持たせます。この string配列は、今後アイテムクラス群(第18回)に変わっていきます。

MenuContext

public class MenuContext
{
    // アイテム情報
    public ItemContext itemContext;
}

ItemContext

public class ItemContext
{
    // ListItem(アイテム一覧)
    public string[] listItemString = {
        "Wooden Stick",  // 装備テスト
        "Bread",   // 回復テスト
        "Seed of Power",  // 付与テスト
        "Key of Last Door"    // 捨てられない・アイテム欄で使えない・装備できないテスト
    };
    // QueryUse(アイテムの用途一覧)
    public string[] queryUseString = {
        "Use",    // 使うテスト
        "Equip",    // 装備テスト
        "Throw Away"    // 捨てるテスト
    };
    // 選択された番号
    public int itemId;

Context を Adapter に渡す

Context をリストの外から渡す

アイテム情報は、セーブデータに含まれるため、リストの外から渡されます。コンテキストは将来的に復元したセーブデータへのリンクを持つと思います。コンテキスト本体は InputUIが持つこととし、Windowをenableにするタイミングでリストに渡すようにします。コンテキストは、InputUI → Window → ScrollViewController の順に渡されます。

InputUIManager

public class InputUIManager : MonoBehaviour
{
    // メニューコンテキスト
    private MenuContext context;
    
    void Start()
    {
        // メニューコンテキストの初期化
        context = new MenuContext();
        context.itemContext = new ItemContext();
    }
    
    void AddWindow(Window w)
    {
        windows.Push( w );
        w.SetActive( true );
        w.SetContext( context );
    }
}

Window

public class Window : MonoBehaviour
{
    public void SetContext(MenuContext context){
        if( child != null ){
            child.SetContext(context);
        }
    }
}

ScrollViewController

public class ScrollViewController : MonoBehaviour, ItemPrefabAdapter
{
    protected MenuContext context;
    
    public void SetContext(MenuContext context)
    {
        Debug.Log(gameObject + ".SetContext itemContext = " + context.itemContext);    // ItemContext参照確認
    	this.context = context;
    }
}

コールタイミングのデバッグ(初期化ミスの解消)

この後、リストに表示する文字列が SerializeField からゲームの途中で渡されるデータに変わるため、リストを表示する時に正しくデータが渡されているかをこの時点でテストします。どのゲームオブジェクトから順にメッセージが渡されてくるるか分からないと実装しにくいので、Script Execution Order を使ってメッセージが渡される順番を指定します。

Script Execution Order の設定

(1) Edit > Project Settings..
(2) Project Settings メニューで、Script Execution Order を選択
(3) 起動順に関わる以下の順序を規定します。

f:id:tomo_mana:20201010232113p:plain
Edit > Project Settings > Script Execution Order
初期化タイミングの変更

上記を設定した後、デバッグした結果、ScrollViewの初期化処理を変更すると、うまくContextを渡せることが分かってきました。これはContextとAdapterの両方を持つ交点の役目だからです。尚、別記事に書きましたが、Awake() が呼ばれた時は、まだ関連する他のオブジェクトが非表示のため、サイズに関わる情報を取得できません。サイズに関わる情報はUpdate() で取得します。

ScrollView

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // ScrollRect

public class ScrollViewController : MonoBehaviour, ItemPrefabAdapter
{
    [SerializeField] string[] itemString;
    protected ContentManager content;
    
    protected MenuContext context;
    
    private bool resized;
    
    // Start is called before the first frame update
    void Awake()
    {
        Debug.Log(gameObject + ".Awake");
        content = gameObject.transform.GetChild(0).GetChild(0).GetComponent<ContentManager>();
        
        resized = false;
        
        // SerializeField や Awake() より前に初期化されるフィールドを利用している間は
        // 正しく動作するが、各オブジェクトの初期化タイミングを正しく実装すると
        // Awake() のタイミングはオブジェクトの関連付けが終わっていないので、
        // リサイズを含む処理を行うには早すぎる
//        Enable();
    }
    
    protected void Enable()
    {
        // 以下は Start() に移動
        // Start() は最初にオブジェクトがEnable()になった後、1回目のUpdate() より前に呼ばれる
//        content.SetAdapter( this );

        // 以下は Update() に移動
        // 表示する度にリサイズするものは、Update() で実装する
//        Vector2 contentSize = content.GetContentSize();
        
        // ScrollView を Content と同じサイズにする
//        RectTransform viewRect = gameObject.transform as RectTransform;
//        Vector2 view = viewRect.sizeDelta;
//        view.x = contentSize.x;
//        view.y = contentSize.y;
//        viewRect.sizeDelta = view;
    }
    
    public void SetContext(MenuContext context)
    {
        this.context = context;
    }
    
    // オブジェクトの関連付けは最初のAwake()、OnEnable() の後に行う
    public void Start()
    {
        content.SetAdapter( this );
    }
    
    // オブジェクトの表示に関わる処理は Update() で行う
    public void Update()
    {
        if( resized == false ){
            Debug.Log(gameObject + ".Update");
            
            Vector2 contentSize = content.GetContentSize();
        
            // ScrollView を Content と同じサイズにする
            RectTransform viewRect = gameObject.transform as RectTransform;
            Vector2 view = viewRect.sizeDelta;
            view.x = contentSize.x;
            view.y = contentSize.y;
            viewRect.sizeDelta = view;
            resized = true;
        }
    }
    
    public void OnDisable()
    {
        Debug.Log(gameObject + ".OnDisable");
        resized = false;
    }
    
    // 長さを取得
    public int GetItemMax()
    {
        return itemString.Length;
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            return itemString[no];
        } else {
            return string.Empty;
        }
    }
}

Adapter で返している stringへの参照を Context への参照に置換する

ScrollView

public class ScrollViewController : MonoBehaviour, ItemPrefabAdapter
{
    // 長さを取得
    public int GetItemMax()
    {
//        return itemString.Length;
        return context.itemContext.listItemString.Length;
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
//            return itemString[no];
            return context.itemContext.listItemString[no];
        } else {
            return string.Empty;
        }
    }
}

Adapter を ScrollView から分割する

ここまでは、ScrollView に Adapter を付けていました。ScrollView が string配列(SerializeField) を持っていたからです。ここまで修正したことで、Adapter が ScrollView になければならない理由は無くなりました。できれば ScrollView は与えられた中身を表示するだけにしたいものです。そのため、Adapter を ScrollView から外して、ScrollView のさらに外側から渡せるようにします。

Adapter を Window継承 に実装する

Windowを継承したクラス、ListItemWindow を作ります。今はテストを簡単にするために、QueryUseゲームオブジェクト用の処理も入れています。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ListItemWindow : Window, ItemPrefabAdapter
{
    void Start()
    {
        child.SetAdapter( this );
    }
    
    // 長さを取得
    public int GetItemMax()
    {
        Debug.Log("gameObject.name = " + gameObject.name);
        if(gameObject.name == "ListItem"){
            return context.itemContext.listItemString.Length;
        } else
        if(gameObject.name == "QueryUse"){
            return context.itemContext.queryUseString.Length;
        } else {
            return 0;
        }
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            Debug.Log("gameObject.name = " + gameObject.name);
            if(gameObject.name == "ListItem"){
                return context.itemContext.listItemString[no];
            } else
            if(gameObject.name == "QueryUse"){
                return context.itemContext.queryUseString[no];
            } else {
                return string.Empty;
            }
        } else {
            return string.Empty;
        }
    }
}

※上記を動かすために、Window.cs の ScrollViewController のスコープを protected から public に変えます。

ScrollView は Adapter への参照だけを持ちます。つまり、リストの子要素の型を規定するインターフェースを、ScrollView が持ったことになります。これによって、リスト単位でプレハブとアダプタの型を一致させることができました。今後は、リストのバリエーションを増やすたびに、プレハブとアダプタをセットで修正すれば、異なるプレハブのリストでも最小限の修正で対応できるようになります(多分)。

ScrollView

public class ScrollViewController : MonoBehaviour, ItemPrefabAdapter
{
    private ItemPrefabAdapter adapter;
    
    public void SetAdapter(ItemPrefabAdapter adapter)
    {
        this.adapter = adapter;
    }
    
    public void Start()
    {
//        content.SetAdapter( this );
        content.SetAdapter( adapter );
    }
}

※ListItem、QueryUse の スクリプトを Window から上記に差し替え、Next() の参照を再度貼りなおします。
Input UI の root 参照も貼りなおします。

最後に表示テストをします。

次回やること

アイテムを使用してステータスを変更する

Unity学習#19-2 (Unity 2019.4.1f1) 主要イベント Awake, OnEnable, Start, Update, OnDisable のコールタイミング

第19回で、リストの内容をリストの外(セーブデータなど)から渡す処理を実装するにあたって、ゲームオブジェクト間の連携がうまくいかない点がいくつかあったため、Awake()、Enable()/Disable()、Start()/Update() のコールタイミングを調べました。

最初にアクティブになる時のメッセージコール順

f:id:tomo_mana:20201013215634p:plain
メッセージ(初回起動時)

最初にアクティブになる時のメッセージコール順をシーケンス図にすると、上のような感じです。

Awake() と Start() は、それぞれ最初の GameObject.SetActive(true) 後に1回だけ呼ばれます。
Awake() は OnEnable() の直前に、Start() は Update() の直前に呼ばれます。複数のゲームオブジェクトが同時にアクティブになる時は、Awake() と Start() は少し呼ばれ方が異なる点に注意が必要と思われます。

Awake()

Awake() は OnEnable() はゲームオブジェクト単位でセットで呼ばれます。そのため、自身のAwake() が終わって最初のOnEnable() が呼ばれた時、まだ他のゲームオブジェクトはAwake()されていない可能性があるということです。

Start()

一方、Start() は Update() とセットで呼ばれるわけではありません。複数のゲームオブジェクトが同時にアクティブになる時、すべてのゲームオブジェクトが Update() されるより前に、すべてのゲームオブジェクトに Start() が呼ばれます。そのため、最初のUpdate() が呼ばれた時、他のゲームオブジェクトは Start() しています。※ただし、プリファブが混じっていたりする時は、挙動が違うかもしれません。

OnEnable()

OnEnable() は、ゲームオブジェクトがActive()になる度にコールされます。そのため、この処理の中でtransformに変更を加えたりしたくなりますが、最初にアクティブになる時にAwake()とセットで呼ばれることから、他のオブジェクトと協調動作には向いていません。OnEnable() を活用する場合は、Awake() 直後の OnEnable() だけ動作を変えるための状態フラグを定義する必要があります。

各タイミングでできそうな初期化処理

メッセージ 可能な初期化処理
Awake() 参照(gameObject, transformなど)だけを使って内部変数を初期化する
OnEnable() アクティブになる度に初期化すべき内部変数を初期化する
Start() GameObject.Find()など、他のゲームオブジェクトがアクティブでないと使えない方法を使って内部変数を初期化する
Update() アクティブになった時に必要なその他の処理を行う(状態フラグで処理を切り替える)

どのゲームオブジェクトからコールされるのか?

尚、Awake()、OnEnable()、Start()、Update() 時にどのゲームオブジェクトから順に呼ばれるかは、ゲームオブジェクトが作られた順、またはゲームオブジェクトが保存されるときに付加されるID(GUID)で決まります。GUID は乱数を使って生成されるため、そのままではゲームオブジェクトが呼ばれる順番を制御できません。ゲームオブジェクトを呼ぶ順番を決めるには、Project Settings の Script Execution Order を使います。

Script Execution Order

(1) Unityメニューから、Project > Project Settings..
(2) Project Settingsメニューから、Script Execution Order を選択
(3) 画面下の [+▼] を選択して、実行順を規定したいC#スクリプト(ゲームオブジェクトではない)を選択します
(4) リストにC#スクリプト名が追加されるので、右側にある数字に値を入れます。
 値が小さい順(昇順)に並び変わります。値が小さい方から順に呼び出されます。

非アクティブ時の動作

OnDisable()

f:id:tomo_mana:20201013221320p:plain
メッセージ(OnDisable)

スクリプトが所属する GameObject が SetActive(false) されるとき、OnDisable() が呼ばれます。OnDisable() は、上記と異なり、親子関係に沿って呼ばれます。親、子、孫ゲームオブジェクトがあって、親ゲームオブジェクトがSetActive(false)されると、孫→子→親の順にゲームオブジェクトが非アクティブになります。

(参考)
イベント関数の実行順序 - Unity マニュアル

Unity学習#19-1 (Unity 2019.4.1f1) C#で基底クラスのインタフェースで派生クラスの関数にアクセスしたい場合

第19回で、リストの内容をリストの外(セーブデータなど)から渡す処理を実装するにあたって、継承とインターフェースについて整理しました。他の言語(C++Java)との違いで少し混乱したためです。

やりたいこと

基底クラス インスタンス = new 派生クラス();
インスタンス.関数();

継承を使用した場合

基底public virtual + 派生public override

継承(Extend) を使用する場合、基底クラスへの参照が派生クラスの関数を呼ぶ条件は、

  • 基底クラスの関数が virtual
  • 派生クラスの関数が override

を満たす必要があります。

ideone.comで確認

// 基底クラス
public class super_virtual {
    public virtual void test()
    {
        Console.WriteLine("super_virtual\n");
    }
//    public virtual void test2();  // error CS0501: `super_virtual.test2()' must have a body because it is not marked abstract, extern, or partial
}
// 派生クラス
public class sub_override_super_virtual : super_virtual {
    public override void test()
    {
        Console.WriteLine("sub_override\n");
    }
}
public class Test
{
    public static void Main()
    {
        // 基底クラス インスタンス = new 派生クラス();
        super_virtual sv2 = new sub_override_super_virtual();
        // インスタンス.関数();
        sv2.test();  // sub_override
    }
}

virtual 関数も実装が必要

C#の virtual 関数は普通の関数と同じく実装できます(というより実装が必須です)。定義だけで実装が無いとコンパイルエラーになります(error CS0501)。この点は C++の virtual 関数 や Java の abstract 関数 のイメージがあると少し混乱する点だと思いました。また、C# は virtual 関数を定義するために抽象クラス(abstract)にする必要がありません。クラス内に非 virtual と virtual が共存できます。

virtual 関数は override が必須

override を付けない場合、基底クラスの関数名を派生クラスでも使用できますが、両者は別の関数として扱われます。ただし派生クラス側で警告が出ます。基底クラスの関数がvirtualでない場合、意図的なものなら派生クラス側の関数名を変えろ(warning CS0108)、基底クラスの関数がvirtualの場合、派生クラスをoverrideにするか、意図的なら派生クラス側の関数名を変えろ(warning CS0114)と言われます。同じ関数名でも別の関数として扱われる仕組みは C++ でも名前修飾という仕組みがあり、何となく理解できました。

virtual なし override はできない

virtual が付いていない関数の override は許可されていません(error CS0506)。 Java は virtual が付いていない関数も virtual 扱いのため、override を付けるとオーバーライドできるようです(初めて知りました)が、C#では基底クラスに明示的に virtual が付いている関数しか override できません。

関数スコープは public

関数スコープは基底/派生クラスとも public にします。クラスの外からクラス関数にアクセスする場合、関数スコープは public 一択になります。尚、クラスの可視性は常にpublic(error CS1527)、virtual/overrideを使用する時のコードの可視性は、publicまたはprotectedが許可されています(error CS0621)。ただし、派生クラスの関数スコープは基底クラスと同じである必要があります(error CS0507)。

インターフェースを使用した場合

インターフェース(interface)を使用した場合は、virtual/overrideのような修飾子は不要ですが、実装に当たっていくつかの制限があります。

スコープ不要(C#)

インターフェース、およびインターフェースの関数宣言にスコープを付けません。インターフェースにpublic以外のスコープを付ける(error CS1527)、インターフェースの関数宣言にあらゆるスコープを付ける(error CS0106)と、コンパイルエラーになります。

インターフェースにpublicスコープが必要な場合がある(Unity)

Unityの場合、さらにインターフェースにpublicが付いていないと、インターフェースを引数として使う関数を実装できません(error CS0051)。また、実装型にインターフェース型を暗黙的に代入しようとするとコンパイルエラーになります(error CS0266)。

Unityで確認

// TestInterface.cs
public interface TestInterface
{
    // Unity では、interface に public を付けておく
    void test();
    void test_in(TestInterface it);
    TestInterface test_out();
}

// TestInterfaceInpliment.cs
using UnityEngine;

public class TestInterfaceInpliment : TestInterface
{
    TestInterfaceInpliment it_impl;
    
    public void test()
    {
        Debug.Log("test");
    }
    public void test_in(TestInterface it)
    {
        // interface が public でないと以下のエラーが出ます
        // error CS0051: Inconsistent accessibility: parameter type 'TestInterface' is less accessible than method 'TestInterfaceInpliment.test_in(TestInterface)'
        it_impl = (TestInterfaceInpliment)it;  // 実装型 ← インターフェース型(キャスト必要)
    }
    public TestInterface test_out()
    {
        return it_impl;  // インターフェース型 ← 実装型(暗黙的なキャスト)
    }
}

// TestInterfaceBehaviour.cs
using UnityEngine;

public class TestInterfaceBehaviour : MonoBehaviour
{
    TestInterface testInterface;
    
    public void Awake()
    {
        testInterface = new TestInterfaceInpliment();
        testInterface.test();
        testInterface.test_in(testInterface);
        testInterface = testInterface.test_out();
    }
}

Unityとしての制限

SerializeFieldが使えない

Unity で、インターフェース型を使う場合は、もう少し制限がありそうです(これはまだ分かっていない部分が多いです)。インターフェース型は、[SerializeField] で Inspector ウィンドウ から設定できるようにすることができません。いくつかのサイトを調べていると、Unity 2019.3 から インターフェース型もInspectorウィンドウに表示できる [SerializeReference] が追加されたとのことでした。しかし、他の方も指摘されているように、これを指定しても Inspector ウィンドウには参照名が出るだけで、ファイルをアタッチすることはできません。これについてはいくつかのサイトで設定方法が紹介されていました。そのうち調べようと思います。

確認コード

確認に使ったコードを載せておきます。

継承(virtual と override)

f:id:tomo_mana:20201012214155p:plain
virtual と override

ideone.comで確認

using System;

public class super_not_virtual {
    public void test()
    {
        Console.WriteLine("super_not_virtual\n");
    }
}
public class super_virtual {
    public virtual void test()
    {
        Console.WriteLine("super_virtual\n");
    }
//    public virtual void test2();  // error CS0501: `super_virtual.test2()' must have a body because it is not marked abstract, extern, or partial
}
public class sub_not_override_super_not_virtual : super_not_virtual {
    public void test()
    {
        Console.WriteLine("sub_not_override\n");  // warning CS0108: `sub_not_override_super_not_virtual.test()' hides inherited member `super_not_virtual.test()'. Use the new keyword if hiding was intended
    }
}
public class sub_not_override_super_virtual : super_virtual {
    public void test()
    {
        Console.WriteLine("sub_not_override\n");  // warning CS0114: `sub_not_override_super_virtual.test()' hides inherited member `super_virtual.test()'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword
    }
}
/*public class sub_override_super_not_virtual : super_not_virtual {
    public override void test()
    {
        Console.WriteLine("sub_override\n");  // error CS0506: `sub_override_super_not_virtual.test()': cannot override inherited member `super_not_virtual.test()' because it is not marked virtual, abstract or override
    }
}*/
public class sub_override_super_virtual : super_virtual {
    public override void test()
    {
        Console.WriteLine("sub_override\n");
    }
}
public class Test
{
    public static void Main()
    {
        // 1
        super_not_virtual snv1 = new sub_not_override_super_not_virtual();
        snv1.test();  // super_not_virtual
        
        // 2
        sub_not_override_super_not_virtual snosnv = new sub_not_override_super_not_virtual();
        snosnv.test();  // sub_not_override
        
        // 3
//      super_not_virtual snv2 = new sub_override_super_not_virtual();
//      snv2.test();  // compile error
        
        // 4
//      sub_override_super_not_virtual sosnv = new sub_override_super_not_virtual();
//      sosnv.test();  // compile error
        
        // 5
        super_virtual sv1 = new sub_not_override_super_virtual();
        sv1.test();  // super_virtual
        
        // 6
        sub_not_override_super_virtual snosv = new sub_not_override_super_virtual();
        snosv.test();  // sub_not_override
        
        // 7
        super_virtual sv2 = new sub_override_super_virtual();
        sv2.test();  // sub_override
        
        // 8
        sub_override_super_virtual sosv = new sub_override_super_virtual();
        sosv.test();  // sub_override
    }
}

継承(許可されたスコープ)

確認用コード(ideone.comで確認)

using System;

/*
protected class protected_super_virtual {
    // error CS1527: Namespace elements cannot be explicitly declared as private, protected, protected internal, or private protected
}
*/
public class super_virtual_public_func {
    public virtual void test()
    {
        Console.WriteLine("public_super_virtual\n");
    }
}
public class super_virtual_protected_func {
    protected virtual void test()
    {
        Console.WriteLine("public_super_virtual\n");
    }
}
/*
public class super_virtual_private_func {
    private virtual void test()
    {
        // error CS0621: `super_virtual_private_func.test()': virtual or abstract members cannot be private
        Console.WriteLine("public_super_virtual\n");
    }
}
*/
public class sub_override_public_func_super_virtual_public_func : super_virtual_public_func {
    public override void test()
    {
        Console.WriteLine("sub_override\n");
    }
}
/*
public class sub_override_public_func_super_virtual_protected_func : super_virtual_protected_func {
    public override void test()
    {
        // error CS0507: `sub_override_public_func_super_virtual_protected_func.test()': cannot change access modifiers when overriding `protected' inherited member `super_virtual_protected_func.test()'
        Console.WriteLine("sub_override\n");
    }
}
*/
public class sub_override_protected_func_super_virtual_protected_func : super_virtual_protected_func {
    protected override void test()
    {
        Console.WriteLine("sub_override\n");
    }
}
/*
public class sub_override_protected_func_super_virtual_public_func : super_virtual_public_func {
    protected override void test()
    {
        // error CS0507: `sub_override_protected_func_super_virtual_public_func.test()': cannot change access modifiers when overriding `public' inherited member `super_virtual_public_func.test()'
        Console.WriteLine("sub_override\n");
    }
}
*/

public class Test
{
    public static void Main()
    {
    }
}

インターフェース(C#

ideone.comで確認

using System;

interface ITestable {
//  public void test();  // error CS0106: The modifier `public' is not valid for this item
    void test();
    void test_in(ITestable it);
    ITestable test_out();
}
public interface ITestable_public {
    void test();
    void test_in(ITestable_public it);
    ITestable_public test_out();
}
class ITestable_implement : ITestable
{
    ITestable_implement it_impl;
    
    /*
    void test()
    {
        // error CS0737: `ITestable_implement' does not implement interface member `ITestable.test()' and the best implementing candidate `ITestable_implement.test()' is not public
        Console.WriteLine("test");  
    }
    */
    public void test()
    {
        Console.WriteLine("test");
    }
    public void test_in(ITestable it)
    {
        it_impl = (ITestable_implement)it;
    }
    public ITestable test_out()
    {
        ITestable it = it_impl;
        return it;
    }
}
class ITestable_implement_public : ITestable_public
{
    ITestable_implement_public it_impl;
    
    public void test()
    {
        Console.WriteLine("test");
    }
    public void test_in(ITestable_public it)
    {
        it_impl = (ITestable_implement_public)it;
    }
    public ITestable_public test_out()
    {
        ITestable_public it = it_impl;
        return it;
    }
}

/*
protected interface ITestable_protected {
    // error CS1527: Namespace elements cannot be explicitly declared as private, protected, protected internal, or private protected
    void test();
}
private interface ITestable_private {
    void test();
}
*/

public class Test
{
    public static void Main()
    {
        ITestable it = new ITestable_implement();
        it.test();
        it.test_in(it);
        it = it.test_out();
    }
}

インターフェース(Unityでの制限)

Unityで確認

// TestInterface.cs
public interface TestInterface
{
    void test();
    void test_in(TestInterface it);
    TestInterface test_out();
}

// TestInterfaceInpliment.cs
using UnityEngine;

public class TestInterfaceInpliment : TestInterface
{
    TestInterfaceInpliment it_impl;
    
    public void test()
    {
        Debug.Log("test");
    }
    public void test_in(TestInterface it)
    {
        // Unity では、interface が public でないと 以下のエラーが出る
        // error CS0051: Inconsistent accessibility: parameter type 'TestInterface' is less accessible than method 'TestInterfaceInpliment.test_in(TestInterface)'
        
//      it_impl = it;  // error CS0266: Cannot implicitly convert type 'TestInterface' to 'TestInterfaceInpliment'. An explicit conversion exists (are you missing a cast?)
        it_impl = (TestInterfaceInpliment)it;
    }
    public TestInterface test_out()
    {
        return it_impl;
    }
}

// TestInterfaceBehaviour.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TestInterfaceBehaviour : MonoBehaviour
{
    TestInterfaceInpliment testInterfaceInpliment;
    TestInterface testInterface;
    
    public void Awake()
    {
        testInterfaceInpliment = new TestInterfaceInpliment();
        testInterfaceInpliment.test();
        testInterfaceInpliment.test_in(testInterfaceInpliment);
//      testInterfaceInpliment = testInterfaceInpliment.test_out();  // error CS0266: Cannot implicitly convert type 'TestInterface' to 'TestInterfaceInpliment'. An explicit conversion exists (are you missing a cast?)
        testInterfaceInpliment = (TestInterfaceInpliment)testInterfaceInpliment.test_out();
        
        testInterface = new TestInterfaceInpliment();
        testInterface.test();
        testInterface.test_in(testInterface);
        testInterface = testInterface.test_out();
    }
}

Unity学習#18 (Unity 2019.4.1f1) アイテムとステータス

今回はリストにアイテムを表示させるに先立って、アイテムとステータスを定義します。

ステータス

アイテムはステータスを増強するものと捉えて、先にステータスを定義します。

項目

第11課で、ゲームの駆け引きを生む最低限の要素として、以下の3要素を挙げていました。
●体力(消費の対象)
●削る力(消費の単位)・・・攻撃力
●削る速さ(消費行動の優先度)・・・速度

体力は消費の対象なので、最大値と現在値を定義します。

次に、体力、攻撃力、速度の値域を決めるために、最小値と最小値について考えます。

最小値

●体力の最大値=0:存在できない→0になってはいけない
●体力の現在値=0:生きられない→ゲームオーバー
●攻撃=0:生き抜けない
●速度=0:動けない(自力で生き抜けない)

いずれも最小値は致命的です。駆け引きに必要な最低限の要素なので、どれかが欠けても駆け引きが成り立たなくなります。そのため、通常は体力の現在値は 0 を許容(ゲームオーバー)として、攻撃力と速度は 0 を避けるか、0 でも何らかの行動が取れるように補正をかけると思われます。これは一人旅か仲間がいるかによって変わるため、ゲームバランスの要素も含まれます。

今の段階では、攻撃力と速度が最小値を取れなくするような制限はかけないものとします。体力の最大値は、0 を取らないようにする必要があります。

最大値

●体力の最大値=無限大:回復が持つ効果が大きい
●体力の現在値=無限大:死なない
●攻撃力=無限大:敵なし
●速度=無限大:システムによる

最大値を考えるにあたって、まず駆け引きが成り立たなくなる(無敵)領域について考えました。最大値は自分だけでなく対決する相手のステータスとのバランスになります。何を最大とするのかはとても重要そうです。

データの最大値と表示の最大値

無限大(無敵)というのはあり得ないとして、最大値にはゲームプレイヤーから見える表示上の最大値(10進数)と、ゲームプレイヤーからは見えないデータ上の最大値(2進数)の、2つの制限があります。
たとえば体力の表示欄に4桁の制限を付けたら最大値は9999ですが、データ上は2進数のため、9999以上の値となる最小値として、16384(2の14乗) が最大値になります。

今はfloatやdoubleなどの浮動小数点計算が普通に使われており、気にすることもないのかもしれませんが、演算が今より遅かった頃は、データの最大値はゲーム機に搭載するCPUのレジスタ幅も考慮していたと思います。例えばファミコンは8bit CPUのため、できるだけ2の8乗まで(0-255)の値になるようにするか、2の16乗まで(0-65535)の値になるように、値域を設定していたと思います。

また、後述する保存領域の制限も大きかったと思います。

保存容量による制限

ステータス情報は、プログラムとは別の不揮発領域に保存する必要があります。最近は不揮発領域が増えたり、データのクラウド化も進んだので、一つのゲームが不揮発領域を占有するよりかは、スマートフォンやパソコンの一枠を間借りするか、ゲーム機のメモリの一部を間借りすることがほとんどかと思います。それでも、他のアプリなどと不揮発領域(リソース)を共有するため、少ない容量で管理できることに越したことはありません。

オフラインで行うゲームで、プログラムもステータス情報も同じ不揮発媒体(メモリなど)に保存する場合は、画像やテクスチャの方がよっぽど大容量でしょう。オンラインゲームで、サーバ上でステータスを管理する場合、ステータス情報は頻繁にやり取りされるため、データ幅を小さくするために、容量の検討は価値があるかもしれません。

厳密には、保存容量をバイト単位にしない場合は、データの送受信の度にデータを圧縮・解凍する処理時間とのトレードオフになります。それも、3Dのレンダリング処理に比べたら、気にするほどでも無いでしょう・・・

いろいろ考えましたが、とりあえず最大値はゲームバランス重視で決めれば良いと思いました。今の段階では、仮で容量を決めます。

最大値と最小値の差

これはどちらかというとゲームバランスの観点になります。取りうる値が大きいことは振れ幅が大きいことを意味します。値が大きくなると成長を感じやすい反面、強い状態と弱い状態との差が激しく、成長して強くなりすぎる(難易度が下がる)などのデメリットもあるかもしれません。

ただ、駆け引きの点では、値そのものの大きさよりも、対立する相手の平均的な攻撃力とプレイヤーキャラクターの体力との比率の方が重要かもしれません。たとえば、体力が9999あっても、敵の平均的な攻撃が1000近いとすれば、体力が999あって、敵の平均的な攻撃が100近いのと変わりませんし、むしろ値が大きい方が分解能が大きくなります。これは、いずれ駆け引きについて考えるときに、考えることにします。

アイテム

アイテムの効果

アイテムの効果は、ステータスに一定の強化または弱化を一定時間与えるもの(付与)と、体力の現在値を回復または減らすもの(回復)があります。対立する相手に効果を及ぼすものは、駆け引きについて考える時に考えたいと思います。

要素

ステータスを一定時間の間強化または弱化するアイテムの効果を記述するには、以下の要素が必要そうです。

●対象となるステータス
●効果時間
●値(プラスとマイナス)
●値の単位(絶対値、パーセンテージ)
●何回使えるか(摩耗)

付与の例

たとえば、付与について、「ほうれんそう=5分間、攻撃力を20%UP」 だった場合、以下のようになります。

パラメータ
対象となるステータス 攻撃力
効果時間 5分間
+20
値の単位
何回使えるか 1
回復の例

回復についても考えてみます。たとえば、「パン=体力の現在値を30だけ回復する」場合、以下のようになります。

パラメータ
対象となるステータス 体力の現在値
効果時間 恒久
+30
値の単位 絶対値
何回使えるか 1

回復は、ステータスに不可逆的な影響を与えると考えると、付与ではなく体力の現在値の書き換えです。

時間制限があるかどうか

アイテムは、付与と回復という区分よりも、時間制限の有無に着目して、時間制限あり(付与)となし(書き換え)に分ける方がうまく分類できそうです。

不可逆かどうか

「効果時間が恒久であるものは書き換えとみなす」ことにしようと思ったのですが、実際は無限に長いアイテム効果を与えるものを付与した状態で、アイテム効果を無効化された場合、恒久だけれども付与(不可逆的ではない)、ということもあり得ます。その意味で、恒久には不可逆かどうかが重要になります。

●対象時間: enum{不可逆、恒久、有限時間・・・}

複数の効果

一つのアイテムが複数の効果を持つことも珍しくないと思います。そのため、アイテムはアイテム効果のリストを持ちます。これで、ステータスの強化・弱化を持たないアイテムも将来的に対応できます。

●アイテム→List<アイテム効果>

アイテム効果の付与

アイテム効果の付与は、アイテムごとに効果時間があると思います。ゲームプレイヤーはトータルでプレイヤーキャラクターがどれくらい強化または弱化されているかを気にしますが、システムとしてはどのアイテムの効果があとどのくらいの間付与されているかを気にします。

そのため、ステータスはアイテム効果の付与について、適用中のアイテムとその残時間(または経過時間)のリストを持ちます。

●アイテム効果の付与: マップ<アイテムID, 経過時間>

歩数、あるいはリアルタイム時計の時間経過によって、ゲーム上の時間が経過する毎に、上記リストに追加されたアイテムは、経過時間が加算されます。アイテムが持つすべての効果が時間切れとなった場合、付与リストからそのアイテムが消滅します。(一つのアイテムが複数の効果を持ち、効果ごとに効果時間が定義されているため、このような表現になります)

アイテムの装備

アイテムの使い途として、時間制限付きで付与する以外に、装備することもあります。これはただ持っているだけかもしれないし、選んでいる状態かもしれませんが、プレイヤーキャラクターに対して一定のアイテム効果を発揮します。装備の場合、効果時間よりかは、耐久力(摩耗)を考慮すると思います。
付与と装備の違いは、この耐久の考え方の違いと思われます。付与は消費して時間制限付きの効果を与えます。装備は効果を与えますが、時間制限よりはむしろ使用回数制限(耐久)付きの効果を与えます。似ていますが、時間制限と使用回数制限は分けた方が良さそうです。

武器の例

たとえば、「木の棒=攻撃力+10だが10回で壊れる」場合、以下のようになる。

パラメータ
対象となるステータス 攻撃力
耐久力 10
+10
値の単位 絶対値
防具の例

今は防御力は考慮していませんが、防具についても、似た概念を適用できると思います。
たとえば、「木の盾=体力の最大値+10パーセントだが5回攻撃を受けたら壊れる」場合、以下のようになります。

パラメータ
対象となるステータス 最大HP
耐久力 5
+10
値の単位 パーセント
装備の数

装備できる武器の数は一つとは限りません。二刀流なら2つの武器で攻撃します。また、右手と左手のように部位に分けた時には、部位ごとにアイテムを持ちます。

●武器、防具 = List<アイテムID、使用回数>

武器は攻撃した時、防具は攻撃を受けた時に消費します。盾で攻撃できる時はどうするか悩みますが、その時は別途考えます。

アイテム効果の参照(アイテム辞書)

メニュー用に、アイテム毎に取りうるアクションをまとめます。
●使うことができる
●装備することができる
●捨てることができる(!)
昔のゲームの中には、捨てたら絶対にクリアできなくなるアイテムを捨てられる仕様が存在しているものがありました。捨てられるかどうかは重要です!

アイテム辞書は先ほどのアイテム効果と組み合わせて、以下のリストになります。
⚫︎アイテム辞書 = <List<アイテム効果>, List<bool>>[アイテム数]

アイテムの残数管理

アイテムの保持は、同じアイテムを束ねて表示したり、同じアイテムでも列挙したりすることもあります。持ち物に最大で保持できる数や、入手できる回数に制限を付けたりすることもあります。そのため、保持するアイテムには、数量があります。

順序管理

保持するアイテムは、拾った順に登録するものとします。

保持するアイテム量が少なく、アイテムの種類が少ない間は、アイテムを拾うたびに前方検索でアイテム数を増やせば良いのですが、保持できる量かアイテムの種類かのどちらかでも数が多くなると、前方検索では処理時間が無駄になります。また、数量制限や取得数制限なども加味すると、全部のアイテムの拾い状態を配列で持つ方が効率がいい可能性もあります。全部のアイテムの拾い状態をテーブルで持てば、アイテムの数が多くても、検索時間はいつも最小になります。

●アイテム残数管理 = アイテム数量[アイテム数]

相関図(UMLクラス図)

アイテムとステータスの要件をまとめると以下になります。
●ステータスには「要素」と「最大・最小・値域」という要素がある。
●アイテムはステータスの強化・弱化に特化すると、その効果の及ぼし方には「付与」と「装備」とがある。
●アイテムの「残数管理」がある。
●アイテム効果の参照を行う「アイテム辞書」がある。

(クラス図)

f:id:tomo_mana:20200925001626p:plain
クラス図(ステータスとアイテム)

説明には出てきませんでしたが、キャラクターはあるパーティーに属すると思います(それが一人旅でも)。また、当然アイテムには「アイテム名」があります。

次回やること

メニューにアイテムを表示させて、アイテムを使用できるようにする。