Internationalisation | Blog | Superflu Riteurnz

Internationalisation | Blog | Superflu Riteurnz Internationalisation | Blog | Superflu Riteurnz
  • Steam
  • Nintendo Switch
  • Itch.io
  • Google Play
Superflu et son assistante Sophie

Blog

Internationalisation

2022-10-26

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) :

fiche_secu

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'est fr_FR ou en_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 finalement fr_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.

i