Как стать автором
Обновить

Комментарии 22

Цифровой айдишник ведь крайне неудобно. Почему бы не сделать что-то вроде «Units.HeavyTank.Title» в качестве айдишника?
Можно и так, но мне показалось удобнее использовать целочисленный. Кроме того — поиск чуточку быстрее за счет сравнения)
А почему бы тогда просто не хранить переводы как Dictionary<AbstractKey, string>? И никаких сравнений не нужно делать (в идеальном, конечно же, случае)
Потому что Unity без дополнительных костылей не сериализует Dictionary в редакторе, а хотелось обойтись без них и иметь возможность быстрого редактирования переводов.
удобнее использовать целочисленный
Чем удобнее?

Кроме того — поиск чуточку быстрее за счет сравнения)
Вы ведь так шутите, да?
У вас словарь хранит ключ -> список (язык->перевод). Гораздо удобнее сделать язык -> список (ключ->перевод). Переводить в юнити всё равно неудобно, и профессиональный переводчик потребует файл с переводами языка-источника, а у вас все переводы в одном файле.
Например, у вас 10 языков, вы хотите одиннадцатый. Вы хотите передавать весь файл со всеми переводами? Или создавать нужный файл для переводчика? Или делать отдельный инструмент для обработки таблицы переводов? Так не удобнее ли сразу переводы хранить в нормальном формате? в .xliff или .mo/.po, ну или в yaml, хотя бы.

В конце статьи автор пишет:


Репозиторий на GitHub (здесь добавлены некоторые дополнительные функции — экспорт/импорт в csv, компоненты для TextMesh и Image).

Еще немного и автор дойдет до интеграции с Google Sheets

Вполне вероятно) Пока csv хватало для нужд.
Пока проекты, над которыми приходилось работать, были на 2-3 языках, так что таких проблем еще не возникало. Спасибо вам за информацию!

Как и прочие комментаторы, посоветую не использовать в качестве id численные значения. Лучше всего подойдет перечисление (enum) с осмысленными названиями идентификаторов. В том же инспекторе будет намного удобней ориентироваться.


И еще одно. Я заметил, что если для текущего языка текст не добавлен, Вы возвращаете значение по-умолчанию (в одном месте это пустая строка, в другом — "not translated"). Хорошей практикой в данном случае считается возвращать id этой строки в качестве заглушки. Чтобы во время игры, заметив заглушку, Вы точно знали, где править.

Хорошая идея, добавлю. Спасибо!
[zanuda_mode]
Unity — это реализация IoC для ASP.Net приложений
Unity3D — игровой движок
Мне кажется стоит поправить заголовок и по тексту лучше тоже поправить. Иначе будут искать инфу про IoC, а наступят на статью про gamedev.
[/zanuda_mode]
Спасибо, поправил!

Не верьте на слово!
Unity3d в какой-то момент переименовали в Unity, в чем можно убедиться на их сайте. Хост остался старый, а движок везде зовется просто Unity.


К сожалению, точную дату переименования не нашел.

Переименовали после появления 2D в Unity3D, после чего 3D перестало иметь смысл.
Опять же непонятно откуда взялась история про «переименование». Даже в самых первых упоминаниях движка на Unite 2007 (San Francisco) авторы движка называют его Unity. Источник — www.youtube.com/watch?v=bU17M_HDRBE
Движок всё-таки называется Unity. Название Unity3D привязалось к нему из-за имени домена unity3d.com. (к слову — домен unity.com они выкупили относительно недавно, буквально года полтора назад он ещё не редиректил на основной unity3d.com) Нигде в официальных источниках или в документации вы не найдёте упоминания Unity3D.
Так что всё-таки придётся как-то жить с двумя разными вещами, называнными Unity.
Таким образом, мы получаем возможность удобного редактирования наших переводов в редакторе Unity


Совершенно неудобный.
Тут либо писать кастомный инспектор, либо как уже писали, проще перейти на гугловские таблицы.

Тоже писал простенькую систему локализации, но основу наоборот попытался сделать максимально отвязанной от Unity. На статью не тянет, так что оставлю здесь. Возможно, кому-нибудь пригодится (к тому же, удачные решения обоих подходов можно совместить, мое решение не может в локализацию изображений, например).


Основа локализации — класс LocalizationString. В "базовой поставке" содержит описание и саму локализованную строку:


LocalizationString
    /// <summary>
    ///    Represents string localized to some language.
    /// </summary>
    [Serializable]
    public class LocalizationString
    {
        /// <summary>
        ///    Constructs empty LocalizationString.
        /// </summary>
        public LocalizationString() { }

        /// <summary>
        ///    Constructs LocalizationString with given description and localized value.
        /// </summary>
        public LocalizationString(String description, String value)
        {
            Description = description;
            Value = value;
        }

        public override String ToString()
        {
            return Value;
        }

        /// <summary>
        ///    Description of the string.
        /// </summary>
        public String Description { get; internal set; }

        /// <summary>
        ///    Translation of the string to desired language.
        /// </summary>
        public String Value { get; internal set; }
}

Могут быть определены наследники с дополнительными свойствами и своими правилам конвертации (может быть полезным для сложных случаев или специфичных языков).


На начальном этапе локализация представляет собой простое дерево директорий, листья которого (т.е. самые нижние директории) содержат json файлы сериализованных LocalizationString, по одному файлу на каждый язык (для сериализации лично я использовал JsonFX). Например:


Localization/MainMenu/SettingsButton/English.json
{
    "__type": "HabitableZone.Localization.Common.LocalizationString",
    "Description": "Settings button.",
    "Value": "Settings"
}

Эти директории и файлы строк можно создавать как вручную (Ctrl-C+Ctrl-V, поменять содержимое в блокноте), так и написав редактор локализации с удобным интерфейсом, дополнительными проверками и т.д.


Затем простая программа-агрегатор пробегает по этим каталогам, собирая Dictionary<String, LocalizationString>: игровую локализацию, непосредственно используемую игрой, один словарь на язык. Пример:


Assets/Resources/Localization/English.json
{
    "MainMenu.LoadWorldButton": 
        {
            "Description": "Load saved world button.",
            "Value": "Load world"
        },
    "MainMenu.NewWorldButton": 
        {
            "Description": "New world button.",
            "Value": "New world"
        },
    "MainMenu.QuitButton": 
        {
            "Description": "Quit button.",
            "Value": "Quit"
        },
    "MainMenu.SettingsButton": 
        {
            "Description": "Settings button.",
            "Value": "Settings"
        },
    "Shared.CommonLabels.Acceleration": 
        {
            "Description": "Title of UI elements used for displaying acceleration of something.",
            "Value": "Acceleration"
        },
    "Shared.CommonLabels.Mass": 
        {
            "Description": "Title of UI elements used for displaying mass of something.",
            "Value": "Mass"
        },
    "Shared.CommonLabels.Temperature": 
        {
            "Description": "Title of UI elements used for displaying temperature of something.",
            "Value": "Temperature"
        },
    "Shared.CommonLabels.Velocity": 
        {
            "Description": "Title of UI elements used for displaying velocity of something.",
            "Value": "Velocity"
        },
    "Shared.Units.AU": 
        {
            "Description": "AU",
            "Value": "AU"
        },
    "Shared.Units.Days": 
        {
            "Description": "Days",
            "Value": "d."
        },
    "Shared.Units.Kelvins": 
        {
            "Description": "Kelvins",
            "Value": "K"
        },
    "Shared.Units.Megawatts": 
        {
            "Description": "Megawatts",
            "Value": "MW"
        },
    "Shared.Units.Tons": 
        {
            "Description": "Tons",
            "Value": "t"
        },
    "Shared.Units.Years": 
        {
            "Description": "Years",
            "Value": "y."
        },
    "Shared.Units.kmps": 
        {
            "Description": "Kilometres per second",
            "Value": "km/s"
        },
    "Shared.Units.mps^2": 
        {
            "Description": "m/s^2",
            "Value": "m/s^2"
        },
    "Shared.VersionText": 
        {
            "Description": "Version text usually displayed near the corner of screen.",
            "Value": "Some Game Prototype v0.42"
        }
}

Как можно заметить, ключи строк формируются из названий директорий, в которых они находятся на этапе создания. Не помню, сам я это придумал или подсмотрел в комментариях к другим статьям про локализацию, но решение мне показалось удобным.


Осталось лишь предоставить удобный доступ к содержимому этого ресурсного файла:


LocalizationManager

    /// <summary>
    ///    Provides acess to localized strings.
    /// </summary>
    public sealed class LocalizationManager
    {
        /// <summary>
        ///    Initializes LocalizationLanguage as unknown and tries to load provided localization.
        /// </summary>
        public LocalizationManager(SystemLanguage initialLanguage)
        {
            LocalizationLanguage = SystemLanguage.Unknown;
            try
            {
                Load(initialLanguage);
            }
            catch (LocalizationLoadException)
            {
                Debug.Log($"Can't load {initialLanguage}, loading English.");
                Load(SystemLanguage.English);
            }
        }

        /// <summary>
        ///    Occurs when LocalizationLanguage is changed.
        /// </summary>
        public event SEventHandler<SystemLanguage> LocalizationLanguageChanged;

        /// <summary>
        ///    Currently loaded localization's language. SystemLanguage.Unknown if nothing is loaded.
        /// </summary>
        public SystemLanguage LocalizationLanguage { get; private set; }

        /// <summary>
        ///    Loads localization for given language from resource files.
        /// </summary>
        /// <remarks>
        ///    Simply loads json as a dictionaries hierarchy.
        /// </remarks>
        public void Load(SystemLanguage language)
        {
            try
            {
                var textAsset = Resources.Load<TextAsset>(@"Localizations\" + language + @"Localization");
                using (var stream = new MemoryStream(textAsset.bytes))
                {
                    _localization = Serialization.DeserializeDataFromJson<GameLocalization>(stream);
                }

                LocalizationLanguage = language;

                LocalizationLanguageChanged?.Invoke(language);
            }
            catch (Exception exception)
            {
                throw new LocalizationLoadException(
                    $"Can't load localization of language \"{language}\".",
                    exception);
            }
        }

        /// <summary>
        ///    Returns localized string associated with the specified key.
        /// </summary>
        /// <remarks>
        ///    Example: UI.VersionText
        /// </remarks>
        public LocalizationString GetLocalizationString(String keyString)
        {
            try
            {
                return _localization[keyString];
            }
            catch (Exception exception)
            {
                throw new KeyNotFoundException($"Localization doesn't contains string of key {keyString}.",
                    exception);
            }
        }

        private GameLocalization _localization; //Практически пустой наследник Dictionary, я в нем только символ-разделитель определил
    }

    public class LocalizationLoadException : Exception
    {
        public LocalizationLoadException(String message, Exception innerException) : base(message, innerException) { }
    }

Через этого менеджера можно загрузить нужный язык и получать локализованные строки. Использующие его объекты могут получить его через DI, или можно просто сделать его синглтоном/статическим.


Пример использования: скрипт, локализующий компонент UI текста на своем объекте, достаточно указать ключ локализации в инспекторе:


TextLocalizer

    /// <summary>
    ///    Sets localized string in the UnityEngine.UI.Text.text.
    /// </summary>
    public sealed class TextLocalizer : MonoBehaviour
    {
        [Inject]
        public void InjectDependencies(LocalizationManager localizationManager)
        {
            _localizationManager = localizationManager;
        }

        private void OnEnable()
        {
            _localizationManager.LocalizationLanguageChanged += SetText;
            SetText(_localizationManager.LocalizationLanguage);
        }

        private void OnDisable()
        {
            _localizationManager.LocalizationLanguageChanged -= SetText;
        }

        private void SetText(SystemLanguage language)
        {
            if (language != SystemLanguage.Unknown)
                GetComponent<Text>().text = _localizationManager.GetLocalizationString(_localizationKey).Value;
        }

        private LocalizationManager _localizationManager;

        [SerializeField] private String _localizationKey;
}

Вариантов развития множество:


  • Вместо подгрузки всего json использовать sqlite или еще что-нибудь для подгрузки строки "по требованию" (если объем локализации в памяти станет проблемой).
  • Создать наследников LocalizationString с дополнительными правилами преобразования/форматирования/etc. Например, можно реализовать различные окончания прилагательных и существительных, когда какой-либо текст динамически изменяется.
  • Создать редактор файлов локализации, автоматически проверяющий наличие перевода на все интересующие языки, имеющий удобный интерфейс и т.д.

Хотел короче, но, как всегда, получилась бесполезная простыня. Впрочем, может быть это натолкнет кого-нибудь на полезные мысли при создании своего велосипеда.

Забыл упомянуть, что, на мой взгляд, существенным минусом обоих методов является отсутствие инструментальной поддержки со стороны средств разработки: при вбивании ключа в инспекторе (будь до labelID из статьи или строковый ключ), нет никакого автодополния. При необходимости получить локализованное значение в коде, нужно вызывать что-то вроде GetString("some_key") со всеми вытекающими проблемами при рефакторинге.


В то же время, например, решарпер умеет автоматически добавлять строку в ресурсный файл и генерировать свойство для доступа к ней.

Да, есть такой минус, безусловно. В комментариях выше подсказывали вариант создавать enum с возможными значениями (либо можно завести класс-протокол с поименованными константами-ключами), но данный вариант я не использую из-за необходимости привязки к конкретному проекту.
В комментариях выше подсказывали вариант создавать enum с возможными значениями

Это все-равно в Юнити неудобно, т.к. элементов локализации в игре сотни — выбирать один из списка занятие неблагодарное.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории