Простой плагин для локализации приложений на Unity3D

  • Tutorial
Я думаю, каждый разработчик на 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;
		}
	}
}

Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

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

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

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


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

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

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

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


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

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

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


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

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


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

                    Тоже писал простенькую систему локализации, но основу наоборот попытался сделать максимально отвязанной от 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. Например, можно реализовать различные окончания прилагательных и существительных, когда какой-либо текст динамически изменяется.
                    • Создать редактор файлов локализации, автоматически проверяющий наличие перевода на все интересующие языки, имеющий удобный интерфейс и т.д.

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

                    • 0

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


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

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

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

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