This is my second game, not yet finished but working. I've put so much time into it I wanted to get feedback before continuing. I always learn so much from those that comment.
Whilst my first (still a work in progress), is object orientated, this isn't. Given that though, I certainly appreciate the OO method more now.
Please try the game out and post your feedback:
# Space Invaders
# By Dave
#
# A simple Space Invaders clone
#
# Planned features not yet implemented:
# - bonus dropped if strobing invader hit
# - high scores tracking using pickled data
# - explosion effects (drawn using colored pixels?)
# - more....
import math
import pygame
import random
import sys
from itertools import cycle
from datetime import datetime
from pygame import gfxdraw
from pygame.locals import *
def print_text(surface, font, text, surf_rect, x = 0, y = 0, center = False,\
color = (255, 255, 255)):
"""
Draws text onto a surface. If center, text is centered on screen at y
"""
if not center:
textimage = font.render(text, True, color)
surface.blit(textimage, (x, y))
else:
textimage = font.render(text, True, color)
text_rect = textimage.get_rect()
x = (surf_rect.width // 2) - (text_rect.width // 2 )
surface.blit(textimage, (x, y))
def game_is_over(surface, font, ticks):
timer = ticks
surf_rect = surface.get_rect()
surf_height = surf_rect.height
surf_width = surf_rect.width
print_text(screen, font, "G A M E O V E R", surf_rect, y = 260,\
center = True)
pygame.display.update()
while True:
ticks = pygame.time.get_ticks()
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
if ticks > timer + 3000:
break
def next_level(level):
level += 1
if level > 6:
level = 6
return level
def load_level(level):
# create and populate(not all) lists
invaders, colors = [], []
start_intx, end_intx, increment_intx = 85, 725, 40 # 85, 725
start_inty, end_inty, increment_inty = 60, 60, 30 #
end_inty = end_inty + level * 30 # rows invaders = level number
color_val = 256 / end_inty # ensure no color repetition
for x in range(start_intx, end_intx, increment_intx):
for y in range(start_inty, end_inty, increment_inty):
invaders.append(pygame.Rect(x, y, 30, 15))
colors.append(((x * 0.35) % 256, (y * color_val) % 256))
return invaders, colors, len(invaders)
def draw_title_invader():
rect = Rect(285,247,230,115)
rect_width = 230
a = 71
b = 171
pygame.draw.rect(backbuffer, (150,a, b),rect)
# left eye
pygame.draw.circle(backbuffer, BLACK, (rect.x+46,rect.y+30), 23)
#right eye
pygame.draw.circle(backbuffer, BLACK,(rect.x+rect_width-46,rect.y+30)\
,23)
# left antennae
pygame.draw.line(backbuffer, (150, a, b),(rect.x+115, rect.y),\
(rect.x+50, rect.y-55),2 )
# right antennae
pygame.draw.line(backbuffer,(150, a, b), (rect.x+ rect_width - 113,\
rect.y),(rect.x + rect_width-50, rect.y-55),2)
# left side mouth
pygame.draw.line(backbuffer, BLACK, (rect.x+46, rect.y+92),\
(rect.x + 115, rect.y + 61), 2)
# right side mouth
pygame.draw.line(backbuffer, BLACK, (rect.x+rect_width-46,\
rect.y+92), (rect.x+rect_width-115,\
rect.y+61), 2)
def draw_bonus_invader(i, bonus_color, bx, bonus_x):
if i == 0:
pygame.draw.circle(backbuffer, bonus_color,
(bonus_invader.x+bx,bonus_invader.y+7),2)
if i == 1:
pygame.draw.circle(backbuffer, bonus_color,
(bonus_invader.x+bx,bonus_invader.y+7),2)
if i == 2:
pygame.draw.circle(backbuffer, bonus_color,
(bonus_invader.x+bx,bonus_invader.y+7),2)
if i == 3:
pygame.draw.circle(backbuffer, bonus_color,
(bonus_invader.x+bx,bonus_invader.y+7),2)
if i == 4:
pygame.draw.circle(backbuffer, bonus_color,
(bonus_invader.x+bx,bonus_invader.y+7),2)
if i == 5:
pygame.draw.circle(backbuffer, bonus_color,
(bonus_invader.x+bx,bonus_invader.y+7),2)
bx = next(bonus_x) # skip a color(ie it's not drawn)to move
# light sequence up ship
def draw_invader(backbuffer, rect, a, b, animate_invaders, ticks,\
animation_time):
invader_width = 30
# draw invader
pygame.draw.rect(backbuffer, (150, a, b), rect)
# left eye
pygame.gfxdraw.filled_circle(backbuffer, rect.x + 6, rect.y + 4, 3, \
BLACK)
#right eye
pygame.gfxdraw.filled_circle(backbuffer, rect.x + invader_width - 7,\
rect.y + 4, 3, BLACK)
# left antennae
pygame.gfxdraw.line(backbuffer, rect.x + 14, rect.y, rect.x + 8,\
rect.y - 6, (150, a, b))
# right antennae
pygame.gfxdraw.line(backbuffer, rect.x + invader_width - 15, rect.y,\
rect.x + invader_width - 8, rect.y - 6, (150, a, b))
# draw 'animation' if required
if animate_invaders:
pygame.gfxdraw.filled_trigon(backbuffer, rect.x+6, rect.y + 12,\
rect.x + 14, rect.y + 4, rect.x +\
invader_width - 7, rect.y + 12, BLACK)
else:
# left side mouth
pygame.gfxdraw.line(backbuffer, rect.x + 6, rect.y + 12,\
rect.x + 15, rect.y + 8, BLACK)
# right side mouth
pygame.gfxdraw.line(backbuffer, rect.x + invader_width - 7,\
rect.y + 12, rect.x + invader_width - 15,\
rect.y + 8, BLACK)
# ensure trigon is drawn for more than just a frame
if ticks > animation_time + 200:
animate_invaders = False
return animate_invaders
##def load_sound(file):
## return pygame.mixer.Sound(file)
pygame.init()
pygame.mixer.init() # not always called by pygame.init()
screen = pygame.display.set_mode((800, 600))
pygame.display.set_caption("Space Invaders")
fpsclock = pygame.time.Clock()
#get screen metrics
the_screen = screen.get_rect()
screen_width = the_screen.width
screen_height = the_screen.height
backbuffer = pygame.Surface((the_screen.width, the_screen.height))
# fonts
font1 = pygame.font.SysFont(None, 30)
font2 = pygame.font.SysFont("Impact", 54)
font3 = pygame.font.SysFont("Impact", 36)
### load sounds
##space_voiceover = load_sound("SpaceInvadersIntro.wav")
##missile_sound = load_sound("missile.wav")
### play voiceover on startup
##space_voiceover.play()
# User event frequencies
RELOAD_SPEED = 400
MOVE_SIDEWAYS = 1000
MOVE_DOWN = 1000
BONUS_FREQ = 10000
INV_SHOOT_FREQ = 500
# create user events
move_invaders_sideways = pygame.USEREVENT + 1
move_invaders_down = pygame.USEREVENT + 2
reload = pygame.USEREVENT + 3
invader_shoot = pygame.USEREVENT + 4
bonus = pygame.USEREVENT + 5
# event timers
pygame.time.set_timer(move_invaders_down, 0)
pygame.time.set_timer(move_invaders_sideways, MOVE_SIDEWAYS)
pygame.time.set_timer(reload, RELOAD_SPEED)
pygame.time.set_timer(invader_shoot, INV_SHOOT_FREQ)
pygame.time.set_timer(bonus, BONUS_FREQ)
# colors
BLACK = (0,0,0)
WHITE = (255,255,255)
RED = (255,0,0)
GREEN = (0,255,0)
BLUE = (0,0,255)
YELLOW = (255,255,0)
DIMGRAY = (105,105,105)
shots, invader_shots, inv_shot_colors, bonus_invaders = [], [], [], []
# create player ship
player = Rect(380,578,42,20)
player_gun = Rect(player.x + 18,player.y - 4, 6, 4)
# make screen rect for purposes of text-centering etc
the_screen = screen.get_rect()
# invader animation variables
animation_time = 0
animate_invaders = False
invader_width = 30
invader_height = 15
# flashing text vars
the_text = cycle(["Press Enter To Play, Earthling...", ""])
insert = next(the_text)
flash_timer = 0
# flashing bonus item vars
y1,y2,y3,y4,y5,y6 = (255,255,0), (225,225,0), (195,195,0), (165,165,0),\
(135,135,0), (105,105,0)
bonus_colors = cycle([y1,y2,y3,y4,y5,y6])
bonus_color = next(bonus_colors)
bonus_x = cycle([4,11,18,25,32,39]) # change draw x coord
bonus_timer = 0 # used to control frequency of changes
# vars for moving invaders down
move_right, move_down, reloaded = True, True, True
vert_steps = 0
side_steps = 0
moved_down = False
invaders_paused = False
invaders = 0 # prevents error until list is created
initial_invaders = 0 # use to manage freq of inv shots as invaders removed
shoot_level = 1 # manage freq of shots
# various gameplay variables
game_over = True
score = 0
lives = 2
level = 0
playing = False
# event loop
while True:
ticks = pygame.time.get_ticks()
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
if event.type == KEYUP:
if event.key == pygame.K_1 and not game_over:
print("Next level")
if event.type == invader_shoot and not game_over:
i = random.randint(0, len(invaders)-1)
shot_from = invaders[i]
a, b = colors[i]
invader_fired = True
invader_shots.append(Rect(shot_from.x, shot_from.y, 5, 7))
inv_shot_colors.append((150, a, b))
if event.type == reload and not game_over:
reloaded = True
pygame.time.set_timer(reload, 0)
if event.type == move_invaders_sideways and not game_over:
if move_right:
for invader in invaders: invader.move_ip(10,0)
side_steps += 1
else:
for invader in invaders: invader.move_ip(-10,0)
side_steps -= 1
if side_steps == 6 or side_steps == -6:
if vert_steps <= 31: # and not moved_down
pygame.time.set_timer(move_invaders_sideways, 0)
pygame.time.set_timer(move_invaders_down, MOVE_DOWN)
# keep invaders moving horizontally after 31 down movements
else: move_right = not move_right
if event.type == move_invaders_down and not game_over:
#for i in range(20): print("down event")
move_right = not move_right
animate_invaders = True
animation_time = ticks
# reset move_sideways timer
pygame.time.set_timer(move_invaders_sideways, MOVE_SIDEWAYS)
# cancel move_down timer
pygame.time.set_timer(move_invaders_down, 0)
for invader in invaders: invader.move_ip(0,10)
vert_steps += 1
if event.type == bonus and not game_over:
#a = Rect(769,20,45,15)
bonus_invaders.append(Rect(797,20,45,15))
# keyboard polling
pressed = pygame.key.get_pressed()
if pressed[K_ESCAPE]: pygame.quit(), sys.exit()
elif pressed[K_RETURN]:
if game_over: game_over = False
elif pressed[K_d] or pressed[K_RIGHT]:player.move_ip((8, 0))
#player_gun.move_ip((8,0))
elif pressed[K_a] or pressed[K_LEFT]: player.move_ip((-8, 0))
if pressed[K_SPACE]:
if reloaded:
reloaded = False
# create timeout of RELOAD_SPEED
pygame.time.set_timer(reload, RELOAD_SPEED)
# shrink copy of player rect to imitate a missile
missile = player.copy().inflate(-38, -10)
# spawn missile higher to ensure appears missile fired from 'gun'
# when the ship is moving horizontally
missile.y -= 9
shots.append(missile)
#missile_sound.play()
backbuffer.fill(BLACK)
if not game_over:
playing = True
if level == 0:
level = next_level(level)
invaders, colors, initial_invaders = load_level(level)
move_right, move_down, reloaded = True, True, True
vert_steps = 0
side_steps = 0
moved_down = False
invaders_paused = False
pygame.time.set_timer(invader_shoot, 500)
shoot_level = 1
for shot in invader_shots:
shot.move_ip((0,random.randint(5,11)))
if not backbuffer.get_rect().contains(shot):
i = invader_shots.index(shot)
del invader_shots[i]
del inv_shot_colors[i]
if shot.colliderect(player):
lives -= 1
if lives < 0:
lives = 0
game_over = True
i = invader_shots.index(shot)
del invader_shots[i]
del inv_shot_colors[i]
for shot in shots:
shot.move_ip((0, -8))
for inv_shot in invader_shots:
if inv_shot.colliderect(shot):
shots.remove(shot)
i = invader_shots.index(inv_shot)
del invader_shots[i]
del inv_shot_colors[i]
for b_invader in bonus_invaders:
if b_invader.colliderect(shot):
shots.remove(shot)
i = bonus_invaders.index(b_invader)
del bonus_invaders[i]
score += 1
if not backbuffer.get_rect().contains(shot):
shots.remove(shot)
else:
hit = False
for invader in invaders:
if invader.colliderect(shot):
score += 1
hit = True
i = invaders.index(invader)
del invaders[i]
del colors[i]
if hit: shots.remove(shot)
# move bonus invader
for bonus_invader in bonus_invaders:
bonus_invader.move_ip((-4,0 ))
## if not screen.get_rect().contains(bonus_invader):
## bonus_invaders.remove(bonus_invader)
if bonus_invader.x < -55:
bonus_invaders.remove(bonus_invader)
# check if all invaders killed, if so, move to next level
if len(invaders) == 0:
level = next_level(level)
invaders, colors, initial_invaders = load_level(level)
move_right, move_down, reloaded = True, True, True
vert_steps = 0
side_steps = 0
moved_down = False
invaders_paused = False
pygame.time.set_timer(invader_shoot, 500)
shoot_level = 1
# adjust shot freq when invader numbers decrease
if len(invaders) < initial_invaders*.75 and shoot_level == 1:
pygame.time.set_timer(invader_shoot, 750)
shoot_level = 2
elif len(invaders) < initial_invaders*.5 and shoot_level == 2:
pygame.time.set_timer(invader_shoot, 1000)
shoot_level = 3
elif len(invaders) < initial_invaders*.25 and shoot_level == 3:
pygame.time.set_timer(invader_shoot, 1500)
shoot_level = 4
# draw invaders
for rect, (a, b) in zip(invaders, colors):
animate_invaders = draw_invader(backbuffer, rect, a, b,\
animate_invaders, ticks, \
animation_time)
# draw bonus invaders
if ticks > bonus_timer + 169:
bonus_timer = ticks # change colors every 169ms approx
for bonus_invader in bonus_invaders:
pygame.draw.rect(backbuffer, (0,0,0,0), bonus_invader)
pygame.draw.ellipse(backbuffer,DIMGRAY,bonus_invader)
for i in range(6):
bonus_color = next(bonus_colors)
bx = next(bonus_x)
draw_bonus_invader(i, bonus_color, bx, bonus_x)
# draw space ship shots
for shot in shots:
pygame.draw.rect(backbuffer, (255,0,0), shot)
# draw invader shots
for shot, color in zip(invader_shots, inv_shot_colors):
pygame.draw.rect(backbuffer, color, shot)
#update 'gun' position and draw ship/gun
#player_gun = Rect(player.x, player.y, 6, 4)
player_gun.x = player.x+18
pygame.draw.rect(backbuffer, DIMGRAY, player)
pygame.draw.rect(backbuffer, DIMGRAY, player_gun)
player.clamp_ip(backbuffer.get_rect())
print_text(backbuffer, font1, "Invaders Pnwed: {}".format(score),\
the_screen, x=590, y=0)
print_text(backbuffer, font1, "Lives: {}".format(lives), the_screen,\
x=0, y=0)
print_text(backbuffer, font1, "Level: {}".format(level), the_screen,\
x=0, y=580)
if game_over:
if playing:
game_is_over(backbuffer, font2, ticks)
playing = False
level = 0
lives = 2
score = 0
shots, invader_shots, inv_shot_colors, bonus_invaders = [], [], [], []
print_text(backbuffer, font2, "SPACE INVADERS", the_screen, y=5,\
center=True)
draw_title_invader()
if ticks > flash_timer + 800: # "press to play" flashing text
insert = next(the_text)
flash_timer = ticks
print_text(backbuffer, font3, insert, the_screen, y =\
the_screen.height-40, center=True)
screen.blit(backbuffer, (0,0))
pygame.display.update()
fpsclock.tick(30)
2 Answers 2
Just a minor improvements.
1) If you count something manually or repeat exact lines one after other probably you do something wrong. Use iteration if possible
def draw_bonus_invader(i, bonus_color, bx, bonus_x):
x, y = bonus_invader.x, bonus_invader.y
pygame.draw.circle(backbuffer, bonus_color, (x+bx, y+7), 2)
if i == 5:
bx = next(bonus_x)
2) Try not to use \ to split logical line. You don’t need it anyway if splitting inside (). Also I'm not familiar with PyGame, so I can guess what the parameters are but not always:
# wrong
pygame.draw.line(backbuffer, (150, a, b),(rect.x+115, rect.y),\
(rect.x+50, rect.y-55),2 )
# better
x, y = rect.x, rect.y
pygame.draw.line(Surface=backbuffer, color=(150, a, b),
start_pos=(x+115, y), end_pos=(x+50, y-55), width=2)
3) # create user events
and # event timers
looks a bit painful. You should try to use dictionaries more often for creating special options and then just iterate over them.
Also I'd create list of strings ['bonus', 'invader_shoot', ...]
and just use it to create dictionary
events_map = {'bonus': (event, opt_1, opt_2),
'invader_shoot': (event, opt_1, opt_2),
...}
But that requires replacing many variables like this:
bonus --> event = events_map['bonus'][0]
all param --> event, opt_1, opt_2 = events_map['bonus']
Also note that I try to sort functions and variables in alphabetic order if possible.
# create user events
bonus = pygame.USEREVENT + 5
invader_shoot = pygame.USEREVENT + 4
move_invaders_down = pygame.USEREVENT + 2
move_invaders_sideways = pygame.USEREVENT + 1
reload = pygame.USEREVENT + 3
# event timers
options_map = {bonus: BONUS_FREQ,
invader_shoot: INV_SHOOT_FREQ,
move_invaders_down: 0,
move_invaders_sideways: MOVE_SIDEWAYS,
reload: RELOAD_SPEED
}
for (event, option) in event_map.items():
pygame.time.set_timer(event, option)
4) Multiple if statements are bad, since they all will be processed one after another every time code is executed. Use elif
or better: dictionaries. Again:
# Assigned functions aren’t executed. You just assign names (if without ())
event_map = {bonus: bonus_foo, invader_shoot: invader_shoot_foo,
KEYUP: KEYUP_foo, reload: reload_foo, ...}
# event loop
while True:
ticks = pygame.time.get_ticks()
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
elif not game_over:
# Here is where execution begins
event_map[event.type]()
5) Try to organise code in general: - Defined functions are in random order. Use alphabetic. It will be easier to find and edit each. - Constants are everywhere. Constants block are in non-clear order. And they are somewhat mixed with code. Define them at the place they are needed if possible. If used in many situations, place somewhere at the beginning or use separate module just for the constants and functions.
Edit:
If you don’t want to introduce classes you should probably stick with elif statements. You can try to modify global variables, but very carefully since they can mess code logic easily and usually this a bad practice to use many global variables:
reloaded = False
def reload_f():
global reloaded
reloaded = True
print(reloaded) #--> False
reload_f()
print(reloaded) #--> True
On the other hand with classes you can edit class parameters anywhere in the code as well as obtain parameter values:
class MyShip(object):
def __init__(self):
object.__init__(self)
self.isReloaded = False
self.bullets = 0
def reload(self):
self.isReloaded = True
self.bullets = 10
player = MyShip()
print(player.isReloaded, player.bullets) #--> False, 0
bullets = player.reload()
print(player.isReloaded, player.bullets) #-->True, 10
So instead of elif statement:
# define some classes with useful methods
player = MyShip()
invaders = InvaderShips()
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
elif not game_over:
event_map = {reload: player.reload,
move_invaders_sideways: invaders.move_sideways,
...}
if event.type in event_map:
event_map[event.type]
-
\$\begingroup\$ Thank you for taking the time to highlight that. Much appreciated! I've implemented your proposed amendments with the exception the of the draw functions(they don't take keyword args) and the event map in "4". Unless I haven't grasped fully what you were suggesting, the closest I could come up has been added to my original post as an edit. I had to return values from the functions and this can't be done with the dict method (I think) Thanks again, Dave. \$\endgroup\$Michael Johnson– Michael Johnson2016年10月03日 15:29:51 +00:00Commented Oct 3, 2016 at 15:29
-
\$\begingroup\$ The code is now in an answer below Vasyalisk. \$\endgroup\$Michael Johnson– Michael Johnson2016年10月03日 15:44:56 +00:00Commented Oct 3, 2016 at 15:44
-
\$\begingroup\$ Added new info at the end of the post if you would like to turn into OOE. Otherwise you should probably use elif logic. Good luck! \$\endgroup\$Vasyalisk– Vasyalisk2016年10月03日 20:01:07 +00:00Commented Oct 3, 2016 at 20:01
-
\$\begingroup\$ Thanks for the edit. I might well convert the game to use classes once I've finished it. That would probably be a good learning exercise. Using the existing structure was interesting but I wouldn't want to use it again for something like this. \$\endgroup\$Michael Johnson– Michael Johnson2016年10月04日 07:13:06 +00:00Commented Oct 4, 2016 at 7:13
Reworked event loop. Tidies up and corrects the original somewhat:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
elif not game_over:
if event.type == invader_shoot:
invader_shoot_f()
elif event.type == reload:
reloaded = reload_f()
elif event.type == move_invaders_sideways:
move_right, side_steps = move_invaders_sideways_f(move_right,
side_steps,
vert_steps)
elif event.type == move_invaders_down:
move_right, animate_invaders, animation_time, vert_steps = \
move_invaders_down_f(move_right, vert_steps, ticks)
elif event.type == bonus:
bonus_invaders.append(Rect(797, 20, 45, 15))
There was also a bug present which would arise whenever there was an invader shot - player collision and that invader shot was also partially off the screen:
if shot.colliderect(player):
changed to:
# ignore any collisions with invader shot partially off screen
# Let these be picked up by the preceding few lines of code only
# 10 is the height of the invader shots
if shot.colliderect(player) and shot.y < the_screen.height -10:
draw_bonus_invader
? \$\endgroup\$