在本章中,我们将在游戏《外星人入侵》中添加外星人。首先,我们在屏幕上边缘附近添加一个外星人,然后生成一群外星人。我们让这群外星人向两边和下面移动,并删除被子弹击中的外星人。最后,我们将显示玩家拥有的飞船数量,并在玩家的飞船用完后结束游戏。

通过阅读本章,你将更深入地了解Pygame和大型项目的管理。你还将学习如何检测游戏对象之间的碰撞,如子弹和外星人之间的碰撞。检测碰撞有助于你定义游戏元素之间的交互:可以将角色限定在迷宫墙壁之内或在两个角色之间传球。我们将时不时地查看游戏开发计划,以确保编程工作不偏离轨道。

着手编写在屏幕上添加一群外星人的代码前,先来回顾一下这个项目,并更新开发计划

13.1 回顾项目

开发较大的项目时,进入每个开发阶段前回顾一下开发计划,搞清楚接下来要通过编写代码来完成哪些任务是不错的主意。

  • 研究既有代码,确定实现新功能前是否要进行重构
  • 在屏幕左上角添加一个外星人,并指定适合的边距
  • 根据第一个外星人的边距和屏幕尺寸计算屏幕上可容纳多少个外星人。我们将创建一个循环来创建一系列外星人,这些外星人填满了屏幕的上半部分。
  • 让外星人群向两边和下方移动,直到外星人被全部击落,有外星人撞到飞船,或有外星人抵达屏幕底端。如果整群外星人都被击落,我们将再创建一群外星人。如果有外星人撞到了飞船或抵达屏幕底端,我们将销毁飞船并再创建一群外星人。
  • 限制玩家可用的飞船数量,配给的飞船用完后,游戏结束。

我们将在实现功能的同时完善这个计划,但就目前而言,该计划已足够详尽。

在给项目添加新功能前,还应审核既有代码。每进入一个新阶段,通常项目都会更复杂,因此最好对混乱或低效的代码进行清理。

我们在开发的同时一直不断地重构,因此当前需要做的清理工作不多,但每次为测试新功能而运行这个游戏时,都必须使用鼠标来关闭它,这太讨厌了。下面来添加一个结束游戏的快捷键Q:

image.png

在check_keydown_events() 中,我们添加了一个代码块,以便在玩家按Q时结束游戏。这样的修改很安全,因为Q键离箭头键和空格键很远,玩家不小心按Q键而导致游戏结束的可能性不大。现在测试时可按Q关闭游戏,而无需使用鼠标来关闭窗口了。

13.2 创建第一个外星人

在屏幕上放置外星人与放置飞船类似。每个外星人的行为都由Alien 类控制,我们将像创建Ship 类那样创建这个类。

image.png
👆写错了,这里忘记用父类进行初始化了。

image.png
如果没用父类进行初始化,之后运行会报错,这样我们就没办法使用父类的方法和属性了。

13.3 创建一群外星人

要绘制一群外星人,需要确定一行能容纳多少个外星人以及要绘制多少行外星人。我们将首先计算外星人之间的水平间距,并创建一行外星人,再确定可用的垂直空间,并创建整群外星人。

13.3.1 确定一行可容纳多少个外星人

为确定一行可容纳多少个外星人,我们来看看可用的水平空间有多大。屏幕宽度存储在ai_settings.screen_width 中,但需要在屏幕两边都留下一定的边距,把它设置为外星人的宽度。由于有两个边距,因此可用于放置外星人的水平空间为屏幕宽度减去外星人宽度的两倍:

image.png

image.png

13.3.2 创建多行外星人

为创建一行外星人,首先在alien_invasion.py中创建一个名为aliens 的空编组,用于存储全部外星人,再调用game_functions.py中创建外星人群的函数:

image.png
这些代码大都在前面详细介绍过。为放置外星人,我们需要知道外星人的宽度和高度,因此在执行计算前,我们先创建一个外星人(见❶)。这个外星人不是外星人群的成员,因此没有将它加入到编组aliens 中。在❷处,我们从外星人的rect 属性中获取外星人宽度,并将这个值存储到alien_width 中,以免反复访问属性rect 。在❸处,我们计算可用于放置外星人的水平空间,以及其中可容纳多少个外星人。

相比于前面介绍的工作,这里唯一的不同是使用了int() 来确保计算得到的外星人数量为整数(见❹),因为我们不希望某个外星人只显示一部分,而且函数range() 也需要一个整数。函数int() 将小数部分丢弃,相当于向下圆整(这大有裨益,因为我们宁愿每行都多出一点点空间,也不希望每行的外星人之间过于拥挤)。

接下来,我们编写了一个循环,它从零数到要创建的外星人数(见❺)。在这个循环的主体中,我们创建一个新的外星人,并通过设置x 坐标将其加入当前行(见❻)。
将每个外星人都往右推一个外星人的宽度。接下来,我们将外星人宽度乘以2,得到每个外星人占据的空间(其中包括其右边的空白区域),再据此计算当前外星人在当前行的位置。最后,我们将每个新创建的外星人都添加到编组aliens 中。

image.png

这行外星人在屏幕上稍微偏向了左边,这实际上是有好处的,因为我们将让外星人群往右移,触及屏幕边缘后稍微往下移,然后往左移,以此类推。就像经典游戏《太空入侵者》,相比于只往下移,这种移动方式更有趣。我们将让外形人群不断这样移动,直到所有外星人都被击落或有外星人撞上飞船或抵达屏幕底端。

13.3.4 重构create_fleet()

倘若我们创建了外星人群,也许应该让create_fleet() 保持原样,但鉴于创建外星人的工作还未完成,我们稍微清理一下这个函数。下面是create_fleet() 和两个新函数,get_number_aliens_x() 和create_alien() :

image.png

这是添加在alien.py里面的。我认为这两个函数应该要放在alien.py里面,更符合逻辑。

image.png

13.3.5 添加行

要创建外星人群,需要计算屏幕可容纳多少行,并对创建一行外星人的循环重复相应的次数。为计算可容纳的行数,我们这样计算可用垂直空间:将屏幕高度减去第一行外星人的上边距(外星人高度)、飞船的高度以及最初外星人群与飞船的距离(外星人高度的两倍):

image.png

这将在飞船上方留出一定的空白区域,给玩家留出射杀外星人的时间。
每行下方都要留出一定的空白区域,并将其设置为外星人的高度。
为计算可容纳的行数,我们将可用垂直空间除以外星人高度的两倍(同样,如果这样的计算不对,我们马上就能发现,继而将间距调整为合理的值)。

image.png

知道可容纳多少行后,便可重复执行创建一行外星人的代码:

image.png

为计算屏幕可容纳多少行外星人,我们在函数get_number_rows() 中实现了前面计算available_space_y 和number_rows 的公式(见❶),这个函数与get_number_aliens_x() 类似。
计算公式用括号括起来了,这样可将代码分成两行,以遵循每行不超过79字符的建议(见❷)。这里使用了int() ,因为我们不想创建不完整的外星人行。

为创建多行,我们使用两个嵌套在一起的循环:一个外部循环和一个内部循环(见❸)。其中的内部循环创建一行外星人,而外部循环从零数到要创建的外星人行数。Python将重复执行创建单行外星人的代码,重复次数为number_rows 。

为嵌套循环,我们编写了一个新的for 循环,并缩进了要重复执行的代码。(在大多数文本编辑器中,缩进代码块和取消缩进都很容易,详情请参阅附录B。)我们调用create_alien() 时,传递了一个表示行号的实参,将每行都沿屏幕依次向下放置。

createalien() 的定义需要一个用于存储行号的形参。在create_alien() 中,我们修改外星人的_y 坐标(见❹),并在第一行外星人上方留出与外星人等高的空白区域。相邻外星人行的y 坐标相差外星人高度的两倍,因此我们将外星人高度乘以2,再乘以行号。第一行的行号为0,因此第一行的垂直位置不变,而其他行都沿屏幕依次向下放置。

在create_fleet() 的定义中,还新增了一个用于存储ship 对象的形参,因此在alien_invasion.py中调用create_fleet() 时,需要传递实参ship :

image.png

13.4 让外星人群移动

下面来让外星人群在屏幕上向右移动,撞到屏幕边缘后下移一定的距离,再沿相反的方向移动。我们将不断地移动所有的外星人,直到所有外星人都被消灭,有外星人撞上飞船,或有外星人抵达屏幕底端。
下面先来让外星人向右移动。

13.4.1 向右移动外星人

为移动外星人,我们将使用alien.py中的方法update() ,且对外星人群中的每个外星人都调用它。
首先,添加一个控制外星人速度的设置:

image.png

13.4.2 创建表示外星人移动方向的设置

下面来创建让外星人撞到屏幕右边缘后向下移动、再向左移动的设置。实现这种行为的代码如下:

image.png

设置fleet_drop_speed 指定了有外星人撞到屏幕边缘时,外星人群向下移动的速度。将这个速度与水平速度分开是有好处的,这样你就可以分别调整这两种速度了。

要实现fleetdirection 设置,可以将其设置为文本值,如’left’ 或’right’ ,但这样就必须编写if-elif 语句来检查外星人群的移动方向。鉴于只有两个可能的方向,我们使用值1和-1来表示它们,并在外星人群改变方向时在这两个值之间切换。另外,鉴于向右移动时需要增大每个外星人的_x 坐标,而向左移动时需要减小每个外星人的x 坐标,使用数字来表示方向更合理。

13.4.3 检查外星人是否撞到了屏幕的边缘

现在需要编写一个方法来检查是否有外星人撞到了屏幕边缘,还需修改update() ,以让每个外星人都沿正确的方向移动:

image.png

我们可对任何外星人调用新方法check_edges() ,看看它是否位于屏幕左边缘或右边缘。如果外星人的rect 的right 属性大于或等于屏幕的rect 的right 属性,就说明外星人位于屏幕右边缘(见❶)。如果外星人的rect 的left 属性小于或等于0,就说明外星人位于屏幕左边缘(见❷)。

我们修改了方法update() ,将移动量设置为外星人速度和fleetdirection 的乘积,让外星人向左或向右移。如果fleet_direction 为1,就将外星人当前的 _x 坐标增大alienspeed_factor ,从而将外星人向右移;如果fleet_direction 为-1,就将外星人当前的 _x 坐标减去alien_speed_factor ,从而将外星人向左移。

13.4.4 向下移动外星人群并改变移动方向

有外星人到达屏幕边缘时,需要将整群外星人下移,并改变它们的移动方向。我们需要对game_functions.py做重大修改,因为我们要在这里检查是否有外星人到达了左边缘或右边缘。
为此,我们编写函数check_fleet_edges() 和change_fleet_direction() ,并对update_aliens() 进行修改:

image.png

13.5 射杀外星人

我们创建了飞船和外星人群,但子弹击中外星人时,将穿过外星人,因为我们还没有检查碰撞。在游戏编程中,碰撞指的是游戏元素重叠在一起。要让子弹能够击落外星人,我们将使用sprite.groupcollide() 检测两个编组的成员之间的碰撞。

13.5.1 检测子弹与外星人的碰撞

子弹击中外星人时,我们要马上知道,以便碰撞发生后让外星人立即消失。为此,我们将在更新子弹的位置后立即检测碰撞。

方法sprite.groupcollide()将每颗子弹的rect同外星人的rect作比较,并返回一个字典,其中包含发生了碰撞的子弹和外星人。在这个字典中,每个键都是一颗子弹,而相应的值都是被击中的外星人。

在函数update_bullets()中,使用下面的代码来检查碰撞:

image.png

新增的这行代码遍历数组bullets中的每颗子弹,再遍历数组aliens中的每个外星人。每当有子弹和外星人的rect重叠时,groupcollide() 就在它返回的字典中添加一个键-值对。两个实参True告诉Pygame删除发生碰撞的子弹和外星人。

(要模拟能够穿行到屏幕顶端的高能子弹——消灭它击中的每个外星人,可将第一个布尔实参设置
为False ,并让第二个布尔实参为True 。这样被击中的外星人将消失,但所有的子弹都始终有效,直到抵达屏幕顶端后消失。)

13.5.2 为测试创建大子弹

只需通过运行这个游戏就可以测试其很多功能,但有些功能在正常情况下测试起来比较烦琐。例如,要测试代码能否正确地处理外星人编组为空的情形,需要花很长时间将屏幕上的外星人都击落。

测试有些功能时,可以修改游戏的某些设置,以便专注于游戏的特定方面。例如,可以缩小屏幕以减少需要击落的外星人数量,也可以提高子弹的速度,以便能够在单位时间内发射大量子弹。

测试这个游戏时,我喜欢做的一项修改是增大子弹的尺寸,使其在击中外星人后依然有效,如图13-6所示。请尝试将bullet_width 设置为300,看看将所有外星人都射杀有多快!

类似这样的修改可提高测试效率,还可能激发出如何赋予玩家更大威力的思想火花。(完成测试后,别忘了将设置恢复正常。)

13.5.3 生成新的外星人群

这个游戏的一个重要特点是外星人无穷无尽,一个外星人群被消灭后,又会出现一群外星人。
要在外星人群被消灭后又显示一群外星人,首先需要检查编组aliens 是否为空。如果为空,就调用create_fleet() 。我们将在update_bullets() 中执行这种检查,因为外星人都是在这里被消灭的:

image.png

13.5.4 提高子弹的速度

如果你现在尝试在这个游戏中射杀外星人,可能发现子弹的速度比以前慢,这是因为在每次循环中,Pygame需要做的工作更多了。为提高子弹的速度,可调整settings.py
中bullet_speed_factor 的值。例如,如果将这个值增大到3,子弹在屏幕上向上穿行的速度将变得相当快:

image.png

13.5.5 重构update_bullets()

下面来重构update__bullets(),使其不再完成那么多任务。我们把处理子弹和外星人碰撞的代码移到一个独立的函数中;

image.png

13.6 结束游戏

如果玩家根本不会输,游戏还有什么趣味和挑战性可言?如果玩家没能在足够短的时间内将整群外星人都消灭干净,且有外星人撞到了飞船,飞船将被摧毁。与此同时,我们还 限制了可供玩家使用的飞船数,而有外星人抵达屏幕底端时,飞船也将被摧毁。玩家用光了飞船后,游戏便结束。

13.6.1 检测外星人和飞船碰撞

image.png

方法spritecollideany() 接受两个实参:一个精灵和一个编组。它检查编组是否有成员与精灵发生了碰撞,并在找到与精灵发生了碰撞的成员后就停止遍历编组。在这里, 它遍历编组aliens ,并返回它找到的第一个与飞船发生了碰撞的外星人。

如果没有发生碰撞,spritecollideany() 将返回None ,因此❶处的if 代码块不会执行。如果找到了与飞船发生碰撞的外星人,它就返回这个外星人,因此if 代码块将执行:打印“Ship hit!!!”(见❷)。

(有外星人撞到飞船时,需要执行的任务很多:需要删除余下的所有外星人和子弹,让飞船重新居中,以及创建一群新的外星人。编写完成这些任务的代码前,需要确定检测外星人和飞船碰撞的方法是否可行。而为确定这一点,最简单的方式是编写一条print 语句。)

现在,我们需要将ship 传递给update_aliens()

13.6.2 响应外星人和飞船碰撞

现在需要确定外星人与飞船发生碰撞时,该做些什么。我们不销毁ship 实例并创建一个新的ship 实例,而是通过跟踪游戏的统计信息来记录飞船被撞了多少次(跟踪统计信息还有助于记分)。

下面来编写一个用于跟踪游戏统计信息的新类——GameStats ,并将其保存为文件game_stats.py:

image.png

在这个游戏运行期间,我们只创建一个GameStats 实例,但每当玩家开始新游戏时,需要重置一些统计信息。为此,我们在方法resetstats() 中初始化大部分统计信息,而不是在_init() 中直接初始化它们。

我们在init() 中调用这个方法,这样创建GameStats 实例时将妥善地设置这些统计信息(见❶),同时在玩家开始新游戏时也能调用reset_stats() 。

当前只有一项统计信息——ships_left ,其值在游戏运行期间将不断变化。一开始玩家拥有的飞船数存储在settings.py的ship_limit 中:

image.png

我们导入了新类GameStats (见❶),创建了一个名为stats 的实例(见❷),再调用update_aliens() 并添加了实参stats 、screen 和ship (见❸)。在有外星人撞到飞船时,我们将使用这些实参来跟踪玩家还有多少艘飞船,以及创建一群新的外星人。
有外星人撞到飞船时,我们将余下的飞船数减1,创建一群新的外星人,并将飞船重新放置到屏幕底端中央(我们还将让游戏暂停一段时间,让玩家在新外星人群出现前注意到发生了碰撞,并将重新创建外星人群)。
下面将实现这些功能的大部分代码放到函数ship_hit() 中:

image.png

为让飞船居中,我们将飞船的属性center 设置为屏幕中心的x 坐标,而该坐标是通过属性screen_rect 获得的。
注意:我们根本没有创建多艘飞船,在整个游戏运行期间,我们都只创建了一个飞船实例,并在该飞船被撞到时将其居中。统计信息ships_left 让我们知道飞船
是否用完。

请运行这个游戏,射杀几个外星人,并让一个外星人撞到飞船。游戏暂停后,将出现一群新的外星人,而飞船将在屏幕底端居中。

13.6.3 有外星人到达屏幕底端

如果有外星人到达屏幕底端,我们将像有外星人撞到飞船那样作出响应。请添加一个执行这项任务的新函数,并将其命名为update_aliens()

image.png

13.6.4 游戏结束

现在这个游戏看起来更完整了,但它永远都不会结束,只是ships_left 不断变成更小的负数。下面在GameStats 中添加一个作为标志的属性game_active ,以便在玩家的飞船用完后结束游戏:

image.png

image.png

ship_hit() 的大部分代码都没变。我们将原来的所有代码都移到了一个if 语句块中,这条if 语句检查玩家是否至少还有一艘飞船。如果是这样,就创建一群新的外星人,暂停一会儿,再接着往下执行。如果玩家没有飞船了,就将game_active 设置为False

13.7 确定应运行游戏的哪些部分

在alien_invasion.py中,我们需要确定游戏的哪些部分在任何情况下都应运行,哪些部分仅在游戏处于活动状态时才运行:

image.png

在主循环中,在任何情况下都需要调用check_events() ,即便游戏处于非活动状态时亦如此。例如,我们需要知道玩家是否按了Q键以退出游戏,或单击关闭窗口的按钮。
我们还需要不断更新屏幕,以便在等待玩家是否选择开始新游戏时能够修改屏幕。其他的函数仅在游戏处于活动状态时才需要调用,因为游戏处于非活动状态时,我们不用更新游戏元素的位置。
现在,你运行这个游戏时,它将在飞船用完后停止不动