teriyaki note

好きなものはラーメンと将棋

Unityで脱出ゲームを作る part.1 / アイテムリスト編

< 前回 | 一覧 | 次回 >

前回でUnity の使い方を練習をしたので、今回から本格的に脱出ゲーム製作に取り組んでいきたいと思う。

脱出ゲームといえば、部屋の中を探索し、何かしらのアイテム(メモなど)を入手し、それをヒントに謎を解いていくのが一般的な流れだと思う。 ということは当然、見つけたアイテムを保存しておくためのアイテムリストが必要になるだろう。

今回はそのアイテムリストを作ってみたいと思う。

成果物 (Unity 2018.2.11f1)

f:id:teriyaki398:20181009123517g:plain

上のように、

  • 拾ったアイテムを追加
  • アイテムリスト内のアイテムを選択
  • (使用するなどで) アイテムを消去できる

といった機能を実装したアイテムリストを作ってみた。

今回の内容はGitHubに公開してるので、ご自由にどうぞ。

github.com

参考にしたもの

Unityで脱出ゲームの作り方(7)「アイテムリストを作る」 | 閃光絵巻ラボ

Unityで脱出ゲームの作り方(5)「3Dオブジェクトをクリックで取得」 | 閃光絵巻ラボ

【Unity】uGUIのCanvasとRenderModeについて

カメラの分割

ゲーム画面とアイテムリストの画面を分割して表示したい。

とりあえず画面を 16:9 に設定しておく。

f:id:teriyaki398:20181005233954p:plain:w550

本格的な脱出ゲームを作ろうと思うと、アイテム数も自然と増えてしまうだろう。

この辺はお好みだが、ゲーム画面 14:9、アイテムリスト画面 2:9 になるように分割し、アイテムを2列で表示できるぐらいの余裕を持たせてみる。

まずはアイテムリストを描画するカメラ(ItemListCamera)を用意し、
インスペクタを色々いじる。

  • ゲーム画面が映らないように関係ない場所に移す
  • Clear FlagsSolid Colorに変更して適当に色を変える
  • ViewPoint RectX = 0.875に、W = 0.125に設定する
  • Culling MaskUIのみに変更する

f:id:teriyaki398:20181006001114p:plain:w350

次に、Main Camera のインスペクタから、ViewPoint RectW = 0.875に設定する。

f:id:teriyaki398:20181005234423p:plain:w350

これで以下のように画面が分割されていれば成功。

f:id:teriyaki398:20181006000502p:plain:w550

ボタンを並べてアイテムリストを作る

今回は、アイテムをクリックできるように、アイテムリストにはボタンを並べてみたいと思う

Create > UI > CanvasからCanvasを生成し、名前をItemListCanvasに変更する。

さらに、インスペクタからCanvasコンポーネントRender ModeScreen Space - Cameraに変更し、先ほど生成したItemListCameraRender Cameraにアタッチする。

f:id:teriyaki398:20181006001244p:plain:w350

いよいよCanvas にボタンを並べていくのだが、Unity にはUIを並べるための便利機能としてVertical Layout Groupが存在しているので、これを使ってみよう。

(似たような機能でGrid Layout Groupがあるが、要素のサイズを指定する必要がありそうだったので、今回は要素のサイズを動的に動かせそうなVertical Layout Groupを使う)

ItemListCanvas直下に空のオブジェクトを生成し、名前をItemVerticalに変更。

そして、Add Componentボタンをクリックして、Vertical Layout Groupというコンポーネントを追加する。

  • Stretchからサイズをキャンバスに合わせる(画像の青い矢印部分)
  • ScaleからX = 0.5に設定
  • PivotからX = 0に設定
  • LeftTop..などを全て0にする
  • Child Controls Sizeに全てチェックをつける

f:id:teriyaki398:20181006005401p:plain:w550

これで準備完了。

ItemVerticalを右クリック、UI > Buttonからボタンを生成し、生成したボタンをコピーペーストで複製していく

f:id:teriyaki398:20181006010827p:plain:w550

ボタンを8つほど生成してみたが、きちんと並んでいるのが分かる。

ここまでの操作で左側のボタンを並べることができたので、右側のボタンも同じ要領で生成できそうだ。

f:id:teriyaki398:20181008204145p:plain:w550

全てのボタンのテキストを消し、画像を変更してそれっぽくした。
今後のために、番号のついていないボタンButtonの名前をButton (0)に変えておく。

Tips

次のような手順が簡単かもしれない

  1. ItemVerticalをコピー(先ほどのボタンは全て消しておく)
  2. ItemListCanvas直下にボタンを16個コピペで作る
  3. 8つずつ分けてItemVerticalに移動する
  4. 片方のItemVerticalPivot X = 1に変更して、Rect TransformTop ...を全て0にする

スクリプトからアイテムリストのボタンであることを識別できるように、ボタンにTagをつけておく。

f:id:teriyaki398:20181008204344p:plain:w350

Add Tag...ボタンから新しいタグを登録し、全てのボタンに割り当てた。
今回はItemListButtonという名前で登録してみた。

最後に、今回実装する機能を試すためのRemoveButtonといくつかのオブジェクトを作成して、下のように配置する。

f:id:teriyaki398:20181009113854p:plain:w550

画像の準備

今回の実装では、アイテムリストには対応する画像を表示する方針で行こうと思う。

さらに、実際のゲームでは「アイテムを選択して使用する」という操作を盛り込みたいので、
アイテムが選択された状態を表す画像も用意し、Resources/Imageフォルダに置く。

f:id:teriyaki398:20181009114545p:plain

no_image 何も割り当てらていないときの画像
WhiteBall WhiteBall というオブジェクトに対応する画像
SelectedWhiteBall WhiteBallが選択された状態を表す画像

という対応になっている。

また、WhiteBallBlueBallRedBallという名前は、スクリプトから処理しやすいようにゲーム画面に配置されているオブジェクトの名前と一致させている。

Tips

全ての画像をSprite (2D and UI)に変更しておくことを忘れないように

アイテムリストを操作するクラス

さて、アイテムクラスを操作するクラスItemListクラスを書いてみよう。

ItemListクラスに持たせたい機能は以下が考えられる。

  • アイテムを追加する
  • アイテムを削除する
  • アイテムの選択 / 非選択 状態を切り替える

以下が具体的なコード。

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

public class ItemList{

    // Singleton
    private static ItemList itemList = new ItemList();

    // アイテムボタンオブジェクトのリスト
    public List<GameObject> btnList = new List<GameObject>();

    // 選択状態にあるアイテムの番号を保存する
    public int selectedItemId = -1;
    
    // 選択されている状態を示すキーワード
    public string selectedSymbol = "Selected";

    // 割り当て無し状態の画像名
    public string emptySymbol = "no_image";

    private ItemList(){
        // 存在するボタンをリストに入れる
        int i = 0;
        while(GameObject.Find("Button (" + i.ToString() + ")") != null){
            btnList.Add(GameObject.Find("Button (" + i.ToString() + ")"));
            i++;
        }
    }

    public static ItemList getInstance(){
        return itemList;
    }


    // アイテムをリストに追加する
    public void add(string item_name){

        for(int i=0; i<btnList.Count; i++){
            string im_name = btnList[i].GetComponent<Image>().sprite.name;
            if(im_name == emptySymbol){
                btnList[i].GetComponent<Image>().sprite = Resources.Load("Image/" + item_name, typeof(Sprite)) as Sprite;
                break;
            }
        }

    }

    // リスト内の選択されているアイテムを削除する
    public void removeSelectedItem(){
        
        if(selectedItemId == -1){  // 何も選択されていないなら何もしない
            return;

        } else if (selectedItemId == btnList.Count - 1){   // 一番最後のボタンなら最後だけ変える
            btnList[selectedItemId].GetComponent<Image>().sprite = Resources.Load("Image/" + emptySymbol, typeof(Sprite)) as Sprite;
            selectedItemId = -1;

        } else {    // 途中だけ取りのぞく時
            // num番目のボタンから順番に、次のボタンの画像を割り当てる
            for(int i=selectedItemId; i<btnList.Count - 1; i++){
                btnList[i].GetComponent<Image>().sprite = btnList[i+1].GetComponent<Image>().sprite;
                selectedItemId = -1;

            }
        }
        
    }

    // アイテムリストを選択したときの処理
    public void click(GameObject btnObject){
        
        string im_name = btnObject.GetComponent<Image>().sprite.name;
        // 選択されたボタンの番号を取得
        int id = int.Parse(btnObject.name.Substring("Button (".Length, btnObject.name.Length - "Button ()".Length));

        // 既に選択状態なら選択状態を解除する
        if(id == selectedItemId){

            im_name = im_name.Substring(selectedSymbol.Length);
            btnObject.GetComponent<Image>().sprite = Resources.Load("Image/" + im_name, typeof(Sprite)) as Sprite;
            selectedItemId = -1;

        } else if(im_name != emptySymbol){   // 何かのアイテムが割り当てられていたら

            // 他に選択状態のアイテムがあるなら非選択状態に変更
            if(selectedItemId != -1){
                string temp = btnList[selectedItemId].GetComponent<Image>().sprite.name.Substring(selectedSymbol.Length);
                btnList[selectedItemId].GetComponent<Image>().sprite = Resources.Load("Image/" + temp, typeof(Sprite)) as Sprite;
                selectedItemId = -1;
            }

            // 選択状態にする
            btnObject.GetComponent<Image>().sprite = Resources.Load("Image/" + selectedSymbol + im_name ,typeof(Sprite)) as Sprite;
            selectedItemId = id;
        }

    }

}

冗長な処理をしているかもしれないが、、、

一つずつ読み取っていこう。

// Singleton
private static ItemList itemList = new ItemList();

// アイテムボタンオブジェクトのリスト
public List<GameObject> btnList = new List<GameObject>();

// 選択状態にあるアイテムの番号を保存する
public int selectedItemId = -1;

// 選択されている状態を示すキーワード
public string selectedSymbol = "Selected";

// 割り当て無し状態の画像名
public string emptySymbol = "no_image";

今後、ボタンの数などは変わる可能性があるので、C#のリスト型を使ってみた。

コメントの通りなのだが、

itemList このクラス唯一のインスタンスを入れる
btnList アイテムリストのオブジェクトが格納される
selectedItemId 選択状態にあるアイテムボタンの番号を保存する。何も選択されていないときは-1
selectedSymbol 選択状態の画像に付けている先頭文字列
emptySymbol 何も割り当てられていないボタンに貼り付ける画像名

今回の設計では、アイテムリストは一つしかないのでインスタンスが複数あると色々と面倒。
インスタンスが1つしか存在しないことを示すために、Singletonパターンというものを使ってみた。

詳しくは以下を参照。

JavaのSingletonデザインパターン - Qiita

private ItemList(){
    // 存在するボタンをリストに入れる
    int i = 0;
    while(GameObject.Find("Button (" + i.ToString() + ")") != null){
        btnList.Add(GameObject.Find("Button (" + i.ToString() + ")"));
        i++;
    }
}

コンストラクタの部分

  • ゲーム画面からButton (x)という名前のオブジェクトを探し、存在していたらリストに追加する
  • 順番にリストに追加していき、最後まで追加したらループを抜ける
public static ItemList getInstance(){
    return itemList;
}

他のクラスからnewができなくなっているので、インスタンスを取得したいときはこれを呼び出す。

// アイテムをリストに追加する
public void add(string item_name){

    for(int i=0; i<btnList.Count; i++){
        string im_name = btnList[i].GetComponent<Image>().sprite.name;
        if(im_name == emptySymbol){
            btnList[i].GetComponent<Image>().sprite = Resources.Load("Image/" + item_name, typeof(Sprite)) as Sprite;
            break;
        }
    }

}

addメソッドは、アイテムリストに新たなアイテムを追加する操作を担っている。

  • 引数として追加するアイテムの名前を受け取る
  • まだアイテムが割り当てていないボタンを探す
  • ボタンを見つけたらResources/Imageから画像を読み出して変更する

Tips

breakを忘れると、全てのボタンの画像を変更してしまうので注意しよう。

// リスト内の選択されているアイテムを削除する
public void removeSelectedItem(){
    
    if(selectedItemId == -1){  // 何も選択されていないなら何もしない
        return;

    } else if (selectedItemId == btnList.Count - 1){   // 一番最後のボタンなら最後だけ変える
        btnList[selectedItemId].GetComponent<Image>().sprite = Resources.Load("Image/" + emptySymbol, typeof(Sprite)) as Sprite;
        selectedItemId = -1;

    } else {    // 途中だけ取りのぞく時
        // num番目のボタンから順番に、次のボタンの画像を割り当てる
        for(int i=selectedItemId; i<btnList.Count - 1; i++){
            btnList[i].GetComponent<Image>().sprite = btnList[i+1].GetComponent<Image>().sprite;
            selectedItemId = -1;

        }
    }
    
}

removeSelectedItemメソッドは、選択状態にあるアイテムをアイテムリストから削除する。

リストの途中のアイテムを削除するときは、マスを詰める必要があることに注意しよう。

  • 選択状態のアイテムの番号で分岐
  • numの値で分岐
    • -1 なら何も選択されていないので何もしない
    • 最後のボタン番号 なら最後のボタンだけ変える
    • 途中のボタン番号 ならそれ以降全てのボタンを一つ次のボタンの画像に変更
// アイテムリストを選択したときの処理
public void click(GameObject btnObject){
    
    string im_name = btnObject.GetComponent<Image>().sprite.name;
    // 選択されたボタンの番号を取得
    int id = int.Parse(btnObject.name.Substring("Button (".Length, btnObject.name.Length - "Button ()".Length));

    // 既に選択状態なら選択状態を解除する
    if(id == selectedItemId){

        im_name = im_name.Substring(selectedSymbol.Length);
        btnObject.GetComponent<Image>().sprite = Resources.Load("Image/" + im_name, typeof(Sprite)) as Sprite;
        selectedItemId = -1;

    } else if(im_name != emptySymbol){   // 何かのアイテムが割り当てられていたら

        // 他に選択状態のアイテムがあるなら非選択状態に変更
        if(selectedItemId != -1){
            string temp = btnList[selectedItemId].GetComponent<Image>().sprite.name.Substring(selectedSymbol.Length);
            btnList[selectedItemId].GetComponent<Image>().sprite = Resources.Load("Image/" + temp, typeof(Sprite)) as Sprite;
            selectedItemId = -1;
        }

        // 選択状態にする
        btnObject.GetComponent<Image>().sprite = Resources.Load("Image/" + selectedSymbol + im_name ,typeof(Sprite)) as Sprite;
        selectedItemId = id;
    }

}

clickメソッドは、アイテムリストのボタンがクリックされた時の処理。

といってもただ、アイテムの 選択 / 非選択 状態を切り替えるだけ。

注意したいのが、何もないボタンをクリックした時には何もしない必要があるので、im_name != emptySymbolで何かが割り当てられていることを確認している。

  • クリックされたアイテムのボタンGameObjectを引数として受け取る
  • クリックされたアイテムが選択状態なら、Selectedを抜いた名前の画像に貼り替える
  • ボタンに何かが割り当てられていたら、クリックされたボタンの画像をSelectedを付けた画像に貼り替える

Tips

アイテムが割り当てられていないボタンがクリックされたときには何もしない必要があるため、im_name != emptySymbolで回避している。

アイテムを選択状態にするときに、すでに他のアイテムが選択状態にあるならそれを解除する必要がある

UI関連の操作を行うときはusing UnityEngine.UI;が必要なので注意しよう。

ゲーム側の操作

アイテムリストを操作する準備ができたので、ItemListクラスを利用してみよう。

前回と同じように、GameManagerTestManager.csをアタッチする。

具体的なスクリプトは以下。

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

public class TestManager : MonoBehaviour {

    public EventSystem eventSystem;

    // カメラ関係
    public Camera mainCamera;

    // ray関係
    public Ray ray;
    public RaycastHit hit;
    public GameObject selectedGameObject;

    // ゲームスクリプト
    public ItemList itemList;
    
    // Use this for initialization
    void Start () {
        eventSystem = GameObject.Find("EventSystem").GetComponent<EventSystem>();
        mainCamera = GameObject.Find("Main Camera").GetComponent<Camera>();
        itemList = ItemList.getInstance();
    }
    
    // Update is called once per frame
    void Update () {
        if(Input.GetMouseButtonUp(0)){
            selectedGameObject = eventSystem.currentSelectedGameObject;
            if(selectedGameObject == null){
                searchRoom();
            } else if(selectedGameObject.tag == "ItemListButton"){
                itemList.click(selectedGameObject);
            } else if(selectedGameObject.name == "RemoveButton"){
                itemList.removeSelectedItem();
            }
        }
    }

    public void searchRoom(){
        selectedGameObject = null;
        ray = mainCamera.ScreenPointToRay(Input.mousePosition);
        if(Physics.Raycast(ray, out hit, 10000000, 1 << 10)){

            selectedGameObject = hit.collider.gameObject;
            itemList.add(selectedGameObject.name);
            Destroy(selectedGameObject);

        }
    }

}

こちらはそれほど難しくないだろう。

void Start () {
    eventSystem = GameObject.Find("EventSystem").GetComponent<EventSystem>();
    mainCamera = GameObject.Find("Main Camera").GetComponent<Camera>();
    itemList.init();
}

とりあえずゲームが始まると同時にItemListの初期化メソッドを実行。

void Update () {
    if(Input.GetMouseButtonUp(0)){
        selectedGameObject = eventSystem.currentSelectedGameObject;
        if(selectedGameObject == null){
            searchRoom();
        } else if(selectedGameObject.tag == "ItemListButton"){
            itemList.click(selectedGameObject);
        } else if(selectedGameObject.name == "RemoveButton"){
            itemList.removeSelectedItem();
        }
    }
}
  • ゲーム画面がクリックされたらsearchRoom()を実行
  • アイテムリストのボタンがクリックされたらclickメソッドを呼び出す。(タグ名で判別)
  • RemoveButtonがクリックされたらremoveメソッドを呼び出す。 (オブジェクト名で判別)
public void searchRoom(){
    selectedGameObject = null;
    ray = mainCamera.ScreenPointToRay(Input.mousePosition);
    if(Physics.Raycast(ray, out hit, 10000000, 1 << 10)){

        selectedGameObject = hit.collider.gameObject;
        itemList.add(selectedGameObject.name);
        Destroy(selectedGameObject);

    }
}
  • 取得可能なアイテムをクリックしていたら、アイテムリストに追加
  • ゲーム画面からそのアイテムを削除する

このあたりの処理は以下を参考にした。

Unityで脱出ゲームの作り方(5)「3Dオブジェクトをクリックで取得」 | 閃光絵巻ラボ

これでなんとかアイテムリストが実装できた。

Tips

今回はアイテムが選択されているかどうかを判別するためにリストを用いて、フラグ管理をしていた。

ただ、この実装方法は注意が必要で、フラグの戻し忘れがあったりすると、予期せぬ動作を起こしてしまう可能性がある。

少し動作が重くなってしまうかもしれないが、今回はアイテムの 選択/非選択 を割り当てられている画像の名前 で行う方法があるかもしれない。

今回の例でいうと、ボタンに割り当てられている画像がSelectedから始まっているなら、選択状態だし、そうでないなら非選択状態だと判別する方法である。

このような実装にすることで、プレイヤーが見ている画面とスクリプト内に齟齬が起きにくくすることができる。

ただ、脱出ゲーム程度のサイズのアイテムリストなら問題ないかもしれないが、100や1000といったアイテムを管理したい時には動作が遅くなる(と思っているけどどうなんだろう。。。)

また、今回はint型の変数で選択されているアイテムの番号を管理していたが、「複数のアイテムを選択したい」のような場合にはその数だけ変数を用意するか、List<int>List<bool>のようなリストで管理すると良いだろう。