Localization based on ScriptableObject for Unity3D
Introduction
Welcome, dear readers. This article will discuss the creation of a localization system for applications created in the Unity3D environment, which is based on the use of the ScriptableObject class, which allows localizing not only text, but also sounds and images, as well as downloading such data from the outside. By tradition, before proceeding to the description of the details, let us dwell on what localization is and why we need it.
Very often, and almost always, the development of games (and any other applications) focuses not on one market. Since each market is characterized by its own language group, then developers have to take this into account, because if you make the game only in Russian, then English-speaking users simply won't understand anything. What to do? That's right, you need to provide support for multiple languages in the game. In most cases, only text data is translated and Google Sheets or something similar is often used for this. This is quite simple and flexible, since importing from tables is not difficult. However, not everything is as rosy as it may seem at first glance. What if the game has a lot of voice guidance? Or should the text have a different font for different languages? And finally, the text or something that requires uniqueness for the language in the images? In these cases, the tables are no longer enough.
So what do you ask (if, of course, do not know the answer)? I have come to the use case of ScriptableObject and AssetBundle . The first gives us the ability to store data in the form of Asset 'a, and the second to download and store this data from the outside. ')
Let us consider in more detail what the proposed approach is.
How to store data
To begin with, we will define what needs to be stored and in what form, for this we will move from the general to the particular. The basic data we need to get from any localization system is a list of languages supported by it.
Note : as you move through the article, I will form the necessary classes and describe them. So, languages:
publicclassLocalizationData : ScriptableObject { public List<LanguageData> Languages; } [Serializable] publicclassLanguageData { publicstring Name { get { return _name; } } [SerializeField] privatestring _name; }
The name of the supported language can be used in a localized form and used for output in the interface. As you can see, LocalizationData is a successor of ScriptableObject , in fact , this our class is the main data storage, which will be in the project as an Asset .
What's next? And then we need for each language to store a set of resources, those final data that will be used in the application or game. To begin with, we will define the types of resources that we will use and will create an enumeration for them ( enum ):
Image is a Sprite for use in a Unity GUI based interface or for 2D games. Why is it separate from Texture ? Just for the sake of convenience.
Now we will determine the place where we will store the links to resources directly.
[Serializable] publicclassLocalizationResource { publicstring Tag { get { return _tag; } } publicstring 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] privatestring _tag; [SerializeField] privatestring _stringData; [SerializeField] private Font _fontData; [SerializeField] private Sprite _spriteData; [SerializeField] private Texture _textureData; [SerializeField] private AudioClip _audioData; }
As you can see, the class contains links to all possible types of resources, but don’t get scared, in reality only one of these links is valid (although, of course, nothing prevents you from writing code so that the resource is compiled). The only exceptions are text and font, they can exist together. The provision of such behavior is brought to the level of the data editor (this will be discussed below). Among other things, it also indicates the tag to which the resources belong. What is a tag will be described below. Let's change our LanguageData class with the above.
[Serializable] publicclassLanguageData { publicstring Name { get { return _name; } } public List<LocalizationResource> Resources; [SerializeField] privatestring _name; }
The last problem for the localization data warehouse is the interpretation of the resource and its identification regardless of the language. This is solved by the introduction of tags into the system, which will be stored independently and will allow to solve the problems that have arisen. We describe this in class.
[Serializable] publicclassLocalizationTag { publicstring Name { get { return _name; } } public LocalizationResourceType ResourceType { get { return _resourceType; } } [SerializeField] privatestring _name; [SerializeField] private LocalizationResourceType _resourceType; }
As you can see, the tag is the name that will be used to identify the resource in the system and the type of the resource for its interpretation into the final data. Thus, the data warehouse takes the following form.
publicclassLocalizationData : ScriptableObject { public List<LanguageData> Languages; public List<LocalizationTag> Tags; }
Note : Despite the fact that LocalizationData stores a list of languages, there is no obligation to do just that. Each language can be stored in its Asset 'e. With this approach, languages can be downloaded at the request of the user from the server.
Editor
We have created a view for storing localization data, now we need a tool that will allow us to create this data. I will not give here the full code of the editor, since how to do it depends on the needs of the team and the criteria of convenience, which are quite subjective. In my version, everything is quite primitive and answers the current tasks in the team.
First we need to create an Asset based on the LocalizationData class described above. This can be done in two ways:
Through the use of the static function and the MenuItem attribute
Through the CreateAssetMenu attribute, applied directly to the descendant class of the ScriptableObject
I used the first option, but in fact there is no difference.
The function of creating an Asset for localization data is as follows:
[MenuItem("Assets/Create/Localization Data")] publicstaticvoidCreateLocalizationDataAsset() { 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; } }
After creating Asset 'a, it will appear in the project and now it can be edited. To do this, you need to create a CustomEditor for our LocalizationData class. Since localization is a fairly large amount of data, it cannot be edited directly in the inspector, however, statistical information can be displayed in the following form.
Here, the Open Editor Window button opens an editor window where languages, tags and resources are defined. The editor itself has the following form:
As you can see here everything is quite simple, but it allows you to quickly edit the necessary data. Tags and languages are edited separately from each other, however, if languages are already present, then adding a new tag adds a corresponding resource to each.
I’ll dwell on several important points in the editor:
When changing the type of resource, you must not forget to clear the links if they were, otherwise it may happen that the resource will contain what it should not, and this in turn will lead to an increase in the size of AssetBundle .
The text is presented in a very small window, in which it is not that inconvenient, but almost impossible to edit, so you need to write a separate editor for it.
The text editor window looks like this:
The editor can not be done supporting html-markup ( RichText in the framework of Unity3d ), it's all optional.
The most important point in this code is the ability to copy and paste text from the buffer, otherwise everything is quite simple.
API
Before describing the code of the localization system that will be used in the application, we define the basic requirements that it must fulfill. In fact, the question is quite subjective, each developer presents his own set, depending on the capabilities and the project. I have formed the following list for myself and on the basis of my experience:
Languages should change on the fly. This means that as soon as the user wants to change the language, the changes take effect immediately.
Localization data should be able to be formed from several sources. This means that they do not need to be stored in the same Asset 'e.
Based on this, we will begin to form the code and first create the base class.
LangaungeWasChanged is an event that different subsystems subscribe to. The event is needed for those places where the resource update when changing the language is not required to be done automatically. An instance of the LocalizationController class can be stored anywhere and as important as you like, including the singleton variant.
Now we need to create internal data stores, the first is the tags and the second is the corresponding types of resources:
private Dictionary<string, LocalizationResourceType> _resourceTypeByTag = new Dictionary<string, LocalizationResourceType>();
And the resources themselves:
private Dictionary<string, LocalizationResource> _currentResources = new Dictionary<string, LocalizationResource>();
Now we need a function with which we will get the localization resource by tag. This is necessary to obtain data in manual mode.
publicobjectGetResourceByTag(string tag) { if (_resourceTypeByTag.ContainsKey(tag)) { var resourceType = _resourceTypeByTag[tag]; var resource = _currentResources[tag]; switch (resourceType) { case LocalizationResourceType.Text: returnnew 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; } } returnnull; }
And what about the automatic option and update data on the fly when changing the language?
For this purpose, we will create a subscriber store and two methods.
private Dictionary<string, List<Action<object>>> _tagHandlers = new Dictionary<string, List<Action<object>>>(); publicvoidSubscribeTag(string tag, Action<object> handler) { if (!_tagHandlers.ContainsKey(tag)) { _tagHandlers.Add(tag, new List<Action<object>>()); } _tagHandlers[tag].Add(handler); } publicvoidUnsubscribeTag(string tag, Action<object> handler) { if (_tagHandlers.ContainsKey(tag)) { var handlers = _tagHandlers[tag]; if (handlers.Contains(handler)) { handlers.Remove(handler); } } }
Now we need to add methods to install data from Asset.
publicvoidSetLanguage(LanguageData language) { ClearResources(); AddResources(language.Resources); UpdateLocalizeResources(); OnLanguageWasChanged?.Invoke(); } publicvoidAddTags(IList<LocalizationTagParameter> tags) { for (var i = 0; i < tags.Count; i++) { var tag = tags[i]; _resourceTypeByTag.Add(tag.Name, tag.ResourceType); } } publicvoidAddResources(IList<LocalizationResource> resources) { foreach (var resource in resources) { _currentResources.Add(resource.Tag, resource); } } publicvoidUpdateLocalizeResources() { foreach (var tag in _tagHandlers.Keys) { var resource = GetResourceByTag(tag); var handlers = _tagHandlers[tag]; foreach (var handler in handlers) { handler(resource); } } }
The AddTags method adds tags to existing ones in the system. The AddResources method adds current language resources. The UpdateLocalizeResources method calls methods of subscribers to a language change event. The last thing left to do is add data cleaning methods.
Note : For editor mode and in the AddTags method and in the AddResources method, you can / need to insert checks for duplicate tag names. This can be done through #if UNITY_EDITOR #endif .
So, if you look at all the written code, in general, the very basis is not any difficulty, everything is very simple. However, we lack one more thing, and in particular a component that will allow us to update resources by tag.
An instance of this class can be started in any script working with an interface or data that requires localization. For convenience, you can create a separate editor for it for the inspector, using CustomPropertyDrawer . Such an editor might look something like this:
How to use
So, the above described how we store the localization data and the code necessary to work with them. We now consider the basic scenarios for using the described localization system.
And the first will be the option when we have one data set in which several languages are stored
publicclassGameLocalization : MonoBehaviour { publicstatic LocalizationController Controller { get { if (_localizationController == null) { _localizationController = new LocalizationController(); } return _localizationController; } } public LocalizationData DefaultLocalization; publicint DefaultLanguage; privatestatic LocalizationController _localizationController; voidStart() { if (DefaultLocalization == null) { StartCoroutine(LoadLocalizationData("http://myserver.ru/localization", (bundle) => { DefaultLocalization = bundle.LoadAllAssets<LocalizationData>()[0]; InitLanguage(); bundle.Unload(true); })); }else { InitLanguage(); } } publicvoidChangeLanguage(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); yieldreturn request.SendWebRequest(); var assetBundle = DownloadHandlerAssetBundle.GetContent(request); result(assetBundle); request.Dispose(); } privatevoidInitLanguage() { Controller.AddTags(DefaultLocalization.Tags); Controller.SetLanguage(DefaultLocalization.Languages[DefaultLanguage]); } }
What we get: at the start we see if the Asset localization is installed, if so, we initialize the localization controller and set the default language, if not, we download the Asset from the server. There are also two methods to set the language and get a list of languages to display them in the interface. When calling the SetLanguage method , all resource change subscribers by tag will receive notifications and update their resources.
Now consider the option when we have data localization scattered across several Asset 's.
Here we need to change a few methods from the previous example.
public LocalizationData LocalizationAudio; public LocalizationData LocalizationImage; public LocalizationData LocalizationText; publicint DefaultLanguage; voidStart() { Controller.AddTags(LocalizationAudio.Tags); Controller.AddTags(LocalizationImage.Tags); Controller.AddTags(LocalizationText.Tags); ChangeLanguage(DefaultLanguage); } publicvoidChangeLanguage(int languageId) { Controller.ClearResources(); Controller.AddResources(LocalizationAudio.Languages[languageId].Resources); Controller.AddResources(LocalizationImage.Languages[languageId].Resources); Controller.AddResources(LocalizationText.Languages[languageId].Resources); Controller.UpdateLocalizeResources(); }
I think everything is clear here without explanation: we just add tags as before, but we add resources manually, then call the UpdateLocalizeResource method, which will trigger a notification for all subscribers to the tags.
And in the end, it remains to consider the work with resources and tags at the end point, i.e. at the content level and as an example, take the Image object from the Unity GUI ;
publicclassLocalizeImage : MonoBehaviour { public LocalizationTagDefinition ImageTag; privatevoidOnEnable() { ImageTag.Subsribe((data) => { GetComponent<Image>().sprite = data as Sprite; }); } privatevoidOnDisable() { ImageTag.Unsubscribe(); } }
Here we use the previously described component LocalizationTagDefinition . Hanging this script on an object, we get an automatic image change in case the language changes.
Conclusion
In conclusion, I want to say that the use of this approach in my current work has quite greatly facilitated life with localization. In the segment in which my projects are developed, the volume of various data is quite large: both the speech, and the image, and the text. Also, most languages are not included in the main application and they are loaded on demand. Among other things, the game sometimes has to behave differently for different languages (this is achieved by adding json lines to textual localization data). Of course, the system may not be optimal and there is room for development both in terms of the code and in the editor (especially, for example, import text data from Google Sheets and make it more pleasing to the eye), but for my projects it is enough at the moment.
At the end of a small example where the approach described above was used. This is the visual editor of the logic Panthea VS (the ideological inspirer of which was PlayMaker ).