Python、PyGame 和树莓派游戏开发教程(三)
十五、继承、组合和聚合
当大多数人学习面向对象编程时,他们会学到三样东西:
-
对象具有包含对象状态的属性(数据)。
-
控制访问(更改或查看)对象状态的方法。
-
可以使用一种叫做继承的技术来扩展对象。
还有其他的,但是这些是人们第一次接触面向对象编程时记住的三件主要事情。
大多数人关注最后一个:通过继承进行对象扩展。在很多情况下确实如此,但是有很多方法可以使用称为组合和聚合的技术来扩展对象。本节将介绍对象扩展的三种方法。
遗产
继承发生在 Python 语言的最底层。当你创建一个新的类时,你是在扩展一个叫做“object”的基类这个简单的物体
class Foo:
def bar(self):
print("bar")
foo = Foo()
foo.bar()
可以显式重写为
class Foo(object):
def bar(self):
print("bar")
foo = Foo()
foo.bar()
事实上,如果您正在使用较新的 Python 语法,我们鼓励您使用这种语法。在这篇文章的后面,你会看到它被用在“入侵者”游戏中。有关新旧方式的更多信息,请访问 https://wiki.python.org/moin/NewClassVsClassicClass 。
定义类时使用新的 MyClass(object)语法。
更进一步,让我们创建两个类。第一个是基类。
基类包含执行一组给定操作所需的基本功能级别。它可以包含方法,这些方法是将由子类实现的操作的占位符。
子类是从另一个类派生的任何类。实际上,你创建的每一个类都是 Python 基类“object”的子类。
基类和子类
在“pygamebook”中创建一个名为“ch15”的新文件夹,并在这个新文件夹中创建一个名为“baseclass.py”的文件,然后输入以下代码:
class MyBaseClass(object):
def methodOne(self):
print ("MyBaseClass::methodOne()")
当一个类从另一个类派生时,请记住将基类的名称放在新类名称后面的括号中:
class MyChildClass(MyBaseClass):
def methodOne(self):
print ("MyChildClass::methodOne()")
我们将创建一个函数来调用每个类的 methodOne()方法:
def callMethodOne(obj):
obj.methodOne()
此方法接受一个参数“obj ”,并调用该对象的 methodOne()方法。
instanceOne = MyBaseClass()
instanceTwo = MyChildClass()
然后,它创建“MyBaseClass”和“MyChildClass”类的实例。
callMethodOne(instanceOne)
callMethodOne(instanceTwo)
使用函数,我们传入基类和子类的实例。保存并运行程序。你应该看看
MyBaseClass::methodOne()
MyChildClass::methodOne()
调用该函数,然后它接受参数并调用它接收的对象的 methodOne()方法。在最后一个 callMethodOne()行之后添加另一行:
callMethodOne(5)
运行程序。您应该会看到类似如下的输出
MyBaseClass::methodOne()MyChildClass::methodOne()
Traceback (most recent call last):
File "baseclass.py", line 26, in <module>
callMethodOne(5)
File "baseclass.py", line 17, in callMethodOne
obj.methodOne()
AttributeError: 'int' object has no attribute 'methodOne'
这是因为 Python 内置的“int”对象不包含名为“methodOne”的方法。
Python 使用了一种叫做 duck typing 的技术。
当我看到一只像鸭子一样走路、像鸭子一样游泳、像鸭子一样嘎嘎叫的鸟时,我就把那只鸟叫做鸭子。
这意味着当 Python 看到一个对象上的方法调用时,它会假设该消息可以传递给它。这种技术的好处是继承几乎已经被一种叫做接口编程的技术所取代。
对接口编程意味着你不需要担心对象的内部工作;你只需要知道有哪些方法就可以了。
尽管如此,继承仍然有其适用的用途。例如,您可能有一个提供许多所需功能的基类。子类然后会实现它们特定的方法。
界面编程
让我们看另一个例子。我们将对两个不同的对象使用相同的方法,而不是使用继承:
class Dog(object):
def makeNoise(self):
print ("Bark!")
class Duck(object):
def makeNoise(self):
print ("Quack!")
animals = [ Dog(), Duck() ]
for a in animals:
a.makeNoise()
我们的两个类——Dog 和 Duck——都包含一个名为 makeNoise()的方法。创建一个包含 Dog 和 Duck 类实例的动物列表。然后遍历列表来调用每个对象的 makeNoise()方法。
关于构造函数和基类的注释
为了完善继承,我们需要提到调用对象构造函数的基类的推荐步骤。以下面两个类为例:
class Foo(object):
x = 0
def __init__(self):
print ("Foo constructor")
self.x = 10
def printNumber(self):
print (self.x)
class Bar(Foo):
def __init__(self):
print ("Bar constructor")
b = Bar()
b.printNumber()
当您运行此代码时,您将获得以下输出:
Bar constructor
0
即使“Bar”扩展了“Foo”,它也没有初始化“x”字段,因为没有调用父类的 init()方法。要正确地做到这一点,请将“Bar”的构造函数改为
def __init__(self):
super(Bar, self).__init__()
print ("Bar constructor")
这是怎么回事?super()方法允许我们引用基类;但是,基类需要知道两件事:派生类类型和实例。我们通过传入派生类的类型来实现这一点——在本例中是“Bar”和“self”然后我们可以调用方法 init()来正确设置我们的字段。当您运行该程序时,您应该看到
Foo constructor
Bar constructor
10
在派生类的构造函数中编写任何其他代码之前,必须始终调用基类的构造函数。如果您正在创建一个具有大量功能的基类并从它继承,这一点尤其正确。一定要调用基类的构造函数!
作文
组合是一个或多个对象包含在另一个对象中。对于复合,包含对象的创建和销毁由容器对象控制。容器对象通常充当所包含对象的控制器。例如:
class Alien:
def __init__(self, x, y):
self.x = x
self.y = y
def draw(self):
pass
“Alien”类只包含 x 和 y 坐标,用于在屏幕上的特定点显示外星人。你可能想添加的其他属性是外星人的类型或者它的护盾强度。
class AlienSwarm:
def __init__(self, numAliens):
self.swarm = []
y = 0
x = 24
for n in range(numAliens):
alien = Alien(x, y)
self.swarm.append(alien)
x += 24
if x > 640:
x = 0
y += 24
init()方法采用单个参数来表示群体中外星人的数量。该方法中的逻辑确保群体均匀地分布在屏幕上。每个外星人被 24 个像素宽和 24 个像素低分开。
def debugPrint(self):
for a in self.swarm:
print ("x=%d, y=%d" % (a.x, a.y))
def isHit(self, x, y):
alienToRemove = None
for a in self.swarm:
print ("Checking Alien at (%d, %d)" % (a.x, a.y))
if x>=a.x and x <= a.x + 24 and y >= a.y and y <= a.y + 24:
print (" It's a hit! Alien is going down!")
alienToRemove = a
break
if alienToRemove != None:
self.swarm.remove(alienToRemove)
return True
return False
swarm = AlienSwarm(5)
swarm.debugPrint()
“break”关键字用于退出封闭循环。当' break '关键字被执行时,程序的控制跳转到循环语句之后的那一行。一个相关的关键词是“继续”Continue 停止处理当前循环迭代中的剩余语句,并将控制权移回循环顶部。“中断”和“继续”都适用于任何循环结构。
Alien 类从不在 AlienSwarm 之外调用。它是由 AlienSwarm 类创建的,任何与外界的交互也是通过这个类完成的。
聚合
从概念上讲,聚合很像合成。容器对象有一个到其他对象的链接,它通过一个或多个方法以某种形式操纵它们。然而,最大的区别是对象的创建和销毁是在类之外的其他地方处理的。对于聚合,容器类不能删除它使用的对象。
假设我们有一个碰撞类,我们想检查玩家的子弹是否击中了外星人,我们可以实现类似这样的东西——假设 alien 和 AlienSwarm 保持不变:
class Bullet:
def __init__(self, x, y):
self.x = x
self.y = y
class Player:
def __init__(self):
self.bullets = [Bullet(24, 8)]
self.score = 0
def getBullets(self):
return self.bullets
def removeBullet(self, bullet):
self.bullets.remove(bullet)
class Collision:
def __init__(self, player, swarm):
self.player = player
self.swarm = swarm
def checkCollisions(self):
bulletKill = []
for b in player.getBullets():
if swarm.isHit(b.x, b.y):
bulletKill.append(b)
continue
for b in bulletKill:
self.player.score += 10
print ("Score: %d" % self.player.score)
self.player.removeBullet(b)
swarm = AlienSwarm(5)
player = Player()
collision = Collision(player, swarm)
collision.checkCollisions()
Collision 类是一个聚合,也就是说,它包含对另外两个类的引用:Player 和 AlienSwarm。它不控制这些类的创建和删除。
这符合我们坚实的原则;每个类应该有一个单一的目的,并且应该相互独立。在这种情况下,我们的玩家类不需要了解外星人,同样,AlienSwarm 类也不需要了解玩家。我们可以使用我们的接口创建一个介于两者之间的类,以允许我们(程序员)确定是否发生了冲突。
结论
Python 支持标准的 OOP 技术,但也提供了自己独特的一面:duck typing。通过对接口编程,我们可以确保我们的类可以彼此独立地编写。
对界面编程,保持你的类小而灵活
十六、游戏项目:贪食蛇
在我们的第二个游戏中,我们将重现经典的贪食蛇游戏。自 20 世纪 70 年代末以来,蛇就一直伴随着我们,如果你有一部诺基亚手机,你可能会在上面安装这个游戏的一个版本。你控制一条蛇,用光标键在屏幕上移动。你必须吃水果才能成长。你不允许触摸外墙或你自己。我说过你在成长吗?见图 16-1 。
图 16-1
贪食蛇游戏
在这个游戏中,我们将介绍以下内容:
-
类声明和实例(对象)
-
文件输入
-
基于单元的碰撞检测
-
功能
-
源文本
Snake 将使用比面向对象技术更多的函数。在很大程度上,我们在这个游戏中的对象只是为了组织的目的。将很少涉及 OOP。
功能
定义了以下功能:
-
drawData
-
drawegameover
-
拉丝克
-
牵引墙
-
headHitBody
-
headHitWall
-
loadImages
-
loadMapFile
-
爱情生活
-
定位浆果
-
更新游戏
我们可以创建一个结构图,如图 16-2 所示,展示这些功能如何协同工作。
图 16-2
贪食蛇游戏的结构图
结构图显示了每个功能如何相互作用。括号中的函数不存在。它们被用来像函数一样组合在一起。例如,绘制游戏调用三个独立的函数。我们可以创建另一个功能——我将让读者自行判断。
蛇形框架
贪食蛇游戏的基本轮廓如下所示。在您的工作文件夹中创建一个新文件,并将其命名为 snake.py。别忘了一边走一边看评论,帮助你理解发生了什么,作者(我)的意图是什么。我们将在本节的后面用代码替换一些注释。在键入代码时,您应该在自己的清单中包含注释。这将作为后面代码的占位符。
#!/usr/bin/python
import pygame, os, sys
import random
from pygame.locals import *
现在你应该知道我们节目的熟悉开头了!hash-bang 和导入我们需要的 Python 模块:PyGame、OS 和 System。我们也为这个游戏引入了一个新的:随机。这个模块将允许我们为浆果生成一个随机的起始位置。
pygame.init()
fpsClock = pygame.time.Clock()
surface = pygame.display.set_mode((640, 480))
font = pygame.font.Font(None, 32)
为了缩小地图尺寸,游戏将在 640×480 的窗口中运行。我们马上会看到如何创建地图。我们的 PyGame 初始化和保持每秒 30 帧的时钟也在这里初始化。我们的最后一点初始化是使用默认字体创建一个字体对象,大小为 32 像素。
class Position:
def __init__(self, x, y):
self.x = x
self.y = y
我们的第一堂课很简单:位置。这保持了地图块的位置。我们使用构造函数(在 Python 中,这是 init()方法)来传递 x 和 y 坐标。
class GameData:
def __init__(self):
self.lives = 3
self.isDead = False
self.blocks = []
self.tick = 250
self.speed = 250
self.level = 1
self.berrycount = 0
self.segments = 1
self.frame = 0
bx = random.randint(1, 38)
by = random.randint(1, 28)
self.berry = Position(bx, by)
self.blocks.append(Position(20,15))
self.blocks.append(Position(19,15))
self.direction = 0
游戏数据包含了我们需要存储的关于游戏的所有东西。这些数据的大部分是玩家的蛇。
-
生命——玩家剩余的生命数量。
-
Is dead–当蛇的头部接触到尾巴的一部分或墙壁时,设置为 true。
-
blocks–组成蛇尾的块的列表。
-
滴答——用于向下计数到下一个动画帧的累计总数。以毫秒计。
-
速度–默认的节拍速度。同样以毫秒为单位。
-
等级–当前的难度等级。
-
浆果数量——蛇在这一关吃掉的浆果数量。
-
细分市场——消费浆果时增加的细分市场数量。该值会改变每个级别。
-
帧–用于绘制蛇头的当前动画帧。这条蛇有两帧动画,与吃豆人没有什么不同。
-
方向——蛇当前行进的方向。0 是右,1 是左,2 是上,3 是下。这条蛇只能向四个方向之一移动。他们也不能逆转方向。例如,如果蛇向右移动,玩家不能向左移动。他们可以向上或向下移动,或者继续向右移动。
snake 从两个块开始,这两个块由“Position”类的两个实例表示;这意味着它有一个头部和一个尾部。每吃一颗浆果,碎片的数量就会增加。
浆果位置 bx 和 by 用于将浆果定位在游戏屏幕上的某个位置。这些存储在 GameData 类的“berry”属性中。
def loseLife(gamedata):
pass
def positionBerry(gamedata):
pass
def loadMapFile(fileName):
return None
def headHitBody(gamedata):
return False
def headHitWall(map, gamedata):
return False
def drawData(surface, gamedata):
pass
def drawGameOver(surface):
pass
def drawWalls(surface, img, map):
pass
def drawSnake(surface, img, gamedata):
pass
def updateGame(gamedata, gameTime):
pass
def loadImages():
return {}
这些都是绘制在结构图上的功能。
当我们开始实现游戏的功能时,我们会详细讨论它们。
images = loadImages()
images['berry'].set_colorkey((255, 0, 255))
我们的图像是使用 loadImages()函数加载的。图像存储在字典中。该键是一个字符串值,给出的示例显示我们将“浆果”图像的颜色键设置为紫色(红色= 255,绿色= 0,蓝色= 255)。PyGame 不会绘制任何与提供的颜色匹配的图像像素。这意味着你的图像中可以有透明的像素。这对于窗口或复杂形状(如浆果)非常方便。
snakemap = loadMapFile('map.txt')
data = GameData()
quitGame = False
isPlaying = False
这些局部(到主游戏循环)变量用于存储地图,创建 GameData 类的一个实例,一个确定用户是否退出游戏的控制变量,最后一个确定用户是否正在玩游戏。默认值为“False ”,因为我们希望以“游戏结束”模式开始游戏,以允许用户选择是玩游戏还是退出应用。
while not quitGame:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
在一个真实的游戏中,如果用户关闭了窗口,你可能不想退出游戏。或者,至少您会希望提示他们确认该操作。然而,在这个简单的游戏中,我们只是关闭游戏并退出到桌面。
if isPlaying:
x = random.randint(1, 38)
y = random.randint(1, 28)
我们的屏幕尺寸是 40 个街区长,30 个街区宽。对于 640×480 的屏幕,这意味着我们的块大小为 16×16 像素。这里产生的随机值将被用来放置玩家控制的蛇将要吃掉的浆果。
我们的随机值介于 1 和 38 之间,因为我们希望生成一个介于 1 和 38 之间的值。我们的地图将是一个构成游戏区域边界的实心块。我们将在下一节详细讨论这一点。
rrect = images['berry'].get_rect()
rrect.left = data.berry.x * 16
rrect.top = data.berry.y * 16
现在我们已经有了 x 和 y 坐标的随机值,我们将把它们分配给 berry 图像矩形的左和顶场。
坐标乘以 16,因为每个单元的大小为 16×16。
# Do update stuff here
我们的更新程序将放在这里。这只是一个占位符注释。这种类型的评论将贯穿全书。如果您将注释视为“键入”代码的一部分,请将其包含在您自己的源代码中。我们将在课文的后面回到这一点,如果你没有它,它会导致混乱。
isPlaying = (data.lives > 0)
如果玩家已经没有生命了,这是一个将 isPlaying 变量设置为 false 的好方法。你可以很容易地把它改写成一个“如果”语句。你会怎么做?
if (isPlaying):
isPlaying 的值可能在上一行之后发生了变化。这就是我们在这里对这个变量做另一个 if 检查的原因。
surface.fill((0, 0, 0))
# Do drawing stuff here
else:
如果游戏没有在玩,那么它就处于“游戏结束”模式。小心这个“else ”,因为它与前面的*“if”语句成对出现。“游戏结束”模式向用户显示消息。如果他们想再次玩游戏,用户必须按键盘上的“空格”。*
keys = pygame.key.get_pressed()
if (keys[K_SPACE]):
isPlaying = True
data = None
data = GameData()
如果用户按空格键,我们将 isPlaying 标志设置为 true,并将数据重置为 GameData 的一个新实例。当您处理完一个对象后,将指向它的变量设置为“None”是一个很好的做法。
drawGameOver(surface)
“游戏结束”屏幕是通过调用 drawGameOver()函数绘制的。
pygame.display.update()
fpsClock.tick(30)
我们的最后几行翻转屏幕(双缓冲显示),将帧速率限制在每秒 30 帧的最大值。保存程序。程序现在不会运行;我们需要先加载图像和地图数据,然后才能在屏幕上看到任何内容。
形象
游戏需要以下图片:
-
berry.png——蛇吃的浆果
-
snake.png–一个多帧图像,包含蛇使用的所有图像
-
wall.png——蛇无法穿越的障碍
我们的图像是 16×16,除了 snake.png 是 144×16 像素。这样做的原因是我们想要的蛇的所有图像都包含在同一个文件中。见图 16-3 。
图 16-3
蛇的骨架
这些图片和本书中的所有例子一样,可以从 http://sloankelly.net 下载。
加载图像
复制或制作图像,并将它们放在与 snake.py 文件相同的目录中。找到 loadImages()函数,并将其更改为如下所示:
def loadImages():
wall = pygame.image.load('wall.png')
raspberry = pygame.image.load('berry.png')
snake = pygame.image.load('snake.png')
这些图像是单独加载的,但我们将把它们放在一个字典中,以便将所有图像放在一起。
return {'wall':wall, 'berry':raspberry, 'snake':snake}
下一步是创建和加载组成游戏屏幕的地图。
游戏地图
游戏的地图保存在一个名为 map.txt 的文本文件中。在“snake.py”所在的文件夹中创建一个名为“map.txt”的新文件。在该文件中输入以下文本:
1111111111111111111111111111111111111111
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1000000000000000000000000000000000000001
1111111111111111111111111111111111111111
那是 30 行文本。顶线和底线是
1111111111111111111111111111111111111111
剩下的几行是
1000000000000000000000000000000000000001
如果你愿意,你可以尝试不同的 0 和 1 的模式。每个“0”代表一个蛇可以穿过的开放空间。每个“1”代表一堵墙,如果碰到它,蛇就会被杀死。保存该文件并打开 snake.py。找到 loadMapFile()函数并将其更改为
def loadMapFile(fileName):
f = open(fileName, 'r')
content = f.readlines()
f.close()
return content
readlines()方法将文件中的每一行文本读入一个列表。保存“snake.py”文件。
绘制“游戏结束”屏幕
如果我们现在运行游戏,我们将什么也看不到,因为我们没有实现任何绘制方法。让我们从显示“游戏结束”屏幕开始。找到 drawGameOver()函数,并将其更改为
def drawGameOver(surface):
text1 = font.render("Game Over", 1, (255, 255, 255))
text2 = font.render("Space to play or close the window", 1, (255, 255, 255))
Font 的 render()方法创建一个完全适合文本的 PyGame 表面。render()方法采用的参数是要显示的字符串、抗锯齿级别和颜色。
抗锯齿意味着文本不会出现锯齿状边缘。在图 16-4 中,你可以看到反走样和无走样的效果。
图 16-4
该字体的抗锯齿版本显示在图像的下半部分
图像被从中间分割开,在红线的左边显示反锯齿文本,在右边显示锯齿版本。
cx = surface.get_width() / 2
cy = surface.get_height() / 2
textpos1 = text1.get_rect(centerx=cx, top=cy - 48)
textpos2 = text2.get_rect(centerx=cx, top=cy)
我们在这里使用命名参数,因为我们不需要为文本位置指定所有的值。这两行创建了用于在屏幕中间放置文本的矩形。
surface.blit(text1, textpos1)
surface.blit(text2, textpos2)
传递给函数的表面实例的 blit()方法用于在表面上绘制文本。保存并运行游戏。当你运行游戏时,你应该会看到下面的屏幕(如图 16-5 所示):
图 16-5
游戏开始时出现的“游戏结束”屏幕
当你完成的时候关上窗户。如果你按“空格”,屏幕会变成空白,什么也不会发生,因为我们还没有添加更新或绘制屏幕的功能。现在让我们添加绘图功能。
画游戏
蛇的绘制、游戏区域和游戏数据(玩家的生活、分数和关卡文本)由三个函数执行:
-
牵引墙
-
拉丝克
-
drawData
在“snake.py”中,在源代码中向下滚动到这样一行
# Do drawing stuff here
在该注释下添加以下几行。确保每行有正确数量的制表符。每行的左栏应直接从注释的' # '下开始:
drawWalls(surface, images['wall'], snakemap)
surface.blit(images['berry'], rrect)
drawSnake(surface, images['snake'], data)
drawData(surface, data)
画浆果没有具体的例程,我们就直接调用主面的 blit()方法。我们在屏幕上画图是有顺序的。在其他图像之后绘制在屏幕上的图像将出现在顶部。所以,墙壁出现在蛇的后面,蛇出现在生命/分数显示的后面。
画墙壁
墙壁是在 drawWalls()函数中绘制的。在源代码中找到这个函数,并将其改为
def drawWalls(surface, img, map):
该函数有三个参数。第一个参数是我们将在其上绘制墙块的主表面。第二个是我们将用来表示墙中一块砖的图像,最后第三个参数是地图数据。这是我们之前从文件中加载的数据。
row = 0
for line in map:
col = 0
for char in line:
if ( char == '1'):
对于行中的每个字符,我们都要检查它。如果字符是“1 ”,我们就放一块积木。因为我们记录了行(变量“row”)和列(变量“col”)的数量,所以计算屏幕上的位置只需要将它们分别乘以 16。为什么呢?因为我们的块图像是 16×16 像素,而我们的文件不是按像素映射的。相反,映射文件中的每个字符代表一个 16×16 的块。
这是一个从零到我们给定的最大行列数排列的字符数组。在这个游戏中,最大值是 40 块乘 30 块。对于一个 640×480 的屏幕来说,就是每块 16×16 像素。
imgRect = img.get_rect()
imgRect.left = col * 16
imgRect.top = row * 16
surface.blit(img, imgRect)
每次绘制块时,都会更改图像矩形的左值和上值,以确保图像绘制到表面的正确位置。
col += 1
row += 1
保存并运行游戏。当你按空格键开始游戏时,你会看到操场周围的墙和浆果。当你准备好了,关闭游戏,让我们开始添加生命,级别和分数显示。见图 16-6 。
图 16-6
游戏运行时显示的墙和浆果与代码到目前为止
绘制玩家数据
玩家需要一些关于他们表现如何的反馈。这通常是他们得分、剩余生命数量以及当前水平的指标。我们将在 drawData()函数中添加代码,以给出玩家的反馈。在代码中找到 drawData()函数,并将其更改为:
def drawData(surface, gamedata):
该函数接受两个参数。第一个是我们将在其上绘制数据的表面。第二是实际的游戏数据本身。引入了一个新的字符串函数,称为 format。它类似于 print()方法使用的方法,但是结果可以存储在一个变量中。使用占位符代替数字和字符串的%d 和%s。第一个变量是{0},第二个变量是{1},依此类推:
text = "Lives = {0}, Level = {1}"
info = text.format(gamedata.lives, gamedata.level)
text = font.render(info, 0, (255, 255, 255))
textpos = text.get_rect(centerx=surface.get_width()/2, top=32)
surface.blit(text, textpos)
使用元组将数据注入字符串,将数据呈现为文本。这被称为字符串格式化,我们在前面的章节中看到了这种类型的代码。
此时保存程序。如果你愿意,你可以运行它。这一次,当游戏开始时,你会在屏幕上方看到玩家的生活和当前水平。
画蛇
画蛇比我们之前的绘图功能要复杂一点——这就是为什么我把它留在最后!我们的蛇形象(实际。png 文件)是 144 像素乘 16,这意味着它包含九个 16×16 的图像。我们需要以某种方式将它们分割成单独的图像。
在代码中找到 drawSnake()函数,并将其改为
def drawSnake(surface, img, gamedata):
该函数接受三个参数。第一个是要在其上绘制蛇的表面。第二个是蛇的图像,最后第三个参数是 GameData 实例。这包含了我们的蛇的所有信息。具体来说,GameData 类的 blocks 属性包含一个范围为 0 的坐标列表..39 为列,0 为..一排 29 个。坐标存储为“位置”类的实例。
这些坐标是位置类的实例。blocks 属性是一个列表,随着 snake 的增长,列表中的条目数量也会增长。
first = True
这被设置为 true,因为绘制的第一个块是特殊的。这是蛇头。我们在这里绘制的图像依赖于
-
蛇面对的方向
-
不管它的嘴是否张开
再看图 16-7 中的蛇图像。
图 16-7
蛇图像包含蛇的头部和尾部的编码数据
子图像实际上有一个模式。最后一个单元格是正常的尾块。剩下的八块代表蛇头。它们在数组中的位置对应于蛇的方向:
-
0–右
-
1–左侧
-
2 页以上
-
3-向下
如果我们将存储在 GameData 的 direction 属性中的方向号乘以 2,就可以得到我们想要的图像的起始单元格号。蛇头也是有动画的——它会开合。我们所要做的就是添加当前帧(GameData 的 frame 属性)来获得所需的图像。
for b in gamedata.blocks:
dest = (b.x * 16, b.y * 16, 16, 16)
我们循环遍历列表中蛇的所有块(位置)。目标是一个简单的计算:位置乘以单个单元格的尺寸(16×16 像素)得到屏幕坐标。
if first:
first = False
src = (((gamedata.direction * 2) + gamedata.frame) * 16, 0, 16, 16)
如果我们在列表的最前面,我们画蛇的头。请记住,我们可以通过指定一个元组来绘制图像的一部分,该元组表示子图像的起始 x 和 y 像素及其宽度和高度。
对于我们的蛇,我们的子图像的 x 坐标是使用以下公式计算的:
((方向 2) +动画 _ 帧)* 16*
我们的图像取自 sprite 工作表的顶部,顶部是 y 坐标为 0(零)的地方。我们的宽度和高度尺寸也固定为 16×16 像素。
else:
src = (8 * 16, 0, 16, 16)
对于普通块,我们只想绘制 snake.png 文件中的最后一幅图像。这是最右边的 16×16 正方形,恰好是图像的第八帧。我们可以硬连接该值,但在这种情况下,8 * 16 会产生更具描述性的代码。
surface.blit(img, dest, src)
保存并运行游戏,你会看到蛇、墙和玩家数据,如图 16-8 所示。
图 16-8
蛇、浆果和墙
更新游戏
静态屏幕虽然有趣,但不能代替实际玩游戏!然而,我们还没有实现任何例程来获取玩家输入,检查碰撞,或者更新玩家的数据。找到以下行:
# Do update stuff here
就在该行之后,添加以下代码:
updateGame(data, fpsClock.get_time())
大部分更新代码驻留在 updateGame()函数中。我们稍后会详细讨论这个问题。
crashed = headHitWall(snakemap, data) or headHitBody(data)
if (crashed):
loseLife(data)
positionBerry(data)
我们现在测试蛇的头部是撞到了一面墙(headHitWall()函数)还是自己的身体(headHitBody()函数)。如果是这种情况,玩家失去一条生命,浆果被重新定位。
updateGame()方法
这是游戏中最大的方法,也是最有效的方法。其目的是
-
更新蛇的头部和尾部
-
获取玩家的输入
-
查看蛇头是否击中了浆果
浏览到如下所示的代码部分:
def updateGame(gamedata, gameTime):
pass
将此功能更改为
def updateGame(gamedata, gameTime):
gamedata.tick -= gameTime
head = gamedata.blocks[0]
游戏的每个部分都可以以不同的速度更新。例如,您可能只想每秒更新一次游戏的某些部分,而其他部分您可能想每秒更新 30 次。这可以通过读取系统时钟并确定自上次调用代码以来的毫秒数来实现。在这个方法中,我们将自上次调用以来的差异(以毫秒为单位)作为“游戏时间”传递
游戏数据的滴答随着当前游戏时间而减少。当这个计数器达到零时,我们更新蛇的头部以显示它是关闭的(如果它当前是打开的)或打开的(如果它当前是关闭的)。我们也注意到蛇头的当前位置。这总是“游戏数据”的 blocks 属性的第零个元素
if (gamedata.tick < 0):
gamedata.tick += gamedata.speed
gamedata.frame += 1
gamedata.frame %= 2
如果 tick 属性小于零,我们就给它加上速度,重新启动计时器。然后,我们将当前帧数加 1。我们使用模计算将值固定为 0 或 1,因为我们只有两帧动画。在其他语言中,有一个“switch”或“case”语句。在 Python 中情况并非如此(抱歉),但是使用嵌套的 if/elif 语句很容易实现。
if (gamedata.direction == 0):
move = (1, 0)
elif (gamedata.direction == 1):
move = (-1, 0)
elif (gamedata.direction == 2):
move = (0, -1)
else:
move = (0, 1)
在蛇的游戏中,蛇总是在动;玩家只控制方向。基于玩家想要蛇移动的方向,创建适当的元组。
newpos = Position(head.x + move[0], head.y + move[1])
然后,这个元组用于生成和存储蛇头的新位置。
first = True
for b in gamedata.blocks:
temp = Position(b.x, b.y)
b.x = newpos.x
b.y = newpos.y
newpos = Position(temp.x, temp.y)
蛇的尾巴随着头部向上移动。这当然只是一种错觉;我们实际上做的是将蛇的各段移动到前一段的位置。
蛇形运动
与 updateGame()函数保持一致;蛇的运动被限制在四个方向之一:左、右、上、下。玩家只能真正建议运动:蛇自己在自己的蒸汽下运动。玩家通过按键盘上的一个箭头键来选择蛇的方向。
为了获得键盘输入,我们获取当前被按下的键的列表:
keys = pygame.key.get_pressed()
get_pressed()方法返回一个布尔值字典。现在我们已经按下了键,我们可以测试每个箭头键,看看玩家是否按下了它。我们还必须确保他们不会试图走相反的方向。如果玩家已经向左转,他就不能向右转;如果他已经向下,他就不能向上转,等等。
if (keys[K_RIGHT] and gamedata.direction != 1):
gamedata.direction = 0
elif (keys[K_LEFT] and gamedata.direction != 0):
gamedata.direction = 1
elif(keys[K_UP] and gamedata.direction != 3):
gamedata.direction = 2
elif(keys[K_DOWN] and gamedata.direction != 2):
gamedata.direction = 3
我们将当前方向存储在“gamedata”实例的方向字段中。
触摸浆果
updateGame()函数的最后一部分是处理我们对蛇头触摸浆果的反应。要继续游戏,玩家必须让蛇“吃掉”出现在游戏场上的浆果。为了“吃”浆果,玩家必须将蛇头转向浆果出现的细胞。一旦浆果被“吞噬”,一个新的浆果会被放置在屏幕上的另一个随机位置,蛇会增长一定数量的部分。分段的数量取决于玩家的级别。级别越高,添加到蛇的分段就越多。
if (head.x == gamedata.berry.x and head.y == gamedata.berry.y):
lastIdx = len(gamedata.blocks) - 1
for i in range(gamedata.segments):
blockX = gamedata.blocks[lastIdx].x
blockY = gamedata.blocks[lastIdx].y
gamedata.blocks.append(Position(blockX, blockY))
如果蛇的头部和浆果在同一个细胞中,那么我们在蛇的尾部附加适当数量的片段。我们加到最后的段数取决于游戏中的等级。级别越高,添加的分段越多。这使得游戏在后面的关卡中变得更加困难,因为每吃掉一个浆果,蛇就会有更多的部分。
bx = random.randint(1, 38)
by = random.randint(1, 28)
gamedata.berry = Position(bx, by)
gamedata.berrycount += 1
接下来,我们生成一个新位置,并将其设置为浆果的位置。我们还增加了一个计数器,记录我们的蛇已经吃掉的浆果的数量。
如果我们的蛇吃掉了十个浆果,我们就进入下一关。这有增加蛇的速度的附加效果(增加一点额外的刺激!),以及蛇每吃一颗浆果给玩家增加的段数。
我们将段的数量固定为 64,更新速度(毫秒)固定为 100:
if (gamedata.berrycount == 10):
gamedata.berrycount = 0
gamedata.speed -= 25
gamedata.level += 1
gamedata.segments *= 2
if (gamedata.segments > 64):
gamedata.segments = 64
if (gamedata.speed < 100):
gamedata.speed = 100
冲突检出
正如我们所看到的,这个游戏中的碰撞检测是基于每个单元而不是每个像素进行的。在某些方面,这使我们的工作更容易,因为我们需要做的就是确定一个块何时与另一个块重叠,换句话说,它们占用同一个单元。
助手功能
有四个函数我们还没有填充,但是没有它们我们将无法检测玩家是否撞到了墙或者蛇是否碰到了自己。我们缺少的实现是针对
-
迷失人生()
-
浆果位置()
-
headHitBody()
-
headHitWall()
失去一条生命
当蛇头撞到自己的尾巴或墙壁时,玩家失去一条生命。当这种情况发生时,我们删除了所有构成蛇尾巴的当前块,并从生命数中减去 1。然后我们给蛇添加两个方块,让玩家重新开始。在代码中找到“loseLife ”,并将其更改为如下所示:
def loseLife(gamedata):
gamedata.lives -= 1
gamedata.direction = 0
将生命数减一,并将方向重新设置为向右。
gamedata.blocks[:] = []
这一行删除列表中的所有项目。
gamedata.blocks.append(Position(20,15))
gamedata.blocks.append(Position(19,15))
在默认位置向蛇添加两个新块。
重新定位浆果
当玩家死了,我们必须为浆果找到一个新的位置。在代码中找到“positionBerry”函数,并将其更改为如下所示:
def positionBerry(gamedata):
bx = random.randint(1, 38)
by = random.randint(1, 28)
found = True
首先,我们在游戏中生成一个随机数。然后,我们循环遍历所有游戏块,以确保我们不会在蛇本身中随机生成一个位置:
while (found):
found = False
for b in gamedata.blocks:
if (b.x == bx and b.y == by):
found = True
检查浆果是否占据了与蛇块相同的位置是很容易的。我们只需要检查两个值是否相等:浆果和每个方块的 x 和 y 坐标。
if (found):
bx = random.randint(1, 38)
by = random.randint(1, 28)
如果浆果在包含方块的单元格上,则“found”设置为 True。如果发生这种情况,我们为“bx”和“by”变量分配新值,然后重试。
gamedata.berry = Position(bx, by)
一旦我们找到一个不包含一条蛇的块,我们就将位置分配给游戏数据的 berry 字段。
测试蛇身命中
蛇的头不能“触摸”它的身体。每次我们更新蛇的时候,我们还必须检查头部是否接触到了身体。我们基于单元的碰撞检测使这变得容易。我们只需要对照组成蛇身体的其余部分的 x 和 y 坐标来检查蛇头的 x 和 y 坐标。找到“headHitBody”函数,并将其更改为如下所示:
def headHitBody(gamedata):
head = gamedata.blocks[0]
创建一个变量来保存对组成 snake 的块列表中第一个块的引用。这是蛇头。
for b in gamedata.blocks:
一次检查一个模块。
if (b != head):
如果块不是头部,检查头部是否与当前块在同一个单元中。
if(b.x == head.x and b.y == head.y):
return True
如果头部与蛇身体中的块在同一位置,则向函数的调用方返回 True。
return False
否则,返回 False,向调用者表明没有冲突。
测试点击量
我们需要填写的最后一个函数是测试蛇头是否撞墙。找到“headHitWall”功能,并将其更改为:
def headHitWall(map, gamedata):
row = 0
for line in map:
col = 0
for char in line:
if ( char == '1'):
对于行中的每个字符,我们检查它是否是一个墙字符。我们的地图文件包含 0 和 1;任何“1”代表游戏区域中的一面墙。我们的控制变量“col”和“row”是根据块的第零个元素的当前位置来检查的。这是蛇头。
if (gamedata.blocks[0].x == col and gamedata.blocks[0].y == row):
return True
col += 1
row += 1
return False
结论
保存游戏并运行它。你应该可以开始玩蛇了。如果你有任何错误,对照书中的文本检查代码,确保你没有调换任何字母。记住空白很重要:那些“制表符”需要在正确的位置!作为最后一种选择,从 http://sloankelly.net 下载代码,然后对照那里的代码检查你的代码。
作为一个练习,改变生活/水平指标,以显示收集浆果的数量。如果每个浆果值 5 分,移动到另一个级别会给玩家额外的 100 分呢?你需要在游戏数据中加入哪些变量?*
十七、模型视图控制器
在“设计你的游戏”一节中提到过模型视图控制器,它描述了不同对象之间的交互如何被用来简化问题:将一个大问题分解成更小的更容易管理的块。见图 17-1 。
图 17-1
模型视图控制器设计模式
模型
模型表示与对象相关联的数据或属性。例如,一个玩家在空间中有一个位置,生命,护盾强度和得分。该模型通常只有很少的方法,可能与将数据保存或序列化到磁盘驱动器等廉价存储有关。这将用于保存游戏数据。然而,更有可能的是,您将拥有一个保存控制器,它将从模型中读取数据并存储它们。
视角
视图是游戏中每个模型的可视化表示。有些模型在游戏中没有直接的视觉表现。例如,与 RPG(角色扮演游戏)中的 NPC(非玩家角色)相关联的数据
控制器
控制器是连接模型和视图的粘合剂。玩家与视图交互(点击按钮,移动玩家),这调用控制器上的一个方法。接着,控制器更新模型以表示新的状态。
在计算术语中,状态是对象或值的当前值。例如,在一种状态下,玩家可能在跳跃,而在另一种状态下,他们可能在奔跑。在每个状态中,内部变量(对象的字段)被设置为特定的值。
为什么要用 MVC?
MVC 允许你,程序员,把对象的功能从它的视觉表现和数据中分离出来。由于每个职责由不同的类处理,所以很容易更换不同的控制器和视图。
作为 MVC 的一个说明性例子,让我们创建一个小游戏,用光标键在屏幕上移动机器人。我们将添加第二个视图,它包含雷达视图中的一个小光点。我们首先将这些类分离到不同的文件中,然后使用另一个文件作为“粘合代码”将它们合并到一个游戏中,这是游戏的主要循环。见图 17-2 。
图 17-2
机器人“游戏”显示机器人在中间。雷达在左上方
上层社会
我们将要创建的类是
-
雷达视图
-
机器人控制器
-
机器人发电机
-
机器人模型
-
机器人视图
您不需要为您创建的每个类添加“模型”、“视图”和“控制器”,但是在这个例子中它清楚地向我们展示了什么类执行什么目的。
雷达视图
雷达视图在窗口左上角的一个小屏幕上显示一个代表机器人的小光点。
机器人控制器
机器人控制器根据玩家的输入改变模型的状态。
机器人发电机
机器人生成器在指定的时间段后在屏幕上的随机位置生成一个机器人。也可以设置机器人的最大数量。
机器人模型
机器人模型保存机器人的状态。它根本没有方法,只有数据。
机器人视图
机器人视图在屏幕上显示机器人。它不改变机器人模型;它只是从模型中读取数据,并根据模型的状态决定显示什么。
文件夹
在“pygamebook”文件夹中创建一个名为“ch17”的新目录我们将在这个目录中创建所有文件。
机器人模型
名为 RobotModel 的模型类只包含机器人的数据。这个类的每个实例的更新将使用 RobotController 来完成,这个类将在后面定义。
创建一个名为“robotmodel.py”的新文件,并键入以下代码:
class RobotModel(object):
类是用一个名字定义的。在我们的例子中,我们将根据预期的目的对每个类进行后置处理。您可能不想这样做,或者这样做可能没有意义。对自己的类名进行判断。
def __init__(self, x, y, frame, speed):
init 方法(类定义中的函数称为方法)是一种特殊的方法,称为构造函数。它有四个参数,分别是机器人的起始位置、当前动画帧及其更新速度。第一个参数“self”是 Python 所必需的,指的是正在创建的对象。
self.x = x
self.y = y
self.frame = frame
self.speed = speed
self.timer = 0
我们将使用“定时器”成员字段来控制机器人的当前帧;它有一个“行走”动画。RobotModel 类的其余部分是访问和更改模型数据的方法:
def setPosition(self, newPosition):
self.x, self.y = newPosition
def getPosition(self):
return (self.x, self.y)
def getFrame(self):
return self.frame
def nextFrame(self):
self.timer = 0
self.frame += 1
self.frame %= 4
RobotController 调用 nextFrame()方法将机器人移动到下一帧。它将帧计数加 1,然后使用模运算符(%)将 self.frame 字段箝位在 0 和 3 之间。
def getTimer(self):
return self.timer
def getSpeed(self):
return self.speed
def setSpeed(self, speed):
self.speed = speed
这些 getter 和 setter 方法将由 RobotGenerator 和 RobotController 类使用。
Getters 和 Setters 之所以被称为 Getters 和 Setters,是因为它们以“get”或“set”开头,用于访问包含在类实例中的数据
机器人视角
RobotView 类在机器人模型中的位置显示机器人的大图形。机器人使用的图形包含四帧,每帧为 32×32 像素。见图 17-3 。
图 17-3
机器人,一个 128×32 像素的图像,有四个 32×32 帧
当前帧是在 RobotController 类中计算的,我们一会儿就会看到。同时,创建一个名为 robotview.py 的新文件,并输入以下文本:
import pygame
from pygame.locals import *
我们需要 Rect 类的这个导入。
from robotmodel import RobotModel
我们的 RobotView 类使用 RobotModel,所以我们需要导入那个文件。
class RobotView(object):
def __init__(self, imgPath):
self.img = pygame.image.load(imgPath)
def draw(self, surface, models):
for model in models:
rect = Rect(model.getFrame() * 32, 0, 32, 32)
surface.blit(self.img, model.getPosition(), rect)
draw()方法接受要在其上绘制机器人的表面以及模型列表。for 循环遍历“模型”中的每个机器人实例,并在表面上绘制它们。
因为我们只想显示一小部分 32×32 的图像。使用模型的框架计算要复制到屏幕的源区域。该模型有四个帧:0、1、2 和 3。如果这个值乘以 32,可能的矩形是(0,0,32,32),(32,0,32,32),(64,0,32,32),(96,0,32,32),如图 17-4 所示。
图 17-4
机器人动画的每一帧的开始坐标
雷达图
雷达视图在雷达屏幕上显示一个微小的光点(3×3 像素,白色)。雷达屏幕是一个 66×50 像素的图像,具有 1 像素的边框。见图 17-5 。
图 17-5
66×50 雷达图像
雷达的面积为 64×48 像素,但图形略大,以适应外部周围 1 像素的边框。雷达的比例尺是主播放区的 1:10,主播放区为 640×480 像素。这也是为什么光点是 3×3 像素,因为它非常接近机器人的 32×32 像素的实际大小。
创建名为 radarview.py 的新文件,并输入以下文本:
import pygame
from robotmodel import RobotModel
class RadarView(object):
def __init__(self, blipImagePath, borderImagePath):
self.blipImage = pygame.image.load(blipImagePath)
self.borderImage = pygame.image.load(borderImagePath)
构造函数有两个参数:一个是光标图像路径,另一个是边框图像路径。图像被加载并放置到字段中,供 draw()方法稍后使用。
def draw(self, surface, robots):
for robot in robots:
draw 方法接受机器人将被绘制到的表面和机器人列表。
x, y = robot.getPosition()
x /= 10
y /= 10
x += 1
y += 1
surface.blit(self.blipImage, (x, y))
代表机器人的‘信号’需要我们做一些数学计算。我们需要将坐标转换成介于 0..x 轴上的 639 和 0..479 设置为介于 0..雷达 x 轴上的 63 和 0..雷达的 y 轴是 47 度。这意味着我们必须将机器人的位置除以 10,再加上 1,因为记住我们的 1 像素雷达边界不算数。
surface.blit(self.borderImage, (0, 0))
最后,绘制边界,完成雷达视图。
机器人控制器
机器人控制器是将模型和视图结合在一起的粘合剂;它使用时钟来更新当前帧,并轮询键盘来读取玩家的输入。它使用这个输入根据机器人的速度(每秒像素)更新玩家的位置。
创建名为 robotcontroller.py 的新文件,并键入以下代码:
from robotmodel import RobotModel
机器人的模型 RobotModel 是从 robotmodel.py 文件导入的,因为控制器类读取和写入机器人模型的值。
这意味着控制器改变游戏中每个机器人的状态。
class RobotController(object):
def __init__(self, robots):
self.robots = robots
RobotController 的构造函数接受一个机器人列表,它将每帧更新一次。不是在每个对象上调用一个更新,而是调用一个更新方法 RobotController 的 update()方法一次,它更新每个模型。这是处理大量类似项目的一种非常有效的方式。
def update(self, deltaTime):
for robot in self.robots:
robot.timer += deltaTime
if robot.getTimer() >= 0.125:
robot.nextFrame()
每个机器人在一个循环中被处理。使用为每个机器人存储的数据,代码确定是更新下一帧还是通过改变其位置来移动对象(参见下文)。
与上次调用此方法的时间差,此时间被添加到模型的“timer”字段中。如果“计时器”大于或等于 0.125 秒,我们告诉模型移动到下一帧。
speed = self.multiply(robot.getSpeed(), deltaTime)
pos = robot.getPosition()
x, y = self.add(pos, speed)
sx, sy = robot.getSpeed()
模型的位置会以每秒像素数乘以上次调用该方法的时间差的方式递增。这详细解释如下:
if x < 0:
x = 0
sx *= -1
elif x > 607:
x = 607
sx *= -1
if y < 0:
y = 0
sy *= -1
elif y > 447:
y = 447
sy *= -1
robot.setPosition((x, y))
robot.setSpeed((sx, sy))
在这一系列 if 语句中,x 轴和 y 轴上的值被固定在屏幕上。然后在当前机器人模型上设置新的位置和速度。
def multiply(self, speed, deltaTime):
x = speed[0] * deltaTime
y = speed[1] * deltaTime
return (x, y)
def add(self, position, speed):
x = position[0] + speed[0]
y = position[1] + speed[1]
return (x, y)
两个帮助函数使元组的工作变得更容易。元组是不可变的,这意味着我们不能改变任何元素的值。我们可以创造新的元组,但不能改变现有的元组。这两个辅助方法使得元组的乘法和加法变得稍微容易一些。
机器人发电机
最后一个类不是 MVC 模式的一部分,但是我需要一种方法来以随机的位置和速度生成机器人。为此,我创建了 RobotGenerator 类。创建一个名为“robotgenerator.py”的新文件,并输入以下代码:
import random
from robotmodel import RobotModel
class RobotGenerator(object):
def __init__(self, generationTime = 1, maxRobots = 10):
self.robots = []
self.generationTime = generationTime
self.maxRobots = maxRobots
self.counter = 0
RobotGenerator 的构造函数允许调用者(创建类实例的代码部分)指定创建机器人和机器人最大数量之间的秒数。“self.counter”字段以秒为单位存储当前时间。如果“self.counter”大于或等于“self.generationTime”,则创建一个机器人(参见以下更新)。
def getRobots(self):
return self.robots
获取机器人列表。该方法有两种访问方式;它作为参数传递给 RobotController 构造函数,并作为参数传递给 RadarView 和 RobotView draw()方法。
def update(self, deltaTime):
self.counter += deltaTime
计时器增加增量时间,增量时间本身是一秒的几分之一。
if self.counter >= self.generationTime and len(self.robots) < self.maxRobots:
self.counter = 0
x = random.randint(36, 600)
y = random.randint(36, 440)
frame = random.randint(0, 3)
sx = -50 + random.random() * 100
sy = -50 + random.random() * 100
newRobot = RobotModel(x, y, frame, (sx, sy))
self.robots.append(newRobot)
如果计数器达到某个时间(generationTime)并且机器人的数量小于机器人的最大数量,我们就在场景中添加一个新的机器人。生成的机器人的位置和速度是随机的。
确保恒定速度
我们希望确保物体运动时速度恒定。有时其他例程需要更长的时间来运行,我们无法确保这一点。例如,如果我们决定我们的机器人应该以每秒 200 像素的速度移动。如果我们假设我们的例程每秒调用 30 帧,那么我们应该每帧增加 6.25 帧。正确不对!
我们的机器人的位置应该每秒改变 200 个像素。如果玩家按住右光标键,机器人应该在 1 秒后向右移动 200 像素。如果 update 方法每秒只被调用 15 次会发生什么?这意味着我们的机器人在 1 秒钟内只会移动 15 × 6.25 = 93.75 个像素。
还记得在“Snake”中,当我们想要更新代码时,我们使用时钟的毫秒刻度来更新部分代码。我们可以使用这个时间增量来计算我们在游戏的单个“滴答”中需要行进的距离。游戏每循环一次,就是一个滴答。
这意味着即使有可变的帧速率,你仍然会有一个恒定的速度,因为增量时间将确保你的速度保持不变。
使用 delta time,您每秒 15 次的更新仍然会在按住右光标键 1 秒后产生 200 像素的位移。为什么呢?因为每一次更新,我们都要将期望的速度乘以上次呼叫后的几分之一秒。十五分之一秒,也就是 66 毫秒。
0.066 × 200 =每次更新 13.2 像素
13.2 像素× 15 次更新=每秒 198 像素
这大概是我们想要的速度。如果我们的帧速率增加到每秒 60 帧:
每秒 60 帧是 16.67 毫秒
0.01667 × 200 =每次更新 3.333 像素
3.333 像素× 60 次更新=每秒 200.00 像素
你可以看到每秒 60 帧,我们得到的速度比每秒 15 帧要精确得多。不过,对于我们的目的来说,每秒 30 帧已经足够满足我们的需求了。
主机器人程序
主机器人程序将所有这些单独的类组合成一个“游戏”例子。创建一个名为 robot.py 的新文件。在这个新文件中,添加以下代码:
import pygame, sys
from pygame.locals import *
我们非常熟悉的导入来访问 PyGame 的例程和类库,以及 Python 提供的操作系统和系统库。
from robotview import RobotView
from robotcontroller import RobotController
from robotgenerator import RobotGenerator
from radarview import RadarView
这些从各自的文件中导入机器人模型、机器人视图、雷达视图、机器人生成器和机器人控制器。我们使用“from”关键字来最小化所需的输入量。有了“from”,我们只需要输入类名,而不是“robotview”。机器人视图。
pygame.init()
fpsClock = pygame.time.Clock()
surface = pygame.display.set_mode((640, 480))
接下来,我们初始化 PyGame 并设置一个时钟,这样我们可以将帧速率限制在每秒 30 帧。我们的测试游戏将是 640×480 像素大小。
lastMillis = 0
“lastMillis”保持帧之间的最后毫秒数。该值由“fpsClock.tick()”返回。
generator = RobotGenerator()
view = RobotView('robot.png')
radar = RadarView('blip.png', 'radarview.png')
controller = RobotController(generator.getRobots())
这是我们创建类的实例的地方。构造函数参数被传递。在这个例子中,我们只是使用硬编码的值,但是如果您愿意,您可以很容易地从文本文件中读取这些数据。
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
sys.exit()
我们的主循环有我们以前见过的越狱;当用户关闭窗口时,我们退出 PyGame,并向操作系统发出信号,表示我们正在退出应用。
deltaTime = lastMillis / 1000
generator.update(deltaTime)
controller.update(deltaTime)
通常,您希望在绘制它们的可视化表示之前更新您的类。生成器和控制器都需要更新调用,以便生成新的机器人,并更新已生成的机器人。记住,所有的控制器代码都在一个类中,如果我们改变了那个控制器类中的任何东西,我们所有的机器人都会受到影响。这个真的很厉害!
surface.fill((0, 0, 0))
view.draw(surface, generator.getRobots())
radar.draw(surface, generator.getRobots())
接下来,用黑色清除屏幕,fill()方法的元组用于颜色的红色、绿色和蓝色分量,黑色表示没有所有颜色,因此所有值都为零。首先绘制主视图,因此这将使用当前动画帧在其位置绘制所有机器人。接下来雷达被画在上面。
这就是所谓的抽签顺序。首先被绘制到屏幕上的图像被绘制在稍后绘制的图像之后。把它想象成放在桌子上的照片。放在最前面的会被放在最上面的遮住。
pygame.display.update()
lastMillis = fpsClock.tick(30)
我们在主游戏循环中的最后动作是将前端缓冲区翻转到后端,反之亦然,并将帧速率固定在每秒 30 帧。“lastMillis”被存储,这将为我们提供生成最后一帧所花费的大致时间。这将用于确定每个机器人的位置和动画帧。
保存并运行游戏。大约一秒钟后,一个机器人会出现,然后一个接着一个,直到屏幕上出现十个。请注意,“雷达”视图会随着每个机器人的相对位置而更新。
结论
模型视图控制器设计模式可用于从功能上将一个对象分成三个独立的类。这使程序员能够决定以后如何组合这些类。例如,如果您只想在开发之初提供键盘支持,那么可以在稍后阶段轻松添加一个支持游戏杆的新控制器。这个新增加的内容不会影响视图或模型类。
如果你同时有很多 NPC 在屏幕上,MVC 是理想的。您可以使用一个类来存储它们的位置/帧数据(模型),一个类来执行更新(控制器),另一个类来显示它们(视图)。事实上,根据 NPC 的类型,你可以有不同的视图,例如,BlacksmithView 只画铁匠,ChefView 只画厨师。这减少了内存中的数据量,因为只有一个类(BlacksmithView)有铁匠图像的实例,只有一个类(ChefView)有厨师图像的实例。在一个更传统的 OOP 环境中,你可能会将位置和形状数据放在一起,这意味着你可能会在内存中存储成千上万的图像。
十八、音频
音频是制作游戏的重要组成部分。你可以拥有世界上最好的视觉效果,最好的机械,但是缺少了一些东西——那就是音频!在这一章中,我们来看看如何播放一次性的声音,如爆炸或效果以及音乐。
声音是使用 PyGame 内置的混音器对象播放的。像 PyGame,在使用之前必须先初始化混音器。
pygame.mixer.init()
同样,当您停止使用混音器时,您应该通过调用 quit 方法来优雅地关闭它:
pygame.mixer.quit()
您可以通过调用“get_busy()”方法来检查混音器是否正在播放声音:
pygame.mixer.get_busy()
这将返回一个布尔值 True 或 False,表明混音器仍在做一些事情。我们将在两个示例程序中使用它来保持程序运行。
Sound 类的 init()方法采用单个参数,该参数通常只是磁盘上声音文件的路径。你也可以传入缓冲区和其他东西,但是我们只会传入声音文件的路径。
shootSound = pygame.mixer.Sound('playershoot.wav')
像所有其他类一样,调用构造函数 init()方法——会传回该类的一个实例。声音类只能加载 Ogg Vorbis 或 WAV 文件。Ogg 文件是压缩的,因此更适合对空间要求严格的机器。
播放声音
在“pygamebook”中创建一个名为“ch18”的新文件夹在该文件夹中创建一个名为“testsound.py”的新 Python 脚本文件。输入下面的代码并运行它来播放声音。playershoot.wav 文件可以在参考资料部分的网站( http://sloankelly.net )上找到。如果你不想下载那个文件,你可以提供你自己的。
import pygame, os, sys
from pygame.locals import *
导入常用模块。
pygame.mixer.init()
初始化混音器。
shootSound = pygame.mixer.Sound('playershoot.wav')
加载 playershoot.wav 声音文件,并将其命名为 shootSound。
shootSound.play()
通过调用 Play()方法播放声音。
while pygame.mixer.get_busy():
pass
这是一个伪 while 语句,用于在声音播放时保持程序忙碌。还记得 pass 关键字吗?这就像一个在 Python 中什么都不做的空白语句。您可以使用它来创建函数的存根代码,或者像在本例中一样,创建空白 while 循环。
pygame.mixer.quit()
当您完成音频时,请始终退出混音器。保存并运行程序,你会听到一声“呸!”关门前的噪音。这是一次性音效的一个例子。这是游戏音频故事的一部分。第二个是音乐,我们将在接下来讨论。
播放、暂停和更改音量
sound 对象允许您更改播放音乐的音量。混音器也可以进行很好的淡出。下面的程序将开始播放一段音乐,并允许播放器控制音量,还可以播放/暂停音乐。完成后,音乐将淡出,程序将停止。
在本节中,我们将介绍
-
pygame.mixer.fadeout()
-
pygame.mixer.pause()
-
pygame.mixer.unpause()
-
Sound.set_volume()
在“ch18”中创建一个名为“playsong.py”的新 Python 脚本,并添加以下代码。像往常一样,我会边走边解释:
import pygame
from pygame.locals import *
PyGame 运行所需的导入。
class Print:
def __init__(self):
self.font = pygame.font.Font(None, 32)
def draw(self, surface, msg, position):
obj = self.font.render(msg, True, (255, 255, 255))
surface.blit(obj, position)
这是一个小的助手类,它将使在主代码中打印文本变得更加容易。它创建一个字体实例,然后“draw()”方法将给定的文本呈现到一个表面上,然后该文本又被传送到给定的表面上。
pygame.init()
pygame.mixer.init()
PyGame 和混音器的初始化。
fpsClock = pygame.time.Clock()
surface = pygame.display.set_mode((640, 480))
out = Print()
创建时钟、主绘图表面和“Print”对象的实例。显示器是 640×480 像素,因为我们不需要为这个项目显示太多信息。
song = pygame.mixer.Sound('bensound-theelevatorbossanova.ogg')
song.play(-1)
将歌曲载入内存并立即播放。请注意,“play()”方法被传递了一个参数–1,这意味着它将一直重复,直到被告知停止为止。
running = True
paused = False
fading = False
volume = 1
从上到下,这些控制变量是:
-
要在音乐播放时保持程序运行
-
音乐暂停了吗
-
音乐逐渐消失了吗
-
音乐音量
进入主循环:
while running:
for event in pygame.event.get():
if event.type == QUIT:
pygame.mixer.fadeout(1000)
通过检查“running”变量,主循环保持运行。如果该变量包含“True”,程序将继续循环并执行循环体。第一部分是“for”循环,它决定了游戏应该处于什么状态。第一个检查(如上)是看玩家是否已经退出游戏(例如,他们点击了窗口上的 X 按钮)。如果是这样,我们指示调音台将音乐淡出 1000 毫秒或 1 秒。
elif event.type == KEYDOWN:
下一个检查是看玩家是否按了一个键。如果他们有,我们想对它作出反应,空格键用于暂停/取消暂停音乐,和键分别用于降低和增加音量,退出键(ESC)用于退出游戏。
if event.key == pygame.K_SPACE:
paused = not paused
if paused:
pygame.mixer.pause()
else:
pygame.mixer.unpause()
如果玩家按下空格键,“暂停”变量将被设置为与当前值相反的值。这意味着如果它是“真”,它将被设置为“假”,反之亦然。在混音器对象上调用适当的方法来暂停/取消暂停音乐。
elif event.key == pygame.K_ESCAPE and not fading:
fading = True
pygame.mixer.fadeout(1000)
如果玩家按下了 escape 键,并且游戏没有让音乐渐隐,那么将“fading”设置为 True,这样玩家就不能让音乐一直渐隐,并通知 PyGame mixer 音乐应该在 1 秒(1000 毫秒)内从最大音量渐隐到零。fadeout()方法采用以毫秒为单位的数值。
elif event.key == pygame.K_LEFTBRACKET:
volume = volume - 0.1
if volume < 0:
volume = 0
song.set_volume(volume)
音量介于 0 和 1 之间。0 表示关闭(静音),1 表示最大音量。如果玩家按下左[括号,音量应该会降低。为此,我们从当前体积中减去 0.1。然后进行检查以确保它保持在范围 0 内..1,然后在“song”对象上调用“set_volume()”以应用此新卷。“song”对象是。我们之前加载的 ogg 文件。
elif event.key == pygame.K_RIGHTBRACKET:
volume = volume + 0.1
if volume > 1:
volume = 1
song.set_volume(volume)
如果播放器按右]括号,音量应该会增加。为此,我们在当前体积中添加 0.1。然后进行检查以确保它保持在范围 0 内..1,然后在“song”对象上调用“set_volume()”以应用此新卷。
现在事件已经处理好了,最后的更新步骤是检查我们是否还在播放音频,如果不是,我们应该退出循环:
if not pygame.mixer.get_busy():
running = False
如果“运行”为假,游戏退出。
surface.fill((0, 0, 0))
out.draw(surface, "Press <SPACE> to pause / unpause the music", (4, 4))
out.draw(surface, "Press <ESC> to fade out and close program", (4, 36))
out.draw(surface, "Press [ and ] to alter the volume", (4, 68))
out.draw(surface, "Current volume: {0:1.2f}".format(volume), (4, 100))
pygame.display.update()
fpsClock.tick(30)
在屏幕上绘制文本,让玩家知道要按的键。
pygame.mixer.quit()
pygame.quit()
当游戏结束时,确保退出混合器和 PyGame。
保存并运行程序;您应该会看到如图 18-1 所示的输出。你还会听到正在播放的歌曲。
图 18-1。
“playsong.py”脚本的输出
结论
这是一个小的介绍,你可以用 PyGame 混音器实现什么。在你的游戏中加入音频非常重要,因为它可以增强乐趣感,并真正有助于传达(例如)物体的重量或受到了多少伤害。
记住当你的游戏结束时,一定要退出混合器!