Простой плагин для локализации приложений на Unity3D07.11.2017 13:48
Я думаю, каждый разработчик на 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
Мы можем создать сколько угодно новых 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, который будет вызываться только один раз:
Для получения строки с нужным 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 (для тестов в редакторе):
Здесь метод 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-а будет получена строка на текущем языке конкретного компонента, а не на системном.
Создадим последний класс 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
Демонстрация
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 _labels=new List();
Label _defaultLabel = new Label (-1);
public static Label defaultLabel {
get {
return instance._defaultLabel;
}
}
public static List 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 ();
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
}
}
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;
}
}
}