EnumにStringを紐付ける方法を駆使して、Unityの多言語対応機能を実装してみた

# はじめに
おはこんばんにちは！

ココネ株式会社でクライアント開発を担当しているIです。

ここでは、実際会社のコードでも利用している[Enumにstringを紐付ける方法](https://engineering.cocone.io/2021/06/02/unity_scene_enum/ "UnityのScene管理をEnumで行う話")を参考に、EnumでUIのテキストを指定し、指定した言語に切り替わる多言語対応機能を実装してみた話をしたいと思います。

完成形はこちら！
<img width=50% alt="多言語対応GIF" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2998478/4f6a317a-2929-dfb6-5072-debb0bdd7c02.gif"><img width=50% alt="Editor上の表示" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2998478/819a928a-f3f5-c89e-0f49-fd0fd0592b5d.png">

# 環境
Unity：2021.3.14f1
TextMeshPro：3.0.6

# Enumからテキストを取得する
まずは、指定したEnumからテキストを取得できるようにします。
```cs:TextEnum.cs
public enum Text
{
    [Text(new[]
    {
        "おはよう!",
        "Hello!"
    })]
    Hello
}

public static TextAttribute GetTextAttribute(this Enum value)
{
    return value
           .GetType()
           .GetField(value.ToString())
           .GetCustomAttributes(false)
           .OfType<TextAttribute>()
           .First();
        }
```
``` cs:TextAttribute.cs
 public class TextAttribute : Attribute
 {
    private string[] texts;
    public string Text
    {
        get
        {
            return texts[((int)Config.Language)];
        }
    }

    public TextAttribute(string[] texts)
    {
        this.texts = texts;
    }
}
```

``` cs:Config.cs
 public static class Config
    {
        public static Language Language => LanguageReactiveProperty.Value;

        public static ReactiveProperty<Language> LanguageReactiveProperty
            = new ReactiveProperty<Language>(Language.Japanese);
    }
```
``` cs:Language.cs
 public enum Language
    {
        Japanese,
        English,
    }
```
EnumのLanguageとTextのアトリビュートの文字列配列がそれぞれ対応しており、実際に利用すると、、
``` 
//textJP = "おはよう!"となる
var textJP = Text.Hello.GetTextAttribute().Text;

Config.LanguageReactiveProperty.Value = .Language.English;

//textEN = "Hello!"となる
var textEN = Text.Hello.GetTextAttribute().Text;
```
といった感じです。
これだけでも使い道はありそうです。

しかし、既に割と文量がいってしまいましたが、ここからが本番です(-_-;)
どんどん逝きましょう♪（ここからはコードで語るのがもっと多くなります）

# 問題点
今の状態だと、
1. 毎回GetTextAttribute()でテキストを取得しているため、無駄が多い
2. UIのテキストに利用するにはスクリプトから適用するしかない（Editor上でテキスト入力してもそれは多言語対応できない）

以上の問題があります。
順番に解決していきましょう！

# テキストをキャッシュする
「毎回GetTextAttribute()でテキストを取得しているため、無駄が多い」
これについては、キャッシュすれば解決しそうです。

しかし、一度利用したテキストすべてをキャッシュするのはメモリを圧迫するのでしたくありません...

そこで、参照がなくなれば削除される「ScritableObject」を利用することにし、
各画面ごとに必要なテキストのみをまとめたクラスを作成して分割することにしました。
こうすることで、表示する画面に必要なテキストのみがロードされ、表示しなくなった画面のテキストのキャッシュは消え、より無駄がなくなります。

以上をもとに共通処理をまとめたTextHolderクラス、そしてそれを継承させて各画面で必要なテキストのみをもったクラスを作成します。

なお、ScriptableObjectはほぼstaticと同じ挙動をするので継承元のTextHolderクラスでは変数(cacheなど)の実態を持つと、継承先の変数すべてが変更されてしまいます。
ですので、変数は継承先で実態を持つようにする必要があります。

``` cs:TextHolder.cs
 public class TextHolder : ScriptableObject
 {
        protected virtual TextDict[] cache { get; set; }
        //言語の変更を通知（UIのテキストを更新する際に利用）
        protected Subject<Unit> refreshedTextHolderSubject = new Subject<Unit>();
        public IObservable<Unit> refreshedTextHolderObservable => refreshedTextHolderSubject;

        protected TextDict[] LoadTextHolder()
        {
            return GetType()
                  .GetNestedTypes()
                  .First()
                  .GetFields()
                  .Skip(1)
                  .Select(field =>
                            new TextDict
                            (
                                field.Name,
                                field.GetCustomAttributes(false).OfType<TextAttribute>().First().Text
                            )
                         )
                  .ToArray();
        }

        public TextDict[] GetTextHolder()
        {
            if (cache == null)
            {
                cache = LoadTextHolder();
            }
            return cache;
        }
        
        public string GetText(int index)
        {
            return GetTextHolder()[index].Text;
        }
    }

    public class TextDict
    {
        //UIのエディタ拡張で利用する
        public string Label { get; }
        public string Text { get; }

        public TextDict(string label, string text)
        {
            Label = label;
            Text = text;
        }
    }
```

各画面で必要なテキストのみをもったクラスを作成します。
今回はタイトル画面で利用するクラス(TitleTextHolder)を作成します。
``` cs:TitleTextHolder.cs
 public class TitleTextHolder : TextHolder
    {
        public enum Text
        {
            [Text(new[]
            {
                "タイトル",
                "Title"
            })]
            Title,

            [Text(new[]
            {
                "スタート",
                "Start"
            })]
            Start,
        }

        public string GetText(Text label)
        {
            return GetText((int)label);
        }

        protected override TextDict[] cache { get; set; }

        private CompositeDisposable disposables = new CompositeDisposable();

#if UNITY_EDITOR
        //UnityEditor上ではAwake()はUnityEditor起動時にのみ呼ばれるので
        //OnEnable()で更新されるようにする
        private void OnEnable()
#else
        private void Awake()
#endif
        {
            disposables.Clear();
            //言語変更の際に呼ばれる
            Config.LanguageReactiveProperty.Subscribe(_ =>
            {
                //キャッシュしたテキストを更新
                cache = LoadTextHolder();
                //UIのテキストを更新
                refreshedTextHolderSubject.OnNext(Unit.Default);
            }).AddTo(disposables);
        }

        public void Dispose()
        {
            disposables.Clear();
        }
    }
```
このように、各画面ごとに必要なテキストをまとめた〇〇TextHolderクラスを適宜追加していきます。

ScriptableObjectのAwake・OnEnable・OnDestoryの挙動がよくわからず、意外と手こずってしまった...

なお、〇〇TextHolderクラスはText(Enum)の中身以外は同じなので、エディタ拡張を使ってスクリプトの自動生成をすればよいと思いますが、今回は直接関係ないので割愛します。

# Editor上でもテキストを指定できるようにする
「UIのテキストに利用するにはスクリプトから適用するしかない」
こちらはUIのエディタ拡張で解決できそうです。

具体的にはまず、TextMeshPro-Textを継承したコンポーネントクラス(MultiText)を作成します。
そして、それのエディタ拡張をして、〇〇TextHolder.Textを指定できるようにし、そのText(Enum)をもとにテキストが表示される、といった感じです。

``` cs:MultiText.cs
public class MultiText : TextMeshProUGUI
{
        //〇〇TextHolderを格納する
        [SerializeField] public TextHolder textHolder;
        //Editor上で指定した〇〇TextHolder.Text(Enum)をintに変換した値を格納する
        [SerializeField] public int DefaultTextLabelIndex;

        private int textLabelIndex;

        protected override void Start()
        {
            base.Start();
            if (textHolder == null)
            {
                return;
            }

            textLabelIndex = DefaultTextLabelIndex;

            SetText(textLabelIndex);
            //言語が変更された際に呼ばれ、UIのテキストを更新する
            textHolder.refreshedTextHolderObservable.Subscribe(_ =>
            {
                SetText(textLabelIndex);
            }).AddTo(this.gameObject);
        }

        //指定した〇〇TextHolder.Text(Enum)をintに変換した値をもとにUIのテキストを更新する
        public void SetText(int index)
        {
            var t = textHolder.GetText(index);
            if (string.IsNullOrEmpty(t))
            {
                return;
            }
            text = t;
            textLabelIndex = index;
        }
}
```
このクラスをエディタ拡張します。
``` cs:MultiTextEditor.cs
[CustomEditor(typeof(MultiText))]
public class MultiTextEditor : TMP_EditorPanelUI
{
        public override void OnInspectorGUI()
        {
            var component = target as MultiText;
            if (component == null)
            {
                return;
            }

            //〇〇TextHolderを登録できるようにする
            EditorGUILayout.PropertyField(serializedObject.FindProperty("textHolder"), new GUIContent("Text Holder"));

            if (component.textHolder != null)
            {
                //〇〇TextHolder.Textプルダウンメニューに登録する文字列配列
                var textDict = component.textHolder.GetTextHolder();

                //コピーで作成した場合、以前のDefaultTextLabelIndexが残ってしまい
                //IndexOutOfRangeExceptionになってしまうのを防ぐ
                var selectedIndex = Mathf.Clamp(component.DefaultTextLabelIndex, 0, textDict.Length - 1);

                //〇〇TextHolder.Textプルダウンメニューの作成
                //指定したText(Enum)を登録し、テキスト更新する
                component.DefaultTextLabelIndex = EditorGUILayout.Popup("Text Label", selectedIndex, textDict.Select(t => t.Label).ToArray());
                component.text = textDict[component.DefaultTextLabelIndex].Text;
            }
            else
            {
                component.DefaultTextLabelIndex = 0;
            }

            serializedObject.ApplyModifiedProperties();

            base.OnInspectorGUI();
        }
}
```
これでEditor上でもテキストが設定できるようになりました！
またUIのテキストもText(Enum)のintの値をもとにしていることから、言語が変更されるとテキストが更新されるようになりました。

よって、よーやく多言語対応機能が完成しました！！

# おわりに
今回の多言語対応機能のポイントはEditor上でもEnumを指定でテキストが設定できる点だと思います。
また、
〇〇TextHolder.GetText(〇〇TextHolder.Text.XXX);
でテキストを取得できるので、使いやすいのではないかと思います。

この記事が多言語対応機能、延いてはゲーム開発の参考になればと思います！！