How to create a Turn-Based RPG in Phaser 3 – Part 3

In Part One of this tutorial we created the WorldScene, and in Part Two we made the BattleScene. If you haven’t read them I strongly recommend for you to do so before continuing. Now, in Part Three, we will combine both scenes into a working game.

Source code

You can download the files for this tutorial here.

Did you come across any errors in this tutorial? Please let us know by completing this form and we’ll look into it!

FREE COURSES
Python Blog Image

FINAL DAYS: Unlock coding courses in Unity, Godot, Unreal, Python and more.

Assets

All assets used in this tutorial are CC0 licensed. The tiles are created by Kenney Vleugels and you can download them at www.kenney.nl
Player characters – https://opengameart.org/content/rpg-character-sprites
Enemies – https://opengameart.org/content/dragon-1

Scene Switching

At the start, I will try to simplify and explain the scene switching logic. We will use the final version of the WorldScene from the first part of the tutorial and we will create very basic BattleScene and UIScene.

Get your WorldScene working and then edit the config object to add BattleScene and UIScene. Edit it to look like this:

var config = {
    type: <a class="wpil_keyword_link" href="https://gamedevacademy.org/what-is-phaser/" target="_blank" rel="noopener" title="Phaser" data-wpil-keyword-link="linked">Phaser</a>.AUTO,
    parent: 'content',
    width: 320,
    height: 240,
    zoom: 2,
    pixelArt: true,
    physics: {
        default: 'arcade',
        arcade: {
            gravity: { y: 0 },
            debug: false // set to true to view zones
        }
    },
    scene: [
        BootScene,
        WorldScene,
        BattleScene,
        UIScene
    ]
};
var game = new Phaser.Game(config);

And here are our simplified BattleScene and UIScenes. We will use them to show how the scene switching will work:

var BattleScene = new Phaser.Class({

    Extends: Phaser.Scene,

    initialize:

    function BattleScene ()
    {
        Phaser.Scene.call(this, { key: 'BattleScene' });
    },
    create: function ()
    {
        // set the background of the main scene green
        this.cameras.main.setBackgroundColor('rgba(0, 200, 0, 0.5)');
        // Run UI Scene at the same time
        this.scene.run('UIScene');
    }
});

var UIScene = new Phaser.Class({

    Extends: Phaser.Scene,

    initialize:

    function UIScene ()
    {
        Phaser.Scene.call(this, { key: 'UIScene' });
    },

    create: function ()
    {       
        this.graphics = this.add.graphics();
        this.graphics.lineStyle(1, 0xffffff);
        this.graphics.fillStyle(0x031f4c, 1);        
        this.graphics.strokeRect(2, 150, 90, 100);
        this.graphics.fillRect(2, 150, 90, 100);
        this.graphics.strokeRect(95, 150, 90, 100);
        this.graphics.fillRect(95, 150, 90, 100);
        this.graphics.strokeRect(188, 150, 130, 100);
        this.graphics.fillRect(188, 150, 130, 100);
    }
});

Now we need to change the WorldScene to start the BattleScene. Change WorldScene onMeetEnemy like this:

    onMeetEnemy: function(player, zone) {        
        // we move the zone to some other location
        zone.x = Phaser.Math.RND.between(0, this.physics.world.bounds.width);
        zone.y = Phaser.Math.RND.between(0, this.physics.world.bounds.height);
        
        // shake the world
        this.cameras.main.shake(300);
        
        // switch to BattleScene
        this.scene.switch('BattleScene');
    },

If you run the game now, you will see that, when the player hits an invisible enemy, it starts BattleScene. And BattleScene will control the UIScene. Lets see how to return back to the WorldScene. I will create a BattleScene method exitBattle:

    exitBattle: function() {
        this.scene.sleep('UIScene');
        this.scene.switch('WorldScene');
    },

This function will sleep the UIScene (make it not active and not visible) and will switch from BattleScene to WorldScene. For now I will add simple time event that will call this function after 2 seconds.
Add this to the end of BattleScene create method:

var timeEvent = this.time.addEvent({delay: 2000, callback: this.exitBattle, callbackScope: this});

Now we have a problem. Everything works only the first time we switch to BattleScene. The second time, its create function is not called so we don’t have UIScene visible and we don’t exit BattleScene after the given time.
To fix this we need to listen to Scene ‘wake’ event. Add this code at the end of BattleScene create function:

this.sys.events.on('wake', this.wake, this);

We need to add the wake function. It will run the UIScene and will add a timed event to exit the BattleScene:

    wake: function() {
        this.scene.run('UIScene');  
        this.time.addEvent({delay: 2000, callback: this.exitBattle, callbackScope: this});        
    },

Now our simple BattleScene should work just fine each time we enter a battle.

Make the game work

Its time to change the BattleScene code to the one from the second part of this tutorial. Remove only its BootScene as we don’t need two boot scenes to load our game. You may need to add the resources of the enemies to the current BootScene loader.

        // enemies
        this.load.image("dragonblue", "assets/dragonblue.png");
        this.load.image("dragonorrange", "assets/dragonorrange.png");

Its time to change the unit class like this:

var Unit = new Phaser.Class({
    Extends: Phaser.GameObjects.Sprite,

    initialize:

    function Unit(scene, x, y, texture, frame, type, hp, damage) {
        Phaser.GameObjects.Sprite.call(this, scene, x, y, texture, frame)
        this.type = type;
        this.maxHp = this.hp = hp;
        this.damage = damage; // default damage     
        this.living = true;         
        this.menuItem = null;
    },
    // we will use this to notify the menu item when the unit is dead
    setMenuItem: function(item) {
        this.menuItem = item;
    },
    // attack the target unit
    attack: function(target) {
        if(target.living) {
            target.takeDamage(this.damage);
            this.scene.events.emit("Message", this.type + " attacks " + target.type + " for " + this.damage + " damage");
        }
    },    
    takeDamage: function(damage) {
        this.hp -= damage;
        if(this.hp <= 0) {
            this.hp = 0;
            this.menuItem.unitKilled();
            this.living = false;
            this.visible = false;   
            this.menuItem = null;
        }
    }    
});

Here you will see the variable menuItem. We will link each unit to its menu item, and when the unit is dead, it will notify the menu item for this, so the player won’t be able to select a killed enemy.
Also we have new member variable – living. We will use it to check if the current unit is alive. Only living units will be able to participate in battle.

Now we need to edit the next unit attack circle to take in consideration this new property. Change BattleScene nextTurn method to this:

    nextTurn: function() {        
        do {
            this.index++;
            // if there are no more units, we start again from the first one
            if(this.index >= this.units.length) {
                this.index = 0;
            }
        } while(this.units[this.index].living);
        
        // if its player hero
        if(this.units[this.index] instanceof PlayerCharacter) {                
            this.events.emit("PlayerSelect", this.index);
        } else { // else if its enemy unit
            // pick random hero
            var r = Math.floor(Math.random() * this.heroes.length);
            // call the enemy"s attack function 
            this.units[this.index].attack(this.heroes[r]);  
            // add timer for the next turn, so will have smooth gameplay
            this.time.addEvent({ delay: 3000, callback: this.nextTurn, callbackScope: this });
        }
    },

Here we increment the index until we get a living unit. The units that are dead won’t be able to move any more. But now we have a problem. We will have endless cycle if there are no living units. To avoid this, we must check for game over or victory.

Add this code at the top of nextTurn:

        if(this.checkEndBattle()) {           
            this.endBattle();
            return;
        }

And here is how checkEndBattle should look:

    checkEndBattle: function() {        
        var victory = true;
        // if all enemies are dead we have victory
        for(var i = 0; i < this.enemies.length; i++) {
            if(this.enemies[i].living)
                victory = false;
        }
        var gameOver = true;
        // if all heroes are dead we have game over
        for(var i = 0; i < this.heroes.length; i++) {
            if(this.heroes[i].living)
                gameOver = false;
        }
        return victory || gameOver;
    },

And here is endBattle:

    endBattle: function() {       
        // clear state, remove sprites
        this.heroes.length = 0;
        this.enemies.length = 0;
        for(var i = 0; i < this.units.length; i++) {
            // link item
            this.units[i].destroy();            
        }
        this.units.length = 0;
        // sleep the UI
        this.scene.sleep('UIScene');
        // return to WorldScene and sleep current BattleScene
        this.scene.switch('WorldScene');
    },

The final version of nextTurn:

    nextTurn: function() {  
        // if we have victory or game over
        if(this.checkEndBattle()) {           
            this.endBattle();
            return;
        }
        do {
            // currently active unit
            this.index++;
            // if there are no more units, we start again from the first one
            if(this.index >= this.units.length) {
                this.index = 0;
            }            
        } while(!this.units[this.index].living);
        // if its player hero
        if(this.units[this.index] instanceof PlayerCharacter) {
            // we need the player to select action and then enemy
            this.events.emit("PlayerSelect", this.index);
        } else { // else if its enemy unit
            // pick random living hero to be attacked
            var r;
            do {
                r = Math.floor(Math.random() * this.heroes.length);
            } while(!this.heroes[r].living) 
            // call the enemy's attack function 
            this.units[this.index].attack(this.heroes[r]);  
            // add timer for the next turn, so will have smooth gameplay
            this.time.addEvent({ delay: 3000, callback: this.nextTurn, callbackScope: this });
        }
    },

As we have seen from the simple version of BattleScene we need to listen for the wake event. I’ve created new function startBattle and moved more of the starting logic there. Here is how my startBattle looks:

    startBattle: function() {
        // player character - warrior
        var warrior = new PlayerCharacter(this, 250, 50, "player", 1, "Warrior", 100, 20);        
        this.add.existing(warrior);
        
        // player character - mage
        var mage = new PlayerCharacter(this, 250, 100, "player", 4, "Mage", 80, 8);
        this.add.existing(mage);            
        
        var dragonblue = new Enemy(this, 50, 50, "dragonblue", null, "Dragon", 50, 3);
        this.add.existing(dragonblue);
        
        var dragonOrange = new Enemy(this, 50, 100, "dragonorrange", null,"Dragon2", 50, 3);
        this.add.existing(dragonOrange);
        
        // array with heroes
        this.heroes = [ warrior, mage ];
        // array with enemies
        this.enemies = [ dragonblue, dragonOrange ];
        // array with both parties, who will attack
        this.units = this.heroes.concat(this.enemies);
        
        this.index = -1; // currently active unit
        
        this.scene.run("UIScene");        
    },

And here is how the BattleScene create function changes:

    create: function () {    
        // change the background to green
        this.cameras.main.setBackgroundColor("rgba(0, 200, 0, 0.5)");
        this.startBattle();
        // on wake event we call startBattle too
        this.sys.events.on('wake', this.startBattle, this);             
    },

Now we will change the MenuItem class like this:

var MenuItem = new Phaser.Class({
    Extends: Phaser.GameObjects.Text,
    
    initialize:
            
    function MenuItem(x, y, text, scene) {
        Phaser.GameObjects.Text.call(this, scene, x, y, text, { color: "#ffffff", align: "left", fontSize: 15});
    },
    
    select: function() {
        this.setColor("#f8ff38");
    },
    
    deselect: function() {
        this.setColor("#ffffff");
    },
    // when the associated enemy or player unit is killed
    unitKilled: function() {
        this.active = false;
        this.visible = false;
    }
    
});

Here you can see the unitKilled method called by the units on dying. This will deactivate and hide the menu item. Now we must change the menu navigation, so when the selection moves up or down, it will skip the deactivated items.
Here is how to change Menu methods moveSelectionUp and moveSelectionDown:

    moveSelectionUp: function() {
        this.menuItems[this.menuItemIndex].deselect();
        do {
            this.menuItemIndex--;
            if(this.menuItemIndex < 0)
                this.menuItemIndex = this.menuItems.length - 1;
        } while(!this.menuItems[this.menuItemIndex].active);
        this.menuItems[this.menuItemIndex].select();
    },
    moveSelectionDown: function() {
        this.menuItems[this.menuItemIndex].deselect();
        do {
            this.menuItemIndex++;
            if(this.menuItemIndex >= this.menuItems.length)
                this.menuItemIndex = 0;
        } while(!this.menuItems[this.menuItemIndex].active);
        this.menuItems[this.menuItemIndex].select();
    },

We need to change the select method too, so when the menu is activated, it will select an active item:

    select: function(index) {
        if(!index)
            index = 0;       
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex = index;
        while(!this.menuItems[this.menuItemIndex].active) {
            this.menuItemIndex++;
            if(this.menuItemIndex >= this.menuItems.length)
                this.menuItemIndex = 0;
            if(this.menuItemIndex == index)
                return;
        }        
        this.menuItems[this.menuItemIndex].select();
        this.selected = true;
    },

This is the moment to mention, that I decided to change the name of the event SelectEnemies to SelectedAction. Also, I changed the listeners to mach the name. You can continue with the old name or use the new one. The best way is to just use replace all and your ide will do everything automatically. As the event is fired when the player have selected an action, I think the code now is a bit more self explanatory.

We need to change the Menu remap method too:

    remap: function(units) {
        this.clear();        
        for(var i = 0; i < units.length; i++) {
            var unit = units[i];
            unit.setMenuItem(this.addMenuItem(unit.type));            
        }
        this.menuItemIndex = 0;
    },

And change Menu addMenuItem to this:

    addMenuItem: function(unit) {
        var menuItem = new MenuItem(0, this.menuItems.length * 20, unit, this.scene);
        this.menuItems.push(menuItem);
        this.add(menuItem); 
        return menuItem;
    },

Now we are almost done with the changes. What is left, is to fix the UIScene and the WorldScene to listen for wake event. I will start with the UIScene. The code that is responsible for the global menu creation will stay at its create method, but the code responsible for the specific battle will go to new function. Change UIScene create method to this:

    create: function ()
    {    
        // draw some background for the menu
        this.graphics = this.add.graphics();
        this.graphics.lineStyle(1, 0xffffff);
        this.graphics.fillStyle(0x031f4c, 1);        
        this.graphics.strokeRect(2, 150, 90, 100);
        this.graphics.fillRect(2, 150, 90, 100);
        this.graphics.strokeRect(95, 150, 90, 100);
        this.graphics.fillRect(95, 150, 90, 100);
        this.graphics.strokeRect(188, 150, 130, 100);
        this.graphics.fillRect(188, 150, 130, 100);
        
        // basic container to hold all menus
        this.menus = this.add.container();
                
        this.heroesMenu = new HeroesMenu(195, 153, this);           
        this.actionsMenu = new ActionsMenu(100, 153, this);            
        this.enemiesMenu = new EnemiesMenu(8, 153, this);   
        
        // the currently selected menu 
        this.currentMenu = this.actionsMenu;
        
        // add menus to the container
        this.menus.add(this.heroesMenu);
        this.menus.add(this.actionsMenu);
        this.menus.add(this.enemiesMenu);
                
        this.battleScene = this.scene.get("BattleScene");                                
        
        // listen for keyboard events
        this.input.keyboard.on("keydown", this.onKeyInput, this);   
        
        // when its player cunit turn to move
        this.battleScene.events.on("PlayerSelect", this.onPlayerSelect, this);
        
        // when the action on the menu is selected
        // for now we have only one action so we dont send and action id
        this.events.on("SelectedAction", this.onSelectedAction, this);
        
        // an enemy is selected
        this.events.on("Enemy", this.onEnemy, this);
        
        // when the scene receives wake event
        this.sys.events.on('wake', this.createMenu, this);
        
        // the message describing the current action
        this.message = new Message(this, this.battleScene.events);
        this.add.existing(this.message);        
        
        this.createMenu();     
    },

You see now that we call createMenu both when we start the UIScene for the first time and when its started through wake event. Here is our createMenu:

    createMenu: function() {
        // map hero menu items to heroes
        this.remapHeroes();
        // map enemies menu items to enemies
        this.remapEnemies();
        // first move
        this.battleScene.nextTurn(); 
    },

After running the game now you may found a problem. If its player turn, and he press multiple times space when selecting an enemy, nextTurn is called multiple times and you can witness strange behavior. To fix this we must be sure that we send only one event per menu. To do so, I’ve decide to add property selected to the Menu class. When Menu gets focus, this property becomes true. When the player chooses an action, it becomes false and the Menu won’t get its action method called again.
Here is how the Menu class should look at the end:

var Menu = new Phaser.Class({
    Extends: Phaser.GameObjects.Container,
    
    initialize:
            
    function Menu(x, y, scene, heroes) {
        Phaser.GameObjects.Container.call(this, scene, x, y);
        this.menuItems = [];
        this.menuItemIndex = 0;
        this.x = x;
        this.y = y;        
        this.selected = false;
    },     
    addMenuItem: function(unit) {
        var menuItem = new MenuItem(0, this.menuItems.length * 20, unit, this.scene);
        this.menuItems.push(menuItem);
        this.add(menuItem); 
        return menuItem;
    },  
    // menu navigation 
    moveSelectionUp: function() {
        this.menuItems[this.menuItemIndex].deselect();
        do {
            this.menuItemIndex--;
            if(this.menuItemIndex < 0)
                this.menuItemIndex = this.menuItems.length - 1;
        } while(!this.menuItems[this.menuItemIndex].active);
        this.menuItems[this.menuItemIndex].select();
    },
    moveSelectionDown: function() {
        this.menuItems[this.menuItemIndex].deselect();
        do {
            this.menuItemIndex++;
            if(this.menuItemIndex >= this.menuItems.length)
                this.menuItemIndex = 0;
        } while(!this.menuItems[this.menuItemIndex].active);
        this.menuItems[this.menuItemIndex].select();
    },
    // select the menu as a whole and highlight the choosen element
    select: function(index) {
        if(!index)
            index = 0;       
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex = index;
        while(!this.menuItems[this.menuItemIndex].active) {
            this.menuItemIndex++;
            if(this.menuItemIndex >= this.menuItems.length)
                this.menuItemIndex = 0;
            if(this.menuItemIndex == index)
                return;
        }        
        this.menuItems[this.menuItemIndex].select();
        this.selected = true;
    },
    // deselect this menu
    deselect: function() {        
        this.menuItems[this.menuItemIndex].deselect();
        this.menuItemIndex = 0;
        this.selected = false;
    },
    confirm: function() {
        // when the player confirms his slection, do the action
    },
    // clear menu and remove all menu items
    clear: function() {
        for(var i = 0; i < this.menuItems.length; i++) {
            this.menuItems[i].destroy();
        }
        this.menuItems.length = 0;
        this.menuItemIndex = 0;
    },
    // recreate the menu items
    remap: function(units) {
        this.clear();        
        for(var i = 0; i < units.length; i++) {
            var unit = units[i];
            unit.setMenuItem(this.addMenuItem(unit.type));            
        }
        this.menuItemIndex = 0;
    }
});

And here how we will change the UIScene onKeyInput:

    onKeyInput: function(event) {
        if(this.currentMenu && this.currentMenu.selected) {
            if(event.code === "ArrowUp") {
                this.currentMenu.moveSelectionUp();
            } else if(event.code === "ArrowDown") {
                this.currentMenu.moveSelectionDown();
            } else if(event.code === "ArrowRight" || event.code === "Shift") {

            } else if(event.code === "Space" || event.code === "ArrowLeft") {
                this.currentMenu.confirm();
            } 
        }
    },

Now when you run the game almost everything should run fine. But sometimes when you go back from BattleScene to WorldScene, the character is not waiting patiently but moving in some direction and you need to press and release the key for this direction to stop it. Its small bug, but we have to fix it. We need to wait for WorldScene wake event and then reset the keys.

Add this row to the end of WorldScene create function:

this.sys.events.on('wake', this.wake, this);

And here is how the wake method should look:

    wake: function() {
        this.cursors.left.reset();
        this.cursors.right.reset();
        this.cursors.up.reset();
        this.cursors.down.reset();
    },

With this we are finished with the third part of the tutorial. Although we have done a lot of work, our game need much more work to be fully functional. Get it as an exercise to save the state of the player characters and to make them get experience and levels. Also you can add more actions to the Actions menu and pass them together with the events.