ゲーム化!tomo_manaのブログ

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

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

Unity学習#13 (Unity 2019.4.1f1) メニューの設計

今回はメニューの基礎部分の設計に挑戦しました。

これまで使用していた「Player」という言葉ですが、Unity上では複数の意味で使われることが分かりました。そのため、今回から、「ゲームをプレイする人」が操作するゲーム上のキャラクターを「操作キャラ」と呼ぶことにしました。また操作キャラの状態やコマンド表示を「操作キャラの状態表示」および「ゲームコマンド」および「メニュー」と呼ぶことにします。

設計課題

最初に、メニューを設計する時の課題についてまとめます。

メニューの追加に当たって検討しないといけないのは、以下の2点です。

1. 「ゲームコマンド」を表示している間は「操作キャラ」を動かさない。
2. 「ゲームコマンド」および「メニュー」も階層化が必要。

つまり、キーを受け付ける対象を、ゲームコマンド や メニュー が表示されている状態に応じて切り替える必要があります。これを実現するために、第12回で使用した Player Input を 操作キャラ の外に置く必要があります。また、ゲームコマンド や メニュー など、複数の「ウィンドウ」のうち、どのウィンドウがキーを受け付ける対象として「選択されている」のかを切り替える必要があります。

状態に応じてどのオブジェクトにキーを渡すかを切り替える手段として、Unity では Event SystemsRayCast があります(2つはセットで使用します)。しかし、今回は Event SystemsRayCast の機能は使用しないことにしました。詳しくは別の記事にしますが、Input Manager/Input SystemEvent Systems ではキーを渡すタイミングに違いがあって、Input Manager/Input System はキーを離した時のイベントを通知できますが、Event Systems はキーを離した時のイベントが処理できません。そのため、キーの受信にEvent Systems を使用すると、操作感の違いを埋める必要があります。やりようはあると思うのですが、時間がかかりそうだったので、一旦 Input System だけで作ることにします。ただ、最低限の移植性は検討します。

Event SystemsCanvas を始めて使う時に実装されます。ここでは、Event Systems が提供するイベントハンドラを使ったキーの受信方法を採用しないという意味です。

参考(EventSystems と RayCast)

Event Systems では、画面でマウスクリックをした時に、その座標上にどれだけのオブジェクトが重なっているかを、手前から奥へと走査する機能 RayCast を使って、オブジェクトの重なりと、その結果どのオブジェクトが選択されたかを計算する機能があります。RayCast はちょうど光線を画面の手前から奥へと発射するイメージで、この光線に当たったオブジェクトがどういう順番に、いくつ当たったか、どれが一番手前かを操作する機能です。この計算のために、RayCast の対象となるオブジェクトにはすべて当たり判定(Collider)を使用します。将来的にタッチパッドiPhoneandroidなど)に対応する時に、この機能が必要になるかもしれませんが、今の段階では冗長なので、今回はその「重なり」を表現する手段としてスタック(Stack)を使用します。

参考(Stack)

スタック(Stack)はUnityに特有の機能というよりは、昔からある画面管理などで使われる単純なロジックです。Unityでも、C#スクリプト上で使用できます。スタックは後から入れたものを先に出すだけの単純なリストです。メニューは一般的に後から表示したものが先に処理されるので、その目的では、Event SystemsRayCast を使わなくても、今選択されている画面の管理が可能です。

キー入力を受け付けるオブジェクトを変更する

第12回で、「操作キャラ」に入力処理(Player Input)を付けました。しかし、「ゲームコマンド」や「メニュー」を操作している間は、「操作キャラ」に入力処理があるのは都合が悪いことが分かりました。「操作キャラ」に Player Input が付いていなくても、他のオブジェクトから Player Input と同じタイミングで同じ処理を入れてあげれば、「操作キャラ」は操作性を損なわずに Player Input だけを「操作キャラ」の外に分離できます。このPlayer Input を引き受けるオブジェクトをEmptyで実現します。

キーマップ

第12回で作成した、以下のActionMap(Input System)を使用します。

f:id:tomo_mana:20200813202821p:plain
InputActions > UI(Submit, Cancel追加版)

キー入力を受け付けるオブジェクトを作成する

(1) Hierarchyウィンドウで右クリック > Create Empty
(2) オブジェクトの名称を変更(Hierarchyウィンドウでオブジェクト選択 > Inspectorウィンドウ上で名称を変更)
(ここでは Input UIにしました。)

キー入力を受け付けるオブジェクトから操作キャラにアクセスさせる

ここは力技ですが、先ほどのEmptyオブジェクトのキーイベント処理から、もともと操作キャラ側に実装されていたキーイベント処理を呼び出します。

キー入力側(コード)

キー入力用のコードは以下のようにします。
コード

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

public class InputUIManager2 : MonoBehaviour
{
    // ターゲットとなる操作キャラ
    [SerializeField] GameObject player;
    private PlayerControl2 playercs;
    
    // キー入力
    private Vector2 input;
    
    // Start is called before the first frame update
    void Start()
    {
        playercs = player.GetComponent<PlayerControl2>();
    }

    // 新Input System (入力イベントで取得)
    void OnNavigate(InputValue value)
    {
        input = value.Get<Vector2>();
        if(playercs != null){
            playercs.Move(input);
        }
    }
    
    // Update is called once per frame
    void Update()
    {
    }
}
操作キャラ側(コード)

操作キャラのコードは以下のように修正しました。
コード

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

public class PlayerControl2 : MonoBehaviour
{
    // キー入力
    private Vector2 input;
    
    // 移動
    private float speed;
    private Rigidbody2D rigidBody;
    
    // アニメーション
    private Vector2 scale;
    private Animator animator;
    
    // Start is called before the first frame update
    void Start()
    {
        speed = 0.1f;
        rigidBody = GetComponent<Rigidbody2D>();
        animator = GetComponent<Animator>();
    }

    // Update is called once per frame
    void Update()
    {
    }
    
    public void Move(Vector2 v)
    {
    	input = v;
    }
    
    void FixedUpdate() {
        // 移動
        if (input == Vector2.zero){
            return;
        }
        rigidBody.position += input * speed;
        
        // キャラクターの左右反転
        scale = this.transform.localScale;
        if( input.x > 0 ){
            scale.x = 1;
        } else if( input.x < 0 ){
            scale.x = -1;
        }
        this.transform.localScale = scale;
        
        // アニメーション方向決定
        animator.SetFloat("dirX", input.x);
        animator.SetFloat("dirY", input.y);
    }
}

Player Input からの入力部(OnMove())を、外部からの入力用のインタフェース(Move())に変更しました(Move関数は自作関数です)。引数にInput.Valueを使わずVector2だけを受け取るようにしたことで、操作キャラ側にはInputSystemのインクルード(using)も不要になりました。

キー入力用のオブジェクトに操作キャラをリンクする

(1) Hierarchyウィンドウから、キー入力用のオブジェクト (Input UI) を選択
(2) Inspectorウィンドウで、C#スクリプトの位置までスクロールする
 キー入力側のコンパイルが完了していたら、操作キャラ側のコードをリンクできる状態になっています。
(3) Hierarchyウィンドウから、操作キャラのゲームオブジェクトを (2) にドラッグ&ドロップ

入力側が操作キャラをわざわざGameObjectで取得して、そこからGetComponent()C#スクリプトを取り出すのは、少し冗長に感じるかもしれません。これは、キー入力の拡張を検討した結果になります。
この部分を考えるために、Player Input について少し調べました。 Player Input は1人のゲームプレイヤーを代表していて、1人用のゲームでは1つだけ存在できます。2人以上のマルチプレイでは、Player Input はプレイ人数分だけ作ります。その場合、Player Input と 操作キャラ は複製できる必要があります。この時、例えば1Pの操作キャラと2Pの操作キャラは、見た目が違うかもしれませんし、もしかすると能力や属性も違うかもしれません。もし上記のようにしておけば、見た目が違う場合は、複製したゲームオブジェクトのAnimatorを変更したら済みます。能力や属性も違うのなら、C#スクリプトのクラス名を Interface に変えると、入力部分を変更することなく、キャラを複製できます。これは、RPGでシーン毎に操作キャラを変える場合や、格闘ゲームで操作キャラを選択するなどの対応をする時に役に立ちます。

操作キャラの Player Input を消す

(1) Hierarchyウィンドウから、操作キャラのゲームオブジェクトを選択
(2) Inspectorウィンドウから、Player Input を見つける
(3) Player Input の名称が表示されている領域で右クリック > Remove Component

メニューを表示する

メニューの表示には、Canvas > Panel を使用します。そのうちに見栄えするデザインにしたいですが、今回は動作重視で行きます。メニューの仕様は以下の通りです。

f:id:tomo_mana:20200813091958p:plain
メニュー表示イメージ

第11回にある通り、基本的な操作は CancelSubmit の2つのボタンにします。
フィールド

キー 機能
十字キー (WASD) キャラクターを動かす
Submit (E) 足元または前方を調べる
話しかける
アイテムを使用する
Cancel (Q) メニューを表示する

メニュー

キー 機能
十字キー (WASD) コマンドやアイテムを選択する
Submit (E) メニューを一つ進める(確定)
Cancel (Q) メニューを一つ戻る(キャンセル)

パネル順序管理の状態遷移

最小状態での順序は以下になります。

f:id:tomo_mana:20200813091709p:plain
メニューの状態遷移

第11回の観察から、最小のRPG要素では、キャラクター1名、ステータスは3要素(体力、攻撃、速さ)、やり込み要素は道具のみのため、道具表示→使う選択(または装備する選択)→効果表示、の3層で表現できそうです。
ただ、今後やり込み要素を追及すると、この階層は深くなります。キャラクターの追加、ステータスの追加、保持する道具の最大値、戦局に影響する道具の細分化(魔法、技など)、その他の次元で必要なパラメータ、など。そのため、この階層を一旦抽象化します。(抽象化=目的の抽出)

f:id:tomo_mana:20200813151849p:plain
パネルの抽象的な状態遷移
f:id:tomo_mana:20200813151924p:plain
パネルの抽象的な状態遷移表

パネルを追加する

(1) Hyerarchyウィンドウで右クリック > Create Empty
(2) Hyerarchyウィンドウで右クリック > Create > UI > Panel で パネルを3枚作成
(3) パネルの名前は、それぞれ"ListItem", "QueryUse", "Description" としました。

f:id:tomo_mana:20200813205144p:plain
パネルの追加

先ほど追加したキー入力(InputUI)と、今回追加したパネル。画像では余分なパネルも追加されています。

パネル順序管理(コード)

コード

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;
    
    // Start is called before the first frame update
    void Start()
    {
        // 操作キャラの初期化
        if(player == null){
            player = GetComponent<PlayerControl2>();
        }
        
        // スタックの初期化
        windows = new Stack<Window>();
        windows.Clear();
    }

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

コードが大きくなる時は、キー入力、キーを受けた時の処理、画面表示は3層に分けることが多いと思いますが、今の段階ではコードが小さいので、キー入力の中にキーを受けた時の処理までまとめて記述します。主に以下の点に注意して作りました。

1. パネルは何層あるか分からなくても良い作りにするので、最初(root)、最後(terminate)、中間(layer)だけを意識します。

2. キャラを操作する状態か、パネルを操作する状態かを区別するのは、最初のスタックであるrootがあるかどうかで見分けます。また、最後のスタックであるterminateがある場合、一旦すべてのスタックを開放します。(パネルの表示・非表示も忘れずに行います)

3. スタックはUnity C#で用意されたインタフェースです。スタックへの追加はPush()、取り出しはPop()、スタックを取り出さずに参照する処理はPeek()を使います。

4. パネルの表示・非表示にはSetActive(bool)、パネルの操作はInput Action Map > UIで定義されたインターフェースに似せたNavigate()を定義しました。

5. パネルは何層あるか分からないので、次のパネルの取り出しは、パネル側の実装にします。パネル側に、Next() インターフェースを実装します。

パネル(コード)

コード

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

public class Window : MonoBehaviour
{
    // 次のウィンドウ
    [SerializeField] Window windowNext;
    
    // Start is called before the first frame update
    void Start()
    {
    }
    
    public void Navigate(Vector2 value){
        // リストの移動
    }
    
    public void SetActive(bool b){
        gameObject.SetActive(b);
    }
    
    public Window Next(){
        // リスト要素毎にNextを持つ。その種類のWindowNextを返す。
        // 今はwindowのテストのため、windowNextをそのまま返す
        return windowNext;
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

今の段階で、パネル側はあまりすることが無く、表示・非表示の切り替えと次のパネルを渡す処理だけを実装しました。

パネルを関連させる

パネルの実装が終わると、Next を要素として持たせることができるので、先ほどのListItemQueryUseDescriptionをその順に設定します。
InputUIroot = ListItem, terminate = Description
ListItemnext = QueryUse
QueryUsenext = Description
Descriptionnext = None

最後に、全てのパネルをデフォルトで非表示にします。
(1) Hierarchyウィンドウから、非表示にするパネルを選択
(2) Inspectorウィンドウで、パネル名の左のチェックを外します。

これで、道具などを表示をするため土台ができました。表示位置は次回以降に調整するため、今回は適当です。以下のキャプチャーでは、イメージを付けるために、パネルにテキストを入れています。

f:id:tomo_mana:20200813210237p:plain
メニューを実際に表示させたところ

後記

新しいInput Systemに関する記事がまだ少ないので、参考のために以下を残しておきます。

Player Input

Player Input コンポーネントは複数のオブジェクトに実装できます。ただ、適用されるのは一番最後に作られたオブジェクトになります。厳密には、後から作られたPlayer Input ほどゼロに近い index が付与され、Input Systemから認知される Player Input は常にindex がゼロのものになります。複数のPlayer Input を使用するには、Player Input Manager が必要です。後述のInput Systemに関するYoutubeがとても分かりやすくためになりました。

参考にしたリンク

Input System
www.youtube.com

Event Systems と RayCast
qiita.com

Stack
gametukurikata.com

次にやること

アイテムのリスト化