HTML5 Game Localization with i18next library and ExcaliburJS - 10/11
This is an open-source project on game development with a focus on the HTML5 Excalibur game engine. Check out this feature implementation on this commit in our Github repository!
Here we are with our 10th tutorial about the ExcaliburJS HTML5 game engine! You can read here the list of features we are going to develop for our Excalibur game, such as level logic with XState library. If you're interested in game development, follow me on Twitter and Instagram to be caught up on my latest news!
Table of Contents
In this tutorial: internationalization
In this tutorial we add i18next to internationalize our game.
With this feature, the user will be able to choose between two languages: English or Italian. We'll persist the language settings as we did for the sound one. The language choice will affect the main game menu and the Twine interactive dialogue we've added in this tutorial.
What we suppose you already know
In this tutorial we do not assume anything in particular. We'll use i18next library in the simplest way.
What we are going to do
In this tutorial we'll start by adding the i18next library to our project. Then we'll add the files containing the translation texts for both English and Italian, and update the existing code to be translated. It's enough for us to set up as many translation files as the number of languages we want our game to be available in, and to add a menu button to change the current game language. We will save permanently the user choice and update each React component to be up-to-date with the latest chosen language. We'll also translate the Twine interactive dialogue we've added in the last tutorial so that it is displayed in the right language.
How does i18next library work?
This library reads the translation files in JSON format and returns the text for the chosen language, given a key.
Now we're ready to start developing. The first step will be to install all the required dependencies.
i18next installation
By following the i18next documentation we open the terminal and run:
npm install i18next --save
You should see a new dependency in the package.json
file.
Data set up
Before starting developing, we need to add a new key-value pair in our localStorage
, to set up the language at the first opening of the game. We do this in the init
method of the StoreService
class:
@Service()
export class StoreService {
...
public async init() {
this.asyncStoreService.init();
await this.isKey({ key: StoreConstants.inizializedKey }).then(async (result) => {
if (!result) {
...
await this.setItem({ key: StoreConstants.settings.language, value: 'en' });
}
});
}
...
}
We're adding a new key to the localStorage
. In our code's logic, the key is added only if there is no already existing table (check out the init
method of the StoreService
). Remember to delete the existing database from the developer tools, otherwise, nothing is going to work!
Create a LanguageService
As we did for many other features, we create now a service whose only responsibility is to manage the game language. Create under the src/services
folder a LanguageService
class. This service will:
- Initialize the i18next library as required
- Read the last saved language from the database
- Give the translated text
- Persist the last language set by the user from the main menu
Let's start from the service initialization. When initializing, the service must initialize the i18next library and read the last saved language value from the persisted data:
...
import en from '../assets/i18n/en.json';
import it from '../assets/i18n/it.json';
...
@Service()
export default class LanguageService {
private currentLanguage: AvailableLanguages;
private tFunction: TFunction;
private storeService = Container.get(StoreService);
public async init() {
this.currentLanguage = await this.storeService.getItem({key: StoreConstants.settings.language}) as AvailableLanguages;
this.tFunction = await i18next.init({
lng: this.currentLanguage, // if you're using a language detector, do not define the lng option
debug: false,
resources: {
en: {
translation: {
...en
}
},
it: {
translation: {
...it
}
}
}
});
}
};
export type AvailableLanguages = 'it' | 'en';
The en
and it
files are two JSON files containing the translated texts. They have the same keys but different values. The en.json
file is done as
{
"playKey": "Play",
"scoreKey": "Score",
"soundKey": "Sound",
"languageKey": "Language",
"yourScoreKey": "Your score is",
"goToMainMenuKey": "Go to main menu",
"noTranslationKey": "Missing translation",
"backKey": "Back",
"playSwordAdventureKey": "Play Sword Adventure!",
"italianKey": "Italian",
"englishKey": "English"
}
While the it.json
file is:
{
"playKey": "Gioca",
"scoreKey": "Punteggio",
"soundKey": "Suono",
"languageKey": "Lingua",
"yourScoreKey": "Il tuo punteggio è",
"goToMainMenuKey": "Vai al menù",
"noTranslationKey": "Traduzione mancante",
"backKey": "Indietro",
"playSwordAdventureKey": "Gioca a Sword Adventure!",
"italianKey": "Italiano",
"englishKey": "Inglese"
}
The LanguageService
will look into these JSON data for the requested translated texts. The init
method of the service also initializes the i18next tFunction
as explained in their documentation.
We'll add another couple of methods to the LanguageService
: we already know that we'll need to know the current language to display the right Twine dialogue, and that we need to update the UI components texts according to the last chosen language. We add then a method to read the language value (getCurrentLanguage
), and another one to subscribe to its updates (onLanguageChange
). And of course, we need a method to translate texts (translate
) and another to change the current language (changeLanguage
):
@Service()
export default class LanguageService {
private currentLanguage: AvailableLanguages;
private currentLanguageSub: BehaviorSubject<AvailableLanguages> = new BehaviorSubject(undefined);
...
private storeService = Container.get(StoreService);
public getCurrentLanguage(): AvailableLanguages {
return this.currentLanguage;
}
public async init() {
...
}
public translate(key: string): string {
const noTranslation: string = i18next.t(Translation.keys.noTranslation);
return this.tFunction(key, noTranslation);
}
public async changeLanguage(languageKey: AvailableLanguages) {
this.tFunction = await i18next.changeLanguage(languageKey);
this.currentLanguage = languageKey;
this.currentLanguageSub.next(this.currentLanguage);
this.storeService.setItem({key: StoreConstants.settings.language, value: languageKey});
}
public onLanguageChange() {
return this.currentLanguageSub.pipe(filter(value => Util.isDefined(value)));
}
};
The LanguageService
is now set.
A new menu entry for language setting
We create now a simple React component to change the current game language. Let's call it LanguageSettings.tsx
and place it under the src/ui
folder:
export default function LanguageSettings(): JSX.Element {
const languageService: LanguageService = Container.get(LanguageService);
const [showModal, setShowModal] = useState(false);
const [selected, setSelected] = useState<string>(languageService.getCurrentLanguage());
const [languageText, setLanguageText] = useState(languageService.translate(Translation.keys.language));
const backText = languageService.translate(Translation.keys.backButton);
return (
<div>
<IonButton onClick={() => {
setShowModal(true)
}
} expand='block' shape="round" size="small" fill='solid' color="dark">
<IonIcon slot="icon-only" icon={language}/>
</IonButton>
<IonModal isOpen={showModal}>
<div className='gradient-background flex--vertical flex--justify-center flex-align-items--center'>
<div>
<IonText>{languageText}</IonText>
</div>
<div>
<IonCard>
<IonCardContent>
<div>
<IonRadioGroup value={selected} onIonChange={e => {
setSelected(e.detail.value);
languageService.changeLanguage(e.detail.value as AvailableLanguages).then(() => {
setLanguageText(languageService.translate('languageKey'));
});
}}>
<IonItem lines='none'>
<IonLabel>{languageService.translate('italianKey')}</IonLabel>
<IonRadio slot="start" value="it"/>
</IonItem>
<IonItem lines='none'>
<IonLabel>{languageService.translate('englishKey')}</IonLabel>
<IonRadio slot="start" value="en"/>
</IonItem>
</IonRadioGroup>
</div>
</IonCardContent>
</IonCard>
</div>
<div>
<IonButton onClick={() => {
setShowModal(false)
}}>
{backText}
<IonIcon slot="end" icon={arrowUndoCircleOutline} />
</IonButton>
</div>
</div>
</IonModal>
</div>
);
};
As you can see from the following image
the language menu is a component containing a radio-group
with the available languages (English and Italian) as possible choices. When a language is chosen, the LanguageService
is called to update the current language. In the useEffect
React hook, the component listen for language changes: every time the language changes, it has to update its texts!
In my day job, I am an Angular developer. This is the way I've found to update React components when chosen language changes. If there's a better way to update their texts, just let me know on Twitter or Instagram!
As you can see, we've also created a Traslation
module which contains the same keys which are used in the en.json
and it.json
files. In this way, we avoid spreading them all over the different files, so that there are fewer modifications to be done if it happens to change the translation keys.
We now add the LanguageSettings.tsx
to the MainMenu.tsx
component:
export default function MainMenu() {
...
<div style={{flexGrow:1}} id={id}>
<div className="flex--vertical flex--space-between flex-align-items--center">
<div>
<IonText>Sword Adventure</IonText>
</div>
<div>
<IonButton
onClick={() => {
const game = Game.getInstance();
game.goTo('playLevel');
}}
color="primary"
size='large'
>
{playText}
</IonButton>
</div>
<SoundSettings></SoundSettings>
<LanguageSettings></LanguageSettings>
</div>
</div>
...
}
Before being completed with the LanguageService
, we need to replace the hard-coded texts we've put in the different React components (SoundSettings.tsx
, Score.tsx
and EndOfLevelModal.tsx
) and in the Game.ts
class, where there's the text of the starting button. as you can see from the public repository commit.
Last but not least, we need to change the Twine interactive dialogue with the mentor, otherwise, it will be also displayed in the English version. The easiest solution is to create a Twine dialogue for each of the Twine files that are there. We then rename the mission.html
file to mission_en.html
, and create a mission_it.html
file which contains the same dialogue in the Italian version. The last thing is to make the IFrameService
load the right file, accordingly to the current game language. We can do it in its toggleIFrame
method, computing a filePath
variable which depends upon the LanguageService.getCurrentLanguage()
response:
public toggleIFrame(show: boolean):void {
...
if (show) {
const currentLanguage = this.languageService.getCurrentLanguage();
const filePath = `twinery-dialog/mission_${currentLanguage}.html`;
const config: HTMLElementConfig = {
id: 'iframe',
tagName: 'iframe',
classes: ['position-absolute', 'position-top', 'full-size'],
parentContainer: this.iFrameContainerElement,
attributes: [{ name: 'src', value: filePath }],
};
...
}
...
}
We are all set! Our game is now fully internationalized. If you want to add more languages or need to translate other texts, you just need to add as many JSON translation files as you need and remember to have the same keys in all of them. Here's our final result:
Conclusions
In this tutorial, we've added the i18next library to internationalize our game. The game is now available in two languages, English and Italian. We've also updated the existing code, both on the React components side, and on the Twine interactive dialogue one.
In the next tutorial we'll add the last feature which is platform-independent: scene transitions. Read about them here! After the scene transition implementation, we'll start diving into customized features for the Android platform, such as touch gestures motion controllers, and AdMob monetization. Stay tuned!
Did you like this article and wish there were more? Donate on Paypal so that I can write more!