Localization
OpenMon ships with a custom localization system backed by Google Sheets. Translations are downloaded as TSV files, parsed into ScriptableLanguage assets, and served at runtime through the Localizer class. The system lives in OpenMon.Foundation.Localization with game-specific post-processors in OpenMon.MCE.Localization.PostProcessors.
Architecture Overview
| Class | Purpose |
|---|---|
Localizer | Core runtime service. Loads language packs, resolves keys, applies post-processors |
ILocalizer | Interface for the localizer, injected via Zenject |
ScriptableLanguage | ScriptableObject holding all key-value pairs for one language |
LocalizerSettings | Developer config: language pack directory, Google Sheet URLs, post-processors |
LocalizerConfiguration | Player config: persisted selected language (default: "English") |
GoogleSheetLoader | Editor tool that downloads sheets and generates ScriptableLanguage assets |
LocalizedTextMeshPro | UI component that auto-updates a TMP_Text when the language changes |
LocalizationFontSwitcher | Swaps TMP fonts per-language (for CJK support) |
LanguageFontMap | ScriptableObject mapping language names to TMP_FontAsset overrides |
LocalizedTextPostProcessor | Abstract base for post-processing resolved text |
ReplaceSubstring | Built-in post-processor: simple find-and-replace |
InjectPlayerName | MCE post-processor: replaces {PlayerName} with the player character's name |
InjectDexValues | MCE post-processor: replaces {Dex.CaughtSpecies} with the caught count |
Google Sheets Integration
Translations are authored in Google Sheets with this layout:
| Key | English | Spanish | Japanese |
|---|---|---|---|
Menu/Play | Play | Jugar | ... |
Menu/Options | Options | Opciones | ... |
Monsters/001/Name | Sproutyl | Brotino | ... |
Each column after the key column represents a language. The column header becomes the ScriptableLanguage asset name.
Editor Import
The GoogleSheetLoader editor utility downloads each sheet URL from LocalizerSettings.GoogleSheetsDownloadUrls, converts it to TSV export format, and parses the rows into ScriptableLanguage assets saved to Assets/Resources/{LanguagePackDirectory}/.
The flow:
- For each URL, convert the Google Sheets
/editURL to/export?format=tsv - Download the TSV file to
Temp/ - Parse headers to discover language names
- Create or update a
ScriptableLanguageasset per language - Populate
Language[key] = valuefor each row - Save assets and refresh the AssetDatabase
Multiple sheets are supported. If two sheets define the same key, the later URL wins (lower URLs in the list override higher ones).
Runtime Re-download
The Localizer can re-download translations at runtime via ReDownloadLanguagesFromGoogleSheet(). This creates temporary ScriptableLanguage instances in memory (not saved to disk) and reloads the language system. Useful for testing translations without re-importing in the editor.
ScriptableLanguage
Each language is a ScriptableLanguage ScriptableObject containing a SerializableDictionary<string, string> of all key-value pairs. The asset is stored in Resources/Languages/ (configurable via LocalizerSettings.LanguagePackDirectory).
// ScriptableLanguage resolves keys with a fallback to the raw key
string value = englishPack["Menu/Play"]; // "Play"
string missing = englishPack["Nonexistent"]; // "Nonexistent" (raw key as fallback)
When a key is not found, the asset returns the key itself. This makes missing translations immediately visible in-game as raw key strings.
Localizer
The Localizer is the main runtime service, bound as ILocalizer via Zenject. It loads all ScriptableLanguage assets from the Resources directory on initialization.
Resolving Text
// Indexer syntax (most common)
string text = localizer["Menu/Play"]; // "Play"
// With substitution parameters
// If the value is "You caught {0}!" and "Monsters/025/Name" resolves to "Thundermane"
string text = localizer["Messages/Caught", true, "Monsters/025/Name"];
// Result: "You caught Thundermane!"
// Parameters as literal strings (not localization keys)
string text = localizer.GetText("Messages/Caught", false, "Thundermane");
The {0}, {1}, etc. placeholders in translation values are replaced by the corresponding modifier string. When modifiersAreLocalizableKeys is true (default), each modifier is itself resolved through the localizer before substitution.
Language Switching
// Get available languages
List<string> languages = localizer.GetAllLanguageIds(); // ["English", "Spanish", "Japanese"]
// Get current language
string current = localizer.GetCurrentLanguage(); // "English"
// Switch language (persisted automatically to config)
localizer.SetLanguage("Spanish");
// Switch by index
localizer.SetLanguage(1);
When the language changes, the selection is persisted via IConfigurationManager so it survives app restarts. All subscribers to the language-changed event are notified.
Language Change Events
// Subscribe with language name
localizer.SubscribeToLanguageChange((string newLang) =>
{
Debug.Log("Language changed to: " + newLang);
});
// Subscribe without parameter (just a notification)
localizer.SubscribeToLanguageChange(() => RefreshUI());
// Unsubscribe
localizer.UnsubscribeFromLanguageChange(myCallback);
Localization Key Conventions
Keys follow a hierarchical path format:
| Domain | Pattern | Example |
|---|---|---|
| UI / Menus | Menu/{Screen}/{Element} | Menu/Battle/Run |
| Monsters | Monsters/{DexNumber}/Name | Monsters/001/Name |
| Monster descriptions | Monsters/{DexNumber}/Description | Monsters/001/Description |
| Moves | Moves/{MoveName}/Name | Moves/Tackle/Name |
| Items | Items/{ItemName}/Name | Items/Potion/Name |
| Quests | Quests/{QuestName} | Quests/FindTheProfessor |
| Quest objectives | Quests/{QuestName}/Objectives/{N} | Quests/FindTheProfessor/Objectives/0 |
| Dialog | Dialog/{SceneName}/{NPC}/{Line} | Dialog/Route1/OldMan/0 |
Text Post-Processors
After a key is resolved and modifiers are substituted, the text passes through a pipeline of LocalizedTextPostProcessor instances configured in LocalizerSettings.TextPostProcessors. Each processor can modify the string in-place.
Built-in: ReplaceSubstring
A simple find-and-replace processor. Create via OpenMon > Localization > TextPostProcessors > ReplaceSubstring. Configure the Original and Replacement fields in the inspector. Useful for global substitutions like replacing \n with actual newlines.
MCE: InjectPlayerName
Replaces {PlayerName} in any localized string with the player character's LocalizableName. Create via MCE > Localization > TextPostProcessors > InjectPlayerName.
// Translation value: "Welcome, {PlayerName}! Ready to explore?"
// After post-processing: "Welcome, Red! Ready to explore?"
MCE: InjectDexValues
Replaces {Dex.CaughtSpecies} with the number of species the player has caught. Create via MCE > Localization > TextPostProcessors > InjectDexValues.
Custom Post-Processors
Extend LocalizedTextPostProcessor to create your own:
[CreateAssetMenu(menuName = "MyGame/Localization/MyPostProcessor")]
public class MyPostProcessor : LocalizedTextPostProcessor
{
public override bool PostProcessText(ref string text, params object[] extraParams)
{
text = text.Replace("{MyToken}", GetMyValue());
return true;
}
}
Add the asset to LocalizerSettings.TextPostProcessors for it to be included in the pipeline.
UI: LocalizedTextMeshPro
The LocalizedTextMeshPro component binds a TMP_Text to a localization key. It auto-updates when the language changes.
// Set from code
localizedText.SetValue("Menu/Play");
// With modifiers
localizedText.SetValue("Messages/Caught", true, "Monsters/025/Name");
Add this component to any GameObject with a TMP_Text. Set the LocalizationKey field in the inspector. When SetOnEnable is true (default), the text resolves immediately on enable.
CJK Font Support
For languages like Japanese that require different glyphs, use the LocalizationFontSwitcher component together with a LanguageFontMap asset.
Setup
- Create a LanguageFontMap asset via OpenMon > Localization > Language Font Map
- Add entries mapping language names to TMP_FontAsset references:
"Japanese"->NotoSansJP-Regular SDF- Optionally set a bold variant per language
- Add
LocalizationFontSwitcherto your root Canvas - Assign the
LanguageFontMapto theFontMapfield - Set
SearchScopetoChildren(default) orScene
When the language changes, the switcher iterates all TMP_Text components in the configured scope and swaps their font. It caches the original font so switching back to a non-mapped language restores the default.
Dependency Injection Setup
The LocalizationInstaller (create via OpenMon > Installers > Localization) binds:
LocalizerSettingsas a singleton (injected intoLocalizer)ILocalizertoLocalizeras a lazy singleton- All configured
TextPostProcessorsare queued for injection
SceneContext
+-- LocalizationInstaller (references LocalizerSettings asset)
Adding a New Language
- Add a new column to your Google Sheet with the language name as the header (e.g.,
French) - Fill in translations for all keys
- Re-run the Google Sheet import in the editor
- A new
French.assetwill appear inResources/Languages/ - If the language requires a different font, add an entry to your
LanguageFontMap
No code changes are required. The Localizer discovers all ScriptableLanguage assets in the configured directory at startup.