— Making a 2D split screen game — Note: this tutorial assumes knowledge of C, as well as prior tutorials.
Introduction Our game is missing something for the players to aim towards; there is no real point to it, other than to play around, collect pods, and shoot down the other player. This part changes all that, with goals being randomly set (for now). Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./versus11 to run the code. You will see a window open like the one above, with each player on either side of our zone. Use the default controls (or, at your option, copy the config.json file from a previous tutorial, to use that - remember to exit the game before copying the replacement file). Play the game as normal. Goals will be randomly set, to aim towards. Either reaching a target score, wiping out all the opponent's lives, or earning the most points within a set time limit (or a combination of all of the above!). Once the match is concluded there is no way to continue, so once you're finished, close the window to exit. Inspecting the code As stated, this part will randomly produce goals for the players to aim towards. In our full game, the players will be able to choose what the match should consist of (there won't be an option to make things truly random, as it can be too wild and unpredictable). Adding in our logic for goals is very easy, as all we need do is check who has reached the target score first, for example. Over, as ever, to defs.h to start with:
The new define ZONE_UNLIMITED_TARGET is used to specify that the goal doesn't have a limit. So, if we're applying this to our lives, we'll have unlimited lives. We'll see this more in use in zone.c, later on. Speaking of lives, let's move over to structs.h, where we've updated Player:
We've now introduced a variable called `lives`, that will hold the number of lives the player has remaining (not including their current life). Zone has also been updated:
We've added in a variable called timeRemaining. This will hold the remaining time in our game. A long while back, we introduced the Game struct, that contains lots of information about our game state. This was so that we could create a config.json file that could be ported across all the tutorial parts, so that one need not constantly fiddle with the settings. We'll have a look at this struct now:
zoneNum is the number of the zone we're in (reserved for later use). livesLimit the number of lives the Players have. scoreLimit is the target score. If this score is reached by either player, the match will end. timeLimit is our time limit for the match. After the time has expired, the player with the highest score will win. `aliens` is whether the zone will include alien creatures, that can get in the way of things (also for future use). Finally, `wins` in the total number of wins the Player has achieved so far. Now, over to zone.c, where the bulk of the changes for this part have taken place. Starting with initZone:
We currently have no way of configuring our game setup, so for now we're randomly creating the win conditions. We start by setting Game's livesLimit, scoreLimit, and timeLimit to be unlimited, and then giving each a 50/50 chance of having an actual value. At the end, if everything is still unlimited, we're setting to scoreLimit to be 1000, so that the players don't buzz around without anything to do (in our full game, this will actually be a valid choice by the player!). With that done, we set Zone's timeRemaining according to Game's timeLimit (which is in minutes). We then set a variable called gameOverTimer to 4 seconds. This is a special counter that will kick in once the game's target is met, and the game is over. As we'll see, it adds in a small delay after victory, so the match doesn't end immediately. We then set a variable called `winner` to NULL. `winner` is a pointer to the Player that won the match. Finally, we set a control variable called `state` to ZS_IN_PROGRESS, to say that our game in currently in progress. Our ZS (Zone State) enums are stored in zone.c itself, since they are only used there. We currently have just two: ZS_IN_PROGRESS and ZS_GAME_OVER, to represent a game in progress, and the game being over. Now over to `logic`, where we've made some larger changes:
Before, we were simply calling all our doEntities, doPods, etc. functions. Now, we're doing a bit more. To begin with, we're storing the value of our App's deltaTime, since we may be manipulating it. Next, we test the current `state`. If we're in ZS_IN_PROGRESS, we'll want to call respawnEntities and checkGoal, to see if any of our goals have been met. We'll see checkGoals in a bit. We don't want to do either of these two things if the game is over. Next, if we're in ZS_GAME_OVER (the game is over), we'll decrease the value of gameOverTime. Notice the next part - we're testing if gameOverTimer is greater than 1 second, and if so we're quartering App's deltaTime. This leads to a nice "slow down" effect when the game is won in certain ways. We need to store the value of the delta so that we can restore it later on. With that done, we're testing if gameOverTimer is greater than negative than two seconds, and calling all our game processing functions. As you'll see later, we're using 0 as gameOverTimer's base value for various other things. Finally, we restore App's deltaTime to the stored value (oldDeltaTime). Notice that we do this after all our game processing is complete, so that we don't mess up anything that relies on the delta time elsewhere. Now on to checkGoal:
As we saw, this is invoked only if the game is in ZS_IN_PROGRESS state. This is a very simple function to understand. We test each of our limits, to see if they're not in unlimited state, and react accordingly. For our time limit, we decrease the value of Zone's timeRemaining, and call a function named gameOver if it hits 0 (notice that we're also setting gameOverTimer to 0 immediately, to avoid the slowdown effect, and end things sooner). For the others, we're checking if either player has run out of lives, or if they have hit the score limit. Let's look at the gameOver function itself now:
We're taking a bit of care here, to ensure that we're still in ZS_IN_PROGRESS state, before determining the winner. There are a number of steps here we're using to determine our winner. First, if we determine one player has run out of lives, the opponent wins. If this isn't the case, we check which player has the higher score. If this is a tie, we finally check which player has more lives remaining. If, after this, we have a winner, we'll increase the number of wins for that player, in Game. Finally, we set state to ZS_GAME_OVER, so we only determine the winner once. The match is considered a draw is `winner` is NULL. That's all our logic handled, so now we can look at the rendering changes. To start with, we've updated `draw`:
We're calling a new function here named drawGameOver, passing over the number of the player (`i`). drawGameOver is another easy to understand function:
This function is basically responsible for drawing the "WINNER" / "LOSER / "DRAW" text that comes up on the screen when the game ends. We test first if gameOverTimer is 0 or less, and then determine what text to draw. If the current player (indexed by `i` in Zone's `players`) is the winner, we'll prepare to print WINNER. If this player isn't the winner (and `winner` isn't NULL), then we'll darken the screen and prepare to print LOSER!. If winner is NULL, we'll consider the match a draw, and prepare to print DRAW. We'll then print the text we prepared, as well as the total number of wins, just beneath it. Note that when displaying our WINNER text, we're doing so with a rainbow effect. This is handled in text.c and draw.c. That's all for zone.c. As I said earlier, this is where the bulk of the changes have been made. We've made some misc. changes elsewhere, that we'll look at next. First, over to player.c, where we've updated initPlayer:
Now, when setting up a Player, if we're not playing a match with unlimited lives, we'll set their lives to the number of lives in Game's livesLimit, less one (consumed upon creation) A change to takeDamage follows:
Now, as well as flagging the player as `dead` if their `health` hits 0, we're testing if the `attacker` is the other player. If so, we're increasing the opponent's `score` by 100 points. Next, over to `die`:
Whenever the `die` function is called for the player, we'll deducted one of their remaining lives, if, again, this isn't a match with unlimited lives. That's it for player.c. To finish off, we'll jump over to hud.c, where we've made some changes to display the game limits. To begin with, we've updated drawHUD:
We're calling a new function named drawLimits:
This function is responsible for drawing our time limit and our score limit. We check first if there is a time limit, and then prepare the minutes (`m`) and seconds (`s`) remaining, using sprintf to format them into a char buffer called `text`. By default, we'll be rendering the time limit in white, at a small text size. If there is only 10 seconds left on the clock, however, we'll increase the size of the text, and render it in red every half a second. This will draw the player's attention to the fact that time is running out. We render the score limit just below this (always in white, always at the same size, and vertically adjusted based on the size of the time limit text). Lastly, we've made an update to drawBottomBars:
For both players, we're now rendering their `lives` (if applicable to the match), and their `score`. There we go! We now have a match that can be played and completed by the players. We still need to establish a proper game loop, so the next match can start after this one is concluded, but otherwise all the pieces are in place. Now, a little while back I made a comment about some readers might've become aware of "trouble brewing". Ths is due to the fact that we're doing many, many more collision checks than we need. For example, when bullets are fired, they are testing every triangle of the Zone, as well as every entity. The entities are doing likewise. While this is a simple game and most modern computers won't be stretched by it, the number of collision checks can be greatly reduced. So, in the next part, we're going to look at adding in a spatial grid, to solve this problem. Purchase The source code for all parts of this tutorial (including assets) is available for purchase, as part of the SDL2 tutorials bundle: From itch.io | |
Desktop site |