通过构建游戏学习-Python(二)

通过构建游戏学习 Python(二)

原文:zh.annas-archive.org/md5/8d68d722c94aedcc91006ddf3f78c65a

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:数据结构和函数

在这一章中,我们将穿越数据结构和函数的概念,这是 Python 的两个主要构建模块。普通变量是存储任何类型的单个数据单元的好方法,但对于数据数组,我们应该始终使用数据结构。Python 有大量可用的数据结构,您可以使用它们来表示和操作数据集,甚至将它们组合在一起以制作自己的数据结构。我们已经看到了内置数据类型,如整数,布尔值,浮点数,字符串和字符。它们被称为内置类型,因为它们与 Python 一起提供。现在,我们将探索内置数据结构,如列表,字典,元组和集合。这些内置数据类型的组合会独立实现数据结构。例如,如果我们将不同的整数放在一个地方,它们就是数字数组。Python 称它们为列表,这是广泛使用的数据结构。

为了成为熟练的程序员,我们首先必须了解核心编程范式,如变量,数字,模块和内置函数,然后再深入研究数据结构和算法。这本书也不例外。我们已经介绍了 Python 的基础知识;现在是时候深入研究数据结构和用于访问和操作数据的方法。在上一章中,我们使用条件和循环修改了我们的游戏。现在,让我们将对 Python 的知识扩展到包括数据结构和函数的广泛概念,以便我们可以完善我们的游戏决定最快

进一步。

本章将涵盖以下主题:

  • 我们为什么需要数据结构?

  • Python 的四个结构支柱-列表,字典,集合和元组

  • 函数

  • 向井字棋游戏添加人工智能

  • 游戏测试和可能的修改

技术要求

以下是您需要正确理解本章的要求:

查看以下视频以查看代码的运行情况:

bit.ly/2oNoxOL

我们为什么需要数据结构?

作为程序员或计算机科学家,我们总是在寻找优化代码的方法。优化是一种改进代码以提高代码效率和质量的方式。数据结构是计算机中组织数据的一种聪明方式,因此更容易检索和访问数据,从而实现代码优化。

到目前为止,我们已经学会了如何使用条件语句来制定条件,以及如何使用普通变量来进行流程控制。然而,现实世界的数据不限于一个单位。我们可能收集大量数据,这些数据可能具有最高级别的复杂性。它可能包含数千个整数,数百个布尔值,或者它们的组合。因此,将它们存储到一个单一的普通变量中是不可能的。请看以下示例:

在上面的代码中,我们尝试将两个值分配给一个变量。这产生了语法错误。我们甚至尝试将两个字符串值放入一个变量a中,但它执行了连接,并将其分配为单个值。因此,在普通变量中存储多个值是不可能的。但是,我们可以轻松地将这个普通变量转换为数据结构,如下面的代码片段所示:

>>> a = 8 , 9
>>> a
(8,9)
>>> type(a) 
<class 'tuple'>

我们已将普通变量a转换为元组,这是 Python 的一种内置数据结构。我们将在接下来的部分中详细介绍这一点。

这个变量只能存储单个数据单元,但如果我们进行多次赋值,前面的值将被覆盖。然而,如果你想在一个占位符中保留所有数据,数据结构是一种方法。

作为程序员,我们的主要责任是对输入数据集进行某种操作。输入可以是任何东西,比如电子邮件或密码,或者可能是进入系统或谷歌地图位置的请求,我们可以使用数据使用算法进行某种计算。此外,haversine 算法(请参考以下网址了解更多关于这个算法的信息:rosettacode.org/wiki/Haversine_formula)可以给出您的位置和目的地之间的精确距离。因此,输入数据可以有很广泛的范围,但主要任务是对其进行操作。我们的系统和处理器没有足够的能力一次处理数百太字节的数据操作。因此,选择适当的数据结构是程序员可以进行的主要优化。如果我们能够以任何有组织的形式将这些输入存储到更快的数据结构中,我们甚至可以轻松地执行复杂的任务。数据结构只是提供结构给这些复杂数据的地方或存储,但是像获取和操作这样的过程是使用算法来执行的。

还有疑问吗?让我们通过以图书馆为例来清楚地理解数据结构和算法。首先,想象一个没有适当管理的图书馆的情景。书籍没有被正确放置在相关的部分。现在,在特定部分搜索书籍是没有意义的,因为它不会在那里。最好的情况是你可能会在几分钟内找到你的书,但最坏的情况是你可能不得不搜索整个图书馆来找一本关于历史的书,例如。然而,如果图书馆得到了适当的组织和管理,你将能够直接去到存放历史书籍的相关部分,并且只在那个部分搜索你的书。在这里,图书馆代表了数据结构,书是你正在寻找的数据。每当你需要数据时,你去到数据结构,如果它被适当管理,你将能够轻松地检索到它。定义你将如何搜索书籍的步骤被称为算法。

理论够了,让我们动手编码,学习 Python 的四大数据结构支柱——列表字典集合元组

Python 的四大结构支柱——列表、字典、集合和元组

在第二章中,学习 Python 的基础知识,我们学习了字符串,并将它们称为不可变数据类型,因为它们不允许赋值操作。这在以下代码中显示:

>>> name = "Python"
>>> name[0] = 'hey'
TypeError: 'str' object does not support item assignment

然而,数据结构必须是灵活的,这意味着我们应该能够从任何位置存储和提取数据元素。因此,Python 的大多数内置数据结构都是可变的,这意味着它们可以通过适当的索引进行更改和操作。四种数据结构的适当类别如下:

  • 列表和元组:可变数据结构

  • 字典:映射数据结构

  • 集合:可变且无序的数据结构

每个类别都因其独特性而存在,您将看到在接下来的部分中,很容易将它们区分为优越或次优。但是,请记住,它们在某些时候都是优越的;我们可以选择一个适合情况的数据结构。例如,我们说字典是数据结构之王,但我们可能会遇到元组可能是存储数据的更快方式的情况,通常在使用 SQLite 和 MySQL 等数据库制作 Python 程序时会出现这种情况。现在,让我们来看看 Python 的每个内置数据结构,从基本的可变数据结构开始,即列表。

列表

就像字符串是字符序列一样,列表是值序列。值可以是任何类型的组合。列表中的值称为该列表的项。列表是可变和有序的数据结构,可以使用索引提取列表的元素。与字符串一样,我们可以使用切片技术从列表中提取多个元素。列表以存储同质数据类型而臭名昭著,但它们也支持异质数据类型。我们不仅限于使用单一方法创建列表;有多种方法可以做到这一点。让我们看一些在 Python 中创建列表的基本方法:

>>> first_list = []  
>>> type(first_list)
<class 'list'>

创建列表的最简单方法是使用方括号——[]。您可以在这些方括号中添加多个元素,有多种方法可以做到这一点:

  • 首先,我们可以在声明列表的同时向列表中添加元素,如下例所示:
      >>> numbers = [1,2,3,4,5,6,7,8,9]
  • 您还可以使用 Python 内置方法向列表中添加元素。例如,append方法可用于向列表中插入元素。元素被添加到列表的最后位置,如下所示:
      >>> numbers.append(10)
      >>> print(numbers)
      [1,2,3,4,5,6,7,8,9,10]

我们还可以创建一个包含多种类型值的列表,如下例所示:

>>> [3,7,9,"odd",True]
[3,7,9,"odd",True]

在这里,我们创建了一个包含数字、字符串和布尔值的列表。因此,我们在一个列表中存储了异构的数据类型。我们还可以在一个列表中添加多个列表,这些被称为嵌套列表。正如术语所示,一个列表嵌套在另一个列表中,如下例所示:

>>> [1,2,3,[4,5,6],7,["hey","Python"]]

在上一个例子中,我们创建了一个包含六个元素的单个列表。我们在主列表中有整数和两个整体列表([4,5,6]["hey","Python"])。因此,这些类型的列表被称为嵌套列表。

每当您将这些列表分配给变量时,变量类型最终变为列表类型。现在,变量的类型已从内置数据类型(如intstrbool)更改为内置数据结构,即列表。

创建列表的另一种方法是使用内置的 Python 方法——list()方法——但在前面的过程中是多余的,因为我们必须将整个列表作为这个列表方法的参数。这被称为类型转换方法。如果你想将任何其他数据结构转换为列表,我们使用list()方法,如下例所示:

>>> list([1,2,3,4,5])
[1,2,3,4,5]

list()方法中,我们必须以包含元素的列表形式传递参数,这些元素使用方括号括起来。到这一点,您一定已经猜到了,Python 中可用的每个内置数据结构都必须有一个内置方法来创建其数据结构。我们使用dict()方法创建字典,使用set()方法创建集合,使用tuple()创建元组,就像list()方法创建列表一样。

由于我们在本节中揭示了创建一个名为list的简单而强大的数据结构的不同方法,让我们看看如何访问和操作其存储的数据。

访问列表元素

如果你回忆一下我们访问字符串元素的方式,你也可以在列表的情况下复制这个过程。我们在列表中使用方括号来指示它的位置,这样我们就可以提取和与特定元素交互。我们称之为索引,它被添加在这个[]括号符号内。创建新列表时也是一样的。列表的索引从 0 开始,以单位数字递增,同时从左到右遍历。与字符串一样,列表也支持负索引:

>>> winner_names = ["Chandler","Joey","Monica","Racheal","Ross"]
>>> winner_names[0] #0 is first index
'Chandler'
>>> winner_names[-1] #-1 is last element
'Ross'

当我们尝试给字符串赋值时,是无效的。与字符串不同,列表可以重新分配项目给列表。因此,我们可以说列表是可变的,这意味着它们是可改变和可修改的。这个特性使得列表成为所有数据结构中最简单和最灵活的。我们可以使用 append 方法来分配元素,这是我们在前面部分看到的,但这个方法只允许我们将元素添加到列表的末尾。如果你想要添加元素到任何特定位置,你可以通过索引和赋值语句明确告诉 Python 解释器来做这件事。

例如,如果你想在列表的两个元素之间添加loves,你可以这样做:

>>> msg = ["Joey","Monica","Racheal"]
>>> msg[1] = "loves"
>>> msg
['Joey','loves','Racheal']

因此,我们可以看到位置一的元素Monica已经被loves替换,这表明我们可以改变元素的顺序,并重新分配任何其他元素给列表。

在处理数据结构时,观察它们是一种良好的实践。我们可以将它们视为一种映射过程,列表上的每个元素都映射到某个索引。索引是位置,每当我们通过索引回溯列表时,我们就能访问这些索引的元素。即使你有一个嵌套列表,也就是说,在单个列表内有一个或多个列表,它们也会被映射到一个索引,如下面的例子所示:

>>> web_dev = [["Django","Flask"],["Laravel","Symfony"],"Nodejs","GOLang"]
>>> web_dev[0]
['Django','Flask']
>>> web_dev[1]
['Laravel','Symfony']

我们知道方括号用于访问列表的元素,但如果我们想要访问嵌套列表的元素,我们必须添加另一个方括号,以指定需要访问这些元素的索引级别,如下面的例子所示:

>>> web_dev[0][0]
'Django'
>>> web_dev[1][1]
'Symfony'

我们可以使用in关键字来检查元素是否在列表中。语句的语法会返回一个布尔值,要么是True,要么是False

>>> names = ["John","Jack","Cody"]
>>> "Cody" in names
True
>>> "Harry" in names
False

访问列表的元素更容易,但有时如果在计算正确的索引时出错,可能会得到意外的结果。因此,你必须从索引 0 开始计算列表的元素。如果你在方括号中放入不映射到任何值的索引,你将遇到一个错误,称为IndexError,如下面的例子所示:

>>> odd = [1,3,5,7,9]
>>> odd[20]
IndexError: list index out of range

IndexError消息基本上解释了我们为什么会遇到这个错误。名为 odd 的列表的索引停在 4。然而,我们传递了 20,这是没有映射到值的位置,或者简单地说,我们在这个位置没有任何元素。因此,在处理列表时,我们必须跟踪插入值的每个位置,以免遇到任何异常。然而,我们有一个解决方案来防止这种情况——只需回想一下异常处理伙伴!这就是你需要调用的,以处理这些异常,使我们的代码能够正常运行而不是崩溃。

既然我们已经学会了使用索引技术访问这些元素,那么让我们深入了解如何遍历整个列表,这是访问整个列表的一部分。首先,你必须意识到的是循环。因为我们正在处理一个包含多个数据项的列表——这意味着多次访问多个数据——我们只需要回想一下我们通常会使用的重复操作的方法。没有比循环更适合这种情况的了。因此,如果你想读取列表的所有元素,for循环是最合适的方法,例如:

>>> for number in [1,2,3,4]:
         print(number)
1
2
3
4

我们也可以在for循环中更新和完善我们的列表。以下示例是我们迄今为止学到的最重要的示例之一;确保你从中掌握每一个细微的信息:

>>> even_num, odd_num = [], []
>>> for i in range(0,10):
        if i % 2 == 0:
            even_num.append(i)
        else:
            odd_num.append(i)

>>> print(even_num)
[0,2,4,6,8]
>>> print(odd_num)
[1,3,5,7,9]

像往常一样,让我们将前面的代码分成几个部分。首先,我们声明了两个空列表,它们将是我们的偶数和奇数列表的输出列表。然后,我们使用循环来访问列表的元素。语句 range(0, 10)将生成一个包含 0 到 9 的数字的列表。这里,10 是排除位置。因此,我们逐个循环整个元素列表。如果你对递归编程的概念有困难,回想一下遍历字典部分。在每次迭代中取出列表的每个元素后,我们进入循环体并检查将确定元素是偶数还是奇数的条件。如果是偶数,我们将其追加,这意味着我们将该元素插入even_num列表中,在奇数的情况下我们也是这样做的。

哇,你意识到你刚刚做了什么吗?你使用了一个简单但强大的数据结构,并进行了线性搜索。虽然我们还有很多其他主题要讨论,但这是迄今为止我们所做的最好的事情。现在,准备好学习更多关于列表操作和方法的知识。

列表操作和方法

你还记得前一章中 Python 的类型转换方法吗?这绝对是将一种数据类型转换为另一种的最佳方法。我们已经看过字符串、它的切片技术和方法。不过,我们意识到它是不可变的。这种限制是如此强大,以至于我们无法更改该字符串的任何元素。不过,现在我们已经来到了最灵活的数据结构,它被称为list。那么,为什么不将字符串转换为列表,以便我们也可以使其可变呢。让我们使用以下示例来澄清这一点:

>>> name = "python"
>>> type(name)
<class 'str'>
>>> name = list(name) #list() method converts any data type to list
>>> type(name)
<class 'list'>
>>> name[0] = 'c'
>>> name
['c', 'p', 'y', 't', 'h', 'o', 'n']

现在,我们可以随心所欲地操作前面的列表;也许使用内置方法。不过,除了赋值之外,大多数操作与字符串的操作相似。我们在字符串部分学到了很多东西,比如切片、加法和乘法操作,甚至一些字符串方法。字符串和列表的操作相当相似——它们甚至从相同的索引 0 开始。也就是说,Python 为字符串和列表提供的内置方法并不是那么相似,为什么会呢?它们是不同类型的数据或结构。

你可以对列表进行算术运算,比如加法和乘法。不过要记住,加法只能在两个列表之间进行,而乘法必须在列表和任何整数之间进行,如下面的例子所示:

>>> even = [0,2,4,6,8]
>>> odd = [1,3,5,7,9]
>>> number = even + odd
>>> number
[0,1,2,3,4,5,6,7,8,9]
>>> ["john"] * 3
['john','john','john']

在第一个例子中,我们使用加法运算符对列表执行了连接操作。在第二个例子中,我们将列表乘以三,乘法的效果可以在该列表的内容中观察到。在我们的例子中,john被乘以三,创建了三个john值。

Python 提供的内置方法用于操作列表的值。它们通过创建列表的对象来对列表进行操作。让我们不要在这里谈论对象,我们有一个专门的章节来讨论对象。

有许多可用的内置方法可以操作列表结构,但我们将在这里介绍最重要的方法。我发现它们很有用,因为大多数开发人员在进行大型项目时只使用其中的一些。但是,如果你想发现更多,浏览文档页面总是一个好的实践。

我们已经看到如何使用append方法将元素插入列表。这个方法将元素添加到列表的末尾。但是,如果你想要插入多个元素到列表中,我们可以使用extend方法,如下例所示:

>>> list_1 = [1,2,3]
>>> list_1.append(4)
>>> list_1
[1,2,3,4]
>>> list_2 = [5,6,7]
>>> list_1.extend(list_2)
>>> list_1
[1,2,3,4,5,6,7]

在前面的代码中,extend方法将列表作为参数,并附加调用的列表的所有元素。当我们打印list_2时,我们会看到列表保持不变。

与添加元素到列表的方法类似,我们也有一个可以从列表中删除元素的方法。实际上,有两种方法可以用来删除元素。一种是通过将索引作为参数传递,而另一种是直接将要删除的元素作为参数传递。当我们使用pop方法时,我们必须传递要从列表中删除的元素的索引;但是当我们使用remove方法时,我们必须将元素传递给它,以指定需要删除的特定元素。看一下以下代码片段的例子:

>>> fruits = ["Apple","Banana","Orange","Mango"]
>>> fruits.pop(1)
"Banana"
>>> fruits
["Apple","Orange","Mango"]
>>> fruits = ["Apple","Banana","Orange","Mango"]
>>> fruits.remove('Orange')
>>> fruits
["Apple","Banana","Mango"]

还有另一种删除列表中元素的方法,就是使用简单的del关键字。警告:如果你写>>> del fruits,整个列表将被删除。确保你明确指定需要删除的元素。可以以类似于使用方括号访问元素的方式获取特定元素,如下例所示:

>>> fruits = ["Apple","Banana","Orange","Mango"]
>>> del fruits[-1]
>>> fruits
["Apple","Banana","Orange"]

Python 中有许多可用的内置函数,可以对列表执行算术和逻辑操作。使用这些函数不可避免地使代码更清晰、更可读,我们可以在一行内执行许多任务。Python 列表的一些重要内置函数如下:

>>> prime = [2,3,5,7,11,13,17]
>>> sum(prime) 
58
>>> min(prime)
2
>>> max(prime)
17
>>> len(prime)
7

在这里,sum函数将给我们一个列表元素之间的加法结果。这个方法只适用于整数和浮点值。接下来,min 和 max 函数分别给出列表的最小值和最大值。另一个重要的函数是len(),它将给出列表的长度。这个len函数适用于任何对象。此外,我们可以将它与字符串一起使用,以找到列表中字符的数量。

有时,你可能只想提取列表的特定部分或切片,例如,只提取包含 1000 个项目的列表中存储的前四个项目。在这种情况下,你必须使用切片技术,这将在下一节中介绍。

切片列表

在学习切片列表的技巧之前,让我们回顾一下如何切片字符串的部分。我们使用方括号运算符指定切片的起始和结束点。在列表的情况下,情况相当类似,如下例所示:

>>> book = "Python Games"
#lets extract Games
>>> book[7:]
'Games'

通过在方括号内添加起始索引和停止索引来对列表进行切片。在前面的例子中,停止索引元素被排除在结果切片之外。让我们做一个简单的例子,可以切片我们列表中的元素的部分:

>>> info = ["I","Love","Python","Java","NodeJS","C"]
>>> info[:3:]
["I","Love","Python"]

info[:3:]语句中给出的第二个冒号是可选的。第一个分号将两个块分开,作为起始和结束位置,但如果您不想添加step,则第二个冒号是不必要的。要了解更多关于[start:stop:step]的信息,请查看第二章 学习 Python 基础中的字符串切片技术部分。以以下代码为例:

>>> info[:3] #same result as previous
["I", "Love", "Python"]

在前面的代码中,>>> info[:3:],我们在方括号内添加了一个冒号(:)分隔符来指定列表的索引。第一个冒号前的空格是切片的起始索引;这里,我们传递了空索引,这意味着它是默认的,它将从列表的开头开始切片。我们在第一个冒号后的下一个占位符中传递了索引三,以指定切片过程的结束索引。这里,索引三处的元素是Java,但它处于排除位置,这意味着它将从列表的开头切片直到索引为二的元素。第二个冒号后的最后一个占位符指定了切片中需要包含的步骤。它的值为空,这意味着它是默认的;因此,我们得到了一个在这些索引之间没有跳过任何元素的结果。它的工作方式与字符串切片技术相同。

现在,让我们通过检查字符串对象的缺陷来了解列表的需求。我们将在下一节中看到,列表被认为比字符串更优越和更普遍

字符串和列表对象

到目前为止,我们已经涵盖了关于列表的多个主题;我们看到了如何为自己创建一个列表,并且我们看到了如何使用内置方法添加、删除和操作列表的元素。现在,让我们谈谈关于字符串和列表对象的另一个重要概念。每当我们创建任何字符串时,都会创建一个对象并将其存储在特定的内存引用中。对于程序中创建的任何字符串,Python 解析器都会为它们创建一个对象,如下例所示:

>>> name_1 = "Python"
>>> name_2 = "Python"
>>> name_1 is name_2
True

在前面的例子中,name_1name_2都指向同一个对象。因此,我们可以说它们是等价和相同的。使用相同的Python字符串创建了两个变量。这两个赋值操作并不会创建两个对象;相反,会创建一个单一的对象并映射到全局命名空间中。我们可以看到,这两个具有相同内容的变量创建了一个单一的对象:

但是在列表的情况下,即使内容相同,它们也会创建两个不同的对象,如下例所示:

>>> list_1 = ['a',1,2]
>>> list_2 = ['a',1,2]
>>> list_1 is list_2
False

您可以清楚地看到,在前面的代码中,我们得到了False的结果,这意味着这两个列表是两个不同的对象。它们并不相似,尽管它们的内容是相似的。因此,每当我们创建列表变量时,我们将它们称为列表对象,其内容是该对象的值。

最后,在本节中,我们已经介绍了我们的基本和强大的list数据结构。虽然我们还没有发现list的威力,但我们从第二章 学习 Python 基础开始就一直在使用它。你还记得我们用list来表示井字棋游戏的位置吗?因此,我们可以得出结论,即使我们有更强大和复杂的数据结构,如字典、树和队列,列表仍被认为是数据结构的女王,因为它们在简单的结构中容纳复杂的数据类型时非常有用。现在,让我们学习一下字典,它被认为是数据结构的国王

字典

对新数据结构的发现是因为先前数据结构的缺陷。让我们回顾一下列表的缺点。我们已经将元素存储在遵循某种顺序的列表结构中,并且我们必须使用索引来检索这些值。但是这些索引是虚构的。每当您想使用列表时,您都必须记住该序列的顺序,否则您将遇到IndexError异常。

现在,让我们了解一下 Python 中可用的更坚固的数据结构。字典,顾名思义,涉及以与牛津词典相似的方式处理数据结构。在我们的现实世界字典中,我们有键和值对。键是指您想在字典中搜索的单词,而值是指该单词的含义。与牛津词典类似,我们的字典数据结构中也有键和值对,并且我们将它们称为元素或项目。在列表的情况下,我们也有键和值对。键是虚构的,即索引,值是该列表的元素,如下例所示:

>>> my_list = ["python","java"]

在这里,python字符串是值,索引零是它的键。在列表的情况下,键只能是整数。在字典的情况下,键可以是任何类型。我们需要在字典结构中明确指定键。在每个键和值对之间,我们需要放一个冒号(:)。让我们创建一个字典来澄清事情:

>>> my_dict = {}
>>> type(my_dict)
<class 'dict'>

我们使用方括号[]来创建列表。但现在,我们将使用花括号{}来创建字典。我们必须使用键:值对向字典中添加项目。让我们创建一个简单的字典,其中包含人名作为键,年龄作为值:

>>> info = {"Monica" : 32, "Joey" : 29, "Ross" : 55 }
>>> info
{'Monica': 32, 'Ross': 55, 'Joey': 29} 

您可以将字典想象为一组索引和一组值之间的映射器。在这里,索引可以是任何类型,而不像列表那样只能是整数。在我们的info字典中,我们将键设置为字符串集合,将值设置为整数。

现在,让我们观察一下在前面的代码中打印出的info字典。我们可以清楚地看到输出序列与输入的顺序不同。元素位置已经交换。在这种情况下,如果元素较少,这可能不是问题。但是,如果我们创建一个包含 1,000 个项目的字典,您将清楚地观察到输出字典的顺序与输入的顺序不同。在我们的示例中,我们将Ross键添加到字典的末尾,但在打印相同的字典时,我们得到Ross: 55添加到第二个位置。因此,您可能会想知道,访问该字典的元素会有什么区别吗?一点也没有!字典是无序排列的,不像列表那样。要访问字典的元素,我们必须使用键作为标识符。访问字典的元素与访问列表的元素非常相似,但是我们不是在方括号内放置索引,而是放置键。例如,如果您想获取Monica的年龄,我们使用以下代码:

>>> info["Monica"]
32
>>> info["Joey"]
29
>>> info["Chandler"]
KeyError: 'Chandler'

我们将得到KeyError而不是IndexError,这指定字典中没有名为Chandler的键的元素。因此,访问列表可能会增加负担,因为我们必须跟踪该列表的每个可能的索引。对于长度较小的列表来说,这不是问题,但想象一下包含 10,000 个或更多元素的列表。为了克服这种开销,最好使用字典,因为它们更容易访问,而且遇到异常的几率也较小。话虽如此,字典也不是完美的数据结构,我们将在接下来的部分看到为什么大多数人更喜欢列表而不是字典。

另一种创建字典的方法是使用dict()方法。让我们看看它是如何使用的:

>>> info = dict()
>>> info
{}

我们使用内置的dict()方法创建了一个空字典。现在,让我们看看如何向该字典添加元素:

>>> info["Python"] = 1990
>>> info["C"] = 1973
>>> info["Java"] = 1970
>>> info
['Python': 1990, 'C': 1973, 'Java': 1970]

由于我们已经看到了如何使用两种方法创建自己的字典,让我们看看如何获取该字典的每个元素。由于我们的数据结构可能包含许多值,我们必须使用循环来迭代它。我们将在下一节中看看如何遍历字典。

遍历字典

由于字典包含有限数量的键和值,我们可以使用for循环来迭代它。for循环将遍历字典的键。可以使用方括号[]来提取特定键的值,并将键传递给它。让我们看看这是如何工作的:

>>> info = {'Python': 1990, 'C': 1973, 'Java': 1970}
>>> for key in info:
        print(key,info[key])

Python 1990
C 1973
Java 1970   

在前面的代码中,info[key]将提取该键的值。for循环将遍历字典的键,并且在每次迭代中,key变量将存储字典的键。然而,如果我们想要在for循环内提取键和值,我们将会得到ValueError。让我们看看我是什么意思:

>>> for key,value in info:
        print(key,value)
ValueError: too many values to unpack (expected 2)

我们得到了前面的错误,因为字典不是可迭代的。然而,我们可以将其转换为另一个数据结构,例如元组或列表,以便我们可以直接在for循环的定义中获取键和值。我们将通过将其转换为元组使这个字典可迭代,这将在即将到来的关于元组的部分中介绍。

Python 提供了一堆内置方法,以便根据您的需求操作字典。例如,如果您想要删除一个项目或向字典中插入一个项目,您不必编写自定义逻辑来实现它;相反,Python 有内置函数来实现这一点。我们将在下一节中介绍一些最重要的字典方法。

字典方法

向字典添加元素更容易,我们已经看到了一些例子。现在,让我们看看如何使用pop()方法从字典中删除一个元素。对于作为pop()键的参数,该方法将从字典中删除并返回一个元素。让我们看一个简单的例子:

>>> info = {'Python': 1990, 'C': 1973, 'Java': 1970}
>>> info.pop('C')
1973 
>>> info
{'Python':1990, 'Java': 1970}

如果要检索键的特定值,可以使用get方法:

>>> info.get('Python')
1990

我们可以调用values方法进入字典,它将返回一个对象视图,表示字典的所有值。类似于values(),我们可以使用keys()方法打印字典对象,它将表示字典的所有键:

>>> info.values()
dict_values([1990, 1970])
>>> info.keys()
dict_keys(['Python', 'Java'])

我们还可以使用len()方法,它将返回存储在字典中的项目数,如下例所示:

>>> len(info)
2

如果您想打印字典的浅拷贝,可以使用copy()方法,如下例所示:

>>> old = { "Zero" : 0 , "One" : 1}
>>> new = old.copy()
>>> new
{'Zero': 0, 'One': 1}

现在,我们已经看到了一些例子,这些例子让我们知道如何创建自己的字典,并向我们展示了如何使用各种字典方法访问它们。现在,让我们探索元组——另一个不可变的数据结构。

元组

元组在处理方面与列表非常相似,但它们是不可变的,而列表是可变的。我们可以以类似于列表的方式在元组中存储值的序列。就像我们使用[]来创建列表,使用{}来创建字典一样,我们使用()来创建元组。存储在元组中的值可以是任何类型,并且这些值都通过索引进行映射,就像列表一样。元组的第一个元素的索引是零,并且从左到右递增,同时从左到右遍历。元组的一个优点是它们是可迭代的。因此,我们可以将非可迭代的数据结构,例如字典,转换为元组,以便我们可以在循环声明中提取键和值对。

让我们创建一个简单的元组:

>>> numbers = (1,2,3,4,5)
>>> type(numbers)
<class 'tuple'>

我们还可以使用 Python 中的内置方法来创建元组。我们可以使用tuple()方法创建空元组:

>>> numbers = tuple()
>>> numbers
()
>>> numbers = tuple('abcde')
>>> numbers
('a','b','c','d','e')

如果您想创建一个只有一个元素的元组,您必须在添加这个元素后加上逗号,否则 Python 会将其视为内置数据类型,比如整数或字符串,如下面的代码所示:

>>> odd = (1,)
>>> type(odd)
<class 'tuple'>
>>> even = (2)
>>> type(even)
<class 'int'>

创建元组的另一种方法是在每个项目之间添加逗号:

>>> numbers = 1,2,3,4,5,6,7
>>> type(numbers)
<class 'tuple'>

我们对列表执行的大多数操作在元组的情况下也适用。为了访问元组的元素,我们使用方括号运算符并将索引传递给它,如下例所示:

>>> numbers[0]
1
>>> numbers[-1]
7

切片操作也可以像列表一样用于元组。这个操作将导致从元组中提取的一系列值。看下面的例子:

>>> numbers[3:]
(4,5,6,7)
>>> numbers[::2]
(1,3,5,7)

元组不支持项目赋值,这使得它成为一个不可变的数据结构,如下例所示:

>>> names = ("Jack","Cody","Hannah")
>>> names[0] = "Perry"
TypeError: 'tuple' object does not support item assignment

现在您已经了解了字典和元组,让我们看看如何将它们从一个转换为另一个。因为所有可用的数据结构都不是完美的,它们都有一些缺陷;因此,接下来的部分将是迄今为止我们所涵盖的最重要的部分之一。这是我们将在字典和元组之间执行转换的地方。

元组和字典

字典不是完美的可迭代对象,这意味着我们不能使用for循环直接从中提取键和值。我们只能从字典中提取键,但如果要提取键:值对,我们必须将其转换为另一个可迭代的数据结构。让我们看一个例子并观察结果,显示了从字典到列表的转换:

>>> person_address = {"Carl": "London", "Montana": "Edinburgh"}
>>> list(person_address)
["Carl","Montana"]

将字典直接转换为列表不会保留字典的值。它会返回一个只包含字典键的对象。由于缺少值,这些信息是无用的。让我们尝试将其转换为元组并查看结果:

>>> tuple(person_address)
("Carl","Montana")

除了使用tuple()方法将字典转换为元组,还有另一种有效的方法。我们可以使用items()方法执行相同的任务。它用于返回包含键和值存储在嵌套元组中的列表的字典对象,如下例所示:

>>> person_address.items()
dict_items([('Carl', 'London'), ('Montana', 'Edinburgh')])

现在,我们可以使用for循环在这个对象中进行迭代,并在声明的同时获取键和值,如下例所示:

>>> for key,value in person_address.items():
        print(key,value)
Carl London
Montana Edinburgh

到目前为止,我们已经涵盖了三种强大的数据结构——列表、字典和元组。接下来是;一种无序的结构,被认为是可迭代和可变的,但不存储重复元素。

通过将这种数据结构与数学中的著名概念集进行简化。在数学中,集被认为是不同实体的集合,通常被认为是对象。数字 1、2 和 3 分别是对象,但当它们组合在一起时,它们形成一个大小为 3 的单一集合。它们在 Python 中也是一样的。Python 中的集是一组既不排序也不索引的对象。

可以使用两种不同的方法创建 Python 集:

  • 第一种方法类似于创建字典的方式;我们将传递对象本身,而不是键和值对,如下例所示:
      >>> num = {1,2,3,4,5}
      >>> type(num)
      <class 'set'>
  • 另一种方法是使用 Python 的内置方法set(),您需要以列表形式传递对象序列,如下例所示:
      >>> set(['a','b','c'])
      {'c','a','b'}

在前面的代码中,我们可以看到花括号中的元素是无序的。我们在创建集合时传递的对象顺序不会被保留。它们也不支持集合中的重复项。如果集合中同一元素多次重复,只会保留一个元素,其他所有元素都将从结构中删除,如下例所示:

>>> {"laptop","mobile","mouse","laptop","mobile"}
{'mouse', 'laptop', 'mobile'}

与列表和元组不同,集合也是非索引的。如果要访问集合的元素,不能使用索引技术,因为这会引发TypeError

>>> names = {"Ariana","Smith","David"}
>>> names[0]
TypeError: 'set' object is not subscriptable

由于集合是可迭代的,我们只能通过循环来访问它们。适当的循环将是 for 循环,因为在使用它时我们不必担心终止点:

>>> names = {"Ariana","Smith","David"}
>>> for name in names:
        print(name)

Ariana
Smith
David

现在我们已经看到了如何创建和访问自己的集合,让我们深入了解可用的集合基本方法,以便我们可以操纵它们的结构。

集合方法

集合是可变的,但一旦它们被创建,就不能更改它们的项;相反,你可以向该集合添加或删除项。它与列表非常相似,但是有序的。现在,让我们从 Python 集合的最常用方法开始这个主题:

  • 我们可以向集合中添加单个和多个项,有两种方法可以做到这一点。add()方法每次只会向集合中插入一个单个项。另一方面,update()方法将同时向集合中添加多个项。元素的添加是无序的,它们可能被插入到任何位置:
      >>> favorite = {"Java","C","C#"}
      >>> favorite.add("Python")
      >>> favorite
      {'Java','C#','Python','C'}

      >>> #for update method
      >>> favorite.update(["Python","JavaScript","R"])
      >>> favorite
      {'Python','Java','R','C#','C','JavaScript'}
  • 有许多方法可以删除集合的元素。可以使用remove()discard()pop()等方法。如果要从集合中删除的项不存在,remove()将抛出一个名为KeyError的异常,但在discard()方法的情况下,我们的代码不会遇到任何错误,如下例所示:
      >>> favorite.remove('C')
      >>> favorite
      {'Python','R',"JavaScript','Java','C#'}

      >>> favorite.remove("NodeJS")
      KeyError: 'NodeJS'

      >>> favorite.discard("NodeJS")
      >>> #no error
  • 我们还可以使用pop()方法从集合中删除元素。pop()只会从集合中删除最后一个元素。然而,由于集合是无序的且没有索引,我们不知道哪个元素将成为集合中的最后一个元素。因此,使用pop()会很危险,因为我们无法知道特定项的移除。pop()将返回从集合中移除的项,如下例所示:
      >>> favorite.pop()
      'R'
  • 如果你想从集合中删除每个元素,可以使用两种方法,但这些操作的结果略有不同。可以使用del关键字加上集合的名称来删除整个集合元素以及集合结构。另一方面,clear()方法用于清空集合,但其结构不会被完全删除:
      >>> favorite.clear()
      >>> favorite
      set()
      >>> del favorite
      >>> favorite
      NameError: name 'favorite' is not defined
  • 我们还可以执行并集、交集等操作,就像在数学中一样。并集操作返回一个包含原始集合中所有元素和指定集合中所有元素的集合。集合会删除重复项。如果任何项存在于多个集合中,它将只在结果集中添加一次。你可以用逗号分隔每个集合来执行多个集合之间的并集:
      >>> set_1 = {1,2,3}
      >>> set_2 = {3,4,5}
      >>> set_1.union(set_2)
      {1,2,3,4,5}
      >>> set_3 = {4,5,6,7}
      >>> set_1.union(set_2,set_3)
      {1,2,3,4,5,6,7}
  • 我们有intersection()方法,它将导致多个集合之间共同的项目集合,如下例所示:
      >>> set_1 = {'a','b','c'}
      >>> set_2 = {'b','c','d'}
      >>> set_1.intersection(set_2)
      {'b','c'}

在前面的部分,我们已经介绍了 Python 的基础知识。到目前为止,我们已经建立了核心编程的坚实基础,但我们还不能构建一个高级游戏。

在接下来的部分,我们将深入探讨最重要的概念,不仅适用于 Python,而且适用于编程一般,那就是函数。在那一部分之后,你将拥有过程式编程的能力,这将在我们从那时起构建的每个高级游戏中非常有帮助。

函数

首先,让我们回顾一下到目前为止我们学到的所有主题,并观察过程式编程函数以及它们为什么首先是需要的。我们学会了如何使用变量、数字、模块、条件和循环创建多行语句。然而,我们并没有止步于此;我们涵盖了 Python 的所有基本数据结构,如列表、字典、元组和集合。这种编程范式将导致代码行数的丰富,有时我们可能需要一遍又一遍地调用相同的代码。看看下面的例子:

>>> 3 + 5
8
>>> 6 + 7
13

在前面的代码中,我们正在添加两个数字。每次进行加法运算时,我们需要写两个数字,然后是加法运算符。与其为许多加法操作做同样的任务,为什么不制作一个可以执行加法的单个语句,并将该语句放入我们可以多次调用它的范围内呢?这个范围代表函数。我们可以通过调用这些函数多次来调用这个语句的执行。让我们制作一个可以添加任意两个数字的函数:

>>> def add(a,b):
        print(a + b)

在前面的代码中,我们用add定义了函数。def关键字和一个名称一起用于指定 Python 解析器以创建函数。在函数的范围内,我们可以添加多个语句。现在,我们不需要每次手动添加两个数字,我们可以调用这个add函数来执行任意两个数字之间的加法。因此,这部分代码可用于可以添加任意两个数字的操作。第一个任务是声明函数,这就是我们刚刚做的;下一个任务是调用该函数。在调用该函数之前,不会执行该函数内的任何操作。您必须使用相同的函数名称来调用该函数。现在,如果您想执行add操作,您需要以相同的签名add调用它,并将两个值作为参数传递给它。如果您传递一个数字,它将作为参数传递给该函数调用:

>>> add(4,5)
9
>>> add(10,11)
21

在前面的结果中,括号内的每个数字都被传递给函数参数:a 和 b。在第一个操作中,add(4,5),4 被作为值传递给变量 a,5 被作为值传递给变量 b。

让我们将这些函数与以下咖啡机进行比较。我们将原材料,如咖啡豆、糖和水,投放到咖啡机中,咖啡机将加工这些原材料,并为我们提供一杯咖啡。与咖啡机一样,函数也接受包含值的原始参数作为输入。这些参数将用于处理,在函数内部完成,并给我们有意义的结果。有时,函数不返回任何东西;我们称这些为void

我们看了几个例子,我们通过名称调用了函数,但它们的声明是由 Python 在内部进行的。例如,以print()方法为例。我们使用这个函数在终端上向用户打印任何消息,但我们没有使用def关键字来定义它;我们只是调用它,因为它是一个内置函数。因此,如果您使用任何函数,如print()input()type(),您都是通过在其括号内传递参数来调用该函数。您可以通过参观官方 Python 文档来查看print()或 Python 的任何其他内置方法的实现。在调用input()print()时,我们将一个字符串作为参数传递给它的括号内。让我们看一个函数调用的例子:

>>> type('a')
<class 'str'>

在前面的代码中,我们使用了type调用函数。参数传递在函数的括号内。我们可以传递尽可能多的参数作为括号内的表达式,但是我们必须确保只传递所需的位置参数。在函数声明中,如果我们使用了两个参数来定义函数,那么在调用时,我们应该传递相同数量的参数。否则,它会抛出一个错误,就像下面的例子所示:

>>> def add(a,b):
        print(a+b)
>>> add(3)
TypeError: add() missing 1 required positional argument: 'b'

>>> add(3,4,5)
TypeError: add() takes 2 positional arguments but 3 were given

因此,我们可以得出结论,函数接受一个参数,根据该参数执行一些语句,并返回一个结果。在我们的add(a,b)函数中,我们在函数内部打印了结果,但是我们使用了return关键字来从函数中返回一个result,而不是在函数的范围内打印它:

>>> def add(a,b):
        c = a + b
        return c

>>> result = add(3,5)
>>> result
8

因此,我们有两种类型的函数。一种在函数的范围内打印结果,而不是从中返回结果,通常为空。虽然 Python 没有空函数的命名约定,其他编程语言称这些为空函数,这意味着它们不返回任何东西。另一种类型将产生函数的返回值。当调用函数时,应该捕获这些返回值,就像在代码中:result = add(3,5)result的值是函数的返回值。

您可能会遇到一个函数必须返回多个值的情况。我们可以使用元组结构从函数中返回多个值。让我们看一个简单的例子:

>>> def result(a,b):
        print("Before Swapping: ")
        print(a,b)
        print("After Swapping: ")
        return b,a
>>> result(4,5)
Before Swapping: 
4 5
After Swapping: 
(5, 4)

我们将在下一节学习默认参数的概念。学习这个概念将帮助我们构建更灵活的函数,因此这是一个重要的主题。

默认参数

在函数调用期间,我们通常将一个值作为位置参数传递给相应的参数。但是,如果我们犯了一个错误,传递的参数比所需的少一个或多一个,我们的程序将遇到异常。因此,总是将一些参数指定为默认值是一个很好的做法:

>>> def msg(str1,str2):
        print("I love {} and hate {}".format(str1,str2))

>>> msg("Python")
TypeError: msg() missing 1 required positional argument: 'str2'

现在,让我们看一下默认参数的威力。在使用它们之前,您应该记住默认参数必须放在参数顺序的末尾。创建默认参数的语法是argument_name = value。在前面的例子中,如果您想将str1作为默认参数,它应该放在str2之后,否则您将从 Python 解释器那里得到一个语法错误,就像下面的例子所示:

>>> def msg(str1 = "Python",str2):
        print("I love {} and hate {}".format(str1,str2))

SyntaxError: non-default argument follows default argument

正如错误消息所澄清的那样,我们不能将默认参数指定为左侧的位置参数。它们应该跟随非默认参数,就像下面的例子所示:

>>> def msg(str1,str2 = "Java"):
        print("I love {} and hate {}".format(str1,str2))

>>> msg("Python")
I love Python and hate Java

在前面的例子中,看一下我们在其中只使用一个参数调用函数的部分。现在,该参数是一个位置参数。因为它在位置一,所以它将被传递给函数的第一个参数。因此,Python值将被传递给str1参数。在Python值之后,我们什么也没传递。而不是遇到TypeError,我们能够得到一个正确的结果。这就是默认参数的威力。但是,如果在函数调用时向该默认参数传递另一个值,那么默认参数值将被覆盖为新值:

>>> msg("Python","C")
I love Python and hate C

到目前为止,我们能够使用一些位置参数来调用函数,比如 a 和 b。但是如果我们必须创建一个能够添加 200 个数字的函数呢?调用这样的函数add(a,b,c,d,..),其中每个变量代表一个数字,是不可能的。我们也会缺少变量,因为对于 200 个数字,我们必须维护 200 个变量。因此,最有效的方法是将所有这些参数打包到一个变量中,并将其作为单个参数传递给函数。然后,函数将解包该变量并执行相关操作。我们可以使用列表数据结构作为变量来存储这些多个值。我们将在下一节中看一下如何打包和解包普通和关键字参数。

打包和解包参数

让我们举一个简单的例子,这将帮助我们理解为什么我们首先需要这种打包和解包方法。在这个例子中,我们将添加数字:

>>> def add(a,b):
        result = a + b
        return result

>>> print(add(4,5))
9

我们的代码对于较少的数字可以正常工作,也许最多可以达到 10 个值。随着数字的小幅增加,需要做一些小的修改,但这没关系。但是,如果我们有 100 个数字呢?跟踪这些数字到变量中是不可能的,也不是有效的。我们的代码看起来也不专业。现在,Python 有一个名为打包参数的疯狂功能。在这里,我们谈论的是参数,即普通参数,比如列表和元组。我们可以制作一个包含多个数字的列表。让我们看看如何制作一个可以添加多个数字的函数,使用打包参数的情况:

>>> def add(*args):
        result = 0
        for item in arg:
                   result = result + item
        print(result)

>>> add(1,2,3,4,9,4,2,5,5,8)
43

让我们观察我们在这里编写的代码。*arg约定用于打包参数。在这里,args指的是参数,这是 Python 中参数的默认命名约定,但只要遵循变量命名模式的规则和约定,你可以给它取任何名字。一个单独的星号(*)是必不可少的,它表示我们正在打包成一个单一的参数。我们正在将每个项目打包到args中;因此,args将被构建为一个列表。我们知道列表是可迭代的,这允许我们使用 for 循环在其中循环。现在,在调用函数时,我们不必担心任何位置参数,甚至包含值的参数。在函数调用期间传递的每个数据片段都将使用这种方法打包到列表中。现在,我们不再受限于使用分配值给指定位置参数的参数。我们可以对每种数据类型,甚至结构执行这些打包参数技术。

解包参数的工作方式与打包类似。我们使用单个星号紧挨着参数,指定我们正在使用解包技术。在这里,参数必须是一个列表、字符串或另一个表示项目集合的结构。看一下以下示例:

>>> print(*"Python")
P y t h o n

由于参数作为字符串(Python)传递,我们解包它,以便每个元素都单独打印出来,中间带有一些空格。你也可以按照以下方式解包列表结构的元素:

>>> numbers = [1,2,3,4]
>>> print(*numbers)
1 2 3 4

因此,我们可以使用单个星号打包和解包普通参数,但是为了打包和解包关键字参数,我们必须使用双星号。用于打包和解包关键字参数的语法是**kwargs。只需记住对于普通参数使用单个星号,对于关键字参数使用双星号。args代表参数,kwargs是关键字参数的命名约定。我们将在下一节中看一些打包和解包关键字参数的示例。

打包和解包关键字参数

关键字参数指的是字典。字典不能像列表或元组那样打包和解包。字典包含键和值对;因此,它们不能以正常的方式打包和解包。为了将它们与正常参数区分开,我们使用双星号。**kwargs用于将字典的所有元素打包成单个参数。然而,我们知道字典不可迭代,换句话说,我们不能在字典内部循环并直接获取键和值对。为了检索键和值对,我们需要使用items()方法将kwargs转换为元组。我们已经在前面的部分看到了它的实现。让我们看一个简单的例子,说明如何实现打包关键字参数:

#code is written as script
pack_keyword_args.py

def about(name,age,like):
    info = "I am {}. I am {} years old and I like {}. ".format(name,age,like)
    return info

dictionary = {"name": "Ross", "age": 55, "like": "Python"}
print(about(**dictionary))

>>>
I am Ross. I am 55 years old and I like Python

在上面的例子中,我们做了两件事:我们制作了一个字典,将使用**dictionary打包成单个参数,并将每个值传递给函数的位置参数。在字典定义中,字典的键必须与制作函数时使用的参数相同,即nameagelike。即使是单个拼写错误也会导致TypeError

现在,是时候来介绍解包关键字参数了。语法将是相似的,包含双星号,后面跟着字典名称,或kwargs。由于我们正在解包,所以必须将**kwargs作为函数的参数添加进去,因为解包必须在函数内部完成。让我们看一个简单的例子来澄清这一点:

#unpacking_key_args.py
def about(**kwargs):
    for key, value in kwargs.items():
          print("{} is {}".format(key,value))

about(Python = "Easy", Java = "Hard")

>>> #output
Python is Easy
Java is Hard

在调用about函数时,我们向参数传递了一个值,就像我们在普通函数的情况下通常传递的那样。例如,Python是参数,它的值是字符串。现在,这个值被传递给about函数的参数。然而,在函数括号内没有名为PythonJava的参数。相反,有**kwargs,它将这些argument_name = value格式转换为字典。这是一种打包参数的形式。现在,在函数内部,我们必须解包它。此时,我们知道kwargs是一个不可迭代的字典。我们不能在不将其转换为元组或列表的情况下获取其key:value对。将字典转换为元组的一种简单方法是使用items()方法。现在,在使用items()方法将字典转换为元组对象后,kwargs看起来是这样的:

>>> kwargs.items()
dict_items([('Python', 'Easy'), ('Java', 'Hard')])

现在,我们正在循环遍历元组对象的这些项,每个对象都包含由逗号分隔的键和值。因此,对于每次迭代,我们都会得到键和值对,并通过适当格式化打印出来。

现在,我们掌握了不仅可以帮助我们创建自己的函数,还可以根据我们的需求修改它们的知识。如果你想使你的程序更具重用性和稳健性,必须使用打包和解包参数等方法。在这个广泛的函数式编程概念之后,现在是时候探索 Python 中的三个重要函数:匿名函数、递归函数和内置函数。让我们逐个来看看它们。我们将从匿名函数开始。

匿名函数

顾名思义,这些函数没有任何名称或签名。就像我们使用add(a,b)函数的名称来执行两个数字之间的加法操作一样,在匿名函数的情况下,这个add签名是无效的。如果您回忆一下我们使用def关键字创建普通函数的方式,在匿名函数的情况下,我们使用lambda关键字。因此,匿名函数也被称为 lambda 函数。在创建任何函数时,我们需要记住两件事:参数和表达式。参数是函数的独立和特定的输入,而表达式嵌入在函数体内。在lambda函数的情况下,我们可以传递任意数量的参数,但只能有一个表达式。这意味着lambda函数只能执行一个操作。

让我们创建一个简单的lambda函数,以便更容易地理解这些信息:

>>> square = lambda x: x**2
>>> square(8)
64

在这个例子中,square是结果的容器。由于lambda函数没有独特的签名或名称,我们应该使用这个容器作为值传递参数,也就是square。在这里,使用lambda函数的语法如下:

lambda arguments: expression

注意argumentsexpression的名称;我们不能在lambda函数内添加多个语句。如果我们尝试在lambda函数内执行多个语句,就会遇到以下错误:

>>> result = lambda x, y: x//y, x%y
Traceback (most recent call last):
  File "<pyshell#0>", line 1, in <module>
    result = lambda x, y: x//y, x%y
NameError: name 'x' is not defined

我们传递了x,y,也就是多个参数,这是完全有效的,但两个表达式x//yx%y不会被lambda执行。我们将在接下来的章节中使用这些lambda函数来创建游戏。由于本章有许多内容要涵盖,而且空间不够了,我想在这里结束这个话题;但是,我强烈建议您多练习这些类型的函数。您可以随时使用 Python 文档进行帮助。

让我们来看另一种类型的函数:递归——一种涉及使用过程、子程序、函数或算法调用自身的计算机编程技术,在一步中具有终止条件,当满足终止条件时,程序也终止。

递归函数

在本节中,我们将揭示另一种编程范式,称为递归编程。递归是一种编程方式,其中一个函数会多次调用自身,直到满足特定条件为止。在函数体内,我们将调用相同的函数本身,这使它成为递归。这与嵌套条件类似,其中在单个if条件中有另一个if..else的范围。

递归应该有一个基本或终止条件,以指定程序的停止标准。没有基本条件,我们的递归程序将无法正常运行。如果在程序执行时未满足基本条件,递归程序将导致无限循环。让我们来看一个简单的编程示例,观察递归的工作原理:

>>> def factorial(number):
        if number == 1:
            return 1
        else:
            return number*factorial(number-1)

>>> factorial(4)
24

让我们来探索前面的例子,以揭示关于递归编程的有趣事实。打印任何数字的阶乘是一个简单的例子,我们可以在学习递归编程时参考。在前面的程序中,我们有一个基本或终止条件:当数字为一时,我们返回一。这不是一个随机的陈述;相反,这是找到阶乘数的数学模式。看看下面的例子:

To find factorial of 5= 5! = 5*4*3*2*1! = 5*4*3*2*1 = 120

对于任何数字,找到阶乘的过程在遇到 1 之后结束。因此,这是一个基本条件,每当我们的程序触发它时,我们可以终止程序。在程序的else部分的范围内,我们再次调用阶乘函数,但使用不同的参数。您可以观察到我们找到 5 的阶乘的例子;每次我们进入下一步时,我们都会将该数字减一,并将其与当前数字相乘,这代表了这个语句:

>>> number*factorial(number-1)。这个条件被称为递归情况,导致了递归。

因此,有两种使用 Python 进行逻辑推理的方法:使用循环和条件语句进行基本逻辑,或者使用递归。有时,使用全新的逻辑来解决问题会很困难,在这种情况下,我们会尝试使用递归。尽管递归代码看起来更简单、更清晰,但与其他代码相比,它是一个昂贵的调用,因为在计算过程中需要大量的时间和空间。现在,让我们谈谈使用内置函数来执行操作的更快、更便宜的方法。我们已经介绍了许多内置函数,比如max()min()len()。因此,这一部分将更容易理解。

内置函数

Python 带有多个内置函数,可以直接在我们的程序中使用。我们不需要导入它们,也不需要额外的努力来执行它们。例如,我们有print()。我们之前可能不知不觉地使用了许多内置函数,但它们也是一种函数。唯一的区别是它们是由 Python 创建者制作的。它们快速,更重要的是,使用它们可以使我们的代码更简单、更清晰。只需这样想:使用我们自己的自定义方法来添加两个数字可能需要至少三行代码,但使用内置函数,我们可以在一行代码中使用sum()函数来完成。

您可以通过浏览 Python 官方文档来查看每一个内置函数。其次,我们还可以在 Python shell 中获取包含内置函数列表的信息。您可以输入以下命令>>> dir(__builtins__)来获取包含 68 个内置函数的列表。我们已经看到了其中一些最重要的函数,例如type()方法和类型转换技术。它们都是使用内置函数实现的。

我不会在本节中涵盖每一个内置函数,因为这不是本书的实际目的;相反,我们将直接进入下一个主题,这将是一个有趣的主题,因为我们将使用到目前为止学到的函数和数据结构来修改我们的井字棋游戏。然而,我强烈鼓励您通过自己学习一些内置函数来谨慎前进。它们现在可能还不重要,但在您的职业生涯中某个时候肯定会派上用场。

现在我们已经学习了数据结构和函数,我们将使用它们来修改之前构建的井字棋游戏,为其增加智能。我们将在下一节中介绍这个内容。

为我们的游戏增加智能

在本章中,我们进行了多次修改,例如添加条件语句和循环以增强代码结构和处理。然而,这还不够完美。在本节中,我们将使用本章学到的函数和数据结构来修改我们的井字棋游戏。

由于函数将通过消除代码的重复而使我们的代码长度变短,并且调试它,以后在代码中进行更改也会更容易;你可以简单地重定向到特定的函数,而不是遍历整个程序。因此,这两个特性在将游戏板打印到终端时对我们有帮助。如果你回忆一下我们在上一章中编写的代码,用于打印游戏板布局的代码被重复使用。现在,我们可以创建一个函数,将所有我们需要的代码放在里面,这样我们就可以打印出游戏板的布局,并且可以在代码中的任何时间和任何地方调用它。

我们代码的下一个实现将是为我们的井字游戏添加智能。到目前为止,如果你运行你的井字游戏,你会发现它可以由两个玩家玩。然而,两个玩家都应该使用同一台计算机,并且他们应该通过轮流来玩。现在,我们将添加计算机智能,可以作为一个玩家来玩我们的游戏。我们实际上正在制作一个玩家可以与计算机对战的游戏。

像往常一样,我们将从头脑风暴游戏的基本要素开始,收集关于游戏布局和模型的关键信息。

头脑风暴和信息收集

术语“人工智能”在技术世界中非常臭名昭著,但如果你深入研究,它其实是一堆模块和条件,决定了代理的流程。在这里,代理可以是任何做决定的东西,比如机器、人类和机器人。所有这些代理执行的动作都可以产生最理想的结果。在我们的井字游戏中,代理是一个计算机玩家,它应该采取行动,可以在比赛中击败我们的玩家。我们有一个专门的章节来学习人工智能及其理性代理,这将在我们完成基本游戏编程的学习后进行介绍。然而,在本节中,我们将创建一个简单的人工智能,可以决定最有利的移动,以击败人类玩家,或者大部分时间结束游戏为平局。

我们将采用过程式编程的方法来为系统添加智能。不要被过程式编程这个术语所压倒,它只是一种使用函数来实现目标的方法。你必须记住的一件事是,每个函数都应该只执行一个任务。例如,我们可以制作print_board()函数,每次调用它时,它只会打印游戏的布局。这个print_board()函数不会从用户那里获取输入,也不会让任何玩家成为赢家。因此,函数的存在应该通过执行一个模块化的任务来保留。我们还可以制作is_winner()函数,它将检查是否有任何玩家是赢家。

下图显示了我们的游戏如何制作一个简单的算法。在这里,我们可以看到如何检查井字游戏板上的位置,以便计算机的下一步能够产生最佳结果;接近赢得比赛,或者在最坏的情况下,使比赛成为平局,而不是计算机输掉比赛:

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

现在,我们已经形成了基本算法,这样我们就可以开始编写代码,实现我们游戏中的基本智能。我们将在下一节“智能模型的实现”中使用这些知识,以解决智能模型的问题。

智能模型的实现

首先,让我们使用函数来完善我们的代码;让我们创建一个名为printBoard()的函数。这个函数将包含一些代码行,用来打印我们井字游戏的棋盘布局:

#tic_tac_toe_AI.py

def printBoard(board):
    print('   |   |')
    print(' ' + board[7] + ' | ' + board[8] + ' | ' + board[9])
    print('   |   |')
    print('---------------')
    print('   |   |')
    print(' ' + board[4] + ' | ' + board[5] + ' | ' + board[6])
    print('   |   |')
    print('---------------')
    print('   |   |')
    print(' ' + board[1] + ' | ' + board[2] + ' | ' + board[3])
    print('   |   |')

前面的代码将打印出棋盘的布局;如果你想执行函数内部的语句,你必须调用它。在这里,我们必须使用board参数来调用它,这个参数是包含了棋盘所有位置的列表,也就是十个空位置,[' '] *10。让我们调用这个函数并观察结果:

>>> board = [' ']*10
>>> #calling the function:
>>> printBoard(board)

      |    |
      |    |
      |    |
-----------------
      |    |
      |    |
      |    | 
-----------------
      |    |
      |    |
      |    |

现在,是时候制作一个函数,来检查任何玩家是否是赢家了。我们在这里并没有制作全新的逻辑;相反,我们将在函数的范围内放置我们在前面章节中制作的所有语句。现在,每当任何用户在棋盘上做出动作时,我们可以调用这个函数来检查那个特定玩家是否是赢家。因此,函数可以消除代码的重复或重复。让我们使用isWinner()方法来检查是否有任何用户满足成为赢家的条件:

#tic_tac_toe_AI.py
#after printBoard(board) function

def isWinner(board, current_player):
    return ((board[7] == current_player and board[8] == current_player and board[9] == current_player) or
    (board[4] == current_player and board[5] == current_player and board[6] == current_player) or
    (board[1] == current_player and board[2] == current_player and board[3] == current_player) or 
    (board[7] == current_player and board[4] == current_player and board[1] == current_player) or
    (board[8] == current_player and board[5] == current_player and board[2] == current_player) or
    (board[9] == current_player and board[6] == current_player and board[3] == current_player) or
    (board[7] == current_player and board[5] == current_player and board[3] == current_player) or
    (board[9] == current_player and board[5] == current_player and board[1] == current_player))

在前面的代码中,isWinner函数的参数是board,其中包含了棋盘布局的位置和玩家的棋子,可以是X或者O。我们正在重用在上一章中编写的相同代码,只是做了一些小修改。这个方法将返回一个True或者False的布尔类型,并且我们将在玩家在游戏中做出新动作时调用它。我们使用这个方法来检查整行、整列和对角线的棋盘布局,如果有任何用户占据了它,就会返回True或者False

在井字游戏中,我们以位置的形式移动玩家,并将玩家的字符分配给它,可以是X或者O。我们在前一章中已经看到了它的实现。在这里,我们将制作一个单独的函数,用来给位置分配一个值。在下面的代码中,board代表了包含位置的游戏布局;current_player要么是X,要么是O,而move是用户的输入:

def makeMove(board, current_player, move):
    board[move] = current_player

现在,是时候让计算机玩我们的游戏了。让我们回顾一下我们在前一节中制作的算法。我们将进行多次检查:计算机是否能在下一步赢得比赛,以及人类玩家是否能在下一步赢得比赛。如果是这样,我们将阻止获胜的位置。我们不能在真正的棋盘布局游戏中执行这些操作,因为我们不希望我们的棋盘布局被填充。因此,我们将复制一份棋盘布局,以便我们可以在新的克隆棋盘布局中执行这些检查操作。让我们制作一个函数来复制原始的棋盘布局:

def boardCopy(board):
     cloneBoard = []
     for pos in board:
         cloneBoard.append(pos)

     return cloneBoard

在我们克隆了原始棋盘之后,我们必须检查计算机是否有空位可以移动。让我们制作一个函数来检查棋盘布局中的可用空位:

def isSpaceAvailable(board, move):
     return board[move] == ' '

isSpaceAvailable返回一个布尔类型:要么是True,要么是False。如果在传递的棋盘布局上可以移动,它将返回True。如果位置已经被任何玩家占据,它将返回False

现在,是时候进入我们话题的主要部分了:让计算机玩我们的游戏。让我们创建一个名为makeComputerMove()的函数,并将board参数和computerPlayer字符传递给它。这里,board代表了包含所有位置的棋盘布局,而computerPlayer是一个字符,可以是X或者O

#tic_tac_toe_AI.py

 def makeComputerMove(board, computerPlayer):
     #part 1 
     for pos in range(1,10):
         #pos is for position of board layout
         clone = boardCopy(board)
         if isSpaceAvailable(clone, pos):
             makeMove(clone, computerPlayer, pos)
             if isWinner(clone, computerPlayer):
                return pos

在前面的代码#part1中,我们检查了计算机是否能在下一步获胜。首先,我们循环遍历了整个棋盘布局的位置,并使用boardCopy函数克隆了棋盘。然后,我们将 1 到 10 的每个位置传递给isWinner函数,检查空间是否可用。我们通过使用isWinner函数检查该移动是否会使计算机玩家成为赢家,并在这种情况下返回特定的移动位置。这部分代码使我们的计算机玩家足够智能,可以根据其有利的预测决定下一步移动。

在为我们的计算机玩家增加智能的过程中,下一步是跟踪人类玩家的移动。这样做可以在棋盘上做出聪明的移动,使玩家不会轻易获胜。此外,如果人类玩家在棋盘的一行上占据了两个位置,我们可以移动来阻止第三个位置。让我们编写makeComputerMove()函数的#part2。为了检查人类玩家是否会获胜,我们必须以虚拟的方式扮演人类玩家来玩游戏。我们可以在不影响原始棋盘的情况下做到这一点,因为我们可以在棋盘的副本中扮演人类。现在,为了检查人类玩家是否会获胜,我们必须获得一个玩家字母,即XO。我们可以设置条件来检查人类玩家是X还是O。获得该字母后,我们可以在棋盘游戏的副本上以虚拟的方式扮演人类,但请记住我们是为计算机玩家编写代码。

def makeComputerMove(board, computerPlayer):
     if computerPlayer == 'X':
         humanPlayer = 'O'
     else:
         humanPlayer = 'X'

     #add part1 code here
     #now check if human player will win on next move or not in part2:
     #part2
     for pos in range(1,10):
         clone = boardCopy(board)
         if isSpaceAvailable(clone, pos):
             makeMove(clone, humanPlayer, pos)
             if isWinner(clone, humanPlayer):
                return pos

我们刚刚编写的代码将为计算机玩家增加一个智能移动。我们让计算机以虚拟的方式扮演井字棋游戏的人类玩家。我们正在检查下一步人类玩家是否会获胜。如果他们会,我们将返回该移动,以便计算机将其字母放在该位置,阻止人类获胜。

在头脑风暴和信息收集过程中,我们制作了一个流程图,以跟踪将智能嵌入我们的计算机玩家的活动。我们执行了其中的两个活动:检查获胜的最佳移动,以及阻止人类玩家的下一个最佳移动。我们还可以通过进行初始移动使计算机玩家变得更加智能,这是人类玩家通常会做的。例如,当我们玩井字棋时,我们会从中心位置开始,因为我们认为这是最好的起始位置。那么,为什么不让计算机也这样做呢?让我们编写一些代码,让计算机检查棋盘上中心位置的可用性,并相应地保留该位置。

def makeComputerMove(board, computerPlayer):
     #add part1
     #add part2
     #Occupy center position if it is available
     #part3
     if isSpaceAvailable(board, 5):
         return 5   

我们可以通过检查角落位置的可用性使这个计算机玩家变得更加智能。棋盘上的角落位置是[1,3,7,9]。由于我们的棋盘上有四个角落,我们维护了一个列表来跟踪它们。现在,让我们创建一个新的getRandomMove()函数,它将接受棋盘和移动作为参数。移动参数将以列表的形式提供,例如角落位置。

#tic_tac_toe_AI.py
 import random
 def getRandomMove(board, moves):
     availableMoves = []
     for move in moves:
         if isSpaceAvailable(board, move):
             availableMoves.append(move)

     if availableMoves.__len__() != 0:
         return random.choice(availableMoves)
     else:
         return None       

在前面的代码中发生了很多事情,所以让我们把事情分解成更简单的部分。首先,这个方法将接受以列表形式提供的移动,即[1,2,3,4,5];我们必须使用这个函数选择其中一个元素。然而,这个列表的元素不仅仅是数字;它们是棋盘布局的移动或位置。因此,我们必须检查每个移动的空间是否可用。如果有可用空间,我们将该移动添加到一个名为availableMoves的新列表中。筛选完成后,我们进行条件检查,以确定是否有任何可用移动。

>>> availableMoves.__len__() != 0表达式与len(availableMoves)相同,它将返回列表的长度。我们称这些实现(__len__())为数据模型,我们将在即将到来的专门章节中进行介绍。如果availableMoves的长度为零,我们将返回None。但如果不为零,我们将执行一个表达式。让我们将这个表达式分解成片段:

  • import random: 如果你回忆一下第二章的主题,学习 Python 基础,我们导入了 math 模块来执行数学计算,比如平方根和阶乘,我们使用import math命令导入 math 模块。现在,我们正在导入一个 random 模块,这意味着我们可以使用该模块中定义的方法。从 random 模块调用方法的语法是random.method_name()

  • random.choice(): choice 方法将从被调用的元素列表中随机选择一个元素。例如,执行以下命令将从传递给它的值范围中随机选择一个值:

      >>> import random
      >>> random.choice([1, 2, 4, 5, 6])
      5
      >>> random.choice([1, 2, 4, 5, 6])
      2
  1. 我们将availableMoves传递给它,以便choice方法可以随机选择任意一个移动。这对我们的游戏至关重要,因为有时计算机必须随机做出决定。

现在,让我们在makeComputerMove函数中调用getRandomMove函数。如果你浏览一下makeComputerMove函数的代码,我们已经添加了一个语句,将帮助计算机占据中心位置。角落位置呢?它们也是井字棋游戏的重要位置。如果我们占据了棋盘的中心和角落位置,我们的计算机将有很高的获胜几率。因此,我们必须增强我们的代码,使计算机玩家占据角落位置。由于角落位置是[1, 3, 7, 9],我们必须将其作为列表参数传递给我们刚刚创建的getRandomMove函数:

#tic_tac_toe_AI.py
 def makeComputerMove(board, computerPlayer):
     #add part1
     #add part2
     #add part3
     #code to occupy corner positions
     move = getRandomMove(board, [1, 3, 7, 9])
     if move is not None:
         return move

     #moves for remaining places ==> [2, 4, 6, 8]
     return getRandomMove(board, [2, 4, 6, 8])

在前面的代码中,我们添加了代码,将在任何角落位置上获取随机移动。我们已经涵盖了中心位置[5]和角落位置[1,3,7,9]的玩家移动;现在,我们还剩下边缘位置[2,4,6,8]。我们调用了getRandomMove函数,它将从传递的列表中选择任意一个随机移动。

在前面的章节中,我们学到了许多东西,比如循环、条件语句等等。在下一节中,我们将编写一些代码来使用它们来控制程序流程。我们将称之为主函数

使用主函数控制程序流程

我们编写了许多函数,比如makeComputerMoveisWinner等等,但它们还没有被调用。我们知道在调用函数之前,函数不会执行其中的代码。因此,我们将创建一个新的函数,来处理程序的流程。通常我们称之为主函数,但你可以随意命名。我们在之前的章节中编写的代码,比如主游戏循环或切换玩家回合,将嵌入到这个主函数中。唯一需要显式调用的函数就是这个主函数。让我们现在创建一个:

#tic_tac_toe_AI.py
 def main():
     while True:
         board = [' '] * 10
         player, computer = 'X', 'O'
         turn = "human"
         print("The " + turn + " will start the game")
         isGameRunning = True

         while isGameRunning:
             if turn == "human":
                 printBoard(board)
                 move = ' '
                 while move not in '1 2 3 4 5 6 7 8 9'.split() or not 
                  isSpaceAvailable(board, int(move)):
                     print('What is your next move? (choose between 1-9)')
                     move = int(input())
                  makeMove(board, player, move)
                  if isWinner(board, player):
                      printBoard(board)
                      print("You won the game!")
                      isGameRunning = False
             else:
                 #computer turn       

我们以前多次编写了前面的代码,比如在打印棋盘、切换玩家和创建获胜者时。不同之处在于,这里我们使用了函数。我们有一个与一个函数相关的任务,比如isWinner,它检查玩家是否获胜,而不是编写整个代码来检查获胜者,我们只需编写一次并在主函数中使用它。您可以看到我们已经编写了一些代码来从用户那里获取输入作为棋盘游戏的移动值。我们可以制作一个函数来从用户那里获取输入。现在让我们来做,使主函数更清晰、更易读:

def makePlayerMove(board):
     move = ' '
     while move not in '1 2 3 4 5 6 7 8 9'.split() or not 
      isSpaceAvailable(board, int(move)):
         print('What is your next move? (choose between 1-9)')
         move = int(input().strip())
         return move

现在,让我们将这个新创建的函数添加到主函数中。我们还将完成代码的else部分,让计算机玩我们的游戏:

def main():
     while True:
         board = [' '] * 10
         player, computer = 'X', 'O'
         turn = 'human'
         print("The " + turn + " will start the game")
         isGameRunning = True

         while isGameRunning:
             if turn == 'human':
                 printBoard(board)
                 move = makePlayerMove(board)
                 makeMove(board, player, move)
                 if isWinner(board, player):
                     printBoard(board)
                     print("You won the game!")
                     isGameRunning = False
                 else:
                     printBoard(board)
                     turn = 'computer'
             else:
                 move = makeComputerMove(board, computer)
                 makeMove(board, computer, move)
                 if isWinner(board, computer):
                     printBoard(board)
                     print('You loose!')
                     isGameRunning = False
                 else:
                     turn = 'human'

 main() #calling main function

现在,让我们运行游戏,并与我们定制的 AI 代理对战。以下插图显示了我们游戏的输出,并显示了新的井字棋棋盘布局。这是通过对printBoard进行函数调用实现的:

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

现在,我们已经制作了一个足够吸引任何玩家玩游戏的布局。但是,还有一些修改可以进行,这将在下一节中介绍。

游戏测试和可能的修改

我们在本章中制作的游戏已经足够可以与计算机对战。在游戏中使用 AI 主要是解决游戏在与环境交互时可能面临的所有可能情况。在我们的井字棋游戏中,与国际象棋或围棋相比,我们的走法并不多,因此制作 AI 代理更容易。通过制作一个能够做出两个聪明的举动的 AI,我们能够与人类竞争,比如检查下一个最佳走法以获胜或通过模拟阻止人类获胜。如果您想知道模拟是什么,您将不得不回想一下我们刚刚实现的算法,以检查人类玩家是否会在下一步获胜。此外,计算机玩家在克隆棋盘上扮演人类玩家,并像人类一样进行虚拟游戏。这就是模拟,我们让计算机模仿系统的真实过程或行为。

通过模拟预测最佳走法后,我们的程序会为计算机玩家返回最佳的下一个可能走法。让我们进一步推广这种技术。我们在游戏中所做的是制作一个能够制作模拟环境以预测下一个最佳走法的 AI。相同的技术应用于整个范围的 AI 应用,例如自动驾驶汽车;我们在计算机内部制作了一个模拟环境,汽车是一个代理,将根据障碍物做出左转或右转的决定。井字棋在与环境交互时比较简单,因为它的走法或情况较少,但是编写自动驾驶汽车模拟需要我们认识到在道路上驾驶汽车时可能出现的一整套情况。因此,我们可以得出结论,AI 主要是关于创建一个程序,其中代理必须考虑与环境交互时可能面临的所有情况,并对每种情况做出响应。

我们的竞争对手足够聪明,使游戏对玩家来说更加困难,但人类也拥有让电脑玩家受限的终极力量。人类玩家不会让电脑轻易获胜。因此,大部分时间我们的游戏会以平局结束。然而,如果你运行游戏,你会发现我们还没有处理这些情况。现在,每当我们的游戏是平局时,而不是停止游戏,我们的游戏将不断要求用户输入。相反,我们必须给用户一个消息,告诉他们再试一次,并帮助用户再次玩我们的游戏。为了检查平局条件,我们必须检查棋盘是否已满。当棋盘位置全部被占满且没有人获胜时,我们就有了平局条件。我们可以制作另一个函数来检查棋盘是否已满:

def isBoardOccupied(board):
    for pos in range(1,10):
        if isSpaceAvailable(board,pos):
            return False
    return True

上面的isBoardOccupied()函数将根据检查返回一个布尔类型,要么是True,要么是False,这将确定棋盘是否已满。如果棋盘已满,它将返回True,如果没有,它将返回False。我们正在使用我们在前一节中创建的isSpaceAvailable()函数,它将检查棋盘上是否有空位。现在,让我们用这个新函数来完善我们的代码:

def main():
     while True:
         # add the code here from part1
         while isGameRunning:
             if turn == 'human':
                 move = makePlayerMove(board)
                 makeMove(board, player, move)
                 if isWinner(board, player):
                     printBoard(board)
                     print("You won the game!")
                     isGameRunning = False
                 else:
                     if isBoardOccupied(board):
                        print("Game is a tie")
                        break
                     else:
                        turn = 'computer'

             else:
                 move = makeComputerMove(board, computer)
                 makeMove(board, computer, move)
                 if isWinner(board, computer):
                     printBoard(board)
                     print('You loose!')
                     isGameRunning = False
                 else:
                     if isBoardOccupied(board):
                        print("Game is tie")
                        break
                     else:
                        turn = 'human'

 main() #calling main function

总结

本章内容简洁而扼要,包含了丰富的信息,从数据结构到函数。这些主题是任何复杂程序的基石,因此,我们将在接下来的每个游戏中使用它们。我们从学习数据结构的必要性开始,深入探讨了 Python 的基本数据结构,如列表、字典、元组和集合。我们讲解了如何创建这些数据结构并对其进行操作。

我们学习了创建用户定义函数、调用它们和记录它们的方法。我们还看到函数就像机器,你可以输入原始数据,然后得到有意义的输出。我们看到了使用位置参数和默认参数输入数据到函数的方法。然后,我们看到了通过打包和解包普通和关键字参数来修改我们的函数,以便从中获得最佳性能。

我们还使用函数和数据结构进一步修改了我们的游戏,并制定了可以应对不同游戏情况的简单算法。我们让我们的电脑玩家足够聪明,可以击败我们的人类玩家。然后,我们还制作了一个模拟环境,其中一个代理可以测试和训练自己,以预测下一个最佳移动。虽然我们的游戏制作起来很简单,但它给了我们许多关于需要进行的流程的想法,例如头脑风暴、设计、编码基础和分析,然后我们才真正开始编写模块化代码。

最后,我们讲解了过程式编程,指的是使用函数来构建程序。在下一章中,我们将讲解基于 curses 的过程式编程。我们将使用与终端无关的屏幕绘图和基于文本的终端来创建程序。我们将使用 curses 事件和屏幕绘图来制作一个贪吃蛇游戏,然后利用 curses 属性来制定玩贪吃蛇游戏的逻辑。

你是否对进入下一章感到兴奋?它将带你进行一次冒险之旅,学习使用 curses 模块进行游戏编程,以及如何处理用户事件和游戏控制台。在那之前,我强烈建议你参考官方 Python 文档,并浏览 Python 内置的数据结构和模块;并且在没有额外帮助的情况下进行练习。到目前为止我们所学到的知识将在本书的各章中使用,所以现在是时候复习我们迄今为止学到的主题了。

第五章:通过构建贪吃蛇游戏来了解 curses

每当开发人员编写游戏或应用程序时,他们可能需要重复使用代码的某些部分。例如,当我们希望玩家在游戏控制台内移动时,他们多次使用左右箭头键。因此,我们需要能够处理此类事件并对其进行处理的代码。多次编写相同的代码来处理相同的操作不支持不要重复自己DRY)原则,因此我们需要使用可以多次调用以多次执行相同操作的函数。

为了方便起见,这些函数被捆绑到称为模块的容器中。正如您可能还记得上一章所述,我们在大多数程序中使用了模块。例如,通过使用random模块函数,我们能够在特定范围内获得随机数;另一方面,数学模块使我们能够执行不同的数学计算。在本章中,我们将介绍另一个模块,称为 curses。它将为我们提供一个接口,我们可以在其中处理 curses 库,该库包含直接与 Python 终端交互的函数。这意味着我们可以制作一个简单的基于终端的游戏。

本章将涵盖以下主题:

  • 了解 curses

  • 启动 curses 应用程序

  • 使用 curses 进行用户输入

  • 使用 curses 制作贪吃蛇游戏

  • 游戏测试

技术要求

您将需要以下内容才能充分利用本章:

查看以下视频以查看代码的实际操作:

bit.ly/2oG1CVO

了解 curses

Curses 是一个终端控制器库,允许我们编写基于文本的应用程序。术语“终端”与任何平台无关,因此 curses 可以在任何操作系统上使用。使用 curses,开发人员将能够直接编写应用程序,而无需与终端交互。curses 库是以控制字符的形式发送命令的媒介,同时确定应该在哪个操作系统或终端上执行。

在 Python 中,我们有两个名为 windows-curses 和 unicurses 的库。这两个库都提供了可以为输出终端屏幕设置所需外观的函数。它们使用控制序列进行更新。简而言之,开发人员将设计输出窗口屏幕的外观,并调用函数使 curses 发挥作用。因此,在基于 curses 的应用程序中,我们不会得到像我们期望的那样用户友好的输出,因为我们只能使用 curses 库编写基于文本的应用程序。因此,您使用 curses 编写的任何游戏都将在终端中运行,即 Windows 的命令提示符或 Linux 的终端。

Python 的curses库将允许我们编写基于文本的用户界面,并通过用户输入控制屏幕。在本章中使用的库将帮助我们控制屏幕移动并处理用户事件或输入。从 curses 构建的程序将不具有类似于现代 GUI 应用程序或 Android 应用程序的功能,这些应用程序具有诸如文本视图、标签、滑块、图表和模板等小部件。相反,它将提供简单的小部件和工具,如命令行界面CLI),其中大部分在仅文本应用程序中找到。

Python 的 curses 模块是 C 编程语言的 curses 的一个适应。唯一的区别是使用 Python;一切都可以在不需要我们深入了解低级例程的情况下完成。我们可以调用接口来调用函数,这些函数将依次调用 curses 来处理用户操作。

在处理 curses 时,窗口屏幕被视为字符矩阵。每个窗口界面由程序员设置,并包括高度、宽度和边框。设置这样的坐标后,程序员将调用 Python curses 来更新该屏幕。使用诸如文本视图、按钮和标签之类的小部件也是以相同的方式进行的;也就是说,我们将初始化应在窗口中放置的坐标,并调用 curses 相应地更新它。要处理来自 curses 的用户输入,我们必须导入它。我们可以轻松地导入诸如 RIGHT、LEFT、UP 和 DOWN 等操作,并根据程序的需要处理它们的行为。在大多数游戏中,这些事件将为游戏角色提供移动。我们将在本章末尾介绍的游戏是贪吃蛇游戏,而蛇本身将是我们的主要角色。这意味着 LEFT、RIGHT、UP 和 DOWN 等操作将使蛇移动到新位置。Python 的 Windows 版本没有内置的 curses 模块,也不以相同的名称提供。但是,有两个兼容的模块可用,它们执行相同的功能。这些称为 unicurses 和 windows-curses。我们将在本章中使用后者。

让我们通过制作一个简单的应用程序来开始学习 curses。我们将制作一个简单的hello程序,该程序将在 curses 终端中打印。

启动 curses 应用程序

我们将使用 Python 中未预打包的模块构建应用程序。因此,我们必须在我们的机器上手动安装该软件包。安装 Python 时,应该已经自动在您的机器上安装了一个称为 pip 的软件包管理系统。此管理工具用于安装和组织用 Python 编写的库。因此,如果您想在程序中使用任何第三方库或依赖项,您必须使用 pip 工具进行安装。安装任何软件包的方法很简单。您只需编写pip install命令,后跟您希望安装的库的名称。库的名称区分大小写,因此在编写库的名称时不应出现错误。如果您想检查库中编写的代码,只需搜索该库的文档。您将获得有关该库的信息,以及可在程序中使用的函数。

我们将使用 windows-curses 库来编写基于文本的程序,因此我们必须使用pip命令安装该软件包。如果您的机器是 Windows,则应在命令提示符中执行 pip 命令;如果您使用的是 Mac OS X 或 Linux,则应在您的机器的终端中执行 pip 命令。以下屏幕截图显示了我们需要执行pip命令的方式:

类似地,在 Linux 机器上安装 curses,您可以打开终端并运行以下命令:

$ sudo apt-get install libncurses5-dev libncursesw5-dev

现在,我们将能够使用 curses 模块编写程序。此时,我们安装的 curses 模块将以与其他内置模块(如 math 或 random)相同的方式可用。与内置模块类似,我们可以简单地导入 curses 模块并开始调用其中定义的函数。以下步骤解释了创建任何 curses 应用程序的路线图:

  1. 让我们首先导入 curses 并查看它是否已正确安装。我们用于导入任何模块的命令是import后跟模块的名称。我们的模块名称是 curses。因此,命令将如下所示:
 >>> import curses
 >>> #no any error
  1. 我们可以得出结论,它已成功导入,因为 Python 解析器没有抛出错误。现在,我们可以使用这个模块来编写程序。让我们编写一个简单的程序来观察 curses 模块的工作过程:
      #program is written as Scripts
      # curser_starter.py

      import curses
      import time
      window_screen = curses.initscr()
      window_screen.clear()

      time.sleep(10)

我们不能直接从 Python IDLE 运行任何 curses 应用程序。要运行它,你必须导航到存储 Python 文件的文件夹,并双击该文件以打开它。你会得到一个空白屏幕,顶部有一个光标,它将在那里停留 10 秒。10 秒后,同样的空白窗口屏幕将从屏幕上弹出。该屏幕将产生可以用 curses 编写的基于文本的应用程序。

让我们看看我们之前的代码,并揭示 curses 的有趣函数:

  • 首先,像往常一样,我们导入了我们想要在程序中使用的模块。我们在这里导入了两个模块:curses 和 time。curses 模块为我们提供了不同的可用函数,可以用来编写基于文本的应用程序,而 time 模块为我们提供了不同的可用函数,可以用来更新我们的输出屏幕行为。在这个程序中,我们调用了time模块的sleep方法,它将保持屏幕的输出,持续的时间是括号内传递的时间(在我们的例子中是 10 秒)。10 秒后,我们的输出屏幕将消失。

  • 在使用 curses 编写任何代码之前,它应该被初始化。调用initscr()函数将初始化 curses。因此,对于任何 curses 应用程序,我们应该在代码的第一行初始化 curses。这个初始化代码将返回一个窗口对象,代表我们程序的输出屏幕。在这里,这个初始化被窗口对象window_screen捕获,它代表了我们输出终端的屏幕。因此,对 curses API 的任何函数调用都应该使用window_screen。第一个调用是使用clear()函数。

我们成功创建了一个游戏屏幕,并用一个方法调用来保持它。然而,当前屏幕的可修改性还不够。作为程序员,我们可能希望通过明确指定高度和宽度来定制屏幕。幸运的是,Python 的 curses 模块提供了另一种方法来实现这一点,即newwin方法。我们将在下一节中学习它。

新屏幕和窗口对象

initscr()函数的调用返回的窗口对象代表了输出窗口的整个屏幕。这个窗口对象还支持不同的方法,可以向窗口显示文本,修改它,接受用户的事件和更新位置等。这个initscr()函数的唯一缺点是我们不能将屏幕的自定义高度或宽度传递给它。它只代表了输出终端的默认整个屏幕。

有时,我们可能希望游戏屏幕被定制,使其高度为 20,宽度为 60,例如。在这里,高度和宽度可以被视为列和行,其中每个单位代表矩阵中的一行。由于我们传递了宽度为 60,将会有 60 条水平线。高度为 20 也是一样的;将会有 20 条垂直线。你也可以将它们表示为像素。要创建一个新的屏幕,这可能是我们在制作 curses 应用程序时要做的事情,因为initscr()函数不会为我们做这个,我们必须调用新函数。这个函数将根据指定的坐标将更大的窗口屏幕分成一个新的窗口。这个函数的名称是newwin(),字面意思是新窗口,它接受四个参数,即高度宽度YX。它们的传递顺序是YX,这与其他库相比是不寻常的。Y值是列的位置,而X值是行的位置。看一下下面的图表,它解释了YX的值:

因此,通过增加Y的值,我们向下移动,这与矩阵中的列相同。同样,通过增加X的值,我们向屏幕的右侧移动,这与矩阵中的行相同。正如我们所看到的,curses 以字符矩阵的形式存储窗口屏幕。我们可以使用这些坐标来表示游戏显示的位置,以及游戏角色。例如,如果您想让您的玩家在(5,0)的位置移动,就像前面的图表中所示的那样,您将调用move(5,0)函数来实现。记住参数传递的顺序。Y的值后面是X,如果您在其他库中有游戏编程的背景,这可能会让您感到困惑。

例如,我们将创建一个程序,在其中使用newwin()函数在大屏幕内创建一个新屏幕。此函数内的四个参数分别是heightwidthyx。记住这个顺序,因为我们必须以类似的方式传递它:

height = 20
width = 60
y = 0
x= 0

screen = curses.newwin(height, width, y, x)

现在,是时候编写一个简单的程序,可以向我们的 curses 应用程序添加一些文本了:

# text_app.py
import curses
import time

screen = curses.initscr()
curses.noecho()
curses.cbreak()
screen.keypad(True)
screen.addstr(0,0, "Hello")
screen.refresh()
time.sleep(10)
curses.endwin()

让我们逐行观察前面的代码,并了解我们使用的每个方法,如下所示:

  • 首先,我们导入了两个重要的模块:curses 和 time。之后,我们使用initscr()函数初始化了窗口对象。noecho()函数将关闭应用程序中的自动回显过程。这是必要的,因为当用户玩游戏时,我们不希望他们向我们展示他们按下了什么键;相反,我们希望他们根据该事件执行操作。下一个函数调用是cbreak()。这种模式将帮助我们的程序立即对用户的输入做出反应。例如,在 Python 的input()方法中,除非我们在键盘上按下Enter,否则此方法不会执行任何操作。然而,在cbreak()函数的情况下,它将帮助应用程序立即对任何输入键做出反应,而无需按Enter。这很重要,因为我们必须制作一个用户可以立即得到响应的游戏。例如,如果用户按下 DOWN 键,游戏角色必须立即向下移动。这与缓冲输入函数不同,后者将接收所有输入并将其存储在一个缓冲区中,只有在用户按下Enter时才会做出反应。

  • 下一个函数调用是keypad()函数。我们通过传递 True 作为参数启用了键盘模式。每当我们在终端中按下任何键时,它都以多字节转义序列的形式返回数据。例如,Esc发送\x1b。这是 1 个字节。Page Up发送\x1bH。这是 3 个字节。为了处理终端返回的这种数据,curses 使用了一个可以手动导入的特殊值。例如,要处理键盘上按下的 DOWN 键,我们可以将其导入为curses.KEY_DOWN。这是通过启用键盘模式来实现的。

  • 之后,我们调用了addstr()函数。这个函数将在其调用期间指定的位置将一个字符串添加到输出屏幕上。我们向它传递了三个参数。记住,前两个参数的顺序是 y,x。传递的最后一个参数是需要添加到(y,x)位置的字符串。我们传递了一个值为(0,0),这意味着字符串将被添加到输出窗口的左上角。我们调用的下一个方法是refresh(),它将更新窗口对象screen的字符矩阵。如果你仔细看代码,你会发现每当我们添加或刷新屏幕的内容时,我们都是使用一个窗口 curses 对象来做的,这个对象是使用initscr()函数初始化的。然而,终端的行为已经被 curses 模块改变了。例如,为了改变终端的默认回显行为,我们直接从 curses 模块而不是从窗口光标对象中直接调用了noecho()函数。

现在,让我们运行我们的代码来观察结果。确保你从终端或命令提示符中运行你的应用程序,使用filename.py

你可以将位置从(0,0)改变为任何其他值,比如(5,5),来观察窗口和填充格式。最后,我们用 curses 制作了我们的第一个程序。

现在,是时候探索 curses 的另一个特性了,这个特性是基于处理用户输入的能力。# 使用 curses 处理用户输入在任何游戏中,用户输入是需要正确处理的最关键的信息之一。在处理这些类型的操作时,我们不能有任何延迟。

在 curses 的情况下,我们有两种方法可以从用户那里获取输入。这两种方法如下:

  • getch(): 如果你有 C 或 C++等语言的编程背景,这对你来说应该不是什么新鲜事。getch()函数就像在 C 中一样,用于创建一个持续监听用户按键的监听器。它返回一个从 0 到 255 的整数,代表被按下的键的 ASCII 码。例如,a的 ASCII 码是097。大于 255 的值是特殊键,例如Page Up和导航键,即 UP、DOWN、LEFT 和 RIGHT。我们可以将这些键的值与 curses 中存储的常量进行比较;例如,curses.UPcurses.DOWNcurses.LEFTcurses.RIGHT
  • getkey(): getchgetkey做的是同样的事情,但是getkey函数会将返回的整数转换为字符串。诸如 a-z 或 A-Z 之类的普通键将作为一个包含一个字符的字符串返回,可以与ord()函数进行比较。

然而,特殊键或功能键将作为一个更长的字符串返回,其中包含一个键并表示动作的类型,比如KEY_UP。让我们写一个可以处理键盘事件的程序:

#program3.py
import curses as c
screen = c.initscr()
win = c.newwin(20, 60, 0, 0)
c.noecho()
c.cbreak()
screen.keypad(True)
while True:
  char = screen.getch()
  #takes input  
  if char == ord('q'):
    break  
  if char == ord('p'):      
    win.addstr(5,10, "Hello World")      
    win.refresh()
screen.endwin()

当我们讨论使用 True 循环时,我们讨论了这段代码。如果你对任何这些命令感到困惑,请确保复习前面的主题。

你可能会观察到这段代码中的一个奇怪的地方是,我们导入了 curses 并给它起了一个别名 c。这是重命名模块的过程。现在,我们可以在每个方法调用时使用c.method_name(),而不是在每次调用时使用curses.method_name,这当然消除了每次写相同模块名的开销。

在循环内,我们使用getch()函数从用户那里获取输入。之后,字符被存储在char变量中,我们将其与ord函数的返回值进行比较。记住,getch函数将返回一个 Unicode 值?ord函数也是如此。它接受一个字符作为参数,并返回该字符的 Unicode 值。我们使用条件语句来进行条件判断。因此,如果用户在键盘上按下q,我们将结束程序,如果用户在键盘上按下p,我们将在输出窗口的位置(y,x)打印Hello World

让我们运行我们的 Python 文件,C:\User\Desktop> python program3.py,并查看输出:

按下键盘上的q键来终止循环并关闭应用程序。

请注意,qQ不同,因为这些字符的 ASCII 码不同。

我们的代码运行得很好,但变得越来越长,尽管应用程序很简单。我们已经调用了很多方法,比如noecho()cbreak()keypad()endwin()。为了消除调用这么多函数的开销,我们可以使用 curses 模块中的包装函数。所有这些函数,包括 curses 对象的初始化,都是由包装函数自动完成的。只需记住,包装函数是一个包含所有这些方法的捆绑调用。

同样,我们也可以使用 curses 模块来处理鼠标事件。让我们使用包装函数编写一个程序,并在同一个程序中处理鼠标按钮的事件:

#mouse_events.py

import curses as c
def main(screen):
  c.curs_set(0) #hides the cursor
  c.mousemask(1)

  inp = screen.getch()
  if inp == c.KEY_MOUSE:
      screen.addstr(17,40, "Mouse is clicked")
      screen.refresh()
  screen.getch()

c.wrapper(main)

让我们详细看一下前面的代码:

  • 我们将从最后一行开始,我们在那里使用了可调用对象调用了包装函数。我们已经了解了wrapper()的目的;它消除了多个函数调用,比如initscr()noecho()等等。因此,使用包装函数进行调试更容易。不仅如此,这个函数还通过 try 和 catch 块在内部处理异常。每当你遇到一个未知的异常,你可能没有捕获到,你总是可以信任包装函数来处理。这将识别程序的错误并提供异常消息,而不会使应用程序崩溃。包装函数的参数将是一个可调用对象,这里是主函数。这个主函数有一个screen参数,它是 curses 窗口对象。我们没有在程序的任何地方使用initscr()函数初始化 curses 对象,因为这是由包装函数在内部完成的。

  • 在主函数的范围内,我们调用了两个方法:curs_set(0),它将隐藏输出屏幕上的光标,以及mousemask(1),它将接受鼠标事件。在这里,鼠标事件将是特殊符号或功能字符,与正常的字母字符不同。因此,curses 已经定义了常量来表示这些功能字符。这与 UP 键盘键相同;我们有KEY_UP常量;在鼠标事件的情况下,我们有KEY_MOUSE常量。这些应该从 curses 模块中调用,例如curses.KEY_MOUSE。在我们获得这样的鼠标事件之后,我们将在输出终端上打印鼠标被点击getch()方法将输入任何可能是与鼠标相关或键盘按钮的事件。让我们运行程序以获得以下输出:

现在我们已经获得了足够的知识来使用 curses 制作游戏,让我们继续下一部分,这将让我们了解游戏逻辑是如何在底层实现的。我们将制作一个简单的贪吃蛇游戏。

使用 curses 制作贪吃蛇游戏

我们已经知道编写游戏的过程并不像看起来那么容易。我们必须遵循许多程序来使游戏可玩,因为在将游戏暴露给环境时,我们可能会被许多不需要的和意外的异常所淹没。因此,遵循正确的执行顺序总是至关重要的,即使可能比平常花费更多的时间。在本节中,我们将使用 curses 创建一个贪吃蛇游戏。在即将到来的章节中,我们将对其进行修改,使其成为一个更具吸引力的游戏。一个好的游戏并不总是意味着一个良好的用户界面,因为界面为用户提供价值,而不是程序员。我们必须养成编写良好代码并制作良好界面的习惯,这要求我们遵循本节中将要介绍的每一步。我们将使用 curses 模块来制作最初的贪吃蛇游戏。然后,在下一章中,我们将使用面向对象编程对其进行修改。

在编码之前,我们必须收集有关游戏模型和界面的信息。在建模过程中,我们必须提取关键信息,比如如何将游戏字符渲染到屏幕上如何制作事件监听器,以及如何制作允许游戏角色移动的逻辑。我们将在下一部分中涵盖所有这些内容。

头脑风暴和信息收集

就像我们一直在做的那样,第一步是头脑风暴和收集关于游戏布局和游戏模型的关键信息。在贪吃蛇游戏中,我们有两个角色:蛇(玩家)和它的食物。每当蛇吃食物时,它的长度应该增加。这是基本的想法。现在,让我们复习一下我们可以使用的资源。显然,Python 提供的资源更加丰富,但我们还没有学会如何制作图形字符并在游戏中使用它们。我们只学会了如何在基于文本的终端中制作游戏。我们可以使用 A-Z 等字符来指定游戏对象。例如,我们可以制作蛇XXXXXXX,这是 X 的组合。食物可以用O表示。让我们看看这在我们的游戏控制台中会是什么样子:

我们还必须决定游戏的屏幕。initscr()方法将创建整个屏幕作为 curses 对象。我们不希望这样;相反,我们希望制作一个可以通过高度、宽度和 y、x 位置自定义的游戏屏幕。正如您可能记得的那样,我们可以使用newwin()方法将屏幕分成一个新的屏幕。

最重要的是要记住跟踪坐标,因为我们必须为我们的游戏玩法制作一个边界。我们可以制定一些规则,指定游戏角色的边界位置,如果它们触及该边界,我们可以终止我们的游戏。

我们必须为两件事制定逻辑:

  • 每当蛇吃食物时,我们必须在新位置生成新的食物。

  • 每当蛇吃食物时,我们必须增加蛇的速度,使游戏变得更加困难。我们还应该跟踪蛇头和蛇身之间的碰撞。

在前一点方面,我们可以使用 random 模块,它提供了一个(y, x)的随机坐标位置,我们可以将食物分配给它。对于后一点,我们必须使用一个名为 timeout 的 curses 方法。我们必须将延迟的值作为参数传递给该函数。根据 Python 的官方文档,timeout 函数为窗口设置阻塞或非阻塞读取行为。如果延迟为负数,则使用阻塞读取(将无限期地等待输入)。如果延迟为零,则使用非阻塞读取,如果没有输入等待,getch()将返回-1。如果延迟为正数,则getch()将阻塞延迟毫秒,并且如果在该时间结束时仍然没有输入,则返回-1。因此,我们可以根据延迟改变游戏的速度,当延迟为零或正数时。

因此,在curses.timeout(delay)命令方面,如果您使延迟为负数,您的蛇将以快速的速度移动。然而,我们需要记住这里有一些约束;蛇的速度应该随着蛇的长度增加。首先,蛇是什么?在我们的游戏中是如何制作的?我们在上一章学习了列表。让我们用它来制作一个蛇。我们已经看到了我们的蛇的结构,它是一堆 X 字符。但在游戏开始时,我们应该为蛇提供一个较小的长度,也许是 3 的长度,即XXX。我们将把这些 X 中的每一个存储在列表中,它表示坐标,比如[[4,10],[4,9],[4,8]]。在这里,这些列表中的每一个代表一个 X,也就是说,在[4,10]的位置,我们将有一个 X,而在 4,9 的位置还有一个 X。请记住,这些应该是 y,x 位置,并且它们应该相邻,因为它们代表蛇的身体。

假设我们的延迟是 100,这将是恒定的。因此,我们表示速度的命令将是curses.timeout(100),这将是蛇在整个游戏中的恒定速度。然而,我们可以通过增加蛇的长度来改变游戏的速度。现在,让我们继续进行下一节,我们将为我们的游戏制作一个边界。

初始。

在本节中,我们将开始编写游戏的代码。我们将使用 curses 模块来实现这一点。首先,我们将初始化游戏屏幕并制作一些游戏角色。看一下以下代码:

#snake_game.py
import curses as c

c.initscr()
win = c.newwin(20,60,0,0)
win.keypad(1)
c.noecho()
c.curs_set(0)
win.border(0)
win.nodelay(1)

snake = [[4,10], [4,9], [4,8]]
food = [10,20]

win.addch(food[0],food[1], 'O')

在前面的代码中没有什么新的。您还可以使用wrapper()函数消除所有的函数调用。我们可以看到我们有两个列表变量,snakefood,它们包含代表它们在游戏控制台中位置的坐标。我们还调用了addch函数。它将以类似的方式工作addstr函数。我们传递了食物的位置并在该位置添加了O字符。

制作电脑游戏需要两个步骤:第一步是制作一个必须自然吸引人的视觉效果,而第二步是让玩家与游戏互动。为了使游戏具有互动性,我们必须处理玩家提供的事件。这就是我们将在下一节中做的事情。

处理用户按键事件

我们已经开始构建游戏的基本布局。现在,让我们编写一些代码来处理用户键盘事件。贪吃蛇是一个简单的游戏。我们可以通过处理键盘的四个键:上、下、左、右来使其工作。我们可以使用getch()来获取用户输入。但请记住,这些不是字母字符,它们是功能字符。因此,我们必须导入常量,如KEY_UPKEY_DOWNKEY_LEFTKEY_RIGHT来获取这些 ASCII 值。让我们开始编写处理用户事件的代码:

from curses import KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT
#CODE FROM PREVIOUS TOPIC

key = KEY_RIGHT #default key

#ASCII value of ESC is 27
while key != 27:
  win.border(0)
  win.timeout(100) #speed for snake
  default_key = key
  event = win.getch()
  key = key if event == -1 else event
  if key not in [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, 27]:
     key = default_key

我们编写的代码可能看起来很复杂,但所有这些事情都已经涵盖过了。让我们看看我们做了什么:

  • 在第一个语句中,我们将默认键设置为KEY_RIGHT。这很重要,因为我们不希望在用户没有按键时使蛇移动。因此,当游戏开始时,我们的蛇角色将自动向右移动。

  • 之后,我们创建了一个游戏循环。这个循环将一直执行,直到我们按下Esc,因为Esc的 ASCII 值是 27。在循环内部,我们调用了 timeout 方法,它将代表我们的贪吃蛇角色的速度。在下一行,我们使用getch()方法获取用户的事件。请记住,如果按下任何键事件,它的值将是-1。因此,我们可以进行比较,并将用户按下的键放入键变量中。然而,键可以是任何东西,比如字母字符或特殊符号,如[!,@,#,$],因此我们必须用适当的键进行过滤,例如 LEFT、RIGHT、UP 和 DOWN。如果用户按下的键不在其中,我们将使键具有默认值KEY_RIGHT

现在,我们可以将我们的程序与键盘或操纵杆等输入设备进行通信。是时候进入下一部分了,在那里我们将创建我们的第一个逻辑,当用户按下左、右、上和下键时,更新蛇字符的头部位置

游戏逻辑-更新蛇头的位置

在前面的部分,我们能够使用 curses 提供的常量来处理用户事件。就像移动一样,蛇的头部也可以改变。我们必须制定全新的逻辑来更新蛇头的位置。我们的蛇是存储在列表中的坐标的组合。嵌套列表的第一个元素是蛇头的位置。因此,我们只需要更新列表的第一个元素。让我们看看我们将如何做到这一点:

while key != 27:
  #code from preceding topic
  snake.insert(0, [snake[0][0] + (key == KEY_DOWN and 1) + 
  (key == KEY_UP and -1), snake[0][1] + (key == KEY_LEFT and -1) + 
  (key == KEY_RIGHT and 1)])

这可能看起来有点难以理解,所以让我澄清一下。

snake变量是一个列表。因此,我们可以使用insert()方法来操作该列表元素。insert()方法将接受两个参数:一个是索引,另一个是要插入的元素。在前面的代码中,索引是 0,这意味着我们要在列表的第一个元素中插入一个元素,它代表了蛇的头部。下一个参数是要添加到索引 0 的元素。我们可以在两个语句之间看到一个逗号(,):snake[0][0] + (key == KEY_DOWN and 1) + (key == KEY_UP and -1)snake[0][1] + (key == KEY_LEFT and -1) + (key == KEY_RIGHT and 1)。第一个语句表示蛇头的 y 坐标,而第二个语句表示蛇头的 x 坐标。在蛇头的 y 部分,可以表示为一列,我们可以有两种移动方式:向下或向上。向下时,我们必须在当前头部位置的 y 元素上加 1,而向上时,我们必须在当前 y 位置减 1。对于蛇头的 x 部分,我们有左和右的移动。

按下左键时,我们将 x 坐标减 1,按下右键时,我们将 x 加 1。还是困惑吗?看一下下面的图表应该能让你更清楚:

请记住,这个更新必须按照(y,x)的顺序进行。对于每次按下UPDOWN键,y 坐标都会增加或减少 1,这是蛇头的 snake[0][0]坐标。对于 x 坐标,这是 snake[0][1],这是我们之前使用的相同的增加和减少,但是当用户按下RIGHTLEFT键时。

现在我们已经制定了一些逻辑来更新蛇的位置,我们需要让蛇吃食物。我们要讨论的逻辑很简单:当蛇的头部位置与食物的位置相同时,我们可以说蛇吃了食物。让我们现在来讨论这个。

游戏逻辑-当蛇吃食物时

让我们为我们的游戏制作下一个逻辑部分。在这一部分,我们将让蛇吃食物。这很容易实现。每当蛇的头部触碰到食物时,我们就假设蛇已经吃了食物。因此,蛇的头部坐标和食物的坐标将是相同的。我们还必须制定一些逻辑,一旦蛇吃掉当前的食物,就会在下一个位置生成食物。下一个食物的位置应该是随机的。我们可以使用random模块来创建这样一个任意的位置。让我们开始编写代码:

from random import randint

这是从模块中导入任何函数的新方法。在调用这个函数时,我们不必写类似random.randint()的东西。相反,我们可以直接在我们的程序中调用它。randint()方法中的参数必须是值的范围。例如,randint(2,8)返回 2 到 8 之间的数字,就像这样:

while key != 27:
#add the following code after updating head position
  if snake[0] == food:
      food = []
      while food == []:
        food = [randint(1,18), randint(1,58)]
        if food in snake: 
            food = []
      win.addch(food[0], food[1], 'O')
  else:
      last = snake.pop()
      win.addch(last[0], last[1], ' ')
  win.addch(snake[0][0], snake[0][1], 'X')

c.endwin()

在代码的 if 部分中,我们添加了将食物放在新位置的逻辑。请记住,在游戏开始时,我们将新窗口的高度初始化为 20,宽度为 60。因此,我们只能在这个边界内生成食物。在代码的 else 部分中,如果用户无法吃到食物,我们会弹出最后一个元素。在倒数第二行,我们将蛇头的位置与'X'字符相加。

让我们运行游戏,看看目前的样子:

现在,我们的游戏已经足够可玩了。在制作这个游戏的过程中,我们学到了很多东西,比如如何在处理游戏控制台的方法和坐标时制定游戏逻辑。现在,让我们继续下一节,我们将学习如何测试和修改我们的游戏。

游戏测试和修改

为了发现任何程序的缺陷,运行和测试它总是一个好主意。就像我们之前的游戏一样,我们也可以对 Snake 游戏进行修改。以下几点解释了我们可以对游戏进行的一些修改:

  • 当你运行游戏时,你会注意到的第一件事是,我们的游戏没有逻辑来决定蛇是否与自己的其他部分发生碰撞。如果它与身体的其他部分发生碰撞,我们必须停止游戏。让我们在 while 循环中添加这个逻辑:
      if snake[0] in snake[1:]: 
          break
  • 在前面的代码中,snake[0]代表蛇的头部,而 snake[1:]代表蛇的身体。因此,前面的条件意味着头部坐标在蛇的身体内,这意味着发生了碰撞。在这种情况下,我们使用break语句来跳出循环并终止游戏。

  • 假设我们想要添加玩家的得分。添加得分很简单;蛇吃掉的食物数量等于玩家的得分。我们可以将得分的值初始化为 0 开始:

      score = 0 
      while key != 27:
        # CODE TO ADD SCORE IN THE SCREEN
        win.border(0)
        win.addstr(0, 2, 'Score : ' + str(score) + ' ') 
        win.addstr(0, 27, ' SNAKE ')

        if snake[0] == food:
            food = []
            #AFTER EATING EVERY FOOD SCORE = FOOD
            score += 1
            while food == []:
              food = [randint(1,18), randint(1,58)]
              if food in snake: food = []
            win.addch(food[0], food[1], 'O')
        else:
            end = snake.pop()
            win.addch(last[0], last[1], '')
        win.addch(snake[0][0], snake[0][1], 'X')

      c.endwin()

在前面的代码中,我们添加了一些带有addstr方法的语句,这些语句将在指定位置提供玩家的得分。现在,让我们运行游戏:

运行游戏后,您可以看到我们能够在 curses 的界面中进行游戏。然而,一旦您的蛇撞到边界线,您将遇到一个异常,游戏将自动终止。我们将在接下来的章节中详细学习如何处理边界碰撞(具体来说,第十一章,使用 Pygame 制作 Outdo Turtle - Snake 游戏 UI),但是现在,让我们学习一下我们可以使用的最简单的方法来处理并消除触发异常。首先,观察边界屏幕的尺寸,并注意边界所在的实际高度和宽度。考虑查看win变量,以了解边界屏幕的大小。现在,看着高度为 20,我们可能会假设每当蛇触碰顶部边界时,也就是说,蛇头位置为 0 时,蛇头必须通过自己的边界进入,其 y 坐标为 19。请记住,在上下边界中,只有 y 坐标会改变。这个逻辑的代码如下:

if snake[0][0] == 0:
    snake[0][0] = 18 #regenerate snake from lower boundary line

if snake[0][0] == 19:
    snake[0][0] = 1 #regenerate snake from upper boundary line

同样,我们必须处理蛇撞到右边界或左边界的情况。由于高度对于任何一种情况都保持不变,我们只关心宽度(x 位置)。由于由 win 变量声明的屏幕宽度为 60,我们可以预期蛇在 0(右边)和 59(左边)左右撞到边界时会相应地重新生成。您必须添加以下代码来处理发生在左右边界的碰撞:

if snake[0][1] == 0:
    snake[0][1] = 58 #regenerate from left
if snake[0][1] == 59:
    snake[0][1] = 1 #regenerate from right

最后,我们已经完成了 Snake 游戏。它足够吸引人,让任何用户都能玩这个游戏。我们还学会了如何用全新的逻辑创建程序。这是我们用来制作基于文本的游戏的第一个简单模块。尽管它是可玩的,但我们没有为它添加任何图形,所以看起来相当单调。通过学习一个名为面向对象编程的新 Python 范例,我们将使它更加引人入胜。我们已经成功地对我们的游戏进行了一些修改。现在,是时候学习 Python 最重要的概念了:面向对象编程。

总结

在本章中,我们开始揭开 curses 游戏编程的世界。显然,这并不是完美的游戏,因为它没有惊人的动画或奇妙的界面。我们几乎没有涉及这些话题,因为 curses 提供的应用是基于文本的,并在普通终端上运行。甚至 Snake 游戏的游戏角色,如蛇和食物,都是由字母组成的。尽管我们没有额外的努力使游戏更具吸引力,但我们已经学会了如何制作游戏逻辑。我们在 Snake 游戏中制作的逻辑中有两个重要的部分:第一个是游戏控制台的坐标与玩家位置的交互,第二个是使角色发生碰撞。curses 支持的坐标系统顺序很奇怪。在大多数库中,如 pygame 和 pyopengl,我们有一个以(x,y)顺序表示的坐标系统,但在 curses 中,它是(y,x)。如果两个字符在相同的坐标点(y,x)上,它们之间发生碰撞。为了做到这一点,我们必须检查蛇头和蛇身之间的碰撞。这个逻辑听起来可能很简单,但从长远来看会很有用。例如,在即将推出的游戏中,如 Flappy Bird 或 Angry Birds,我们将使用相同的逻辑来检查角色之间的碰撞。

我们为 Snake 游戏编写的代码非常细致和彻底,因为游戏是以过程式编程为基础编写的。在下一章中,我们将学习 Python 最重要的概念——面向对象编程,并相应地修改我们的代码,这将使我们的代码更易读和可重用。

第六章:面向对象编程

编程不仅仅是编写程序,同样重要的是理解它们,这样我们就可以修复其中的错误和漏洞。因此,我们说程序员天生就是用来阅读和理解代码的。然而,随着程序变得越来越复杂,编写易读的程序变得更加困难。在本书中,我们既写了美观的代码,也写了混乱的代码。我们用顺序编程制作了一个井字棋游戏,其可读性较低。我们可以将这些程序视为不优雅的,因为我们很难阅读和理解它们的代码和顺序流程。在编写这些程序之后,我们使用函数对其进行了修改,从而使我们混乱的代码更加优雅。然而,如果你正在处理包含数千行代码的程序,很难在同一个文件中编写程序并理解你正在使用的每个函数的行为。因此,发现和修复以过程方式编写的程序的错误也是困难的。因此,我们需要一种方法,可以将多行程序轻松地分解成更小的模块或部分,以便更容易地发现和修复这些错误。有许多实现这一目标的方法,但最有效和流行的方法是使用面向对象编程OOP)方法。

事实证明,我们从本书的开头就一直在使用对象,但并没有准确地了解它们是如何制作和使用的。本章将帮助您通过一些简单的示例了解面向对象编程的术语和概念。本章末尾,我们还将根据 OOP 方法修改我们在前几章中使用函数编写的贪吃蛇游戏代码。

本章将涵盖以下主题:

  • 面向对象编程概述

  • Python 类

  • 封装

  • 继承

  • 多态

  • 使用 OOP 实现的贪吃蛇游戏

  • 可能的错误和修改

技术要求

为了充分利用本章,您将需要以下内容:

  • Python 3.5 或更新版本

  • Python IDLE(Python 内置 IDE)

  • 文本编辑器

  • 网络浏览器

本章的文件可以在本书的 GitHub 存储库中找到:github.com/PacktPublishing/Learning-Python-by-building-games/tree/master/Chapter06

查看以下视频,以查看代码的实际运行情况:

bit.ly/2oKD6D2

面向对象编程概述

Python 中的一切都是对象。我们从本书的开头就一直在雄辩地陈述这一观点,并且在每一章中都在证明这个说法。一切都是对象。对象可以是元素、属性或函数的集合。数据结构、变量、数字和函数都是对象。面向对象编程是一种编程范式,它通过对象的帮助提供了一种优雅的程序结构方式。对象的行为和属性被捆绑到模板中,我们称之为类。这些行为和属性可以从该类的不同对象中调用。不要被行为和属性这些术语搞混了。它们只是方法和变量的不同名称。在某些类中定义的函数被称为方法。

我们将在本章后面深入探讨类和方法的概念,但现在,让我们在实际为它们制作模板之前更多地了解对象。

我们从这本书的开始就在不知不觉地使用对象。我们以前使用过不同类的方法,比如randint()方法。这个方法是通过导入一个名为random的模块来使用的。这个方法也是 Python 的内置类。类是一个模板,我们可以在其中编写对象的函数。例如,一个人可以被表示为一个对象。一个人有不同的特征,比如nameagehair_color,这些是唯一的属性。然而,人所执行的行为,比如吃饭、走路和睡觉,是行为或方法。我们可以从这些模板中创建任意多的对象。但现在,让我们想象两个对象:

Object 1: Stephen : name = "Stephen Hawking", age= 56, hair_color= brown, eating, walking, sleeping

Object 2: Albert: name = "Albert Einstein", age = 77, hair_color= black, eating, walking, sleeping

在前面的两个对象中,nameagehair_color是唯一的。所有的对象都有唯一的属性,但它们执行的行为或方法是相同的,比如吃饭、走路和睡觉。因此,我们可以得出结论,与输入和输出交互的数据模型是一个属性,因为它将被输入到方法中。根据每个对象的唯一属性,类的方法将产生不同的结果。

因此,我们可以说,面向对象编程是将现实世界的实体建模为具有唯一数据关联的对象,并且可以执行某些函数的方法。在类内部定义的函数称为方法,因此我们只需要从函数切换到方法。然而,请注意,方法的工作方式与函数的工作方式类似。就像函数是通过其名称或标志调用的一样,方法也需要通过其名称调用。但是,这个调用应该由对象发起。让我们看一个简单的例子来澄清这一点:

>>> [1,2,3].pop()
3

我们在前面的章节中看过这些方法。但是如果你仔细看这段代码,你会发现我们是从对象中调用方法。我们使用了pop方法,并在列表对象上调用它。这是面向对象编程的一个简单原型。面向对象编程的一个优点是它隐藏了方法调用的内部复杂性。正如你可能记得的,我们用随机模块调用了randint方法。我们甚至没有查看随机库的内容。因此,我们避免了库的工作复杂性。面向对象编程的这个特性将使我们只关注程序的重要部分,而不是方法的内部工作。

面向对象编程的两个主要实体是对象和类。我们可以通过使用模板来模拟类,其中方法和属性被映射。方法是函数的同义词,而属性是将每个对象与另一个对象区分开的属性。让我们通过创建一个简单的类和对象来对这个术语有一个很好的理解。

Python 类

正如我们在前一节中讨论的,对象继承了类内部编写的所有代码。因此,我们可以使用在类主体内映射的方法和属性。类是一个模板,可以从中创建实例。看一下下面的例子:

在前面,Bike类可以被认为是一个模板,可以从中实例化对象。在Bike类中,有一些属性,这些属性唯一地代表了从这个类创建的对象。每个创建的对象都会有不同的属性,比如名称、颜色和价格,但它们会调用相同的方法。这个方法应该与类的实例一起调用。让我们看看如何在 Python 中创建类:

>>> class Bike:
        pass

我们用 class 关键字在 Python 中创建一个类,后面跟着类的名称。通常,类名的第一个字母是大写的;在这里,我们写了Bike,B 是大写的。现在,在全局范围内,我们已经创建了Bike类。在类的主体内,我们写了一个 pass,而不是方法和属性。现在,让我们创建这个类的对象:

>>> suzuki = Bike()
>>> type(suzuki)
<class '__main__.Bike'>

在上面的代码中,我们从Bike类中创建了一个名为Suzuki的实例。实例化表达式看起来类似于函数调用。现在,如果您检查Suzuki对象的类型,它是Bike类的类型。因此,任何对象的类型都将是类类型,因为对象是类的实例。

现在是时候向这个Bike类添加一些方法了。这类似于函数的声明。def关键字,后面跟着方法的名称,是声明类的方法的最佳方式。让我们看一下以下代码:

#class_ex_1.py
class Bike:
    def ride_Left(self):
        print("Bike is turning to left")

    def ride_Right(self):
        print("Bike is turning to right")

    def Brake(self):
        print("Breaking......")

suzuki = Bike()
suzuki.ride_Left()
suzuki.Brake()

>>> 
Bike is turning to left
Breaking......

我们向Bike类添加了三个方法。在声明这些方法时使用的参数是self变量。这个self变量或关键字也是类的一个实例。您可以将这个self变量与指针进行比较,该指针指向当前对象。在每次实例化时,self变量表示指向当前类的指针对象。我们将很快澄清self关键字的用法和重要性,但在此之前,看一下上面的代码,我们创建了一个Suzuki对象,并用它调用了类的方法。

上面的代码类似于我们从 random 模块调用randint方法的代码。这是因为我们正在使用 random 库的方法。

当定义任何类时,只定义了对象的表示,这最终减少了内存损失。在上面的例子中,我们用名为Bike的原型制作了一个原型。可以从中制作不同的实例,如下所示:

>>> honda = Bike() #first instance
>>> honda.ride_Right()
 Bike is turning to right

 >>> bmw = Bike() #second instance
 >>> bmw.Brake()
 Breaking......

现在我们已经看过如何创建对象并使用类内定义的方法,我们将向类添加属性。属性或属性定义了每个对象的独特特征。让我们向我们的类添加一些属性,比如namecolorprice

class Bike:
     name = ''
     color= ' '
     price = 0

     def info(self, name, color, price):
         self.name = name
         self.color = color
         self.price = price
         print("{}: {} and {}".format(self.name,self.color,self.price))

 >>> suzuki = Bike()
 >>> suzuki.info("Suzuki", "Black", 100000)
 Suzuki: Black and 100000

在上面的代码中有很多行话。在幕后,这个程序是关于类和对象的创建。我们添加了三个属性:namecolorprice。要使用类的这些属性,我们必须用self关键字引用它们。namecolorprice参数被传递到info函数中,并分配给Bike类的相应namecolorprice属性。self.name, self.color, self.price = name,color,price语句将初始化类变量。这个过程称为初始化。我们也可以使用构造函数进行初始化,就像这样:

class Bike:
    def __init__(self,name,color,price):
        self.name = name
        self.color = color
        self.price = price

    def info(self):
        print("{}: {} and {}".format(self.name,self.color,self.price))

>>> honda = Bike("Honda", "Blue", 30000)
>>> honda.info()
Honda: Blue and 30000

在 Python 中,特殊的init方法将模拟构造函数。构造函数是用于初始化类的属性的方法或函数。构造函数的定义在我们创建类的实例时执行。根据init的定义,我们可以在创建类的对象时提供任意数量的参数。类的第一个方法应该是构造函数,并且必须初始化类的成员。类的基本格式应该在开始时有属性声明,然后是方法。

现在我们已经创建了自己的类并声明了一些方法,让我们探索面向对象范式的一些基本特性。我们将从封装开始,它用于嵌入在类内声明的方法和变量的访问权限。

封装

封装是将数据与代码绑定为一个称为胶囊的单元的一种方式。这样,它提供了安全性,以防止对代码进行不必要的修改。使用面向对象范式编写的代码将以属性的形式具有关键数据。因此,我们必须防止数据被损坏或变得脆弱。这就是所谓的数据隐藏,是封装的主要特性。为了防止数据被意外修改,封装起着至关重要的作用。我们可以将类的成员设为私有成员以实现封装。私有成员,无论是方法还是属性,都可以在其签名的开头使用双下划线来创建。在下面的例子中,__updateTech是一个私有方法:

class Bike:
    def __init__(self):
        self.__updateTech()
    def Ride(self):
        print("Riding...")
    def __updateTech(self):
        print("Updating your Bike..")

>>> honda = Bike()
Updating your Bike..
>>> honda.Ride()
Riding...
>>> honda.__updateTech()
AttributeError: 'Bike' object has no attribute '__updateTech'

在前面的例子中,我们无法从类的对象中调用updateTech方法。这是由于封装。我们使用双下划线将此方法设为私有。但有时我们可能需要修改这些属性或行为的值。我们可以使用 getter 和 setter 来修改。这些方法将获取类的属性的值并设置值。因此,我们可以得出结论,封装是面向对象编程的一个特性,它将防止我们意外修改和访问数据,但不是有意的。类的私有成员实际上并不是隐藏的;相反,它们只是与其他成员区分开来,以便 Python 解析器能够唯一解释它们。updateTech方法是通过在其名称开头使用双下划线(__)来使其成为私有和唯一的。类的属性也可以使用相同的技术来私有化。现在让我们来看一下:

class Bike:
    __name = " "
    __color = " "
    def __init__(self,name,color):
        self.__name = name
        self.__color = color
    def info(self):
        print("{} is of {} color".format(self.__name,self.__color))

>>> honda = Bike("Honda", "Black")
>>> honda.info()
Honda is of Black color

我们可以清楚地看到namecolor属性是私有的,因为它们以双下划线开头。现在,让我们尝试使用对象来修改这些值:

>>> honda.__color = "Blue"
>>> honda.info()
Honda is of Black color

我们尝试修改Bike类的颜色属性,但什么也没发生。这表明封装将防止意外更改。但如果我们需要有意地进行更改呢?这可以通过 getter 和 setter 来实现。看看以下例子以了解更多关于 getter 和 setter 的信息:

class Bike:
    __name = " "
    __color = " "
    def __init__(self,name,color):
        self.__name = name
        self.__color = color
    def setNewColor(self, color):
        self.__color = color
    def info(self):
        print("{} is of {} color".format(self.__name,self.__color))

>>> honda = Bike("Honda", "Blue")
>>> honda.info()
Honda is of Blue color
>>> honda.setNewColor("Orange")
>>> honda.info()
Honda is of Orange color

在前面的程序中,我们定义了一个Bike类,其中包含一些私有成员,如namecolor。我们使用init构造函数在创建类的实例时初始化属性的值。我们尝试修改它的值。然而,我们无法更改其值,因为 Python 解析器将这些属性视为私有。因此,我们使用setNewColor setter 为该私有成员设置新值。通过提供这些 getter 和 setter 方法,我们可以使类成为只读或只写,从而防止意外数据修改和有意的窃取。

现在,是时候来看一下面向对象范式的另一个重要特性——继承。继承帮助我们编写将从其父类继承每个成员并允许我们修改它们的类。

继承

继承是面向对象编程范式中最重要和最知名的特性。你还记得函数的可重用特性吗?继承也提供了可重用性,但伴随着大量的代码。要使用继承,我们必须有一个包含一些代码的现有类。这必须由一个新类继承。这样的现有类称为类或类。我们可以创建一个新类作为Child类,它将获取并访问父类的所有属性和方法,这样我们就不必从头开始编写代码。我们还可以修改子类继承的方法的定义和规范。

在下面的示例中,我们可以看到Child类或Derived类指向BaseParent类,这意味着存在单一继承:

在 Python 中,使用继承很容易。通过在Child类后面的括号中提及Parent类的名称,Child类可以从Parent类继承。以下代码显示了如何实现单一继承:

class Child_class(Parent_class):
    <child-class-members>

单个类也可以继承多个类。我们可以通过在括号内写入所有这些类的名称来实现这一点:

class Child_class(Base_class1, Base_class2, Base_class3 .....):
    <child-class-members>

让我们写一个简单的例子,以便更好地理解继承。在下面的例子中,Bike将是Parent类,Suzuki将是Child类:

class Bike:
    def __init__(self):
        print("Bike is starting..")
    def Ride(self):
        print("Riding...")

class Suzuki(Bike):
    def __init__(self,name,color):
        self.name = name
        self.color = color
    def info(self):
        print("You are riding {0} and it's color is 
          {1}".format(self.name,self.color))

#Save above code in python file and Run it

>>> suzuki = Suzuki("Suzuki", "Blue")
>>> suzuki.Ride()
Riding...
>>> suzuki.info()
You are riding Suzuki and it's color is Blue

让我们看一下前面的代码,并对继承感到惊讶。首先,我们创建了一个Base类,并在其中添加了两个方法。之后,我们创建了另一个类,即子类或派生类,称为Suzuki。它是一个子类,因为它使用class Suzuki(Bike)语法继承了其父类Bike的成员。我们还向子类添加了一些方法。创建这两个类后,我们创建了子类的对象。我们知道,当创建对象时,将自动调用要调用的方法是构造函数或init。因此,在创建该类的对象时,我们传递了构造函数要求的值。之后,我们从Suzuki类的对象中调用Ride方法。您可以在Suzuki类的主体内检查Ride方法。它不在那里——相反,它在Bike类的套件中。由于继承,我们能够调用Base类的方法,就好像它们在Child类中一样。我们还可以在Child类中使用在Base类中定义的每个属性。

然而,并非所有特性都在子类中继承。当我们创建子类的实例时,子类的init方法被调用,但Parent的方法没有被调用。然而,有一种方法可以调用该构造函数:使用super方法。这在下面的代码中显示:

class Bike:
    def __init__(self):
        print("Bike is starting..")
    def Ride(self):
        print("Riding...")

class Suzuki(Bike):
    def __init__(self,name,color):
        self.name = name
        self.color = color
        super().__init__()

>>> suzuki = Suzuki("Suzuki", "Blue")
Bike is starting..

super()方法指的是超类或Parent类。因此,在实例化超类之后,我们调用了该超类的init方法。

这类似于Bike().__init__(),但在这种情况下,Bike is starting..将被打印两次,因为Bike()语句将创建一个Bike类的对象。这意味着init方法将被自动调用。第二次调用是使用Bike类的对象进行的。

在 Python 中,多级继承是可用的。当任何子类从另一个子类继承时,将创建一个链接的序列。关于如何创建多级继承链,没有限制。以下图表描述了多个类从其父类继承特性:

以下代码显示了多级继承的特点。我们创建了三个类,每个类都继承了前一个类的特点:

class Mobile:
    def __init__(self):
        print("Mobile features: Camera, Phone, Applications")
class Samsung(Mobile):
    def __init__(self):
        print("Samsung Company")
        super().__init__()
class Samsung_Prime(Samsung):
    def __init__(self):
        print("Samsung latest Mobile")
        super().__init__()

>>> mobile = Samsung_Prime()
Samsung latest Mobile
Samsung Company
Mobile features: Camera, Phone, Applications

现在我们已经看过继承,是时候看看另一个特性,即多态。从字面上看,多态是适应不同形式的能力。因此,这个特性将帮助我们以不同的形式使用相同的代码,以便可以用它执行多个任务。让我们来看一下。

多态

在面向对象的范式中,多态性允许我们在Child类中定义与Parent类中定义的相同签名的方法。正如我们所知,继承允许我们使用Parent类的每个方法,就好像它们是在Child类中的子类对象的帮助下。然而,我们可能会遇到这样的情况,我们必须修改在父类中定义的方法的规格,以便它独立于Parent类执行。这种技术称为方法重写。顾名思义,我们正在用Child类内部的新规格覆盖Base类的已有方法。使用方法重写,我们可以独立调用这两个方法。如果你在子类中重写了父类的方法,那么该方法的任何版本(无论是子类的新版本还是父类的旧版本)都将根据使用它的对象的类型来调用。例如,如果你想调用方法的新版本,你应该使用Child类对象来调用它。谈到父类方法,我们必须使用Parent类对象来调用它。因此,我们可以想象到这两组方法已经开发出来,但是具有相同的名称和签名,这意味着基本的多态性。在编程中,多态性是指相同的函数或方法以不同的形式或类型使用。

我们可以从到目前为止学到的知识中开始思考多态性的例子。你还记得len()函数吗?这是一个内置的 Python 函数,以对象作为参数。这里,对象可以是任何东西;它可以是字符串、列表、元组等。即使它有相同的名称,它也不限于执行单一任务——它可以以不同的形式使用,如下面的代码所示:

>>> len(1,2,3) #works with tuples
3
>>> len([1,2,3]) #works with lists
3
>>> len("abc") #works with strings
3

让我们看一个例子来演示继承的多态性。我们将编写一个程序,创建三个类;一个是Base类,另外两个是Child类。这两个Child类将继承Parent类的每一个成员,但它们每个都会独立实现一个方法。这将是方法重写的应用。让我们看一个使用继承的多态性概念的例子:

class Bird:
    def about(self):
        print("Species: Bird")
    def Dance(self):
        print("Not all but some birds can dance")

class Peacock(Bird):
    def Dance(self):
        print("Peacock can dance")
class Sparrow(Bird):
    def Dance(self):
        print("Sparrow can't dance")

>>> peacock = Peacock()
>>> peacock.Dance()
Peacock can dance
>>> sparrow = Sparrow()
>>> sparrow.Dance()
Sparrow can't dance
>>> sparrow.about() #inheritance
Species: Bird

你看到的第一件事是Dance方法在所有三个类中都是共同的。但在这些类的每一个中,我们对Dance方法有不同的规格。这个特性特别有用,因为在某些情况下,我们可能想要定制从Parent类继承的方法,在Child类中可能没有任何意义。在这种情况下,我们使用与Child类内部相同签名的方法重新定义这个方法。这种重新实现方法的技术称为方法重写,通过这个过程创建的不同方法实现了多态性。

现在我们已经学习了面向对象编程的重要概念及其主要特性,比如封装、继承和多态性,是时候利用这些知识来修改我们在上一章中使用 curses 制作的蛇游戏了。由于我们无法使用这些面向对象的原则来使上一章的代码变得不那么混乱和晦涩,我们将使我们的代码更具可重用性和可读性。我们将在下一节开始使用 OOP 修改我们的游戏。

蛇游戏实现

在本章中,我们探讨了面向对象编程的各种特性,包括继承、多态性、数据隐藏和封装。我们没有涉及的一个特性,称为方法重载,将在第九章“数据模型实现”中介绍。我们已经学到了足够多关于 OOP 的知识,使我们的代码更易读和可重用。让我们按照传统模式开始这一部分,即头脑风暴和信息收集。

头脑风暴和信息收集

正如我们已经讨论过的,面向对象编程与游戏界面编程无关;相反,它是一种使代码更加稳健和更加清晰的范式。因此,我们的界面将类似于由 curses 模块制作的程序——基于文本的终端。然而,我们将使用面向对象的范式来完善我们的代码,并且我们将专注于对象而不是动作和逻辑。我们知道面向对象编程是一种数据驱动的方法。因此,我们的程序必须容纳游戏屏幕和用户事件数据。

我们在游戏中使用面向对象的原则的主要目标如下:

  • 将程序分成较小的部分,称为对象。这将使程序更易读,并允许我们轻松跟踪错误和错误。

  • 能够通过函数在对象之间进行通信。

  • 数据是安全的,因为它不能被外部函数使用。这就是封装。

  • 我们将更加注重数据而不是方法或程序。

  • 对程序进行修改,如添加属性和方法,可以很容易地完成。

现在,让我们开始头脑风暴并收集一些关于游戏模型的信息。显然,我们必须使用上一章的相同代码来布局游戏和其角色,即SnakeFood。因此,我们必须为它们各自取两个类。SnakeFood类将在其中定义控制游戏布局和用户事件的方法。

我们必须使用诸如KEY_DOWNKEY_UPKEY_LEFTKEY_RIGHT等 curses 事件来处理蛇角色的移动。让我们来可视化一下基本的类和方法:

  1. 首先,我们必须导入 curses 来初始化游戏屏幕并处理用户按键移动。

  2. 然后,我们必须导入随机模块,因为一旦蛇吃了食物,我们就必须在随机位置生成食物。

  3. 之后,我们初始化常量,如屏幕高度、宽度、默认蛇长度和超时时间。

  4. 然后,我们用构造函数声明了Snake类,它将初始化蛇的默认位置、窗口、头部位置和蛇的身体。

  5. Snake类内部,我们将添加一些方法,如下:

  • eat_food将检查蛇是否吃了食物。如果吃了,蛇的长度将增加。

  • collision将检查蛇是否与自身发生了碰撞。

  • update将在用户移动并改变Snake角色的位置时被调用。

  1. 最后,我们声明Food类并定义渲染和重置方法来在随机位置生成和删除食物。

现在,让我们通过声明常量和导入必要的模块来开始编写程序。这与上一章没有什么不同——我们将使用 curses 来初始化游戏屏幕并处理用户事件。我们将使用随机模块在游戏控制台上生成一个随机位置,以便我们可以在该位置生成新的食物。

声明常量并初始化屏幕

与前一章类似,我们将导入 curses 模块,以便我们可以初始化游戏屏幕并通过指定高度和宽度来自定义它。我们必须声明默认蛇长度和其位置作为常量。以下代码对你来说将是熟悉的,除了name == "__main__"模式:

import curses
from curses import KEY_RIGHT, KEY_LEFT, KEY_DOWN, KEY_UP
from random import randint

WIDTH = 35
HEIGHT = 20
MAX_X = WIDTH - 2
MAX_Y = HEIGHT - 2
SNAKE_LENGTH = 5
SNAKE_X = SNAKE_LENGTH + 1
SNAKE_Y = 3
TIMEOUT = 100

if __name__ == '__main__':
    curses.initscr()
    curses.beep()
    curses.beep()
    window = curses.newwin(HEIGHT, WIDTH, 0, 0)
    window.timeout(TIMEOUT)
    window.keypad(1)
    curses.noecho()
    curses.curs_set(0)
    window.border(0)

在前面的代码中,我们声明了一堆常量来指定高度、宽度、默认蛇长度和超时时间。我们对所有这些术语都很熟悉,除了__name__ == "__main__"模式。让我们详细讨论一下:

通过查看这个模式,我们可以得出结论,将"main"字符串赋值给 name 变量。就像__init__()是一个特殊方法一样,__name__是一个特殊变量。每当我们执行脚本文件时,Python 解释器将执行写在零缩进级别的代码。但是在 Python 中,没有像 C/C++中那样自动调用的main()函数。因此,Python 解释器将使用特殊的__name__变量设置为__main__字符串。每当 Python 脚本作为主程序执行时,解释器将使用该字符串设置特殊变量。但是当文件从另一个模块导入时,name 变量的值将设置为该模块的名称。因此,我们可以得出结论,name 变量将确定当前的工作模块。我们可以评估这个模式的工作方式如下:

  • 当前源代码文件是主程序时:当我们将当前源文件作为主程序运行,即C:/> python example.py,解释器将把"__main__"字符串赋给特殊的 name 变量,即name == "__main__"

  • 当另一个程序导入您的模块时:假设任何其他程序是主程序,并且它正在导入我们的模块。>>> import example语句将 example 模块导入主程序。现在,Python 解释器将通过删除.py扩展名来细化脚本文件的名称,并将该模块名称设置为 name 变量,即name == "example"。由于这个原因,写在 example 模块中的代码将对主程序可用。特殊变量设置完成后,Python 解释器将逐行执行语句。

因此,__name__ == "__main__"模式可用于执行其中写入的代码,如果源文件直接执行,而不是导入。我们可以得出结论,写在此模式内的代码将被执行。在 Python 中,没有main()函数,它是在低级编程语言中自动调用的。

在这种情况下,顶层代码以一个if块开始,后面跟着模式的name,评估当前的工作模块。如果当前程序是main,我们将执行写在if块内的代码,通过 curses 初始化游戏屏幕并在游戏中创建一个新窗口。

现在我们已经开始编写一个程序,初始化了游戏屏幕并声明了一些常量,是时候创建一些类了。游戏中有两个角色:SnakeFood。我们将从现在开始创建两个类,并根据需要对它们进行修改。让我们从创建Snake类开始。

创建蛇类

在为游戏创建屏幕后,我们的下一个重点将是在屏幕上渲染游戏角色。我们将首先创建Snake类。我们知道类将有不同的成员,即属性和方法。正如我们在上一章中提到的,创建Snake角色时,我们必须跟踪蛇在游戏窗口中的xy位置。为了跟踪蛇的身体位置,我们必须提取蛇的xy坐标。我们应该使用字母字符来构成蛇的身体,因为 curses 只支持基于文本的终端。让我们开始创建Body类,它将为我们提供蛇的位置并提供蛇身体的字符:

class Body(object):
    def __init__(self, x, y, char='#'):
        self.x = x
        self.y = y
        self.char = char

    def coor(self):
        return self.x, self.y

在前面的程序中,#用于构成蛇的身体结构。我们在Body类内定义了两个成员:构造函数和coor方法。coor方法用于提取蛇身体的当前坐标。

现在,让我们为游戏角色创建一个类。我们将从Snake类开始。我们应该维护一个列出的数据结构,以便我们可以存储蛇的身体位置。应该使用构造函数来初始化这些属性。让我们开始编写Snake类的构造函数:

class Snake:
    REV_DIR_MAP = {
        KEY_UP: KEY_DOWN, KEY_DOWN: KEY_UP,
        KEY_LEFT: KEY_RIGHT, KEY_RIGHT: KEY_LEFT,
    }

    def __init__(self, x, y, window):
        self.body_list= [] 
        self.timeout = TIMEOUT
        for i in range(SNAKE_LENGTH, 0, -1):
            self.body_list.append(Body(x - i, y))

        self.body_list.append(Body(x, y, '0'))
        self.window = window
        self.direction = KEY_RIGHT
        self.last_head_coor = (x, y)
        self.direction_map = {
            KEY_UP: self.move_up,
            KEY_DOWN: self.move_down,
            KEY_LEFT: self.move_left,
            KEY_RIGHT: self.move_right
        }

Snake类中,我们创建了一个字典。每个键和值表示一个相反的方向。如果您对屏幕上的方向表示感到困惑,请返回到上一章。字符的位置用坐标表示。我们声明了构造函数,它允许我们初始化类的属性。我们创建了body_list来保存蛇的身体;一个代表蛇游戏屏幕的窗口对象;蛇的默认方向,即右方向;和一个方向映射,其中包含使用 curses 常量如KEY_UPKEY_DOWNKEY_LEFTKEY_RIGHT来容纳角色的移动。

对于每个方向映射,我们调用move_upmove_downmove_leftmove_right函数。我们将很快创建这些方法。

下面的代码行声明在Snake类中,并将蛇身体的坐标添加到body_list中。Body(x-i,y)语句是Body类的实例,它将指定蛇身体的坐标。在Body类的构造函数中,#用于指定蛇身体的布局:

for i in range(SNAKE_LENGTH, 0, -1):
            self.body_list.append(Body(x - i, y))

让我们看一下前面的代码并探索一下。这段代码将扩展Snake类的特性:

  1. 首先,我们必须通过在Snake类中添加一些新成员来开始。我们首先添加一个简单的方法,它将扩展蛇的身体:
      def add_body(self, body_list):
              self.body_list.extend(body_list)
  1. 现在,我们必须创建另一个方法,将游戏对象渲染到屏幕上。这个程序的一个重要步骤是将蛇的身体渲染到游戏屏幕上。由于我们必须用#表示蛇,我们可以使用 curses,并使用addstr方法。在下面的渲染方法中,我们循环遍历了蛇的整个body_list,并为每个实例添加了'#'
        def render(self):
                    for body in self.body_list:
                        self.window.addstr(body.y, body.x, body.char)
  1. 现在,让我们创建Snake类的对象。我们可以在name == '__main__'模式中创建它:
      if __name__ == '__main__':
       #code from preceding topic
       snake = Snake(SNAKE_X, SNAKE_Y, window)

       while True:
       window.clear()
       window.border(0)
       snake.render()

在上述程序中,我们创建了一个蛇对象。由于在创建对象时Snake类的构造函数将自动调用,我们传入了SNAKE_XSNAKE_Y参数,这提供了蛇和窗口的默认位置。窗口对象屏幕是通过 curses 的newwin方法创建的。在 while 循环中,我们使用蛇对象调用渲染方法,这将在游戏屏幕中添加一个蛇。

尽管我们已经成功将蛇渲染到游戏控制台中,但我们的游戏还没有准备好测试,因为程序无法处理某些操作,例如用户按键盘上的左、右、上和下键来移动Snake角色。我们知道 curses 模块提供了一个方法,让我们可以从用户那里获取输入,并相应地处理它。

处理用户事件

在上一章中,我们看到使用 curses 模块从用户那里获取输入并处理输入是非常容易的。在本节中,我们将把这些方法添加到Snake类中,因为与用户操作相关的方法与Snake角色的移动相关。让我们在Snake类中添加一些方法:

def change_direction(self, direction):
        if direction != Snake.REV_DIR_MAP[self.direction]:
            self.direction = direction

上述方法将改变蛇的方向。在这里,我们初始化了REV_DIR_MAP字典,其中包含表示相反方向的键和值。因此,我们将当前方向传递给这个方法,根据用户按下的事件来改变它。方向参数是从用户那里输入的。

现在,是时候提取蛇的头部和头部的坐标了。我们知道蛇的头部位置在蛇移动时会改变。即使穿过蛇的边界,我们也必须使蛇从另一侧出现。因此,蛇的头部位置将根据用户的移动而改变。我们需要创建一个可以适应这些变化的方法。我们可以使用属性装饰器来实现这一点,它将把Snake类的头部属性的更改视为方法。这就像一个 getter。不要被这些术语所压倒,因为我们将在以后的章节中介绍这些内容(列表推导和属性)。话虽如此,让我们来看一下以下示例。这个例子将帮助你理解@property装饰器:

class Person:
    def __init__(self,first,last):
        self.first = first
        self.last = last
        self.email = '{0}.{1}@gmail.com'.format(self.first, self.last)

per1 = Person('Ross', 'Geller')
print(per1.first)
print(per1.last)
print(per1.email)

#output
Ross
Geller
Ross.Geller@gmail.com

现在,让我们改变first属性的值并打印所有这些值:

per1.first = "Rachel"
print(per1.first)
print(per1.email)

#output
Rachel
Ross.Geller@gmail.com

你可以清楚地看到,更改没有反映在电子邮件中。电子邮件的名称已经保留了之前的Ross值。因此,为了使程序自动适应变化,我们需要将属性设置为装饰器。让我们将电子邮件设置为属性并观察结果:

class Person:
    def __init__(self,first,last):
        self.first = first
        self.last = last

    @property
    def email(self):
        return '{0}.{1}@gmail.com'.format(self.first,self.last)

以下代码在 Python shell 中执行:

>>> per1 = Person('Ross', 'Geller')
>>> per1.first = "Racheal"
>>> per1.email()
Racheal.Geller@gmail.com

我们对属性所做的更改已经在类的属性中得到了自发的反映,这得益于装饰器属性的帮助。我们将在下一章中详细了解这一点。这只是一个快速的介绍。

我们只涵盖了它,因为这是使蛇的头属性成为属性装饰器的重要部分:

   @property
    def head(self):
        return self.body_list[-1]

    @property
    def coor(self):
        return self.head.x, self.head.y

head方法将提取列表的最后一个元素,表示蛇的头部。coor方法将返回一个包含(xy)坐标的元组,表示蛇的头部。

让我们再添加一个函数,用于更新蛇的方向:

 def update(self):
        last_body = self.body_list.pop(0)
        last_body.x = self.body_list[-1].x
        last_body.y = self.body_list[-1].y
        self.body_list.insert(-1, last_body)
        self.last_head_coor = (self.head.x, self.head.y)
        self.direction_map[self.direction]()

前面的update方法将弹出身体的最后一部分,并将其插入到更新新头部位置之前。

现在,让我们使用 curses 模块处理用户事件:

if __name__ == '__main__':
    #code from preceding topic
    #snake is object of Snake class
    while True:
        event = window.getch()
         if event == 27:
            break

        if event in [KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT]:
            snake.change_direction(event)

          if event == 32:
            key = -1
            while key != 32:
                key = window.getch()

        snake.update()

我们在上一章的前面代码中学习了工作机制,所以你不应该有任何问题理解它。现在,让我们让蛇朝着某个方向移动。在Snake类中,我们之前添加了direction_map属性,其中包含了映射到不同函数的字典,如move_upmove_downmove_leftmove_right。这些函数将根据用户的操作改变蛇的位置:

#These functions are added inside the Snake class
 def move_up(self):
        self.head.y -= 1
        if self.head.y < 1:
            self.head.y = MAX_Y

    def move_down(self):
        self.head.y += 1
        if self.head.y > MAX_Y:
            self.head.y = 1

    def move_left(self):
        self.head.x -= 1
        if self.head.x < 1:
            self.head.x = MAX_X

    def move_right(self):
        self.head.x += 1
        if self.head.x > MAX_X:
            self.head.x = 1

我们在上一章中制定了这个逻辑,并将使蛇向上、向下、向左或向右移动。我们可以将屏幕想象成一个包含行和列的矩阵。通过向上的动作,蛇将在 Y 轴上移动,因此 y 位置应该减小;同样,通过向下的动作,蛇将向下移动 Y 轴,因此我们需要增加 y 坐标。对于蛇的左右移动,我们将分别减小和增加 X 轴。

现在,我们已经处理了用户事件,这结束了Snake类。如果有碰撞,现在是处理碰撞的时候了。我们还必须向游戏添加另一个角色,即Food,这将通过创建一个新类来实现。

处理碰撞装饰器属性的帮助。

在这一部分不会创建高尚的逻辑。我们必须检查蛇的头部是否与蛇的身体部分发生了碰撞。这应该通过检查头部的坐标(yx)是否与蛇的身体的任何坐标相匹配来完成。因此,让我们制作一个新的@property方法,用于检查碰撞:

    @property
    def collided(self):
        return any([body.coor == self.head.coor
                    for body in self.body_list[:-1]])

在上面的例子中,如果可迭代对象中的任何项为True,则任何函数将返回True;否则,它将返回Falseany函数中的语句是一个列表推导语句,用于检查蛇头的坐标是否与蛇身的任何部分的坐标相同。

现在,让我们在主循环中使用snake对象调用这个方法:

if __name__ == "__main__": while True:
        #code from preceding topics
        #snake is Snake class object
        if snake.collided:
            break

添加食物类

我们需要添加到游戏中的下一个角色是Food。正如我们已经说过的,我们必须为每个角色创建一个不同的类,因为它们应该具有不同的行为和属性。让我们为Food角色创建另一个类。我们将称之为Food类。

class Food:
    def __init__(self, window, char='&'):
        self.x = randint(1, MAX_X)
        self.y = randint(1, MAX_Y)
        self.char = char
        self.window = window

    def render(self):
        self.window.addstr(self.y, self.x, self.char)

    def reset(self):
        self.x = randint(1, MAX_X)
        self.y = randint(1, MAX_Y)

如果你仔细阅读了本章的Python 类部分,这一节不应该让你感到困惑。在 Python 中创建一个类,我们使用class关键字,后面跟着类名。然而,我们必须使用括号来表示继承。如果你将括号留空,它们将抛出一个错误。因此,我们在括号内添加了一个对象,这是可选的。你可以简单地移除括号,它们将完美地工作。我们使用了 random 模块中的randint方法来在随机位置创建食物。render方法将在指定的(y,x)位置添加X字符。

现在,让我们创建Food类的对象,并通过调用render方法在屏幕上渲染食物:

if __name__ == '__main__':
    food = Food(window, '*')
    while True:
        food.render()

你可能还记得,我们创建的逻辑使蛇吃食物的方式与蛇头坐标与食物坐标发生碰撞的逻辑相同。在实际制作这个逻辑之前,我们将为Snake类制作另一个方法,用于处理吃食物后的后续逻辑:

def eat_food(self, food):
    food.reset()
    body = Body(self.last_head_coor[0], self.last_head_coor[1])
    self.body_list.insert(-1, body)

在蛇吃了食物之后,上述逻辑将被调用。吃了食物之后,我们将重置它,这意味着食物将在下一个随机位置生成。然后,我们将通过将食物的最后一个坐标添加到蛇的身体上来增加身体的位置。

现在,让我们添加一些逻辑,确保我们调用这个方法。正如我们已经讨论过的,逻辑将会很简单:每当蛇头与食物的位置发生碰撞时,我们将调用eat_food方法。

if __name__ == '__main__':
#snake is object of Snake class
#food is object of Food class
    while True:
        if snake.head.x == food.x and snake.head.y == food.y:
            snake.eat_food(food)

curses.endwin()

让我们运行游戏并观察输出:

最后,我们已经用面向对象的范式修改了游戏。你可能觉得使用类和对象更复杂和冗长,但通过更多的练习,你会变得更加熟悉。话虽如此,面向对象编程为我们的程序提供了更多的可读性和可重用性特性。举个例子,如果你在Snake角色中发现了一个 bug,你可以通过检查食物的不必要代码来追踪它。现在,让我们跳到下一节,测试游戏并对其进行必要的修改。

游戏测试和可能的修改

无法通过按下F5直接从 Python 脚本运行 curses 应用程序。因此,我们必须通过命令提示符外部运行它,使用filename.py命令。

现在,让我们在游戏中添加分数:

  1. 首先,在Snake类中将分数值初始化为 0。我们还将在Snake类中添加一个score方法:
      class Snake:
          self.score = 0
          @property
          def score(self):
              return 'Score : {0}'.format(self.score)
  1. 现在,我们必须在蛇吃食物时每次增加这个分数。蛇吃食物后将调用的方法是eat_food方法。因此,我们将在这个方法中增加分数:
      def eat_food(self, food):
          food.reset()
          body = Body(self.last_head_coor[0], self.last_head_coor[1])
          self.body_list.insert(-1, body)
          self.score += 1
  1. 现在,让我们使用 curses 窗口对象的addstr方法渲染分数:
      while True:
          window.addstr(0, 5, snake.score)
  1. 上述语句将从蛇对象中调用score方法,并在(0,5)位置添加分数。请记住,在 curses 中,第一个位置是 y,第二个位置是 x。

让我们再次运行游戏:

总结

在本章中,我们学习了编程中最重要的范式之一——面向对象编程。我们涵盖了类和对象的所有概念,以使您更容易阅读和编写自己的代码。我们还探讨了如何定义类的成员并访问它们。通过实际示例,我们熟悉了面向对象方法的特性。我们还学习了继承、封装、多态和方法重写。这些特性也将在接下来的章节中使用,所以确保您对这些主题每个都有很好的掌握。

在下一章中,我们将学习列表推导和属性。下一章的目的是找到一种优化代码的方法,使程序在执行方面更短、更快。我们将学习如何处理条件和逻辑,以实现更易读和更易调试的单行代码。我们还将利用这个概念来修改我们的贪吃蛇游戏。

posted @ 2024-04-18 11:01  绝不原创的飞龙  阅读(36)  评论(0)    收藏  举报