UnityでExcel Importerを使って、シナリオの管理とノベルゲームの基礎を作ってみた。RPGの会話にも使えるよ。

以下のスクリーンショットと同じものが作れます。
![DIalougeSS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87165/1b31c2f4-cbf3-cfab-7032-38f90305500e.png)

現在RPGを製作中で、ひとまずシナリオのメッセージを表示する箇所を作ることになりました。
シナリオのデータはスプレッドシートで作成して管理するのがよいと思い試してみました。
また、キャラクターやアイテムなど様々なデータも管理する必要があるので応用が利くと考えました。

以下URLは作成した成果物をExpotPackegeしたファイルのリンクです。
適当に新しく作ったプロジェクトや、あるいは現在制作中のゲームで使ってみてください。
ダウンロードしてインポートすればすぐに使えます。
https://1drv.ms/u/s!AqdG-lHUsHuI2gl-QYk7ZC_pQeKr?e=YUZg7B


たいていの場面や状況でメッセージを映すことができると思います。
例えば、Timelineを使ったムービーの字幕を映したり、戦闘中に必殺技を使ったときに技名を叫んだりなどです。

なぜできるかというとOnEnableで最初の文章を映すようにしています。
なのでスクリプトをアタッチしているオブジェクトのアクティブ状態を有効化するだけでよいからです。
それにあわせて、対話が終了したときにアクティブ状態を無効にするよう作りました。

NPCとの会話であれば、NPCごとにUIのプレハブを子オブジェクトとして持たせればOKです。
ただ、話しかける部分は無いので作る必要があります。
NPCオブジェクトのUIを有効化する関数を呼び出せばよいでしょう。

#Unity Excel Importerのメリット

Excelのデータをスクリプタブルオブジェクトに変換してくれる超便利なやつです。
しかも、ただそれだけではありません。以下のメリットがあります。

１．簡単にExcelファイルを変換できる
２．Enumに対応している
３．#を行の先頭のセルに最初の１文字として書いておくだけで、その行は読み込まさせないことができる。
４．Excelファイルに空の行や列を挿入するだけで、それ以降のデータは読み込まないように作られている
５．自動更新機能が付いている

便利な機能がそろっていますから使うほかはありえませんでした。
もはや頭が上がりません。RPGでは様々な箇所に使用しますから常に感謝しています。

#作成の手順

手順は以下の通りです。

１．最低限必要な機能を考える
２．エクセルにシナリオを書く
３．Entityクラスを書く
４．エクセルデータをインポートする
５．Excel Assetを半自動で作成する
６．UIを構築する
７．シナリオを表示するプログラムを書く
８．１に戻って機能を増やす

私は、以上を繰り返して仕組みを作りました。
仕様変更を繰り返したともいえます。
ですが、エクセルのインポートが簡単なので問題は発生しませんでした。

#シナリオを読むのに必要な処理
１．NPCに話しかけるなどの会話のきっかけ
２．メッセージなど映すUIの表示
３．最初の文章を映す
４．次の文章を読む
５．選択肢を映す
６．選択肢で分岐する
７．分岐したけど、結局は同じ結末になって収束する
８．対話が終了するときにUIを非表示にする
以上が最低限必要な処理だと思います。
今回は、話しかけるなどのきっかけの部分はありません。ご注意ください。

#UIを構成するオブジェクト

シーンの全体像です。撮影のために選択肢のオブジェクトを有効化しています。

CanvasにはスクリプトのDialougeクラスをアタッチしています。

メッセージウインドウの背景部分はボタンで作っています。Eventには次のメッセージを映す関数を割り当てています。
選択肢もボタンです。同じように、それぞれ選択肢ごとに関数を割り当てています。

選択肢はChoicesObjectという名前の空のオブジェクトの子にしています。
ふたつの選択肢を同時に非アクティブにするためです。

![DialougeUI.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87165/e8e14381-df65-e86b-7fe6-d818ec3ea7a7.png)
#Excelファイル
作成したプログラムをバグがないか試すのに必要なデータのみ書いています。

１行目と２行目が同じメッセージなのは、選択肢が表示されたときメッセージウインドウが暗くなるようにしてあるからです。
まず、本文を明るく映し、つぎに本文を暗くして選択肢を明るく映す仕組みです。
そのため、２行目は、あくまでも選択肢がメインの文章になります。

また、９行目は本文がありません。J列のendOfTalkがTRUEになっているところが他の行とは違います。
この行の役割は対話が終了するかのフラグだけです。８行目の本文を映すために必要です。
フラグがTRUEだったらとき、すぐにUIを非アクティブにする仕組みだからです。

![ExcelFile.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87165/d0e18f7b-119f-5fe7-8ada-8a13ed6153c7.png)


#Entityクラス

Excel Importerで最初に作成する必要のあるスクリプトです。
エクセルで各列に記述する要素をパブリックな変数で宣言しておきます。

注意点が３点あります。
１．Serializable属性を記述すること
２．何も継承しないこと
２．変数名をExcelファイルの各列の項目名と一致させること
以上が注意点になります。

それぞれの変数の意味についてはコメントを読んでみてください。

```C#
[System.Serializable]
public class Sentence	//センテンスと読みます。文章という意味です。シナリオは文章で構成されているので、この名前にしました。
{
	public int id;				//検索するための一意な数値です。
	public string message;		//メッセージの本文です。
	public bool branch;			//選択肢があるかを判定するために使います。
	public string yesMessage;	//肯定的な選択肢です。
	public string noMessage;	//否定的な選択肢です。
	public int choseYes;		//肯定的な選択肢を選んだとき、どの文章に分岐するかのIDです。
	public int choseNo;			//否定的な選択肢を選んだとき、どの文章に分岐するかのIDです。
	public bool doConnect;		//分岐した後に収束するときなどに使います。
	public int skipId;			//収束するとき、どの文章から読むのかのIDです。
	public bool endOfTalk;		//対話が終了する最後の文章なのか判定するのに使います。
}
```

#ExcelAssetクラス

ScriptableObjectクラスを継承していて、エクセルの行ごとのデータを持った配列を持っているクラスです。

インポートしたエクセルデータをもとにExcel Importerが半自動で作成してくれるスクリプトです。
手動で作成してもよいですが注意点があります。

１．[ExcelAsset]属性を書いておくこと
２．クラス名はエクセルのファイル名と同じにすること
３．変数名はエクセルのシートの名前と同じにすること
４．ScriptableObjectクラスを継承すること
５．リストの型を間違えないようにすること
以上が注意点です。

それでは、作成してみましょう。

まず、右クリックと左クリックで作成します。
そのあと一部だけ書き換える必要があります。
そして、エクセルデータは再インポートするとスクリプタブルオブジェクトに変換されます。
![InkedCreateExcelAssetMenu_LI.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87165/b012fb1e-fed1-1e10-0055-8ece74572fa9.jpeg)

コメントアウトを消してから
リストの型を自分で作ったものに書き換えます。

```C#
//書き換える前

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

[ExcelAsset]
public class Chapter : ScriptableObject
{
	//public List<EntityType> sentences; // Replace 'EntityType' to an actual type that is serializable.
}

```

```C#
//書き換えた後

using System.Collections.Generic;
using UnityEngine;

[ExcelAsset]
public class Chapter : ScriptableObject
{
	public List<Sentence> sentences; // Replace 'EntityType' to an actual type that is serializable.
}
```

#Dialogueクラス
UIの制御をするクラスです。

処理の流れは以下の通りになります。

１．UIが有効化する
２．一意な行番号でデータを検索して取得する
３．Textコンポーネントにメッセージを代入する
４．選択肢があれば、選択肢のアクティブ状態を有効化する
５．選択肢を選んだあとは無効化する
６．対話が終了フラグがあったらUIを無効化する

```C#
using System.Linq;
using UnityEngine;
using UnityEngine.UI;

public class Dialogue : MonoBehaviour
{
	[SerializeField] GameObject dialogue = null;
	[SerializeField] GameObject choices = null;
	[SerializeField] Button readMore = null;
	[SerializeField] Text massage = null;
	[SerializeField] Text yBranchMassage = null;
	[SerializeField] Text nBranchMassage = null;
	[SerializeField] Chapter chapter = null;

	//文章を検索するときに使うIDです。
	int CurrentSentenceID = 0;

	//オブジェクトが有効になったとき呼び出すように、Unityが用意してくれているイベント関数です。
	private void OnEnable()
	{
		//最初の1行目を表示します。
		Sentence result = SerchSentence(CurrentSentenceID);
		ShowingMassage(result);
	}

	private void Update()
	{
		Debug.Log(CurrentSentenceID);
	}

	//文章の続きを表示します。
	public void ReadmoreMessage()
	{
		//文章番号をひとつだけ次に進めます。
		CurrentSentenceID++;

		//文章番号をもとに文を検索します。
		Sentence result = SerchSentence(CurrentSentenceID);
		//メッセージがこれ以上ない場合はダイアログUIを非アクティブにする。
		EndOfTalk(result);
		//得られたメッセージを表示します。
		ShowingMassage(result);
		ShowingMassageIsBranch(result);
		Connect(result);
	}

	//メッセージを表示させるだけです。
	void ShowingMassage(Sentence sentence)
	{
		massage.text = sentence.message;
	}

	Sentence SerchSentence(int Id)
	{
		//シナリオのメッセージ配列の中から文章をIDで検索して取得します。
		Sentence result = chapter.sentences.First(
			(Sentence line) => { return line.id == Id; }
		);
		return result;
	}

	//選択肢のメッセージを表示させるだけです。
	//選択肢のUIはボタンとテキストで構成されています。
	void ShowingMassageIsBranch(Sentence sentence)
	{
		if (sentence.branch)
		{
			SwichReadmoreInteractable();
			SwichCoicesActivate();
			yBranchMassage.text = sentence.yesMessage;
			nBranchMassage.text = sentence.noMessage;
		}
	}

	//肯定を選択したとき、ボタンコンポーネントのEventから呼び出されます。
	public void ChooseYes()
	{
		//選択肢を押下したときReadmoreボタンを有効にします。
		SwichReadmoreInteractable();
		//選択肢を非表示にします。
		SwichCoicesActivate();

		Sentence result = SerchSentence(CurrentSentenceID);
		Skip(result.choseYes);
	}

	//否定を選択したとき、ボタンコンポーネントのEventから呼び出されます。
	public void ChooseNo()
	{
		//選択肢を押下したときReadmoreボタンを有効にします。
		SwichReadmoreInteractable();
		//選択肢を非表示にします。
		SwichCoicesActivate();

		Sentence result = SerchSentence(CurrentSentenceID);
		Skip(result.choseNo);
	}

	//文章を読み飛ばします。
	//選択の結果、シナリオが分岐したのち収束するときなどに使用します。
	void Connect(Sentence sentence)
	{
		if (sentence.doConnect)
		{
			Skip(sentence.skipId);
		}
	}

	//別の文章に読み飛ばします。
	void Skip(int id)
	{
		Sentence result = SerchSentence(id);
		CurrentSentenceID = result.id;
		ShowingMassage(result);
	}

	//最後の文章になったらUIを非表示にします。
	//会話が終わったのにもかかわらず、UIが表示されていたらバグです。
	//だから非表示する必要があります。
	void EndOfTalk(Sentence sentence)
	{
		if (sentence.endOfTalk)
		{
			//このゼロは現在読んでいる文章をリセットするための数値です。
			CurrentSentenceID = 0;
			SwichDialougeActivate();
		}
	}

	

	//Readmoreボタンを押下の可不可を切り替えます。
	//選択肢があるとき、選択肢以外のボタンを押せなくするために使用します。
	void SwichReadmoreInteractable()
	{
		if (readMore.interactable)
		{
			readMore.interactable = false;
		}
		else
		{
			readMore.interactable = true;
		}
	}

	//アクティブ状態を切り替えます。
	void SwichActiveState(GameObject target)
	{
		if (target.activeSelf)
		{
			target.SetActive(false);
		}
		else
		{
			target.SetActive(true);
		}
	}

	//選択肢のアクティブ状態を切り替えます。
	void SwichCoicesActivate()
	{
		if (choices.activeSelf)
		{
			choices.SetActive(false);
		}
		else
		{
			choices.SetActive(true);
		}
	}

	//対話UIのアクティブ状態を切り替えます。
	void SwichDialougeActivate()
	{
		if (dialogue.activeSelf)
		{
			dialogue.SetActive(false);
		}
		else
		{
			dialogue.SetActive(true);
		}
	}
}
```

#最後に

Excel Importerを公開してくださっている@mikitoさん、ありがとうございます！

コメントで質問したときコードの一部が正しく表示されていなかったにもかかわらず
問題のある個所を丁寧に指摘していただいたことを改めて心から感謝いたします。

#追記
話しかけるを実装するにはどうしたらいいか気になって人もいると思いますのでメモ書き程度に記しておきます。
１．レイキャストや当たり判定を使ってNPCに近づいたことを判定したときに
２．NPCオブジェクトがUIを有効化する

以上です。

@mikitoさんによるExcel Importerの紹介と説明の記事
https://qiita.com/mikito/items/2ad911f69180c15102a1