PygameZero-游戏编程入门手册-全-
PygameZero 游戏编程入门手册(全)
一、创作电脑游戏
编写电脑游戏是让编程变得有趣的一个好方法,但它也有缺点。主要的缺点是,要制作一个可用的游戏,你需要写很多代码,这需要很多时间。一个完整的工作游戏对于一本初学编程的书来说通常是太多了。不要担心,因为这本书使用了有效的例子,并利用 Python 和 Pygame Zero 的简单性使它尽可能地简单。在本书中,你将创建几个不同的游戏来说明不同的编程技术。
创造一个游戏不仅仅是写代码。这本书涵盖了创建一个电脑游戏以及编程的一些其他方面。
首先你需要一个想法。然后需要发展这个想法,提出一套规则和控制。它可能需要额外的资源,如图像和声音。然后,您需要编写代码来实现它。接下来(现在是有趣的部分),你需要测试它,找出什么有效,如何改进。然后你回到起点重新定义这个想法,并重复编程周期。
在这一章中,你还将了解 Python 和 Pygame Zero,以及使其适合游戏编程的一些原因。
灵感而不是模仿
第一步是想出一个主意。为此,你可以从你玩过的游戏中获得灵感,这些游戏可以是现有的电脑游戏、纸牌游戏、棋盘游戏或游乐场游戏。或者你可以想出一个全新的游戏,也许从现实世界的活动中获得灵感。如果你想基于以前已经做过的东西来创造一个游戏,那么你必须小心不要侵犯他人的知识产权,包括版权、专利和商标。
像许多法律一样,保护游戏和计算机程序的规则很复杂,并且因国家不同而不同。不可能对错综复杂的法律提供真正的指导,但是有一些你应该遵循的一般规则。
版权可以保护文字、图形、代码、音乐等作品的各个方面。然而,版权并不包括游戏的理念或游戏的玩法。作品一旦创作出来就自动享有版权,通常不需要具体的版权声明或注册,尽管这可以提供额外的保护。
专利要复杂得多,可以涵盖想法和概念。专利是为了发明,在游戏编程的情况下,它们可以被授予游戏的特定技术方面。例如,有些专利涉及赛车游戏中显示方向的方式,以及足球游戏中如何识别球员。要知道你正在开发的游戏可能涉及哪些专利是非常困难的。如果你正在创建一个商业游戏,那么你可能需要寻求专利方面的专业建议。
商标是保护名字和标志的一种方式,在电脑游戏中,它们可以包括角色的外貌。这可能会阻止您使用受商标保护的可识别字符。如果你想使用任何受商标保护的角色,那么你需要从商标所有人那里获得许可。
玩游戏
了解什么是好游戏的最好方法就是玩游戏。不要只玩一个游戏,玩很多不同的游戏。玩好游戏和坏游戏,想想是什么让游戏变得好和坏。
你是厌倦了玩这个游戏,还是它让你着迷,以至于你不能把自己从屏幕上拉走?哪些游戏让你想继续玩下去,为什么?
如前所述,你不仅仅需要从电脑游戏中获得灵感。也玩一些棋盘游戏。想想什么效果好,什么不好。想想用实物玩游戏和在电脑屏幕上玩游戏的区别;两者都可能有利有弊。
创造资源
在查看其他资源时,您可能会想到图形和声音效果。您可能还需要其他资源,包括介绍性视频、教程和背景音乐。
对于大多数游戏来说,你会想要包含图形。这些图形的外观和大小可以决定编程。例如,如果你有一个角色需要在屏幕上移动,那么你需要知道这个角色如何移动(它的脚是否移动)以及这个角色移动所需的空间大小。因此,在开始编程之前,至少创建任何图形的轮廓是有意义的。
声音效果有时可以留到项目的后期,尽管它们通常仍然是创建一个完整游戏的重要部分。如果把它们留到以后再添加,那么在设计游戏的时候,想想它们什么时候会被使用,会有什么影响,仍然是一个好主意。
开发周期
与编程相关的主要术语是敏捷。敏捷编程是一种开发软件的方式,它以小增量创建代码,一次实现一个功能,然后返回添加更多的代码。术语敏捷编程通常指的是一种通过定期评审和团队会议(称为 scrums)在团队中开发软件的编程技术,但是当你自己编程时也可以使用类似的技术。
关于使用敏捷风格方法开发代码的一些要点:
-
收集需求。与最终用户会面,或者像自己是客户一样与自己一起回顾自己的想法。
-
规划发展。将工作分成小块,每次可以执行一点。
-
设计代码以完成当前功能。
-
写代码。
-
测试代码。除了测试独立代码,还要测试它如何与其他部分交互。
-
评估代码是否仍然符合需求。
-
回到 1。考虑已经创建的代码。这与它试图实现的目标相一致吗?
对你开发的代码的每一部分重复这个循环。一旦实现了所有需要的部分,您就可以得到一个发布版本。当添加更多特性或改进代码时,遵循相同的周期。
使用敏捷编程时一些有用的东西:
-
在代码的不同部分如何交互之间设计接口。
-
用增量发布在短代码冲刺中工作。
-
对上一步已经完成的内容和下一步将要创建的内容进行定期的简短回顾。评审通常在工作环境中每天进行,但如果您在业余时间工作,则有所不同。
-
通过代码需要通过的特定测试来执行测试驱动的开发。自动化测试在敏捷编程中很流行,但是您也可以手动测试。
-
定期重构代码;审查代码以提高清晰度/性能。
-
定期与用户(或者你自己,如果是个人项目)一起检查设计是否符合预期。
-
使用橡皮鸭调试(参见第十一章)。
本书中的游戏是基于敏捷编程而创建的。书中不会特别列出任何代码评审,但是您会看到代码是如何一次启动一个特性来构建的。
让编程变得有趣
无论你的全职工作是编写电脑游戏,还是你在业余时间做的事情,编程都应该是你喜欢的事情。我从创作自己喜欢玩的东西中找到了极大的满足感。
虽然你可以试着提前想好这些概念,但是你可能不知道你是否喜欢这个游戏,直到你开始玩它。然后,当你开始调整游戏,以确保它是正确的难度,或者是否有你想要添加的功能。这将在第四章中详细讨论,届时你将会看到一些用于改进初始游戏设计的技术。
Python 和 Pygame Zero
Python 是一种流行的编程语言,在教育界和工业界都有使用。它适用于许多不同的计算机操作系统,包括苹果 Mac OS X、微软 Windows 和 Linux。学习 Python 的一些好处是它容易学习,使用的代码更少(与其他一些语言相比),并且可以帮助教授好的编程技术。
Pygame 是一个可以在 Python 中使用的库,可以使图形游戏编程变得更容易。Pygame Zero 是一个使用 Pygame 的库,但通过减少所需的代码量,使图形游戏编程比 Pygame 更容易。使用这些,有可能在屏幕上创建角色,并非常容易地移动它们。
这本书使用了运行在 Linux 上的 Python 版本,这是 Raspberry Pi 上的当前版本。这些游戏应该可以在不同的计算机系统和安装了 Pygame Zero 的最新版本的 Python 上运行。
Python 中有不同的编程风格。在本书中,最初的几个程序主要是使用函数式编程技术编写的,但随后的程序将基于面向对象编程。当开始编程时,函数式编程风格通常被认为更容易学习,但是一旦你开始创建更长的程序,那么当使用面向对象编程时,编写和理解代码通常更容易。
编译与解释
不同的计算机和操作系统以不同的方式工作。如果您正在创建一个为手机或平板电脑设计的游戏(使用触摸屏),那么您可能需要设计不同于为带有游戏控制器的游戏机设计游戏的界面。此外,计算机内部不同的处理器和操作系统不同的工作方式意味着很难编写能在多台计算机上运行的游戏。
编写计算机代码时,通常会使用一种基于文本的编程语言。计算机不能直接运行那个,代码需要转换成计算机能理解的机器码。当使用 C 之类的计算机语言时,必须先将代码转换成机器代码,然后才能运行程序。这就是所谓的编译语言,程序需要被编译成与运行它的计算机体系结构相匹配的机器代码。
Python 的做法不同,它使用解释器将代码转换成机器语言。这是在程序运行时完成的。这样做的好处是,只要您想要运行代码的计算机有解释器,您通常不需要做任何额外的事情来在该计算机上运行它。缺点是解释型语言运行速度较慢,因为它需要在运行时转换这些代码。这种性能对于本书中的任何游戏来说都不是问题,但是如果你在编写一个图形密集型游戏的话,你应该意识到这一点。
还有一种混合形式,代码被编译成中间形式,但仍然需要解释器(或类似的东西)才能在每个特定的计算机体系结构上运行。这就是 Java 如何使用 Java 虚拟机将 Java 字节码转换成计算机可以理解的机器语言。
按照 Python 的解释,它应该能够在各种不同的计算机上运行,而不需要任何改变。不幸的是,在某些平台上安装 Python 解释器和 Pygame Zero 库有时会有点棘手。幸运的是,有一个更简单的解决方案,使用 Mu 编辑器,这是 Pygame Zero 编程初学者的首选编辑器。
选择编程环境
在本书中,游戏是为 Raspberry Pi 设计的,Raspberry Pi 是一种小型、廉价的计算机,专门为学习计算和计算机编程的人设计。树莓 Pi 有不同的变体,包括小巧的树莓 Pi Zero 和功能齐全的树莓 Pi 4。你可以在本书的游戏中使用任何型号的 Raspberry Pi,尽管出于性能原因,我建议使用 Raspberry Pi 2 或更好的版本。如果你也使用 Raspberry Pi 为游戏设计图像(如第五章所述),那么 Raspberry Pi 4 可能更好,但这不是必需的。
Raspberry Pi 是学习 Python 的理想选择,因为你需要的大部分软件都已经预装了。这些程序仍然可以在其他计算机上运行,如果你愿意,你可以在另一个平台上自由开发代码,但是在其他系统上需要一些额外的步骤。
Python 程序是文本文件,因此,您可以在任何文本编辑器中创建它们。如果你以前没有用 Python 编程,那么我建议你从 Mu 编辑器开始。Mu 编辑器不是最强大的编辑器,但是它的简单性使它成为入门的理想选择。它还处理包括 Pygame Zero 在内的大部分设置。
如果使用的是 Raspberry Pi,那么 Raspbian 的最新版本包含 Mu,但是如果它还没有安装,那么您可以从命令 shell 安装 Mu。通过单击屏幕顶部的黑色终端图标启动命令 shell。
然后输入以下命令:
sudo apt update
sudo apt install mu-editor
然后,您可以从 Raspbian 菜单系统运行 Mu。从开始菜单中选择编程菜单,然后点击 Mu,它看起来应该如图 1-1 所示。
图 1-1
管理部门编辑器的屏幕截图
如果您想在其他操作系统上安装 Mu,那么您可以从 https://codewith.mu/
下载 Mu 编辑器。在 Windows 下安装时,Mu 网站上的建议是“只为该用户安装”。这将使添加以后可能需要的任何模块变得更加容易。
管理部门编辑器有不同的模式,适用于不同的编程环境。这本书使用了 Python 3 和 Pygame Zero 模式。
当你有了更多的经验,你可能会想换一个更强大的编辑器。如果使用 Raspberry Pi,那么你有一个数字可以选择,你可以直接从命令行运行程序。如果您使用不同的环境,那么您可能需要用 Pygame Zero 设置一个本地 Python 环境。
摘要
本章已经讨论了在你开始编程之前应该考虑的一些事情。它给出了一些建议,告诉你可以从哪里获得灵感,并警告你应该避免围绕他人知识产权的一些陷阱。
它解释了 Python 是什么,以及为什么 Pygame Zero 是游戏编程初学者的好选择。
在下一章,你将开始编写代码,并使用 Python 创建一个命令行游戏。
二、Python 入门
为了开始编程 Python,本章从一些基本的命令行编程开始。这将创建一个简单的基于文本的游戏,可以使用键盘玩。这仅仅是开始;从下一章开始,你将能够创建有趣的图形游戏。
使用管理部门编辑器
当您第一次启动编辑器时,它会询问您以哪种模式启动。您将在本书的项目中使用的模式是 Python 3 和 Pygame Zero。如果您之前已经运行过编辑器,那么它将以上次使用的模式启动,在这种情况下,您可以使用编辑器左上角的模式按钮来更改模式。
对于本章,您将创建基本的基于文本的程序,因此您应该选择 Python 3。在以后的章节中,你应该使用 Pygame Zero。
当你第一次启动 Mu 时,应该会有一个空屏幕,上面有一条注释# Write your code here :-).
行首的#表示这是一个注释,将被忽略。注释对于程序员解释程序如何工作真的很有用,但是 Python 就是忽略了它们。现在您可以删除这一行,但是当您编写自己的代码时,我建议您添加注释来解释代码是如何工作的,因为这对将来理解代码是有用的。
首先,你可以创建一个名为“Hello World”的基础程序。这是你能创建的最小的程序之一。这实际上是一行代码,如下所示:
print ("Hello World")
用以下打印语句替换管理部门编辑器中的注释。然后,您需要在运行程序之前保存它;我建议将它保存在默认文件夹(/home/pi/mu_code)中,并将其命名为 helloworld.py。如果您试图在保存之前运行代码,那么系统会提示您首先保存它。
保存文件后,单击 Run,您将在屏幕底部看到程序正在运行。在这种情况下,它将 Hello World 打印到基于文本的屏幕区域。如图 2-1 所示。
图 2-1
在管理部门编辑器中运行的 Hello World 程序
完成后,单击停止图标停止程序运行。
这是从 Mu 运行 Python 程序最常见的方式。另一种方法是从 Raspbian Linux 命令 shell 运行程序。使用保存按钮保存当前程序。在本例中,通过查看编辑器底部的状态消息,您将看到文件保存的位置
/home/pi/mu_code/helloworld.py
要从命令行运行该程序,请从 Raspbian 菜单启动器启动终端程序。终端是一个基于文本的界面,用于与操作系统通信,包括启动其他程序。您可以使用 cd 命令切换到存储程序的文件夹。文件名由目录组成,该目录由直到最后一个“/”字符的所有字符组成(注意,Linux 上的目录分隔符与 Windows 操作系统上使用的文件夹分隔符方向相反)。
在这种情况下,目录路径是/home/pi/mu_code/
cd /home/pi/mu_code/
python3 helloworld.py
您的程序现在将运行并显示与您之前在 Mu 输出屏幕中看到的相同的“Hello World”文本。如图 2-2 所示。
图 2-2
从命令行运行 Hello World 代码
运行 Python 代码的另一种方式是使用 REPL。它代表 read-eval-print 循环(但名字并不重要)。REPL 提供了一种以交互模式运行 Python 代码的方式。在将少量代码包含到您的程序中之前,这对于测试运行这些代码非常有用。
要在 REPL 中运行相同的代码,请在管理部门编辑器菜单栏中单击 REPL。您必须在 Python 3 模式下才能看到该菜单选项。如果未显示 REPL 图标,则使用管理部门菜单栏上的模式图标来更改模式。点击 REPL 图标后,屏幕底部会出现一个交互式外壳。请注意,如果您之前的编程仍在运行,那么它将并排显示程序输出和 REPL,如果是这样,那么单击停止按钮,这将为 REPL 提供编辑器的全部宽度。
您将在 REPL 屏幕上看到一条提示,通常会显示“IN [1]:”。在提示符下输入上一个程序代码
图 2-3
管理部门编辑器中运行 Hello World 代码的 REPL
print ("Hello World")
然后按 Enter 查看运行该指令的效果。如图 2-3 所示。
您也可以通过从命令行运行python3
来访问 REPL。在这种情况下,REPL 提示符由三个大于字符“> > >”显示。如果从命令行运行,那么您需要按 Ctrl-D 退出。
Python 编程
创建 Python 程序时,需要遵循一定的结构,这样程序才能正确运行。第一个游戏将涵盖 Python 程序运行需要遵循的一些规则。
这个游戏是一个简单的笑话测验。程序会问玩家一个带有玩笑答案的问题。如果玩家正确回答了问题,那么他们将受到祝贺;否则,他们将会得到笑点。
单击 Mu 中的 New 按钮创建一个新文件,并输入清单 2-1 中的代码。
1 print ("Welcome to the Python joke program")
2 player_guess = input ("Why couldn't the engineer fix the computer?\n")
3 if (player_guess == "too many bits"):
4 print ("Well done!")
5 else:
6 print ("too many bits")
Listing 2-1Joke quiz program
必须完全按照显示的内容输入代码,但左侧的数字除外,这些数字不应输入(默认情况下显示在管理部门编辑器的空白处)。包含这些数字是为了更容易解释代码,或者在代码不能正常工作时帮助解决问题。它们不应该包含在内,因为它们不构成代码的一部分。
Python 代码是区分大小写的,所以就 Python 而言,print、PRiNT 和 Print 是完全不同的。间距也很重要。第 1、2、3 和 5 行应该从编辑器左侧的第一个字符位置开始。第 4 行和第 6 行应该缩进四个空格;编辑器会在看到冒号“:”字符后自动缩进。每当您按 Tab 键时,Mu 还会插入四个空格。
保存然后运行程序。当你第一次点击保存,你需要给它一个文件名。将文件命名为 joke.py 或其他合适的名称。然后,您可以单击播放按钮来运行该程序。
该程序将打印“欢迎来到 Python 笑话”,然后是“为什么工程师不能修好计算机?”。此时,玩家需要猜一猜。输入I don't know
。然后,计算机会以“位数太多”作为回应。
在这一点上,我想为这样一个糟糕的笑话道歉。我相信你可以做得更好,所以请随意将引号之间的文字改成你最喜欢的笑话。
如果您第二次运行该程序,那么您已经知道了答案,因此您可以在提示时键入答案,如图 2-4 所示。
图 2-4
joke.py 游戏的输出
为了解释代码是如何工作的,一次看一行是很有用的。
第一行是print ("Welcome to the Python joke program")
。
这段代码运行一个名为 print 的函数。函数是执行特定功能的代码块。在这种情况下,打印功能包含在 Python 中,并且包含可以将文本打印到屏幕上的代码。因为有了括号,您可以将打印视为一种功能。一些函数需要一个或多个括号内的值,这些值被称为参数,但并不是所有的函数都使用参数。在 print 的例子中,它接受一个文本字符串参数。文本两边的引号表示文本将用作文本字符串而不是变量。
Note
功能和方法。你可以在本书中看到函数和方法的参考。在 Python 中,方法类似于函数,但包含在类中,对对象进行操作。Python 根据上下文使用两者。这些是面向对象的编程术语,在第九章中有解释。
第 2 行使用了input
函数,它向用户显示一条消息,然后等待用户输入响应。该参数是一个文本字符串,就像第 1 行中使用的一样。该函数返回一个字符串值,其中包含玩家输入的猜测值。返回值存储在一个名为player_guess
的变量中。
参数文本字符串在末尾包含一个特殊序列\n
。这是一个将光标移动到下一行的转义序列。这是必需的,因为与打印功能不同,输出功能不会自动添加新行。变量和转义序列将在本章后面讨论变量时解释。
第 3 行比较存储在player_guess
中的变量,看它是否匹配文本字符串"too many bits"
。如果匹配,那么它运行缩进的代码块,在本例中是第 4 行。第 4 行是print
函数,再次给玩家消息"Well done!"
。
第 5 行是一个else
,与第 3 行的 if 相反。如果不满足第 3 行的条件,那么它运行 else 子句后的缩进文本块,即第 6 行。第 6 行使用print
函数来print "too many bits"
。
if
和else
子句是条件语句,稍后会有更详细的解释。
还可以添加更多的东西来改进程序,但首先理解数据如何存储在计算机程序中是有用的。
变量
在计算机程序中存储信息的一种常见方式是使用变量。你可以把一个变量想象成一个盒子,你可以在里面存储一些东西,但是变量不是存储物理对象,而是存储信息。
以下示例将创建一个名为my_variable
的新变量,并将值 7 存储在该变量中。
my_variable = 7
变量名必须以字母或下划线字符开头。变量名的其余部分可以包括字母、数字和下划线。变量名总是区分大小写,所以名为My_Variable
的变量与my_variable
不同。
在一些编程语言中,你需要指定你将在变量中存储什么,比如它将是一个数字还是一个字符串。在 Python 中,这是动态的,因此变量可以根据需要改变类型。了解不同的变量类型很重要,因为经常需要在不同的变量类型之间进行转换。
Python 中使用的主要变量类型有
图 2-5
组成单词 PYTHON 的字符串
-
Integers (int) 存储不带任何分数的整数。
-
整数的例子有 3,3948392 和–237(它们不必是正数)。
-
浮点数(float) 存储包含分数或小数点的数字。浮点数的例子包括 2.99、3.14159、–345.2 和 1.0。
-
Character (chr) 指文本的单个字符。在 Python 中,字符以 unicode 存储,因此除了能够存储标准文本(如“a”和数字(如“3”)之外,还有许多不同的字符,如希腊字母或带重音符号的字母。请注意,“3”字符与数字 3 不同;当存储为字符时,通常需要将其转换为数字,然后才能对其执行任何算术运算。
-
字符串(str) 用于保存文本。它们被存储为串在一起的字符的集合。你可以把它想象成一串字母珠子,每个珠子有一个字母组成一个单词(见图 2-5 )。字符串可以是任意长度,从空字符串(零个字符)到整本书(如果您愿意)。
-
布尔(bool) 可以表示真或假。事实上,它们只能保存这两个值,这意味着它们对于简单的是或否决策非常有用。
每种变量类型(如 int)的括号中的文本是内置函数的名称,用于将不同的变量转换为该变量类型。例如,如果你有一个整数,但希望它是一个字符串,那么你可以使用 str 函数。然后,您可以使用 type 函数来查看存储的是什么类型的变量。
要看到这一点,您可以在 REPL 中输入一些命令。在管理部门中,单击 REPL 按钮,然后在此处显示的>>>字符后面输入文本。响应以粗体显示。
>>> variable1 = 10
>>> print (variable1)
10
>>> type(variable1)
int
>>> variable2 = str(variable1)
>>> print(variable2)
10
>>> type(variable2)
str
如您所见,当使用 print 函数打印时,variable1 和 variable2 似乎显示了相同的值,但是它们被存储为不同的变量类型。
如果您试图使用+运算符连接这两个变量,那么您将得到如下所示的错误:
>>> variable3 = variable1 + variable2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
需要在变量类型之间进行转换的一个常见原因是,如果您想要打印存储在整型或浮点型变量中的值。这可以通过打印 str 函数的输出来实现。
>>> print ("The value of variable1 is " + str(variable1))
The value of variable1 is 10
当需要对数字执行算术运算时,理解数字何时存储为字符串和整数/浮点数也很重要。通过测试 REPL 的一些操作,您可以亲自看到这一点。
>>> integer1 = 1
>>> integer2 = 2
>>> integer3 = integer1 + integer2
>>> print (integer3)
3
>>>
>>> string1 = "1"
>>> string2 = "2"
>>> string3 = string1 + string2
>>> print (string3)
12
如您所见,当将整数相加时,您会得到算术和,即本例中的 1 + 2 = 3。如果它们被存储为字符串,那么第二个字符串被附加到第一个字符串,给出字符串“12”。
这个例子说明了为什么区分数字和字符串中的数字很重要,但是用浮点数代替整数呢?事实上,为什么我们甚至需要整数作为一个浮点数可以容纳任何整数值,只是在小数点后加零?主要有两个原因。首先是为了效率;存储整数需要更少的空间,而且对计算机来说更容易操作。另一个是关于由于取整数值造成的不准确性。为了存储浮点数,特别是那些由于除法运算而产生的数字,计算机可能需要对数值进行舍入。如果您随后创建一个具有相同数量但使用不同技术的不同变量,那么它可能会被不同地舍入,从而给出一个几乎相同但不完全相同的值。因此,检查浮点值是否等于另一个浮点值通常被认为是不安全的。如果您想要检查某个值的浮点值,您应该总是比较以查看是否在某个范围内,而不是假设它是一个精确的值。如果你只需要整数,那么最好将它们存储为整数。
想要增加或减少一定数量的数字是很常见的。例如,当一个玩家得分时,你可能需要增加分数变量。您可以使用以下代码实现这一点:
score = score + 1
这是可行的,但是有一个快捷方式允许你增加一个现有的变量。在 Python 中,可以使用+=来增加变量,使用-=来减少变量。你可以在 REPL 测试这个来看这个。
>>> score = 0
>>> score += 1
>>> score
1
>>> score += 2
>>> score
3
>>> score -= 1
>>> score
2
+=和-=快捷键被程序员大量使用,你很少会在程序中看到更长的格式。
+=也适用于字符串,它会将新字符串追加到第一个字符串的末尾。这一点如下所示:
>>> var1 = "string 1"
>>> var1 += " string 2"
>>> var1
'string 1 string 2'
字符串和格式
如前所述,字符串是一组字符。字符串不需要局限于普通的文本字符,因为它们也可以使用特殊的字符序列。您已经看到了转义序列\n
,它插入了一个换行符;还有其他一些,如\'
和\"
,当您想要在字符串中包含引号时使用,当您想要在字符串中包含\字符时使用。
创建一个字符串就像把一些文本放在引号中一样简单(单引号或双引号)。“这是一个字符串”,“这也是一个字符串”。在 Python 中使用单引号和双引号的唯一区别是,如果您想在字符串中使用相同的引号,那么您需要首先使用转义字符。
如前所示,您还可以使用加号+将字符串加在一起。这将两个字符串组合成一个新的单个字符串(其他一些编程语言称之为串联)。如果您想包含一个非字符串变量,那么首先将它转换成一个字符串,如下所示:
>>> string1 = "Your score is "
>>> score = 10
>>> string2 = string1 + str(score) + "points"
>>> print (string2)
Your score is 10 points
有一些替代技术可用于格式化字符串。第一种称为 printf 风格的格式。Python 文档现在不鼓励使用 printf 样式的格式,因为使用它时很容易出错。如果您在其他人的代码中遇到这种格式,识别它是很有用的。如果您遇到右引号后带有%的字符串,那么他们使用的是 printf 样式的格式:
>>> score = 20
>>> "Your score is %d points" % (score)
'Your score is 20 points'
格式化字符串的一种改进方法是使用 str.format()。它使用大括号{}来表示变量应该插入的位置。要创建与前面示例相同的,您应该输入
>>> score = 30
>>> "Your score is {} points".format(score)
'Your score is 30 points'
更好的方法是使用新的 f 弦。这些包括字符串主要部分的变量名,而不是把它加到末尾。
>>> score = 40
>>> f"Your score is {score} points"
'Your score is 40 points'
不幸的是,f 字符串仅在 Python 的最新版本(3.6 版或更高版本)中可用,在旧版本中将会失败。Raspbian 在 Buster image (2019)之前没有兼容版本。现在可以在 Raspberry Pi 上使用 f-strings,但是这种用法将仅限于那些运行 Raspbian 最新版本的用户。其他计算机升级到 Python 的最新版本也需要一些时间,因此您可能更适合使用 str.format 或者使用+字符将字符串连接在一起。本书中的代码主要使用串联或 str.format 方法,这取决于哪种方法更具可读性。
还有许多内置的字符串方法,在操作文本时会有所帮助。例如,如果您希望在比较文本时忽略大小写字母之间的差异,则可以使用 str.lower 方法将字符串转换为小写。这包含在清单 2-2 所示的笑话程序的改进代码中。
print ("Welcome to the Python joke program")
player_guess = input ("Why couldn't the engineer fix the computer?\n")
if (player_guess.lower() == "too many bits"):
print ("Well done!")
else:
print ("too many bits")
Listing 2-2Updated joke quiz program
笑话问答程序的更新版本现在将接受答案,不管玩家是否使用任何大写字母。
列表
前面提到的变量对于存储单个信息非常有用,但是当您需要存储更多信息时就有点受限了。为此,Python 提供了列表。
例如,如果您希望在一个问答游戏中有许多不同的问题,那么您可以创建一个名为 questions 1、question2 等的列表,而不是创建不同的变量。下面的两个列表显示了五个小测验的问题和答案。这将用于创建第一个游戏。
answers = ["Tetris", "Picade", "Python", "Sega", "luigi"]
questions = [
"What Russian tile matching game was popular in the 1980s?",
"What is the name of the Raspberry Pi arcade machine from Pimoroni?",
"What programming language has a logo featuring two snakes?",
"Which company created Sonic The Hedgehog?",
"What is the name of Mario's twin brother?"
]
我把答案放在第一位,因为它们更短,更容易理解。答案列表包含五个字符串。方括号表示这是一个列表,各个条目用逗号分隔。
如果条目长度超过几个单词,那么将每个条目放在单独的一行上通常更容易阅读代码。正如您在问题列表中看到的,这与带有方括号和分隔逗号的答案遵循相同的格式,但是每个条目都放在一个新行上,并且在每行的开头有四个空格字符,以指示这是同一个块的一部分。
可以通过使用列表名称后跟方括号中的索引位置来访问各个条目。与大多数编程语言一样,索引从位置 0 开始。以下示例显示了如何将第一个问题和答案打印到屏幕上:
>>> answers = ["Tetris", "Picade", "Python", "Sega", "luigi"]
>>>
>>> questions = [
... "What Russian tile matching game was popular in the 1980s?",
... "What is the name of the Raspberry Pi arcade machine from Pimoroni?",
... "What programming language has a logo featuring two snakes?"
... "Which company created Sonic The Hedgehog?",
... "What is the name of Mario's twin brother?"
... ]
>>> print (questions[0])
What Russian tile matching game was popular in the 1980s?
>>> print (answers[0])
Tetris
您也可以通过索引引用问题来更新问题。为了纠正没有以大写字母开始 Luigi 的故意错误,我们可以将其更新如下:
answers[4] = "Luigi"
若要向列表中添加问题,请使用 append 方法。
>>> questions.append("What is the name of the giant barrel throwing ape in Nintendo's classic game?")
>>> questions.append("Donkey Kong")
您也可以使用[]创建一个空列表。要存储玩家的猜测,您可以使用
>>> guesses = []
如果您决定要删除一个条目,那么可以使用 del 语句删除一个作为指定索引的条目。例如,要删除第二个问题,请使用
>>> del questions[1]
这将移动列表中的其余条目来填补空白,因此如果您希望保持两个数组的顺序,那么您需要对答案列表执行同样的操作。
您可以对列表做更多的事情,包括在指定位置插入条目,根据条目的值删除条目,甚至对整个列表重新排序。有关更多详细信息,请参见 Python 文档,附录 b 中包含一个链接。
Note
Python 还有一种不同的数据存储对象类型,称为数组。它的工作方式与列表类似,但首先需要导入。数组确实有一些优点,例如,如果您需要对整个数组执行数学运算。数组超出了本书的范围。如果您想了解更多信息,请参见附录 b 中的链接。
字典
当您希望根据索引位置访问数据时,列表是组织数据的一种有用方式,但有时您希望将信息与单词相关联。在这种情况下,您可以使用一个字典,其中每个条目都与一个键而不是一个数字位置相关联。
你可以把它想象成一本传统的字典,用一个词来索引,然后提供一个描述。Python 中的字典可以使用任何字符串作为索引,这就是所谓的键。描述可以是任何种类的变量或对象,称为值。
创建字典的方式与创建列表的方式类似,但是在字典周围使用大括号{},并使用键值对。
>>> dictionary1 = {'key1':'value1', 'key2':'value2'}
然后使用键而不是我们在列表中使用的数字索引来引用各个条目
>>> print (dictionary1['key2'])
value2
一个例子是,你的游戏根据用户选择的语言有不同的欢迎信息。您可以使用用户的语言作为密钥。
>>> welcome_message = {'english':'Welcome', 'french':'Bienvenue', 'german':'Herzlich willkommen'}
>>> language = 'french'
>>> print (welcome_message[language])
Bienvenue
元组
Python 中另一种常用的数据结构是元组。认为元组的最好方式是将其视为一个一旦创建就不能更改的列表(在编程“行话”中,这被称为不可变的)。这些通常在 Python 中作为返回值使用,其中需要返回多个值,或者表示具有多个值的对象,如 x,y 坐标。
要创建一个元组,只需创建一个用括号括起来的值列表。例如,下面可以表示一艘宇宙飞船的位置,其中 x = 10,y = 15。
position1 = (10,15)
当在屏幕上创建一个演员时,你会在第三章中看到使用元组的例子。
条件语句(if、elif、else)
条件语句提供了一种改变代码执行的方法。它们的工作方式是测试特定的条件,如果条件满足,就只运行部分代码。
您已经在清单 2-1 的早期代码中看到了一个 if 语句。这里重复了代码中处理 if 语句的部分:
3 if (player_guess.lower() == "too many bits"):
4 print ("Well done!")
5 else:
6 print ("too many bits")
在这种情况下,第 4 行的代码只有在满足条件时才运行。第 6 行的代码仅在不满足“else”定义的条件时运行。
if 语句计算直到冒号的所有测试或指令。这就是所谓的条件表达式。它确定条件语句的输出是真还是假。如果为 true,则运行 If 后面缩进的文本块。如果它不是真的,那么它将跳过该文本块。
“else”子句和相关的代码块是可选的。如果包含该条件,那么该代码将仅在不满足“if”条件时运行。
文本块的缩进很重要。我建议每个块每缩进四个空格。在 Mu 中,这通常是自动完成的,按 Tab 键会自动用正确的空格数替换它。在其他编辑器中,按 Tab 键可能会生成一个制表符而不是四个空格;这将阻止代码运行。
添加 if 语句时,您需要评估的值不一定是正确或错误的答案,在这种情况下,您可以使用比较运算符将其更改为正确或错误的答案。考虑一个游戏,当玩家在游戏中前进时,你增加不同数量的分数。一枚银币加 1 分,一枚金币加 5 分,一袋硬币加 10 分。如果玩家达到 100 分,他们就可以升级。这很容易在添加分数代码中实现,使用
if (score == 100):
level += 1
print ("Level up to "+str(level))
然而,这个代码有一个问题。如果玩家已经达到了 98 分,然后收集了一袋为他们赢得 10 分的硬币,那么他们的分数将增加到 108 分。该比较永远不会是真的,因为分数会增加得太快,并且他们不会满足分数等于 100 的条件。
相反,你需要检查分数是否等于或大于 100。尖括号“< >”可以用来检查某个值是小于还是大于某个值。因此
if (score > 99):
将检查 100 或更高。或者,您可以将它与等号结合起来,与大于或等于进行比较。因此
if (score >= 100):
如果分数等于 100 或大于 100,将起作用。
不同比较的总结如图 2-6 所示。
图 2-6
常见的比较运算符
如果您使用大于号代替相等测试来更改代码,那么您可能还需要更新代码的相关部分。如果在早期的代码中替换了大于或等于,那么每当玩家在 100 分之后得到一分,就会增加等级。因此,代码需要在一定范围内进行检查,而不仅仅是增加级别,比如
if (score >= 100 and score < 200):
level = 1
print ("Level up to "+str(level))
这增加了另一个测试,即逻辑and
操作符。使用and
语句,只有当左侧和右侧都为真时,才满足条件。
这是以的形式使用的
if (condition1 and condition2):
另一个逻辑运算符是or
运算符,如果任一条件为真,该运算符将计算为真。图 2-7 对此进行了总结。
图 2-7
逻辑运算符
乍一看,包括真假可能会觉得多余,但有时也可能有用。通常,这些可以用作循环中的条件(循环中的 True 运算符会创建一个永久循环),或者在调试代码时临时使用其中任何一个都很有用。
还有其他方法来评估真或假的条件。这可以通过返回值的函数或直接输入变量来实现。在这些情况下,如果一个值等于假或零,那么它被评估为假。对于任何其他返回值,该值计算为 true。这可能是返回值为正或为负,或者字符串为非空。当试图理解一个值将如何被解释时,这会引起一点混乱。如果有一些不明确的地方,那么我建议将它与一个已知的值进行比较,以使它变得清晰。
简单的问答游戏
在了解了一些基础知识之后,您现在应该可以使用之前创建的问题和答案列表来创建一个简单的测验了。
将清单 2-3 中的代码输入一个新文件。忽略为了使代码更容易解释而包含的行号。
1 # Simple quiz game
2
3 # Score starts at 0 - add one for each correct answer
4 score = 0
5
6 # List of questions
7 questions = [
8 "What Russian tile matching game was popular in the 1980s? ",
9 "What is the name of the Raspberry Pi arcade machine from Pimoroni? ",
10 "What programming language has a logo featuring two snakes? ",
11 "Which company created Sonic The Hedgehog? ",
12 "What is the name of Mario's twin brother? "
13 ]
14
15 # Answers - correspond to each question
16 answers = ["Tetris", "Picade", "Python", "Sega", "Luigi"]
17
18 print ("Welcome to the computer game quiz")
19
20 # Ask the first questions, store response in player_guess
21 player_guess = input (questions[0])
22 if (player_guess.lower() == answers[0].lower()):
23 # If correct say so and add 1 point
24 print ("Correct")
25 score += 1
26 else:
27 print ("Incorrect")
28
29 # Ask the second question
30 player_guess = input (questions[1])
31 if (player_guess.lower() == answers[1].lower()):
32 # If correct say so and add 1 point
33 print ("Correct")
34 score += 1
35 else:
36 print ("Incorrect")
37
38 # Ask the third questions
39 player_guess = input (questions[2])
40 if (player_guess.lower() == answers[2].lower()):
41 # If correct say so and add 1 point
42 print ("Correct")
43 score += 1
44 else:
45 print ("Incorrect")
46
47 print ("You scored {} points".format(score))
Listing 2-3Simple quiz game – quiz0.1.py
这个程序包含在附带的源代码 quiz0.1.py 中。
代码以一些以#字符为前缀的注释开始。
第 4 行创建了 score 变量,并将其初始值设置为 0。
第 6 到 16 行添加了问题和答案,如前所述。
在向玩家(18)发出欢迎消息后,第 21 到 27 行询问第一个问题,并使用 if 语句检查它是否正确。你会在第 22 行看到玩家的答案和正确答案都被转换成小写(。lower function)以便玩家是否使用大写字母输入答案并不重要。
第 29 到 36 行问第二个问题,然后第 38 到 45 行问第三个问题。
最后,第 47 行告诉玩家他们做得有多好。
如果您查看第 21 到 27 行、第 30 到 36 行以及第 39 到 45 行,您会注意到一些代码在代码块之间是重复的。除了问题编号之外,第一个问题的文本块与第二个和第三个问题的文本块相同。仅仅三个问题就浪费了很多代码,但是想象一下如果有更多的问题。如果您必须为每个新问题添加八行额外的代码(包括一个注释),那么这将会增加很多代码。这就是循环有用的地方。
循环–While,For
在条件语句之后,代码需要做的最重要的事情之一就是重复动作。这通常以循环的形式完成。
清单 2-3 中的测试代码展示了重复代码如何增加需要编写的代码量。这也意味着,如果您想要对代码进行更改,那么更改将需要跨多行进行,这不仅浪费时间,还会增加出错的风险。
当涉及到需要保持运行的代码时,循环甚至更加重要。如果你有一台街机,那么当每个人都玩完后,如果需要重启整个机器,那就没什么好处了。对于大多数电脑游戏来说,在“游戏结束”之后,你希望可以选择重新开始游戏,而不需要重启。
在 Python 中创建循环时,本质上有两种不同类型的循环。while
循环是最容易构建的,因此将首先讨论。
While 循环
可以通过演示来展示while
循环
num_times = 0
while (num_times < 10):
print ("This is line number "+str(num_times))
num_times += 1
如果在管理部门 REPL 中输入代码并按 enter 键,则应看到以下内容:
This is line number 0
This is line number 1
This is line number 2
This is line number 3
This is line number 4
This is line number 5
This is line number 6
This is line number 7
This is line number 8
This is line number 9
这将重复该命令十次。
要考虑的主要问题是 while 循环,它将在变量 num_times 小于 10 时运行。要循环运行,num_times 变量必须在每次循环中更新。
在这种情况下,变量在每个循环中递增一次,但有时变量可能会发生不同的变化。当玩家的分数小于某个值时,或者直到达到某个触发器时,可能需要运行循环。在以后的游戏中会有更多循环的例子。
For 循环
另一种方法是 for 循环。通常情况下,for
循环通常用于遍历一个列表。当您想给它一个列表并为列表中的每一项运行一些代码时,这很有用。
同样,这很容易通过一个例子来演示
questions = [
"What Russian tile matching game was popular in the 1980s?",
"What is the name of the Raspberry Pi arcade machine from Pimoroni?",
"What programming language has a logo featuring two snakes?"
"Which company created Sonic The Hedgehog?",
"What is the name of Mario's twin brother?"
]
for this_question in questions:
print (this_question)
这将依次打印出每个问题,并显示以下输出:
What Russian tile matching game was popular in the 1980s?
What is the name of the Raspberry Pi arcade machine from Pimoroni?
What programming language has a logo featuring two snakes?
Which company created Sonic The Hedgehog?
What is the name of Mario's twin brother?
看看 for 循环中的代码,它所做的是遍历问题列表并将当前值存储在一个名为this_question
的临时变量中。然后它打印出this_question
的内容。
另一个例子是你想运行一个循环固定的次数。这是之前使用的 while 循环的替代方法:
for x in range(0,10):
print ("This is line number "+str(x))
这一次,for 循环使用了 range 函数,该函数允许它在一个数字范围内进行迭代。实际上,它就像一个从第一个参数到第二个参数的数字列表(不包括第二个参数)。这将给出一个 0,1,2,3,4,5,6,7,8,9 的列表。还有第三个参数可以用来改变数字之间的步长。
所以range(0,10,2)
只会显示 0 到 9 之间的偶数。
该函数的格式为
range(start, stop, step)
start (optional if only one parameter is used) - the first number included
stop (required) - the maximimum value is one less than the stop value
step (optional) - the difference between each value
这些值可以是负数。如果你想递减计数,那么步长可以是-1,每次迭代递减一。
其他一些编程语言有不同的for
和foreach
循环。Python 的for
循环就像其他编程语言中的foreach
循环,但是通过range
函数,它可以像其他编程语言中的for
循环一样工作。
永久循环–为真时
while 循环的一个特殊情况是,它可以在条件设置为 True 的情况下运行。这意味着循环将永远运行。
while True:
print ("Program is still running")
我不建议你运行前面的代码,因为它会一直运行下去。实际上,永远可能是一个夸张的说法(但这是其他一些编程语言中使用的术语);循环实际上一直运行,直到你从外部停止程序,计算机停止,或者世界末日,无论哪一个先出现!
如果您确实运行了程序,那么您可以使用 Mu 中的 Stop 按钮或者 Ctrl-C(如果从命令行运行 Python)来取消。Ctrl-C 将发送一个信号告诉 Python 停止运行,并给出一个键盘中断错误消息。在命令行程序中包含永久循环是很常见的,尽管在 Pygame Zero 中不太常见,因为永久循环是在后台处理的。
您也可以看到使用while 1
的其他程序。当 1 的值为真时,情况也是一样的。
改变循环流程-中断并继续
如果想要一个“几乎永远”的循环会怎样?也许你想让程序永远运行下去,除非玩家要求退出。有两个语句可以用来改变循环中的流(这也适用于 for 循环),它们是 break 和 continue。
break 语句将导致程序在该点退出循环,然后在循环外运行代码。continue 语句使代码跳回到循环的开始,重新计算表达式,然后再次运行循环(如果满足条件)或退出循环(如果不满足条件)。
功能
函数是定义代码块的一种方式,这样就可以在程序的其他地方使用它。这些可以内置并包含在库中,或者您可以创建自己的库。
最流行的 Python 函数之一是 print 函数,它已经在本章的许多例子中使用过。在最基本的用途中,print 函数接受一个字符串,该字符串显示在控制台中。
print ("string")
本质上,当你调用一个函数时,当前的程序流会暂停。提供的任何参数都被传递给函数,函数中的代码运行,然后当函数完成时,流程返回到代码中的前一点。
您可以创建自己的函数,如清单 2-4 所示。
1 def ask_question (question, answer):
2 player_guess = input(question)
3 if (player_guess.lower() == answer.lower()):
4 print ("Correct")
5 return 1
6 else:
7 print ("Incorrect")
8 return 0
Listing 2-4Example of a Python function
再次注意,行号不会出现在代码中。
如果您试图运行它,这段代码不会做任何事情,而是应该作为一个更大的程序的一部分。在这种情况下,行号通常不会从 0 开始,因为函数通常不会是可执行文件中的第一个条目。
这与清单 2-3 (第 20 到 27 行)中使用的代码相同,但是使用了一个名为ask_question
的函数,并将问题和答案作为参数传递,而不是直接访问列表。
第一行使用了“def”语句,将它标识为一个函数。第 1 行的下一项是函数名ask_question
。函数名遵循与变量类似的约定,例如,它不能以数字开头,约定建议使用下划线字符而不是空格。括号用于包含需要传递给函数的任何参数。在这种情况下,有两个论点:问题和答案。第 1 行的最后一个字符是“:”字符,它表示函数的开始,函数的内容需要在下面缩进。
函数中不需要使用参数,但是如果没有参数,括号仍然是必需的。向函数传递参数时需要知道的一件重要事情是,函数会将作为参数传递的数据进行本地复制,因此当函数返回时,对这些变量所做的任何更改都会丢失。
除了 return 语句(稍后将解释)之外,函数体与前面的代码相同,并且函数中不使用列表代码中的条目,而是使用参数中提供的值。这意味着不同的参数可以传递给函数,而不必复制代码。
第 5 行和第 8 行的 return 语句用于结束函数并返回主代码。Return 语句并不总是必要的,因为如果到达函数的末尾,就有一个隐含的 return,但是如果您希望代码在到达函数末尾之前返回,或者如果函数需要传回一个值,则可以添加 return 语句。return 语句后面通常跟有要返回的值或变量,但是如果没有(或者没有 return 语句),则返回一个特殊值“None”。
变量作用域
变量可以在代码的主要部分或函数内部创建;范围定义了变量可以在哪里更新,可以是局部的,也可以是全局的。如果变量是在函数内部创建的,那么它将是一个局部变量,只能在函数内部使用。复制到局部函数中的参数也是如此。这允许多个变量具有相同的名称,这是代码重用的一个重要特性。它还可以防止意外更改另一个函数中的变量。
有时你需要访问在别处创建的变量。例如,如果有一个保存分数的变量,那么它可能需要由任何需要更新分数的函数来更新。为了实现这一点,应该在函数中使用 global 关键字,这样它就可以将变量作为全局变量来访问。
这是通过一个例子最容易理解的。清单 2-5 显示了示例代码来演示局部和全局变量的使用。
variable1 = 1
variable2 = 1
def local_function (variable1):
variable1 += 1
variable2 = 5
print ("variable1 in local_function {}".format(variable1))
print ("variable2 in local_function {}\n".format(variable2))
def global_function (argument1):
global variable1, variable2
variable1 = argument1 + 10
variable2 = 15
print ("variable1 in global_function {}".format(variable1))
print ("variable2 in global_function {}\n".format(variable2))
print ("variable1 in top level-code {}".format(variable1))
print ("variable2 in top level-code {}\n".format(variable2))
local_function (variable1)
print ("variable1 in top level-code {}".format(variable1))
print ("variable2 in top level-code {}\n".format(variable2))
global_function (variable1)
print ("variable1 in top level-code {}".format(variable1))
print ("variable2 in top level-code {}".format(variable2))
Listing 2-5Code demonstrating variable scope
运行时,它将产生清单 2-6 中所示的输出。
variable1 in top-level code 1
variable2 in top-level code 1
variable1 in local_function 2
variable2 in local_function 5
variable1 in top-level code 1
variable2 in top-level code 1
variable1 in global_function 11
variable2 in global_function 15
variable1 in top-level code 11
variable2 in top-level code 15
Listing 2-6Output of code demonstrating variable scope
有两个变量是在代码的顶层创建的(在任何函数之外)。有两个功能;local_function
展示了局部变量,而global_function
展示了如何改变全局变量。除了清楚地表明所指的是什么之外,命名没有任何意义。任何函数都可以有局部或全局变量的任意组合。
这两个变量都被设置为 1,第一个 print 语句证实了这一点。第一个变量作为参数传递给local_function
,它被定义为一个局部变量,只在函数内部可见。该值增加到 2,显示在函数内部,但函数完成后,原始变量不变。另一个名为 variable2 的变量被创建并设置为 5。在local_function
内使用时,显示值为 5,但这个变量只存在于函数内,在那个函数外,variable2
的值保持为 1。
在global_function
中,variable1
作为一个参数被传递,但被存储为一个名为argument1
的局部变量。variable1
和variable2
都通过全局语句被设置为全局变量,当它们在该函数中被更新时,它也会更新全局顶级变量中的值。
还有一件事。如果一个变量是在顶层创建的,然后在一个函数中读取而没有使用全局语句,那么顶层变量的值将被读取。该变量名不能用作局部变量的名称。
应该尽可能避免使用全局变量。这是因为在多个地方更新变量会导致代码难以理解和调试。如果你在代码中看到太多的全局变量,这有时被称为“不好的味道”。在使用 Pygame Zero 时(从下一章开始),你会看到使用了相当多的全局变量。这是 Pygame Zero 的一个特性,因为代码在 Pygame Zero 的不同函数中运行,如果不使用全局变量,很难将变量传递给这些函数。幸运的是,面向对象编程使这变得更容易,但这将在第九章讨论。在接下来的几章中,请接受将会有一些全局变量,但是在本书的后面情况会改变。
重构代码
现在,您已经学习了一些额外的编程技术的理论,您可以通过一个新的改进的测验将它付诸实践。游戏玩家不会注意到这个版本的任何不同,但我喜欢把它看作是“更好的代码”。
这就是所谓的重构代码。重构是对代码结构进行的更改,这种更改通常不会增加任何额外的功能,但会使代码更清晰、更易于理解。它还可以用来简化新特性的添加。
新代码如清单 2-7 所示,我将其命名为 quiz0.2.py。
# Simple quiz game
def ask_question (question, answer):
player_guess = input(question)
if (player_guess.lower() == answer.lower()):
print ("Correct")
return 1
else:
print ("Incorrect")
return 0
# List of questions
questions = [
"What Russian tile matching game was popular in the 1980s? ",
"What is the name of the Raspberry Pi arcade machine from Pimoroni? ",
"What programming language has a logo featuring two snakes? ",
"Which company created Sonic The Hedgehog? ",
"What is the name of Mario's twin brother? "
]
# Answers - correspond to each question
answers = ["Tetris", "Picade", "Python", "Sega", "Luigi"]
while True:
print ("Welcome to the computer game quiz")
# Score starts at 0 - add one for each correct answer
score = 0
for i in range (0,len(questions)):
if (ask_question (questions[i], answers[i]) == True):
score += 1
print ("You scored {} points\n".format(score))
Listing 2-7Refactored version of simple quiz game – quiz0.2.py
这个改进的测验从一个名为ask_question
的函数开始。这个函数向玩家提问,检查是否有正确的回答,并根据提供的答案是否正确返回 1 或 0。该函数位于代码的开头,因为它需要在被调用之前被定义。
然后,问题和答案被存储为列表。列表的顺序是这样的,问题和答案有相同的索引。代码的其余部分被包装在 while True 循环中,以便在回答完问题后,测验会返回到开始。
进一步的改进
就像本书中的所有游戏一样,这是一个有效的游戏,但是还有改进的空间。一些改进的想法是添加更多的问题(或者为您感兴趣的主题更改它们),选择随机出现的问题,并根据正确回答的问题数量更改输出以给出不同的短语。
试着添加这些,看看你是否能让游戏更有趣。您需要使用随机模块来随机选择问题;你可以在网上找到细节,或者在下一章解释后回到这里。在提供的源代码中,我包含了一个将所有这些合并为 quiz0.3.py 的示例,尽管我建议您在查看代码之前尝试一下进行自己的更改。
摘要
这一章非常简要地介绍了 Python 编程语言。本书篇幅不够,无法详细解释 Python。从现在开始,本书将假设读者对 Python 编程语言有所了解。如果你需要更多关于 Python 入门的信息,那么我推荐 Apress 出版社出版的 M. L .赫特兰德的《Python 入门》一书。
我还在附录 b 中包含了官方 Python 文档的链接。
下一章将继续使用 Pygame Zero 创建图形游戏。
三、Pygame Zero
到目前为止,这些程序都是基于文本的,但如果我们仅仅停留在基于文本的游戏上,它们就不会像图形游戏那样有吸引力。这本书的其余部分是关于图形游戏,将使用 Pygame Zero 创建。
要了解 Pygame Zero 是什么,首先需要了解 Pygame 是什么。Pygame 是一个 Python 的编程库。它是为更容易地创建多媒体应用(如游戏)而设计的。它还可以在多个平台上运行,使得与其他电脑共享你制作的游戏变得更加容易。
虽然 pygame 使用 Python 创建游戏变得容易多了,但在开始之前,它仍然需要相当数量的标准代码,即样板代码。
Pygame Zero 是一个较新的编程库,它使用 Pygame,但是去掉了许多样板代码,使得创建游戏更加容易。设计用于教育,以帮助教学编程,这是一个伟大的方式开始创造电脑游戏。
Pygame 零开发
在撰写本文时,Pygame Zero 是一个正在进行的项目,定期进行改进。幸运的是,大多数变化都保持了向后兼容性,但一些新功能不会在 Pygame Zero 的所有安装上工作。如果您确信您编写的游戏只能在当前或更高版本上运行,那么您可以将它们包含到您的游戏设计中,但是如果您的游戏能够在更广泛的计算机上运行更重要,那么您可能希望将自己限制在与 Pygame Zero 库的旧版本兼容的功能上。
一个例子是,你现在可以在资源(如图像文件)上包括文件路径和文件扩展名,但这在 Pygame Zero 的旧版本(2018 年夏季之前)上不起作用。本书中的代码已经在 Pygame Zero 的最新版本上进行了测试,但是在我意识到向后兼容性问题的地方,我已经尝试编写代码以便在旧版本中也能工作。
指南针游戏
第一个图形游戏将被称为指南针游戏。指南针游戏的灵感来自于我志愿加入的童子军玩的一个游戏;这又是一个游戏的变种,叫做“船长来了”。该游戏用于帮助教授四个基本方向(指南针的点)。在真实(非电脑)游戏中,童子军大厅的每面墙上都贴有一个标签。幼仔被给予一个方向,他们必须跑向合适的墙。还可以给出额外的指令,比如队长来了,幼崽必须站着不动,敬礼。
在这一章中,你将创建一个电脑版本的游戏,玩家得到一个必须遵守的指令。玩家必须按照指定的方向移动他们的角色。游戏如图 3-1 所示。
图 3-1
指南针游戏截图
这将提供一个学习 Pygame Zero 的机会,以及如何让一个角色在屏幕上走动。这将使用敏捷方法创建,一次添加一个功能来创建游戏。
必需的文件
这个项目需要几个图像文件。这些需要放在游戏源代码正下方的图像目录中。
您将需要第三章目录中源代码的文件,然后是子目录 images。如果您正在使用管理部门编辑器,那么它们应该被复制到目录/home/pi/mu_code/images
中。
如果您在提供的源代码的第三章目录中查找,您会看到许多以 compassgame 为前缀的 Python 文件。游戏要经历多次迭代,在游戏开发的不同阶段都会用到这些文件。如果你是按照电脑上的说明来做的,那么你只需要使用一个名为 compassgame.py 的文件来创建游戏,这个文件将贯穿本章。如果您想直接跳到每个阶段的代码,而不是自己键入代码,可以使用源代码中提供的文件。
在 Pygame Zero 模式下运行 Mu
游戏应该在 Mu 中创建为一个新文件。你需要把游戏模式调到零。这可以通过单击编辑器左上角的模式图标来实现。如图 3-2 所示。
图 3-2
在管理部门编辑器中更改为 Pygame Zero 模式
首先将下面两行添加到文件中,然后将其保存为 compassgame.py。
WIDTH = 800
HEIGHT = 600
然后点击 Mu 中的 Play 按钮,你应该会看到一个黑屏,800 像素宽,600 像素高。第一个例子很好地展示了为什么 Pygame Zero 如此易于使用。仅仅定义屏幕的尺寸就足以创建一个游戏窗口。事实上,这可能是使用一个空文件启动的,因为这些是默认值。这比使用 Pygame 的等价代码要少,也更容易理解。
您可以通过单击右上角的 x 或按管理部门菜单栏中的停止来关闭程序。
如果您没有使用 Mu,那么可以在任何其他编辑器中创建该文件,但是应该使用以下命令从命令行运行:
pgzrun compassgame.py
这段代码可以在 compassgame-layout1.py 文件的源代码中找到。
Tip
记住,如果菜单中有 run 菜单项而不是 play,那么你需要切换到 Pygame Zero 模式。单击左上角的模式按钮选择您的模式。
添加背景图像
既然您已经知道了如何创建一个基本的 Pygame Zero 应用,那么是时候添加一些更有趣的东西了。你可以从用一些更有趣的东西替换普通的黑色背景开始。
用清单 3-1 中的代码替换当前代码。
WIDTH = 800
HEIGHT = 600
BACKGROUND_IMG = "compassgame_background_01"
def draw():
screen.blit(BACKGROUND_IMG, (0,0))
Listing 3-1Simple Pygame Zero program with image background
该代码在提供的源代码中以 compassgame-layout2.py 的形式提供。
单击 Play 按钮,您现在应该会看到和以前一样的屏幕,但是它现在会有一个绿色的背景图像。如果不起作用,请确保您已经将图像复制到正确的目录中。mu_code/images 目录下应该有一个文件 compassgame_background_01.png。
该代码通过创建一个变量 BACKGROUND_IMG 来工作,该变量包含要显示的文件的名称。图像作为文件名输入,没有任何路径信息或。png 后缀。在最新版本的 Pygame Zero 上,如果您愿意,可以使用完整的文件名,但是为了保持与旧版本 Pygame Zero 的兼容性,文件必须位于 image 文件夹中,并且不包含后缀。这对于任何用作演员和背景的图像文件都是一样的。
第def draw()
:
行定义了绘图功能。这是一个 Python 函数,Pygame Zero 每秒大约调用 60 次。它应该用来告诉 Pygame Zero 屏幕上应该显示什么。
该函数调用screen.blit
,它在适当的位置显示位图图像(在本例中,从屏幕的左上角开始显示 0,0)。
Note
使用这么长文件名的原因是因为默认情况下,Mu 将所有代码放在同一个目录中。如果创建多个 Python 程序,它们都共享同一个图像目录。像这样命名它们使得哪个文件用于哪个程序变得很明显。
如果你正在使用一个不同的编辑器或者已经将你的游戏组织到一个专用的目录中,那么你可能想要从文件名的开头删除 compassgame_ 前缀。
图像文件名也包括一个数字,这将允许我们改变背景或人的外观。
添加演员
在计算机图形学中,角色和其他物体被称为精灵。在 Pygame Zero 的例子中,它用一个更“友好”的名字来称呼精灵演员。我经常称这些为精灵,因为这是正确的计算术语,但是请记住在 Pygame Zero 中定义它们时要将它们添加为 Actor 对象。
精灵是计算机游戏中使用的图像,通常由位图图像创建。这些通常以人物(人、动物、外星人等)的形式出现。),但它们也可以用于玩家需要与之交互的对象,如障碍物、球或武器发射的子弹。
在这种情况下,您可以从代表玩家角色的单个精灵开始。稍后你可以添加更多精灵作为障碍来增加挑战。
玩家角色的精灵需要几个图像,这样你就可以显示它面向不同的方向,让它看起来在移动。最少需要的是一个人物面向以下每个方向的图像:前、右、左、后。为了使运动更加真实,可以使用额外的图像,使腿在图像之间移动。在第五章中,你将会看到如何设计你自己的精灵角色,但是现在你可以使用源代码中包含的精灵。最简单的方法是从 mu_code 的 image 子目录中的源代码的 image 目录中复制文件。这个游戏的精灵是一个人,但是它可以被一个动物或者一个完全不同的角色代替,比如一辆汽车。
要创建 sprite,请使用带有图像文件的 Actor 对象。
player = Actor('imagefile')
与前面提到的背景图像相同的规则也适用于图像。如果您想要最大程度的兼容性,请使用位于映像目录中的不带扩展名的映像名称。如果使用 Pygame Zero 的最新版本,可以包含扩展名和文件位置的路径。
要将 sprite 定位在屏幕的特定位置,您可以随后将该位置添加为一个元组。下面将在屏幕中央创建一个精灵:
player = Actor('compassgame_person_down_1', (WIDTH/2,HEIGHT/2))
坐标系从屏幕的左上角开始。x 坐标向右增加,y 坐标向下增加。这与图形和地图的工作方式不同。图 3-3 中的图像显示的是游戏画面,其中标注了一些关键坐标。
图 3-3
Pygame 零屏幕坐标
除了创建演员,您还需要包含将它绘制到屏幕上的代码。这是通过将以下条目放入 draw 函数来实现的:
player.draw()
清单 3-2 中显示了演示这一点的代码,它作为 compassgame-player.py 包含在源代码中。
WIDTH = 800
HEIGHT = 600
BACKGROUND_IMG = "compassgame_background_01"
#Player character
player = Actor('compassgame_person_down_1', (WIDTH/2,HEIGHT/2))
def draw():
screen.blit(BACKGROUND_IMG, (0,0))
player.draw()
Listing 3-2Simple Pygame Zero program with player actor
在屏幕上移动精灵
现在您已经创建了一个 sprite(演员),您可以从键盘读取按键,并使播放器向按键的方向移动。
为了更容易测试一个键是否被按下,Pygame Zero 为每个键提供了一个属性。要测试向上箭头键是否被按下,您应该检查“keyboard.up”的值。如果值为真,则按下向上键,如果值为假,则不按下。
你不会用这个方法从玩家那里得到文本输入,因为它不会告诉你按键的顺序。然而,它对于游戏编程是有用的,其中只有少量的键可以被按下,并且可以同时按下多个键(例如向上和向右以对角移动)。
当你知道向哪个方向移动玩家时,你可以改变 x 和 y 属性来移动角色一定的像素。
移动角色的代码如清单 3-3 所示。用更新后的代码替换当前代码。
WIDTH = 800
HEIGHT = 600
BACKGROUND_IMG = "compassgame_background_01"
#Player character
player = Actor('compassgame_person_down_1', (WIDTH/2,HEIGHT/2))
# Direction that player is facing
direction = 'down'
def draw():
screen.blit(BACKGROUND_IMG, (0,0))
player.draw()
def update():
# Need to be able to update global variable direction
global direction
# Check for direction keys pressed
# Can have multiple pressed in which case we move in all the directions
# The last one in the order below is set as the direction to determine the
# image to use
new_direction = "
if (keyboard.up):
new_direction = 'up'
move_actor(new_direction)
if (keyboard.down):
new_direction = 'down'
move_actor(new_direction)
if (keyboard.left) :
new_direction = 'left'
move_actor(new_direction)
if (keyboard.right) :
new_direction = 'right'
move_actor(new_direction)
# If new direction is not "" then we have a move button pressed
# so set appropriate image
if (new_direction != ") :
# Set image based on new_direction
player.image = "compassgame_person_"+new_direction+"_1"
direction = new_direction
def move_actor(direction, distance = 5):
if (direction == 'up'):
player.y -= distance
if (direction == 'right'):
player.x += distance
if (direction == 'down'):
player.y += distance
if (direction == 'left'):
player.x -= distance
# Check not moved past the edge of the screen
if (player.y <= 30):
player.y = 30
if (player.x <= 12):
player.x = 12
if (player.y >= HEIGHT - 30):
player.y = HEIGHT - 30
if (player.x >= WIDTH - 12):
player.x = WIDTH - 12
Listing 3-3Code to allow the character to move around the screen
这包含在名为 compassgame-movement1.py 的源代码中。
到目前为止,您应该能够理解大部分代码,但是有一些新的东西可能需要解释。
new_direction 变量是更新函数内部的局部变量。它用来保持它检测到的最后一个键被按下的方向(所以如果你向上和向右按,它会保持向右)。这样做是为了当两个键都被按下时,角色不会在向上和向右之间改变,但在稍后让角色的腿移动时也很有用。由于 new_direction 存储为字符串,因此可以使用以下行将其包含在播放器图像中:
player.image = "compassgame_person_"+new_direction+"_1"
如果玩家面向右侧,将显示图像compassgame_person_right_1.png.
添加了一个名为 move_actor 的新函数。顾名思义,这会移动演员在屏幕上的位置。第一个参数是移动的方向。该函数的第二个参数被定义为“distance = 5
”。这意味着如果向函数提供了一个值,那么该值将存储在距离变量中,但是如果参数中没有传递任何内容,那么距离变量将被设置为 5。当您想要包含参数的默认值时,这很有用。
移动演员时,代码会检查位置,以确保角色不会超出屏幕边缘。它使用 30 个像素的 y 偏移值和 12 个像素的 x 偏移值,这样整个演员都保留在屏幕上。
使动作更加真实
如果您运行清单 3-3 中的代码,您将看到角色四处移动并面向他们行走的方向,但这看起来并不特别真实。由于腿不动,玩家看起来像是在滑行而不是在行走。为了使运动看起来更真实一点,我们可以改变图像来显示人的腿在运动。
使用的技术类似于制作卡通的方式,每一帧都是通过轻微的移动单独绘制的。然后这些画面一个接一个地显示出来,形成一幅动态图像。在一幅典型的漫画中,他们每秒钟的运动可能会产生大约 20 幅图像。理论上,在每次运行更新功能时都有可能改变图像,大约每秒 60 次;然而,为了简单起见,代码只在第五次调用更新函数时更新。这将给出每秒 12 帧的帧速率。要实现这一点,需要玩家移动的每个方向都有四幅图像。图 3-4 显示了用于右侧方向的四幅图像。
图 3-4
向右走的四个精灵图像
在这个例子中,只有腿在移动,但是你可以让手臂也在移动,这样会更真实一点。
每个方向使用 4 个图像需要 16 个不同的图像。如果你想提高帧速率,那么你可以增加图像的数量。如果你想移动角色的次数增加一倍,你可以将每张图片之间的延迟减半,并将图片的数量增加一倍,达到 32 张。
以前,通过更新 actor 属性来更改图像。为了允许显示不同的图像,可以将此更改为对名为 set_actor_image 的新函数的函数调用,该函数将基于行进方向和序列中的适当图像来确定正确的图像。
要更新前面的代码以显示字符的工作情况,请执行以下步骤。
在代码顶部附近添加一个新的全局变量 player_step_count。它可以放在方向变量的定义之后。
player_step_count = 1
更换管路
player.image = "compassgame_person_"+new_direction+"_1"
随着
set_actor_image (new_direction)
然后将以下代码添加到文件的底部:
# Show image matching new_direction and current step count
def set_actor_image (new_direction):
global player, player_step_count
player_step_count += 1
if player_step_count >= 4:
player_step_count = 1
player.image = "compassgame_person_"+new_direction+"_"+str(player_step_count)
更新后的代码以 compassgame-movement2.py 的形式包含在源代码中。如果您现在运行代码,那么您会看到腿在移动,但速度会太快。它仍然需要代码通过每隔 5 帧替换图像来减慢运动。
这是通过允许 player_step_count 一直计数到图像数量的五倍,然后将图像数量除以 5 来实现的。然后,代码将丢弃任何余数,然后加 1(从 1 开始图像编号,而不是从 0 开始)。
This is best illustrated by working through some examples.
With player_step_count set to 0
Divide player_step_count (0) by the delay (5) giving 0.0
Discard anything after the decimal place which gives 0
Add 1 to get image number 1
With player_step_count set to 1
Divide player_step_count (1) by the delay (5) giving 0.2
Discard anything after the decimal place which gives 0
Add 1 to get image number 1
With player_step_count set to 5
Divide player_step_count (5) by the delay (5) giving 1.0
Discard anything after the decimal place which gives 1
Add 1 to get image number 2
With player_step_count set to 19
Divide player_step_count (19) by the delay (5) giving 3.8
Discard anything after the decimal place which gives 3
Add 1 to get image number 4
With player_step_count set to 20, the maximum value has been exceeded so set back to 0 and recalculate the value.
其中大部分使用基本运算,但是要丢弃小数点后的值,您将需要函数 floor(),它包含在 math 模块中。floor 函数定义为返回小于或等于 x 的最大整数值。
数学模块包括几个数学函数,在创建游戏时会很有用。更多详情可查阅 https://docs.python.org/3.5/library/math.html
。
要导入数学模块,请在代码顶部添加以下行:
import math
然后更新 set_actor_image 函数(它被添加到代码的底部)以匹配以下内容:
# Show image matching new_direction and current step count
def set_actor_image (new_direction):
global player, player_step_count
step_delay = 5
player_step_count += 1
if player_step_count >= 4 * step_delay:
player_step_count = 1
player_step_position = math.floor(player_step_count / step_delay) +1
player.image = "compassgame_person_"+new_direction+"_"+str(player_step_position)
更新后的文件作为 compassgame-movement3.py 包含在源代码中。
如果您运行更新的代码,您应该会看到腿以更真实的速度移动。
保持游戏状态
编程中的一个重要概念是能够跟踪程序所处的状态。这是程序需要跟踪过去发生的事情以及影响它如何处理未来事件的地方。
如果你从棋盘游戏的角度来考虑,那么最初的状态可能是你把游戏从盒子里拿出来,并把适当的指示物放入每个位置。一旦游戏设置好了,那么可能会有另一个状态来决定谁将首先开始游戏(可能基于骰子的滚动)。
然后,当游戏开始时,每个人之间的状态将依次改变,以便他们滚动设备,移动到下一个位置,并执行任何所需的动作。最后,当一个玩家达到目标时,会有一些胜利的状态。
在电脑游戏中,这是需要使用一个或多个变量来跟踪的东西。如果游戏代码正在显示菜单屏幕,而不是游戏已经在进行中,那么游戏代码可以不同地处理按键。变量可以是任何东西,从具有特定会议的单个数字到具有多个属性的完整类。
对于这个游戏,代码需要跟踪两件事。一个是游戏的状态,让它在游戏结束时不会一直在屏幕上移动角色,另一个是玩家需要往哪个方向移动。这些可以合并成一个变量,但是为了更容易理解,这个例子使用了两个独立的变量,一个叫做 game_state,另一个叫做 target_direction。
第一个变量称为 game_state,它跟踪游戏中的不同阶段。当游戏还没有开始时,这是一个空字符串;当游戏正在进行时,这是一个“正在玩”的字符串;当游戏已经结束时,这是一个“结束”的字符串。
总之:
-
"–游戏未开始
-
“玩”——游戏正在进行中
-
“结束”——游戏结束
对于目标方向,变量可以是不同的基本方向(罗盘上的四个主要方向)。这些是“北”、“东”、“南”和“西”,如图 3-5 所示。
图 3-5
罗盘上的四个点
代码将被更新以生成随机方向。因此,需要通过在文件顶部添加以下条目来导入随机模块:
import random
通过在文件顶部附近添加以下行来添加变量(例如在 BACKGROUND_IMG 条目之后):
game_state = "
target_direction = "
在更新函数顶部附近,用以下内容替换global direction
行:
global direction, game_state, target_direction
# If state is not running then we give option to start or quit
if (game_state == " or game_state == 'end'):
# Display instructions (in draw() rather than here)
# If space key then start game
if (keyboard.space):
game_state = "playing"
target_direction = get_new_direction()
# If escape then quit the game
if (keyboard.escape):
quit()
return
在文件的底部,添加以下函数:
def get_new_direction():
move_choices = ['north', 'east', 'south', 'west']
return random.choice(move_choices)
这段代码将处理游戏开始时的状态。
如果游戏没有进行,那么它等待玩家按下开始键,在这个例子中是空格键。如果按下该按钮,它会将状态设置为正在播放,并分配一个新的目标方向。
get_new_direction 函数有一个不同方向的列表,并使用 random choice 来随机选择一个方向。
这在源代码中以 compassgame_state1.py 的形式提供。
你现在可以再次运行游戏。记住你现在需要按下空格键,玩家才能被移动。
接下来要添加的是告诉玩家走哪条路的方法。这可以通过使用 screen.draw.text()来完成,它将在屏幕上显示文本。用以下代码替换当前的 draw 函数:
def draw():
screen.blit(BACKGROUND_IMG, (0,0))
# If game not running then give instruction
if (game_state == "):
# Display message on screen
screen.draw.text("Press space bar to start", center=(WIDTH/2,HEIGHT/2), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
elif (game_state == 'end'):
screen.draw.text("Game Over\nPress space bar to start again", center=(WIDTH/2,HEIGHT/2), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
else:
screen.draw.text(target_direction, center=(WIDTH/2,50), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
player.draw()
新的 draw 功能根据游戏状态显示三个不同的文本块。第一个方块是当game_state = "
,这种情况下它指示玩家按空格键开始游戏。第二个块由 elif (else if)控制,它检查游戏是否结束,第三个块是游戏正在进行时。没有必要检查正在玩游戏的状态,因为如果不是前两个状态,那么它一定是在玩状态。
只有在玩游戏的时候才会调用player.draw
,否则文本会覆盖玩家。现在还不可能到达游戏的终点。这将是以后要实施的事情。
这段代码有趣的地方在于显示文本的部分。以下是第一个条目的详细信息,但其他条目都以类似的方式工作:
screen.draw.text("Press space bar to start", center=(WIDTH/2,HEIGHT/2), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
text 方法接受一个要显示的字符串和一个位置;其余的参数是可选的。只需将一个元组作为第二个参数,例如(10,10),就可以输入位置。在这种情况下,文本在屏幕中央看起来更好,因此元组被传递给 center 参数。它使用宽度和高度值的一半来确定位置。
这里使用的其他可选参数有
-
fontsize–用于设置字体的大小;默认值为 24。
-
阴影–给文本添加阴影;这些值是阴影位置的 x 和 y 偏移。
-
颜色–文本的颜色。
-
颜色
如您所见,代码使用不同的方式输入颜色。您可以使用一些不同的颜色格式,例如(r,g,b),其中(255,255,255)是白色,或者 html 颜色字符串,其中“#202020”是浅灰色。有关如何创建颜色的更多详细信息,请参见第六章。
Note
由随机模块创建的随机数是伪随机的。计算机很难创建真正的随机数,因此它们有办法生成对最终用户来说看起来是随机的数字。根据操作系统和硬件的不同,它可能包括不太确定的来源,如时间和鼠标移动,使其不太可预测。这对于游戏来说通常已经足够了,但是如果将它用于加密目的,您可能需要查看其他随机来源。
检测碰撞
如果到目前为止你已经遵循了代码(或者运行了 compassgame-movement3.py ),那么你现在应该有一个可以在屏幕上工作的角色了。下一步是检测玩家何时移动到屏幕的正确一侧。在这种情况下,角色靠近那一边就足够了,而不是在最边上,因为这比必须停在那一边更自然一些。实现的方法之一是创建代码来查看字符的位置,并检查它是否达到某个阈值。虽然这是一种有效的方法,但是如果你改变了字符的大小(可能是不同的高宽比),你可能需要更新代码来处理这个问题,这就有点不灵活了。相反,Pygame Zero 提供了一个很好的功能,允许它检查冲突。
不幸的是,Pygame Zero 文档没有提供太多关于检测碰撞的信息。Pygame Zero 使用标准的 Pygame 方法,这些方法在 Pygame 文档中有很好的记录(参见附录 B 中的链接)。
碰撞检测通常用于检测两个精灵(演员)是否碰撞。要理解这一点,你需要知道 Pygame 中的所有精灵都有一个 Rect 属性。这是当你通过 Pygame Zero 创建一个演员时自动创建的。Rect 是一个你看不见的虚拟矩形。它是完全包含图像大小的最小矩形。如图 3-6 所示,在演员周围添加了一个边框。
图 3-6
带边框的演员
collideRect 方法可用于确定一个矩形是否与另一个矩形重叠。例如,考虑一个汽车游戏,其中两辆汽车在游戏区域内移动,您想知道它们是否会相撞。如果您有名为 car1 和 car2 的演员,您可以使用
if car1.collideRect(car2):
print ("Car 1 and Car 2 have collided")
回到游戏,目前在做《罗盘游戏》;对于这个特殊的检测,我们实际上不需要创建一个演员来碰撞,我们只需要知道他们何时接近屏幕的边缘。相反,我们可以创建一个具有适当尺寸的简单 Rect 对象。然后,如果演员与这些矩形中的一个碰撞,我们知道它们在那个区域。图 3-7 中的图像显示了游戏的布局,矩形显示在游戏网格上。在这张图片中,矩形被夸大了,以便更容易看到。
图 3-7
检测玩家接近屏幕边缘的碰撞矩形
你会看到矩形重叠。这不是这个游戏的问题,因为我们只是检查玩家是否达到了其中之一,但这是你在创建其他游戏时需要注意的。
以下代码用于创建矩形,可在 draw 函数之前添加:
#Rectangles for compass points for collision detection to ensure player is in correct position
box_size = 50
north_box = Rect((0, 0), (WIDTH, box_size))
east_box = Rect((WIDTH-box_size, 0), (WIDTH, HEIGHT))
south_box = Rect((0, HEIGHT-box_size), (WIDTH, HEIGHT))
west_box = Rect((0, 0), (box_size, HEIGHT))
前面的矩形是不可见的,这就是我们想要的。暂时显示矩形是一个好主意,因为它可以帮助显示任何矩形是否在错误的位置。为此,您可以将以下内容添加到 draw 函数中(在 screen.blit 之后)。这还包括一个围绕玩家的盒子,并为每个矩形使用不同的颜色。
screen.draw.rect(north_box, (255,0,0))
screen.draw.rect(east_box, (0,255,0))
screen.draw.rect(south_box, (0,0,255))
screen.draw.rect(west_box, (255,255,255))
screen.draw.rect(Rect(player.topleft, player.size), (0,0,0))
源代码以 compass game-collidel 1 . py 的形式包含在内。对该版本做一点小小的改动是一个好主意,这样您就可以看到当您在屏幕上移动时演员周围的矩形也在移动。每当玩家周围的方框与其他矩形重叠时,就可以检测到冲突。
要检测冲突,您可以将以下代码添加到更新函数的底部:
if (player.colliderect(north_box)):
print ("Collided with North")
if (player.colliderect(south_box)):
print ("Collided with South")
if (player.colliderect(east_box)):
print ("Collided with East")
if (player.colliderect(west_box)):
print ("Collided with West")
这作为 compass game-collider 2 . py 包含在源代码中。现在,如果你运行这个程序,如果你在 Mu 的控制台上观看(或者在终端上观看,如果你从那里启动的话),每当玩家进入一个矩形时,你会看到几条打印消息。
这有利于测试,但是您现在应该在下一阶段之前用 colliderect 和 draw.rect 语句删除代码块。
使用矩形是一种执行碰撞检测的便捷方式,对于这个游戏来说足够好了。如果使用其他游戏,你可能需要考虑精灵如何互动,特别是当他们周围有很多“空白”的时候。如果你有一个没有填满矩形的形状,那么如果一个玩家实际上没有接触到另一个物体,但是矩形重叠了,这可能会让玩家感到沮丧。相反,可以使用碰撞点方法在特定点上进行测试,或者在您自己的代码中实现更精确的碰撞检测。
方向的改变
现在你可以添加代码来处理玩家到达目标时的情况。一旦他们到达要求的区域,玩家需要被告知他们下一步需要去哪里。玩家应该在被告知下一个目标之前移动到新的位置,以此类推。
删除打印出冲突通知的代码后,在更新函数底部的位置添加以下内容:
if (reach_target(target_direction)):
target_direction = get_new_direction()
此外,在更新功能后添加以下内容:
def reach_target(target_direction):
if (target_direction == 'north'):
if (player.colliderect(north_box)):
return True
else:
return False
elif (target_direction == 'south'):
if (player.colliderect(south_box)):
return True
else:
return False
elif (target_direction == 'east'):
if (player.colliderect(east_box)):
return True
else:
return False
elif (target_direction == 'west'):
if (player.colliderect(west_box)):
return True
else:
return False
更新函数中的额外代码将使用函数 reach_target 检查玩家是否到达了他们的目标目的地。
如果玩家与当前目标方向相关的盒子发生碰撞,则 reach_target 函数返回 true。如果不是,则返回 false。
这段代码可以从 compassgame-collide3.py 获得。
如果你运行这个游戏,你应该在屏幕的顶部看到指令,如果你去指定它们的一边,那么你会得到一个新的指令。
记分
要添加评分机制,只需要有一个变量,每次达到目标时都会更新。要实现这一点,需要创建一个新的全局变量来保存当前的分数。
# Current score for this game
score = 0
这需要是一个全局变量,所以在 update 函数中,将全局行更新为
global direction, game_state, target_direction, score
每次游戏开始都需要重置分数,所以在 game_state 设置为“正在玩”的文本块中添加score = 0
。
要增加分数,在更新函数的底部,在 if 语句中添加 score += 1,该语句检查是否达到目标。
因此
if (reach_target(target_direction)):
target_direction = get_new_direction()
成为
if (reach_target(target_direction)):
target_direction = get_new_direction()
score += 1
这将记录分数。要在屏幕上显示它,您可以更新 draw 函数来显示分数。首先将它作为一个全局变量添加到抽奖开始处,然后在最后的 else 文本块中添加以下内容,以便在游戏进行过程中显示分数。您可以将它放在调用 player.draw()之前。
screen.draw.text('Score '+str(score), fontsize=60, center=(WIDTH-130,50), shadow=(1,1), color=(255,255,255), scolor="#202020")
你也可以在游戏结束部分添加最终得分。
screen.draw.text("Game Over score "+str(score)+"\nPress space to start", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
draw 函数将如下所示:
def draw():
global score
screen.blit(BACKGROUND_IMG, (0,0))
# If game not running then give instruction
if (game_state == "):
# Display message on screen
screen.draw.text("Press space bar to start", center=(WIDTH/2,HEIGHT/2), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
elif (game_state == 'end'):
screen.draw.text("Game Over "+str(score+"\nPress space bar to start again", center=(WIDTH/2,HEIGHT/2), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
else:
screen.draw.text(target_direction, center=(WIDTH/2,50), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
screen.draw.text('Score '+str(score), fontsize=60, center=(WIDTH-130,50), shadow=(1,1), color=(255,255,255), scolor="#202020")
player.draw()
这作为 compassgame-score.py 包含在源代码中。
如果您运行更新的代码,您将看到分数随着您达到每个目标而增加。
添加倒计时器
最后,需要有一些东西使它成为一个挑战。否则,你可以永远在两边之间徘徊。如果没有任何形式的挑战,我敢肯定大多数人会很快厌倦。
为了增加挑战,将会有一个计时器,这样玩家需要在设定的时间内在屏幕上移动。计时器将在固定时间启动,例如 10 秒,给玩家时间到达目标。如果他们成功了,那么计时器将被重置,但会稍微递减,使其变得更难。如果他们不能在规定时间内完成,那么游戏就结束了。
计算时间的一种粗略方法是考虑更新功能的运行频率。在 Pygame Zero 中,更新函数通常每秒被调用 60 次,大约 0.016 秒,所以通过计算函数被调用的次数,你可以计算出玩家完成任务需要多长时间。这样做的问题是循环的频率没有保证;如果计算机很忙,那么它可能需要更长的更新时间,给玩家一个不公平的优势。相反,代码应该跟踪自上次调用更新函数以来已经过了多长时间。这可以通过向 update()方法添加一个参数来确定自上次运行以来有多长时间。为此,用update(time_interval)
替换 update()。time_interval 变量将设置为自上次运行更新以来的秒数(大约为 0.016)。
要实现这一点,请添加以下全局变量:
# Number of seconds to play when the timer starts
timer_start = 10.9
# number of seconds to decrement the timer each time we score a point
timer_decrement = 0.2
# This is the actual timer set to the initial start value
timer = timer_start
在 update 方法中将 timer 变量添加到全局变量列表中(不需要添加其他新变量,因为我们不需要更改它们)。
在处理游戏开始时按下 keyboard.space 键的代码块中,添加
timer = timer_start
就在方向键被按下之前,减少计时器并检查我们没有低于 0.9。
# Update timer with difference from previous
timer -= time_interval
# Check to see if timer has run out
if (timer < 0.9):
game_state = 'end'
return
然后在分数增加之后(每次达到目标),需要重置计时器(但是包括基于当前分数的递减)。
# Update timer - subtracting timer decrement for each point scored
timer = timer_start - (score ∗ timer_decrement)
最后,要在屏幕上看到 timer,将 timer 作为全局变量添加到 draw 函数中,并在屏幕上显示分数的同时添加以下内容。
screen.draw.text('Time: '+str(math.floor(timer)), fontsize=60, center=(100,50), shadow=(1,1), color=(255,255,255), scolor="#202020")
你可能想知道为什么 10 秒倒计时的计时器设置为 10.9 秒。
这是因为打印使用 floor 函数来去除任何分数,并以整秒显示计时器。如果我们测试计时器在零以上,玩家将期望计时器显示达到零时游戏立即结束,而不是继续计算下一秒。此外,玩家也希望计时器停留在 10 秒,而不是 9 秒,一旦我们减去第一个时间间隔。从 10.9 秒开始计时,到不到 1 秒结束,几乎正好是 10 秒,用户将看到从 10 到 0 的值。
指南针游戏版本 0.1 的最终代码
你现在将有一个完整的游戏可以玩。当你到达终点时,它会告诉你你的分数。然后你可以按空格键来试试这个游戏,看看你是否能打破这个分数。游戏的完整列表,到目前为止,包含在列表 3-4 中。这也作为 compassgame-v0.1.py 包含在源代码中。
import random
import math
WIDTH = 800
HEIGHT = 600
BACKGROUND_IMG = "compassgame_background_01"
game_state = "
target_direction = "
#Player character
player = Actor('compassgame_person_down_1', (WIDTH/2,HEIGHT/2))
# Which image is being displayed
player_step_count = 1
# Direction that player is facing
direction = 'down'
# Number of seconds to play when the timer starts
timer_start = 10.9
# number of seconds to decrement the timer each time we score a point
timer_decrement = 0.2
# This is the actual timer set to the initial start value
timer = timer_start
#Rectangles for compass points for collision detection to ensure player is in correct position
box_size = 50
north_box = Rect((0, 0), (WIDTH, box_size))
east_box = Rect((WIDTH-box_size, 0), (WIDTH, HEIGHT))
south_box = Rect((0, HEIGHT-box_size), (WIDTH, HEIGHT))
west_box = Rect((0, 0), (box_size, HEIGHT))
# Current score for this game
score = 0
def draw():
global score, timer
screen.blit(BACKGROUND_IMG, (0,0))
# If game not running then give instruction
if (game_state == "):
# Display message on screen
screen.draw.text("Press space bar to start", center=(WIDTH/2,HEIGHT/2), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
elif (game_state == 'end'):
screen.draw.text("Game Over "+str(score)+"\nPress space bar to start again", center=(WIDTH/2,HEIGHT/2), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
else:
screen.draw.text(target_direction, center=(WIDTH/2,50), fontsize=60, shadow=(1,1), color=(255,255,255), scolor="#202020")
screen.draw.text('Score '+str(score), fontsize=60, center=(WIDTH-130,50), shadow=(1,1), color=(255,255,255), scolor="#202020")
screen.draw.text('Time: '+str(math.floor(timer)), fontsize=60, center=(100,50), shadow=(1,1), color=(255,255,255), scolor="#202020")
player.draw()
def update(time_interval):
# Need to be able to update global variable direction
global direction, game_state, target_direction, score, timer_start, timer_decrement, timer
# If state is not running then we give option to start or quit
if (game_state == " or game_state == 'end'):
# Display instructions (in draw() rather than here)
# If space key then start game
if (keyboard.space):
game_state = "playing"
timer = timer_start
target_direction = get_new_direction()
# If escape then quit the game
if (keyboard.escape):
quit()
return
# Update timer with difference from previous
timer -= time_interval
# Check to see if timer has run out
if (timer < 0.9):
game_state = 'end'
return
# Check for direction keys pressed
# Can have multiple pressed in which case we move in all the directions
# The last one in the order below is set as the direction to determine the
# image to use
new_direction = "
if (keyboard.up):
new_direction = 'up'
move_actor(new_direction)
if (keyboard.down):
new_direction = 'down'
move_actor(new_direction)
if (keyboard.left) :
new_direction = 'left'
move_actor(new_direction)
if (keyboard.right) :
new_direction = 'right'
move_actor(new_direction)
# If new direction is not "" then we have a move button pressed
# so set appropriate image
if (new_direction != ") :
# Set image based on new_direction
set_actor_image (new_direction)
direction = new_direction
if (reach_target(target_direction)):
target_direction = get_new_direction()
score += 1
# Update timer - subtracting timer decrement for each point scored
timer = timer_start - (score ∗ timer_decrement)
def reach_target(target_direction):
if (target_direction == 'north'):
if (player.colliderect(north_box)):
return True
else:
return False
elif (target_direction == 'south'):
if (player.colliderect(south_box)):
return True
else:
return False
elif (target_direction == 'east'):
if (player.colliderect(east_box)):
return True
else:
return False
elif (target_direction == 'west'):
if (player.colliderect(west_box)):
return True
else:
return False
def move_actor(direction, distance = 5):
if (direction == 'up'):
player.y -= distance
if (direction == 'right'):
player.x += distance
if (direction == 'down'):
player.y += distance
if (direction == 'left'):
player.x -= distance
# Check not moved past the edge of the screen
if (player.y <= 30):
player.y = 30
if (player.x <= 12):
player.x = 12
if (player.y >= HEIGHT - 30):
player.y = HEIGHT - 30
if (player.x >= WIDTH - 12):
player.x = WIDTH - 12
# Show image matching new_direction and current step count
def set_actor_image (new_direction):
global player, player_step_count
step_delay = 5
player_step_count += 1
if player_step_count >= 4 ∗ step_delay:
player_step_count = 1
player_step_position = math.floor(player_step_count / step_delay) +1
player.image = "compassgame_person_"+new_direction+"_"+str(player_step_position)
def get_new_direction():
move_choices = ['north', 'east', 'south', 'west']
return random.choice(move_choices)
Listing 3-4Compass game. A simple Pygame Zero program with image background
完整的游戏大概 170 行代码,包括注释和空行。这听起来可能很多,但比在许多其他编程语言中要少得多。
摘要
本章介绍了 Pygame Zero 以及创建第一个图形游戏。代码相当长,这反映了创建一个游戏所付出的努力,但它比许多其他编程语言中所需的同等代码要短得多。
这个游戏目前还很基础,将在下一章游戏设计中进一步开发。
四、游戏设计
希望你有机会在进入本章之前玩完第三章的游戏。你觉得它怎么样?
如果你的经历和我的一样,那么开始的几次会很有趣,但是之后乐趣会有所下降。有两个原因:一个是一旦你记住了动作,玩起来就很简单,事实上有点太简单了,另一个原因是因为计时器随着每一级的增加而减少,它很快就会达到时间太少的地步,这意味着你在每场比赛中都会得到相似的分数。
在这一章中,我们将看看是什么让游戏变得有趣,以及我们如何做一些改变来改进游戏。这构成了游戏设计的基础。
是什么让游戏变得有趣?
在我们考虑添加任何代码之前,想想你玩过的游戏,是什么让它们变得有趣。以下是我想到的一些东西,也许你能想到其他因素:
-
具有挑战性但可以实现
-
选择和后果
-
奖励和进步
-
可爱的角色
-
故事情节/历史相关性
-
教育(有时)
-
玩游戏需要适当的时间
-
包容性
-
适合年龄
这些并不是所有游戏都需要的。把它们想象成指导方针,让你思考游戏设计,但是不要有太多限制。意识到什么时候可以包含这些特性可以让游戏更有趣。这些也可能彼此相关,例如奖励如何帮助克服挑战,或者进展在哪里被用来揭示故事情节的下一部分。
在设计一个新游戏的时候,考虑这些问题并思考如何在游戏中实现它们是一个好主意。如果你认为这对你的游戏不重要,那也没关系。
所有这些特性都没有唯一的答案,这取决于你想创作什么类型的游戏以及你的目标受众是谁。
具有挑战性但可以实现
当你在玩游戏的时候,你希望能够感觉到你已经取得了一些成就。这通常是通过在游戏中有一个你需要克服的挑战来实现的。挑战可能是一种技能;它可能是关于快速反应;或者它可能需要使用脑力来解决一个难题。
有一些流行的游戏不提供挑战,但它们通常提供一些别的东西。如果你想一想数字绘画应用,它们不是你通常认为具有挑战性的,而是放松或治疗性的;有些游戏可能是创造性的,而不是竞争性的,如创意模式下的《我的世界》。可以说,你可以说缺乏挑战性意味着他们不会被归类为游戏,这是值得思考的事情。
在大多数游戏中,在游戏的简单性和挑战性之间有一个平衡,让你觉得你已经取得了一些成就。把一个游戏做得太简单,玩家可能会感到无聊并在其他地方寻找新的挑战,把它做得太难,他们可能会放弃认为他们不能再进步了。
一般来说,你会希望游戏开始容易,让玩家明白如何玩而不会面临太多的挑战。然后随着他们在游戏中的进步,让游戏变得更有挑战性并给玩家一种成就感会变得更难。
在思考如何让一个游戏具有挑战性的时候,你要思考这个游戏是否会有可预测性,或者是否会有随机的元素。一个可预测的游戏在每次玩的时候都会有完全相同的反应。这意味着每个游戏都有相同的难度,但是通过大量的练习,玩家可以学会这个级别。有了随机元素,游戏就不那么可预测了,玩家需要调整他们的玩法来适应游戏。
选择和后果
一些游戏创造了玩家需要做出的选择。有些选择只是改变了游戏的外观或感觉(也许是不同颜色的服装),但我真正谈论的是决定游戏玩法的选择。这些可能是方向的选择,是战斗还是选择外交的决定,或者是追求什么技术的决定。这是一个特别好的方法,可以让游戏变得有挑战性,让玩家觉得自己控制了游戏。如果提供一个选择,那么玩家所做的选择通常会有一个结果,这个结果决定了他们如何在游戏中前进。
奖励和进步
当一个游戏包含一个挑战,那么奖励玩家是有用的,这给他们一种满足感,这是值得努力的。奖励可以只是通过各级进展(级别上升),或者它可能涉及解锁一个新的角色或权力。这些力量通常可以和挑战一起帮助完成下一关。
可爱的角色
许多电脑游戏让你扮演一个特定角色或控制一队角色。你游戏中的一个角色可能是专门为你的游戏而创造的,也可能与电影或电视等现有的特许经营有关。
你可能想尝试创建一个与你最喜欢的电影相关的游戏,也许是一个哈利波特巫师游戏,但是你可能会遇到版权问题。如果是现有的特许经营,那么你需要知道版权和许可限制。一般来说,如果你使用任何基于电影、电视或知名人物的东西,那么你需要得到特许经营者的许可。
如果你创造了自己的角色,那么你可以赋予他们自己的个性和特质,这样玩家就可以和他们交往。在某些情况下,这些角色可以凭借自身的能力成为名人,只要想想萝拉·卡芙特这个角色,他最初是一个视频角色,后来被拍成了电影。
还要记住,人物不一定是人。它们可以是生物或交通工具,或者你甚至可以让无生命的物体变得有生命。
故事情节/历史相关性
有一点经常是可选的,那就是游戏是遵循故事情节还是以历史故事为背景。一个故事可以帮助玩家更多地与游戏联系起来,让他们觉得自己是故事的一部分。这可以成为继续玩游戏的强大动力。
历史相关性是指你将游戏建立在一个真实的历史时刻上。一个流行的是有一个游戏是与一场历史性的战斗或历史上的一个重要时刻,如铁路的诞生。
然而,有许多游戏没有任何类型的故事情节,你只是为了好玩而玩。这完全取决于你想创造的游戏类型。
教育的
另一个可选的方面是游戏是否有教育意义。这可以包括传统的儿童教育游戏,如加法和乘法游戏,成人“大脑游戏”,帮助教你玩乐器的游戏,或者可能包括参考历史事件的游戏。
这些可以是一个明显的目标,也可以只是游戏的一个微妙特征。这可以与奖励联系起来,但不仅仅是屏幕上的徽章,玩家可以感觉到他们学到了一些东西,他们可以离开计算机使用。他们也可能非常微妙,也许通过故事情节或通过学习如何克服障碍来学习历史。
玩游戏需要适当的时间
当考虑玩游戏需要多长时间时,你需要考虑玩家将如何玩游戏。这是一个你希望他们长时间坐下来玩的游戏,还是他们用来打发白天空闲时间的游戏?
你还应该考虑游戏是否可以保存,以及保存之间可以间隔多久。如果你花了很长时间试图完成一个关卡,但是却没有时间去完成它,这是非常令人沮丧的。如果你能保存并恢复到那个水平,就可以避免因需要去其他地方而产生的挫败感。
包容性
有几种方法可以让游戏更包容其他人。这可能包括为那些发现传统键盘控制难以使用的残疾人提供的附加/简化控制。或者它可以包括用不同的角色来代表玩游戏的人的性别或肤色的能力。
确保你不使用任何负面的刻板印象也同样重要。在过去,女性角色被用作处于困境中的少女,等待男性骑士来拯救她。令人欣慰的是,随着更多的女性角色在电影和电脑游戏中担任主角,这些现在变得不那么常见了。
在开发游戏时记住这些想法,可能会有一些简单的事情可以实现,使游戏对更多样化的人群更具吸引力。
适合年龄
最后,我会提到一个游戏应该是适合年龄的。这本书里的游戏都是为家庭设计的。如果你的目标是年纪较大的玩家,那么你可以少用家人朋友的语言,但这可能会使它不太适合其他人。暴力的程度和造成伤害的现实程度也是如此。游戏的目标年龄也应该反映在所使用的图形类型中,这将在下一章的图形中详细讨论。
改进指南针游戏
采纳这些建议,我们可以做一些事情来让指南针游戏变得更好。在本章中不可能实现所有这些想法,但是你可以增加三个新的特性来改善游戏性:
-
改进计时器,这样即使分数很高也有更多的机会完成。
-
增加一些随机障碍,让游戏更有挑战性。
-
添加一个高分,保存最高得分。
这些都是为了使游戏更具挑战性,但也包括在保存高分方面的奖励。
Note
本章使用的代码需要与第三章相同的资源。你需要将第四章的源代码复制到与第三章的源代码相同的目录下。
更新的计时器
游戏计时器的问题是它线性递减,每次计时的时间长度相同。这在一开始很有效,但是在得到大约 38 分后,就变得很难了;完成这项任务几乎是不可能的。所需要的是定时器功能,其在开始时非常快速地减少时间(以创建挑战元素),但是随着时间的推移,它减少得不太快,从而给出仍然能够完成任务的合理机会。
这将涉及到一些数学。在这个阶段,我们将保持简单。要用的公式是 x / (x + h)。这里 x 是分数,h 是偏移量。我们将使用偏移量 10。这个公式开始时增加很快,但是随着 x 变大,它趋向于值 1。为了得到计时器的时间,我们从开始时间中减去它。
为了确定合适的值,使用 Python 绘图模块对此进行了测试。我不会详细介绍代码是如何工作的,但源代码是在一个名为 timedecaygraph.py 的文件中提供的。如果您查看源代码,您应该能够看到它是如何工作的。如果您想尝试运行代码,首先需要安装 plotly 模块。Mu 编辑器的未来版本将包括一种安装模块的方式,但在编写本文时还不可用。要添加模块,请执行以下操作之一:
-
在 Raspberry Pi 上,您可以使用
sudo pip3 install plotly
安装模块
-
在其他 Linux 发行版上
Install either the same as previously or
sudo pip install plotly
-
在 Windows 上
你需要告诉 pip Mu 正在使用的 pkg 的位置。
On my computer, that is achieved using
pip install plotly --target="c:\users\stewart\AppData\Local\Mu\pkgs"
你需要用安装 Mu 的用户名替换
stewart
。 -
关于麦克·OS X
首先创建一个单独的目录来运行程序,并复制到 timedecaygraph.py 文件中。
使用以下内容创建名为 setup.cfg 的文件:
[install]
Prefix=
然后使用以下命令安装软件包
pip3 install plotly --upgrade --target /Applications/mu-editor.app/Contents/Resources/app_packages
一旦安装了 plotly,就可以在 Mu 中运行 timedecaygraph.py(首先将模式从 Pygame Zero 改为 Python 3)。
根据您的系统,它可能会在 web 浏览器中打开结果,但在其他系统中,您可能需要将输出保存为 html 文件,然后用 web 浏览器手动打开它。
通过调整公式值,我发现下面的公式运行良好:
start_value + 1.5 - (start_value ∗ (i/ (i + 10)))
参见图 4-1 中的截图,该图显示了新公式与线性衰减的对比。
图 4-1
显示不同衰变公式的图形屏幕截图
从图中可以看出,改进后的公式最初下降的速度比线性衰减快得多,但随着分数的增加,衰减也小得多。
要在代码中实现这一点,加载上一章末尾的当前版本代码(compassgame-v0.1.py)。
移除不再需要的 timer_decrement 变量。
然后在更新函数中,替换以下条目
timer = timer_start - (score ∗ timer_decrement)
随着
timer = timer_start + 1.5 - (timer_start ∗ (score/ (score + 10)))
值 10 设定衰减速度,1.5 用于增加偏移。如果您希望能够微调这些值,可以将它们更改为变量。
这作为 compassgame-timer2.py 包含在源代码中。
添加障碍
我们能做的下一件事是通过增加玩家必须避开的障碍来增加一点挑战。这可以通过添加新的级别来实现。第一级没有任何障碍,第二级增加了一些障碍,第三级增加了一些不同的障碍,以此类推。图 4-2 中的截图显示了游戏在避开一些障碍后的样子。
图 4-2
要避开障碍物的指南针游戏
增加障碍需要几个变化。从第三章末尾的代码开始(compassgame-v0.1.py)。第一个是在文件顶部附近添加更多的变量和定义:
OBSTACLE_IMG = "compassgame_obstacle_01"
# Current score for this game
score = 0
# Score for each level
score_per_level = 20
# What level are we on
level = 1
#Obstacles - these are actors, but stationary ones - default positions
obstacles = []
# Positions to place obstacles Tuples: (x,y)
obstacle_positions = [(200,200), (400, 400), (500,500), (80,120), (700, 150), (750,540), (200,550), (60,320), (730, 290), (390,170), (420,500) ]
要显示障碍,将它添加到 draw 函数中,确保它不在任何 if-else 子句中。
for i in range (0,len(obstacles)):
obstacles[i].draw()
添加一个新的 set_level 函数来创建障碍演员。这可以朝向瓷砖的末端。
def set_level(level_number):
global level, obstacles, obstacle_positions
level = level_number
# Reset / remove all obstacles
obstacles = []
if (level < 1):
return
# Add appropriate number of obstacles - up to maximum available positions
for i in range (0,len(obstacle_positions)):
# If we have already added more than the obstacle level number then stop adding more
if (i >= level_number - 1):
break
obstacles.append(Actor(OBSTACLE_IMG, obstacle_positions[i]))
每当级别增加时,将调用此函数。除了更新级别编号的全局变量之外,它还会产生需要避免的障碍。
障碍列表开始时是空的,所以没有画出障碍。当等级高于 1 级时,就会产生新的障碍。这些是作为演员添加的,但是不像我们的玩家,他们不能在屏幕上移动。
你需要确保障碍图像存在;否则,程序可能会挂起而不显示错误信息,从而很难知道哪里出错了。
更新位于更新函数底部附近的 if(reach _ target(target _ direction)):代码块。
if (reach_target(target_direction)):
target_direction = get_new_direction()
score += 1
# check if we need to move up a level
if (score >= level * score_per_level):
set_level(level + 1)
# Level score is the number of points scored in this level
level_score = score - ((level - 1) * score_per_level)
# Update timer - subtracting timer decrement for each point scored
timer = timer_start + 1.5 - (timer_start * (level_score/ (level_score + 10)))
在这个代码中,级别每增加 20 级。20 分之前不会有障碍,然后增加一个障碍,40 分时增加第二个障碍,以此类推。这给了每个关卡一个合理的难度等级,但是在开发阶段测试游戏的时候会花很多时间。您可能希望将 score_per_level 的值减少到 10,这样您就可以测试障碍物是否创建正确,而不需要玩很长时间。这是开发游戏时经常要做的事情。在一些游戏中,这些是作为特殊的“作弊代码”被编码到游戏中的,这些代码将用于直接跳到某个级别或添加某些能量来帮助测试。
更新后的代码在源代码中以 compassgame-obstacle1.py 的形式提供。你可以测试代码,障碍会在得分 20 分后出现,但玩家能够直接穿过它们。显然,当玩家碰到它们时,需要一些额外的代码来做一些事情。这是通过在更新函数的末尾添加以下代码块来实现的:
# detect if collision with obstacle (game over)
for current_obstacle in obstacles:
if player.colliderect(current_obstacle):
game_state = "end"
return
这与用于检测玩家何时到达游戏区域的一侧的代码相同,但是使用循环来与列表中的每个障碍进行比较。如果玩家撞上了一个障碍物,那么游戏被设置为“结束”状态,触发游戏的结束。到目前为止,源代码中包含的代码是 compassgame-obstacle2.py。
增加高分
接下来的功能是增加一个高分。这告诉玩家先前获得的最高分是多少,并给玩家一些目标。通常,高分将存储多个值以及它们的名字或缩写,但现在您应该从一个最高分的值开始。高分的一个特点是它需要保存在某个地方,这样当电脑关机时它就不会丢失。因此,这将包括如何将数据保存到磁盘上的文件中,以及如何读回数据。在 Raspberry Pi 的例子中,它不是存储在物理硬盘上,而是存储在 SD 卡上,但是使用 Python 可以像在磁盘上一样访问它。
在 Pygame Zero 的最新版本中,有一个存储功能,提供了一种简单的存储信息的方法。在撰写本文时,Pygame Zero 文档中还没有完整记录该函数。虽然传统的 Python 文件操作更难使用,但对于任何 Python 编程来说,它们都是有用的工具。我建议学习这里使用的方法,这对将来的 Python 编程很有用。
在文件顶部附近添加以下新的全局变量:
HIGH_SCORE_FILENAME = "compassgame_score.dat"
新增两个函数,一个是从磁盘中检索高分(get_high_score),另一个是保存最新的高分(set_high_score)。这些可以添加到文件的底部。
# Reads high score from file and returns as a number
def get_high_score():
file = open(HIGH_SCORE_FILENAME, 'r')
entry = file.readline()
file.close()
high_score = int(entry)
return high_score
# Writes a high score to the file
def set_high_score(new_score):
file = open(HIGH_SCORE_FILENAME, 'w')
file.write(str(high_score))
file.close()
get_high_score 函数从文件中读取一个值。首先,它使用 open 函数打开文件。第一个参数是文件名,第二个参数是一个或多个字符,表示文件应该以何种模式打开。在这种情况下,“r”表示读取,其他常见模式是写入“w”和追加“a”。默认情况下,该文件以默认的文本模式打开,但是您可以使用“b”选项以二进制模式访问该文件。例如,要以只读二进制模式打开文件,可以使用“rb”。
该文件作为 file 对象返回,然后可用于读取该文件。该函数通过 readline 方法使用 file 对象,该方法将从文件中读取一行。对 readline 的后续调用将读入更多的行。在这种情况下,我们只有一个条目,所以只需要调用一次。
由于高分已经存储到一个文本文件中,它将是一个字符串而不是一个数字。因为我们需要能够将它与数字进行比较,所以需要使用 int 函数将它从字符转换为整数。然后返回结果值。
您还会注意到有一行 file.close(),它在函数读取完文件后关闭文件。这是释放文件所必需的,这样以后这个程序或另一个程序就可以打开它。
set_high_score 函数的工作方式与 get_high_score 类似,但它是写入文件而不是从中读取。首先更新全局变量 high_score,然后它以写模式打开文件,并写入转换为字符串的高分值。然后文件被关闭。
在更新函数中,在第score = 0
行之前添加以下代码:
high_score = get_high_score()
if (score > high_score) :
set_high_score(score)
这在代码中的位置意味着新的高分直到下一个游戏开始后才被保存。这样做是为了保持代码简单,更易于阅读。你可能想在游戏结束后检查这个。
最后,游戏结束时需要代码来显示高分。将“游戏结束”的当前打印声明替换为以下两行:
high_score = get_high_score()
screen.draw.text("Game Over\nScore "+str(score)+"\nHigh score "+str(high_score)+"\nPress map or duck button to start", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
试着除了
如果您现在尝试运行代码,它将不起作用。不幸的是,它没有给出错误消息就失败了,这令人沮丧。这是因为对文件访问没有错误检查。当代码第一次尝试读入高分文件时,那么它就不存在了。您可以添加代码来检查文件是否存在,但是在文件操作过程中还会出现其他问题。例如,文件可能存在,但值已损坏。为了避免必须进行大量不同的检查,我们可以在 try except 代码块中使用 Python 异常处理。
尝试除了有三个步骤。首先“try”块将运行代码;如果有任何错误(异常),那么可以使用“except”块来处理它们,然后无论异常是否发生,“finally”块都将运行。
清单 4-1 显示了用于处理异常的代码的一般示例。
try:
operation_that_may_fail()
except:
print ("An exception occurred")
finally:
print ("I run regardless")
Listing 4-1Example of a try except exception handling
这里的代码试图运行可能会失败的操作。如果它触发了一个异常,那么 except 代码将运行。不管怎样,finally 块都会运行。
您也可以只捕捉某些异常。以下代码显示了如何只捕捉 IO 错误:
except IOError:
对于不同类型的错误,还可以使用多个 except 块。发生异常时,您可以按如下方式访问异常属性:
except Exception as e:
这将在变量 e 中提供一个异常值。您可以使用print (e)
将其显示在控制台屏幕上。异常处理将在第十一章中进一步解释。
要在访问高分文件时使用 try except 异常处理,可以用以下新代码替换这两个高分函数:
# Reads high score from file and returns as a number
def get_high_score():
try:
file = open(HIGH_SCORE_FILENAME, 'r')
entry = file.readline()
file.close()
high_score = int(entry)
except Exception as e:
print ("An error occurred reading the high score file :" + str(e))
high_score = 0
return high_score
# Writes a high score to the file
def set_high_score(new_score):
global high_score
high_score = new_score
try:
file = open(HIGH_SCORE_FILENAME, 'w')
file.write(str(high_score))
file.close()
except Exception as e:
print ("An error occurred writing to the high score file :" + str(e))
更新后的代码命名为 compassgame-highscore.py。
代码中处理异常的方式意味着,如果出现异常,程序将继续运行。在读取错误的情况下,high_score 只是被赋予零值。这在这里是可以接受的,因为游戏不保存高分照样可以玩。在某些程序中,未能保存数据可能是一个关键问题,因此会导致其他操作,可能包括终止程序。
像这样简单的高分可以在一段时间内增加额外的游戏性,但最终你会达到一个很难甚至不可能击败分数的地步。许多游戏通过添加不同的元素或在游戏时赚取积分来克服这一点,积分可用于购买物品,从而更容易获得更高的分数。在军事游戏中,这可能是盔甲或更强大的武器。这超出了本书的范围,因为它需要很多额外的代码来包含一个基于奖励的系统,但这是你在设计自己的游戏时可能要考虑的事情。
这个游戏只是实现了一些想法。这足以让游戏变得更加有趣。指南针游戏在目前的形式下永远不会是一个特别好的游戏,因为它有点太简单了。然而,这是一个很好的游戏,展示了如何将图形融入游戏和计算机动画的基础。新的特性应该会让你知道如何实现这些特性,让你自己的游戏更有趣。
摘要
你现在已经看到了一些额外的元素是如何改变游戏玩法并使游戏更有趣的。这是通过一次添加一个新特性来实现的,这是敏捷编程的一个特性。
本章还展示了如何将计时元素添加到挑战元素中。然后展示了如何读写文件,以及如何处理访问文件时可能出现的错误。
下一章将着眼于如何在游戏中创建和使用图形。
五、图形设计
视觉图形是任何游戏的关键部分。他们设置场景,设置游戏的基调,并决定一个游戏是否有视觉吸引力。不同游戏之间的细节水平差异很大,从最初的乒乓球游戏(只有简单的阻挡球棒和球)到现代商业游戏(可能包括真实的视频镜头)。
在一个理想的世界里,所有的开发者都是伟大的艺术家,或者有一个艺术家可以为他们创作图形。情况并不总是这样,所以这一章看一些简单的方法来创建适合在游戏中使用的图形。即使你有一个专业的艺术家,一些程序员可能会创建被称为程序员艺术的基本图像,在创建专业艺术作品之前,它被用作占位符来演示游戏。
保持简单,这本书将主要涵盖简单的像素艺术为基础的字符和简单的 2D 图像。这些图形可能适合 20 世纪 80 年代的复古感觉,或者与许多独立游戏中使用的风格一致。如果你想创建一些更复杂的 2D 或 3D 图形,它还会看一些其他有用的工具。
你在游戏中包含的细节水平将取决于你自己的艺术天赋(或者你的图形设计师的艺术天赋,如果是多人团队的一部分)和用于创建图形的时间量。即使你在绘画方面不是特别有艺术天赋,你仍然可以创造一些简单的卡通风格的图像。我为这本书里的所有游戏制作了图形;虽然它们不太可能赢得任何现实主义奖项,但它们表明,你无需成为专业艺术家,也能创作出一些简单的图形。
创建主题
在开始创建图形之前,您应该决定图形的样式和主题。当开始编程时,从简单的图像开始通常是一个好主意,因为这些图像在 Pygame Zero 使用的简单 Actor 对象中工作得很好。与你在商业 AAA 游戏中看到的栩栩如生的角色相比,这些也需要更少的处理能力。这并不意味着你的角色需要毫无生气,因为你仍然可以赋予角色他们自己的风格和个性。
其他一些需要考虑的事情:
-
游戏是基于什么样的环境?游戏可以基于陆地,海洋,甚至太空。每个地方都有自己的挑战和优势。
-
图形会逼真吗?可以创建图形来创建现实主义或可以带你到一个幻想世界。
-
这个游戏对家庭友好吗?如果你想让游戏适合小孩子,那么你应该避免暴力、不良语言和其他不合适的内容。如果游戏确实包含某种程度的暴力或破坏,那么漫画式的暴力比使用栩栩如生的图像更适合儿童。你可以考虑有一个家庭友好模式,更适合年轻玩家的图形。
-
人物可以定制吗?如果主角是一个人,那么玩家可能会喜欢选择一个可以与之交往的角色。这可能是通过提供不同的性别、肤色、头发颜色或衣着选择。如果角色是一种动物或幻想生物,那么可能会有不同动物或生物的选项。这也适用于无生命的物体,如车辆,不同的品牌、型号或颜色。
决定主题后,你可以为背景和游戏中的角色创建图像。
文件格式
有不同的文件格式可用于图像。最常见的两种是位图和矢量格式,我们将在这里讨论。
位图图像
到目前为止使用的图像都是位图图像。位图图像(也称为光栅图像)是作为单个像素创建的,这些像素是图像的最小单个块。位图定义了组成图像的每个像素的颜色。
如图 5-1 所示。这是一个 10 x 10 像素的简单图像,白色背景,黑色矩形。涂成白色的方块将被存储为白色像素,涂成黑色的方块将被存储为黑色像素。
图 5-1
简单位图图像
这是一个微不足道的形象。位图图像通常由更多的像素组成,因此存储每个像素的颜色会导致文件非常大。比如指南针游戏用的背景图是 800 x 600 像素,也就是 48 万像素。如果使用 3 个字节来表示颜色(这是典型的),那么该图像的大小大约为 1.4 MB。您可以通过将图像转换为 Windows 位图来证明这一点。bmp)图像格式。为了避免如此大的文件,图像格式通常支持压缩。
Pygame Zero 中使用的两种最流行的图像格式是 PNG(.png)和 JPEG(。jpg)。PNG(便携式网络图形)格式支持无损压缩。这减小了文件大小,但保持了图像中所有数据的完整性。JPEG 格式(由联合图像专家组创建)使用有损压缩,这种压缩会删除文件中的一些信息,同时使文件看起来尽可能接近原始文件。有损压缩通常会使文件变小,但会导致质量下降。
JPEG 文件适用于压缩优先的大型图像。这使它们成为一种有用的照片格式。
PNG 具有良好的压缩性,没有质量损失,并且支持透明性,因此它通常是游戏编程的好选择。
矢量图像
位图图像的替代物是矢量图像。矢量图像不是存储每个像素的细节,而是存储如何从形状创建图像的指令。对于之前在图 5-1 中使用的图像,文件格式将描述如何使用矩形创建图像。
清单 5-1 显示了如何将位图图像绘制成矢量图像的伪代码。
Create blank page 10 pixels x 10 pixels
Set the page color to white
Draw a rectangle starting at position 1,1 which is 6 x 7 pixels in size.
Color the rectangle black
Listing 5-1Example of a try except exception handling
Tip
伪代码是用来描述程序如何工作的。它不能直接在任何编程语言中运行,因为它没有正常编程语言所需的正确词汇或语法。它有助于解释代码如何工作。
矢量图像的主要优点如下:
-
可以编辑和移动这些形状,而不会丢失与其他形状重叠的任何信息。
-
当放大一个形状时,它仍然是清晰的,而位图变成像素化的。
-
通常文件较小。
矢量图像的一种流行格式是 SVG(可缩放矢量图形),它是一种通用文件格式。还有许多其他矢量文件格式,它们通常与特定的编辑应用相关联(例如 LibreOffice Draw 中使用的 ODG)。
Pygame Zero 不能像显示位图图像那样显示这些图像。在设计游戏时,必须将矢量图像转换为位图图像,使用能够理解矢量图像格式的代码进行转换,或者使用指示 Pygame Zero 使用其内置形状工具创建图像的代码。这些方法中的每一种都将在本章或接下来的两章中讨论。
有用的工具
有许多工具可以用来设计计算机图形。这里展示的例子都是免费的,可以在 Raspberry Pi 上使用。对于其中的一些,显示了如何创建图像的示例。
Draw 程式库
Draw 是 LibreOffice 办公套件中包含的应用之一。默认情况下,它包含在 Raspberry Pi NOOBs 映像中,并可从网站 www.libreoffice.org/
获得其他操作系统。
Draw 对于创建 2D 矢量图像非常有用,然后可以将其转换为位图图像,以便在 Pygame Zero 中使用。
图 5-2 中的截图显示了一个在 Draw 中创建的人。右图被分成不同的组件,以展示如何使用基本形状创建这些组件。
图 5-2
在 LibreOffice Draw 中创建的人物精灵图像
有几种不同的形状可供使用,如图 5-3 所示。对于更复杂的形状,绘图工具包括一个选项,用于使用可以形成任何形状的线集合来创建不规则多边形。
图 5-3
图书馆绘图中的简单形状绘图工具
设计完精灵后,可以使用导出选项将其导出为 PNG 文件。如果您勾选了“选择”复选框,那么它将只导出选定的对象。如果您在同一个文档中创建多个图像,这将非常有用。
红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石
LibreOffice Draw 是一个很好的程序,但是对于一个更专业的绘图应用来说,还有另外一个免费的选择,那就是 Inkscape。Inkscape 是一个矢量绘图程序,它将自己与 Adobe Illustrator 和 CorelDRAW 相比较。默认情况下,它不包含在 NOOBS 安装中,但可以使用
sudo apt install inkscape
Inkscape 也可用于其他操作系统,可从 https://inkscape.org/
下载。图 5-4 中的截图显示了 Inkscape 中一辆汽车的图纸。
图 5-4
在 Inkscape 中创建的汽车图像
Inkscape 比 LibreOffice Draw 稍难使用,但功能更强大。如果您还不熟悉矢量绘图程序,那么您可能想先尝试 LibreOffice Draw,然后在准备进入下一阶段时使用 Inkscape。它的工作方式有所不同的一个例子是 LibreOffice Draw 有一个用于创建不规则多边形的多边形工具,而在 Inkscape 中这是通过使用铅笔工具来实现的。要创建多边形,请绘制第一条线,然后从前一条线的末端开始每条后续线。完成后,单击第一条线的起点将产生一个多边形,您可以用颜色填充它。
Inkscape 文件直接保存为 SVG 文件,这使它们有助于与其他应用共享,并且图像可以导出为 PNG 位图文件,以便在 Pygame Zero 中使用。
GIMP
GIMP (GNU 图像处理程序)是一个位图编辑器。这是一个功能强大的工具,有很多特性,但由于这一点,它可能很难学习。它可以安装在树莓 Pi 上,使用
sudo apt install gimp
在其他操作系统上,您可以在 www.gimp.org
下载一个版本。GIMP 有很多方法可以用来创建图形。这里展示了两个例子,一个是从图画或照片中创建背景图像,另一个展示了如何使用它来创建适合精灵的简单像素艺术。
从图画或照片创建计算机图像
这个例子将展示从图画或照片创建计算机图形图像背后的原理。这可以用来将概念作品制作成游戏的背景。在这种情况下,我从一个城堡的照片中创建了一个城堡的计算机图形图像。照片图像首先被加载到 GIMP 中,并调整到成品图像的大小,如图 5-5 所示。
图 5-5
GIMP 与一座城堡的照片
你会看到在图像的顶部有一个透明区域(棋盘图案)。这是因为调整了图像的大小以达到所需的宽高比。
图像将被创建在一个新的层,照片最终被删除。使用图层工具创建新图层,如图 5-6 所示。
图 5-6
带有新层的 GIMP 层对话框
新图层被分为两个区域,分别显示蓝天和绿地。使用的主要工具是自由选择工具(套索)和填充工具(桶);这些都在图 5-7 中突出显示。
图 5-7
显示自由选择和填充工具的 GIMP 工具对话框
可以调整图层的顺序和不透明度,以便可以在背景中看到照片,然后使用自由选择工具绘制轮廓。您可以使用 Ctrl 和鼠标滚轮来放大和缩小。您可以使用滚动条在图像中移动。如果你不小心点错了地方,那就用键盘上的退格键。选区如图 5-8 所示,可以看到城堡形状的隐约轮廓。
图 5-8
GIMP 中城堡轮廓的选择
然后使用填充工具用适当的颜色填充轮廓。这是重复添加更多的细节,如门和窗户。图像可以保存为 GIMP XCF 文件,这将允许您继续编辑它,并导出为 PNG 文件,以便在 Pygame Zero 中使用。导出的城堡图像如图 5-9 所示。
图 5-9
城堡的导出图像
重复这个过程,直到达到适当的细节水平。我已经添加了一座桥,一条路,以及干护城河远处的深绿色。
也可以使用铅笔或画笔在图像上绘图。我已经用画笔工具添加了一些云彩。这些是用软笔刷在两层上绘制的,部分透明,给它一个更柔和的外观。
创建像素艺术精灵
另一种方法是利用你自己的想象力完全从头开始创建图像。在这个例子中,一个简单的航天器像素艺术精灵。首先创建一个新的图像。将大小设置为适当的细节级别(在本例中为 32 x 32 像素),并在“更多选项”对话框下,选择背景为透明。
然后,您可以放大图像,并使用“大小”设置为 1 的笔单独为相关像素着色。我首先创建一个简单的轮廓形状,如图 5-10 所示。
图 5-10
在 GIMP 中创建像素艺术精灵
为了更容易创建对称,我添加了一个临时层,用一条线显示图像的中间。然后,您可以为线条的每一侧计算相同数量的像素。如图 5-11 所示。
图 5-11
用对称线在 GIMP 中创建像素艺术精灵
根据需要继续添加细节。一旦完成,图像可以导出为 PNG 文件,如图 5-12 所示。在导出图像时,通常应该将任何未使用的像素保留为透明,但是我将背景着色为灰色,以便更容易看到白色图像。
图 5-12
像素艺术飞船
搅拌机
到目前为止讨论的工具都是为 2D 图像设计的。Blender 是一款 3D 设计工具。这对创建 3D 游戏很有用,但这超出了本书的范围;相反,我将展示一个示例,说明如何通过将光照和阴影应用到 3D 模型,然后将其导出为 2D 图像,来创建更加 3D 的外观。
Blender 是一款免费提供的专业设计工具。可以安装在树莓 Pi 上。
sudo apt install blender
如果在树莓 Pi 上运行,那么我建议使用 4GB 内存的树莓 Pi 4;它可以在旧版本上运行,但是非常慢,几乎不可用。对于其他操作系统,程序可以从 www.blender.org
下载。
Blender 是一个非常强大的工具,但是很难学。它在屏幕上到处都有工具,在不同的地方有多个下拉菜单,鼠标操作与 2D 工具不同。因此,对于新用户来说,这可能会非常混乱。
如果你真的学会了,那么它会很有用。你可能想从一些简短的教程开始,着眼于某些方面,而不是试图在一个项目中全部掌握。
创建 3D 对象超出了本书的范围,但了解如何在游戏中使用 Blender 中创建的对象可能会有所帮助。以下步骤显示了如何将搅拌机模型导出为适合在游戏中使用的 2D 图像。
图 5-13 中的图像显示了一个为游戏创建的简单的导弹/子弹图像。它由圆柱体和圆锥体组成。作为一个没有阴影的基本 2D 对象,它看起来非常简单,但是通过应用光源,你可以看到阴影,它可以呈现更 3D 的外观。
图 5-13
带有导弹三维模型的搅拌机
设计完图像后,可以将对象渲染成 2D 图像,然后保存为图像文件,如图 5-14 所示。
图 5-14
带另存为图像菜单选项的搅拌机
使用代码创建
到目前为止,这些工具都是在工具中创建图像,然后导出到 Pygame Zero 中使用。另一种方法是使用代码在 Pygame Zero 中生成图像。这可以利用 Pygame Zero 中的形状绘制工具。
第七章包括一个完全使用这种技术从头开始制作的游戏。游戏截图如图 5-15 所示。
图 5-15
使用代码创建的坦克游戏截图
这个游戏中的图形是基本的,但是可以添加更多的细节来使它们更加真实。
其他来源
如果你不想创建自己的图像,那么你可以让别人创建一些图形。你将需要检查图形的许可,允许你在游戏中使用它们。一些许可证可能会对图形的使用、修改和分发方式加以限制。他们也可能根据你的游戏是否被货币化而强加不同的许可。
以下是可能有用的一小部分来源;请注意,这些网站中的某些网站可能对不同的图像使用不同的许可证,或者可能使用限制图像使用方式的许可证:
-
打开游戏艺术-
https://opengameart.org/
-
pixabay─
https://pixabay.com/
-
Itch.io 免费游戏资产—
https://itch.io/game-assets/free
这不是一个详尽的列表。使用互联网搜索引擎进行搜索,将会列出其他带有适合在您自己的游戏中使用的图形的网站。
摘要
这展示了一些可用于创建计算机游戏编程中使用的图像的常用工具。它们是如何被使用的细节已经超出了本书的范围,但是它已经包括了你在创建图形时可能想要使用的一些技术的概述。它还包括一些网站的建议,可能有合适的图形可以使用。
下一章将介绍 Pygame Zero 中如何使用颜色,以及在游戏编程中使用颜色的一些技巧。
六、颜色
在第三章中,简单提到了定义颜色有不同的方法。这一章将会介绍颜色在 Pygame Zero 中的不同用法。您还将看到如何使用鼠标与程序进行交互。
本章将使用一些代码示例,但本章并不是要创建一个特定的游戏;这是关于学习新的工具和技术,可能在未来有用。
颜色混合
为了理解颜色模型,看看定义颜色的不同方法是很有用的。在很小的时候,你就应该知道你可以通过混合不同颜色的颜料来制作不同的颜色。通过这个你知道了原色是蓝色、红色和黄色。如果你在彩色打印机上看墨水,你仍然会看到它在工作,但是使用青色(浅蓝色)、品红色(浅红色)和黄色。你还会看到,你有一个黑色的墨水给一个真正的黑色。这就是众所周知的 CMYK 颜色模型。
CMYK 模式非常适合打印机,因为它是减色模式。你从浅色(通常是白纸)开始,添加的墨水防止颜色被反射。通过添加特定数量的墨水,您可以过滤掉不想要的光线,以获得您想要的颜色。
计算机屏幕上使用的 RGB 方案则相反。它不是阻挡颜色,而是从黑色屏幕开始,并添加彩色光,以达到所需的颜色。因为颜色是增加的而不是减少的,所以减色法使用不同的颜色。计算机屏幕上使用的颜色是红色、绿色和蓝色(RGB)。还有其他配色方案,Python 中也有可以在不同颜色模型之间转换的模块,但本质上大多数游戏编程只需要 RGB。
在 Pygame 中,零 RGB 值通常作为一个元组输入,以 0 到 255 的数字列出三种不同的颜色成分。例如,要表示橙色,您可以使用(255,165,0),其中 255 表示红色部分(最大值),165 表示绿色部分,0 表示蓝色部分。它也可以作为十六进制值输入,就像在 HTML 或 CSS 中定义的一样。这显示了相同的三个值,但转换为十六进制(基数为 16)而不是十进制。对于橙色,这将是#ffa500。还有大约 657 个不同的单词可以用来表示从“aliceblue”到“yellowgreen”的各种颜色。一小部分颜色代码如图 6-1 所示。
图 6-1
颜色代码列表
生成此列表的代码在清单 6-1 中,并作为 color-demo.py 包含在源代码中。演示程序显示颜色选择的 word、RGB 和 HTML 值。它在黑白背景上显示它们,使颜色可见。
# Program to demonstrate some of the color words including in Pygame / Pygame Zero
import pygame
WIDTH = 800
HEIGHT = 600
colors = ['aquamarine1', 'black', 'blue', 'magenta', 'gray', 'green', 'limegreen', 'maroon', 'navy', 'brown', 'purple',
'red', 'lightgray', 'orange', 'white', 'yellow', 'violet']
def draw():
screen.draw.filled_rect(Rect((400,0),(400,600)),(255,255,255))
line_number = 0
for color in colors:
print_color (color, line_number)
line_number += 1
def print_color (colorname, line_number):
color_rgb_string = "{},{},{}".format(pygame.Color(colorname).r, pygame.Color(colorname).g, pygame.Color(colorname).b)
color_html_string = "#{:02x}{:02x}{:02x}".format(pygame.Color(colorname).r, pygame.Color(colorname).g, pygame.Color(colorname).b)
screen.draw.text(colorname, (20,30*(line_number+1)), color=colorname)
screen.draw.text(color_rgb_string, (130,30*(line_number+1)), color=colorname)
screen.draw.text(color_html_string, (250,30*(line_number+1)), color=colorname)
screen.draw.text(colorname, (420,30*(line_number+1)), color=colorname)
screen.draw.text(color_rgb_string, (530,30*(line_number+1)), color=colorname)
screen.draw.text(color_html_string, (650,30*(line_number+1)), color=colorname)
Listing 6-1Code to display a selection of color words with color codes
代码使用 Pygame Zero 显示文本,但是访问 Pygame。直接颜色列表。Pygame Zero 文档中没有颜色列表,但是在 Pygame 源代码的附录 B 中有一个链接,在那里可以看到所有定义的颜色。
弹跳球
为了进一步演示颜色的使用,我制作了一个小程序,展示一个球在屏幕上弹跳。球在移动时会改变颜色。我不会在游戏中使用这个,但我会解释所使用的技术,如果你想制作一个依赖于弹跳的游戏,如突破,这可能是有用的。程序截图如图 6-2 所示。
图 6-2
彩色弹力球
清单 6-2 中显示了这方面的代码,并作为 bouncingball.py 包含在提供的源代码中。
WIDTH = 800
HEIGHT = 600
# starting positions
ball_x = 400
ball_y = 300
ball_speed = 5
# Velocity separated into x and y components
ball_velocity = [0.7 * ball_speed, 1 * ball_speed]
ball_radius = 20
ball_color_pos = 0
def draw():
screen.clear()
draw_ball()
def update():
global ball_x, ball_y, ball_velocity, ball_color_pos
ball_color_pos += 1
if (ball_color_pos > 255):
ball_color_pos = 0
ball_x += (ball_velocity[0])
ball_y += (ball_velocity[1])
if (ball_x + ball_radius >= WIDTH or ball_x - ball_radius <= 0):
ball_velocity[0] = ball_velocity[0] * -1
if (ball_y + ball_radius >= HEIGHT or ball_y - ball_radius <= 0):
ball_velocity[1] = ball_velocity[1] * -1
def draw_ball():
color = color_wheel (ball_color_pos)
screen.draw.filled_circle ((ball_x,ball_y), ball_radius, color)
# Cycle around a color wheel - 0 to 255
def color_wheel(pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return (0, pos * 3, 255 - pos * 3)
Listing 6-2Code to display a selection of color words with color codes
与所有 Pygame Zero 代码一样,该代码基于 draw 和 update 函数。
更新函数处理球的移动。球有一个速度(速度和方向的组合),它以每次运行更新函数时 x 和 y 的变化来存储。使用默认速度 5,每次调用该函数时,球将在 X 方向移动 3.5 个像素,在 Y 方向移动 5 个像素。当球碰到墙时,它在适当方向上的速度将会逆转。
draw 函数运行 draw_ball 函数,该函数使用 screen.draw.filled_circle 绘制球。它从 color_wheel 函数中计算出球的颜色。
色轮分三个阶段创建。第一阶段开始时没有红灯,全绿灯,没有蓝光。在这个阶段,红光增加,蓝光减少。
第二阶段是红光减少,蓝光增加,没有绿光。
第三阶段是绿光增加,蓝光减少,没有红光。
这仅使用轮子周围的一片,具有固定的亮度。可用的颜色总数超过 1600 万,但因为它只占用一个切片,所以每次调用 color_wheel 函数时,它都会返回 256 种不同颜色中的一种。使用下一种颜色,每画一次球意味着球在屏幕上移动时会改变颜色。
背景颜色选择器
为了帮助可视化不同的颜色,下一个程序将提供一种查看与不同颜色代码相关的颜色的方法。
该程序允许用户选择一种颜色,它将显示在窗口的下半部分。如图 6-3 所示。
图 6-3
颜色选择程序
像这一章的其余部分一样,它不会涉及到创建一个完整的游戏,但它将展示可以用于创建游戏的技术。这包括如何处理鼠标事件来使用鼠标创建游戏。
处理鼠标事件
当移动、单击或拖动鼠标时,会触发一个事件。然后这些函数调用鼠标事件函数,您可以在自己的代码中实现这些函数。这些功能是鼠标按下、鼠标抬起和鼠标移动。如果您在 Pygame Zero 代码中实现了这些函数,那么只要其中一个事件被触发,它们就会被调用。
看看 on_mouse_down 函数,每次按下一个鼠标按钮都会触发它。该函数可以有两个参数;如果它们包含在函数中,那么它们将被提供鼠标的位置和按下的鼠标按钮。
清单 6-3 中显示了一个示例函数。
def on_mouse_down(pos, button):
if (button == mouse.LEFT):
print ("Mouse pressed, position {} {}".format((pos[0]), pos[1]))
Listing 6-3Code to handle mouse press
每次按下左键时使用这个代码,它会将鼠标的坐标打印到控制台上。如果屏幕上有演员,那么可以使用演员碰撞点方法检测鼠标是否在其中一个演员上。这与使用传统(非游戏)应用不同。在游戏中,你通常希望鼠标一点就有动作(如按下按钮、发射激光或转动卡片)。在传统的应用中,要按下一个按钮,通常需要按下按钮,然后当鼠标在同一点上时也需要释放它。这意味着跟踪按钮是否在 on_mouse_down 期间,然后等待直到 on_mouse_up 被调用。因为这是一本游戏编程的书,所以它将只涉及第一个,但是如果你在非游戏应用中使用 Pygame Zero 的话,这是你需要考虑的事情。
创建颜色选择器
颜色选择器使用选定的颜色创建一个 filled_rectangle。矩形占据了程序窗口的一半。这类似于之前使用的 filled_circle,只是它使用了 Rect 对象。颜色是基于颜色 _ 红色、颜色 _ 绿色和颜色 _ 蓝色的变量设置的。每个参数的值都是通过 on_mouse_down 函数使用加号和减号按钮设置的。这些按钮是作为演员对象创建的图像,就像创建角色或其他精灵一样。
颜色选择器的代码如清单 6-4 所示。
WIDTH = 800
HEIGHT = 600
color_red = 0
color_green = 0
color_blue = 0
change_amount = 5
BOX = Rect((0,300),(800,300))
button_minus_red = Actor("button_minus_red", (260,63))
button_plus_red = Actor("button_plus_red", (310,63))
button_minus_green = Actor("button_minus_green", (260,143))
button_plus_green = Actor("button_plus_green", (310,143))
button_minus_blue = Actor("button_minus_blue", (260,223))
button_plus_blue = Actor("button_plus_blue", (310,223))
def draw() :
screen.clear()
screen.draw.text("Red", (45,45), fontsize=40, color="red")
screen.draw.text(str(color_red), (160,45), fontsize=40, color="red")
screen.draw.text("Green", (45,125), fontsize=40, color="green")
screen.draw.text(str(color_green), (160,125), fontsize=40, color="green")
screen.draw.text("Blue", (45,205), fontsize=40, color="blue")
screen.draw.text(str(color_blue), (160,205), fontsize=40, color="blue")
button_minus_red.draw()
button_plus_red.draw()
button_minus_green.draw()
button_plus_green.draw()
button_minus_blue.draw()
button_plus_blue.draw()
screen.draw.filled_rect (BOX, (color_red,color_green,color_blue))
def update() :
pass
def on_mouse_down(pos, button):
global color_red, color_green, color_blue
if (button == mouse.LEFT):
if (button_minus_red.collidepoint(pos)):
color_red -= change_amount
if (color_red < 1):
color_red = 0
elif (button_plus_red.collidepoint(pos)):
color_red += change_amount
if (color_red > 255):
color_red = 255
elif (button_minus_green.collidepoint(pos)):
color_green -= change_amount
if (color_green < 1):
color_green = 0
elif (button_plus_green.collidepoint(pos)):
color_green += change_amount
if (color_green > 255):
color_green = 255
elif (button_minus_blue.collidepoint(pos)):
color_blue -= change_amount
if (color_blue < 1):
color_blue = 0
elif (button_plus_blue.collidepoint(pos)):
color_blue += change_amount
if (color_blue > 255):
color_blue = 255
Listing 6-4Color selector program
on_mouse_down 函数处理所有的按钮按压。每个按钮都有一个文本块,用于查看按钮是否与鼠标位置发生冲突。如果检测到碰撞,它会将相应颜色的值增加或减少 5。改变 5 而不是 1 的原因是为了减少所需的按钮点击次数,尽管这并不意味着只能显示颜色的子集。
摘要
本章介绍了如何在 Pygame Zero 中创建颜色以及如何使用颜色。弹跳球程序展示了如何使用这些颜色。颜色选择器提供了一种创建不同颜色的方法,以及如何使用鼠标与程序进行交互。这些程序中使用的代码可以用作创建游戏的构建块。
在下一章,这些颜色将被用来创建另一个使用矢量图像的游戏。
七、坦克游戏·零
最后几章已经涵盖了一些理论;现在你将有机会在新游戏中应用这些技术。这个游戏是一个炮兵战斗游戏,叫做零号坦克游戏——一场摧毁敌人坦克的战斗。
这个游戏将会使用前几章学到的一些特性,并在此基础上进行扩展。它将使用动态矢量图形来创建精灵和背景图像。它还将涵盖一种追踪坦克炮发射的炮弹轨迹的新技术。
我没有一行一行地介绍这个程序,而是解释了一些用来创建这个游戏的不同技术。这些将在本章的末尾集合在一起,以创建一个可用的游戏。
游戏是双人回合制游戏。玩家 1 将向敌人的坦克发射一枚炮弹,试图摧毁它。如果不成功,那么 2 号玩家就有机会了。如此反复,直到其中一个玩家的炮弹成功命中对方的坦克。
坦克的矢量图像
这个游戏不是使用位图图像,而是使用内置的 Pygame Zero 形状创建的。这包括创建为多边形的风景和使用简单形状创建的坦克。坦克的基本外形如图 7-1 所示。
图 7-1
使用矢量形状创建的坦克形状
在代码中,坦克的底部被称为轨道,它被创建为一个多边形;主要部分被称为船体,创建为矩形;顶部是炮塔,呈椭圆形;枪是长方形的,但却是多边形的。
这将需要额外的代码来计算出坦克的位置和不同形状的相对坐标。绘制枪的位置的数学将会非常复杂,所以会放在一个单独的函数中。清单 7-1 中显示了绘制一个坦克的代码。这作为 tankshape.py 包含在源代码中。
import math
import pygame
WIDTH=800
HEIGHT=600
left_tank_position = 50,400
left_gun_angle = 20
def draw():
draw_tank ("left", left_tank_position, left_gun_angle)
def draw_tank (left_right, tank_start_pos, gun_angle):
(xpos, ypos) = tank_start_pos
tank_color = (216, 216, 153)
# The shape of the tank track is a polygon
# (uses list of tuples for the x and y co-ords)
track_positions = [
(xpos+5, ypos-5),
(xpos+10, ypos-10),
(xpos+50, ypos-10),
(xpos+55, ypos-5),
(xpos+50, ypos),
(xpos+10, ypos)
]
# Polygon for tracks (pygame not pygame zero)
pygame.draw.polygon(screen.surface, tank_color, track_positions)
# hull uses a rectangle which uses top right coords and dimensions
hull_rect = Rect((xpos+15,ypos-20),(30,10))
# Rectangle for tank body "hull" (pygame zero)
screen.draw.filled_rect(hull_rect, tank_color)
# Despite being an ellipse pygame requires this as a rect
turret_rect = Rect((xpos+20,ypos-25),(20,10))
# Ellipse for turret (pygame not pygame zero)
pygame.draw.ellipse(screen.surface, tank_color, turret_rect)
# Gun position involves more complex calculations so in a separate function
gun_positions = calc_gun_positions (left_right, tank_start_pos, gun_angle)
# Polygon for gun barrel (pygame not pygame zero)
pygame.draw.polygon(screen.surface, tank_color, gun_positions)
# Calculate the polygon positions for the gun barrel
def calc_gun_positions (left_right, tank_start_pos, gun_angle):
(xpos, ypos) = tank_start_pos
# Set the start of the gun (top of barrel at point it joins the tank)
if (left_right == "right"):
gun_start_pos_top = (xpos+20, ypos-20)
else:
gun_start_pos_top = (xpos+40, ypos-20)
# Convert angle to radians (for right subtract from 180 deg first)
relative_angle = gun_angle
if (left_right == "right"):
relative_angle = 180 - gun_angle
angle_rads = math.radians(relative_angle)
# Create vector based on the direction of the barrel
# Y direction *-1 (due to reverse y of screen)
gun_vector = (math.cos(angle_rads), math.sin(angle_rads) * -1)
# Determine position bottom of barrel
# Create temporary vector 90deg to existing vector
if (left_right == "right"):
temp_angle_rads = math.radians(relative_angle - 90)
else:
temp_angle_rads = math.radians(relative_angle + 90)
temp_vector = (math.cos(temp_angle_rads), math.sin(temp_angle_rads) * -1)
# Add constants for gun size
GUN_LENGTH = 20
GUN_DIAMETER = 3
gun_start_pos_bottom = (gun_start_pos_top[0] + temp_vector[0] *
GUN_DIAMETER, gun_start_pos_top[1] + temp_vector[1] * GUN_DIAMETER)
# Calculate barrel positions based on vector from start position
gun_positions = [
gun_start_pos_bottom,
gun_start_pos_top,
(gun_start_pos_top[0] + gun_vector[0] * GUN_LENGTH,
gun_start_pos_top[1] + gun_vector[1] * GUN_LENGTH),
(gun_start_pos_bottom[0] + gun_vector[0] * GUN_LENGTH,
gun_start_pos_bottom[1] + gun_vector[1] * GUN_LENGTH),
]
return gun_positions
Listing 7-1Code to display a tank created using shapes
程序首先导入一些模块。一个是数学模块,一个是 pygame。需要导入 pygame 的原因是,虽然游戏是为 Pygame Zero 设计的,但有一些功能是 Pygame Zero 目前没有的。导入 pygame 使代码能够利用 pygame 模块中的功能。
接下来有一些关于坦克位置和火炮角度的全局变量。这些是指左油箱;在最终的游戏中,将会有两个坦克,并且随着游戏的发展,它们的名字将会是一致的。
draw 函数是通过调用draw_tank
函数绘制坦克的单个入口。没有更新功能,因为此时不需要它。
绘制坦克的任务转到draw_tank
函数。该函数的第一个参数是单词“left”或“right”。这在这段代码中没有使用,因为目前它只创建了左边的容器,但是如果知道以后会需要的话,最好包含任何将来的参数。其他参数表示坦克的位置和火炮指向的角度。
draw_tank
功能首先定义代表坦克履带的形状。这被创建为多边形。多边形可以是至少有三条边的任何闭合形状,这使得它非常适合不规则形状。
track_positions = [
(xpos+5, ypos-5),
(xpos+10, ypos-10),
(xpos+50, ypos-10),
(xpos+55, ypos-5),
(xpos+50, ypos),
(xpos+10, ypos)
]
pygame.draw.polygon(screen.surface, tank_color, track_positions)
用代表形状的所有顶点(每个角)创建了track_positions
列表。Pygame Zero 目前不包含创建多边形的代码。为了克服这个限制,使用 Pygame 方法。与 Pygame Zero 中使用的开始screen.draw
不同,该方法是pygame.draw.polygon
,并且使用screen.surface
将要绘制的表面作为第一个参数传递。
下一个形状是一个矩形,可以直接从 Pygame Zero 绘制。
hull_rect = Rect((xpos+15,ypos-20),(30,10))
screen.draw.filled_rect(hull_rect, tank_color)
hull_rect
是 Rect 对象,它有一个元组来表示起始位置(left,top)和一个元组来表示矩形的像素大小(width,height)。然后与颜色一起传递给screen.draw.filled_rect
。
转台创建为一个椭圆。Pygame Zero 目前不支持椭圆(只有一个圆),所以这也需要使用 Pygame 创建。椭圆被定义为包含椭圆的矩形(Rect 对象)。
turret_rect = Rect((xpos+20,ypos-25),(20,10))
pygame.draw.ellipse(screen.surface, tank_color, turret_rect)
draw 函数中的最后一项是绘制枪管。这是一个矩形,它被旋转以反映选定的角度。因为它是以一个角度绘制的,所以它被创建为一个多边形。确定顶点位置的数学过程非常复杂,所以它被分解成一个独立的函数calc_gun_positions
。枪如图 7-2 所示,显示了枪在坦克上的位置和枪的角度。
图 7-2
使用矢量形状创建的坦克形状
已经编写了calc_gun_positions
函数来支持坦克在屏幕的左边(枪指向右边)或者在屏幕的右边(枪指向左边)。这是通过首先为桶的顶部设置适当的开始位置来完成的,桶的顶部与坦克的外壳重叠。gun_angle
是从图 7-2 所示基准线算起的度数。如果坦克在右边,那么火炮角度通过减去 180 度转换成相对角度。
然后,角度被转换为弧度,因为这是数学模块用于三角函数的角度。然后基于 x 轴变化的余弦和 y 轴变化的正弦创建gun_vector
。该向量给出了 x 和 y 的相对变化,并且可以乘以枪的长度来计算枪顶部顶点的位置。使用类似的技术来找到底部位置,该位置相对于枪向量成 90 度角(取决于它是左还是右而为正或负)。最后,创建一个名为gun_positions
的列表,该列表被返回到 draw 函数以创建多边形。
创造动态景观
在前面的代码中,坦克只是停留在一个静止的位置,悬停在空中。下一部分将为坦克的站立创造景观。不是创建一个每次玩游戏都一样的静态景观,而是创建一个动态景观。这将展示如何使用随机数生成动态景观。图 7-3 显示了一个示例景观。
图 7-3
坦克游戏的动态景观
景观将生成为多边形。你可能认为你可以用一个随机数值来确定 y 轴的值。这并不那么简单,因为随机数会导致每个点之间的明显差异,导致景观过于崎岖和不现实。取而代之的是,通过计算一个随机值作为与先前位置的差值来创建风景。这给出了一个更加渐进的变化。我还在左右两侧创建了一个平坦的区域,这是坦克的位置。清单 7-2 显示了这方面的代码。该代码作为 tanktrajectory.py 包含在源代码中。
import random
import pygame
WIDTH=800
HEIGHT=600
SKY_COLOR = (165, 182, 209)
GROUND_COLOR = (9,84,5)
# How big a chunk to split up x axis
LAND_CHUNK_SIZE = 20
# Max that land can go up or down within chunk size
LAND_MAX_CHG = 20
# Max height of ground
LAND_MIN_Y = 200
# Position of the two tanks - set to zero, update before use
left_tank_position = (0,0)
right_tank_position = (0,0)
def draw():
screen.fill(SKY_COLOR)
pygame.draw.polygon(screen.surface, GROUND_COLOR, land_positions)
# Setup game - allows create new game
def setup():
global left_tank_position, right_tank_position, land_positions
# Setup landscape (these positions represent left side of platform)
# Choose a random position
# The complete x,y co-ordinates will be saved in a
# tuple in left_tank_rect and right_tank_rect
left_tank_x_position = random.randint (10,300)
right_tank_x_position = random.randint (500,750)
# Sub divide screen into chunks for the landscape
# store as list of x positions (0 is first position)
current_land_x = 0
current_land_y = random.randint (300,400)
land_positions = [(current_land_x,current_land_y)]
while (current_land_x < WIDTH):
if (current_land_x == left_tank_x_position):
# handle tank platform
left_tank_position = (current_land_x, current_land_y)
# Create level ground for the tank to sit on
# Add another 50 pixels further along at same y position
current_land_x += 60
land_positions.append((current_land_x, current_land_y))
continue
elif (current_land_x == right_tank_x_position):
# handle tank platform
right_tank_position = (current_land_x, current_land_y)
# Create level ground for the tank to sit on
# Add another 50 pixels further along at same y position
current_land_x += 60
land_positions.append((current_land_x, current_land_y))
continue
# Checks to see if next position will be where the tanks are
if (current_land_x < left_tank_x_position and current_land_x +
LAND_CHUNK_SIZE >= left_tank_x_position):
# set x position to tank position
current_land_x = left_tank_x_position
elif (current_land_x < right_tank_x_position and current_land_x +
LAND_CHUNK_SIZE >= right_tank_x_position):
# set x position to tank position
current_land_x = right_tank_x_position
elif (current_land_x + LAND_CHUNK_SIZE > WIDTH):
current_land_x = WIDTH
else:
current_land_x += LAND_CHUNK_SIZE
# Set the y height
current_land_y += random.randint(0-LAND_MAX_CHG,LAND_MAX_CHG)
# check not too high or too low
# Note the reverse logic as high y is bottom of screen
if (current_land_y > HEIGHT): # Bottom of screen
current_land_y = HEIGHT
if (current_land_y < LAND_MIN_Y):
current_land_y = LAND_MIN_Y
# Add to list
land_positions.append((current_land_x, current_land_y))
# Add end corners
land_positions.append((WIDTH,HEIGHT))
land_positions.append((0,HEIGHT))
# Setup the game (at end so that it can see the other functions)
setup()
Listing 7-2Code to generate a random landscape for the tank game
在设置了一些常量和变量之后,调用 setup 函数。调用setup
的指令在文件的底部。这是因为在 Python 中,函数必须在被调用之前定义,所以通过将它放在文件的底部,所有早期的函数都已经被加载了。
在创建地面之前,需要计算坦克的位置。这是为了使代码可以确保坦克安装在地面的水平部分。储罐的 x 位置是基于随机整数设置的;一旦计算了地面,将在稍后添加 y 位置。然后背景被分割成固定大小的块。如果一个坦克会在下一个区域,那么块会在这个位置结束,这样就可以创建水平区域了。然后创建下一个块,y 轴保持不变。
如果当前区域没有坦克,那么它会被随机改变。所有这些位置都被添加到一个列表中,然后由 draw 函数用来绘制多边形。
计算轨迹
当炮弹从枪中射出时,它不是沿着一条直线。这是由几个因素造成的,主要的影响是重力。忽略其他因素,那么引力把它拉向地球将导致壳的路径形成抛物线,因为它首先升高,然后开始向地球回落。
在现实世界中,由于空气阻力和遇到的任何风阻力,路径会被扭曲。为了简单起见,这个程序只考虑重力。这将由函数update_shell_position
和函数draw_shell
来处理。为了说明这一点,我创建了一个程序 tanktrajectory.py,它将显示某组值的完整路径。路径如图 7-4 所示,修改颜色以提高对比度。
图 7-4
坦克炮弹发射轨迹示例
清单 7-3 中显示了演示这一点的代码。
import math
import pygame
WIDTH=800
HEIGHT=600
SKY_COLOR = (165, 182, 209)
SHELL_COLOR = (255,255,255)
shell_start_position = (50,500)
left_gun_angle = 50
left_gun_power = 60
-
shell_positions = []
def draw_shell (position):
(xpos, ypos) = position
# Create rectangle of the shell
shell_rect = Rect((xpos,ypos),(5,5))
pygame.draw.ellipse(screen.surface, SHELL_COLOR, shell_rect)
def draw():
screen.fill(SKY_COLOR)
for this_position in shell_positions:
draw_shell(this_position)
def update_shell_position (left_right):
global shell_power, shell_angle, shell_start_position, shell_current_position, shell_time
init_velocity_y = shell_power * math.sin(shell_angle)
# Direction - multiply by -1 for left to right
if (left_right == 'left'):
init_velocity_x = shell_power * math.cos(shell_angle)
else:
init_velocity_x = shell_power * math.cos(math.pi - shell_angle)
# Gravity constant is 9.8 m/s² but this is in terms of screen so instead use a suitable value
GRAVITY_CONSTANT = 0.004
# Constant to give a sensible distance on x axis
DISTANCE_CONSTANT = 1.5
# time is calculated in update cycles
shell_x = shell_start_position[0] + init_velocity_x * shell_time * DISTANCE_CONSTANT
shell_y = shell_start_position[1] + -1 * ((init_velocity_y * shell_time) -
(0.5 * GRAVITY_CONSTANT * shell_time * shell_time))
shell_current_position = (shell_x, shell_y)
shell_time += 1
def setup_trajectory():
global shell_positions, shell_current_position, shell_power, shell_angle, shell_time
shell_current_position = shell_start_position
shell_angle = math.radians (left_gun_angle)
shell_power = left_gun_power / 40
shell_time = 0
while (shell_current_position[0] < WIDTH and shell_current_position[1] < HEIGHT):
update_shell_position("left")
shell_positions.append(shell_current_position)
setup_trajectory()
Listing 7-3Code to demonstrate trajectory for a tank shell being fired
setup_trajectory
函数用于演示轨迹,不会包含在游戏中。它设置角度,然后创建一个 while 循环,计算炮弹落地或离开屏幕右侧之前将经过的所有位置。
update_shell_position
功能从计算 x 和 y 方向的初速度开始。这是基于枪的威力和角度。
然后需要两个常数:一个表示重力常数(拉向地球的力的大小)的值,另一个表示距离常数,它影响壳在每一步在 x 方向上移动的距离。重力的值是 9.8 米/秒 2
,但这是假设以米为单位测量的真实距离。在电脑屏幕的情况下,我们用像素来度量虚拟距离。所使用的值是使用试错法创建的,以获得看起来真实并给出合适曲线的值。相同的试错法用于距离常数。然后将这些值包含在以下算法中,以确定每个时间间隔的壳位置。
shell_x = shell_start_position[0] + init_velocity_x * shell_time * DISTANCE_CONSTANT
shell_y = shell_start_position[1] + -1 * ((init_velocity_y * shell_time) -
(0.5 * GRAVITY_CONSTANT * shell_time * shell_time))
这不包括任何空气阻力、风阻力的因素,也不包括除重力之外作用在壳体上的任何其他力。
这个演示程序同时显示所有的贝壳位置,但在游戏中一次只会画一个贝壳,它会在屏幕上缓慢移动。
检测碰撞
在早期的游戏中,碰撞是基于矩形碰撞特性的。虽然这是一个有用的技术,但它没有这个游戏所需要的精确度。一种替代技术是通过寻找像素的颜色来检测炮弹何时与坦克或地面碰撞,以查看它是否与坦克或地面的颜色匹配。要做到这一点,地面和每个坦克的颜色必须是独一无二的。清单 7-4 显示了将用于检测碰撞的函数。
def detect_hit (left_right):
global shell_current_position
(shell_x, shell_y) = shell_current_position
# Add offset (3 pixels)
# offset left/right depending upon direction of fire
if (left_right == "left"):
shell_x += 3
else:
shell_x -= 3
shell_y += 3
offset_position = (math.floor(shell_x), math.floor(shell_y))
# Check whether it's off the screen
# temporary if just y axis, permanent if x
if (shell_x > WIDTH or shell_x <= 0 or shell_y >= HEIGHT):
return 10
if (shell_y < 1):
return 1
# Get color at position
color_pixel = screen.surface.get_at(offset_position)
if (color_pixel == GROUND_COLOR):
return 11
if (left_right == 'left' and color_pixel == TANK_COLOR_P2):
return 20
if (left_right == 'right' and color_pixel == TANK_COLOR_P1):
return 20
return 0
Listing 7-4Function to detect collision with tank or ground
这段代码在外壳前面创建一个偏移量,这样就不会看到它自己的颜色。然后检查该位置是否在屏幕之外。如果它超过了屏幕的顶部,那么这只是一个暂时的情况,所以如果它离开了屏幕的右侧或左侧,它将返回一个不同的值。然后,代码使用下面一行来读取偏移位置的像素值:
color_pixel = screen.surface.get_at(offset_position)
这将返回偏移位置的像素值。如果该值与坦克或地面的颜色匹配,那么它将返回一个适当的值。
在这个函数中,返回的值只是被选择来代表不同条件的值。如果您正在编写将在其他程序中重用的代码,那么创建一个常量以便更容易理解该值的含义通常是一个好主意。例如,在第六章中查看鼠标的状态时,会进行一个测试来查看按钮的值是否等于mouse.LEFT
。mouse.LEFT
的值只是一个数字,恰好是 1。一般来说,记住mouse.LEFT
比记住每个不同按钮产生的数字更容易。因为这仅用于这个特定的函数,所以返回真实值,但是代码中包含了注释来解释这些值的含义。
完整的游戏代码
还有相当多的额外代码,但是大部分都包含了在前面章节中已经演示过的技术。
与大多数程序一样,需要跟踪游戏的状态,以了解哪个玩家当前处于活动状态,或者显示适当的消息。这是通过在变量game_state
中设置适当的文本来实现的。不同的状态列在程序开始时的注释中;他们是
-
“开始”——开始前的定时延迟
-
“球员 1”——等待球员摆好位置
-
“玩家 1 开火”——玩家 1 开火
-
“玩家 2”——玩家 2 设定位置
-
“玩家 2 开火”——玩家 2 开火了
-
“game _ over _ 1”-显示玩家 1 赢了
-
“game _ over _ 2”——显示玩家 2 赢了
这些状态在更新或绘制函数中有适当的代码,以确保游戏给出正确的提示或适当地处理输入。
从更新函数中调用player_keyboard
函数,检查是否有按键被按下。如果按下向上或向下按钮,则枪仰角被调整;如果按下左按钮或右按钮,则调整功率(以最大功率的百分比表示),如果按下空格键,则发射炮弹。还有一个额外的测试,看看是否按下了左键,这是另一个选项,而不是空格来发射炮弹。这是为了让游戏可以与 Picade 或其他基于 Raspberry Pi 的街机一起工作,这些机器将该键映射到一个物理按钮。
游戏第一次运行时,所有需要运行的代码都有一个设置函数。这创造了景观,也为以后需要的许多变量设置了值。还有额外的代码向用户显示消息。完整游戏的代码如清单 7-5 所示。
import math
import random
import pygame
WIDTH=800
HEIGHT=600
# States are:
# start - timed delay before start
# player1 - waiting for player to set position
# player1fire - player 1 fired
# player2 - player 2 set position
# player2fire - player 2 fired
# game_over_1 / game_over_2 - show who won 1 = player 1 won etc.
game_state = "player1"
# Color constants
SKY_COLOR = (165, 182, 209)
GROUND_COLOR = (9,84,5)
# Different tank colors for player 1 and player 2
# These colors must be unique as well as the GROUND_COLOR
TANK_COLOR_P1 = (216, 216, 153)
TANK_COLOR_P2 = (219, 163, 82)
SHELL_COLOR = (255,255,255)
TEXT_COLOR = (255,255,255)
# How big a chunk to split up x axis
LAND_CHUNK_SIZE = 20
# Max that land can go up or down within chunk size
LAND_MAX_CHG = 20
# Max height of ground
LAND_MIN_Y = 200
# Timer used to create delays before action (prevent accidental button press)
game_timer = 0
# Angle that the gun is pointing (degrees relative to horizontal)
left_gun_angle = 20
right_gun_angle = 50
# Amount of power to fire with - is divided by 40 to give scale 10 to 100
left_gun_power = 25
right_gun_power = 25
# These are shared between left and right as we only fire one shell at a time
shell_power = 1
shell_angle = 0
shell_time = 0
# Position of shell when fired (create as a global - but update before use)
shell_start_position = (0,0)
shell_current_position = (0,0)
# Position of the two tanks - set to zero, update before use
left_tank_position = (0,0)
right_tank_position = (0,0)
# Draws tank (including gun - which depends upon direction and aim)
# left_right can be "left" or "right" to depict which position the tank is in
# tank_start_pos requires x, y co-ordinates as a tuple
# angle is relative to horizontal - in degrees
def draw_tank (left_right, tank_start_pos, gun_angle):
(xpos, ypos) = tank_start_pos
# Set appropriate color for the tank
if (left_right == "left"):
tank_color = TANK_COLOR_P1
else:
tank_color = TANK_COLOR_P2
# The shape of the tank track is a polygon
# (uses list of tuples for the x and y co-ords)
track_positions = [
(xpos+5, ypos-5),
(xpos+10, ypos-10),
(xpos+50, ypos-10),
(xpos+55, ypos-5),
(xpos+50, ypos),
(xpos+10, ypos)
]
# Polygon for tracks (pygame not pygame zero)
pygame.draw.polygon(screen.surface, tank_color, track_positions)
# hull uses a rectangle which uses top right co-ords and dimensions
hull_rect = Rect((xpos+15,ypos-20),(30,10))
# Rectangle for tank body "hull" (pygame zero)
screen.draw.filled_rect(hull_rect, tank_color)
# Despite being an ellipse pygame requires this as a rect
turret_rect = Rect((xpos+20,ypos-25),(20,10))
# Ellipse for turret (pygame not pygame zero)
pygame.draw.ellipse(screen.surface, tank_color, turret_rect)
# Gun position involves more complex calculations so in a separate function
gun_positions = calc_gun_positions (left_right, tank_start_pos, gun_angle)
# Polygon for gun barrel (pygame not pygame zero)
pygame.draw.polygon(screen.surface, tank_color, gun_positions)
def draw_shell (position):
(xpos, ypos) = position
# Create rectangle of the shell
shell_rect = Rect((xpos,ypos),(5,5))
pygame.draw.ellipse(screen.surface, SHELL_COLOR, shell_rect)
# Calculate the polygon positions for the gun barrel
def calc_gun_positions (left_right, tank_start_pos, gun_angle):
(xpos, ypos) = tank_start_pos
# Set the start of the gun (top of barrel at point it joins the tank)
if (left_right == "right"):
gun_start_pos_top = (xpos+20, ypos-20)
else:
gun_start_pos_top = (xpos+40, ypos-20)
# Convert angle to radians (for right subtract from 180 deg first)
relative_angle = gun_angle
if (left_right == "right"):
relative_angle = 180 - gun_angle
angle_rads = relative_angle * (math.pi / 180)
# Create vector based on the direction of the barrel
# Y direction *-1 (due to reverse y of screen)
gun_vector = (math.cos(angle_rads), math.sin(angle_rads) * -1)
# Determine position bottom of barrel
# Create temporary vector 90deg to existing vector
if (left_right == "right"):
temp_angle_rads = math.radians(relative_angle - 90)
else:
temp_angle_rads = math.radians(relative_angle + 90)
temp_vector = (math.cos(temp_angle_rads), math.sin(temp_angle_rads) * -1)
# Add constants for gun size
GUN_LENGTH = 20
GUN_DIAMETER = 3
gun_start_pos_bottom = (gun_start_pos_top[0] + temp_vector[0] * GUN_DIAMETER, gun_start_pos_top[1] + temp_vector[1] * GUN_DIAMETER)
# Calculate barrel positions based on vector from start position
gun_positions = [
gun_start_pos_bottom,
gun_start_pos_top,
(gun_start_pos_top[0] + gun_vector[0] * GUN_LENGTH, gun_start_pos_top[1] + gun_vector[1] * GUN_LENGTH),
(gun_start_pos_bottom[0] + gun_vector[0] * GUN_LENGTH, gun_start_pos_bottom[1] + gun_vector[1] * GUN_LENGTH),
]
return gun_positions
def draw():
global game_state, left_tank_position, right_tank_position, left_gun_angle, right_gun_angle, shell_start_position
screen.fill(SKY_COLOR)
pygame.draw.polygon(screen.surface, GROUND_COLOR, land_positions)
draw_tank ("left", left_tank_position, left_gun_angle)
draw_tank ("right", right_tank_position, right_gun_angle)
if (game_state == "player1" or game_state == "player1fire"):
screen.draw.text("Player 1\nPower "+str(left_gun_power)+"%", fontsize=30, topleft=(50,50), color=(TEXT_COLOR))
if (game_state == "player2" or game_state == "player2fire"):
screen.draw.text("Player 2\nPower "+str(right_gun_power)+"%", fontsize=30, topright=(WIDTH-50,50), color=(TEXT_COLOR))
if (game_state == "player1fire" or game_state == "player2fire"):
draw_shell(shell_current_position)
if (game_state == "game_over_1"):
screen.draw.text("Game Over\nPlayer 1 wins!", fontsize=60, center=(WIDTH/2,200), color=(TEXT_COLOR))
if (game_state == "game_over_2"):
screen.draw.text("Game Over\nPlayer 2 wins!", fontsize=60, center=(WIDTH/2,200), color=(TEXT_COLOR))
def update():
global game_state, left_gun_angle, left_tank_position, shell_start_position, shell_current_position, shell_angle, shell_time, left_gun_power, right_gun_power, shell_power, game_timer
# Delayed start (prevent accidental firing by holding start button down)
if (game_state == 'start'):
game_timer += 1
if (game_timer == 30):
game_timer = 0
game_state = 'player1'
# Only read keyboard in certain states
if (game_state == 'player1'):
player1_fired = player_keyboard("left")
if (player1_fired == True):
# Set shell position to end of gun
# Use gun_positions so we can get start position
gun_positions = calc_gun_positions ("left", left_tank_position, left_gun_angle)
shell_start_position = gun_positions[3]
shell_current_position = gun_positions[3]
game_state = 'player1fire'
shell_angle = math.radians (left_gun_angle)
shell_power = left_gun_power / 40
shell_time = 0
if (game_state == 'player1fire'):
update_shell_position ("left")
# shell value is whether the shell is inflight, hit or missed
shell_value = detect_hit("left")
# shell_value 20 is if other tank hit
if (shell_value >= 20):
game_state = 'game_over_1'
# 10 is offscreen and 11 is hit ground, both indicate missed
elif (shell_value >= 10):
game_state = 'player2'
if (game_state == 'player2'):
player2_fired = player_keyboard("right")
if (player2_fired == True):
# Set shell position to end of gun
# Use gun_positions so we can get start position
gun_positions = calc_gun_positions ("right", right_tank_position, right_gun_angle)
shell_start_position = gun_positions[3]
shell_current_position = gun_positions[3]
game_state = 'player2fire'
shell_angle = math.radians (right_gun_angle)
shell_power = right_gun_power / 40
shell_time = 0
if (game_state == 'player2fire'):
update_shell_position ("right")
# shell value is whether the shell is inflight, hit or missed
shell_value = detect_hit("right")
# shell_value 20 is if other tank hit
if (shell_value >= 20):
game_state = 'game_over_2'
# 10 is offscreen and 11 is hit ground, both indicate missed
elif (shell_value >= 10):
game_state = 'player1'
if (game_state == 'game_over_1' or game_state == 'game_over_2'):
# Allow space key or left-shift (picade) to continue
if (keyboard.space or keyboard.lshift):
game_state = 'start'
# Reset position of tanks and terrain
setup()
def update_shell_position (left_right):
global shell_power, shell_angle, shell_start_position, shell_current_position, shell_time
init_velocity_y = shell_power * math.sin(shell_angle)
# Direction - multiply by -1 for left to right
if (left_right == 'left'):
init_velocity_x = shell_power * math.cos(shell_angle)
else:
init_velocity_x = shell_power * math.cos(math.pi - shell_angle)
# Gravity constant is 9.8 m/s² but this is in terms of screen so instead use a sensible constant
GRAVITY_CONSTANT = 0.004
# Constant to give a sensible distance on x axis
DISTANCE_CONSTANT = 1.5
# Wind is not included in this version, to implement then decreasing wind value is when the wind is against the fire direction
# wind > 1 is where wind is against the direction of fire. Wind must never be 0 or negative (which would make it impossible to fire forwards)
wind_value = 1
# time is calculated in update cycles
shell_x = shell_start_position[0] + init_velocity_x * shell_time * DISTANCE_CONSTANT
shell_y = shell_start_position[1] + -1 * ((init_velocity_y * shell_time) - (0.5 * GRAVITY_CONSTANT * shell_time * shell_time * wind_value))
shell_current_position = (shell_x, shell_y)
shell_time += 1
# Detects if the shell has hit something.
# Simple detection looks at color of the screen at the position
# uses an offset to not detect the actual shell
# Return 0 for in-flight,
# 1 for offscreen temp (too high),
# 10 for offscreen permanent (too far),
# 11 for hit ground,
# 20 for hit other tank
def detect_hit (left_right):
global shell_current_position
(shell_x, shell_y) = shell_current_position
# Add offset (3 pixels)
# offset left/right depending upon direction of fire
if (left_right == "left"):
shell_x += 3
else:
shell_x -= 3
shell_y += 3
offset_position = (math.floor(shell_x), math.floor(shell_y))
# Check whether it's off the screen
# temporary if just y axis, permanent if x
if (shell_x > WIDTH or shell_x <= 0 or shell_y >= HEIGHT):
return 10
if (shell_y < 1):
return 1
# Get color at position
color_pixel = screen.surface.get_at(offset_position)
if (color_pixel == GROUND_COLOR):
return 11
if (left_right == 'left' and color_pixel == TANK_COLOR_P2):
return 20
if (left_right == 'right' and color_pixel == TANK_COLOR_P1):
return 20
return 0
# Handles keyboard for players
# If player has hit fire key (space) then returns True
# Otherwise changes angle of gun if applicable and returns False
def player_keyboard(left_right):
global shell_start_position, left_gun_angle, right_gun_angle, left_gun_power, right_gun_power
# get current angle
if (left_right == 'left'):
this_gun_angle = left_gun_angle
this_gun_power = left_gun_power
else:
this_gun_angle = right_gun_angle
this_gun_power = right_gun_power
# Allow space key or left-shift (picade) to fire
if (keyboard.space or keyboard.lshift):
return True
# Up moves firing angle upwards, down moves it down
if (keyboard.up):
this_gun_angle += 1
if (this_gun_angle > 85):
this_gun_angle = 85
if (keyboard.down):
this_gun_angle -= 1
if (this_gun_angle < 0):
this_gun_angle = 0
# left reduces power, right increases power
if (keyboard.right):
this_gun_power += 1
if (this_gun_power > 100):
this_gun_power = 100
if (keyboard.left):
this_gun_power -= 1
if (this_gun_power < 10):
this_gun_power = 10
# Update the appropriate global (left / right)
if (left_right == 'left'):
left_gun_angle = this_gun_angle
left_gun_power = this_gun_power
else:
right_gun_angle = this_gun_angle
right_gun_power = this_gun_power
return False
# Setup game - allows create new game
def setup():
global left_tank_position, right_tank_position, land_positions
# Setup landscape (these positions represent left side of platform)
# Choose a random position
# The complete x,y co-ordinates will be saved in a tuple in left_tank_rect and right_tank_rect
left_tank_x_position = random.randint (10,300)
right_tank_x_position = random.randint (500,750)
# Sub divide screen into chunks for the landscape
# store as list of x positions (0 is first position)
current_land_x = 0
current_land_y = random.randint (300,400)
land_positions = [(current_land_x,current_land_y)]
while (current_land_x < WIDTH):
if (current_land_x == left_tank_x_position):
# handle tank platform
left_tank_position = (current_land_x, current_land_y)
# Add another 50 pixels further along at same y position (level ground for tank to sit on)
current_land_x += 60
land_positions.append((current_land_x, current_land_y))
continue
elif (current_land_x == right_tank_x_position):
# handle tank platform
right_tank_position = (current_land_x, current_land_y)
# Add another 50 pixels further along at same y position (level ground for tank to sit on)
current_land_x += 60
land_positions.append((current_land_x, current_land_y))
continue
# Checks to see if next position will be where the tanks are
if (current_land_x < left_tank_x_position and current_land_x + LAND_CHUNK_SIZE >= left_tank_x_position):
# set x position to tank position
current_land_x = left_tank_x_position
elif (current_land_x < right_tank_x_position and current_land_x + LAND_CHUNK_SIZE >= right_tank_x_position):
# set x position to tank position
current_land_x = right_tank_x_position
elif (current_land_x + LAND_CHUNK_SIZE > WIDTH):
current_land_x = WIDTH
else:
current_land_x += LAND_CHUNK_SIZE
# Set the y height
current_land_y += random.randint(0-LAND_MAX_CHG,LAND_MAX_CHG)
# check not too high or too lower (note the reverse logic as high y is bottom of screen)
if (current_land_y > HEIGHT): # Bottom of screen
current_land_y = HEIGHT
if (current_land_y < LAND_MIN_Y):
current_land_y = LAND_MIN_Y
# Add to list
land_positions.append((current_land_x, current_land_y))
# Add end corners
land_positions.append((WIDTH,HEIGHT))
land_positions.append((0,HEIGHT))
# Setup the game (at end so that it can see the other functions)
setup()
Listing 7-5Complete code for Tank Game Zero
而不是自己输入所有代码,你会找到一个名为 tankgame.py 的书源代码的副本。
您可能会注意到有些代码是重复的。这是因为当参与人 1 在玩的时候有一些代码,参与人 2 也有非常相似的代码。这是通常最好避免的事情;这不仅意味着更多的输入,还使得记住更新两个容器的代码以及在出错时进行调试变得更加困难。这是可以在未来版本中重构的,也是面向对象编程可以帮助解决的,这将在第九章中介绍。
改进游戏
这个游戏是从制作一个令人愉快的游戏开始的。事实上,有几个商业游戏是基于火炮游戏的概念。许多人使用坦克,但其他人用其他物体代替坦克,如针对城堡墙壁的弹射器或带有各种不同武器的蠕虫。甚至有一个游戏使用弹射器向试图偷吃鸡蛋的猪发射不同的鸟。
现在你已经了解了相关的概念,你能想出让游戏更有趣的方法吗?以下是我的一些想法:
-
拥有多条生命或者需要不同数量的伤害等级(生命值)。
-
改变参与人先走的顺序,这样参与人 1 并不总是占优势。
-
用不同的风量加上风阻。
-
添加音效或背景音乐。
-
显示炮弹击中时的爆炸。
-
添加计算机播放器选项。
-
有不同形状或不同颜色的坦克。
-
不同的坦克可以有不同的力量和生命值,这样可以在更强的火力和更好的抗打击之间做出选择。
-
赚取点数用于坦克升级。
-
让油箱移动。
-
多辆坦克。
-
不同的武器。
-
用不同的物体或生物替换坦克。
您可以将这些特性添加到现有的代码中,或者使用您学到的概念来创建另一个游戏。
摘要
本章涵盖了各种技术,包括绘制矢量图像,创建动态景观,计算轨迹,以及创建游戏的其他步骤。坦克游戏将在下一章中再次使用,这将增加一些音效和背景音乐。
八、声音
在游戏中加入声音会增加一个额外的维度,有助于让游戏变得生动。这可以通过添加特殊效果声音或添加背景音乐来设置情绪来实现。你也可以使用声音作为游戏中的一个关键组件。
除了介绍如何通过 Pygame Zero 将音乐添加到游戏中,本章还将介绍创建音效或音乐的方法,以及一些可用于处理声音的工具。
本章从如何创造自己的声音和音乐开始。如果你只是对使用别人创造的音效或音乐感兴趣,你可以跳到这一章的后面,在那里声音被加入到一个 Pygame Zero 游戏中。
录制声音效果
对于真实的声音效果,它们通常是通过录制真实的声音来创建的。然而,可能无法记录你在游戏中创造的效果。如果你碰巧没有挑战者坦克,那么你可能需要看一些听起来像坦克的东西,而不是记录一辆真正的坦克。如果你正在创建一个未来的科幻游戏,那么你可能需要看看计算机生成的声音。
即使你能记录下你想要的确切效果,这听起来也不太适合游戏。我关注的一件事是如何创造蒸汽火车的声音。在合理的距离内,我有几条保存铁路,所以我拜访了它们来记录声音。一个问题是,有许多来自人、宠物和周围其他事物(如汽车交通)的额外背景噪音。此外,录制的声音虽然真实,但并不符合您可能期望的声音,也不符合游戏中正在发生的事情。例如,在记录火车的声音时,机车的声音伴随着许多不同的噪音,如车厢叮当作响和车轮摩擦轨道的声音。我发现,通过记录机车脱离火车时的声音,而不是牵引火车时的声音,可以获得更好的声音。
当你想录制声音时,你可能不想带着树莓皮、屏幕和附件。在这种情况下,你可以使用一个便携式录音机,也许是一个移动电话,使用录像机或使用录音工具。如果您已经用手机捕获了合适的音频格式,本文将详细介绍如何使用 Audacity 转换和编辑这些格式。
创造人工音效
如果你不能录下真实的声音效果,那么也许可以用家用物品创造一个等效的声音。这里有几个例子:
-
在沙砾铺成的托盘中行走时鞋子发出的嘎吱声。
-
拍击椰子壳发出的马蹄声。
-
基于烟火的爆炸。如果当地法律不允许消费烟花,那么你可以记录一个专业的展示。
-
浴缸里发出的水声。
我在坦克游戏中使用了人工音效。坦克开火的声音是基于爆开一个气球,时间变慢。爆炸的声音是在一次公开的烟火表演中录制的。
您也可以使用 Sonic Pi 之类的音乐创作工具合成创建声音效果。可以使用不同形状的波形和添加音频效果来创建各种声音,尤其适用于科幻类型的效果。
有一些网站提供了如何创建人工音效的示例。这里列出了两个例子,但还有其他例子。
-
epic 声音
在树莓派上录制音频
Raspberry Pi 不包括音频输入。如果你想直接在树莓派上录音,那么你需要一个音频输入设备。最常见的方法是 USB 麦克风(如图 8-1 所示)或带有麦克风插座的 USB 音频适配器。
图 8-1
带 USB 麦克风的树莓派
在录制声音之前,您应该通过电视或外部扬声器播放声音来测试音频在 Raspberry Pi 上是否正常工作。aplay 命令可通过以下命令使用:
aplay /usr/share/sounds/alsa/Front_Left.wav
aplay /usr/share/sounds/alsa/Front_Right.wav
这些命令通过左右扬声器测试立体声。如果没有声音,则桌面右上角的声音图标提供了模拟(耳机插孔)或 HDMI 的选择。或者,也可以通过终端配置工具进行更改。
sudo raspi-config
选择高级选项,然后选择音频,这将提供使用选项
-
汽车
-
力 3.5 毫米(“耳机”)插孔
-
HDMI 电源
连接 USB 麦克风
连接麦克风后,您应该从终端运行 dmesg 来查看所连接设备的详细信息。dmesg 工具将显示来自内核环形缓冲区日志的消息。
dmesg
在底部,您应该会看到类似于清单 8-1 中所示消息的条目。
[ 3407.526441] usb 1-1.3: new full-speed USB device number 4 using xhci_hcd
[ 3407.670531] usb 1-1.3: New USB device found, idVendor=0c76, idProduct=1690, bcdDevice= 1.00
[ 3407.670539] usb 1-1.3: New USB device strings: Mfr=0, Product=1, SerialNumber=0
[ 3407.670544] usb 1-1.3: Product: USB PnP Device(Echo-058)
[ 3407.677945] input: USB PnP Device(Echo-058) as /devices/platform/scb/fd500000.pcie/pci0000:00/0000:00:00.0/0000:01:00.0/usb1/1-1/1-1.3/1-1.3:1.2/0003:0C76:1690.0007/input/input15
[ 3407.746906] hid-generic 0003:0C76:1690.0007: input,hidraw3: USB HID v1.00 Device [USB PnP Device(Echo-058)] on usb-0000:01:00.0-1.3/input2
[ 3407.844707] usb 1-1.3: Warning! Unlikely big volume range (=496), cval->res is probably wrong.
[ 3407.844724] usb 1-1.3: [50] FU [Mic Capture Volume] ch = 1, val = 0/7936/16
[ 3407.847365] usbcore: registered new interface driver snd-usb-audio
Listing 8-1Partial output of dmesg showing USB microphone
这个例子是使用 Fifine 技术的 USB 麦克风。它使用驱动程序 Echo-058。
右击桌面右上方的声音图标也可以看到设备,如图 8-2 所示。
图 8-2
带 USB 麦克风的 Raspberry Pi 声音设置
使用记录
连接麦克风后,有几种不同的工具可以用来录音。对于一个简单的命令行工具,标准的 NOOBS 图像中包含了一个记录。
要使用 arecord,通过运行 are cord–l 找到设备,它将给出类似于清单 8-2 中的输出。
arecord -l
***** List of CAPTURE Hardware Devices ****
card 1: DeviceEcho058 [USB PnP Device(Echo-058)], device 0: USB Audio [USB Audio]
Subdevices: 1/1
Subdevice #0: subdevice #0
Listing 8-2Output of arecord –l command
卡号(本例中为 1)和设备号(本例中为 0)构成了设备参考的基础,本例中为 hw:1,0。需要使用 plughw 插件;在本例中,设备是 plughw:1,0。
以下命令将创建一个 wav 文件,16 位 little endian,最长持续时间为 60 秒,保存为文件 audiorecord.wav:
arecord -D plughw:1,0 -t wav -f S16_LE -d 60 audiorecord.wav
使用命令行的一种替代方法是图形应用 Audacity,这将在下面介绍。
大胆
Audacity 是一个强大的工具,可以用来录制和编辑音频。在这里,您将看到如何使用 Audacity 在 Raspberry Pi 上录制音频、转换音频格式、从视频文件中提取音频以及修剪音频文件。
默认情况下,Raspberry Pi 上不包含 Audacity,但是可以使用
sudo apt install audacity
这将在“声音和视频”菜单中添加一个选项。对于其他操作系统,可以从 www.audacityteam.org
下载 Audacity。
程序截图如图 8-3 所示。
图 8-3
Audacity 音频编辑器的截图
这里有一些您可能想尝试的事情的建议,这将有助于您熟悉 Audacity 的一些特性。
大胆记录声音
Audacity 可以直接从麦克风录音。可以使用图形用户界面选择麦克风、开始录音和停止录音。
-
启动 Audacity,它不会显示任何声音波形。
-
确保选择麦克风作为输入设备(显示在麦克风图标旁边)。
-
点按红色的“录制”按钮,对着麦克风说话或录制附近的声音。
-
停止记录。
-
将音频导出为合适的声音格式(WAV 和 OGG 是在 Pygame Zero 中使用的好格式)。
转换音频格式
Audacity 可以读取多种不同的音频文件格式,然后在导出时将它们转换成另一种格式。这可能是从 MP3 文件或 M4A 文件(通常在手机上使用)转换为 WAV 或 OGG 文件。
-
关闭任何现有项目。
-
使用“文件”菜单中的“打开”加载音频文件。
-
选取“导出”以存储为不同的音频格式。
从视频文件中提取音频
除了读取音频文件,Audacity 还可以从 MP4 和 AVI 等视频格式文件中提取音频。该过程与转换音频格式相同,只是您选择视频作为源,而不是音频文件。
修剪音频文件
通常在创建音频文件时,您会在想要的声音之前和之后有额外的录音。
-
打开音频文件。
-
用鼠标沿着波形选择要调整的部分。
-
按 Delete 键。
-
将更新的声音导出为合适的文件格式。
这已经涵盖了一些有用的特性,但是仅仅触及了 Audacity 的皮毛。它可以处理多个轨道,并提供过滤器,让您应用不同的效果,以声音。
用 Sonic Pi 创作音乐
创作音乐有多种选择。Raspberry Pi 中包含的一个有用的工具是 Sonic Pi。
Sonic Pi 是一个基于代码的音乐创作和表演工具。它是为现场音乐表演而设计的,但也可以用来创作音乐,然后用作电脑游戏的背景音乐。界面截图如图 8-4 所示。它被认为是一个编程工具,所以在 Raspbian 的编程菜单上。
图 8-4
Sonic Pi 音乐创作工具截图
该程序有几个缓冲文本编辑标签,可以输入代码。代码基于 Ruby,这与 Python 有很大不同。在这本书里不可能详述,但是会给出一个例子来说明如何用它来创作背景音乐。
Sonic Pi 中的音乐通常是使用可在代码中操作的样本创建的。它也可以通过输入音符来使用不同的样本乐器演奏一首曲子。清单 8-3 中包含了一段示例音乐。
piano_notes = (ring :r, :c4, :e4, :f4, :g4, :r, :r, :r,
:r, :c4, :e4, :f4, :g4, :r, :r, :r,
:r, :c4, :e4, :f4, :g4, :e4, :c4, :e4,
:d4, :r, :r, :e4, :e4, :d4, :c4, :c4,
:e4, :g4, :g4, :g4, :f4, :r, :r, :e4,
:f4, :g4, :e4, :c4, :d4, :c4)
live_loop :piano do
use_synth :piano
tick
play piano_notes.look, attack: 0.2, release: 0.1, amp: 0.5
sleep 0.25
end
Listing 8-3Code to create music in Sonic Pi
将代码输入其中一个缓冲区,然后按运行。
这段代码的工作原理是播放存储在数组(列表)中的音符,并在循环中播放。曲子是《圣徒在中行进》的简化版。这是一首没有任何版权问题的传统歌曲。
另一个例子显示在清单 8-4 中,这是一个原创作品,作为在 Sonic Pi 中以不同方式创作音乐的例子。
# Example tune for Sonic-Pi
tune1_notes = (ring :c4, :d4, :e4, :f4, :g4, :f4, :d4, :c3)
dsaw_notes = (ring :e4, :r, :g4, :r, :a4, :b4, :r, :a4, :b4, :r, :d5, :r, :b4, :d5, :r, :b4, :r, :e4, :r, :g4, :r, :a4, :b4, :r, :a4, :b4, :r, :d5, :r, :b4, :d5, :r, :b4, :r, :g4, :r, :e4, :r, :e4, :r, :e4, :r, :g4, :r)
piano_notes = (ring :r, :f4, :r, :a4, :r, :g4, :r, :b4)
with_fx :reverb, room: 1, mix: 0.3 do
live_loop :tune1 do
8.times do
tick
play tune1_notes.look, release: 0.1, amp: 0.6
sleep 0.25
end
end
end
with_fx :echo do
live_loop :dsaw do
use_synth :mod_dsaw
play dsaw_notes.look, attack: 0.2, release: 0.1, amp: 0.05
sleep 0.125
end
end
with_fx :flanger do
live_loop :piano do
use_synth :piano
play piano_notes.look, attack: 0.2, release: 0.1, amp: 0.5
sleep 0.125
end
end
Listing 8-4Another musical tune created in Sonic Pi
这使用了三个不同的循环和一些特殊效果。这就产生了一个可以用作游戏背景音乐的曲调。
要将音乐录制为可在 Pygame Zero 中使用的 WAV 文件,请在开始播放音乐前单击录制按钮,然后再次单击录制按钮停止录制,并将其保存为文件。然后,您需要使用 Audacity 在开头或结尾删除任何不想要的沉默。
代码基于 Ruby,这与 Python 非常不同,超出了本书的范围。要了解 Sonic Pi 的更多信息,程序中包含了一个很好的教程。更多细节请看音速小子的左下角。
下载免费的声音和音乐
有很多地方可以下载免费的声音和音乐。这些包括现场效果的录音以及免费提供的原创音乐。每当你从这些网站获得声音或音乐时,你需要检查许可证是否允许你的预期用途。
两个流行的音效网站是声音圣经( http://soundbible.com/
)和 Freesound ( https://freesound.org
)。网站上列出的大多数声音效果都有授权许可,这意味着只要你信任创作者,你就可以用于大多数目的。有些样本确实限制声音仅供个人使用,因此您可能需要小心使用。
如果你正在寻找音乐,那么在知识共享网站上有几个链接 http://bit.ly/ccmusic1
。该网站链接到其他已知有免费音乐的网站,但您需要检查任何使用限制。
在 Pygame Zero 中添加音效
创建或下载了合适的音效后,下一步就是将它添加到你的游戏中。声音可以是 WAV 或 OGG 格式。
要在 Pygame Zero 中播放声音,首先创建一个名为 sounds 的新子目录,并将你的声音效果复制到那里。播放声音的命令格式是sounds
,后跟文件名(没有任何扩展名)和播放等适当的方法。
要播放声音“explode.wav ”,您可以使用
sounds.explode.play()
这种方法应该只用于短暂的声音效果。它会将整个声音文件加载到内存中,如果您尝试将它用于较长的音乐文件,可能会对性能产生重大影响。如果你想演奏更长的音乐,请参阅本章后面的“在 Pygame Zero 中演奏音乐”。
我在 sounds 子目录中加入了两个音效,分别叫做 tankfire.wav 和 explode.wav。这两个音效用于为上一章创建的坦克游戏添加一些音效。
要添加坦克炮开火的声音,在游戏状态设置为'player1fire'
时添加sounds.tankfire.play()
条目。
game_state = 'player1fire'
sounds.tankfire.play()
对于炮弹命中时的爆炸,在游戏状态设置为'game_over_1'
时加上sounds.explode.play()
。
game_state = 'game_over_1'
sounds.explode.play()
应对'player2fire'
和'game_over_2'
重复此操作。所有需要的文件都包含在提供的源代码中。
在 Pygame Zero 中播放音乐
当你需要一些音乐播放更长时间,那么有一个音乐播放器选项。内置的音乐对象提供了通过一次加载一点音轨来播放音乐的能力。它一次只允许播放一首曲目,但可以与声音结合起来,在播放背景音乐的同时产生特殊效果。音乐文件应该存储在名为music
的目录中。
这是 Pygame Zero 中一个相对较新的特性,并且带有警告。音乐支持取决于计算机系统及其对特定编解码器回放的支持程度。它应该可以处理 MP3、OGG 和 WAV 文件。MP3 音乐无法在某些 Linux 系统上播放,这可能是由于现在已经过期的专利。据报道,OGG 的文件也有问题。看起来 WAV 可能是更安全的选择,尽管这可能只是因为报告的问题较少。WAV 文件是未压缩的,这可能会导致文件很大。
要播放音乐曲目,请调用 music.play 并使用音乐曲目的名称。例如,如果您在音乐目录中保存了一首名为 backing.ogg 的曲目,那么您可以使用
music.play('backing')
该曲目将在后台连续播放。如果你只想让音轨播放一次,比如在游戏结束时,那么你可以使用play_once
方法。
music.play_one('victorymusic')
在这两种情况下,它都会停止任何先前的曲目或队列中的任何曲目。如果您想在当前曲目之后添加下一首曲目,那么您可以使用music.queue
。
可以对音乐进行stop
、pause
和unpause
操作,也可以通过在方法名称前加上音乐对象的set_volume
来改变音量。
用音调创作的钢琴游戏
Pygame Zero 的另一个选择是使用内置的音调发生器播放计算机生成的声音。音调发生器是创建声音的一种有用方法,但它使用合成声音,并且不如使用采样声音创建的声音质量好。在 Pygame Zero 的 1.2 版本中加入,包含在 Raspbian 和 Mu 的最新版本中。它可能无法在一些旧版本上工作。
音调发生器允许您选择音调的音高和持续时间。这些确实需要很短的时间来生成(每个音符几毫秒),所以最好提前创建。这是通过使用tone.create
的音高和持续时间来实现的。例如,要演奏中间 C 音(第四个八度音程),您可以使用
middle_c = tone.create('C4', 0.5)
然后使用播放
middle_c.play()
为了把它变成一个游戏,我用音调发生器做了一个简单的基于钢琴的游戏。该游戏将允许你使用虚拟键盘播放音乐,并提供一个游戏,玩家按下适当的键来播放一首曲子。截图如图 8-5 所示。
图 8-5
钢琴游戏截图
点按任何键都将播放相应的音符。单击“演示”按钮将播放该曲调的演示。单击“开始”将开始游戏;当音符到达目标线时点击正确的键将获得一分。
这个游戏是为树莓派触摸屏设计的。它仍然可以和鼠标一起使用,但是当你需要移动鼠标指针的时候就很难玩了。这个游戏的一个限制是玩家一次只能按一个键。这是 Pygame Zero 的一个限制,不支持多点触控。如果你想使用多点触控,那么你需要看看不同的编程框架,比如 Kivy,但这超出了本书的范围。
完整游戏的代码如清单 8-5 所示。按钮是使用形状创建的,因此不需要图像或声音文件。
# Piano Game
# Screen resolution based on Raspberry Pi 7" screen
WIDTH = 800
HEIGHT = 410
# Notes are stored as quarter time intervals
# where no note is played use "
# There is no error checking of the tune, all must be valid notes
# When the saints go marching in
tune = [
", 'C4', 'E4', 'F4', 'G4', ", ", ", ", 'C4', 'E4', 'F4', 'G4', ", ", ",
", 'C4', 'E4', 'F4', 'G4', 'E4', 'C4', 'E4', 'D4', ", ", 'E4', 'E4', 'D4', 'C4', 'C4',
'E4', 'G4', 'G4', 'G4', 'F4', ", ", 'E4', 'F4', 'G4', 'E4', 'C4', 'D4', 'C4'
]
# State allows 'menu' (waiting), 'demo' (play demo), 'game' (game mode), 'gameover' (show score)
state = 'menu'
score = 0
note_start = (50,250)
note_size = (50,160)
# List of notes to include on noteboard
notes_include_natural = ['F3','G3','A3','B3','C4','D4','E4','F4','G4','A4','B4','C5','D5','E5']
# List of sharps (just reference note without sharp)
notes_include_sharp = ['F3','G3','A3','C4','D4','F4','G4','A4','C5','D5']
note_rect_sharp = {}
note_rect_natural = {}
notes_tones = {}
beats_per_minute = 116
# Crotchet is a quarter note
# 1 min div by bpm
time_crotchet = (60/beats_per_minute)
time_note = time_crotchet/2
# how long has elapsed since the last note was started - or a rest
time_since_beat = 0
# The current position that is playing in the list
# A negative number indicates that the notes are shown falling,
# but hasn't reached the play line
note_position = -10
button_demo = Actor("button_demo", (650,40))
button_start = Actor("button_start", (150,40))
# Setup notes
def setup():
global note_rect_natural, note_rect_sharp, notes_tones
i = 0
sharp_width = 2*note_size[0]/3
sharp_height = 2*note_size[1]/3
for note_ref in notes_include_natural:
note_rect_natural[note_ref] = Rect(
(note_start[0]+(note_size[0]*i),note_start[1]),(note_size)
)
# Add note
notes_tones[note_ref]=tone.create(note_ref, time_note)
# Is there a sharp note?
if note_ref in notes_include_sharp:
note_rect_sharp[note_ref] = Rect(
(note_start[0]+(note_size[0]*i)+sharp_width, note_start[1]),
(sharp_width,sharp_height)
)
# Create version in Note#Octave eg. C#4
note_ref_sharp = note_ref[0]+"#"+note_ref[1]
notes_tones[note_ref_sharp]=tone.create(note_ref_sharp, time_note)
i+=1
def draw():
screen.fill('white')
button_demo.draw()
button_start.draw()
draw_piano()
if (state == 'demo' or state == 'game'):
draw_notes()
# draw line for hit point
screen.draw.line ((50, 220), (WIDTH-50, 220), "black")
if (state == 'game'):
screen.draw.text("Score {}".format(score), center=(WIDTH/2,50), fontsize=60,
shadow=(1,1), color=("black"), scolor="white")
if (state == 'gameover'):
screen.draw.text("Game over. Score {}".format(score), center=(WIDTH/2,150), fontsize=60,
shadow=(1,1), color=("black"), scolor="white")
def draw_notes():
for i in range (0, 10):
if (note_position + i < 0):
continue
# If no more notes then finish
if (note_position + i >= len(tune)):
break
draw_a_note (tune[note_position+i], i)
# position is how far ahead
# 0 = current_note, 1 = next_note etc.
def draw_a_note(note_value, position):
if (len(note_value) > 2 and note_value[2] == 's'):
sharp = True
note_value = note_value[0:2]
else:
sharp = False
if (position == 0) :
color = 'green'
else:
color = 'black'
if note_value != ":
if sharp == False:
screen.draw.filled_circle((note_rect_natural[note_value].centerx, 220-(15*position)), 10, color)
else:
screen.draw.filled_circle((note_rect_sharp[note_value].centerx, 220-(15*position)), 10, color)
screen.draw.text("#", center=(note_rect_sharp[note_value].centerx+20, 220-(15*position)),
fontsize=30, color=(color))
def update(time_interval):
global time_since_beat, note_position, state
time_since_beat += time_interval
# Only update when the time since last beat is reached
if (time_since_beat < time_crotchet):
return
# reset timer
time_since_beat = 0
if state == 'demo':
note_position += 1
if (note_position >= len(tune)):
note_position = -10
state = 'menu'
# Play current note
if (note_position >= 0 and tune[note_position] != "):
notes_tones[tune[note_position]].play()
elif state == 'game':
note_position += 1
if (note_position >= len(tune)):
note_position = -10
state = 'gameover'
def draw_piano():
for this_note_rect in note_rect_natural.values() :
screen.draw.rect(this_note_rect, 'black')
for this_note_rect in note_rect_sharp.values() :
screen.draw.filled_rect(this_note_rect, 'black')
def on_mouse_down(pos, button):
global state, note_position, score
if (button == mouse.LEFT):
if button_demo.collidepoint(pos):
note_position = -10
state = "demo"
elif button_start.collidepoint(pos):
note_position = -10
state = "game"
else:
# First check sharp notes as they overlap the natural keys
for note_key, note_rect in note_rect_sharp.items():
if (note_rect.collidepoint(pos)):
note_key_sharp = note_key[0]+"#"+note_key[1]
if (note_key_sharp == tune[note_position]):
score += 1
notes_tones[note_key_sharp].play()
return
for note_key, note_rect in note_rect_natural.items():
if (note_rect.collidepoint(pos)):
if (note_key == tune[note_position]):
score += 1
notes_tones[note_key].play()
return
setup()
Listing 8-5Code for Piano Game
我不会一行一行地讨论这个问题,但是我会讨论代码如何工作的一些关键部分。
从顶部开始,您会看到屏幕分辨率设置为仅 410 的高度。这是因为 7 寸屏幕减去顶部菜单栏和窗口装饰后的分辨率。
曲调是一个数组,它列出了需要演奏的音符。在这种情况下,这是为了当圣人在游行。这种音乐起源于大约 19 世纪末到 20 世纪初。你可以用一首更现代的曲子来代替它,但是在这种情况下,如果你重新发行这个游戏,你需要考虑到版权问题。曲调需要非常简单,因为一次只能演奏一个音符,而且只能演奏四分音符和休止符。在这种情况下,音乐被简化并稍作改动。和弦已被单音符取代,较长音符上的延音被移除。曲子应该还是可以辨认的。音符以基于音符和八度音程的字符串形式存储在列表中,其中 C4 是中音 c。如果有一个升半音,则可以通过在音符和八度音程之间添加#来指示。
还有几个其他变量和两个 Actors,它们表示作为图像创建的两个按钮。速度由beats_per_minute
的数字决定,然后转换成每一拍之间的时间长度,以秒为单位。在每分钟 116 拍的情况下,这是一分钟内四分音符的数量。这相当于列表中每个条目的每个四分音符之间有 0.51 秒。大约每 0.016 秒调用一次 update 函数,这应该可以提供相当准确的时间。音符持续时间存储在变量time_note
中,它是音符之间时间的一半,这样如果快速弹奏音符就不会合并。
另一个变量是note_position
,用来表示当前音符所在数组的位置。变量从–10 开始,因为这允许音符从屏幕顶部落下。只有当note_position
达到 0 的时候才会播放那个音符(如果玩试玩的话)或者玩家需要点击音符(在游戏中)。变量之后是函数,后面是对setup
的调用。这是因为这些函数需要在setup
函数试图使用它们之前加载到内存中。即使对setup
的调用是文件的最后一行,它仍然在 Pygame Zero 运行更新和绘制函数之前运行。
setup
函数创建创建键盘所需的rect
对象,并预加载键盘的所有音符。按键被创建为两个独立的列表,临时记号(升半音和降半音)是黑键,自然键是白键。临时记号在代码中被称为升半音,因为它们是从先前的自然调偏移创建的,所以被命名为 C3 的升半音是 C#3。
将使用的每个音符都使用代码预加载到字典notes_tones
中
notes_tones[note_ref]=tone.create(note_ref, time_note)
这防止了放置票据时的延迟。一旦创建,就可以使用
notes_tones['C3'].play()
draw_piano
函数调用自然键的screen.draw.rect
和临时键的screen.draw.filled_rect
。
on_mouse_down
函数处理按钮上的点击,根据需要将状态设置为演示或游戏。它还检测钢琴键盘上的任何键是否被按下,如果是,它开始演奏音符。如果在游戏模式下,如果正确的键被按下,它会增加分数。
update
功能检查下一张纸币是否有足够的时间。它使用参数timer_interval
,该参数给出了自更新函数上次运行以来已经过去的时间。它用这个来跟踪自最后一个音符演奏以来的时间。如果没有到达time_crotchet
中的时间,那么它从函数返回。如果定时器已经超过该时间,那么它可以更新note_position
是否处于演示或游戏状态。
draw
功能显示按钮、键盘和任何需要显示的注释或文本。画一条线作为音符应该何时弹奏的目标。这使用了使用起点和终点坐标的screen.draw.line
。它还会在游戏中显示分数,并在游戏结束时显示游戏结束消息。
这是一个简单有趣的游戏,但要创建一个可以用来教人弹钢琴的游戏,还需要更多的东西。如前所述,缺乏多点触摸是相当有限的。您仍然可以做一些事情来改进游戏,例如在应该按下按键时点亮按键(通过使用带有适当颜色的 filled_rect)并提供一种改变速度的方法。它也仅限于播放四分音符,这是可以改变的,但需要根据音符的持续时间载入每个音符的多个版本。
摘要
本章介绍了在 Sonic Pi 中制作和使用声音和音乐的几种不同方式。这包括使用 Raspberry Pi 作为录音设备,或者转换和编辑其他设备上录制的声音。还介绍了如何使用 Sonic Pi 创建自己的音乐。
然后介绍了通过 Pygame Zero 播放声音的三种不同方式。使用 Sound 对象播放的声音效果、使用 music 对象播放的音乐以及使用 tone 对象播放的音调。
下一章是关于面向对象的编程,展示了使用 Python 创建软件的另一种方法。
九、面向对象编程
到目前为止,这些程序主要使用过程式编程。过程编码风格是学习编程的好方法,但是使用面向对象编程也有好处,这将在接下来讨论。Python 的一个有用之处是它支持许多不同的编码风格,甚至允许在同一个代码中使用多种风格。在使用 Python 模块时,您已经使用了一些面向对象的代码,包括 Pygame Zero。
在解释了面向对象编程的主要概念之后,本章将开始一个新的游戏。这是基于经典游戏“配对”,有时被称为记忆游戏。在这个游戏中,桌子上有几张面朝下的牌。每张卡片都有一张配对的图片。你需要通过每回合翻两张牌来找到对子。如果你成功了,那么你就拿着这些牌,并得到一分。
什么是面向对象编程?
面向对象编程(OOP)是一种不同的编程风格,它基于数据和对数据的操作。在与 Pygame Zero 互动时,你已经在整本书中看到了这一点。参与者是一个对象的实例。一个参与者可以通过改变它的属性来操作,比如 pos(改变它在屏幕上的位置),并且可以对它执行操作,比如 draw 方法(在屏幕上绘制它)。
面向对象编程中的四个主要概念是封装、数据抽象、多态和继承。这些被称为面向对象编程的四大支柱:
-
封装是关于保持内部状态私有以保护数据。Python 没有真正的封装,这通常是通过使用私有变量和方法来实现的。然而,Python 确实有在名称前使用 __(双下划线)的惯例,以防止意外使用私有变量或方法。
-
数据抽象是封装的扩展,它使得隐藏内部操作的细节变得更加容易。这有助于创建一个简单、更稳定的界面。
-
多态性允许一个孩子像他的父母一样行动。这是通过共享代码实现更好的代码重用的一种方式。它还可以根据输入参数提供不同的方法实现。
-
继承允许在相似的对象之间重用部分代码。
这本书将集中在面向对象编程提供的特定方面,这些方面使得游戏的设计和编程变得更加容易。它将展示如何使用封装和抽象来使游戏编程更简单、更容易编写和理解。它还将给出一个例子,说明继承如何通过利用现有代码来帮助减少需要编写的代码量。它还有助于减少全局变量的数量,这些变量会使代码在出错时难以理解和调试。
OOP 类和对象
面向对象编程是基于对象的。在现实世界中,我们认为物体是物理的东西,如笔记本电脑、电话或书籍。在编程中,对象可以是任何存储数据或与你交互的东西。内置对象包括屏幕、演员或声音,您可以为任何东西创建自己的对象。对象将数据保存为对象的内部变量,可以通过对象属性读取和操作这些变量。大多数对象也有可以在对象上执行的操作(方法)。
创建类、属性和方法
要创建对象,需要有一个蓝图来告诉计算机如何处理对象。蓝图被称为类。每个对象都被称为该类的一个实例。通常一个类是在一个单独的文件中创建的,这个文件的名字和这个类一样,以. py 结尾。这个类的名字叫 Ball,它遵循了类名首字母大写的惯例。如果类中有多个单词,那么任何单词的首字母也要大写,比如 MyClass。文件名通常与类名相同,但都是小写的。在这个例子中,类名是 Ball,所以文件叫做 ball.py。您可以将它们放在现有文件中,或者放在包含多个类的文件中,但是通常每个类有一个文件是个好主意。
class Ball():
shape = "sphere"
def __init__(self, position, radius, color):
self.position = position
self.radius = radius
self.color = color
def draw(self, screen):
screen.draw.filled_circle(self.position, self.radius, self.color)
Listing 9-1Example of a OOP class
该文件以一个类定义开始,说明这是一个面向对象的类。这是创建球对象的蓝图。
列出的第一个变量称为shape
,其值为“球体”。这是一个类变量。该变量只有一个实例跨越该类的所有实例。它最常用于不变的值,尽管在下一章,你会看到一个由多个对象编辑的类变量的例子。
更常见的是拥有实例变量,它们对于类的每个实例都是唯一的。实例变量在方法中创建,并以关键字self
为前缀。以 self 为前缀创建的实例变量可用于该类中定义的所有方法。也可以在没有 self 关键字的情况下创建局部变量,它们的行为方式与过程式编程中的局部变量相同。
在这个类中有两种方法。方法与函数本质上是一样的,只是它们对对象执行操作,并且可以访问对象中的数据。它们是使用 def 关键字创建的,类似于函数的定义方式。
第一种方法叫做__init__
。这称为构造函数,每当创建类的实例时都会调用它。有一种方法叫draw
,会在屏幕上画球。你可以看到这两种方法都把self
作为第一个参数。self
关键字用于表示类的实例,并被方法用来访问类实例中的数据。调用方法时,不需要在参数上提供任何内容;相反,self
被自动传递给方法,用于访问实例变量。
第一次创建对象时,运行构造函数方法(__init__
)。它通常用于设置任何变量。在这种情况下,位置、大小和颜色需要三个值。方法中的参数总是作为局部变量保存,所以它们被复制到self.position
、self.radius
和self.color
中。它们存储在对象中,可以根据需要通过任何其他方法读取和写入。没有必要将这些标记为全局的;通过 self 关键字,所有方法都可以自动使用它们。
下一个方法是draw
,它在屏幕上把球画成一个实心圆。它可以访问先前通过构造函数设置的所有变量,即self.position
、self.radius
和self.color
。这种方法有一个异常之处。以前在调用screen.draw
操作时,它使用内置的屏幕对象。这在 Pygame Zero 使用的顶级函数中的 draw 函数内工作,但是当使用单独的对象时,需要将对 screen 对象的引用作为参数提供。
创建类(对象)的实例
创建类后,可以创建类的实例。这就像使用类作为蓝图来创建物理对象一样。如清单 9-2 所示,它创建了一个可执行程序 balldemo.py。
from ball import Ball
WIDTH = 800
HEIGHT = 600
ball1 = Ball((400,300),10,"red")
def draw():
ball1.draw(screen)
Listing 9-2Creating instances of a class
这是一个基本程序,运行时会在屏幕中央显示球。第一行导入该类。它从文件 ball.py(与主程序文件在同一个目录中)中读取它,并从中导入类 ball。导入后,您可以使用该类。
创建了一个名为 ball1 的新对象,它是类的一个实例。为了创建实例,它使用类的名称,后跟构造函数中列出的参数。这将创建新的实例并运行 init 方法。
在 draw 函数中,在绘制球的实例 ball1 上调用 draw 方法。内置屏幕需要传递给 ball1 的 draw 方法,以便它能够绘制到屏幕上。
这将创建球的一个实例,但是您可以使用
ball2 = Ball((100,100),20,"green")
然后使用添加到 draw 方法中
ball2.draw(screen)
它在屏幕的左上角绘制了一个大的绿色球。
访问对象的属性
对象中的变量称为属性。正如您所看到的,类定义中的变量以 self 为前缀,这是指该类的实例。
如果您在类之外,那么您可以用实例名替换 self。对于实例 ball1,您可以使用
ball1.color
如果您想检查这个值,那么您可以使用
if ball1.color == "red":
...
或者如果你想改变这个值,那么你可以使用
ball1.color = "orange"
这适用于在 init 方法或任何其他方法中创建的所有变量,只要这些变量以 self 为前缀。
术语
面向对象编程的一个特点是它使用了所有的新术语。这里是对一些术语的回顾,以使其更加清晰。图 9-1 显示了类和实例之间的关系。
图 9-1
类到实例的关系
图顶部显示的类是创建对象的蓝图。它定义了如何创建类,它们有什么属性,以及可以对它们执行的操作。您通常不能直接使用该类,而是需要创建称为实例的特定对象。在本例中,我们创建了两个实例,称为 ball1 和 ball2。它们都是从同一个蓝图中创建的,所以将以相似的方式运行,但是它们有自己的属性集(存储为实例变量)。使用此球 1 颜色设置为红色,而球 2 颜色设置为绿色。
封装和数据抽象
如前所述,面向对象编程的两个好处是封装和数据抽象。这对程序员的好处是,它将类的内部结构与使用该结构的代码分离开来。当不同的程序员在同一个项目上工作时,这可以使它变得更容易,并且它可以使将来的更改变得更容易。
这可能有用的一个场景是当多人在同一个项目上工作时。如果程序员事先就类的接口达成一致,那么他们就可以独立工作。这超出了单个项目的影响;它还有助于创建可供他人使用的代码库。
另一种情况是您希望将来对代码进行更改。如果你想增加一个新的特性或者改进现有的代码,那么它会把这个类和其他类分开。如果保持接口不变,可以更改 ball 类中的任何内部代码,以完全不同的方式绘制球。
Python 不像其他一些编程语言那样严格地执行数据抽象。从类外部更改任何实例变量都是可能的,这会导致数据抽象的丢失。如果您想在类外隐藏一些变量,那么您可以通过在它们前面加上 __(两个下划线字符)来隐藏它们。即使使用这个,也不是完全的数据抽象。Python 对于面向对象的编程是有用的,但是依赖于程序员来创建稳定的接口和使用良好的编程最佳实践。
遗产
继承允许创建子类,该子类从父类继承它的一些属性和操作。这是避免代码重复的一种方式。这样做的好处是节省了程序员的输入,但更重要的是有助于减少 bug 的数量。
为了演示这一点,您可以想象一个飞行游戏,其中有一个飞机类来表示飞机。如果一个游戏有不同种类的位面,那么这些位面可以做不同的事情。图 9-2 显示了三种不同类型的飞机:客机、货机和战斗机。
图 9-2
使用父类和子类的继承
这些都是不同类型的飞机,所以会有一些共同点。其他事情可能只适用于某些类型的飞机。所有飞机都有许多引擎以及起飞和降落的能力,所以这些都可以在飞机(父)类中配置。还有一些其他的属性和操作是某些类型的球所独有的。例如,客机有许多乘客,但这对战斗机来说没有意义。战斗机可以装载和发射导弹,但是客机和货机都不需要这种能力。
继承的工作方式是在父类中定义公共属性和方法,然后将任何独特的特性添加到子类中。使用子类的代码可以使用从父类继承的或在子类中的操作。清单 9-3 中的代码演示了子类如何引用其父类。
from airplane import Airplane
class PassengerPlane(Airplane):
def __init__(self):
Airplane.__init__(self)
self.number_passengers = 0
def load_passengers (number_passengers):
self.number_passengers = number_passengers
def unload_passengers ():
self.number_passengers = 0
Listing 9-3Inheritance demonstration showing a child class
这段代码继承自飞机类,添加了一个名为number_passengers
的新属性和两个名为load_passengers
和unload_passengers
的新方法。可以使用以下方法创建客机的实例
plane1 = PassengerPlane()
这将访问它自己的方法,例如
plane1.load_passengers(20)
以及父方法,如
plane1.take_off()
面向对象程序设计
当我第一次开始使用面向对象编程时,我发现的一个挑战是决定定义什么对象。在代表现实世界中的物体的情况下,这是显而易见的。在平面的例子中,很明显,它会被认为是一个物体,但不那么有形的东西呢?玩家的分数是一个对象,还是一个属性?如果是属性,那么分数属于什么对象呢?
在某些情况下,某个东西是否应该是一个对象并没有确定的答案。这取决于游戏的类型,它如何与其他对象交互,以及程序员的个人偏好。我将展示我所使用的技术,当你设计你自己的游戏时,这些技术可能会对你有所帮助。这不是强制性的,根据经验,您可能不需要这样做,但这是我在创建新程序时经常使用的一种技术。
这种技术有助于显示要创建什么类以及它们的属性和操作应该是什么。首先,用一两段文字写下这个游戏是做什么的,以及它将如何运作。你应该把它写下来(或者输入电脑),用完整的句子而不是要点来写。不要试图凭记忆做这件事,因为你需要看到下一步要用的单词。现在浏览你写的描述,找出所有的名词。将名词作为可能的类名写下来。接下来看看所有的形容词,然后适用于那些名词,并把它们写在相关的名词下面。这些将是属性(变量)。然后寻找主动动词以及它们与名词的关系。将这些列在它们相关的名词下;这些将成为操作(方法)。
这里有一个简短的总结:
-
名词(名称)-类别
-
形容词(描述性词语)-属性(变量)
-
主动动词(动作词)-操作(方法)
然后,这些单词为类、属性和操作提供了建议。请注意,这些只是建议;然后你应该运用你自己的判断来判断这些是否都是必要的。可能是一些条目彼此相似,或者是一些名词没有足够的意义来拥有它们自己的类别。然而,它确实给了你一个设计类结构的起点。当你开发游戏的时候,你总是可以重新审视这个结构。
配对记忆游戏
既然您已经熟悉了面向对象编程的一些方面,那么是时候在另一个游戏中将它付诸实践了。这是一个传统的基于卡片的记忆游戏的数字版本。该游戏通常使用图片卡进行,每张卡片是一对匹配的卡片中的一张。牌面朝下,玩家轮流翻开两张牌,试图找到匹配的牌对。游戏截图如图 9-3 。
图 9-3
配对记忆游戏截图
在传统游戏中,通常有两个或更多的人轮流试图赢得最多的分数,但在这个版本中,只有一个玩家将与时间赛跑。为了使用面向对象编程来创建它,我首先遵循前面解释的设计方法。我已经写下了这个游戏的如下解释。
这是一个记忆游戏。游戏从许多面朝下的牌开始。每张卡片上都有一张与另一张卡片相匹配的图片。玩家翻开两张牌,看它们是否匹配。如果它们匹配,那么玩家的分数增加。如果它们不匹配,那么卡被重置,玩家有另一次机会。有一个计时器随着时间倒计时,如果计时器值为零,那么游戏结束。如果玩家在一个级别上匹配所有对,那么玩家被奖励奖励点数,并且卡被再次分发。
然后我确定了标有()的名词,它们将成为类。形容词用[];它们将成为属性。主动动词用{}标记;它们将成为操作。
这是一个(记忆游戏)。游戏从一些[面朝下]的(牌)开始。每张(卡片)上都有一张与另一张卡片相匹配的[图片]。(玩家){翻}两张牌,看它们是否{匹配}。如果它们匹配,则玩家的(分数)会{增加}。如果它们不匹配,则卡被{重置},玩家有另一次机会。有一个(计时器)随着时间倒计时,如果[计时器值]为零,那么游戏结束。如果玩家在 a(级别)上匹配所有对子,那么玩家将获得[奖励点数]并再次发牌。
请记住,这些是指导方针,而不是固定的规则。你可以在识别合适的单词和短语时运用你自己的判断力,或者你可以在以后的某个阶段这样做。这只是让程序员更容易决定如何创建不同的类的一种方式。然后,我将它们制作成以下内容:
-
记忆游戏
-
属性:不适用
-
操作:复位;交易
-
玩家
-
属性:分数;奖励积分
-
操作:不适用
-
卡
-
属性:面朝下;画
-
操作:火柴(另一张牌);重置
-
小时
-
属性:剩余时间
-
操作:倒计时;达到零
-
级别
-
属性:不适用
-
操作:不适用
这只能被认为是一个起点。您现在应该回顾一下,看看是否有您现在想要更改的内容。您可以在实现代码时重新审视这一点,因为它可能需要更改。
有些事情立即改变是有意义的。有一点是记忆游戏定义了整体游戏。我们可以创建它,而不需要将它定义为一个单独的类,这将允许它使用 Pygame Zero draw 和 update 函数;它还需要一个变量来跟踪游戏的状态。
另一个值得考虑的是,奖励点数不需要成为属性,而是可以添加到正常分数中;把加分作为一个操作而不是一个属性是有意义的。此外,级别可能不需要成为一个类,但可以合并到记忆游戏或玩家类中。
完成这个练习后,您应该对需要什么类以及一些属性和操作有所了解。在编写代码时,您可能会认为还需要其他的类、属性或操作。您可以在创建代码时添加任何附加的属性或操作。
提供的源代码中有两个版本的代码,一个是初始版本,另一个是更新版本。为了测试代码,你需要一些卡片图片。这本书的源代码包括一些我在英国湖区拍摄的照片。这些是用来制作卡片的,但是你也可以用你自己的照片或图片来个性化这个游戏。
创建类
创建代码没有固定的顺序。在创建主程序之前,我通常先创建一些类。这意味着我可以在编写主程序之前对这些类进行一些测试。这里解释了我创建的类。
计时器类
第一个类是定时器类。timer 类用于跟踪已经过去了多长时间,以便游戏必须在分配的时间内完成。这是一个非常简单的类,但是对于演示如何创建这个类非常有用。代码如清单 9-4 所示,保存在一个名为 timer.py 的文件中。
import math
import time
class Timer():
def __init__(self, start_count):
self.start_count = start_count
self.start_time = time.time()
# start count down, with optional parameter to replace the start_count value
# -1 is used as a "magic number", this method should only be called with positive number
# if it isn't given a number then -1 indicates no new time give
def start_count_down(self, new_time = -1):
if (new_time >= 0):
self.start_count = new_time
self.start_time = time.time()
def get_time_remaining(self):
current_time = self.start_count + self.start_time - time.time()
if (current_time <= 0):
return 0
return math.ceil(current_time)
Listing 9-4Timer class
该文件首先导入两个模块,数学和时间。顾名思义,这些提供了数学和时间函数。
类别由条目定义
class Timer():
这为常规类创建了类定义。类名为 Timer。构造函数被定义为 init 方法。它的第一个参数是 self,它总是包含在类构造函数中。然后,它接受一个参数,即 start_count 值。这是一个倒计时定时器,start_count 值是倒计时的秒数。
def __init__(self, start_count):
self.start_count = start_count
self.start_time = time.time()
构造函数还创建了一个变量self.start_time
,它被传递了从 epoch 开始的秒数。在 Linux 系统上,纪元是 00:00:00 1970-01-01 UTC(1970 年 1 月 1 日)。实际时间对于这个游戏来说并不重要,但是它可以作为一个参考点来衡量已经过去的时间。
start_count_down
方法用于启动定时器。它包括通常的自我论证。然后它有一个参数new_time = -1
。通过设置new_time
在参数中有一个值,它使参数可选。如果一个参数被传递,那么它将被放入new_time
变量中;如果没有传递参数,那么变量将取值-1。
def start_count_down(self, new_time = -1):
如果new_time
被改变,那么它被放入self.new_time
变量中。然后,该方法通过在self.start_time
变量中存储当前时间(以秒为单位)来重启计时器。
get_time_remaining
方法返回计数器到达零之前剩余的秒数,或者如果已经超过了倒计时时间,则返回零。math.ceil
功能用于将时间四舍五入到最接近的整秒。这使得倒计时总是显示一个完整的秒数,只有当整整一秒过去时才递减。
卡片类别
下一个类是向玩家显示卡片的卡片类。这显示在清单 9-5 中,应该保存为 card.py。这个类还演示了继承是如何扩展 Actor 类的。
from pgzero.actor import Actor
# Card is based on an Actor (uses inheritance)
class Card(Actor):
def __init__(self, name, back_image, card_image):
Actor.__init__(self, back_image, (0,0))
self.name = name
self.back_image = back_image
self.card_image = card_image
# Status can be 'back' (turned over) 'front' (turned up) or 'hidden' (already used)
self.status = 'back'
# Override Actor.draw
def draw(self):
if (self.status == 'hidden'):
return
Actor.draw(self)
def turn_over(self):
if (self.status == 'back'):
self.status = 'front'
self.image = self.card_image
elif (self.status == 'front'):
self.status = 'back'
self.image = self.back_image
# Attempt to turn over a hidden card - ignore
else:
return
def hide(self):
self.status = 'hidden'
# When unhide set it to back image
def unhide (self):
self.status = 'back'
self.image = self.back_image
def is_hidden (self):
if self.status == 'hidden':
return True
return False
# Is it turned to face forward
def is_faceup (self):
if self.status == 'front':
return True
return False
def reset (self):
self.unhide()
def set_position(self, x, y):
self.x = x
self.y = y
def equals (self, othercard):
if self.name == othercard.name:
return True
return False
Listing 9-5Card class with inheritance
该文件的第一个条目是导入 pgzero.actor 中的 Actor 类。
from pgzero.actor import Actor
这是 Pygame Zero 通常自动加载的,但是因为这是在一个单独的类文件中,所以需要显式导入。
然后,它定义了一个名为 Card 的新类:
class Card(Actor):
括号中的单词“Actor”表示这将是 Actor 类的子类。
新类从父类导入方法,然后父类可以被覆盖。包含了覆盖构造函数方法的__init__
方法。它包括对自我的引用和三个变量。name 变量是用于检查匹配对的标签;其他参数用于将图像文件名传递给 Card 类。
def __init__(self, name, back_image, card_image):
Actor.__init__(self, back_image, (0,0))
self.name = name
self.back_image = back_image
self.card_image = card_image
self.status = 'back'
有一个名为 status 的变量,它将跟踪卡是面朝上(正面)还是面朝下(背面),或者它是否已经被使用(隐藏)。
您还将看到,在该代码块中,Actor.__init__
方法被调用。这与创建不属于子类的 Actor 的方法相同。在这种情况下,通过在父类的名称前加上前缀,直接调用父类的__init__
方法。如果没有__init__
方法,那么将调用父方法的__init__
。
Card 类中的下一个方法是 draw 方法,它也覆盖了父类中的方法。这样做是为了仅在状态不等于隐藏时显示卡片。如果卡片没有隐藏,那么它通过调用Actor.draw(self)
来调用父级的 draw 方法。
然后有一些方法在卡对象上执行操作。父类中不存在这些方法。它们是专门针对卡片的方法,在大多数情况下,对于不是卡片的其他参与者没有意义。
这张卡片有两幅不同的图像。该卡以显示back_image
开始,但是当调用turn_over
方法时,它变成了card_image
。这是通过改变self.image
属性来实现的,这是 Actor 类的一个特性。其他一些方法主要是获取和设置变量值。例如,hide
和unhide
方法用于改变隐藏变量的值,is_hidden
方法返回隐藏变量的值。实际上并不需要这些方法,因为可以直接改变隐藏变量。无论是直接更新和读取变量,还是使用方法更新和读取变量,都各有利弊。Python 的口头禅通常是采用更简单的直接更新变量的选项,而对于其他一些编程语言,每当需要访问对象中的变量时,都鼓励使用 getter 和 setter 方法。
我通常更喜欢使用方法来访问变量。主要优点是它支持数据抽象的概念。假设在未来的某一天,您决定添加一个选项来部分隐藏一张卡片。比如你可能加了一个特性,一张牌只有上一轮没翻过才能翻;如果一张牌在上一轮中被使用过,那么它应该变灰以表示它不能被使用。为了实现这一点,您可以更改隐藏变量,使其不再是只能保存两种状态(真或假)的布尔值,而是使用一个数字来表示透明度的大小。如果您只使用方法来访问值,那么您可以将它作为一个新特性添加进来,而不会破坏库的使用方式。当您在不同的程序之间重用相同的代码时,这尤其有用。
equals 方法将当前卡片的名称与另一张卡片的名称进行比较。参数othercard
将被传递给一个对象,从这个对象中它可以检查另一张卡的名称。
游戏类
在这一点上,我决定不创建一个单独的球员类,因为它只会为分数保存一个变量。通常不值得为一个变量创建一个类。
最初,我将所有的分数和状态跟踪合并到主程序文件中。当我做程序文件时,它变得很长,很难理解它是如何工作的。这就是所谓的臭味。为了避免这种情况,我创建了一个名为 GamePlay 的新类。这就是所谓的代码重构,也就是代码被更新,但通常不会添加任何额外的功能。通常是重新组织和修改代码,使其更容易阅读,或者更有效。
Note
臭味是一个编程术语,表示糟糕的代码设计。这通常不是一个错误,但可能会减慢开发速度,使代码难以理解,或者增加将来出现错误的风险。
创建 GamePlay 类的另一个好处是,它将用户分数从主代码中分离出来,应该会使它更容易在后期制作成双人游戏。
游戏类如清单 9-6 所示,并保存为 gameplay.py。
# State is tracked as a number, but to make the code readable constants are used
STATE_NEW = 0 # Game ready to start, but not running
STATE_PLAYER1_START = 1 # Player 1 to turn over card
STATE_PLAYER1_CARDS_1 = 2 # Card 1 turned over
STATE_PLAYER1_CARDS_2 = 30 # Card 2 turned over
STATE_END = 50
# Number of seconds to display high score before allowing click to continue
TIME_DISPLAY_SCORE = 3
class GamePlay:
def __init__ (self):
# These are what we need to track
self.score = 0
self.state = STATE_NEW
# These are the cards that have been turned up.
self.cards_selected = [None, None]
# If game has not yet started
def is_new_game(self):
if self.state == STATE_NEW:
return True
return False
def is_game_over(self):
if self.state == STATE_END:
return True
return False
def set_game_over(self):
# player gets to see high score
self.state = STATE_END
def is_game_running(self):
if (self.state >= STATE_PLAYER1_START and self.state < STATE_END):
return True
return False
def start_game(self):
self.score = 0
self.state = STATE_PLAYER1_START
def set_new_game(self):
self.state = STATE_NEW
def is_pair_turned_over(self):
if (self.state == STATE_PLAYER1_CARDS_2):
return True
return False
# Return the index position of the specified card
def get_card(self, card_number):
return self.cards_selected[card_number]
# Point scored, so add score and update state
def score_point(self):
self.score += 1
self.state = STATE_PLAYER1_START
# Not a pair - just update state
def not_pair(self):
self.state = STATE_PLAYER1_START
# If a card is clicked then update the state accordingly
def card_clicked(self, card_index):
if (self.state == STATE_PLAYER1_START):
self.cards_selected[0] = card_index
self.state = STATE_PLAYER1_CARDS_1
elif (self.state == STATE_PLAYER1_CARDS_1):
self.cards_selected[1] = card_index
self.state = STATE_PLAYER1_CARDS_2
Listing 9-6GamePlay class
GamePlay 类提供的主要功能是跟踪游戏状态和记录分数。该文件首先创建一些用于表示不同状态的常量。这些都不是必须的,但是state == STATE_PLAYER1_START
比state == 1
可读性更好。常量都是大写的,以表明它们是常量,不应该被更改,但就 Python 而言,这些只是变量。变量的值并不重要,只要它们总是被常量引用。
__init__
方法用于创建分数和状态变量。下一个变量cards_selected
是一个列表,它跟踪哪张牌被翻了出来。它以每个值作为None
开始。None 是一种特殊的变量类型,表示没有设置任何值。需要这两个条目,以便可以将卡号存储在其中。
包含的方法主要是关于提供游戏的状态。例如,如果游戏即将开始,方法is_new_game
将返回值 True 否则,它将返回 False。提供这些代码是因为与对照状态代码进行检查相比,这样更容易理解代码在做什么。
稍微复杂一点的方法是card_clicked
方法。该方法查看当前状态以确定被点击的卡片是第一张还是第二张,并更新cards_selected
中的相应条目。
程序文件
创建了类文件之后,程序文件就简单多了。它仍然很长,但比所有代码都在一个文件中要短。代码如清单 9-7 所示。
# Memory Card Game - PyGame Zero
import random
from card import Card
from timer import Timer
from gameplay import GamePlay
# These constants are used to simplify the game
# For more flexibility these could be replaced with configurable variables
# (eg. different number of cards for different difficulty levels)
NUM_CARDS_PER_ROW = 4
X_DISTANCE_BETWEEN_CARDS = 120
Y_DISTANCE_BETWEEN_CARDS = 120
CARD_START_X = 220
CARD_START_Y = 130
TIME_LIMIT = 60
TITLE = "Lake District Memory Game"
WIDTH = 800
HEIGHT = 600
cards_available = {
'airafalls' : 'memorycard_airafalls',
'ambleside' : 'memorycard_ambleside',
'bridgehouse' : 'memorycard_bridgehouse',
'derwentwater' : 'memorycard_derwentwater',
'ravenglassrailway' : 'memorycard_ravenglassrailway',
'ullswater' : 'memorycard_ullswater',
'weatherstone' : 'memorycard_weatherstone',
'windermere' : 'memorycard_windermere'
}
card_back = "memorycard_back"
## Setup instance variables
count_down = Timer(TIME_LIMIT)
game_state = GamePlay()
all_cards = []
# Create individual card objects, two per image
for key in cards_available.keys():
# Add to list of cards
all_cards.append(Card(key, card_back, cards_available[key]))
# Add again (to have 2 cards for each img)
all_cards.append(Card(key, card_back, cards_available[key]))
## Functions are defined here - the rest of the initialization
## is at the bottom of the file
# Shuffle the cards and update their positions
# Do not draw as this is called before the screen is properly setup
def deal_cards():
# Create a temporary list of card indexes that is then shuffled
keys = []
for i in range (len(all_cards)):
keys.append(i)
random.shuffle(keys)
# Setup card positions
xpos = CARD_START_X
ypos = CARD_START_Y
cards_on_row = 0
for key in keys:
# Reset (ie. unhide if hidden and display back)
all_cards[key].reset()
all_cards[key].set_position(xpos,ypos)
xpos += X_DISTANCE_BETWEEN_CARDS
cards_on_row += 1
# If reached end of row - move to next
if (cards_on_row >= NUM_CARDS_PER_ROW):
cards_on_row = 0
xpos = CARD_START_X
ypos += Y_DISTANCE_BETWEEN_CARDS
def update():
if (game_state.is_new_game()):
pass
elif (game_state.is_game_over()):
pass
else:
if (count_down.get_time_remaining()<=0):
game_state.set_game_over()
# Mouse clicked
def on_mouse_down(pos, button):
# Only interested in the left button
if (not button == mouse.LEFT):
return
# If new game then this click is to start the game
if (game_state.is_new_game()):
game_state.start_game()
# start the timer
count_down.start_count_down(TIME_LIMIT)
deal_cards()
return
# If game over then this click is to get to new game screen
if (game_state.is_game_over()):
# Make sure the timer has reached zero (short delay to see state)
if (count_down.get_time_remaining()<=0):
game_state.set_new_game()
return
## Reach here then we are in game play
# First check for both already clicked and this is a click to test
if (game_state.is_pair_turned_over()):
if (all_cards[game_state.get_card(0)].equals(all_cards[game_state.get_card(1)])):
# Add points and hide the cards
game_state.score_point()
all_cards[game_state.get_card(0)].hide()
all_cards[game_state.get_card(1)].hide()
# Check if we are at the end of this level (all cards done)
if (end_level_reached()):
deal_cards()
# If not match then turn both around
else:
all_cards[game_state.get_card(0)].turn_over()
all_cards[game_state.get_card(1)].turn_over()
game_state.not_pair()
return
## Otherwise we just turn over the next card if clicked
for i in range (len(all_cards)):
if (all_cards[i].collidepoint(pos)):
# Ignore if card hidden, or has already been turned up
if (all_cards[i].is_hidden() or all_cards[i].is_faceup()):
return
all_cards[i].turn_over()
# Update state
game_state.card_clicked(i)
# If reach end of level ?
def end_level_reached():
for card in all_cards:
if (not card.is_hidden()):
return False
return True
def draw():
screen.fill((220, 220, 220))
if (game_state.is_new_game()):
screen.draw.text("Click mouse to start", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
if (game_state.is_game_over()):
screen.draw.text("Game Over\nScore: "+str(game_state.score), fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
if (game_state.is_game_running()):
for card in all_cards:
card.draw()
screen.draw.text("Time remaining: "+str(count_down.get_time_remaining()), fontsize=40, bottomleft=(50,50), color=(0,0,0))
screen.draw.text("Score: "+str(game_state.score), fontsize=40, bottomleft=(600,50), color=(0,0,0))
### End of functions - start of initialization code
deal_cards()
Listing 9-7Memory game main program file
与其他文件不同,主程序文件不是作为单独的类创建的。这不同于其他一些要求一切都面向对象的编程语言。在 Python 的情况下,这是可选的,而在 Pygame Zero 的情况下,在程序的主要部分不使用单独的类更容易。相反,程序使用 Pygame Zero 挂钩,比如draw
和update
函数。
为了理解这个程序,看一下整个文件是很有用的。导入和变量,以及类实例的初始化都是在文件的顶部定义的。函数在中间,在程序初始化过程中运行的附加代码在这一行之后的底部
### End of functions - start of initialization code
该程序首先导入随机模块,然后导入之前创建的三个类:卡片、计时器和游戏性。定义了几个常量,用于纸牌的间距和游戏设置,如计时器的持续时间。还有一个卡片背面图像文件名的变量,以及一个不同卡片图像文件名的字典。这些设置通常存储在单独的配置文件中,但是为了简单起见,它们被包含在 memory.py 文件中。然后创建一个名为all_cards
的空列表,它将保存 Card 类的实例。
接下来处理类的实例的创建。Timer
和GamePlay
类只需要一个普通赋值创建的实例。
count_down = Timer(TIME_LIMIT)
game_state = GamePlay()
对于 Cards 类,每个将要显示的卡片都需要有一个实例。一个 for 循环用于创建这些并将它们添加到all_cards
列表中。这是卡片对象的列表。为每张卡创建两个实例,以便在列表中有匹配对。
all_cards = []
# Create individual card objects, two per image
for key in cards_available.keys():
# Add to list of cards
all_cards.append(Card(key, card_back, cards_available[key]))
# Add again (to have 2 cards for each img)
all_cards.append(Card(key, card_back, cards_available[key]))
这些函数列在后面,后面是对文件底部的deal_cards
的调用。这需要放在定义了deal_cards
函数之后;否则,会导致错误。把它放在最后可以让代码更容易理解。
deal_cards
函数的工作原理是从卡片中创建一个所有钥匙的列表。然后它调用random.shuffle
函数,这个函数将卡片混合成一个随机的顺序。然后,它根据卡片之间的间距,用它们的坐标更新每张卡片。可以通过使用列表中的索引来访问这些卡,如以下示例条目所示:
all_cards[key].set_position(xpos,ypos)
接下来是更新功能。它首先检查游戏是新的还是已完成的。如果是这种情况,它不做任何由关键字pass
指示的事情。使用 pass 没有任何作用,但是如果您计划在将来添加额外的代码,它可以作为一个占位符。如果游戏正在进行中,那么它调用计时器上的get_time_remaining
方法,并在游戏结束时改变游戏状态。
if (count_down.get_time_remaining()<=0):
game_state.set_game_over()
更新游戏的大部分代码是由鼠标动作驱动的,所以是在on_mouse_down
而不是update
函数中。on_mouse_down 函数根据用户是否在玩游戏而被不同地处理。如果游戏没有在进行中,那么点击改变游戏的状态,例如开始游戏。如果游戏正在进行中,那么它将首先测试两张已经翻过的牌。如果是,那么它测试两张卡片是否匹配,或者隐藏卡片(如果匹配)或者将卡片重新设置为面朝下。如果两张卡都还没有翻过来,那么它使用collidepoint
方法检查一张卡是否已经被点击,如果是,就翻过来并更新游戏状态。还有一个针对end_level_reached
功能的检查,该功能检查是否所有的牌都已被翻过,如果是,则洗牌以备玩家重新开始。
draw 函数将一些消息放在屏幕上,如果合适的话,调用 draw 方法在屏幕上显示每张卡片。
您可能已经注意到,在任何函数中都没有更新的全局变量。类实例的行为确实像全局变量,但是因为它们是使用类的方法更新的,这意味着与直接更新全局变量相比,它不太可能产生难以理解的错误。
这就完成了游戏。这项运动还有很大的改进空间。您可以通过提供不同的卡图案来改善游戏的外观,或者通过改变卡的数量或玩游戏的时间长度来改变难度。你也可以把游戏改成双人游戏,或者让玩家和电脑比赛,而不是和时间赛跑。使用面向对象技术可能比使用过程化编码风格更容易。
摘要
面向对象编程是过程编程的一种替代方法,它将数据和处理数据的方法紧密地联系在一起。这对于代码重用和随着代码量的增加帮助组织程序结构特别有用。本章解释了面向对象编程的一些关键概念,以及如何在 Python 中实现它们。它包括一个游戏,演示了如何实现这些概念。
下一章将着眼于在游戏中加入人工智能来创造一个基于计算机的竞争者。
十、人工智能
电脑游戏中的人工智能(AI)是通过编程让电脑表现得像有智能一样。一般来说,这可能是显示由计算机控制的角色或对象背后的智能。
这通常不同于人们经常与人工智能联系在一起的机器学习。机器学习是一种与语音识别或模式识别等其他系统相关联的人工。
在电脑游戏中,人工智能可以像敌人采取的预先确定的路线一样简单,也可以包括一些复杂的算法,跟踪玩家的移动并以逼真的方式做出反应。为了更好地工作,需要为玩家设置一个合适的难度。机器学习的问题是,如果你用它来创造一个对手,那么它可能会变得不可战胜,而不仅仅是具有挑战性。机器学习可能更适合创建逼真的背景或特效。
当我提到人工智能时,我实际上是在寻找可以用来创建适当级别的计算机播放器的算法。这一章将会看到一些可以应用到游戏中的简单人工智能的例子,以及一些关于如何制作一个计算机播放器的理论和一些代码例子。
人工智能记忆游戏
从第九章到第九章的记忆游戏目前是一个试图超越时间的案例。这是一个小小的挑战,但和与对手比赛是不一样的。相反,创造一个人工智能玩家来对抗是可能的。为了设计 AI 播放器,考虑人们通常如何玩游戏,挑战是什么,以及他们使用什么策略来获胜。顾名思义,游戏中的挑战是记忆。如果你能在翻牌时记住所有的牌,那么赢得比赛的机会就大大增加了。也有一个运气的因素,我们可以考虑到人工智能。我也将展示人工智能可以被调整来创造不同的困难。
在我第一次尝试重写代码时,我在现有文件中添加并更改了代码。随着这种情况的发展,代码变得又长又混乱,这是一个典型的糟糕的例子。为了解决这个问题,我重构了代码,添加了新的类来简化程序。随着代码的增加,会有多个不同的类,跟踪文件变得更加困难。让这一点更容易理解的一个方法是创建一个图表来显示类之间的关系。为此,我创建了一个 UML 类图,如图 10-1 所示。该图只是一个近似值,因为包含所有的属性和方法会显得过于拥挤。它还将顶级 memory.py 文件显示为一个类,这是不正确的。尽管它不是一个“纯粹的”UML 文件,但它对于展示程序如何工作是很有用的。
图 10-1
记忆游戏的 UML 类图
图上的线条和箭头显示了类之间的关系。三角形箭头表示继承,其中子节点从父节点继承属性和方法。实心菱形显示组合关系,也称为“具有”关系,因为该类具有该类的一个或多个实例。大多数组合是 1 对 1 的(为了简单起见,省略了数字),但是 CardTable 包含一对多关系(1:∑)中的多个卡片实例。
代码文件包含在名为 memorygame2 的目录中的源代码中。第一类是卡片类。这个类与以前的版本相比只有一些变化。代码如清单 10-1 所示。
from pgzero.actor import Actor
# Card is based on an Actor (uses inheritance)
class Card(Actor):
def __init__(self, name, back_image, card_image):
Actor.__init__(self, back_image, (0,0))
self.name = name
self.back_image = back_image
self.card_image = card_image
# Status can be 'back' (turned over) 'front' (turned up) or 'hidden' (already used)
self.status = 'back'
# Number is unique number based on position
# count left to right, top to bottom
# updated after dealt
self.number = None
# Override Actor.draw
def draw(self):
if (self.status == 'hidden'):
return
Actor.draw(self)
def turn_over(self):
if (self.status == 'back'):
self.status = 'front'
self.image = self.card_image
elif (self.status == 'front'):
self.status = 'back'
self.image = self.back_image
# Attempt to turn over a hidden card - ignore
else:
return
def hide(self):
self.status = 'hidden'
# When unhide set it to back image
def unhide (self):
self.status = 'back'
self.image = self.back_image
def is_hidden (self):
if self.status == 'hidden':
return True
return False
# Is it turned to face forward
def is_faceup (self):
if self.status == 'front':
return True
return False
# Is it turned to face down
def is_facedown (self):
if self.status == 'back':
return True
return False
def reset (self):
self.unhide()
def set_position(self, x, y):
self.x = x
self.y = y
def equals (self, othercard):
if self.name == othercard.name:
return True
return False
Listing 10-1Card class for AI memory game
一个新的类是 CardTable 类,创建它是为了简化 memory.py 文件中的一些代码。它包含了所有卡片的列表。它还包括设置牌桌,发牌,然后在屏幕上把它们都画出来的方法。有一个方法可以返回所有面朝下的牌,这是人工智能需要知道它可以从哪些牌中选择的。还有一种方法是测试是否到达关卡末尾(所有牌都配对成功)。
CardTable 类的代码如清单 10-2 所示。
import random
from card import Card
class CardTable:
def __init__ (self, card_back, cards_available):
self.cards = []
# Create individual card objects, two per image
for key in cards_available.keys():
# Add to list of cards
self.cards.append(Card(key, card_back, cards_available[key]))
# Add again (to have 2 cards for each img)
self.cards.append(Card(key, card_back, cards_available[key]))
def draw_cards(self):
for this_card in self.cards:
this_card.draw()
# Set the table settings
def setup_table(self, card_start_x, card_start_y, num_cards_per_row, x_distance_between_cards, y_distance_between_cards):
self.card_start_x = card_start_x
self.card_start_y = card_start_y
self.num_cards_per_row = num_cards_per_row
self.x_distance_between_cards = x_distance_between_cards
self.y_distance_between_cards = y_distance_between_cards
# Returns all cards that are face down as Card objects
def cards_face_down(self):
selected_cards = []
for this_card in self.cards:
if (this_card.is_facedown()):
selected_cards.append(this_card)
return selected_cards
# Shuffle the cards and update their positions
def deal_cards(self):
# Create a temporary list of card indexes that is then shuffled
keys = []
for i in range (len(self.cards)):
keys.append(i)
random.shuffle(keys)
# Setup card positions
xpos = self.card_start_x
ypos = self.card_start_y
cards_on_row = 0
# Give each card number based on position
# count left to right, top to bottom
card_number = 0
for key in keys:
# Reset (ie. unhide if hidden and display back)
self.cards[key].reset()
self.cards[key].number = card_number
self.cards[key].set_position(xpos,ypos)
xpos += self.x_distance_between_cards
cards_on_row += 1
# If reached end of row - move to next
if (cards_on_row >= self.num_cards_per_row):
cards_on_row = 0
xpos = self.card_start_x
ypos += self.y_distance_between_cards
card_number += 1
# If reach end of level
def end_level_reached(self):
for card in self.cards:
if (not card.is_hidden()):
return False
return True
def check_card_clicked (self, pos):
for this_card in self.cards:
# If not facedown then skip
if (not this_card.is_facedown()):
continue
if (this_card.collidepoint(pos)):
return this_card
return None
Listing 10-2CardTable class file
GamePlay 类是之前 GamePlay 类的简化版本。分数属性已经被删除,因为它现在由玩家类处理,为每个玩家提供分数。还有额外的状态属性和方法来处理第二个玩家。游戏类的代码包含在清单 10-3 中。
# State is tracked as a number, but to make the code readable constants are used
STATE_NEW = 0 # Game ready to start, but not running
STATE_PLAYER1_START = 10 # Player 1 to turn over card
STATE_PLAYER1_CARDS_1 = 11 # Card 1 turned over
STATE_PLAYER1_CARDS_2 = 12 # Card 2 turned over
STATE_PLAYER2_START = 20 # Player 2 starts go
STATE_PLAYER2_WAIT = 21 # Delay before Card 1 turned over
STATE_PLAYER2_CARDS_1 = 22 # Card 1 turned over
STATE_PLAYER2_CARDS_2 = 23 # Card 2 turned over
STATE_END = 50
# Number of seconds to display high score before allowing click to continue
TIME_DISPLAY_SCORE = 3
class GamePlay:
def __init__ (self):
self.state = STATE_NEW
# If game has not yet started
def is_new_game(self):
if self.state == STATE_NEW:
return True
return False
def is_game_over(self):
if self.state == STATE_END:
return True
return False
def is_player_1(self):
if (self.state >= STATE_PLAYER1_START and self.state <= STATE_PLAYER1_CARDS_2):
return True
return False
def is_player_2(self):
if (self.state >= STATE_PLAYER2_START and self.state <= STATE_PLAYER2_CARDS_2):
return True
return False
def is_player_2_start(self):
if (self.state == STATE_PLAYER2_START):
return True
return False
def is_player_2_wait(self):
if (self.state == STATE_PLAYER2_WAIT):
return True
return False
def is_player_2_card1(self):
if (self.state == STATE_PLAYER2_CARDS_1):
return True
return False
def is_player_2_card2(self):
if (self.state == STATE_PLAYER2_CARDS_2):
return True
return False
def set_player_2_wait(self):
self.state = STATE_PLAYER2_WAIT
def set_player_2_card1(self):
self.state = STATE_PLAYER2_CARDS_1
def set_player_2_card2(self):
self.state = STATE_PLAYER2_CARDS_2
def start_game(self):
self.state = STATE_PLAYER1_START
def set_game_over(self):
# player gets to see high score
self.state = STATE_END
def is_game_running(self):
if (self.state >= STATE_PLAYER1_START and self.state < STATE_END):
return True
return False
# Continue with current player (matched correctly)
def continue_player (self):
if self.state <= STATE_PLAYER1_CARDS_2:
self.state = STATE_PLAYER1_START
else:
self.state = STATE_PLAYER2_START
# Switch to next player (not matched)
def next_player (self):
if self.state <= STATE_PLAYER1_CARDS_2:
self.state = STATE_PLAYER2_START
else:
self.state = STATE_PLAYER1_START
def set_new_game(self):
self.state = STATE_NEW
def is_pair_turned_over(self):
if (self.state == STATE_PLAYER1_CARDS_2):
return True
return False
# If a card is clicked then update the state accordingly
def card_clicked(self):
if (self.state == STATE_PLAYER1_START):
self.state = STATE_PLAYER1_CARDS_1
elif (self.state == STATE_PLAYER1_CARDS_1):
self.state = STATE_PLAYER1_CARDS_2
Listing 10-3GamePlay class file
Timer 类与前面的相同,但是使用方式不同。它不是用来作为玩家对战的计时器,而是用来为人工智能玩家添加延迟,以便人类玩家可以看到计算机正在翻的牌。清单 10-4 中显示了 Timer 类。
import math
import time
class Timer():
def __init__(self, start_count):
self.start_count = start_count
self.start_time = time.time()
# start count down, with optional parameter to replace the start_count value
# -1 is used as a "magic number", this method should only be called with positive number
# if it isn't given a number then -1 indicates no new time give
def start_count_down(self, new_time = -1):
if (new_time >= 0):
self.start_count = new_time
self.start_time = time.time()
def get_time_remaining(self):
current_time = self.start_count + self.start_time - time.time()
if (current_time <= 0):
return 0
return math.ceil(current_time)
Listing 10-4Timer class file
玩家类在早期版本中被考虑过,但当时并不需要。随着 AI 的加入,为玩家提供一个独立的职业变得更加有用。这是一个简单的类,保存玩家和卡片选择的分数。它使用 card 类,该类具有在select_card
方法期间传递的卡的实例,然后使用get_card
方法返回它。这就是图 10-1 中玩家和卡类之间构成的原因。播放器类的代码如清单 10-5 所示。
from card import Card
class Player():
def __init__ (self):
# Track which cards are turned over
self.guess = [None, None]
self.score = 0
def score_point (self):
self.score += 1
# Returns a single card object - either 0 or 1
def get_card (self, card_number):
return self.guess[card_number]
# Reset cards held in hand, but does not hide / turn_over card
def reset_cards(self):
self.guess[0] = None
self.guess[1] = None
def select_card(self, card):
if (self.guess[0] == None):
self.guess[0] = card
else:
self.guess[1] = card
# Returns the number of cards that are selected
def num_cards_selected(self):
if (self.guess[0] == None):
return 0
elif (self.guess[1] == None):
return 1
else:
return 2
Listing 10-5Player class file
最后一个类文件包含 PlayerAi 类,它继承了 Player 类,增加了 Ai 玩家进行随机猜测的能力。这是人工智能的一个非常基本的形式,稍后会详细介绍。代码包含在清单 10-6 中。
import random
from player import Player
class PlayerAi (Player):
def __init__(self):
Player.__init__(self)
def make_guess(self, available_cards):
self.guess_random(available_cards)
def guess_random (self, available_cards):
this_guess = random.choice(available_cards)
this_guess.turn_over()
self.select_card(this_guess)
def get_card (self, card_number):
return self.guess[card_number]
Listing 10-6Player class file
最后,memory.py 文件已经更新。用户交互仍然在on_mouse_down
函数中处理,但是它现在在更新函数中包括了 AI 播放器。每当人工智能玩家执行一个操作,都会有一个由timer.start_count_down
触发的延迟,这有效地暂停了人工智能的任何操作,直到timer.get_time_remaining
显示时间已经超过。这显示在清单 10-7 中。
# Memory Card Game - PyGame Zero
import random
from card import Card
from gameplay import GamePlay
from player import Player
from playerai import PlayerAi
from timer import Timer
from cardtable import CardTable
# These constants are used to simplify the game
# For more flexibility these could be replaced with configurable variables
# (eg. different number of cards for different difficulty levels)
NUM_CARDS_PER_ROW = 4
X_DISTANCE_BETWEEN_CARDS = 120
Y_DISTANCE_BETWEEN_CARDS = 120
CARD_START_X = 220
CARD_START_Y = 130
TITLE = "Lake District Memory Game"
WIDTH = 800
HEIGHT = 600
cards_available = {
'airafalls' : 'memorycard_airafalls',
'ambleside' : 'memorycard_ambleside',
'bridgehouse' : 'memorycard_bridgehouse',
'derwentwater' : 'memorycard_derwentwater',
'ravenglassrailway' : 'memorycard_ravenglassrailway',
'ullswater' : 'memorycard_ullswater',
'weatherstone' : 'memorycard_weatherstone',
'windermere' : 'memorycard_windermere'
}
card_back = "memorycard_back"
## Setup instance variables
game_state = GamePlay()
player1 = Player()
ai = PlayerAi()
# Timer is used for AI thinking time
timer = Timer(2)
all_cards = CardTable(card_back, cards_available)
all_cards.setup_table(CARD_START_X, CARD_START_Y, NUM_CARDS_PER_ROW, X_DISTANCE_BETWEEN_CARDS, Y_DISTANCE_BETWEEN_CARDS)
all_cards.deal_cards()
def update():
if (game_state.is_player_2_start()):
timer.start_count_down()
game_state.set_player_2_wait()
if (game_state.is_player_2_wait()):
if (timer.get_time_remaining() <= 0):
ai.make_guess(all_cards.cards_face_down())
timer.start_count_down()
game_state.set_player_2_card1()
# card 1 turned
elif (game_state.is_player_2_card1()):
if (timer.get_time_remaining() <= 0):
ai.make_guess(all_cards.cards_face_down())
timer.start_count_down()
game_state.set_player_2_card2()
# Card 2 selected - wait then check if matches
elif (game_state.is_player_2_card2()):
if (timer.get_time_remaining() <= 0):
if ai.get_card(0).equals(ai.get_card(1)):
# If match add points and hide the cards
ai.score_point()
ai.get_card(0).hide()
ai.get_card(1).hide()
ai.reset_cards()
# Game Over
if (all_cards.end_level_reached()):
game_state.set_game_over()
# If user guess correct then they get another attempt
else:
game_state.continue_player()
# If not match then turn both around
else:
ai.get_card(0).turn_over()
ai.get_card(1).turn_over()
ai.reset_cards()
game_state.next_player()
# Mouse clicked
def on_mouse_down(pos, button):
# Only interested in the left button
if (not button == mouse.LEFT):
return
# If new game then this click is to start the game
if (game_state.is_new_game() or game_state.is_game_over()):
game_state.start_game()
all_cards.deal_cards()
player1.score = 0
ai.score = 0
return
## Reach here then we are in game play
# Is it player1's turn
if (game_state.is_player_1()):
# Check for both already clicked and this is a click to test
if (game_state.is_pair_turned_over()):
if (player1.get_card(0).equals(player1.get_card(1))):
# If match add points and hide the cards
player1.score_point()
player1.get_card(0).hide()
player1.get_card(1).hide()
player1.reset_cards()
# End of game
if (all_cards.end_level_reached()):
game_state.set_game_over()
# If user guess correct then they get another attempt
else:
game_state.continue_player()
# If not match then turn both around
else:
player1.get_card(0).turn_over()
player1.get_card(1).turn_over()
player1.reset_cards()
game_state.next_player()
return
# Check if clicked on a card
card_clicked = all_cards.check_card_clicked(pos)
if (card_clicked != None):
card_clicked.turn_over()
player1.select_card(card_clicked)
# Update state
game_state.card_clicked()
def draw():
screen.fill((220, 220, 220))
if (game_state.is_new_game()):
screen.draw.text("Click mouse to start", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
if (game_state.is_game_over()):
screen.draw.text("Game Over\nPlayer 1 score: "+str(player1.score)+"\nPlayer 2 (AI) score: "+str(ai.score), fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
if (game_state.is_game_running()):
# Set colors based on which player is selected
if (game_state.is_player_1()):
player1_color = (0,0,0)
player2_color = (128,128,128)
else:
player1_color = (128,128,128)
player2_color = (0,0,0)
all_cards.draw_cards()
screen.draw.text("Player 1: "+str(player1.score), fontsize=40, bottomleft=(50,50), color=player1_color)
screen.draw.text("Player 2 (AI): "+str(ai.score), fontsize=40, bottomleft=(550,50), color=player2_color)
# Display computer status during ai turns
if (game_state.is_player_2_wait() or game_state.is_player_2_card1()):
screen.draw.text("Thinking which card to pick", fontsize=40, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
Listing 10-7The main memory.py file with basic AI
这可以运行和发挥,但非常容易击败。游戏每次只是随机选择一张牌,所以直到只剩下几张牌的时候,他们得到匹配的概率都很小。
美好的记忆
为了让游戏更有挑战性,我们可以让电脑记住所做的猜测。由于播放器代码与代码的其余部分是分离的,所以只有两个文件需要更新。这些文件包含 Player 类和 PlayerAi 类。这里将列出两个修改过的文件,但是完整的源代码包含在 memory3 目录中。
首先,需要有一个地方来存放已经看过的卡片。如果看到的牌存储在 PlayerAi 类中,那么它只会看到 Ai 玩家翻出来的牌。如果卡片被存储在玩家类中,那么就有可能存储所有由人类玩家和人工智能玩家翻开的卡片。
将列表保存为类变量而不是实例变量将使它对所有实例可见,包括子类的所有实例。这是通过将变量放在类的顶部来实现的,如下所示:
class Player():
card_memory = {}
click_order = []
这里创建了两个变量:card_memory
是一个保存卡片的字典,其中包含卡片名称的索引,click_order
是一个列表,它记录了卡片被点击的顺序。目前实际上并不需要第二个,但是现在添加它将简化下一个阶段。
每当出现一张牌时,为了更新类变量,需要将以下内容添加到select_card
方法中:
Player.card_memory[card.number] = card
由于这个方法是由 PlayerAi 继承的,因此每当人类玩家或电脑玩家翻牌时都会调用它。在一个新游戏开始时,这些变量也需要被重置,这个新游戏是在一个静态方法reset_cards
中实现的。更新后的 player.py 文件如清单 10-8 所示。
from card import Card
class Player():
# Index of cards that ai remembers
# Stored as dictionary as cards will be missing or be forgotten
card_memory = {}
click_order = []
def __init__ (self):
# Track which cards are turned over
self.guess = [None, None]
self.score = 0
@staticmethod
def new_game():
Player.card_memory = {}
Player.click_order = []
def score_point (self):
self.score += 1
# Returns a single card object - either 0 or 1
def get_card (self, card_number):
return self.guess[card_number]
# Reset cards held in hand, but does not hide / turn_over card
def reset_cards(self):
self.guess[0] = None
self.guess[1] = None
def select_card(self, card):
if (self.guess[0] == None):
self.guess[0] = card
else:
self.guess[1] = card
Player.card_memory[card.number] = card
# Returns the number of cards that are selected
def num_cards_selected(self):
if (self.guess[0] == None):
return 0
elif (self.guess[1] == None):
return 1
else:
return 2
Listing 10-8Updated Player class to add improved AI
PlayerAi 类中添加了三种不同的方法,展示了实现改进内存的不同方式。更新后的源代码如清单 10-9 所示。每种新方法将在后面解释。
import random
from player import Player
class PlayerAi (Player):
def __init__(self):
Player.__init__(self)
def make_guess(self, available_cards):
#self.guess_random(available_cards)
#self.guess_remember_all(available_cards)
#self.guess_remember_sometimes(available_cards)
self.guess_remember_recent(available_cards)
def guess_random (self, available_cards):
this_guess = random.choice(available_cards)
this_guess.turn_over()
self.select_card(this_guess)
def guess_remember_all (self, available_cards):
# If first guess then use random
if (self.guess[0] == None):
self.guess_random(available_cards)
return
# Search to see if we have seen a matching card
for search_card in Player.card_memory.values():
# ignore if current card - or card has been hidden since
if (search_card == self.guess[0] or search_card.is_hidden()):
continue
# Check to see if the card matches
if (search_card.equals(self.guess[0])):
search_card.turn_over()
self.select_card(search_card)
return
# If not found the matching card then use random
self.guess_random(available_cards)
def guess_remember_sometimes (self, available_cards):
# If first guess then use random
if (self.guess[0] == None):
self.guess_random(available_cards)
return
# Random whether make a proper guess or random guess
if (random.randint(1,10) < 5):
self.guess_random(available_cards)
return
# Search to see if we have seen a matching card
for search_card in Player.card_memory.values():
# ignore if current card - or card has been hidden since
if (search_card == self.guess[0] or search_card.is_hidden()):
continue
# Check to see if the card matches
if (search_card.equals(self.guess[0])):
search_card.turn_over()
self.select_card(search_card)
return
# If not found the matching card then use random
self.guess_random(available_cards)
def guess_remember_recent (self, available_cards):
# If first guess then use random
if (self.guess[0] == None):
self.guess_random(available_cards)
return
# Get last 4 cards that were clicked
# These are just card numbers
recent_cards = Player.click_order[:-4]
# Search to see if one of those is a matching card
for search_card in Player.card_memory.values():
# ignore if current card - or card has been hidden since
if (search_card == self.guess[0] or search_card.is_hidden()):
continue
# ignore if not a recent card
if (search_card.number not in recent_cards):
continue
# Check to see if the card matches
if (search_card.equals(self.guess[0])):
search_card.turn_over()
self.select_card(search_card)
return
# If not found the matching card then use random
self.guess_random(available_cards)
def get_card (self, card_number):
return self.guess[card_number]
Listing 10-9Updated PlayerAi class to add improved AI
新方法的第一个是guess_remember_all
,它会记住每一张翻过来的牌。该方法从选择一张随机卡片开始。如果相应的对子已经翻了,那么它会翻相应的牌。这是通过在Player.card_memory.values
中查找卡片来处理的,该卡片返回字典中所有值的列表。该方法的关键部分如下:
for search_card in Player.card_memory.values():
这是一个循环遍历字典中所有值的 for 循环。该值是保存在变量search_card
中的卡对象。然后,它使用
if (search_card.equals(self.guess[0])):
如果它与先前翻过来的牌匹配,那么它使用
search_card.turn_over()
self.select_card(search_card)
由于这种方法记住了每一张翻开的牌,所以这是一个很难攻克的关卡。如果你有一个好的记忆或者你选择的牌非常幸运,那么击败它是可能的,但是这是令人沮丧的困难。
下一个方法叫做guess_remember_sometimes
。顾名思义,它能记住以前的卡片,但只是有时候。这是基于随机检查来确定是否从存储器中搜索卡。这与guess_remember_all
基本相同,除了以下附加代码:
if (random.randint(1,10) < 5):
self.guess_random(available_cards)
return
它创建一个介于 1 和 10 之间的随机数。如果数字小于 5,则它执行随机猜测。如果数字为 5 或更大,则它在存储器中搜索该卡。要比较的值(在本例中为 5)可以上下调整,以提高成功猜测的概率。
这给出了一个合理的难度水平,但不是特别现实。原因是人类玩家通常比之前翻的牌更容易记住最近翻的牌。
最后一个方法叫做guess_remember_recent
。这为电脑玩家提供了短期记忆。所有翻过来的牌仍然存储在字典中,但是计算机在检查匹配时只使用在Player.click_order
变量中列出的最近的牌。
这是通过创建一个单独的列表来实现的,该列表只保存click_order
列表的最后四个条目。
recent_cards = Player.click_order[:-4]
然后,当检查匹配时,它使用以下内容跳过不在recent_cards
列表中的任何卡:
if (search_card.number not in recent_cards):
continue
你可以试着调整电脑浏览的最近卡片数量来改变难度。
你还可以做其他的事情来让它看起来更真实。例如,你可以将这些技术结合起来,使电脑玩家的行为更自然,因为它对最近的牌有很好的记忆,但随着牌越翻越多,随机猜对的可能性就越小。这是留给读者的一个练习。
如果你看一下make_guess
方法,你可以看到除了guess_remember_recent
方法,不同的方法都被注释掉了。这为您提供了一种尝试不同方法并进行比较的方式。只需删除注释掉您想要测试的字符和注释掉其他字符的“#”字符。
def make_guess(self, available_cards):
#self.guess_random(available_cards)
#self.guess_remember_all(available_cards)
#self.guess_remember_sometimes(available_cards)
self.guess_remember_recent(available_cards)
你可以做的一件事是让玩家选择难度。想一想你可以如何添加它。我在源代码中添加了另一个版本的记忆游戏,它包含了这个选项。它存储在目录 memory4 中;在查看提供的代码之前,先考虑一下如何添加它。
战舰
你如何创造人工智能的另一个例子可以在游戏战舰中看到。这是你在某个阶段几乎肯定玩过的经典游戏。最初是一个纸质游戏,你必须尝试击沉你对手的船只,现在这通常是一个棋盘游戏,使用模型船和塑料桩来显示船只何时被击中或错过。如图 10-2 所示。
图 10-2
传统战舰棋盘游戏
这个游戏将是这个经典游戏的电脑版,用来演示人工智能。玩战舰所涉及的智力是我们大多数人下意识做的事情。的确,这个游戏很大程度上是基于运气,但如果没有策略,非智能计算机版本几乎肯定会输给人类对手。
玩战列舰有三个主要策略可以考虑:
-
随机——每回合随机出牌是最基本的策略。在尝试过许多位置之前,成功击中每艘对手船只的几率非常低。
-
随机感知船只——第二个策略是你随机射击,直到成功击中对手的船只。一旦击中对手的船,你就向邻近的位置开火,直到船被击沉。船沉了之后,你又开始尝试随机的位置。
-
概率分析——这是一种终极策略,电脑对手可以计算出剩余船只在特定位置的概率。
在这个游戏中,我实现了第二个随机射击的策略。原因是第一关对大多数玩家来说太容易了,而第三关可能太难了。
为了保持代码简短,书中列出的版本对人类和电脑玩家的飞船都有固定的位置。这使我能够演示计算机播放器的工作方式,而不必列出许多额外的代码。然而,我在源代码中加入了第二个版本,这是一个完整的游戏,玩家可以定位他们自己的船,计算机为他们的船选择随机的位置。本书列出的版本在战舰目录中,更完整的版本在战舰 2 目录中。
这个游戏中包含了六个 Python 文件,加上 image 文件夹中的一些图片。该游戏使用了与记忆游戏类似的面向对象的编程方法。文件 battleship.py 是主可执行文件。每个玩家都有一个舰队,舰队由五艘船组成。每艘船都是 Actor 类的孩子。有一个 grid 类处理网格位置,并将网格上的位置转换为屏幕上的位置。最后,Ai 类是本章最感兴趣的一个,因为它是智能编码的地方。
我将快速浏览每个文件,并在最后解释 Ai 类。
第一个文件是战舰. py 主程序文件。这显示在清单 10-10 中。
from fleet import Fleet
from grid import Grid
from ai import Ai
WIDTH = 1024
HEIGHT = 768
# Start of your grid (after labels)
YOUR_GRID_START = (94,180)
# Start of enemy grid
ENEMY_GRID_START = (544,180)
GRID_SIZE = (38,38)
player = "player1"
grid_img_1 = Actor ("grid", topleft=(50,150))
grid_img_2 = Actor ("grid", topleft=(500,150))
own_fleet = Fleet(YOUR_GRID_START, GRID_SIZE)
enemy_fleet = Fleet(ENEMY_GRID_START, GRID_SIZE)
## Manually position ships position random or allow
## player to choose.
own_fleet.add_ship("destroyer",(7,0),"horizontal")
own_fleet.add_ship("cruiser",(1,1),"horizontal")
own_fleet.add_ship("submarine",(1,4),"vertical")
own_fleet.add_ship("battleship",(4,5),"horizontal")
own_fleet.add_ship("carrier",(9,3),"vertical")
enemy_fleet.add_ship("destroyer",(5,8),"horizontal", True)
enemy_fleet.add_ship("cruiser",(3,4),"vertical", True)
enemy_fleet.add_ship("submarine",(4,1),"horizontal", True)
enemy_fleet.add_ship("battleship",(8,3),"vertical", True)
enemy_fleet.add_ship("carrier",(1,1),"vertical", True)
# Don't need a player1 object
# Player 2 represents the AI player
player2=Ai()
def draw():
screen.fill((192,192,192))
grid_img_1.draw()
grid_img_2.draw()
screen.draw.text("Battleships", fontsize=60, center=(WIDTH/2,50), shadow=(1,1), color=(255,255,255), scolor=(32,32,32))
screen.draw.text("Your fleet", fontsize=40, topleft=(100,100), color=(255,255,255))
screen.draw.text("The enemy fleet", fontsize=40, topleft=(550,100), color=(255,255,255))
own_fleet.draw()
enemy_fleet.draw()
if (player == "gameover"):
screen.draw.text("Game Over", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor=(32,32,32))
def update():
global player
if (player == "player2"):
grid_pos = player2.fire_shot()
result = own_fleet.fire(grid_pos)
player2.fire_result (grid_pos, result)
# If ship sunk then inform Ai player
if (result == True):
if (own_fleet.is_ship_sunk_grid_pos(grid_pos)):
player2.ship_sunk(grid_pos)
# As a ship is sunk - check to see if all ships are sunk
if own_fleet.all_sunk():
player = "gameover"
return
# If reach here then not gameover, so switch back to main player
player = "player1"
def on_mouse_down(pos, button):
global player
if (button != mouse.LEFT):
return
if (player == "player1"):
if (enemy_fleet.grid.check_in_grid(pos)):
grid_location = enemy_fleet.grid.get_grid_pos(pos)
#print (Grid.grid_to_string(grid_location))
enemy_fleet.fire(grid_location)
if enemy_fleet.all_sunk():
player = "gameover"
else:
# switch to player 2
player = "player2"
Listing 10-10The main battleship.py program file for Battleship game
这个文件导入了一些类并创建了主类的实例。这包括两个网格和相应的舰队,一个是人类玩家的舰队位置,另一个是电脑玩家的。通过人类和计算机舰队的硬编码位置,船只被添加到舰队中。这是为了减少这个阶段的代码量。draw 函数遍历各种对象,调用它们的每个绘制方法,并显示状态文本。
人类代码和人工智能代码是分离的。update 函数处理计算机玩家,而 on_mouse_down 函数处理与人类玩家的交互。
舰队类负责跟踪船只和射击。它包括测试一艘船是否沉没(在这种情况下,它被设置为可见)和测试整个舰队是否沉没的方法,这是游戏结束的触发器。fleet.py 的代码如清单 10-11 所示。
import math
from grid import Grid
from ship import Ship
from pgzero.actor import Actor
class Fleet:
def __init__ (self, start_grid, grid_size):
self.start_grid = start_grid
self.grid_size = grid_size
self.ships = []
self.grid = Grid(start_grid, grid_size)
self.shots = []
# Is there a ship at this position that has sunk
def is_ship_sunk_grid_pos (self, check_grid_pos):
# find ship at that position
for this_ship in self.ships:
if (this_ship.includes_grid_pos(check_grid_pos)):
return this_ship.is_sunk()
# If there is no ship at this position then return False
return False
def add_ship (self, type, position, direction, hidden=False):
self.ships.append(Ship(type, self.grid, position, direction, hidden))
# check through ships to see if any still floating
def all_sunk (self):
for this_ship in self.ships:
if not this_ship.is_sunk():
return False
return True
# Draws entire fleet (each of the ships)
def draw(self):
for this_ship in self.ships:
this_ship.draw()
for this_shot in self.shots:
this_shot.draw()
def fire (self, pos):
# Is this a hit
for this_ship in self.ships:
if (this_ship.fire(pos)):
# Hit
self.shots.append(Actor("hit",topleft=self.grid.grid_pos_to_screen_pos(pos)))
#check if this ship sunk
if this_ship.is_sunk():
# Ship sunk so make it visible
this_ship.hidden = False
return True
self.shots.append(Actor("miss",topleft=self.grid.grid_pos_to_screen_pos(pos)))
return False
Listing 10-11Fleet class for Battleship game
舰队类提供的主要内容之一是属于该舰队的所有船只的列表。这在列表self.ships
中,是基于船舶等级创建的。它还保存所有已经发射的镜头,作为代表击中或未击中的演员的列表。
船舶等级如清单 10-12 所示。它是 Actor 类的一个子类,带有一些额外的代码来处理船只在适当网格上的位置,以及船只何时隐藏或可见。
from pgzero.actor import Actor
from grid import Grid
# Ship is referred to using an x,y position
class Ship (Actor):
def __init__ (self, ship_type, grid, grid_pos, direction, hidden=False):
Actor.__init__(self, ship_type, (10,10))
self.ship_type = ship_type
self.grid = grid
self.image = ship_type
self.grid_pos = grid_pos
self.topleft = self.grid.grid_pos_to_screen_pos((grid_pos))
# Set the actor anchor position to center of the first square
self.anchor = (38/2, 38/2)
self.direction = direction
if (direction == 'vertical'):
self.angle = -90
self.hidden = hidden
if (ship_type == "destroyer"):
self.ship_size = 2
self.hits = [False, False]
elif (ship_type == "cruiser"):
self.ship_size = 3
self.hits = [False, False, False]
elif (ship_type == "submarine"):
self.ship_size = 3
self.hits = [False, False, False]
elif (ship_type == "battleship"):
self.ship_size = 4
self.hits = [False, False, False, False]
elif (ship_type == "carrier"):
self.ship_size = 5
self.hits = [False, False, False, False, False]
def draw(self):
if (self.hidden):
return
Actor.draw(self)
def is_sunk (self):
if (False in self.hits):
return False
return True
def fire (self, fire_grid_pos):
if self.direction == 'horizontal':
if (fire_grid_pos[0] >= self.grid_pos[0] and
fire_grid_pos[0] < self.grid_pos[0]+self.ship_size and
fire_grid_pos[1] == self.grid_pos[1]):
self.hits[fire_grid_pos[0]-self.grid_pos[0]] = True
return True
else:
if (fire_grid_pos[0] == self.grid_pos[0] and
fire_grid_pos[1] >= self.grid_pos[1] and
fire_grid_pos[1] < self.grid_pos[1]+self.ship_size):
self.hits[fire_grid_pos[1]-self.grid_pos[1]] = True
return True
return False
# Does this ship cover this grid_position
def includes_grid_pos (self, check_grid_pos):
# If first pos then return True
if (self.grid_pos == check_grid_pos):
return True
# check x axis
elif (self.direction == 'horizontal' and
self.grid_pos[1] == check_grid_pos[1] and
check_grid_pos[0] >= self.grid_pos[0] and
check_grid_pos[0] < self.grid_pos[0] + self.ship_size):
return True
elif (self.direction == 'vertical' and
self.grid_pos[0] == check_grid_pos[0] and
check_grid_pos[1] >= self.grid_pos[1] and
check_grid_pos[1] < self.grid_pos[1] + self.ship_size):
return True
else :
return False
Listing 10-12Ship class for Battleship game
Ship 类使用船只类型来确定船只的大小。这是根据船名来定的,比如驱逐舰(两格位)或者战列舰(四格位)。它还会更新锚点位置。这与船上使用的航海锚无关,而是与 Pygame Zero 演员的锚位置有关。默认情况下,锚点是图像的中心,但在这种情况下,它被设置为船占据的第一个网格位置(顶部,左侧)的中心。这个位置用于放置船只及其旋转。这使得在网格上定位船只更加容易,因此当船只垂直放置时,它会在网格列中旋转。
然后,构造函数创建一个对应于每个网格位置的列表,名为self.hits
。对于每个位置,该列表都被设置为 False,然后每当命中其中一个位置时,该列表都被更新为 True。如果它们都被设置为真,那么这艘船就被认为沉没了。这可以使用is_sunk
方法进行测试。
fire 方法通过查看其网格位置是否与船只占据的任何位置相匹配来确定火是否击中船只,并相应地更新状态。方法includes_grid_position
执行类似的检查,但是用于检查船只是否存在于该位置并且不改变其状态。
舰队和船只类中的方法使用网格位置,而不是屏幕位置。Grid 类用于将鼠标单击的屏幕位置转换为两个网格之一上的网格位置。战舰类和战舰. py 中的on_mouse_down
函数都使用它。网格类如清单 10-13 所示。
import math
class Grid:
# Grid dimensions are in terms of screen pixels
def __init__ (self, start_grid, grid_size):
self.start_grid = start_grid
self.grid_size = grid_size
# Does co-ordinates match this grid - if so which screen_position
def check_in_grid (self, screen_pos):
if (screen_pos[0] < self.start_grid[0] or
screen_pos[1] < self.start_grid[1] or
screen_pos[0] > self.start_grid[0] + (self.grid_size[0] * 10) or
screen_pos[1] > self.start_grid[1] + (self.grid_size[1] * 10)):
return False
else:
return True
def get_grid_pos (self, screen_pos):
x_offset = screen_pos[0] - self.start_grid[0]
x = math.floor(x_offset / self.grid_size[0])
y_offset = screen_pos[1] - self.start_grid[1]
y = math.floor(y_offset / self.grid_size[1])
if (x < 0 or y < 0 or x > 9 or y > 9):
return None
return (x,y)
# Gets top left of a grid position - returns as screen position
def grid_pos_to_screen_pos (self, grid_pos):
x = self.start_grid[0] + (grid_pos[0] * self.grid_size[0])
y = self.start_grid[1] + (grid_pos[1] * self.grid_size[1])
return (x,y)
Listing 10-13Grid class for Battleship game
这是使用存储在grid_pos
中的网格起始位置和存储在grid_size
中的每个网格方块的大小来处理的。数学模块中的下限方法用于将值向下舍入到最接近的整数。
最后一个类是 Ai 类,它是实现计算机播放器的地方。这是本章的关键部分,因此将对其进行更详细的解释。代码如清单 10-14 所示。
import random
from grid import Grid
# Provides Ai Player
class Ai:
NA = 0
MISS = 1
HIT = 2
def __init__ (self):
# Create 2 dimension list with no shots fired
# access using [x value][y value]
# Pre-populate with NA
self.shots = [ [Ai.NA for y in range(10)] for x in range(10) ]
# Hit ship is the position of the first successful hit on a ship
self.hit_ship = None
def fire_shot(self):
# If not targeting hit ship
if (self.hit_ship == None):
return (self.get_random())
else:
# Have scored a hit - so find neighboring positions
# copy hit_ship into separate values to make easier to follow
hit_x = self.hit_ship[0]
hit_y = self.hit_ship[1]
# Try horizontal if not at edge
if (hit_x < 9):
for x in range (hit_x+1,10):
if (self.shots[x][hit_y] == Ai.NA):
return (x,hit_y)
if (self.shots[x][hit_y] == Ai.MISS):
break
if (hit_x > 0):
for x in range (hit_x-1,-1, -1):
if (self.shots[x][hit_y] == Ai.NA):
return (x,hit_y)
if (self.shots[x][hit_y] == Ai.MISS):
break
if (hit_y < 9):
for y in range (hit_y+1,10):
if (self.shots[hit_x][y] == Ai.NA):
return (hit_x,y)
if (self.shots[hit_x][y] == Ai.MISS):
break
if (hit_y > 0):
for y in range (hit_y-1,-1, -1):
if (self.shots[hit_x][y] == Ai.NA):
return (hit_x,y)
if (self.shots[hit_x][y] == Ai.MISS):
break
# Catch all - shouldn't get this, but just in case guess random
return (self.get_random())
def fire_result(self, grid_pos, result):
x_pos = grid_pos[0]
y_pos = grid_pos[1]
if (result == True):
result_value = Ai.HIT
if (self.hit_ship == None):
self.hit_ship = grid_pos
else:
result_value = Ai.MISS
self.shots[x_pos][y_pos] = result_value
def get_random(self):
# Copy only non-used positions into a temporary list
non_shots = []
for x_pos in range (0,10):
for y_pos in range (0,10):
if self.shots[x_pos][y_pos] == Ai.NA:
non_shots.append((x_pos,y_pos))
return random.choice(non_shots)
# Let Ai know that the last shot sunk a ship
# list_pos is provided, but not currently used
def ship_sunk(self, grid_pos):
# reset hit ship
self.hit_ship = None
Listing 10-14Ai class for Battleship game
在导入和类定义之后,有三个类变量,称为 NA、MISS 和 HIT。这些被用作常量,只是让代码的其余部分更容易理解。看了代码,就更容易理解那个 Ai 了。不中表示不中,而不仅仅是使用数字 1,对于 NA(在那个位置没有射击)和命中也是一样。
之后是通常的构造函数 init 它有一个入口
self.shots = [ [Ai.NA for y in range(10)] for x in range(10) ]
这是一种创建 2D 列表并用 Ai.NA 预先填充它的方法。最终将得到如下所示的列表:
[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]
网格中的每个位置都有一个条目,可以使用self.shots[x-pos][y-pos]
来访问。如果您以前面的格式查看网格,那么 x 和 y 轴被交换(y 交叉,x 向下),但这只是它在打印清单中的表示方式。重要的是如何使用 x,y 位置访问它。
在构造函数中创建的另一个变量是self.hit_ship
。这将记录最后一次射击成功击中目标的位置。当船沉没时,它被重置为无。
射击时,有多个阶段:
-
调用 fire_shot 方法,该方法计算出下一个“猜测”的射击位置。在这个阶段,Ai 类不知道那个镜头是否成功。
-
然后,battleship.py 从 fleet 类调用 fire 方法,该方法添加适当的命中或未命中演员,并返回 True 或 False 以指示该镜头是否命中目标。
-
调用 fire_result 方法,该方法允许 Ai 类更新镜头列表,以了解镜头是否导致命中。
-
如果船已经被击沉,那么调用 ship _ sunk 方法,这样 Ai 类就知道它不需要继续瞄准那艘船。
之所以需要在多个阶段完成,是因为人工智能看不到敌人的船在哪里。因此,它不知道它的拍摄是否成功。
fire_shot
做的第一件事是查看它是否知道一艘尚未沉没的船的位置。它通过查看self.hit_ship
是否被设置为无来实现。如果它不知道敌舰的位置,那么它会使用get_random
方法进行随机猜测,如下所示:
def get_random(self):
# Copy only non-used positions into a temporary list
non_shots = []
for x_pos in range (0,10):
for y_pos in range (0,10):
if self.shots[x_pos][y_pos] == Ai.NA:
non_shots.append((x_pos,y_pos))
return random.choice(non_shots)
这使用random.choice
方法从可用位置中进行选择。在调用它之前,它需要一个列表,只显示还没有尝试过的镜头,这就是代码的其余部分要做的。它创建一个non_shots
列表,然后使用一个嵌套的 for 循环检查所有的网格位置,并将所有尚未尝试过的网格位置添加到non_shots
列表中。然后网格位置被返回给fire_shot
,它依次使用该位置作为其返回值。
如果已经有一艘船最近被击中了,但是还没有被击沉,那么self.hit_ship
中就会有一个位置。在这种情况下,代码会尝试四个不同的方向,直到找到下一个合适的网格位置。合适的位置是没有尝试过的任何位置,并且与成功的拍摄相邻。从以下代码摘录中可以看出这一点:
if (hit_x < 9):
for x in range (hit_x+1,10):
if (self.shots[x][hit_y] == Ai.NA):
return (x,hit_y)
if (self.shots[x][hit_y] == Ai.MISS):
break
如果 hit_ship 的 x 位置小于 9(不在网格的右侧),那么它将向右遍历所有位置。如果它遇到一个值为 NA 的位置,那么这是一个有效的镜头,所以它返回那个位置。相反,如果它遇到一个失误,那么它知道船不在那个方向,所以它使用一个 break 从 for 循环中退出,然后代码继续检查下一个方向。
其他 if 语句做同样的事情,但在其他方向寻找,直到找到一个有效的镜头。
有一个最终条目,如果四个方向都不适用,那么它将返回一个随机的猜测。这不应该被调用,因为在船沉没之前,应该总是有一个有效的位置可以尝试。对于添加“以防万一”代码是否是一个好主意,存在意见分歧。我的理由是,如果有一些我没有想到的情况,或者代码中有错误,那么这将允许游戏继续进行,而不会给用户一个错误。相反的论点是,这可能会隐藏代码中的问题,游戏会继续运行,但不是以预期的方式运行。
当快照的结果已知时,调用fire_result
方法。它用命中是否成功来更新网格位置。如果该镜头是命中的,并且hit_ship
的值当前被设置为无,则它还更新hit_ship
的值。最后一种方法是ship_sunk
,它在船只成功沉没后将hit_ship
的值重置为 0。
这段代码很好地实现了这个策略,但是还有一些地方需要改进。一个是 Ai 总是以相同的顺序尝试位置(水平然后垂直)。如果玩家明白这一点,那么他们就可以通过始终将船只放置在远离左右边缘且始终垂直的位置来获得优势。这只会产生很小的差异,但可以通过随机决定先尝试哪个方向来解决。它也可以在两艘船接触的地方被欺骗,首先击中一艘船,然后击沉第二艘船。它不会回去干掉它撞上的第一艘船。这些不会阻止游戏运行,但是会是一个很好的挑战,让读者创建一个改进的版本。
正如我在开始时警告的,图形游戏编程使用大量代码。走到这一步需要 300 行代码,但这还不包括让用户放置自己的船只或让计算机选择船只位置的能力。这是你可能想自己实现的东西,或者你可以在源代码中的文件夹 battleship2 中查找,在那里我创建了另一个版本,它实现了那个特性以及一些其他的改进。
决赛游戏截图如图 10-3 。
图 10-3
完整的战舰游戏
摘要
这一章探讨了如何让计算机像人类玩家一样运行。在列出的两个例子中,人工智能被创建来模仿人类在玩游戏时会经历的相同过程。
当设计一些计算机程序时,你可能希望让计算机尽可能“聪明”。计算机太聪明的问题是,计算机可以分析可能的结果,这使得它很难被击败。在创作电脑游戏时,重要的是要考虑游戏的难度,使其具有挑战性,但不要太难。
通过创建不同的难度级别或使游戏看起来更像人类,这些游戏还有改进的空间。你可能喜欢尝试调整人工智能或者思考如何将人工智能添加到其他游戏中。
十一、改进和调试
这最后一章将研究一些额外的技术来改进你的代码。当出现问题时,它还会为调试提供一些帮助。决赛将是一场 2D 自上而下的太空射击游戏。这将有助于你建立信心,利用从本书中学到的知识创建自己的游戏。
其他技术
在本书中,介绍了几种不同的游戏制作技术。其中一些被广泛应用于多种游戏中,而另一些可能只对特定类型的游戏有益。还有很多其他的东西可以改善游戏性,让游戏看起来更专业,或者节省你的时间。我在这里增加了一些,可以帮助你提高编程技巧的数量。
关于 Pygame Zero 的更多信息
Pygame Zero 的官方文档可以在 https://pygame-zero.readthedocs.io/en/stable/
在线获得。文档在初次学习 Pygame Zero 时非常有用,但它提供的内容有限。
有一些功能没有包含在官方文档中。标题变量就是一个例子。这就像用来改变窗口大小的宽度和高度变量,但是在这种情况下,标题替换了游戏窗口标题栏上的标题。还有一个图标选项,可用于向任务栏上的应用添加缩略图图标。清单 11-1 展示了这两者在一个示例程序中的作用。
WIDTH = 400
HEIGHT = 200
TITLE = "My Game Title"
ICON = "spacecrafticon.png"
Listing 11-1Program with TITLE and ICON options
图标中引用的文件需要在应用运行的目录中。这通常是与可执行文件相同的目录,但是在 Mu 的情况下,您可能需要将它复制到 mu_code 目录。理想情况下,图标应该是一个 32 x 32 像素大小的 PNG 文件。
图 11-1 显示了在 Raspberry Pi 上运行的程序。左边的游戏不包括标题或图标条目,所以有默认的“Pygame Zero Game”和默认图标。右边的标题是“我的游戏标题”,包括一个飞船图标。
图 11-1
运行带有或不带有标题和图标条目的程序
如果这些都是无证的,那你怎么发现他们呢?我认为值得考虑的是,Pygame Zero 仍然处于早期阶段,正在随着时间的推移进行开发。在您阅读本文时,这些特性可能已经添加到文档中。也可能有更多的新特性还没有在文档中出现。
了解这些特性的一个方法是查看其他人创建的程序。这样你就能看到别人发现了什么。还有一个地方就是看 Pygame Zero 的源代码。源代码在 GitHub 上的 https://github.com/lordmauve/pgzero
。源代码是非常高级的代码,因此对于经验不足的程序员来说可能很难读懂。在寻找特定的东西时,它有时会很有用。
更多关于 Pygame 的信息
除了 Pygame Zero,还可以调用父库 Pygame 中的方法。这已经在第七章的坦克游戏中使用过了。一个例子是直接使用 Pygame 库的pygame.draw.polygon
。
你可以在 www.pygame.org/docs/
的官方文档中找到更多关于 Pygame 的信息。
添加字体
在之前的游戏中,屏幕上显示的文字一直使用默认字体。您可以通过在游戏目录中创建一个fonts
目录并将字体复制到那里来使用其他字体。您可以将现有字体复制到该文件夹中,也可以添加自定义字体,而无需在系统上安装它们。在 Linux(包括 Raspberry Pi)上,通常可以通过从/usr/share/fonts/truetype 复制系统字体来使用它们。或者,你可以在网上搜索找到许多免费的字体。其他系统,如 Windows,很可能对许多字体有版权限制。如果你想和别人分享你的游戏,你应该避免任何非自由字体。当你发布你的游戏时,你可能还需要包括字体的版权信息。
字体的安装有一个异常,因为对于 Pygame Zero,字体的文件名必须全部是小写的。字体文件还必须是以. ttf 扩展名结尾的 True Type 字体。将字体文件复制到 fonts 目录时,您需要重命名该字体文件,以删除任何大写字母。
一旦文件在字体目录中,您可以通过使用文件名(没有。ttf 扩展名)。这显示在以下代码中:
screen.draw.text("This is using the Deja Vu Sans Font", fontname="dejavusans", fontsize=40, topleft=(30,30), color=(255,255,255))
这使用了似曾相识的字体,这是树莓派的标准字体,也可以从 https://dejavu-fonts.github.io/
免费下载。
滚动屏幕
一些游戏可以使用滚动背景。这通常用于玩家在屏幕上保持静止(或在屏幕范围内移动),但背景移动以使玩家看起来在移动的情况。这可以是从一边滚动到另一边(通常在平台游戏中使用,玩家从左到右行走)或从上到下(用于显示交通工具,如飞机,正在向屏幕顶部移动)。
根据游戏的不同,你可能会有一个重复的背景图片,或者你可能会有多个图片可以从一个滚动到另一个。通常,这些图像与屏幕大小相同,但这不是必须的。
创造这种效果的一种方法是使用screen.blit
,它已经在本书的大部分游戏中被用作背景图像。这用于在屏幕上显示图像。在图像位置中使用偏移将显示与屏幕重叠的图像部分。图 11-2 中的图表显示了如何定位两个具有不同偏移量的相同图像,从而产生看起来连续的图像。
图 11-2
创建滚动屏幕
清单 11-2 中的代码展示了如何实现这一点。它使用一个scroll_position
作为第一幅图像的 x 位置,第二幅图像紧随其后,从屏幕外开始。可以调整scroll_speed
以适当加快或减慢滚动速度。
scroll_speed = 2
scroll_position = 0
def draw ():
screen.blit("background_scroll", (scroll_position,0))
screen.blit("background_scroll", (scroll_position+800,0))
def update():
global scroll_position
scroll_position -= scroll_speed
if (scroll_position <= -800):
scroll_position = 0
Listing 11-2Creating a scrolling background
从 CSV 配置文件中读取
当保存游戏的高分时,文件的读写在第四章中有介绍。在这种情况下,它只是一个单一的条目。当存储了更多的信息时,就需要以一种信息易于检索的方式存储数据。
有许多不同的文件格式可以使用,每一种都有其优点和缺点。一种简单的格式是将信息存储为逗号分隔的值,称为 CSV 文件。在这种格式中,文件的每一行都包含多个值,用逗号分隔,如下所示:
String value,1,2,3.1
在本例中,有一个字符串,后跟两个整数和一个浮点数。需要注意的重要一点是,数字是以字符串的形式存储的,所以在它们被转换成数字之前,您不能操作它们。
第一步是将文件分成独立的部分。因为它们是用逗号分隔的,所以您可以使用string.split
方法,该方法根据字符来分隔字符串。在这种情况下,它会根据逗号的位置来拆分行。如果字符串中有逗号怎么办?如果字符串中有逗号,那么 CSV 格式会在字符串两边加上引号,表示引号中的逗号不应被拆分。在下面的条目中,值是相同的,但是这次字符串中有一个逗号。
"String, value",1,2,3.1
现在不能再使用 split 方法,因为这样会忽略引号,导致字符串被分成两个值。
解决方案是使用知道如何处理 CSV 文件的模块。Python 包含了可以做到这一点的 csv 模块。为了演示这一点,我们需要一个 CSV 文件。清单 11-3 中的文件是敌人文件的简化版本,将在太空射击游戏中使用。
0.3,asteroid,asteroid_sml,200,0,4
0.9,asteroid,asteroid_sml,100,0,4
0.9,asteroid,asteroid_med,400,0,3
1.2,asteroid,asteroid_sml,750,0,4
Listing 11-3Sample CSV file for reading demo
这是用来制造需要躲避或摧毁的小行星。第一个字段是小行星出现在屏幕上的时间(以秒为单位),表示它是小行星的关键字“小行星”,图像文件名,x 和 y 坐标,最后是小行星的速度(以像素/时间间隔为单位)。
文件另存为 csvdemo.csv。扩展名表示它是 csv 文件,但它可以有不同的扩展名。在游戏中,它将被命名为“敌人. dat ”,以表明它是一种数据文件形式。扩展名对程序如何处理文件没有任何影响,但是如果您命名文件。csv 文件,则可以在电子表格或类似的应用中打开该文件。这可能是你不希望玩家能够做到的。
该文件是在文本编辑器中创建的。不可能使用 Mu 来编辑文件,因为它只允许您编辑 Python 文件,但是在 Raspberry Pi 和 Linux 发行版上有一个文本编辑器应用,或者使用其他操作系统,该编辑器可能被称为记事本或文本编辑。
读取文件的源代码如清单 11-4 所示。
import csv
import sys
configfile = "csvdemo.csv"
try:
with open(configfile, 'r') as file:
csv_reader = csv.reader(file)
for enemy_details in csv_reader:
start_time = float(enemy_details[0])
# value 1 is type
image = enemy_details[2]
start_pos = (int(enemy_details[3]),
int(enemy_details[4]))
velocity = float(enemy_details[5])
print ("Start time {}, Image {}, Start Pos {}, Velocity {}".format(start_time, image, start_pos, velocity))
except IOError:
print ("Error reading configuration file "+configfile)
# Just end as cannot play without config file
sys.exit()
except:
print ("Corrupt configuration file "+configfile)
sys.exit()
Listing 11-4Code to read in a CSV configuration file
这是一个标准的 Python 3 可执行文件,而不是 Pygame Zero 文件。要在 Mu 中运行代码,您需要更改模式。该代码在文件打开命令之前使用了with
关键字,但在其他方面与之前读取文件的方式相似。当使用with
关键字时,不需要显式关闭文件。这在读取文件出现问题时非常有用,因为关闭文件是自动处理的。
整个操作包含在一个 try except 子句中,该子句将尝试并捕捉任何错误。在这种情况下,发生错误时什么也做不了,因为没有文件中的数据,程序什么也做不了。如果错误是由于读取文件时的 IOError 造成的,那么它会给出一个与文件损坏时不同的错误消息。
使用csv.reader
处理 CSV 文件,它解析文件并放入csv_reader
中,后者将数据存储为 2D 列表。在需要数值的地方,根据需要使用 int 或 float 进行转换。如果数据格式不正确,它们将触发异常,因此它们也包含在 try 子句中。
操纵杆和游戏手柄
到目前为止,游戏都被设计成用鼠标或键盘来玩。下一步将是增加对操纵杆或游戏手柄的支持。不幸的是,Pygame Zero 还不支持游戏手柄,尽管它在路线图上被列为一个潜在的未来功能。在此之前,可以使用 QJoyPad 来模拟键盘按键。这个可以从 http://qjoypad.sourceforge.net/
下载。游戏手柄需要在每台使用它的电脑上进行配置。
为了获得更真实的街机游戏体验,Picade 或 Picade 控制台可以提供一个操纵杆,它可以像键盘一样工作。
为 Picade 创建街机游戏
如果你想在游戏中获得完整的街机游戏体验,那么 Picade 是一款基于 Raspberry Pi 的紧凑型街机。它可以作为一个需要连接到电视或显示器的控制台使用,也可以作为一个带有内置屏幕的完整拱廊机柜使用。
Picade arcade 机柜的照片如图 11-3 所示。
图 11-3
皮莫罗尼皮卡德运行空间射击游戏
Picade 使用安装在树莓派上的帽子。然后将帽子连接到安装在橱柜顶部和侧面的操纵杆和拱廊按钮(开关)。帽子将按键转换成信号发送给树莓派,就好像它们来自键盘一样。您也可以单独购买帽子,用它来打造自己的橱柜。另一种方法是使用不同的电路板来模拟按键,如 Makey Makey 或 Arduino。
图 11-4 中的图像显示了与操纵杆相关的按键以及 Picade 上的每个按钮。
图 11-4
Pimoroni Picade 按键布局
大多数为键盘设计的游戏使用方向键,它们被映射到 Picade 上的操纵杆。按钮有点模糊,所以为了让游戏可以在标准键盘和 Picade 上玩,允许按下两个不同的键来提供 Picade 和普通键盘的兼容性是很有用的。
这通过在按键检查中使用布尔“或”来实现。以下代码摘自坦克游戏第七章,该章允许使用键盘空格键或 Picade 底部黄色按钮(左移)来发射炮弹。
if (keyboard.space or keyboard.lshift):
game_state = 'start'
您可能还想考虑通过配置文件来配置关键代码。
另一件需要注意的事情是,Picade 的屏幕尺寸为传统的 4:3,虽然它可以玩为不同屏幕尺寸设计的游戏,但分辨率 800 x 600 和 1024 x 768 是一个不错的选择。
为 Picade 设计的游戏可以使用键盘而不是操纵杆和按钮在任何树莓 Pi 上运行。Picade 通常运行 RetroPie,这将在下面讨论。
复古派
RetroPie 提供了一种在树莓 Pi 上玩复古电脑游戏的方式。这可以是 Picade,也可以是普通的覆盆子 Pi。RetroPie 通常用于通过模拟器玩老式电脑游戏。由于商业游戏的潜在版权问题,默认情况下它通常不包括任何游戏。
除了运行模拟器游戏,RetroPie 还可以运行在 Pygame 或 Pygame Zero 中创建的游戏。在 RetroPie 中添加对游戏的支持可以让更多的人使用它。RetroPie 可以按照 https://retropie.org.uk/
的说明下载安装。
RetroPie 默认不包含 Pygame Zero,但是 Pygame Zero 可以使用
sudo apt install python3-pgzero
您可以添加一个新菜单来安装您自己的游戏。要向系统添加新菜单,将清单 11-5 中的编码添加到文件/etc/emulationstation/es_systems.cfg
中的</systemList>
条目之前。
<system>
<name>pgzero</name>
<fullname>Pygame Zero</fullname>
<path>/home/pi/RetroPie/roms/pgzero</path>
<extension>.sh</extension>
<command>%ROM%</command>
<theme>pgzero</theme>
</system>
Listing 11-5Define new menu for RetroPie
在适当的主题文件夹中也需要有一个条目。源代码中包含一个文件。这可以按照以下说明进行提取:
cd ~
tar -xvzf pgzero-retro-theme.tgz
cd /etc/emulationstation/themes/carbon
sudo cp -r ~/retropietheme/* .
安装后,会有一个 Pygame Zero 的菜单。
要在 RetroPie 上安装游戏,在 roms 目录下创建一个文件夹,通常是~/RetroPie/roms。在我的例子中,我创建了一个名为 pgzero 的。在该目录中,创建一个简单的 shell 脚本来启动程序。脚本文件如清单 11-6 所示。
cd ~/compassgame
pgzrun compassgame.py
Listing 11-6Script file for launching the compass game ~/RetroPie/roms/pgzero/CompassGame.py
脚本文件还需要可执行权限,这可以通过使用
chmod +x ~/RetroPie/roms/pgzero/CompassGame.py
游戏现在可以从主菜单中选择。菜单截图如图 11-5 所示。
图 11-5
双打零式菜单
排除故障
当程序出错时,这就是所谓的 bug。早期的错误可能包括机械问题,其中包括一只死了的蛾子阻碍了继电器的关闭。现在,它通常指计算机代码中的错误。这可能是与预期行为相比,对程序运行方式产生负面影响的任何事情。这可能是程序根本没有运行,也可能是演员需要移动两个额外像素才能被检测到的小错误。这也可能是一个性能问题,即程序运行速度比它应该运行的速度慢。
如何测试和调试程序的细节很容易写满一整本书。这本书将着眼于一些与调试和性能相关的技术。
错误消息
首先要检查的是是否有任何错误消息。在 Mu 中,这些通常显示在屏幕底部的面板中。但是,当您单击“停止”时,它们将会丢失。另一种方法是尝试从命令行运行程序,看看是否会得到错误消息。
有时给出的信息会很明显,帮助你马上找到问题。对于一些错误消息,您可能需要做一些调查。例如,典型的错误消息可能包括
KeyError: "No image found like 'batleship'. Are you sure the image exists?"
首先要检查的是该名称是否与预期的文件匹配。在这种情况下,有一个错别字“战舰”拼写错误。
如果名称是正确的,那么您应该检查文件是否在正确的目录中。对于图像,它应该在img/
子目录中。还要注意,在某些情况下,文件是从其他位置引用的,这可能是相对于程序文件的位置,或者在某些情况下是在程序运行的目录中(如~/mu_code
目录)。
其他错误可能是指代码中的语法错误。他们通常会给你行号,但是要注意代码中的错误可能比它说的要早。例如,这是无效语法错误消息的一部分:
File "battleship.py", line 19
grid_img_2 = Actor ("grid", topleft=(500,150))
^
SyntaxError: invalid syntax
该错误似乎表明问题出在第 19 行。但是,查看第 18 行和第 19 行的代码可以发现,错误实际上出现在第 18 行。
18\. grid_img_1 = Actor ("grid", topleft=(50,150)
19\. grid_img_2 = Actor ("grid", topleft=(500,150))
在这种情况下,第 18 行缺少右括号。由于缺少括号,解释器认为第 19 行是第 18 行的延续,并且第 19 行有错误。这是一种常见的情况,所以总是努力在有错误的那一行之前检查查找错误。
同样不要忘记确保 Mu 处于 Pygame Zero 模式。如果你得到一个错误,说“NameError: name 'Actor '未定义”,那么这可能是因为你正试图在 Python 3 模式下运行。
检查变量名
另一个常见的问题是输入错误的变量名。如果你试图把一些东西存储到一个不同的变量中,那么 Python 会创建一个新的变量。因此,引用正确变量的代码将看不到更新。请记住,变量名是区分大小写的,因此使用错误的大小写会产生相同的效果。
您还应该检查该变量在当前范围内是否可访问。如果你试图更新一个没有包含在全局变量中的变量,那么它会创建一个局部变量而不会更新全局变量。
打印报表
当试图理解程序的行为时,一个有用的工具是使用打印命令。当程序在图形显示中运行时,这些可用于向控制台显示消息。
通过添加一些打印命令,您可以随着游戏的进行跟踪变量的状态,看看会发生什么。
IDE 调试工具
Mu 编辑器包括一些基本的调试工具,这些工具可以改进打印语句的添加。Mu 中有一个调试模式(在播放按钮旁边)。您可以通过单击行号在代码中设置断点。断点由红色圆圈表示。从菜单中,您可以运行到断点,或者单步执行,一些变量会显示在右侧的新窗格中。
随着编程的进展,您可能想看看专业的 IDE(集成开发环境)。不幸的是,用 Pygame Zero 设置大多数 ide 是很困难的,所以你现在可能想坚持使用 Mu,但这是你将来可能想看的东西。
橡皮鸭调试
有时候程序并没有按照你期望的方式运行,你也不知道为什么。如果是这种情况,那么了解一下程序应该如何工作是很有用的。这样做的一个好方法是在单步执行代码时大声描述它应该如何工作。这可以用在无生命的物体上,比如橡皮鸭。这个想法是,在讨论代码的工作方式时,您可能会意识到为什么它没有像预期的那样工作。这种效果令人惊讶。我最喜欢的调试鸭如图 11-6 ,但是你不需要用鸭;任何其他对象也一样。
图 11-6
英国调试鸭
表演
Python 的一个特点是它是一种解释型语言。这意味着您编写的基于文本的代码在运行时会被转换为计算机能够理解的代码。这与编译语言相比,编译语言是在程序运行之前完成的。一般来说,解释型语言比先编译程序要慢,这可能会导致性能问题。
到目前为止,本书中创建的游戏都很短,因此应该不会导致性能问题,但是随着使用的演员和资源数量的增加,您可能会发现代码开始运行缓慢。
本书中的一些代码已经通过检查自更新功能上次运行以来的时间来允许以不同的速度运行,但这可能不足以阻止游戏无响应。
当编写代码时,首要任务通常是使代码尽可能简单,这样就很容易理解它是如何工作的。这有助于限制错误的数量,并使其更容易维护,但它可能不会产生最有效的代码。
可以采取一些措施来提高程序的性能。首先要确定性能问题可能出在哪里。如果不了解问题出在哪里,那么资源可能会浪费在优化很少使用的代码上,或者计算机处于空闲状态而不会注意到性能的提高。通常你需要在程序运行过程中定期调用的循环中寻找。
下一件事是确保您有某种测试方法来查看您的更改是否提高了性能。有时,所做的更改听起来可能会提高性能,但实际上会降低性能。
以下是一些可以提高性能的建议:
-
如果一个现有的 Python 库已经存在,那么就使用它(它可能已经被优化了)。
-
检查消耗大量资源的循环。
-
避免全局变量。
-
在函数中,一旦完成就返回,而不是继续执行不必要的代码。
-
使用代码模式(找到其他人创建的已经考虑了性能的代码)。
-
重新设计算法。
这些只是一些建议,可能会也可能不会提高性能。最后一个技巧是模糊的,实际上取决于您正在创建的代码。如果你在做别人可能已经做过的事情,比如整理信息,那就看看别人都创造了什么代码。可能有些算法处理少量数据比处理大量数据效果更好。
太空射击游戏
这本书的最后一个游戏是一个太空射击游戏。在这部影片中,一艘宇宙飞船飞来飞去,向阻挡它前进的障碍物射击。这个游戏吸收了整本书中讨论过的很多技术。它是为 Picade 设计的,但使用键盘控制也能同样工作良好。为了适应街机的主题,游戏有一种有意的复古感觉,包括位图图像、乐谱的块字体和微小的声音效果。
该游戏的设计模拟了一个小行星带,飞船必须绕过这个小行星带或者炸开一条路。小行星被称为敌人,因为未来可能还会有敌人的飞船飞过屏幕。
游戏截图如图 11-7 。
图 11-7
太空射击游戏
源代码被分成不同类的多个文件。飞船被定义为 Actor 类的子类。这显示在清单 11-7 中。
from pgzero.actor import Actor
class SpaceShip(Actor):
def set_speed (self, movement_speed):
self.movement_speed = movement_speed
def move (self, direction):
if (direction == "up"):
self.y -= self.movement_speed
elif (direction == "down"):
self.y += self.movement_speed
elif (direction == "left"):
self.x -= self.movement_speed
elif (direction == "right"):
self.x += self.movement_speed
# Make sure that the ship remains on the screen
if self.x < 20:
self.x = 20
if self.x > 780:
self.x = 780
if self.y < 20:
self.y = 20
if self.y > 580:
self.y = 580
Listing 11-7Spaceship class in file spaceship.py
这实质上是指南针游戏中角色代码的面向对象版本。这比指南针游戏简单,因为当飞船移动时图像不会改变。一个可能的改进是添加不同的图像,如果你想让船看起来像是在侧移时倾斜,或者让火焰在加速前进时变得更大。
下一个文件是小行星类。这是一个 Actor 类的孩子处理屏幕上小行星的绘制。这显示在清单 11-8 中。
from pgzero.actor import Actor
import time
from constants import *
class Asteroid(Actor):
def __init__ (self, screen_size, start_time, image, start_pos, velocity):
Actor.__init__(self, image, (start_pos))
self.screen_size = screen_size
self.start_pos = start_pos
self.start_time = start_time
self.velocity = velocity
self.status = STATUS_WAITING
def update(self, level_time, time_interval):
if self.status == STATUS_WAITING:
# Check if time reached
if (time.time() > level_time + self.start_time):
# Reset to start position
self.x = self.start_pos[0]
self.y = self.start_pos[1]
self.status = STATUS_VISIBLE
elif self.status == STATUS_VISIBLE:
self.y+=self.velocity * 60 * time_interval
def reset(self):
self.status = STATUS_WAITING
def draw(self):
if self.status == STATUS_VISIBLE:
Actor.draw(self)
def hit(self):
self.status = STATUS_DESTROYED
Listing 11-8Asteroid class in file asteroid.py
这个类通过添加一些变量和方法来扩展 Actor 类。start_time
是相对于每一关的开始,小行星出现在屏幕上的时间。根据小行星的大小,小行星可以有不同的图像。start_pos
决定了小行星在屏幕上的起始位置,然后速度就是小行星向屏幕底部移动的速度,是小行星移动的像素数的度量。
update 方法处理小行星何时变得可见,并相对于其速度移动小行星。重置方法隐藏了小行星。hit 方法更新显示小行星是否已被摧毁的状态。draw 方法测试小行星是否可见,如果可见,则调用父 draw 方法在屏幕上显示它。
constants.py 文件中存储了几个必需的常量。这是为了使它们在多个文件和类中可用。这显示在清单 11-9 中。
# Status for each of the enemies
STATUS_WAITING = 0
STATUS_VISIBLE = 1
STATUS_DESTROYED = 2
STATUS_OFFSCREEN = 3
# Delay in seconds for messages on screen
DELAY_TIME = 2
Listing 11-9Shared constants in file constants.py
这可以像系统配置文件一样使用,但是在编辑该文件时需要小心,因为它是一个 Python 文件,任何错误都可能导致程序停止运行,并显示一条模糊的错误消息。
小行星类别定义了单个小行星。敌人类提供了一个小行星的集合,因此可以同时处理多个实例。这显示在清单 11-10 中。
import sys
import time
import csv
from constants import *
from pgzero.actor import Actor
from asteroid import Asteroid
# Enemies is anything that needs to be destroyed
# Could be an asteroid or an enemy fighter etc.
class Enemies:
def __init__(self, screen_size, configfile):
self.screen_size = screen_size
self.asteroids = []
# Time that this level started
self.level_time = time.time()
self.level_end = None
# Load the config file
try:
with open(configfile, 'r') as file:
csv_reader = csv.reader(file)
for enemy_details in csv_reader:
if enemy_details[1] == "end":
self.level_end = float(enemy_details[0])
elif enemy_details[1] == "asteroid":
start_time = float(enemy_details[0])
# value 1 is type
image = enemy_details[2]
start_pos = (int(enemy_details[3]),
int(enemy_details[4]))
velocity = float(enemy_details[5])
self.asteroids.append(Asteroid(start_time, image, start_pos, velocity))
except IOError:
print ("Error reading configuration file "+configfile)
# Just end as cannot play without config file
sys.exit()
except:
print ("Corrupt configuration file "+configfile)
sys.exit()
# Next level reset time
def next_level (self):
self.level_time = time.time()
for this_asteroid in self.asteroids:
this_asteroid.reset()
def reset (self):
self.level_time = time.time()
for this_asteroid in self.asteroids:
this_asteroid.reset()
# Updates positions of all enemies
def update(self, time_interval):
# Check for level end reached
if (self.level_end != None and
time.time() > self.level_time + self.level_end):
self.next_level()
for this_asteroid in self.asteroids:
this_asteroid.update(self.level_time, time_interval)
# Draws all active enemies on the screen
def draw(self, screen):
for this_asteroid in self.asteroids:
this_asteroid.draw()
# Check if a shot hits something - return True if hit
# otherwise return False
def check_shot(self, shot):
# check for any visible objects colliding with shot
for this_asteroid in self.asteroids:
# skip any that are not visible
if this_asteroid.status != STATUS_VISIBLE:
continue
if (this_asteroid.colliderect(shot)):
this_asteroid.hit()
return True
return False
# Check if crashed - return True if crashed
# otherwise return False
def check_crash(self, spacecraft, collide_points=None):
for this_asteroid in self.asteroids:
# skip any that are not visible
if this_asteroid.status != STATUS_VISIBLE:
continue
# Crude detection based on rectangles
if (this_asteroid.colliderect(spacecraft)):
# More accurate detection, but more time consuming
# (optional if collide_points default to None)
if (collide_points == None):
this_asteroid.status = STATUS_DESTROYED
return True
for this_point in collide_points:
if this_asteroid.collidepoint(
spacecraft.x+this_point[0],
spacecraft.y+this_point[1] ):
this_asteroid.status = STATUS_DESTROYED
return True
return False
Listing 11-10Enemies class in file enemies.py
这个职业已经被命名和编写,所以它可以扩展到其他敌人,而不仅仅是小行星。init 方法主要用于读取配置文件。配置文件使用逗号分隔的变量来定义每个敌人出现的时间、地点和速度。这与清单 11-4 中的前一段代码相同,但是增加了一个额外的选项“end ”,以表示当到达关卡末尾时,会创建一个小行星对象的新实例,而不是打印到屏幕上。这存储在小行星列表中。
其他方法处理改变一个水平,包括重置所有的敌人。更新方法检查到达关卡时间的终点,否则只为每个小行星调用更新。draw 方法循环绘制任何已创建的小行星。check_shot 和 check_crash 方法检查是否有任何镜头或航天器撞击了小行星。如果这两种情况中的任何一种发生了,那么这颗小行星将被毁灭。check_crash 方法使用一种新技术来检测冲突。以前的碰撞使用了 colliderect,它使用了一个包含整个航天器的矩形。这样做的问题是,由于图像顶部的大部分区域不是船的一部分,碰撞发生得太快了。这可以在图 11-8 中看到,航天器和小行星之间仍然有明显的间隙,但它们的矩形重叠。
图 11-8
不规则形状上的碰撞问题
为了克服这个问题,使用了基于航天器末端的点列表。
Player 类用于与播放器相关的变量。代码包含在清单 11-11 中。
class Player:
def __init__ (self):
self.lives = 3
self.score = 0
def reset (self):
self.lives = 3
self.score = 0
Listing 11-11Player class in file player.py
如您所见,这是一个非常简单的类,只有几行代码。它用于存储玩家剩余的生命数并跟踪分数。这是为了避免出现难以管理的全局变量。取而代之的是 player 类的一个实例,它可以用来保存生命的数量和分数。
Shot 类是 Actor 类的子类,用于跟踪镜头。这显示在清单 11-12 中。
from pgzero.actor import Actor
class Shot(Actor):
def update(self, time_interval):
self.y-=3 * 60 * time_interval
Listing 11-12Player class in file player.py
镜头基本上是一个演员与镜头的形象发射。快照所需的大部分功能由父类提供,但是提供了一个更新方法来在每次刷新时移动演员的位置。
其余的代码在 spaceshooter.py 文件中,如清单 11-13 所示。
import time
from constants import *
from spaceship import SpaceShip
from player import Player
from shot import Shot
from enemies import Enemies
WIDTH=800
HEIGHT=600
TITLE="Space shooter game"
ICON="spacecrafticon.png"
scroll_speed = 2
player = Player()
spacecraft = SpaceShip("spacecraft", (400,480))
spacecraft.set_speed(4)
enemies = Enemies((WIDTH,HEIGHT), "enemies.dat")
# List to track shots
shots = []
# shot last fired timestamp - to ensure don't fire too many shots
shot_last_fired = 0
# time in seconds
time_between_shots = 0.5
scroll_position = 0
# spacecraft hit points
# positions relative to spacecraft center which classes as a collide
spacecraft_hit_pos = [
(0,-40), (10,-30), (-10,-30), (13,-15), (-13,-15), (25,-3), (-25,-3),
(46,12), (-46,12), (25,24), (-25,24), (10,27), (-10,27), (0,27) ]
# Status
# "start" = Press fire to start
# "game" = Game in progress
# "gameover" = Game Over
status = "start"
# value for waiting when asking for option
wait_timer = 0
def draw ():
# Scrolling background
screen.blit("background", (0,scroll_position-600))
screen.blit("background", (0,scroll_position))
enemies.draw(screen)
spacecraft.draw()
# Shots
for this_shot in shots:
this_shot.draw()
screen.draw.text("Score: {}".format(player.score), fontname="computerspeak", fontsize=40, topleft=(30,30), color=(255,255,255))
screen.draw.text("Lives: {}".format(player.lives), fontname="computerspeak", fontsize=40, topright=(770,30), color=(255,255,255))
if status == "start" or status == "start-wait":
screen.draw.text("Press fire to start game", fontname="computerspeak", fontsize=40, center=(400,300), color=(255,255,255))
elif status == "gameover" or status == "gameover-wait":
screen.draw.text("Game Over", fontname="computerspeak", fontsize=40, center=(400,200), color=(255,255,255))
def update(time_interval):
global status, scroll_position, shot_last_fired, wait_timer
# Allow Escape to quit straight out of the game regardless of state of the game
if keyboard.escape:
sys.exit()
# Wait on fire key press to start game
if status == "start":
# start timer
wait_timer = time.time() + DELAY_TIME
status = "start-wait"
if status == "start-wait":
if (time.time() < wait_timer):
return
if keyboard.space or keyboard.lshift:
player.reset()
enemies.reset()
status = "game"
elif status == "gameover":
# start timer
wait_timer = time.time() + DELAY_TIME
status = "gameover-wait"
elif status == "gameover-wait":
if (time.time() < wait_timer):
return
if keyboard.space or keyboard.lshift:
status = "start"
elif status == "game":
# Scroll screen
scroll_position += scroll_speed
if (scroll_position >= 600):
scroll_position = 0
# Update existing shots
for this_shot in shots:
# Update position of shot
this_shot.update(time_interval)
if this_shot.y <= 0:
shots.remove(this_shot)
# Check if hit asteroid or enemy
elif enemies.check_shot(this_shot):
player.score += 10
# remove shot (otherwise it continues to hit others)
shots.remove(this_shot)
sounds.asteroid_explode.play()
if enemies.check_crash(spacecraft, spacecraft_hit_pos):
player.lives -= 1
if player.lives < 1:
status = "gameover"
return
else:
sounds.space_crash.play()
# Update enemies after checking for a shot hit
enemies.update(time_interval)
# Handle keyboard
if keyboard.up:
spacecraft.move("up")
if keyboard.down:
spacecraft.move("down")
if keyboard.left:
spacecraft.move("left")
if keyboard.right:
spacecraft.move("right")
if keyboard.space or keyboard.lshift:
# check if time since last shot reached
if (time.time() > shot_last_fired + time_between_shots):
# rest time last fired
shot_last_fired = time.time()
shots.append(Shot("shot",(spacecraft.x,spacecraft.y-25)))
# Play sound of gun firing
sounds.space_gun.play()
Listing 11-13Space shooter main program file spaceshooter.py
这段代码的大部分应该是熟悉的,因为它使用的技术与其他游戏或本章前面的清单中使用的技术相似。
主要的类实例是玩家、飞船和敌人。还有一个列表用于跟踪发射的子弹。航天器撞击位置列表用于航天器的碰撞点位置。
draw 函数包括清单 11-2 中的背景滚动代码。然后从敌人和飞船对象中调用每个绘制方法。它还根据需要显示文本,使用计算机朗读字体。字体的详细信息可从 https://fontstruct.com/fontstructions/show/1436469
获得,许可详细信息包含在字体目录中。
更新函数处理游戏的状态,调用各种更新方法并更新背景滚动图像的位置。
它会更新镜头,并删除任何超出屏幕顶部的镜头。它还检查飞船是否已经坠毁,并更新生命或将状态更改为游戏结束。代码的其余部分处理按键和飞行器的移动,并在按下 fire 按钮时创建一个 Shot 类的新实例。
spaceshooter.py 文件还通过位于sounds
目录中的三个文件添加音效:asteroid_explode.wav
、space_crash.wav
和space_gun.wav
。这些声音文件基于来自 freesound 库的 Creative Commons 许可下的文件。我用 Audacity 编辑了声音,改变了音调,过滤掉了有限的频率范围。license.txt 文件中包含源代码的详细信息。
还有一个文件需要确定小行星应该什么时候出现。这是一个名为 answers . dat 的文件,遗憾的是 Mu 只能用来编辑以结尾的文件。因此应该使用另一个文本编辑器来编辑该文件。这是一个决定每个小行星何时出现的配置文件。配置文件如清单 11-14 所示。
0.3,asteroid,asteroid_sml,200,0,4
0.9,asteroid,asteroid_sml,100,0,4
0.9,asteroid,asteroid_med,400,0,3
1.2,asteroid,asteroid_sml,750,0,4
1.2,asteroid,asteroid_sml,400,0,4
1.6,asteroid,asteroid_lge,350,0,4
2.0,asteroid,asteroid_med,200,0,4
2.4,asteroid,asteroid_sml,150,0,2.5
2.5,asteroid,asteroid_med,450,0,4
2.7,asteroid,asteroid_med,605,0,4
3.0,asteroid,asteroid_lge,720,0,4
3.1,asteroid,asteroid_sml,380,0,4
3.6,asteroid,asteroid_lge,770,0,4
3.8,asteroid,asteroid_sml,200,0,3
3.8,asteroid,asteroid_sml,100,0,4
4.1,asteroid,asteroid_med,400,0,4
4.4,asteroid,asteroid_sml,750,0,4.5
5.0,asteroid,asteroid_sml,400,0,4
5.0,asteroid,asteroid_lge,350,0,3
5.0,asteroid,asteroid_med,200,0,4
5.2,asteroid,asteroid_sml,150,0,4
5.2,asteroid,asteroid_sml,600,0,3
5.2,asteroid,asteroid_med,620,0,4
5.2,asteroid,asteroid_med,450,0,5
5.5,asteroid,asteroid_lge,720,0,4
5.6,asteroid,asteroid_sml,380,0,4
6.0,asteroid,asteroid_lge,770,0,4
9.0,end
Listing 11-14Space shooter enemies configuration file enemies.dat
文件中引用了三个不同的图像,asteroid_sml.png、asteroid_med.png 和 asteroid_lge.png,它们都在images
目录中。
还有一个名为 spacecrafticon.png 的图标文件,用于应用标题栏上显示的图标。
文件“敌人. dat”和“spacecrafticon.png”需要在程序运行的目录中。从命令行运行时,这通常是 spaceshooter.py 文件所在的位置。如果从 Mu 编辑器运行游戏,那么这两个文件将需要在mu_code
目录中。
摘要
太空射击游戏使用了贯穿全书的各种技术。还可以添加其他功能。一个有用的特性是一个高分,就像指南针游戏中增加的那样。另一种是针对不同类型的敌人,也许是一种可以反击而不是直接撞上飞船的敌人。如果你不喜欢复古的感觉,那么你可以把图片换成质量更好的。
接下来去哪里?
回顾书中的游戏,你会发现许多游戏使用了相同或相似的技术。这些是开始创作游戏所需要的基本技能,但是还有很多需要学习。最好的学习方法是去写一些代码并创建你自己的游戏。
你可以从这本书的一个游戏开始,增加新的功能。你可以从一个现有的游戏开始,改变主要玩家的形象来彻底改变游戏。也许把飞船换成赛车,把背景换成赛车必须行驶的赛道。
感觉更冒险?现在,你已经看到了这些在不同游戏中的实现,你将有希望学到足够的知识来设计和创建你自己的游戏。附录中有一些有用的链接,链接到你可能会发现有用的更多信息;这包括 Pygame Zero 代码以及可以一起使用的 Pygame。
希望这表明游戏编程是对每个有编程经验的人开放的。
我将进一步开发这些游戏,或者创建你自己的版本。我制作的任何新版本都将通过社交媒体 PenguinTutor 在 Twitter、脸书和 YouTube 上分享。请随意分享你所做的任何改进或者你受这本书启发而创造的任何东西。