Blog
Internationalisation
Assez tôt dans le développement du jeu, je me suis dit que ce serait cool de pouvoir le traduire, notamment en anglais, histoire de pouvoir élargir le public potentiel.
Je vais donc vous causer d'internationalisation, ou i18n en plus court. Le jeu n'ayant à ce jour aucune voix enregistrée, il s'agira avant tout de traduire les textes… et quelques images.
Le principe général
Je souhaite continuer à écrire mon jeu en français et à pouvoir
m'occuper de la traduction à part. On va donc déporter toute la
traduction du jeu dans un fichier séparé, locale.yaml
, qui va
ressembler à ça :
locales:
- { id: "fr_FR", description: "Français (France)" }
- { id: "en_US", description: "English (US)" }
lines:
- fr_FR: "Je ne sais pas trop par où commencer..."
en_US: "I'm not sure where to start ..."
- fr_FR: "Vous devriez peut-être examiner ce joli tableau."
en_US: "Maybe you should take a look at this lovely painting."
Au départ, je liste les langues disponibles : pour l'instant, je ne
supporte que le français et l'anglais, mais je me laisse la
possibilité d'ajouter d'autres langues par la suite. Notez que
j'utilise le code habituel langue_PAYS
, ce qui permet
théoriquement de distinguer plusieurs variantes d'une même langue
(français québecois, anglais britannique, etc.).
Ensuite, je liste chaque ligne de dialogue (ou d'interface) dans les
deux langues. Bien entendu, je ne m'amuse pas à faire ça à la main :
j'ai bricolé un script qui va récupérer, dans toutes les données du
jeu (descriptifs des niveaux et des objets), les lignes à traduire, et
qui ensuite ajoute au fichier locale.yaml
, pour chaque ligne :
- fr_FR: "Une ligne de dialogue trouvée."
en_US: TODO
Il ne me reste plus, ensuite, qu'à remplacer ces TODO. Le script vérifie également que toutes les lignes listées précédemment existent encore dans les données du jeu : si jamais j'ai corrigé une faute de français, par exemple, la ligne en question n'existe plus en tant que tel. Dans ce cas, la ligne est déplacée dans une section à part (ce qui me permet de ne pas la perdre : si j'avais corrigé une faute, je veux pouvoir retrouver la traduction que j'avais faite de la ligne corrigée).
Pour l'instant, je stocke l'intégralité des lignes dans un unique fichier, mais il est possible que je le segmente si jamais il devient trop gros au fur et à mesure que j'ajoute des niveaux au jeu.
Le cas des images
L'immense majorité des images ne va pas bouger d'une langue à l'autre. Pourtant, il va nécessairement exister des différences : ainsi, certaines énigmes requièrent l'observation de certains documents, et il est donc nécessaire que ces documents apparaissent dans la langue choisie.
Par exemple, cette fiche de sécu trouvée dans le premier niveau est traduite (même si ça n'est pas très crédible dans le monde réel, nous sommes bien d'accord) :
En pratique, dans les données du jeu, à l'image
images/windows/fiche_secu.png
va être ajoutée une image
images/windows/fiche_secu.en_US.png
. Au chargement, le moteur va donc
simplement chercher, pour chaque image, s'il existe une variante avec
le code du pays ajouté à la fin.
Côté code
Au niveau des lignes de dialogues, c'est très simple, je stocke, pour
chaque langue (différente de la langue de base, ici le français) un
objet de type std::unordered_map<std::string,
std::string>
:
il s'agit, en gros, d'une table de hashage qui permet d'associer une
chaîne de caractère à une autre.
Ainsi, quand le moteur va chercher une ligne de dialogue, il va soit chercher la ligne en français, soit chercher dans une de ces table la traduction dans la langue voulue. En termes de performances, la table de hashage devrait être suffisamment efficace pour accueillir des miliers de lignes de dialogue sans que le chargement d'une ligne ne prenne un temps notable.
Pour les images, c'est un peu le même principe : les images traduites ont une table de hashage qui associe à chaque langue un objet image différent. À l'affichage, le moteur va donc simplement regarder la langue sélectionnée avant de choisir telle ou telle image.
La seule chose à laquelle il faut prendre garde, c'est le moment où la langue est modifiée : les images et textes déjà affichés doivent être supprimés et rechargés dans la nouvelle langue pour ne pas conserver d'infos « obsolètes » (dans une autre langue) à l'écran.
Détection automatique de la langue
Si le moteur laisse évidemment l'option de changer de langue à tout moment, il semble aujourd'hui impensable qu'un jeu ne se lance pas dans la langue du système automatiquement. Voyons donc comment détecter cette langue lors du premier lancement du jeu.
En théorie, la bibliothèque standard C++ fournit une fonction pour
accéder à différentes locales définies,
std::locale. Pour
récuper le fameux code langue_PAYS
utilisé, il suffit donc tout
simplement de faire :
std::string locale = std::locale("").name();
Et bien entendu, le seul unique système sur lequel cette fonction marche correctement est… Gnunux.
J'en avais déjà causé brièvement dans mes articles sur les portages Android et Windows, alors je ne détaille pas la logique, mais en gros, on va se débrouiller pour reconstituer ce code avec les fonctionnalités spécifiques à chaque système, ce qui nous donne quelque chose comme ça :
#if defined(SOSAGE_ANDROID)
# include <SDL.h>
# include <jni.h>
#elif defined(SOSAGE_WINDOWS)
# include <wchar.h>
# include <winnls.h>
#elif defined(SOSAGE_MAC)
# include <CoreFoundation/CoreFoundation.h>
#else
# include <locale>
#endif
std::string get_locale()
{
#if defined(SOSAGE_ANDROID)
JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();
jclass jni_class(env->GetObjectClass(activity));
jmethodID method_id = env->GetStaticMethodID(jni_class, "getLocale", "()Ljava/lang/String;");
jstring jstr = (jstring) env->CallStaticObjectMethod(jni_class, method_id);
const char *str = env->GetStringUTFChars(jstr, 0);
std::string out (str);
env->ReleaseStringUTFChars(jstr, str);
env->DeleteLocalRef(jstr);
env->DeleteLocalRef(jni_class);
return out;
#elif defined(SOSAGE_WINDOWS)
wchar_t name[LOCALE_NAME_MAX_LENGTH];
int l = GetUserDefaultLocaleName(name, LOCALE_NAME_MAX_LENGTH);
if (l == 0)
return "";
std::wstring ws (name, name + l);
return std::string(ws.begin(), ws.end());
#elif defined(SOSAGE_MAC)
CFLocaleRef cflocale = CFLocaleCopyCurrent();
CFStringRef language = (CFStringRef)CFLocaleGetValue(cflocale, kCFLocaleLanguageCode);
CFStringRef country = (CFStringRef)CFLocaleGetValue(cflocale, kCFLocaleCountryCode);
char lstr[256], cstr[256];
CFStringGetCString(language, lstr, 256, kCFStringEncodingUTF8);
CFStringGetCString(country, cstr, 256, kCFStringEncodingUTF8);
CFRelease(cflocale);
return std::string(lstr) + "_" + std::string(cstr);
#else
try // std::locale might throw runtime error if no locale declared
{
return std::locale("").name();
}
catch (std::runtime_error&)
{
}
return "";
#endif
}
Notez que la SDL propose une fonction de ce genre, mais depuis une version plus récente que la version de SDL que je supporte. Si je mets à jour, je virerai probablement ma fonction perso pour ce que la SDL propose.
Notre fonction get_locale()
nous renvoie donc un code
langue_PAYS
ou une chaîne vide si la langue n'a pas été
trouvée. C'est discutable, mais voici comment on choisit ensuite la
langue :
- si le code
langue_PAYS
retourné est supporté par le jeu (ici, si c'estfr_FR
ouen_US
donc), on utilise cette langue ; - sinon, on oublie le pays et on regarde si une autre variante de la
langue est disponible (sur un ordinateur québecois, on détectera
fr_CA
qui n'est pas supporté, mais on utilisera finalementfr_FR
car le code langue est le même) ; - sinon, si la langue n'est pas dispo (ou si
get_locale()
renvoie une chaîne vide), on utilise l'anglais par défaut, ce qui me semblait préférable à utiliser le français.
Bien sûr, il reste toujours l'option de changer la langue dans le menu par la suite, mais ça reste beaucoup plus confortable si ça marche directement dans la bonne langue.
Conclusion
Avec ce petit mécanisme, j'arrive à gérer une langue additionnelle, l'anglais, sans perturber l'écriture du jeu qui reste en français. Rien ne s'oppose à ce que j'ajoute d'autres langues par la suite, que ce soit des variantes comme le français québecois ou des langues complètement différentes comme l'espagnol.
Au niveau des axes d'amélioration, je pourrais remplacer la table de hashage unique des texte par une mini-table pour chaque réplique : ça serait probablement plus efficace, mais comme je l'ai dit, pour l'instant il n'y a clairement aucun souci de performance, donc je garde mon mécanisme actuel.