Unityでスプレッドシートを読み込む

# 使用ライブラリ
UniTask

https://github.com/Cysharp/UniTask

# 事前準備

スプレッドシートの共有設定をリンクを知っている全員にする。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304635/60961657-dc6d-c6c3-6d01-cb9ec663e2eb.png)

↓みたいな感じに変数名とかを合わせる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304635/4d1f60e2-4997-decc-eb9b-7e0451be61bc.png)

```C#
    [Serializable]
    public class AttackData 
    {
        public string attackKey;
        public int power;
        public Attribute attribute;
    }
```

JsonUtilityではなくJsonConvertを使ってるので、SerializeFieldを使えない。
泣く泣くpublic変数にします。
大文字小文字も多分合わせないといけないはず。

# コード
```C#
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;

namespace Takenokohal.Utility
{
    public static class CsvConverter
    {
        public static string CsvToJson(string csv)
        {
            var v = JsonConvert.SerializeObject(CsvToDictionary(csv));
            return v;
        }


        private static IReadOnlyList<IReadOnlyDictionary<string, object>> CsvToDictionary(string csv)
        {
            const string splitKey = @"""";
            var rows = csv.Split(new[] { splitKey + "\n" }, StringSplitOptions.None);
            var dataList = rows
                .Select(SplitRowToData).ToList();

            var cleanedData = CleanUp(dataList).ToList();

            var header = cleanedData[0];

            header.RemoveAll(string.IsNullOrEmpty);

            var dicList = new List<Dictionary<string, object>>();
            foreach (var currentRow in cleanedData.Skip(1))
            {
                var dic = new Dictionary<string, object>();
                for (int i = 0; i < header.Count; i++)
                {
                    var dataString = currentRow[i];
                    var key = header[i];
                    if (int.TryParse(dataString, out var result))
                    {
                        dic.Add(key, result);
                        continue;
                    }

                    dic.Add(header[i], dataString);
                }

                dicList.Add(dic);
            }

            return dicList;
        }
        
        private static string[] SplitRowToData(string row)
        {
            return row.Split(new[] { ',' }, StringSplitOptions.None);
        }

        private static List<List<string>> CleanUp(IReadOnlyList<IReadOnlyList<string>> origin)
        {
            var clone = new List<List<string>>();
            foreach (var data in origin.Where(value => !string.IsNullOrEmpty(value[0])))
            {
                var list = data.Select(s => s.Trim(new[]
                {
                    '"'
                })).ToList();

                clone.Add(list);
            }

            return clone;
        }
    }
}
```

```C#
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json;
using UnityEngine.Networking;

namespace Takenokohal.Utility
{
    public static class SpreadSheetLoader
    {
        public static async UniTask<IReadOnlyList<T>> LoadData<T>(string url)
        {
            var req = UnityWebRequest.Get(url);
            var result = await req.SendWebRequest();
            var csv = result.downloadHandler.text;
            var json = CsvConverter.CsvToJson(csv);

            var target = new List<T>();
            JsonConvert.PopulateObject(json, target, new JsonSerializerSettings()
            {
                ObjectCreationHandling = ObjectCreationHandling.Replace
            });
            return target;
        }
    }
}
```


# 解説
## SpreadSheetLoader
### csv読み込み
urlを引数に、好きなデータのデータのリストを持ってきます。

```C#
var req = UnityWebRequest.Get(url);
var result = await req.SendWebRequest();
var csv = result.downloadHandler.text;
```

この部分でスプレッドシートをstringとして読み込んでる。

URLの書き方は、

スプレッドシートの編集画面のURLが
```
https://docs.google.com/spreadsheets/d/hogehoge/edit?gid=0#gid=0
```

となっているはずなので、このhogehoge部分をコピペして、

```
"https://docs.google.com/spreadsheets/d/hogehoge/gviz/tq?tqx=out:csv&sheet=";
```

みたいに書く。

```
sheet=
```    
の後にシート名を書く。

### Jsonにコンバート

CsvConverter（後述）を使ってJsonにしてもらう。

```C#
            var json = CsvConverter.CsvToJson(csv);
```

### 好きなデータに変換

JsonConvert.PopulateObjectで変換。
UnityのJsonUtilityは使いにくかったのでNewtonsoft.JsonのJsonConvertを使いました。

```C#
            var target = new List<T>();
            JsonConvert.PopulateObject(json, target, new JsonSerializerSettings()
            {
                ObjectCreationHandling = ObjectCreationHandling.Replace
            });
            return target;
```

## CsvConverter

### 考え方

Jsonだとクラスの中身とDictionary<string,object>の中身が同じ形になることを利用して、まずDictionaryに変換してからそれをJsonにすることで好きなデータとして拾ってます。


### csvをListのListに

```C#
            const string splitKey = @"""";
            var rows = csv.Split(new[] { splitKey + "\n" }, StringSplitOptions.None);
            var dataList = rows
                .Select(SplitRowToData).ToList();
```

```C#
        private static string[] SplitRowToData(string row)
        {
            return row.Split(new[] { ',' }, StringSplitOptions.None);
        }
```
\nで切ってしまうと、スプレッドシート内で改行ができなくなってしまうので、""\nで区切ってます。
ダウンロードしてきたcsvデータに毎回ゴミ列が含まれていたのでそれを利用しました。

### データを整理
```C#
            var cleanedData = CleanUp(dataList).ToList();
```

```C#
        private static List<List<string>> CleanUp(IReadOnlyList<IReadOnlyList<string>> origin)
        {
            var clone = new List<List<string>>();
            foreach (var data in origin.Where(value => !string.IsNullOrEmpty(value[0])))
            {
                var list = data.Select(s => s.Trim(new[]
                {
                    '"'
                })).ToList();

                clone.Add(list);
            }

            return clone;
        }
```
1列目が空になっている行を削除。
1列目以外はわざと空欄にしてるかもしれないので消さない。
csvの生のデータを読んだ時に「"」がすごい邪魔そうな気がしたのでTrimしたが、要らないかも。

### 変数名を取得
```C#
            var header = cleanedData[0];

            header.RemoveAll(string.IsNullOrEmpty);
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304635/97f25fa3-733b-1a14-6580-a0fb0af596b2.png)

1行目は絶対こんな感じのヘッダーになっていると思うのでそれを取得。
空欄を削除。

### Dictionaryに変換

```C#
            var dicList = new List<Dictionary<string, object>>();
            foreach (var currentRow in cleanedData.Skip(1))
            {
                var dic = new Dictionary<string, object>();
                for (int i = 0; i < header.Count; i++)
                {
                    var dataString = currentRow[i];
                    var key = header[i];
                    if (int.TryParse(dataString, out var result))
                    {
                        dic.Add(key, result);
                        continue;
                    }

                    dic.Add(header[i], dataString);
                }

                dicList.Add(dic);
            }

            return dicList;
```
データの1行目はさっきのヘッダーになっているのでスキップ。
要素を取っていって、intにパースできそうならパースする。改造すればfloatにもできるかも。

# 実際に使っているコード

```C#
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using Sirenix.OdinInspector;
using SpellProject.Utility;
using Takenokohal.Utility;
using UnityEditor;
using UnityEngine;

namespace SpellProject.Data.Database
{
    public abstract class DatabaseBase<T> : SerializedScriptableObject
        where T : IKeyHoldingData
    {
        protected abstract DatabaseURL.SheetName GetSheetName();

        [SerializeField] private List<T> data;

        public IReadOnlyList<T> DataList => data;

        public T FindByKey(string key) => data.First(value => value.Key == key);
        public T FindByIndex(int index) => data[index];
        public int GetIndex(string key) => data.FindIndex(value => value.Key == key);

#if UNITY_EDITOR
        public async UniTask UpdateAsync()
        {
            data.Clear();
            var v = await SpreadSheetLoader.LoadData<T>(DatabaseURL.GetDataURL(GetSheetName()));

            data = v.ToList();
            EditorUtility.SetDirty(this);
            AssetDatabase.SaveAssets();
        }
#endif
    }
}
```

```C#
using SpellProject.Utility;
using UnityEngine;

namespace SpellProject.Data.Database
{
    [CreateAssetMenu(menuName = "Create AttackDatabase", fileName = "AttackDatabase", order = 0)]
    public class AttackDatabase : DatabaseBase<AttackData>
    {
        protected override DatabaseURL.SheetName GetSheetName()
        {
            return DatabaseURL.SheetName.Attack;
        }
    }
}
```

実際に自作ゲームで使ってるコード。
UpdateAsynceを呼んだらデータベースが更新されるようにしてる。

# 結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304635/719daa16-ee21-6d7b-c49d-4e24a02d37f1.png)