Skip to main content

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

ClassPurpose
LocalizerCore runtime service. Loads language packs, resolves keys, applies post-processors
ILocalizerInterface for the localizer, injected via Zenject
ScriptableLanguageScriptableObject holding all key-value pairs for one language
LocalizerSettingsDeveloper config: language pack directory, Google Sheet URLs, post-processors
LocalizerConfigurationPlayer config: persisted selected language (default: "English")
GoogleSheetLoaderEditor tool that downloads sheets and generates ScriptableLanguage assets
LocalizedTextMeshProUI component that auto-updates a TMP_Text when the language changes
LocalizationFontSwitcherSwaps TMP fonts per-language (for CJK support)
LanguageFontMapScriptableObject mapping language names to TMP_FontAsset overrides
LocalizedTextPostProcessorAbstract base for post-processing resolved text
ReplaceSubstringBuilt-in post-processor: simple find-and-replace
InjectPlayerNameMCE post-processor: replaces {PlayerName} with the player character's name
InjectDexValuesMCE post-processor: replaces {Dex.CaughtSpecies} with the caught count

Google Sheets Integration

Translations are authored in Google Sheets with this layout:

KeyEnglishSpanishJapanese
Menu/PlayPlayJugar...
Menu/OptionsOptionsOpciones...
Monsters/001/NameSproutylBrotino...

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:

  1. For each URL, convert the Google Sheets /edit URL to /export?format=tsv
  2. Download the TSV file to Temp/
  3. Parse headers to discover language names
  4. Create or update a ScriptableLanguage asset per language
  5. Populate Language[key] = value for each row
  6. 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:

DomainPatternExample
UI / MenusMenu/{Screen}/{Element}Menu/Battle/Run
MonstersMonsters/{DexNumber}/NameMonsters/001/Name
Monster descriptionsMonsters/{DexNumber}/DescriptionMonsters/001/Description
MovesMoves/{MoveName}/NameMoves/Tackle/Name
ItemsItems/{ItemName}/NameItems/Potion/Name
QuestsQuests/{QuestName}Quests/FindTheProfessor
Quest objectivesQuests/{QuestName}/Objectives/{N}Quests/FindTheProfessor/Objectives/0
DialogDialog/{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

  1. Create a LanguageFontMap asset via OpenMon > Localization > Language Font Map
  2. Add entries mapping language names to TMP_FontAsset references:
    • "Japanese" -> NotoSansJP-Regular SDF
    • Optionally set a bold variant per language
  3. Add LocalizationFontSwitcher to your root Canvas
  4. Assign the LanguageFontMap to the FontMap field
  5. Set SearchScope to Children (default) or Scene

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:

  • LocalizerSettings as a singleton (injected into Localizer)
  • ILocalizer to Localizer as a lazy singleton
  • All configured TextPostProcessors are queued for injection
SceneContext
+-- LocalizationInstaller (references LocalizerSettings asset)

Adding a New Language

  1. Add a new column to your Google Sheet with the language name as the header (e.g., French)
  2. Fill in translations for all keys
  3. Re-run the Google Sheet import in the editor
  4. A new French.asset will appear in Resources/Languages/
  5. 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.