Game state persistence between different gaming sessions in ExcaliburJS - 9/11
![Game state persistence between different gaming sessions in ExcaliburJS - 9/11](http://nicastro.in/images/sword-adventure-tutorial/data-persistence.gif)
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!
Let's go on with the 9th tutorial of my ExcaliburJS serie! You can read here the list of features we are going to develop for our Excalibur game, such as Twine integration for characters interactive dialogs. 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: data persistence
In this tutorial we add capacitorJS and its SQLite community plugin to manage data persistence.
In the last tutorial we've enriched our game with a simple graphical interface for the main menu. From the menu it's possible to set the sound settings: the user can mute or unmute the background music. In this tutorial, we're going to persist the user choice so that the sound settings are kept between different game sessions!
What we suppose you already know
In this tutorial we assume you now how React useEffect
and useState
API works. We also assume you have a basic knowledge of rxjs
library.
What we are going to do
In this tutorial we'll start by adding CapacitorJS to our project. We're not going yet to add an Android or IOS platform to it, we'll do it in one of the following tutorials. After having installed CapacitorJS, we'll use one of its community plugins by jepiqueau to persist permanently some of our game data, i.e. the sound settings and the user total score, which is increased every time the user takes a coin.
How does the Capacitor Data Storage SQLite Plugin work?
As we've written in the beginning, we want to develop a cross-platform game, that is a game which can be released on many platforms. How is it possible? And how can this plugin work on every platform?
Let's take a step back and look at CapacitorJS. This Ionic tool is meant to build and distribute hybrid applications. Hybrid apps are web apps written in HTML, CSS and javascript which are wrapped in a platform-specific container. In the case of Android applications, the container is a Java class. But what's the bridge between the Java class and the web code we're writing?
For Android devices, the answer is WebView. This is a special view that displays web pages inside an application. The web pages do not have every single piece of layout we usually see in a web page - for instance, there are no search bar or navigation buttons. This makes it possible to write a web app that looks like native applications even if they are not. Tools such as CapacitorJS makes all of this possible. The advantage of developing and hybrid app instead of native applications are many and plenty of famous applications are hybrid, as you can read here.
Now that the underlying mechanism of the game that we are developing is clearer, it's much easier to understand how the Capacitor SQLite Plugin works: it relies on the localStorage
given by the Web APIs, as explained in the MDN docs.
The localStorage
is not a safe place where to save sensitive data! Use another CapacitorJS plugin if you need to save user passwords, login, or personal data.
Now we're ready to start developing. The first step will be to install all the required dependencies.
CapacitorJS installation
By following the CapacitorJS documentation we open the terminal and run:
npm i @capacitor/core
npm i -D @capacitor/cli
npx cap init
The last command initializes the capacitor.config.js
file, which specifies the package name it will be used for the automatic build of the Android application -which we'll implement in one of the future tutorials.
Remember to change the webDir
with the dist
directory, which is the folder where we set up webpack
to place the output code.
Capacitor SQLite plugin installation
According to its documentation,
Capacitor Data Storage SQLite Plugin is a custom Native Capacitor plugin providing a key-value permanent store for simple data of type string only to SQLite on IOS, Android, and Electron platforms and IndexDB for the Web platform.
Which is the kind of plugin we can take advantage of. Other community plugins are more comprehensive than this one, but they are less immediate to use since they require knowing at least the basics of SQL. For the kind of information we need to save, and considering that we do not need to encrypt any data, this plugin is the easiest solution. To install it, we follow its documentation. We open the terminal and run:
npm install --save capacitor-data-storage-sqlite
npm install --save localforage
In the public repository of this project, you can find two classes for data persistence under the src/db-plugin
folder. We've written them by adapting the example code we've found in the plugin documentation.
Initialize the data services
We are going to use the StoreService
under the src/db-plugin
folder to read and save from the localStorage
. The AsyncStoreService
is the original code that was given as an example in the plugin repository. It works with promises
, while the StoreService
takes advantage of the rxjs Observable
. We'll mainly use the StoreService
so that we do not need for every
promise
to complete. The only place where we want to wait for the promise
result is when we initialize the service, that is the init
method of the StoreService
class:
@Service()
export class StoreService {
private asyncStoreService: AsyncStoreService = Container.get(AsyncStoreService);
public async init() {
this.asyncStoreService.init();
await this.isKey({ key: StoreConstants.inizializedKey }).then(async (result) => {
if (!result) {
await this.setItem({ key: StoreConstants.inizializedKey, value: true });
await this.setItem({ key: StoreConstants.coinsCount, value: 0 });
await this.setItem({ key: StoreConstants.settings.sound, value: true });
}
});
}
...
}
In this method we check if a key exists. If the key doesn't exist, then it's the first time the game is played, and we need to set up all the keys we need in our localStorage
: there will be one for saving the coins count (a number), and another one to remember the sound setting (a boolean for on/off). We'll call this init
method in the index.ts
file, where we initialize already all the required services. We'll change the initialization sequence so that the StoreService
is the first one:
const storeService = Container.get(StoreService);
await storeService.init().then(async () => {
const game = Game.getInstance();
game.startCustomLoader().then(() => {
domService.removeElement('loader-container');
game.goTo('menuLevel');
});
});
After having initialized the data service, the game can start.
Persist the sound settings
Now that we have a service to persist data, we can use it to remember the user preferences about sound. We'll add a component to the MainMenu.tsx
React element, which is a toggle to turn off or on the sound.
The MainMenu.tsx
becomes
export default function MainMenu() {
const id = uiConfig.mainMenu;
return (
<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'
>
Play
</IonButton>
</div>
<SoundSettings></SoundSettings>
</div>
</div>
);
}
Where as you can see there's a SoundSetting
new component, which is done as:
export default function SoundSettings() {
const storeService = Container.get(StoreService);
const [soundChecked, setSoundChecked] = useState(undefined);
const soundText = 'Sound';
useEffect(() => {
storeService.getItem({key: StoreConstants.settings.sound}).then((value) => {
setSoundChecked(value === 'true');
});
});
return (
<div className='flex--horizontal-no-full flex--justify-center flex-align-items--center'>
<IonToggle checked={soundChecked} onClick={() => {
const newValue = !soundChecked;
setSoundChecked(newValue);
storeService.setItem({key: StoreConstants.settings.sound, value: newValue});
const audioManager = Container.get(AudioManager);
audioManager.setVolumeOn(newValue);
}}/>
<IonLabel className="font-large">{soundText} {soundChecked === true ? 'on' : 'off'}</IonLabel>
</div>
);
}
The component asks the StoreService
for the last saved sound setting value, then adjusts the toggle to reflect it. As you can see, when the toggle is actioned, there's a call to an AudioManager
. This is the service responsible for dealing with audio media. We've written it taking inspiration from the various examples that can be found in the Excalibur gallery. It looks as:
@Service()
export class AudioManager {
private storeService: StoreService = Container.get(StoreService);
private soundsOn: boolean;
private backgroundVolume: number = 0.5;
public init(): void {
this.storeService.getItemObs({key: StoreConstants.settings.sound})
.pipe(filter(Util.isDefined))
.subscribe(isActive => {
this.soundsOn = isActive === 'true';
AudioAssets.tracks.backgroundMusic.volume = this.soundsOn ? this.backgroundVolume : 0;
});
}
public startBackgroundMusic(): void {
AudioAssets.tracks.backgroundMusic.volume = 0;
AudioAssets.tracks.backgroundMusic.volume = this.soundsOn ? this.backgroundVolume : 0;
AudioAssets.tracks.backgroundMusic.loop = true;
if (!AudioAssets.tracks.backgroundMusic.isPlaying()) {
AudioAssets.tracks.backgroundMusic.play();
}
}
public setVolumeOn(on: boolean): void {
this.soundsOn = on;
AudioAssets.tracks.backgroundMusic.volume = this.soundsOn ? this.backgroundVolume : 0;
if (this.soundsOn) {
this.startBackgroundMusic();
}
}
public stop(): void {
AudioAssets.tracks.backgroundMusic.stop();
}
}
The AudioAssets
is a module similar to the ones we've already written to import the images for displaying the characters, as explained in this tutorial.
Saving the score
It's quite easy at this point to save the user a new score every time she takes a coin. We create a GameStateService
whose responsibility is to read and save the state of the game, that is at this point only the coin count (it could be the number of lives for example).
@Service()
export class GameStateService {
private storeService = Container.get(StoreService);
private coinsCount: BehaviorSubject = new BehaviorSubject(undefined);
public init(): void {
this.storeService.getItemObs({key: StoreConstants.coinsCount})
.pipe(first())
.subscribe(value => {
this.coinsCount.next(parseInt(value));
});
}
public onCoinsCountChange(){
return this.coinsCount.asObservable();
}
public getCoinCount(){
return this.coinsCount.value;
}
public setCoinCount(newCount:number){
this.coinsCount.next(newCount);
this.storeService.setItem({key: StoreConstants.coinsCount, value: newCount});
}
}
In the PlayLevelLogicService
it's enough to call the new service every time we update the state machine context.coins
, in the updateCoins
action:
...
actions: {
updateCoin: (context) => {
context.coins += 1;
this.gameStateService.setCoinCount(context.coins);
},
...
}
...
That's it! Now we are saving persistently both the sound settings and the game state! The last thing we can do is customize a little the graphical interface by importing a font from Google Fonts, and by changing the default colors of Ionic using their Color Generator. We can also add a simple component Score.tsx
on the main menu displaying the user total score.
Here's the result you should see at the end of this tutorial:
Conclusions
In this tutorial we've added CapacitorJS and its SQLite community plugin to manage data persistence between different openings of the game. The user can now set the background music on or off according to her tastes.
In the next tutorial, we'll enrich the main menu with another setting, that is the language setting. We're going in fact to internationalize our game by integrating the i18next library. Read how in the next tutorial!
In the next tutorial we'll integrate the i18next library to internationalize our game.
Did you like this article and wish there were more? Donate on Paypal so that I can write more!