UnityのローカルDB（SQLite3）にGoogleSpreadSheetで設定したデータを流せるようにする

# 目次
[1.はじめに](はじめに)
[2.前提](#2-前提)
[3.GoogleSpreadSheetの作成](#3-GoogleSpreadSheetの作成)
[4.GoogleAppsScriptの作成](#4-GoogleAppsScriptの作成)
[5.Unity側の準備](#5-Unity側の準備)
[6.エディタ拡張の作成](#6-エディタ拡張の作成)
[7.おわりに](#7-おわりに)

# 1.はじめに
Unityでゲーム開発をする際に、必ずぶつかる壁としてデータ(マスタ・ユーザー)の管理をどうするか？ということが挙げられると思います。
そこで、なるべく設定・修正反映のコストと管理コストを抑えつつ、UnityのローカルDBへデータを流せるようにしていきたいと考え、この記事を書きました。
一部分だけでも何かの役に立てば嬉しいです。
なお、ローカルDBに流すだけなのでサーバー連携はしないです。

# 2.前提
詳細は後で記載するとして、まず前提説明をしようと思います。

・ データはGoogleSpreadSheetで管理する
・ Unity側でのローカルDBはSQLiteを使用する
・ サーバー連携はしない
・ Unityのバージョンは2023.2.2f1

その上で、GoogleSpreadSheetで書かれたデータをUnity側で管理しているSQLiteに流せるようにするという感じでやっていきます。

# 3.GoogleSpreadSheetの作成
まずデータ管理を行うスプレッドシートの作成を行っていきます。
スプレッドシートの作成は、Gmailアカウントにログインして右上メニューから行います。
<img width="30%" src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/ddafb2ad-304f-1f0c-26b8-bd1c3118322e.png>

そこから「空白のスプレッドシート」を選んで真っ新な状態のシートを開きます。

そのシートにデータを記入します。
1行目にカラム名、2行目に型名として、入力していきます。
また、シート名はテーブル名として統一します。
<img width="100%" src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/0d3c6f74-9652-c031-23f0-6c1964c57fd8.png>

備考: シートの共有設定をしたい場合はこちらの記事などを参考に行ってください。
https://closuppo.com/spreadsheet-restrictions/

# 4.GoogleAppsScriptの作成
次に入力したデータをUnityで取り込めるようにjsonで出力するGAS（GoogleAppsScript）を作成していきます。
スプレッドシートのツールバーから、「拡張機能」→「AppsScript」を選択して、AppsScriptを追加できるようにします。
![資料3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/d37943cd-0d9b-5b74-1a6a-121ab25d63f1.png)
こんな感じの画面になっていればOKです。

そこのコードに下記をコピペします
```
// jsonでデータをエクスポート
function exportData() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheets = spreadsheet.getSheets();

  // Json用のデータを格納する
  let objectData = { };

  // 各シート別に処理
  for (var i = 0; i < sheets.length; i++) {
    const sheet = sheets[i];
    const sheetName = sheet.getName();
    const lastRow = sheet.getLastRow();
    const lastColumn = sheet.getLastColumn();

    // カラム名と型名の取得
    const columnNames = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
    const columnTypes = sheet.getRange(2, 1, 1, lastColumn).getValues()[0];

    const upperCamelColumnNames = columnNames.map(function (name) {
      return toPascalCase(name);
    });

    // データの取得
    const data = sheet.getRange(3, 1, lastRow - 2, lastColumn).getValues();

    // テーブルごとにオブジェクトを作成
    const tableData = [];
    for (let j = 0; j < data.length; j++) {
      const rowData = {};
      for (let k = 0; k < upperCamelColumnNames.length; k++) {
        const columnName = upperCamelColumnNames[k];
        const columnType = columnTypes[k];
        rowData[columnName] = data[j][k];
      }
      tableData.push(rowData);
    }

    // テーブル名をシート名にしてデータに追加
    objectData[toPascalCase(sheetName)] = tableData;
  }

  // データをJSON形式に変換
  const jsonData = JSON.stringify(objectData);

  // ログ出力
  Logger.log(jsonData);

  // Blob作成
  const blob = Utilities.newBlob(jsonData, "application/json", "exported_data.json");

  // ファイルをダウンロードできるダイアログを生成
  const url = "data:application/json;base64," + Utilities.base64Encode(blob.getBytes());
  const htmlOutput = HtmlService.createHtmlOutput('<a href="' + url + '" download="exported_data.json">Download exported_data.json</a>')
    .setWidth(400)
    .setHeight(100);
  SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'Download JSON File');
}

// CSharpクラスのEntityクラスを作成
function generateCSharpEntities() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheets = spreadsheet.getSheets();
  let entityNames = [];

  // ファイルを格納するフォルダの作成
  const folder = DriveApp.createFolder("CSharpEntities");

  try {
    // 各シートごとに処理
    for (let i = 0; i < sheets.length; i++) {
      const sheet = sheets[i];
      const sheetName = toPascalCase(sheet.getName());
      entityNames.push(sheetName);
      const lastRow = sheet.getLastRow();
      const lastColumn = sheet.getLastColumn();

      // カラム名と型名の取得
      const columnNames = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
      const columnTypes = sheet.getRange(2, 1, 1, lastColumn).getValues()[0];

      // カラム名をパスカルケースに変換
      const pascalCaseColumnNames = columnNames.map(function (name) {
        return toPascalCase(name);
      });

      // シートごとのC#クラスの文字列を生成
      let code = "[System.Serializable]\n" + "public class " + sheetName + "\n{\n";

      // カラムごとにプロパティを生成
      for (let j = 0; j < pascalCaseColumnNames.length; j++) {
        code += "  public " + columnTypes[j] + " " + pascalCaseColumnNames[j] + " { get; set; }\n";
      }

      code += "}\n\n";

      saveCSharpCode(code, sheetName, folder);
    }

    // Entity受け取り用のSpreadSheetDataクラスを作成する
    const spreadSheetDataClassName = "SpreadSheetData";
    let code = "[System.Serializable]\n" + "public class " + spreadSheetDataClassName + "\n{\n";
    // Entityごとにプロパティを生成
    for (let i = 0; i < entityNames.length; i++) {
      code += "  public " + entityNames[i] + " " + entityNames[i] + " { get; set; }\n";
    }
    code += "}\n\n";
    saveCSharpCode(code, spreadSheetDataClassName, folder);

    // フォルダをZIP圧縮
    const files = [];
    const fileIterator = folder.getFiles();
    while (fileIterator.hasNext()) {
      files.push(fileIterator.next().getBlob());
    }

    const zipBlob = Utilities.zip(files, "CSharpEntities.zip");

    // Blobをダウンロード
    const url = "data:application/zip;base64," + Utilities.base64Encode(zipBlob.getBytes());
    const htmlOutput = HtmlService.createHtmlOutput('<a href="' + url + '" download="CSharpEntities.zip">Download CSharpEntities.zip</a>')
      .setWidth(400)
      .setHeight(100);

    // ダウンロードリンクを表示
    SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'Download CSharpEntities.zip');
  } finally {
    // フォルダを削除
    folder.setTrashed(true);
  }
}

function toPascalCase(str) {
  const initial = str.substring(0, 1).toUpperCase();
  const upperCamel = str.substring(1).replace(/_([a-z])/g, function (_, p1) {
    return p1.toUpperCase();
  });
  return initial + upperCamel;
}

function saveCSharpCode(code, name, folder) {
  // C#クラスを Blob として保存
  const blob = Utilities.newBlob(code, MimeType.PLAIN_TEXT);
  // Blob をファイルとして保存
  folder.createFile(blob.setName(name + ".cs"));
}

function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('カスタム')
    .addItem('データをエクスポート', 'exportData')
    .addItem('Entityクラスをエクスポート', 'generateCSharpEntities')
    .addToUi();
}
```

こちらをCtrl + S で保存した後にスプレッドシートの方に戻ると、
上部のツールバーに「カスタム」というものが追加されているのが確認できます。（少し表示されるまでにラグがあります。）

![資料4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/ffeb2925-3be7-6d38-4cac-31245cc3a01c.png)

先ほど追加したGASは、この「データをエクスポート」「Entityクラスをエクスポート」というコマンドと紐づいており、それぞれUnity側に渡すデータとクラスをスプレッドシートから読み込んで出力するものとなっています。

早速「データをエクスポート」を押してみてください。
![資料5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/2ecaa4d3-e83e-91eb-0afb-781e06a8509a.png)
正しくデータが先ほどのフォーマットに沿って入力されていた場合、このようなポップアップが出てくると思います。
この「exported_data.json」がデータの実体となります。
後の手順で使用するので保存しておいてください。

「Entityクラスをエクスポート」を押した時は、Unity側に組み込むためのEntityクラスが出力されます。
下記のようなものがzip化されて出力されるので、こちらも保存しておいてください。
![資料6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/5a87c5ef-cb63-65fb-23db-0d459546b1e1.png)

# 5.Unity側の準備
次にエクスポートしたデータを使用するためのUnity側の準備を進めていきます。
まずはUnityで新規にプロジェクトを作成します。2Dや3Dなどはどちらでも大丈夫です。
プロジェクトの作成手順は省略します。UnityHubから適宜対象のバージョンをインストールしてください。
https://unity.com/ja/download
（ここでは2023.2.2f1を使います。）
とりあえずプロジェクトを作成し、SampleSceneが開いている状態まで行きます。
![資料9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/95445a80-ce81-7e37-dff3-9841cf203a16.png)


続いてUnityでSQLiteを取り扱うためのプラグインとして、
SQLite4Unity3dを入れます。
https://github.com/robertohuertasm/SQLite4Unity3d/blob/master/SQLite4Unity3d.zip

こちらのzipをダウンロードした後解凍し、「SQLite4Unity3d」をUnityプロジェクトのAssets以下に配置してください。
「Plugins」と「SQLite.cs」が重要なので、それだけ持ってくる形でも問題ないです。
![資料8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/64667364-0974-191f-bbd9-1745a579cf87.png)

さらに先ほどダウンロードしたJsonをデシリアライズするためのプラグインとして、
NewtonsoftJsonを使います。
https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2/manual/index.html
こちらは、PackageManagerから入れることができるので、そちらを使用するのが楽です。
「Window」→「PackageManager」→「左上+ボタン」→「Install package from git URL」から入力欄に「com.unity.nuget.newtonsoft-json」と入れるとNewtonsoftJsonを入れることができます。
![資料10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/d231df25-123b-a5e3-6894-dbb233d7ff1f.png)
※補足 JsonUtilityではなく、わざわざNewtonsoftJsonを入れているのは、
JsonUtilityだと変換先でプロパティを使用している場合デシリアライズできないという問題があるためでした。

ここまででプラグインの準備はできたので、
先ほど生成したEntityクラスをプロジェクトに持ってきます。
今回は適当に「Assets/Scripts/Entity」というフォルダの下に持ってきました。
![資料11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/e733ac27-8151-a40d-bbfd-399b0a5f9452.png)

続けてSQLiteを実際に使えるようにする準備も行います。
下記のSQLiteManagerというクラスを使用して、SQLiteの操作を行えるようにしておきます。
また、「Assets」以下にSQLiteのDBを配置するための「StreamingAssets」という名前のフォルダを作っておいてください。
ちなみに、今回は初期化してデータを流すところまでなので、それに必要な関数しか定義していないです。
適宜UpdateやDeleteなど必要な関数は追加してください。
```
using System.Collections.Generic;
using SQLite4Unity3d;

public class SQLiteManager : SingletonMonoBehaviour<SQLiteManager>
{
    private SQLiteConnection connection;
    private readonly string databasePath = "Assets/StreamingAssets/data.db";

    private SQLiteManager()
    {
        connection = new SQLiteConnection(databasePath);
    }

    public void CreateTable<T> ()
    {
        connection.CreateTable<T>();
    }

    public void Clear<T>()
    {
        connection.DropTable<T>();
        connection.CreateTable<T>();
    }

    public void InsertAll<T>(List<T> entity)
    {
        connection.InsertAll(entity);
    }
}
```
SingletonMonoBehaviourも追加してください。
```
using UnityEngine;

public abstract class SingletonMonoBehaviour<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T instance;

    public static T Instance
    {
        get
        {
            if (instance == null)
            {
                var findObjects = FindObjectsByType<T>(FindObjectsSortMode.None);
                if (0 < findObjects.Length)
                {
                    instance = findObjects[0];
                }

                if (instance == null)
                {
                    var singletonObject = new GameObject(typeof(T).Name);
                    instance = singletonObject.AddComponent<T>();
                }
            }

            return instance;
        }
    }

    protected virtual void Awake()
    {
        if (instance == null)
        {
            instance = this as T;
            DontDestroyOnLoad(this);
        }
        else
        {
            Destroy(gameObject);
        }
    }
}
```

![資料12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/552b32eb-0eff-1db4-4622-3e55442fc369.png)

# 6.エディタ拡張の作成
最後に、Unityでデータを読み込むためのエディタ拡張の作成を行います。
Assets以下の任意の場所に「Editor」フォルダを作成し、その中に下記のSpreadSheetDataReaderを追加してください。
```
using UnityEngine;
using UnityEditor;
using System.IO;
using Newtonsoft.Json;

public class SpreadSheetDataReader : EditorWindow
{
    private string jsonFilePath = "";

    [MenuItem("Custom Tools/SpreadSheetDataReader")]
    private static void ShowWindow()
    {
        EditorWindow.GetWindow(typeof(SpreadSheetDataReader));
    }

    private void OnGUI()
    {
        GUILayout.Label("JSON File Path:", EditorStyles.boldLabel);
        jsonFilePath = EditorGUILayout.TextField(jsonFilePath);

        if (GUILayout.Button("Browse JSON File"))
        {
            BrowseJsonFile();
        }

        if (GUILayout.Button("Load Data"))
        {
            LoadData();
        }
    }

    private void BrowseJsonFile()
    {
        string initialPath = string.IsNullOrEmpty(jsonFilePath) ? Application.dataPath : jsonFilePath;
        string selectedPath = EditorUtility.OpenFilePanel("Select JSON File", initialPath, "json");

        if (!string.IsNullOrEmpty(selectedPath))
        {
            jsonFilePath = MakePathRelativeToProject(selectedPath);
            Repaint();
        }
    }

    private string MakePathRelativeToProject(string fullPath)
    {
        if (fullPath.StartsWith(Application.dataPath))
        {
            return "Assets" + fullPath.Substring(Application.dataPath.Length);
        }
        else
        {
            return fullPath;
        }
    }

    private void LoadData()
    {
        string fullPath = Path.Combine(Application.dataPath, jsonFilePath);
            if (File.Exists(fullPath))
            {
                string jsonContent = File.ReadAllText(fullPath);
                var result = JsonConvert.DeserializeObject<SpreadSheetData>(jsonContent);
                UpdateData(result);
                Debug.Log("Succeeded loading");
            }
            else
            {
                Debug.LogError("JSON file not found at path: " + fullPath);
            }
    }

    // --- 以下はプロジェクトごとに修正してください ---
    private void UpdateData(SpreadSheetData data)
    {
        SQLiteManager.Instance.Clear<Monster>();
        SQLiteManager.Instance.Clear<Item>();
        SQLiteManager.Instance.Clear<UserItem>();

        SQLiteManager.Instance.InsertAll<Monster>(data.Monster);
        SQLiteManager.Instance.InsertAll<Item>(data.Item);
        SQLiteManager.Instance.InsertAll<UserItem>(data.UserItem);
    }
}
```
![資料13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/0d70b469-53ee-70de-cb97-0b7b51f21508.png)

このプログラムの内容は、Jsonを読み込んでデシリアライズし、DBにデータをInsertするというものになっています。（Insert前に一度全部初期化しています。）
「以下はプロジェクトごとに修正してください」というコメント以下の部分に関しては、
プロジェクトごとで使用するEntityに対する記載を行うようにしてください。
ここでは、MonsterとItemとUserItemというEntityがあるので、それに対してデータを追加するようにしています。

こちらを記載してUnityプロジェクトに戻ると、ツールバーに「Custom Tools」というものが追加されていると思います。
![資料14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/e437607a-292b-adb1-90a3-70b86e4ded44.png)

早速、動作を確認してみましょう。
「Custom Tools」→「SpreadSheetDataReader」を選択すると、下記のようなウィンドウが出てきます。
![資料15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/4cf5da8b-665b-3672-ebd0-06299acd6262.png)

「Browse Json File」から先ほどダウンロードしたexported_data.jsonを選択し、
「Load Data」を押してください。
コンソールログを確認して、「Succeeded loading」と出たらデータが入っているはずです。

データが正しく入っているかどうか確認してみましょう。
「DB Browser for SQLite」をダウンロードします。
https://sqlitebrowser.org/dl/

インストールできたら、開いて「ファイル」→「データベースを開く」から
「Assets/StreamingAssets/data.db」を選択して開いてみましょう。
「Assets/StreamingAssets/data.db」は先ほどJsonを読み込んだタイミングで生成されているはずです。
![資料16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/353b9f24-61f6-d63e-0ba2-b6d81696c0e5.png)

開いてデータ閲覧から、正しくスプレッドシートで設定したデータが入っていればOKです。

![資料17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3664980/02719322-c35b-0abf-42c9-4676ee105efa.png)

データに修正があった場合は、スプレッドシートの更新を行った後に、
「データをエクスポート」して、再び「SpreadSheetDataReader」から読み込むとDBが更新されます。

テーブルを追加した場合は、上記の前に「Entityクラスをエクスポート」を行い、
追加したEntityクラスをUnityに入れた上で、SpreadSheetDataクラスの更新と、SpreadSheetDataReaderのUpdateDataの追記も行います。

# 7.おわりに
以上で、スプレッドシートからUnity側のSQLiteへデータを流せるようになりました。
課題としては、テーブルやカラムを追加する度にEntityクラスの更新もそうですが、SpreadSheetDataReaderのUpdateData部分の追記を行わなくてはならないところです。
この部分も別クラスとして切り出してエクスポートすべきかなと思っています。
また、ユーザーデータ部分に関しては別でボタンを用意して、マスタとは別で更新できるような形にした方が、使い勝手が大幅に向上すると思います。