ゲーム化!tomo_manaのブログ

ゲーム化!tomo-manaのブログ

Unityでゲームを作る方法について紹介しています

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 参照も貼りなおします。

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

次回やること

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