Isometric Map in SFML
isometric rpg 2D SFML
Implementing the Map
In order to implement a map, we need to clarify what exactly a map cell or box is. The map itself is very simple, just a 2D allocated array of map cells. Outside of rendering the map, the map cells do most of the work of storing objects, etc... Here is the map cell format we are going to use:
As you can see here, a map cell is merely a set of lists of things to draw. These lists hold pointers to IsometricMapSprites (the actual instances need to be kept elsewhere; the map is just an aid to rendering, not an object management device, although we will "cheat" with some objects, as you'll see in a bit.) You might be wondering why I have a list for things like floor, walls and roof. This is to give us flexibility in decorating a map. You can add as many floor graphics, wall graphics or roof graphics to a cell as you want, and the graphics will be layered in a First-In-First-Out manner. In this way, graphics can be layered using alpha-blending to apply decorations. You can set a floor graphic of a plain dungeon stone floor, then add an alpha blended smear of blood on top of it. Same with walls. You can draw a plain-Jane wall, then add a torch in a sconce as an overlay.
In a way, this kind of layered decoration is a bit simpler in a pure 2D system than in a 3D system, since in true 3D you often need to worry about Z-fighting artifacts when rendering multiple images onto the same plane. In 2D, since we don't check depth, it's not an issue. Just be sure that you add graphics in the order in which they are to be drawn, and all should be well.
You can also use the layered floors to implement transitions between terrain types. I talk about this more in the section on creating artwork.
NodeSize, or size of a cell, has direct bearing upon the isometric view. NodeSize determines the image dimensions of the graphic files that are drawn for the roof, floor and walls sections. The dimensions of a floor or roof image are equal to (4*NodeSize, 2*Nodesize). A wall piece isn't bounded in height, but the image width is equal to (2*NodeSize). So if you set NodeSize to 32, then your diamond floor tiles will be sized (128,64). If you set NodeSize to 64, your floor tiles will be (256,128). Thus, you must find a balance. Larger tiles allow greater detail and help to reduce the appearance of repetition in the tiles, since fewer tiles are visible onscreen at one time. However, this comes with the tradeoff of inflated memory usage. I usually use a NodeSize of either 32 or 64. Smaller than 32, the tiles tend to be very small and repetition is very obvious; larger than 64, the tiles tend to use a lot of memory. Also, NodeSize should be limited to powers-of-2 to ensure that the images used are powers of 2, unless you can be sure that the hardware allows non-power-of-2 textures. SFML will upload images to texture memory, and I believe it will resize textures to the nearest power of 2 greater than the image dimensions, so you can reduce texture wastage by using power-of-2 images to start.
Map Cell Coordinates
It is necessary to be able to find which cell a given World(x,y) lies within. This is done by simply taking the floor of the coordinate divided by NodeSize.
cellx = math.floor(worldx/NodeSize) celly = math.floor(worldy/NodeSize)
The map uses a bucketing system, where dynamic objects are added to the map and placed into buckets. The object world location is used to calculate which cell bucket, or list, the object should be placed in, so that when the map is drawn, only the objects residing in visible cells are drawn. The cell coordinate space is referred to as Cell(x,y).
2D isometric games come with some oft-times tricky layering problems, since the lack of depth information can make sorting properly a tad difficult. Some of the problems are circumvented via the drawing order. The drawing order I use is this:
1) Draw all floors of all visible cells
2) Iterate the cells from top of screen to bottom (more on this in a bit) and for each cell draw Objects followed by Roofs, followed by Walls
This order has some requirements. When a dynamic object is bucketed into a cell's object list, it needs to be sorted into the list so that the list is ordered back-to-front. The value used for comparison to perform this sorting is obtained by: WorldPosition.X + WorldPosition.Y + WorldPosition.Z. This value is consistent with the depth of the various coordinates within a cell relative to the conceptual camera; nevertheless, there are still constraints placed upon objects. Objects should be no larger than a cell in size; larger objects should be broken up into cell-sized chunks. This pertains to walls as well as to dynamic objects. Isometric systems traditionally do not like objects that cover more than one cell; all manner of posts in the Isometric Games forum attempt to deal with this problem, which becomes somewhat less of a problem if the objects are broken up. Breaking them up will also help with graphic reuse; you can reduce memory footprint by using large structures built up out of reusable smaller pieces, rather than having lots of special use large structures.
Drawing The Map
This part is where the magic happens. The brain-dead solution of course is to just iterate the entire 2D map array and draw each cell, and let the hardware cull non-visible objects, but this can be bad performance-wise if you have a large map with lots of objects. A better plan is to just figure out which parts of the map are visible and draw only those cells. In a traditional top-down tile game, where the camera is not rotated around the Up Axis by 45 degrees, this task is trivial, since the visible area will just be a sub-rectangle of the world cell array. In an isometric game, though, the visible shape is different. The following graphic shows a map with a possible visible area highlighted.
In this graphic, the rectangle of the screen view is represented by the grey rectangle; all cells that the view rectangle intersects are highlighted in blue. You can see that the pattern is not a regular rectangle, but a much more complex shape. In order to render back to front, we need to render this complex shape row by row. Here is a graphic showing rendering order:
Here you can see some of the squares numbered. This is the drawing order. (The numbering should continue down the whole sequence, but I'm lazy.) If drawn in this order the cells should be drawn with basically correct overlap, as long as the constraints of the system are maintained, as far as object sorting, etc... There are probably going to still be a few edge-cases; this comes with the territory, and most of those can be handled through the collision system, by keeping objects far enough from walls and other solid things so that weird overlap doesn't occur.
Despite the seeming complexity of drawing rows like this, it's actually pretty easy. You can calculate the coordinates of the upper left corner of the chunk by reverse-projecting the upper-left corner of the viewport into World Space. You can calculate the number of columns and rows to draw similarly by dividing the size of the viewport by the on-screen size of a cell. Here, the Height of a cell comes into play, since you need to extend the viewport down to accommodate. You need to draw an arbitrary number of rows below the bottom edge of the viewport, to account for tall objects that might be visible on-screen even though the floor of the cell they lie in might not be. You also need to pad the top and sides by one cell in each direction to account for partially visible cells. But once you have constructed your suitably padded bounds, it becomes a simple matter of iterating the rows.
You start from the top row, which is an Even row. If the number of Nodes you need to draw across the screen (Screen Width / (NodeSize*4)) is NumNodes, then on Even rows you need to draw NumNodes+1 nodes, whereas on odd rows you draw NumNodes cells. (You can see in the image that the rows alternate in length). To advance along a row by one cell, you add 1 to cellx and subtract 1 from cell y. To advance to the next row from an even row, you add 1 to x; to advance to the next row from an odd row, you add 1 to y. And that's basically it. Here is an excerpt from the IsometricMap::draw() method, the part that renders all of the floors:
This excerpt does a number of things besides just drawing. A map has a specific center specified as an (x,y) coordinate. The center of the map corresponds to the point, in World Space, that aligns with the center of the screen. By changing the center, you can scroll the map. The drawing code presented takes the current View of the passed RenderWindow and uses the dimensions of it, as well as the map center, to calculate all of the parameters necessary for drawing. It also calculates the new Screen Space view necessary to draw the requisite piece of map, centering it upon the map center point. Once the view is set, the rows and columns of nodes in the visible chunk are iterated as described above. This iteration process is actually performed 2 times: once for the floor lists, and once for objects, roofs and walls. A variation of it is also performed before the drawing calls, in order to update the lighting of the visible region. And while we are on the topic of lighting...