In this final part of the tutorial series, we'll build on the first tutorial and learn about implementing pickups, triggers, level swapping, path finding, path following, level scrolling, isometric height, and isometric projectiles.
1. Pickups
Pickups are items that can be collected within the level, normally by simply walking over them—for example, coins, gems, cash, ammo, etc.
Pickup data can be accommodated right into our level data as below:
[ [1,1,1,1,1,1], [1,0,0,0,0,1], [1,0,8,0,0,1], [1,0,0,8,0,1], [1,0,0,0,0,1], [1,1,1,1,1,1] ]
In this level data, we use 8
to denote a pickup on a grass tile (1
and 0
represent walls and walkable tiles respectively, as before). This could be a single tile image with a grass tile overlaid with the pickup image. Going by this logic, we will need two different tile states for every tile which has a pickup, i.e. one with pickup and one without to be shown after the pickup gets collected.
Typical isometric art will have multiple walkable tiles—suppose we have 30. The above approach means that if we have N pickups, we will need N x 30 tiles in addition to the 30 original tiles, as each tile will need to have one version with pickups and one without. This is not very efficient; instead, we should try to dynamically create these combinations.
To solve this, we could use the same method we used to place the hero in the first tutorial. Whenever we come across a pickup tile, we will place a grass tile first and then place the pickup on top of the grass tile. This way, we just need N pickup tiles in addition to 30 walkable tiles, but we would need number values to represent each combination in the level data. To solve the need for N x 30 representation values, we can keep a separate pickupArray
to exclusively store the pickup data apart from the levelData
. The completed level with the pickup is shown below:
For our example, I am keeping things simple and not using an additional array for pickups.
Picking Up Pickups
Detecting pickups is done in the same way as detecting collision tiles, but after moving the character.
if(onPickupTile()){ pickupItem(); } function onPickupTile(){//check if there is a pickup on hero tile return (levelData[heroMapTile.y][heroMapTile.x]==8); }
In the function onPickupTile()
, we check whether the levelData
array value at the heroMapTile
coordinate is a pickup tile or not. The number in the levelData
array at that tile coordinate denotes the type of pickup. We check for collisions before moving the character but need to check for pickups afterwards, because in the case of collisions the character should not occupy the spot if it is already occupied by the collision tile, but in case of pickups the character is free to move over it.
Another thing to note is that the collision data usually never changes, but the pickup data changes whenever we pick up an item. (This usually just involves changing the value in the levelData
array from, say, 8
to 0
.)
This leads to a problem: what happens when we need to restart the level, and thus reset all pickups back to their original positions? We do not have the information to do this, as the levelData
array has been changed as the player picked up items. The solution is to use a duplicate array for the level while in play and to keep the original levelData
array intact. For instance, we use levelData
and levelDataLive[]
, clone the latter from the former at the start of the level, and only change levelDataLive[]
during play.
For the example, I am spawning a random pickup on a vacant grass tile after each pickup and incrementing the pickupCount
. The pickupItem
function looks like this.
function pickupItem(){ pickupCount++; levelData[heroMapTile.y][heroMapTile.x]=0; //spawn next pickup spawnNewPickup(); }
You should notice that we check for pickups whenever the character is on that tile. This can happen multiple times within a second (we check only when the user moves, but we may go round and round within a tile), but the above logic won't fail; since we set the levelData
array data to 0
the first time we detect a pickup, all subsequent onPickupTile()
checks will return false
for that tile. Check out the interactive example below:
2. Trigger Tiles
As the name suggests, trigger tiles cause something to happen when the player steps on them or presses a key when on them. They might teleport the player to a different location, open a gate, or spawn an enemy, to give a few examples. In a sense, pickups are just a special form of trigger tiles: when the player steps on a tile containing a coin, the coin disappears and their coin counter increases.
Let's look at how we could implement a door that takes the player to a different level. The tile next to the door will be a trigger tile; when the player presses the x key, they'll proceed to the next level.
To change levels, all we need to do is swap the current levelData
array with that of the new level, and set the new heroMapTile
position and direction for the hero character. Suppose there are two levels with doors to allow passing between them. Since the ground tile next to the door will be the trigger tile in both levels, we can use this as the new position for the character when they appear in the level.
The implementation logic here is the same as for pickups, and again we use the levelData
array to store trigger values. For our example, 2
denotes a door tile, and the value beside it is the trigger. I have used 101
and 102
with the basic convention that any tile with a value greater than 100 is a trigger tile and the value minus 100 can be the level which it leads to:
var level1Data= [[1,1,1,1,1,1], [1,1,0,0,0,1], [1,0,0,0,0,1], [2,102,0,0,0,1], [1,0,0,0,1,1], [1,1,1,1,1,1]]; var level2Data= [[1,1,1,1,1,1], [1,0,0,0,0,1], [1,0,8,0,0,1], [1,0,0,0,101,2], [1,0,1,0,0,1], [1,1,1,1,1,1]];
The code for checking for a trigger event is shown below:
var xKey=game.input.keyboard.addKey(Phaser.Keyboard.X); xKey.onUp.add(triggerListener);// add a Signal listener for up event function triggerListener(){ var trigger=levelData[heroMapTile.y][heroMapTile.x]; if(trigger>100){//valid trigger tile trigger-=100; if(trigger==1){//switch to level 1 levelData=level1Data; }else {//switch to level 2 levelData=level2Data; } for (var i = 0; i < levelData.length; i++) { for (var j = 0; j < levelData[0].length; j++) { trigger=levelData[i][j]; if(trigger>100){//find the new trigger tile and place hero there heroMapTile.y=j; heroMapTile.x=i; heroMapPos=new Phaser.Point(heroMapTile.y * tileWidth, heroMapTile.x * tileWidth); heroMapPos.x+=(tileWidth/2); heroMapPos.y+=(tileWidth/2); } } } } }
The function triggerListener()
checks whether the trigger data array value at the given coordinate is greater than 100. If so, we find which level we need to switch to by subtracting 100 from the tile value. The function finds the trigger tile in the new levelData
, which will be the spawn position for our hero. I have made the trigger to be activated when x is released; if we just listen for the key being pressed then we end up in a loop where we swap between levels as long as the key is held down, since the character always spawns in the new level on top of a trigger tile.
Here is a working demo. Try picking up items by walking over them and swapping levels by standing next to doors and hitting x.
3. Projectiles
A projectile is something that moves in a particular direction with a particular speed, like a bullet, a magic spell, a ball, etc. Everything about the projectile is the same as the hero character, apart from the height: rather than rolling along the ground, projectiles often float above it at a certain height. A bullet will travel above the waist level of the character, and even a ball may need to bounce around.
One interesting thing to note is that isometric height is the same as height in a 2D side view, although smaller in value. There are no complicated conversions involved. If a ball is 10 pixels above the ground in Cartesian coordinates, it could be 10 or 6 pixels above the ground in isometric coordinates. (In our case, the relevant axis is the y-axis.)
Let's try to implement a ball bouncing in our walled grassland. As a touch of realism, we'll add a shadow for the ball. All we need to do is to add the bounce height value to the isometric Y value of our ball. The jump height value will change from frame to frame depending on the gravity, and once the ball hits the ground we'll flip the current velocity along the y-axis.
Before we tackle bouncing in an isometric system, we'll see how we can implement it in a 2D Cartesian system. Let's represent the jump power of the ball with a variable zValue
. Imagine that, to begin with, the ball has a jump power of 100, so zValue = 100
.
We'll use two more variables: incrementValue
, which starts at 0
, and gravity
, which has a value of -1
. Each frame, we subtract incrementValue
from zValue
, and subtract gravity
from incrementValue
in order to create a dampening effect. When zValue
reaches 0
, it means the ball has reached the ground; at this point, we flip the sign of incrementValue
by multiplying it by -1
, turning it into a positive number. This means that the ball will move upwards from the next frame, thus bouncing.
Here's how that looks in code:
if(game.input.keyboard.isDown(Phaser.Keyboard.X)){ zValue=100; } incrementValue-=gravity; zValue-=incrementValue; if(zValue<=0){ zValue=0; incrementValue*=-1; }
The code remains the same for the isometric view as well, with the slight difference that you can use a lower value for zValue
to start with. See below how the zValue
is added to the isometric y
value of the ball while rendering.
function drawBallIso(){ var isoPt= new Phaser.Point();//It is not advisable to create points in update loop var ballCornerPt=new Phaser.Point(ballMapPos.x-ball2DVolume.x/2,ballMapPos.y-ball2DVolume.y/2); isoPt=cartesianToIsometric(ballCornerPt);//find new isometric position for hero from 2D map position gameScene.renderXY(ballShadowSprite,isoPt.x+borderOffset.x+shadowOffset.x, isoPt.y+borderOffset.y+shadowOffset.y, false);//draw shadow to render texture gameScene.renderXY(ballSprite,isoPt.x+borderOffset.x+ballOffset.x, isoPt.y+borderOffset.y-ballOffset.y-zValue, false);//draw hero to render texture }
Check out the interactive example below:
Do understand that the role played by the shadow is a very important one which adds to the realism of this illusion. Also, note that we're now using the two screen coordinates (x and y) to represent three dimensions in isometric coordinates—the y-axis in screen coordinates is also the z-axis in isometric coordinates. This can be confusing!
4. Finding and Following a Path
Path finding and path following are fairly complicated processes. There are various approaches using different algorithms for finding the path between two points, but as our levelData
is a 2D array, things are easier than they might otherwise be. We have well-defined and unique nodes which the player can occupy, and we can easily check whether they are walkable.
Related Posts
- A* Pathfinding for Beginners
- Goal-Based Vector Field Pathfinding
- Speed Up A* Pathfinding With the Jump Point Search Algorithm
- The "Path Following" Steering Behavior
A detailed overview of pathfinding algorithms is outside of the scope of this article, but I will try to explain the most common way it works: the shortest path algorithm, of which A* and Dijkstra's algorithms are famous implementations.
We aim to find nodes connecting a starting node and an ending node. From the starting node, we visit all eight neighbouring nodes and mark them all as visited; this core process is repeated for each newly visited node, recursively.
Each thread tracks the nodes visited. When jumping to neighbouring nodes, nodes that have already been visited are skipped (the recursion stops); otherwise, the process continues until we reach the ending node, where the recursion ends and the full path followed is returned as a node array. Sometimes the end node is never reached, in which case the path finding fails. We usually end up finding multiple paths between the two nodes, in which case we take the one with the smallest number of nodes.
Path Finding
It is unwise to reinvent the wheel when it comes to well-defined algorithms, so we would use existing solutions for our path-finding purposes. To use Phaser, we need a JavaScript solution, and the one I have chosen is EasyStarJS. We initialise the path-finding engine as below.
easystar = new EasyStar.js(); easystar.setGrid(levelData); easystar.setAcceptableTiles([0]); easystar.enableDiagonals();// we want path to have diagonals easystar.disableCornerCutting();// no diagonal path when walking at wall corners
As our levelData
has only 0
and 1
, we can directly pass it in as the node array. We set the value of 0
as the walkable node. We enable diagonal walking capability but disable this when walking close to the corners of non-walkable tiles.
This is because, if enabled, the hero may cut into the non-walkable tile while doing a diagonal walk. In such a case, our collision detection will not allow the hero to pass through. Also, please be advised that in the example I have completely removed the collision detection as that is no longer necessary for an AI-based walk example.
We will detect the tap on any free tile inside the level and calculate the path using the findPath
function. The callback method plotAndMove
receives the node array of the resulting path. We mark the minimap
with the newly found path.
game.input.activePointer.leftButton.onUp.add(findPath) function findPath(){ if(isFindingPath || isWalking)return; var pos=game.input.activePointer.position; var isoPt= new Phaser.Point(pos.x-borderOffset.x,pos.y-borderOffset.y); tapPos=isometricToCartesian(isoPt); tapPos.x-=tileWidth/2;//adjustment to find the right tile for error due to rounding off tapPos.y+=tileWidth/2; tapPos=getTileCoordinates(tapPos,tileWidth); if(tapPos.x>-1&&tapPos.y>-1&&tapPos.x<7&&tapPos.y<7){//tapped within grid if(levelData[tapPos.y][tapPos.x]!=1){//not wall tile isFindingPath=true; //let the algorithm do the magic easystar.findPath(heroMapTile.x, heroMapTile.y, tapPos.x, tapPos.y, plotAndMove); easystar.calculate(); } } } function plotAndMove(newPath){ destination=heroMapTile; path=newPath; isFindingPath=false; repaintMinimap(); if (path === null) { console.log("No Path was found."); }else{ path.push(tapPos); path.reverse(); path.pop(); for (var i = 0; i < path.length; i++) { var tmpSpr=minimap.getByName("tile"+path[i].y+"_"+path[i].x); tmpSpr.tint=0x0000ff; //console.log("p "+path[i].x+":"+path[i].y); } } }
Path Following
Once we have the path as a node array, we need to make the character follow it.
Say we want to make the character walk to a tile that we click on. We first need to look for a path between the node that the character currently occupies and the node where we clicked. If a successful path is found, then we need to move the character to the first node in the node array by setting it as the destination. Once we get to the destination node, we check whether there are any more nodes in the node array and, if so, set the next node as the destination—and so on until we reach the final node.
We will also change the direction of the player based on the current node and the new destination node each time we reach a node. Between nodes, we just walk in the required direction until we reach the destination node. This is a very simple AI, and in the example this is done in the method aiWalk
shown partially below.
function aiWalk(){ if(path.length==0){//path has ended if(heroMapTile.x==destination.x&&heroMapTile.y==destination.y){ dX=0; dY=0; isWalking=false; return; } } isWalking=true; if(heroMapTile.x==destination.x&&heroMapTile.y==destination.y){//reached current destination, set new, change direction //wait till we are few steps into the tile before we turn stepsTaken++; if(stepsTaken<stepsTillTurn){ return; } console.log("at "+heroMapTile.x+" ; "+heroMapTile.y); //centralise the hero on the tile heroMapSprite.x=(heroMapTile.x * tileWidth)+(tileWidth/2)-(heroMapSprite.width/2); heroMapSprite.y=(heroMapTile.y * tileWidth)+(tileWidth/2)-(heroMapSprite.height/2); heroMapPos.x=heroMapSprite.x+heroMapSprite.width/2; heroMapPos.y=heroMapSprite.y+heroMapSprite.height/2; stepsTaken=0; destination=path.pop();//whats next tile in path if(heroMapTile.x<destination.x){ dX = 1; }else if(heroMapTile.x>destination.x){ dX = -1; }else { dX=0; } if(heroMapTile.y<destination.y){ dY = 1; }else if(heroMapTile.y>destination.y){ dY = -1; }else { dY=0; } if(heroMapTile.x==destination.x){ dX=0; }else if(heroMapTile.y==destination.y){ dY=0; } //...... } }
We do need to filter out valid click points by determining whether we've clicked within the walkable area, rather than a wall tile or other non-walkable tile.
Another interesting point for coding the AI: we do not want the character to turn to face the next tile in the node array as soon as he has arrived in the current one, as such an immediate turn results in our character walking on the borders of tiles. Instead, we should wait until the character is a few steps inside the tile before we look for the next destination. It is also better to manually place the hero in the middle of the current tile just before we turn, to make it all feel perfect.
Check out the working demo below:
5. Isometric Scrolling
When the level area is much larger than the available screen area, we will need to make it scroll.
The visible screen area can be considered as a smaller rectangle within the larger rectangle of the complete level area. Scrolling is, essentially, just moving the inner rectangle inside the larger one. Usually, when such scrolling happens, the position of the hero remains the same with respect to the screen rectangle, commonly at the screen center. Interestingly, all we need to implement scrolling is to track the corner point of the inner rectangle.
This corner point, which we represent in Cartesian coordinates, will fall within a tile in the level data. For scrolling, we increment the x- and y-position of the corner point in Cartesian coordinates. Now we can convert this point to isometric coordinates and use it to draw the screen.
The newly converted values, in isometric space, need to be the corner of our screen too, which means they are the new (0, 0)
. So, while parsing and drawing the level data, we subtract this value from the isometric position of each tile, and can determine if the tile's new position falls within the screen.
Alternatively, we can decide we are going to draw only an X x Y isometric tile grid on screen to make the drawing loop efficient for larger levels.
We can express this in steps as so:
- Update Cartesian corner point's x- and y-coordinates.
- Convert this to isometric space.
- Subtract this value from the isometric draw position of each tile.
- Draw only a limited predefined number of tiles on screen starting from this new corner.
- Optional: Draw the tile only if the new isometric draw position falls within the screen.
var cornerMapPos=new Phaser.Point(0,0); var cornerMapTile=new Phaser.Point(0,0); var visibleTiles=new Phaser.Point(6,6); //... function update(){ //... if (isWalkable()) { heroMapPos.x += heroSpeed * dX; heroMapPos.y += heroSpeed * dY; //move the corner in opposite direction cornerMapPos.x -= heroSpeed * dX; cornerMapPos.y -= heroSpeed * dY; cornerMapTile=getTileCoordinates(cornerMapPos,tileWidth); //get the new hero map tile heroMapTile=getTileCoordinates(heroMapPos,tileWidth); //depthsort & draw new scene renderScene(); } } function renderScene(){ gameScene.clear();//clear the previous frame then draw again var tileType=0; //let us limit the loops within visible area var startTileX=Math.max(0,0-cornerMapTile.x); var startTileY=Math.max(0,0-cornerMapTile.y); var endTileX=Math.min(levelData[0].length,startTileX+visibleTiles.x); var endTileY=Math.min(levelData.length,startTileY+visibleTiles.y); startTileX=Math.max(0,endTileX-visibleTiles.x); startTileY=Math.max(0,endTileY-visibleTiles.y); //check for border condition for (var i = startTileY; i < endTileY; i++) { for (var j = startTileX; j < endTileX; j++) { tileType=levelData[i][j]; drawTileIso(tileType,i,j); if(i==heroMapTile.y&&j==heroMapTile.x){ drawHeroIso(); } } } } function drawHeroIso(){ var isoPt= new Phaser.Point();//It is not advisable to create points in update loop var heroCornerPt=new Phaser.Point(heroMapPos.x-hero2DVolume.x/2+cornerMapPos.x,heroMapPos.y-hero2DVolume.y/2+cornerMapPos.y); isoPt=cartesianToIsometric(heroCornerPt);//find new isometric position for hero from 2D map position gameScene.renderXY(sorcererShadow,isoPt.x+borderOffset.x+shadowOffset.x, isoPt.y+borderOffset.y+shadowOffset.y, false);//draw shadow to render texture gameScene.renderXY(sorcerer,isoPt.x+borderOffset.x+heroWidth, isoPt.y+borderOffset.y-heroHeight, false);//draw hero to render texture } function drawTileIso(tileType,i,j){//place isometric level tiles var isoPt= new Phaser.Point();//It is not advisable to create point in update loop var cartPt=new Phaser.Point();//This is here for better code readability. cartPt.x=j*tileWidth+cornerMapPos.x; cartPt.y=i*tileWidth+cornerMapPos.y; isoPt=cartesianToIsometric(cartPt); //we could further optimise by not drawing if tile is outside screen. if(tileType==1){ gameScene.renderXY(wallSprite, isoPt.x+borderOffset.x, isoPt.y+borderOffset.y-wallHeight, false); }else{ gameScene.renderXY(floorSprite, isoPt.x+borderOffset.x, isoPt.y+borderOffset.y, false); } }
Please note that the corner point is incremented in the opposite direction to the hero's position update as he moves. This makes sure that the hero stays where he is with respect to the screen. Check out this example (use arrows to scroll, tap to increase the visible grid).
A couple of notes:
- While scrolling, we may need to draw additional tiles at the screen borders, or else we may see tiles disappearing and appearing at the screen extremes.
- If you have tiles that take up more than one space, then you will need to draw more tiles at the borders. For example, if the largest tile in the whole set measures X by Y, then you will need to draw X more tiles to the left and right and Y more tiles to the top and bottom. This makes sure that the corners of the bigger tile will still be visible when scrolling in or out of the screen.
- We still need to make sure that we don't have blank areas on the screen while we are drawing near the borders of the level.
- The level should only scroll until the most extreme tile gets drawn at the corresponding screen extreme—after this, the character should continue moving in screen space without the level scrolling. For this, we will need to track all four corners of the inner screen rectangle, and throttle the scrolling and player movement logic accordingly. Are you up for the challenge to try implementing that for yourself?
Conclusion
This series is particularly aimed at beginners trying to explore isometric game worlds. Many of the concepts explained have alternate approaches which are a bit more complicated, and I have deliberately chosen the easiest ones.
They may not fulfil most of the scenarios you may encounter, but the knowledge gained can be used to build upon these concepts to create more complicated solutions. For example, the simple depth sorting implemented will break when we have multi-storied levels and platform tiles moving from one story to the other.
But that is a tutorial for another time.