ゲーム化!tomo_manaのブログ

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

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

Unity学習#16 (Unity 2019.4.1f1) リストにカーソル矢印を追加する

今回は第14回で作成したリストにカーソル(今どの項目が選択されているか)を追加します。(カーソルという呼び方が一般的かどうか分かりません)
ボタンを使ったリストの作り方は、サイトを見つけられたのですが、パネルでカーソルを実現するサイトが見つけられませんでした。基本的な方法はボタンを使った場合と同じですが、一部自作する必要があります。

カーソルの表示は、リストの子要素が選択された時の画像にカーソルを追加することで実現することにします。また、選択・非選択時の画像切り替えに、EventSystemのSetSelectedGameObject() を利用することにします。

カーソル画像の作成

Panel に使われている画像(background)を使用して、カーソル用の画像を作れないかと考えたのですが、この画像はUnity独自の形式のようで、使い方が分かりません。カーソル用の画像は一から自作することにしました。

画像の準備

(1) カーソル表示のために、2枚の画像を作ります。
以下の画像はWindows標準のペイントソフトで作りました。今回は動きを確認するために枠を付けています。図の黒塗部はこの後、透明色にします。
(図は32x32ドット。画像は拡大しています)

(2) 透明色を設定します
以下のサイトで黒塗部を透明色に変えました。
WEBブラウザ上で簡単に透過PNG画像を作成できるツール | 無料で画像を加工できるサイト PEKO STEP

(3) 画像をUnity に読み込みます
Unity画面 > Project > Assetフォルダにドラッグ&ドロップ

画像の拡大・縮小の設定

画像を拡大・縮小するための設定をUnity上で行います。
(1) Projectウィンドウで、先ほど追加した画像を選択
(2) Inspectorウィンドウから、Sprite Editor をクリック

f:id:tomo_mana:20200917211259p:plain
画像 > Inspector > Sprite Editor

(3) Sprite Editorで、画像の拡張・縮小する領域を設定します(Unityリファレンスでは「9スライス」と呼ぶようです)
L:Left, R:Right, T:Top, B:Bottom
(4) Apply をクリック

図は設定中の画面です。白に透明色だと枠の境界線が分かりにくいので、図ではオレンジ色にしています。
f:id:tomo_mana:20200917212031p:plain f:id:tomo_mana:20200917212104p:plain

※今回は枠内を透明色にしていますが、選択時と非選択時の背景色を塗り分けるのもよさそうです。

ここまでについて、以下のサイトを参考にしました。
negi-lab.blog.jp

キーの通知(Navigate)

キーの通知は、第13回で作成した Player Input 用のゲームオブジェクトを使って、PlayerInput → ScrollView → Content に通知します。リストの何番が選択されているかを、Content が把握するようにします(後述)。

Player Input のゲームオブジェクトに付ける C#スクリプト

今回のテスト用に以下のコードを作成します。

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

public class UITest : MonoBehaviour
{
    // 一番最初のメニュー
    [SerializeField] ScrollViewController scrollView;
    
    // 新Input System (入力イベントで取得)
    void OnNavigate(InputValue value)
    {
        // ScrollViewに付けた自作のNavigate関数をコールする
        scrollView.Navigate(value.Get<Vector2>());
    }
}

(1) Hierarchyウィンドウから、Player Input を付けたオブジェクトを選択
(2) Inspectorウィンドウに、上記C#スクリプトをドラッグ&ドロップ
(3) "Scroll View" に、「ScrollView の C#スクリプト」を付けたゲームオブジェクトをドラッグ&ドロップ

ScrollView の C#スクリプト

ScrollView の C#スクリプト は、Player Input からのイベントを受けた時にコールされる Navigate() を実装します。選択方向は Vector2 で受け取ります。

public class ScrollViewController : MonoBehaviour
{
    private ContentManager content;
    
    void Start()
    {
        // Scroll scrollView -> ViewPort -> Content
        content = gameObject.transform.GetChild(0).GetChild(0).GetComponent<ContentManager>();
    }
    
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        // Contentに付けた自作のNavigate関数をコールする
        // ここはちょっとまどろっこしい
        content.Navigate(value);
    }
}

Content の C#スクリプト

さらに ScrollView の孫オブジェクトである Content には、ScrollView からコールされる Navigate() を実装します。ここでは、通知がされたことを確認するログ出力だけ入れています(この後、この部分に処理を追加していきます)

using UnityEngine.EventSystems;

public class ContentManager : MonoBehaviour
{
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        Debug.Log("Navigate" + value);
    }
}

要素の選択

今どの要素が選択されているかを、Content に持たせます。SetContent() は、第14回に追加した処理です。

public class ContentManager : MonoBehaviour
{
    // 現在選択されているリストの要素番号
    private int itemSelectedNo;
    
    // リストのコンテンツを設定する
    public void SetContent(string[] str)
    {
        itemSelectedNo = 0;
        Debug.Log(itemSelectedNo + " is Selected");
    }
    
    // 上下キーを押された時の挙動
    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;
            }
        }
        Debug.Log(itemSelectedNo + " is Selected");
    }
}

選択・非選択状態の切り替え

どの要素を選択するかは、Content が行います。しかし、選択→非選択への切り替えは Content でなくリストの子要素側でやって欲しいと思います。そこで、EventSystem の SetSelectedGameObject() を使用して、選択されたらOnSelect()、選択が解除されたらOnDeselect()が呼ばれるようにします。OnSelect()、OnDeselect() を使うには、UnityEngine.EventSystems の ISelectHandler と IDeselectHandler を実装します。

リストの子要素(プレハブ)

リストの子要素は、選択された時の画像をSerializeFieldとして持ち、選択されていない時の画像と選択された時の画像を切り替える処理だけを実装します。選択されていない時の画像は、Panel を追加した時に持っている Image クラスの Source Image を使用します。この処理はかなり一般的な処理になったので、クラス名もリスト専用でなく、一般的な名称にしました。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // Image
using UnityEngine.EventSystems; // ISelectHandler, IDeselectHandler

public class ChangeSprite : MonoBehaviour, ISelectHandler, IDeselectHandler
{
    // 選択時のSprite
    [SerializeField] Sprite sourceImageSelected;
    // 非選択時のSprite(Imageクラスの初期値を保持)
    private Sprite sourceImageDefault;

    private Image image;
    private bool selected;
    
    // Start is called before the first frame update
    void Start()
    {
        image = gameObject.GetComponent<Image>();
        sourceImageDefault = image.sprite;
        selected = false;
    }
    
    public void OnSelect(BaseEventData eventData)
    {
        Debug.Log("OnSelect");
        if( sourceImageSelected ){
            selected = true;
            image.sprite = sourceImageSelected;
        }
    }
    
    public void OnDeselect(BaseEventData eventData)
    {
        Debug.Log("OnDeselect");
        if( selected ){
            image.sprite = sourceImageDefault;
            selected = false;
        }
    }
}

リストの子要素は、第14回でプレハブにしたので、プレハブに上記コードを取り付けます。
(1) Projectウィンドウから、リストの子要素のプレハブをダブルクリック
(2) Hierarchyウィンドウから、リストの子要素の一番上のゲームオブジェクトを選択
(3) Inspectorウィンドウに、上記コードをドラッグ&ドロップ
(4) "Source Image Selected" に、先ほど作成した選択時の画像をドラッグ&ドロップ
(5) Imageコンポーネント の "Source Image" に、先ほど作成した非選択時の画像をドラッグ&ドロップ

f:id:tomo_mana:20200917222902p:plain
リスト子要素プレハブへのスクリプトと画像の追加

Content

Content からは、先ほどのプレハブから作られたリストの子要素を、EventSystem に投げます。

using UnityEngine.EventSystems;

public class ContentManager : MonoBehaviour
{
    // 表示用のリストの子要素 GameObject のプール(以前に作成したもの)
    private List<GameObject> itemPool;
    
    private int itemSelectedNo = 0;
    private EventSystem eventSystem;
    
    void Start()
    {
        eventSystem = EventSystem.current;
    }
    // 上下キーを押された時の挙動
    public void Navigate(Vector2 value)
    {
        Debug.Log("Navigate" + value);
        eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
        // 上記関数内で OnDeselect(), OnSelect() がコールされます
    }
}

※将来的にandroidiphoneなどのタッチパッド系に処理を変える時には、より積極的に EventSystem を使うことになるので、今のタイミングで EventSystem を活用するのは悪くない選択ではないかと思います。

既存コードとの結合

前回までのコードと結合します(第14回参考)

ScrollView

コード

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

public class ScrollViewController : MonoBehaviour
{
    [SerializeField] string[] itemString;
    private ContentManager content;
    
    // Start is called before the first frame update
    void Start()
    {
        content = gameObject.transform.GetChild(0).GetChild(0).GetComponent<ContentManager>();
        
        // テスト
        Enable();
    }
    
    void Enable()
    {
        content.SetContent( itemString );
        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 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 bool initf;
    
    // 現在選択されているリストの要素番号
    private int itemSelectedNo;
    
    // リストの子要素への選択・非選択通知
    private EventSystem eventSystem;
    
    // アイテムセット
    public void SetContent(string[] str)
    {
        itemString = str;
        
        max = itemString.Length;
        if( max > itemMax ){
            max = itemMax;
        }
        
        if( initf == false ){
            Start();
        }
        
        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 = itemString[i];
            
            // オブジェクトを親に登録するためにも、Transform を使用する
            itemPool[i].transform.SetParent( gameObject.transform, false );
        }
        
        itemSelectedNo = 0;
        eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
    }
    
    // アイテムサイズ取得
    public Vector2 GetContentSize()
    {
        RectTransform rectTransform = itemPool[0].transform as RectTransform;
        
        return new Vector2(
            rectTransform.rect.width,
            rectTransform.rect.height * max
        );
    }
    
    // Start is called before the first frame update
    void Start()
    {
        if( initf == true ){
            return;
        }
        
        // リスト表示数を制限
        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 );
        }
        
        initf = true;
        
        eventSystem = EventSystem.current;
    }
    
    // disable で Pool を解放
    void Disable()
    {
        // ウィンドウの子要素を開放する
        gameObject.transform.DetachChildren();
    }
    
    // 上下キーを押された時の挙動
    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;
            }
        }
        Debug.Log(itemSelectedNo + " is Selected");
        
        eventSystem.SetSelectedGameObject( itemPool[itemSelectedNo] );
        // 上記関数内で OnDeselect(), OnSelect() がコールされます
    }
}

リストにカーソルが追加されて、上下キーの入力でカーソルが移動するようになりました!

f:id:tomo_mana:20200917225609p:plain
出力テスト

次回やること

リストをメニューの階層化(第13回)と結合する(Submitで次のリストを表示する)