原文:
zh.annas-archive.org/md5/8d68d722c94aedcc91006ddf3f78c65a
译者:飞龙
第七章:列表推导和属性
“需要是发明之母”是一句流行的英语谚语,意思是迄今为止或将来发明的任何先驱性想法都是因为它们的需要。例如,巨大的视频托管平台 YouTube 之所以受欢迎,不仅是因为其商业模式,还因为它的推出时间。许多创意艺术家,如视频编辑者、歌手、舞者和游戏玩家,希望该平台能在全球范围内得到认可,而观众希望有一个可以免费学习和娱乐的平台。因此,需求是任何新发明的推动力。然而,这并不意味着每一个在正确时间创造的革命性想法都会成功。其中一些因为没有解决技术所带来的限制而失败。我们的异想天开受到这些技术的限制,尽管我们一直在进步,但我们还没有到达那里。
因此,为了使任何革命性的想法成功,我们必须了解我们的限制。我们的主要限制是内存空间和处理能力。在处理这些限制的同时,本章将教会我们编写一个优雅的程序,可以在一定程度上节省内存存储和运行时间。我们将学习 Python 提供的理解和生成,它们将使程序在保持可读性的同时运行得更快。
本章将涵盖以下主题:
-
代码复杂性概述
-
循环与列表推导的比较
-
装饰器
-
Python 属性
-
使用 LC 和属性完善贪吃蛇游戏
技术要求
您需要满足以下要求才能完成本章:
-
Python 3.5 或更新版本
-
Python IDLE(Python 的内置 IDE)
-
文本编辑器
-
网络浏览器
本章的文件可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter07
查看以下视频,了解代码的运行情况:
代码复杂性概述
到目前为止,我们一直在学习 Python 的基础知识,如函数、数据结构和面向对象编程。现在,我们能够创建自己的逻辑,甚至编写一些游戏。随着我们继续为这些游戏添加功能,我们预计会有数百万行代码。这些庞大的代码行(LOC)将很难理解、解释和处理。例如,在某些情况下,我们可能需要在代码可维护性和优化之间进行权衡。假设您维护一个购物网站的代码,有一天您的网站被数百万次点击,这超出了服务器的处理速度。现在,您必须适应这样一种情况,即您必须要么在没有延迟的情况下为客户提供没有适当产品推荐的页面,要么在稍有延迟的情况下为客户提供适当的推荐。
另一方面,我们可能希望实现一定程度的代码优化。如果某个程序需要几秒钟才能执行,那么优化后,我们可能希望在一毫秒内运行它。现在,我们可能认为这段时间微不足道,但在第一次运行时确实如此。然而,当我们不得不运行相同的程序一千次时,我们可能会节省一些时间,这对于任何实时应用可能是有用的。
在本章中,我们将专注于修改我们的代码以提高其质量和效率的方法。如果我们设法使原始程序的代码更短,减少内存消耗并增加其执行速度,并减少输入/输出指令的交互,那么可以说任何原始程序都已经被优化了。优化的基本规则是,优化的结果必须与非优化的结果具有相同的输出和后果。
然而,当优化后的程序在时间和空间复杂性方面比非优化程序有利的结果时,这些要求可能是微不足道的。例如,在火箭发射活动中,我们可能希望获得周围区域的实时数据,而不在乎数据的准确性。因此,即使优化可能以某种方式影响系统的输出,但在这种情况下优化是重要的。
在学习优化之前,我们将先看看它的必要性。为了检查优化的空间,我们必须首先分析代码,而分析代码的主要方法是使用复杂性分析。算法复杂性分析是一个工具,它将解释程序随着程序大小的增加而表现的行为。当输入到程序中增加时,程序的大小也会增加。因此,我们必须根据数学f(n)
函数来检查程序,其中n代表程序的输入。现在,你可能会想知道,运行这个算法是否会导致时间单位的差异,这取决于诸如 NASA 或苹果公司等公司使用的不同计算机,它们的处理能力比我们的简单计算机要高。因此,对我们的 PC 上运行的算法进行评判可能是不公平的。如果你曾经面对这种模棱两可的情况,那么恭喜你,因为你正在像程序员一样思考。为了测试算法是否独立于处理速度、磁盘能力和强大的软件,科学家们开发了一种称为渐近分析的东西。这种分析将检查算法与输入的大小,并且不记录执行所需的时间。我们称之为时间复杂度,它允许我们检查算法在输入数据大小方面的运行情况。为了观察算法的时间复杂度,我们应该使用最好和最知名的符号,即大 O 符号。这个符号将帮助我们分析算法的最坏情况,并帮助我们优化它。让我们使用一些简单的例子来分析以下复杂性:
O(1)
: 这个符号用来定义与输入大小无关的算法。增加或减少输入数据的任何集合可能不会影响算法的执行速度:
arr = [1,2,3,4,5]
for i in arr:
print(arr[0])
前面的程序将打印数组的第一个元素,无论其中的数据是什么。因此,它的时间复杂度为O(1)
。这被认为是最佳情况,很难在现实情况中实现。
O(n)
: 这个符号描述了随着输入数据的大小(n)
的增加,算法的运行时间将呈线性增加。例如,在下面的程序中,最坏情况可能导致我们遍历整个列表。因此,性能取决于输入的大小:
n = int(input("Enter any number"))
for i in range(1,100):
if i == n:
print(i)
break
O(n²)
: 这个符号指定了与输入数据的平方大小成正比的算法的性能。在嵌套循环中非常常见。
还有一些其他符号,比如O(2^N)
和O(log N)
,但我们不需要再深入了解,因为我们已经学到足够多,可以区分好的代码和坏的代码。
现在我们已经获得了足够的关于优化的信息,以及我们如何分析算法的方式,是时候看一些例子来澄清非优化和优化代码之间的差异了。在深入分析以下代码的算法之前,我们将学习如何分析程序的复杂性。由于本书不打算教授高级算法概念,我们将研究评估性能和优化的基本思想。这将为您提供一个工具,帮助您编写更短、可读且不浪费内存资源的程序。因此,这种实践将使我们能够在根据场景的资源的有效使用方面,即时间和内存,区分算法时做出正确的决策。让我们开始看一下以下代码:
for i in range(1, 10):
for j in range(i):
print(i, end='')
print()
#output
1
22
333
4444
55555
666666
7777777
88888888
999999999
在前面的代码中,我们使用了两个嵌套的for
循环来获得所需的输出。在第一个 for 循环的情况下,它逐个获取范围的所有元素,并且在每次迭代时,我们进行第二个 for 循环。对于第二个循环,我们将有相同元素的相同数量的范围计数。例如,对于元素 2,我们将在第二个 j 循环中得到[2,2],从而多次打印相同的数字。如果您正确地遵循了前面的章节,那么这段代码应该不难理解。现在,让我们观察有趣的部分。我们知道第一个 i^([-])循环将迭代整个数据集的范围,这将导致时间复杂度为O(n)
。j 循环也是如此。因此,总时间复杂度将是O(n) * O(n)
,这将导致O(n²)
。这代表了一个昂贵的算法。我们必须尝试将嵌套循环的程序转换为单个循环,如下所示:
for i in range(1, 10):
print (str(i) * i)
#output
1
22
333
4444
55555
666666
7777777
88888888
999999999
前面的程序包含一个单独的 for 循环,因此它将一次循环整个数据集,结果只会是O(n)
而不是O(n²)
。
您可能想知道为什么这些东西如此重要,为什么我们在本章中涵盖了它们。答案很简单。尽管在 Python 编写的某些应用程序中,即 Android 应用程序或网站,节省几毫秒可能是不必要的。但是,在处理大量数据的大型应用程序中,这种时间测量可能会增加。例如,让我们想象一个应用程序调用一个函数来预测新闻是否是假的。假设非优化的代码需要几秒钟来进行预测,而优化则需要几毫秒。在这里,数量似乎很小,但想象一下我们调用相同的函数 100 万次。现在,计算整体上将节省的时间:277.5 小时。
这很麻烦,不是吗?Python 提供了两种构造来促进这些庞大数据集的更快和更有效的处理:推导和生成器。推导有三种类型,即列表、字典和集合。首先,我们将深入学习列表推导。然后,我们将通过与它们相关联来探索另外两种(字典和集合)。
循环与列表推导
自从第三章以来,我们一直在使用循环编写我们的程序,流程控制-为您的游戏构建决策制造者,我们对循环模式非常熟悉,特别是对于循环。它们将迭代一些项目,并且在每次迭代时,迭代变量将执行一些操作。通过将 for 循环与适当的数据结构结合使用,可以减轻 for 循环的强大力量,就像这样:
new_list = []
for i in range(10):
new_list.append(i)
print(new_list)
#output
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Python 还有另一种更简单的方法来做同样的事情,即列表推导。列表推导的输出将始终是一个列表,这将是在 for 循环上下文中表达式评估的结果。这之后是条件。使用列表推导通过表达式和条件模拟for
循环的代码将是单行代码。因此,使用列表推导编写的代码更短,更易于维护。要理解列表推导的工作原理,我们必须熟悉其模式。我们将在下一节学习列表推导模式。
列表推导模式
在本节中,我们将使用列表推导修改之前由for
循环编写的代码。列表推导的结果是一个列表。方括号内的模式是一个表达式,后面跟着一个循环,如下所示:
new_list = [i for i in range(10)]
print(new_list)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
在上面的代码中,左侧的对象,即new_list
代表存储列表推导结果的输出列表。在右侧的表达式中,由方括号括起来的语句将导致列表推导。首先,我们传递要执行的表达式,然后是循环和条件(如果有的话)。以下插图表示了列表推导的模式:
让我们看一个简单的例子来解释上述模式:
even_power = [i * i for i in range(5) if i % 2 == 0]
print(even_power)
[0, 4, 16]
方括号内的第一个语句表示一个表达式。在使用列表推导时,只能有一个单一的表达式,而不像for
循环的主体那样。在表达式之后,我们应用空格并提供迭代。我们也可以添加嵌套循环。在添加迭代之后,我们必须指定条件,如果有的话。列表推导广泛用于连接两个列表的元素并创建一个新的列表,如下所示:
numbers = [1,2,3,4,5]
alphabets = ['a','b','c','d','e']
new_list = [[n,a] for n in numbers for a in alphabets]
print(new_list)
[[1, 'a'], [1, 'b'], [1, 'c'], [1, 'd'], [1, 'e'], [2, 'a'], [2, 'b'], [2, 'c'], [2, 'd'], [2, 'e'], [3, 'a'], [3, 'b'], [3, 'c'], [3, 'd'], [3, 'e'], [4, 'a'], [4, 'b'], [4, 'c'], [4, 'd'], [4, 'e'], [5, 'a'], [5, 'b'], [5, 'c'], [5, 'd'], [5, 'e']]
上面的代码能够创建一个复杂的列表。推导不仅限于列表;还有字典和集合的推导。对于列表,我们使用方括号进行推导。对于集合和字典推导,我们需要使用大括号{}
。然而,这些推导的模式对所有这些推导都是相似的。让我们看一个例子:
dict_comp = {x:chr(65+x) for x in range(1, 6)}
print(dict_comp)
{1: 'B', 2: 'C', 3: 'D', 4: 'E', 5: 'F'}
上面的代码表示了字典推导的用法。模式与列表推导类似,只是我们使用大括号进行推导。字典推导的结果将是一个字典。同样,在集合推导的情况下,推导的结果将是一个集合。这在下面的代码中显示:
set_comp = {x ** 2 for x in range(5) if x % 2 == 0}
type(set_comp)
print(set_comp)
#output
<class 'set'>
{0, 16, 4}
在结束本节之前,我们必须介绍 Python 的两个强大的内置函数,它们可以比以往更快地操作集合的数据。如果你曾经有机会学习大数据,你应该听说过这两个词:zip 和 map。Python 提供了这两个函数,以便在最小的负载和更快的计算下处理大量数据。让我们看一个简单的例子来理解 zip 和 map 的概念。假设我们有两个包含有限整数的列表。现在,你必须编写一个程序来创建一个新的列表,该列表将存储每个列表中的最小数字。将对具有相同索引的元素进行比较:
Input: a = [2,3,4,5,6,7] and b = [0,3,2,1,3,4]
Output: [0, 3, 2, 1, 3, 4]
最简单和常规的方法如下所示:
a = [2,3,4,5,6,7]
b = [0,3,2,1,3,4]
result = []
length = len(a)
for i in range(length):
result.append(min(a[i],b[i]))
print(result)
#output
[0, 3, 2, 1, 3, 4]
现在,让我们学习另一种执行上述计算的方法。这是使用zip
和map
函数制作的单行代码。zip
函数是一个简单的 Python 内置函数,它接受两个相等长度的对象并将它们合并在一起。如果你将两个相等长度的列表传递给zip
函数,它将把它们合并成一个,以便在单个对象内执行计算。这在下面的代码中显示:
>>> numbers = [1,2,3]
>>> letters = ['a','b','c']
>>> list(zip(numbers,letters))
[(1, 'a'), (2, 'b'), (3, 'c')]
我们知道应该进行数字之间的比较,因为它们具有相同的索引。因此,我们可以使用zip
函数将原始数字数组与zip
函数结合起来,以便我们可以将数字的元组存储在单个列表中,就像这样:
>>> list(zip(a,b))
[(2, 0), (3, 3), (4, 2), (5, 1), (6, 3), (7, 4)]
映射函数
编程的主要任务是执行计算。对元素进行的操作可以独立于彼此进行;也就是说,我们可以分别对列表 a 和 b 进行比较,就像我们在前面的代码中所做的那样,或者简单地将它们合并,以便可以更快地进行比较。zip
方法能够将长度相同的两个对象合并为一个新的可迭代对象。现在,主要任务是创建一个比较操作,并将其应用于可迭代对象的每个元素,这是通过使用map
函数来完成的。map
函数采用某个函数,并将其应用于可迭代对象的每个元素。
根据 Python 的官方文档,可以描述map
如下:
Map 将函数应用于可迭代对象的每个项目,并返回结果列表。如果传递了额外的可迭代参数,则函数必须采用相同数量的参数,并且并行应用于所有可迭代对象的项目。如果一个可迭代对象比另一个短,则假定它将被扩展为 None 项目。如果函数为 None,则假定为恒等函数;如果有多个参数,map()
返回一个由包含所有可迭代对象对应项目的元组的列表(一种转置操作)。可迭代参数可以是序列或任何可迭代对象;结果始终是列表。
调用 map 函数时传递的参数是一个函数,后面跟着可迭代对象。通常,我们使用匿名或 lambda 函数,例如some_function
,它接受一些位置参数并将它们作为元组返回。这在以下代码中显示:
map(some_function, some_iterables)
让我们创建一个简单的示例来说明map
函数的用法:
>>> map(lambda x: x*2, (1,2,3,4))
<map object at 0x057E9AF0>
前面的代码并不成功,因为map
函数并不返回任何可迭代对象。相反,它打印表示地图对象的字符串。为了实现期望的结果,我们必须使用列表构造函数包装map
方法,就像这样:
>>> list(map(lambda x: x*2, (1,2,3,4)))
[2, 4, 6, 8]
现在,我们将使用map
和zip
函数的概念来找到两个列表中最小元素的列表。以下代码非常简单;我们首先定义了两个数组。之后,我们使用map
函数,它将采用包含比较操作的lambda
函数和zip
方法,并将两个数组合并为元组列表。zip
方法生成的每对元组都会传递给比较的lambda
函数:
>>> a = [2,3,4,5,6,7]
>>> b = [0,3,2,1,3,4]
>>> list(map(lambda pair: min(pair), zip(a,b)))
[0, 3, 2, 1, 3, 4]
借助map
和zip
的功能,我们可以做任何事情,类似于列表推导。通过使用列表推导、map
函数和 for 循环完成的前面的程序,我们可以看到以下运行时性能:
For Loop: 4.56s
List comprehension: 2.345s
Map: 2..11s
因此,Python 的这三个特性主要使集合的操作比任何其他操作都更快。但就代码的可维护性和可读性而言,列表推导在提供有效定制程序内部工作方式的方法方面名列前茅。现在,是时候了解 Python 的另一个特性,即装饰器。这使我们能够修改现有对象的功能,而不修改其当前结构。
装饰器
装饰器是一种设计模式,它在不改变原始结构的情况下为现有对象添加新功能。我们必须习惯于 Python 中的一切都是对象,甚至函数也是对象。用于定义这些对象的不同名称只是它们的标识符。让我们运行以下代码:
def fun1(info):
print(info)
fun1("Good Morning")
fun2 = fun1
fun2("Good Morning")
当我们运行上面的代码时,fun1
和fun2
函数打印出相同的输出"Good Morning"
,因为两者都指向相同的对象(函数)。因此,函数只是带有属性的对象。让我们回到装饰器。基本上,装饰器是一个构造,其中程序的一部分试图在编译时改变程序的另一部分的行为。对于函数来说,装饰器接受一个函数,为其添加独特的功能,最终返回它,如下所示:
def decorate_it(func):
def inner():
print("Decorated")
func()
return inner
def non_Decorated():
print("Not-Decorated")
现在,让我们尝试从 Python shell 中运行上面的代码:
>>> non_Decorated()
Not-Decorated
#now try to decorate the above function
>>> decorate = decorate_it(non_Decorated)
>>> decorate()
Decorated
Not-Decorated
在上面的例子中,decorate_it()
是一个装饰器,它以未装饰的函数作为参数。decorate = decorate_it(non_Decorated)
语句是一个赋值语句,其中Non_Decorated
函数被传递给装饰器,它返回了名为 decorate 的函数。因此,我们可以得出结论,装饰器是可调用的,返回一个可调用的。在上面的例子中,我们可以看到decorate_it()
装饰器为non_Decorated
或普通函数添加了一些功能。当装饰器开始变得有名气时,引入的设计模式是首先装饰函数,然后返回第二个可调用对象的名称,就像我们在这个例子中所做的那样。然而,程序员们发现这项工作是多余的。因此,他们开发了另一种语法,简化了前面的结构:使用@
符号。
要装饰一个普通函数,我们使用@
符号,加上装饰器的名称,并将其放在未装饰的函数的顶部,如下所示:
@decorate_it
def non_Decorated():
print("Not-Decorated")
上面的代码是下面的代码的辅助,我们之前写过:
def non_Decorated():
print("Not-Decorated")
decorate = decorate_it(non_Decorated)
让我们看另一个例子。我们想制作一个装饰器,它就像一个异常处理程序,当程序遇到异常活动时抛出错误消息。上面的装饰器很简单,因为它不关心传递给内部函数的参数。现在,我们将制作一个程序,它将乘以任意两个数字,但也处理错误,如果传递了其他数据,比如字符串或复数:
def multiply(a,b):
print(a*b)
>>> multiply(2,5)
10
>>> multiply('c', 'f')
TypeError: can't multiply sequence by non-int of type 'str'
现在,我们将尝试制作一个装饰器,它将检查我们是否得到了异常,就像在上面的代码中一样,并自动处理它:
def smart_multiply(func):
def inner(a,b):
if (a.isdigit() and b.isdigit()):
a = int(a)
b = int(b)
print("multiplying",a," with ",b)
return func(a,b)
else:
print("Whoops!! Not valid multiplication")
return
return inner
@smart_multiply
def multiply(a,b):
print(a*b)
a = input("value of a: ")
b = input("value of b: ")
multiply(a,b)
一旦你运行了上面的代码,你将被要求在 Python Shell 中输入条目。你必须为a
和b
输入两个实体,然后代码就会完成剩下的工作:
value of a: 4
value of b: 5
multiplying 4 with 5
20
让我们再次运行上面的代码。这一次,我们将把a
和b
的值输入为字符串:
value of a: t
value of b: y
Whoops!! Not valid multiplication
正如你所看到的,装饰器的inner
函数具有与未装饰函数传入的参数相同的数量。因此,可以使用inner(*args, **kwargs)
进行泛化,其中args
是位置参数的元组,kwargs
表示关键字参数的字典。现在,我们可以制作能够处理任意数量参数的装饰器,如下所示:
def universal(func):
def inner(*args, **kwargs):
print("It works for any function")
return func(*args,**kwargs)
return inner
因此,在编译时,装饰器修改了原始函数、方法甚至类的操作,而不改变被装饰对象的代码。这最终导致了不要重复自己(DRY)技术的使用。在下一节中,我们将学习@property
装饰器 - Python 的内置装饰器,用于实现property()
函数。正如你可能还记得上一章所述,@property
的这种构造已经被使用,并且它被定义为实现 getter 和 setter 的 Pythonic 方式。现在,我们将详细了解它。
Python 属性
要理解首先使用属性的用法,我们必须回顾面向对象范式的一个原则:数据封装。这将数据与方法捆绑为一个单一的胶囊。将要获取和设置类的属性的方法称为 getter 和 setter。面向对象编程的这一原则暗示了类的属性必须私有化,以防止意外修改或盗窃。让我们从一个简单的例子开始:
class Speed:
def __init__(self, speed = 0):
self.speed = speed
def change_to_mile(self):
return (self.speed*0.6213,"miles")
在上述代码中,我们创建了一个名为Speed
的类,用于存储车辆的速度(以公里为单位)。它有一个members
方法,用于将公里转换为英里。现在,我们可以创建Speed
类的对象,并随意操纵这个类的成员。我们将使用 Python Shell 进行操作,如下所示:
>>> car = Speed()
>>> car.speed = 45
>>> car.speed
45
>>> car.change_to_mile()
(27.958499999999997, ' miles')
每当对类的属性进行赋值时,Python 解释器都会维护一个字典,其中属性及其值被维护为键和值。在Speed
类的情况下,我们可以使用__dict__ 属性
检索对象的任何属性,即speed
:
>>> car.__dict__ {'speed': 45}
因此,每当我们执行car.speed
操作时,Python 解释器会在上述字典中进行搜索,并将值提取为car.__dict__['speed']
。
现在,假设上述代码在交通控制领域全球范围内变得流行。有一天,交通警察提出应该对车辆的速度进行约束,以便可以执行法律。现在,我们必须修改代码,以便如果任何驾驶员驾驶速度过快,程序会向他们提供警告消息。我们可以使用 getter 和 setter 来实现这一点。在setter
方法内部,我们可以使用条件语句明确检查车辆的最高速度。可以这样做:
class Speed:
def __init__(self, speed = 0):
self.set_speed(speed)
def change_to_mile(self):
return (self.get_speed*0.6213," miles")
#new updates are made as follows using getter and setter
def get_speed(self):
return self._speed
def set_speed(self, km):
if km > 50:
raise ValueError("You are liable to speeding ticket")
self._speed = km
在上述代码中,进行了两个重大修改,我们对它们很熟悉。它们是getter: get_speed
方法和setter: set_speed
方法。在代码中进行的另一个更改是属性的签名。速度属性以单下划线开头,这使其成为私有属性(数据封装)。在 Python Shell 中尝试以下代码:
>>> car = Speed(30)
>>> car.get_speed()
30
>>> car.set_speed(38)
>>> car.get_speed()
38
>>> car.set_speed(70)
ValueError: You are liable to speeding ticket
对原始程序的更新成功地反映了新的限制范围。驾驶员不被允许以超过 50 公里/小时的速度驾驶他们的车辆。
现在,让我们运行上述代码,并观察新更新可能引起的开销。我们可以简单地比较使用 getter 和 setter 编写的代码与不使用它们编写的代码。当您尝试调整原始代码以适应新更改时,将会出现一个主要的头痛问题,因为您必须修改代码,从调用car.speed
对象的属性到调用car.get_speed()
的属性。构造函数必须更改为car.set_speed
(speed)。我们可能会发现在这个程序中进行更改更容易,但是想象一下,如果程序有 10,000 多行代码。对于任何程序员来说,更新和与新代码同步将是一件困难的事情。现在,属性装饰器开始发挥作用。以下代码为我们解决了这个问题:
class Speed:
def __init__(self, speed = 0):
self.speed = speed
def change_to_mile(self):
return (self.speed*0.6213," miles")
@property
def speed(self):
return self._speed
@speed.setter
def speed(self,km):
if km > 50:
raise ValueError("You are liable to speeding ticket")
self._speed = km
由于我们熟悉装饰器,上述构造对我们来说应该是熟悉的。现在,让我们在 Python Shell 中运行我们的代码:
>>> car = Speed(40)
>>> car.speed
40
使用属性构造,我们修改了原始类并提供了一些约束。但是这一次,我们移除了我们所做的更改,比如由 getter 和 setter 添加的get_speed
和set_speed
。因此,交通控制系统可以使用这个新代码,而不需要对原始代码进行任何更改,这导致了向后兼容性。
我们还有另一种实现上述代码的方法,那就是使用property()
函数。以下代码等同于使用@
属性构造编写的上述代码:
class Speed:
def __init__(self, speed = 0):
self.speed = speed
def change_to_mile(self):
return (self.speed*0.6213," miles")
def get_speed(self):
return self._speed
def set_speed(self, km):
if km > 50:
raise ValueError("You are liable to speeding ticket")
self._speed = km
#using property
speed = property(get_speed,set_speed)
前面代码的最后一行创建了一个 speed 属性的对象。请记住,属性必须由可能被更改的属性组成。我们添加了一些代码,创建了属性的对象,并在括号内传递了 getter 和 setter 方法。现在,任何使用 speed 值的程序都会自动调用get_speed
方法,任何分配 speed 值的程序都会调用set_speed
方法,而无需查找由类管理的dictionary(obj.__dict__)
。
现在,让我们利用本章学到的列表理解和属性知识来修改我们的蛇游戏。
使用 LC 和属性完善蛇游戏
这一部分将尽可能保持简洁,因为没有新内容可覆盖。现在我们已经详细学习了列表理解和属性,我们应该能够快速地覆盖这个主题,就像我们在上一章的总结中讨论的那样。简而言之:列表理解是一种从其他可迭代对象创建新元素列表的技术。列表理解语句由包含必须对每个元素进行转换的方括号组成,以及一个 for 循环。然后是一些条件。另一方面,@property
或property()
构造是实现 getter 和 setter 的 Pythonic 方式。
让我们来看看我们可以对蛇游戏进行的一些改进:
- 首先,我们可以创建一个函数,检查蛇与边界或自身的碰撞。例如,如果蛇头的坐标(x,y)与其身体的坐标相同,我们就有了碰撞。这个逻辑可以用列表理解来实现:
[body.coor == self.head.coor for body in self.body_list[:-1]]
。以下表达式将在结果列表中存储一个 True 或 False 的布尔值。对于蛇身的每个位置,都会进行body.coor == self.head.coor
的比较。以下代码行表示一个函数,根据碰撞检查返回 True 或 False:
def collided(self):
return any([body.coor == self.head.coor
for body in self.body_list[:-1]])
- 其次,我们可以用
@property
构造装饰前面的方法。由于我们已经详细介绍过,这不应该对我们造成任何困惑。如果有的话,让我来解释一下。@property
的主要用途是支持向后兼容。我们可以修改类的规范并实现约束,而不实际修改分发给客户的先前版本的代码。同样,我们可以用@property
装饰一个得分函数,因为我们需要更新它的时间值。因此,为了不断地将得分方法作为属性访问,我们可以像这样添加我们之前装饰的属性:
@property
def score(self):
return 'Score : {0}'.format(self.score)
属性和列表理解的前面实现都是使代码更易读和易维护的简单有效的方式。在企业级 Python 编程中,我们经常会发现这些类型的构造。
总结
本章揭示了理解和生成的高级概念,接着是一些示例及其在现实世界中的应用。我们看到了理解的用法和 Python 的一些内置函数,比如 map 和 zip,它们超越了 for 循环的性能。虽然这些理解和映射的概念可能被高估了,但如果我们有大量代码需要考虑性能而不是代码可读性,通常会发现它们很有帮助。本章还探讨了装饰器,它为现有代码添加了一些额外功能,而不影响其原始内容。然后,我们学习了属性装饰器的概念,这是一种 Pythonic 的实现方式,可以实现 getter 和 setter,同时保持向后兼容的代码。
从下一章开始,我们的主要目标可能会倾向于游戏编程。我们已经成功学习了 Python 的基本知识,以便成为熟练的游戏程序员。现在,我们将学习关于图形用户界面以及使用 Python 提供的模块(如 turtle 和 pygame)制作图形界面的方法。但在我们跳到下一章之前,请确保你已经正确地使用我们迄今为止编写的代码。对于任何程序员来说,能够逐行阅读代码是非常重要的。如果你已经对自己的技能有足够的信心,那么可以继续到下一章,我们将学习 turtle 模块,这是在游戏屏幕上绘制形状的基本方法。
第八章:海龟类 - 在屏幕上绘制
不久以前,程序员,尤其是游戏程序员,在构建程序时会面临许多复杂性。难怪!那时,互联网门户的帮助还不够,包括没有堆栈溢出,更重要的是,程序员没有可以使用的通用工具;他们必须首先创建一个工具,然后在程序中使用它。他们创建的工具将处理一些游戏特定的内容(用于声音和图形的特定驱动程序)。由于资源稀缺,程序员不得不使用汇编语言创建游戏,这将是处理能力、显示、声音和控制例程的权衡。甚至在调试时也会遇到最糟糕的情况。他们需要复杂昂贵的机器来复制他们的程序,他们还需要日志记录和调试扩展。本章的主要目标是让我们熟悉使用海龟进行二维(2D)空间绘图,以及海龟的事件处理方法,并创建简单的 2D 空闲动画。
在撰写本文时,我们在游戏行业取得了巨大进步。我们已经创建了工具,使我们能够使用任何编程语言制作游戏,例如 Python 和 C(对 CPU 要求低的游戏)。由于设备驱动程序的通信,所有低级例程都被高级软件隐藏起来。Python 等高级语言是抽象的;它们提供较少的访问权限以获取低级功能。我们可以将多个东西组合在一起作为类,这些类可以从另一个类继承特性,从而消除了代码的重复。Python 提供了海龟和 Pygame 等模块,其中包含了大量用于设计游戏角色和处理用户事件的方法。在本章中,我们将学习有关海龟模块的知识。从本章开始构建的每个东西都将使用前面章节的技术,同时还会添加一些显著的特性。
本章将涵盖以下主题:
-
海龟概述
-
技术要求
-
海龟命令简介
-
海龟事件
-
使用海龟绘制形状
技术要求
本节将带您了解基本的 Python 图形编程模块及其工作原理。因此,您需要以下资源:
-
Python 3.5 或更高版本;参见第一章,了解 Python - 设置 Python 和编辑器
-
Python IDLE
-
一个文本编辑器
-
一个键盘
-
一个鼠标(笔记本电脑的触摸板无法使用)
本章的文件可以在此处找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter08
观看以下视频以查看代码的运行情况:
了解海龟模块
就像计算机的不同组件同样重要以提供更好的计算体验一样,我们也需要计算机的不同组件共同工作,以提供更好的游戏体验。计算机的显卡负责计算屏幕的视觉图像,然后在发送到显示器之前对图像信号进行模块化。输入设备如鼠标、键盘和游戏手柄需要根据程序处理用户事件。音频卡需要处理音频信号,然后将其发送到扬声器等输出设备。在游戏编程的早期阶段,程序员需要分别阅读每个设备的技术手册,并在隔离状态下编写每个设备的代码。这意味着即使是简单的游戏,它们之间的通信也需要花费一年的时间。然而,随着技术的进步,特别是驱动程序的进步,程序员免除了手动处理这些设备与操作系统之间的通信的烦恼。
尽管我们开发了一个称为驱动程序的简单程序,它作为与不同设备通信的通用接口,但不同的硬件和版本不兼容性使得程序员在开发可以在多个平台上玩的游戏时更加困难。幸运的是,我们有 Python,一种具有使程序可以跨平台的能力的语言。Turtle 是 Python 模块,提供了可以用来创建图片和图形的绘图板。据说海龟模块是上世纪 90 年代另一种流行编程语言Logo的姐妹模块,Logo有一个想象中的海龟图标和一个用于在屏幕上绘制的笔。Python 的标准库turtle类似于 Logo 编程语言。为了使用海龟模块,我们必须导入它。导入它更容易,因为它作为标准 Python 库打包,不需要手动安装。以下步骤解释了如何制作任何海龟应用程序:
-
使用
import
命令导入海龟模块。如果忽略这一步,就不会有控制海龟的界面。 -
创建一个控制海龟。这一步用于实例化海龟,以创建一个新的海龟控制器,例如,
game = turtle.Turtle()
。 -
创建控制后,我们可以通过调用海龟模块的方法在绘图屏幕上绘制和执行多个任务。
-
我们需要显式调用一个重要的方法,它持有游戏屏幕,即
turtle.done()
。这个方法会暂停程序。您需要手动关闭窗口以关闭应用程序。
在海龟包中,当我们运行通过调用海龟模块的方法制作的程序时,将会出现一个新窗口,带有一支笔,以及由海龟命令绘制的形状。让我们了解一些重要的海龟命令。
介绍海龟命令
海龟模块带有多个命令,以方法的形式独立使用。有一些方法可以使笔向前和向后移动,还有一些可以创建形状。查看下表,了解最重要的海龟命令。您可以在官方 Python 文档页面上详细了解它们:
方法 | 参数 | 描述 |
---|---|---|
Turtle() | 无 | 创建并返回一个新的海龟对象。 |
forward() | 距离 | 将海龟向前移动指定的距离。 |
backward() | 距离 | 将海龟向后移动指定的距离。 |
right() | 角度 | 将海龟顺时针旋转。 |
left() | 角度 | 将海龟逆时针旋转。 |
penup() | 无 | 抬起海龟的笔。 |
pendown() | 无 | 放下海龟的笔。 |
up() | 无 | 抬起海龟的笔。 |
down() | None | 放下海龟的笔。 |
color() | 颜色名称 | 更改海龟笔的颜色。 |
fillcolor() | 颜色名称 | 更改海龟用于填充多边形的颜色。 |
heading() | None | 返回当前的方向。 |
position() | None | 返回当前位置。 |
goto() | x, y (位置) | 将海龟移动到位置 x, y。 |
begin_fill() | None | 记住填充多边形的起点。 |
end_fill() | None | 关闭多边形并用当前填充颜色填充它。 |
dot() | None | 在当前位置留下一个点。 |
stamp() | None | 在当前位置留下一个海龟形状的印记。 |
shape() | 形状名称 | 应该是 arrow, classic, turtle, 或 circle。 |
在上面的表格中,我们可以通过观察方法名称的字面意义来猜测调用这些方法的结果。例如,forward(amount)
方法将以指定的参数作为参数向前移动笔。所有这些方法都用于在海龟的绘图画布中绘制不同的形状。观察第一个 >>> Turtle()
方法。这将返回海龟的对象,必须使用该对象来调用这些方法。例如,我们将编写一个程序,该程序将在屏幕上绘制一条线。以下是此示例的代码:
import turtle
pacman = turtle.Turtle()
pacman.forward(100)
turtle.done()
通过运行上面的代码,我们可以观察到以下输出:
连同 Python shell 一起,新屏幕应该像之前的那个一样弹出,这代表了海龟绘图板。最初,附着在虚拟海龟上的笔将驻留在绘图板的中心。海龟对象的任何方法调用都必须操纵笔的移动。上面的代码可以通过以下步骤来解释:
-
首先,我们必须导入 turtle,这是一个第一步,将确保海龟类中的所有命令对我们可用。
-
第二步是创建一个海龟控制器,我们称之为吃豆人。
-
然后,我们从吃豆人面对的点向前移动 100 像素。最初,吃豆人 海龟控制器是朝右的;因此,笔从中心向右移动 100 像素,形成了一条直线。
-
最后,
turtle.done()
将暂停海龟绘图板屏幕,以便我们可以清楚地观察输出。为了关闭海龟屏幕,我们必须手动关闭 Python shell 或海龟图形屏幕。
我们刚刚学会了如何创建一条直线,但是这些线看起来很无聊,对程序没有任何美感。现在是学习如何使用另一个方法的时候了,这个方法将转动笔到另一个方向。例如,我们可能想要将笔的方向从最初的方向改变到另一个方向:
import turtle
pacman = turtle.Turtle()
pacman.forward(50)
pacman.right(90)
pacman.forward(50)
pacman.right(90)
pacman.forward(50)
pacman.right(90)
pacman.forward(50)
pacman.right(90)
turtle.done()
我们已经熟悉了forward
方法,现在我们引入了right()
方法。如果你看一下之前的方法表,你会发现right
方法和角度作为参数传递了进去。因此,这个方法将执行一些旋转,并伴随着传递进去的角度。由于我们传递了 90 度,这个方法将进行一个 90 度的顺时针旋转。如果你想要将笔逆时针旋转,我们需要调用 left 方法并指定旋转的角度。在前面的程序中,我们将它旋转了 90 度。所有角度都是 90 度的几何形状要么是正方形,要么是长方形。然而,我们知道forward
方法会产生一条直线,这与几何形状的边是一样的。由forward
方法创建的边的长度是相等的,为 50,这作为forward
方法的参数传递进去。有了这些证据,我们可以肯定地期望在乌龟画板上画出一个正方形形状。让我们运行前面的代码来观察输出。正如预期的那样,画出了正方形形状:
仔细看一下前面的代码;你看到了一些代码的重复吗?显然,forward
和left
方法的调用被多次执行,这最终违反了 DRY 原则。这种顿悟并非没有练习 Python 范式而来。因此,我们可以说练习是区分好坏程序员的关键。现在,回想一下我们需要什么来消除代码的冗余;我们应该使用循环或函数。我们将在这里使用一个循环:
import turtle
pacman = turtle.Turtle()
for i in range(4):
pacman.forward(50)
pacman.right(90)
turtle.done()
我猜我们在阅读和理解这段代码时不会遇到任何问题。正如我们在第三章中提到的,流程控制 - 为你的游戏构建决策者,我们可以使用一系列函数创建迭代级别。由于我们需要运行这些方法四次,我们使用了 range 函数创建了四次迭代。任何需要重复的内容都会在 for 循环的范围内缩进四个块。
在这个例子中需要注意的一点是,我们有多个处理画板上笔的移动的方法。到目前为止我们学到的两个乌龟命令是forward(amount)
,它将乌龟向它所面对的方向前进一定距离,以及right(degree)
,它使乌龟顺时针旋转指定的角度。请注意,right
和left
命令不会在屏幕上写任何东西;它们只用于旋转。
根据我们迄今为止学到的一切模式,我们可以预测backward
方法将会将笔从原来的方向向后移动指定的距离。我建议你尝试稍微修改前面的代码 - 通过使用backward
重构forward
方法,通过使用left
重构right
- 并相应地观察结果。我想在这里花点时间总结这个话题,而不涉及其他函数,因为我们将在接下来的章节中制作游戏时逐个学习它们。我们将制作多个游戏,比如蛇游戏、乒乓球游戏和使用乌龟模块的 Flappy Bird。现在,我们将探索如何连接输入设备,比如鼠标和键盘,到我们的游戏中,以便玩家可以与乌龟环境进行交互。
探索乌龟事件
正如我们在前面的章节中提到的,处理用户事件是创建任何游戏的主要构建块之一。事件代表了在游戏过程中任何时候需要执行的动作。你是否曾经想过程序是如何在低层次处理事件的?当用户使用键盘或鼠标执行任何事件时,该请求被存储在一个类似队列的结构中。队列结构很重要,因为处理这些事件的顺序必须是先来先服务的。然后,根据用户操作的行为,程序处理事件。这两个任务——渲染和动作处理——由程序独立执行。例如,在反恐精英游戏中,用户可以从枪中射击,即使周围没有敌人。这里,事件是用户按键开枪,渲染任务是在玩家周围生成敌人。除非我们编写程序来执行这两个任务,否则这两个任务不会独立执行。在本节中,我们将学习如何将用户动作作为输入,并相应处理。处理用户动作意味着服务存储在队列结构中的动作。
大多数事件都是基于鼠标或键盘的使用,但有些事件必须由程序自动预测并相应处理,比如ontimer(fun, time)
方法。这个方法接受两个参数:函数和毫秒时间。这个方法设置一个定时器,在time
毫秒后调用fun
函数。让我们做一个简单的程序来理解这一点:
import turtle
star = turtle.Turtle()
exit = False
def main():
if not exit:
for i in range(100):
star.forward(i)
star.right(144)
main()
turtle.mainloop()
代码的最后一行(turtle.mainloop()
)只是执行了在循环中执行的相同操作。直到用户明确退出窗口屏幕,对main
函数的调用才会终止。当程序有一个用于监听传入连接的 while 循环时,它的重要性就会显现出来,但我们不希望计算机一直专注于这一情况:
def draw_objects():
#statements
draw_objects() #may be you want to call it within the time interval
of 100ms
draw_objects()
turtle.mainloop()
前面的代码与 while 循环的工作方式完全相同,但现在 Python 解析器不再专门执行一个任务。相反,每 100 毫秒,draw_objects()
任务将被执行,而剩下的 99.99 毫秒,Python 解析器可以自由执行任何其他任务。
有趣的是,前面的代码代表了任何 turtle 程序的正确结果。虽然调用不同的函数会在屏幕上显示不同的字符,但使用 turtle 的主要目的是将游戏角色渲染到屏幕上。让我们将前面的代码分解成以下几点:
-
前几步代表着导入 turtle 并创建一个 turtle 控制器,这将允许我们通过它调用所有的
turtle
方法。 -
我们创建了一个
main
函数,在其中,我们有一些代码来创建一个星形图案。迭代次数是 100 次,这意味着我们将在输出屏幕上打印 100 颗星星,但请记住,它们会很接近。
在屏幕上正确渲染字符的最佳方法是使用ontimer
方法。让我们用ontimer
方法修改相同的程序。让我们看看如何在程序中使用它:
import turtle
star = turtle.Turtle()
exit = False
def main():
if not exit:
star.forward(50)
star.right(144)
turtle.ontimer(main,500)
main()
与以前不同,前面的程序不会打印多个星星;相反,它打印一个单一的星星。然而,ontimer
方法消除了调用 for 循环的开销,因为它设置了定时器来一遍又一遍地调用相同的函数。在这个程序中,我们传递了main
函数和 500 作为参数,这意味着main
函数应该在每 500 毫秒调用一次。运行前面的程序将产生以下输出:
现在是时候学习如何处理键盘和鼠标事件了。和往常一样,已经定义了用于处理键盘事件的方法和用于处理鼠标事件的方法。但是,在处理用户事件之前,乌龟必须启动一个监听器,它会持续保持清醒状态以监听任何事件。使用listen
方法创建这样一个监听器控制器,即>>> turtle.listen()
。以下表格描述了用于处理键盘事件的方法:
方法名称 | 参数 | 描述 |
---|---|---|
turtle.onkeypress(function, key = None) | Function:没有参数或None 的函数。Key:以字符串或符号形式的键,例如,q 或space 。 | 用于将函数绑定到键盘上按下的任何键事件。如果未指定键,则任何键都可以使用。 |
turtle.onkeyrelease(function, key) | Function:没有参数或None 的函数。Key:以字符串形式的键,如a ,或符号,如enter 。 | 用于将函数绑定到键释放事件。如果函数为None ,则解除事件的绑定。 |
让我们编写一个简单的程序,以便掌握使用这些处理键盘操作的方法的思想:
import turtle
star = turtle.Turtle()
def main():
for i in range(30):
star.forward(100)
star.right(144)
turtle.onkeypress(main,"space")
turtle.listen()
turtle.mainloop()
让我们运行程序并观察输出。按下F5后,你会看到两个屏幕,其中一个会有乌龟图形板和笔位于其中心。现在,按下键盘上的Spacebar键。一旦按下,它就开始在屏幕上绘制一个星星。
在main
函数内部,我们添加了一些代码来制作一个星星。然而,正如你所看到的,main
函数并没有被显式调用,就像我们通常调用函数一样;相反,它是使用onkeypress
方法调用的。这个方法将键绑定到函数,每当按键时,函数就会自动调用。如果从前面的代码中删除最后一行,监听控制器就不会起作用。listen
方法用于创建一个控制器,不断监听这些类型的操作。
以类似的方式,我们可以调用onkeyrelease
方法。在前面的代码中用onkeyrelease
替换onkeypress
,并观察输出。输出将是相同的。onkeyrelease
方法用于将函数绑定到按键释放事件。
同样,处理鼠标事件的方式也并不太不同——它们也是通过方法调用来处理的。以下表格描述了可以用来处理鼠标事件的方法:
方法 | 参数 | 描述 |
---|---|---|
onclick(function, button = 1, add = None) | Function:使用两个参数(x, y)调用一个函数,表示鼠标或指针点击位置的坐标。Button:表示鼠标按钮,默认 = 1 ,表示左键。Add:用于添加多个绑定。如果传递True ,将添加新的绑定,否则将保持当前绑定。 | 将函数绑定到鼠标点击事件。如果用户点击乌龟画布的任何位置,将使用点击位置的坐标调用函数。 |
onrelease(function, button = 1, add = None) | Function:使用两个参数(x, y)调用一个函数,表示乌龟绘图板上点击位置的坐标。Button:默认 = 1 表示使用左鼠标按钮。用于添加鼠标按钮的数字。Add:根据True 或False 的值,决定是否添加新的绑定。 | 将函数绑定到鼠标按钮释放事件。 |
ondrag(function, button = 1, add = None) | Function:带有两个参数的函数,表示点击点的坐标进入游戏屏幕。Button:添加一个数字以指示鼠标按钮监听器。 | 将函数绑定到当前海龟控制器上的鼠标移动事件。如果函数为None ,则将删除当前绑定。 |
让我们制作一个简单的程序来理解如何使用前面的方法处理鼠标事件:
import turtle
pacman = turtle.Turtle()
def move(x,y):
pacman.forward(180)
print(x,y)
turtle.onclick(move) #calling move method
#turtle.onclick(None) #to remove event-binding
您可以看到onclick
方法只调用了移动函数,然后移动方法使用代表点击点的x和y坐标在画布上。运行上述程序在屏幕上不会绘制任何线条,直到您点击绘图画布。当您点击屏幕上的任何点时,您将在 Python shell 中看到其坐标,并且直线将出现在画布上。我们将在接下来的章节中介绍剩余的turtle
方法,以及如何制作一些小游戏。在那之前,我们将尝试使用turtle
模块和迄今为止学到的 Python 设计模式来制作一些形状。
使用海龟绘制形状
制作形状的过程对人类来说可能看起来是一项乏味和繁琐的任务,但对计算机来说并非如此。想象一下在考虑角度和边的情况下制作具有精确几何测量的六边形。这个过程本身就会让我们大多数人感到不知所措。另一方面,计算机被认为是勤奋工作的;我们可以向它抛出尽可能多的任务,它会优雅地执行它们。
正如我们之前提到的,绘制任何形状时两个关键信息是每条边的角度和长度。我们可以创建变量来存储它们,以便在程序中需要时引用它们。对于任何形状,边的数量都会不同。例如,三角形有三条边,而六边形有六条边。我们需要在程序中明确指定边的数量。在本节中,我们将制作两种形状,一个六边形和一个星形,还加上一些颜色。本节的主要目的是帮助您了解编程范式是如何使用的,以及特定模块,以制作吸引人的游戏。
以下步骤列表描述了为了逐个创建两个形状所需的路线图。我们将首先创建的形状是一个六边形:一个有六条边的形状,具有自定义长度。之后,我们将再次制作星形图案,但这次我们将为其添加颜色属性:
- 六边形:我们将通过定义特定变量来创建这个形状,比如边的数量、内角和边的长度。之后,我们将使用 for 循环创建六次迭代,因为我们必须调用线渲染方法六次(因为六边形有六条边)。我们将使用
forward
方法绘制一条直线,使用right
方法将海龟顺时针旋转特定角度:
import turtle
hexagon = turtle.Turtle()
num_of_sides = 6
length_of_sides = 70
angle = 360.0 / num_of_sides
for i in range(num_of_sides):
hexagon.forward(length_of_sides)
hexagon.right(angle)
turtle.done()
-
您可以看到使用
turtle
模块在画布上绘制形状是多么方便。我们已经熟悉了这些方法以及使用循环来消除多行代码的重复;因此,理解我们在这里编写的代码不会很难。 -
星形:使用 Turtle 制作星形比使用任何其他模块更容易。我们已经使用了
turtle
的两种方法来制作它,即forward
和left
。但在本节中,我们将使用turtle
模块提供的color
方法为星形上色。我们将首先定义颜色调色板,即不同的颜色名称,并且我们将调用begin_fill
和begin_end
方法,这将为形状添加颜色。以下表格显示了可以用于给海龟着色的三种方法:
方法 | 参数 | 描述 |
---|---|---|
color(*args) | Args 代表颜色的名称。当前颜色用于使用forward 或backward 方法绘制线条。颜色名称可以作为单个值:color(“blue”) ,双值:color(“black”,”green”) 或rgb 浮点值给出。 | 用于改变乌龟笔的颜色。 |
begin_fill() | 无 | 这个方法将记住填充多边形的起始点。 |
end_fill() | 无 | 它将关闭在乌龟画布中绘制的形状并用当前填充颜色填充它。 |
例如,我们将编写一个程序,使用这些方法来给星形图案上色。我们将使用红色和黄色的颜色组合来使星星更具吸引力。我们一直在使用import turtle
命令使turtle
方法可供程序使用。与其这样做,我们可以使用from turtle import *
命令从 turtle 中导入所有内容。现在,我们可以直接调用turtle
方法,即forward(100)
,而不是使用>>> turtle.forward(100)
来调用它。让我们编写一个程序来创建这样一个星形图案:
from turtle import *
color('red', 'yellow')
begin_fill()
while True:
forward(200)
left(170)
if abs(pos()) < 1:
break
end_fill()
done()
我非常喜欢乌龟与 Python 一起工作的方式。能够将每个函数绑定到 Python 的编程范式使得turtle
模块的使用效果很好。在前面的代码中,我们可能不会对第一行代码感到困惑,它只是从turtle
模块中导入了所有内容——每个属性和成员。我们使用color
方法制作了红色和黄色的调色板。在主循环中,我们将遇到两种方法,这些方法我们从本章开始就一直在使用。此外,我们添加了一个条件来指示乌龟笔的停止点。abs()
方法用于返回数字的绝对值,即>>> abs(-4)
,得到 4。在abs()
函数内部,我们调用了turtle
模块的pos()
方法,它将返回乌龟的位置作为一个两元素列表。我们检查了当前位置,如果小于 1,例如 0,那么它必须代表中心位置,因为(0,0)代表中心位置。如果我们在迭代后遇到中心位置,那么这意味着我们可以终止程序,因为在这一点上,我们必须已经画了一个星星。如果我们继续,乌龟笔将在相同的位置上画另一个星星,从而覆盖旧的星星。
因此,为了防止这种连续迭代,我们添加了一个条件行:if abs(pos()) < 1
。
执行上述程序会产生以下输出。在这里你必须记住的一件事是,从调色板开始,我们使用红色笔画星星,完成后,我们使用黄色填充星星形状的内部:
现在您已经了解了使用turtle
方法创建形状并对其上色的方法,我们将在此结束本章。我们将在接下来的章节中使用本章学到的概念,如创建图案和处理用户事件,制作简单的迷你游戏,比如 Snake、Pong 和 Flappy Bird。
总结
Python 的turtle模块是构建 2D 迷你游戏的强大平台。它包含各种方法,以便简化游戏角色的设计过程。我们在本章中编写了许多程序,并处理了用户事件。我们通过介绍turtle
模块的关键特性开始了本章,并为可以使用 Pythonturtle
模块制作的任何游戏构建了一个通用原型。本章教会了我们如何使用turtle
模块来制作 2D 画布动画。除了为游戏角色添加动画,我们还学会了如何通过处理用户事件创建游戏界面和用户控制器之间的通信接口。
完成本章后,你将能够使用turtle
模块创建简单的 2D 游戏。你还将能够处理鼠标和键盘提供的用户操作,这使我们能够制作用户交互式游戏。现在你已经学会了如何使用 2D Turtle 画布创建简单的动画,你可以创建任何几何形状;在进入下一章之前再尝试几个。
本章我们没有涉及任何游戏,因为要使用turtle
模块创建游戏,我们首先需要探索向量——创建向量、存储向量、找到向量的大小、向量相加、否定、对角线移动等等。我们将在下一章中涵盖所有这些概念。
向量的主题无疑是任何游戏开发者工具包中最基本的主题。向量是代表屏幕上出现的游戏角色的大小和方向的数学术语。大小表示角色所在点的当前坐标的模,而方向表示游戏角色移动的方向。现在是你玩弄turtle
模块并掌握处理用户事件以及构建吸引人的形状和角色的完美时机。
第九章:数据模型实现
游戏是一种通过互动来模拟或至少模拟真实世界环境的媒介,玩家通过动作和移动来控制游戏角色。我们知道,玩家可以用键盘、鼠标或操纵杆等输入设备与游戏进行互动的方式有很多种。为了将这些输入信号转化为有意义的信息,我们需要用相应的动作来处理这些信号。在大多数游戏中,我们使用键盘按键来移动游戏角色,但在内部,这些信号是由称为向量的数学对象处理的。这对于任何游戏来说都非常重要,无论图形看起来如何,因为它导致玩家产生动作并用适当的反应来处理它们。
在本章中,我们将介绍二维向量——操纵游戏角色位置的方法。向量坐标(x,y)的变化代表了游戏玩家指定的移动。对于任何编程初学者来说,本章将是改变生活的,因为它将教会我们如何使用数学概念,如加法、减法、乘法、旋转和反射,以及数据模型实现这样的编程范式。本章的最终目标是让您熟悉使用 Python 进行运算符重载的概念,使用 Python 内置方法来操纵向量位置,以及实现数据模型或魔术函数。
本章将涵盖以下主题:
-
运算符重载概述
-
技术要求
-
处理二维向量
-
向量运动的数据模型
技术要求
本章将带领我们体验 Python 简单而强大的运算符重载概念。因此,您需要具备以下工具:
-
Python 3.5 或更新版本
-
Python IDLE(Python 内置的 IDE)
-
文本编辑器
-
网络浏览器
本章的文件可以在这里找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter09
观看以下视频以查看代码的运行情况:
理解运算符重载
这是一个新概念,对于初学者来说可能会有些模糊,但有必要了解这一知识。在编程术语中,用编程语言定义的一切都有特定的用途。例如,我们不能使用sum()
方法来找到元素之间的差异。我们可以扩展任何操作的含义超出其正常用法或预定义的操作用法。以加法(+)运算符为例;这个运算符可以用来添加简单的整数,连接两个独立的字符串,甚至合并两个列表。这是因为加法运算符在不同的类中被重载,也就是说,在字符串和整数类中定义了不同的实现。这就是运算符重载的威力。
还需要记住的一点是,相同的函数或内置运算符对于多个类的对象具有不同的行为,如下例所示:
>>> 6 + 6
12
>>> "Python" + " is " + "best"
'Python is best'
>>> [1,2,3] + [4,5]
[1,2,3,4,5]
有几种方法支持运算符重载;这些被称为数据模型,有时也被称为魔术方法。之所以这样称呼,是因为这些特殊方法扩展了方法的功能,从而为我们的类增添了魔力。这些数据模型不应该由我们调用;而是在类内部自动发生。例如,当我们使用+
运算符执行加法操作时,Python 解析器内部调用__add__()
方法。Python 的不同内置类,如str
、int
、list
等,都有不同的内部定义的魔术函数。我们可以使用dir
函数打印专门用于特定类的魔术函数列表。例如,以下列表指示了str
类中定义的几种方法和属性:
>>> dir(str)
['__add__', '__class__', '__contains__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getnewargs__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mod__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__rmod__', '__rmul__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'capitalize', 'casefold', 'center', 'count', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'format_map', 'index', 'isalnum', 'isalpha', 'isascii', 'isdecimal', 'isdigit', 'isidentifier', 'islower', 'isnumeric', 'isprintable', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']
如前面的str
类的方法和属性列表所示,我们可以观察到几个以双下划线开头和结尾的方法。例如,__add__()
方法用于使用+
运算符连接两个字符串:
>>> first_name = "Ross"
>>> first_name.__add__(" Geller")
'Ross Geller'
在上面的示例中,我们可以看到__add__()
函数的工作方式与+
类似。这些数据模型旨在用于扩展具有重载行为的预定义含义。根据数学规范,我们通常使用+
、-
、/
和*
等运算符与数字对象一起使用。然而,通过重载技术,我们可以使用相同的运算符来处理多个对象,如字符串和列表。我们可以得出结论,加法运算符(+
)被重载。同样,Python 为不同的运算符定义了不同的数据模型,即__sub__()
用于-
运算符,__mul__()
用于*
运算符,__div__()
用于/
运算符。
现在我们已经学会了如何在最基本的形式中使用数据模型来实现 add 函数,我们将实现一些自定义类的示例。
在自定义类中使用数据模型
现在我们知道如何使用__add__()
魔术函数与整数和字符串等各种数据类型一起使用,让我们观察它如何在自定义(用户定义的)Python 类中使用。我们将考虑以下示例来说明数据模型在我们的自定义类中的用法:
class Base:
def __init__(self, first):
self.first = first
def add(self, other):
print(self.first + other)
我们将使用以下代码创建前面类的对象。此代码在 Python shell 中执行:
>>> obj1 = Base(1)
>>> obj2 = Base(2)
>>> obj1.add(obj2)
TypeError: unsupported operand type(s) for +: 'int' and 'Base'
正如预期的那样,我们得到一个错误,说不支持不同类型的操作数,这意味着+
运算符对于添加自定义类的对象是不起作用的。如前所述,为了解决这类问题,我们可以使用运算符重载。我们可以在我们的类中显式定义这些特殊方法,以使对象与内置方法和运算符兼容。例如,在加法操作的情况下,我们必须在类内部显式定义__add__()
方法,看起来像这样:
class Base:
def __init__(self, first):
self.first = first
def __add__(self, other): #operator '+' is overloaded
print(self.first + other.first)
让我们通过创建Base
类的不同对象来检查这是否有效:
>>> obj1 = Base(1)
>>> obj2 = Base(2)
>>> obj1.__add__(obj2)
3
#for strings as add method is defined internally inside str class
>>> obj3 = Base("Hello ")
>>> obj4 = Base("World")
>>> obj3.__add__(obj4)
'Hello World'
因此,魔术函数,或者__add__()
数据模型被重写,成功地在两个整数和两个字符串之间执行了加法操作。我们也可以检查其他数据对象,如列表和元组。现在,我们可以清楚地预测模式;如果我们想要重载任何数学运算符并在我们的自定义类中以不同的方式实现它,我们必须在我们的类中定义数据模型。希望你明白了!现在,我们可以预测__mul__()
模式,以便我们可以在不同对象之间执行乘法,__sub__()
执行减法,等等。
在实际学习使用这些魔术函数的重要性之前,让我们先观察 Python 中另一个强大但不太常用的魔术方法,即__new__()
数据模型。你可以轻松观察这些方法的工作方式;只需删除方法名称周围的下划线和括号,你就会得到new
关键字。如果你有来自 Java 和 C#等高级语言的编程背景,你可能已经理解我的观点。对于那些对new
关键字的概念还不熟悉的人,这个操作符用于创建类的实例。例如,在 Python 中,我们有object = class_name()
,而在 Java 中,我们有object = new class_name()
。
因此,__new__()
魔术方法是在创建类的对象时调用的第一个方法,甚至在调用__init__()
构造函数之前就调用了它,并且是隐式调用的。__new__()
方法负责创建新对象,并返回使用构造函数的__init__()
方法初始化的对象。你还记得,在面向对象的章节中,我们将__init__()
方法称为特殊方法,实际上是一个魔术方法。让我们考虑以下示例来了解__new__()
魔术方法:
class Base:
def __new__(cls):
print("This is __new__() magic method")
obj = object.__new__(cls)
return obj
def __init__(self):
print("This is __init__() magic method")
self.info = "I love Python"
以下代码在 Python shell 中执行。我们正在创建一个Base
类的对象,并观察到在init
方法之前调用了new
方法:
>>> obj = Base()
This is __new__() magic method
This is __init__() magic method
请注意,在上述代码中,我们在定义new
魔术方法时将cls
作为参数传递,并在定义init
构造函数时将self
变量作为参数传递。cls
和self
这两个变量之间的区别在 PEP 8 中有定义,它定义了 Python 代码的样式指南。这种编码风格并不是强制性的,但根据 PEP 8,我们应该始终做到以下几点:
-
始终将
self
用作实例方法的第一个参数。 -
始终将
cls
用作类方法的第一个参数。
我认为我们现在已经足够能够预测任何内置函数的工作内部。让我们以len()
方法为例。如果 Python 中有任何内置的fun()
函数,它对应于__fun__()
。Python 解析器会进行内部调用,如object.__fun__()
,其中对象是类的实例。考虑到这个类比,对于len()
函数,Python 解析器将其调用解释为object.__len__()
,并返回对象的长度。我们已经看到了它的内部工作方式;然而,由于我们想要覆盖的主要主题是如何覆盖它,让我们在我们自定义的类中定义这个魔术方法(类似于前面的例子,我们使用add
魔术函数来添加类的对象)。在__len__()
的情况下,考虑以下示例:
>>> info = "I love Python"
>>> len(info)
13
>>> info.__len__()
13
因此,当我们在自己的类中定义这样的魔术方法或数据模型时,我们覆盖了 Python 原始定义的函数的行为;因此,我们不再调用原始方法。当你用新方法覆盖原始方法时,我们称之为方法覆盖。到目前为止,我们一直在学习数据模型及其在我们自己的类中的使用方式。现在,让我们学习为什么它们在游戏编程中是必不可少的。我们将在下一节中通过探索向量来做到这一点。
处理二维向量
在实际探索向量之前,让我们从运动的基本概述开始,以及如何使角色在直线上移动。要移动任何对象或图像,我们必须对每一帧进行微小的固定量的改变。运动必须对每一帧都是固定的,以使其对称。要使一个对象在水平方向上移动,我们对x位置进行固定量的加法,要使其在垂直方向上移动,我们对y位置加上相同的量。因此,2D 游戏中的运动可以表示为(x,y)。让我们考虑以下例子,以说明如何使用这些坐标来在游戏环境中绘制任何形状:
def line(a, b, x, y):
"Draw line from `(a, b)` to `(x, y)`."
import turtle
turtle.up()
turtle.goto(a, b)
turtle.down()
turtle.goto(x, y)
我们使用了在上一章中使用的turtle
模块,用于使用(a,b)和(x,y)位置绘制线条。goto()
方法用于将笔移动到传递的位置。这些坐标——(x,y)或(a,b)——清楚地显示了知道位置以创建游戏角色的重要性(我们使用线条作为任何游戏角色的比喻)。
我们可以认为直线运动的使用非常有用,但从不同的角度来看,一个只支持垂直或水平运动的游戏可能会显得乏味和无聊。例如,在吃豆人游戏中,玩家只能在垂直或水平方向上移动,这可能是合适的,但在赛车游戏中,用户可以朝任何方向移动,这种运动就不适用了。我们必须能够通过调整每一帧的x和y位置来朝任何方向移动。我们将使用相同的x和y位置来生成直线和对角线运动:一个表示x和y位置速度的比率。表示(x, y)
的形式被称为向量,但更重要的是,向量表示方向,而标量不表示。我们将在下一小节中更详细地探讨向量。
探索向量
正如数学格言所说:
“向量是指任何具有大小和方向的量,特别用于确定空间中一个点相对于另一个点的位置。”
我们完全同意。这个概念源自数学,对于任何游戏程序员来说都是最知名的话题,无论是天真还是老练。向量是任何对象位置的正确表示,附加了方向的关键信息。向量与直线运动的形式相似,以x和y坐标(2D)的形式表示,但它们不仅仅限于提供大小的信息;它们有一个特定的目的。例如,向量(4,5)表示下一个位置,其中 4 被添加到当前位置的x坐标,5 被添加到当前位置的y坐标;类似于这样——(0 + 4,0 + 5)——其中(0,0)是原点或中心位置。让我们用以下例子形象地来研究向量:
在前面的图表中,向量(4,5)具有大小和方向。绿线表示大小,橙线表示方向。因此,一个向量如果没有前面的方向信息就是不完整的。让我们看另一个简单的例子来进一步澄清这一点:
前面的图表已经说明了一切。向量 AB 是目标位置与初始位置的x和y位置的减法。假设一个吃豆人在位置(30,20),他必须到达目标位置(50,45)。向量 AB 是关键信息,表明吃豆人在x方向上还需要移动 20 个单位,在y方向上还需要移动 25 个单位。
众所周知,Python 没有内置的向量数据结构。如果你认为有的话,快速在互联网上搜索一下,你会得到基本的概念。然而,在前面的章节中,我们并没有涵盖向量作为内置数据结构。虽然我们没有向量作为内置数据类型,但我们可以自己制作一个。正如我们所知,向量包括两个不同的位置(x,y),我们的主要目标是使用其他内置数据结构来制作它们。例如,我们可以使用列表来制作向量,但使用索引来表示每个点,如[0]
和[1]
,会增加不必要的开销。元组也是如此。创建向量的最佳方式可能是制作我们自己的向量类。这样做,我们可以引用点为x和y,而不是索引。此外,使用数据模型与向量可以获得最佳的利用。我们可以在向量类中使用__add__()
,__mul__()
和许多其他魔术函数,这将为游戏角色引入运动。例如,我们将创建一个简单的向量类,并使用__str__()
方法,以及一个构造函数,它将提供向量位置的适当表示:
class Vector(object):
def __init__(self, x = 0, y = 0):
self.x = x
self.y = y
def __str__(self):
return "(%s, %s)"%(self.x, self.y)
在前面的程序中,我们创建了一个Vector
类,并在其中定义了两个成员:一个是构造函数,另一个是魔术方法。现在,当我们创建这个类的任何对象时,比如> pos = Vector(10,40)
,init()
方法将执行初始化,这样我们就可以引用向量的每个分量,如>>> pos.x
和>>> pos.y
。__str__()
方法是魔术方法,它被用作覆盖方法,并且在我们的Vector
类中有一个自定义定义,用作向量分量的表示形式,即x和y位置。让我们通过运行以下代码并创建一个Vector
类对象来看看它是如何工作的:
>>> pos = Vector(10, 40)
>>> pos.__str__()
'(10, 40)'
除了__str__()
方法之外,我们还有一堆适用于操作向量的魔术函数。我们可以使用__add__()
执行向量之间的加法,__sub__()
执行减法,__neg__()
执行否定等。我们将在下一节学习这些数据模型以及使用它们修改向量的方法。
用于向量运动建模
正如我们所知,向量是构成大小和方向的量。当根据用户的行动确定游戏角色的下一个位置时,这两个信息非常关键。例如,游戏角色 Steve(一个 Minecraft 角色)可以使用向量来确定他必须使用大小(AB)和方向(→AB)来跟踪他的目标。虽然我们可以逐个更改这些信息源,但我们主要关注大小,因为大小负责在 2D 游戏中提供运动。在本节中,我们将揭示教我们如何添加和减去向量,甚至执行乘法和除法的技术。这些类型的操作将作为逻辑添加到游戏中,以及用户事件,因此每当用户在键盘上按下任何键时,它都会被特定事件处理。在进行这种数学操作时可以使用的技术如下:
-
对已知分量的向量进行操作(减法/加法)
-
通过查找分量执行操作,或者简单地使用头/尾方法
让我们学习如何使用这些技术,跳到下一节,在那里我们将使用魔术函数或数据模型执行向量操作。
向量加法
与数值加法类似,我们可以使用“add()”数据模型重载+
运算符,它将添加两个不同的向量并将其效果组合以产生一个新的单一向量。使用此方法,我们可以使游戏角色进行对角线运动。我们需要两个向量来执行加法;第一个将是游戏角色的当前位置,下一个将是用户在键盘上按下任何键时需要添加的向量的每个分量的预定义固定量。以下图示了向量加法的详细过程:
当您有一个由元组或列表表示的向量时,永远不要使用+
运算符执行向量的加法操作。[1,2] + [3,4]不会像这样添加单个数字:[4,6]。相反,它将两个列表连接成一个,如下所示:[1,2,3,4]。
以下代码使用“iadd()”魔术函数来添加两个向量。iadd
和add
方法的工作方式类似,但它们之间的主要区别是“iadd()”将其结果存储在内存位置中,而“add()”不会。您可以使用其中任何一个来编写以下代码:
def __iadd__(self, other):
if isinstance(other, Vector):
self.x += other.x
self.y += other.y
else:
self.x += other
self.y += other
return "(%s, %s)"%(self.x, self.y)
确保前面的代码包含在先前创建的Vector
类中。 “iadd()”方法接受参数* other ,表示需要添加到其上调用的向量的第二个向量。在魔术函数内部,我们已经制作了条件语句,以检查传递的 other *向量是否是Vector
类的类型。如果是,我们将第一个向量的匹配分量与第二个向量相加,即first.x
到second.x
和first.y
到second.y
,其中 first 和 second 是向量。让我们创建Vector
类的实例并检查向量加法的输出:
>>> a1 = Vector(10,20)
>>> a2 = Vector(30,40)
>>> a1.__iadd__(a2)
'(40, 60)'
现在我们已经成功使用魔术方法来实现向量加法,是时候学习更多的方法来实现向量减法和向量否定了。
向量减法
就像向量的加法意味着游戏角色的前进运动一样,向量的减法暗示着与当前面向相反的方向。我们可以使用“sub()”或“isub()”来实现向量减法。我们通常更喜欢isub
,因为它在返回结果之前存储结果,并且可以完美地用于克隆向量对象,以便我们可以在重复对象中执行不同的操作,而不会损害原始对象。向量减法与加法非常相似;而不是添加向量的每个分量,我们只是要减去它们。这种运动在游戏中非常有用,比如吃豆人,用户必须在不干扰游戏过程的情况下突然改变方向。让我们在Vector
类中编写以下代码,以执行向量减法:
def __isub__(self, other):
if isinstance(other, Vector):
self.x -= other.x
self.y -= other.y
else:
self.x -= other
self.y -= other
return "(%s, %s)"%(self.x, self.y)
让我们在 Python shell 中运行上述代码,以观察向量减法的结果:
>>> a1 = Vector(10,20)
>>> a2 = Vector(30,40)
>>> a1.__isub__(a2)
'(-20, -20)'
向量乘法和除法
乘法和除法等操作将使向量变大和变小。由于乘法而产生的运动变化可能是线性的,当向量乘以任何标量时。例如,当我们将任何向量乘以二时,其大小将是以前的两倍,但方向将保持不变。同样,当我们用负数乘以相同的向量,比如-2 时,它的方向将与最初的方向相反。乘法操作通常用于缩放向量。我们可以按以下方式乘以和除以两个向量:
def __imul__(self, other):
if isinstance(other, Vector):
self.x *= other.x
self.y *= other.y
else:
self.x *= other
self.y *= other
return "(%s, %s)"%(self.x, self.y)
def __itruediv__(self, other):
if isinstance(other, Vector):
self.x /= other.x
self.y /= other.y
else:
self.x /= other
self.y /= other
return "(%s, %s)"%(self.x, self.y)
与向量乘法和除法类似,我们可以使用标量数量进行缩放过程。我们将传递一个数字,而不是第二个向量,作为魔术方法的参数。可以按以下方式完成:
def __mul__(self, scalar):
return (self.x * scalar, self.y * scalar)
def __div__(self, scalar):
return (self.x / scalar, self.y / scalar)
向量否定和相等
由于我们已经涵盖了向量的最重要的操作,如加法、乘法和减法,我们现在将学习简单但重要的向量操作技术,即向量否定和相等。当玩家想要从当前状态到达前一个状态时(因为 AB = -BA),向量否定就变得很重要,这意味着否定任何向量都会创建另一个大小相同但方向相反的向量。为了否定一个向量,我们可以简单地向向量的每个分量添加-
负运算符。例如,我们可以考虑以下代码行:
def __neg__(self):
return (–self.x, –self.y)
我们可以通过检查向量的每个分量来检查两个向量是否相等。例如,first.x
应该与second.x
进行比较,first.y
应该与second.y
进行比较。例如,以下方法将在两个向量相等时返回True
:
def __eq__(self, other):
"""v.__eq__(w) -> v == w
>>> v = vector(1, 2)
>>> w = vector(1, 2)
>>> v == w
True
"""
if isinstance(other, vector):
return self.x == other.x and self.y == other.y
return NotImplemented
根据 Python 官方文档:
("NotImplemented
向运行时发出信号,告诉它应该要求其他人满足操作。在表达式a == b
中,如果a.__eq__(b)
返回 NotImplemented
,那么 Python 会尝试* b.__eq__(a)
。如果 b 知道足够返回* True
或 False
,那么表达式可以成功。如果不知道,那么运行时将退回到内置行为(基于==和!=的身份)")。
总结
在本章中,我们涵盖了各种主题,从数据模型到向量的创建和操作。向量无疑是任何游戏开发者最重要的主题;它们帮助创建游戏角色和精灵的运动,使游戏更具用户互动性。我们学习了不同的操作,如加法、减法、除法、否定等。我们还使用这些操作和魔术方法来操作我们的向量组件。魔术方法是方法重写的一部分,应该在第六章 面向对象编程中介绍。然而,我将它保留到了这一章,因为在探索向量时学习它更有意义。
由于关于向量的数学逻辑是游戏中角色移动的主要基础,您已经学会了如何使用魔术函数实现运算符重载。本章学到的向量操作技能很重要,因为它们指定了对象的位置,并帮助我们执行一些代数运算。
本章向我们介绍了二维向量——一种数学概念,使得游戏中角色的运动成为可能。为了实现这一点,我们必须使用魔术函数的数据重载概念。为了重载任何运算符——即改变任何运算符的实现,比如+
或-
——我们将这些运算符的使用从原始数据类型扩展到复杂数据结构。本章的主要目标是向您介绍使用 Python 编程范式实现 2D 向量运算等数学概念的方法。
在下一章中,我们将利用本章的知识,使用海龟模块进行游戏编程的过山车之旅。我们将制作多个游戏,如贪吃蛇、乒乓球和 Flappy Bird。现在,是时候让您开始尝试向量的实验了;尝试将它们混合在一起,并为向量开发各种运动。
第十章:使用 Turtle 升级蛇游戏
大多数电脑游戏玩家认为游戏因其外观而令人兴奋和吸引人。在某种程度上,这是真的。计算机游戏必须在视觉上具有吸引力,以便玩家感觉自己在其中参与。大多数游戏开发人员和游戏设计师花费大量时间开发游戏图形和动画,以提供更好的体验给玩家。
本章将教您如何使用 Python 的turtle
模块从头开始构建游戏的基本布局。正如我们所知,turtle
模块允许我们制作具有二维(2D)运动的游戏;因此,本章我们将只制作 2D 游戏,如 flappy bird、pong 和 snake。本章将涵盖的概念非常重要,以便将运动与游戏角色的用户操作绑定起来。
通过本章结束时,您将学会通过创建 2D 动画和游戏来实现数据模型。因此,您将学会如何处理游戏逻辑的不同组件,例如定义碰撞、边界、投影和屏幕点击事件。通过学习游戏编程的这些方面,您将能够学会如何使用turtle
模块定义和设计游戏组件。
本章将涵盖以下主题:
-
计算机像素概述
-
使用 Turtle 模块进行简单动画
-
使用 Turtle 升级蛇游戏
-
乒乓球游戏
-
flappy bird 游戏
-
游戏测试和可能的修改
技术要求
您需要以下资源:
-
Python 3.5 或更新版本
-
Python IDLE(Python 内置的 IDE)
-
文本编辑器
-
网络浏览器
本章的文件可以在这里找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter10
查看以下视频以查看代码运行情况:
探索计算机像素
当您仔细观察计算机屏幕时,您可能会发现形成行和列的小点。从一定距离上看,这些点的矩阵代表图像,这是我们在屏幕上看到的。这些点称为像素。由于计算机游戏应该在视觉上令人愉悦,我们必须使用这些像素来创建和自定义游戏屏幕,甚至使用它们来使玩家在游戏中移动,这将显示在屏幕上。每当玩家在键盘上按下任何键时,移动的变化必须反映在屏幕的像素上。例如,当玩家按下右键时,特定字符必须在屏幕上向右移动若干个像素单位,以表示运动。我们在上一章中讨论了矢量运动,它能够覆盖一些类的方法以实现运动。我们将使用矢量的技术来使游戏角色进行像素移动。让我们观察以下大纲,我们将使用矢量和 turtle 模块来制作任何游戏:
-
制作一个
Vector
类,其中将具有__add__()
、__mul__()
和__div__()
等方法,这些方法将对我们的向量点执行算术运算。 -
使用
Vector
类在游戏屏幕上实例化玩家,并设置其瞄准目标或移动。 -
使用
turtle
模块制作游戏边界。 -
使用
turtle
模块绘制游戏角色。 -
应该使用
Vector
类的旋转、前进和移动等操作,以使游戏角色移动。 -
使用主循环处理用户事件。
我们将通过制作简单的Mario像素艺术来学习像素表示。以下代码显示了多维列表中像素的表示,这是一个列表的列表。我们使用多维列表将每个像素存储在单独的行中:
>>> grid = [[1,0,1,0,1,0],[0,1,0,1,0,1],[1,0,1,0,1,0]]
前面的网格由三行组成,代表像素位置。类似于列表元素提取方法,>>> grid[1][4]
语句从网格的第二个列表(即[0,1,0,1,0,1])中返回’0’的位置值。 (请参考第四章,数据结构和函数,以了解更多关于列表操作的信息。)因此,我们可以访问网格内的任何单元格。
以下代码应该写在 Python 脚本中。通过创建一个mario.py
文件,我们将用它来创建马里奥像素艺术:
-
首先导入 turtle——
import turtle
——这是我们将要使用的唯一模块。 -
使用
>>> Pen = turtle.Turtle()
命令实例化turtle
模块。 -
使用速度和颜色属性为画笔指定两个属性:
Pen.speed(0)
Pen.color("#0000000") #or Pen.color(0, 0, 0)
- 我们必须创建一个名为
box
的new
函数,该函数将使用画笔方法绘制正方形形状来绘制一个盒子。这个盒子大小代表像素艺术的尺寸:
def box(Dimension): #box method creates rectangular box
Pen.begin_fill()
# 0 deg.
Pen.forward(Dimension)
Pen.left(90)
# 90 deg.
Pen.forward(Dimension)
Pen.left(90)
# 180 deg.
Pen.forward(Dimension)
Pen.left(90)
# 270 deg.
Pen.forward(Dimension)
Pen.end_fill()
Pen.setheading(0)
- 我们必须将画笔定位到屏幕左上角的位置开始绘画。这些命令应该在
box()
函数之外定义:
Pen.penup()
Pen.forward(-100)
Pen.setheading(90)
Pen.forward(100)
Pen.setheading(0)
- 定义盒子大小,代表我们要绘制的像素艺术的尺寸:
boxSize = 10
- 在第二阶段,您必须以多维列表的形式声明像素,这些像素代表每个像素的位置。以下的
grid_of_pixels
变量代表了代表像素位置的线网格。下面的代码行必须添加到box
函数定义之外。(请参考github.com/PacktPublishing/Learning-Python-by-building-games
来定位游戏文件,即mario.py
。):
请记住,单个形式的像素组合代表一条直线。
grid_of_pixels = [[1,1,1,1,2,2,2,2,2,2,2,2,1,1,1,1]]
grid_of_pixels.append([1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,1])
grid_of_pixels.append([1,1,1,0,0,0,3,3,3,3,3,0,3,1,1,1])
grid_of_pixels.append([1,1,0,3,0,3,3,3,3,3,3,0,3,3,3,1])
grid_of_pixels.append([1,1,0,3,0,0,3,3,3,3,3,3,0,3,3,3])
grid_of_pixels.append([1,1,0,0,3,3,3,3,3,3,3,0,0,0,0,1])
grid_of_pixels.append([1,1,1,1,3,3,3,3,3,3,3,3,3,3,1,1])
grid_of_pixels.append([1,1,1,0,0,2,0,0,0,0,2,0,1,1,1,1])
grid_of_pixels.append([1,1,0,0,0,2,0,0,0,0,2,0,0,0,1,1])
grid_of_pixels.append([0,0,0,0,0,2,2,2,2,2,2,0,0,0,0,0])
grid_of_pixels.append([3,3,3,0,2,3,2,2,2,2,3,2,0,3,3,3])
grid_of_pixels.append([3,3,3,3,2,2,2,2,2,2,2,2,3,3,3,3])
grid_of_pixels.append([3,3,3,2,2,2,2,1,1,2,2,2,2,3,3,3])
grid_of_pixels.append([1,1,1,2,2,2,1,1,1,1,2,2,2,1,1,1])
grid_of_pixels.append([1,0,0,0,0,1,1,1,1,1,1,0,0,0,0,1])
grid_of_pixels.append([0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0])
- 使用颜色定义像素艺术的调色板。我们将使用颜色代码来定义艺术品的颜色,如下面的代码所示。十六进制颜色代码(HEX)代表红色、绿色和蓝色的颜色组合(#RRGGBB)。请参考
htmlcolorcodes.com/
以分析不同颜色的不同代码:
palette = ["#4B610B" , "#FAFAFA" , "#DF0101" , "#FE9A2E"]
- 接下来,我们应该开始使用我们在步骤 7和步骤 8中定义的像素网格和调色板来绘制像素艺术。我们必须使用我们之前制作的
box()
函数来制作像素艺术。像素艺术由行和列组成;因此,我们必须声明两个循环来绘制艺术品。以下代码调用了turtle
模块的不同函数,如forward()
、penup()
和pendown()
。我们在上一章中学习了它们;它们将利用画笔根据像素网格的列表来绘制。
for i in range (0,len(grid_of_pixels)):
for j in range (0,len(grid_of_pixels[i])):
Pen.color(palette[grid_of_pixels[i][j]])
box(boxSize)
Pen.penup()
Pen.forward(boxSize)
Pen.pendown()
Pen.setheading(270)
Pen.penup()
Pen.forward(boxSize)
Pen.setheading(180)
Pen.forward(boxSize*len(grid_of_pixels[i]))
Pen.setheading(0)
Pen.pendown()
让我们消化前面的代码片段。它包含一个for
循环,从 0 的初始值循环到代表画布中位置的像素网格的长度。每个像素代表一个位置,我们必须使用画笔进行绘制;因此,我们逐个循环每个像素。在二维for
循环内,我们从调色板中获取颜色并调用box
方法,该方法创建一个矩形框,我们的马里奥艺术应该在其中呈现。我们使用turtle
画笔在这个框内绘制,使用forward()
函数。我们在像素的行中执行相同的操作,如第 i 个循环所示。
一旦我们完成了前面的代码组合,也就是我们执行了box
方法、初始化和两个主要的for
循环,我们就可以运行代码并观察以下马里奥像素艺术。运行我们的代码后,turtle
模块的画笔将开始绘制,最终会给我们以下艺术品:
由于我们熟悉像素和矢量运动的概念,现在是使用 2D 图形制作游戏的时候了。我们将使用turtle
模块以及数据模型来创建游戏角色并使它们移动。我们将通过在下一节中制作一个简单的动画来开始这个冒险。
使用 Turtle 模块理解简单动画
到目前为止,我们可能已经熟悉了turtle
模块的不同方法。这意味着我们不会在创建游戏角色时遇到任何问题。同样,游戏角色的运动是使用矢量运动来实现的。矢量加法和减法等操作通过对象的旋转提供直线运动(有关更多信息,请参阅第九章,数据模型实现)。以下代码片段中定义的move
操作将为游戏角色提供随机移动。move
方法将以另一个矢量作为催化剂,并执行数学运算以更新当前位置,同时考虑游戏角色的方向:
>>> v = (1,2) #vector coordinates
>>> v.move(3,4) # vector addition is done (1,2) + (3,4)
>>> v
(4,6)
rotate
方法将按逆时针方向旋转矢量特定角度(原地)。以下示例表示rotate
方法的调用:
>>> v = vector(1, 2)
>>> v.rotate(90)
>>> v == vector(-2, 1)
True
我们必须在Vector
类中定义前面两种方法。按照以下步骤实现Vector
类:
-
您必须从使用 class 关键字定义
Vector
类开始。我们将定义 slots 作为类属性,其中包含三个属性。slots 表示一个包含三个关键信息的属性:x、y和 hash。x和y的值是游戏角色的当前位置,而 hash 用于定位数据记录。例如,如果使用x和y坐标实例化Vector
类,则将激活 hash 属性。否则,它将保持未激活状态。 -
矢量元素的坐标,即(5,6),由x和y表示,其中x=5,y=6,hash 变量表示插槽是否为空。hash 变量用于定位数据记录并检查
Vector
类是否已实例化。如果插槽属性已经包含x和y,则 hash 属性将阻止对插槽的进一步赋值。我们还将定义PRECISION
属性(用户定义),它将把x和y的坐标四舍五入到一定的级别。为了使事情清楚,代码中添加了几个示例,并且您可以在三行注释中观察到这一点:
#following class will create vector
#representing current position of game character
class vector(collections.Sequence):
"""Two-dimensional vector.
Vectors can be modified in-place.
>>> v = vector(0, 1)
>>> v.move(1)
>>> v
vector(1, 2)
>>> v.rotate(90)
>>> v
vector(-2.0, 1.0)
"""
PRECISION = 6 #value 6 represents level of rounding
#for example: 4.53434343 => 4.534343
__slots__ = ('_x', '_y', '_hash')
- 接下来,我们需要定义类的第一个成员。我们知道类的第一个成员是
__init__()
方法。我们将定义它以初始化类属性,即x和y。我们已经将x和y的值四舍五入到PRECISION
属性指示的一定精度级别。round()
是 Python 的内置函数。以下代码行包含一个构造函数,我们在其中使用round
方法初始化矢量坐标(x,y):
def __init__(self, x, y):
"""Initialize vector with coordinates: x, y.
>>> v = vector(1, 2)
>>> v.x
1
>>> v.y
2
"""
self._hash = None
self._x = round(x, self.PRECISION)
self._y = round(y, self.PRECISION)
- 您可能已经注意到,您已将x和y属性作为私有属性,因为它们以单下划线(
_x
,_y
)开头。因此,无法直接初始化这些类型的属性,这导致了数据封装,这是我们在面向对象范例主题中讨论过的。现在,为了获取和设置这些属性的值,您必须使用getter
和setter
方法。这两种方法将成为Vector
类的属性。以下代码表示如何为我们的Vector
类实现getter
和setter
:
@property
def x(self):
"""X-axis component of vector.
>>> v = vector(1, 2)
>>> v.x
1
>>> v.x = 3
>>> v.x
3
"""
return self._x
@x.setter
def x(self, value):
if self._hash is not None:
raise ValueError('cannot set x after hashing')
self._x = round(value, self.PRECISION)
@property
def y(self):
"""Y-axis component of vector.
>>> v = vector(1, 2)
>>> v.y
2
>>> v.y = 5
>>> v.y
5
"""
return self._y
@y.setter
def y(self, value):
if self._hash is not None:
raise ValueError('cannot set y after hashing')
self._y = round(value, self.PRECISION)
- 除了
getter
和setter
方法之外,您可能已经注意到了_hash
,它表示插槽是否已分配。为了检查插槽是否已经被分配,我们必须实现一个数据模型,即__hash__()
。
简单回顾一下:数据模型或魔术函数允许我们更改由其祖先之一提供的方法的实现。
现在,我们将在我们的Vector
类上定义hash
方法,并以不同的方式实现它:
def __hash__(self):
"""v.__hash__() -> hash(v)
>>> v = vector(1, 2)
>>> h = hash(v)
>>> v.x = 2
Traceback (most recent call last):
...
ValueError: cannot set x after hashing
"""
if self._hash is None:
pair = (self.x, self.y)
self._hash = hash(pair)
return self._hash
- 最后,您必须在
Vector
类中实现两个主要方法:move()
和rotate()
。我们将从move
方法开始。move
方法将移动向量到其他位置(原地)。这里,其他是传递给move
方法的参数。例如,(1, 2).move(2, 3)
将得到(3, 5)。记住:移动是通过任何向量算术运算来完成的,即加法、乘法、除法等。我们将使用__add__()
魔术函数(参考第九章,数据模型实现)来为向量创建移动。在此之前,我们必须创建一个返回向量副本的copy
方法。copy()
方法很重要,因为我们不希望操作损害我们的原始向量;相反,我们将在原始向量的副本上执行算术运算:
def copy(self):
"""Return copy of vector.
>>> v = vector(1, 2)
>>> w = v.copy()
>>> v is w
False
"""
type_self = type(self)
return type_self(self.x, self.y)
- 在实现
add
函数之前,您必须实现iadd
魔术函数。我们使用__iadd__
方法来实现扩展的add
运算符赋值。我们可以在Vector
类中实现__iadd__()
魔术函数,如下所示。我们在上一章中看到了它的实现(第九章,数据模型实现):
def __iadd__(self, other):
"""v.__iadd__(w) -> v += w
>>> v = vector(1, 2)
>>> w = vector(3, 4)
>>> v += w
>>> v
vector(4, 6)
>>> v += 1
>>> v
vector(5, 7)
"""
if self._hash is not None:
raise ValueError('cannot add vector after hashing')
elif isinstance(other, vector):
self.x += other.x
self.y += other.y
else:
self.x += other
self.y += other
return self
- 现在,您需要创建一个新的方法
__add__
,它将在原始向量的副本上调用前面的__iadd__()
方法。最后一条语句__radd__ = __add__
具有重要的意义。让我们观察一下radd
和add
之间的下面的图示关系。它的工作原理是这样的:Python 尝试评估表达式Vector(1,4) + Vector(4,5)。首先,它调用int.__add__((1,4), (4,5))
,这会引发异常。之后,它将尝试调用Vector.__radd__((1,4), (4,5))
:
很容易看出,__radd__
的实现类似于add
:(参考__add__()
方法中注释中定义的示例代码):
def __add__(self, other):
"""v.__add__(w) -> v + w
>>> v = vector(1, 2)
>>> w = vector(3, 4)
>>> v + w
vector(4, 6)
>>> v + 1
vector(2, 3)
>>> 2.0 + v
vector(3.0, 4.0)
"""
copy = self.copy()
return copy.__iadd__(other)
__radd__ = __add__
- 最后,我们准备为我们的动画制作第一个移动序列。我们将从在我们的类中定义
move
方法开始。move()
方法将接受一个向量作为参数,并将其添加到表示游戏角色当前位置的当前向量中。move
方法将实现直线加法。以下代码表示了move
方法的定义:
def move(self, other):
"""Move vector by other (in-place).
>>> v = vector(1, 2)
>>> w = vector(3, 4)
>>> v.move(w)
>>> v
vector(4, 6)
>>> v.move(3)
>>> v
vector(7, 9)
"""
self.__iadd__(other)
-
接下来,我们需要创建
rotate()
方法。这个方法相当棘手,因为它会逆时针旋转向量一个指定的角度(原地)。这个方法将使用三角函数操作,比如角度的正弦和余弦;因此,我们首先要导入一个数学模块:import math
。 -
以下代码描述了定义旋转方法的方式;在其中,我们添加了注释以使这个操作对您清晰明了。首先,我们用
angle*π/ 180.0
命令/公式将角度转换为弧度。之后,我们获取了向量类的x和y坐标,并执行了x = x*cosθ - y*sinθ
和y = y*cosθ + x*sinθ
操作:
import math
def rotate(self, angle):
"""Rotate vector counter-clockwise by angle (in-place).
>>> v = vector(1, 2)
>>> v.rotate(90)
>>> v == vector(-2, 1)
True
"""
if self._hash is not None:
raise ValueError('cannot rotate vector after hashing')
radians = angle * math.pi / 180.0
cosine = math.cos(radians)
sine = math.sin(radians)
x = self.x
y = self.y
self.x = x * cosine - y * sine
self.y = y * cosine + x * sine
数学公式x = xcosθ - ysin**θ在向量运动中非常重要。这个公式用于为游戏角色提供旋转运动。xcosθ代表基础x轴运动,而ysinθ代表垂直y轴运动。因此,这个公式实现了在二维平面上以角度θ旋转一个点。
最后,我们完成了两个方法:move()
和rotate()
。这两种方法完全独特,但它们都代表向量运动。move()
方法实现了__iadd_()
魔术函数,而rotate()
方法具有自己的自定义三角函数实现。这两种方法的组合可以形成游戏角色在画布或游戏屏幕上的完整运动。为了构建任何类型的 2D 游戏,我们必须实现类似的运动。现在,我们将制作一个蚂蚁游戏的简单动画,以开始我们的游戏冒险之旅。
以下步骤描述了制作 2D 游戏动画的过程:
-
首先,您必须导入必要的模块。由于我们必须为先前制作的
move()
方法提供随机向量坐标,我们可以预测我们将需要一个随机模块。 -
之后,我们需要另一个模块——
turtle
模块,它将允许我们调用ontimer
和setup
等方法。我们还需要向量类的方法,即move()
和rotate()
。 -
如果该类维护在任何其他模块或文件中,我们必须导入它。创建两个文件:
base.py
用于向量运动和animation.py
用于动画。然后,导入以下语句:
from random import *
from turtle import *
from base import vector
-
前两个语句将从 random 和 turtle 模块中导入所有内容。第三个语句将从基本文件或模块中导入向量类。
-
接下来,我们需要为游戏角色定义初始位置以及其目标。它应该被初始化为向量类的一个实例:
ant = vector(0, 0) #ant is character
aim = vector(2, 0) #aim is next position
- 现在,您需要定义 wrap 方法。该方法以x和y位置作为参数,称为
value
,并返回它。在即将推出的游戏中,如 flappy bird 和 Pong,我们将扩展此功能,并使其将值环绕在某些边界点周围:
def wrap(value):
return value
- 游戏的主控单元是
draw()
函数,它调用一个方法来使游戏角色移动。它还为游戏绘制屏幕。我们将从Vector
类中调用move
和rotate
方法。从 turtle 模块中,我们将调用goto
、dot
和ontimer
方法。goto
方法将在游戏屏幕上的指定位置移动海龟画笔,dot
方法在调用时创建指定长度的小点,ontimer(function, t)
方法将安装一个定时器,在t
毫秒后调用该函数:
def draw():
"Move ant and draw screen."
ant.move(aim)
ant.x = wrap(ant.x)
ant.y = wrap(ant.y)
aim.move(random() - 0.5)
aim.rotate(random() * 10 - 5)
clear()
goto(ant.x, ant.y)
dot(10)
if running:
ontimer(draw, 100)
- 在上述代码中,
running
变量尚未声明。我们现在将在draw()
方法的定义之外进行声明。我们还将使用以下代码设置游戏屏幕:
setup(420, 420, 370, 0)
hideturtle()
tracer(False)
up()
running = True
draw()
done()
最后,我们完成了一个简单的 2D 动画。它由一个长度为 10 像素的简单点组成,但更重要的是,它具有附加的运动,这是在Vector
类中实现魔术函数的结果。下一节将教我们如何使用本节中实现的魔术函数来制作更健壮的游戏,即蛇游戏。我们将使用 turtle 模块和魔术函数制作蛇游戏。
使用 Turtle 升级蛇游戏
事实证明,在本书的前几章中我们一直在构建贪吃蛇游戏:在第五章中,使用 curses 模块学习贪吃蛇游戏;在第六章中,面向对象编程;以及在第七章中,通过属性和列表推导式进行改进。我们从 curses 模块开始(第五章,学习使用 curses 构建贪吃蛇游戏),并使用面向对象的范例进行修改。curses 模块能够提供基于字符的终端游戏屏幕,这最终使游戏角色看起来很糟糕。尽管我们学会了如何使用 OOP 和 curses 构建逻辑,以及制作贪吃蛇游戏,但应该注意到游戏主要关注视觉:玩家如何看到角色并与之交互。因此,我们的主要关注点是使游戏具有视觉吸引力。在本节中,我们将尝试使用 turtle 模块和向量化移动来升级贪吃蛇游戏。由于在贪吃蛇游戏中只有一种可能的移动方式,即通过按左、右、上或下键进行直线移动,我们不必在基本文件的向量类中定义任何新内容。我们之前创建的move()
方法足以为贪吃蛇游戏提供移动。
让我们开始使用 turtle 模块和Vector
类编写贪吃蛇游戏,按照以下步骤进行:
- 像往常一样,首先导入必要的模块,如下面的代码所示。您不必先导入所有内容;我们也可以在编写其他内容时一起导入,但一次导入所有内容是一个好习惯,这样我们之后就不会忘记任何东西:
from turtle import *
from random import randrange
from base import vector
- 现在,让我们进行一些头脑风暴。我们暂时不能使用精灵或图像。在开始使用 Pygame 之后,我们将在即将到来的章节中学习这些内容。现在,我们必须制作一个代表 2D 蛇角色的形状。您必须打开
base.py
文件,在那里我们创建了Vector
类并定义了Square
方法。请注意,Square
方法是在Vector
类之外声明的。以下代码是使用 turtle 方法创建正方形形状的简单实现:
def square(x, y, size, name):
"""Draw square at `(x, y)` with side length `size` and fill color
`name`.
The square is oriented so the bottom left corner is at (x, y).
"""
import turtle
turtle.up()
turtle.goto(x, y)
turtle.down()
turtle.color(name)
turtle.begin_fill()
for count in range(4):
turtle.forward(size)
turtle.left(90)
turtle.end_fill()
- 接下来,在贪吃蛇游戏模块中导入这个新方法。现在,我们可以在贪吃蛇游戏的 Python 文件中调用 square 方法:
from base import square
- 导入所有内容后,我们将声明变量,如 food、snake 和 aim。food 表示向量坐标,是
Vector
类的一个实例,例如 vector(0,0)。snake 表示蛇角色的初始向量位置,即(vector(10,0)),而蛇的身体必须是向量表示的列表,即(vector(10,0)、vector(10,1)和 vector(10,2))表示长度为 3 的蛇。aim
向量表示必须根据用户的键盘操作添加或减去到当前蛇向量的单位:
food = vector(0, 0)
snake = [vector(10, 0)]
aim = vector(0, -10)
- 在
snake-Python
文件(主文件)中导入所有内容并声明其属性后,我们将开始定义贪吃蛇游戏的边界,如下所示:
def inside(head):
"Return True if head inside boundaries."
return -200 < head.x < 190 and -200 < head.y < 190
- 您还应该定义贪吃蛇游戏的另一个重要方法,即
move()
,因为这将负责在游戏屏幕上移动贪吃蛇角色,如下所示:
def move():
"Move snake forward one segment."
head = snake[-1].copy()
head.move(aim)
if not inside(head) or head in snake:
square(head.x, head.y, 9, 'red')
update()
return
snake.append(head)
if head == food:
print('Snake:', len(snake))
food.x = randrange(-15, 15) * 10
food.y = randrange(-15, 15) * 10
else:
snake.pop(0)
clear()
for body in snake:
square(body.x, body.y, 9, 'black')
square(food.x, food.y, 9, 'green')
update()
ontimer(move, 100)
- 让我们逐行理解代码:
-
在
move
方法的开始,我们获取了snakehead
并执行了一个复制操作,这个操作是在Vector
类中定义的,我们让蛇自动向前移动了一个段落,因为我们希望蛇在用户开始玩游戏时自动移动。 -
之后,
if not inside(head) or head in snake
语句用于检查是否有任何碰撞。如果有,我们将通过将红色
渲染到蛇上来返回。 -
在语句的下一行
head == food
中,我们检查蛇是否能够吃到食物。一旦玩家吃到食物,我们将在另一个随机位置生成食物,并在 Python 控制台中打印分数。 -
在
for body in snake: ..
语句中,我们循环遍历了蛇的整个身体,并将其渲染为黑色
。 -
在
Vector
类内部定义的square
方法被调用以为游戏创建食物。 -
在代码的最后一条语句中,调用了
ontimer()
方法,该方法接受move()
函数,并将安装一个定时器,每 100 毫秒调用一次move
方法。
- 在定义了
move()
方法之后,您必须设置游戏屏幕并处理乌龟屏幕。与setup
方法一起传递的参数是宽度
、高度
、setx
和sety
位置:
setup(420, 420, 370, 0)
hideturtle()
tracer(False)
- 我们游戏的最后部分是处理用户事件。我们必须让用户玩游戏;因此,每当用户从键盘输入时,我们必须调用适当的函数。由于 Snake 是一个简单的游戏,只有几个移动,我们将在下一节中介绍它。一旦用户按下任意键,我们必须通过改变蛇的方向来处理它。因此,我们必须为处理用户操作制作一个快速的方法。以下的
change()
方法将根据用户事件改变蛇的方向。在这里,我们使用了 turtle 模块提供的listen
接口,它将监听任何传入的用户事件或键盘输入。onkey()
接受一个函数,该函数将根据用户事件调用 change 方法。例如,当按下Up
键时,我们将通过增加当前y
值 10 个单位来改变y坐标:
def change(x, y):
"Change snake direction."
aim.x = x
aim.y = y
listen()
onkey(lambda: change(10, 0), 'Right')
onkey(lambda: change(-10, 0), 'Left')
onkey(lambda: change(0, 10), 'Up')
onkey(lambda: change(0, -10), 'Down')
move()
done()
现在是时候运行我们的游戏了,但在此之前,请记住将包含vector
和square
类的文件(以及包含 Snake 游戏的文件)放在同一个目录中。游戏的输出看起来像这样:
除了乌龟图形,我们还可以在 Python 终端中打印分数:
现在我们已经通过使用 Python 模块和面向对象编程范式提供的多种方法来完成了 Snake 游戏,我们可以在即将到来的游戏中一次又一次地重复使用这些东西。在base.py
文件中定义的Vector
类可以在许多 2D 游戏中反复使用。因此,代码的重复使用是面向对象编程提供的主要优点之一。我们将在接下来的几节中只使用Vector
类制作几个游戏,例如乒乓球和飞翔的小鸟。在下一节中,我们将从头开始构建乒乓球游戏。
探索乒乓球游戏
现在我们已经通过使用 Python 模块和面向对象编程范式提供的多种方法来完成了 Snake 游戏(尽管它很陈词滥调,但它非常适合掌握 2D 游戏编程的知识),现在是时候制作另一个有趣的游戏了。我们将在本节中介绍的游戏是乒乓球游戏。如果您以前玩过,您可能会发现更容易理解我们将在本节中介绍的概念。对于那些以前没有玩过的人,不用担心!我们将在本节中涵盖一切,这将帮助您制作自己的乒乓球游戏并玩它,甚至与朋友分享。以下的图表是乒乓球游戏的图形表示:
前面的图表描述了乒乓游戏的游戏场地,其中两个玩家是两个矩形。他们可以上下移动,但不能左右移动。中间的点是球,必须由任一玩家击中。在这个游戏中,我们必须为游戏角色的两种运动类型解决问题:
-
对于球来说,它可以在任何位置移动,但如果任一方的玩家未接到球,他们将输掉比赛,而对方玩家将获胜。
-
对于玩家,他们只能向上或向下移动:应该处理两个玩家的四个键盘动作。
除了运动之外,为游戏指定边界甚至更加棘手。水平线可以上下移动,是球必须击中并在另一个方向上反射的位置,但如果球击中左侧或右侧的垂直边界,游戏应该停止,错过球的玩家将输掉比赛。现在,让我们进行头脑风暴,以便在实际开始编码之前了解必要的要点:
-
创建一个随机函数,它可以返回一个随机值,但在屏幕高度和宽度确定的范围内。从这个函数返回的值可能对使球在游戏中进行随机移动很有用。
-
创建一个方法,在屏幕上绘制两个矩形,实际上是我们游戏的玩家。
-
应该声明第三个函数,它将绘制游戏并将乒乓球移动到屏幕上。我们可以使用在先前制作的
Vector
类中定义的move()
方法,该方法将移动向量(就地)。
现在我们已经完成了后勤工作,可以开始编码了。按照以下步骤制作自己的乒乓游戏:
- 首先导入必要的模块,即 random、turtle 和我们自定义的名为
base
的模块,其中包含一堆用于向量运动的方法:
from random import choice, random
from turtle import *
from base import vector
- 以下代码表示
value()
方法的定义,以及三个变量的赋值。value()
方法将在(-5, -3)和(3, 5)之间随机生成值。这三个赋值语句根据它们的名称是可以理解的:
-
第一个语句表示球的初始位置。
-
第二个语句是球的进一步目标。
-
第三个语句是
state
变量,用于跟踪两个玩家的状态:
def value():
"Randomly generate value between (-5, -3) or (3, 5)."
return (3 + random() * 2) * choice([1, -1])
ball = vector(0, 0)
aim = vector(value(), value())
state = {1: 0, 2: 0}
- 下一个函数很有趣;这将在游戏屏幕上呈现矩形形状。我们可以使用 turtle 模块及其方法来呈现任何形状,如下所示:
def rectangle(x, y, width, height):
"Draw rectangle at (x, y) with given width and height."
up()
goto(x, y)
down()
begin_fill()
for count in range(2):
forward(width)
left(90)
forward(height)
left(90)
end_fill()
- 制作绘制矩形的函数后,我们需要制作一个新的方法,该方法可以调用在前面步骤中定义的方法。除此之外,新方法还应该将乒乓球无缝地移动到游戏屏幕上:
def draw():
"Draw game and move pong ball."
clear()
rectangle(-200, state[1], 10, 50)
rectangle(190, state[2], 10, 50)
ball.move(aim)
x = ball.x
y = ball.y
up()
goto(x, y)
dot(10)
update()
- 现在,是时候解决游戏的主要难题了:当球击中水平和垂直边界,或者当球击中玩家的矩形球拍时会发生什么?我们可以使用
setup
方法创建具有自定义高度和宽度的游戏屏幕。以下代码应该添加到draw()
函数中:
#when ball hits upper or lower boundary
#Total height is 420 (-200 down and 200 up)
if y < -200 or y > 200:
aim.y = -aim.y
#when ball is near left boundary
if x < -185:
low = state[1]
high = state[1] + 50
#when player1 hits ball
if low <= y <= high:
aim.x = -aim.x
else:
return
#when ball is near right boundary
if x > 185:
low = state[2]
high = state[2] + 50
#when player2 hits ball
if low <= y <= high:
aim.x = -aim.x
else:
return
ontimer(draw, 50)
- 现在我们已经解决了游戏角色的移动问题,我们需要制作游戏屏幕并找到处理用户事件的方法。以下代码将设置游戏屏幕,该屏幕从 turtle 模块中调用:
setup(420, 420, 370, 0)
hideturtle()
tracer(False)
- 制作游戏屏幕后,我们必须通过制作自定义函数来监听和处理用户的键盘事件。我们将制作
move()
函数,该函数将通过在调用此函数时传递的一定数量的单位来移动玩家的位置。这个移动函数将处理矩形球拍的上下移动:
def move(player, change):
"Move player position by change."
state[player] += change
- 最后,我们将使用 turtle 方法提供的
listen
接口来处理传入的键盘事件。由于有四种可能的移动,即每个玩家的上下移动,我们将保留四个键盘键[W、S、I和K],这些键将由 turtle 内部附加监听器,如下面的代码所示:
listen()
onkey(lambda: move(1, 20), 'w')
onkey(lambda: move(1, -20), 's')
onkey(lambda: move(2, 20), 'i')
onkey(lambda: move(2, -20), 'k')
draw()
done()
前面的步骤非常简单易懂,但让我们更加流畅地掌握步骤 4和步骤 5中定义的概念。在步骤 4中,clear()
方法之后的前两行代码将创建指定高度和宽度的矩形几何形状。state[1]
代表第一个玩家,而state[2]
代表第二个玩家。ball.move(aim)
语句是对矢量类内声明的move
方法的调用。
这个方法调用将执行指定矢量之间的加法,结果是直线运动。dot(10)
语句将创建一个宽度为 10 个单位的球。
同样,在步骤 5中,我们使用了>>> setup(420, 420, 370, 0)
语句来创建一个宽度为 420px,高度为 420px 的屏幕。当球击中上下边界时,必须改变方向一定量,而该量恰好是当前y的负值(-y改变方向)。然而,当球击中左边界或右边界时,游戏必须终止。在检查上下边界之后,我们对x坐标进行比较,并检查低和高状态。如果球在这些值下面,它必定与球拍碰撞,否则我们返回from
函数。确保将此代码添加到先前定义的draw()
函数中。
当您运行 Pong 游戏文件时,您会看到两个屏幕;一个屏幕将有一个乌龟图形屏幕,其中包含两个玩家准备玩您自己的 Pong 游戏。输出将类似于我们在头脑风暴 Pong 游戏时之前看到的图表。现在您已经了解了处理键盘操作的方式,以及使用 turtle 的ontimer
函数调用自定义函数,让我们做一些新的事情,这将有一个控制器。它将监听屏幕点击操作并对其做出响应。我们在诸如 Flappy Bird 这样的游戏中需要这个功能,用户在屏幕上点击并改变鸟的位置。
理解 Flappy Bird 游戏
每当我们谈论有屏幕点击操作或屏幕点击操作的游戏时,Flappy Bird 就会浮现在脑海中。如果您以前没有玩过,确保您在flappybird.io/
上查看它,以便熟悉它。尽管您在该网站看到的界面与我们将在本节中制作的 Flappy Bird 游戏不同,但不用担心——在学习 Python 的 GUI 模块Pygame之后,我们将模拟其界面。但现在,我们将使用 Python turtle 模块和矢量运动制作一个简单的 2D Flappy Bird 游戏。我们一直在使用onkey
方法来处理键盘操作,在前面的部分中,我们使用onkey
方法来嵌入特定键盘键的监听器。
然而,也有一些可以使用鼠标操作玩的游戏——通过点击游戏屏幕。在本节中,我们将按照以下步骤创建 Flappy,这是一款受到 Flappy Bird 启发的游戏:
-
首先,您应该为游戏玩法定义一个边界。您可以创建一个函数,该函数以矢量点作为参数,并检查它是否在边界内,然后相应地返回
True
或False
。 -
您必须制作一个渲染函数,用于将游戏角色绘制到屏幕上。正如我们所知,turtle 无法处理 GUI 中的许多图像或精灵;因此,您的游戏角色将类似于几何形状。您可以通过制作任何形状来代表您的鸟角色。如果可能的话,尽量使它小一些。
-
制作了一个渲染函数之后,您需要创建一个能够更新对象位置的函数。这个函数应该能够处理
tap
动作。
我们可以在整个 Flappy Bird 游戏的编码过程中使用预定义的Vector
蓝图。之前的路线图清楚地暗示了我们可以通过定义三个函数来制作一个简单的 Flappy Bird 游戏。让我们逐个定义这些函数:
- 首先,您需要设置屏幕。这个屏幕代表了输出游戏控制台,在这里您将玩我们的 Flappy Bird 游戏。您可以使用海龟模块通过
setup()
来创建一个游戏屏幕。让我们创建一个宽度为 420 像素,高度为 420 像素的屏幕:
from turtle import *
setup(420, 420, 370, 0)
- 您应该定义一个函数,用来检查用户是否在边界内点击或触摸。这个函数应该是一个布尔值,如果点击点在边界内,应该返回
True
;否则,应该返回False
:
def inside(point):
"Return True if point on screen."
return -200 < point.x < 200 and -200 < point.y < 200
- 我已经建议您如果以前没有玩过 Flappy Bird 游戏,可以去试试。在玩游戏时,您会发现游戏的目标是保护小鸟角色免受障碍物的影响。在现实世界游戏中,我们有垂直管道形式的障碍物。由于我们在使用海龟模块编码时没有足够的资源来使用这样的精灵或界面,我们将无法在本节中使用。正如我已经告诉过您的,我们将在学习 Pygame 时自己制作很酷的界面,但现在,我们将高度关注游戏逻辑,而不是 GUI。因此,我们将给游戏角色一些随机形状;小圆形状的小鸟角色和大圆形状的障碍物。小鸟将从向量类实例化,表示其初始位置。球(障碍物)必须作为列表制作,因为我们希望障碍物在小鸟的路径上:
bird = vector(0, 0)
balls = []
- 现在您已经熟悉了游戏角色,可以通过创建一些函数来渲染它们。在函数中,我们已经传递了
alive
作为一个变量,它将是一个布尔值,这将检查玩家是否死亡。如果小鸟还活着,我们使用goto()
跳转到该位置,并用绿色渲染一个点。如果小鸟死了,我们用红色渲染这个点。以下代码中的 for 循环将渲染一些障碍物:
def draw(alive):
"Draw screen objects."
clear()
goto(bird.x, bird.y)
if alive:
dot(10, 'green')
else:
dot(10, 'red')
for ball in balls:
goto(ball.x, ball.y)
dot(20, 'black')
update()
- 正如我们在之前的蓝图中讨论的,接下来是游戏的主控制器。这个函数必须执行多个任务,但所有这些任务都与更新对象的位置有关。对于那些以前没有玩过 Flappy Bird 的用户来说,他们可能很难理解下面的代码;这就是为什么我鼓励您去玩原版 Flappy Bird 游戏。如果您检查游戏中小鸟的移动,您会发现它只能在y轴上移动,即上下移动。同样对于障碍物,它们必须从右向左移动,就像现实世界游戏中的垂直管道一样。以下的
move()
函数包括了小鸟的初始运动。最初,我们希望它下降 5 个单位,并相应地减少。对于障碍物的部分,我们希望它向左移动 3 个单位:
from random import *
from base import vector #for vectored motion
def move():
"Update object positions."
bird.y -= 5
for ball in balls:
ball.x -= 3
- 您必须在
move
函数内明确地创建多个障碍物。由于障碍物应该随机生成,我们可以使用随机模块来创建它:
if randrange(10) == 0:
y = randrange(-199, 199)
ball = vector(199, y)
balls.append(ball) #append each obstacles to list
- 接下来,我们需要检查玩家是否能够阻止小鸟触碰障碍物。检查的方法很简单。如果球或障碍物超出了左边界,我们可以将它从球的列表中移除。最初,我们制作了
inside
函数来检查任何点是否在边界内;现在,我们可以用它来检查障碍物是否在边界内。它应该看起来像这样:
while len(balls) > 0 and not inside(balls[0]):
balls.pop(0)
- 请注意,我们已经为障碍物添加了一个条件;现在是时候添加一个条件来检查小鸟是否还活着。如果小鸟掉下来并触及下边界,程序应该终止:
if not inside(bird):
draw(False)
return
- 现在,我们将添加另一个条件——检查障碍物是否与小鸟发生了碰撞。有几种方法可以做到这一点,但现在,我们将通过检查球和障碍物的位置来实现这一点。首先,您必须检查障碍物和小鸟的大小:障碍物或球的大小为 20 像素,小鸟的大小为 10 像素(在第 4 点定义);因此,我们可以假设它们在彼此之间的距离为 0 时发生了碰撞。因此,
>>> if abs(ball - bird) < 15
表达式将检查它们之间的距离是否小于 15(考虑到球和小鸟的宽度):
for ball in balls:
if abs(ball - bird) < 15:
draw(False)
return
draw(True)
ontimer(move, 50) #calls move function at every 50ms
- 现在我们已经完成了更新对象的位置,我们需要处理用户事件——这是当玩家轻击游戏屏幕时应该实现的内容。当用户轻击屏幕时,我们希望小鸟上升一定数量的像素。传递给轻击函数(x,y)的参数是游戏屏幕上点击点的坐标:
def tap(x, y):
"Move bird up in response to screen tap."
up = vector(0, 30)
bird.move(up)
- 最后,是时候使用 turtle 模块添加一个监听器了。我们将使用
onscreenclick()
函数,它将以用户定义的任何函数作为参数(在我们的情况下是tap()
函数),并将以画布上点击点的坐标(x,y)调用该函数。我们已经使用 tap 函数来调用这个监听器:
hideturtle()
up()
tracer(False)
onscreenclick(tap)
move()
done()
这似乎是很多工作,对吧?的确是。在本节中,我们已经涵盖了很多内容:定义边界的方法,渲染游戏对象,更新对象位置以及处理轻击事件或鼠标事件。我觉得我们已经学到了很多关于使用 turtle 模块构建 2D 游戏的逻辑。尽管使用 turtle 模块制作的游戏并不是很吸引人,但我们通过构建这些游戏学到的逻辑将在接下来的章节中反复使用。在这类游戏中,我们并不太关心界面,而是会在 Python shell 中运行我们的游戏并观察它的外观。上述程序的结果将是这样的:
错误消息:没有名为’base’的模块。这是因为您还没有将您的Base
模块(包含我们在使用 Turtle 模块进行简单动画部分中制作的Vector
类的 Python 文件)和 Python 游戏文件添加到同一个目录中。确保您创建一个新目录并将这两个文件存储在一起,或者从以下 GitHub 链接获取代码:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter10
。
对于由 Turtle 制作的游戏,修改的空间很小。但我强烈建议您自行测试游戏,并发现可能的修改。如果您发现了任何修改,尝试实现它们。在下一节中,我们将介绍如何正确测试游戏并应用修改,使这些游戏比以前更加稳固。
游戏测试和可能的修改
许多人错误地认为,要成为一名熟练的游戏测试人员,您应该是一名游戏玩家。这在某种程度上可能是正确的,但大多数情况下,游戏测试人员并不关心游戏的前端设计。他们主要关注处理游戏服务器和客户端计算机之间的数据通信的后端部分。我将带您了解我们的 Pong 游戏的游戏测试和修改过程,同时涵盖以下几点:
- 增强游戏角色:以下代码代表游戏角色的新模型。我们仅使用乌龟模块来实现它。挡板是代表乒乓球游戏玩家的矩形框。有两个,即挡板 A 和挡板 B:
import turtle
# Paddle A
paddle_a = turtle.Turtle()
paddle_a.speed(0)
paddle_a.shape('square')
paddle_a.color('white')
paddle_a.penup()
paddle_a.goto(-350, 0)
paddle_a.shapesize(5, 1)
# Paddle B
paddle_b = turtle.Turtle()
paddle_b.speed(0)
paddle_b.shape('square')
paddle_b.color('white')
paddle_b.penup()
paddle_b.goto(350, 0)
paddle_b.shapesize(5, 1)
- 在游戏中添加主角(一个球):与创建 A 和 B 挡板类似,我们将使用乌龟模块以及
speed()
、shape()
和color()
等命令来创建一个球角色并为其添加功能:
# Ball
ball = turtle.Turtle()
ball.speed(0)
ball.shape('circle')
ball.color('white')
ball.penup()
ball.dx = 0.15
ball.dy = 0.15
- 为游戏添加得分界面:我们将使用乌龟画笔为每个玩家得分绘制一个界面。以下代码包括了从乌龟模块调用的方法,即
write()
方法,用于写入文本。它将arg的字符串表示放在指定位置:
# Pen
pen = turtle.Turtle()
pen.speed(0)
pen.color('white')
pen.penup()
pen.goto(0, 260)
pen.write("Player A: 0 Player B: 0", align='center',
font=('Courier', 24, 'bold'))
pen.hideturtle()
# Score
score_a = 0
score_b = 0
- 键盘绑定与适当的动作:在以下代码中,我们已经将键盘与适当的函数绑定。每当按下键盘键时,将使用
onkeypress
调用指定的函数;这就是事件处理。对于paddle_a_up
和paddle_b_up
等方法感到困惑吗?一定要复习乒乓球游戏部分:
def paddle_a_up():
y = paddle_a.ycor()
y += 20
paddle_a.sety(y)
def paddle_b_up():
y = paddle_b.ycor()
y += 20
paddle_b.sety(y)
def paddle_a_down():
y = paddle_a.ycor()
y += -20
paddle_a.sety(y)
def paddle_b_down():
y = paddle_b.ycor()
y += -20
paddle_b.sety(y)
# Keyboard binding
wn.listen()
wn.onkeypress(paddle_a_up, 'w')
wn.onkeypress(paddle_a_down, 's')
wn.onkeypress(paddle_b_up, 'Up')
wn.onkeypress(paddle_b_down, 'Down')
- 乌龟屏幕和主游戏循环:以下几个方法调用代表了乌龟屏幕的设置:游戏的屏幕大小和标题。
bgcolor()
方法将以指定颜色渲染乌龟画布的背景。这里,屏幕的背景将是黑色:
wn = turtle.Screen()
wn.title('Pong')
wn.bgcolor('black')
wn.setup(width=800, height=600)
wn.tracer(0)
主游戏循环看起来有点棘手,但如果你仔细看,你会发现我们已经了解了这个概念。主循环从设置球的运动开始。dx
和dy
的值是其运动的恒定单位。对于**#边界检查部分,我们首先检查球是否击中了上下墙壁。如果是,我们就改变它的方向,让球重新进入游戏。对于#2:对于右边界**,我们检查球是否击中了右侧的垂直边界,如果是,我们就将得分写给另一个玩家,然后结束游戏。左边界也是一样的:
while True:
wn.update()
# Moving Ball
ball.setx(ball.xcor() + ball.dx)
ball.sety(ball.ycor() + ball.dy)
# Border checking
#1: For upper and lower boundary
if ball.ycor() > 290 or ball.ycor() < -290:
ball.dy *= -1
#2: for RIGHT boundary
if ball.xcor() > 390:
ball.goto(0, 0)
ball.dx *= -1
score_a += 1
pen.clear()
pen.write("Player A: {} Player B: {}".format(score_a, score_b),
align='center', font=('Courier', 24, 'bold'))
#3: For LEFT boundary
if ball.xcor() < -390:
ball.goto(0, 0)
ball.dx *= -1
score_b += 1
pen.clear()
pen.write("Player A: {} Player B: {}".format(score_a, score_b),
align='center', font=('Courier', 24, 'bold'))
现在,我们必须处理球击中玩家的挡板的情况。以下两个条件代表了挡板和球之间的碰撞:前一个是针对挡板 B 的,后一个是针对挡板 A 的。由于挡板 B 位于屏幕的右侧,我们检查球的坐标是否与挡板的坐标加上其宽度相同。如果是,我们使用ball.dx *= -1
命令来改变球的方向。setx
方法将把球的第一个坐标改为340,而将y坐标保持不变。这里的逻辑与我们制作贪吃蛇游戏时使用的逻辑类似,当蛇头与食物碰撞时:
# Paddle and ball collisions
if (ball.xcor() > 340 and ball.xcor() < 350) and (ball.ycor()
< paddle_b.ycor() + 60 and ball.ycor() > paddle_b.ycor() -60):
ball.setx(340)
ball.dx *= -1
if (ball.xcor() < -340 and ball.xcor() > -350) and (ball.ycor()
< paddle_a.ycor() + 60 and ball.ycor() > paddle_a.ycor() -60):
ball.setx(-340)
ball.dx *= -1
实施如此严格的修改的好处不仅在于增强游戏角色,还在于控制不一致的帧速率——即连续图像(帧)在显示屏上出现的速率。我们将在即将到来的关于Pygame的章节中详细了解这一点,在那里我们将使用自己的精灵来定制基于乌龟的贪吃蛇游戏。在总结本章之前,让我们运行定制的乒乓球游戏并观察结果,如下所示:
总结
在本章中,我们探索了 2D 乌龟图形的世界,以及矢量运动。
我尽量使这一章尽可能全面,特别是在处理矢量运动时。我们创建了两个单独的文件;一个是Vector
类,另一个是游戏文件本身。Vector
类提供了一种表示x和y位置的 2D 坐标的方法。我们执行了多个操作,比如move和rotation,使用数据模型——覆盖了我们自定义的Vector
类的实际行为。我们简要地观察了通过创建马里奥像素艺术来处理计算机像素的方法。我们制作了一个像素网格(列表的列表)来表示像素的位置,并最终使用 turtle 方法来渲染像素艺术。之后,我们通过定义一个独立的Vector
类来制作了一个简单的动画,该类表示游戏角色的位置。我们在整个游戏过程中都使用了 turtle 模块和我们自定义的Vector
类。虽然我觉得你已经准备好开始你的 2D 游戏程序员生涯了,但正如我们所说,“熟能生巧”,在你感到舒适之前,你需要大量尝试。
这一章对于我们所有想成为游戏程序员的人来说都是一个突破。我们学习了使用 Python 和 turtle 模块构建游戏的基础知识,学会了如何处理鼠标和键盘等不同的用户事件。最后,我们还学会了如何使用 turtle 模块创建不同的游戏角色。当你继续阅读本书时,你会发现 turtle 的这些概念是非常重要的,所以确保在继续之前复习它们。
在下一章中,我们将学习 Pygame 模块——这是使用 Python 构建交互式游戏最重要的平台。从下一章开始,我们将深入探讨一些话题,比如你可以加载图像或精灵,制作自己的游戏动画。你还会发现,与 C 或 C++相比,使用 Python 构建游戏是多么容易。