Last time, we implemented text and simple sprite interactions.
So let's remove the debug output from the previous repository. Because today, we're going to add some missile attacks using the mouse. We'll also talk how to programmatically make shapes.
Also, remember the video from the first part of this series? From this point, we will be discussing topics not found in that talk. After my talks, I usually allocate time for audience suggestions. Its usually a hit or a miss if a topic will be discussed (like mouse input, special effects, GUI, audio, etc), even though I'm ready if the audience request for that. I find this makes each of my talk performance unique as different audience asks for different things.
In this series, I have more time to explain how games do things.
Simple Particle System
We're going to implement missiles using a simple Particle System. What's a particle system? They are game objects that follow simple behavior to create special effects. All modern video games use a particle system in one way or another.
Here is an example of a particle system. Each particle is a circle with values for position, velocity, direction, colors, transparency percentage, size, gravity, etc. The particle system than spawns multiple particles with random values.
Here is an example of a particle system in a triple A game:
We'll talk more in depth about particle systems as I've dedicated to this in the next chapter. For now, we'll make a simple one.
I mentioned that particles are game objects. Not all particles use images (our missile won't be). Which means our class hierarchy for game objects need to be updated.
# gameobject.py class GameObject: def __init__(self): pass def draw(self, screen): pass def update(self): pass class Particle(GameObject): pass class Sprite(GameObject): def __init__(self, filename, pos): super().__init__() self.image = pygame.image.load(filename).convert() self.image.set_colorkey((255, 128, 128)) self.pos = pos[:] self.visible = True def draw(self, screen): screen.blit(self.image, self.pos) def update(self): pass class Hero(Sprite): [...] class Shot(Sprite): [...] class Enemy(Sprite): [...]
Previously, Game Objects require an image. With the above changes, we created two subclass for game objects with image (sprites) and those without. As long as we inherit from GameObject, we can update and draw in the game loop. In addition, since Sprites and Particles are Game Objects, we can add code that affects all game objects like data marshaling (turning game objects into something that can be saved and loaded to disk or through the network), logs (when and where game objects are created), auto registration to game world on spawn, and more. We'll make use of this inheritance tree later.
Note: In a later chapter, we will be discussing Entity Component System, which is an alternative for the class hierarchy shown above. For simple games and as an exercise for game programming, inheritance is good enough. But for large game projects with many moving parts, ECS is the design pattern recommended for video games.
This is not to say that OOP inheritance cannot be used for games. The Source engine uses inheritance and I heard that a lot of games where sold using that engine.
We're going to make a particle which we'll represent on the screen as a circle. Pygame.draw allows us to draw rectangles, circles, lines, and other basic shapes. We'll randomize the color and its radius will be between 10 to 20.
# gameobject.py import math def magnitude(vector): return math.sqrt((vector * vector) + (vector * vector)) def normalize(vector): mag = magnitude(vector) return [vector / mag, vector / mag] [...] class Missile(Particle): def __init__(self, pos, target): super().__init__() self.pos = pos[:] self.color = [random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)] self.radius = random.randint(10, 20) self.speed = 10 self.direction = [self.pos - self.target, self.pos - self.target] self.direction = normalize(direction) def draw(self, screen): pygame.draw.circle(screen, self.color, self.pos, self.radius) def update(self): velocity = [self.direction * self.speed, self.direction * speed] self.pos += velocity self.pos += velocity
Linear Algebra for Game programming
Its unavoidable. Game programming is closely related to mathematics. While we could avoid the maths by using descriptive function names, it'll be helpful if you have an idea on what operations they do.
We've been using vectors for our positions. Our speed variable have are scalar numbers.
For this code, we need to figure out the direction a vector is relative to our position. Wherever we click (target position), we should add to that direction so we can get there.
Magnitude refers to the length of the vector. The Normalize function changes the vector so that it would have a length of 1. Normalizing a vector is useful in game programming as it simplifies a lot of code. In our code, if we don't normalize, we'll need to do more maths to figure out the angle from the particle to the target and then how far we need to move in relation to the speed.
Wolfire explains this much better than I could (and they have pictures!).
Now have the game spawn a missile on mouse click.
# main.py from gameobject import Shot, Hero, Enemy, Missile [...] if input.is_down(pygame.K_SPACE): new_shot = Shot(hero.pos) shots.append(new_shot) if pygame.mouse.get_pressed(): new_missile = Missile(hero.pos, pygame.mouse.get_pos()) shots.append(new_missile)
If we give this code a try and click, we'll get an error:
AttributeError: 'Missile' object has no attribute 'image'
This is where having a common inheritance tree with Sprites and particle effects become useful. We can update the collision detection so that it'll ask the object of its size, instead of assuming that all game objects have images.
Change the check_collision call in update_world():
# main.py has_collided = check_collision(shot.pos, shot.get_size(), enemy.pos, enemy.get_size())
and update the game objects class:
#gameobject.py class GameObject: def __init__(self): pass def draw(self, screen): pass def update(self): pass def get_size(self): return [0, 0] class Sprite(GameObject): [...] def get_size(self): return self.image.get_size() class Missile(Particle): [...] def get_size(self): return [self.radius, self.radius]
We put a default value of zero, so a game object will not crash the collision detection code. We override all get_size() for sprite objects. While we can do the same with Particles, in my experience, different particles draws different things (e.g. some draw circles, some draw sprites, some boxes, etc).
Running the code above now gives us this error:
pygame.draw.circle(screen, self.color, self.pos, self.radius) TypeError: integer argument expected, got float
This happens because of the vector maths we did above. Pygame are built for 2D games and when drawing or blitting, the exact pixel location is needed. The simplest fix here is to convert the float to int.
def draw(self, screen): pygame.draw.circle(screen, self.color, [int(self.pos), int(self.pos)], self.radius)
Now there's no more errors in the code. Here's what it looks like:
Oops. Its going out from our hero's behind. The problem is that we have the direction reversed when we calculated the direction. Here's the fix:
self.direction = [self.target - self.pos, self.target - self.pos]
Hey! It works.
Before we finish this section, there's two bugs that need to be fixed.
I called this attack the finger missile, but the missile comes out of his cape. Let's fix that:
# main.py [...] if pygame.mouse.get_pressed(): pos = [hero.pos + 200, hero.pos + 50] new_missile = Missile(pos, pygame.mouse.get_pos()) shots.append(new_missile)
The last bug is subtle. Its hard to see from youtube, so I clipped this as a gif (7 mb. Sorry, gifs are inefficient). Its a collision bug. The missile isn't touching the enemy, but it still collided.
This happens because the pygame.draw.circle() draws around the [x, y] position given. But in our collision code, it assumes that the top-left is the [x, y] position. This can be fixed by drawing our missile with an offset:
# gameobject [...] def draw(self, screen): pos = self.pos[:] pos -= self.radius / 2 pos -= self.radius / 2 pos = [int(pos), int(pos)] pygame.draw.circle(screen, self.color, pos, self.radius)
Tomorrow, we'll add special effects and give our enemies some extra defense. Then we'll add some pew pew sounds.