-
Notifications
You must be signed in to change notification settings - Fork 0
4. The Game Loop
NOTE: this section assumes you have read the previous section on the Title Screen as said section lays out important information about memory allocation, memory copying, as well as drawing to the screen.
So we've drawn the title. I chose this first because I think we should give the users something pretty to look at before doing anything else. But now that they have been prompted to press start, we can go about actually receiving that input.
Just after drawTitle(), we set up a couple variables. The first is simple enough, it gets set to false when the actual games starts, and true when the game is over.
xLastWakeTime is a little more complex. It contains the current number of ticks since execution start and we'll be going into more detail on what that means later.
With that taken care of, we launch into an infinite loop with no break point. This is because from here on, the only thing the game needs to do while power is on is display a menu for game settings, start the game with those settings, and then display the title again once the game is over.
This is followed by an inner infinite loop wherein the joystick state is read then evaluated to see if the start button is pressed. If it is pressed, we will break out of the inner loop (after debouncing the start input so as to not immediately select something on the next screen).
When start is not pressed, we delay for 2 ticks and this is where xLastWakeTime comes in.
Ticks are an abstraction representing configurable increments of time in FreeRTOS. For this project we've (attmepted to) configure FreeRTOS tick rate at 120hz which means there will be 120 ticks of the os per second (assuming nothing runs over its time allotment which I wont be covering here). For this game we are targeting a frame rate of 60 FPS (which is technically possible with the use of selective update draws instead of drawing the whole screen). To achieve that, we have the game delay execution at the end of every infinite loop. This is important not only for steady framerate but because it also grants time for other FreeRTOS tasks like the battery monitor to run.
When we call vTaskDelayUntil, xLastWakeTime is evaluated. If two ticks have already elapsed since the previous delay, no delay is taken. Otherwise the delay occurs until the 2 ticks have elapsed. Either way, xLastWakeTime is updated to the current tick count of the task during the call to vTaskDelayUntil.
The total effect is that the title screen is displayed until the user presses start or powers off the console.
So assume now we have pressed start and escaped that inner loop. The game then calls drawMenu() which I will not be covering since it is very similar to drawTitle() but you are welcome to examine its code in screen.c. Once the menu is on display, we initialize the selection value and flash timer to 0 before entering another infinite input loop (with break point).
During this loop we again evaluate the input state but this time we check additional inputs including directional inputs which are used to update the level selection. If buttons A, B, or Start are pressed, the loop and menu exit. Otherwise, the flash timer is checked and incremented. We use the flash timer to blank the current selection in 8 frame intervals to give it a flashing appearance. Finally there is another call to vTaskDelayUntil, covered earlier.
Now that we've confirmed our settings in the menu, the first order of business is to set bGameOver to false. We're ready to start the actual game! We initialize our gameplay variables like level, score, lines, speed, and the speed counter before calling drawGameScreen(). This is the last time we draw to the entire screen until a game over occurs. We also use memset to zero out the 2d array representing the play field as it is faster and simpler than a loop or memcpy (more on the play field can be found in Appendix A).
We draw the first piece which will appear half off the top of the screen (currentPiece is initialized at startup and will always be a valid value). We call the RNG function to pick the next piece and draw it in the preview window then call helper methods to draw the values of the score, level, and line displays. With the setup done, the we enter the heart of the game loop with while(!bGameOver).
The first thing that occurs in this loop is checking the state of the gamepad. This time we do not use the debounce method. Rather we keep track of the held state then use (or reset) shift counters which will move the tetromino when a directional input has been held for a certain number of frames. This set of ifs only update whether something has been released except the last case. You can see here, when down is released, we 0 out the Y shift counter as well as the bonus the player gets for holding down until the piece is locked. (More on shift counters later)
The if block for checking the Start input implements pause functionality which I wont cover in depth. It clears the playField and the nextPiece sections of the screen so the player cant sit and think about their next move. It then stops gameplay until start is pressed again then redraws the cleared screen sections.
After the pause logic, we evaluate and set a boolean for forceDown; has enough time elapsed that the piece should "fall" on it own?
We then evaluate the down input and this is where we get into shift counters. If we responded to player input immediately after it was made, the game would be pretty much uncontrollable. "Twitchy" is the word that comes to mind as you would have to be supernaturally quick at both making and releasing your inputs. Debouncing until the input is no longer held (as in the debounce() method) is one way to solve this but would break the game. Using shift counters give much needed cushion to the players inputs so that the game has a good feel and does so without halting the programs execution.
In the case of our vertical shift counter, we check if down was also held last frame. If not, we set the shift counter repeatY to 1. If down was held last frame, we increment the shift counter then evaluate it. If it is 3 after incrementing, forceDown is set to true (same as when it "falls") and the shift counter is set back to 1.
Similar logic is applied to the horizontal inputs in the following else ifs with the exception that we want the piece to shift on the first frame of input and repeat shifting only after enough frames have elapsed. Said else-if blocks have their own shift counter but they also have their movements immediately applied to them (more on why that is later). The game first attempts to move the current piece in the desired direction then runs collision detection on that new position.
Collision detection is how the game engine determines whether the place we are trying to move a piece to is valid or not. I won't be going in depth on how we do this until a later section. Just know it checks the 4x4 space of blocks in the playField that we are trying to move the currentPiece into then returns true if a collision occurs meaning that position is invalid and we cannot move the piece into it. When that is the case for horizontal directions, we revert the change to currentX so that no move occurs. If there is no collision, the update to currentX is valid, and we redraw the whole playField (there's room for optimization here) followed by drawing the new position of current piece. Remember draws happen on top of each other but don't effect other parts of the screen so if we didn't redraw at least a portion of the playField, the currentPiece would appear to still be in its previous position in addition to its new position.
Following the shift logic is rotation logic which is similar to shifting aside from a few exceptions. Rotations never repeat when held so they don't need shift counters, we just have to ensure the button wasn't held in the previous frame before we rotate. We store rotation as an unsigned integer then increment it for clockwise and decrement for counter-clockwise. The 8-bit unsigned integer currentRotation stores that value but is always modulo divided by 4 as there are only 4 rotations. We dont care about overflow and underflow because they dont cause variation in the expected behavior. It will always act as a counter going 0 - 3 then back to 0 again because 255 % 4 = 3 (google "unsigned integer overflow" and "uint8 max" if that didnt make sense).
Now that we've evaluated and acted on all other inputs, it's time to move the piece down if applicable. Unlike other piece movements, this is the movement responsible for locking the piece into place. By moving down (and locking the piece) last, we allow the player to move or rotate a piece into place even when it appears to have "landed", giving it that classic "sliding" effect and creating opportunities for skillful last second maneuvers. We previously mentioned the boolean forceDown. If the player has held the down button for enough frames, or enough frames have elapsed that the piece should drop on its own, this boolean will be set to true.
At a high level, if the current piece is supposed to move down but cannot because of a collision, the piece is then locked into its current position on the playField and the lines and score variables are updated. When a lock occurs, we check if any lines have been completed. This occurs when row of the playField becomes full with blocks. At that point, it dissappears and adds a value to the score. Up to 4 rows can be cleared simultaneously rewarding the player with higher scores than clearing lines individually. Determining how many rows have been cleared is very similar to our collision detection so I will be going in depth on it in that section. Just know instead of checking for overlap in a 4x4 space of the playfield, we are now checking the 4x10 swath of playfield current piece was just locked into and seeing how many rows are fully occupied. If there are any cleared rows, we remove them from the playField shifting higher rows down as we go (and playing a nice little animation as well).
After the lock and clearing logic, next piece then attempts to spawn. If it has a collision in the spawning position at the top row of playField, the game is over.
Regardless of whether the piece dropped or locked, we need to redraw the field and current piece and we do that just after the block of collision logic. At this point the only thing left to do is delay until the next frame. In the case of game overs, we halt the gameplay and go back to the title. This could be more elegant but I haven't gotten around to programming a game over animation.
If you managed to stick with me this far, you should have a rough idea on how to go about programming games for the GO. I really hope this helps and inspires others. If you'd like more info on collisions and the play field, check out Appendix A. where I cover them in depth.