💱

Unity Localization の設定をプロジェクトを跨いで共有する

2022/12/13に公開

はじめに

この記事は Unity Advent Calendar 2022 (その1) 13日目の記事になります。

前日の記事は @adarapata さんの VContainerがどのように動いているのか読む でした。


本稿では「Serialize された Unity Localization の LocalizedString をプロジェクトを跨いで共有する」ためのテクニックをご紹介します。

ぶっちゃけ、このテクニックが必要になる人はかなり少ないと思いますが、1人でもこの記事が刺さってくれる人がいたら嬉しいです。

バージョン情報

本稿で扱う各種バージョンは下記のとおりです。

  • Unity: 2022.2.0f1
  • Localization: 1.4.2

前提

Unity Localization は、その名の通り Unity プロジェクトをローカライズする際に有用なパッケージです。

文字列のローカライズだけでなく、テクスチャや音声などのアセットもローカライズ可能になっており、独自構文によるフォーマッタのサポートや変数の埋め込みやなど、ローカライズ時に欲しくなる機能が揃ったパッケージといえます。

筆者が勤める株式会社キッズスターのプロダクト「うごっこランド」でも活用しております。

背景

前述の「うごっこランド」では、複数の AR ゲームが1つのアプリに同梱されたゲームアプリであり、個々のゲームは独立した Unity プロジェクトとして開発を進めつつ、「母艦」となるプロジェクトでそれらを Unity Package としてインポートすることで1つのアプリとして成立するような仕組みを構築しております。

こういった構成にする場合に立ちはだかるのが アセットの GUID や Localization の各 Entry に割り振られる ID などのプロジェクト固有の ID をどう取り回すのか?という問題です。

もう少し具体的に言うと、母艦側で定義した Localization の StringTable への参照である TableReference.TableCollectionNameGuid や StringTable の Entry への参照である TableEntryReference.KeyId の値を、個別ゲームが知る術が無いため、共通的な LocalizedString を個別ゲーム側で用いることができなくなってしまいます。

このままではプロジェクト分割ができなくなってしまうので、なんとかせねば!ということで調査して、対応方法を見つけたので記事として認めた次第です。

本題

TableReference, TableEntryReference

「背景」の章でも軽く触れていますが、TableReference, TableEntryReference について深掘りします。

これらの型は struct として宣言された「Table や TableEntry の参照情報」です。(そのままw)

ザックリとした実装のイメージは、次に示すようなものになっています。(主要なフィールド・プロパティのみを抜粋し、プロパティの実装は詳細を省略しています)

[Serializable]
public struct TableReference
{
    // このフィールドの値が GUID:... となっている場合は ReferenceType = Guid として取り扱われる
    [SerializeField] string m_TableCollectionName;
    public TableReference.Type ReferenceType { get; private set; }
    // ReferenceType = Guid な場合に、Deserialize 時に Guid.Parse() された値が入る
    public Guid TableCollectionNameGuid { get; private set; }
    public string TableCollectionName
    {
        get => m_TableCollectionName;
        private set => m_TableCollectionName = value;
    }
}

[Serializable]
public struct TableEntryReference
{
    [SerializeField] long m_KeyId;
    [SerializeField] string m_Key;
    // m_KeyId が 0L でない場合は Type.Id、m_Key が空文字でない場合は Type.Name が設定される
    public TableEntryReference.Type ReferenceType { get; private set; }
    public long KeyId
    {
        get => m_KeyId;
        private set => m_KeyId = value;
    }
    public string Key
    {
        get => m_Key;
        private set => m_Key = value;
    }
}

これを見て分かるように、各 Reference 構造体はプロパティとして string 型の Name 的なプロパティと Guid 型や long 型の ID 的なプロパティを公開しており、Table や TableEntry 取得時には ReferenceType の値に対応する値を用いてテーブルを検索するような実装になっています。

また、string, Guid, long を各型にキャストする暗黙オペレータ(implicit operator)も実装されています。

LocalizedString

さて、続いて深掘りするのは LocalizedString です。

この型は [Serializable] な class として宣言されており、「MonoBehaviour や ScriptableObject にローカライズされた文字列への参照を Serialize する」際に有用です。

ザックリとした実装のイメージは、次に示すようなものになっています。(主要なフィールド・プロパティのみを抜粋し、プロパティの実装は詳細を省略しています)

public abstract class LocalizedReference
{
    [SerializeField] TableReference m_TableReference;
    [SerializeField] TableEntryReference m_TableEntryReference;
    public TableReference TableReference
    {
        get => m_TableReference;
        set => m_TableReference = value;
    }
    public TableEntryReference TableEntryReference
    {
        get => m_TableEntryReference;
        set => m_TableEntryReference = value;
    }
}

[Serializable]
public class LocalizedString : LocalizedReference
{
}

先述した TableReference や TableEntryReference を Serialize しており、これらのフィールドを書き換えるための Setter プロパティも公開しています。

LocalizedString.GetLocalizedString()

LocalizedString.GetLocalizedString() は以下のように宣言されたメソッドで、そのままズバリローカライズされた文字列を取得するためのメソッドです。

// 同期メソッドは内部的に GetLocalizedStringAsync().WaitForCompletion(); を実行している
public string GetLocalizedString();
public string GetLocalizedString(params object[] arguments);
public string GetLocalizedString(IList<object> arguments);

public AsyncOperationHandle<string> GetLocalizedStringAsync();
public AsyncOperationHandle<string> GetLocalizedStringAsync(IList<object> arguments)

ランタイムでは、このメソッドを叩いてローカライズされた文字列を出力したりします。

Inspector での LocalizedString 設定

さて、本稿の核心となるトピックがこちらです。

次のように LocalizedString を SerializeField に設定した MonoBehaviour があったとして、このフィールドにインスペクタから対象の Entry を選択したとします。

public class Sample : MonoBehaviour
{
    [SerializeField] private LocalizedString _localizedString;

    private void Start()
    {
        Debug.Log(_localizedString.GetLocalizedString());
    } 
}

Untitled

この時、選択されている Table や TableEntry を示す TableReference や TableEntryReference がどうなっているのかをインスペクタを Debug モードにして確認してみましょう。

Untitled

ReferenceTypeGuidId となっており、文字列ではなく ID での参照となっていることが確認できます。

通常のプロジェクトであればこれで問題ないのですが、プロジェクトを分割している場合、LocalizedString を設定したプロジェクト上での ID が設定されてしまうので、この ID を知らない別のプロジェクトで当該コンポーネントを開くと Missing 扱いになってしまいます。

Untitled

もちろん、StringTable ごと共有してしまえばこの問題は解消されるのですが、さまざまな事情により「StringTable はプロジェクト毎に作成したい」というケースもあると思うので、その場合にこの問題が壁になります。

解決策

ここまで周りくどく書いてきましたが、ID ベースではなく文字列ベースの参照に変更すれば万事解決です。

インスペクタを Debug モードにして、TableReference の Table Collection Name や TableEntryReference の Key に目的の Table, TableEntry の名前を設定するだけです。(TableEntryReference の Key Id を空にするのをお忘れなく)

Untitled

解決案 +α

上述の手段で問題解決はできるとはいえ、毎回手動で入力していくのは骨が折れますし、ミスも起きやすいです。

そういった処理は Editor スクリプトにやらせるのが定石ということで、以下のようなスクリプトを書いてみました。

// 以下は Editor 向け Assembly に定義

public static class LocalizationHelper
{
    private static IDictionary<TableReference, SharedTableData> SharedTableDataCaches { get; } = new Dictionary<TableReference, SharedTableData>();
    private static Locale EditorLocale { get; set; }

    [MenuItem("CONTEXT/" + nameof(LocalizableMonoBehaviour) + "/Convert String Reference")]
    [MenuItem("CONTEXT/" + nameof(LocalizableScriptableObject) + "/Convert String Reference")]
    private static void ConvertStringReference(MenuCommand menuCommand)
    {
        EditorLocale ??= LocalizationSettings.Instance.GetAvailableLocales().Locales.First();
        if (EditorLocale == default)
        {
            Debug.LogWarning("No Locales configured.");
            return;
        }
        var instance = menuCommand.context;
        var type = instance.GetType();
        var fields = type
            .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
            .Where(x => typeof(LocalizedString).IsAssignableFrom(x.FieldType));
        foreach (var field in fields)
        {
            var localizedString = (LocalizedString)field.GetValue(instance);
            if (localizedString == default)
            {
                continue;
            }
            if (!SharedTableDataCaches.TryGetValue(localizedString.TableReference, out var sharedTableData))
            {
                SharedTableDataCaches[localizedString.TableReference] = sharedTableData = LocalizationSettings.Instance.GetStringDatabase().GetTable(localizedString.TableReference, EditorLocale).SharedData;
            }
            // string → TableReference, string → TableEntryReference の暗黙キャストを用いる
            localizedString.TableReference = localizedString.TableReference.TableCollectionName;
            localizedString.TableEntryReference = localizedString.TableEntryReference.ResolveKeyName(sharedTableData);
        }
    }
}

// 以下は Runtime 向け Assembly に定義

public abstract class LocalizableMonoBehaviour : MonoBehaviour
{
}

public abstract class LocalizableScriptableObject : ScriptableObject
{
}

このスクリプトをプロジェクトに置いておき、インスペクタ上で自作クラスである LocalizableMonoBehaviourLocalizableScriptableObject を継承した型を右クリックしコンテキストメニューの Convert String Reference を選択すると、自動的に ID 参照から文字列参照に変換されます。

Untitled

まとめ

というわけで本稿では「プロジェクトを跨いで LocalizedString の設定値を共有する」方法についてまとめました。

本稿で取り扱った内容は以下のリポジトリで公開しております。

https://github.com/monry/Examples-StringBasedLocalization

筆者が勤める株式会社キッズスターが手掛けるプロダクトの性質上、この手の「プロジェクト固有のアセットや値を別プロジェクトに共有する」ためのノウハウが豊富です。
もし、そういったノウハウに興味があったり携わってみたいという奇特な方がいらっしゃれば、是非とも @monry までお声掛けください!😁(Wantedly でお声掛けくださっても OK です!)

明日の Unity Advent Calendar 2022 は、

です!

Discussion