Part 4 - Isometric Maps


In the previous installment of this series, we dealt with a new tile shape - the Hexagon. While this type of map shape is very popular for pen-and-paper role playing and war games, it isn't used all that often on computer games. Much more common is the isometric perspective map.

An isometric projection is a way of drawing 3D objects in 2D. Isometric tile engines have been used in computer games since the 1980's, with Q-Bert and Zaxxon being the first games to utilize the projection to simulate a semi-3D world without the horsepower that true 3D graphics require. You can think of an isometric projection as viewing a object from a very particular angle, where the apparent angle between any two of th three axis (x, y, and z) is 120 degrees (isometric means "equal measure").

If you search around on the internet, you can find several examples of isometric artwork, and I have settled on the image at OpenGameArt.org as a sample tileset for working on the isometric variation of the tile engine.



Just like we did with hexagonal maps, we will continue to represent our map as a two-dimensional array of map cells. As before, what really changes is he way we are drawing the cells to the screen. We will use exactly the same offset technique we did for the hexagon engine. In fact, to switch from our hex-based tiles to our isometric tiles, the only changes we need to make are:

  • Replace the tileset and update the TileWidth and TileHeight settings
  • Modify the TileStepX, TileStepY, and OddRowOffset values in the Tile class


For drawing the basic terrain with the engine, that's it. Let's go ahead and do that right now. Add the image above to your project and edit the LoadContent() method of the Game1 class to load it instead of the existing tileset:

Tile.TileSetTexture = Content.Load<Texture2D>(@"Textures\TileSets\part4_tileset");


In the Tile class, we need to update our size and spacing variables. Replace the current values with:

static public int TileWidth = 64;
static public int TileHeight = 64;
static public int TileStepX = 64;
static public int TileStepY = 16;
static public int OddRowXOffset = 32;
static public int HeightTileOffset = 32;


In this case, our tiles are 64x64 pixels, though this is a bit deceptive as, at least for flat terrain tiles, the image only occupies the bottom 32 pixels of the tile. While our tiles are spaced the full width horizontally (64 pixels), they are spaced 1/4th of our tile size (16 pixels) vertically, which is actually half of the size of the terrain area itself. By the same token, each odd row is offset by half of the tile width (32 pixels). Here is a closeup of what a tile might look like:




You probably noticed that I also snuck in a variable called HeightTileOffset. We'll revisit that shortly, but for now I'll say that it is to support rows 4 thru 8 of the tileset image above.

Go ahead and run your project... those changes are all we need to make the engine support isometric terrain tiles:



Ok, we're done with isometric maps! What do you mean, that only uses the top three rows of this beautiful tileset?

Ok, ok... If we were only interested in drawing flat terrain tiles, we could stop here... we already have that working. But it is true... looking at the tileset, there are some really nice "elevated" tiles. These tiles occupy more than the bottom 32 pixels of the tile image, and give the impression of 3-dimensional height to the tile. In order to be able to draw these, we need to make a few modifications to the way we are drawing our tiles.

Handling "Height" Tiles


We want to draw a "base" set of tiles on the map always at the terrain height, so we will leave the BaseTiles list in the MapCell class in place. We are going to add a new List object called HeightTiles to store tiles that are elevated above the base level. Add this declaration right under the declaration for the BaseTiles List in the MapCell class:

public List<int> HeightTiles = new List<int>();


We will also need a method to add HeightTiles easily:

public void AddHeightTile(int tileID)
{
    HeightTiles.Add(tileID);
}


In order for these "3d" tiles to appear to overlap correctly, we need to draw the tiles on our map in a specific order. We need to draw tiles starting in the upper right corner, one diagonally down and right "line" at a time for the tiles to stack properly. While we could certainly come up with a loop that would draw things in the proper order for us, I think it is easier to take advantage of the layer depth drawing features of XNA and simply calculate the z-depth relationship of the tiles we are drawing and just draw them in the order we currently do.

Using the z-depth instead of the drawing order will also allow us to (fairly) easily insert a player avatar later on without *too much* mucking about with the tile engine code.

At the top of the Game1 class, update the baseOffsetX and baseOffsetY values with those below, and add the heightRowDepthMod variable:

int baseOffsetX = -32;
int baseOffsetY = -64;
float heightRowDepthMod = 0.0000001f;


Recall that we will use the base offsets to hide the edges of the map by moving it off of the screen. The heightRowDepthMod value will be used a little later when we begin stacking tiles on top of each other.

In the Draw() method of the Game1 class, add two parameters to the SpriteBatch.Begin() call:

spriteBatch.Begin(SpriteSortMode.BackToFront, BlendState.AlphaBlend);


This tells XNA that we are going to specify the layer depth for the sprites we are drawing and that we want them to be sorted from back (1.0f) to front (0.0f) when they finally get drawn to the screen when SpriteBatch.End() is called.

Just below this, after we set the values for offsetX and offsetY, lets add two float variables:

float maxdepth = ((myMap.MapWidth + 1) + ((myMap.MapHeight + 1) * Tile.TileWidth)) * 10;
float depthOffset;


Here we are going to compute the maximum depths that we will use to draw any MapCell in the map by multiplying the Width (+1) and the Height (+1) of the map by the width of a tile, and multiplying dividing the result by 10. Since our map is 50x50 cells and our TileWidth is 64, this value will be (51 + 51 * 64)/10, or 33150. We will need this value when we draw the actual tiles to the screen, so we will be coming back to this in just a minute.

A few lines down, inside the "for (int x = 0; x < squaresAcross; x++)" loop and before the foreach loop that draws our tiles, add the following:

int mapx = (firstX + x);
int mapy = (firstY + y);
depthOffset = 0.7f - ((mapx + (mapy * Tile.TileWidth)) / maxdepth);


The mapx and mapy variables are only here to make the subsequent code a little easier to understand. We could certainly inline (firstX + x) wherever we use mapx, but splitting the two variable out will make things read more easily.

To compute the layer depth we will draw a tile at, we use a base depth of 0.7f, and subtract from that (bringing the result closer to the screen) the relative location of the tile we are drawing divided by our maxdepth value from above. For example, if we are drawing the MapCell located at 10,10 on the map, our result is 0.7f - ((10 + (10 * 64))/33150, or 0.7f - 0.0196078431372549 (roughly... it gets rounded off, of course). The numbers aren't critical to us, really... just that we are fractionally moving the layer depth closer to the screen depending on the relative location of the tile we are drawing on the map. The lowest our layer depth will ever get approaches, but doesn't reach, 0.6f, leaving us plenty of layer depth space for other things like interface items, particles, etc.

Replace the current foreach loop (that draws the base tiles) with the following:

foreach (int tileID in myMap.Rows[mapy].Columns[mapx].BaseTiles)
{
    spriteBatch.Draw(

        Tile.TileSetTexture,
        new Rectangle(
            (x * Tile.TileStepX) - offsetX + rowOffset + baseOffsetX,
            (y * Tile.TileStepY) - offsetY + baseOffsetY,
            Tile.TileWidth, Tile.TileHeight),
        Tile.GetSourceRectangle(tileID),
        Color.White,
        0.0f,
        Vector2.Zero,
        SpriteEffects.None,
        1.0f);
}


If we ran this right now, we would not see any difference from the previous version. All we have really done is specify the extra parameters we need in order to provide a layer depth, which we have set to 1.0f, meaning that our BaseTiles layer will always be further away from the camera than anything else we will be drawing.

Right under the existing foreach loop, add another loop that will draw our HeightTiles:

int heightRow = 0;

foreach (int tileID in myMap.Rows[mapy].Columns[mapx].HeightTiles)
{
    spriteBatch.Draw(
        Tile.TileSetTexture,
        new Rectangle(
            (x * Tile.TileStepX) - offsetX + rowOffset + baseOffsetX,
            (y * Tile.TileStepY) - offsetY + baseOffsetY - (heightRow * Tile.HeightTileOffset),
            Tile.TileWidth, Tile.TileHeight),
        Tile.GetSourceRectangle(tileID),
        Color.White,
        0.0f,
        Vector2.Zero,
        SpriteEffects.None,
        depthOffset - ((float)heightRow * heightRowDepthMod));
    heightRow++;
}


We have introduced a new variable here called heightRow, which we use to keep track of how many height tiles we have drawn. We need to do this because as we draw each subsequent height tile stacked on the same spot, we need to make two adjustments to our Draw() call. This first is that we need to move the while draw up by the value of Tile.HeightTileOffset so that the tile appears to be stacked on the tile below it.

The second adjustment is to the layer depth we calculated for this map cell earlier. Every time we draw a height tile, we will move the layer depth 0.0000001f closer to the screen (the value of heightRowDepthMod above). This is enough that it will appear above the existing items on this MapCell, but not enough to interfere with the layer depth of surrounding cells.

Again, if we run our code at this point we won't see any change, because we haven not yet updated the map to show any HeightTiles. Lets edit our TileMap class to include a few of these stacking tiles. In the constructor of the TileMap class, add the following onto the end of the method:

Rows[16].Columns[4].AddHeightTile(54);

Rows[17].Columns[3].AddHeightTile(54);

Rows[15].Columns[3].AddHeightTile(54);
Rows[16].Columns[3].AddHeightTile(53);

Rows[15].Columns[4].AddHeightTile(54);
Rows[15].Columns[4].AddHeightTile(54);
Rows[15].Columns[4].AddHeightTile(51);

Rows[18].Columns[3].AddHeightTile(51);
Rows[19].Columns[3].AddHeightTile(50);
Rows[18].Columns[4].AddHeightTile(55);

Rows[14].Columns[4].AddHeightTile(54);

Rows[14].Columns[5].AddHeightTile(62);
Rows[14].Columns[5].AddHeightTile(61);
Rows[14].Columns[5].AddHeightTile(63);


To stack tiles onto each other, we simply add them to the same row and column. For example, row 14, column 5 will have three different tiles in it's height stack : 62, 61, and 63. The first tile added (62) will appear on the bottom of the stack, and the last tile (63) will appear on the top. I chose these tiles because they have a small waterfall coming off the side of the tile. Tile 62 has a small "splash" displayed at the bottom of the waterfall, so that is the one I put at the lowest position in the stack.

As long as we use tiles that are appropriate for the location we are putting them (ie, no full block tiles above slanted, riser tiles, etc) we can build a nice little 3D-ish isometric scene. Go ahead and run your project and you should see this:



Yet Another Set of Tiles


If you look on the tile sheet, the tiles on rows 9, 10, 11, 12, and all but two of row 13 are designed to be placed on top of existing terrain to form an overlay. While we could add these tiles directly to the base terrain layer, that would not allow us to place them on top of elevated map areas such as we just created with the HeightTiles list - they would always show up as stacked on the ground-level base layer.

To address this, we will update MapCell once again to add an additional list of tiles, and a helper method for adding tiles to the list:

public List<int> TopperTiles = new List<int>();

public void AddTopperTile(int tileID)
{
    TopperTiles.Add(tileID);
}


We will also need to update the Draw() method in the Game1 class to draw this new set of tiles. Right after the foreach loop that draws the height tiles, add the following:

foreach (int tileID in myMap.Rows[y + firstY].Columns[x + firstX].TopperTiles)
{
    spriteBatch.Draw(
        Tile.TileSetTexture,
        new Rectangle(
            (x * Tile.TileStepX) - offsetX + rowOffset + baseOffsetX,
            (y * Tile.TileStepY) - offsetY + baseOffsetY - (heightRow * Tile.HeightTileOffset),
            Tile.TileWidth, Tile.TileHeight),
        Tile.GetSourceRectangle(tileID),
        Color.White,
        0.0f,
        Vector2.Zero,
        SpriteEffects.None,
        depthOffset - ((float)heightRow * heightRowDepthMod));
}


Note that our SpriteBatch.Draw() call here is identical to the call for drawing HeightTiles. The only difference is that we don't increment the heightRow value during the loop, resulting in us drawing at whatever height we left off at when drawing height tiles (or the base height if we didn't draw any height tiles for this MapCell).

Lets update the TileMap class to add a few Topper tiles so we can see them in action. Once again, in the constructor of the class, add the following at the end:

Rows[17].Columns[4].AddTopperTile(114);
Rows[16].Columns[5].AddTopperTile(115);
Rows[14].Columns[4].AddTopperTile(125);
Rows[15].Columns[5].AddTopperTile(91);
Rows[16].Columns[6].AddTopperTile(94);


Here we are placing a patch of grass at 4,17 and 5,16, a fallen log at 4,14 and a few water tiles at the base of the cliff. Here is the result:



Note that the log and the patch of grass are properly partially covered by the surrounding tiles, and the grass itself obscures the cliff wall behind it.

Tile Coordinates


We are just about ready to wrap up this installment, but I wanted to throw a quick "extra" in here for helping to determine tile locations on the map. Create a new folder in your content project called Fonts and create a new SpriteFont object called "Pericles6" in the Fonts folder. Set the FontName property to "Pericles" and the Size to 6. Add a declaration for the SpriteFont to the Game1 declarations area:

SpriteFont pericles6;


In the LoadContent() method, add a line to load the font:

pericles6 = Content.Load<SpriteFont>(@"Fonts\Pericles6");


Finally, add the following right after the last foreach loop in the Draw() method:

spriteBatch.DrawString(pericles6, (x + firstX).ToString() + ", " + (y + firstY).ToString(),
    new Vector2((x * Tile.TileStepX) - offsetX + rowOffset + baseOffsetX + 24,
    (y * Tile.TileStepY) - offsetY + baseOffsetY + 48), Color.White, 0f, Vector2.Zero,
    1.0f, SpriteEffects.None, 0.0f);


All we are doing here is calculating a drawing location for around the center of each base tile and printing the X/Y coordinates to the screen there. You can leave this in your code and just comment it out, or you can set up a key in your Update() method to toggle the coordinates display on or off.

Wrapping Up


That covers the basics of the Isometric engine for now. You probably noticed that there are trees at the bottom of the tileset above that are not contained within the boundries of a single tile. In a future installment we will look at how we can incorporate them into our engine as well.

There are lots of places I would like to take this series from this point, so here are a few potential future topics. Let me know which ones you would find of most interest:

  • Embedding data into the map to represent details about what the map cell contains
  • Serializing and Deserializing map files for long-term storage
  • Handling non-terrain objects and objects larger than a single tile
  • Adding a player-controlled character to the isometric map
  • Building a functional map editor


Other suggestions are welcome as well.

You can download the code for this installment here:




































 

 
 
Site Contents Copyright 2006 Full Revolution, Inc. All rights reserved.
This site is in no way affiliated with Microsoft or any other company.
All logos and trademarks are copyright their respective companies.
RSS FEED