• 2D shoot 'em up
SDL2 Santa game tutorial 🎅
SDL2 Shooter 3 tutorial
The Legend of Edgar 1.36
SDL2 map editor tutorial [UPDATED]
TBFTSS: The Pandoran War - Amiga OS4 Port
— A simple turn-based strategy game —
Up until now, we've been playing the game on a small, rather badly laid out map. In this part, we're going to generate one that is a bit more pleasing to explore.
Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./sdl2TBS15 to run the code. You will see a window open like the one above, showing three wizards in a maze-like map, as well as a number of ghosts. Play the game as normal. Notice how the layout of the map changes each time, but always has a vast area in which the player can explore. Once you're finished, close the window to exit.
Inspecting the code
Our map is going to be generated using a technique known as cellular automata. This is a popular method of map generation, that typically results in a cave-like output. This is different to the drunk-walk technique that we used in SDL2 Rogue. Due to the nature of the generation approach, not all of the map will be accessible. Therefore, we'll be employing several additional steps, to ensure that the player isn't boxed away from the main map.
Starting with defs.h:
We've just increased the size of the map. We could make this even larger if we desired, but keep in mind that it would lead to repetiton quite quickly.
Now, moving over to mapGen.c. This is where the main focus on this part will be. Let's start with generateMap:
This function is called my initStage and is responsible for our map generation. We're calling doCellularAutomata in a while-loop, that depends on the outcome of verifyCapacity in order to exit. The reason for this is because the map generated by doCellularAutomata might not meet our criteria; verifyCapacity will test to see if the map allows for a certain level of exploration, based on a start point, and if not it will reject the map. Finally, `decorate` will be called to update the walls, to make them appear a little less flat.
Coming first to doCellularAutomata:
This function is our main cellular automata step. It can be considered to be split into three sections. We'll go through these one at a time.
First, we're mallocing an array of MapTiles of MAP_WIDTH length (as `tmp`). We're then entering into a for-loop, and, for each row, allocating an array of MapTiles of length MAP_HEIGHT to each row index. In short, we're creating another set of map data, as temporary storage. With that done, there's a 40% chance that we're going to set the value of the MapTile at the current x/y index to 1. So, 40% of our map will have a tile value of 1, the rest 0.
The next step is the smooth step. Right now, our map is a random scattering of 0s and 1s. We want to erode and replace some of these values, keeping only those who meet certain criteria. Basically, we're going to loop through each value in our temporary map and count the number of surrounding tiles with a value of 1 (via countNeighbours). The result is assigned to a variable called `n`. If the value of `n` is greater than 3, we're going to set the value of Stage's map as the same index as `tmp` to 1. By default, the value is 0.
Something important now - we're evaluating the temporary map and updating Stage's map! It's important not to update the map we're testing as we go along, as this will lead to unexpected results. In short, the data we're testing would be changing under our feet, and we need it to be atomic, hence the reason we're working on a copy.
Once we've been through `tmp`'s values and updated Stage's map's value, we then setup another couple of for-loops to copy Stage's map data into `tmp`'s map data. The reason for this is because we're going to be performing this smoothing step 3 times, and so we need to copy all the changes from Stage's map back into `tmp`, so at the commencement of the next loop we're working with up-to-date data.
With that done, we're freeing all of `tmp`'s data, to ensure we don't leak memory. At the end of this function, Stage's map will contain a map with a cave-like appearance. However, there is a problem - part of the map will be alcoves that will be cut off from the main body. We don't want this, as it means that game elements could be randomly inserted there and be inaccessible. Bad if it's a ghost, even worse if it's where the player's party starts. We'll see how we overcome this in a moment.
First, let's look at countNeighbours:
Quite straightforward. We're passing over the temporary map data (`data`), and the location of the map that we want to count the surrounding tiles from (`mx` and `my`). We do so by setting a variable called `n` to 0. This will represent the number of neighbours. We then use two for-loops on the `x` and `y` (-1 to 1, inclusive), to check the surrounding tiles. We ignore the centre tile (the one we're counting the neighbours for). We check the value of the surrounding tile, and if it's 1, we'll increment the value of `n`. Finally, we'll return `n`, which will be the count of the number of surrounding tiles with a value of 1.
So, if a tile has 4 or more neighbours with a value of 1, it will become or remain a 1 in our Stage's map data. Otherwise, it will become a 0. So, map tiles not connected to many others will be destroyed, and holes will be plugged. This is somewhat like Conway's Game of Life.
Now, let's turn to verifyCapacity:
The idea behind this function is to ensure that we have enough connected ground tiles (seen as 1s in the map data), to provide a map that can be navigated. Inaccessible areas will be sealed off. This is achieved by flood filling random areas of the map, and counting how many tiles were affected.
We start by setting a variable called `attempts` to 0. This will control the number of attempts we've made to fill a suitably sized location of the map, at random. Next, we enter into a while-loop, where we randomly select an array of the map (`x` and `y`), checking that it's a MapTile with value 1, and then call a function named floodFill. We'll see more on this in a bit, but basically it attempts to convert tiles of value 1 to value 2. The function returns the number of tiles that it filled. With this, we work out the percentage cover of the map (as a value between 0 and 1) and test whether it met the percentage we need to consider the map usable (REQUIRED_FLOOD_FILL_PERCENT). If not, we'll test to see if we've exceeded our number of fill attempts, and exit the function by returning 0. Otherwise, we'll call floodFill again, to flip our 2s back to 1s.
If our flood fill criteria is met, we'll loop through our map data and convert all the map tile with a value of 2 into TILE_GROUND. Everything else will have a value of TILE_WALL.
So, in summary - we're randomly picking a spot on the map, flood filling it to see how much of it is connected to the rest of the map, and converting the tiles to floors and walls, to turn it into a proper map.
Now let's look at floodFill:
We'll not linger on this, as it's basically just an implementation of a flood fill, using a queue. The function takes four parameters: `x` and `y`, the starting location on Stage's map data; and `from` and `to`, the tile value we want to convert from and to (example, 1 and 2). Our flood fill is achieved by using a queue (fillTail and fillHead), in a similar fashion to evaluating our units' move range.
The queue is appended to using a function named addFillNode:
Again, this function is quite similar to the one we use in units.c. Basically, we're testing that the map location we wish to add to our queue (`x` and `y`) lies within the map and is of the tile type we want to convert (`from`). We next test that the node doesn't already exist in our queue, and add it as needed.
The last function is `decorate`:
This function is merely responsible for updating some of the wall and ground tiles to use different images, depending on what they are close to. We loop through all whole map, searching for tiles and their neighbours that fit the if-statements, and update the tile value accordingly. For example, we're changing a ground tile that neighbours a wall to use the image that has a shadow, to give some depth. Not a lot more to say here, as decorating the map is mainly about styling.
That's our map generation done! As you can see, we're simply implementing a cellular automata function, and then cleaning things up, to make it usable. With that done, there are a couple of other things we need to address.
Heading over to player.c, we've updated addPlayerUnits:
Since our map now contains many more walls, we need to ensure that our starting location lies within an open area. We do this by first setting up a while-loop to look for a starting location on the map (as `mx` and `my`) that that the mages can stand on (isGround). We keep updating `mx` and `my`, and testing the location until we find a valid point. `mx` and `my` will be the point around which we'll add our units. Now, when setting the player unit's `x` and `y`, we'll take `mx` and `my`, and randomly add a value between -3 and +3. This basically gives us a radius to work with.
Finally we've updated ghosts.c:
We've updated the unit's ai type to AI_PASSIVE, so our white ghosts are once again passive spirits, who will wander the map and not react to the mages, at all.
That's our map generation done! We've now got something more interesting to work with. Of course, there is plenty more that could be done with our map generation to make things even better, but what we have here is enough for now and a great starting point.
Speaking of interesting things, how about we add in MapTiles that the ghosts can move across, but the player cannot? A hazard, if you will. In the next part, we're going to look at adding in slime tiles. These tiles will block the mage's movement, but allow the ghosts free passage (as they can just float over the top). However, the mages won't be at a complete loss, as they will be able to target the slime tiles and destroy them!
The source code for all parts of this tutorial (including assets) is available for purchase:
It is also available as part of the SDL2 tutorial bundle: