[Unity] CSVファイルを用いたローカライズ対応

六回目の投稿です。よろしくお願いします。

2025/03/11
追記 : List\<string[]>ではシリアライズできないため、string[]を含む自作クラスLineDataを用いて、List\<LineData>としてデータを運用するようにしました。これにより、エディタ内に、文言データが記録されるようにしました。



# 目次
1. [概要](#概要)
2. [実装概要](#実装概要)
3. [実装方法](#実装方法)
4. [最後に](#最後に)



# 概要
##### 解説内容 :
1. CSVファイルの行ごとの読み込み方法
1. Idを用いた文言データの検索方法
1. ローカライズ対応 (多言語対応)

##### 利点 :
1. コードと文言データが分離され、データの参照性と改変容易性が向上する。
1. データを編集ソフト(例 : グーグルスプレッドシート)を用いて管理することで、大人数での管理が容易となる。
1. 最終的には、Unity上での変更点はCSVファイルの更新のみとなり、変更点が少ないことから、Gitでの競合リスクを低減できる。

##### 今回使用するCSVファイル :
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/480241/1d293fe5-b8d1-7baf-cede-c1a0eaae5492.png)
(例 : 言語がenのときに、idに1を指定すると、"number"が表示される。)



# 実装概要
処理の概略図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/480241/86303cdb-8f0c-99c5-a974-deaeea1fc2e7.png)


#### [1. CSVファイルの読み込み方法の概要](#1-csvファイルの読み込み方法の実装)
1. CSVファイルの内容を読み込むためのスクリプトファイル(CsvReader.cs)を作成する。
1. CSVファイル指定時に、StringReaderクラスを用いて上から順に行を読み込み、string配列に保存していく。
1. ローカライズ処理用の、CsvReaderクラスを継承したクラス(WordingReader.cs)を作成する。

::: note info
* 上記動作をエディタ上で完結させることにより、アプリ側での負担を減らす。
:::


#### [2. Idを用いたデータの検索方法の概要](#2-idを用いたデータの検索方法の実装)
1. CsvReader.csで、FindDataWithId(string)というidから文言を取得する処理を記述する。
1. テキストUIを設置し、文言を適用するスクリプトファイル(LocalizeText.cs)を作成する。
1. 動作確認用に、Idの更新に関するエディタ拡張を実装する。


#### [3. ローカライズ対応方法の概要](#3-ローカライズ対応方法の実装)
1. WordingReader.csで、言語タイプを設定し、言語変更時に指定したイベントが呼ばれるように、言語変更処理を記述する。
1. LocalizeText.csで、言語変更時に、言語に応じた文言が適用されるように処理を記述する。



# 実装方法
#### 1. CSVファイルの読み込み方法の実装
##### 1.1. CsvReader.csの作成
以下のように、CsvReader.csを作成する :
<details><summary>CsvReader.cs (tap)</summary>

```
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using System.IO;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace App.ReadCsv
{
    public class CsvReader : MonoBehaviour
    {
        /// <summary>
        /// 行データ
        /// </summary>
        [System.Serializable]
        public class LineData
        {
            public string[] Data
            {
                get => _data;
                set => _data = value;
            }
            [SerializeField]
            private string[] _data;
        }
        
        [SerializeField]
        private TextAsset csv;
        [SerializeField, HideInInspector]
        private List<LineData> rawReadDatas = new List<LineData>(); // エディタ上で設定済み
        
#if UNITY_EDITOR
        private void OnValidate()
        {
            if (!csv) { return; }

            var path = AssetDatabase.GetAssetPath(csv);
            if (!path.Contains(".csv"))
            {
                csv = null;
                rawReadDatas.Clear();
                Debug.LogError("CSVファイルを指定してください。");
                return;
            }
            rawReadDatas = Read(csv.text);
        }

        /// <summary>
        /// データの読み込み
        /// </summary>
        private static List<LineData> Read(string text)
        {
            var readDatas = new List<LineData>();
            if (string.IsNullOrEmpty(text)) { return readDatas; }

            var reader = new StringReader(text);
            while (reader.Peek() != -1)            // 読み込む文字がなくなるまで実行
            {
                var line = reader.ReadLine();      // 1行づつ読み込む
                var splitedLine = line.Split(","); // 行をセル単位で分割
                readDatas.Add(new LineData { Data = splitedLine });    
            }
            Debug.Log($"Read CSV :\n {string.Join("\n", readDatas.Select(e => string.Join(", ", e.Data)))}");

            return readDatas;
        }
#endif
    }
}
```

</details>

::: note info
* #if UNITY_EDITOR {処理} #endif : {処理}がUnityエディタでしか実行されないようにする。
* LineDataクラス : List\<string[]>はシリアライズできないので、string[]をラップする、ラッパークラスを用意し、シリアライズ可能としている。
* [SerializeField, HideInInspector] : インスペクター上では表示されないように、Unity内部に保存されるようにしている。
* OnValidate() : ファイルを指定した際などに呼ばれる。
* ".csv"という文字列が含まれている場合にしか許容しない : CSVファイルしか受け付けないようにする。
* StringReaderクラス : 行がなくなるまで、上から順に行を読み込み、保存していっている。
:::


##### 1.2. WordingReader.csの作成
以下のように、WordingReader.csを作成し、空のゲームオブジェクトに付与し、CSVファイルを適用させる :
<details><summary>WordingReader.cs (tap)</summary>

```
namespace App.ReadCsv.Localize
{
    public class WordingReader : CsvReader
    {
        
    }
}
```

</details>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/480241/6bf51471-239e-ed0b-39de-a22f2f358191.png)


#### 2. Idを用いたデータの検索方法の実装
##### 2.1. CsvReader.csに、Idから文言を取得する処理を記述
以下のように、CsvReader.csに追記する :
<details><summary>CsvReader.cs (tap)</summary>

```diff_c_sharp
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using System.IO;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace App.ReadCsv
{
    public class CsvReader : MonoBehaviour
    {
        /// <summary>
        /// 行データ
        /// </summary>
        [System.Serializable]
        public class LineData
        {
            public string[] Data
            {
                get => _data;
                set => _data = value;
            }
            [SerializeField]
            private string[] _data;
        }
        
        [SerializeField]
        private TextAsset csv;
        [SerializeField, HideInInspector]
        private List<LineData> rawReadDatas = new List<LineData>(); // エディタ上で設定済み

+       private static List<LineData> readDatas = new List<LineData>();
+      
+       private void Awake()
+       {
+           if (readDatas.Count > 0) { return; }
+       
+           readDatas = rawReadDatas;
+       }
+
+       /// <summary>
+       /// idを用いて、データを検索
+       /// </summary>
+       public static LineData FindDataWithId(string id)
+       {
+           var lineData = readDatas.Find(e => e.Data[0] == id);
+           if (lineData == null) { return lineData; }
+           
+           var lineDataExceptId = new LineData() { Data = new string[lineData.Data.Length - 1] };
+           for (var i = 0; i < lineData.Data.Length - 1; i++) // 1列目を取り除く
+           {
+               lineDataExceptId.Data[i] = lineData.Data[i + 1];
+           }
+           return lineDataExceptId;
+       }
        
#if UNITY_EDITOR
        private void OnValidate()
        {
            if (!csv) { return; }

            var path = AssetDatabase.GetAssetPath(csv);
            if (!path.Contains(".csv"))
            {
                csv = null;
                rawReadDatas.Clear();
                Debug.LogError("CSVファイルを指定してください。");
                return;
            }
            rawReadDatas = Read(csv.text);
        }

        /// <summary>
        /// データの読み込み
        /// </summary>
        private static List<LineData> Read(string text)
        {
            var readDatas = new List<LineData>();
            if (string.IsNullOrEmpty(text)) { return readDatas; }

            var reader = new StringReader(text);
            while (reader.Peek() != -1)            // 読み込む文字がなくなるまで実行
            {
                var line = reader.ReadLine();      // 1行づつ読み込む
                var splitedLine = line.Split(","); // 行をセル単位で分割
                readDatas.Add(new LineData { Data = splitedLine });    
            }
            Debug.Log($"Read CSV :\n {string.Join("\n", readDatas.Select(e => string.Join(", ", e.Data)))}");

            return readDatas;
        }
#endif
    }
}
```

</details>

:::note info
* readDatas : データをstaticとして運用することでシーン間で共有され、かつ、FindDataWithId()をstatic関数にでき、運用しやすくなる。
* readDatas.FirstOrDefault(e => e[0] == id) : 読み込んだデータの1行目の1列目が"id"という文字列であるかどうかを確認している。(今回使用したCSVファイルの1行目は、"id,jp,en"なので、満たしている。)
* FindDataWithId()では、1列目(id)が取り除かれた行(JpとEnの文言)のみが返される。
:::


##### 2.2. テキストUI(Legacy)を設置し、LocalizeText.csを作成
テキストUIを設置し、以下のように、LocalizeText.csを作成し、テキストに付与する :
<details><summary>LocalizeText.cs (tap)</summary>

```
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;

namespace App.ReadCsv.Localize
{
    [RequireComponent(typeof(Text))] // Textコンポーネントを自動で付与
    public class LocalizeText : MonoBehaviour
    {
        public string Id // idの取得・適用
        {
            get => id;
            set
            {
                id = value;
                onIdChanged?.Invoke();
            }
        }
        [SerializeField, Header("文言ID")]
        private string id;

        private Text text;

        private readonly UnityEvent onIdChanged = new UnityEvent();

        private void Awake()
        {
            text = GetComponent<Text>();
        }

        private void Start()
        {
            onIdChanged.AddListener(UpdateText);
        }

        private void OnEnable()
        {
            UpdateText();
        }

        private void UpdateText()
        {
            var wordings = CsvReader.FindDataWithId(id);
            if (wordings == null) 
            {
#if UNITY_EDITOR
                Debug.LogError("LocalizeText : 存在しないidです。");
#endif
                return; 
            }
            text.text = wordings.Data[0]; // 0 : Jp, 1 : En
        }
    }
}
```

</details>

::: note info
* CsvReader.FindDataWithId(id) : インスペクター上で指定したIdに応じた文言を取得
* UnityEvent onIdChanged : UpdateText()を登録しておくことで、Idが変更された際に、自動でテキストが更新されるように、Setter (string Id { set => ...} )を定義している。
:::

この時点で、テキストに付与したLocalizeText.idにidを指定することで、日本語の文言が表示されるようになっている。(例 : idに1を指定すると、テキストに"番号"と表示される。)


##### 2.3. LocalizeText.csに、Idの更新に関するエディタ拡張を実装
以下のように、LocalizeText.csに追記する :
<details><summary>LocalizeText.cs (tap)</summary>

```diff_c_sharp
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
+#if UNITY_EDITOR
+using UnityEditor;
+#endif

namespace App.ReadCsv.Localize
{
    [RequireComponent(typeof(Text))]
    public class LocalizeText : MonoBehaviour
    {
        public string Id
        {
            get => id;
            set
            {
                id = value;
                onIdChanged?.Invoke();
            }
        }
        [SerializeField, Header("文言ID")]
        private string id;

        private Text text;

        private readonly UnityEvent onIdChanged = new UnityEvent();

        private void Awake()
        {
            text = GetComponent<Text>();
        }

        private void Start()
        {
            onIdChanged.AddListener(UpdateText);
        }

        private void OnEnable()
        {
            UpdateText();
        }

        private void UpdateText()
        {
            var wordings = CsvReader.FindDataWithId(id);
            if (wordings == null) 
            {
#if UNITY_EDITOR
                Debug.LogError("LocalizeText : 存在しないidです。");
#endif
                return; 
            }
            text.text = wordings.Data[0]; // 0 : Jp, 1 : En
        }
    }

+#if UNITY_EDITOR
+   [CustomEditor(typeof(LocalizeText))]
+   public class LocalizeTextEditor : Editor
+   {
+       private string id;
+
+       public override void OnInspectorGUI()
+       {
+           base.OnInspectorGUI(); // LocalizeTextクラスでのインスペクター上での記述を消さないようにする。
+
+           GUILayout.Space(15);
+
+           using (new EditorGUILayout.HorizontalScope())
+           {
+               id = EditorGUILayout.TextField(id); // id入力欄
+
+               GUILayout.Space(5);
+
+               if (GUILayout.Button("Idを更新"))
+               {
+                   var creator = (LocalizeText)target; // LocalizeTextクラスの取得
+                   creator.Id = id;
+               }
+           }
+       }
+   }
+#endif
}
```

</details>

すると、エディタ上にIdの更新ボタンが出現するので、ゲームを実行し、Idを入力・更新する :
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/480241/b51636f7-124e-fd14-d4c9-6c8cbc207b58.png)


#### 3. ローカライズ対応方法の実装
##### 3.1. WordingReader.csに、言語タイプを設定し、言語変更時に指定したイベントが呼ばれるように、言語変更処理を記述
以下のように、WordingReader.csに追記する :
<details><summary>WordingReader.cs (tap)</summary>

```diff_c_sharp
+using UnityEngine;
+using UnityEngine.Events;
+
namespace App.ReadCsv.Localize
{
    public class WordingReader : CsvReader
    {
+       public enum LanguageType
+       {
+           Jp = 0,
+           En,
+       }
+
+       public static LanguageType CurrentLanguageType
+       {
+           get => currentLanguageType;
+           set
+           {
+               currentLanguageType = value;
+               OnLanguageSwitched?.Invoke();
+#if UNITY_EDITOR
+               Debug.Log($"WordingReader : 言語が{value}に変更されました。");
+#endif
+           }
+       }
+       private static LanguageType currentLanguageType = LanguageType.Jp;
+
+       public static UnityEvent OnLanguageSwitched { get; set; } = new UnityEvent(); // 言語変更時に呼ばれるイベント
+
+       public void SwitchNextLanguage()
+       {
+           var length = System.Enum.GetValues(typeof(LanguageType)).Length;
+           CurrentLanguageType = (LanguageType)(((int)CurrentLanguageType + 1) % length);
+       }
+
+       private void Update()
+       {
+#if UNITY_EDITOR
+           if (Input.GetKeyDown(KeyCode.L))
+           {
+               SwitchNextLanguage();
+           }
+#endif
        }
    }
}
```

</details>

:::note info
* Jp = 0 : FindDataWithId()では、1列目が取り除かれたreadDataが取得されるので、Jpが0番目に来るため、Jpを0番目と番号付けておく。
* SwitchNextLanguage() : デバッグ用に、Lキーで文言を変更できる機能を搭載しておく。
:::


##### 3.2. LocalizeText.csに、言語変更時に、言語に応じた文言が適用されるように処理を記述
以下のように、LocalizeText.csに追記する :
<details><summary>LocalizeText.cs (tap)</summary>

```diff_c_sharp
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace App.ReadCsv.Localize
{
    [RequireComponent(typeof(Text))]
    public class LocalizeText : MonoBehaviour
    {
        public string Id
        {
            get => id;
            set
            {
                id = value;
                onIdChanged?.Invoke();
            }
        }
        [SerializeField, Header("文言ID")]
        private string id;

        private Text text;

        private readonly UnityEvent onIdChanged = new UnityEvent();

        private void Awake()
        {
            text = GetComponent<Text>();
        }

        private void Start()
        {
            onIdChanged.AddListener(UpdateText);
        }

        private void OnEnable()
        {
+           WordingReader.OnLanguageSwitched.AddListener(UpdateText);
            UpdateText();
        }

        private void UpdateText()
        {
            var wordings = CsvReader.FindDataWithId(id);
            if (wordings == null) 
            {
#if UNITY_EDITOR
                Debug.LogError("LocalizeText : 存在しないidです。");
#endif
                return; 
            }
            
+           var languageType = WordingReader.CurrentLanguageType;
+           if (wordings.Data.Length <= (int)languageType)
+           {
+#if UNITY_EDITOR
+               Debug.LogError($"LocalizeText : 読み込んだデータに、言語タイプ\"{languageType}\"は存在しません。");
+#endif
+               return;
+           }
-           text.text = wordings.Data[0]; // 0 : Jp, 1 : En
+           text.text = wordings.Data[(int)languageType];
        }
        
+       private void OnDisable()
+       {
+           WordingReader.OnLanguageSwitched.RemoveListener(UpdateText);
+       }
    }

#if UNITY_EDITOR
   [CustomEditor(typeof(LocalizeText))]
   public class LocalizeTextEditor : Editor
   {
       private string id;

       public override void OnInspectorGUI()
       {
           base.OnInspectorGUI(); // LocalizeTextクラスでのインスペクター上での記述を消さないようにする。

           GUILayout.Space(15);

           using (new EditorGUILayout.HorizontalScope())
           {
               id = EditorGUILayout.TextField(id); // id入力欄

               GUILayout.Space(5);

               if (GUILayout.Button("Idを更新"))
               {
                   var creator = (LocalizeText)target; // LocalizeTextクラスの取得
                   creator.Id = id;
               }
           }
       }
   }
#endif
}
```

</details>

:::note info
* WordingReader.OnLanguageSwitched : イベントにUpdateText()を登録することで、言語変更時にテキストが更新されるようにする。
:::


# 最後に
どうでしたか、わかりやすかったでしょうか。
CSVファイルを用いてデータを管理することで、大人数での管理が容易になり、アセットの変更も最小限になり、Gitでの競合のリスクも低減されます。個人的には、グーグルスプレッドシートの使用を推奨しており、対応言語を増やす際にも便利だと思われます。
また、[前回](https://qiita.com/marthallll/items/ebee64b1ccec189900c9)の記事と組み合わせることで、ワンボタンで、GSS(文言データ)を取得し、適用されるようになります。つまり、GSS上で文言を編集した後、ボタンを押すだけで、それがゲーム内に反映されるようなシステムが作れます。
また気が向いたら何か書きます。それでは。

宣伝 : CUPLEXという時間差アクションパズルゲームを作成しています。私は主にプログラムとプロジェクト管理を担当しています。よかったら、覗いてみてください↓
https://store.steampowered.com/app/2499100/CUPLEX/