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

В данной статье я опишу разработку простого плагина для локализации UI Text-компонентов с возможностью динамической смены языка и редактирования текста в редакторе.

Для перечисления используемых языков (в статье будем рассматривать русский и английский) будем использовать enum Unity SystemLanguage.

К сожалению, насколько мне известно, Unity из коробки не поддерживает сериализацию Dictionary или key-value-pair-классов. Так что, чтобы не усложнять, напишем пару своих классов для нужд плагина.

Translation


Структура Translation — по-сути является парой ключ-значение:

public struct Translation {
	public SystemLanguage key;
	public string value;

	public Translation(SystemLanguage key, string value) {
		this.key = key;
		this.value = value;
	}
}

Label


Следующий шаг: создадим класс Label. Его задача — хранить в себе уникальный целочисленный id и список Translation:

[System.Serializable]
public class Label{
	[SerializeField] int _id;
	[SerializeField] List<Translation> translations = new List<Translation>();

        public int id {
		get {
			return _id;
		}
		private set {
			_id = value;
		}
	}

	public Label(int id) {
		this.id = id;
	}
}

Т.к. класс Label по-сути реализует логику Dictionary, необходимо добавить два публичных метода: Get и Set.
В методе Get мы ищем, есть ли строка на запрашиваемом языке, если есть — возвращаем ее, если нет — возвращаем пустую строку.
В методе Set аналогично — если у нас есть строка на нужном языке — меняем ее, если нет — добавляем.

public string Get(SystemLanguage language) {
	for (int i = 0; i < translations.Count; i++) {
		if (translations[i].key == language) {
		    return translations[i].value;
		}
	}
	translations.Add(new Translation(language, string.Empty));
	return translations[translations.Count - 1].value;
}

public void Set(SystemLanguage language, string str) {
	for (int i = 0; i < translations.Count; i++) {
		if (translations[i].key == language){
		    translations[i] = new Translation(language, str);
		    return;
		 }
	}
	 translations.Add(new Translation(language, str));
}

LabelsData


Все экземпляры Label необходимо где-то хранить и редактировать в инспекторе Unity. Воспользуемся для этого средствами Unity и создадим свой класс LabelsData, унаследованный от ScriptableObject. ScriptableObject позволяет хранить в файловой структуре проекта нужные данные и часто используется как небольшая игровая база данных.
В классе LabelsData будет храниться List со всеми переводами игры и Label по-умолчанию для ошибок.

Для создания экземпляра LabelsData мы добавим перед объявлением класса атрибут CreateAssetMenu:

[CreateAssetMenu(fileName="LabelsData", menuName="SimpleLocalizator/LabelsData")]
public class LabelsData : ScriptableObject {
	[SerializeField] List<Label> _labels=new List<Label>();
	Label _defaultLabel = new Label (-1, "not translated");

	public static Label defaultLabel {
		get {
			return instance._defaultLabel;
		}
	}

	public static List<Label> labels {
		get {
			return instance._labels;
		}
		private set {
			instance._labels = value;
		}
	}
}

Для обеспечения доступности экземпляра LabelsData используем ленивую инициализацию по следующей логике:

1. Публичные поля являются геттерами, которые возвращают значения полей экземпляра instance;
2. instance — глобальная ссылка на экземпляр LabelsData. Если она не инициализирована, то применяется подгрузка из папки Resources, либо создается новый экземпляр LabelsData.
3. Из этого следует, что наша база переводов должна быть размещена в Resources.

static LabelsData _instance;
public static LabelsData instance {
	get {
		if (_instance==null) {
			_instance = (LabelsData)Resources.Load ("LabelsData");
			if (_instance == null) {
				_instance = CreateInstance<LabelsData> ();
			} 
		}
		return _instance;
	}
}

Таким образом, мы получаем возможность удобного редактирования наших переводов в редакторе Unity:

Screenshot


Мы можем создать сколько угодно новых Label, назначить им id для идентификации и заполнить переводами на нужные языки.

LanguageManager


Следующий шаг — напишем статический класс LanguageManager, предоставляющий удобный интерфейс для всех компонентов, реализующих отображение мультиязычного контента.

public static class LanguageManager{
	static SystemLanguage _currentLanguage = SystemLanguage.English;

	public static SystemLanguage currentLanguage {
		get {
			return _currentLanguage;
		}
		set {
			_currentLanguage = value;
			if (onLanguageChanged != null)
				onLanguageChanged ();
		}
	}

	public static Action onLanguageChanged;
}

Посредством свойства currentLanguage данного класса мы можем менять текущий язык, а все компоненты, подписанные на событие onLanguageChanged — изменят свое содержание.
Для первоначального определения текущего системного языка добавим метод Init, который будет вызываться только один раз:

public static bool autoDetectLanguage=true;
private static bool init = false;

static void Init() {
	if (!init) {
		init = true;
		 if (autoDetectLanguage) {
		      currentLanguage = Application.systemLanguage;
		 }
		 else {
		      currentLanguage = currentLanguage;
		 }
		 Debug.Log("LanguageManager: initialized. Current language: " + currentLanguage);
        }
}

Для получения строки с нужным id добавим метод GetString, в котором мы будем искать среди данных LabelsData нужный Label, а если его нет — возвращать строку по-умолчанию «not translated»:

public static string GetString(int labelID) {
	return GetString(labelID, currentLanguage);
}

public static string GetString(int labelID, SystemLanguage language) {
	Init();
	for (int i = 0; i < LabelsData.labels.Count; i++) {
	     if (LabelsData.labels[i].id == labelID) {
	           return LabelsData.labels[i].Get(language);
	     }
	}
	return LabelsData.defaultLabel.Get(language);
}

Представление


Теперь осталось написать компоненты плагина, отвечающие за отображение контента. Какой контент мы можем отображать в Unity? Строки для UI.Text и TextMesh, какие-либо картинки (например, иконки и баннеры на русском и английском языках). В рамках статьи рассмотрим отображение мультиязычных строк для UI.Text.

Создадим абстрактный класс MultiLanguageComponent для отображения контента, от которого будем наследоваться дальше. Его задачи просты — хранить текущий язык, подписываться на LanguageManager.onLanguageChanged и обновлять содержимое в OnValidate (для тестов в редакторе):

public abstract class MultiLanguageComponent : MonoBehaviour {
	[SerializeField] SystemLanguage _currentLanguage = SystemLanguage.English;

	protected SystemLanguage currentLanguage {
		get {
			return _currentLanguage;
		}
		set {
			_currentLanguage = value;
			Refresh ();
		}
	}

	public void OnValidate() {
		currentLanguage = _currentLanguage;
	}

	void OnEnable() {
		OnLanguageRefresh ();
		LanguageManager.onLanguageChanged += OnLanguageRefresh;
	}

	void OnDisable() {
		LanguageManager.onLanguageChanged -= OnLanguageRefresh;
	}

	void OnLanguageRefresh() {
		currentLanguage = LanguageManager.currentLanguage;
	}

	protected virtual void Refresh() {
	}
}

Здесь метод Refresh — виртуальный, который мы будем переопределять в классах-наследниках.

Создадим класс-наследник MultiLanguageTextBase, хранящий в себе целочисленный labelID:

public abstract class MultiLanguageTextBase : MultiLanguageComponent{
	[SerializeField] int _labelID;
	[SerializeField] bool toUpper=false;

	public int labelID {
		get {
			return _labelID;
		}
		set {
			_labelID = value;
			Refresh();
		}
	}
}

Переопределим в нем метод Refresh. Т.к. Refresh будет вызываться при изменении языка приложения либо при изменении labelID — в нем мы получаем строку на нужном языке от LanguageManager и вызываем метод VisualizeString (в котором в наследниках строка будет уже выводиться на экран приложения с помощью UI.Text или TextMesh). Переменная local нужна для определения того, происходит ли обновление в редакторе до запуска приложения — в этом случае для дебага от LanguageManager-а будет получена строка на текущем языке конкретного компонента, а не на системном.

protected override void Refresh() {
        bool local = (Application.isEditor && !Application.isPlaying);
        string str = local ? LanguageManager.GetString(labelID, currentLanguage) : LanguageManager.GetString(labelID);
	if (toUpper)
                str = str.ToUpper();
	VisualizeString(str);
}

protected abstract void VisualizeString(string str);

Создадим последний класс MultiLanguageTextUI, уже непосредственно выводящий строку на экран и наследующийся от MultiLanguageTextBase. В нем мы переопределим метод VisualizeString для вывода текста в UI.Text:

[RequireComponent(typeof(Text))]
public class MultiLanguageTextUI : MultiLanguageTextBase {
	Text _text;
	public Text text {
		get {
			if (_text == null && gameObject!=null)
				_text = GetComponent<Text> ();
			return _text;
		}
	}

	protected override void VisualizeString(string str) {
		if (text != null)
		        text.text = str;
	}
}

Теперь мы можем просто добавить компонент MultiLanguageTextUI на объект с текстом и выставить нужный labelID:

Screenshot


Демонстрация


GIF


Итог


Таким образом, мы получили простую систему локализации для приложения. В дальнейшем можно отнаследоваться от MultiLanguageComponent и добавить собственные компоненты для перевода.

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

Полный код


Translation.cs
using UnityEngine;

namespace SimpleLocalizator {
	[System.Serializable]
	public struct Translation {
		public SystemLanguage key;
		public string value;

		public Translation(SystemLanguage key, string value) {
			this.key = key;
			this.value = value;
		}
	}
}


Label.cs
using UnityEngine;
using System.Collections.Generic;

namespace SimpleLocalizator {
	[System.Serializable]
	public class Label{
		#region Data
		[SerializeField] int _id;
	        [SerializeField] List<Translation> translations = new List<Translation>();
	        private const string defaultText = "not translated";
                #endregion

               #region Interface
                public int id {
			get {
				return _id;
			}
			private set {
				_id = value;
			}
		 }

		 public Label(int id) {
			this.id = id;
		 }

		public string Get(SystemLanguage language) {
		        for (int i = 0; i < translations.Count; i++) {
		                if (translations[i].key == language) {
		                        return translations[i].value;
		                }
		        }
		        translations.Add(new Translation(language, defaultText));
		        return translations[translations.Count - 1].value;
		}

		public void Set(SystemLanguage language, string str) {
		         for (int i = 0; i < translations.Count; i++) {
		                  if (translations[i].key == language){
		                          translations[i] = new Translation(language, str);
		                          return;
		                  }
		         }
		         translations.Add(new Translation(language, str));
		}
		#endregion
	}
}


LabelsData.cs
using System.Collections.Generic;
using UnityEngine;
using System.Text;

namespace SimpleLocalizator {
	[CreateAssetMenu(fileName="LabelsData", menuName="SimpleLocalizator/LabelsData")]
	public class LabelsData : ScriptableObject {
		[SerializeField] List<Label> _labels=new List<Label>();
		Label _defaultLabel = new Label (-1);

		public static Label defaultLabel {
			get {
				return instance._defaultLabel;
			}
		}

		public static List<Label> labels {
			get {
				return instance._labels;
			}
			private set {
				instance._labels = value;
			}
		}

		static LabelsData _instance;
		public static LabelsData instance {
			get {
				if (_instance==null) {
					_instance = (LabelsData)Resources.Load ("LabelsData");
					if (_instance == null) {
						_instance = CreateInstance<LabelsData> ();
                                        Debug.Log ("LabelsData: loaded instance from resources is null, created instance");
					}
				}
				return _instance;
			}
		}
	}
}


LanguageManager.cs
using UnityEngine;
using System;

namespace SimpleLocalizator {
	public static class LanguageManager{
		#region Data
		public static bool autoDetectLanguage=true;
		static SystemLanguage _currentLanguage = SystemLanguage.English;
	    private static bool init = false;
		#endregion

		#region Interface
		public static SystemLanguage currentLanguage {
			get {
				return _currentLanguage;
			}
			set {
				_currentLanguage = value;
				if (onLanguageChanged != null)
					onLanguageChanged ();
			}
		}

		public static Action onLanguageChanged;

		public static string GetString(int labelID)
		{
		        return GetString(labelID, currentLanguage);
		}

	        public static string GetString(int labelID, SystemLanguage language) {
	               Init();
	               for (int i = 0; i < LabelsData.labels.Count; i++) {
	                        if (LabelsData.labels[i].id == labelID) {
	                                return LabelsData.labels[i].Get(language);
	                        }
	               }
	               return LabelsData.defaultLabel.Get(language);
	        }
                #endregion

               #region Methods
		static void Init() {
		       if (!init) {
		                init = true;
		                if (autoDetectLanguage){
		                          currentLanguage = Application.systemLanguage;
		                }
		                else {
		                          currentLanguage = currentLanguage;
		                }
		                Debug.Log("LanguageManager: initialized. Current language: " + currentLanguage);
                       }
		}
		#endregion
	}
}


MultiLanguageComponent.cs

using UnityEngine;

namespace SimpleLocalizator {
	public abstract class MultiLanguageComponent : MonoBehaviour {
		[SerializeField] SystemLanguage _currentLanguage = SystemLanguage.English;

		protected SystemLanguage currentLanguage {
			get {
				return _currentLanguage;
			}
			set {
				_currentLanguage = value;
				Refresh ();
			}
		}

		public void OnValidate() {
			currentLanguage = _currentLanguage;
		}

		void OnEnable() {
			OnLanguageRefresh ();
			LanguageManager.onLanguageChanged += OnLanguageRefresh;
		}

		void OnDisable() {
			LanguageManager.onLanguageChanged -= OnLanguageRefresh;
		}

		void OnLanguageRefresh() {
			currentLanguage = LanguageManager.currentLanguage;
		}

		protected virtual void Refresh() {
		}
	}
}


MultiLanguageTextBase.cs

using UnityEngine;

namespace SimpleLocalizator {
	public abstract class MultiLanguageTextBase : MultiLanguageComponent{
		#region Unity scene settings
		[SerializeField] int _labelID;
		[SerializeField] bool toUpper=false;
		#endregion

		#region Interface
		public int labelID {
			get {
				return _labelID;
			}
			set {
				_labelID = value;
			        Refresh();
			}
		}
        #endregion

        #region Methods
                protected override void Refresh() {
                        bool local = (Application.isEditor && !Application.isPlaying);
                        string str = local ? LanguageManager.GetString(labelID, currentLanguage) : 
                        LanguageManager.GetString(labelID);
		        if (toUpper)
                                str = str.ToUpper();
		        VisualizeString(str);
		}

	        protected abstract void VisualizeString(string str);
	#endregion
	}
}


MultiLanguageTextUI.cs

using UnityEngine;
using UnityEngine.UI;

namespace SimpleLocalizator {
	[RequireComponent(typeof(Text))]
	public class MultiLanguageTextUI : MultiLanguageTextBase {
		Text _text;
		public Text text {
			get {
				if (_text == null && gameObject!=null)
					_text = GetComponent<Text> ();
				return _text;
			}
		}

		protected override void VisualizeString(string str)
		{
		    if (text != null)
		        text.text = str;
		}
	}
}

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


  1. TheShock
    07.11.2017 01:36

    Цифровой айдишник ведь крайне неудобно. Почему бы не сделать что-то вроде «Units.HeavyTank.Title» в качестве айдишника?


    1. AlexdeBur Автор
      07.11.2017 01:42

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


      1. GrimMaple
        07.11.2017 02:07

        А почему бы тогда просто не хранить переводы как Dictionary<AbstractKey, string>? И никаких сравнений не нужно делать (в идеальном, конечно же, случае)


        1. AlexdeBur Автор
          07.11.2017 02:12

          Потому что Unity без дополнительных костылей не сериализует Dictionary в редакторе, а хотелось обойтись без них и иметь возможность быстрого редактирования переводов.


      1. TheShock
        07.11.2017 02:42

        удобнее использовать целочисленный
        Чем удобнее?

        Кроме того — поиск чуточку быстрее за счет сравнения)
        Вы ведь так шутите, да?


  1. lexxpavlov
    07.11.2017 09:15

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


    1. SlavniyTeo
      07.11.2017 09:45

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


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

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


      1. AlexdeBur Автор
        07.11.2017 13:32

        Вполне вероятно) Пока csv хватало для нужд.


    1. AlexdeBur Автор
      07.11.2017 13:36

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


  1. SlavniyTeo
    07.11.2017 09:50

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


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


    1. AlexdeBur Автор
      07.11.2017 13:39

      Хорошая идея, добавлю. Спасибо!


  1. HomoLuden
    07.11.2017 11:24

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


    1. AlexdeBur Автор
      07.11.2017 13:33

      Спасибо, поправил!


      1. SlavniyTeo
        07.11.2017 13:54

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


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


        1. Tutanhomon
          08.11.2017 11:12

          Переименовали после появления 2D в Unity3D, после чего 3D перестало иметь смысл.


          1. KumoKairo
            08.11.2017 14:18

            Опять же непонятно откуда взялась история про «переименование». Даже в самых первых упоминаниях движка на Unite 2007 (San Francisco) авторы движка называют его Unity. Источник — www.youtube.com/watch?v=bU17M_HDRBE


    1. KumoKairo
      07.11.2017 14:10

      Движок всё-таки называется Unity. Название Unity3D привязалось к нему из-за имени домена unity3d.com. (к слову — домен unity.com они выкупили относительно недавно, буквально года полтора назад он ещё не редиректил на основной unity3d.com) Нигде в официальных источниках или в документации вы не найдёте упоминания Unity3D.
      Так что всё-таки придётся как-то жить с двумя разными вещами, называнными Unity.


  1. alt3d
    07.11.2017 13:34

    Таким образом, мы получаем возможность удобного редактирования наших переводов в редакторе Unity


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


  1. ArXen42
    08.11.2017 00:27

    Тоже писал простенькую систему локализации, но основу наоборот попытался сделать максимально отвязанной от 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; }
    }


    1. ArXen42
      08.11.2017 00:46

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


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


      1. AlexdeBur Автор
        08.11.2017 02:23

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


        1. TheShock
          08.11.2017 02:58

          В комментариях выше подсказывали вариант создавать enum с возможными значениями

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