Unity＋Spreadsheet連携で作るゲームイベント管理システム

## 概要
初めまして。昨年、[コンタマ](https://www.4gamer.net/games/508/G050866/20200518030/)と呼ばれるインディーズでゲーム開発をし、その中で、google spread sheetとUnityを連携したイベント管理システムを作りました。もうすぐ本ゲームはサービス終了となってしまいますが、せっかくなので、開発したシステムについて共有いたします。なおサービス終了後にできうる範囲でソースコードを公開する予定です。


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111483/28df74c9-a644-3cb6-1f00-7b6638486064.png" width="150"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111483/a38c5589-8791-eff4-8f23-00d7ab46e059.png" width="147"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111483/249d280f-e611-3a4e-32c3-3916e4d3aacb.png" width="166">



## モチベーション
ゲーム内で発生する多くのイベントをプログラマーが全てプログラム内を処理書くのは大変。そこでプログラムが書けないプランナーが自由に入力して作業を分担できるように仕組み化したい。

## 利用環境
* Unity 2019.3.12f(最新でもおそらく問題ないです。）
* Google Spread sheet(あくまでエディタとしてcsvで書ければ大丈夫です。）

## 手順

### イベント処理用のcsvの作成

まず、ゲームで処理したい仕様に合わせてスプレッドシートの記述を決めます。コンタマの例だと処理したいイベントのコマンドの種類として、「会話」「待機」「アニメ開始」「アニメ終了」「選択肢」などがありました。まずは下記の画像のような形で用意するコマンドとスプレッドシートの仕様を決めました。（これはあくまでチームで仕様を共有するために作ったもので、作らなくても問題ありません。）

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111483/8c9fe178-c834-8d35-af51-846feb248e66.png" width="500">

この仕様をベースに、実際にUnity側で読み込むためのcsvをspreadsheetで作成しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111483/773312bd-e96c-1ead-f362-d3545cdfedff.png" width="500">

使い方として、
*１列目・・・イベント識別子となります。同じイベント識別子（画像の例だと例えばMAINSCENARIO0)が一番上から一番下まで連続している範囲で順次イベントを処理していく形になります。

* 2列目・・・コマンドを実施するキャラクター定義、ただしコマンドによってはキャラクターが定義がなくても良いものもあります。この辺りはコマンド仕様によります。
* 3列目・・・コマンド名。
* 4列目以降・・・汎用パラメータ用の列。これはコマンドによって使い方が異なります。

このような形でSpreadsheet上でプランナーが編集し、それをcsvとしてダウンロードして取り込むという設計にしていました。（もちろんUnityのビルド時やランタイムで自動的に取り込んで、ということも実現可能ですが、ここでは割愛します。）


### Unityでcsvを処理するためのプログラムの作成

上記の通りに作成したcsvをUnityのプロジェクトフォルダ内にダウンロードしてある前提で、Unity上で、csvの記述に従って、イベントを処理するプログラムを書きました。ゲームサービス終了後にソースコードを公開する予定ですが、今回は一部を抜粋します。

まずは/Assets/Resources以下においたcsvファイルをロードするための実装。

```
TextAsset csvFile; // CSVファイル
List<string[]> csvDatas = new List<string[]>();

// csvから読み出して全てのイベントデータを格納するリスト
public List<DialogueData> dialogueDatas = new List<DialogueData>();
public DialogueData currentDialogue = null;
・
・
・
・

public void LoadCsv(){
       
        csvFile = Resources.Load("dialogueCSV") as TextAsset; // Resouces下のCSV読み込み
        StringReader reader = new StringReader(csvFile.text);

        // , で分割しつつ一行ずつ読み込み
        // リストに追加していく
        int latestId = 0;
        string latestCode = "";
        int lineNum = 0;

        while (reader.Peek() != -1) 
        {
            // 一行ずつ読み込み
            string line = reader.ReadLine();

            // 区切りでリストに追加
            csvDatas.Add(line.Split(','));

            string curentCode = csvDatas[lineNum][0];

            if (latestId == 0 && dialogueDatas.Count == 0)
            {
                dialogueDatas.Add(new DialogueData(0, curentCode));
                latestCode = curentCode;
            }

            if (curentCode != latestCode)
            {
                latestId++;
                dialogueDatas.Add(new DialogueData(latestId, curentCode));
                latestCode = curentCode;
            }

            //コマンドごとの専用処理。この例だと「会話」コマンドのときの会話内容にて[player]を実際のプレイヤー名に置き換えるみたいな処理
            if (csvDatas[lineNum][2] == "会話") {

                string text = csvDatas[lineNum][3].Replace("[player]", Data.Instance.characterName);
                dialogueDatas[latestId].addArgs(csvDatas[lineNum][1], csvDatas[lineNum][2], text, csvDatas[lineNum][4], csvDatas[lineNum][5], csvDatas[lineNum][6], csvDatas[lineNum][7], csvDatas[lineNum][8], csvDatas[lineNum][9]);
            }
            else
            {
                dialogueDatas[latestId].addArgs(csvDatas[lineNum][1], csvDatas[lineNum][2], csvDatas[lineNum][3], csvDatas[lineNum][4], csvDatas[lineNum][5], csvDatas[lineNum][6], csvDatas[lineNum][7], csvDatas[lineNum][8], csvDatas[lineNum][9]);
            }

            lineNum++;
        }
    }
}
```

次にdialogueDatasに格納されたデータを使ってイベントを処理するプログラム。Updateの中で動かします。indexに現在のイベントコマンドの行（=csvの各行にあたります。）が入っていき、一つコマンドを処理するごとにインクリメントされています。Updateの中で自動的にindexをインクリメントする処理もあれば、ユーザーの処理や特定のイベントを待ってからインクリメントする場合もあります。詳しくは下記のコードをご覧ください。

```
// 経過時間管理用
private float seconds = 0.0f; 

// 待機処理指定用
float waitTime = 0.0f;

void Update () {

      
        if (waitTime > 0.0f)
        {
            if(seconds >= waitTime){
                waitTime = 0.0f;
            }
        }
        else
        {
 
            if (eventIndex != -1)
            {
                if(index >= finalTalkIndex ){
                   
                    if (tm.isFadeOutKept)
                    {
                        FinishEvent();
                    }
                    else
                    {
                        tm.StartFadeOut(OnFadeOutAfterEventFinished);
                    }
                    return;
                }



                command = currentDialogue.talkDatas[index].type;
                    
                switch (command)
                {

                    case "配置":
                        {
                            // キャラクターを配置するコマンド処理
                            //キャラクターはUnityEditor内で指定した指定の場所（３候補でcharacterIndexで表現)に出せるようにした。
                            int charaIndex = int.Parse(currentDialogue.talkDatas[index].arg0);

                            if(chara[charaIndex] != null)
                            {
                                chara[charaIndex].transform.position = charaPrevPos[charaIndex];
                                chara[charaIndex] = null;
                            }

                            if (currentDialogue.talkDatas[index].speaker == "player")
                            {
                                chara[charaIndex] = gm.character[Data.Instance.characterId];
                                name[charaIndex] = Data.Instance.characterName;
                                Data.Instance.libraryData.isCharacterReleased[0] = true;
                            }
                            else
                            {
                                chara[charaIndex] = GameObject.Find(currentDialogue.talkDatas[index].speaker);
                                name[charaIndex] = currentDialogue.talkDatas[index].speaker;

                            }

                            if (posDummy == null)
                            {
                                break;
                            }

                            if (chara[charaIndex] != null)
                            {
                                charaPrevPos[charaIndex] = chara[charaIndex].transform.position;
                                chara[charaIndex].transform.position = posDummy[charaIndex].transform.position;
                                chara[charaIndex].transform.rotation = posDummy[charaIndex].transform.rotation;
                                if (currentDialogue.talkDatas[index].speaker == "player")
                                {
                                    if (prevScale == new Vector3(0, 0, 0))
                                    {
                                        prevScale = chara[charaIndex].transform.localScale;
                                    }

                                    if (Data.Instance.sequence == GameManagerScript.GameSeq.RANK_THIRD)
                                    {
                                        chara[charaIndex].transform.localScale = GameObject.Find("rank3_dummy_event").transform.localScale;
                                    }
                                    else if (Data.Instance.sequence == GameManagerScript.GameSeq.RANK_FORTH || Data.Instance.sequence == GameManagerScript.GameSeq.BEFORE_ENDING)
                                    {
                                        chara[charaIndex].transform.localScale = GameObject.Find("rank4_dummy_event").transform.localScale;
                                    }
                                    else
                                    {
                                        chara[charaIndex].transform.localScale = posDummy[charaIndex].transform.localScale;
                                    }
                                }
                            }
                            index++;
                        }

                        break;

                    case "配置解除":
                        {
                            // 「配置」コマンドで配置したキャラクターを解除するコマンド処理
                            int charaIndex = int.Parse(currentDialogue.talkDatas[index].arg0);
                            chara[charaIndex].transform.position = charaPrevPos[charaIndex];
                            chara[charaIndex] = null;
                            index++;
                        }
                        break;
                    case "会話":
                        // 特定のキャラクターに会話をさせるプログラム
                        TalkUIDisplay();


                        if (isDialogSkipMode)
                        {
                            index++;

                            if (currentDialogue.talkDatas.Count - 1 < index)
                            {
                                break;
                            }

                            if (currentDialogue.talkDatas[index].type != "会話")
                            {
                                // 会話UI消去
                                TalkUIDelete();

                                break;
                            }
                        }
                        if (Input.GetMouseButtonDown(0))
                        {
                            
                            index++;
                            
                            if (currentDialogue.talkDatas.Count - 1 < index)
                            {
                                break;
                            }

                            if (currentDialogue.talkDatas[index].type != "会話"){

                                // 会話UI消去
                                TalkUIDelete();
                                break;
                            }
                        }

                        break;
                    case "待機":
                        //何もせず指定の秒数待機するコマンド処理
                        seconds = 0;
                        waitTime = (float)int.Parse(currentDialogue.talkDatas[index].arg0);
                        index++;
                        break;
                    case "アニメ開始":
                        {
                            //指定のキャラクターに特定のアニメーションを停止するコマンド処理
                            String anim = currentDialogue.talkDatas[index].arg0;
                            int animCharaIndex = GetChara(currentDialogue.talkDatas[index].speaker);
                            chara[animCharaIndex].GetComponent<CharacterBase>().startEventAnim(anim);
                            index++;
                        }
                        break;
                    case "アニメ終了":
                        {
                            //指定のキャラクターの特定のアニメーションを停止するコマンド処理
                            int animCharaIndex = GetChara(currentDialogue.talkDatas[index].speaker);
                            chara[animCharaIndex].GetComponent<CharacterBase>().cancelEventAnim();
                            //waitTime = (float)int.Parse(currentDialogue.talkDatas[index].text);
                            index++;
                        }
                        break;
                    case "フェードアウト":
                        if (!tm.isRunning())
                        {
                            // 画面をフェードアウトさせるコマンド処理
                           tm.StartFadeOut(OnFadeFinished);
                        }

                        break;
                    case "フェードイン":

                        if (!tm.isRunning())
                        {
                            // 画面をフェードインさせるコマンド処理
                            tm.StartFadeIn(OnFadeFinished);
                        }

                        break;
                    case "選択肢":

                        // 会話中に選択肢を表示させるためのコマンド処理
                        dialogWindow.SetActive(true);
                        GameObject.Find("DialogueText").GetComponent<Text>().text = currentDialogue.talkDatas[index].arg0;
                        
                        break;
                    case "スマホ":
                   
                        break;
                    case "合コン誘い":
                        // 会話UI表示
                        TalkUIDisplay();

                        // ステータス表示
                        uiMane.SetOnStatusNotFlag();
                        uiMane.DisplayStatus();

                        break;
                    case "イベント移行":
                        {
                            //特定のイベント（csvで指定されてるイベントID)に移行するためのコマンド処理。これにより自由にイベント同士を連結できる
                            int jumpEventId = getEventId(currentDialogue.talkDatas[index].arg0);
                            JumpEvent(jumpEventId);
                        }
                        break;


                    case "背景セット":
                        //指定の背景をセットするコマンド処理

                        if (currentDialogue.talkDatas[index].arg0 == "BG_ROOM")
                        {
                            if(!prevPos.Equals(new Vector3(-100, -100, -100)))
                            {
                                bg.transform.position = prevPos;

                                prevPos = new Vector3(-100, -100, -100);
                            }

                            if (bgPos != null)
                            {
                                bgPos.SetActive(true);
                            }
                           
                        }
                        else
                        {
                            if(bg != null)
                            {
                                bg.transform.position = prevPos;
                            }
                            bg = GameObject.Find(currentDialogue.talkDatas[index].arg0);
                            if (bg != null)
                            {
                                bg.SetActive(true);
                                bgPos.SetActive(true);
                                prevPos = bg.transform.position;
                                bg.transform.position = bgPos.transform.position;
                                bgPos.SetActive(false);
                                bg.SetActive(true);
                            }
                            else
                            {
                                Debug.Log("NO BG FOUND!!");
                            }

                        }

                        // 背景変えたときは一度キャラの配置を全部解除するようにしている。
                        if (chara[0] != null)
                        {     chara[0].transform.position = charaPrevPos[0];
                            chara[0] = null;
                        }
                        if (chara[1] != null)
                        {
                            chara[1].transform.position = charaPrevPos[1];
                            chara[1] = null;
                        }
                        if (chara[2] != null)
                        {
                            chara[2].transform.position = charaPrevPos[2];
                            chara[2] = null;
                        }
                        index++;
                        break;
                    default:
                        index++;
                        break;

                }



            }
        }

        seconds += Time.deltaTime;

}
```
このような形で様々なイベント用のコマンドを処理していきます。コンタマでは特殊なケースなども多く、なかなか汎用的な作りにできていませんが、できる限り汎用的に作れると再利用しやすいかと思います。

実際に指定した特定のイベントを有効化する処理と終了する処理も下記に記載します。

```
public void Activate(int eventId, UnityAction callback){
        isBGReady = false;
        isEvent = true;
        seconds = 0.0f;
        eventIndex = eventId;
        index = 0;

        currentDialogue = dialogueDatas[eventId];
        finalTalkIndex = currentDialogue.talkDatas.Count;

        // 進化終了時にGamemanagerに伝える（callback）ための関数を受け取る。
        OnCompleteCallback = callback;
}
・
・
・
public void FinishEvent(){
        ResetDialogue();
        dialogWindow.SetActive(false);
        // 会話UI消去
        TalkUIDelete();
      
　　　　　//　キャラポジションの位置の初期化処理
        for (int i = 0; i < 3; i++)
        {
            if (chara[i] != null && name[i] == Data.Instance.characterName)
            {
                chara[i].transform.Rotate(new Vector3(0.0f, 1.0f, 0.0f), -300);
                if (prevScale != new Vector3(0, 0, 0))
                {
                    chara[i].transform.localScale = prevScale;
                    prevScale = new Vector3(0, 0, 0);
                }
                chara[i].GetComponent<CharacterBase>().cancelEventAnim();
            }
        }

        index = 0;
        finishedEventCode.Clear();
        OnFadeInAfterEventFinished();
}

```


### 次回予告

今回はイベントシーンを処理するためのspreadsheet上csvの作成と、csvのロード・順次処理するためのプログラムイメージを共有しました。次回は各コマンド内においての処理（例えば会話を表示するときの処理、フェード処理、などなど）を共有していければと思います。

なお、アプリ公開終了後に、できる限りでのspreadsheetやソースコード公開を検討しています。ご期待ください！