teriyaki note

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

Unityで脱出ゲームを作る part.5 / カメラを動かす編

< 前回 | 一覧 | 次回 >

今回はカメラを色々な場所・角度に動かせるようにして、部屋を探索できるようにしたい。

前回の part.5 / モノを動かす編 でもカメラを少しだけ動かしてみたが、その場で90度ずつ回転するだけで物足りない。

また、スクリプトに移動する場所などを直接書き込んでいたが、探索させたい場所が増えると管理するのが大変になりそう。ということで、今回はCSVファイルを外部に用意し、それを読み込んで移動できるようにしたい。

成果物

その場で回転に加えて、移動も可能に!
f:id:teriyaki398:20181115151338g:plain:w550

部屋の隅まで探索することができる!
f:id:teriyaki398:20181115151442g:plain:w550

使用したアセットは前回と同じ。

今回のスクリプト・画像ファイルなどはGitHubに公開している。

github.com

準備

まず、部屋を移動するためのトリガーには二種類ある。

  1. 画面に表示されているUIをクリックする
  2. 部屋の移動できそうな場所をクリックする

そのため、UIをクリックした場合の処理と、場所をクリックしたときの処理を分けて書く必要がある。

1 のUIをクリックした場合は、前回書いたものをアップデートすることでなんとかなりそう。

ただ、2. の移動できそうな場所をクリックして移動させるには、前回ドアを動かしたときのように、トリガーとなるオブジェクトを配置してやる必要がある。

今回は

  • スタート地点
  • 窓のそば
  • ソファーの裏側

という三つの位置間を移動できるようにしたい。

イメージ的にはこんな感じ
f:id:teriyaki398:20181115160455p:plain:w400

また、スタート地点と窓のそば地点では、東西南北の4方向に回転できるようにしたい。

ということでトリガーとなるオブジェクトを次のように配置した。

窓のそば(BesideTheWindow 地点)の北向き(N)に移動するためのトリガーオブジェクト
f:id:teriyaki398:20181115155151p:plain:w500

窓のそばからスタート(Start 地点)の南向き(S)に移動するためのトリガーオブジェクト f:id:teriyaki398:20181115155147p:plain:w500

窓のそばからソファーの裏(BehindTheSofa 地点)に移動するためのトリガーオブジェクト f:id:teriyaki398:20181115155155p:plain:w500

また、移動のトリガーオブジェクトを識別するために専用のレイヤー(11 : ShiftLocation)を用意して、先ほどのオブジェクトに割り当てた。

f:id:teriyaki398:20181115161001p:plain:w450

また、左右に加えて、上下のボタンUIも用意した。

f:id:teriyaki398:20181115164543p:plain:w500

f:id:teriyaki398:20181115164548p:plain:w500

CSVファイルの用意

CSVファイルというのはコンマ(",")区切りのデータのこと。

別に何を使ってもいいのだが、Excel で編集してCSV形式で保存した。

f:id:teriyaki398:20181115163659p:plain:w500

各行に移動させたい場所に関するデータを記述している。

左から順に

  • ポジションの名前
  • x座標
  • y座標
  • z座標
  • x回転座標
  • y回転座標
  • z回転座標
  • 左ボタンを押した時に遷移するポジション名
  • 上ボタンを押した時に遷移するポジション名
  • 右ボタンを押した時に遷移するポジション名
  • 下ボタンを押した時に遷移するポジション名

例えばスタート地点の北向きなら

ポジション名 StartN
x座標 -3.58
y座標 2.88
z座標 0
x回転座標 0
y回転座標 90
z回転座標 0
左ボタン StartW
上ボタン none
右ボタン StartE
下ボタン none

という感じ。

とりあえず sample.csv という名前で Resources/Camera/ 直下に保存しておく。

スクリプト

今回のデモに関係するスクリプトは以下の2つ。

クラス 役割
CameraController カメラの移動などを管理する
TestManager ゲーム画面を管理する

CameraController クラス

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


public class CameraController {
    // Singleton
    private static CameraController cameraController = new CameraController();

    // GameObjects
    public GameObject mainCamera;
    public GameObject leftButton;
    public GameObject upButton;
    public GameObject rightButton;
    public GameObject downButton;
    
    // ポジションを格納する辞書の辞書
    public Dictionary<string, Dictionary<string, string>> cameraPos = new Dictionary<string, Dictionary<string, string>>();

    // 最初のポジション
    string currentPosName;


    public static CameraController getInstance(){
        return cameraController;
    }


    // コンストラクタ
    private CameraController(){
        // カメラ
        mainCamera = GameObject.Find("Main Camera");
        
        // 上下左右のボタン
        leftButton  = GameObject.Find("LeftButton");
        upButton    = GameObject.Find("UpButton");
        rightButton = GameObject.Find("RightButton");
        downButton  = GameObject.Find("DownButton");

        // CSVファイルをロード
        loadCSV();
        // 最初は "startN" から
        currentPosName = "StartN";
        // UIの表示をリセット
        resetUI();
        
    }


    // UIがクリックされた時に呼ばれる
    public void clickUI(GameObject clickedUI){

        switch(clickedUI.name){
            case "LeftButton":
                moveCamera(cameraPos[currentPosName]["left"]);
                currentPosName = cameraPos[currentPosName]["left"];
                break;

            case "UpButton":
                moveCamera(cameraPos[currentPosName]["up"]);
                currentPosName = cameraPos[currentPosName]["up"];
                break;

            case "RightButton":
                moveCamera(cameraPos[currentPosName]["right"]);
                currentPosName = cameraPos[currentPosName]["right"];
                break;
            
            case "DownButton":
                moveCamera(cameraPos[currentPosName]["down"]);
                currentPosName = cameraPos[currentPosName]["down"];
                break;
        }

        // 遷移を行ったらUIをリセットする
        resetUI();
    }

    // トリガーオブジェクトがクリックされた時に呼ばれる
    public void clickObject(GameObject clickedObject){

        string next = clickedObject.name;
        moveCamera(next);
        currentPosName = next;

        resetUI();
    }


    // CSVファイルを読み込んで移動できるポジションリストを作る
    public void loadCSV(){
        // CSVを読み込むための変数もろもろ
        TextAsset file = Resources.Load("Camera/sample") as TextAsset;
        StringReader reader = new StringReader(file.text);

        // 読み込み部分
        while(reader.Peek() > -1){
            string line = reader.ReadLine();
            string[] data = line.Split(',');

            Dictionary<string, string> pos = new Dictionary<string, string>();
            pos["pos_name"] = data[0];
            pos["x"]        = data[1];
            pos["y"]        = data[2];
            pos["z"]        = data[3];
            pos["rot_x"]    = data[4];
            pos["rot_y"]    = data[5];
            pos["rot_z"]    = data[6];
            pos["left"]     = data[7];
            pos["up"]       = data[8];
            pos["right"]    = data[9];
            pos["down"]     = data[10];

            cameraPos[data[0]] = pos;
        }
    }


    // カメラを指定したポジション名に動かす
    public void moveCamera(string next){

        iTween.MoveTo(mainCamera, iTween.Hash(
            "x", float.Parse(cameraPos[next]["x"]),
            "y", float.Parse(cameraPos[next]["y"]),
            "z", float.Parse(cameraPos[next]["z"]),
            "time", 0.6f
        ));
        
        iTween.RotateTo(mainCamera, iTween.Hash(
            "x", float.Parse(cameraPos[next]["rot_x"]),
            "y", float.Parse(cameraPos[next]["rot_y"]),
            "z", float.Parse(cameraPos[next]["rot_z"]),
            "time", 0.6f
        ));

    }

    // 現在のポジションに合わせてUIの表示/非表示を切り替える
    public void resetUI(){
        
        if(cameraPos[currentPosName]["left"] != "none"){
            leftButton.SetActive(true);
        } else {
            leftButton.SetActive(false);
        }

        if(cameraPos[currentPosName]["up"] != "none"){
            upButton.SetActive(true);
        } else {
            upButton.SetActive(false);
        }

        if(cameraPos[currentPosName]["right"] != "none"){
            rightButton.SetActive(true);
        } else {
            rightButton.SetActive(false);
        }

        if(cameraPos[currentPosName]["down"] != "none"){
            downButton.SetActive(true);
        } else {
            downButton.SetActive(false);
        }
    }

}

前回から色々追加してあるので一つずつ読んでいく。

メソッド名 役割
clickUI UIがクリックされたときの処理
clickObject ゲーム画面のトリガーオブジェクトがクリックされたときの処理
loadCSV CSVを読み込んで変数に格納する
moveCamera カメラを指定したポジションに移動させる
resetUI UIの表示/非表示を切り替える

Tips

今回は C#辞書型 を用いた実装をする。

辞書型を扱うためには using System.Collections.Generic; が必須。

メンバ変数

// Singleton
private static CameraController cameraController = new CameraController();

// GameObjects
public GameObject mainCamera;
public GameObject leftButton;
public GameObject upButton;
public GameObject rightButton;
public GameObject downButton;

// ポジションを格納する辞書の辞書
public Dictionary<string, Dictionary<string, string>> cameraPos = new Dictionary<string, Dictionary<string, string>>();

// 最初のポジション
string currentPosName;

まず 上下左右のUIを格納しておくための変数(◯◯Button)を用意する。

CSVファイルからデータを読み込んで記録しておくための値に、少しややこしいが、値に辞書をもつ辞書型の cameraPos を用意する。

実際にデータにアクセスする場合は、

cameraPos["ポジション名"]["データ名"]

のようにすればよい。

Tips

今回のように値にfloatstringなどの複数の型をもたせたい場合は、 動的型付け変数のdynamicを使用する方法があるらしい。

ただ、使ってみようとしたらエラーが発生して、めんどくさくて全てstringで実装した。

後から調べてみたら、dynamic.NET Framework 4.0 で追加された機能で、Unityデフォルトでは.NET 3.5が用いられるのでエラーになっていたようだ。 Edit > Project Settings > Player > Configuration > Script Runtime Version から 4.x 環境に変更すれば使えるようになるかもしれない。

コンストラク

// コンストラクタ
private CameraController(){
    // カメラ
    mainCamera = GameObject.Find("Main Camera");
    
    // 上下左右のボタン
    leftButton  = GameObject.Find("LeftButton");
    upButton    = GameObject.Find("UpButton");
    rightButton = GameObject.Find("RightButton");
    downButton  = GameObject.Find("DownButton");

    // CSVファイルをロード
    loadCSV();
    // 最初は "startN" から
    currentPosName = "StartN";
    // UIの表示をリセット
    resetUI();
    
}

コンストラクタ部分。

  • mainCameraleftButtonなど、必要になるGameObjectを各変数に割り当てておく。
  • 後述するメソッドを実行している。
  • ゲームスタート時点ではStartNという場所から始めたいので、現在のポジション名をStartNに変更しておく。

簡単に説明すると、loadCSVメソッドでは先ほど用意したCSVファイルを読み出して、辞書に格納していく操作を行う。

また、resetUIメソッドではそのポジションに応じてUIの表示/非表示を切り替える操作を行う。
例えば、スタート地点では上下のボタンで遷移は行わないので、上下ボタンを非表示にしておく。といった感じ。

奥に進んだときにだけ下ボタンが出現しているのが分かるだろうか。

f:id:teriyaki398:20181115151338g:plain:w500

clickUI メソッド

// UIがクリックされた時に呼ばれる
public void clickUI(GameObject clickedUI){

    switch(clickedUI.name){
        case "LeftButton":
            moveCamera(cameraPos[currentPosName]["left"]);
            currentPosName = cameraPos[currentPosName]["left"];
            break;

        case "UpButton":
            moveCamera(cameraPos[currentPosName]["up"]);
            currentPosName = cameraPos[currentPosName]["up"];
            break;

        case "RightButton":
            moveCamera(cameraPos[currentPosName]["right"]);
            currentPosName = cameraPos[currentPosName]["right"];
            break;
        
        case "DownButton":
            moveCamera(cameraPos[currentPosName]["down"]);
            currentPosName = cameraPos[currentPosName]["down"];
            break;
    }

    // 遷移を行ったらUIをリセットする
    resetUI();
}

ゲーム画面を管理しているスクリプトで、UIのクリックが確認されたらこのメソッドを呼び出す。

現在のポジションから左ボタンを押したときに遷移するポジション名は

cameraPos[currentPosName]["left"]

で取得できる。このように直感的にデータにアクセスできるのが辞書型の強み。

行なっている処理は非常にシンプルで、

  • クリックされたUIに応じて、moveCamera() を実行してカメラを移動させる
  • 同時に現在のポジション名を更新する
  • resetUI() を実行してUIの表示をリセットする

clickObject メソッド

// トリガーオブジェクトがクリックされた時に呼ばれる
public void clickObject(GameObject clickedObject){

    string next = clickedObject.name;
    moveCamera(next);
    currentPosName = next;

    resetUI();
}

こちらはもっと単純。

トリガーオブジェクトの名前がそのまま次に遷移させたいポジション名になっているので、

  • クリックされたオブジェクトの名前を変数に格納
  • moveCamera() でカメラを動かす
  • 現在のポジション名を更新
  • UI の表示を更新

loadCSV メソッド

// CSVファイルを読み込んで移動できるポジションリストを作る
public void loadCSV(){
    // CSVを読み込むための変数もろもろ
    TextAsset file = Resources.Load("Camera/sample") as TextAsset;
    StringReader reader = new StringReader(file.text);

    // 読み込み部分
    while(reader.Peek() > -1){
        string line = reader.ReadLine();
        string[] data = line.Split(',');

        Dictionary<string, string> pos = new Dictionary<string, string>();
        pos["pos_name"] = data[0];
        pos["x"]        = data[1];
        pos["y"]        = data[2];
        pos["z"]        = data[3];
        pos["rot_x"]    = data[4];
        pos["rot_y"]    = data[5];
        pos["rot_z"]    = data[6];
        pos["left"]     = data[7];
        pos["up"]       = data[8];
        pos["right"]    = data[9];
        pos["down"]     = data[10];

        cameraPos[data[0]] = pos;
    }
}

CSVファイルの読み込みに関しては以下を参考にした。

qiita.com

  • コンマ区切りで分割して、辞書を作成。
  • cameraPos に {Key : ポジション名, Value : 作成した辞書} という感じて格納していく

moveCamera メソッド

// カメラを指定したポジション名に動かす
public void moveCamera(string next){

    iTween.MoveTo(mainCamera, iTween.Hash(
        "x", float.Parse(cameraPos[next]["x"]),
        "y", float.Parse(cameraPos[next]["y"]),
        "z", float.Parse(cameraPos[next]["z"]),
        "time", 0.6f
    ));
    
    iTween.RotateTo(mainCamera, iTween.Hash(
        "x", float.Parse(cameraPos[next]["rot_x"]),
        "y", float.Parse(cameraPos[next]["rot_y"]),
        "z", float.Parse(cameraPos[next]["rot_z"]),
        "time", 0.6f
    ));

}

引数として受け取ったポジション名にカメラを遷移させるメソッド。

以前のドアと同じようにiTweenを用いている。

assetstore.unity.com

移動するべき座標は

cameraPos[next]["x"]

という感じで取得できるので、あとはそれに従って座標と角度を動かすだけ。

resetUI メソッド

// 現在のポジションに合わせてUIの表示/非表示を切り替える
public void resetUI(){
    
    if(cameraPos[currentPosName]["left"] != "none"){
        leftButton.SetActive(true);
    } else {
        leftButton.SetActive(false);
    }

    if(cameraPos[currentPosName]["up"] != "none"){
        upButton.SetActive(true);
    } else {
        upButton.SetActive(false);
    }

    if(cameraPos[currentPosName]["right"] != "none"){
        rightButton.SetActive(true);
    } else {
        rightButton.SetActive(false);
    }

    if(cameraPos[currentPosName]["down"] != "none"){
        downButton.SetActive(true);
    } else {
        downButton.SetActive(false);
    }
}

こちらの実装もかなり単純。

ボタンUIをクリックした時に遷移するポジションがない場合は noneCSVに記述してある。

そのため、上下左右の4つに対して、もしnoneじゃないならボタンを表示し、noneなら非表示にしている。

TestManager クラス

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;

// controllers
public ItemListController itemListController;
public ItemDetailController itemDetailController;
public CameraController cameraController;
public EventController eventController;



// Use this for initialization
void Start () {
    eventSystem = GameObject.Find("EventSystem").GetComponent<EventSystem>();
    mainCamera = GameObject.Find("Main Camera").GetComponent<Camera>();

    // アイテムリストの操作
    itemListController = ItemListController.getInstance();
    // アイテム詳細画面の操作 + アイテムごとの処理
    itemDetailController = ItemDetailController.getInstance();
    itemDetailController.createItemInstances();
    // カメラの操作
    cameraController = CameraController.getInstance();
    // ゲーム画面でのイベント操作 + イベントの処理
    eventController = EventController.getInstance();

}

// Update is called once per frame
void Update () {
    if(Input.GetMouseButtonUp(0)){

        selectedGameObject = eventSystem.currentSelectedGameObject;

        if(selectedGameObject == null){
            searchRoom();
        } else {
            switch(selectedGameObject.tag){
                case "ItemListButton":
                    itemListController.click(selectedGameObject);
                    break;
                
                case "ItemDetail":
                    itemDetailController.main(selectedGameObject);
                    break;

                case "CameraUI":
                    cameraController.clickUI(selectedGameObject);
                    break;
            }
        }
        
    }

    // dキーで詳細画面へのショートカット
    if(Input.GetKey(KeyCode.D)){
        selectedGameObject = GameObject.Find("DetailButton");
        itemDetailController.main(selectedGameObject);
    }
}

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

        selectedGameObject = hit.collider.gameObject;
        Debug.Log("Selected object is " + selectedGameObject.name);

        switch(selectedGameObject.layer){
            case 9:  // Clickable
                eventController.main(selectedGameObject);
                break;
            
            case 10: // Getable
                itemListController.add(selectedGameObject);
                Destroy(selectedGameObject);
                break;

            case 11: // ShiftLocation
                cameraController.clickObject(selectedGameObject);
                break;
        }

    }
}

}

こちらはゲーム自体を管理するクラスだが、以前とほぼ同じような実装になっている。

異なる部分は以下だけ。

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

        selectedGameObject = hit.collider.gameObject;
        Debug.Log("Selected object is " + selectedGameObject.name);

        switch(selectedGameObject.layer){
            case 9:  // Clickable
                eventController.main(selectedGameObject);
                break;
            
            case 10: // Getable
                itemListController.add(selectedGameObject);
                Destroy(selectedGameObject);
                break;

            case 11: // ShiftLocation
                cameraController.clickObject(selectedGameObject);
                break;
        }

    }
}

新しくカメラ遷移のためのトリガーオブジェクトをレイヤー11番で配置したので、

if(Physics.Raycast(ray, out hit, 10000000, 1 << 9 | 1 << 10 | 1 << 11))

で 11 番レイヤーのオブジェクトにも当たるようにし、

case 11: // ShiftLocation
    cameraController.clickObject(selectedGameObject);
    break;

その物体に当たったときは、先ほど設定したメソッドを呼び出している。