Introduction
This project is an attempt aimed at replicating 2 different ideas as a way of showing how applicable the Raspberry Pi is in a host of diverse activities and in the world of embedded systems. The game basically consists of a tank, classfied as the "hero", facing off other tanks to defend some sort of emblem object which can be destroyed. The enemy tank and the hero tanks shoot at each other as a form of offense. The game is lost when the hero's lives run our or the emblem is destroyed. It was meant to replicate:
- 1.An old 2d game
- 2.The use and feel of an arcade machine.
Design
The idea for implementing the tank game using python was first proposed by Kaili but we both agreed that for the project to be time-worthy it had to be improved upon as there were already some basic implementations of the game out there. The project evolved over several weeks to include levels, external game controllers, and powerups, as well as a health gauge.
We got to work using a very basic implementation of the game Kaili found.
The first big step was reverse engineering the game implementation. We painstakingly read through to understand how each function and class affected the game and how they were all tied in together. A main component of the implementation was the PyGame module, so we also had to understand the relations between the basic Pygame functions and the custom functions created.
The whole process also involved a lot of trial and error testing and messing with various functions, components and variables. In the folder containing the code we have, we have:
- main.py: The game is basically run from there. It creates an instance of the TankWar class and then calls upon the run_game function in the class.
- tank_war.py This file contains the logic of the game, including key detection, enemy movement and respawning etc.
- sprites.pyThis file contains the classes used to define various components used in the game as well as some functions for these components. The component classes are a subclass of a BaseSprite class which also inherits from the pygame Sprite class.
- settings.pyContains the map layout of the game, global variables and strings of the directories where images used to implement the game can be found.
Once we both understood the implementation, we decided to then work in parallel.This meant that there was no delineated process and some things belonging to the same criteria were developed before others.
Movement
Movement was basically implemented by shifting the image postion by a fixed amount when the button was pressed, in the case of the hero. The only continuously mobile components are the tanks, the hero and the enemy tasks. To mimic up, down, left and right turns we have 4 images that depict those different motions. Using keyboard input in the beginning, When movement is detected , a specific image is called and used in the game. The game is updated on each cycle with the image corresponding to the direction pressed. For the enemy tanks, their movements are determined by a function that calls randdom.randint(). the number returned is then used to access an array of strings with the paths of images of the enemy tank corresponding to left, right up and down images and then the game is updated.
Powerups
Shield
The first new implementation was adding a powerup. We started with a shield powerup represented by a star. An image had to be obtained for that and was added to our "resources" folder. It's directory was then added to settings. py where it could be called if needed. Next we created a class for it, as a subclass of BaseSprite to inherit its functions and attributes. We then had to implement logic to cause the star to appear randomly and in random positions as well as what to do when the hero tank came in contact with the star. Using pygame.sprite.collide_rect helped detect collision of the hero with the star object, as well as other objects like bullets, enemy tanks and the walls themselves. When the hero collides with the star object, it activates an attribute which makes the hero invulnerable, ie. the collision of an enemy bullet with the hero, fails to decrease the number of lives of the hero for about 10s. It also changes the picture of the hero tank to that of a shield in that duration. The sheld image is shown below:
Wall Invulnerability
With the same train of thought as the first powerup, we implemented the second powerup, in the form of a pickaxe image, to provide invulnerability to the walls within which the emblem object was hidden. Upon collding with the pickaxe object the wall around the emblem changes to a silvery color, depicting impenetrability. A slow system would actually give some insight into this. To give further context,we started to develop the game on our laptops to the point where we needed to transfer the development onto the RPi. The RPi is a robust system on its own, but one really sees the effect of loading the RPi. So when testing out the Invulnerability feature we had a loaded RPi, which ended up painting a picture of how this powerup is brought into effect. Upon collision with the pickup image one could see the walls disappear and reappear from the top all the way to the bottom, indicating that upon the acquisition of the powerup, we wrote code to redraw the whole game space, including the silver colored ones and leaving the normal ones out based on a pre-written map found in the settings.py which will be discussed later. The pickaxe image is shown below:
Dash
We implemented a dash function to cause the hero tank to go faster at the push of a button. This was done by adding a new state that controls the dash movement and manipulating the state of that which in turn manipulated how fast the tank went.
Health Gauge Indicator
As all games go, there needs to be some kind of way to determine how many lives the hero tank has left. As a result, we have to have graphical indicator. This kind of game required a definite number of lives rather than some continuous health indicator with a 100% gauge. So we picked an arbitrary image to represent a life, which ended up being an emoji icon bearing a frown. One of the images represented a life and the hero has 3 lives so we had to put out 3 of these emojis on the game. Over here we made a design choice. Rather than add the life indicators as some kind of Wall Sprite subclass, we added it as an entirely different component on its own to further give us more autonomy over what we chose to do with it. We then placed the image in the resource folder and added its path to settings.py. To display three emojis representing 3 lives we create a group and these emojis to them as components. The hero has a variable called self.life. When the hero is instantiated we draw the emojis on the left upper corner of the screen based on the number of lives defined by the hero's life variable. Removing them upon the loss of life was a bit tricky however. It required a custom update method to check if the number of elements in the group with the life components equal that of self.hero.life. If it didn't we would redraw the lives according to the current number found in the hero instance.
Score display
We decided to added a simple score display using pygame's text rendering function. First we added self.score to keep track of the score in each game. Upon collision of a bullet from the hero with an enemy, we increment the score by 5. With this one can gain more than 5 points per kill before the enemy object completely disappears because multiple bullets in succession can collide with the enemy tank before it's killed off and removed from the game. After the score is incremented, the score is updated and redrawn in the next frame.
Level Implementation
The arrangement of the game is based on an array containing a matrix of numbers, all located in settings. py. Settings.py further has variables named after the different types of walls used that correspond to the numbers used in the matrix. To draw up a game, the matrix array is looped through and further used to access another array of paths, where the name of the file corresponds to element of the array is accessed. Say for example, in settings.py, RED_WALL has a value of 0. In that case, 0 on the map corresponds to setting up a red wall. 0 is then placed as the key in the array of paths which when sorted, calls 0.png which is the image of the RED_WALL. That's how the game is arranged.
Based on this we decided to implement multiple levels. Each level would have a distinct map, ideally harder to navigate than the level below. Our means of moving to another level was if one could acquire a certain score. Using the concept of classes, we instantiated 3 Level Classes, one corresponding to each level. Upon meeting the score requirements, we use the run_game() functions of the higher levels to redraw the map, taking one to another level. We generated these maps both manually and by the use of a python script found online.
However we could not classify other arrangements as new levels yet without adding new features. In higher levels we increased the number of lives of enemy tanks by a specific number and also increased the number of enemies per level.
In Level 3, we added water to the arrangement such that both the hero and enemy could die by colliding with the water(although in retrospect, we probably should have done that with just the hero.)Some pictures of the wall types are shown below:
Sound
Furthermore using pygame.mixer, there are sounds attached to each hit, kill and explosion.The sound is played, then a time sleep is used to keep the sound playing after which it is then stopped.
Pause
Our implementation of Pause is attached to one of the PiTFT buttons, connected to GPIO 17. Upon Pausing , all the boolean attributes related to the movement of enemies, the hero and bullets is set to False, bringing the game to a standstill.
Start Screen
Every game(at least every gave we ever played) had some sort of start screen so we decided to create one too. On the issue of the RPi being applicable as an embedded system, we decided to let the start screen operate on the PiTFT rather than the monitor. Using pygame we built a start screen and incorporated a start and quit button. Using touch input the start button is selected, using subprocess to run main.py.
High Scores
Another feature we decided to implement was keeping track of high scores. At the end of every game, Two dialog boxes appear asking to choose a save directory and to input a player name. This opens a txt file calles scores and right both the name and the high score on a line, with a semi colon(;) separating them. On the start screen, we introduced another button with the test "Scores". Tapping on scores opens the same folder to which the scores were saved , splits them based on their commas and places them in array as tuples. They are also sorted and then the 3 highest scores are printed out on another page on the piTFT. There's also the addition of a back button to go back to the main menu.
Hardware and Control
As mentioned earlier on,we wanted to achieve an arcade game kind of feeling. So we decided on alternative means of controlling the hero, an arcade-style joystick and push buttons. The push buttons are three terminal switches. One COM terminal and two NC terminals. The COM terminal is connected to an unused GPIO and the NC is connected to ground. NC means normally closed meaning that the COM is connected to NC until toggled. The jostick is made of 4 switches that are pressed or activated depending on which direction the joystick is turned to. The switches have two terminals. 1 COM and 1 NC. For the pushbutton to shoot,we used an interrupt , executing the self.hero.shot() function. However, movement with the joystick and the dash function required a different methos. To move and to dash requires the manipulation of various states of the hero component. Hence there was the need to use if statements to execute them in order to change the necessary state variables and reset them to default when the button is not been pressed. Per the nature of the game we definitely did not have any problems with button bounces. In fact they were welcome. We always want to shoot the maximum number of bullets we can as well as keep moving in whatever directions we want to. The reaction to the release of the button was also greatly effective.
Testing
As a Game, testing mainly required playing it severally to check what did and what did not work. Testing was incremental. When testing the health indicator we first used print statements to check if the hero's life was actually decreasing as well as the number of life objects located in the group each time the hero died. This helped me figure out why the objects were not being drawn onto the screen. This kind of testing procedure was applicable to almost all components of the game:
- Test the initialisation of a component and its assignment to a variable
- Check the state of the variable and ensure it's being updated
- Test to make sure the correct object path is selected.
- Test to make sure it's being drawn correctly.
As mentioned earlier, most of the preliminary work was done on our individual machines. So we had to test in the Rpi after. That also brought about some difficulties which will be talked about in the Problems Section.
Problems
On our laptops, it seemed to work effortlessly but on the RPi we ran into a lot of problems. One of the major problems was the loading of the game images onto the screen. Wrong obejcts were placed on the game screen. To begin with we thought it was a relative path problem so we changed the paths to absolute paths. There was still no change until we discovered that in adding file paths to the wall array, the array was sorted in reverse order and since the map numbers were supposed to correspond to the paths in an ascending order, we had wrong images being used.
There was also an issue involving the start screen. Scores displayed on the piTFT properly. However there arose the problem of executing main.py on the monitor. Using the Start screen on the piTFT made it impossible to play the game on the monitor. To demo in the end we had to execute the start screen on the monitor as well.
There was also a minor problem in getting everything displayed on the screen and that was solved by making sure the matrix size of the maps corresponded with the screen size set
Other
As an embedded system, the project should be able to operate with some sort of independence. As such we used the crontab to schedule the start screen to run immediately after startup so the game can be played without the need to launch from desktop. We also assigned a shutdown button to avoid us from having to type commands in the terminal.
Conclusion
We were successfully able to implement the game and our improvements while getting it to work with external peripherals connecting it to the GPIO. The only thing we were not able to accomplish was starting up the game from the screen of the PiTFT.
Future Work
If we had more time to work on the project, we'd first make sute the start screen worked properly on the PiTFT, then design an actual holder for the joystick and pushbuttons. We'd also add more levels and increase the "intelligence" of the enemies to make the game harder.
Furthermore we'd randomly generate maps per level so players could have a new experience each time.
We also wanted to make the game startup and shutdown autonomous, ie the game shuts down at a particular time until booted up again
Parts
All parts were provided by the ECE 5725 staff. They include
- Raspberry Pi 4
- 2 PushButtons
- Arcade Style Joystick
- Breadboard
- PiTFT
Work Distribution
Kaili handled finding examples of code to use, as well as all the powerups plus the high score display. Schuyler handled the health gauge, the score saving and the connections of the pushbuttons and joystick. The work distribution was even and testing , as well as debugging was done together. We also worked on the website as well.
References
- We used previous code from most of the labs
- Basic Tank War Implementation
- Switch Terminals
- Read a File line by line