我们来开发一个游戏吧!我们将使用Pygame,这是一组功能强大而有趣的模块,可用于管理图形、动画乃至声音,让你能够更轻松地开发复杂的游戏。通过使用Pygame来处理在屏幕上绘制图像等任务,你不用考虑众多烦琐而艰难的编码工作,而是将重点放在程序的高级逻辑上。
在本章中,你将安装Pygame,再创建一艘能够根据用户输入而左右移动和射击的飞船。在接下来的两章中,你将创建一群作为射杀目标的外星人,并做其他的改进, 如限制可供玩家使用的飞船数以及添加记分牌。
从本章开始,你还将学习管理包含多个文件的项目。我们将重构很多代码,以提高代码的效率,并管理文件的内容,以确保项目组织有序。 创建游戏是趣学语言的理想方式。

看别人玩你编写的游戏让你很有满足感,而编写简单的游戏有助于你明白专业级游戏是怎么编写出来的。在阅读本章的过程中,请动手输入并运行代码,以明白各个代码块对整个游戏所做的贡献,并尝试不同的值和设置,这样你将对如何改进游戏的交互性有更深入的认识。

12.1 规划项目

下面来编写有关游戏《外星人入侵》的描述,其中虽然没有涵盖这款游戏的所有细节,但能让你清楚地知道该如何动手开发它。

在游戏《外星人入侵》中,玩家控制着一艘最初出现在屏幕底部中央的飞船。玩家可以使用箭头键左右移动飞船,还可使用空格键进行射击。游戏开始时,一群外星人出现在天空中,他们在屏幕中向下移动。玩家的任务是射杀这些外星人。玩家将所有外星人都消灭干净后,将出现一群新的外星人,他们移动的速度更快。只要有外星人撞到了玩家的飞船或到达了屏幕底部,玩家就损失一艘飞船。玩家损失三艘飞船后,游戏结束。

在第一个开发阶段,我们将创建一艘可左右移动的飞船,这艘飞船在用户按空格键时能够开火。设置好这种行为后,我们就能够将注意力转向外星人,并提高这款游戏的可玩
性。

12.3 开始游戏项目

现在来开始开发游戏《外星人入侵》。首先创建一个空的Pygame窗口,供后面用来绘制游戏元素,如飞船和外星人。我们还将让这个游戏响应用户输入、设置背景色以及加载飞船图像。

12.3.1 创建Pygame窗口以及响应用户输入

首先,我们创建一个空的Pygame窗口。使用Pygame编写的游戏的基本结构如下:
image.png

pygame.init() 初始化背景设置,让Pygame能够正确地工作
pygame.display.set_mode() 来创建一个名为screen 的显示窗口,这个游戏的所有图形元素都将在其中绘制。实参(1200, 800) 是一个元组,指定了游戏窗口的尺寸。

通过将这些尺寸值传递给pygame.display.set_mode() ,我们创建了一个宽1200像素、高800像素的游戏窗口(你可以根据自己的显示器尺寸调整这些值)。

对象screen 是一个surface。在Pygame中,surface是屏幕的一部分,用于显示游戏元素。在这个游戏中,每个元素(如外星人或飞船)都是一个surface。display.set_mode()返回的surface表示整个游戏窗口。我们激活游戏的动画循环后,每经过一次循环都将自动重绘这个surface。

这个游戏由一个while 循环控制,其中包含一个事件循环以及管理屏幕更新的代码。事件是用户玩游戏时执行的操作,如按键或移动鼠标。为让程序响应事件,我们编写一个事件循环,以侦听事件,并根据发生的事件执行相应的任务。for 循环就是一个事件循环。

为访问Pygame检测到的事件,我们使用方法pygame.event.get() 。所有键盘和鼠标事件都将促使for 循环运行。在这个循环中,我们将编写一系列的if 语句来检测并响应特定的事件。例如,玩家单击游戏窗口的关闭按钮时,将检测到pygame.QUIT 事件,而我们调用sys.exit() 来退出游戏

pygame.display.flip() ,命令Pygame让最近绘制的屏幕可见。

在我们移动游戏元素时,pygame.display.flip() 将不断更新屏幕,以显示元素的新位置,并在原来的位置隐藏元素,从而营造平滑移动的效果。

在这个基本的游戏结构中,最后一行调用run_game() ,这将初始化游戏并开始主循环。
如果此时运行这些代码,你将看到一个空的Pygame窗口。
image.png

12.3.2 设置背景颜色

Pygame默认创建一个黑色屏幕,这太乏味了。下面来将背景设置为另一种颜色:

image.png

利用fill方法来设置背景颜色

image.png

该颜色只需指定一次,因此我们在进入主while 循环前定义它。
在Pygame中,颜色是以RGB值指定的。这种颜色由红色、绿色和蓝色值组成,其中每个值的可能取值范围都为0~255。颜色值(255, 0, 0)表示红色,(0, 255, 0)表示绿色,而(0, 0, 255)表示蓝色。通过组合不同的RGB值,可创建1600万种颜色。在颜色值(230, 230, 230)中,红色、蓝色和绿色量相同,它将背景设置为一种浅灰色。
我们调用方法screen.fill() ,用背景色填充屏幕;这个方法只接受一个实参:一种颜色

12.3.3 创建设置类

每次给游戏添加新功能时,通常也将引入一些新设置。下面来编写一个名为settings 的模块,其中包含一个名为Settings 的类,用于将所有设置存储在一个地方,以免在代码中到处添加设置。

这样,我们就能传递一个设置对象,而不是众多不同的设置。另外,这让函数调用更简单,且在项目增大时修改游戏的外观更容易:要修改游戏,只需修改settings.py中的一些值,而无需查找散布在文件中的不同设置。

image.png

image.png

我们把数据存放在Settings类里面,当我们要使用数据的时候,我们只需要创建一个类的对象。通过类的对象调用类的数据就可以了

12.4 添加飞船图像

下面将飞船加入到游戏中。为了在屏幕上绘制玩家的飞船,我们将加载一幅图像,再使用Pygame方法blit() 绘制它。

为游戏选择素材时,务必要注意许可。最安全、最不费钱的方式是使用http://pixabay.com/ 等网站提供的图形,这些图形无需许可,你可以对其进行修改。
在游戏中几乎可以使用任何类型的图像文件,但使用位图(.bmp)文件最为简单,因为Pygame默认加载位图。虽然可配置Pygame以使用其他文件类型,但有些文件类型要求你在计算机上安装相应的图像库。大多数图像都为.jpg、.png或.gif格式,但可使用Photoshop、GIMP和Paint等工具将其转换为位图。

12.4.1 创建Ship类

选择用于表示飞船的图像后,需要将其显示到屏幕上。我们将创建一个名为ship 的模块,其中包含Ship 类,它负责管理飞船的大部分行为。

首先,我们导入了模块pygame 。Ship 的方法init() 接受两个参数:引用self 和screen ,其中后者指定了要将飞船绘制到什么地方。为加载图像,我们调用了pygame.image.load() (见❶)。这个函数返回一个表示飞船的surface,而我们将这个surface存储到了self.image 中。

加载图像后,我们使用get_rect() 获取相应surface的属性rect (见❷)。Pygame的效率之所以如此高,一个原因是它让你能够像处理矩形(rect 对象)一样处理游戏元素,即便它们的形状并非矩形。像处理矩形一样处理游戏元素之所以高效,是因为矩形是简单的几何形状。这种做法的效果通常很好,游戏玩家几乎注意不到我们处理的不是游戏元素的实际形状。

处理rect 对象时,可使用矩形四角和中心的 x y 坐标。可通过设置这些值来指定矩形的位置。

要将游戏元素居中,可设置相应rect 对象的属性center 、centerx 或centery 。要让游戏元素与屏幕边缘对齐,可使用属性top 、bottom 、left 或right ;要调整游戏元素的水平或垂直位置,可使用属性x 和y ,它们分别是相应矩形左上角的 x y 坐标。这些属性让你无需去做游戏开发人员原本需要手工完成的计算,你经常会用到这些属性。

image.png

12.4.2 在屏幕上绘制飞船

下面来更新alien_invasion.py,使其创建一艘飞船,并调用其方法blitme():
image.png
我们导入Ship 类,并在创建屏幕后创建一个名为ship 的Ship 实例。必须在主while 循环前面创建该实例,以免每次循环时都创建一艘飞船。填充背景后,我们调用ship.blitme() 将飞船绘制到屏幕上,确保它出现在背景前面(见❷)。
现在如果运行alien_invasion.py,将看到飞船位于空游戏屏幕底部中央,如图12-2所示。
image.png

12.5 重构:模块game_functions

在大型项目中,经常需要在添加新代码前重构既有代码。重构旨在简化既有代码的结构,使其更容易扩展。在本节中,我们将创建一个名为game_functions 的新模块,它将存储大量让游戏《外星人入侵》运行的函数。通过创建模块game_functions ,可避免alien_invasion.py太长,并使其逻辑更容易理解。

12.5.1 函数check_events()

我们将首先把管理事件的代码移到一个名为check_events() 的函数中,以简化run_game() 并隔离事件管理循环。通过隔离事件循环,可将事件管理与游戏的其他方面(如更新屏幕)分离。
将check_events() 放在一个名为game_functions 的模块中:

image.png

这个模块中导入了事件检查循环要使用的sys 和pygame 。当前,函数check_events() 不需要任何形参,其函数体复制了alien_invasion.py的事件循环。
下面来修改alien_invasion.py,使其导入模块game_functions ,并将事件循环替换为对函数check_events() 的调用:

image.png

12.5.2 函数update_screen()

image.png
image.png

12.6 驾驶飞船

下面来让玩家能够左右移动飞船。为此,我们将编写代码,在用户按左或右箭头键时作出响应。我们将首先专注于向右移动,再使用同样的原理来控制向左移动。通过这样做,你将学会如何控制屏幕图像的移动。

12.6.1 响应按键

每当用户按键时,都将在Pygame中注册一个事件。事件是通过方法pygame.event.get()获取的,因此在函数check_events()中,我们需要指定检查哪些类型的事件。每次按键都被注册为一个KEYDOWN事件。

检测到KEYDOWN事件时,我们需要检查按下的是否是指定的键。例如,如果按下的是右箭头键,我们就增大飞船的rect.centerx值,将飞船向右移动。

image.png

如果现在运行alien_invasion.py,则每按右箭头键一次,飞船都将向右移动1像素。这是一个开端,但并非控制飞船的高效方式。下面来改进控制方式,允许持续移动。

12.6.2 允许不断移动

玩家按住右箭头键不放时,我们希望飞船不断地向右移动,直到玩家松开为止。我们将让游戏检测pygame.KEYUP 事件,以便玩家松开右箭头键时我们能够知道这一点;然后,我们将结合使用KEYDOWN 和KEYUP 事件,以及一个名为moving_right 的标志来实现持续移动。

飞船不动时,标志moving_right 将为False 。玩家按下右箭头键时,我们将这个标志设置为True ;而玩家松开时,我们将这个标志重新设置为False 。

飞船的属性都由Ship 类控制,因此我们将给这个类添加一个名为moving_right 的属性和一个名为update() 的方法。方法update() 检查标志moving_right 的状态,如果这个标志为True ,就调整飞船的位置。每当需要调整飞船的位置时,我们都调用这个方法。

image.png

在方法init() 中,我们添加了属性self.moving_right ,并将其初始值设置为False (见❶)。接下来,我们添加了方法update() ,它在前述标志为True 时向右移动飞船。

下面来修改check_events() ,使其在玩家按下右箭头键时将moving_right 设置为True ,并在玩家松开时将moving_right 设置为False :

image.png

12.6.3 左右移动

飞船能够不断地向右移动后,添加向左移动的逻辑很容易。我们将再次修改Ship 类和函数checkevents() 。下面显示了对Ship 类的方法_init() 和update() 所做的相关修改:

image.png
如果因玩家按下K_LEFT 键而触发了KEYDOWN 事件,我们就将moving_left 设置为True ;如果因玩家松开K_LEFT 而触发了KEYUP 事件,我们就将moving_left 设置为False 。

这里之所以可以使用elif 代码块,是因为每个事件都只与一个键相关联;如果玩家同时按下了左右箭头键,将检测到两个不同的事件。

如果此时运行alien_invasion.py,将能够不断地左右移动飞船;如果你同时按左右箭头键,飞船将纹丝不动。
下面来进一步优化飞船的移动方式:调整飞船的速度;限制飞船的移动距离,以免它移到屏幕外面去。

12.6.4 调整飞船的速度

当前,每次执行while 循环时,飞船最多移动1像素,但我们可以在Settings 类中添加属性ship_speed_factor ,用于控制飞船的速度。

我们将根据这个属性决定飞船在每次循环时最多移动多少距离。下面演示了如何在settings.py中添加这个新属性:

image.png

我们将ship_speed_factor 的初始值设置成了1.5 。需要移动飞船时,我们将移动1.5像素而不是1像素。

通过将速度设置指定为小数值,可在后面加快游戏的节奏时更细致地控制飞船的速度。然而,rect 的centerx 等属性只能存储整数值,因此我们需要对Ship 类做些修改:

image.png

image.png

我们在init()的形参列表中添加了ai_settings,让飞船能够获取其速度设置。接下来,我们将形参ai_settings的值存储在一个属性中,以便能够在update()中使用它。鉴于现在调整飞船的位置时,将增加或减去一个单位为像素值小数,因此需要将位置存储在一个能够存储小数值的变量中。可以使用小数来设置rect的属性,但rect将只存储这个值的整数部分。为准确地存储飞船的位置,我们定义了一个可以存储小数值的新属性self.center。我们使用函数float()将self.rect.centerx的值转换为小数,并将结果存储到self.center中。

现在在update()中调整飞机的位置时,将self.center的值增加或减去ai_settings.ship_speed_factor的值。更新self.center后,我们再根据它来更新控制飞船位置的self.rect.centerx。self.rect.centerx 将只存储self.center 的整数部分,但对显示飞船而言,这问题不大。
在alien_invasion.py中创建Ship 实例时,需要传入实参ai_settings

12.6.5 限制飞船的活动范围

当前,如果玩家按住箭头键的时间足够长,飞船将移到屏幕外面,消失得无影无踪。下面来修复这种问题,让飞船到达屏幕边缘后停止移动。为此,我们将修改Ship 类的方法update()

image.png

只要让移动语句在一定范围内生效即可

12.6.6 重构check_events()

随着游戏开发的进行,函数check_events() 将越来越长,我们将其部分代码放在两个函数中:一个处理KEYDOWN 事件,另一个处理KEYUP 事件:

image.png

image.png

12.7 简单回顾

下一节将添加射击功能,这需要新增一个名为bullet.py的文件,并对一些既有文件进行修改。当前,我们有四个文件,其中包含很多类、函数和方法。添加其他功能之前,为让你清楚这个项目的组织结构,先来回顾一下这些文件。

12.7.1 alien_invasion.py

主文件alien_invasion.py创建一系列整个游戏都要用到的对象:存储在ai_settings 中的设置、存储在screen 中的主显示surface以及一个飞船实例。文件alien_invasion.py还包含游戏的主循环,这是一个调用check_events() 、ship.update() 和update_screen() 的while 循环。
要玩游戏《外星人入侵》,只需运行文件alien_invasion.py。其他文件
(settings.py、game_functions.py、ship.py)包含的代码被直接或间接地导入到这个文件中。

12.7.2 settings.py

文件settings.py包含Settings 类,这个类只包含方法init() ,它初始化控制游戏外观和飞船速度的属性。

12.7.3 game_functions.py

文件game_functions.py包含一系列函数,游戏的大部分工作都是由它们完成的。函数check_events() 检测相关的事件,如按键和松开,并使用辅助函数check_keydown_events() 和check_keyup_events() 来处理这些事件。
就目前而言,这些函数管理飞船的移动。模块game_functions 还包含函数update_screen() ,它用于在每次执行主循环时都重绘屏幕。

12.7.4 ship.py

文件ship.py包含Ship 类,这个类包含方法init() 、管理飞船位置的方法update() 以及在屏幕上绘制飞船的方法blitme() 。表示飞船的图像存储在文件夹images下的 文件ship.bmp中。

12.8 射击

下面来添加射击功能。我们将编写玩家按空格键时发射子弹(小矩形)的代码。子弹将在屏幕中向上穿行,抵达屏幕上边缘后消失。

12.8.1 添加子弹设置

首先,更新settings.py,在其方法init() 末尾存储新类Bullet 所需的值:

image.png

12.8.2 创建Bullet类

下面来创建存储Bullet 类的文件bullet.py,其前半部分如下:
image.png

Bullet 类继承了我们从模块pygame.sprite中导入的Sprite类。通过使用精灵,可将游戏中相关的元素编组,进而同时操作编组中的所有元素。为创建子弹实例,需要向init()传递ai_settings、screen和ship实例,还调用了super()来继承Sprite

我们创建了子弹的属性rect 。子弹并非基于图像的,因此我们必须使用pygame.Rect() 类从空白开始创建一个矩形。创建这个类的实例时,必须提供矩形左上角的x 坐标和 y 坐标,还有矩形的宽度和高度。我们在(0, 0)处创建这个矩形,但接下来的两行代码将其移到了正确的位置,因为子弹的初始位置取决于飞船当前的位置。子弹的宽度和高度是从ai_settings 中获取的。

我们将子弹的centerx 设置为飞船的rect.centerx 。子弹应从飞船顶部射出,因此我们将表示子弹的rect 的top 属性设置为飞船的rect 的top 属性,让子弹看起来像是从飞船中射出的

我们将子弹的 y 坐标存储为小数值,以便能够微调子弹的速度
image.png

方法update() 管理子弹的位置。发射出去后,子弹在屏幕中向上移动,这意味着y 坐标将不断减小,因此为更新子弹的位置,我们从self.y 中减去self.speedfactor 的值。接下来,我们将self.rect.y 设置为self.y 的值。属性speed_factor 让我们能够随着游戏的进行或根据需要提高子弹的速度,以调整游戏的行为。子弹发射后,其 _x 坐标始终不变,因此子弹将沿直线垂直地往上穿行。

12.8.3 将子弹存储到编组中

定义Bullet类和必要的设置之后,就可以编写代码了,在玩家每次按空格键时都射出一发子弹。首先,我们将在alien_invasion.py中创建一个编组(group),用于存储所有有效的子弹,以便能够管理发射出去的所有子弹。

这个编组是pygame.sprite.Group类的一个实例;pygame.sprite.Group类类似于列表,但提供了有助于游戏开发的额外功能。在主循环中,我们将使用这个编组在屏幕上绘制子弹,以及更新每颗子弹的位置:
image.png
image.png

我们导入了pygame.sprite中的Group类。我们创建了一个Group实例,并将其命名为bullets。这个编组是在while循环外面创建的,这样就无需每次运行该循环时都创建一个新的子弹编组。

我们将bullets 传递给check_events()和update_screen()。在check_events()中,需要玩家在按空格时处理bullets而在update_screen() 中,需要更新要绘制到屏幕上的bullets 。

12.8.4 开火

在game_functions.py中,我们还需要修改check_keydown_events(),以便在玩家按空格键时发射一颗子弹。我们无需修改check_keyup_events(),因为玩家松开空格键什么都不会发生。
我们还需修改update_screen(),确保在调用flip()前屏幕上重绘每颗子弹。

image.png

image.png

编组bulltes 传递给了check_keydown_events() 。玩家按空格键时,创建一颗新子弹(一个名为new_bullet 的Bullet 实例),并使用方法add() 将其加入到编组bullets 中;代码bullets.add(new_bullet) 将新子弹存储到编组bullets 中。
在check_events() 的定义中,我们需要添加形参bullets ;调用check_keydown_events() 时,我们也需要将bullets 作为实参传递给它。
我们给在屏幕上绘制子弹的update_screen() 添加了形参bullets 。方法bullets.sprites() 返回一个列表,其中包含编组bullets 中的所有精灵。为在屏幕上绘制发射的所有子弹,我们遍历编组bullets 中的精灵,并对每个精灵都调用draw_bullet() 。

如果此时运行alien_invasion.py,将能够左右移动飞船,并发射任意数量的子弹。子弹在屏幕上向上穿行,抵达屏幕顶部后消失,如图12-3所示。可在settings.py中修改子弹的尺寸、 颜色和速度。

12.8.5 删除已经消失的子弹

当前,子弹抵达屏幕顶端后消失,这仅仅是因为Pygame无法在屏幕外面绘制它们。这些子弹实际上依然存在,它们的 y 坐标为负数,且越来越小。这是个问题,因为它们将继续消耗内存和处理能力。

我们需要将这些已消失的子弹删除,否则游戏所做的无谓工作将越来越多,进而变得越来越慢。为此,我们需要检测这样的条件,即表示子弹的rect 的bottom 属性为零,它表明子弹已穿过屏幕顶端:

image.png

在for循环中,不应从列表或编组中删除条目,因此必须遍历编组的副本。我们使用了方法copy()来设置for循环
这让我们能够在循环中修改bullets。我们检查每颗子弹,看看它是否已经从屏幕顶端消失。如果是这样,就将其从bullets中删除。

如果这些代码没有问题,我们发射子弹后查看终端窗口时,将发现随着子弹一颗颗地在屏幕顶端消失,子弹数将逐渐降为零。运行这个游戏并确认子弹已被删除后,将这条print 语句删除。如果你留下这条语句,游戏的速度将大大降低,因为将输出写入到终端而花费的时间比将图形绘制到游戏窗口花费的时间还多。

12.8.6 限制子弹数量

很多射击游戏都对可同时出现在屏幕上的子弹数量进行限制,以鼓励玩家有目标地射击。下面在游戏《外星人入侵》中作这样的限制。

首先,在settings.py中存储所允许的最大子弹数:

image.png

image.png
玩家按空格键时,我们检查bullets 的长度。如果len(bullets) 小于3,我们就创建一个新子弹;但如果已有3颗未消失的子弹,则玩家按空格键时什么都不会发生。如果你现在运行这个游戏,屏幕上最多只能有3颗子弹。

12.8.7 创建函数update_bullets()

编写并检查子弹管理代码后,可将其移到模块game_functions 中,以让主程序文件alien_invasion.py尽可能简单。我们创建一个名为update_bullets() 的新函数,并将其添加到game_functions.py的末尾:

image.png
image.png

12.8.8 创建函数fire_bullet()

下面将发射子弹的代码移到一个独立的函数中,这样,在check_keydown_events() 中只需使用一行代码来发射子弹,让elif 代码块变得非常简单:
image.png
image.png

函数fire_bullet() 只包含玩家按空格键时用于发射子弹的代码;在check_keydown_events() 中,我们在玩家按空格键时调用fire_bullet() 。
请再次运行alien_invasion.py,确认发射子弹时依然没有错误。

12.9 小结

在本章中,你学习了:游戏开发计划的制定;使用Pygame编写的游戏的基本结构;
如何设置背景色,以及如何将设置存储在可供游戏的各个部分访问的独立类中;
如何在屏幕上绘制图像,以及如何让玩家控制游戏元素的移动;
如何创建自动移动的元素,如在屏幕中向上飞驰的子弹,以及如何删除不再需要的对象;
如何定期重构项目的代码,为后续开发提供便利。
在第13章中,我们将在游戏《外星人入侵》中添加外星人。在第13章结束时,你将能够击落外星人——但愿是在他们撞到飞船前!