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

Я думаю, каждый разработчик на 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 translations = new List();

        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


Для обеспечения доступности экземпляра 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 ();
			} 
		}
		return _instance;
	}
}


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

Screenshot
1lzx2z1fatk1mhmwvwzkvnqq77m.jpeg


Мы можем создать сколько угодно новых 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 ();
			return _text;
		}
	}

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


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

Screenshot
-teqowhcnhbbjho1rhdaikpcuts.jpeg


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


GIF
jrae-j5oyuohkxvphotzdikcq74.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;
		}

		public override string ToString() {
			return (key.ToString () + ":" + value);
		}
	}
}


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

namespace SimpleLocalizator {
	[System.Serializable]
	public class Label{
		#region Data
		[SerializeField] int _id;
	        [SerializeField] List translations = new List();
	        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));
		}

	        public List Keys() {
	                  List keys = new List();
	                  for (int i = 0; i < translations.Count; i++) {
	                          if (!keys.Contains(translations[i].key))
                                          keys.Add(translations[i].key);
	                  }
	                  return keys;
	        }
		#endregion

		#region Methods
		public override string ToString ()
		{
			return "Label (id:" + id.ToString () + ")."+translations.ToString();
		}
		#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


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 ();
				return _text;
			}
		}

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

© Habrahabr.ru