Help us understand the problem. What is going on with this article?

ローカライズをAirtableとMasterMemoryで行う

More than 1 year has passed since last update.

前回の記事

https://qiita.com/yKimisaki/items/dd49e8f0ebe8c22f87ca
>MasterMemoryとAirtableでいい感じにマスタを高速に読む

ローカライズとは

多言語対応です、日本語のアプリを英語に翻訳したりなどです。
基本的にアプリに文字列を埋め込んでるとなかなか面倒くさかったり、漏れたりするのですが、最初の段階でローカライズの仕組みを手元に持っておかないのがたいてい面倒になる理由です。
というわけでUnity上で簡単お手軽なローカライズ機能を作ってみました。

その前に

前回の記事と、
https://qiita.com/yKimisaki/items/dd49e8f0ebe8c22f87ca
>MasterMemoryとAirtableでいい感じにマスタを高速に読む

https://ykimisaki-my.sharepoint.com/:p:/g/personal/y_kimisaki_kimisaki_jp/EX9J_lb_fj1GptYDZ4tX_ZcBanQZ_FfNDIpQDT4wOrZueQ?rtime=uXJK0VQM10g
>明日から使えるMagicOnion
の後半で紹介しているGitHub上のプロジェクトのApplicationEntryPoint

の知識があると楽です。

作戦

image.png

このようなテーブルとシートがあったときに、以下の3パターンで設定できると便利かなと思います。

1. Unity上で直接Textに設定

image.png

Textを継承したLocalizationTextみたいなコンポーネントを作ります。
エディタ上で埋め込みと同じ感じですね。

2. スクリプト上で固定値を読み込む

private void Start()
{
    FruitNameText.text = Localization.Fruit.Apple;
}

スクリプトに文字列を埋め込みと同じ感じですね。

3. スクリプト上でIDから引っ張ってくる

private void Start()
{
    FruitNameText.text = Localization.Fruit.Search(fruitNameID);
}

マスターやステージのIDなどから動的に変化させることができます。

実装をする

MasterMemoryのテーブルを作成

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public class LocalizationSheetNameAttribute : Attribute
{
    public string SheetName { get; }

    public LocalizationSheetNameAttribute(string sheetName)
    {
        SheetName = sheetName;
    }
}

LocalizationSheetNameAttributeはあとでその役割がわかります、とりあえずこういうのがあると便利と思ってください。

[Union(0, typeof(FruitLocalizationSheet))]
public interface ILocalizationSheet
{
    string LocalizationID { get; set; }
    string JP { get; set; }
    string EN { get; set; }
}

[MessagePackObject(true)]
[MemoryTable(nameof(FruitLocalizationSheet))]
[LocalizationSheetName("Fruit")]
public class FruitLocalizationSheet : ILocalizationSheet
{
    [PrimaryKey]
    public string LocalizationID { get; set; }

    public string JP { get; set; }
    public string EN { get; set; }
}

各シートに紐づくクラスを作成します。
ここでは必ずインターフェイスをかませましょう、理由は

public static string GetLocalizedString(this ILocalizationSheet sheet)
{
    switch (UserConfig.Current.Language)
    {
        case Language.JP:
            return sheet.JP;
        case Language.EN:
            return sheet.EN;
    }

    return sheet.LocalizationID;
}

という拡張メソッドを定義したいからです。

MasterMemoryのテーブルにAirtableからデータを詰め込む

Localization.Fruit.Apple;

をコード上で達成するには、

public static class Localization
{
    public static class Fruit
    {
        private static FruitLocalizationSheetTable table;

        public static string Orange => table.FindByLocalizationID("Orange").GetLocalizedString();
        public static string Apple => table.FindByLocalizationID("Apple").GetLocalizedString();
        public static string Grape => table.FindByLocalizationID("Grape").GetLocalizedString();

        public static void Register(MemoryDatabase db)
        {
            table = db.FruitLocalizationSheetTable;
        }

        public static string Search(string localizationID)
        {
            switch (localizationID)
            {
                case "Orange":
                    return Orange;
                case "Apple":
                    return Apple;
                case "Grape":
                    return Grape;
            }
            return localizationID;
        }
    }
}

というようなコードが必要です。
つまり、Airtableからデータを持ってきてコードを生成してあげます。

https://github.com/Cysharp/MicroBatchFramework
MicroBatchFrameworkやらなんでもいいのですが、.NET Coreのコンソールアプリを用意します。

そして

https://gist.github.com/yKimisaki/f02d3dd75f305ce22b66e2b4b8625f4c

みたいなコード生成ロジックを書きます。
もちろんリフレクション増し増しです。

ポイントはいくつかあるのですが、

var attribute = type.GetCustomAttribute<LocalizationSheetNameAttribute>();

ここでLocalizationSheetNameAttributeがついたクラスのみ生成の対象とします。

var loadTableAsync = loadTableAsyncMethodInfo.MakeGenericMethod(type);
dynamic rowsTask = loadTableAsync.Invoke(client.GetLocalizationBase(), new[] { attribute.SheetName });
dynamic rows = await rowsTask;

これは、
https://github.com/yKimisaki/AirtableClient/blob/master/AirtableClient/AirtableClient/AirtableBase.cs#L26
を読んでいるところです。
型がわからなくてもとりあえずdynamicで取っておけば、実態がTaskならそのままawaitもできます。

で、これを実行すると

using MyProject.Tables;
namespace MyProject.Client.LocalizationSystem
{
    public static class Localization
    {
        public static class Fruit
        {
            private static FruitLocalizationSheetTable table;
            public static string Orange => table.FindByLocalizationID("Orange").GetLocalizedString();
            public static string Apple => table.FindByLocalizationID("Apple").GetLocalizedString();
            public static string Grape => table.FindByLocalizationID("Grape").GetLocalizedString();
            public static void Register(MemoryDatabase db)
            {
                table = db.FruitLocalizationSheetTable;
            }
            public static string Search(string localizationID)
            {
                switch (localizationID)
                {
                    case "Orange":
                        return Orange;
                    case "Apple":
                        return Apple;
                    case "Grape":
                        return Grape;
                }
                return localizationID;
            }
        }
        public static void Register(MemoryDatabase db)
        {
            Fruit.Register(db);
        }
        public static string Search(LocalizationTable table, string localizationID)
        {
            switch(table)
            {
                case LocalizationTable.Fruit:
                    return Fruit.Search(localizationID);
            }
            return localizationID;
        }
    }
    public enum LocalizationTable
    {
        Fruit,
    }
}

というコードが生成されます。
生成先はAssets/Scripts/Generatedフォルダとかにしておくといいと思います。

ここでApplicationEntryPointというやつで、

using UniRx;
using UniRx.Async;
using UnityEngine;
using MyProject.Client.LocalizationSystem;

namespace MyProject.Client
{
    public static class ApplicationEntryPoint
    {
        private static UniTaskCompletionSource _source = new UniTaskCompletionSource();
        public static async UniTask WaitInitializationAsync()
        {
            await _source.Task;
            await UniTask.SwitchToMainThread();
        }

        [RuntimeInitializeOnLoadMethod]
        private static void Main()
        {
            MainCoreAsync().Forget();
        }

        private static async UniTask MainCoreAsync()
        {
            MessagePack.Resolvers.CompositeResolver.RegisterAndSetAsDefault
            (
                MessagePack.Resolvers.GeneratedResolver.Instance,
                MessagePack.Resolvers.BuiltinResolver.Instance,
                MessagePack.Resolvers.PrimitiveObjectResolver.Instance,
                MasterMemoryResolver.Instance,
                Resolvers.MagicOnionResolver.Instance
            );

            var loader = new ClientMasterLoader(MessagePack.Resolvers.CompositeResolver.Instance);
            var db = new MemoryDatabase(await loader.LoadAsync(), formatterResolver: loader.FormatterResolver);
            MasterCache.Register(db);
            Localization.Register(db);

            _source.TrySetResult();
        }
    }
}

Localization.Register(db)の部分でMasterMemoryに格納されている翻訳データを渡します。

LocalizationTextComponentを作成

public class LocalizationText : Text
{
    public LocalizationTable LocalizationTable;
    public string LocalizationID;

    protected override async void Awake()
    {
        if (Application.isPlaying)
        {
            await ApplicationEntryPoint.WaitInitializationAsync();

            base.Awake();

            text = Localization.Search(LocalizationTable, LocalizationID);
        }
    }
}

await ApplicationEntryPoint.WaitInitializationAsync()をすることで、必ず初期化を待てます。
つまりエディタ上でどのシーンから起動しても大丈夫。サイコー!

で、InspectorのほうもUnityEditor.UI.TextEditor拡張で作ります。

[CustomEditor(typeof(LocalizationText))]
public class LocalizationTextInspector : UnityEditor.UI.TextEditor
{
    private LocalizationText component;
    private bool isOpen = false;

    private LocalizationTable prevLocalizationTable;
    private string[] localizationIdArray;
    private int selectedIndex;
    private int prevSelectedIndex;

    protected override void OnEnable()
    {
        base.OnEnable();

        if (component == null)
        {
            component = target as LocalizationText;
            if (component == null)
            {
                return;
            }
        }
        if (!string.IsNullOrEmpty(component.LocalizationID))
        {
            SetText();
        }
    }

    public override void OnInspectorGUI()
    {
        component.LocalizationTable = (LocalizationTable)EditorGUILayout.EnumPopup("Localization Sheet", component.LocalizationTable);
        if (localizationIdArray == null || prevLocalizationTable != component.LocalizationTable)
        {
            if (localizationIdArray != null && prevLocalizationTable != component.LocalizationTable)
            {
                component.LocalizationID = "";
            }

            foreach (var nestedType in typeof(Localization).GetNestedTypes())
            {
                if (nestedType.Name != component.LocalizationTable.ToString())
                {
                    continue;
                }

                localizationIdArray = nestedType.GetProperties()
                    .Select(x =>
                    {
                        if (x.Name[0] == '_')
                        {
                            return x.Name.Substring(1);
                        }
                        return x.Name;
                    })
                    .OrderBy(x => x)
                    .ToArray();
            }

            if (string.IsNullOrEmpty(component.LocalizationID))
            {
                prevSelectedIndex = -2;
                selectedIndex = -1;
            }
            else
            {
                for (var i = 0; i < localizationIdArray.Length; ++i)
                {
                    if (component.LocalizationID == localizationIdArray[i])
                    {
                        prevSelectedIndex = i;
                        selectedIndex = i;
                    }
                }
            }

            prevLocalizationTable = component.LocalizationTable;
        }

        selectedIndex = EditorGUILayout.Popup("Localization ID", selectedIndex, localizationIdArray);
        if (selectedIndex >= 0 && selectedIndex != prevSelectedIndex)
        {
            component.LocalizationID = localizationIdArray[selectedIndex];

            SetText();

            prevSelectedIndex = selectedIndex;

            EditorUtility.SetDirty(component);
        }

        isOpen = EditorGUILayout.Foldout(isOpen, "Text Component");
        if (isOpen)
        {
            base.OnInspectorGUI();
        }
    }

    private void SetText()
    {
        if (Application.isPlaying)
        {
            return;
        }

        component.text = $"{component.LocalizationTable}:{component.LocalizationID}";
    }
}

これで編集中は

image.png

これで再生中は

image.png

という感じになります。

まとめ

またも雑い感じですみません。
AirtableとMasterMemoryをもう少し推し進めれていくとこういうことができます、ということをしたかったのですが、ちょっと大掛かりになってしまいました。
ある程度のパフォーマンスはMasterMemoryなどに任せてしまって、純粋に使い勝手のほうに軸を置いて作ってみるのもありですね。

yKimisaki
書いてるものは個人の感想です
http://kimisaki.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away