第六章:笨鸟先飞游戏编程
我们在前面两个章节已经使用PyGame实现了两个经典游戏,在本章我们将会实现第三个经典游戏,笨鸟先飞。这个游戏的复杂之处在于更细致的动画处理,游戏得分判断机制,以及游戏按钮的交互。我们仍会以相似的顺序来讲解,介绍游戏规则、设计思路和代码实现方法。本章还会遇到类的继承用法。
一、笨鸟先飞游戏介绍
游戏规则
在本游戏中,玩家需要控制一只小鸟越过钢管组成的丛林。小鸟前进的速度是恒定的,多个钢管不停的会从屏幕右侧出现。每两个钢管之间会存在一个空隙。玩家需要精细的控制小鸟的飞行姿态来通过这个空隙。此外,小鸟会持续受到重力影响而下坠,玩家点击一次鼠标就会给小鸟一次展翅上升的力量。如果小鸟飞的过高,达到屏幕上边界,或者飞的过低,接触地面,则游戏失败。同样的,如果小鸟撞击到钢管,游戏也会失败。所以玩家主要目标,就是鼠标输入,精确控制的小鸟的纵向位置,穿越钢管丛林。小鸟每穿越一对钢管,则分值增加一分。游戏画面如下。
游戏资源
游戏资源有两类,一类是图片资源,包括了游戏中五种视觉元素。分别是游戏背景图片,地面图片,钢管图片,重启按键图片,以及小鸟图片。为了增加动画效果,资源包括了三种小鸟的姿态,以表现翅膀扇动的效果。另一类是声音资源,这里有四个文件,一个是会持续播放的背景音乐,另外三种音效是小鸟穿越钢管后的成功音效,失败落地的音效,以及扇动翅膀上升的音效。
如下三张图片就是三种不同的小鸟姿态,其翅膀的位置略有区别。



二、游戏功能和程序设计
游戏功能
本游戏要实现的主要功能包括:
- 实现重力效果,并用鼠标按键控制小鸟的上下飞行
- 实现小鸟的飞行动画效果
- 判断小鸟和边界及钢管的碰撞情况。发生碰撞则游戏失败。
- 处理钢管的生成和移动,处理地面的移动。
- 判断当小鸟越过钢管时,给分值加分。
- 当游戏失败后显示重启游戏的按键,等待玩家输入。
程序设计
我们仍然使用面向对象的设计思想,游戏中比较复杂的对象我们会封装成一个类来处理,例如小鸟类和钢管类,还有按键类,背景和地面则可以简单处理。类图设计如下。
classDiagram
Game *-- Bird : Composition
Game *-- Pipe :Composition
Game *-- Button : Composition
class Game {
bird
pipes
button
...
}
class Bird {
}
class Pipe {
}
class Button {
}
Bird类中处理小鸟的图片加载、飞行控制、动画效果等功能。
Pipe类中处理钢管的图片加载、随机生成等功能。
Button类中处理重启按键的图片加载、交互操作等功能。
游戏主体流程还是封装在一个Game类之中,要实现的主要游戏机制包括,场景初始化,定义窗口和各初始变量,实例化游戏角色。游戏主循环中仍是三类典型的游戏逻辑,处理用户的输入,更新游戏数据,生成游戏输出。
三、代码实现
Bird类
首先定义小鸟类Bird,这里我们使用继承语法来创建类。这里的继承是指,当我们定义一个类的时候,同时指定它的父类,这样它就可以具备父类已有的属性和方法。在代码定义中用括号填写要继承的父类名称。此外继承的是pygame模块中的Sprite类。Sprite类是游戏中的基本角色。代码中使用继承的好处是,Sprite类中已经写好了很多函数方法,我们可以直接使用而无需再从头写代码。
在初始化代码中,super关键词是之前没有遇到过的,它的含义是先运行父类的初始化函数。之后定义一个图片列表,用循环来加载三张图片资源,这样images中就保存了所有的小鸟图片,index是资源的索引编号,选取后的图片保存在image中。后续使用时会根据情况改变index的值,以显示不同的图片,这样达到动画效果。然后定义其边框矩形rect,并加载音效wing。
flying用于定义小鸟是否处于飞行的状态,failed用于定义游戏是否失败的状态。flying和failed这两个逻辑值的组合有四种可能,分别用于四种场景。场景一,当游戏刚开始,准备飞行而没有飞的时候,flying为假,failed为假。场景二,正常游戏开始后小鸟正常飞行阶段,flying为真,failed为假。场景三,当小鸟碰到障碍失败,死亡垂直下落阶段,还没到地面时,flying为真,failed为真。场景四,小鸟死亡垂直下落到地面后,flying为假,failed为真。
class Bird(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.images = []
self.index = 0
self.counter = 0
self.vel = 0
self.cap = 10
self.flying = False
self.failed = False
self.clicked = False
for num in range (1, 4):
img = pygame.image.load(f"resources/bird{num}.png")
self.images.append(img)
self.image = self.images[self.index]
self.rect = self.image.get_rect()
self.rect.center = [x, y]
self.wing = pygame.mixer.Sound('resources/wing.wav')
handle_input函数负责处理用户,函数中有两个条件判断,在第一个条件判断中,利用了mouse.get_pressed函数,这个函数会返回鼠标上各按键是否被按下的逻辑值,这里编号0意味着鼠标左键。clicked变量是保存鼠标按键是否点击过的状态。所以第一个条件判断的逻辑就是,如果鼠标左键被点击,而且之前没有点击过的话,将速度变量vel设置为-10,速度变量会影响小鸟的位移方向,所以会让它向上移动。同时播放扇动翅膀的音效。函数中使用clicked = True是为了避免按住左键后持续上升的情况。第二个条件判断中,如果鼠标左键没有被按下,那就让clicked等于False,以方便第一个条件判断的正常运行。
def handle_input(self):
if pygame.mouse.get_pressed()[0] == 1 and not self.clicked :
self.clicked = True
self.vel = -1 * self.cap
self.wing.play()
if pygame.mouse.get_pressed()[0] == 0:
self.clicked = False
然后编写处理动画效果的函数animation。此处需要实现两种动画效果,一种是表现小鸟不停扇动翅膀的效果,另一种是小鸟在上升时头部上仰和下降时俯冲的效果。
从贪吃蛇游戏中我们已经学习到,在不同的帧中显示不同的图片,就会呈现出动画效果,在本例中,我们不希望小鸟的翅膀扇的过快,所以会当游戏每运行5帧之后,再更改图片显示。所以counter用于帧数计数,当超过5时,我们把图片进行更换,更换的方式是增加索引编号index,这样image获取的图片就不一样了。当取到最后一张图片后,为了避免索引越界,使用求余数的方法让index回头等于0。这样第一种动画效果就完成了。
第二种动画效果是最后一行代码实现的。transform.rotate函数可以让图片对象进行旋转,函数第一个参数是要旋转的对象,第二个参数是旋转的角度。此处我们是用小鸟的速度变量vel来做为旋转角度参数。当小鸟速度为正值时是向下飞,让小鸟朝向地面一些,当小鸟速度为负值时是向上飞,让小鸟朝向天空一些。这样就完美实现了第二种动画效果。
def animation(self):
flap_cooldown = 5
self.counter += 1
if self.counter > flap_cooldown:
self.counter = 0
self.index + 1
self.index = (self.index + 1) % 3
self.image = self.images[self.index]
self.image = pygame.transform.rotate(self.images[self.index], self.vel * -2)
touch_ground函数用于判断小鸟的底部是否和地面发生碰撞。update函数用于控制小鸟状态更新,要注意这里有两种状态变量,第一个状态变量是flying,当小鸟处于飞行状态时,也就是flying为真值,表示游戏开始了,要增加向下的速度以模拟重力加速度的效果,但不会让向下的速度太高,如果速度超过8就将速度稳定在8。在条件判断处调用touch_ground函数,只要小鸟还没有接触地面,就根据速度值更新小鸟的纵向坐标y,表现小鸟下落效果。第二个状态变量是failed,当它为假时,表示小鸟还没有撞上障碍,需要处理玩家输入和动画效果。否则旋转小鸟90度,以表现坠落的效果。
def touch_ground(self):
return self.rect.bottom >= Game.ground_y
def update(self):
if self.flying :
self.vel += 0.5
if self.vel > 8:
self.vel = 8
if not self.touch_ground():
self.rect.y += int(self.vel)
if not self.failed:
self.handle_input()
self.animation()
else:
self.image = pygame.transform.rotate(self.images[self.index], -90)
在Bird类中,我们并没有像之前那样定义绘图函数。这是因为Bird类是通过继承Sprite类来创建的,draw函数已经在Sprite类中定义好了,我们只需要直接使用即可。当前正常使用的前提是在Bird类中要设置好image属性。
Pipe类
Pipe类表示游戏中的钢管角色,也是通过继承Sprite来创建该类。其类属性scroll_speed表示钢管的移动速度,pipe_gap表示两根钢管之间空隙的大小。初始化函数中,用passed表示某个钢管是否被小鸟穿越飞过。
钢管是成对出现的,一种是放置在上方,一种是放置在下方的。is_top表示某个钢管位置是否放在上方。我们可以用同一种图片表示这两种钢管,所以is_top为真的钢管,需要用flip函数来将图片进行翻转操作,如果is_top为假的钢管,就不需要翻转。
设置钢管位置时,是基于外部传入的参数x和y来确定的。从游戏示例图中可以观察到,两个成对的钢管坐标之间存在着紧密的关系。这两个钢管的横坐标x是一样的,而位于上部钢管的底部,和位于下部钢管的顶部,二者之间的差值,就是空隙的大小。基于以上逻辑,用bottomleft位置坐标来控制上部钢管位置,bottomleft即左下角坐标位置,让其y坐标减去一半的空隙大小。用topleft位置控制下部钢管位置,topleft即左上角坐标位置,让其y坐标加上一半的空隙大小。这样让上下两只钢管可以横坐标对齐,纵坐标错开位置,留出空隙。
update函数用于位置更新,当钢管右侧位置小于0,也就是当钢管不断左移,移出屏幕左侧边界后,使用kill函数来删除这个对象。kill函数也是Sprite类自带的函数,我们直接使用。
class Pipe(pygame.sprite.Sprite):
scroll_speed = 4
pipe_gap = 180
def __init__(self, x, y, is_top):
super().__init__()
self.passed = False
self.is_top = is_top
self.image = pygame.image.load("resources/pipe.png")
self.rect = self.image.get_rect()
if is_top :
self.image = pygame.transform.flip(self.image, False, True)
self.rect.bottomleft = [x, y - Pipe.pipe_gap // 2]
else:
self.rect.topleft = [x, y + Pipe.pipe_gap // 2]
def update(self):
self.rect.x -= Pipe.scroll_speed
if self.rect.right < 0:
self.kill()
Button类
Button类用于创建按键。在初始化函数中加载资源和定义边框矩形。pressed函数用于捕捉玩家的鼠标输入,条件判断首先要看事件是否满足鼠标点击事件。满足此条件后,通过get_pos函数获取鼠标点击的坐标位置。然后调用按键的边框矩形的方法函数collidepoint,来计算点击坐标是否在边框内部。这样通过两个条件来判断鼠标是否在按键内点击。如果是则返回逻辑真。
class Button:
def __init__(self, x, y):
self.image = pygame.image.load('resources/restart.png')
self.rect = self.image.get_rect(centerx=x,centery=y)
def pressed(self, event):
action = False
if event.type == MOUSEBUTTONDOWN:
pos = pygame.mouse.get_pos()
if self.rect.collidepoint(pos):
action = True
return action
def draw(self,surface):
surface.blit(self.image, self.rect)
Game类
Game类用来定义游戏主体流程,在类属性中用ground_y定义了地面在显示窗口中的纵坐标位置。初始化函数中定义了窗口,ground_x表示地面横坐标位置,observed是一个字典用于保存一系列信息,pipe_group和bird_group是创建的两个空集合,用于保存一组钢管角色和一组小鸟角色。创建bird对象后,bird_group.add是将这个对象角色加入到集合中。当然小鸟集合中只会有这一只。group这种集合方便我们统一管理所有角色对象,例如位置移动和绘图等操作都会更方便。然后使用new_pipes函数增加钢管,mixer.music.load用于加载背景音乐。
class Game():
ground_y = 650
def __init__(self,Width=600,Height=800):
pygame.init()
self.Win_width , self.Win_height = (Width, Height)
self.surface = pygame.display.set_mode((self.Win_width, self.Win_height))
self.ground_x = 0
self.score = 0
self.pipe_counter = 0
self.observed = dict()
self.Clock = pygame.time.Clock()
self.fps = 60
self.font = pygame.font.SysFont('Bauhaus 93', 60)
self.images = self.loadImages()
self.sounds = self.loadSounds()
self.pipe_group = pygame.sprite.Group()
self.bird_group = pygame.sprite.Group()
self.flappy = Bird(100, self.ground_y // 2)
self.bird_group.add(self.flappy)
self.new_pipes(time=0)
self.button = Button(self.Win_width//2 , self.Win_height//2 )
pygame.display.set_caption('Flappy Bird')
pygame.mixer.music.load('resources/BGMUSIC.mp3')
pygame.mixer.music.play(-1)
初始化中加载资源的函数都不复杂,loadImages是加载背景和地面两种图片,保存在字典中。loadSounds是加载两种音效。
def loadImages(self):
background = pygame.image.load('resources/bg.png')
ground = pygame.image.load('resources/ground.png')
return {'bg':background, 'ground':ground}
def loadSounds(self):
hit = pygame.mixer.Sound('resources/hit.wav')
point = pygame.mixer.Sound('resources/point.wav')
return {'hit':hit, 'point':point}
在游戏失败后,我们需要重启游戏,这里reset_game函数用于清空当前场景中的钢管,重建新的钢管,重置小鸟位置,重置分值和观察信息。
def reset_game(self):
self.pipe_group.empty()
self.new_pipes(time=0)
self.flappy.rect.x = 100
self.flappy.rect.y = self.ground_y // 2
self.score = 0
self.observed = dict()
pygame.mixer.music.play(-1)
用户输入处理
除了在Bird类中处理用户输入控制飞行之外,还有两个特殊场景需要处理玩家的输入。一个场景是之前提到过的当游戏刚启动时,需要玩家点击鼠标来正式起飞。因为小鸟一旦起飞就会受重力影响下坠,如果玩家没准备好,可能就直接失败了。start_flying函数就是用来判断是否满足这个场景,并且玩家是否点击了鼠标。如果条件满足,就则flying设置为真,小鸟起飞,重力开始起作用。
另一个场景是游戏失败后,需要判断玩家要不要重启游戏,这里判断条件包括游戏处于失败状态,而且重启按键被点击,那么就将failed设定为假,再调用reset_game重置游戏信息。新一局游戏将会开始。
def start_flying(self,event):
if (event.type == pygame.MOUSEBUTTONDOWN
and not self.flappy.flying
and not self.flappy.failed):
self.flappy.flying = True
def game_restart(self,event):
if (self.flappy.failed
and self.button.pressed(event)):
self.flappy.failed = False
self.reset_game()
碰撞检测
handle_collision是用于碰撞检测的函数,这里小鸟和钢管都在角色组中,我们直接使用groupcollide函数来判断这两组角色有没有碰撞。只要这两个角色组中有任何角色产生碰撞,都会返回逻辑真值。另外两个条件就是看小鸟有没有碰到上界和下界。如果任何一个碰撞检测判断为真,则将failed设置为真,游戏失败,播放失败音效,停止背景音乐。
def handle_collision(self):
if (pygame.sprite.groupcollide(self.bird_group, self.pipe_group, False, False)
or self.flappy.rect.top < 0
or self.flappy.rect.bottom >= Game.ground_y):
self.flappy.failed = True
self.sounds['hit'].play()
pygame.mixer.music.stop()
游戏数据更新
ground_update函数用于表现地面向左移动,让玩家感觉小鸟在向右飞行的效果。移动地面就是更新地面的横坐标ground_x,每一帧会将地面的横坐标减少一定的量,减少值的大小等于scroll_speed。因为地面的图片资源大小有限,所以每移动超过35个像素后,就重置坐标ground_x等于0。这样用一张有限的图片就表现出无限的大地。
def ground_update(self):
self.ground_x -= Pipe.scroll_speed
if abs(self.ground_x) > 35:
self.ground_x = 0
new_pipes函数用于生成新的钢管,pipe_counter是一个计数器,当计数器超过90时,我们才会去生成新的一对钢管。也就是说在上一次钢管生成后,游戏要运行90帧后,才会有新钢管出现。这个数字不能太小,否则钢管密度太大,游戏难度过高。在游戏刚开始的时候,这里我们会将time参数设置为0,是为了立即生成一对新钢管。
在Pipe对象的构造参数中,第一个参数是横坐标位置,这里设置为Win_width,也就是说钢管生成后会隐藏在窗口右侧等待入场,第二个参数是纵坐标,此处pipe_height是一个在一定范围内的随机数,让钢管生成有随机性,游戏更有可玩性。ground_y是地面的纵坐标位置,也就是天空到地面的高度,所以钢管的纵坐标位置是在天空到地面的中间位置上,再增加随机值因素得到的。top_pipe和btm_pipe分别表示位于上方和下方的钢管,然后加到pipe_group角色组里去。
def new_pipes(self, time = 90):
self.pipe_counter += 1
if self.pipe_counter >= time:
pipe_height = random.randint(-150, 150)
top_pipe = Pipe(self.Win_width, self.ground_y // 2 + pipe_height, True)
btm_pipe = Pipe(self.Win_width, self.ground_y // 2 + pipe_height, False)
self.pipe_group.add(top_pipe)
self.pipe_group.add(btm_pipe)
self.pipe_counter = 0
我们需要判断,小鸟什么时候飞越了钢管,这样才能让分值增加。困难点在于场景中有很多个钢管,需要对钢管做标记,这里的标记变量就是Pipe中的属性passed,一开始passed都是逻辑假,即位于小鸟右侧,没有被飞越的。get_pipe_dist函数是用于计算保存观测值。函数先用一个列表解析,选择右侧钢管中前两个,对这两个钢管进行遍历,如果是上方钢管,就获取其钢管边框的右边缘则坐标,同时获取其底部坐标,如果是下方钢管,则获取顶部坐标,这些信息都保存在字典observed中。
check_pipe_pass函数用来判断是不是飞越了钢管空隙,即小鸟边框的左侧坐标是否大于等于钢管的右侧坐标。条件满足的话,分值增加,播放音效。并将钢管角色组中前两个钢管,也就是刚飞越过的两个钢管,将其属性passed设置为真。这样在后续计算新的observed时,就不会再考虑它们了。
def get_pipe_dist(self):
pipe_2 = [pipe for pipe in self.pipe_group.sprites() if pipe.passed==False][:2]
for pipe in pipe_2:
if pipe.is_top:
self.observed['pipe_dist_right'] = pipe.rect.right
self.observed['pipe_dist_top'] = pipe.rect.bottom
else:
self.observed['pipe_dist_bottom'] = pipe.rect.top
def check_pipe_pass(self):
if self.flappy.rect.left >= self.observed['pipe_dist_right']:
self.score += 1
self.pipe_group.sprites()[0].passed = True
self.pipe_group.sprites()[1].passed = True
self.sounds['point'].play()
钢管有关的处理函数比较多,所以用pipe_update函数将相关函数组合在一起,这样逻辑清晰更有条理。函数包括生成新钢管,更新角色组中所有角色的位置,让其移动起来。只要角色组中还有钢管,就会计算获取观测信息,再检查判断是否飞越空隙。
def pipe_update(self):
self.new_pipes()
self.pipe_group.update()
if len(self.pipe_group)>0:
self.get_pipe_dist()
self.check_pipe_pass()
check_failed函数用于处理游戏失败后的情况,判断failed是否为真,条件满足后,停止背景音乐,再看小鸟有没有碰到地面,在没有碰到地面前,保持动画代码的运行,好让小鸟有一个垂直下坠的效果,当接触到地面后,再让飞行中止,显示重启游戏的按键。
def check_failed(self):
if self.flappy.failed:
pygame.mixer.music.stop()
if self.flappy.touch_ground():
self.button.draw(self.surface)
self.flappy.flying = False
绘图输出
draw_text是用于绘制文字,这是为了在窗口上绘制分值。draw函数整合了所有绘图有关的函数,先绘制背景,再绘制角色,注意到虽然我们在Pipe类和Bird类中都没有定义draw函数,角色组仍然可以绘图,是因为我们是用的继承,只要类中有image属性,它都会自己带上draw函数。注意绘制顺序,背景在最前面,文字在最后面。不同的顺序有不同的效果。
def draw_text(self,text,color,x,y):
img = self.font.render(text, True, color)
self.surface.blit(img,(x,y))
def draw(self):
self.surface.blit(self.images['bg'],(0,0))
self.pipe_group.draw(self.surface)
self.bird_group.draw(self.surface)
self.surface.blit(self.images['ground'],(self.ground_x,self.ground_y))
self.draw_text(f'score: {self.score}', (255, 255, 255), 20, 20)
游戏主循环
最终我们使用play_step函数,将游戏一帧中所有必要逻辑组装进来。首先会判断玩家是否准备好了,以启动小鸟开始飞行,如果失败则重启游戏。然后更新小鸟的状态。如果在正常飞行阶段,则判断碰撞,更新钢管角色组,更新地面状态。最后绘制所有游戏场景,处理游戏失败情况。
def play_step(self):
game_over = False
for event in pygame.event.get():
if event.type == pygame.QUIT:
game_over = True
self.start_flying(event)
self.game_restart(event)
self.bird_group.update()
if not self.flappy.failed and self.flappy.flying:
self.handle_collision()
self.pipe_update()
self.ground_update()
self.draw()
self.check_failed()
pygame.display.update()
self.Clock.tick(self.fps)
return game_over, self.score
最后在文件最后写一个main入口,以实例化游戏类,用一个while循环充当游戏主循环,循环内来调用play_step函数。
if __name__ == '__main__':
game = Game()
while True:
game_over, score = game.play_step()
if game_over == True:
break
print('Final Score', score)
pygame.quit()
本章小结
本章我们用面向对象的编程方法,实现了笨鸟先飞的游戏。这个游戏有许多复杂的游戏机制和显示效果,例如小鸟飞行时的重力效果和动画效果。再例如钢管的随机生成和间隔出现的机制,以及小鸟飞越钢管的判定方法。我们还在游戏中设置了一个按键来和用户进行交互。大家可以反复阅读代码,以理解这些有趣的游戏效果和游戏机制是如何实现的。
在本章我们还学习了OOP的一个新概念,就是“继承”的概念。从已有的父类那里继承不了家产,但可以继承一些已有的属性和方法。PyGame中的Sprite类带有一些函数我们不必再自己定义。另外,我们还学习了PyGame模块中group的使用,当游戏场景中角色比较多的时候,使用group会较为方便。
大家理解本章代码后,可以尝试对代码做一些修改来改变游戏的难易程度。例如修改钢管间空隙的大小,或是修改钢管出现的时间间隔。也可以尝试去修改游戏中的重力影响,或者修改小鸟向上飞行的能力。看看哪种改动更好玩。
前面这三章,我们都是在编写动作类的小游戏。这些游戏都体现了玩家操控所带来的乐趣。在第七章,我们会学习编写另一种游戏,这种游戏体现了玩家运用智力所带来的乐趣,这就是经典的五子棋游戏。