(转载)[植物大战僵尸一代汇编修改教程]绘制
引言
贴图的使用是植物大战僵尸改版制作过程中至关重要的内容之一,而其中涉及的一些概念和操作对于初学者而言可能难以理解,也有不少改版作者只知晓其表面而不知其原理,导致难以运用贴图强化版本的视觉体验。本文将由浅入深地讲解植物大战僵尸游戏中的绘制系统,并着重于常用的绘制效果的实现,请读者确保在阅读前已拥有一定的对植物大战僵尸程序数据结构的理解和对植物大战僵尸游戏的修改能力,熟练掌握常用汇编的读写,并具有一定的C++语言基础。
1 贴图
各式各样的图片构建了丰富多彩的植物大战僵尸游戏画面。那么图片在游戏中是以怎样的形式存在,又是如何呈现到我们眼前的?本章中我们将围绕这两个问题共同展开探究。
1.1 像素和颜色
计算机能够处理的所有信息,都必须以二进制数字化的形式存在。因此,一张图片不能直接被计算机识别和存储,必须寻找一种能够将其等价转换为一串数字的方法。
1.1.1 像素
现代计算机中普遍使用的方法是,将图像在横向和纵向上分别进行切割,使连续的图像被分隔为若干个离散的“格子”,每个格子中只保留统一的一种颜色,然后只要将所有格子的位置和颜色以数字表示并依次存储即可。这种方式虽然会不可避免地在一定程度上导致图像数据的丢失,例如将图像放大时会因缺少足够的细节而模糊和失去真实性,但是只要切割进行得足够细致,在宏观上看起来就不会有违和感。
在这种存储方式中,每一个被切割形成的“格子”称为图像中的一个像素(pixel),同一横排中按原来次序排列的一组像素称为图像的行(row),同一竖排中按原来次序排列的一组像素称为图像的列(column)。图像一行中拥有的像素数量,称为图像的宽度(width);图像一列中拥有的像素数量,称为图像的高度(height)。例如在下图中,如果每一个小格子是图像中的一个像素,则该图像的宽度为11像素,高度为5像素。
像素的概念示意
类似于在平面直角坐标系中使用坐标来描述一个点的位置,在图像中也可以使用像素坐标来描述一个像素的位置。在计算机中,通常使用图像的左上角作为像素坐标的原点,以原点水平向右和竖直向下分别作为横轴和纵轴的正方向,原点处视为横、纵坐标均为0。例如在上图中,绝大多数的像素都是白色或橙色,而唯一一个蓝色像素位于图像中的第5列第1行,可以用坐标(5,1)表示。
不过对于存储而言,其实并不需要将每一个像素的坐标都存储下来,因为图像中每一行和每一列的像素数量分别总是相同的,所以只需要总体地在图片信息中记录图像的宽度和高度,然后直接将每一个像素顺次排列即可,这样排列存储的像素被称为图像的像素集(bits)。读取时,可以根据记录的宽度和高度数据,对像素集中的像素重新进行行列的排布。植物大战僵尸游戏中使用从左至右、从上至下的存储方式,则上图可以按像素集存储为“白白橙橙橙白橙橙橙白白橙橙橙橙白蓝白橙橙橙橙……”。
这样,我们初步了解了图像在计算机中的存储思路,认识到像素是图像显示的基本单位,是图像中不可再分的最小的元素。然后现在只剩下最后一个问题:如何将一个像素的颜色数字化?
1.1.2 RGB颜色模型
在物理课本上我们学过,红、绿、蓝三种颜色的光按照不同的比例混合,能够产生任何一种其他颜色的光,因此红、绿、蓝三种颜色被称为光的三原色。可以使用这三种颜色的灯来验证这一结论,如下图(1)所示,红绿重叠的部分呈现黄色,红蓝重叠的部分呈现品红色,蓝绿重叠的部分呈现青色,三盏灯共同重叠的中心区域则为白色;如果此时将红色灯的亮度降低一半,如下图(2),可以发现,除蓝绿重叠部分没有受到影响外,其他各重叠部分的颜色都随之发生了变化;如果将红色和蓝色的灯完全关闭,只留下绿色的灯,如下图(3)所示,原先白色的中心区域此时也完全成为了绿色;如果把唯一剩下的绿色灯也关闭,那么下图(4)中便只剩下一片漆黑了。
色光混合示意
这样,对于任意一种颜色的光,都可以将其等价为特定亮度的红、绿、蓝三色光混合的形式,而亮度就是可以数字化的信息了。像这样与一种颜色的光等效的一组特定的红、绿、蓝色光的亮度,分别称为这种颜色的红、绿、蓝色分量(component)。
然而,自然界中的颜色有无数种,而计算机能够存储的数字却是有限的,如何在精度与消耗之间进行取舍,仍然是一个值得权衡的问题。综合考虑人眼对颜色的分辨能力以及计算机中数据存储的特点,一种被普遍采用的方案是,分别使用1个字节的无符号整数表示颜色的红、绿、蓝色分量的值,以0表示对应原色的光完全不参与混合,而255表示以最大亮度参与混合。例如,巧克力色的光可以由210亮度的红色光、105亮度的绿色光和30亮度的蓝色光混合而成,故称巧克力色的红、绿、蓝色分量的值分别为210、105和30,记作RGB(210,105,30)。
这种使用红、绿、蓝三色光的亮度组合来描述任意一种颜色的方式,即称为RGB颜色模型(RGB color model)。由于1个字节能够表示256个无符号的整数,所以RGB颜色模型总共能够表示(256³=)16777216种不同的颜色。
1.1.3 不透明度
现在,我们已经找到了用数字表示颜色的方式,于是将图像数字化的想法也就得以实现。但是这种方案其实还有一个瑕疵——“透明”应该如何表示?
显然,仅凭三色灯光并不能混合出一种名为“透明”的颜色,因为光能否透过一个物体是由该物体的材料决定的,而与色光本身无关。而即使同样是能透光的物体,照在玻璃上的光会几乎全部穿过玻璃,而照在墨镜上的光只有一小部分能够透过墨镜。可见,透明也是有程度之分的。
说回像素,像素和现实中的物体一样,也可以是透明、不透明或半透明的。计算机中使用不透明度(opacity)来描述一个像素的不透明程度(由于一些历史的和技术的原因,不透明度通常使用字母A表示,即Alpha)。有了前文红、绿、蓝色数字化方式的经验,不透明度也被规定使用1个字节的无符号整数来表示,以0表示完全透明,255表示完全不透明。
需要注意的是,不透明度并不是颜色本身的属性,但是将其视作颜色的一个分量进行处理,有些时候会带来很多方便。可以在上一节使用RGB表示颜色的基础上再追加一个不透明度值,例如不透明的黑色可以记为RGBA(0,0,0,255),半透明(即不透明度为128)的红色可以记为RGBA(255,0,0,128)。
红、绿、蓝色值的3个字节,加上不透明度的1个字节,刚好可以用1个四字节的无符号整数来数字化一个像素。习惯上,通常将这个数字写成十六进制的形式,且其从高至低的4个字节分别记录像素的不透明度、红色、绿色、蓝色的值,称为颜色的十六进制形式(或argb形式)。例如,RGBA(100,80,200,255)按十六进制可以写成0xFF6450C8。
特别地,在使用自然语言或代码表达颜色时,无论是RGBA形式还是argb形式,如果省略不透明度分量,则默认不透明度为255。例如,0xA530CC等价于0xFFA530CC。但是在像素集中,仍然需要按照后者般完整存储,不能省略。
1.1.4 游戏中的颜色对象
使用整数表达颜色的方式,在直接针对像素的存储和处理中展现出便捷与高效兼得的优势。但是对于用户而言,将“整数”与“颜色”混用在有些时候可能并不安全,需要一种更加明确的、能够区别于一般数字的表示颜色的方式。对此,游戏中给出的优化方案是,将颜色封装成单独的类型(Color)来处理。一个颜色对象中使用4个四字节的整数分别存储一种颜色的红、绿、蓝色及不透明度分量的值,总计需要16字节的内存空间。
游戏中预设了纯白色和纯黑色两个颜色对象,它们分别被存储在72289C和7228AC开始的4个四字节中,有需要时可以直接取用。不过需要注意,这两个颜色对象在游戏中的很多地方都在使用,所以请勿擅自修改它们的值。
如果需要自行创建其他颜色的对象,游戏也提供了多个重载的构造函数,可以根据实际情况选择最方便的一个。例如下图中使用的函数,只需要将所需颜色的红、蓝、绿色值及不透明度值分别传入即可。
push #255
push #207
mov edx,#232
mov ecx,#204
lea eax,[aColor1]
call 5643F0
可以调用Color::ToInt函数将一个颜色对象转化为对应颜色的argb形式。例如
lea eax,[aColor1]
call 564450
函数通过eax寄存器返回0xFFCCE8CF。反之,也可以直接由argb形式的整数构造颜色对象。例如
mov ecx,CCE8CF
lea eax,[aColor2]
call 564390
还可以使用颜色的operator!=运算符判断两个颜色是否不相等。只要两个颜色的红、蓝、绿色或不透明度分量中任一不相等,则认为两个颜色不相等。例如,下面的代码可以用于对刚刚创建的两个颜色对象执行此判断。
lea eax,[aColor2]
lea ecx,[aColor1]
call 5644B0
调用结束后,观察到eax寄存器类的值为0,说明这两个颜色对象是相等的。这也再次证明,省略不透明度分量时,默认的不透明度值为255。
最后,在使用完这两个临时的颜色对象后,需要按照与构造时相反的顺序将其分别析构。但由于Color类的析构函数中不实际执行任何操作,所以在代码优化时可以略去对其的调用。
1.2 贴图对象
在上一节中,我们介绍了让计算机能够处理图片的方式。植物大战僵尸游戏文件夹中main.pak资源包内就存在大量的图片文件,这些图片都会在游戏运行期间被加载至内存中,而游戏中使用贴图(Image)作为存储图片信息的载体。
就像电脑中的图片文件支持jpg、bmp、png等多种格式,植物大战僵尸游戏中的贴图也支持内存贴图(MemoryImage)和DDraw贴图(DDImage)两类。好在游戏中对这两类贴图进行了一定程度的封装,使得我们对贴图的绝大多数操作都无需刻意区分贴图的类型,而可以使用统一的代码对任何贴图进行同等的处理。
1.2.1 贴图的相关概念
贴图中最核心的数据就是其中存储的图像,所以其图像的宽度和高度,分别称为该贴图的宽度和高度。可以通过Image::GetWidth和Image::GetHeight函数获取一张贴图的宽度和高度,也可以直接取贴图对象mWidth和mHeight的值。
我们来尝试使用这个方法查询原版贴图的宽度和高度。原版从文件中读取得到的贴图对象大多都是以指针的形式存储为全局变量的,而6A7880地址中存储的是老虎机的贴图的指针。这里测试的时候需要注意,因为游戏中贴图多是在即将可能被使用之前才进行提取的,所以测试的代码也需要等到提取完成之后才可以运行,对于老虎机的贴图也就是等待标题界面的加载进度条显示“点击开始”之后。后面的讲解中也会有一些需要像这样使用原版贴图进行测试的场景,也需要同样注意这一点。
首先是通过调用函数来获取老虎机贴图的宽高,并将宽度和高度分别存储为aWidth和aHeight全局变量。
mov eax,[6A7880]
call 588A10
mov [aWidth],eax
mov eax,[6A7880]
call 588A20
mov [aHeight],eax
创建线程调用后发现,[aWidth]和[aHeight]中的值分别为285和108,这与资源包中该图片文件的信息吻合。
原版老虎机贴图的图像信息
然后来尝试直接取贴图对象存储宽度和高度的全局变量,并同样进行存储以方便观察。
mov ecx,[6A7880]
mov eax,[ecx+24]
mov [aWidth],eax
mov eax,[ecx+28]
mov [aHeight],eax
运行后结果与调用函数时一致。不仅如此,如果进入Image::GetWidth和Image::GetHeight函数的内部查看,可以发现函数中也正是执行的与此相同的直接取成员变量的操作而已,两种取贴图宽高的方法是完全等价的。
知道了贴图的宽度和高度,便可以从贴图的像素集中重新还原出图像,进而实现一些对图像的处理。不过由于贴图系统对常用操作的封装,我们暂时不会遇到需要直接操作像素的情况;而此处提出的贴图的宽高等基础概念,在后续的讲解和实践中却仍然是经常需要的。
1.2.2 分份贴图
考虑这样一种特殊的需求:想要制作一个鲜花盛开的场景,于是准备了100种花的贴图,每朵花随机使用其中的一张。随机数倒是可以通过游戏内提供的相关函数得到,但是想要将一个数字对应到一张贴图就比较麻烦了。除了将这100张贴图全部放入一个数组,再以随机数为索引取得其中的一张贴图以外,游戏中还提供了一种特殊的方式以便捷地模拟这样的效果——分份贴图(image strip)。
分份贴图将一个完整的贴图图像切割为若干个小格(cell),每一格分别用于存储一个子图像。横向排列的一组子图像称为分份贴图的一行,纵向排列的一组子图像称为分份贴图的一列。原版中一个典型的使用分份贴图的例子是墓碑,如下所示,该贴图中有4行5列共20格图像。
原版中墓碑的贴图
要指明分份贴图中的特定一份有两种表达方式。一是通过该格所在的列数和行数来指定,例如上图中呈蓝色十字架形、中间雕有骷髅的一个墓碑图像,可以表达为分份贴图中第2列第1行的一份;二是按照从左至右、从上至下的顺序对每一格依次编号,这样上述的一份图像即是分份贴图中的第7份。
分份贴图要求其中每一份图像的尺寸必须是相同的,这样贴图中只需要通过mNumRows和mNumCols分别存储分份贴图纵向切割成的行数和横向切割成的列数,再结合贴图整体图像的总宽高,就可以计算出每一份子图像的宽度和高度。贴图也提供了Image::GetCelWidth和Image::GetCelHeight两个函数用于封装这个计算过程,我们在需要时直接调用这两个函数来获取即可。例如,下面的代码获取了上述墓碑贴图中每一份的宽度和高度。
mov ecx,[6A723C]
call 588A40
mov [aCelWidth],eax
mov ecx,[6A723C]
call 588A30
mov [aCelHeight],eax
运行代码后,发现aCelWidth的值为86,aCelHeight的值为91。
[思考]如果以一般的贴图调用Image::GetCelWidth和Image::GetCelHeight函数,会产生怎样的效果?
不过需要明确,分份贴图只是为了方便用户使用而提出的概念,对于程序而言,它和一般的贴图并没有什么实质上的区别。分份贴图可以作为一张完整的图像呈现,一般的贴图也可以强行“分份”地使用。甚至在一些特定的情境中,将一般的贴图视作只有1行1列的分份贴图,可能会带来意想不到的便利。
现在,我们已经了解了游戏中使用的贴图以及一些相关的概念和行为。从下一节开始,我们将正式开始本文的“主线任务”——绘制。
1.3 贴图上的绘制
所谓绘制,就是将用户选定或构思的图像,按照用户指定的方式呈现在一张贴图上,并覆盖该贴图原有的部分或全部内容。这种覆盖是永久性地、不可撤销的,因此,在贴图上进行任何绘制总是破坏性的,也就是一旦开始绘制就再也无法复原到绘制前的状态。
贴图提供了一些函数用于在其上绘制图形或其他贴图,只需要将合适的绘图参数以合适的方式传递给函数,就可以实现在贴图上的绘制。
需要注意的是,由于在不同类型的贴图上进行同种绘制时需要进行的实际操作可能并不相同,为了在实际编写代码中可以不用考虑贴图类型的区别,而可以使用同样的代码自适应地对任何贴图进行处理,贴图类中几乎所有提供给外部(用户)使用的绘图函数都被设置成了虚函数,在使用时请务必遵守虚函数的调用规则,否则可能产生一些无法预期的影响,甚至可能导致程序崩溃。
1.3.1 绘制模式
由于贴图最重要的数据就是其中各个像素的颜色值,所以绘制的核心过程就是贴图自身原有的像素与需要绘制的内容中的像素之间的颜色混合。在这个过程中,颜色通过不同的算法进行混合,最终得到的结果也可能会不相同,而这个“混合的算法”就称为此次绘制中使用的绘制模式(draw mode)。原版中支持正常模式(normal mode)和加法模式(additive mode)两种绘制模式。
正常模式下,新绘制的颜色将一定程度地遮盖贴图上原有的颜色,遮盖的程度即为新颜色的不透明度。如果新绘制的颜色是完全不透明的颜色,则新颜色将完全覆盖贴图上的原有颜色,否则不完全的遮盖效果将形成一种“介于两种颜色之间”的颜色。加法模式下,贴图上原有的颜色将与新绘制的颜色直接相加,得到一个比两种颜色都更亮的颜色。因此在游戏中,正常绘制模式被广泛运用于一般的绘制过程,而加法绘制模式通常被用于实现发光、高亮等效果。下图展示了分别使用两种绘制模式在RGBA(18,156,171,255)颜色上绘制RGBA(216,144,18,255)颜色产生的效果。
两种绘制模式产生的不同效果示意
可以看出,绘制中使用的绘制模式对绘制产生的效果起到了至关重要的作用。所以,贴图类中提供的几乎所有的绘制函数都需要接受绘制模式参数,因为任何绘制都需要它来决定在核心步骤中应当发生的行为。
1.3.2 在贴图上绘制图形
贴图中有对绘制进行封装的图形包括线段和矩形,其中矩形又分为实心矩形和空心矩形两种,而线段的绘制又提供了有抗锯齿和没有抗锯齿的两个函数(但是未开启3D加速时,抗锯齿的线段绘制不支持使用加法绘制模式)。
以绘制实心矩形的Image::FillRect函数为例,该函数接受的参数包括:一个矩形即需要绘制的矩形,一个颜色表示矩形内部的填充颜色,一个整数表示需要使用的绘制模式,当然,还包括需要用来在其上进行绘制的贴图本身(this)的指针。注意,这里指定的矩形(线段同理)一定不可以超出贴图本身的大小(例如当贴图宽度只有100像素时,横坐标为30、宽度为80的矩形将会超出贴图的右边界),否则可能会导致对贴图像素数据的访问和写入越界,进而污染其他重要的数据甚至导致程序崩溃。
我们来尝试调用这个函数在原版的铲子框中绘制一个实心矩形。为了方便,这里可以直接在全局地址中通过dd伪指令定义一个矩形和一个颜色,例如
aRect:
dd #15 #20 #15 #30
aColor:
dd #150 #100 #250 #255
然后编写调用绘制实心矩形的代码,并创建线程执行这段代码。其中,关卡内铲子框的贴图的指针被存储于6A71CC地址中。
Code:
push 00
push aColor
push aRect
mov ecx,[6A71CC]
mov eax,[ecx]
mov edx,[eax+08]
call edx
ret
代码执行后,进入一个可以使用铲子的关卡,可以看到,铲子框里出现了一个紫色的矩形。
在铲子框中绘制的实心矩形
拿起铲子后观察,效果更明显。
在铲子框中绘制的实心矩形(拿起铲子观察)
读者可以自行尝试使用不同的矩形、不同的颜色和不同的绘制模式,也可以尝试使用其他的函数绘制其他的图形,或者尝试在其他贴图上进行绘制,然后分别观察产生的效果。不过正如前文所述,每一次的绘制都会永久覆盖贴图上的部分或全部内容,而无论经过多少次绘制,贴图中都只会保留最后一次覆盖上去的数据;这在宏观上表现为,当在同一张贴图上多次绘制的图像内容存在重叠时,越晚绘制的内容将被显示在越高的图层。
1.3.3 在贴图上绘制贴图
在贴图上绘制其他贴图比绘制图形要复杂一些。需要在其上进行绘制的贴图,称为此次绘制中的目标贴图(destination image,简称DestImage);用来绘制在目标贴图上的贴图,称为此次绘制中的源贴图(source image,简称SrcImage)。例如,若要将铲子的贴图绘制到草坪上,则铲子贴图作为源贴图,草坪贴图作为目标贴图。
考虑到有些情况下,我们可能并不需要绘制整个源贴图,而只是想要截取其中的一部分;尤其是对于分份贴图,通常每次只需要绘制其中的一份。所以,在进行贴图的绘制时,还需要提供一个矩形表示使用源贴图的区域,这个矩形被称为此次绘制中的源矩形(source rectangle,简称SrcRect)。对于分份贴图,通常取需要使用的一份的矩形作为源矩形。如果确实需要使用整个源贴图,可以将源矩形设置为与整张源贴图的范围相同,即横纵坐标为0、宽度和高度分别等于源贴图整体的宽度和高度。
在目标贴图上绘制源贴图可以调用目标贴图的Blt函数,该函数与绘制图形时一样也需要颜色和绘制模式参数,但是这里的颜色参数和绘制图形时略有不同,它表示的是对源贴图的着色效果,即先将源贴图“视为”染上了指定颜色(但不会实际改变源贴图上的内容),再将其按指定的方式绘制到目标贴图上。如果不需要对源贴图进行额外着色,而只是将源贴图或其中的一部分原原本本地绘制出来,可以使用纯白色作为着色颜色,因为在任何颜色上着纯白色都不会使其值发生变化。
我们来尝试将图鉴贴图(其指针被存储在6A7930地址中)的一部分绘制到铲子框中。
Code:
push 00
push 72289C
push aRect
push #25
push #15
mov eax,[6A7930]
push eax
mov ecx,[6A71CC]
mov eax,[ecx]
mov edx,[eax+24]
call edx
ret
align 10 CC
aRect:
dd #5 #20 #15 #30
如下图所示,可以看到,图鉴贴图的一部分已经出现在了铲子框中。
在铲子框中绘制图鉴
需要注意的是,在一张贴图上绘制其自身通常是一种不好的行为,尤其是当源区域和目标区域产生重叠时,可能产生预期之外的效果。例如在上例中,如果将源贴图改为与目标贴图使用同一个贴图对象,预期的效果应当如下左图所示,但实际产生的效果却如下右图。
1.3.4 裁剪
在上一节绘制图鉴贴图的例子中,我们只截取了图鉴贴图的一小部分进行绘制;那么,如果改成绘制整个图鉴贴图,会产生怎么样的效果呢?为了实现这个行为,在代码中我们可以将源矩形对象由常量改为变量,并在运行时取图鉴贴图的宽高作为源矩形的宽高。运行代码后,我们发现,如下图所示,图鉴的图像以奇怪的形式出现在了铲子框中!不仅如此,这时如果尝试在游戏中进行一些操作,甚至可能只是等待一段时间,程序都可能会突然崩溃。
尝试在铲子框中绘制完整图鉴产生的实际效果
这是因为,虽然源矩形的确没有超出源贴图的范围,但在目标贴图上并没有足够大的位置能够容纳需要绘制的图像,于是在绘制过程中仍然出现了对内存数据的越界访问和写入。好在源贴图和目标贴图的尺寸都是可知的,那么越界部分的宽度和高度便是可以计算的,只需要在源矩形中将这部分裁去,便仍然可以尽可能地进行绘制。
不过贴图系统提供了一种更优雅的方式来解决这个问题。既然问题出现的原因是目标贴图上没有足够的可用空间而负责绘图的函数对此并不知情,那么只需要额外提供给函数一个参数,用一个矩形表示目标贴图上可以进行绘制的区域,让函数只能在这个矩形规定的范围内进行绘制,而舍弃超出矩形范围外的部分图像即可。这个行为在绘制中被称为裁剪(clipping),而用于指定裁剪的边界的矩形就称为此次绘制中的裁剪矩形(clip rectangle,简称ClipRect)。
贴图的BltF函数相较于Blt函数,除了接受的绘制坐标参数的类型由整数变为单浮点数以外,还额外接受一个裁剪矩形作为参数。一般情况下,只需要将裁剪矩形设置为与整张目标贴图的范围相同,即可避免绘制出界的问题。
mov ecx,[6A71CC]
mov [aClipRect],00
mov [aClipRect+04],00
mov eax,ecx
call 588A10
mov [aClipRect+08],eax
mov eax,ecx
call 588A20
mov [aClipRect+0C],eax
改为调用目标贴图的BltF函数,并将这个裁剪矩形传入,绘制出的效果如下所示。
使用裁剪矩形防止绘制越界
除了用于限制图像不会超出目标贴图本身的范围以外,裁剪矩形也可以单纯地被用于限定目标贴图上可以进行绘制的区域,从而模拟一些物理的或视觉上的效果。例如,如果需要将铲子框视作一个“相框”,那么“相片”则理应只能显示在中间的部分。如果事先将裁剪矩形设置为与该区域相同,之后无论以什么贴图作为“相片”,都不会出现越界显示到“相框”上的情况。如下三图是以留声机的贴图进行实践尝试的结果,其中左图中的绘制不使用裁剪矩形,而其他两张图中的绘制按照如前所述的方式进行裁剪,且右图为了方便观察,额外在图中用红色矩形框标注出了裁剪矩形在目标贴图上的位置和大小。
[思考]仅使用Image::Blt函数而不使用裁剪矩形,能否实现与使用裁剪矩形时相同的效果?
至此,不知读者是否产生过这样的疑问:既然只需要适当缩小源矩形并调整绘制的位置,就可以实现与裁剪相同的效果,那为什么还要专门设置裁剪矩形的概念呢?
对于目前所说的各种基础绘制,确实可以通过一定的计算并相应修改源矩形,以使之实质上完全替代裁剪矩形的功能,但裁剪矩形的存在解除了源贴图和源矩形与目标贴图之间位置与尺寸等方面的耦合,使得二者可以各自独立、互不干扰地对绘制的效果进行预设。同时,在后续的章节中将会介绍到一些更复杂的绘制,届时会遇到一些仅通过源矩形无法解决的裁剪问题,这才是裁剪矩形真正展现其重要性的情境。
1.3.5 贴图绘制中的简单变换(一)
在上一节中,我们通过引入裁剪矩形解决了在铲子框中绘制图鉴贴图时产生内存写入越界的问题。但是这样一来,实际绘制出的图鉴贴图就并不完整,这在有些情况下可能会影响观感。如果能想办法将图鉴的贴图“缩小”一些,那么铲子框中就能够容纳完整的图鉴了。
类似这样需要先对从源贴图上选取的部分进行适当的尺寸调整,然后再绘制到目标贴图上的情况,在实际游戏中也是相当常见的,于是贴图系统也提供了用于实现此功能的Image::StretchBlt函数。此函数接受需要将图像伸缩至的宽度和高度,加之需要将其绘制至目标贴图上的位置的横纵坐标,组成一个矩形作为参数。这个指示了需要实际绘制图像的位置和形状的矩形,称为此次绘制中的目标矩形(destination rectangle,简称DestRect);而这种在实际绘制之前对源贴图上源矩形内图像的状态进行改动的行为,称为贴图绘制过程中的变换(transformation)。这里说到的伸缩就是一种常用的变换。
与上一节中同样地计算出裁剪矩形,然后以裁剪矩形的宽高减去目标矩形的横纵坐标,得到目标矩形的宽高,
mov [aDestRect],#15
mov [aDestRect+04],#25
mov eax,[aClipRect+08]
sub eax,[aDestRect]
mov [aDestRect+08],eax
mov eax,[aClipRect+0C]
sub eax,[aDestRect+04]
mov [aDestRect+0C],eax
并改为调用目标贴图的StretchBlt函数。额外地,该函数接受的fastStretch参数用于指示使用何种算法进行伸缩变换。这是因为,对图像进行变换自然会产生额外的性能消耗,常规伸缩模式会优先确保变换后的视觉效果,但进行变换和绘制可能会需要更长的时间;快速伸缩则会牺牲伸缩后的图像的一些细节,以确保整个绘制过程可以迅速地完成。这个参数的设置,使得用户在“效率”与“效果”之间的取舍中保留了自行进行权衡和做出决策的机会。本例中使用常规伸缩方式,绘制出的效果如下图所示。
进行拉伸变换的绘制
源贴图、目标贴图、源矩形、目标矩形和裁剪矩形是贴图绘制调用过程中的五个重要参数,它们分别对应了“要绘制什么”“要往哪里绘制”“绘制哪一部分”“绘制成什么样”“有多大地方可以绘制”这五个对绘制效果起到决定性作用的问题。
贴图绘制中的五个重要参数及其关系示意
如图所示,只有源矩形使用的坐标以源贴图左上角为原点,目标矩形和裁剪矩形使用的坐标均以目标贴图左上角为原点。源矩形内的图像将被伸缩至与目标矩形相同的位置和形状,但绘制时只会保留位于裁剪矩形内的部分。
1.3.6 贴图绘制中的简单变换(二)
游戏中另一种常见的变换是旋转,贴图系统中提供了Image::BltRotated函数用于实现进行旋转变换的绘制。函数接受两个单浮点数表示旋转中心相对于源图像的坐标,以及一个双浮点数表示旋转的弧度(以逆时针为正方向)。
旋转变换下的绘制过程可以这样形象地理解:欲将一张照片挂在墙上,首先选定一个合适的位置并将照片放置于此,然后在照片上的某个位置扎上一根图钉用来将其固定在墙上,这时用手拨动照片,照片便会以图钉所在位置为中心旋转。只不过放在游戏中,这个“图钉”是允许扎在源贴图之外的。
旋转示意
我们来尝试使用这个函数在禅境花园的贴图([6A7AF0],需进入禅境花园后加载)上绘制旋转的卡槽贴图([6A7950])。首先仍然是按照前文相同的方式计算源矩形和裁剪矩形,读者这里也可以自行尝试使用不同的源矩形和裁剪矩形。然后编写代码调用禅境花园贴图的BltRotated函数,并创建线程执行。
Code:
mov ecx,[6A7AF0]
mov [aClipRect],00
mov [aClipRect+04],00
mov eax,ecx
call 588A10
mov [aClipRect+08],eax
mov eax,ecx
call 588A20
mov [aClipRect+0C],eax
mov edx,[6A7950]
mov [aSrcRect],00
mov [aSrcRect+04],00
mov eax,edx
call 588A10
mov [aSrcRect+08],eax
mov eax,edx
call 588A20
mov [aSrcRect+0C],eax
sub esp,10
fld [aRotCenter+04]
fstp [esp+0C]
fld [aRotCenter]
fstp [esp+08]
fld qword ptr [aRotRad]
fstp qword ptr [esp]
push 00
push 72289C
push aClipRect
push aSrcRect
sub esp,08
fld [aPosition+04]
fstp [esp+04]
fld [aPosition]
fstp [esp]
push edx
mov eax,[ecx]
mov edx,[eax+2C]
call edx
ret
align 10 CC
aRotCenter:
dd (float)50.0 (float)50.0
aRotRad:
dq (double)-0.5
aClipRect:
dd #0 #0 #0 #0
aSrcRect:
dd #0 #0 #0 #0
aPosition:
dd (float)150.0 (float)100.0
绘制的效果如下图所示。读者也可以额外尝试不同的旋转中心和旋转弧度,并观察绘制结果的差异及其规律。
进行旋转变换的绘制
2 绘图
现在,我们对植物大战僵尸游戏中的绘制有了一个初步的了解,但是到目前为止,我们距离最初提出的目标还有相当一段距离。本章中我们将对游戏中的绘制展开更深一步的探究。
2.1 图形上下文
在上一章中,我们介绍了一些在贴图上进行简单绘制的函数,但是在代码实践中,不知读者是否产生过以下这些感受:图形绘制函数不接受裁剪矩形作为参数,一旦不注意就很容易出现绘制越界的情况;贴图绘制中通常只需要使用正常绘制模式且不需要进行着色,但仍然每次调用都需要传递这两个参数;只想简单地绘制分份贴图其中的一份,却需要写很多代码来计算源矩形;使用裁剪矩形通常只是为了防止绘制越界,却不能方便地直接取到与目标贴图范围相同的矩形……
可见,仅依靠贴图系统提供的几个函数进行绘制,在很多时候其实并不足够方便。为了解决绘制中可能产生的实参数值计算复杂和同一实参重复传递等问题,游戏提供了图形上下文(Graphics)用于整合部分绘制所需的参数并封装一些常用的计算步骤。同时,这种封装本身就能够自动兼容不同类型的贴图,所以图形上下文提供的绘制函数便不再需要是虚函数了。
2.1.1 使用图形上下文进行绘制
图形上下文封装的是在目标贴图上的绘制,故也需要由目标贴图对象构造图形上下文对象,且构造后一般不宜再更改目标贴图为其他贴图对象。这次我们尝试在栈上、仍然以铲子框的贴图创建图形上下文对象。这意味着,使用其进行的任何绘制都将以铲子框的贴图为目标贴图。
sub esp,68
mov ecx,[6A71CC]
lea eax,[esp]
push eax
call 586A30
在Graphics类的构造函数中可以看到,上下文中的各项参数都被赋予了能够将图像原样绘制的初始值,且裁剪矩形也被默认设置为与目标贴图范围相同。可以直接使用这些初始值进行默认的绘制,也可以先根据实际需求正确地对图形上下文中的绘制参数进行设定,然后再调用其提供的绘图函数进行绘制操作,以实现理想的效果。
函数Graphics::Translate和Graphics::TranslateF用于设定后续所有涉及到坐标的行为(包括绘制、设置裁剪和重新设定原点位置等)中,作为基准的坐标原点在目标贴图上的位置偏移。例如,如果将原点偏移设定为(100,200),然后调用函数在(50,0)位置处进行绘制,则实际绘制将会在目标贴图上(150,200)处进行。又例如,先将原点偏移至(50,100)处,后又将原点调整至(-100,50)处,则后续行为中原点位于目标贴图上(-50,150)位置。原点偏移机制的设定,使得每一次的绘制任务不必过分纠结其进行的绝对位置,而可以分别专注于其本身细节的表达。
函数Graphics::SetClipRect和Graphics::ClipRect用于设定裁剪矩形,二者的区别在于,前者直接将裁剪矩形设定为给定的矩形,而后者是在当前裁剪矩形的基础上额外追加一个裁剪范围要求,使得实际裁剪范围变为原有裁剪矩形和给定矩形的交集。而无论使用哪个函数,实际设置的裁剪矩形都会额外和整个目标贴图的矩形取交集,这是为了确保后续进行的任何绘制都不会出现越界的问题。如果不再需要进行裁剪,可以通过函数Graphics::ClearClipRect清除额外的裁剪要求,将裁剪矩形恢复至与目标贴图的范围相同。
至于上一章中讲到的其他绘制参数,图形上下文都提供了相应的函数用于设定其值,部分还额外提供了函数用于获取参数的当前值。唯一需要注意的是,在进行贴图绘制时,如果需要对源贴图进行着色,必须额外调用Graphics::SetColorizeImages进行设置,否则即使通过Graphics::SetColor设置了颜色,也并不能够生效着色效果(同理,不再需要着色时,可以直接调用前者设置不着色,而无需额外将颜色参数设置为白色);而在图形绘制中,总是使用最后一次设置的颜色对图形进行填充,无论当前的设定是否为需要着色。
在上一章中,我们将绘制模式、颜色、矩形作为参数传递给Image::FillRect函数,在目标贴图上绘制了一个实心矩形;现在,我们尝试使用刚刚创建的图形上下文对象复刻相同的效果。但是这一次,我们需要先调用函数对颜色和绘制模式两个参数进行设置,不过考虑到绘制模式参数的初始值已经是正常模式,所以这里只需要实际进行颜色的设置即可。
mov eax,aColor
lea ecx,[esp]
call 586CC0
然后调用Graphics::FillRect函数进行绘制。这个函数提供了一个可以直接将矩形的横纵坐标及宽高传入、而无需先以这些数据构造矩形对象的重载。当然,如果已经存在一个现成的矩形对象,也可以直接使用该函数的另一个重载。
push #30
push #15
push #20
push #15
lea eax,[esp+10]
call 586D50
此外,读者也可以自行尝试使用图形上下文复刻上一章中讲到的其他绘制。调用图形上下文对象封装的函数进行绘制,相较于直接调用目标贴图提供的函数,可以极大地简化需要编写的代码,并显著提高绘制行为的安全性。
最后,在所有的绘制任务都完成之后,不要忘记将这个图形上下文对象删除,释放其占用的各项资源。由于本例中使用的图形上下文对象是直接在栈上构造的,而没有额外申请堆内存,故无需进行内存释放的操作。这种情况下,也可以优化为直接调用其析构函数,而无需严格遵循虚析构调用的流程。
lea ecx,[esp]
call 586B10
add esp,68
ret
2.1.2 图形上下文对象在函数间的传递
正如现实生活中面对一件复杂的事务时,我们往往会将其拆解成若干个相对简单的步骤,对于一个复杂的绘制任务,尤其是对于其中有重复工作的部分,我们也可以通过自行封装相关功能的函数的方式,将其分解成若干个容易完成的简单绘制。此时只需要将图形上下文对象的指针作为函数参数传递,就可以实质上使得包括目标贴图在内的绘制所需的绝大多数信息在函数间流通。
本节中我们来编写一个函数用于绘制一个简易的卡槽。首先可以确定,该函数需要接受图形上下文对象的指针及绘制位置的横纵坐标作为参数。我们在函数开头保护可能用到的非易失寄存器,且考虑图形上下文对象指针会经常使用,于是提前将其存储在esi寄存器中。
push ebx
push esi
push edi
mov esi,[esp+10]
上一节中我们说,需要在绘制前先设置图形上下文中的各项参数。但是对于自己编写的函数而言,从外部传入的图形上下文对象中很可能已经完成了参数的设定,这种情况下就可以直接使用其进行绘制。而如果想强制固定部分绘制参数的值,一般也最好要保持绘制前后从外部传入的值不改变,以免影响外部的后续绘制。常用的有以下三种方式:一是复制一个额外的图形上下文对象,在这个临时对象上进行修改,并使用其进行绘制,这种方法适用于需要交替使用多组参数进行绘制的情况;二是先备份需要修改的参数的原值,在绘制结束后再从备份中恢复,这种方法适用于需要修改的参数较少的情况;三是调用Graphics::PushState函数保存当前的状态,绘制结束后调用Graphics::PopState恢复上一次保存的状态,这种方法广泛适用于除上述两种以外的各种情况。这里我们以固定绘制模式为正常模式为例,先调用Graphics::GetDrawMode函数获取原本的绘制模式并存储在edi寄存器中。
mov eax,esi
call 586D00
mov edi,eax
然后将绘制的原点设置到参数指定的位置,方便后续绘制的进行。
mov eax,[esp+14]
mov edx,[esp+18]
push edx
push eax
mov eax,esi
call 5878B0
在参数指定的位置绘制卡槽的贴图,然后在适当的位置再绘制几个卡牌的贴图(其指针被存储在6A73E0地址中)。卡牌的贴图是分份贴图,其中包含了绿卡、紫卡等多种卡底样式。可以使用Graphics::DrawImageCel函数绘制其中的指定一份。
push #8
push #85
mov eax,#2
mov ecx,[6A73E0]
push esi
call 587E50
然后再在卡牌的中心绘制一些植物的贴图。6A71E8地址中存储的是packet_plants.png加载为的贴图对象,这张贴图也是分份贴图。可以将绘制植物的代码插入在每一张卡牌的绘制之后,这样在逻辑上更为自然一些。
mov eax,#3
push #8
push #85
mov ecx,[6A71E8]
push esi
call 587E50
最后,恢复图形上下文对象的原点及绘制模式,以及弹出函数开头处保护的各个寄存器。
mov eax,[esp+14]
neg eax
mov edx,[esp+18]
neg edx
push edx
push eax
mov eax,esi
call 5878B0
mov ecx,edi
mov eax,esi
call 586CF0
pop edi
pop esi
pop ebx
ret
这样,一个简易的卡槽就绘制完成了。可以创建线程调用这个函数,观察绘制后的结果如下。
“简易卡槽”
[思考]对于绘制中需要的参数,为什么有些被整合进了图形上下文的成员变量中,需要在绘制前先调用函数进行参数的设定;而有些仍然保留以函数参数的形式,在实际绘制时才传递给执行绘制的函数?
图形上下文将一轮绘制任务中通常不会需要改变的参数包装为成员变量,而将每一次具体绘制中都可能不相同的参数保留在调用函数时传递,这样的设计使得绘制参数的传递兼具了便捷性和灵活性。
2.1.3 缩放
在上一节中,我们编写了一个使用图形上下文绘制简易卡槽的函数。那么如果有需要将这个简易卡槽整体放大或缩小,应当如何通过代码实现呢?
在上一章中,我们讲到了使用目标矩形进行伸缩变换的绘制;以及在前文中提到,图形上下文也提供了实现该功能的Graphics::DrawImage函数重载。但是不同的贴图尺寸也各不相同,这导致每绘制一张贴图时都需要专门为其计算目标矩形;不仅如此,卡槽整体的放大或缩小意味着各个贴图之间的间距也应当相应发生变化,这也会带来不少额外的计算量。
对于类似这样的需求,图形上下文允许用户设置缩放(scale)值以按固定比例地对后续绘制的所有图像进行尺寸调整。例如,当横向缩放的比例被设定为1.5、纵向缩放的比例被设定为0.8时,后续绘制的所有贴图都会被拉伸至1.5倍宽度和0.8倍高度。
此外,就像旋转变换中需要指定旋转中心,缩放变换中也需要指定一个缩放的原点;不过不同的是,缩放原点使用的是在目标贴图上的坐标。如下图所示,源图像中的所有点分别在横向和纵向上向着靠近(缩小)或远离(放大)缩放原点的方向移动一段距离,移动的距离等于该点与缩放原点的距离乘以缩放比例。
缩放示意
函数Graphics::SetScale用于设置缩放原点及比例。
push (float)300
push (float)400
push (float)1.5
push (float)0.6
lea eax,[esp+10]
call 5878D0
设置缩放后,再次尝试调用我们在上一节编写的函数,绘制的结果如下所示。
在有缩放时绘制的简易卡槽
缩放和伸缩本质上只是同一种变换的不同表示方式,在编写代码时,也应该根据实际情况选择合适的方式,以简便且留有拓展性的方式实现所需的效果。
2.2 游戏中的绘制
至此,我们已经基本掌握了在贴图上进行绘制的多种方法,不过在之前的代码实践中,我们都是创建线程在原版的贴图上进行绘制测试的,只有当游戏中将该贴图输出到屏幕上时,我们才得以借机观察绘制的效果。此外,对于实际搭建场景而言,直接在场景的背景贴图上进行绘制虽然节省效率,但是会导致贴图原本的数据受到污染,使贴图变得不再可复用。例如,当植物被种植在草坪上时,会覆盖一部分草坪的贴图,而当植物被移除后,覆盖的部分却再也不能重新恢复如初。
原版游戏程序在设计时当然也需要考虑这个问题。因此,游戏中在进行向屏幕的绘制时,会使用一张与整个游戏窗口相同尺寸的贴图,先将所有需要显示的内容绘制至这张贴图上,然后再一并提交至屏幕显示。这张特殊的贴图被称为屏幕贴图(screen image),在屏幕贴图上进行任何绘制的结果最终都会体现至窗口上。
但是仅仅得到屏幕贴图还不足以实现向屏幕的绘制,正如前文所说,后绘制的内容总是会覆盖先绘制的内容。使用以屏幕贴图创建的图形上下文对象,并在恰当的时机插入执行绘制的代码,才是能够正常实现绘制效果的必要条件。
2.2.1 逻辑更新与画面刷新
在此之前,让我们先来简单了解一下原版游戏是如何运转的。
程序自开始运行直至窗口被关闭的期间,总是在不断循环地处理着各种各样的事务,并以固定的频率(100次/秒)对其中的所有资源和数据进行维护和更新,每次这样的处理过程称为一次逻辑更新。例如,如果一个物体在宏观上以200像素每秒的速度匀速移动,那么在每次逻辑更新中,该物体将被移动2像素。
游戏中的所有内容本身都只是一些内存中的数据,而根据这些数据,以合适的顺序分别在合适的位置绘制合适的图像,才形成了我们能够实际体验到的丰富多彩的画面。随着逻辑更新的进行,各个图像及其位置可能也需要相应地进行调整,此时窗口上的内容便需要重新绘制。于是,程序在进行逻辑更新的间隙不定期地插入对整个窗口上的图像的重绘处理,每次这样的处理过程称为一次画面刷新。程序通过不断循环调用逻辑更新和画面刷新的相关函数,使得游戏能够以我们看到的这样稳定运转。
对于实际制作功能而言,我们也应当遵守这种逻辑更新和画面刷新分别独立的规则,即:在原版执行逻辑更新的相关函数内的合适位置插入我们自己的逻辑代码,而在原版执行绘制的相关函数内的合适位置插入我们自己的绘制代码。此外,虽然每次窗口重绘时屏幕贴图不会专门清空上一次绘制留下的内容,但是由于画面刷新并不能保证有固定的频率,所以每一次进行的绘制都应当只关注本次需要绘制的内容,而始终与之前已经进行的任何绘制保持独立。
2.2.2 控件的绘制
控件系统作为程序内容的核心,窗口的重绘过程中最重要的也就是各个控件的重绘,而后者是由程序的控件管理器发起,并分由其中的各个控件依次完成的。这时,各控件的Draw函数会接受到以屏幕贴图创建的图形上下文对象,函数中使用该对象进行的绘制便是以屏幕为目标的。
一些内容比较复杂的界面接受到绘制的任务时,也可能会分情形或分步骤地将整个界面的绘制拆分为若干个相对简单的函数,对于这类控件,我们在构思修改或插入代码时也应当顺应其思路。例如,图鉴对话(AlmanacDialog)的绘制主体分为DrawPlants、DrawZombies和DrawIndex三个函数,每次只根据当前所处的界面调用其中一个,如果我们要在植物页添加新的绘制,那么将代码插入在AlmanacDialog::DrawPlants便比直接写在AlmanacDialog::Draw要更加适宜。
我们来尝试在小游戏界面中绘制之前编写的建议卡槽。这里我们简单地将其绘制于界面内所有其他内容之上,所以可以直接来到该函数的结尾处(但需在进行清理工作之前)。分析代码可知,函数开头处(42F183)的“mov esi,[ebp+08]”指令将实参的图形上下文对象指针存入esi寄存器中,且后续并没有其他指令修改该寄存器中的值。观察到函数结尾处没有任何非易失寄存器内的值需要保护,所以,我们可以注入例如以下代码。
push #100
push #200
push esi
call DrawSeedBank
add esp,0C
脚本激活后,小游戏界面中出现了熟悉的卡槽图案。
在小游戏界面中向屏幕绘制简易卡槽
将脚本取消激活,随即观察到卡槽消失,说明我们编写的绘制确实是以屏幕为目标,而非像之前那样绘制在界面背景或其他贴图上的。
[思考]在控件的Draw函数中进行的绘制,一定是向屏幕进行的绘制吗?
需要再次强调,使用图形上下文对象进行绘制时,其目标贴图一定总是图形上下文对象中包装的目标贴图。我们可以将以任何贴图创建的图形上下文对象提供给控件进行绘制,只是窗口重绘的过程中,控件管理器会调用各个控件的Draw函数,并将以屏幕贴图创建的图形上下文对象逐级向下传递,在这个过程中,我们编写的各项绘制才得以在屏幕上进行。
2.2.3 关卡界面内的绘制
最后,我们将重点聚焦在关卡界面内的绘制上,毕竟这一部分是游戏性的核心内容之一。
关卡界面的绘制是按步骤划分函数的典型。考虑到关卡界面可能与图鉴(从菜单进入)和商店(从选卡界面进入)的对话同时存在,且这两个对话总是完全覆盖在关卡界面之上,所以当这两个对话其中任一存在时,关卡界面都不需要实际进行绘制。Board::Draw函数在开头处就执行了这个判断,除此之外,该函数便只负责记录一些与绘制有关的性能数据,而所有的实际绘制都被安排在Board::DrawGameObjects函数中进行。
为了在关卡中营造出尽可能真实的三维视觉效果,关卡中对所有对象(植物、僵尸、卡槽等)都预设了其应当处于的图层,然后按照预设图层由下至上的顺序进行绘制。根据这个思路,关卡内游戏对象的绘制过程(即Board::DrawGameObjects函数)可以划分为三个核心步骤。
第一步,整理所有需要绘制的内容,在一个清单中列出每个绘制项目的类型、数据和图层。对于植物、僵尸等独立对象,记录的数据即为对应对象的指针;对于冰道,每行冰道的长度已经记录在关卡界面的成员中,此处只需要记录其所处的行,以和图层进行对应;而对于金钱栏、天气等唯一且固定的用户界面,只需要类型和图层对应就可以确定其绘制,不需要记录任何其他的数据。例如,函数开头处的第一个遍历植物的循环中,对于每一株存活的植物,首先将植物本身的绘制加入清单中,然后若为禅境花园,则额外添加盆栽的需求等的绘制项目;若植物为磁力菇或吸金磁,则额外添加其吸引的物品的绘制项目。每一个绘制项目占用12字节的内存,清单中最多可以容纳2048个绘制项目。
第二步,将绘制清单中的所有项目按照图层由下至上的顺序进行排序。
第三步,依次取出排序后的清单中的每一项绘制项目,根据其类型分别调用对应的函数执行绘制。在这一步骤中,最开始庞大的绘制任务将正式开始被拆解为若干相对简单的任务,并被分别派发至专门负责的函数中,而这些函数中可能会对接收到的任务进行进一步的拆解和派发,以实现更加精细的分工。尤其是对于植物、僵尸等游戏对象,从此处开始便进入到各自的绘制函数中,关卡界面在调用其绘制之前将图形上下文的原点偏移至其所在的位置,然后在其绘制结束返回后恢复图形上下文的原点位置。待清单中的项目全部绘制完成之后,整个关卡界面的绘制也就大功告成。
了解了关卡界面内的绘制过程,一方面可以更加深入理解关卡界面的设计逻辑,另一方面也可以作为在关卡内增加绘制项目的参考,如果能总是顺应着原版的思路在合适的位置插入我们自定义的绘制,便能够有效促使在实践中写出更加清晰且易于维护的代码。
2.3 绘图的进一步封装
尽管图形上下文已经封装了不少常用的绘制功能,但在实践中还是存在以下两个麻烦的问题:一是用于绘制分份贴图的Graphics::DrawImageCel函数不支持浮点数形式的绘制坐标,二是只对单次绘制进行缩放的实现代码有些过于复杂(需要自己计算目标矩形,或者需要先记录原始的缩放原点及比例,然后对其值进行设置,绘制结束后再恢复其原值)。
为了解决这两个问题,游戏中额外提供了TodDrawImage系列的6个函数,其函数名及地址如下表所示。
其中,函数名中带有“Cel”的为兼容绘制分份贴图使用,反之则仅限用于绘制一般贴图;函数名以“F”结尾的函数,其绘制坐标接受单浮点数类型的参数,否则接受整数作为绘制坐标。函数名中带有“Scaled”的表示绘制时进行缩放变换,若为“CenterScaled”则表示以源贴图中心所在位置作为缩放原点(此时参数传入的绘制坐标也用于指定源贴图中心落在目标贴图上的位置),否则以源贴图左上角所在位置作为缩放原点。当使用这些进行缩放的函数时,图形上下文中设置的缩放值便不再使用。
例如,我们可以在Board::DrawGameObjects函数结尾处注入如下代码(分析代码可知,此处栈内[ebp+C]中存储了参数的图形上下文对象指针),通过调用TodDrawImageCelCenterScaledF函数,在关卡界面的正中央绘制一个放大的卡牌。游戏窗口的宽度为800像素,高度为600像素,所以参数传入的横、纵坐标应分别为400和300。
push (float)2.5
push (float)4.0
push 01
push (float)300.0
push (float)400.0
mov ecx,[6A73E0]
mov eax,[ebp+0C]
call 5127C0
add esp,14
开启脚本后,效果如下图所示。
在关卡界面的正中央绘制大卡牌
使用TodDrawImage系列的函数,在只对单次绘制进行缩放时可以有效简化需要编写的代码;而当需要将场景整体进行缩放时,仍然是使用Graphics的缩放机制要更为方便。在编写代码时也需要注意这方面的差别,根据实际情况选择合适的实现方式。
以上所述的内容,虽然只是绘图系统中非常浅显的一层,但已经足够应付实际制做改版中遇到的大多数需求。如果你对上面的内容还有疑惑之处,可以试着反复而仔细地咀嚼这些部分,并辅以一段时间的实战演练,或是来改版交流群里(群号:745044080)和遇到过相似问题的其他作者们互相交流讨论,确保对上述基础知识做到完全掌握。
结语
本文讲述了植物大战僵尸一代中贴图绘制的相关操作及其原理,而在游戏中还有另一种经常遇到的绘制需求——文本绘制,其涉及到的是字体(Font)对象的相关实现,但是使用字体进行的文本绘制也在绘图上下文中有一定的封装。如有需要,读者可以仿照着本文的讲解思路,参考PopCap开源的SexyAppFramework框架,自行对其中的字体系统进行研究。希望通过本文,也教会读者一些基本的技术研究方法。
转载自B站 HwequentBrony(已授权)