ゲーム化!tomo_manaのブログ

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

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

Unity学習#22 (Unity 2019.4.1f1) 使ったアイテムの削除、アイテムへの操作を画面に表示

メニュー作成も長期戦になりましたが、やっと本来やりたい処理を実装できるようになってきました。今回は使ったアイテムがリストから削除される処理と、アイテムを使った時の内容を画面に表示する処理を実装します。

概要

今回は、そこまで難しい修正はありませんでした。ただ、相変わらずタイミング制御関係のバグに悩まされていて、細かいバグをつぶしながら作業したために、記事にする時に手間取りました。

修正の順序

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

最初に、アイテムの操作を決定した時の処理(Submit)をリストに通知するために、ISubmitHanlderインターフェースを実装します。ISubmitHandlerへのメッセージは、EventSystemが飛ばしてくれるわけではなく、このインターフェース自体を活用して、自分でメッセージを飛ばす処理を実装します。前回、ExecuteEvents.Execute の中身を調べた時に知った「型チェック」を使って、メッセージを飛ばします。
アイテムを追加・削除するために、固定長の配列からList型に置き換えます。また、画面に表示するために、テキスト領域への参照を持つ画面クラスを作成しました。最後に、リスト画面表示処理(ListItemWindow)を画面毎に分割しました。

修正コード

最初に修正したコードをまとめ、その後に各修正のポイントをまとめます。

ItemContext

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

public class ItemContext
{
    // QueryUse(アイテムの用途一覧)
    public string[] queryUseString = {
        "Use",    // 使うテスト
        "Equip",    // 装備テスト
        "Throw Away"    // 捨てるテスト
    };
    // Description(アイテムの効果の説明1)
    public string[] itemOperationString = {
        "Used",
        "Equipped",
        "Discarded"
    };
    // 選択された番号
    public int itemId;//  // アイテムNo
    public int itemOperation;   // アイテムへの操作
    
    // ListItem(アイテム一覧)
    public List<Item> itembag;
    
    // アイテムフラグ
    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 List<Item>();
        itembag.Add( new Item( "Wooden Stick", DISPOSABLE | EQUIPABLE ) );  // 装備テスト
        itembag.Add( new Item( "Bread", DISPOSABLE | USABLE ) );  // 回復テスト
        itembag.Add( new Item( "Seed of Power", DISPOSABLE | USABLE ) );  // 付与テスト
        itembag.Add( new Item( "Key of Last Dungeon", NOTUSE ) );  // 捨てられない・アイテム欄で使えない・装備できないテスト
        itembag.Add( new Item( "TEST", USABLE ) );  // 捨てられない・アイテム欄で使えない・装備できないテスト
    }
}

InputUI

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

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) ){
            // メニュー状態
            Debug.Log("Window contains root");
            
            Window w = windows.Peek();
            if( w is ISubmitHandler h ){
            	h.OnSubmit(null);
            }
            
            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 );
        }
    }
    
    // Update is called once per frame
    void Update()
    {
    }
}

ListItem

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

public class ListItemWindow : Window, ItemPrefabAdapter
{
    public override void Awake()
    {
        base.Awake();
        child.SetAdapter( this );
    }
    
    public void OnEnable()
    {
        if( context != null ){
            context.itemContext.itemId = 0;
        }
    }
    
    void Start()
    {
    }
    
    // 長さを取得
    public int GetItemMax()
    {
        return context.itemContext.itembag.Count;
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            return context.itemContext.itembag[no].name;
        }
        return string.Empty;
    }
    
    public void SetSelected(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            context.itemContext.itemId = no;
        }
    }
}

QueryUse

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

public class QueryUseWindow : Window, ItemPrefabAdapter, ISubmitHandler
{
    [SerializeField] TextWindow textWindow;
    
    public override void Awake()
    {
        base.Awake();
        child.SetAdapter( this );
    }
    
    public void OnEnable()
    {
        if( context != null ){
            context.itemContext.itemOperation = 0;
        }
    }
    
    void Start()
    {
    }
    
    // 長さを取得
    public int GetItemMax()
    {
        if( context == null ){
            return -1;
        }
        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;
    }
    
    // 要素を取得
    public string GetItem(int no)
    {
        if( 0 <= no && no < GetItemMax() ){
            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() ){
            context.itemContext.itemOperation = no;
        }
    }
    
    public void OnSubmit(BaseEventData eventData)
    {
        // 選択番号→アクション
        int i, c = 0;
        int s = context.itemContext.itemId;
        int no = context.itemContext.itemOperation;
        for(i = 0; i < 3; i++){
            if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){
                if( c == no ){
                    context.itemContext.itemOperation = i;
                    break;
                } else {
                    c++;
                }
            }
        }
        // アイテムを使った!表示
        string str = context.itemContext.itemOperationString[ context.itemContext.itemOperation ] +
            " " +
            context.itemContext.itembag[ context.itemContext.itemId ].name;
        if( textWindow != null ){
            textWindow.Write(str);
        }
        // 使ったアイテムをリストから削除する
        if( context.itemContext.itemOperation == (int)ItemUsage.USE
         || context.itemContext.itemOperation == (int)ItemUsage.DISPOSE
        ){
            context.itemContext.itembag.RemoveAt( context.itemContext.itemId );
        }
    }
}

Description

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

public class TextWindow : Window
{
    private TextMeshProUGUI textArea;
    private bool updated;
    private string pooledString = null;
    
    public void Write(string str)
    {
        pooledString = str;
        updated = true;
    }
    
    public override void Awake()
    {
        base.Awake();
        textArea = gameObject.transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
        updated = true;
    }
    
    public void OnDisable()
    {
        updated = false;
        pooledString = null;
        textArea.text = null;
    }
    
    // Start is called before the first frame update
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {
        if( updated )
        {
            if( textArea != null ){
                if( pooledString != null ){
                    textArea.text = pooledString;
                }
                Canvas.ForceUpdateCanvases();
            }
            updated = false;
        }
    }
}


以下、各修正の要点だけまとめます。

アイテムへの操作を決定(Submit)した時の処理

InputSystemでは、Submit時のイベント OnSubmit() は PlayerInput を持つゲームオブジェクトに飛びます。ここから、対象のコンポーネントに Submit イベントを転送します。今回はEventSystemですでに用意されているISubmitHandlerを使って実装します。尚、ISubmitHandlerを実装しただけで EventSystem から OnSubmit() が自動でコールされることはありません。今回は、この ISubmitHandler をただのインターフェースとして活用することにします。

ISubmitHandler と OnSubmit() を追加する

PlayerInput経由で Submit処理 を受ける InputUIManager.cs から、リスト画面 ListItemWindow.cs に向けてOnSubmit() をコールします。

(1) ListItemWindow: ISubmitHandler と OnSubmit() 関数を追加します。今の段階では Debug.Log() だけ入れて動作テストします。

ListItemWindow.cs

class ListItemWindow : Window, ListItemAdapter, ISubmitHandler
{
    public void OnSubmit()
    {
        Debug.Log(gameObject + ".OnSubmit");
    }
}

(2) InputUIManager: ISubmitHandler を持つコンポーネント への OnSubmit() をコール
次に、InputUIManager.cs 側から、ISubmitHandler を持つコンポーネントに OnSubmit() イベントを送ります。ExecuteEvents.Execute<>() で使われていた型チェックを活用して、ISubmitHandler が実装されたコンポーネントだけにイベントを渡します。このようにすることで、すべての Window が ISubmitHandler を実装しなくても済みます。

InputUIManager.cs

// メニューのスタック
private Stack<Window> windows;

void OnSubmit()
{
    if( windows.Contains(root) ){
        Window w = windows.Peek();
        // 型チェック
        // 成功(true)すると抽出した型(ISubmitHandler)への参照が取得できる
        // ここでは Window型 w から ISubmitHandler型 h にキャストされる
        if( w is ISubmitHandler h ){
            h.OnSubmit(null);
        }
    }
}

一旦動作テストして、正しくOnSubmit() がコールされるか確認します。

どの操作かを識別する

第21回で、定義されたアイテム操作の中から、アイテム毎に行える操作だけを抽出する処理を実装しました。今回は、可能なアイテム操作の中から何番目の操作を選択したかを格納する変数 itemOperation を定義します。定義されたアイテム操作は「=0: 使う(Use)」「=1: 装備する(Equip)」「=2: 捨てる(Discard)」の3つです。

f:id:tomo_mana:20201209231110p:plain
抽出の逆処理のイメージ


(1) アイテムの選択状態を保持する状態管理クラス ItemContext に、itemOperation を追加します。アイテムを使った時の表示文字列 itemOperationString[] も定義します。

ItemContext.cs

// Description(アイテムの効果の説明1)
public string[] itemOperationString = {
    "Used",
    "Equipped",
    "Discarded"
};
// 選択された番号
public int itemId;  // アイテムNo
public int itemOperation;   // アイテムへの操作

(2) リスト側は、選択された時に選択番号を登録する処理を追加します。

ListItemWindow.cs

public void SetSelected(int no)
{
    if( 0 <= no && no < GetItemMax() ){
        context.itemContext.itemOperation = no;
    }
}

(3) さらに、リスト側に選択された時の処理を追加します。先ほど追加したOnSubmit() に実装します。逆処理は、アイテムを取得する時の処理 GetItem() をひっくり返したような作り方になります。

ListItemWindow.cs

public void OnSubmit(BaseEventData eventData)
{
    // 選択番号→アクション
    int i, c = 0;
    int s = context.itemContext.itemId;
    int no = context.itemContext.itemOperation;
    for(i = 0; i < 3; i++){
        if( (context.itemContext.itembag[s].usage & (1 << i)) != 0 ){
            if( c == no ){
                context.itemContext.itemOperation = i;
                break;
            } else {
                c++;
            }
        }
    }
}

アイテム操作を識別するために、第x回で行った抽出の逆処理を実装します。今回は選択肢が3つしかないのでfor文で作っていますが、選択肢が多くなった場合はマップファイルを定義しても良いかもと思いました。また、今回は禁じ手の変数使いまわし(選択番号→表示する文字のIDへの転用)を使っているので、この点は改善が必要です・・・

(4) OnEnable() でitemOperationを初期化しておきます。
ListItemWindow.cs

void OnEnable()
{
    context.itemContext.itemOperation = 0;
}

アイテムへの操作(出力テスト)

選択されたアイテム操作から文字列を正しく引っ張ってこれたかどうかをテストします。Debug.Log() で itemOperation を表示しても良いのですが、せっかくなのでここで表示予定の文字列を作ってみます。まだ日本語化していないので、今回は英語の並び順(Used/Equipped/Discarded xxx)で表示します。

ListItemWindow.cs

string str = context.itemContext.itemOperationString[ context.itemContext.itemOperation ] +
    " " +
    context.itemContext.itembag[ context.itemContext.itemId ].name;
Debug.Log(gameObject + ".OnSubmit: " + str);

アイテム一覧に追加・削除できる形式にする

第19回で、アイテムを固定長の配列で定義しました。固定長は作りやすい反面、個数の増減には弱いので、可変長のList型に置き換えます。List型は固定長の配列と同様にインデックスで参照できるため、変更する箇所は型定義、初期化、配列長の3つです。

配列からコレクションに変更する

(1) 型定義と初期化処理の置き換え
Item[] → List() に置き換えます。初期化処理は List.Add() で置き換えます。
ItemContext.cs

public class ItemContext
{
    // ListItem(アイテム一覧)
    public List<Item> itembag;
    
    // コンストラクタ
    public ItemContext()
    {
        // クラス配列はコンストラクタで初期化する
        itembag = new List<Item>();
        itembag.Add( new Item( "Wooden Stick", DISPOSABLE | EQUIPABLE ) );  // 装備テスト
        itembag.Add( new Item( "Bread", DISPOSABLE | USABLE ) );  // 回復テスト
        itembag.Add( new Item( "Seed of Power", DISPOSABLE | USABLE ) );  // 付与テスト
        itembag.Add( new Item( "Key of Last Dungeon", NOTUSE ) );  // 捨てられない・アイテム欄で使えない・装備できないテスト
        itembag.Add( new Item( "TEST", USABLE ) );  // 捨てられない・アイテム欄で使えない・装備できないテスト
    }
}

(2) 配列長を参照している箇所の置き換え
string[].Length → List.Count

public int GetItemMax()
{
//    return context.itemContext.itembag.Length;    // string[] の配列長
    return context.itemContext.itembag.Count;    // List<>の要素数
}

OnSubmit() でアイテムを削除する

(1) 先ほどのOnSubmit() 処理でアイテム除去の1文を追加します
先ほどのList型への置き換えで、List.RemoveAt( int ) で指定した番号のアイテムを削除できるようになります。

context.itemContext.itembag.RemoveAt( context.itemContext.itemId );

使ったアイテムを画面に表示する

メニュー画面の構成(Canvas)は以下のようになっていました。
f:id:tomo_mana:20200923223906p:plain

Submit を押していった時の画面表示は、ListItem → QueryUse → Description の順です。今回は、この3つ目の画面 Description 用のスクリプトを作成します。

テキスト領域にアクセスするためのスクリプトを作成

アイテムを画面に表示するために、表示画面用のスクリプトを作成します。

(1) Description: Text(TMPro)にアクセスできるスクリプトTextWindow.cs を作成する

外から文字列を直接渡すだけで画面を更新できる関数 Write() を用意し、Write() が呼ばれる毎に画面を更新するようにしています。(この処理の実装中にタイミング制御の問題が発生したため、その解消のためにすでに入り組んだコードになっています)

TextWindow.cs(新規)

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

public class TextWindow : Window
{
    private TextMeshProUGUI textArea;
    private bool updated;
    private string pooledString = null;
    
    public void Write(string str)
    {
        pooledString = str;
        updated = true;
    }
    
    public override void Awake()
    {
        base.Awake();
        textArea = gameObject.transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
        updated = true;
    }
    
    public void OnDisable()
    {
        updated = false;
        pooledString = null;
        textArea.text = null;
    }
    
    // Update is called once per frame
    void Update()
    {
        if( updated )
        {
            if( textArea != null ){
                if( pooledString != null ){
                    textArea.text = pooledString;
                }
                Canvas.ForceUpdateCanvases();
            }
            updated = false;
        }
    }
}

テキスト領域に書く処理を追加する

あとは先ほどログに出力テストを行った文字を、先ほどの3番目の画面に渡すだけです。

QueryUseWindow.cs

string str = context.itemContext.itemOperationString[ context.itemContext.itemOperation ] +
    " " +
    context.itemContext.itembag[ context.itemContext.itemId ].name;

// Debug.Log(gameObject + ".OnSubmit: " + str);
if( textWindow != null ){
    textWindow.Write(str);
}

その他

ファイルの分割

ここまではListItemWindow と QueryUseWIndow は同じイベントに反応するので、同一ファイルで実装していましたが、ある程度実装が落ち着いたので、ListItemWindow と QueryUseWindow を分離しました。

ファイルを分割する時に注意する点は、Inspectorウィンドウで付けていた参照が外れる事です。新しいスクリプトを追加した後で、Inspectorウィンドウでそれぞれの参照を確認します。(以下省略)

ファイル分割時に発覚したタイミング関係の不具合修正

Contextを参照するGetItem、GetItemMaxのnullチェックが甘かったため、nullチェックを追加しました。(以下省略)

次回やること

コンテキストにステータス情報を追加して、アイテムを使用した時にステータスが変化する処理を実装します。