[PVZ一代匯編修改教程]基礎(chǔ)控件的使用
? ? ? ? PVZ中的各種控件在游戲與玩家的交互中起到了相當重要的作用,善用好這些控件也可以為改版增添不少的自由度和可玩性(例如現(xiàn)在改版常見的難度控制臺等)。但是控件中涉及到的一些概念和操作對于改版新人而言可能有些難以理解,市面上也暫時缺乏對于這些內(nèi)容的講解教程。本文將盡可能詳細地解釋基礎(chǔ)控件的使用方式,請讀者確保在閱讀前已擁有一定的對PVZ數(shù)據(jù)結(jié)構(gòu)的理解和對PVZ的修改能力(最好同時有一些C++基礎(chǔ),沒有也問題不大)。

控件的工作機制
? ? ? ? 控件的基本工作就是等待玩家的操作,并且在玩家操作產(chǎn)生特定事件后,根據(jù)接收到的事件進行相應(yīng)的處理,這在程序來看就是調(diào)用相應(yīng)的處理函數(shù)。
? ? ? ? 但是,如果按照傳統(tǒng)的函數(shù)調(diào)用方式,即調(diào)用一個固定的地址的話,那么對于所有的控件(無論是按鈕還是勾選框,或者是更復(fù)雜的對話或界面),同種的事件總是會調(diào)用到同一個函數(shù),這樣在這個函數(shù)中就需要花費大量的精力來對不同控件進行區(qū)分,這顯然不是一種明智的策略。
? ? ? ? 很容易想到的一個解決方向是,為不同的控件分別規(guī)定不同的函數(shù),讓不同的控件在接收到同一事件后,可以分別調(diào)用其自身相應(yīng)事件的函數(shù)。如此,我們不妨將這些函數(shù)本身作為控件的成員變量,通過設(shè)置這些變量的值就可以改變控件在相應(yīng)事件觸發(fā)時調(diào)用的函數(shù)。同時,這要求同一類事件的所有函數(shù)使用相同的參數(shù)和調(diào)用約定。
? ? ? ? 然而實際使用中,大量的函數(shù)指針變量不僅浪費了大量的內(nèi)存空間,同時也需要在創(chuàng)建時進行大量重復(fù)的賦值操作??紤]到對于同一類(class)的控件,其各項事件對應(yīng)的函數(shù)總是在控件創(chuàng)建時就已經(jīng)確定,且后續(xù)基本不會再需要修改,于是,我們可以將控件各事件的函數(shù)的地址統(tǒng)一放置在一個數(shù)組中,并規(guī)定例如“當控件被點擊時,就調(diào)用數(shù)組中的第2個函數(shù)”,這樣,在創(chuàng)建控件時,只需要將這個數(shù)組的地址賦值給每個控件的特定一個成員變量[1]即可。如此,函數(shù)的調(diào)用過程會變?yōu)槿缦滤镜男问剑ㄈ匀灰陨鲜鳇c擊事件為例,假設(shè)此處esi為某控件的指針):
mov eax,[esi]????????? ? // 取得“函數(shù)數(shù)組”的地址
mov edx,[eax+08]???? //?取得數(shù)組中第2個函數(shù)的地址
mov ecx,esi????????????? // 約定使用ecx傳遞this指針
call edx???????????????????? // 調(diào)用數(shù)組中的第2個函數(shù)
? ? ? ? 通過這種方式調(diào)用的函數(shù)被稱為虛函數(shù),存儲了各個虛函數(shù)地址的“函數(shù)數(shù)組”即為一個虛函數(shù)表。派生類將基類中的一個虛函數(shù)替換為與其格式完全相同的新函數(shù),并以新函數(shù)的地址覆蓋虛函數(shù)表中原函數(shù)地址的過程,稱為虛函數(shù)的覆寫(override)。
? ? ? ? 虛函數(shù)的調(diào)用約定如下,在覆寫虛函數(shù)時請務(wù)必遵守:
使用ecx寄存器傳遞this指針;
其他參數(shù)按聲明中的順序,從右向左依次入棧;
如果函數(shù)返回值類型為結(jié)構(gòu)體,則用于接收返回值的地址緊接參數(shù)之后入棧;
由被調(diào)用者負責棧的清理,即函數(shù)使用 ret x 返回(其中 x 為傳參使用棧的大小);

“Listener”
? ? ? ? 考慮這樣一種情況:同一個界面中存在10個按鈕,當?shù)趇個按鈕被按下時,玩家可以獲得i顆鉆石。如果按照上述方式分別為這10個按鈕指定虛函數(shù)表以觸發(fā)不同的按下事件,需要進行10次近乎重復(fù)的操作。而如果按鈕的數(shù)量進一步增加,這個過程只會更加麻煩。為了實現(xiàn)對同時工作的同一類型的一組控件的統(tǒng)一管理,也為了對不同的玩家操作導(dǎo)致的相同結(jié)果的處理進行封裝,在此引入監(jiān)聽器(Listener)的概念。
? ? ? ? 基礎(chǔ)控件在接收到特定事件后,其僅根據(jù)事件處理其自身狀態(tài)的改變,而將事件產(chǎn)生的影響交由其對應(yīng)的監(jiān)聽器處理。例如原版的選項對話中表示3D加速開啟與否的勾選框控件,當鼠標按下時,勾選框會自動地改變自身的已勾選與否的狀態(tài)(即從已勾選變?yōu)槲垂催x,或從未勾選變?yōu)橐压催x)。但是在玩家嘗試勾選時,游戲需要判斷玩家的電腦是否支持3D加速,并在不支持或不推薦時彈出提示彈窗。這部分過程都是需要交由該勾選框的監(jiān)聽器進行處理的。所以,監(jiān)聽器的本質(zhì)就是在控件自發(fā)處理接收到的事件的過程中為外界提供的一個介入處理過程的接口。[2]
? ? ? ? 以下以按鈕的監(jiān)聽器(即ButtonListener)為例具體分析監(jiān)聽器的使用方法。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中包含了按鈕的各項事件對應(yīng)的虛函數(shù),由于這些虛函數(shù)的存在,雖然其看似不存在成員變量,不占用內(nèi)存空間,但實際上其中卻隱藏了一個虛函數(shù)表。也就是說,在匯編來看,一個ButtonListener實例占用0x4字節(jié)的內(nèi)存,其+0的地址處存儲的是指向虛函數(shù)表的指針。
? ? ? ? 為了讓按鈕能夠在指定事件觸發(fā)時執(zhí)行指定的操作,我們只需要自己定義一個繼承自ButtonListener的類,重寫其相應(yīng)事件的虛函數(shù)即可。在匯編來看,其核心步驟就是定義一個自己的虛函數(shù)表,按照一定的順序[3]列出各項事件的函數(shù)的地址,對于需要修改的事件的函數(shù),使用自己重新的函數(shù)的地址;對于不需要修改的事件的函數(shù),只需要使用原版默認的函數(shù)即可。例如以下是一個規(guī)定了鼠標按下和鼠標松開事件的虛函數(shù)表在CT腳本中的寫法(其中MyButtonPress和MyButtonDepress為已經(jīng)定義的函數(shù)標簽):
MyVFTable:
dd 401000 MyButtonPress MyButtonDepress 539D90 42FB50 42FB50 483370
? ? ? ? 隨后,將MyVFTable的地址賦給ButtonListener實例的+0指針,即完成了ButtonListener實例的構(gòu)造。對于其他的例如CheckboxListener、SliderListener等,也是同理。
? ? ? ? 但是一般而言,基礎(chǔ)控件不會脫離對話或界面而獨立存在,所以基礎(chǔ)控件的監(jiān)聽器也不會單獨存在實例,而是作為其他對話或界面的基類,其虛函數(shù)表指針也包含在相應(yīng)對話或界面的成員中。這是為了讓監(jiān)聽器監(jiān)聽的所有控件都能以指針的形式存儲在對話或界面的成員變量內(nèi),而對話或界面本身作為監(jiān)聽器就可以直接獲取到這些控件的指針,從而方便相關(guān)操作的進行。
? ? ? ? 當要把控件創(chuàng)建在一個對話或界面中時,若該對話或界面中已經(jīng)存在有同種控件,那么大概率該對話或界面本身就有繼承自控件對應(yīng)的監(jiān)聽器類(例如原版的選項界面中存在按鈕、勾選框和滑動條三種基礎(chǔ)控件,選項界面本身繼承自Dialog、SliderListener和CheckboxListener,而Dialog又有繼承自ButtonListener)且有覆寫部分事件的處理函數(shù)。此時,一種更為推薦的做法是直接修改或覆寫原有監(jiān)聽器類中的相關(guān)虛函數(shù)。同時,如果要在界面中創(chuàng)建一種新的基礎(chǔ)控件,也應(yīng)當盡可能修改該界面類的繼承關(guān)系,使其繼承目標控件對應(yīng)的監(jiān)聽器類(請結(jié)合前文注釋[1]中所述的成員變量的內(nèi)存排列順序和原版涉及多重繼承的類型的構(gòu)造函數(shù)自行研究)。

控件的構(gòu)造與析構(gòu)
構(gòu)造
? ? ? ? 從現(xiàn)有資料或者原版各種對話和界面的構(gòu)造函數(shù)中均能很輕易地找出幾種基礎(chǔ)控件的構(gòu)造函數(shù),此處略去尋找的過程,直接給出函數(shù)的格式[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);
? ? ? ? 可以發(fā)現(xiàn),這些函數(shù)的格式幾乎如出一轍[5]地呈現(xiàn)形如“Listener+Id+this”的結(jié)構(gòu)。
? ? ? ? 其中,theId為控件的編號,用于在監(jiān)聽器的統(tǒng)一處理中對控件進行分辨。需要注意的是,同時存在的同種控件的編號不能重復(fù)。
? ? ? ? this指針指向被構(gòu)造的對象自身,此處需要先申請一塊足夠大小的內(nèi)存區(qū)域傳遞給函數(shù),讓函數(shù)在申請的這片內(nèi)存上存儲初始化的數(shù)據(jù)以構(gòu)造相應(yīng)的控件。申請內(nèi)存的具體操作,請自行參考現(xiàn)有資料。
創(chuàng)建過程的封裝
? ? ? ??對于控件中最常用的按鈕和勾選框,原版封裝了其內(nèi)存的申請、實例的構(gòu)造及部分初始化設(shè)定等操作,只需要提供相關(guān)的參數(shù),即可自動創(chuàng)建相應(yīng)的控件:
448620 - MakeButton(
const std::string& theText, //按鈕上的文本
ButtonListener* theListener,
int theId);448BC0 - MakeNewButton(
Image* theImageDown, //按鈕按下時的貼圖
ebx = Image* theImageOver, //按鈕鼠標懸浮時的貼圖
Image* theImageNormal, //按鈕正常狀態(tài)下的貼圖
Font* theFont, //按鈕上文本的字體
const std::string& theText, //按鈕上的文本
ButtonListener* theListener,
int theId);456860 - MakeNewCheckbox(
ecx = bool theDefault, //勾選框的默認值,即初始時是否處于被勾選狀態(tài)
CheckboxListener* theListener,
int theId //勾選框的編號);
? ? ? ??以上函數(shù)的返回值即為對應(yīng)的控件。其中,448620的函數(shù)創(chuàng)建的按鈕屬于LawnStoneButton,可以將其視為重寫了繪制函數(shù)的DialogButton[6];448BC0的函數(shù)創(chuàng)建的按鈕屬于NewLawnButton,也就是常說的“貼圖按鈕”。
析構(gòu)
? ? ? ? 控件的析構(gòu)函數(shù)也是虛函數(shù),且PVZ中所有控件的析構(gòu)函數(shù)均是位于虛函數(shù)表中的首位。于是,對于任何類型的控件,其析構(gòu)方式均是固定的(假設(shè)此處esi為某控件的指針):
mov eax,[esi]????????? ??//?取得虛函數(shù)表的地址
mov edx,[eax]????? ? ??//?取得析構(gòu)函數(shù)的地址
push 01????????????????? ? // 此參數(shù)表示是否釋放控件占用的內(nèi)存空間(delete)
mov ecx,esi??????????????// 約定使用ecx傳遞this指針
call edx?????????????????????// 調(diào)用析構(gòu)函數(shù)
? ? ? ??完成控件的創(chuàng)建后,控件的事件理應(yīng)已經(jīng)能夠自動觸發(fā),但是如果你一路跟著實踐到這里,會發(fā)現(xiàn)不僅無法觸發(fā)控件,甚至控件都無法顯示出來。

父控件與控件管理器
? ? ? ? 前面說到,控件的基本工作是根據(jù)接收到的事件調(diào)用相應(yīng)的處理函數(shù)。一般來說,按鈕等基礎(chǔ)控件的工作只有在特定的“工作場所”中進行才有意義,這個“工作場所”就是其所位于的對話或界面。像這樣一個控件A完全依附于另一個控件B的情況,將被依附的控件B稱為A的父控件,相應(yīng)地A稱為B的一個子控件。子控件包括更新和繪制在內(nèi)的一切行為都由父控件調(diào)用完成且默認只能在父控件的范圍內(nèi)進行。
? ? ? ? 游戲窗口接收到的所有對于控件的操作均會交由控件管理器(WidgetManager)處理,控件管理器則負責找出作為事件主體的一個控件,然后向這個控件發(fā)出事件。以鼠標按下按鈕為例,當程序接收到鼠標按下的消息后,將消息交由控件管理器,然后控件管理器根據(jù)鼠標按下時的位置找到具體被按下的按鈕[7],調(diào)用這個按鈕的MouseDown事件函數(shù),然后按鈕的監(jiān)聽器再在這個函數(shù)的過程中介入處理ButtonPress產(chǎn)生的影響(例如播放按鈕按下的音效等)。
? ? ? ? 為了接受控件管理器的管理,對話和界面在創(chuàng)建完成后需要調(diào)用相應(yīng)的函數(shù)以加入到控件管理器中。其中,對話的添加已由SexyAppBase進行了進一步的封裝,界面等一般控件的添加則需要直接調(diào)用WidgetManager的基類WidgetContainer提供的AddWidget函數(shù)[8]:
【對話】
546320 - Sexy::SexyAppBase::AddDialog(
Dialog* theDialog,?
int theDialogId,?
ecx = SexyAppBase* this);【一般控件】
5370A0 - Sexy::WidgetContainer::AddWidget(
Widget* theWidget,
ecx = WidgetContainer* this);
? ? ? ? 同樣地,作為子控件的按鈕、勾選框等也需要在其父控件加入到控件管理器時,相應(yīng)地也加入到其父控件中。實際上,WidgetContainer::AddWidget函數(shù)就可以看做是將theWidget設(shè)為this(即自身)的子控件。在這個過程中,父控件會調(diào)用子控件的AddedToManager函數(shù)以使控件管理器確認對該子控件的管理權(quán),同時該子控件也會再對其下的所有子控件如此做。
? ?? ? ?就像前文所述的監(jiān)聽器的“介入”性質(zhì),控件的AddedToManager函數(shù)也可以視為提供給控件用于在控制權(quán)確認的過程中介入的接口。所以,我們需要在控件管理器AddWidget對話或界面時,也就是對話或界面觸發(fā)AddedToManager時,將對話或界面內(nèi)的按鈕、勾選框等控件設(shè)為其自身的子控件。于是以在界面中創(chuàng)建按鈕為例,我們找到按鈕所在界面的AddedToManager函數(shù),這個函數(shù)在其虛函數(shù)表中+0x50的位置(如果原界面使用的是默認的AddedToManager函數(shù),則需要自行覆寫該函數(shù)并修改虛函數(shù)表)。在這個函數(shù)中以按鈕為參數(shù)調(diào)用界面的AddWidget函數(shù),將按鈕設(shè)為界面的子控件,這個函數(shù)在其虛函數(shù)表中+0xC的位置。例如(假設(shè)此處esi為按鈕的指針,edi為界面的指針):
mov edx,[edi]
mov edx,[edx+0C]
push esi
mov ecx,edi
call edx
? ? ? ? 同時,當父控件從控件管理器中移除時,也需要相應(yīng)地將子控件從父控件中移出,即在父控件的RemovedFromManager函數(shù)中以子控件為參數(shù)調(diào)用RemoveWidget函數(shù)。上述兩個函數(shù)分別在控件虛函數(shù)表中+54和+10的位置。該過程與上述同理,不再贅述。
? ? ? ? 順帶一提,前文所說的控件的創(chuàng)建和刪除,如果該控件有父控件(或有意為其指定父控件),則創(chuàng)建和刪除的過程也應(yīng)當在這個父控件的構(gòu)造和析構(gòu)函數(shù)中進行。
? ? ? ? 至此,基礎(chǔ)控件已經(jīng)能在父控件的支持下自動進行包括繪制和更新在內(nèi)的所有過程,對于各項事件也能通過相應(yīng)的Listener執(zhí)行我們指定的處理程序。
? ? ? ? 至于額外的內(nèi)容,例如如何設(shè)定控件的坐標等,均可以借助相關(guān)的虛函數(shù)方便且直接地實現(xiàn),有需要者可自行研究,本文不再贅述。

總結(jié)
一般來說,創(chuàng)建基礎(chǔ)控件的過程分為以下步驟:
定義需要的事件函數(shù),修改父控件的虛函數(shù)表;
在父控件的構(gòu)造函數(shù)中創(chuàng)建控件,在父控件的析構(gòu)函數(shù)中刪除控件;
在父控件的AddedToManager函數(shù)中調(diào)用其AddWidget函數(shù)將控件設(shè)為其子控件;在父控件的RemovedFromManager函數(shù)中調(diào)用其RemoveWidget取消設(shè)為其子控件;
根據(jù)需求設(shè)定控件的坐標、貼圖、文本、字體等。

后記
? ? ? ? 本文以按鈕、勾選框和滑塊條這三種最常用的基礎(chǔ)控件為例,講解了控件的創(chuàng)建和使用方式,希望讀者在閱讀之后能夠?qū)W會舉一反三,因為同樣的思路也可以應(yīng)用在其他控件(例如列表控件(ListWidget)和輸入框控件(EditWidget)等)甚至對話和界面上。

注釋
[1]特定一個成員變量:在匯編來看,一般為其+0的地址處,但當發(fā)生多重繼承時,虛函數(shù)表與普通成員一樣依次排列,且派生類的虛函數(shù)合并至聲明的第一個基類的虛函數(shù)表中。例如,C繼承了A和B,那么C類的虛函數(shù)會合并至A類的虛函數(shù)表中,C類中的數(shù)據(jù)依次按照“A和C的虛函數(shù)表-A的普通成員-B的虛函數(shù)表-B的普通成員-C的普通成員”的順序排列。這么做是為了方便基類和派生類指針之間的相互轉(zhuǎn)化,例如在上例中,若要將某指針變量從C*轉(zhuǎn)化為B*,則只需要將原變量的數(shù)值加上“B的虛函數(shù)表”的偏移值即可。
[2]這種“介入的接口”性質(zhì)的函數(shù),在控件的運作體系中被廣泛使用,例如后文中會說到的在控件加入父控件時被觸發(fā)的AddedToManager等。
[3]一定順序:一般來說,虛函數(shù)的排列順序與源碼中聲明的順序一致(重載函數(shù)除外)。在PVZ的絕大多數(shù)類(InternetManager除外)中,析構(gòu)函數(shù)都是排在首位的。對于如何確定有重載的虛函數(shù)的順序這個問題,最嚴謹且最方便的方式是查看內(nèi)測版的pdb文件,對于pdb中沒有的情況最好依次自行求證。派生類覆寫基類的虛函數(shù)產(chǎn)生新的虛函數(shù)表時,其中所有虛函數(shù)的排列順序與原表必須完全一致。
[4]格式:本文中給出的函數(shù)格式,若無特殊說明,默認為匯編調(diào)用格式。
[5]如出一轍:實際上勾選框和滑塊條的構(gòu)造函數(shù)應(yīng)當另含有兩個Image*類型的參數(shù),但是這兩個參數(shù)在原版中已被內(nèi)聯(lián)至函數(shù)內(nèi),故在此不再討論。
[6]DialogButton為增加了坐標偏移和文字偏移屬性的ButtonWidget,被用于Sexy范疇的Dialog中,而LawnStoneButton覆寫了其繪制函數(shù),被用于Lawn范疇的LawnDialog中。
[7]實際的尋找是一個遞歸的過程,從控件管理器開始的每一級控件都只會在其本身的子控件中尋找最符合的一個,直至最終找到一個不存在子控件或點擊位置不在其任何子控件上的控件。其核心是Sexy::WidgetContainer::GetWidgetAtHelper函數(shù)。
[8]這兩個函數(shù)都是虛函數(shù),請遵循虛函數(shù)的調(diào)用方式。