Learning Python Programming - Part 7.2 - Adding zombies to our game
Welcome to the 7.2nd part of an ongoing series, Learning Python!
Join our programming tutorial!
Let's just code something already!
(source)
Where did we leave off last time?
Last time we made our minimum viable product: a screen with a survivor that can move around on it.
Previous Post
What should we add next?
We can add zombies or bullets first, both are equally important, but I think it would be more fun to outrun zombies for now rather than shoot into nothingness. So let's make a zombie class.
What defines a zombie?
A zombie in the scope of our game will be a green circle that can move around the screen. The zombies will need to move toward the survivors, and will need to have health that can be taken away when they are shot. This adds up to a class definition, just like when we made the survivor class.
(source)
class Zombie:
colour = "Green"
def __init__(self,pos):
self.pos = pos
self.speed = random.randrange(MIN_ZOMBIE_SPEED, MAX_ZOMBIE_SPEED)
self.rad = random.randrange(MIN_ZOMBIE_RAD, MAX_ZOMBIE_RAD)
self.drawn = None
def draw(self, canvas):
self.drawn = canvas.create_oval(self.pos[0]-self.rad,self.pos[1]-self.rad,
self.pos[0]+self.rad,self.pos[1]+self.rad,
fill=self.colour)
We can literally copy and paste the draw method from the survivor class.
What are the random.randranges for?
Here we are using them to generate zombie diversity. Giving a random choice on size and speed will create any combination of big/small fast/slow zombies.
Why did we put the color outside of the __init__ method?
Because every zombie will be green, we put it outside the init method to make the variable default for all zombies. If we put in the __init__ method "self.colour = "Green"" it would provide the same results, but writing it this way shows that this variable is for every member of this class and most likely won't be changed.
We have some more constant variables in use here, so we need to define them at the top of our program.
MIN_ZOMBIE_SPEED = 1
MAX_ZOMBIE_SPEED = 10
MIN_ZOMBIE_RAD = 1
MAX_ZOMBIE_RAD = 10
ZOMBIE_POPULATION = 10
We are also using the random module, so we need to import it at the top of our code.
import random
Notice the similarities between the Survivor class and the Zombie class?
These similarities tell us that we can make a super class that will house the similar features. This is completely optional and if you don't have a super class in your code, it'll work just fine, but it's good coding practice to avoid rewriting the same code.
(source)
This picture could be a zombie, or a woman. This is what we are defining when we make the super class.
Making a super class:
To make a super class, all we have to do is make another class. Because both Survivors and Zombies are entities, I'll call the super-class Entity. You can name your super-class anything you want following the class naming rules. (rules outlined here)
class Entity:
def __init__(self, speed, pos, rad, colour):
self.speed = speed
self.pos = pos
self.rad = rad
self.colour = colour
self.drawn = None
def draw(self, canvas):
self.drawn = canvas.create_oval(self.pos[0]-self.rad,self.pos[1]-self.rad,
self.pos[0]+self.rad,self.pos[1]+self.rad,
fill=self.colour)
Those are the shared features for now. Because we have the shared features in a super-class, we can remove them from the other class definitions and add the inheritance.
From the Survivor class and the Zombie class we will remove the draw method and the drawn variable because they are completely redone in the Entity class. The next things to change are the speed, pos, rad, and colour. Because these aren't completely replaced in the super-class, we need to call our super class's __init__ function with these values. To do this we'll use the super() function in the sub class's __init__ function to get the super class, then call the __init__ function just like any other method call.
super().__init__(speed, pos, rad, colour)
In this function call, we need to either define those variables or replace the variable names with the appropriate values.
Replacing our variable names gives us a function call that looks like this: (for the Zombie class)
super().__init__(random.randrange(MIN_ZOMBIE_SPEED, MAX_ZOMBIE_SPEED), pos, random.randrange(MIN_ZOMBIE_RAD, MAX_ZOMBIE_RAD), "Green")
Our new classes look something like this:
class Entity:
def __init__(self, speed, pos, rad, colour):
self.speed = speed
self.pos = pos
self.rad = rad
self.colour = colour
self.drawn = None
def draw(self, canvas):
self.drawn = canvas.create_oval(self.pos[0]-self.rad,self.pos[1]-self.rad,
self.pos[0]+self.rad,self.pos[1]+self.rad,
fill=self.colour)
class Survivor(Entity):
def __init__(self, pos):
super().__init__(1, pos, 6, "Red")
def move(self, dir):
if dir == "u" or dir == "d":
axi = 1
else:
axi = 0
if dir == "d" or dir == "r":
pos = 1
else:
pos = -1
self.pos[axi] += pos*self.speed
if axi == 0:
canvas.move(self.drawn, pos*self.speed, 0)
else:
canvas.move(self.drawn, 0, pos*self.speed)
class Zombie(Entity):
def __init__(self,pos):
super().__init__(random.randrange(MIN_ZOMBIE_SPEED, MAX_ZOMBIE_SPEED),
pos, random.randrange(MIN_ZOMBIE_RAD, MAX_ZOMBIE_RAD),
"Green")
After the zombie class, we need to tell Python to make some zombies.
To make some zombies, we can recall the same code we used to make a survivor and modify it to make a population of zombies.
Here's the previous code for the survivor:
survivor = Survivor([WIDTH/2,HEIGHT/2])
survivor.draw(canvas)
With our zombies, we'll use something similar, but we'll have a number of zombies, not just one. To make a ton of zombies, we can just stick them in a list.
Which kind of loop will we use to make a number of zombies?
In Python we can make a “for loop” or a “while loop”. A for loop won't need a counter variable, and it is more concise than the while loop in this case.
A for loop:
zombies = []
for i in range(ZOMBIE_POPULATION):
zombie = Zombie([random.randrange(WIDTH),random.randrange(HEIGHT)])
zombies.append(zombie)
zombie.draw(canvas)
A while loop:
zombies = []
counter = 0
while (counter < ZOMBIE_POPULATION):
zombie = Zombie([random.randrange(WIDTH),random.randrange(HEIGHT)])
zombies.append(zombie)
zombie.draw(canvas)
counter += 1
As you can see the for loop and the while loop are very much similar to each other, but there are a few differences. These differences make the for loop the best choice in this situation.
Now we've got some zombies! But they don't move...
Just like with the Survivor, we need to make a move method for the Zombie class. Because we will not be moving the Zombie class with keys, our move method needs to be quite different, and more general than in the Survivor class.
Finding where the Zombie should go
Basic "zombie" movement would be the zombies following the Survivors, and if we have more than one survivor need the zombie to follow the closest one. With that information, we can make a method that takes in the positions of the survivors and move the zombies appropriately. So let's make a function that takes in a list of positions.
(We're inside the Zombie class)
def move(self, poss):
pass
(The list of positions and looping is for if you have more than one survivor. If you're only going to use one survivor you can just throw a position in as the argument and ignore the distance formula loop and the min function. It wouldn't hurt to have an understanding of how we do it though.)
Now we have a list of positions in the variable "poss." (Normally I use pos for a position variable and poss is just the plural of pos.) Next we need to find which in that list is closest to this specific Zombie Entity. To do this, we need to loop over the distance formula with each survivor position to get how far away each survivor is.
To loop through the survivor positions, we'll use a for loop and create a list of distances that have indexes that line up with the position indexes in the poss variable.
(inside the move method in the zombie class)
lst_of_dists = []
for pos in poss:
lst_of_dists.append(distance_formula(pos, self.pos))
First, we create a new list to keep the distances, then we create the for loop to loop through all the positions. We use the list.append method to add a value to the end of the list and we append the return value of a future helper function "distance_formula."
You don't have to make the distance_formula a helper function, but I like to do this to keep my code looking almost scripted and very readable.
To make the distance_formula helper function we need to go up to the top of our code and define a new function with the arguments for two positions and a return of the distance between those two.
Remember the distance formula is:
(source)
(At the top of our code, but under the tkinter stuff for organization.)
def distance_formula(pos0, pos1):
return math.sqrt((pos1[0]-pos0[0])**2+(pos1[1]+pos0[1])**2)
Moving the Zombie
Now that we have a list of distances, we need to find the closest one, and move the zombie toward that position.
To find the smallest distance from the lst_of_dists we can use the built in method called "min" which will "Return the smallest item in an iterable or the smallest of two or more arguments." To use this function, we need to call it like any other function.
smallest_dist = min(lst_of_dists)
With the smallest distance we need to get the index (where it is in the list) and use that index to get the position to move to. Because of how we constructed the distances list the index of the smallest distance will align with the corresponding position in the position list. To get the index of the smallest value, we can use the index method, which we call from the list similar to how we would if the method index was a class method in the class List. (it sort of is) Then with the index of the smallest position, we get the position in that index of the positions list.
pos_to_move_to = poss[lst_of_dists.index(min(lst_of_dists))]
The code for this looks a little complicated, but we can break it down to see what it is doing.
# getting the smallest distance:
smallest_dist = min(lst_of_dists)
# where is the smallest distance in the list of distances?
distances_index = lst_of_dists.index(smallest_dist)
# what position is in the distances_index position of the positions list?
pos_to_move_to = poss[distances_index]
If we break it down, we can see exactly what is going on.
Now we have the position of the smallest distance, but that doesn't help much with moving the zombie. To move the zombie we need some trigonometry to get a direction for it to move in. For a direction, we'll use an angle measure that will tell us which way to move. To get the angle measure we need another helper function that will take in two positions and return the desired angle in radians to move our zombie. The desired angle is expressed with the mathematical function arctan(y/x), but in Python we need to use the math module's atan function or the atan2 function. The atan2 function will take in two arguments and the atan function will take in one argument. We'll use the atan2 function below.
Again these helper functions are completely optional and are used mostly for readability and ease of coding.
def get_angle(pos0, pos1):
deltax = pos0[0]-pos1[0]
deltay = pos0[1]-pos1[1]
return math.atan2(deltay, deltax)
Now that we have the angle, we can move the zombie in the direction of the closest survivor. We'll use this angle to transform our zombie position to a new position closer to the survivor. With some more trigonometry we can get the new position with cos for x and sin for y.
(inside the zombie class's move method after we find the smallest distance.)
angle = get_angle(pos_to_move_to, self.pos)
deltax = math.cos(angle)*self.speed
deltay = math.sin(angle)*self.speed
self.pos[0] += deltax
self.pos[1] += deltay
return [deltax, deltay]
Now we've moved the zombie in the numbers but not on the screen.
To move the zombie on the screen, we use the canvas.move(object, deltax, deltay) method like we did for the survivor. To keep our move method clean, we'll just return the deltax and deltay to move the zombie in the update function.
For our update loop, we'll just add in the code to go through all the zombies, move them in the numbers and move them on the screen.
for zombie in zombies:
delta = zombie.move([survivor.pos])
canvas.move(zombie.drawn, delta[0], delta[1])
Now we've got some zombies that will chase down the survivors! In the next part, we'll work with health and damage.