Build maintainable games with Phaser 3 – 3: User settings


Third article in series on building games, that are easy to maintain. Saving and loading user settings.

last checked with Phaser 3.12.0 – beta 2

Goal

In this part of series, we will add code to save and load user settings. In browser you can use LocalStorage for this. But we will think ahead a little and prepare our saving/loading infrastructure for other types of storages. Some game sponsors have their own storage and we also want to work, for example, with Facebook Instant games API.

Loading test sprites

As first thing, we will put some test sprite assets into asset folder. There is small atlas with 2 buttons – sound and music. Each has active and inactive icon. You can use your images or grab mine from part 3 of tutorial at GitHub repository.
We will load this atlas in preload() method of Prelaoad state. Open Preload.ts and add these lines:

        // --------------------------------------------------------------------
        public preload(): void {

            console.log("Loading assets...");

            // load atlas with test sprites
            this.load.atlas("Sprites", "assets/sprites/Sprites.png", "assets/sprites/Sprites.json");
        }

Settings class

Next, create new file Settings.ts in source root folder and put these lines into it:

namespace App {

    export class Settings {

        // music and sound
        public musicOn: boolean = true;
        public soundOn: boolean = true;
    }
}

These are our base settings together with their default values. You can add another properties here, based on your game needs. Now we need to wire it into rest of the game. To do so, open App.ts and add line near to top:

/// <reference path ="Settings.ts" />

namespace App {

    // game
    export let game: Phaser.Game = null;

    // settings
    export let settings = new Settings();
}

If compiler complains, that “Class ‘Settings’ is used before its declaration”, then add reference to Settings.ts in the very top of file.
As you can see, we are putting new instance of Settings into settings variable. Thanks to this, our game will have always at least default values in case no settings are saved.

StorageUtils library class

StorageUtils will be our second library class shared between this and any future game. All changes and bug fixes can be done in one place. Create file StorageUtils.ts and put it into folder for libs you created in part 2. It should now be in the same folder as ObjectUtils.ts created in last part of tutorial. Listing for StoreageUtils.ts is pretty long, so I will put it here first and then describe it. In series we did not consider sponsor APIs yet, but as said in Goals, we will think a little ahead and prepare for it:

namespace Utils {

    export interface ISponsorStorage {
        /** 
         * sponsor specific save method - to some sponsor storage
         */
        save(key: string, data: any): Promise<void>;
        /** 
         * sponsor specific load method - to some sponsor storage
         */
        load(key: string): Promise<any>;
        /** 
         * if true, loading/saving is first attempted from sponsor specific methods and then to/from standar storage
         * if false, only specific methods are used
         */
        fallbackToStandardStorage(): boolean;
    }


    export class StorageUtils {

        private static _sponsorStorage: ISponsorStorage = null;

        private static _allowMultipleRequests: boolean = false;
        private static _requestsCounter: number = 0;

        // --------------------------------------------------------------------
        public static set sponsorStorage(sponsorStorage: ISponsorStorage) {
            StorageUtils._sponsorStorage = sponsorStorage;
        }

        // --------------------------------------------------------------------
        public static set allowMultipleRequests(allowMultipleRequests: boolean) {
            StorageUtils._allowMultipleRequests = allowMultipleRequests;
        }

        // --------------------------------------------------------------------
        public static async save(key: string, data: any): Promise<void> {

            // check if any load/save request is still running
            if (!StorageUtils._allowMultipleRequests && StorageUtils._requestsCounter > 0) {
                throw new Error("Previous load/save request was not finished yet");
            }
            ++StorageUtils._requestsCounter;


            // sponsor specific storage?
            let sponsorStorage = StorageUtils._sponsorStorage;
            if (sponsorStorage !== null) {
                // save
                await sponsorStorage.save(key, data);

                // fallback set to true? Use also standard local storage?
                if (!sponsorStorage.fallbackToStandardStorage()) {
                    --StorageUtils._requestsCounter;
                    return;
                }
            }


            // standard storage
            let storage = StorageUtils.getLocalStorage();

            if (storage !== null) {
                let dataString = JSON.stringify(data);

                console.log(`saving key ${key}: ${dataString}`);

                storage.setItem(key, dataString);

            } else {
                --StorageUtils._requestsCounter;
                throw new Error("Standard storage not available");
            }

            --StorageUtils._requestsCounter;
        }

        // --------------------------------------------------------------------
        public static async load(key: string): Promise<any> {

            // check if any load/save request is still running
            if (!StorageUtils._allowMultipleRequests && StorageUtils._requestsCounter > 0) {
                throw new Error("Previous load/save request was not finished yet");
            }
            ++StorageUtils._requestsCounter;


            let data = null;

            // sponsor specific storage?
            let sponsorStorage = StorageUtils._sponsorStorage;
            if (sponsorStorage !== null) {
                // save
                data = await sponsorStorage.load(key);

                // if got some data (not null or undefined) or fallback to standard storage not not allowed
                if (data != null || !sponsorStorage.fallbackToStandardStorage()) {
                    --StorageUtils._requestsCounter;
                    return data;
                }
            }


            // standard storage
            let storage = StorageUtils.getLocalStorage();

            if (storage !== null) {
                let dataString = storage.getItem(key);

                console.log(`loading key ${key}: ${dataString}`);

                data = JSON.parse(dataString);

            } else {
                --StorageUtils._requestsCounter;
                throw new Error("Standard storage not available");
            }

            --StorageUtils._requestsCounter;

            return data;
        }

        // --------------------------------------------------------------------
        private static getLocalStorage(): Storage {
            try {
                if ("localStorage" in window && window["localStorage"] != null) {
                    return localStorage;
                }
            } catch (e) {
                return null;
            }

            return null;
        }
    }
}

Simple things first. In very bottom, there is method getLocalStorage(). This method simply checks if local storage is available in browser and if yes, it returns it.
Now, back to top. There is defined interface ISponsorStorage with three methods: save(), load() and fallbackToStandardStorage(). If sponsor has some kind of own storage, you can implement this interface and then pass it to StorageUtils with sponsorStorage setter on line 28. If sponsorStorage is not null, it is then taken into account in default load() and save() methods – sponsor specific code is called from it. If implementation of fallbackToStandardStorage() returns true, then saving is done not only into sponsor’s storage, but also into local storage. When loading, sponsor’s storage is asked first for saved data and if null or undefined, method tries to load it from local storage.
In class you can also set if multiple ongoing save / load requests are possible. By default allowMultipleRequests is false and saving before previous save request was completed will throw error.
Notice, that save() and load() are asynchronous and return Promise. This is not needed for local storage, but we want to handle all possible storages in the same way and some cloud storages may take time to save / load data.
save() and load() methods are then very simple. It step by step goes through all that things we described. It checks if there is any ongoing save / load request. Then it checks if there is any special sponsor storage and finally it uses local storage.

Loading settings

Now, we are ready to use load() and save() method from within the game. First, let’s add new entry into Config.ts. We will define SAVE_KEY, so we are not hardcoding it in game:

namespace App {

    export class Config {

        // game dimensions
        public static readonly GAME_WIDTH = 800;
        public static readonly GAME_HEIGHT = 600;

        // saving
        public static readonly SAVE_KEY = "maintainable_game_save";
    }
}

We will load settings when creating game in Game.ts. Add following lines after super constructor call:

            // load user settings
            Utils.StorageUtils.load(App.Config.SAVE_KEY)
                .then(function (data: any) {
                    // if data is not null and not undefined
                    if (data != null) {
                        App.settings = data;
                        console.log("Settings loaded...");
                    } else {
                        console.log("No saved settings.");
                    }
                });

Here we finally call load() method of our StorageUtils class. As it is asynchronous method returning Promise, we wait for resolve. When resolved, passed data contains object with loaded data. One note here: do not use any methods in Settings class. Methods are not saved and when data are loaded back, you have object without methods. Calling any of them would fail as they became undefined. If you really needed some methods in Settings class, you would have to take loaded object and property by property copy it into existing settings instance. As we have no methods in Settings, we can take object with loaded data and simply replace App.settings with it.

Saving settings

For testing load / save functionality, we will replace red circle from previous parts with two icons – sound and music settings. Screen will look like this:

Our target is to save their state and when game is reloaded they are either enabled or disabled. To achieve this, we will modify Menu.ts class to look like this:

///<reference path = "SceneBase.ts" />

namespace MaintainableGame {

    export class Menu extends SceneBase {

        // --------------------------------------------------------------------
        public create(): void {
            console.log("Menu");

            // bacground color
            this.cameras.main.backgroundColor = Phaser.Display.Color.ValueToColor(0x808080);

            // focus on 0, 0
            this.setView();

            // sound and music icons
            this.addAudioControlls();
        }

        // --------------------------------------------------------------------
        private addAudioControlls(): void {
            let y = -this.gameHeight / 2 + 50;

            // sound
            let soundIconFrame = App.settings.soundOn ? "IconSoundOn" : "IconSoundOff";
            let sound = this.add.sprite(-40, y, "Sprites", soundIconFrame);
            sound.setInteractive();
            sound.on("pointerdown", function (this: Menu) {
                App.settings.soundOn = !App.settings.soundOn;
                sound.setFrame(App.settings.soundOn ? "IconSoundOn" : "IconSoundOff");
                this.saveSettings();
            }, this);

            // music
            let musicIconFrame = App.settings.musicOn ? "IconMusicOn" : "IconMusicOff";
            let music = this.add.sprite(40, y, "Sprites", musicIconFrame);
            music.setInteractive();
            music.on("pointerdown", function (this: Menu) {
                App.settings.musicOn = !App.settings.musicOn;
                music.setFrame(App.settings.musicOn ? "IconMusicOn" : "IconMusicOff");
                this.saveSettings();
            }, this);
        }

        // --------------------------------------------------------------------
        private saveSettings(): void {
            Utils.StorageUtils.save(App.Config.SAVE_KEY, App.settings)
                .then(function () {
                    console.log("Settings saved...");
                });
        }
    }
}

In addAudioControlls, we create two icons – one for sound and second for music. Initial state of icon is based on its setting, App.settings.soundOn for sounds and App.settings.musicOn for music. When any of icons is clicked, setting is negated and saved with call to saveSettings(). In saveSettings() we call save() method of SotrageUtils class.

Conclusion

We are finished. We have created solid infrastructure for saving to and loading from various storages (even some new ones that may come). Our StorageUtils class is in Libs folder, that we can share with other games, so all code updates and fixes are done at one place. As usually, code for this tutorial is on GitHub. In next part we will prepare our game for handling various sponsors.


Leave a Reply

Your email address will not be published. Required fields are marked *