— Creating an in-game achievement system — Note: this tutorial assumes knowledge of C, as well as prior tutorials.
Introduction A number of gaming platforms these days, such as PlayStation, Xbox, and Steam support achievements; a system in which players are rewarded for carrying out specific tasks in a game, such as completing a level within a set time, defeating a certain number of enemies, finding a bunch of hidden objects, etc. In this tutorial, we're going to look at how to add such a system to a game, including how to upload the progress online, so it can be viewed outside of the game. This first part will cover everything that is needed to get our system up and running. Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./medals01 to run the code. You will see a window open like the one above, showing some text on a black screen. Press 1 to earn a bronze medal, Return to earn a silver medal, and press Space three times to earn a gold medal. Once you've earned all other medals, you will be automatically awarded the ruby medal. Close the window to exit. Inspecting the code We'll start by looking at defs.h, where all our defines live:
We have an enum here to hold all our medals. We have 4 medals (with MEDAL_MAX existing to help with our array sizes). We also have an enum for our sounds:
Right now, the only sound we're playing is the one for when a medal is earned, which we're calling SND_MEDAL. Again, SND_MAX is to help with our array sizes. Next, let's look at structs.h, starting with the Medal struct:
The Medal struct holds all the details of our medal. `id` is the internal id of the medal (such as "key1"). `title` is the title of the medal, such as "First!". `description` is the description of the medal, such as "Pressed 1". `type` is the type of medal this is, using a value from the enums in defs.h. `notify` is a field to say whether this medal should notify the player. We'll see this in detail later. `hidden` is a flag to say whether the medal is hidden. You may have seen this before. It's useful for preventing story points in games from being spoiled if a player browses the achievements list before reaching that part of the game. awardDate is the date at which the medal was earned, in milliseconds from the epoch. Our Medal is a linked list, and therefore has a `next` pointer. Next, we have a Game struct, to hold our game information:
Right now, it has one field - medalsHead, which will be used to hold the medal information, as a linked list. That's defs.h and structs.h done, so now we can move on to how we're handling our Medals. All our logic and rendering for our Medals lives in a file called medals.c. There are quite a number of functions to look at, as a result, so we'll work through them all one at a time. Starting with initMedals:
We're first making a call to a function named loadMedals. We'll see this later. Next, we're loading our four medal textures, assigning each to the appropriate index in an AtlasImage array called medalTextures (static in medals.c, of length MEDAL_MAX). With those loaded, we set a variable named notifyOrder to 1. This variable is being used to track the order in which medals are awarded for the current session, since it's possible for multiple medals to be unlocked at the same instant. We'll see this being used in a moment. Finally, we call another function named resetAlert. Again, we'll see this in action later. The next function is awardMedal. This is the key function we call when the player unlocks a Medal (such as pressing Space three times):
This function takes a single argument - the id of the Medal we want to unlock (as `id`). We start by assigning two variables named hasRuby and numRemaining to 0. hasRuby will be used to track whether we have a Ruby Medal available, so that we can award it upon unlocking all the other medals. numRemaining tracks the number of Medals that we have yet to unlock. We then begin looping through our list of medals, assigning each to a variable called `m`. We test to see if `m`'s awardDate is 0 and also whether its `id` is the same as the id passed into the function. If a Medal's awardDate is 0, it is currently unearned. Testing to see if the medal's awardDate is 0 prevents us from unlocking it multiple times. Upon finding the Medal to unlock, we set `m`'s awardDate to the current time, by calling time(NULL), and also set its `notify` value to the current value of notifyOrder. We also increment the value of notifyOrder as we do so. This means that each time a medal is unlocked, the unlock order can be preserved. We then test the medal's awardDate value. If it's 0, we'll increment numRemaining, since we still have medals left to award. We're also test to see if the `id` of the medal is "ruby". Much like the platinum trophy on the PlayStation, our Ruby Medal will only be unlocked once we've unlocked all other medals. Having processed our medals and awarded a medal if approrpriate, we finally check to see if we want to award the Ruby Medal. The Ruby Medal will always be the final medal we award, so we test to see if we only have one medal remaining, and also whether there is a Ruby medal to award. If we don't check to see if there is a Ruby medal to award, we run the risk of a stack overflow as our game keeps trying to unlock a Ruby medal that doesn't exist. The next function is doMedalAlerts:
This function is responsible for either selecting the next medal alert to show or displaying the current one. We start by testing if alertMedal is NULL. If so, we'll call nextAlert (we'll see more on this next). If it's not NULL, we'll want to slide it in from the right-hand side of the screen. We start by subtracting 24 from alertRect's `x`, using the MAX macro to limit it to the screen's width, less the width (w) of alertRect. In other words, we'll be decreasing the value of `x`, but not so much that the alert fully leaves the right-hand edge of the screen. We also decrease the value of alertTimer. Once its value has reached 0 or less, we'll set alertMedal's `notify` to 0 and call resetAlert. This is basically the timer for how long the alert will be displayed before it vanishes from the screen. Setting the Medal's `notify` to 0 means that it is no longer a candidate for displaying. The next function is nextAlert:
This is another essential function, and one that looks for the next Medal to be displayed. We start by looping through all our Medals (assigned to a variable called `m`), and looking for any that have a notify value of greater than 0. If we find one, we'll want to compare its `notify` value to the current alertMedal's `notify`. If alertMedal is NULL or alertMedal's `notify` value is greater than `m`'s, we'll assign alertMedal to `m`. Basically, we're looking for a Medal with the lowest non-zero notify value to display next. We'll then check if alertMedal is not NULL, meaning we've got a Medal to display. We'll play a sound, then use calcTextDimensions with the Medal's `title`, to work out the length of the alert box. Note that we're assuming the length of the title here will be sensible, as we're not limiting it in any way; a long title could therefore produce a very long box. We're then adding 125 to `w` (the width of the box) for padding. We're then checking which is greater: `w` or alertRect's `w` (that is defaulted to a value of 475 in resetAlert). The idea behind this is that we want to ensure the alert is at least 475 pixels wide; a short title would make things look a bit odd. Next, we're going to add in the medal description to the alert. As the description could be longer than the title and overflow the box, we're going to add in one character at a time and test the length of the text we want to produce, until we either add in all the text or we reach the edge of the alert box. To start with, we're memsetting a variable called abbrevDescription (a char array of length MAX_DESCRIPTION_LENGTH + 4), into which we'll be copying our text. We're then using strlen to get the length of the Medal's description before then using a for-loop to iterate over all the characters. For each iteration of the loop, we're using calcTextDimensions to see if the width of abbrevDescription (assigned to a variable called `w`) is less than alertRect's `w` (its width). If so, we're using strncat to append the single character at description's `i` position to abbrevDescription. If it doesn't fit, we're going to use strcat to append an ellipsis (...) to the end of the string, and then call break to exit the loop. This will ultimately mean that abbrevDescription will contain the full description or an abbreviated one (hence the name). Long descriptions will be neatly clipped and not overflow our alert box. Next, we have resetAlert. It's a rather simple function:
All we're doing here is setting alertTimer to 3 seconds, setting the alertMedal to NULL, and then resetting the alertRect's `x`, `y`, `w`, and `h` values to SCREEN_WIDTH, 10, 475, and 90 respectively, to ensure the alert is drawn correctly. Setting alertRect's `x` to the value of SCREEN_WIDTH means it always start off at the right-hand side of the display. We now come to drawMedalAlert:
This function is what draws our alert. We first check to see if alertMedal is not NULL, meaning that we have a medal alert to display. We then call drawRect and drawOutlineRect, feeding in our alertRect's `x`, `y`, `w`, and `h`, to draw the alert's background (black) and frame (white). Next, we draw the medal's texture, according to its `type`. We do so by rendering the AtlasImage in the medalTextures array at the index of `type`; other medal images when loaded during initMedals align with the type of medal in our enum, so they match up nicely. Our rendered medal texture is aligned to the left-hand side of the alert panel. Next, we draw the alertMedal's `title` text. We draw it starting 90 pixels to the right of the left-hand edge, so that it doesn't render over the medal texture. After that, we reduce the font scale to 0.7 and render the text of abbrevDescription. This, we draw below the title. Notice something - as stated earlier, when rendering the abbreviate description we're drawing it at a scaled font size of 0.7. However, when we measured it for the abbreviation, we used a font scale of 1.0. This is done because it works a little better, and we don't have to get overly fussy with our edge calculations. That's all our medal logic and rendering done. The last thing we need to cover in the function is loading the medals themselves. loadMedals covers this:
A standard JSON data loading function. We're loading our JSON and then looping through the array of objects, creating a Medal for each one, and setting the fields. The created Medals are added to our Game's linked list. We're also testing if the "hidden" field exists in the JSON object. If so, we're setting the Medal's `hidden` value to 1. In our system, this field is optional, and its presence in the JSON means that the Medal will be flagged as hidden (regardless of the value in the JSON). (notice when it comes to getting the medal `type`, we're calling a function named lookup (see the lookup tutorial for more details on how this works). That's medals.c all covered. We can now look at the demo application we've built to make use of it. Starting with game.c, we have one simple function - initGame:
initGame simply memsets our `game` object (global to the application). We'll be adding more to this file in future. Moving on to demo.c now. This is where we handle all the logic and rendering of our application. The first function is initDemo:
We're setting a variable called spaceCount to 0. This variable will be used to count how many times we've pressed the Space bar. We're also setting app.delegate's `logic` and `draw` function pointers to the `logic` and `draw` functions of demo.c. Looking at `logic` next:
We're testing three keys. Firstly, we're testing if 1 has been pressed on the keyboard. If so, we're clearing the key, and then calling awardMedal, passing over "key1". This means that we'll be awarding a medal when the user presses 1. According to our Medals logic, if the medal with an id of "key1" hasn't been awarded yet, the alert will show. We're then testing to see if Return has been pressed. If so, we're calling awardMedal, this time passes over "keyReturn". Finally, we're testing if Space has been pressed. Notice that we're incrementing the value of spaceCount, testing if the value is 3 or greater, and then calling awardMedal, passing over "keySpace". So, we must press Space 3 times in order to unlock the keySpace medal. That's all that's needed in our logic to process our medal unlocking. Pretty simple, eh? The `draw` function follows:
This function is straightforward. All we're doing is looping through the game medals and printing out their `title` and `description` on the screen. We're defaulting the text to a light grey (setting the values of `r`, `g`, `b` to 128). We're then testing the medal's awardDate. If it's greater than 0 (meaning, its been unlocked), we're changing `r` and `g` to 255, and `b` to 0, so that we render the text in yellow. Just a couple more functions left to cover. Moving across to init.c, let's look at initGameSystem:
As well as our usual init calls, we're also calling initGame and initMedals, that we defined earlier. Finally, we come to main.c, and our main function:
It's in the main loop that we're calling doMedalAlerts and drawMedalAlert. The reason we're doing this here is because we always want to process and draw our medals, regardless of what happening in the application. This means, for example, that we don't need to worry about explicitly processing them on a title screen, during gameplay, and in other places. They'll always be processed and always be drawn, almost as if they are working at a system level. And there we have it. Our Medal system is more or less done. We can load medals, unlock them, and display alerts. In our next part, we'll look at how to integrate this into gameplay. 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 |