- Each localization is represented by a "language" reference (cmn_loc_lang).
- Language contains named groups (e.g. main menu, pause menu).
- Groups contain key - string pairs.
- "Getter" functions accept a "default language" (e.g. English) string that will be used if the current language does not contain the requested one.
- The tool scans your project for uses of the functions and forms file(s) to be localized / uploaded to a localization service.
This is a cheat sheet for the cmnLoc extension by YellowAfterlife!
The extension can be acquired from itch.io.
Check out the blog post for a slightly more in-depth explanation of concepts and ideas.
Quick display controls: Categories · Sections · Everything ·
- Import the extension to your project.
- Change the strings that you want to be translatable to go through the localization functions instead.
- Generate the files to be translated
- Translate the files!
- Add the translated files to your Included Files and load them when appropriate.
For GameMaker: Studio, see additional notes.
You can use these to get the translated strings (or source code strings if there is no translation).
Some rules apply to using these in a way that will allow them to be picked up by the generator.
This function looks up a group in the current language by name.
If there is not a group, an empty one will be created, so the returned value is always a valid group.
This function looks up a string in a group of the current language.
If the string exists, it will replace the value
argument.
group
can be a group name (for one-off uses)
or a reference to a group (acquired from cmn_loc_get_group).
value
can be omitted if it's the same as key
:
label = cmn_loc_string("Menu", "Start"); // value is "Start" here
If one or more trailing arguments
are provided,
value
is assumed to contain placeholders ({0}
, {1}
, ...)
like the modern-day string
function has it:
var g = cmn_loc_get_group("Settings"); label = cmn_loc_string(g, "Volume", "Volume: {0}%", 65); // stores "Volume: 65%"
If you are using GameMaker Studio 2.3 or GM2022+, you can also call the get function from a group reference, like so:
var g = cmn_loc_get_group("Settings"); label = g.get("Volume", "Volume: {0}%", 65); // stores "Volume: 65%"
For further convenience, see cmn_loc_get_func.
This function works like cmn_loc_string, but accepts values to be inserted as an array instead.
count
indicates the number of items in arg_array
to use for replacements.
If omitted, the whole array will be used.
var vals; vals[0] = 5; vals[1] = 7; var g = cmn_loc_get_group("UI"); label = cmn_loc_string_ext(g, "HP", "HP: {0}/{1}", vals); // stores "HP: 5/7"
If you are using GameMaker Studio 2.3 or GM2022+, you can also call the get_ext function from a group reference, like so:
var vals = [5, 7]; var g = cmn_loc_get_group("UI"); label = g.get_ext("HP", "HP: {0}/{1}", vals); // stores "HP: 5/7"
If you are using GameMaker Studio 2.3 or GM2022+, this convenience function looks up a group and returns its bound get function, which further reduces the amount of added code per translatable string.
var f = cmn_loc_get_func("Settings"); label = f("Volume", "Volume: {0}%", 65); // stores "Volume: 65%" label_back = f("Back");
The generator can pick up groups and strings in your code if you follow a few rules.
Groups and functions should be stored in local variables on declaration.
var g = cmn_loc_get_group("Settings"); // yes global.g = cmn_loc_get_group("Settings"); // no var g; ...; g = cmn_loc_get_group("Settings"); // no
A group is tied to its language so it's a good idea to only retrieve groups when you need strings from them in general.
Group/function should be the first variable in the declaration.
var g = cmn_loc_get_group("Settings"); // yes var i = 1, g = cmn_loc_get_group("Settings"); // no
Fortunately,
var a, b
andvar a; var b
offer the same end result in GML.Macros are supported
#macro LF cmn_loc_get_func #macro LC_SETTINGS "Settings" var L = LF(LC_SETTINGS); label = L("Volume", "Volume: {0}%", 65);
Group names, keys, and values should be string literals or combinations of string literals (including macros).
var g = cmn_loc_get_group("Settings.Audio"); // yes var g = cmn_loc_get_group("Settings" + ".Audio"); // yes #macro LC_SETTINGS "Settings" var g = cmn_loc_get_group(LC_SETTINGS + ".Audio"); // yes var g = cmn_loc_get_group(global.settings_name); // no var g = cmn_loc_get_group("Settings" + string(2)); // no var s = "Settings"; var g = cmn_loc_get_group(s + ".Audio"); // no
You can still use the extension functions without respecting these rules - this only affects the strings that will be automatically put in the generated files.
Once you have added localization functions to your code, it is time to generate files to be translated!
You have two options here:
A web-based generator
OpencmnLocGen.html
in any semi-recent web browser.Accepts ZIP and YYZ archives with project files inside and outputs single files/ZIP archives with translatable files in chosen format.
Although it is a web page, it works without an internet connection and your files never leave your computer.
cmnLocGen.n
This is a command-line utility for advanced users.You'll want to install Neko VM and run it from Command Prompt/terminal as
neko cmnLocGen.n [arguments]
Running without arguments (or with
--help
) will display the supported arguments.
For purposes of comparing the formats, suppose we have a snippet of code like this:
var m = cmn_loc_get_group("Menu"); cmn_loc_string(m, "Start"); cmn_loc_string(m, "About", "A good\nvideogame"); /// @loc Shown in a corner cmn_loc_string(m, "Exit"); var o = cmn_loc_get_group("Settings"); cmn_loc_string(o, "Volume", "Volume: {0}%", 65); cmn_loc_string(o, "Back");
A standard Comma-Separated Values table.
CSV specification supports multi-line values, so the "table" structure is just about what you might expect:
Group | Key | Context | Text |
---|---|---|---|
Menu | Start | Start | |
Menu | About | Shown in a corner | A good videogame |
Menu | Exit | Exit | |
Settings | Volume | Volume: {0}% | |
Settings | Back | Back |
This is like the regular CSV, but instead of having a mostly-redundant Group column, groups are indicated by an empty Key cell with group name placed in Context column.
Key | Context | Text |
---|---|---|
Menu | ||
Start | Start | |
About | Shown in a corner | A good videogame |
Exit | Exit | |
Settings | ||
Volume | Volume: {0}% | |
Back | Back |
Here's the deal: there is no standard for INI files and no agreed upon way of representing multi-line text in values.
Therefore I'm offering two separate options for generating INI files.
Multi-line values (if any) will become additional key-value pairs:
[Menu] Start=Start # Shown in a corner About=A good About=videogame Exit=Exit [Settings] Volume=Volume: {0}% Back=Back
Quotation marks will be inserted around the individual line-values
if they have leading/trailing whitespace,
start with a quotation mark,
or contain comment characters (;
, #
).
Otherwise the values are assumed to have no escape characters.
This is a different approach to the problem: we'll assume INI strings to support JSON-style escape characters, which might sound familiar.
[Menu] Start=Start # Shown in a corner About="A good\nvideogame" Exit=Exit [Settings] Volume=Volume: {0}% Back=Back
The output is about what you'd expect, group ➜ key ➜ value:
{ "Menu": { "Start": "Start", "About": "A good\nvideogame", "Exit": "Exit" }, "Settings": { "Volume": "Volume: {0}%", "Back": "Back" } }
There's a little caveat though: there's no context information because JSON standard does not include comments.
This is a subset of Chrome extension localization format, which is most notable for supporting multi-line descriptions.
Menu.json
:
{ "Start": { "message": "Start", "description": "" }, "About": { "message": "a good\nvideogame", "description": "Shown in a corner" }, "Exit": { "message": "Exit", "description": "" } }
Settings.json
:
{ "Volume": { "message": "Volume: {0}%", "description": "" }, "Back": { "message": "Back", "description": "" } }
Disclosure:
There's quite a number of localization platforms!
I'm using Crowdin as an example here as it has the following qualities:
- Supports groups
Localization looks less intimidating when it's not a single massive list of key-value pairs. - Supports "context"/comments, including multi-line ones
- Reasonable pricing for indie games?
(free if your localizations are public and there aren't too many words/languages)
There might be something even better that I haven't seen.
Anyway,
- Create a project as usual
- Generate files in "JSON (CRX)" format and add them to Crowdin.
(on Sources tab,https://crowdin.com/project/YourProject/sources/files
) - Add the
JSON String Exporter
integration to the project.
- Navigate to Translations (
https://crowdin.com/project/YourProject/translations
) Click Target File Bundles ➜ Add Bundle
- Set "Source files path" to
*.json
- Set "Target file" ➜ "Format" to "JSON String Exporter"
Click on the gear button next to "JSON String Exporter"
- Set "Configuration type" to "Object key-value"
Set "Export pattern" to
{ "%identifier%": "%translation%" }
Set "Resulting file pattern" to
%language%/%file_name%.json
- Set "Source files path" to
Once you've done some translations, you can download the bundle that you've set up earlier on the Translations page, extract the ZIP to your Included files, and load it using cmn_loc_lang_load_json_folder.
If you'd like to pack each language into a single file instead of a folder, I have added an option for that:
neko CmnLocGen.n --combine-crx crowdin-bundle.zip outputFolder
which will create a single JSON file per language in outputFolder.
If you choose an INI-based format for your files, you can translate them as usual and use version control software to help you update them (e.g. if you add/remove/change strings) later:
- Create a new empty local Git repository.
- Commit your original (pre-update) generated localization file into the main branch.
- Create a secondary branch.
- Commit the translated file into it, overwriting the original file.
Commit changes for the file now indicate the lines that have been localized. - Commit the new (post-update) generated localization file into the main branch.
- Merge the main branch into your secondary branch.
Line additions/removals will happen automatically while merge conflicts will indicate text that has changed since being translated.
The following functions enable you to load localizations from files and strings.
The usual approach is to load localizations on game start or when you first need them, but if you intend to unload/reload them later, you will need to clean up your language references using cmn_loc_lang_destroy.
This global variable holds the current language.
Changing languages is a matter of assigning a new language reference (cmn_loc_lang) into it, which will affect subsequent calls to localization functions.
Loads a language from a JSON file containing group ➜ key ➜ value associations.
That means an object of objects.
Returns a language reference (cmn_loc_lang).
If the file does not contain valid JSON, returns undefined
.
For example, if in your Included Files
you've had a file called Ukrainian.json
containing the following:
{ "Menu.Main": { "Multiplayer": "Мережева гра", "Singleplayer": "Одиночна гра", "Options": "Налаштування", "Exit game": "Вийти з гри" }, "Menu.Options": { "SoundVolume": "Гучність звуків: {0}", "MusicVolume": "Гучність музики: {0}", "Back": "Назад" } }
You could do
cmn_loc_current = cmn_loc_lang_load_json_file("Ukrainian.json");
to load it.
Loads a language from a JSON string, using the same format as cmn_loc_lang_load_json_file.
If the string does not contain valid JSON, returns undefined
.
This function loads a language from a ds_map
.
The map should have a group ➜ key ➜ value structure like shown in cmn_loc_lang_load_json_file.
If takeOwnership
is true
, the map will be associated with the language
and destroyed along with it.
Note: if you are creating the map yourself (rather than decoding it),
make sure to add sub-maps using ds_map_add_map
so that they are associated
with the parent map.
Loads a language from a folder of .json
files.
Files containing invalid JSON will be ignored.
The file name (without extension) will be used for the group name and the contents will be used for key-value pairs within.
For example, if you have a folder called Ukrainian
containing
Menu.Main.json
{ "Multiplayer": "Мережева гра", "Singleplayer": "Одиночна гра", "Options": "Налаштування", "Exit game": "Вийти з гри" }
Menu.Options.json
{ "SoundVolume": "Гучність звуків: {0}", "MusicVolume": "Гучність музики: {0}", "Back": "Назад" }
You can do
cmn_loc_lang_load_json_folder("Ukrainian");
To achieve the same result as with the cmn_loc_lang_load_json_file example above.
Loads a language from an INI file at path.
The content should be formatted roughly as per generated format.
If the first section of the file contains a comment reading cmnLoc:json
,
the file is presumed to use JSON strings with escape characters.
Like above, but loads from an INI file stored in a string instead.
Loads one or more translations from a ds_grid
.
Columns
(optional) is an array of column names (case-sensitive!)
or indexes (0-based, includes prefix columns like Group/Key/Context)
to load.
Missing names and out-of-bounds indexes are ignored.
If omitted, all available languages will be loaded.
Returns an array of language references.
If you have specified columns to load and none were found,
this can be an empty array.
Configuration:
Indicates whether your CSV table contains a "context/comment" column after the "Key" column.
false
:
Group | Key | Value | ... |
---|---|---|---|
Menu | Start | Start | |
Menu | About | A good videogame |
true
:
Group | Key | Context | Value | ... |
---|---|---|---|---|
Menu | Start | Start | ||
Menu | About | Shown in a corner | A good videogame |
Indicates whether the group is stored inside the "context/comment" column, as per CSV Alternative format.
false
:
Group | Key | Context | Text |
---|---|---|---|
Menu | Start | Start | |
Menu | About | Shown in a corner | A good videogame |
true
:
Key | Context | Text |
---|---|---|
Menu | ||
Start | Start | |
About | Shown in a corner | A good videogame |
If the group names end with this string, it will be trimmed off.
For example, when downloading "Preview translations" CSV from Crowdin,
group names include the source file extensions ("Menu.json"
),
so you would want to do
cmn_loc_csv_group_suffix = ".json";
Represents a language. You can keep multiple of these in the memory at the same time,
Flat name: cmn_loc_lang_create
Creates a new localization language.
Unless you're going to populate key-value pairs yourself, you should look into loading functions instead.
Flat name: cmn_loc_lang_destroy
Localizations are typically loaded on game start and stick around for the duration of the game, but if you do need to swap them out, call this function to free the data structures related to this language.
Flat name: cmn_loc_lang_get_group
Returns a group with the given name; creates and stores a new one if necessary.
This is similarly handy if you're populating the key-value pairs yourself.
Flat name: cmn_loc_lang_add_group
Adds an existing group to this language.
Returns whether successful. Fails if there's already a same-named group added.
Flat name: cmn_loc_lang_set_plural_rules
Sets/changes pluralization rules for the language.
This changes the pluralization rules that are used when a string is not present
in the current language and the fallback string
(the value
argument in localization functions) is used.
By default, these are set up for English, which is the equivalent of doing
cmn_loc_lang_set_default_plural_rules("one: # == 1");
Represents a string group within a language.
Flat name: cmn_loc_group_create
Creates a new group.
key_value_map
is a ds_map
containing key-value pairs for this group.
If provided, an existing one will be referenced.
Otherwise a new map is created (and destroyed along with the group later).
Flat name: cmn_loc_group_destroy
Destroys a previously created group along with its key-value map (if owned by it)
A language will destroy its groups when it is destroys, so you shouldn't usually have to call this yourself.
Flat name: cmn_loc_group_get
This is a group-scope equivalent of cmn_loc_string.
Flat name: cmn_loc_group_get_ext
This is a group-scope equivalent of cmn_loc_string_ext.
Flat name: cmn_loc_group_set
Stores/replaces a key-value pair in this group.
The extension supports a subset of International Components for Unicode message format.
To use these, replace a simple {0}
placeholder by {0, type, ...arguments}
.
The following are supported:
This chooses a plural form for the provided quantity.
The syntax is as following:
{index, plural, category {text} }
Placeholder value at index
should be a number;
The message can contain one or more categories and should generally contain
an other
category (which acts as a catch-all if nothing else matches).
For example, you might have the following
menu_skip(L("EntryCount", "{0} public {0, plural,\n" + "one {game}\n" + "other {games}\n" + "}:", n));
to have "0 games", "1 game", "2 games" in English, and a translation could use the categories that are available for the language, for example:
{0} онлайн {0, plural, one {гра} few {гри} other {ігор} }:
which would produce "1 гра", "2 гри", ..., "5 ігор" as the Ukrainian pluralization rules have it.
#
can be used inside the messages to insert the input quantity, like so:
menu_skip(L("EntryCount", "{0, plural,\n" + "one {# game}\n" + "other {# games}\n" + "}:", n));
If necessary, both the base language and translations can also use =number
for special cases, like so:
menu_skip(L("EntryCount", "{0, plural,\n" + "one {# game}\n" + "other {# games}\n" + "=0 {no games}\n" + "}:", n));
Category rules are set up by calling set_plural_rules and cmn_loc_lang_set_default_plural_rules.
Outputs one or other string based on what the argument currently is:
{index, select, value1 {text 1} value2 {text 2} other {catch-all} }
This is typically used to pick appropriate gender-based inflections for the language, but you can use it for whatever else that might need one-of-N options depending on the language.
Using set_plural_rules and cmn_loc_lang_set_default_plural_rules you can specify rules for pluralization categories that you will then be able to use in plural type messages.
Per-language rules can be looked up on the Unicode website.
This is the numeric value that you're matching.
For example, a rule checking that the value is 1 might look like
one: # == 1
Integer (4
, -4
) and floating-point (4.1
, -4.1
) numbers are supported.
You may enclose expressions in parentheses.
For example,
-
3 + 1 * 2
would be5
-
(3 + 1) * 2
would be8
-
-a
-
a + b
-
a - b
-
a * b
-
a / b
-
a div b
: integer division (14 div 5
is2
) -
a % b
: remainder of division, can be fractional (alt:a mod b
)
Comparisons:
-
a = b
: equals (alt:a == b
) -
a != b
: not equals (alt:a <> b
) -
a < b
-
a <= b
-
a > b
-
a >= b
The result of comparisons and boolean operations is 1
(true) or 0
(false).
Boolean operators:
-
a and b
: boolean AND (alt:a && b
) -
a or b
: boolean OR (alt:a || b
) -
not a
: boolean NOT (alt:!a
)
A value is considered "truthy" if it's larger than 0.5.
Range checks:
-
val in (min .. max)
-
val not in [min .. max]
(alt:val !in [min .. max]
)
Start/end of the range can be parentheses (exclusive) or square brackets (inclusive).
For example,
-
5 in [3, 7]
is1
(in range) -
1 in [3, 7]
is0
(out of range) -
7 in [3, 7]
is1
(edge of inclusive range) -
7 in [3, 7)
is0
(edge of exclusive range)
Set checks:
-
val in {a, b, c}
-
val not in {a, b, c}
Set items can be values or ranges:
-
# in {4, 5, 6}
returns1
if value is 4, 5, or 6 -
# in {4, [5, 6]}
returns1
if value is 4 or between 5 and 6 (incl.)
The following few are supported:
Returns whether a number is an integer (no fractional part)
-
is_int(4)
is1
-
is_int(4.1)
is0
Returns the absolute value of a number
-
abs(-3)
is3
-
abs(4)
is4
Returns the sign of a number
-
sign(4)
is1
-
sign(-4)
is-1
-
sign(0)
is0
Returns the smaller of two numbers
-
min(3, 4)
is3
-
min(5, 4)
is4
Returns the larger of two numbers
-
max(3, 4)
is4
-
max(5, 4)
is5
To read the formulas in the rule sheet, check out the variable names in the documentation.
According to the sheet, in English you say "1 day" and any other number is "N days". Rather convenient, isn't it
So that could be written as:
one: # == 1
Ukrainian (and some other languages from eastern-European group) doesn't have it so easy - the rules for the singular category are:
v = 0 andWhich translates to:
i % 10 = 1 and
i % 100 != 11
- The number is an integer
- Remainder of division of the number by 10 is 1
- Remainder of division of the number by 100 is not 11
That means that the rule applies to 1, 21, 31, or 101, but not 11 or 111.
This could be written as:
one: is_int(#) and # % 10 = 1 and # % 100 != 11
The cardinal rule for Polish is listed as following:
v = 0 andWhich translates to:
i % 10 = 2..4 and
i % 100 != 12..14
- The number is an integer
- Remainder of division of the number by 10 is 2..4 (incl.)
- Remainder of division of the number by 100 is not 12..14 (incl.)
This could be written as:
few: is_int(#) and # % 10 in [2 .. 4] and # % 10 not in [12 .. 14]
TLDR: it's like GML priorities, but with in
/ not in
sandwiched between comparisons and boolean AND/OR.
From highest to lowest:
- Unary operators (
not val
,-val
) -
*
,/
,%
,div
-
+
,-
-
==
,!=
,<
,<=
,>
,>=
-
in
,not in
-
and
-
or
There is now a GM:S version of the extension, which works generally the same, with a few remarks:
First, GM:S obviously doesn't have methods, so instead of
var lang = new cmn_loc_lang(); // ... var group = lang.get_group("Menu"); // ... lang.destroy();
you have
var lang = cmn_loc_lang_create(); // ... var group = cmn_loc_lang_get_group(lang, "Menu"); // ... cmn_loc_lang_destroy(lang);
(see "flat name" in function descriptions)
The other thing is that GM:S does not allow to use macros to create shorter/different names for functions.
As a workaround, cmnLoc's generator will look for a file
called cmnLoc-macros.gml
next to your project file,
which can contain #macro
definitions in GMS2 format.
You can use these to let cmnLoc know that your script is equivalent to a cmnLoc function.
For example, you could make a script like this:
/// loc(group, key, value, ...values) switch (argument_count) { case 2: return cmn_loc_string(argument[0], argument[1]); case 3: return cmn_loc_string(argument[0], argument[1], argument[2]); case 4: return cmn_loc_string(argument[0], argument[1], argument[2], argument[3]); case 5: return cmn_loc_string(argument[0], argument[1], argument[2], argument[3], argument[4]); } var n = argument_count - 3; var v = array_create(n); for (var i = 0; i < n; i++) v[i] = argument[3 + i]; return cmn_loc_string_ext(argument[0], argument[1], argument[2], v);
and add the following to cmnLoc-macros.gml
:
#macro loc cmn_loc_string
which would allow you to use loc("Menu", "Start")
instead of
cmn_loc_string("Menu", "Start")
.