Localization
Each language is one JSON file, named for its language code. The game looks text up by key, so a
translation is a file of the same keys with the words changed. The engine loads the files through
LocalizationBootstrap, which is registered in the service
container. The engine does not load them itself: the game calls InitializeLanguages once, usually in
Initialize.
Language files
A language file is a JSON object. Keys can be nested or written with dots. These two lines mean the same key:
{ "menu": { "start": "Start" } }
{ "menu.start": "Start" }
Values are strings. A value may contain {0}, {1} and so on, which are filled in with the
arguments the text is given. The formatting follows the current culture.
A key whose name begins with _ is for the translator, not the game. It is skipped, so notes can sit
beside the text:
{
"_translator": "Translated from the English file.",
"language": {
"name": "Français",
"font": "NotoSans-Regular.ttf",
"script": "latin"
},
"menu": {
"start": "Commencer",
"quit": "Quitter"
},
"score.points": "Score : {0}"
}
Three keys under language describe the language itself:
language.nameis the name shown to the player. A file without it is reported as a problem, and the language code is shown in its place.language.fontnames a font that can draw the language's text.language.scriptnames the script the language is written in, one of the names in LanguageScripts. It defaults tolatin.
font and script are the translator saying what their text needs. The engine reads them and
applies neither: it keeps the font's name as written and does not load it. What to draw in that font
is the game's choice. A game might make it the default for its menus, or give it to Dear ImGui so
debug windows can show the language (see Dear ImGui), and leave a pixel-art HUD in its
own font.
Setting up
Set the folder and the languages before calling InitializeLanguages. Do it in Initialize:
using BSGameLib.Game.Localization;
protected override void Initialize()
{
var languages = GetService<LocalizationBootstrap>();
languages.FilePath = Path.Combine(AppContext.BaseDirectory, "Localization");
languages.DiscoverLanguages();
languages.DefaultLanguageCode = "fr"; // the player's choice, once the game has one
languages.FallbackLanguageCode = "en";
languages.InitializeLanguages();
base.Initialize();
}
These are the settings:
FilePathis the folder of the language files. It defaults toLocalizationunder the working directory, which is not the game's own folder when the game is started from elsewhere. Set it to a path underAppContext.BaseDirectory, and copy the files into the output with the project file.DiscoverLanguages()adds every.jsonfile in the folder as a language.AddLanguage(code)adds one by hand. The list starts withen.AdditionalFolderslists further folders to search, afterFilePath. A player's own translation can go here. When the same language is in more than one folder, the last folder's file is loaded, so it replaces the shipped one.DefaultLanguageCodeis the language to show first.FallbackLanguageCodeis the language that fills in keys the current language lacks. It isenby default, and should have every key.
InitializeLanguages loads every language and makes the default current. If the default fails to load,
the fallback is shown instead. If neither loads, it throws. Calling it again starts over and reloads
every file.
Looking text up
Text is a LocalizedString. Give it a key with FromKey, or a plain
string with FromRaw or an implicit conversion. A key with arguments is formatted into the text:
LocalizedString title = "Untitled";
LocalizedString start = LocalizedString.FromKey("menu.start");
LocalizedString score = LocalizedString.FromKey("score.points", 1200);
string text = score.Resolve();
A LocalizedString converts to string by resolving at that moment, so it always shows the current
language. LocalizedString.Exists(key) says whether a key is present in the current or fallback
language. TryResolve(key, out string value) looks a key up without falling back to showing the key
itself.
A key that no language has is shown as the key, and the log warns about it once for each language.
Changing the language
TrySetLanguage(code) makes a loaded language current, and returns false if that language is not
loaded:
var languages = GetService<LocalizationBootstrap>();
// False when French is not loaded, and the current language stays.
bool switched = languages.TrySetLanguage("fr");
Text already on screen keeps the words it had. Text resolved after the change is in the new language.
That includes views: a view resolves its {Localize key} references when it is built, so a view that is
already open keeps its text until it is built again. See Bindings and commands.
The languages and their problems
The bootstrap reports what it found, which is the list a language menu needs:
var languages = GetService<LocalizationBootstrap>();
foreach (var language in languages.SupportedLanguages)
Console.WriteLine($"{language.LanguageCode}: {language.DisplayName}");
var current = languages.CurrentLanguage; // null before InitializeLanguages has run, or if the language shown is not supported
SupportedLanguages returns a LanguageInfo for each language that
loaded. Its FontFile and Script are the values from the language file, and FilePath is the file it
came from.
Problems lists what is wrong with the files at the last InitializeLanguages. Each
LanguageProblem names the language, its file and a short message, such as
"is not valid JSON (line 3)", "has no language.name", or a key the fallback language does not have. A
translator can check their file against this list.
Text the game receives while it runs
SetAddedTexts(name, texts) adds sentences beside the language files while the game runs. Calling it with
the same name again replaces that set, and passing no sentences removes it. A key is looked up in the
current language first, then in these sets, then in the fallback. InitializeLanguages removes every set.
Text that wins over a language file
SetOverrideTexts(languageCode, name, texts) sets sentences that win over one language's own file, for
an editor's unsaved changes or for tuning text while the game runs. Like added sets, a second call with
the same language and name replaces the set, and passing no sentences removes it. An override applies
only to its language, whether that language is the one shown or the fallback, so an editor working on a
language that isn't showing changes nothing the player sees. A key that only an override has is found
without being reported missing. Text already resolved stays as it was; text resolved from then on uses
the override. InitializeLanguages removes every override set, so an editor that reloads the files puts
back any changes it hasn't saved yet.