[Unity]ローカライズ拡張例

#com.unity.localization
Unity にて `Localization` というローカライズ用のパッケージがリリースされています。インストールは `Package Manager` → `Add package by name...` → `com.unity.localization` と入力 (Unity 2019.4 以降を推奨）。（[公式ドキュメント](https://docs.unity3d.com/Packages/com.unity.localization@1.0/manual/index.html)）
なお依存している `Addressables` パッケージもインストールされます。すでにこれを使用しているプロジェクトの場合はバージョン更新が必要になるかもしれません。

導入手順等々は省略し、本稿ではこのパッケージで可能なカスタムフォーマットの例を紹介します。

#ローカル変数

まず準備として、今回のすべての例に使う、変数を導入します。グローバル変数とローカル変数の2種類が利用でき、今回はローカル変数で値を渡します。

通常の手順に従いテキストオブジェクトをローカライズ対応させると
`Localize String Event`
というコンポーネントが追加されるので、そこの `Local Variables` に値を追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/9730ed72-6f29-4d8c-d434-c7edaa4aa196.png)

次にローカライズのエントリーを追加したら、 `Smart` チェックを入れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/0b519fa2-242c-11a4-9550-e284518971fe.png)

これで文字列は解釈されるようになり、 `{変数名}` で値に置き換えられます。

#組み込まれている関数例

組み込まれてる関数でローカル変数を使用してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/4f0a982d-0def-8fa4-c84e-84422af7890a.png)
英語に
`{num:plural:an apple|{} apples}`
と入力しておくと、ローカル変数を `num` としてゲーム上では
`num`が1 : `an apple`
`num`が2 : `2 apples`
と表示され、厄介な複数形の問題を解決できます。

この `plural` という部分をカスタムで用意してみよう、というのがいうのが本稿の趣旨です。

#カスタム関数の定義手順

1) C#で記述
2) それを登録

C#に加えて登録が必要なので、先に解説しておきます。カスタム関数を定義したら `Project Settings` から以下のように登録します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/6a2aebfc-2c7b-90a5-6705-bfbb17d97654.png)

この例では `Fahrenheit Formatter` という自作のフォーマッターを指定しています。以下の例すべてでこの登録が必要なので、忘れないようにしましょう。

#実装例
##摂氏華氏変換
まずはわかりやすい例。実際にゲームで使用される例は少ないように感じますが。

```c#:FahrenheitFormatter.cs
using System;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.SmartFormat.Core.Extensions;
using UnityEngine.Assertions;

[Serializable]
[DisplayName("Fahrenheit Formatter")]
public class FahrenheitFormatter : FormatterBase
{
    public override string[] DefaultNames => new string[] { "fahrenheit" };

    public override bool TryEvaluateFormat(IFormattingInfo formattingInfo)
    {
        if (formattingInfo.CurrentValue is float celsius)
        {
            var f = celsius * 9f / 5f + 32f;
            var str = f.ToString(formattingInfo.FormatterOptions);
            formattingInfo.Write($"{str}");
            return true;
        }
        else
            Assert.IsTrue(false); // make sure the value is float.

        return false;
    }
}
```

翻訳例：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/651fc0dc-3286-8d9c-3737-ff05f491e2db.png)


実行結果：
英語：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/3e7ca917-8138-6269-d12d-7b03ae22d71f.png)
日本語
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/91547e5c-75e6-c2bd-3f4b-917399780a9e.png)


##マイル速度変換

自動車のメーターで速度に mph 表記をする例は多いので、現実世界のレースゲームを作るのであれば必須でしょう。

```c#:MphFormatter.cs
using System;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.SmartFormat.Core.Extensions;
using UnityEngine.Assertions;

[Serializable]
[DisplayName("MPH Formatter")]
public class MPHFormatter : FormatterBase
{
    public override string[] DefaultNames => new string[] { "mph" };

    public override bool TryEvaluateFormat(IFormattingInfo formattingInfo)
    {
        if (formattingInfo.CurrentValue is float kph)
        {
            var mph = kph * 0.621371f;
            var str = mph.ToString(formattingInfo.FormatterOptions);
            formattingInfo.Write($"{str}");
            return true;
        }
        else
            Assert.IsTrue(false); // make sure the value is float.

        return false;
    }
}
```

翻訳例：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/1b4af11c-3d6c-4de0-6e53-b500e12dbf10.png)

実行結果：
英語：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/8908378d-3aea-e21a-74e2-083cc7882e49.png)

日本語：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/f3fac8d9-f578-21ef-ffaf-2d126c4a1cf6.png)

##順位表示

1st, 2nd, 3rd, 4th ときて
11th, 12th, 13th となり
21st, 22nd, 23rd という、とても厄介な英語の問題。
人数の多いランキングを表示するときは対応が必要になります。

```c#:OrdinalFormatter.cs
using System;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.SmartFormat.Core.Extensions;
using UnityEngine.Assertions;

[Serializable]
[DisplayName("Ordinal Formatter")]
public class OrdinalFormatter : FormatterBase
{
    public override string[] DefaultNames => new string[] { "ordinal" };

    public override bool TryEvaluateFormat(IFormattingInfo formattingInfo)
    {
        if (formattingInfo.CurrentValue is int num)
        {
            var absnum = Mathf.Abs(num);
            var j = absnum % 10;
            var k = absnum % 100;
            if (j == 1 && k != 11) {
                formattingInfo.Write($"{num}st");
                return true;
            }
            if (j == 2 && k != 12) {
                formattingInfo.Write($"{num}nd");
                return true;
            }
            if (j == 3 && k != 13) {
                formattingInfo.Write($"{num}rd");
                return true;
            }
            formattingInfo.Write($"{num}th");
            return true;
        }
        else
            Assert.IsTrue(false); // make sure the value is int.

        return false;
    }
}
```
翻訳例：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/4a381a74-3a92-bd76-58a3-df2098705c7a.png)


実行結果：
英語：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/f6ff06b5-5469-2532-e7f5-b740efaab6b3.png)

日本語：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/99fe04a2-1a6c-e656-a9c3-7991a46b0886.png)



##漢数字

日本語に特化して頑張りたいケース。

```C#:KansuujiFormatter.cs
using System;
using System.Text;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.SmartFormat.Core.Extensions;
using UnityEngine.Assertions;

[Serializable]
[DisplayName("Kansuuji Formatter")]
public class KansuujiFormatter : FormatterBase
{
    static readonly string[] Digit = { "", "一", "二", "三", "四", "五", "六", "七", "八", "九" };
    static readonly string[] SubUnit = { "", "十", "百", "千" };
    static readonly string[] Unit = { "", "万", "億" };
            
    public override string[] DefaultNames => new string[] { "kansuuji" };
    StringBuilder _stringBuilder = new StringBuilder("", 128);

    void get4digits(int num, StringBuilder sb)
    {
        for (var i = 0; i < 4; ++i)
        {
            int r = num % 10;
            if (r > 0)
                sb.Insert(0, SubUnit[i]);
            if (r != 1 || i == 0)
                sb.Insert(0, Digit[r]);
            num /= 10;
        }
    }

    public override bool TryEvaluateFormat(IFormattingInfo formattingInfo)
    {
        if (formattingInfo.CurrentValue is int num)
        {
            if (num == 0)
            {
                formattingInfo.Write("零");
                return true;
            }

            _stringBuilder.Clear();
            var n = Mathf.Abs(num);

            for (var unit = 0; n > 0; n /= 10000)
            {
                var dig4 = n % 10000;
                if (dig4 > 0)
                {
                    _stringBuilder.Insert(0, Unit[unit]);
                    get4digits(dig4, _stringBuilder);
                }                    
                ++unit;
            }
            if (num < 0)
                _stringBuilder.Insert(0, "マイナス");
            formattingInfo.Write(_stringBuilder.ToString());
            return true;
        }
        else
            Assert.IsTrue(false); // make sure the value is int.

        return false;
    }
}
```

翻訳例：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/cf244a5e-75a8-7a0c-616d-d7ae767c3ed1.png)

実行結果：
英語：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/0162f619-9b81-75b3-e5c7-07627fc408f5.png)

日本語：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/e0e01270-04d9-aa74-56e2-2290118aba82.png)

英語でカンマ区切りで表記されていますが、これは `{chapter:#,0}.` のように指定しているためです。通常の変数展開にはカスタム関数を記述しなくても、 C# の [StringFormat](https://docs.microsoft.com/en-us/dotnet/api/system.string.format) のフォーマットが使用できます。

#おまけ：ローカル変数の更新サンプル

ローカル変数をランタイムで更新する場合のサンプルを載せておきます。おなじゲームオブジェクトに追加し、ローカル変数名を指定すれば毎フレーム値が増加します。

```C#:LocalVariableUpdater.cs
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Components;
using UnityEngine.Localization.SmartFormat.PersistentVariables;
using UnityEngine.Assertions;

public class LocalVariableUpdater : MonoBehaviour
{
    public string LocalVariableName;
    FloatVariable _floatVariable;
    
    void Start()
    {
        var localizeStringEvent = GetComponent<LocalizeStringEvent>();
        var localizedString = localizeStringEvent.StringReference;
        _floatVariable = localizedString[LocalVariableName] as FloatVariable;
        Assert.IsNotNull(_floatVariable);
    }

    void Update()
    {
        _floatVariable.Value++;
    }
}
```

#まとめ
そもそも単位変換は式さえあればいかようにも実装できますが、翻訳者が適切な単位を選択できるようにするところにローカライズでこれを行うメリットがあるでしょう。適切な表記というのは母国語の人間でないとわからないので、翻訳者に選択を委譲することでローカライズの品質を向上することができますね。めでたしめでたし。