四、创建视觉效果
电脑游戏本质上是非常视觉化的,游戏开发人员花费大量时间处理图形和改进视觉效果,为玩家创造最具娱乐性的体验。这一章给你一个为电脑游戏生成视觉效果的坚实基础。
在本章中,我们将使用 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 。颜色表
一些早期的计算机仅限于这些花哨的颜色;幸运的是,现在你可以创造更多微妙的色彩!
尝试不同的值是很值得的,这样你会对计算机生成的颜色有一个直观的感觉。稍加练习,你会发现你可以观察这三种颜色值,并对颜色的样子做出有根据的猜测。让我们写一个脚本来帮助我们做到这一点。当您运行清单 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(僵尸绿)。结果颜色显示在屏幕的下半部分。
您可以通过更改脚本顶部的color1
和color2
的值来尝试混合其他颜色。尝试在完全对比的颜色和相近颜色的阴影之间混合。
使用图像
图像是大多数游戏的重要组成部分。显示器通常由存储在硬盘(或 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
。
convert
和convert_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
函数还采用一个可选的矩形来定义要清除的区域,这是一种绘制实心矩形的便捷方式。
注意如果你用其他方法在整个屏幕上绘图,你不需要调用
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))
注意
get_at
方法从硬件表面读取时会非常慢。显示器可以是一个硬件表面,尤其是在全屏运行的情况下,因此您可能应该避免获取显示器的像素。
锁定表面
每当 Pygame 绘制到一个表面上时,它首先必须被锁定。当一个界面被锁定时,Pygame 拥有对该界面的完全控制权,在解锁之前,计算机上的任何其他进程都不能使用它。当你在一个表面上绘图时,锁定和解锁会自动发生,但是如果 Pygame 不得不做许多锁定和解锁,它会变得低效。
在清单 4-7 中有一个调用set_at
100 次的循环,这导致 Pygame 锁定和解锁screen
表面 100 次。我们可以通过手动锁定来减少锁定和解锁的数量,并加快循环速度。清单 4-8 与之前的清单几乎相同,但是运行得更快,因为在绘制之前有一个对lock
的调用,并且在所有像素都被绘制之后有一个对unlock
的调用。
注意要锁定的呼叫数量应该与要解锁的呼叫数量相同。如果你忘记解锁一个表面,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 到屏幕的左上角。如果background
和screen
的尺寸一样,我们就不需要用纯色的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
参数,就像rect
和circle
一样使用。清单 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
参数,其工作方式与rect
和circle
相同。清单 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
注意如果你没有使用 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 帧的速度移动,或者在你的计算机允许的范围内尽可能平滑地移动;另一种通过每五帧更新一次来模拟慢速计算机。你应该看到,虽然第二个精灵的移动非常不平稳,但它实际上以相同的平均速度移动。因此,对于使用基于时间的运动的游戏,较低的帧速率将导致不太愉快的观看体验,但实际上不会减慢动作。
注意虽然写得好的游戏在低帧率下仍然可以玩,但如果运动太不稳定,人们就会失去玩游戏的兴趣。就我个人而言,我不想玩一个运行速度远低于每秒 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_x
和speed_y
的值,因为在这个演示中,我并不关心精灵最终会出现在哪里。然而,在真实的游戏中,我们会希望为精灵选择一个最终目的地,并相应地计算speed_x
和speed_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 )。游戏需要这些信息来激活投射武器或在两点之间画出激光束。
图 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_vector
的Vector2
对象。我们可以将向量的分量分别称为my_vector.x
和my_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 )。
图 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 )。
图 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 类。
注意如果你使用列表或元组来存储你的向量,不要试图用+ 运算符把它们加在一起。在 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 )。
图 5-4 。将一个向量乘以一个标量
注意一个向量乘以另一个向量也是可能的,但是在游戏中并不常用,你可能永远也不会需要它。
那么,士兵阿尔法可能会如何使用向量乘法——或者更准确地说,游戏程序员会如何使用它?向量乘法有助于根据时间将向量分解成更小的步长。如果我们知道α可以在 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
移动了多远,然后乘以方向向量。产生的矢量给了我们x
和y
自前一帧以来的变化,所以我们把它添加到精灵位置。
为此,我们需要修改向量。在这里,我们正在创建自己的对象,我们获得并需要修改的所有信息都是一个“Vector2”对象。这就是为什么我们必须添加像 add、sub,等等这样的方法。要修改这些数据,我们还需要几个方法:getitem 和 setitem。这些方法允许我们引用一个索引,并将该索引的值设置为其他值。我们在这里不使用它,但是如果您想要移除或删除一个索引,您将需要一个 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
模块检测所有这些按键。
注意虽然 QWERTY 是最常见的,但它并不是唯一的键盘布局;还有其他键盘如 AZERTY 和 Dvorak 。可以使用相同的键盘常量,但是按键可能位于键盘上的不同位置。如果您让玩家选择他们自己的键在游戏中使用,他们可以选择最适合他们键盘的控制键。
检测按键
在 Pygame 中有两种方法可以检测按键。一种方法是处理KEYDOWN
事件,当一个键被按下时发出,以及KEYUP
事件,当该键被释放时发出。这对于输入文本非常有用,因为即使从上一帧开始按下和键,我们也会得到键盘事件。事件还会捕捉到快速点击点火按钮的按键。但是当我们使用键盘输入进行移动时,我们只需要在绘制下一帧之前知道该键是否被按下。在这种情况下,我们可以更直接地使用pygame.key
模块。
键盘上的每个键都有一个与之关联的键常量,它是一个我们可以用来在代码中识别该键的值。每个常数以K_
开头。有字母(K_a
到K_z
)、数字(K_0
到K_9
),还有很多其他的常量比如K_f1
、K_LEFT
、K_RETURN
。完整列表见 Pygame 文档(https://www.pygame.org/docs/ref/key.html
)。因为K_a
到K_z
都有常量,你可能会期望有等价的大写版本——但是没有。之所以这样,是因为大写字母是组合键(Shift + key)的结果。如果您需要检测大写字母或其他移位的键,请在包含此类组合键结果的键事件中使用Unicode
参数。
我们可以使用pygame.key.get_pressed
函数来检测一个键是否被按下。它返回一个布尔值列表 ( True
或False
值),每个关键常量一个。要查找一个特定的键,使用它的常量作为按键列表的索引。例如,如果我们使用空格键作为发射按钮,我们可以编写如下的触发代码:
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 中从简单向量计算出的对角向量的直观描述。
图 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_LEFT
或K_RIGHT
,则将 x 分量设置为–1 或+1,如果按下K_UP
或K_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
模块中的sin
和cos
函数来完成。sin
函数计算 x 分量,cos
计算 y 分量。这两个函数都以弧度表示角度(一个圆有 2 * pi 弧度),但是因为清单 6-3 使用度数,它必须通过乘以 pi 并除以 180°将旋转转换成弧度。在我们从两个组件中组合航向矢量后,它可以像以前一样用于给我们基于时间的运动。
在清单 6-3 显示精灵之前,精灵被旋转,这样我们可以看到它面向哪个方向。这是通过来自transform
模块的一个名为rotate
的函数来完成的,该函数获取一个表面加上一个角度,并返回一个包含旋转精灵的新表面。这个函数的一个问题是返回的精灵可能与原始精灵的尺寸不同(见图 6-2 ),所以我们不能将它 blit 到与未旋转的精灵相同的坐标,否则它将被绘制在屏幕上的错误位置。解决这个问题的一个方法是在一个位置绘制 sprite,将 sprite 图像的中心放在 sprite 在屏幕上的位置的下面。这样,不管旋转表面的大小,精灵都将在屏幕上的相同位置。
图 6-2 。旋转曲面会改变其大小
实现鼠标控制
鼠标的历史几乎和键盘一样长。多年来,鼠标的设计没有太大变化——设备变得更符合人体工程学(手形),但设计保持不变。
经典鼠标下面有一个橡皮球,可以在桌子或鼠标垫上滚动。球的运动由鼠标内部与球接触的两个滚轮获得。现在,几乎所有的鼠标都是激光鼠标,精度更高。朋友们可以恶作剧移除彼此的鼠标球的日子已经一去不复返了,但现在我们可以用胶带纸盖住激光孔,也同样有趣。
随着时间的推移,越来越多的鼠标也有了各种按钮。几乎所有的电脑都有一个可以滚动的鼠标滚轮,可以作为第三个遥控器。还有很多鼠标都有各种各样的按钮。
游戏经常利用这些鼠标创新。额外的按钮总是方便快捷地进入游戏控制,鼠标滚轮可以在第一人称射击游戏中切换武器!当然,当用狙击枪干掉敌人时,激光鼠标的额外准确性总是很有用。
用鼠标旋转移动
您已经看到在屏幕上绘制鼠标光标非常简单:您只需从MOUSEMOTION
事件或直接从pygame.mouse.get_pos
函数中获得鼠标的坐标。如果您只想显示鼠标光标,这两种方法都可以,但是鼠标移动也可以用于控制绝对位置以外的其他东西,例如在 3D 游戏中旋转或上下查看。在这种情况下,我们不能直接使用鼠标位置,因为坐标将被限制在屏幕的边缘,我们不希望玩家被限制向左或向右转动的次数!在这些情况下,我们希望获得鼠标的相对运动,通常称为鼠标鼠标键,这只是意味着从上一帧开始鼠标已经移动了多远。清单 6-4 在精灵演示中增加了鼠标旋转运动。除了光标键,当鼠标向左或向右移动时,精灵也会旋转。
警告如果你打算复制、粘贴并运行这段代码,请注意,要退出,你将而不是能够使用你的鼠标。您将使用键盘上的 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 )。如果您晃动模拟操纵杆并按下方向键,您还会看到各种其他事件,我们将在下一节介绍这些事件。
图 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
事件。该事件包含三个值:joy
、hat
和value
。第一个,joy
,是事件来自的操纵杆的索引;that
是被压帽子的索引;并且value
指示它被按下的方式。hat
值实际上是 x 和 y 轴变化的元组——轴的负数表示向左或向下,正数表示向右或向上。
注意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
,它提供三条信息:joy
、axis
和value
。第一个是joy
,是joystick
对象的 ID;axis
是轴的索引;value
表示轴的当前位置,在 1(向左或向下)和+1(向右或向上)之间变化。
注意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_x
和axis_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 中的)。您可以轻松判断按钮或控件是否工作不正常。
图 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.keyboard
、pygame.mouse
和pygame.joystick
。其中,你可以支持玩家想在游戏中使用的任何设备。你不必支持所有三个,如果没有明确的赢家,你可以给玩家一个选择。
有时候控制会立刻影响玩家角色,所以它会完全服从玩家的命令。经典枪战是这样的;你向左按,船立刻向左,反之亦然。不过,为了更真实一点,控件应该通过施加推力和破坏力来间接影响玩家角色。现在你知道了如何从游戏控制器中读取信息,接下来的章节将教你如何在更真实的游戏世界中使用这些信息来操纵物体。
游戏的控制是玩家如何与游戏世界互动。因为我们无法融入游戏中,矩阵的风格,操控应该感觉尽可能自然。当考虑游戏的控制方法时,看看类似的游戏是如何玩的是个好主意。对于相似类型的游戏来说,控制没有太大的变化。这不是因为游戏设计师缺乏想象力;只是游戏玩家已经开始期望游戏以类似的方式工作。如果你确实使用了更有创造性的控制方法,准备好向玩家证明它的合理性,或者给他们提供一个更标准的选择!
在下一章,我们将探索人工智能的主题,你将学习如何在游戏中添加非玩家角色。*