(转载)[PVZ一代汇编修改教程]基础控件的使用
PVZ中的各种控件在游戏与玩家的交互中起到了相当重要的作用,善用好这些控件也可以为改版增添不少的自由度和可玩性(例如现在改版常见的难度控制台等)。但是控件中涉及到的一些概念和操作对于改版新人而言可能有些难以理解,市面上也暂时缺乏对于这些内容的讲解教程。本文将尽可能详细地解释基础控件的使用方式,请读者确保在阅读前已拥有一定的对PVZ数据结构的理解和对PVZ的修改能力(最好同时有一些C++基础,没有也问题不大)。
控件的工作机制
控件的基本工作就是等待玩家的操作,并且在玩家操作产生特定事件后,根据接收到的事件进行相应的处理,这在程序来看就是调用相应的处理函数。
但是,如果按照传统的函数调用方式,即调用一个固定的地址的话,那么对于所有的控件(无论是按钮还是勾选框,或者是更复杂的对话或界面),同种的事件总是会调用到同一个函数,这样在这个函数中就需要花费大量的精力来对不同控件进行区分,这显然不是一种明智的策略。
很容易想到的一个解决方向是,为不同的控件分别规定不同的函数,让不同的控件在接收到同一事件后,可以分别调用其自身相应事件的函数。如此,我们不妨将这些函数本身作为控件的成员变量,通过设置这些变量的值就可以改变控件在相应事件触发时调用的函数。同时,这要求同一类事件的所有函数使用相同的参数和调用约定。
然而实际使用中,大量的函数指针变量不仅浪费了大量的内存空间,同时也需要在创建时进行大量重复的赋值操作。考虑到对于同一类(class)的控件,其各项事件对应的函数总是在控件创建时就已经确定,且后续基本不会再需要修改,于是,我们可以将控件各事件的函数的地址统一放置在一个数组中,并规定例如“当控件被点击时,就调用数组中的第2个函数”,这样,在创建控件时,只需要将这个数组的地址赋值给每个控件的特定一个成员变量[1]即可。如此,函数的调用过程会变为如下所示的形式(仍然以上述点击事件为例,假设此处esi为某控件的指针):
mov eax,[esi] // 取得“函数数组”的地址
mov edx,[eax+08] // 取得数组中第2个函数的地址
mov ecx,esi // 约定使用ecx传递this指针
call edx // 调用数组中的第2个函数
通过这种方式调用的函数被称为虚函数,存储了各个虚函数地址的“函数数组”即为一个虚函数表。派生类将基类中的一个虚函数替换为与其格式完全相同的新函数,并以新函数的地址覆盖虚函数表中原函数地址的过程,称为虚函数的覆写(override)。
虚函数的调用约定如下,在覆写虚函数时请务必遵守:
使用ecx寄存器传递this指针;
其他参数按声明中的顺序,从右向左依次入栈;
如果函数返回值类型为结构体,则用于接收返回值的地址紧接参数之后入栈;
由被调用者负责栈的清理,即函数使用 ret x 返回(其中 x 为传参使用栈的大小);
———————————————————————————————————————————————
“Listener”
考虑这样一种情况:同一个界面中存在10个按钮,当第i个按钮被按下时,玩家可以获得i颗钻石。如果按照上述方式分别为这10个按钮指定虚函数表以触发不同的按下事件,需要进行10次近乎重复的操作。而如果按钮的数量进一步增加,这个过程只会更加麻烦。为了实现对同时工作的同一类型的一组控件的统一管理,也为了对不同的玩家操作导致的相同结果的处理进行封装,在此引入监听器(Listener)的概念。
基础控件在接收到特定事件后,其仅根据事件处理其自身状态的改变,而将事件产生的影响交由其对应的监听器处理。例如原版的选项对话中表示3D加速开启与否的勾选框控件,当鼠标按下时,勾选框会自动地改变自身的已勾选与否的状态(即从已勾选变为未勾选,或从未勾选变为已勾选)。但是在玩家尝试勾选时,游戏需要判断玩家的电脑是否支持3D加速,并在不支持或不推荐时弹出提示弹窗。这部分过程都是需要交由该勾选框的监听器进行处理的。所以,监听器的本质就是在控件自发处理接收到的事件的过程中为外界提供的一个介入处理过程的接口。[2]
以下以按钮的监听器(即ButtonListener)为例具体分析监听器的使用方法。SexyAppFramework的源码中对于ButtonListener的定义如下:
class ButtonListener
{
public:
virtual void ButtonPress(int theId) {}
virtual void ButtonPress(int theId, int theClickCount) { ButtonPress(theId); }
virtual void ButtonDepress(int theId) {}
virtual void ButtonDownTick(int theId) {}
virtual void ButtonMouseEnter(int theId) {}
virtual void ButtonMouseLeave(int theId) {}
virtual void ButtonMouseMove(int theId, int theX, int theY) {}
};
不难看出,ButtonListener中包含了按钮的各项事件对应的虚函数,由于这些虚函数的存在,虽然其看似不存在成员变量,不占用内存空间,但实际上其中却隐藏了一个虚函数表。也就是说,在汇编来看,一个ButtonListener实例占用0x4字节的内存,其+0的地址处存储的是指向虚函数表的指针。
为了让按钮能够在指定事件触发时执行指定的操作,我们只需要自己定义一个继承自ButtonListener的类,重写其相应事件的虚函数即可。在汇编来看,其核心步骤就是定义一个自己的虚函数表,按照一定的顺序[3]列出各项事件的函数的地址,对于需要修改的事件的函数,使用自己重新的函数的地址;对于不需要修改的事件的函数,只需要使用原版默认的函数即可。例如以下是一个规定了鼠标按下和鼠标松开事件的虚函数表在CT脚本中的写法(其中MyButtonPress和MyButtonDepress为已经定义的函数标签):
MyVFTable:
dd 401000 MyButtonPress MyButtonDepress 539D90 42FB50 42FB50 483370
随后,将MyVFTable的地址赋给ButtonListener实例的+0指针,即完成了ButtonListener实例的构造。对于其他的例如CheckboxListener、SliderListener等,也是同理。
但是一般而言,基础控件不会脱离对话或界面而独立存在,所以基础控件的监听器也不会单独存在实例,而是作为其他对话或界面的基类,其虚函数表指针也包含在相应对话或界面的成员中。这是为了让监听器监听的所有控件都能以指针的形式存储在对话或界面的成员变量内,而对话或界面本身作为监听器就可以直接获取到这些控件的指针,从而方便相关操作的进行。
当要把控件创建在一个对话或界面中时,若该对话或界面中已经存在有同种控件,那么大概率该对话或界面本身就有继承自控件对应的监听器类(例如原版的选项界面中存在按钮、勾选框和滑动条三种基础控件,选项界面本身继承自Dialog、SliderListener和CheckboxListener,而Dialog又有继承自ButtonListener)且有覆写部分事件的处理函数。此时,一种更为推荐的做法是直接修改或覆写原有监听器类中的相关虚函数。同时,如果要在界面中创建一种新的基础控件,也应当尽可能修改该界面类的继承关系,使其继承目标控件对应的监听器类(请结合前文注释[1]中所述的成员变量的内存排列顺序和原版涉及多重继承的类型的构造函数自行研究)。
———————————————————————————————————————————————
控件的构造与析构
构造
从现有资料或者原版各种对话和界面的构造函数中均能很轻易地找出几种基础控件的构造函数,此处略去寻找的过程,直接给出函数的格式[4]:
【按钮】
53F200 - Sexy::ButtonWidget::ButtonWidget(
ButtonListener* theListener,
int theId,
ButtonWidget* this);
【勾选框】
53EF50 - Sexy::Checkbox::Checkbox(
CheckboxListener* theListener,
int theId,
esi = Checkbox* this);
【滑动条】
53A510 - Sexy::Slider::Slider(
SliderListener* theListener,
int theId,
esi = Slider* this);
可以发现,这些函数的格式几乎如出一辙[5]地呈现形如“Listener+Id+this”的结构。
其中,theId为控件的编号,用于在监听器的统一处理中对控件进行分辨。需要注意的是,同时存在的同种控件的编号不能重复。
this指针指向被构造的对象自身,此处需要先申请一块足够大小的内存区域传递给函数,让函数在申请的这片内存上存储初始化的数据以构造相应的控件。申请内存的具体操作,请自行参考现有资料。
创建过程的封装
对于控件中最常用的按钮和勾选框,原版封装了其内存的申请、实例的构造及部分初始化设定等操作,只需要提供相关的参数,即可自动创建相应的控件:
448620 - MakeButton(
const std::string& theText, //按钮上的文本
ButtonListener* theListener,
int theId);
448BC0 - MakeNewButton(
Image* theImageDown, //按钮按下时的贴图
ebx = Image* theImageOver, //按钮鼠标悬浮时的贴图
Image* theImageNormal, //按钮正常状态下的贴图
Font* theFont, //按钮上文本的字体
const std::string& theText, //按钮上的文本
ButtonListener* theListener,
int theId);
456860 - MakeNewCheckbox(
ecx = bool theDefault, //勾选框的默认值,即初始时是否处于被勾选状态
CheckboxListener* theListener,
int theId //勾选框的编号);
以上函数的返回值即为对应的控件。其中,448620的函数创建的按钮属于LawnStoneButton,可以将其视为重写了绘制函数的DialogButton[6];448BC0的函数创建的按钮属于NewLawnButton,也就是常说的“贴图按钮”。
析构
控件的析构函数也是虚函数,且PVZ中所有控件的析构函数均是位于虚函数表中的首位。于是,对于任何类型的控件,其析构方式均是固定的(假设此处esi为某控件的指针):
mov eax,[esi] // 取得虚函数表的地址
mov edx,[eax] // 取得析构函数的地址
push 01 // 此参数表示是否释放控件占用的内存空间(delete)
mov ecx,esi // 约定使用ecx传递this指针
call edx // 调用析构函数
完成控件的创建后,控件的事件理应已经能够自动触发,但是如果你一路跟着实践到这里,会发现不仅无法触发控件,甚至控件都无法显示出来。
———————————————————————————————————————————————
父控件与控件管理器
前面说到,控件的基本工作是根据接收到的事件调用相应的处理函数。一般来说,按钮等基础控件的工作只有在特定的“工作场所”中进行才有意义,这个“工作场所”就是其所位于的对话或界面。像这样一个控件A完全依附于另一个控件B的情况,将被依附的控件B称为A的父控件,相应地A称为B的一个子控件。子控件包括更新和绘制在内的一切行为都由父控件调用完成且默认只能在父控件的范围内进行。
游戏窗口接收到的所有对于控件的操作均会交由控件管理器(WidgetManager)处理,控件管理器则负责找出作为事件主体的一个控件,然后向这个控件发出事件。以鼠标按下按钮为例,当程序接收到鼠标按下的消息后,将消息交由控件管理器,然后控件管理器根据鼠标按下时的位置找到具体被按下的按钮[7],调用这个按钮的MouseDown事件函数,然后按钮的监听器再在这个函数的过程中介入处理ButtonPress产生的影响(例如播放按钮按下的音效等)。
为了接受控件管理器的管理,对话和界面在创建完成后需要调用相应的函数以加入到控件管理器中。其中,对话的添加已由SexyAppBase进行了进一步的封装,界面等一般控件的添加则需要直接调用WidgetManager的基类WidgetContainer提供的AddWidget函数[8]:
【对话】
546320 - Sexy::SexyAppBase::AddDialog(
Dialog* theDialog,
int theDialogId,
ecx = SexyAppBase* this);
【一般控件】
5370A0 - Sexy::WidgetContainer::AddWidget(
Widget* theWidget,
ecx = WidgetContainer* this);
同样地,作为子控件的按钮、勾选框等也需要在其父控件加入到控件管理器时,相应地也加入到其父控件中。实际上,WidgetContainer::AddWidget函数就可以看做是将theWidget设为this(即自身)的子控件。在这个过程中,父控件会调用子控件的AddedToManager函数以使控件管理器确认对该子控件的管理权,同时该子控件也会再对其下的所有子控件如此做。
就像前文所述的监听器的“介入”性质,控件的AddedToManager函数也可以视为提供给控件用于在控制权确认的过程中介入的接口。所以,我们需要在控件管理器AddWidget对话或界面时,也就是对话或界面触发AddedToManager时,将对话或界面内的按钮、勾选框等控件设为其自身的子控件。于是以在界面中创建按钮为例,我们找到按钮所在界面的AddedToManager函数,这个函数在其虚函数表中+0x50的位置(如果原界面使用的是默认的AddedToManager函数,则需要自行覆写该函数并修改虚函数表)。在这个函数中以按钮为参数调用界面的AddWidget函数,将按钮设为界面的子控件,这个函数在其虚函数表中+0xC的位置。例如(假设此处esi为按钮的指针,edi为界面的指针):
mov edx,[edi]
mov edx,[edx+0C]
push esi
mov ecx,edi
call edx
同时,当父控件从控件管理器中移除时,也需要相应地将子控件从父控件中移出,即在父控件的RemovedFromManager函数中以子控件为参数调用RemoveWidget函数。上述两个函数分别在控件虚函数表中+54和+10的位置。该过程与上述同理,不再赘述。
顺带一提,前文所说的控件的创建和删除,如果该控件有父控件(或有意为其指定父控件),则创建和删除的过程也应当在这个父控件的构造和析构函数中进行。
至此,基础控件已经能在父控件的支持下自动进行包括绘制和更新在内的所有过程,对于各项事件也能通过相应的Listener执行我们指定的处理程序。
至于额外的内容,例如如何设定控件的坐标等,均可以借助相关的虚函数方便且直接地实现,有需要者可自行研究,本文不再赘述。
———————————————————————————————————————————————
总结
一般来说,创建基础控件的过程分为以下步骤:
定义需要的事件函数,修改父控件的虚函数表;
在父控件的构造函数中创建控件,在父控件的析构函数中删除控件;
在父控件的AddedToManager函数中调用其AddWidget函数将控件设为其子控件;在父控件的RemovedFromManager函数中调用其RemoveWidget取消设为其子控件;
根据需求设定控件的坐标、贴图、文本、字体等。
———————————————————————————————————————————————
后记
本文以按钮、勾选框和滑块条这三种最常用的基础控件为例,讲解了控件的创建和使用方式,希望读者在阅读之后能够学会举一反三,因为同样的思路也可以应用在其他控件(例如列表控件(ListWidget)和输入框控件(EditWidget)等)甚至对话和界面上。
———————————————————————————————————————————————
注释
[1]特定一个成员变量:在汇编来看,一般为其+0的地址处,但当发生多重继承时,虚函数表与普通成员一样依次排列,且派生类的虚函数合并至声明的第一个基类的虚函数表中。例如,C继承了A和B,那么C类的虚函数会合并至A类的虚函数表中,C类中的数据依次按照“A和C的虚函数表-A的普通成员-B的虚函数表-B的普通成员-C的普通成员”的顺序排列。这么做是为了方便基类和派生类指针之间的相互转化,例如在上例中,若要将某指针变量从C*转化为B*,则只需要将原变量的数值加上“B的虚函数表”的偏移值即可。
[2]这种“介入的接口”性质的函数,在控件的运作体系中被广泛使用,例如后文中会说到的在控件加入父控件时被触发的AddedToManager等。
[3]一定顺序:一般来说,虚函数的排列顺序与源码中声明的顺序一致(重载函数除外)。在PVZ的绝大多数类(InternetManager除外)中,析构函数都是排在首位的。对于如何确定有重载的虚函数的顺序这个问题,最严谨且最方便的方式是查看内测版的pdb文件,对于pdb中没有的情况最好依次自行求证。派生类覆写基类的虚函数产生新的虚函数表时,其中所有虚函数的排列顺序与原表必须完全一致。
[4]格式:本文中给出的函数格式,若无特殊说明,默认为汇编调用格式。
[5]如出一辙:实际上勾选框和滑块条的构造函数应当另含有两个Image*类型的参数,但是这两个参数在原版中已被内联至函数内,故在此不再讨论。
[6]DialogButton为增加了坐标偏移和文字偏移属性的ButtonWidget,被用于Sexy范畴的Dialog中,而LawnStoneButton覆写了其绘制函数,被用于Lawn范畴的LawnDialog中。
[7]实际的寻找是一个递归的过程,从控件管理器开始的每一级控件都只会在其本身的子控件中寻找最符合的一个,直至最终找到一个不存在子控件或点击位置不在其任何子控件上的控件。其核心是Sexy::WidgetContainer::GetWidgetAtHelper函数。
[8]这两个函数都是虚函数,请遵循虚函数的调用方式。
转载自B站 HwequentBrony(已授权)