通过构建游戏学习-Python-全-
通过构建游戏学习 Python(全)
原文:
zh.annas-archive.org/md5/8d68d722c94aedcc91006ddf3f78c65a译者:飞龙
前言
2018 年 9 月,我正在教一些学生使用 Python 进行游戏编程和自动化。然后,我意识到是时候创建一本书,不仅提供关于使用 Python 进行游戏编程丰富内容的信息,还展示如何制作和部署模仿真实世界著名游戏(如 Flappy Bird 和 Angry Birds)的游戏。我希望为你提供成为真正的 Python 游戏开发人员所需的所有基本要素和原语。这本书不是你通常的传统 Python 理论书籍;我们的方法将尽可能地实用。每一章将包含一个单一但强大的真实世界游戏示例,这不仅会很有趣,还会教导你编程范式,这将是成为熟练的 Python 开发人员的第一步。
Python 是 2018/19 年最广泛使用的编程语言之一,根据 Stack Overflow 和 TIOBE 进行的调查,它的受欢迎程度增长的速度预计不会很快减少。如果你观察大型科技公司用于处理业务的工具,你会发现他们高度依赖 Python,因为它易于使用和快速原型设计。不仅如此,你还会发现 Python 可以用于开发从数据科学到高端网络应用程序等各种应用,当你开始学习 Python 的基础知识时,你将能够几乎创造任何你想要的东西。
学习 Python 有很多理由,其中一个重要的原因是 Python 社区。世界上许多最优秀的开发人员不断为 Python 社区做出贡献,添加新的库/模块和功能。如果你想要快速创建新的东西,这些库将非常有帮助。因此,Python 专注于产品而不是陷入低级编程的例行程序和复杂性,这使它成为初学者最喜爱的编程语言。
在本书中,我们将首先介绍一些重要的编程概念,如变量、数字、布尔逻辑、条件和循环。在建立了核心编程概念的坚实基础之后,我们将进入数据结构和函数等高级部分。随着章节难度的增加,学习的速度也会加快。在完成第七章《列表推导和属性》之后,我们将完全掌握所有基础知识,可以应用于创建高级内容,如 Flappy Bird 模拟器、Angry Bird 模拟器和 AI 玩家。在每一章中,都会有一个“游戏测试和可能的修改”主题,迫使你思考如何处理错误以及如何完善程序。
本书的要求
为了更好地掌握本书中所述的每个主题,我鼓励你跟着源代码和示例一起学习。为了正确编写代码,你需要在你的计算机上安装 Python。我使用的是 Python 的最新版本(截至 2019 年 9 月),即 3.7 版本,但你可以使用任何新于 3.5+的版本。Python 的彻底安装过程将在第一章中涵盖,根据你使用的操作系统(Linux、macOS 或 Windows)。你还需要一个正常运行的互联网连接来下载 GitHub 代码和 Python 第三方库。我们将在本书的后面安装不同的 Python 库,包括 PyGame、Pymunk 和 PyOpenGL。对于每一个库,安装过程将在相关章节中进行介绍。在使用这些模块时,我们的程序会变得更长,因此我们强烈建议你使用一个好的 Python 文本编辑器。我将使用 PyCharm IDE 来使用 Python 创建复杂的游戏,它的安装也在第一章中介绍。除了这些软件要求,本书没有特定的要求。
这本书是为谁准备的
本书适用于任何想学习 Python 的人。您可以是初学者,也可以是之前尝试学习过但无聊的课程或书籍使您偏离轨道的人,或者是想要提高技能的人。本书将帮助您以最有趣的方式获得核心知识并提高技能:通过构建游戏。它主要关注使用 Python 模块 PyGame、PyOpenGL 和 Pymunk 进行 GUI 编程。学习者不需要具备编程技能,因为本书将涵盖您在 Python 方面需要了解的一切。我们将通过构建三个迷你游戏来学习turtle模块,并且您将学会如何创建自己的 2D 游戏,即使您是完全的初学者。如果您曾经想过使用 Python 的 PyGame 模块进行游戏开发,那么本书适合您。
为了充分利用本书
为了充分利用本书中提供的信息,建议您跟随示例进行操作。不需要具备 Python 的先验知识,但是对算术和逻辑操作等数学概念的经验对于深入理解代码是必不可少的。基于 Python 的应用程序不限于任何特定的操作系统,所以所需的只是一个体面的代码编辑器和一个浏览器。在整本书中,我们使用的是 PyCharm Community 2019.2 编辑器,这是一个开源编辑器,可以免费下载。
下载示例代码文件
您可以从您在www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support注册,将文件直接发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载”
-
在“搜索”框中输入书名,然后按照屏幕上的说明操作。
文件下载后,请确保使用以下最新版本的解压缩软件解压缩文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learning-Python-by-building-games。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的书籍和视频目录,可以在github.com/PacktPublishing/上找到。去看看吧!
代码实例
访问以下链接,查看代码运行的视频:
使用的约定
本书中使用了许多文本约定。
CodeInText:指示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。例如:“截图显示了编辑后的python_ex_1.py文件。”
代码块设置如下:
n = int(input("Enter any number"))
for i in range(1,100):
if i == n:
print(i)
break
当我们希望引起您对代码块的特定部分的注意时,相关行或项目以粗体设置:
def fun(b):
print("message")
a = 9 + b
move_player(a)
fun(3)
任何命令行输入或输出都以以下形式编写:
>>> cd Desktop
粗体:表示新术语,重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:“在安装程序中,请确保您勾选了“将 Python 添加到 PATH”框。”
警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。
第一章:了解 Python-设置 Python 和编辑器
Python 在数据和分析行业中臭名昭著,但在游戏行业中仍然是一个隐藏的工具。在使用其他游戏引擎(如 Unity 和 Godot)制作游戏时,我们倾向于将设计逻辑与核心编程原则相结合。但在 Python 的情况下,主要是问题分析和编程范式的融合。程序流程或结构是一个与其编程范式交织在一起的序列。编程范式正如其名称所示,它便于程序员以最经济和高效的方式编写问题的解决方案。例如,用两行代码而不是十行代码编写程序是使用编程范式的结果。程序流程分析或结构分析的目的是揭示有关需要调用各种设计模式的程序的信息。
在本章中,我们将学习以下主题:
-
使用 Python 介绍编程
-
安装 Python
-
Python 的构建模块
-
安装 PyCharm IDE
-
编写Hello World之外的编程代码
技术要求
以下是您在本书中需要的最低硬件要求的列表:
-
具有至少 4GB RAM 的工作 PC
-
外部鼠标适配器(如果您使用笔记本电脑)
-
至少需要 5GB 的硬盘空间来下载外部 IDE 和 Python 软件包
您需要以下软件才能充分利用本书(我们将在本章中下载所有这些软件):
-
各种开源 Python 软件包,如 pygame,pymunk 和 pyopenGL
-
Pycharm IDE(社区版),您可以在
www.jetbrains.com/pycharm/找到 -
各种开源软件包,如
pygame和pycharm -
本章的代码可以在本书的 GitHub 存储库中找到:
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter01
查看以下视频以查看代码的运行情况:
使用 Python 介绍编程
编程的古老格言如下:
“编码基本上是用于开发应用程序、网站和软件的计算机语言。没有它,我们将无法使用我们所依赖的主要技术,如 Facebook、智能手机、我们选择查看最喜欢的博客的浏览器,甚至博客本身。这一切都运行在代码上。”
我们对此深表认同。计算机编程既可以是一项有益的工作,也可能是一项乏味的活动。有时,我们可能会遇到无法找到程序中捕获的异常(程序的意外行为)的调整的情况,后来我们发现错误是因为错误的模块或不良的实践。编写程序类似于写作文章;首先,我们必须了解文章的模式;然后,我们分析主题并写下来;最后,我们检查语法。
与写作文章的过程类似,编写代码时,我们必须分析编程语言的模式或语法,然后分析问题,然后编写程序。最后,我们检查它的语法,通常是通过 alpha 和 beta 测试来完成的。
本书将尝试让您成为一个可以分析问题、建立高贵逻辑并提出解决问题的想法的人。我们不会让这段旅程变得单调;相反,我们将通过在每一章节中构建游戏来学习 Python 语法。到本书结束时,您将会像一个程序员一样思考——也许不是专业的程序员,但至少您将已经掌握了使用 Python 制作自己程序的技能。
在本书中,您将学习到两个关键的内容:
-
首先,您将学习 Python 的词汇和语法。我不是指学习 Python 的理论或历史。首先,我们必须学习 Python 的语法;然后,我们将看看如何使用该语法创建语句和表达式。这一步包括收集数据和信息,并将其存储在适当的数据结构中。
-
然后,您将学习与调用适当方法的想法相对应的程序。这个过程包括使用在第一步收集的数据来获得预期的输出。这第二步不是特定于任何编程语言。这将教我们各种编程原型,而不仅仅是 Python。
在学习了 Python 之后,学习任何其他编程语言都会更容易。您将观察到的唯一区别是其他编程语言的语法复杂性和程序调试工具。在本书中,我们将尽可能多地学习各种编程范式,以便我们可以开始编程生涯。
您对 Python 还有疑问吗?
让我们看一下一些使用 Python 制作的产品:
-
没有提到谷歌的清单是不完整的。他们在他们的网络搜索系统和页面排名算法中使用它。
-
迪士尼使用 Python 进行创意过程。
-
BitTorrent 和 DropBox 都是用 Python 编写的。
-
Mozilla Firefox 用它来探索内容,并且是 Python 软件包的主要贡献者。
-
NASA 用它进行科学研究。
清单还在继续!
让我们简单地看一下代码程序是如何工作的。
解释代码程序
为了简单地解释代码程序是如何工作的,让我们以制作煎蛋卷为例。您首先从食谱书中学习基础知识。首先,您收集一些器具,并确保它们干净干燥。之后,您打蛋,加盐和胡椒,直到混合均匀。然后,您在不粘锅中加入黄油,加入蛋液,煮熟,甚至可以倾斜锅来检查煎蛋卷的每个部分是否煮熟。
在编程方面,首先,我们谈论收集我们的工具,比如器具和鸡蛋,这涉及收集将被我们程序中的指令操纵的数据。之后,我们谈论煮鸡蛋,这就是你的方法。我们通常在方法中操纵数据,以便以对用户有意义的形式得到输出。这里,输出是一个煎蛋卷。
给程序提供指令是程序员的工作。但让我们区分客户和程序员。如果您使用的产品是让您向计算机发出指令来为您执行任务,那么您是客户,但如果您设计了为您为所有人创建的产品完成任务的指令,这表明您是程序员。只是“为一个人”还是“为所有人”决定了用户是客户还是程序员。
我们将在 Windows 命令提示符或 Linux 终端中使用的一些指令是用来打开我们机器的目录。有两种执行此操作的方法。您可以使用图形用户界面,也可以使用终端或命令提示符。如果您在相应的字段中键入dir命令,那么您现在正在告诉计算机在该位置显示目录。在任何编程语言中都可以做同样的事情。在 Python 中,我们有模块来为我们做这个。我们必须在使用之前导入该模块。Python 提供了许多模块和库来执行此类操作。在诸如 C 之类的过程式编程语言中,它允许与内存进行低级交互,这使得编码更加困难,但使用 Python 可以更容易地使用标准库,这使得代码更短更易读。《如何像计算机科学家一样思考学习 Python》的作者大卫·比兹利曾经被问到,“为什么选择 Python?”他简单地回答说,“Python 只是更有趣和更高效”。
与 Python 交谈
Python 已经存在了很多年(将近 29 年),尽管它经历了许多升级,但它仍然是最容易让初学者学习的语言。这主要是因为它可以与英语词汇相关联。类似于我们用英语单词和词汇做陈述,我们可以用 Python 语法编写陈述和操作,命令可以解释、执行并给我们提供结果。我们可以用条件和流控制来反映某物的位置,比如去那里作为一个命令。学习 Python 的语法非常容易;真正的任务是利用 Python 提供的所有资源来构建全新的逻辑,以解决复杂的问题。仅仅学习基本的语法和写几个程序是不够的;你必须练习足够多,以便能够提出解决现实问题的革命性想法。
我们在英语词典中有很多词汇。与英语词典不同,Python 只包含少量单词,我们通常称之为保留字。总共有 33 个。它们是指示 Python 解释器执行特定操作的指令。修改它们是不可能的——它们只能用于执行特定任务。此外,当我们调用打印语句并在其中写一些文本时,预期它会打印出该消息。如果你想制作一个从用户那里获取输入的程序,调用打印语句是无用的;必须调用输入语句才能实现这一点。以下表格显示了我们的 33 个保留字:
False |
class |
finally |
is |
return |
|---|---|---|---|---|
None |
continue |
for |
lambda |
try |
True |
def |
from |
nonlocal |
while |
and |
del |
global |
not |
with |
as |
elif |
if |
or |
yield |
assert |
else |
import |
pass |
|
break |
except |
in |
raise |
这些单词都可以在我们的英语词典中找到。此外,如果我们在词典中搜索单词return,它只会给我们返回原始位置的动词含义。Python 中也使用相同的语义;当你在函数中使用 return 语句时,你是在从函数中取出一些东西。在接下来的章节中,我们将看到所有这些关键字的用法。
现在我们已经开始学习如何通过检查其关键字来使用 Python 进行对话,我们将安装 Python。做好准备,打开你的机器,开始一些有趣的事情。
安装 Python
在本节中,我们将看看如何在 Windows 和 macOS 上安装 Python。
对于 Windows 平台
Python 不会预装在 Windows 上。我们必须从官方网站手动下载并安装它。让我们看看如何做到这一点:
-
首先,打开你喜欢的浏览器,打开以下网址:
www.Python.org/。 -
你将被引导到下图所示的页面。一旦你被重定向到 Python 的官方网站,你会看到三个部分:下载、文档和工作。点击页面底部的下载部分:

- 你会看到一个文件列表,如下截图所示。选择适合你平台的文件。在本节中,我们将看一下 Windows 的安装,所以我们会点击 Windows 可执行文件链接。如下截图所示:

- 点击后,你将得到一个需要下载的文件。打开下载的文件后,你将得到安装程序,如下所示:

- 在安装程序中,确保您选中“将 Python 添加到 PATH”框。这将在我们的环境变量中放置 Python 库文件,以便我们可以执行我们的 Python 程序。之后,您将收到有关其成功安装的消息:

- 按下 Windows 键+R打开运行,然后在运行选项卡中键入
cmd打开 Windows 命令提示符。然后,在命令 shell 中键入Python:

如果您得到前面截图中显示的 Python 版本,那么 Python 已成功安装在您的计算机上。恭喜!现在,您可以通过使用 Python 编写您的第一个程序来动手实践。
如果出现错误提示Python is not recognized as an internal or external command,则必须显式将 Python 添加到路径环境变量中。按照以下步骤执行:
-
打开控制面板,导航到“系统和安全”,然后转到“系统”以查看有关您系统的基本信息。
-
打开高级系统设置,然后选择“环境变量...”。
-
在“变量”部分,搜索“路径”。选择“路径”变量,然后按“编辑...”选项卡。
-
在“编辑环境变量”选项卡中单击“新建”。
-
添加此路径,使其指向您的 Python 安装目录,即 C:\Users\admin\AppData\Local\Programs\Python\Python37\。
-
单击“确定”按钮以保存这些更改:

现在,我们已成功在 Windows 上安装了 Python。如果您使用的是 Mac,下一节将帮助您也访问 Python。
对于 Mac 平台
Python 在 Mac OS X 上预先安装。要检查您安装的 Python 版本,您应该打开命令行并输入Python --version。如果您得到 3.5 或更新的版本号,您就不需要进行安装过程,但如果您有 2.7 版本,您应该按照以下说明下载最新可用版本:
- 打开浏览器,输入
www.Python.org/downloads/。您将被发送到以下页面:

- 单击 macOS 64 位/32 位安装程序。您将获得一个
.pkg文件。下载它。然后,导航到已安装的目录并单击该安装程序。您将看到以下选项卡。按“继续”以启动安装程序:

每当您下载 Python 时,一堆软件包将安装在您的计算机上。我们不能直接使用这些软件包,所以我们应该为每个独立的任务单独调用它们。要编写程序,我们需要一个环境,我们可以在其中调用 Python,以便它可以为我们完成任务。在下一节中,我们将探索 Python 提供的用户友好环境,我们可以在其中编写自己的程序并运行它们以查看它们的输出。
现在,您已在 Mac OS X 上安装了 Python 3.7 版本,您可以打开终端并使用python --version命令检查您的 Python 版本。您将看到 Python 2.7.10。原因是 Mac OS X 预先安装了 Python 2.7+版本。要使用更新的 Python 版本,您必须使用python3命令。在终端中键入以下命令并观察结果:
python3 --version
现在,为了确保 Python 使用您刚刚安装的较新版本的解释器,您可以使用一种别名技术,将当前工作的 Python 版本替换为 Python3。要执行别名,您必须按照以下步骤执行:
-
打开终端并输入
nano ~/.bash_profile命令以使用 nano 编辑器打开 bash 文件。 -
接下来,转到文件末尾(在导入路径之后)并键入
alias python=python3命令。要保存 nano 文件,请按Ctrl + X,然后按Y保存。
现在,再次打开您的终端,并输入我们之前使用的相同命令来检查我们拥有的 Python 版本。它将更新到较新版本的 Python。从现在开始,为了从 Mac 运行任何 Python 文件,您可以使用这个 Python 命令,后面跟着文件的签名或文件名。
介绍 Python Shell 和 IDLE
Python Shell 类似于 Windows 的命令提示符和 Linux 和 Mac OS X 的终端,您可以在其中编写将在文件系统中执行的命令。这些命令的结果会立即在 shell 中打印出来。您还可以使用任何终端中的 Python 命令(> python)直接访问此 shell。结果将包含由于代码执行不正确而导致的异常和错误,如下所示:
>>> imput("Enter something")
Traceback (most recent call last):
File "<pyshell#5>", line 1, in <module>
imput()
NameError: name 'imput' is not defined
>>> I love Python
SyntaxError: invalid syntax
正如您所看到的,我们遇到了一个错误,Python IDE 明确告诉我们我们遇到的错误名称,在这种情况下是NameError(一种语法错误)。SyntaxError是由于代码的不正确模式而发生的。在前面的代码示例中,当您编写I love Python语法时,这对 Python 解释器来说什么都不意味着。如果要纠正这个问题,您应该编写正确的命令或正确定义某些内容。写imput而不是 input 也是语法错误。
逻辑错误或语义错误即使您的程序语法正确也会发生。然而,这并不能解决您的问题领域。它们很难追踪,因此很危险。程序完全正确,但并没有解决它本来要解决的任何问题。
当您在计算机上下载 Python 软件包时,一个名为 IDLE(Python 内置 IDE)的Python 集成开发环境(IDE)会自动下载到您的计算机上。您可以在搜索栏中输入IDLE来进入这个环境。IDLE 是一个免费的开源程序,提供了两个界面,您可以在其中编写代码。我们可以在 IDLE 中编写脚本和终端命令。
现在我们已经熟悉了在 Python Shell 中不应该做什么,让我们谈谈 Python Shell 的细节——这是一个您可以编写 Python 代码的环境。
Python Shell 的细节
正如我们之前提到的,在这一部分,我们将参观 Python 的细节。这包括 Python 的内置 shell,Python 的文本编辑器(通常称为 Python 脚本)和 Python 文档页面。
按照以下步骤了解 Python Shell 的细节:
- 当您打开 Python Shell 时,您会看到以下窗口。您在 shell 中看到的第一件事是 Python 的当前版本号:

- 在 Python shell 中,有三个角括号相邻放置在一起,就像这样:
>>>。您可以从那里开始编写您的代码:

- 按下F1打开 Python 文档,或转到帮助选项卡,单击 Python Docs F1(在 Windows 机器上)。要在线访问文档,请转到
docs.python.org/3/:

希望您现在对 Python Shell 已经很熟悉了。我们将在 shell 中编写大量代码,因此请确保通过自定义或长时间玩耍来熟悉它。完成后,您可以继续下一节,在那里您将学习在编写第一个 Python 程序之前需要了解的内容。
Python 的基本组成部分
在编写 Python 程序时,我们使用一些常规模式。Python 作为一种高级语言,不关心低级例程,但具有与它们交互的能力。Python 由六个构建块组成。用 Python 制作的每个程序都围绕着它们展开。这些构建块是输入、输出、顺序执行、条件、递归和重用。现在让我们来详细了解一下它们:
-
输入:输入无处不在。如果你用 Python 制作一个应用程序,它主要处理用户输入的格式,以便收集有意义的结果。Python 中有一个内置的
input()方法,因此我们可以从用户那里获取数据输入。 -
输出:在我们操纵用户输入的数据之后,是时候向用户呈现它了。在这一层,我们利用设计工具和演示工具来格式化有意义的输出并发送给用户。
-
顺序执行:这保留了语句的执行顺序。在 Python 中,我们通常使用缩进,即表示作用域的空格。任何在零级缩进的命令都会首先执行。
-
条件:这些为程序提供流程控制。基于比较,我们制定逻辑,使代码流动,并执行或跳过它。
-
递归:这是需要做的任何事情,直到满足某些条件。我们通常称它们为循环。
-
重用:编写一次代码,使用无数次。重用是一种范式,我们编写一组代码,给它一个引用,并在需要时使用它。函数和对象提供了可重用性。
在 Python Shell 中编写程序对大多数程序员来说可能很容易调试,但从长远来看可能会产生额外的开销。如果你想保存代码以备将来参考,或者你想编写多行语句,你可能会被 Python 解释器的功能不足所压倒。为了解决这个问题,我们必须创建一个脚本文件。它们被称为脚本,因为它们允许你在单个文件中编写多行语句,然后立即运行。当我们有多个数据存储和文件要处理时,这将非常方便。你可以通过扩展名来区分 Python 文件和其他文件,也就是.py。你还应该将 Python 脚本文件保存为.py扩展名。
要从终端或 Windows 命令提示符中运行你的脚本文件,你必须告诉 Python 解释器通过文件名运行该文件,就像这样:
$ Python Python_file_name.py
在上述命令中,$是操作系统提示符。首先,你必须使用Python命令调用 Python 解释器,并告诉它执行其旁边的文件名。
如果你想在终端中查看Python文件的内容,请使用以下命令:
$ cat Python_file_name.py
$ nano Python_file_name.py
要退出 Python 终端,在终端中写入exit()命令。
现在我们已经学会了如何打开和退出 Python 环境的界面,我们必须了解它的构建模块。许多初学者错误地认为程序只有两个构建模块:输入和输出。在下一节中,我们将看到如何通过使用编程的六个构建模块来驳斥这一假设。
编程最困难的部分是学习编程范式,比如面向对象编程、DRY 原则或线性时间复杂度模型。如果你掌握了这些原型,学习任何新的编程语言都将变得轻而易举。话虽如此,使用 Python 学习所有这些范式要比 Java 或 C#容易得多,因为在 Python 中,代码会更短,语法也更符合英语习惯:

在我们编写第一个程序之前,我们将安装另一个 IDLE,以备后面的章节中我们将编写复杂的游戏。在这些类型的游戏中,IDLE 提供的功能是不够的,因此我们将看到如何在下一节中安装 PyCharm——一个高级的 IDLE。
安装 PyCharm IDE
在本章的前面,我们发现了 IDLE。我们已经看到了一个环境,我们可以在其中编写代码并立即获得输出。但是,您可以想象一下,如果我们有很多代码要一次执行,可能是 1000 行代码,一次执行一行。我们必须通过编写脚本来解决这个问题,这是 Python 代码的集合。这将一次执行,而不是在 IDLE 的 shell 中逐行执行。
如果您想编写脚本,请按照以下步骤操作:
-
从您的 PC 中打开搜索选项卡,然后键入
IDLE。 -
点击文件选项卡。
-
按下 New File。
-
将生成一个新文件。您可以在单个文件中编写多个表达式、语句和命令。以下屏幕截图的左侧显示了 Python 脚本,您可以在其中编写多行语句,而以下屏幕截图的右侧显示了 Python Shell,您将在其中执行脚本并获得即时结果:

在编写脚本完成后,您必须在运行之前保存它。要保存文件,请转到文件并单击保存。通过在末尾放置.py扩展名为您的脚本提供适当的文件名,例如test.py。按下F5执行您的脚本文件。
在本书中,我们将构建许多游戏,其中我们将处理图像、物理、渲染和安装 Python 包。这个 IDE,也就是 IDLE,无法提供智能 IDE 功能,比如代码完成、集成和插件以及包的分支。因此,我们必须升级到最好的 Python 文本丰富的 IDE,也就是 PyCharm IDE。让我们开始吧:
- 访问
www.jetbrains.com/pycharm/下载 PyCharm 环境。安装 PyCharm 与安装任何其他程序一样简单。从网站下载安装程序后,点击该安装程序。您应该会看到以下窗口:

-
点击
按钮并将其安装在适当的驱动器上。安装完成后,在搜索栏中搜索 PyCharm并打开它。您应该会看到以下窗口:![]()
-
现在,点击+创建新项目并给您的项目命名。要创建新的 Python 文件,请在项目名称上单击左键,单击 New,然后单击 Python File 选项卡:
![]()
现在,我们拥有了掌握本书所需的一切——我的意思是工具,但显然,我们必须学习 Python 的每种可能的范式来掌握 Python 的概念。现在您已经全副武装了这些工具,让我们编写我们的第一个有效的 Python 程序,No Hello World。
编程代码没有 Hello World
在编程世界中有一个传统,即将Hello World打印为我们的第一个程序。让我们打破常规,使我们的第一个程序成为从用户那里获取输入并将其打印到控制台的程序。按照以下步骤执行您的第一个程序:
- 打开您的 IDLE 并输入以下命令:
>>> print(input("Enter your Name: "))
- 按下Enter执行命令。您将收到一条消息,上面写着输入您的姓名:。输入您的姓名并按Enter。您将看到输出打印您刚刚传递的姓名。
我们在这里使用了两个命令,也称为函数。我们将在接下来的章节中学习它们。现在让我们来看看这两个函数:
-
input()是 Python 的内置函数,将从用户那里获取输入。空格也包括在字符中。 -
print()是 Python 的内置函数,将打印括号内传递的任何内容。
现在我们已经开始使用 Python 的内置 IDLE 编写我们的第一个程序,轮到您测试 IDLE 的工作原理了。由于我们将使用 IDLE 构建大量游戏,请确保您熟悉其界面。本章学习的核心编程模块,如 Python 关键字和输入-打印函数,非常重要,因为它们帮助我们构建可以从用户那里获取输入并显示的程序。
总结
在本章中,我们对 Python 的基础知识进行了概述,并学习了它与英语的词汇有多么相似。我们在计算机上安装了 Python 软件包,并查看了 Python 的预安装集成开发环境(IDE)IDLE。我们看到了如何在 Python IDE 上编写脚本以及如何执行它们。然后,我们在计算机上安装了功能丰富的 Python 文本编辑器 PyCharm IDE。我们编写了我们的第一个 Python 程序,它能够从用户那里获取输入并在屏幕上显示。
本章中您所学到的技能对于构建程序的流程至关重要。例如,我们的程序能够处理输入/输出数据。任何用 Python 制作的游戏都必须对用户或玩家进行交互,这是通过输入和输出界面来实现的。在本章中,我们学习了如何从用户那里获取输入并显示它。随着我们继续阅读本书,我们将探索各种构建程序的方式,包括处理来自鼠标、键盘和屏幕点击的用户事件。
下一章将至关重要,因为我们将学习 Python 的基本要素,如值、类型、变量、运算符和模块。我们还将开始构建一个井字棋游戏。
第二章:学习 Python 的基础知识
Python 不需要在游戏开发中,设计和分析被认为是编程之前完成的步骤。设计和分析要求我们集思广益,模拟程序,并格式化输入。所有这些程序都与数据有关。数据可以是简单的数字列表,也可以是复杂的天气历史。这些数据有自己的类型和结构。数据需要有自己的存储位置,以便我们可以引用它。Python 提供了数据的抽象形式,以对象的形式,这样我们可以创建嵌套的数据结构。
本章将带您体验 Python 中的核心编程范式的过山车之旅。我们将首先学习可用的不同数据类型以及将它们捕捉到变量或存储单元中的方法。我们将学习使用math模块进行不同的数学运算(算术和三角)的方法。在本章结束时,我们将使用在本章学到的知识制作我们的第一个游戏-井字棋。
在本章中,我们将涵盖以下主题:
-
处理值和数据
-
变量和关键字
-
运算符和操作数
-
在代码中编写注释
-
请求用户输入
-
字符串操作
-
构建你的第一个游戏-井字棋
-
可能的错误和警告
-
游戏测试和可能的修改
技术要求
您需要满足以下要求才能充分获得本章的全部好处:
-
您将需要 Python IDLE
-
本章的代码资产可以在本书的 GitHub 存储库中找到:
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter02
观看以下视频以查看代码的运行情况:
处理值和数据
软件的评价取决于其处理数据的能力。每个程序都有自己的数据库设计和实现。数据库是一个模式,数据以这样的方式存储,以便可以快速和安全地检索和操作。据推测,像 Facebook 和 Twitter 这样的社交网络每天收集 17 亿人的数据。这么庞大的数据量,每天都在收集,应该得到妥善处理,因为我们没有足够的内存来存储和处理它。因此,Python 提供了灵活的内置方法,以便对这些数据集进行映射、过滤和减少,以便可以更快地存储和获取以进行处理。
Python 在存储数据方面非常快速。它与 Hadoop 等大数据平台的集成,继承了其兼容性,是我们在大型数据集中使用 Python 的主要原因。强大的包,如 NumPy、pandas 和 scikit-learn,为当今的数据和分析需求提供数据支持。
值是程序计算的一些属性的表示。在这里,属性是任何对象的属性。例如,当我们谈论一个人时,我们通过姓名、年龄和身高来引用他们。这些属性有一个r-value(属性的内容)和一个l-value(内存位置)与它们相关联。属性的内容指的是存储为变量内容的值,而内存位置指的是存储值的物理位置。例如,name = "Python"有一个name变量作为属性;它的r-value 是Python,它的l-value 是 Python 解析器自动分配的唯一 ID,作为名称属性的内存位置。
在 Python 中,值以对象的形式存储。对象有四个特点:ID、命名空间、类型和值。让我们看一个简单的例子,揭示对象的这些特点。
>>> player_age = 90
当创建player_age变量时,它的实例被创建,我们称之为对象。每当创建一个对象时,它都会接收一个唯一的内存存储位置,即一个唯一的 ID 号,并动态地分配一个类型,即一个整数,因为我们将 90 分配给它。之后,将 player 变量添加到命名空间中,以便我们可以检索其值,即 90。以下图尝试简化这个解释:

每当执行任何赋值语句时,解析器都会创建一个对象,该对象从中获取唯一的内存 ID,我们可以引用变量的值。由于 Python 是一种动态类型的语言,它通过分析分配给变量的值动态地分配变量的类型。最终,它将该变量添加到全局命名空间,以便在您想要获取该变量时,可以使用变量名。在这里,memory_ID是指向对象值的位置。一些编程语言,如 C,称之为指针。
每个这些值都有与之关联的类型。1是一个整数,a是一个字符,Hello World是一个字符串。Hello World是一系列字符,因为它定义了一系列字符,所以它被称为字符串。在前一章中,我们看到了一个示例,我们要求用户输入。每当用户输入一些内容时,它被视为字符串。这些值在编程中定义对象。让我们以鹦鹉为例。它将有一个作为字符串的名称,一个作为整数的年龄,以及一个作为男性或女性的性别,用M或F表示,这些都是字符。在 Python 中,字符也被表示为字符串。为了验证这一点,我们有type方法。type方法表示如下:
>>> type('a')
前面命令的输出将是<class 'str'>,这意味着字符也是字符串的一部分。要检查任何值的类型,我们可以使用相同的type方法:
>>> type(1)
>>> type('Hello World')
前面的命令将分别显示类别为int和str。
现在,让我们谈谈数字。数字有两种类型:整数和小数。正如我们所看到的,整数是整数,但小数是浮点数。我们在 Python 中称小数为浮点数,因为它们以浮点格式表示,如下所示:
>> type(3.4)
前面命令的输出是<class 'float'>。
您可以使用print方法在终端中打印这些值。print方法在括号内接受值,并在解释器中给出结果,如下所示:
>>> print(1)
如果您在print语句中放入1,它将打印1作为数字。但是,当您在双引号中放入1时,它将打印1作为字符串,如下所示:
>> print("1")
<class 'str'>
任何数字、文本或特殊符号,如@、\(、%或*,您放在单引号或双引号中最终都将成为字符串。以下是字符串的一个示例:`1`、`Hello`、`False`、`#\)(#`。
当您在print语句中的两个值之间放置逗号时,它会在它们之间放置一个空格,如下所示:
>>> print("abc","abc")
前面的代码将给出abc abc的输出,但它们不再被视为字符串。这是我们在 Python 中看到的第一个语义错误。我们将abc作为字符串传递,但结果是一个非类型:
>>> type(print("abc","abc"))
<class 'NoneType'>
这是一个语义错误的完美例子。我们得到了输出而没有任何错误,但是我们没有得到我们想要的结果。
您还可以检查整数。不可能用逗号在它们之间打印整数。Python 解释器将逗号转换为传递的每个值之间的空格:
>>> print(0,000,000)
这个命令将给我们一个0 0 0的结果。每个逗号都被转换为空格并打印出来。如果您检查从函数返回的值的类型,它也将是NoneType。
现在我们知道了值和类型,让我们熟悉一下变量和关键字。
变量和关键字
编程就是接受和操作我们学到的值。我们在接受和操作这些值时使用变量,以便将来可以引用它们。变量就像盒子,您可以在其中放入不同的东西,并在需要时取出。变量是通过名称创建的,并为其分配一个值。
我们使用等号(=)来进行赋值语句。变量是通过赋值语句创建的;例如:
>>> myAge = 24
>>> info = "I love Python"
>>> isHonest = True
在这里,我们使用赋值语句创建了三个变量。在第一条命令中,我们创建了myAge变量并为其分配了一个整数。在 Python 中,您不必显式指定变量的类型,因为 Python 会在内部执行此操作。这就是使 Python 成为动态类型语言的原因。在第二条命令中,我们创建了info变量并为其分配了一个字符串。最后,我们创建了isHonest变量并为其分配了一个布尔值。
布尔类型是逻辑类型。它们要么是True,要么是False。创建布尔变量与创建其他变量相同,例如,is_hungry = True。
变量是基本的数据存储单元。我们一次可以将一个值分配给一个变量。每当您将另一个值分配给相同的变量名称时,它将覆盖原始值。例如,在这里,我们将info变量设置为字符串,但是如果我用另一个值替换它,比如integer,这是有效的:
>>> info = 23
如果您在 Python 中创建变量,它将为每个变量创建单独的内存引用。因此,每当您用另一个值替换相同的变量时,该特定位置的值将被检索并用新值覆盖。变量名称是保留内存位置中值的指针。您不能在一个变量中存储多个值。您必须使用高级数据结构来实现这一点。我们将在接下来的章节中介绍这一点(第四章:数据结构和函数:用 AI 的味道完善您的游戏)。
将多个变量分配给不同的变量可以在一行代码中完成。我们可以用单个赋值语句来分配它们。变量的名称应该在左侧给出,它们之间应该用逗号隔开。您可以使用以下命令在一行中创建尽可能多的具有不同数据类型的变量:
>>> even, odd, num = 2, 3, 10
您可以通过直接在终端中写入变量的名称来查看变量的值:
>>> even
如果您只在脚本中写变量的名称,那么不会打印出值。相反,它会终止。如果要在屏幕上打印出东西,您必须使用print()方法。要打印任何变量的值,请在 shell 或脚本中键入print(variable_name),如下所示:
>>> print(even)
如果要查看存储在变量中的值的类型,可以调用type()方法。为此,请将变量的名称放在括号内:
>>> type(even)
上述命令将给出<class 'int'>的输出,这意味着可以在变量中存储整数值。
我们还可以在 Python 中将相同的值分配给多个变量。在上述命令中,我们不是分配多个值,而是将单个值分配给它,如下所示:
>>> even, num = 10
在上述命令中,我们将整数值 10 分配给了两个不同的变量even和num。
Python 不需要变量实例化和声明。因此,在 Python 中不需要保留内存空间。当我们使用赋值语句创建变量时,Python 会在内部执行此操作。
Python 已经将 33 个单词保留为关键字,用于特定功能。我们不能使用它们来命名变量。Python 会使用内置脚本检查变量的名称与这些关键字,每当它检测到其中一个单词时,它将抛出语法错误,如下例所示:
>>> and = 23
前面的命令不会被执行,也不能用作变量名,因为它是一个关键字。Python 用它来执行一些逻辑操作。但是,如果您创建一个名为And的变量并为其赋值,Python 将为您创建And变量。对于 Python 来说,And和and是不同的。它是大小写敏感的语言。
为了避免变量名的任何问题,我们可以遵循一些简单的规则。我们将在下一节中介绍这些规则。
变量命名规则
通常我们选择有意义的变量名,因为从长远来看,可能会出现我们完全忘记代码顺序和流程的情况,而没有适当名称的变量会造成混乱。尽管您可以按照一些规则创建任何名称的变量,但强烈建议创建有意义的变量名。比如,您正在制作一个游戏,想要为玩家的生命值创建一个变量;将该变量命名为a不是一个好的做法。相反,您应该将其命名为player_Health,这样对您和可能查看您代码的人来说,这个变量中的代码是清晰的。
通常从编程的角度来看,有两种有效地给变量命名的方式。其中两种被广泛称为驼峰命名法和帕斯卡命名法。观察先前定义的变量的命名约定,playerHealth,变量的第一个字符应该是小写的,其他所有字符都应该是大写的。同样,在帕斯卡命名法中,变量的每个第一个字符都应该是大写的。因此,使用帕斯卡命名法,先前定义的变量可以写成PlayerHealth。您可以使用其中任何一种来命名您的变量。
您的变量名可以是任意长度。它可以包含大写字母(A-Z)、小写字母(a-z)、数字(0-9)和下划线(_)的组合。下划线用于在两个单词之间区分变量中的两个实体。例如,player_Health变量由两个单词组成。我们在它们之间使用下划线。或者,您也可以使用驼峰命名法,其中您将第一个单词以小写字母开头,第二个单词的第一个字母大写,例如,playerHealth。
我们还可以在变量名的开头使用下划线。如果它被用作其他库的库,我们会在我们的代码中使用它。我们也可以在递归语句中使用它,就像这个例子:
>>> _age = 34
在命名变量时,我们需要遵循一些规则,否则 Python 会将其声明为非法并抛出语法错误。以下截图显示了一些非法的赋值语句:

为了消除前述错误,我们必须遵循一些规则。有些是强制性的,而有些只是良好的实践:
-
我们给变量取一个有意义的名字。将年龄变量命名为
age比将其命名为a更有意义。 -
在命名变量时,我们不能使用特殊符号(@、#、$和%)。例如,n@me 不是一个有效的变量名。
-
变量名不应以数字开头。45 age 不是一个合适的变量名,Python 会报错。
-
使用大写名称声明常量,例如,
>>> PI = 3.14。 -
使用
驼峰命名法创建变量名是一个好的做法,例如,>>> myCountry = "USA"。
现在我们已经了解了变量和关键字是什么,以及在命名它们时要遵循的一些规则。现在,让我们继续看看运算符和操作数是什么。
运算符和操作数
数学和编程是两个密切相关但又不同的领域。前者处理理论并提供制定的原则来解决任何问题领域,而后者处理使用这些原则来解决业务领域。编程就是接受数据并使用模型以及适当的数学运算对其进行操作。运算符用于执行这些操作。Python 中有算术和逻辑运算符。
运算符是执行加法、乘法、除法等计算的符号。诸如+、-和/之类的符号用于执行这些操作。运算符应用的值称为操作数。以下是一些运算符的示例:
>>> 3 + 4
>>> 14 - 5 - 9
>>> 2 * 4
在前面的例子中,第一次操作的结果是 7,第二次操作的结果是 0,最后一次操作的结果是 8。您可以在 shell 中添加或减去任意数量的数字。在这里,所有数字都是操作数,而+、-和*等符号是运算符。
另一个 Python 中重要的运算符是除法(/)。在 Python 3.x 中,除法操作的结果是浮点数,例如:
>>> 10 / 4
前面的操作给出了一个结果为 2.5。这与使用计算器得到的结果相同。
在 Python 2.x 中,解释器会截断小数部分,并给出一个结果为 2。如果您想在 Python 3.x 中获得相同的结果,应该使用地板除法(//);例如:
>>> 10 // 4
前面的操作将给我们一个结果为 2,而不是 2.5。
让我们回顾一下我们迄今为止学到的内容,即值、变量和运算符。让我们将所有这些组合成一个语句。这被称为表达式:
>>> x = 10 + 2 * 5
>>> x
您可以将所有这些组合在一起,以制作任何类型的表达式。赋值操作是使用最简单的表达式。我们在创建变量时看到了赋值操作。
当表达式中使用多个运算符时,这些运算的顺序变得重要。我们将在下一节中介绍操作的顺序。
操作顺序
让我们回顾一下我们在学校时学到的基本数学。您可能听说过 BODMAS 规则或 PEDMAS 规则。每当我们的表达式中使用多个运算符时,都会按照这个优先级规则执行操作。括号内的操作、指数、除法、乘法、加法和减法按照这个顺序执行:
-
括号/方括号:这个符号具有最高的优先级,这意味着括号内的操作首先完成。通过在表达式中使用括号,您告诉解释器强制执行某个表达式。例如,在
(10-5)+5*6中,括号内的操作首先完成,即10-5,然后进行乘法。 -
指数/幂:在括号内的操作完成后,指数操作会首先执行。
9**0+1的输出不是 9,而是 1。首先执行指数操作,然后执行加法。 -
除法:除法操作在指数操作之后进行,包括除法,如果它不在括号内。例如,
10 / 2 + 3 + 9 / 3是 11 而不是 5。如果表达式是10 / (2 +3) + 9 /3,输出将是 5。 -
乘法:它的优先级与除法相同。但是,如果表达式中既有除法又有乘法,操作是从左到右顺序执行的。从左到右扫描,如果我们在除法之前得到乘法,就先执行乘法。例如,
3*4 / 3的输出是4而不是 3.999。 -
加法和减法:这两个操作也具有相同的优先级。因此,我们根据从左到右扫描的顺序执行这些操作。例如,在
5-5+6中,我们首先执行减法,然后执行加法,得到 6。
如果您仍然对 BODMAS/PEDMAS 规则感到困惑,您可以简单地使用括号来确保获得预期的结果。在下一节中,我们将学习两个重要的运算符://和%。前者称为地板除法,而后者称为模运算符。
模运算符
之前,我们看到了如何使用地板除法(//)以及它如何为我们提供除法运算的商。但是,如果您想要除法的余数,可以使用取模运算符。取模运算符产生第一个操作数除以第二个操作数时的余数。取模运算符的符号是百分号(%)。以下屏幕截图显示了两个操作:第一个是地板除法,将得到商,而下一个是取模运算,将得到除法的余数:

当我们想要搜索数字模式并创建可以根据该模式划分数字的程序时,取模运算符非常有用。例如,我们可以检查任何数字与 2 之间的除法余数,以确定该数字是偶数还是奇数:
>>> 5 % 2
由于前面的操作给出了余数为 1,5 可以被认为是一个奇数。
前面的所有操作都非常基本,不需要任何艰苦的计算。但是,我们知道计算机以处理复杂任务而闻名。因此,在下一节中,我们将学习math模块,它能够执行复杂的数学运算,例如计算三角函数和复杂方程。
使用数学模块
数学不仅仅局限于加法和乘法。到目前为止,我们已经学习了各种算术运算。我们还没有涉及逻辑运算符和比较,因为这些将在下一章中介绍。为了涵盖许多数学领域,Python 给了我们一个强大的库,称为math模块。我们称包含代码的文件为模块。这些库也被称为内置库,因为它们在安装 Python 时预先打包。
它们是由 Python 制作的,我们可以随时在我们的代码中调用它们,而无需手动安装。如果要使用任何内置库的代码,必须首先调用它。调用它们意味着导入它们。要导入并使用内置库,我们使用import关键字。正如您可能还记得的,从上一章中,它是 Python 中具有特定目的的保留字。因此,import关键字将任何库导入您的代码中。例如,如果要导入math模块,只需编写以下内容:
>>> import math
您将立即看到下一行有一个空的 shell,就像这样:>>>。
这只是指定您正在导入它。导入语句与打印或输入方法不同,后者会立即给我们一个响应。我们应该从该模块中调用某些内容,以便看到任何响应或结果。math模块为我们提供了许多操作。可以通过以下步骤访问这些操作:
- 打开您的 IDLE 并按F1打开文档。您将看到以下窗口:

- 现在,点击模块。您将看到一个包含模块列表的新窗口:

- 从该选项卡搜索数学模块,或者如果您想浏览以字母
m开头的模块列表,只需在键盘上按M:

有很多方法可以使用!不要被术语方法所压倒;我们已经专门介绍了面向对象编程的部分,我们将学习如何创建自己的方法。现在,只需将方法视为我们用来创建表达式的操作。数学模块提供的方法也将执行简单的算术运算和许多其他复杂的运算。如果您想要获得平方根,我们没有特定的运算符来执行,也不能执行复杂的数学运算;相反,您必须使用数学模块。我们将在下面的示例中查看平方根。
要获得一个数字的平方根,我们可以使用sqrt方法。查看sqrt方法的文档,了解更多信息并学习如何调用它。这非常容易!首先,我们写math,然后是一个句点(.),表示我们想要从 math 模块中使用某些东西并使用sqrt方法:
>>> import math
>>> math.sqrt(49)
49的平方根是 7。我们的解释器打印出 7.0,因为sqrt执行了一个浮点运算。
如果您没有导入math模块,而是直接调用了sqrt,您将收到以下错误:

您可能还记得我们讨论print()函数时,我们没有使用任何模块来调用它,因为它是一个内置函数。但是,这个sqrt()函数不是内置的。它来自 Python 的内置库。虽然我们不必像其他第三方模块一样安装它,但在使用它提供的任何功能之前,我们必须导入它。Python 提供的所有模块都是小写的。
我们可以从 math 模块调用一系列函数和常量。这使我们能够进行支持复杂数学计算的多种操作。如果要打印 PI 的值,可以使用math模块,如下所示:
-
首先,我们用
>>> import math导入它。 -
然后,我们使用
module_name并提供一个句点(.)来指定我们要使用该模块和我们要执行的类型操作,例如>>> math.pi。
您可以使用数学函数执行代数、对数、三角、双曲和各种其他操作。但是,这个模块不能对复数执行数学运算,例如z = a + ib。
对于那些复数类型,我们必须导入cmath模块。导入和使用这个模块也类似于math模块。
如果您想使用math模块提供的函数与print()或input()的调用,而不需要加句点,可以使用以下命令:
>>> from math import *
在前面的命令中,*表示您想要导入所有内容。它经典地表示从 math 模块中导入所有内容。现在,如果您想从 math 模块调用任何函数,可以直接调用,类似于我们对输入和打印函数所做的操作:
>>> factorial(4)
前面的函数将被完美执行,并给我们一个结果为 24。
也许您会想为什么模块的概念没有在本书的开头解释。很简单!我们刚刚学习了运算符、操作和表达式,这意味着很容易与数学模块相关联。我们从math模块调用的每个函数都包含运算符、操作数和表达式,但它的实现对我们来说是隐藏的。例如,我们只需使用sqrt函数执行平方根操作,但我们不知道如何用表达式和逻辑来做平方根。在接下来的章节中,当我们涵盖流程控制和函数时,我们将学习这个。因此,模块为我们提供了一种执行高级操作的方式,而无需知道它们是如何工作的。但是,如果您想制作自己的库和模块,那么接下来的章节将帮助您。
如果您想了解有关模块和函数的更多信息,可以简单地使用help命令。Python help命令将为您提供内置函数、模块和关键字的完整文档列表,如以下示例所示:
>>> help([object])
>>> help(input)
Help on built-in function input in module builtins:
input(prompt=None, /)
Read a string from standard input. The trailing newline is stripped.
The prompt string, if given, is printed to standard output without a
trailing newline before reading input.
If the user hits EOF (*nix: Ctrl-D, Windows: Ctrl-Z+Return), raise
EOFError.
On *nix systems, readline is used if available.
关于值和类型的讨论就到此为止。现在,让我们看看如何使我们的代码更易读和可重用,也就是说,其他人应该能够轻松阅读我们的代码。我们谈到了在命名变量时应遵循的规则和约定,这也导致了可读性。有两种使代码易读的方法:
-
在程序中写注释。
-
Pythonic 的方法是创建一个函数。
我们可以通过注释向程序添加注释,这将在下一节中介绍。
在代码中编写注释
即使您正在制作一款普通的软件,它也必须以某种方式与数据进行交互。最终,您的代码将变得更长、更复杂,变得难以管理、阅读和理解。虽然我们最终会理解我们编写的代码,但从长远来看,这将变得更加困难。如果您有 50,000 行代码,并且想要调试其中的语义和逻辑错误,那么很难搜索和索引它们。因此,注释非常有用。注释是一种在代码中写下注释的方式,以便任何试图阅读您的代码的人都知道该程序在做什么。Python 不会解释注释,这意味着每当 Python 解析器看到语句以井号符号 (#) 开头时,它的执行将被跳过。
Python 设计模式可能会很复杂,这使得任何天真的程序员都很难查看代码并理解它在做什么。因此,我们在我们的母语中为程序添加简单的注释,解释为什么我们要编写特定的代码。以 # 开头的注释是单行注释。如果您在包含井号的行下面写点东西,它不会被视为注释。这在以下代码中显示:
>>> # this is single line comment
>>> but this is not comment
在 Python 中,没有多行注释。人们通常认为三个双引号 (""" """) 用于多行注释,但这是不正确的。在 Python 3.x 中,三引号内的字符串被视为常规字符串。您可以使用三个双引号来删除断开的字符串。当字符串的范围没有完全封闭时,字符串被视为断开,就像这个例子中一样:
>>> 'Hey it's me'
前面的字符串是用单引号创建的。字符串中使用了撇号,这会使解释器产生困惑,因为它认为 hey it 是一个字符串,而忽略了 s me。这是一个断开的字符串。您遇到的文本并不是每一段都在字符串中。如果您在 IDLE 中运行此代码,将会得到以下语法错误:

要消除此错误,您可以使用三引号。三引号将删除断开的字符串,即使您的字符串中出现双引号或单引号:
>>> """ Hey! it's me """
>>> """ He said, "How may I help you" """
许多人认为前面的代码行代表多行注释,并做了这样的事情:

您可以清楚地看到,它不是忽略执行该命令,而是通过为我们创建一个字符串来反映我们的命令。如果我们没有将值封装在三个双引号中的变量中,它将被视为垃圾收集器,并给我们一个字符串。许多人因其作为文档字符串的行为而将其误认为是多行注释。文档字符串是放置在函数、模块或类顶部的字符串。例如,这是执行加法操作的函数:
def add:
显然,我们还没有学会如何创建函数,但您可以了解到三个双引号用于提供有关函数、类和模块的一些信息。因此,有些人认为它是多行注释。您可以看出它不是多行注释,因为三引号内的注释可以通过 Python 的特殊函数访问。
由于这是文档字符串,我们可以通过 obj.__doc__ 访问它。由于它可以被方法访问,并且解释器不会忽略它,因此它不能被视为多行注释。因此,我们可以得出结论,Python 中只有单行注释。如果我们确实想要多行注释,应该使用三个双引号,但我们必须确保将它们放在函数、类或模块的定义之上。
在以下代码中,\n 表示换行。这将导致代码中的换行。正如我们所看到的,以下代码在第一行打印 hey,在下一行打印 it's me:
>>> print("hey \n it's me")
hey
it's me
从中,我们可以得出关于注释的以下结论:
- 注释是多余的。它们只是告诉我们每行代码在做什么:
>>> print(customer_info) # printing customer information
- 注释可能包含有关代码的有用信息,甚至一些我们无法通过查看代码提取的关键信息:
>>> d = (400, 200) # d is for display of game console 400*200
>>> TEMP = 23 # temperature is in Celsius
正如我们在上一章中讨论的,我们在创建程序时必须遵循一种方便的模式。虽然这不是强制性的,但这总是一个很好的做法。在Python 的基本组成部分,第一个块是请求用户输入,这将是我们下一次讨论的主题。
请求用户输入
编程的基本组成之一是让用户使用键盘输入数据。无论是用于管理工具还是游戏,所有应用程序都应该从用户那里获取输入。在用户管理应用程序中,我们收集用户信息,如他们的姓名、地址和年龄,并将其插入到数据库中。在游戏中,我们从键盘上获取用户输入以进行移动。根据用户按下的键,我们可以让角色执行一些动作。例如,按下键盘上的Shift键将使角色跳跃。因此,每个应用程序都必须用户友好,这意味着它必须使用户与应用程序进行交互。
让用户在键盘上输入一些内容并将其存储在变量中,以便我们在需要时可以进一步处理,这是一个常见的做法。Python 具有内置函数来从用户那里获取输入,这意味着您无需导入或安装任何内容即可使用此函数。input()函数用于从用户那里获取输入:
>>> input()
当您输入上述命令时,它将为您提供一个写东西的地方。解释器在用户按下键盘上的按钮并按下Enter之前会保持其其他执行。按下Enter键后,程序将恢复并给出用户的文本输入。
以下屏幕截图显示了input()函数在 Python 中的工作方式:

在上面的屏幕截图中,我们使用了input()方法并输入了字符串'I love Python'。黑色文本颜色是用户输入的内容,解释器立即给出了一些输出,这是用户输入的相同字符串。您可以将输入文本存储到变量中,以便我们可以对其进行计算:
>>> message = input()
现在,我们已经看到了如何从用户那里输入数据。向用户提供消息或提示,告诉他们需要在该字段中输入什么,这总是一个很好的做法。消息或提示应作为input方法括号内的字符串给出,如下所示:
>>>user_name = input(" \n Enter your name? : \n")
Enter your name? :
John Doe #this is input from user
'John Doe' #printing content of user_name
在上面的示例中,当用户输入内容并按下Enter时,我们的程序会接受用户的输入并执行指定的任务。但是,如果您想要创建一个应用程序,其中要连续从用户那里获取数据,我们必须使用循环。我们将在接下来的章节中学习循环:
>>> while True:
input("Enter user_names: \n")
上述语句连续从用户那里获取输入。即使按下Enter或输入return关键字后,它也不会停止。在上述命令中,while用于循环。True是表示逻辑和布尔代数的真值的布尔类型。布尔类型要么是True,要么是False。因此,while True语句意味着其中的代码应该无限运行,要求用户无限输入。其结果如下:

调用input()方法时,您在键盘上输入的任何内容都将以字符串形式呈现,即使您输入的是整数,就像在示例中一样:
>>> a = input()
1 #store integer 1 to the variable a
如果你使用type方法检查a变量的类型,即>>> type(a),你会看到一些意想不到的结果。我们从用户那里输入1并将其存储在变量a中。当我们检查存储在a变量中的值的类型时,它不会是一个整数。相反,它会显示str class: <class 'str'>,这意味着你通过调用input()方法在键盘上输入的任何内容都将是字符串类型。但有时,也许我们希望用户输入的整数保持为整数。在这种情况下,我们必须执行类型转换,这将在下一节中介绍。
类型转换或类型转换
有时你可能希望将用户输入的数据作为整数使用。我们看到,用户输入的数据将是一个字符串,即使它是一个整数,就像这个例子中一样:
>>> age = input("Enter your age? \n")
>>> Enter your age?
29
>>> type(age)
<class 'str'>
>>> age
'29'
年龄是用数字表示的。然而,在前面的代码中,它是一个字符串。因此,我们必须将它转换为整数,以便用户输入的信息对计算有意义。这种转换称为类型转换。然而,如果你在不将其转换为适当类型的情况下对这个值进行一些计算,你的结果将是不可取的。例如,如果你想通过将 29 加 2 来改变年龄的值,你不能将它从 29 改变为 31。这是因为字符串不支持增量;相反,它们支持连接:
>>> age
'29'
>>> age + 2
Traceback (most recent call last):
File "<pyshell#3>", line 1, in <module>
age + 2
TypeError: can only concatenate str (not "int") to str
因此,如果你想使用输入的年龄作为整数,我们必须使用类型转换方法。这些方法也是 Python 的内置函数。其中一些如下:
int(arg1, base): 这个方法将任何其他数据类型转换为整数。如果你在int函数的括号中放入一个字符串,它将把它转换为整数。arg1是要转换的字符串,基本参数表示数据的基数是一个字符串:
>>> a = int("10101", 2)
>>> a
21 #conversion from string to integer
>>> b = int("255")
>>>b
255
float(): 这个方法将任何整数转换为浮点数,就像这个例子中一样:
>>> float(3)
3.0 #this is floating point number
str(): 这个方法将任何其他数据类型转换为字符串,就像这个例子中一样:
>>> str(255)
'255'
ord(): 这个方法将字符类型转换为整数,并返回它的 ASCII 值,就像这个例子中一样:
>>> ord('a')
97 #ASCII value of a is 97
其他函数,如tuple()、list()、set()和dict(),将在接下来的章节中介绍。
现在你已经熟悉了 Python 的第一个构建块,即从用户那里输入数据,让我们看看如何使用 Python 提供的不同功能来格式化这些数据。在下一节中,我们将看一下字符串操作,这将反过来调用 Python 提供的不同方法来操作用户输入的数据。
字符串操作
任何数据类型,无论是文本、整数还是布尔值,用双引号(" ")或单引号(' ')括起来,在 Python 中都被视为字符串。字符串值揭示了数据的广泛含义。存储为字符串的数据可以很容易地访问,但不能被改变。因此,它被视为不可变的数据类型。让我们看一下下面的代码:
>>> msg = "happy birthday"
>>> msg.upper() # upper() is inbuilt method of string class that converts string to upper case
'HAPPY BIRTHDAY'
>>> msg
'happy birthday'
在前面的代码中,我们创建了msg变量并将一个字符串存储在其中。我们使用了string类的内置方法来操作该字符串,当我们将msg变量打印回来时,它保持不变。这意味着字符串是不可变的数据类型。如果你想改变字符串的内容,你应该完全覆盖它,就像这个例子中一样:
>>> msg = msg.upper()
>>> msg
'HAPPY BIRTHDAY'
字符串不支持项目赋值。如果你想向字符串添加一个项目,你必须创建一个全新的字符串。因此,Python 的这个特性使它成为不可变的,就像这个例子中一样:
>>> str1 = "John"
>>> str1[0] = "Hello"
Traceback (most recent call last):
File "<pyshell#30>", line 1, in <module>
str1[0] = "Hello"
TypeError: 'str' object does not support item assignment
要使用字符串的内置函数,你必须在字符串上调用一个方法。让我们看一下我们可以在内置方法中使用的模式,即"String".method_name():
>>> "Python".capitalize() #capitalize first letter of string
Python
>>> "xyz".join("pqr") #joins every letter of string "pqr" with xyz except for first and last letter
'pxyzqxyzr'
#len function does not have to call like this, call simply len() with string passed inside parenthesis
>>> len("Python") #prints length of string
6
您可以使用方括号访问字符串的每个元素。我们应该将位置放在方括号内。这些位置在 Python 中称为索引。字符串的索引从 0 开始,从左到右每次增加 1:
>>> info = "Python"
>>> info[2]
t
>>> info[0]
P
您可以在以下图表中观察索引模式。在这里,我们有一个Python字符串。字符串的索引从 0 开始。对于紧挨着具有索引的每个元素,都会增加一个单位到前一个元素的索引。这被称为正索引:

字符串还支持负索引。如果您想要从字符串中获取最后几位数字,可以给出一个-1索引,如下所示:
>>> info = "Python"
>>> info[-1]
n
>>> info[-3]
h
现在,我们已经学会了如何根据索引提取字符串的特定元素。但是,如果您想从字符串中提取多个元素,可以使用字符串切片操作。切片操作与比萨切片相同,表示我们按顺序取出字符串的一些部分。字符串切片可以使用与从字符串中提取单个字符时相同的方括号进行。这两种操作的区别在于,当我们用冒号扩展我们的方括号并为其提供开始、结束(不包括)和步长索引时,就会看到区别。尽管字符串切片的理论可能看起来很复杂,但编程起来很容易。让我们看一个例子来澄清这一点:
>>> email = "johndoe@gmail.com"
假设我们想要从这个电子邮件地址中提取一个人的名字。我们必须跟踪所有的索引才能做到这一点:

由于我们正在切片字符串的一些部分,我们必须将其想象为一个容器,其中每个字符都有其索引,以便更容易地引用它们。要实现字符串切片,请按照以下步骤进行:
-
使用
name_of_string[start: stop]:我们使用[step]命令进行字符串切片。这里,start是起始索引,stop是一个排他位置,这意味着如果您在其上放置一个索引,-1 的元素将被包括在内,但在停止索引处的元素将被排除在外。这里,步骤是可选的。我们将在接下来的章节(第三章:流控制:为您的游戏构建决策制定者)中讨论步骤索引位置。 -
首先决定需要提取什么。您不能随机提取字符串的任何部分。它应该是按顺序进行的。例如,您不能使用单个命令提取
jo和mail。我们可以提取johndoe,因为每个元素都是按顺序排列的。让我们尝试从我们的代码中提取它:
>>> email = "johndoe@gmail.com"
>>> email[0:7:] # 0 is starting position, 7 is stopping position and it is not included
'johndoe'
>>> email[:7:] #empty starting position also means start from 0 index
'johndoe'
在上述代码中,email[0:7:]或email[:7:]告诉我们,第一个索引 0 是字符串的起始索引,这意味着我们要从start打印。您也可以放置 0,表示默认状态,start将从起始位置打印。第二个索引 7 是停止位置,但它是一个排他位置,这意味着解释器将打印直到e字符,但不包括@,因为@在位置 7。最后,第三个索引位置是步长。我们在这里放置一个空格,表示它应该默认持有的值,这意味着我们打印时不跳过任何数字。如果您将步骤放置为>>> email[0:7:2],您将得到jhde作为输出;它将在每个字符之间跳过一个字符。
我们还可以对字符串执行加法和乘法运算。将两个字符串连接在一起称为连接。我们使用+和*等运算符来执行字符串操作,就像这个例子中一样:
>>> "-" * 50 #this will create 50 hyphen or dashes (-)
'--------------------------------------------------'
>>> "a" * 4
'aaaa'
但是,您不能将两种字符串类型相乘。其中一个必须是字符串,另一个必须是整数,如果我们希望对字符串执行乘法操作。
>>> "a" * "b"
Traceback (most recent call last):
File "<pyshell#22>", line 1, in <module>
"a" * "b"
TypeError: can't multiply sequence by non-int of type 'str'
如果您还想要添加字符串,那么操作数必须都是字符串。否则,它将抛出类型错误:
>>> str1 = "Happy"
>>> str2 = "Birthday"
>>> str3 = "John"
>>> str1 + str2 + str3
'HappyBirthdayJohn'
>>> str1 + 45 # YOU CANNOT ADD STRING AND INTEGER
Traceback (most recent call last):
File "<pyshell#28>", line 1, in <module>
str1 + 45
TypeError: can only concatenate str (not "int") to str
现在我们已经了解了字符串操作的基本知识,比如赋值、连接和赋值,我们将学习字符串格式化。如果我们需要根据输入更改文本的格式,这是一个重要的概念。
字符串格式化
字符串格式化是我们通过用变量的内容替换占位符来构建字符串的地方。我们应用%(取模运算符)来执行字符串格式化。如果要指定数字作为占位符,使用%d。如果是字符串,使用%s作为占位符。字符串格式化的结果也是一个字符串。让我们看一个小例子:
>>> key = "love"
>>> value = 13
#lets use string formatting technique
>>> print(" I %s number %d"%(key,value))
'I love number 13'
在上述代码中,%s的位置被 key 变量的值替换,%d的位置被 value 变量的值替换。因此,%d和%s是占位符。你不能在%d的位置上分配字符串值,也不能在%d上分配整数值。
传递的值的数量必须与字符串中使用的格式序列的数量相匹配。否则,它将抛出类型错误,如下所示:
>>> '%s %d %s'%("Hello",1)
Traceback (most recent call last):
File "<pyshell#19>", line 1, in <module>
'%s %d %s'%("Hello",1)
TypeError: not enough arguments for format string
你也可以使用 Python 的内置格式函数来格式化字符串。使用这个函数进行格式化相对容易。我们可以使用大括号{}作为占位符,而不是使用占位符或格式序列,如%d和%s。我们还可以在大括号内指定数字,以便格式化为特定值,如下所示:
>>> print(" I love {}".format("Python"))
'I love Python'
>>> print(" I love {0} and I hate {1}".format("Python", "Java"))
'I love Python and I hate Java'
>>> print(" I love {1} and I hate {0}".format("Python","Java"))
'I love Java and I hate Python'
现在我们熟悉了 Python 的核心编程范式,让我们跳到下一节,在那里我们将学习制作我们的第一个游戏:井字棋。
制作你的第一个游戏-井字棋
Python 语言是一种跨平台语言,这意味着我们可以为任何设备制作游戏。然而,在这里,我们将更多地关注逻辑及其实现,而不是针对特定平台进行编码。与其他语言相比,使用 Python 编写游戏更简单,因为它的语法更短,提供了丰富的内容库,使生产速度更快。话虽如此,如果在编码之前不制定计划,那就不那么容易了。我们必须将游戏实体分解成部分,以便每个实体都可以轻松调试。从现在开始,在制作游戏时,我们将遵循以下一般步骤:
-
头脑风暴和信息收集
-
选择合适的代码编辑器
-
编程模型
-
用户交互-用户输入/操作
到目前为止,我们已经涵盖了各种主题,包括变量、运算符、表达式、从用户那里获取输入并将其打印给用户。现在让我们将所有这些技术应用到我们的第一个游戏中。
头脑风暴和信息收集
在我们开始编码之前,让我们考虑一下游戏的设计和界面。拿出你的笔和纸,开始思考游戏的界面吧!到目前为止我们学到了关于 GUI 的什么?显然没有!这意味着我们必须为我们的第一个游戏使用一个简单的界面。在学习了 Python 的一些高级概念之后,我们将稍后对其进行修改。井字棋是一个需要从用户那里获取输入并根据玩家的移动放置 X 或 O 的游戏。因此,我们的界面应该是这些符号的占位符。我们将制作一个包含_的简单界面。下划线(_)将是我们的占位符,我们将根据玩家的选择放置 X 或 O。
_ | _ | _
_ | _ | _
_ | _ | _
上述代码显示了我们游戏的简单布局。它包含_(下划线)作为占位符和|来分隔符号。
_ | _ | O
_ | X | _
_ | _ | X
正如你所看到的,每当玩家迈出一步时,我们就用与该用户决定相对应的符号替换下划线。现在,我们有了一个游戏的基本界面。
现在我们已经规划了界面,我们需要解决如何跟踪下划线的位置以及如何找出在哪里用适当的符号替换下划线的问题。我们可以为这些下划线中的每一个分配一个数字,并告诉用户选择一个数字。然后,根据该数字,我们可以将其符号分配到该位置,如下所示:
0 | 1 | 2
3 | 4 | 5
6 | 7 | 8
现在,我们已经收集了足够的信息来开始我们的简单游戏。在复杂的现实世界游戏中,头脑风暴和信息收集过程大约需要 6 个月。现在,让我们来选择一个代码编辑器。
选择适当的代码编辑器
我们已经在我们的机器上安装了 Python,并查看了 Python 的预安装编辑器 IDLE。我们将在这个项目中使用该编辑器。让我们开始吧:
- 在搜索栏中搜索 IDLE 并打开它。你会得到以下 Shell:

这个终端或 Shell 通常用于在 Shell 内立即解释命令。这意味着一次只执行一个命令,但我们必须写很多行代码来制作我们的游戏。因此,用这个 Shell 编写游戏是不可能的。我们必须创建一个文件,可以在其中写入多行代码并一次性执行它们。Python 为我们提供了脚本来解决这个问题。
- 点击文件,然后新建文件,或按Ctrl + N。将打开一个新的脚本文件,我们可以在其中写入多行代码。

- 在窗口顶部,我们会看到未命名,这意味着我们还没有保存我们的文件。让我们先保存,因为无论如何我们都必须保存。按下Ctrl + S进行保存。我已经将其保存为
first_game.py。

现在我们已经选择了适合开发的 IDE,让我们开始为游戏开发我们的模型。
编程模型或建模
在编程中,模型是表示程序中数据流的一种方式。在我们的游戏中,这是关于如何使用作为用户输入获得的数据。在头脑风暴和信息收集部分,我们揭示了一些信息,讨论了位置以及如何将每个数字分配给代表玩家选择的位置。模型不包含演示逻辑;相反,它将处理数据逻辑。计算机不关心布局或界面。另一方面,用户需要一个界面来做出反应。因此,每个程序都有前端和后端。前端是应用程序中看到的一切,无论是应用程序的美学部分还是可见部分。在大型项目中,用户体验(UX)设计师主要在前端工作。后端不关心设计,它只关心应用于数据层的算法和安全性。模型被用作前端和后端之间的通信方式。
计算机不关心模型如何呈现数据,但用户应该以信息丰富和美观的方式从模型中获取数据。因此,我们制作了简单的布局,如下所示:
_ | _ | _
_ | _ | _
_ | _ | _
让我们开始创建我们的演示层模型:
#this code is written as scripts
game_board = ['_'] * 9 #this will create 9 underscores
print(game_board[0] + '|' + game_board[1] + '|' + game_board[2])
print(game_board[3] + '|' + game_board[4] + '|' + game_board[5])
print(game_board[6] + '|' + game_board[7] + '|' + game_board[8])
上面的代码代表了我们游戏的布局。它显示给用户。让我们逐行分解一下:
game_board = ['_'] * 9: 这个语句创建了 9 个下划线,作为我们游戏角色的占位符。它存储在game_board变量中。正如你可能记得的,一个变量不能存储多个值。如果我们对同一个变量执行多次赋值,变量将存储最后添加到它的值。因此,这个棋盘不是简单类型的变量。这是一个list变量。我们可以在列表中存储多个数据。让我们打印一下棋盘的值:
>>> board = ['_'] * 9
>>> board
['_', '_', '_', '_', '_', '_', '_', '_', '_'] # 9 underscores is
stored in board list
-
>>> print(game_board[0] + '|' + game_board[1] + '|' + game_board[2]): 上面的命令打印了布局的第一行。在本章的前面,我们已经学习了print语句。括号内的任何内容(字符串或变量值)都会被print语句原样打印出来。我们传递了board[0]来获取棋盘的第一个元素,也就是第一个下划线(_)。我们在每个下划线之间打印一个分隔符(|)。上面语句的输出是_ | _ | _。 -
我们必须再次打印前面的布局两次,这意味着我们必须使用两个
print语句:
>>> print(game_board[3] + '|' + game_board[4] + '|' + game_board[5])
>>> print(game_board[6] + '|' + game_board[7] + '|' + game_board[8])
- 插入在方括号中的数字是我们通常在编程中称为
索引的位置。这指的是list变量的某个位置。列表索引总是从零开始:
>>> board = [1,2,3,4,5,6]
>>> board[0] # this will give value 1 from "board" list
>>> board[5] # this will give value 6 from "board" list
- 下面的代码显示了我们井字棋游戏的最终布局。确保将程序编写为脚本,并按F5运行它:
game_board = ['_'] * 9
print(game_board[0] + '|' + game_board[1] + '|' + game_board[2])
print(game_board[3] + '|' + game_board[4] + '|' + game_board[5])
print(game_board[6] + '|' + game_board[7] + '|' + game_board[8])
#output
"""
_ | _ | _
_ | _ | _
_ | _ | _
"""
- 在前面的代码中,我们做了两件事:首先,在我们的布局的每个位置上打印了下划线,然后我们为每个位置分配了一个数字:
0th | 1st | 2nd
3rd | 4th | 5th
6th | 7th | 8th
现在,我们已经开发了代表游戏基本布局的编程模型,是时候让编程模型与游戏玩家进行交互了。在下一节中,我们将学习如何接受用户输入并对其进行操作,以便与我们游戏的模型进行交互。
用户交互-用户输入和操作
我们正在为用户制作游戏。因此,我们应该制作一个界面,使我们的应用程序更加用户友好。我们在上一节中已经做到了这一点。现在,我们必须从用户那里接受一些输入,并通过模型将其放到布局中。我们知道从用户那里接受输入的一个简单方法是使用input()方法。现在让我们使用它。
为了解决这个问题,我们需要考虑:我们应该从用户那里输入什么?是一个符号,比如 X/O,还是位置?
将输入作为符号是没有意义的,因为在接受输入后,我们应该知道在哪里放置它。因此,我们可以从用户那里接受位置,并自动将符号放入我们的代码中:
#code from models
#...................................................................
#code for user input
while True:
pos = input(" Enter any position you want from (0-8): \n")
pos = int(pos)
game_board[pos] = 'X'
print(game_board[0] + '|' + game_board[1] + '|' + game_board[2])
print(game_board[3] + '|' + game_board[4] + '|' + game_board[5])
print(game_board[6] + '|' + game_board[7] + '|' + game_board[8])
让我们一部分一部分地来分解这个问题:
-
while True:这将无限次运行。我们在请求用户输入部分看到了这种情况。因此,我们将无限次地从用户那里获取输入数据,这意味着我们的游戏循环没有终止。 -
pos = input("输入您想要的任何位置(0-8):\n"):这个语句将从用户那里接受 0 到 8 的位置输入,并将其存储在pos变量中。 -
存储在
pos变量中的数据将是一个字符串,但位置应该是一个整数。因此,我们必须使用int方法将其转换为整数。然后,我们将整数存储在pos变量中,如x = int(x)。 -
game_board[pos] = 'X':这个语句将X分配给用户选择的位置。pos变量包含了用户在上一个命令中选择的 0 到 8 的位置。现在,我们将X分配给该位置,取代下划线,如下所示:
0th | 1st | 2nd
3rd | 4th | 5th
6th | 7th | 8th
如果用户输入4,那么我们将在第4个位置放置X,如下所示:
0th | 1st | 2nd
3rd | X | 5th
6th | 7th | 8th
- 在我们为指定位置分配了玩家符号之后,我们必须使用这三个打印语句再次打印棋盘。它应该保持在循环内,因为我们必须在用户从键盘输入新位置时每次都打印棋盘。
现在我们已经完成了渲染布局和用户输入模型的制作,我们可以运行游戏并观察输出。你将看到的游戏可能不太吸引人,因为它没有一个合适的布局,也不会有我们井字棋游戏应该有的许多功能。在未来的章节中,我们将尽可能地使游戏可玩。现在,让我们来看看可能在我们的游戏中遇到的错误和警告。
可能的错误和警告
到目前为止,我们只涵盖了 Python 的基本基础知识,因此到目前为止,你可能还没有发现太多语义错误。但是,你可能已经习惯了语法错误。首先,变量命名可能会导致错误。如果你不遵循变量命名的规则或约定,你可能会遇到以下错误:
>>> my name = "John Doe"
SyntaxError: invalid syntax
前面的名称是无效的,因为在创建变量名称时不能使用空格。你可以在它们之间加一个下划线来指定它由两个单词组成。my_name是一个变量的有效名称。
如果你拼错了变量名,你会立即得到一个错误。假设你创建了一个名为Msg的变量,并将其用作msg。将返回一个错误,说明这是错误的定义。Python 是大小写敏感的,这意味着True和true在 Python 中是不同的。如果你将一个变量命名为True,那将是非法的,因为它是 Python 的关键字之一。
然而,你可以将一个变量命名为true:
>>> True = 45
SyntaxError: can't assign to keyword
>>> true = 45
>>> true
45
同样的规则也适用于命名模块。在本章中,我们学习了如何导入math模块并使用它的方法。然而,如果你拼错了模块的名称,长期来看会导致许多问题。在 IDLE 上不会立即看到错误;你必须编译你的脚本才能看到。因此,在 IDLE 上调试要困难得多。因此,请确保你正确拼写所有的模块和它们的方法。
>>> import math将成功地将math模块导入到你的项目中,但如果你使用错误的模块名称,你将得到以下错误:
>>> import Math
Traceback (most recent call last):
File "<pyshell#6>", line 1, in <module>
import Math
ModuleNotFoundError: No module named 'Math'
还有另一种比语法错误更危险的错误类型;这些被称为语义错误。语义错误发生在我们没有得到预期结果时。它们不会被解释器检测到,因此很难调试。我们可能由于错误执行表达式而出现语义错误。如果我们对优先规则不够重视,最终会导致程序出现错误的语句。
1 + 3**2表达式的输出是 10,而不是 16。然而,我们可以通过在括号中包围该语句来强制解释器打印 16。(1 + 3) **2将给我们 16。
现在你已经学会了如何纠正程序中遇到的错误,让我们来了解一下修改我们的第一个井字棋游戏的可能方法。
游戏测试和可能的修改
有几种方法可以找到游戏中的错误。首先,你可以向你的朋友们求助,让他们玩你的游戏。你第一次测试游戏时收集到的建议被称为 alpha 测试,它是任何游戏开发生命周期中的一个重要部分。通过采访收集足够的信息后,你可以开始修改你的游戏。
到目前为止我们学到的东西还不足以使我们的游戏更有吸引力。我们将在接下来的章节中学习一些主题,并相应地修改我们的井字棋游戏。
我们在本节制作的游戏很单调,没有激励用户去玩,但通过制作它,我们学到了很多东西。我们看了使用模型和视图概念创建游戏的基本过程。视图指的是我们呈现数据的布局,帮助我们通过界面与用户进行交互,而模型指的是我们在程序和用户之间传递数据的方式。我们还没有涵盖高级的 Python 语言范式,因此我们的能力有限。这意味着本章的游戏很简单。然而,在我们学习条件、循环和函数后,我们将对这个游戏进行修改。
以下是我们可以对游戏进行的一些可能修改:
-
让我们分析一下我们的代码及其局限性。我们告诉用户明确输入 0 到 8 之间的数字来指定用户的移动。如果用户没有输入数字而输入了字符串会怎么样?我们的程序将终止循环并因异常而崩溃。因此,我们将进行的第一个修改是限制用户只能输入数字。如果他们输入其他内容,我们可以打印一个用户友好的消息,而不是让程序崩溃。这个概念被称为异常处理,将在下一章中介绍。
-
目前,这个游戏只能与一个玩家一起玩,但井字棋是一个多人游戏。因此,我们必须学习条件和流控制,这将帮助我们实现玩家之间的过渡。我们将在下一章中做到这一点。
-
当用户捕获整行、整列或对角线时,他们应被视为游戏的赢家,并且游戏应完成执行。然而,我们还没有创建任何逻辑来使玩家成为赢家。到目前为止,我们所学的还不够,但在完成下一章之后,我们将能够对我们的游戏进行重大改变。
通过查看我们可以对我们的游戏进行的修改,我们可以看到在下一章中我们有更大的事情要做。尽管我们在本章中获得的知识足以创建一个编程模型并允许我们与单个玩家互动,但这对我们与多个玩家互动来说还不够,这需要对循环和条件结构有很好的理解。我们将在下一章中涵盖这些概念。
总结
在本章中,我们涵盖了 Python 的两个基本构建模块:输入和提供格式化输出。在本章中,我们查看了 Python 的内置数据类型,并首先学习了不同数据值及其类型,如整数、字符串、浮点数、布尔值和无值。我们通过学习变量、数字和math模块来了解 Python 生态系统。我们看到了如何使用math模块,并对需要遵循的变量创建和使用模块的规则和约定有了很好的理解。如果你想用 Python 开始你的编程生涯,这些主题是必不可少的。这些主题不仅为 Python 打下了坚实的基础,还教会了你在编程中需要遵循和消除的好坏实践,即使你是一名熟练的 Python 程序员。编码不仅仅是编写代码——它是关于以可读和可用的方式呈现信息。因此,我们看到了如何在编程中使用注释来使我们的代码更易读和可重用于其他程序员。
使用户输入数据,然后在我们的程序中使用它是使应用程序用户友好的唯一方法。因此,我们学习了如何使用户输入数据并将其存储在结构中,以便进一步操作时更容易访问。我们最终看到了input()方法的不寻常的工作行为,它将我们的整数或布尔输入数据转换为字符串。由于这个原因,我们学习了类型转换方法,并看到使用 Python 的内置方法执行数据转换是多么容易。
字符串是最基本和原始的数据类型,用于存储文本。我们专门讨论了字符串的创建和操作。我们学习了如何访问字符串的元素。我们还了解到字符串赋值是不可能的,因此我们得出结论字符串是不可变的。我们学习了字符串类的基本方法,如 capitalize、join、upper、lower 和 len。我们研究了字符串的两种格式化技术,即%s和%d,它们被用作占位符和格式化方法。你可以使用其中任何一个,尽管最好在这样做之前对每一个都有所了解。
然后,我们建立了我们的第一个游戏。我们看到建立游戏不仅仅是编码。我们需要经历各种过程,如头脑风暴、建模和用户交互。我们学习了模型和视图如何协同工作。然后,我们制作了一个简单的游戏,并有机会复习到目前为止学到的一切。最后,我们提出了一些我们可以对井字棋游戏进行的修改。随着我们在这本书中的进展,每个修改都将被涵盖。在下一章中,我们将学习流控制以及如何为我们的游戏构建决策者。
第三章:流程控制 - 为你的游戏构建决策制造者
Python 最大的福祉之一是自动化。当我们谈论自动化时,没有令人震惊的逻辑;一切都取决于条件和分支的力量。它们控制程序执行时的顺序。任何程序在其基本阶段都是通过模拟制作的。每当我们在真实环境中部署这些程序时,我们都会被各种噪音和意外行为所压倒。为了防止这种行为,条件起着重要作用。流程控制根据当前的布尔逻辑决定如何执行程序的特定部分。我们在上一章中涵盖了语句和运算符等主题,这些主题在创建布尔逻辑时非常有用。这些语句用于执行算术计算。在本章中,我们将看到如何操作这些语句,这将导致真或假的布尔逻辑。
在本章的中途,我们将学习循环,这是一种重要的技术,可以使我们足够有能力使代码更短更强大。本章将是一个完整的包,其中包括核心编程、条件和递归编程。我们将通过引入布尔逻辑和流程控制来完善上一章中制作的井字棋游戏。
本章将涵盖以下主题:
-
布尔逻辑和逻辑运算符
-
条件语句
-
迭代
-
for和while循环 -
为我们的井字棋游戏制作游戏控制器
技术要求
您需要以下要求才能完成本章:
-
Python 脚本和 IDLE
-
本章的代码资产可以在以下链接找到:
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter03
查看以下视频以查看代码的运行情况:
理解布尔逻辑和逻辑运算符
没有一天会过去我们不会说布尔类型要么是True要么是False。我们使用这些关键字来制定逻辑,以确定我们是否要执行某部分代码。让我们从现实生活中的角度来谈谈布尔类型。如果我们饿了,我们就吃点东西。如果我们累了,我们就休息。让我们将这些情景转化为适当的布尔语句:
-
is_hungry = True:吃点东西 || is_hungry = False: 不吃 -
is_tired = True:休息 || is_tired = False: 做你的工作
你根据手头的布尔逻辑执行这些日常任务。现在,让我们把它与编程联系起来;你可以使用两组基于布尔数据类型的代码:
- (
True):做某事 || (False): 做某事
我们使用布尔表达式来制作这种类型的逻辑。我们在上一章中看到了如何创建表达式。将变量和运算符结合起来将给我们一个简单的表达式形式,就像这个例子:
>>> y
>>> x = y + 6 * 7
然而,布尔表达式有点不同。它们不是给出整数结果,而是提供True或False的结果。布尔表达式的最简单形式可以使用双等号运算符(==)制作。不要将其与单等号(=)混淆。单等号用于赋值,而双等号(==)用于检查它们是否相等,就像这个例子:
>>> 5 == 5
True
>>> "Python" == "Java"
False
如果您比较两种不同类型的数据,结果始终是False:
>>> "5" == 5 # String(5) not equal to int(5)
您可以始终进行类型转换以使您的逻辑为True:
>>> int("5") == 5
True
>>> "5" == str(5)
True
要检查任何布尔变量的类型,可以使用type()方法并获得<class 'bool'>的输出,这意味着True或False是布尔类型的值:
>>> logic = False
>>> type(logic)
'<class 'bool'>'
布尔逻辑也可以与比较运算符一起使用。我们将在下一节中学习如何使用比较运算符创建语句。
比较运算符
任何结果为True或False的表达式都是布尔表达式。这些布尔表达式不能没有比较和逻辑运算符。我们已经看过基本的比较运算符(==);然而,还有六个我们需要了解的(<、>、<=、>=、!=和is)。让我们看看它们的运行情况:
-
5 < 10:5 小于 10,结果为
True。 -
5 > 10:5 大于 10,结果为
False。 -
10 <= 5:10 小于或等于 5,结果为
False。10 既不小于也不等于 5。 -
10 >= 5:10 大于或等于 5,结果为
True。10 大于 5。 -
10 != 10:10 不等于 10,结果为
False。10 等于 10。 -
5 是 5:5 和 5 相同,所以结果是
True。然而,5是 5,所以结果是False。
您可以将前面的数字存储在不同的变量中,并在 IDLE 上尝试相同的布尔表达式,以获得以下结果:
>>> v1 = 5
>>> v2 = 10
>>> v1 < v2
True
>>> v1 > v2
False
>>> v2 <= v1
False
>>> v2 >= v1
True
>>> v2 != v2
False
>>> v1 is v2
False
>>> v1 is v1
True
为了使适用于现实世界的逻辑,我们需要能够同时组合不同的比较操作并立即提供结果的运算符。这些类型的运算符被称为逻辑运算符。在下一节中,我们将学习不同类型的逻辑运算符以及它们的使用方式。
逻辑运算符
运算符被广泛分类为算术运算符、比较运算符和逻辑运算符。我们已经涵盖了算术和比较运算符;现在是时候涵盖逻辑运算符了。
您可以将逻辑运算符与逻辑门(and、or和not)联系起来,逻辑门是任何数字电路的基本构建模块。它们有两个输入,但是通过某些电路计算,我们只得到一个输出。电路处理是通过and、or和not门完成的。类似于逻辑门的数字电路,逻辑运算符可以有许多条件传递,但最终输出将是True或False。这里,conditions指的是我们使用比较运算符制作的布尔表达式。这三种基本逻辑运算符的工作原理如下:
and:两个条件用一个and运算符连接,即condition_one和condition_two。当这些条件中的每一个也为True时,整个条件将为True。如果与and运算符连接的条件中的任一条件为False,结果将为False。让我们看一个例子:
>>> condition_one = 5 > 2 #True
>>> condition_two = 6 < 10 #True
>>> condition_one and condition_two
True
>>> condition_two = 6 > 10
>>> condition_one and condition_two
False
and运算符的真值表,根据布尔或逻辑表达式的组合,将其功能值设置为True或False,如下所示:

or:与and运算符相同——两个条件用一个or运算符连接。如果要添加更多条件,可以添加更多or运算符。对于or运算符,如果连接到它的两个条件都为False,结果将为False;否则,结果将为True。让我们看一个例子:
>>> 4 < 10 or 5 == 5
True
>>> 4 <= 10 or 100 < 50
True
>>> 10 <= 4 or 100 < 50
False
or运算符的真值表,根据布尔或逻辑表达式的组合,将其功能值设置为True或False,如下所示:

not:这个运算符颠倒逻辑的类型。它将False变为True,反之亦然。因此,它被称为逻辑反转器。它只带有一个条件,如下所示:
>>> not (5 < 4) # condition 5 < 4 is False
True
>>> not True
False
not运算符的真值表,根据布尔或逻辑表达式的组合,将其功能值设置为True或False,如下所示:

在 Python 中,也可以用 1 和 0 表示True和False。因此,我们可以得出结论,任何非零整数都可以单独使用逻辑运算符来构成条件,就像这个例子:
>>> 1 and 1
1
>>> 1 and 0
0
>>> 1 or 0
1
>>> 49 or True
49
学习不同类型的运算符非常有趣,但现在我们将转到一个部分,在那里你将学习如何使用这些条件(由比较和逻辑运算符制作)来做出几个决定。条件语句在任何现实场景中都非常实用。我很兴奋地想要学习它们,你呢?
条件语句
到目前为止,我们已经学习了使用比较和逻辑运算符制作条件。现在,我们将讨论如何评估这些条件。条件语句是在我们想要计算这些条件的结果并相应地控制程序流程时非常有用的工具。正如我们已经知道的那样,这些条件的结果将是True或False。因此,根据我们使用的布尔类型,条件语句将执行代码的某部分。我们在 Python 中使用if语句来执行条件语句。在写下if关键字后,我们将条件放在其旁边。条件可以是单个的,也可以是许多逻辑运算符的组合。我们用冒号结束if语句;随后的语句应该正确缩进。看看以下例子:
#filename: conditionals.py
if (True):
#Do something
以下图表示了实现条件语句的布尔逻辑:

在使用 Python 时,请注意以下事项:
-
冒号(
:):如果你想在 Python 中声明作用域,在其中可以编写多个语句,你需要使用冒号(:)来指定。大多数编程语言使用花括号({})来实现这一点,但是 Python 在定义作用域和块语句的范围时有些奇怪,比如函数、if语句、类和循环。然而,一旦你熟悉使用这个,你会觉得很有趣,并且能够在一秒钟内区分出用 Python 编写的代码和其他语言的代码。 -
缩进(空格):在我们用冒号定义作用域之后,我们可以进入其作用域。在其作用域内编写的任何后续语句都应以统一的空格开始,我们称之为缩进。你可以按下Tab键为每个语句提供统一的缩进。大多数初学者犯的错误都是由于不正确的缩进。如果你没有提供正确的缩进,你将会收到 Python 解释器的以下警告:

If语句评估逻辑语句。每当该语句为真时,将执行其缩进的语句;否则,将被跳过。你还可以添加pass关键字,告诉解释器不要执行缩进块内的任何内容,就像这个例子一样:
>>> if ( 4 == 4):
pass
>>> #does not print anything
正如我们已经知道的,布尔语句将产生True或False。如果条件为True,则if语句内的缩进代码将被执行,但如果条件为False,则else部分内的缩进代码将被执行。让我们看一个例子:
>>> number = 1
>>> if number > 0:
print("Number is positive")
else:
print("Number is negative")
Number is positive
>>>
以下图表示了使用条件语句实现检查数字是正数还是负数的程序的流程图:

你可以看到,我们已经为True或False逻辑创建了两个条件分支。根据布尔逻辑的结果,流程控制被转移到程序的两侧。因此,条件语句也被称为分支。
尽管我们的代码能够执行具有两个分支的代码,但我们的代码中存在一些小差距。如果数字变量包含零,则既不是正数也不是负数。因此,我们必须为此条件添加一个条件。每当我们需要计算逻辑的两个以上分支时,我们可以进行链接条件。我们可以使用链接序列添加任意数量的条件。要使用任何其他编程语言执行链接条件,我们使用else if命令。Python 通过使用elif制作不同的命令。让我们看一个例子:
>>> number = input("Enter any number: ")
>>> number = int(number) #converting string to integer
>>> if number > 0:
print("Number is Positive")
elif number == 0:
print("Number is Zero")
else:
print("Number is Negative")
Enter any number: 0
Number is Zero
>>>
我们可以在一个条件语句中放置任意数量的条件。我们称这些为嵌套条件。让我们来看一个例子:
>>> number = 10
>>> if number > 0:
if number % 2 == 0:
print("Number is positive and even")
else:
print("Number is positive and odd")
Number is positive and even
在上面的例子中,外部条件包含两个子分支条件,在第一个分支中,我们检查偶数。下一个默认条件是检查奇数。在这个例子中,我们使用一个简单的单一语句来制定条件,但是在嵌套条件中,条件可以通过逻辑运算符变得复杂,就像这个例子中一样:
>>> number = 4
>>> if number > 0:
if number % 2 == 0 and number < 10:
print("Number {} is small even & positive number".format(number))
Number 4 is small even & positive number
现在你知道如何使用多个条件语句做出决策,我们将看一下一个非常实用的主题,称为迭代。这使我们能够执行一系列指令。这将重复执行,直到达到某个条件为止。
迭代
假设你想写一个程序,你必须打印你的名字 100 次。到目前为止,我们所学到的规定,最简单的方法是使用打印语句 100 次。但是如果你想打印你的名字 10000 次呢?连续写 2/3 页的打印语句不是好的编程。在这种情况下,我们必须使用循环。循环将帮助我们在数据集上进行迭代,直到满足条件。在每次迭代中,执行代码的一部分,并且我们必须每次更新迭代变量。以下是一个迭代变量的示例:
>>> i = 0
>>> i = i + 1
我们使用增量和减量单位更新迭代变量。在这里,我们通过将1加到i的值来更新i的值。这被称为增量。您也可以从中减去 1,这被称为减量。每次我们在缩进循环中执行代码时,我们使用增量或减量语句更新迭代。
同样,有一种相对更简单和更快的实现增量和减量语句的方法。您可以使用以下语句执行多个操作:
-
+=将一个数字添加到变量中,并在其过程中更改变量。 -
-=将变量与一个值相减,并将新值设置为其结果变量。 -
*=将变量乘以一个值,并改变变量的结果。 -
/=将变量与值相除,并将结果放在结果变量上。
让我们看一个例子来看看它的效果:
>>> value = 4
>>> value += 5
>>> print(value)
9
增量和减量运算符的有效性可以通过循环看出,我们多次重复一组操作。让我们看看使用 for 和 while 循环的循环。我们将首先学习for循环。
for 循环
每当你想在数据集中循环,比如在一系列数字范围内,在某个文件中,或者在一些确定的单词集合中,我们使用for循环。它也被称为确定循环。除非你的项目桶中还有某个项目,否则它将迭代。for循环在桶的末尾终止。在这里,桶是一个代表项目列表的隐喻,比如数字列表、单词列表或序列,就像这个例子中:
>>> for i in range(10):
print(i, " John Doe") #range(10) gives [0,1,2,3,4,5,6,7,8,9]
0 John Doe
1 John Doe
2 John Doe
3 John Doe
4 John Doe
5 John Doe
6 John Doe
7 John Doe
8 John Doe
9 John Doe
在上面的代码中,range()方法用于创建一个数字列表。range(10)提供了一个从0到9的数字列表。它被存储为[0,1,2,3,4,5,6,7,8,9]。
在第一次迭代中,i的值变为0,它执行for循环块中的代码,并自动更改i的值为该列表的下一个元素,如下所示:
>>> for i in [6,7,8]:
print(i)
6
7
8
您还可以在包含单词或文本的数据中进行循环。在我们循环列表中的每个单词时,迭代变量将包含一个单词作为值,就像这个例子中一样:
>>> for name in ['Tom','Harry','Ricky','Matt']:
print(name)
Tom
Harry
Ricky
Matt
在上面的例子中,迭代变量是name变量,每次它在列表中迭代时,它都会获取它的值并将其存储在name中。因此,我们只能在for循环的主体中使用name变量。除了for循环中的迭代变量之外,不能使用其他变量。这在下面的代码中显示:
>>> person_names = ['Tom','Harry','Ricky','Matt']
>>> for name in person_names:
print(person_names)
Traceback (most recent call last):
File "<pyshell#26>", line 1, in <module>
for name in person_name:
NameError: name 'person_name' is not defined
在上面的例子中,person_names是一种可以存储数组项的变量类型。这个变量被称为列表。我们将在下一章中介绍列表。这里,迭代变量是name,它是用for循环声明的。然而,在 for 循环的主体中,我们没有使用name变量。相反,我们使用了person_names,这导致了NameError。因此,迭代变量只能在for循环的主体中使用。
我们将要介绍的下一种循环类型是while循环,它将执行类似于for循环的操作,但有一些调整。while循环通常用于我们不关心循环终止点的情况。
while 循环
Python 中的另一种迭代形式可以使用while循环来执行。让我们回顾一下for循环的特点:它用于迭代有限序列的元素,无论是作为数字、单词或文件的列表。如果要使用for循环,必须有一个终止点。在使用for循环时,我们也不关心终止条件。当它达到项目或序列的末尾时,它就会终止。那么,如果我们想要根据自定义条件终止循环呢?在这种情况下,while循环是最合适的循环。我们可以制定一个自定义条件,在这个条件下,我们可以使用while循环来终止递归。
while和for循环都将执行不断的循环。在每次迭代中,它们都将执行循环的主体。for和while循环的主要区别在于,while循环必须在声明中声明更新语句和终止条件。首先,我们必须创建一个迭代变量,然后我们必须创建一个终止条件,以指定循环的停止点。在每次迭代中,我们必须更新迭代变量,就像这样:
>>> i = 0
>>> while (i < 10):
print("John Doe")
i = i + 1
John Doe
John Doe
John Doe
John Doe
John Doe
John Doe
John Doe
John Doe
John Doe
John Doe
在上面的例子中,我们创建了一个迭代变量i,并将其赋值为 0。之后,我们使用了while循环。为了使用这个循环,我们使用了while关键字,然后跟着它的终止条件。我们告诉解释器,我们希望运行这个循环,直到i小于 10。如果i等于或大于 10,我们希望终止这个循环。
之后,我们加上一个冒号来指定我们循环的范围。然后,我们为它添加了一个简单的打印语句,每次运行这个循环时都会执行。最后,我们添加了一个i = i + 1语句来指定更新条件。这将改变i的值为新值,并增加一。这很重要,这样我们就不会陷入无限循环。如果删除更新条件,循环将无限次运行,Python 终端将不会对用户的响应进行交互。创建无限循环的一种方法是使用没有终点的条件,就像这个例子中一样:
>>> while True:
print("Infinite loop")
上面的循环是一个无限循环,因为while关键字没有终点或终止点。如果我们能够将True关键字更改为False,那么这个循环就会终止:
>>> condition = True
>>> while condition:
print("This will run only one time")
condition = False
This will run only one time
在下一节中,我们将学习循环模式,以便了解循环在底层是如何工作的。
循环模式
for和while循环之间可能存在权衡,但在我们想要循环遍历已知元素列表或文件内容时,两者都能很好地工作。我们可以使用这些循环来排列或排序列表或文件中的元素。for循环不能无限次循环,但while循环可以使用永远不会发生的条件来实现。循环的主要目的是从特定文件或列表中获取项目,以便我们可以进一步处理它们。我们可以根据扫描数据集时的最小和最大值或重要和多余的值对这些项目进行排序。
循环模式的构造包含以下三个要点:
-
制作一个迭代变量。可以有一个或多个。它们用于构造表示循环终止点的条件。
-
在循环体内部进行一些计算,以便我们可以逐个操作循环获取的数据项。我们还可以在循环体内部更改迭代变量的值,这在
while循环的情况下通常会这样做。 -
寻找可能的基本条件,以便终止循环。否则,将导致无限循环。我们必须观察循环结束后的结果变量。
如果要演示循环模式的构造和工作范例,最好使用带有项目列表的循环。在下面的示例中,我们将编写一个程序,该程序将获取一个数字列表并检查列表中的最小数。
我们可以用两种方法做到这一点。Python 使得编程对于天真或专业人士都很容易。他们有各种实现相同逻辑的方法,但最常见的是使用 Python 的内置方法,如min()和max()。它们分别从 Python 的数字列表中获取最小和最大的数字。
>>> numbers = [113,115,55,66,65,90]
>>> min(numbers)
55
>>>max(numbers)
115
编写程序的第二种方法是制定我们自己的逻辑。由于此列表中有许多项目,因此我们应立即决定使用循环,这意味着我们必须重复进行一些比较。因此,如果要重复执行任务,最好使用循环。现在,我们需要决定使用for还是while循环。在这里最好使用for循环,因为for循环适用于有限列表。每次迭代迭代变量时,它将包含列表中的一个元素,以便我们可以重复与前一个元素进行比较。一开始,我们没有任何东西可以作为最小数。因此,我们必须创建一个变量,其中将包含None值。这意味着我们没有任何值。第一次迭代后,我们将其值分配给列表的第一个元素。让我们看看它是如何工作的:
>>> smallest_number = None
>>> for item in [113,115,55,66,65,90]:
if smallest_number is None or item < smallest_number:
smallest_number = item
>>> print("Smallest:", smallest_number)
Smallest: 55
让我们把前面的代码分解成以下几个部分:
-
在第一条语句中,
smallest_number = None将None赋值给比较变量。我们使用None而不是其他任何数字,以便在比较时不会漏掉任何数字。 -
在第二条语句中,我们将
item作为迭代变量,它将读取一个数字列表。在每次迭代中,它将存储该列表中的元素。在第一次迭代中,item 的值为 113。在第二次迭代中,item 的值为 115;在第三次迭代中,值为 55;依此类推。 -
我们现在在
for循环的循环体内部,需要构建一个比较语句。首先,我们需要检查最小数是否为None,以确保我们从基础开始。之后,我们将检查列表中的当前项目是否小于smallest_number。第一次迭代的第二个条件为 False,但第一个条件,即smallest_number为None,即为 True,这意味着我们将进入条件体。我们将smallest_value赋值为列表的第一个项目,即 113。 -
在第二次迭代中,项目是 115。我们进入
for循环并检查 115 是否小于smallest_numbe的值,即 113。这是False,因此它不会进入条件的主体;相反,它跳到第三次迭代。 -
在第三次迭代中,项目是 55。我们将检查条件,即检查项目的值(即 55)是否小于
smallest_number的值,即 113。条件(55 <113)为True,因此将smallest_number变量的值更改为 55。for循环将迭代直到该列表的最后一个数字。它将在每次迭代中使用相同的比较操作,以给出最小值,即 55。
通过仅更改比较运算符,我们可以编写一个程序,该程序将打印列表中的最大数字。我们可以使用item > largest_number语句而不是使用item < smallest_number语句来获取最大数字,如下所示:
>>> largest_number = None
>>> for item in [113,115,55,66,65,90]:
if largest_number is None or item > largest_number:
largest_number = item
>>> print("Largest: ",largest_number)
Largest: 115
在下一节中,我们将看看如何使用两个不同的语句break 和 continue,以改变或跳过迭代的顺序。
中断和继续语句
在编写程序时,有时您希望跳过语句的执行或强制停止迭代。这些操作由continue和break语句处理。它们可以在多种用例中发挥作用,例如当您希望使程序对列表的元素进行排序或在满足if条件时中断循环。continue语句用于跳过程序的执行。我们在循环的主体内使用这些语句。我们可以使用这些语句将元素从列表中排序出来。即使我们使用这两个语句,由于break将停止循环,使continue语句无效,因此我们不能在单个循环中使用这两个语句。我们可以在条件语句中使用这些语句。每当满足条件时,我们要么中断要么跳过迭代。让我们编写一个可以对列表的元素进行排序的程序:
>>> items = [1,5,7,8,"Free","spam",False,89,90,11,"Python"]
>>> refined_items = []
>>> for item in items:
if type(item) != int:
continue
else:
refined_items.append(item)
>>> print(refined_items)
[1,5,7,8,89,90,11]
在上述代码中,我们通过在输出列表中保留整数来完善列表的元素。其他数据值,如字符串和布尔值,都被删除。首先,我们循环整个列表,并在每次迭代时将元素存储在项目变量中。我们使用type()方法检查存储在项目变量中的数据的类型。如果存储在项目中的值的类型不是整数,则使用continue语句来推断如果不是整数,则不执行任何操作。如果项目的类型是布尔值或字符串,则使用continue语句跳过该迭代。但是,如果存储在项目变量中的值的类型是整数,则将执行代码的 else 部分中的语句。我们将取该整数项目并将其添加到名为refined_items的新输出列表中。在检查每个元素之后,我们打印精炼的列表,这是数字的最终集合。
如果使用break语句,直到元素 8 之前的情况将保持不变。但是,我们的输出将受限于[1,5,7,8],而不是打印其他元素[89,90,11]。这是因为break语句将在将元素 8 附加到列表后停止迭代。因此,我们可以得出结论,每当 Python 解释器触发break语句时,循环将终止:
>>> items = [1,5,7,8,"Free","spam",False,89,90,11,"Python"]
>>> refined_items = []
>>> for item in items:
if type(item) != int:
break
else:
refined_items.append(item)
>>> print(refined_items)
[1,5,7,8]
我们知道,在实际环境中部署程序时,这些程序将习惯于我们的代码无法处理的不同情况。在这种情况下,我们的程序将终止,这对用户或游戏玩家会产生负面影响。因此,我们必须以这样的方式编写代码,使我们的代码可以适用于任何情况,即使遇到意外错误或异常。编程中的这种强大技术称为异常处理,这是我们将在下一节中介绍的内容。
使用 try 和 except 处理异常
在上一章中,我们创建了一个简单的井字棋游戏。我们在该章节的末尾讨论了一些修改。由于代码的不足,无法处理用户输入除整数以外的情况,因此建议进行一些修改。如果我们的用户将字符串输入为游戏的位置变量,会发生什么?将抛出以下异常:

在上述截图中,我们可以看到我们的代码无法处理用户输入的字符串。只有当我们输入整数时,我们的代码才能正常运行。如果用户错误地输入任何其他数据值,程序将崩溃。本主题的目的是处理这种类型的错误。我们有两种类型的错误:语法错误和异常错误。以下代码显示了两种类型的示例:
>>> print("Hey! it's me")))
SyntaxError: invalid syntax
每当您输入错误的语句时,它都会抛出一个错误消息,即语法错误。在这里,我们使用了比正常情况多两个括号来包围打印语句,这是不正确的。因此,Python 解析器会抛出语法错误。删除这两个额外的括号以消除语法错误:
>>> a = 34
>>> a / 0
Traceback (most recent call last):
File "<pyshell#2>", line 1, in <module>
a / 0
ZeroDivisionError: division by zero
现在,Python 解析器抛出了一个异常错误。即使您的 Python 语法是正确的,也会发生这种错误。这可能是数学错误或逻辑错误。在数学上,您不能将任何数字除以零。这会导致一个无限大,这在 Python 中没有定义。因此,我们遇到了一个异常。有不同类型的异常。如果您想知道您遇到的异常的名称,请在收到异常后检查代码的最后一条语句。在我们的错误消息中,最后一条语句说ZeroDivisionError。因此,我们遇到了一个ZeroDivisionError异常。如果您遇到这些异常中的任何一个,那么您的代码很可能已经崩溃。因此,我们的井字棋游戏也崩溃了,因为它无法处理除整数以外的输入数据。
现在,我们的主要目标是使我们的代码可靠,即使我们的代码遇到异常,也不会崩溃,而是向用户提供友好的消息。在上面的示例中,我们可以发送用户一条消息,说您不能将任何数字除以零,而不是终止程序。这个过程称为异常处理。这些都是在 Python 中的try和except块中完成的。
如果您对代码是否出错不确定,您应该始终使用try和except块。您的主要代码,可能会遇到异常,应该放在try块内。然后,如果它遇到异常,Python 解析器应该执行except块内的代码。以下示例应该让您更清楚:
>>> a = 34
#INSIDE TRY BLOCK: put code that can give you error or exception
>>> try:
print(a/0) #this will give you exception
except:
print("You cannot divide any number by Zero. It is Illegal!") #message to user
You cannot divide any number by Zero. It is Illegal!
上述代码显示了处理这些错误有多么容易。您将主要代码放在try块内,如果遇到异常,主要代码将不会被执行。相反,将执行except块内的代码,这种情况下是一个用户友好的消息。您还可以使用pass,以便在不向用户提供消息的情况下终止程序。
使用 except 关键字,您还可以显式传递异常的名称。但是,请确保您知道要遇到的正确异常名称。在这种情况下,我们知道我们将遇到 ZeroDivisionError,因此我们可以在 except 块中写入异常名称,如下所示:
>>> a = 34
#INSIDE TRY BLOCK: put code that can give you error or exception
>>> try:
print(a/0) #this will give you exception
except ZeroDivisionError:
pass
现在,让我们看看如何利用我们迄今为止学到的一切知识来完善我们的井字棋游戏。我们将使用条件、循环和异常处理来修改我们在上一章中编写的代码。
为我们的井字棋游戏制作游戏控制器
在上一章中,我们构建了一个简单的井字棋游戏。由于在本章中我们已经学习了条件和循环,现在我们能够对我们的游戏进行一些改进。我们将对我们的游戏进行以下更改:
-
我们必须使游戏成为多人游戏,这意味着我们必须对程序进行修改,以便两名玩家可以玩我们的游戏。我们可以制作可以切换玩家的条件。
-
当我们谈到异常处理时,我们看到我们的游戏无法处理用户输入的字符串数据。我们可以使用
try和except块来处理该异常。 -
我们无法确定我们在上一章中编写的代码的获胜者。现在我们已经了解了
if条件,我们可以想出一些逻辑来检查玩家是否获胜。
我们将通过集思广益的方式开始我们的游戏开发过程,以收集有关游戏的一些关键信息。
集思广益和信息收集
我们的代码缺乏的一个重要特性是可读性。在上一章的游戏代码中,我们没有一个合适的方法来跟踪游戏板的位置。我们的代码可以做的第一件事是制作一个 choices 列表,其中包含玩家可以做出的所有选择。在这里,选择是用于井字棋板位置的。在井字棋游戏中,玩家可以在 0 到 8 之间选择数字,如果它们没有被其他玩家占据的话,这些数字就是占位符。
我们在代码中必须添加的第二件事是我们如何切换轮到谁了。由于我们只有两名玩家,我们可以让玩家 1 先行动并进行游戏的第一步。因此,制作逻辑会更容易。我们将制作一个布尔变量,并将其值从 True 更改为 False,以便我们可以制作一个条件来改变玩家的回合,如下所示:
-
playerOne = True确保是玩家 1 的回合。 -
playerOne = False将允许玩家 2 在我们的游戏板上行动。
我们必须遵循井字棋游戏的规则,以便根据玩家在游戏板上占据的位置来确定任何玩家是否获胜。如果玩家 X 或 O 占据了整行、整列或对角线的井字棋板,那么该玩家将被视为获胜者。这在下面的截图中有所体现:

修改模型
我们在上一章中编写的程序只能让一个玩家玩游戏。由于井字棋是一款多人游戏,应该对程序进行修改,以便多名玩家可以玩这个游戏。为此,我们必须做两件事:
-
跟踪棋盘上的空位。
-
制作一个条件来切换玩家的回合。
对于这两种修改,我们必须创建一个变量,可以跟踪游戏板上的每个空位和占用位置:
#code is written in same previous file
choices = []
for pos in range(0,9):
choices.append(str(pos+1))
如果您使用 >>> print(choices) 变量,这将导致一个值列表:['1', '2', '3', '4', '5', '6', '7', '8', '9']。它们是我们棋盘游戏的位置。
现在,如果您想打印棋盘的布局,您不能使用上一章的代码。我们将使用 choices 变量,而不是使用 game_board 变量。这与上一个示例中的工作方式相同。我们将在每一行之间添加一行短划线:
#board layout
print('\n')
print('|' + choices[0] + '|' + choices[1] + '|' + choices[2] + '|')
print('----------')
print('|' + choices[3] + '|' + choices[4] + '|' + choices[5] + '|')
print('----------')
print('|' + choices[6] + '|' + choices[7] + '|' + choices[8] + '|')
#output
'''
|1|2|3|
----------
|4|5|6|
----------
|7|8|9|
'''
我们游戏中的主要问题可能出现在用户输入非数字的情况下。例如,如果玩家输入字符串,游戏将以异常终止,我们不希望发生这种情况。正如您可能记得的那样,我们使用异常处理来避免这种情况。我们将在下一节中将其添加到我们的游戏中。
处理游戏的异常
让我们运行到目前为止我们制作的游戏,并在输入字段中输入一个字符串而不是整数值。你将得到以下异常:

我们不希望我们的程序在用户与我们的游戏交互时出现错误时崩溃。相反,我们可以发送给他们一个用户友好的消息,说这不是有效的,只按整数键。让我们通过使用try和catch块来处理这种类型的异常,如下所示:
while True:
print('\n')
print('|' + choices[0] + '|' + choices[1] + '|' + choices[2] + '|')
print('----------')
print('|' + choices[3] + '|' + choices[4] + '|' + choices[5] + '|')
print('----------')
print('|' + choices[6] + '|' + choices[7] + '|' + choices[8] + '|')
#above code is to print board layouts
try:
choice = int(input("> ").strip())
except:
print("Please enter only valid fields from board (0-8)")
continue
当我们运行我们的程序时,我们将得到以下输出:

在这里,我们将 Python 字符串值输入到输入字段中。而不是使程序崩溃,我们得到了一条消息,说请只输入来自棋盘的有效字段。这是使用try和except块处理异常的一种方便的方法。我们使用了前一章的相同主循环,它将无限次循环。在try块的主体中,我们保留可能引发异常的代码。strip()是一个字符串方法,将从用户的输入中删除空格。我们必须使用int方法对用户输入进行类型转换,以便将以字符串形式的输入数据转换为整数。如果我们遇到异常,我们将执行except块中的代码。continue关键字将使主循环再次从头开始运行,如果我们遇到异常。
必须添加到我们的井字游戏中的主要功能是多人游戏,这样两个玩家可以轮流玩同一场游戏。这个切换功能将在下一节中添加。
切换玩家的轮次
使用 Python 编写程序让两个玩家玩游戏很容易,你只需要创建一个布尔变量来表示当前玩家是谁。然后,根据布尔的两个值,True或False,我们可以改变谁在玩游戏。但是,如果你想添加超过两个玩家,这个想法就行不通了。我们将使用以下布尔值:
-
Is_Current_One = True:当前玩家是玩家 1 或 X。 -
Is_Current_One = False:当前玩家是玩家 2 或 O。
这在以下代码中显示:
#creating Boolean variable
Is_Current_One = True #default player is player X
#first move is done by player X
while True:
#put code of board layouts here
if Is_Current_One:
print("Player X")
else:
print("Player O")
#put try and except block here
#---------------------------------------------
#code to put either X or O on position selected by user
if Is_Current_One:
choices[choice-1] = 'X'
else:
choices[choice-1] = 'O'
#code to toggle between True and False
Is_Current_One = not Is_Current_One
让我们将前面的代码分成几个部分,以便更好地理解它:
-
我们有一个主循环,它将无限次运行,直到触发
break语句。我们已经学会了break关键字将终止我们的循环。在主循环的主体中,我们打印出是玩家 X 还是 O 轮到了,以使玩家意识到轮到他们了。 -
我们创建了一个名为
Is_Current_One的布尔变量,它被赋予了一个值True。这意味着第一个移动的玩家将是玩家X。如果我们将这个变量设为 False,那么第一个移动的默认玩家将是玩家O。 -
在主循环内,我们创建了一个条件来检查玩家
X或玩家O是否已将X或O放置在棋盘布局上。choices[]变量反映了棋盘的位置。choice是用户的输入,我们将其减去 1,因为我们的 choices 变量是一个列表类型。我们知道列表索引从索引 0 开始,但我们已经输入了用户输入从 1 到 9。因此,我们将choice输入变量减去 1 以适应这个列表变量。 -
>>> Is_Current_One = not Is_Current_One语句将在玩家之间切换。正如我们之前提到的,如果Is_Current_One为True,那么玩家就是X,现在,我们还制定了一个条件,这样我们就可以在下一次迭代中将 True 改为 False,这样玩家O就可以进行下一步。
让我们通过运行我们的脚本文件来看看我们现在在做什么。您将在 shell 中看到以下结果打印出来:

现在,我们已经创建了我们的游戏,它可以接受用户的输入,并将其放在井字游戏板上。我们已经制定了一些逻辑来改变轮到谁了。我们还能够使用try和catch块处理游戏中可能出现的异常。
我们一直在快速进展,但我们的游戏还不完整。我们还没有制定任何逻辑,如果玩家占据一行、一列或三个对角线单元,他们就会成为赢家。我们将在下一节中完成这一点。
使玩家成为赢家
井字游戏是一个很容易制作的游戏,但建立这个游戏的主要目的是涵盖 Python 的几乎所有核心编程范式,比如变量、数字、模型、内置方法、循环、分支和异常处理。现在,我们的游戏已经足够好,可以供两个玩家玩,但多人游戏最终只能有一个赢家。因此,我们必须制定全新的逻辑,以奖励玩家如果他们赢了。我们需要涵盖三种用例,如下:
-
如果井字游戏板的整行被一个玩家占据,那个玩家将成为赢家。
-
如果棋盘的整列被一个玩家占据,那个玩家将成为赢家。
-
如果棋盘的整个对角线被一个玩家占据,那个玩家将成为赢家。
让我们打印我们的游戏板布局,以及它们的位置,这样我们就可以在制定前述条件时跟踪棋盘的所有位置:
| 1 | 2 | 3 |
----------
| 4 | 5 | 6 |
----------
| 7 | 8 | 9 |
由于我们必须循环遍历从 1 到 9 的所有这些位置,我们需要使用for循环。由于我们有一个有限的数字列表,所以使用for循环很容易。我们必须制定两个条件来检查玩家是否占据了整行或整列。在处理行和列之后,我们将独立检查对角线条件:
-
对于行:如果任何用户占据[1,2,3],[4,5,6],[7,8,9],那个特定的玩家将被视为赢家。
-
对于列:如果任何用户占据[1,4,7],[2,5,8],[3,6,9],那个特定的玩家将被视为赢家。
然而,choice变量中的位置范围是从 0 到 8,即['0','1','2','3','4','5','6','7','8'],因此索引 0 表示棋盘的第一个位置,索引 1 表示棋盘的第二个位置,依此类推。
我们一直在使用一个while True语句作为我们的主循环。让我们修改一下,这样我们的代码将一直运行,直到有一个玩家成为赢家。我们将运行我们的主循环,直到won=False。如果我们得到了游戏的赢家,我们将改变won变量的值为True,这样主循环就会结束:
won = False #at first we don't have any winner
while not won:
#code from previous topics
#logic to make any player winner:
for pos_x in range(0,3):
pos_y = pos_x * 3
#for row condition:
if (choices[pos_y] == choices[(pos_y + 1)]) and (choices[pos_y]
== choices[(pos_y + 2)]):
#code to change won to True
won = True #main loop will break
#column condition:
if (choices[pos_x] == choices[(pos_x + 3)]) and (choices[pos_x]
== choices[(pos_x + 6)]):
won = True #main loop will break
在前面的代码中,我们制定了两个条件来检查玩家是否是赢家。我们制定了won变量来跟踪任何玩家是否赢了。如果任何玩家占据了整行或整列,我们将使won变量的值为 True,我们的循环将中断,这意味着我们将结束游戏。然而,我们还没有给用户一个关于成为赢家的消息。让我们编写一些代码,在检查行和列条件之后,告诉用户他们是赢家:
while not won:
#code from previous topic
for pos_x in range(0,3):
pos_y = pos_x * 3
#add condition for row and column here
#print who is winner
print("Player " + str(int(Is_Current_One + 1)) + " won, Congratulations!")
我们用print方法编写的语句可能会引起混淆,因为str(int(Is_Current_One + 1))命令。这里,Is_Current_One要么是True,要么是False。然而,它也对应着 1 或 0,其中 1 是True,0 是False。如果玩家X是赢家,那么赢家是玩家 1,但轮到了玩家O,也就是玩家 2。
因此,我们必须将这个加到 1,这样当前玩家就确定为赢家,而不是下一个玩家。由于这是一个双人游戏,这是有道理的。让我们运行我们的代码来检查结果:

我们还没有完成 - 我们还必须添加一个条件来检查对角线是否也被玩家占据。让我们现在添加这个条件:
| 1 | 2 | 3 |
----------
| 4 | 5 | 6 |
----------
| 7 | 8 | 9 |
如果任何玩家占据位置[1,5,9]或[3,5,7],他们将被视为赢家。然而,我们的choices变量是一个包含所有位置的列表。它的索引从 0 开始,这意味着如果你想定位玩家 1 的位置,你应该传递choices[0],就像这样:
while not won:
#code from previous topic
for pos_x in range(0,3):
pos_y = pos_x * 3
#add condition for row and column here
#diagonal condition here:
if ((choices[0] == choices[4] and choices[0] == choices[8]) or
(choices[2] == choices[4] and choices[4] == choices[6])):
won = True
#print who is winner
print("Player " + str(int(Is_Current_One + 1)) + " won, Congratulations!")
现在,让我们再次运行游戏,检查这个条件是否正常工作:

最后,我们完成了我们的游戏!我们能够在游戏中包含许多功能,比如异常处理、多人游戏模式和使玩家成为赢家的逻辑。然而,我们仍然需要通过添加用户定义的函数来完善这个游戏,以便我们可以打印我们的棋盘布局。这将遵循 DRY 原则,并将在下一章中介绍。
总结
本章为我们提供了一个关于 Python 编程范式核心主题的过山车之旅。我们涵盖了流程控制以及如何使用分支和循环来实现它们。我们学习了如何制定条件并将其传递给条件语句。然后,基于这些条件,我们能够在语句的执行之间进行切换。我们看到了如何使用 Python 的循环和分支来自动化事物。我们使用if关键字传递了多个可能的条件,并且根据布尔表达式的结果来控制程序的流程。我们还学习了不同类型的循环,并看到了如何使用它们来迭代一个项目或对象的列表。然后,我们看到了如何使用 try 和 except 块来处理异常。
最后,我们通过结合本章学到的不同范式,使我们的井字棋游戏比以往任何时候都更具可玩性。我们添加了try和except块,以便捕获和正确处理任何异常。我们还添加了多人游戏模式和使玩家成为赢家的逻辑等功能。这使得游戏非常互动。最后,我们使用条件和循环制作了一个游戏控制器。然而,我们不会止步于此;在接下来的章节中将进行更多的修改。
下一章对我们来说将是改变生活的。到目前为止,我们只使用了 Python 的内置函数,比如min()、max()和input()。在下一章中,我们将看到如何制作我们自己的函数,并使用它们,以便我们可以使我们的游戏更易读和可重用。我们将涵盖列表、集合和字典等数据结构,以便我们知道如何管理和存储更复杂的数据集。不过,不要被所有这些陈述所压倒。你已经走了这么远,现在正处于成为熟练的 Python 程序员的边缘。在进入下一章之前,请确保你对我们迄今所学的所有主题都感到舒适。
第四章:数据结构和函数
在这一章中,我们将穿越数据结构和函数的概念,这是 Python 的两个主要构建模块。普通变量是存储任何类型的单个数据单元的好方法,但对于数据数组,我们应该始终使用数据结构。Python 有大量可用的数据结构,您可以使用它们来表示和操作数据集,甚至将它们组合在一起以制作自己的数据结构。我们已经看到了内置数据类型,如整数,布尔值,浮点数,字符串和字符。它们被称为内置类型,因为它们与 Python 一起提供。现在,我们将探索内置数据结构,如列表,字典,元组和集合。这些内置数据类型的组合会独立实现数据结构。例如,如果我们将不同的整数放在一个地方,它们就是数字数组。Python 称它们为列表,这是广泛使用的数据结构。
为了成为熟练的程序员,我们首先必须了解核心编程范式,如变量,数字,模块和内置函数,然后再深入研究数据结构和算法。这本书也不例外。我们已经介绍了 Python 的基础知识;现在是时候深入研究数据结构和用于访问和操作数据的方法。在上一章中,我们使用条件和循环修改了我们的游戏。现在,让我们将对 Python 的知识扩展到包括数据结构和函数的广泛概念,以便我们可以完善我们的游戏决定最快
进一步。
本章将涵盖以下主题:
-
我们为什么需要数据结构?
-
Python 的四个结构支柱-列表,字典,集合和元组
-
函数
-
向井字棋游戏添加人工智能
-
游戏测试和可能的修改
技术要求
以下是您需要正确理解本章的要求:
-
Python IDLE
-
本章的代码资产可以在
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter04找到
查看以下视频以查看代码的运行情况:
我们为什么需要数据结构?
作为程序员或计算机科学家,我们总是在寻找优化代码的方法。优化是一种改进代码以提高代码效率和质量的方式。数据结构是计算机中组织数据的一种聪明方式,因此更容易检索和访问数据,从而实现代码优化。
到目前为止,我们已经学会了如何使用条件语句来制定条件,以及如何使用普通变量来进行流程控制。然而,现实世界的数据不限于一个单位。我们可能收集大量数据,这些数据可能具有最高级别的复杂性。它可能包含数千个整数,数百个布尔值,或者它们的组合。因此,将它们存储到一个单一的普通变量中是不可能的。请看以下示例:

在上面的代码中,我们尝试将两个值分配给一个变量。这产生了语法错误。我们甚至尝试将两个字符串值放入一个变量a中,但它执行了连接,并将其分配为单个值。因此,在普通变量中存储多个值是不可能的。但是,我们可以轻松地将这个普通变量转换为数据结构,如下面的代码片段所示:
>>> a = 8 , 9
>>> a
(8,9)
>>> type(a)
<class 'tuple'>
我们已将普通变量a转换为元组,这是 Python 的一种内置数据结构。我们将在接下来的部分中详细介绍这一点。
这个变量只能存储单个数据单元,但如果我们进行多次赋值,前面的值将被覆盖。然而,如果你想在一个占位符中保留所有数据,数据结构是一种方法。
作为程序员,我们的主要责任是对输入数据集进行某种操作。输入可以是任何东西,比如电子邮件或密码,或者可能是进入系统或谷歌地图位置的请求,我们可以使用数据使用算法进行某种计算。此外,haversine 算法(请参考以下网址了解更多关于这个算法的信息:rosettacode.org/wiki/Haversine_formula)可以给出您的位置和目的地之间的精确距离。因此,输入数据可以有很广泛的范围,但主要任务是对其进行操作。我们的系统和处理器没有足够的能力一次处理数百太字节的数据操作。因此,选择适当的数据结构是程序员可以进行的主要优化。如果我们能够以任何有组织的形式将这些输入存储到更快的数据结构中,我们甚至可以轻松地执行复杂的任务。数据结构只是提供结构给这些复杂数据的地方或存储,但是像获取和操作这样的过程是使用算法来执行的。
还有疑问吗?让我们通过以图书馆为例来清楚地理解数据结构和算法。首先,想象一个没有适当管理的图书馆的情景。书籍没有被正确放置在相关的部分。现在,在特定部分搜索书籍是没有意义的,因为它不会在那里。最好的情况是你可能会在几分钟内找到你的书,但最坏的情况是你可能不得不搜索整个图书馆来找一本关于历史的书,例如。然而,如果图书馆得到了适当的组织和管理,你将能够直接去到存放历史书籍的相关部分,并且只在那个部分搜索你的书。在这里,图书馆代表了数据结构,书是你正在寻找的数据。每当你需要数据时,你去到数据结构,如果它被适当管理,你将能够轻松地检索到它。定义你将如何搜索书籍的步骤被称为算法。
理论够了,让我们动手编码,学习 Python 的四大数据结构支柱——列表、字典、集合和元组。
Python 的四大结构支柱——列表、字典、集合和元组
在第二章中,学习 Python 的基础知识,我们学习了字符串,并将它们称为不可变数据类型,因为它们不允许赋值操作。这在以下代码中显示:
>>> name = "Python"
>>> name[0] = 'hey'
TypeError: 'str' object does not support item assignment
然而,数据结构必须是灵活的,这意味着我们应该能够从任何位置存储和提取数据元素。因此,Python 的大多数内置数据结构都是可变的,这意味着它们可以通过适当的索引进行更改和操作。四种数据结构的适当类别如下:
-
列表和元组:可变数据结构
-
字典:映射数据结构
-
集合:可变且无序的数据结构
每个类别都因其独特性而存在,您将看到在接下来的部分中,很容易将它们区分为优越或次优。但是,请记住,它们在某些时候都是优越的;我们可以选择一个适合情况的数据结构。例如,我们说字典是数据结构之王,但我们可能会遇到元组可能是存储数据的更快方式的情况,通常在使用 SQLite 和 MySQL 等数据库制作 Python 程序时会出现这种情况。现在,让我们来看看 Python 的每个内置数据结构,从基本的可变数据结构开始,即列表。
列表
就像字符串是字符序列一样,列表是值序列。值可以是任何类型的组合。列表中的值称为该列表的项。列表是可变和有序的数据结构,可以使用索引提取列表的元素。与字符串一样,我们可以使用切片技术从列表中提取多个元素。列表以存储同质数据类型而臭名昭著,但它们也支持异质数据类型。我们不仅限于使用单一方法创建列表;有多种方法可以做到这一点。让我们看一些在 Python 中创建列表的基本方法:
>>> first_list = []
>>> type(first_list)
<class 'list'>
创建列表的最简单方法是使用方括号——[]。您可以在这些方括号中添加多个元素,有多种方法可以做到这一点:
- 首先,我们可以在声明列表的同时向列表中添加元素,如下例所示:
>>> numbers = [1,2,3,4,5,6,7,8,9]
- 您还可以使用 Python 内置方法向列表中添加元素。例如,
append方法可用于向列表中插入元素。元素被添加到列表的最后位置,如下所示:
>>> numbers.append(10)
>>> print(numbers)
[1,2,3,4,5,6,7,8,9,10]
我们还可以创建一个包含多种类型值的列表,如下例所示:
>>> [3,7,9,"odd",True]
[3,7,9,"odd",True]
在这里,我们创建了一个包含数字、字符串和布尔值的列表。因此,我们在一个列表中存储了异构的数据类型。我们还可以在一个列表中添加多个列表,这些被称为嵌套列表。正如术语所示,一个列表嵌套在另一个列表中,如下例所示:
>>> [1,2,3,[4,5,6],7,["hey","Python"]]
在上一个例子中,我们创建了一个包含六个元素的单个列表。我们在主列表中有整数和两个整体列表([4,5,6]和["hey","Python"])。因此,这些类型的列表被称为嵌套列表。
每当您将这些列表分配给变量时,变量类型最终变为列表类型。现在,变量的类型已从内置数据类型(如int、str或bool)更改为内置数据结构,即列表。
创建列表的另一种方法是使用内置的 Python 方法——list()方法——但在前面的过程中是多余的,因为我们必须将整个列表作为这个列表方法的参数。这被称为类型转换方法。如果你想将任何其他数据结构转换为列表,我们使用list()方法,如下例所示:
>>> list([1,2,3,4,5])
[1,2,3,4,5]
在list()方法中,我们必须以包含元素的列表形式传递参数,这些元素使用方括号括起来。到这一点,您一定已经猜到了,Python 中可用的每个内置数据结构都必须有一个内置方法来创建其数据结构。我们使用dict()方法创建字典,使用set()方法创建集合,使用tuple()创建元组,就像list()方法创建列表一样。
由于我们在本节中揭示了创建一个名为list的简单而强大的数据结构的不同方法,让我们看看如何访问和操作其存储的数据。
访问列表元素
如果你回忆一下我们访问字符串元素的方式,你也可以在列表的情况下复制这个过程。我们在列表中使用方括号来指示它的位置,这样我们就可以提取和与特定元素交互。我们称之为索引,它被添加在这个[]括号符号内。创建新列表时也是一样的。列表的索引从 0 开始,以单位数字递增,同时从左到右遍历。与字符串一样,列表也支持负索引:
>>> winner_names = ["Chandler","Joey","Monica","Racheal","Ross"]
>>> winner_names[0] #0 is first index
'Chandler'
>>> winner_names[-1] #-1 is last element
'Ross'
当我们尝试给字符串赋值时,是无效的。与字符串不同,列表可以重新分配项目给列表。因此,我们可以说列表是可变的,这意味着它们是可改变和可修改的。这个特性使得列表成为所有数据结构中最简单和最灵活的。我们可以使用 append 方法来分配元素,这是我们在前面部分看到的,但这个方法只允许我们将元素添加到列表的末尾。如果你想要添加元素到任何特定位置,你可以通过索引和赋值语句明确告诉 Python 解释器来做这件事。
例如,如果你想在列表的两个元素之间添加loves,你可以这样做:
>>> msg = ["Joey","Monica","Racheal"]
>>> msg[1] = "loves"
>>> msg
['Joey','loves','Racheal']
因此,我们可以看到位置一的元素Monica已经被loves替换,这表明我们可以改变元素的顺序,并重新分配任何其他元素给列表。
在处理数据结构时,观察它们是一种良好的实践。我们可以将它们视为一种映射过程,列表上的每个元素都映射到某个索引。索引是位置,每当我们通过索引回溯列表时,我们就能访问这些索引的元素。即使你有一个嵌套列表,也就是说,在单个列表内有一个或多个列表,它们也会被映射到一个索引,如下面的例子所示:
>>> web_dev = [["Django","Flask"],["Laravel","Symfony"],"Nodejs","GOLang"]
>>> web_dev[0]
['Django','Flask']
>>> web_dev[1]
['Laravel','Symfony']
我们知道方括号用于访问列表的元素,但如果我们想要访问嵌套列表的元素,我们必须添加另一个方括号,以指定需要访问这些元素的索引级别,如下面的例子所示:
>>> web_dev[0][0]
'Django'
>>> web_dev[1][1]
'Symfony'
我们可以使用in关键字来检查元素是否在列表中。语句的语法会返回一个布尔值,要么是True,要么是False:
>>> names = ["John","Jack","Cody"]
>>> "Cody" in names
True
>>> "Harry" in names
False
访问列表的元素更容易,但有时如果在计算正确的索引时出错,可能会得到意外的结果。因此,你必须从索引 0 开始计算列表的元素。如果你在方括号中放入不映射到任何值的索引,你将遇到一个错误,称为IndexError,如下面的例子所示:
>>> odd = [1,3,5,7,9]
>>> odd[20]
IndexError: list index out of range
IndexError消息基本上解释了我们为什么会遇到这个错误。名为 odd 的列表的索引停在 4。然而,我们传递了 20,这是没有映射到值的位置,或者简单地说,我们在这个位置没有任何元素。因此,在处理列表时,我们必须跟踪插入值的每个位置,以免遇到任何异常。然而,我们有一个解决方案来防止这种情况——只需回想一下异常处理伙伴!这就是你需要调用的,以处理这些异常,使我们的代码能够正常运行而不是崩溃。
既然我们已经学会了使用索引技术访问这些元素,那么让我们深入了解如何遍历整个列表,这是访问整个列表的一部分。首先,你必须意识到的是循环。因为我们正在处理一个包含多个数据项的列表——这意味着多次访问多个数据——我们只需要回想一下我们通常会使用的重复操作的方法。没有比循环更适合这种情况的了。因此,如果你想读取列表的所有元素,for循环是最合适的方法,例如:
>>> for number in [1,2,3,4]:
print(number)
1
2
3
4
我们也可以在for循环中更新和完善我们的列表。以下示例是我们迄今为止学到的最重要的示例之一;确保你从中掌握每一个细微的信息:
>>> even_num, odd_num = [], []
>>> for i in range(0,10):
if i % 2 == 0:
even_num.append(i)
else:
odd_num.append(i)
>>> print(even_num)
[0,2,4,6,8]
>>> print(odd_num)
[1,3,5,7,9]
像往常一样,让我们将前面的代码分成几个部分。首先,我们声明了两个空列表,它们将是我们的偶数和奇数列表的输出列表。然后,我们使用循环来访问列表的元素。语句 range(0, 10)将生成一个包含 0 到 9 的数字的列表。这里,10 是排除位置。因此,我们逐个循环整个元素列表。如果你对递归编程的概念有困难,回想一下遍历字典部分。在每次迭代中取出列表的每个元素后,我们进入循环体并检查将确定元素是偶数还是奇数的条件。如果是偶数,我们将其追加,这意味着我们将该元素插入even_num列表中,在奇数的情况下我们也是这样做的。
哇,你意识到你刚刚做了什么吗?你使用了一个简单但强大的数据结构,并进行了线性搜索。虽然我们还有很多其他主题要讨论,但这是迄今为止我们所做的最好的事情。现在,准备好学习更多关于列表操作和方法的知识。
列表操作和方法
你还记得前一章中 Python 的类型转换方法吗?这绝对是将一种数据类型转换为另一种的最佳方法。我们已经看过字符串、它的切片技术和方法。不过,我们意识到它是不可变的。这种限制是如此强大,以至于我们无法更改该字符串的任何元素。不过,现在我们已经来到了最灵活的数据结构,它被称为list。那么,为什么不将字符串转换为列表,以便我们也可以使其可变呢。让我们使用以下示例来澄清这一点:
>>> name = "python"
>>> type(name)
<class 'str'>
>>> name = list(name) #list() method converts any data type to list
>>> type(name)
<class 'list'>
>>> name[0] = 'c'
>>> name
['c', 'p', 'y', 't', 'h', 'o', 'n']
现在,我们可以随心所欲地操作前面的列表;也许使用内置方法。不过,除了赋值之外,大多数操作与字符串的操作相似。我们在字符串部分学到了很多东西,比如切片、加法和乘法操作,甚至一些字符串方法。字符串和列表的操作相当相似——它们甚至从相同的索引 0 开始。也就是说,Python 为字符串和列表提供的内置方法并不是那么相似,为什么会呢?它们是不同类型的数据或结构。
你可以对列表进行算术运算,比如加法和乘法。不过要记住,加法只能在两个列表之间进行,而乘法必须在列表和任何整数之间进行,如下面的例子所示:
>>> even = [0,2,4,6,8]
>>> odd = [1,3,5,7,9]
>>> number = even + odd
>>> number
[0,1,2,3,4,5,6,7,8,9]
>>> ["john"] * 3
['john','john','john']
在第一个例子中,我们使用加法运算符对列表执行了连接操作。在第二个例子中,我们将列表乘以三,乘法的效果可以在该列表的内容中观察到。在我们的例子中,john被乘以三,创建了三个john值。
Python 提供的内置方法用于操作列表的值。它们通过创建列表的对象来对列表进行操作。让我们不要在这里谈论对象,我们有一个专门的章节来讨论对象。
有许多可用的内置方法可以操作列表结构,但我们将在这里介绍最重要的方法。我发现它们很有用,因为大多数开发人员在进行大型项目时只使用其中的一些。但是,如果你想发现更多,浏览文档页面总是一个好的实践。
我们已经看到如何使用append方法将元素插入列表。这个方法将元素添加到列表的末尾。但是,如果你想要插入多个元素到列表中,我们可以使用extend方法,如下例所示:
>>> list_1 = [1,2,3]
>>> list_1.append(4)
>>> list_1
[1,2,3,4]
>>> list_2 = [5,6,7]
>>> list_1.extend(list_2)
>>> list_1
[1,2,3,4,5,6,7]
在前面的代码中,extend方法将列表作为参数,并附加调用的列表的所有元素。当我们打印list_2时,我们会看到列表保持不变。
与添加元素到列表的方法类似,我们也有一个可以从列表中删除元素的方法。实际上,有两种方法可以用来删除元素。一种是通过将索引作为参数传递,而另一种是直接将要删除的元素作为参数传递。当我们使用pop方法时,我们必须传递要从列表中删除的元素的索引;但是当我们使用remove方法时,我们必须将元素传递给它,以指定需要删除的特定元素。看一下以下代码片段的例子:
>>> fruits = ["Apple","Banana","Orange","Mango"]
>>> fruits.pop(1)
"Banana"
>>> fruits
["Apple","Orange","Mango"]
>>> fruits = ["Apple","Banana","Orange","Mango"]
>>> fruits.remove('Orange')
>>> fruits
["Apple","Banana","Mango"]
还有另一种删除列表中元素的方法,就是使用简单的del关键字。警告:如果你写>>> del fruits,整个列表将被删除。确保你明确指定需要删除的元素。可以以类似于使用方括号访问元素的方式获取特定元素,如下例所示:
>>> fruits = ["Apple","Banana","Orange","Mango"]
>>> del fruits[-1]
>>> fruits
["Apple","Banana","Orange"]
Python 中有许多可用的内置函数,可以对列表执行算术和逻辑操作。使用这些函数不可避免地使代码更清晰、更可读,我们可以在一行内执行许多任务。Python 列表的一些重要内置函数如下:
>>> prime = [2,3,5,7,11,13,17]
>>> sum(prime)
58
>>> min(prime)
2
>>> max(prime)
17
>>> len(prime)
7
在这里,sum函数将给我们一个列表元素之间的加法结果。这个方法只适用于整数和浮点值。接下来,min 和 max 函数分别给出列表的最小值和最大值。另一个重要的函数是len(),它将给出列表的长度。这个len函数适用于任何对象。此外,我们可以将它与字符串一起使用,以找到列表中字符的数量。
有时,你可能只想提取列表的特定部分或切片,例如,只提取包含 1000 个项目的列表中存储的前四个项目。在这种情况下,你必须使用切片技术,这将在下一节中介绍。
切片列表
在学习切片列表的技巧之前,让我们回顾一下如何切片字符串的部分。我们使用方括号运算符指定切片的起始和结束点。在列表的情况下,情况相当类似,如下例所示:
>>> book = "Python Games"
#lets extract Games
>>> book[7:]
'Games'
通过在方括号内添加起始索引和停止索引来对列表进行切片。在前面的例子中,停止索引元素被排除在结果切片之外。让我们做一个简单的例子,可以切片我们列表中的元素的部分:
>>> info = ["I","Love","Python","Java","NodeJS","C"]
>>> info[:3:]
["I","Love","Python"]
info[:3:]语句中给出的第二个冒号是可选的。第一个分号将两个块分开,作为起始和结束位置,但如果您不想添加step,则第二个冒号是不必要的。要了解更多关于[start:stop:step]的信息,请查看第二章 学习 Python 基础中的字符串切片技术部分。以以下代码为例:
>>> info[:3] #same result as previous
["I", "Love", "Python"]
在前面的代码中,>>> info[:3:],我们在方括号内添加了一个冒号(:)分隔符来指定列表的索引。第一个冒号前的空格是切片的起始索引;这里,我们传递了空索引,这意味着它是默认的,它将从列表的开头开始切片。我们在第一个冒号后的下一个占位符中传递了索引三,以指定切片过程的结束索引。这里,索引三处的元素是Java,但它处于排除位置,这意味着它将从列表的开头切片直到索引为二的元素。第二个冒号后的最后一个占位符指定了切片中需要包含的步骤。它的值为空,这意味着它是默认的;因此,我们得到了一个在这些索引之间没有跳过任何元素的结果。它的工作方式与字符串切片技术相同。
现在,让我们通过检查字符串对象的缺陷来了解列表的需求。我们将在下一节中看到,列表被认为比字符串更优越和更普遍。
字符串和列表对象
到目前为止,我们已经涵盖了关于列表的多个主题;我们看到了如何为自己创建一个列表,并且我们看到了如何使用内置方法添加、删除和操作列表的元素。现在,让我们谈谈关于字符串和列表对象的另一个重要概念。每当我们创建任何字符串时,都会创建一个对象并将其存储在特定的内存引用中。对于程序中创建的任何字符串,Python 解析器都会为它们创建一个对象,如下例所示:
>>> name_1 = "Python"
>>> name_2 = "Python"
>>> name_1 is name_2
True
在前面的例子中,name_1和name_2都指向同一个对象。因此,我们可以说它们是等价和相同的。使用相同的Python字符串创建了两个变量。这两个赋值操作并不会创建两个对象;相反,会创建一个单一的对象并映射到全局命名空间中。我们可以看到,这两个具有相同内容的变量创建了一个单一的对象:

但是在列表的情况下,即使内容相同,它们也会创建两个不同的对象,如下例所示:
>>> list_1 = ['a',1,2]
>>> list_2 = ['a',1,2]
>>> list_1 is list_2
False
您可以清楚地看到,在前面的代码中,我们得到了False的结果,这意味着这两个列表是两个不同的对象。它们并不相似,尽管它们的内容是相似的。因此,每当我们创建列表变量时,我们将它们称为列表对象,其内容是该对象的值。
最后,在本节中,我们已经介绍了我们的基本和强大的list数据结构。虽然我们还没有发现list的威力,但我们从第二章 学习 Python 基础开始就一直在使用它。你还记得我们用list来表示井字棋游戏的位置吗?因此,我们可以得出结论,即使我们有更强大和复杂的数据结构,如字典、树和队列,列表仍被认为是数据结构的女王,因为它们在简单的结构中容纳复杂的数据类型时非常有用。现在,让我们学习一下字典,它被认为是数据结构的国王。
字典
对新数据结构的发现是因为先前数据结构的缺陷。让我们回顾一下列表的缺点。我们已经将元素存储在遵循某种顺序的列表结构中,并且我们必须使用索引来检索这些值。但是这些索引是虚构的。每当您想使用列表时,您都必须记住该序列的顺序,否则您将遇到IndexError异常。
现在,让我们了解一下 Python 中可用的更坚固的数据结构。字典,顾名思义,涉及以与牛津词典相似的方式处理数据结构。在我们的现实世界字典中,我们有键和值对。键是指您想在字典中搜索的单词,而值是指该单词的含义。与牛津词典类似,我们的字典数据结构中也有键和值对,并且我们将它们称为元素或项目。在列表的情况下,我们也有键和值对。键是虚构的,即索引,值是该列表的元素,如下例所示:
>>> my_list = ["python","java"]
在这里,python字符串是值,索引零是它的键。在列表的情况下,键只能是整数。在字典的情况下,键可以是任何类型。我们需要在字典结构中明确指定键。在每个键和值对之间,我们需要放一个冒号(:)。让我们创建一个字典来澄清事情:
>>> my_dict = {}
>>> type(my_dict)
<class 'dict'>
我们使用方括号[]来创建列表。但现在,我们将使用花括号{}来创建字典。我们必须使用键:值对向字典中添加项目。让我们创建一个简单的字典,其中包含人名作为键,年龄作为值:
>>> info = {"Monica" : 32, "Joey" : 29, "Ross" : 55 }
>>> info
{'Monica': 32, 'Ross': 55, 'Joey': 29}
您可以将字典想象为一组索引和一组值之间的映射器。在这里,索引可以是任何类型,而不像列表那样只能是整数。在我们的info字典中,我们将键设置为字符串集合,将值设置为整数。
现在,让我们观察一下在前面的代码中打印出的info字典。我们可以清楚地看到输出序列与输入的顺序不同。元素位置已经交换。在这种情况下,如果元素较少,这可能不是问题。但是,如果我们创建一个包含 1,000 个项目的字典,您将清楚地观察到输出字典的顺序与输入的顺序不同。在我们的示例中,我们将Ross键添加到字典的末尾,但在打印相同的字典时,我们得到Ross: 55添加到第二个位置。因此,您可能会想知道,访问该字典的元素会有什么区别吗?一点也没有!字典是无序排列的,不像列表那样。要访问字典的元素,我们必须使用键作为标识符。访问字典的元素与访问列表的元素非常相似,但是我们不是在方括号内放置索引,而是放置键。例如,如果您想获取Monica的年龄,我们使用以下代码:
>>> info["Monica"]
32
>>> info["Joey"]
29
>>> info["Chandler"]
KeyError: 'Chandler'
我们将得到KeyError而不是IndexError,这指定字典中没有名为Chandler的键的元素。因此,访问列表可能会增加负担,因为我们必须跟踪该列表的每个可能的索引。对于长度较小的列表来说,这不是问题,但想象一下包含 10,000 个或更多元素的列表。为了克服这种开销,最好使用字典,因为它们更容易访问,而且遇到异常的几率也较小。话虽如此,字典也不是完美的数据结构,我们将在接下来的部分看到为什么大多数人更喜欢列表而不是字典。
另一种创建字典的方法是使用dict()方法。让我们看看它是如何使用的:
>>> info = dict()
>>> info
{}
我们使用内置的dict()方法创建了一个空字典。现在,让我们看看如何向该字典添加元素:
>>> info["Python"] = 1990
>>> info["C"] = 1973
>>> info["Java"] = 1970
>>> info
['Python': 1990, 'C': 1973, 'Java': 1970]
由于我们已经看到了如何使用两种方法创建自己的字典,让我们看看如何获取该字典的每个元素。由于我们的数据结构可能包含许多值,我们必须使用循环来迭代它。我们将在下一节中看看如何遍历字典。
遍历字典
由于字典包含有限数量的键和值,我们可以使用for循环来迭代它。for循环将遍历字典的键。可以使用方括号[]来提取特定键的值,并将键传递给它。让我们看看这是如何工作的:
>>> info = {'Python': 1990, 'C': 1973, 'Java': 1970}
>>> for key in info:
print(key,info[key])
Python 1990
C 1973
Java 1970
在前面的代码中,info[key]将提取该键的值。for循环将遍历字典的键,并且在每次迭代中,key变量将存储字典的键。然而,如果我们想要在for循环内提取键和值,我们将会得到ValueError。让我们看看我是什么意思:
>>> for key,value in info:
print(key,value)
ValueError: too many values to unpack (expected 2)
我们得到了前面的错误,因为字典不是可迭代的。然而,我们可以将其转换为另一个数据结构,例如元组或列表,以便我们可以直接在for循环的定义中获取键和值。我们将通过将其转换为元组使这个字典可迭代,这将在即将到来的关于元组的部分中介绍。
Python 提供了一堆内置方法,以便根据您的需求操作字典。例如,如果您想要删除一个项目或向字典中插入一个项目,您不必编写自定义逻辑来实现它;相反,Python 有内置函数来实现这一点。我们将在下一节中介绍一些最重要的字典方法。
字典方法
向字典添加元素更容易,我们已经看到了一些例子。现在,让我们看看如何使用pop()方法从字典中删除一个元素。对于作为pop()键的参数,该方法将从字典中删除并返回一个元素。让我们看一个简单的例子:
>>> info = {'Python': 1990, 'C': 1973, 'Java': 1970}
>>> info.pop('C')
1973
>>> info
{'Python':1990, 'Java': 1970}
如果要检索键的特定值,可以使用get方法:
>>> info.get('Python')
1990
我们可以调用values方法进入字典,它将返回一个对象视图,表示字典的所有值。类似于values(),我们可以使用keys()方法打印字典对象,它将表示字典的所有键:
>>> info.values()
dict_values([1990, 1970])
>>> info.keys()
dict_keys(['Python', 'Java'])
我们还可以使用len()方法,它将返回存储在字典中的项目数,如下例所示:
>>> len(info)
2
如果您想打印字典的浅拷贝,可以使用copy()方法,如下例所示:
>>> old = { "Zero" : 0 , "One" : 1}
>>> new = old.copy()
>>> new
{'Zero': 0, 'One': 1}
现在,我们已经看到了一些例子,这些例子让我们知道如何创建自己的字典,并向我们展示了如何使用各种字典方法访问它们。现在,让我们探索元组——另一个不可变的数据结构。
元组
元组在处理方面与列表非常相似,但它们是不可变的,而列表是可变的。我们可以以类似于列表的方式在元组中存储值的序列。就像我们使用[]来创建列表,使用{}来创建字典一样,我们使用()来创建元组。存储在元组中的值可以是任何类型,并且这些值都通过索引进行映射,就像列表一样。元组的第一个元素的索引是零,并且从左到右递增,同时从左到右遍历。元组的一个优点是它们是可迭代的。因此,我们可以将非可迭代的数据结构,例如字典,转换为元组,以便我们可以在循环声明中提取键和值对。
让我们创建一个简单的元组:
>>> numbers = (1,2,3,4,5)
>>> type(numbers)
<class 'tuple'>
我们还可以使用 Python 中的内置方法来创建元组。我们可以使用tuple()方法创建空元组:
>>> numbers = tuple()
>>> numbers
()
>>> numbers = tuple('abcde')
>>> numbers
('a','b','c','d','e')
如果您想创建一个只有一个元素的元组,您必须在添加这个元素后加上逗号,否则 Python 会将其视为内置数据类型,比如整数或字符串,如下面的代码所示:
>>> odd = (1,)
>>> type(odd)
<class 'tuple'>
>>> even = (2)
>>> type(even)
<class 'int'>
创建元组的另一种方法是在每个项目之间添加逗号:
>>> numbers = 1,2,3,4,5,6,7
>>> type(numbers)
<class 'tuple'>
我们对列表执行的大多数操作在元组的情况下也适用。为了访问元组的元素,我们使用方括号运算符并将索引传递给它,如下例所示:
>>> numbers[0]
1
>>> numbers[-1]
7
切片操作也可以像列表一样用于元组。这个操作将导致从元组中提取的一系列值。看下面的例子:
>>> numbers[3:]
(4,5,6,7)
>>> numbers[::2]
(1,3,5,7)
元组不支持项目赋值,这使得它成为一个不可变的数据结构,如下例所示:
>>> names = ("Jack","Cody","Hannah")
>>> names[0] = "Perry"
TypeError: 'tuple' object does not support item assignment
现在您已经了解了字典和元组,让我们看看如何将它们从一个转换为另一个。因为所有可用的数据结构都不是完美的,它们都有一些缺陷;因此,接下来的部分将是迄今为止我们所涵盖的最重要的部分之一。这是我们将在字典和元组之间执行转换的地方。
元组和字典
字典不是完美的可迭代对象,这意味着我们不能使用for循环直接从中提取键和值。我们只能从字典中提取键,但如果要提取键:值对,我们必须将其转换为另一个可迭代的数据结构。让我们看一个例子并观察结果,显示了从字典到列表的转换:
>>> person_address = {"Carl": "London", "Montana": "Edinburgh"}
>>> list(person_address)
["Carl","Montana"]
将字典直接转换为列表不会保留字典的值。它会返回一个只包含字典键的对象。由于缺少值,这些信息是无用的。让我们尝试将其转换为元组并查看结果:
>>> tuple(person_address)
("Carl","Montana")
除了使用tuple()方法将字典转换为元组,还有另一种有效的方法。我们可以使用items()方法执行相同的任务。它用于返回包含键和值存储在嵌套元组中的列表的字典对象,如下例所示:
>>> person_address.items()
dict_items([('Carl', 'London'), ('Montana', 'Edinburgh')])
现在,我们可以使用for循环在这个对象中进行迭代,并在声明的同时获取键和值,如下例所示:
>>> for key,value in person_address.items():
print(key,value)
Carl London
Montana Edinburgh
到目前为止,我们已经涵盖了三种强大的数据结构——列表、字典和元组。接下来是集;一种无序的结构,被认为是可迭代和可变的,但不存储重复元素。
集
通过将这种数据结构与数学中的著名概念集进行简化。在数学中,集被认为是不同实体的集合,通常被认为是对象。数字 1、2 和 3 分别是对象,但当它们组合在一起时,它们形成一个大小为 3 的单一集合。它们在 Python 中也是一样的。Python 中的集是一组既不排序也不索引的对象。
可以使用两种不同的方法创建 Python 集:
- 第一种方法类似于创建字典的方式;我们将传递对象本身,而不是键和值对,如下例所示:
>>> num = {1,2,3,4,5}
>>> type(num)
<class 'set'>
- 另一种方法是使用 Python 的内置方法
set(),您需要以列表形式传递对象序列,如下例所示:
>>> set(['a','b','c'])
{'c','a','b'}
在前面的代码中,我们可以看到花括号中的元素是无序的。我们在创建集合时传递的对象顺序不会被保留。它们也不支持集合中的重复项。如果集合中同一元素多次重复,只会保留一个元素,其他所有元素都将从结构中删除,如下例所示:
>>> {"laptop","mobile","mouse","laptop","mobile"}
{'mouse', 'laptop', 'mobile'}
与列表和元组不同,集合也是非索引的。如果要访问集合的元素,不能使用索引技术,因为这会引发TypeError:
>>> names = {"Ariana","Smith","David"}
>>> names[0]
TypeError: 'set' object is not subscriptable
由于集合是可迭代的,我们只能通过循环来访问它们。适当的循环将是 for 循环,因为在使用它时我们不必担心终止点:
>>> names = {"Ariana","Smith","David"}
>>> for name in names:
print(name)
Ariana
Smith
David
现在我们已经看到了如何创建和访问自己的集合,让我们深入了解可用的集合基本方法,以便我们可以操纵它们的结构。
集合方法
集合是可变的,但一旦它们被创建,就不能更改它们的项;相反,你可以向该集合添加或删除项。它与列表非常相似,但是有序的。现在,让我们从 Python 集合的最常用方法开始这个主题:
- 我们可以向集合中添加单个和多个项,有两种方法可以做到这一点。
add()方法每次只会向集合中插入一个单个项。另一方面,update()方法将同时向集合中添加多个项。元素的添加是无序的,它们可能被插入到任何位置:
>>> favorite = {"Java","C","C#"}
>>> favorite.add("Python")
>>> favorite
{'Java','C#','Python','C'}
>>> #for update method
>>> favorite.update(["Python","JavaScript","R"])
>>> favorite
{'Python','Java','R','C#','C','JavaScript'}
- 有许多方法可以删除集合的元素。可以使用
remove()、discard()和pop()等方法。如果要从集合中删除的项不存在,remove()将抛出一个名为KeyError的异常,但在discard()方法的情况下,我们的代码不会遇到任何错误,如下例所示:
>>> favorite.remove('C')
>>> favorite
{'Python','R',"JavaScript','Java','C#'}
>>> favorite.remove("NodeJS")
KeyError: 'NodeJS'
>>> favorite.discard("NodeJS")
>>> #no error
- 我们还可以使用
pop()方法从集合中删除元素。pop()只会从集合中删除最后一个元素。然而,由于集合是无序的且没有索引,我们不知道哪个元素将成为集合中的最后一个元素。因此,使用pop()会很危险,因为我们无法知道特定项的移除。pop()将返回从集合中移除的项,如下例所示:
>>> favorite.pop()
'R'
- 如果你想从集合中删除每个元素,可以使用两种方法,但这些操作的结果略有不同。可以使用
del关键字加上集合的名称来删除整个集合元素以及集合结构。另一方面,clear()方法用于清空集合,但其结构不会被完全删除:
>>> favorite.clear()
>>> favorite
set()
>>> del favorite
>>> favorite
NameError: name 'favorite' is not defined
- 我们还可以执行并集、交集等操作,就像在数学中一样。并集操作返回一个包含原始集合中所有元素和指定集合中所有元素的集合。集合会删除重复项。如果任何项存在于多个集合中,它将只在结果集中添加一次。你可以用逗号分隔每个集合来执行多个集合之间的并集:
>>> set_1 = {1,2,3}
>>> set_2 = {3,4,5}
>>> set_1.union(set_2)
{1,2,3,4,5}
>>> set_3 = {4,5,6,7}
>>> set_1.union(set_2,set_3)
{1,2,3,4,5,6,7}
- 我们有
intersection()方法,它将导致多个集合之间共同的项目集合,如下例所示:
>>> set_1 = {'a','b','c'}
>>> set_2 = {'b','c','d'}
>>> set_1.intersection(set_2)
{'b','c'}
在前面的部分,我们已经介绍了 Python 的基础知识。到目前为止,我们已经建立了核心编程的坚实基础,但我们还不能构建一个高级游戏。
在接下来的部分,我们将深入探讨最重要的概念,不仅适用于 Python,而且适用于编程一般,那就是函数。在那一部分之后,你将拥有过程式编程的能力,这将在我们从那时起构建的每个高级游戏中非常有帮助。
函数
首先,让我们回顾一下到目前为止我们学到的所有主题,并观察过程式编程函数以及它们为什么首先是需要的。我们学会了如何使用变量、数字、模块、条件和循环创建多行语句。然而,我们并没有止步于此;我们涵盖了 Python 的所有基本数据结构,如列表、字典、元组和集合。这种编程范式将导致代码行数的丰富,有时我们可能需要一遍又一遍地调用相同的代码。看看下面的例子:
>>> 3 + 5
8
>>> 6 + 7
13
在前面的代码中,我们正在添加两个数字。每次进行加法运算时,我们需要写两个数字,然后是加法运算符。与其为许多加法操作做同样的任务,为什么不制作一个可以执行加法的单个语句,并将该语句放入我们可以多次调用它的范围内呢?这个范围代表函数。我们可以通过调用这些函数多次来调用这个语句的执行。让我们制作一个可以添加任意两个数字的函数:
>>> def add(a,b):
print(a + b)
在前面的代码中,我们用add定义了函数。def关键字和一个名称一起用于指定 Python 解析器以创建函数。在函数的范围内,我们可以添加多个语句。现在,我们不需要每次手动添加两个数字,我们可以调用这个add函数来执行任意两个数字之间的加法。因此,这部分代码可用于可以添加任意两个数字的操作。第一个任务是声明函数,这就是我们刚刚做的;下一个任务是调用该函数。在调用该函数之前,不会执行该函数内的任何操作。您必须使用相同的函数名称来调用该函数。现在,如果您想执行add操作,您需要以相同的签名add调用它,并将两个值作为参数传递给它。如果您传递一个数字,它将作为参数传递给该函数调用:
>>> add(4,5)
9
>>> add(10,11)
21
在前面的结果中,括号内的每个数字都被传递给函数参数:a 和 b。在第一个操作中,add(4,5),4 被作为值传递给变量 a,5 被作为值传递给变量 b。
让我们将这些函数与以下咖啡机进行比较。我们将原材料,如咖啡豆、糖和水,投放到咖啡机中,咖啡机将加工这些原材料,并为我们提供一杯咖啡。与咖啡机一样,函数也接受包含值的原始参数作为输入。这些参数将用于处理,在函数内部完成,并给我们有意义的结果。有时,函数不返回任何东西;我们称这些为void:

我们看了几个例子,我们通过名称调用了函数,但它们的声明是由 Python 在内部进行的。例如,以print()方法为例。我们使用这个函数在终端上向用户打印任何消息,但我们没有使用def关键字来定义它;我们只是调用它,因为它是一个内置函数。因此,如果您使用任何函数,如print()、input()或type(),您都是通过在其括号内传递参数来调用该函数。您可以通过参观官方 Python 文档来查看print()或 Python 的任何其他内置方法的实现。在调用input()或print()时,我们将一个字符串作为参数传递给它的括号内。让我们看一个函数调用的例子:
>>> type('a')
<class 'str'>
在前面的代码中,我们使用了type调用函数。参数传递在函数的括号内。我们可以传递尽可能多的参数作为括号内的表达式,但是我们必须确保只传递所需的位置参数。在函数声明中,如果我们使用了两个参数来定义函数,那么在调用时,我们应该传递相同数量的参数。否则,它会抛出一个错误,就像下面的例子所示:
>>> def add(a,b):
print(a+b)
>>> add(3)
TypeError: add() missing 1 required positional argument: 'b'
>>> add(3,4,5)
TypeError: add() takes 2 positional arguments but 3 were given
因此,我们可以得出结论,函数接受一个参数,根据该参数执行一些语句,并返回一个结果。在我们的add(a,b)函数中,我们在函数内部打印了结果,但是我们使用了return关键字来从函数中返回一个result,而不是在函数的范围内打印它:
>>> def add(a,b):
c = a + b
return c
>>> result = add(3,5)
>>> result
8
因此,我们有两种类型的函数。一种在函数的范围内打印结果,而不是从中返回结果,通常为空。虽然 Python 没有空函数的命名约定,其他编程语言称这些为空函数,这意味着它们不返回任何东西。另一种类型将产生函数的返回值。当调用函数时,应该捕获这些返回值,就像在代码中:result = add(3,5)。result的值是函数的返回值。
您可能会遇到一个函数必须返回多个值的情况。我们可以使用元组结构从函数中返回多个值。让我们看一个简单的例子:
>>> def result(a,b):
print("Before Swapping: ")
print(a,b)
print("After Swapping: ")
return b,a
>>> result(4,5)
Before Swapping:
4 5
After Swapping:
(5, 4)
我们将在下一节学习默认参数的概念。学习这个概念将帮助我们构建更灵活的函数,因此这是一个重要的主题。
默认参数
在函数调用期间,我们通常将一个值作为位置参数传递给相应的参数。但是,如果我们犯了一个错误,传递的参数比所需的少一个或多一个,我们的程序将遇到异常。因此,总是将一些参数指定为默认值是一个很好的做法:
>>> def msg(str1,str2):
print("I love {} and hate {}".format(str1,str2))
>>> msg("Python")
TypeError: msg() missing 1 required positional argument: 'str2'
现在,让我们看一下默认参数的威力。在使用它们之前,您应该记住默认参数必须放在参数顺序的末尾。创建默认参数的语法是argument_name = value。在前面的例子中,如果您想将str1作为默认参数,它应该放在str2之后,否则您将从 Python 解释器那里得到一个语法错误,就像下面的例子所示:
>>> def msg(str1 = "Python",str2):
print("I love {} and hate {}".format(str1,str2))
SyntaxError: non-default argument follows default argument
正如错误消息所澄清的那样,我们不能将默认参数指定为左侧的位置参数。它们应该跟随非默认参数,就像下面的例子所示:
>>> def msg(str1,str2 = "Java"):
print("I love {} and hate {}".format(str1,str2))
>>> msg("Python")
I love Python and hate Java
在前面的例子中,看一下我们在其中只使用一个参数调用函数的部分。现在,该参数是一个位置参数。因为它在位置一,所以它将被传递给函数的第一个参数。因此,Python值将被传递给str1参数。在Python值之后,我们什么也没传递。而不是遇到TypeError,我们能够得到一个正确的结果。这就是默认参数的威力。但是,如果在函数调用时向该默认参数传递另一个值,那么默认参数值将被覆盖为新值:
>>> msg("Python","C")
I love Python and hate C
到目前为止,我们能够使用一些位置参数来调用函数,比如 a 和 b。但是如果我们必须创建一个能够添加 200 个数字的函数呢?调用这样的函数add(a,b,c,d,..),其中每个变量代表一个数字,是不可能的。我们也会缺少变量,因为对于 200 个数字,我们必须维护 200 个变量。因此,最有效的方法是将所有这些参数打包到一个变量中,并将其作为单个参数传递给函数。然后,函数将解包该变量并执行相关操作。我们可以使用列表数据结构作为变量来存储这些多个值。我们将在下一节中看一下如何打包和解包普通和关键字参数。
打包和解包参数
让我们举一个简单的例子,这将帮助我们理解为什么我们首先需要这种打包和解包方法。在这个例子中,我们将添加数字:
>>> def add(a,b):
result = a + b
return result
>>> print(add(4,5))
9
我们的代码对于较少的数字可以正常工作,也许最多可以达到 10 个值。随着数字的小幅增加,需要做一些小的修改,但这没关系。但是,如果我们有 100 个数字呢?跟踪这些数字到变量中是不可能的,也不是有效的。我们的代码看起来也不专业。现在,Python 有一个名为打包参数的疯狂功能。在这里,我们谈论的是参数,即普通参数,比如列表和元组。我们可以制作一个包含多个数字的列表。让我们看看如何制作一个可以添加多个数字的函数,使用打包参数的情况:
>>> def add(*args):
result = 0
for item in arg:
result = result + item
print(result)
>>> add(1,2,3,4,9,4,2,5,5,8)
43
让我们观察我们在这里编写的代码。*arg约定用于打包参数。在这里,args指的是参数,这是 Python 中参数的默认命名约定,但只要遵循变量命名模式的规则和约定,你可以给它取任何名字。一个单独的星号(*)是必不可少的,它表示我们正在打包成一个单一的参数。我们正在将每个项目打包到args中;因此,args将被构建为一个列表。我们知道列表是可迭代的,这允许我们使用 for 循环在其中循环。现在,在调用函数时,我们不必担心任何位置参数,甚至包含值的参数。在函数调用期间传递的每个数据片段都将使用这种方法打包到列表中。现在,我们不再受限于使用分配值给指定位置参数的参数。我们可以对每种数据类型,甚至结构执行这些打包参数技术。
解包参数的工作方式与打包类似。我们使用单个星号紧挨着参数,指定我们正在使用解包技术。在这里,参数必须是一个列表、字符串或另一个表示项目集合的结构。看一下以下示例:
>>> print(*"Python")
P y t h o n
由于参数作为字符串(Python)传递,我们解包它,以便每个元素都单独打印出来,中间带有一些空格。你也可以按照以下方式解包列表结构的元素:
>>> numbers = [1,2,3,4]
>>> print(*numbers)
1 2 3 4
因此,我们可以使用单个星号打包和解包普通参数,但是为了打包和解包关键字参数,我们必须使用双星号。用于打包和解包关键字参数的语法是**kwargs。只需记住对于普通参数使用单个星号,对于关键字参数使用双星号。args代表参数,kwargs是关键字参数的命名约定。我们将在下一节中看一些打包和解包关键字参数的示例。
打包和解包关键字参数
关键字参数指的是字典。字典不能像列表或元组那样打包和解包。字典包含键和值对;因此,它们不能以正常的方式打包和解包。为了将它们与正常参数区分开,我们使用双星号。**kwargs用于将字典的所有元素打包成单个参数。然而,我们知道字典不可迭代,换句话说,我们不能在字典内部循环并直接获取键和值对。为了检索键和值对,我们需要使用items()方法将kwargs转换为元组。我们已经在前面的部分看到了它的实现。让我们看一个简单的例子,说明如何实现打包关键字参数:
#code is written as script
pack_keyword_args.py
def about(name,age,like):
info = "I am {}. I am {} years old and I like {}. ".format(name,age,like)
return info
dictionary = {"name": "Ross", "age": 55, "like": "Python"}
print(about(**dictionary))
>>>
I am Ross. I am 55 years old and I like Python
在上面的例子中,我们做了两件事:我们制作了一个字典,将使用**dictionary打包成单个参数,并将每个值传递给函数的位置参数。在字典定义中,字典的键必须与制作函数时使用的参数相同,即name、age和like。即使是单个拼写错误也会导致TypeError。
现在,是时候来介绍解包关键字参数了。语法将是相似的,包含双星号,后面跟着字典名称,或kwargs。由于我们正在解包,所以必须将**kwargs作为函数的参数添加进去,因为解包必须在函数内部完成。让我们看一个简单的例子来澄清这一点:
#unpacking_key_args.py
def about(**kwargs):
for key, value in kwargs.items():
print("{} is {}".format(key,value))
about(Python = "Easy", Java = "Hard")
>>> #output
Python is Easy
Java is Hard
在调用about函数时,我们向参数传递了一个值,就像我们在普通函数的情况下通常传递的那样。例如,Python是参数,它的值是字符串。现在,这个值被传递给about函数的参数。然而,在函数括号内没有名为Python或Java的参数。相反,有**kwargs,它将这些argument_name = value格式转换为字典。这是一种打包参数的形式。现在,在函数内部,我们必须解包它。此时,我们知道kwargs是一个不可迭代的字典。我们不能在不将其转换为元组或列表的情况下获取其key:value对。将字典转换为元组的一种简单方法是使用items()方法。现在,在使用items()方法将字典转换为元组对象后,kwargs看起来是这样的:
>>> kwargs.items()
dict_items([('Python', 'Easy'), ('Java', 'Hard')])
现在,我们正在循环遍历元组对象的这些项,每个对象都包含由逗号分隔的键和值。因此,对于每次迭代,我们都会得到键和值对,并通过适当格式化打印出来。
现在,我们掌握了不仅可以帮助我们创建自己的函数,还可以根据我们的需求修改它们的知识。如果你想使你的程序更具重用性和稳健性,必须使用打包和解包参数等方法。在这个广泛的函数式编程概念之后,现在是时候探索 Python 中的三个重要函数:匿名函数、递归函数和内置函数。让我们逐个来看看它们。我们将从匿名函数开始。
匿名函数
顾名思义,这些函数没有任何名称或签名。就像我们使用add(a,b)函数的名称来执行两个数字之间的加法操作一样,在匿名函数的情况下,这个add签名是无效的。如果您回忆一下我们使用def关键字创建普通函数的方式,在匿名函数的情况下,我们使用lambda关键字。因此,匿名函数也被称为 lambda 函数。在创建任何函数时,我们需要记住两件事:参数和表达式。参数是函数的独立和特定的输入,而表达式嵌入在函数体内。在lambda函数的情况下,我们可以传递任意数量的参数,但只能有一个表达式。这意味着lambda函数只能执行一个操作。
让我们创建一个简单的lambda函数,以便更容易地理解这些信息:
>>> square = lambda x: x**2
>>> square(8)
64
在这个例子中,square是结果的容器。由于lambda函数没有独特的签名或名称,我们应该使用这个容器作为值传递参数,也就是square。在这里,使用lambda函数的语法如下:
lambda arguments: expression
注意arguments和expression的名称;我们不能在lambda函数内添加多个语句。如果我们尝试在lambda函数内执行多个语句,就会遇到以下错误:
>>> result = lambda x, y: x//y, x%y
Traceback (most recent call last):
File "<pyshell#0>", line 1, in <module>
result = lambda x, y: x//y, x%y
NameError: name 'x' is not defined
我们传递了x,y,也就是多个参数,这是完全有效的,但两个表达式x//y和x%y不会被lambda执行。我们将在接下来的章节中使用这些lambda函数来创建游戏。由于本章有许多内容要涵盖,而且空间不够了,我想在这里结束这个话题;但是,我强烈建议您多练习这些类型的函数。您可以随时使用 Python 文档进行帮助。
让我们来看另一种类型的函数:递归——一种涉及使用过程、子程序、函数或算法调用自身的计算机编程技术,在一步中具有终止条件,当满足终止条件时,程序也终止。
递归函数
在本节中,我们将揭示另一种编程范式,称为递归编程。递归是一种编程方式,其中一个函数会多次调用自身,直到满足特定条件为止。在函数体内,我们将调用相同的函数本身,这使它成为递归。这与嵌套条件类似,其中在单个if条件中有另一个if..else的范围。
递归应该有一个基本或终止条件,以指定程序的停止标准。没有基本条件,我们的递归程序将无法正常运行。如果在程序执行时未满足基本条件,递归程序将导致无限循环。让我们来看一个简单的编程示例,观察递归的工作原理:
>>> def factorial(number):
if number == 1:
return 1
else:
return number*factorial(number-1)
>>> factorial(4)
24
让我们来探索前面的例子,以揭示关于递归编程的有趣事实。打印任何数字的阶乘是一个简单的例子,我们可以在学习递归编程时参考。在前面的程序中,我们有一个基本或终止条件:当数字为一时,我们返回一。这不是一个随机的陈述;相反,这是找到阶乘数的数学模式。看看下面的例子:
To find factorial of 5= 5! = 5*4*3*2*1! = 5*4*3*2*1 = 120
对于任何数字,找到阶乘的过程在遇到 1 之后结束。因此,这是一个基本条件,每当我们的程序触发它时,我们可以终止程序。在程序的else部分的范围内,我们再次调用阶乘函数,但使用不同的参数。您可以观察到我们找到 5 的阶乘的例子;每次我们进入下一步时,我们都会将该数字减一,并将其与当前数字相乘,这代表了这个语句:
>>> number*factorial(number-1)。这个条件被称为递归情况,导致了递归。
因此,有两种使用 Python 进行逻辑推理的方法:使用循环和条件语句进行基本逻辑,或者使用递归。有时,使用全新的逻辑来解决问题会很困难,在这种情况下,我们会尝试使用递归。尽管递归代码看起来更简单、更清晰,但与其他代码相比,它是一个昂贵的调用,因为在计算过程中需要大量的时间和空间。现在,让我们谈谈使用内置函数来执行操作的更快、更便宜的方法。我们已经介绍了许多内置函数,比如max()、min()和len()。因此,这一部分将更容易理解。
内置函数
Python 带有多个内置函数,可以直接在我们的程序中使用。我们不需要导入它们,也不需要额外的努力来执行它们。例如,我们有print()。我们之前可能不知不觉地使用了许多内置函数,但它们也是一种函数。唯一的区别是它们是由 Python 创建者制作的。它们快速,更重要的是,使用它们可以使我们的代码更简单、更清晰。只需这样想:使用我们自己的自定义方法来添加两个数字可能需要至少三行代码,但使用内置函数,我们可以在一行代码中使用sum()函数来完成。
您可以通过浏览 Python 官方文档来查看每一个内置函数。其次,我们还可以在 Python shell 中获取包含内置函数列表的信息。您可以输入以下命令>>> dir(__builtins__)来获取包含 68 个内置函数的列表。我们已经看到了其中一些最重要的函数,例如type()方法和类型转换技术。它们都是使用内置函数实现的。
我不会在本节中涵盖每一个内置函数,因为这不是本书的实际目的;相反,我们将直接进入下一个主题,这将是一个有趣的主题,因为我们将使用到目前为止学到的函数和数据结构来修改我们的井字棋游戏。然而,我强烈鼓励您通过自己学习一些内置函数来谨慎前进。它们现在可能还不重要,但在您的职业生涯中某个时候肯定会派上用场。
现在我们已经学习了数据结构和函数,我们将使用它们来修改之前构建的井字棋游戏,为其增加智能。我们将在下一节中介绍这个内容。
为我们的游戏增加智能
在本章中,我们进行了多次修改,例如添加条件语句和循环以增强代码结构和处理。然而,这还不够完美。在本节中,我们将使用本章学到的函数和数据结构来修改我们的井字棋游戏。
由于函数将通过消除代码的重复而使我们的代码长度变短,并且调试它,以后在代码中进行更改也会更容易;你可以简单地重定向到特定的函数,而不是遍历整个程序。因此,这两个特性在将游戏板打印到终端时对我们有帮助。如果你回忆一下我们在上一章中编写的代码,用于打印游戏板布局的代码被重复使用。现在,我们可以创建一个函数,将所有我们需要的代码放在里面,这样我们就可以打印出游戏板的布局,并且可以在代码中的任何时间和任何地方调用它。
我们代码的下一个实现将是为我们的井字游戏添加智能。到目前为止,如果你运行你的井字游戏,你会发现它可以由两个玩家玩。然而,两个玩家都应该使用同一台计算机,并且他们应该通过轮流来玩。现在,我们将添加计算机智能,可以作为一个玩家来玩我们的游戏。我们实际上正在制作一个玩家可以与计算机对战的游戏。
像往常一样,我们将从头脑风暴游戏的基本要素开始,收集关于游戏布局和模型的关键信息。
头脑风暴和信息收集
术语“人工智能”在技术世界中非常臭名昭著,但如果你深入研究,它其实是一堆模块和条件,决定了代理的流程。在这里,代理可以是任何做决定的东西,比如机器、人类和机器人。所有这些代理执行的动作都可以产生最理想的结果。在我们的井字游戏中,代理是一个计算机玩家,它应该采取行动,可以在比赛中击败我们的玩家。我们有一个专门的章节来学习人工智能及其理性代理,这将在我们完成基本游戏编程的学习后进行介绍。然而,在本节中,我们将创建一个简单的人工智能,可以决定最有利的移动,以击败人类玩家,或者大部分时间结束游戏为平局。
我们将采用过程式编程的方法来为系统添加智能。不要被过程式编程这个术语所压倒,它只是一种使用函数来实现目标的方法。你必须记住的一件事是,每个函数都应该只执行一个任务。例如,我们可以制作print_board()函数,每次调用它时,它只会打印游戏的布局。这个print_board()函数不会从用户那里获取输入,也不会让任何玩家成为赢家。因此,函数的存在应该通过执行一个模块化的任务来保留。我们还可以制作is_winner()函数,它将检查是否有任何玩家是赢家。
下图显示了我们的游戏如何制作一个简单的算法。在这里,我们可以看到如何检查井字游戏板上的位置,以便计算机的下一步能够产生最佳结果;接近赢得比赛,或者在最坏的情况下,使比赛成为平局,而不是计算机输掉比赛:

以下图表显示了我们需要完成的程序,以实现算法的第二部分,我们将跟踪人类玩家的每个占据位置,并检查他们是否可以在下一步赢得比赛。如果他们能赢,我们将阻止这些位置。我们还将占据中心和侧面位置,以便没有人类玩家可以轻松赢得比赛:

现在,我们已经形成了基本算法,这样我们就可以开始编写代码,实现我们游戏中的基本智能。我们将在下一节“智能模型的实现”中使用这些知识,以解决智能模型的问题。
智能模型的实现
首先,让我们使用函数来完善我们的代码;让我们创建一个名为printBoard()的函数。这个函数将包含一些代码行,用来打印我们井字游戏的棋盘布局:
#tic_tac_toe_AI.py
def printBoard(board):
print(' | |')
print(' ' + board[7] + ' | ' + board[8] + ' | ' + board[9])
print(' | |')
print('---------------')
print(' | |')
print(' ' + board[4] + ' | ' + board[5] + ' | ' + board[6])
print(' | |')
print('---------------')
print(' | |')
print(' ' + board[1] + ' | ' + board[2] + ' | ' + board[3])
print(' | |')
前面的代码将打印出棋盘的布局;如果你想执行函数内部的语句,你必须调用它。在这里,我们必须使用board参数来调用它,这个参数是包含了棋盘所有位置的列表,也就是十个空位置,[' '] *10。让我们调用这个函数并观察结果:
>>> board = [' ']*10
>>> #calling the function:
>>> printBoard(board)
| |
| |
| |
-----------------
| |
| |
| |
-----------------
| |
| |
| |
现在,是时候制作一个函数,来检查任何玩家是否是赢家了。我们在这里并没有制作全新的逻辑;相反,我们将在函数的范围内放置我们在前面章节中制作的所有语句。现在,每当任何用户在棋盘上做出动作时,我们可以调用这个函数来检查那个特定玩家是否是赢家。因此,函数可以消除代码的重复或重复。让我们使用isWinner()方法来检查是否有任何用户满足成为赢家的条件:
#tic_tac_toe_AI.py
#after printBoard(board) function
def isWinner(board, current_player):
return ((board[7] == current_player and board[8] == current_player and board[9] == current_player) or
(board[4] == current_player and board[5] == current_player and board[6] == current_player) or
(board[1] == current_player and board[2] == current_player and board[3] == current_player) or
(board[7] == current_player and board[4] == current_player and board[1] == current_player) or
(board[8] == current_player and board[5] == current_player and board[2] == current_player) or
(board[9] == current_player and board[6] == current_player and board[3] == current_player) or
(board[7] == current_player and board[5] == current_player and board[3] == current_player) or
(board[9] == current_player and board[5] == current_player and board[1] == current_player))
在前面的代码中,isWinner函数的参数是board,其中包含了棋盘布局的位置和玩家的棋子,可以是X或者O。我们正在重用在上一章中编写的相同代码,只是做了一些小修改。这个方法将返回一个True或者False的布尔类型,并且我们将在玩家在游戏中做出新动作时调用它。我们使用这个方法来检查整行、整列和对角线的棋盘布局,如果有任何用户占据了它,就会返回True或者False。
在井字游戏中,我们以位置的形式移动玩家,并将玩家的字符分配给它,可以是X或者O。我们在前一章中已经看到了它的实现。在这里,我们将制作一个单独的函数,用来给位置分配一个值。在下面的代码中,board代表了包含位置的游戏布局;current_player要么是X,要么是O,而move是用户的输入:
def makeMove(board, current_player, move):
board[move] = current_player
现在,是时候让计算机玩我们的游戏了。让我们回顾一下我们在前一节中制作的算法。我们将进行多次检查:计算机是否能在下一步赢得比赛,以及人类玩家是否能在下一步赢得比赛。如果是这样,我们将阻止获胜的位置。我们不能在真正的棋盘布局游戏中执行这些操作,因为我们不希望我们的棋盘布局被填充。因此,我们将复制一份棋盘布局,以便我们可以在新的克隆棋盘布局中执行这些检查操作。让我们制作一个函数来复制原始的棋盘布局:
def boardCopy(board):
cloneBoard = []
for pos in board:
cloneBoard.append(pos)
return cloneBoard
在我们克隆了原始棋盘之后,我们必须检查计算机是否有空位可以移动。让我们制作一个函数来检查棋盘布局中的可用空位:
def isSpaceAvailable(board, move):
return board[move] == ' '
isSpaceAvailable返回一个布尔类型:要么是True,要么是False。如果在传递的棋盘布局上可以移动,它将返回True。如果位置已经被任何玩家占据,它将返回False。
现在,是时候进入我们话题的主要部分了:让计算机玩我们的游戏。让我们创建一个名为makeComputerMove()的函数,并将board参数和computerPlayer字符传递给它。这里,board代表了包含所有位置的棋盘布局,而computerPlayer是一个字符,可以是X或者O:
#tic_tac_toe_AI.py
def makeComputerMove(board, computerPlayer):
#part 1
for pos in range(1,10):
#pos is for position of board layout
clone = boardCopy(board)
if isSpaceAvailable(clone, pos):
makeMove(clone, computerPlayer, pos)
if isWinner(clone, computerPlayer):
return pos
在前面的代码#part1中,我们检查了计算机是否能在下一步获胜。首先,我们循环遍历了整个棋盘布局的位置,并使用boardCopy函数克隆了棋盘。然后,我们将 1 到 10 的每个位置传递给isWinner函数,检查空间是否可用。我们通过使用isWinner函数检查该移动是否会使计算机玩家成为赢家,并在这种情况下返回特定的移动位置。这部分代码使我们的计算机玩家足够智能,可以根据其有利的预测决定下一步移动。
在为我们的计算机玩家增加智能的过程中,下一步是跟踪人类玩家的移动。这样做可以在棋盘上做出聪明的移动,使玩家不会轻易获胜。此外,如果人类玩家在棋盘的一行上占据了两个位置,我们可以移动来阻止第三个位置。让我们编写makeComputerMove()函数的#part2。为了检查人类玩家是否会获胜,我们必须以虚拟的方式扮演人类玩家来玩游戏。我们可以在不影响原始棋盘的情况下做到这一点,因为我们可以在棋盘的副本中扮演人类。现在,为了检查人类玩家是否会获胜,我们必须获得一个玩家字母,即X或O。我们可以设置条件来检查人类玩家是X还是O。获得该字母后,我们可以在棋盘游戏的副本上以虚拟的方式扮演人类,但请记住我们是为计算机玩家编写代码。
def makeComputerMove(board, computerPlayer):
if computerPlayer == 'X':
humanPlayer = 'O'
else:
humanPlayer = 'X'
#add part1 code here
#now check if human player will win on next move or not in part2:
#part2
for pos in range(1,10):
clone = boardCopy(board)
if isSpaceAvailable(clone, pos):
makeMove(clone, humanPlayer, pos)
if isWinner(clone, humanPlayer):
return pos
我们刚刚编写的代码将为计算机玩家增加一个智能移动。我们让计算机以虚拟的方式扮演井字棋游戏的人类玩家。我们正在检查下一步人类玩家是否会获胜。如果他们会,我们将返回该移动,以便计算机将其字母放在该位置,阻止人类获胜。
在头脑风暴和信息收集过程中,我们制作了一个流程图,以跟踪将智能嵌入我们的计算机玩家的活动。我们执行了其中的两个活动:检查获胜的最佳移动,以及阻止人类玩家的下一个最佳移动。我们还可以通过进行初始移动使计算机玩家变得更加智能,这是人类玩家通常会做的。例如,当我们玩井字棋时,我们会从中心位置开始,因为我们认为这是最好的起始位置。那么,为什么不让计算机也这样做呢?让我们编写一些代码,让计算机检查棋盘上中心位置的可用性,并相应地保留该位置。
def makeComputerMove(board, computerPlayer):
#add part1
#add part2
#Occupy center position if it is available
#part3
if isSpaceAvailable(board, 5):
return 5
我们可以通过检查角落位置的可用性使这个计算机玩家变得更加智能。棋盘上的角落位置是[1,3,7,9]。由于我们的棋盘上有四个角落,我们维护了一个列表来跟踪它们。现在,让我们创建一个新的getRandomMove()函数,它将接受棋盘和移动作为参数。移动参数将以列表的形式提供,例如角落位置。
#tic_tac_toe_AI.py
import random
def getRandomMove(board, moves):
availableMoves = []
for move in moves:
if isSpaceAvailable(board, move):
availableMoves.append(move)
if availableMoves.__len__() != 0:
return random.choice(availableMoves)
else:
return None
在前面的代码中发生了很多事情,所以让我们把事情分解成更简单的部分。首先,这个方法将接受以列表形式提供的移动,即[1,2,3,4,5];我们必须使用这个函数选择其中一个元素。然而,这个列表的元素不仅仅是数字;它们是棋盘布局的移动或位置。因此,我们必须检查每个移动的空间是否可用。如果有可用空间,我们将该移动添加到一个名为availableMoves的新列表中。筛选完成后,我们进行条件检查,以确定是否有任何可用移动。
>>> availableMoves.__len__() != 0表达式与len(availableMoves)相同,它将返回列表的长度。我们称这些实现(__len__())为数据模型,我们将在即将到来的专门章节中进行介绍。如果availableMoves的长度为零,我们将返回None。但如果不为零,我们将执行一个表达式。让我们将这个表达式分解成片段:
-
import random: 如果你回忆一下第二章的主题,学习 Python 基础,我们导入了 math 模块来执行数学计算,比如平方根和阶乘,我们使用import math命令导入 math 模块。现在,我们正在导入一个 random 模块,这意味着我们可以使用该模块中定义的方法。从 random 模块调用方法的语法是random.method_name()。 -
random.choice(): choice 方法将从被调用的元素列表中随机选择一个元素。例如,执行以下命令将从传递给它的值范围中随机选择一个值:
>>> import random
>>> random.choice([1, 2, 4, 5, 6])
5
>>> random.choice([1, 2, 4, 5, 6])
2
- 我们将
availableMoves传递给它,以便choice方法可以随机选择任意一个移动。这对我们的游戏至关重要,因为有时计算机必须随机做出决定。
现在,让我们在makeComputerMove函数中调用getRandomMove函数。如果你浏览一下makeComputerMove函数的代码,我们已经添加了一个语句,将帮助计算机占据中心位置。角落位置呢?它们也是井字棋游戏的重要位置。如果我们占据了棋盘的中心和角落位置,我们的计算机将有很高的获胜几率。因此,我们必须增强我们的代码,使计算机玩家占据角落位置。由于角落位置是[1, 3, 7, 9],我们必须将其作为列表参数传递给我们刚刚创建的getRandomMove函数:
#tic_tac_toe_AI.py
def makeComputerMove(board, computerPlayer):
#add part1
#add part2
#add part3
#code to occupy corner positions
move = getRandomMove(board, [1, 3, 7, 9])
if move is not None:
return move
#moves for remaining places ==> [2, 4, 6, 8]
return getRandomMove(board, [2, 4, 6, 8])
在前面的代码中,我们添加了代码,将在任何角落位置上获取随机移动。我们已经涵盖了中心位置[5]和角落位置[1,3,7,9]的玩家移动;现在,我们还剩下边缘位置[2,4,6,8]。我们调用了getRandomMove函数,它将从传递的列表中选择任意一个随机移动。
在前面的章节中,我们学到了许多东西,比如循环、条件语句等等。在下一节中,我们将编写一些代码来使用它们来控制程序流程。我们将称之为主函数。
使用主函数控制程序流程
我们编写了许多函数,比如makeComputerMove、isWinner等等,但它们还没有被调用。我们知道在调用函数之前,函数不会执行其中的代码。因此,我们将创建一个新的函数,来处理程序的流程。通常我们称之为主函数,但你可以随意命名。我们在之前的章节中编写的代码,比如主游戏循环或切换玩家回合,将嵌入到这个主函数中。唯一需要显式调用的函数就是这个主函数。让我们现在创建一个:
#tic_tac_toe_AI.py
def main():
while True:
board = [' '] * 10
player, computer = 'X', 'O'
turn = "human"
print("The " + turn + " will start the game")
isGameRunning = True
while isGameRunning:
if turn == "human":
printBoard(board)
move = ' '
while move not in '1 2 3 4 5 6 7 8 9'.split() or not
isSpaceAvailable(board, int(move)):
print('What is your next move? (choose between 1-9)')
move = int(input())
makeMove(board, player, move)
if isWinner(board, player):
printBoard(board)
print("You won the game!")
isGameRunning = False
else:
#computer turn
我们以前多次编写了前面的代码,比如在打印棋盘、切换玩家和创建获胜者时。不同之处在于,这里我们使用了函数。我们有一个与一个函数相关的任务,比如isWinner,它检查玩家是否获胜,而不是编写整个代码来检查获胜者,我们只需编写一次并在主函数中使用它。您可以看到我们已经编写了一些代码来从用户那里获取输入作为棋盘游戏的移动值。我们可以制作一个函数来从用户那里获取输入。现在让我们来做,使主函数更清晰、更易读:
def makePlayerMove(board):
move = ' '
while move not in '1 2 3 4 5 6 7 8 9'.split() or not
isSpaceAvailable(board, int(move)):
print('What is your next move? (choose between 1-9)')
move = int(input().strip())
return move
现在,让我们将这个新创建的函数添加到主函数中。我们还将完成代码的else部分,让计算机玩我们的游戏:
def main():
while True:
board = [' '] * 10
player, computer = 'X', 'O'
turn = 'human'
print("The " + turn + " will start the game")
isGameRunning = True
while isGameRunning:
if turn == 'human':
printBoard(board)
move = makePlayerMove(board)
makeMove(board, player, move)
if isWinner(board, player):
printBoard(board)
print("You won the game!")
isGameRunning = False
else:
printBoard(board)
turn = 'computer'
else:
move = makeComputerMove(board, computer)
makeMove(board, computer, move)
if isWinner(board, computer):
printBoard(board)
print('You loose!')
isGameRunning = False
else:
turn = 'human'
main() #calling main function
现在,让我们运行游戏,并与我们定制的 AI 代理对战。以下插图显示了我们游戏的输出,并显示了新的井字棋棋盘布局。这是通过对printBoard进行函数调用实现的:

以下插图描述了人类玩家与计算机 AI 对战的游戏过程。您可以看到人类被计算机玩家击败了:

现在,我们已经制作了一个足够吸引任何玩家玩游戏的布局。但是,还有一些修改可以进行,这将在下一节中介绍。
游戏测试和可能的修改
我们在本章中制作的游戏已经足够可以与计算机对战。在游戏中使用 AI 主要是解决游戏在与环境交互时可能面临的所有可能情况。在我们的井字棋游戏中,与国际象棋或围棋相比,我们的走法并不多,因此制作 AI 代理更容易。通过制作一个能够做出两个聪明的举动的 AI,我们能够与人类竞争,比如检查下一个最佳走法以获胜或通过模拟阻止人类获胜。如果您想知道模拟是什么,您将不得不回想一下我们刚刚实现的算法,以检查人类玩家是否会在下一步获胜。此外,计算机玩家在克隆棋盘上扮演人类玩家,并像人类一样进行虚拟游戏。这就是模拟,我们让计算机模仿系统的真实过程或行为。
通过模拟预测最佳走法后,我们的程序会为计算机玩家返回最佳的下一个可能走法。让我们进一步推广这种技术。我们在游戏中所做的是制作一个能够制作模拟环境以预测下一个最佳走法的 AI。相同的技术应用于整个范围的 AI 应用,例如自动驾驶汽车;我们在计算机内部制作了一个模拟环境,汽车是一个代理,将根据障碍物做出左转或右转的决定。井字棋在与环境交互时比较简单,因为它的走法或情况较少,但是编写自动驾驶汽车模拟需要我们认识到在道路上驾驶汽车时可能出现的一整套情况。因此,我们可以得出结论,AI 主要是关于创建一个程序,其中代理必须考虑与环境交互时可能面临的所有情况,并对每种情况做出响应。
我们的竞争对手足够聪明,使游戏对玩家来说更加困难,但人类也拥有让电脑玩家受限的终极力量。人类玩家不会让电脑轻易获胜。因此,大部分时间我们的游戏会以平局结束。然而,如果你运行游戏,你会发现我们还没有处理这些情况。现在,每当我们的游戏是平局时,而不是停止游戏,我们的游戏将不断要求用户输入。相反,我们必须给用户一个消息,告诉他们再试一次,并帮助用户再次玩我们的游戏。为了检查平局条件,我们必须检查棋盘是否已满。当棋盘位置全部被占满且没有人获胜时,我们就有了平局条件。我们可以制作另一个函数来检查棋盘是否已满:
def isBoardOccupied(board):
for pos in range(1,10):
if isSpaceAvailable(board,pos):
return False
return True
上面的isBoardOccupied()函数将根据检查返回一个布尔类型,要么是True,要么是False,这将确定棋盘是否已满。如果棋盘已满,它将返回True,如果没有,它将返回False。我们正在使用我们在前一节中创建的isSpaceAvailable()函数,它将检查棋盘上是否有空位。现在,让我们用这个新函数来完善我们的代码:
def main():
while True:
# add the code here from part1
while isGameRunning:
if turn == 'human':
move = makePlayerMove(board)
makeMove(board, player, move)
if isWinner(board, player):
printBoard(board)
print("You won the game!")
isGameRunning = False
else:
if isBoardOccupied(board):
print("Game is a tie")
break
else:
turn = 'computer'
else:
move = makeComputerMove(board, computer)
makeMove(board, computer, move)
if isWinner(board, computer):
printBoard(board)
print('You loose!')
isGameRunning = False
else:
if isBoardOccupied(board):
print("Game is tie")
break
else:
turn = 'human'
main() #calling main function
总结
本章内容简洁而扼要,包含了丰富的信息,从数据结构到函数。这些主题是任何复杂程序的基石,因此,我们将在接下来的每个游戏中使用它们。我们从学习数据结构的必要性开始,深入探讨了 Python 的基本数据结构,如列表、字典、元组和集合。我们讲解了如何创建这些数据结构并对其进行操作。
我们学习了创建用户定义函数、调用它们和记录它们的方法。我们还看到函数就像机器,你可以输入原始数据,然后得到有意义的输出。我们看到了使用位置参数和默认参数输入数据到函数的方法。然后,我们看到了通过打包和解包普通和关键字参数来修改我们的函数,以便从中获得最佳性能。
我们还使用函数和数据结构进一步修改了我们的游戏,并制定了可以应对不同游戏情况的简单算法。我们让我们的电脑玩家足够聪明,可以击败我们的人类玩家。然后,我们还制作了一个模拟环境,其中一个代理可以测试和训练自己,以预测下一个最佳移动。虽然我们的游戏制作起来很简单,但它给了我们许多关于需要进行的流程的想法,例如头脑风暴、设计、编码基础和分析,然后我们才真正开始编写模块化代码。
最后,我们讲解了过程式编程,指的是使用函数来构建程序。在下一章中,我们将讲解基于 curses 的过程式编程。我们将使用与终端无关的屏幕绘图和基于文本的终端来创建程序。我们将使用 curses 事件和屏幕绘图来制作一个贪吃蛇游戏,然后利用 curses 属性来制定玩贪吃蛇游戏的逻辑。
你是否对进入下一章感到兴奋?它将带你进行一次冒险之旅,学习使用 curses 模块进行游戏编程,以及如何处理用户事件和游戏控制台。在那之前,我强烈建议你参考官方 Python 文档,并浏览 Python 内置的数据结构和模块;并且在没有额外帮助的情况下进行练习。到目前为止我们所学到的知识将在本书的各章中使用,所以现在是时候复习我们迄今为止学到的主题了。
第五章:通过构建贪吃蛇游戏来了解 curses
每当开发人员编写游戏或应用程序时,他们可能需要重复使用代码的某些部分。例如,当我们希望玩家在游戏控制台内移动时,他们多次使用左右箭头键。因此,我们需要能够处理此类事件并对其进行处理的代码。多次编写相同的代码来处理相同的操作不支持不要重复自己(DRY)原则,因此我们需要使用可以多次调用以多次执行相同操作的函数。
为了方便起见,这些函数被捆绑到称为模块的容器中。正如您可能还记得上一章所述,我们在大多数程序中使用了模块。例如,通过使用random模块函数,我们能够在特定范围内获得随机数;另一方面,数学模块使我们能够执行不同的数学计算。在本章中,我们将介绍另一个模块,称为 curses。它将为我们提供一个接口,我们可以在其中处理 curses 库,该库包含直接与 Python 终端交互的函数。这意味着我们可以制作一个简单的基于终端的游戏。
本章将涵盖以下主题:
-
了解 curses
-
启动 curses 应用程序
-
使用 curses 进行用户输入
-
使用 curses 制作贪吃蛇游戏
-
游戏测试
技术要求
您将需要以下内容才能充分利用本章:
-
Python IDLE(集成开发工具包)
-
本书的代码资产,可以在本书的 GitHub 存储库中找到:
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter05
查看以下视频以查看代码的实际操作:
了解 curses
Curses 是一个终端控制器库,允许我们编写基于文本的应用程序。术语“终端”与任何平台无关,因此 curses 可以在任何操作系统上使用。使用 curses,开发人员将能够直接编写应用程序,而无需与终端交互。curses 库是以控制字符的形式发送命令的媒介,同时确定应该在哪个操作系统或终端上执行。
在 Python 中,我们有两个名为 windows-curses 和 unicurses 的库。这两个库都提供了可以为输出终端屏幕设置所需外观的函数。它们使用控制序列进行更新。简而言之,开发人员将设计输出窗口屏幕的外观,并调用函数使 curses 发挥作用。因此,在基于 curses 的应用程序中,我们不会得到像我们期望的那样用户友好的输出,因为我们只能使用 curses 库编写基于文本的应用程序。因此,您使用 curses 编写的任何游戏都将在终端中运行,即 Windows 的命令提示符或 Linux 的终端。
Python 的curses库将允许我们编写基于文本的用户界面,并通过用户输入控制屏幕。在本章中使用的库将帮助我们控制屏幕移动并处理用户事件或输入。从 curses 构建的程序将不具有类似于现代 GUI 应用程序或 Android 应用程序的功能,这些应用程序具有诸如文本视图、标签、滑块、图表和模板等小部件。相反,它将提供简单的小部件和工具,如命令行界面(CLI),其中大部分在仅文本应用程序中找到。
Python 的 curses 模块是 C 编程语言的 curses 的一个适应。唯一的区别是使用 Python;一切都可以在不需要我们深入了解低级例程的情况下完成。我们可以调用接口来调用函数,这些函数将依次调用 curses 来处理用户操作。
在处理 curses 时,窗口屏幕被视为字符矩阵。每个窗口界面由程序员设置,并包括高度、宽度和边框。设置这样的坐标后,程序员将调用 Python curses 来更新该屏幕。使用诸如文本视图、按钮和标签之类的小部件也是以相同的方式进行的;也就是说,我们将初始化应在窗口中放置的坐标,并调用 curses 相应地更新它。要处理来自 curses 的用户输入,我们必须导入它。我们可以轻松地导入诸如 RIGHT、LEFT、UP 和 DOWN 等操作,并根据程序的需要处理它们的行为。在大多数游戏中,这些事件将为游戏角色提供移动。我们将在本章末尾介绍的游戏是贪吃蛇游戏,而蛇本身将是我们的主要角色。这意味着 LEFT、RIGHT、UP 和 DOWN 等操作将使蛇移动到新位置。Python 的 Windows 版本没有内置的 curses 模块,也不以相同的名称提供。但是,有两个兼容的模块可用,它们执行相同的功能。这些称为 unicurses 和 windows-curses。我们将在本章中使用后者。
让我们通过制作一个简单的应用程序来开始学习 curses。我们将制作一个简单的hello程序,该程序将在 curses 终端中打印。
启动 curses 应用程序
我们将使用 Python 中未预打包的模块构建应用程序。因此,我们必须在我们的机器上手动安装该软件包。安装 Python 时,应该已经自动在您的机器上安装了一个称为 pip 的软件包管理系统。此管理工具用于安装和组织用 Python 编写的库。因此,如果您想在程序中使用任何第三方库或依赖项,您必须使用 pip 工具进行安装。安装任何软件包的方法很简单。您只需编写pip install命令,后跟您希望安装的库的名称。库的名称区分大小写,因此在编写库的名称时不应出现错误。如果您想检查库中编写的代码,只需搜索该库的文档。您将获得有关该库的信息,以及可在程序中使用的函数。
我们将使用 windows-curses 库来编写基于文本的程序,因此我们必须使用pip命令安装该软件包。如果您的机器是 Windows,则应在命令提示符中执行 pip 命令;如果您使用的是 Mac OS X 或 Linux,则应在您的机器的终端中执行 pip 命令。以下屏幕截图显示了我们需要执行pip命令的方式:

类似地,在 Linux 机器上安装 curses,您可以打开终端并运行以下命令:
$ sudo apt-get install libncurses5-dev libncursesw5-dev
现在,我们将能够使用 curses 模块编写程序。此时,我们安装的 curses 模块将以与其他内置模块(如 math 或 random)相同的方式可用。与内置模块类似,我们可以简单地导入 curses 模块并开始调用其中定义的函数。以下步骤解释了创建任何 curses 应用程序的路线图:
- 让我们首先导入 curses 并查看它是否已正确安装。我们用于导入任何模块的命令是
import后跟模块的名称。我们的模块名称是 curses。因此,命令将如下所示:
>>> import curses
>>> #no any error
- 我们可以得出结论,它已成功导入,因为 Python 解析器没有抛出错误。现在,我们可以使用这个模块来编写程序。让我们编写一个简单的程序来观察 curses 模块的工作过程:
#program is written as Scripts
# curser_starter.py
import curses
import time
window_screen = curses.initscr()
window_screen.clear()
time.sleep(10)
我们不能直接从 Python IDLE 运行任何 curses 应用程序。要运行它,你必须导航到存储 Python 文件的文件夹,并双击该文件以打开它。你会得到一个空白屏幕,顶部有一个光标,它将在那里停留 10 秒。10 秒后,同样的空白窗口屏幕将从屏幕上弹出。该屏幕将产生可以用 curses 编写的基于文本的应用程序。
让我们看看我们之前的代码,并揭示 curses 的有趣函数:
-
首先,像往常一样,我们导入了我们想要在程序中使用的模块。我们在这里导入了两个模块:curses 和 time。curses 模块为我们提供了不同的可用函数,可以用来编写基于文本的应用程序,而 time 模块为我们提供了不同的可用函数,可以用来更新我们的输出屏幕行为。在这个程序中,我们调用了
time模块的sleep方法,它将保持屏幕的输出,持续的时间是括号内传递的时间(在我们的例子中是 10 秒)。10 秒后,我们的输出屏幕将消失。 -
在使用 curses 编写任何代码之前,它应该被初始化。调用
initscr()函数将初始化 curses。因此,对于任何 curses 应用程序,我们应该在代码的第一行初始化 curses。这个初始化代码将返回一个窗口对象,代表我们程序的输出屏幕。在这里,这个初始化被窗口对象window_screen捕获,它代表了我们输出终端的屏幕。因此,对 curses API 的任何函数调用都应该使用window_screen。第一个调用是使用clear()函数。
我们成功创建了一个游戏屏幕,并用一个方法调用来保持它。然而,当前屏幕的可修改性还不够。作为程序员,我们可能希望通过明确指定高度和宽度来定制屏幕。幸运的是,Python 的 curses 模块提供了另一种方法来实现这一点,即newwin方法。我们将在下一节中学习它。
新屏幕和窗口对象
从initscr()函数的调用返回的窗口对象代表了输出窗口的整个屏幕。这个窗口对象还支持不同的方法,可以向窗口显示文本,修改它,接受用户的事件和更新位置等。这个initscr()函数的唯一缺点是我们不能将屏幕的自定义高度或宽度传递给它。它只代表了输出终端的默认整个屏幕。
有时,我们可能希望游戏屏幕被定制,使其高度为 20,宽度为 60,例如。在这里,高度和宽度可以被视为列和行,其中每个单位代表矩阵中的一行。由于我们传递了宽度为 60,将会有 60 条水平线。高度为 20 也是一样的;将会有 20 条垂直线。你也可以将它们表示为像素。要创建一个新的屏幕,这可能是我们在制作 curses 应用程序时要做的事情,因为initscr()函数不会为我们做这个,我们必须调用新函数。这个函数将根据指定的坐标将更大的窗口屏幕分成一个新的窗口。这个函数的名称是newwin(),字面意思是新窗口,它接受四个参数,即高度,宽度,Y和X。它们的传递顺序是Y,X,这与其他库相比是不寻常的。Y值是列的位置,而X值是行的位置。看一下下面的图表,它解释了Y和X的值:

因此,通过增加Y的值,我们向下移动,这与矩阵中的列相同。同样,通过增加X的值,我们向屏幕的右侧移动,这与矩阵中的行相同。正如我们所看到的,curses 以字符矩阵的形式存储窗口屏幕。我们可以使用这些坐标来表示游戏显示的位置,以及游戏角色。例如,如果您想让您的玩家在(5,0)的位置移动,就像前面的图表中所示的那样,您将调用move(5,0)函数来实现。记住参数传递的顺序。Y的值后面是X,如果您在其他库中有游戏编程的背景,这可能会让您感到困惑。
例如,我们将创建一个程序,在其中使用newwin()函数在大屏幕内创建一个新屏幕。此函数内的四个参数分别是height、width、y和x。记住这个顺序,因为我们必须以类似的方式传递它:
height = 20
width = 60
y = 0
x= 0
screen = curses.newwin(height, width, y, x)
现在,是时候编写一个简单的程序,可以向我们的 curses 应用程序添加一些文本了:
# text_app.py
import curses
import time
screen = curses.initscr()
curses.noecho()
curses.cbreak()
screen.keypad(True)
screen.addstr(0,0, "Hello")
screen.refresh()
time.sleep(10)
curses.endwin()
让我们逐行观察前面的代码,并了解我们使用的每个方法,如下所示:
-
首先,我们导入了两个重要的模块:curses 和 time。之后,我们使用
initscr()函数初始化了窗口对象。noecho()函数将关闭应用程序中的自动回显过程。这是必要的,因为当用户玩游戏时,我们不希望他们向我们展示他们按下了什么键;相反,我们希望他们根据该事件执行操作。下一个函数调用是cbreak()。这种模式将帮助我们的程序立即对用户的输入做出反应。例如,在 Python 的input()方法中,除非我们在键盘上按下Enter,否则此方法不会执行任何操作。然而,在cbreak()函数的情况下,它将帮助应用程序立即对任何输入键做出反应,而无需按Enter。这很重要,因为我们必须制作一个用户可以立即得到响应的游戏。例如,如果用户按下 DOWN 键,游戏角色必须立即向下移动。这与缓冲输入函数不同,后者将接收所有输入并将其存储在一个缓冲区中,只有在用户按下Enter时才会做出反应。 -
下一个函数调用是
keypad()函数。我们通过传递 True 作为参数启用了键盘模式。每当我们在终端中按下任何键时,它都以多字节转义序列的形式返回数据。例如,Esc发送\x1b。这是 1 个字节。Page Up发送\x1bH。这是 3 个字节。为了处理终端返回的这种数据,curses 使用了一个可以手动导入的特殊值。例如,要处理键盘上按下的 DOWN 键,我们可以将其导入为curses.KEY_DOWN。这是通过启用键盘模式来实现的。 -
之后,我们调用了
addstr()函数。这个函数将在其调用期间指定的位置将一个字符串添加到输出屏幕上。我们向它传递了三个参数。记住,前两个参数的顺序是 y,x。传递的最后一个参数是需要添加到(y,x)位置的字符串。我们传递了一个值为(0,0),这意味着字符串将被添加到输出窗口的左上角。我们调用的下一个方法是refresh(),它将更新窗口对象screen的字符矩阵。如果你仔细看代码,你会发现每当我们添加或刷新屏幕的内容时,我们都是使用一个窗口 curses 对象来做的,这个对象是使用initscr()函数初始化的。然而,终端的行为已经被 curses 模块改变了。例如,为了改变终端的默认回显行为,我们直接从 curses 模块而不是从窗口光标对象中直接调用了noecho()函数。
现在,让我们运行我们的代码来观察结果。确保你从终端或命令提示符中运行你的应用程序,使用filename.py:
你可以将位置从(0,0)改变为任何其他值,比如(5,5),来观察窗口和填充格式。最后,我们用 curses 制作了我们的第一个程序。
现在,是时候探索 curses 的另一个特性了,这个特性是基于处理用户输入的能力。# 使用 curses 处理用户输入在任何游戏中,用户输入是需要正确处理的最关键的信息之一。在处理这些类型的操作时,我们不能有任何延迟。
在 curses 的情况下,我们有两种方法可以从用户那里获取输入。这两种方法如下:
getch(): 如果你有 C 或 C++等语言的编程背景,这对你来说应该不是什么新鲜事。getch()函数就像在 C 中一样,用于创建一个持续监听用户按键的监听器。它返回一个从 0 到 255 的整数,代表被按下的键的 ASCII 码。例如,a的 ASCII 码是097。大于 255 的值是特殊键,例如Page Up和导航键,即 UP、DOWN、LEFT 和 RIGHT。我们可以将这些键的值与 curses 中存储的常量进行比较;例如,curses.UP、curses.DOWN、curses.LEFT和curses.RIGHT。getkey():getch和getkey做的是同样的事情,但是getkey函数会将返回的整数转换为字符串。诸如 a-z 或 A-Z 之类的普通键将作为一个包含一个字符的字符串返回,可以与ord()函数进行比较。
然而,特殊键或功能键将作为一个更长的字符串返回,其中包含一个键并表示动作的类型,比如KEY_UP。让我们写一个可以处理键盘事件的程序:
#program3.py
import curses as c
screen = c.initscr()
win = c.newwin(20, 60, 0, 0)
c.noecho()
c.cbreak()
screen.keypad(True)
while True:
char = screen.getch()
#takes input
if char == ord('q'):
break
if char == ord('p'):
win.addstr(5,10, "Hello World")
win.refresh()
screen.endwin()
当我们讨论使用 True 循环时,我们讨论了这段代码。如果你对任何这些命令感到困惑,请确保复习前面的主题。
你可能会观察到这段代码中的一个奇怪的地方是,我们导入了 curses 并给它起了一个别名 c。这是重命名模块的过程。现在,我们可以在每个方法调用时使用c.method_name(),而不是在每次调用时使用curses.method_name,这当然消除了每次写相同模块名的开销。
在循环内,我们使用getch()函数从用户那里获取输入。之后,字符被存储在char变量中,我们将其与ord函数的返回值进行比较。记住,getch函数将返回一个 Unicode 值?ord函数也是如此。它接受一个字符作为参数,并返回该字符的 Unicode 值。我们使用条件语句来进行条件判断。因此,如果用户在键盘上按下q,我们将结束程序,如果用户在键盘上按下p,我们将在输出窗口的位置(y,x)打印Hello World。
让我们运行我们的 Python 文件,C:\User\Desktop> python program3.py,并查看输出:
按下键盘上的q键来终止循环并关闭应用程序。
请注意,q和Q不同,因为这些字符的 ASCII 码不同。
我们的代码运行得很好,但变得越来越长,尽管应用程序很简单。我们已经调用了很多方法,比如noecho(),cbreak(),keypad()和endwin()。为了消除调用这么多函数的开销,我们可以使用 curses 模块中的包装函数。所有这些函数,包括 curses 对象的初始化,都是由包装函数自动完成的。只需记住,包装函数是一个包含所有这些方法的捆绑调用。
同样,我们也可以使用 curses 模块来处理鼠标事件。让我们使用包装函数编写一个程序,并在同一个程序中处理鼠标按钮的事件:
#mouse_events.py
import curses as c
def main(screen):
c.curs_set(0) #hides the cursor
c.mousemask(1)
inp = screen.getch()
if inp == c.KEY_MOUSE:
screen.addstr(17,40, "Mouse is clicked")
screen.refresh()
screen.getch()
c.wrapper(main)
让我们详细看一下前面的代码:
-
我们将从最后一行开始,我们在那里使用了可调用对象调用了包装函数。我们已经了解了
wrapper()的目的;它消除了多个函数调用,比如initscr(),noecho()等等。因此,使用包装函数进行调试更容易。不仅如此,这个函数还通过 try 和 catch 块在内部处理异常。每当你遇到一个未知的异常,你可能没有捕获到,你总是可以信任包装函数来处理。这将识别程序的错误并提供异常消息,而不会使应用程序崩溃。包装函数的参数将是一个可调用对象,这里是主函数。这个主函数有一个screen参数,它是 curses 窗口对象。我们没有在程序的任何地方使用initscr()函数初始化 curses 对象,因为这是由包装函数在内部完成的。 -
在主函数的范围内,我们调用了两个方法:
curs_set(0),它将隐藏输出屏幕上的光标,以及mousemask(1),它将接受鼠标事件。在这里,鼠标事件将是特殊符号或功能字符,与正常的字母字符不同。因此,curses 已经定义了常量来表示这些功能字符。这与 UP 键盘键相同;我们有KEY_UP常量;在鼠标事件的情况下,我们有KEY_MOUSE常量。这些应该从 curses 模块中调用,例如curses.KEY_MOUSE。在我们获得这样的鼠标事件之后,我们将在输出终端上打印鼠标被点击。getch()方法将输入任何可能是与鼠标相关或键盘按钮的事件。让我们运行程序以获得以下输出:

现在我们已经获得了足够的知识来使用 curses 制作游戏,让我们继续下一部分,这将让我们了解游戏逻辑是如何在底层实现的。我们将制作一个简单的贪吃蛇游戏。
使用 curses 制作贪吃蛇游戏
我们已经知道编写游戏的过程并不像看起来那么容易。我们必须遵循许多程序来使游戏可玩,因为在将游戏暴露给环境时,我们可能会被许多不需要的和意外的异常所淹没。因此,遵循正确的执行顺序总是至关重要的,即使可能比平常花费更多的时间。在本节中,我们将使用 curses 创建一个贪吃蛇游戏。在即将到来的章节中,我们将对其进行修改,使其成为一个更具吸引力的游戏。一个好的游戏并不总是意味着一个良好的用户界面,因为界面为用户提供价值,而不是程序员。我们必须养成编写良好代码并制作良好界面的习惯,这要求我们遵循本节中将要介绍的每一步。我们将使用 curses 模块来制作最初的贪吃蛇游戏。然后,在下一章中,我们将使用面向对象编程对其进行修改。
在编码之前,我们必须收集有关游戏模型和界面的信息。在建模过程中,我们必须提取关键信息,比如如何将游戏字符渲染到屏幕上,如何制作事件监听器,以及如何制作允许游戏角色移动的逻辑。我们将在下一部分中涵盖所有这些内容。
头脑风暴和信息收集
就像我们一直在做的那样,第一步是头脑风暴和收集关于游戏布局和游戏模型的关键信息。在贪吃蛇游戏中,我们有两个角色:蛇(玩家)和它的食物。每当蛇吃食物时,它的长度应该增加。这是基本的想法。现在,让我们复习一下我们可以使用的资源。显然,Python 提供的资源更加丰富,但我们还没有学会如何制作图形字符并在游戏中使用它们。我们只学会了如何在基于文本的终端中制作游戏。我们可以使用 A-Z 等字符来指定游戏对象。例如,我们可以制作蛇XXXXXXX,这是 X 的组合。食物可以用O表示。让我们看看这在我们的游戏控制台中会是什么样子:

我们还必须决定游戏的屏幕。initscr()方法将创建整个屏幕作为 curses 对象。我们不希望这样;相反,我们希望制作一个可以通过高度、宽度和 y、x 位置自定义的游戏屏幕。正如您可能记得的那样,我们可以使用newwin()方法将屏幕分成一个新的屏幕。
最重要的是要记住跟踪坐标,因为我们必须为我们的游戏玩法制作一个边界。我们可以制定一些规则,指定游戏角色的边界位置,如果它们触及该边界,我们可以终止我们的游戏。
我们必须为两件事制定逻辑:
-
每当蛇吃食物时,我们必须在新位置生成新的食物。
-
每当蛇吃食物时,我们必须增加蛇的速度,使游戏变得更加困难。我们还应该跟踪蛇头和蛇身之间的碰撞。
在前一点方面,我们可以使用 random 模块,它提供了一个(y, x)的随机坐标位置,我们可以将食物分配给它。对于后一点,我们必须使用一个名为 timeout 的 curses 方法。我们必须将延迟的值作为参数传递给该函数。根据 Python 的官方文档,timeout 函数为窗口设置阻塞或非阻塞读取行为。如果延迟为负数,则使用阻塞读取(将无限期地等待输入)。如果延迟为零,则使用非阻塞读取,如果没有输入等待,getch()将返回-1。如果延迟为正数,则getch()将阻塞延迟毫秒,并且如果在该时间结束时仍然没有输入,则返回-1。因此,我们可以根据延迟改变游戏的速度,当延迟为零或正数时。
因此,在curses.timeout(delay)命令方面,如果您使延迟为负数,您的蛇将以快速的速度移动。然而,我们需要记住这里有一些约束;蛇的速度应该随着蛇的长度增加。首先,蛇是什么?在我们的游戏中是如何制作的?我们在上一章学习了列表。让我们用它来制作一个蛇。我们已经看到了我们的蛇的结构,它是一堆 X 字符。但在游戏开始时,我们应该为蛇提供一个较小的长度,也许是 3 的长度,即XXX。我们将把这些 X 中的每一个存储在列表中,它表示坐标,比如[[4,10],[4,9],[4,8]]。在这里,这些列表中的每一个代表一个 X,也就是说,在[4,10]的位置,我们将有一个 X,而在 4,9 的位置还有一个 X。请记住,这些应该是 y,x 位置,并且它们应该相邻,因为它们代表蛇的身体。
假设我们的延迟是 100,这将是恒定的。因此,我们表示速度的命令将是curses.timeout(100),这将是蛇在整个游戏中的恒定速度。然而,我们可以通过增加蛇的长度来改变游戏的速度。现在,让我们继续进行下一节,我们将为我们的游戏制作一个边界。
初始。
在本节中,我们将开始编写游戏的代码。我们将使用 curses 模块来实现这一点。首先,我们将初始化游戏屏幕并制作一些游戏角色。看一下以下代码:
#snake_game.py
import curses as c
c.initscr()
win = c.newwin(20,60,0,0)
win.keypad(1)
c.noecho()
c.curs_set(0)
win.border(0)
win.nodelay(1)
snake = [[4,10], [4,9], [4,8]]
food = [10,20]
win.addch(food[0],food[1], 'O')
在前面的代码中没有什么新的。您还可以使用wrapper()函数消除所有的函数调用。我们可以看到我们有两个列表变量,snake和food,它们包含代表它们在游戏控制台中位置的坐标。我们还调用了addch函数。它将以类似的方式工作addstr函数。我们传递了食物的位置并在该位置添加了O字符。
制作电脑游戏需要两个步骤:第一步是制作一个必须自然吸引人的视觉效果,而第二步是让玩家与游戏互动。为了使游戏具有互动性,我们必须处理玩家提供的事件。这就是我们将在下一节中做的事情。
处理用户按键事件
我们已经开始构建游戏的基本布局。现在,让我们编写一些代码来处理用户键盘事件。贪吃蛇是一个简单的游戏。我们可以通过处理键盘的四个键:上、下、左、右来使其工作。我们可以使用getch()来获取用户输入。但请记住,这些不是字母字符,它们是功能字符。因此,我们必须导入常量,如KEY_UP、KEY_DOWN、KEY_LEFT和KEY_RIGHT来获取这些 ASCII 值。让我们开始编写处理用户事件的代码:
from curses import KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT
#CODE FROM PREVIOUS TOPIC
key = KEY_RIGHT #default key
#ASCII value of ESC is 27
while key != 27:
win.border(0)
win.timeout(100) #speed for snake
default_key = key
event = win.getch()
key = key if event == -1 else event
if key not in [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, 27]:
key = default_key
我们编写的代码可能看起来很复杂,但所有这些事情都已经涵盖过了。让我们看看我们做了什么:
-
在第一个语句中,我们将默认键设置为
KEY_RIGHT。这很重要,因为我们不希望在用户没有按键时使蛇移动。因此,当游戏开始时,我们的蛇角色将自动向右移动。 -
之后,我们创建了一个游戏循环。这个循环将一直执行,直到我们按下Esc,因为Esc的 ASCII 值是 27。在循环内部,我们调用了 timeout 方法,它将代表我们的贪吃蛇角色的速度。在下一行,我们使用
getch()方法获取用户的事件。请记住,如果按下任何键事件,它的值将是-1。因此,我们可以进行比较,并将用户按下的键放入键变量中。然而,键可以是任何东西,比如字母字符或特殊符号,如[!,@,#,$],因此我们必须用适当的键进行过滤,例如 LEFT、RIGHT、UP 和 DOWN。如果用户按下的键不在其中,我们将使键具有默认值KEY_RIGHT。
现在,我们可以将我们的程序与键盘或操纵杆等输入设备进行通信。是时候进入下一部分了,在那里我们将创建我们的第一个逻辑,当用户按下左、右、上和下键时,更新蛇字符的头部位置。
游戏逻辑-更新蛇头的位置
在前面的部分,我们能够使用 curses 提供的常量来处理用户事件。就像移动一样,蛇的头部也可以改变。我们必须制定全新的逻辑来更新蛇头的位置。我们的蛇是存储在列表中的坐标的组合。嵌套列表的第一个元素是蛇头的位置。因此,我们只需要更新列表的第一个元素。让我们看看我们将如何做到这一点:
while key != 27:
#code from preceding topic
snake.insert(0, [snake[0][0] + (key == KEY_DOWN and 1) +
(key == KEY_UP and -1), snake[0][1] + (key == KEY_LEFT and -1) +
(key == KEY_RIGHT and 1)])
这可能看起来有点难以理解,所以让我澄清一下。
snake变量是一个列表。因此,我们可以使用insert()方法来操作该列表元素。insert()方法将接受两个参数:一个是索引,另一个是要插入的元素。在前面的代码中,索引是 0,这意味着我们要在列表的第一个元素中插入一个元素,它代表了蛇的头部。下一个参数是要添加到索引 0 的元素。我们可以在两个语句之间看到一个逗号(,):snake[0][0] + (key == KEY_DOWN and 1) + (key == KEY_UP and -1)和snake[0][1] + (key == KEY_LEFT and -1) + (key == KEY_RIGHT and 1)。第一个语句表示蛇头的 y 坐标,而第二个语句表示蛇头的 x 坐标。在蛇头的 y 部分,可以表示为一列,我们可以有两种移动方式:向下或向上。向下时,我们必须在当前头部位置的 y 元素上加 1,而向上时,我们必须在当前 y 位置减 1。对于蛇头的 x 部分,我们有左和右的移动。
按下左键时,我们将 x 坐标减 1,按下右键时,我们将 x 加 1。还是困惑吗?看一下下面的图表应该能让你更清楚:

请记住,这个更新必须按照(y,x)的顺序进行。对于每次按下UP和DOWN键,y 坐标都会增加或减少 1,这是蛇头的 snake[0][0]坐标。对于 x 坐标,这是 snake[0][1],这是我们之前使用的相同的增加和减少,但是当用户按下RIGHT和LEFT键时。
现在我们已经制定了一些逻辑来更新蛇的位置,我们需要让蛇吃食物。我们要讨论的逻辑很简单:当蛇的头部位置与食物的位置相同时,我们可以说蛇吃了食物。让我们现在来讨论这个。
游戏逻辑-当蛇吃食物时
让我们为我们的游戏制作下一个逻辑部分。在这一部分,我们将让蛇吃食物。这很容易实现。每当蛇的头部触碰到食物时,我们就假设蛇已经吃了食物。因此,蛇的头部坐标和食物的坐标将是相同的。我们还必须制定一些逻辑,一旦蛇吃掉当前的食物,就会在下一个位置生成食物。下一个食物的位置应该是随机的。我们可以使用random模块来创建这样一个任意的位置。让我们开始编写代码:
from random import randint
这是从模块中导入任何函数的新方法。在调用这个函数时,我们不必写类似random.randint()的东西。相反,我们可以直接在我们的程序中调用它。randint()方法中的参数必须是值的范围。例如,randint(2,8)返回 2 到 8 之间的数字,就像这样:
while key != 27:
#add the following code after updating head position
if snake[0] == food:
food = []
while food == []:
food = [randint(1,18), randint(1,58)]
if food in snake:
food = []
win.addch(food[0], food[1], 'O')
else:
last = snake.pop()
win.addch(last[0], last[1], ' ')
win.addch(snake[0][0], snake[0][1], 'X')
c.endwin()
在代码的 if 部分中,我们添加了将食物放在新位置的逻辑。请记住,在游戏开始时,我们将新窗口的高度初始化为 20,宽度为 60。因此,我们只能在这个边界内生成食物。在代码的 else 部分中,如果用户无法吃到食物,我们会弹出最后一个元素。在倒数第二行,我们将蛇头的位置与'X'字符相加。
让我们运行游戏,看看目前的样子:

现在,我们的游戏已经足够可玩了。在制作这个游戏的过程中,我们学到了很多东西,比如如何在处理游戏控制台的方法和坐标时制定游戏逻辑。现在,让我们继续下一节,我们将学习如何测试和修改我们的游戏。
游戏测试和修改
为了发现任何程序的缺陷,运行和测试它总是一个好主意。就像我们之前的游戏一样,我们也可以对 Snake 游戏进行修改。以下几点解释了我们可以对游戏进行的一些修改:
- 当你运行游戏时,你会注意到的第一件事是,我们的游戏没有逻辑来决定蛇是否与自己的其他部分发生碰撞。如果它与身体的其他部分发生碰撞,我们必须停止游戏。让我们在 while 循环中添加这个逻辑:
if snake[0] in snake[1:]:
break
-
在前面的代码中,snake[0]代表蛇的头部,而 snake[1:]代表蛇的身体。因此,前面的条件意味着头部坐标在蛇的身体内,这意味着发生了碰撞。在这种情况下,我们使用
break语句来跳出循环并终止游戏。 -
假设我们想要添加玩家的得分。添加得分很简单;蛇吃掉的食物数量等于玩家的得分。我们可以将得分的值初始化为 0 开始:
score = 0
while key != 27:
# CODE TO ADD SCORE IN THE SCREEN
win.border(0)
win.addstr(0, 2, 'Score : ' + str(score) + ' ')
win.addstr(0, 27, ' SNAKE ')
if snake[0] == food:
food = []
#AFTER EATING EVERY FOOD SCORE = FOOD
score += 1
while food == []:
food = [randint(1,18), randint(1,58)]
if food in snake: food = []
win.addch(food[0], food[1], 'O')
else:
end = snake.pop()
win.addch(last[0], last[1], '')
win.addch(snake[0][0], snake[0][1], 'X')
c.endwin()
在前面的代码中,我们添加了一些带有addstr方法的语句,这些语句将在指定位置提供玩家的得分。现在,让我们运行游戏:

运行游戏后,您可以看到我们能够在 curses 的界面中进行游戏。然而,一旦您的蛇撞到边界线,您将遇到一个异常,游戏将自动终止。我们将在接下来的章节中详细学习如何处理边界碰撞(具体来说,第十一章,使用 Pygame 制作 Outdo Turtle - Snake 游戏 UI),但是现在,让我们学习一下我们可以使用的最简单的方法来处理并消除触发异常。首先,观察边界屏幕的尺寸,并注意边界所在的实际高度和宽度。考虑查看win变量,以了解边界屏幕的大小。现在,看着高度为 20,我们可能会假设每当蛇触碰顶部边界时,也就是说,蛇头位置为 0 时,蛇头必须通过自己的边界进入,其 y 坐标为 19。请记住,在上下边界中,只有 y 坐标会改变。这个逻辑的代码如下:
if snake[0][0] == 0:
snake[0][0] = 18 #regenerate snake from lower boundary line
if snake[0][0] == 19:
snake[0][0] = 1 #regenerate snake from upper boundary line
同样,我们必须处理蛇撞到右边界或左边界的情况。由于高度对于任何一种情况都保持不变,我们只关心宽度(x 位置)。由于由 win 变量声明的屏幕宽度为 60,我们可以预期蛇在 0(右边)和 59(左边)左右撞到边界时会相应地重新生成。您必须添加以下代码来处理发生在左右边界的碰撞:
if snake[0][1] == 0:
snake[0][1] = 58 #regenerate from left
if snake[0][1] == 59:
snake[0][1] = 1 #regenerate from right
最后,我们已经完成了 Snake 游戏。它足够吸引人,让任何用户都能玩这个游戏。我们还学会了如何用全新的逻辑创建程序。这是我们用来制作基于文本的游戏的第一个简单模块。尽管它是可玩的,但我们没有为它添加任何图形,所以看起来相当单调。通过学习一个名为面向对象编程的新 Python 范例,我们将使它更加引人入胜。我们已经成功地对我们的游戏进行了一些修改。现在,是时候学习 Python 最重要的概念了:面向对象编程。
总结
在本章中,我们开始揭开 curses 游戏编程的世界。显然,这并不是完美的游戏,因为它没有惊人的动画或奇妙的界面。我们几乎没有涉及这些话题,因为 curses 提供的应用是基于文本的,并在普通终端上运行。甚至 Snake 游戏的游戏角色,如蛇和食物,都是由字母组成的。尽管我们没有额外的努力使游戏更具吸引力,但我们已经学会了如何制作游戏逻辑。我们在 Snake 游戏中制作的逻辑中有两个重要的部分:第一个是游戏控制台的坐标与玩家位置的交互,第二个是使角色发生碰撞。curses 支持的坐标系统顺序很奇怪。在大多数库中,如 pygame 和 pyopengl,我们有一个以(x,y)顺序表示的坐标系统,但在 curses 中,它是(y,x)。如果两个字符在相同的坐标点(y,x)上,它们之间发生碰撞。为了做到这一点,我们必须检查蛇头和蛇身之间的碰撞。这个逻辑听起来可能很简单,但从长远来看会很有用。例如,在即将推出的游戏中,如 Flappy Bird 或 Angry Birds,我们将使用相同的逻辑来检查角色之间的碰撞。
我们为 Snake 游戏编写的代码非常细致和彻底,因为游戏是以过程式编程为基础编写的。在下一章中,我们将学习 Python 最重要的概念——面向对象编程,并相应地修改我们的代码,这将使我们的代码更易读和可重用。
第六章:面向对象编程
编程不仅仅是编写程序,同样重要的是理解它们,这样我们就可以修复其中的错误和漏洞。因此,我们说程序员天生就是用来阅读和理解代码的。然而,随着程序变得越来越复杂,编写易读的程序变得更加困难。在本书中,我们既写了美观的代码,也写了混乱的代码。我们用顺序编程制作了一个井字棋游戏,其可读性较低。我们可以将这些程序视为不优雅的,因为我们很难阅读和理解它们的代码和顺序流程。在编写这些程序之后,我们使用函数对其进行了修改,从而使我们混乱的代码更加优雅。然而,如果你正在处理包含数千行代码的程序,很难在同一个文件中编写程序并理解你正在使用的每个函数的行为。因此,发现和修复以过程方式编写的程序的错误也是困难的。因此,我们需要一种方法,可以将多行程序轻松地分解成更小的模块或部分,以便更容易地发现和修复这些错误。有许多实现这一目标的方法,但最有效和流行的方法是使用面向对象编程(OOP)方法。
事实证明,我们从本书的开头就一直在使用对象,但并没有准确地了解它们是如何制作和使用的。本章将帮助您通过一些简单的示例了解面向对象编程的术语和概念。本章末尾,我们还将根据 OOP 方法修改我们在前几章中使用函数编写的贪吃蛇游戏代码。
本章将涵盖以下主题:
-
面向对象编程概述
-
Python 类
-
封装
-
继承
-
多态
-
使用 OOP 实现的贪吃蛇游戏
-
可能的错误和修改
技术要求
为了充分利用本章,您将需要以下内容:
-
Python 3.5 或更新版本
-
Python IDLE(Python 内置 IDE)
-
文本编辑器
-
网络浏览器
本章的文件可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter06
查看以下视频,以查看代码的实际运行情况:
面向对象编程概述
Python 中的一切都是对象。我们从本书的开头就一直在雄辩地陈述这一观点,并且在每一章中都在证明这个说法。一切都是对象。对象可以是元素、属性或函数的集合。数据结构、变量、数字和函数都是对象。面向对象编程是一种编程范式,它通过对象的帮助提供了一种优雅的程序结构方式。对象的行为和属性被捆绑到模板中,我们称之为类。这些行为和属性可以从该类的不同对象中调用。不要被行为和属性这些术语搞混了。它们只是方法和变量的不同名称。在某些类中定义的函数被称为方法。
我们将在本章后面深入探讨类和方法的概念,但现在,让我们在实际为它们制作模板之前更多地了解对象。
我们从这本书的开始就在不知不觉地使用对象。我们以前使用过不同类的方法,比如randint()方法。这个方法是通过导入一个名为random的模块来使用的。这个方法也是 Python 的内置类。类是一个模板,我们可以在其中编写对象的函数。例如,一个人可以被表示为一个对象。一个人有不同的特征,比如name、age和hair_color,这些是唯一的属性。然而,人所执行的行为,比如吃饭、走路和睡觉,是行为或方法。我们可以从这些模板中创建任意多的对象。但现在,让我们想象两个对象:
Object 1: Stephen : name = "Stephen Hawking", age= 56, hair_color= brown, eating, walking, sleeping
Object 2: Albert: name = "Albert Einstein", age = 77, hair_color= black, eating, walking, sleeping
在前面的两个对象中,name、age和hair_color是唯一的。所有的对象都有唯一的属性,但它们执行的行为或方法是相同的,比如吃饭、走路和睡觉。因此,我们可以得出结论,与输入和输出交互的数据模型是一个属性,因为它将被输入到方法中。根据每个对象的唯一属性,类的方法将产生不同的结果。
因此,我们可以说,面向对象编程是将现实世界的实体建模为具有唯一数据关联的对象,并且可以执行某些函数的方法。在类内部定义的函数称为方法,因此我们只需要从函数切换到方法。然而,请注意,方法的工作方式与函数的工作方式类似。就像函数是通过其名称或标志调用的一样,方法也需要通过其名称调用。但是,这个调用应该由对象发起。让我们看一个简单的例子来澄清这一点:
>>> [1,2,3].pop()
3
我们在前面的章节中看过这些方法。但是如果你仔细看这段代码,你会发现我们是从对象中调用方法。我们使用了pop方法,并在列表对象上调用它。这是面向对象编程的一个简单原型。面向对象编程的一个优点是它隐藏了方法调用的内部复杂性。正如你可能记得的,我们用随机模块调用了randint方法。我们甚至没有查看随机库的内容。因此,我们避免了库的工作复杂性。面向对象编程的这个特性将使我们只关注程序的重要部分,而不是方法的内部工作。
面向对象编程的两个主要实体是对象和类。我们可以通过使用模板来模拟类,其中方法和属性被映射。方法是函数的同义词,而属性是将每个对象与另一个对象区分开的属性。让我们通过创建一个简单的类和对象来对这个术语有一个很好的理解。
Python 类
正如我们在前一节中讨论的,对象继承了类内部编写的所有代码。因此,我们可以使用在类主体内映射的方法和属性。类是一个模板,可以从中创建实例。看一下下面的例子:

在前面,Bike类可以被认为是一个模板,可以从中实例化对象。在Bike类中,有一些属性,这些属性唯一地代表了从这个类创建的对象。每个创建的对象都会有不同的属性,比如名称、颜色和价格,但它们会调用相同的方法。这个方法应该与类的实例一起调用。让我们看看如何在 Python 中创建类:
>>> class Bike:
pass
我们用 class 关键字在 Python 中创建一个类,后面跟着类的名称。通常,类名的第一个字母是大写的;在这里,我们写了Bike,B 是大写的。现在,在全局范围内,我们已经创建了Bike类。在类的主体内,我们写了一个 pass,而不是方法和属性。现在,让我们创建这个类的对象:
>>> suzuki = Bike()
>>> type(suzuki)
<class '__main__.Bike'>
在上面的代码中,我们从Bike类中创建了一个名为Suzuki的实例。实例化表达式看起来类似于函数调用。现在,如果您检查Suzuki对象的类型,它是Bike类的类型。因此,任何对象的类型都将是类类型,因为对象是类的实例。
现在是时候向这个Bike类添加一些方法了。这类似于函数的声明。def关键字,后面跟着方法的名称,是声明类的方法的最佳方式。让我们看一下以下代码:
#class_ex_1.py
class Bike:
def ride_Left(self):
print("Bike is turning to left")
def ride_Right(self):
print("Bike is turning to right")
def Brake(self):
print("Breaking......")
suzuki = Bike()
suzuki.ride_Left()
suzuki.Brake()
>>>
Bike is turning to left
Breaking......
我们向Bike类添加了三个方法。在声明这些方法时使用的参数是self变量。这个self变量或关键字也是类的一个实例。您可以将这个self变量与指针进行比较,该指针指向当前对象。在每次实例化时,self变量表示指向当前类的指针对象。我们将很快澄清self关键字的用法和重要性,但在此之前,看一下上面的代码,我们创建了一个Suzuki对象,并用它调用了类的方法。
上面的代码类似于我们从 random 模块调用randint方法的代码。这是因为我们正在使用 random 库的方法。
当定义任何类时,只定义了对象的表示,这最终减少了内存损失。在上面的例子中,我们用名为Bike的原型制作了一个原型。可以从中制作不同的实例,如下所示:
>>> honda = Bike() #first instance
>>> honda.ride_Right()
Bike is turning to right
>>> bmw = Bike() #second instance
>>> bmw.Brake()
Breaking......
现在我们已经看过如何创建对象并使用类内定义的方法,我们将向类添加属性。属性或属性定义了每个对象的独特特征。让我们向我们的类添加一些属性,比如name,color和price:
class Bike:
name = ''
color= ' '
price = 0
def info(self, name, color, price):
self.name = name
self.color = color
self.price = price
print("{}: {} and {}".format(self.name,self.color,self.price))
>>> suzuki = Bike()
>>> suzuki.info("Suzuki", "Black", 100000)
Suzuki: Black and 100000
在上面的代码中有很多行话。在幕后,这个程序是关于类和对象的创建。我们添加了三个属性:name,color和price。要使用类的这些属性,我们必须用self关键字引用它们。name,color和price参数被传递到info函数中,并分配给Bike类的相应name,color和price属性。self.name, self.color, self.price = name,color,price语句将初始化类变量。这个过程称为初始化。我们也可以使用构造函数进行初始化,就像这样:
class Bike:
def __init__(self,name,color,price):
self.name = name
self.color = color
self.price = price
def info(self):
print("{}: {} and {}".format(self.name,self.color,self.price))
>>> honda = Bike("Honda", "Blue", 30000)
>>> honda.info()
Honda: Blue and 30000
在 Python 中,特殊的init方法将模拟构造函数。构造函数是用于初始化类的属性的方法或函数。构造函数的定义在我们创建类的实例时执行。根据init的定义,我们可以在创建类的对象时提供任意数量的参数。类的第一个方法应该是构造函数,并且必须初始化类的成员。类的基本格式应该在开始时有属性声明,然后是方法。
现在我们已经创建了自己的类并声明了一些方法,让我们探索面向对象范式的一些基本特性。我们将从封装开始,它用于嵌入在类内声明的方法和变量的访问权限。
封装
封装是将数据与代码绑定为一个称为胶囊的单元的一种方式。这样,它提供了安全性,以防止对代码进行不必要的修改。使用面向对象范式编写的代码将以属性的形式具有关键数据。因此,我们必须防止数据被损坏或变得脆弱。这就是所谓的数据隐藏,是封装的主要特性。为了防止数据被意外修改,封装起着至关重要的作用。我们可以将类的成员设为私有成员以实现封装。私有成员,无论是方法还是属性,都可以在其签名的开头使用双下划线来创建。在下面的例子中,__updateTech是一个私有方法:
class Bike:
def __init__(self):
self.__updateTech()
def Ride(self):
print("Riding...")
def __updateTech(self):
print("Updating your Bike..")
>>> honda = Bike()
Updating your Bike..
>>> honda.Ride()
Riding...
>>> honda.__updateTech()
AttributeError: 'Bike' object has no attribute '__updateTech'
在前面的例子中,我们无法从类的对象中调用updateTech方法。这是由于封装。我们使用双下划线将此方法设为私有。但有时我们可能需要修改这些属性或行为的值。我们可以使用 getter 和 setter 来修改。这些方法将获取类的属性的值并设置值。因此,我们可以得出结论,封装是面向对象编程的一个特性,它将防止我们意外修改和访问数据,但不是有意的。类的私有成员实际上并不是隐藏的;相反,它们只是与其他成员区分开来,以便 Python 解析器能够唯一解释它们。updateTech方法是通过在其名称开头使用双下划线(__)来使其成为私有和唯一的。类的属性也可以使用相同的技术来私有化。现在让我们来看一下:
class Bike:
__name = " "
__color = " "
def __init__(self,name,color):
self.__name = name
self.__color = color
def info(self):
print("{} is of {} color".format(self.__name,self.__color))
>>> honda = Bike("Honda", "Black")
>>> honda.info()
Honda is of Black color
我们可以清楚地看到name和color属性是私有的,因为它们以双下划线开头。现在,让我们尝试使用对象来修改这些值:
>>> honda.__color = "Blue"
>>> honda.info()
Honda is of Black color
我们尝试修改Bike类的颜色属性,但什么也没发生。这表明封装将防止意外更改。但如果我们需要有意地进行更改呢?这可以通过 getter 和 setter 来实现。看看以下例子以了解更多关于 getter 和 setter 的信息:
class Bike:
__name = " "
__color = " "
def __init__(self,name,color):
self.__name = name
self.__color = color
def setNewColor(self, color):
self.__color = color
def info(self):
print("{} is of {} color".format(self.__name,self.__color))
>>> honda = Bike("Honda", "Blue")
>>> honda.info()
Honda is of Blue color
>>> honda.setNewColor("Orange")
>>> honda.info()
Honda is of Orange color
在前面的程序中,我们定义了一个Bike类,其中包含一些私有成员,如name和color。我们使用init构造函数在创建类的实例时初始化属性的值。我们尝试修改它的值。然而,我们无法更改其值,因为 Python 解析器将这些属性视为私有。因此,我们使用setNewColor setter 为该私有成员设置新值。通过提供这些 getter 和 setter 方法,我们可以使类成为只读或只写,从而防止意外数据修改和有意的窃取。
现在,是时候来看一下面向对象范式的另一个重要特性——继承。继承帮助我们编写将从其父类继承每个成员并允许我们修改它们的类。
继承
继承是面向对象编程范式中最重要和最知名的特性。你还记得函数的可重用特性吗?继承也提供了可重用性,但伴随着大量的代码。要使用继承,我们必须有一个包含一些代码的现有类。这必须由一个新类继承。这样的现有类称为父类或基类。我们可以创建一个新类作为Child类,它将获取并访问父类的所有属性和方法,这样我们就不必从头开始编写代码。我们还可以修改子类继承的方法的定义和规范。
在下面的示例中,我们可以看到Child类或Derived类指向Base或Parent类,这意味着存在单一继承:

在 Python 中,使用继承很容易。通过在Child类后面的括号中提及Parent类的名称,Child类可以从Parent类继承。以下代码显示了如何实现单一继承:
class Child_class(Parent_class):
<child-class-members>
单个类也可以继承多个类。我们可以通过在括号内写入所有这些类的名称来实现这一点:
class Child_class(Base_class1, Base_class2, Base_class3 .....):
<child-class-members>
让我们写一个简单的例子,以便更好地理解继承。在下面的例子中,Bike将是Parent类,Suzuki将是Child类:
class Bike:
def __init__(self):
print("Bike is starting..")
def Ride(self):
print("Riding...")
class Suzuki(Bike):
def __init__(self,name,color):
self.name = name
self.color = color
def info(self):
print("You are riding {0} and it's color is
{1}".format(self.name,self.color))
#Save above code in python file and Run it
>>> suzuki = Suzuki("Suzuki", "Blue")
>>> suzuki.Ride()
Riding...
>>> suzuki.info()
You are riding Suzuki and it's color is Blue
让我们看一下前面的代码,并对继承感到惊讶。首先,我们创建了一个Base类,并在其中添加了两个方法。之后,我们创建了另一个类,即子类或派生类,称为Suzuki。它是一个子类,因为它使用class Suzuki(Bike)语法继承了其父类Bike的成员。我们还向子类添加了一些方法。创建这两个类后,我们创建了子类的对象。我们知道,当创建对象时,将自动调用要调用的方法是构造函数或init。因此,在创建该类的对象时,我们传递了构造函数要求的值。之后,我们从Suzuki类的对象中调用Ride方法。您可以在Suzuki类的主体内检查Ride方法。它不在那里——相反,它在Bike类的套件中。由于继承,我们能够调用Base类的方法,就好像它们在Child类中一样。我们还可以在Child类中使用在Base类中定义的每个属性。
然而,并非所有特性都在子类中继承。当我们创建子类的实例时,子类的init方法被调用,但Parent的方法没有被调用。然而,有一种方法可以调用该构造函数:使用super方法。这在下面的代码中显示:
class Bike:
def __init__(self):
print("Bike is starting..")
def Ride(self):
print("Riding...")
class Suzuki(Bike):
def __init__(self,name,color):
self.name = name
self.color = color
super().__init__()
>>> suzuki = Suzuki("Suzuki", "Blue")
Bike is starting..
super()方法指的是超类或Parent类。因此,在实例化超类之后,我们调用了该超类的init方法。
这类似于Bike().__init__(),但在这种情况下,Bike is starting..将被打印两次,因为Bike()语句将创建一个Bike类的对象。这意味着init方法将被自动调用。第二次调用是使用Bike类的对象进行的。
在 Python 中,多级继承是可用的。当任何子类从另一个子类继承时,将创建一个链接的序列。关于如何创建多级继承链,没有限制。以下图表描述了多个类从其父类继承特性:

以下代码显示了多级继承的特点。我们创建了三个类,每个类都继承了前一个类的特点:
class Mobile:
def __init__(self):
print("Mobile features: Camera, Phone, Applications")
class Samsung(Mobile):
def __init__(self):
print("Samsung Company")
super().__init__()
class Samsung_Prime(Samsung):
def __init__(self):
print("Samsung latest Mobile")
super().__init__()
>>> mobile = Samsung_Prime()
Samsung latest Mobile
Samsung Company
Mobile features: Camera, Phone, Applications
现在我们已经看过继承,是时候看看另一个特性,即多态。从字面上看,多态是适应不同形式的能力。因此,这个特性将帮助我们以不同的形式使用相同的代码,以便可以用它执行多个任务。让我们来看一下。
多态
在面向对象的范式中,多态性允许我们在Child类中定义与Parent类中定义的相同签名的方法。正如我们所知,继承允许我们使用Parent类的每个方法,就好像它们是在Child类中的子类对象的帮助下。然而,我们可能会遇到这样的情况,我们必须修改在父类中定义的方法的规格,以便它独立于Parent类执行。这种技术称为方法重写。顾名思义,我们正在用Child类内部的新规格覆盖Base类的已有方法。使用方法重写,我们可以独立调用这两个方法。如果你在子类中重写了父类的方法,那么该方法的任何版本(无论是子类的新版本还是父类的旧版本)都将根据使用它的对象的类型来调用。例如,如果你想调用方法的新版本,你应该使用Child类对象来调用它。谈到父类方法,我们必须使用Parent类对象来调用它。因此,我们可以想象到这两组方法已经开发出来,但是具有相同的名称和签名,这意味着基本的多态性。在编程中,多态性是指相同的函数或方法以不同的形式或类型使用。
我们可以从到目前为止学到的知识中开始思考多态性的例子。你还记得len()函数吗?这是一个内置的 Python 函数,以对象作为参数。这里,对象可以是任何东西;它可以是字符串、列表、元组等。即使它有相同的名称,它也不限于执行单一任务——它可以以不同的形式使用,如下面的代码所示:
>>> len(1,2,3) #works with tuples
3
>>> len([1,2,3]) #works with lists
3
>>> len("abc") #works with strings
3
让我们看一个例子来演示继承的多态性。我们将编写一个程序,创建三个类;一个是Base类,另外两个是Child类。这两个Child类将继承Parent类的每一个成员,但它们每个都会独立实现一个方法。这将是方法重写的应用。让我们看一个使用继承的多态性概念的例子:
class Bird:
def about(self):
print("Species: Bird")
def Dance(self):
print("Not all but some birds can dance")
class Peacock(Bird):
def Dance(self):
print("Peacock can dance")
class Sparrow(Bird):
def Dance(self):
print("Sparrow can't dance")
>>> peacock = Peacock()
>>> peacock.Dance()
Peacock can dance
>>> sparrow = Sparrow()
>>> sparrow.Dance()
Sparrow can't dance
>>> sparrow.about() #inheritance
Species: Bird
你看到的第一件事是Dance方法在所有三个类中都是共同的。但在这些类的每一个中,我们对Dance方法有不同的规格。这个特性特别有用,因为在某些情况下,我们可能想要定制从Parent类继承的方法,在Child类中可能没有任何意义。在这种情况下,我们使用与Child类内部相同签名的方法重新定义这个方法。这种重新实现方法的技术称为方法重写,通过这个过程创建的不同方法实现了多态性。
现在我们已经学习了面向对象编程的重要概念及其主要特性,比如封装、继承和多态性,是时候利用这些知识来修改我们在上一章中使用 curses 制作的蛇游戏了。由于我们无法使用这些面向对象的原则来使上一章的代码变得不那么混乱和晦涩,我们将使我们的代码更具可重用性和可读性。我们将在下一节开始使用 OOP 修改我们的游戏。
蛇游戏实现
在本章中,我们探讨了面向对象编程的各种特性,包括继承、多态性、数据隐藏和封装。我们没有涉及的一个特性,称为方法重载,将在第九章“数据模型实现”中介绍。我们已经学到了足够多关于 OOP 的知识,使我们的代码更易读和可重用。让我们按照传统模式开始这一部分,即头脑风暴和信息收集。
头脑风暴和信息收集
正如我们已经讨论过的,面向对象编程与游戏界面编程无关;相反,它是一种使代码更加稳健和更加清晰的范式。因此,我们的界面将类似于由 curses 模块制作的程序——基于文本的终端。然而,我们将使用面向对象的范式来完善我们的代码,并且我们将专注于对象而不是动作和逻辑。我们知道面向对象编程是一种数据驱动的方法。因此,我们的程序必须容纳游戏屏幕和用户事件数据。
我们在游戏中使用面向对象的原则的主要目标如下:
-
将程序分成较小的部分,称为对象。这将使程序更易读,并允许我们轻松跟踪错误和错误。
-
能够通过函数在对象之间进行通信。
-
数据是安全的,因为它不能被外部函数使用。这就是封装。
-
我们将更加注重数据而不是方法或程序。
-
对程序进行修改,如添加属性和方法,可以很容易地完成。
现在,让我们开始头脑风暴并收集一些关于游戏模型的信息。显然,我们必须使用上一章的相同代码来布局游戏和其角色,即Snake和Food。因此,我们必须为它们各自取两个类。Snake和Food类将在其中定义控制游戏布局和用户事件的方法。
我们必须使用诸如KEY_DOWN、KEY_UP、KEY_LEFT和KEY_RIGHT等 curses 事件来处理蛇角色的移动。让我们来可视化一下基本的类和方法:
-
首先,我们必须导入 curses 来初始化游戏屏幕并处理用户按键移动。
-
然后,我们必须导入随机模块,因为一旦蛇吃了食物,我们就必须在随机位置生成食物。
-
之后,我们初始化常量,如屏幕高度、宽度、默认蛇长度和超时时间。
-
然后,我们用构造函数声明了
Snake类,它将初始化蛇的默认位置、窗口、头部位置和蛇的身体。 -
在
Snake类内部,我们将添加一些方法,如下:
-
eat_food将检查蛇是否吃了食物。如果吃了,蛇的长度将增加。 -
collision将检查蛇是否与自身发生了碰撞。 -
update将在用户移动并改变Snake角色的位置时被调用。
- 最后,我们声明
Food类并定义渲染和重置方法来在随机位置生成和删除食物。
现在,让我们通过声明常量和导入必要的模块来开始编写程序。这与上一章没有什么不同——我们将使用 curses 来初始化游戏屏幕并处理用户事件。我们将使用随机模块在游戏控制台上生成一个随机位置,以便我们可以在该位置生成新的食物。
声明常量并初始化屏幕
与前一章类似,我们将导入 curses 模块,以便我们可以初始化游戏屏幕并通过指定高度和宽度来自定义它。我们必须声明默认蛇长度和其位置作为常量。以下代码对你来说将是熟悉的,除了name == "__main__"模式:
import curses
from curses import KEY_RIGHT, KEY_LEFT, KEY_DOWN, KEY_UP
from random import randint
WIDTH = 35
HEIGHT = 20
MAX_X = WIDTH - 2
MAX_Y = HEIGHT - 2
SNAKE_LENGTH = 5
SNAKE_X = SNAKE_LENGTH + 1
SNAKE_Y = 3
TIMEOUT = 100
if __name__ == '__main__':
curses.initscr()
curses.beep()
curses.beep()
window = curses.newwin(HEIGHT, WIDTH, 0, 0)
window.timeout(TIMEOUT)
window.keypad(1)
curses.noecho()
curses.curs_set(0)
window.border(0)
在前面的代码中,我们声明了一堆常量来指定高度、宽度、默认蛇长度和超时时间。我们对所有这些术语都很熟悉,除了__name__ == "__main__"模式。让我们详细讨论一下:
通过查看这个模式,我们可以得出结论,将"main"字符串赋值给 name 变量。就像__init__()是一个特殊方法一样,__name__是一个特殊变量。每当我们执行脚本文件时,Python 解释器将执行写在零缩进级别的代码。但是在 Python 中,没有像 C/C++中那样自动调用的main()函数。因此,Python 解释器将使用特殊的__name__变量设置为__main__字符串。每当 Python 脚本作为主程序执行时,解释器将使用该字符串设置特殊变量。但是当文件从另一个模块导入时,name 变量的值将设置为该模块的名称。因此,我们可以得出结论,name 变量将确定当前的工作模块。我们可以评估这个模式的工作方式如下:
-
当前源代码文件是主程序时:当我们将当前源文件作为主程序运行,即
C:/> python example.py,解释器将把"__main__"字符串赋给特殊的 name 变量,即name == "__main__"。 -
当另一个程序导入您的模块时:假设任何其他程序是主程序,并且它正在导入我们的模块。
>>> import example语句将 example 模块导入主程序。现在,Python 解释器将通过删除.py扩展名来细化脚本文件的名称,并将该模块名称设置为 name 变量,即name == "example"。由于这个原因,写在 example 模块中的代码将对主程序可用。特殊变量设置完成后,Python 解释器将逐行执行语句。
因此,__name__ == "__main__"模式可用于执行其中写入的代码,如果源文件直接执行,而不是导入。我们可以得出结论,写在此模式内的代码将被执行。在 Python 中,没有main()函数,它是在低级编程语言中自动调用的。
在这种情况下,顶层代码以一个if块开始,后面跟着模式的name,评估当前的工作模块。如果当前程序是main,我们将执行写在if块内的代码,通过 curses 初始化游戏屏幕并在游戏中创建一个新窗口。
现在我们已经开始编写一个程序,初始化了游戏屏幕并声明了一些常量,是时候创建一些类了。游戏中有两个角色:Snake和Food。我们将从现在开始创建两个类,并根据需要对它们进行修改。让我们从创建Snake类开始。
创建蛇类
在为游戏创建屏幕后,我们的下一个重点将是在屏幕上渲染游戏角色。我们将首先创建Snake类。我们知道类将有不同的成员,即属性和方法。正如我们在上一章中提到的,创建Snake角色时,我们必须跟踪蛇在游戏窗口中的x和y位置。为了跟踪蛇的身体位置,我们必须提取蛇的x和y坐标。我们应该使用字母字符来构成蛇的身体,因为 curses 只支持基于文本的终端。让我们开始创建Body类,它将为我们提供蛇的位置并提供蛇身体的字符:
class Body(object):
def __init__(self, x, y, char='#'):
self.x = x
self.y = y
self.char = char
def coor(self):
return self.x, self.y
在前面的程序中,#用于构成蛇的身体结构。我们在Body类内定义了两个成员:构造函数和coor方法。coor方法用于提取蛇身体的当前坐标。
现在,让我们为游戏角色创建一个类。我们将从Snake类开始。我们应该维护一个列出的数据结构,以便我们可以存储蛇的身体位置。应该使用构造函数来初始化这些属性。让我们开始编写Snake类的构造函数:
class Snake:
REV_DIR_MAP = {
KEY_UP: KEY_DOWN, KEY_DOWN: KEY_UP,
KEY_LEFT: KEY_RIGHT, KEY_RIGHT: KEY_LEFT,
}
def __init__(self, x, y, window):
self.body_list= []
self.timeout = TIMEOUT
for i in range(SNAKE_LENGTH, 0, -1):
self.body_list.append(Body(x - i, y))
self.body_list.append(Body(x, y, '0'))
self.window = window
self.direction = KEY_RIGHT
self.last_head_coor = (x, y)
self.direction_map = {
KEY_UP: self.move_up,
KEY_DOWN: self.move_down,
KEY_LEFT: self.move_left,
KEY_RIGHT: self.move_right
}
在Snake类中,我们创建了一个字典。每个键和值表示一个相反的方向。如果您对屏幕上的方向表示感到困惑,请返回到上一章。字符的位置用坐标表示。我们声明了构造函数,它允许我们初始化类的属性。我们创建了body_list来保存蛇的身体;一个代表蛇游戏屏幕的窗口对象;蛇的默认方向,即右方向;和一个方向映射,其中包含使用 curses 常量如KEY_UP、KEY_DOWN、KEY_LEFT和KEY_RIGHT来容纳角色的移动。
对于每个方向映射,我们调用move_up、move_down、move_left和move_right函数。我们将很快创建这些方法。
下面的代码行声明在Snake类中,并将蛇身体的坐标添加到body_list中。Body(x-i,y)语句是Body类的实例,它将指定蛇身体的坐标。在Body类的构造函数中,#用于指定蛇身体的布局:
for i in range(SNAKE_LENGTH, 0, -1):
self.body_list.append(Body(x - i, y))
让我们看一下前面的代码并探索一下。这段代码将扩展Snake类的特性:
- 首先,我们必须通过在
Snake类中添加一些新成员来开始。我们首先添加一个简单的方法,它将扩展蛇的身体:
def add_body(self, body_list):
self.body_list.extend(body_list)
- 现在,我们必须创建另一个方法,将游戏对象渲染到屏幕上。这个程序的一个重要步骤是将蛇的身体渲染到游戏屏幕上。由于我们必须用
#表示蛇,我们可以使用 curses,并使用addstr方法。在下面的渲染方法中,我们循环遍历了蛇的整个body_list,并为每个实例添加了'#':
def render(self):
for body in self.body_list:
self.window.addstr(body.y, body.x, body.char)
- 现在,让我们创建
Snake类的对象。我们可以在name == '__main__'模式中创建它:
if __name__ == '__main__':
#code from preceding topic
snake = Snake(SNAKE_X, SNAKE_Y, window)
while True:
window.clear()
window.border(0)
snake.render()
在上述程序中,我们创建了一个蛇对象。由于在创建对象时Snake类的构造函数将自动调用,我们传入了SNAKE_X和SNAKE_Y参数,这提供了蛇和窗口的默认位置。窗口对象屏幕是通过 curses 的newwin方法创建的。在 while 循环中,我们使用蛇对象调用渲染方法,这将在游戏屏幕中添加一个蛇。
尽管我们已经成功将蛇渲染到游戏控制台中,但我们的游戏还没有准备好测试,因为程序无法处理某些操作,例如用户按键盘上的左、右、上和下键来移动Snake角色。我们知道 curses 模块提供了一个方法,让我们可以从用户那里获取输入,并相应地处理它。
处理用户事件
在上一章中,我们看到使用 curses 模块从用户那里获取输入并处理输入是非常容易的。在本节中,我们将把这些方法添加到Snake类中,因为与用户操作相关的方法与Snake角色的移动相关。让我们在Snake类中添加一些方法:
def change_direction(self, direction):
if direction != Snake.REV_DIR_MAP[self.direction]:
self.direction = direction
上述方法将改变蛇的方向。在这里,我们初始化了REV_DIR_MAP字典,其中包含表示相反方向的键和值。因此,我们将当前方向传递给这个方法,根据用户按下的事件来改变它。方向参数是从用户那里输入的。
现在,是时候提取蛇的头部和头部的坐标了。我们知道蛇的头部位置在蛇移动时会改变。即使穿过蛇的边界,我们也必须使蛇从另一侧出现。因此,蛇的头部位置将根据用户的移动而改变。我们需要创建一个可以适应这些变化的方法。我们可以使用属性装饰器来实现这一点,它将把Snake类的头部属性的更改视为方法。这就像一个 getter。不要被这些术语所压倒,因为我们将在以后的章节中介绍这些内容(列表推导和属性)。话虽如此,让我们来看一下以下示例。这个例子将帮助你理解@property装饰器:
class Person:
def __init__(self,first,last):
self.first = first
self.last = last
self.email = '{0}.{1}@gmail.com'.format(self.first, self.last)
per1 = Person('Ross', 'Geller')
print(per1.first)
print(per1.last)
print(per1.email)
#output
Ross
Geller
Ross.Geller@gmail.com
现在,让我们改变first属性的值并打印所有这些值:
per1.first = "Rachel"
print(per1.first)
print(per1.email)
#output
Rachel
Ross.Geller@gmail.com
你可以清楚地看到,更改没有反映在电子邮件中。电子邮件的名称已经保留了之前的Ross值。因此,为了使程序自动适应变化,我们需要将属性设置为装饰器。让我们将电子邮件设置为属性并观察结果:
class Person:
def __init__(self,first,last):
self.first = first
self.last = last
@property
def email(self):
return '{0}.{1}@gmail.com'.format(self.first,self.last)
以下代码在 Python shell 中执行:
>>> per1 = Person('Ross', 'Geller')
>>> per1.first = "Racheal"
>>> per1.email()
Racheal.Geller@gmail.com
我们对属性所做的更改已经在类的属性中得到了自发的反映,这得益于装饰器属性的帮助。我们将在下一章中详细了解这一点。这只是一个快速的介绍。
我们只涵盖了它,因为这是使蛇的头属性成为属性装饰器的重要部分:
@property
def head(self):
return self.body_list[-1]
@property
def coor(self):
return self.head.x, self.head.y
head方法将提取列表的最后一个元素,表示蛇的头部。coor方法将返回一个包含(x,y)坐标的元组,表示蛇的头部。
让我们再添加一个函数,用于更新蛇的方向:
def update(self):
last_body = self.body_list.pop(0)
last_body.x = self.body_list[-1].x
last_body.y = self.body_list[-1].y
self.body_list.insert(-1, last_body)
self.last_head_coor = (self.head.x, self.head.y)
self.direction_map[self.direction]()
前面的update方法将弹出身体的最后一部分,并将其插入到更新新头部位置之前。
现在,让我们使用 curses 模块处理用户事件:
if __name__ == '__main__':
#code from preceding topic
#snake is object of Snake class
while True:
event = window.getch()
if event == 27:
break
if event in [KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT]:
snake.change_direction(event)
if event == 32:
key = -1
while key != 32:
key = window.getch()
snake.update()
我们在上一章的前面代码中学习了工作机制,所以你不应该有任何问题理解它。现在,让我们让蛇朝着某个方向移动。在Snake类中,我们之前添加了direction_map属性,其中包含了映射到不同函数的字典,如move_up、move_down、move_left和move_right。这些函数将根据用户的操作改变蛇的位置:
#These functions are added inside the Snake class
def move_up(self):
self.head.y -= 1
if self.head.y < 1:
self.head.y = MAX_Y
def move_down(self):
self.head.y += 1
if self.head.y > MAX_Y:
self.head.y = 1
def move_left(self):
self.head.x -= 1
if self.head.x < 1:
self.head.x = MAX_X
def move_right(self):
self.head.x += 1
if self.head.x > MAX_X:
self.head.x = 1
我们在上一章中制定了这个逻辑,并将使蛇向上、向下、向左或向右移动。我们可以将屏幕想象成一个包含行和列的矩阵。通过向上的动作,蛇将在 Y 轴上移动,因此 y 位置应该减小;同样,通过向下的动作,蛇将向下移动 Y 轴,因此我们需要增加 y 坐标。对于蛇的左右移动,我们将分别减小和增加 X 轴。
现在,我们已经处理了用户事件,这结束了Snake类。如果有碰撞,现在是处理碰撞的时候了。我们还必须向游戏添加另一个角色,即Food,这将通过创建一个新类来实现。
处理碰撞装饰器属性的帮助。
在这一部分不会创建高尚的逻辑。我们必须检查蛇的头部是否与蛇的身体部分发生了碰撞。这应该通过检查头部的坐标(y,x)是否与蛇的身体的任何坐标相匹配来完成。因此,让我们制作一个新的@property方法,用于检查碰撞:
@property
def collided(self):
return any([body.coor == self.head.coor
for body in self.body_list[:-1]])
在上面的例子中,如果可迭代对象中的任何项为True,则任何函数将返回True;否则,它将返回False。any函数中的语句是一个列表推导语句,用于检查蛇头的坐标是否与蛇身的任何部分的坐标相同。
现在,让我们在主循环中使用snake对象调用这个方法:
if __name__ == "__main__": while True:
#code from preceding topics
#snake is Snake class object
if snake.collided:
break
添加食物类
我们需要添加到游戏中的下一个角色是Food。正如我们已经说过的,我们必须为每个角色创建一个不同的类,因为它们应该具有不同的行为和属性。让我们为Food角色创建另一个类。我们将称之为Food类。
class Food:
def __init__(self, window, char='&'):
self.x = randint(1, MAX_X)
self.y = randint(1, MAX_Y)
self.char = char
self.window = window
def render(self):
self.window.addstr(self.y, self.x, self.char)
def reset(self):
self.x = randint(1, MAX_X)
self.y = randint(1, MAX_Y)
如果你仔细阅读了本章的Python 类部分,这一节不应该让你感到困惑。在 Python 中创建一个类,我们使用class关键字,后面跟着类名。然而,我们必须使用括号来表示继承。如果你将括号留空,它们将抛出一个错误。因此,我们在括号内添加了一个对象,这是可选的。你可以简单地移除括号,它们将完美地工作。我们使用了 random 模块中的randint方法来在随机位置创建食物。render方法将在指定的(y,x)位置添加X字符。
现在,让我们创建Food类的对象,并通过调用render方法在屏幕上渲染食物:
if __name__ == '__main__':
food = Food(window, '*')
while True:
food.render()
你可能还记得,我们创建的逻辑使蛇吃食物的方式与蛇头坐标与食物坐标发生碰撞的逻辑相同。在实际制作这个逻辑之前,我们将为Snake类制作另一个方法,用于处理吃食物后的后续逻辑:
def eat_food(self, food):
food.reset()
body = Body(self.last_head_coor[0], self.last_head_coor[1])
self.body_list.insert(-1, body)
在蛇吃了食物之后,上述逻辑将被调用。吃了食物之后,我们将重置它,这意味着食物将在下一个随机位置生成。然后,我们将通过将食物的最后一个坐标添加到蛇的身体上来增加身体的位置。
现在,让我们添加一些逻辑,确保我们调用这个方法。正如我们已经讨论过的,逻辑将会很简单:每当蛇头与食物的位置发生碰撞时,我们将调用eat_food方法。
if __name__ == '__main__':
#snake is object of Snake class
#food is object of Food class
while True:
if snake.head.x == food.x and snake.head.y == food.y:
snake.eat_food(food)
curses.endwin()
让我们运行游戏并观察输出:

最后,我们已经用面向对象的范式修改了游戏。你可能觉得使用类和对象更复杂和冗长,但通过更多的练习,你会变得更加熟悉。话虽如此,面向对象编程为我们的程序提供了更多的可读性和可重用性特性。举个例子,如果你在Snake角色中发现了一个 bug,你可以通过检查食物的不必要代码来追踪它。现在,让我们跳到下一节,测试游戏并对其进行必要的修改。
游戏测试和可能的修改
无法通过按下F5直接从 Python 脚本运行 curses 应用程序。因此,我们必须通过命令提示符外部运行它,使用filename.py命令。
现在,让我们在游戏中添加分数:
- 首先,在
Snake类中将分数值初始化为 0。我们还将在Snake类中添加一个score方法:
class Snake:
self.score = 0
@property
def score(self):
return 'Score : {0}'.format(self.score)
- 现在,我们必须在蛇吃食物时每次增加这个分数。蛇吃食物后将调用的方法是
eat_food方法。因此,我们将在这个方法中增加分数:
def eat_food(self, food):
food.reset()
body = Body(self.last_head_coor[0], self.last_head_coor[1])
self.body_list.insert(-1, body)
self.score += 1
- 现在,让我们使用 curses 窗口对象的
addstr方法渲染分数:
while True:
window.addstr(0, 5, snake.score)
- 上述语句将从蛇对象中调用
score方法,并在(0,5)位置添加分数。请记住,在 curses 中,第一个位置是 y,第二个位置是 x。
让我们再次运行游戏:

总结
在本章中,我们学习了编程中最重要的范式之一——面向对象编程。我们涵盖了类和对象的所有概念,以使您更容易阅读和编写自己的代码。我们还探讨了如何定义类的成员并访问它们。通过实际示例,我们熟悉了面向对象方法的特性。我们还学习了继承、封装、多态和方法重写。这些特性也将在接下来的章节中使用,所以确保您对这些主题每个都有很好的掌握。
在下一章中,我们将学习列表推导和属性。下一章的目的是找到一种优化代码的方法,使程序在执行方面更短、更快。我们将学习如何处理条件和逻辑,以实现更易读和更易调试的单行代码。我们还将利用这个概念来修改我们的贪吃蛇游戏。
第七章:列表推导和属性
“需要是发明之母”是一句流行的英语谚语,意思是迄今为止或将来发明的任何先驱性想法都是因为它们的需要。例如,巨大的视频托管平台 YouTube 之所以受欢迎,不仅是因为其商业模式,还因为它的推出时间。许多创意艺术家,如视频编辑者、歌手、舞者和游戏玩家,希望该平台能在全球范围内得到认可,而观众希望有一个可以免费学习和娱乐的平台。因此,需求是任何新发明的推动力。然而,这并不意味着每一个在正确时间创造的革命性想法都会成功。其中一些因为没有解决技术所带来的限制而失败。我们的异想天开受到这些技术的限制,尽管我们一直在进步,但我们还没有到达那里。
因此,为了使任何革命性的想法成功,我们必须了解我们的限制。我们的主要限制是内存空间和处理能力。在处理这些限制的同时,本章将教会我们编写一个优雅的程序,可以在一定程度上节省内存存储和运行时间。我们将学习 Python 提供的理解和生成,它们将使程序在保持可读性的同时运行得更快。
本章将涵盖以下主题:
-
代码复杂性概述
-
循环与列表推导的比较
-
装饰器
-
Python 属性
-
使用 LC 和属性完善贪吃蛇游戏
技术要求
您需要满足以下要求才能完成本章:
-
Python 3.5 或更新版本
-
Python IDLE(Python 的内置 IDE)
-
文本编辑器
-
网络浏览器
本章的文件可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter07
查看以下视频,了解代码的运行情况:
代码复杂性概述
到目前为止,我们一直在学习 Python 的基础知识,如函数、数据结构和面向对象编程。现在,我们能够创建自己的逻辑,甚至编写一些游戏。随着我们继续为这些游戏添加功能,我们预计会有数百万行代码。这些庞大的代码行(LOC)将很难理解、解释和处理。例如,在某些情况下,我们可能需要在代码可维护性和优化之间进行权衡。假设您维护一个购物网站的代码,有一天您的网站被数百万次点击,这超出了服务器的处理速度。现在,您必须适应这样一种情况,即您必须要么在没有延迟的情况下为客户提供没有适当产品推荐的页面,要么在稍有延迟的情况下为客户提供适当的推荐。
另一方面,我们可能希望实现一定程度的代码优化。如果某个程序需要几秒钟才能执行,那么优化后,我们可能希望在一毫秒内运行它。现在,我们可能认为这段时间微不足道,但在第一次运行时确实如此。然而,当我们不得不运行相同的程序一千次时,我们可能会节省一些时间,这对于任何实时应用可能是有用的。
在本章中,我们将专注于修改我们的代码以提高其质量和效率的方法。如果我们设法使原始程序的代码更短,减少内存消耗并增加其执行速度,并减少输入/输出指令的交互,那么可以说任何原始程序都已经被优化了。优化的基本规则是,优化的结果必须与非优化的结果具有相同的输出和后果。
然而,当优化后的程序在时间和空间复杂性方面比非优化程序有利的结果时,这些要求可能是微不足道的。例如,在火箭发射活动中,我们可能希望获得周围区域的实时数据,而不在乎数据的准确性。因此,即使优化可能以某种方式影响系统的输出,但在这种情况下优化是重要的。
在学习优化之前,我们将先看看它的必要性。为了检查优化的空间,我们必须首先分析代码,而分析代码的主要方法是使用复杂性分析。算法复杂性分析是一个工具,它将解释程序随着程序大小的增加而表现的行为。当输入到程序中增加时,程序的大小也会增加。因此,我们必须根据数学f(n)函数来检查程序,其中n代表程序的输入。现在,你可能会想知道,运行这个算法是否会导致时间单位的差异,这取决于诸如 NASA 或苹果公司等公司使用的不同计算机,它们的处理能力比我们的简单计算机要高。因此,对我们的 PC 上运行的算法进行评判可能是不公平的。如果你曾经面对这种模棱两可的情况,那么恭喜你,因为你正在像程序员一样思考。为了测试算法是否独立于处理速度、磁盘能力和强大的软件,科学家们开发了一种称为渐近分析的东西。这种分析将检查算法与输入的大小,并且不记录执行所需的时间。我们称之为时间复杂度,它允许我们检查算法在输入数据大小方面的运行情况。为了观察算法的时间复杂度,我们应该使用最好和最知名的符号,即大 O 符号。这个符号将帮助我们分析算法的最坏情况,并帮助我们优化它。让我们使用一些简单的例子来分析以下复杂性:
O(1): 这个符号用来定义与输入大小无关的算法。增加或减少输入数据的任何集合可能不会影响算法的执行速度:
arr = [1,2,3,4,5]
for i in arr:
print(arr[0])
前面的程序将打印数组的第一个元素,无论其中的数据是什么。因此,它的时间复杂度为O(1)。这被认为是最佳情况,很难在现实情况中实现。
O(n): 这个符号描述了随着输入数据的大小(n)的增加,算法的运行时间将呈线性增加。例如,在下面的程序中,最坏情况可能导致我们遍历整个列表。因此,性能取决于输入的大小:
n = int(input("Enter any number"))
for i in range(1,100):
if i == n:
print(i)
break
O(n²): 这个符号指定了与输入数据的平方大小成正比的算法的性能。在嵌套循环中非常常见。
还有一些其他符号,比如O(2^N)和O(log N),但我们不需要再深入了解,因为我们已经学到足够多,可以区分好的代码和坏的代码。
现在我们已经获得了足够的关于优化的信息,以及我们如何分析算法的方式,是时候看一些例子来澄清非优化和优化代码之间的差异了。在深入分析以下代码的算法之前,我们将学习如何分析程序的复杂性。由于本书不打算教授高级算法概念,我们将研究评估性能和优化的基本思想。这将为您提供一个工具,帮助您编写更短、可读且不浪费内存资源的程序。因此,这种实践将使我们能够在根据场景的资源的有效使用方面,即时间和内存,区分算法时做出正确的决策。让我们开始看一下以下代码:
for i in range(1, 10):
for j in range(i):
print(i, end='')
print()
#output
1
22
333
4444
55555
666666
7777777
88888888
999999999
在前面的代码中,我们使用了两个嵌套的for循环来获得所需的输出。在第一个 for 循环的情况下,它逐个获取范围的所有元素,并且在每次迭代时,我们进行第二个 for 循环。对于第二个循环,我们将有相同元素的相同数量的范围计数。例如,对于元素 2,我们将在第二个 j 循环中得到[2,2],从而多次打印相同的数字。如果您正确地遵循了前面的章节,那么这段代码应该不难理解。现在,让我们观察有趣的部分。我们知道第一个 i^([-])循环将迭代整个数据集的范围,这将导致时间复杂度为O(n)。j 循环也是如此。因此,总时间复杂度将是O(n) * O(n),这将导致O(n²)。这代表了一个昂贵的算法。我们必须尝试将嵌套循环的程序转换为单个循环,如下所示:
for i in range(1, 10):
print (str(i) * i)
#output
1
22
333
4444
55555
666666
7777777
88888888
999999999
前面的程序包含一个单独的 for 循环,因此它将一次循环整个数据集,结果只会是O(n)而不是O(n²)。
您可能想知道为什么这些东西如此重要,为什么我们在本章中涵盖了它们。答案很简单。尽管在 Python 编写的某些应用程序中,即 Android 应用程序或网站,节省几毫秒可能是不必要的。但是,在处理大量数据的大型应用程序中,这种时间测量可能会增加。例如,让我们想象一个应用程序调用一个函数来预测新闻是否是假的。假设非优化的代码需要几秒钟来进行预测,而优化则需要几毫秒。在这里,数量似乎很小,但想象一下我们调用相同的函数 100 万次。现在,计算整体上将节省的时间:277.5 小时。
这很麻烦,不是吗?Python 提供了两种构造来促进这些庞大数据集的更快和更有效的处理:推导和生成器。推导有三种类型,即列表、字典和集合。首先,我们将深入学习列表推导。然后,我们将通过与它们相关联来探索另外两种(字典和集合)。
循环与列表推导
自从第三章以来,我们一直在使用循环编写我们的程序,流程控制-为您的游戏构建决策制造者,我们对循环模式非常熟悉,特别是对于循环。它们将迭代一些项目,并且在每次迭代时,迭代变量将执行一些操作。通过将 for 循环与适当的数据结构结合使用,可以减轻 for 循环的强大力量,就像这样:
new_list = []
for i in range(10):
new_list.append(i)
print(new_list)
#output
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Python 还有另一种更简单的方法来做同样的事情,即列表推导。列表推导的输出将始终是一个列表,这将是在 for 循环上下文中表达式评估的结果。这之后是条件。使用列表推导通过表达式和条件模拟for循环的代码将是单行代码。因此,使用列表推导编写的代码更短,更易于维护。要理解列表推导的工作原理,我们必须熟悉其模式。我们将在下一节学习列表推导模式。
列表推导模式
在本节中,我们将使用列表推导修改之前由for循环编写的代码。列表推导的结果是一个列表。方括号内的模式是一个表达式,后面跟着一个循环,如下所示:
new_list = [i for i in range(10)]
print(new_list)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
在上面的代码中,左侧的对象,即new_list代表存储列表推导结果的输出列表。在右侧的表达式中,由方括号括起来的语句将导致列表推导。首先,我们传递要执行的表达式,然后是循环和条件(如果有的话)。以下插图表示了列表推导的模式:

让我们看一个简单的例子来解释上述模式:
even_power = [i * i for i in range(5) if i % 2 == 0]
print(even_power)
[0, 4, 16]
方括号内的第一个语句表示一个表达式。在使用列表推导时,只能有一个单一的表达式,而不像for循环的主体那样。在表达式之后,我们应用空格并提供迭代。我们也可以添加嵌套循环。在添加迭代之后,我们必须指定条件,如果有的话。列表推导广泛用于连接两个列表的元素并创建一个新的列表,如下所示:
numbers = [1,2,3,4,5]
alphabets = ['a','b','c','d','e']
new_list = [[n,a] for n in numbers for a in alphabets]
print(new_list)
[[1, 'a'], [1, 'b'], [1, 'c'], [1, 'd'], [1, 'e'], [2, 'a'], [2, 'b'], [2, 'c'], [2, 'd'], [2, 'e'], [3, 'a'], [3, 'b'], [3, 'c'], [3, 'd'], [3, 'e'], [4, 'a'], [4, 'b'], [4, 'c'], [4, 'd'], [4, 'e'], [5, 'a'], [5, 'b'], [5, 'c'], [5, 'd'], [5, 'e']]
上面的代码能够创建一个复杂的列表。推导不仅限于列表;还有字典和集合的推导。对于列表,我们使用方括号进行推导。对于集合和字典推导,我们需要使用大括号{}。然而,这些推导的模式对所有这些推导都是相似的。让我们看一个例子:
dict_comp = {x:chr(65+x) for x in range(1, 6)}
print(dict_comp)
{1: 'B', 2: 'C', 3: 'D', 4: 'E', 5: 'F'}
上面的代码表示了字典推导的用法。模式与列表推导类似,只是我们使用大括号进行推导。字典推导的结果将是一个字典。同样,在集合推导的情况下,推导的结果将是一个集合。这在下面的代码中显示:
set_comp = {x ** 2 for x in range(5) if x % 2 == 0}
type(set_comp)
print(set_comp)
#output
<class 'set'>
{0, 16, 4}
在结束本节之前,我们必须介绍 Python 的两个强大的内置函数,它们可以比以往更快地操作集合的数据。如果你曾经有机会学习大数据,你应该听说过这两个词:zip 和 map。Python 提供了这两个函数,以便在最小的负载和更快的计算下处理大量数据。让我们看一个简单的例子来理解 zip 和 map 的概念。假设我们有两个包含有限整数的列表。现在,你必须编写一个程序来创建一个新的列表,该列表将存储每个列表中的最小数字。将对具有相同索引的元素进行比较:
Input: a = [2,3,4,5,6,7] and b = [0,3,2,1,3,4]
Output: [0, 3, 2, 1, 3, 4]
最简单和常规的方法如下所示:
a = [2,3,4,5,6,7]
b = [0,3,2,1,3,4]
result = []
length = len(a)
for i in range(length):
result.append(min(a[i],b[i]))
print(result)
#output
[0, 3, 2, 1, 3, 4]
现在,让我们学习另一种执行上述计算的方法。这是使用zip和map函数制作的单行代码。zip函数是一个简单的 Python 内置函数,它接受两个相等长度的对象并将它们合并在一起。如果你将两个相等长度的列表传递给zip函数,它将把它们合并成一个,以便在单个对象内执行计算。这在下面的代码中显示:
>>> numbers = [1,2,3]
>>> letters = ['a','b','c']
>>> list(zip(numbers,letters))
[(1, 'a'), (2, 'b'), (3, 'c')]
我们知道应该进行数字之间的比较,因为它们具有相同的索引。因此,我们可以使用zip函数将原始数字数组与zip函数结合起来,以便我们可以将数字的元组存储在单个列表中,就像这样:
>>> list(zip(a,b))
[(2, 0), (3, 3), (4, 2), (5, 1), (6, 3), (7, 4)]
映射函数
编程的主要任务是执行计算。对元素进行的操作可以独立于彼此进行;也就是说,我们可以分别对列表 a 和 b 进行比较,就像我们在前面的代码中所做的那样,或者简单地将它们合并,以便可以更快地进行比较。zip方法能够将长度相同的两个对象合并为一个新的可迭代对象。现在,主要任务是创建一个比较操作,并将其应用于可迭代对象的每个元素,这是通过使用map函数来完成的。map函数采用某个函数,并将其应用于可迭代对象的每个元素。
根据 Python 的官方文档,可以描述map如下:
Map 将函数应用于可迭代对象的每个项目,并返回结果列表。如果传递了额外的可迭代参数,则函数必须采用相同数量的参数,并且并行应用于所有可迭代对象的项目。如果一个可迭代对象比另一个短,则假定它将被扩展为 None 项目。如果函数为 None,则假定为恒等函数;如果有多个参数,map()返回一个由包含所有可迭代对象对应项目的元组的列表(一种转置操作)。可迭代参数可以是序列或任何可迭代对象;结果始终是列表。
调用 map 函数时传递的参数是一个函数,后面跟着可迭代对象。通常,我们使用匿名或 lambda 函数,例如some_function,它接受一些位置参数并将它们作为元组返回。这在以下代码中显示:
map(some_function, some_iterables)
让我们创建一个简单的示例来说明map函数的用法:
>>> map(lambda x: x*2, (1,2,3,4))
<map object at 0x057E9AF0>
前面的代码并不成功,因为map函数并不返回任何可迭代对象。相反,它打印表示地图对象的字符串。为了实现期望的结果,我们必须使用列表构造函数包装map方法,就像这样:
>>> list(map(lambda x: x*2, (1,2,3,4)))
[2, 4, 6, 8]
现在,我们将使用map和zip函数的概念来找到两个列表中最小元素的列表。以下代码非常简单;我们首先定义了两个数组。之后,我们使用map函数,它将采用包含比较操作的lambda函数和zip方法,并将两个数组合并为元组列表。zip方法生成的每对元组都会传递给比较的lambda函数:
>>> a = [2,3,4,5,6,7]
>>> b = [0,3,2,1,3,4]
>>> list(map(lambda pair: min(pair), zip(a,b)))
[0, 3, 2, 1, 3, 4]
借助map和zip的功能,我们可以做任何事情,类似于列表推导。通过使用列表推导、map函数和 for 循环完成的前面的程序,我们可以看到以下运行时性能:
For Loop: 4.56s
List comprehension: 2.345s
Map: 2..11s
因此,Python 的这三个特性主要使集合的操作比任何其他操作都更快。但就代码的可维护性和可读性而言,列表推导在提供有效定制程序内部工作方式的方法方面名列前茅。现在,是时候了解 Python 的另一个特性,即装饰器。这使我们能够修改现有对象的功能,而不修改其当前结构。
装饰器
装饰器是一种设计模式,它在不改变原始结构的情况下为现有对象添加新功能。我们必须习惯于 Python 中的一切都是对象,甚至函数也是对象。用于定义这些对象的不同名称只是它们的标识符。让我们运行以下代码:
def fun1(info):
print(info)
fun1("Good Morning")
fun2 = fun1
fun2("Good Morning")
当我们运行上面的代码时,fun1和fun2函数打印出相同的输出"Good Morning",因为两者都指向相同的对象(函数)。因此,函数只是带有属性的对象。让我们回到装饰器。基本上,装饰器是一个构造,其中程序的一部分试图在编译时改变程序的另一部分的行为。对于函数来说,装饰器接受一个函数,为其添加独特的功能,最终返回它,如下所示:
def decorate_it(func):
def inner():
print("Decorated")
func()
return inner
def non_Decorated():
print("Not-Decorated")
现在,让我们尝试从 Python shell 中运行上面的代码:
>>> non_Decorated()
Not-Decorated
#now try to decorate the above function
>>> decorate = decorate_it(non_Decorated)
>>> decorate()
Decorated
Not-Decorated
在上面的例子中,decorate_it()是一个装饰器,它以未装饰的函数作为参数。decorate = decorate_it(non_Decorated)语句是一个赋值语句,其中Non_Decorated函数被传递给装饰器,它返回了名为 decorate 的函数。因此,我们可以得出结论,装饰器是可调用的,返回一个可调用的。在上面的例子中,我们可以看到decorate_it()装饰器为non_Decorated或普通函数添加了一些功能。当装饰器开始变得有名气时,引入的设计模式是首先装饰函数,然后返回第二个可调用对象的名称,就像我们在这个例子中所做的那样。然而,程序员们发现这项工作是多余的。因此,他们开发了另一种语法,简化了前面的结构:使用@符号。
要装饰一个普通函数,我们使用@符号,加上装饰器的名称,并将其放在未装饰的函数的顶部,如下所示:
@decorate_it
def non_Decorated():
print("Not-Decorated")
上面的代码是下面的代码的辅助,我们之前写过:
def non_Decorated():
print("Not-Decorated")
decorate = decorate_it(non_Decorated)
让我们看另一个例子。我们想制作一个装饰器,它就像一个异常处理程序,当程序遇到异常活动时抛出错误消息。上面的装饰器很简单,因为它不关心传递给内部函数的参数。现在,我们将制作一个程序,它将乘以任意两个数字,但也处理错误,如果传递了其他数据,比如字符串或复数:
def multiply(a,b):
print(a*b)
>>> multiply(2,5)
10
>>> multiply('c', 'f')
TypeError: can't multiply sequence by non-int of type 'str'
现在,我们将尝试制作一个装饰器,它将检查我们是否得到了异常,就像在上面的代码中一样,并自动处理它:
def smart_multiply(func):
def inner(a,b):
if (a.isdigit() and b.isdigit()):
a = int(a)
b = int(b)
print("multiplying",a," with ",b)
return func(a,b)
else:
print("Whoops!! Not valid multiplication")
return
return inner
@smart_multiply
def multiply(a,b):
print(a*b)
a = input("value of a: ")
b = input("value of b: ")
multiply(a,b)
一旦你运行了上面的代码,你将被要求在 Python Shell 中输入条目。你必须为a和b输入两个实体,然后代码就会完成剩下的工作:
value of a: 4
value of b: 5
multiplying 4 with 5
20
让我们再次运行上面的代码。这一次,我们将把a和b的值输入为字符串:
value of a: t
value of b: y
Whoops!! Not valid multiplication
正如你所看到的,装饰器的inner函数具有与未装饰函数传入的参数相同的数量。因此,可以使用inner(*args, **kwargs)进行泛化,其中args是位置参数的元组,kwargs表示关键字参数的字典。现在,我们可以制作能够处理任意数量参数的装饰器,如下所示:
def universal(func):
def inner(*args, **kwargs):
print("It works for any function")
return func(*args,**kwargs)
return inner
因此,在编译时,装饰器修改了原始函数、方法甚至类的操作,而不改变被装饰对象的代码。这最终导致了不要重复自己(DRY)技术的使用。在下一节中,我们将学习@property装饰器 - Python 的内置装饰器,用于实现property()函数。正如你可能还记得上一章所述,@property的这种构造已经被使用,并且它被定义为实现 getter 和 setter 的 Pythonic 方式。现在,我们将详细了解它。
Python 属性
要理解首先使用属性的用法,我们必须回顾面向对象范式的一个原则:数据封装。这将数据与方法捆绑为一个单一的胶囊。将要获取和设置类的属性的方法称为 getter 和 setter。面向对象编程的这一原则暗示了类的属性必须私有化,以防止意外修改或盗窃。让我们从一个简单的例子开始:
class Speed:
def __init__(self, speed = 0):
self.speed = speed
def change_to_mile(self):
return (self.speed*0.6213,"miles")
在上述代码中,我们创建了一个名为Speed的类,用于存储车辆的速度(以公里为单位)。它有一个members方法,用于将公里转换为英里。现在,我们可以创建Speed类的对象,并随意操纵这个类的成员。我们将使用 Python Shell 进行操作,如下所示:
>>> car = Speed()
>>> car.speed = 45
>>> car.speed
45
>>> car.change_to_mile()
(27.958499999999997, ' miles')
每当对类的属性进行赋值时,Python 解释器都会维护一个字典,其中属性及其值被维护为键和值。在Speed类的情况下,我们可以使用__dict__ 属性检索对象的任何属性,即speed:
>>> car.__dict__ {'speed': 45}
因此,每当我们执行car.speed操作时,Python 解释器会在上述字典中进行搜索,并将值提取为car.__dict__['speed']。
现在,假设上述代码在交通控制领域全球范围内变得流行。有一天,交通警察提出应该对车辆的速度进行约束,以便可以执行法律。现在,我们必须修改代码,以便如果任何驾驶员驾驶速度过快,程序会向他们提供警告消息。我们可以使用 getter 和 setter 来实现这一点。在setter方法内部,我们可以使用条件语句明确检查车辆的最高速度。可以这样做:
class Speed:
def __init__(self, speed = 0):
self.set_speed(speed)
def change_to_mile(self):
return (self.get_speed*0.6213," miles")
#new updates are made as follows using getter and setter
def get_speed(self):
return self._speed
def set_speed(self, km):
if km > 50:
raise ValueError("You are liable to speeding ticket")
self._speed = km
在上述代码中,进行了两个重大修改,我们对它们很熟悉。它们是getter: get_speed方法和setter: set_speed方法。在代码中进行的另一个更改是属性的签名。速度属性以单下划线开头,这使其成为私有属性(数据封装)。在 Python Shell 中尝试以下代码:
>>> car = Speed(30)
>>> car.get_speed()
30
>>> car.set_speed(38)
>>> car.get_speed()
38
>>> car.set_speed(70)
ValueError: You are liable to speeding ticket
对原始程序的更新成功地反映了新的限制范围。驾驶员不被允许以超过 50 公里/小时的速度驾驶他们的车辆。
现在,让我们运行上述代码,并观察新更新可能引起的开销。我们可以简单地比较使用 getter 和 setter 编写的代码与不使用它们编写的代码。当您尝试调整原始代码以适应新更改时,将会出现一个主要的头痛问题,因为您必须修改代码,从调用car.speed对象的属性到调用car.get_speed()的属性。构造函数必须更改为car.set_speed(speed)。我们可能会发现在这个程序中进行更改更容易,但是想象一下,如果程序有 10,000 多行代码。对于任何程序员来说,更新和与新代码同步将是一件困难的事情。现在,属性装饰器开始发挥作用。以下代码为我们解决了这个问题:
class Speed:
def __init__(self, speed = 0):
self.speed = speed
def change_to_mile(self):
return (self.speed*0.6213," miles")
@property
def speed(self):
return self._speed
@speed.setter
def speed(self,km):
if km > 50:
raise ValueError("You are liable to speeding ticket")
self._speed = km
由于我们熟悉装饰器,上述构造对我们来说应该是熟悉的。现在,让我们在 Python Shell 中运行我们的代码:
>>> car = Speed(40)
>>> car.speed
40
使用属性构造,我们修改了原始类并提供了一些约束。但是这一次,我们移除了我们所做的更改,比如由 getter 和 setter 添加的get_speed和set_speed。因此,交通控制系统可以使用这个新代码,而不需要对原始代码进行任何更改,这导致了向后兼容性。
我们还有另一种实现上述代码的方法,那就是使用property()函数。以下代码等同于使用@属性构造编写的上述代码:
class Speed:
def __init__(self, speed = 0):
self.speed = speed
def change_to_mile(self):
return (self.speed*0.6213," miles")
def get_speed(self):
return self._speed
def set_speed(self, km):
if km > 50:
raise ValueError("You are liable to speeding ticket")
self._speed = km
#using property
speed = property(get_speed,set_speed)
前面代码的最后一行创建了一个 speed 属性的对象。请记住,属性必须由可能被更改的属性组成。我们添加了一些代码,创建了属性的对象,并在括号内传递了 getter 和 setter 方法。现在,任何使用 speed 值的程序都会自动调用get_speed方法,任何分配 speed 值的程序都会调用set_speed方法,而无需查找由类管理的dictionary(obj.__dict__)。
现在,让我们利用本章学到的列表理解和属性知识来修改我们的蛇游戏。
使用 LC 和属性完善蛇游戏
这一部分将尽可能保持简洁,因为没有新内容可覆盖。现在我们已经详细学习了列表理解和属性,我们应该能够快速地覆盖这个主题,就像我们在上一章的总结中讨论的那样。简而言之:列表理解是一种从其他可迭代对象创建新元素列表的技术。列表理解语句由包含必须对每个元素进行转换的方括号组成,以及一个 for 循环。然后是一些条件。另一方面,@property或property()构造是实现 getter 和 setter 的 Pythonic 方式。
让我们来看看我们可以对蛇游戏进行的一些改进:
- 首先,我们可以创建一个函数,检查蛇与边界或自身的碰撞。例如,如果蛇头的坐标(x,y)与其身体的坐标相同,我们就有了碰撞。这个逻辑可以用列表理解来实现:
[body.coor == self.head.coor for body in self.body_list[:-1]]。以下表达式将在结果列表中存储一个 True 或 False 的布尔值。对于蛇身的每个位置,都会进行body.coor == self.head.coor的比较。以下代码行表示一个函数,根据碰撞检查返回 True 或 False:
def collided(self):
return any([body.coor == self.head.coor
for body in self.body_list[:-1]])
- 其次,我们可以用
@property构造装饰前面的方法。由于我们已经详细介绍过,这不应该对我们造成任何困惑。如果有的话,让我来解释一下。@property的主要用途是支持向后兼容。我们可以修改类的规范并实现约束,而不实际修改分发给客户的先前版本的代码。同样,我们可以用@property装饰一个得分函数,因为我们需要更新它的时间值。因此,为了不断地将得分方法作为属性访问,我们可以像这样添加我们之前装饰的属性:
@property
def score(self):
return 'Score : {0}'.format(self.score)
属性和列表理解的前面实现都是使代码更易读和易维护的简单有效的方式。在企业级 Python 编程中,我们经常会发现这些类型的构造。
总结
本章揭示了理解和生成的高级概念,接着是一些示例及其在现实世界中的应用。我们看到了理解的用法和 Python 的一些内置函数,比如 map 和 zip,它们超越了 for 循环的性能。虽然这些理解和映射的概念可能被高估了,但如果我们有大量代码需要考虑性能而不是代码可读性,通常会发现它们很有帮助。本章还探讨了装饰器,它为现有代码添加了一些额外功能,而不影响其原始内容。然后,我们学习了属性装饰器的概念,这是一种 Pythonic 的实现方式,可以实现 getter 和 setter,同时保持向后兼容的代码。
从下一章开始,我们的主要目标可能会倾向于游戏编程。我们已经成功学习了 Python 的基本知识,以便成为熟练的游戏程序员。现在,我们将学习关于图形用户界面以及使用 Python 提供的模块(如 turtle 和 pygame)制作图形界面的方法。但在我们跳到下一章之前,请确保你已经正确地使用我们迄今为止编写的代码。对于任何程序员来说,能够逐行阅读代码是非常重要的。如果你已经对自己的技能有足够的信心,那么可以继续到下一章,我们将学习 turtle 模块,这是在游戏屏幕上绘制形状的基本方法。
第八章:海龟类 - 在屏幕上绘制
不久以前,程序员,尤其是游戏程序员,在构建程序时会面临许多复杂性。难怪!那时,互联网门户的帮助还不够,包括没有堆栈溢出,更重要的是,程序员没有可以使用的通用工具;他们必须首先创建一个工具,然后在程序中使用它。他们创建的工具将处理一些游戏特定的内容(用于声音和图形的特定驱动程序)。由于资源稀缺,程序员不得不使用汇编语言创建游戏,这将是处理能力、显示、声音和控制例程的权衡。甚至在调试时也会遇到最糟糕的情况。他们需要复杂昂贵的机器来复制他们的程序,他们还需要日志记录和调试扩展。本章的主要目标是让我们熟悉使用海龟进行二维(2D)空间绘图,以及海龟的事件处理方法,并创建简单的 2D 空闲动画。
在撰写本文时,我们在游戏行业取得了巨大进步。我们已经创建了工具,使我们能够使用任何编程语言制作游戏,例如 Python 和 C(对 CPU 要求低的游戏)。由于设备驱动程序的通信,所有低级例程都被高级软件隐藏起来。Python 等高级语言是抽象的;它们提供较少的访问权限以获取低级功能。我们可以将多个东西组合在一起作为类,这些类可以从另一个类继承特性,从而消除了代码的重复。Python 提供了海龟和 Pygame 等模块,其中包含了大量用于设计游戏角色和处理用户事件的方法。在本章中,我们将学习有关海龟模块的知识。从本章开始构建的每个东西都将使用前面章节的技术,同时还会添加一些显著的特性。
本章将涵盖以下主题:
-
海龟概述
-
技术要求
-
海龟命令简介
-
海龟事件
-
使用海龟绘制形状
技术要求
本节将带您了解基本的 Python 图形编程模块及其工作原理。因此,您需要以下资源:
-
Python 3.5 或更高版本;参见第一章,了解 Python - 设置 Python 和编辑器
-
Python IDLE
-
一个文本编辑器
-
一个键盘
-
一个鼠标(笔记本电脑的触摸板无法使用)
本章的文件可以在此处找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter08
观看以下视频以查看代码的运行情况:
了解海龟模块
就像计算机的不同组件同样重要以提供更好的计算体验一样,我们也需要计算机的不同组件共同工作,以提供更好的游戏体验。计算机的显卡负责计算屏幕的视觉图像,然后在发送到显示器之前对图像信号进行模块化。输入设备如鼠标、键盘和游戏手柄需要根据程序处理用户事件。音频卡需要处理音频信号,然后将其发送到扬声器等输出设备。在游戏编程的早期阶段,程序员需要分别阅读每个设备的技术手册,并在隔离状态下编写每个设备的代码。这意味着即使是简单的游戏,它们之间的通信也需要花费一年的时间。然而,随着技术的进步,特别是驱动程序的进步,程序员免除了手动处理这些设备与操作系统之间的通信的烦恼。
尽管我们开发了一个称为驱动程序的简单程序,它作为与不同设备通信的通用接口,但不同的硬件和版本不兼容性使得程序员在开发可以在多个平台上玩的游戏时更加困难。幸运的是,我们有 Python,一种具有使程序可以跨平台的能力的语言。Turtle 是 Python 模块,提供了可以用来创建图片和图形的绘图板。据说海龟模块是上世纪 90 年代另一种流行编程语言Logo的姐妹模块,Logo有一个想象中的海龟图标和一个用于在屏幕上绘制的笔。Python 的标准库turtle类似于 Logo 编程语言。为了使用海龟模块,我们必须导入它。导入它更容易,因为它作为标准 Python 库打包,不需要手动安装。以下步骤解释了如何制作任何海龟应用程序:
-
使用
import命令导入海龟模块。如果忽略这一步,就不会有控制海龟的界面。 -
创建一个控制海龟。这一步用于实例化海龟,以创建一个新的海龟控制器,例如,
game = turtle.Turtle()。 -
创建控制后,我们可以通过调用海龟模块的方法在绘图屏幕上绘制和执行多个任务。
-
我们需要显式调用一个重要的方法,它持有游戏屏幕,即
turtle.done()。这个方法会暂停程序。您需要手动关闭窗口以关闭应用程序。
在海龟包中,当我们运行通过调用海龟模块的方法制作的程序时,将会出现一个新窗口,带有一支笔,以及由海龟命令绘制的形状。让我们了解一些重要的海龟命令。
介绍海龟命令
海龟模块带有多个命令,以方法的形式独立使用。有一些方法可以使笔向前和向后移动,还有一些可以创建形状。查看下表,了解最重要的海龟命令。您可以在官方 Python 文档页面上详细了解它们:
| 方法 | 参数 | 描述 |
|---|---|---|
Turtle() |
无 | 创建并返回一个新的海龟对象。 |
forward() |
距离 | 将海龟向前移动指定的距离。 |
backward() |
距离 | 将海龟向后移动指定的距离。 |
right() |
角度 | 将海龟顺时针旋转。 |
left() |
角度 | 将海龟逆时针旋转。 |
penup() |
无 | 抬起海龟的笔。 |
pendown() |
无 | 放下海龟的笔。 |
up() |
无 | 抬起海龟的笔。 |
down() |
None | 放下海龟的笔。 |
color() |
颜色名称 | 更改海龟笔的颜色。 |
fillcolor() |
颜色名称 | 更改海龟用于填充多边形的颜色。 |
heading() |
None | 返回当前的方向。 |
position() |
None | 返回当前位置。 |
goto() |
x, y (位置) | 将海龟移动到位置 x, y。 |
begin_fill() |
None | 记住填充多边形的起点。 |
end_fill() |
None | 关闭多边形并用当前填充颜色填充它。 |
dot() |
None | 在当前位置留下一个点。 |
stamp() |
None | 在当前位置留下一个海龟形状的印记。 |
shape() |
形状名称 | 应该是 arrow, classic, turtle, 或 circle。 |
在上面的表格中,我们可以通过观察方法名称的字面意义来猜测调用这些方法的结果。例如,forward(amount) 方法将以指定的参数作为参数向前移动笔。所有这些方法都用于在海龟的绘图画布中绘制不同的形状。观察第一个 >>> Turtle() 方法。这将返回海龟的对象,必须使用该对象来调用这些方法。例如,我们将编写一个程序,该程序将在屏幕上绘制一条线。以下是此示例的代码:
import turtle
pacman = turtle.Turtle()
pacman.forward(100)
turtle.done()
通过运行上面的代码,我们可以观察到以下输出:

连同 Python shell 一起,新屏幕应该像之前的那个一样弹出,这代表了海龟绘图板。最初,附着在虚拟海龟上的笔将驻留在绘图板的中心。海龟对象的任何方法调用都必须操纵笔的移动。上面的代码可以通过以下步骤来解释:
-
首先,我们必须导入 turtle,这是一个第一步,将确保海龟类中的所有命令对我们可用。
-
第二步是创建一个海龟控制器,我们称之为吃豆人。
-
然后,我们从吃豆人面对的点向前移动 100 像素。最初,吃豆人 海龟控制器是朝右的;因此,笔从中心向右移动 100 像素,形成了一条直线。
-
最后,
turtle.done()将暂停海龟绘图板屏幕,以便我们可以清楚地观察输出。为了关闭海龟屏幕,我们必须手动关闭 Python shell 或海龟图形屏幕。
我们刚刚学会了如何创建一条直线,但是这些线看起来很无聊,对程序没有任何美感。现在是学习如何使用另一个方法的时候了,这个方法将转动笔到另一个方向。例如,我们可能想要将笔的方向从最初的方向改变到另一个方向:
import turtle
pacman = turtle.Turtle()
pacman.forward(50)
pacman.right(90)
pacman.forward(50)
pacman.right(90)
pacman.forward(50)
pacman.right(90)
pacman.forward(50)
pacman.right(90)
turtle.done()
我们已经熟悉了forward方法,现在我们引入了right()方法。如果你看一下之前的方法表,你会发现right方法和角度作为参数传递了进去。因此,这个方法将执行一些旋转,并伴随着传递进去的角度。由于我们传递了 90 度,这个方法将进行一个 90 度的顺时针旋转。如果你想要将笔逆时针旋转,我们需要调用 left 方法并指定旋转的角度。在前面的程序中,我们将它旋转了 90 度。所有角度都是 90 度的几何形状要么是正方形,要么是长方形。然而,我们知道forward方法会产生一条直线,这与几何形状的边是一样的。由forward方法创建的边的长度是相等的,为 50,这作为forward方法的参数传递进去。有了这些证据,我们可以肯定地期望在乌龟画板上画出一个正方形形状。让我们运行前面的代码来观察输出。正如预期的那样,画出了正方形形状:

仔细看一下前面的代码;你看到了一些代码的重复吗?显然,forward和left方法的调用被多次执行,这最终违反了 DRY 原则。这种顿悟并非没有练习 Python 范式而来。因此,我们可以说练习是区分好坏程序员的关键。现在,回想一下我们需要什么来消除代码的冗余;我们应该使用循环或函数。我们将在这里使用一个循环:
import turtle
pacman = turtle.Turtle()
for i in range(4):
pacman.forward(50)
pacman.right(90)
turtle.done()
我猜我们在阅读和理解这段代码时不会遇到任何问题。正如我们在第三章中提到的,流程控制 - 为你的游戏构建决策者,我们可以使用一系列函数创建迭代级别。由于我们需要运行这些方法四次,我们使用了 range 函数创建了四次迭代。任何需要重复的内容都会在 for 循环的范围内缩进四个块。
在这个例子中需要注意的一点是,我们有多个处理画板上笔的移动的方法。到目前为止我们学到的两个乌龟命令是forward(amount),它将乌龟向它所面对的方向前进一定距离,以及right(degree),它使乌龟顺时针旋转指定的角度。请注意,right和left命令不会在屏幕上写任何东西;它们只用于旋转。
根据我们迄今为止学到的一切模式,我们可以预测backward方法将会将笔从原来的方向向后移动指定的距离。我建议你尝试稍微修改前面的代码 - 通过使用backward重构forward方法,通过使用left重构right - 并相应地观察结果。我想在这里花点时间总结这个话题,而不涉及其他函数,因为我们将在接下来的章节中制作游戏时逐个学习它们。我们将制作多个游戏,比如蛇游戏、乒乓球游戏和使用乌龟模块的 Flappy Bird。现在,我们将探索如何连接输入设备,比如鼠标和键盘,到我们的游戏中,以便玩家可以与乌龟环境进行交互。
探索乌龟事件
正如我们在前面的章节中提到的,处理用户事件是创建任何游戏的主要构建块之一。事件代表了在游戏过程中任何时候需要执行的动作。你是否曾经想过程序是如何在低层次处理事件的?当用户使用键盘或鼠标执行任何事件时,该请求被存储在一个类似队列的结构中。队列结构很重要,因为处理这些事件的顺序必须是先来先服务的。然后,根据用户操作的行为,程序处理事件。这两个任务——渲染和动作处理——由程序独立执行。例如,在反恐精英游戏中,用户可以从枪中射击,即使周围没有敌人。这里,事件是用户按键开枪,渲染任务是在玩家周围生成敌人。除非我们编写程序来执行这两个任务,否则这两个任务不会独立执行。在本节中,我们将学习如何将用户动作作为输入,并相应处理。处理用户动作意味着服务存储在队列结构中的动作。
大多数事件都是基于鼠标或键盘的使用,但有些事件必须由程序自动预测并相应处理,比如ontimer(fun, time)方法。这个方法接受两个参数:函数和毫秒时间。这个方法设置一个定时器,在time毫秒后调用fun函数。让我们做一个简单的程序来理解这一点:
import turtle
star = turtle.Turtle()
exit = False
def main():
if not exit:
for i in range(100):
star.forward(i)
star.right(144)
main()
turtle.mainloop()
代码的最后一行(turtle.mainloop())只是执行了在循环中执行的相同操作。直到用户明确退出窗口屏幕,对main函数的调用才会终止。当程序有一个用于监听传入连接的 while 循环时,它的重要性就会显现出来,但我们不希望计算机一直专注于这一情况:
def draw_objects():
#statements
draw_objects() #may be you want to call it within the time interval
of 100ms
draw_objects()
turtle.mainloop()
前面的代码与 while 循环的工作方式完全相同,但现在 Python 解析器不再专门执行一个任务。相反,每 100 毫秒,draw_objects()任务将被执行,而剩下的 99.99 毫秒,Python 解析器可以自由执行任何其他任务。
有趣的是,前面的代码代表了任何 turtle 程序的正确结果。虽然调用不同的函数会在屏幕上显示不同的字符,但使用 turtle 的主要目的是将游戏角色渲染到屏幕上。让我们将前面的代码分解成以下几点:
-
前几步代表着导入 turtle 并创建一个 turtle 控制器,这将允许我们通过它调用所有的
turtle方法。 -
我们创建了一个
main函数,在其中,我们有一些代码来创建一个星形图案。迭代次数是 100 次,这意味着我们将在输出屏幕上打印 100 颗星星,但请记住,它们会很接近。
在屏幕上正确渲染字符的最佳方法是使用ontimer方法。让我们用ontimer方法修改相同的程序。让我们看看如何在程序中使用它:
import turtle
star = turtle.Turtle()
exit = False
def main():
if not exit:
star.forward(50)
star.right(144)
turtle.ontimer(main,500)
main()
与以前不同,前面的程序不会打印多个星星;相反,它打印一个单一的星星。然而,ontimer方法消除了调用 for 循环的开销,因为它设置了定时器来一遍又一遍地调用相同的函数。在这个程序中,我们传递了main函数和 500 作为参数,这意味着main函数应该在每 500 毫秒调用一次。运行前面的程序将产生以下输出:

现在是时候学习如何处理键盘和鼠标事件了。和往常一样,已经定义了用于处理键盘事件的方法和用于处理鼠标事件的方法。但是,在处理用户事件之前,乌龟必须启动一个监听器,它会持续保持清醒状态以监听任何事件。使用listen方法创建这样一个监听器控制器,即>>> turtle.listen()。以下表格描述了用于处理键盘事件的方法:
| 方法名称 | 参数 | 描述 |
|---|---|---|
turtle.onkeypress(function, key = None) |
Function:没有参数或None的函数。Key:以字符串或符号形式的键,例如,q或space。 |
用于将函数绑定到键盘上按下的任何键事件。如果未指定键,则任何键都可以使用。 |
turtle.onkeyrelease(function, key) |
Function:没有参数或None的函数。Key:以字符串形式的键,如a,或符号,如enter。 |
用于将函数绑定到键释放事件。如果函数为None,则解除事件的绑定。 |
让我们编写一个简单的程序,以便掌握使用这些处理键盘操作的方法的思想:
import turtle
star = turtle.Turtle()
def main():
for i in range(30):
star.forward(100)
star.right(144)
turtle.onkeypress(main,"space")
turtle.listen()
turtle.mainloop()
让我们运行程序并观察输出。按下F5后,你会看到两个屏幕,其中一个会有乌龟图形板和笔位于其中心。现在,按下键盘上的Spacebar键。一旦按下,它就开始在屏幕上绘制一个星星。
在main函数内部,我们添加了一些代码来制作一个星星。然而,正如你所看到的,main函数并没有被显式调用,就像我们通常调用函数一样;相反,它是使用onkeypress方法调用的。这个方法将键绑定到函数,每当按键时,函数就会自动调用。如果从前面的代码中删除最后一行,监听控制器就不会起作用。listen方法用于创建一个控制器,不断监听这些类型的操作。
以类似的方式,我们可以调用onkeyrelease方法。在前面的代码中用onkeyrelease替换onkeypress,并观察输出。输出将是相同的。onkeyrelease方法用于将函数绑定到按键释放事件。
同样,处理鼠标事件的方式也并不太不同——它们也是通过方法调用来处理的。以下表格描述了可以用来处理鼠标事件的方法:
| 方法 | 参数 | 描述 |
|---|---|---|
onclick(function, button = 1, add = None) |
Function:使用两个参数(x, y)调用一个函数,表示鼠标或指针点击位置的坐标。Button:表示鼠标按钮,默认 = 1,表示左键。Add:用于添加多个绑定。如果传递True,将添加新的绑定,否则将保持当前绑定。 |
将函数绑定到鼠标点击事件。如果用户点击乌龟画布的任何位置,将使用点击位置的坐标调用函数。 |
onrelease(function, button = 1, add = None) |
Function:使用两个参数(x, y)调用一个函数,表示乌龟绘图板上点击位置的坐标。Button:默认 = 1表示使用左鼠标按钮。用于添加鼠标按钮的数字。Add:根据True或False的值,决定是否添加新的绑定。 |
将函数绑定到鼠标按钮释放事件。 |
ondrag(function, button = 1, add = None) |
Function:带有两个参数的函数,表示点击点的坐标进入游戏屏幕。Button:添加一个数字以指示鼠标按钮监听器。 | 将函数绑定到当前海龟控制器上的鼠标移动事件。如果函数为None,则将删除当前绑定。 |
让我们制作一个简单的程序来理解如何使用前面的方法处理鼠标事件:
import turtle
pacman = turtle.Turtle()
def move(x,y):
pacman.forward(180)
print(x,y)
turtle.onclick(move) #calling move method
#turtle.onclick(None) #to remove event-binding
您可以看到onclick方法只调用了移动函数,然后移动方法使用代表点击点的x和y坐标在画布上。运行上述程序在屏幕上不会绘制任何线条,直到您点击绘图画布。当您点击屏幕上的任何点时,您将在 Python shell 中看到其坐标,并且直线将出现在画布上。我们将在接下来的章节中介绍剩余的turtle方法,以及如何制作一些小游戏。在那之前,我们将尝试使用turtle模块和迄今为止学到的 Python 设计模式来制作一些形状。
使用海龟绘制形状
制作形状的过程对人类来说可能看起来是一项乏味和繁琐的任务,但对计算机来说并非如此。想象一下在考虑角度和边的情况下制作具有精确几何测量的六边形。这个过程本身就会让我们大多数人感到不知所措。另一方面,计算机被认为是勤奋工作的;我们可以向它抛出尽可能多的任务,它会优雅地执行它们。
正如我们之前提到的,绘制任何形状时两个关键信息是每条边的角度和长度。我们可以创建变量来存储它们,以便在程序中需要时引用它们。对于任何形状,边的数量都会不同。例如,三角形有三条边,而六边形有六条边。我们需要在程序中明确指定边的数量。在本节中,我们将制作两种形状,一个六边形和一个星形,还加上一些颜色。本节的主要目的是帮助您了解编程范式是如何使用的,以及特定模块,以制作吸引人的游戏。
以下步骤列表描述了为了逐个创建两个形状所需的路线图。我们将首先创建的形状是一个六边形:一个有六条边的形状,具有自定义长度。之后,我们将再次制作星形图案,但这次我们将为其添加颜色属性:
- 六边形:我们将通过定义特定变量来创建这个形状,比如边的数量、内角和边的长度。之后,我们将使用 for 循环创建六次迭代,因为我们必须调用线渲染方法六次(因为六边形有六条边)。我们将使用
forward方法绘制一条直线,使用right方法将海龟顺时针旋转特定角度:
import turtle
hexagon = turtle.Turtle()
num_of_sides = 6
length_of_sides = 70
angle = 360.0 / num_of_sides
for i in range(num_of_sides):
hexagon.forward(length_of_sides)
hexagon.right(angle)
turtle.done()
-
您可以看到使用
turtle模块在画布上绘制形状是多么方便。我们已经熟悉了这些方法以及使用循环来消除多行代码的重复;因此,理解我们在这里编写的代码不会很难。 -
星形:使用 Turtle 制作星形比使用任何其他模块更容易。我们已经使用了
turtle的两种方法来制作它,即forward和left。但在本节中,我们将使用turtle模块提供的color方法为星形上色。我们将首先定义颜色调色板,即不同的颜色名称,并且我们将调用begin_fill和begin_end方法,这将为形状添加颜色。以下表格显示了可以用于给海龟着色的三种方法:
| 方法 | 参数 | 描述 |
|---|---|---|
color(*args) |
Args 代表颜色的名称。当前颜色用于使用forward或backward方法绘制线条。颜色名称可以作为单个值:color(“blue”),双值:color(“black”,”green”)或rgb浮点值给出。 |
用于改变乌龟笔的颜色。 |
begin_fill() |
无 | 这个方法将记住填充多边形的起始点。 |
end_fill() |
无 | 它将关闭在乌龟画布中绘制的形状并用当前填充颜色填充它。 |
例如,我们将编写一个程序,使用这些方法来给星形图案上色。我们将使用红色和黄色的颜色组合来使星星更具吸引力。我们一直在使用import turtle命令使turtle方法可供程序使用。与其这样做,我们可以使用from turtle import *命令从 turtle 中导入所有内容。现在,我们可以直接调用turtle方法,即forward(100),而不是使用>>> turtle.forward(100)来调用它。让我们编写一个程序来创建这样一个星形图案:
from turtle import *
color('red', 'yellow')
begin_fill()
while True:
forward(200)
left(170)
if abs(pos()) < 1:
break
end_fill()
done()
我非常喜欢乌龟与 Python 一起工作的方式。能够将每个函数绑定到 Python 的编程范式使得turtle模块的使用效果很好。在前面的代码中,我们可能不会对第一行代码感到困惑,它只是从turtle模块中导入了所有内容——每个属性和成员。我们使用color方法制作了红色和黄色的调色板。在主循环中,我们将遇到两种方法,这些方法我们从本章开始就一直在使用。此外,我们添加了一个条件来指示乌龟笔的停止点。abs()方法用于返回数字的绝对值,即>>> abs(-4),得到 4。在abs()函数内部,我们调用了turtle模块的pos()方法,它将返回乌龟的位置作为一个两元素列表。我们检查了当前位置,如果小于 1,例如 0,那么它必须代表中心位置,因为(0,0)代表中心位置。如果我们在迭代后遇到中心位置,那么这意味着我们可以终止程序,因为在这一点上,我们必须已经画了一个星星。如果我们继续,乌龟笔将在相同的位置上画另一个星星,从而覆盖旧的星星。
因此,为了防止这种连续迭代,我们添加了一个条件行:if abs(pos()) < 1。
执行上述程序会产生以下输出。在这里你必须记住的一件事是,从调色板开始,我们使用红色笔画星星,完成后,我们使用黄色填充星星形状的内部:

现在您已经了解了使用turtle方法创建形状并对其上色的方法,我们将在此结束本章。我们将在接下来的章节中使用本章学到的概念,如创建图案和处理用户事件,制作简单的迷你游戏,比如 Snake、Pong 和 Flappy Bird。
总结
Python 的turtle模块是构建 2D 迷你游戏的强大平台。它包含各种方法,以便简化游戏角色的设计过程。我们在本章中编写了许多程序,并处理了用户事件。我们通过介绍turtle模块的关键特性开始了本章,并为可以使用 Pythonturtle模块制作的任何游戏构建了一个通用原型。本章教会了我们如何使用turtle模块来制作 2D 画布动画。除了为游戏角色添加动画,我们还学会了如何通过处理用户事件创建游戏界面和用户控制器之间的通信接口。
完成本章后,你将能够使用turtle模块创建简单的 2D 游戏。你还将能够处理鼠标和键盘提供的用户操作,这使我们能够制作用户交互式游戏。现在你已经学会了如何使用 2D Turtle 画布创建简单的动画,你可以创建任何几何形状;在进入下一章之前再尝试几个。
本章我们没有涉及任何游戏,因为要使用turtle模块创建游戏,我们首先需要探索向量——创建向量、存储向量、找到向量的大小、向量相加、否定、对角线移动等等。我们将在下一章中涵盖所有这些概念。
向量的主题无疑是任何游戏开发者工具包中最基本的主题。向量是代表屏幕上出现的游戏角色的大小和方向的数学术语。大小表示角色所在点的当前坐标的模,而方向表示游戏角色移动的方向。现在是你玩弄turtle模块并掌握处理用户事件以及构建吸引人的形状和角色的完美时机。
第九章:数据模型实现
游戏是一种通过互动来模拟或至少模拟真实世界环境的媒介,玩家通过动作和移动来控制游戏角色。我们知道,玩家可以用键盘、鼠标或操纵杆等输入设备与游戏进行互动的方式有很多种。为了将这些输入信号转化为有意义的信息,我们需要用相应的动作来处理这些信号。在大多数游戏中,我们使用键盘按键来移动游戏角色,但在内部,这些信号是由称为向量的数学对象处理的。这对于任何游戏来说都非常重要,无论图形看起来如何,因为它导致玩家产生动作并用适当的反应来处理它们。
在本章中,我们将介绍二维向量——操纵游戏角色位置的方法。向量坐标(x,y)的变化代表了游戏玩家指定的移动。对于任何编程初学者来说,本章将是改变生活的,因为它将教会我们如何使用数学概念,如加法、减法、乘法、旋转和反射,以及数据模型实现这样的编程范式。本章的最终目标是让您熟悉使用 Python 进行运算符重载的概念,使用 Python 内置方法来操纵向量位置,以及实现数据模型或魔术函数。
本章将涵盖以下主题:
-
运算符重载概述
-
技术要求
-
处理二维向量
-
向量运动的数据模型
技术要求
本章将带领我们体验 Python 简单而强大的运算符重载概念。因此,您需要具备以下工具:
-
Python 3.5 或更新版本
-
Python IDLE(Python 内置的 IDE)
-
文本编辑器
-
网络浏览器
本章的文件可以在这里找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter09
观看以下视频以查看代码的运行情况:
理解运算符重载
这是一个新概念,对于初学者来说可能会有些模糊,但有必要了解这一知识。在编程术语中,用编程语言定义的一切都有特定的用途。例如,我们不能使用sum()方法来找到元素之间的差异。我们可以扩展任何操作的含义超出其正常用法或预定义的操作用法。以加法(+)运算符为例;这个运算符可以用来添加简单的整数,连接两个独立的字符串,甚至合并两个列表。这是因为加法运算符在不同的类中被重载,也就是说,在字符串和整数类中定义了不同的实现。这就是运算符重载的威力。
还需要记住的一点是,相同的函数或内置运算符对于多个类的对象具有不同的行为,如下例所示:
>>> 6 + 6
12
>>> "Python" + " is " + "best"
'Python is best'
>>> [1,2,3] + [4,5]
[1,2,3,4,5]
有几种方法支持运算符重载;这些被称为数据模型,有时也被称为魔术方法。之所以这样称呼,是因为这些特殊方法扩展了方法的功能,从而为我们的类增添了魔力。这些数据模型不应该由我们调用;而是在类内部自动发生。例如,当我们使用+运算符执行加法操作时,Python 解析器内部调用__add__()方法。Python 的不同内置类,如str、int、list等,都有不同的内部定义的魔术函数。我们可以使用dir函数打印专门用于特定类的魔术函数列表。例如,以下列表指示了str类中定义的几种方法和属性:
>>> dir(str)
['__add__', '__class__', '__contains__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getnewargs__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mod__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__rmod__', '__rmul__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'capitalize', 'casefold', 'center', 'count', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'format_map', 'index', 'isalnum', 'isalpha', 'isascii', 'isdecimal', 'isdigit', 'isidentifier', 'islower', 'isnumeric', 'isprintable', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']
如前面的str类的方法和属性列表所示,我们可以观察到几个以双下划线开头和结尾的方法。例如,__add__()方法用于使用+运算符连接两个字符串:
>>> first_name = "Ross"
>>> first_name.__add__(" Geller")
'Ross Geller'
在上面的示例中,我们可以看到__add__()函数的工作方式与+类似。这些数据模型旨在用于扩展具有重载行为的预定义含义。根据数学规范,我们通常使用+、-、/和*等运算符与数字对象一起使用。然而,通过重载技术,我们可以使用相同的运算符来处理多个对象,如字符串和列表。我们可以得出结论,加法运算符(+)被重载。同样,Python 为不同的运算符定义了不同的数据模型,即__sub__()用于-运算符,__mul__()用于*运算符,__div__()用于/运算符。
现在我们已经学会了如何在最基本的形式中使用数据模型来实现 add 函数,我们将实现一些自定义类的示例。
在自定义类中使用数据模型
现在我们知道如何使用__add__()魔术函数与整数和字符串等各种数据类型一起使用,让我们观察它如何在自定义(用户定义的)Python 类中使用。我们将考虑以下示例来说明数据模型在我们的自定义类中的用法:
class Base:
def __init__(self, first):
self.first = first
def add(self, other):
print(self.first + other)
我们将使用以下代码创建前面类的对象。此代码在 Python shell 中执行:
>>> obj1 = Base(1)
>>> obj2 = Base(2)
>>> obj1.add(obj2)
TypeError: unsupported operand type(s) for +: 'int' and 'Base'
正如预期的那样,我们得到一个错误,说不支持不同类型的操作数,这意味着+运算符对于添加自定义类的对象是不起作用的。如前所述,为了解决这类问题,我们可以使用运算符重载。我们可以在我们的类中显式定义这些特殊方法,以使对象与内置方法和运算符兼容。例如,在加法操作的情况下,我们必须在类内部显式定义__add__()方法,看起来像这样:
class Base:
def __init__(self, first):
self.first = first
def __add__(self, other): #operator '+' is overloaded
print(self.first + other.first)
让我们通过创建Base类的不同对象来检查这是否有效:
>>> obj1 = Base(1)
>>> obj2 = Base(2)
>>> obj1.__add__(obj2)
3
#for strings as add method is defined internally inside str class
>>> obj3 = Base("Hello ")
>>> obj4 = Base("World")
>>> obj3.__add__(obj4)
'Hello World'
因此,魔术函数,或者__add__()数据模型被重写,成功地在两个整数和两个字符串之间执行了加法操作。我们也可以检查其他数据对象,如列表和元组。现在,我们可以清楚地预测模式;如果我们想要重载任何数学运算符并在我们的自定义类中以不同的方式实现它,我们必须在我们的类中定义数据模型。希望你明白了!现在,我们可以预测__mul__()模式,以便我们可以在不同对象之间执行乘法,__sub__()执行减法,等等。
在实际学习使用这些魔术函数的重要性之前,让我们先观察 Python 中另一个强大但不太常用的魔术方法,即__new__()数据模型。你可以轻松观察这些方法的工作方式;只需删除方法名称周围的下划线和括号,你就会得到new关键字。如果你有来自 Java 和 C#等高级语言的编程背景,你可能已经理解我的观点。对于那些对new关键字的概念还不熟悉的人,这个操作符用于创建类的实例。例如,在 Python 中,我们有object = class_name(),而在 Java 中,我们有object = new class_name()。
因此,__new__()魔术方法是在创建类的对象时调用的第一个方法,甚至在调用__init__()构造函数之前就调用了它,并且是隐式调用的。__new__()方法负责创建新对象,并返回使用构造函数的__init__()方法初始化的对象。你还记得,在面向对象的章节中,我们将__init__()方法称为特殊方法,实际上是一个魔术方法。让我们考虑以下示例来了解__new__()魔术方法:
class Base:
def __new__(cls):
print("This is __new__() magic method")
obj = object.__new__(cls)
return obj
def __init__(self):
print("This is __init__() magic method")
self.info = "I love Python"
以下代码在 Python shell 中执行。我们正在创建一个Base类的对象,并观察到在init方法之前调用了new方法:
>>> obj = Base()
This is __new__() magic method
This is __init__() magic method
请注意,在上述代码中,我们在定义new魔术方法时将cls作为参数传递,并在定义init构造函数时将self变量作为参数传递。cls和self这两个变量之间的区别在 PEP 8 中有定义,它定义了 Python 代码的样式指南。这种编码风格并不是强制性的,但根据 PEP 8,我们应该始终做到以下几点:
-
始终将
self用作实例方法的第一个参数。 -
始终将
cls用作类方法的第一个参数。
我认为我们现在已经足够能够预测任何内置函数的工作内部。让我们以len()方法为例。如果 Python 中有任何内置的fun()函数,它对应于__fun__()。Python 解析器会进行内部调用,如object.__fun__(),其中对象是类的实例。考虑到这个类比,对于len()函数,Python 解析器将其调用解释为object.__len__(),并返回对象的长度。我们已经看到了它的内部工作方式;然而,由于我们想要覆盖的主要主题是如何覆盖它,让我们在我们自定义的类中定义这个魔术方法(类似于前面的例子,我们使用add魔术函数来添加类的对象)。在__len__()的情况下,考虑以下示例:
>>> info = "I love Python"
>>> len(info)
13
>>> info.__len__()
13
因此,当我们在自己的类中定义这样的魔术方法或数据模型时,我们覆盖了 Python 原始定义的函数的行为;因此,我们不再调用原始方法。当你用新方法覆盖原始方法时,我们称之为方法覆盖。到目前为止,我们一直在学习数据模型及其在我们自己的类中的使用方式。现在,让我们学习为什么它们在游戏编程中是必不可少的。我们将在下一节中通过探索向量来做到这一点。
处理二维向量
在实际探索向量之前,让我们从运动的基本概述开始,以及如何使角色在直线上移动。要移动任何对象或图像,我们必须对每一帧进行微小的固定量的改变。运动必须对每一帧都是固定的,以使其对称。要使一个对象在水平方向上移动,我们对x位置进行固定量的加法,要使其在垂直方向上移动,我们对y位置加上相同的量。因此,2D 游戏中的运动可以表示为(x,y)。让我们考虑以下例子,以说明如何使用这些坐标来在游戏环境中绘制任何形状:
def line(a, b, x, y):
"Draw line from `(a, b)` to `(x, y)`."
import turtle
turtle.up()
turtle.goto(a, b)
turtle.down()
turtle.goto(x, y)
我们使用了在上一章中使用的turtle模块,用于使用(a,b)和(x,y)位置绘制线条。goto()方法用于将笔移动到传递的位置。这些坐标——(x,y)或(a,b)——清楚地显示了知道位置以创建游戏角色的重要性(我们使用线条作为任何游戏角色的比喻)。
我们可以认为直线运动的使用非常有用,但从不同的角度来看,一个只支持垂直或水平运动的游戏可能会显得乏味和无聊。例如,在吃豆人游戏中,玩家只能在垂直或水平方向上移动,这可能是合适的,但在赛车游戏中,用户可以朝任何方向移动,这种运动就不适用了。我们必须能够通过调整每一帧的x和y位置来朝任何方向移动。我们将使用相同的x和y位置来生成直线和对角线运动:一个表示x和y位置速度的比率。表示(x, y)的形式被称为向量,但更重要的是,向量表示方向,而标量不表示。我们将在下一小节中更详细地探讨向量。
探索向量
正如数学格言所说:
“向量是指任何具有大小和方向的量,特别用于确定空间中一个点相对于另一个点的位置。”
我们完全同意。这个概念源自数学,对于任何游戏程序员来说都是最知名的话题,无论是天真还是老练。向量是任何对象位置的正确表示,附加了方向的关键信息。向量与直线运动的形式相似,以x和y坐标(2D)的形式表示,但它们不仅仅限于提供大小的信息;它们有一个特定的目的。例如,向量(4,5)表示下一个位置,其中 4 被添加到当前位置的x坐标,5 被添加到当前位置的y坐标;类似于这样——(0 + 4,0 + 5)——其中(0,0)是原点或中心位置。让我们用以下例子形象地来研究向量:

在前面的图表中,向量(4,5)具有大小和方向。绿线表示大小,橙线表示方向。因此,一个向量如果没有前面的方向信息就是不完整的。让我们看另一个简单的例子来进一步澄清这一点:

前面的图表已经说明了一切。向量 AB 是目标位置与初始位置的x和y位置的减法。假设一个吃豆人在位置(30,20),他必须到达目标位置(50,45)。向量 AB 是关键信息,表明吃豆人在x方向上还需要移动 20 个单位,在y方向上还需要移动 25 个单位。
众所周知,Python 没有内置的向量数据结构。如果你认为有的话,快速在互联网上搜索一下,你会得到基本的概念。然而,在前面的章节中,我们并没有涵盖向量作为内置数据结构。虽然我们没有向量作为内置数据类型,但我们可以自己制作一个。正如我们所知,向量包括两个不同的位置(x,y),我们的主要目标是使用其他内置数据结构来制作它们。例如,我们可以使用列表来制作向量,但使用索引来表示每个点,如[0]和[1],会增加不必要的开销。元组也是如此。创建向量的最佳方式可能是制作我们自己的向量类。这样做,我们可以引用点为x和y,而不是索引。此外,使用数据模型与向量可以获得最佳的利用。我们可以在向量类中使用__add__(),__mul__()和许多其他魔术函数,这将为游戏角色引入运动。例如,我们将创建一个简单的向量类,并使用__str__()方法,以及一个构造函数,它将提供向量位置的适当表示:
class Vector(object):
def __init__(self, x = 0, y = 0):
self.x = x
self.y = y
def __str__(self):
return "(%s, %s)"%(self.x, self.y)
在前面的程序中,我们创建了一个Vector类,并在其中定义了两个成员:一个是构造函数,另一个是魔术方法。现在,当我们创建这个类的任何对象时,比如> pos = Vector(10,40),init()方法将执行初始化,这样我们就可以引用向量的每个分量,如>>> pos.x和>>> pos.y。__str__()方法是魔术方法,它被用作覆盖方法,并且在我们的Vector类中有一个自定义定义,用作向量分量的表示形式,即x和y位置。让我们通过运行以下代码并创建一个Vector类对象来看看它是如何工作的:
>>> pos = Vector(10, 40)
>>> pos.__str__()
'(10, 40)'
除了__str__()方法之外,我们还有一堆适用于操作向量的魔术函数。我们可以使用__add__()执行向量之间的加法,__sub__()执行减法,__neg__()执行否定等。我们将在下一节学习这些数据模型以及使用它们修改向量的方法。
用于向量运动建模
正如我们所知,向量是构成大小和方向的量。当根据用户的行动确定游戏角色的下一个位置时,这两个信息非常关键。例如,游戏角色 Steve(一个 Minecraft 角色)可以使用向量来确定他必须使用大小(AB)和方向(→AB)来跟踪他的目标。虽然我们可以逐个更改这些信息源,但我们主要关注大小,因为大小负责在 2D 游戏中提供运动。在本节中,我们将揭示教我们如何添加和减去向量,甚至执行乘法和除法的技术。这些类型的操作将作为逻辑添加到游戏中,以及用户事件,因此每当用户在键盘上按下任何键时,它都会被特定事件处理。在进行这种数学操作时可以使用的技术如下:
-
对已知分量的向量进行操作(减法/加法)
-
通过查找分量执行操作,或者简单地使用头/尾方法
让我们学习如何使用这些技术,跳到下一节,在那里我们将使用魔术函数或数据模型执行向量操作。
向量加法
与数值加法类似,我们可以使用“add()”数据模型重载+运算符,它将添加两个不同的向量并将其效果组合以产生一个新的单一向量。使用此方法,我们可以使游戏角色进行对角线运动。我们需要两个向量来执行加法;第一个将是游戏角色的当前位置,下一个将是用户在键盘上按下任何键时需要添加的向量的每个分量的预定义固定量。以下图示了向量加法的详细过程:
当您有一个由元组或列表表示的向量时,永远不要使用+运算符执行向量的加法操作。[1,2] + [3,4]不会像这样添加单个数字:[4,6]。相反,它将两个列表连接成一个,如下所示:[1,2,3,4]。
以下代码使用“iadd()”魔术函数来添加两个向量。iadd和add方法的工作方式类似,但它们之间的主要区别是“iadd()”将其结果存储在内存位置中,而“add()”不会。您可以使用其中任何一个来编写以下代码:
def __iadd__(self, other):
if isinstance(other, Vector):
self.x += other.x
self.y += other.y
else:
self.x += other
self.y += other
return "(%s, %s)"%(self.x, self.y)
确保前面的代码包含在先前创建的Vector类中。 “iadd()”方法接受参数* other ,表示需要添加到其上调用的向量的第二个向量。在魔术函数内部,我们已经制作了条件语句,以检查传递的 other *向量是否是Vector类的类型。如果是,我们将第一个向量的匹配分量与第二个向量相加,即first.x到second.x和first.y到second.y,其中 first 和 second 是向量。让我们创建Vector类的实例并检查向量加法的输出:
>>> a1 = Vector(10,20)
>>> a2 = Vector(30,40)
>>> a1.__iadd__(a2)
'(40, 60)'
现在我们已经成功使用魔术方法来实现向量加法,是时候学习更多的方法来实现向量减法和向量否定了。
向量减法
就像向量的加法意味着游戏角色的前进运动一样,向量的减法暗示着与当前面向相反的方向。我们可以使用“sub()”或“isub()”来实现向量减法。我们通常更喜欢isub,因为它在返回结果之前存储结果,并且可以完美地用于克隆向量对象,以便我们可以在重复对象中执行不同的操作,而不会损害原始对象。向量减法与加法非常相似;而不是添加向量的每个分量,我们只是要减去它们。这种运动在游戏中非常有用,比如吃豆人,用户必须在不干扰游戏过程的情况下突然改变方向。让我们在Vector类中编写以下代码,以执行向量减法:
def __isub__(self, other):
if isinstance(other, Vector):
self.x -= other.x
self.y -= other.y
else:
self.x -= other
self.y -= other
return "(%s, %s)"%(self.x, self.y)
让我们在 Python shell 中运行上述代码,以观察向量减法的结果:
>>> a1 = Vector(10,20)
>>> a2 = Vector(30,40)
>>> a1.__isub__(a2)
'(-20, -20)'
向量乘法和除法
乘法和除法等操作将使向量变大和变小。由于乘法而产生的运动变化可能是线性的,当向量乘以任何标量时。例如,当我们将任何向量乘以二时,其大小将是以前的两倍,但方向将保持不变。同样,当我们用负数乘以相同的向量,比如-2 时,它的方向将与最初的方向相反。乘法操作通常用于缩放向量。我们可以按以下方式乘以和除以两个向量:
def __imul__(self, other):
if isinstance(other, Vector):
self.x *= other.x
self.y *= other.y
else:
self.x *= other
self.y *= other
return "(%s, %s)"%(self.x, self.y)
def __itruediv__(self, other):
if isinstance(other, Vector):
self.x /= other.x
self.y /= other.y
else:
self.x /= other
self.y /= other
return "(%s, %s)"%(self.x, self.y)
与向量乘法和除法类似,我们可以使用标量数量进行缩放过程。我们将传递一个数字,而不是第二个向量,作为魔术方法的参数。可以按以下方式完成:
def __mul__(self, scalar):
return (self.x * scalar, self.y * scalar)
def __div__(self, scalar):
return (self.x / scalar, self.y / scalar)
向量否定和相等
由于我们已经涵盖了向量的最重要的操作,如加法、乘法和减法,我们现在将学习简单但重要的向量操作技术,即向量否定和相等。当玩家想要从当前状态到达前一个状态时(因为 AB = -BA),向量否定就变得很重要,这意味着否定任何向量都会创建另一个大小相同但方向相反的向量。为了否定一个向量,我们可以简单地向向量的每个分量添加-负运算符。例如,我们可以考虑以下代码行:
def __neg__(self):
return (–self.x, –self.y)
我们可以通过检查向量的每个分量来检查两个向量是否相等。例如,first.x应该与second.x进行比较,first.y应该与second.y进行比较。例如,以下方法将在两个向量相等时返回True:
def __eq__(self, other):
"""v.__eq__(w) -> v == w
>>> v = vector(1, 2)
>>> w = vector(1, 2)
>>> v == w
True
"""
if isinstance(other, vector):
return self.x == other.x and self.y == other.y
return NotImplemented
根据 Python 官方文档:
("NotImplemented 向运行时发出信号,告诉它应该要求其他人满足操作。在表达式a == b中,如果a.__eq__(b)返回 NotImplemented,那么 Python 会尝试* b.__eq__(a)。如果 b 知道足够返回* True 或 False,那么表达式可以成功。如果不知道,那么运行时将退回到内置行为(基于==和!=的身份)")。
总结
在本章中,我们涵盖了各种主题,从数据模型到向量的创建和操作。向量无疑是任何游戏开发者最重要的主题;它们帮助创建游戏角色和精灵的运动,使游戏更具用户互动性。我们学习了不同的操作,如加法、减法、除法、否定等。我们还使用这些操作和魔术方法来操作我们的向量组件。魔术方法是方法重写的一部分,应该在第六章 面向对象编程中介绍。然而,我将它保留到了这一章,因为在探索向量时学习它更有意义。
由于关于向量的数学逻辑是游戏中角色移动的主要基础,您已经学会了如何使用魔术函数实现运算符重载。本章学到的向量操作技能很重要,因为它们指定了对象的位置,并帮助我们执行一些代数运算。
本章向我们介绍了二维向量——一种数学概念,使得游戏中角色的运动成为可能。为了实现这一点,我们必须使用魔术函数的数据重载概念。为了重载任何运算符——即改变任何运算符的实现,比如+或-——我们将这些运算符的使用从原始数据类型扩展到复杂数据结构。本章的主要目标是向您介绍使用 Python 编程范式实现 2D 向量运算等数学概念的方法。
在下一章中,我们将利用本章的知识,使用海龟模块进行游戏编程的过山车之旅。我们将制作多个游戏,如贪吃蛇、乒乓球和 Flappy Bird。现在,是时候让您开始尝试向量的实验了;尝试将它们混合在一起,并为向量开发各种运动。
第十章:使用 Turtle 升级蛇游戏
大多数电脑游戏玩家认为游戏因其外观而令人兴奋和吸引人。在某种程度上,这是真的。计算机游戏必须在视觉上具有吸引力,以便玩家感觉自己在其中参与。大多数游戏开发人员和游戏设计师花费大量时间开发游戏图形和动画,以提供更好的体验给玩家。
本章将教您如何使用 Python 的turtle模块从头开始构建游戏的基本布局。正如我们所知,turtle模块允许我们制作具有二维(2D)运动的游戏;因此,本章我们将只制作 2D 游戏,如 flappy bird、pong 和 snake。本章将涵盖的概念非常重要,以便将运动与游戏角色的用户操作绑定起来。
通过本章结束时,您将学会通过创建 2D 动画和游戏来实现数据模型。因此,您将学会如何处理游戏逻辑的不同组件,例如定义碰撞、边界、投影和屏幕点击事件。通过学习游戏编程的这些方面,您将能够学会如何使用turtle模块定义和设计游戏组件。
本章将涵盖以下主题:
-
计算机像素概述
-
使用 Turtle 模块进行简单动画
-
使用 Turtle 升级蛇游戏
-
乒乓球游戏
-
flappy bird 游戏
-
游戏测试和可能的修改
技术要求
您需要以下资源:
-
Python 3.5 或更新版本
-
Python IDLE(Python 内置的 IDE)
-
文本编辑器
-
网络浏览器
本章的文件可以在这里找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter10
查看以下视频以查看代码运行情况:
探索计算机像素
当您仔细观察计算机屏幕时,您可能会发现形成行和列的小点。从一定距离上看,这些点的矩阵代表图像,这是我们在屏幕上看到的。这些点称为像素。由于计算机游戏应该在视觉上令人愉悦,我们必须使用这些像素来创建和自定义游戏屏幕,甚至使用它们来使玩家在游戏中移动,这将显示在屏幕上。每当玩家在键盘上按下任何键时,移动的变化必须反映在屏幕的像素上。例如,当玩家按下右键时,特定字符必须在屏幕上向右移动若干个像素单位,以表示运动。我们在上一章中讨论了矢量运动,它能够覆盖一些类的方法以实现运动。我们将使用矢量的技术来使游戏角色进行像素移动。让我们观察以下大纲,我们将使用矢量和 turtle 模块来制作任何游戏:
-
制作一个
Vector类,其中将具有__add__()、__mul__()和__div__()等方法,这些方法将对我们的向量点执行算术运算。 -
使用
Vector类在游戏屏幕上实例化玩家,并设置其瞄准目标或移动。 -
使用
turtle模块制作游戏边界。 -
使用
turtle模块绘制游戏角色。 -
应该使用
Vector类的旋转、前进和移动等操作,以使游戏角色移动。 -
使用主循环处理用户事件。
我们将通过制作简单的Mario像素艺术来学习像素表示。以下代码显示了多维列表中像素的表示,这是一个列表的列表。我们使用多维列表将每个像素存储在单独的行中:
>>> grid = [[1,0,1,0,1,0],[0,1,0,1,0,1],[1,0,1,0,1,0]]
前面的网格由三行组成,代表像素位置。类似于列表元素提取方法,>>> grid[1][4]语句从网格的第二个列表(即[0,1,0,1,0,1])中返回'0'的位置值。 (请参考第四章,数据结构和函数,以了解更多关于列表操作的信息。)因此,我们可以访问网格内的任何单元格。
以下代码应该写在 Python 脚本中。通过创建一个mario.py文件,我们将用它来创建马里奥像素艺术:
-
首先导入 turtle——
import turtle——这是我们将要使用的唯一模块。 -
使用
>>> Pen = turtle.Turtle()命令实例化turtle模块。 -
使用速度和颜色属性为画笔指定两个属性:
Pen.speed(0)
Pen.color("#0000000") #or Pen.color(0, 0, 0)
- 我们必须创建一个名为
box的new函数,该函数将使用画笔方法绘制正方形形状来绘制一个盒子。这个盒子大小代表像素艺术的尺寸:
def box(Dimension): #box method creates rectangular box
Pen.begin_fill()
# 0 deg.
Pen.forward(Dimension)
Pen.left(90)
# 90 deg.
Pen.forward(Dimension)
Pen.left(90)
# 180 deg.
Pen.forward(Dimension)
Pen.left(90)
# 270 deg.
Pen.forward(Dimension)
Pen.end_fill()
Pen.setheading(0)
- 我们必须将画笔定位到屏幕左上角的位置开始绘画。这些命令应该在
box()函数之外定义:
Pen.penup()
Pen.forward(-100)
Pen.setheading(90)
Pen.forward(100)
Pen.setheading(0)
- 定义盒子大小,代表我们要绘制的像素艺术的尺寸:
boxSize = 10
- 在第二阶段,您必须以多维列表的形式声明像素,这些像素代表每个像素的位置。以下的
grid_of_pixels变量代表了代表像素位置的线网格。下面的代码行必须添加到box函数定义之外。(请参考github.com/PacktPublishing/Learning-Python-by-building-games来定位游戏文件,即mario.py。):
请记住,单个形式的像素组合代表一条直线。
grid_of_pixels = [[1,1,1,1,2,2,2,2,2,2,2,2,1,1,1,1]]
grid_of_pixels.append([1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,1])
grid_of_pixels.append([1,1,1,0,0,0,3,3,3,3,3,0,3,1,1,1])
grid_of_pixels.append([1,1,0,3,0,3,3,3,3,3,3,0,3,3,3,1])
grid_of_pixels.append([1,1,0,3,0,0,3,3,3,3,3,3,0,3,3,3])
grid_of_pixels.append([1,1,0,0,3,3,3,3,3,3,3,0,0,0,0,1])
grid_of_pixels.append([1,1,1,1,3,3,3,3,3,3,3,3,3,3,1,1])
grid_of_pixels.append([1,1,1,0,0,2,0,0,0,0,2,0,1,1,1,1])
grid_of_pixels.append([1,1,0,0,0,2,0,0,0,0,2,0,0,0,1,1])
grid_of_pixels.append([0,0,0,0,0,2,2,2,2,2,2,0,0,0,0,0])
grid_of_pixels.append([3,3,3,0,2,3,2,2,2,2,3,2,0,3,3,3])
grid_of_pixels.append([3,3,3,3,2,2,2,2,2,2,2,2,3,3,3,3])
grid_of_pixels.append([3,3,3,2,2,2,2,1,1,2,2,2,2,3,3,3])
grid_of_pixels.append([1,1,1,2,2,2,1,1,1,1,2,2,2,1,1,1])
grid_of_pixels.append([1,0,0,0,0,1,1,1,1,1,1,0,0,0,0,1])
grid_of_pixels.append([0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0])
- 使用颜色定义像素艺术的调色板。我们将使用颜色代码来定义艺术品的颜色,如下面的代码所示。十六进制颜色代码(HEX)代表红色、绿色和蓝色的颜色组合(#RRGGBB)。请参考
htmlcolorcodes.com/以分析不同颜色的不同代码:
palette = ["#4B610B" , "#FAFAFA" , "#DF0101" , "#FE9A2E"]
- 接下来,我们应该开始使用我们在步骤 7和步骤 8中定义的像素网格和调色板来绘制像素艺术。我们必须使用我们之前制作的
box()函数来制作像素艺术。像素艺术由行和列组成;因此,我们必须声明两个循环来绘制艺术品。以下代码调用了turtle模块的不同函数,如forward()、penup()和pendown()。我们在上一章中学习了它们;它们将利用画笔根据像素网格的列表来绘制。
for i in range (0,len(grid_of_pixels)):
for j in range (0,len(grid_of_pixels[i])):
Pen.color(palette[grid_of_pixels[i][j]])
box(boxSize)
Pen.penup()
Pen.forward(boxSize)
Pen.pendown()
Pen.setheading(270)
Pen.penup()
Pen.forward(boxSize)
Pen.setheading(180)
Pen.forward(boxSize*len(grid_of_pixels[i]))
Pen.setheading(0)
Pen.pendown()
让我们消化前面的代码片段。它包含一个for循环,从 0 的初始值循环到代表画布中位置的像素网格的长度。每个像素代表一个位置,我们必须使用画笔进行绘制;因此,我们逐个循环每个像素。在二维for循环内,我们从调色板中获取颜色并调用box方法,该方法创建一个矩形框,我们的马里奥艺术应该在其中呈现。我们使用turtle画笔在这个框内绘制,使用forward()函数。我们在像素的行中执行相同的操作,如第 i 个循环所示。
一旦我们完成了前面的代码组合,也就是我们执行了box方法、初始化和两个主要的for循环,我们就可以运行代码并观察以下马里奥像素艺术。运行我们的代码后,turtle模块的画笔将开始绘制,最终会给我们以下艺术品:

由于我们熟悉像素和矢量运动的概念,现在是使用 2D 图形制作游戏的时候了。我们将使用turtle模块以及数据模型来创建游戏角色并使它们移动。我们将通过在下一节中制作一个简单的动画来开始这个冒险。
使用 Turtle 模块理解简单动画
到目前为止,我们可能已经熟悉了turtle模块的不同方法。这意味着我们不会在创建游戏角色时遇到任何问题。同样,游戏角色的运动是使用矢量运动来实现的。矢量加法和减法等操作通过对象的旋转提供直线运动(有关更多信息,请参阅第九章,数据模型实现)。以下代码片段中定义的move操作将为游戏角色提供随机移动。move方法将以另一个矢量作为催化剂,并执行数学运算以更新当前位置,同时考虑游戏角色的方向:
>>> v = (1,2) #vector coordinates
>>> v.move(3,4) # vector addition is done (1,2) + (3,4)
>>> v
(4,6)
rotate方法将按逆时针方向旋转矢量特定角度(原地)。以下示例表示rotate方法的调用:
>>> v = vector(1, 2)
>>> v.rotate(90)
>>> v == vector(-2, 1)
True
我们必须在Vector类中定义前面两种方法。按照以下步骤实现Vector类:
-
您必须从使用 class 关键字定义
Vector类开始。我们将定义 slots 作为类属性,其中包含三个属性。slots 表示一个包含三个关键信息的属性:x、y和 hash。x和y的值是游戏角色的当前位置,而 hash 用于定位数据记录。例如,如果使用x和y坐标实例化Vector类,则将激活 hash 属性。否则,它将保持未激活状态。 -
矢量元素的坐标,即(5,6),由x和y表示,其中x=5,y=6,hash 变量表示插槽是否为空。hash 变量用于定位数据记录并检查
Vector类是否已实例化。如果插槽属性已经包含x和y,则 hash 属性将阻止对插槽的进一步赋值。我们还将定义PRECISION属性(用户定义),它将把x和y的坐标四舍五入到一定的级别。为了使事情清楚,代码中添加了几个示例,并且您可以在三行注释中观察到这一点:
#following class will create vector
#representing current position of game character
class vector(collections.Sequence):
"""Two-dimensional vector.
Vectors can be modified in-place.
>>> v = vector(0, 1)
>>> v.move(1)
>>> v
vector(1, 2)
>>> v.rotate(90)
>>> v
vector(-2.0, 1.0)
"""
PRECISION = 6 #value 6 represents level of rounding
#for example: 4.53434343 => 4.534343
__slots__ = ('_x', '_y', '_hash')
- 接下来,我们需要定义类的第一个成员。我们知道类的第一个成员是
__init__()方法。我们将定义它以初始化类属性,即x和y。我们已经将x和y的值四舍五入到PRECISION属性指示的一定精度级别。round()是 Python 的内置函数。以下代码行包含一个构造函数,我们在其中使用round方法初始化矢量坐标(x,y):
def __init__(self, x, y):
"""Initialize vector with coordinates: x, y.
>>> v = vector(1, 2)
>>> v.x
1
>>> v.y
2
"""
self._hash = None
self._x = round(x, self.PRECISION)
self._y = round(y, self.PRECISION)
- 您可能已经注意到,您已将x和y属性作为私有属性,因为它们以单下划线(
_x,_y)开头。因此,无法直接初始化这些类型的属性,这导致了数据封装,这是我们在面向对象范例主题中讨论过的。现在,为了获取和设置这些属性的值,您必须使用getter和setter方法。这两种方法将成为Vector类的属性。以下代码表示如何为我们的Vector类实现getter和setter:
@property
def x(self):
"""X-axis component of vector.
>>> v = vector(1, 2)
>>> v.x
1
>>> v.x = 3
>>> v.x
3
"""
return self._x
@x.setter
def x(self, value):
if self._hash is not None:
raise ValueError('cannot set x after hashing')
self._x = round(value, self.PRECISION)
@property
def y(self):
"""Y-axis component of vector.
>>> v = vector(1, 2)
>>> v.y
2
>>> v.y = 5
>>> v.y
5
"""
return self._y
@y.setter
def y(self, value):
if self._hash is not None:
raise ValueError('cannot set y after hashing')
self._y = round(value, self.PRECISION)
- 除了
getter和setter方法之外,您可能已经注意到了_hash,它表示插槽是否已分配。为了检查插槽是否已经被分配,我们必须实现一个数据模型,即__hash__()。
简单回顾一下:数据模型或魔术函数允许我们更改由其祖先之一提供的方法的实现。
现在,我们将在我们的Vector类上定义hash方法,并以不同的方式实现它:
def __hash__(self):
"""v.__hash__() -> hash(v)
>>> v = vector(1, 2)
>>> h = hash(v)
>>> v.x = 2
Traceback (most recent call last):
...
ValueError: cannot set x after hashing
"""
if self._hash is None:
pair = (self.x, self.y)
self._hash = hash(pair)
return self._hash
- 最后,您必须在
Vector类中实现两个主要方法:move()和rotate()。我们将从move方法开始。move方法将移动向量到其他位置(原地)。这里,其他是传递给move方法的参数。例如,(1, 2).move(2, 3)将得到(3, 5)。记住:移动是通过任何向量算术运算来完成的,即加法、乘法、除法等。我们将使用__add__()魔术函数(参考第九章,数据模型实现)来为向量创建移动。在此之前,我们必须创建一个返回向量副本的copy方法。copy()方法很重要,因为我们不希望操作损害我们的原始向量;相反,我们将在原始向量的副本上执行算术运算:
def copy(self):
"""Return copy of vector.
>>> v = vector(1, 2)
>>> w = v.copy()
>>> v is w
False
"""
type_self = type(self)
return type_self(self.x, self.y)
- 在实现
add函数之前,您必须实现iadd魔术函数。我们使用__iadd__方法来实现扩展的add运算符赋值。我们可以在Vector类中实现__iadd__()魔术函数,如下所示。我们在上一章中看到了它的实现(第九章,数据模型实现):
def __iadd__(self, other):
"""v.__iadd__(w) -> v += w
>>> v = vector(1, 2)
>>> w = vector(3, 4)
>>> v += w
>>> v
vector(4, 6)
>>> v += 1
>>> v
vector(5, 7)
"""
if self._hash is not None:
raise ValueError('cannot add vector after hashing')
elif isinstance(other, vector):
self.x += other.x
self.y += other.y
else:
self.x += other
self.y += other
return self
- 现在,您需要创建一个新的方法
__add__,它将在原始向量的副本上调用前面的__iadd__()方法。最后一条语句__radd__ = __add__具有重要的意义。让我们观察一下radd和add之间的下面的图示关系。它的工作原理是这样的:Python 尝试评估表达式Vector(1,4) + Vector(4,5)。首先,它调用int.__add__((1,4), (4,5)),这会引发异常。之后,它将尝试调用Vector.__radd__((1,4), (4,5)):

很容易看出,__radd__的实现类似于add:(参考__add__()方法中注释中定义的示例代码):
def __add__(self, other):
"""v.__add__(w) -> v + w
>>> v = vector(1, 2)
>>> w = vector(3, 4)
>>> v + w
vector(4, 6)
>>> v + 1
vector(2, 3)
>>> 2.0 + v
vector(3.0, 4.0)
"""
copy = self.copy()
return copy.__iadd__(other)
__radd__ = __add__
- 最后,我们准备为我们的动画制作第一个移动序列。我们将从在我们的类中定义
move方法开始。move()方法将接受一个向量作为参数,并将其添加到表示游戏角色当前位置的当前向量中。move方法将实现直线加法。以下代码表示了move方法的定义:
def move(self, other):
"""Move vector by other (in-place).
>>> v = vector(1, 2)
>>> w = vector(3, 4)
>>> v.move(w)
>>> v
vector(4, 6)
>>> v.move(3)
>>> v
vector(7, 9)
"""
self.__iadd__(other)
-
接下来,我们需要创建
rotate()方法。这个方法相当棘手,因为它会逆时针旋转向量一个指定的角度(原地)。这个方法将使用三角函数操作,比如角度的正弦和余弦;因此,我们首先要导入一个数学模块:import math。 -
以下代码描述了定义旋转方法的方式;在其中,我们添加了注释以使这个操作对您清晰明了。首先,我们用
angle*π/ 180.0命令/公式将角度转换为弧度。之后,我们获取了向量类的x和y坐标,并执行了x = x*cosθ - y*sinθ和y = y*cosθ + x*sinθ操作:
import math
def rotate(self, angle):
"""Rotate vector counter-clockwise by angle (in-place).
>>> v = vector(1, 2)
>>> v.rotate(90)
>>> v == vector(-2, 1)
True
"""
if self._hash is not None:
raise ValueError('cannot rotate vector after hashing')
radians = angle * math.pi / 180.0
cosine = math.cos(radians)
sine = math.sin(radians)
x = self.x
y = self.y
self.x = x * cosine - y * sine
self.y = y * cosine + x * sine
数学公式x = xcosθ - ysin**θ在向量运动中非常重要。这个公式用于为游戏角色提供旋转运动。xcosθ代表基础x轴运动,而ysinθ代表垂直y轴运动。因此,这个公式实现了在二维平面上以角度θ旋转一个点。
最后,我们完成了两个方法:move()和rotate()。这两种方法完全独特,但它们都代表向量运动。move()方法实现了__iadd_()魔术函数,而rotate()方法具有自己的自定义三角函数实现。这两种方法的组合可以形成游戏角色在画布或游戏屏幕上的完整运动。为了构建任何类型的 2D 游戏,我们必须实现类似的运动。现在,我们将制作一个蚂蚁游戏的简单动画,以开始我们的游戏冒险之旅。
以下步骤描述了制作 2D 游戏动画的过程:
-
首先,您必须导入必要的模块。由于我们必须为先前制作的
move()方法提供随机向量坐标,我们可以预测我们将需要一个随机模块。 -
之后,我们需要另一个模块——
turtle模块,它将允许我们调用ontimer和setup等方法。我们还需要向量类的方法,即move()和rotate()。 -
如果该类维护在任何其他模块或文件中,我们必须导入它。创建两个文件:
base.py用于向量运动和animation.py用于动画。然后,导入以下语句:
from random import *
from turtle import *
from base import vector
-
前两个语句将从 random 和 turtle 模块中导入所有内容。第三个语句将从基本文件或模块中导入向量类。
-
接下来,我们需要为游戏角色定义初始位置以及其目标。它应该被初始化为向量类的一个实例:
ant = vector(0, 0) #ant is character
aim = vector(2, 0) #aim is next position
- 现在,您需要定义 wrap 方法。该方法以x和y位置作为参数,称为
value,并返回它。在即将推出的游戏中,如 flappy bird 和 Pong,我们将扩展此功能,并使其将值环绕在某些边界点周围:
def wrap(value):
return value
- 游戏的主控单元是
draw()函数,它调用一个方法来使游戏角色移动。它还为游戏绘制屏幕。我们将从Vector类中调用move和rotate方法。从 turtle 模块中,我们将调用goto、dot和ontimer方法。goto方法将在游戏屏幕上的指定位置移动海龟画笔,dot方法在调用时创建指定长度的小点,ontimer(function, t)方法将安装一个定时器,在t毫秒后调用该函数:
def draw():
"Move ant and draw screen."
ant.move(aim)
ant.x = wrap(ant.x)
ant.y = wrap(ant.y)
aim.move(random() - 0.5)
aim.rotate(random() * 10 - 5)
clear()
goto(ant.x, ant.y)
dot(10)
if running:
ontimer(draw, 100)
- 在上述代码中,
running变量尚未声明。我们现在将在draw()方法的定义之外进行声明。我们还将使用以下代码设置游戏屏幕:
setup(420, 420, 370, 0)
hideturtle()
tracer(False)
up()
running = True
draw()
done()
最后,我们完成了一个简单的 2D 动画。它由一个长度为 10 像素的简单点组成,但更重要的是,它具有附加的运动,这是在Vector类中实现魔术函数的结果。下一节将教我们如何使用本节中实现的魔术函数来制作更健壮的游戏,即蛇游戏。我们将使用 turtle 模块和魔术函数制作蛇游戏。
使用 Turtle 升级蛇游戏
事实证明,在本书的前几章中我们一直在构建贪吃蛇游戏:在第五章中,使用 curses 模块学习贪吃蛇游戏;在第六章中,面向对象编程;以及在第七章中,通过属性和列表推导式进行改进。我们从 curses 模块开始(第五章,学习使用 curses 构建贪吃蛇游戏),并使用面向对象的范例进行修改。curses 模块能够提供基于字符的终端游戏屏幕,这最终使游戏角色看起来很糟糕。尽管我们学会了如何使用 OOP 和 curses 构建逻辑,以及制作贪吃蛇游戏,但应该注意到游戏主要关注视觉:玩家如何看到角色并与之交互。因此,我们的主要关注点是使游戏具有视觉吸引力。在本节中,我们将尝试使用 turtle 模块和向量化移动来升级贪吃蛇游戏。由于在贪吃蛇游戏中只有一种可能的移动方式,即通过按左、右、上或下键进行直线移动,我们不必在基本文件的向量类中定义任何新内容。我们之前创建的move()方法足以为贪吃蛇游戏提供移动。
让我们开始使用 turtle 模块和Vector类编写贪吃蛇游戏,按照以下步骤进行:
- 像往常一样,首先导入必要的模块,如下面的代码所示。您不必先导入所有内容;我们也可以在编写其他内容时一起导入,但一次导入所有内容是一个好习惯,这样我们之后就不会忘记任何东西:
from turtle import *
from random import randrange
from base import vector
- 现在,让我们进行一些头脑风暴。我们暂时不能使用精灵或图像。在开始使用 Pygame 之后,我们将在即将到来的章节中学习这些内容。现在,我们必须制作一个代表 2D 蛇角色的形状。您必须打开
base.py文件,在那里我们创建了Vector类并定义了Square方法。请注意,Square方法是在Vector类之外声明的。以下代码是使用 turtle 方法创建正方形形状的简单实现:
def square(x, y, size, name):
"""Draw square at `(x, y)` with side length `size` and fill color
`name`.
The square is oriented so the bottom left corner is at (x, y).
"""
import turtle
turtle.up()
turtle.goto(x, y)
turtle.down()
turtle.color(name)
turtle.begin_fill()
for count in range(4):
turtle.forward(size)
turtle.left(90)
turtle.end_fill()
- 接下来,在贪吃蛇游戏模块中导入这个新方法。现在,我们可以在贪吃蛇游戏的 Python 文件中调用 square 方法:
from base import square
- 导入所有内容后,我们将声明变量,如 food、snake 和 aim。food 表示向量坐标,是
Vector类的一个实例,例如 vector(0,0)。snake 表示蛇角色的初始向量位置,即(vector(10,0)),而蛇的身体必须是向量表示的列表,即(vector(10,0)、vector(10,1)和 vector(10,2))表示长度为 3 的蛇。aim向量表示必须根据用户的键盘操作添加或减去到当前蛇向量的单位:
food = vector(0, 0)
snake = [vector(10, 0)]
aim = vector(0, -10)
- 在
snake-Python文件(主文件)中导入所有内容并声明其属性后,我们将开始定义贪吃蛇游戏的边界,如下所示:
def inside(head):
"Return True if head inside boundaries."
return -200 < head.x < 190 and -200 < head.y < 190
- 您还应该定义贪吃蛇游戏的另一个重要方法,即
move(),因为这将负责在游戏屏幕上移动贪吃蛇角色,如下所示:
def move():
"Move snake forward one segment."
head = snake[-1].copy()
head.move(aim)
if not inside(head) or head in snake:
square(head.x, head.y, 9, 'red')
update()
return
snake.append(head)
if head == food:
print('Snake:', len(snake))
food.x = randrange(-15, 15) * 10
food.y = randrange(-15, 15) * 10
else:
snake.pop(0)
clear()
for body in snake:
square(body.x, body.y, 9, 'black')
square(food.x, food.y, 9, 'green')
update()
ontimer(move, 100)
- 让我们逐行理解代码:
-
在
move方法的开始,我们获取了snakehead并执行了一个复制操作,这个操作是在Vector类中定义的,我们让蛇自动向前移动了一个段落,因为我们希望蛇在用户开始玩游戏时自动移动。 -
之后,
if not inside(head) or head in snake语句用于检查是否有任何碰撞。如果有,我们将通过将红色渲染到蛇上来返回。 -
在语句的下一行
head == food中,我们检查蛇是否能够吃到食物。一旦玩家吃到食物,我们将在另一个随机位置生成食物,并在 Python 控制台中打印分数。 -
在
for body in snake: ..语句中,我们循环遍历了蛇的整个身体,并将其渲染为黑色。 -
在
Vector类内部定义的square方法被调用以为游戏创建食物。 -
在代码的最后一条语句中,调用了
ontimer()方法,该方法接受move()函数,并将安装一个定时器,每 100 毫秒调用一次move方法。
- 在定义了
move()方法之后,您必须设置游戏屏幕并处理乌龟屏幕。与setup方法一起传递的参数是宽度、高度、setx和sety位置:
setup(420, 420, 370, 0)
hideturtle()
tracer(False)
- 我们游戏的最后部分是处理用户事件。我们必须让用户玩游戏;因此,每当用户从键盘输入时,我们必须调用适当的函数。由于 Snake 是一个简单的游戏,只有几个移动,我们将在下一节中介绍它。一旦用户按下任意键,我们必须通过改变蛇的方向来处理它。因此,我们必须为处理用户操作制作一个快速的方法。以下的
change()方法将根据用户事件改变蛇的方向。在这里,我们使用了 turtle 模块提供的listen接口,它将监听任何传入的用户事件或键盘输入。onkey()接受一个函数,该函数将根据用户事件调用 change 方法。例如,当按下Up键时,我们将通过增加当前y值 10 个单位来改变y坐标:
def change(x, y):
"Change snake direction."
aim.x = x
aim.y = y
listen()
onkey(lambda: change(10, 0), 'Right')
onkey(lambda: change(-10, 0), 'Left')
onkey(lambda: change(0, 10), 'Up')
onkey(lambda: change(0, -10), 'Down')
move()
done()
现在是时候运行我们的游戏了,但在此之前,请记住将包含vector和square类的文件(以及包含 Snake 游戏的文件)放在同一个目录中。游戏的输出看起来像这样:

除了乌龟图形,我们还可以在 Python 终端中打印分数:

现在我们已经通过使用 Python 模块和面向对象编程范式提供的多种方法来完成了 Snake 游戏,我们可以在即将到来的游戏中一次又一次地重复使用这些东西。在base.py文件中定义的Vector类可以在许多 2D 游戏中反复使用。因此,代码的重复使用是面向对象编程提供的主要优点之一。我们将在接下来的几节中只使用Vector类制作几个游戏,例如乒乓球和飞翔的小鸟。在下一节中,我们将从头开始构建乒乓球游戏。
探索乒乓球游戏
现在我们已经通过使用 Python 模块和面向对象编程范式提供的多种方法来完成了 Snake 游戏(尽管它很陈词滥调,但它非常适合掌握 2D 游戏编程的知识),现在是时候制作另一个有趣的游戏了。我们将在本节中介绍的游戏是乒乓球游戏。如果您以前玩过,您可能会发现更容易理解我们将在本节中介绍的概念。对于那些以前没有玩过的人,不用担心!我们将在本节中涵盖一切,这将帮助您制作自己的乒乓球游戏并玩它,甚至与朋友分享。以下的图表是乒乓球游戏的图形表示:

前面的图表描述了乒乓游戏的游戏场地,其中两个玩家是两个矩形。他们可以上下移动,但不能左右移动。中间的点是球,必须由任一玩家击中。在这个游戏中,我们必须为游戏角色的两种运动类型解决问题:
-
对于球来说,它可以在任何位置移动,但如果任一方的玩家未接到球,他们将输掉比赛,而对方玩家将获胜。
-
对于玩家,他们只能向上或向下移动:应该处理两个玩家的四个键盘动作。
除了运动之外,为游戏指定边界甚至更加棘手。水平线可以上下移动,是球必须击中并在另一个方向上反射的位置,但如果球击中左侧或右侧的垂直边界,游戏应该停止,错过球的玩家将输掉比赛。现在,让我们进行头脑风暴,以便在实际开始编码之前了解必要的要点:
-
创建一个随机函数,它可以返回一个随机值,但在屏幕高度和宽度确定的范围内。从这个函数返回的值可能对使球在游戏中进行随机移动很有用。
-
创建一个方法,在屏幕上绘制两个矩形,实际上是我们游戏的玩家。
-
应该声明第三个函数,它将绘制游戏并将乒乓球移动到屏幕上。我们可以使用在先前制作的
Vector类中定义的move()方法,该方法将移动向量(就地)。
现在我们已经完成了后勤工作,可以开始编码了。按照以下步骤制作自己的乒乓游戏:
- 首先导入必要的模块,即 random、turtle 和我们自定义的名为
base的模块,其中包含一堆用于向量运动的方法:
from random import choice, random
from turtle import *
from base import vector
- 以下代码表示
value()方法的定义,以及三个变量的赋值。value()方法将在(-5, -3)和(3, 5)之间随机生成值。这三个赋值语句根据它们的名称是可以理解的:
-
第一个语句表示球的初始位置。
-
第二个语句是球的进一步目标。
-
第三个语句是
state变量,用于跟踪两个玩家的状态:
def value():
"Randomly generate value between (-5, -3) or (3, 5)."
return (3 + random() * 2) * choice([1, -1])
ball = vector(0, 0)
aim = vector(value(), value())
state = {1: 0, 2: 0}
- 下一个函数很有趣;这将在游戏屏幕上呈现矩形形状。我们可以使用 turtle 模块及其方法来呈现任何形状,如下所示:
def rectangle(x, y, width, height):
"Draw rectangle at (x, y) with given width and height."
up()
goto(x, y)
down()
begin_fill()
for count in range(2):
forward(width)
left(90)
forward(height)
left(90)
end_fill()
- 制作绘制矩形的函数后,我们需要制作一个新的方法,该方法可以调用在前面步骤中定义的方法。除此之外,新方法还应该将乒乓球无缝地移动到游戏屏幕上:
def draw():
"Draw game and move pong ball."
clear()
rectangle(-200, state[1], 10, 50)
rectangle(190, state[2], 10, 50)
ball.move(aim)
x = ball.x
y = ball.y
up()
goto(x, y)
dot(10)
update()
- 现在,是时候解决游戏的主要难题了:当球击中水平和垂直边界,或者当球击中玩家的矩形球拍时会发生什么?我们可以使用
setup方法创建具有自定义高度和宽度的游戏屏幕。以下代码应该添加到draw()函数中:
#when ball hits upper or lower boundary
#Total height is 420 (-200 down and 200 up)
if y < -200 or y > 200:
aim.y = -aim.y
#when ball is near left boundary
if x < -185:
low = state[1]
high = state[1] + 50
#when player1 hits ball
if low <= y <= high:
aim.x = -aim.x
else:
return
#when ball is near right boundary
if x > 185:
low = state[2]
high = state[2] + 50
#when player2 hits ball
if low <= y <= high:
aim.x = -aim.x
else:
return
ontimer(draw, 50)
- 现在我们已经解决了游戏角色的移动问题,我们需要制作游戏屏幕并找到处理用户事件的方法。以下代码将设置游戏屏幕,该屏幕从 turtle 模块中调用:
setup(420, 420, 370, 0)
hideturtle()
tracer(False)
- 制作游戏屏幕后,我们必须通过制作自定义函数来监听和处理用户的键盘事件。我们将制作
move()函数,该函数将通过在调用此函数时传递的一定数量的单位来移动玩家的位置。这个移动函数将处理矩形球拍的上下移动:
def move(player, change):
"Move player position by change."
state[player] += change
- 最后,我们将使用 turtle 方法提供的
listen接口来处理传入的键盘事件。由于有四种可能的移动,即每个玩家的上下移动,我们将保留四个键盘键[W、S、I和K],这些键将由 turtle 内部附加监听器,如下面的代码所示:
listen()
onkey(lambda: move(1, 20), 'w')
onkey(lambda: move(1, -20), 's')
onkey(lambda: move(2, 20), 'i')
onkey(lambda: move(2, -20), 'k')
draw()
done()
前面的步骤非常简单易懂,但让我们更加流畅地掌握步骤 4和步骤 5中定义的概念。在步骤 4中,clear()方法之后的前两行代码将创建指定高度和宽度的矩形几何形状。state[1]代表第一个玩家,而state[2]代表第二个玩家。ball.move(aim)语句是对矢量类内声明的move方法的调用。
这个方法调用将执行指定矢量之间的加法,结果是直线运动。dot(10)语句将创建一个宽度为 10 个单位的球。
同样,在步骤 5中,我们使用了>>> setup(420, 420, 370, 0)语句来创建一个宽度为 420px,高度为 420px 的屏幕。当球击中上下边界时,必须改变方向一定量,而该量恰好是当前y的负值(-y改变方向)。然而,当球击中左边界或右边界时,游戏必须终止。在检查上下边界之后,我们对x坐标进行比较,并检查低和高状态。如果球在这些值下面,它必定与球拍碰撞,否则我们返回from函数。确保将此代码添加到先前定义的draw()函数中。
当您运行 Pong 游戏文件时,您会看到两个屏幕;一个屏幕将有一个乌龟图形屏幕,其中包含两个玩家准备玩您自己的 Pong 游戏。输出将类似于我们在头脑风暴 Pong 游戏时之前看到的图表。现在您已经了解了处理键盘操作的方式,以及使用 turtle 的ontimer函数调用自定义函数,让我们做一些新的事情,这将有一个控制器。它将监听屏幕点击操作并对其做出响应。我们在诸如 Flappy Bird 这样的游戏中需要这个功能,用户在屏幕上点击并改变鸟的位置。
理解 Flappy Bird 游戏
每当我们谈论有屏幕点击操作或屏幕点击操作的游戏时,Flappy Bird 就会浮现在脑海中。如果您以前没有玩过,确保您在flappybird.io/上查看它,以便熟悉它。尽管您在该网站看到的界面与我们将在本节中制作的 Flappy Bird 游戏不同,但不用担心——在学习 Python 的 GUI 模块Pygame之后,我们将模拟其界面。但现在,我们将使用 Python turtle 模块和矢量运动制作一个简单的 2D Flappy Bird 游戏。我们一直在使用onkey方法来处理键盘操作,在前面的部分中,我们使用onkey方法来嵌入特定键盘键的监听器。
然而,也有一些可以使用鼠标操作玩的游戏——通过点击游戏屏幕。在本节中,我们将按照以下步骤创建 Flappy,这是一款受到 Flappy Bird 启发的游戏:
-
首先,您应该为游戏玩法定义一个边界。您可以创建一个函数,该函数以矢量点作为参数,并检查它是否在边界内,然后相应地返回
True或False。 -
您必须制作一个渲染函数,用于将游戏角色绘制到屏幕上。正如我们所知,turtle 无法处理 GUI 中的许多图像或精灵;因此,您的游戏角色将类似于几何形状。您可以通过制作任何形状来代表您的鸟角色。如果可能的话,尽量使它小一些。
-
制作了一个渲染函数之后,您需要创建一个能够更新对象位置的函数。这个函数应该能够处理
tap动作。
我们可以在整个 Flappy Bird 游戏的编码过程中使用预定义的Vector蓝图。之前的路线图清楚地暗示了我们可以通过定义三个函数来制作一个简单的 Flappy Bird 游戏。让我们逐个定义这些函数:
- 首先,您需要设置屏幕。这个屏幕代表了输出游戏控制台,在这里您将玩我们的 Flappy Bird 游戏。您可以使用海龟模块通过
setup()来创建一个游戏屏幕。让我们创建一个宽度为 420 像素,高度为 420 像素的屏幕:
from turtle import *
setup(420, 420, 370, 0)
- 您应该定义一个函数,用来检查用户是否在边界内点击或触摸。这个函数应该是一个布尔值,如果点击点在边界内,应该返回
True;否则,应该返回False:
def inside(point):
"Return True if point on screen."
return -200 < point.x < 200 and -200 < point.y < 200
- 我已经建议您如果以前没有玩过 Flappy Bird 游戏,可以去试试。在玩游戏时,您会发现游戏的目标是保护小鸟角色免受障碍物的影响。在现实世界游戏中,我们有垂直管道形式的障碍物。由于我们在使用海龟模块编码时没有足够的资源来使用这样的精灵或界面,我们将无法在本节中使用。正如我已经告诉过您的,我们将在学习 Pygame 时自己制作很酷的界面,但现在,我们将高度关注游戏逻辑,而不是 GUI。因此,我们将给游戏角色一些随机形状;小圆形状的小鸟角色和大圆形状的障碍物。小鸟将从向量类实例化,表示其初始位置。球(障碍物)必须作为列表制作,因为我们希望障碍物在小鸟的路径上:
bird = vector(0, 0)
balls = []
- 现在您已经熟悉了游戏角色,可以通过创建一些函数来渲染它们。在函数中,我们已经传递了
alive作为一个变量,它将是一个布尔值,这将检查玩家是否死亡。如果小鸟还活着,我们使用goto()跳转到该位置,并用绿色渲染一个点。如果小鸟死了,我们用红色渲染这个点。以下代码中的 for 循环将渲染一些障碍物:
def draw(alive):
"Draw screen objects."
clear()
goto(bird.x, bird.y)
if alive:
dot(10, 'green')
else:
dot(10, 'red')
for ball in balls:
goto(ball.x, ball.y)
dot(20, 'black')
update()
- 正如我们在之前的蓝图中讨论的,接下来是游戏的主控制器。这个函数必须执行多个任务,但所有这些任务都与更新对象的位置有关。对于那些以前没有玩过 Flappy Bird 的用户来说,他们可能很难理解下面的代码;这就是为什么我鼓励您去玩原版 Flappy Bird 游戏。如果您检查游戏中小鸟的移动,您会发现它只能在y轴上移动,即上下移动。同样对于障碍物,它们必须从右向左移动,就像现实世界游戏中的垂直管道一样。以下的
move()函数包括了小鸟的初始运动。最初,我们希望它下降 5 个单位,并相应地减少。对于障碍物的部分,我们希望它向左移动 3 个单位:
from random import *
from base import vector #for vectored motion
def move():
"Update object positions."
bird.y -= 5
for ball in balls:
ball.x -= 3
- 您必须在
move函数内明确地创建多个障碍物。由于障碍物应该随机生成,我们可以使用随机模块来创建它:
if randrange(10) == 0:
y = randrange(-199, 199)
ball = vector(199, y)
balls.append(ball) #append each obstacles to list
- 接下来,我们需要检查玩家是否能够阻止小鸟触碰障碍物。检查的方法很简单。如果球或障碍物超出了左边界,我们可以将它从球的列表中移除。最初,我们制作了
inside函数来检查任何点是否在边界内;现在,我们可以用它来检查障碍物是否在边界内。它应该看起来像这样:
while len(balls) > 0 and not inside(balls[0]):
balls.pop(0)
- 请注意,我们已经为障碍物添加了一个条件;现在是时候添加一个条件来检查小鸟是否还活着。如果小鸟掉下来并触及下边界,程序应该终止:
if not inside(bird):
draw(False)
return
- 现在,我们将添加另一个条件——检查障碍物是否与小鸟发生了碰撞。有几种方法可以做到这一点,但现在,我们将通过检查球和障碍物的位置来实现这一点。首先,您必须检查障碍物和小鸟的大小:障碍物或球的大小为 20 像素,小鸟的大小为 10 像素(在第 4 点定义);因此,我们可以假设它们在彼此之间的距离为 0 时发生了碰撞。因此,
>>> if abs(ball - bird) < 15表达式将检查它们之间的距离是否小于 15(考虑到球和小鸟的宽度):
for ball in balls:
if abs(ball - bird) < 15:
draw(False)
return
draw(True)
ontimer(move, 50) #calls move function at every 50ms
- 现在我们已经完成了更新对象的位置,我们需要处理用户事件——这是当玩家轻击游戏屏幕时应该实现的内容。当用户轻击屏幕时,我们希望小鸟上升一定数量的像素。传递给轻击函数(x,y)的参数是游戏屏幕上点击点的坐标:
def tap(x, y):
"Move bird up in response to screen tap."
up = vector(0, 30)
bird.move(up)
- 最后,是时候使用 turtle 模块添加一个监听器了。我们将使用
onscreenclick()函数,它将以用户定义的任何函数作为参数(在我们的情况下是tap()函数),并将以画布上点击点的坐标(x,y)调用该函数。我们已经使用 tap 函数来调用这个监听器:
hideturtle()
up()
tracer(False)
onscreenclick(tap)
move()
done()
这似乎是很多工作,对吧?的确是。在本节中,我们已经涵盖了很多内容:定义边界的方法,渲染游戏对象,更新对象位置以及处理轻击事件或鼠标事件。我觉得我们已经学到了很多关于使用 turtle 模块构建 2D 游戏的逻辑。尽管使用 turtle 模块制作的游戏并不是很吸引人,但我们通过构建这些游戏学到的逻辑将在接下来的章节中反复使用。在这类游戏中,我们并不太关心界面,而是会在 Python shell 中运行我们的游戏并观察它的外观。上述程序的结果将是这样的:

错误消息:没有名为'base'的模块。这是因为您还没有将您的Base模块(包含我们在使用 Turtle 模块进行简单动画部分中制作的Vector类的 Python 文件)和 Python 游戏文件添加到同一个目录中。确保您创建一个新目录并将这两个文件存储在一起,或者从以下 GitHub 链接获取代码:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter10。
对于由 Turtle 制作的游戏,修改的空间很小。但我强烈建议您自行测试游戏,并发现可能的修改。如果您发现了任何修改,尝试实现它们。在下一节中,我们将介绍如何正确测试游戏并应用修改,使这些游戏比以前更加稳固。
游戏测试和可能的修改
许多人错误地认为,要成为一名熟练的游戏测试人员,您应该是一名游戏玩家。这在某种程度上可能是正确的,但大多数情况下,游戏测试人员并不关心游戏的前端设计。他们主要关注处理游戏服务器和客户端计算机之间的数据通信的后端部分。我将带您了解我们的 Pong 游戏的游戏测试和修改过程,同时涵盖以下几点:
- 增强游戏角色:以下代码代表游戏角色的新模型。我们仅使用乌龟模块来实现它。挡板是代表乒乓球游戏玩家的矩形框。有两个,即挡板 A 和挡板 B:
import turtle
# Paddle A
paddle_a = turtle.Turtle()
paddle_a.speed(0)
paddle_a.shape('square')
paddle_a.color('white')
paddle_a.penup()
paddle_a.goto(-350, 0)
paddle_a.shapesize(5, 1)
# Paddle B
paddle_b = turtle.Turtle()
paddle_b.speed(0)
paddle_b.shape('square')
paddle_b.color('white')
paddle_b.penup()
paddle_b.goto(350, 0)
paddle_b.shapesize(5, 1)
- 在游戏中添加主角(一个球):与创建 A 和 B 挡板类似,我们将使用乌龟模块以及
speed()、shape()和color()等命令来创建一个球角色并为其添加功能:
# Ball
ball = turtle.Turtle()
ball.speed(0)
ball.shape('circle')
ball.color('white')
ball.penup()
ball.dx = 0.15
ball.dy = 0.15
- 为游戏添加得分界面:我们将使用乌龟画笔为每个玩家得分绘制一个界面。以下代码包括了从乌龟模块调用的方法,即
write()方法,用于写入文本。它将arg的字符串表示放在指定位置:
# Pen
pen = turtle.Turtle()
pen.speed(0)
pen.color('white')
pen.penup()
pen.goto(0, 260)
pen.write("Player A: 0 Player B: 0", align='center',
font=('Courier', 24, 'bold'))
pen.hideturtle()
# Score
score_a = 0
score_b = 0
- 键盘绑定与适当的动作:在以下代码中,我们已经将键盘与适当的函数绑定。每当按下键盘键时,将使用
onkeypress调用指定的函数;这就是事件处理。对于paddle_a_up和paddle_b_up等方法感到困惑吗?一定要复习乒乓球游戏部分:
def paddle_a_up():
y = paddle_a.ycor()
y += 20
paddle_a.sety(y)
def paddle_b_up():
y = paddle_b.ycor()
y += 20
paddle_b.sety(y)
def paddle_a_down():
y = paddle_a.ycor()
y += -20
paddle_a.sety(y)
def paddle_b_down():
y = paddle_b.ycor()
y += -20
paddle_b.sety(y)
# Keyboard binding
wn.listen()
wn.onkeypress(paddle_a_up, 'w')
wn.onkeypress(paddle_a_down, 's')
wn.onkeypress(paddle_b_up, 'Up')
wn.onkeypress(paddle_b_down, 'Down')
- 乌龟屏幕和主游戏循环:以下几个方法调用代表了乌龟屏幕的设置:游戏的屏幕大小和标题。
bgcolor()方法将以指定颜色渲染乌龟画布的背景。这里,屏幕的背景将是黑色:
wn = turtle.Screen()
wn.title('Pong')
wn.bgcolor('black')
wn.setup(width=800, height=600)
wn.tracer(0)
主游戏循环看起来有点棘手,但如果你仔细看,你会发现我们已经了解了这个概念。主循环从设置球的运动开始。dx和dy的值是其运动的恒定单位。对于#边界检查部分,我们首先检查球是否击中了上下墙壁。如果是,我们就改变它的方向,让球重新进入游戏。对于#2:对于右边界,我们检查球是否击中了右侧的垂直边界,如果是,我们就将得分写给另一个玩家,然后结束游戏。左边界也是一样的:
while True:
wn.update()
# Moving Ball
ball.setx(ball.xcor() + ball.dx)
ball.sety(ball.ycor() + ball.dy)
# Border checking
#1: For upper and lower boundary
if ball.ycor() > 290 or ball.ycor() < -290:
ball.dy *= -1
#2: for RIGHT boundary
if ball.xcor() > 390:
ball.goto(0, 0)
ball.dx *= -1
score_a += 1
pen.clear()
pen.write("Player A: {} Player B: {}".format(score_a, score_b),
align='center', font=('Courier', 24, 'bold'))
#3: For LEFT boundary
if ball.xcor() < -390:
ball.goto(0, 0)
ball.dx *= -1
score_b += 1
pen.clear()
pen.write("Player A: {} Player B: {}".format(score_a, score_b),
align='center', font=('Courier', 24, 'bold'))
现在,我们必须处理球击中玩家的挡板的情况。以下两个条件代表了挡板和球之间的碰撞:前一个是针对挡板 B 的,后一个是针对挡板 A 的。由于挡板 B 位于屏幕的右侧,我们检查球的坐标是否与挡板的坐标加上其宽度相同。如果是,我们使用ball.dx *= -1命令来改变球的方向。setx方法将把球的第一个坐标改为340,而将y坐标保持不变。这里的逻辑与我们制作贪吃蛇游戏时使用的逻辑类似,当蛇头与食物碰撞时:
# Paddle and ball collisions
if (ball.xcor() > 340 and ball.xcor() < 350) and (ball.ycor()
< paddle_b.ycor() + 60 and ball.ycor() > paddle_b.ycor() -60):
ball.setx(340)
ball.dx *= -1
if (ball.xcor() < -340 and ball.xcor() > -350) and (ball.ycor()
< paddle_a.ycor() + 60 and ball.ycor() > paddle_a.ycor() -60):
ball.setx(-340)
ball.dx *= -1
实施如此严格的修改的好处不仅在于增强游戏角色,还在于控制不一致的帧速率——即连续图像(帧)在显示屏上出现的速率。我们将在即将到来的关于Pygame的章节中详细了解这一点,在那里我们将使用自己的精灵来定制基于乌龟的贪吃蛇游戏。在总结本章之前,让我们运行定制的乒乓球游戏并观察结果,如下所示:

总结
在本章中,我们探索了 2D 乌龟图形的世界,以及矢量运动。
我尽量使这一章尽可能全面,特别是在处理矢量运动时。我们创建了两个单独的文件;一个是Vector类,另一个是游戏文件本身。Vector类提供了一种表示x和y位置的 2D 坐标的方法。我们执行了多个操作,比如move和rotation,使用数据模型——覆盖了我们自定义的Vector类的实际行为。我们简要地观察了通过创建马里奥像素艺术来处理计算机像素的方法。我们制作了一个像素网格(列表的列表)来表示像素的位置,并最终使用 turtle 方法来渲染像素艺术。之后,我们通过定义一个独立的Vector类来制作了一个简单的动画,该类表示游戏角色的位置。我们在整个游戏过程中都使用了 turtle 模块和我们自定义的Vector类。虽然我觉得你已经准备好开始你的 2D 游戏程序员生涯了,但正如我们所说,“熟能生巧”,在你感到舒适之前,你需要大量尝试。
这一章对于我们所有想成为游戏程序员的人来说都是一个突破。我们学习了使用 Python 和 turtle 模块构建游戏的基础知识,学会了如何处理鼠标和键盘等不同的用户事件。最后,我们还学会了如何使用 turtle 模块创建不同的游戏角色。当你继续阅读本书时,你会发现 turtle 的这些概念是非常重要的,所以确保在继续之前复习它们。
在下一章中,我们将学习 Pygame 模块——这是使用 Python 构建交互式游戏最重要的平台。从下一章开始,我们将深入探讨一些话题,比如你可以加载图像或精灵,制作自己的游戏动画。你还会发现,与 C 或 C++相比,使用 Python 构建游戏是多么容易。
第十一章:使用 Pygame 超越 Turtle - 使用 Pygame 制作贪吃蛇游戏 UI
Python 游戏开发在某种程度上与pygame模块相关。到目前为止,我们已经学习了关于 Python 的各种主题和技术,因为在我们进入pygame模块之前,我们必须了解它们。所有这些概念将被用作构建 Pygame 游戏时的技术。我们现在可以开始使用面向对象的原则,矢量化移动进行事件处理,旋转技术来旋转游戏中使用的图像或精灵,甚至使用我们在 turtle 模块中学到的东西。在 turtle 模块中,我们学习了如何创建对象(参见第六章,面向对象编程),这些对象可以用于在我们可能使用 Pygame 构建的游戏的基本阶段调试不同的功能。因此,我们迄今为止学到的东西将与 Pygame 模块的其他功能一起使用,这些功能可以帮助我们制作更吸引人的游戏。
在本章中,我们将涵盖多个内容,从学习 Pygame 的基础知识——安装、构建模块和不同功能开始。之后,我们将学习 Pygame 的不同对象。它们是可以用于多种功能的模块,例如将形状绘制到屏幕上,处理鼠标和键盘事件,将图像加载到 Pygame 项目中等等。在本章的最后,我们将尝试通过添加多个功能使我们的贪吃蛇游戏在视觉上更具吸引力,例如自定义的贪吃蛇图像、苹果作为食物以及游戏的菜单屏幕。最后,我们将把我们的贪吃蛇游戏转换为可执行文件,以便您可以将游戏与朋友和家人分享,并从他们那里获得反馈。本章将涵盖以下主题:
-
Pygame 基础知识
-
Pygame 对象
-
初始化显示和处理事件
-
对象渲染——制作贪吃蛇游戏
-
游戏菜单
-
转换为可执行文件
-
游戏测试和可能的修改
技术要求
您需要以下要求才能完成本章:
-
Python—3.5 或更高版本
-
PyCharm IDE——参考第一章,了解 Python-设置 Python 和编辑器,了解下载过程
本章的文件可以在github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter11找到。
查看以下视频,以查看代码的运行情况:
理解 pygame
使用pygame模块编写游戏需要在您的计算机上安装 pygame。您可以通过访问官方 Pygame 库的网站(www.pygame.org)手动下载,或者使用终端并使用pip install pygame命令进行安装。
Pygame 模块可以免费从上述网站下载,因此我们可以按照与下载其他 Python 模块相似的过程进行下载。但是,我们可以通过使用视觉上更具吸引力和有效的替代 IDE PyCharm 来消除手动下载 pygame 的麻烦,我们在第一章,了解 Python-设置 Python 和编辑器中下载了 PyCharm。在该章节中,我们熟悉了在 PyCharm 中下载和安装第三方包的技术。
一旦您将 pygame 包下载到 PyCharm 中,请给它一些时间来加载。现在,我们可以通过编写以下代码来测试它。以下两行代码检查pygame模块是否已下载,如果已下载,它将打印其版本:
import pygame
print(pygame.version.ver) #this command will check pygame version installed
print(pygame.version.vernum) #alternate command
如果 pygame 成功安装到您的计算机上,您将观察到以下输出。版本可能有所不同,但在撰写本书时,它是 1.9.6 版(2019 年最新版本)。本书的内容适用于任何版本的pygame,因为它具有向后兼容性。请确保您的 pygame 版本新于 1.9+:
pygame 1.9.6
Hello from the pygame community. https://www.pygame.org/contribute.html
1.9.6
Pygame 对许多 Python 游戏开发者来说是一个乌托邦;它包含大量的模块,从制作界面到处理用户事件。pygame 中定义的所有这些模块都可以根据我们的需求独立使用。最重要的是,您也可以使用 pygame 制作游戏,这可能是平台特定的,也可能不是。调用 pygame 的模块类似于调用类的方法。您可以始终使用 pygame 命名空间访问这些类,然后使用您想要使用的类。例如,pygame.key将读取键盘上按下的键。因此,key类负责处理键盘操作。类似地,pygame.mouse模块用于管理鼠标事件。pygame 的这些以及许多其他模块都可以相互独立地调用,这使得我们的代码更易于管理和阅读。您可以从 pygame 模块的官方文档页面搜索可用模块的列表,但几乎 80%的游戏只需要四到六个模块。如果您想了解更多信息,最好是探索其官方文档页面。在其中,我们在每个游戏中主要使用两个类,即显示模块,以便访问和操作游戏显示;以及鼠标和键盘或操纵杆模块,以处理游戏的输入事件。我不会说其他模块不重要,但这些模块是游戏的基石。以下表格摘自 Python pygame 官方文档;它给了我们关于pygame模块及其用法的简洁概念:
| 模块名称 | 描述 |
|---|---|
pygame.draw |
绘制形状、线条和点。 |
pygame.event |
处理外部事件。 |
pygame.font |
处理系统字体。 |
pygame.image |
将图像加载到项目中。 |
pygame.joystick |
处理操纵杆移动/事件。 |
pygame.key |
从键盘读取按键。 |
pygame.mixer |
混音、加载和播放声音。 |
pygame.mouse |
读取鼠标事件。 |
pygame.movie |
播放/运行电影文件。 |
pygame.music |
播放流式音频文件。 |
pygame |
捆绑为高级 pygame 函数/方法。 |
pygame.rect |
处理矩形区域并可以创建一个框结构。 |
此外还有一些其他模块,比如 surface、time 和 transform。我们将在本章和接下来的章节中探讨它们。所有前述的模块都是平台无关的,这意味着它们可以被调用,无论机器使用的操作系统是什么。但是会有一些特定于操作系统的错误,以及由于硬件不兼容或不正确的设备驱动程序而导致的错误。如果任何模块与任何机器不兼容,Python 解析器将其返回为None,这意味着我们可以事先检查以确保游戏正常工作。以下代码将检查是否存在任何指定的模块(pygame.module_name),如果没有,它将在打印语句中返回一个自定义消息,本例中是“没有这样的模块!尝试其他”:
if pygame.overlay is None:
print("No such module! Try other one")
print("https://www.pygame.org/contribute.html")
exit()
要完全掌握pygame的概念,我们必须养成观察其他 pygame 开发者编写的代码的习惯。通过这样做,您将学习使用pygame构建游戏的模式。如果像我一样,只有在陷入僵局时才查看文档,那么我们可以编写一个简单的程序来帮助我们理解pygame的概念以及我们可以调用其不同模块的方式。我们将编写一个简单的代码来说明这一点:
import pygame as p #abbreviating pygame with p
p.init()
screen = p.display.set_mode((400, 350)) #size of screen
finish = False while not finish:
for each_event in p.event.get():
if each_event.type == p.QUIT:
finish = True
p.draw.rect(screen, (0, 128, 0), p.Rect(35, 35, 65, 65))
p.display.flip()
在讨论上述代码之前,让我们运行它并观察输出。您将得到一个几何形状—一个绿色的矩形框,它将呈现在特定高度和宽度的屏幕内。现在,是时候快速地记下pygame模块的构建块了。为了简化事情,我已经在以下几点中列出了它们:
-
import pygame: 这是我们从本书开始就熟悉的导入语句。这次,我们将 pygame 框架导入到我们的 Python 文件中。 -
pygame.init(): 这个方法将初始化 pygame 内嵌的一系列模块/类。这意味着我们可以使用 pygame 的命名空间调用其他模块。 -
pygame.display.set_mode((width, height)): 作为元组(width, height)传递的大小是期望的屏幕大小。这个大小代表我们的游戏控制台。返回的对象将是一个窗口屏幕,或者表面,我们将在其中执行不同的图形计算。 -
pygame.event.get(): 这个语句将处理事件队列。正如我们在前几章中讨论的那样,队列将存储用户的不同事件。如果不显式调用此语句,游戏将受到压倒性的 Windows 消息的阻碍,最终将变得无响应。 -
pygame.draw.rect(): 我们将能够使用绘图模块在屏幕上绘制。不同的形状可以使用此模块绘制。关于这一点,我们将在下一节—Pygame 对象中进行更多讨论。rect()方法以屏幕对象、颜色和位置作为参数,绘制一个矩形。第一个参数代表屏幕对象,它是显示类的返回对象;第二个是颜色代码,以 RGB(red, green, blue)代码的形式作为元组传递;第三个是矩形的尺寸。为了操纵和存储矩形区域,pygame 使用Rect对象。Rect()可以通过组合四个不同的值—高度、宽度、左侧和顶部来创建。 -
pygame.QUIT: 每当您明确关闭 pygame 屏幕时,就会调用此事件,这是通过按游戏控制台最右上角的close(X)按钮来完成的。 -
pygame.display.flip(): 这与update()函数相同,可以使屏幕上的任何新更新可见。在制作或 blitting 形状或字符时,必须在游戏结束时调用此方法,以确保所有对象都被正确渲染。这将交换 pygame 缓冲区,因为 pygame 是一个双缓冲框架。
上述代码在执行时呈现绿色矩形形状。正如我们之前提到的,rect()方法负责创建矩形区域,颜色代码(0, 128, 0)代表绿色。
不要被这些术语所压倒;您将在接下来的章节中详细了解它们。在阅读本章时,请确保养成一个习惯,即在代码之间建立逻辑连接:从一个位置映射游戏到另一个位置,也就是显示屏,渲染字符,处理事件。
如果您遇到无法关闭 pygame 终端的情况,那肯定是因为您没有正确处理事件队列。在这种情况下,您可以通过按下Ctrl + C来停止终端中的 Python。
在跳转到下一节之前,我想讨论一下命令的简单但深奥的工作—pygame 初始化—这是通过pygame.init()语句完成的。这只是一条简单的命令,但它执行的任务比我们想象的要多。顾名思义,这是 pygame 的初始化。因此,它必须初始化pygame包的每个子模块,即display、rect、key等。不仅如此,它还将加载所有必要的驱动程序和硬件组件的查询,以便进行通信。
如果您想更快地加载任何子模块,可以显式初始化特定的子模块,并避免所有不必要的子模块。例如,pygame.music.init()将只初始化 pygame 维护的子模块中的音乐子模块。对于本书中将要涵盖的大多数游戏,pygame模块需要超过三个子模块。因此,我们可以使用通用的pygame.init()方法来执行初始化。在进行了上述调用之后,我们将能够使用pygame模块的所有指定子模块。
初始化过程之后,开始创建显示屏是一个良好的实践。显示屏的尺寸取决于游戏的需求。有时,您可能需要为游戏提供全屏分辨率,以使其完全互动和吸引人。可以通过 pygame 表面对象来操作屏幕大小。在显示类上调用set_mode方法将返回表示整个窗口屏幕的对象。如果需要,还可以为显示屏设置标题;标题将添加到顶部导航栏中,与关闭按钮一起。以下代码表示了向游戏屏幕添加标题或游戏名称的方法:
pygame.display.set_caption("My First Game")
现在,让我们谈谈传递给set_mode方法的参数。第一个——也是最重要的——参数是屏幕表面的尺寸。尺寸应该以元组的形式传递,即宽度和高度,这是强制性的。其他参数是可选的(在之前的程序中,我们甚至都没有使用它们);它们被称为标志。我们需要它们是因为与宽度和高度相关的信息有时不足以进行适当的显示。
我们可能希望有全屏或可调整大小的显示,在这种情况下,标志可能更适合于显示创建。说到标志,它是一个可以根据情况打开和关闭的功能,有时候使用它可能会节省时间,相对而言。让我们来看一下下表中的一些标志,尽管我们不会很快使用它们,但在这里介绍它们可以避免在即将到来的部分中不必要的介绍:
| 标志 | 目的 |
|---|---|
FULLSCREEN |
创建覆盖整个屏幕的显示。建议用于调试的窗口化屏幕。 |
DOUBLEBUF |
用于创建双缓冲显示。强烈建议用于HWSURFACE或OPENGL,它模拟了 3D 显示。 |
HWSURFACE |
用于创建硬件加速的显示,即使用视频卡内存而不是主内存(必须与FULLSCREEN标志结合使用)。 |
RESIZABLE |
创建可调整大小的显示。 |
NOFRAME |
无边框或边框的显示,也没有标题栏。 |
OPENGL |
创建可渲染的 OpenGL 显示。 |
您可以使用按位或运算符将多个标志组合在一起,这有助于在屏幕表面方面获得更好的体验。为了创建一个双缓冲的 OpenGL 渲染显示,您可以将可选的标志参数设置为DOUBLEBUF|OPENGL;这里,(|)是按位OR运算符。即使 pygame 无法渲染我们要求的完美显示,这可能是由于缺乏适当的显卡,pygame 将为我们在选择与我们的硬件兼容的显示方面做出决定。
游戏开发中最重要的一个方面是处理用户事件,通常是在游戏循环内完成的。在主游戏循环内,通常有另一个循环来处理用户事件——事件循环。事件是一系列消息,通知 pygame 在代码外部可以期待什么。事件可能是用户按键事件,也可能是通过第三方库传输的任何信息,例如互联网。
作为一组创建的事件被存储在队列中,并保留在那里,直到我们明确地处理它们。虽然在 pygame 的事件模块中有不同的函数提供了捕获事件的方法,get()是最可靠的,也很容易使用。在获取了各种操作后,我们可以使用 pygame 事件处理程序来处理它们,使用pump或get等函数。请记住,如果您只处理特定的操作,事件队列可能会混入其他您不感兴趣的表面事件。因此,必须明确地使用事件属性来处理事件,类似于我们在前面的示例中使用QUIT事件属性所做的。您还可以通过eventType.__dict__属性完全访问事件对象的属性。我们将在即将到来的事件处理部分中彻底学习它们。
在学习如何使用 pygame 升级我们之前制作的snake游戏之前,我们必须学习 pygame 的一些重要概念——Pygame 对象、绘制到屏幕和处理用户事件。我们将逐一详细学习这些概念。我们将从Pygame 对象开始,学习表面对象、创建表面和矩形对象。我们还将学习如何使用 pygame 绘制形状。
Pygame 对象
由内部使用类制作的pygame模块通过允许我们创建对象并使用它们的属性,使代码可读性和可重用性。正如我们之前提到的,pygame模块中定义了几个类,可以独立调用以执行独立的任务。例如,draw类可用于绘制不同的形状,如矩形、多边形、圆形等;event类可以调用get或pump等函数来处理用户事件。可以通过创建对象来进行这些调用,首先为每个操作创建对象。在本节中,您将探索这些概念,这将帮助您学习如何访问表面对象、矩形对象和绘制到屏幕。
创建自定义尺寸的空白表面最基本的方法是从 pygame 命名空间调用Surface构造函数。在创建Surface类的对象时,必须传递包含宽度和高度信息的元组。以下代码行创建了一个 200x200 像素的空白表面:
screen_surface = pygame.Surface((200,200))
我们可以指定一些可选参数,最终会影响屏幕的视觉效果。您可以将标志参数设置为以下一个或多个参数之一:
-
HWSURFACE:创建硬件表面。在游戏的上下文中这并不是很重要,因为它是由 pygame 内部完成的。 -
SRCALPHA:它使用alpha 信息来转换背景,这是指使屏幕背景透明的过程。它创建一个带有 alpha 转换的表面。alpha 信息将使表面的一部分变为透明。如果您将其用作可选标志,您必须指定一个以上的强制参数,包括深度,并将其值分配为 32,这是 alpha 信息的标准值。
此外,如果您想创建一个包含图像作为背景的表面,可以从pygame模块中调用image类。image 类包含load方法,可以使用需要呈现的背景图像文件名作为参数进行调用。传递的文件名应该是完整的名称,带有其原始扩展名:
background_surface = pygame.image.load(image_file_name.extension).convert()
从image类调用的 load 函数会从您的计算机中读取图像文件,然后返回包含图像的表面。在这里,屏幕尺寸将由图像大小确定。Surface对象的convert()成员函数将把指定的图像转换为显示屏支持的格式。
现在,让我们学习如何在单个表面内创建多个表面,通常称为子表面。
子表面
顾名思义,子表面是单个主表面内的嵌套表面列表。主表面可以被称为父表面。父表面可以使用Surface构造函数、set_mode或图像创建。当你在子表面上绘制时,它也会绘制在父表面上,因为子表面也是父表面的一部分。创建子表面很容易;你只需要从Surface对象调用subsurface方法,并且传递的参数应该指示要覆盖的parent类的位置。通常传递的坐标应该在父屏幕内创建一个小矩形。下面的代码显示了如何创建一个子表面:
screen = Pygame.load("image.png")
screen.subsurface((0,0),(20,20))
screen.subsurface((20,0),(20,20))
你可以将这些子表面存储到数据结构中,比如字典,这样你就可以轻松地引用它们。你可以观察到传递给子表面方法的位置——它们与其他位置不同。点(0,0)总是表示子表面从父屏幕的左上角开始。
子表面有几种可用的方法,你可以从官方文档中了解到所有这些方法。其中最有用的方法之一是get_parent(),它返回子表面的父表面。如果没有使用get_parent方法调用任何子表面,它将返回None。
现在,我们将学习关于表面对象的下一个方法,这是在使用 pygame 制作任何游戏时经常使用的blit,它代表位块传输。
blit你的对象
虽然术语blitting可能没有在牛津词典中定义,但在使用 pygame 制作游戏时具有更大的意义。blit通常被称为位边界块传输,或块信息传输,是一种将图像从一个表面复制到另一个表面的方法,通常是通过裁剪或移动。假设你有Surfaceb(你的屏幕),你想在屏幕上绘制一个形状,比如一个矩形。所以,你需要做的是绘制一个矩形,然后将缓冲区的矩形块传输到屏幕缓冲区。这个过程叫做blitting。当我们使用 pygame 制作游戏时,你会发现它被用来绘制背景、字体、角色,以及你能想象到的一切。
为了blit表面,你可以从结果表面对象(通常是显示对象)调用blit方法。你必须传递你的源表面,比如角色、动画和图像,以及要blit的坐标作为参数。与理论上听起来的相比,调用blit方法相当简单。下面的代码显示了如何在指定位置(0,0)blit背景图像,即屏幕的左上角:
screen.blit(image_file_name.png, (0,0))
假设你有一组需要根据不同帧率渲染的图像。我们也可以使用blit方法来做到这一点。我们可以改变帧数的值,并在结果屏幕的不同区域blit图像,以制作图像的动画。这通常是在静态图像的情况下完成的。例如,我们将在下一章中使用 Pygame 创建 flappy bird 游戏的克隆。
在那个游戏中,我们需要在不同的位置(通常称为精灵)上blit管道和小鸟(flappy 游戏的角色)的静态图像。这些精灵只是可以直接从互联网使用的图像,或者根据我们的需要自己制作的图像。以下代码展示了一种根据不同帧率blit图像的简单方法:
screen.blit(list_of_images, (400, 300), (frame_number*10, 0, 100, 100))
在 Flappy Bird 游戏中,一个图像列表包含了鸟在飞行和下落两种姿势的图像。根据用户事件,我们将使用blit方法渲染它们。
在跳转到下一节之前,让我们了解一下可能微不足道但必须了解的帧率主题。这个术语经常被用作衡量游戏性能的基准。视频游戏中的帧率意味着你在屏幕上观察到的图像刷新或获取的次数。帧率是以每秒帧数或FPS(不要与第一人称射击混淆)来衡量的。
决定游戏帧率的因素有很多,但当代游戏玩家希望的是没有任何滞后或游戏运行缓慢。因此,更高的帧率总是更好。低帧率可能会在不合适的时候产生不幸的情况。一个例子可能是在用户能够跳跃或从一定高度跌落的游戏中;低帧率会导致系统滞后,并经常使屏幕冻结,使用户无法与游戏进行交互。许多现代游戏,例如第一人称射击游戏,如绝地求生和堡垒之夜,都是以达到大约 60 帧每秒的帧率为目标开发的。但在 Pygame 开发的简单游戏中,15 到 30 帧每秒之间被认为是可以接受的。一些批评者认为 30 帧每秒以下会产生断断续续的动画和不真实的运动,但正如我们所知,pygame 允许我们创建大多数迷你游戏。因此,15 到 30 帧每秒之间对我们来说是足够的。
让我们进入下一节,我们将学习如何使用pygame绘制不同的形状。
使用 pygame 绘制模块进行绘制
最常用的模块之一是draw,它声明了许多方法,可以用来在游戏屏幕上绘制形状。使用此模块的目的是绘制线条、圆形和多边形,事实上,任何几何形状。你可能会想知道使用它的重要性——它有广泛的用途。我们可能需要创建形状以执行裁剪,或者将精灵或图像渲染到屏幕上。有时,您可能希望将这些形状用作游戏中的角色;像俄罗斯方块这样的游戏就是一个完美的例子。即使在创建游戏时您可能不会发现它非常有用,而是会使用精灵,但在测试游戏动画时可能会有所帮助。您不必去任何地方了解这些形状在游戏开发中的重要性;您可以观察到我们迄今为止创建的游戏。直到现在,在贪吃蛇游戏中,我们一直在使用简单的矩形形状来表示蛇的身体和头部。虽然这可能并不十分吸引人,在游戏的初期阶段,我们总是可以使用这样的形状来制作游戏。
使用 pygame 创建这样的形状比使用任何其他模块都要容易。我们可以调用绘制模块,以及函数名称。函数名称将是您想要绘制的形状的名称。例如,对于一个圆,我们将使用pygame.draw.circle(),对于一个矩形,我们将使用:pygame.draw.rect()。pygame.draw中函数的前两个参数是要绘制的表面,后面是要用来绘制的颜色。绘制函数的第一个参数是Surface对象,表示要在其上绘制的屏幕。下一个参数表示要在其上绘制形状的屏幕位置。
这三个参数对于每个几何形状都是强制性的,但最后一个取决于形状。该方法的最后一个参数表示在绘制这些形状时使用的数学量,例如圆的半径或直径。通常,传递的第三个参数应该表示坐标位置,以x和y坐标的形式,其中点(0, 0)表示屏幕左上角的位置。下表列出了在绘制模块中可用的方法数量,这些方法可用于绘制任何几何形状:
| 函数 | 描述 |
|---|---|
rect |
绘制矩形 |
polygon |
绘制正多边形(具有三个或更多封闭边的几何形状) |
line |
绘制线条 |
lines |
绘制多条线 |
circle |
绘制圆 |
ellipse |
绘制椭圆 |
举个例子,让我们使用circle方法并观察pygame绘图模块的运行情况。我们需要知道半径的值才能画一个圆。半径是从圆的中心到圆的边缘的距离,也就是圆的弧长。调用圆函数时应传递的参数是屏幕,代表表面对象;圆的颜色;圆应该被绘制的位置;最后是圆的半径。由于我们使用随机模块生成圆的半径的随机值,而不是给定特定值,以下代码创建了多个圆,具有随机宽度和随机位置,并且使用随机颜色。如果为每个参数输入特定值,将会绘制一个形状:
import pygame as game
from pygame.locals import *
from random import *
import sys
game.init()
display_screen = game.display.set_mode((650, 470), 0, 32)
while True:
for eachEvent in game.event.get():
if eachEvent.type == QUIT:
sys.exit()
circle_generate_color = (randint(0,255), randint(0,255),
randint(0,255))
circle_position_arbitary = (randint(0,649), randint(0,469))
circle_radius_arbitary = randint(1,230)
game.draw.circle(display_screen, circle_generate_color,
circle_position_arbitary, circle_radius_arbitary)
game.display.update()
从本章开始编写的代码在 PyCharm Community IDE 中,该 IDE 是在第一章中下载的,了解 Python-设置 Python 和编辑器。确保pygame安装在解释器的主目录上,以便在任何新创建的 Python 文件上都可以通用地使用pygame。
在使用 PyCharm IDE 时可以注意到的一个重要特性是,它可以为我们提供有关安装pygame模块的所有模块的信息。要确定draw模块中存在哪些函数,选择代码中的circle或draw关键字,然后在键盘上按Ctrl + B,这将将您重定向到draw模块的声明文件。
在谈论代码时,很容易理解。主要的三行代码被突出显示,以便您可以直接观察它们的重要性。大多数情况下,第三行调用circle方法,声明在draw模块中,它接受参数,屏幕对象,颜色,位置和半径以绘制一个圆。前面程序的输出将不断打印具有随机半径和随机颜色的圆,直到用户手动关闭屏幕,这是由于事件处理程序完成的,由pygame.event.get方法完成。
同样,您可以绘制许多形状和大小的多边形,范围可以从三边形到 9999 边形。就像我们使用pygame.draw.circle函数创建圆形一样,我们可以使用pygame.draw.polygon来绘制任何类型的多边形。对多边形函数的调用以点列表的形式作为参数,并将使用这些点绘制多边形形状。我们可以使用类似的方式使用特定的称谓绘制不同的几何形状。
在接下来的部分中,我们将学习使用pygame模块初始化显示屏和处理键盘和鼠标事件的不同方法。
初始化显示屏和处理事件
游戏开发人员主要将专注于如何使玩家感到参与其中,使游戏更具互动性。在这种情况下,必须将两个方面紧密联系在一起,即视觉上吸引人的显示和处理玩家的事件。我们不希望玩家被糟糕的显示屏和游戏运动中的滞后所压倒。在本节中,我们将讨论开发人员在制作游戏时必须考虑的两个主要方面:通过适应可用的可选参数来初始化显示的不同方式,以及处理用户操作事件,例如按下键盘键或鼠标按钮时。您想要创建的显示类型取决于您计划开发的游戏类型。
在使用pygame模块制作游戏时,您必须记住的一件事是,向游戏添加更多操作将影响游戏的流畅性,这意味着如果您向游戏中添加多个功能,游戏的互动性就会越来越差。因此,我们将主要专注于使用pygame模块制作迷你游戏。市场上还有更先进的 Python 模块可用于制作高功能游戏,我们将在接下来的章节中探讨它们。目前,我们将看到如何初始化显示,这是通过选择较低的分辨率来完成的,因为我们不希望游戏以任何方式滞后。
从现在开始制作的任何游戏都将具有固定和低分辨率,但您可以通过让用户选择自定义显示来进行实验。以下代码是创建 pygame 窗口的简单方法,我们之前编写的代码中也见过:
displayScreen = pygame.display.set_mode((640, 480), 0, 32) #standard size
set_mode()的第一个参数将是屏幕的尺寸。元组中的值(640, 480)表示屏幕的高度和宽度。这个尺寸值将创建一个小窗口,与大多数桌面屏幕兼容。然而,我们可能会遇到一个情况,即游戏必须具有FULLSCREEN,而不是小屏幕。在这种情况下,我们可以使用一个可选参数,给出FULLSCREEN的值。显示全屏的代码看起来像这样:
displayScreen = pygame.display.set_mode((640, 480), FULLSCREEN, 32)
然而,我们可能会观察到使用全屏模式与自定义显示之间的性能差异。在全屏模式下打开游戏将运行得更快,因为它不会与其他后台桌面屏幕进行交互,而另一个屏幕,具有自定义显示,可能会与您的机器上运行的其他显示屏合并。除此之外,在小屏幕上调试游戏比全屏游戏更容易,因为您应该考虑在全屏模式下关闭游戏的替代方法,因为关闭按钮将不可见。要检查 PC 支持的不同显示分辨率,您可以调用list_modes()方法,它将返回包含分辨率列表的元组,看起来像这样:
>>> import pygame as p
>>> p.init()
>>> print(p.display.list_modes())
[(1366, 768), (1360, 768), (1280, 768), (1280, 720), (1280, 600), (1024, 768), (800, 600), (640, 480), (640, 400), (512, 384), (400, 300), (320, 240), (320, 200)]
有时,您可能会感到屏幕上显示的图像质量略有下降。这主要是由于显卡功能较少,无法提供您请求的图像颜色。这由pygame进行补偿,它将图像转换为适合您设备的图像。
在某些游戏中,您可能希望用户决定选择显示屏的大小。权衡的问题在于玩家选择高质量视觉还是使游戏运行顺畅。我们的主要目标将是处理事件,可以在可调整大小的屏幕和全屏之间切换。以下代码说明了在窗口化屏幕和全屏之间切换的方法。当用户在键盘上按下F时,它将在屏幕之间切换。
当你运行程序时,窗口屏幕和全屏之间的切换过程并不是即时的。这是因为pygame需要一些时间来检查显卡的特性,如果显卡不够强大,它会自行处理图像的质量:
import pygame as p #abbreviating pygame module as p
from pygame.locals import *
import sys
p.init()
displayScreen = p.display.set_mode((640, 480), 0, 32)
displayFullscreen = False while True:
for Each_event in p.event.get():
if Each_event.type == QUIT:
sys.exit()
if Each_event.type == KEYDOWN:
if Each_event.key == K_f:
displayFullscreen = not displayFullscreen
if displayFullscreen:
displayScreen = p.display.set_mode((640, 480),
FULLSCREEN, 32)
else:
displayScreen = p.display.set_mode((640, 480), 0, 32)
p.display.update()
让我们逐行学习显示切换的过程:
-
你必须从
pygame模块开始导入。第二个导入语句将导入 Pygame 使用的常量。然而,它的内容会自动放置在pygame模块的命名空间中,我们可以使用pygame.locals来仅包含pygame常量。常量的例子包括:KEYDOWN,键盘k_constants等。 -
你将在游戏开始时设置默认的显示模式。这个显示将是默认显示,每当你第一次运行程序时,当前定制的显示将被渲染。我们默认传递了一个(640, 480)的显示屏。
-
要切换显示屏,你必须创建一个布尔变量
Fullscreen,它将是True或False,基于这一点,我们将设置屏幕的模式。 -
在主循环中,你必须处理键盘按键动作的事件。每当用户在键盘上按下F键时,我们将改变布尔变量的值,如果
FULLSCREEN变量的值为True,我们必须将显示切换到全屏模式。额外的标志FULLSCREEN作为第二个参数添加到add_mode()函数中,深度为 32。 -
在 else 部分,如果全屏的值为
False,你必须以窗口版本显示屏幕。相同的键F用于在窗口和全屏之间切换屏幕。
现在我们已经学会了如何使用不同的可用标志修改窗口可视化效果,让我们进入下一部分,我们将讨论接受用户输入和控制游戏,这通常被称为处理用户事件。
处理用户事件
在传统的 PC 游戏中,我们通常看到玩家只使用键盘来玩游戏。即使在今天,大多数游戏仍然完全依赖于键盘操作。随着游戏行业的发展,我们可以从多种输入设备接受用户输入,如鼠标和操纵杆。通常,鼠标用于处理动作,它可以给游戏画面提供全景视图。如果你玩过反恐精英或任何第一人称射击游戏,鼠标允许玩家在多个角度旋转视角,而键盘操作则处理玩家的移动,如向左移动、向右移动、跳跃等。键盘通常用于触发射击和躲避等动作,因为它的操作就像一个开关。开关只有两种可能性:打开或关闭;键盘按键也只有按下或未按下,这概括了处理键盘操作的技术。在典型的 19 世纪游戏中,我们曾经通过检查键盘的操作来生成游戏敌人。当用户不断按下键盘按键时,我们会生成更多的敌人。
鼠标和键盘这两种输入设备的组合非常适合这些游戏,因为鼠标能够处理方向运动,并且以平滑的方式进行操作。例如,当你玩第一人称射击游戏时,你可以使用键盘和鼠标来旋转玩家。当有敌人在你身后时,通常会使用鼠标快速旋转到那个位置,而不是使用键盘来旋转。
为了检测和监听所有的键盘按键,你必须使用pygame.key模块。这个模块能够检测任何键是否被按下,甚至支持方向运动。这个模块还能够处理任何键盘动作。基本上,有两种处理 pygame 中按键的方法:
-
通过处理按键按下事件,当键盘上的键被按下时触发。
-
通过处理键盘上释放键时触发的 KEYUP 事件。
虽然这些事件处理程序是检查按键的一个很好的方法,但处理键盘输入以进行移动并不适合它们。我们需要事先知道键盘键是否被按下,以便绘制下一帧。因此,直接使用pygame.key模块将使我们能够有效地处理键盘键。键盘的键(a-z,0-9 和 F1-F12)具有由 pygame 预定义的键常量。这些键常量可以被称为键码,用于唯一标识它们。键码总是以K_开头。对于每个可能的键,键码看起来像(K_a到K_z),(K_0到K_9),并包含其他常量,如K_SPACE,K_LEFT和K_RETURN。由于硬件不兼容性,pygame 无法处理一些键盘键。这个异常在网上由几位开发者讨论过。你可能需要参考他们以更详细地了解这一点。
处理任何键盘动作的最基本方法是使用pygame.key get_pressed函数。这个方法非常强大,因为它为所有键盘常量分配布尔值,要么是True,要么是False。我们可以通过使用if条件来检查:键盘常量的值是True还是False?如果是True,显然是有键被按下了。get_pressed方法调用返回一个键常量的字典,字典的键是键盘的键常量,字典的值是布尔值,dictionary_name[K_a] = True。假设你正在制作一个程序,它将使用up作为跳跃按钮。你需要编写以下代码:
import pygame as p
any_key_pressed = p.key.get_pressed()
if any_key_pressed[K_UP]:
#UP key has been pressed
jump()
让我们更详细地了解pygame.key模块。以下每个函数都将以不同的方式处理键盘键:
-
pygame.key.get_pressed(): 正如我们在前面的代码中看到的,这个方法返回一个包含键盘每个键的布尔值的字典。你必须检查键的值来确定它是否被按下。换句话说,如果键盘键的任何值被设置为True,则该索引的键被认为是被按下的。 -
pygame.key.name(): 正如其名称所示,这个方法调用将返回按下的键的名称。例如,如果我得到一个值为 115 的KEY_UP事件,你可以使用key.name来打印出这个键的名称,这种情况下是一个字符串,s。 -
pygame.key.get_mods(): 这将确定哪个修改键被按下。修改键是与Shift、Alt和Ctrl组合的普通键。为了检查是否有任何修改键被按下,你必须首先调用get_mods方法,然后跟着K_MOD。方法调用和常量之间用按位与运算符分隔,例如,event.key == pygame.K_RIGHT和pygame.key.get_mods() & pygame。KMOD_LSHIFT方法可用于检查左Shift键。 -
pygame.key.set_mods(): 你也可以临时设置修改键以观察修改键被按下的效果。要设置多个修改键,通常使用按位或运算符(|)将它们组合起来。例如,pygame.key.set_mods(KMOD_SHIFT | KMOD_LSHIFT)将设置 SHIFT 和 LEFT Shift修改键。 -
pygame.key.get_focused(): 要从键盘获取每个按下的键,显示必须专注于键盘操作。这个方法调用将通过检查显示是否正在从系统接收键盘输入来返回一个布尔值。在游戏中可能有一个自定义屏幕的情况下,游戏屏幕没有焦点,因为你可能在使用其他应用程序;这将返回False,这意味着显示不活跃或没有专注于监听键盘操作。但在全屏显示模式下,你将完全专注于单个屏幕,在这种情况下,这个方法将始终返回True。
还有一些 pygame 按键功能,比如get_repeat和set_repeat,它们在你想要在键盘上连续按住任意键时发生重复动作的情况下非常有用。例如,打开记事本并连续按下s键。你会看到字符s会被打印多次。这个功能可以使用pygame.key set_repeat函数嵌入。这个函数将接受两个参数:延迟和间隔,单位为毫秒。
第一个延迟值是按键重复之前的初始延迟,而下一个间隔值是重复按键之间的延迟。您可以使用调用 set_repeat方法并不带参数来禁用这些按键重复功能。默认情况下,当 pygame 被初始化时,按键重复功能是被禁用的。因此,您不需要手动禁用它。请访问以下网站以获取 pygame 官方文档,以了解更多关于 pygame 按键功能的信息:www.pygame.org/docs/ref/key.html。
您可以通过分配上、下、左或右键来使用键盘为游戏屏幕的精灵/图像/对象设置移动。直到现在,我们一直在使用不同的模块,如 Python turtle 和 curses 来做到这一点。然而,我们无法处理静态精灵或图像的移动。我们只处理了上、下、左、右和几何对象的按键事件,但现在 pygame 允许我们使用更复杂的图形并相应地处理它们。
我们可以分配任何键盘键来执行方向移动,但按照传统方法,我们可以适当地将光标键或箭头键分配为它们在键盘上的位置完美,这样玩家可以轻松游戏。但在一些复杂的多人游戏中,比如第一人称射击游戏,A、W、S和D键被分配用于方向移动。现在,你可能想知道为了使任何箭头键以这样的方式行为,可以用于方向移动,你需要做什么。只需回想一下向量的力量:这是一个数学概念,无论你使用什么语言或模块,都对游戏开发有用。移动任何几何形状和图像的技术是相同的;我们需要创建一个指向我们可能想要前进的方向的向量。表示游戏角色的位置非常简单:你可以用 2D 坐标(x, y)表示它,用 3D 坐标(x, y, z)表示它。然而,方向向量是必须添加到当前向量位置的单位量,以便转到下一帧。例如,通过按下键盘上的下键,我们必须向下移动,x位置不变,但y坐标增加一个单位。下表解释了四个方向的方向移动:
| 位置 | 方向向量 |
|---|---|
| 上 | (0, -1) |
| 下 | (0, 1) |
| 左 | (-1, 0) |
| 右 | (1, 0) |
我们可能还希望玩家允许对角线移动,如下图所示:

上面的插图代表了上和右键盘键的矢量运动。假设在游戏开始时,玩家位于位置(0, 0),这意味着他们位于中心。现在,当用户按上(箭头键)键盘键时,将(0, 0)与上方向矢量(0, -1)相加,得到的矢量将是玩家的新位置。对角线移动(两个键的组合,这种情况下是上和右)将在玩家当前矢量位置上增加(0.707, -0.707)。我们可以使用这种矢量运动技术来为任何游戏对象提供方向运动,无论是精灵/静态图像还是几何形状。以下代码代表了使用 pygame 事件处理技术的矢量运动:
import pygame as p
import sys
while True:
for anyEvent in p.event.get():
if anyEvent.type == QUIT:
sys.exit()
any_keys_pressed = p.key.get_pressed()
movement_keys = Vector2(0, 0) #Vector2 imported from gameobjects
#movement keys are diectional (arrow) keys
if any_keys_pressed[K_LEFT]:
movement_keys.x = –1
elif any_keys_pressed[K_RIGHT]:
movement_keys.x = +1
if any_keys_pressed[K_UP]:
movement_keys.y = -1
elif any_keys_pressed[K_DOWN]:
movement_keys.y = +1
movement_keys.normalize() #creates list comprehension
[refer chapter 7]
尽管了解如何使物体在八个方向移动(四个基本方向和四个对角线移动)是值得的,但使用所有八个方向不会使游戏更加流畅。在假设中,使物体朝八个方向移动有点不自然。然而,现在的游戏允许玩家以 360 度的方式观察视图。因此,为了制作具有这种功能的游戏,我们可以使用键进行旋转运动,而不是使用八个键动作。为了计算旋转后的矢量,我们必须使用数学模块计算角度的正弦和余弦。角度的正弦负责x分量的运动,而余弦负责y分量的运动。这两个函数都使用弧度角;如果旋转角度是度数,你必须使用(degree*pi/180)将其转换为弧度:
resultant_x = sin(angle_of_rotational_sprite*pi/180.0)
#sin(theta) represents base rotation about x-axix
resultant_y = cos(angle_of_rotational_sprite*pi/180.0)
#cos(theta) represents height rotation about y-axis
new_heading_movement = Vector2(resultant_x, resultant_y)
new_heading_movement *= movement_direction
现在,让我们学习实现鼠标控制,并观察它如何在游戏开发中使用。
鼠标控制
拥有鼠标控制,以及键盘控制,如果你想使游戏更加互动,这是很方便的。有时,处理八个方向键是不够的,在这种情况下,你还必须处理鼠标事件。例如,在像 flappy bird 这样的游戏中,用户基本上必须能够使用鼠标玩,尽管在移动游戏中使用屏幕点击,在 PC 上,你必须能够提供鼠标操作。在显示屏中绘制鼠标光标非常简单;你只需要从MOUSEMOTION事件中获取鼠标的坐标。类似于键盘get_pressed函数,你可以调用pygame.mouse.get_pos()函数来获取鼠标的位置。鼠标移动在游戏中非常有用——如果你想使游戏角色旋转,或者制作一个屏幕点击游戏,甚至如果你想上下查看游戏屏幕。
为了理解处理鼠标事件的方法,让我们看一个简单的例子:
import pygame as game #now instead of using pygame, you can use game
game.init()
windowScreen = game.display.set_mode((300, 300))
done = False # Draw Rect as place where mouse pointer can be clicked RectangularPlace = game.draw.rect(windowScreen, (255, 0, 0),(150, 150, 150, 150))
game.display.update()
# Main Loop while not done:
# Mouse position and button clicking.
position = game.mouse.get_pos()
leftPressed, rightPressed, centerPressed = game.mouse.get_pressed() #checking if left mouse button is collided with rect place or not if RectangularPlace.collidepoint(position) and leftPressed:
print("You have clicked on a rectangle")
# Quit pygame.
for anyEvent in game.event.get():
if anyEvent.type == game.QUIT:
done = True
我已经突出了代码的一些重要部分。重点主要放在帮助我们理解鼠标事件实现的那些部分上。让我们逐行看代码:
-
首先,你必须定义一个对象——一个将有鼠标事件监听器设置以捕获它的区域。在这种情况下,你必须使用
pygame.draw.rect方法调用将区域声明为矩形。 -
在主循环内,你必须使用
pygame.mouse.get_pos()函数获取鼠标的位置,这将表示当前光标坐标。 -
然后,你必须从
pygame.mouse模块调用get_pressed()方法。将返回一个布尔值列表。对于左、右或中间,布尔值True表示在特定实例中,特定鼠标按钮被按下,而其余两个没有。在这里,我们捕获了三个鼠标按钮的布尔值。 -
现在,要检查用户是否按在矩形内,你必须调用
collidepoint方法并向其传递一个位置值。位置表示当前光标位置。如果鼠标在当前位置点击,pressed1将为True。 -
当这两个语句都为
True时,您可以相应地执行任何操作。请记住,即使您在窗口屏幕中点击了,这个程序也不会打印消息,因为它不属于矩形的一部分。
与pygame.key模块类似,让我们详细了解pygame.mouse模块。该模块包含八个函数:
-
pygame.mouse.get_rel(): 它将以元组形式返回相对鼠标移动,包括x和y的相对移动。 -
pygame.mouse.get_pressed(): 它将返回三个布尔值,代表鼠标按钮,如果任何一个为True,则相应的按钮被视为按下。 -
pygame.mouse.set_cursor(): 它将设置标准光标图像。这很少需要,因为通过在鼠标坐标上绘制图像可以获得更好的效果。 -
pygame.mouse.get_cursor(): 它执行两个不同的任务:首先,它设置光标的标准图像,其次,它获取关于系统光标的确定性数据。 -
pygame.mouse.set_visible(): 它改变标准鼠标光标的可见性。如果为False,光标将不可见。 -
pygame.mouse.get_pos(): 它返回一个元组,包含鼠标在画布中点击位置的x和y值。 -
pygame.mouse.set_pos(): 它将设置鼠标位置。它接受一个元组作为参数,其中包含画布中x和y的坐标。 -
pygame.mouse.get_focused(): 这个布尔函数的结果基于窗口屏幕是否接收鼠标输入的条件。它类似于key.get_focused函数。当 pygame 在当前窗口屏幕中运行时,窗口将接收鼠标输入,但只有当 pygame 窗口被选中并在显示器的最前面运行时才会接收。如果另一个程序在后台运行并被选中,那么 pygame 窗口将无法接收鼠标输入,这个方法调用的输出将是False。
您可能玩过一些飞机或坦克游戏,鼠标用作瞄准设备,键盘用于移动和射击动作。这些游戏非常互动。因此,您应该尝试制作一个可以尽可能结合这两种事件的游戏。这两种类型的事件非常有用,对于任何游戏开发都很重要。我建议您花时间尝试这些事件。如果可能的话,尝试只使用几何对象制作自己的游戏。现在,我们将学习如何使用 pygame 和我们自己的精灵制作游戏。
这个游戏将是前一章中由 turtle 模块制作的贪吃蛇游戏的修改版本。所有的概念都是一样的,但是我们将制作外观吸引人的角色,并且我们将使用 pygame 处理事件。
对象渲染
计算机以颜色网格的形式存储图像。通常,RGB(红色、绿色和蓝色)足以提供像素的信息。但除了 RGB 值之外,在处理 pygame 游戏开发时,图像的另一个组成部分也很有用,那就是 alpha 信息(通常称为属性组件)。alpha 信息代表图像的透明度。这些额外的信息非常有用;在 pygame 的情况下,通常我们会激活 alpha 属性,然后将一张图像绘制或放置在另一张图像的顶部。通过这样做,我们可以看到部分背景。通常,我们会使用 GIMP 等第三方软件来使图像的背景透明。
除了知道如何使图像的背景透明之外,我们还必须知道如何将它们导入到我们的项目中,以便我们可以使用它们。将任何静态图像或精灵导入 Python 项目非常容易,pygame 使其变得更加容易。我们有一个图像模块,它提供了一个 load 方法来导入图像。在调用 load 方法时,您必须传递一个带有完整文件名的图像,包括扩展名。以下代码表示了一种将图像导入 Python 项目的方法:
gameBackground = pygame.image.load(image_filename_for_background).convert()
Image_Cursor = pygame.image.load(image_filename_mouseCursor).convert_alpha()
您想要导入游戏项目的图像应该与游戏项目所在的目录相同。例如,如果 Python 文件保存在 snake 目录中,则 Python 文件加载的图像也应保存在 snake 目录中。
在图像模块中,load 函数将从硬盘加载文件并返回一个包含要加载的图像的新生成的表面。对pygame.image.load的第一次调用将读取图像文件,然后立即调用convert方法,将图像转换为与我们的显示器相同的格式。由于图像和显示屏的转换处于相同的深度级别,因此绘制到屏幕上相对较快。
第二个语句是加载鼠标光标。有时,您可能希望将自定义鼠标光标加载到游戏中,第二行代码就是这样做的方法。在加载mouse_cursor的情况下,使用convert_alpha而不是 convert 函数。这是因为鼠标光标的图像包含有关透明度的特殊信息,称为alpha 信息,并使图像的一部分变得不可见。通过禁用 alpha 信息,我们的鼠标光标将被矩形或正方形形状包围,从而使光标看起来不太吸引人。基本上,alpha 信息用于表示将具有透明背景的图像。
现在我们已经学会了如何将图像导入 Python 项目,让我们学习如何旋转这些图像。这是一种非常有用的技术,因为在构建游戏时,我们可能需要按一定角度旋转图像,以使游戏更具吸引力。例如,假设我们正在制作一个贪吃蛇游戏,我们正在使用一张图像作为蛇头。现在,当用户在键盘上按下“上”键时,蛇头应该旋转,并且必须平稳地向上移动。这是通过pygame.transform模块完成的。Rotate方法可以从 transform 模块中调用以便进行旋转。旋转方法接受从image.load()函数加载的图像表面,并指定旋转的角度。通常,转换操作会调整像素的大小或移动部分像素,以使表面与显示屏兼容:
pygame.transform.rotate(img, 270) #rotation of image by 270 degree
在我们开始开发自己的视觉吸引人的贪吃蛇游戏之前,您必须了解 Pygame time模块。点击此链接了解更多信息:www.pygame.org/docs/ref/time.html#pygame.time.Clock。Pygame.time模块用于监控时间。时间时钟还提供了几个函数来帮助控制游戏的帧速率。帧速率是连续图像出现在显示屏上的速率或频率。每当调用时间模块的Clock()构造函数时,它将创建一个对象,该对象可用于跟踪时间。Pygame 开发人员在 Pygame 时间模块内部定义了各种函数。但是,我们只会使用tick方法,它将更新时钟。
Pygame.time.Clock.tick()应该在每帧调用一次。在函数的两次连续调用之间,tick()方法跟踪每次调用之间的时间(以毫秒为单位)。通过每帧调用Clock.tick(60),程序被限制在 60 FPS 的范围内运行,并且即使处理能力更高,也不能超过它。因此,它可以用来限制游戏的运行速度。这在由 Pygame 开发的游戏中很重要,因为我们希望游戏能够平稳运行,而不是通过 CPU 资源来补偿。每秒帧数(帧速率)的值可以在由 Pygame 开发的游戏中的游戏中任何地方从 15 到 40。
现在,我们已经有足够的信息来使用 Pygame 制作我们自己的游戏,其中将有精灵和游戏角色的平滑移动。我们将在下一节中开始初始化显示。我们将使用 Pygame 模块更新我们的贪吃蛇游戏。
初始化显示
初始化显示非常基础;您可以始终通过导入必要的模块并在set_mode()方法中提供显示的特定尺寸来创建窗口化屏幕。除此之外,我们将声明一个主循环。请参考以下代码以观察主循环的声明:
import pygame as game
from sys import exit
game.init()
DisplayScreen = game.display.set_mode((850,650))
game.display.set_caption('The Snake Game') #game title
game.display.update()
gameOver = False
while not gameOver:
for anyEvent in game.event.get():
print(event)
exit()
game.quit()
quit()
初始化后,您可以运行程序检查一切是否正常。如果出现“没有 pygame 模块”的错误,请确保您按照上述步骤在 PyCharm IDE 上安装 Pygame。现在,我们将学习如何使用颜色。
使用颜色
计算机颜色的基本原理是颜色相加,这是一种将三种基本颜色相加以创建新颜色的技术。三种基本颜色是红色、绿色和蓝色,通常称为 RGB 值。每当 Pygame 需要将任何颜色添加到游戏中时,您必须将其传递给三个整数的元组,每个整数分别对应红色、绿色或蓝色。
将整数值传递给元组的顺序很重要,对整数进行微小的更改会导致不同的颜色。颜色的每个组件的值必须在 0 到 255 之间,其中 255 表示颜色具有绝对强度,而 0 表示该颜色根本没有强度。例如,(255, 0, 0)表示红色。以下表格指示了不同颜色的颜色代码:
| 颜色名称 十六进制码#RRGGBB 十进制码(R,G,B) |
|---|
| --- |
| 黑色 #000000 (0,0,0) |
| 白色 #FFFFFF (255,255,255) |
| 红色 #FF0000 (255,0,0) |
| 酸橙色 #00FF00 (0,255,0) |
| 蓝色 #0000FF (0,0,255) |
| 黄色 #FFFF00 (255,255,0) |
| 青色/水绿色 #00FFFF (0,255,255) |
| 洋红/紫红 #FF00FF (255,0,255) |
现在,让我们为我们的贪吃蛇游戏项目添加一些颜色:
white = (255,255,255)
color_black = (0,0,0)
green = (0,255,0)
color_red = (255,0,0)
while not gameOver:
#1 EVENT GET
DisplayScreen.fill(white) #BACKGROUND WHITE
game.display.update()
现在,在下一节中,我们将学习如何使用pygame模块创建游戏对象。
制作游戏对象
为了开始创建游戏对象,我们不会直接使用贪吃蛇精灵或图像。相反,我们将从使用一个小矩形框开始,然后我们将用贪吃蛇图像替换它。这在大多数游戏中都需要做,因为我们必须在游戏开发的开始测试多个事物,比如帧速率、碰撞、旋转等。在处理所有这些之后,很容易将图像添加到 pygame 项目中。因此,在本节中,我们将制作类似矩形框的游戏对象。我们将制作贪吃蛇的头部和身体,它将是一个小矩形框。我们最初将为贪吃蛇的头部制作一个盒子,另一个为食物,然后为其添加颜色:
while not gameOver:
DisplayScreen.fill(white) #background of game
game.draw.rect(DisplayScreen, color_black, [450,300,10,10]) #1\. snake
#two ways of defining rect objects
DisplayScreen.fill(color_red, rect=[200,200,50,50]) #2\. food
现在我们将为game对象添加移动。在之前的章节中,我们已经谈论了很多这些内容,比如在处理方向移动时使用向量:
change_x = 300
change_y = 300
while not gameOver:
for anyEvent in game.event.get():
if anyEvent.type == game.QUIT:
gameOver = True
if anyEvent.type == game.KEYDOWN:
if anyEvent.key == game.K_LEFT:
change_x -= 10
if anyEvent.key == game.K_RIGHT:
change_x += 10
DisplayScreen.fill(white)
game.draw.rect(DisplayScreen, black, [change_x,change_y,10,10])
game.display.update()
在先前的代码中,change_x和change_y表示蛇的初始位置。每当开始玩我们的游戏时,蛇的默认位置将是(change_x, change_y)。通过按下左键或右键,我们改变它的位置。
当你此刻运行游戏时,你可能会观察到你的游戏只会移动一步,当你按下并立即释放键盘键时,游戏会立即停止。这种异常行为可以通过处理多个运动来纠正。在这种情况下,我们将创建lead_x_change,这将根据主change_x变量的变化。请记住,我们没有处理上下键事件;因此,不需要lead_y_change。
lead_x_change = 0
while not gameOver:
for anyEvent in game.event.get():
if anyEent.type == game.QUIT:
gameOver = True
if anyEvent.type == game.KEYDOWN:
if anyEvent.key == game.K_LEFT:
lead_x_change = -10
if anyEvent.key == game.K_RIGHT:
lead_x_change = 10
change_x += lead_x_change
DisplayScreen.fill(white)
game.draw.rect(DisplayScreen, black, [change_x,change_y,10,10])
game.display.update()
在新的代码行中,我们添加了额外的信息lead_x_change,它将被称为x坐标的变化,每当用户按下左右键盘键时,蛇就会自动移动。代码的突出部分(change_x += lead_x_change)负责使蛇持续移动,即使用户不按任何键(蛇游戏的规则)。
现在,当你按下一个键时,你可能会在游戏中看到另一种不寻常的行为。在我的情况下,我运行了我的游戏,当我开始按下左键时,蛇开始快速地连续地从左到右移动。这是由于帧速率的宽松性;我们现在必须明确指示游戏的帧速率,以限制游戏的运行速度。我们将在下一节中介绍这个问题。
使用帧速率概念
这个话题对我们来说并不陌生;我已经尽我最大的努力尽早介绍这个话题。在讨论时钟模块时,我们也学习了帧速率的概念。在本节中,我们将看到帧速率的概念在实际中的应用。到目前为止,我们已经制作了一个可以运行的游戏,但它在移动上没有任何限制。它在一个方向或另一个方向上持续移动,速度很快,我们当然不希望这样。我们真正想要的是使蛇持续移动,但在一定的帧速率内。我们将使用pygame.time.Clock来创建一个对象,它将跟踪我们游戏的时间。我们将使用tick函数来更新时钟。tick 方法应该每帧调用一次。通过每帧调用Clock.tick(15),游戏将永远不会以超过 15 FPS 的速度运行。
clock = game.time.Clock()
while not gameOver:
#event handling
#code from preceding topic
clock.tick(30) #FPS
重要的是要理解 FPS 并不等同于游戏中精灵的速度。开发者制作游戏的方式是可以在高端和低端设备上玩。你会发现在低配置的机器上游戏有点迟缓和抖动,但两种设备上的精灵或角色都会以平均速度移动。我们并不否认使用基于时间的运动游戏的机器,帧速率慢会导致视觉体验不佳,但它不会减慢动作的速度。
因此,为了制作一个视觉上吸引人的游戏,甚至在普及设备上也兼容,通常最好将帧速率设置在 20 到 40 FPS 之间。
在接下来的部分,我们将处理剩余的方向运动。处理这些运动并没有什么不同;它们可以通过矢量运动来处理。
处理方向运动
我们已经处理了x轴变化的运动。现在,让我们添加一些代码来处理y轴的运动。为了使蛇持续移动,我们必须使lead_y_change,它代表连续添加到当前位置的方向量,即使用户不按任何键盘键:
lead_y_change = 0
while not gameOver:
if anyEvent.type == game.KEYDOWN:
if anyEvent.key == game.K_LEFT:
lead_x_change = -10
lead_y_change = 0
elif anyEvent.key == game.K_RIGHT:
lead_x_change = 10
lead_y_change = 0
elif anyEvent.key == game.K_UP:
lead_y_change = -10
lead_x_change = 0
elif anyEvent.key == game.K_DOWN:
lead_y_change = 10
lead_x_change = 0
change_x += lead_x_change
change_y += lead_y_change
现在我们已经处理了蛇的每种可能的运动,让我们为蛇游戏定义边界。change_x和change_y的值表示头部的当前位置。如果头部撞到边界,游戏将终止。
while not gameOver:
if change_x >= 800 or change_x < 0 or change_y >= 600 or change_y < 0:
gameOver = True
现在,我们将学习另一个编程概念,这将使我们的代码看起来更清晰。到目前为止,我们已经为许多组件使用了数值,比如高度、宽度、FPS 等。但是如果你必须更改其中一个这些值会发生什么?在搜索代码和再次调试时会有很多开销。现在,我们可以创建常量变量,而不是直接使用这些数值,我们将这些值存储在其中,并在需要时检索它们。这个过程叫做去除硬编码。让我们为每个这些数值创建一个合适的名称的变量。代码应该看起来像这样:
#variable initialization step
import pygame as game
game.init()
color_white = (255,255,255)
color_black = (0,0,0)
color_red = (255,0,0)
#display size
display_width = 800
display_height = 600
DisplayScreen = game.display.set_mode((display_width,display_height))
game.display.set_caption('') #game title
gameOver = False
change_x = display_width/2
change_y = display_height/2
lead_x_change = 0
lead_y_change = 0
objectClock = game.time.Clock()
pixel_size = 10 #box size
FPS = 30 #frame rate
在变量初始化步骤中去除硬编码后,我们将转向主游戏循环。以下代码表示主游戏循环(在初始化步骤之后添加):
#main loop
while not gameOver:
for anyEvent in game.event.get():
if anyEvent.type == game.QUIT:
gameOver = True
if anyEvent.type == game.KEYDOWN:
if anyEvent.key == game.K_LEFT:
lead_x_change = -pixel_size
lead_y_change = 0
elif anyEvent.key == game.K_RIGHT:
lead_x_change = pixel_size
lead_y_change = 0
elif anyEvent.key == game.K_UP:
lead_y_change = -pixel_size
lead_x_change = 0
elif anyEvent.key == game.K_DOWN:
lead_y_change = pixel_size
lead_x_change = 0
#step 3: adding logic which will check if snake hit boundary or not
现在我们已经添加了处理用户事件的方法到主循环中,让我们重构代表逻辑的代码,比如当蛇撞到游戏边界时会发生什么,或者当蛇改变速度时会发生什么。在处理用户事件后,应该在主循环中添加以下代码:
if change_x >= display_width or change_x < 0 or change_y >= display_height
or change_y < 0:
gameOver = True
change_x += lead_x_change
change_y += lead_y_change
DisplayScreen.fill(color_white)
game.draw.rect(DisplayScreen, color_black,
[change_x,change_y,pixel_size,pixel_size])
game.display.update()
objectClock.tick(FPS)
前面的所有代码已经简要描述过了,我们在前面的三个代码块中实际上是将变量重构为一些有意义的名称,以消除硬编码;例如,为显示宽度添加一个变量名,为颜色代码添加一个变量名,等等。
在接下来的部分,我们将在屏幕上添加一个食物字符,并创建一些逻辑来检查蛇是否吃了苹果。
添加食物到游戏中
在屏幕上添加一个字符非常简单。首先,为字符创建一个位置,最后,在该位置上blit字符。在蛇游戏中,食物必须在任意位置渲染。因此,我们将使用随机模块创建随机位置。我创建了一个新的函数gameLoop(),它将使用前面部分的代码。我使用apple作为食物。稍后,我将为它添加一个苹果图像。以下代码定义了游戏的主循环:
def MainLoopForGame():
global arrow_key #to track which arrow key user pressed
gameOver = False
gameFinish = False
#initial change_x and change_y represent center of screen
#initial position for snake
change_x = display_width/2
change_y = display_height/2
lead_x_change = 0
lead_y_change = 0
在为游戏显示和角色定义一些初始值之后,让我们添加一些逻辑来为蛇游戏添加苹果(食物)(这应该在MainLoopForGame函数内)。
XpositionApple = round(random.randrange(0, display_width-pixel_size))
YpositionApple = round(random.randrange(0, display_height-pixel_size))
这两行代码将为x和y创建随机位置。确保导入随机模块。
接下来,我们需要在MainLoopForGame函数内定义主游戏循环。添加到主循环内的代码将处理多个事情,比如处理用户事件,绘制游戏角色等。让我们从以下代码中获取用户事件开始:
while not gameOver:
while gameFinish == True:
DisplayScreen.fill(color_white)
game.display.update()
#game is object of pygame
for anyEvent in game.event.get():
if anyEvent.type == pygame.KEYDOWN:
if anyEvent.key == pygame.K_q:
gameOver = True
gameFinish = False
if anyEvent.key == pygame.K_c:
MainLoopForGame()
前面的代码将很容易理解,因为我们在本章的前面已经做过这个。我们首先用白色填充游戏的背景屏幕,然后使用pygame模块的事件类获取事件。我们检查用户是否输入了q键,如果是,我们就退出游戏。同样,既然我们从用户那里得到了一个事件,让我们处理使蛇游戏移动的事件,比如左右箭头键。在获取用户事件后,应该添加以下代码:
#event to make movement for snake based on arrow keys
for anyEvent in game.event.get():
if anyEvent.type == game.QUIT:
gameOver = True
if anyEvent.type == game.KEYDOWN:
if anyEvent.key == game.K_LEFT:
arrow_key = 'left'
lead_x_change = -pixel_size
lead_y_change = 0
elif anyEvent.key == game.K_RIGHT:
arrow_key = 'right'
lead_x_change = pixel_size
lead_y_change = 0
elif anyEvent.key == game.K_UP:
arrow_key = 'up'
lead_y_change = -pixel_size
lead_x_change = 0
elif anyEvent.key == game.K_DOWN:
arrow_key = 'down'
lead_y_change = pixel_size
lead_x_change = 0
先前的代码已经编写好了,所以确保你按照程序的顺序进行。参考提供的代码资产github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter11。让我们把剩下的代码添加到主循环中,处理渲染蛇食物的逻辑。在处理用户事件之后,应该添加以下代码:
if change_x >= display_width or change_x < 0 or change_y >=
display_height or change_y < 0:
gameFinish = True
change_x += lead_x_change
change_y += lead_y_change
DisplayScreen.fill(color_white)
Width_Apple = 30
game.draw.rect(DisplayScreen, color_red, [XpositionApple,
YpositionApple, Width_Apple, Width_Apple])
game.draw.rect(DisplayScreen, color_black,
[change_x,change_y,pixel_size, pixel_size])
game.display.update()
objectClock.tick(FPS)
game.quit()
quit()
MainLoopForGame()
在代码的突出部分,我们将绘制一个红色的矩形,并将其渲染在由pixel_size= 10的高度和宽度的随机模块定义的位置。
现在我们已经为蛇添加了食物,让我们制作一个函数,使蛇的身体增长。到目前为止,我们只处理了蛇的头部;现在是时候制作一个函数,通过单位块来增加蛇的身体。请记住,只有在蛇吃了食物之后才会调用这个函数:
def drawSnake(pixel_size, snakeArray):
for eachSegment in snakeArray:
game.draw.rect(DisplayScreen, color_green [eachSegment[0],eachSegment[1],pixel_size, pixel_size])
在主游戏循环中,我们必须声明多个东西。首先,我们将声明snakeArray,它将包含蛇的身体。游戏开始时,蛇的长度为 1。每当蛇吃食物时,我们将增加它:
def MainLoopForGame():
snakeArray = []
snakeLength = 1
while not gameOver:
head_of_Snake = []
#at the beginning, snake will have only head
head_of_Snake.append(change_x)
head_of_Snake.append(change_y)
snakeArray.append(head_of_Snake)
if len(snakeArray) > snakeLength:
del snakeArray[0] #deleting overflow of elements
for eachPart in snakeArray[:-1]:
if eachPart == head_of_Snake:
gameFinish = True #when snake collides with own body
drawSnake(pixel_size, snakeArray)
game.display.update()
变量的名称告诉你一切你需要知道的。我们以前做过很多次,也就是为蛇的头部制作列表,并检查它是否与蛇的身体发生碰撞。蛇方法调用pixel_size,这是蛇的尺寸,以及包含与蛇身体相关的位置列表的蛇列表。蛇将根据这些列表进行blit,通过在snake函数内定义的绘制语句。
接下来,我们需要定义逻辑来使蛇吃食物。这个逻辑已经被反复使用,在 pygame 的情况下也不例外。每当蛇的头部位置与食物位置相同时,我们将增加蛇的长度,并在一个新的随机位置生成食物。确保在更新显示后,在主游戏循环中添加以下代码:
#condition where snake rect is at the top of apple rect
if change_x > XpositionApple and change_x < XpositionApple + Width_Apple or change_x + pixel_size > XpositionApple and change_x + pixel_size < XpositionApple + Width_Apple:
if change_y > YpositionApple and change_y < YpositionApple +
Width_Apple:
#generate apple to new position
XpositionApple = round(random.randrange(0,
display_width-pixel_size))
YpositionApple = round(random.randrange(0,
display_height-pixel_size))
snakeLength += 1
elif change_y + pixel_size > YpositionApple and change_y + pixel_size
< YpositionApple + Width_Apple:
XpositionApple = round(random.randrange(0, display_width-
pixel_size))
YpositionApple = round(random.randrange(0, display_height-
pixel_size))
snakeLength += 1
由于我们能够添加一些逻辑来检查蛇是否吃了食物,并做出相应的反应,现在是时候为角色添加精灵或图像了。正如我们之前提到的,我们将添加我们自己的蛇头,而不是使用沉闷的矩形形状。让我们开始创建一个。
添加蛇的精灵
最后,我们可以开始使我们的游戏更具吸引力——我们将制作蛇的头。我们不需要额外的知识来为游戏角色创建图像。你也可以从互联网上下载图像并使用它们。然而,在这里,我将向你展示如何为自己创建一个,并如何在我们的蛇游戏中使用它。
按照以下步骤,逐行进行:
-
打开任何绘图应用程序,或者在搜索栏中搜索绘图,然后打开应用程序。
-
按下Ctrl + W来调整和扭曲你选择的图片,或者直接使用上方菜单栏的调整按钮。这将打开一个新的调整窗口。可以按百分比和像素进行调整。使用百分比调整并保持 20x20 的纵横比,即水平:20,垂直:20。
-
之后,你会得到一个绘制屏幕。选择你想要制作的蛇头的颜色。在制作游戏时,我们创建了一个绿色的蛇身体;因此,我也会选择绿色作为蛇头的颜色。我会使用画笔画出类似以下图片的东西。如果你愿意,你可以花时间创作一个更好的。完成后,保存文件:

-
现在,你必须使图像的背景透明。你也可以使用一些在线工具,但我将使用之前提到过的 GIMP 软件。你必须从官方网站上下载它。它是开源的,可以免费使用。去网站上下载 GIMP:
www.gimp.org/downloads/。 -
用 GIMP 软件打开你之前制作的蛇头。从最上面的菜单中选择图层选项卡,选择透明度,然后点击添加 Alpha 通道。这将添加一个通道,可以用来使我们图像的背景透明。
-
从菜单屏幕中点击颜色选项卡。将会出现一个下拉菜单。点击颜色到 Alpha,使背景透明。将该文件导出到与您的 Python 文件存储在同一目录中。
现在我们有了蛇头的精灵,让我们在 Python 文件中使用blit命令来渲染它。如你所知,在使用任何图像之前,你必须导入它。由于我已经将蛇头图像保存在与 Python 文件相同的目录中,我可以使用pygame.image.load命令:
image = game.image.load('snakehead.png')
在drawSnake方法的主体内,你必须 blit 图像;就像这样:
DisplayScreen.blit(image, (snakeArray[-1][0], snakeArray[-1][1]))
现在,当你运行游戏时,你会观察到一个奇怪的事情。当我们按下任何一个箭头键时,头部不会相应地旋转。它将保持在默认位置。因此,为了使精灵根据方向的移动而旋转,我们必须使用transform.rotate函数。观察蛇的方法,因为它有一种方法可以在没有旋转的情况下blit图像。现在,我们将添加几行代码,使精灵旋转:
def drawSnake(pixel_size, snakeArray):
if arrow_key == "right":
head_of_Snake = game.transform.rotate(image, 270) #making rotation of 270
if arrow_key== "left":
head_of_Snake = game.transform.rotate(image, 90)
if arrow_key== "up":
head_of_Snake = image #default
if arrow_key== "down":
head_of_Snake = game.transform.rotate(image, 180)
DisplayScreen.blit(head_of_Snake, (snakeArray[-1][0], snakeArray[-1][1]))
for eachSegment in snakeArray[:-1]:
game.draw.rect(DisplayScreen, color_green,[eachSegment[0],eachSegment[1],
pixel_size, pixel_size])
现在,不再使用苹果的矩形框,让我从互联网上下载一个苹果的样本,以 PNG 的形式(透明背景),也blit它:
appleimg = game.image.load('apple.png')
#add apple.png file in same directory of python file
while not gameOver:
#code must be added before checking if user eats apple or not
DisplayScreen.blit(appleimg, (XpositionApple, YpositionApple))
让我们运行游戏并观察输出。虽然蛇头看起来更大了,但我们可以随时调整它的大小:

在下一节中,我们将学习如何为我们的游戏添加一个菜单。菜单是每次打开游戏时看到的屏幕,通常是一个欢迎屏幕。
为游戏添加一个菜单
为任何游戏添加一个介绍屏幕需要我们具备使用pygame模块处理字体的知识。pygame 提供了一个功能,使我们可以使用不同类型的字体,包括改变它们的大小的功能。pygame.font模块用于向游戏添加字体。字体用于向游戏屏幕添加文本。由于介绍或欢迎屏幕需要玩家显示一个包含字体的屏幕,我们必须使用这个模块。调用SysFont方法向屏幕添加字体。SysFont方法接受两个参数:第一个是字体的名称,第二个是字体的大小。以下一行代码初始化了相同字体的三种不同大小:
font_small = game.font.SysFont("comicsansms", 25)
font_medium = game.font.SysFont("comicsansms", 50)
font_large = game.font.SysFont("comicsansms", 80)
我们将首先使用text_object函数创建一个表面,用于小号、中号和大号字体。文本对象函数将使用文本创建一个矩形表面。传递给此方法的文本将添加到框形对象中,并从中返回,如下所示:
def objects_text(sample_text, sample_color, sample_size):
if sample_size == "small":
surface_for_text = font_small.render(sample_text, True, sample_color)
elif sample_size == "medium":
surface_for_text= font_medium.render(sample_text, True, sample_color)
elif sample_size == "large":
surface_for_text = font_large.render(sample_text, True, sample_color)
return surface_for_text, surface_for_text.get_rect()
让我们在 Python 文件中创建一个新的函数,使用上述字体向屏幕添加一条消息:
def display_ScreenMessage(message, font_color, yDisplace=0, font_size="small"):
textSurface, textRectShape = objects_text(message, font_color, font_size)
textRectShape.center = (display_width/ 2), (display_height/ 2) + yDisplace
DisplaySurface.blit(textSurface, textRectShape)
向screen方法传递的消息将创建一个矩形表面,以blit传递给它的文本作为msg。默认字体大小是小号,文本居中对齐在矩形表面的中心。现在,让我们为我们的游戏创建一个游戏介绍方法:
def intro_for_game(): #function for adding game intro
intro_screen = True while intro_screen:
for eachEvent in game.event.get():
if eachEvent.type == game.QUIT:
game.quit()
quit()
if eachEvent.type == game.KEYDOWN:
if eachEvent.key == game.K_c:
intro_screen = False
if eachEvent.key == game.K_q:
game.quit()
quit()
DisplayScreen.fill(color_white)
display_ScreenMessage("Welcome to Snake",
color_green,
-99,
"large")
display_ScreenMessage("Made by Python Programmers",
color_black,
50)
display_ScreenMessage("Press C to play or Q to quit.",
color_red,
180)
game.display.update()
objectClock.tick(12)
这个游戏的intro方法在游戏loop方法调用之前被调用。例如,看看下面的代码:
intro_for_game()
MainLoopForGame()
最后,欢迎菜单的输出应该是这样的:

最后,我们的游戏已经准备好分发了。你可能会看到我们的游戏是一个扩展名为.py的 Python 文件,它不能在没有安装 Python 的机器上执行。因此,在下一节中,我们将学习如何将 Python 文件转换为可执行文件,以便我们可以在 Windows 机器上全球分发我们的游戏。
转换为可执行文件
如果您已经制作了自己的 pygame 游戏,显然您希望与朋友和家人分享。在互联网世界中,共享文件非常容易,但当另一端的用户没有预安装 Python 时,问题就会出现。不是每个人都能为了测试您的游戏而安装 Python。更好的想法是制作可在许多这些机器上执行的可执行文件。我们将在本节中学习如何转换为.exe,其他版本(Linux 和 Mac)将在接下来的章节中介绍。
如果使用 Python 提供的模块,将 Python 文件转换为可执行文件会更容易。其中有几个模块——py2exe和cx_Freeze。我们将在本节中使用第一个。
使用 py2exe
要将 Python 文件转换为可执行文件,我们可以使用另一个名为py2exe的 Python 模块。py2exe模块不是 pygame 中预安装的——它不是标准库——但可以通过使用以下命令进行下载:
pip install py2exe
OR
py -3.7 -m pip install py2exe
下载py2exe模块后,转到包含您的 Python 文件的文件夹。在该位置打开命令提示符或终端并运行代码。它将把您的 Python 文件打包成一个.exe文件,或者成为可执行文件。以下命令将搜索并复制脚本使用的所有文件到一个名为dist的文件夹中。在dist中将会有一个snake.exe文件;这个文件将是 Python 代码的输出模拟,可以在没有安装 Python 的机器上执行。例如,您的朋友可能没有在他们的机器上安装 Python,但他们仍然可以运行这个文件。为了将游戏分发到任何其他 Windows 机器,您只需发送dist文件夹或snake.exe文件的内容。只需运行以下命令:
python snake.py py2exe #conversion command
这将创建一个名为snake的游戏,并带有.exe的扩展名。您可以在 Windows 平台上分发这些文件并从中获得响应。恭喜!你终于做到了。现在,让我们学习使用 pygame 进行游戏测试。
游戏测试和可能的修改
有时,您的机器可能会出现内存不足的情况。如果内存不足,并且您尝试将更多图像加载到游戏中,即使使用了 pygame 的最大努力,此过程也将被中止。pygame.image.load必须伴随一些内存才能正常执行任务。在内存不足的情况下,您可以预测到肯定会触发某种异常。即使有足够的内存,如果尝试加载不在硬盘驱动器中的图像,或者说,在编写文件名时出现了拼写错误,您可能会收到异常。因此,最好事先处理它们,这样我们就不必事后再去调试它们。
其次,让我们检查当我们向set_mode方法提供不寻常的屏幕尺寸时会发生什么。回想一下,set_mode是我们用来创建Surface对象的方法。例如,假设我们忘记向set_mode添加两个值,而只添加了一个。在这种情况下,我们也会触发错误:
screen = pygame.display.set_mode((640))
TypeError: 2 argument expected
假设,与其忘记为高度和宽度添加适当的尺寸,如果我们将高度值添加为 0 会发生什么?在 PyCharm IDE 的情况下,这个问题不会创建任何异常。相反,程序将无限运行,导致您的机器崩溃。然而,这些程序通常会抛出一个pygame.error: cannot set 0 sized display的异常。现在您知道了pygame可能出错的地方,可以捕获这些异常并相应地处理它们:
try:
display = pygame.display.set_mode((640,0))
except pygame.error:
print("Not possible to create display")
exit()
因此,最好明智地选择您的显示屏,以消除任何不必要的异常。但更有可能的是,如果您尝试加载不在硬盘上的图像,您可能会遇到pygame错误的异常。因此,处理异常是一个很好的做法,以确保游戏的精灵或图像被正确加载。
总结
在本章中,我们研究了pygame模块,并发现了在游戏开发中使用它的原因。我们从下一章开始涵盖的大多数游戏都将在某种程度上基于pygame模块。因此,在继续之前,请确保自己使用 pygame 制作一个简单的游戏。
我们开始学习如何使用 pygame 对象制作游戏。我们学到了各种东西,包括处理涉及鼠标和键盘等输入设备的用户按键事件;我们制作了精灵动画;我们学习了颜色属性;并且使用向量运动处理了不同的对角线和方向性移动。我们使用简单的绘图应用程序创建了自己的精灵,并使用 GIMP 应用程序添加了 alpha 属性。我们尝试通过整合交互式游戏屏幕,也就是菜单屏幕,使游戏更具互动性。最后,我们学会了如何使用py2exe模块将 Python 文件转换为可执行文件。
本章的主要目标是让您熟悉精灵的使用,以便您可以制作 2D 游戏。您还学会了如何处理用户事件和不同的移动,包括对角线移动。您还学会了如何使用外部软件创建自定义精灵和图像,以及在游戏中使用它们的方法。不仅如此,您还熟悉了颜色和rect对象的概念,并学会了如何使用它们使游戏更具用户互动性,通过部署菜单和得分屏幕。
在下一章中,我们将运用本章学到的概念制作自己的 flappy bird 克隆游戏。除了本章学到的内容,我们还将学习游戏动画、角色动画、碰撞原理、随机对象生成、添加分数等许多概念。
第十二章:学习角色动画、碰撞和移动
动画是一门艺术。这引发了关于如何通过为每个角色添加纹理或皮肤,或者通过保持无可挑剔的图形用户界面来创建模拟人物或物体的物理行为的虚拟世界的问题。在创建动画时,我们不需要了解控制器或物理设备的工作原理,但动画是物理设备和游戏角色之间的媒介。动画通过在图像视图中以适当的阴影和动作引导玩家,因此它是一门艺术。作为程序员,我们负责游戏角色在特定方向移动的位置和原因,而动画师负责它们的外观和动作。
在 Python 的pygame模块中,我们可以使用精灵来创建动画和碰撞-这是大型图形场景的一部分的二维图像。也许我们可以自己制作一个,或者从互联网上下载一个。在使用 pygame 加载这样的精灵之后,我们将学习构建游戏的两个基本模块:处理用户事件和构建动画逻辑。动画逻辑是一个简单而强大的逻辑,它使精灵或图像在用户事件控制下朝特定方向移动。
通过本章,您将熟悉游戏控制器的概念以及使用它为游戏角色创建动画的方法。除此之外,您还将了解有关碰撞原理以及使用 pygame 掩模方法处理碰撞的方法。不仅如此,您还将学习处理游戏角色的移动方式,如跳跃、轻拍和滚动,同时制作类似 flappy bird 的游戏。
在本章中,我们将涵盖以下主题:
-
游戏动画概述
-
滚动背景和角色动画
-
随机对象生成
-
检测碰撞
-
得分和结束屏幕
-
游戏测试
技术要求
您需要以下要求清单才能完成本章:
-
Pygame 编辑器(IDLE)版本 3.5 或更高。
-
Pycharm IDE(参考第一章,了解 Python-设置 Python 和编辑器,进行安装程序)。
-
Flappy Bird 游戏的代码资产和精灵可在本书的 GitHub 存储库中找到:
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter12
观看以下视频,查看代码的运行情况:
了解游戏动画
就像你在电脑游戏中看到的一切一样,动画模仿现实世界,或者试图创造一个让玩家感觉自己正在与之交互的世界。用二维精灵绘制游戏相当简单,就像我们在上一章中为贪吃蛇游戏制作角色时所看到的那样。即使是二维角色,我们也可以通过适当的阴影和动作创建三维运动。使用pygame模块可以更容易地为单个对象创建动画;我们在上一章中看到了一点实际操作,当时我们为贪吃蛇游戏创建了一个简单的动画。在本节中,我们将使用pygame模块为多个对象创建动画。我们将制作一个简单的程序,用于创建下雪的动画。首先,我们将使用一些形状填充雪花(在此程序中,我们使用的是圆形几何形状,但您可以选择任何形状),然后创建一些动画逻辑,使雪花在环境中移动。
在编写代码之前,确保你进行了一些头脑风暴。由于在上一章中我们编写了一些高级逻辑,所以这一部分对你来说可能更容易,但是确保你也学习了我们在这里做的事情,因为对接下来的部分非常有用,我们将开始制作 Flappy Bird 游戏的克隆版本。
正如我们所知,雪花动画需要一个位置(x,y)来渲染雪花。这个位置可以任意选择,因此你可以使用随机模块来选择这样的位置。以下代码展示了如何使用pygame模块在随机位置绘制任何形状。由于使用了for循环进行迭代,我们将使用它来创建一个迭代的范围,最多进行 50 次调用(eachSnow的值从 0 到 49)。回想一下前一章,你学习了如何使用 pygame 的draw模块将任何形状绘制到屏幕上。考虑到这一点,让我们看看以下代码:
#creates snow
for eachSnow in range(50):
x_pos = random.randrange(0, 500)
y_pos = random.randrange(0, 500)
pygame.draw.circle(displayScreen, (255,255,255) , [x_pos, y_pos], 2) #size:2
想象一下,我们使用了前面的代码来制作动画,这将绘制圆形雪花。运行后,你会发现输出中有些奇怪的地方。你可能已经猜到了,但让我为你解释一下。前面的代码制作了一个圆圈——在某个随机位置——并且先前制作的圆圈在新圆圈创建时立即消失。我们希望我们的代码生成多个雪花,并确保先前制作的圆圈位于右侧位置而不是消失。你发现前面的代码有点 bug 吗?既然你知道了错误的原因,花点时间考虑如何解决这个错误。你可能会想到一个普遍的想法,那就是使用数据结构来解决这个问题。我倾向于使用列表。让我们对前面的代码进行一些修改:
for eachSnow in range(50):
x_pos = random.randrange(0, 500)
y_pos = random.randrange(0, 500)
snowArray.append([x_pos, y_pos])
现在,在snowArray列表中,我们已经添加了随机创建的雪的位置,即x和y。对于雪的多个x_pos和y_pos值,将形成一个嵌套列表。例如,一个列表可能看起来像[[20,40],[40,30],[30,33]],表示随机制作的三个圆形雪花。
对于使用前面的for循环创建的每一片雪花,你必须使用另一个循环进行渲染。获取snow_list变量的长度可能会有所帮助,因为这将给我们一个关于应该绘制多少雪花的想法。对于由snow_list指示的位置数量,我们可以使用pygame.draw模块绘制任何形状,如下所示:
for eachSnow in range(len(snowArray)):
# Draw the snow flake
pygame.draw.circle(displayScreen, (255,255,255) , snowArray[i], 2)
你能看到使用pygame模块绘制图形有多容易吗?即使这对你来说并不陌生,这个概念很快就会派上用场。接下来,我们将看看如何让雪花向下飘落。按照以下步骤创建圆形雪花的向下运动:
- 首先,你必须让雪向下移动一个单位像素。你只需要对
snowArray元素的y_pos坐标进行更改,如下所示:
color_WHITE = (255, 255, 255)
for eachSnow in range(len(snowArray)):
# Draw the snow flake
pygame.draw.circle(displayScreen, color_WHITE, snow_Array[i], 2)
# moving snow one step or pixel below
snowArray[i][1] += 1
- 其次,你必须确保,无论何时雪花消失在视野之外,都会不断地创建。在步骤 1中,我们已经为圆形雪花创建了向下运动。在某个时候,它将与较低的水平边界相撞。如果它碰到了这个边界,你必须将它重置,以便从顶部重新渲染。通过添加以下代码,圆形雪花将在屏幕顶部使用随机库进行渲染:
if snowArray[i][1] > 500:
# Reset it just above the top
y_pos = random.randrange(-50, -10)
snowArray[i][1] = y_pos
# Give it a new x position
x_pos = random.randrange(0, 500)
snowArray[i][0] = y_pos
这个动画的完整代码如下(带有注释的代码是不言自明的):
- 首先,我们编写的前面的代码需要重新定义和重构,以使代码看起来更好。让我们从初始化开始:
import pygame as p
import random as r
# Initialize the pygame
p.init()
color_code_black = [0, 0, 0]
color_code_white = [255, 255, 255]
# Set the height and width of the screen
DISPLAY = [500, 500]
WINDOW = p.display.set_mode(DISPLAY)
# Create an empty list to store position of snow
snowArray = []
- 现在,在初始化的下面添加你的
for循环:
# Loop 50 times and add a snow flake in a random x,y position
for eachSnow in range(50):
x_pos = r.randrange(0, 500)
y_pos = r.randrange(0, 500)
snowArray.append([x_pos, y_pos])
objectClock = game.time.Clock()
- 类似地,我们将通过创建主循环来结束逻辑,该循环将一直循环,直到用户显式点击关闭按钮:
# Loop until the user clicks the close button.
finish = False
while not finish:
for anyEvent in p.event.get(): # User did something
if anyEvent.type == p.QUIT: # If user clicked close
finish = True # Flag that we are done so we
exit this loop
# Set the screen background
WINDOW.fill(BLACK)
# Process each snow flake in the list
for eachSnow in range(len(snowArray)):
# Draw the snow flake
p.draw.circle(WINDOW, color_code_white, snowArray[i], 2)
# One step down for snow [falling of snow]
snowArray[i][1] += 1
- 最后,检查雪花是否在边界内:
# checking if snow is out of boundary or not
if snowArray[i][1] > 500:
# reset if it from top
y_pos = r.randrange(-40, -10)
snowArray[i][1] = y_pos
# New random x_position
x_pos = r.randrange(0, 500)
snowArray[i][0] = x_pos
- 最后,更新屏幕上已经绘制的内容:
# Update screen with what you've drawn.
game.display.update()
objectClock.tick(20)
#if you remove following line of code, IDLE will hang at exit
game.quit()
上述代码由许多代码片段组成:初始化游戏变量,然后创建游戏模型。在步骤 3中,我们创建了一些简单的逻辑来控制游戏的动画。我们在步骤 3中构建了两个代码模型,使我们的游戏对用户进行交互(处理用户事件),并创建一个游戏对象(圆形降雪),它使用for循环进行渲染。尽管我们将在接下来的章节中创建更复杂的动画,但这是一个很好的动画程序开始。您可以清楚地看到,在幕后,创建动画需要使用循环、条件和游戏对象。我们使用 Python 编程范式,如 if-else 语句、循环、算术和向量操作来创建游戏对象动画。
除了动画几何形状,您甚至可以动画精灵或图像。为此,您必须制作自己的精灵或从互联网上下载一些。在接下来的部分中,我们将使用pygame模块来动画精灵。
动画精灵
动画精灵与动画几何形状没有什么不同,但它们被认为是复杂的,因为您必须编写额外的代码来使用动画逻辑blit这样的图像。然而,这种动画逻辑对于您加载的每个图像都不会相同;它因游戏而异。因此,您必须事先分析适合您的精灵的动画类型,以便您可以相应地编写代码。在本节中,我们不打算创建任何自定义图像;相反,我们将下载一些(感谢互联网!)。我们将在这些精灵中嵌入动画逻辑,以便我们的程序将促进适当的阴影和移动。
为了让您了解动画静态图像或精灵有多容易,我们将创建一个简单的程序,该程序将加载大约 15 个角色图像(向左和向右移动)。每当用户按键盘上的左键或右键时,我们将blit(渲染)它们。执行以下步骤来学习如何创建一个动画精灵程序:
- 首先,您应该从为
pygame程序创建一个基本模板开始。您必须导入一些重要的模块,为动画控制台创建一个表面,并声明空闲友好的quit()函数。
import pygame
pygame.init()
win = pygame.display.set_mode((500,480)) pygame.quit()
- 其次,您必须加载images目录中列出的所有精灵和图像。该目录包含几个精灵。您必须下载它并保存在存储 Python 文件的目录中(可以在 GitHub 上找到 sprites/images 文件,网址为
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter12):
#walk_Right contains images in which character is turning towards
Right direction
walkRight = [pygame.image.load('Right1.png'),
pygame.image.load('Right2.png'), pygame.image.load('Right3.png'),
pygame.image.load('Right4.png'), pygame.image.load('Right5.png'),
pygame.image.load('Right6.png'), pygame.image.load('Right7.png'),
pygame.image.load('Right8.png'), pygame.image.load('Right9.png')] #walk_left contains images in which character is turning towards
left direction
walkLeft = [pygame.image.load('Left1.png'),
pygame.image.load('Left2.png'), pygame.image.load('Left3.png'),
pygame.image.load('Left4.png'), pygame.image.load('Left5.png'),
pygame.image.load('Left6.png'), pygame.image.load('Left7.png'),
pygame.image.load('Left8.png'), pygame.image.load('Left9.png')]
#Background and stand still images
background = pygame.image.load('bg.jpg')
char = pygame.image.load('standing.png')
- 接下来,我们需要声明一些基本变量,例如角色的初始位置和速度,即游戏精灵每单位按键击移动的距离。在下面的代码中,我已经将速度声明为五个单位,这意味着游戏角色将从当前位置移动固定的 5 个像素:
x = 50
y = 400
width = 40
height = 60
vel = 5
clock = pygame.time.Clock()
- 您必须声明一些额外的变量,以便根据用户在键盘上按下什么来跟踪精灵的移动。如果按下左箭头键,则
left变量将为True,而如果按下右箭头键,则right变量将为False。walkCount变量将跟踪按下键的次数:
left = False
right = False
walkCount = 0
在这里,我们已经完成了任何 pygame 程序的基本布局——导入适当的模块,声明变量以跟踪移动,加载精灵等等。程序的另外两个部分是最重要的,所以请确保您理解它们。我们将开始创建一个主循环,像往常一样。这个主循环将处理用户事件,也就是说,当用户按下左或右键时要做什么。其次,您必须创建一些动画逻辑,这将根据用户事件确定在什么时间点blit什么图像。
我们将从处理用户事件开始。按照以下步骤进行:
- 首先,您必须声明一个主循环,它必须是一个无限循环。我们将使用
tick方法为游戏提供FPS。正如您可能记得的那样,这个方法应该在每帧调用一次。它将计算自上一次调用以来经过了多少毫秒:
finish = False
while not finish: clock.tick(27)
- 其次,开始处理关键的用户事件。在简单的精灵动画中,您可以从处理两种基本移动开始:左和右。在接下来的部分中,我们将通过处理跳跃/轻击动作来制作游戏。这段代码应该写在一个 while 循环内:
while not finish:
clock.tick(27)
for anyEvent in pygame.event.get():
if anyEvent.type == pygame.QUIT:
finish = True
keys = pygame.key.get_pressed()
#checking key pressed and if character is at x(boundary) or not?
if keys[pygame.K_LEFT] and x > vel:
x -= vel #going left by 5pixels
left = True
right = False
#checking RIGHT key press and is character coincides with
RIGHT boundary.
# value (500 - vel - width) is maximum width of screen,
thus x should be less
elif keys[pygame.K_RIGHT] and x < 500 - vel - width:
x += vel #going right by 5pixels
left = False
right = True
else:
#not pressing any keys
left = False
right = False
walkCount = 0
Animation_Logic()
观察上述代码的最后一行——对Animation_Logic()函数的调用已经完成。然而,这个方法还没有被声明。这个方法是由精灵或图像制作的任何游戏的核心模块。在动画逻辑内编写的代码将执行两个不同的任务:
-
从加载精灵时定义的图像列表中 blit 或渲染图像。在我们的情况下,这些是
walkRight、walkLeft、bg和char。 -
根据逻辑重新绘制游戏窗口,这将检查从图像池中选择哪个图像。请注意,
walkLeft包含九个不同的图像。这个逻辑将从这些图像中进行选择。
现在我们已经处理了用户事件,让我们学习如何为之前加载的精灵制作动画逻辑。
动画逻辑
精灵是包含角色并具有透明背景的静态图像。这些精灵的额外 alpha 信息是必不可少的,因为在 2D 游戏中,我们希望用户只看到角色而不是他们的背景。想象一下一个游戏,其中一个角色与单调的背景 blit。这会给玩家留下对游戏的坏印象。例如,以下精灵是马里奥角色。假设您正在制作一个马里奥游戏,并且从以下精灵中裁剪一个角色,却忘记去除其蓝色背景。角色连同其蓝色背景将在游戏中呈现,使游戏变得糟糕。因此,我们必须手动使用在线工具或离线工具(如 GIMP)去除(如果有的话)角色背景。精灵表的一个示例如下:

现在,让我们继续我们的精灵动画。到目前为止,我们已经使用pygame声明了处理事件的模板;现在,让我们编写我们的动画逻辑。正如我们之前所断言的那样,动画逻辑是简单的逻辑,将在图像之间进行选择并相应地进行 blit。现在让我们制定这个逻辑:
def Animation_Logic():
global walkCount
win.blit(background, (0,0))
#check_1
if walkCount + 1 >= 27:
walkCount = 0
if left:
win.blit(walkLeft[walkCount//3], (x,y))
walkCount += 1
elif right:
win.blit(walkRight[walkCount//3], (x,y))
walkCount += 1
else:
win.blit(char, (x, y))
walkCount = 0
pygame.display.update()
你将看到的第一件事是global变量。walkCount变量最初在主循环中声明,并计算用户按下任何键的次数。然而,如果你删除global walkCount语句,你将无法在Animation_Logic函数内改变walkCount的值。如果你只想在函数内访问或打印walkCount的值,你不需要将其定义为全局变量。但是,如果你想在函数内操作它的值,你必须将其声明为全局变量。blit命令将采用两个参数:一个是需要渲染的精灵,另一个是精灵必须渲染到屏幕上的位置。在前面的代码中,写在#check_1之后的代码是为了在角色到达极限位置时对其进行限定。这是一个检查,我们必须渲染一个char图像,这是一个角色静止的图像。
渲染精灵始于我们检查左移动是否激活。如果为True,则在(x, y)位置blit图像。(x, y)的值由事件处理程序操作。每当用户按下左箭头键时,x的值将从其先前的值减少五个单位,并且图像将被渲染到该位置。由于这个动画只允许角色在水平方向上移动,要么在正的X轴上,要么在负的X轴上,y 坐标没有变化。同样,对于右移动,我们将从walkRight的图像池中渲染图像到指定的(x, y)位置。在代码的 else 部分,我们blit一个 char 图像,这是一个角色静止的图像,没有移动。因此,walkCount等于零。在我们blit完所有东西之后,我们必须更新它以反映这些变化。我们通过调用display.update方法来做到这一点。
让我们运行动画并观察输出:

在控制台中,如果你按下左箭头键,角色将开始向左移动,如果你按下右箭头键,角色将向右移动。由于 y 坐标没有变化,并且我们没有在主循环中处理任何事件来促进垂直移动,角色只能在水平方向移动。我强烈建议你尝试这些精灵,并尝试通过改变 y 坐标来处理垂直移动。虽然我已经为你提供了一个包含图像列表的资源列表,但如果你想在游戏中使用其他精灵,你可以去以下网站下载任何你想要的精灵:www.spriters-resource.com/。这个网站对于任何 pygame 开发者来说都是一个天堂,所以一定要去访问并下载任何你想要的游戏精灵,这样你就可以尝试这个(用马里奥来尝试可能会更好)。
从下一节开始,我们将开始制作 Flappy Bird 游戏的克隆。我们将学习滚动背景和角色动画、随机对象生成、碰撞和得分等技术。
滚动背景和角色动画
现在你已经了解足够关于 pygame 精灵和动画,你有能力制作一个包含复杂精灵动画和多个对象的游戏。在这一部分,我们将通过制作一个 Flappy Bird 游戏来学习滚动背景和角色动画。这个游戏包含多个对象,鸟是游戏的主角,游戏中的障碍物是一对管道。如果你以前没有玩过这个游戏,可以访问它的官方网站试一试:flappybird.io/。
说到游戏,制作起来并不难,但通过照顾游戏编程的多个方面,对于初学者来说可能是一项艰巨的任务。话虽如此,我们不打算自己制作任何精灵——它们在互联网上是免费提供的。这使得我们的任务变得更加容易。由于游戏角色的设计是开源的,我们可以直接专注于游戏的编码部分。但是,如果你想从头开始设计你的游戏角色,可以使用任何简单的绘图应用程序开始制作它们。对于这个 Flappy Bird 游戏,我将使用免费提供的精灵。
我已经在 GitHub 链接中添加了资源。如果你打开图像文件夹,然后打开背景图像文件,你会看到它包含特定高度和宽度的背景图像。但是在 Flappy Bird 游戏中,你可以观察到背景图像是连续渲染的。因此,使用 pygame,我们可以制作一个滚动背景,这样我们就可以连续blit背景图像。因此,我们可以使用一张图像并连续blit它,而不是使用成千上万份相同的背景图像副本。
让我们从制作一个角色动画和一个滚动背景开始。以下步骤向我们展示了如何使用面向对象编程为每个游戏角色制作一个类:
- 首先,你必须开始声明诸如 math、os(用于加载具有指定文件名的图像)、random、collections 和 pygame 等模块。你还必须声明一些变量,表示每秒帧数设置、动画速度和游戏控制台的高度和宽度:
import math
import os
from random import randint
from collections import deque
import pygame
from pygame.locals import *
Frame_Rate = 60 #FPS
ANIMATION_SPEED = 0.18 # pixels per millisecond
WINDOW_WIDTH = 284 * 2 # Background image sprite size: 284x512 px;
#our screen is twice so to rendered twice: *2
WINDOW_HEIGHT = 512
-
现在,让我们将图像文件夹中的所有图像加载到 Python 项目中。我还将创建两个方法,用于在帧和毫秒之间进行转换。
-
让我们看看
loading_Images函数是如何通过以下代码工作的:
def loading_Images():
"""Function to load images"""
def loading_Image(image_name):
"""Return the sprites of pygame by create unique filename so that
we can reference them"""
new_filename = os.path.join('.', 'images', image_name)
image = pygame.image.load(new_filename) #loading with pygame
module
image.convert()
return image
return {'game_background': loading_Image('background.png'),
'endPipe': loading_Image('endPipe.png'),
'bodyPipe': loading_Image('bodyPipe.png'),
# GIF format file/images are not supported by Pygame
'WingUp': loading_Image('bird-wingup.png'),
'WingDown': loading_Image('bird-wingdown.png')}
在前面的程序中,我们定义了loading_Image函数,它从特定目录加载/提取所有图像,并将它们作为包含名称作为键和图像作为值的字典返回。让我们通过以下参数分析这样一个字典中的键和值将如何存储:
-
background.png:Flappy Bird 游戏的背景图像。 -
img:bird-wingup.png:这张 Flappy Bird 的图像有一只翅膀向上指,当在游戏中点击屏幕时渲染。 -
img:bird-wingdown.png:这部分图像在 Flappy Bird 自由下落时使用,也就是当用户没有点击屏幕时。这张图像有 Flappy Bird 的翅膀向下指。 -
img:bodyPipe.png:这包含了可以用来创建单个管道的离散身体部位。例如,在 Flappy Bird 游戏中,应该从顶部和底部渲染两个离散的管道片段,它们之间留有一个间隙。 -
img:endPipe.png:这部分图像是管道对的底部。有两种类型的这样的图像:小管道对的小管道底部和大管道对的大管道底部图像。
同样,我们有一个嵌套的loading_Image函数,用于为每个加载的精灵创建一个文件名。它从/images/文件夹加载图像。在连续加载每个图像之后,它们会使用convert()方法进行调用,以加快 blitting(渲染)过程。传递给loading_Image函数的参数是图像的文件名。image_name是给定的文件名(连同其扩展名;.png是首选)通过os.path.join方法加载它,以及convert()方法以加快 blitting(渲染)过程。
加载图像后,我们需要创建两个函数,用于在指定的帧速率下执行帧率的转换(请参阅第十章,使用海龟升级贪吃蛇游戏,了解更多关于帧速率的信息)。这些函数集主要执行从帧到毫秒的转换以及相反的转换。帧到毫秒的转换很重要,因为我们必须使用毫秒来移动Bird角色,也就是鸟要上升的毫秒数,一个完整的上升需要Bird.CLIMB_DURATION毫秒。如果你想让鸟在游戏开始时做一个(小)上升,可以使用这个。让我们创建这样两组函数(代码的详细描述也可以在 GitHub 上找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter12):
def frames_to_msec(frames, fps=FPS):
"""Convert frames to milliseconds at the specified framerate. Arguments: frames: How many frames to convert to milliseconds. fps: The framerate to use for conversion. Default: FPS. """ return 1000.0 * frames / fps
def msec_to_frames(milliseconds, fps=FPS):
"""Convert milliseconds to frames at the specified framerate. Arguments: milliseconds: How many milliseconds to convert to frames. fps: The framerate to use for conversion. Default: FPS. """ return fps * milliseconds / 1000.0
现在,为鸟角色声明一个类。回想一下第六章,面向对象编程,我们学到每个实体都应该由一个单独的类来表示。在 Flappy Bird 游戏中,代表PipePair(障碍物)的实体或模型与另一个实体(比如鸟)是不同的。因此,我们必须创建一个新的类来表示另一个实体。这个类将代表由玩家控制的鸟。由于鸟是我们游戏的“英雄”,鸟角色的任何移动只允许由玩游戏的用户来控制。玩家可以通过点击屏幕使鸟上升(快速上升),否则它会下沉(缓慢下降)。鸟必须通过管道对之间的空间,每通过一个管道就会得到一个积分。同样,如果鸟撞到管道,游戏就结束了。
现在,我们可以开始编写我们的主角了。你还记得如何做吗?这是任何优秀游戏程序员的最重要特征之一——他们会进行大量头脑风暴,然后写出小而优化的代码。因此,让我们先进行头脑风暴,预测我们想要如何构建鸟角色,以便之后可以无缺陷地编写代码。以下是一些必须作为 Bird 类成员定义的基本属性和常量:
-
类的属性:
x是鸟的 X 坐标,y是鸟的 Y 坐标,msec_to_climb表示鸟要上升的毫秒数,一个完整的上升需要Bird.CLIMB_DURATION毫秒。 -
常量:
-
WIDTH:鸟图像的宽度(以像素为单位)。 -
HEIGHT:鸟图像的高度(以像素为单位)。 -
SINK_SPEED:鸟在不上升时每毫秒下降的像素速度。 -
CLIMB_SPEED:鸟在上升时每毫秒上升的像素速度,平均而言。更多信息请参阅Bird.update文档字符串。 -
CLIMB_DURATION:鸟执行完整上升所需的毫秒数。
现在我们已经有了关于游戏中鸟角色的足够信息,我们可以开始为其编写代码了。下面的代码行表示 Bird 类,其中成员被定义为类属性和常量:
class Bird(pygame.sprite.Sprite): WIDTH = HEIGHT = 50
SINK_SPEED = 0.18
CLIMB_SPEED = 0.3 CLIMB_DURATION = 333.3 def __init__(self, x, y, msec_to_climb, images):
"""Initialize a new Bird instance.""" super(Bird, self).__init__()
self.x, self.y = x, y
self.msec_to_climb = msec_to_climb
self._img_wingup, self._img_wingdown = images
self._mask_wingup = pygame.mask.from_surface(self._img_wingup)
self._mask_wingdown = pygame.mask.from_surface(self._img_wingdown)
让我们来谈谈鸟类内部定义的构造函数或初始化器。它包含许多参数,可能会让你感到不知所措,但它们实际上很容易理解。在构造函数中,我们通常定义类的属性,比如代表鸟位置的 x 和 y 坐标,以及其他参数。现在让我们来看看这些:
-
x:鸟的初始 X 坐标。 -
y:鸟的初始 Y 坐标。 -
msec_to_climb: 剩余的毫秒数要爬升,完整的爬升需要Bird.CLIMB_DURATION毫秒。如果你想让小鸟在游戏开始时做一个(小)爬升,可以使用这个。 -
images: 包含此小鸟使用的图像的元组。它必须按照以下顺序包含以下图像: -
小鸟上飞时的翅膀
-
小鸟下落时的翅膀
最后,应声明三个重要属性。这些属性是image、mask和rect。想象属性是小鸟在游戏中的基本动作。它可以上下飞行,这在图像属性中定义。然而,小鸟类的另外两个属性相当不同。rect属性将获取小鸟的位置、高度和宽度作为Pygame.Rect(矩形的形式)。记住,pygame可以使用rect属性跟踪每个游戏角色,类似于一个无形的矩形将被绘制在精灵周围。mask 属性获取一个位掩码,可用于与障碍物进行碰撞检测:
@property def image(self):
"Gets a surface containing this bird image" if pygame.time.get_ticks() % 500 >= 250:
return self._img_wingup
else:
return self._img_wingdown
@property def mask(self):
"""Get a bitmask for use in collision detection. The bitmask excludes all pixels in self.image with a transparency greater than 127.""" if pygame.time.get_ticks() % 500 >= 250:
return self._mask_wingup
else:
return self._mask_wingdown
@property def rect(self):
"""Get the bird's position, width, and height, as a pygame.Rect."""
return Rect(self.x, self.y, Bird.WIDTH, Bird.HEIGHT)
由于我们已经熟悉了rect和mask属性的概念,我就不再重复了,所以让我们详细了解一下图像属性。图像属性获取指向小鸟当前图像的表面。这将决定根据pygame.time.get_ticks()返回一个图像,其中小鸟的可见翅膀指向上方或指向下方。这将使 Flappy Bird 动画化,即使 pygame 不支持动画 GIF。
现在是时候结束Bird类了,但在此之前,你必须声明一个方法,用于更新小鸟的位置。确保你阅读了我在三引号中添加的描述,作为注释:
def update(self, delta_frames=1):
"""Update the bird's position.
One complete climb lasts CLIMB_DURATION milliseconds, during which the bird ascends with an average speed of CLIMB_SPEED px/ms. This Bird's msec_to_climb attribute will automatically be decreased accordingly if it was > 0 when this method was called. Arguments: delta_frames: The number of frames elapsed since this method was last called. """ if self.msec_to_climb > 0:
frac_climb_done = 1 - self.msec_to_climb/Bird.CLIMB_DURATION
#logic for climb movement
self.y -= (Bird.CLIMB_SPEED * frames_to_msec(delta_frames) *
(1 - math.cos(frac_climb_done * math.pi)))
self.msec_to_climb -= frames_to_msec(delta_frames)
else:
self.y += Bird.SINK_SPEED * frames_to_msec(delta_frames)
数学cosine(angle)函数用于使小鸟平稳爬升。余弦是一个偶函数,这意味着小鸟会做一个平稳的爬升和下降运动:当小鸟在屏幕中间时,可以执行一个高跳,但当小鸟靠近顶部/底部边界时,只能做一个轻微的跳跃(这是 Flappy Bird 运动的基本原理)。
让我们运行游戏,看看小鸟是如何渲染的。然而,我们还没有创建任何逻辑来让玩家玩游戏(我们很快会做到)。现在,让我们运行游戏,观察界面的样子:

根据上述代码,你必须能够创建一个完整的Bird类,其中包含用于遮罩、更新和获取位置(即高度和宽度)的属性,使用rect。我们 Flappy Bird 游戏中的小鸟角色仅与运动相关——垂直上下移动。我们游戏中的下一个角色是管道(小鸟的障碍物),处理起来相当复杂。我们必须随机连续地blit管道对。让我们看看如何做到这一点。
理解随机对象生成
我们已经在前面的部分中介绍了Bird角色的动画。它包括一系列处理小鸟垂直运动的属性和特性。由于Bird类仅限于为小鸟角色执行动作,我们无法向其添加任何其他角色属性。例如,如果你想在游戏中为障碍物(管道)添加属性,不能将其添加到Bird类中。你必须创建另一个类来定义下一个对象。这个概念被称为封装(我们在第六章中学习过,面向对象编程),其中代码和数据被包装在一个单元内,以便其他实体无法伤害它。
让我们创建一个新的类来生成游戏的障碍物。你必须首先定义一个类,以及一些常量。我已经在代码中添加了注释,以便你能理解这个类的主要用途:
class PipePair(pygame.sprite.Sprite):
"""class that provides obstacles in the way of the bird in the form of pipe-pair."""
WIDTH = 80
HEIGHT_PIECE = 32
ADD_INTERVAL = 3000
在我们实际编写这个PipePair类之前,让我给你一些关于这个类的简洁信息,以便你能理解以下每个概念。我们将使用不同的属性和常量,如下所示:
-
PipePair类:一个管道对(两根管道的组合)被插入以形成两根管道,它们之间只提供了一个小间隙,这样小鸟才能穿过它们。每当小鸟触碰或与任何管道对碰撞时,游戏就会结束。 -
属性:
x是pipePair的X位置。这个值是一个浮点数,以使移动更加平滑。pipePair没有Y位置,因为它在y方向上不会改变;它始终保持为 0。 -
image:这是pygame模块提供的表面,用于blitpipePair。 -
mask:有一个位掩码,排除了所有self.image中透明度大于 127 的像素。这可以用于碰撞检测。 -
top_pieces:顶部管道与末端部分的组合,这是管道顶部部分的基础(这是一个由管道顶部部分组成的一对)。 -
bottom_pieces:下管道(向上指向的隧道)与末端部分的组合,这是底部管道的基础。 -
常量:
-
WIDTH:管道片段的宽度,以像素为单位。因为管道只有一片宽,这也是PipePair图像的宽度。 -
PIECE_HEIGHT:管道片段的高度,以像素为单位。 -
ADD_INTERVAL:添加新管道之间的间隔,以毫秒为单位。
正如我们已经知道的,对于任何类,我们需要做的第一件事就是初始化一个类或构造函数。这个方法将初始化新的随机管道对。以下截图显示了管道对应该如何渲染。管道有两部分,即顶部和底部,它们之间插入了一个小空间:

让我们为PipePair类创建一个初始化器,它将blit管道的底部和顶部部分,并对其进行蒙版处理。让我们了解一下需要在这个构造函数中初始化的参数:
-
end_image_pipe:代表管道底部(末端部分)的图像 -
body_image_pipe:代表管道垂直部分(管道的一部分)的图像
管道对只有一个 x 属性,y 属性为 0。因此,x属性的值被赋为WIN_WIDTH,即float(WIN_WIDTH - 1)。
以下步骤代表了需要添加到构造函数中以在游戏界面中创建一个随机管道对的代码:
- 让我们为
PipePair初始化一个新的随机管道对:
def __init__(self, end_image_pipe, body_image_pipe):
"""Initialises a new random PipePair. """ self.x = float(WINDOW_WIDTH - 1)
self.score_counted = False
self.image = pygame.Surface((PipePair.WIDTH, WINDOW_HEIGHT),
SRCALPHA)
self.image.convert() # speeds up blitting
self.image.fill((0, 0, 0, 0))
#Logic 1: **create pipe-pieces**--- Explanation is provided after
the code
total_pipe_body_pieces = int((WINDOW_HEIGHT - # fill window from
top to bottom
3 * Bird.HEIGHT - # make room for bird to fit through
3 * PipePair.HEIGHT_PIECE) / # 2 end pieces + 1 body piece
PipePair.HEIGHT_PIECE # to get number of pipe pieces
)
self.bottom_pipe_pieces = randint(1, total_pipe_body_pieces)
self.top_pipe_pieces = total_pipe_body_pieces -
self.bottom_pieces
- 接下来,我们需要定义两种类型的管道对——底部管道和顶部管道。添加管道对的代码会将管道图像 blit,并且只关心管道对的y位置。管道对不需要水平坐标(它们应该垂直渲染):
# bottom pipe
for i in range(1, self.bottom_pipe_pieces + 1):
piece_pos = (0, WIN_HEIGHT - i*PipePair.PIECE_HEIGHT)
self.image.blit(body_image_pipe, piece_pos)
end_y_bottom_pipe = WIN_HEIGHT - self.bottom_height_px
bottom_end_piece_pos = (0, end_y_bottom_pipe -
PipePair.PIECE_HEIGHT)
self.image.blit(end_image_pipe, bottom_end_piece_pos)
# top pipe
for i in range(self.top_pipe_pieces):
self.image.blit(body_image_pipe, (0, i *
PipePair.PIECE_HEIGHT))
end_y_top_pipe = self.top_height_px
self.image.blit(end_image_pipe, (0, end_y_top_pipe))
# external end pieces are further added to make compensation
self.top_pipe_pieces += 1
self.bottom_pipe_pieces += 1 # for collision detection
self.mask = pygame.mask.from_surface(self.image)
尽管代码旁边提供的注释有助于理解代码,但我们需要以更简洁的方式了解逻辑。total_pipe_body_piece变量存储了一帧中可以添加的管道数量的高度。例如,它推断了可以插入当前实例的底部管道和顶部管道的数量。我们将其强制转换为整数,因为管道对始终是整数。bottom_pipe_piece类属性表示底部管道的高度。它可以在 1 到total_pipe_piece支持的最大宽度范围内。类似地,顶部管道的高度取决于总管道件数。例如,如果画布的总高度为 10,底部管道的高度为 1,那么通过在两个管道对之间留下一个间隙(假设为 3),剩下的高度应该是顶部管道的高度(即其高度为 10 - (3+1) = 6),这意味着除了管道对之间的间隙外,不应提供其他间隙。
前面的代码中的所有内容都是不言自明的。尽管代码很简单,但我希望你专注于代码的最后一行,我们用它来检测碰撞。检测的过程很重要,因为在 Flappy Bird 游戏中,我们必须检查小鸟是否与管道对发生碰撞。通常通过使用pygame.mask模块添加蒙版来实现。
现在,是时候向PipePair类添加一些属性了。我们将添加四个属性:visible、rect、height_topPipe_px和height_bottomPipe_px。rect属性的工作方式类似于Bird类的rect调用,它返回包含PipePair的矩形。类的visible属性检查管道对在屏幕上是否可见。另外两个属性返回以像素为单位的顶部和底部管道的高度。以下是PipePair类的前四个属性的代码:
@property def height_topPipe_px(self):
"""returns the height of the top pipe, measurement is done in pixels"""
return (self.top_pipe_pieces * PipePair.HEIGHT_PIECE)
@property def height_bottomPipe_px(self):
"""returns the height of the bottom pipe, measurement is done in pixels"""
return (self.bottom_pipe_pieces * PipePair.HEIGHT_PIECE)
@property def visible(self):
"""Get whether this PipePair on screen, visible to the player."""
return -PipePair.WIDTH < self.x < WINDOW_WIDTH
@property def rect(self):
"""Get the Rect which contains this PipePair."""
return Rect(self.x, 0, PipePair.WIDTH, PipePair.HEIGHT_PIECE)
现在,在封装之前,我们需要向PipePair类添加另外两个方法。第一个方法collides_with将检查小鸟是否与管道对中的管道发生碰撞:
def collides_with(self, bird):
"""check whether bird collides with any pipe in the pipe-pair. The
collide-mask deploy a method which returns a list of sprites--in
this case images of bird--which collides or intersect with
another sprites (pipe-pair) Arguments: bird: The Bird which should be tested for collision with this PipePair. """ return pygame.sprite.collide_mask(self, bird)
第二个方法update将更新管道对的位置:
def update(self, delta_frames=1):
"""Update the PipePair's position. Arguments: delta_frames: The number of frames elapsed since this method was last called. """ self.x -= ANIMATION_SPEED * frames_to_msec(delta_frames)
现在我们知道每个方法的工作原理,让我们看看代码的运行情况。在运行游戏之前,你不会了解游戏中的任何缺陷。花时间运行游戏并观察输出:

好的,游戏足够吸引人了。点击事件完美地工作,背景图像与鸟的图像一起呈现,并且上升和下沉动作的物理效果也很好。然而,你可能已经观察到一个奇怪的事情(如果没有,请看前面的截图),即在与管道对碰撞后,我们的小鸟能够继续向前移动。这是我们游戏中的一个大缺陷,我们不希望出现这种情况。相反,我们希望在发生这种情况时关闭游戏。因此,为了克服这样的错误,我们必须使用碰撞的概念(一种处理多个游戏对象相互碰撞的技术)。
现在我们已经完成了两个游戏角色类,即Bird和PipePair,让我们继续制作游戏的物理部分:初始化显示和处理碰撞。
检测碰撞
处理碰撞的过程是通过找出两个独立对象触碰时必须执行的操作来完成的。在前面的部分中,我们为每个对象添加了一个掩码,以检查两个对象是否发生碰撞。pygame模块使得检查碰撞过程非常容易;我们可以简单地使用sprite.collide_mask来检查两个对象是否接触。然而,这个方法所需的参数是掩码对象。在前一节中,我们添加了collides_with方法来检查鸟是否与管道对中的一个碰撞。现在,让我们使用该方法来检查碰撞。
除了检测碰撞,我们还将为游戏制作一个物理布局/模板。我在这一部分没有强调基本的 pygame 布局,因为自从我们开始做这个以来,这对你来说应该是不言自明的。以下步骤描述了制作一个检测游戏角色碰撞(Bird与pipePairs)的模型的布局:
- 首先定义主函数,之后将被外部调用:
def main():
"""Only function that will be externally called, this
is main function Instead of importing externally, if we call this function from
if **name** == __main__(), this main module will be executed. """ pygame.init()
display_surface = pygame.display.set_mode((WIN_WIDTH,
WIN_HEIGHT)) #display for screen
objectClock = pygame.time.Clock() images = loading_Images()
- 让我们创建一些逻辑,使鸟出现在屏幕的中心。如果你玩过 Flappy Bird 游戏,你会知道鸟被放在画布的中心,它可以向上或向下移动:
#at any moment of game, bird can only change its y position,
so x is constant
#lets put bird at center Objectbird = Bird(50, int(WIN_HEIGHT/2 - Bird.HEIGHT/2), 2,
(images['WingUp'], images['WingDown']))
pipes = deque()
#deque is similar to list which is preferred otherwise
if we need faster operations like
#append and pop
frame_clock = 0 # this counter is only incremented
if the game isn't paused
- 现在,我们必须将管道对图像添加到
pipes变量中,因为一个管道是由pipe-body和pipe-end连接而成的。这个连接是在PipePair类内部完成的,因此在创建实例后,我们可以将管道对附加到管道列表中:
done = paused = False
while not done:
clock.tick(FPS)
# Handle this 'manually'.
If we used pygame.time.set_timer(),
# pipe addition would be messed up when paused. if not (paused or frame_clock %
msec_to_frames(PipePair.ADD_INTERVAL)):
pipe_pair = PipePair(images['endPipe'],
images['bodyPipe'])
pipes.append(pipe_pair)
- 现在,处理用户的操作。由于 Flappy Bird 游戏是一个点击游戏,我们将处理鼠标事件(参考我们在第十一章中涵盖的鼠标控制部分,使用 Pygame 制作超越乌龟-贪吃蛇游戏 UI):
*#handling events
**#Since Flappy Bird is Tapped game**
**#we will handle mouse events***
*for anyEvent in pygame.event.get():
#EXIT GAME IF QUIT IS PRESSED*
*if anyEvent.type == QUIT or (anyEvent.type == KEYUP and
anyEvent.key == K_ESCAPE):*
*done = True
break elif anyEvent.type == KEYUP and anyEvent.key in
(K_PAUSE, K_p):* *paused = not paused*
*elif anyEvent.type == MOUSEBUTTONUP or
(anyEvent.type == KEYUP and anyEvent.key in
(K_UP, K_RETURN, K_SPACE)):* *bird.msec_to_climb =
Bird.CLIMB_DURATION*
if paused:
continue #not doing anything [halt position]
- 最后,这就是你一直在等待的:如何利用 Python 的
pygame模块构建碰撞接口。在完成这些步骤的其余部分后,我们将详细讨论以下代码的突出部分:
# check for collisions pipe_collision = any(eachPipe.collides_with(bird)
for eachPipe in pipes)
if pipe_collision or 0 >= bird.y or
bird.y >= WIN_HEIGHT - Bird.HEIGHT:
done = True
#blit background for position_x_coord in (0, WIN_WIDTH / 2):
display_surface.blit(images['game_background'],
(position_x_coord, 0))
#pipes that are out of visible, remove them
while pipes and not pipes[0].visible:
pipes.popleft()
for p in pipes:
p.update()
display_surface.blit(p.image, p.rect)
bird.update()
display_surface.blit(bird.image, bird.rect)
- 最后,以一些多余的步骤结束程序,比如使用更新函数渲染游戏,给用户一个多余的消息等等:
pygame.display.flip()
frame_clock += 1
print('Game Over!')
pygame.quit()
#----------uptill here add it to main function----------
if __name__ == '__main__':
#indicates two things:
#In case other program import this file, then value of
__name__ will be flappybird
#if we run this program by double clicking filename
(flappybird.py), main will be called
main() #calling main function
在前面的代码中,突出显示的部分很重要,所以确保你理解它们。在这里,any()函数通过检查鸟是否与管道对碰撞来返回一个布尔值。根据这个检查,如果是True,我们就退出游戏。我们还将检查鸟是否触碰到了水平最低或水平最高的边界,如果是的话也会退出游戏。
让我们运行游戏并观察输出:

游戏已经足够可玩了,所以让我们为游戏添加一个告诉玩家他们得分如何的功能。
得分和结束屏幕
给 Flappy Bird 游戏添加分数非常简单。玩家的分数将是玩家通过的管道或障碍物的数量。如果玩家通过了 20 个管道,他们的分数将是 20。让我们给游戏添加一个得分屏幕:
score = 0
scoreFont = pygame.font.SysFont(None, 30, bold=True) #Score default font: WHITE
while not done:
#after check for collision
# procedure for displaying and updating scores of player
for eachPipe in pipes:
if eachPipe.x + PipePair.WIDTH < bird.x and not
eachPipe.score_counted:
#when bird crosses each pipe
score += 1
eachPipe.score_counted = True
Surface_Score = scoreFont.render(str(score),
True, (255, 255, 255)) #surface
x_score_dim = WIN_WIDTH/2 - score_surface.get_width()/2
#to render score, no y-position
display_surface.blit(Surface_Score, (x_score_dim,
PipePair.HEIGHT_PIECE)) #rendering
pygame.display.flip() #update
frame_clock += 1
print('Game over! Score: %i' % score)
pygame.quit()
现在,游戏看起来更吸引人了:

在下一节中,我们将看看如何测试一切,并尝试应用一些修改。
游戏测试
虽然 Flappy Bird 可以修改的地方较少,但你总是可以通过修改一些游戏角色属性来测试游戏,以改变游戏的难度。在前一节中,我们运行了我们的游戏,并看到管道对之间有很大的空间。这将使游戏对许多用户来说非常容易,所以我们需要通过缩小两个管道对之间的空间来增加难度。例如,在Bird类中,我们声明了四个属性。将它们更改为不同的值以观察效果:
WIDTH = HEIGHT = 30 #change it to make space between pipe pairs
smaller/bigger SINK_SPEED = 0.18 #speed at which bird falls CLIMB_SPEED = 0.3 #when user taps on screen, it is climb speed
#make it smaller to make game harder CLIMB_DURATION = 333.3
您还可以改变游戏属性的值,使您的游戏看起来独一无二。Flappy Bird 中使用的一些不同游戏属性包括每秒帧数和动画速度。您可以改变这些值来实现必要的变化。虽然您可以改变动画速度的值,但对于 Flappy Bird 游戏来说,每秒帧数为 60 是足够的。
与手动调试和搜索可能的修改不同,您可以简单地在调试模式下运行程序以更快地测试它。假设您已经在 Pycharm 的 IDE 中编写了 Flappy Bird 游戏(我推荐这样做),您可以通过按下Shift + F9或简单地点击运行选项卡并从那里以调试模式运行程序。运行后,尝试玩游戏,并尝试使其适应用户可能遇到的任何情况。任何错误都将出现在程序的终端中,您可以从中跳转到具有多个错误的程序位置。
总结
在本章中,我们更深入地探讨了精灵动画和碰撞的概念。我们看了如何为几何形状制作简单动画,创建复杂的精灵动画,并了解了在某些情况下哪种方法最有效。我们将 pygame 的事件处理方法与动画逻辑相结合,根据当前的游戏状态渲染图像。基本上,动画逻辑维护一个队列,用户事件将被存储在其中。一次获取一个动作将图像渲染到一个位置。
使用 pygame 制作的游戏原型有三个核心模块:加载精灵(原始精灵或从互联网下载的精灵)、处理用户事件和动画逻辑,控制游戏角色的移动。有时,您可能不是拥有独立的精灵图像,而是精灵表—包含角色图像的表。您可以使用在线工具或甚至 pygame 的rect方法来裁剪它们。在获得游戏的适当图像或精灵后,我们处理了用户事件,并创建了动画逻辑来使游戏精灵移动。我们还研究了 pygame 的遮罩属性,可以用来检测对象之间的碰撞。
完成本章后,您现在了解了游戏控制器和动画,已经了解了碰撞原理(包括 pygame 的遮罩属性),已经了解了精灵动画(创建角色的奔跑动画),并已经了解了添加交互式记分屏幕以使游戏更加用户友好。
您在本章中获得的知识可以应用的领域范围广泛,对大多数 Python pygame 开发人员来说是纯金。处理精灵对于几乎所有基于 pygame 的游戏都很重要。尽管角色动画、碰撞和移动是简单但强大的概念,但它们是使 Python 游戏具有吸引力和互动性的三个主要方面。现在,尝试创建一个简单的角色扮演游戏(RPG)游戏,比如 Junction Jam(如果您还没有听说过,可以搜索一下),并尝试在其中嵌入碰撞和精灵移动的概念。
在下一章中,我们将通过创建游戏网格和形状来学习 pygame 的基本图形编程。我们将通过编写俄罗斯方块游戏来学习多维列表处理和有效空间确定。
第十三章:使用 Pygame 编写俄罗斯方块游戏
打破常规思维,这是一个老话,对于游戏开发者来说可能听起来陈词滥调,但仍然非常适用。大多数改变游戏行业的游戏都包含一些独特的元素,并代表了普通观众的口味。但这种全球性的假设通过丢弃可能在大多数游戏开发者中普遍存在的方法而被高估。毕竟,数学范式、对象渲染工具和软件保持不变。因此,在本章中,我们将探索一些每个游戏程序员都必须了解的高级数学变换和范式。
在本章中,我们将学习如何创建本世纪最受欢迎和下载量最大的游戏之一,这是 90 年代孩子们非常熟悉的游戏——俄罗斯方块。我们将学习如何通过从多维列表中格式化的形状来从头开始创建它。我们将学习如何绘制基本图形和游戏网格,这将帮助我们定位游戏对象。我们还将学习如何实现几何形状和图形的旋转变换。尽管这个概念听起来可能很简单,但这些概念的应用范围从不同的 2D 到 3D 的角色扮演游戏(RPGs)。
通过本章结束时,您将熟悉不同的概念,如创建网格(虚拟和物理)结构,以根据位置和颜色代码定位游戏对象。然后,您将学习如何使用列表推导来处理多维列表。此外,读者还将了解不同的移位变换和碰撞检查原则。在上一章中,我们使用 pygame 使用掩码实现了碰撞检查。然而,在本章中,我们将以程序员的方式来做这件事——这可能有点复杂,但包含了丰富的知识。
在本章中,我们将涵盖以下主题:
-
了解俄罗斯方块的基本要素
-
创建网格和随机形状
-
设置窗口和游戏循环
-
转换形状格式
-
修改游戏循环
-
清除行
-
游戏测试
技术要求
您需要以下要求才能完成本章:
-
Pygame 编辑器(IDLE)—建议使用 3.5+版本。
-
PyCharm IDE-参考第一章,了解 Python-设置 Python 和编辑器,了解安装过程。
-
俄罗斯方块游戏的代码资产可以在 GitHub 上找到,网址为
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter13
查看以下视频以查看代码的运行情况:
了解俄罗斯方块的基本要素
将 pygame 精灵和图像合并到我们的 Python 游戏中是一个简单的过程。它需要一个内置的 Python 模块—os—,它将从您的计算机加载文件。在上一章中,我们在构建 Flappy Bird 游戏时学习了如何对精灵进行旋转、平移和碰撞,并逐个处理它们。这些变换不仅仅适用于图像,还适用于不同的几何图形和形状。当我们谈论使用这样的变换操作时,俄罗斯方块是每个人心中的游戏——玩家被允许通过周期运动改变几何形状的形状和大小。这种周期性运动将在顺时针和逆时针方向上创建逼真的几何形状的旋转变换。对于不熟悉俄罗斯方块的人,请查看www.freetetris.org/game.php并观察游戏的网格和环境。
通过观察游戏环境,您会注意到三个主要的事情:
-
几何形状,如 L、T、S、I 和正方形:这些几何形状将以字母字符的形式呈现,并且为了区分它们,每个形状将有不同的颜色。
-
网格:这将是几何形状可以移动的地方。这将是游戏画布,几何形状将从顶部落到底部。玩家无法控制这个网格,但他们可以控制形状。
-
旋转形状:当形状/块向下掉落时,玩家可以使用键盘上的箭头键来改变形状的结构(请记住,只允许旋转变换)。
以下图表显示了我们将在游戏中使用的形状:

如果你玩过上述链接中的游戏,你会看到前面的形状在游戏的网格(画布)内移动。相应的字母代表它们所类似的每个几何形状。玩家只能使用箭头键来旋转这些形状。例如,当形状I掉落到网格时,玩家可以在垂直I和水平I之间切换。但对于正方形形状,我们不必定义任何旋转,因为正方形(由于其相等的边)在旋转后看起来完全相同。
现在你已经熟悉了我们俄罗斯方块游戏的游戏角色(几何形状),让我们进一步进行头脑风暴,以提取关于游戏的一些关键信息。让我们谈谈俄罗斯方块的基本要素。由于俄罗斯方块需要创建不同的几何形状,毫无疑问我们将需要pygame模块。pygame模块可以用来创建网格、边界和游戏角色。你还记得pygame的draw模块(来自第十一章,使用 Pygame 制作 Outdo Turtle - 贪吃蛇游戏 UI)吗?显然,如果不使用pygame的draw模块,你无法制作出好的游戏。同样,为了处理用户操作事件,如键盘操作,我们需要 pygame。
函数的蓝图代表了可以通过 Python 的pygame模块构建的俄罗斯方块的顶层视图:
-
build_Grid(): 这个函数将在游戏画布中绘制网格。网格是我们可以用不同颜色渲染几何形状的地方。 -
create_Grid(): 这个函数将在网格中创建不同的水平线,以便我们可以跟踪每个形状进行旋转变换。 -
rotating_shapes:这种技术将在相同的原点内旋转几何形状。这意味着旋转不会改变对象的尺寸(长度和高度)。
现在我们已经完成了头脑风暴的过程,让我们深入了解俄罗斯方块的基本概念。俄罗斯方块的环境简单而强大。我们必须在其中绘制网格,以便我们可以跟踪不同形状的每个(x,y)位置。同样,为了跟踪每个几何形状,我们需要创建一个字典,它将以键的形式存储对象的位置,以值的形式存储对象的颜色。
让我们从为我们的游戏编写模板代码开始:
import pygame
import random
#declare GLOBALS
width = 800
height = 700
#since each shape needs equal width and height as of square
game_width = 300 #each block will have 30 width
game_height = 600 #each block will have 30 height
shape_size = 30
#check top left position for rendering shapes afterwards
top_left_x, top_left_y = (width - game_width) // 2, height - game_height
现在我们已经完成了为我们的游戏声明全局变量的工作,这些变量主要负责屏幕的宽度和高度,我们可以开始为游戏对象定义形状格式。在下一节中,我们将定义一个嵌套列表,我们可以用它来定义游戏对象的多个结构(主要用于几何形状)。
创建形状格式
接下来的信息有点棘手。我们将声明俄罗斯方块的形状格式(所有必要的几何形状)。让我们看一个简单的例子,如下所示:
#Example for creating shapes I
I = [['..**0**..',
'..**0**..',
'..**0**..',
'..**0**..',
'.....'],
['.....',
'**0000**.',
'.....',
'.....',
'.....']] #each 0 indicates block for shapes
观察前面代码中的形状格式。它是一个嵌套列表,我们需要它是因为I支持一次旋转,这将把垂直的I变成水平的I。观察前面列表的第一个元素;它包含一个句点(.),以及一个标识符(0),表示空和块的放置。在点或句点的位置,我们不会有任何东西,所以它将保持空白。但在0的位置,我们将存储块。为了做到这一点,从前面的代码中删除句点,并观察只有元素0。你会在零索引中看到垂直I,在第一个索引中看到水平I。对于正方形形状,我们不需要额外的旋转,所以我们最终将在列表内部声明正方形形状的一个元素。它将是这样的:
#for square shapes square = [['.....',
'.....',
'.00..',
'.00..',
'.....']]
现在我们知道如何为几何形状创建格式了,让我们为不同的形状创建代码的起始部分:
#following is for shape I
""" first element of list represents original structure,
Second element represents rotational shape of objects """ I = [['..0..',
'..0..',
'..0..',
'..0..',
'.....'],
['.....',
'0000.',
'.....',
'.....',
'.....']]
#for square shape
O = [['.....',
'.....',
'.00..',
'.00..',
'.....']]
#for shape J
J = [['.....',
'.0...',
'.000.',
'.....',
'.....'],
['.....',
'..00.',
'..0..',
'..0..',
'.....'],
['.....',
'.....',
'.000.',
'...0.',
'.....'],
['.....',
'..0..',
'..0..',
'.00..',
'.....']]
同样,让我们像之前一样为另外几个几何形状定义形状格式:
#for shape L
L = [['.....',
'...0.',
'.000.',
'.....',
'.....'],
['.....',
'..0..',
'..0..',
'..00.',
'.....'],
['.....',
'.....',
'.000.',
'.0...',
'.....'],
['.....',
'.00..',
'..0..',
'..0..',
'.....']]
#for shape T
T = [['.....',
'..0..',
'.000.',
'.....',
'.....'],
['.....',
'..0..',
'..00.',
'..0..',
'.....'],
['.....',
'.....',
'.000.',
'..0..',
'.....'],
['.....',
'..0..',
'.00..',
'..0..',
'.....']]
现在我们已经成功地为我们的游戏定义了角色,让我们创建一个数据结构来保存这些对象,以及它们的颜色。让我们编写以下代码来实现这一点:
game_objects = [I, O, J, L, T] #you can create as many as you want
objects_color = [(255, 255, 0), (255, 0, 0), (0, 0 , 255), (255, 255, 0), (128, 165, 0)]
由于我们已经完成了基本的起始文件,也就是说,我们已经理解并创建了我们的游戏对象,在下一节中,我们将开始为我们的游戏创建一个网格,并将游戏对象渲染到屏幕上。
创建网格和随机形状
现在我们已经定义了形状的格式,是时候给它们实际的特征了。我们为形状提供特征的方式是定义尺寸和颜色。之前,我们将方块的尺寸定义为 30,这并不是任意的;形状的尺寸必须在高度和宽度上相等。在本章中我们要绘制的每个几何形状都将至少类似于正方形。感到困惑吗?看看我们定义形状格式的代码,包括句点(.)和字符(0)。如果你仔细观察列表的每个元素,你会看到正方形的格式,行和列中排列着相等数量的点。
正如我们在了解俄罗斯方块的基本要素部分中提到的,网格是我们游戏角色将驻留的地方或环境。玩家控制或动作只能在网格区域内激活。让我们谈谈网格在我们的游戏中如何使用。网格是屏幕以垂直和水平线的形式划分,每行和每列都由此组成。让我们自己制作一个并观察结果:
#observe that this is not defined inside any class
def build_Grid(occupied = {}):
shapes_grid = [[(0, 0, 0) for _ *in range(10)] for* _ in range(20)]
for row in range(len(shapes_grid)):
for column in range(len(shapes_grid[row])):
if (column, row) in occupied:
piece = occupied[(column, row)]
shapes_grid[row][column] = piece
return shapes_grid
前面的代码很复杂,但它是 pygame 大多数游戏的基本构建块。前面的代码将返回一个网格,显然是我们俄罗斯方块游戏的环境,但它也可以用于多种用途,比如稍加修改就可以用于制作井字游戏或吃豆人等。build_Grid()函数的参数是一个参数——occupied 字典。这个字典将从调用这个函数的地方传递给这个函数。主要是这个函数将在主函数内部调用,这将启动创建游戏网格的过程。
传递给build_Grid的 occupied 字典将包含一个键和一个值(因为它是一个字典)。键将表示每个块或形状所在的位置。值将包含每个形状的颜色代码,由键表示。例如,在你的打印字典中,你会看到类似{位置:颜色代码}的东西。
操作的下一行应该是一个让你大吃一惊的时刻。如果没有,你就错过了什么!这可以在第七章中找到,列表推导和属性。借助一行代码,我们定义了行和列的排列(多维列表)。它将为我们提供一系列值,可以用来创建一系列线的网格。当然,线将在主函数中稍后借助pygame的draw模块来绘制。我们将创建一个包含 10 行和一个包含 20 列的列表。现在,让我们谈谈代码的最后几行(高亮部分)。这些代码将循环遍历每个占用的位置,并通过修改它将其添加到网格中。
在为我们的游戏定义环境之后,我们需要做的下一件大事是定义游戏的形状。记住,每个形状都会有这样的属性:
-
行和列位置:网格特定位置将被指定为一定行和列的形状或几何图形。
-
形状名称:形状的标识符,表示要渲染哪些形状。我们将为每个形状添加字母字符,例如,形状 S 的字符 S。
-
颜色:每个形状的颜色。
-
旋转:每个形状的旋转角度。
现在我们已经了解了每个形状的可用属性,让我们为形状定义类,并将每个属性附加到它上面。按照以下代码创建Shape类:
class Shape:
no_of_rows = 20 #for y dimension
no_of_columns = 10 #for x dimension
#constructor
def __init__(self, column, row, shape):
self.x = column
self.y = row
self.shape = shape
#class attributes
self.color = objects_color[game_objects.index(shape)]
#get color based on character indicated by shape name or shape variable
self.rotation = 0
objects_color和game_objects变量之前已经定义,它们是两个包含一个列表中的字母字符的不同列表。另一个列表中包含它们的颜色代码。
此刻,如果你运行你的游戏,你除了一个空的黑屏之外什么也看不到,这是因为我们的网格背景是用黑色代码渲染的。我们知道,如果我们想要画任何东西,可以借助 Python 的pygame模块来实现。此外,我们是从网格的顶部到底部绘制形状,所以我们必须随机生成形状。因为我们有五种形状,即 I、O、J、L 和 T,我们需要随机地渲染它们,一一地。让我们编写一个函数来实现以下代码片段。记住,我们在开始时已经导入了一个随机模块:
def generate_shapes():
global game_objects, objects_color
return Shape(4, 0, random.choice(game_objects)) #creating instance
前面的后端逻辑对于任何涉及几何形状和图形的游戏都是至关重要的。这种知识的范围比你想象的要广泛得多。许多 RPG 游戏,包括 Minecraft,都让玩家与不同的几何形状进行交互。因此,创建网格是至关重要的,这样我们就可以引用每个图形的位置和颜色。现在我们已经创建了一些通用逻辑,可以创建不同形状和颜色的图形,我们需要一个工具,可以将这些形状渲染到网格中,通常是通过 OpenGL 或 pygame 来完成(PyOpenGL 将在接下来的第十四章中介绍,了解 PyOpenGL)。然而,在 Python 的情况下,更优秀的工具将是 pygame。因此,我们将使用pygame模块来制作俄罗斯方块游戏的形状和字符。
在下一节中,我们将创建一些逻辑,为网格结构设置游戏窗口。我们还将尝试运行游戏并观察其环境。
设置窗口和游戏循环
在设置游戏对象之后,我们游戏中的下一个重要步骤是渲染网格。不要被误导以为我们已经创建了网格,因为我们定义了build_Grid()方法之后。虽然这是一个有效的观点,但我们建立的网格到目前为止都是虚拟的。如果你简单地调用build_Grid方法,你将看不到任何东西,只会看到一个黑屏,这是网格的背景。在这里,我们将为这个网格提供一个结构。使用每个位置,由行和列指定,我们将使用pygame模块创建一条直线。
让我们创建一个简单的函数来为我们的游戏绘制一个窗口(主窗口),网格将驻留在其中:
def create_Grid(screen_surface, grid_scene):
screen_surface.fill(0, 0, 0) #black background
for i in range(len(grid_scene)):
for j in range(len(grid_scene[i])):
#draw main rectangle which represents window
pygame.draw.rect(screen_surface, grid_scene[i][j], (top_left_x +
j* 30, top_left_y + i * 30, 30, 30), 0)
#above code will draw a rectangle at the middle of surface screen
build_Grid(screen_surface, 20 , 10) #creating grid positions
pygame.draw.rect(screen_surface, (255, 0, 0), (top_left_x, top_left_y,
game_width, game_height), 5)
pygame.display.update()
上述代码行将创建网格的物理结构,它将有不同的行和列。在循环遍历整个网格场景或网格的位置之后,我们将进入网格范围,以便使用先前突出显示的代码部分绘制一个矩形和网格边框。
同样,让我们通过为其定义边界来为这个网格提供物理结构。每一行和每一列都将通过在其中创建线条来区分。由于我们可以使用 pygame draw模块绘制线条,我们将使用它来编写以下函数:
"""function that will create borders in each row and column positions """
def show_grid(screen_Surface, grid):
""" --- following two variables will show from where to
draw lines---- """
side_x = top_left_x
side_y = top_left_y
for eachRow in range(grid):
pygame.draw.line(screen_Surface, (128,128,128), (side_x, side_y+
eachRow*30), (side_x + game_width, side_y + eachRow * 30))
# drawing horizontal lines (30)
for eachCol in range(grid[eachRow]):
pygame.draw.line(screen_Surface, (128,128,128), (side_x +
eachCol * 30, side_y), (side_x + eachCol * 30, side_y +
game_height))
# drawing vertical group of lines
上述函数有一个主循环,它循环进入由build_Grid方法确定的几行。在进入网格结构的每一行之后,它将使用pygame draw模块以颜色代码(128, 128, 128)绘制线条,从(side_x, side_y)开始,然后指向下一个坐标(side_x + game_width, side_y + eachRow *30)。起始点(side_x, side_y)是网格的最左侧角,而下一个坐标值(side_x + game_width, side_y + eachRow *30)表示网格的最右侧角的坐标。因此,我们将从网格的最左侧角绘制一条线到最右侧角。
在显式调用了前一个函数之后,你会看到以下输出:

在设置了上述的网格或环境之后,我们将进入有趣的部分,也就是创建主函数。主函数将包含不同的内容,主要是用于调用和设置网格,并处理用户事件或操作,比如用户按下退出键或键盘上的箭头键时会发生什么。让我们用以下代码来定义它:
def main():
occupied = {} #this refers to the shapes occupied into the screen
grid = build_Grid(occupied)
done = False
current_shape = generate_shapes() #random shapes chosen from lists.
next_shape = generate_shapes()
clock = pygame.time.Clock()
time_of_fall = 0 #for automatic fall of shapes
while not done:
for eachEvent in pygame.event.get():
if eachEvent.type == pygame.QUIT:
done = True
exit()
既然我们已经开始定义主函数,它是我们游戏的指挥官,让我们定义它必须做的事情,如下所示:
-
调用多个函数,比如
build_Grid()和create_Grid(),它们将设置游戏的环境 -
定义一个方法,执行代表字符的形状的旋转
-
定义一些逻辑,将下落时间限制添加到游戏中,也就是物体下落的速度
-
改变一个形状,在一个形状落到地面后
-
创建一些逻辑来检查形状的占用位置
上述过程是主函数的功能,我们应该解决它们。我们将在本节中解决前两个问题,但剩下的两个问题将在接下来的部分中解决。因此,主函数的第一个操作是调用一些关键函数,用于创建游戏的网格。如果你看上述的代码行,你会看到我们已经调用了build_Grid方法,它负责创建网格结构的行和列的虚拟位置。现在,剩下的任务只是调用create_Grid()方法,它将使用pygame draw模块为这个虚拟网格提供适当的物理结构。我们已经定义了这两个函数。
在下一节中,我们将学习一个重要的数学变换范式,即旋转,并将在我们的俄罗斯方块游戏中添加旋转游戏对象的功能。
理解旋转
在我们继续编写代码并修改主函数之前,让我们先了解一下数学知识。如果游戏与数学范式无关,那么游戏就什么都不是。运动、形状、角色和控制都由数学表达式处理。在本节中,我们将介绍数学的另一个重要概念:变换。尽管变换在数学中是一个模糊的概念,但我们将尽力学习这个概念。具体来说,有不同类型的变换:旋转、平移、反射和放大。在大多数游戏中,我们只需要两种类型的变换:旋转和放大。在本章中,我们将使用俄罗斯方块实现旋转变换,然后在第十六章中实现放大变换(构建愤怒的小鸟游戏时,学习游戏人工智能-构建一个玩游戏的机器人)。
术语旋转是一个数学概念,它表示当一个对象被旋转时,意味着它以特定角度顺时针或逆时针旋转。考虑以下例子:

在前面的例子中,我们有一个矩形形状,代表了俄罗斯方块游戏中的字母I字符。现在,想象一下玩家按下键盘上的上箭头键。在这种情况下,I的矩形形状必须以 90 度的角度旋转,并放置为水平的I字符,如前面的图表所示。因此,这些旋转是为了改变图形的形状,而不是尺寸。水平I和垂直I具有相同的尺寸(高度和宽度)。现在您已经了解了一些关于旋转的知识,您可以回到我们为每个字符(I、O、J、L 和 T)定义形状格式的代码,并观察多维列表。在I的情况下,您可以观察到它有两个元素。列表的第一个元素是游戏对象I的原始形状,列表的第二个元素是在旋转约 90 度后的扭曲形状。观察一下O字符,它是一个正方形。即使旋转任意角度,正方形仍然保持不变。因此,在正方形形状的情况下,列表中只有一个元素。
尽管我们已经了解了关于旋转的这些琐事,以及它们如何与每个形状格式相关联,但问题仍然存在:何时可以渲染每个形状,何时应执行旋转操作?答案很简单。当玩家按下键盘上的任何箭头键时,我们将执行旋转。但是哪里的代码暗示用户正在按键盘键?显然,这是在事件处理过程中完成的!在主函数中,我们开始捕获事件,并处理QUIT键的操作。现在,让我们使用以下代码对任何箭头键执行旋转:
代码应该添加在事件处理步骤中,在处理QUIT键之后。确保为代码提供适当的缩进。代码将在github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter13上提供。
if anyEvent.type == pygame.KEYDOWN:
if anyEvent.key == pygame.K_LEFT:
current_shape.x -= 1 #go left with shape
elif anyEvent.key == pygame.K_RIGHT:
current_shape.x += 1 #go right with shape
elif anyEvent.key == pygame.K_UP:
# rotate shape with angle of rotation
(rotation variable)
current_shape.rotation = current_shape.rotation + 1 %
len(current_shape.game_objects)
if anyEvent.key == pygame.K_DOWN:
# moving current shape down into the grid
current_shape.y += 1
如果您想了解更多关于对象旋转如何在幕后工作的知识,请确保查看以下网址:mathsdoctor.co.uk。
为了设置窗口画布或游戏屏幕,我们可以简单地调用pygame set_mode方法,并相应地渲染网格的窗口。方法调用的以下行应该在主函数中添加,在您设置了用户处理事件之后:
create_Grid(screen_surface) #screen surface will be initialized with
pygame below
现在我们已经为屏幕创建了一个网格,让我们设置主屏幕并调用主函数:
screen_surface = pygame.display.set_mode((width, height))
main() #calling only
我们已经涵盖了几乎所有重要的事情,包括渲染显示,旋转对象,创建网格,渲染网格边界;但还有一个问题:我们如何将形状渲染到网格中?显然,我们的计算机还不够聪明,无法理解我们之前创建的多维列表来定义形状格式。还是困惑?检查我们为每个字符创建的多维列表,比如 I,O,J,L 和 T——我们的计算机无法理解这样的列表。因此,我们必须将这些列表值或属性转换为我们的计算机将进一步处理的维度值。我们的计算机将理解的维度值是指位置值。由于我们已经建立了网格,我们可以使用网格结构的行和列为计算机提供位置值。因此,让我们创建一个函数来实现它。
转换形状格式
我们的计算机无法理解数据结构的模糊内容,比如存储在多维列表中的内容。例如,看一下以下代码:
#for square shapes square = [['.....',
'.....',
'.00..',
'.00..',
'.....']]
在以前的方形模式中,我们将一系列句点(.)与0配对。计算机不会认识 0 代表什么,句点代表什么。我们只知道句点在一个空位上,这意味着它的位置可以被忽略,而0所在的位置是块的位置。因此,我们需要编写一个程序,告诉计算机从网格中提取只有0所在的位置的程序。我们将通过定义以下函数来实现它:
def define_shape_position(shape_piece):
positions = []
list_of_shapes = shape_piece.game_objects[shape_piece.rotation %
len(shape_piece.shape)]
for i, line in enumerate(list_of_shapes):
row = list(line)
for j, column in enumerate(row):
if column == '0':
positions.append((shape_piece.x + j, shape_piece.y + i))
for p, block_pos in enumerate(positions):
positions[p] = (block_pos[0] - 2, block_pos[1] - 4)
return positions
让我们详细看一下以前的代码:
-
首先,这个函数返回对象的块的位置。因此,我们首先创建一个块字典。
-
其次,我们存储了几个形状的列表,由多维字符列表
game_objects(I,O,J,L 和 T)定义,并进行了旋转。 -
现在,重要的部分:这个函数必须返回什么位置?这些位置是放置在网格中的
0的位置。 -
再次观察多维列表。你会看到一堆点(
.)和0作为元素。我们只想要0所在的位置,而不是句点或点所在的位置。 -
在我们使用
if column == \'0\'命令检查每一列是否有0之后,我们只将这样的位置存储到 positions 字典中,并从函数中返回。
当进行旋转和移动等操作时,用户可能会触发一些无效的移动,比如将对象旋转到网格外部。因此,我们必须检查这些无效的移动并阻止它们发生。我们将创建check_Moves()函数来实现这一点。这个函数的参数将是形状和网格位置;形状是必要的,以检查特定旋转是否允许在由网格参数指示的位置内进行。如果网格指定的当前位置已经被占据,那么我们将摆脱这样的移动。有不同的实现方式,但最快最简单的方式是检查网格背景的颜色。如果网格中特定位置的颜色不是黑色,那么这意味着该位置已经被占据。因此,你可以从这个逻辑中得出一个详细的参考,解释为什么我们将网格的背景颜色设为黑色。通过这样做,我们可以检查对象是否已经在网格中。如果任何新对象下降到网格中,我们不应该通过已经存在于网格中的对象。
现在,让我们创建一个函数来检查位置是否被占用:
def check_Moves(shape, grid):
""" checking if the background color of particular position is
black or not, if it is, that means position is not occupied """
valid_pos = [[(j, i) for j in range(10) if grid[i][j] == (0,0,0)]
for i in range(20)]
""" valid_pos contains color code in i variable and
position in j variable--we have to filter to get only
j variable """
valid_pos = [j for p in valid_pos for j in p]
""" list comprehension --same as writing
for p in valid_pos:
for j in p:
p
"""
""" Now get only the position from such shapes using
define_shape_position function """
shape_pos = define_shape_position(shape)
"""check if pos is valid or not """
for eachPos in shape_pos:
if eachPos not in valid_pos:
if eachPos[1] > -1: #eachPos[1] represents y value of shapes
and if it hits boundary
return False #not valid move
return True
到目前为止,我们一直在为我们的游戏构建后端逻辑,这涉及到渲染网格、操作网格、改变网格位置、实现决定两个对象碰撞时发生什么的逻辑等。尽管我们已经做了这么多,但当你运行游戏时,你仍然只会看到网格的形成,什么都没有。这是因为我们的主循环是游戏的指挥官——它将顺序地命令其他函数,但在主循环内,除了处理用户事件的代码之外,我们什么都没有。因此,在下一节中,我们将修改游戏的主循环并观察输出。
修改游戏循环
正如我们之前提到的,我们的主游戏循环负责执行许多任务,包括处理用户事件、处理网格、检查可能的移动等。我们一直在制作将检查这些动作、移动和环境的函数,但我们还没有调用它们一次,这将在本节中完成。如果你从高层次的角度观察主游戏循环,它将包含四个主要的架构构建块:
-
创建网格和处理游戏对象的移动。例如,掉落到网格中的对象的速度应该是多少?
-
处理用户事件。我们已经在检查事件并相应地旋转对象时做过这个,但前面的代码没有考虑
check_Moves()函数,它将检查移动是否有效。因此,我们将相应地修改前面的代码。 -
为游戏对象添加颜色(唯一颜色)。例如,
S的颜色应该与I不同。 -
添加逻辑,检查对象撞击网格底部时会发生什么。
我们将逐步实现上述每个步骤。让我们从为对象添加速度开始。速度指的是网格结构中对象的自由下落速度。以下代码应该添加到主函数中:
global grid
occupied = {} # (x pos, y pos) : (128, 0, 128)
grid = build_Grid(occupied)
change_shape = False
done = False
current_shape = generate_shapes()
next_shape = generate_shapes()
clock = pygame.time.Clock()
timeforFall = 0
while not done:
speedforFall = 0.25
grid = build_Grid(occupied)
timeforFall += clock.get_rawtime()
clock.tick()
# code for making shape fall freely down the grid
if timeforFall/1000 >= speedforFall:
timeForFall = 0
current_shape.y += 1 #moving downward
#moving freely downward for invalid moves
if not (check_Moves(current_shape, grid)) and current_shape.y > 0:
current_shape.y -= 1
change_shape = True
假设玩家尝试进行无效的移动。即使在这种情况下,游戏对象(形状)也必须自由向下掉落。这样的操作是在前面代码的最后三行中完成的。除此之外,代码是不言自明的;我们已经为对象定义了下落到网格中的速度,并使用了时钟模块来实现时间约束。
实现下一个逻辑,这相对容易一些。我们已经讨论了在俄罗斯方块中处理用户事件,考虑了旋转对象和进行简单的左右移动等细节。然而,在这些代码中,我们没有检查用户尝试的移动是否有效。我们必须首先检查这一点,以确保用户不能进行任何无效的移动。为了实现这一点,我们将调用之前创建的check_Moves()方法。以下代码将处理用户事件:
if anyEvent.type == pygame.KEYDOWN:
if anyEvent.key == pygame.K_LEFT:
current_shape.x -= 1
if not check_Moves(current_shape, grid):
current_shape.x += 1 # not valid move thus
free falling shape
elif anyEvent.key == pygame.K_RIGHT:
current_shape.x += 1
if not check_Moves(current_shape, grid):
current_shape.x -= **1**
""" ROTATING OBJECTS """
elif anyEvent.key == pygame.K_UP:
current_shape.rotation = current_shape.rotation + 1 %
len(current_shape.shape)
if not check_Moves(current_shape, grid):
current_shape.rotation = current_shape.rotation - 1
% len(current_shape.shape)
"""Moving faster while user presses down action key """
if anyEvent.key == pygame.K_DOWN:
current_shape.y += 1
if not check_Moves(current_shape, grid):
current_shape.y -= 1
首先,关注被突出显示的代码。代码的第一个突出显示的部分是指移动是否有效进入网格,这是由check_Moves()函数检查的。我们允许当前形状向右角移动,即朝着正 x 轴。同样,关于上键,它负责检查对象是否允许旋转(只有上键会旋转对象;左 和 右 键会将对象从左到右移动,反之亦然)。在旋转的情况下,我们通过像素变换来旋转它,这是通过选择多维列表中指示的位置之一来完成的。例如,在形状 I 的情况下,列表中有两个元素:一个原始形状和另一个旋转形状。因此,为了使用另一个旋转形状,我们将检查移动是否有效,如果有效,我们将呈现新的形状。
应该添加到主函数中的第三段代码将处理为绘制网格中的形状添加颜色的技术。以下代码将为游戏范围内的每个对象添加颜色:
position_of_shape = define_shape_position(current_shape)
""" define_shape_function was created to return position of blocks of
an object """
# adding color to each objects in to the grid.
for pos in range(len(position_of_shape)):
x, y = position_of_shape[pos]
""" when shapes is outside the main grid, we don't care """
if y > -1: # But if we are inside the screen or grid,
we add color
grid[y][x] = current_shape.color #adding color to the grid
最后,必须添加到主函数中的最后一段逻辑将处理当对象触地时的情况。让我们添加以下代码到主函数中以实现它:
if change_shape:
for eachPos in position_of_shape:
pos = (eachPos[0], eachPos[1])
occupied[pos] = current_shape.color
current_shape = next_shape
next_shape = generate_shapes()
change_shape = False
在上述代码中,我们通过检查布尔变量change_shape的内容来检查对象是否自由下落。然后,我们检查形状的当前位置并创建(x,y),它将表示占用的位置。然后将这样的位置添加到名为 occupied*的字典中。您必须记住,该字典的值是相同对象的颜色代码。在将当前对象分配给网格范围后,我们将使用generate_shapes()方法生成一个新形状。
最后,让我们通过调用create_Grid()函数来结束我们的主函数,参数是在以下代码中由 pygame 的set_mode()方法初始化的网格和表面对象(我们之前初始化了 pygame 的surface对象):
create_Grid(screen_surface, grid)
让我们运行游戏并观察输出:

现在,您可以清楚地看到我们能够制作一个俄罗斯方块游戏,用户可以根据需要转换对象并进行游戏。但等等!我们的游戏缺少一个重要的逻辑。我们如何激励玩家玩这个游戏?如果游戏只是关于旋转对象和用对象填充网格,那它就不会是历史悠久的游戏(这个游戏改变了 90 年代的游戏产业)。是的!游戏中必须添加一些逻辑,当调用这个逻辑时,我们将观察到每当行位置被占用时,我们必须清除这些行并将行向下移动一步,这将使我们比以前少了几行。我们将在下一节中实现这一点。
清除行
正如我们之前提到的,在本节中,我们将检查所有行的每个位置是否完全被占用。如果它们被占用,我们将从网格中删除这些行,并且这将导致每一行在网格中向下移动一步。这个逻辑很容易实现。我们将检查整行是否被占用,并相应地删除这些行。您还记得check_Moves()函数的情况吗?如果此函数检查每行的背景颜色,如果每行都没有黑色背景颜色,这意味着这样的行是被占用的。但即使我们有一个空位置,这意味着这个位置的背景颜色将是黑色,并且将被视为未被占用。因此,在清除行的情况下,我们可以使用类似的技术:如果在任何行中,任何位置的背景颜色是黑色,这意味着该位置未被占用,这样的行不能被清除。
让我们创建一个函数来实现清除行的逻辑:
def delete_Row(grid, occupied):
# check if the row is occupied or not
black_background_color = (0, 0, 0)
number_of_rows_deleted = 0
for i in range(len(grid)-1,-1,-1):
eachRow = grid[i]
if black_background_color not in eachRow:
number_of_rows_deleted += 1
index_of_deleted_rows = i
for j in range(len(eachRow)):
try:
del occupied[(j, i)]
except:
continue
让我们消化前面的代码。这是一个相当复杂的逻辑,所以确保你学会了所有的东西;这些概念不仅适用于游戏创建,而且在技术面试中也经常被问到。问题在于如何通过创建逻辑来移动数据结构的值,而不是使用 Python 内置函数。我想以这种方式教给你,而不是使用任何内置方法,因为知道这个可能对编程的任何技术领域都有帮助。现在,让我们观察代码。它以创建一个number_of_rows_deleted变量开始,该变量表示已从网格中删除的行数。关于已删除行数的信息很重要,因为在删除这些行数后,我们需要将位于已删除行上方的行数向下移动相同的数量。例如,看看下面的图表:

同样,现在我们知道了使用if black_background_color not in eachRow表达式要删除什么,我们可以确定网格的每一行是否有空位。如果有空位,这意味着行没有被占据,如果有,那么黑色背景颜色,即(0, 0, 0),不会出现在任何行中。如果我们没有找到黑色背景颜色,那么我们可以确定行被占据,我们可以通过进一步检查条件来删除它们。在代码的突出部分中,你可以看到我们只取第 j 个元素,这只是一列。这是因为在删除行时,I的值保持不变,但第 j 列的值不同。因此,我们在单行内循环整个列,并使用del命令删除被占据的位置。
从上一行代码中,我们能够删除整行,如果有任何行被占据,但我们没有解决删除后应该发生什么,这是棘手的部分。在我们删除每一行后,不仅会删除方块,整个包含行的网格也会被删除。因此,在删除的方块位置,我们不会有空行;相反,包含网格的整行将被删除。因此,为了确保我们不减少实际网格的数量,我们需要从顶部添加另一行来补偿。让我们编写一些代码来实现这一点:
#code should be added within delete_Row function outside for loop
if number_of_rows_deleted > 0: #if there is at least one rows deleted
for position in sorted(list(occupied), position=lambda x:
x[1])[::-1]:
x, y = position
if y < index_of_deleted_rows:
""" shifting operation """
newPos = (x, y + number_of_rows_deleted)
occupied[newPos] = occupied.pop(position)
return number_of_rows_deleted
好了!让我们消化一下。这是相当复杂但非常强大的信息。前面的代码将实现将行块从顶部向下移入网格。首先,只有在我们删除了任何行时才需要移位;如果是,我们就进入逻辑来执行移位。首先,让我们只观察涉及 lambda 函数的代码,即list(occupied), position=lambda x: x[1]。该代码将创建一个包含网格所有位置的列表,然后使用 lambda 函数仅获取位置的y部分。请记住,获取方块的x位置是多余的——对于每一行,x的值保持不变,但y的值不同。因此,我们将获取y位置的值,然后使用sorted(x)函数对其进行排序。排序函数将根据y坐标的值对位置进行排序。
首先,排序将根据y的较小值到y的较大值进行。例如,看看下面的图表:

调用 sorted 方法,然后反转列表(参见第四章,数据结构和函数,了解更多关于如何反转列表的信息)很重要,因为有时网格的底部部分可能没有被占据,只有上层会被占据。在这种情况下,我们不希望移位操作对未被占据的底部行造成任何伤害。
同样,在追踪每一行的位置后,我们将检查是否有任何删除行上方的行,使用if y < index_of_deleted_rows表达式。同样,在这种情况下,x的值是无关紧要的,因为它在单行内是相同的;在我们检查是否有任何删除行上方的行之后,我们执行移位操作。移位操作非常简单;我们将尝试为位于删除行正上方的每一行分配新位置。我们可以通过增加删除行的数量来创建新位置的值。例如,如果有两行被删除,我们需要将y的值增加两个,以便删除行上方的方块和随后的方块将向下移动两行。在我们将行向下移动到网格后,我们必须从先前的位置弹出方块。
既然我们已经定义了一个函数,如果整行被占据,它将清除整行,让我们从主函数中调用它来观察其效果:
def main():
...
while not done:
...
if change_shape:
...
change_shape = False
delete_Row(grid, occupied)
最后,在这个漫长而乏味的编码日子里,我们取得了非常有成效的结果。当您运行声明了主函数的模块时,您将看到以下输出:

游戏看起来很吸引人,我已经在代码中测试了一切。代码看起来非常全面和详尽,没有漏洞。同样,您可以玩它并与朋友分享,并发现可以对这个游戏进行的可能修改。这是一个高级游戏,当用 Python 从头开始编码时,它充分提高了自己的水准。在构建这个游戏的过程中,我们学到了很多东西。我们学会了如何定义形状格式(我们以前做过更复杂的事情,比如精灵的转换和处理精灵的碰撞),但这一章在不同方面都具有挑战性。例如,我们必须注意诸如无效移动、可能的碰撞、移位等事项。我们实现了一些逻辑,通过比较两种不同的颜色对象:网格或表面的背景颜色与游戏对象颜色,来确定对象是否放置在某个位置。
我们还没有完成;我们将在下一节尝试实现更多逻辑。我们将看看我们的游戏可以进行哪些其他修改。我们将尝试构建一些逻辑,随着游戏的进行,将增加游戏的难度级别。
游戏测试
我们的游戏可以进行多种修改,但最重要的修改将是添加欢迎屏幕、增加难度级别和得分屏幕。让我们从欢迎屏幕开始,因为它很容易实现。我们可以使用pygame模块创建一个窗口,并使用文本表面向用户提供消息。以下代码显示了如何为我们的俄罗斯方块游戏创建一个主屏幕:
def Welcome_Screen(surface):
done = False
while not done:
surface.fill((128,0,128))
font = pygame.font.SysFont("comicsans", size, bold=True)
label = font.render('Press ANY Key To Play Tetris!!', 1, (255, 255,
255))
surface.blit(label, (top_left_x + game_width /2 -
(label.get_width()/2), top_left_y + game_height/2 -
label.get_height()/2))
pygame.display.update()
for eachEvent in pygame.event.get():
if eachEvent.type == pygame.QUIT:
done = True
if event.type == pygame.KEYDOWN:
main(surface) #calling main when user enters Enter key
pygame.display.quit()
运行游戏后,您将看到以下输出,其中将呈现欢迎屏幕。按下任意键后,您将被重定向到俄罗斯方块游戏:

同样,让我们添加一些逻辑,以增加游戏的难度。有两种实现这种逻辑的方法。首先,您可以创建一个计时器,如果玩家玩的时间超过关联计时器的范围,我们可以减慢下落速度,使形状比以前下落得更快(增加速度):
timeforLevel = 0
while not done:
speedforFall = 0.27 - timeforLevel
...
if timeforLevel / 10000 > 0.5:
timeforLevel = 0
if timeforLevel > 0.15:
timeforLevel += 0.05
...
""" ---------------------------------------------------
speedforFall = 0.24 will make object to fall faster comparative
to speedforFall = 0.30
----------------------------------------------------- """
同样,我们可以实现另一段逻辑来增加游戏的难度。这种方法比之前的更好。在这种方法中,我们将使用分数来增加游戏的难度。以下代码表示了如何实现玩家的得分以增加游戏级别的蓝图:
def increaseSpeed(score):
game_level = int(score*speedForFall)
speedforFall = 0.28 - (game_level)
return speedforFall
在前面的代码中,我们实现了分数和物体速度之间的关系。假设玩家的分数更高。这意味着用户一直在玩较低难度的级别,因此,这样一个高分值将与更高的下落速度值相乘,导致speedforFall的增加,然后从物体的速度中减去,这将创建一个更快的下落动作。相反,玩在更高级别的玩家将有一个较低的分数,这将与物体速度的较低值相乘,导致一个较低的数字,然后从speedforFall变量中减去。这将导致玩更难级别的玩家速度变化较小。但假设玩家是专业的,并且在更难的级别中得分更高。在这种情况下,物体的下落速度相应增加。
我们最终完成了一个完全功能的俄罗斯方块游戏。在本章中,我们学习了使用 Python 进行游戏编程的几个高级概念。在创建过程中,我们复习了一些我们之前学到的关于 Python 的基本概念,比如操作多维列表,列表推导,面向对象的范式和数学变换。除了复习这些概念,我们还发现了一些新颖的概念,比如实现旋转,实现移位操作,从头开始创建形状格式,创建网格(虚拟和物理)结构,并在网格中放置物体。
总结
在本章中,我们探索了实现多维列表处理的Pythonic方式。我们创建了一个多维列表来存储不同几何形状的格式,并使用数学变换对其进行操作。
我们使用了俄罗斯方块的简单示例来演示游戏中几种数据结构的使用,以及它们的操作。我们实现了一个字典,将键存储为位置,值存储为这些物体的颜色代码。构建这样一个字典对于俄罗斯方块等游戏来说是救命的。在制作检查碰撞和移位操作的逻辑时,我们使用字典来观察任何物体的背景颜色是否与任何位置的背景相同。尽管俄罗斯方块只是一个案例研究,但在这个游戏中使用的技术也被用于许多现实世界的游戏,包括 Minecraft,几乎每个 RPG 游戏。
数学变换涉及的操作对我们非常重要。在本章中,我们使用了旋转原理来改变物体的结构而不改变其尺寸。从本章中您将掌握的知识是巨大的。诸如操作多维列表之类的概念可以扩展到数据应用程序,并被称为 2D Numpy 数组,用于创建不同的类比,比如街道类比,多旅行者问题等。尽管字典被认为是数据结构之王,但处理多维列表并不逊色,因为它与列表推导的简单性相结合。除了实现这些复杂的数据结构,我们还学会了如何实现数学变换,即游戏物体的旋转运动。这个特性在任何 3D 游戏中都非常有用,因为它将为用户提供对场景的 360 度视图。同样,我们还学会了如何创建网格结构。
网格结构用于跟踪物体的位置。在像 WorldCraft 这样的复杂游戏中,跟踪游戏的物体和资源是任何游戏开发者的强制性任务,在这种情况下,网格非常有效。可以将不可见的网格实现为字典,或者作为任何复杂的集合。
本章的主要目标是让您熟悉 2D 游戏图形,即绘制基本图形和游戏网格。同样,您还了解了另一种检测游戏对象之间碰撞的方法(在 Flappy Bird 游戏中,我们使用了 pygame 掩模技术来检测碰撞)。在本章中,我们实现了一种通用和传统的碰撞检测方法:通过检查背景颜色属性和对象颜色属性。同样,我们学会了如何通过旋转来创建不同结构的对象。这种技术可以用来在游戏中生成多个敌人。我们没有为每个角色设计多个不同的对象(这可能耗时且昂贵),而是使用变换来改变对象的结构。
下一章是关于 Python OpenGL,通常称为 PyOpenGL。我们将看到如何使用 OpenGL 创建不同的几何结构,并观察如何将 PyOpenGL 和 pygame 一起使用。我们将主要关注不同的数学范式。我们将看到顶点和边等属性如何用于创建不同的复杂数学形状。此外,我们将看到如何使用 PyOpenGL 实现游戏中的放大和缩小功能。
第十四章:了解 PyOpenGL
几何形状和图形在游戏开发中起着至关重要的作用。当涉及到先进的图形技术的开发时,我们往往忽视它们的重要性。然而,许多流行的游戏仍然使用这些形状和图形来渲染游戏角色。数学概念,如变换、向量运动以及放大和缩小的能力,在操纵游戏对象时具有重要作用。Python 有几个模块来支持这种操纵。在本章中,我们将学习一个强大的 Python 功能——PyOpenGL 模块。
在探索 PyOpenGL 时,我们将学习如何使用基本图形(即顶点和边)创建复杂的几何形状。我们将从安装 Python PyOpenGL 并开始用它绘图开始。我们将使用它制作几个对象,如三角形和立方体。我们不会使用 pygame 来创建这些形状;相反,我们将使用纯数学概念来定义顶点和边的直角坐标点。我们还将探索不同的 PyOpenGL 方法,如裁剪和透视。我们将涵盖每一个方法,以了解 PyOpenGL 如何用于创建吸引人的游戏角色。
在本章结束时,您将熟悉创建基本图形的传统和数学方法。这种创建形状的方式为程序员和设计师提供了操纵他们的游戏对象和角色的能力。您还将学习如何在游戏中实现放大和缩小的功能,以及如何通过绘制几何基本图形来使用颜色属性。
本章将涵盖以下主题:
-
理解 PyOpenGL
-
使用 PyOpenGL 制作对象
-
理解 PyOpenGL 方法
-
理解颜色属性
技术要求
您需要以下要求清单才能完成本章:
-
建议使用 Pygame 编辑器(IDLE)版本 3.5+。
-
您将需要 Pycharm IDE(参考第一章,了解 Python-设置 Python 和编辑器,了解安装过程)。
-
本章的代码资产可以在本书的 GitHub 存储库中找到:
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter14
查看以下视频以查看代码的运行情况:
理解 PyOpenGL
在过去,包含经过 3D 加速硬件处理的三维场景的图形程序是每个游戏程序员都想要的东西。尽管按照今天的标准来看这是正常的,但硬件与多年前并不相同。大部分游戏的图形必须使用低处理设备中的软件进行渲染。因此,除了创建这样的场景,渲染也需要相当长的时间,最终会使游戏变慢。游戏界面的出现,也被称为图形卡,为游戏行业带来了革命;程序员现在只需要关注界面、动画和自主游戏逻辑,而不必关心处理能力。因此,90 年代后创建的游戏具有更丰富的游戏性和一丝人工智能(多人游戏)。
众所周知,图形卡可以处理渲染和优化场景等三维功能。但是,要使用这些功能,我们需要一个在我们的项目和这些接口之间进行通信的编程接口。我们在本章中要使用的应用程序编程接口(API)是 OpenGL。OpenGL 是一个跨平台(程序可以在任何机器上运行)的 API,通常用于渲染 2D 和 3D 图形。该 API 类似于用于促进与图形处理单元的交互的库,并且通过使用硬件加速渲染来加速图形渲染方法。它作为图形驱动程序的一部分预装在大多数机器上,尽管您可以使用GL 视图 实用程序来检查其版本。在我们开始编写程序以便使用 PyOpenGL 绘制几何形状和图形之前,我们需要在我们的机器上安装它。
安装 PyOpenGL
即使 OpenGL 已经存在于您的系统上,您仍然需要单独安装 PyOpenGL 模块,以便所需的 OpenGL 驱动程序和 Python 框架可以相互通信。Pycharm IDE 提供了一个服务,可以定位 Python 解释器并安装 PyOpenGL,从而消除了手动安装的开销。按照以下步骤在 Pycharm IDE 中安装 PyOpenGL:
-
单击顶部导航栏中的“文件”,然后单击“设置”。然后,将鼠标悬停在左侧导航窗口上,并选择项目:解释器选项。
-
选择当前项目的 Python 解释器,即 Python 3.8+(后跟您的项目名称),并从解释器下拉菜单旁边的菜单屏幕上按下添加(+)按钮。
-
在搜索栏中搜索 PyOpenGL,然后按“安装包”按钮。
或者,如果您想要外部安装 PyOpenGL,您可以将其下载为 Python 蛋文件。
Python 蛋是一个逻辑结构,包含了 Python 项目特定版本的发布,包括其代码、资源和元数据。有多种格式可用于物理编码 Python 蛋,也可以开发其他格式。但是,Python 蛋的一个关键原则是它们应该是可发现和可导入的。也就是说,Python 应用程序应该能够轻松高效地找出系统上存在哪些蛋,并确保所需蛋的内容是可导入的。
这些类型的文件被捆绑在一起,以创建可以通过简单的安装过程从Python 企业应用套件(PEAK)下载的 Python 模块。要下载 Python 蛋文件,您必须下载 Python easy_install模块。转到peak.telecommunity.com/DevCenter/EasyInstall,然后下载并运行ez_setup.py文件。成功安装 easy install 后,在命令行/终端中运行以下命令以安装 PyOpenGL:
easy_install PyOpenGL
Easy install 不仅用于安装 PyOpenGL,还可以借助它下载或升级大量的 Python 模块。例如,easy_install SQLObject 用于安装 SQL PyPi 包。
通常情况下,当我们需要使用包时,我们需要将它们导入到我们的项目中。在这种情况下,您可以创建一个演示项目(demo.py)来开始测试 OpenGL 项目。这样我们就可以使用诸如代码可维护性和调试之类的功能,我们将使用 Pycharm IDE 制作 PyOpenGL 项目,而不是使用 Python 的内置 IDE。打开任何新项目,并按照以下步骤检查 PyOpenGL 是否正在运行:
- 使用以下命令导入 PyOpenGL 的每个类:
from OpenGL.GL import *
- 现在,使用以下命令导入所需的 OpenGL 函数:
from OpenGL.GLU import *
- 接下来,您应该将
pygame导入到您的项目中:
from pygame.locals import *
- 使用
pygame命令为您的项目初始化显示:
import pygame
from pygame.locals import *
window_screen = pygame.display.set_mode((640, 480),
HWSURFACE|OPENGL|DOUBLEBUF)
- 运行您的项目并分析结果。如果出现新屏幕,您可以继续制作项目。但是,如果提示说 PyOpenGL 未安装,请确保按照前面的安装过程进行操作。
前面的四行很容易理解。让我们逐一讨论它们。第一步非常简单-它告诉解释器导入 PyOpenGL 以及其多个类,这些类可用于不同的功能。以这种方式导入可以减少逐个导入 PyOpenGL 的每个类的工作量。第一个导入是强制性的,因为这一行导入以gl关键字开头的不同 OpenGL 函数。例如,我们可以使用诸如glVertex3fv()之类的命令,用于绘制不同的 3D 形状(我们稍后会介绍这个)。
导入语句的下一行,即from OpenGL.GLU import *,是为了我们可以使用以glu开头的命令,例如gluPerspective()。这些类型的命令对于更改显示屏的视图以及渲染的对象非常有用。例如,我们可以使用这样的glu命令进行裁剪和剪裁等转换。
类似于 PyOpenGL GL 库,GLU 是一个 Python 库,用于探索相关数据集内部或之间的关系。它们主要用于在影响渲染对象的形状和尺寸的同时对显示屏进行更改。要了解有关 GLU 内部的更多信息,请查看其官方文档页面:pyopengl.sourceforge.net/pydoc/OpenGL.GLU.html。
下一行只是将pygame导入到我们的项目中。使用 OpenGL 创建的表面是 3D 的,它需要pygame模块来渲染它。在使用gl或glu模块的任何命令之前,我们需要调用pygame模块使用set_mode()函数创建一个显示(感受pygame模块的强大)。由pygame模块创建的显示将是 3D 而不是 2D,同时使用 OpenGL 库的set_mode函数。之后,我们告诉 Python 解释器创建一个 OpenGL 表面并将其作为window_screen对象返回。传递给set_mode函数的元组(高度,宽度)表示表面大小。
在最后一步,我希望您关注以下可选参数:
-
HWSURFACE:它在硬件中创建表面。主要用于创建加速的 3D 显示屏,但仅在全屏模式下使用。 -
OPENGL:它向 pygame 建议创建一个 OpenGL 渲染表面。 -
DOUBLEBUF:它代表双缓冲,pygame 建议对HWSURFACE和OPENGL使用。它减少了屏幕上颜色闪烁的现象。
还有一些其他可选参数,如下:
-
FULLSCREEN:这将使屏幕显示渲染为全屏视图。 -
RESIZABLE:这允许我们调整窗口屏幕的大小。 -
NOFRAME:这将使窗口屏幕无边框,无控件等。有关 pygame 可选参数的更多信息,请访问www.pygame.org/docs/ref/display.html#pygame.display.set_mode。
现在我们已经在我们的机器上安装了 PyOpenGL 并为屏幕对象设置了一个窗口,我们可以开始绘制对象和基本图形。
使用 PyOpenGL 制作对象
OpenGL 主要用于绘制不同的几何形状或基元,所有这些都可以用于创建 3D 画布的场景。我们可以制作多边形(多边形)形状,如三角形、四边形或六边形。应该向基元提供多个信息,如顶点和边,以便 PyOpenGL 可以相应地渲染它们。由于与顶点和边相关的信息对于每个形状都是不同的,因此我们有不同的函数来创建不同的基元。这与 pygame 的 2D 函数(pygame.draw)不同,后者用于使用相同的单个函数创建多个形状。例如,三角形有三个顶点和三条边,而四边形有四个顶点。
如果您具有数学背景,对顶点和边的了解对您来说将是小菜一碟。但对于那些不了解的人来说,任何几何形状的顶点都是两条或两条以上线相交的角或点。例如,三角形有三个顶点。在下图中,A、B和C是三角形 ABC 的顶点。同样,边是连接一个顶点到另一个顶点的线段。在下面的三角形中,AB、BC 和 AC 是三角形 ABC 的边:

要使用 PyOpenGL 绘制这样的几何形状,我们需要首先调用一些基本的 OpenGL 基元,这些基元列在下面:
-
首先,使用要绘制的任何基元调用
glBegin()函数。例如,应调用glBegin(GL_TRIANGLES)来通知解释器我们将要绘制的三角形形状。 -
关于顶点(A、B、C)的下一个重要信息对于绘制形状至关重要。我们使用
glVertex()函数发送有关顶点的信息。 -
除了有关顶点和边的信息之外,您还可以使用
glColor()函数提供有关形状颜色的其他信息。 -
在提供足够的基本信息之后,您可以调用
glEnd()方法通知 OpenGL 已经提供了足够的信息。然后,它可以开始绘制指定的形状,如glBegin方法提供的常量所示。
以下代码是使用 PyOpenGL 绘制三角形形状的伪代码(参考前面的插图以了解 PyOpenGL 函数的操作):
#Draw a geometry for the scene
def Draw():
#translation (moving) about 6 unit into the screen and 1.5 unit to left
glTranslatef(-1.5,0.0,-6.0)
glBegin(GL_TRIANGLES) #GL_TRIANGLE is constant for TRIANGLES
glVertex3f( 0.0, 1.0, 0.0) #first vertex
glVertex3f(-1.0, -1.0, 0.0) #second vertex
glVertex3f( 1.0, -1.0, 0.0) #third vertex
glEnd()
下图显示了三角形的法线。法线是一个数学术语,表示单位向量(具有 1 的大小和方向,请参考第十章,使用海龟升级蛇游戏,了解更多关于向量的信息)。这个信息(法线)很重要,因为它告诉 PyOpenGL 每个顶点的位置。例如,glVertex3f(0, 1, 0)会在y轴上放置一个顶点。因此,(x, y, z)表示x轴、y轴和z轴上的大小,如下所示:

现在我们知道如何创建基本的三角形基元,让我们看一下以下表格,了解可以使用 PyOpenGL 绘制的其他不同类型的基元:
| 常量关键字 | 形状 |
|---|---|
GL_POINTS |
将点或点绘制到屏幕上 |
GL_LINES |
绘制线条(单独的线条) |
GL_TRIANGLES |
绘制三角形 |
GL_QUADS |
绘制四边形(四边形) |
GL_POLYGON |
绘制多边形(任何边或顶点) |
现在我们能够使用基元常量绘制任何基元,前提是我们有关于它们顶点的信息。让我们创建以下四边形:

以下是绘制前述立方体基元的伪代码:
glBegin(GL_QUADS)
glColor(0.0, 1.0, 0.0) # vertex at y-axis
glVertex(1.0, 1.0, 0.0) # Top left
glVertex(1.0, 1.0, 0.0) # Top right
glVertex(1.0, 1.0, 0.0) # Bottom right
glVertex(1.0, 1.0, 0.0) # Bottom left
glEnd()
在上一行代码中,我们首先定义了GL_QUADS常量,以通知 PyOpenGL 我们正在绘制的基本图元的名称。然后,我们使用glColor方法添加了颜色属性。同样,我们使用glVertex方法定义了立方体的四个主要顶点。作为glVertex方法的参数传递的坐标代表了平面上的x、y和z轴。
现在我们能够使用 PyOpenGL 绘制不同的几何形状,让我们了解 PyOpenGL 的不同渲染函数/基本图元,以便我们可以制作其他复杂的结构。
理解 PyOpenGL 方法
众所周知,计算机屏幕具有二维视图(高度和宽度)。为了显示由 OpenGL 创建的三维场景,场景必须经过几次矩阵变换,通常称为投影。这允许将 3D 场景呈现为 2D 视图。在各种变换方法中,常用于投影的有两种(裁剪和归一化)。这些矩阵变换应用于 3D 坐标系,并缩减为 2D 坐标系。GL_PROJECTION矩阵经常用于执行与投影相关的变换。投影变换的数学推导是另一回事,我们永远不会使用它们,但理解它的工作原理对于任何游戏程序员来说都是重要的。让我们来看看GL_PROJECTION的工作原理:
-
裁剪:这将把场景的顶点坐标转换为场景的裁剪坐标。裁剪是一个调整场景长度的过程,以便从
视口(窗口显示)中裁剪掉一些部分。 -
归一化:这个过程被称为标准化设备坐标(NDC),它通过将裁剪坐标除以裁剪坐标的
w分量来将裁剪坐标转换为设备坐标。例如,裁剪坐标 x[c]、y[c]和 z[c]通过与 w[c]进行比较。不在-w[c]到+w[c]范围内的顶点被丢弃。这里的下标c表示裁剪坐标系。
因此,更容易推断矩阵变换的过程,包括GL_PROJECTION,包括两个步骤:裁剪,紧接着是归一化到设备坐标。以下图示了裁剪的过程:

我们可以清楚地观察到裁剪(有时称为剔除)的过程只在裁剪坐标中执行,这些坐标由 2D 视口的大小定义。要找出哪些裁剪坐标已被丢弃,我们需要看一个例子。假设x、y和z是裁剪坐标,它们的值与w(x、y)的坐标进行比较,决定任何顶点(或形状的一部分)是否保留在屏幕上或被丢弃。如果任何坐标位于-w[c]的值以下和+w[c]的值以上,那个顶点就被丢弃。在上图中,顶点 A 位于+w[c]之上,而顶点 B 和 C 位于-w[c]之下,因此两个顶点都被丢弃。此外,顶点 D 和 E 位于(-w[c],+w[c])的值范围内,因此它们保留在视图中。w[c]的值由视口的宽度确定。因此,OpenGL 的投影矩阵(GL_PROJECTION)接受 3D 坐标并执行投影,将其转换为可以呈现在 2D 计算机显示屏上的 2D 坐标。尽管可能会丢失一些信息,但它被认为是将 3D 场景渲染到 2D 屏幕上的最有效方法之一。
然而,我们还没有完成——在投影完成后,我们必须将 3D 场景转换为 2D,这需要使用另一个 OpenGL 矩阵变换,称为GL_MODELVIEW。然而,这种转换的步骤是相当不同的。首先进行矩阵变换,将坐标系乘以视距。
为了将它们转换为 2D 组件,为每个z分量提供了。要理解模型视图矩阵,我们必须理解构成其组成部分的两个矩阵:模型矩阵和视图矩阵。模型矩阵在模型世界中执行多个转换,如旋转、缩放和平移,而视图矩阵调整相对于摄像机位置的场景。视图矩阵负责处理对象在玩家观看场景时的外观,类似于第一人称角色的屏幕/视点。
现在我们了解了 OpenGL 的变换矩阵,让我们制作一个简单的程序(resize.py),可以相应地调整显示屏的大小:
- 首先导入 OpenGL。
from OpenGL.GL import *
from OpenGL.GLU import *
- 制作一个简单的函数
change_View(),以显示屏的大小为参数,如下所示:
def change_View():
pass
- 从步骤 3到步骤 6中的代码应该添加到
change_View()函数中。添加一个对ViewPort的函数调用,它以初始值和显示大小为参数,如下所示:
glViewport(0, 0 , WIDTH, HEIGHT)
- 现在,是时候添加投影矩阵了。要添加
GL_PROJECTION,我们必须调用glMatrixMode()方法,检查被调用的矩阵的模式,如下所示:
glMatrixMode(GL_PROJECTION) #first step to apply projection matrix
- 在应用投影矩阵后,应调用两个重要的方法,即
glLoadIdentity()和gluPerspective(),它们为投影矩阵设置了“基准”:
aspect_ratio = float(width/height)
glLoadIdentity()
gluPerspective(40., aspect_ratio, 1., 800.)
- 设置投影矩阵后,下一步是设置模型视图矩阵。可以通过调用
glMatrixMode()方法激活模型视图矩阵模式:
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
前面的六个步骤向我们展示了如何调整显示屏,将 3D 场景显示在 2D 显示屏中。步骤 1和步骤 2专注于导入 OpenGL。在步骤 3中,我们调用了glViewport()方法,并传递了一个参数,范围从(0, 0)到(width, height),这告诉 OpenGL 我们要使用整个屏幕来显示场景。下一步调用了glMatrixMode()方法,告诉 OpenGL 每次函数调用都将应用投影矩阵。
步骤 5调用了两个新方法,正如glLoadIdentity()的签名所述,用于使投影矩阵成为单位矩阵,这意味着投影矩阵的所有坐标都应该更改为1。最后,我们调用另一个方法gluPerspective(),它设置了分类/标准投影矩阵。您可能已经注意到gluPerspective()方法以glu开头而不是gl,因此,此函数是从 GLU 库中调用的。gluPerspective方法传递了四个浮点参数,即相机视点的视场角,宽高比和两个裁剪平面点(近和远)。因此,裁剪是通过gluPerspective函数完成的。要了解裁剪是如何完成的,请参考我们在本主题开头讨论的星形几何形状的示例。
现在,是时候将我们学到的知识付诸实践,制作一个与 PyOpenGL 结构交互的程序。我们还将定义另一个属性,使对象更具吸引力。这被称为颜色属性。我们将定义一个立方体,以及关于顶点和边的数学信息。
理解颜色属性
在现实世界的场景中,与物体相关联的颜色有很多,但是计算机设备并不足够智能或者能力强大到可以区分和捕捉所有这些颜色。因此,几乎不可能在数字形式中容纳每一种可能的颜色。因此,科学家们为我们提供了一种表示不同颜色的方法:RGB模式。这是三种主要颜色组件的组合:红色、绿色和蓝色。通过组合这些组件,我们可以创建几乎所有可能的颜色。每个组件的值范围从 0 到 255;对每个组件的代码的更改会导致新的颜色。
OpenGL 中使用的颜色属性与现实世界的颜色反射属性非常相似。我们观察到的物体的颜色实际上并不是它的颜色;相反,它是物体反射的颜色。物体可能具有某种波长的属性,物体可以吸收某种颜色并反射出另一种颜色。例如,树木吸收阳光除了绿色。我们感知并假设它是绿色的,但实际上物体没有颜色。这种光反射的概念在 OpenGL 中得到了很好的应用——通常我们定义一个可能具有明确颜色代码的光源。此外,我们还将定义物体的颜色代码,然后将其与光源相乘。结果的颜色代码或光是从物体反射出来的结果,被认为是物体的颜色。
在 OpenGL 中,颜色以包含四个组件的元组形式给出,其中三个是红色、绿色和蓝色。第四个组件代表 alpha 信息,表示物体的透明级别。在 OpenGL 中,与 RGB 组件的值为 0 到 255 不同,我们提供的值范围是 0 到 1。例如,黄色是红色和绿色的组合,因此它的 alpha 信息是(1, 1, 0)。请参考community.khronos.org/t/color-tables/22518了解更多关于 OpenGL 颜色代码的信息。
以下函数/特性在 OpenGL 的颜色属性中可用:
-
glClearColor(): 这个函数设置一个清晰的颜色,这意味着它填充在尚未绘制的区域上的颜色。颜色代码的值可以作为一个元组给出,范围从 0 到 1。例如,glClearColor(1.0, 1.0, 1.0, 0.0)表示用白色填充。 -
glShadeModel(): 这个函数启用了 OpenGL 的光照特性。通常传递给glShadeModel的参数是GL_FLAT,用于给形状的面或边缘上色,比如立方体和金字塔。如果你想给曲面对象上色而不是给面体对象上色,你可以使用GL_SMOOTH。 -
glEnable(): 这实际上不是与颜色属性相关的方法,但是用于启用它们。例如,glEnable(GL_COLOR_MATERIAL)将启用材料,这允许我们与表面和光源进行交互。此外,通过调整设置,材料的属性主要用于使任何物体更轻和更锐利。
现在我们熟悉了颜色属性的概念和创建颜色属性的方法,让我们编写一个简单的程序,使用 PyOpenGL 的颜色属性来绘制一个立方体。
头脑风暴网格
在我们开始编码之前,头脑风暴一下并获取必要的信息总是一个好习惯,这样我们才能创建一个程序。因为我们将创建一个渲染立方体的程序——一个有八个顶点、12 条边和六个面的表面——我们需要明确定义这样的信息。我们可以将这些属性定义为嵌套元组——单个元组内的元组。
以一个顶点作为参考,我们可以同时获取其他顶点的位置。假设一个立方体有一个顶点在(1,-1,-1)。现在,假设立方体的所有边都是 1 个单位长,我们可以得到顶点的坐标。以下代码显示了立方体的顶点列表:
cube_Vertices = (
(1, -1, -1),
(1, 1, -1),
(-1, 1, -1),
(-1, -1, -1),
(1, -1, 1),
(1, 1, 1),
(-1, -1, 1),
(-1, 1, 1),
)
同样,有 12 条边(边是从一个顶点到另一个顶点画出的线)。由于有八个顶点(0 到 7),让我们编写一些代码,使用八个顶点定义 12 条边。在以下代码中,作为元组传递的标识符表示从一个顶点到另一个顶点画出的边或面。例如,元组(0,1)表示从顶点 0 到顶点 1 画出的边:
cube_Edges = (
(0,1),
(0,3),
(0,4),
(2,1),
(2,3),
(2,7),
(6,3),
(6,4),
(6,7),
(5,1),
(5,4),
(5,7),
)
最后,必须提供的最后一部分信息是关于表面的。一个立方体有六个面,每个面包含四个顶点和四条边。我们可以这样提供这些信息:
cube_Surfaces = (
(0,1,2,3),
(3,2,7,6),
(6,7,5,4),
(4,5,1,0),
(1,5,7,2),
(4,0,3,6)
)
注意提供顶点、边和表面的顺序很重要。例如,在cube_Surfaces数据结构中,如果你交换了元组的第二个项目和第一个项目,立方体的形状将会恶化。这是因为每个信息都与顶点信息相关联,也就是说,表面(0,1,2,3)包含了第一个、第二个、第三个和第四个顶点。
现在我们已经完成了头脑风暴,并收集了关于我们要绘制的形状的一些有用信息,是时候开始使用 PyOpenGL 及其库来渲染立方体了,这个库通常被称为GLU 库。
理解 GLU 库
现在我们已经收集了关于我们形状的边、面和顶点的信息,我们可以开始编写模型了。我们已经学习了如何使用glBegin()和glVertex3fv()等方法使用 OpenGL 绘制形状。让我们使用它们,并创建一个可以绘制立方体结构的函数:
- 首先导入 OpenGL 和 GLU 库。在导入库之后,将我们在头脑风暴中定义的有关顶点、边和表面的信息添加到同一个文件中:
from OpenGL.GL import *
from OpenGL.GLU import *
- 接下来,定义函数并获取表面和顶点。这个过程非常简单;我们将从绘制立方体的表面开始。我们应该使用
GL_QUADS属性来绘制四面体表面(困惑吗?请参考本章的使用 OpenGL 制作对象部分获取更多信息):
def renderCube():
glBegin(GL_QUADS)
for eachSurface in cube_Surfaces:
for eachVertex in eachSurface:
glColor3fv((1, 1, 0)) #yellow color code
glVertex3fv(cube_Surfaces[eachVertex])
glEnd()
- 最后,在
renderCube()方法中,编写一些可以绘制线段的代码。使用GL_LINES参数来绘制线段:
glBegin(GL_LINES)
for eachEdge in cube_Edges:
for eachVertex in eachEdge:
glVertex3fv(cube_Vertices[eachVertex])
glEnd()
这个三行的过程足以创建复杂的几何形状。现在,你可以对这些立方体执行多个操作。例如,你可以使用鼠标触控板旋转物体。正如我们所知,处理这样的用户事件需要一个pygame模块。因此,让我们定义一个函数,来处理事件,并使用 PyOpenGL 的一些特性。从import pygame语句开始你的代码,并添加以下代码:
def ActionHandler():
pygame.init()
screen = (800, 500)
pygame.display.set_mode(screen, DOUBLEBUF|OPENGL) #OPENGL is essential
#1: ADD A CLIPPING TRANSFORMATION
gluPerspective(85.0, (screen[0]/screen[1]), 0.1, 50)
# 80.0 -> field view of camera
#screen[0]/screen[1] -> aspect ration (width/height)
#0.1 -> near clipping plane
#50 -> far clipping plane
glRotatef(18, 2, 0, 0) #start point
前面的代码片段非常容易理解,因为我们从本章的开始就一直在做这个。在这里,我们使用了pygame模块,它使用 OpenGL 场景或接口设置游戏屏幕。我们添加了一个变换矩阵,它使用gluPerspective()函数执行裁剪。最后,我们在实际旋转之前添加了立方体的初始位置(在开始时可能在哪里)。
现在我们已经介绍了 OpenGL 的基本知识,让我们使用 pygame 的事件处理方法来操纵立方体的结构,就像这样:
while True:
for anyEvent in pygame.event.get():
if anyEvent.type == pygame.QUIT:
pygame.quit()
quit()
if anyEvent.type == pygame.MOUSEBUTTONDOWN:
print(anyEvent)
print(anyEvent.button) #printing mouse event
#mouse button 4 and 5 are at the left side of the mouse
#mouse button 4 is used as forward and backward navigation
if anyEvent.button == 4:
glTranslatef(0.0,0.0,1.0) #produces translation
of (x, y, z)
elif anyEvent.button == 5:
glTranslatef(0.0,0.0,-1.0)
在处理基于鼠标按钮导航的事件之后,让我们使用 PyOpenGL 提供的一些方法来渲染立方体。我们将使用glRotatef()等方法来执行矩阵变换。在处理事件的地方之后,写入以下代码:
glRotatef(1, 3, 1, 1)
#The glRotatef is used to perform matrix transformation which performs a rotation
#of counterclockwise with an angle of degree about origin through the point #provided as (x, y, z).
#-----------------------------------------------------------------
#indicates the buffer that needs to be cleared
#GL_COLOR_BUFFER_BIT: enabled for color drawing
#GL_DEPTH_BUFFER_BIT: depth buffer which needs to be cleared
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
#render cube
renderCube()
pygame.display.flip()
pygame.time.wait(12)
#call main function only externally
ActionHandler()
上述代码的突出部分表示调整大小的变换,最终导致了使用 ZOOM-UP 和 ZOOM-DOWN 功能。现在,您可以运行程序,观察立方体在 pygame 屏幕中心以黄色渲染。尝试使用外部鼠标和导航按钮(按钮 4 和 5)进行放大和缩小。您还可以观察项目中如何使用裁剪:每当我们使一个立方体变得如此之大以至于超出裁剪平面时,立方体的一些部分将从视口中移除。
通过这种方式,我们可以结合两个强大的 Python 游戏模块,即pygame和PyOpenGL,制作 3D 场景和界面。我们只是简单地介绍了创建一些形状和如何变换它们的方法。现在,轮到您去发现更多关于 PyOpenGL 的知识,并尝试制作一个更加用户友好和吸引人的游戏,提供丰富的纹理和内容。
总结
在本章中,我们涵盖了许多有趣的主题,主要涉及表面和几何形状。虽然在本章中我们使用了术语矩阵,但我们并没有使用数学方法进行矩阵计算,因为 Python 内置了执行此类操作的一切。尽管如此,我们应该记住这句古老的格言,游戏程序员不需要拥有数学博士学位,因为只要我们想制作游戏,基本的数学水平就足够了。在这里,我们只学习了平移、缩放和旋转,如果我们想制作一个 3D 场景,这已经足够了。我们没有陷入使用数学方法进行平移或缩放的概念中——相反,我们学习了使用编程方法。
我们首先学习了如何使用 pygame 的setting方法设置 OpenGL 显示屏。由于 OpenGL 是一个广阔而深奥的研究领域,无法在单一章节中涵盖所有内容。因此,我们只涵盖了如何加载/存储三维模型以及如何通过应用裁剪、旋转和调整大小变换将它们应用到 OpenGL 渲染表面上。我们还研究了颜色属性,并将它们与 PyOpenGL 和 pygame 一起使用。本章的主要目标是让您更容易理解如何使用 OpenGL 创建 3D 形状,同时提供关键的几何信息,如顶点、边和表面。现在您将能够使用 OpenGL 创建 3D 形状、图形和可视化。您现在也知道如何将 OpenGL 的颜色属性与其他着色模式区分开来。
在下一章中,我们将学习另一个重要的模块,名为Pymunk。这是一个非常强大的物理库,为游戏角色增加了物理能力。我们将学习在需要讨论真实世界环境时使用的不同术语,如速度和加速度,这些术语用于处理碰撞和游戏角色的移动。在学习这些概念的同时,我们还将制作一个愤怒的小鸟游戏,并将其部署到各种平台上。
第十五章:通过构建愤怒的小鸟游戏来了解 Pymunk
Python 作为数据科学和机器学习领域的独立语言已有半个世纪之久,但在游戏开发行业并不够流行,直到像 pymunk 这样的开源软件包出现。这些开源软件包为游戏开发人员提供了一个简单的界面,通过模拟来模仿真实世界的环境,从而允许他们创建与玩家输入相关联的单个或多个物体。这一进步将连续物理模型引入了 Python 游戏开发中,其中一些物体被允许休息以提高效率,并且只有在碰撞原则下才会被引入光线。通过这种模型,我们可以正确而有效地处理多个物体的碰撞。
通过本章的学习,您将了解 Pythonic 2D 物理库的基础知识,从而知道如何使用类和子模块来构建像愤怒的小鸟这样的复杂游戏,通过考虑质量、运动、惯性、弹性和力矩等物理属性来模拟真实世界的环境。您还将学习如何创建 2D 刚体并将它们与玩家的输入相关联,以模拟物理冲量。这将导致刚体在模拟环境(空间)内的运动。您还将学习如何使用时间间隔步长(dt)通过更新促进刚体在该空间内运动的物理属性。
到目前为止,您一直在检查两个游戏实体之间的碰撞(在第十一章中,使用 Pygame 制作 Outdo Turtle - Snake Game UI,您检查了蛇与边界墙之间的碰撞,而在第十二章,学习角色动画、碰撞和移动中,您检查了鸟与垂直管道之间的碰撞),但本章将更加启发人,因为您将逐个检查三个游戏对象之间的碰撞,并通过创建碰撞处理程序执行操作。
本章将涵盖以下主题:
-
了解 pymunk
-
创建角色控制器
-
创建多边形类
-
探索 Pythonic 物理模拟
-
实施弹弓动作
-
处理碰撞
-
创建关卡
-
处理用户事件
-
可能的修改
技术要求
您必须具备以下要求才能完成本章:
-
Pygame 编辑器(IDLE)版本 3.5 或更高版本。
-
PyCharm IDE(参考第一章,了解 Python - 设置 Python 和编辑器,了解安装过程)。
-
pymunk模块(可在www.pymunk.org/en/latest/找到的开源库)。 -
本章的代码可以在本书的 GitHub 存储库中找到:
github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter15 -
愤怒的小鸟的精灵表外部链接:
www.spriters-resource.com/mobile/angrybirds/sheet/59982/。
观看以下视频以查看代码的运行情况:
了解 pymunk
在现实环境中,物体以各种方向任意移动。因此,为了模仿这种运动,游戏必须处理物体的不同物理行为。例如,当我们把一个物体扔到空中时,由于重力的存在,物体会在某个时刻撞击地面。同样,我们还必须处理每次物体从表面弹回时速度的减小。例如,如果我们拿一个球扔到空中,一段时间后,它必须以原始速度 V[o]撞击地面,然后在表面弹起,以速度 V[f]上升。因此,很明显 V[o] > V[f]。在游戏环境中实现这种物体行为将给玩家留下良好的印象。
作为自然科学的一个分支,物理学试图通过模拟和数学推导来模拟真实世界的行为。物理学定义了不同的术语,如质量、惯性、冲量、弹性、摩擦等。这些术语定义了物体在不同环境中暴露时的特性。不要陷入物理学的复杂性,让我们开始做生意。真正的问题是,为什么我们需要在游戏中加入物理学?这个问题的答案很简单:与现实世界的物体一样,游戏也有物体/角色。这些角色由游戏的玩家控制。大多数玩家喜欢玩模拟真实世界现象的游戏。
在使用pymunk模块之前,您必须了解一些物理术语,如下所示:
-
质量: 从字面上讲,质量指的是任何物体的重量。在考虑其物理定义时,物体的质量是物体中物质的量的度量。
-
力: 力是由物体与另一个物体的相互作用而产生的对物体的推或拉。
-
重力: 导致苹果向地面掉落的力。重力是吸引两个物体彼此的力。
-
弹性: 受变形的物体的属性,它们会重新塑形并恢复到原来的形状。例如,弹簧和橡皮筋即使受到力的作用也会恢复到原来的形状。
-
力矩: 力矩是导致物体围绕特定点或轴旋转的属性。
如果您以前没有玩过愤怒的小鸟,请确保查看此链接:freeangrybirdsgame.org/play/angry_birds_online.html。在玩游戏时,观察角色、结构和弹弓动作的数量。
如果我们愤怒的小鸟游戏中的两个角色(鸟和猪)都有水平移动,那将会很无聊。例如,当玩家从弹弓或弹弓射出一只愤怒的小鸟时,如果它不遵循抛射运动(45 度运动),而只是水平运动(90 度运动)会怎么样?这违反了物理定律之一,即地球对你产生吸引力。也许我们可以说这就是为什么这很重要。违反这样的定律会使游戏变得愚蠢和荒谬,这可能会损害游戏的声誉。为了在游戏中模拟这种真实世界的物理现象,Python 社区开发了一个 2D 物理库。我们可以使用这个库为游戏对象应用不同的特性,如质量、惯性、冲量和摩擦。
首先,我建议您查看 pymunk 的官方文档www.pymunk.org/en/latest/pymunk.html.。由于pymunk的软件包和模块经常更新,您将在官方文档页面上看到大量资源。只是不要被它们的数量所压倒——我们只需要其中的一些资源来制作使用 pymunk 2D 物理库的游戏。
现在您已经阅读了文档,我假设您可能已经看到了几个子模块和类。我们将需要其中一些,我们将讨论所有这些。我们将从 pymunk 开始,这是最受欢迎和广泛使用的子模块。它被命名为vec2d。要观察vec2d的工作原理,您必须复习我们在第九章中学到的基础知识,数据模型实现。简而言之,我们使用不同的数据模型来实现向量操作(我们使用__add__()来添加向量,__str__()来格式化向量等)。我们已经学习了关于向量操作的知识,但是以一种 Pythonic 的方式;现在,让我们以一种模块化的方式来学习。Python 开发者社区已经为vec2d创建了一个子模块;也就是说,Vec2d类,以执行任何与向量相关的操作。
在查看Vec2d类的示例之前,让我们先设置 PyCharm 项目。打开 PyCharm 编辑器并创建一个新项目。我会称其为愤怒的小鸟。提供项目名称后,按“创建”按钮创建项目。当 PyCharm 准备好您的项目后,请创建一个名为test.py的新 Python 文件。在编写任何代码之前,我们必须在当前项目中安装pymunk模块。
按照以下步骤操作(要获取有关如何在 PyCharm 中安装任何第三方库的详细说明,请参阅第一章,了解 Python - 设置 Python 和编辑器):
-
单击“文件”|“设置”。将打开“设置”窗口。
-
在左侧选项卡上,单击“项目:愤怒的小鸟”选项卡。它将列出已在 Python 解释器中安装的所有模块。
-
要添加新模块,请单击“包”选项卡旁边的(+)按钮。
-
搜索
pymunk并安装该模块(确保您的互联网连接正常)。
现在pymunk模块已成功安装,让我们回到Vec2d类。正如我们之前提到的,这个类可以用来执行向量操作。这是一种替代使用数据模型进行向量操作的方法。让我们看一个使用Vec2d类创建向量的简单示例:
from pymunk.vec2d import Vec2d
print(Vec2d(2, 7))
#add two vectors
print(Vec2d(2, 7) + Vec2d((3, 4)))
#results
Vec2d(2, 7)
Vec2d(5, 11)
除了执行数学计算之外,Vec2d还可以执行不同的高级功能计算。例如,如果您想要找到两个向量之间的距离,我们可以调用“get_distance()”函数,如下所示:
print(Vec2d(3,4).get_distance(Vec2d(9,0)))
7.211102550927978
上述函数使用公式√(x2 − x1)² +(y2 − y1)² 计算两个向量点之间的距离,其中(x1,y1)和(x2,y2)是两个向量坐标。要了解有关距离公式的更多信息,请转到www.purplemath.com/modules/distform.htm。
现在我们已经探索了Vec2d,我们将学习关于pymunk类。有超过 10 个类,但我们只会学习重要的类。您可以通过访问它们的官方文档页面来了解它们。让我们逐一学习。
探索 pymunk 的内置类
首先,我们将从Space类开始。这个类指的是所有游戏角色将驻留的占位符。游戏角色的移动也将在此空间中定义。随着游戏的进行,刚性物体的属性(具有质量、摩擦、弹性和惯性等物理属性)将在此空间中发生变化。例如,不同空间中的物体将具有不同的速度和加速度。在愤怒的小鸟游戏中,愤怒的小鸟的速度将与玩家最初从弹弓上射出它然后与游戏中的结构(横梁和柱子,我们将在一分钟内介绍)发生碰撞时不同。
pymunk模块中定义了许多方法,因此我们将从最重要的方法开始:add_collision_handler(collision_type_a, collision_type_b)。回想一下第十一章,使用 Pygame 制作贪吃蛇游戏 UI,你制作了一个贪吃蛇游戏,并自己添加了碰撞处理程序,添加了一些逻辑,暗示当两个对象的位置相同时,它们被认为发生了碰撞。这种方法是以更简单的方式做同样的事情,只需调用pymunk内置函数。由pymunk创建的碰撞处理程序将接受两个参数:type_a和type_b。您必须记住这两种类型都是整数。我们将使用它们明确定义两个对象。例如,在愤怒的小鸟游戏中,将有三个主要角色:鸟、木头和猪(要下载所需的资源,请查看技术要求部分中提到的 GitHub 链接)。由于我们有三个角色,我们必须为每个角色添加碰撞处理程序,如下所示:
-
当鸟和猪碰撞时:我们将调用
add_collision_handler(0, 1),其中0表示鸟角色的整数类型,1 表示猪游戏角色的整数类型。 -
当鸟和木头碰撞时:我们将调用
add_collision_handler(0, 2),其中2表示木头游戏角色的整数类型。(请记住,在整个游戏过程中,0 必须代表鸟角色,不能用于任何其他角色)。 -
当猪和木头碰撞时:我们将调用
add_collision_handler(1, 2)。
通过这样做,我们将感受到Space类内定义的碰撞处理程序的强大。此函数检查两个对象是否发生碰撞,并返回CollisionHander,用于表示type_a和type_b之间的碰撞。
现在我们已经了解了如何处理 pymunk 中的碰撞,我们将学习pymunk模块中最重要和最常用的两个类:Body和Shape。首先,我们将开始学习 pymunkBody类及其属性。然后,我们将探索 pymunkShape类,学习如何向几何图形添加不同的物理属性,如弹性、质量和力矩。
探索 pymunk Body 类
在制作像愤怒的小鸟这样的复杂游戏时,我们必须定义多个游戏角色,比如鸟、猪和木结构。以下插图提供了这些游戏角色的视觉效果:

所有这些都是图像(在 Pygame 的意义上,它们是精灵)。除非我们将它们转换为刚体,否则不能直接使用。Pygame 定义物理测量(质量、运动、摩擦和冲量)的方式意味着它将这些精灵转换为刚体。这就是Body类的强大之处:Body类接受任何形状(圆形、多边形、精灵等)并注入质量、力矩、力和许多其他属性,如下所示:
import pymunk
space = pymunk.Space() #creating Space instance
body = pymunk.Body() #creating Body instance
object = pymunk.Circle(body, 4)
object.density = 2
#print body measurements
print("Mass : {:.0f} and Moment: {:.0f}".format(body.mass, body.moment))
space.add(body, object)
print("Mass: {:.0f} and Moment: {:.0f}",format(body.mass, body.moment))
前面代码的结果如下:
Mass : 0 and Moment: 0
Mass: 101 and Moment: 804
在上述代码中,我们首先定义了space。正如我们之前提到的,Space是一个代表物体的占位符的类。仔细看一下space.add(body, object)语句:我们使用add()方法将对象添加到space中。同样,我们创建了Body类的一个实例。Body类并不一定意味着物体或游戏角色;相反,它是一个虚拟的地方,我们可以在其中添加游戏角色。object = pymunk.Circle(body, 4)语句将创建一个半径为4单位的圆形物体,并将其添加到Body的范围内。创建圆形物体后,我们添加了密度(物体的强度属性:物体所占体积单位质量;请参考以下链接了解有关密度的更多信息:www.nuclear-power.net/nuclear-engineering/thermodynamics/thermodynamic-properties/what-is-density-physics/)。
在将density属性添加到对象后,我们打印了两个物体:第一个是当物体未添加到空间中时的情况,另一个是圆形物体(连同density)添加到空间中的情况。我们打印了两个物体。如预期的那样,第一个物体未添加到空间中,我们没有为该物体定义任何属性,因此其质量和力矩显示为零。同样,在物体添加到space后,它们的质量和力矩分别变为 101 和 804 标准单位。
现在,让我们学习另一个重要的pymunk模块类,名为Shape。
探索 pymunk Shape 类
Shape类有三个不同的类别:Circle、Poly和Segment。然而,了解Shape类本身就足以让我们理解这些类别。让我们学习一下我们可以从以下几点调用形状的一些重要物理属性(全部小写):
-
copy():执行当前形状的深复制。 -
density:形状的密度。这是一个非常重要的属性,用于计算附加形状的物体的质量和转动惯量。我们在pymunk Body class部分的示例中看到了这个属性。 -
elasticity:定义形状的弹性。此属性用于定义形状的弹跳性质。如果弹性值为 0,则该形状无法弹跳。对于完美的弹跳,弹性值应为 1。 -
friction:定义形状的摩擦系数。0的friction值定义了无摩擦的表面,而1定义了完全光滑(无粗糙)的表面。 -
mass:定义形状的重量。当mass较大时,物体无法弹跳和自由移动。 -
moment:计算形状的力矩。
为了观察上述属性的应用,我们不创建Shape类的实例。相反,我们使用Circle、Poly和Segment类。
Circle类(我们在上一节中使用过)可以这样实例化:
pymunk.Circle(body, radius_of_circular_shape)
在圆形物体的情况下,也可以定义密度、弹性、摩擦、质量和力矩等属性。我们将在制作愤怒的小鸟游戏时看到这方面的例子。
同样,我们可以使用Poly类创建多边形形状。以下语法表示使用Poly类创建实例:
pymunk.Poly(body, vertices, transform = None, radius = 0)
在上一行代码中,body是Body类的实例,代表形状的虚拟空间。vertices参数定义了多边形凸包的顶点。凸包是由Poly类使用顶点自动计算的。剩下的两个参数,transform和radius是可选的。transform是Transform类的对象(参考www.pymunk.org/en/latest/pymunk.html#pymunk.Poly了解更多关于transform的信息),它将变换应用到多边形的每个顶点,而radius参数设置了创建的多边形形状的半径。
你可能会想知道在制作愤怒的小鸟游戏时Poly类的应用是什么。在这个游戏中,我们有两个主要角色,以及由Poly类制作的木结构,包括梁和柱。在制作愤怒的小鸟游戏时会进一步讨论这些内容。
最后,我们还有另一个有用的类,称为Segment类。让我们来探讨如何创建它的实例:
pymunk.Segment(body, point1, point2, radius)
Segment类负责定义两点之间的线段形状:point1和point2。这是一个重要的类,因为它定义了游戏的表面。radius参数定义了从point1到point2绘制的线段的厚度。还可以为这个形状添加一些前面提到的属性,比如mass、density、elasticity和friction。大多数情况下,摩擦用于定义游戏表面的粗糙程度。即使在愤怒的小鸟游戏中,我们也可以使用Segment类创建游戏表面,并将物体与一定程度的摩擦(0—1)关联起来,这定义了表面的精细度和粗糙度水平。数值 0 代表 100%的精细,而 1 代表完全粗糙。
现在我们已经全面掌握了与pymunk模块相关的所有类和属性,我们可以开始编写愤怒的小鸟游戏了。
创建一个角色控制器
如果你还没有玩过愤怒的小鸟,我强烈鼓励你去试一试。在网上搜索愤怒的小鸟并玩上几分钟。在玩游戏时,观察主要角色(小鸟和猪)、它们的动作以及它们与木结构的互动。木结构由不同的梁和柱结构组成,其中不同数量的木结构依次嵌套。
在查看原始游戏后,你可以开始编写自己的愤怒的小鸟游戏。我们之前在 PyCharm 中安装pymunk模块时制作了愤怒的小鸟项目。我们将使用相同的项目文件夹来创建这个游戏。创建一个新的 Python 文件并命名为characters.py。
在这个愤怒的小鸟项目中,我们不会在一个单独的文件中编写整个代码。在编写像愤怒的小鸟这样复杂的游戏时,对于不同的任务,我们创建不同的模块是很重要的。这样做,我们可以在测试游戏时更容易地找到错误。在这个愤怒的小鸟游戏中,我们将创建四个 Python 文件:characters.py、polygon.py、main.py和level.py。
我们刚刚创建的第一个文件将包含主要的游戏角色:小鸟和猪。木梁和柱结构将在下一个文件中创建;也就是polygon.py。但现在,让我们集中在characters.py文件上。
characters.py文件将包含两个类:一个是Bird,另一个是Pig。然后,我们将定义几个属性来控制每个类的运动,也就是物理属性。以下代码表示了characters.py文件的内容:
import pymunk as p #aliasing pymunk as p
from pymunk import Vec2d #for vector manipulation
在导入必要的模块之后,让我们为Bird角色定义一个类(愤怒的小鸟的移动由玩游戏的玩家控制):
class RoundBird():
def __init__(self, distance, angle, x_pos, y_pos, space):
weight = 5
r = 12 #radius
value_of_inertia = p.moment_for_circle(weight, 0, r, (0, 0))
obj_body = p.Body(weight, value_of_inertia)
obj_body.position = x_pos, y_pos
power_value = distance * 53
impulse = power_value * Vec2d(1, 0)
angle = -angle
obj_body.apply_impulse_at_local_point(impulse.rotated(angle))
obj_shape = p.Circle(obj_body, r, (0, 0))
obj_shape.elasticity = 0.95 #bouncing angry bird
obj_shape.friction = 1 #for roughness
obj_shape.collision_type = 0 #for checking collisions later
space.add(obj_body, obj_shape)
#class RoundBird attribute ----
self.body = obj_body
self.shape = obj_shape
在上述代码行中,我们为愤怒的小鸟角色定义了所有的物理和位置属性。我们首先定义构造函数。构造函数的参数如下:
-
两个物体位置之间的
distance,通常通过距离公式计算(www.purplemath.com/modules/distform.htm),并传递给Bird类。 -
angle以度为单位执行Bird角色的移动。 -
x_pos,y_pos表示Bird的位置。 -
space表示Bird被渲染的space对象。
在构造函数中,我们为Bird角色添加了多个物理属性。例如,elasticity= 0.95表示弹跳能力(标准),friction = 1(表面粗糙度水平),power = work done(距离)* time(53)。小鸟的质量(重量)为 20,birdLife类属性表示每当 Bird 角色与地面或其他角色(Pig 或木结构)发生碰撞时减少的数量。
摩擦、弹性和功都不是随机的(我没有随意使用它们)。它们在官方文档页面上有定义。请参考以下网址查看图表:www.pymunk.org/en/latest/pymunk.html#pymunk.Shape。
Bird类的两个重要方法(在上述代码中突出显示)是由pymunk模块定义的内置函数。第一个方法moment_for_circle()计算空心圆的转动惯量(任何物体对其速度变化的抵抗)。传递给函数的参数是物体的质量,即内半径和外半径。观察传递为0的内半径,这意味着愤怒的小鸟(游戏的主要角色)是一个实心圆。如果内半径是0,这意味着这是一个实心圆形物体。外半径定义了愤怒的小鸟的圆形尺寸。同样,观察collision_type = 0属性。这个语句将为 Bird 游戏角色添加整数类型。在使用add_collision_handler(type_a, type_b)检查两个对象之间的碰撞时,我们使用这个碰撞类型值来表示角色的0值是Bird。对于Bird角色,我们的碰撞类型等于0。Pig类将其碰撞类型定义为1。
同样,下一个方法apply_impulse_at_local_point(impulse, point = (0, 0))将对物体施加局部冲量。这将表示当施加力时愤怒的小鸟的动量将发生多大变化。参考study.com/academy/lesson/impulse-definition-equation-calculation-examples.html了解更多关于冲量和动量的知识。
接下来,我们需要为Pig角色定义类。以下代码应该在Bird类之后编写:
class RoundPig():
def __init__(self, x_pos, y_pos, space):
self.life = 20 #life will be decreased after
collision of pig with bird
weight = 5
r = 14 #radius
value_of_inertia = p.moment_for_circle(weight, 0, r, (0, 0))
obj_body = p.Body(weight, value_of_inertia)
#creates virtual space to render shape obj_body.position = x_pos, y_pos
#add circle to obj body
obj_shape = p.Circle(obj_body, r, (0, 0))
obj_shape.elasticity = 0.95
obj_shape.friction = 1
obj_shape.collision_type = 1
space.add(obj_body, obj_shape)
self.body = obj_body
self.shape = obj_shape
上述代码与Bird类类似。与之前一样,我们为Pig角色定义了相同级别的弹性和摩擦。我们为对象添加了惯性和质量效应。对于Pig角色,collision_type被添加为1,这意味着在检查 Pig 和 Bird 之间的碰撞时,我们可以简单地调用add_collision_handler(0, 1),其中0表示 Bird,1表示 Pig。
现在我们已经为愤怒的小鸟游戏创建了两个主要的类,即RoundBird和RoundPig,在characters.py文件中,我们将创建另一个游戏角色,即木结构(横梁和柱子)。
创建多边形类
对于每个游戏实体,我们都创建了单独的类,即 Bird 和 Pig。由于我们最终的游戏实体是木结构(玩家用弹弓射击的目标),我们将创建一个不同的 Python 文件,并为该实体创建一个类。但在此之前,让我们先了解有关精灵表的一个重要概念。
在 Python 游戏开发中使用的图像通常称为精灵,它们是静态图像,基于用户的操作(例如在键盘上点击箭头键时移动蛇)进行一些操作(矢量移动)。在前几章中(第十二章,了解角色动画、碰撞和移动,和第十三章,使用 Pygame 编写俄罗斯方块游戏),我们使用了精灵(单个图像),但没有使用精灵表(包含多个静态图像的表)。以下是一个精灵表的示例,特定于我们的愤怒的小鸟游戏:

这些图像文件通常不包含游戏角色的单个图像。正如您所看到的,它们通常包含大量不同的游戏角色。但大多数情况下,我们只需要整个精灵表中的单个图像。因此,问题是,我们如何从这样的精灵表中提取单个图像?我们使用Pygame模块的Rect类来实现。您还记得 Pygame 模块中的Rect类(第十一章,使用 Pygame 创建 Outdo Turtle-蛇游戏 UI)吗?该类基于左、上、宽度和高度维度创建一个矩形对象。为了从上述精灵表中提取图像,我们将在其中一个精灵周围绘制一个矩形,如下所示:

这种映射是通过Rect类的帮助完成的。Rect类将创建一个具有四个屏幕点(左、上、宽度和高度)尺寸的矩形。通过更改Rect对象的任何四个维度,我们可以提取精灵表的部分或子表面。
现在,让我们通过创建一个木结构来看看它的作用。首先,从以下 GitHub 链接下载精灵资源:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter15/res。您将看到各种图像,以及代码资源。res文件夹内将有两个文件夹:一个用于照片,另一个用于声音。您必须复制整个文件夹并将其粘贴到 PyCharm 编辑器中愤怒的小鸟项目文件夹中。
导入资源后,我建议您打开wood.png文件。该文件包含不同的木结构。在创建多边形时,我们必须使用Rect类裁剪其中一个图像。
在同一个愤怒的小鸟项目中,创建另一个名为polygon.py的 Python 文件。我们将从导入必要的模块开始:
import pymunk as pym
from pymunk import Vec2d
import Pygame as pg
import math
现在,让我们创建Polygon类:
class Polygon():
def __init__(self, position, length, height, space, mass=5.0):
value_moment = 1000
body_obj = pym.Body(mass, value_moment)
body_obj.position = Vec2d(position)
shape_obj = pym.Poly.create_box(body_obj, (length, height))
shape_obj.color = (0, 0, 255)
shape_obj.friction = 0.5
shape_obj.collision_type = 2 #adding to check collision later
space.add(body_obj, shape_obj)
self.body = body_obj
self.shape = shape_obj
wood_photo =
pg.image.load("../res/photos/wood.png").convert_alpha()
wood2_photo =
pg.image.load("../res/photos/wood2.png").convert_alpha()
rect_wood = pg.Rect(251, 357, 86, 22)
self.beam_image = wood_photo.subsurface(rect_wood).copy()
rect_wood2 = pg.Rect(16, 252, 22, 84)
self.column_image = wood2_photo.subsurface(rect_wood2).copy()
我们为Polygon类定义的属性与我们为Bird和Pig类所做的非常相似:我们初始化了摩擦力,并添加了collision_type,以便用整数2引用多边形形状。构造函数接受一个参数,即position,告诉我们要渲染的多边形的位置,多边形的长度和高度,将渲染多边形的space对象,以及多边形形状的mass。
在上述代码中唯一的新颖之处是代码的高亮部分。我们使用 Pygame 的load方法将wood.png和wood2.png图像加载到 Python 项目中。convert_alpha()方法充当优化器,并将创建一个适合快速 blitting 的新图像表面。Rect类需要四个维度来创建一个矩形表面(参见第十一章,使用 Pygame 制作贪吃蛇游戏 UI)。提供的尺寸值并非随机给出,而是代表我们需要提取的精灵表面的子表面的值。例如,self.beam_image = wood.subsurface(rect).copy()命令将从wood.png文件中提取水平横梁图像(由红色矩形包围的木块),如下所示;

现在我们已经提取了水平和垂直的木质图像(横梁和柱子),我们可以开始绘制包含它们的多边形。然而,出现了一个问题。尽管我们一直在使用 Pygame 和pymunk,但它们的坐标系统并不相同:pymunk使用的坐标系统的原点在左下角,而 Pygame 则使用的坐标系统的原点在左上角。因此,我们将编写一个函数,将pymunk坐标系统转换为兼容的 Pygame 坐标系统:
def convert_to_pygame(self, pos):
"""Function that will transform pymunk coordinates to
Pygame coordinates"""
return int(pos.x), int(-pos.y+610)
上述函数很重要,因为游戏表面将由Pygame模块制作。因此,我们必须跟踪横梁和柱子必须呈现的位置。现在,让我们开始在表面上绘制polygon:
def draw_poly(self, element, screen):
"""Draw beams and columns"""
polygon = self.shape
if element == 'beams':
pos = polygon.body.position
pos = Vec2d(self.convert_to_pygame(pos))
angle_degrees = math.degrees(polygon.body.angle)
rotated_beam = pg.transform.rotate(self.beam_image,
angle_degrees)
offset = Vec2d(rotated_beam.get_size()) / 2.
pos = pos - offset
final_pos = pos
screen.blit(rotated_beam, (final_pos.x, final_pos.y))
上述函数将用于在屏幕上放置一个横梁,其中一个对象作为参数传递给它。函数的第一个参数是element,告诉函数要绘制哪个多边形:是横梁还是柱子?我们将在下面的代码中添加一些逻辑来绘制柱子,但现在让我们观察到目前为止我们已经写的内容。代码首先获取shape对象。然后,我们检查元素是否为beam。如果是beam,那么我们获取图像的位置并将其转换为Vec2d坐标位置。代码的高亮部分(获取旋转横梁图像的角度)将确保横梁图像在红色矩形(虚拟)区域内,如下所示:

只需从上述代码中删除高亮行并观察结果。您会发现由于Vec2d坐标系统的偏移,横梁不会完全对齐。同样,让我们添加一些代码,以便我们可以将柱子绘制到屏幕上:
if element == 'columns':
pos = polygon.body.position
pos = Vec2d(self.convert_to_pygame(pos))
angle_degrees = math.degrees(polygon.body.angle) + 180
rotated_column = pg.transform.rotate(self.column_image,
angle_degrees)
offset = Vec2d(rotated_column.get_size()) / 2.
pos = pos - offset
final_pos = pos
screen.blit(rotated_column, (final_pos.x, final_pos.y))
在上述代码中,前几行将pymunk坐标转换为 Pygame 坐标。由于柱子应该在 Pygame 表面上呈现,因此这种转换是必要的。同样,在获取位置坐标之后,我们取一个坐标角度,并确保向其添加 180 或 0,以使其保持原始图像而不旋转。获取图像后,我们对其进行变换,并创建一个新图像作为rotated_column图像。请记住,如果旋转角度不是 90 的倍数,图像将会变形。在上一行代码中,如果不从旋转图像中移除offset,则图像将向下移动表面,如下面的截图所示:

在上述截图中,红线代表表面。因此,如果不从柱子的体位置中移除偏移量,柱子将显示在表面下方。
现在我们已经完成了Polygon类,该类在从主类中调用draw_poly()函数时将渲染横梁或柱子,现在是时候制作我们的主类了,这是所有类的指导者。这个类将负责创建所有类的实例,并调用不同类中定义的方法来将游戏对象渲染到 Pygame 游戏表面中。
探索 Python 的物理模拟
首先,让我们从回顾我们迄今为止所做的工作开始。我们首先定义了两个主要的游戏实体:Bird和Pig。为了模拟真实世界的物理现象,为这些角色定义了所有主要的物理属性,如质量、惯性和摩擦力。在创建了这两个主要的游戏角色之后,我们又创建了另一个 Python 文件,以便我们可以创建Polygon类。这个类是为了在游戏中渲染木结构,借助横梁和柱子。现在,我们将创建另一个名为main.py的 Python 文件。这将是游戏的主控制器。
使用以下代码在main.py文件中声明基本物理。我们将从导入一些必要的模块开始:
import os
import sys
import math
import time
import Pygame
import pymunk
from characters import RoundBird #our characters.py file have Bird class
在导入了必要的模块之后,我们需要从之前添加的精灵中裁剪一些子表面。显然,我们不希望从精灵表中获取所有内容,因此我们将只提取其中的部分内容来创建游戏角色。然而,由于我们的主要角色,愤怒的小鸟,只有一个图像,并且不在精灵表中,我们不需要为愤怒的小鸟和弹弓裁剪图像。然而,对于Pig角色,我们必须创建一个Rect对象,因为Pig图像在精灵表中是捆绑在一起的。因此,我们将使用以下代码加载图像:
Pygame.init()
screen = Pygame.display.set_mode((1200, 650))
redbird = Pygame.image.load(
"../res/photos/red-bird3.png").convert_alpha()
background_image = Pygame.image.load(
"../res/photos/background3.png").convert_alpha()
sling_image = Pygame.image.load(
"../res/photos/sling-3.png").convert_alpha()
full_sprite = Pygame.image.load(
"../res/photos/full-sprite.png").convert_alpha()
rect_screen = Pygame.Rect(181, 1050, 50, 50)
cropped_image = full_sprite.subsurface(rect_screen).copy()
pig_image = Pygame.transform.scale(cropped_image, (30, 30))
#(30, 30) resulting height and width of pig
在前面的代码中,我们首先使用 Pygame 模块定义了一个游戏屏幕。之后,我们加载了所有存在的单个图像的图像,而不是精灵表,比如red-bird3.png、background3.png和sling-3.png。正如我们之前提到的,猪的图像是full-sprite.png中一组图像的一部分。由于我们只需要一张猪的图像,我们将执行类似于提取横梁和柱子时进行的过程。我们将创建一个具有猪形状确切尺寸的Rect对象,然后使用它从精灵表中提取猪的图像。然后,我们将裁剪该图像并将其存储为一个裁剪对象,最终将其转换为高度和宽度分别为30、30的对象。
现在我们已经提取了游戏对象所需的图像,让我们开始通过声明每个对象的物理变量和位置变量来认真对待这项工作:
running = True #base physics code space_obj = pymunk.Space()
space_obj.gravity = (0.0, -700.0)
正如我们所知,愤怒的小鸟游戏是通过使用鼠标拉伸弹弓进行弹射动作来进行的。因此,我们必须声明一些变量来处理这些弹弓动作:
mouse_distance = 0 #distance after stretch rope_length = 90 angle = 0 mouse_x_pos = 0 mouse_y_pos = 0 mouse_pressed = False time_of_release = 0 initial_x_sling, initial_y_sling = 135, 450 #sling position at rest (not stretched) next_x_sling, next_y_sling = 160, 450
在前面的代码中,我们已经定义了不同的变量,以便在弹弓动作之前和之后跟踪鼠标的位置。我们将在之后声明sling_action()函数,该函数将操作这些值。现在,让我们创建一个列表,用于跟踪在空间中显示的猪、鸟、横梁和柱子的数量:
total_pig = []
total_birds = []
beams = []
columns = []
#color code WHITE = (255, 255, 255)
RED = (255, 0, 0)
BLACK = (0, 0, 0)
BLUE = (0, 0, 255)
现在我们已经为愤怒的小鸟游戏定义了所有必要的变量(如果需要,我们将稍后添加更多变量),现在是时候为屏幕创建一个表面了。这个表面不是一个背景表面;相反,它是所有结构所在的一些地面。愤怒的小鸟也会从这个表面上弹起,因此我们必须为这个地面添加一些物理属性,如下所示:
# Static floor static_floor_body = pymunk.Body(body_type=pymunk.Body.STATIC)
static_lines_first = [pymunk.Segment(static_floor_body, (0.0, 060.0), (1200.0, 060.0), 0.0)]
static_lines_second = [pymunk.Segment(static_floor_body, (1200.0, 060.0), (1200.0, 800.0), 0.0)]
#lets add elasticity and friction to surface for eachLine in static_lines_first:
eachLine.elasticity = 0.95
eachLine.friction = 1
eachLine.collision_type = 3
for eachLine in static_lines_second:
eachLine.elasticity = 0.95
eachLine.friction = 1
eachLine.collision_type = 3 space_obj.add(static_lines_first)
前面的代码行将创建一些静态地面。在实例化静态物体时,我们可以通过添加pymunk.Body.STATIC常量来明确设置body-type为STATIC。在定义静态物体之后,我们必须使用Segment类来创建一条线段,连接一个点和另一个点(回想一下探索 pymunk Space 类部分中的Segment类)。对于每个线段,我们已经添加了elasticity来支持弹跳属性,friction来表示粗糙度,以及collision_type来检查其他游戏对象是否与地面表面发生碰撞,这将在检查碰撞部分中稍后进行检查。在创建这些静态表面之后,我们将它们添加到Space对象中,这将把它们渲染到屏幕上。
在定义静态表面之后,我们需要定义弹弓动作,即玩家在拉动弹弓绳索时会发生什么。我们将在下一节中实现这一点。
实施弹弓动作
在这一节中,我们将实施弹弓动作。玩家将通过弹弓动作与游戏角色进行交互。但在实施弹弓动作之前,我们必须注意一些事情:玩家可以拉动弹弓绳索多远?冲量的角度是多少(玩家释放绳索后的运动轨迹)?鼠标动作点与绳索当前伸展点之间的距离是多少?所有这些问题都必须通过声明函数来解决。首先,我们需要将 pymunk 坐标转换为 Pygame 坐标,以便我们可以正确地将游戏对象与屏幕对齐(这种转换的原因在创建多边形类部分中讨论过)。
以下函数将把pymunk坐标转换为 Pygame 坐标:
def convert_to_pygame(pos):
""" function that performs conversion of pymunk coordinates to
Pygame coordinates"""
return int(pos.x), int(-pos.y+600)
尽管 pymunk 的x坐标与 Pygame 的x坐标相同,但由于 pymunk 的原点在左下角,我们必须将其更改为左上角。同样,让我们定义另一个函数,即vector,它将把传递的点转换为向量。以下代码表示vector函数的实现:
def vector(a, b):
#return vector from points
p = b[0] - a[0]
q = b[1] - a[1]
return (p, q)
参考第九章,数据模型实现,了解更多关于如何使用位置向量创建向量的信息。这里,参数a和b表示从参考点转换为向量的点。现在我们已经创建了一个向量,让我们定义一个函数,它将返回两点之间的距离:
def distance(x0, y0, x1, y1):
"""function to calculate the distance between two points"""
dx = x1 - x0
dy = y1 - y0
dist = ((dx ** 2) + (dy ** 2)) ** 0.5
return dist
前面的代码将计算两点之间的距离公式,即(x0, y0)和(x1, y1),使用sqrt((x1 - x0) + (y0 - yo))距离公式,其中sqrt代表平方根 (math.sqrt(4) = 2)。**运算符代表幂。例如,dx ** 2等同于(dx)²。
现在我们已经计算出距离,我们需要学习如何计算单位向量。单位向量是具有大小为 1 的向量。我们并不真正关心大小,但单位向量的重要性在于它告诉我们向量的方向。一旦我们有了单位向量,我们就可以通过任何因子放大它,以获得特定方向的新向量。在创建弹弓动作时,了解单位向量的重要性很重要,因为这将为我们提供有关弹弓伸展方向的信息。要找到与向量相同方向的单位向量,我们必须将它除以它的大小。使用数学推导,让我们构建一个函数并创建一个单位向量:
def unit_vector(v):
""" returns the unit vector of a point v = (a, b) """
mag = ((v[0]**2)+(v[1]**2))**0.5
if mag == 0:
mag = 0.000000000000001
unit_p = v[0] / mag #formula to calculate unit vector: vector[i]/magnitude
unit_q = v[1] / mag
return (unit_p, unit_q)
在前面的代码中,h 的值由sqrt(a² + b²)大小公式确定。要找到单位向量,向量的每个分量(v[0], v[1])都要除以大小(mag)。
现在,我们已经声明了不同的函数来定义弹弓动作的位置、大小和方向,我们可以开始定义执行弹弓动作的方法。下图表示了弹弓,它有两端,但没有绳子连接:

在这里,我们的主要任务将是将小鸟(主角)添加到这个弹弓上,并为其定义位置。让我们从在sling_action中定义一些全局变量开始:
def sling_action():
"""will Set up sling action according to player input events"""
global mouse_distance
global rope_length
global angle
global mouse_x_pos
global mouse_y_pos
在上一行代码中,我们声明了一些全局变量。然而,这些属性在探索 Python 物理模拟部分的开头被初始化为一些初始值。这意味着我们将不得不进行一些操作来更新这些变量的值。mouse_distance变量将包含从弹弓静止位置到玩家拉伸弹弓绳索的位置的距离值。同样,rope_length表示玩家拉伸弹弓绳索时的绳长。角度表示冲量的角度,它被计算为斜率角度。弹弓绳索的斜率表示玩家拉伸时绳索的陡峭程度。mouse-x-pos和mouse-y-pos表示弹弓绳索被拉伸时鼠标的当前位置。
现在,在sling_action函数中,我们需要解决三件事:
-
将愤怒的小鸟添加到弹弓的绳索上(如下面的截图所示)。
-
使小鸟停留在绳索上,即使弹弓的绳索被拉伸。
-
解决弹弓绳索被完全拉伸的情况。
要了解这些事件是什么,请看下面的图片:

现在,让我们在sling_action函数中解决所有上述的行动:
#add code inside sling_action function """ Fixing bird to the sling rope (Addressing picture 1)""" vec = vector((initial_x_sling, initial_y_sling), (mouse_x_pos, mouse_y_pos))
unit_vec = unit_vector(vec)
uv_1 = unit_vec[0]
uv_2 = unit_vec[1]
mouse_distance = distance(initial_x_sling, initial_y_sling, mouse_x_pos, mouse_y_pos)
#mouse_distance is a distance between sling initials point to the point at which currrent bird is
fix_pos = (uv_1*rope_length+initial_x_sling, uv_2*rope_length+initial_y_sling)
highest_length = 102 #when stretched
上述代码将在弹弓动作中为愤怒的小鸟角色创建一个视图。首先,通过两个坐标点(sling_original,mouse_current)创建了v向量,例如,((2, 3), (4, 5)),其中(2, 3)表示静止位置的弹弓或弹弓的中心点,而(4, 5)表示玩家激活鼠标动作时的位置。我们将从这个向量创建一个单位向量,以了解玩家拉伸的方向。然后,我们将计算mouse_distance,通过调用先前定义的distance()函数来计算。这个距离表示从静止弹弓中心到当前鼠标位置的距离。(mouse_x_pos,mouse_y_pos)的值表示绳索被拉伸后小鸟的最终位置。uv_1和uv_2单位向量将确保小鸟保持在绳索上,这由鼠标的位置表示。例如,如果鼠标指针指向上方,绳索和小鸟将向上拉伸。
类似地,让我们解决第二种情况,即使愤怒的小鸟在绳索被完全拉伸时仍然停留在绳索上。我们将在以下代码中实现它:
#to make bird stay within rope x_redbird = mouse_x_pos - 20 y_redbird = mouse_y_pos - 20 if mouse_distance > rope_length:
pux, puy = fix_pos
pux -= 20
puy -= 20
first_pos = pux, puy
screen.blit(redbird, first_pos)
second_pos = (uv_1*highest_length+initial_x_sling, uv_2*highest_length+initial_y_sling) #current position ==> second_pos
Pygame.draw.line(screen, (255, 0, 0), (next_x_sling, next_y_sling), second_pos, 5)
#front side catapult rope
screen.blit(redbird, first_pos)
Pygame.draw.line(screen, (255, 0, 0), (initial_x_sling, initial_y_sling), second_pos, 5)
#ANOTHER SIDE of catapult
在上述代码中发生了很多事情,但这些操作更容易和更数学化。你必须试着理解逻辑,而不是试图理解语法。让我们深入代码,揭示每行代码背后的原因。我们首先将鼠标位置减少 20 个单位,以确保在拉伸时,鸟仍然停留在绳子的边缘。尝试将这个值改为 40 并观察效果。接下来,我们检查mouse_distance是否大于rope_length,以确保拉伸的距离在限制范围内。我们不希望鼠标距离大于最大绳长。在这种情况下,我们将取鼠标距离并将其减少,直到它小于绳子的最大长度。
之后,我们将在绳子的末端blit红色小鸟(愤怒的小鸟图像)。同样,我们也必须blit绳子。在前面的图片中,观察绳子拉动的地方,绳子变成了红色。如果我们从静态弹弓的中心blit绳子到最大可能的长度,就会产生这种红色。观察代码的粗体部分;我们已经画了一条代表绳子的线,颜色代码为(255, 0, 0),也就是红色。这有两个语句:一个在每一边。因此,我们已经实现了用户将绳子拉伸到其最大定义长度的条件。
现在,我们必须解决第三种情况,也就是当玩家将绳子拉到最大长度时会发生什么?在前一行代码中,我们检查了if mouse_distance > rope_length,因此如果玩家拉伸小于rope_length,它应该在代码的else部分中解决,如下所示:
else:
#when rope is not fully stretched
mouse_distance += 10
third_pos = (uv_1*mouse_distance+initial_x_sling,
uv_2*mouse_distance+initial_y_sling)
Pygame.draw.line(screen, (0, 0, 0), (next_x_sling, next_y_sling),
third_pos, 5)
screen.blit(redbird, (x_redbird, y_redbird))
Pygame.draw.line(screen, (0, 0, 0), (initial_x_sling,
initial_y_sling), third_pos, 5)
与前面的代码类似,我们使距离不小于 10,这意味着当用户稍微拉伸绳子时,它的mouse_distance将等于或大于 10。然后,我们创建third_pos来定义渲染绳子和愤怒的小鸟的位置。uv_1和uv_2是指示拉伸方向的单位向量。在获得位置后,我们blit愤怒的小鸟,然后画一条线表示绳子。这将是黑色的,并且将在前面和后面完成。
现在,我们已经为所有情况定义了场景,让我们添加一行代码来计算冲动的角度。只要绳子有拉伸,就会产生这个角度。tan(冲动角度)等于拉伸绳子的斜率。斜率被定义为上升除以下降或(dy/dx),其中dy是y的变化,dx是x的变化。因此,冲动角度可以计算为tan^(-1)(dy / dx)。要了解有关此公式的起源和应用的更多信息,请查看www.intmath.com/plane-analytic-geometry/1b-gradient-slope-line.php。
让我们使用这个公式来计算冲动的角度,如下所示:
#this is angle of impulse (angle at which bird is projected)
change_in_y = mouse_y_pos - initial_y_sling
change_in_x = mouse_x_pos - initial_x_sling
if change_in_x == 0:
#if no change in x, we make fall within the area of sling
dx = 0.00000000000001 angle = math.atan((float(change_in_y))/change_in_x) #tan-1(dy / dx)
冲动的前角度将是确定愤怒的小鸟在弹弓动作后路径的必要条件。
最后,我们已经完成了弹弓动作。现在,让我们跳到下一节,解决两个游戏对象之间的碰撞。
解决碰撞
回顾一下,回答以下问题:我们如何知道两个游戏对象何时发生了碰撞?你有答案吗?每当两个对象在坐标系内的相同位置时,它们被认为发生了碰撞。然而,在 pymunk 的情况下,我们不需要检查是否发生了碰撞。相反,一个方法调用将为我们检查这一点。例如,调用space.add_collision_handler(0, 1)将添加一个碰撞处理程序,以检查鸟和猪角色之间是否发生了碰撞。这里,0整数代表了Bird类内部定义的collision_type。Pig类定义的collision_type是1。因此,这些collision_type必须是唯一的,以便每个游戏实体可以唯一地识别它们。
尽管我们有一个更简单的方法来添加一个处理程序来检查碰撞,但程序仍然要求细节;也就是说,当两个游戏对象发生碰撞时会发生什么?必须执行什么操作?这是通过使用post_solve来解决的。我们将明确告诉碰撞处理程序,如果X和Y之间发生碰撞,那么应该调用特定的方法;例如,space.add_collision_handler(0, 1).post_solve = perform_some_action。
每当游戏对象之间发生碰撞时,让我们定义每个动作。我们将首先定义一个必须在 Bird 和 Pig 之间发生碰撞时执行的动作。让我们编写一个执行此操作的函数:
def post_solve_bird_pig(arbiter, space_obj, _):
"""Action to perform after collision between bird and pig""" object1, object2 = arbiter.shapes #Arbiter class obj
bird_body = object1.body
pig_body = object2.body
bird_position = convert_to_pygame(bird_body.position)
pig_position = convert_to_pygame(pig_body.position)
radius = 30
Pygame.draw.circle(screen, (255, 0, 0), bird_position, radius, 4)
#screen => Pygame surface Pygame.draw.circle(screen, RED, pig_position, radius, 4)
#removal of pig
pigs_to_remove = []
for pig in total_pig:
if pig_body == pig.body:
pig.life -= 20 #decrease life
pigs_to_remove.append(pig)
for eachPig in pigs_to_remove:
space_obj.remove(eachPig.shape, eachPig.shape.body)
total_pig.remove(eachPig)
在上述代码中,该方法接受一个Arbiter类的对象:arbiter。arbiter对象将封装所有碰撞的对象/形状,甚至存储所有碰撞对象的位置。由于游戏对象被绘制到 Pygame 屏幕中,我们需要知道它们在 Pygame 坐标系中的确切位置。因此,需要将 pymunk 坐标转换为 Pygame 坐标。类似地,我们为post_solve函数定义的过程是在 Pig 和 Bird 之间发生碰撞后立即执行的动作。该动作将减少猪的生命,然后最终将其从空间中移除。space.remove()语句将从屏幕中移除游戏对象。
同样,让我们定义另一个必须在 Bird 和木结构之间发生碰撞后执行的动作。与前面的代码类似,在碰撞后,木梁和柱必须从空间或屏幕中移除。以下函数将处理此类动作:
def post_solve_bird_wood(arbiter, space_obj, _):
"""Action to perform after collision between bird and wood structure"""
#removing polygon
removed_poly = []
if arbiter.total_impulse.length > 1100:
object1, object2 = arbiter.shapes
for Each_column in columns:
if object2 == Each_column.shape:
removed_poly.append(Each_column)
for Each_beam in beams:
if object2 == Each_beam.shape:
removed_poly.append(Each_beam)
for Each_poly in removed_poly:
if Each_poly in columns:
columns.remove(Each_poly)
if Each_poly in beams:
beams.remove(Each_poly)
space_obj.remove(object2, object2.body)
#you can also remove bird if you want
与以前类似,arbiter对象将保存有关碰撞形状和位置的信息。在这里,total_impulse属性将返回应用于解决碰撞的冲量。要了解有关Arbiter类的更多信息,请转到www.pymunk.org/en/latest/pymunk.html。现在,在获取碰撞的影响后,我们将检查arbiter是否具有beam或column的形状,因为 arbiter 对象将包含碰撞对象的列表。在循环遍历arbiter对象内存储的beam和column之后,我们将其从空间中移除。
最后,我们将处理最后的碰撞——当Pig与木结构发生碰撞时必须执行的动作。让我们添加一个实现它的方法:
def post_solve_pig_wood(arbiter, space_obj, _):
"""Action to perform after collision between pig and wood"""
removed_pigs = []
if arbiter.total_impulse.length > 700:
pig_shape, wood_shape = arbiter.shapes
for pig in total_pig:
if pig_shape == pig.shape:
pig.life -= 20 if pig.life <= 0: #when life is 0
removed_pigs.append(pig)
for Each_pig in removed_pigs:
space_obj.remove(Each_pig.shape, Each_pig.shape.body)
total_pig.remove(Each_pig)
与前两种方法类似,此函数还将检查arbiter对象的内容,该对象负责封装有关碰撞对象的形状和碰撞发生位置的所有信息。使用Arbiter类对象的内容,我们已经检查了冲击后的长度,然后要么删除了Pig角色,要么减少了其生命单位。
下一步是添加碰撞处理程序。由于我们已经声明了所有必须在两个对象之间发生碰撞后执行的post_solve动作,让我们使用post_solve将其添加到碰撞处理程序中,如下所示:
# bird and pigs space.add_collision_handler(0, 1).post_solve=post_solve_bird_pig
# bird and wood space.add_collision_handler(0, 2).post_solve=post_solve_bird_wood
# pig and wood space.add_collision_handler(1, 2).post_solve=post_solve_pig_wood
在添加碰撞处理程序之后,我们需要添加一个事件处理程序,处理玩游戏的玩家的事件。但在此之前,更容易处理级别。我所说的级别实际上是使用横梁和柱子创建结构。尽管我们从精灵表中提取了横梁和柱子,但我们从未用它们创建过结构。让我们使用横梁和柱子创建一些木结构。
创建级别
我们不仅创建了三个主要的游戏实体,还创建了一个碰撞处理程序和sling_action函数。但我们还没有完成。我们必须使用beam和column游戏对象的帮助将木结构添加到空间中。beam是一个水平的木制矩形结构,而column是一个垂直的木制矩形结构。在这一部分,我们将创建另一个类,并通过定义不同的木结构来为游戏定义一个级别。您将需要创建一个新的 Python 文件并将其命名为level.py。在该文件中,开始编写以下代码来定义木结构:
from characters import RoundPig #HAVE TO ADD PIG IN STRUCTURE
from polygon import Polygon #POLYGON
在导入必要的模块之后,我们可以开始创建一个Level类:
class Level():
#each level will be construct by beam, column, pig
#will create wooden structure
def __init__(self, pigs_no, columns_no, beams_no, obj_space):
self.pigs = pigs_no #pig number
self.columns = columns_no
self.beams = beams_no
self.space = obj_space
self.number = 0 #to create build number
self.total_number_of_birds = 4 #total number of initial bird
在上述代码中,我们创建了一个Level类,它有一个构造函数,接受pigs、columns、beams和space作为参数。这些参数对你来说应该不陌生。所有这些都代表不同类的对象。同样地,我们使用构造函数初始化了类变量。number属性的使用将在一分钟内讨论。在使用之前,描述它的用法是没有意义的。还有一个带有total_number_of_birds签名的属性,它表示玩家在弹弓上投射的愤怒小鸟的数量。现在,让我们为游戏建立第一个级别:
def build_0(self):
pig_no_1 = RoundPig(980, 100, self.space)
pig_no_2 = RoundPig(985, 182, self.space)
self.pigs.append(pig_no_1)
self.pigs.append(pig_no_2)
pos = (950, 80)
self.columns.append(Polygon(pos, 20, 85, self.space))
pos = (1010, 80)
self.columns.append(Polygon(pos, 20, 85, self.space))
pos = (980, 150)
self.beams.append(Polygon(pos, 85, 20, self.space))
pos = (950, 200)
self.columns.append(Polygon(pos, 20, 85, self.space))
pos = (1010, 200)
self.columns.append(Polygon(pos, 20, 85, self.space))
pos = (980, 240)
self.beams.append(Polygon(pos, 85, 20, self.space))
self.total_number_of_birds = 4
在上述代码中,我们以窗口方式排列了beam和column(一个层叠在另一个上面)。我们还在结构内部添加了两只猪。要创建这样的横梁和柱子,我们必须创建Polygon类的实例(我们在创建多边形类部分中创建了它)。虽然函数中的代码看起来很长,但这里并没有创造新的逻辑。我们只是实例化了不同的横梁和柱子,并提供了一个渲染位置。pos的值是一个元组,表示多边形应该放置在空间中的位置。
现在,让我们在同一个level.py文件中创建另一个方法,并将这个级别命名为0。记住,这是Level类的方法:
def load_level(self):
try:
level_name = "build_"+str(self.number)
getattr(self, level_name)()
except AttributeError:
self.number = 0
level_name = "build_"+str(self.number)
getattr(self, level_name)()
最后,这里是我们在创建类的构造函数时初始化的number属性的应用。这个load_level()方法将执行字符串连接来构建代表level_levelNumber的函数名。例如,上述代码的高亮部分将产生build_name = "build_0" [最初 number = 0]和getattr(self, "build_0)(),这等同于build_0()。
get_attr(object, p)等同于object.p。如果你觉得可能会出现属性错误异常,这个方法就很重要。例如,get_attr(object, p, 10)会在出现异常时返回 10。因此,这个方法可以用来提供一个默认值。当给定名称的属性在对象中不存在时,就会出现属性错误。
由于这个load_level()方法应该从一个文件中被显式调用,我们将在main.py文件中执行这个操作。打开你的main.py文件,然后继续我们离开的地方的代码。写下以下代码来调用最近创建的load_level()方法:
#write it in main.py file
from level import Level
level = Level(total_pig, columns, beams, space)
level.number = 0 level.load_level()
在上一行代码中,我们从level模块中导入Level类。我们通过传递pig、columns、beams和space的列表来创建Level类的一个实例。同样地,我们将number的初始值设为0,这意味着load_level()方法应该调用build_0方法的开始。你可以通过添加更难的级别来增加number的值。
既然我们已经将级别加载到我们的main.py文件中,现在是时候处理用户操作事件了。我们将在下一节中使用 Pygame 来处理鼠标事件。
处理用户事件
在这一节中,我们将处理用户事件。这对你来说不是新鲜事。自从第五章 通过构建蛇游戏学习关于 Curses以来,我们一直在各种情况下处理用户操作事件。在构建蛇游戏时,我们处理了键盘事件,而对于 Flappy Bird,我们处理了鼠标点击事件。在处理这些事件时,我们发现使用pygame模块是最简单和最通用的方法;我们只需要一行代码来监听传入的操作并相应地处理它们。
但是在愤怒的小鸟的情况下,处理鼠标动作有点棘手。当我们将鼠标动作超出空间范围并尝试执行弹弓动作时,问题就会出现。这是不允许的,因此我们必须检查鼠标动作是否应与弹弓动作相关联(先前创建的拉动弹弓绳子的函数)。因此,让我们学习如何通过编写以下代码来处理用户的输入事件:
while running:
# handle Input events
for eachEvent in Pygame.event.get():
if eachEvent.type == Pygame.QUIT:
running = False
elif eachEvent.type == Pygame.KEYDOWN and event.key ==
Pygame.K_ESCAPE:
running = False
现在我们已经检查了QUIT动作事件,我们可以开始处理鼠标事件(当用户使用鼠标从弹弓中发射愤怒的小鸟时)。
if (Pygame.mouse.get_pressed()[0] and mouse_x_pos > 100 and
mouse_x_pos < 250 and mouse_y_pos > 370 and mouse_y_pos < 550):
mouse_pressed = True if (event.type == Pygame.MOUSEBUTTONUP and
event.button == 1 and mouse_pressed):
# Release new bird
mouse_pressed = False
if level.number_of_birds > 0:
level.number_of_birds -= 1
time_of_release = time.time()*1000
x_initial = 154
y_initial = 156
在上述代码中,我们首先检查鼠标动作是否在范围内。我们检查鼠标点击是否在空间范围内(mouse_x_pos > 100 and mouse_x_pos < 250 and mouse_y_pos > 370 and mouse_y_pos < 550)。如果是,我们将mouse_pressed变量赋值为True。
接下来,我们将执行释放小鸟的动作。释放每只鸟后,我们检查是否还有其他鸟。如果有,我们减少一只鸟的数量,并将x-initial, y-initial的值分别赋为 154, 156。这些值是弹弓静止时的中心坐标。现在,当弹弓被拉伸时,将会有一个新值,我们将称之为mouse-x-pos,mouse-y-pos。请记住,我们不必计算从(mouse_x_pos, mouse_y_pos)到(x-initial, y-initial)的距离,因为我们在创建sling_action函数时已经这样做了。因此,我们将使用我们在那里计算的mouse_distance来执行释放小鸟的动作:
#add code after x-initial and y-initial declaration
if mouse_distance > rope_length:
mouse_distance = rope_length
if mouse_x_pos < initial_x_sling+5:
bird = RoundBird(mouse_distance, angle, x_initial, y_initial,
space_obj)
total_birds.append(bird)
else:
bird = RoundBird(-mouse_distance, angle, x_initial, y_initial,
space_obj)
total_birds.append(bird)
if level.number_of_birds == 0:
game_finish_time = time.time()
在上述代码中,我们正在将附加到绳子的当前Bird对象添加到鸟列表中。这个列表将为我们提供有关当前鸟与弹弓中心的距离、冲量角度和space对象的信息。现在我们已经处理了玩家的输入动作,让我们使用以下代码将每个对象blit到空间中:
mouse_x_pos, mouse_y_pos = Pygame.mouse.get_pos()
# Blit the background image screen.fill((130, 200, 100))
screen.blit(background_image, (0, -50))
# Blitting the first part of sling image rect = Pygame.Rect(50, 0, 70, 220)
screen.blit(sling_image, (138, 420), rect)
# Blit the remaining number of angry bird if level.total_number_of_birds > 0:
for i in range(level.total_number_of_birds-1):
x = 100 - (i*35)
screen.blit(redbird, (x, 508))
在上述代码中,我们得到了当前鼠标位置(鼠标动作在空间中的位置)。然后,我们使用之前加载的背景图像绘制了背景。同样,我们将弹弓图像blit到屏幕上。现在,我们必须blit等待排队放入弹弓的愤怒小鸟,如下截图所示:

由于total_number_of_birds是在Level类中定义的属性,我们必须通过创建一个实例来使用它。除非鸟的数量大于 0,我们才创建一个表示鸟数量的列表。在for循环代码中,我们必须减少鸟的数量 1,因为一只鸟将被放入弹弓。在获取实际剩余鸟的数量后,我们必须获取将这些鸟渲染到空间中的位置。尽管y位置(高度)是恒定的,即 508 个单位,但x位置是通过提供每个鸟之间的空间来计算的,单位为i*35,其中i表示for循环创建的可迭代对象。例如,对于第 2 只鸟,空间中的位置将是(2*35, 508)。
现在,我们将调用弹弓动作。当鼠标在范围内按下并且小鸟在空间中具有一定的冲量角度时,我们必须使用以下代码调用sling_action方法:
# Draw sling action checking user input if mouse_pressed and level.total_number_of_birds > 0:
sling_action()
else: #blit bird when there is no stretch of sling
if time.time()*1000 - time_of_release > 300 and
level.number_of_birds > 0:
screen.blit(redbird, (130, 426))
如果我们有mouse_pressed并且鸟的数量大于 0,我们执行弹弓动作;否则,我们只在位置(130,426)上进行blit。在代码的else部分,我们不执行弹弓动作。确定是否必须执行弹弓动作的方法是观察鼠标是否已经按下(释放)以及释放后的time_of_release。如果当前时间有显著差异,我们不执行弹弓动作。如果有显著差异,那意味着鸟还没有被释放。为了释放鸟,当前时间必须等于time_of_release。这是当我们在释放之前在弹弓中进行blit redbird 的情况。
执行sling_action后,我们可以使用以下代码跟踪必须从范围内移除的鸟和猪的数量:
removed_bird_after_sling = []
removed_pigs_after_sling = [] # Draw total_birds for bird in total_birds:
if bird.shape.body.position.y < 0:
removed_bird_after_sling.append(bird)
pos = convert_to_pygame(bird.shape.body.position)
x_pos, y_pos = pos
x_pos -= 22 #Pygame compatible
y_pos -= 20
screen.blit(redbird, (x_pos, y_pos)) #blit bird
Pygame.draw.circle(screen, BLUE,
pos, int(bird.shape.radius), 2) #creates blue circle
at the edge of bird
在代码的突出部分,我们检查鸟是否撞到了地面。如果是,那意味着我们必须将鸟添加到removed_bird_after_sling列表中。类似地,我们获取鸟角色的 Pygame 坐标并在(x_pos,y_pos)位置上进行blit。撞击后,鸟周围会出现一个蓝色圆圈。
类似地,我们必须在撞击后移除鸟和猪。编写以下代码来实现这一点:
# Remove total_birds and total_pig for bird in removed_bird_after_sling:
space_obj.remove(bird.shape, bird.shape.body)
total_birds.remove(bird)
for pig in removed_pigs_after_sling:
space_obj.remove(pig.shape, pig.shape.body)
total_pig.remove(pig)
类似地,让我们将猪绘制到空间中:
# Draw total_pig for Each_pig in total_pig:
pig = Each_pig.shape
if pig.body.position.y < 0: #when pig hits ground or fall to the ground
removed_pigs_after_sling.append(pig)
pos = convert_to_pygame(pig.body.position) #pos is a tuple
x_pos, y_pos = pos
angle_degrees = math.degrees(pig.body.angle)
pig_rotated_img = Pygame.transform.rotate(pig_image, angle_degrees)
#small random rotation within wooden frame
width,height = pig_rotated_img.get_size()
x_pos -= width*0.5
y_pos -= height*0.5
screen.blit(pig_rotated_img, (x_pos, y_pos))
Pygame.draw.circle(screen, BLUE, pos, int(pig.radius), 2)
猪撞到地面后,我们必须将其添加到removed_pigs_after_sling列表中。我们使用 Pygame 坐标获取身体的位置。类似地,我们对pig对象执行变换。旋转变换在 0.5 单位内。这种自动变换将使猪在空间中平稳移动而不保持静止。如果将旋转值更改为超过 2 个单位,猪的位置将急剧恶化。
两个主要的游戏实体已经渲染到空间中;即猪和鸟。现在,是时候向游戏屏幕添加一些其他游戏实体了;即横梁和柱子。我们之前创建了一个beam和column列表来跟踪横梁和柱子的数量。让我们使用它来渲染游戏中的结构:
# Draw columns and Beams
#beam and column are object of Poly class for column in columns:
column.draw_poly('columns', screen)
for beam in beams:
beam.draw_poly('beams', screen)
现在,是时候更新物理:鸟在弹弓动作后应该以多快的速度前进,以及为了游戏的稳定性应该建立多少帧的更新。首先,让我们定义时间步长的长度:
time_step_change = 1.0/50.0/2.
在先前定义的时间间隔(dt或时间步长)中,观察到我们使用 2 个单位的dt将空间的模拟向前推进了 50 次。如果将dt的值从 2 增加到 4 或更多,模拟将变慢。根据 pymunk 的官方文档:使用更小的dt执行更多步骤会创建稳定的模拟。这里,值 50 代表了定义的步骤,而 2 的dt创建了总共向空间前进 100 个单位的移动。空间中的前向模拟代表了愤怒的小鸟向木结构投射的速度。
现在,使用这个时间间隔,让我们将这些步骤添加到模拟中:
#time_step_change = 1.0/50.0/2. for x in range(2):
space_obj.step(time_step_change) # This causes two updates for frame # Blitting second part of the sling rect_for_sling = Pygame.Rect(0, 0, 60, 200)
screen.blit(sling_image, (120, 420), rect_for_sling)
Pygame.display.flip() #updating the game objects
clock.tick(50)
使用space对象调用的step方法将更新给定时间步长(dt或时间步长)的空间。请参考www.pymunk.org/en/latest/_modules/pymunk/space.html 了解更多关于step方法的信息。
最后,让我们运行游戏。点击“Run”选项卡,然后点击main.py文件。运行愤怒的小鸟游戏的结果如下:

最后,我们的游戏完成了。您可以通过更改它们的值并观察结果来测试我们为游戏实体定义的不同物理属性。如果我是您,我可能会更改dt的步长值,并检查它如何影响对象的模拟。显然,将dt的值从较低更改为较高会使“弹弓动作”触发后对象的速度变慢。例如,更改步长值(dt = 4),您会发现愤怒的小鸟比以前慢。这是由于模拟向前移动增加了额外的单位。
虽然我们的游戏可以完全正常地玩和测试,但还有一些调整可以实现,使我们的游戏更具吸引力。例如,我们可以为游戏添加音效并增加更多关卡。我们将在下一节中讨论这一点。
可能的修改
在测试我们的游戏时,可能会出现没有太多空间进行进一步修改的情况。但是,我想到了一个重要的修改:为游戏添加soundFx。为了在用户与虚拟世界交流时提供积极的体验,音效起着重要作用。考虑到这一点,Python 的Pygame模块提供了一个接口,以便我们可以为游戏添加配乐。
首先,要为游戏添加音效,我们需要将音乐加载到游戏中。在 GitHub 上查看本书的资源文件夹:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter15/res。然后,查看sounds文件夹,其中包含可以添加到游戏项目中的音乐文件。我将使用angry-birds.ogg文件(您可以使用任何您喜欢的文件,甚至可以从互联网上下载)。
以下代码将音乐文件加载到您的 Python 项目中。确保代码编写在main.py文件中:
def load_music():
"""Function that will load the music"""
song_name = '../res/sounds/angry-birds.ogg'
Pygame.mixer.music.load(song_name)
Pygame.mixer.music.play(-1)
在前面的函数定义中,我们首先定义了音乐文件的路径,并将其存储为song_name变量中的字符串。现在,要加载播放文件,我们可以使用mixer.music类,该类具有预定义的load()方法,该方法将歌曲加载到 Python 项目中。要播放刚刚加载的音乐,我们将调用play()方法。play 方法接受两个参数:loop 和 start。这两个参数都是可选的。循环值将为-1,这意味着必须连续播放加载的音乐。例如,如果要连续播放音乐,例如六次,可以在其上调用play方法并带有loop = 5参数。例如,play(5)将使音乐连续播放 6 次。
现在,让我们在同一个main.py文件中调用上述函数。您可以这样调用:
load_music()
这就是如果我们想要将音乐加载到我们的 Python 游戏中。现在,您可以玩游戏并享受配乐。
我们可以进行的下一个修改是添加不同的关卡。返回 Python 项目并打开level.py文件。它将包含Level类以及一个名为build_0的单个函数。您可以添加任意多个关卡。在本节中,我们将为游戏添加另一个关卡,并将其命名为build_1。以下函数应该编写在level.py文件的Level类中:
def build_1(self):
"""Function that will render level 1"""
obj_pig = RoundPig(1000, 100, self.space)
self.pigs.append(obj_pig)
pos = (900, 80)
self.columns.append(Polygon(pos, 20, 85, self.space))
pos = (850, 80)
self.columns.append(Polygon(pos, 20, 85, self.space))
pos = (850, 150)
self.columns.append(Polygon(pos, 20, 85, self.space))
pos = (1050, 150)
self.columns.append(Polygon(pos, 20, 85, self.space))
pos = (1105, 210)
self.beams.append(Polygon(pos, 85, 20, self.space))
self.total_number_of_birds = 4 #reduce the number to
make game more competitive
在前面的代码中,我们定义了一个函数,用于创建一个木结构。仔细观察代码-我们创建了Pig和Polygon类的实例。猪角色被创建在空间中的位置(1000, 10)。同样,三根柱子依次创建并垂直对齐。pos局部变量表示这些游戏实体必须呈现的空间位置。要使用这些游戏实体创建任何随机结构,可以测试pos变量的不同值。但是,请确保您定义的位置在空间内并且在空间的左下角。例如,给定位置(50, 150)会使任何游戏实体更靠近弹弓,并且不会使游戏具有竞争性。因此,在构建这样的结构时,请确保实体远离弹弓。
现在,当你运行第二关的程序时,你将看到以下输出:

你可以添加任意多的关卡。你只需要一点创造力来制作游戏关卡-形成横梁和柱子结构,这样玩家就很难打破。如果你想添加进一步的修改,你可以为游戏添加得分。你可以为游戏实体(猪、横梁和柱子)分配一些值,每当鸟与这些游戏实体发生碰撞时,你可以将该值添加到玩家的得分中。我们在第十二章中实现了类似的逻辑,学习角色动画、碰撞和移动。
最后,我们的游戏是可玩的,你可以测试每个游戏实体的声音效果和物理属性。你可以测试弹性属性如何为游戏表面提供真实世界的模拟。你还可以测试空间的模拟速度。要了解更多关于模拟步骤和步长时间间隔的信息,请查看www.pymunk.org/en/latest/_modules/pymunk/space.html上提供的在线资源。
我很享受写这一章,也很享受制作这个游戏。我希望你也一样。在下一章中,我们将学习每个 Python 游戏开发者都必须具备的其他重要技能-为游戏添加一个人工角色。这个角色将在同一个游戏中与人类玩家进行游戏和竞争。确切地说,我们将在游戏中创建一个类似人类的玩家,并为其添加智能,就像我们人类一样。下一章将是有趣而有教育意义的。让我们开始吧!
总结
在这一章中,我们探讨了如何通过为游戏角色和环境添加真实世界的物理属性来创建 Pythonic 2D 物理模拟空间。我们首先学习了各种pymunk模块的基础知识,比如 vec2d、子模块、不同的类和属性,这些将构建 2D 刚体。这些刚体具有模拟真实世界物体特性的能力,比如质量、惯性、运动和弹性。利用这些特性,我们能够为每个游戏实体提供独特的特征,即鸟、猪、横梁和柱子。
本章的主要目的是让你了解如何有效地使用pymunk模块来创建像愤怒的小鸟这样复杂的游戏。像愤怒的小鸟这样的游戏被认为是复杂的,不是因为它包含了各种实体,而是因为它们必须模拟真实世界的物理属性。由于pymunk包含了不同的类来处理这样的环境,我们使用它来创建游戏环境、表面和游戏实体,比如愤怒的小鸟、猪和多边形。在本章中,你还学会了如何处理超过两个游戏角色之间的碰撞和移动。到目前为止,我们已经学会了如何创建一个处理程序来处理两个游戏对象之间的碰撞(蛇和边界之间以及小鸟和垂直管道之间的碰撞),但本章帮助你了解了如何轻松地创建一个碰撞处理程序来处理多个游戏实体之间的碰撞。
下一章将是有趣且具有挑战性的。我们将学习如何创建非玩家角色(NPC)—一个足够聪明以与人类玩家竞争的人工玩家。我们将通过定义人类玩家在相同情况下执行的移动和动作来创建这些 NPC。例如,当人类玩家看到面前有墙时,他们会采取行动来避免碰撞。类似的策略也将被输入到人工玩家中,以便他们能够做出聪明的举动,并能够有效地与人类玩家竞争。
第十六章:学习游戏人工智能-构建一个玩家机器人
- 游戏开发人员的目标是创建具有挑战性和乐趣的游戏。尽管许多程序员尝试过,但许多游戏失败的主要原因是,人类玩家喜欢在游戏中受到人工玩家的挑战。创造这样的人工玩家的结果通常被称为非玩家角色(NPC)或人工玩家。虽然创建这样的玩家很有趣(只对程序员来说),但除非我们为这些人工玩家注入一些智能,否则它不会为游戏增添任何价值。创建这样的 NPC 并使它们以某种程度的意识和智能(与人类智能相当)与人类玩家互动的过程称为人工智能(AI)。
在本章中,我们将创建一个智能系统,该系统将能够与人类玩家竞争。该系统将足够智能,能够进行类似于人类玩家的移动。系统将能够自行检查碰撞,检查不同的可能移动,并进行最有利的移动。哪种移动是有利的将高度依赖于目标。人工玩家的目标将由程序员明确定义,并且基于该目标,计算机玩家将能够做出智能的移动。例如,在蛇 AI 游戏中,计算机玩家的目标是进行一次移动,使它们更接近蛇食物,而在第一人称射击(FPS)游戏中,人工玩家的目标是接近人类玩家并开始向人类玩家开火。
通过本章结束时,您将学会如何通过定义机器状态来创建一个人工系统,以定义人工玩家在任何情况下会做什么。同样,我们将以蛇 AI 为例,以说明如何向计算机玩家添加智能。我们将为游戏角色创建不同的实体:玩家、计算机和青蛙(蛇食物),并探索面向对象和模块化编程的强大功能。在本章中,您将主要找到我们已经涵盖的内容,并学会如何有效地使用它以制作有生产力的游戏。
本章将涵盖以下主题:
-
理解人工智能
-
开始蛇 AI
-
添加计算机玩家
-
为计算机玩家添加智能
-
构建游戏和青蛙实体
-
构建表面渲染器和处理程序
-
可能的修改
技术要求
为了有效地完成本章,必须获得以下要求清单:
-
Pygame 编辑器(IDLE)-建议使用 3.5+版本
-
PyCharm IDE(参见第一章,了解 Python-设置 Python 和编辑器,安装程序)
-
资产(蛇和青蛙
.png文件)-可在 GitHub 链接获取:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter16
查看以下视频以查看代码的运行情况:
理解人工智能
随着众多算法和模型的出现,今天的游戏开发者利用它们来创建人工角色,然后让它们与人类玩家竞争。在现实世界的游戏中,被动地玩游戏并与自己竞争已经不再有趣,因此,程序员故意设置了几种难度和状态,使游戏更具挑战性和乐趣。程序员使用的几种方法中,最好且最流行的之一是让计算机与人类竞争。听起来有趣且复杂吗?问题是如何可能创建这样的算法,使其能够与聪明的人类竞争。答案很简单。作为程序员,我们将定义几种聪明的移动,使计算机能够以与人类类似的方式应对这些情况。
在玩游戏时,人类足够聪明,可以保护他们的游戏角色免受障碍物和失败。因此,在本章中,我们的主要目标是为 NPC 提供这样的技能。我们将使用之前制作的贪吃蛇游戏(第十一章,使用 Pygame 制作贪吃蛇游戏 UI),稍微完善一下,并为其添加一个具有一定意识的计算机玩家,它将知道食物(蛇吃的东西)在哪里,以及障碍物在哪里。确切地说,我们将为我们的计算机角色定义不同的移动,使其拥有自己的生活。
首先,回顾一下第四章,数据结构和函数。在那一章中,我们创建了一个简单的井字游戏,并在其中嵌入了一个简单的智能算法。在那个井字游戏中,我们能够让人类玩家与计算机竞争。我们首先定义了模型,处理了用户事件,然后最终添加了不同的移动,以便计算机自行游戏。我们还测试了游戏,计算机能够在某些情况下击败玩家。因此,我们在第四章,数据结构和函数中已经学习了基本的 AI 概念。然而,在本章中,我们将更深入地探索 AI 的世界,并揭示关于智能算法的其他有趣内容,这些内容可以添加到我们之前制作的贪吃蛇游戏中。
要了解 AI 算法的工作原理,我们必须对状态机图表有相当多的了解。状态机图表(通常源自计算理论)定义了 NPC 在不同情况下必须做什么。我们将在下一个主题中学习状态机图表或动画图表。
实现状态。
每个游戏的状态数量都不同,这取决于游戏的复杂程度。例如,在像 FPS 这样的游戏中,NPC 或敌人必须有不同的状态:随机寻找人类玩家,在玩家位置随机生成一定数量的敌人,向人类玩家射击等等。每个状态之间的关系由状态机图表定义。这个图表(不一定是图片)代表了从一个状态到另一个状态的转变。例如,敌人在什么时候应该向人类玩家开火?在什么距离应该生成随机数量的敌人?
以下图表代表不同的状态,以及这些状态何时必须从一个状态改变到另一个状态:

观察前面的图表,你可能会觉得它并不陌生。我们之前在为井字游戏添加智能计算机玩家时做过类似的事情。在图中,我们从随机的敌人移动开始,因为我们不希望每个敌人都在同一个地方渲染。同样,在敌人被渲染后,它们被允许接近人类玩家。敌人的移动没有限制。因此,可以实现敌人位置和人类玩家位置之间的简单条件检查,以执行敌人的矢量移动(第十章,用海龟升级蛇游戏)。同样,在每次位置改变后,敌人的位置与人类玩家的位置进行检查,如果它们彼此靠近,那么敌人可以开始朝向人类玩家开火。
在每个状态之间,都有一些步骤的检查,以确保计算机玩家足够智能,可以与人类玩家竞争。我们可以观察到以下伪代码,它代表了前述的机器状态:
#pseudocode for random movement
state.player_movement():
if state.hits_boundary:
state.change_movement()
在前面的伪代码中,每个状态定义了必须执行的代码,以执行诸如player_movement、hits_boundary和change_movements之类的检查操作。此外,在接近人类玩家的情况下,伪代码看起来像下面这样:
#pseudocode for check if human player and computer are near
if state.player == "explore":
if human(x, y) == computer(x, y):
state.fire_player()
else:
state.player_movement()
前面的伪代码并不是实际代码,但它为我们提供了关于我们可以期望 AI 为我们做什么的蓝图。在下一个主题中,我们将看到如何利用伪代码和状态机的知识,为我们的蛇游戏创建不同的实体。
开始蛇 AI
如在 FPS 的情况下讨论的那样,蛇 AI 的情况下可以使用类似的机器状态。在蛇 AI 游戏中,我们的计算机玩家需要考虑的两个重要状态如下:
-
计算机玩家有哪些有效的移动?
-
从一个状态转换到另一个状态的关键阶段是什么?
关于前面的几点,第一点指出,每当计算机玩家接近边界线或墙壁时,必须改变计算机玩家的移动(确保它保持在边界线内),以便计算机玩家可以与人类玩家竞争。其次,我们必须为计算机蛇玩家定义一个目标。在 FPS 的情况下,如前所述,计算机敌人的主要目标是找到人类玩家并执行射击操作,但是在蛇 AI 中,计算机玩家必须接近游戏中的食物。蛇 AI 中真正的竞争在于人类和计算机玩家谁能更快地吃到食物。
现在我们知道了必须为 NPC(计算机玩家)定义的动作,我们可以为游戏定义实体。与我们在第十一章中所做的类似,使用 Pygame 制作 Outdo Turtle - 蛇游戏 UI,我们的蛇 AI 有三个主要实体,它们列举如下:
-
类
Player:它代表人类玩家,所有动作都与人类相关——事件处理、渲染和移动。 -
类
Computer:它代表计算机玩家(一种 AI 形式)。它执行诸如更新位置和更新目标之类的动作。 -
类
Frog:它代表游戏中的食物。人类和计算机之间的竞争目标是尽快接近青蛙。
除了这三个主要的游戏实体之外,还有两个剩余的游戏实体来定义外围任务,它们如下:
-
类
Collision:它代表将具有方法以检查任何实体(玩家或计算机)是否与边界发生碰撞。 -
类
App:它代表将渲染显示屏并检查任何实体是否吃掉青蛙的类。
现在,借助这些实体蓝图,我们可以开始编码。我们将首先添加一个Player类,以及可以渲染玩家并处理其移动的方法。打开你的 PyCharm 编辑器,在其中创建一个新的项目文件夹,然后在其中添加一个新的 Python 文件,并将以下代码添加到其中:
from pygame.locals import *
from random import randint
import pygame
import time
from operator import *
在前面的代码中,每个模块对你来说都很熟悉,除了operator。在编写程序时(特别是在检查游戏实体与边界墙之间的碰撞时),使用数学函数来执行操作比直接使用数学运算符要非常有帮助。例如,如果要检查if value >= 2,我们可以通过使用operator模块内定义的函数来执行相同的操作。在这种情况下,我们可以调用ge方法,它表示大于等于:if ge(value, 2)。类似于ge方法,我们可以调用诸如以下的不同方法:
-
gt(a, b): 检查 a > b—如果 a > b 则返回True;否则返回False -
lt(a, b): 检查 a < b—如果 a < b 则返回True;否则返回False -
le(a, b): 检查 a <= b—如果 a <= b 则返回True;否则返回False -
eq(a, b): 检查 a == b—如果 a == b 则返回True;否则返回False
现在你已经导入了必要的模块,让我们开始有趣的事情,创建Player类:
class Player:
x = [0] #x-position
y = [0] #y-position
size = 44 #step size must be same for Player, Computer, Food
direction = 0 #to track which direction snake is moving
length = 3 #initial length of snake MaxMoveAllow = 2
updateMove = 0 def __init__(self, length):
self.length = length
for i in range(0, 1800):
self.x.append(-100)
self.y.append(-100)
# at first rendering no collision
self.x[0] = 1 * 44
self.x[0] = 2 * 44
在前面的代码中,我们开始定义类属性:(x,y)代表蛇的初始位置,size代表蛇块的步长,direction(值范围从 0 到 4)代表蛇移动的当前方向,length是蛇的原始长度。名为direction的属性的值将在 0 到 3 之间变化,其中 0 表示蛇向右移动,1 表示蛇向左移动,类似地,2 和 3 分别表示上和下方向。
接下来的两个类属性是MaxMoveAllow和update。这两个属性将在名为updateMove的函数中使用(在下面的代码中显示),它们确保玩家不被允许使蛇移动超过两次。玩家可能会一次输入多于两个箭头键,但如果所有效果或箭头键同时反映,蛇将移动不协调。为了避免这种情况,我们定义了maxMoveAllowed变量,以确保最多同时处理两次箭头键按下。
同样地,我们在类内部定义了构造函数,用于执行类属性的初始化。在渲染蛇玩家在随机位置之后(通过for循环完成),我们编写了一条语句,确保在游戏开始时没有碰撞(高亮部分)。代码暗示了蛇的每个方块之间的位置必须相隔三个单位。如果将self.x[0] = 2*44的值更改为self.x[0] = 1 *44,那么蛇头和其之间将发生碰撞。因此,为了确保在游戏开始时(玩家开始玩之前)没有碰撞,我们必须在方块之间提供特定的位置间隔。
现在,让我们使用MaxMoveAllow和updateMove属性来创建update函数:
def update(self):
self.updateMove = self.updateMove + 1
if gt(self.updateMove, self.MaxAllowedMove):
# update previous to new position
for i in range(self.length - 1, 0, -1):
self.x[i] = self.x[i - 1]
self.y[i] = self.y[i - 1]
# updating the position of snake by size of block (44)
if self.direction == 0:
self.x[0] = self.x[0] + self.size
if self.direction == 1:
self.x[0] = self.x[0] - self.size
if self.direction == 2:
self.y[0] = self.y[0] - self.size
if self.direction == 3:
self.y[0] = self.y[0] + self.size
self.updateMove = 0
前面的代码对你来说并不陌生。你以前多次见过这样的逻辑(在第六章,“面向对象编程”中,以及第十一章,“用 Pygame 制作贪吃蛇游戏 UI”中,处理蛇的位置时)。简而言之,前面的代码行改变了人类玩家的当前位置,根据按下的箭头键。你可以在代码中看到,我们还没有处理任何箭头键(我们将在App类中处理),但我们已经创建了一个名为direction的属性,它可以跟踪哪个键被按下。如果direction等于0,这意味着右箭头键被按下,因此我们增加x位置与块大小。
同样,如果direction是1,我们通过减去块大小44来改变x位置值,这意味着蛇将朝负x轴移动。(这不是新信息;可以在第九章,“数据模型实现”中找到详细讨论。)
现在,为了确保每个direction属性与值 0 到 3 相关联,我们将为每个创建函数,如下所示:
def moveRight(self):
self.direction = 0 def moveLeft(self):
self.direction = 1 def moveUp(self):
self.direction = 2 def moveDown(self):
self.direction = 3 def draw(self, surface, image):
for item in range(0, self.length):
surface.blit(image, (self.x[item], self.y[item]))
观察前面的代码,你可能已经注意到direction属性的重要性。每个移动都有一个相关联的值,可以在处理用户事件时使用pygame模块(我们将在本章后面讨论)。但是,现在只需看一下draw函数,它接受蛇(人类玩家)的surface和image作为参数,并相应地进行 blits。你可能会有这样的问题:为什么不使用传统方法(自第八章,“Turtle Class – Drawing on the Screen”以来一直在使用的方法)来处理用户事件,而是使用direction属性?这个问题是合理的,显然你也可以以这种方式做,但在 Snake AI 的情况下,实施这样的代码存在重大缺点。由于 Snake AI 有两个主要玩家或游戏实体(人类和计算机),它们每个都必须有独立的移动。因此,对每个实体使用传统方法处理事件将会很繁琐和冗长。更好的选择是使用一个属性来跟踪哪个键被按下,并为每个玩家独特地处理它,这正是我们将要做的,使用direction属性。
现在我们已经完成了主要的人类玩家,我们将转向计算机玩家。我们将开始为Computers类编写代码,它将在下一个主题中处理计算机的移动。
添加计算机玩家
最后,我们来到了本章的主要部分——重点部分——将计算机蛇角色添加到游戏中变得更容易。与外观一样,计算机的移动处理技术必须类似于人类玩家。我们可以重用Player类中编写的代码。唯一不同的是Player类的目标。对于人类玩家,目标未定义,因为移动的目标由玩家的思想实现。例如,人类玩家可以通过控制蛇的移动方向来有效地玩游戏。如果蛇食物在左边,那么人类玩家不会按右箭头键,使蛇朝相反方向移动。但是,计算机不够聪明,无法自行考虑赢得游戏的最佳方式。因此,我们必须明确指定计算机玩家的目标。为个别玩家/系统指定目标的技术将导致智能系统,并且其应用范围广泛——从游戏到机器人。
目前,让我们复制写在Player类内部的代码,并将其添加到名为Computer的新类中。以下代码表示了Computer类的创建,以及它的构造函数:
class Computer:
x = [0]
y = [0]
size = 44 #size of each block of snake
direction = 0
length = 3 MaxAllowedMove = 2
updateMove = 0 def __init__(self, length):
self.length = length
for item in range(0, 1800):
self.x.append(-100)
self.y.append(-100)
# making sure no collision with player
self.x[0] = 1 * 44
self.y[0] = 4 * **44**
与Player类类似,它有四个属性,其中direction的初始值为0,这意味着在计算机实际开始玩之前,蛇将自动向右(正x轴)方向移动。此外,构造函数中初始化的所有内容都与Player类相似,除了代码的突出部分。代码的最后一行是y[0],它从4*44开始。回想一下在人类玩家的情况下,代码的相同部分是2*44,表示列位置。编写这段代码,我们暗示游戏开始时人类玩家蛇和计算机玩家蛇之间不应该发生碰撞。但是,x[0]的值是相同的,因为我们希望每条蛇都从同一行开始,但不在同一列。通过这样做,我们避免了它们的碰撞,并且每个玩家的蛇将被正确渲染。
同样,我们必须添加update方法,它将根据direction属性反映计算机蛇的x、y位置的变化。以下代码表示了update方法,它将确保计算机蛇只能同时使用两个箭头键移动的组合:
def update(self):
self.updateMove = self.updateMove + 1
if gt(self.updateMove, self.MaxAllowedMove):
# Previous position changes one by one
for i in range(self.length - 1, 0, -1):
self.x[i] = self.x[i - 1]
self.y[i] = self.y[i - 1]
# head position change
if self.direction == 0:
self.x[0] = self.x[0] + self.size
if self.direction == 1:
self.x[0] = self.x[0] - self.size
if self.direction == 2:
self.y[0] = self.y[0] - self.size
if self.direction == 3:
self.y[0] = self.y[0] + self.size
self.updateMove = 0
前面的代码与Player类类似,所以我不会费心解释它。您可以参考Player类的update函数,了解这个方法是如何工作的。与Player类类似,我们必须添加四个方法,这些方法将相应地改变direction变量的值:
def moveRight(self):
self.direction = 0 def moveLeft(self):
self.direction = 1 def moveUp(self):
self.direction = 2 def moveDown(self):
self.direction = 3
编写的代码将能够更新计算机玩家的direction,但这还不足以做出聪明的移动。比如,如果蛇食在右侧,到目前为止编写的代码将无法跟踪食物的位置,因此计算机蛇可能会去相反的地方。因此,我们必须明确指定计算机玩家将朝着靠近蛇食的位置移动。我们将在下一个主题中介绍这一点。
为计算机玩家添加智能
到目前为止,已经定义了两个游戏实体,它们都处理玩家的移动。与Player类不同,另一个游戏实体(计算机玩家)不会自行决定下一步的移动。因此,我们必须明确要求计算机玩家做出一步将蛇靠近食物的移动。通过这样做,计算机玩家和人类玩家之间将会有巨大的竞争。这看起来实现起来相当复杂;然而,这个想法仍然保持不变,正如之前讨论的那样,以及机器状态图。
通过机器状态图,AI 玩家必须考虑两件事:
-
检查蛇食的位置,并采取行动以靠近它。
-
检查蛇的当前位置,并确保它不会撞到边界墙。
第一步将实现如下:
def target(self, food_x, food_y):
if gt(self.x[0] , food_x):
self.moveLeft()
if lt(self.x[0] , food_x):
self.moveRight()
if self.x[0] == food_x:
if lt(self.y[0] , food_y):
self.moveDown()
if gt(self.y[0] , food_y):
self.moveUp()
def draw(self, surface, image):
for item in range(0, self.length):
surface.blit(image, (self.x[item], self.y[item]))
在上一行代码中,我们调用了不同的先前创建的方法,如moveLeft(),moveRight()等。这些方法将导致蛇根据direction属性值移动。target()方法接受两个参数:food_x和food_y,它们组合地指代蛇食物的位置。操作符gt和lt用于执行与蛇的x-head 和y-head 位置的比较操作。例如,如果蛇食物在负x-轴上,那么将对蛇的x-位置和食物的x-位置进行比较(gt(self.x[0], food_x))。显然,food_x在负x-轴上,这意味着蛇的x-位置更大,因此调用moveLeft()。正如方法的签名所暗示的,我们将转向,并将计算机玩家蛇朝着负x-轴移动。对食物的每个(x, y)位置进行类似的比较,每次调用不同的方法,以便我们可以引导计算机玩家朝着蛇食物移动。
现在我们已经添加了简单的计算机玩家,它能够通过多个障碍物,让我们在下一个主题中添加Frog和Collision类。Frog类负责在屏幕上随机位置渲染青蛙(蛇的食物),Collision将检查蛇之间是否发生碰撞,或者蛇与边界墙之间是否发生碰撞。
构建游戏和青蛙实体
如前所述,我们将在本主题中向我们的代码中添加另外两个类。这些类在我们的 Snake AI 中有不同的用途。Game实体将通过检查传递给它们的成员方法的参数来检查是否发生任何碰撞。对于Game实体,我们将定义一个简单但强大的方法,名为checkCollision(),它将根据碰撞返回True或False的布尔值。
以下代码表示Game类及其成员方法:
class Game:
def checkCollision(self, x1, y1, x2, y2, blockSize):
if ge(x1 , x2) and le(x1 , x2 + blockSize):
if ge(y1 , y2) and le(y1, y2 + blockSize):
return True
return False
对checkCollision()方法的调用将在主类中进行(稍后将定义)。但是,你会注意到传递的参数(x和y值)将是蛇的当前位置,从中调用此方法。假设你创建了Game类的一个实例,并传递了人类玩家的(x1,y1,x2和y2)位置值。这样做,你就是在为人类玩家调用checkCollision方法。条件语句将检查蛇的位置值是否与边界墙相同。如果是,它将返回True;否则,它将返回False。
接下来重要的游戏实体是Frog。这个类在随机位置渲染Frog的图像,每次被任何玩家(人类或计算机)吃掉后都会重新渲染。以下代码表示了Frog类的声明:
class Frog:
x = 0
y = 0
size = 44 def __init__(self, x, y):
self.x = x * self.size
self.y = y * self.size
def draw(self, surface, image):
surface.blit(image, (self.x, self.y))
在上述代码中,我们定义了x-位置、y-位置和draw方法,以便渲染青蛙图像。通过创建Frog类来调用这个方法。
在下一个主题中,我们将通过创建和实现最后一个实体:主App实体来完成我们的程序。这将是我们游戏的中央指挥官。
构建表面渲染器和处理程序
首先,让我们回顾一下我们到目前为止所做的事情。我们开始编写代码,定义了两个主要的游戏实体:Player和Computer。这两个实体在行为和渲染方法方面都非常相似,只是在Computer类中引入了额外的target()方法,以确保计算机玩家足够聪明,能够与人类玩家竞争。同样,我们声明了另外两个实体:Game和Frog。这两个类为贪吃蛇 AI 提供了后端功能,比如添加碰撞逻辑,以及检查蛇食物应该渲染的位置。我们在这些不同的实体中创建了多个方法,但我们从未创建过实例/对象。这些实例可以从主要的单一类中创建,我们现在要实现这个类。我将称这个类为App类。
看一下以下代码片段,以便为App类编写代码:
class App:
Width = 800 #window dimension
Height = 600
player = 0 #to track either human or computer
Frog = 0 #food def __init__(self):
self._running = True
self.surface = None
self._image_surf = None
self._Frog_surf = None
self.game = Game()
self.player = Player(5) #instance of Player with length 5 (5
blocks)
self.Frog = Frog(8, 5) #instance of Frog with x and y position
self.computer = Computer(5) #instance of Computer player with
length 5
前面的代码定义了一些属性,比如游戏控制台的Height和Width。同样,它有一个构造函数,用于初始化不同的类属性,以及创建Player、Frog和Computer实例。
接下来,要从计算机加载图像并将其添加到 Python 项目中(参考第十一章,使用 Pygame 创建 Outdo Turtle-贪吃蛇游戏 UI,了解更多关于load方法的信息)。游戏的资源,比如蛇身和食物,可以在这个 GitHub 链接上找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter16。但是,你也可以自己创建并进行实验。我之前在第十一章,使用 Pygame 创建 Outdo Turtle-贪吃蛇游戏 UI中教过你如何使用 GIMP 和简单的绘图应用程序创建透明精灵。试着回顾一下这些概念,并自己尝试一下。现在,我要将两个图像加载到 Python 项目中。
最好使用.png 文件作为精灵,并且不要在文件名中包含数字值。例如,名为snake12.png的蛇身文件名是无效的。文件名应该不包含数字值。同样,确保将这些.png文件添加到 Python 项目文件夹中。回顾一下第十一章,使用 Pygame 创建 Outdo Turtle-贪吃蛇游戏 UI,查看如何将图像加载到 Python 项目中。
以下代码将加载两个图像文件到 Python 项目中:
def loader(self):
pygame.init()
self.surface = pygame.display.set_mode((self.Width, self.Height),
pygame.HWSURFACE)
self._running = True
self._image_surf = pygame.image.load("snake.png").convert()
self._Frog_surf = pygame.image.load("frog-main.png").convert()
在前面的代码行中,我们使用pygame.display模块创建了一个surface对象。然后,我们将两个图像——snake.png和frog-main.png——加载到 Python 项目中。convert()方法将改变渲染对象的像素格式,使其在任何表面上都能完美工作。
同样,如果一个游戏有事件,并且与用户交互,那么必须实现on_event方法:
def on_event(self, event):
if event.type == QUIT:
self._running = False
def on_cleanup(self):
pygame.quit()
最后,让我们定义main函数:
def main(self):
self.computer.target(self.Frog.x, self.Frog.y)
self.player.update()
self.computer.update()
在前面的函数中,我们调用了target方法,以确保计算机玩家能够使用其中定义的功能。如前所述,target()方法接受食物的x、y坐标,计算机会决定靠近食物。同样,调用了Player和Computer类的update方法。
现在让我们定义renderer()方法。这个方法将把蛇和食物绘制到游戏表面上。这是使用pygame和draw模块完成的:
def renderer(self):
self.surface.fill((0, 0, 0))
self.player.draw(self.surface, self._image_surf)
self.Frog.draw(self.surface, self._Frog_surf)
self.computer.draw(self.surface, self._image_surf)
pygame.display.flip()
如果你觉得你不理解renderer()方法的工作原理,去第十一章,使用 Pygame 创建 Outdo Turtle-贪吃蛇游戏 UI。简而言之,这个方法将不同的对象(image_surf和Frog_surf)绘制到游戏屏幕上。
最后,让我们创建一个handler方法。这个方法将处理用户事件。根据用户按下的箭头键,将调用不同的方法,比如moveUp()、moveDown()、moveLeft()和moveRight()。这四个方法都在Player和Computer实体中创建。以下代码定义了handler方法:
def handler(self):
if self.loader() == False:
self._running = False while (self._running):
keys = pygame.key.get_pressed()
if (keys[K_RIGHT]):
self.player.moveRight()
if (keys[K_LEFT]):
self.player.moveLeft()
if (keys[K_UP]):
self.player.moveUp()
if (keys[K_DOWN]):
self.player.moveDown() self.main()
self.renderer()
time.sleep(50.0 / 1000.0);
前面的handler方法已经被创建了很多次(我们看到了高级和简单的方法),这个是最简单的一个。我们使用了pygame模块来监听传入的按键事件,并根据需要处理它们,通过调用不同的方法。例如,当用户按下向下箭头键时,就会调用moveDown()方法。最后的sleep方法将嵌入计时器,以便在两次连续的按键事件之间有所区别。
最后,让我们调用这个handler方法:
if __name__ == "__main__":
main = App()
main.handler()
让我们运行游戏并观察输出:

正如预期的那样,这个游戏还需要添加一些东西,包括:当人类玩家和电脑玩家吃到食物时会发生什么,以及蛇与自身碰撞时会发生什么?如果你一直正确地跟随本书,这对你来说应该是小菜一碟。我们已经多次添加了相同的逻辑(在第七章,列表推导和属性;第十章,用海龟升级蛇游戏;和第十一章,用 Pygame 超越海龟-蛇游戏 UI)。但除了这个逻辑,还要关注两条相似的蛇:一条必须根据人类玩家的行动移动,另一条则独立移动。计算机蛇知道与边界墙的碰撞和食物的位置。一旦你运行游戏,计算机玩家将立即做出反应,并试图做出聪明的移动,早于人类玩家。这就是在现实游戏行业中应用人工智能。虽然你可能认为蛇 AI 示例更简单,但在现实世界中,AI 也是关于机器独立行动,无论算法有多复杂。
但是,游戏中必须进行一些调整,这将在下一个主题“可能的修改”中进行讨论。
游戏测试和可能的修改
首先,我建议你回头观察我们定义Game类的部分。我们在其中定义了checkCollision()方法。这个方法可以用于多种目的:首先,检查玩家是否与蛇食物发生碰撞;其次,检查玩家是否与边界墙发生碰撞。这个时候你一定会有一个“恍然大悟”的时刻。第七章,列表推导和属性,到第十一章,用 Pygame 超越海龟-蛇游戏 UI,都是关于使用这种技术来实现碰撞原理的,即如果食物对象的(x,y)位置与任何玩家的(x,y)坐标相同,则称为发生碰撞。
让我们添加代码来检查任何玩家是否与食物发生了碰撞:
# Does human player snake eats Frog for i in range(0, self.player.length):
if self.game.checkCollision(self.Frog.x, self.Frog.y,
self.player.x[i], self.player.y[i], 44):
#after each player eats frog; next frog should be spawn in next
position
self.Frog.x = randint(2, 9) * 44
self.Frog.y = randint(2, 9) * 44
self.player.length = self.player.length + 1 # Does computer player eats Frog for i in range(0, self.player.length):
if self.game.checkCollision(self.Frog.x, self.Frog.y,
self.computer.x[i], self.computer.y[i], 44):
self.Frog.x = randint(2, 9) * 44
self.Frog.y = randint(2, 9) * 44
同样,让我们使用相同的函数来检查人类玩家的蛇是否撞到了边界墙。你可能认为在计算机玩家的情况下也需要检查这一点,但这是没有意义的,因为在Computer类中定义的target方法不会让这种情况发生。换句话说,计算机玩家永远不会撞到边界墙,因此检查是否发生碰撞是没有意义的。但是,在人类玩家的情况下,我们将使用以下代码进行检查:
# To check if the human player snake collides with its own body for i in range(2, self.player.length):
if self.game.checkCollision(self.player.x[0], self.player.y[0],
self.player.x[i], self.player.y[i], 40):
print("You lose!")
exit(0)
pass
我们将在这里结束这个话题,但是您可以通过添加一个游戏结束屏幕使这个游戏更具吸引力,我们已经学会了如何使用pygame在第十一章中创建。您可以创建一个表面并在其中渲染一个带有标签的字体,以创建这样一个游戏结束屏幕,而不是最后的pass语句。
但是,在结束本章之前,让我们来看看我们游戏的最终输出:

在游戏中您可能注意到的另一件事是,计算机玩家的蛇长度是恒定的,即使它吃了食物。我故意这样做,以免我的游戏屏幕被污染太多。但是,如果您想增加计算机玩家的蛇长度(每次蛇吃食物时),您可以在计算机玩家蛇吃青蛙后添加一个语句:
self.computer.length = self.computer.length + 1
最后,我们来到了本章的结束。我们学到了不同的东西,也复习了旧知识。与人工智能相关的概念是广泛的;我们只是尝试触及表面。您可以通过访问以下网址找到使用 Python 在游戏中的其他 AI 含义:www.pygame.org/tags/ai。
总结
在本章中,我们探讨了在游戏中实现 AI 的基本方法。然而,AI 的工作方式在很大程度上取决于奖励智能系统的每一步。我们使用了机器状态图来定义计算机玩家的可能状态,并用它来执行每个实体的不同动作。在这一章中,我们采用了不同的编程范式;事实上,这是对我们迄今为止学到的一切的回顾,另外还使用了智能算法来处理 NPC。
对于每个定义的实体,我们都创建了一个类,并采用了基于属性和方法的封装和模型的面向对象范式。此外,我们定义了不同的类,如Frog和Game,以实现碰撞的逻辑。为了实现单一逻辑,我们为每个游戏实体(Player和Computer)创建了单独的类。您可以将其理解为多重继承。本书的主要目的是让读者了解如何使用 Python 创建游戏机器人。此外,某种程度上,目的是在单一章节中复习我们在整本书中学到的所有编程范式。
正如古谚所说:已知乃一滴,未知则是海洋。我希望您仍然渴望更多地了解 Python。我建议您加强基本的编程技能并经常进行实验,这将确实帮助您实现成为游戏开发人员的梦想工作。游戏行业是巨大的,掌握 Python 知识将会产生巨大影响。Python 是一种美丽的语言,因此您将受到更深入学习的激励,而这本书将是您迈向成为 Python 专家的第一步。




浙公网安备 33010602011771号