Table of Contents

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.name is 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.font names a font that can draw the language's text.
  • language.script names the script the language is written in, one of the names in LanguageScripts. It defaults to latin.

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:

  • FilePath is the folder of the language files. It defaults to Localization under the working directory, which is not the game's own folder when the game is started from elsewhere. Set it to a path under AppContext.BaseDirectory, and copy the files into the output with the project file.
  • DiscoverLanguages() adds every .json file in the folder as a language. AddLanguage(code) adds one by hand. The list starts with en.
  • AdditionalFolders lists further folders to search, after FilePath. 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.
  • DefaultLanguageCode is the language to show first.
  • FallbackLanguageCode is the language that fills in keys the current language lacks. It is en by 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.