Pygame Tutorial 04 - Let's make an Invaders style shooter Part 1
Now that we have some of the basics down in previous tutorials, let's go and build a slighty bigger exercise project that has some of the most common elements found in almost any computer or video game like object movement, player input, collision and the like. This example takes inspiration from Taito's arcade classic hit 'Space Invaders' and we'll be trying to replicate some of the mechanics though not slavishly adhering to them. There is still some wiggle room for own interpretations. So, for laughs and giggles let's call this title "Commander Sheppy VS. The U.A.P's". (You may call it any name of your choosing but this one just came to mind when I did some digital drawings, and if you happen to be a "Mass Effect" fan you might catch the reference.)
Anyways, we'll start with the basic setup, drawing the background and displaying a moveable player character on screen, as well as the HUD at the top of the viewport.
Before we start coding however, we've got to set up the folder structure in order for the source code to find the dependencies aka the assets like graphics and sound files. Simply create a project folder eg. "SheppyVsUAPs" and place your main.py file in there. Then create two sub folders, one called "img" and another one labeled as "sound".
Of course you can also download my personal project folder at my gitHub account here: https://github.com/MerlinSolis if you wish to follow along. The complete main.py file as well as all images, sounds, as well as the font we use for the U.I. are included.
Step 1: Creating the Window
First are going to import the modules we need for our project:
# imports
import pygame as pg
import sys
from pygame.math import Vector2
As you can see I have imported pygame as pg, just a little shorthand making typos less likely and saving myself some typing time ;)
sys for now we'll just use to cleanly exit our interpreter at the end of our code and from pygame.math we'll import the Vector2 class in order to make movement of our character, bullets and ufos a bit easier.
# basic setup
pg.init()
SCREEN_WIDTH, SCREEN_HEIGHT = 1024, 768
screen = pg.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), 16)
clock = pg.time.Clock()
FPS = 60
pg.display.set_caption("Sheppy VS The UAP's")
This gives us the basic framework for a pygame window by initializing pygame, setting the resolution and creating a display surface to draw on. After that comes our frame timer, that will cap the execution of the main loop to around 60 frames per second. Further we set the caption in our window's title bar to the incredibly catchy title "Sheppy VS The UAP's".
Next comes the main loop to keep the window open and where we'll be updating our objects each frame once we have them defined properly.
# background base color black
bg_col = (0,0,0)
# loop condition
run = True
while run:
# frame timer
clock.tick(FPS)
# show the caption together with the current frames per second value
pg.display.set_caption(f"Sheppy VS The UAPs | FPS: {clock.get_fps():.1f}")
# fill the background with black
screen.fill(bg_col)
# event handling loop
for event in pg.event.get():
# window X quits the program
if event.type == pg.QUIT:
run = False
# Escape (boss) key quits the program
if event.type == pg.KEYDOWN and event.key == pg.K_ESCAPE:
run = False
# update the screen
pg.display.update()
# exit the interpreter
pg.quit()
sys.exit()
So, when we run this now, we'll get a black rectangle with a given size of 1024 by 768 pixels to be used as our canvas drawing all the game objects we need.
Step 2: Loading the Background Image and displaying it
All fine and dandy but we'll add a legit background image for this type of game. Let's create an image loading area under our initial set-up area:
# loading images
img_bg = pg.image.load("img/background-black.png").convert()
img_bg = pg.transform.scale(img_bg, (SCREEN_WIDTH, SCREEN_HEIGHT))
Here we load the starry background image in our img folder, convert it into pygame's native format and then scaling it to the size of our display surface.
lastly we'll have to blit it to screen inside the main loop to make it visible. Just beneath the screen.fill(bg_col) command we'll write:
# draw starry background image
screen.blit(img_bg, (0, 0))
Ok, the starry sky is set, however we still need some sort of ground for our character to walk on. A ground that is made of square tiles.
Step 3: Creating the Ground
First we have to load 32 x 32 square tile from our image directory at the top in our image loading section.
img_ground_tile = pg.image.load("img/ground_tile.png").convert_alpha()
This will give us the image but we'll a little more than that. We'll create an empty ground tile list which will be used to store our individual tile objects. Of course we also need a grid which comes as a two dimensional list where each list entry corresponds to an individual row on the grid, and the numeric values in each row represent each column.
# ground set-up
ground_tile_list = []
ground_layout = [
#0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],#0
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],#1
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],#2
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] #3
]
As you may have noticed, these lists within the list store 0 and 1 integers. I've decided to encode the areas where a tile is placed as '1' and empty spaces as '0' respectively.
We'll also create a custom class that takes in all the attributes needed for each ground tile.
# blueprint for tiles
class Tile():
def __init__(self, pos, image, scale=1):
self.image = pg.transform.scale(image, (image.get_width() * scale, image.get_height() * scale))
self.rect = self.image.get_rect(topleft = (pos))
def draw(self):
screen.blit(self.image, self.rect)
def update(self):
self.draw()
This comes in handy as we not only want to create a tile floor but also the destructable heaps of cover behind which the player can hide from incoming shots.
As you can see the tile class is very simple, just scales the image, positions it with the help of a rectangle and finally draws it to the screen. The rectangle will also be useful to determine whether a bullet has collided with a tile which subsequently will be deleted to mimic a degrading cover wall. Later more to that.
Now that we have the components, we need to assemble the tiles. To do that we define a function named generate_ground.
# create the tiled ground
def generate_ground(ground_tile_list, ground_layout, Tile, tile_img):
for row_index, row in enumerate(ground_layout):
for col_index, col in enumerate(row):
if col == 1:
x_pos = col_index * tile_img.get_width()
y_pos = (SCREEN_HEIGHT - tile_img.get_height() * 4) + (row_index * tile_img.get_height())
new_tile = Tile((x_pos, y_pos), tile_img)
ground_tile_list.append(new_tile)
return ground_tile_list
ground_tile_list = generate_ground(ground_tile_list, ground_layout, Tile, img_ground_tile)
It does what the name suggests, and loops through each row and column of the ground_layout, pastes a tile at the given x and y positions, and saves it in the ground_tile_list. Once all tiles have been placed according to the layout, we can now iterate through each and every one of them within the ground tile list to draw them onto the display surface.
Just underneath where we blitted the starry night sky image, we can add a for-loop that does exactly that:
# draw the tiled ground
for tile in ground_tile_list:
tile.update()
If now run the program, we'll see the ground tiles positioned at the bottom of the screen, 30 square tiles accross, and 4 squares from the bottom. The spaces that were designated as '0' in the ground_layout list are of course void of any tile. That's where we are going to place our player character, "Sheppy".
Step 4: Create the Player
Initially we'll have to load the raw sprite sheets of our venerable Commander Shepard into its file handles respective to walking directions and scale them up to an acceptable size, in this case by a factor of 2, but you may tinker with it at your own gusto and see how large or small you wish to make your game character sprite.
scale_factor = 2
img_shep_walk_left = pg.image.load("img/shepard_walk_left.png").convert_alpha()
img_shep_walk_left = pg.transform.scale(img_shep_walk_left,
(img_shep_walk_left.get_width() * scale_factor,
img_shep_walk_left.get_height() * scale_factor))
img_shep_walk_right = pg.image.load("img/shepard_walk_right.png").convert_alpha()
img_shep_walk_right = pg.transform.scale(img_shep_walk_right,
(img_shep_walk_right.get_width() * scale_factor,
img_shep_walk_right.get_height() * scale_factor))
We also require a dictionary into which we put our frames for easy access by using keywords such as "walk_left" and "walk_right".
# the sliced images are going to be stored here!
animation_dict = {
"walk_left": [],
"walk_right": []
}
You'll notice that both lists on the value side of the dictionary are still empty. We are going to fill them with frames in just a moment.
In order to use the individual frames on those two .png files, we need to write a custom function that extracts the frames from each image sheet. The function takes in two arguments, the raw_image_sheet, of which we have two, and the number of frames each sheet holds. Basically we loop the number of frames contained on the sheet and store each of them in a temporary variable. For the image slicing to occur, we use the .subsurface(x_posistion, y_position, width, height) method. Right underneath the extracted frame is appended to the animation list.
Once the for loop is done with extracting all frames and storing them in the animation list, this list is being returned by the function ready to be used outside the function.
# loading a raw image sheet and returning a sliced sprite list
def load_sprite_sheet(raw_image_sheet, animation_steps):
animation_list = []
for x in range(animation_steps):
temp_img = raw_image_sheet.subsurface(x * (raw_image_sheet.get_width() / 4), 0, (raw_image_sheet.get_width() / 4),
raw_image_sheet.get_height())
animation_list.append(temp_img)
return animation_list
# store the sliced frames in the animation dictionary
animation_dict["walk_left"] = load_sprite_sheet(img_shep_walk_left, 4)
animation_dict["walk_right"] = load_sprite_sheet(img_shep_walk_right, 4)
The returned lists are stored in the animation dictionary under their corresponding keywords "walk_left" and "walk_right".
Awesome! Now that we have the player images ready, we only need a Player class to control them. Let's create one.
# blue print for the player character
class PlayerObject():
def __init__(self, pos, speed, direction, animation_dict):
self.anim_update_interval = 120 # ms per frame
self.frame_update_time = pg.time.get_ticks()
self.pos = Vector2(pos)
self.direction = Vector2(direction)
self.frame_index = 0
self.action = "walk_right"
self.animation_dict = animation_dict
self.image = self.animation_dict[self.action][self.frame_index]
self.rect = self.image.get_rect(center = (self.pos))
self.delta_x = 0
self.speed = speed
def update_action(self, new_action):
if new_action != self.action:
self.action = new_action
self.frame_index = 0
self.frame_update_time = pg.time.get_ticks()
def get_input(self):
keys = pg.key.get_pressed()
if keys[pg.K_d]:
self.direction.x = 1
self.update_action("walk_right")
elif keys[pg.K_a]:
self.direction.x = -1
self.update_action("walk_left")
else:
self.direction.x = 0
self.frame_index = 0
def screen_boundary_check(self):
if self.rect.left + self.delta_x < 0:
self.delta_x = 0 - self.rect.left
self.frame_index = 0
elif self.rect.right + self.delta_x > SCREEN_WIDTH:
self.delta_x = SCREEN_WIDTH - self.rect.right
self.frame_index = 0
def move(self):
self.delta_x = self.speed * self.direction.x
self.screen_boundary_check()
self.pos.x += self.delta_x
self.rect.center = self.pos
def animate(self):
self.image = self.animation_dict[self.action][self.frame_index]
current_time = pg.time.get_ticks()
if current_time - self.frame_update_time > self.frame_update_time:
self.frame_index += 1
self.frame_update_time = pg.time.get_ticks()
if self.frame_index >= len(self.animation_dict[self.action]):
self.frame_index = 0
def draw(self):
screen.blit(self.image, self.rect)
def update(self):
self.get_input()
self.move()
self.animate()
self.draw()
This gives us the framework for our player. The player class constructors constructor takes position, speed, direction, and the animation dictionary as parameters to be initialized and captured in the def __init__() method for later use within the class.
Our player class consists also of following methods, that do a specific part in the functioning of the player sprite.
def update_action(self, new_action) changes the animation walking cycle from left to right once it is called in the input method. For example, if we are currently walking right with our player and then hitting the button that is associated with walking left, the animation (called action in here) switches to the left facing frame list, resets the frame index to 0 so that it starts from the beginning and also resets the frame timer to a new time stamp.
Besides switching the animation frame sets by calling update_action(), the input method also sets the direction vector on the x axis to 1 for right, and -1 for left. If neither the 'd'-key nor 'a'-key are pressed, it simply sets the direction vector to 0, so no movement at all, and the frame_index to 0, which means the first frame of the current set practically stopping the animation from walking to standing.
The screen_boundary_check method checks whether the player character's bounding rectangle is getting close to either the left side of the screen or the right side and moves the player as long as the distance to the screen boundary is greater than 0. Once the player is too close, the movement is restricted and the animation is stopped by setting the frame index to 0.
Move() does what the name suggests by multiplying speed by direction and storing it in delta_x which is then used to update the position vector and the bounding rectangle of the player. It also restricts the movement once the boundary check method comes into play.
The animate() method acts as a kind of flip book, looping through the images stored in the animation dictionary, updating each image when the animation timer exceeds the specified interval, in our case 120 milliseconds. Once the loop cycle is completed, it resets the frame index to 0 just to repeat it all over again.
The draw() method simply blits the image to the display surface at the position specified by the position of the bounding rectangle.
The update() method wraps all other remaining methods into itself and is called in the main loop applying all changes at around 60 times a second.
If we now create an instance called "sheppy":
sheppy = PlayerObject((SCREEN_WIDTH / 2, SCREEN_HEIGHT - 100), 5, (0,0), animation_dict)
and call sheppy.update() in the main loop, we'll be able to move Sheppy around at the bottom of the screen in the area where we created our ground tiles earlier.
This will be all for the moment. See you all in the next section of this particular tutorial or another topic around programming, design, graphics, entertaining tech and the likes that are in the making. Have a good one! Yours truly, Merlin.










