Введение
Приветствую Вас, уважаемые читатели. В данной статье пойдет речь о создании системы локализации приложений, созданных в среде Unity3D, в основе которой лежит использование класса ScriptableObject, что позволяет локализовать не только текст, но и звуки и изображения, а также загружать такие данные извне.
По традиции, прежде чем приступить к описанию подробностей, остановимся на том, что такое локализация и зачем нам это нужно.
Очень часто, да и практически всегда, разработка игр (и любых других приложений) ориентируется не на один рынок. Поскольку каждый рынок характеризуется своей языковой группой, то разработчикам приходится это учитывать, ведь если вы сделаете игру только на русском, то англоязычные пользователи, просто на просто ничего не поймут. Что делать? Правильно, нужно обеспечить в игре поддержку нескольких языков. В большинстве случаев переводу подвергаются только текстовые данные и для этого часто используются Google Sheets или нечто подобное. Это достаточно просто и гибко, поскольку импорт из таблиц не представляет сложности. Однако не все так радужно, как может показаться на первый взгляд. Что если в игре много голосового сопровождения? Или текст должен иметь разный шрифт для разных языков? И напоследок еще и текст или что-то требует уникальности для языка в изображениях? В этих случаях таблиц уже недостаточно.
Так что же делать спросите вы (если, конечно, уже не знаете ответа)? Я пришел к варианту использования ScriptableObject и AssetBundle. Первое дает нам возможность хранить данные в виде Asset’a, а второе загружать и хранить эти данные извне.
Рассмотрим подробнее, что из себя представляет предлагаемый подход.
Как хранить данные
Для начала определим то, что нужно хранить и в каком виде, для этого будем двигаться от общего к частному. Базовыми данными, которые мы должны получить от любой системы локализации, является список поддерживаемых ей языков.
Примечание: по мере продвижения по статье я буду формировать нужные классы и описывать их. Итак, языки:
public class LocalizationData : ScriptableObject
{
public List<LanguageData> Languages;
}
[Serializable]
public class LanguageData
{
public string Name
{
get
{
return _name;
}
}
[SerializeField]
private string _name;
}
Имя поддерживаемого языка можно использовать в локализованном виде и использовать для вывода в интерфейсе. Как видно LocalizationData является наследником ScriptableObject, собственно этот наш класс и является основным хранилищем данных, который и будет лежать в проекте в виде Asset’а.
Что дальше? А дальше нам надо для каждого языка хранить набор ресурсов, тех конечных данных, которые будут использоваться в приложении или игре. Для начала определим типы ресурсов, которые мы будем использовать и заведем для них перечисление (enum):
public enum LocalizationResourceType
{
Text,
Image,
Texture,
Audio
}
Image — это Sprite для использования в интерфейсе на основе Unity GUI или для 2D игр. Почему он отдельно от Texture? Просто ради удобства.
[Serializable]
public class LocalizationResource
{
public string Tag
{
get
{
return _tag;
}
}
public string StringData
{
get
{
return _stringData;
}
}
public Font FontData
{
get
{
return _fontData;
}
}
public Sprite SpriteData
{
get
{
return _spriteData;
}
}
public Texture TextureData
{
get
{
return _textureData;
}
}
public AudioClip AudioData
{
get
{
return _audioData;
}
}
[SerializeField]
private string _tag;
[SerializeField]
private string _stringData;
[SerializeField]
private Font _fontData;
[SerializeField]
private Sprite _spriteData;
[SerializeField]
private Texture _textureData;
[SerializeField]
private AudioClip _audioData;
}
Как видно, класс содержит ссылки на все возможные типы ресурсов, однако не пугайтесь, в реальности только одна из этих ссылок валидна (хотя, конечно, ничто не мешает написать код так, чтобы ресурс был сборным). Исключение составляют только текст и шрифт, они могут существовать вместе. Обеспечение такого поведения вынесено на уровень редактора данных (об этом будет сказано ниже). Помимо прочего, здесь же указывается тэг, к которому принадлежат ресурсы. Что такое тэг будет описано ниже. Изменим наш класс LanguageData с учетом вышеописанного.
[Serializable]
public class LanguageData
{
public string Name
{
get
{
return _name;
}
}
public List<LocalizationResource> Resources;
[SerializeField]
private string _name;
}
Последней проблемой для хранилища данных локализации является интерпретация ресурса и его идентификация в независимости от языка. Это решается введением в систему тэгов, которые будут хранится независимо и позволят решить возникшие проблемы. Опишем это в классе.
[Serializable]
public class LocalizationTag
{
public string Name
{
get
{
return _name;
}
}
public LocalizationResourceType ResourceType
{
get
{
return _resourceType;
}
}
[SerializeField]
private string _name;
[SerializeField]
private LocalizationResourceType _resourceType;
}
Как видно тэг — это имя, которая будет использовано для идентификации ресурса в системе и тип ресурса для его интерпретации в конечные данные. Таким образом хранилище данных примет следующий вид.
public class LocalizationData : ScriptableObject
{
public List<LanguageData> Languages;
public List<LocalizationTag> Tags;
}
Примечание: несмотря на тот факт, что LocalizationData хранит список языков, нет обязательства делать именно так. Каждый язык можно хранить в своем Asset'е. При таком подходе, языки можно загружать по требованию пользователя с сервера.
Редактор
Мы сформировали представление для хранения данных локализации, теперь нам нужен инструмент, который позволит создавать эти данные. Я не буду приводить здесь полный код редактора, поскольку как его делать зависит от потребностей команды и критериев удобства, которые достаточно субъективны. В моем варианте все достаточно примитивно и отвечает текущим задачам в команде.
Для начала нам нужен создать Asset на основе описанного выше класса LocalizationData. Это можно сделать двумя способами:
- Через использование статической функции и атрибута MenuItem
- Через атрибут CreateAssetMenu, применяемый непосредственно к классу потомку ScriptableObject
Я использовал первый вариант, но на самом деле разницы нет.
[MenuItem("Assets/Create/Localization Data")]
public static void CreateLocalizationDataAsset()
{
var selectionPath = AssetDatabase.GetAssetPath(Selection.activeObject);
if (string.IsNullOrEmpty(selectionPath))
{
selectionPath = Application.dataPath;
}
var path = EditorUtility.SaveFilePanelInProject(
"Create Localization Data",
"NewLocalizationData",
"asset",
string.Empty,
selectionPath);
if (path.Length > 0)
{
var asset = ScriptableObject.CreateInstance<LocalizationData>();
AssetDatabase.CreateAsset(asset, path);
AssetDatabase.SaveAssets();
EditorUtility.FocusProjectWindow();
Selection.activeObject = asset;
}
}
После создания Asset’a он появится в проекте и теперь его можно редактировать. Для этого необходимо создать CustomEditor для нашего класса LocalizationData. Поскольку, локализация — это достаточно большой объем данных, то редактировать его напрямую в инспекторе нельзя, однако статистическую информацию можно вывести в следующем виде.
Здесь по кнопке Open Editor Window открывается окно редактора, где задаются языки, тэги и ресурсы. Сам редактор имеет следующий вид:
Как видно тут все достаточно просто, но при этом позволяет быстро редактировать необходимые данные. Тэги и языки редактируются отдельно друг от друга, однако если языки уже присутствуют, то при добавлении нового тэга каждому добавляется соответствующий ресурс.
Остановлюсь на нескольких важных моментах в редакторе:
- При изменении типа ресурса необходимо не забывать очищать ссылки, если они были, иначе может получиться, что ресурс будет содержать то, что не должен, а это в свою очередь приведет к росту размера AssetBundle’а.
- Текст представлен в очень маленьком окошке, в котором его не то, чтобы неудобно, а практически невозможно редактировать, поэтому для него необходимо написать отдельный редактор.
Окно редактора текста выглядит следующим образом:
Редактор можно не делать поддерживающим html-разметку (RichText в рамках Unity3d), это все по желанию.
public class LocalizationTextEditorWindow : EditorWindow
{
public SerializedProperty CurrentTextProperty;
public Font TextFont;
private GenericMenu _copyPasteMenu;
private GUIStyle _textStyle;
public static void Show(string tag, string language, SerializedProperty textProperty, Font textFont)
{
var instance = (LocalizationTextEditorWindow)EditorWindow.GetWindow(typeof(LocalizationTextEditorWindow), true);
instance.titleContent = new GUIContent("[{0}: {1}]".Fmt(language, tag), string.Empty);
instance.CurrentTextProperty = textProperty;
instance.TextFont = textFont;
}
private void OnEnable()
{
_copyPasteMenu = new GenericMenu();
_copyPasteMenu.AddItem(new GUIContent("Copy"), false, () =>
{
EditorGUIUtility.systemCopyBuffer = CurrentTextProperty.stringValue;
});
_copyPasteMenu.AddItem(new GUIContent("Paste"), false, () =>
{
CurrentTextProperty.stringValue = EditorGUIUtility.systemCopyBuffer;
CurrentTextProperty.serializedObject.ApplyModifiedProperties();
});
}
private void OnGUI()
{
if (CurrentTextProperty == null) return;
if (_textStyle == null)
{
_textStyle = new GUIStyle(EditorStyles.textArea);
_textStyle.font = TextFont;
}
if (Event.current.type == EventType.MouseDown && Event.current.button == 1)
{
_copyPasteMenu.ShowAsContext();
}
CurrentTextProperty.stringValue = GUI.TextArea(new Rect(0f, 0f, position.width, position.height), CurrentTextProperty.stringValue, _textStyle);
CurrentTextProperty.serializedObject.ApplyModifiedProperties();
}
}
Самым важным моментом в этом коде является возможность копировать и вставлять текст из буфера, в остальном все достаточно просто.
API
Прежде, чем описывать код системы локализации, который будет использоваться в приложении, определим основные требования, которые он должен выполнять. На самом деле вопрос достаточно субъективный, каждый разработчик предъявляет свой набор, в зависимости от возможностей и проекта. Я для себя и на основе своего опыта сформировал следующий список:
- Языки должны меняться налету. Это означает, что как только пользователь захотел поменять язык, то изменения вступают в силу сразу же.
- Данные локализации должны уметь формироваться из нескольких источников. Это означает, что их не обязательно хранить в одном Asset’е.
Исходя из этого начнем формировать код и для начала создадим базовый класс.
public class LocalizationController
{
public delegate void LanguageWasChanged();
public static event LanguageWasChanged OnLanguageWasChanged;
}
LangaungeWasChanged — это событие, на которое подписываются разные подсистемы. Событие нужно для тех мест, где обновление ресурсов при смене языка не требуется делать в автоматическом режиме. Экземпляр класса LocalizationController можно хранить где угодно и главное как угодно, включая вариант синглетона.
Теперь нам нужно завести внутренние хранилища данных, первое — это тэги и второе — это типы ресурсов им соответствующие:
private Dictionary<string, LocalizationResourceType> _resourceTypeByTag = new Dictionary<string, LocalizationResourceType>();
И сами ресурсы:
private Dictionary<string, LocalizationResource> _currentResources = new Dictionary<string, LocalizationResource>();
Теперь нам нужна функция, с помощью которой мы будем получать ресурс локализации по тэгу. Это нужно для получения данных в ручном режиме.
public object GetResourceByTag(string tag)
{
if (_resourceTypeByTag.ContainsKey(tag))
{
var resourceType = _resourceTypeByTag[tag];
var resource = _currentResources[tag];
switch (resourceType)
{
case LocalizationResourceType.Text:
return new KeyValuePair<string, Font>(resource.StringData, resource.FontData);
case LocalizationResourceType.Image:
return resource.SpriteData;
case LocalizationResourceType.Texture:
return resource.TextureData;
case LocalizationResourceType.Audio:
return resource.AudioData;
}
}
return null;
}
А что же с автоматическим вариантом и обновлением данных налету при смене языка?
private Dictionary<string, List<Action<object>>> _tagHandlers = new Dictionary<string, List<Action<object>>>();
public void SubscribeTag(string tag, Action<object> handler)
{
if (!_tagHandlers.ContainsKey(tag))
{
_tagHandlers.Add(tag, new List<Action<object>>());
}
_tagHandlers[tag].Add(handler);
}
public void UnsubscribeTag(string tag, Action<object> handler)
{
if (_tagHandlers.ContainsKey(tag))
{
var handlers = _tagHandlers[tag];
if (handlers.Contains(handler))
{
handlers.Remove(handler);
}
}
}
public void SetLanguage(LanguageData language)
{
ClearResources();
AddResources(language.Resources);
UpdateLocalizeResources();
OnLanguageWasChanged?.Invoke();
}
public void AddTags(IList<LocalizationTagParameter> tags)
{
for (var i = 0; i < tags.Count; i++)
{
var tag = tags[i];
_resourceTypeByTag.Add(tag.Name, tag.ResourceType);
}
}
public void AddResources(IList<LocalizationResource> resources)
{
foreach (var resource in resources)
{
_currentResources.Add(resource.Tag, resource);
}
}
public void UpdateLocalizeResources()
{
foreach (var tag in _tagHandlers.Keys)
{
var resource = GetResourceByTag(tag);
var handlers = _tagHandlers[tag];
foreach (var handler in handlers)
{
handler(resource);
}
}
}
Метод AddTags добавляет тэги к существующим в системе. Метод AddResources добавляет текущие языковые ресурсы. Метод UpdateLocalizeResources вызывает методы подписчиков на событие изменения языка. Последнее, что осталось сделать это добавить методы очистки данных.
Примечание: Для режима редактора и в метод AddTags и в метод AddResources можно/нужно вставить проверки на дубликаты имени тэгов. Это можно сделать через #if UNITY_EDITOR #endif.
public void ClearResources()
{
_currentResources.Clear();
}
public void Clear()
{
_resourceTypeByTag.Clear();
_currentResources.Clear();
_tagHandlers.Clear();
}
Итак, если посмотреть на весь написанный код, то в целом сама основа не представляет из себя никакой сложности, все очень просто. Однако нам не хватает еще одной вещи, а в частности компонента, который позволит нам обновлять ресурсы по тэгу.
[Serializable]
public class LocalizationTagDefinition
{
public string Tag;
private Action<object> _languageChangedHandler;
public void Subsribe (Action<object> handler)
{
_languageChangedHandler = handler;
LocalizationController.SubscribeTag(Tag, handler);
}
public void Unsubscribe()
{
LocalizationController.UnsubscribeTag(Tag, _languageChangedHandler);
}
}
Экземпляр данного класса можно заводить в любом скрипте работающим с интерфейсом или данными, требующими локализации. Для удобства можно завести для него отдельный редактор для инспектора, используя CustomPropertyDrawer. Такой редактор может выглядеть примерно так:
Как использовать
Итак, выше было описано то, как мы храним данные локализации и код необходимый для работы с ними. Рассмотрим теперь базовые сценарии использования описанной системы локализации.
public class GameLocalization : MonoBehaviour
{
public static LocalizationController Controller
{
get
{
if (_localizationController == null)
{
_localizationController = new LocalizationController();
}
return _localizationController;
}
}
public LocalizationData DefaultLocalization;
public int DefaultLanguage;
private static LocalizationController _localizationController;
void Start()
{
if (DefaultLocalization == null)
{
StartCoroutine(LoadLocalizationData("http://myserver.ru/localization", (bundle) =>
{
DefaultLocalization = bundle.LoadAllAssets<LocalizationData>()[0];
InitLanguage();
bundle.Unload(true);
}));
}else
{
InitLanguage();
}
}
public void ChangeLanguage(int languageId)
{
Controller.SetLanguage(DefaultLocalization.Languages[languageId]);
}
public List<string> GetLanguages()
{
var languages = new List<string>();
for (var i = 0; i < DefaultLocalization.Languages.Count; i++)
{
languages.Add(DefaultLocalization.Languages[i].Name);
}
return languages;
}
IEnumerator LoadLocalizationData(string url, Action<AssetBundle> result)
{
var request = UnityWebRequestAssetBundle.GetAssetBundle(url);
yield return request.SendWebRequest();
var assetBundle = DownloadHandlerAssetBundle.GetContent(request);
result(assetBundle);
request.Dispose();
}
private void InitLanguage()
{
Controller.AddTags(DefaultLocalization.Tags);
Controller.SetLanguage(DefaultLocalization.Languages[DefaultLanguage]);
}
}
Что у нас получается: на старте мы смотрим, есть ли установленный Asset локализации, если да, то мы инициализируем контроллер локализации и устанавливаем язык по умолчанию, если нет, то загружаем Asset с сервера. Также присутствует два метода, для установки языка и получения списка языков, для показа их в интерфейсе. При вызове метода SetLanguage все подписчики изменений ресурсов по тэгам получат уведомления и обновят свои ресурсы.
Теперь рассмотрим вариант, когда у нас данные локализации разбросаны по нескольким Asset’ам.
public LocalizationData LocalizationAudio;
public LocalizationData LocalizationImage;
public LocalizationData LocalizationText;
public int DefaultLanguage;
void Start()
{
Controller.AddTags(LocalizationAudio.Tags);
Controller.AddTags(LocalizationImage.Tags);
Controller.AddTags(LocalizationText.Tags);
ChangeLanguage(DefaultLanguage);
}
public void ChangeLanguage(int languageId)
{
Controller.ClearResources();
Controller.AddResources(LocalizationAudio.Languages[languageId].Resources);
Controller.AddResources(LocalizationImage.Languages[languageId].Resources);
Controller.AddResources(LocalizationText.Languages[languageId].Resources);
Controller.UpdateLocalizeResources();
}
Я думаю тут все понятно без пояснений: мы просто добавляем тэги, как и раньше, но ресурсы добавляем в ручном режиме, после чего вызываем метод UpdateLocalizeResource, который вызовет уведомление для всех подписчиков на тэги.
И в заключении нам осталось рассмотреть работу с ресурсами и тэгами в конечной точке, т.е. на уровне контента и в качестве примера возьмем объект Image из Unity GUI;
public class LocalizeImage : MonoBehaviour
{
public LocalizationTagDefinition ImageTag;
private void OnEnable()
{
ImageTag.Subsсribe((data) =>
{
GetComponent<Image>().sprite = data as Sprite;
});
}
private void OnDisable()
{
ImageTag.Unsubscribe();
}
}
Здесь мы используем описанный раннее компонент LocalizationTagDefinition. Повесив данный скрипт на объект, мы получим автоматическое изменение изображения в случае, если сменится язык.
Заключение
В заключении хочу сказать, что применение данного подхода в текущей моей работе довольно сильно облегчило жизнь с локализацией. В сегменте, в котором разрабатываются мои проекты объем различных данных достаточно большой: и речь, и изображение, и текст. Также большая часть языков не включена в основное приложение и они загружается по требованию пользователя. Помимо прочего игра порой должна вести себя по-разному для разных языков (это достигается путем добавления json строк в текстовые данные локализации). Конечно, система возможно не оптимальна и есть куда развиваться и по части кода, и по части редактора (особенно его, например добавить импорт текстовых данных из Google Sheets и сделать его более приятным глазу), но для моих проектов ее на данный момент достаточно.
В конце небольшой пример, где использовался описанный выше подход. Это визуальный редактор логики Panthea VS (идейным вдохновителем которого был PlayMaker).
Автор: Ichimitsu