Sei sulla pagina 1di 13

XNA Isometric Games by Martin Actor

A lot of people have asked about isometric rendering, with specific attention to sorting so that things overlap properly. This tutorial will show you a method that is both fast and accurate. The approach is specifically engineered for isometric view, grid-based games. It is not well-suited to adaptation to non-grid-based movement or gameplay. With that in mind, let's cover a few core concepts.

In a grid-based world, we want to naturally represent this organized structure as logically and simply as possible, in order to simplify our code and make things easily expanded in the future as we add cool, new features. The most basic memory structure we have for such a thing is an array, and as it turns out, this particularly simple memory structure is exactly what we want :

The image above shows a grid that is two dimensional, 4 by 4, showing the zero-based indices. We'll be working with arrays and grids much larger than this, but it shows a very important property that all arrays (and nearly all memory structures) share - numbering indices starting with zero. The reason for this is simple - whenever you access an array, you are requesting the value or object at a particular offset from the start of the array. Zero offset means the first "grid" of the related dimension. We want our world to be locked to a grid, and the array gives us this virtual representation perfectly. Before we can define a suitable array, we need to decide on how the world will be represented in code. We could very easily use a simple numbering system to indicate what is in a particular grid (using 1 to be a wall, 2 to be a tree, 3 to be a floor, etc). Such a system is inherently limiting, however, and with an object oriented language like C#, we have everything to gain from using a much more flexible system by abstracting the concept of a grid into a Class. This will enable us to encapsulate all the properties of a grid in a single object. So, what properties do we need? We need just three properties, for now :

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

class MapGrid { public Point Coordinate; public uint ProcessID; public Texture2D Texture; public MapGrid(int x, int y, Texture2D t) { Coordinate.X = x; Coordinate.Y = y; Texture = t; return; } }

The Point type is a struct that consists of two ints, X and Y. This makes it a good type to use for a coordinate pair (X,Y), and having our grid know its grid coordinates in the map it's a part of will come in handy later. While the Texture member may seem obvious (that being the texture we draw for this grid), the ProcessID member may not be. Let's put off getting into that right now, as it will be aptly explained in a bit. Now that we have an idea of how our grids will be represented, let's put together another class that will manage the grids and the world as a whole :
1 2 3 4 5 6 7 8 9

class Map { private uint ProcessID = 0; protected MapGrid[,] Grids; private int _mapwidth; public int Width { get { return _mapwidth; } }

10 11 12 13 14 15 16 17 18 19 20

private int _mapheight; public int Height { get { return _mapheight; } } public Map() { } public void Initialize(int w, int h, Texture2D defaulttex) { _mapwidth = w;

21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39

_mapheight = h; Grids = new MapGrid[w, h]; for (int j = 0; j < h; j++) for (int i = 0; i < w; i++) Grids[i, j] = new MapGrid(i, j, defaulttex); return; } public MapGrid GetGrid(int x, int y) { if (x < 0) return null; if (y < 0) return null; if (x >= Width) return null; if (y >= Height) return null; return Grids[x, y]; } public MapGrid GetGrid(Point p) { return GetGrid(p.X, p.Y);

40 41

} }

Naturally, this won't be all the code in our Map class, but it's a start. You'll notice another ProcessID member - yes, they are related, but we'll yet wait to explain until later. We declare our array of grids, suitably named Grids, as a two dimensional array of MapGrids. We also define width and height members that we hide through accessors, methods that we use to ensure that no outside code can directly modify those members at all (which could prove disastrous if it should happen). We setup a method to allow us to easily initialize our Grids, along with a texture we want applied to all grids by default (which could very well be null if we so chose). Lastly, we provide a way for the Map class to give us a particular MapGrid by referencing its grid coordinate in the world (which is the same pair of values that the grid object indexes at in the Grids array). With that started, we now need to figure out how we're going to draw the grids themselves. There are generally two ways we can draw an isometric view of our world, and each approach sets limitations that affect the rest of the project, most notably the way we create artwork for the game. We can choose either a non-rotated with tilted view, similar to the classic Legend of Zelda and Final Fantasy 2/3 games, or we can choose a 45 degree rotated with tilt view, similar to other classics like Syndicate, Populous, and Diablo, just to name a quick few. Because we're hardcore fans of the true isometric view, that being 45 degree rotation with tilt, that's what we'll do, but unfortunately, it's the far more difficult of the two to get looking right (let alone good). With a normal view, we could simply iterate across our grid along the X axis and shift down the Y axis in order to draw the entire grid as such :

This would result in the expected display of the textures overlapping in a predictable and desired fashion, from left to right and front to back. However, what we want is a bit different we want a rotated view, and this is where the trick is. The resultant visual we want is :

How do we achieve this? Think back to how we iterate across a grid normally - from left to right, top to bottom. This is important, as it ensures (when things are kept simple like having no objects larger than a single grid, but we'll get to that later) we achieve the proper visual overlap and depth to what we draw. How do we go left to right, top to bottom on a grid that isn't rotated, in order to make it look as if it is? Simple :

We iterate diagonally across the grid in the manner above in order to achieve the following :

By doing things a little different, we end up doing them in a similar enough manner to benefit the same. Now, the code for iterating normally through a two dimensional array we've already seen in our Initialize method above. Iterating diagonally changes things a lot. To understand how the code works, let's clarify what we're looking at :

We want to be going across from left to right, top to bottom. This means starting at (0,0) (using the convention of X,Y), then going from (0,1) to (1,0), then (0,2) to (2,0), and so on. Our code also needs to be flexible enough to handle different starting and ending coordinates, as well as non-square grid areas. Since we're operating on diagonals instead of rows, we need to rewrite entirely how we iterate :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18

public void Render(SpriteBatch sb, ref Point CenterGrid, int DisplayAreaWidth, int DisplayAreaHeight, int ScreenWidth, int ScreenHeight) { int CurX, CurY; // this tell us which grid coordinate we're dealing with int LimitX, LimitY; // these tell us which coordinate limits on each axis we are currently processing int OriginX, OriginY; // these mark our starting grid for the entire area int StartX, StartY; // these mark our starting grid for the line int EndX, EndY; // these mark our ending grid for the line bool bEdgeX, bEdgeY; // these get used to know when we finish rendering the desired area // initialize our values to the first grid we'll process OriginX = StartX = LimitX = CenterGrid.X - DisplayAreaWidth / 2; OriginY = StartY = LimitY = CenterGrid.Y - DisplayAreaHeight / 2; // ensure we aren't starting outside the bounds of our grid array if (LimitX < 0) OriginX = StartX = LimitX = 0; if (LimitY < 0) OriginY = StartY = LimitY = 0;

19 20 21 22 23 24 25 26 27 28 29

// initialize our ending grid EndX = StartX + DisplayAreaWidth - 1; EndY = StartY + DisplayAreaHeight - 1; // ensure we aren't ending outside the bounds of our grid array if (EndX >= Width) EndX = Width - 1; if (EndY >= Height) EndY = Height - 1; while (true) { // start a new line CurX = StartX; CurY = LimitY;

30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48

// reset our edge values bEdgeX = bEdgeY = false; // iterate across the line of grids while ((CurX <= LimitX) && (CurY >= StartY)) { // move to the next grid on the line CurX++; CurY--; } // increment our X axis limit if (++LimitX > EndX) { // this means our diagonal traversals are hitting the right most edge of the area LimitX = EndX; // we've also hit the bottom most edge of the array if (++StartY > EndY) {

49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75

StartY = EndY; bEdgeY = true; } } // increment our Y axis limit if (++LimitY > EndY) { // this means our diagonal traversals are hitting the bottom most edge of the area LimitY = EndY; // we've also hit the right most edge of the array if (++StartX > EndX) { StartX = EndX; bEdgeX = true; } } // hitting both edges at the same time means we've run out of diagonals if (bEdgeX &amp;&amp; bEdgeY) break; } return; }

It's a lot of code that may be confusing, but we're not done yet. What we have above is the basic framework for iterating diagonally across our array of grids given an arbitrary center grid (where our camera or player may be) and a defined view area (maybe we don't want to draw the entire map at once but just a 25 by 25 square area centered on our camera or player). The SpriteBatch, ScreenWidth, and ScreenHeight arguments are all to support the actual rendering

of the map grids. Before we can go further, however, we need to talk about the actual grid image or texture. In the images above, we see a tile that's twice as wide as it is high. This makes it easy to create the sense of depth when drawing other things on top of the tile. We don't necessarily have to use the size of the tile above - nearly any size will do. What we want to be aware of is how the tiles will look when overlapping each other, as well as how we want overlapping to occur (if even at all). Start by creating a base tile texture. This serves as the reference point for every tile and sprite later on for the game - they must all be of at least the base tile's size, and sprites intended to be drawn on top of them must also conform through positioning. There are a number of techniques, too many to go into real depth here, for drawing isometric tiles; the one key point to keep in mind is that we're going to see the tiles a *lot*; that is, after all, the nature of tiling - to use a tile repeatedly. Once we have a base tile, as well as a floor tile, we need to modify our Map.Render() method to know how to position the grids on the screen. We accomplish this with the addition of two members to our Map class :
1 2

public int FloorTileWidthHalf; public int FloorTileHeightHalf;

It may seem odd storing only half the width and half the height of our base tile, but it turns out that things become much simpler (and faster computationally) by doing so. Because we store the half-values, it's interesting to note that while the tiles above are 34x17 in size, the halfvalues we'll be using are 16 and 8. This is because we expect a one pixel overlap on the left and right side of our tile, and we expect a one pixel overlap on the bottom. This is the result of intended tile overlap. It's not required, and a lot of games don't even do it; it's simply for a different visual look. We need to modify our Map.Render() method now, with the following additions :
1 2 3 4 5 6 7 8 9 10 11 12

int SpriteOriginX, SpriteOriginY; // these mark the rendering location of the first grid, serves as a rendering origin for all grids Vector2 Sprite; // this is used to store the current grid's rendering location MapGrid CurGrid; // this tells us which grid we are currently processing // this is how we know whether or not we've processed a grid without having to touch every grid an extra time ProcessID++; // add just before the first while loop // initialize the sprite rendering origin coordinates SpriteOriginX = (ScreenWidth / 2) + (ScreenWidth % 2) FloorTileWidthHalf; SpriteOriginY = (ScreenHeight / 2) + (ScreenHeight % 2) +

13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40

FloorTileHeightHalf; SpriteOriginX -= (CenterGrid.X - StartX) * FloorTileWidthHalf (CenterGrid.Y - StartY) * FloorTileWidthHalf; SpriteOriginY -= (CenterGrid.X - StartX) * FloorTileHeightHalf + (CenterGrid.Y - StartY) * FloorTileHeightHalf; // add between the block above and the first while loop sb.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.Immediate, SaveStateMode.None); // inside the second while loop // this is how we flag the grid as being processed CurGrid.ProcessID = ProcessID; // if there's no sprite associated with this grid, continue on if (CurGrid.Texture == null) { CurX++; CurY--; continue; } // set our sprite rendering coordinates Sprite.X = SpriteOriginX + (CurX - OriginX) * FloorTileWidthHalf (CurY - OriginY) * FloorTileWidthHalf + FloorTileWidthHalf CurGrid.Texture.Width / 2; Sprite.Y = SpriteOriginY + (CurX - OriginX) * FloorTileHeightHalf + (CurY - OriginY) * FloorTileHeightHalf - CurGrid.Texture.Height; // draw this grid sb.Draw(CurGrid.Texture, Sprite, Color.White); // right before we return from the method entirely sb.End();

That's a lot of additional code - we'll see it in a more complete state later on. Notice, again, ProcessID - now's the time to cover it. Remember how we're iterating over every grid in the area we want to draw? Right now, we touch each grid only once, and that's ideal. Later on, however, we're going to be introducing code that can potentially draw or process a grid more

than once, and that's what we want to avoid. How can we tell if we've already touched a grid or not? We could use a boolean variable as a flag, but then we'd need to clear that flag before we did any processing at all, and that can add up to a noticeable slowdown in rendering if we're drawing a lot of grids at once. A nice trick is to use a counter, usually an int or uint. This counter gets incremented at the start of the code. When we touch a grid, we can check its internal counter, and if the two don't match, we know we haven't drawn or processed it yet for this frame. We end up processing a grid exactly once, which is what we want to achieve. "What if we run out of numbers for the counter?" you might ask. A valid concern, so let's double check ourselves. A uint in C# (and thus XNA) is a 32bit (4 bytes) value that's strictly positive. This means we get the full range of numbers that 32 bits can give - 2 to the 32nd power. The number of numbers a uint can express is therefore 2^32 or 4,294,967,296. Almost 4.3 billion. That's a lot, but we need to put that in rendering time to really sort this out. If we were to render 1000 frames per second non-stop, and starting our counter at 0 adding 1 for every frame, we wouldn't hit the full 2^32 values for 4,294,967 seconds (plus some change). Those nearly 4.3 million seconds is over 71,000 minutes or almost 1200 hours or nearly 50 days. 50 days of drawing 1000 frames per second before we have to worry about our uint counter rolling back over to 0. If we limit ourselves to just 60 frames per second, we get an astonishing 828 days - over 2 years! Considering that we restart back at 0 every time the game starts, we can be safe in the knowledge that our using a uint for a counter is not a concern at all. The next items that may be confusing are the lines where we assign the SpriteOriginX, SpriteOriginY, and Sprite variables. SpriteOriginX and SpriteOriginY store the bottom-left corner of the first grid we'll draw. This is important as it gives us a point from which we can logically base off of as we iterate over our grid area. We store into Sprite the actual drawing location of the current grid we're processing by offsetting from SpriteOriginX/Y in a manner related to the difference in grid coordinates and the tile half sizes. If we were to stop here and test what we have so far, we'd find ourselves capable of drawing any sized area of single-tile grids and objects. That is to say, we're about 75% of the way to our desired goal. To get the rest of the way, we need to figure out how to handle and draw objects that span multiple grids. Multi-grid objects present a challenge, as we can't simply draw them at the first grid they're on. Let's examine the issue more closely.

Above is an image of a single-grid tree object on three different grids. Because the tree object is only one grid in size (Width of 1 and Height of 1), it properly overlaps previously drawn grids and objects. For objects that cover more than one grid, we want them to appear as such :

Without properly handling multi-grid objects, we would instead get :

This very clearly looks wrong. The problem is that we want to somehow wait to draw the object until everything that could overlap it is already drawn - we want to defer the drawing of the object. Before we start on the new functionality, we need to add a new class - MapObject.
1 2 3 4 5 6 7 8

class MapObject { public Point Location; public Map Map; public int Width; public int Height; public Texture2D Texture; }

We don't need to get into further details to show that we have a base class for all objects that can occupy a grid in our game world. This will allow us to standardize world objects in a way that makes rendering them consistent and easy. You may be curious as to why we have a reference to a Map object. This is to support future functionality, such as objects (including players) moving between different maps. We also need to modify MapGrid by adding a reference to the MapObject that is on it (if any) via :
1

public MapObject Object;

Before we make a lot of changes to our Map class, let's discuss in detail our multi-grid object solution. So, we want to know when it's safe to draw our multi-grid object. Knowing that we draw left to right, top to bottom, and also knowing that we "base" all MapObjects on their upper-left corner (that is, a MapObject's Location is the grid coordinate of the upper-left most grid, or the grid that the MapObject is on with the lowest X and lowest Y coordinate values), means we can safely make some assumptions. The first, and arguably the most important, assumption is that of all the grids under our multi-grid object, the very first grid to ever be processed will be our Object's Location grid. The correlary to this is that the last grid to be processed under our object will be the bottom-right most one. Due to the order in which grids are rendered, we can't simply render all the grids under an object at once, separate from the regular rendering order for the map area. To do so would introduce obviously inconsistent overlap issues, if we designed our tiles to overlap. We need to ensure that not only do we wait until it's safe to draw the floor under the object before we draw the object itself, but we have to make sure that we preserve the order of rendering itself (left to right, top to bottom) at all times. Most importantly, we want to preserve the visual overlapping so that things look right - it's worth saying again because we have to realize that there's going to be a tradeoff. We're going to have to decide on a method that favorably balances speed and visual accuracy. We need our method to be fast, and this boils down programmatically to touching each grid as little as possible (ideally just once). It also means keeping lists that we have to iterate over as small as possible. Visual accuracy equates to proper overlap by both grids and objects on the screen, so that things in general look right. Let's inspect our grid to get an idea of what needs to be deferred.

If we focus on ensuring that all overlap occurs left to right, top to bottom, we have an overlap layout like the following :

The red-tinted grids show which grids we would have to potentially defer if we were to ensure that all grids rendered left to right. This doesn't initially seem so bad, but keep in mind that we could possibly have a lot of multi-grid objects that could create overlapping regions of deferred grids. Because we're drawing left to right, any grid to the right and below our object's Location grid needs to be drawn after we've determined it's safe to draw our object. It also means that should we have other objects to the right of this one, their drawing gets deferred as well. There is an alternative :

Instead of ensuring that all overlapping occurs properly, we focus on what will stand out far more readily - improper overlapping on the objects instead of the grids. This means that what we really care about are grids that could possibly draw on top of our object if we draw things out of order. To guard against this, we can make a very handy observation :

The purple-tinted grids show the grids that are at the edges, visually, of our object. What makes these two grids special is that they define the edge-cases between grids that don't overlap our object and grids that do. This gives us a way to determine when it is safe to draw our object and the grids under it - when both edge grids have been processed, we know to draw all grids under the object and then draw the object. Will this potentially lead to visually backwards overlapping of the grids under the object? Yes, it very much can, but consistent floor texture design can mitigate this entirely, making the effect completely unnoticeable. Implementing the deferred rendering of objects means adding a great deal of code to our Map class. With the code being freely available below, we won't go into a line by line explanation here; the code available for download is well documented through explanatory comments. It's worth noting, also, that an optimization is made in the code - the use of an ObjectPool, an object that manages a collection of other objects that get used and discarded frequently, to help reduce garbage collection from the constant creation and removal of NoRenderAreas (see code). When the code is said and done, our results look great :

We get a consistent 60 frames per second rendering a 50x50 map. Due to the very limited use of float variables and types, we're even ready for use on the XBox 360. Included with the source for the rendering code is the source for the demo project above, including assets. You can download the entire source+project+assets at the following link :

Potrebbero piacerti anche