Unity, C#ジェネリック, GASでマスターデータを作成・更新してみる

せっかくQiitaに登録したので、何かしらゲームプログラムマーっぽい記事を書いてみました(後自分の備忘録)。
初記事+駄文ですがご容赦を。

# 概要
自分は良く趣味プログラミングで使用するステータスなどのマスターデータをGoogleSpreadSheetで管理、その内容をUnityのScriptableObjectに変換して使用しています。
![ss0.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/702d6907-bfdb-7f58-7248-019c139ee3cf.png)
![ss1.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/48a0ff7e-258b-beba-6bcc-a0f22f308ef0.png)
しかし、新しくシートを追加したときに発生するUnity側の新規スクリプト作成などが面倒に思えてきたので、出来る限りその手間を削減してみました。

※UnityとGASの連携は以下の記事が大変参考になりました。
> 【Unity】セリフやステータスなど大事な情報をGoogleスプレッドシートだけで管理する【GAS】
> https://qiita.com/john95206/items/22ad04e2d30799954c76

# 環境情報
- Windows10(64bit)
- Unity2018.3(Unity2017.2でも動作確認済み)
- C#4.0
- MiniJson
- GoogleChrome(GoogleDriveとGASが使えれば何でも良いです)
    - Chrome拡張機能に``GoogleAppsScript``を入れておく
    - 参考サイト
    - https://qiita.com/t_imagawa/items/47fc130a419b9be0b447

# GAS側でやること
### GoogleSpreadSheetにデータを書く
GoogleDriveに新規でGoogleSpreadSheetを作成して、データを作っていきます。
※そもそもGoogleDriveが良く分からん、という方はこちらを参考に。
https://www.appsupport.jp/googleapps/drive/

#### 作成するデータの前提条件
今回は出来る限り処理をまとめたいので、作成するデータに少し条件を付けています

- 1行目→各列の説明, 2行目→各列のC#側で扱う時の型情報, 3行目→各列の変数名
- 今回作成するデータは、**必ず「id」というstring型データを持つこと**

※A列のコマンドに関しては任意。自分はコメントアウトしたいので入れているだけです。

サンプルとして**「Constant」**というスプレッドシートに、**「text」**というシートを作成して以下のようなデータを作成しました。
![ss3.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/a8877273-55af-7a04-fb04-144907be7ed4.png)

### GoogleAppsScript(GAS)プロジェクトの作成
自分のGASプロジェクトを開いて、「新規スクリプト」ボタンを押してプロジェクトを作成する
(今回は``MasterDataLoad``という名前にしました)
https://script.google.com/home
![ss4.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/0a09ade1-bb54-b982-4bcf-f524c2bbcff6.png)


### GASでGetAPIコードを書いていく

Unity側から呼び出すAPIの内容を書いていきます。
返す情報

- 型情報とそれに紐づく変数(ここは今回使用しないので省略してもおｋ)
- シートに記載されている各行のデータ情報

最終的に上記をJson形式にまとめます。

※補足
下記コードの``function GetSpreadSheet(e)``で指定しているIDは、スプレッドシートを開いた時のURLに記載されています
コード上では``hogehoge``となっているので、適宜置き換えて下さい。
``https://docs.google.com/spreadsheets/d/ここの文字列がIDです/edit#gid=0``

```js:main.gs
function doGet(e)
{
  // URLのパラメーターにシート名がある場合は、そのシートの情報を引っ張る
  // 無い場合はデバッグ用のシートを取得する
  var ss = GetSpreadSheet(e);
  if(ss == null)
  {
    Logger.log("スプレッドシートが見つかりませんでした");
    Browser.msgBox(Logger.getLog());
    return;
  }
  
  var sheet = GetSheet(ss, e);
  if(sheet == null)
  {
    Logger.log("シートが見つかりませんでした");
    Browser.msgBox(Logger.getLog());
    return;
  }
  
  // シートからJsonデータの作成及び送信
  var json = ConvertSheetToJson(sheet);
  var jsonStr = JSON.stringify(json)
  Logger.log(jsonStr);
  
  return ContentService.createTextOutput(jsonStr).setMimeType(ContentService.MimeType.JSON);
}

function GetSpreadSheet(e)
{
  // 定義されていない場合は指定したIDを返す
  if(e == undefined || e.parameter == undefined || e.parameter.id == undefined)
  {
    // デバッグ実行する際には、ここにかかれたシートIDが実行される
    return SpreadsheetApp.openById("hogehoge");
  }
  return SpreadsheetApp.openById(e.parameter.id);
}

function GetSheet(ss, e)
{
  // 定義されていない場合は指定したシート名を返す
  if(e == undefined || e.parameter == undefined || e.parameter.name == undefined)
  {
    // デバッグ実行する際には、ここにシート名が実行される
    return ss.getSheetByName("text");
  }
  return ss.getSheetByName(e.parameter.name)
}

function ConvertSheetToJson(sheet)
{
  // 1~3行目は外しておく
  var recoardSize = sheet.getLastRow() - 3;
  var columnSize = sheet.getLastColumn();
  var sheetJson = sheet.getRange(4, 1, recoardSize, columnSize).getValues();
  Logger.log("レコード数%s, カラム数%s", recoardSize, columnSize);
  
  // 各カラムの変数の型
  var variableTypeList = sheet.getRange(2, 1, 1, columnSize).getValues()[0];
  // 各カラムの変数名
  var variableNameList = sheet.getRange(3, 1, 1, columnSize).getValues()[0];
  
  // 出力するJsonの作成
  var jsonArray = new Object();
  // 型情報と変数の紐づけ
  jsonArray["VariableInfo"] = GetVariableInfoJson(variableTypeList, variableNameList, columnSize);
  // 各テーブル情報の格納
  jsonArray["Table"] = GetTableInfoJson(sheetJson, variableNameList, recoardSize);
  
  return jsonArray;
}

// 各変数名と型情報を紐づけていく
function GetVariableInfoJson(variableTypeList, variableNameList, columnSize)
{
  var jsonObj = {};
  // 0番目のcommandはテーブルに必要ないので1から開始する
  for(var i = 1; i < columnSize; ++i)
  {    
    if(!(variableTypeList[i] in jsonObj))
    {
      jsonObj[variableTypeList[i]] = new Array();
    }
    jsonObj[variableTypeList[i]].push(variableNameList[i]);
  }
  return jsonObj;
}

// 各テーブル情報の格納
function GetTableInfoJson(sheetJson, variableNameList, recoardSize)
{
  var jsonArray = [];
  
  // 1行ずつ見ていく
  for(var i = 0; i < recoardSize; ++i)
  {
    var line = sheetJson[i];
    // コメントアウト行なら次に飛ばす
    if(line[0].indexOf("#") != -1) continue;
    // IDが指定されていなければそこで終了
    if(!line[1]) break;
    
    // 内容をJsonに入れていく
    var obj = new Object();
    for(var j = 1; j < line.length; ++j)
    {
      obj[variableNameList[j]] = line[j];
    }
    jsonArray.push(obj);
  }
  return jsonArray;
}
```

### デバッグ実行して動作確認
コードが書けたら、デバッグ実行して実際に返却されるJsonテキストを確認します。
出力されたテキストに、VariableInfo, Tableブロックがそれぞれあり、シート内容と齟齬が無ければおｋです。

**手順**
1. 関数で**「doGet」**を指定して、**虫マークボタン**を押すとデバッグ実行されます。
2. その後、**表示→ログ**を押すと先ほど実行した処理で出力されたログが表示されます。
![ss5.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/e99f8ef6-2703-5e69-aca5-80d4ba1ee63d.png)
![ss6.png](https://qiita-image-store.s3.amazonaws.com/0/332769/91cc4be6-73f2-94af-566e-799eb2dd7bb2.png)

### APIの外部公開設定

1. **ファイル→版を管理**をクリック。説明文を入力して新しいバージョンを保存を押す。
2. **公開→ウェブアプリケーションとして導入**をクリック
3. 各項目を設定していく
    1. 現在のウェブアプリケーション…**URLはどこかにメモしておく(Unity側から呼び出す際に必要)**
    2. プロジェクトバージョン…先ほど保存したバージョンを選択する
    3. 次のユーザーとして～～…特に拘りが無ければ**自分**
    4. アプリケーションにアクセス～～…特に拘りが無ければ**全員(匿名ユーザーを含む)**
4. 設定が終わったら**更新ボタン**を押して終了

![ss7.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/38b9d770-f75b-d4e1-8fb9-0a6b0d158cd4.png)
![ss8.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/898b863d-e23b-e19b-1449-fadb971cf56d.png)


ここの記事に詳しく載っているので、こちらも参考にしてみてください。
> Google Spreadsheetに書いたシナリオをUnityのScriptableObjectにする
> https://qiita.com/hideyuki_hori/items/32dd3d65dd447dabe282

# Unity側でやること
APIとデータの準備が整ったので、いよいよマスターデータ本体の作成に移ります。

- 最初に全てのマスターデータの基礎となる**MasterDataItemBase**、そのアイテムをリストで保持する**MasterDataTableBase**の二つを作成します。今後新規でマスターデータを追加する際には、マスターデータ本体のクラス, そのリスト用クラスをそれぞれ作成して、上記の二つをそれぞれ継承させます。
- 次に今回サンプルで追加したマスターデータのクラスになる**ConstantText**, **ConstantTextTable**を作成します。
- 最後にScriptableObject作成スクリプトを書いて、GASで書いたAPIを実行してScriptableObjectを作成して完了です。
    - 今回はMiniJsonを使用するため、``Dictionary<string, object>``形式でJsonから情報を取得していきます。

※各項目毎にそれぞれコードを載せています。分かりづらそうな部分にはタイトルの直後に補足説明を箇条書きで入れています。

### マスターデータの基底クラス作成
データの前提条件に当たる部分はここに記載していきます。
※**このクラス自体はScriptableObjectでは無いので注意**

```C#:MasterDataItemBase.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// マスターデータテーブルの各項目の基底クラス
[System.Serializable]
public abstract class MasterDataItemBase
{
	public string id;
	public MasterDataItemBase(Dictionary<string, object> item)
	{
		if(item == null)
		{
			Debug.LogError("引数がnullです");
			return;
		}
		id = item.GetString("id");
	}
}
```

### マスターデータテーブルの基底クラス作成
実際にScriptableObjectとして定義するクラスの基底クラス
派生クラスをリストで持つ必要があるため、ジェネリック化している

- ``where T : MasterDataItemBase``
    - このクラスを使用する際には必ず**MasterDataItemBase**を継承したクラスでないといけないため定義している
- ``public void AddOrUpdate(Dictionary<string, object> item)``
    - 派生クラスのコンストラクタを呼び出して、データを作成しています
    - C#では通常テンプレート型のコンストラクタには引数が渡せないようなので、**System.Activator.CreateInstance**を使用して対応しています

```C#:MasterDataTableBase.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// データテーブルそのものの基底クラス
// ジェネリック化しているので、派生クラス作成時にMasterDataTableBaseを継承した項目クラスを設定するだけでおｋ
public abstract class MasterDataTableBase<T> : ScriptableObject where T : MasterDataItemBase
{
	[SerializeField]
	protected List<T> list = new List<T>();

	private int GetIndexToID(string id)
	{
		if(list == null)
		{
			Debug.LogError("初期化が済んでいません");
			return -2;
		}

		if(id.IsNullOrEmpty())
		{
			Debug.LogError("IDが異常値です");
			return -2;
		}
		return list.FindIndex(x => x.id.Equals(id));
	}

	public void Clear()
	{
		list.Clear();
	}

	public void AddOrUpdate(Dictionary<string, object> item)
	{
		T tmp = Activator.CreateInstance(typeof(T), item) as T;
		AddOrUpdate(tmp);
	}

	public void AddOrUpdate(T item)
	{
		if(Application.isPlaying) return;
		
		int index = GetIndexToID(item.id);
		// 異常値の場合は何もしない
		if(index == -2) return;

		// 見つからない場合は新規追加
		if(index < 0)
		{
			list.Add(item);
		}
		// 既にある場合はその要素を更新
		else
		{
			list[index] = item;
		}
	}

	public T GetItemInfo(string id)
	{
		if(id.IsNullOrEmpty()) return null;
		return list.Find(x=>x.id == id) as T;
	}
}
```

### ConstantTextマスターデータクラスを作成してみる
今回サンプルで追加したマスターデータクラスを定義してみました。
**ConstantText**にid以外の必要なパラメーターとコンストラクタを定義、実際のオブジェクトに当たる**ConstantTextTable**は、MasterDataTableBaseを継承して、ConstantTextを指定するだけで終わりです。
基本的に今後新しくマスターデータを追加するときも上記とやることは同じなので、かなり楽ですね(当社比)。

実際にコード上でテキストデータを引っ張ってくる際には、``GetItemInfo(id)``を使用して該当するデータを取得します。

```C#:ConstantTextTable.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class ConstantText : MasterDataItemBase
{
	public string text;

	public ConstantText(Dictionary<string, object> item) : base(item)
	{
		if(item == null) return;
		text = item.GetString("text");
	}
}

public class ConstantTextTable : MasterDataTableBase<ConstantText>{}
```

### マスターデータ本体(ScriptableObject)作成スクリプトを書いていく
基本的にやっていることは通常のScriptableObjectの作成と変わりありませんが、所々ジェネリック化しています。
また今回UnityEditor上でコルーチンを回しています。詳しくは以下のサイトを閲覧ください。
> StartCoroutine(MonoBehaviour)を使わずにコルーチンを実行する【Unity】【エディタ拡張】
> http://kan-kikuchi.hatenablog.com/entry/Coroutine_Editor

- 定数定義周り
    - URL_FORMAT…GAS側でどのシートを呼び出すか判別するときに使用します
    - 参考記事(GASでGetパラメータを受け取ってスプレッドシートに書き込む方法)
    - https://qiita.com/hirohiro77/items/a947416f803f45777338
- ``CreateOrUpdateConstantTextTable``
    - 今回作成したデータをScriptableObjectとして出力するための関数
    - 今後新しくデータを追加したときは、このようなフォーマットの関数を追加するだけでおｋ
- ``private static void CreateOrUpdateCalc<B, T>(string spreadSheetId, string sheetName) where B : MasterDataItemBase where T : MasterDataTableBase<B>``
    - 長い
    - ``where``の部分を見れば分かりますが、BとTに定義するクラスは最初に定義した基底クラスをそれぞれ継承したクラスでないといけません
    - 処理の流れは以下の通りです
        - APIで引数のシート情報をJsonテキストで受け取る
        - ScriptableObject作成or取得
        - JsonText内の**Table**ブロックを、``Dictionary<string, object>``に変換してforeachでマスターデータ作成
        - 最後に保存して終了

```C#:CreateMasterDataObject.cs
using System;
using System.Text;
using System.IO;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using UnityEditor;

// テーブル用のScriptableObjectとその元となる.csファイルの作成&更新を行うスクリプト
// GoogleSpreadSheetの情報をAPIで取得して、そこからファイルを作成する
public class CreateMasterDataObject
{
	private const string MASTER_DATA_OBJECT_PATH = "Assets/Resources/MasterData/";
	private const string API_URL = "APIの外部公開設定でメモしたURLを記載する";
	private const string URL_FORMAT = "{0}?id={1}&name={2}";

	// 各スプレッドシートIDとシート名
	private const string CONSTANT_SPREAD_SHEET = "シートIDを適宜入れる";
	private const string TEXT_SHEET_NAME = "text";

	private static IEnumerator m_NowUpdate = null;

	[MenuItem("Assets/MasterData/Constant/CreateOrUpdateConstantTextTable")]
	private static void CreateOrUpdateConstantTextTable()
	{
		CreateOrUpdateCalc<ConstantText, ConstantTextTable>(CONSTANT_SPREAD_SHEET, TEXT_SHEET_NAME);
	}

	// テーブル作成or更新～保存処理までの共通処理
	private static void CreateOrUpdateCalc<B, T>(string spreadSheetId, string sheetName) where B : MasterDataItemBase where T : MasterDataTableBase<B>
	{
		string resultText = string.Empty;

		m_NowUpdate = SendWebRequestToGet(string.Format(URL_FORMAT, API_URL, spreadSheetId, sheetName), (string result)=>{ resultText = result; });
		while(m_NowUpdate.MoveNext()){}

		if(resultText.IsNullOrEmpty())
		{
			Debug.LogWarning("resultTextが空の為、終了しました");
			return;
		}
		
		// ScriptableObjectのファイル名はテーブルの型名と同じにしておく
		string tableFileName = typeof(T).ToString();
		string masterAssetPath = string.Format("{0}{1}.asset", MASTER_DATA_OBJECT_PATH, tableFileName);
		T master = AssetDatabase.LoadAssetAtPath<T>(masterAssetPath);
		// データが無い場合は新規作成
		if(master == null)
		{
			master = Editor.CreateInstance<T>();
			AssetDatabase.CreateAsset(master, masterAssetPath);
			AssetDatabase.Refresh();
		}

		// 取得したAPIResultをJsonに変換してテーブルに格納する
		var json = MiniJSON.Json.Deserialize(resultText) as Dictionary<string, object>;
		var tableList = json.GetValueList<Dictionary<string, object>>("Table");
		// クリアしてから格納する
		master.Clear();
		foreach (var item in tableList)
		{
			master.AddOrUpdate(item);
		}

		//ダーティとしてマークする(変更があった事を記録する)
		EditorUtility.SetDirty(master);
		//保存する
		AssetDatabase.SaveAssets();
		
		Debug.LogFormat("{0}作成完了", tableFileName);
		Debug.Log(masterAssetPath);
	}

	// Web通信用関数
	private static IEnumerator SendWebRequestToGet(string url, Action<string> callback)
	{
		Debug.Log("API通信開始");
		
		UnityWebRequest request = UnityWebRequest.Get(url);
		yield return request.SendWebRequest();

		while(!request.isDone)
		{
			yield return 0;
		}

		// エラーチェック
		if(request.isNetworkError || request.responseCode != 200)
		{
			Debug.LogError(request.responseCode);
			Debug.LogError(request.error);
			m_NowUpdate = null;
			if(callback != null)
			{
				callback(string.Empty);
			}
			yield break;
		}

		Debug.Log(request.downloadHandler.text);
		Debug.Log(request.downloadHandler.data);

		if(callback != null)
		{
			// ここで改行を置き換え無いと改行がDebug.LogやuGUI.textで反映されない
			string text = request.downloadHandler.text.Replace("\\n", Environment.NewLine);
			callback(text);
		}

		m_NowUpdate = null;
		Debug.Log("API通信終了");
	}
}
```

### 動作テスト
Projectビューで右クリック、もしくはEditor上部のAssetsをクリック。するとMasterDataという欄が下の方にあります。
MasterData→Constant→CreateOrUpdateConstantTextTableを実行すると、コードで指定したパスのフォルダにScriptableObjectが生成されます。

![ss9.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/d8661d6d-2942-d609-551f-ce15d5e4fd8f.png)
![ss10.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/006ec72e-9004-a25a-06a5-919134c71c22.png)
![ss11.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/76ce4a0d-ade1-383a-1f82-c8f3c569515f.png)
![ss12.PNG](https://qiita-image-store.s3.amazonaws.com/0/332769/ae84f32e-5531-617b-1ee2-56a0ec266f62.png)


# まとめ
主に継承とジェネリックを使ってマスターデータ作成・更新処理をスッキリさせてみましたが、やっぱりジェネリックは奥が深いですね。
この記事を書く前からコード自体は書いてあったものの、その時も``where T``の指定方法やジェネリック引数付きコンストラクタで頭を悩ませてました。

ただ手間は多少削減できたものの、どうしても新規シートを追加した時手動でそのシートに対応するパラメーターなどを記載したクラスと、ScriptableObject作成処理の追加をしているので、ここら辺も出来ればボタン一つで実行できれば大分楽になりそうです。今回は特に扱わなかったですが、シートに型情報と変数は記載してあり、APIからその情報も取得できる状態ではあるのでちょっと頑張ればできそうな気もしますね。

理想は新規データをスプレッドシートに記載したら、Unity側で専用ウィンドウを表示。シートIDとシート名をテキスト入力欄に入れて、ボタンを押せばクラスファイルとオブジェクト作成までやってくれる、ってところですかね。

# 補足
- C#コード内にちょくちょく現れている``string.IsNullOrEmpty()``。これは自分でstring型の拡張メソッドを定義して使っています。コードは以下の通り。

```C#:StringExtensions.cs
// stringの拡張クラス
public static class StringExtensions
{
	public static bool IsNullOrEmpty(this string source)
	{
		if(source == null || source.Length <= 0 || source == "null" || source == "Null" || source == "NULL")
		{
			return true;
		}
		return false;
	}
}
```

# 参考記事まとめ
MiniJson
https://gist.github.com/darktable/1411710
【Unity】セリフやステータスなど大事な情報をGoogleスプレッドシートだけで管理する【GAS】
https://qiita.com/john95206/items/22ad04e2d30799954c76
GASでGetパラメータを受け取ってスプレッドシートに書き込む方法
https://qiita.com/hirohiro77/items/a947416f803f45777338
StartCoroutine(MonoBehaviour)を使わずにコルーチンを実行する【Unity】【エディタ拡張】
http://kan-kikuchi.hatenablog.com/entry/Coroutine_Editor
型パラメーターの制約 (C# プログラミング ガイド)
https://docs.microsoft.com/ja-jp/dotnet/csharp/programming-guide/generics/constraints-on-type-parameters
【C#】ジェネリック引数付きコンストラクタ 2
http://kou-yeung.hatenablog.com/entry/2016/04/22/004627
Activator.CreateInstance Method
https://docs.microsoft.com/ja-jp/dotnet/api/system.activator.createinstance?redirectedfrom=MSDN&view=netframework-4.7.2#System_Activator_CreateInstance_System_Type_System_Object___
ScriptableObjectの変更した値が戻ってしまう場合の対処法【Unity】【ScriptableObject】【トラブルシューティング】
http://kan-kikuchi.hatenablog.com/entry/ScriptableObject_SetDirty_SaveAssets