Pygame-游戏开发入门指南-全-

Pygame 游戏开发入门指南(全)

原文:Beginning Python games development with PyGame

协议:CC BY-NC-SA 4.0

零、简介

游戏开发是一个编程领域,它同时结合了许多主题:数学、物理、图形、用户界面、人工智能等等。由于这个原因,游戏开发既有挑战性又有回报。在大多数语言中,游戏开发并不推荐给任何编程初学者。然而,使用 Python 这样的友好语言和 Pygame 这样的模块进行游戏开发,可以让任何人轻松进入游戏开发领域。即使你是 Python 编程的新手,或者是经验丰富的开发人员,你也会发现游戏开发是进一步学习编程的好方法。通常情况下,编程不是一种非常视觉化的体验,但游戏改变了这一点。您可以看到您的编程逻辑在以一种通常不可能的方式工作。

这本书是给谁的

这本书是写给任何想创造一个电脑游戏,或者想学习游戏开发背后的技术的人的。尽管 Python 是本书的首选工具,但其中涉及的许多技术同样适用于其他语言。

这本书的结构

使用 Pygame 开始 Python 游戏开发分为 12 章,每一章都建立在前一章的基础上——有几个明显的例外。我已经把它组织好了,这样你可以很快得到结果,并在屏幕上看到一些东西,如果你像我一样不耐烦,你可能会喜欢。几乎所有的列表都是独立运行的小项目,它们都是独立的,并且很有娱乐性。因为实验是最好的学习方法,所以鼓励您使用示例代码并修改它以产生不同的效果。您也可以在自己的项目中使用任何代码——得到我的许可!前两章以相当对话的方式介绍了 Python 语言。如果你用 Python 提示阅读它们,你会发现你可以很快学会这门语言。这两章并不构成一个完整的语言教程,但是足够让你理解书中的 Python 代码并编写自己的代码。偶尔,在本书的其余部分会引入新的语法和语言特性,但是我会在第一次使用它们的地方解释它们。如果你精通 Python,可以直接跳到第三章。

第三章是你对 Pygame 的第一次介绍,涵盖了它的历史和功能。它还解释了设置图形显示和处理事件的基础知识,这些技能对于任何游戏都是必不可少的。您将会非常熟悉本章介绍的代码,因为在本书其余部分的所有示例代码中都会用到它。

第四章直接进入创建视觉效果和用 Pygame 在屏幕上绘图的各种方法。

第五章探讨了游戏程序员用来移动这些图像的技术。你会发现关于基于时间的运动的讨论特别有价值,因为它对于游戏中的任何动画都是必不可少的。

第六章告诉你所有你需要知道的,让你的游戏与几乎任何游戏设备连接。本章中的示例代码将让你用键盘、鼠标和操纵杆移动一个角色。

第七章有点不寻常,因为它比其他章节更独立,不太依赖前面的章节。它涵盖了人工智能的主题,并包括一个完整的工作模拟一个蚂蚁的巢,但我在这一章解释的技术可以用来添加看似智能的角色到任何游戏。

第八章和 9 温和地介绍了在 Pygame 中使用三维图形,这是一个重要的话题,因为现在大多数游戏都有 3D 元素——即使它们不是完整的 3D 游戏。我用直观的术语解释数学,使它更容易掌握,你应该会发现它并不像第一次出现时那样令人生畏。

第十章从 3D 图形中稍作休息,讨论如何使用 Pygame 添加音效和音乐,甚至包括一个完全工作的点唱机应用。

最后两章建立在第八章和第九章的基础上,以提高你的 3D 图形知识,并解释如何利用你的显卡上的专用游戏硬件。到《??》第十一章结束时,你将拥有足够的知识来渲染和操作屏幕上的三维物体。第十二章探讨了几种你可以用来创造更令人印象深刻的 3D 视觉效果和产生特殊效果的技术。

除了 12 章之外,还有 2 个附录:附录 A 是整本书使用的游戏对象库的参考,附录 B 解释了如何打包你的游戏并发送给其他人。

先决条件

要运行本书中的代码,您至少需要 Python 的 3.4 版本和 Pygame 的 1.7.1 版本,您可以分别从www.python.orgwww.pygame.org下载。如果你想运行 3D 样本代码,你还需要 PyOpenGL,你可以从pyopengl.sourceforge.net下载。所有这些都是免费软件,这本书包含了如何安装和开始使用它们的说明。如果你发现你有困难,我们将包括安装这些软件包。

下载代码

读者可以在本书的主页www.apress.com/9781484209714上找到本书的源代码。只需向下滚动大约一半,然后单击源代码/下载选项卡。你也可以提交勘误表,从出版社找到相关的书目。

联系作者

我很乐意回答任何关于这本书的内容和源代码的问题。请随时通过http://pythonprogramming.netHSKinsley@gmail.com联系我。

一、Python 简介

我们将用来创建游戏的编程语言是 Python,它之所以得名是因为该语言的原作者是英国电视连续剧巨蟒剧团的粉丝。虽然我们将使用 Python 来创建游戏,但这种语言是一种通用编程语言,用于数据分析、机器人、创建网站等等。Google、NASA 和 Instagram 等公司和机构都非常依赖 Python。

有很多可供选择的语言可以用来创建游戏,但我选择了 Python,因为它倾向于处理细节,让您(程序员)专注于解决问题。对我们来说,解决问题意味着在屏幕上显示游戏角色,让他们看起来很棒,并让他们与虚拟环境互动。

本章是对 Python 的友好介绍;它将帮助您快速掌握这门语言,以便您能够阅读示例代码并开始编写自己的代码。如果你熟悉 Python,那么可以跳过前两章。如果您对 Python 完全陌生,或者您想参加进修课程,请继续阅读。

要开始使用 Python,你首先需要为你的电脑安装一个 Python 解释器。有适用于 PC、Linux 和 Mac 的版本。我们将在本书中使用 Python 3.4 版本。要获得 Python,请前往http://python.org/downloads

Image 到本书出版时,可能会有更高版本的 Python 问世。Python 2 和 Python 3 之间的差异相当显著,尽管未来的更新预计会很小。请随意下载 Python 的最新版本。

您对 Python 的第一印象

运行 Python 代码的通常方式是将其保存到一个文件中,然后运行它。我们很快就会这样做,但现在我们将在交互模式中使用 Python,这让我们一次输入一行代码,并立即收到反馈。你会发现这是 Python 的优势之一。它是学习语言的一个很好的辅助工具,并且经常被有经验的程序员用于数据分析等主题,因为您可以很容易地改变一行并看到即时输出。

一旦您在系统上安装了 Python,您就可以像运行任何其他程序一样运行它。如果你有 Windows,只需双击图标或在开始菜单中选择它。对于其他有命令行的系统,只需键入"python"就可以在交互模式下启动 Python。如果你同时安装了 Python 2 和 Python 3,你可能需要输入python3,而不仅仅是"python"

当您第一次运行 Python 解释器时,您会看到如下内容:

Python 3.4.2 (v3.4.2:ab2c023a9432, Oct  6 2014, 22:16:31)
 [MSC v.1600 64 bit (AMD64)] on win32
Type "copyright", "credits" or "license()" for more information.
>>>

根据您运行的 Python 版本和*台(Windows、Mac、Linux 等),文本可能会有所不同。)你正在运行它。重要的部分是三个 v 形符号(>>>),这是 Python 的提示——是你输入一些代码的邀请,然后 Python 试图运行这些代码。

计算机语言教程的一个长期传统是,你写的第一个程序显示文本“Hello,World!”在银幕上——我又有什么资格打破传统呢!所以深呼吸,在提示后输入print("Hello, World!")。Python 窗口现在会在提示行中显示以下内容:

>>> print("Hello, World!")

如果您按下 Enter 键,Python 将运行您刚才输入的代码行,如果一切顺利,您将在屏幕上看到以下内容:

>>> print("Hello, World!")
Hello, World!
>>> _

Python 已经执行了您的代码行,显示了结果,并给出了一个新的提示让您输入更多代码。那么我们的代码到底是如何工作的呢?单词print是一个函数,它告诉 Python 将随后的内容打印到屏幕上。在print函数之后是一个字符串,它只是字母和/或数字的集合。Python 将引号(")之间的任何内容视为字符串。尝试在引号之间输入您自己的文本,您应该会发现 Python 会像以前一样将它打印到屏幕上。

数字

我们稍后将回到字符串,但是现在让我们从 Python 可以处理的最简单的信息开始:数字。Python 非常擅长处理数字,你几乎可以像使用计算器一样使用它。要查看它的运行情况,请在 Python 中键入以下内容(您不需要键入提示,因为 Python 会为您显示出来):

>>> 2+2

猜猜 Python 会对这一行做什么,然后按回车键。如果你猜对了 4,那就吃块饼干吧——这正是它的作用。Python 对求值 2+2,这在 Python 术语中称为表达式,并显示结果。你也可以用做减法,*做乘法,/做除法。这些符号被称为运算符。你可能会用得最多的是+*/。以下是一些例子:

>>> 10–5
5
>>> 2*4
8
>>> 6/2+1
4
>>> –2+7
5

在现实世界中,只有一种数字,但是计算机——以及随之而来的 Python——有几种表示数字的方式。两种最常用的数字类型是整数 ?? 和浮点数 ??。整数是没有小数点的整数,而浮点有小数点,可以存储小数值。通常你应该使用哪一个是显而易见的——例如,如果你的游戏有生命的概念,你会使用一个整数来存储它们,因为你不太可能有半个生命或 3.673 个生命。浮点值更常用于需要精度的真实世界的值,例如,在一个赛车游戏中,您的汽车可能有每小时 92.4302 英里的速度,您可以将它存储在一个浮点中。

到目前为止,您输入的数字都是整数。要告诉 Python 一个数字是浮点数,只需包含一个小数点。比如 5 和 10 都是整数,但是 5。和 10.0 都是浮点数。

>>> 3/2
1.5
>>> 3.0/2.
1.5
>>> 3./2.
1.5

除了基本的数学之外,你还可以用数字做很多其他的事情。括号是用来保证某个东西先计算;这里有一个例子:

>>> 3/2+1
2.5
>>> 3/(2+1)
1.0

第一行先计算 3 除以 2,然后加 1,结果是 2.5。第二行首先计算 2 加 1,因此结果为 3 除以 3,即 1。

您可以使用的另一个运算符是运算符,它将值提升到幂。例如,2 的 3 次方等于 222。幂运算符是**,作用于整数和浮点数。这里有两个幂操作符的例子:

>>> 2**3
8
>>> 3.0**4
81.0

Python 3 以一种非常可预期的方式处理计算和数字信息,不像它的前身或许多其他语言。许多语言的范围从-2147 百万到 2147 百万,给你一个超过 40 亿的范围。然而,Python 3 并不是这样有界的。

让我们通过计算 2 的 100 次方来创建一个大数,也就是 2×2×2×2……×2 重复 100 次。

>>> 2**100
1267650600228229401496703205376

这是一个很大的数字!如果你感到勇敢,试着计算2**1000甚至2**10000,然后看着你的屏幕被巨大的数字填满。

下一节之前再给大家介绍一个运营商吧。模数 ( %)运算符计算除法的余数。例如,15 的模数 6 是 3,因为 6 除以 15 的两倍,还剩下 3。让我们请 Python 来帮我们做这件事:

>>> 15%6
3

有了这几个运算符,你现在可以计算任何可以计算的东西,无论是两盘河豚的 15%小费,还是一个兽人用+1 斧头击中盔甲造成的伤害。

我不太了解兽人,但让我们计算一下两碟河豚(生河豚,一种日本美食,我希望有一天能尝试一下)的小费。河豚很贵,任何东西都要 200 美元,因为如果不是由受过专门训练的厨师烹制的,吃了它会致命!假设我们在东京找到一家餐馆,供应一盘诱人的河豚,价格为 100 美元。我们可以用 Python 来为我们计算小费:

>>> (100*2)*15/100
30.0

这相当于两个 100 美元盘子价格的 15%——30 美元的小费。对这家餐厅来说足够好了,但是数字会根据我们购买河豚的地点和服务质量而变化。我们可以通过使用变量来使其更加清晰和灵活。变量是一个值的标签,当你创建变量时,你可以用它来代替数字本身。在我们的小费计算中,我们有三个变量:河豚的价格、盘子的数量和小费的百分比。要创建变量,请键入其名称,后跟等号(=),然后键入您要赋予它的值:

>>> price = 100
>>> plates = 2
>>> tip = 15

等号(=)被称为赋值运算符。

Image 注意 Python 变量是区分大小写的,这意味着如果变量名大写不同,Python 会将其视为完全唯一的——这意味着ApplesAPPLESApPlEs被视为三个不同的变量。

我们现在可以用这三个变量代替数字。让我们再计算一下我们的小费:

>>> (price*plates)*(tip/100)
30.0

这也计算了相同的值,但是现在更清楚了一点,因为我们一眼就能看出这些数字代表什么。它也更加灵活,因为我们可以改变变量,重新计算。假设第二天早上我们吃河豚,但在一家便宜的餐馆(75 美元一盘),那里的服务不太好,只值 5%的小费:

>>> price = 75
>>> tip = 5
>>> (price*plates)*(tip/100.)
7.5

这是 7.50 美元的小费,因为服务员很慢才送来清酒,我讨厌为了我的清酒而等待。

用线串

Python 可以存储的另一条信息是字符串。字符串是字符的集合(一个字符是字母、数字、符号等。)并且可以用来存储几乎任何种类的信息。一个字符串可以包含一个图像、一个声音文件,甚至一个视频,但是字符串最常见的用途是存储文本。要在 Python 中输入字符串,请用单引号(')或双引号(")将其括起来。这里有两根弦;两者包含完全相同的信息:

"Hello"
'Hello'

那么,为什么创建字符串的方式不止一种呢?好问题;假设我们想存储这个句子:

我对巫师说了“巫术”。

如果我们将整个句子放在一个带双引号的字符串中,Python 无法知道您想要在单词 wizard 之后结束字符串,并将假定字符串在 said 之后的空格处结束。让我们试一试,看看会发生什么:

>>> print("I said "hocus pocus" to the wizard.")
Traceback ( File "<interactive input>", line 1
     print("I said "hocus pocus" to the wizard.")
                          ^
SyntaxError: invalid syntax

Python 已经抛出了一个异常。本书后面会有更多关于异常的内容,但是现在如果你看到这样的异常,Python 会告诉你你输入的代码有问题。我们可以通过使用替代引号符号来解决在字符串中包含引号的问题。让我们来试试同一个句子,但这次用单引号(')括起来:

>>> print('I said "hocus pocus" to the wizard.')
I said "hocus pocus" to the wizard.

Python 对此相当满意,这次没有抛出异常。这可能是解决报价问题的最简单的方法,但是还有其他选择。如果您在引号前键入反斜杠字符(),它会告诉 Python 您不想在此结束字符串-您只想在字符串中包含引号符号。在 Python 中,反斜杠字符被称为“转义字符”。下面是一个例子:

>>> print("I said \"hocus pocus\" to the wizard.")
I said "hocus pocus" to the wizard.

这样解决问题的方式不同,但结果是一样的。尽管信息太多可能会增加您的负担,但是还有一种定义字符串的方法:如果您以三重单引号(''')或三重双引号(""")开始一个字符串,Python 知道直到它到达另一组相同类型的三重引号时才结束该字符串。这很有用,因为文本很少在行中包含三个引号。这是我们的向导字符串,使用了三重引号:

>>> print("""I said "hocus pocus" to the wizard.""")
I said "hocus pocus" to the wizard.

串联字符串

现在你有几种创建字符串的方法,但是你能用它们做什么呢?就像数字一样,字符串也有可以用来创建新字符串的运算符。如果将两个字符串相加,将得到一个新字符串,其中包含第一个字符串,第二个字符串附加在末尾。您可以使用+操作符添加字符串,就像处理数字一样;我们来试试:

>>> "I love "+"Python!"
'I love Python!'

Python 将两个字符串加在一起并显示了结果。像这样把字符串加在一起叫做字符串串联 。您可以连接任意两个字符串,但不能将一个字符串与一个数字连接起来。让我们试试看会发生什么:

>>> "high "+5
Traceback (most recent call last):
  File "<interactive input>", line 1, in ?
TypeError: cannot concatenate 'str' and 'int' objects

在这里,我们试图通过将数字 5 加到一个字符串上来生成字符串'high 5'。这对 Python 来说没有意义,它通过抛出另一个异常让你知道。如果你想把一个数字加到一个字符串上,你必须先把这个数字转换成一个字符串。你可以通过从数字中构造一个新的字符串来轻松地从数字中创建字符串。下面是如何创建我们的high 5字符串。

>>> "high "+str(5)
'high 5'

这是因为str(5)从数字 5 构造了一个字符串,Python 很乐意将这个字符串与另一个字符串连接起来。

也可以对字符串使用乘法(*)运算符,但只能将字符串乘以整数。猜猜下面一行 Python 代码会做什么:

>>> 'eek! '*10

你可以看到 Python 可以相当直观;如果你把一个字符串乘以 10,它会重复 10 次。字符串并不支持所有的数学运算符,比如/,因为它们会做什么并不直观。"apples"–"oranges"可能意味着什么?

解析字符串

因为字符串可以被看作是字符的集合,所以能够引用它的一部分而不是整体通常是有用的。 Python 用索引操作符来做这件事,它由方括号[]组成,包含字符的偏移量。第一个角色是[0],第二个是[1],第三个是[2],以此类推。从 0 开始而不是从 1 开始可能看起来有点奇怪,但这是计算机语言中的一个传统,当你编写更多的 Python 代码时,你会发现它实际上简化了事情。让我们看看字符串索引的实际应用。首先,我们将创建一个包含字符串的变量,就像处理数字一样:

>>> my_string = 'fugu-sashi'
>>> print(my_string)
'fugu-sashi'

通常你会给字符串取一个更好的名字,但是对于这个小例子,我们就称它为my_string(在mystring之间的下划线字符用来代替空格,因为 Python 不允许变量名中有空格)。我们可以用索引运算符从字符串中挑选出单个字母:

>>> my_string[0]
'f'
>>> my_string[3]
'u'

my_string[0]给你一个字符串,第一个字符在fugu-sashi里,是f。第二行给出了第四个字符,因为第一个字符的偏移量是 0 而不是 1。尽量不要把偏移量看作字符本身的数字,而是看作字符之间的空格(见图 1-1);这将使索引更加直观。

9781484209714_Fig01-01.jpg

图 1-1 。字符串索引

假设我们想找到一个字符串中的最后一个字符。从图 1-1 可以看到,最后一个字符是偏移 9 的“I”,但是如果我们事先不知道字符串呢?我们可以从一个文件中提取字符串,或者玩家可以在一个高分表中键入它。为了找到最后的偏移量,我们首先需要找到字符串的长度,这可以用len 函数来完成。把函数想象成存储的 Python 代码;您向该函数传递一些信息,它使用这些信息来执行一个操作,然后返回,可能带有新的信息。这正是len所做的;我们给它一个字符串,它返回这个字符串的长度。让我们试试my_string上的len功能:

>>> len(my_string)
10

my_string中有 10 个字符,但是我们不能用 10 作为偏移量,因为它正好在字符串的末尾。为了得到结束字符,我们需要 10 之前的偏移量,简单来说就是 9,所以我们减去 1。下面是如何使用len来查找字符串中的最后一个字符:

>>> my_string[len(my_string)-1]
'i'

很简单,我希望你会同意!但是 Python 可以通过使用负索引 让我们变得更容易。如果你用负数做索引,Python 会把它当作从字符串末尾的偏移量,所以[-1]是最后一个字符,[-2]是倒数第二个字符,依此类推(见图 1-2 )。

9781484209714_Fig01-02.jpg

图 1-2 。负索引

我们现在可以用更少的代码找到最后一个字符:

>>> my_string[-1]
'i'

分割字符串

除了提取字符串中的单个字符,你还可以通过分割字符串来挑选出一组字符。切片的工作方式很像索引,但是您使用了两个由冒号(:)分隔的偏移量。第一个偏移量是 Python 应该开始切片的地方;第二个偏移量是应该停止切片的地方。同样,将偏移量视为字符之间的空格,而不是字符本身。

>>> my_string[2:4]
'gu'
>>> my_string[5:10]
'sashi'

第一行告诉 Python 在偏移量 2 和 4 之间切片。从图中可以看出,这些偏移量之间有两个字符:gu。Python 将它们作为单个字符串返回,'gu'。第二行在偏移量 5 和 10 之间分割字符串,并返回字符串'sashi'。如果省略第一个偏移量,Python 会使用字符串的开头;如果省略第二个,它使用字符串的结尾。

>>> my_string[:4]
'fugu'
>>> my_string[5:]
'sashi'

切片可以再取一个值,用作步骤的值。如果步长值为 1 或者您没有提供它,Python 将简单地返回前两个偏移之间的切片。如果您使用步长值 2 进行切片,那么将返回包含原始字符串中每隔一个字符的字符串。步长 3 将返回每第三个字符,依此类推。以下是这种切片的一些示例:

>>> my_string[::2]
'fg-ah'
>>> my_string[1::3]
'u-s'

第一行从字符串的开头到结尾分段(因为省略了前两个偏移量),但是因为步长值是 2,所以它每隔一个字符进行一次分段。第二行从偏移量 1(在u处)开始,切片到末尾,每三个字符取一个。切片中的步长值也可以是负值,这有一个有趣的效果。当 Python 看到一个负步长时,它会颠倒切片的顺序,从第二个偏移到第一个偏移。您可以使用此功能轻松反转字符串:

>>> my_string[::-1]
'ihsas-uguf'
>>> my_string[::-2]
'issuu'

第一行只是返回一个字符顺序相反的字符串。因为步长值是负的,所以它从字符串的结尾到开头。

字符串方法

除了这些操作符,字符串还有许多方法,它们是包含在 Python 对象中的的函数,并对它们执行一些操作。Python 字符串包含许多有用的方法来帮助您处理字符串。下面是其中的一些,适用于我们的赋格弦乐:

>>> my_string.upper()
'FUGU-SASHI'
>>> my_string.capitalize()
'Fugu-sashi'
>>> my_string.title()
'Fugu-Sashi'

这里我们对一个字符串应用不同的方法。每一个都返回一个以某种方式修改过的新字符串。我们可以看到,upper返回一个所有字母都转换为大写的字符串,capitalize返回一个第一个字符转换为大写的新字符串,title返回一个每个单词的第一个字符转换为大写的新字符串。这些方法不需要任何其他信息,但是括号仍然是告诉 Python 调用函数所必需的。

Image 注意 Python 字符串是不可变的,也就是说一个字符串一旦创建就不能修改,但是可以从中创建新的字符串。实际上你很少会注意到这一点,因为创建新的字符串是如此容易(这就是我们一直在做的事情)!

列表和元组

像大多数语言一样,Python 有存储对象组的方法,这很幸运,因为只有一个外星人、一颗子弹或一种武器的游戏会很无聊!存储其他对象的 Python 对象被称为集合,最简单和最常用的集合之一是 list。让我们首先创建一个空列表:

>>> my_list=[]

方括号创建了一个空列表,然后将其分配给变量my_list。要向列表中添加内容,可以使用append方法,该方法将任何 Python 对象添加到列表的末尾。让我们假设我们的列表将包含我们一周的购物,并添加几个项目:

>>> my_list.append('chopsticks')
>>> my_list.append('soy sauce')

在这里,我们向my_list添加了两个字符串,但是我们可以很容易地添加 Python 的任何其他对象,包括其他列表。如果您现在在 Python 提示符下键入my_list,它将为您显示它的内容:

>>> my_list
['chopsticks', 'soy sauce']

在这里,我们可以看到这两个字符串现在存储在列表中。我们不能只靠筷子和酱油生活,所以让我们在购物清单上增加几样东西:

>>> my_list.append('wasabi')
>>> my_list.append('fugu')
>>> my_list.append('sake')
>>> my_list.append('apple pie')
>>> my_list
['chopsticks', 'soy sauce', 'wasabi', 'fugu', 'sake', 'apple pie']

修改列表项

Python 列表是可变的,这意味着你可以在它们被创建后改变它们。因此,除了使用 index 操作符检索列表内容之外,您还可以通过向它分配一个新项来更改任何索引处的项。假设我们特别想得到酱油;我们可以通过赋值操作符(= ): 为第二项赋值来改变它

>>> my_list[1]='dark soy sauce'
>>> my_list
['chopsticks', 'dark soy sauce', 'wasabi', 'fugu', 'sake', 'apple pie']

移除列表项目

除了更改列表中的项目,您还可以从列表中删除项目。假设我们想删除apple pie,因为它似乎不适合我们购物清单的其余部分。我们可以使用del操作符来完成这项工作,它将从我们的列表中删除任何一项——在本例中,它是最后一项,因此我们将使用负索引:

>>> del my_list[-1]
>>> my_list
['chopsticks', 'dark soy sauce', 'wasabi', 'fugu', 'sake']

列表支持许多操作符,它们的工作方式与字符串相似。让我们看一下切片和索引,您应该会觉得非常熟悉:

>>> my_list[2]
'wasabi'
>>> my_list[-1]
'sake'

第一行返回偏移量为 2 的字符串,这是我们购物列表中的第三个位置。就像字符串一样,列表中的第一项总是 0。第二行使用负索引,就像 strings [-1]返回最后一项。

切片列表的工作方式类似于切片字符串,除了它们返回一个新的列表而不是一个字符串。让我们把购物清单分成两部分:

>>> my_list[:2]
['chopsticks', 'dark soy sauce']
>>> my_list[2:]
['wasabi', 'fugu', 'sake']
>>>

在第一个片段中,我们要求 Python 给出从列表开始到偏移量 2 的所有项目;在第二个片段中,我们请求了从偏移量 2 到列表末尾的所有内容。列表偏移量的工作方式就像字符串偏移量一样,所以尽量把它们看作列表中对象之间的空格,而不是对象本身。因此,偏移量 0 在第一项之前,偏移量 1 在第一项之后第二项之前。

您也可以使用+操作符添加列表。当您一起添加列表时,它会创建一个包含两个列表中的项目的列表。让我们创建一个新列表,并将其添加到我们的购物清单中:

>>> my_list2 = ['ramen', 'shiitake mushrooms']
>>> my_list += my_list2
>>> my_list
['chopsticks', 'dark soy sauce', 'wasabi', 'fugu', 'sake', 'ramen', 
`'shiitake mushrooms']`

`第一行创建了一个名为 my_list2 的新字符串列表。我们创建的第二个列表与第一个略有不同;我们没有创建一个空白列表并一次添加一个条目,而是创建了一个已经有两个条目的列表。第二行使用+=操作符,这是一种很有用的简写:my_list+=my_list2 与 my_list=my_list+my_list2 相同,其效果是将两个列表相加,并将结果存储回 my_list 中。

列出方法

除了这些操作符,列表还支持许多方法。让我们使用sort方法将我们的购物清单按字母顺序排序:

>>> my_list.sort()
>>> my_list
['chopsticks', 'dark soy sauce', 'fugu', 'ramen', 'sake', 
`'shiitake mushrooms', 'wasabi']`

`方法对列表的内容进行排序。顺序取决于列表的内容,但对于字符串列表,排序是按字母顺序进行的。

你会注意到 Python 在调用sort后没有打印任何东西;这是因为sort不返回一个排序列表,而只是对它被调用的列表进行排序。第二行是要求 Python 显示我们的列表内容所必需的。

假设我们要去购物,我们想从清单上拿走一件商品,然后去超市找。我们可以用pop方法做到这一点,该方法从列表中删除一个条目并返回它:

>>> my_list.pop(0)
'chopsticks'

我们已经要求my_list在偏移量 0 处“弹出”项目,这是chopsticks。如果我们现在显示购物清单的内容,我们应该看到第一项确实已经被删除:

>>> my_list
['fugu', 'ramen', 'sake', 'shiitake mushrooms', 'soy sauce', 'wasabi']

列表方法比我们在这里介绍的要多;详见表 1-1 。

表 1-1 。Python 列表方法

|

方法方法

|

描述

|
| --- | --- |
| append | 将项目追加到列表中 |
| count | 统计项目在列表中出现的次数 |
| extend | 添加另一个集合中的项目 |
| index | 查找字符串的偏移量 |
| insert | 将项目插入列表 |
| pop | 从列表中移除一个偏移量处的项并返回它 |
| remove | 从列表中移除特定项目 |
| reverse | 反转列表 |
| sort | 对列表排序 |

元组

我们将在本节介绍的另一个集合是元组。元组类似于列表,除了它们是不可变的;也就是说,就像字符串一样,一旦创建,内容就不能更改。当元组包含的信息以某种方式联系在一起时,通常优先于列表使用元组,例如,元组可以表示电话号码和区号,因为这两部分都需要拨号。它们的创建方式与列表类似,但是使用圆括号(),而不是方括号。让我们创建一个存储我们最喜欢的寿司外卖店的电话号码的元组:

>>> my_tuple=('555', 'EATFUGU')
>>> my_tuple
('555', 'EATFUGU')

这里我们创建了一个包含两个字符串的元组,其中包含我们的河豚外卖的区号和号码。为了证明元组是不可变的,让我们尝试向它追加一个项:

>>> my_tuple.append('ramen')
Traceback (most recent call last):
  File "<interactive input>", line 1, in ?
AttributeError: 'tuple' object has no attribute 'append'

Python 抛出了一个AttributeError异常,让你知道元组不支持append。如果您尝试做任何修改元组的事情,您将得到类似的结果。然而,元组支持所有的索引和切片操作符,因为这些操作符不修改元组。

>>> my_tuple[0]
'555'
>>> my_tuple[1]
'EATFUGU'

打开包装

因为元组通常用于传递组值,Python 提供了一种简单的提取方法,称为解包。让我们把元组分解成两个变量:一个表示区号,一个表示号码。

>>> my_tuple=('555', 'EATFUGU')
>>> area_code, number = my_tuple
>>> area_code
'555'
>>> number
'EATFUGU'

在这里,您可以看到在一行代码中,Python 将元组的两个部分解包为两个独立的值。解包实际上适用于列表和其他 Python 对象,但是您最常将它用于元组。

提取元组中的值的另一种方法是将其转换为列表。您可以通过用元组作为参数构造一个列表来实现这一点——例如,list(my_tuple)返回等价列表,即['555', 'EATFUGU']。您也可以反过来,通过调用列表上的tuple来创建一个元组——例如,tuple(['555', 'EATFUGU'])返回我们的原始元组。

在接下来的章节中,你将了解到使用元组而不是列表的最佳位置;现在使用经验法则,如果您从不需要修改内容,您应该使用元组。

Image 注意创建一个包含一个或零个条目的元组与列表略有不同。这是因为 Python 也使用括号来定义类似数学表达式中的优先级。要定义只有一个项目的元组,请在项目后添加逗号;要定义空元组,只需在括号中包含逗号本身。比如,('ramen',)是一个只有一项的元组,(,)是一个空元组。

字典

我们要看的最后一个集合类型是字典。我们之前看到的集合都是序列集合,因为值从开始到结束都是一个序列,你可以通过它们在列表中的位置来访问它们。字典是映射集合,因为它们将一条信息映射到另一条信息。我们可以使用字典通过将食品名称映射到其价格来存储购物清单的价格。假设河豚 100 美元,拉面 5 美元;我们可以创建一个保存这些信息的字典,如下所示:

>>> my_dictionary={'ramen': 5.0, 'fugu': 100.0}

花括号创建了一个字典。在大括号中,我们有一个字符串'ramen',后跟一个冒号,然后是数字5.0(以美元表示的价格)。这告诉 Python 字符串映射到数字;换句话说,如果我们知道食物的名称,我们就可以查找价格。字典中的多个条目用逗号分隔;在这个例子中,我们有第二个条目,它将'fugu'映射到值100.0

为了检索该信息,我们再次使用方括号([])操作符,传入我们想要搜索的(在本例中,键不是fugu就是ramen)。字典返回与关键字相关联的——商品的价格。让我们来看看我们的两把钥匙:

>>> my_dictionary['fugu']
100.0
>>> my_dictionary['ramen']
5.0

您也可以通过向字典分配新值来向字典添加新项目:

>>> my_dictionary['chopsticks']=7.50
>>> my_dictionary['sake']=19.95
>>> my_dictionary
{'sake': 19.0, 'ramen': 5.0, 'chopsticks': 7.5, 'fugu': 100.0}

这里我们给词典增加了两个新词。您可能已经注意到,当 Python 为我们显示列表时,条目的顺序与我们最初创建它时的顺序不同。这是因为字典对字典中的键没有任何顺序概念,你看到的显示内容没有特定的顺序。重要的是 Python 记得什么键映射到什么值——这一点它做得很好!

一个循环是一种不止一次运行一段代码的方式。循环是编程语言的基础,你会发现你在游戏中写的几乎每一行代码都在某种循环中。像许多其他编程语言一样,Python 有两种类型的循环来处理所有的循环需求:while 循环的和 for 循环的

While 循环

当你只在一个条件为真时重复一段代码时,使用一个while循环。让我们使用一个简单的 while 循环来显示从 1 到 5 的数字。我们将从在解释器中输入以下几行开始:

>>> count=1
>>> while count<=5:
...

当您在第二行之后按 Enter 键时,您会注意到现在看到的不是通常的 Python 提示符,而是三个句点(...)。这是因为行尾的冒号表示后面还有更多代码。在 while 循环的情况下,它是我们想要重复的代码。

所有语言都需要某种方式来标记代码块的开始和结束。有的用花括号({ })之类的符号,有的用doend之类的词。Python 做的事情略有不同,使用缩进来定义代码块。要告诉 Python 一行代码是块的一部分,而不是代码的其余部分,请在该行之前插入一个制表符(通过按 tab 键):

...     print(count)
...     count+=1

Image 注意在一些系统中,你可能会发现一个制表符会自动插入到程序块的第一行。如果一个块中有大量代码,这可能会很方便。删除制表符,并按 Enter 键正常结束块。

在最后一行之后按两次 Enter 空行告诉解释器您已经完成了代码块的输入。while 循环现在运行并显示数字 1 到 5。那么这是如何工作的呢?嗯,while语句后面是一个条件 ( count<=5,可以读作“是count 小于等于5?”Python 第一次遇到 while 循环,count是 1,满足我们小于等于 5 的条件——所以 Python 运行代码块。代码块中的两行先打印出count的值,然后加 1。第二次,count是 2,也满足条件,我们再次循环。最终count变成 6,肯定是不是小于等于 5,这次 Python 跳过了代码块。

小于或等于(<=)只是一个比较运算符。其他可以使用的请参见表 1-2 。

表 1-2 。比较运算符

|

操作员

|

描述

|
| --- | --- |
| < | 不到 |
| <= | 小于或等于 |
| > | 大于 |
| >= | 大于或等于 |
| == | 等于 |
| != | 不等于 |

Image 小心小心你的循环!如果你使用一个永远为真的条件,比如2>1,Python 将永远循环下去。如果您最终陷入这种困境,请按 Ctrl+C 来停止 Python。每个程序员都至少陷入过一次无限循环!

对于循环

虽然循环有它们的用途,知道如何使用它们也很重要,但是 for 循环通常是更好的选择。一个 for 循环遍历一个 iterable Python 对象,给你一个新值,直到没有剩余的条目。你以前遇到过 iterables:列表,元组,字典,甚至字符串都是 iterable 对象。让我们将 while 循环示例重写为 for 循环:

>>> for count in range(0,6):
...     print(count)

这里我们迭代了range函数的结果,它创建了一个从第一个参数到第二个参数的值列表,但不包括第二个参数。

如您所见,对range的调用创建了一个包含数字 0 到 5 的列表,这正是我们想要在循环中显示的内容。当 Python 第一次遍历 for 循环时,它从列表中挑选第一个值,并将其赋给变量count;然后它运行循环中的代码,简单地将count的当前值打印到屏幕上。当循环到达列表末尾时,它在五次循环后结束。

如果您想从除 0 以外的任何数字开始,您将需要这两个参数,但请尝试:

>>> for count in range(6):
print(count)

Image 注意你应该看到你得到了和以前一样的输出。

实践中的 Python

在我们进入下一章之前,让我们把我们所学的应用到实际中去。心算从来都不是我的强项,所以我想写一小段 Python 代码来遍历我们的购物清单并找到总价。首先,我们将创建一个包含本周杂货的列表和一个将每件商品的名称与其价格对应起来的字典:

>>> shopping=['fugu', 'ramen', 'sake', 'shiitake mushrooms', 'soy sauce', 'wasabi']
>>> prices={'fugu':100.0, 'ramen':5.0, 'sake':45.0, 'shiitake mushrooms':3.5, 'soy sauce':7.50, 'wasabi':10.0}
>>> total=0.00

好极了。我们现在有两个 Python 集合来存储关于我们的杂货的所有信息,还有一个变量来存储总数。我们现在需要做的是循环遍历shopping,在prices中查找每个价格,并将其添加到total:

>>> for item in shopping:
...   total+= prices[item]
>>> total
171.0

这就够了!变量total现在保存了我们购物清单中每一项的总和,我们可以看到总数是非常合理的 171 美元。别担心,接下来几章中的示例代码会比购物清单有趣得多!

摘要

在第一章中,我们已经探索了一些基本的 Python 结构,其中大部分在编写新代码时会经常用到。当涉及到编写游戏和其他 Python 程序时,你可以把你到目前为止学到的东西看作是最基本的工具。数据(数字和字符串)和集合(元组、列表和字典)尤其重要,因为可以在其中存储游戏的每个方面。

在接下来的章节中,你将学习如何将你所学的知识整合起来,以创建更复杂的程序。您将发现如何使用逻辑、创建函数以及利用面向对象编程的力量。``

二、探索 Python

在前一章中,我们一次输入一行 Python 代码,但现在我们将把交互式解释器放到一边,开始创建 Python 文件。在这一章中,我们将介绍更多 Python 代码的构建模块,并向你展示如何使用类来帮助创建游戏。我们还将解释如何使用所有 Python 安装所附带的代码库。

创建脚本

包含 Python 代码的文件称为脚本。创建脚本只需要一个简单的文本编辑器,但最好使用编辑器,通常称为“IDE”。IDLE(Python 的标准发行版附带的)是我们在这里将使用的,尽管有许多替代方法。

要运行一个脚本,你需要在编辑器中编写代码,保存它,然后你通常只需双击它,或者如果你更喜欢命令行,键入python后跟一个空格和你的脚本的名称。大多数 Python 编辑器都有一个快捷键来运行您正在编辑的脚本。在空闲状态下运行脚本的快捷方式是 F5 键。要打开 IDLE,如果桌面上有快捷方式,请单击它,或者搜索 IDLE。打开它,你看到的是交互式编辑器。转到文件新建,这是一个空文档。你在这里写的是你的*剧本*。完成后,您需要保存它,您可以运行它。

`Image 注意大多数 Python 编辑器会自动为你缩进。通常默认缩进是四个空格。

运行后(F5 或 Run image Run 模块),应该会看到另一个窗口弹出,这是控制台输出,参见图 2-1 。

9781484209714_Fig02-01.jpg

图 2-1 。空闲的 Python 代码和输出控制台窗口

使用逻辑

不仅仅对于热血的瓦肯人来说,逻辑是任何软件的重要组成部分——包括游戏。任何游戏都需要根据给定或计算的信息做出决策。如果一束激光击中了玩家的悬停坦克,游戏必须决定是否已经造成足够的破坏来摧毁它——如果已经造成,则显示一个爆炸动画。这只是一个例子,为了让我们相信它不仅仅是一台愚蠢的机器,电脑游戏必须做出一系列决定。所以请戴上你的斯波克耳朵,我们将涵盖逻辑。

理解布尔值

计算机使用的逻辑是布尔逻辑,这样称呼是因为它是由乔治·布尔在 19 世纪发明的——在 PlayStation 上市前几年。

你已经看到了在上一章中作为 while 循环的一部分使用的逻辑;count<=5是一个逻辑表达式,像所有逻辑表达式一样,它要么产生True要么产生False。这些真值,正如它们被称为的那样,在做决定时被使用。在 while 循环的情况下,True的值告诉 Python 继续循环一次,但是False的值导致 Python 跳过代码块。(见清单 2-1 中逻辑表达式的几个例子。)布尔逻辑需要记住的重要一点是,没有中间值:你不可能有 25%的正确和 75%的错误——它总是非此即彼。

清单 2-1 。简单的逻辑

score = 100
health = 60
damage = 50
fugu = "tasty"
print(score != 100)
print(health - damage > 0)
print(fugu == "tasty")

运行这个简单的脚本会产生以下输出:

False

True

True

Image TrueFalse也有对应的数字:1 代表True,0 代表False。这是计算机科学的核心,它完全归结为 0 和 1,对应于电信号的存在(1 或True)或不存在(0 或False)。

布尔值可以像任何其他 Python 类型一样处理,因此可以有一个引用布尔值的变量。例如,如果我们要添加行is_fugu_tasty = (fugu == "tasty"),那么is_fugu_tasty将引用值True

如果语句

使用if语句时,逻辑表达式发挥了自己的作用。只有当条件为真时,才使用if语句来运行代码。如果条件为假,那么 Python 会跳过代码块的末尾。下面是一个简单的if语句的例子:

if fugu == "tasty":
   print("Eat the fugu!")

该条件使用比较运算符(==)将变量与字符串进行比较。假设我们使用清单 2-1 中的值fugu,这个比较将产生True,这给 Python 运行缩进代码块开了绿灯。

逻辑积算符

通常情况下,您需要检查几个条件。假设我们想吃河豚,如果它好吃的话低于 100 美元一盘。我们可以用and操作符:将这两个条件结合起来

price = 50.0
if fugu == ("tasty" and price < 100.0):
    print("Eat the fugu!")

这里我们只吃河豚,如果fugu被设置为"tasty" 并且price的值小于100.0

Image 一个常见的逻辑错误是使用 if 和 and 语句,比如:if 河豚和酱油== "tasty:"认为这是在问河豚和酱油是否都好吃。相反,这只是要求河豚是真实的,酱油是“美味的”。正确的方法应该是:如果河豚“好吃”,酱油“好吃”。

表 2-1 列出了and运算符如何组合值,但我希望它是不言自明的。你可以在现实生活中找到这个操作者;例如,我的汽车只有在电池没有耗尽我还有汽油的情况下才能启动。

表 2-1 。And 运算符的真值表

|

逻辑

|

结果

|
| --- | --- |
| False and False | False |
| True and False | False |
| False and True | False |
| True and True | True |

Or 运算符

为了补充and操作符,我们有了or操作符,如果第一个或第二个值是True,就会产生True。假设我们想吃河豚,如果它好吃或者它很便宜(毕竟,谁能拒绝便宜的河豚呢?):

if fugu == "tasty" or price < 20.0:
    print("Eat the fugu!")

就像and运算符一样,or运算符在现实生活中有很多应用。如果我没油了,我的车就会停下来或者电池没电了。or真值表见表 2-2 。

表 2-2 。or 运算符的真值表

|

逻辑

|

结果

|
| --- | --- |
| False or False | False |
| True or False | True |
| False or True | True |
| True or True | True |

“非”算符

我们要看的最后一个逻辑运算符是not运算符,它交换一个布尔值的状态,因此True变成了False,而False变成了True(参见表 2-3 )。你可以用这个来逆转任何情况:

if not (fugu == "tasty" and price < 100.0):
    print("Don't eat the fugu!")

这里我们颠倒了fugu == "tasty" and price < 100.0条件,这样 Python 只有在条件为而不是真(即假)时才运行代码块。

表 2-3 。Not 运算符的真值表

|

逻辑

|

结果

|
| --- | --- |
| not True | False |
| not False | True |

else 语句

你可能已经注意到,前面的片段与我们第一个河豚逻辑语句相反。我们有一个在条件为真时发生的动作,另一个在相同的条件不为真时运行。这是一种常见的情况,Python 有一种方法来附加一个替代动作到一个if语句上。else语句跟在if语句之后,引入了一个新的代码块,该代码块只有在条件为假时才会运行。让我们看一个如何使用else的例子:

if fugu == "tasty":
    print("Eat the fugu!")
else:
    print("Don't eat the fugu!")

Python 运行这段代码时,如果条件为真,就会运行第一条print语句;else它会运行第二个条件。

elif 语句

通常另一个if语句会跟在一个else语句之后。Python 将一个else后跟一个if组合成一条语句:elif语句。假设我们想根据价格将河豚分为三类。为了便于讨论,我们将 20-100 美元归类为价格合理的河豚,高于这个范围的归类为昂贵的河豚,低于这个范围的归类为便宜的河豚。Python 可以使用elif : 来为我们做这件事

if price < 20:
    print("Cheap fugu!")
elif price < 100:
    print("Reasonably priced fugu.")
else:
    print("Expensive fugu!")

这里我们有三个代码块,但是只有一个运行。如果price小于 20,那么第一块运行;如果price小于 100,并且第一个块为假,那么第二个块将运行;并且price的任何其他值将导致第三个块运行。在if之后,你可以有任意多的elif语句,但是如果你有一个else语句,它必须在最后。

了解功能

函数是一段存储的 Python 代码,您可以向它传递信息,也可以从它那里获取信息。Python 提供了大量有用的函数(参见表 2-4 中的一些例子),但是你也可以创建自己的函数。

表 2-4 。一些内置的 Python 函数

|

功能

|

描述

|

例子

|
| --- | --- | --- |
| abs | 求一个数的绝对值 | abs(-3) |
| help | 显示任何 Python 对象的使用信息 | help([]) |
| len | 返回字符串或集合的长度 | len("hello") |
| max | 返回最大值 | max(3, 5) |
| min | 返回最小值 | min(3, 4) |
| round | 将浮点数舍入到给定的精度 | round(10.2756, 2) |

*如需更全面的 Python 内置函数列表,请参阅 Python 文档,或访问 http://doc.python.org

定义函数

要在 Python 中定义一个函数,您可以使用def语句,后跟您想要赋予函数的名称。您可以使用任何您想要的名称,但是给它一个描述它实际做什么的名称是一个好主意!函数名通常是小写的,并且可以使用下划线来分隔单词。清单 2-2 是一个简单的 Python 函数,用于计算河豚的小费。

清单 2-2 。计算河豚的小费

def fugu_tip(price, num_plates, tip):
    total = price * num_plates
    tip = total * (tip / 100)
    return tip

print(fugu_tip(100.0, 2, 15.0))
print(fugu_tip(50.0, 1, 5.0))

该脚本产生以下输出:

30.0
2.5

当 Python 第一次遇到一个def语句时,它知道期待一个函数定义,由函数名和括号中的一系列参数组成。正如forwhileif语句一样,冒号用于引入代码块(称为函数体)。在前面提到的语句中,代码块不会立即运行——它只是被存储起来,直到需要的时候。调用该函数会导致 Python 跳转到函数体的开头,并将调用中给出的信息赋给每个参数。所以在清单 2-2 中,对fugu_tip的第一次调用是在price设置为 100、num_plates设置为 2、tip设置为 15 的情况下运行的。

fugu_tip中唯一没有遇到过的是return语句,它告诉 Python 从函数跳回,可能带有一些新信息。在fugu_tip的例子中,我们返回提示的值,但是函数可以返回任何 Python 对象。

Image 注意函数中不需要return语句。如果没有return语句,函数将在到达代码块末尾时返回值None——这是一个特殊的 Python 值,表示“这里什么都没有”函数可以用来计算或返回数据,或者它们可以简单地运行一段代码,比如发送一封电子邮件。

你可能已经注意到在fugu_tip里面创建了两个变量;这些被称为局部变量,因为它们只存在于函数内部。当函数返回时,totaltip将不再存在于 Python 的内存中——尽管函数外部可能有同名的变量。

默认值

参数可以有一个默认的值,如果您没有在函数调用中提供一个值,就使用这个值。如果没有默认值,Python 会在您忘记参数时抛出异常。让我们给fugu_tip赋予默认值。我给小费很大方,所以我们将默认的tip设为 15(代表餐费的百分比),由于我不喜欢一个人吃饭,我们将默认的num_plates设为 2。

要在 Python 中设置默认值,请在参数名后面附加一个=符号,后面跟一个您想要给它的值。参见清单 2-3 中的以获得带有这些默认值的修改后的fugu_tip函数。fugu_tip现在可以只用一个值来调用;如果忽略其他两个值,它们会自动填充。函数定义中可以有任意多的默认值,但是带默认值的参数必须出现在参数列表的末尾。

清单 2-3 。计算河豚的小费

def fugu_tip(price, num_plates=2, tip=15.):
    total = price * num_plates
    tip = total * (tip / 100.)
    return tip

print(fugu_tip(100.0))
print(fugu_tip(50.0, 1, 5.0))
print(fugu_tip(50.0, tip=10.0))

运行此代码将为我们提供以下提示值:

30.0
2.5
10.0

你可能已经注意到清单 2-3 中的有些不寻常。对fugu_tip的第三次调用省略了num_plates的值,并通过名称设置了tip的值。当您像这样显式地设置参数时,它们被称为关键字参数。如果您的函数有许多参数,但您只需要设置其中的几个参数,那么它们就很有用。如果没有默认值,参数必须以与参数列表相同的顺序给出。

介绍面向对象编程

你可能听说过面向对象编程(OOP)这个术语。但是如果你不熟悉它也不用担心,因为这个概念非常简单。

那么,用面向对象的术语来说,对象到底是什么?嗯,字面上可以是任何东西。在一个游戏中,我们可能有一个粒子的物体——比方说,爆炸产生的燃烧灰烬,或者引起爆炸的悬浮坦克。事实上,整个游戏世界都可能是一个物体。对象的目的是包含信息,并给予程序员用这些信息做事情的能力。

当构造一个对象时,通常最好从计算出它包含什么信息,或属性开始。让我们想想在一个被设计成代表未来悬浮坦克的物体中会发现什么。它应至少包含以下属性:

  • 坦克在哪里?
  • 它面向哪个方向?
  • 它跑得有多快?
  • 它有多少护甲?
  • 它有几个壳?

现在我们有了描述坦克和它在做什么的信息,我们需要给它执行坦克在游戏中需要做的所有动作的能力。在 OOP 中,这些动作被称为方法。我能想到下面这些方法是一辆坦克绝对需要的,但可能会更多:

  • Move:向前移动坦克。
  • Turn:向左/向右旋转水箱。
  • Fire:发射炮弹。
  • Hit:这是敌方炮弹击中坦克时的动作。
  • 用爆炸动画替换坦克。

您可以看到,这些方法通常会更改对象的属性。当使用Move方法时,它将更新坦克的Position。同样,当使用Fire方法时,会更新Ammo的值(当然除非没有剩下Ammo;然后Fire什么都不会做!).

使用类

一个是 Python 定义对象的方式。你以前实际上使用过类;列表,字典,甚至字符串都是类,但是你也可以自己创建。把一个类想象成一个对象的一种模板,因为你定义了这个类一次,然后用它来创建你需要的任意多的对象。让我们写一个简单的Tank类(见清单 2-4);我们稍后将使用它来创建一个简单的游戏。

清单 2-4 。储罐等级定义示例

class Tank(object):
    def __init__(self, name):
        self.name  = name
        self.alive = True
        self.ammo  = 5
        self.armor = 60

当 Python 遇到class Tank(object):时,它会创建一个名为Tank的类,这个类是从名为object基类派生而来的。从一个类派生意味着建立在它所做的基础上。我们可以先创建一个叫Vehicle的职业,它可以处理移动和转弯,然后从它派生并增加发射武器的能力来创建一辆坦克。这种方法的优点是Vehicle可以被重用来给其他游戏实体旋转和移动的能力。对于这个例子,我们没有另一个类来构建,所以我们的基类将是object,这是 Python 本身内置的一个简单的类。

Image 注意我可能给你的印象是object做的不多,但实际上它在与类一起工作时在幕后做了很多有用的事情——你只是不直接使用它。

class语句后缩进代码块中的所有内容都是类定义。在这里,我们设置用于描述对象的属性,并提供它将需要的所有方法。在 Python 中,属性只是存储在对象中的变量,而方法是处理对象的函数。在我们的Tank类中,有一个奇怪命名的方法叫做__init__,它对 Python 有特殊的意义。当你创建一个对象时,Python 会自动调用这个方法。Python 程序员通常使用它来为对象分配属性,但是在首次创建对象时,您可以做任何其他可能需要的事情。

这个__init__方法有两个参数:selfname。因为方法可能用于许多对象,所以我们需要某种方法来知道我们正在使用哪个对象。这就是self的用武之地——它是对 Python 自动提供给所有方法调用的当前对象的引用。第二个参数(name)是一个字符串,我们将使用它来区分坦克,因为坦克不止一个。

__init__中的代码首先将name参数复制到一个属性中,以便我们稍后可以检索它;然后它会分配一些我们需要的其他属性。在我计划的游戏中,我们不需要坦克的大量信息;我们只需要知道坦克是否还活着(self.alive),它还有多少弹药(self.ammo,以及它还有多少装甲(self.armor)。

Image 注意你不用调用第一个参数self。您可以将其命名为任何您想要的名称,但是坚持使用self是一个好主意,这样当您阅读您的代码时,您将确切地知道它的用途。Python 程序员倾向于坚持这种约定,所以在交换代码时不会产生混淆。

创建对象

现在我们有了一个坦克定义,我们可以通过调用Tank来创建一个新的坦克,我们提供了一个字符串。我们来看一个例子:

my_tank = Tank("Bob")

这创建了一个名为 Bob 的新 tank,并调用__init__来初始化它。然后坦克鲍勃被分配给变量my_tank,这个变量被称为Tank类的实例。我们现在可以将my_tank视为一个单独的对象——将它传递给函数,存储在列表中,等等,或者我们可以单独访问属性。例如,print my_tank.name会显示Bob

加入我们的行列

只有一个方法,Tank类做不出任何有趣的事情。让我们用清单 2-5 中的一些方法来充实它。

清单 2-5 。扩展坦克等级

def __str__(self):
    if self.alive:
        return "%s (%i armor, %i shells)" % (self.name, self.armor, self.ammo)
    else:
        return "%s (DEAD)" % self.name

def fire_at(self, enemy):
    if self.ammo >= 1:
        self.ammo -= 1
        print(self.name, "fires on", enemy.name)
        enemy.hit()
    else:
        print(self.name, "has no shells!")

def hit(self):
    self.armor -= 20
    print(self.name, "is hit!")
    if self.armor <= 0:
        self.explode()

def explode(self):
    self.alive = False
    print(self.name, "explodes!")

清单 2-5 中的第一个方法是另一个特殊的方法。任何名字的开头和结尾都有两个下划线,这对 Python 来说有着特殊的意义。__str __ 的作用是返回一个描述对象的字符串;当您试图用str将对象转换成字符串时,它会被调用,这将在您打印它时发生。因此,如果我们要做print my_tank,它应该显示一个字符串,其中包含一些关于坦克鲍勃的有用信息。清单 2-5 中的__str__根据坦克是活的还是死的返回不同的字符串。如果坦克是活的,那么这一行将运行:

return "%s (%i armor, %i shells)" % (self.name, self.armor, self.ammo)

这可能是你以前没见过的。使用%操作符将字符串"%s (%i armor, %i shells)"与元组(self.name, self.armor, self.ammo)组合在一起。这被称为字符串格式化,这是创建复杂字符串的一个很好的方式,没有太多的麻烦。字符串中的前两个字符是%s,这告诉 Python 用元组中的第一项替换它们,元组是包含坦克名称的字符串。在字符串的后面,Python 到达了%i,它被元组中的第二个项目(一个整数)替换,以此类推,直到元组中不再有项目。字符串插值通常比将许多小字符串相加更容易使用。这一行做同样的事情,但是使用简单的字符串连接:

return self.name+" ("+str(self.armor)+" armor, "+str(self.ammo)+" shells)"

这有点复杂,因为我相信你会同意!字符串格式化可以用多种方式格式化整数、浮点数和字符串。更多信息参见 Python 文档(https://docs.python.org/3.4/library/string.html)。

Tank类中的第二个方法fire_at是,事情变得有趣起来。它带有参数enemy,这是我们想要射击的坦克目标。首先,它检查还有多少ammo剩余。如果至少有一发炮弹,则减少self.ammo1(因为我们刚刚发射了一发炮弹),调用敌方坦克的hit方法。在敌人坦克的hit方法中减少self.armor20。如果没有装甲剩余,那么敌人已经死了,所以我们调用它的explode方法来标记坦克已经死了。

如果这是一个我们正在开发的图形游戏,这些方法会创造一些视觉效果。fire_at将创建一个外壳图像或 3D 模型,并设置其轨迹,explode可能会显示某种令人印象深刻的爆炸动画。但是对于这个小测试游戏,我们将只使用几个print语句来描述当前正在发生的事情。

清单 2-6 完整地展示了Tank类;另存为tank.py。如果你运行这个脚本,它什么也不会做,因为它只是定义了Tank类。我们将使用游戏代码的其余部分创建另一个 Python 脚本。

清单 2-6 。tank.py

class Tank(object):
    def __init__(self, name):
        self.name  = name
        self.alive = True
        self.ammo  = 5
        self.armor = 60

    def __str__(self):
        if self.alive:
            return "%s (%i armor, %i shells)" % (self.name, self.armor, self.ammo)
        else:
            return "%s (DEAD)" % self.name

    def fire_at(self, enemy):
        if self.ammo >= 1:
            self.ammo -= 1
            print(self.name, "fires on", enemy.name)
            enemy.hit()
        else:
            print(self.name, "has no shells!")

    def hit(self):
        self.armor -= 20
        print(self.name, "is hit!")
        if self.armor <= 0:
            self.explode()

    def explode(self):
        self.alive = False
        print(self.name, "explodes!")

实践中的 Python

我们将要创建的游戏与其说是一个游戏,不如说是一个模拟游戏,但它应该足以介绍几个重要的游戏概念。我们将创造一些坦克,让他们互相射击。胜利者只是游戏中剩下的最后一辆坦克。清单 2-7 显示了完成坦克游戏的代码。

清单 2-7 。tankgame.py

from tank import Tank

tanks = {"a":Tank("Alice"), "b":Tank("Bob"), "c":Tank("Carol") }
alive_tanks = len(tanks)

while alive_tanks > 1:

      for tank_name in sorted( tanks.keys() ):
            print(tank_name, tanks[tank_name])

      first = input("Who fires? ").lower()
      second = input("Who at? " ).lower()

      try:
            first_tank = tanks[first]
            second_tank = tanks[second]
      except KeyError as name:
            print("No such tank!", name)
            continue

      if not first_tank.alive or not second_tank.alive:
            print("One of those tanks is dead!")
            continue

      print("*" * 30)

      first_tank.fire_at(second_tank)
      if not second_tank.alive:
          alive_tanks -= 1

      print("*" * 30)

for tank in tanks.values():
      if tank.alive:
         print(tank.name, "is the winner!")
         break

当你第一次(以任何语言)看到任何一段代码时,都会有点害怕。但是一旦你把它分解,你会发现它是由熟悉的东西组成的。所以让我们像一个训练有素的厨师准备河豚一样剖析清单 2-7 !

tankgame.py需要做的第一件事就是导入我们的坦克模块(tank.py),里面包含了Tank类。当一个新的脚本运行时,它只能访问内置的类,比如字符串和列表。如果您想使用另一个没有直接定义的类,您首先必须从另一个 Python 文件中导入它。行from tank import Tank告诉 Python 寻找名为tank(假设为.py)的模块,并在Tank类中读取。另一种方法是做一个简单的import tank,让我们访问tank.py中的所有内容。

Image 注意当你执行from tank import Tank时,它将Tank类(大写 T)导入到当前的名称空间——这意味着你现在可以使用Tank,就像你刚刚将它剪切并粘贴到你的脚本中一样。然而,如果你只是做了import tank,你已经导入了 tank 名称空间,这意味着你必须将Tank类称为tank.Tank,就像在my_tank = tank.Tank("Bob")中一样。有关import语句的详细信息,请参阅本章后面的“导入简介”一节。

接下来我们创建一个名为tanks的字典,它将用于存储我们所有的坦克对象。我们将使用三个,但是如果你喜欢的话,可以随意添加更多的坦克。

tanks = { "a":Tank("Alice"), "b":Tank("Bob"), "c":Tank("Carol") }
alive_tanks = len(tanks)

三个坦克都有字符串"a""b""c"作为关键字,所以我们可以很容易地查找它们。一旦我们创建了自己的坦克,我们就将坦克的数量存储在alive_tanks中,这样我们就可以继续计算游戏中的坦克数量:

while alive_tanks > 1:

这是一个 while 循环的开始,当有一个以上的坦克幸存时,这个循环会继续下去。游戏的核心总是有一个大循环。对于一个视觉游戏,主循环每帧运行一次,以更新和显示视觉效果,但这里的循环代表模拟中的一个回合。

在 while 循环中,我们首先打印一个空行,使每一轮的文本更容易分开。然后,我们有另一个循环,显示每个储罐的一些信息:

print
for tank_name in sorted( tanks.keys() ):
    print(tank_name, tanks[tank_name])

字典的keys方法返回它所包含的键的列表,但是由于字典的性质,这些键不一定按照它们被添加的顺序。因此,当我们得到tanks的键列表时,我们立即将它传递给sorted,这是一个内置函数,返回列表的排序副本。

for 循环中的print语句在tanks字典中查找键,并打印它找到的 tank 对象。记住,打印一个对象调用它的__str__函数来获得一些有用的信息。

接下来我们要求用户提供两个坦克:开火的坦克(first)和被击中的坦克(second):

first  = input("Who fires? ").lower()
second = input("Who at? " ).lower()

内置函数raw_input显示一个提示,并等待用户输入一些文本,然后以字符串的形式返回。在前面的代码中,我们调用返回字符串的lower方法将其转换为小写,因为我们需要一个小写字符串来查找适当的坦克,但是我们不介意用户使用大写字母输入名称。

有了这两个坦克钥匙,我们可以用它们来查找实际的坦克物体。这很简单:我们只需做tanks[first]来取回坦克:

try:
    first_tank  = tanks[first]
    second_tank = tanks[second]
except KeyError as name:
    print("No such tank!", name)
    continue

但是因为用户可以在提示符下输入任何东西,所以我们需要某种方法来处理用户出错或故意破坏游戏的情况!

每当 Python 无法完成要求它做的事情时,它就会抛出一个异常。如果您不做任何事情来处理这些异常,Python 脚本将会退出——这在真实的游戏中将是灾难性的。幸运的是,预测潜在的异常并在发生时处理它们是可能的。如果firstsecondtanks字典中是而不是键,那么当我们试图查找它们时,Python 将抛出一个KeyError异常。这不会使脚本退出,因为我们在一个try:块中查找键,这告诉 Python 代码块可能会抛出异常。如果发生了KeyError,Python 将跳转到except KeyError:下的代码(如果没有异常发生,将被忽略)。

在我们的KeyError异常处理程序中,我们首先显示一条简短的消息,通知用户他们做错了什么,然后继续执行一条continue语句,告诉 Python 忽略这个循环中的其余代码,并跳回到最内层循环的顶部。

if not first_tank.alive or not second_tank.alive:
   print("One of those tanks is dead!")
   continue

这段代码处理一个或两个坦克都死了的情况——因为对一个死了的坦克开火是没有意义的,而且死了的坦克无论如何也不能开火!它只是显示一条消息并执行另一个continue

如果我们已经设法在代码中做到这一点,我们有两个有效的坦克对象:first_tanksecond_tank:

first_tank.fire_at(second_tank)
if not second_tank.alive:
     alive_tanks -= 1

第一辆坦克负责开火,所以我们调用它的fire_at方法,并将第二辆坦克作为敌人传入。如果第二辆坦克被第一辆杀死(armor达到 0),其alive属性将被设置为False。当这种情况发生时,alive_tanks计数减少 1。

最终,在几轮游戏之后,alive_tanks的值将达到 1。当这种情况发生时,主游戏循环将结束,因为它只在alive_tanks大于 1 时循环。

最后一段代码的目的是显示哪辆坦克赢得了比赛:

for tank in tanks.values():

    if tank.alive:
        print(tank.name, "is the winner!")
        break

这是另一个遍历tanks.values()中每个值的循环,是对keys()的补充——它给出了我们所有坦克对象的列表。我们知道只有一个坦克的alive设置为True,所以我们用一个简单的if语句来测试它。一旦我们找到最后剩下的坦克,我们打印一个小消息,然后执行break语句。break语句是continue的伙伴,但是它不是跳到循环的开头,而是跳到结尾并停止循环。

这就是我们的小游戏。现在我第一个承认这不是最令人兴奋的游戏。它不是雷神之锤,但即使是雷神之锤也会做类似的事情。所有的 3D 射手必须记录生命值/护甲和弹药,以及谁还活着。不过,到本书结束时,我们的游戏对象将会以令人惊叹的 3D 方式呈现,而不是一行文本。以下是tankgame.py的输出:

a Alice (60 armor, 5 shells)
b Bob (60 armor, 5 shells)
c Carol (60 armor, 5 shells)
Who fires? a
Who at? b

******************************
Alice fires on Bob
Bob is hit!
******************************

a Alice (60 armor, 4 shells)
b Bob (40 armor, 5 shells)
c Carol (60 armor, 5 shells)
Who fires?

使用标准库

Python 打包了大量的类和函数,被称为标准库。这就是为什么 Python 经常被描述为包含电池,因为你可以利用 Python 专家编写的代码做任何事情,从三角学到下载网页和发送电子邮件。库被组织成模块或包,每个都有特定的用途。你可以通过导入这些模块来使用它们,就像坦克游戏(见清单 2-7 )导入Tank类一样。

当您在 Python 中导入内容时,它会首先在当前目录中查找相应的 Python 文件。如果没有找到,Python 会在标准库中寻找一个模块。

让我们看一下标准库中的几个模块。我们不可能涵盖所有的内容——这本身就需要一本书——但是如果您需要任何模块的更多信息,可以看看 Python 发行版附带的文档,或者在https://docs.python.org/3.4/library/上在线浏览。

引进进口

有几种方法可以从你自己的代码或者标准库中导入东西。使用哪种方法取决于您希望如何访问模块中包含的类和函数。模块可以用关键字import导入,后跟一个模块名。例如,下面的代码行将导入一个名为mymodule的模块:

import mymodule

以这种方式导入模块会创建一个新的名称空间,这意味着您需要在您使用的任何类或函数之前键入模块的名称和一个点。例如,如果mymodule中有一个名为myfunction的函数,您可以这样调用它:

mymodule.myfunction()

这是从标准库中导入模块的通常方式,因为它将每个模块中的东西分开;如果在不同的模块中有另一个名为myfunction的函数,就不会混淆哪个函数被调用。

也可以使用from语句从模块中导入特定的类或函数。下面一行将myclassmymodule导入到当前名称空间:

from mymodule import myclass

如果你只想从模块中得到一些东西,并且你知道它们的名字不会和你的脚本中的任何东西冲突,那么就使用这个方法。您可以通过在每个类、函数之间添加逗号来导入多个类、函数等。所以from mymodule import myclassmyfunction将从mymodule类中导入两个东西。

您可以使用一个*符号来表示您想要将模块中的所有内容导入到当前的名称空间中。例如,下面一行将把所有内容从mymodule导入到当前名称空间:

from mymodule import *

这种导入方法节省了输入时间,因为您不需要模块的名称来引用它——但是只有当模块包含少量内容并且您知道名称不会与脚本中的其他类或函数冲突时,才使用这种方法。对于这种导入来说,math模块是一个很好的选择。

最后,您还可以使用单词作为来导入模块或模块中的函数,以更改相关的名称。例如:

import mymodule as mm

现在,不需要键入 mymodule 来引用该模块,只需键入 mm 即可。您也可以使用从模块导入的函数来实现这一点。

游戏的有用模块

标准库包含了大量的模块,但是你在游戏中只会用到其中的一小部分。让我们来看看一些更常用的模块。

数学模块

当我告诉人们我不太擅长数学时,他们经常感到惊讶。“可你是电脑程序员啊!”他们惊呼。“没错,”我告诉他们。"我让计算机替我做算术。"Python 内置了基础数学;您可以在不导入特殊模块的情况下进行加法、减法和乘法运算。但是你确实需要math模块来实现更高级的功能——那种你可以在科学计算器上找到的东西。其中几个见表 2-5 。

表 2-5 。数学模块中的一些函数

|

功能

|

描述

|

例子

|
| --- | --- | --- |
| sin | 返回一个数的正弦值,以弧度为单位 | sin(angle) |
| cos | 返回一个数字的余弦值,以弧度为单位 | cos(angle) |
| tan | 返回一个数字的正切值,以弧度为单位 | tan(angle) |
| ceil | 返回大于或等于一个数字的最大整数 | ceil(3.4323) |
| fabs | 返回数字的绝对值(不带符号) | fabs(–2.65) |
| floor | 返回小于或等于一个数字的最大整数 | floor(7.234) |
| pi | 圆周率的值 | pi*radius**2 |

给定一个圆的半径,让我们使用math模块来计算它的面积。如果你还记得学校的话,这个公式是圆周率乘以半径的*方,其中圆周率是一个幻数,等于 3.14 左右。幸运的是 Python 对数字的记忆比我好,你可以相信它对圆周率有更精确的表示。这个函数非常简单,我们将使用交互式解释器:

>>> from math import pi
>>> def area_of_circle(radius):
...     return pi*radius**2
...
>>> area_of_circle(5)
78.53981633974483

首先,我们从 math 模块导入 pi。然后,我们定义一个非常简单的函数,它获取半径并返回圆的面积。为了测试它,我们计算一个半径为 5 个单位的圆的面积,结果是 78.5 个单位多一点。

日期时间模块

模块有许多处理日期和时间的函数和类。您可以用它来查询电脑内部时钟的时间,并计算日期之间的时差。这听起来可能是一个简单的任务,因为我们经常对日期进行心算,但当您考虑闰年和时区时,这可能会变得有点复杂!幸运的是,我们可以依靠一些聪明的程序员的工作,让 Python 毫不费力地做到这一点。在datetime模块中有一个同名的类。让我们用它来查找当前时间:

>>> from datetime import datetime
>>> the_time = datetime.now()
>>> the_time.ctime()
'Thu Feb 19 13:04:14 2015'

datetime模块导入datetime类后,我们调用函数now返回一个带有当前时间的datetime对象。函数now就是所谓的静态方法,因为你在一个类上使用它,而不是用那个类创建的一个对象。一旦我们将当前日期和时间存储在the_time中,我们就调用ctime方法,该方法以字符串的形式返回时间的友好表示。显然,当您运行它时,它将返回不同的结果。

那么在游戏中找时间有什么用呢?嗯,你可能想存储一个保存游戏和高分的时间戳。你也可以把游戏和一天中的当前时间联系起来,这样中午的时候会很明亮和阳光灿烂,但是如果你在晚上玩的话,会很黑暗和阴沉。请看一下表 2-6 ,你可以在datetime模块中找到一些东西。

表 2-6 。日期时间模块中的一些类

|

班级

|

描述

|
| --- | --- |
| timedelta | 存储两个时间之间的差异 |
| date | 存储日期值 |
| datetime | 存储日期和时间值 |
| time | 存储时间值 |

随机模块

当您得知random模块用于生成随机数时,您不会感到惊讶,尽管您可能会惊讶地发现它生成的数字并不是真正随机的。这是因为计算机实际上不能随机选择某些东西;在相同的条件下,他们会再次做同样的事情。random生成的数字是伪随机,这意味着它们是从一个很长的数字序列中抽取的,这些数字看起来是随机的,但如果你生成足够多的数字,它们最终会重复出现。幸运的是,你可以在游戏中使用它们,因为没有人会注意到它们重复了几十亿次!

随机(或伪随机数)在游戏中非常有用,可以防止它们变得可预测。如果一个游戏没有随机元素,玩家最终会记住所有的动作序列,使它变得不那么有趣(对大多数人来说)。

让我们写一个简短的脚本来模拟一个标准六面骰子的十次投掷(清单 2-8 )。

清单 2-8 。骰子模拟器

import random
for roll in range(10):
    print(random.randint(1,6))

哇,就三行。所有这些只是导入random模块,然后调用random.randint十次并打印结果。函数randint带两个参数ab,返回一个在ab范围内的伪随机数(可能包括结束值)。所以randint(1, 6)像骰子一样返回 1、2、3、4、5 或 6。

Image 注意你可能已经注意到在清单 2-8 中roll的值实际上从来没有在循环中使用过。对xrange(10)的调用生成了从 0 到 9 的数字,但是我们忽略了它们,因为我们感兴趣的只是重复循环十次。与其为一个从未使用过的值想一个名字,不如用一个下划线代替它。所以清单 2-8 中的循环可能会被重写为for _ in range(10):

虽然清单 2-8 产生的数字看起来是随机的,但它们实际上是伪随机的,这意味着它们是从一个大的数学生成的序列中选择的。有时候,您可能需要重复一系列伪随机数——例如,在播放演示时。您可以通过调用random.seed函数告诉random模块从序列中的特定点开始生成数字。如果你用相同的值调用它两次,它将导致random模块重现相同的数字序列。清单 2-9 展示了如何使用seed函数来创建可预测的序列。

清单 2-9 。一个更好的骰子模拟器

import random
random.seed(100)

for roll in range(10):
    print(random.randint(1, 6))

print("Re-seeded")
random.seed(100)

for roll in range(10):
    print(random.randint(1, 6))

如果您运行这个小脚本,您将看到相同的数字序列,重复两次。查看表 2-7 ,了解random模块的一些功能。

表 2-7 。随机模块中的一些函数

|

功能

|

描述

|
| --- | --- |
| seed | 播种随机数生成器 |
| randint | 返回两个值之间的随机整数 |
| choice | 从集合中选择一个随机元素 |
| random | 返回介于 0 和 1 之间的浮点数 |

摘要

我们已经看到,您可以在 Python 代码中使用布尔逻辑来做出决策。if语句接受一个布尔表达式,比如a > 3,并且仅当该条件导致True时才运行一个代码块。您可以在一个if语句后添加一个或多个else语句,这些语句仅在条件为False时运行它们的代码块。使用andor运算符可以合并逻辑,使用not运算符可以反转逻辑。

函数存储在 Python 代码中,用def语句创建。当您定义函数时,您指定一个参数列表,该列表是函数运行和可选返回值所需的信息列表。许多内置函数可供您使用。

面向对象编程是一个简单概念的花哨术语。它仅仅意味着存储描述某个事物所需的信息以及处理这些信息的大量操作。在游戏中,几乎所有的东西都会被定义为一个对象。Python 类是用class语句定义的,您可以将它视为创建新对象的模板。在一个class语句中创建的函数被称为方法,除了第一个参数是方法应用的对象之外,它与其他函数相似。__init__函数是一个特殊的方法,当一个对象第一次被创建时被调用;您使用它来初始化包含在对象中的信息,或属性

Python 有一个很大的标准库,可以做各种有用的事情。该库被组织成多个模块,其中可以包含类、函数或其他 Python 对象。

在下一章,我们将介绍如何使用 Pygame 模块打开一个窗口并显示图形。`

三、Pygame 简介

你曾经打开过你的电脑,看看里面的情况吗?现在不需要这样做,但是您会发现它是由许多必要的部件构建而成,可以提供您的计算体验。视频卡生成图像并向您的显示器发送信号。声卡将声音混合在一起,并将音频发送到您的扬声器。然后是输入设备,如键盘、鼠标和操纵杆,以及各种其他电子小发明——所有这些在制作游戏中都是必不可少的。

在家用电脑的早期,发型难看、戴着厚框眼镜的程序员不得不掌握电脑的每一个部件。游戏程序员必须阅读每台设备的技术手册,以便编写计算机代码与之通信——所有这些都是在开发实际游戏之前进行的。当制造商推出具有新功能的不同设备和现有设备版本时,情况只会变得更糟。程序员希望支持尽可能多的设备,以便他们的游戏有更大的市场,但他们发现自己陷入了使用这些新显卡和声卡的细节中。对于购买游戏的公众来说,这也是一个痛苦,他们必须仔细检查盒子,看看他们是否有正确的设备组合来使游戏运行。

随着微软视窗等图形操作系统的引入,事情变得简单了一些。他们给了游戏程序员一种与设备通信的方式。这意味着程序员可以扔掉技术手册,因为制造商提供了驱动程序、处理操作系统和硬件之间通信的小程序。

快进到更*的时代,程序员仍然留着糟糕的发型,但戴着更薄的眼镜。游戏程序员的生活仍然不容易。尽管有一种通用的图形、音频和输入交流方式,但由于市场上硬件的多样性,编写游戏仍然很棘手。妈妈在当地超市购买的廉价家用电脑与公司高管购买的顶级电脑大相径庭。正是这种多样性使得初始化硬件并准备好在游戏中使用变得如此困难。幸运的是,现在 Pygame 在这里,我们有了一种创建游戏的方法,而不必担心这些细节(游戏程序员也有时间出去做体面的发型)。

在这一章中,我们将向你介绍 Pygame,并解释如何使用它来创建一个图形显示和读取输入设备的状态。

Pygame 的历史

Pygame 建立在另一个名为简单直接媒体层(SDL) 的游戏创作库之上。《SDL》是 Sam Lantinga 在洛基软件公司(一家现已倒闭的游戏公司)工作时写的,旨在简化游戏从一个*台移植到另一个*台的任务。它提供了一种在多个*台上创建显示以及使用图形和输入设备的通用方法。因为它操作起来非常简单,所以在 1998 年发布时,它非常受游戏开发人员的欢迎,并且已经被用于许多业余爱好和商业游戏。

SDL 是用 C 语言写的,这是一种常用于游戏的语言,因为它的速度快,并且能够在底层与硬件一起工作。但是用 C 或它的后继者 C++ 开发可能会很慢并且容易出错。因此,程序员开发了与他们喜欢的语言的绑定,现在几乎可以在任何语言中使用 SDL。Pygame 就是这样一个绑定,它允许 Python 程序员使用强大的 SDL 库。

Pygame 和 SDL 已经积极开发了许多年,因为它们都是开源的,所以大量的程序员致力于改进和增强这个用于创建游戏的高超工具。

安装 Pygame

安装模块可能是学习 Python 的最大挑战之一。随着时间的推移,Python 的开发者试图让这个过程变得简单一些。安装模块会因你的操作系统而异,但我会尽我所能来说明主要模块的安装方法:Windows,Mac,Linux。

您首先要做的是解决 Python 安装的位版本问题。默认是 32 位,但您可能已经获得了 64 位安装。要找出你有什么,打开空闲,并阅读最上面的文字。下面是一个在 Windows 操作系统上安装 32 位 Python 的示例:

Python 3.4.3 (v3.4.3:9b73f1c3e601, Feb 24 2015, 22:43:06) [MSC v.1600 32 bit (Intel)] on win32
Type "copyright", "credits" or "license()" for more information.

注意哪里写着 32 位(Intel);这意味着我使用的是 32 位版本的 Python。这意味着我所有的模块必须是 32 位模块。如果您使用的是 64 位版本的 Python,那么您的所有模块都必须是 64 位的(尽管您可能有所期待)。然而,在 64 位版本的 Windows 上运行 32 位版本的 Python 是可能的。

因此,假设您使用 Python 的默认安装,即 32 位,那么您现在正在寻找 Pygame 的 32 位版本。安装 Pygame 最简单的方法是打开 bash、您的终端或命令行,键入:

pip install pygame

就这么简单!你在这里可能不成功。您可能会遇到如下错误:

'pip' is not recognized as an internal or external command, operable program or batch file

如果是这样,那么尝试直接引用 pip 脚本。pip 脚本实际上是包含在 Python 安装中的 pip.py 文件。找到您的 Python 安装目录,在其中查找“脚本”目录,然后您应该会看到其中的 pip.py 文件。记下这个完整路径,并使用它。例如,如果您在 Windows 上,您应该键入:

C:/Python34/Scripts/pip install pygame

在 Windows 或 Linux 上,您应该可以成功完成前面的尝试。

对于 Mac 用户来说,事情可能没有这么简单。历史上,Mac OS 在使用各种形式的 Python GUI 相关模块时遇到了很多麻烦。这是一个正在进行的活动,所以你最好的选择是查看来自http://pygame.org/wiki/macintosh的最新安装说明。

自从我上次在那里看了以后,说明已经改变了,所以它们可能会随着时间的推移而改变。

如果你使用的是 Windows 系统,你可能会发现一切都还存在问题。一种相对较新的安装模块的方法是使用扩展名为. whl 的 Wheel 文件,这种方法可能会更加流行。

要安装这些,您只需下载。whl 文件,然后使用 pip 通过引用。whl 文件作为您想要安装的文件。例如,您可以下载一个名为 py game-1 . 9 . 2 A0-cp34-none-win32 . whl 的. whl 文件。要安装该文件,假设您已将该文件下载到下载目录中,您可以键入如下内容:

pip install C:/Users/H/Downloads/pygame-1.9.2a0-cp34-none-win32.whl

同样,您可能需要给出 pip 脚本的完整路径。

使用 Pygame

Pygame 包包含许多可以独立使用的模块。你可能在游戏中使用的每一个设备都有一个模块,还有许多其他模块可以让游戏创作变得轻而易举。所有 Pygame 模块见表 3-1 。您通过pygame名称空间访问这些模块;例如,pygame.display指的是display模块。

表 3-1 。Pygame 包中的模块

|

模块名

|

目的

|
| --- | --- |
| pygame.cdrom | 访问和控制 CD 驱动器 |
| pygame.cursors | 加载光标图像 |
| pygame.display | 访问显示屏 |
| pygame.draw | 绘制形状、线条和点 |
| pygame.event | 经理外部事件 |
| pygame.font | 使用系统字体 |
| pygame.image | 加载并保存图像 |
| pygame.joystick | 使用操纵杆和类似设备 |
| pygame.key | 从键盘上读取按键 |
| pygame.mixer | 加载并播放声音 |
| pygame.mouse | 管理鼠标 |
| pygame.movie | 播放电影文件 |
| pygame.music | 处理音乐和流式音频 |
| pygame.overlay | 访问高级视频叠加 pygame |
|   | 包含高级 Pygame 函数 |
| pygame.rect | 管理矩形区域 |
| pygame.sndarray | 处理声音数据 |
| pygame.sprite | 管理运动图像 |
| pygame.surface | 管理图像和屏幕 |
| pygame.surfarray | 处理图像像素数据 |
| pygame.time | 管理时序和帧速率 |
| pygame.transform | 调整大小和移动图像 |

*有关 Pygame 模块的完整文档,请参见 http://pygame.org/docs/

一些你会在每个游戏中用到的模块。你总是会有某种显示器,所以display模块是必不可少的,你肯定会需要某种输入,无论是键盘、操纵杆还是鼠标。其他模块不太常用,但结合起来,它们给你一个最强大的游戏创作工具。

并不是表 3-1 中的所有模块都能保证出现在每个*台上。运行游戏的硬件可能不具备某些功能,或者没有安装所需的驱动程序。如果是这种情况,Pygame 会将模块设置为None,这样便于测试。下面的代码片段检测pygame.font模块是否可用,如果不可用就退出:

if pygame.font is None:
   print("The font module is not available!")
   exit()

你好世界重访

正如我在第一章中提到的,学习新语言时有一个传统,你写的第一个代码会显示“你好,世界!”在屏幕上。从技术上来说,我们已经用一个print 'Hello, World!'语句做到了这一点——但这有点令人失望,因为作为游戏程序员,我们感兴趣的是创造吸引人的视觉效果,而一行文本根本做不到!我们将使用 Pygame 创建一个 Hello World 脚本,它会在您的桌面上打开一个图形窗口,并在标准鼠标光标下绘制一个图像。运行时,你会看到一个类似于图 3-1 中所示的窗口。

9781484209714_Fig03-01.jpg

图 3-1 。Pygame 中的 Hello World

代码见清单 3-1 。如果你愿意,现在就运行它;我们将在本章中一步一步地介绍它。

清单 3-1 。Hello World Redux (helloworld.py)

#!/usr/bin/env python

background_image_filename = 'sushiplate.jpg'
mouse_image_filename = 'fugu.png'

import pygame
from pygame.locals import *
from sys import exit

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)
pygame.display.set_caption("Hello, World!")

background = pygame.image.load(background_image_filename).convert()
mouse_cursor = pygame.image.load(mouse_image_filename).convert_alpha()

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

        screen.blit(background, (0,0))

        x, y = pygame.mouse.get_pos()
        x-= mouse_cursor.get_width() / 2
        y-= mouse_cursor.get_height() / 2
        screen.blit(mouse_cursor, (x, y))

        pygame.display.update()

我们需要两张清单 3-1 的图片:一张作为背景,另一张作为鼠标光标。您可以从 Apress 网站的源代码/下载部分下载这个示例和其他示例的文件。如果你现在不能上网,你可以使用你硬盘上的图像文件,或者用任何图形或照片编辑软件制作。任何图像都可以作为背景,只要它的尺寸至少是 640×480(再大一些,超出的部分就会被剪掉)。对于鼠标光标,你将需要一个更小的图像,以适应舒适的背景;合适的尺寸是 80 乘 80。继续第一章的河豚主题,官方背景将是一幅碗筷的图片,和一幅非常原始的河豚图片供鼠标光标使用。前两行设置图像的文件名;如果您使用不同的图像,您应该用图像的位置替换文件名。让我们把这个脚本分成小块。在脚本的顶部,我们导入了运行示例时需要的外部模块、类、函数等:

import pygame
from pygame.locals import *
from sys import exit

第一行导入了pygame包,让我们可以访问它的所有子模块,比如pygame.imagepygame.sound。第二行将一些函数和常量(不变的值)导入顶级名称空间。为了使用 Pygame,这样做并不是必须的,但是这样做很方便,因为我们不必在常用值前面加上pygame名称空间。最后一个import语句从sys(标准库中的一个模块)导入一个函数。你可能已经猜到了,exit的目的是立即完成脚本。调用它会导致 Pygame 窗口消失,Python 关闭。当用户点击关闭按钮时,脚本调用exit;否则,用户将无法关闭窗口!

Image 提示如果你遇到无法关闭 Pygame 窗口的情况,你也许可以通过按 Ctrl+C 来停止 Python 的运行

这一行相当简单的 Python 代码实际上做了很多工作:

pygame.init()

它会初始化pygame包中的每个子模块,这可能会加载驱动程序并查询硬件,以便 Pygame 准备好使用您计算机上的所有设备。您可以通过单独调用每个子模块中的init函数来仅初始化您想要使用的模块;例如,pygame.sound.init()会初始化声音模块。这可以使脚本启动得更快一点,因为只有您实际使用的模块才会被初始化。对于游戏来说,你将需要大部分的模块,如果不是全部的话——所以我们将坚持使用这个包罗万象的初始化函数。在我们调用它之后,我们就拥有了 Pygame 的全部力量!

在初始化 Pygame 之后,我们需要创建一个显示表面:

screen = pygame.display.set_mode((640, 480), 0, 32)
pygame.display.set_caption("Hello, World!")

显示可以是你桌面上的一个窗口,也可以是整个屏幕,但是你总是通过 Pygame Surface 对象来访问它。我们脚本中对 pygame.display.set_mode 的调用返回表示桌面上窗口的表面对象。它需要三个参数:只有第一个是必需的,它应该是一个包含我们想要创建的显示的宽度和高度的元组。我们的窗口将是 640 x 480 像素,这足够大,所以我们可以看到正在发生的事情,但也不会大到遮住太多的桌面。我们给 set_mode 的下一个参数是一个包含显示创建中使用的标志的值。标志是可以打开或关闭的特征;您可以用按位 OR 运算符(|)组合几个标志。例如,要创建双缓冲硬件表面,请将 flags 参数设置为 DOUBLEBUF|HWSURFACE。您可以使用的旗帜见表 3-2 。我将在本章后面的“打开显示器”部分更详细地介绍它们。对于第一个 Pygame 脚本,我们不会启用任何标志,所以我们给标志的值只是 0,这也是默认值。

表 3-2 。pygame.display.set_mode 的标志

|

|

目的

|
| --- | --- |
| FULLSCREEN | 创建一个充满整个屏幕的显示。 |
| DOUBLEBUF | 创建“双缓冲”显示。推荐给HWSURFACEOPENGL。 |
| HWSURFACE | 创建硬件加速显示(必须与FULLSCREEN标志结合使用)。 |
| OPENGL | 创建 OpenGL 可渲染显示。 |
| RESIZABLE | 创建可调整大小的显示。 |
| NOFRAME | 从显示中删除边框和标题栏。 |

下一个参数指定显示表面的深度,,这是用于在显示器中存储颜色的的数量。一位,或二进制数字是计算机中最基本的存储单位。位正好有两个潜在值,1 或 0,在内存中以 8 位为一组排列。一组 8 位称为一个字节。如果这对你来说听起来像是技术术语,不要担心;Python 倾向于对程序员隐藏这种事情。我们将使用值 32 作为我们的位深度,因为它给了我们最多的颜色;其他潜在的位深度值见表 3-3 。如果你不提供深度值或者设置为 0,Pygame 将使用你桌面的深度。

表 3-3 。位深度值

|

比特深度

|

颜色数量

|
| --- | --- |
| 8 位 | 256 种颜色,从更大的调色板中选择 |
| 15 位 | 32,768 种颜色,带备用位 |
| 16 位 | 65,536 种颜色 |
| 24 位 | 1670 万色 |
| 32 位 | 1670 万色,带备用 8 位 |

*也可能有其他位深度,但这些是最常见的。

Image 有时 Pygame 无法给我们所要求的精确显示。可能是显卡不支持我们要求的功能。幸运的是,Pygame 将选择与硬件兼容的显示器,并模拟我们实际要求的显示器。谢谢你,Pygame!

如果一切顺利,对set_mode的调用将在你的桌面上显示一个 Pygame 窗口,并返回一个Surface对象,该对象随后被存储在变量screen中。我们对新创建的 surface 做的第一件事是调用display模块中的set_caption来设置 Pygame 窗口的标题栏。我们将标题设置为“你好,世界!”—只是为了使它成为一个有效的 Hello World 脚本!

接下来,我们使用pygame.image中的load函数来加载背景和鼠标光标的两幅图像。我们传入脚本开始时存储的图像的文件名:

background   = pygame.image.load(background_image_filename).convert()
mouse_cursor = pygame.image.load(mouse_image_filename).convert_alpha()

load函数从硬盘读取一个文件,并返回一个包含图像数据的表面。这些是与我们的显示器相同类型的对象,但是它们代表存储在内存中的图像,并且在我们将它们绘制到主显示器之前是不可见的。对pygame.image.load的第一次调用读入背景图像,然后立即调用convert,它是Surface对象的成员函数。该函数将图像转换为与我们的显示器相同的格式,因为如果显示器具有相同的深度,绘制图像会更快。鼠标光标以类似的方式加载,但是我们调用convert_alpha而不是convert。这是因为我们的鼠标光标图像包含了 alpha 信息,这意味着图像的一部分可以是半透明的或者完全不可见的。如果鼠标图像中没有 alpha 信息,我们只能使用难看的正方形或长方形作为鼠标光标!下一章将更详细地介绍 alpha 和图像格式。

脚本中的下一行直接跳到主游戏循环:

while True:

这个 while 循环将True作为条件,这意味着它将继续循环,直到我们break退出它,或者以其他方式强制它退出。所有游戏都会有一个类似的循环,通常每次屏幕刷新都会重复一次。

在主游戏循环中,我们有另一个循环——事件循环,大多数游戏也会有这样或那样的形式:

for event in pygame.event.get():
    if event.type == QUIT:
        pygame.quit()
        exit()

事件是 Pygame 通知你在你的代码之外发生了什么的方式。事件是为许多事情而创建的,从按键到从互联网接收信息,并且在您处理它们之前一直在为您排队。pygame.event 模块中的函数 get 返回等待我们的任何事件,然后我们在 for 循环中遍历这些事件。对于这个脚本,我们只对退出事件感兴趣,当用户单击 Pygame 窗口中的关闭按钮时,由 Pygame 生成该事件。因此,如果事件类型是 QUIT,我们调用 exit 来关闭,所有其他事件都被忽略。当然,在一个游戏中,我们必须处理更多的事件。为了完全关闭 pygame,调用 pygame.quit()和 exit()非常重要。Pygame.quit()取消 Pygame 的初始化。

下一行将背景图像拼接到屏幕上(拼接表示从一个图像复制到另一个图像):

screen.blit(background, (0,0))

这一行使用 screen Surface对象的blit成员函数,它接受一个源图像——在本例中是我们的 640 x 480 背景——和一个包含目标位置的元组。背景从不移动;我们只是希望它覆盖整个 Pygame 窗口,所以我们 blit 到坐标(0,0),这是屏幕的左上角。

Image 提示重要的是,你可以轻松地浏览屏幕的每个部分。如果你不这样做,当你制作动画时可能会出现奇怪的视觉效果,并且你的游戏在每台运行它的计算机上可能看起来不一样。尝试注释掉对screen.blit的调用,看看会发生什么。

在我们绘制背景之后,我们想要在通常的鼠标指针下面绘制mouse_cursor:

x, y = pygame.mouse.get_pos()
x -= mouse_cursor.get_width()/2
y -= mouse_cursor.get_height()/2
screen.blit(mouse_cursor, (x, y))

获得鼠标的位置很好也很简单;pygame.mouse模块包含了我们使用鼠标所需的一切,包括get_pos,它返回一个包含鼠标坐标的元组。为了方便起见,第一行将这个元组解包为两个值:xy。当我们 blit 鼠标光标时,我们可以使用这两个值作为坐标,但是这样会把图像的左上角放在鼠标下面,我们希望图像的中心在鼠标下面。所以我们做了一点计算(不要害怕!)调整xy,使鼠标图像向上移动一半高度,向左移动一半宽度。使用这些坐标将图像的中心放在鼠标指针的正下方,这样看起来更好。至少对于鱼的图像是这样的——如果您想使用更典型的指针图像,请调整坐标,使指针位于实际鼠标坐标的下方。

对鼠标图像进行位图化和对背景进行位图化的方法是一样的,但是我们使用的是我们计算的坐标而不是(0,0)。这足以产生我们想要的效果,但是在我们看到任何东西之前,我们还必须做一件事:

pygame.display.update()

当你通过 blits 构建一个图像到屏幕表面时,你不会马上看到它们。这是因为 Pygame 首先将图像构建到一个后台缓冲区,这是一个在显示之前在内存中不可见的显示。如果我们没有这一步,用户将会看到单独的 blits,这将是最不愉快的闪烁。对于游戏程序员来说,闪烁是敌人!我们希望看到如丝般光滑,令人信服的动画。幸运的是,我们只需要调用pygame.display.update()就可以确保我们在内存中创建的图像没有闪烁地显示给用户。

当您运行这个脚本时,您应该会看到类似于图 3-1 的内容。如果你使用的是“官方”图片,那么一条长相奇怪的鱼会忠实地跟随鼠标光标。

了解事件

在 Hello World 中,我们只处理了QUIT事件,这是必不可少的,除非你想拥有不朽的 Pygame 窗口!Pygame 创建其他事件来通知你诸如鼠标移动和按键之类的事情。

事件可以在任何时候生成,不管你的程序正在做什么。例如,当用户按下游戏手柄上的 fire 按钮时,您的代码可能会在屏幕上绘制一辆坦克。因为您不能在事件发生时立即做出反应,Pygame 将它们存储在一个队列中,直到您准备好处理它们(通常在主游戏循环的开始)。您可以将事件队列想象成一排等待进入大楼的人,每个人都携带关于某个事件的特定信息。当玩家按下 fire 按钮时,joystick事件到达,携带关于哪个键被按下的信息。类似地,当玩家释放 fire 按钮时,同一个joystick事件的克隆会出现,并带有关于被释放按钮的信息。接下来可能会发生mouse事件和key事件。

检索事件

在前面的例子中,我们调用了pygame.event.get() 来检索所有的事件并将它们从队列中删除,这就像打开门让所有人进来一样。这可能是处理事件的最佳方式,因为它确保我们在继续向屏幕绘制内容之前已经处理了所有的事情——但是还有其他方式来处理事件队列。如果调用pygame.event.wait(),Pygame 会等待一个事件发生后再返回,这就像在门口等着,直到有人来。这个函数不常用于游戏,因为它会暂停脚本,直到有事情发生,但是对于与系统上的其他程序(比如媒体播放器)协作更多的 Pygame 应用来说,这个函数会很有用。另一种方法是pygame.event.poll(),如果有一个等待,它将返回一个事件,如果队列中没有事件,它将返回一个类型为NOEVENT的虚拟事件。无论使用哪种方法,重要的是不要让它们堆积起来,因为事件队列的大小是有限的,如果队列溢出,事件将会丢失。

有必要定期调用至少一个事件处理函数,以便 Pygame 可以在内部处理事件。如果不使用任何事件处理函数,可以调用pygame.event.pump()来代替事件循环。

事件对象包含一些描述发生的事件的成员变量。它们包含的信息因事件而异。所有事件对象的唯一共同点是type,它是一个指示事件类型的值。您首先查询的就是这个值,这样您就可以决定如何处理它。表 3-4 列出了您可能收到的标准事件;我们将在本章中讨论其中的一些。

表 3-4 。标准事件

|

事件

|

目的

|

因素

|
| --- | --- | --- |
| QUIT | 用户已单击关闭按钮。 | none |
| ACTIVEEVENT | Pygame 已被激活或隐藏。 | gain, state |
| KEYDOWN | 键已被按下。 | unicode, key, mod |
| KEYUP | 钥匙已被释放。 | key, mod |
| MOUSEMOTION | 鼠标已被移动。 | pos, rel, buttons |
| MOUSEBUTTONDOWN | 鼠标按钮被按下。 | pos, button |
| MOUSEBUTTONUP | 释放了鼠标按钮。 | pos, button |
| JOYAXISMOTION | 操纵杆或手柄被移动。 | joy, axis, value |
| JOYBALLMOTION | 欢乐球被感动了。 | joy, ball, rel |
| JOYHATMOTION | 操纵杆帽被移动了。 | joy, hat, value |
| JOYBUTTONDOWN | 操纵杆或键盘按钮被按下。 | joy, button |
| JOYBUTTONUP | 操纵杆或键盘按钮被释放。 | joy, button |
| VIDEORESIZE | Pygame 窗口已调整大小。 | size, w, h |
| VIDEOEXPOSE | 部分或全部 Pygame 窗口暴露。 | none |
| USEREVENT | 发生了用户事件。 | code |

让我们编写一个简单的 Pygame 脚本来显示所有生成的事件。清单 3-2 使用pygame.event.wait()来等待单个事件。一旦得到一个,它就用str把它转换成一个字符串,并把它添加到一个列表中。代码的其余部分显示新事件以及尽可能多的先前事件,以适合屏幕。它使用font模块来显示文本(我们将在后面讨论)。

Image 提示如果将清单 3-2 中的填充颜色改为(0,0,0),字体颜色改为(0,255,0),看起来会有点像矩阵风格的代码。你可能需要发挥一下你的想象力!

清单 3-2 。显示消息队列导入 pygame

from pygame.locals import *
from sys import exit

pygame.init()
SCREEN_SIZE = (800, 600)
screen = pygame.display.set_mode(SCREEN_SIZE, 0, 32)

while True:

    for event in pygame.event.get():

        if event.type == QUIT:
            pygame.quit()
            exit()

        print(event)

    pygame.display.update()

如果你运行清单 3-2 中的,你会看到一个简单的黑色窗口。如果你在窗口上移动鼠标,你将开始看到控制台的打印输出(参见图 3-2 )。这些事件指定鼠标的当前位置、自上次运动事件以来鼠标移动了多远,以及当前按下了哪些按钮。你可以用 pygame.mouse 模块获得鼠标的当前位置,就像我们在 Hello World 的例子中所做的那样,但是你冒着丢失玩家一直在做的事情的信息的风险。这对于在后台做大量工作的台式电脑来说是一个特别的问题,可能偶尔会让你的游戏暂停一小段时间。对于一个鼠标光标,你只需要知道鼠标在每一帧开始的位置,那么使用 pygame.mouse.get_pos()是合理的。如果你使用鼠标移动来驱动坦克和按钮来开火,那么最好使用事件,这样游戏就可以更密切地监控玩家在做什么。

9781484209714_Fig03-02.jpg

图 3-2 。事件脚本的输出

处理鼠标运动事件

正如您所看到的,每当您将鼠标移动到 Pygame 窗口上时,MOUSEMOTION事件就会发出。它们包含这三个值:

  • buttons—A tuple of three numbers that correspond to the buttons on the mouse.

    所以buttons[0]是鼠标左键,buttons[1]是中键(像鼠标滚轮一样),而buttons[2]是右键。如果按钮被按下,那么它的值被设置为 1;如果未按下,该值将为 0。可以同时按下多个按钮。

  • pos— 包含事件生成时鼠标位置的元组。

  • rel— 一个元组,包含自上次鼠标运动事件以来鼠标移动的距离(有时称为鼠标 mickies )。

处理鼠标按钮事件

除了运动事件,鼠标还产生MOUSEBUTTONDOWNMOUSEBUTTONUP事件。如果您在消息队列脚本上单击鼠标,您将首先看到向下事件,然后当您将手指从按钮上移开时,会看到向上事件。那么为什么会有这两个事件呢?如果您使用鼠标按钮作为发射火箭的触发器,您将只需要其中一个事件,但您可能有不同类型的武器,例如在按住按钮时连续发射的连锁枪。在这种情况下,您将在下降事件时启动链枪加速,并使其开火,直到您获得相应的上升事件。两种类型的鼠标按钮事件都包含以下两个值:

  • 按钮— 被按下的按钮的号码。值 1 表示按下了鼠标左键,2 表示按下了中键,3 表示按下了右键。
  • pos— 包含事件生成时鼠标位置的元组。

处理键盘事件

键盘和操纵杆有相似的向上和向下事件;按下按键时发出KEYDOWN,松开按键时发出KEYUP。 清单 3-3 演示了如何响应KEYUPKEYDOWN事件,用光标键移动屏幕上的东西。如果运行这个清单,您将看到一个包含简单背景图像的窗口。按向上、向下、向左或向右,背景将向那个方向滑动。将手指从光标键上拿开,背景将停止移动。

清单 3-3 。使用键盘事件移动背景

import pygame
from pygame.locals import *
from sys import exit

background_image_filename = 'sushiplate.jpg'
pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)
background = pygame.image.load(background_image_filename).convert()

x, y = 0, 0
move_x, move_y = 0, 0

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

        if event.type == KEYDOWN:
            if event.key == K_LEFT:
                move_x = -1
            elif event.key == K_RIGHT:
                move_x = +1
            elif event.key == K_UP:
                move_y = -1
            elif event.key == K_DOWN:
                move_y = +1

        elif event.type == KEYUP:
            if event.key == K_LEFT:
                move_x = 0
            elif event.key == K_RIGHT:
                move_x = 0
            elif event.key == K_UP:
                move_y = 0
            elif event.key == K_DOWN:
                move_y = 0

        x+= move_x
        y+= move_y

        screen.fill((0, 0, 0))
        screen.blit(background, (x, y))

        pygame.display.update()

清单 3-3 开头就像 Hello World 它导入并初始化 Pygame,然后加载一个背景图片。这个脚本中的事件循环是不同的,因为它处理KEYDOWNKEYUP。这些关键事件都包含相同的三个值:

  • 键— 这是一个代表被按下或释放的键的数字。键盘上的每个物理键都有一个以K_开头的常数。字母键是从K_aK_z,但是其他键也有常量,比如K_SPACEK_RETURN。有关您可以使用的关键常量的完整列表,请参见www.pygame.org/docs/ref/key.html
  • mod— 该值表示与其他键组合使用的键,如 Shift、Alt 和 Ctrl。这些修饰键中的每一个都由一个以KMOD_开头的常数表示,比如KMOD_SHIFTKMOD_ALTKMOD_CTRL。使用按位 AND 运算符检查这些值。例如,如果按下 Ctrl 键,mod & KMOD_CTRL将计算为Truewww.pygame.org/docs/ref/key.html提供了修饰键的完整列表。
  • unicode— 这是被按下的键的 Unicode 值。它是通过将被按下的键与被按下的任何修饰键组合而产生的。英语字母表和其他语言中的每个符号都有一个 Unicode 值。你不会经常在游戏中使用这个值,因为按键更像是开关而不是输入文本。一个例外是输入一个高分表,您希望玩家能够输入非英文字母以及混合大写和小写字母。

KEYDOWN的处理程序中,我们检查对应于光标键的四个键常量。如果按下K_LEFT,则move_x的值被设置为–1;如果按下K_RIGHT,则设置为+1。这个值随后被添加到背景的 x 坐标上,以便将其向左或向右移动。还有一个move_y值,如果按下K_UPK_DOWN则设置该值,这将垂直移动背景。

我们还处理了KEYUP事件,因为我们希望当用户释放光标键时背景停止移动。KEYUP事件处理程序中的代码类似于 down 事件,但是它将move_xmove_y设置回零以阻止背景移动。

事件循环之后,我们要做的就是将值move_xmove_y加到xy上,然后在(x,y)处绘制背景。你之前唯一没见过的是screen.fill((0, 0, 0)),它用来把显示屏清成黑色(颜色在第四章的中解释)。这条线是必要的,因为如果我们移动背景图像,它就不再覆盖整个显示——我猜想从技术上讲,这意味着它不再是背景!

过滤事件

并不是所有的事件都需要在每个游戏中处理,而且通常有其他方法可以获得事件可能给你的信息。例如,如果您正在使用pygame.mouse.get_pos(),那么您不需要响应MOUSEMOTION事件。

有时,您还需要暂停某些事件的处理。如果你要在关卡之间播放过场动画,你可能会忽略输入事件,直到它结束。Pygame 事件模块有许多函数可以帮助你做到这一点。

您可以使用set_block功能阻止事件队列中的事件。例如,下面一行将禁止鼠标移动:

pygame.event.set_blocked(MOUSEMOTION)

如果您传入一个事件类型列表,所有这些事件都将被阻止。例如,下面的行将通过阻止KEYDOWNKEYUP事件来禁用所有键盘输入:

pygame.event.set_blocked([KEYDOWN, KEYUP])

如果你想解锁所有事件,将None的值传递给set_blocked。该行将允许事件队列中的所有事件发生:

pygame.event.set_blocked(None)

set_blocked相对的是set_allowed,它选择应该被允许(解除阻止)的事件。它还接受单个事件类型或事件类型列表。但是如果你传入None的值,它会有效地阻止所有的事件。你可以用pygame.event.get_block询问 Pygame 某个事件当前是否被阻止,它采用单一事件类型。

发布事件

通常是 Pygame 为你创建所有的事件,但是你也可以创建你自己的事件。您可以使用这种能力来回放演示(通过复制玩家的输入),或者模拟猫走过键盘的效果(我喜欢让我的游戏防猫)。

要发送一个事件,首先用pygame.event.Event构造一个事件对象,然后用pygame.event.post发布它。事件将被放在队列的末尾,准备在事件循环中检索。下面是如何模拟玩家按空格键:

my_event = pygame.event.Event(KEYDOWN, key=K_SPACE, mod=0, unicode=u' ')
pgame.event.post(my_event)

Event构造函数接受事件的类型,比如表 3-4 中的一个事件,后面是事件应该包含的值。因为我们正在模拟KEYDOWN事件,所以我们需要提供事件处理程序期望出现的所有值。如果您愿意,可以将这些值作为字典提供。这一行创建相同的事件对象:

my_event = pygame.event.Event(KEYDOWN, {"key":K_SPACE, "mod":0, "unicode":u' '})

除了模拟 Pygame 生成的事件,您还可以创建全新的事件。您所要做的就是为事件使用一个大于USEREVENT的值,这是 Pygame 将为自己的事件 id 使用的最大值。如果您想在继续绘制到屏幕之前在事件循环中做一些事情,这有时会很有用。下面是一个用户事件响应猫走过键盘的例子:

CATONKEYBOARD = USEREVENT+1
my_event = pygame.event.Event(CATONKEYBOARD, message="Bad cat!")
pgame.event.post(my_event)

处理用户事件的方式与 Pygame 生成的普通事件相同——只需检查事件类型,看它是否与您的自定义事件匹配。以下是您处理CATONKEYBOARD事件的方式:

for event in pygame.event.get():
    if event.type == CATONKEYBOARD:
        print(event.message)

打开显示器

在 Hello World 示例中,我故意忽略了打开一个显示器,因为我们只需要一个简单的显示器,但是 Pygame 有多种显示器选项。您创建的显示类型取决于游戏。使用固定分辨率(显示尺寸)通常更容易,因为它可以简化您的代码。你的决定还取决于你在游戏中有多少动作——你在屏幕上一次移动的东西越多,游戏运行得越慢。您可能需要选择较低的分辨率来进行补偿(这将再次加快速度)。

最好的解决方案通常是让玩家决定他们想要运行的分辨率,以便他们可以调整显示,直到他们在视觉质量和游戏运行的流畅程度之间取得良好的妥协。如果你走这条路,你必须确保你的游戏在所有可能的分辨率下看起来都没问题!

在编写游戏之前,不要担心这个问题。在您尝试 Pygame 脚本时,只需选择一个适合您的分辨率,但也可以随意尝试一下。

全屏显示

在 Hello World 中,我们使用以下代码行创建了一个 Pygame 窗口:

screen = pygame.display.set_mode((640, 480), 0, 32)

第一个参数是我们想要创建的窗口的大小。大小为(640,480)会创建一个适合大多数桌面的小窗口,但是如果您愿意,您可以选择不同的大小。在窗口中运行对于调试来说是非常好的,但是大多数游戏用动作填满了整个屏幕,没有通常的边框和标题栏。全屏模式通常更快,因为你的 Pygame 脚本不必与你桌面上的其他窗口配合。要设置全屏模式,使用set_mode的第二个参数的FULLSCREEN标志:

screen = pygame.display.set_mode((640, 480), FULLSCREEN, 32)

Image 注意如果你的脚本在全屏模式下出了问题,有时很难回到你的桌面。所以最好先在窗口模式下测试。您还应该提供退出脚本的替代方法,因为关闭按钮在全屏模式下是不可见的。

当您进入全屏模式时,您的视频卡可能会切换到不同的视频模式,这将改变显示器的宽度和高度,并可能改变它一次可以显示多少种颜色。显卡只支持几种大小和颜色数量的组合,但是如果你尝试选择一种显卡不直接支持的视频模式,Pygame 会帮你。如果不支持您要求的显示器尺寸,Pygame 将选择下一个尺寸并将您的显示器复制到它的中心,这可能会导致显示器的顶部和底部出现黑色边框。要避免这些边框,请选择几乎所有显卡都支持的标准分辨率之一:(640、480)、(800、600)或(1024、768)。要查看您的显示器支持哪些分辨率,您可以使用pygame.display.list_modes(),它会返回包含支持的分辨率的元组列表。让我们从交互式解释器中尝试一下:

>>> import pygame
>>> pygame.init()
(6, 0)
>>> pygame.display.list_modes()
[(1920, 1080), (1680, 1050), (1600, 1024), (1600, 900), (1366, 768), (1360, 768), (1280, 1024), (1280, 960), (1280, 800), (1280, 768), (1280, 720), (1152, 864), (1024, 768), (800, 600), (720, 576), (720, 480), (640, 480)]

Image 注意注意pygame.init()是如何返回一个元组的(6,0). pygame.init()返回一个元组,该元组包含成功初始化的次数,后跟失败初始化的次数。

如果显卡无法提供您要求的颜色数量,Pygame 将自动转换显示面中的颜色以适应(这可能会导致图像质量略有下降)。

清单 3-4 是一个演示从窗口模式到全屏模式的简短脚本。如果您按下 F 键,显示屏将会填满整个屏幕(发生这种情况时,可能会有几秒钟的延迟)。第二次按 F,显示屏将返回到一个窗口。

清单 3-4 。全屏示例

import pygame
from pygame.locals import *
from sys import exit

background_image_filename = 'sushiplate.jpg'

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)
background = pygame.image.load(background_image_filename).convert()

Fullscreen = False

while True:

     for event in pygame.event.get():
         if event.type == QUIT:
             pygame.quit()
             exit()
     if event.type == KEYDOWN:
        if event.key == K_f:
            Fullscreen = not Fullscreen
            if Fullscreen:
                 screen = pygame.display.set_mode((640, 480), FULLSCREEN, 32)
            else:
                 screen = pygame.display.set_mode((640, 480), 0, 32)

     screen.blit(background, (0,0))
     pygame.display.update()

Resizable Pygame Windows

有时你可能希望用户能够调整 Pygame 窗口的大小,这通常是通过点击窗口的一角并用鼠标拖动来实现的。当您调用set_mode时,通过使用RESIZABLE标志很容易做到这一点。Pygame 通过发送一个包含新的窗口宽度和高度的VIDEORESIZE事件来通知你的代码用户是否改变了窗口大小。当您得到这些事件之一时,您应该再次调用pygame.display.set_mode来将显示设置为新的尺寸。清单 3-5 展示了如何响应VIDEORESIZE事件。

清单 3-5 。使用可调整大小的窗口

import pygame
from pygame.locals import *
from sys import exit

background_image_filename = 'sushiplate.jpg'

SCREEN_SIZE = (640, 480)

pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE, RESIZABLE, 32)

background = pygame.image.load(background_image_filename).convert()

while True:

     event = pygame.event.wait()
     if event.type == QUIT:
          pygame.quit()
          exit()
     if event.type == VIDEORESIZE:
          SCREEN_SIZE = event.size
          screen = pygame.display.set_mode(SCREEN_SIZE, RESIZABLE, 32)
          pygame.display.set_caption("Window resized to "+str(event.size))

     screen_width, screen_height = SCREEN_SIZE
     for y in range(0, screen_height, background.get_height()):
        for x in range(0, screen_width, background.get_width()):
            screen.blit(background, (x, y))

     pygame.display.update()

当您运行这个脚本时,它将显示一个简单的 Pygame 窗口和一个背景图片。如果你点击窗口的角或者边,用鼠标拖动,脚本会得到一个VIDEORESIZE事件。在该消息的处理程序中是对set_mode的另一个调用,它创建了一个与新尺寸匹配的新屏幕表面。调整大小消息包含以下值:

  • size— 这是一个包含窗口新维度的元组;size[0]是宽度,size[1]是高度。
  • w— 这个值包含窗口的新宽度。它与size[0]的值相同,但可能更方便。
  • h— 这个值包含窗口的新高度。它与size[1]的值相同,但可能更方便。

因为这个脚本的显示大小会有所不同,所以我们绘制背景的方式会稍有不同,方法是根据需要多次将背景图像块化以覆盖显示。对range的两次调用产生了放置这些背景图像所需的坐标。

大多数游戏都是全屏运行的,所以可调整大小的显示屏可能不是你经常使用的功能。但是如果你需要的话,它就在你的工具箱里!

没有边框的窗口

通常当你创建一个 Pygame 窗口时,你会想要一个带有标题栏和边框的标准窗口。但是,也可以创建一个没有这些功能的窗口,这样用户就不能移动或调整窗口大小,或者通过关闭按钮关闭窗口。这种用法的一个例子是用于闪屏的窗口。有些游戏可能需要一段时间才能加载,因为它们包含许多图像和声音文件。如果发生这种情况时,屏幕上什么也看不见,玩家可能会觉得游戏没有运行,并试图再次启动它。要设置无边框显示,调用set_mode时使用NOFRAME标志。例如,下面的行将创建一个“裸”窗口:

screen = pygame.display.set_mode(SCREEN_SIZE, RESIZABLE, 32)

附加显示标志

在对set_mode的调用中还可以使用一些标志。我认为它们是高级的,因为如果使用不当,它们会影响性能,或者在某些*台上导致兼容性问题。通常最好使用值0显示窗口,使用值FULLSCREEN显示全屏,以确保你的游戏能在所有*台上正常运行。也就是说,如果你知道你在做什么,你可以为额外的性能设置一些高级标志。做实验也没有坏处(不会伤害你的电脑)。

如果你设置了HWSURFACE标志,它将创建一个所谓的硬件表面 。这是一种特殊的显示表面,存储在图形卡的内存中。它只能与FULLSCREEN标志结合使用,比如:

screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE | FULLSCREEN, 32)

硬件表面可以比在系统(常规)内存中创建的表面更快,因为它们可以利用图形卡的更多功能来加速位块传输。硬件表面的缺点是它们在所有*台上都没有得到很好的支持。它们倾向于在 Windows *台上工作,但在其他*台上就不那么好了。硬件表面也受益于DOUBLEBUF标志。这有效地创建了两个硬件表面,但是在任一时刻只有一个是可见的。以下代码行创建一个双缓冲硬件表面:

screen = pygame.display.set_mode(SCREEN_SIZE, DOUBLEBUF | HWSURFACE | FULLSCREEN, 32)

通常当你调用pygame.display.update()`时,整个屏幕会从内存复制到显示器上——这需要一点时间。双缓冲表面允许你立即切换到新的屏幕,从而使你的游戏运行得更快。

您可以使用的最后一个显示标志是OPENGL。OpenGL ( www.opengl.org/)是一个图形库,它使用几乎每个显卡上都有的 3D 图形加速器。使用这个标志的缺点是你将不能再使用 Pygame 的 2D 图形功能。我们将在第九章的中介绍使用 OpenGL 创建 3D。

Image 注意如果使用双缓冲显示器,应该调用pygame.display.flip()而不是pygame.display.update()。这是即时显示切换,而不是复制屏幕数据。

使用字体模块

我承诺将介绍我们在事件队列脚本中使用的字体模块。在屏幕上绘制文本的能力确实有助于测试脚本;您可能还需要它来显示游戏说明、菜单选项等等。字体模块使用 TrueType 字体(TTF),这种字体在大多数系统上用于呈现高质量的*滑文本。您的计算机上会安装许多这样的字体,可供字体模块使用。

要使用字体,必须先创建一个Font对象。最简单的方法是使用pygame.font.SysFont,它使用你已经安装在电脑上的一种字体。下面一行为 Arial 字体(一种易于阅读的通用字体)创建了一个Font对象:

my_font = pygame.font.SysFont("arial", 16)

第一个参数是要创建的字体名称,下一个参数以像素为单位指定字体大小。Pygame 将在您安装的字体中查找名称为“arial”的字体;如果没有找到,将返回默认字体。您可以通过调用pygame.font.get_fonts()获得系统上安装的字体列表。也可以通过调用pygame.font.Font直接从.ttf文件中创建字体,它需要一个文件名。下面一行加载文件my_font.ttf并返回一个Font对象:

my_font = pygame.font.Font("my_font.ttf", 16)

一旦你创建了一个Font对象,你可以用它来渲染文本到一个新的表面。要渲染文本,使用Font对象的render成员函数。它会创建一个包含文本的新表面,然后您可以将它 blit 到显示器上。以下代码行呈现一段文本并返回一个新表面:

text_surface = my_font.render("Pygame is cool!", True, (0,0,0), (255, 255, 255))

render的第一个参数是你要渲染的文本。它必须是一条线;如果您想要多行,您将不得不打破字符串,并使用多个渲染调用。第二个参数是一个布尔值(TrueFalse),用于启用抗锯齿文本。如果设置为True,文本将具有现代、*滑的外观;否则,它会显得更加像素化。render的下两个参数是文本颜色,后面是背景颜色。背景是可选的,如果你省略它(或者设置为None,背景将是透明的。

为了完成对字体模块的介绍,让我们编写一个小脚本来将我的名字渲染到一个表面上,并保存为一个图像。请随意更改您自己的名字。如果你修改清单 3-6 中的第一行,它就会这样做。

清单 3-6 。将您的名字写入图像文件

import pygame
my_name = "Harrison Kinsley"
pygame.init()
my_font = pygame.font.SysFont("arial", 64)
name_surface = my_font.render(my_name, True, (0, 0, 0), (255, 255, 255))
pygame.image.save(name_surface, "name.png")

这个脚本非常简单,我们甚至不需要创建一个显示!当您运行清单 3-6 中的时,您不会在屏幕上看到太多变化,但是代码会在与脚本相同的位置创建一个名为 name.png 的图像文件。您可以用任何图像浏览器软件打开该文件。将表面保存到一个文件是通过 pygame.image 模块完成的,我们将在下一章中介绍。

字体模块提供了其他功能以及Font对象,您可能偶尔需要使用。它们主要是信息性的,旨在检索关于字体的各种信息。有一些函数可以模拟粗体和斜体文本,但是最好使用专用的粗体或斜体字体。有关字体模块的完整信息,请参见www.pygame.org/docs/ref/font.html中的文档。

Image 注意安装的字体因电脑而异,你不能总是依赖于现有的特定字体。如果 Pygame 没有找到你要求的字体,它将使用一个默认的字体,看起来可能不一样。解决方案是分发。ttf 文件,但是要确保你得到了字体作者的许可!对于免费分发的字体,你可以使用来自比特流 Vera 家族的东西(http://en.wikipedia.org/wiki/Bitstream_Vera)。

Pygame 在行动

当我还是个孩子的时候,“scrolly 消息”在业余图形程序员中非常流行。scrolly 消息,或者现在所知的 marquee ,就是从右向左滑过屏幕的文本。清单 3-7 是 scrolly 消息的 Pygame 实现。它也不是没有缺点,其中最主要的是它的移动速度不一致,在不同的计算机上可能更快或更慢。这是一个你将在下一章学习如何解决的问题。

这个脚本的大部分现在应该都很熟悉了,所以我就不分解了。尝试调整代码以产生不同的结果。您可能还想插入自己选择的文本,这可以通过修改脚本开头的message字符串来实现。

清单 3-7 。Scrolly 消息脚本

import pygame
from pygame.locals import *
from sys import exit

background_image_filename = 'sushiplate.jpg'
SCREEN_SIZE = (640, 480)
message="    This is a demonstration of the scrolly message script. "

pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE)

font = pygame.font.SysFont("arial", 80);
text_surface = font.render(message, True, (0, 0, 255))

x = 0
y = ( SCREEN_SIZE[1] - text_surface.get_height() ) / 2

background = pygame.image.load(background_image_filename).convert()

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    screen.blit(background, (0,0))

    x-= 2
    if x < -text_surface.get_width():
        x = 0

    screen.blit(text_surface, (x, y))
    screen.blit(text_surface, (x+text_surface.get_width(), y))
    pygame.display.update()

摘要

Pygame 是一个构建游戏的强大*台。它由许多子模块组成,用于各种游戏相关的任务。Pygame 在大量*台上同样运行良好。所有主要的桌面系统甚至一些控制台都有端口,因此您可以在自己喜欢的*台上开发游戏,并在另一个*台上玩。

我们制作了一个 Hello World 脚本,演示了启动 Pygame、创建显示、接收事件,然后绘制到屏幕上的基本操作,这些步骤将在您创建更复杂的游戏和演示时使用。如果你曾经用 C 或 C++ 做过游戏编程,你会体会到代码的简单性,尤其是创建显示的一行程序。

我们探讨了在创建显示时可以使用的标志,这些标志可以提高性能或增加功能。最好是禁用这些标志,至少在你对 Pygame 和计算机图形更加熟悉之前。您应该会发现默认设置仍然可以提供出色的性能。

您还学习了如何管理事件队列来处理 Pygame 发送给您的各种事件,甚至学习了如何创建自定义事件。清单 2-3 让您确切地看到生成了什么事件以及它们包含的信息。当您尝试自己的脚本时,您会发现这个清单是一个方便的工具。

本章涵盖了使用 Pygame 需要的所有样板代码。下一章研究图形、运动和动画。`

四、创建视觉效果

电脑游戏本质上是非常视觉化的,游戏开发人员花费大量时间处理图形和改进视觉效果,为玩家创造最具娱乐性的体验。这一章给你一个为电脑游戏生成视觉效果的坚实基础。

在本章中,我们将使用 Python 编写的例子和 Pygame 模块来说明我们的观点。您不需要完全理解这些程序,但是我们鼓励您去尝试!

使用像素功率

如果你仔细观察你的电脑屏幕,你应该能看出它是由一排排彩色圆点组成的。这些点如此紧密地聚集在一起,以至于当你在一个舒适的距离观看屏幕时,它们合并成一个单一的图像。显示器中的一个单独的点被称为一个像素,或像素 。因为电脑游戏本质上主要是视觉的,像素在很大程度上是游戏程序员的工具。

让我们编写一个小的 Python 脚本来生成包含所有可能颜色的图像。清单 4-1 使用 Pygame 创建一个包含所有可能颜色值的大图像。运行该脚本需要几分钟时间,但当它完成时,它会保存一个名为 allcolors.bmp 的图像文件,您可以在图像查看器或 web 浏览器中打开该文件。不要管清单 4-1 的细节;我们将在本章中讲述不熟悉的代码。

清单 4-1 。生成包含每种颜色的图像

import pygame
pygame.init()

screen = pygame.display.set_mode((640, 480))

all_colors = pygame.Surface((4096,4096), depth=24)

for r in range(256):
    print(r+1, "out of 256")
    x = (r&15)*256
    y = (r>>4)*256
    for g in range(256):
        for b in range(256):
            all_colors.set_at((x+g, y+b), (r, g, b))

pygame.image.save(all_colors, "allcolors.bmp")
pygame.quit()

清单 4-1 对于 Pygame 脚本来说是不寻常的,因为它不是交互式的。当它运行时,你会看到它从 1 数到 256,然后在将位图文件保存到与脚本相同的位置后退出。不要担心它很慢一次生成一个像素的位图是你在游戏中永远不需要做的事情!

使用颜色

你可能熟悉如何用颜料创造颜色。如果你有一罐蓝色颜料和一罐黄色颜料,那么你可以通过混合这两种颜料来创造绿色。事实上,你可以把红、黄、蓝三原色按不同的比例混合,制成任何颜色的颜料。电脑配色的工作原理类似,但“原色”是红色、绿色和蓝色。为了理解这种差异,我们需要了解颜色背后的科学——别担心,这并不复杂。

要看到一种颜色,来自太阳或灯泡的光必须被某些东西反射并穿过你眼睛里的晶状体。阳光或灯泡发出的人造光可能看起来是白色的,但实际上它包含了彩虹混合在一起的所有颜色。当光线照射到一个表面时,其中的一些颜色被吸收,其余的被反射。正是这种反射光进入你的眼睛,被感知为颜色。以这种方式创建颜色时,称为 减色 。电脑屏幕的工作方式不同。它们不是反射光,而是通过将红色、绿色和蓝色的光叠加在一起产生自己的颜色(这个过程被称为 颜色叠加 )。

这是目前足够的科学。现在我们需要知道如何在 Python 程序中表示颜色,因为为所有 1670 万种颜色想名字是不切实际的!

在 Pygame 中表示颜色

当 Pygame 需要一种颜色时,您可以将它作为一个由三个整数组成的元组来传递,每个整数对应一种颜色成分,按照红色、绿色和蓝色的顺序。每个组件的值应该在 0 到 255 的范围内,其中 255 是全强度,0 表示该组件对最终颜色没有任何贡献。表 4-1 列出了使用设置为关闭或全亮度的组件可以创建的颜色。

表 4-1 。颜色表

Table4-1.jpg

一些早期的计算机仅限于这些花哨的颜色;幸运的是,现在你可以创造更多微妙的色彩!

尝试不同的值是很值得的,这样你会对计算机生成的颜色有一个直观的感觉。稍加练习,你会发现你可以观察这三种颜色值,并对颜色的样子做出有根据的猜测。让我们写一个脚本来帮助我们做到这一点。当您运行清单 4-2 中的时,您会看到一个分成两半的屏幕。屏幕顶部有三个刻度,分别代表红色、绿色和蓝色分量,还有一个圆圈代表当前选定的值。如果您单击其中一个标尺上的任意位置,它将修改组件并更改结果颜色,结果颜色显示在屏幕的下半部分。

尝试将滑块调整到(96,130,51),这将产生令人信服的僵尸绿色,或者调整到(221,99,20)以产生令人愉悦的火球橙色。

清单 4-2 。调整颜色的脚本

import pygame
from pygame.locals import *
from sys import exit

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)

# Creates images with smooth gradients
def create_scales(height):
    red_scale_surface   = pygame.surface.Surface((640, height))
    green_scale_surface = pygame.surface.Surface((640, height))
    blue_scale_surface  = pygame.surface.Surface((640, height))
    for x in range(640):
        c = int((x/639.)*255.)
        red   = (c, 0, 0)
        green = (0, c, 0)
        blue  = (0, 0, c)
        line_rect = Rect(x, 0, 1, height)
        pygame.draw.rect(red_scale_surface, red, line_rect)
        pygame.draw.rect(green_scale_surface, green, line_rect)
        pygame.draw.rect(blue_scale_surface, blue, line_rect)
    return red_scale_surface, green_scale_surface, blue_scale_surface

red_scale, green_scale, blue_scale = create_scales(80)

color = [127, 127, 127]

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    screen.fill((0, 0, 0))

    # Draw the scales to the screen
    screen.blit(red_scale, (0, 00))
    screen.blit(green_scale, (0, 80))
    screen.blit(blue_scale, (0, 160))

    x, y = pygame.mouse.get_pos()

    # If the mouse was pressed on one of the sliders, adjust the color component
    if pygame.mouse.get_pressed()[0]:
        for component in range(3):
            if y > component*80 and y < (component+1)*80:
                color[component] = int((x/639.)*255.)
            pygame.display.set_caption("PyGame Color Test - "+str(tuple(color)))

    # Draw a circle for each slider to represent the current setting
    for component in range(3):
        pos = ( int((color[component]/255.)*639), component*80+40 )
        pygame.draw.circle(screen, (255, 255, 255), pos, 20)

    pygame.draw.rect(screen, tuple(color), (0, 240, 640, 240))

    pygame.display.update()

清单 4-2 介绍了pygame.draw模块,用于在屏幕上绘制线条、矩形、圆形和其他形状。我们将在本章后面更详细地讲述这个模块。

一旦你有了一种颜色,你可能想对它做一些事情。假设我们有一个太空游戏中的士兵不幸遭遇流星雨,却没有带伞。当流星划过大气层时,我们可以用“橙色火球”来表示它们,但是当它们撞击地面时,它们会逐渐变成黑色。我们如何找到较暗的颜色?

缩放颜色

要使颜色变深,只需将每个分量乘以一个介于 0 和 1 之间的值。如果你取火球橙(221,99,20),将每个分量乘以 0.5(换句话说,将它们减少一半),那么你得到(110.5,49.5,10)。但是因为颜色分量是整数,我们需要去掉小数部分来得到(110,49,10)。如果你使用清单 4-2 来创建这个颜色,你应该看到它确实是一个更暗的火球橙色。我们不想每次都必须在脑子里算,所以让我们写一个函数来替我们算。清单 4-3 是一个函数,它接受一个颜色元组,将每个数字乘以一个浮点值,然后返回一个新的元组。

清单 4-3 。缩放颜色的函数

def scale_color(color, scale):
    red, green, blue = color
    red   = int(red*scale)
    green = int(green*scale)
    blue  = int(blue*scale)
    return red, green, blue

fireball_orange = (221, 99, 20)
print(fireball_orange)
print(scale_color(fireball_orange, .5))

如果你运行清单 4-3 ,它将显示火球橙色的颜色元组和更暗的版本:

(221, 99, 20)
(110, 49, 10)

将每个分量乘以 0 到 1 之间的值会使颜色变深,但如果乘以大于 1 的值会怎样呢?它会使颜色变得更亮,但有一点你必须小心。让我们使用比例值 2 来制作一个真正明亮的橙色火球。将下面一行添加到列表 4-3 中,看看颜色会发生什么变化:

print(scale_color(fireball_orange, 2.))

这为输出添加了一个额外的颜色元组:

(442, 198, 40)

第一个(红色)分量是 442——这是一个问题,因为颜色分量必须是 0 到 255 之间的值!如果你在 Pygame 中使用这个颜色元组,它将抛出一个TypeError异常,所以在使用它来绘制任何东西之前,我们“修复”它是很重要的。我们所能做的就是检查每个组件,如果它超过 255,就把它设置回 255——这个过程被称为使颜色饱和。清单 4-4 是一个执行颜色饱和度的函数。

清单 4-4 。使颜色饱和的函数

def saturate_color(color):
    red, green, blue = color
    red   = min(red, 255)
    green = min(green, 255)
    blue  = min(blue, 255)
    return red, green, blue

清单 4-4 使用内置函数min,它返回两个值中较低的一个。如果组件在正确的范围内,则返回时保持不变。但如果大于 255,则返回 255(这正是我们需要的效果)。

如果我们在缩放后使额外明亮的火球橙色饱和,我们会得到以下输出,Pygame 会很乐意接受:

(255, 198, 40)

颜色分量在 255 处饱和时,颜色会更亮,但可能不是完全相同的色调。而如果你一直缩放一种颜色,最终可能会变成(255,255,255),也就是亮白色。通常更好的方法是选择你想要的最亮的颜色,然后向下缩放(使用小于 1 的因子)。

我们现在知道了当使用大于零的值时缩放的作用。但是如果它小于零,也就是负的呢?缩放颜色时使用负值会产生负颜色分量,这没有意义,因为颜色中的红色、绿色或蓝色不能少于零。避免用负值缩放颜色!

混合颜色

你可能想对颜色做的其他事情是将一种颜色逐渐混合到另一种颜色中。假设我们在一个恐怖游戏中有一个僵尸,它通常是一种病态的僵尸绿色,但最*从一个熔岩坑中出现,目前发出明亮的火球橙色。随着时间的推移,僵尸会冷却下来,回到它通常的颜色。但是我们如何计算中间色来使过渡看起来*滑呢?

我们可以使用所谓的线性插值 ,这是一个有趣的术语,用于沿直线从一个值移动到另一个值。这个词如此拗口,以至于游戏程序员更喜欢使用首字母缩写词 lerp 。要在两个值之间进行 lerp,您需要找到第二个值和第一个值之间的差值,将其乘以一个介于 0 和 1 之间的系数,然后将其与第一个值相加。因子 0 或 1 将产生第一个或第二个值,但因子 0.5 给出的值介于第一个和第二个值之间。任何其他因素将导致两个端点之间的比例值。让我们看一个 Python 代码中的例子,让它更清楚。清单 4-5 定义了一个函数lerp,它接受两个值和一个因子,并返回一个混合值。

清单 4-5 。简单学习示例

def lerp(value1, value2, factor):
    return value1+(value2-value1)*factor

print(lerp(100, 200, 0.))
print(lerp(100, 200, 1.))
print(lerp(100, 200, .5))
print(lerp(100, 200, .25))

这将产生以下输出。试着预测一下lerp(100, 200, .75)的结果会是什么。

100.0
200.0
150.0
125.0

要在颜色之间进行变换,只需在每个组件之间进行变换,就可以产生一种新的颜色。如果您随时间改变该因子,将会产生*滑的颜色过渡。清单 4-6 包含函数blend_color,它执行颜色学习。

清单 4-6 。通过学习混合颜色

import pygame
from pygame.locals import *
from sys import exit

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)

color1 = (221, 99, 20)
color2 = (96, 130, 51)
factor = 0.

def blend_color(color1, color2, blend_factor):
    red1, green1, blue1 = color1
    red2, green2, blue2 = color2
    red   = red1+(red2-red1)*blend_factor
    green = green1+(green2-green1)*blend_factor
    blue  = blue1+(blue2-blue1)*blend_factor
    return int(red), int(green), int(blue)

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    screen.fill((255, 255, 255))

    tri = [ (0,120), (639,100), (639, 140) ]
    pygame.draw.polygon(screen, (0,255,0), tri)
    pygame.draw.circle(screen, (0,0,0), (int(factor*639.), 120), 10)

    x, y = pygame.mouse.get_pos()
    if pygame.mouse.get_pressed()[0]:
        factor = x / 639.
        pygame.display.set_caption("PyGame Color Blend Test - %.3f"%factor)

    color = blend_color(color1, color2, factor)
    pygame.draw.rect(screen, color, (0, 240, 640, 240))

    pygame.display.update()

如果你运行清单 4-6 中的,你会在屏幕顶部看到一个滑块。最初它会在最左边,代表因子 0(火球橙色)。如果单击并向屏幕右侧拖动,可以将混合因子*滑地更改为 1(僵尸绿)。结果颜色显示在屏幕的下半部分。

您可以通过更改脚本顶部的color1color2的值来尝试混合其他颜色。尝试在完全对比的颜色和相*颜色的阴影之间混合。

使用图像

图像是大多数游戏的重要组成部分。显示器通常由存储在硬盘(或 CD、DVD 或其他媒体设备)上的图像集合组成。在 2D 游戏中,图像可能代表背景、文本、玩家角色或人工智能(AI)对手。在 3D 游戏中,图像通常用作纹理来创建 3D 场景。

计算机将图像存储为颜色网格。这些颜色的存储方式因复制图像所需的颜色数量而异。照片需要全范围的颜色,但图表或黑白图像的存储方式可能不同。一些图像还为每个像素存储额外的信息。除了通常的红色、绿色和蓝色分量之外,可能还有一个 alpha 分量。颜色的 alpha 值通常用于表示的半透明性和,这样当被绘制在另一个图像上时,部分背景可以显示出来。我们在 Hello World Redux 中使用了一个带有 alpha 通道的图像(清单 3-1)。如果没有 alpha 通道,鱼的图像将被绘制在一个丑陋的矩形内。

使用 Alpha 通道创建图像

如果你用数码相机拍了一张照片,或者用一些图形软件画了一张照片,那么它很可能不会有 alpha 通道。将 alpha 通道添加到图像中通常需要使用图形软件。为了给一条鱼的图像添加一个 alpha 通道,我用了 Photoshop,但是你也可以用其他软件比如 GIMP ( http://www.gimp.org)来做这个。关于 GIMP 的介绍,请参见 Akkana Peck 的入门 GIMP:从新手到专业人员 (Apress,2006)。

将 alpha 通道添加到现有图像的替代方法是使用 3D 渲染包创建图像,如 Autodesk 的 3ds Max 或免费替代软件 Blender ( http://www.blender.org)。使用这种软件,你可以直接输出一幅背景不可见的图像(你也可以创建几帧动画或不同角度的视图)。这可能会产生最好的效果,一个光滑的游戏,但你可以做很多手动阿尔法通道技术。试着给你的猫、狗或金鱼拍张照片,然后用它做个游戏!

存储图像

在硬盘上存储图像有多种方法。多年来,已经开发了许多图像文件格式,每种格式都有优点和缺点。幸运的是,有一小部分已经成为最有用的,特别是两个:JPEG 和 PNG。这两种格式在图像编辑软件中都得到了很好的支持,你可能不需要在游戏中使用其他格式来存储图像。

  • JPEG(联合图像专家组) JPEG 图像文件的扩展名一般为jpg,有时也有.jpeg。如果你使用数码相机,它产生的文件可能是 JPEGs,因为它们是专门为存储照片而设计的。他们使用一种被称为有损压缩的过程,这种方法非常擅长缩小文件大小。有损压缩的缺点是它会降低图像的质量,但通常它是如此微妙,以至于你不会注意到差异。还可以调整压缩量,以在视觉质量和压缩之间进行折衷。它们对于照片来说可能很棒,但是 JPEGs 对于任何有硬边的东西都不好,比如字体或图表,因为有损压缩往往会扭曲这类图像。如果你有这些类型的图片,PNG 可能是一个更好的选择。
  • PNG(便携式网络图形) PNG 文件可能是最通用的图像格式,因为它们可以存储各种各样的图像类型,并且仍然可以很好地压缩。他们还支持 alpha 通道,这对游戏开发者来说是一个真正的福音。PNG 使用的压缩是无损的,这意味着存储为 PNG 文件的图像将与原始图像完全相同。缺点是,即使有良好的压缩,它们也可能比 JPEGs 大。

除了 JPEG 和 PNG,Pygame 还支持读取以下格式:

  • GIF(非动画)
  • 位图文件的扩展名(Bitmap)
  • 足细胞标记蛋白
  • TGA(仅未压缩)
  • 标签图像文件格式。
  • LBM(和 PBM)
  • PBM(和百万分率、百万分率)
  • 交叉相位调制

根据经验,只对有很多颜色变化的大图像使用 JPEG 否则,使用 PNGs。

使用表面对象

将图像加载到 Pygame 是通过一个简单的一行程序完成的;获取你想要加载的图像的文件名,并返回一个 surface 对象,它是一个图像的容器。表面可以表示多种类型的图像,但是 Pygame 对我们隐藏了大部分细节,所以我们可以用同样的方式对待它们。一旦你在内存中有了一个表面,你就可以在它上面绘图,变换它,或者把它复制到另一个表面来建立一个图像。甚至屏幕也被表示为表面对象。对pygame.display.set_mode的初始调用返回一个表示显示器的表面对象。

创建曲面

调用pygame.image.load是创建表面的一种方式。它创建了一个匹配图像文件的颜色和尺寸的表面,,但是你也可以创建任何你需要的尺寸的空白表面(假设有足够的内存来存储它)。要创建一个空白表面,用一个包含所需尺寸的元组调用pygame.Surface构造函数。以下行创建一个 256 x 256 像素的表面:

blank_surface = pygame.Surface((256, 256))

如果没有任何其他参数,这将创建一个与显示颜色数量相同的表面。这通常是您想要的,因为当图像具有相同数量的颜色时,复制图像会更快。

pygame.Surface还有一个depth参数,用于定义表面的颜色深度。这类似于pygame.display.set_mode中的深度参数,定义了表面颜色的最大数量。一般情况下最好不要设置这个参数(或者设置为 0),因为 Pygame 会选择一个与显示相匹配的深度——尽管如果你想要表面的 alpha 信息,你应该将depth设置为 32。下面一行创建一个带有 alpha 信息的表面:

blank_alpha_surface = pygame.Surface((256, 256), depth=32)

转换曲面

当您使用表面对象时,您不必担心图像信息如何存储在内存中,因为 Pygame 会向您隐藏这一细节。所以大多数时候,图像格式是你不需要担心的,因为不管你使用什么类型的图像,你的代码都可以工作。这种自动转换的唯一缺点是,如果你使用不同格式的图像,Pygame 将不得不做更多的工作,这可能会降低游戏性能。解决方法是将你所有的图片转换成相同的格式。表面对象为此有一个convert方法。

如果不带任何参数调用convert,表面将被转换为显示表面的格式。这很有用,因为当源和目标类型相同时,复制表面通常是最快的,并且大多数图像最终将被复制到显示器。给任何对pygame.image.load的调用加上.convert()是一个好主意,以确保你的图像以最快的格式显示。例外是当你的图像有一个阿尔法通道,因为convert可以丢弃它。幸运的是,Pygame 提供了一个convert_alpha方法,将表面转换为快速格式,但保留图像中的任何 alpha 信息。在前一章中我们已经使用了这两种方法;下面两行摘自清单 3-1:

background = pygame.image.load(background_image_filename).convert()
mouse_cursor = pygame.image.load(mouse_image_filename).convert_alpha()

背景只是一个实心矩形,所以我们使用convert。但是鼠标光标边缘不规则,需要 alpha 信息,所以我们叫convert_alpha

convertconvert_alpha都可以把另一个曲面作为参数。如果提供了一个曲面,该曲面将被转换以匹配另一个曲面。

请记住,要使用前面的行,您需要一个已经定义的曲面对象;否则,您会收到一个错误,因为没有指定视频模式。

矩形对象

Pygame 经常要求你给它一个矩形来定义屏幕的哪一部分应该受到函数调用的影响。例如,您可以通过设置裁剪矩形来限制 Pygame 在屏幕的矩形区域上绘图(下一节将介绍)。您可以使用包含四个值的元组来定义矩形:左上角的 x 和 y 坐标,后跟矩形的宽度和高度。或者,您可以将 x 和 y 坐标作为一个元组给出,后跟宽度和高度作为另一个元组。以下两条线定义了具有相同尺寸的矩形:

my_rect1 = (100, 100, 200, 150)
my_rect2 = ((100, 100), (200, 150))

你可以使用当时最方便的方法。例如,您可能已经将坐标和大小存储为一个元组,因此使用第二种方法会更容易。

除了定义矩形之外,Pygame 还有一个Rect类,它存储与矩形元组相同的信息,但包含许多方便的方法来处理它们。Rect对象使用如此频繁,以至于它们被包含在pygame.locals中——所以如果在脚本的顶部有from pygame.locals import *,就不需要在它们前面加上模块名。

要构造一个Rect对象,可以使用与矩形元组相同的参数。下面几行构建了等同于两个矩形元组的Rect对象:

from pygame import rect
my_rect3 = Rect(100, 100, 200, 150)
my_rect4 = Rect((100, 100), (200, 150))

一旦你有了一个Rect对象,你可以调整它的位置或大小,检测一个点是在里面还是外面,或者找到其他矩形相交的地方。更多细节见 Pygame 文档(http://www.pygame.org/docs/ref/rect.html)。

剪报

通常,当你为一个游戏创建一个屏幕时,你可能只想在屏幕的一部分上绘图。例如,在一个策略命令和征服类型的游戏中,你可能在屏幕的顶部有一个可滚动的地图,在它的下面有一个显示部队信息的面板。但是当你开始在屏幕上绘制军队图像的时候,你不希望他们覆盖信息面板。为了解决这个问题,surfaces 有一个剪辑区域,,它是一个矩形,定义了屏幕的哪一部分可以被绘制。要设置剪辑区域,调用带有Rect样式对象的表面对象的set_clip方法。您也可以通过调用get_clip来检索当前的剪辑区域。

下面的代码片段展示了我们如何使用剪辑来构建一个策略游戏的屏幕。对 clip 的第一次调用设置了区域,因此对draw_map的调用只能绘制到屏幕的上半部分。对set_clip的第二次调用将剪辑区域设置为屏幕的剩余部分:

screen.set_clip(0, 0, 640, 300)
draw_map()
screen.set_clip(0, 300, 640, 180)
draw_panel()

子表面

地下是在另一个表面里面的一个表面。当你在一个表面下绘图时,它也会在它的表面上绘图。子表面的一个用途是绘制图形字体。模块以单一颜色产生漂亮、清晰的文本,但是一些游戏需要更丰富的图形字体。您可以为每个字母保存一个图像文件,但是创建一个包含所有字母的图像,然后在将图像加载到 Pygame 中时创建 26 个子表面可能会更容易。

要创建一个 subsurface,您需要调用Surface对象的subsurface方法,该方法采用一个矩形来定义它应该覆盖父对象的哪一部分。它将返回一个新的Surface对象,其颜色格式与父对象相同。下面是我们如何加载一个字体图像,并把它分成字母大小的部分:

my_font_image = Pygame.load("font.png")
letters = []
letters["a"]  = my_font_image.subsurface((0,0), (80,80))
letters["b"]  = my_font_image.subsurface((80,0), (80,80))

这创建了my_font_image的两个子表面,并将它们存储在一个字典中,这样我们就可以很容易地查找给定字母的子表面。当然,我们需要的不仅仅是“a”和“b”,所以对 subsurface 的调用可能会在一个循环中重复 26 次。

使用子曲面时,请务必记住它们有自己的坐标系。换句话说,地表下的点(0,0)总是左上角,无论它位于其父体的哪个位置。

填充表面

当你在显示器上创建图像时,你应该覆盖整个屏幕;否则,先前屏幕的部分内容将会显示出来。如果你不画出每一个像素,当你试图动画任何东西时,你会得到一个不愉快的频闪效果。避免这种情况的最简单的方法是通过调用表面对象的fill方法来清除屏幕,该方法采用一种颜色。以下内容将屏幕清空为黑色:

screen.fill((0, 0, 0))

fill函数还采用一个可选的矩形来定义要清除的区域,这是一种绘制实心矩形的便捷方式。

Image 注意如果你用其他方法在整个屏幕上绘图,你不需要调用fill来清除。

设置表面中的像素

你可以对一个表面做的最基本的事情之一是设置单独的像素,这有画一个小点的效果。很少需要一次绘制一个像素,因为有更有效的方法来绘制图像,但如果您需要进行任何离线图像处理,它会很有用。

要在一个表面上绘制一个像素,使用set_at方法,该方法获取您想要设置的像素的坐标,后跟您想要设置的颜色。我们将通过编写一个绘制随机像素的脚本来测试set_at。当你运行清单 4-7 时,你会看到屏幕慢慢地被随机颜色的点填满;每一个都是独立的像素。

清单 4-7 。绘制随机像素的脚本(random.py)

import pygame
from pygame.locals import *
from sys import exit
from random import randint

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    rand_col = (randint(0, 255), randint(0, 255), randint(0, 255))
    for _ in range(100):
        rand_pos = (randint(0, 639), randint(0, 479))
        screen.set_at(rand_pos, rand_col)

    pygame.display.update()

获取表面中的像素

set_at的补码是get_at,返回给定坐标像素的颜色。获取像素有时对于碰撞检测是必要的,这样代码就可以通过查看下面的颜色来确定玩家角色站在什么上面。如果所有的*台和障碍物都是某种颜色(或颜色范围),这将会非常有效。set_at只接受一个参数,这个参数应该是你想要查看的像素坐标的元组。下面的代码行获取了一个叫做screen : 的表面中坐标为(100,100)的像素

my_color = screen.get_at((100, 100))

Image 注意get_at方法从硬件表面读取时会非常慢。显示器可以是一个硬件表面,尤其是在全屏运行的情况下,因此您可能应该避免获取显示器的像素。

锁定表面

每当 Pygame 绘制到一个表面上时,它首先必须被锁定。当一个界面被锁定时,Pygame 拥有对该界面的完全控制权,在解锁之前,计算机上的任何其他进程都不能使用它。当你在一个表面上绘图时,锁定和解锁会自动发生,但是如果 Pygame 不得不做许多锁定和解锁,它会变得低效。

在清单 4-7 中有一个调用set_at 100 次的循环,这导致 Pygame 锁定和解锁screen表面 100 次。我们可以通过手动锁定来减少锁定和解锁的数量,并加快循环速度。清单 4-8 与之前的清单几乎相同,但是运行得更快,因为在绘制之前有一个对lock的调用,并且在所有像素都被绘制之后有一个对unlock的调用。

Image 注意要锁定的呼叫数量应该与要解锁的呼叫数量相同。如果你忘记解锁一个表面,Pygame 可能会变得没有反应。

清单 4-8 。带锁定的随机像素(randoml.py)

import pygame
from pygame.locals import *
from sys import exit
from random import randint

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    rand_col = (randint(0, 255), randint(0, 255), randint(0, 255))
    screen.lock()
    for _ in range(100):
        rand_pos = (randint(0, 639), randint(0, 479))
        screen.set_at(rand_pos, rand_col)
    screen.unlock()
    pygame.display.update()

并非所有曲面都需要锁定。硬件表面有(屏幕通常是硬件表面),但普通的软件表面没有。 Pygame 在 surface 对象中提供了一个mustlock方法,如果一个表面需要锁定,该方法将返回True。你可以在进行任何锁定或解锁之前检查mustlock的返回值,但是锁定一个不需要它的表面是没有问题的,所以你也可以锁定任何你打算在上面进行大量绘制的表面。

Blitting

你可能最常使用的表面对象的方法是blit,它是位块传输?? 的缩写。blit ing 简单地说就是将图像数据从一个表面复制到另一个表面。你将使用它来绘制背景,字体,字符,以及游戏中的任何东西!

为了 blit 一个表面,你从目标表面对象(通常是显示器)调用blit,并给它一个源表面(你的精灵,背景,等等);后面跟着你想把它 blit 到的坐标。您也可以通过向定义源区域的参数添加一个Rect样式的对象来 blit 表面的一部分。这里有两种使用blit方法的方式:

screen.blit(background, (0,0))

它将一个名为background的表面 blits 到屏幕的左上角。如果backgroundscreen的尺寸一样,我们就不需要用纯色的fill屏幕了。

另一种方式是

screen.blit(ogre, (300, 200), (100*frame_no, 0, 100, 100))

如果我们有一个包含几帧食人魔行走的图像,我们可以使用类似这样的东西将它传送到屏幕上。通过改变frame_no的值,我们可以从源表面的不同区域进行 blit。

用 Pygame 绘图

在前面的例子中,我们使用了pygame.draw模块中的一些函数。这个模块的目的是在屏幕上绘制线条、圆和其他几何形状。你可以用它来创建一个完整的游戏,而不用加载任何图像。经典的 Atari 游戏《小行星》就是一个很棒的游戏的例子,它只是用线条画出了形状。即使你不使用pygame.draw模块来创建一个完整的游戏,当你不想麻烦地创建图像时,你也会发现它对实验很有用。当您需要可视化代码中发生的事情时,您还可以使用它在游戏顶部绘制一个调试覆盖图。

pygame.draw中函数的前两个参数是你想要渲染的表面——可以是屏幕(显示表面)或普通表面——后面是你想要绘制的颜色。每个 draw 函数也将接受至少一个点,可能还有一个点列表。一个点应该以包含 x 和 y 坐标的元组的形式给出,其中(0,0)是屏幕的左上角。

这些 draw 函数的返回值是一个Rect对象,该对象给出了已经被绘制到的屏幕区域,如果我们只想刷新屏幕上已经被更改的部分,这将非常有用。表 4-2 列出了pygame.draw模块中的功能,我们将在本章中介绍。

表 4-2 。pygame.draw 模块

|

功能

|

目的

|
| --- | --- |
| rect | 绘制矩形 |
| polygon | 绘制多边形(有三条或更多条边的形状) |
| circle | 画一个圆 |
| ellipse | 绘制一个椭圆 |
| arc | 画弧线 |
| line | 画一条线 |
| lines | 画几条线 |
| aaline | 绘制一条抗锯齿(*滑)线 |
| aalines | 绘制几条抗锯齿线 |

pygame.draw.rect

这个函数在一个表面上绘制一个矩形。除了目标表面和颜色,pygame.rect还获取您想要绘制的矩形的尺寸和线条的宽度。如果将width设置为 0 或者省略,矩形会用纯色填充;否则,将只绘制边缘。

让我们编写一个脚本来测试 Pygame 的矩形绘制能力。清单 4-9 用随机的位置和颜色绘制十个随机填充的矩形。它产生了一种奇怪的美丽,现代艺术般的效果。

清单 4-9 。矩形测试

import pygame
from pygame.locals import *
from sys import exit

from random import *

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

screen.lock()
for count in range(10):
    random_color = (randint(0,255), randint(0,255), randint(0,255))
    random_pos = (randint(0,639), randint(0,479))
    random_size = (639-randint(random_pos[0],639), 479-randint(random_pos[1],479))
    pygame.draw.rect(screen, random_color, Rect(random_pos, random_size))

screen.unlock()

pygame.display.update()

while True:
    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

还有另一种方法可以在表面上画出填充的矩形。surface对象的fill方法采用了一个Rect风格的对象,该对象定义了填充表面的哪一部分——并绘制了一个完美的填充矩形!其实fill可以比pygame.draw.rect更快;它可能是硬件加速的(换句话说,由图形卡而不是主处理器执行)。

pygame.draw.polygon

多边形是有很多边的形状,也就是说,从三角形到百万边形(10,000 条边——我查过了!)和超越。对pygame.draw.polygon的调用获取一系列点,并在它们之间绘制形状。像pygame.rect一样,它也有一个可选的width值。如果width被省略或设置为 0,多边形将被填充;否则,将只绘制边缘。

我们用一个简单的脚本测试 Pygame 的多边形绘制能力。清单 4-10 保存了一个点数列表。每当它得到一个MOUSEBUTTONDOWN事件,它就把鼠标的位置添加到点列表中。当它至少有三个点时,它会画一个多边形。

尝试在对pygame.draw.polygon的调用中添加一个width参数,以使用未填充的多边形。

清单 4-10 。用 Pygame 绘制多边形

import pygame
from pygame.locals import *
from sys import exit

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

points = []

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()
        if event.type == MOUSEBUTTONDOWN:
            points.append(event.pos)

    screen.fill((255,255,255))

    if len(points) >= 3:
        pygame.draw.polygon(screen, (0,255,0), points)
    for point in points:
        pygame.draw.circle(screen, (0,0,255), point, 5)

    pygame.display.update()

pygame.draw.circle

circle函数在表面上画一个圆。它取圆心和圆的半径(半径是圆心到边缘的距离)。像其他绘图函数一样,它也需要一个线条宽度值。如果width为 0 或省略,则用直线画圆;否则就是实心圆。清单 4-11 用随机颜色在屏幕上绘制随机填充的圆圈。

清单 4-11 。随机圆

import pygame
from pygame.locals import *
from sys import exit

from random import *

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

for _ in range(25):
    random_color  = (randint(0,255), randint(0,255), randint(0,255))
    random_pos    = (randint(0,639), randint(0,479))
    random_radius = randint(1,200)
    pygame.draw.circle(screen, random_color, random_pos, random_radius)

    pygame.display.update()

while True:

   for event in pygame.event.get():
      if event.type == QUIT:
         pygame.quit()
         exit()

pygame.draw.ellipse

你可以把椭圆想象成一个被压扁的圆形。如果你把一个圆拉长成一个长方形,它就会变成一个椭圆。除了表面和颜色,ellipse函数还接受一个Rect样式的对象,椭圆应该适合这个对象。它还需要一个width参数,就像rectcircle一样使用。清单 4-12 绘制一个椭圆,该椭圆适合从屏幕左上角延伸到当前鼠标位置的矩形。

清单 4-12 。画一个椭圆

import pygame
from pygame.locals import *
from sys import exit

from random import *

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
          pygame.quit()
          exit()

    x, y = pygame.mouse.get_pos()
    screen.fill((255,255,255))
    pygame.draw.ellipse(screen, (0,255,0), (0,0,x,y))

    pygame.display.update()

pygame.draw.arc

arc函数只画椭圆的一部分,但只画边;arc没有填充选项。像椭圆函数一样,它采用一个适合圆弧的Rect样式的对象(如果它覆盖了整个椭圆)。它也需要两个弧度的角度。第一个角度是圆弧应该开始绘制的地方,第二个角度是应该停止的地方。它还为线条使用了一个width参数,默认为 1,但是您可以为较粗的线条设置更大的值。清单 4-13 画一条适合整个屏幕的弧线。结束角度取自鼠标的 x 坐标,因此如果您左右移动鼠标,它将改变弧的长度。

清单 4-13 。电弧试验

import pygame
from pygame.locals import *
from sys import exit

from random import *
from math import pi

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    x, y  = pygame.mouse.get_pos()
    angle = (x/639.)*pi*2.
    screen.fill((255,255,255))
    pygame.draw.arc(screen, (0,0,0), (0,0,639,479), 0, angle)

    pygame.display.update()

pygame.draw.line

pygame.draw.line的调用在两点之间画了一条线。表面和颜色之后,需要两个点:你要画的线的起点和终点。还有可选的width参数,其工作方式与rectcircle相同。清单 4-14 从屏幕边缘到当前鼠标位置画了几条线。

清单 4-14 。线条绘制(drawinglines.py)

import pygame
from pygame.locals import *
from sys import exit

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

while True:

    for event in pygame.event.get():
      if event.type == QUIT:
          pygame.quit()
          exit()

    screen.fill((255, 255, 255))

    mouse_pos = pygame.mouse.get_pos()

    for x in range(0,640,20):
        pygame.draw.line(screen, (0, 0, 0), (x, 0), mouse_pos)
        pygame.draw.line(screen, (0, 0, 0), (x, 479), mouse_pos)

    for y in range(0,480,20):
        pygame.draw.line(screen, (0, 0, 0), (0, y), mouse_pos)
        pygame.draw.line(screen, (0, 0, 0), (639, y), mouse_pos)

    pygame.display.update()

pygame.draw.lines

通常线是按顺序画的,所以每一行都是从上一行停止的地方开始。pygame.draw.lines的第一个参数是一个布尔值,表示该行是否关闭。如果设置为True,将在列表的最后一个点和第一个点之间绘制一条额外的线;否则,它将保持打开状态。该值之后是一个点列表,用于在这些点和常用的width参数之间画线。

清单 4-15 使用pygame.draw.lines从鼠标位置得到的点列表中画一条线。当列表中有超过 100 个点时,它会删除第一个点,因此该行奇迹般地开始“undraw”自己!这可能是一款蠕虫游戏的良好起点。

清单 4-15 。绘制多条线(multiplelines.py)

import pygame
from pygame.locals import *
from sys import exit

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

points = []

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()
        if event.type == MOUSEMOTION:
           points.append(event.pos)
           if len(points)>100:
               del points[0]

    screen.fill((255, 255, 255))

    if len(points)>1:
        pygame.draw.lines(screen, (0,255,0), False, points, 2)

    pygame.display.update()

皮格,拉,阿琳

您可能已经从前面的线条绘制函数中注意到,这些线条有一个锯齿状的外观。这是因为一个像素只能画在一个网格的坐标上,如果它不是水*或垂直的,它可能不在直线的正下方。这种效应被称为混叠 ,计算机科学家已经做了大量工作来避免这种情况。任何试图避免或减少锯齿的技术都被称为抗锯齿

Pygame 可以绘制出比pygame.draw.line绘制的线条更加*滑的抗锯齿线条。函数pygame.draw.aaline具有与pygame.draw.line相同的参数,但绘制出*滑的线条。抗锯齿线的缺点是绘制速度比普通线慢,但这种速度并不明显。只要视觉质量很重要,就使用aaline

要查看不同之处,用aaline版本替换前面示例代码中对pygame.draw.line的调用。

皮涅斯,拉皮,阿亚林

就像pygame.draw.line,有一个pygame.draw.lines的抗锯齿版本。对pygame.draw.aalines的调用使用与pygame.draw.lines相同的参数,但是绘制了*滑的线条,所以在代码中很容易在两者之间切换。

摘要

颜色是创建计算机图形的最基本的东西。游戏中的所有图像最终都是通过以某种形式操纵颜色来创建的。我们已经看到 Pygame 如何存储颜色,以及如何通过组合现有颜色来制作新颜色。在学习颜色操作的过程中,我们介绍了 lerping(线性插值),我们将在各种游戏任务中使用它。

表面对象是 Pygame 的画布,可以存储各种图像。幸运的是,我们不必担心它们如何存储的细节,因为当你通过一个表面处理图像时,它们看起来都是同一类型的。

我们详细介绍了 draw 模块,因为它可以方便地在游戏中直观地描述额外的信息。例如,你可以用它在你的敌人身上画一个小箭头,指示他们的前进方向。

这一章已经涵盖了所有你可以用 Pygame 创建视觉效果的方法。有了这些信息,你可以创建地下城、外星世界和其他游戏环境的图像。

在下一章中,你将学习如何随着时间的推移制作动画。

五、让东西动起来

在现实世界中,物体以各种不同的方式移动,这取决于它们在做什么,游戏必须*似这些运动,以创建令人信服的虚拟表示。一些游戏可以摆脱不切实际的运动——例如,吃豆人以恒定的速度沿直线移动,可以在瞬间改变方向,但如果你在驾驶游戏中将这种运动应用到汽车上,就会破坏这种幻觉。毕竟,在驾驶游戏中,你会期望赛车需要一些时间来达到全速,它绝对不应该能够在一瞬间转向 180 度!

对于有一点现实感的游戏,游戏程序员必须考虑是什么让事物移动。我们来看一个典型的驾驶游戏。不管车辆是自行车、拉力赛车还是半挂卡车,都有一个来自发动机的力驱动它前进。还有其他的力量在作用。车轮的阻力会随着你行驶路面的不同而不同,所以车辆在泥地上的操控会和在柏油路面上不同。当然还有重力,它不断地将汽车拉向地球(玩家可能不会注意到这一点,直到他试图跳过一个峡谷)!事实上,可能有成百上千的其他力联合起来产生了车辆的运动。

幸运的是,对于我们这些游戏程序员来说,我们只需要模拟其中的一些力,就可以创造出令人信服的运动错觉。一旦我们的模拟代码编写完成,我们就可以将它应用于游戏中的许多对象。举个例子,重力会影响一切(除非游戏设定在太空中),所以我们可以将重力相关的代码应用到任何物体上,无论是扔过来的手榴弹,还是从悬崖上掉下来的坦克,还是从空中飞过的斧头。

本章描述了如何以可预测的方式在屏幕上移动对象,以及如何在其他人的计算机上保持一致。

了解帧速率

关于电脑游戏中的运动,我们需要知道的第一件事是,没有什么是真正运动的——至少在任何物理意义上不是。电脑屏幕或电视机向我们展示了一系列图像,当图像之间的时间足够短时,我们的大脑会将这些图像混合起来,创造出一种流体运动的幻觉,就像一本翻书一样。产生*滑运动所需的图像数量,或称,因人而异。电影使用每秒 24 帧,但电脑游戏往往需要更快的帧速率。每秒 30 帧是一个不错的目标,但一般来说,帧速率越高,运动看起来就越*滑——尽管每秒 70 帧后,很少有人能察觉到任何改善,即使他们声称他们可以!

游戏的帧速率还受到显示设备(如显示器)每秒钟刷新次数的限制。例如,我的液晶显示器的刷新率为 60 赫兹,这意味着它每秒钟刷新显示器 60 次。生成比刷新率更快的帧会导致所谓的“撕裂”,即下一帧的一部分与前一帧相结合。

获得一个好的帧速率通常意味着牺牲视觉效果,因为你的电脑做的工作越多,帧速率就越慢。好消息是,你桌面上的电脑可能已经快得足以生成你想要的视觉效果了。

直线运动

让我们从研究简单的直线运动开始。如果我们每帧移动一幅图像一个固定的量,那么它看起来会移动。要水*移动它,我们将增加到 x 坐标,要垂直移动它,我们将增加到 y 坐标。清单 5-1 演示了如何水*移动图像。它在指定的 x 坐标绘制一个图像,然后将值 10.0 添加到每一帧,这样在下一帧它将会向右移动一点。当 x 坐标经过屏幕的右边缘时,它被设置回 0,这样它就不会完全消失。移动的 2D 图像通常被称为精灵

清单 5-1 。简单直线移动(simplemove.py)

import pygame
from pygame.locals import *
from sys import exit

background_image_filename = 'sushiplate.jpg'
sprite_image_filename = 'fugu.png'

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)

background = pygame.image.load(background_image_filename).convert()
sprite = pygame.image.load(sprite_image_filename)

# The x coordinate of our sprite
x = 0.

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    screen.blit(background, (0,0))
    screen.blit(sprite, (x, 100))
    x += 1

    # If the image goes off the end of the screen, move it back
    if x > 640:
        x -= 640

    pygame.display.update()

如果你运行清单 5-1 ,你会看到河豚图像从左向右滑动。这正是我们想要的效果,但是清单 5-1 的设计有一个缺陷。问题是我们无法确切知道将图像绘制到屏幕上需要多长时间。它看起来相当*滑,因为我们正在创建一个非常简单的框架,但在游戏中,绘制框架的时间将根据屏幕上的活动量而变化。我们不希望一个游戏在变得有趣的时候变慢。另一个问题是清单 5-1 中的精灵在功能较弱的计算机上会运行得较慢,而在功能较强的计算机上会运行得较快。

是时候了

解决这个问题的诀窍是让运动基于时间。我们需要知道从上一帧开始已经过了多长时间,这样我们就可以相应地在屏幕上定位所有的东西。pygame.time模块包含一个Clock对象,我们可以用它来记录时间。要创建一个时钟对象,调用它的构造函数pygame.time.Clock :

clock = pygame.time.Clock()

一旦你有了一个时钟对象,你应该每帧调用它的成员函数tick一次,该函数返回从上一次调用开始经过的时间,单位是毫秒(一秒钟有 1000 毫秒):

time_passed = clock.tick()

tick函数也为最大帧速率取一个可选参数。如果游戏在桌面上运行,您可能希望设置此参数,这样它就不会占用计算机的所有处理能力:

# Game will run at a maximum 30 frames per second
time_passed = clock.tick(30)

毫秒通常用于游戏中的事件计时,因为处理整数值比分数时间更容易,每秒 1,000 次时钟滴答声对于大多数游戏任务来说通常足够准确。也就是说,在处理速度等问题时,我通常更喜欢以秒为单位,因为对我来说,每秒 250 像素比每毫秒 0.25 像素更有意义。从毫秒到秒的转换就像除以 1,000 一样简单:

time_passed_seconds = time_passed / 1000.0

Image 注意如果你没有使用 Python 3+ 一定要除以一个浮点值 1000.0。如果不包括浮点,结果将向下舍入到最接*的整数!

那么我们如何使用time_passed_seconds来移动一个精灵呢?我们需要做的第一件事是为精灵选择一个速度。假设我们的精灵以每秒 250 像素的速度移动。在这个速度下,sprite 将在 2.56 秒内覆盖 640 像素屏幕的宽度(640 除以 250)。接下来我们需要计算出精灵从上一帧开始在这么短的时间内移动了多远,并将这个值加到 x 坐标上。数学很简单:只要将精灵的速度乘以time_passed_seconds。清单 5-2 在清单 5-1 的基础上增加了基于时间的移动,并且不管你运行精灵的计算机的速度如何,都会以相同的速度移动精灵。

清单 5-2 。基于时间的移动(timebasedmovement.py)

import pygame
from pygame.locals import *
from sys import exit

background_image_filename = 'sushiplate.jpg'
sprite_image_filename = 'fugu.png'

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)

background = pygame.image.load(background_image_filename).convert()
sprite = pygame.image.load(sprite_image_filename)

# Our clock object
clock = pygame.time.Clock()

# X coordinate of our sprite
x = 0

# Speed in pixels per second
speed = 250

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    screen.blit(background, (0,0))
    screen.blit(sprite, (x, 100))

    time_passed = clock.tick()
    time_passed_seconds = time_passed / 1000.0

    distance:moved = time_passed_seconds * speed
    x += distance:moved

    if x > 640:
        x -= 640

    pygame.display.update()

理解游戏中的帧速率和精灵速度之间的区别是很重要的。如果你在一台慢速电脑和一台快速电脑上并排运行清单 5-2 中的,那么河豚将会出现在每一个屏幕上的相同位置,但是与快速电脑相比,慢速电脑上的河豚移动将会不稳定。与其在两台不同的机器上运行这个脚本,不如让我们写一个脚本来模拟这种差异(见清单 5-3 )。

清单 5-3 。帧速率和速度比较(frameratecompare.py)

import pygame
from pygame.locals import *
from sys import exit

background_image_filename = 'sushiplate.jpg'
sprite_image_filename = 'fugu.png'

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)

background = pygame.image.load(background_image_filename).convert()
sprite = pygame.image.load(sprite_image_filename)

# Our clock object
clock = pygame.time.Clock()

x1 = 0
x2 = 0
# Speed in pixels per second
speed = 250

frame_no = 0

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    screen.blit(background, (0,0))
    screen.blit(sprite, (x1, 50))
    screen.blit(sprite, (x2, 250))

    time_passed = clock.tick(30)
    time_passed_seconds = time_passed / 1000.0

    distance:moved = time_passed_seconds * speed
    x1 += distance:moved

    if (frame_no % 5) == 0:
        distance:moved = time_passed_seconds * speed
        x2 += distance:moved * 5

    # If the image goes off the end of the screen, move it back
    if x1 > 640:
       x1 -= 640
    if x2 > 640:
       x2 -= 640

    pygame.display.update()
    frame_no += 1

如果你运行清单 5-3 中的,你会看到两个精灵在屏幕上移动。最上面的一个以每秒 30 帧的速度移动,或者在你的计算机允许的范围内尽可能*滑地移动;另一种通过每五帧更新一次来模拟慢速计算机。你应该看到,虽然第二个精灵的移动非常不*稳,但它实际上以相同的*均速度移动。因此,对于使用基于时间的运动的游戏,较低的帧速率将导致不太愉快的观看体验,但实际上不会减慢动作。

Image 注意虽然写得好的游戏在低帧率下仍然可以玩,但如果运动太不稳定,人们就会失去玩游戏的兴趣。就我个人而言,我不想玩一个运行速度远低于每秒 15 帧的游戏,它会变得非常令人迷惑!

对角线运动

直线运动是有用的,但是如果所有东西都水*或垂直移动,游戏可能会变得很无聊。我们需要能够在我们选择的任何方向移动精灵,我们可以通过调整每一帧的 x 和 y坐标来做到这一点。 清单 5-4 通过向两个坐标添加基于时间的移动来设置一个沿对角线方向移动的精灵。这个清单还添加了一些琐碎的“冲突检测”当精灵越过边缘时,它不会将精灵推回到初始位置,而是向相反的方向反弹。

清单 5-4 。简单的对角线移动(diagonalmovement.py)

import pygame
from pygame.locals import *
from sys import exit

background_image_filename = 'sushiplate.jpg'
sprite_image_filename = 'fugu.png'

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)

background = pygame.image.load(background_image_filename).convert()
sprite = pygame.image.load(sprite_image_filename).convert_alpha()

clock = pygame.time.Clock()

x, y = 100, 100
speed_x, speed_y = 133, 170

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    screen.blit(background, (0,0))
    screen.blit(sprite, (x, y))

    time_passed = clock.tick(30)
    time_passed_seconds = time_passed / 1000.0

    x += speed_x * time_passed_seconds
    y += speed_y * time_passed_seconds

    # If the sprite goes off the edge of the screen,
    # make it move in the opposite direction
    if x > 640 - sprite.get_width():
        speed_x = -speed_x
        x = 640 - sprite.get_width()
    elif x < 0:
        speed_x = -speed_x
        x = 0

    if y > 480 - sprite.get_height():
        speed_y = -speed_y
        y = 480 - sprite.get_height()
    elif y < 0:
        speed_y = -speed_y
        y = 0

    pygame.display.update()

为了实现这一反弹,我们首先必须意识到我们已经触及了一个边缘。这是通过一些简单的坐标数学计算完成的。如果 x 坐标小于 0,我们知道我们已经越过了屏幕的左边,因为左边的坐标是 0。如果 x 加上子画面的宽度大于屏幕的宽度,我们知道子画面的右边缘已经碰到了屏幕的右边缘。y 坐标的代码类似,但是我们使用子画面的高度而不是宽度:

if x > 640 – sprite.get_width():
     speed_x = –speed_x
     x = 640 – sprite.get_width()
elif x < 0:
     speed_x = –speed_x
     x = 0

我们已经看到,将基于时间的值添加到 sprite 的 x 和 y 坐标会创建一个对角线移动。在清单 5-4 中,我随机选择了speed_xspeed_y的值,因为在这个演示中,我并不关心精灵最终会出现在哪里。然而,在真实的游戏中,我们会希望为精灵选择一个最终目的地,并相应地计算speed_xspeed_y。最好的方法是用向量

探索矢量

我们使用两个值来生成对角线运动:一个是位置的 x 分量的速度,另一个是 y 分量的速度。这两个值的组合形式就是所谓的向量。向量是游戏开发者从数学中借用的东西,它们被用于许多领域,包括 2D 和 3D 游戏。

向量与点相似,都有 x 和 y 的值(在 2D),但它们有不同的用途。坐标(10,20)处的点将始终是屏幕上的同一个点,但是(10,20)的向量意味着从当前位置开始在 x 坐标上加 10,在 y 坐标上加 20。所以你可以认为一个点是一个从原点(0,0)开始的向量。

创建向量

您可以通过从第二个点中减去第一个点中的值来计算任意两个点的向量。让我们用一个虚构游戏的例子来演示一下。玩家角色——一个来自未来名叫阿尔法的机器人战士——必须用狙击步枪摧毁一个贝塔级的哨兵机器人。阿尔法躲在坐标 A (10,20)的灌木丛后面,瞄准坐标 B (30,35)的贝塔。为了计算到目标的矢量 AB,Alpha 必须从 a 中减去 B 的分量。因此矢量 AB 是(30,35)–(10,20),也就是(20,15)。这告诉我们,从 A 到 B,我们必须在 x 方向走 20 个单位,在 y 方向走 15 个单位(见图 5-1 )。游戏需要这些信息来激活投射武器或在两点之间画出激光束。

9781484209714_Fig05-01.jpg

图 5-1 。创建向量

存储矢量

Python 中没有内置的 vector 类型,但是对于所有的数字,最常用的模块是 NumPy。NumPy 是一个 C 优化模块,用于处理 Python 中各种数值运算。NumPy 的网址是:http://www.numpy.org/。要安装 NumPy,您很可能需要完成与安装 PyGame 相同的操作。最简单的方法是在命令行/终端中使用 pip 和

pip install numpy

虽然我们可以利用 NumPy,但我相信使用 NumPy 会混淆幕后实际发生的事情,尤其是如果这是您第一次使用矢量和矩阵进行游戏开发。在我们研究这些例子的时候,我将介绍如何自己编写向量和矩阵运算的程序,并为您指出模拟相同运算的 NumPy 功能的方向。

清单 5-5 。简单向量定义

class Vector2:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __str__(self):
        return "(%s, %s)"%(self.x, self.y)

为了定义一个向量,我们现在可以使用Vector2对象。例如,调用my_vector = Vector2(10, 20)会产生一个名为my_vectorVector2对象。我们可以将向量的分量分别称为my_vector.xmy_vector.y

我们应该添加到我们的Vector2类中的第一件事是从两点创建一个向量的方法,因为这是创建向量最常见的方法(见清单 5-6 )。

清单 5-6 。点的向量

class Vector2:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __str__(self):
        return "(%s, %s)"%(self.x, self.y)

    def from_points(P1, P2):
        return Vector2(P2[0] - P1[0], P2[1] - P1[1])

前面的操作(P2[0] - P1[0], P2[1] - P1[1]),可以通过导入 NumPy 模块然后做(numpy.array(P1) - numpy.array(P1))来实现。清单 5-7 展示了我们如何使用我们的类来创建两点之间的向量。

清单 5-7 。测试from_points方法

A = (10.0, 20.0)
B = (30.0, 35.0)
AB = Vector2.from_points(A, B)
print(AB)

执行此示例会产生以下输出:

(20.0, 15.0)

矢量幅度

从 A 到 B 的矢量的大小是这两点之间的距离。继续赛博士兵主题,阿尔法的燃料量有限,需要计算从 A 到 B 的距离才能知道他是否能到达 B,我们已经计算出矢量 AB 为(20,15)。星等会告诉我们他需要行进的距离。

要计算一个矢量的大小,先对分量求*方,将它们相加,然后求结果的*方根。所以一个矢量(20,15)的大小是 20 *20 + 15 *15 的*方根,也就是 25(见图 5-2 )。让我们给我们的Vector2添加一个方法来计算震级(清单 5-8 )。

9781484209714_Fig05-02.jpg

图 5-2 。创建向量

清单 5-8 。向量幅度函数

import math

class Vector2:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __str__(self):
        return "(%s, %s)"%(self.x, self.y)

    def from_points(P1, P2):
        return Vector2( P2[0] - P1[0], P2[1] - P1[1] )

    def get_magnitude(self):
        return math.sqrt( self.x**2 + self.y**2 )

A = (10.0, 20.0)
B = (30.0, 35.0)
AB = Vector2.from_points(A, B)
print(AB)
print(AB.get_magnitude())

(20.0, 15.0)
25.0

math.sqrt(self.x**2 + self.y**2行进行幅度计算。Python 中的**运算符将一个值提升到幂,因此我们可以像math.sqrt(self.x*self.x + self.y*self.y)一样轻松地编写计算。如果您要将 numpy 作为 np 导入,您可以通过以下方式执行类似的操作:

np.sqrt(T.dot(T))

前面是其中 T 是*移向量(在我们的例子中是 AB)。

最后几行创建一个测试向量,然后调用我们刚刚添加的get_magnitude。如果你手边有一些绘图纸,你可能想画出点 A 和 B,并验证两者之间的距离是 25.0。

单位向量

向量实际上描述了两件事:大小方向。例如,士兵 Alpha 可以使用向量 AB 来计算他必须行进多远(幅度),但是向量也告诉他应该面向哪个方向(方向)。通常这两条信息在一个向量中捆绑在一起,但偶尔你只需要其中一条。我们已经了解了如何计算幅度,但我们也可以通过将分量除以幅度来删除矢量中的幅度信息。这叫做归一化矢量,并产生一种特殊的矢量叫做单位矢量 。单位向量的长度始终为 1,通常用于表示方向。当我们进入第三维度时,你会发现它们对于从碰撞检测到照明的一切都是必不可少的。让我们给Vector2添加一个方法,该方法将向量归一化,并将其转换为单位向量(见清单 5-9 )。

清单 5-9 。测试单位向量法

import math

class Vector2:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __str__(self):
        return "(%s, %s)"%(self.x, self.y)

    def from_points(P1, P2):
        return Vector2( P2[0] - P1[0], P2[1] - P1[1] )

    def get_magnitude(self):
        return math.sqrt( self.x**2 + self.y**2 )

    def normalize(self):
        magnitude = self.get_magnitude()
        self.x /= magnitude
        self.y /= magnitude

A = (10.0, 20.0)
B = (30.0, 35.0)
AB = Vector2.from_points(A, B)
print("Vector AB is", AB)
print("Magnitude of Vector AB is", AB.get_magnitude())
AB.normalize()
print("Vector AB normalized is", AB)

执行该脚本会产生以下输出:

Vector AB is (20.0, 15.0)
Magnitude of Vector AB is 25.0
Vector AB normalized is (0.8, 0.6)

向量加法

向量加法将两个向量组合成一个向量,具有两者的组合效果。假设士兵阿尔法在拿起 B 点机器人守卫的东西后,必须在 C 点(15,45)与一艘补给船会合。从 B 到 C 的向量是(–15,10),这意味着他必须在 x 方向上后退 15 个单位,并在 y 方向上继续前进 5 个单位。如果我们把 BC 矢量的分量加到 AB 矢量上,我们得到一个从 A 到 C 的矢量(见图 5-3 )。

9781484209714_Fig05-03.jpg

图 5-3 。向量加法

要将矢量加法添加到我们的矢量库中,我们可以创建一个名为add的方法,然后调用AB.add(BC)返回 AB 和 BC 相加的结果,但是如果我们可以简单地调用AB+BC会更自然。Python 为我们提供了一种方法。通过定义一个名为 __ add__的特殊方法,我们可以让 Python 知道如何将Vector2的两个实例加在一起。当 Python 看到AB+BC时,会尝试调用AB. __add__(BC),所以我们要定义__add__返回一个包含计算结果的新对象。这被称为操作符过载。所有的基本运算符都有类似的特殊方法,比如减法()的__sub__和乘法(*)的__mul__。清单 5-10 用一个__add__方法扩展了 vector 类。

Image 注意如果你使用列表或元组来存储你的向量,不要试图用+ 运算符把它们加在一起。在 Python 中(1,2)+(3,4)是不是 (4,6);实际上是(1,2,3,4),这不是一个有效的 2D 向量。

清单 5-10 。将__add__方法添加到我们的Vector2

import math

class Vector2:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __str__(self):
        return "(%s, %s)"%(self.x, self.y)

    def from_points(P1, P2):
        return Vector2( P2[0] - P1[0], P2[1] - P1[1] )

    def get_magnitude(self):
        return math.sqrt( self.x**2 + self.y**2 )

    def normalize(self):
        magnitude = self.get_magnitude()
        self.x /= magnitude
        self.y /= magnitude

    # rhs stands for Right Hand Side
    def __add__(self, rhs):
        return Vector2(self.x + rhs.x, self.y + rhs.y)

A = (10.0, 20.0)
B = (30.0, 35.0)
C = (15.0, 45.0)

AB = Vector2.from_points(A, B)
BC = Vector2.from_points(B, C)

AC = Vector2.from_points(A, C)
print("Vector AC is", AC)

AC = AB + BC
print("AB + BC is", AC)

执行该脚本会产生以下输出:

Vector AC is (5.0, 25.0)
AB + BC is (5.0, 25.0)

向量减法

减去一个矢量意味着沿着矢量指向的相反的方向前进。如果士兵阿尔法被迫从一个装备精良的机器人面前撤退,他可能会计算出一个到对手的矢量,然后从他当前的位置减去这个矢量,找到他正后方的一个点。向量减法的数学与加法非常相似,但是我们从分量中减去而不是加上。清单 5-11 展示了一个从另一个向量中减去一个向量的方法,你可以把它添加到Vector2类中。注意,与典型的方法不同,这个方法有双下划线,就像您看到的 init 方法一样。由于我们已经创建了自己的对象类型,Python 不知道它可能具有或不具有什么样的属性。因此,为了让 python 像我们希望的那样处理减号这样的符号,我们需要添加处理这些动作的方法。

如果不添加这些方法,Python 中会出现一个错误,说明 TypeError:不支持的操作数类型-:“Vector2”和“vector 2”

清单 5-11 。向量减法

def __sub__(self, rhs):
        return Vector2(self.x - rhs.x, self.y - rhs.y)

向量否定

让我们假设士兵阿尔法到达了 B 点,却发现他忘了带备用电池;他怎么能算出一个向量回到 A(即向量 BA)?他可以让重新计算给定的分数,但是另一个选择是否定已经计算过的矢量 AB。对向量求反会创建一个指向相反方向的相同长度的向量。所以——AB 和 BA 是一样的。要求向量的反,只需求分量的反。清单 5-12 是一个做否定的成员函数,你可以把它添加到Vector2类中。

清单 5-12 。向量否定

def __neg__(self):
        return Vector2(-self.x, -self.y)

向量乘法和除法

也可以将一个向量乘以(或除以)一个标量(一个数),这具有改变向量长度的效果。简单地将每个分量乘以或除以标量值。清单 5-13 向我们的Vector2类添加了两个方法来实现乘法和除法功能。

清单 5-13 。向量乘法和除法

def __mul__(self, scalar):
        return Vector2(self.x * scalar, self.y * scalar)

def __truediv__(self, scalar):
        return Vector2(self.x / scalar, self.y / scalar)

如果你把任何一个向量乘以 2.0,它的大小就会翻倍;如果你把一个向量除以 2.0(或者乘以 0.5),它的大小会减半。将一个矢量乘以一个大于 0 的数会产生一个指向相同方向的矢量,但是如果你乘以一个小于 0 的数,产生的矢量会“翻转”并指向相反的方向(见图 5-4 )。

9781484209714_Fig05-04.jpg

图 5-4 。将一个向量乘以一个标量

Image 注意一个向量乘以另一个向量也是可能的,但是在游戏中并不常用,你可能永远也不会需要它。

那么,士兵阿尔法可能会如何使用向量乘法——或者更准确地说,游戏程序员会如何使用它?向量乘法有助于根据时间将向量分解成更小的步长。如果我们知道α可以在 10 秒内覆盖从 A 到 B 的距离,我们可以通过使用一点向量代码来计算α每秒后的坐标。清单 5-14 展示了如何使用Vector2类来做这件事。

清单 5-14 。计算位置

A = (10.0, 20.0)
B = (30.0, 35.0)
AB = Vector2.from_points(A, B)
step = AB * .1
position = Vector2(*A)
for n in range(10):
    position += step
    print(position)

这会产生以下输出:

(12.0, 21.5)
(14.0, 23.0)
(16.0, 24.5)
(18.0, 26.0)
(20.0, 27.5)
(22.0, 29.0)
(24.0, 30.5)
(26.0, 32.0)
(28.0, 33.5)
(30.0, 35.0)

在计算了点 A 和 B 之间的向量后,清单 5-14 创建了一个向量step,它是 AB 向量的十分之一。循环内部的代码将这个值添加到position,这是我们将用来存储 Alpha 当前位置的另一个向量。我们这样做十次,阿尔法旅程的每一秒一次,在我们前进的时候打印出当前的position向量。最终,经过十次迭代,我们到达了 B 点,安然无恙!如果你得到输出并画出这些点,你会看到它们从 A 到 b 形成了一条完美的直线。

在两点之间移动时,像这样计算中间位置是很重要的。您还可以使用向量来计算重力、外力和摩擦力下的运动,以创建各种真实的运动。

使用矢量来创造运动

现在我们已经介绍了向量,我们可以使用它们以各种方式移动游戏角色,并实现简单的基于力的物理,使游戏更有说服力。

对角线运动

让我们使用矢量来创建更准确的对角线运动。我们如何以恒定的速度将精灵从屏幕上的一个位置移动到另一个位置?第一步是创建一个从当前位置到目的地的向量(使用Vector2.from_points或类似的东西)。我们只需要这个向量中的方向信息,而不需要大小,所以我们对它进行归一化,得到精灵的方向。在游戏循环中,我们计算精灵用speed * time_passed_seconds移动了多远,然后乘以方向向量。产生的矢量给了我们xy自前一帧以来的变化,所以我们把它添加到精灵位置。

为此,我们需要修改向量。在这里,我们正在创建自己的对象,我们获得并需要修改的所有信息都是一个“Vector2”对象。这就是为什么我们必须添加像 addsub,等等这样的方法。要修改这些数据,我们还需要几个方法:getitemsetitem。这些方法允许我们引用一个索引,并将该索引的值设置为其他值。我们在这里不使用它,但是如果您想要移除或删除一个索引,您将需要一个 delitem 方法。最后,我们需要为 getitem 方法添加一些处理,允许我们的数据被视为一个列表。

清单 5-15 。向矢量脚本添加 getitem 和 setitem,并修改 init 方法

def __init__(self, x=0, y=0):
    self.x = x
    self.y = y

    if hasattr(x, "__getitem__"):
        x, y = x
        self._v = [float(x), float(y)]
    else:
        self._v = [float(x), float(y)]

def __getitem__(self, index):
    return self._v[index]

def __setitem__(self, index, value):
    self._v[index] = 1.0 * value

游戏对象向量类

我们之前构建的Vector2类对于基础向量数学来说已经足够好了,你可以用它作为你自己的向量类的起点(几乎每个游戏开发者都在某个时候写过向量类!).然而,要快速启动并运行,你可以使用我作为游戏对象的一部分编写的Vector2类,这是一个简化编写游戏的框架。您可以从https://github.com/PythonProgramming/Beginning-Game-Development-with-Python-and-Pygame?? 下载游戏物品。

Vector2类是gameobjects名称空间中更大的类集合的一部分。您可以从 GitHub 下载 gameobjects 库(如前所列),或者您可以从我们刚刚构建的 vector 功能开始构建自己的库!要做到这一点,你需要做的就是,在你正在编写的 python 脚本所在的目录/文件夹中,添加一个新文件夹,命名为 gameobjects。现在,以我们一直在做的脚本为例,将其命名为 vector2.py。确保 vector2.py 只包含类和其中的方法,删除类外的行。这意味着您应该删除以下内容:

A = (10.0, 20.0)
B = (30.0, 35.0)
AB = Vector2.from_points(A, B)
step = AB * .1
position = Vector2(*A)
for n in range(10):
    position += step
    print(position)

如果您将代码(如前面的代码)留在模块中,然后导入该模块,则剩余的代码将在您导入脚本时运行。当您导入某些内容时,就好像您已经运行了那批代码。您可以做的一件事是使用 if 语句来检查模块是否正在独立运行,或者它是否已经被导入。为此,您应该这样做:

if __name__ == "__main__":
    A = (10.0, 20.0)
    B = (30.0, 35.0)
    AB = Vector2.from_points(A, B)
    step = AB * .1
    position = Vector2(*A)
    for n in range(10):
        position += step
        print(position)

现在你应该有一个 gameobjects 目录,和你正在编写的脚本在同一个目录中,vector2.py 应该在 gameobjects 目录中。

清单 5-16 使用向量实现基于时间的移动。当你运行它时,你会看到一个精灵一动不动地停在屏幕上,但一旦你点击屏幕,代码将计算一个向量到新的位置,并设置精灵以每秒 250 像素的速度移动。如果你再次点击,将会计算出一个新的向量,精灵将会改变它的方向朝向鼠标。

清单 5-16 。使用向量进行基于时间的移动(vectormovement.py)

import pygame
from pygame.locals import *
from sys import exit
from gameobjects.vector2 import Vector2

background_image_filename = 'sushiplate.jpg'
sprite_image_filename = 'fugu.png'

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)

background = pygame.image.load(background_image_filename).convert()
sprite = pygame.image.load(sprite_image_filename).convert_alpha()

clock = pygame.time.Clock()

position = Vector2(100.0, 100.0)
speed = 250
heading = Vector2()

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()
        if event.type == MOUSEBUTTONDOWN:
            destination = Vector2(*event.pos) - (Vector2(*sprite.get_size())/2)
            heading = Vector2.from_points(position, destination)
            heading.normalize()

    screen.blit(background, (0,0))
    screen.blit(sprite, (position.x, position.y))

    time_passed = clock.tick()
    time_passed_seconds = time_passed / 1000.0

    distance:moved = time_passed_seconds * speed
    position += heading * distance:moved
    pygame.display.update()

目的地计算可能需要一点解释。它使用Vector2类找到一个点,将我们的精灵直接放在鼠标坐标上。当用在函数调用的参数前时,*符号展开一个元组或列表。所以Vector2(*event.pos)相当于Vector2(event.pos[0], event.pos[1]),会用鼠标的位置创建一个矢量。类似的代码用于创建一个向量,该向量包含 sprite 图形的一半尺寸。像这样使用向量可能会被认为是滥用数学概念,但如果它能节省我们一点时间,这是值得的。清单 5-17 展示了我们如何在不滥用向量的情况下重写计算。

清单 5-17 。长距离计算目的地坐标

destination_x = event.pos[0] – sprite.get_width()/2.0
destination_y = event.pos[1] – sprite.get_height()/2.0
destination = (destination_x, destination_y)

摘要

移动精灵或屏幕上的任何其他东西,都需要在每一帧的坐标上添加小值,但如果您希望移动*滑且一致,则需要基于当前时间,或者更具体地说,是自上一帧以来的时间。使用基于时间的运动对于在尽可能多的计算机上运行游戏也很重要——计算机可以在每秒钟生成的帧数方面有很大差异。

我们已经介绍了矢量,它是任何游戏开发者工具箱中必不可少的一部分。向量简化了你在编写游戏时要做的大量数学工作,你会发现它们非常通用。如果你想花时间来构建我们在本章中探索的Vector2类,这是非常值得做的,但是你可以使用游戏对象库中的Vector2类(你可以从书中的源代码下载这个)来节省时间。这也是我们将在接下来的章节中用到的。

二维运动的技术很容易扩展到三维。您会发现,Vector3类包含了许多在Vector2类中使用的方法,但是增加了一个组件(z)。

现在是开始尝试在屏幕上移动东西和混合各种动作的好时机。通过在屏幕上创建您的朋友和家人的图形,并让他们滑动和弹跳,可以获得很多乐趣!

在下一章,你将学习如何连接输入设备,比如键盘和游戏杆到精灵,这样玩家就可以和游戏世界互动。

六、接受用户输入

玩家可以通过多种方式与游戏互动,本章将详细介绍各种输入设备。除了从设备中检索信息,我们还将探索如何将玩家所做的转化为游戏中有意义的事件。这对任何游戏来说都是极其重要的——不管一个游戏看起来和听起来有多好,它也必须易于交互。

控制游戏

在一台典型的家用电脑上,我们可以非常依赖键盘和鼠标。这种古老的设置是第一人称射击爱好者的首选,他们喜欢用鼠标控制头部移动(即四处张望),并保持一只手在键盘上进行方向控制和射击。键盘 可以通过为四个基本方向分配一个键来进行运动:上、下、左、右。通过组合按下这些键(上+ 右、下+ 右、下+ 左、上+ 左),可以指示另外四个方向。但是八个方向对于大多数游戏来说仍然是非常有限的,所以它们不太适合需要一点技巧的游戏。

大多数玩家更喜欢鼠标这样的模拟设备。标准鼠标是方向控制的理想选择,因为它可以准确地检测到任何方向的任何东西,从微小的调整到快速的扫描。如果你玩过仅用键盘控制的第一人称射击游戏,你就会体会到其中的不同。按下“向左旋转”键会使玩家像机器人一样匀速旋转,这远不如能够快速转身射击从侧面靠*的怪物有用。

键盘和鼠标很适合玩游戏,这可能有点讽刺,因为这两款设备在设计时都没有考虑到游戏。然而,操纵杆和游戏手柄、纯粹是为游戏而设计的,并随着他们用来玩的游戏一起发展。第一个操纵杆是模仿飞机上使用的控制器设计的,有一个简单的方向杆和一个按钮。它们在当时的游戏控制台中很受欢迎,但玩家发现必须用一只手抓住操纵杆基座,同时用另一只手移动操纵杆是不舒服的。这导致了游戏手柄的发展,这种手柄可以用双手握着,但仍然可以让玩家很容易地用手指和拇指控制。第一个游戏手柄的一边是方向控制,另一边是触发按钮。如今,游戏手柄上有许多按钮,到处都有多余的手指可以按下它们,还有几根棍子。经典的方向键仍然可以在游戏手柄上找到,但大多数也有可以检测微调的模拟杆。许多游戏还有力反馈功能,它可以通过让游戏手柄响应屏幕上的事件而震动或发出隆隆声来为游戏增加额外的维度。毫无疑问,未来还会有其他功能添加到游戏手柄中,进一步增强游戏体验。

还有其他设备可以用于游戏,但大多数都是模仿标准的输入设备。因此,如果你的游戏可以用鼠标玩,它也可以用这些类似鼠标的设备玩。游戏输入设备不断改进;考虑一下虚拟现实的现状,比如 Oculus Rift,它模拟了一些你在游戏中以“自由视角”操作鼠标时可能会遇到的情况。然而,这里的不同之处在于,你现在有了类似鼠标的设备、键盘,然后是 Oculus Rift,可以为游戏提供更多输入。我想你已经确信输入和处理输入很重要,让我们来谈谈键盘吧!

了解键盘控制

今天使用的大多数键盘是 QWERTY 键盘,这样称呼是因为第一行字母的前六个字母拼成 QWERTY。品牌之间存在差异;按键的形状和大小可能略有不同,但它们在键盘上的位置大致相同。这是一件好事,因为电脑用户不想每次买了新键盘都要重新学习如何打字。我用过的所有键盘都有五排标准的打字键:一排用于功能键 F1-F12,四个光标键用于在屏幕上移动插入符号。他们也有一个“数字小键盘”,这是一组用于输入数字和做加法的按键,以及其他一些杂七杂八的按键。为了节省空间,笔记本电脑键盘上的数字小键盘经常被省略,因为数字键在键盘的主要部分是重复的。我们可以用pygame.key模块检测所有这些按键。

Image 注意虽然 QWERTY 是最常见的,但它并不是唯一的键盘布局;还有其他键盘如 AZERTY 和 Dvorak 。可以使用相同的键盘常量,但是按键可能位于键盘上的不同位置。如果您让玩家选择他们自己的键在游戏中使用,他们可以选择最适合他们键盘的控制键。

检测按键

在 Pygame 中有两种方法可以检测按键。一种方法是处理KEYDOWN事件,当一个键被按下时发出,以及KEYUP事件,当该键被释放时发出。这对于输入文本非常有用,因为即使从上一帧开始按下键,我们也会得到键盘事件。事件还会捕捉到快速点击点火按钮的按键。但是当我们使用键盘输入进行移动时,我们只需要在绘制下一帧之前知道该键是否被按下。在这种情况下,我们可以更直接地使用pygame.key模块。

键盘上的每个键都有一个与之关联的键常量,它是一个我们可以用来在代码中识别该键的值。每个常数以K_开头。有字母(K_aK_z)、数字(K_0K_9),还有很多其他的常量比如K_f1K_LEFTK_RETURN。完整列表见 Pygame 文档(https://www.pygame.org/docs/ref/key.html)。因为K_aK_z都有常量,你可能会期望有等价的大写版本——但是没有。之所以这样,是因为大写字母是组合键(Shift + key)的结果。如果您需要检测大写字母或其他移位的键,请在包含此类组合键结果的键事件中使用Unicode参数。

我们可以使用pygame.key.get_pressed函数来检测一个键是否被按下。它返回一个布尔值列表 ( TrueFalse值),每个关键常量一个。要查找一个特定的键,使用它的常量作为按键列表的索引。例如,如果我们使用空格键作为发射按钮,我们可以编写如下的触发代码:

pressed_keys = pygame.key.get_pressed()
if pressed_keys[K_SPACE]:
    # Space key has been pressed
    fire()

应该讨论的是,各种键盘具有关于它们支持多少同时按键的各种规则。如果你看看游戏键盘,你会发现它们几乎总是列出支持多少个同时按键作为卖点。许多廉价键盘只支持 3-5 个同时按键,而游戏键盘可以支持 25 个或更多。

在许多游戏中,你可能有一个关键蹲下,向前移动,扫射一点,慢慢移动。也许当你在做这种斜蹲慢射的时候,你想通过按下数字 5 来切换到你的手榴弹。你可能会发现按 5 的时候什么都不会发生!恐怖啊!要是你买了 Pro Gamer X“9000 多”键盘就好了!

你不仅应该考虑到你的玩家可能没有最好的键盘,记住他们总共只有十个手指,但通常只有五个手指可以真正使用,因为一只手可能放在鼠标上。然而,有很多游戏玩家会用一个手指按下两个或更多的键。

此外,你可能要考虑那些使用游戏手柄的人,他们的按钮数量可能非常有限。

记住所有这些,让我们写一个脚本来试验键盘。清单 6-1 使用get_pressed函数检测任何被按下的键,并在屏幕上显示它们的列表。

清单 6-1 。测试按下的按键(keydemo.py)

import pygame
from pygame.locals import *
from sys import exit

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

font = pygame.font.SysFont("arial", 32);
font_height = font.get_linesize()

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    screen.fill((255, 255, 255))

    pressed_key_text = []
    pressed_keys = pygame.key.get_pressed()
    y = font_height

    for key_constant, pressed in enumerate(pressed_keys):
        if pressed:
            key_name = pygame.key.name(key_constant)
            text_surface = font.render(key_name+" pressed", True, (0,0,0))
            screen.blit(text_surface, (8, y))
            y+= font_height

        pygame.display.update()

在清单 6-1 获得作为布尔值列表的按键后,它进入一个for循环,遍历每个值。您会注意到,该循环通过调用enumerate间接迭代按下的键,这是一个内置函数,它返回索引的元组(第一个值为 0,第二个值为 1,依此类推)和迭代列表中的值。如果迭代的值(pressed)是True,那么那个键被按下了,我们输入一个小代码块在屏幕上显示出来。这个代码块使用了键盘模块中的另一个函数pygame.key.name,它接受一个按键常量并返回一个带有按键描述的字符串(也就是说,它将K_SPACE变成了"space")。

在这里,你可以测试你的键盘它支持多少个键?您可能会发现,在某些情况下,您可以支持多达 8 个或更多,但在其他情况下,可能只有 4 个,或者类似的情况。这是由于键盘的工作方式(参见:http://www.sjbaker.org/wiki/index.php?title=Keyboards_Are_Evil了解更多信息)。我有一个 2005 年的键盘,它只支持清单 6-1 中的的四个键,但是我的主键盘是一个游戏键盘,我可以按下尽可能多的键。然而,我没有试图用我的脚。

你可能会注意到,当你运行清单 6-1 时,它会显示numlock pressed,尽管你当时并没有接触它。这是因为 numlock 是一个特殊的键,可以在键盘上切换状态。打开后,数字小键盘可以用来输入数字。但当它关闭时,数字小键盘用于滚动和导航文本的其他键。另一个类似的键是 Caps Lock 键。如果你点击 Caps Lock,清单 6-1 将显示caps lock pressed,即使它已经被释放。再次轻按以停用大写锁定状态。还有一个键可以做到这一点,那就是滚动锁(在个人电脑键盘上),现在已经不常使用了。这三个键不应该用作触发器,因为 Pygame 无法改变这种行为。

让我们更详细地看一下pygame.key模块:

  • 一个 Pygame 窗口只在窗口被聚焦时接收关键事件,通常是通过点击窗口标题栏。所有顶层窗口都是如此。如果窗口有焦点并且可以接收按键事件,get_focused函数返回True;否则返回False。当 Pygame 以全屏模式运行时,它将始终拥有焦点,因为它不必与其他应用共享屏幕。
  • key.get_pressed—返回每个键的布尔值的列表。如果任何值被设置为True,则该索引的键被按下。
  • key.get_mods—返回单个值,指示按下了哪个修饰键。修饰键是与其他键结合使用的键,如 Shift、Alt 和 Ctrl。要检查修改键是否被按下,使用带有一个KMOD_常量的按位 AND 运算符(&)。例如,要检查左 Shift 键是否被按下,您可以使用pygame.key.get_mods() & KMOD_LSHIFT
  • pygame.key.set_mods—您也可以设置一个修饰键来模仿按键被按下的效果。要设置一个或多个修饰键,请将KMOD_常量与按位 or 运算符(|)结合使用。例如,要设置 Shift 和 Alt 键,你可以使用pygame.key.set_mods(KMOD_SHIFT | KMOD_ALT)
  • pygame.key.set_repeat—如果你打开你最喜欢的文本编辑器,按住一个字母键,你会看到短暂的延迟后该键开始重复,如果你想多次输入一个字符,而不是多次按下和释放,这很有用。您可以使用set_repeat函数让 Pygame 向您发送重复的KEY_DOWN事件,该函数获取一个键重复之前的初始延迟值和一个重复键之间的延迟值。这两个值都以毫秒为单位(每秒 1000 毫秒)。您可以通过不带参数调用set_repeat来禁用按键重复。
  • pygame.key.name—此函数采用一个KEY_常量,并返回该值的描述性字符串。这对于调试很有用,因为当代码运行时,我们只能看到一个关键常量的值,而看不到它的名字。例如,如果我得到一个值为 103 的键的KEY_DOWN事件,我可以使用key.name打印出该键的名称(在本例中是“g”)。

用键进行方向移动

您可以使用键盘通过分配上、下、左、右键在屏幕上移动精灵。任何键都可以用于定向移动,但最明显的是光标键,因为它们是为定向移动而设计的,并且放在合适的位置,可以单手操作。第一人称射击爱好者也习惯于使用 W、A、S 和 D 键来移动。

那么我们如何把按键变成定向运动呢?与大多数类型的运动一样,我们需要创建一个指向我们想要去的方向的方向向量。如果只按下四个方向键中的一个,航向矢量就相当简单。表 6-1 列出了四个基本方向矢量。

表 6-1 。简单方向向量

|

方向

|

矢量

|
| --- | --- |
| 左边的 | -1, 0 |
| 对吧 | +1, 0 |
| 起来 | 0, -1 |
| 向下 | 0, 1 |

除了水*和垂直移动,我们希望用户能够通过同时按下两个键来对角移动。例如,如果按下向上键和向右键,精灵应该沿对角线向屏幕的右上角移动。我们可以通过添加两个简单的向量来创建这个对角向量。如果我们把(0.0, –1.0)和右边的(1.0, 0,0)加起来,就得到了(1.0, –1.0),它把指向右边,但是我们不能用这个作为航向矢量,因为它不再是单位矢量(长度为 1)。如果我们把它作为一个方向向量,我们会发现我们的精灵在对角线上的移动速度比垂直或水*方向都要快,这并不是很有用。

在我们使用我们计算的航向之前,我们应该通过归一化把它变回一个单位向量,这给我们一个大约为(0.707, –0.707)的航向。参见图 6-1 中从简单向量计算出的对角向量的直观描述。

9781484209714_Fig06-01.jpg

图 6-1 。通过组合简单向量得到对角向量

清单 6-2 实现了这种定向运动。当您运行它时,您会看到一个 sprite,它可以通过按下任何光标键来水*或垂直移动,或者通过同时按下两个光标键来对角移动。

如果您运行清单 6-2 而不修改我们的 vector2.py 文件,您会发现一个错误,这个错误是由于试图将某个数除以零而产生的。您可以在 normalize 方法中处理这个问题:

def normalize(self):
        magnitude = self.get_magnitude()

        try:
            self.x /= magnitude
            self.y /= magnitude
        except ZeroDivisionError:
            self.x = 0
            self.y = 0

如果你试图除以零,那么 x 和 y 一定是零。因此,你可以像前面的例子一样很容易地解决它。现在我们准备好让一些东西动起来:

清单 6-2 。简单方向移动(keymovement.py)

import pygame
from pygame.locals import *
from sys import exit
from gameobjects.vector2 import Vector2

background_image_filename = 'sushiplate.jpg'
sprite_image_filename = 'fugu.png'

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)
background = pygame.image.load(background_image_filename).convert()
sprite = pygame.image.load(sprite_image_filename).convert_alpha()

clock = pygame.time.Clock()

sprite_pos = Vector2(200, 150)
sprite_speed = 300

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    pressed_keys = pygame.key.get_pressed()

    key_direction = Vector2(0, 0)

    if pressed_keys[K_LEFT]:
        key_direction.x = -1
    elif pressed_keys[K_RIGHT]:
         key_direction.x = +1
    if pressed_keys[K_UP]:
        key_direction.y = -1
    elif pressed_keys[K_DOWN]:
        key_direction.y = +1

    key_direction.normalize()

    screen.blit(background, (0,0))
    screen.blit(sprite, (sprite_pos.x,sprite_pos.y))

    time_passed = clock.tick(30)
    time_passed_seconds = time_passed / 1000.0

    sprite_pos += key_direction * sprite_speed * time_passed_seconds

    pygame.display.update()

清单 6-2 在计算方向向量时作弊了一点。如果按下K_LEFTK_RIGHT,则将 x 分量设置为–1 或+1,如果按下K_UPK_DOWN,则将 y 分量设置为–1 或+1。这与将两个简单的水*和垂直航向矢量相加的结果相同。如果你曾经看到一个数学捷径,让你用更少的代码做一些事情,请随意尝试一下——游戏开发者发现他们积累了许多这样节省时间的宝石!

你可能已经注意到,只有八个矢量用于这个矢量运动。如果我们预先计算这些向量,并将它们直接插入到代码中,我们可以减少运行脚本时所做的工作量。如果你喜欢挑战,这是一个值得做的练习,但由于计算方向向量每帧只做一次,加速它不会对帧速率产生明显的影响。留意像这样的情况;减少游戏创建一个画面所需的工作量被称为优化,当有大量动作发生时,这变得更加重要。

用键旋转运动

向八个方向移动有点人为,因为在现实生活中你不会看到很多东西像这样移动。大多数可移动的东西可以自由旋转,但只能向它们所指的方向移动,或者向后移动——但绝对不止八个方向。我们仍然可以使用相同的上、下、左、右键来模拟这种情况,但是我们必须改变这些键控制的内容。我们想要做的是用左右键来控制旋转,用前后键来控制移动,这将给我们的精灵向任何方向移动的能力。清单 6-3 使用完全相同的一组按键,但是使用这个自由旋转控件在屏幕上移动精灵。

清单 6-3 。自由旋转控制(keyrotatemovement.py)

import pygame
from pygame.locals import *
from sys import exit
from gameobjects.vector2 import Vector2
from math import *

background_image_filename = 'sushiplate.jpg'
sprite_image_filename = 'fugu.png'

pygame.init()

screen = pygame.display.set_mode((640, 480), 0, 32)

background = pygame.image.load(background_image_filename).convert()
sprite = pygame.image.load(sprite_image_filename).convert_alpha()

clock = pygame.time.Clock()

sprite_pos = Vector2(200, 150)
sprite_speed = 300
sprite_rotation = 0
sprite_rotation_speed = 360 # Degrees per second

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()
    pressed_keys = pygame.key.get_pressed()

    rotation_direction = 0.
    movement_direction = 0.

    if pressed_keys[K_LEFT]:
        rotation_direction = +1.0
    if pressed_keys[K_RIGHT]:
        rotation_direction = -1.0
    if pressed_keys[K_UP]:
        movement_direction = +1.0
    if pressed_keys[K_DOWN]:
        movement_direction = -1.0

    screen.blit(background, (0,0))

    rotated_sprite = pygame.transform.rotate(sprite, sprite_rotation)
    w, h = rotated_sprite.get_size()
    sprite_draw_pos = Vector2(sprite_pos.x-w/2, sprite_pos.y-h/2)
    screen.blit(rotated_sprite, (sprite_draw_pos.x,sprite_draw_pos.y))

    time_passed = clock.tick()
    time_passed_seconds = time_passed / 1000.0

    sprite_rotation += rotation_direction * sprite_rotation_speed * time_passed_seconds

    heading_x = sin(sprite_rotation*pi/180.0)
    heading_y = cos(sprite_rotation*pi/180.0)
    heading = Vector2(heading_x, heading_y)
    heading *= movement_direction

    sprite_pos+= heading * sprite_speed * time_passed_seconds

    pygame.display.update()

清单 6-3 的工作方式与前一个例子相似,但是它计算航向的方式不同。我们不是通过按下任何键来创建方向向量,而是通过子画面的旋转来创建方向向量(存储在变量sprite_rotation中)。当按下适当的键时,我们修改的就是这个值。当右键被按下时,我们增加精灵旋转,把它转向一个方向。当左键被按下时,我们从旋转中减去,使它转向相反的方向。

为了从旋转中计算航向矢量,我们必须计算角度的正弦余弦,这可以用math模块中的sincos函数来完成。sin函数计算 x 分量,cos计算 y 分量。这两个函数都以弧度表示角度(一个圆有 2 * pi 弧度),但是因为清单 6-3 使用度数,它必须通过乘以 pi 并除以 180°将旋转转换成弧度。在我们从两个组件中组合航向矢量后,它可以像以前一样用于给我们基于时间的运动。

在清单 6-3 显示精灵之前,精灵被旋转,这样我们可以看到它面向哪个方向。这是通过来自transform模块的一个名为rotate的函数来完成的,该函数获取一个表面加上一个角度,并返回一个包含旋转精灵的新表面。这个函数的一个问题是返回的精灵可能与原始精灵的尺寸不同(见图 6-2 ),所以我们不能将它 blit 到与未旋转的精灵相同的坐标,否则它将被绘制在屏幕上的错误位置。解决这个问题的一个方法是在一个位置绘制 sprite,将 sprite 图像的中心放在 sprite 在屏幕上的位置的下面。这样,不管旋转表面的大小,精灵都将在屏幕上的相同位置。

9781484209714_Fig06-02.jpg

图 6-2 。旋转曲面会改变其大小

实现鼠标控制

鼠标的历史几乎和键盘一样长。多年来,鼠标的设计没有太大变化——设备变得更符合人体工程学(手形),但设计保持不变。

经典鼠标下面有一个橡皮球,可以在桌子或鼠标垫上滚动。球的运动由鼠标内部与球接触的两个滚轮获得。现在,几乎所有的鼠标都是激光鼠标,精度更高。朋友们可以恶作剧移除彼此的鼠标球的日子已经一去不复返了,但现在我们可以用胶带纸盖住激光孔,也同样有趣。

随着时间的推移,越来越多的鼠标也有了各种按钮。几乎所有的电脑都有一个可以滚动的鼠标滚轮,可以作为第三个遥控器。还有很多鼠标都有各种各样的按钮。

游戏经常利用这些鼠标创新。额外的按钮总是方便快捷地进入游戏控制,鼠标滚轮可以在第一人称射击游戏中切换武器!当然,当用狙击枪干掉敌人时,激光鼠标的额外准确性总是很有用。

用鼠标旋转移动

您已经看到在屏幕上绘制鼠标光标非常简单:您只需从MOUSEMOTION事件或直接从pygame.mouse.get_pos函数中获得鼠标的坐标。如果您只想显示鼠标光标,这两种方法都可以,但是鼠标移动也可以用于控制绝对位置以外的其他东西,例如在 3D 游戏中旋转或上下查看。在这种情况下,我们不能直接使用鼠标位置,因为坐标将被限制在屏幕的边缘,我们不希望玩家被限制向左或向右转动的次数!在这些情况下,我们希望获得鼠标的相对运动,通常称为鼠标鼠标键,这只是意味着从上一帧开始鼠标已经移动了多远。清单 6-4 在精灵演示中增加了鼠标旋转运动。除了光标键,当鼠标向左或向右移动时,精灵也会旋转。

Image 警告如果你打算复制、粘贴并运行这段代码,请注意,要退出,你将而不是能够使用你的鼠标。您将使用键盘上的 Esc 键退出游戏!

清单 6-4 。旋转鼠标移动(mouserotatemovement.py)

import pygame
from pygame.locals import *
from sys import exit
from gameobjects.vector2 import Vector2
from math import *

background_image_filename = 'sushiplate.jpg'
sprite_image_filename = 'fugu.png'

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

background = pygame.image.load(background_image_filename).convert()
sprite = pygame.image.load(sprite_image_filename).convert_alpha()

clock = pygame.time.Clock()

pygame.mouse.set_visible(False)
pygame.event.set_grab(True)

sprite_pos = Vector2(200, 150)
sprite_speed = 300.
sprite_rotation = 0.
sprite_rotation_speed = 360\. # Degrees per second

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()
        if event.type == KEYDOWN:
            if event.key == K_ESCAPE:
                pygame.quit()
                exit()

    pressed_keys = pygame.key.get_pressed()
    pressed_mouse = pygame.mouse.get_pressed()

    rotation_direction = 0.
    movement_direction = 0.

    rotation_direction = pygame.mouse.get_rel()[0] / 3.

    if pressed_keys[K_LEFT]:
        rotation_direction = +1.
    if pressed_keys[K_RIGHT]:
        rotation_direction = -1.
    if pressed_keys[K_UP] or pressed_mouse[0]:
        movement_direction = +1.
    if pressed_keys[K_DOWN] or pressed_mouse[2]:
        movement_direction = -1.
    screen.blit(background, (0,0))

    rotated_sprite = pygame.transform.rotate(sprite, sprite_rotation)
    w, h = rotated_sprite.get_size()
    sprite_draw_pos = Vector2(sprite_pos.x-w/2, sprite_pos.y-h/2)
    screen.blit(rotated_sprite, (sprite_draw_pos.x, sprite_draw_pos.y))

    time_passed = clock.tick()
    time_passed_seconds = time_passed / 1000.0

    sprite_rotation += rotation_direction * sprite_rotation_speed * time_passed_seconds

    heading_x = sin(sprite_rotation*pi/180.)
    heading_y = cos(sprite_rotation*pi/180.)
    heading = Vector2(heading_x, heading_y)
    heading *= movement_direction

    sprite_pos+= heading * sprite_speed * time_passed_seconds

    pygame.display.update()

要使用鼠标控制精灵旋转,清单 6-4 必须为鼠标启用虚拟无限区域,这可以防止 Pygame 将鼠标限制在物理屏幕区域。这是通过以下两行完成的:

pygame.mouse.set_visible(False)
pygame.event.set_grab(True)

调用set_visible(False)关闭鼠标光标,调用set_grab(True) 抓取鼠标,这样 Pygame 就完全控制了它。这样做的一个副作用是你不能使用鼠标来关闭窗口,所以你必须提供一个关闭脚本的替代方法(清单 6-4 如果按下 Esc 键就会退出)。以这种方式使用鼠标也使得使用其他应用变得困难,这就是为什么它通常最好在全屏模式下使用。

清单 6-4 中的下面一行获取 x 轴的鼠标按键(在索引[0]处)。它将它们除以 5,因为我发现使用该值直接旋转 sprite 太快了。您可以调整该值来更改鼠标灵敏度(数字越大,鼠标控制的灵敏度越低)。游戏通常让玩家在偏好菜单中调整鼠标灵敏度。

rotation_direction = pygame.mouse.get_rel()[0] / 5.

除了用鼠标旋转之外,这些按钮还用于向前(鼠标左键)和向后(鼠标右键)移动精灵。检测按下的鼠标按钮类似于按键。函数pygame.mouse.get_pressed()返回鼠标左键、鼠标中键和鼠标右键的三个布尔元组。如果有任何一个是True,那么在调用时相应的鼠标按钮被按住。使用此函数的另一种方法如下,它将元组解包为三个值:

lmb, mmb, rmb = pygame_mouse.get_pressed()

让我们更详细地看看pygame.mouse。这是另一个简单的模块,只有八个功能:

  • pygame.mouse.get_pressed—返回按下的鼠标按钮,作为三个布尔值的元组,一个用于鼠标左键、中键和右键。
  • pygame.mouse.get_rel—将相对鼠标移动(或鼠标键)作为具有 x 和 y 相对移动的元组返回。
  • pygame.mouse.get_pos—以 x 和 y 值的元组形式返回鼠标坐标。
  • pygame.mouse.set_pos—设置鼠标位置。将坐标作为 x 和 y 的元组或列表。
  • pygame.mouse.set_visible—改变标准鼠标光标的可见性。如果False,光标将不可见。
  • pygame.mouse.get_focused—如果 Pygame 窗口正在接收鼠标输入,则返回True。当 Pygame 在一个窗口中运行时,它只会在窗口被选中并且位于显示屏前面时接收鼠标输入。
  • pygame.mouse.set_cursor—设置标准光标图像。这是很少需要的,因为更好的结果可以通过将图像块传输到鼠标坐标来实现。
  • pyGame.mouse.get_cursor—获取标准光标图像。见上一条。

鼠标游戏

不起眼的鼠标可以让你在游戏中变得很有创造力。即使是一个简单的光标也可以成为解谜和策略游戏的一个很好的游戏工具,鼠标经常被用来在游戏舞台上选择和放下游戏元素。这些元素可以是任何东西,从激光反射镜到嗜血的怪物,这取决于游戏。实际的鼠标指针不必是箭头。对于一个神的游戏,它可能是一个神的无所不能的手,或者可能是一个神秘的外星人探测器。经典的 point 'n' click 冒险游戏也很好地利用了鼠标。在这些游戏中,游戏角色会走到玩家点击的任何地方,然后看一看。如果它是一个有用的物体,它可以被储存起来,以便在游戏中使用。

对于更直接控制玩家角色的游戏,鼠标用于旋转或移动。飞行模拟可以使用鼠标的 x 和 y 轴来调整飞机的俯仰和偏航,这样玩家可以对轨迹进行微调。在 Pygame 中,一个简单的方法是将鼠标放在飞机的角度上。我也喜欢在第一人称射击游戏中使用鼠标,用 x 轴左右旋转,用 y 轴上下查看。这感觉很自然,因为头部倾向于那样移动;我可以把头转向任何一边,也可以上下看。我也可以把头歪向两边,但我不常这样做!

您也可以组合鼠标和键盘控制。我特别喜欢用键盘移动,用鼠标瞄准的游戏。例如,一辆坦克可以用光标键移动,但是用鼠标旋转炮塔,这样你就可以不用面对同一个方向就可以向敌人开火。

当然,你并不局限于像其他游戏一样使用鼠标。尽你所能地发挥创造力——但一定要先在你的几个朋友身上测试一下!

实施操纵杆控制

除了游戏之外,操纵杆并没有因为需要在其他地方使用而受到限制,并且可以完全自由地创新,所以现代操纵杆具有*滑的成型设计,并且可以利用玩家可以支配的每一个多余的手指。虽然单词操纵杆经常被用来表示任何类型的游戏控制器,但它更专业地描述了在 f 光模拟中使用的飞行操纵杆。

如今,游戏手柄最受游戏玩家的欢迎,通常与游戏机配套。它们可以舒适地握在双手中,有许多按钮,此外还有一个方向垫和两个拇指操作的模拟杆。想想你的 Xbox 或 PlayStation 控制器。许多人将他们的 Xbox 控制器插入他们的计算机。

Pygame 的joystick模块支持各种具有相同接口的控制器,并且不区分各种可用的设备。这是一件好事,因为我们不想为每个游戏控制器编写代码!

操纵杆基础

让我们从查看pygame.joystick模块开始,它只包含五个简单的函数:

  • pygame.joystick.init—初始化操纵杆模块。这个是由pygame.init自动调用的,所以你很少需要自己调用。
  • pygame.joystick.quit—不初始化操纵杆模块。像init一样,这个函数是自动调用的,所以不经常需要。
  • pygame.joystick.get_init—如果操纵杆模块已经初始化,则返回True。如果它返回False,接下来的两个操纵杆功能就不起作用了。
  • pygame.joystick.get_count—返回当前插入计算机的操纵杆数量。
  • pygame.joystick.Joystick—创建一个新的操纵杆对象,用于访问该操纵杆的所有信息。构造函数获取操纵杆的 ID——第一个操纵杆的 ID 是 0,然后是 1,依此类推,直到系统上有多少个操纵杆。

操纵杆模块中的前三个函数在初始化过程中使用,不经常手动调用。另外两个函数更重要,因为我们将需要它们来找出有多少游戏杆插入到计算机中,并为我们想要使用的任何游戏杆创建joystick对象。这些操纵杆对象可以用来查询有关操纵杆的信息,以及获取任何按钮的状态和任何模拟操纵杆的位置。下面是我们如何将第一个操纵杆插入计算机:

joystick = None
if pygame.joystick.get_count() > 0:
    joystick = pygame.joystick.Joystick(0)

这段代码使用了一个名为joystick的变量,它将引用第一个操纵杆。它最初被设置为无,因为有可能没有操纵杆插入计算机。如果至少插入了一个(pygame.joystick.get_count() > 0),则为第一个操纵杆 ID 创建一个joystick对象,该 ID 始终为 0。当以这种方式工作时,我们必须小心,因为如果我们试图在没有的时候使用joystick,Python 将抛出一个异常。所以在你尝试使用之前,你应该先用if joystick测试一下是否有操纵杆。

操纵杆按钮

操纵杆有如此多的按钮,以至于很少有游戏能全部使用它们。对于一个典型的游戏手柄来说,右侧有四个按钮,最常用来作为启动按钮,或用于执行游戏中最基本的操作,中间有一两个按钮,用于选择、开始或暂停等操作。通常还有两到四个按钮、按钮,它们是游戏手柄边缘上的细长按钮,位于食指和食指自然放置的地方。我发现这些按钮适用于需要按住按钮的动作。例如,一个赛车游戏可能会使用肩膀上的按钮来加速和刹车。肩膀按钮在第一人称射击游戏中也可以很好地左右扫射(侧步)。一些 pad 还在 pad 的底部隐藏了两个额外的按钮,这可能有点难以接*,因此可能是不经常需要的操作的最佳选择。最后,通常有两个按钮位于模拟操纵杆的下方,因此您必须按下操纵杆才能激活它们。这些按钮可能最适合用来激活正在用操纵杆移动的东西,因为如果玩家按下另一个按钮,它会弹回到中间。没有多少游戏使用这些按钮,因为很少有玩家意识到它们!

*如果你没有游戏杆,你可能想至少回顾一下代码,但是你不需要游戏杆来完成本章的剩余部分,或者这本书。

与其他输入设备一样,访问游戏手柄时有两种选择:通过事件或直接查询手柄的状态。当按下 pad 上的一个按钮时,Pygame 发出一个JOYBUTTONDOWN事件,其中包含操纵杆 ID 和按钮的索引(按钮从 0 开始编号)。你可以从joystick对象的get_numbuttons成员函数中得到操纵杆上的按钮数量。当释放一个按钮时,Pygame 发出一个相应的JOYBUTTONDOWN事件,包含相同的信息。

让我们改编来自第三章的事件脚本(清单 3-2 )来查看操纵杆事件的生成。清单 6-5 类似于原始代码,但是初始化所有当前插入的操纵杆,并过滤掉任何与操纵杆无关的事件。

清单 6-5 。显示操纵杆事件(events.py)

import pygame
from pygame.locals import *
from sys import exit

pygame.init()

SCREEN_SIZE = (640, 480)
screen = pygame.display.set_mode( SCREEN_SIZE, 0, 32)

font = pygame.font.SysFont("arial", 16);
font_height = font.get_linesize()
event_text = []

joysticks = []
for joystick_no in range(pygame.joystick.get_count()):
    stick = pygame.joystick.Joystick(joystick_no)
    stick.init()
    joysticks.append(stick)

while True:

    event = pygame.event.wait()
    if event.type in (JOYAXISMOTION,
                      JOYBALLMOTION,
                      JOYHATMOTION,
                      JOYBUTTONUP,
                      JOYBUTTONDOWN):
       event_text.append(str(event))

    event_text = event_text[int(-SCREEN_SIZE[1]/font_height):]

    if event.type == QUIT:
        pygame.quit()
        exit()

    screen.fill((255, 255, 255))

    y = SCREEN_SIZE[1]-font_height
    for text in reversed(event_text):
        screen.blit( font.render(text, True, (0, 0, 0)), (0, y) )
        y-=font_height

    pygame.display.update()

当你运行清单 6-5 并按下你操纵杆上的一些按钮时,你应该看到每次按钮按下的JOYBUTTONDOWN和相应的JOYBUTTONUP(参见图 6-3 )。如果您晃动模拟操纵杆并按下方向键,您还会看到各种其他事件,我们将在下一节介绍这些事件。

9781484209714_Fig06-03.jpg

图 6-3 。显示操纵杆事件

检测游戏手柄按钮的状态与检测按键和鼠标按钮的状态略有不同。不是返回一个布尔值列表,而是在joystick对象中使用get_button成员函数,该函数获取按钮的索引并返回其状态。尽管您可以随时找到操纵杆按钮的状态,但在每一帧开始时获取按钮状态的快照通常是个好主意。这样,您可以确保在绘制屏幕时状态不会改变。下面是我们如何获得按钮状态的布尔列表:

joystick_buttons = []
for button_no in range(joystick.get_numbuttons()):
    joystick_buttons.append (joystick.get_button(button_no) )

操纵杆方向控制

虽然操纵杆上的按钮最常用于激活动作,但我们仍然需要某种方式在游戏中移动或瞄准。这就是方向控制的用武之地。操纵杆上通常有两种形式的方向控制:方向键(d-pad)和模拟杆。你使用哪一个主要取决于游戏的类型。往往一个明显更好,但是对于一些游戏也可以组合使用。本节涵盖了这两个方面,并解释了如何将来自方向控件的输入转换为游戏中的运动。

数字垫

d-pad 是游戏手柄上的一个圆形或十字形小按钮,通常用拇指按压边缘来指示方向。如果你看一下 d-pad 的下面,你会看到有四个十字形排列的开关。当你在任何方向按下键盘时,你也可以按下这些开关中的一个或两个,它们被解释为像光标键一样给出八个方向。d-pad 可能是旧技术,但它们往往是某些游戏动作的最佳选择。我喜欢使用 d-pad 来选择菜单、*移地图和在*台游戏中跳跃。

Pygame 将 d-pad 称为“帽子”,因为操纵杆的顶部有一个类似 d-pad 的控件。然而,对于我们的目的来说,帽子d-pad 是一回事。

当你按下 d-pad 时,Pygame 会向你发送一个JOYHATMOTION事件。该事件包含三个值:joyhatvalue。第一个,joy,是事件来自的操纵杆的索引;that是被压帽子的索引;并且value指示它被按下的方式。hat值实际上是 x 和 y 轴变化的元组——轴的负数表示向左或向下,正数表示向右或向上。

Image 注意d-pad 没有任何上下事件,因为当它被释放时,它会弹回中间,并发送另一个 JOYHATMOTION 事件。

我们也可以绕过 d-pad 的事件,向 Pygame 询问 d-pad 的当前状态。第一步是找出一个操纵杆有多少个 d-pad(可能一个也没有),这可以用joystick对象的get_numhats成员函数来完成。然后,我们可以通过调用get_hat来获得每个 d-pad 的状态,该调用获取 hat 索引并返回轴元组。

这个元组非常类似于我们在清单 6-2 中费力创建的key_direction向量,我们可以非常方便地用它创建一个航向向量,只需用它的值创建一个Vector2对象,并对结果进行归一化!清单 6-6 使用这种方法在屏幕上滚动图像。

清单 6-6 。使用键盘滚动(hatscroll.py)

import pygame
from pygame.locals import *
from sys import exit
from gameobjects.vector2 import Vector2

picture_file = map.png'

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

picture = pygame.image.load(picture_file).convert()
picture_pos = Vector2(0, 0)
scroll_speed = 1000.

clock = pygame.time.Clock()

joystick = None
if pygame.joystick.get_count() > 0:
    joystick = pygame.joystick.Joystick(0)
    joystick.init()

if joystick is None:
    print("Sorry, you need a joystick for this!")
    pygame.quit()
    exit()

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    scroll_direction = Vector2(*joystick.get_hat(0))
    scroll_direction.normalize()

    screen.fill((255, 255, 255))
    screen.blit(picture, (-picture_pos.x, picture_pos.y))

    time_passed = clock.tick()
    time_passed_seconds = time_passed / 1000.0

    picture_pos += scroll_direction * scroll_speed * time_passed_seconds

    pygame.display.update()

您可以在这段代码中找到自己的地图。我建议进入谷歌搜索,输入“地图”你可以找到一张 2000 x 1000 或更大的漂亮的大地图来使用。越大越好。这应该会给你一个感觉。如果你喜欢的游戏有地图,你也可以搜索它。

模拟棒

所有现代的游戏手柄都至少有一个模拟操纵杆,类似于弹簧按钮,可以用拇指移动。相比于 d-pad,游戏玩家更喜欢这些,因为它们提供了更好的控制,并且对于具有更高水*真实感的游戏来说感觉更自然。

Pygame 将模拟摇杆视为两个独立的轴:一个用于 x 轴(左右),一个用于 y 轴(上下)。将它们分开处理的原因是,虽然模拟棒是最常见的,但同样的功能也用于可能只有单轴的其它器件。

模拟操纵杆移动的事件是JOYAXISMOVEMENT,它提供三条信息:joyaxisvalue。第一个是joy,是joystick对象的 ID;axis是轴的索引;value表示轴的当前位置,在 1(向左或向下)和+1(向右或向上)之间变化。

Image 注意y 轴始终跟随 x 轴,所以第一棒使用轴索引 0 和 1。如果有第二个模拟棒可用,它将使用插槽 2 和 3。

除了JOYAXISMOVEMENT事件之外,您还可以从joystick对象中获取任何轴的状态。使用get_numaxis查询操纵杆上的轴数,使用get_axis检索其当前值。让我们在清单 6-6 的基础上增加用模拟摇杆滚动的能力(这样更容易精确地*移图像)。

清单 6-7 从 x 和 y 轴计算一个额外的向量analog_scroll。这个向量没有被归一化,因为除了我们想要移动的方向之外,我们将使用它来指示我们想要移动的有多快。我们仍然需要乘以一个速度值,因为轴的范围从–1 到+1,这将导致非常缓慢的移动。

清单 6-7 。使用模拟滚动条滚动(analoghatscroll.py)

import pygame
from pygame.locals import *
from sys import exit
from gameobjects.vector2 import Vector2

picture_file = 'map.png

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

picture = pygame.image.load(picture_file).convert()
picture_pos = Vector2(0, 0)
scroll_speed = 1000.

clock = pygame.time.Clock()

joystick = None
if pygame.joystick.get_count() > 0:
    joystick = pygame.joystick.Joystick(0)
    joystick.init()

if joystick is None:
    print("Sorry, you need a joystick for this!")
    pygame.quit()
    exit()

while True:

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    scroll_direction = Vector2(0, 0)
    if joystick.get_numhats() > 0:
        scroll_direction = Vector2(*joystick.get_hat(0))
        scroll_direction.normalize()

    analog_scroll = Vector2(0, 0)
    if joystick.get_numaxes() >= 2:
        axis_x = joystick.get_axis(0)
        axis_y = joystick.get_axis(1)
        analog_scroll = Vector2(axis_x, -axis_y)

    screen.fill((255, 255, 255))
    screen.blit(picture, (-picture_pos.x, picture_pos.y))

    time_passed = clock.tick()
    time_passed_seconds = time_passed / 1000.0

    picture_pos += scroll_direction * scroll_speed * time_passed_seconds
    picture_pos += analog_scroll * scroll_speed * time_passed_seconds

    pygame.display.update()

虽然清单 6-7 只是简单地在屏幕上*移图像,但它可能是策略或上帝游戏中滚动地图的基础。

处理操纵杆死区

因为模拟棒和其他轴设备是机械的东西,它们会受到磨损,即使是全新的控制器也会有不完美之处。这可能会导致操纵杆在未被操纵时轻微摆动。如果你看到一个连续的JOYAXISMOTION事件流,但没有接触任何东西,那么你的控制器就会受到影响。但是,现在还不要放弃它——这个问题在代码中很容易处理。如果轴的值非常接*零,则将它们设置为零。这就在操纵杆的中心产生了一个所谓的死区,在这个区域不会检测到任何移动。它应该足够小,以至于玩家不会注意到它,但仍然可以掩盖磨损的棍子产生的任何噪音。

将下面的代码片段添加到前面的清单中(就在分配了axis_xaxis_y之后),以创建一个死区:

if abs(axis_x) < 0.1:
    axis_x = 0.
if abs(axis_y) < 0.1:
    axis_y = 0.

操纵杆对象

操纵杆对象包含了你需要从操纵杆获得的所有信息。您插入的每个游戏杆或游戏控制器都可以有一个。让我们更详细地看看操纵杆对象。它们包含以下方法:

  • joystick.init—初始化操纵杆。必须在操纵杆对象中的其他函数之前调用。
  • joystick.quit—不初始化操纵杆。调用这个函数后,Pygame 不会再从设备发送任何与游戏杆相关的事件。
  • joystick.get_id—检索操纵杆的 ID(与给予Joystick构造器的 ID 相同)。
  • joystick.get_name—检索操纵杆的名称(通常是制造商提供的字符串)。这条线对于所有的操纵杆都是唯一的。
  • joystick.get_numaxes—检索操纵杆上的轴数。
  • joystick.get_axis—检索轴的–1 到+1 之间的值。该函数获取您感兴趣的轴的索引。
  • joystick.get_numballs—检索操纵杆上轨迹球的数量。轨迹球类似于鼠标,但只提供相对运动。
  • joystick.get_ball—检索一个元组,该元组包含自上次调用get_ball以来球在 x 轴和 y 轴上的相对运动。获取您感兴趣的球的索引。
  • joystick.get_button—检索按钮的状态,可以是True(按下)或False(未按下)。这个函数获取您感兴趣的按钮的索引。
  • joystick.get_numhats—检索操纵杆上 d-pad 的数量。
  • joystick.get_hat—检索hat的状态,作为 x 轴和 y 轴的两个值的元组。这个函数获取您感兴趣的帽子的索引。

观看操纵杆的运行

让我们写一个脚本来帮助你玩pygame.joystick模块。清单 6-8 绘制了操纵杆当前状态的粗略表示,包括轴、d-pad 和所有按钮。您可以通过按数字键在已插入的操纵杆之间切换(0 是第一个操纵杆,1 是第二个操纵杆,依此类推)。

如果你在游戏中无法使用游戏杆,用清单 6-8 中的测试一下(参见图 6-4 中的)。您可以轻松判断按钮或控件是否工作不正常。

9781484209714_Fig06-04.jpg

图 6-4 。操纵杆演示脚本在行动

清单 6-8 。操纵杆演示(joystickdemo . py)

import pygame
from pygame.locals import *
from sys import exit

pygame.init()
screen = pygame.display.set_mode((640, 480), 0, 32)

# Get a list of joystick objects
joysticks = []
for joystick_no in range(pygame.joystick.get_count()):
    stick = pygame.joystick.Joystick(joystick_no)
    stick.init()
    joysticks.append(stick)

if not joysticks:
    print("Sorry! No joystick(s) to test.")
    pygame.quit()
    exit()

active_joystick = 0

pygame.display.set_caption(joysticks[0].get_name())

def draw_axis(surface, x, y, axis_x, axis_y, size):

    line_col = (128, 128, 128)
    num_lines = 40
    step = size / float(num_lines)
    for n in range(num_lines):
        line_col = [(192, 192, 192), (220, 220, 220)][n&1]
        pygame.draw.line(surface, line_col, (x+n*step, y), (x+n*step, y+size))
        pygame.draw.line(surface, line_col, (x, y+n*step), (x+size, y+n*step))

    pygame.draw.line(surface, (0, 0, 0), (x, y+size/2), (x+size, y+size/2))
    pygame.draw.line(surface, (0, 0, 0), (x+size/2, y), (x+size/2, y+size))

    draw_x = int(x + (axis_x * size + size) / 2.)
    draw_y = int(y + (axis_y * size + size) / 2.)
    draw_pos = (draw_x, draw_y)
    center_pos = (x+size/2, y+size/2)
    pygame.draw.line(surface, (0, 0, 0), center_pos, draw_pos, 5)
    pygame.draw.circle(surface, (0, 0, 255), draw_pos, 10)

def draw_dpad(surface, x, y, axis_x, axis_y):

    col = (255, 0, 0)
    if axis_x == -1:
        pygame.draw.circle(surface, col, (x-20, y), 10)
    elif axis_x == +1:
       pygame.draw.circle(surface, col, (x+20, y), 10)

    if axis_y == -1:
        pygame.draw.circle(surface, col, (x, y+20), 10)
    elif axis_y == +1:
        pygame.draw.circle(surface, col, (x, y-20), 10)

while True:

    joystick = joysticks[active_joystick]

    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            exit()

    if event.type == KEYDOWN:
        if event.key >= K_0 and event.key <= K_1:
            num = event.key - K_0

            if num < len(joysticks):
                active_joystick = num
                name = joysticks[active_joystick].get_name()
                pygame.display.set_caption(name)

    # Get a list of all the axis
    axes = []
    for axis_no in range(joystick.get_numaxes()):
        axes.append( joystick.get_axis(axis_no) )

    axis_size = min(256, 640 / (joystick.get_numaxes()/2))

    pygame.draw.rect(screen, (255, 255,255), (0, 0, 640, 480))

    # Draw all the axes (analog sticks)
    x = 0
    for axis_no in range(0, len(axes), 2):
        axis_x = axes[axis_no]
        if axis_no+1 < len(axes):
            axis_y = axes[axis_no+1]
        else:
            axis_y = 0.
        draw_axis(screen, x, 0, axis_x, axis_y, axis_size)
        x += axis_size

    # Draw all the hats (d-pads)
    x, y = 50, 300
    for hat_no in range(joystick.get_numhats()):
        axis_x, axis_y = joystick.get_hat(hat_no)
        draw_dpad(screen, x, y, axis_x, axis_y)
        x+= 100

    #Draw all the buttons x, y = 0.0, 390.0
    button_width = 640 / joystick.get_numbuttons()
    for button_no in range(joystick.get_numbuttons()):
        if joystick.get_button(button_no):
            pygame.draw.circle(screen, (0, 255, 0), (int(x), int(y)), 20)
        x += button_width

    pygame.display.update()

摘要

Pygame 中的控件有三个模块:pygame.keyboardpygame.mousepygame.joystick。其中,你可以支持玩家想在游戏中使用的任何设备。你不必支持所有三个,如果没有明确的赢家,你可以给玩家一个选择。

有时候控制会立刻影响玩家角色,所以它会完全服从玩家的命令。经典枪战是这样的;你向左按,船立刻向左,反之亦然。不过,为了更真实一点,控件应该通过施加推力和破坏力来间接影响玩家角色。现在你知道了如何从游戏控制器中读取信息,接下来的章节将教你如何在更真实的游戏世界中使用这些信息来操纵物体。

游戏的控制是玩家如何与游戏世界互动。因为我们无法融入游戏中,矩阵的风格,操控应该感觉尽可能自然。当考虑游戏的控制方法时,看看类似的游戏是如何玩的是个好主意。对于相似类型的游戏来说,控制没有太大的变化。这不是因为游戏设计师缺乏想象力;只是游戏玩家已经开始期望游戏以类似的方式工作。如果你确实使用了更有创造性的控制方法,准备好向玩家证明它的合理性,或者给他们提供一个更标准的选择!

在下一章,我们将探索人工智能的主题,你将学习如何在游戏中添加非玩家角色。*

七、带我去见你们的领导

将玩家角色放置在一个令人信服的世界中只是创建游戏的一部分。为了让游戏变得有趣,你需要给玩家一些挑战。这些可能以陷阱和障碍的形式出现,但要真正娱乐你的玩家,你需要让他们与非玩家角色(NPC)互动——这些角色在游戏中似乎有一定程度的智能或意识。创造这些 NPC 的过程被称为人工智能(AI) 。在这一章中,我们将探索一些简单的技巧,你可以用它们来赋予你的游戏角色自己的生活。

为游戏创造人工智能

你可能已经在 Pygame 文档中找到了一个pygame.ai模块。没有一个,因为每个游戏在创建 NPC 时都有非常不同的要求。向水管工扔桶的猿的代码不需要太多工作——所有猿需要确定的是它应该向左还是向右扔桶,这一点你可以用一行 Python 代码来模拟!在未来派第一人称射击游戏中创造一个令人信服的敌方战斗人员可能需要更多的努力。人工智能玩家必须计划从地图的一部分到另一部分的路线,同时瞄准武器和躲避敌人的火力。它可能还要根据弹药供应和装甲库存来做决定。这一切做得越好,人工智能玩家就越优秀,对玩家的挑战也越大。

虽然游戏中的大部分人工智能是用来创造令人信服的对手来对抗,但将人工智能技术用于更和*的目的正变得越来越流行。NPC 不需要总是敌人,必须一看到就派出去;他们也可能是游戏世界中的角色,以增加游戏的深度。一些 NPC 甚至可能是玩家的朋友,应该保护他们免受伤害,因为他们在任务中积极协助。其他游戏,如大获成功的模拟人生,根本不需要玩家角色,完全由 NPC 组成。

人工智能也有助于通过添加不直接参与游戏的背景人物(相当于电影临时演员的游戏)来使游戏世界更有说服力。我们可以应用一些人工智能技术,让鸟类聚集在一起,或者在赛车游戏中,成群的人从失控的汽车中逃离。正是这种对细节的关注真正将玩家与游戏世界联系起来。诀窍是让玩家相信,即使他们现在不玩游戏,游戏世界也会存在。

人工智能有着难以相处的名声,但这并不是它应得的。你为 AI 创建的许多代码可以在各种组合中重用,以创建各种不同类型的 NPC。事实上,大多数游戏对游戏中的每个角色都使用相同的代码,你只需要调整几个值来改变行为。

这一章不会涵盖大量的人工智能理论(这很容易就能占据一整本书)。相反,它会给你一些技巧,你可以应用到游戏中的许多情况。

什么是智能?

智能是一个很难定义的东西,即使对于 AI 程序员来说也是如此。我相信我很聪明,也有自知之明,但是我只能假设其他人也很聪明,因为他们在很多方面都和我一样。其他人像我一样说话、移动、检查电子邮件、倒垃圾——所以我认为他们很聪明。类似地,在游戏中,如果一个角色的行为方式和智能事物一样,那么玩家会认为它是智能的。程序员可能知道一个角色的动作仅仅是几页计算机代码的结果,但是玩家会忘记这个事实。就玩家而言,如果它像僵尸一样走路,像僵尸一样呻吟,像僵尸一样吃人,那么它就是僵尸!

所以游戏中的智力是一种假象(现实生活中也可能是)。产生这种错觉的代码与前几章中的代码没有太大区别。您将使用 Python 字符串、列表、字典等相同的基本工具来构建类,这些类实际上是 NPC 的大脑。事实上,Python 可能是编写 AI 的最佳语言之一,因为它的内置对象范围很大。

探索人工智能

人工智能对于创造一个有趣的游戏来说并不是必不可少的。我以前喜欢玩经典的*台游戏,游戏中的主人公必须从一个*台跳到另一个*台,然后厚颜无耻地跳到怪物的头上。

虽然这些游戏中的怪物都是 NPC,但它们的行动有点初级,不能被认为是 AI。让我们看看一个典型的*台游戏怪物的脑袋内部(见清单 7-1 )。这个清单是伪代码 ,它是用来演示一项技术的代码,但实际上并不运行。

清单 7-1 。*台怪物的伪代码

self.move_forward()
if self.hit_wall():
    self.change_direction()

清单 7-1 中的这个怪物除了能够检测到它是否撞到了墙壁之外,对周围的环境没有任何意识,而且它肯定不会对即将用头着地的玩家角色做出任何反应。一般来说,对人工智能的一个要求是 NPC 必须知道游戏中的其他实体,尤其是玩家角色。让我们考虑另一种类型的游戏怪物:来自冥界的投掷火球的小鬼。小恶魔在生活中有一个简单的任务:找到玩家并朝他的方向扔出一个火球。清单 7-2 是小鬼大脑的伪代码。

清单 7-2 。Imp AI 的伪代码

if self.state == "exploring":
    self.random_heading()
    if self.can_see(player):
        self.state = "seeking"

elif self.state == "seeking":
    self.head_towards("player")
    if self.in_range_of(player):
        self.fire_at(player)
    if not self.can_see(player):
        self.state = "exploring"

小鬼可以处于两种状态之一:探索或者寻找。imp 的当前状态存储在self.state的值中,并指示哪个代码块当前控制 imp 的动作。当小鬼在探索时(即self.state == "exploring"),它会通过选择一个随机的航向,在地图上漫无目的地走来走去。但是如果它看到玩家,它会切换到第二种状态"seeking"。一个处于搜寻模式的小鬼会朝玩家走去,一旦进入射程就会开火。只要玩家能被看到,它就会一直这样做,但是如果胆怯的玩家撤退,小鬼就会切换回探索状态。

我们的小鬼当然不是深度思考者,但它确实知道周围的环境(例如,玩家在哪里)并采取相应的行动。即使有两种状态,小鬼也足够聪明,可以成为第一人称射击游戏中的敌人。如果我们再添加几个状态,并定义在它们之间切换的条件,我们可能会创造出一个更强大的敌人。这是游戏 AI 中常见的技术,被称为状态 机器

Image 注意这个小鬼并不是最聪明的黑社会居民。如果玩家再也看不见了,小鬼就会停止寻找,即使玩家刚刚躲在一棵树后面!幸运的是,我们可以在状态机的基础上创建一个更智能的 imp 类。

实现状态机

小鬼大脑的两种状态形成了一个非常简单的状态机。一个国家通常定义两件事:

  • NPC 此刻正在做什么
  • 此时它应该切换到另一个状态

探索状态到寻找状态的条件是self.can_see(player)——换句话说,“我(小鬼)能见玩家吗?”相反的条件(not self.can_see(player))用于从寻找返回到探索。 图 7-1 是 imp 状态机的示意图,它实际上是它的大脑。箭头定义了状态和切换状态必须满足的条件之间的联系。状态机中的链接总是单向的,但可能有另一个链接返回到原始状态。在回到初始状态之前,还可能有几个中间状态,这取决于 NPC 行为的复杂性。

9781484209714_Fig07-01.jpg

图 7-1 。Imp 状态机

除了当前行为和条件,状态还可以包含 进入动作退出动作。进入动作是在进入新状态之前完成的事情,通常用于执行状态运行所需的一次性动作。对于小鬼状态机中的seeking状态,一个进入动作可能会计算出一个朝向玩家的方向,并发出一个声音来表明它已经看到了玩家——或者是小鬼准备战斗所需的任何东西。退出动作与进入动作相反,在离开一个状态时执行。

让我们创建一个稍微有趣一点的状态机,这样我们就可以将它付诸实践。我们将创建一个模拟蚂蚁的巢穴。在用人工智能做实验时,经常使用昆虫,因为它们的行为非常简单,很容易建模。在我们的模拟宇宙中,我们将有三个实体:树叶、蜘蛛和蚂蚁本身。树叶会在屏幕上随机生长,然后被蚂蚁收获并送回蚁巢。蜘蛛在屏幕上游荡,只要它们不靠*蚁巢,蚂蚁就会容忍它们。如果一只蜘蛛进入巢穴,它会被追逐和撕咬,直到它死去或者设法逃得足够远。

Image 注意即使我们在这个模拟中使用了昆虫主题,我们将要编写的人工智能代码也适用于许多场景。如果我们用巨型“机甲”机器人、坦克和空投燃料来代替蚂蚁、蜘蛛和树叶,那么模拟仍然是有意义的。

游戏实体

虽然我们有三种不同类型的实体,但为游戏实体设计一个包含通用属性和动作的基类是个好主意。这样,我们就不需要为每个实体复制代码,并且我们可以轻松地添加其他实体,而不需要太多额外的工作。

一个实体将需要存储它的名字("ant""leaf""spider"),以及它的当前位置、目的地、速度和用来在屏幕上表示它的图像。你可能会觉得奇怪的是"leaf"实体会有目的地和速度。我们不会有神奇的会走路的树叶;我们只需将它们的速度设为零,这样它们就不会移动。这样,我们仍然可以像对待其他实体一样对待树叶。除了这些信息之外,我们还需要为游戏实体定义一些常见的功能。我们需要一个函数将实体呈现到屏幕上,另一个函数处理实体(例如,更新它在屏幕上的位置)。清单 7-3 显示了创建一个GameEntity类的代码,这个类将被用作每个实体的基础。

清单 7-3 。游戏实体的基类

class GameEntity(object):

    def __init__(self, world, name, image):

        self.world = world
        self.name = name
        self.image = image
        self.location = Vector2(0, 0)
        self.destination = Vector2(0, 0)
        self.speed = 0.

        self.brain = StateMachine()

        self.id = 0

    def render(self, surface):

        x, y = self.location
        w, h = self.image.get_size()
        surface.blit(self.image, (x-w/2, y-h/2))

    def process(self, time_passed):

        self.brain.think()

        if self.speed > 0 and self.location != self.destination:

           vec_to_destination = self.destination - self.location
           distance:to_destination = vec_to_destination.get_length()
           heading = vec_to_destination.get_normalized()
           travel_distance = min(distance:to_destination, time_passed * self.speed)
           self.location += travel_distance * heading

GameEntity类还保存了一个对world的引用,这是一个我们将用来存储所有实体位置的对象。这个World对象很重要,因为它是实体在模拟中了解其他实体的方式。实体还需要一个 ID 来在世界上标识它,并为它的大脑提供一个StateMachine对象(我们将在后面定义)。

GameEntityrender函数只是将实体的图像传送到屏幕上,但首先调整坐标,使当前位置位于图像的中心下方,而不是左上角。我们这样做是因为实体将被视为具有一个点和一个半径的圆,这将在我们需要检测与其他实体的交互时简化数学。

GameEntity对象的process函数首先调用self.brain.think,它将运行状态机来控制实体(通常通过改变其目的地)。在这个模拟中只有蚂蚁会使用状态机,但是我们可以给任何实体添加 AI。如果我们还没有为实体建立状态机,这个调用将简单地返回而不做任何事情。process函数的其余部分将实体向其目的地移动,如果它还不在那里的话。

建造世界

现在我们已经创建了一个GameEntity类,我们需要创建一个世界供实体居住。对于这个模拟来说,世界上没有太多东西——只有一个巢,由屏幕中心的一个圆圈表示,以及许多不同类型的游戏实体。World类(见清单 7-4 )绘制嵌套并管理其实体。

清单 7-4 。世界级

class World(object):

    def __init__(self):

        self.entities = {} # Store all the entities
        self.entity_id = 0 # Last entity id assigned
        # Draw the nest (a circle) on the background
        self.background = pygame.surface.Surface(SCREEN_SIZE).convert()
        self.background.fill((255, 255, 255))
        pygame.draw.circle(self.background, (200, 255, 200), NEST_POSITION, int(NEST_SIZE))

    def add_entity(self, entity):

        # Stores the entity then advances the current id
        self.entities[self.entity_id] = entity
        entity.id = self.entity_id
        self.entity_id += 1

    def remove_entity(self, entity):

        del self.entities[entity.id]

    def get(self, entity_id):

        # Find the entity, given its id (or None if it is not found)
        if entity_id in self.entities:
          return self.entities[entity_id]
        else:
          return None

    def process(self, time_passed):

        # Process every entity in the world
        time_passed_seconds = time_passed / 1000.0
        for entity in self.entities.itervalues():
          entity.process(time_passed_seconds)

    def render(self, surface):

        # Draw the background and all the entities
        surface.blit(self.background, (0, 0))
        for entity in self.entities.values():
          entity.render(surface)

    def get_close_entity(self, name, location, e_range=100):

        # Find an entity within range of a location
        location = Vector2(*location)

        for entity in self.entities.values():
          if entity.name == name:
             distance = location.get_distance:to(entity.location)
             if distance < e_range:
                 return entity
        return None

因为我们有许多GameEntity对象,所以使用 Python list 对象来存储它们是非常自然的。虽然这可行,但我们会遇到问题;当一个实体需要从这个世界上删除时(例如,它已经死了),我们必须搜索这个列表来找到它的索引,然后调用del来删除它。在列表中搜索可能会很慢,而且随着列表的增长会变得更慢。存储实体的一个更好的方法是使用 Python 字典,即使有很多实体,它也能有效地找到一个实体。

为了在字典中存储实体,我们需要一个值作为键,它可以是字符串、数字或其他值。为每只蚂蚁起一个名字会很困难,所以我们简单地按顺序给蚂蚁编号:第一只蚂蚁是#0,第二只蚂蚁是#1,依此类推。这个数字就是实体的id,并存储在每个GameEntity对象中,这样我们就可以在字典中找到该对象(见图 7-2 )。

9781484209714_Fig07-02.jpg

图 7-2 。实体词典

Image 注意一个把增量数字映射到它的值的字典类似于一个列表,但是如果一个值被删除,这些键不会向下移动。所以蚂蚁 5 号仍然是蚂蚁 5 号,即使蚂蚁 4 号被移除。

World类中的大多数函数负责以某种方式管理实体。有一个add_entity函数将一个实体添加到世界中,一个remove_entity函数将它从世界中移除,还有一个get函数查找给定了id的实体。如果getentities字典中找不到id,则返回None。这很有用,因为它会告诉我们一个实体已经被删除了(id值永远不会被重用)。考虑这样一种情况,一群蚂蚁正在紧追一只入侵蚁巢的蜘蛛。每个 ant 对象存储它正在追踪的蜘蛛的id,并且将查找它(用get)以检索蜘蛛的位置。然而,在某个时候,这只不幸的蜘蛛将会被送走,从这个世界上消失。当这种情况发生时,用蜘蛛的idget函数的任何调用都将返回None,因此蚂蚁将知道它们可以停止追逐并返回到其他任务。

同样在World类中,我们有一个process和一个render函数。World对象的process函数调用每个实体的process函数,给它一个更新位置的机会。render功能类似;除了绘制背景,它还调用每个实体对应的render函数,在其所在位置绘制合适的图形。

最后,在World类中有一个名为get_close_entity的函数,它查找世界上某个位置特定距离内的实体。这用于模拟中的几个地方。

Image 注意在实现 NPC 时,你通常应该限制它可用的信息,因为像真人一样,NPC 不一定知道世界上正在发生的一切。我们用蚂蚁来模拟这种情况,只让它们看到有限距离内的物体。

Ant 实体类

在我们为蚂蚁的大脑建模之前,让我们看看Ant类(见清单 7-5 )。它源自GameEntity,因此它将拥有GameEntity的所有功能,以及我们添加到它上面的任何附加功能。

清单 7-5 。Ant 实体类

class Ant(GameEntity):

    def __init__(self, world, image):

        # Call the base class constructor
        GameEntity.__init__(self, world, "ant", image)

        # Create instances of each of the states
        exploring_state = AntStateExploring(self)
        seeking_state = AntStateSeeking(self)
        delivering_state = AntStateDelivering(self)
        hunting_state = AntStateHunting(self)

        # Add the states to the state machine (self.brain)
        self.brain.add_state(exploring_state)
        self.brain.add_state(seeking_state)
        self.brain.add_state(delivering_state)
        self.brain.add_state(hunting_state)

        self.carry_image = None

  def carry(self, image):

        self.carry_image = image

  def drop(self, surface):

  # Blit the 'carry' image to the background and reset it
  if self.carry_image:
      x, y = self.location
      w, h = self.carry_image.get_size()
      surface.blit(self.carry_image, (x-w, y-h/2))
      self.carry_image = None

  def render(self, surface):

      # Call the render function of the base class
      GameEntity.render(self, surface)

# Extra code to render the 'carry' image
if self.carry_image:
    x, y = self.location
    w, h = self.carry_image.get_size()
    surface.blit(self.carry_image, (x-w, y-h/2))

我们的Ant类(init)的构造函数首先用行GameEntity. init (self, world, "ant", image)调用基类的构造函数。我们必须这样调用它,因为如果我们要调用self. init,Python 将调用Ant中的构造函数——并以无限循环结束!ant 构造函数中的剩余代码创建状态机(在下一节中介绍),并将名为carry_image的成员变量设置为None。该变量由carry函数设置,用于存储蚂蚁携带的物体的图像;它可能是一片树叶或一只死蜘蛛。如果调用drop函数,会将carry_image置回None,不再绘制。

因为能够携带其他图像,所以在渲染精灵时,蚂蚁有一个额外的要求。除了自己的图像之外,我们还想绘制蚂蚁携带的图像,所以蚂蚁有一个render专用版本,它调用基类中的render函数,然后渲染carry_image,如果它没有设置为None

构建大脑

每只蚂蚁在其状态机中都有四种状态,这足以模拟蚂蚁的行为。定义状态机的第一步是计算出每个状态应该做什么,即该状态的动作(见表 7-1 )。

表 7-1 。Ant 状态的操作

|

状态

|

行动

|
| --- | --- |
| 探索 | 走向世界上的任意一点。 |
| 寻找 | 向一片树叶走去。 |
| 发表 | 给巢穴送东西。 |
| 打猎 | 追逐一只蜘蛛。 |

我们还需要定义连接状态的链接。它们采用条件的形式,以及满足条件时要切换到的状态的名称。例如,探索状态有两个这样的链接(见表 7-2 )。

表 7-2 。来自浏览状态的链接

|

情况

|

目的地国家

|
| --- | --- |
| 看见一片树叶了吗? | 寻找 |
| 蜘蛛攻击基地? | 打猎 |

一旦我们定义了状态之间的联系,我们就有了一个可以作为实体大脑的状态机。图 7-3 显示了我们将为 ant 构建的完整状态机。像这样在纸上画一个状态机是一种很好的可视化方式,当你需要把它转换成代码时会有帮助。

9781484209714_Fig07-03.jpg

图 7-3 。蚂蚁状态机

让我们将此付诸实践,并为状态机创建代码。我们将从定义一个单独状态的基类开始(见清单 7-6 )。稍后,我们将为状态机创建另一个类,作为一个整体来管理它包含的状态。

基类State除了在构造函数中存储状态名之外,实际上不做任何事情。State中的其余函数什么都不做——pass关键字只是告诉 Python 你有意将函数留空。我们需要这些空函数,因为不是所有我们将要构建的状态都会实现基类中的所有函数。例如,探索状态没有退出操作。当我们开始实现AntStateExploring类时,我们可以省略exit_actions函数,因为它将安全地退回到基类(State)中不做任何事情的函数版本。

清单 7-6 。州的基类

class State(object):

  def __init__(self, name):
      self.name = name

  def do_actions(self):
      pass

  def check_conditions(self):
      pass

  def entry_actions(self):
      pass

  def exit_actions(self):
      pass

在构建状态之前,我们需要构建一个管理它们的类。StateMachine类(见清单 7-7 )将每个状态的一个实例存储在一个字典中,并管理当前活动的状态。think函数每帧运行一次,并在活动状态下调用do_actions——做该状态被设计要做的任何事情;探索状态将选择随机的地方行走,寻找状态将向叶子移动,等等。think函数也调用状态的check_conditions函数来检查所有的链路状况。如果check_conditions返回一个字符串,将选择一个新的活动状态,任何退出和进入动作将运行。

清单 7-7 。状态机类

class StateMachine(object):

  def __init__(self):

      self.states = {}  # Stores the states
      self.active_state = None  # The currently active state

  def add_state(self, state):

      # Add a state to the internal dictionary
      self.states[state.name] = state

  def think(self):

 # Only continue if there is an active state
     if self.active_state is None:
         return

     # Perform the actions of the active state, and check conditions
     self.active_state.do_actions()

     new_state_name = self.active_state.check_conditions()
     if new_state_name is not None:
         self.set_state(new_state_name)

  def set_state(self, new_state_name):

      # Change states and perform any exit / entry actions
      if self.active_state is not None:
          self.active_state.exit_actions()

      self.active_state = self.states[new_state_name]
      self.active_state.entry_actions()

既然我们已经有了一个正常工作的状态机类,我们可以通过从State类派生并实现它的一些功能来开始实现每个单独的状态。我们将实现的第一个状态是探索状态,我们称之为AntStateExploring(见清单 7-8 )。这个状态的输入动作给蚂蚁一个随机的速度,并把它的目的地设置为屏幕上的一个随机点。主要动作,在do_actions功能中,如果表达式randint(1, 20) == 1为真,则选择另一个随机目的地,这将在每 20 次调用中发生一次,因为randint(在random模块中)选择一个大于或等于第一个参数且小于或等于第二个参数的随机数。这给了我们想要的蚂蚁般的随机搜索行为。

探索状态的两个输出链接在check_conditions函数中实现。第一个条件寻找距离蚂蚁位置 100 像素以内的叶子实体(因为这是我们的蚂蚁能看到的距离)。如果附*有叶子,那么check_conditions记录它的id并返回字符串seeking,这将指示状态机切换到寻找状态。如果蚁巢内有任何蜘蛛在蚂蚁位置的 100 个像素内,剩下的条件将切换到hunting

Image 小心随机数是让你的游戏更有趣的好方法,因为可预测的游戏过一会儿就会变得无趣。但是要小心随机数——如果出了问题,可能很难重现问题!

清单 7-8 。蚂蚁的探索状态(AntStateExploring)

class AntStateExploring(State):

    def __init__(self, ant):

        # Call the base class constructor to initialize the State
        State.__init__(self, "exploring")
        # Set the ant that this State will manipulate
        self.ant = ant

    def random_destination(self):

        # Select a point in the screen
        w, h = SCREEN_SIZE
        self.ant.destination = Vector2(randint(0, w), randint(0, h))

    def do_actions(self):

        # Change direction, 1 in 20 calls
        if randint(1, 20) == 1:
             self.random_destination()

    def check_conditions(self):

        # If there is a nearby leaf, switch to seeking state
        leaf = self.ant.world.get_close_entity("leaf", self.ant.location)
        if leaf is not None:
                self.ant.leaf_id = leaf.id
                return "seeking"
        # If there is a nearby spider, switch to hunting state
        spider = self.ant.world.get_close_entity("spider", NEST_POSITION, NEST_SIZE)
        if spider is not None:
             if self.ant.location.get_distance:to(spider.location) < 100.:
                 self.ant.spider_id = spider.id
                 return "hunting"

        return None

    def entry_actions(self):

        # Start with random speed and heading
        self.ant.speed = 120\. + randint(-30, 30)
        self.random_destination()

正如你从清单 7-8 中看到的,单个州的代码不需要非常复杂,因为各州一起工作产生的东西比其各部分的总和还要多。其他状态与AntStateExploring相似,它们基于该状态的目标选择目的地,如果它们已经实现了该目标,或者该目标不再相关,则切换到另一个状态。

在游戏的主循环中没有太多事情可做。一旦创建了World对象,我们只需每帧调用一次processrender来更新和绘制模拟中的所有内容。主循环中还有几行代码,用于在世界上的随机位置创建叶实体,并偶尔创建从屏幕左侧进入的蜘蛛实体。

清单 7-9 显示了整个模拟过程。运行时会看到类似图 7-4 的东西;蚂蚁在屏幕上四处游荡,收集树叶,杀死蜘蛛,并将它们堆积在巢穴中。你可以看到蚂蚁满足人工智能的标准,因为它们知道自己的环境——在有限的意义上——并采取相应的行动。

您将需要使用我们的 gameobjects 库源代码中的 vector2.py 版本,或者您可以自己在这里编写一些新方法。这些方法可以得到到给定点的距离或者向量的长度。我鼓励您尝试自己构建它们,如果您发现自己陷入困境,可以查看提供的源代码!

虽然在这个模拟中没有玩家角色,但这是我们最接*真实游戏的一次。我们有一个世界,一个实体框架,还有人工智能。它可以通过增加一个玩家角色变成一个游戏。你可以为玩家定义一个全新的实体,也许是一只不得不吃蚂蚁的螳螂,或者给蜘蛛实体添加键盘控制,让它从巢中收集蛋。或者,这种模拟是策略游戏的一个很好的起点,在这种游戏中,成群的蚂蚁可以被派去收集树叶或袭击邻*的巢穴。游戏开发者要尽可能有想象力!

9781484209714_Fig07-04.jpg

图 7-4 。蚂蚁模拟

清单 7-9 。完整的人工智能模拟(antstatemachine.py)

import pygame
from pygame.locals import *

from random import randint, choice
from gameobjects.vector2 import Vector2

SCREEN_SIZE = (640, 480)
NEST_POSITION = (320, 240)
ANT_COUNT = 20
NEST_SIZE = 100

class State(object):

    def __init__(self, name):
        self.name = name

    def do_actions(self):
        pass

    def check_conditions(self):
        pass

    def entry_actions(self):
        pass

    def exit_actions(self):
        pass

class StateMachine(object):

    def __init__(self):

        self.states = {}
        self.active_state = None

    def add_state(self, state):

        self.states[state.name] = state

    def think(self):

        if self.active_state is None:
            return

        self.active_state.do_actions()

        new_state_name = self.active_state.check_conditions()
        if new_state_name is not None:
            self.set_state(new_state_name)

    def set_state(self, new_state_name):

        if self.active_state is not None:
            self.active_state.exit_actions()

        self.active_state = self.states[new_state_name]
        self.active_state.entry_actions()

class World(object):

    def __init__(self):

        self.entities = {}
        self.entity_id = 0
        self.background = pygame.surface.Surface(SCREEN_SIZE).convert()
        self.background.fill((255, 255, 255))
        pygame.draw.circle(self.background, (200, 255, 200), NEST_POSITION, int(NEST_SIZE))

    def add_entity(self, entity):

        self.entities[self.entity_id] = entity
        entity.id = self.entity_id
        self.entity_id += 1

    def remove_entity(self, entity):

        del self.entities[entity.id]

    def get(self, entity_id):

        if entity_id in self.entities:
            return self.entities[entity_id]
        else:
            return None

    def process(self, time_passed):

        time_passed_seconds = time_passed / 1000.0
        for entity in list(self.entities.values()):
            entity.process(time_passed_seconds)

    def render(self, surface):

        surface.blit(self.background, (0, 0))
        for entity in self.entities.values():
            entity.render(surface)

    def get_close_entity(self, name, location, e_range=100):

        location = Vector2(*location)

        for entity in self.entities.values():

            if entity.name == name:
                distance = location.get_distance:to(entity.location)
                if distance < e_range:
                    return entity
        return None

class GameEntity(object):

    def __init__(self, world, name, image):

        self.world = world
        self.name = name
        self.image = image
        self.location = Vector2(0, 0)
        self.destination = Vector2(0, 0)
        self.speed = 0.

        self.brain = StateMachine()

        self.id = 0

    def render(self, surface):

        x, y = self.location
        w, h = self.image.get_size()
        surface.blit(self.image, (x-w/2, y-h/2))

    def process(self, time_passed):

        self.brain.think()

        if self.speed > 0 and self.location != self.destination:

            vec_to_destination = self.destination - self.location
            distance:to_destination = vec_to_destination.get_length()
            heading = vec_to_destination.get_normalized()
            travel_distance = min(distance:to_destination, time_passed * self.speed)
            self.location += travel_distance * heading

class Leaf(GameEntity):

    def __init__(self, world, image):
        GameEntity.__init__(self, world, "leaf", image)

class Spider(GameEntity):

    def __init__(self, world, image):
        GameEntity.__init__(self, world, "spider", image)
        self.dead_image = pygame.transform.flip(image, 0, 1)
        self.health = 25
        self.speed = 50 + randint(-20, 20)

    def bitten(self):

        self.health -= 1
        if self.health <= 0:
            self.speed = 0
            self.image = self.dead_image
        self.speed = 140

    def render(self, surface):

        GameEntity.render(self, surface)

        x, y = self.location
        w, h = self.image.get_size()
        bar_x = x - 12
        bar_y = y + h/2
        surface.fill( (255, 0, 0), (bar_x, bar_y, 25, 4))
        surface.fill( (0, 255, 0), (bar_x, bar_y, self.health, 4))

    def process(self, time_passed):

        x, y = self.location
        if x > SCREEN_SIZE[0] + 2:
            self.world.remove_entity(self)
            return

        GameEntity.process(self, time_passed)

class Ant(GameEntity):

    def __init__(self, world, image):

        GameEntity.__init__(self, world, "ant", image)

        exploring_state = AntStateExploring(self)
        seeking_state = AntStateSeeking(self)
        delivering_state = AntStateDelivering(self)
        hunting_state = AntStateHunting(self)

        self.brain.add_state(exploring_state)
        self.brain.add_state(seeking_state)
        self.brain.add_state(delivering_state)
        self.brain.add_state(hunting_state)

        self.carry_image = None

    def carry(self, image):

        self.carry_image = image

    def drop(self, surface):

        if self.carry_image:
            x, y = self.location
            w, h = self.carry_image.get_size()
            surface.blit(self.carry_image, (x-w, y-h/2))
            self.carry_image = None

    def render(self, surface):

        GameEntity.render(self, surface)

        if self.carry_image:
            x, y = self.location
            w, h = self.carry_image.get_size()
            surface.blit(self.carry_image, (x-w, y-h/2))

class AntStateExploring(State):

    def __init__(self, ant):

        State.__init__(self, "exploring")
        self.ant = ant

    def random_destination(self):

        w, h = SCREEN_SIZE
        self.ant.destination = Vector2(randint(0, w), randint(0, h))

    def do_actions(self):

        if randint(1, 20) == 1:
            self.random_destination()

    def check_conditions(self):

        leaf = self.ant.world.get_close_entity("leaf", self.ant.location)
        if leaf is not None:
            self.ant.leaf_id = leaf.id
            return "seeking"

        spider = self.ant.world.get_close_entity("spider", NEST_POSITION, NEST_SIZE)
        if spider is not None:
            if self.ant.location.get_distance:to(spider.location) < 100:
                self.ant.spider_id = spider.id
                return "hunting"

        return None

    def entry_actions(self):

        self.ant.speed = 120\. + randint(-30, 30)
        self.random_destination()

class AntStateSeeking(State):

    def __init__(self, ant):

        State.__init__(self, "seeking")
        self.ant = ant
        self.leaf_id = None

    def check_conditions(self):

        leaf = self.ant.world.get(self.ant.leaf_id)
        if leaf is None:
            return "exploring"

        if self.ant.location.get_distance:to(leaf.location) < 5:

            self.ant.carry(leaf.image)
            self.ant.world.remove_entity(leaf)
            return "delivering"

        return None

    def entry_actions(self):

        leaf = self.ant.world.get(self.ant.leaf_id)
        if leaf is not None:
            self.ant.destination = leaf.location
            self.ant.speed = 160 + randint(-20, 20)

class AntStateDelivering(State):

    def __init__(self, ant):

        State.__init__(self, "delivering")
        self.ant = ant

    def check_conditions(self):

        if Vector2(*NEST_POSITION).get_distance:to(self.ant.location) < NEST_SIZE:
            if (randint(1, 10) == 1):
                self.ant.drop(self.ant.world.background)
                return "exploring"

        return None

    def entry_actions(self):

        self.ant.speed = 60.
        random_offset = Vector2(randint(-20, 20), randint(-20, 20))
        self.ant.destination = Vector2(*NEST_POSITION) + random_offset

class AntStateHunting(State):

    def __init__(self, ant):

        State.__init__(self, "hunting")
        self.ant = ant
        self.got_kill = False

    def do_actions(self):

        spider = self.ant.world.get(self.ant.spider_id)

        if spider is None:
            return

        self.ant.destination = spider.location

        if self.ant.location.get_distance:to(spider.location) < 15:

            if randint(1, 5) == 1:
                spider.bitten()

                if spider.health <= 0:
                    self.ant.carry(spider.image)
                    self.ant.world.remove_entity(spider)
                    self.got_kill = True

    def check_conditions(self):

        if self.got_kill:
            return "delivering"

        spider = self.ant.world.get(self.ant.spider_id)

        if spider is None:
            return "exploring"

        if spider.location.get_distance:to(NEST_POSITION) > NEST_SIZE * 3:
            return "exploring"

        return None

    def entry_actions(self):

        self.speed = 160 + randint(0, 50)

    def exit_actions(self):

        self.got_kill = False

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, 0, 32)

    world = World()

    w, h = SCREEN_SIZE

    clock = pygame.time.Clock()

    ant_image = pygame.image.load("ant.png").convert_alpha()
    leaf_image = pygame.image.load("leaf.png").convert_alpha()
    spider_image = pygame.image.load("spider.png").convert_alpha()

    for ant_no in range(ANT_COUNT):

        ant = Ant(world, ant_image)
        ant.location = Vector2(randint(0, w), randint(0, h))
        ant.brain.set_state("exploring")
        world.add_entity(ant)

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()

        time_passed = clock.tick(30)

        if randint(1, 10) == 1:
            leaf = Leaf(world, leaf_image)
            leaf.location = Vector2(randint(0, w), randint(0, h))
            world.add_entity(leaf)

        if randint(1, 100) == 1:
            spider = Spider(world, spider_image)
            spider.location = Vector2(-50, randint(0, h))
            spider.destination = Vector2(w+50, randint(0, h))
            world.add_entity(spider)

        world.process(time_passed)
        world.render(screen)

        pygame.display.update()

if __name__ == "__main__":
    run()

摘要

游戏中人工智能的目标是让非玩家角色以真实的方式行为。好的人工智能给游戏增加了一个额外的维度,因为玩家会觉得他们是在一个真实的世界,而不是一个计算机程序。糟糕的人工智能可以像图形中的小故障或不真实的声音一样容易地破坏现实主义的幻觉——甚至可能更容易。玩家可能会相信一个粗略绘制的简笔画是一个真实的人,但前提是它不要撞到墙上!

NPC 表面上的智能并不总是与用来模拟它的代码量有关。玩家倾向于将智力归因于并不存在的 NPC。在我们为本章创建的蚂蚁模拟中,蚂蚁在追逐蜘蛛时会形成一个有序的队列。我的一个朋友看到了这一点,并评论说它们在狩猎中合作——但当然蚂蚁是完全独立行动的。有时候,要让玩家相信某样东西是聪明的,需要做的工作少得惊人。

状态机是实现游戏 AI 的一种实用而简单的方式,因为它们将复杂的系统(即大脑)分解成易于实现的较小块。它们不难设计,因为我们习惯于想象其他人或动物在做事时在想什么。把每一个想法都变成计算机代码可能不太实际,但你只需要在游戏中*似行为来模拟它。

我们在本章中创建的简单状态机框架可以在你自己的游戏中使用,以构建令人信服的 AI。与 ant 模拟一样,首先要定义 NPC 的动作,然后弄清楚如何在这些动作之间切换。一旦你把这些写在纸上(如图 7-3 )你就可以开始用代码构建各个州了。

下一章是用 Pygame 渲染三维图形的温和介绍。

八、进入第三维度

游戏通常试图模仿真实世界,或者创造一个离现实不远的世界,玩家仍然能够以某种方式认同它。在过去,这需要玩家真正的信心飞跃,因为技术还不能创造看起来更像现实的视觉效果。但随着技术的进步,游戏设计者开始推动硬件来创建更有说服力的图形。

最初一切都是二维的,因为绘制 2D 精灵是一个相当简单的操作,控制台和计算机可以做得相当好。即使只有 2D 功能,游戏设计者也试图用阴影和移动来创建三维外观。最终,游戏硬件变得能够创建更令人信服的 3D 图形,开发者可以自由地试验额外的维度。早期的 3D 游戏有粗糙的图形,由线条和*坦的无阴影三角形生成,但很快这些图形就演变成了具有数千个多层多边形和逼真照明的丰富场景。

如今,大多数游戏都是 3D 的,家用电脑配有显卡,硬件专用于创建 3D 视觉效果。你桌面上的电脑可以在几分之一秒内生成 3D 图像——几十年前这需要几个小时——你可以在你的 Pygame 应用中访问这些功能。本章讲述了存储 3D 信息和创建图像的基础知识。

创造深度的幻觉

就像电脑游戏中的其他东西一样,3D 是一种幻觉。电视和监视器仍然只能显示二维图像。如果你在玩游戏时把头从一边移到另一边,你将看不到更多的场景,因为它本质上是*的,没有真正的深度。如果你使用的是虚拟现实设备,你可能会得到一些运动。不管它让你感觉如何,这也是一种幻觉。

这种错觉可能相当有说服力,因为我们的大脑高度发达,能够识别三维世界的特征。我们的大脑判断我们正在观看的深度的主要方式是通过结合来自每只眼睛的两个图像。但是即使你闭上一只眼睛,你会发现你能够判断距离,并且在不撞到东西的情况下绕过去(即使比以前难了一点)。这是因为视觉线索,如透视和阴影,也用于理解每只眼睛的图像,我们的大脑下意识地使用这些信息来帮助我们理解深度。因此,即使屏幕上的图像是*面的,如果它包含透视和阴影,它仍然看起来有深度。

3D 图形游戏中的物体必须按照你在现实世界中的预期方式运动。有时,物体以一种看似合理的方式移动就足以产生深度的幻觉。清单 8-1 是一个例子,说明了运动本身是如何创造出具有明显的三维视觉效果的。

清单 8-1 。深度错觉(parallaxstars.py)

import pygame
from pygame.locals import *
from random import randint

class Star(object):

    def init (self, x, y, speed):

        self.x = x
        self.y = y
        self.speed = speed

def run():

    pygame.init()
    screen = pygame.display.set_mode((640, 480), FULLSCREEN)

    stars = []

    # Add a few stars for the first frame
    for n in xrange(200):

        x = float(randint(0, 639))
        y = float(randint(0, 479))
        speed = float(randint(10, 300))
        stars.append( Star(x, y, speed) )

    clock = pygame.time.Clock()

    white = (255, 255, 255)

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                return
            if event.type == KEYDOWN:
                return

    # Add a new star
    y = float(randint(0, 479))
    speed = float(randint(10, 300))
    star = Star(640., y, speed)
    stars.append(star)
    time_passed = clock.tick()
    time_passed_seconds = time_passed / 1000.

    screen.fill((0, 0, 0))

    # Draw the stars
    for star in stars:

        new_x = star.x - time_passed_seconds * star.speed
        pygame.draw.aaline(screen, white, (new_x, star.y), (star.x+1., star.y))
        star.x = new_x

    def on_screen(star):
        return star.x > 0

    # Remove stars that are no longer visible
    stars = filter(on_screen, stars)

    pygame.display.update()

if name == " main__":
    run()

当你运行清单 8-1 时,你会看到一个相当令人信服的星域,不同距离的恒星在屏幕上移动。虽然星域看起来是 3D 的,但是在代码里你不会发现特别陌生的东西;它所做的只是在屏幕上以不同的速度移动一些点。深度印象是你的大脑假设快速物体离你很*,慢速物体离你很远的结果。

理解 3D 空间

为了在 2D 游戏中存储一个实体的位置,你使用一个有两个分量的坐标系,x 和 y,对应于屏幕上的物理像素。对于一个 3D 游戏,你需要使用一个坐标系统和一个叫做 z 的附加组件(见图 8-1 )。该额外组件用于测量屏幕进入离开的距离。当然,Pygame 实际上无法使用 3D 坐标绘制任何东西,因为屏幕是*面的。因此,3D 坐标最终必须转换成 2D 坐标,才能用于在屏幕上渲染任何东西。我们将在本章后面介绍如何做到这一点,但首先我们需要知道如何用 Python 存储 3D 坐标。

9781484209714_Fig08-01.jpg

图 8-1 。三维坐标系统

在三维坐标系中,x 指向右侧,y 指向上方。这不同于我们用来创建 2D 图形的坐标系,在这里 y 指向屏幕的下方。在 3D 中,如果增加 y 分量,坐标会将在屏幕上上移,而不是下移。

根据所使用的图形技术,3D 坐标系中的 z 轴可以指向两种方向之一;它要么将指向屏幕(远离观众),要么将指向屏幕外(朝向观众)。在本书中,我们将使用一个指向屏幕外的正 z 坐标系统。图 8-2 显示了一个间谍机器人的三维坐标系——用一个圆表示——坐标为(7,5,10)。因为这不是一本弹出式图书,所以额外的轴表示为一条对角线。

3D 坐标的单位可以代表任何东西,这取决于游戏的规模。如果你正在写一个第一人称射击游戏,单位可能是米甚至厘米,但是对于一个太空游戏,单位可能代表一个更大的尺度(可能是光年)!假设机器人的坐标以米为单位,士兵阿尔法(玩家角色)站在坐标(0,0,0)处,面朝 z 轴的负方向,机器人将在玩家的右后方 10 米的空中悬停。

9781484209714_Fig08-02.jpg

图 8-2 。3D 坐标系中的机器人

使用 3D 矢量

到目前为止,您已经熟悉了我们在 2D 样本中用来表示位置和方向的二维向量。3D 中的向量与 2D 中的向量相似,但是有三个分量,而不是两个。这些 3D 矢量拥有许多与 2D 相同的功能;它们可以被相加、相减、缩放等等。三维向量可以存储在元组或列表中,但使用专用的类将使计算更容易。清单 8-2 展示了我们如何开始定义一个 3D Vector类。

清单 8-2 。开始 3D 矢量课程

from math import sqrt

class Vector3(object):

    def init (self, x, y, z):

        self.x = x
        self.y = y
        self.z = z

    def add (self, x, y, z):

        return Vector3(self.x + x, self.y + y, self.z + z)

    def get_magnitude(self):

        return sqrt(self.x ** 2 + self.y ** 2 + self.z ** 2)

可以在 3D 中完成的大多数操作都可以扩展到 z 分量。例如,_add_函数非常类似于 2D 版本,但是它也增加了两个向量的 z 分量。

我们将使用游戏对象库中为 3D 代码定义的Vector3类。清单 8-3 展示了我们如何导入和使用这个类。

清单 8-3 。使用游戏对象 Vector3 类

from gameobjects.vector3 import *

A = Vector3(6, 8, 12)
B = Vector3(10, 16, 12)

print("A is", A)
print("B is", B)
print("Magnitude of A is", A.get_magnitude())
print("A+B is", A+B)
print("A-B is", A-B)

print("A normalized is", A.get_normalized())
print("A*2 is", A * 2)

运行此代码会生成以下输出:

A is (6, 8, 12)
B is (10, 16, 12)
Magnitude of A is 15.620499351813308
A+B is (16, 24, 24)
A-B is (-4, -8, 0)
A normalized is (0.384111, 0.512148, 0.768221)
A*2 is (12, 16, 24)

3D 中基于时间的运动

我们可以使用Vector3类在 3D 中进行基于时间的移动,就像在二维中一样。作为一个例子,让我们用一点 3D 矢量数学来计算一个目标矢量,并算出投射武器的中间坐标(见图 8-3 )。

9781484209714_Fig08-03.jpg

图 8-3 。计算目标向量

士兵阿尔法已经从他在图 8-2 中原来的位置走了几米,现在站在点(–6,2,0)。间谍机器人仍在(7,5,10)处盘旋,监视阿尔法的行动。幸运的是,阿尔法敏锐的听觉(或玩家的扬声器)捕捉到了反重力引擎微弱的呼呼声,他决定干掉这个机器人。为了向机器人开火,阿尔法需要计算从他的肩扛式等离子步枪到机器人位置的矢量。

阿尔法可能站在点(–6,2,0)上方,但他的肩膀在点(–6,2,2)离地 2 米,所以这是矢量计算的起点。从起点 A(–6,2,2)减去机器人的位置(点 B 在(7,5,10),得到目标向量(13,3,8)。标准化该向量会产生可用于基于时间的移动的方向向量。清单 8-4 展示了如何用代码做这些计算。

清单 8-4 。创建目标向量

from gameobjects.vector3 import *

A = (-6, 2, 2)
B = (7, 5, 10)
plasma_speed = 100 # meters per second

AB = Vector3.from_points(A, B)
print("Vector to droid is", AB)

distance:to_target = AB.get_magnitude()
print("Distance to droid is", distance:to_target, "meters")

plasma_heading = AB.get_normalized()
print("Heading is", plasma_heading)

运行清单 8-4 中的会产生以下输出:

Vector to droid is (13, 3, 8)
Distance to droid is 15.556349186104045 meters
Heading is (0.835672, 0.192847, 0.514259)

如果我们在游戏中使用这些值来渲染等离子闪电,我们可以通过将方向向量乘以自上一帧以来经过的时间和等离子闪电的速度来计算等离子闪电自上一帧以来移动了多远。将结果与螺栓的当前位置相加,就得到它的新位置。代码看起来会像这样:

bolt_location += plasma_heading * time_passed_seconds * plasma_speed

在您可以在 Pygame 应用中创建 3D 投射物之前,您首先必须学习如何将 3D 坐标转换为 2D 坐标,以便将其渲染到屏幕上——这是下一节的主题。

投影 3D 点

在 3D 空间中存储点就像创建一个三个值的元组,或一个Vector3对象一样简单,但我们不能在 Pygame 的绘图函数中使用这两个函数,因为它们都将坐标作为 2D 点。要在三维坐标上画任何东西,我们首先要把 ?? 投影到 2D 屏幕上。

*行投影

将 3D 坐标转换为 2D 坐标的一种方法是简单地丢弃 z 分量,这就是所谓的*行投影。清单 8-5 显示了一个非常简单的函数,我们可以用它将一个三维坐标转换成*行投影的 2D。

清单 8-5 。执行*行投影的函数

def parallel_project(vector3):

    return (vector3.x, vector3.y)

虽然*行投影很快也很容易做到,但它们并不经常在游戏中使用,因为忽略 z 分量就没有深度感。使用*行投影渲染的 3D 场景有点像通过放大倍数很高的变焦镜头观看;世界看起来是*的,不同距离的物体看起来就像是紧挨着的。图 8-4 显示了一个用*行投影渲染的立方体。

9781484209714_Fig08-04.jpg

图 8-4 。用*行投影渲染的立方体

透视投影

一般来说,游戏和 3D 计算机图形中更常见的投影是透视投影,因为它考虑了物体与观众的距离。透视投影复制了远离观察者的物体看起来比*处的物体小的方式。用透视投影渲染的物体也会看起来向地*线变窄,这种效果被称为透视缩小(见图 8-5 )。清单 8-6 是一个用透视投影投影 3D 坐标并返回结果的函数。

9781484209714_Fig08-05.jpg

图 8-5 。用透视投影渲染的立方体

清单 8-6 。执行透视投影的函数

def perspective_project(vector3, d):

    x, y, z = vector3
    return (x * d/z, –y * d/z)

透视投影涉及的数学比简单的*行投影多一点。perspective_project函数将 x 和 y 坐标乘以d值(我们将在后面讨论),然后除以 z 分量。它也否定了 y 分量(–y),因为 y 轴在 2D 是反方向的。

perspective_project中的d值是观看距离,它是从摄像机到 3D 世界单位中的单位与屏幕上的像素直接对应的位置的距离。例如,如果我们有一个坐标为(10,5,100)的物体,投影的观看距离为 100,我们将它在(11,5,100)处向右移动一个单位,那么它在屏幕上看起来正好移动了一个像素。如果它的 z 值不是 100,它将相对于屏幕移动不同的距离。

图 8-6 显示了观看距离与屏幕宽度和高度的关系。假设玩家(由笑脸表示)正坐在屏幕前面,那么观看距离大约是从屏幕到玩家头部的距离(以像素为单位)。

9781484209714_Fig08-06.jpg

图 8-6 。透视投影中的观看距离

视野

那么我们如何为视距(d)选择一个好的值呢?我们可以通过实验来找到一个使 3D 场景看起来令人信服的值,但是我们可以通过从视场(fov) 、计算d来消除猜测,这是在某一时刻可见的场景的角度范围。对于人类来说,fov 就是左眼到右眼的范围,大概是 180 度。图 8-7 显示了视场和视距之间的关系。当视场角增加(变宽)时,随着更多的场景变得可见,观看距离减少。fov 减小(变窄)则相反;观看距离增加,并且较少的场景可见。

9781484209714_Fig08-07.jpg

图 8-7 。视野

视野是定义 3D 场景中有多少透视的更好方法,但是我们仍然需要透视投影中的d值。为了从 fov 中计算出d,我们需要使用一点三角学。清单 8-7 是一个取 fov 加上屏幕宽度的函数,使用math模块中的tan函数计算视距。

Image 提示通过在互联网上查找公式,你可以在 3D 图形中完成很多工作,但偶尔你可能需要自己算一算。如果数学不是你的强项,不要让这吓到你——你只需要基础知识,尤其是三角学。

清单 8-7 。计算观看距离

from math import tan

def calculate_viewing_distance(fov, screen_width):

    d = (screen_width/2.0) / tan(fov/2.0)
    return d

我通常使用 45 到 60 度之间的值作为我的视野,这给了我一个自然的视角。更高的值可能对赛车游戏有好处,因为增加的视角夸大了速度的效果。较低的值可能对策略游戏更好,因为它们显示了更多的场景。

你也可以根据游戏中发生的事情来调整视野。一个伟大的狙击步枪效果可以通过快速缩小视野,使摄像机放大,然后在玩家开火时将其移回。

3D 世界

让我们编写一个应用来测试我们到目前为止所涉及的概念。因为我们还没有探索如何绘制 3D 对象,我们将通过在 2D 屏幕上投影的 3D 点上绘制图像来构建一个场景。这就产生了一个可识别的 3D 场景,即使图像在接*摄像机时尺寸没有变化(见图 8-8 )。

如果你运行清单 8-8 中的 ,你会看到一个由许多球体图像沿其边缘形成的立方体。通过按光标键,您可以水*和垂直*移“摄像机”;按 Q 和 A 在场景中前后移动。W 和 S 键调整透视投影的观看距离。您可以从立方体的外观和查看图表(绿色)中看到这种效果。尝试观察距离和视野-请注意,宽视野使立方体看起来细长,窄视野使立方体看起来扁*。

Image 注意3D 场景中的一个摄像机只是当前视点;它可以是玩家角色眼中的视角,或者游戏中的任何其他视角。

9781484209714_Fig08-08.jpg

图 8-8 。Pygame 中的一个简单的 3D 引擎

清单 8-8 。简单 3D 引擎(simple3d.py)

import pygame
from pygame.locals import *
from gameobjects.vector3 import Vector3
from math import *
from random import randint

SCREEN_SIZE =  (640, 480)
CUBE_SIZE = 300

def calculate_viewing_distance(fov, screen_width):

    d = (screen_width/2.0) / tan(fov/2.0)
    return d

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, 0)

    default_font = pygame.font.get_default_font()
    font = pygame.font.SysFont(default_font, 24)

    ball = pygame.image.load("ball.png").convert_alpha()

    # The 3D points
    points = []

    fov = 90\. # Field of view
    viewing_distance = calculate_viewing_distance(radians(fov), SCREEN_SIZE[0])

    # Create a list of points along the edge of a cube
    for x in range(0, CUBE_SIZE+1, 20):
        edge_x = x == 0 or x == CUBE_SIZE

        for y in range(0, CUBE_SIZE+1, 20):
            edge_y = y == 0 or y == CUBE_SIZE

            for z in range(0, CUBE_SIZE+1, 20):
                edge_z = z == 0 or z == CUBE_SIZE

                if sum((edge_x, edge_y, edge_z)) >= 2:

                    point_x = float(x) - CUBE_SIZE/2
                    point_y = float(y) - CUBE_SIZE/2
                    point_z = float(z) - CUBE_SIZE/2

                    points.append(Vector3(point_x, point_y, point_z))

    # Sort points in z order
    def point_z(point):
        return point.z
    points.sort(key=point_z, reverse=True)

    center_x, center_y = SCREEN_SIZE
    center_x /= 2
    center_y /= 2

    ball_w, ball_h = ball.get_size()
    ball_center_x = ball_w / 2
    ball_center_y = ball_h / 2

    camera_position = Vector3(0.0, 0.0, -700.)
    camera_speed = Vector3(300.0, 300.0, 300.0)

    clock = pygame.time.Clock()

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()

        screen.fill((0, 0, 0))

        pressed_keys = pygame.key.get_pressed()

        time_passed = clock.tick()
        time_passed_seconds = time_passed / 1000.

        direction = Vector3()
        if pressed_keys[K_LEFT]:
            direction.x = -1.0
        elif pressed_keys[K_RIGHT]:
            direction.x = +1.0

        if pressed_keys[K_UP]:
            direction.y = +1.0
        elif pressed_keys[K_DOWN]:
            direction.y = -1.0

        if pressed_keys[K_q]:
            direction.z = +1.0
        elif pressed_keys[K_a]:
            direction.z = -1.0

        if pressed_keys[K_w]:
            fov = min(179., fov+1.)
            w = SCREEN_SIZE[0]
            viewing_distance = calculate_viewing_distance(radians(fov), w)
        elif pressed_keys[K_s]:
            fov = max(1., fov-1.)
            w = SCREEN_SIZE[0]
            viewing_distance = calculate_viewing_distance(radians(fov), w)

        camera_position += direction * camera_speed * time_passed_seconds

        # Draw the 3D points
        for point in points:

            x, y, z = point - camera_position

            if z > 0:
                x =  x * viewing_distance / z
                y = -y * viewing_distance / z
                x += center_x
                y += center_y
                screen.blit(ball, (x-ball_center_x, y-ball_center_y))

        # Draw the field of view diagram
        diagram_width = SCREEN_SIZE[0] / 4
        col = (50, 255, 50)
        diagram_points = []
        diagram_points.append( (diagram_width/2, 100+viewing_distance/4) )
        diagram_points.append( (0, 100) )
        diagram_points.append( (diagram_width, 100) )
        diagram_points.append( (diagram_width/2, 100+viewing_distance/4) )
        diagram_points.append( (diagram_width/2, 100) )
        pygame.draw.lines(screen, col, False, diagram_points, 2)

        # Draw the text
        white = (255, 255, 255)
        cam_text = font.render("camera = "+str(camera_position), True, white)
        screen.blit(cam_text, (5, 5))
        fov_text = font.render("field of view = %i"%int(fov), True, white)
        screen.blit(fov_text, (5, 35))
        txt = "viewing distance = %.3f"%viewing_distance
        d_text = font.render(txt, True, white)
        screen.blit(d_text, (5, 65))

        pygame.display.update()

if __name__ == "__main__":
    run()

清单 8-8 从创建一个带有立方体边缘坐标的Vector3对象列表开始。然后,这些点按它们的 z 分量排序,以便在渲染时,首先绘制离观察者较*的点。否则,距离点可能会与靠*观看者的距离点重叠,这将打破 3D 的幻觉。

在主循环中,摄像机的位置根据当前按下的键而改变。您可以看到,移动 3D 点的代码与移动 2D 精灵非常相似,只是增加了一个在 3D 场景中前后移动的 z 组件。用基于时间的运动来更新位置的代码实际上与 2D 计算相同;它只是使用了Vector3对象而不是Vector2对象。

代码中的下一步是绘制场景中所有点的循环。首先,通过减去camera_position变量调整该点,使其相对于摄像机。如果产生的 z 分量大于 0,则意味着该点在相机前面,并且可能是可见的-否则,绘制它就没有意义。当点在相机前面时,它通过将 x 和 y 分量乘以观看距离并除以 z 分量来投影。对于 2D 绘图功能,y 轴也翻转指向正确的方向。最后,通过给 x 分量增加一半的宽度(center_x)和给 y 分量增加一半的高度(center_y)来调整 2D 坐标以将“世界”放置在屏幕的中心。

剩下的代码绘制了一个小图,显示了观看距离与屏幕宽度和 fov 的关系。它还在屏幕上显示一些信息,以便您可以看到按键的效果。

如果您想尝试此演示,请尝试添加创建其他对象(如金字塔和球体)的其他点列表。你可能还想让这些“物体”在 3D 中移动,就像我们在前面章节中对 2D 精灵所做的那样。

摘要

具有 3D 视觉效果的游戏最有可能吸引玩家并让他们开心。这是真的,不是因为图形更真实——早期的 3D 游戏实际上看起来比 2D 的同类游戏粗糙——而是因为它们感觉更自然。3D 游戏中的物体可以旋转,可以从不同的角度观看,就像在现实世界中一样。

存储关于 3D 点和方向的信息是对 2D 的简单扩展;我们只是需要一个额外的组件来支持额外的维度。如果你使用游戏对象库中的Vector3类,你会发现大部分的数学运算实际上与 2D 相同,因为 vector 类处理额外的部分。到目前为止,我们在向 3D 转移的过程中所做的所有不同就是将点投影到屏幕上,以使用 Pygame 的绘图功能。实际上有许多类型的投影,但透视投影是最常见的,因为它可以创建看起来很自然的场景。

在下一章,我们将探索如何用 OpenGL 创建丰富的 3D 场景,OpenGL 是许多商业游戏背后的技术,包括雷神之锤系列。你会发现我们在这一章中讨论的一些内容实际上是由 OpenGL 自动完成的,但是我没有浪费你的时间——当你创建 OpenGL 支持的视觉效果时,理解投影和视野将会帮助你。

九、探索第三维度

您已经看到了如何在三维空间中获取一个点,然后将它投影到屏幕上,以便可以对其进行渲染。投影只是渲染 3D 场景过程的一部分;您还需要操纵游戏中的点来逐帧更新场景。本章介绍了矩阵,这是一种数学捷径,用于操纵游戏中物体的位置和方向。

您还将了解如何使用 Pygame 和 OpenGL 来访问显卡的 3D 图形功能,从而创建令人印象深刻的视觉效果,与商业游戏不相上下。

什么是矩阵?

早在电影《??》之前,数学家和游戏程序员就已经在使用矩阵了。矩阵是任意大小的数字网格,但在 3D 图形中,最有用的矩阵是 4 × 4 矩阵。本节介绍如何使用矩阵在 3D 世界中定位对象。

描述一个 3D 物体在游戏中的样子需要一些不同的信息,但是它的基本形状是由一系列的点定义的。在前一章中,我们通过沿着立方体的边缘构建一系列点来创建一个立方体的模型。更典型地,游戏模型的点数列表是从用专用软件创建的文件中读取的。无论模型是如何创建的,它都必须被放置在游戏世界中的某个位置,指向适当的方向,并且可能被缩放到新的大小。3D 模型中点的这些变换是用矩阵完成的。

理解矩阵如何工作需要大量的数学知识,这超出了本书的范围。幸运的是,你不需要知道它们是如何工作的就能使用它们。更重要的是理解矩阵的作用,以及它们如何与屏幕上的 3D 图形相关联。这是教科书中通常没有涉及到的内容,也可能是矩阵有一点神秘的名声的原因。让我们来看一个矩阵,并试着理解它。下面是一种最简单的矩阵:

[ 1 0 0 0 ]
[ 0 1 0 0 ]
[ 0 0 1 0 ]
[ 0 0 0 1 ]

这个矩阵由排列成四行四列的 16 个数字组成。对于 3D 图形中使用的大多数矩阵,只有前三列不同;第四列将由三个零后跟一个 1 组成。

Image 注意矩阵可以包含任意数量的行和列,但是当我在本书中使用词语矩阵时,我指的是 4 × 4 矩阵,通常用于 3D 图形。

前三行表示 3D 空间中的一个轴,它只是指向变换的 x、y 和 z 方向的三个向量。将这三个向量想象成向右、向上向前有助于避免与 x、y 和 z 轴混淆。这些矢量总是相对于被变换的对象。例如,如果游戏角色是一个人形男性,那么矩阵可能在他胸部中央的某个地方。右向量将从他的右臂指向外,上向量将从他的头顶指向外,而前向量将通过他的胸部指向前。

第四行是矩阵*移,这是坐标(0,0,0)将结束的位置,如果用这个矩阵进行变换的话。因为大多数 3D 对象都是围绕原点建模的,所以您可以将*移视为对象变换后的位置。

如果我们用这个矩阵改造一辆坦克,它会在哪里结束?嗯,*移是(0,0,0),所以它会在屏幕的中央。向量是(1,0,0),这意味着坦克的右侧面向 x 轴的方向。向上向量为(0,1,0),面向屏幕正 y 方向的顶部。最后,向前向量是(0,0,1),这将使坦克炮塔直接指向屏幕外。见图 9-1 了解矩阵各部分的分解。

9781484209714_Fig09-01.jpg

图 9-1 。矩阵的组成

Image 有些书显示矩阵的行和列是翻转的,这样翻译的部分在右列而不是底行。游戏程序员通常使用与本书相同的约定,因为以这种方式在内存中存储矩阵会更有效。

使用矩阵类

游戏对象库包含一个名为Matrix44的类,我们可以使用 Pygame。让我们在交互式解释器中试验一下。以下代码显示了如何导入 Matrix44 并开始使用它:

>>> from gameobjects.matrix44 import *
>>> identity = Matrix44()
>>> print(identity)

第一行从gameobjects.matrix44模块导入Matrix44类。第二行创建了一个Matrix44对象,默认为单位矩阵,并将其命名为identity。第三行将 identity 的值打印到控制台,产生以下输出:

[ 1 0 0 0 ]
[ 0 1 0 0 ]
[ 0 0 1 0 ]
[ 0 0 0 1 ]

让我们看看如果我们用单位矩阵来变换一个点会发生什么。下面的代码创建了一个元组(1.0,2.0,3.0),我们将使用它来表示一个点(这里也可以使用一个Vector3对象)。然后,它使用 matrix 对象的transform函数来转换该点,并将结果作为另一个元组返回:

>>> p1 = (1.0, 2.0, 3.0)
>>> identity.transform(p1)

这会产生以下输出:

(1.0, 2.0, 3.0)

返回的点与p1相同,这是我们期望从单位矩阵中得到的。其他矩阵有更有趣(也更有用)的效果,我们将在本章介绍。

小型元件

可以单独访问矩阵的组件(参见图 9-2 )。您可以使用索引运算符([])访问单个值,该运算符获取矩阵中您感兴趣的值的行和列。例如,matrix[3, 1]返回第 3 行第 1 列的值,而matrix[3, 1] = 2.0会将该值设置为 2.0。这个值实际上是矩阵的*移部分的 y 分量,所以改变它将改变一个物体在地面上的高度。

9781484209714_Fig09-02.jpg

图 9-2 。小型元件

可以通过调用get_row方法提取矩阵的各个行,该方法将行作为一个四值元组返回。例如,matrix.get_row(0)返回第零行(第一行,x 轴),而matrix.get_row(3)返回最后一行。还有一个等效的set_row方法,它接受您想要设置的行的索引,以及一个最多四个值的序列,以复制到该行中。与Matrix44类的大多数方法一样,get_rowset_row处理Vector3对象和内置类型。

Matrix44类还包含许多可以用来检索行的属性,这比使用行索引更直观。例如,您可以使用属性m.translate,而不是使用m.get_row(3)来检索矩阵的翻译部分,这具有相同的效果。您也可以用m.translate = (1, 2, 3)替换m.set_row(3, (1, 2, 3))—两者都会将第 3 行的前三个值设置为(1, 2, 3)。表 9-1 列出了可用于访问矩阵中行的属性。

表 9-1 。Matrix44 对象的行属性

|

矩阵属性

|

别名

|
| --- | --- |
| x_axis | 第 0 行 |
| y_axis | 第一行 |
| z_axis | 第 2 行 |
| right | 第 0 行 |
| up | 第一行 |
| forward | 第 2 行 |
| translate | 第三排 |

您还可以使用get_columnset_column来获取和设置矩阵的列,它们的工作方式与 row 方法相同。它们可能没那么有用,因为列不能像行那样提供那么多相关信息。get_column的一个用途是检查右列是否为(0,0,0,1),因为其他任何东西都可能表明代码中有错误。清单 9-1 是一个如何检查矩阵有效性的例子。它使用 Python 的assert关键字来检查矩阵的第 3 列。如果第三列是(0,0,0,1),那么什么都不会发生;否则,Python 会抛出一个AssertionError。您不应该捕捉这些类型的异常;它们是 Python 告诉你代码有问题,你应该调查问题的方式。

Image 提示试着养成写断言条件的习惯。它们是早期捕捉代码中问题的好方法。如果想让 Python 忽略代码中的 assert 语句,用python –O调用脚本。

清单 9-1 。检查矩阵是否有效

from gameobjects.matrix44 import *

identity = Matrix44()
print(identity)
p1 = (1.0, 2.0, 3.0)
identity.transform(p1)

assert identity.get_column(3) == (0, 0, 0, 1), "Something is wrong with this matrix!"

翻译矩阵

一个*移矩阵是一个矩阵,它将一个矢量添加到被变换的点上。如果我们用一个转换矩阵来转换一个 3D 模型的点,它将移动这个模型,使它的中心在世界上一个新的坐标上。您可以通过调用Matrix44.translation来创建一个*移矩阵,它将*移向量作为三个值。下面的代码创建并显示一个翻译矩阵 :

>>> p1 = (1.0, 2.0, 3.0)
>>> translation = Matrix44.translation(10, 5, 2)
>>> print(translation)
>>> translation.transform(p1)

这会产生以下输出:

[  1 0 0 0 ]
[  0 1 0 0 ]
[  0 0 1 0 ]
[ 10 5 2 1 ]

转换矩阵的前三行与单位矩阵相同;*移向量存储在最后一行。当p1被变换时,它的分量被添加到*移中——与向量相加的方式相同。3D 游戏中的每个对象都必须被翻译;否则,一切都将位于屏幕的中心!

操纵矩阵*移是移动 3D 对象的主要方式。您可以将矩阵的*移行视为对象的坐标,并用基于时间的移动来更新它。清单 9-2 是一个基于当前速度向前移动 3D 模型(这里是坦克)的例子。

清单 9-2 。移动 3D 对象的示例

tank_heading = Vector3(tank_matrix.forward)
tank_matrix.translation += tank_heading * tank_speed * time_passed

清单 9-2 中的第一行获取坦克的航向。假设坦克的移动方向与它所指向的方向相同,那么它的前进方向与其forward向量(z 轴)相同。第二行通过将坦克的前进方向乘以坦克的速度和经过的时间来计算坦克自上一帧以来移动的距离。然后将得到的向量添加到矩阵*移中。如果在每一帧都这样做,坦克会在它指向的方向上*稳移动。

Image 注意如果矩阵没有缩放,您只能将正向矢量视为一个方向(见下一节)。如果有,那么你必须将前向向量规范化,使它的长度为 1。如果清单 9-2 中的坦克被缩放,你可以用Vector3(tank_matrix.forward).get_normalized()计算前进方向。

标度矩阵

比例矩阵 用于改变 3D 对象的大小,可以在游戏中创建有用的效果。例如,如果你有一个生存恐怖游戏,里面有许多僵尸在一个荒凉的城市游荡,如果它们的大小完全相同,看起来可能会有点奇怪。高度上的一点变化会让成群的亡灵看起来更有说服力。比例也可以随时间变化以产生其他视觉效果;快速缩放一个红色球体,让它吞没一个敌人,然后慢慢消失,可以产生一个取悦大众的火球效果。

下面的代码创建了一个缩放矩阵,它将对象的维度加倍。当我们用它来变换p1时,我们得到了一个具有两倍于原始分量的点:

>>> scale = Matrix44.scale(2.0)
>>> print(scale)
>>> scale.transform(p1)

这会产生以下输出:

[ 2 0 0 0 ]
[ 0 2 0 0 ]
[ 0 0 2 0 ]
[ 0 0 0 1 ]

比例值也可以小于 1,这将使模型更小。例如,Matrix44.scale(0.5)将创建一个矩阵,使一个三维对象的一半大小。

Image 注意如果你创建一个负比例值的比例矩阵,它会产生翻转一切的效果,使左变成右,上变成下,前变成后!

您还可以为每个轴创建一个具有三个不同值的缩放矩阵,从而在每个方向上以不同的方式缩放对象。例如,Matrix44.scale(2.0, 0.5, 3.0)将创建一个矩阵,使一个对象的宽度增加一倍,高度增加一半,深度增加三倍!您不太可能经常需要它,但它可能很有用。例如,要模拟汽车轮胎中的灰尘,可以不均匀地缩放灰尘云的模型,使其看起来像是轮胎扬起的。

要推断矩阵的比例,请查看左上角 3 × 3 值中的轴向量。在未缩放的矩阵中,轴的每个向量的长度为 1。对于比例矩阵,每个向量的长度(即幅度)是对应轴的比例。比如scale矩阵中的第一个轴向量是(2,0,0),长度为 2。在所有矩阵中,长度可能不像这样明显,因此这段代码演示了如何寻找 x 轴的刻度:

>>> x_axis_vector = Vector3(scale.x_axis)
>>> print(x_axis_vector.get_magnitude())

这会产生以下结果:

2.0

旋转矩阵

3D 游戏中的每一个物体都必须在某个点旋转,这样它才能面向合适的方向。大多数东西都面向它们移动的方向,但是你可以将 3D 对象定向到任何你想要的方向。旋转也是吸引注意力的好方法。比如加电(弹药,额外生命等。)经常围绕 y 轴旋转,因此它们从背景景色中脱颖而出。

最简单的旋转矩阵是围绕 x、y 或 z 轴的旋转,你可以用Matrix44中的x_rotationy_rotationz_rotation类方法创建它(参见图 9-3 )。

9781484209714_Fig09-03.jpg

图 9-3 。旋转矩阵

为了预测一个点将向哪个方向旋转,想象你自己沿着旋转轴看。正转逆时针,负转顺时针。让我们在交互式解释器中试验一下。我们将在(0,10,0),–45 度绕 z 轴旋转一个点(见图 9-4 )。

>>> z_rotate = Matrix44.z_rotation(radians(–45))
>>> print(z_rotate)
>>> a = (0, 10, 0)
>>> z_rotate.transform(a)

这将显示一个 z 旋转矩阵,以及使用它来*移点(0,10,0)的结果:

[ 0.707107     -0.707107        0       0 ]
[ 0.707107      0.707107        0       0 ]
[ 0                    0        1       0 ]
[ 0                    0        0       1 ]

如果原始点是指针在 12 点钟的末端,那么变换后的点将位于 1 和 2 之间的中间位置。

9781484209714_Fig09-04.jpg

图 9-4 。绕 z 轴的旋转

当使用 3D 旋转时,我发现为我的头部可视化一个轴很有帮助,其中(0,0,0)在我大脑的某个地方。x 轴指向我右耳外,y 轴指向我头顶,z 轴指向我鼻子外。如果我绕着 x 轴旋转我的头,我会上下点头。绕 y 轴旋转会让我向左或向右转头。围绕 z 轴的旋转会让我好奇的把头从一边倾斜到另一边。或者,当你考虑旋转时,你可以用你的拇指和前两个手指指向每个轴的正方向,并实际旋转你的手。

矩阵乘法

通常在一个游戏中,你需要对一个 3D 物体进行多次变换。对于一个坦克游戏,你可能想要*移到世界上的一个位置,然后旋转到它前进的方向。你可以用两个矩阵来改造坦克,但是也可以通过使用矩阵乘法 来创建一个具有组合效果的单一矩阵。当你把一个矩阵乘以另一个矩阵时,你得到的是一个完成两种变换的矩阵。让我们通过将两个*移矩阵相乘来测试一下:

>>> translate1 = Matrix44.translation(5, 10, 2)
>>> translate2 = Matrix44.translation(-7, 2, 4)
>>> print(translate1 * translate2)

这将打印出translate1乘以translate2的结果:

[ 1     0       0       0 ]
[ 0     1       0       0 ]
[ 0     0       1       0 ]
[ –2    12      6       1 ]

结果也是一个翻译矩阵。矩阵中最后一行(翻译部分)是(–2,12,6),这是由(5,10,2)和(–7,2,4)翻译的组合效果。矩阵不需要属于相同的类型就可以相乘。让我们试着用一个旋转矩阵乘以一个*移矩阵。下面的代码创建了两个矩阵translaterotate,以及一个矩阵translate_rotate,它具有两种效果:

>>> translate = Matrix44.translation(5, 10, 0)
>>> rotate = Matrix44.y_rotation(radians(45))
>>> translate_rotate = translate * rotate
>>> print(translate_rotate)

这将显示两个矩阵相乘的结果:

[ 0.707107      0       -0.707107       0 ]
[ 0             1       0               0 ]
[ 0.707107      0       0.707107        0 ]
[ 5             10      0               1 ]

如果我们用translate_rotate变换一辆坦克,它会把它放在坐标(5,10,0)上,绕 y 轴旋转 45 度。

虽然矩阵乘法类似于数字相乘,但有一个显著的区别:乘法的顺序很重要。对于数字,AB 的结果与 BA 相同,但如果 A 和 B 是矩阵,这就不成立。我们生成的translate_rotate矩阵首先将对象*移到(5,10,0),然后围绕其中心点旋转它。如果我们以相反的顺序做乘法,得到的矩阵将会不同。下面的代码演示了这一点:

>>> rotate_translate = rotate * translate
>>> print(rotate_translate)

这将显示以下矩阵:

[ 0.707107      0       -0.707107       0 ]
[ 0             1       0               0 ]
[ 0.707107      0       0.707107        0 ]
[ 3.535534      10      -3.535534       1 ]

如您所见,这导致了不同的矩阵。如果我们用rotate_translate转换一个模型,它将首先围绕 y 轴旋转它,然后然后*移它,但是因为*移是相对于旋转发生的,所以物体将会在完全不同的地方结束。作为一个经验法则,你应该先做*移,然后是旋转,这样你就可以预测物体的最终位置。

行动矩阵

目前的理论已经足够了;现在让我们运用矩阵和变换的知识来做一个有趣的演示。当你运行清单 9-3 时,你会看到另一个立方体的边缘被精灵渲染。用于转换立方体的矩阵显示在屏幕的左上角。最初的转换是单位矩阵,它将立方体直接放在屏幕的中间,z 轴朝向你。如果我们有一个坦克模型,而不是一个立方体,那么它将在屏幕外面向,面向你。

按下 Q 和 A 键,围绕 x 轴旋转立方体;按 W 和 S 使其绕 y 轴旋转;按 E 和 D 键可以绕 z 轴旋转。当立方体旋转时,生成的变换矩阵被显示出来(见图 9-5 )。查看创建矩阵的代码(粗体);它首先创建一个 x 旋转,然后乘以 y 旋转,再乘以 z 旋转。

9781484209714_Fig09-05.jpg

图 9-5 。实际的 3D 转换

Image 提示创建关于所有三个轴的变换的一个更快的方法是使用xyz_rotation函数,它需要三个角度。

清单 9-3 。矩阵变换在行动(rotation3d.py)

import pygame
from pygame.locals import *
from gameobjects.vector3 import Vector3
from gameobjects.matrix44 import Matrix44 as Matrix
from math import *
from random import randint

SCREEN_SIZE =  (640, 480)
CUBE_SIZE = 300

def calculate_viewing_distance(fov, screen_width):

    d = (screen_width/2.0) / tan(fov/2.0)
    return d

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, 0)

    font = pygame.font.SysFont("courier new", 16, True)

    ball = pygame.image.load("ball.png").convert_alpha()

    points = []

    fov = 75\. # Field of view
    viewing_distance = calculate_viewing_distance(radians(fov), SCREEN_SIZE[0])

    # Create a list of points along the edge of a cube
    for x in range(0, CUBE_SIZE+1, 10):
        edge_x = x == 0 or x == CUBE_SIZE

        for y in range(0, CUBE_SIZE+1, 10):
            edge_y = y == 0 or y == CUBE_SIZE

            for z in range(0, CUBE_SIZE+1, 10):
                edge_z = z == 0 or z == CUBE_SIZE

                if sum((edge_x, edge_y, edge_z)) >= 2:

                    point_x = float(x) - CUBE_SIZE/2
                    point_y = float(y) - CUBE_SIZE/2
                    point_z = float(z) - CUBE_SIZE/2

                    points.append(Vector3(point_x, point_y, point_z))

    def point_z(point):
        return point[2]

    center_x, center_y = SCREEN_SIZE
    center_x /= 2
    center_y /= 2

    ball_w, ball_h = ball.get_size()
    ball_center_x = ball_w / 2
    ball_center_y = ball_h / 2

    camera_position = Vector3(0.0, 0.0, 600.)

    rotation = Vector3()
    rotation_speed = Vector3(radians(20), radians(20), radians(20))

    clock = pygame.time.Clock()

    # Some colors for drawing
    red = (255, 0, 0)
    green = (0, 255, 0)
    blue = (0, 0, 255)
    white = (255, 255, 255)

    # Labels for the axes
    x_surface = font.render("X", True, white)
    y_surface = font.render("Y", True, white)
    z_surface = font.render("Z", True, white)

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()

        screen.fill((0, 0, 0))

        time_passed = clock.tick()
        time_passed_seconds = time_passed / 1000.

        rotation_direction = Vector3()

        #Adjust the rotation direction depending on key presses
        pressed_keys = pygame.key.get_pressed()

        if pressed_keys[K_q]:
            rotation_direction.x = +1.0
        elif pressed_keys[K_a]:
            rotation_direction.x = -1.0

        if pressed_keys[K_w]:
            rotation_direction.y = +1.0
        elif pressed_keys[K_s]:
            rotation_direction.y = -1.0

        if pressed_keys[K_e]:
            rotation_direction.z = +1.0
        elif pressed_keys[K_d]:
            rotation_direction.z = -1.0

        # Apply time based movement to rotation
        rotation += rotation_direction * rotation_speed * time_passed_seconds

        # Build the rotation matrix
        rotation_matrix = Matrix.x_rotation(rotation.x)
        rotation_matrix *= Matrix.y_rotation(rotation.y)
        rotation_matrix *= Matrix.z_rotation(rotation.z)

        transformed_points = []

        # Transform all the points and adjust for camera position
        for point in points:

            p = rotation_matrix.transform_vec3(point) - camera_position

            transformed_points.append(p)

        transformed_points.sort(key=point_z)

        # Perspective project and blit all the points
        for x, y, z in transformed_points:

            if z < 0:
                x = center_x + x * -viewing_distance / z
                y = center_y + -y * -viewing_distance / z

                screen.blit(ball, (x-ball_center_x, y-ball_center_y))

        # Function to draw a single axes, see below
        def draw_axis(color, axis, label):

            axis = rotation_matrix.transform_vec3(axis * 150.)
            SCREEN_SIZE =  (640, 480)
            center_x = SCREEN_SIZE[0] / 2.0
            center_y = SCREEN_SIZE[1] / 2.0
            x, y, z = axis - camera_position

            x = center_x + x * -viewing_distance / z
            y = center_y + -y * -viewing_distance / z

            pygame.draw.line(screen, color, (center_x, center_y), (x, y), 2)

            w, h = label.get_size()
            screen.blit(label, (x-w/2, y-h/2))

        # Draw the x, y and z axes
        x_axis = Vector3(1, 0, 0)
        y_axis = Vector3(0, 1, 0)
        z_axis = Vector3(0, 0, 1)

        draw_axis(red, x_axis, x_surface)
        draw_axis(green, y_axis, y_surface)
        draw_axis(blue, z_axis, z_surface)

        # Display rotation information on screen
        degrees_txt = tuple(degrees(r) for r in rotation)
        rotation_txt = "Rotation: Q/A %.3f, W/S %.3f, E/D %.3f" % degrees_txt
        txt_surface = font.render(rotation_txt, True, white)
        screen.blit(txt_surface, (5, 5))

        # Displat the rotation matrix on screen
        matrix_txt = str(rotation_matrix)
        txt_y = 25
        for line in matrix_txt.split('\n'):
            txt_surface = font.render(line, True, white)
            screen.blit(txt_surface, (5, txt_y))
            txt_y += 20

        pygame.display.update()

if __name__ == "__main__":
    run()

清单 9-3 中的矩阵将立方体的点转换到它们在屏幕上的最终位置。游戏在渲染 3D 世界的过程中会进行许多这样的转换,并且拥有比 2D 精灵更复杂的图形。在接下来的部分,你将学习如何连接模型中的点,并使用光照来创建立体的 3D 模型。

OpenGL 简介

今天的图形卡配备了专用于绘制 3D 图形的芯片,但情况并非总是如此;在家用电脑上 3D 游戏的早期,程序员必须编写代码来绘制每个游戏的图形。在软件中绘制多边形(游戏中使用的形状)非常耗时,因为处理器必须单独计算每个像素。当具有 3D 加速功能的显卡变得流行时,它们释放了处理器来处理游戏的其他方面,如人工智能,从而产生了外观更好、游戏性更丰富的游戏。

OpenGL 是一个应用编程接口(API ),用于处理图形卡的 3D 功能。还有其他的 3D API,但是我们将使用 OpenGL,因为它很好地支持跨*台;OpenGL 驱动的游戏可以在许多不同的计算机和控制台上运行。它默认安装在 Pygame 运行的所有主要*台上,通常作为图形驱动的一部分。

在 Pygame 中使用 OpenGL 有一个缺点。对于一个 OpenGL 游戏,你不能从一个表面 blit 到屏幕上,或者用任何pygame.draw函数直接画到屏幕上。你可以使用任何其他不画到屏幕上的 Pygame 模块,比如pygame.keypygame.timepygame.image。使用 OpenGL 时,Pygame 脚本的事件循环和一般结构不会改变,因此您仍然可以应用在前面章节中学到的知识。

安装 PyOpenGL

虽然 OpenGL 可能已经安装在您的系统上,但是您仍然需要安装 PyOpenGL,这是一个用 Python 语言连接您计算机上的 OpenGL 驱动程序的模块。你可以用 pip 安装 PyOpenGL,方法是打开 cmd.exe、bash 或者你碰巧使用的 shell,然后做:

pip install PyOpenGL

有关 PyOpenGL 的更多信息,请访问该项目的网站http://pyopengl.sourceforge.net/。关于 OpenGL 的最新消息,请看http://www.opengl.org/ .

Image 提示 Easy Install 是一个非常有用的工具,因为它可以自动找到并安装大量的 Python 模块。

正在初始化 OpenGL

PyOpenGL 模块由许多函数组成,这些函数可以用一行代码导入:

from OpenGL.GL import *

这一行导入的是以gl开头的 OpenGL 函数,比如glVertex,我们后面会讲到。要开始在 PyGame 中使用 PyOpenGL,您几乎总是需要以下导入:

from OpenGL.GL import *
from OpenGL.GLU import *
import pygame
from pygame.locals import *

包含一些我们将要使用的屏幕定义,OpenGL。GL 和 OpenGL。GLU 是 OpenGL 的核心模块。

在使用这些模块中的任何函数之前,您必须首先告诉 Pygame 创建一个 OpenGL 显示表面。虽然这个表面不同于典型的 2D 显示表面,但它是用pygame.display.set_mode函数以通常的方式创建的。下面一行创建了一个名为screen的 640 × 480 的 OpenGL 表面:

screen = pygame.display.set_mode((640, 480), HWSURFACE|OPENGL|DOUBLEBUF)

OPENGL标志告诉 Pygame 创建一个 OpenGL 表面;HWSURFACE在硬件中创建,对加速 3D 很重要;而DOUBLEBUF使其双缓冲,减少闪烁。您可能还想添加FULLSCREEN来扩展显示以填充整个屏幕,但在开发时以窗口模式工作会很方便。

OpenGL 优先

OpenGL 包含几个矩阵,应用于你在屏幕上绘制的坐标。最常用的两种叫做GL_PROJECTIONGL_MODELVIEW。投影矩阵(GL_PROJECTION)获取一个 3D 坐标,并将其投影到 2D 空间,以便将其渲染到屏幕上。在我们的 3D 实验中,我们一直在手动进行这一步——它基本上是乘以视角距离并除以 z 分量。模型视图矩阵实际上是两个矩阵的组合:模型矩阵变换(*移、缩放、旋转等。)模型在世界中的位置和视图矩阵调整对象相对于摄像机(通常是玩家角色的视点)。

调整显示大小

在我们开始在屏幕上绘制任何东西之前,我们首先必须告诉 OpenGL 显示器的尺寸,并设置好GL_PROJECTIONGL_MODELVIEW矩阵(见清单 9-4 )。

清单 9-4 。调整视口大小

def resize(width, height):
    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(60, float(width)/height, 1, 10000)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

清单 9-4 中的resize函数获取屏幕的宽度和高度,并且应该在显示初始化或屏幕尺寸改变时调用。对glViewport的调用告诉 OpenGL 我们要使用坐标为(0,0)的屏幕区域,大小为(widthheight),也就是整个屏幕。下一行调用glMatrixMode(GL_PROJECTION),它告诉 OpenGL 所有进一步的矩阵调用都将应用于投影矩阵。接下来是对glLoadIdentity的调用,它将投影矩阵重置为 identity,以及对gluPerspective(来自 GLU 库)的调用,它设置一个标准的透视投影矩阵。这个函数有四个参数:摄像机的视野,长宽比(宽度除以高度),然后是*剪裁*面和远剪裁*面。这些剪裁*面定义了可以“看见”的距离范围;玩家看不到任何超出这个范围的东西。3D 屏幕中的可视区域被称为可视*截头体 (参见图 9-6 ),它类似于顶部被切掉一部分的金字塔。

9781484209714_Fig09-06.jpg

图 9-6 。观察*截头体

正在初始化 OpenGL 功能

resize函数足以开始使用 OpenGL 函数来渲染屏幕,但是我们应该设置一些其他的东西来使它更有趣(参见清单 9-5 )。

清单 9-5 。正在初始化 OpenGL

def init():

    glEnable(GL_DEPTH_TEST)
    glClearColor(1.0, 1.0, 1.0, 0.0)

    glShadeModel(GL_FLAT)
    glEnable(GL_COLOR_MATERIAL)

    glEnable(GL_LIGHTING)
    glEnable(GL_LIGHT0)
    glLight(GL_LIGHT0, GL_POSITION, (0, 1, 1, 0))

init函数做的第一件事是用GL_DEPTH_TEST调用glEnable,它告诉 OpenGL 启用 Z 缓冲区。这确保了远离相机的对象不会被绘制在靠*相机的对象上,而不管我们在代码中绘制它们的顺序如何。

glEnable函数用于启用 OpenGL 特性,glDisable用于禁用 OpenGL 特性。这两个函数都采用以GL_开头的大写常量之一。我们将在本书中介绍一些你可以在游戏中使用的元素,但是完整的列表请参见 OpenGL 文档。

init中的第二行设置了的清晰颜色,这是屏幕上未绘制部分的颜色(相当于在 2D 示例代码中自动调用screen.fill)。在 OpenGL 中,颜色以红色、绿色、蓝色和 alpha 分量的四个值给出,但不是 0 到 255 之间的值,而是使用 0 到 1 之间的值。

函数中的其余行初始化 OpenGL 的照明功能,该功能根据 3D 世界中大量灯光的位置自动为 3D 对象着色。对glShadeModel的调用将着色模型设置为GL_FLAT,用于着色多面物体,如立方体或任何有边缘表面的物体。阴影模型的另一个设置是GL_SMOOTH,它更适合给弯曲的物体加阴影。对glEnable(GL_COLOR_MATERIAL)的调用告诉 OpenGL 我们想要启用材质,这是定义表面如何与光源交互的设置。例如,我们可以通过调整其材质属性,使球体看起来像大理石一样高度抛光,或者像一块水果一样柔软。

清单 9-5 的剩余部分启用照明(glEnable(GL_LIGHTING))和零照明(glEnable(GL_LIGHT0))。在 OpenGL 中你可以打开许多不同的灯;它们被编号为GL_LIGHT0GL_LIGHT1GL_LIGHT2等等。在一个游戏中,你至少要有一个灯光(可能是太阳的),和其他的灯光,比如头灯,灯,或者特效。例如,在火球效果中放置光源将确保它照亮周围的地形。

最后一行将灯光零点的位置设置为(0,1,1,0)。这个元组中的前三个值是光线的 x、y 和 z 坐标;最后一个值告诉 OpenGL 使其成为一个方向光,这将创建一个具有*行光线的光源,类似于太阳。如果最后一个值是 1,OpenGL 创建一个点光源,看起来像一个特写镜头,如灯泡,蜡烛,或等离子火球。点光源和定向光源的区别见图 9-7 。

Image 提示你可以通过glGetInteger(GL_MAX_LIGHTS)获得你的 OpenGL 驱动支持的灯光数量。通常您会得到八个,但它会根据您的*台而有所不同。

9781484209714_Fig09-07.jpg

图 9-7 。OpenGL 光源

三维绘图

现在我们已经初始化了 OpenGL 并创建了光源,我们可以开始绘制 3D 形状了。OpenGL 支持许多可以用来构建 3D 场景的图元,比如点、线和三角形。根据原语的类型和启用的 OpenGL 特性,每一个都需要一些信息。正因为如此,在 2D Pygame 中,每个原语没有单一的函数。这些信息是在一些函数调用中给出的,当 OpenGL 获得了所有需要的信息后,它就可以绘制图元了。

要在 OpenGL 中绘制一个图元,首先调用glBegin,用一个图元常量(见表 9-2 )。接下来,向 OpenGL 发送绘制图元所需的信息。它至少需要一些 3D 点,用glVertex函数指定(一个顶点是形成形状的一部分的点),但是你可以给它其他信息,比如用glColor函数给它颜色。一旦给出了所有的信息,调用glEnd,它告诉 OpenGL 所有的信息都已经提供了,可以用它来绘制图元。

Image 注意glVertex的调用应该总是在一个顶点的其他信息被给出之后。

表 9-2 。OpenGL 图元

|

常数

|

原始的

|
| --- | --- |
| GL_POINTS | 画点 |
| GL_LINES | 绘制单独的线条 |
| GL_LINE_STRIP | 绘制连接线 |
| GL_LINE_LOOP | 绘制连接线,最后一个点连接到第一个点 |
| GL_TRIANGLES | 绘制三角形 |
| GL_TRIANGLE_STRIPS | 绘制三角形,其中每个附加顶点与前面两个顶点形成一个新的三角形 |
| GL_QUADS | 绘制四边形(有四个顶点的形状) |
| GL_QUAD_STRIP | 绘制四边形条带,其中每两个顶点都与前两个顶点相连 |
| GL_POLYGON | 绘制多边形(具有任意数量顶点的形状) |

清单 9-6 是一个如何用 OpenGL 绘制红色方块的例子。第一行告诉 OpenGL 你想画四边形(有四个点的形状)。下一行发送红色(1.0,0.0,0.0),所以在下一次调用glColor之前,所有顶点都是红色的。对glVertex的四次调用发送了正方形每个角的坐标,最后,对glEnd的调用告诉 OpenGL 你已经完成了顶点信息的发送。有了四个顶点,OpenGL 可以绘制一个四边形,但是如果你给它更多的顶点,它会为你发送的每四个顶点绘制一个四边形。

清单 9-6 。绘制红色方块的伪代码

glBegin(GL_QUADS)
glColor(1.0, 0.0, 0.0) # Red
glVertex(100.0, 100.0, 0.0) # Top left
glVertex(200.0, 100.0, 0.0) # Top right
glVertex(200.0, 200.0, 0.0) # Bottom right
glVertex(100.0, 200.0, 0.0) # Bottom left

glEnd()

标准

如果您启用了 OpenGL 光照,您将需要发送一条称为法线的图元附加信息,它是一个面向 3D 形状外部的单位向量(长度为 1.0 的向量)。该向量对于计算场景中灯光的明暗度是必需的。例如,如果你在屏幕中心有一个立方体沿轴对齐,正面的法线是(0,0,1),因为它正对着 z 轴,而右边的法线是(1,0,0),因为它正对着 x 轴(见图 9-8 )。

要向 OpenGL 发送一个法线,使用glNormal3d函数,它为法线向量取三个值,或者使用glNormal3dv函数,它取三个值的序列。例如,如果清单 9-6 中的正方形是一个立方体的正面,你可以用glNormal3d(0, 0, 1)glNormal3dv(front_vector)设置法线。后者很有用,因为它可以和Vector3对象一起使用。如果你使用*面阴影(glShadeModel(GL_FLAT)),你将需要每个面一个法线。对于*滑着色(glShadeModel(GL_SMOOTH)),你需要提供一个每个顶点的法线。

9781484209714_Fig09-08.jpg

图 9-8 。立方体的法线

显示列表

如果你有很多图元要画——这是 3D 游戏的典型情况——那么进行所有必要的调用来把它们都画出来会很慢。一次将许多图元发送到 OpenGL 比一次发送一个更快。有几种方法可以做到这一点,但最简单的方法之一是使用显示列表

你可以把一个显示列表想象成一些已经被记录下来的 OpenGL 函数调用,并且可以以最高速度回放。要创建显示列表,首先调用glGenLists(1),它返回一个id值来标识显示列表。然后用id和常量GL_COMPILE调用glNewList,开始编译显示列表。当你完成发送原语到 OpenGL 后,调用glEndList结束编译过程。一旦你编译了显示列表,用id调用glCallList以最大速度绘制记录的图元。显示列表可以让你创建和商业产品一样快的游戏,所以养成使用它们的习惯是个好主意!清单 9-7 是一个如何创建一个显示列表来绘制坦克模型的例子。它假设有一个函数draw_tank,该函数将图元发送到 OpenGL。

一旦你创建了一个显示列表,你可以通过在每次调用glCallList(tank_display_id)之前设置不同的变换矩阵,在同一个场景中多次绘制它。

清单 9-7 。创建显示列表

# Create a display list
tank_display_list = glGenLists(1)
glNewList(tank_display_list, GL_COMPILE)

draw_tank()

# End the display list
glEndList()

存储 3D 模型

3D 对象是图元的集合,通常是三角形或四边形,它们构成了更大形状的一部分。例如,可以用六个四边形创建一个立方体,每边一个。更复杂的形状,尤其是像人或长着虫眼的外星人这样的有机形状,需要更多的图元来创建。存储模型最有效的方法是保存一个顶点列表,以及关于使用哪些点来绘制面(图元)的附加信息。例如,一个立方体可以存储为六个顶点(每个角一个),而面将作为四个索引存储到列表中(见图 9-9 )。

这是模型在 3D 编辑软件生成的文件中的典型存储方式。虽然有各种不同的格式,但它们都包含一个顶点列表和一个连接顶点和图元的索引列表。我们将在本书的后面讨论如何阅读这些模型。

9781484209714_Fig09-09.jpg

图 9-9 。面和顶点

观看 OpenGL 的运行

我们在一章中已经讲了足够多的理论;让我们把我们所学的付诸实践。我们将使用 OpenGL 创建一个非常简单的由立方体组成的世界,并给玩家飞行和探索的能力。

当你运行清单 9-8 时,你会发现自己置身于一个五颜六色的迷宫。使用左右光标键左右*移,使用 Q 和 A 键前后移动。效果很像第一人称射击游戏,但如果你按下向上或向下光标键,你会发现你实际上可以在 3D 世界的上方或下方飞行(见图 9-10 )。如果你按 Z 或 X 键,你也可以滚动摄像机。

那么这个世界是怎么创造的呢?清单 9-8 中的Map类读入一个小位图(map.png)并遍历每个像素。当它找到一个非白色像素时,它会在 3D 中的相应点创建一个彩色立方体。Cube类包含一个顶点、法线和法线索引的列表,这些索引定义了立方体的每条边使用了哪些顶点,它使用这些信息来绘制六条边。

整个世界通过一个单独的摄像机矩阵(camera_matrix)进行变换,当用户按下按键时,该矩阵被修改。当用户旋转相机时,该矩阵乘以旋转矩阵,并且*移行被调整以向前和向后移动相机。旋转和*移都使用熟悉的基于时间的计算来提供一致的速度。

在渲染 3D 世界之前,我们必须将相机矩阵发送到 OpenGL。下面的代码行上传相机矩阵到 OpenGL:

glLoadMatrixd(camera_matrix.get_inverse().to_opengl())

get_inverse函数返回矩阵的,这是一个与原始矩阵完全相反的矩阵。我们之所以用反的,而不是原的,是因为我们想把世界上的一切都转换成相对于相机的。换句话说,如果你正直视一个物体,并把头转向右边,那么这个物体现在在你视野的左边??。相机矩阵也一样;世界正以相反的方式转变。

Matrix44to_opengl函数将矩阵转换为单个列表,这是glLostMatrixd将矩阵发送到 OpenGL 所需的格式。一旦发送了矩阵,3D 世界中的一切都将被转换成与摄像机相关。

Image 注意这可能看起来有点奇怪,但是当你在一个 3D 世界里移动相机时,你实际上是在改变世界而不是相机!

9781484209714_Fig09-10.jpg

图 9-10 。立方体世界

清单 9-8 。在立方体世界飞来飞去!(firstopengl.py)

from math import radians

from OpenGL.GL import *
from OpenGL.GLU import *

import pygame
from pygame.locals import *

from gameobjects.matrix44 import *
from gameobjects.vector3 import *

SCREEN_SIZE = (800, 600)

def resize(width, height):

    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(60.0, float(width)/height, .1, 1000.)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():

    glEnable(GL_DEPTH_TEST)

    glShadeModel(GL_FLAT)
    glClearColor(1.0, 1.0, 1.0, 0.0)

    glEnable(GL_COLOR_MATERIAL)

    glEnable(GL_LIGHTING)
    glEnable(GL_LIGHT0)
    glLight(GL_LIGHT0, GL_POSITION,  (0, 1, 1, 0))

class Cube(object):

    def __init__(self, position, color):

        self.position = position
        self.color = color

    # Cube information

    num_faces = 6

    vertices = [ (0.0, 0.0, 1.0),
                 (1.0, 0.0, 1.0),
                 (1.0, 1.0, 1.0),
                 (0.0, 1.0, 1.0),
                 (0.0, 0.0, 0.0),
                 (1.0, 0.0, 0.0),
                 (1.0, 1.0, 0.0),
                 (0.0, 1.0, 0.0) ]

    normals = [ (0.0, 0.0, +1.0),  # front
                (0.0, 0.0, -1.0),  # back
                (+1.0, 0.0, 0.0),  # right
                (-1.0, 0.0, 0.0),  # left
                (0.0, +1.0, 0.0),  # top
                (0.0, -1.0, 0.0) ] # bottom

    vertex_indices = [ (0, 1, 2, 3),  # front
                       (4, 5, 6, 7),  # back
                       (1, 5, 6, 2),  # right
                       (0, 4, 7, 3),  # left
                       (3, 2, 6, 7),  # top
                       (0, 1, 5, 4) ] # bottom

    def render(self):

        # Set the cube color, applies to all vertices till next call
        glColor( self.color )

        # Adjust all the vertices so that the cube is at self.position
        vertices = []
        for v in self.vertices:
            vertices.append( tuple(Vector3(v)+ self.position) )

        # Draw all 6 faces of the cube
        glBegin(GL_QUADS)

        for face:no in range(self.num_faces):

            glNormal3dv( self.normals[face:no] )

            v1, v2, v3, v4 = self.vertex_indices[face:no]

            glVertex( vertices[v1] )
            glVertex( vertices[v2] )
            glVertex( vertices[v3] )
            glVertex( vertices[v4] )

        glEnd()

class Map(object):

    def __init__(self):

        map_surface = pygame.image.load("map.png")
        map_surface.lock()

        w, h = map_surface.get_size()

        self.cubes = []

        # Create a cube for every non-white pixel
        for y in range(h):
            for x in range(w):

                r, g, b, a = map_surface.get_at((x, y))

                if (r, g, b) != (255, 255, 255):

                    gl_col = (r/255.0, g/255.0, b/255.0)
                    position = (float(x), 0.0, float(y))
                    cube = Cube( position, gl_col )
                    self.cubes.append(cube)

        map_surface.unlock()

        self.display_list = None

    def render(self):

        if self.display_list is None:

            # Create a display list
            self.display_list = glGenLists(1)
            glNewList(self.display_list, GL_COMPILE)

            # Draw the cubes
            for cube in self.cubes:
                cube.render()

            # End the display list
            glEndList()

        else:

            # Render the display list
            glCallList(self.display_list)

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE|OPENGL|DOUBLEBUF)

    resize(*SCREEN_SIZE)
    init()

    clock = pygame.time.Clock()

    # This object renders the 'map'
    map = Map()

    # Camera transform matrix
    camera_matrix = Matrix44()
    camera_matrix.translate = (10.0, .6, 10.0)

    # Initialize speeds and directions
    rotation_direction = Vector3()
    rotation_speed = radians(90.0)
    movement_direction = Vector3()
    movement_speed = 5.0

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
        quit()
            if event.type == KEYUP and event.key == K_ESCAPE:
                pygame.quit()
        quit()

        # Clear the screen, and z-buffer
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        time_passed = clock.tick()
        time_passed_seconds = time_passed / 1000.

        pressed = pygame.key.get_pressed()

        # Reset rotation and movement directions
        rotation_direction.set(0.0, 0.0, 0.0)
        movement_direction.set(0.0, 0.0, 0.0)

        # Modify direction vectors for key presses
        if pressed[K_LEFT]:
            rotation_direction.y = +1.0
        elif pressed[K_RIGHT]:
            rotation_direction.y = -1.0
        if pressed[K_UP]:
            rotation_direction.x = -1.0
        elif pressed[K_DOWN]:
            rotation_direction.x = +1.0
        if pressed[K_z]:
            rotation_direction.z = -1.0
        elif pressed[K_x]:
            rotation_direction.z = +1.0
        if pressed[K_q]:
            movement_direction.z = -1.0
        elif pressed[K_a]:
            movement_direction.z = +1.0

        # Calculate rotation matrix and multiply by camera matrix
        rotation = rotation_direction * rotation_speed * time_passed_seconds
        rotation_matrix = Matrix44.xyz_rotation(*rotation)
        camera_matrix *= rotation_matrix

        # Calcluate movment and add it to camera matrix translate
        heading = Vector3(camera_matrix.forward)
        movement = heading * movement_direction.z * movement_speed
        camera_matrix.translate += movement * time_passed_seconds

        # Upload the inverse camera matrix to OpenGL
        glLoadMatrixd(camera_matrix.get_inverse().to_opengl())

        # Light must be transformed as well
        glLight(GL_LIGHT0, GL_POSITION,  (0, 1.5, 1, 0))

        # Render the map
        map.render()

        # Show the screen
        pygame.display.flip()

if __name__ == "__main__":
    run()

摘要

这一章我们已经讲了很多内容。我们从矩阵开始,这是一个重要的话题,因为它们在 3D 游戏中无处不在,包括手持设备和游戏机。处理矩阵的数学可能很吓人,但是如果你使用一个预建的矩阵类,比如gameobjects.Matrix44,你不需要知道它们如何工作的细节(大多数游戏程序员不是数学家)!更重要的是,你知道如何结合*移,旋转和缩放来操纵游戏中的对象。从矩阵的数字网格中可视化矩阵也是一项有用的技能,如果你的游戏出了问题,它将帮助你修复错误。

您还学习了如何使用 OpenGL 创建 3D 视觉效果。OpenGL 是一个庞大而强大的 API,我们只是触及了它的一部分。我们介绍了如何存储 3D 模型并将其发送到 OpenGL 进行渲染的基础知识,即使启用了更多的 OpenGL 功能,我们也可以利用这些知识。后面的章节将描述如何添加纹理和透明度,以创建真正令人印象深刻的视觉效果!

清单 9-8 是任何 OpenGL 实验的良好起点。尝试调整一些值来产生不同的效果,或者给立方体世界添加更多有趣的形状。你甚至可以通过增加几个敌人把它变成一个游戏(见第七章)。

在下一章中,我们将暂时离开 3D,探索如何在 Pygame 中使用声音。

十、让事情变得丰富

声音是任何游戏的重要组成部分,因为它能从虚拟世界提供即时反馈。如果你在玩游戏时把声音关小,你可能会发现这是一种非常被动的体验,因为我们希望事件伴随着声音。

像创作游戏的其他方面一样,好的音效需要大量的创造力。为游戏中的动作选择一套合适的音效可以决定视觉效果是有效还是完全无效。

本章探索了 Pygame 模块,你可以用它来给你的游戏添加音效和音乐。我们还将介绍如何使用自由软件来创建和编辑你的声音。

什么是声音?

声音本质上是振动,通常通过空气传播,但也可能通过水或其他物质传播。几乎所有的东西都会振动,并将振动以声音的形式传到空气中。例如,当我打字时,我可以听到塑料键与下面的表面碰撞时产生的“噼啪”声。塑料振动非常快,推动周围的空气分子,反过来推动其他分子,并发出连锁反应,最终到达我的耳朵,被解释为声音。

振动中的能量越多,声音就越大。按键相对安静,因为按下一个键不需要太大的力,但如果我用很大的力敲击键盘,比如用大锤,声音会更大,因为振动中会有更多的能量。

声音也可以在音高、上变化,这是空气中振动的速度。一些材料,如金属,往往振动非常快,当受到撞击时会产生高音调的噪音。其他材料以不同的速率振动,产生不同的音调。

大多数声音是音高和音量变化的复杂混合体。如果我把一个玻璃杯掉到石头地板上,最初的撞击会产生很大的噪音,接着是碎片振动并落回地面时发出的各种声音。所有这些声音的结合产生了一种我们认为是玻璃破碎的声音。

声音在到达听者的耳朵之前也会被改变。我们都很熟悉在你和说话的人之间的一堵墙是如何使声音变得模糊 并让人难以理解的。这是因为声音可以穿过墙壁和空气,但它会降低音量并改变途中的振动。声音也可能会从某些表面上反弹出 ??,产生回声等效果。在游戏中复制这样的物理效果是增强视觉效果的一个好方法。如果游戏角色进入一个大洞穴,当他走路时,如果他的脚步声有回音,那就更有说服力了。但是就像游戏设计的大多数方面一样,一点艺术上的许可是允许的。太空中没有声音,因为没有空气供它传播,但我仍然希望我的激光炮能产生令人满意的 zap 噪音!

存储声音

早期的电脑游戏使用芯片创造简单的音调来产生电子哔哔声和口哨声,但不能产生复杂的声音。如今,游戏硬件可以存储和再现现实生活中的声音,为游戏创造丰富的额外维度。计算机上的声卡可以录制和播放高质量的音频。

声音可以用一种波来表示。图 10-1 显示了代表声音的一小部分(几分之一秒)的声波——完整的声音会更长更复杂。波形显示了声音的能量或振幅、如何随时间变化。

声波形成许多波峰和波谷;这些波峰和波谷的幅度差越大,声音的音量就越大。声音的音调由波的频率(波峰之间的时间距离)决定;峰值在时间上越接*,声音就越高。

9781484209714_Fig10-01.jpg

图 10-1 。一个声波

要在计算机上存储声音,您必须首先将其转换为数字形式,这可以通过将麦克风插入声卡的 mic 插孔,或者插入专为计算机使用而设计的新型麦克风的 USB 端口来实现。当麦克风拾取声音时,声波被转换成电信号,该电信号由声卡以固定的间隔进行采样,产生一系列可以保存到文件中的数字。样本是表示特定时刻波形振幅的值,用于在回放时重建波形。样本越多,声卡播放声音就越准确。图 10-2 显示了从低采样率重建的波形,覆盖在原始波形上。您可以看到,采样波形通常会跟随真实波形,但许多细节会丢失,从而产生低质量的声音。较高的采样速率会产生更接*原始波形的波形,回放时听起来会更好。

9781484209714_Fig10-02.jpg

图 10-2 。样本声波

采样率以赫兹(Hz)或千赫兹(KHz)为单位,赫兹表示每秒采样数,千赫兹表示每秒采样数千个。电话质量约为 6KHz,CD 质量为 44KHz。采样速率可以比 CD 质量更高,但只有狗和蝙蝠能够分辨出这种差异!

声音格式

像图像一样,数字音频也有许多不同的文件格式,它们会影响质量和文件大小。Pygame 支持两种音效音频格式:WAV(仅未压缩)和 Ogg。大多数处理声音的软件都可以读写 WAV 文件。对 Ogg 的支持并不普遍,但仍然非常普遍。如果一个应用不直接支持 Ogg,也许可以通过升级或插件来添加它。

声音有许多属性会影响质量和文件大小:

  • 单个样本的大小,通常是 8 位或 16 位整数,尽管有些格式支持浮点样本。通常您应该使用 16 位存储声音文件,因为它可以再现 CD 质量的声音,并且最受声卡支持。
  • Sample rate—每秒存储的样本数。采样速率最常见的值是 11025Hz、22050Hz 或 44100Hz,但也有许多其他可能的值。采样速率越高,产生的声音质量越好,但文件也会越大。
  • Channels 声音文件可以是单声道(单声道声音),也可以是立体声(左右扬声器的独立声道)。立体声听起来更好,但使用的内存是未压缩音频文件的两倍。
  • Compression 声音可以生成大文件。例如,一分钟长、44100Hz、16 位的立体声音频将产生大约 10MB 的数据。幸运的是,音频可以被压缩,这样就可以放入更小的空间。Pygame 不支持压缩的 WAV 文件,但是支持 Ogg 格式,压缩性非常好。

决定你需要这些属性的什么组合通常取决于你将如何分发你的游戏。如果你将在 CD 或 DVD 上发行你的游戏,你可能会有足够的空间来存储高质量的声音。然而,如果你想通过电子邮件或下载来发布你的游戏,你可能需要牺牲一点质量来获得更小的文件。

创造声音效果

创建音效的一种方法是简单地录制自己的音效。例如,如果你需要一个引擎噪音,最好的方法就是录下真实引擎运转的声音。其他声音是不切实际的,甚至是不可能捕捉到的,可能需要一点创造力来创造一个*似的声音——一个好的枪声可以通过记录一个气球爆炸,然后使用声音编辑软件来扩展和加深声音来创造。通过记录铅笔敲击金属台扇格栅的声音,然后提高音调并添加一点回声,甚至可以创建相位器火。

Image 提示如果你想在外面录音,又不想随身带着笔记本电脑,那就买个便宜的录音机吧。质量可能会受到一点影响,因为你不是直接录制到高质量的数字,但你总是可以用 Audacity ( http://audacity.sourceforge.net/ )或类似的软件来清理声音。

要开始录制音效,你需要一个麦克风。您可以使用带有 2.5 毫米插孔的标准麦克风,插入声卡的麦克风插孔,也可以使用专门为计算机设计的 USB 麦克风。这两种话筒都能提供良好的效果,但最好不要使用头戴式话筒,因为它们针对录制语音而非一般音效进行了优化。

除了麦克风,你还需要软件来采集声音并保存到你的硬盘上。大多数操作系统都带有一个可以录制声音的基本程序,但你可能会从其他声音软件中获得更好的结果,如 Audacity ( 图 10-3 ),这是一个用于 Windows、Mac OS X 和 Linux 的开源应用。你可以从http://audacity.sourceforge.net .下载 Audacity(免费)

9781484209714_Fig10-03.jpg

图 10-3 。大胆

要 Audacity 录音,请单击录音按钮(红色圆圈)开始录音,然后单击停止按钮(黄色方块)结束录音。然后声音的波形会显示在主窗口中,主窗口中有许多控制,您可以使用它们来检查波形并选择部分波形。

Audacity 有许多你可以用来编辑声音的特性。多个声音可以混合在一起,您可以在它们之间进行剪切和粘贴。您还可以应用各种效果来提高声音质量,或者完全改变它们!

要使用 Audacity 应用声音,请选择您想要更改的音频部分,然后从“效果”菜单中选择一种效果。以下是您可以使用的一些效果:

  • 放大— 使声音变大。一般来说,你应该尽可能大声地储存你的声音,不要让削波。当波的振幅大于可储存的范围时,会发生削波,并降低声音的质量。如果波的顶部或底部是一条水*线,就意味着声音被削波了。
  • 改变音高— 提高或降低声音。如果你提高一个声音的音调,它听起来就像在氦气中一样,如果你降低音调,它听起来就会更低沉和像上帝一样。改变音高是把一种声音变成另一种声音的好方法。如果你要录下两个金属勺子碰撞的声音,并降低音调,听起来就像是剑碰到了盔甲。
  • 回声— 给声音添加回声。添加回声可以让你的效果听起来像是在任何地方,从一个空房间到一个巨大的洞穴。
  • 去噪— 如果你不够幸运能够接触到录音棚和专业设备,你的录音可能会有轻微的嘶嘶声,这是由背景噪音以及麦克风和声卡的缺陷造成的。噪音消除效果很好地清理了你的声音。

编辑完音效后,您可以将它们导出为各种其他格式,包括 WAV 和 Ogg。最好保留原始文件,以便在需要时可以将它们导出为不同的格式。

Image 警告从电影中录制声音似乎是为你的游戏获得有趣效果的好方法,但你可能会违反版权法——所以最好避免。

股票音效

你也可以购买 CD 上的音效,或者从网上下载。这些音效是高质量的,因为它们是在录音室制作的,并且经过专业编辑。我个人用过的一个热门网站是 Sounddogs ( http://www.sounddogs.com/)。他们的 CD 很贵,但是你也可以单独购买,按秒付费。如果你的游戏只需要一打左右的短音效,价格还是比较合理的。

网上也有许多免费音效的来源。Pygame wiki 包含一个页面,列出了一些好的网站(www.pygame.org/wiki/resources)。你也可以通过搜索网络找到更多好的网站。

用 Pygame 播放声音

可以通过pygame.mixer界面用 Pygame 播放音效。在你可以使用混音器之前,它必须首先用一些参数初始化,这些参数定义了你将要播放的声音类型。这可以用pygame.mixer.init函数来完成,但是在某些*台上,混音器是由pygame.init自动初始化的。Pygame 提供了一个pyame.mixer.pre_init函数,您可以使用它来设置这个自动初始化的参数。两个初始化函数都采用以下四个参数:

  • frequency—这是音频播放的采样率,与声音文件的采样率含义相同。高采样率可能会降低性能,但即使是旧的声卡也可以轻松处理44100的频率设置,这是 CD 质量。另一个常见的频率设置是22050,听起来不太好。
  • size—这是用于回放的音频样本的大小,单位为。样本量可以是816。在相同的性能下,16的值是最佳的,因为音质比8高得多。这个值也可以是负数,表示混音器应该使用带符号的样本(某些*台要求)。签名样本和未签名样本的音质没有区别。
  • stereo—该参数应设置为单声道的1或立体声的2。建议使用立体声,因为它可以用来制造声音来自屏幕上某个特定点的错觉。
  • buffer—这是为回放而缓冲的样本的数量。较低的值导致较低的延迟,即要求 Pygame 播放声音和您实际听到声音之间的时间。较高的值会增加延迟,但对于避免声音丢失可能是必要的,因为声音丢失会导致令人讨厌的爆音和咔哒声。我发现一个值4096最适合 44100,16 位立体声。该值必须始终是 2 的幂。

以下是如何初始化 16 位、44100Hz 立体声的混音器:

pygame.mixer.pre_init(44100, 16, 2, 4096)
pygame.init()

pygame.mixer.pre_init的调用设置混音器的参数,该参数在对pygame.init的调用中初始化。如果你需要在 Pygame 初始化后更改任何参数,你必须在调用pygame.mixer.init重新初始化之前,调用pygame.mixer.quit退出混合器。

声音对象

Sounds 对象用于存储和播放从 WAV 或 Ogg 文件中读取的音频数据。您可以用pygame.mixer.Sound构造一个Sound对象,它接受声音文件的文件名,或者一个包含数据的 Python file对象。下面是如何从硬盘上加载一个名为phaser.ogg的声音文件:

phaser_sound = Pygame.mixer.Sound("phaser.ogg")

您可以用play方法播放一个Sound对象,该方法有两个可选参数:loopmaxtime。为loop设置一个值会使声音在首次播放后重复播放。例如,如果loop设置为5,声音将播放完毕,然后重复 5 次(共 6 次)。您也可以将loop设置为–1的特殊值,这将导致声音连续播放,直到您调用stop方法。

maxtime参数用于在给定的毫秒数后停止回放,这对于设计为循环播放(连续播放)的声音很有用,因为您可以精确指定它们将播放多长时间。

如果对play的调用成功,它将返回一个Channel对象(见下一节);否则,它将返回None。以下是一次性播放相位器声音的方法:

channel = phaser_sound.play()

这一行将播放一个五秒钟长的相位炮射击:

channel = phaser_sound.play(–1, 5000)

关于Sound对象方法的完整列表,参见表 10-1 。

表 10-1 。声音对象的方法

|

方法

|

目的

|
| --- | --- |
| fadeout | 逐渐降低所有频道的音量。fadeout采用单个参数,即以毫秒为单位的渐变长度。 |
| get_length | 返回声音的长度,以秒为单位。 |
| get_num_channels | 计算声音播放的次数。 |
| get_volume | 以介于 0.0 和 1.0 之间的浮点数形式返回声音的音量,其中 0.0 表示静音,1.0 表示最大音量。 |
| play | 播放声音。请参阅“声音对象”一节了解参数的描述。返回值是一个Channel对象,如果 Pygame 无法播放声音,则为 None。 |
| set_volume | 设置播放声音时的音量。该参数是一个介于 0.0 和 1.0 之间的浮点数,其中 0.0 表示静音,1.0 表示全音量。 |
| stop | 立即停止声音播放。 |

声音频道

声道是由声卡混合在一起的几个声源之一,在 Pygame 中由Channel对象表示。Sound对象的play方法为将播放声音的通道返回一个Channel对象,如果所有通道都忙于播放,则返回None。您可以通过调用pygame.mixer.get_num_channels功能来检索可用频道的数量。如果您发现您没有足够的通道来播放您需要的所有声音,您可以通过调用pygame.mixer.set_num_channels函数来创建更多的通道。

如果您只想以最大音量播放声音,可以放心地忽略Sound.play的返回值。否则,您可以使用Channel对象的方法创建一些有用的效果。其中最有用的一个功能是独立设置左右扬声器的音量,这可以用来创造一种声音来自屏幕上特定点的幻觉——这种效果被称为立体声*移Channel对象的set_volume方法可以接受两个参数:左扬声器的音量和右扬声器的音量,都是 0 到 1 之间的值。清单 10-1 显示了一个函数,它在给定发声事件的 x 坐标和屏幕宽度的情况下计算扬声器的音量。x 坐标离扬声器越远,音量就越低,所以当一个点从左到右在屏幕上移动时,左扬声器将降低音量,而右扬声器将提高音量。

清单 10-1 。计算立体声*移的函数

def stereo_pan(x_coord, screen_width):

    right_volume = float(x_coord) / screen_width
    left_volume = 1.0 - right_volume

    return (left_volume, right_volume)

清单 10-2 展示了如何使用stereo_pan函数来播放一个爆炸坦克的声音效果。图 10-4 显示了爆炸的位置如何与左右声道的值相关联。

清单 10-2 。使用立体声 _ 声相功能

tank.explode() # Do explosion visual
explosion_channel = explosion_sound.play()
if explosion_channel is not None:
    left, right = stereo_pan(tank.position.x, SCREEN_SIZE[0])
    explosion_channel.set_volume(left, right)

9781484209714_Fig10-04.jpg

图 10-4 。设定爆炸的立体声声相

Image 提示如果你为一个移动的精灵更新每一帧的立体声*移,它将增强立体声效果。

一般来说,最好将选择频道的任务留给 Pygame,但是可以通过调用Channel对象的play方法来强制Sound对象通过特定的频道播放,该方法获取您想要播放的声音,后跟您想要它重复的次数以及您想要它播放的最长时间。这样做的一个原因是为高优先级声音保留一个或多个通道。例如,您可能不希望背景环境噪声阻挡玩家的枪声。要保留多个通道,调用pygame.mixer.set_reserved函数,这可防止多个通道被Sound.play方法考虑。例如,如果您调用pygame.mixer.set_reserved(2),Pygame 在从Sound对象调用play时将不会选择通道01。清单 10-3 显示了如何保留前两个频道。

清单 10-3 。保留频道

pygame.mixer.set_reserved(2)
reserved_channel_0 = pygame.mixer.Channel(0)
reserved_channel_1 = pygame.mixer.Channel(1)

下面是如何通过一个保留的频道强制播放声音:

reserved_channel_1.play(gunfire_sound)

关于Channel对象方法的完整列表,参见表 10-2 。

表 10-2 。通道对象的方法

|

方法

|

目的

|
| --- | --- |
| fadeout | 在一段时间内渐隐(降低音量)声音,以毫秒为单位。 |
| get_busy | 如果频道上正在播放声音,则返回 True。 |
| get_endevent | 返回声音结束播放时将发送的事件,如果没有设置结束事件,则返回 NOEVENT。 |
| get_queue | 返回任何排队等待播放的声音,如果没有排队声音,则返回 None。 |
| get_volume | 以介于 0.0 和 1.0 之间的单个值检索通道的当前音量(不考虑 set_volume 设置的立体声音量)。 |
| pause | 暂时暂停播放此频道上的任何声音。 |
| play | 在特定频道播放声音。接受 Sound 对象和可选的循环和最大时间值,它们与 Sound.play 的含义相同 |
| queue | 当前声音结束时播放给定的声音。获取要排队的声音对象。 |
| set_endevent | 当当前声音播放完毕时请求事件。获取要发送的事件的 id,它应该在 USEREVENT 之上(pygame.locals中的常量),以避免与现有事件冲突。如果没有给定参数,Pygame 将停止发送结束事件。 |
| set_volume | 设置该频道的音量。如果给定一个值,它将用于两个扬声器。如果给定两个值,则独立设置左右扬声器音量。两种方法都将音量作为 0.0 到 1.0 之间的值,其中 0.0 是无声的,1.0 是最大音量。 |
| stop | 立即停止播放频道上的任何声音。 |
| unpause | 继续播放暂停的频道。 |

混合器功能

我们已经在pygame.mixer模块中介绍了许多功能。表 10-3 给出了它们的完整列表。

表 10-3 。pygame.mixer 中的函数

|

功能

|

目的

|
| --- | --- |
| pygame.mixer.Channel | 为给定的通道索引创建通道对象。 |
| pygame.mixer.fadeout | 逐渐将所有通道的音量降低到 0。采用渐变时间(以毫秒为单位)。 |
| pygame.mixer.find_channel | 查找当前未使用的频道并返回其索引。 |
| pygame.mixer.get_busy | 如果正在播放声音(在任何频道上),则返回 True。 |
| pygame.mixer.get_init | 如果混合器已经初始化,则返回 True。 |
| pygame.mixer.get_num_channels | 检索可用频道的数量。 |
| pygame.mixer.init | 初始化混音器模块。有关参数的说明,请参见本节的开头部分。 |
| pygame.mixer.pause | 暂时停止所有频道的声音播放。 |
| pygame.mixer.pre_init | 当调音台通过调用 pygame.init 自动初始化时,设置调音台的参数。 |
| pygame.mixer.quit | 退出混音器。这是在 Python 脚本结束时自动完成的,但是如果您想用不同的参数重新初始化混合器,您可能需要调用它。 |
| pygame.mixer.set_num_channels | 设置可用频道的数量。 |
| pygame.mixer.Sound | 创建声音对象。获取包含声音数据的文件名或 Python 文件对象。 |
| pygame.mixer.stop | 停止所有频道的声音播放。 |
| pygame.mixer.unpause | 继续播放暂停的声音(请参见 pygame.mixer.pause)。 |

聆听调音台的运转

让我们写一个脚本,在 Pygame 中试验音效。如果你运行清单 10-4 中的,你会看到一个带有鼠标光标的白屏。点击屏幕上的任意位置,抛出一个银球,银球在重力作用下下落,当银球从屏幕边缘或底部反弹时,会播放声音效果(参见图 10-5 )。

清单 10-4 。调音台运行中(bouncesound.py)

import pygame
from pygame.locals import *
from random import randint
from gameobjects.vector2 import Vector2

SCREEN_SIZE = (640, 480)

# In pixels per second, per second
GRAVITY = 250.0
# Increase for more bounciness, but don't go over 1!
BOUNCINESS = 0.7

def stero_pan(x_coord, screen_width):

    right_volume = float(x_coord) / screen_width
    left_volume = 1.0 - right_volume

    return (left_volume, right_volume)

class Ball(object):

    def __init__(self, position, speed, image, bounce:sound):

        self.position = Vector2(position)
        self.speed = Vector2(speed)
        self.image = image
        self.bounce:sound = bounce:sound
        self.age = 0.0

    def update(self, time_passed):

        w, h = self.image.get_size()

        screen_width, screen_height = SCREEN_SIZE

        x, y = self.position
        x -= w/2
        y -= h/2

        # Has the ball bounce
        bounce = False

        # Has the ball hit the bottom of the screen?
        if y + h >= screen_height:
            self.speed.y = -self.speed.y * BOUNCINESS
            self.position.y = screen_height - h / 2.0 - 1.0
            bounce = True

        # Has the ball hit the left of the screen?
        if x <= 0:
            self.speed.x = -self.speed.x * BOUNCINESS
            self.position.x = w / 2.0 + 1
            bounce = True

        # Has the ball hit the right of the screen
        elif x + w >= screen_width:
            self.speed.x = -self.speed.x * BOUNCINESS
            self.position.x = screen_width - w / 2.0 - 1
            bounce = True

        # Do time based movement
        self.position += self.speed * time_passed
        # Add gravity
        self.speed.y += time_passed * GRAVITY

        if bounce:
            self.play_bounce:sound()

        self.age += time_passed

    def play_bounce:sound(self):

        channel = self.bounce:sound.play()

        if channel is not None:
            # Get the left and right volumes
            left, right = stero_pan(self.position.x, SCREEN_SIZE[0])
            channel.set_volume(left, right)

    def render(self, surface):

        # Draw the sprite center at self.position
        w, h = self.image.get_size()
        x, y = self.position
        x -= w/2
        y -= h/2
        surface.blit(self.image, (x, y))

def run():

    # Initialise 44KHz 16-bit stero sound
    pygame.mixer.pre_init(44100, 16, 2, 1024*4)
    pygame.init()
    pygame.mixer.set_num_channels(8)
    screen = pygame.display.set_mode(SCREEN_SIZE, 0)

    print(pygame.display.get_wm_info())
    hwnd = pygame.display.get_wm_info()["window"]
    x, y = (200, 200)

    pygame.mouse.set_visible(False)

    clock = pygame.time.Clock()

    ball_image = pygame.image.load("ball.png").convert_alpha()
    mouse_image = pygame.image.load("mousecursor.png").convert_alpha()

    # Load the sound file
    bounce:sound = pygame.mixer.Sound("bounce.wav")

    balls = []

    while True:

        for event in pygame.event.get():

            if event.type == QUIT:
                pygame.quit()
                quit()

            if event.type == MOUSEBUTTONDOWN:

                # Create a new ball at the mouse position
                random_speed = ( randint(-400, 400), randint(-300, 0) )
                new_ball = Ball( event.pos,
                                 random_speed,
                                 ball_image,
                                 bounce:sound )
                balls.append(new_ball)

        time_passed_seconds = clock.tick() / 1000.

        screen.fill((255, 255, 255))

        dead_balls = []

        for ball in balls:

            ball.update(time_passed_seconds)
            ball.render(screen)

            # Make not of any balls that are older than 10 seconds
            if ball.age > 10.0:
                dead_balls.append(ball)

        # remove any 'dead' balls from the main list
        for ball in dead_balls:

            balls.remove(ball)

        # Draw the mouse cursor
        mouse_pos = pygame.mouse.get_pos()
        screen.blit(mouse_image, mouse_pos)

        pygame.display.update()

if __name__ == "__main__":

    run()

9781484209714_Fig10-05.jpg

图 10-5 。布朗森. py

Ball类的update方法检测到精灵碰到了屏幕的边缘或底部时,它反转精灵的方向并调用Sound对象的play方法。精灵的 x 坐标用于计算左右扬声器的音量,以便声音看起来是从精灵碰到屏幕边界的点发出的。效果相当有说服力——如果你闭上眼睛,你应该还是能分辨出球弹向哪里!

如果你通过快速点击鼠标创建了很多精灵,你可能会发现一些反弹停止产生声音效果。发生这种情况是因为所有可用的频道都被用于播放相同的声音效果,而新的声音只能在频道空闲时播放。

用 Pygame 玩音乐

虽然pygame.mixer模块可以播放任何类型的音频,但通常不建议将其用于音乐,因为音乐文件往往很大,会对计算机资源造成压力。Pygame 提供了一个名为pygame.mixer.musicpygame.mixer子模块,可以一次读取(并播放)一段音乐文件,而不是一次读取全部,这就是所谓的音频流

Image 音乐可能是pygame.mixer.music最常见的用途,但你可以用它来流式传输任何大型音频文件,比如画外音音轨。

获取音乐

如果你不够幸运,没有音乐天赋,你可能不得不用别人创作的音乐。有很多股票音乐网站,你可以购买游戏中使用的音乐文件,或者免费下载。你可以在 Pygame wiki ( http://www.pygame.org/wiki/resources)上找到一些商业和免费音乐网站。

Image 警告从你的音乐收藏中选择几首曲目,让你最喜欢的乐队作为游戏的配乐,这可能很诱人,但这违反了版权法,应该避免!

播放音乐

pygame.mixer.music模块可以播放 MP3 和 Ogg 音乐文件。对 MP3 的支持因*台而异,所以最好使用 Ogg 格式,它在各种*台上都受到很好的支持。您可以使用 Audacity 或其他声音编辑应用将 MP3 转换为 Ogg。

要播放一个音乐文件,首先用你想要播放的音乐曲目的文件名调用pygame.mixer.music.load函数,然后调用pygame.mixer.music.play开始播放(参见清单 10-5 )。你想用这个模块做的任何事情都可以用pygame.mixer.music模块来完成(没有Music对象,因为一次只能传输一个音乐文件)。有停止、倒带、暂停、设置音量等功能。完整列表见表 10-4 。

假设您有一个文件名 techno.ogg(参见使用 Audacity 将音乐转换成。ogg),您可以播放这样的音乐:

清单 10-5 。播放音乐文件

pygame.mixer.music.load("techno.ogg")
pygame.mixer.music.play()

表 10-4 。pygame.mixer.music 函数

|

功能

|

目的

|
| --- | --- |
| pygame.mixer.get_busy | 如果音乐正在播放,则返回 True。 |
| pygame.mixer.music.fadeout | 在一段时间内减少音量。以毫秒为单位计算渐变时间。 |
| pygame.mixer.music.get_endevent | 返回要发送的结束事件,如果没有事件,则返回 0。 |
| pygame.mixer.music.get_volume | 返回音乐的音量;参见 set_volume。 |
| pygame.mixer.music.load | 加载音乐文件进行播放。获取音频文件的文件名。 |
| pygame.mixer.music.play | 开始播放载入的音乐文件。开始播放音乐后,您想要音乐重复播放的次数,后跟您想要开始回放的点(以秒为单位)。如果将第一个值设置为–1,它将一直重复,直到您调用 pygame.mixer.stop。 |
| pygame.mixer.music.rewind | 从头开始播放音乐文件。 |
| pygame.mixer.music.set_endevent | 请求在音乐播放完毕时发送一个事件。获取要发送的事件的 id,该 id 应该在 user event(py game . locals 中的常量)之上,以避免与现有事件冲突。如果没有给定参数,Pygame 将停止发送结束事件。 |
| pygame.mixer.music.set_volume | 设置音乐的音量。将音量作为 0.0 到 1.0 之间的值,其中 0.0 表示静音,1.0 表示最大音量。当加载新音乐时,音量将被重置为 1.0。 |
| pygame.mixer.music.stop | 停止播放音乐。 |
| pygame.mixer.music.unpause | 继续播放暂停的音乐。 |
| pygame.muxer.music.get_pos | 返回音乐播放的时间,以毫秒为单位。 |
| pygame.muxer.music.pause | 暂时暂停播放音乐。 |
| pygame.muxer.music.queue | 设置当前音乐结束时播放的曲目。接受一个参数,即您要播放的文件的文件名。 |

在行动中聆听音乐

让我们使用pygame.mixer.music模块创建一个简单的点唱机。清单 10-6 从硬盘上的一个路径读入一个 Ogg 文件列表,并显示一些熟悉的类似高保真的按钮,你可以用它们来播放、暂停或停止音乐,并在曲目列表中移动(见图 10-6 )。如果您更改列表顶部的MUSIC_PATH值,您可以让它从您自己的收藏中播放。

清单 10-6 。Pygame 点唱机(jukebox.py)

import pygame
from pygame.locals import *

from math import sqrt
import os
import os.path

# Location of music on your computer
MUSIC_PATH = "./MUSIC"
SCREEN_SIZE = (800, 600)

def get_music(path):

    # Get the filenames in a folder
    raw_filenames = os.listdir(path)

    music_files = []
    for filename in raw_filenames:

        # We only want ogg files
        if filename.endswith('.ogg'):
            music_files.append(os.path.join(MUSIC_PATH, filename))

    return sorted(music_files)

class Button(object):

    def __init__(self, image_filename, position):

        self.position = position
        self.image = pygame.image.load(image_filename)

    def render(self, surface):

        # Render at the center
        x, y = self.position
        w, h = self.image.get_size()
        x -= w /2
        y -= h / 2

        surface.blit(self.image, (x, y))

    def is_over(self, point):

        # Return True if a point is over the button
        point_x, point_y = point
        x, y = self.position
        w, h = self.image.get_size()
        x -= w /2
        y -= h / 2

        in_x = point_x >= x and point_x < x + w
        in_y = point_y >= y and point_y < y + h

        return in_x and in_y

def run():

    pygame.mixer.pre_init(44100, 16, 2, 1024*4)
    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, 0)

    default_font = pygame.font.get_default_font()
    font = pygame.font.SysFont("default_font", 50, False)

    # Create our buttons
    x = 100
    y = 240
    button_width = 150

    # Store the buttons in a dictionary, so we can assign them names
    buttons = {}
    buttons["prev"] = Button("prev.png", (x, y))
    buttons["pause"] = Button("pause.png", (x+button_width*1, y))
    buttons["stop"] = Button("stop.png", (x+button_width*2, y))
    buttons["play"] = Button("play.png", (x+button_width*3, y))
    buttons["next"] = Button("next.png", (x+button_width*4, y))

    music_filenames = get_music(MUSIC_PATH)

    if len(music_filenames) == 0:
        print("No OGG files found in ", MUSIC_PATH)
        return

    white = (255, 255, 255)
    label_surfaces = []

    # Render the track names
    for filename in music_filenames:

        txt = os.path.split(filename)[-1]

        print("Track:", txt)

        txt = txt.split('.')[0]
        surface = font.render(txt, True, (100, 0, 100))
        label_surfaces.append(surface)

    current_track = 0
    max_tracks = len(music_filenames)

    pygame.mixer.music.load( music_filenames[current_track] )

    clock = pygame.time.Clock()

    playing = False
    paused = False

    # This event is sent when a music track ends
    TRACK_END = USEREVENT + 1
    pygame.mixer.music.set_endevent(TRACK_END)

    while True:

        button_pressed = None

        for event in pygame.event.get():

            if event.type == QUIT:
                pygame.quit()
                quit()

            if event.type == MOUSEBUTTONDOWN:

                # Find the pressed button
                for button_name, button in buttons.items():
                    if button.is_over(event.pos):
                        print(button_name, "pressed")
                        button_pressed = button_name
                        break

            if event.type == TRACK_END:
                # If the track has ended, simulate pressing the next button
                button_pressed = "next"

        if button_pressed is not None:

            if button_pressed == "next":
                current_track = (current_track + 1) % max_tracks
                pygame.mixer.music.load( music_filenames[current_track] )
                if playing:
                    pygame.mixer.music.play()

            elif button_pressed == "prev":

                # If the track has been playing for more that 3 seconds,
                # rewind i, otherwise select the previous track
                if pygame.mixer.music.get_pos() > 3000:
                    pygame.mixer.music.stop()
                    pygame.mixer.music.play()
                else:
                    current_track = (current_track - 1) % max_tracks
                    pygame.mixer.music.load( music_filenames[current_track] )
                    if playing:
                        pygame.mixer.music.play()

            elif button_pressed == "pause":
                if paused:
                    pygame.mixer.music.unpause()
                    paused = False
                else:
                    pygame.mixer.music.pause()
                    paused = True

            elif button_pressed == "stop":
                pygame.mixer.music.stop()
                playing = False

            elif button_pressed == "play":
                if paused:
                    pygame.mixer.music.unpause()
                    paused = False
                else:
                    if not playing:
                        pygame.mixer.music.play()
                        playing = True

        screen.fill(white)

        # Render the name of the currently track
        label = label_surfaces[current_track]
        w, h = label.get_size()
        screen_w = SCREEN_SIZE[0]
        screen.blit(label, ((screen_w - w)/2, 450))

        # Render all the buttons
        for button in list(buttons.values()):
            button.render(screen)

        # No animation, 5 frames per second is fine!
        clock.tick(5)
        pygame.display.update()

if __name__ == "__main__":
    run()

9781484209714_Fig10-06.jpg

图 10-6 。点唱机脚本

自动点唱机使用pygame.mixer.set_endevent功能请求在曲目播放完毕时发送一个事件。Pygame 没有为此提供事件,但是您可以通过使用大于USEREVENT(pygame.locals中的常数)的id值来轻松创建自己的事件。清单 10-6 使用了id TRACK_END,其值为USEREVENT + 1。当在主事件循环中检测到TRACK_END事件时,它开始流式播放下一个音乐文件,以便按顺序播放曲目。

摘要

声音是一种创造性的媒介,它可能需要大量的实验来完善游戏中的音频。选择好的音效至关重要,因为玩家可能会多次听到它们,而糟糕或恼人的音频会很快阻碍进一步的播放。对于配乐来说也是如此,配乐既有增强的潜力,也有让人烦恼的潜力。

pygame.mixer模块提供了 Pygame 的音效功能,允许你在多个频道中的一个上加载和播放声音文件。当声音通过Sound对象播放时,Pygame 会自动分配一个空闲通道。这是最简单的方法,但是您可以通过播放特定频道的声音来选择自己管理频道。我建议,只有当你要播放许多声音,并希望对它们进行优先排序时,才这样做。

虽然你可以用Sound对象播放任何音频,但最好用pygame.mixer.music模块播放音乐,因为它可以流式传输音频,而不是将整个文件加载到内存中。音乐模块提供了许多简单的功能,您可以使用它们来管理游戏中的音乐。

下一章将介绍更多你可以用来在游戏中创造令人信服的 3D 视觉效果的技术。

十一、灯光,摄像机,开拍!

在第八章和第九章中,你学习了如何操作 3D 信息和使用 OpenGL 显示简单的模型。在这一章中,我们将介绍如何使用图像来创建更具视觉吸引力的场景。我们还将讨论如何从文件中读取 3D 模型,这是创建完美游戏的重要一步。

使用纹理

在第九章中,你学习了如何从阴影多边形创建一个 3D 模型,但是为了让一个物体看起来更有说服力,你需要使用纹理,这是被拉伸成多边形的图像。顶点和多边形创建模型的形状,但是纹理定义任何 3D 对象的最终外观。你可以把一个未来士兵的模型变成一个僵尸,甚至是一个雕像,只要简单地改变它的纹理所使用的图像。

OpenGL 对纹理有极好的支持,可以用来在游戏中创建高度细节化的模型和场景。

用 OpenGL 上传纹理

在你可以使用图像作为 OpenGL 的纹理之前,你首先必须上传它。这将获取原始图像信息,并将其发送到图形卡的高速视频内存。用作纹理的图像大小必须至少为 64 x 64 像素,宽度和高度必须是 2 的幂(64、128、256 等。).如果你的图像不是 2 的幂,你应该调整它以适应下一个 2 的幂。清单 11-1 是一个给定一个维度,可以计算 2 的下一次幂的函数。

清单 11-1 。计算 2 的下一次幂

from math import log, ceil
def next_power_of_2(size):
    return 2 ** ceil(log(size, 2))

Image 注意一些显卡确实能够使用不是 2 的幂的纹理,但是为了最大的兼容性,最好坚持 2 的幂。

纹理大小的上限因显卡而异。可以通过调用glGetIntegerv(GL_MAX_TEXTURE_SIZE)向 OpenGL 询问支持的最大纹理大小。在我的电脑上,它返回了16384,这的确是一个非常大的纹理!

以下列表概述了在 OpenGL 中上传图像数据和创建纹理所需的步骤:

  • pygame.image.load加载图像。
  • 使用pygame.image.tostring功能检索包含原始图像数据的字符串。
  • glGenTextures生成一个id,可以用来识别纹理。
  • 设置渲染多边形时影响纹理使用方式的任何参数。
  • 使用glTexImage2D功能上传原始图像数据。

加载图像的方式与在 2D 游戏中加载图像的方式相同——只需用图像文件名调用pygame.image.load。例如,以下代码行加载一个名为sushitex.png的图像并返回一个表面:

texture_surface = pygame.image.load("sushitex.png")

一旦您有了包含您的图像的 Pygame 表面,调用pygame.image.tostring来检索包含原始图像数据的字符串。pygame.image.tostring的第一个参数是表面;第二个应该是RGB对于不透明的图像,或者RGBA如果图像有一个 alpha 通道。最后一个参数应该设置为True,它告诉 py game翻转返回数据中的行(否则,纹理会颠倒)。以下代码行检索我们之前加载的表面的原始图像数据:

texture_data = pygame.image.tostring(texture_surface, 'RGB', True)

OpenGL 中的每个纹理都必须用glGenTextures函数分配一个id值(一个数字)。这些id用于选择绘制 OpenGL 图元时使用的纹理。glGenTextures函数获取您想要生成的id的数量,并返回一个 ID 值列表,或者如果您只需要一个值,则返回一个值。纹理id s 一般是顺序分配的(所以前三个纹理很可能会被命名为 1,2,3),但情况可能并不总是这样。

Image 注意纹理id s 是整数值,而不是物体。删除纹理id,或者让它超出范围,都不会清理纹理。你应该手动删除你不想再使用的纹理(稍后见“删除纹理”一节)。

一旦你指定了一个纹理id,调用glBindTexture来告诉 OpenGL 所有后续的纹理相关函数都应该使用这个纹理。glBindTexture函数有两个参数;第一个参数应该是标准 2D 纹理的GL_TEXTURE_2D,第二个参数是你想要绑定的id。下面两行为sushitex.png纹理创建了一个单独的id,并且绑定以供使用:

texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)

在上传图像数据之前,我们需要设置几个纹理参数,它们是影响 OpenGL 在渲染时如何使用纹理的值。要设置纹理参数,请调用glTexParameteri获取整数参数,或glTexParameterf获取浮点参数。两个函数都采用相同的三个参数;纹理类型(GL_TEXTURE_2D用于标准 2D 纹理),后跟要设置的参数名称和要设置的值。大多数参数是可选的,如果您不更改它们,它们将使用默认值。

我们需要为所有纹理设置的两个参数是GL_TEXTURE_MIN_FILTERGL_TEXTURE_MAX_FILTER,,它们告诉 OpenGL 应该使用什么方法来放大或缩小纹理。当纹理像素(通常称为纹理像素)小于屏幕像素时,使用GL_TEXTURE_MIN_FILTER参数。当纹理元素变得比像素大时,使用参数。下面两行将两个参数都设置为GL_LINEAR,这使得缩放后的纹理看起来*滑,不那么像素化。稍后我们将讨论其他可能的值。

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)

接下来我们需要调用glPixelStorei(GL_UNPACK_ALIGNMENT, 1),它告诉 OpenGL 原始图像数据中的行是如何被打包在一起的,然后调用glTexImage2D上传图像数据。下面几行上传了我们之前检索到的texture_data。一旦这一步完成,纹理就可以用来绘制三角形、四边形和任何其他 OpenGL 图元。

glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
width, height = texture_surface.get_rect().size
glTexImage2D( GL_TEXTURE_2D,
              0,        # First mip-level
              3,        # Bytes per pixel
              width,
              height,
              0,        # Texture border
              GL_RGB,
              GL_UNSIGNED_BYTE,
              texture_data)

glTexImage2D有很多参数,因为 OpenGL 被设计成在它支持的图像格式中非常灵活。幸运的是,当从 Pygame 表面上传纹理数据时,我们通常可以坚持使用我们之前描述的参数,尽管对于带有 alpha 通道的表面,你应该将3改为4,将GL_RGB改为GL_RGBA。关于glTexImage2D参数的更详细说明,参见表 11-1 。

表 11-1 。glTexImage2D 的参数(按函数调用顺序)

|

参数

|

说明

|
| --- | --- |
| target | 通常将be GL_TEXTURE_2D用于图像纹理。OpenGL 支持一维和三维纹理,但这些在游戏中不太常用。 |
| level | mip 映射级别,其中 0 是第一个(最大)级别,1 是下一个级别。我一会儿将解释 mip 地图。 |
| internalFormat | 指示数据如何存储在视频存储器中。一般来说,这是不透明图像的3和带有 alpha 通道的图像的4,但它也接受其他格式的常量。 |
| width | 图像的宽度,以像素为单位。 |
| height | 图像的高度,以像素为单位。 |
| border | 设置纹理的边框。可以是用于边框的0,也可以是用于单像素边框的1。 |
| format | 要上传的图像数据的格式。这通常是不透明图像的GL_RGB,或者带有 alpha 通道的图像的GL_RGBA。 |
| type | 指定图像组件的存储方式;通常这是GL_UNSIGNED_BYTE,用于从 Pygame 表面检索的图像数据。 |
| data | 包含原始图像数据的字符串。 |

一旦上传了纹理,就不再需要原始的 Pygame 表面对象,因为 OpenGL 有它的副本。您可以让它超出范围,让 Python 为您清理,或者调用del删除引用。显式总是比隐式好,所以这里应该用 del,但是两种方式都可以。

纹理坐标

当在 OpenGL 中启用纹理时,可以为每个顶点分配一个纹理坐标,该坐标定义了纹理中的一个位置。当一个有纹理的多边形被渲染到屏幕上时,纹理将在这些坐标之间被拉伸

OpenGL 使用归一化的纹理坐标,这意味着无论纹理图像中有多少个像素,纹理的宽度和高度始终为 1.0(见图 11-1 )。所以纹理坐标(0.5,0.5)总是在纹理的中心。纹理的左下方是坐标(0,0),右上方是坐标(1,1)。使用标准化坐标的优点是,如果改变图像的尺寸,它们将保持不变。

9781484209714_Fig11-01.jpg

图 11-1 。纹理坐标

纹理坐标的存储方式与二维向量的存储方式相同:要么是元组,要么是Vector2对象。在 OpenGL 中,纹理坐标的组成部分被称为 s 和 t,但为了方便起见,您仍然可以使用一个Vector2对象,并将这些组成部分称为 x 和 y。不过,一般来说,除了存储它们并根据需要将它们发送到 OpenGL 之外,您很少需要对纹理坐标做任何事情,因此元组通常是最佳选择。

Image 注意一些 3D APIs、工具、书籍将纹理坐标称为 u 和 v,而不是 s 和 t

渲染纹理

要在渲染多边形或任何其他 OpenGL 图元时使用纹理,您必须使用glTexCoord2f函数为每个顶点提供纹理坐标,该函数将 s 和 t 组件作为参数。图元将使用用glBindTexture函数绑定的最后一个纹理来绘制。

下面的代码绘制了一个与 x 和 y 轴对齐的四边形。它对四个角使用纹理坐标,这样整个图像将在四边形中绘制。

# Draw a quad (4 vertices, 4 texture coords)
glBegin(GL_QUADS)

# Top left corner
glTexCoord2f(0, 1)
glVertex3f(-100, 100, 0)

# Top right corner
glTexCoord2f(1, 1)
glVertex3f(100, 100, 0)

# Bottom right corner
glTexCoord2f(1, 0)
glVertex3f(100, -100, 0)

# Bottom left corner
glTexCoord2f(0, 0)
glVertex3f(-100, -100, 0)

glEnd()

删除纹理

每个纹理都会耗尽视频内存,这是一种有限的资源。当你完成一个纹理(切换等级,退出游戏,等等。),您应该通过调用glDeleteTextures来删除它,以释放它的视频内存。这个函数取你想要删除的id,或者一个id的列表,一旦纹理被删除了,再去绑定它就是一个错误,所以你要丢弃id的值。

下面一行删除了一个名为texture_id的纹理 ID:

glDeleteTextures(texture_id)

查看纹理运行情况

让我们写一个脚本来演示上传一个纹理并在渲染一个 OpenGL 图元时使用它。我们将绘制一个纹理四边形,为了表明它不仅仅是一个精灵,我们将围绕 x 轴旋转它。

在清单 11-2 的init函数中是对glEnable(GL_TEXTURE_2D)的调用,它启用 OpenGL 纹理。如果你在让纹理在 OpenGL 中工作时遇到问题,检查一下你是否已经调用了这个函数。

清单 11-2 。运行中的纹理(opengltex.py)

from math import radians

from OpenGL.GL import *
from OpenGL.GLU import *

import pygame
from pygame.locals import *

SCREEN_SIZE = (800, 600)

def resize(width, height):

    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(60.0, float(width)/height, .1, 1000.)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():

    glEnable(GL_TEXTURE_2D)
    glClearColor(1.0, 1.0, 1.0, 0.0)

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE|OPENGL|DOUBLEBUF)

    resize(*SCREEN_SIZE)
    init()

    # Load the textures
    texture_surface = pygame.image.load("sushitex.png")
    # Retrieve the texture data
    texture_data = pygame.image.tostring(texture_surface, 'RGB', True)

    # Generate a texture id
    texture_id = glGenTextures(1)
    # Tell OpenGL we will be using this texture id for texture operations
    glBindTexture(GL_TEXTURE_2D, texture_id)

    # Tell OpenGL how to scale images
    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR )
    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR )

    # Tell OpenGL that data is aligned to byte boundries
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1)

    # Get the dimensions of the image
    width, height = texture_surface.get_rect().size

    # Upload the image to OpenGL
    glTexImage2D( GL_TEXTURE_2D,
                  0,
                  3,
                  width,
                  height,
                  0,
                  GL_RGB,
                  GL_UNSIGNED_BYTE,
                  texture_data)

    clock = pygame.time.Clock()

    tex_rotation = 0.0

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()

        time_passed = clock.tick()
        time_passed_seconds = time_passed / 1000.
        tex_rotation += time_passed_seconds * 360.0 / 8.0

        # Clear the screen (similar to fill)
        glClear(GL_COLOR_BUFFER_BIT)

        # Clear the model-view matrix
        glLoadIdentity()

        # Set the modelview matrix
        glTranslatef(0.0, 0.0, -600.0)
        glRotate(tex_rotation, 1, 0, 0)

        # Draw a quad (4 vertices, 4 texture coords)
        glBegin(GL_QUADS)

        glTexCoord2f(0, 1)
        glVertex3f(-300, 300, 0)

        glTexCoord2f(1, 1)
        glVertex3f(300, 300, 0)

        glTexCoord2f(1, 0)
        glVertex3f(300, -300, 0)

        glTexCoord2f(0, 0)
        glVertex3f(-300, -300, 0)

        glEnd()

        pygame.display.flip()

    glDeleteTextures(texture_id)

if __name__ == "__main__":
    run()

run函数执行读入sushitex.png图像并将图像数据上传到 OpenGL 所需的五个步骤。然后,它通过向 OpenGL 发送四个纹理坐标和四个顶点来绘制一个纹理四边形。纹理坐标通过glTexcoord2f函数发送,该函数将st组件作为参数。glTexcoord2f的替代方法是glTexCoord2fv函数,它采用一个序列(tuple、Vector2等)。)而不是个人价值观。

旋转是通过glRotate函数完成的,该函数创建一个绕轴的旋转矩阵,并将其乘以当前的模型-视图矩阵。它有四个参数:第一个参数是要旋转的角度(以度为单位),后面是轴的 x、y 和 z 分量。例如,调用glRotate(45, 1, 0, 0)将围绕 x 轴旋转 45 度,调用glRotate(-30, 0, 1, 0)将围绕 y 轴旋转 30 度(即,30 度顺时针)。您还可以使用此功能绕任何轴旋转,而不仅仅是三个基本轴。

当你运行清单 11-2 时,你会看到类似图 11-2 的东西,有一个大的纹理四边形绕着 x 轴旋转。尝试使用纹理坐标值来查看它们对四边形的影响。

9781484209714_Fig11-02.jpg

图 11-2 。opengltex.py

Mip 映射

当一个多边形中的像素比绘制它的纹理中的像素少时,OpenGL 必须跳过纹理中的一些像素,以使它适合。这会导致渲染的多边形产生扭曲效果,这种效果随着纹理的缩小而恶化。这种失真会分散 3D 动画场景的注意力,降低视觉效果的整体质量。

Mip 映射 就是一种将这种影响降到最低的技术。它的工作原理是预先计算逐渐变小的纹理版本,每个版本的尺寸都是前一个纹理的一半(见图 11-3 )。例如,一个 256 x 256 像素的纹理也将有一个 128 x128 像素的版本,然后是一个 64 x64 像素的版本,以及更小的纹理版本,其中每个版本的大小是其前面纹理的一半,一直到最终纹理(它是原始纹理中所有像素的*均单个像素)。当 OpenGL 使用 mip 映射纹理渲染多边形时,它将使用最接*屏幕上多边形大小的 mip 级别,这减少了跳过的像素数量并提高了视觉质量。

9781484209714_Fig11-03.jpg

图 11-3 。Mip 地图

Image Mip 是 parvo 中 multum 的首字母缩略词,拉丁语意为“小空间中的多”

您可以通过将第二个参数设置为glTexImage2D来上传每个 mip 级别的图像数据。原始的——也是最大的——纹理是 mip 级别的0。第一个 mip 等级是1,应该是原来的一半尺寸,第二个 mip 等级是2,应该是原来的四分之一尺寸。

因为计算 mip 级别是一项常见任务,所以我们可以使用 OpenGL 工具库(OpenGL.GLU)中的一个函数在一次调用中创建并上传所有 mip 级别。除了levelborder(不需要)之外,gluBuild2DMipmaps函数可以代替对glTexImage2D的调用,并采用相同的参数。例如,下面的调用可以替换对清单 11-2 中glTexImage2D的调用(完整示例参见源代码文档中的 11-2.1.py):

gluBuild2DMipmaps( GL_TEXTURE_2D,
                   3,
                   width,
                   height,
                   GL_RGB,
                   GL_UNSIGNED_BYTE,
                   texture_data )

使用 mip 贴图纹理的唯一缺点是它们比非 mip 贴图纹理多使用三分之一的视频内存,但是视觉质量的提高是值得的。我建议对 3D 场景中使用的所有纹理使用 mip 贴图。对于未缩放的纹理,例如字体或*视显示器,您不需要使用 mip 贴图。

纹理参数

OpenGL 在渲染 3D 场景的方式上非常灵活。可以设置许多纹理参数来创建视觉效果,并调整使用纹理渲染多边形的方式。本节涵盖了一些常用的纹理参数——完整的列表超出了本书的范围,但是你可以在线阅读 OpenGL 文档(https://www.opengl.org/sdk/docs/HYPERLINK``http://www.opengl.org/sdk/docs/man/)”``)了解所有细节)。

最小和最大过滤器

在清单 11-2 中,我们设置了两个纹理参数GL_TEXTURE_MIN_FILTERGL_TEXTURE_MAX_FILTER,它们定义了用于纹理缩放的最小化最大化过滤器。这两个值都被设置为GL_LINEAR,这在大多数情况下都可以得到很好的结果,但是也可以使用其他值来微调缩放比例。

当 OpenGL 将纹理多边形渲染到屏幕上时,它会定期对纹理进行采样,以计算多边形中像素的颜色。如果纹理是 mip 映射的(见我们前面的讨论),OpenGL 也必须决定它应该采样的 mip 级别。它用来采样纹理和选择 mip 级别的方法由最小化或最大化过滤器参数定义。

您可以为最大化过滤器(GL_TEXTURE_MAX_FILTER)设置的唯一值是GL_NEARESTGL_LINEAR。通常最好坚持使用GL_LINEAR,这使得 OpenGL 在缩放时使用双线性过滤 来*滑纹理,但纹理在高比例下会显得模糊。另一种选择是GL_NEAREST,它看起来更锐利,但却是块状的。这些值也受最小化过滤器(GL_TEXTURE_MIN_FILTER)支持,此外还有其他四个常量告诉 OpenGL 如何在颜色计算中包括 mip 级别(见表 11-2 )。最小化过滤器的最高质量设置是GL_LINEAR_MIPMAP_LINEAR,它像GL_LINEAR一样柔化纹理,但也在两个最接*的 mip 级别之间混合(称为三线性过滤 )。下面几行将最小化和最大化过滤器方法设置为最高质量的设置(本示例的完整代码在源代码文档的 11-2.2.py 中):

glTexParameteri(GL_TEXTURE_2D,  GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D,  GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)

表 11-2 。最小和最大过滤器参数的潜在值

|

参数常数

|

样本效应

|
| --- | --- |
| GL_NEAREST | 选择离采样点最*的纹理元素。这使纹理看起来清晰,但可能会出现低质量。 |
| GL_LINEAR | 选择最接*采样点的四个纹理元素,并在它们之间混合。这会*滑渲染的纹理,并使其在缩放时看起来不那么锯齿状。 |
| GL_NEAREST_MIPMAP_NEAREST | 选择最接*采样点的纹理元素,以及最接*正在渲染的像素大小的 mip 级别。Minimizing filter only。 |
| GL_LINEAR_MIPMAP_NEAREST | 混合最接*采样点的四个纹理元素,并选择最接*正在渲染的像素大小的 mip 级别。Minimizing filter only。 |
| GL_NEAREST_MIPMAP_LINEAR | 选择最接*采样点的纹理元素,并在最接*渲染像素大小的两个 mip 级别之间混合。Minimizing filter only。 |
| GL_LINEAR_MIPMAP_LINEAR | 在最接*采样点的四个纹理元素之间混合,并在最接*渲染像素大小的两个 mip 级别之间混合。Minimizing filter only。 |

纹理包装

组件在 0 到 1 范围内的纹理坐标将引用纹理内部的点,但是组件在该范围之外的纹理坐标并不是错误,事实上,它非常有用。OpenGL 如何处理不在 0 到 1 范围内的坐标由GL_TEXTURE_WRAP_SGL_TEXTURE_WRAP_T纹理参数定义。这些参数的默认值是GL_REPEAT,它使离开纹理一边的样本出现在另一边。这产生了纹理*铺的效果,因此它的多个副本被并排放置。您可以通过编辑清单 11-2 中对的调用来看到这一点。尝试用以下代码替换对glBeginglEnd的调用之间的代码行(参见源代码文档中的 11-2.3.py,了解整个脚本中包含的这段代码):

glTexCoord2f(0, 3)
glVertex3f(-300, 300, 0)

glTexCoord2f(3, 3)
glVertex3f(300, 300, 0)

glTexCoord2f(3, 0)
glVertex3f(300, -300, 0)

glTexCoord2f(0, 0)
glVertex3f(-300, -300, 0)

如果您运行编辑过的清单 11-2 ,您应该会看到类似图 11-4 的内容。只画了一个四边形,但是纹理重复了 9 次,因为纹理成分的范围是从 0 到 3。像这样*铺纹理在游戏中很有用,因为你可以纹理化非常大的多边形,而不必把它们分成更小的块。例如,可以用一个拉长的四边形和一个*铺纹理创建一个长栅栏,这比每个栅栏都用小四边形更有效。

9781484209714_Fig11-04.jpg

图 11-4 。重复纹理坐标

重复纹理与使用下面的 Python 函数变换每个纹理坐标的组件具有相同的效果(尽管包装是由您的显卡而不是 Python 完成的)。%符号是模数运算符,它返回除法中的余数,因此% 1.0将返回一个数的小数部分。

def wrap_repeat(component):
    return component %  1.0

Image 提示拼贴最适用于被设计为无缝的纹理——也就是说,如果你将它们并排放置,你就看不到其中一个结束,另一个开始。

包裹参数的另一个设置是GL_MIRRORED_REPEAT,它与GL_REPEAT相似,但是在样本点越过纹理边缘时镜像。要查看这一点,请在清单 11-2 的函数中的while True:行之前插入以下两行,完整示例参见源代码文档中的 11-2.4:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT)

现在当你运行清单 11-2 时,你应该会看到重复的图像在纹理的 s 和 t 轴周围被镜像。镜像重复设置是一种使纹理看起来无缝的好方法,即使它们不是设计出来的。对应于GL_MIRRORED_REPEAT的 Python 代码如下:

def wrap_mirrored_repeat(component):
    if int(component) %  2:
        return 1.0 - (component %  1.0)
    else:
        return component %  1.0

我们要看的最后一个纹理包裹设置是GL_CLAMP_TO_EDGE,它将纹理成分饱和到 0 到 1 的范围。如果纹理组件超出该范围,它将被设置为 0 或 1,这取决于哪个最接*原始值。使纹理组件饱和的视觉效果是,当 OpenGL 在纹理边界之外采样时,纹理的外部边缘会无限重复。当您的纹理坐标非常接*纹理边缘,但您希望确保 OpenGL 不会对纹理边缘上的点进行采样时,此设置非常有用(如果您启用了GL_LINEAR过滤器设置,这种情况会发生)。要查看此设置的效果,将对glTexParameteri的调用更改如下(源代码文档中的完整代码示例见 11-2.5.py):

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)

以下函数是GL_CAMP_TO_EDGE的 Python 等价物:

def wrap_clamp_to_edge(component):
    if component > 1.0:
        return 1.0
    elif component < 0.0:
        return 0.0
    return component

我们已经将GL_TEXTURE_WRAP_SGL_TEXTURE_WRAP_T的值设置为相同的值,但是如果您需要,它们可以独立设置。例如,要创建一个栅栏四边形,我们可能希望纹理水*重复(在 s 组件上),而不是垂直重复(在 t 组件上)。

尝试使用清单 11-2 中的来看看组合纹理包裹参数的效果。

使用模型

三维模型很少直接在代码中定义。一般来说,它们是从 3D Studio 或 Blender 等 3D 应用生成的文件中读入的。本节介绍 3D 模型如何存储在文件中,以及如何在游戏中读取和显示它们。

存储模型

与图像一样,有多种文件格式可用于存储 3D 模型,每种格式都由不同的制造商生产,具有不同的功能。与图像不同,在为游戏存储模型时,没有明确的赢家,有许多可供选择。

模型可以存储为二进制或文本。二进制格式非常有效地打包信息,并且倾向于生成较小的文件,而文本格式将信息存储为文本行,这样生成的文件较大但更容易处理。

用于存储 3D 信息的文件将至少包含一个顶点列表,还可能包含一个纹理坐标和法线列表。许多还包含关于纹理和照明的附加信息。您不必使用 3D 模型中的所有信息,但是您实现的格式功能越多,它就越接*艺术家创建的模型。

Image 提示很多游戏开发者都会发布他们游戏中使用的 3D 模型格式。你可以用商业游戏中的角色来测试你的游戏——但是要小心违反版权法!

三维模型的 OBJ 格式

为了处理 3D 模型,我们将使用波前 OBJ 格式(扩展.obj),这是一种已经存在多年的简单文本格式。大多数处理 3D 模型的软件至少能够编写 OBJ 文件,也可能能够读取它们。它们是基于文本的,因此您可以在文本编辑器中打开它们,如 Windows 记事本,或其他*台上的等效工具。

OBJ 文件通常带有一个素材库文件(扩展名为.mtl),其中包含各种定义模型中多边形应该如何渲染的设置,包括纹理的文件名。

解析 OBJ 文件

OBJ 文件中的每一行都定义了一条信息(顶点,纹理坐标等)。)并由一个或多个由空格分隔的单词组成。第一个词表示该行包含的内容,其余的词是信息本身。例如,下面一行在(–100,50,–20)处定义了一个顶点:

v –100 50 –20

Python 非常擅长读取文本文件,并且具有一次读取一行的内置支持。如果迭代一个打开的文件对象,它将返回一个包含每一行的字符串。下面是我们如何开始编写 Python 代码来解析存储在 OBJ 文件中的 3D 模型:

obj_file = file("tank.obj")
for line in obj_file:
    words = line.split() # Split line on spaces
    command = words[0]
    data = words[1:]

每次通过for循环都返回文件中的下一行。在循环中,我们将这一行拆分成单词。第一个单词被命名为command,其余的单词存储在一个名为data的列表中。一个完整的解析器将基于command的值选择一个动作,并使用包含在data列表中的任何信息。

表 11-3 包含了 OBJ 文件中一些常用的命令。还有更多可能的命令,但这些可以用来存储游戏中使用的 3D 模型。我们可以使用表 11-3 中的信息来读取和显示坦克的 3D 模型。

表 11-3 。波前 OBJ 文件中的线

|

线条

|

说明

|
| --- | --- |
| # | 表示该行是注释,应被软件忽略。 |
| f <vertex1> <vertex2> etc. | 定义模型中的面。每个单词由三个用正斜杠分隔的值组成:顶点索引、纹理坐标索引和法线索引。面上的每个点都有三个一组的值(三角形为 3,四边形为 4,等等。). |
| mtllib <filename> | 指定 OBJ 文件的材质库。 |
| usemtl <material name> | 从材料库中选择材料。 |
| v <x> <y> <z> | 在 x,y,z 上定义一个顶点。 |
| vt <s> <t> | 定义纹理坐标。 |

材料库文件

波前 OBJ 文件通常与包含纹理和照明信息的材质库文件配合使用。当你在 OBJ 文件中遇到一个mtlib命令时,它会引入一个包含这些额外信息的素材库文件。在命令数据的第一个字中给出了素材库的文件名,并且应该附加扩展名.mtl

素材库也是文本文件,可以用与父 OBJ 文件相同的方式进行解析。在素材库文件中,newmtl命令开始一个新的素材,它包含一些参数。其中一个参数是纹理文件名,它是用map_Kd命令引入的。例如,材质文件中的以下几行将定义一个名为tankmaterial的纹理,该纹理的文件名为tanktexture.png:

newmtl tankmaterial
map_Kd tanktexture.png

观看模型运行

让我们写一个类来加载波前 OBJ 文件,并用 OpenGL 渲染它。我创建了一个未来主义坦克的模型(见图 11-5 ),用 AC3D ( www.inivis.com/)建造,导出为mytank.objmytank.mtl。我的艺术技巧有限;请随意用你自己的 3D 物体替换我的模型。您可以使用任何能够导出 OBJ 文件的 3D modeler 软件。

9781484209714_Fig11-05.jpg

图 11-5 。AC3D 中的未来坦克物体

我们正在构建的类叫做Model3D,负责读取模型并存储其几何(顶点,纹理坐标,模型等。).它还可以将几何图形发送到 OpenGL 来渲染模型。Model3D需要存储的内容详见表 11-4 。

表 11-4 。存储在 Model3D 类中的信息

|

名字

|

目的

|
| --- | --- |
| self.vertices | 顶点(3D 点)列表,存储为三个值的元组(x、y 和 z)。 |
| self.tex_coords | 纹理坐标列表,存储为两个值的元组(对于 s 和 t)。 |
| self.normals | 法线列表,存储为三个值的元组(x、y 和 z)。 |
| self.materials | 一个Material对象的字典,这样我们可以在给定材质名称的情况下查找纹理文件名。 |
| self.face:groups | 将存储每种材质的面的FaceGroup对象列表。 |
| self.display_list_id | 一个显示列表id,我们将使用它来加速 OpenGL 渲染。 |

除了Model3D类,我们需要定义一个类来存储材质和面组,它们是共享相同材质的多边形。清单 11-3 是Model3D类的开始。

清单 11-3 。model3d.py 中的类定义

# A few imports we will need later
from OpenGL.GL import *
from OpenGL.GLU import *

import pygame
import os.path

class Material(object):

  def   init (self):

        self.name = ""
        self.texture_fname = None
        self.texture_id = None

class FaceGroup(object):

  def   init (self):

        self.tri_indices = []
        self.material_name = ""

class Model3D(object):

  def   init (self):

        self.vertices = []
        self.tex_coords = []
        self.normals = []
        self.materials = {}
        self.face:groups = []
        # Display list id for quick rendering
        self.display_list_id = None

现在我们已经有了基本的类定义,我们可以给Model3D添加一个方法来打开一个 OBJ 文件并读取内容。在read_obj方法中(参见清单 11-4 ,我们遍历文件的每一行,并将其解析成一个命令字符串和一个数据列表。许多if语句决定如何处理存储在data中的信息。

清单 11-4 。解析 OBJ 文件的方法

def read_obj(self, fname):
    current_face:group = None
    file_in = open(fname)

    for line in file_in:

        # Parse command and data from each line
        words = line.split()
        command = words[0]
        data = words[1:]

        if command == 'mtllib': # Material library

            model_path = os.path.split(fname)[0]
            mtllib_path = os.path.join( model_path, data[0] )
            self.read_mtllib(mtllib_path)

        elif command == 'v': # Vertex
            x, y, z = data
            vertex = (float(x), float(y), float(z))
            self.vertices.append(vertex)

        elif command == 'vt': # Texture coordinate

            s, t = data
            tex_coord = (float(s), float(t))
            self.tex_coords.append(tex_coord)

        elif command == 'vn': # Normal

            x, y, z = data
            normal = (float(x), float(y), float(z))
            self.normals.append(normal)

        elif command == 'usemtl' : # Use material

            current_face:group = FaceGroup()
            current_face:group.material_name = data[0]
            self.face:groups.append( current_face:group )

        elif command == 'f':

            assert len(data) ==  3, "Sorry, only triangles are supported"

            # Parse indices from triples
            for word in data:
                vi, ti, ni = word.split('/')
                indices = (int(vi) - 1, int(ti) - 1, int(ni) - 1)
                current_face:group.tri_indices.append(indices)

    for material in self.materials.values():

        model_path = os.path.split(fname)[0]
        texture_path = os.path.join(model_path, material.texture_fname)
        texture_surface = pygame.image.load(texture_path)
        texture_data = pygame.image.tostring(texture_surface, 'RGB', True)

        material.texture_id = glGenTextures(1)
        glBindTexture(GL_TEXTURE_2D, material.texture_id)

        glTexParameteri( GL_TEXTURE_2D,
                         GL_TEXTURE_MAG_FILTER,
                         GL_LINEAR)
        glTexParameteri( GL_TEXTURE_2D,
                         GL_TEXTURE_MIN_FILTER,
                         GL_LINEAR_MIPMAP_LINEAR)

        glPixelStorei(GL_UNPACK_ALIGNMENT,1)
        width, height = texture_surface.get_rect().size
        gluBuild2DMipmaps( GL_TEXTURE_2D,
                           3,
                           width,
                           height,
                           GL_RGB,
                           GL_UNSIGNED_BYTE,
                           texture_data)

OBJ 文件中的第一个命令通常是mtllib,它告诉我们素材库文件的名称。当遇到这个命令时,我们将素材库的文件名传递给read_mtllib方法(我们将在后面编写)。

如果命令由几何体(顶点、纹理坐标或法线)组成,它将被转换为浮点值元组,并存储在适当的列表中。例如,行v 10 20 30将被转换成元组(10, 20, 30)并附加到self.vertices

每组面之前是一个usemtl命令,它告诉我们后续的面将使用哪种材质。当read_obj遇到这个命令时,它会创建一个新的FaceGroup对象来存储随后的材质名称和面信息。

面由f命令、定义,由面上每个顶点的一个词组成(3 代表三角形,4 代表四边形,等等)。).每个单词包含顶点、纹理坐标和法线列表的索引,由正斜杠字符(/)分隔。例如,下面的线定义了一个三角形,其中第一个点使用顶点 3、纹理坐标 8 和法线 10。这些索引的三元组存储在当前的面组中,当我们渲染它时,将用于重建模型形状。

按照代码解析 OBJ 文件中的每一行,我们进入一个循环,读取材质字典中的纹理并将它们上传到 OpenGL。我们将使用 mip 映射和高质量纹理缩放。

Image 注意为了简单起见,Model3D 类只处理包含三角形的 OBJ 文件。如果您的模型包含四边形或其他多边形,您将需要使用 3D 建模器将其转换为三角形。

读取材质的方法类似于read_obj方法,但是更简单,因为我们只对纹理名称感兴趣。材料中还存储了其他信息,但为了简单起见,我们现在忽略它。

在一个素材库文件中,newmtl命令开始一个新的材质定义,map_Kd命令设置纹理文件名。read_mtllib方法(清单 11-5 )提取这些信息并存储在self.materials字典中。

清单 11-5 。解析素材库

def read_mtllib(self, mtl_fname):

    file_mtllib = open(mtl_fname)
    for line in file_mtllib:

        words = line.split()
        command = words[0]
        data = words[1:]

        if command == 'newmtl':
            material = Material()
            material.name = data[0]
            self.materials[data[0]] = material

        elif command == 'map_Kd':
            material.texture_fname = data[0]

这两个函数(read_objread_mtllib)足以从 OBJ 文件中读取我们需要的所有信息,现在我们可以编写代码将几何图形发送到 OpenGL。draw方法(见清单 11-6 )遍历每个面组,绑定一个纹理,并将几何列表中的数据发送给 OpenGL。

清单 11-6 。将几何图形发送到 OpenGL

def draw(self):

    vertices = self.vertices
    tex_coords = self.tex_coords
    normals = self.normals

    for face:group in self.face:groups:

        material = self.materials[face:group.material_name]
        glBindTexture(GL_TEXTURE_2D, material.texture_id)

        glBegin(GL_TRIANGLES)
        for vi, ti, ni in face:group.tri_indices:
            glTexCoord2fv( tex_coords[ti] )
            glNormal3fv( normals[ni] )
            glVertex3fv( vertices[vi] )
        glEnd()

def draw_quick(self):

    if self.display_list_id is None:
        self.display_list_id = glGenLists(1)
        glNewList(self.display_list_id, GL_COMPILE)
        self.draw()
        glEndList()

    glCallList(self.display_list_id)

一次发送一个顶点的几何图形可能会很慢,因此还有一个draw_quick方法来编译包含模型几何图形的显示列表,然后只需调用一次glCallList就可以呈现该列表。

Model3D类现在包含了我们加载和渲染 3D 模型所需的一切,但是在我们使用它之前,我们应该编写代码来清理我们使用过的 OpenGL 资源。清单 11-7 给Model3D添加了一个free_resources方法,删除显示列表和任何创建的纹理。当您不再需要模型时,可以调用这个方法,或者您可以让它被__del__方法自动调用,当不再有对对象的引用时,Python 就会调用这个方法。

清单 11-7 。清理 OpenGL 资源

def __del__(self):

    #Called when the model is cleaned up by Python
    self.free_resources()

def free_resources(self):

    # Delete the display list and textures
    if self.display_list_id is not None:
        glDeleteLists(self.display_list_id, 1)
        self.display_list_id = None

    # Delete any textures we used
    for material in self.materials.values():
        if material.texture_id is not None:
            glDeleteTextures(material.texture_id)

    # Clear all the materials
    self.materials.clear()

    # Clear the geometry lists
    del self.vertices[:]
    del self.tex_coords[:]
    del self.normals[:]
    del self.face:groups[:]

Image 注意手动调用free_resources函数是个好主意,因为如果 Python 在 PyOpenGL 退出后清理 Model3D 对象,可能会导致错误。

使用 Model3D 类

让我们编写一个脚本,使用我们的Model3D类来加载和呈现模型。清单 11-8 创建一个Model3D对象并用它来读取mytank.obj(和材料)。

清单 11-8 。渲染坦克模型(tankdemo.py)

from math import radians

from OpenGL.GL import *
from OpenGL.GLU import *

import pygame
from pygame.locals import *

# Import the Model3D class
import model3d

SCREEN_SIZE = (800, 600)

def resize(width, height):

    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(60.0, float(width)/height, .1, 1000.)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():

    # Enable the GL features we will be using
    glEnable(GL_DEPTH_TEST)
    glEnable(GL_LIGHTING)
    glEnable(GL_COLOR_MATERIAL)
    glEnable(GL_TEXTURE_2D)
    glEnable(GL_CULL_FACE)

    glShadeModel(GL_SMOOTH)
    glClearColor(1.0, 1.0, 1.0, 0.0) # white

    # Set the material
    glMaterial(GL_FRONT, GL_AMBIENT, (0.0, 0.0, 0.0, 1.0))
    glMaterial(GL_FRONT, GL_DIFFUSE, (0.2, 0.2, 0.2, 1.0))
    glMaterial(GL_FRONT, GL_SPECULAR, (1.0, 1.0, 1.0, 1.0))
    glMaterial(GL_FRONT, GL_SHININESS, 10.0)

    # Set light parameters
    glLight(GL_LIGHT0, GL_AMBIENT, (0.0, 0.0, 0.0, 1.0))
    glLight(GL_LIGHT0, GL_DIFFUSE, (0.4, 0.4, 0.4, 1.0))
    glLight(GL_LIGHT0, GL_SPECULAR, (1.0, 1.0, 1.0, 1.0))

    # Enable light 1 and set position
    glEnable(GL_LIGHT0)
    glLight(GL_LIGHT0, GL_POSITION,  (0, .5, 1, 0))

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE|OPENGL|DOUBLEBUF)

    resize(*SCREEN_SIZE)
    init()

    clock = pygame.time.Clock()

    # Read the model
    tank_model = model3d.Model3D()
    tank_model.read_obj('mytank.obj')

    rotation = 0.0

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()

        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        time_passed = clock.tick()
        time_passed_seconds = time_passed / 1000.0

        glLoadIdentity()
        glRotatef(15, 1, 0, 0)
        glTranslatef(0.0, -1.5, -3.5)

        rotation += time_passed_seconds * 45.0
        glRotatef(rotation, 0, 1, 0)

        tank_model.draw_quick()

        pygame.display.flip()

if __name__ == "__main__":
    run()

清单 11-8 中的函数启用了我们将使用的 OpenGL 特性。它还为材质和灯光设置参数,这将使坦克具有金属外观。我们将在下一章讨论材质和照明参数。

在绘制坦克之前,设置模型矩阵,使相机略高于坦克并俯视它——这比沿 z 轴观察它提供了一个稍微有趣的视点。

glRotatef(15, 1, 0, 0)的调用创建了一个绕 x 轴 15 度的旋转矩阵,相当于向下看了一点。这个旋转矩阵乘以一个*移矩阵,调用glTranslatef(0.0, -1.5, -3.5),有效地将坦克下移 1.5 个单位,再移回 3.5 个单位。第二次调用glRotatef使坦克绕 y 轴旋转,这样我们可以看到它在运动。

当你运行清单 11-8 中的时,你应该会看到一辆看起来令人生畏的坦克绕着 y 轴旋转(见图 11-6 )。如果你有一个更好的模型来代替坦克,那么用你的模型的文件名替换mytank.obj

9781484209714_Fig11-06.jpg

图 11-6 。加油示范 py

Image 注意并非所有的 3D modeler 应用都使用相同的比例。如果您发现您的模型非常大或非常小,您可能需要在导出之前对其进行缩放,或者更改代码以在不同的距离查看对象。

摘要

纹理是使 3D 场景看起来令人信服的主要方式,因为你可以将真实世界的图像应用到游戏中的对象。照片是创建纹理的好方法——数码相机是游戏开发的绝佳工具!或者您可以使用其他来源的图像;Pygame wiki ( www.pygame.org/wiki/resources)有一个很棒的免费纹理网站链接集。

因为 OpenGL 是独立于 Pygame 创建的,所以没有一个函数可以用来读取和上传一个图像作为纹理;将文件中的图像数据保存到高速视频存储器需要几个步骤。如果你使用本章概述的步骤,你应该没有问题处理纹理。请务必设置最小化和最大化纹理参数,以及包装功能。

您学习了如何从文件中读取 3D 模型并使用 OpenGL 渲染它。波前 OBJ 格式得到了很好的支持,你可能会发现Model3D类对于你写的任何游戏都足够了。或者您可能想要扩展read_obj方法来覆盖更多的特性(例如,四边形)。如果你想在游戏中支持其他格式,你可以在网上找到你需要的文档。编写解析器可能具有挑战性,但这是掌握 3D 模型创建细节的一个很好的方法。

在下一章中,你将学习如何使用 OpenGL 光照,并创建令人印象深刻的特效,为你的游戏增添光彩。

十二、使用 OpenGL 设置场景

你已经对 OpenGL 有了很大的进步,已经学会了如何在 3D 场景中渲染和操作对象。在这一章中,我们将更详细地介绍照明,并向你介绍 OpenGL 的其他特性,这些特性将帮助你给你的游戏增添光彩。

了解照明

我在第七章中向你介绍了 OpenGL 的光照功能,但是忽略了一些细节。在这一部分,我们将探索如何使用灯光,并用它们来增加 3D 场景的真实感。

OpenGL 使用您分配给模型顶点或面的法线来计算玩家将看到的光线颜色。渲染场景中的多边形时会使用这些颜色,方法是为每个面指定一种颜色(*面着色),或者在顶点之间混合颜色(如果启用了*滑着色)。模型越详细,光照就越有说服力,因为颜色只计算整个面或顶点,而不是多边形中的每个像素。

OpenGL 支持三种类型的灯光:

  • *行光: *行光是光线相互*行的光源。太阳通常被视为*行光——尽管从技术上来说它是点光源——因为它太远了,当光线到达地球时,光线实际上是*行的。
  • 点光源(也叫位置光): 点光源是从单个点发出的光源,用于表示场景中大多数邻*的光线。点光源的常见用途是灯泡、火焰和特殊效果。
  • 聚光灯: 聚光灯类似于点光源,从一个点发出,但光线聚焦到一个圆形区域,以该点为尖端,形成一个圆锥形。这里不涉及 OpenGL 聚光灯,因为在游戏中不常用到,不过如果有兴趣可以在www.opengl.org/sdk/ docs/man/在线查看 OpenGL 文档。聚光灯一样的效果可以通过其他方式来实现,通常是混合,我将在本章后面介绍。

启用照明

要在 OpenGL 中启用光照,只需调用glEnable(GL_LIGHTING)。如果你需要暂时禁用照明——比如渲染字体——调用glDisable(GL_LIGHTING)。你还需要启用你想在场景中使用的单个灯光,这也可以用glEnable通过传递一个灯光常量(GL_LIGHT0GL_LIGHT7)来实现。

设置灯光参数

使用glLight功能可以设置许多灯光参数。这个函数为你想要设置的灯光取一个常量(GL_LIGHT0GL_LIGHT7),后面是你想要设置的参数的常量和你想要设置的值。灯光参数列表见表 12-1 。(此表不包括聚光灯使用的参数。完整列表见 OpenGL 文档。)

表 12-1 。灯光参数

|

参数名称

|

类型

|

说明

|
| --- | --- | --- |
| GL_POSITION | 要点 | 设置灯光 GL_AMBIENT 的位置 |
| GL_AMBIENT | 颜色 | 设置灯光的环境强度 |
| GL_DIFFUSE | 颜色 | 设置灯光的漫射颜色 |
| GL_SPECULAR | 颜色 | 设置灯光将创建的镜面高光的颜色 |
| GL_CONSTANT_ATTENUATION | 数字 | 指定了常数衰减系数 |
| GL_LINEAR_ATTENUATION | 数字 | 指定线性衰减系数 |
| GL_QUADRATIC_ATTENUATION | 数字 | 指定二次衰减系数 |

GL_POSITION参数用于设置将要创建的光的位置和类型。它获取位置的 x、y 和 z 坐标以及一个名为w的附加参数。如果w设置为 0.0,灯光将为方向,如果w设置为 1.0,灯光将为位置(点光源)。以下两条线在(1.0,1.0,1.0)处创建*行光,在坐标(50.0,100.0,0.0)处创建位置光:

glLight(GL_LIGHT0, GL_POSITION, (1.0, 1.0, 1.0, 0.0))
glLight(GL_LIGHT1, GL_POSITION, (50.0, 100.0, 0.0, 1.0))

*行光沿原点方向发出*行光线,因此(1.0,1.0,1.0)处的位置光将模拟位于当前视点右上方后方的无限远的光源。位置光从坐标(50.0,100.0,0.0)向所有方向发送光线。

还有三种颜色值可以为灯光设置:环境光漫反射镜面反射。环境颜色用于模拟从场景中其他对象反射的间接光的效果。在阳光明媚的日子里,周围的颜色会非常明亮,因为即使在阴凉处也有足够的光线可以看清。相反,在一个只有一盏油灯照明的洞穴里,周围的颜色会很暗,甚至可能是黑色,从而在没有照明的地方形成一片漆黑。

灯光的漫反射颜色是照亮周围场景的灯光的主要颜色,取决于您试图模拟的光源;阳光很可能是明亮的白色,但其他光源,如蜡烛、火球和魔法咒语,可能会有不同的颜色。

灯光的最终颜色值是高光颜色,它是由反射或抛光表面产生的高光颜色。如果你看一个闪亮的物体,你会看到亮点,光线直接反射到你的眼睛里。在 OpenGL 中,这些亮点的颜色是由高光颜色参数决定的。

以下三个对glLight的调用将创建一个很适合作为火球或火箭的光:

glLight(GL_LIGHT1, GL_AMBIENT, (0.05, 0.05, 0.05))
glLight(GL_LIGHT1, GL_DIFFUSE, (1.0, 0.2, 0.2))
glLight(GL_LIGHT1, GL_SPECULAR, (1.0, 1.0, 1.0))

环境是深灰色的,因为我们希望效果是局部的,而不是在短距离之外贡献太多的光。漫反射是明亮的火焰红色,镜面反射是强烈的白色,以在附*的表面产生明亮的高光。

灯光也有三个衰减、??,定义点光源的亮度如何随距离变化。OpenGL 使用三个因子常数线性、二次、来计算一个数字,该数字乘以由照明计算产生的颜色。OpenGL 使用的公式相当于清单 12-1 中的get_attenuation函数,它取一个点到光源的距离,后跟三个衰减因子。get_attenuation的返回值是一个数字,表示距离光源一定距离处的灯光亮度。

清单 12-1 。计算衰减系数的函数

def get_attenuation(distance, constant, linear, quadratic):
    return 1.0 / (constant + linear * distance + quadratic * (distance ** 2))

Image 注意OpenGL 可以使用的最大亮度级别是 1.0——大于 1.0 的值不会有任何额外的效果。

对于一个火球效果,我们可能希望在视觉效果周围的区域发出强烈的光,但是很快就消失了。以下对glLight的调用设置了三个衰减因子,使得光线在一个单位的半径内达到最大强度,但很快减弱,直到几个单位之外几乎没有任何光线:

glLight(GL_LIGHT1, GL_CONSTANT_ATTENUATION, 0.5)
glLight(GL_LIGHT1, GL_LINEAR_ATTENUATION, 0.3)
glLight(GL_LIGHT1, GL_QUADRATIC_ATTENUATION, 0.2)

Image 注意衰减需要 OpenGL 为渲染的每个多边形做更多的工作,并且会对你的游戏产生性能影响——尽管除非你有一个旧的显卡,否则这可能不会被注意到。相对于现代游戏,我们的游戏只有很少的多边形,所以这绝对不是问题。

使用材料

OpenGL 中的材质是定义多边形应该如何与光源交互的参数集合。 这些参数是用glMaterial函数设置的,并与灯光参数结合生成渲染时使用的最终颜色。glMaterial的第一个参数通常是GL_FRONT,它设置面向相机的多边形的参数,但也可以是面向远离相机的多边形的GL_BACK——或者是面向两者的GL_FRONT_AND_BACK。对于完全封闭的模型——也就是说,你看不到它们的内部——你可以坚持使用GL_FRONT,因为只有正面是可见的(除非你在模型的内部!).glMaterial的第二个参数是你要设置的材料参数的常数,最后一个参数是你要设置的值。表 12-2 列出了您可以使用的材料参数。

表 12-2 。材料参数

|

参数名称

|

类型

|

说明

|
| --- | --- | --- |
| GL_AMBIENT | 颜色 | 设置材质颜色的环境贡献。 |
| GL_DIFFUSE | 颜色 | 设置材质的漫射颜色。 |
| GL_SPECULAR | 颜色 | 设置材质上高光的颜色。 |
| GL_SHININESS | 数字 | 指定一个介于 0 和 128 之间的值,用于定义材质的光泽度。 |
| GL_EMISSION | 颜色 | 指定材料的发射颜色。 |

“环境光”、“漫反射”和“镜面反射颜色”参数的含义与它们对于灯光的含义相同,并且与相应的灯光参数组合在一起。GL_SHININESS参数定义了材质上镜面高光的大小。较高的值会产生小的高光,使材质看起来坚硬或光滑。值越低,创建的材质越粗糙。GL_EMISSION参数定义材料的发射颜色,即材料本身发出的光的颜色。材质在 OpenGL 中不能用作光源,但是设置发射色会使模型看起来有自己的内部照明。

调整参数

为材质和灯光选择参数更像是一门艺术而不是科学;实验通常是获得你想要的外观的最好方法。选择上一章中旋转槽的材料和灯光设置(清单 11-8 )来使槽看起来像金属。尝试使用坦克的材质值来创建不同的外观。通过改变一些参数,你应该能够使它看起来像一个塑料玩具或木制模型。

Image 提示灯光参数可以随时间变化,产生一些有趣的效果。

管理灯光

在渲染场景时,至少有八个 OpenGL 灯光供您使用,但是很容易想象需要更多的灯光。考虑一个以教堂为背景的场景;窗外有月光,几盏闪烁的灯,祭坛上有许多蜡烛。我们不能在一帧中激活所有这些光源,那么我们如何管理它们呢? 第一步是把相*的光源组合成单一的光。祭坛上的蜡烛是一个完美的候选;虽然有许多光源,但如果将它们替换为从一组蜡烛中心的某个地方发出的单点光源,玩家可能不会注意到。减少所用灯光数量的下一步是优先选择明亮和/或关闭的灯光,而不是较远或不太明亮的灯光。如果我们对每一帧都执行这些步骤,我们可以在不影响场景视觉质量的情况下减少灯光数量。

场景中灯光的数量也会影响游戏的帧速率,因为 OpenGL 必须为每个额外的灯光做一系列的计算。为了提高性能,你可能希望将游戏中使用的最大灯光数量设置为比 OpenGL 所能处理的最大数量低

理解混合

到目前为止,当我们渲染多边形时,纹理的颜色已经完全取代了它们下面的屏幕像素——这是我们在渲染实心物体时需要的,但不会帮助我们渲染半透明物体。为此,我们需要利用 OpenGL 的混合特性,将纹理的颜色和其下表面的颜色结合起来。

混合对于创造许多玩家在今天的游戏中认为理所当然的效果也是必不可少的,因为它可以用来创造任何东西,从汽车挡风玻璃上的污点到壮观的爆炸。在这一部分,我们将探索如何在你的游戏中使用混合,并检查一些可以用它创建的效果。

使用混合

当 OpenGL 执行混合时,它从源和目标(即纹理和屏幕)采样一种颜色,然后用一个简单的等式将这两种颜色组合起来,以产生写入屏幕的最终颜色。OpenGL 对颜色进行数学运算,但是如果它是用 Python 编写的,它可能看起来像这样:

src = texture.sample_color()
dst = screen.sample_color()
final_color = blend_equation(src * src_factor, dst * dst_factor)
screen.put_color(final_color)

srcdst值 是从纹理和屏幕上采样的两种颜色。这些颜色各自乘以一个混合因子 ( src_factordst_factor),并与一个blend_equation函数相结合,产生写入屏幕的最终颜色。组合不同的混合因子和混合方程式可以产生大量的视觉效果。

在 OpenGL 术语中,混合因子是用glBlendFunc函数设置的,该函数为源因子和目标因子取两个常数。例如,调用glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)会将源颜色乘以源的 alpha 分量,将目标颜色乘以 1 减去源的 alpha 分量。见表 12-3 中可能混合因子的完整列表。

表 12-3 。混合因子常数

|

混合因子常数

|

说明

|
| --- | --- |
| GL_ZERO | 乘以零 |
| GL_ONE | 乘以一 |
| GL_SRC_COLOR | 乘以源 |
| GL_ONE_MINUS_SRC_COLOR | 乘以 1 减去源颜色 |
| GL_DST_COLOR | 乘以目的地 |
| GL_ONE_MINUS_DST_COLOR | 乘以 1 减去目标颜色 |
| GL_SRC_ALPHA | 乘以源 alpha 分量 |
| GL_ONE_MINUS_SRC_ALPHA | 乘以源 alpha 分量的倒数 |
| GL_DST_ALPHA | 乘以目标 alpha |
| GL_ONE_MINUS_DST_ALPHA | 乘以目标 alpha 的倒数 |
| GL_CONSTANT_COLOR | 乘以恒定颜色(用glBlendColor设置) |
| GL_ONE_MINUS_CONSTANT_COLOR | 乘以常数颜色的倒数 |
| GL_CONSTANT_ALPHA | 乘以常数α |
| GL_ONE_MINUS_CONSTANT_ALPHA | 乘以常数α的倒数 |
| GL_SRC_ALPHA_SATURATE | 指定源 alpha 和一减去源 alpha 的最小值 |

使用glBlendEquation函数设置混合方程,该函数采用多个潜在混合方程常数中的一个。默认值是GL_ADD,它只是将源颜色和目标颜色相加(乘以混合因子后)。混合方程常数的完整列表见表 12-4 。

表 12-4 。混合方程常数

|

混合方程常数

|

说明

|
| --- | --- |
| GL_FUNC_ADD | 添加源颜色和目标颜色 |
| GL_FUNC_SUBTRACT | 从源颜色中减去目的颜色 |
| GL_FUNC_REVERSE_SUBTRACT | 从目标颜色中减去 |
| GL_MIN | 计算源颜色和目标颜色的最小值(最暗值) |
| GL_MAX | 计算源颜色和目标颜色的最大值(最亮值) |
| GL_LOGIC_OP | 用一个逻辑运算组合源和目标颜色(有关逻辑运算的信息,请参见 OpenGL 文档) |

混合中涉及的另一个 OpenGL 函数是glBlendColor,它设置一个在一些混合因子中使用的恒定颜色。该函数将红色、绿色、蓝色和 alpha 分量用作常量颜色。例如,glBlendColor(1.0, 0.0, 0.0, 0.5)将设置 50%半透明红色的恒定颜色。

在游戏中启用混合的步骤如下:

  • 通过调用glEnable(GL_BLEND)启用混合。
  • 调用glBlendFunc功能设置混合因子。混合系数的完整列表见表 12-3 。
  • 调用glBlendEquation函数设置混合方程式。混合方程的完整列表见表 12-4 。
  • 如果您已经使用了一个引用恒定颜色的混合因子,您还需要调用glBlendColor函数,该函数将红色、绿色、蓝色和 alpha 颜色组件作为参数。

OpenGL 通过各种混合选项支持许多潜在的混合效果。以下部分涵盖了游戏中常用的一些效果以及创建这些效果所需的混合选项。

阿尔法混合

最常用的混合效果之一是 alpha 混合,它使用图像中的 alpha 通道来控制像素的不透明度。Alpha 混合可用于创建带有孔洞或不规则边缘的纹理,如撕破的旗帜。

我们可以使用以下函数调用来设置 alpha 混合:

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glBlendEquation(GL_FUNC_ADD)

这告诉 OpenGL 将源颜色乘以源 alpha,将目标颜色乘以 1.0 减去源 alpha。然后,它用GL_FUNC_ADD混合公式将这两种颜色结合起来——简单地将它们加在一起,产生写入屏幕的最终颜色。效果是根据源颜色的 alpha 分量对颜色进行插值,相当于清单 12-2 中的alpha_blend函数。

清单 12-2 。Python Alpha 混合函数

def alpha_blend(src, dst):
    return src * src.a + dst * (1.0—src.a)

这种 alpha 混合对于在需要许多小多边形的地方创建细节非常有用。例如,越野赛车游戏中的一簇杂草可以创建为具有半透明区域的单一纹理,而不是详细的模型。

混合也可以使用常量 alpha 组件来完成,而不是在纹理中使用 alpha 通道。这对于从整体上改变纹理的不透明度非常有用,而不是针对单个纹理元素。常量 alpha 混合可以用来渲染从窗口到力场的任何东西。下面的调用将使纹理 50%透明,或者 50%不透明,如果你喜欢的话。

glBlendColor(1.0, 1.0, 1.0, 0.5)
glBlendFactor(GL_CONSTANT_ALPHA, GL_ONE_MINUS_CONSTANT_ALPHA)
glBlendEquation(GL_ADD)

添加剂混合

另一种有用的混合技术是加色混合,它类似于基本的 alpha 混合,只是源颜色被直接添加到目标颜色,从而使底层图像变亮。这就产生了一种发光的效果,这就是为什么加法混合经常被用来渲染火焰、电流或类似的取悦大众的特殊效果。下面的函数调用选择添加剂混合:

glBlendFunc(GL_SRC_ALPHA,  GL_ONE)
glBlendEquation(GL_FUNC_ADD)

这与常规 alpha 混合的唯一区别是目标混合因子被设置为GL_ONE,它将颜色乘以 1.0——实际上,根本不改变它。添加剂混合相当于清单 12-3 中的additive_blend函数。

清单 12-3 。Python 加法混合函数

def additive_blend(src, dst):
    return src * src.a + dst

减法混合

加法混合的反向操作是减法混合,即从底层图像中减去源颜色,使其变得更暗。以下函数调用为减法混合设置混合选项:

glBlendFunc(GL_SRC_ALPHA,  GL_ONE)
glBlendEquation(GL_FUNC_REVERSE_SUBTRACT)

混合方程式被设置为GL_FUNC_REVERSE_SUBTRACT而不是GL_FUNC_SUBTRACT,因为我们想要从目标减去源,而不是相反。

GL_ADD没有倒版是因为 A+B 永远和 B+A 一样(减法也不一样;A–B 并不总是与 B–A 相同。

减色混合非常适合渲染烟雾,因为几层会产生纯黑色。清单 12-4 中的函数是 Python 中减法混合的等价物。

清单 12-4 。Python 减法混合函数

def subtractive_blend(src, dst):
    return dst—src * src.a

看到融合在行动中

让我们写一些代码来帮助我们可视化一些常用的混合效果。当您执行清单 12-5 时,您会看到一个背景图像和另一个较小的图像——我们的朋友河豚——可以用鼠标移动。fugu 图像包含 alpha 信息,但是由于没有设置混合选项,您将看到图像中通常不可见的部分。如果你按下键盘上的 1 键,它将启用阿尔法混合,河豚图像的背景像素将变得不可见(参见图 12-1 )。按 2 选择加性混合,使河豚发出鬼魅般的光芒,按 3 选择减性混合,使河豚产生暗影。

清单 12-5 。演示混合效果(blenddemo.py)

from OpenGL.GL import *
from OpenGL.GLU import *

import pygame
from pygame.locals import *

SCREEN_SIZE = (800, 600)

def resize(width, height):

    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(45.0, float(width)/height, .1, 1000.)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():

    glEnable(GL_TEXTURE_2D)
    glEnable(GL_BLEND)
    glClearColor(1.0, 1.0, 1.0, 0.0)

def upload_texture(filename, use_alpha=False):

    # Read an image file and upload a texture
    if use_alpha:
        format, gl_format, bits_per_pixel = 'RGBA', GL_RGBA, 4
    else:
        format, gl_format, bits_per_pixel = 'RGB', GL_RGB, 3

    img_surface = pygame.image.load(filename)

    #img_surface = premul_surface(img_surface)

    data = pygame.image.tostring(img_surface, format, True)

    texture_id = glGenTextures(1)

    glBindTexture(GL_TEXTURE_2D, texture_id)

    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR )
    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR )

    glPixelStorei(GL_UNPACK_ALIGNMENT, 1)

    width, height =  img_surface.get_rect().size

    glTexImage2D(   GL_TEXTURE_2D,
                    0,
                    bits_per_pixel,
                    width,
                    height,
                    0,
                    gl_format,
                    GL_UNSIGNED_BYTE,
                    data )

    # Return the texture id, so we can use glBindTexture
    return texture_id

def draw_quad(x, y, z, w, h):

    # Send four vertices to draw a quad
    glBegin(GL_QUADS)

    glTexCoord2f(0, 0)
    glVertex3f(x-w/2, y-h/2, z)

    glTexCoord2f(1, 0)
    glVertex3f(x+w/2, y-h/2, z)

    glTexCoord2f(1, 1)
    glVertex3f(x+w/2, y+h/2, z)

    glTexCoord2f(0, 1)
    glVertex3f(x-w/2, y+h/2, z)

    glEnd()

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE|OPENGL|DOUBLEBUF)

    resize(*SCREEN_SIZE)
    init()

    # Upload the background and fugu texture
    background_tex = upload_texture('background.png')
    fugu_tex = upload_texture('fugu.png', True)

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()
            if event.type == KEYDOWN:
                if event.key == K_1:
                    # Simple alpha blending
                    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
                    glBlendEquation(GL_FUNC_ADD)
                elif event.key == K_2:
                    # Additive alpha blending
                    glBlendFunc(GL_SRC_ALPHA, GL_ONE)
                    glBlendEquation(GL_FUNC_ADD)
                elif event.key == K_3:
                    # Subtractive blending
                    glBlendFunc(GL_SRC_ALPHA, GL_ONE)
                    glBlendEquation(GL_FUNC_REVERSE_SUBTRACT)

        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        # Draw the background
        glBindTexture(GL_TEXTURE_2D, background_tex)

        glDisable(GL_BLEND)
        draw_quad(0, 0, -SCREEN_SIZE[1], 600, 600)
        glEnable(GL_BLEND)

        # Draw a texture at the mouse position
        glBindTexture(GL_TEXTURE_2D, fugu_tex)
        x, y = pygame.mouse.get_pos()
        x -= SCREEN_SIZE[0]/2
        y -= SCREEN_SIZE[1]/2
        draw_quad(x, -y, -SCREEN_SIZE[1], 256, 256)

        pygame.display.flip()

    # Free the textures we used
    glDeleteTextures(background_tex)
    glDeleteTextures(fugu_tex)

if __name__ == "__main__":
    run()

9781484209714_Fig12-01.jpg

图 12-1 。混合效果

试着改变glBlendFuncglBlendEquation中的常量来产生一些更有趣的效果。如果你想实现这里没有提到的特殊效果,你可以在网上找到参数。

混合问题

如果在场景中大量使用混合,会有一些潜在的问题。渲染混合多边形时,它仍会将信息写入深度缓冲区,OpenGL 使用深度缓冲区来防止背景对象与前景对象重叠。这样做的问题是,一旦一个半透明的多边形被画出,它后面就不能再画多边形了。一个好的解决方法是画出所有不透明的多边形,然后从后向前画半透明的多边形,这样最远的多边形会先画出来。

混合多边形的渲染时间也往往比不透明多边形长,因此,如果您发现场景渲染缓慢,请尝试减少半透明多边形的数量。

理解雾

雾 是 OpenGL 的一个特性,可以用来模拟大气对渲染对象的影响。启用雾化功能时,OpenGL 会随着距离的增加将多边形混合为一种纯色,这样当对象从前景移动到背景时,它会逐渐呈现雾化颜色,直到变成一个纯色轮廓。在早期的游戏中,雾经常被用来隐藏物体不能被拉得很远的事实。它不是突然出现的风景,而是在几帧内与背景色融合在一起。现代游戏较少受到这个问题的困扰,因为它们可以将场景渲染到更远的距离,但当远处的物体进入相机的范围时,雾仍然经常被用来巧妙地融入其中。

雾本身也有助于创造视觉效果;最明显的用途是模拟真实的雾,但您也可以使用该功能通过雾化成黑色或模拟火星上的红色薄雾来增强昏暗的室内场景。

雾参数

调用glEnable(GL_ FOG)到 让 OpenGL 应用雾到所有渲染的多边形上。您可以使用glFog系列函数设置许多雾参数,这些函数采用一个常量作为您想要设置的值,后跟该值本身。

雾的颜色可以通过用常量GL_FOG_COLOR调用glFogfv来设置,后面跟着你想要使用的颜色。例如,下面一行将雾的颜色设置为纯白色(也许是为了模拟暴风雪):

glFogfv(GL_FOG_COLOR, (1.0, 1.0, 1.0))

OpenGL 有三种不同的雾模式,它们定义了雾如何随距离变化。这些雾化模式可以用glFogi功能设置。例如,下面一行将设置 fog 使用GL_LINEAR模式:

glFogi(GL_FOG_MODE, GL_LINEAR)

GL_LINEAR雾模式最常用于掩盖远处景物进入视野的效果,因为起点和终点可以独立设置,雾会在它们之间淡入。例如,如果您有一个赛车游戏,可以将 1,000 个单位渲染到远处,并且您希望距离轨迹和树在最终单位上淡入,您可以设置以下雾参数:

glFogi(GL_FOG_MODE, GL_LINEAR)
glFogf(GL_FOG_START, 999.0)
glFogf(GL_FOG_END, 1000.0)

GL_FOG_STARTGL_FOG_END参数标记雾应该开始和结束的摄像机的距离。

GL_FOG_MODE参数的其他潜在值是GL_EXPGL_EXP2。两者都能产生看起来更自然的雾,并且更适合模拟真实的雾或霾。GL_EXP模式为摄像机附*的物体快速混合雾,但为较远的物体混合较慢。GL_EXP2与此相似,但是开始时前景中的雾色较少。

GL_EXPGL_EXP2都使用单一的密度值,而不是起点和终点的值。密度可以用GL_FOG_DENSITY参数设置,该值介于 0.0 和 1.0 之间,值越高,雾越浓。以下调用将创建一个令人信服的火星薄雾,红色GL_EXP2雾的密度为 0.2:

glFogfv(GL_FOG_COLOR, (1.0, 0.7, 0.7))
glFogi(GL_FOG_MODE, GL_EXP2)
glFogf(GL_FOG_DENSITY, 0.2)

看到雾在行动

让我们修改前一章的旋转坦克,而不是写一个完整的脚本来测试雾(见清单 11-8)。我们将从添加几行到init函数开始,以启用雾化并设置雾化参数。下面的线条创建了一个简单的线性雾,逐渐变成白色:

glEnable(GL_FOG)
glFogfv(GL_FOG_COLOR, (1.0, 1.0, 1.0))
glFogi(GL_FOG_MODE, GL_LINEAR)
glFogf(GL_FOG_START, 1.5)
glFogf(GL_FOG_END, 3.5)

如果您现在运行修改后的清单 11-8,您将会看到类似于图 12-2 的内容。雾开始于距离摄像机 1.5 个单位,结束于距离摄像机 3.5 个单位,这使得坦克的一部分被雾完全遮挡。这不是一个非常有用的雾,因为它会模糊任何不靠*相机的东西,但它确实很好地展示了效果。

9781484209714_Fig12-02.jpg

图 12-2 。浓雾弥漫的坦克

旋转坦克演示的另一个有用的修改是能够相对于摄像机移动坦克,这样我们就可以看到雾是如何随着距离变化的。用以下代码行替换对run函数中glTranslate的调用,用鼠标移动坦克:

tank_distance = pygame.mouse.get_pos()[1] / 50.0
glTranslatef(0.0, -1.5, -tank_distance)

现在当你运行坦克演示时,你可以通过上下移动鼠标来控制坦克与摄像机的距离。试着用火星烟雾的设置替换我们添加到init函数中的对glFogi的调用,或者用你自己的值进行实验以产生新的效果。您可能还想将透明色更改为类似于雾色,以便一个浓雾弥漫的坦克完全消失在背景中。

渲染背景

一个坦克游戏可能会设定在一个户外环境,可能是一个荒凉的,后世界末日的荒地,远处有山。自然,我们希望尽可能详细地渲染背景场景,为游戏动作提供良好的背景,但即使我们可以模拟每座山,也仍然太慢,无法将 3D 视觉效果一直渲染到地*线。这是一个玩家可以潜在地看到地*线的任何游戏的常见问题(即使是室内游戏也会受到影响,如果他们有一个窗口)。

天空体

渲染远处风景的一个常见解决方案是天空盒,它只是一个每边都有风景的纹理立方体。 立方体的前、后、左、右四个面都呈现出朝向地*线的景象。立方体的顶部是天空的图像,立方体的底部是地面。当天空盒被画在摄像机周围时,玩家四周都是远处风景的图像,这就产生了一种身临其境的错觉。

创建天空盒

天空盒是一个立方体的模型,可以在你选择的建模软件中创建——我用过的所有 3D 应用都提供了创建立方体图元的选项。天空盒应该围绕原点创建,这样立方体的中心就在(0,0,0)处。立方体的每一面都应该从天空盒的六个场景纹理中选择一个。

Image 注意你可能需要翻转立方体表面的法线,使它们指向内而不是外。这样做的原因是,立方体将从内部而不是外部被查看,并且您希望多边形面向相机。

生成天空盒纹理可能需要更多的努力,因为每个纹理必须与它共享一条边的其他四个纹理无缝对齐。如果你有艺术倾向,你可以画出这些纹理,但是使用 3D 建模软件为天空盒的每个面渲染场景的六个视图可能更容易。渲染天空盒的一个很好的选择是 Terragen ( www.planetside.co.uk/terragen/),它可以创建非常逼真的虚拟景观图像。我使用 Terragen 创建了图 12-3 中的天空立方体纹理,我们将在天空盒样本代码中使用。

9781484209714_Fig12-03.jpg

图 12-3 。天空盒的纹理

渲染天空盒

渲染天空盒应该是在一个新的帧中首先要做的事情,并且不需要清除颜色缓冲区(尽管你仍然需要清除深度缓冲区)。

因为天空盒只是立方体的一个模型,所以它可以存储为任何其他模型,但是在渲染之前需要一些额外的步骤:

  • 将天空盒中所有纹理的包裹模式设置为GL_CLAMP_TO_EDGE。这对于避免立方体面相交的天空盒中的接缝是必要的。有关包装模式的更多信息,请参见上一章。这一步只需要做一次。
  • 将天空盒的位置设置为与摄像机(即播放器)相同。这是因为天空盒代表了玩家永远无法到达的非常遥远的风景。
  • glDisable(GL_LIGHTING)禁用照明。我们不需要使用 OpenGL 的光照特性,因为天空盒的纹理已经被有效地预光照了。禁用照明后,OpenGL 将使用原始亮度级别渲染纹理。
  • glDepthMask(False)禁用深度缓冲器。通常,如果玩家在一个立方体里面,他将看不到立方体外面的任何东西,这显然不是我们想要的。用glDepthMask(False)深度蒙版设置为False告诉 OpenGL 忽略天空盒中的深度信息,这样其他模型将在它上面渲染。

渲染天空盒后,请确保重新启用照明和深度遮罩,否则场景中的其他模型可能无法正确渲染。下面两行应该跟在调用之后以呈现天空盒:

glEnable(GL_LIGHTING)
glDepthMask(True)

观看天空盒的运行

让我们编写代码来呈现天空盒。清单 12-6 使用前一章的Model3D类来加载天空盒模型及其相关纹理。当你运行它时,你会看到一个山脉的风景,如果你用鼠标调整视点,你将能够从任何方向看到风景。

清单 12-6 。渲染天空盒(skybox.py)

from math import radians
from OpenGL.GL import *
from OpenGL.GLU import *

import pygame
from pygame.locals import *

# Import the Model3D class
import model3d

SCREEN_SIZE = (800, 600)

def resize(width, height):

    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(60.0, float(width)/height, .1, 1000.)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():

    # Enable the GL features we will be using
    glEnable(GL_DEPTH_TEST)
    glEnable(GL_LIGHTING)
    glEnable(GL_TEXTURE_2D)
    glShadeModel(GL_SMOOTH)

    # Enable light 1 and set position
    glEnable(GL_LIGHTING)
    glEnable(GL_LIGHT0)
    glLight(GL_LIGHT0, GL_POSITION,  (0, .5, 1))

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, FULLSCREEN|HWSURFACE|OPENGL|DOUBLEBUF)

    resize(*SCREEN_SIZE)
    init()

    # Read the skybox model
    sky_box = model3d.Model3D()
    sky_box.read_obj('tanksky/skybox.obj')

    # Set the wraping mode of all textures in the sky-box to GL_CLAMP_TO_EDGE
    for material in sky_box.materials.values():

        glBindTexture(GL_TEXTURE_2D, material.texture_id)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)

    # Used to rotate the world
    mouse_x = 0.0
    mouse_y = 0.0

    #Don't display the mouse cursor
    pygame.mouse.set_visible(False)

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()
            if event.type == KEYDOWN:
                pygame.quit()
                quit()

        # We don't need to clear the color buffer (GL_COLOR_BUFFER_BIT)
        # because the skybox covers the entire screen
        glClear(GL_DEPTH_BUFFER_BIT)

        glLoadIdentity()

        mouse_rel_x, mouse_rel_y = pygame.mouse.get_rel()
        mouse_x += float(mouse_rel_x) / 5.0
        mouse_y += float(mouse_rel_y) / 5.0

        # Rotate around the x and y axes to create a mouse-look camera
        glRotatef(mouse_y, 1, 0, 0)
        glRotatef(mouse_x, 0, 1, 0)

        # Disable lighting and depth test
        glDisable(GL_LIGHTING)
        glDepthMask(False)

        # Draw the skybox
        sky_box.draw_quick()

        # Re-enable lighting and depth test before we redraw the world
        glEnable(GL_LIGHTING)
        glDepthMask(True)

        # Here is where we would draw the rest of the world in a game

        pygame.display.flip()

if __name__ == "__main__":
    run()

Skybox 增强功能

虽然天空盒创造了一个令人信服的远景幻觉,但也有一些增强功能可以用来给背景添加更多的视觉光晕。天空盒技术的一个缺点是它不会随着时间的推移而改变,因为图像已经预先渲染过了。 为天空盒的部分制作动画,或者在其上添加大气效果,可以增加一点额外的真实感。例如,让太阳发出一点微光或渲染远处的闪电可以增强天空盒。也可以将半透明的天空盒分层并制作一个或多个动画。例如,可能有一个天空盒用于远山,另一个用于云。独立旋转云天空盒会产生逼真的天气效果。

天空盒并不是游戏中渲染背景的唯一方法;天空穹顶 是一种类似的技术,使用球体或半球来显示远处的风景。圆顶可能是比立方体更明显的选择,因为真实的天空本质上是球形的,但是球体不像立方体那样容易纹理化。如果玩家永远不会直接向上看,圆柱是另一种选择。

去哪里寻求帮助

游戏编程的乐趣之一是它提供了许多有趣的问题,当你最终解决它们时,会给你一种真正的成就感。每一次你想出如何让事情运转起来,或者改正你犯的错误,你的游戏就离你想象的更*了一点。所以面对问题和修复错误是开发游戏过程的一部分,而不是什么可怕的事情。通常情况下,只需要从编程中休息一会儿,再想一想,答案就会出现在你面前。不过,偶尔,即使是经验丰富的游戏开发者也会面临他们找不到解决方案的问题。这是你应该寻求帮助的地方。

很可能另一个程序员面临着同样的问题,并在 Web 上记录了它,这就是为什么互联网是程序员最好的调试工具。尝试使用谷歌或其他搜索引擎,搜索与你面临的问题相关的关键词。如果你没有找到答案,你可以在邮件列表或新闻组中发布一条关于你的问题的消息。对于 Python 相关的问题,请前往http://www.reddit.com/r/learnpython/并发表您的问题。编程社区非常乐于助人,并且愿意付出很多努力来帮助你,只要你也付出一些努力。也可以在http://stackoverflow.com/上发帖提问。

摘要

在这一章中,我们详细介绍了 OpenGL 的光照特性。OpenGL 光照非常强大,可以极大地增强游戏的真实感。灯光和材质参数的组合将帮助您创建您在游戏中寻找的那种情绪,无论是明亮欢快的卡通世界,还是每个角落都潜伏着无法形容的怪物的噩梦般的景观。

混合是创建大量特殊效果的关键,但也可以用来简单地渲染半透明的对象。我们只讨论了一些可以用 OpenGL 的混合特性创建的效果;结合混合因子和方程式可以创建更多。

雾是 OpenGL 的另一个功能,它可以通过模拟大气效果来增强你的游戏,或者当它进入相机的范围时,隐藏远处场景的效果。雾很容易添加到场景中,因为只需要几行代码来启用和设置参数,因此您可以在不更改渲染代码的情况下试验雾。

我们还讨论了天空盒,它是给游戏添加背景的好方法。天空盒可能是旧技术,但它们仍然在现代游戏中使用,包括尖端的主机游戏。

游戏开发是一个不断扩展的领域,这使得它成为一个令人着迷的爱好或职业。大多数开发人员——无论是新手还是行业老手——都对他们所做的事情充满热情,并乐于与他人分享知识和代码。一旦你做好了一个游戏,你应该考虑提交到 Pygame 网站( http://www.pygame.org)上,分享给 Pygame 社区。www.pyweek.org

我们希望你喜欢读这本书。如果您对我们讨论的主题有任何疑问,我们很乐意回答。您可以在我们的网站上找到我们的联系方式:

原作者威尔·麦古根:http://www.willmcgugan.com

更新作者哈里森·金斯利:http://pythonprogramming.net

十三、附录 A:游戏对象参考

本附录记录了游戏对象库中的一些最基本的类,这些类为创建 2D 和 3D 游戏提供了一个工具箱。有关游戏对象的最新信息,请参见https://github.com/PythonProgramming/Beginning-Game-Development-with-Python-and-Pygame

进口

游戏对象库由许多可以单独导入的子模块组成。一般来说,最好使用from <module> import <object>语法只导入您需要的类。例如,这一行导入了Color类:

from gameobjects.color import Color

有时,子模块中有一些您可能也想使用的辅助函数。在这种情况下,您可以使用from <module> import *将所有内容导入您的名称空间。例如,下面的行将导入gameobjects.vector3类中的所有内容:

from gameobjects.vector3 import *

贡献的

游戏对象库是一个开源项目,这意味着任何程序员都可以做出贡献。我们鼓励你提交你发现的任何错误修复,或者你认为可能对库有帮助的类。

游戏对象.颜色.颜色

颜色对象代表一种带有 alpha 值的颜色。组件存储在 0 到 1 的范围内,如 OpenGL 所使用的,但是您可以用rgba8属性或as_tuple方法转换成整数 RGBA 格式。颜色对象支持基本的数学运算符;乘、除、加、减。你也可以将颜色乘以或除以一个标量值(例如,一个整数或浮点数),但是加上或减去一个标量将会抛出一个TypeError异常。

构造器

gameobjects.color构造函数接受红色、绿色和蓝色分量以及一个可选的 alpha 分量,范围从 0 到 1。组件也可以由三个或四个值组成。如果颜色是在没有任何参数的情况下构造的,它将默认为黑色。

属性

可以检索组件并设置rgba属性(参见表 A-1 )。rgba8rgb8(只读)属性可用于将组件检索为一个 0 到 255 范围内的整数元组。

表 A-1 游戏对象.颜色属性

|

属性

|

目的

|
| --- | --- |
| r | 0 到 1 范围内的红色分量 |
| g | 零到一范围内的绿色分量 |
| b | 0 到 1 范围内的蓝色分量 |
| a | 零到一范围内的 Alpha 分量 |
| rgba8 号 | (红、绿、蓝、阿尔法)组件的元组,范围从 0 到 255(与 Pygame 的 draw 函数兼容) |
| rgb8 | (红、绿、蓝)组件元组,范围为 0 到 255 |

方法

颜色对象支持表 A-2 中所示的方法。

表 A-2 。颜色方法

|

方法名称

|

说明

|
| --- | --- |
| copy() | 返回颜色的副本 |
| as_tuple() | 将颜色的副本作为其组件的元组返回 |
| as_tuple_rgb() | 将颜色的副本作为其红色、绿色和蓝色分量的元组返回 |
| as_tuple_rgba() | 与as_tuple相同 |
| as_html() | 以 HTML 格式返回包含颜色的字符串 |
| saturate() | 强制所有分量在 0 到 1 的范围内 |
| get_saturate() | 返回颜色的副本,其中所有组件都已饱和到范围 0–1 |
| invert() | 反转颜色(将每个组件设置为 1 减去自身) |
| mul_alpha() | 将红色、绿色和蓝色分量乘以 alpha 分量 |

类方法

gameobjects.color类包含许多类方法,这些方法提供了创建新颜色对象的替代方法(见表 A-3 )。

表 A-3 。颜色类方法

|

方法名称

|

说明

|
| --- | --- |
| black() | 返回表示黑色的颜色对象。 |
| white() | 返回表示全白的颜色对象。 |
| from_rgba8(r, g, b, a) | 从 0–255 范围内的颜色分量创建颜色对象。 |
| from_html(col_str) | 从以 HTML 格式编码的颜色创建一个颜色对象。 |
| gray (level) | 创建一个灰色对象;level是 0 到 1 范围内的一个值。 |
| from_palette(color_name) | 从内部调色板创建命名的颜色,例如,red, ivory, aquamarine等。有关完整列表,请参见在线文档。 |

game objects . matrix 44 . matrix 44

Matrix44类存储一个代表 3D 变换的 4 x 4 矩阵。

构造器

如果没有给定参数,矩阵被初始化为单位矩阵。如果给定一个参数,它应该是一个由矩阵的 16 个分量组成的序列(例如,一个列表)。如果给定了四个参数,它们应该是最多四个值的四个序列。每行中缺少的值用单位矩阵中的值来填充(因此您可以使用Vector3或三个值的元组)。

属性

一个matrix44对象的属性(见表 A-4 )代表矩阵的行。您只能整体检索或设置这些属性的内容,也就是说,您不能单独设置行的各个部分。如果你需要访问矩阵中的单个值,使用matrix44对象的索引([])操作符。

表 A-4 gameobjects.matrix44.Matrix44 属性

|

属性

|

说明

|
| --- | --- |
| x_axis | 矩阵的 x 轴 |
| right | x 轴的别名 |
| y_axis | 矩阵的 y 轴 |
| up | y 轴的别名 |
| z_axis | 矩阵的 z 轴 |
| forward | z 轴的别名 |
| translate | 矩阵的翻译部分 |

方法

矩阵对象包含大量的方法来操作矩阵的组件,并使用它来变换点(参见表 A-5 )。

表 A-5 。Matrix44 属性

|

方法名称

|

说明

|
| --- | --- |
| to_opengl() | 返回与 OpenGL 兼容的矩阵组件列表。 |
| set(row1, row2, row3, row4) | 设置矩阵的四行。 |
| get_row(row_no) | 以四个值的元组形式检索矩阵的一行;row_no是行索引(0、1、2 或 3)。 |
| fast_mul(rhs) | 将该矩阵乘以另一个矩阵。这比*=运算符稍快,但只能用于只包含旋转、缩放或*移的矩阵。 |
| copy() | 返回此矩阵的副本。 |
| components() | 返回 16 个矩阵分量的迭代器。 |
| transposed_components() | 以转置顺序返回 16 个矩阵分量的迭代器。 |
| rows() | 以四个值的元组的形式返回矩阵行的迭代器。 |
| columns() | 以四值元组的形式返回矩阵列的迭代器。 |
| get_row_vec3(row_no) | 将一行作为Vector3对象进行检索。 |
| get_column(col_no) | 以四个值的元组形式检索列。 |
| set_row(row_no, row) | 设置行的内容;row_no是要设置的行的索引,row 是要设置的最多四个值的序列。 |
| set_column(col_no, col) | 设置列的内容;col_no是列的索引,row 是一个最多包含四个要设置的值的序列。 |
| transform_vec3(v) | 用这个矩阵变换一个Vector3对象(v),并将结果作为另一个Vector3返回。 |
| transform(v) | 转换向量并将结果作为元组返回。 |
| transform_sequence(points) | 转换点序列,并将结果作为元组列表返回。 |
| rotate(v) | 用矩阵变换一个点,但忽略矩阵的*移部分。 |
| transpose() | 交换矩阵的行和列。 |
| get_transpose() | 返回矩阵的转置副本。 |
| get_inverse_rot_trans() | 返回只有旋转和*移的矩阵的逆矩阵。 |
| get_inverse() | 返回此矩阵的逆矩阵。 |
| invert() | 反转这个矩阵(原地)。 |
| move(forward, right, up) | 基于*移的标题向*移添加偏移。这些参数都是可选的,应该是三个值的序列。 |

类方法

Matrix44类包含许多创建基本变换矩阵的类方法(见表 A-6 )。

表 A-6 。Matrix44 类方法

|

类方法名

|

说明

|
| --- | --- |
| from_iter(iterable) | 从 16 个值的 iterable(可以在 for 循环中工作的任何东西)创建一个矩阵。 |
| clone(m) | 创建矩阵m的副本。 |
| identity() | 创建一个单位矩阵。 |
| scale(x, y, z) | 创建比例矩阵。如果省略 y 和 z 刻度,则返回 x 的统一刻度矩阵。 |
| translation(x, y, z) | 创建到(x,y,z)的*移矩阵。 |
| x_rotation(angle) | 围绕 x 轴创建一个弧度为angle的旋转矩阵。 |
| y_rotation(angle) | 围绕 y 轴创建一个弧度为angle的旋转矩阵。 |
| z_rotation(angle) | 围绕 z 轴创建一个弧度为angle的旋转矩阵。 |
| rotation_about_axis(axis, angle) | 创建绕轴的旋转矩阵。axis参数可以是三个值的任意序列;angle参数应以弧度为单位。 |
| xyz_rotation(x, y, z) | 创建一个矩阵,它具有围绕所有三个轴旋转的组合效果。 |

gameobjects.vector2.Vector2

类表示一个二维向量,可以用来存储 2D 游戏中的标题和位置。一个Vector2对象的组件可以通过xy属性访问,或者通过索引操作符([])访问。Vector2对象支持数学运算符。

构造器

Vector2对象的构造函数要么接受向量的 x 和 y 分量的两个值,要么接受两个值的序列。如果没有给定参数,vector 将默认为(0,0)。

属性

表格 A-7 列出了Vector2对象的属性。

表 A-7 向量 2 属性

|

属性

|

说明

|
| --- | --- |
| x | 向量的 x 分量。 |
| y | 向量的 y 分量。 |
| length | 向量的长度。也可以设置该属性来更改向量的长度。 |

方法

表 A-8 列出了Vector2对象的方法。

表 A-8 。Vector2 方法

|

方法名称

|

说明

|
| --- | --- |
| copy() | 返回这个向量的一个副本。 |
| get_length() | 返回这个向量的长度。 |
| get_magnitude() | 返回该向量的大小(与长度相同)。 |
| normalize() | 规格化这个向量,使它的长度为 1。也返回向量。 |
| get_normalized() | 返回向量的规范化副本。 |
| get_distance:to() | 返回从这个向量到点的距离。 |

类方法

Vector2类有许多方法来构造新的Vector2对象(见表 A-9 )。

表 A-9 。Vector2 类方法

|

类方法名

|

说明

|
| --- | --- |
| from_iter(iterable) | 从可迭代的值中创建一个Vector2对象。 |
| from_points(p1, p2) | 从两点创建一个Vector2对象。 |

gameobjects.vector3.Vector3

Vector3类表示一个 3D 向量,可以用来存储三维空间中的标题和位置。Vector3对象与Vector2对象非常相似,但是包含一个额外的属性z

构造器

Vector3对象的构造函数要么接受向量的 x、y 和 z 分量的三个值,要么接受三个值的序列。如果没有给定参数,vector 将默认为(0,0,0)。

属性

表格 A-10 列出了Vector3对象的属性。

表 A-10 向量 2 属性

|

属性

|

说明

|
| --- | --- |
| x | 向量的 x 分量。 |
| y | 向量的 y 分量。 |
| z | 向量的 z 分量。 |
| length | 向量的长度。也可以设置该属性来更改向量的长度。 |

方法

表 A-11 列出了Vector3对象的方法。

表 A-11 。Vector3 方法

|

方法名称

|

说明

|
| --- | --- |
| set(x, y, z) | 将向量的分量设置为浮点值。 |
| as_tuple() | 以三个值的元组形式返回向量。 |
| get_length() | 检索向量的长度。 |
| get_magnitude() | 检索向量的大小(与长度相同)。 |
| set_length() | 设置向量的长度。 |
| get_distance:to(p) | 检索从这个向量到点的距离。 |
| normalize() | 归一化向量,使其长度为 1。也返回向量。 |
| get_normalized() | 返回向量的规范化副本。 |
| dot(other) | 返回这个向量与另一个向量的点积。 |
| cross(other) | 返回这个向量与另一个向量的叉积。 |

类方法

表 A-12 列出了可用于创建新Vector3对象的类方法。

表 A-12 。Vector3 类方法

|

类方法名

|

说明

|
| --- | --- |
| from_points(p1, p2) | 在两点之间创建一个Vector3对象。 |
| from_iter(iterable) | 从三个值的 iterable 创建一个Vector3对象。 |

十四、附录 B:打包你的游戏

如果你已经用 Pygame 写了一个游戏,你可能会想和别人分享你的杰作。分发游戏最简单的方法是将你的 Python 代码和数据打包成压缩的存档文件,比如 ZIP、TAR 或 GZIP,然后上传到你的网站或者通过电子邮件发送。这种方法的问题是,Python 和您使用的任何外部模块必须在您的游戏可以玩之前安装,这使得代码发行版只适合其他 Python 程序员。要将您的游戏分发给更广泛的非技术观众,您需要以一种熟悉的方式为您选择的*台打包您的游戏。

本附录介绍了如何将您的游戏打包成允许非技术用户安装和玩的格式。

创建 Windows 包

在 Windows 上安装游戏通常需要双击一个 EXE 文件,这将启动一个安装程序。安装程序通常是一个向导的形式,有几页显示许可协议,并询问用户从哪里复制游戏文件和安装什么图标。最后一页上的“完成”按钮开始复制文件和创建图标。

在 Windows *台上为您的 Pygame 游戏创建用户友好的安装程序需要两个步骤:

  1. 将您的主 Python 文件转换成无需安装 Python 即可运行的可执行文件。
  2. 使用 installer builder 软件创建一个包含游戏文件的 EXE 文件。

创建安装程序使你的游戏可以被最广泛的观众访问,如果你的游戏是商业游戏,这是必不可少的。如果您的目标受众的技术水*足以解压缩 ZIP 文件并双击 EXE 文件,您可以跳过第二步。

使用 cx_Freeze

要把 Python 文件变成可执行文件,可以使用cx_Freeze,它本身就是一个 Python 模块。cx_Freeze不是 Python 标准库的一部分,但是可以在 cmd.exe/bash/terminal 中用下面的命令轻松安装:

pip install cx_Freeze

在为您的 Python 代码创建可执行文件之前,您需要编写setup.py,它包含关于您的项目的信息并启动cx_Freeze。让我们为第七章中的蚂蚁状态机清单创建一个setup.py ( 清单 B-1 )。

清单 B-1 。创建可执行的 Python 项目(setup.py)

import cx_Freeze

executables = [cx_Freeze.Executable("ants_game.py")]

cx_Freeze.setup(
    name="Ant Game",
    options={"build_exe": {"packages":["pygame"],
                           "include_files":["ant.png","leaf.png","spider.png",'gameobjects']}},
    executables = executables
    )

首先我们导入cx_Freeze,然后我们指定我们将要创建的可执行文件。接下来,我们用参数nameoptionsexecutables建立构建。在选项中,我们包括我们正在使用的标准包。我们构建的gameobjects库不能在包中工作,所以我们把它当作一个包含库,用include_files参数包含它。在这里,你可以放音乐、图像和任何其他你的程序应该附带的文件。然后定义可执行文件,我们已经完成了。确保你有我们的蚂蚁游戏的最终版本,名为ants_game.py,连同ant.pngleaf.pngspider.png都在与ants_game.py脚本以及我们现在正在编写的setup.py脚本相同的目录中。你还需要那里的gameobjects库。

这可能看起来很简单,但是将包冻结到可执行文件中会引起很大的麻烦!胆怯的人不应该去尝试。如需帮助打包有特定要求的东西,或了解更多关于模块如何工作的信息,请访问http://cx-freeze.readthedocs.org/en/latest/index.html

应该在 cmd.exe/bash/terminal 中使用以下命令运行setup.py脚本:

python setup.py build

如果您得到关于 python 不是可识别命令的错误,那么您可以:

C:/Python34/python setup.py build

这会搜索脚本使用的所有文件,并将其复制到名为build的文件夹中。在build里面是另一个包含一些机器信息的文件夹,然后在那里你会发现ants_game.exe文件,它启动了 Ant 状态机模拟,以及其他不需要先安装 Python 就能运行的必要文件。如果你压缩这个文件夹,你可以与任何人分享,不管他们是否安装了 Python 或者各种模块,他们可以运行游戏,但是为了专业的感觉,你还应该建立一个安装程序。

构建安装程序

对于稍微更专业的感觉,您也可以构建一个安装程序,它将为您的用户提供一个单独的安装程序。这样,他们不需要用一些文件提取器来处理解压缩,他们只需双击就可以安装你的应用。要做到这一点,除了在构建文件时,您需要做:

python setup.py bdist_msi

这将创建一个 dist 目录。dist 里面是安装程序。现在你可以把这个安装程序分发给你的朋友,然后他们就可以安装了。请记住,如果你有一个 32 位版本的 Windows,这使得游戏的 32 位安装程序。32 位或 64 位 Windows 可以安装并运行此程序。如果您使用的是 64 位版本的 windows,那么您的发行版将是 64 位的,不能在 32 位机器上运行。

为 Linux 创建软件包

为 Linux 创建包比为 Windows 创建包更容易,因为大多数发行版都默认安装了 Python,如果没有所需的 Python 版本,包管理器可以下载。要创建一个 Linux 包,使用 Python 标准库中的distutils模块,它可以生成源文件(tarballs)或 RPM 文件。包含游戏需求的描述也是一个好主意,以防 Linux 发行版不能提供它们。

有关distutils模块的更多信息,请参见https://docs.python.org/3.4/distutils/的在线文档。你也可以在 Linux 上使用cx_Freeze,通过安装它

pip install cx_Freeze

然后,您可以使用以下内容创建游戏的发行版:

cxfreeze ant_game.py

为 Mac 创建软件包

您也可以使用cx_Freeze创建 Mac 磁盘映像。你需要用安装cx_Freeze

pip install cx_Freeze

请参见 Windows 部分的清单 B-1 以创建setup.py文件,以及关于所需文件的信息。转换成磁盘映像的命令是

python setup.py bdist_dmg
posted @ 2024-08-10 15:27  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报