— Simple 2D adventure game — Note: this tutorial assumes knowledge of C, as well as prior tutorials.
Introduction In this tutorial series, we'll look at creating a small adventure game, in which the player can explore a dungeon, interact with entities, collect items, and a bunch of other things. This game is loosely inspired by Adventure on the Atari 2600, although it will feature a multiscrolling environmnent, and more modern features. The plot of the game is that a prisoner (known as a "contestant") has been given the chance of earning his freedom if he can find and return 4 artefacts scattered throughout the dungeon. As there will be a lot to this, we'll be taking this tutorial slow (expect some long articles, too!). Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./adventure01 to run the code. You will see a window open like the one above, showing the prisoner on a tiled background. Use WASD to move around. Your movement is restricted to the bounds of the screen, and the prisoner will move from square to square, like in a rogue-like. Close the window to exit. Inspecting the code Again, as things like setting open SDL, opening a window, and working with a sprite atlas have been covered in previous tutorials we'll be skipping over them, and focusing on what it takes to make this game. Keep in mind also that since this game is going to be larger and more complicated than previous ones, our file structure for our code has changed. Rather than everything living in src, our files now live in subfolders. We'll continue to refer to them by filename, however: . |-- common.h |-- defs.h |-- game | |-- dungeon.c | |-- dungeon.h | |-- entities.c | |-- entities.h | |-- map.c | |-- map.h | |-- player.c | `-- player.h |-- json | |-- cJSON.c | `-- cJSON.h |-- main.c |-- main.h |-- structs.h `-- system |-- atlas.c |-- atlas.h |-- draw.c |-- draw.h |-- init.c |-- init.h |-- input.c |-- input.h |-- textures.c |-- textures.h |-- util.c `-- util.h We'll start by looking at structs.h, where we define our object structures:
The first thing we're defining is a map structure. This will hold a multidimensional array about our tile data, based on MAP_WIDTH and MAP_HEIGHT (as defined in defs.h). At this point, it's just an array of ints, nothing complicated. The next thing we're defining is an Entity. An entity in our game will anything that lives in the dungeon: the contestant, an object to pick up, a door, that sort of thing. The x and y coordinates will be location on the map, while the facing variable will say which way they are facing (for when moving left and right, for example). It will also hold a texture and a link to the next Entity. The last thing we declare is a Dungeon object. It will hold our entities, the map, and a SDL_Point (x + y coordinates) to help with the rendering. More on this in a bit. Let's now look at dungeon.c, which will be where our main drawing and logic for the game will be performed. We're once again using the logic and draw delegate approach:
Our initDungeon prepares things. It calls off to initMap, initEntities, and initPlayer. It then sets the dungeon render offset, based on the render size of the map. Since our screen size is 1280 x 720 and our tiles are 64 x 64, there will be space left aroun the map. To compensate for this, we center the rendering, so things don't look odd. Finally, we assign the logic and draw delegates to dungeon.c's logic and draw. The logic function itself is simple, it just calls doPlayer (defined in player.c):
Equally, our draw function calls drawMap and drawEntities, defined in map.c and entities.c, respectively:
Moving onto map.c, we'll see things are quite simple here right now. There are only handful of function. Starting with initMap:
This function calls off to loadTiles, to load our tile graphics, and also sets up the map data itself. Right now, we're just looping through our multidimensional array in our map data and setting every tile to TILE_GROUND. TILE_GROUND is set to 1 in defs.h:
Moving onto the loadTiles function, we're again doing something straightforward:
We're creating a for-loop from 1 to MAX_TILES (defined as 64), creating a filename using sprintf, and attempting to load the named AtlasImage. So, for example, the first tile image to look for will be "gfx/tiles/1.png", then "gfx/tiles/2.png", etc. We're also passing 0 to getAtlasImage, to tell the function that this image is optional. Our getAtlasImage will print an error and exit if we attempt to fetch an image that doesn't exist, but in this case we don't need all our tile images to exist. The reason for this is so that we can define the ground tiles (walkable one) as being numbered 1 - 25, and wall tiles defined as 26 - 64. This won't strictly be our setup later, but it should give you an idea of what we're going for. The next function in map.c is drawMap, which right now simply delegate to drawMapTiles:
Right now, the drawMapTiles function renders the entire map on screen, by looping through the x and y of the map data, and drawing the tile:
For each iteration of the inner loop (x) we're grabbing the value of dungeon.map.data and assign it to n (this is just to increase readability and isn't strictly required). We're then testing the value of n and only drawing it if it's value is greater than TILE_HOLE (0). Since tile 0 is never loaded and will always be NULL, we'll skip it by default. We could of course also test whether tiles[n] is NULL. Having determined that we want to render the tile, we call blitAtlasImage, passing over the AtlasImage from tiles[n] and the coordinates the render at. We also tell the blitAtlasImage not to center the image (0) and not to mirror it in any way (SDL_FLIP_NONE). entities.c is where we handle all our entities. The first function is initEntities:
Our entities will be handled as a linked list, the head of the list living in the Dungeon object. We're pointing the entity tail (the last entry in the list, to which new ones will be appended) to entityHead. We need to do this to ensure that when we create our first entity, the game won't crash. Speaking of creating entities, we have a function just for doing that:
This is a convenience function to malloc an Entity, append it to the Dungeon's entity tail, make the new one the tail, default the facing to FACING_LEFT, and return it. Doing this work here helps to keep everything in one place, rather than scattering mallocs all over the place when creating an entity. Drawing the entities themselves isn't too different from drawing the tiles, other than we're looping through our list of entities:
We're using the entity's x and y coordinates as their location on in the dungeon, and multiplying up the same way as we did with the tiles (including adding the render offset). One minor difference is that you're centering the entity around the tile (by adding half the TILE_SIZE to the x and y), and also horizontally flipping the entity's texture depending on whether they are facing left or right. Time to now talk about how we handle the prisoner contestant. player.c is where we'll deal with everything related to player controls. Starting from the top, we're declaring a variable called moveDelay.
This variable will be used to ensure that when we hold down a movement key we don't race from one end of the map to the other in the blink of an eye. Of course, we could cancel the key when it's pressed, but most people will prefer not to keep presssing WASD to move about. For setup, our initPlayer (as called by initDungeon) is a simple function:
We're calling spawnEntity, assigning it to the player variable (a global variable, like dungeon), setting the texture by fetching the AtlasImage to use, and setting moveDelay to 0, so we can move right away. Controlling the player is simple, and handled in doPlayer:
We only want to be able to move the player when moveDelay is 0. We therefore need to decrement the value of moveDelay each time doPlayer is called, limiting it at 0. The macro MAX will return the greater value between 0 and moveDelay less the deltaTime (more of on the delta time at the end of the tutorial). This macro will means that moveDelay will never go below zero. We then test if moveDelay is 0. If so, we're allowed to process movement controls. We'll test the standard WSAD control scheme to determine the direction we want to move, changing the player facing depending on whether we're moving left or right. To move the player, we're delegating to the movePlayer function, passing in the delta directions we want to go:
dx and dy will represent our delta x and y directions. So, if we're moving left, dx will be -1. If down, dy will be 1, etc. What this function when does is assign x and y variables the values of the player's x and y, plus the dx and dy we have specified. The x and y are then clamped to between 0 and the map's width and height, less one (remember: arrays are 0 indexed). Then, the x and y are assigned to the player's x and y. We don't have to create x and y variables here, it's just done to increase readability a bit. Finally, we set the moveDelay to 5, to stop the next movement from happening right away. If we wanted speed up the player's movement, we could reduce this value, or increase it to make them move slower. That's it for the game's main logic. Before we finish up this first part, let's look at some of the functions we're making use of, as well as that time delta stuff. To begin with, let's look at the getAtlasImage function:
If you've read the sprite atlas tutorial, you'll recognise this function. It looks up an AtlasImage by name. As we can see, the required variable will cause the function to call exit(1) if the AtlasImage isn't found. Many will not like to employ such a brutal method of ensuring that necessary data exists, but this can be useful during the development phase. Perhaps wrap it in a #ifdef block if it isn't to your taste. The blitAtlasImage is, again, like the one from the sprite atlas tutorial, although we're passing over the SDL_RenderFlip variable, for use with SDL_RenderCopyEx:
Finally, let's look at main.c. This is where we are calculating our deltaTime:
Delta time and unlocked frame rates isn't something I tend to use; I prefer to lock at around 60fps using SDL_Delay(16) or an exact 60fps lock by using an accumulator with the remaining time (60fps in 16.6666666666667 frames per second). However, for this tutorial (and perhaps others in future), we'll experiment with it. Notice how we're always calling SDL_Delay(1). This is so that we're giving the CPU and GPU a chance to rest. You can see the difference by commenting out the line and checking your CPU usage with and without it. You'll notice that without the line, your CPU is doing a lot more work, for no reason. Another thing to take not of when using delta times is how we never allow our logic rate to exceed 1. This because we want our logic to always operate at 60fps, no matter what.
LOGIC_RATE is defined in defs.h as:
If we exceed our logic rate, undesirable side effects can occur, such as collision detection failing (objects skipping over one another and never overlapping). This is a way of avoiding our logic from becoming tied to our frame rate. The final function is doFPS. It's just for debugging and hasn't been employed just yet.
That's the first part of our tutorial done. Mostly setup and baby steps. The following parts will be more exciting! Purchase The source code for all parts of this tutorial (including assets) is available for purchase: From itch.io It is also available as part of the SDL2 tutorial bundle: | |
Desktop site |