Create Your Own Isometric Tile-Based Game: Part 2

tutorial_iso_part2_1200x630

In part 2, we’ll spend some time structuring our code, so it’s more representative of a full scale game platform. We’ll add interactivity and animation. We’ll write a depth sorting function that will ensure our rendering updates accurately. Lastly, we’ll explore some tricks for optimisation to keep our game running efficiently on all devices.

Note: This is part 2 of an ongoing series on isometric game dev. It’s a continuation of the previous chapter, part 1, which you can find here, please start with that if you are new to the series.

If you’ve completed part 1 already, you can continue from your own code, or if you’d rather, you can download the completed code from part 1 and use that instead:

Structuring

Often as you expand your games functionality, you’ll need to do some structuring, to add flexibility, organise your project and optimise your code, so that’s what we’re going to do now. You probably noticed at the end of our part 1, our Tile enum is getting a little inefficient e.g.

enum Tile: Int {

    case Ground
    case Wall_n
    case Wall_ne
    case Wall_e
    case Wall_se
    case Wall_s
    case Wall_sw
    case Wall_w
    case Wall_nw
    case Droid_n
    case Droid_ne
    case Droid_e
    case Droid_se
    case Droid_s
    case Droid_sw
    case Droid_w
    case Droid_nw

    var description:String {
        switch self {
        case Ground:
            return "Ground"
        case Wall_n:
            return "Wall North"
        case Wall_ne:
            return "Wall North East"
        case Wall_e:
            return "Wall East"
        case Wall_se:
            return "Wall South East"
        case Wall_s:
            return "Wall South"
        case Wall_sw:
            return "Wall South West"
        case Wall_w:
            return "Wall West"
        case Wall_nw:
            return "Wall North West"
        case Droid_n:
            return "Droid North"
        case Droid_ne:
            return "Droid North East"
        case Droid_e:
            return "Droid East"
        case Droid_se:
            return "Droid South East"
        case Droid_s:
            return "Droid South"
        case Droid_sw:
            return "Droid South West"
        case Droid_w:
            return "Droid West"
        case Droid_nw:
            return "Droid North West"
        }
    }

    var image:String {
        switch self {
        case Ground:
            return "ground"
        case Wall_n:
            return "wall_n"
        case Wall_ne:
            return "wall_ne"
        case Wall_e:
            return "wall_e"
        case Wall_se:
            return "wall_se"
        case Wall_s:
            return "wall_s"
        case Wall_sw:
            return "wall_sw"
        case Wall_w:
            return "wall_w"
        case Wall_nw:
            return "wall_nw"
        case Droid_n:
            return "droid_n"
        case Droid_ne:
            return "droid_ne"
        case Droid_e:
            return "droid_e"
        case Droid_se:
            return "droid_se"
        case Droid_s:
            return "droid_s"
        case Droid_sw:
            return "droid_sw"
        case Droid_w:
            return "droid_w"
        case Droid_nw:
            return "droid_nw"
        }
    } 
}

Essentially, all we have is 3 tiles (ground, wall, droid) but since we need 8 directional variations for each Wall and Droid tile, it’s creating quite a list and we’re losing independence between tile type and direction.

Delete the above mentioned Tile enum and in its place, add this code:

enum Direction: Int {

    case N,NE,E,SE,S,SW,W,NW

    var description:String {
        switch self {
        case N:return "North"
        case NE:return "North East"
        case E:return "East"
        case SE:return "South East"
        case S:return "South"
        case SW:return "South West"
        case W:return "West"
        case NW:return "North West"
        }
    }
}

enum Tile: Int {

    case Ground, Wall, Droid

    var description:String {
        switch self {
        case Ground:return "Ground"
        case Wall:return "Wall"
        case Droid:return "Droid"
        }
    }
}

enum Action: Int {
    case Idle, Move

    var description:String {
        switch self {
        case Idle:return "Idle"
        case Move:return "Move"
        }
    }
}

Great. Now we have separate enums for Tile and Direction. We’ve also added a third enum, Action that contains Idle and Move properties. We’ll now be able to use these enums in combination when assigning tile, direction and action data, which we’ll demonstrate right now.

You may notice, our image var has gone from our Tile enum. We’ll still need that data but we’re now going to implement it in a more organised way. Select File > New > File, then select iOS > Source > Swift File and click Next. Name the file Texture and click Create. You should now have Texture.swift added to your project:

Screen Shot 2015-02-24 at 3.39.13 pm
Replace the contents with this:

import UIKit
import SpriteKit

func textureImage(tile:Tile, direction:Direction, action:Action) -> String {

    switch tile {
    case .Droid:
        switch action {
        case .Idle:
            switch direction {
            case .N:return "droid_n"
            case .NE:return "droid_ne"
            case .E:return "droid_e"
            case .SE:return "droid_se"
            case .S:return "droid_s"
            case .SW:return "droid_sw"
            case .W:return "droid_w"
            case .NW:return "droid_nw"
            }
        case .Move:
            switch direction {
            case .N:return "droid_n"
            case .NE:return "droid_ne"
            case .E:return "droid_e"
            case .SE:return "droid_se"
            case .S:return "droid_s"
            case .SW:return "droid_sw"
            case .W:return "droid_w"
            case .NW:return "droid_nw"
            }
        }
    case .Ground:
        return "ground"
    case .Wall:
        switch direction {
        case .N:return "wall_n"
        case .NE:return "wall_ne"
        case .E:return "wall_e"
        case .SE:return "wall_se"
        case .S:return "wall_s"
        case .SW:return "wall_sw"
        case .W:return "wall_w"
        case .NW:return "wall_nw"
        }
    }

}

You can see this function accepts arguments for Tile, Direction and Action, it then considers those arguments and returns the appropriate file name that we will use for our texture image. Because we have not nested this function within a class, it’s globally accessible (can be called from any class/file).

We’re now going to add data to our tiles array, so we can take advantage of our new structuring. In GameScene.swift, at the top of your GameScene class, remove this code:

let tiles = [
        [8, 1, 1, 1, 1, 2],
        [7 ,0, 0, 0, 0, 3],
        [7 ,0, 11, 0, 0, 3],
        [7 ,0, 0, 0, 0, 3],
        [7 ,0, 0, 0, 0, 3],
        [6, 5, 5, 5, 5, 4]
    ]

and put this in its place:

var tiles:[[(Int, Int)]]

then just below, inside the init(...) method, just above the line:

view2D = SKSpriteNode()

add this code:

tiles =     [[(1,7), (1,0), (1,0), (1,0), (1,0), (1,1)]]
tiles.append([(1,6), (0,0), (0,0), (0,0), (0,0), (1,2)])
tiles.append([(1,6), (0,0), (2,2), (0,0), (0,0), (1,2)])
tiles.append([(1,6), (0,0), (0,0), (0,0), (0,0), (1,2)])
tiles.append([(1,6), (0,0), (0,0), (0,0), (0,0), (1,2)])
tiles.append([(1,5), (1,4), (1,4), (1,4), (1,4), (1,3)])

What we’ve done here is replace our previous basic values Int, with tuples (Int, Int). The first tuple value indicates the Tile enum, while the second value gives us the Direction enum. So for instance the value (1,7) translates to (Tile.Wall, Direction.NW) and our droid, facing east is now represented as (2,2) e.g. (Tile.Droid, Direction.E).

Note: It would have been better to keep the tiles array as a constant (let). Technically, we should have been able to define the array like this:

let tiles = [
        [(1,7), (1,0), (1,0), (1,0), (1,0), (1,1)],
        [(1,6), (0,0), (0,0), (0,0), (0,0), (1,2)],
        [(1,6), (0,0), (2,2), (0,0), (0,0), (1,2)],
        [(1,6), (0,0), (0,0), (0,0), (0,0), (1,2)],
        [(1,6), (0,0), (0,0), (0,0), (0,0), (1,2)],
        [(1,5), (1,4), (1,4), (1,4), (1,4), (1,3)]
]

but at the time I wrote this code, the full array definition would cause a reoccurring indexing bug that would freeze xCode, documented here. So I’ve had to create the array as a var, then use the append method to construct the array progressively, the resulting array is the same but doing it this way means xCode still works, which is kind of important :-/

Right! back to the code. Now that we’ve restructured our data, we need to update our code that utilises it. In GameScene.swift, update your place2DTile(...) method to look like this:

 func placeTile2D(tile:Tile, direction:Direction, position:CGPoint) {

    let tileSprite = SKSpriteNode(imageNamed: textureImage(tile, direction, Action.Idle))

    tileSprite.position = position

    tileSprite.anchorPoint = CGPoint(x:0, y:0)

    view2D.addChild(tileSprite)

}

Here, we’ve removed the image parameter and in its place, used our tile and direction parameters. Then in the body of the function, we’ve updated our tileSprite definition to use our new textureImage(...) function. We pass on the tile and direction arguments and set the action as Idle which will be the default for any new tile.

Update the placeAllTiles2D(...) method to this:

func placeAllTiles2D() {

        for i in 0..<tiles.count {

            let row = tiles[i];

            for j in 0..<row.count {

                let tile = Tile(rawValue: row[j].0)!
                let direction = Direction(rawValue: row[j].1)!

                var point = CGPoint(x: (j*tileSize.width), y: -(i*tileSize.height))

                if (tile == Tile.Droid) {
                    placeTile2D(Tile.Ground, direction:direction, position:point)
                }

                placeTile2D(tile, direction:direction, position:point)
            }

        }       
}

In the above code, we’re now getting the tile and direction values from our new tuple-based tiles array. Then we’ve updated the placeTile2D(...) calls, so they conform to the new parameters we added in the previous step.

Next, as you’d expect, we need to make the equivalent updates to the iso tile placement methods, so they now look like this:

func placeTileIso(tile:Tile, direction:Direction, position:CGPoint) {

        let tileSprite = SKSpriteNode(imageNamed: "iso_3d_"+textureImage(tile, direction, Action.Idle))

        tileSprite.position = position

        tileSprite.anchorPoint = CGPoint(x:0, y:0)

        viewIso.addChild(tileSprite)

} 
func placeAllTilesIso() {

        for i in 0..<tiles.count {

            let row = tiles[i];

            for j in 0..<row.count {

                let tile = Tile(rawValue: row[j].0)!
                let direction = Direction(rawValue: row[j].1)!

                var point = point2DToIso(CGPoint(x: (j*tileSize.width), y: -(i*tileSize.height)))

                if (tile == Tile.Droid) {
                    placeTileIso(Tile.Ground, direction:direction, position:point)
                }

                placeTileIso(tile, direction:direction, position:point)

            }
        }
}

Lastly, add these new CGPoint processing functions under your operator overloads near the top of your GameScene.swift file:

func distance(p1:CGPoint, p2:CGPoint) -> CGFloat {
    return CGFloat(hypotf(Float(p1.x) - Float(p2.x), Float(p1.y) - Float(p2.y)))
}

func round(point:CGPoint) -> CGPoint {
    return CGPoint(x: round(point.x), y: round(point.y))
}

func floor(point:CGPoint) -> CGPoint {
    return CGPoint(x: floor(point.x), y: floor(point.y))
}

func ceil(point:CGPoint) -> CGPoint {
    return CGPoint(x: ceil(point.x), y: ceil(point.y))
}

Run your app.

add_droid
What!? It looks the same! Well, that’s actually what we want at this stage. Everything we just did, could be considered under the hood upgrades. The changes add flexibility and logical structure which put us in a good position for the next stage of development. If you need to compare your code at this point, you can download the entire project at this stage of development, here:

Where’s a Hero When You Need One?

In any game, certain objects are going to be more complex than others. A simple wall object may be in the category of set it and forget it, where the hero character (e.g. the droid) will constantly be getting and setting data and we want to make these processes as convenient and efficient as possible. So let’s set up a Character class that can help us achieve this.

Select File > New > File, then select iOS > Source > Swift File and click Next. Name the file Character and click Create. In Character.swift, replace the contents with this code:

import UIKit
import SpriteKit

//1
protocol TileObject {
    var tile:Tile {get}
}

//2
class Character {

    var facing:Direction
    var action:Action

    var tileSprite2D:SKSpriteNode!
    var tileSpriteIso:SKSpriteNode!

    init() {
        facing = Direction.E
        action = Action.Idle
    }

}

//3
class Droid:Character, TileObject {

    let tile = Tile.Droid

}

In the above code:

  1. We setup a TileObject protocol that all complex tile objects will conform to, the protocol contains the single constant, tile, that once instantiated, will be used as a shortcut for identification e.g.
    if tile == hero.tile { //do something }
  2. Then we create a Character class that stores our characters current Direction and Action and their tileSprite… instances for convenience.
  3. We then define our Droid class, which subclasses the Character class and conforms to the TileObject protocol.

Note: By setting up a Character super class and a TileObject protocol, we can ensure any future characters will be consistent in their construction and therefor much more predictable and easily managed when we include them into our game.

OK, we have our Droid class ready, let’s get some use out of it. In GameScene.swift, at the top of your GameScene class, under the line:

let tileSize = (width:32, height:32)

add this line:

let hero = Droid()

In the placeTile2D(...) method, under the line:

let tileSprite = SKSpriteNode(imageNamed: textureImage(tile, direction, Action.Idle))

add this code:

if (tile == hero.tile) {
    hero.tileSprite2D = tileSprite
    hero.tileSprite2D.zPosition = 1
}

As mentioned earlier, we’ve used our tile var that we setup in out tileObject protocol to easily identify which tile is the hero tile, we then point our hero.tileSprite2D property to the tileSprite, so we can access it conveniently. In the very next line, we do just that. We access the hero.tileSprite var and set its zPosition to 1. This will ensure that the hero sprite is always drawn on top of the level tiles in our 2D view.

Similarly, in the placeTileIso(...) method, under the line:

let tileSprite = SKSpriteNode(imageNamed: "iso_3d_"+textureImage(tile, direction, Action.Idle))

add this code:

if (tile == hero.tile) {
    hero.tileSpriteIso = tileSprite
}

Same thing as before, but we won’t worry about the zPosition. Depth in the isometric view will ultimately be handled via a more complex depth sorting function that we’ll implement later in this tutorial.

Adding Interactivity and Animation

With our hero defined, we can now start to move him around. At the bottom of GameScene.swift but still within the GameScene class, add this code:

override func touchesEnded(touches: Set<NSObject>, withEvent event: UIEvent) {

        //1
        let touch = touches.first as! UITouch
        let touchLocation = touch.locationInNode(viewIso)
        //2
        var touchPos2D = pointIsoTo2D(touchLocation)
        //3
        touchPos2D = touchPos2D + CGPoint(x:tileSize.width/2, y:-tileSize.height/2)
        //4
        let heroPos2D = touchPos2D + CGPoint(x:-tileSize.width/2, y:-tileSize.height/2)
        //5
        hero.tileSprite2D.position = heroPos2D

}

In this code:

  1. We get the touch location from the isometric view.
  2. Convert the point from Isometric to 2D.
  3. Adjust for the isometric tile anchor point offset.
  4. Adjust for the 2D hero tile anchor point offset.
  5. Set the position of the 2D hero tile.

Now, directly below the touchesEnded(...) method we just added, add this code:

override func update(currentTime: CFTimeInterval) {

   hero.tileSpriteIso.position = point2DToIso(hero.tileSprite2D.position)

}

Notice how we only run calculations for the 2D sprite in touchesEnded(...). We then simply update the isometric view from the converted 2D data.

Run your app.

Screen Shot 2015-02-26 at 10.40.25 am

Your droid should now be positioning itself wherever you touch in the isometric view. You should also be seeing the 2D view update accordingly. You’ll likely notice a couple of things:

  1. The droid in the isometric view gets hidden behind the other tiles when it goes into the South/East area of the level. This is a depth sorting issue which we will remedy later in this tutorial. For now, let’s just focus on the 2D view.
  2. The droid is merely ‘jumping’ from point to point. We want our droid to animate smoothly to the destination. Let’s do that now.

In the touchesEnded(...) method, remove this line of code we just added in the previous step:

hero.tileSprite2D.position = heroPos2D

Add this code in its place:

let velocity = 100
let time = NSTimeInterval(distance(heroPos2D, hero.tileSprite2D.position)/CGFloat(velocity))
hero.tileSprite2D.removeAllActions()
hero.tileSprite2D.runAction(SKAction.moveTo(heroPos2D, duration: time))

Velocity is our desired movement speed. The value 100 is subject to your preference. We then calculate the time it will take to reach the destination, based on our velocity and distance. We then clear any actions that may be running on our hero.tileSprite and run our new moveTo action.

Run your app.

Facebook-meme

Alright, our droid is animating around our level, but it’s still not very convincing. It’s like there’s something over in the far East that he can’t take his eyes off. To be more plausible, we’ll need to face our hero in the direction he moves. If our game only needed to render in the top-down 2D view, we could simply rotate the sprite. However, since we’re working with a pseudo 3D view, we’ll need to use pre-rendered images for each of our 8 directions and sub them in and out as our hero moves in different directions.

First, we’ll set up our textures for convenient, efficient substitution during runtime. Select Texture.swift in your project navigator and add this code at the bottom of the file:

protocol TextureObject {
    static var sharedInstance: TextureDroid {get}
    var texturesIso:[[SKTexture]?] {get}
    var textures2D:[[SKTexture]?] {get}
}

private let textureDroid = TextureDroid()

class TextureDroid: TextureObject  {

    class var sharedInstance: TextureDroid {
        return textureDroid
    }

    var texturesIso:[[SKTexture]?]
    var textures2D:[[SKTexture]?]

    init() {

        texturesIso = [[SKTexture]?](count: 2, repeatedValue: nil)
        textures2D = [[SKTexture]?](count: 2, repeatedValue: nil)

        //Idle
        texturesIso[Action.Idle.rawValue] = [
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.N, Action.Idle)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.NE, Action.Idle)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.E, Action.Idle)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.SE, Action.Idle)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.S, Action.Idle)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.SW, Action.Idle)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.W, Action.Idle)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.NW, Action.Idle)),
        ]

        //Move
        texturesIso[Action.Move.rawValue] = [
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.N, Action.Move)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.NE, Action.Move)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.E, Action.Move)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.SE, Action.Move)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.S, Action.Move)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.SW, Action.Move)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.W, Action.Move)),
            SKTexture(imageNamed: "iso_3d_"+textureImage(Tile.Droid, Direction.NW, Action.Move)),
        ]

        //Idle
        textures2D[Action.Idle.rawValue] = [
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.N, Action.Idle)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.NE, Action.Idle)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.E, Action.Idle)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.SE, Action.Idle)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.S, Action.Idle)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.SW, Action.Idle)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.W, Action.Idle)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.NW, Action.Idle)),
        ]

        //Move
        textures2D[Action.Move.rawValue] = [
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.N, Action.Move)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.NE, Action.Move)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.E, Action.Move)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.SE, Action.Move)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.S, Action.Move)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.SW, Action.Move)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.W, Action.Move)),
            SKTexture(imageNamed: textureImage(Tile.Droid, Direction.NW, Action.Move)),
        ]

    }

}

Here, we’ve created a TextureObject protocol, we’ve then created a singleton class, TextureDroid, that conforms to the TextureObject protocol. The private constant we created is part of the singleton class setup. Singleton classes act as shared instances, they are only ever instantiated once during runtime. Which makes them perfect to use as our texture caching class. Why? because even if we had 30 droids in our game, they would all use this 1 texture class instance, so you won’t end up with 30 instances of the same information.

Now select the Character.swift file in the project navigator. In your Droid class, under the line:

let tile = Tile.Droid

add this code:

func update() {

        if (self.tileSpriteIso != nil) {

            self.tileSpriteIso.texture = TextureDroid.sharedInstance.texturesIso[self.action.rawValue]![self.facing.rawValue]

        }
        if (self.tileSprite2D != nil) {

            self.tileSprite2D.texture = TextureDroid.sharedInstance.textures2D[self.action.rawValue]![self.facing.rawValue]
        }
}

Here, we’ve setup a function that will update our droid textures for our 2D and isometric views. The textures are selected in respect to the droids current action and facing values. So ultimately, what we’re setting up here, is a few methods that will allow us to set the action and facing direction of our droid, then call update() and have the Droid and TextureDroid classes auto-manage the texture substitutions.

OK, so to complete this process, we’ll need to figure out the angle (in degrees), that the droid should be facing, based on its current position and its destination (we can do this with some basic trigonometry). Then we’ll need to find which of our 8 directions the angle falls into. e.g. in the following diagram, anything that falls within 333.75° to 22.5° would be considered North, 22.5° to 67.5° is North East and so on.

degreesToDirection

So let’s write a method that takes any degree value (0° to 360°) and returns the appropriate Direction enum.

In GameScene.swift, just above the touchesEnded(...) method, add this code:

func degreesToDirection(var degrees:CGFloat) -> Direction {

        if (degrees < 0) {
            degrees = degrees + 360
        }
        let directionRange = 45.0

        degrees = degrees + CGFloat(directionRange/2)

        var direction = Int(floor(Double(degrees)/directionRange))

        if (direction == 8) {
            direction = 0
        }

        return Direction(rawValue: direction)!
}

Notice that since we’ve setup our directional values as an enum, we can use a slick, mathematical approach that generates their raw values (0 to 7), as opposed to having to run a clunky block of conditions for each possibility.

Now we have all the methods ready, lets execute the code. In the touchesEnded(...) class, just above the line:

let velocity = 100

add this code:

//1
let deltaY = heroPos2D.y - hero.tileSprite2D.position.y
let deltaX = heroPos2D.x - hero.tileSprite2D.position.x
//2
let degrees = atan2(deltaX, deltaY) * (180.0 / CGFloat(M_PI))
//3
hero.facing = degreesToDirection(degrees)
//4
hero.update()

In the above code:

  1. We get the distance between the current position and the destination (deltaX, deltaY).
  2. We use some trigonometry to get the angle (degrees).
  3. We use our new degreesToDirection(…) method and set hero.facing.
  4. We call update() to substitute our texture based on our new facing direction.

Run your app.

Screen Shot 2015-02-26 at 1.55.25 pm

Excellent, our heroic droid has gained his sense of orientation. Next up…

Depth Sorting

Depth sorting ensures that tiles that are closer to the player are drawn on top of those further away. For instance, in the previous screenshot, the ground tiles should have been drawn at lower depth than the droid. There are loads of different sorting methods developers will use, it all depends on the specifics of your game. Depth sorting can quickly become very CPU intensive, so you should be aiming to make your sorting function as simple and optimised as possible, as long as it results in an accurate rendering for your particular game.

In our game, we’re not supporting complex z (vertical) axis sorting. For example, there are no traversable raised platforms, stairs, trenches etc that would require the depth sorting task to become more robust. For our game, every object is either ground level (e.g. ground tiles), or sits on top of the ground (e.g. wall and droid tiles). So, given this, we can assume that the ground tiles should always be rendered at the lowest depth. This is great, because it means we can separate them from the wall and droid tiles and exclude them from the sorting process altogether. That almost cuts our processing requirements in half! (depending on your level design). Let’s make some adjustments to implement this.

At the top of your GameScene class, under the line:

let viewIso:SKSpriteNode

add this code:

let layerIsoGround:SKNode
let layerIsoObjects:SKNode

In the init(...) method, under the line:

viewIso = SKSpriteNode()

add this code:

layerIsoGround = SKNode()
layerIsoObjects = SKNode()

Then, in the didMoveToView(...) method, just above the line:

addChild(viewIso)

add this code:

viewIso.addChild(layerIsoGround)
viewIso.addChild(layerIsoObjects)

So, we just made 2 subviews (layers) in our isometric view. We’re going to put all the ground tiles in layerIsoGround and all other objects, that need to be considered while depth sorting, in layerIsoObjects. layerIsoGround was added to the parent viewIso first, so it will be at a lower depth than layerIsoObjects by default, meaning, you guessed it, the ground tiles will always be rendered below all other tiles. Let’s adjust our affected code to conform with this restructuring.

In the placeTileIso(...) method, replace this line:

viewIso.addChild(tileSprite)

with this code:

if (tile == Tile.Ground) {
    layerIsoGround.addChild(tileSprite)
} else if (tile == Tile.Wall || tile == Tile.Droid) {
    layerIsoObjects.addChild(tileSprite)
}

Ok, with our restructring sorted, let’s add our depth sorting function:
Just above the touchesEnded(...) method, insert this function:

func sortDepth() {

        //1
        let childrenSortedForDepth = layerIsoObjects.children.sorted() {

            let p0 = self.pointIsoTo2D($0.position)
            let p1 = self.pointIsoTo2D($1.position)

            if ((p0.x+(-p0.y)) > (p1.x+(-p1.y))) {
                return false
            } else {
                return true
            }

        }
        //2
        for i in 0..<childrenSortedForDepth.count {

            let node = (childrenSortedForDepth[i] as! SKNode)

            node.zPosition = CGFloat(i)

        }
}

Broken down:

  1. We run a sort process on all the children in layerIsoObjects (e.g. the Wall and Droid tiles). The sorted function adds the x and y values of each isometric tile (once converted to the 2D coordinate space), the larger sum means the tile is closer to the player and should therefor be at a higher depth (e.g returns false, to be placed later in the children array).
  2. We then take this childrenSortedForDepth array and assign a depth (zPosition) to each child based on its position in the array.

We now need to call our depth sorting method frequently enough, so as the droid (or any objects added later on) change position, the tiles are sorted accordingly. With that said, let’s try pinning the execution onto the end of our update(...) method, so it now looks like this:

override func update(currentTime: CFTimeInterval) {

    hero.tileSpriteIso.position = point2DToIso(hero.tileSprite2D.position)

    sortDepth()
}

Run your app.

Screen Shot 2015-02-26 at 3.21.31 pm

Depth sorting, sorted.

Optimise, Optimise, Optimise

As mentioned earlier, depth sorting can be an expensive process in terms of chewing your CPU. You want to take any reasonable measures available to reduce its cost. Run your app again, but this time, have a look at the debug navigator in xCode. To view it, click the icon highlighted in this screenshot, in the menu just above the project navigator:

Screen Shot 2015-02-26 at 4.18.00 pm

You can see a quick analytic of the apps performance while running. Pay particular attention to the CPU reading. These values will vary depending on the device you’re testing on. I’m using an iPhone 6 and while the game is idling, the CPU runs at around 20%:

Screen Shot 2015-02-26 at 4.12.35 pm

That may not seem like a cause for concern, but remember, this app will want to run on less capable devices than the iPhone 6. Also, the complexity of this game, is in its infancy. As the level sizes expand and more objects and functionality are added, the CPU cost will go up and up. So the more preemptive optimisations we can make early on, the better.

So Let’s Think About This. How Could We Reduce CPU Cost for Our Depth Sorting Process?

We could spend more time trying to optimise the sorting function
We could, though it’s already stripped down to a pretty basic algorithm and the fact that we’ve managed to exclude all ground tiles from the process has already reduced the work by half.

We could look into the core functions of SpriteKit
There may be more optimal alternatives to sorting the child sprites, like deleting them all, then adding new sprites each time. I haven’t looked into this, so it may help or it may hinder, but it’s food for thought.

We could reduce the frequency of the depth sort execution
Bingo. At the moment, our sortDepth() method is being fired EVERY FRAME. At the assumed optimal framerate, that’s 60 times a second. The sort function doesn’t crunch any crucial game calculations, it’s just a rendering assistant. If we ran it, say, 10 times per second, it’s still depth sorting every 0.1 seconds. Given that our droid doesn’t move that fast, I’d bet that a 10th of a second rendering inaccuracy would go unnoticed.

Let’s try it out. At the top of your GameScene class, under this line:

let hero = Droid()

add this code:

let nthFrame = 6
var nthFrameCount = 0

Then at the bottom of your GameScene class, in the update(...) method, remove this line:

sortDepth()

and in its place, put this

nthFrameCount += 1     
if (nthFrameCount == nthFrame) {
    nthFrameCount = 0
    updateOnNthFrame()
}

Lastly, add this method directly under your update(...) method:

func updateOnNthFrame() {
   sortDepth()
}

What we’ve done here, is setup a split system for our games processes. The processes that rely on a high framerate, like game logic (movement, collisions, projectiles etc) will continue to be called every frame in the update(...) method. However, less critical processes like depth sorting, will be called once every nthFrame. I’ve set this to 6, meaning it will be called 10 times per 60 frames. You can tweak this value as you see fit. If you run your app and you notice some glitches, decrease the nthFrame value, so updateOnNthFrame() is run more frequently. Essentially, the higher nthFrame value you can get away with, the better.

Ok, so let’s take her for a test drive. Run your app and check the debug navigator CPU performance.

Screen Shot 2015-02-26 at 4.13.46 pm

11% we’ve almost halved our apps entire CPU cost.

shablagoo

Seeing as we’re on a roll, let’s update our tiles array to read like this:

tiles =     [[(1,7), (1,0), (1,0), (1,0), (1,0), (1,0), (1,1)]]
tiles.append([(1,6), (0,0), (0,0), (0,0), (0,0), (0,0), (1,2)])
tiles.append([(1,6), (0,0), (2,2), (0,0), (0,0), (0,0), (1,2)])
tiles.append([(1,6), (0,0), (0,0), (1,5), (1,4), (1,4), (1,3)])
tiles.append([(1,6), (0,0), (0,0), (0,0), (0,0), (0,0), (0,0)])
tiles.append([(1,6), (0,0), (0,0), (0,0), (0,0), (0,0), (0,0)])
tiles.append([(1,5), (1,4), (1,4), (1,4), (1,4), (1,4), (1,3)])

Run your app.

Screen Shot 2015-02-26 at 3.32.54 pm

This level arrangement better demonstrates our depth sorting. We’re really starting to paint a convincing picture of 3D space. The 1 aspect that kills the illusion, is the fact that our hero is so mighty, no wall can stop him. He just goes wherever he wants! In part 3 of this tutorial series, we’re going to use A* Pathfinding to put some limitations on his free will.

Conclusion

Congratulations! You’ve just completed part 2 of the ongoing isometric tutorial series. Here is a sample project with all of the code to this point:

Don’t forget, you can sign up to the newsletter or follow us on Twitter/Facebook to be notified of future episodes in the series. Cheers!

Go to part 3 in the isometric series now >

Dave Longbottom

Dave is the indie dev behind Big Sprite Games. Read more on the About page.

14 thoughts on “Create Your Own Isometric Tile-Based Game: Part 2

  1. Thanks! The Part2 release was just accurate to my birthday day, so I take this as a gift for me LOL.
    Awesome second part of the tutorial, I can’t wait to add some boundaries for the robot to collide and not to go through the walls. I’ll wait for the Part3 anxiously. Thanks for this tutorial it is awesome!

  2. Hey! Now, I have a question, it has nothing to do with this tutorial kind of game. It is more like a Clash of Clans type.

    Let’s say that I want to know when the user touches the robot, but in my code, the robot is a viewIso child.
    This if my code.

    robot = SKSpriteNode(imageNamed: "rob")
    robot.position = CGPointMake(20, -300)
    robot.setScale(0.4)
    robot.name = "theRobot"
    robot.userInteractionEnabled = false
    viewIso.addChild(robot)
    
    override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
            /* Called when a touch begins */
    
            let touch = touches.anyObject() as UITouch
            let location = touch.locationInNode(self)
            var node:SKNode = self.nodeAtPoint(location)
    
            if(node.name == "theRobot")
            {
                println("TOUCHED ROBOT")
            }
            else
            {
                println("TOCHED NOPE")
            }
    }
    
    

    How can I achieve this?

    Thanks for your time.

    1. Hi Charly, I haven’t had time to test your code, yet. At first glance though, it looks pretty logical. If what you have here, works, the next thing I’d do, is look for places to optimise. For example, I’d probably try and avoid using the ‘SKSpriteNode.name` attribute as your comparator, simply because it’s a String type. I’d be more inclined to build an extended class of some kind (not unlike we did with the Character class in this tutorial), so you can use an Integer type for a lower-cost comparison. If you need quick answers on stuff like this, don’t hesitate to post a question on stackoverflow.com. The community on that site is usually quite quick to respond and full of talented, experienced devs, that are happy to help.

      1. Thanks Dave! I’ve resolved the problem, and yes indeed stackoverflow.com helps some times. And by the way, when is Part 3 coming out? I just can’t wait for it!

        Thanks for the help!

  3. Hey,
    first of all thank you for the great tutorial.

        class var sharedInstance: TextureDroid {get}
    

    When adding this line I get the following error:

    Class properties are only allowed within classes

    When i delete this line i get the error “Immutable value ‘self.texturesIso’ may not be assigned to’ in this lines

    texturesIso[Action.Idle.rawValue] =
    

    Thanks in Advance, i would appreciate someones help

    jonas

    1. Hi,
      For that piece of code, it did show some errors,
      what I did was:
      1.Change “class” to “static” as Xcode recommended in this line:

      static var sharedInstance: TextureDroid {get}
      

      2.For the immutable error, I just changed “let” to “var” as in the below two lines:

      let texturesIso:[[SKTexture]?]
       let textures2D:[[SKTexture]?]
      

      Then errors disappeared. Hope this helps.

  4. I have added the following code to the hero movement as to attempt to move the viewIso in the opposite direction, causing the hero to “stay” in the spot in the iPhone window and the viewIso to move. But the following code only “kinda” works and is a bit off, I assume due to some of the math being used here. Any help would be much appreciated!!

    let velocity = 100
    let time = NSTimeInterval(distance(heroPos2D, hero.tileSprite2D.position)/CGFloat(velocity))
    hero.tileSprite2D.removeAllActions()
    hero.tileSprite2D.runAction(SKAction.moveTo(heroPos2D, duration: time))

        viewIso.removeAllActions()
        let distanceX = heroPos2D.x - hero.tileSprite2D.position.x
        let distanceY = heroPos2D.y - hero.tileSprite2D.position.y
        viewIso.runAction(SKAction.moveTo(CGPoint(x:viewIso.position.x - distanceX, y:viewIso.position.y - distanceY), duration: time))
    

    1. Hi Leland, I haven’t had time to research your approach but what you’re doing here seems logical. If it’s not quite working, I would try calculating and setting the viewIso’s counter position each frame via the update() function. That way you’re calculating off the droid’s absolute offset value instead of trying to synch 2 tweening actions.

  5. For Swift 2.2, the sortDepth() method should look like this:

    func sortDepth() {
    
            //1
            let childrenSortedForDepth = layerIsoObjects.children.sort({ (e0, e1) -> Bool in
    
                let p0 = self.pointIsoTo2D(e0.position)
                let p1 = self.pointIsoTo2D(e1.position)
    
                if ((p0.x+(-p0.y)) > (p1.x+(-p1.y))) {
                    return false
                } else {
                    return true
                }
    
            })
            //2
            for i in 0..<childrenSortedForDepth.count {
    
                let node = (childrenSortedForDepth[i])
                node.zPosition = CGFloat(i)
    
            }
        }
    
  6. You should consider editing the sortDepth function from > to >=

    if ((p0.x+(-p0.y)) >= (p1.x+(-p1.y))) {
    ...
    }

    It’s more accurate.

Leave a Reply

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

Use Markdown backticks to wrap your code blocks e.g.

```
// This is my example code block
func myFunction( ) -> Bool {
    return true
}
```

You may also use these HTML tags and attributes: <a href="" title=""> <blockquote cite=""> <pre> <cite> <code> <em> <strong>