Lua API

i18n.md topic

Localization

In order to allow players who are fluent in different languages to understand all kinds of content in the game, a localization system is needed. This allows a single piece of text to be translated across any language, instead of hard-coding strings for all possible languages in the code.

Getting started

In Elona foobar, locale files are written in Lua scripts. Here is a simple example of a locale file.

ELONA.i18n:add {
   my_string = "Hello, world!"
}

Let's say the above config file is part of a mod named my_mod. Locale files would be placed in a folder structure like my_mod/locale/en/my_strings.lua. The placement of the my_strings.lua file in the en/ folder indicates it is to be used with the English language. When the locale files are loaded, the localization ID my_mod.my_string will be automatically generated for you, pointing to the text "Hello, world!". When you want to use this text somewhere, you can use the I18N API.

local I18N = ELONA.require("core.I18N")
local my_string = I18N.get("my_mod.my_string")

-- Assuming English is the current language:
assert(my_string == "Hello, world!")

Now all a translator has to do in order to add language support is to add a new subfolder in their native language in the locale directory, like this:

ELONA.i18n:add {
   my_string = "こんにちは世界!"
}

If this file is named my_mod/locale/ja/my_strings.lua, then switching the language to Japanese will have this effect:

 local I18N = ELONA.require("core.I18N")
 local my_string = I18N.get("my_mod.my_string")
 assert(my_string == "こんにちは世界!")

Notice that we didn't have to change any code, yet got a different result based on the language. This system will allow for easily allowing people fluent in any language to play the game and understand additional content added by modders.

Note that if a given translation string doesn't exist, a warning will be returned instead, like "<Unknown ID: ...>". Make sure that all the translations you want to define have been placed in locale, or things will break. (You can also make optional translations for specific circumstances. See below for details.)

Organization

When creating new translation strings, it helps to organize sets of related localizations by grouping them together. Lua allows for this by the usage of tables:

ELONA.i18n:add {
  some_section = {
    greeting = "Hello, world!"
  },
  other_section = {
    greeting = "Welcome, traveler!"
  }
}

Once again, assume this file is part of a mod named my_mod. When this file is loaded, both my_mod.some_section.greeting and my_mod.other_section.greeting will be available for use.

It also helps to name translation files logically. For example, all the translations relating to characters being added could go in chara.lua, and translations for items in item.lua.

A handy feature of Lua is the ability to add comments. Use them for adding more detail.

ELONA.i18n:add {
  -- You can write comments like this.
  --[[
  -- Or this.
  --]]
  my_string = "Hello, world!"
}

Interpolation

This is nice and all, but you may be wondering: what happens if I want to insert text somewhere inside the localized text? For example, a common case is adding the name of a character or place in a string. Don't worry, it's easier than it seems:

ELONA.i18n:add {
  you_see = "You see {$1}."
}

You can tell that this string uses interpolation syntax by the braces ({$1}). The thing inside the braces indicates what gets placed there. In this case, the $1 stands for the first argument passed to the localization formatter. You can pass arguments to translations like this:

local my_string = I18N.get("my_mod.you_see", "Vernis")
assert(my_string == "You see Vernis.")

Note that you can pass more arguments to the formatter without any difference.

local my_string = I18N.get("my_mod.you_see", "Vernis", "and some putits")
assert(my_string == "You see Vernis.")

However, passing less arguments than the locale string uses will result in formatting issues. These won't cause errors, but unsightly <missing argument #N> text will show up in the formatted string.

local my_string = I18N.get("my_mod.you_see")
assert(my_string == "You see <missing argument #1>.")

Functions in interpolations

Say you want to insert the name of a character in a localized string. You attempt to do something like this:

ELONA.i18n:add {
  greeting = "Hello, {$1}!"
}

Then you try passing a character in:

local player = Chara.player()
local my_string = I18N.get("my_mod.greeting", player)

But this gives you the text LuaCharacter(0). What's going on? The issue is you passed in a character object instead of the character's name. This could be remedied like so:

local my_string = I18N.get("my_mod.greeting", player.name)

However, there's a better way to do this, which makes the intent of the localization clearer. You can pass the character object to the function as before, but change the locale file to this:

ELONA.i18n:add {
  greeting = "Hello, {name($1)}!"
}

This has the effect of placing the character's name in the string. Now it's clear that the thing being passed to the formatter is a character, since the name function is being called. There are various functions that may be called; see the I18N documentation for a complete list.

Enums

Some of the legacy localizations that are part of the base game have this format.

ELONA.i18n:add {
   fruit = {
      _0 = "apple",
      _1 = "banana",
      _2 = "pear",
   }
}

This indicates that the object fruit is an enum, or enumeration. These are used for translating strings tied to specific integer IDs that are used by the game. In these cases, it is important that each numbered item in the enum has the same format (strings or objects).

You could do something like this to retrieve an enum's property.

local fruit_id = 1
local fruit = I18N.get("core.fruit._" .. fruit_id)

However, it's cleaner to do this instead.

local fruit_id = 1
local fruit = I18N.get_enum("core.fruit", fruit_id)

I18N.get_enum is equivalent to the first version and will retrieve the given localized string for the enum. Of course, this depends on the localization group having the enum format.

Enums with properties

Sometimes enums will have multiple properties per ID, like this.

ELONA.i18n:add {
   fruit = {
      _0 = {
          name = "apple",
          color = "red",
      },
      _1 = {
          name = "banana",
          color = "yellow",
      },
      _2 = {
          name = "pear",
          color = "green",
      }
   }
}

In this case I18N.get_enum shouldn't be used, since it's for enums whose IDs point to single strings. Instead, use I18N.get_enum_property.

local fruit_id = 1
local fruit = I18N.get_enum_property("core.fruit", "name", fruit_id)

Optional enum translations

Sometimes, translations for a given enum item might not exist. For example, some map entrances lack descriptions:

ELONA.i18n:add {
   map = {
      _4 = {
           name = "North Tyris"
      },
      _5 = {
           name = "Vernis",
           desc = "You see Vernis. The mining town is full of liveliness.",
      },
      -- ...
   }
}

But the I18N.get_enum_property function will return a warning if a given translation string doesn't exist. The solution is to use I18N.get_enum_property_optional instead:

local town_id = 4
local town_name = I18N.get_enum_property("core.map", "name", town_id)
local town_desc = I18N.get_enum_property_optional("core.map", "desc", town_id)
if town_desc ~= nil then
   GUI.txt(town_desc)
else
   GUI.txt(town_name)
end

In summary, use I18N.get_enum_property for properties that must exist, and I18N.get_enum_property_optional for ones that might not.