一点声明
原文链接:http://eli.thegreenplace.net/2008/12/13/writing-a-game-in-python-with-pygame-part-i/
作者主页:http://eli.thegreenplace.net/
本文已征得原文作者同意,由Arthur1989翻译为中文,你可以任意转载本文,请注明出处,尤其是原英文出处,谢谢

这是"使用Python语言和Pygame模块编写游戏"教程的第1部分.

概述

游戏是编程中应用性最强的领域之一.即使是编写最为简单的游戏,你也不得不考虑图形,数学,物理甚至于人工智能等知识.它是一项很有趣的练习编程的方法.

如果你是Python的一名粉丝(就算你不是),而且你又对游戏的设计很感兴趣,那么对你而言Pygame将是一个很伟大的模块,它专为电子游戏设计,你绝对不容错过.它能在大部分平台上运行,并且提供了许多简洁的工具,以管理复杂图形世界中的移动和声音.

因特网上有大量的Pygame的教程,但绝大部分是相对基础的.即便是Pygame book这本书也限于介绍的层次.为了达到精通的境界,我决定自己写一个教程,希望它能为那些想要使用Pygame的人提供进一步的台阶.

本教程强烈建议你自己动手修改代码,不妨做做课后练习.这对于理解你所学到的知识很有帮助.

前言

正如我前面提到过的,本教程并不适合毫无经验的新手.如果你刚刚开始学习Pygame,你可以暂时移步到这个页面.这份教程简单介绍了Pyagme,它也是值得参考的.

这里,我假设一了解下面的知识:

>>Python(你可以不是高级开发者,但绝不能是完全的初学者)
>>基本的数学和物理知识(容器,矩形,移动法则,概率,等等).我会介绍并不常见的技巧,但是我不会教你如何向容器添加元素等.
>>熟悉Pygame.也就是,你至少看过上面提过的一个教程.

哦,还有一件事...本教程主要集中于2D游戏.3D游戏完全是一个新的层次,比起粗糙的3D模型,我更喜欢相对简单但是完整的游戏.

让我们开始吧.

在这一部分中,我们最终会实现这么一个模型--一个creeps(译注: 野生生物,下文中此词不作翻译.后面有对该词的介绍)的完全模拟器,圆形的生物在屏幕上四处爬行,时不时地碰到墙而改变方向.

creeps_screenshot_1

尽管这还不是一个游戏,但它确实一个有用的起点,我们可以从它出发实现各色各样的想法.目前为止,我很享受于慢慢决定最终它会变成什么游戏.

代码

本教程第1部分的完整代码可以在这里下载,它包含所有需要的图片.我建议你下载它,并且运行里面的演示程序.手头有程序的代码是很有帮助的.我的测试环境是Python 2.5.2和Pygame 1.8.1,但它应该能够在其他版本上运行.

Pygame的文档

Pygame的API帮助文档写的很不错.它列出了Pyagme中的所有模块,类,常量以及函数,对于每一个你并不熟悉的类/模块,我建议你多多参考这个资源.

Creeps

好吧,首先让我们看看本教程第1部分的目标.

>>我们希望creeps可以在屏幕上四处爬行.
>>creeps的数量和形状是可以很容易的配置的.
>>creeps碰到墙之后将会真实的反弹.
>>为了让程序更加有趣,creeps的移动将是随机的.

那么,究竟creep是什么呢?

一个creep就是一张可以旋转(通过Pygame)并且移动位置的小图片.让旋转了的图片足够好看是一件充满艺术的事,这超出了我的能力,因此我限定图片的旋转角度必须是45的倍数.(意味着creep可以往东南西北和东北,西北,东南,西南这8个方向移动)

下载链接中,creep的图片是.png格式的文件[1]

pinkcreepbluecreepgraycreep

注意,所有的creep图片都有相同的方向.后面我们将会知道,这是很重要的一点.

creep是怎么移动的呢?

正如你已经毫无疑问的读过的Pygame的简单教程中提到的一样(你一定读过了吧?),物体的移动仅仅是一种视觉的幻象.在显示屏上,其实没有什么东西真的是在移动.相反的,为了让人产生物体移动的错觉,游戏仅仅是飞快地显示了一系列的图片,这些图片之间相差无几.对大众来说,每秒更新频率超过30的一组图片看起来已经足够流畅了.

为了实现屏幕的周期刷新,在游戏代码中我们使用"game loop"(循环).

游戏的循环

就像所有的GUI程序一样,每个游戏都有它自己的"主循环".在Pygame中,你可以用一个Python循环来实现它,这是很简单的.下面是我们的主循环:

# The main game loop
#
while True:
    # Limit frame speed to 50 FPS
    #
    time_passed = clock.tick(50)

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

    # Redraw the background
    screen.fill(BG_COLOR)

    # Update and redraw all creeps
    for creep in creeps:
        creep.update(time_passed)
        creep.blitme()

    pygame.display.flip()

让我们看看程序,怎么样?好吧,让我们看看程序里到底发生了什么.正如我说过的,它是你Python循环的基础--不会终止,除非用户打算退出.你可以看得出来,pygame.QUIT是唯一的事件处理函数,当用户点击程序的关闭框时,它将被激活.

这个循环多久运行一次呢?这是由对clock的调用决定的.clock是pygame.time.Clock的一个对象,它在上面代码之前已被创建.对clock的调用大概是这样子的:程序休眠直到下一个1/50秒(上界).在实际中,设定游戏的刷新率为50FPS(帧每秒)是很不错的,因为一方面,我们希望游戏看起来流畅;另一方面,我们并不希望游戏消耗太多的CPU资源.你可以尝试设置不同的刷新率来看看效果.比如说,将它设置为10PFS时,演示程序看起来怎么样?而且,你可以看看练习题1和练习题3.

顺便说一下,现在,你自己在文档中查阅tick的介绍的机会来了.

真正有趣的事情接下来就要发生了.在每一次循环中,屏幕都将被背景色重绘,并且所有的creep状态都将被更新,然后显示在屏幕上.最后,显示(译注:原文是display,这是Pygame的一个重量级的概念,为清晰起见,下文中display将被翻译为"显示(display)")的重绘是通过flip来完成的(是的,现在你应该查看它的文档了).

在主循环之前发生了什么

现在,让我们看看主循环之前发生了什么吧:

# Game parameters
SCREEN_WIDTH, SCREEN_HEIGHT = 400, 400
BG_COLOR = 150, 150, 80
CREEP_FILENAMES = [
    'bluecreep.png',
    'pinkcreep.png',
    'graycreep.png']
N_CREEPS = 20

pygame.init()
screen = pygame.display.set_mode(
            (SCREEN_WIDTH, SCREEN_HEIGHT), 0, 32)
clock = pygame.time.Clock()

# Create N_CREEPS random creeps.
creeps = []
for i in range(N_CREEPS):
    creeps.append(Creep(screen,
                        choice(CREEP_FILENAMES),
                        (   randint(0, SCREEN_WIDTH),
                            randint(0, SCREEN_HEIGHT)),
                        (   choice([-1, 1]),
                            choice([-1, 1])),
                        0.1))

好吧,并没有什么魔法.前面几行代码的意思是自明的.假设你已经懂得如何初始化Pygame,如何创建一个显示(display)对象.那么,creep的创建是怎样的呢?

creeps是一串Creep对象的链表--这正是creep图片的核心和灵魂.下面是Creep类的声明,它包含了类Creep的构造函数:

class Creep(Sprite):
    """ A creep sprite that bounces off walls and changes its
        direction from time to time.
    """
    def __init__(
            self, screen, img_filename, init_position,
            init_direction, speed):
        """ Create a new Creep.

            screen:
                The screen on which the creep lives (must be a
                pygame Surface object, such as pygame.display)

            img_filaneme:
                Image file for the creep.

            init_position:
                A vec2d or a pair specifying the initial position
                of the creep on the screen.

            init_direction:
                A vec2d or a pair specifying the initial direction
                of the creep. Must have an angle that is a
                multiple of 45 degres.

            speed:
                Creep speed, in pixels/millisecond (px/ms)
        """

构造函数的参数是组织得很好的,你可以看到,当一个Creep的对象creep被创建时,实参是如何被传递给形参的:

creeps.append(Creep(screen,
                    choice(CREEP_FILENAMES),
                    (   randint(0, SCREEN_WIDTH),
                        randint(0, SCREEN_HEIGHT)),
                    (   choice([-1, 1]),
                        choice([-1, 1])),
                    0.1))

首先,我们把屏幕的图层(译注:原文是surface,为另一Pygame的概念,下文译为"图层(surface)")传递给Creep.Creep用它来计算碰墙后如何反弹(译注:即,通过图层(surface)的边界知道墙的坐标),还有,在哪里显示自己.

接下来,Creep随机地从图片列表中选出一张作为自己的图片(choice是Python的标准random库中的一个函数),而且,它被随机地设置了它在屏幕上的初始位置(randint也是random中的函数)),初始方向(后文再作讨论).((译注,这句话中的"屏幕",根据作者的意思是,客户区).速度的单位是0.1 px/ms(像素每毫秒),或者说,每秒100像素.

向量和方向

在creeps演示程序中,这一部分也许最为简单了.在游戏编程中,对向量的融会贯通很重要,因为在涉及到屏幕上图像的运动时,向量是主要的数学工具.

我们使用向量来完成两件事.一件是描述位置和速度(位移).你肯定知道的,在XY坐标轴上的一个点的位置可以用一个2维向量表示.位置和速度这两个向量的区别在于,位移是有矢量的.换句话说,在原来的位置向量上加上一个位移向量,得到的是另一个位置的向量:

pos_velocity

这是很不错的,但还是有一点点的别扭.在数学里,正如上图所示,XY坐标系是这样子的,X轴正方向向右,Y轴正方向向上.但是,在屏幕上绘图时,事情有点不一样.在大部分的图形绘制接口中,客户区的左上角逻辑坐标为(0,0),X轴正方向向右,Y轴正方向向下.也就是说,图形绘制中,XY坐标系是这样子的:

vectors_01

上图很重要!它表示了我们在creep演示程序中将要使用到的8个基本的单位向量.这些也就是creep能够前进的方向了(都是45度的倍数).在阅读下文之前,请确定你已经理解了这一点.

还记得Creep构造函数中的方向参数吗?它用来设置creep的初始方向.实际上,构造函数允许接受一个pair(译注,一种二维的数据结构),它将被转为向量并且规范化.(比如说,传进(-1,-1)将得到期望的西北方向)

当creep决定改变方向或者撞到墙壁时,creep的方向将发生改变.

实现向量

很意外地,在Pygame发布的版本中,并没有带上一个"标准的"向量的实现.因此游戏开发者要么自己写一个,要么上网找一个向量的模块.

在下载链接的包中,含有一个vec2d.py文件,它是一个2维向量是实现,我是在这个Pygame wiki的页面上找到它的.该文件很漂亮地实现了2维向量的许多有用的功能.你不必读懂它的所有代码,但你可以看一看练习题4.

更新creep的信息

这个演示程序中最有趣的部分是Creep类中的Update函数.

def update(self, time_passed):

这个函数由主循环调用,它接受的参数是自从上次调用之后过去的时间(以毫秒计).通过这一点,我们就能计算creep的下一个位置.

让我们一步步的剖析update函数的代码:

# Maybe it's time to change the direction ?
#
self._change_direction(time_passed)

# Make the creep point in the correct direction.
# Since our direction vector is in screen coordinates
# (i.e. right bottom is 1, 1), and rotate() rotates
# counter-clockwise, the angle must be inverted to
# work correctly.
#
self.image = pygame.transform.rotate(
    self.base_image, -self.direction.angle)

(Arthur1989注:未完,待续.)

[Update 2009/11/11]

首先,当creep随机的改变方向时,内联函数_change_direction将被调用.在读懂update函数之后,回过头来你会觉_change_direction其实非常简单,所以我把它留在了练习题5.

update的下一操作是将creep的图像往正确的方向旋转一定的角度.还记得每个creep图片是怎么指向正确方向的吗?这对于不断正确更新creep方向而言来说相当重要.transform.rotate(读它的文档!)根据所得到的角度将给定的图层进行逆时针旋转.

现在解释一下,为什么我们传递给transform.rotate函数的角度是负角度呢?其实这正是因为上文中我介绍的Y轴翻转了的"XY坐标系".想想一下creep的基图像:(顺便提一下,它在Creep的构造函数中被加载.)

bluecreep

假设我们想得到的是,在我们视觉中(即,物理坐标系),creep朝东南方向的图像.如果我们将45度传递给rotate,那么我们得到的将是creep朝东北方向的图像(因为rotate函数做的是逆时针旋转).因此,为了正确的旋转creep,我们必须将旋转角度取反.

接下来,在update函数中,我们看到:

# Compute and apply the displacement to the position
# vector. The displacement is a vector, having the angle
# of self.direction (which is normalized to not affect
# the magnitude of the displacement)
#
displacement = vec2d(
    self.direction.x * self.speed * time_passed,
    self.direction.y * self.speed * time_passed)

self.pos += displacement

正如我说过的,self.direction是一个规范化的向量,它代表creep运行的方向.规划化是很重要的,因为我们不希望它影响位移的正确计算复杂度.位移的计算是简单的在x轴和y轴两个方向上的将速度乘以时间.

Update函数中其余的代码处理creep碰墙反弹的情形.为了让它更为智能,我打算首先介绍creep是如何绘制到屏幕上的.

位块传送

Blitting是游戏编程者间通用的传递图片(或模式)到可绘图层的行话.在Pygame中,这是通过blit函数实现的:

def blitme(self):
    """ Blit the creep onto the screen that was provided in
        the constructor.
    """
    # The creep image is placed at self.pos.
    # To allow for smooth movement even when the creep rotates
    # and the image size changes, its placement is always
    # centered.
    #
    draw_pos = self.image.get_rect().move(
        self.pos.x - self.image_w / 2,
        self.pos.y - self.image_h / 2)
    self.screen.blit(self.image, draw_pos)

位块传送,就像Pygame中其他很多功能一样,使用功能多面的pygame.Rect类.blit函数接收的参数为:一张图片(实际上是一个图层),一个矩形,它用来指定blit调用的图层,图片将绘制到该图层上.

当然,我们必须提供creep当前所在的坐标,在该坐标上绘制creep.但是,我们还需要对坐标做一些小小的调整,为什么呢?

这是因为,当图片在Pygame中旋转时,它的面积会增大,下面是解释:

creep_rotation

因为图片是矩形的,因此在旋转之后的图片中,Pygame必须保持原有图片的所有信息,因此,旋转后的图片面积可能会增大.这种情况只发生在旋转角度不是90度的倍数的时候,你可以做做练习题6.

所以,不管什么时候creep改变了方向,它的面积都会变大(译注:相对于上一时刻方向未变时的面积).如果不做一定的调整,在creep每次改变方向的那一刻,它的位置会发生位移,这将使得演示程序看起来不够流畅顺滑.

其实所谓的调整是很简单的:每次要绘制creep时,我们将其绘制在它的中点(重心)位置.请看代码:

draw_pos = self.image.get_rect().move(
    self.pos.x - self.image_w / 2,
    self.pos.y - self.image_h / 2)
self.screen.blit(self.image, draw_pos)

上面计算所得到的正是creep图像的重心位置.即便是creep旋转时,面积变大了,它的重心还是不会改变的.

碰墙反弹

首先,请确定上面的"重心"的技巧你已经掌握了(参考练习题7).如果你确实已经掌握了,那么creep碰墙反弹是很容易的,下面是这部分功能的代码:

# When the image is rotated, its size is changed.
# We must take the size into account for detecting
# collisions with the walls.
#
self.image_w, self.image_h = self.image.get_size()
bounds_rect = self.screen.get_rect().inflate(
                -self.image_w, -self.image_h)

if self.pos.x < bounds_rect.left:
    self.pos.x = bounds_rect.left
    self.direction.x *= -1
elif self.pos.x > bounds_rect.right:
    self.pos.x = bounds_rect.right
    self.direction.x *= -1
elif self.pos.y < bounds_rect.top:
    self.pos.y = bounds_rect.top
    self.direction.y *= -1
elif self.pos.y > bounds_rect.bottom:
    self.pos.y = bounds_rect.bottom
    self.direction.y *= -1

首先,通过将与屏幕重叠的矩形转为图层计算屏幕的边界(长和宽).(为了使用重心代表creep所在位置,这是必须的.)

然后,对于四面墙,我们计算creep是不是撞上它们了,如果是的话,在理想的情况下,creep将与墙成镜面反射弹出.让我们对其中一扇墙做分析:

left_bounce

if self.pos.x < bounds_rect.left:
    self.pos.x = bounds_rect.left
    self.direction.x *= -1

这部分代码是计算creep与左边的墙碰撞后的方向的.creep总是从右方撞上左墙,因此,只要将creep的方向向量中X取反,Y保持不变,creep就会以正确的方向反弹了.

总结

到这里,我们已经看过creeps.py中大部分最有趣的代码了.如果你有什么不懂的地方,不妨对照着本教程中的图片再一次仔细地查看完整的代码.如果还是有什么不明白的话,请告诉我,我会很乐于帮助你的.

相同的教程/幻灯片,不同的人理解的层次不同.最基础的是读懂它,高级一点的是实践它.为了真正地掌握知识,你必须勇于挑战教程中没有解释过的那一部分.因此,我再一次建议你看看下面的练习题,想想到底该怎么做.最好你能写出答案,并且在代码上实现出来.

接下来是什么?

这个creep的演示程序对于一些游戏来说,是个不错的模板.我还没有决定接下来我要写些什么,我也还没决定究竟要把教程写到什么地步.因此,我接下来的打算将取决于读者的反馈.请随意留言或者发我邮件.

练习

1.通过修改N_CREEPS常量的值来增加creep的数量.在我的桌面电脑上,在有上百哥creep时,演示程序还是运行得很流畅.
2.修改生成creep的代码块,使得所有creep中有60%是灰色的,20%是蓝色和粉色的.
3.调用主循环中的clock.tick函数.尝试打印出相邻的两次tick间经过了多长时间,然后修改tick的参数.如果你不提供任何参数,tick函数将尽可能快地运行.计算一下,当creep数目增加时,相邻的两次tick间时间是多少.
4.打开vec2d.py文件,读懂它的所有函数和属性.
5.修改_change_direction函数,来改变creep的行为.

(1)让它们更频繁地改变方向.

(2)你能让creep每隔一段时间停止不动1秒钟吗?(提示:修改速度函数)

6.你能用基本的三角几何知识计算出在Pygame中,图片旋转45度,它的面积增大多少吗?
7重写绘制函数,删去图像的重心位置作为绘制位置的代码(直接使用未调整的图像的位置).演示程序运行起来怎么样?