At the beginning of this tutorial series, we started building our Phaser 3 MMORPG. In Part 1, we did the following:
- We set up the basic project and installed the required dependencies.
- We added SocketIO to our project and added the server side logic for when a player connects and disconnects to our game.
- We wrapped up by adding the SocketIO library to the client side code.
In Part 2 of this tutorial series, we will continue adding the web socket logic to client-side code, update the logic for adding players to our game and start adding the logic for allowing the players to attack.
If you didn’t complete Part 1 and would like to continue from here, you can find the code for it here.
You can download all of the files associated with the source code for Part 2 here.
Let’s get started!
ACCESS NOW</a> </div>
</div></div>
</div></div>
</div></div>
</div></div></div>"}" data-sheets-userformat="{"2":2579,"3":{"1":0},"4":[null,2,16777215],"7":{"1":[{"1":2,"2":0,"5":[null,2,0]},{"1":0,"2":0,"3":3},{"1":1,"2":0,"4":1}]},"12":0,"14":{"1":2,"2":16777215}}"> BUILD GAMES FINAL DAYS: Unlock 250+ coding courses, guided learning paths, help from expert mentors, and more.
Refactoring Client Logic
Before we start working on the logic for creating our player when the currentPlayers
web socket message is received, we are going to refactor the logic in the methodcreate
of our Phaser game. Currently, our methodcreate
contains a lot of logic and to make our game more manageable,so we are going to split the logic out into a few different functions.
To do this, open public/assets/js/game.js
and replace all of the logic in the create
function with the following code:
create() { this.socket = io(); // create map this.createMap(); // create player animations this.createAnimations(); // create player this.createPlayer(); // update camera this.updateCamera(); // user input this.cursors = this.input.keyboard.createCursorKeys(); // create enemies this.createEnemies(); } createMap() { // create the map this.map = this.make.tilemap({ key: 'map' }); // first parameter is the name of the tilemap in tiled var tiles = this.map.addTilesetImage('spritesheet', 'tiles', 16, 16, 1, 2); // creating the layers this.map.createStaticLayer('Grass', tiles, 0, 0); this.map.createStaticLayer('Obstacles', tiles, 0, 0); // don't go out of the map this.physics.world.bounds.width = this.map.widthInPixels; this.physics.world.bounds.height = this.map.heightInPixels; } createAnimations() { // animation with key 'left', we don't need left and right as we will use one and flip the sprite this.anims.create({ key: 'left', frames: this.anims.generateFrameNumbers('player', { frames: [1, 7, 1, 13] }), frameRate: 10, repeat: -1 }); // animation with key 'right' this.anims.create({ key: 'right', frames: this.anims.generateFrameNumbers('player', { frames: [1, 7, 1, 13] }), frameRate: 10, repeat: -1 }); this.anims.create({ key: 'up', frames: this.anims.generateFrameNumbers('player', { frames: [2, 8, 2, 14] }), frameRate: 10, repeat: -1 }); this.anims.create({ key: 'down', frames: this.anims.generateFrameNumbers('player', { frames: [0, 6, 0, 12] }), frameRate: 10, repeat: -1 }); } createPlayer() { // our player sprite created through the phycis system this.player = this.physics.add.sprite(50, 100, 'player', 6); // don't go out of the map this.player.setCollideWorldBounds(true); } updateCamera() { // limit camera to map this.cameras.main.setBounds(0, 0, this.map.widthInPixels, this.map.heightInPixels); this.cameras.main.startFollow(this.player); this.cameras.main.roundPixels = true; // avoid tile bleed } createEnemies() { // where the enemies will be this.spawns = this.physics.add.group({ classType: Phaser.GameObjects.Zone }); for (var i = 0; i < 30; i++) { var x = Phaser.Math.RND.between(0, this.physics.world.bounds.width); var y = Phaser.Math.RND.between(0, this.physics.world.bounds.height); // parameters are x, y, width, height this.spawns.create(x, y, 20, 20); } // add collider this.physics.add.overlap(this.player, this.spawns, this.onMeetEnemy, false, this); }
In the code above, we moved all of the logic that was in the create
function into separate functions, and we removed the collision logic for the object layer in our game.
If you save your code changes, restart the server, and visit http://localhost:3000/game.html in your browser, you should see that the game still loads. If you move your character around the map, you should notice that the character can now walk through the trees.
Updating the Player Creation Logic
With the create
function logic refactored, we will now work on updating the player creation logic. To do this, replace the create
function in the WorldScene
class, in public/assets/js/game.js
with the following code:
create() { this.socket = io(); // create map this.createMap(); // create player animations this.createAnimations(); // user input this.cursors = this.input.keyboard.createCursorKeys(); // create enemies this.createEnemies(); // listen for web socket events this.socket.on('currentPlayers', function (players) { Object.keys(players).forEach(function (id) { if (players[id].playerId === this.socket.id) { this.createPlayer(players[id]); } else { this.addOtherPlayers(players[id]); } }.bind(this)); }.bind(this)); this.socket.on('newPlayer', function (playerInfo) { this.addOtherPlayers(playerInfo); }.bind(this)); }
In the code above, we did the following:
- First, we removed the `this.updateCamera();` line. We will be moving this to the
createPlayer
function. - We then added event listeners for the
currentPlayers
andnewPlayer
web socket messages. - In the function that is triggered when the
currentPlayers
event is received, we create an array of all the keys in theplayers
object and loop through them. We then check to see if that object’splayerId
matches the socket id of the currently connected player.- If the id matches, then we call the
createPlayer
function and pass that function the player object. - If the id does not match, then we call a new function called
addOtherPlayers
and pass that function the player object.
- If the id matches, then we call the
- In the function that is triggered when the
newPlayer
event is received, we call theaddOtherPlayers
function and pass that function theplayerInfo
object.
Next, replace the logic for the createPlayer
function inside the WorldScene
class with the following code:
createPlayer(playerInfo) { // our player sprite created through the physics system this.player = this.add.sprite(0, 0, 'player', 6); this.container = this.add.container(playerInfo.x, playerInfo.y); this.container.setSize(16, 16); this.physics.world.enable(this.container); this.container.add(this.player); // update camera this.updateCamera(); // don't go out of the map this.container.body.setCollideWorldBounds(true); }
In the code above, we did the following:
- First, we updated the
x
andy
position of the player game object to be 0. Since we will be placing the player game object inside a container, that object’s location will be relative to the container’s location. - Next, we created a new container and placed that container at the location of the
playerInfo
object that was created on the server. - Then, we enabled physics on the container and added the player game object to the container.
- Finally, we called the
updateCamera
function and updated thesetCollideWorldBound
logic to be tied to the new container instead of the player game object.
Now, we need to update a few places in our code where we are referencing the player game object and replace it with the new container object. First, in the updateCamera
function in the WorldScene
class, update the following line: `this.cameras.main.startFollow(this.player);` to be:
this.cameras.main.startFollow(this.container);
Next, we will need to replace the update
function logic in the WorldScene
class with the following code:
update() { if (this.container) { this.container.body.setVelocity(0); // Horizontal movement if (this.cursors.left.isDown) { this.container.body.setVelocityX(-80); } else if (this.cursors.right.isDown) { this.container.body.setVelocityX(80); } // Vertical movement if (this.cursors.up.isDown) { this.container.body.setVelocityY(-80); } else if (this.cursors.down.isDown) { this.container.body.setVelocityY(80); } // Update the animation last and give left/right animations precedence over up/down animations if (this.cursors.left.isDown) { this.player.anims.play('left', true); this.player.flipX = true; } else if (this.cursors.right.isDown) { this.player.anims.play('right', true); this.player.flipX = false; } else if (this.cursors.up.isDown) { this.player.anims.play('up', true); } else if (this.cursors.down.isDown) { this.player.anims.play('down', true); } else { this.player.anims.stop(); } } }
In the code above, we did the following:
- First, we added a check to see if the new container object existed. We did this to make sure we didn’t run any of our
update
logic until the container object has been created. - Next, we updated all of the
setVelocity
lines to be tied to the container game object instead of the player game object.
The last thing we need to do before we can test our code changes is to update the createEnemies
logic. In the createEnemies
function in the WorldScene
class, remove the following code:
this.physics.add.overlap(this.player, this.spawns, this.onMeetEnemy, false, this);
Now, if you save your code changes and refresh your browser you should see that the game still loads and that the player is now at a new location.
If you refresh your browser, you should see that the player gets created at a new location each time.
Creating other players
With the logic in place for creating the main player, we will now add the logic for the addOtherPlayers
function. To keep track of the other players in our game, we will first need to create a new Phaser Physics Group. To do this, add the following line at the top of the create
function inside the WorldScene
class, below the this.socket = io()
line:
this.otherPlayers = this.physics.add.group();
Then, add the following code below the createPlayer
function in the WorldScene
class:
addOtherPlayers(playerInfo) { const otherPlayer = this.add.sprite(playerInfo.x, playerInfo.y, 'player', 9); otherPlayer.setTint(Math.random() * 0xffffff); otherPlayer.playerId = playerInfo.playerId; this.otherPlayers.add(otherPlayer); }
In the code above, we did the following:
- First, we created a new game object using the information contained in the
playerInfo
object. - Then, we choose a random tint for the
otherPlayer
game object and stored that player’s id inside theotherPlayer
object so we can reference that value later. - Finally, we added the
otherPlayer
game object to theotherPlayers
group.
Now, if you save your code changes and refresh your browser, the game should load like before. However, if you open a new tab or window and navigate to the game page, you should see the other player in your game.
Note: you may need to move your player around the map to find the other player.
Handling Players disconnecting
Before we move on to creating our enemies, we have one issue we need to resolve with our player creation logic. Currently, if a player keeps refreshing their browser, or if they exit our game that player’s game object will stay in the other player’s game.
To fix this issue, we need to update our client-side logic to listen for the disconnect
event, and when this event is received, we can delete that player’s game object. To do this, add the following code to the bottom of the create
function:
this.socket.on('disconnect', function (playerId) { this.otherPlayers.getChildren().forEach(function (player) { if (playerId === player.playerId) { player.destroy(); } }.bind(this)); }.bind(this));
Let’s review the code we just added:
- First, we listened for the
disconnect
event and we defined a function that will be triggered when this event is triggered. - When this function is called, that function will receive the id of the player that disconnected. Inside this function, we create an array of all of the
otherPlayer
game objects by calling thegetChildren
method on the Phaser group. We then, use theforEach
method to loop through the array. - While looping through the array of game objects, we check to see if that game objects
playerId
matches the providedplayerId
, and if it does then we destroy that game object. Now, if you save your code changes, refresh your browser, and load the game in multiple tabs, you should see that the individual player game objects are removed when they leave the game.
Creating Enemies
With the player creation logic out of the way, we will now start working on creating the enemies in our game. For this tutorial series, we will be creating the enemies on the client side of our game. Normally, we would want to create these enemies on our server and broadcast their location to the client side, however, this is out of scope for this tutorial.
For the enemies, we will be replacing the zones that are randomly placed around the map with player game objects. To do this, the first thing we need to do is load in the images that we will be using for enemies in our game. In the BootScene
class, add the following code at the bottom of the preload
method:
this.load.image('golem', 'assets/images/coppergolem.png'); this.load.image('ent', 'assets/images/dark-ent.png'); this.load.image('demon', 'assets/images/demon.png'); this.load.image('worm', 'assets/images/giant-worm.png'); this.load.image('wolf', 'assets/images/wolf.png'); this.load.image('sword', 'assets/images/attack-icon.png');
Then, replace all of the code in the createEnemies
function in the WorldScene
class with the following code:
createEnemies() { // where the enemies will be this.spawns = this.physics.add.group({ classType: Phaser.GameObjects.Sprite }); for (var i = 0; i < 20; i++) { const location = this.getValidLocation(); // parameters are x, y, width, height var enemy = this.spawns.create(location.x, location.y, this.getEnemySprite()); enemy.body.setCollideWorldBounds(true); enemy.body.setImmovable(); } }
In the code above, we did the following:
- First, we changed the class type of the game object that was being created from Zone to Sprite.
- Next, we changed the number of enemies created from 30 to 20.
- Then, when we are creating the enemy game objects we call a new function called
getValidLocation
to get the location for where to place the enemy game object. Then, when we create the enemy game object we pass thelocation
we just created and we use a new function calledgetEnemySprite
to get the sprite for this enemy game object. - Finally, we call the
setCollideWorldBounds
andsetImmovable
methods on the enemy game object’s body.
Next, in the WorldScene
class add the following code below the createEnemies
function:
getEnemySprite() { var sprites = ['golem', 'ent', 'demon', 'worm', 'wolf']; return sprites[Math.floor(Math.random() * sprites.length)]; } getValidLocation() { var validLocation = false; var x, y; while (!validLocation) { x = Phaser.Math.RND.between(0, this.physics.world.bounds.width); y = Phaser.Math.RND.between(0, this.physics.world.bounds.height); var occupied = false; this.spawns.getChildren().forEach((child) => { if (child.getBounds().contains(x, y)) { occupied = true; } }); if (!occupied) validLocation = true; } return { x, y }; }
Let’s review the code we just added:
- First, in the
getEnemySprite
function we created an array of the enemy sprites we loaded earlier. Then, we usedMath.floor
andMath.random
to return a random sprite from that array. - Then, in the
getValidLocation
function we create a while loop and inside the while loop, we generate a random x and y position that is within the bounds of our game.- Once we do this, we loop through all of the enemy game objects.
- For each of the enemy game objects, we get the bounds of that game object and we check to see if the
x
andy
positions we created earlier are contained within those bounds. - If the
x
andy
positions are not occupied, then we exit out of the while loop and return those positions.
Finally, we need to add a collider between the player container and the enemies group. To do this, add the following line at the bottom of the createPlayer
function in the WorldScene
class:
this.physics.add.collider(this.container, this.spawns);
Now, if you save your code changes and refresh your browser, you should see the new enemy game objects.
Conclusion
With the new enemy game objects in place, that brings this part of the tutorial to an end. In Part 3, we will wrap up the tutorial by doing the following:
- Add logic for moving the enemy game objects.
- Add logic for attacking the enemy game objects.
- Add logic for allowing the players to chat.
I hope you enjoyed this and found it helpful! If you have any questions, or suggestions on what we should cover next, please let us know in the comments below.