PygameZero 游戏编程入门手册(三)
九、面向对象编程
到目前为止,这些程序主要使用过程式编程。过程编码风格是学习编程的好方法,但是使用面向对象编程也有好处,这将在接下来讨论。Python 的一个有用之处是它支持许多不同的编码风格,甚至允许在同一个代码中使用多种风格。在使用 Python 模块时,您已经使用了一些面向对象的代码,包括 Pygame Zero。
在解释了面向对象编程的主要概念之后,本章将开始一个新的游戏。这是基于经典游戏“配对”,有时被称为记忆游戏。在这个游戏中,桌子上有几张面朝下的牌。每张卡片都有一张配对的图片。你需要通过每回合翻两张牌来找到对子。如果你成功了,那么你就拿着这些牌,并得到一分。
什么是面向对象编程?
面向对象编程(OOP)是一种不同的编程风格,它基于数据和对数据的操作。在与 Pygame Zero 互动时,你已经在整本书中看到了这一点。参与者是一个对象的实例。一个参与者可以通过改变它的属性来操作,比如 pos(改变它在屏幕上的位置),并且可以对它执行操作,比如 draw 方法(在屏幕上绘制它)。
面向对象编程中的四个主要概念是封装、数据抽象、多态和继承。这些被称为面向对象编程的四大支柱:
-
封装是关于保持内部状态私有以保护数据。Python 没有真正的封装,这通常是通过使用私有变量和方法来实现的。然而,Python 确实有在名称前使用 __(双下划线)的惯例,以防止意外使用私有变量或方法。
-
数据抽象是封装的扩展,它使得隐藏内部操作的细节变得更加容易。这有助于创建一个简单、更稳定的界面。
-
多态性允许一个孩子像他的父母一样行动。这是通过共享代码实现更好的代码重用的一种方式。它还可以根据输入参数提供不同的方法实现。
-
继承允许在相似的对象之间重用部分代码。
这本书将集中在面向对象编程提供的特定方面,这些方面使得游戏的设计和编程变得更加容易。它将展示如何使用封装和抽象来使游戏编程更简单、更容易编写和理解。它还将给出一个例子,说明继承如何通过利用现有代码来帮助减少需要编写的代码量。它还有助于减少全局变量的数量,这些变量会使代码在出错时难以理解和调试。
OOP 类和对象
面向对象编程是基于对象的。在现实世界中,我们认为物体是物理的东西,如笔记本电脑、电话或书籍。在编程中,对象可以是任何存储数据或与你交互的东西。内置对象包括屏幕、演员或声音,您可以为任何东西创建自己的对象。对象将数据保存为对象的内部变量,可以通过对象属性读取和操作这些变量。大多数对象也有可以在对象上执行的操作(方法)。
创建类、属性和方法
要创建对象,需要有一个蓝图来告诉计算机如何处理对象。蓝图被称为类。每个对象都被称为该类的一个实例。通常一个类是在一个单独的文件中创建的,这个文件的名字和这个类一样,以. py 结尾。这个类的名字叫 Ball,它遵循了类名首字母大写的惯例。如果类中有多个单词,那么任何单词的首字母也要大写,比如 MyClass。文件名通常与类名相同,但都是小写的。在这个例子中,类名是 Ball,所以文件叫做 ball.py。您可以将它们放在现有文件中,或者放在包含多个类的文件中,但是通常每个类有一个文件是个好主意。
class Ball():
shape = "sphere"
def __init__(self, position, radius, color):
self.position = position
self.radius = radius
self.color = color
def draw(self, screen):
screen.draw.filled_circle(self.position, self.radius, self.color)
Listing 9-1Example of a OOP class
该文件以一个类定义开始,说明这是一个面向对象的类。这是创建球对象的蓝图。
列出的第一个变量称为shape,其值为“球体”。这是一个类变量。该变量只有一个实例跨越该类的所有实例。它最常用于不变的值,尽管在下一章,你会看到一个由多个对象编辑的类变量的例子。
更常见的是拥有实例变量,它们对于类的每个实例都是唯一的。实例变量在方法中创建,并以关键字self为前缀。以 self 为前缀创建的实例变量可用于该类中定义的所有方法。也可以在没有 self 关键字的情况下创建局部变量,它们的行为方式与过程式编程中的局部变量相同。
在这个类中有两种方法。方法与函数本质上是一样的,只是它们对对象执行操作,并且可以访问对象中的数据。它们是使用 def 关键字创建的,类似于函数的定义方式。
第一种方法叫做__init__。这称为构造函数,每当创建类的实例时都会调用它。有一种方法叫draw,会在屏幕上画球。你可以看到这两种方法都把self作为第一个参数。self关键字用于表示类的实例,并被方法用来访问类实例中的数据。调用方法时,不需要在参数上提供任何内容;相反,self被自动传递给方法,用于访问实例变量。
第一次创建对象时,运行构造函数方法(__init__)。它通常用于设置任何变量。在这种情况下,位置、大小和颜色需要三个值。方法中的参数总是作为局部变量保存,所以它们被复制到self.position、self.radius和self.color中。它们存储在对象中,可以根据需要通过任何其他方法读取和写入。没有必要将这些标记为全局的;通过 self 关键字,所有方法都可以自动使用它们。
下一个方法是draw,它在屏幕上把球画成一个实心圆。它可以访问先前通过构造函数设置的所有变量,即self.position、self.radius和self.color。这种方法有一个异常之处。以前在调用screen.draw操作时,它使用内置的屏幕对象。这在 Pygame Zero 使用的顶级函数中的 draw 函数内工作,但是当使用单独的对象时,需要将对 screen 对象的引用作为参数提供。
创建类(对象)的实例
创建类后,可以创建类的实例。这就像使用类作为蓝图来创建物理对象一样。如清单 9-2 所示,它创建了一个可执行程序 balldemo.py。
from ball import Ball
WIDTH = 800
HEIGHT = 600
ball1 = Ball((400,300),10,"red")
def draw():
ball1.draw(screen)
Listing 9-2Creating instances of a class
这是一个基本程序,运行时会在屏幕中央显示球。第一行导入该类。它从文件 ball.py(与主程序文件在同一个目录中)中读取它,并从中导入类 ball。导入后,您可以使用该类。
创建了一个名为 ball1 的新对象,它是类的一个实例。为了创建实例,它使用类的名称,后跟构造函数中列出的参数。这将创建新的实例并运行 init 方法。
在 draw 函数中,在绘制球的实例 ball1 上调用 draw 方法。内置屏幕需要传递给 ball1 的 draw 方法,以便它能够绘制到屏幕上。
这将创建球的一个实例,但是您可以使用
ball2 = Ball((100,100),20,"green")
然后使用添加到 draw 方法中
ball2.draw(screen)
它在屏幕的左上角绘制了一个大的绿色球。
访问对象的属性
对象中的变量称为属性。正如您所看到的,类定义中的变量以 self 为前缀,这是指该类的实例。
如果您在类之外,那么您可以用实例名替换 self。对于实例 ball1,您可以使用
ball1.color
如果您想检查这个值,那么您可以使用
if ball1.color == "red":
...
或者如果你想改变这个值,那么你可以使用
ball1.color = "orange"
这适用于在 init 方法或任何其他方法中创建的所有变量,只要这些变量以 self 为前缀。
术语
面向对象编程的一个特点是它使用了所有的新术语。这里是对一些术语的回顾,以使其更加清晰。图 9-1 显示了类和实例之间的关系。
图 9-1
类到实例的关系
图顶部显示的类是创建对象的蓝图。它定义了如何创建类,它们有什么属性,以及可以对它们执行的操作。您通常不能直接使用该类,而是需要创建称为实例的特定对象。在本例中,我们创建了两个实例,称为 ball1 和 ball2。它们都是从同一个蓝图中创建的,所以将以相似的方式运行,但是它们有自己的属性集(存储为实例变量)。使用此球 1 颜色设置为红色,而球 2 颜色设置为绿色。
封装和数据抽象
如前所述,面向对象编程的两个好处是封装和数据抽象。这对程序员的好处是,它将类的内部结构与使用该结构的代码分离开来。当不同的程序员在同一个项目上工作时,这可以使它变得更容易,并且它可以使将来的更改变得更容易。
这可能有用的一个场景是当多人在同一个项目上工作时。如果程序员事先就类的接口达成一致,那么他们就可以独立工作。这超出了单个项目的影响;它还有助于创建可供他人使用的代码库。
另一种情况是您希望将来对代码进行更改。如果你想增加一个新的特性或者改进现有的代码,那么它会把这个类和其他类分开。如果保持接口不变,可以更改 ball 类中的任何内部代码,以完全不同的方式绘制球。
Python 不像其他一些编程语言那样严格地执行数据抽象。从类外部更改任何实例变量都是可能的,这会导致数据抽象的丢失。如果您想在类外隐藏一些变量,那么您可以通过在它们前面加上 __(两个下划线字符)来隐藏它们。即使使用这个,也不是完全的数据抽象。Python 对于面向对象的编程是有用的,但是依赖于程序员来创建稳定的接口和使用良好的编程最佳实践。
遗产
继承允许创建子类,该子类从父类继承它的一些属性和操作。这是避免代码重复的一种方式。这样做的好处是节省了程序员的输入,但更重要的是有助于减少 bug 的数量。
为了演示这一点,您可以想象一个飞行游戏,其中有一个飞机类来表示飞机。如果一个游戏有不同种类的位面,那么这些位面可以做不同的事情。图 9-2 显示了三种不同类型的飞机:客机、货机和战斗机。
图 9-2
使用父类和子类的继承
这些都是不同类型的飞机,所以会有一些共同点。其他事情可能只适用于某些类型的飞机。所有飞机都有许多引擎以及起飞和降落的能力,所以这些都可以在飞机(父)类中配置。还有一些其他的属性和操作是某些类型的球所独有的。例如,客机有许多乘客,但这对战斗机来说没有意义。战斗机可以装载和发射导弹,但是客机和货机都不需要这种能力。
继承的工作方式是在父类中定义公共属性和方法,然后将任何独特的特性添加到子类中。使用子类的代码可以使用从父类继承的或在子类中的操作。清单 9-3 中的代码演示了子类如何引用其父类。
from airplane import Airplane
class PassengerPlane(Airplane):
def __init__(self):
Airplane.__init__(self)
self.number_passengers = 0
def load_passengers (number_passengers):
self.number_passengers = number_passengers
def unload_passengers ():
self.number_passengers = 0
Listing 9-3Inheritance demonstration showing a child class
这段代码继承自飞机类,添加了一个名为number_passengers的新属性和两个名为load_passengers和unload_passengers的新方法。可以使用以下方法创建客机的实例
plane1 = PassengerPlane()
这将访问它自己的方法,例如
plane1.load_passengers(20)
以及父方法,如
plane1.take_off()
面向对象程序设计
当我第一次开始使用面向对象编程时,我发现的一个挑战是决定定义什么对象。在代表现实世界中的物体的情况下,这是显而易见的。在平面的例子中,很明显,它会被认为是一个物体,但不那么有形的东西呢?玩家的分数是一个对象,还是一个属性?如果是属性,那么分数属于什么对象呢?
在某些情况下,某个东西是否应该是一个对象并没有确定的答案。这取决于游戏的类型,它如何与其他对象交互,以及程序员的个人偏好。我将展示我所使用的技术,当你设计你自己的游戏时,这些技术可能会对你有所帮助。这不是强制性的,根据经验,您可能不需要这样做,但这是我在创建新程序时经常使用的一种技术。
这种技术有助于显示要创建什么类以及它们的属性和操作应该是什么。首先,用一两段文字写下这个游戏是做什么的,以及它将如何运作。你应该把它写下来(或者输入电脑),用完整的句子而不是要点来写。不要试图凭记忆做这件事,因为你需要看到下一步要用的单词。现在浏览你写的描述,找出所有的名词。将名词作为可能的类名写下来。接下来看看所有的形容词,然后适用于那些名词,并把它们写在相关的名词下面。这些将是属性(变量)。然后寻找主动动词以及它们与名词的关系。将这些列在它们相关的名词下;这些将成为操作(方法)。
这里有一个简短的总结:
-
名词(名称)-类别
-
形容词(描述性词语)-属性(变量)
-
主动动词(动作词)-操作(方法)
然后,这些单词为类、属性和操作提供了建议。请注意,这些只是建议;然后你应该运用你自己的判断来判断这些是否都是必要的。可能是一些条目彼此相似,或者是一些名词没有足够的意义来拥有它们自己的类别。然而,它确实给了你一个设计类结构的起点。当你开发游戏的时候,你总是可以重新审视这个结构。
配对记忆游戏
既然您已经熟悉了面向对象编程的一些方面,那么是时候在另一个游戏中将它付诸实践了。这是一个传统的基于卡片的记忆游戏的数字版本。该游戏通常使用图片卡进行,每张卡片是一对匹配的卡片中的一张。牌面朝下,玩家轮流翻开两张牌,试图找到匹配的牌对。游戏截图如图 9-3 。
图 9-3
配对记忆游戏截图
在传统游戏中,通常有两个或更多的人轮流试图赢得最多的分数,但在这个版本中,只有一个玩家将与时间赛跑。为了使用面向对象编程来创建它,我首先遵循前面解释的设计方法。我已经写下了这个游戏的如下解释。
这是一个记忆游戏。游戏从许多面朝下的牌开始。每张卡片上都有一张与另一张卡片相匹配的图片。玩家翻开两张牌,看它们是否匹配。如果它们匹配,那么玩家的分数增加。如果它们不匹配,那么卡被重置,玩家有另一次机会。有一个计时器随着时间倒计时,如果计时器值为零,那么游戏结束。如果玩家在一个级别上匹配所有对,那么玩家被奖励奖励点数,并且卡被再次分发。
然后我确定了标有()的名词,它们将成为类。形容词用[];它们将成为属性。主动动词用{}标记;它们将成为操作。
这是一个(记忆游戏)。游戏从一些[面朝下]的(牌)开始。每张(卡片)上都有一张与另一张卡片相匹配的[图片]。(玩家){翻}两张牌,看它们是否{匹配}。如果它们匹配,则玩家的(分数)会{增加}。如果它们不匹配,则卡被{重置},玩家有另一次机会。有一个(计时器)随着时间倒计时,如果[计时器值]为零,那么游戏结束。如果玩家在 a(级别)上匹配所有对子,那么玩家将获得[奖励点数]并再次发牌。
请记住,这些是指导方针,而不是固定的规则。你可以在识别合适的单词和短语时运用你自己的判断力,或者你可以在以后的某个阶段这样做。这只是让程序员更容易决定如何创建不同的类的一种方式。然后,我将它们制作成以下内容:
-
记忆游戏
-
属性:不适用
-
操作:复位;交易
-
玩家
-
属性:分数;奖励积分
-
操作:不适用
-
卡
-
属性:面朝下;画
-
操作:火柴(另一张牌);重置
-
小时
-
属性:剩余时间
-
操作:倒计时;达到零
-
级别
-
属性:不适用
-
操作:不适用
这只能被认为是一个起点。您现在应该回顾一下,看看是否有您现在想要更改的内容。您可以在实现代码时重新审视这一点,因为它可能需要更改。
有些事情立即改变是有意义的。有一点是记忆游戏定义了整体游戏。我们可以创建它,而不需要将它定义为一个单独的类,这将允许它使用 Pygame Zero draw 和 update 函数;它还需要一个变量来跟踪游戏的状态。
另一个值得考虑的是,奖励点数不需要成为属性,而是可以添加到正常分数中;把加分作为一个操作而不是一个属性是有意义的。此外,级别可能不需要成为一个类,但可以合并到记忆游戏或玩家类中。
完成这个练习后,您应该对需要什么类以及一些属性和操作有所了解。在编写代码时,您可能会认为还需要其他的类、属性或操作。您可以在创建代码时添加任何附加的属性或操作。
提供的源代码中有两个版本的代码,一个是初始版本,另一个是更新版本。为了测试代码,你需要一些卡片图片。这本书的源代码包括一些我在英国湖区拍摄的照片。这些是用来制作卡片的,但是你也可以用你自己的照片或图片来个性化这个游戏。
创建类
创建代码没有固定的顺序。在创建主程序之前,我通常先创建一些类。这意味着我可以在编写主程序之前对这些类进行一些测试。这里解释了我创建的类。
计时器类
第一个类是定时器类。timer 类用于跟踪已经过去了多长时间,以便游戏必须在分配的时间内完成。这是一个非常简单的类,但是对于演示如何创建这个类非常有用。代码如清单 9-4 所示,保存在一个名为 timer.py 的文件中。
import math
import time
class Timer():
def __init__(self, start_count):
self.start_count = start_count
self.start_time = time.time()
# start count down, with optional parameter to replace the start_count value
# -1 is used as a "magic number", this method should only be called with positive number
# if it isn't given a number then -1 indicates no new time give
def start_count_down(self, new_time = -1):
if (new_time >= 0):
self.start_count = new_time
self.start_time = time.time()
def get_time_remaining(self):
current_time = self.start_count + self.start_time - time.time()
if (current_time <= 0):
return 0
return math.ceil(current_time)
Listing 9-4Timer class
该文件首先导入两个模块,数学和时间。顾名思义,这些提供了数学和时间函数。
类别由条目定义
class Timer():
这为常规类创建了类定义。类名为 Timer。构造函数被定义为 init 方法。它的第一个参数是 self,它总是包含在类构造函数中。然后,它接受一个参数,即 start_count 值。这是一个倒计时定时器,start_count 值是倒计时的秒数。
def __init__(self, start_count):
self.start_count = start_count
self.start_time = time.time()
构造函数还创建了一个变量self.start_time,它被传递了从 epoch 开始的秒数。在 Linux 系统上,纪元是 00:00:00 1970-01-01 UTC(1970 年 1 月 1 日)。实际时间对于这个游戏来说并不重要,但是它可以作为一个参考点来衡量已经过去的时间。
start_count_down方法用于启动定时器。它包括通常的自我论证。然后它有一个参数new_time = -1。通过设置new_time在参数中有一个值,它使参数可选。如果一个参数被传递,那么它将被放入new_time变量中;如果没有传递参数,那么变量将取值-1。
def start_count_down(self, new_time = -1):
如果new_time被改变,那么它被放入self.new_time变量中。然后,该方法通过在self.start_time变量中存储当前时间(以秒为单位)来重启计时器。
get_time_remaining方法返回计数器到达零之前剩余的秒数,或者如果已经超过了倒计时时间,则返回零。math.ceil功能用于将时间四舍五入到最接近的整秒。这使得倒计时总是显示一个完整的秒数,只有当整整一秒过去时才递减。
卡片类别
下一个类是向玩家显示卡片的卡片类。这显示在清单 9-5 中,应该保存为 card.py。这个类还演示了继承是如何扩展 Actor 类的。
from pgzero.actor import Actor
# Card is based on an Actor (uses inheritance)
class Card(Actor):
def __init__(self, name, back_image, card_image):
Actor.__init__(self, back_image, (0,0))
self.name = name
self.back_image = back_image
self.card_image = card_image
# Status can be 'back' (turned over) 'front' (turned up) or 'hidden' (already used)
self.status = 'back'
# Override Actor.draw
def draw(self):
if (self.status == 'hidden'):
return
Actor.draw(self)
def turn_over(self):
if (self.status == 'back'):
self.status = 'front'
self.image = self.card_image
elif (self.status == 'front'):
self.status = 'back'
self.image = self.back_image
# Attempt to turn over a hidden card - ignore
else:
return
def hide(self):
self.status = 'hidden'
# When unhide set it to back image
def unhide (self):
self.status = 'back'
self.image = self.back_image
def is_hidden (self):
if self.status == 'hidden':
return True
return False
# Is it turned to face forward
def is_faceup (self):
if self.status == 'front':
return True
return False
def reset (self):
self.unhide()
def set_position(self, x, y):
self.x = x
self.y = y
def equals (self, othercard):
if self.name == othercard.name:
return True
return False
Listing 9-5Card class with inheritance
该文件的第一个条目是导入 pgzero.actor 中的 Actor 类。
from pgzero.actor import Actor
这是 Pygame Zero 通常自动加载的,但是因为这是在一个单独的类文件中,所以需要显式导入。
然后,它定义了一个名为 Card 的新类:
class Card(Actor):
括号中的单词“Actor”表示这将是 Actor 类的子类。
新类从父类导入方法,然后父类可以被覆盖。包含了覆盖构造函数方法的__init__方法。它包括对自我的引用和三个变量。name 变量是用于检查匹配对的标签;其他参数用于将图像文件名传递给 Card 类。
def __init__(self, name, back_image, card_image):
Actor.__init__(self, back_image, (0,0))
self.name = name
self.back_image = back_image
self.card_image = card_image
self.status = 'back'
有一个名为 status 的变量,它将跟踪卡是面朝上(正面)还是面朝下(背面),或者它是否已经被使用(隐藏)。
您还将看到,在该代码块中,Actor.__init__方法被调用。这与创建不属于子类的 Actor 的方法相同。在这种情况下,通过在父类的名称前加上前缀,直接调用父类的__init__方法。如果没有__init__方法,那么将调用父方法的__init__。
Card 类中的下一个方法是 draw 方法,它也覆盖了父类中的方法。这样做是为了仅在状态不等于隐藏时显示卡片。如果卡片没有隐藏,那么它通过调用Actor.draw(self)来调用父级的 draw 方法。
然后有一些方法在卡对象上执行操作。父类中不存在这些方法。它们是专门针对卡片的方法,在大多数情况下,对于不是卡片的其他参与者没有意义。
这张卡片有两幅不同的图像。该卡以显示back_image开始,但是当调用turn_over方法时,它变成了card_image。这是通过改变self.image属性来实现的,这是 Actor 类的一个特性。其他一些方法主要是获取和设置变量值。例如,hide和unhide方法用于改变隐藏变量的值,is_hidden方法返回隐藏变量的值。实际上并不需要这些方法,因为可以直接改变隐藏变量。无论是直接更新和读取变量,还是使用方法更新和读取变量,都各有利弊。Python 的口头禅通常是采用更简单的直接更新变量的选项,而对于其他一些编程语言,每当需要访问对象中的变量时,都鼓励使用 getter 和 setter 方法。
我通常更喜欢使用方法来访问变量。主要优点是它支持数据抽象的概念。假设在未来的某一天,您决定添加一个选项来部分隐藏一张卡片。比如你可能加了一个特性,一张牌只有上一轮没翻过才能翻;如果一张牌在上一轮中被使用过,那么它应该变灰以表示它不能被使用。为了实现这一点,您可以更改隐藏变量,使其不再是只能保存两种状态(真或假)的布尔值,而是使用一个数字来表示透明度的大小。如果您只使用方法来访问值,那么您可以将它作为一个新特性添加进来,而不会破坏库的使用方式。当您在不同的程序之间重用相同的代码时,这尤其有用。
equals 方法将当前卡片的名称与另一张卡片的名称进行比较。参数othercard将被传递给一个对象,从这个对象中它可以检查另一张卡的名称。
游戏类
在这一点上,我决定不创建一个单独的球员类,因为它只会为分数保存一个变量。通常不值得为一个变量创建一个类。
最初,我将所有的分数和状态跟踪合并到主程序文件中。当我做程序文件时,它变得很长,很难理解它是如何工作的。这就是所谓的臭味。为了避免这种情况,我创建了一个名为 GamePlay 的新类。这就是所谓的代码重构,也就是代码被更新,但通常不会添加任何额外的功能。通常是重新组织和修改代码,使其更容易阅读,或者更有效。
Note
臭味是一个编程术语,表示糟糕的代码设计。这通常不是一个错误,但可能会减慢开发速度,使代码难以理解,或者增加将来出现错误的风险。
创建 GamePlay 类的另一个好处是,它将用户分数从主代码中分离出来,应该会使它更容易在后期制作成双人游戏。
游戏类如清单 9-6 所示,并保存为 gameplay.py。
# State is tracked as a number, but to make the code readable constants are used
STATE_NEW = 0 # Game ready to start, but not running
STATE_PLAYER1_START = 1 # Player 1 to turn over card
STATE_PLAYER1_CARDS_1 = 2 # Card 1 turned over
STATE_PLAYER1_CARDS_2 = 30 # Card 2 turned over
STATE_END = 50
# Number of seconds to display high score before allowing click to continue
TIME_DISPLAY_SCORE = 3
class GamePlay:
def __init__ (self):
# These are what we need to track
self.score = 0
self.state = STATE_NEW
# These are the cards that have been turned up.
self.cards_selected = [None, None]
# If game has not yet started
def is_new_game(self):
if self.state == STATE_NEW:
return True
return False
def is_game_over(self):
if self.state == STATE_END:
return True
return False
def set_game_over(self):
# player gets to see high score
self.state = STATE_END
def is_game_running(self):
if (self.state >= STATE_PLAYER1_START and self.state < STATE_END):
return True
return False
def start_game(self):
self.score = 0
self.state = STATE_PLAYER1_START
def set_new_game(self):
self.state = STATE_NEW
def is_pair_turned_over(self):
if (self.state == STATE_PLAYER1_CARDS_2):
return True
return False
# Return the index position of the specified card
def get_card(self, card_number):
return self.cards_selected[card_number]
# Point scored, so add score and update state
def score_point(self):
self.score += 1
self.state = STATE_PLAYER1_START
# Not a pair - just update state
def not_pair(self):
self.state = STATE_PLAYER1_START
# If a card is clicked then update the state accordingly
def card_clicked(self, card_index):
if (self.state == STATE_PLAYER1_START):
self.cards_selected[0] = card_index
self.state = STATE_PLAYER1_CARDS_1
elif (self.state == STATE_PLAYER1_CARDS_1):
self.cards_selected[1] = card_index
self.state = STATE_PLAYER1_CARDS_2
Listing 9-6GamePlay class
GamePlay 类提供的主要功能是跟踪游戏状态和记录分数。该文件首先创建一些用于表示不同状态的常量。这些都不是必须的,但是state == STATE_PLAYER1_START比state == 1可读性更好。常量都是大写的,以表明它们是常量,不应该被更改,但就 Python 而言,这些只是变量。变量的值并不重要,只要它们总是被常量引用。
__init__方法用于创建分数和状态变量。下一个变量cards_selected是一个列表,它跟踪哪张牌被翻了出来。它以每个值作为None开始。None 是一种特殊的变量类型,表示没有设置任何值。需要这两个条目,以便可以将卡号存储在其中。
包含的方法主要是关于提供游戏的状态。例如,如果游戏即将开始,方法is_new_game将返回值 True 否则,它将返回 False。提供这些代码是因为与对照状态代码进行检查相比,这样更容易理解代码在做什么。
稍微复杂一点的方法是card_clicked方法。该方法查看当前状态以确定被点击的卡片是第一张还是第二张,并更新cards_selected中的相应条目。
程序文件
创建了类文件之后,程序文件就简单多了。它仍然很长,但比所有代码都在一个文件中要短。代码如清单 9-7 所示。
# Memory Card Game - PyGame Zero
import random
from card import Card
from timer import Timer
from gameplay import GamePlay
# These constants are used to simplify the game
# For more flexibility these could be replaced with configurable variables
# (eg. different number of cards for different difficulty levels)
NUM_CARDS_PER_ROW = 4
X_DISTANCE_BETWEEN_CARDS = 120
Y_DISTANCE_BETWEEN_CARDS = 120
CARD_START_X = 220
CARD_START_Y = 130
TIME_LIMIT = 60
TITLE = "Lake District Memory Game"
WIDTH = 800
HEIGHT = 600
cards_available = {
'airafalls' : 'memorycard_airafalls',
'ambleside' : 'memorycard_ambleside',
'bridgehouse' : 'memorycard_bridgehouse',
'derwentwater' : 'memorycard_derwentwater',
'ravenglassrailway' : 'memorycard_ravenglassrailway',
'ullswater' : 'memorycard_ullswater',
'weatherstone' : 'memorycard_weatherstone',
'windermere' : 'memorycard_windermere'
}
card_back = "memorycard_back"
## Setup instance variables
count_down = Timer(TIME_LIMIT)
game_state = GamePlay()
all_cards = []
# Create individual card objects, two per image
for key in cards_available.keys():
# Add to list of cards
all_cards.append(Card(key, card_back, cards_available[key]))
# Add again (to have 2 cards for each img)
all_cards.append(Card(key, card_back, cards_available[key]))
## Functions are defined here - the rest of the initialization
## is at the bottom of the file
# Shuffle the cards and update their positions
# Do not draw as this is called before the screen is properly setup
def deal_cards():
# Create a temporary list of card indexes that is then shuffled
keys = []
for i in range (len(all_cards)):
keys.append(i)
random.shuffle(keys)
# Setup card positions
xpos = CARD_START_X
ypos = CARD_START_Y
cards_on_row = 0
for key in keys:
# Reset (ie. unhide if hidden and display back)
all_cards[key].reset()
all_cards[key].set_position(xpos,ypos)
xpos += X_DISTANCE_BETWEEN_CARDS
cards_on_row += 1
# If reached end of row - move to next
if (cards_on_row >= NUM_CARDS_PER_ROW):
cards_on_row = 0
xpos = CARD_START_X
ypos += Y_DISTANCE_BETWEEN_CARDS
def update():
if (game_state.is_new_game()):
pass
elif (game_state.is_game_over()):
pass
else:
if (count_down.get_time_remaining()<=0):
game_state.set_game_over()
# Mouse clicked
def on_mouse_down(pos, button):
# Only interested in the left button
if (not button == mouse.LEFT):
return
# If new game then this click is to start the game
if (game_state.is_new_game()):
game_state.start_game()
# start the timer
count_down.start_count_down(TIME_LIMIT)
deal_cards()
return
# If game over then this click is to get to new game screen
if (game_state.is_game_over()):
# Make sure the timer has reached zero (short delay to see state)
if (count_down.get_time_remaining()<=0):
game_state.set_new_game()
return
## Reach here then we are in game play
# First check for both already clicked and this is a click to test
if (game_state.is_pair_turned_over()):
if (all_cards[game_state.get_card(0)].equals(all_cards[game_state.get_card(1)])):
# Add points and hide the cards
game_state.score_point()
all_cards[game_state.get_card(0)].hide()
all_cards[game_state.get_card(1)].hide()
# Check if we are at the end of this level (all cards done)
if (end_level_reached()):
deal_cards()
# If not match then turn both around
else:
all_cards[game_state.get_card(0)].turn_over()
all_cards[game_state.get_card(1)].turn_over()
game_state.not_pair()
return
## Otherwise we just turn over the next card if clicked
for i in range (len(all_cards)):
if (all_cards[i].collidepoint(pos)):
# Ignore if card hidden, or has already been turned up
if (all_cards[i].is_hidden() or all_cards[i].is_faceup()):
return
all_cards[i].turn_over()
# Update state
game_state.card_clicked(i)
# If reach end of level ?
def end_level_reached():
for card in all_cards:
if (not card.is_hidden()):
return False
return True
def draw():
screen.fill((220, 220, 220))
if (game_state.is_new_game()):
screen.draw.text("Click mouse to start", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
if (game_state.is_game_over()):
screen.draw.text("Game Over\nScore: "+str(game_state.score), fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
if (game_state.is_game_running()):
for card in all_cards:
card.draw()
screen.draw.text("Time remaining: "+str(count_down.get_time_remaining()), fontsize=40, bottomleft=(50,50), color=(0,0,0))
screen.draw.text("Score: "+str(game_state.score), fontsize=40, bottomleft=(600,50), color=(0,0,0))
### End of functions - start of initialization code
deal_cards()
Listing 9-7Memory game main program file
与其他文件不同,主程序文件不是作为单独的类创建的。这不同于其他一些要求一切都面向对象的编程语言。在 Python 的情况下,这是可选的,而在 Pygame Zero 的情况下,在程序的主要部分不使用单独的类更容易。相反,程序使用 Pygame Zero 挂钩,比如draw和update函数。
为了理解这个程序,看一下整个文件是很有用的。导入和变量,以及类实例的初始化都是在文件的顶部定义的。函数在中间,在程序初始化过程中运行的附加代码在这一行之后的底部
### End of functions - start of initialization code
该程序首先导入随机模块,然后导入之前创建的三个类:卡片、计时器和游戏性。定义了几个常量,用于纸牌的间距和游戏设置,如计时器的持续时间。还有一个卡片背面图像文件名的变量,以及一个不同卡片图像文件名的字典。这些设置通常存储在单独的配置文件中,但是为了简单起见,它们被包含在 memory.py 文件中。然后创建一个名为all_cards的空列表,它将保存 Card 类的实例。
接下来处理类的实例的创建。Timer和GamePlay类只需要一个普通赋值创建的实例。
count_down = Timer(TIME_LIMIT)
game_state = GamePlay()
对于 Cards 类,每个将要显示的卡片都需要有一个实例。一个 for 循环用于创建这些并将它们添加到all_cards列表中。这是卡片对象的列表。为每张卡创建两个实例,以便在列表中有匹配对。
all_cards = []
# Create individual card objects, two per image
for key in cards_available.keys():
# Add to list of cards
all_cards.append(Card(key, card_back, cards_available[key]))
# Add again (to have 2 cards for each img)
all_cards.append(Card(key, card_back, cards_available[key]))
这些函数列在后面,后面是对文件底部的deal_cards的调用。这需要放在定义了deal_cards函数之后;否则,会导致错误。把它放在最后可以让代码更容易理解。
deal_cards函数的工作原理是从卡片中创建一个所有钥匙的列表。然后它调用random.shuffle函数,这个函数将卡片混合成一个随机的顺序。然后,它根据卡片之间的间距,用它们的坐标更新每张卡片。可以通过使用列表中的索引来访问这些卡,如以下示例条目所示:
all_cards[key].set_position(xpos,ypos)
接下来是更新功能。它首先检查游戏是新的还是已完成的。如果是这种情况,它不做任何由关键字pass指示的事情。使用 pass 没有任何作用,但是如果您计划在将来添加额外的代码,它可以作为一个占位符。如果游戏正在进行中,那么它调用计时器上的get_time_remaining方法,并在游戏结束时改变游戏状态。
if (count_down.get_time_remaining()<=0):
game_state.set_game_over()
更新游戏的大部分代码是由鼠标动作驱动的,所以是在on_mouse_down而不是update函数中。on_mouse_down 函数根据用户是否在玩游戏而被不同地处理。如果游戏没有在进行中,那么点击改变游戏的状态,例如开始游戏。如果游戏正在进行中,那么它将首先测试两张已经翻过的牌。如果是,那么它测试两张卡片是否匹配,或者隐藏卡片(如果匹配)或者将卡片重新设置为面朝下。如果两张卡都还没有翻过来,那么它使用collidepoint方法检查一张卡是否已经被点击,如果是,就翻过来并更新游戏状态。还有一个针对end_level_reached功能的检查,该功能检查是否所有的牌都已被翻过,如果是,则洗牌以备玩家重新开始。
draw 函数将一些消息放在屏幕上,如果合适的话,调用 draw 方法在屏幕上显示每张卡片。
您可能已经注意到,在任何函数中都没有更新的全局变量。类实例的行为确实像全局变量,但是因为它们是使用类的方法更新的,这意味着与直接更新全局变量相比,它不太可能产生难以理解的错误。
这就完成了游戏。这项运动还有很大的改进空间。您可以通过提供不同的卡图案来改善游戏的外观,或者通过改变卡的数量或玩游戏的时间长度来改变难度。你也可以把游戏改成双人游戏,或者让玩家和电脑比赛,而不是和时间赛跑。使用面向对象技术可能比使用过程化编码风格更容易。
摘要
面向对象编程是过程编程的一种替代方法,它将数据和处理数据的方法紧密地联系在一起。这对于代码重用和随着代码量的增加帮助组织程序结构特别有用。本章解释了面向对象编程的一些关键概念,以及如何在 Python 中实现它们。它包括一个游戏,演示了如何实现这些概念。
下一章将着眼于在游戏中加入人工智能来创造一个基于计算机的竞争者。
十、人工智能
电脑游戏中的人工智能(AI)是通过编程让电脑表现得像有智能一样。一般来说,这可能是显示由计算机控制的角色或对象背后的智能。
这通常不同于人们经常与人工智能联系在一起的机器学习。机器学习是一种与语音识别或模式识别等其他系统相关联的人工。
在电脑游戏中,人工智能可以像敌人采取的预先确定的路线一样简单,也可以包括一些复杂的算法,跟踪玩家的移动并以逼真的方式做出反应。为了更好地工作,需要为玩家设置一个合适的难度。机器学习的问题是,如果你用它来创造一个对手,那么它可能会变得不可战胜,而不仅仅是具有挑战性。机器学习可能更适合创建逼真的背景或特效。
当我提到人工智能时,我实际上是在寻找可以用来创建适当级别的计算机播放器的算法。这一章将会看到一些可以应用到游戏中的简单人工智能的例子,以及一些关于如何制作一个计算机播放器的理论和一些代码例子。
人工智能记忆游戏
从第九章到第九章的记忆游戏目前是一个试图超越时间的案例。这是一个小小的挑战,但和与对手比赛是不一样的。相反,创造一个人工智能玩家来对抗是可能的。为了设计 AI 播放器,考虑人们通常如何玩游戏,挑战是什么,以及他们使用什么策略来获胜。顾名思义,游戏中的挑战是记忆。如果你能在翻牌时记住所有的牌,那么赢得比赛的机会就大大增加了。也有一个运气的因素,我们可以考虑到人工智能。我也将展示人工智能可以被调整来创造不同的困难。
在我第一次尝试重写代码时,我在现有文件中添加并更改了代码。随着这种情况的发展,代码变得又长又混乱,这是一个典型的糟糕的例子。为了解决这个问题,我重构了代码,添加了新的类来简化程序。随着代码的增加,会有多个不同的类,跟踪文件变得更加困难。让这一点更容易理解的一个方法是创建一个图表来显示类之间的关系。为此,我创建了一个 UML 类图,如图 10-1 所示。该图只是一个近似值,因为包含所有的属性和方法会显得过于拥挤。它还将顶级 memory.py 文件显示为一个类,这是不正确的。尽管它不是一个“纯粹的”UML 文件,但它对于展示程序如何工作是很有用的。
图 10-1
记忆游戏的 UML 类图
图上的线条和箭头显示了类之间的关系。三角形箭头表示继承,其中子节点从父节点继承属性和方法。实心菱形显示组合关系,也称为“具有”关系,因为该类具有该类的一个或多个实例。大多数组合是 1 对 1 的(为了简单起见,省略了数字),但是 CardTable 包含一对多关系(1:∑)中的多个卡片实例。
代码文件包含在名为 memorygame2 的目录中的源代码中。第一类是卡片类。这个类与以前的版本相比只有一些变化。代码如清单 10-1 所示。
from pgzero.actor import Actor
# Card is based on an Actor (uses inheritance)
class Card(Actor):
def __init__(self, name, back_image, card_image):
Actor.__init__(self, back_image, (0,0))
self.name = name
self.back_image = back_image
self.card_image = card_image
# Status can be 'back' (turned over) 'front' (turned up) or 'hidden' (already used)
self.status = 'back'
# Number is unique number based on position
# count left to right, top to bottom
# updated after dealt
self.number = None
# Override Actor.draw
def draw(self):
if (self.status == 'hidden'):
return
Actor.draw(self)
def turn_over(self):
if (self.status == 'back'):
self.status = 'front'
self.image = self.card_image
elif (self.status == 'front'):
self.status = 'back'
self.image = self.back_image
# Attempt to turn over a hidden card - ignore
else:
return
def hide(self):
self.status = 'hidden'
# When unhide set it to back image
def unhide (self):
self.status = 'back'
self.image = self.back_image
def is_hidden (self):
if self.status == 'hidden':
return True
return False
# Is it turned to face forward
def is_faceup (self):
if self.status == 'front':
return True
return False
# Is it turned to face down
def is_facedown (self):
if self.status == 'back':
return True
return False
def reset (self):
self.unhide()
def set_position(self, x, y):
self.x = x
self.y = y
def equals (self, othercard):
if self.name == othercard.name:
return True
return False
Listing 10-1Card class for AI memory game
一个新的类是 CardTable 类,创建它是为了简化 memory.py 文件中的一些代码。它包含了所有卡片的列表。它还包括设置牌桌,发牌,然后在屏幕上把它们都画出来的方法。有一个方法可以返回所有面朝下的牌,这是人工智能需要知道它可以从哪些牌中选择的。还有一种方法是测试是否到达关卡末尾(所有牌都配对成功)。
CardTable 类的代码如清单 10-2 所示。
import random
from card import Card
class CardTable:
def __init__ (self, card_back, cards_available):
self.cards = []
# Create individual card objects, two per image
for key in cards_available.keys():
# Add to list of cards
self.cards.append(Card(key, card_back, cards_available[key]))
# Add again (to have 2 cards for each img)
self.cards.append(Card(key, card_back, cards_available[key]))
def draw_cards(self):
for this_card in self.cards:
this_card.draw()
# Set the table settings
def setup_table(self, card_start_x, card_start_y, num_cards_per_row, x_distance_between_cards, y_distance_between_cards):
self.card_start_x = card_start_x
self.card_start_y = card_start_y
self.num_cards_per_row = num_cards_per_row
self.x_distance_between_cards = x_distance_between_cards
self.y_distance_between_cards = y_distance_between_cards
# Returns all cards that are face down as Card objects
def cards_face_down(self):
selected_cards = []
for this_card in self.cards:
if (this_card.is_facedown()):
selected_cards.append(this_card)
return selected_cards
# Shuffle the cards and update their positions
def deal_cards(self):
# Create a temporary list of card indexes that is then shuffled
keys = []
for i in range (len(self.cards)):
keys.append(i)
random.shuffle(keys)
# Setup card positions
xpos = self.card_start_x
ypos = self.card_start_y
cards_on_row = 0
# Give each card number based on position
# count left to right, top to bottom
card_number = 0
for key in keys:
# Reset (ie. unhide if hidden and display back)
self.cards[key].reset()
self.cards[key].number = card_number
self.cards[key].set_position(xpos,ypos)
xpos += self.x_distance_between_cards
cards_on_row += 1
# If reached end of row - move to next
if (cards_on_row >= self.num_cards_per_row):
cards_on_row = 0
xpos = self.card_start_x
ypos += self.y_distance_between_cards
card_number += 1
# If reach end of level
def end_level_reached(self):
for card in self.cards:
if (not card.is_hidden()):
return False
return True
def check_card_clicked (self, pos):
for this_card in self.cards:
# If not facedown then skip
if (not this_card.is_facedown()):
continue
if (this_card.collidepoint(pos)):
return this_card
return None
Listing 10-2CardTable class file
GamePlay 类是之前 GamePlay 类的简化版本。分数属性已经被删除,因为它现在由玩家类处理,为每个玩家提供分数。还有额外的状态属性和方法来处理第二个玩家。游戏类的代码包含在清单 10-3 中。
# State is tracked as a number, but to make the code readable constants are used
STATE_NEW = 0 # Game ready to start, but not running
STATE_PLAYER1_START = 10 # Player 1 to turn over card
STATE_PLAYER1_CARDS_1 = 11 # Card 1 turned over
STATE_PLAYER1_CARDS_2 = 12 # Card 2 turned over
STATE_PLAYER2_START = 20 # Player 2 starts go
STATE_PLAYER2_WAIT = 21 # Delay before Card 1 turned over
STATE_PLAYER2_CARDS_1 = 22 # Card 1 turned over
STATE_PLAYER2_CARDS_2 = 23 # Card 2 turned over
STATE_END = 50
# Number of seconds to display high score before allowing click to continue
TIME_DISPLAY_SCORE = 3
class GamePlay:
def __init__ (self):
self.state = STATE_NEW
# If game has not yet started
def is_new_game(self):
if self.state == STATE_NEW:
return True
return False
def is_game_over(self):
if self.state == STATE_END:
return True
return False
def is_player_1(self):
if (self.state >= STATE_PLAYER1_START and self.state <= STATE_PLAYER1_CARDS_2):
return True
return False
def is_player_2(self):
if (self.state >= STATE_PLAYER2_START and self.state <= STATE_PLAYER2_CARDS_2):
return True
return False
def is_player_2_start(self):
if (self.state == STATE_PLAYER2_START):
return True
return False
def is_player_2_wait(self):
if (self.state == STATE_PLAYER2_WAIT):
return True
return False
def is_player_2_card1(self):
if (self.state == STATE_PLAYER2_CARDS_1):
return True
return False
def is_player_2_card2(self):
if (self.state == STATE_PLAYER2_CARDS_2):
return True
return False
def set_player_2_wait(self):
self.state = STATE_PLAYER2_WAIT
def set_player_2_card1(self):
self.state = STATE_PLAYER2_CARDS_1
def set_player_2_card2(self):
self.state = STATE_PLAYER2_CARDS_2
def start_game(self):
self.state = STATE_PLAYER1_START
def set_game_over(self):
# player gets to see high score
self.state = STATE_END
def is_game_running(self):
if (self.state >= STATE_PLAYER1_START and self.state < STATE_END):
return True
return False
# Continue with current player (matched correctly)
def continue_player (self):
if self.state <= STATE_PLAYER1_CARDS_2:
self.state = STATE_PLAYER1_START
else:
self.state = STATE_PLAYER2_START
# Switch to next player (not matched)
def next_player (self):
if self.state <= STATE_PLAYER1_CARDS_2:
self.state = STATE_PLAYER2_START
else:
self.state = STATE_PLAYER1_START
def set_new_game(self):
self.state = STATE_NEW
def is_pair_turned_over(self):
if (self.state == STATE_PLAYER1_CARDS_2):
return True
return False
# If a card is clicked then update the state accordingly
def card_clicked(self):
if (self.state == STATE_PLAYER1_START):
self.state = STATE_PLAYER1_CARDS_1
elif (self.state == STATE_PLAYER1_CARDS_1):
self.state = STATE_PLAYER1_CARDS_2
Listing 10-3GamePlay class file
Timer 类与前面的相同,但是使用方式不同。它不是用来作为玩家对战的计时器,而是用来为人工智能玩家添加延迟,以便人类玩家可以看到计算机正在翻的牌。清单 10-4 中显示了 Timer 类。
import math
import time
class Timer():
def __init__(self, start_count):
self.start_count = start_count
self.start_time = time.time()
# start count down, with optional parameter to replace the start_count value
# -1 is used as a "magic number", this method should only be called with positive number
# if it isn't given a number then -1 indicates no new time give
def start_count_down(self, new_time = -1):
if (new_time >= 0):
self.start_count = new_time
self.start_time = time.time()
def get_time_remaining(self):
current_time = self.start_count + self.start_time - time.time()
if (current_time <= 0):
return 0
return math.ceil(current_time)
Listing 10-4Timer class file
玩家类在早期版本中被考虑过,但当时并不需要。随着 AI 的加入,为玩家提供一个独立的职业变得更加有用。这是一个简单的类,保存玩家和卡片选择的分数。它使用 card 类,该类具有在select_card方法期间传递的卡的实例,然后使用get_card方法返回它。这就是图 10-1 中玩家和卡类之间构成的原因。播放器类的代码如清单 10-5 所示。
from card import Card
class Player():
def __init__ (self):
# Track which cards are turned over
self.guess = [None, None]
self.score = 0
def score_point (self):
self.score += 1
# Returns a single card object - either 0 or 1
def get_card (self, card_number):
return self.guess[card_number]
# Reset cards held in hand, but does not hide / turn_over card
def reset_cards(self):
self.guess[0] = None
self.guess[1] = None
def select_card(self, card):
if (self.guess[0] == None):
self.guess[0] = card
else:
self.guess[1] = card
# Returns the number of cards that are selected
def num_cards_selected(self):
if (self.guess[0] == None):
return 0
elif (self.guess[1] == None):
return 1
else:
return 2
Listing 10-5Player class file
最后一个类文件包含 PlayerAi 类,它继承了 Player 类,增加了 Ai 玩家进行随机猜测的能力。这是人工智能的一个非常基本的形式,稍后会详细介绍。代码包含在清单 10-6 中。
import random
from player import Player
class PlayerAi (Player):
def __init__(self):
Player.__init__(self)
def make_guess(self, available_cards):
self.guess_random(available_cards)
def guess_random (self, available_cards):
this_guess = random.choice(available_cards)
this_guess.turn_over()
self.select_card(this_guess)
def get_card (self, card_number):
return self.guess[card_number]
Listing 10-6Player class file
最后,memory.py 文件已经更新。用户交互仍然在on_mouse_down函数中处理,但是它现在在更新函数中包括了 AI 播放器。每当人工智能玩家执行一个操作,都会有一个由timer.start_count_down触发的延迟,这有效地暂停了人工智能的任何操作,直到timer.get_time_remaining显示时间已经超过。这显示在清单 10-7 中。
# Memory Card Game - PyGame Zero
import random
from card import Card
from gameplay import GamePlay
from player import Player
from playerai import PlayerAi
from timer import Timer
from cardtable import CardTable
# These constants are used to simplify the game
# For more flexibility these could be replaced with configurable variables
# (eg. different number of cards for different difficulty levels)
NUM_CARDS_PER_ROW = 4
X_DISTANCE_BETWEEN_CARDS = 120
Y_DISTANCE_BETWEEN_CARDS = 120
CARD_START_X = 220
CARD_START_Y = 130
TITLE = "Lake District Memory Game"
WIDTH = 800
HEIGHT = 600
cards_available = {
'airafalls' : 'memorycard_airafalls',
'ambleside' : 'memorycard_ambleside',
'bridgehouse' : 'memorycard_bridgehouse',
'derwentwater' : 'memorycard_derwentwater',
'ravenglassrailway' : 'memorycard_ravenglassrailway',
'ullswater' : 'memorycard_ullswater',
'weatherstone' : 'memorycard_weatherstone',
'windermere' : 'memorycard_windermere'
}
card_back = "memorycard_back"
## Setup instance variables
game_state = GamePlay()
player1 = Player()
ai = PlayerAi()
# Timer is used for AI thinking time
timer = Timer(2)
all_cards = CardTable(card_back, cards_available)
all_cards.setup_table(CARD_START_X, CARD_START_Y, NUM_CARDS_PER_ROW, X_DISTANCE_BETWEEN_CARDS, Y_DISTANCE_BETWEEN_CARDS)
all_cards.deal_cards()
def update():
if (game_state.is_player_2_start()):
timer.start_count_down()
game_state.set_player_2_wait()
if (game_state.is_player_2_wait()):
if (timer.get_time_remaining() <= 0):
ai.make_guess(all_cards.cards_face_down())
timer.start_count_down()
game_state.set_player_2_card1()
# card 1 turned
elif (game_state.is_player_2_card1()):
if (timer.get_time_remaining() <= 0):
ai.make_guess(all_cards.cards_face_down())
timer.start_count_down()
game_state.set_player_2_card2()
# Card 2 selected - wait then check if matches
elif (game_state.is_player_2_card2()):
if (timer.get_time_remaining() <= 0):
if ai.get_card(0).equals(ai.get_card(1)):
# If match add points and hide the cards
ai.score_point()
ai.get_card(0).hide()
ai.get_card(1).hide()
ai.reset_cards()
# Game Over
if (all_cards.end_level_reached()):
game_state.set_game_over()
# If user guess correct then they get another attempt
else:
game_state.continue_player()
# If not match then turn both around
else:
ai.get_card(0).turn_over()
ai.get_card(1).turn_over()
ai.reset_cards()
game_state.next_player()
# Mouse clicked
def on_mouse_down(pos, button):
# Only interested in the left button
if (not button == mouse.LEFT):
return
# If new game then this click is to start the game
if (game_state.is_new_game() or game_state.is_game_over()):
game_state.start_game()
all_cards.deal_cards()
player1.score = 0
ai.score = 0
return
## Reach here then we are in game play
# Is it player1's turn
if (game_state.is_player_1()):
# Check for both already clicked and this is a click to test
if (game_state.is_pair_turned_over()):
if (player1.get_card(0).equals(player1.get_card(1))):
# If match add points and hide the cards
player1.score_point()
player1.get_card(0).hide()
player1.get_card(1).hide()
player1.reset_cards()
# End of game
if (all_cards.end_level_reached()):
game_state.set_game_over()
# If user guess correct then they get another attempt
else:
game_state.continue_player()
# If not match then turn both around
else:
player1.get_card(0).turn_over()
player1.get_card(1).turn_over()
player1.reset_cards()
game_state.next_player()
return
# Check if clicked on a card
card_clicked = all_cards.check_card_clicked(pos)
if (card_clicked != None):
card_clicked.turn_over()
player1.select_card(card_clicked)
# Update state
game_state.card_clicked()
def draw():
screen.fill((220, 220, 220))
if (game_state.is_new_game()):
screen.draw.text("Click mouse to start", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
if (game_state.is_game_over()):
screen.draw.text("Game Over\nPlayer 1 score: "+str(player1.score)+"\nPlayer 2 (AI) score: "+str(ai.score), fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
if (game_state.is_game_running()):
# Set colors based on which player is selected
if (game_state.is_player_1()):
player1_color = (0,0,0)
player2_color = (128,128,128)
else:
player1_color = (128,128,128)
player2_color = (0,0,0)
all_cards.draw_cards()
screen.draw.text("Player 1: "+str(player1.score), fontsize=40, bottomleft=(50,50), color=player1_color)
screen.draw.text("Player 2 (AI): "+str(ai.score), fontsize=40, bottomleft=(550,50), color=player2_color)
# Display computer status during ai turns
if (game_state.is_player_2_wait() or game_state.is_player_2_card1()):
screen.draw.text("Thinking which card to pick", fontsize=40, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")
Listing 10-7The main memory.py file with basic AI
这可以运行和发挥,但非常容易击败。游戏每次只是随机选择一张牌,所以直到只剩下几张牌的时候,他们得到匹配的概率都很小。
美好的记忆
为了让游戏更有挑战性,我们可以让电脑记住所做的猜测。由于播放器代码与代码的其余部分是分离的,所以只有两个文件需要更新。这些文件包含 Player 类和 PlayerAi 类。这里将列出两个修改过的文件,但是完整的源代码包含在 memory3 目录中。
首先,需要有一个地方来存放已经看过的卡片。如果看到的牌存储在 PlayerAi 类中,那么它只会看到 Ai 玩家翻出来的牌。如果卡片被存储在玩家类中,那么就有可能存储所有由人类玩家和人工智能玩家翻开的卡片。
将列表保存为类变量而不是实例变量将使它对所有实例可见,包括子类的所有实例。这是通过将变量放在类的顶部来实现的,如下所示:
class Player():
card_memory = {}
click_order = []
这里创建了两个变量:card_memory是一个保存卡片的字典,其中包含卡片名称的索引,click_order是一个列表,它记录了卡片被点击的顺序。目前实际上并不需要第二个,但是现在添加它将简化下一个阶段。
每当出现一张牌时,为了更新类变量,需要将以下内容添加到select_card方法中:
Player.card_memory[card.number] = card
由于这个方法是由 PlayerAi 继承的,因此每当人类玩家或电脑玩家翻牌时都会调用它。在一个新游戏开始时,这些变量也需要被重置,这个新游戏是在一个静态方法reset_cards中实现的。更新后的 player.py 文件如清单 10-8 所示。
from card import Card
class Player():
# Index of cards that ai remembers
# Stored as dictionary as cards will be missing or be forgotten
card_memory = {}
click_order = []
def __init__ (self):
# Track which cards are turned over
self.guess = [None, None]
self.score = 0
@staticmethod
def new_game():
Player.card_memory = {}
Player.click_order = []
def score_point (self):
self.score += 1
# Returns a single card object - either 0 or 1
def get_card (self, card_number):
return self.guess[card_number]
# Reset cards held in hand, but does not hide / turn_over card
def reset_cards(self):
self.guess[0] = None
self.guess[1] = None
def select_card(self, card):
if (self.guess[0] == None):
self.guess[0] = card
else:
self.guess[1] = card
Player.card_memory[card.number] = card
# Returns the number of cards that are selected
def num_cards_selected(self):
if (self.guess[0] == None):
return 0
elif (self.guess[1] == None):
return 1
else:
return 2
Listing 10-8Updated Player class to add improved AI
PlayerAi 类中添加了三种不同的方法,展示了实现改进内存的不同方式。更新后的源代码如清单 10-9 所示。每种新方法将在后面解释。
import random
from player import Player
class PlayerAi (Player):
def __init__(self):
Player.__init__(self)
def make_guess(self, available_cards):
#self.guess_random(available_cards)
#self.guess_remember_all(available_cards)
#self.guess_remember_sometimes(available_cards)
self.guess_remember_recent(available_cards)
def guess_random (self, available_cards):
this_guess = random.choice(available_cards)
this_guess.turn_over()
self.select_card(this_guess)
def guess_remember_all (self, available_cards):
# If first guess then use random
if (self.guess[0] == None):
self.guess_random(available_cards)
return
# Search to see if we have seen a matching card
for search_card in Player.card_memory.values():
# ignore if current card - or card has been hidden since
if (search_card == self.guess[0] or search_card.is_hidden()):
continue
# Check to see if the card matches
if (search_card.equals(self.guess[0])):
search_card.turn_over()
self.select_card(search_card)
return
# If not found the matching card then use random
self.guess_random(available_cards)
def guess_remember_sometimes (self, available_cards):
# If first guess then use random
if (self.guess[0] == None):
self.guess_random(available_cards)
return
# Random whether make a proper guess or random guess
if (random.randint(1,10) < 5):
self.guess_random(available_cards)
return
# Search to see if we have seen a matching card
for search_card in Player.card_memory.values():
# ignore if current card - or card has been hidden since
if (search_card == self.guess[0] or search_card.is_hidden()):
continue
# Check to see if the card matches
if (search_card.equals(self.guess[0])):
search_card.turn_over()
self.select_card(search_card)
return
# If not found the matching card then use random
self.guess_random(available_cards)
def guess_remember_recent (self, available_cards):
# If first guess then use random
if (self.guess[0] == None):
self.guess_random(available_cards)
return
# Get last 4 cards that were clicked
# These are just card numbers
recent_cards = Player.click_order[:-4]
# Search to see if one of those is a matching card
for search_card in Player.card_memory.values():
# ignore if current card - or card has been hidden since
if (search_card == self.guess[0] or search_card.is_hidden()):
continue
# ignore if not a recent card
if (search_card.number not in recent_cards):
continue
# Check to see if the card matches
if (search_card.equals(self.guess[0])):
search_card.turn_over()
self.select_card(search_card)
return
# If not found the matching card then use random
self.guess_random(available_cards)
def get_card (self, card_number):
return self.guess[card_number]
Listing 10-9Updated PlayerAi class to add improved AI
新方法的第一个是guess_remember_all,它会记住每一张翻过来的牌。该方法从选择一张随机卡片开始。如果相应的对子已经翻了,那么它会翻相应的牌。这是通过在Player.card_memory.values中查找卡片来处理的,该卡片返回字典中所有值的列表。该方法的关键部分如下:
for search_card in Player.card_memory.values():
这是一个循环遍历字典中所有值的 for 循环。该值是保存在变量search_card中的卡对象。然后,它使用
if (search_card.equals(self.guess[0])):
如果它与先前翻过来的牌匹配,那么它使用
search_card.turn_over()
self.select_card(search_card)
由于这种方法记住了每一张翻开的牌,所以这是一个很难攻克的关卡。如果你有一个好的记忆或者你选择的牌非常幸运,那么击败它是可能的,但是这是令人沮丧的困难。
下一个方法叫做guess_remember_sometimes。顾名思义,它能记住以前的卡片,但只是有时候。这是基于随机检查来确定是否从存储器中搜索卡。这与guess_remember_all基本相同,除了以下附加代码:
if (random.randint(1,10) < 5):
self.guess_random(available_cards)
return
它创建一个介于 1 和 10 之间的随机数。如果数字小于 5,则它执行随机猜测。如果数字为 5 或更大,则它在存储器中搜索该卡。要比较的值(在本例中为 5)可以上下调整,以提高成功猜测的概率。
这给出了一个合理的难度水平,但不是特别现实。原因是人类玩家通常比之前翻的牌更容易记住最近翻的牌。
最后一个方法叫做guess_remember_recent。这为电脑玩家提供了短期记忆。所有翻过来的牌仍然存储在字典中,但是计算机在检查匹配时只使用在Player.click_order变量中列出的最近的牌。
这是通过创建一个单独的列表来实现的,该列表只保存click_order列表的最后四个条目。
recent_cards = Player.click_order[:-4]
然后,当检查匹配时,它使用以下内容跳过不在recent_cards列表中的任何卡:
if (search_card.number not in recent_cards):
continue
你可以试着调整电脑浏览的最近卡片数量来改变难度。
你还可以做其他的事情来让它看起来更真实。例如,你可以将这些技术结合起来,使电脑玩家的行为更自然,因为它对最近的牌有很好的记忆,但随着牌越翻越多,随机猜对的可能性就越小。这是留给读者的一个练习。
如果你看一下make_guess方法,你可以看到除了guess_remember_recent方法,不同的方法都被注释掉了。这为您提供了一种尝试不同方法并进行比较的方式。只需删除注释掉您想要测试的字符和注释掉其他字符的“#”字符。
def make_guess(self, available_cards):
#self.guess_random(available_cards)
#self.guess_remember_all(available_cards)
#self.guess_remember_sometimes(available_cards)
self.guess_remember_recent(available_cards)
你可以做的一件事是让玩家选择难度。想一想你可以如何添加它。我在源代码中添加了另一个版本的记忆游戏,它包含了这个选项。它存储在目录 memory4 中;在查看提供的代码之前,先考虑一下如何添加它。
战舰
你如何创造人工智能的另一个例子可以在游戏战舰中看到。这是你在某个阶段几乎肯定玩过的经典游戏。最初是一个纸质游戏,你必须尝试击沉你对手的船只,现在这通常是一个棋盘游戏,使用模型船和塑料桩来显示船只何时被击中或错过。如图 10-2 所示。
图 10-2
传统战舰棋盘游戏
这个游戏将是这个经典游戏的电脑版,用来演示人工智能。玩战舰所涉及的智力是我们大多数人下意识做的事情。的确,这个游戏很大程度上是基于运气,但如果没有策略,非智能计算机版本几乎肯定会输给人类对手。
玩战列舰有三个主要策略可以考虑:
-
随机——每回合随机出牌是最基本的策略。在尝试过许多位置之前,成功击中每艘对手船只的几率非常低。
-
随机感知船只——第二个策略是你随机射击,直到成功击中对手的船只。一旦击中对手的船,你就向邻近的位置开火,直到船被击沉。船沉了之后,你又开始尝试随机的位置。
-
概率分析——这是一种终极策略,电脑对手可以计算出剩余船只在特定位置的概率。
在这个游戏中,我实现了第二个随机射击的策略。原因是第一关对大多数玩家来说太容易了,而第三关可能太难了。
为了保持代码简短,书中列出的版本对人类和电脑玩家的飞船都有固定的位置。这使我能够演示计算机播放器的工作方式,而不必列出许多额外的代码。然而,我在源代码中加入了第二个版本,这是一个完整的游戏,玩家可以定位他们自己的船,计算机为他们的船选择随机的位置。本书列出的版本在战舰目录中,更完整的版本在战舰 2 目录中。
这个游戏中包含了六个 Python 文件,加上 image 文件夹中的一些图片。该游戏使用了与记忆游戏类似的面向对象的编程方法。文件 battleship.py 是主可执行文件。每个玩家都有一个舰队,舰队由五艘船组成。每艘船都是 Actor 类的孩子。有一个 grid 类处理网格位置,并将网格上的位置转换为屏幕上的位置。最后,Ai 类是本章最感兴趣的一个,因为它是智能编码的地方。
我将快速浏览每个文件,并在最后解释 Ai 类。
第一个文件是战舰. py 主程序文件。这显示在清单 10-10 中。
from fleet import Fleet
from grid import Grid
from ai import Ai
WIDTH = 1024
HEIGHT = 768
# Start of your grid (after labels)
YOUR_GRID_START = (94,180)
# Start of enemy grid
ENEMY_GRID_START = (544,180)
GRID_SIZE = (38,38)
player = "player1"
grid_img_1 = Actor ("grid", topleft=(50,150))
grid_img_2 = Actor ("grid", topleft=(500,150))
own_fleet = Fleet(YOUR_GRID_START, GRID_SIZE)
enemy_fleet = Fleet(ENEMY_GRID_START, GRID_SIZE)
## Manually position ships position random or allow
## player to choose.
own_fleet.add_ship("destroyer",(7,0),"horizontal")
own_fleet.add_ship("cruiser",(1,1),"horizontal")
own_fleet.add_ship("submarine",(1,4),"vertical")
own_fleet.add_ship("battleship",(4,5),"horizontal")
own_fleet.add_ship("carrier",(9,3),"vertical")
enemy_fleet.add_ship("destroyer",(5,8),"horizontal", True)
enemy_fleet.add_ship("cruiser",(3,4),"vertical", True)
enemy_fleet.add_ship("submarine",(4,1),"horizontal", True)
enemy_fleet.add_ship("battleship",(8,3),"vertical", True)
enemy_fleet.add_ship("carrier",(1,1),"vertical", True)
# Don't need a player1 object
# Player 2 represents the AI player
player2=Ai()
def draw():
screen.fill((192,192,192))
grid_img_1.draw()
grid_img_2.draw()
screen.draw.text("Battleships", fontsize=60, center=(WIDTH/2,50), shadow=(1,1), color=(255,255,255), scolor=(32,32,32))
screen.draw.text("Your fleet", fontsize=40, topleft=(100,100), color=(255,255,255))
screen.draw.text("The enemy fleet", fontsize=40, topleft=(550,100), color=(255,255,255))
own_fleet.draw()
enemy_fleet.draw()
if (player == "gameover"):
screen.draw.text("Game Over", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor=(32,32,32))
def update():
global player
if (player == "player2"):
grid_pos = player2.fire_shot()
result = own_fleet.fire(grid_pos)
player2.fire_result (grid_pos, result)
# If ship sunk then inform Ai player
if (result == True):
if (own_fleet.is_ship_sunk_grid_pos(grid_pos)):
player2.ship_sunk(grid_pos)
# As a ship is sunk - check to see if all ships are sunk
if own_fleet.all_sunk():
player = "gameover"
return
# If reach here then not gameover, so switch back to main player
player = "player1"
def on_mouse_down(pos, button):
global player
if (button != mouse.LEFT):
return
if (player == "player1"):
if (enemy_fleet.grid.check_in_grid(pos)):
grid_location = enemy_fleet.grid.get_grid_pos(pos)
#print (Grid.grid_to_string(grid_location))
enemy_fleet.fire(grid_location)
if enemy_fleet.all_sunk():
player = "gameover"
else:
# switch to player 2
player = "player2"
Listing 10-10The main battleship.py program file for Battleship game
这个文件导入了一些类并创建了主类的实例。这包括两个网格和相应的舰队,一个是人类玩家的舰队位置,另一个是电脑玩家的。通过人类和计算机舰队的硬编码位置,船只被添加到舰队中。这是为了减少这个阶段的代码量。draw 函数遍历各种对象,调用它们的每个绘制方法,并显示状态文本。
人类代码和人工智能代码是分离的。update 函数处理计算机玩家,而 on_mouse_down 函数处理与人类玩家的交互。
舰队类负责跟踪船只和射击。它包括测试一艘船是否沉没(在这种情况下,它被设置为可见)和测试整个舰队是否沉没的方法,这是游戏结束的触发器。fleet.py 的代码如清单 10-11 所示。
import math
from grid import Grid
from ship import Ship
from pgzero.actor import Actor
class Fleet:
def __init__ (self, start_grid, grid_size):
self.start_grid = start_grid
self.grid_size = grid_size
self.ships = []
self.grid = Grid(start_grid, grid_size)
self.shots = []
# Is there a ship at this position that has sunk
def is_ship_sunk_grid_pos (self, check_grid_pos):
# find ship at that position
for this_ship in self.ships:
if (this_ship.includes_grid_pos(check_grid_pos)):
return this_ship.is_sunk()
# If there is no ship at this position then return False
return False
def add_ship (self, type, position, direction, hidden=False):
self.ships.append(Ship(type, self.grid, position, direction, hidden))
# check through ships to see if any still floating
def all_sunk (self):
for this_ship in self.ships:
if not this_ship.is_sunk():
return False
return True
# Draws entire fleet (each of the ships)
def draw(self):
for this_ship in self.ships:
this_ship.draw()
for this_shot in self.shots:
this_shot.draw()
def fire (self, pos):
# Is this a hit
for this_ship in self.ships:
if (this_ship.fire(pos)):
# Hit
self.shots.append(Actor("hit",topleft=self.grid.grid_pos_to_screen_pos(pos)))
#check if this ship sunk
if this_ship.is_sunk():
# Ship sunk so make it visible
this_ship.hidden = False
return True
self.shots.append(Actor("miss",topleft=self.grid.grid_pos_to_screen_pos(pos)))
return False
Listing 10-11Fleet class for Battleship game
舰队类提供的主要内容之一是属于该舰队的所有船只的列表。这在列表self.ships中,是基于船舶等级创建的。它还保存所有已经发射的镜头,作为代表击中或未击中的演员的列表。
船舶等级如清单 10-12 所示。它是 Actor 类的一个子类,带有一些额外的代码来处理船只在适当网格上的位置,以及船只何时隐藏或可见。
from pgzero.actor import Actor
from grid import Grid
# Ship is referred to using an x,y position
class Ship (Actor):
def __init__ (self, ship_type, grid, grid_pos, direction, hidden=False):
Actor.__init__(self, ship_type, (10,10))
self.ship_type = ship_type
self.grid = grid
self.image = ship_type
self.grid_pos = grid_pos
self.topleft = self.grid.grid_pos_to_screen_pos((grid_pos))
# Set the actor anchor position to center of the first square
self.anchor = (38/2, 38/2)
self.direction = direction
if (direction == 'vertical'):
self.angle = -90
self.hidden = hidden
if (ship_type == "destroyer"):
self.ship_size = 2
self.hits = [False, False]
elif (ship_type == "cruiser"):
self.ship_size = 3
self.hits = [False, False, False]
elif (ship_type == "submarine"):
self.ship_size = 3
self.hits = [False, False, False]
elif (ship_type == "battleship"):
self.ship_size = 4
self.hits = [False, False, False, False]
elif (ship_type == "carrier"):
self.ship_size = 5
self.hits = [False, False, False, False, False]
def draw(self):
if (self.hidden):
return
Actor.draw(self)
def is_sunk (self):
if (False in self.hits):
return False
return True
def fire (self, fire_grid_pos):
if self.direction == 'horizontal':
if (fire_grid_pos[0] >= self.grid_pos[0] and
fire_grid_pos[0] < self.grid_pos[0]+self.ship_size and
fire_grid_pos[1] == self.grid_pos[1]):
self.hits[fire_grid_pos[0]-self.grid_pos[0]] = True
return True
else:
if (fire_grid_pos[0] == self.grid_pos[0] and
fire_grid_pos[1] >= self.grid_pos[1] and
fire_grid_pos[1] < self.grid_pos[1]+self.ship_size):
self.hits[fire_grid_pos[1]-self.grid_pos[1]] = True
return True
return False
# Does this ship cover this grid_position
def includes_grid_pos (self, check_grid_pos):
# If first pos then return True
if (self.grid_pos == check_grid_pos):
return True
# check x axis
elif (self.direction == 'horizontal' and
self.grid_pos[1] == check_grid_pos[1] and
check_grid_pos[0] >= self.grid_pos[0] and
check_grid_pos[0] < self.grid_pos[0] + self.ship_size):
return True
elif (self.direction == 'vertical' and
self.grid_pos[0] == check_grid_pos[0] and
check_grid_pos[1] >= self.grid_pos[1] and
check_grid_pos[1] < self.grid_pos[1] + self.ship_size):
return True
else :
return False
Listing 10-12Ship class for Battleship game
Ship 类使用船只类型来确定船只的大小。这是根据船名来定的,比如驱逐舰(两格位)或者战列舰(四格位)。它还会更新锚点位置。这与船上使用的航海锚无关,而是与 Pygame Zero 演员的锚位置有关。默认情况下,锚点是图像的中心,但在这种情况下,它被设置为船占据的第一个网格位置(顶部,左侧)的中心。这个位置用于放置船只及其旋转。这使得在网格上定位船只更加容易,因此当船只垂直放置时,它会在网格列中旋转。
然后,构造函数创建一个对应于每个网格位置的列表,名为self.hits。对于每个位置,该列表都被设置为 False,然后每当命中其中一个位置时,该列表都被更新为 True。如果它们都被设置为真,那么这艘船就被认为沉没了。这可以使用is_sunk方法进行测试。
fire 方法通过查看其网格位置是否与船只占据的任何位置相匹配来确定火是否击中船只,并相应地更新状态。方法includes_grid_position执行类似的检查,但是用于检查船只是否存在于该位置并且不改变其状态。
舰队和船只类中的方法使用网格位置,而不是屏幕位置。Grid 类用于将鼠标单击的屏幕位置转换为两个网格之一上的网格位置。战舰类和战舰. py 中的on_mouse_down函数都使用它。网格类如清单 10-13 所示。
import math
class Grid:
# Grid dimensions are in terms of screen pixels
def __init__ (self, start_grid, grid_size):
self.start_grid = start_grid
self.grid_size = grid_size
# Does co-ordinates match this grid - if so which screen_position
def check_in_grid (self, screen_pos):
if (screen_pos[0] < self.start_grid[0] or
screen_pos[1] < self.start_grid[1] or
screen_pos[0] > self.start_grid[0] + (self.grid_size[0] * 10) or
screen_pos[1] > self.start_grid[1] + (self.grid_size[1] * 10)):
return False
else:
return True
def get_grid_pos (self, screen_pos):
x_offset = screen_pos[0] - self.start_grid[0]
x = math.floor(x_offset / self.grid_size[0])
y_offset = screen_pos[1] - self.start_grid[1]
y = math.floor(y_offset / self.grid_size[1])
if (x < 0 or y < 0 or x > 9 or y > 9):
return None
return (x,y)
# Gets top left of a grid position - returns as screen position
def grid_pos_to_screen_pos (self, grid_pos):
x = self.start_grid[0] + (grid_pos[0] * self.grid_size[0])
y = self.start_grid[1] + (grid_pos[1] * self.grid_size[1])
return (x,y)
Listing 10-13Grid class for Battleship game
这是使用存储在grid_pos中的网格起始位置和存储在grid_size中的每个网格方块的大小来处理的。数学模块中的下限方法用于将值向下舍入到最接近的整数。
最后一个类是 Ai 类,它是实现计算机播放器的地方。这是本章的关键部分,因此将对其进行更详细的解释。代码如清单 10-14 所示。
import random
from grid import Grid
# Provides Ai Player
class Ai:
NA = 0
MISS = 1
HIT = 2
def __init__ (self):
# Create 2 dimension list with no shots fired
# access using [x value][y value]
# Pre-populate with NA
self.shots = [ [Ai.NA for y in range(10)] for x in range(10) ]
# Hit ship is the position of the first successful hit on a ship
self.hit_ship = None
def fire_shot(self):
# If not targeting hit ship
if (self.hit_ship == None):
return (self.get_random())
else:
# Have scored a hit - so find neighboring positions
# copy hit_ship into separate values to make easier to follow
hit_x = self.hit_ship[0]
hit_y = self.hit_ship[1]
# Try horizontal if not at edge
if (hit_x < 9):
for x in range (hit_x+1,10):
if (self.shots[x][hit_y] == Ai.NA):
return (x,hit_y)
if (self.shots[x][hit_y] == Ai.MISS):
break
if (hit_x > 0):
for x in range (hit_x-1,-1, -1):
if (self.shots[x][hit_y] == Ai.NA):
return (x,hit_y)
if (self.shots[x][hit_y] == Ai.MISS):
break
if (hit_y < 9):
for y in range (hit_y+1,10):
if (self.shots[hit_x][y] == Ai.NA):
return (hit_x,y)
if (self.shots[hit_x][y] == Ai.MISS):
break
if (hit_y > 0):
for y in range (hit_y-1,-1, -1):
if (self.shots[hit_x][y] == Ai.NA):
return (hit_x,y)
if (self.shots[hit_x][y] == Ai.MISS):
break
# Catch all - shouldn't get this, but just in case guess random
return (self.get_random())
def fire_result(self, grid_pos, result):
x_pos = grid_pos[0]
y_pos = grid_pos[1]
if (result == True):
result_value = Ai.HIT
if (self.hit_ship == None):
self.hit_ship = grid_pos
else:
result_value = Ai.MISS
self.shots[x_pos][y_pos] = result_value
def get_random(self):
# Copy only non-used positions into a temporary list
non_shots = []
for x_pos in range (0,10):
for y_pos in range (0,10):
if self.shots[x_pos][y_pos] == Ai.NA:
non_shots.append((x_pos,y_pos))
return random.choice(non_shots)
# Let Ai know that the last shot sunk a ship
# list_pos is provided, but not currently used
def ship_sunk(self, grid_pos):
# reset hit ship
self.hit_ship = None
Listing 10-14Ai class for Battleship game
在导入和类定义之后,有三个类变量,称为 NA、MISS 和 HIT。这些被用作常量,只是让代码的其余部分更容易理解。看了代码,就更容易理解那个 Ai 了。不中表示不中,而不仅仅是使用数字 1,对于 NA(在那个位置没有射击)和命中也是一样。
之后是通常的构造函数 init 它有一个入口
self.shots = [ [Ai.NA for y in range(10)] for x in range(10) ]
这是一种创建 2D 列表并用 Ai.NA 预先填充它的方法。最终将得到如下所示的列表:
[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]
网格中的每个位置都有一个条目,可以使用self.shots[x-pos][y-pos]来访问。如果您以前面的格式查看网格,那么 x 和 y 轴被交换(y 交叉,x 向下),但这只是它在打印清单中的表示方式。重要的是如何使用 x,y 位置访问它。
在构造函数中创建的另一个变量是self.hit_ship。这将记录最后一次射击成功击中目标的位置。当船沉没时,它被重置为无。
射击时,有多个阶段:
-
调用 fire_shot 方法,该方法计算出下一个“猜测”的射击位置。在这个阶段,Ai 类不知道那个镜头是否成功。
-
然后,battleship.py 从 fleet 类调用 fire 方法,该方法添加适当的命中或未命中演员,并返回 True 或 False 以指示该镜头是否命中目标。
-
调用 fire_result 方法,该方法允许 Ai 类更新镜头列表,以了解镜头是否导致命中。
-
如果船已经被击沉,那么调用 ship _ sunk 方法,这样 Ai 类就知道它不需要继续瞄准那艘船。
之所以需要在多个阶段完成,是因为人工智能看不到敌人的船在哪里。因此,它不知道它的拍摄是否成功。
fire_shot做的第一件事是查看它是否知道一艘尚未沉没的船的位置。它通过查看self.hit_ship是否被设置为无来实现。如果它不知道敌舰的位置,那么它会使用get_random方法进行随机猜测,如下所示:
def get_random(self):
# Copy only non-used positions into a temporary list
non_shots = []
for x_pos in range (0,10):
for y_pos in range (0,10):
if self.shots[x_pos][y_pos] == Ai.NA:
non_shots.append((x_pos,y_pos))
return random.choice(non_shots)
这使用random.choice方法从可用位置中进行选择。在调用它之前,它需要一个列表,只显示还没有尝试过的镜头,这就是代码的其余部分要做的。它创建一个non_shots列表,然后使用一个嵌套的 for 循环检查所有的网格位置,并将所有尚未尝试过的网格位置添加到non_shots列表中。然后网格位置被返回给fire_shot,它依次使用该位置作为其返回值。
如果已经有一艘船最近被击中了,但是还没有被击沉,那么self.hit_ship中就会有一个位置。在这种情况下,代码会尝试四个不同的方向,直到找到下一个合适的网格位置。合适的位置是没有尝试过的任何位置,并且与成功的拍摄相邻。从以下代码摘录中可以看出这一点:
if (hit_x < 9):
for x in range (hit_x+1,10):
if (self.shots[x][hit_y] == Ai.NA):
return (x,hit_y)
if (self.shots[x][hit_y] == Ai.MISS):
break
如果 hit_ship 的 x 位置小于 9(不在网格的右侧),那么它将向右遍历所有位置。如果它遇到一个值为 NA 的位置,那么这是一个有效的镜头,所以它返回那个位置。相反,如果它遇到一个失误,那么它知道船不在那个方向,所以它使用一个 break 从 for 循环中退出,然后代码继续检查下一个方向。
其他 if 语句做同样的事情,但在其他方向寻找,直到找到一个有效的镜头。
有一个最终条目,如果四个方向都不适用,那么它将返回一个随机的猜测。这不应该被调用,因为在船沉没之前,应该总是有一个有效的位置可以尝试。对于添加“以防万一”代码是否是一个好主意,存在意见分歧。我的理由是,如果有一些我没有想到的情况,或者代码中有错误,那么这将允许游戏继续进行,而不会给用户一个错误。相反的论点是,这可能会隐藏代码中的问题,游戏会继续运行,但不是以预期的方式运行。
当快照的结果已知时,调用fire_result方法。它用命中是否成功来更新网格位置。如果该镜头是命中的,并且hit_ship的值当前被设置为无,则它还更新hit_ship的值。最后一种方法是ship_sunk,它在船只成功沉没后将hit_ship的值重置为 0。
这段代码很好地实现了这个策略,但是还有一些地方需要改进。一个是 Ai 总是以相同的顺序尝试位置(水平然后垂直)。如果玩家明白这一点,那么他们就可以通过始终将船只放置在远离左右边缘且始终垂直的位置来获得优势。这只会产生很小的差异,但可以通过随机决定先尝试哪个方向来解决。它也可以在两艘船接触的地方被欺骗,首先击中一艘船,然后击沉第二艘船。它不会回去干掉它撞上的第一艘船。这些不会阻止游戏运行,但是会是一个很好的挑战,让读者创建一个改进的版本。
正如我在开始时警告的,图形游戏编程使用大量代码。走到这一步需要 300 行代码,但这还不包括让用户放置自己的船只或让计算机选择船只位置的能力。这是你可能想自己实现的东西,或者你可以在源代码中的文件夹 battleship2 中查找,在那里我创建了另一个版本,它实现了那个特性以及一些其他的改进。
决赛游戏截图如图 10-3 。
图 10-3
完整的战舰游戏
摘要
这一章探讨了如何让计算机像人类玩家一样运行。在列出的两个例子中,人工智能被创建来模仿人类在玩游戏时会经历的相同过程。
当设计一些计算机程序时,你可能希望让计算机尽可能“聪明”。计算机太聪明的问题是,计算机可以分析可能的结果,这使得它很难被击败。在创作电脑游戏时,重要的是要考虑游戏的难度,使其具有挑战性,但不要太难。
通过创建不同的难度级别或使游戏看起来更像人类,这些游戏还有改进的空间。你可能喜欢尝试调整人工智能或者思考如何将人工智能添加到其他游戏中。