实验Delphi的扫雷外挂
作者:admin 日期:2006-07-17
其实我不玩网络游戏的,所以对于外挂类程序也没什么好或坏的感觉,甚至平时基本不会想到这个概念。这个实验项目一开始称为“扫雷助手”,而究其技术含量,与现在真正实用的游戏外挂程序也没有可比性。设计它的意义,无非在于对某些Windows API的进一步熟悉,使从未涉足过此领域的新手(当初的我自己)能够揭开某些“神秘的面纱”。之所以选择“扫雷”这个最经典的小游戏进行hack,一方面,使这个项目更贴近生活,更容易被理解,另一方面也可以最大化本人的成就感^_^,毕竟,在新手中,对扫雷的hack的探索似乎从来都没有停止过,从早期的修改.ini文件和注册表,到后来的Esc和xyzzy,无不体现着一代代人对这个目标的不懈追求^_^。而对于我们这些搞专业的来说,则应该有专业一点的方式,要有点技术含量!于是我做了这个叫MineAssistant的项目。
它的原理说来也不难,无非实时地替人做两件事:1。把一定是雷的方块标上雷的记号,2。把一定不是雷的方块点击开。至于轻易判断不了是不是雷的,还得靠你的运气。其实,扫雷游戏中,这两项最简单的操作绝对是占了绝大多数时间的,不妨看看实测的效果:我使用扫雷外挂的纪录是:初级、中级都1秒,高级4秒。(和修改注册表的相比还是差了一点,不过那个一点技术含量都没有,呵呵)
显然,这个实现过程涉及到了从一个程序操作另一个程序的窗口。这必须使用Windows
API,主要的有下面几个:
FindWindow
GetDC
GetPixel
PostMessage
稍微有点基础的看也看得出来,本人对于扫雷进程的内部数据是一无所知的,对于当前雷区的状态信息的取得,都是通过最浅显最笨的方法,就是直接读取屏幕像素。而反馈的操作,也都是通过PostMessage简单的发送一条信息来模仿鼠标点击。这样,本程序就自然的分成了两个层次,一个是与扫雷窗口的接口层,一个是实现判断的算法层。若是有基础的人,在提示到这以后,应该就可以自己来实现它了^_^,但就本人的经验来看,还有一个难点,就是如何从一个个像素的取值来分析出每个方块是什么状态。难不成用模式识别?!!别紧张,当初本人看了别人写的象棋对战程序,也忐忑地问了一句:难道要用人工智能?对方笑答,没那么严重,说这个算法你也写得出来。——实际上,只要检查每个方块上一两个特定位置像素,就可以得知这个方块的性质。当然,怎样找到这一两个像素的位置,还是要费一番周折的。为此,我写了好几个临时程序,专门用来测量“扫雷”窗口的各种尺寸参数。基本原理是,用FindWindow和GetDC得到扫雷窗口的HDC,然后把整个画面写到一个.bmp文件中去,再用Windows画图将其打开,用放大镜放到最大倍数,然后……一个个像素地数!另外,还用ResHacker把扫雷程序中的位图资源提取出来,把小方块中所有像素用程序进行扫描,以找到方块内某个特定像素,靠其颜色足以区分各种方块(未挖开的空白,未挖开的插旗,未挖开的问号,和挖开的1~8的数字,和挖开的空白)。在这其中我郁闷地发现,任何一个像素都不可能完全区分它们,因为扫雷窗口上的颜色总共还没有那么多种。引入第二个像素是不可避免的,于是我用另一个位置的像素,首先区分是挖开的还是没挖开的,才算解决了这个问题。
另外,关于开发工具的选择。“程序员就像男人,编程语言就像女人,一般男人都想要很多女人,可没几个男人能真正了解一个女人”,这句话引自CSDN首页上曾看到的网友评论,本人当初也曾有过少年轻狂的时候,要了不少“女人”,以为女人越多真的越牛X,导致现在还对不少编程语言都一知半解。在做这个程序的时候,最了解的是Delphi,所以就用Delphi实现了。现在想来,Delphi确实是做此类程序的较好的选择。它和Windows底层有良好的互操作性,唯一不便则是,从MSDN中看到的WinAPI的声明都是C的,在Delphi中要稍微变换一下长相,而这些资料则是Borland所缺乏的。另外,再吹毛求疵一点,就是Delphi屏蔽了太多底层细节,使很多初学者冷丁与HDC等概念打交道时会陌生。但一般来说,这更像是使用者的毛病,而不是开发工具的。另外,C++也是个好选择,如果拿到今天,也许我更愿意用C++去实现它,C++配上wxWidgets,用来开发Windows应用程序真不是闹着玩的。但我一定不会喜欢用Java来实现它的,原因我想不必说大家也知道!
好,现在用Delphi开始扫雷外挂的开发之旅。首先,来看一下前面提到的,本人跋山涉水啊,翻山越岭啊,费尽心思搜集来的扫雷窗口资料。所有资料取自Windows XP下带的扫雷“版本5.1”。注意,WinXP下的扫雷与Win9x中的,窗口图形有点细微差别,这足以使该外挂在Win9x下无法正确运行。不过我最终还是不打算加进判断OS版本并采用两套不同参数的代码了,毕竟只是个实验项目!
实测数据(单位为像素):
=================================================================
每个小方块:宽=16,高=16
雷区的最大可能大小:宽=30个方块, 高=24个方块
从雷区的四边,到窗口客户区的四边,各有间隔,分别为:左=12,右=8,上=55,下=8
在每一个方块内,如下两个像素可用来判断该方块的性质(坐标为相对方块左上角得值):
首先判断(0,0)处,若为clWhite,则为未挖开的方块,若为clGray,则为挖开的。
若是未挖开的方块,可再判断(5,4),对应关系如下:
clSilver:空白
clRed:插旗
clBlack:问号
若是挖开的方块,可再判断(7,4),对应关系如下:
clSilver:空白(相当于数字0)
clBlue:数字1
clGreen:数字2
clRed:数字3
clNavy:数字4
clMaroon:数字5
clTeal:数字6
clBlack:数字7
clGray:数字8
=================================================================
另外,雷区上方的“重开始”按钮的状态也是很重要的数据,对于它的判断,后面遇到的时候有详述。
有了前述的数据,编写接口层似乎已经不成问题了。现在来构思一下程序运行的过程。说句实话,那种对于某种变化实时作出响应的程序,一般都用什么方法编成,本人并不很了解,推测无非是两种思想,一种是用一个线程不断地对这个变化进行检查,发现改变则动作;另一种是注册一个类似于钩子的东西,用回调函数来处理。无疑第二种方式要更节省系统资源,只是难在本人对Windows的钩子掌握的还一塌糊涂。若换了个高手,无疑应该用这种方式,而我却只有望着Spy++中迅速滚动的消息发呆的份。也许等我学好了钩子之后,会写个0.2版,而在0.1版中我已经决定了使用丑陋的反复检查的方式。于是,用了一个TTimer控件,定时间隔设在了30左右,用它的OnTimer事件来进行一次检查和反馈操作。时间间隔是反复试验确定的,太小,会很占CPU,太大则反应会变慢。
这样,运行过程已经确定下来:
OnTimer事件->判断当前可否进行操作->取得整个雷区当前状况->用算法进行分析->反馈操作
所谓不可进行操作的时候,无非是指:根本没有扫雷窗口,或者窗口部分被遮挡(此时无法取得正确的像素值),或者扫雷游戏没有开始。
在我的代码中,OnTimer事件处理过程的核心就是如下简单的几句:
=================================================================
if GetMineWindow then
begin
FetchCells;
AnalyzeCells;
OperateCells;
end;
=================================================================
其中:
GetMineWindow函数返回一个Boolean值,表示可否进行操作。如果可以,同时将关于扫雷窗口的一些参数存放进全局变量中。
FetchCells过程取得整个雷区所有方块的信息,填入输入缓冲区。
AnalyzeCells过程对输入缓冲区中的数据进行分析,将反馈操作填入输出缓冲区。
OperateCells过程根据输出缓冲区中的数据对扫雷窗口进行反馈操作。
上述输入缓冲区和输出缓冲区,各是一个二维数组,直观地对应了扫雷窗口上的每一个方块。前者保存每个方块的当前状态供分析,后者保存分析完毕后,将要实施到每一个方块的操作。雷区的宽和高都不是固定的,而这两个二维数组,则无论何时都要能够保存所有方块的信息。这时有两个选择,一是定义足以容下最大情况的静态数组,二是使用动态数组。为了简单,我采用了前者,毕竟最大情况也不是大得难以忍受。这样,还需要一对整型变量保存实际雷区的宽和高。
现在可以来看一下全部需要的全局常量,变量和类型:
=================================================================
const
MINE_WINDOW_TITLE = '扫雷'; //窗口标题,供寻找扫雷窗口用
//以下四个,为雷区四边到窗口客户区四边的距离
TOP_MARGIN = 55; //上边距
BOTTOM_MARGIN = 8; //下边距
LEFT_MARGIN = 12; //左边距
RIGHT_MARGIN = 8; //右边距
CELL_WIDTH = 16; //每个方块宽度
CELL_HEIGHT = 16; //每个方块高度
MAX_COLUMN_COUNT = 30; //雷区最大可能的列数
MAX_ROW_COUNT = 24; //雷区最大可能的行数
type
//每个方块的可能状态,包括0~8的数字(0不显示),未知的,已插旗标记为雷的,和标记问号的
TCellState = (cs0, cs1, cs2, cs3, cs4, cs5, cs6, cs7, cs8, csUnknown,
csMarked, csPossible);
//TCellState的集合类型,供分析算法使用
TCellStates = set of TCellState;
//对每个方块的操作种类,包括无操作,左键单击,右键单击,左右键同时单击,和右键双击(用于将问号标记成旗)
TOperation = (opNone, opLeftClick, opRightClick, opBothClick,
opRightDoubleClick);
var
MineWnd: HWND; //保存扫雷窗口的句柄
MineDC: HDC; //保存扫雷窗口的设备上下文
//雷区的实际宽度和高度(方块数)
AreaWidth: Integer
AreaHeight: Integer;
//输入缓冲区
Cells: array[0..MAX_COLUMN_COUNT-1, 0..MAX_ROW_COUNT-1] of TCellState;
//输出缓冲区
Operations: array[0..MAX_COLUMN_COUNT-1, 0..MAX_ROW_COUNT-1] of
TOperation;
=================================================================
如上述,本程序分为了接口层和算法层。上述全局变量和常量,基本都属于接口层的内容。下面,来看接口层的具体实现。其工作的第一步,是要捕获扫雷窗口并取得其信息。这由函数GetMineWindow来完成:
=================================================================
//试图取得可用的扫雷窗口,返回值表示是否成功。若成功,则全局变量
//MineWnd、MineDC、AreaHeight、AreaWidth都得到相应的填充。若失败,则以上变量的值无意义。
function GetMineWindow: Boolean;
var
clientRect: TRect;
begin
result := false;
MineWnd := FindWindow(nil, MINE_WINDOW_TITLE); //检查是否存在“扫雷”窗口,并且必须为当前窗口
if (MineWnd = 0) or (GetForegroundWindow <> MineWnd) then
Exit;
MineDC := GetDC(MineWnd); //取得“扫雷”窗口的设备上下文
if MineDC = 0 then
Exit;
GetClientRect(MineWnd, clientRect); //检查“扫雷”窗口的内容是否全部显示在屏幕上
with TCanvas.Create do
try
Handle := MineDC;
if (ClipRect.Left <> clientRect.Left) or
(ClipRect.Right <> clientRect.Right) or
(ClipRect.Top <> clientRect.Top) or
(ClipRect.Bottom <> clientRect.Bottom) then
Exit;
finally
Free;
end;
//从已获得的clientRect中的数值,根据实测数据计算AreaWidth和AreaHeight的值。
AreaWidth := (clientRect.Right - LEFT_MARGIN - RIGHT_MARGIN) div
CELL_WIDTH;
AreaHeight := (clientRect.Bottom - TOP_MARGIN - BOTTOM_MARGIN) div
CELL_HEIGHT;
//检查游戏是否在进行中,原理为判断“重开始”按钮的图标上的
//某一像素是否是指定的值。该经验由实测得到,只有游戏进行中,该像素才为该值。
if TColor(GetPixel(MineDC, AreaWidth*8 + 8, 30)) <> clBlack then
Exit;
result := true;
end;
=================================================================
理解这个函数的工作过程,有几个要点:
WinAPI函数FindWindow:用来查找当前桌面上的某个窗口。第一个参数是指定该窗口的“窗口类”的名字,这个稍微高深了一点,只有研究过Windows SDK编程才会理解。当它为nil的时候,使用第二个参数,也就是窗口标题栏的字符串来查找。若找到这样一个窗口,则返回值为其窗口句柄,否则为0。
WinAPI函数GetForegroundWindow:无参数,返回桌面上的当前窗口,也就是标题条加亮的窗口的句柄。
WinAPI函数GetDC:给定一个窗口句柄,返回它的设备上下文句柄。“设备上下文”实际上就是一个“画布”,在Delphi中,被封装成了TCanvas类。获得了某个设备上下文句柄,就可以用一个TCanvas型的对象指向它(这个过程是,把句柄赋给TCanvas对象的Handle属性),从而实现画布的各种操作。
WinAPI函数GetClientRect:给定某个窗口句柄,取得它的客户区矩形,这个矩形是一个TRect类型的变量。调用这个函数,要用一个TRect型的变量来接收结果,而不是用返回值。这个结果的Left和Top成员都必定是0,而Right和Bottom成员其实就是窗口客户区的宽和高。
TCanvas类的属性ClipRect:简单的说,在此处,该TRect型属性取得的是该画布实际上被显示在屏幕上的矩形部分。只有该画布不被其它窗口遮挡,并且没有移出桌面边界的时候,这个矩形才完全等于等于窗口的客户区矩形。这用来判断扫雷窗口是否全部可见。
WinAPI函数GetPixel:给定一个设备上下文(画布)句柄和X,Y坐标,取得一个像素的值。这个值是整型的,可以简单的强制转换为TColor类型。
上述库函数,具体说明可以参考MSDN和Delphi自身的帮助文档,可以得到最为权威、详细、正确的说明。
不得不说一下GetMineWindow函数的最后几行,它牵涉到了对“重开始”按钮的hack。注意一下,可以发现那个简单的脸谱总共有5种状态:平时的笑脸,自身被按下时的笑脸,在雷区中按下鼠标时的紧张表情,触雷时的衰脸和胜利时酷酷的表情~——显然,只有在第一种情况时,扫雷外挂才应该动作,其它四种时则应该停止。我编了一个临时程序,找到了一个像素位置,它只有在第一种情况下值为clBlack,其它情况都不是。它的坐标为(AreaWidth*8
+ 8, 30),横坐标是个随方块列数而变的变量,很好理解,因为无论窗口有多宽,该按钮都是水平居中的。
不得不说,捕获“扫雷”窗口以及取得它的数据,是本程序的一个难点。现在这个难点已经解决,接下来,完成接口层已经不是问题了。那么,来看接口层的两个核心过程:
=================================================================
//取得整个雷区每个方块的状态,填入Cells中供分析。
procedure FetchCells;
var
i, j: Integer;
begin
//扫描每个方块,根据指定像素的颜色判断该方块的性质。
//特定像素的颜色与方块性质的对应关系归纳自“扫雷”程序本身的资源。
for i:=0 to AreaWidth-1 do
for j:=0 to AreaHeight-1 do
//首先判断(0, 0)点的像素
case TColor(GetPixel(MineDC, LEFT_MARGIN + i*CELL_WIDTH, TOP_MARGIN +
j*CELL_HEIGHT)) of
clWhite:
//是未挖开的方块,再判断(5, 4)点的像素
case TColor(GetPixel(MineDC, LEFT_MARGIN + i*CELL_WIDTH + 5,
TOP_MARGIN + j*CELL_HEIGHT + 4)) of
clSilver: Cells[i, j] := csUnknown; //未翻开的方块
clRed: Cells[i, j] := csMarked; //已标记为雷的方块
clBlack: Cells[i, j] := csPossible; //标问号的方块
end;
clGray:
//是已挖开的方块,再判断(7, 4)点的像素
case TColor(GetPixel(MineDC, LEFT_MARGIN + i*CELL_WIDTH + 7,
TOP_MARGIN + j*CELL_HEIGHT + 4)) of
clSilver: Cells[i, j] := cs0; //空白,相当于数字0
clBlue: Cells[i, j] := cs1; //数字1
clGreen: Cells[i, j] := cs2; //数字2
clRed: Cells[i, j] := cs3; //数字3
clNavy: Cells[i, j] := cs4; //数字4
clMaroon: Cells[i, j] := cs5; //数字5
clTeal: Cells[i, j] := cs6; //数字6
clBlack: Cells[i, j] := cs7; //数字7
clGray: Cells[i, j] := cs8; //数字8
end;
end;
end;
=================================================================
以上程序中,三个包含 case...of 的行,都用到了前面实测到的数据。执行完之后,扫雷窗口所有方块的状态就原原本本地在输入缓冲区里了。
=================================================================
//将Operations中所记载的对每个方块的操作真正作用于扫雷窗口。
procedure OperateCells;
var
i, j: Integer;
downMsg, upMsg: Cardinal; //按下和抬起鼠标按钮时分别发送的消息
wparam, lparam: Integer; //消息参数
clickCount: Integer; //按键次数,只有取值1或2
begin
//扫描每个方块
for i:=0 to AreaWidth-1 do
for j:=0 to AreaHeight-1 do
begin
if Operations[i, j] = opNone then
Continue;
//根据操作种类,设定发送的消息及其参数
lparam := ((TOP_MARGIN + j*CELL_HEIGHT) shl 16) + (LEFT_MARGIN +
i*CELL_WIDTH);
wparam := IfThen(Operations[i, j] = opBothClick, MK_RBUTTON, 0);
downMsg := IfThen(Operations[i, j] in [opRightClick,
opRightDoubleClick], WM_RBUTTONDOWN, WM_LBUTTONDOWN);
upMsg := IfThen(Operations[i, j] in [opRightClick, opRightDoubleClick],
WM_RBUTTONUP, WM_LBUTTONUP);
//设定发送消息次数,即单击还是双击
clickCount := IfThen(Operations[i, j] = opRightDoubleClick, 2, 1);
//发送消息
repeat
PostMessage(MineWnd, downMsg, wparam, lparam);
PostMessage(MineWnd, upMsg, wparam, lparam);
Dec(clickCount);
until clickCount = 0;
end;
end;
=================================================================
这里需要说的是WM_LBUTTONDOWN、WM_LBUTTONUP、WM_RBUTTONDOWN和WM_RBUTTONUP四个消息。它们是在一个窗口客户区内按下或抬起鼠标左或右按钮时,发给这个窗口的消息。所以,手动地发送这些消息,其实就是模拟鼠标的点击。发送消息用到了WinAPI函数PostMessage,它和大家所熟悉的SendMessage函数的参数是相同的,作用也几乎相同,主要区别是不等待消息的返回,详见MSDN。上述四个消息的WParam和LParam都具有同样的意义:WParam用来指定按下该键的时候,还有哪些其它特定的键(其它鼠标键,或Ctrl,Shift等)被按下。0表示没有其它键被按下,在这里还用到了值MK_RBUTTON,即按下左键时指定右键同时也被按下,用来模拟同时按下左右键的情况。当然,发送RBUTTONDOWN和RBUTTONUP时指定MK_LBUTTON也是同样的效果。而LParam则指定了按下或抬起键时鼠标指针的坐标,它的高16位为Y坐标,低16位为X坐标。给lparam赋值的那一行,就是把X,Y两个值组装成了一个lparam。
另外提一下IfThen函数,这个函数平时并不见有多人使用,但它真的很方便,至少它解决了对于“Delphi中没有C的 ? : 运算符”的抱怨^_^。不错,这就是Delphi的问号运算符,三个参数中
第一个是Boolean型,若为真,返回值就是第二个参数,否则是第三个参数。第二、第三个参数类型相同,并且有多个重载版本。数值版本需要包含Math库,而字符串版本需要StrUtils库。显然,编译上述代码是需要包含Math库的。若不愿,当然你也可以使用
if...then。
至此,接口层全部实现完毕,接下来就可以安心的实现“数学模型”了。
如前所述,算法层的实现,不外乎两种操作:1。如果一个方块的数值等于周围未挖开的方块数目,则把周围所有方块标记为雷;2。如果一个方块的数值等于周围已经标记为雷的方块个数,则在该块上同时单击左右键。实际上,这只是最简单的两种判断(简单到甚至不该称之为“判断”,而只是例行公事而已),而比这更复杂的分析判断还可以有很多,但现在我们追求的是程序的简单易懂,而且,就这两种最简单的判断,已经可以达到很好的效果了,在实际中它们绝对占到了扫雷所用时间的一大多半。更高级的判断,在扫雷外挂的0.2版本里也已经实现了,但在此处若要加以叙述,不免还要大幅增加篇幅。
就来看这个最简单的算法:
=================================================================
//根据Cells中的数据进行判断,把适当的操作填入Operations中
procedure AnalyzeCells;
var
i, j: Integer;
neighborCount: Integer; //保存一个方块周围未挖开的方块的数目
begin
//首先清空输出缓冲区
for i:=0 to AreaWidth-1 do
for j:=0 to AreaHeight-1 do
Operations[i, j] := opNone;
//扫描输入缓冲区,执行两种最简单的判断
for i:=0 to AreaWidth-1 do
for j:=0 to AreaHeight-1 do
begin
//取得一个方块周围未挖开的方块的数目
neighborCount := CountNeighbors(i, j, [csUnknown, csPossible]);
//只有1~8的数字,并且周围存在未挖开的方块,这样的方块才有分析价值
if (Cells[i, j]>cs0) and (Cells[i, j]<=cs8) and (neighborCount > 0) then
//第一种情况
if neighborCount = ord(Cells[i, j])-CountNeighbors(i, j, [csMarked])
then
MarkAllNeighbors(i, j)
//第二种情况
else if ord(Cells[i, j]) = CountNeighbors(i, j, [csMarked]) then
Operations[i, j] := opBothClick;
end;
end;
//将指定方块周围8个方块中,未挖开的,包括已标记问号的,都标记为雷。
procedure MarkAllNeighbors(const x, y: Integer);
var
i,j: Integer;
begin
//扫描以某个坐标为中心的9个方块
for i:=x-1 to x+1 do
for j:=y-1 to y+1 do
begin
//去除中心块,并避免数组越界
if ((i=x) and (j=y)) or (i<0) or (i>=AreaWidth) or (j<0) or
(j>=AreaHeight) then
Continue;
//未挖开的空白则单击右键,未挖开的标问号的,则双击右键
if Cells[i, j] = csUnknown then
Operations[i, j] := opRightClick
else if Cells[i, j] = csPossible then
Operations[i, j] := opRightDoubleClick;
end;
end;
//取得指定方块周围8个方块中等于任一个指定状态的方块的个数。
function CountNeighbors(const x, y: Integer; const targetStates:
TCellStates): Integer;
var
i,j: Integer;
begin
result := 0;
//扫描以某个坐标为中心的9个方块
for i:=x-1 to x+1 do
for j:=y-1 to y+1 do
begin
//去除中心块,并避免数组越界
if ((i=x) and (j=y)) or (i<0) or (i>=AreaWidth) or (j<0) or
(j>=AreaHeight) then
Continue;
//计数指定状态的方块
if Cells[i, j] in targetStates then
Inc(result);
end;
end;
=================================================================
其中,由于枚举TCellState的常量位置的安排,Ord函数对cs0~cs8所取得的值正是0~8,即等于该方块的数值。这个算法可以算是中规中矩,没什么取巧的地方,因此应该不那么难懂。不错,至此扫雷外挂已经完全实现完毕。把上述所有函数和全局内容放在一个单元(可以是一个窗体)里,设好TTimer控件的间隔,就可以很理想的工作了。在外挂类程序的开发中,本例用到的也许是最“笨”的一种方法,但对于平面方格类游戏,其原理具有通用性。它不需对游戏底层数据、协议之类有什么了解,只需要了解游戏的屏幕图形就可以了。本例对于Windows窗口相关的某些API,也是一个较好的熟悉机会,对于初学者会有其意义。这个例子本身并不是最完善的,了解了思想,每个人自可以做出更加完善的程序。比如,应用钩子,这会大幅度减少该程序占用的系统资源。
几天时间终于写完了这堆文章,希望能给适当的人带来适当的收益。欢迎交流,信箱:euth@163.com
它的原理说来也不难,无非实时地替人做两件事:1。把一定是雷的方块标上雷的记号,2。把一定不是雷的方块点击开。至于轻易判断不了是不是雷的,还得靠你的运气。其实,扫雷游戏中,这两项最简单的操作绝对是占了绝大多数时间的,不妨看看实测的效果:我使用扫雷外挂的纪录是:初级、中级都1秒,高级4秒。(和修改注册表的相比还是差了一点,不过那个一点技术含量都没有,呵呵)
显然,这个实现过程涉及到了从一个程序操作另一个程序的窗口。这必须使用Windows
API,主要的有下面几个:
FindWindow
GetDC
GetPixel
PostMessage
稍微有点基础的看也看得出来,本人对于扫雷进程的内部数据是一无所知的,对于当前雷区的状态信息的取得,都是通过最浅显最笨的方法,就是直接读取屏幕像素。而反馈的操作,也都是通过PostMessage简单的发送一条信息来模仿鼠标点击。这样,本程序就自然的分成了两个层次,一个是与扫雷窗口的接口层,一个是实现判断的算法层。若是有基础的人,在提示到这以后,应该就可以自己来实现它了^_^,但就本人的经验来看,还有一个难点,就是如何从一个个像素的取值来分析出每个方块是什么状态。难不成用模式识别?!!别紧张,当初本人看了别人写的象棋对战程序,也忐忑地问了一句:难道要用人工智能?对方笑答,没那么严重,说这个算法你也写得出来。——实际上,只要检查每个方块上一两个特定位置像素,就可以得知这个方块的性质。当然,怎样找到这一两个像素的位置,还是要费一番周折的。为此,我写了好几个临时程序,专门用来测量“扫雷”窗口的各种尺寸参数。基本原理是,用FindWindow和GetDC得到扫雷窗口的HDC,然后把整个画面写到一个.bmp文件中去,再用Windows画图将其打开,用放大镜放到最大倍数,然后……一个个像素地数!另外,还用ResHacker把扫雷程序中的位图资源提取出来,把小方块中所有像素用程序进行扫描,以找到方块内某个特定像素,靠其颜色足以区分各种方块(未挖开的空白,未挖开的插旗,未挖开的问号,和挖开的1~8的数字,和挖开的空白)。在这其中我郁闷地发现,任何一个像素都不可能完全区分它们,因为扫雷窗口上的颜色总共还没有那么多种。引入第二个像素是不可避免的,于是我用另一个位置的像素,首先区分是挖开的还是没挖开的,才算解决了这个问题。
另外,关于开发工具的选择。“程序员就像男人,编程语言就像女人,一般男人都想要很多女人,可没几个男人能真正了解一个女人”,这句话引自CSDN首页上曾看到的网友评论,本人当初也曾有过少年轻狂的时候,要了不少“女人”,以为女人越多真的越牛X,导致现在还对不少编程语言都一知半解。在做这个程序的时候,最了解的是Delphi,所以就用Delphi实现了。现在想来,Delphi确实是做此类程序的较好的选择。它和Windows底层有良好的互操作性,唯一不便则是,从MSDN中看到的WinAPI的声明都是C的,在Delphi中要稍微变换一下长相,而这些资料则是Borland所缺乏的。另外,再吹毛求疵一点,就是Delphi屏蔽了太多底层细节,使很多初学者冷丁与HDC等概念打交道时会陌生。但一般来说,这更像是使用者的毛病,而不是开发工具的。另外,C++也是个好选择,如果拿到今天,也许我更愿意用C++去实现它,C++配上wxWidgets,用来开发Windows应用程序真不是闹着玩的。但我一定不会喜欢用Java来实现它的,原因我想不必说大家也知道!
好,现在用Delphi开始扫雷外挂的开发之旅。首先,来看一下前面提到的,本人跋山涉水啊,翻山越岭啊,费尽心思搜集来的扫雷窗口资料。所有资料取自Windows XP下带的扫雷“版本5.1”。注意,WinXP下的扫雷与Win9x中的,窗口图形有点细微差别,这足以使该外挂在Win9x下无法正确运行。不过我最终还是不打算加进判断OS版本并采用两套不同参数的代码了,毕竟只是个实验项目!
实测数据(单位为像素):
=================================================================
每个小方块:宽=16,高=16
雷区的最大可能大小:宽=30个方块, 高=24个方块
从雷区的四边,到窗口客户区的四边,各有间隔,分别为:左=12,右=8,上=55,下=8
在每一个方块内,如下两个像素可用来判断该方块的性质(坐标为相对方块左上角得值):
首先判断(0,0)处,若为clWhite,则为未挖开的方块,若为clGray,则为挖开的。
若是未挖开的方块,可再判断(5,4),对应关系如下:
clSilver:空白
clRed:插旗
clBlack:问号
若是挖开的方块,可再判断(7,4),对应关系如下:
clSilver:空白(相当于数字0)
clBlue:数字1
clGreen:数字2
clRed:数字3
clNavy:数字4
clMaroon:数字5
clTeal:数字6
clBlack:数字7
clGray:数字8
=================================================================
另外,雷区上方的“重开始”按钮的状态也是很重要的数据,对于它的判断,后面遇到的时候有详述。
有了前述的数据,编写接口层似乎已经不成问题了。现在来构思一下程序运行的过程。说句实话,那种对于某种变化实时作出响应的程序,一般都用什么方法编成,本人并不很了解,推测无非是两种思想,一种是用一个线程不断地对这个变化进行检查,发现改变则动作;另一种是注册一个类似于钩子的东西,用回调函数来处理。无疑第二种方式要更节省系统资源,只是难在本人对Windows的钩子掌握的还一塌糊涂。若换了个高手,无疑应该用这种方式,而我却只有望着Spy++中迅速滚动的消息发呆的份。也许等我学好了钩子之后,会写个0.2版,而在0.1版中我已经决定了使用丑陋的反复检查的方式。于是,用了一个TTimer控件,定时间隔设在了30左右,用它的OnTimer事件来进行一次检查和反馈操作。时间间隔是反复试验确定的,太小,会很占CPU,太大则反应会变慢。
这样,运行过程已经确定下来:
OnTimer事件->判断当前可否进行操作->取得整个雷区当前状况->用算法进行分析->反馈操作
所谓不可进行操作的时候,无非是指:根本没有扫雷窗口,或者窗口部分被遮挡(此时无法取得正确的像素值),或者扫雷游戏没有开始。
在我的代码中,OnTimer事件处理过程的核心就是如下简单的几句:
=================================================================
if GetMineWindow then
begin
FetchCells;
AnalyzeCells;
OperateCells;
end;
=================================================================
其中:
GetMineWindow函数返回一个Boolean值,表示可否进行操作。如果可以,同时将关于扫雷窗口的一些参数存放进全局变量中。
FetchCells过程取得整个雷区所有方块的信息,填入输入缓冲区。
AnalyzeCells过程对输入缓冲区中的数据进行分析,将反馈操作填入输出缓冲区。
OperateCells过程根据输出缓冲区中的数据对扫雷窗口进行反馈操作。
上述输入缓冲区和输出缓冲区,各是一个二维数组,直观地对应了扫雷窗口上的每一个方块。前者保存每个方块的当前状态供分析,后者保存分析完毕后,将要实施到每一个方块的操作。雷区的宽和高都不是固定的,而这两个二维数组,则无论何时都要能够保存所有方块的信息。这时有两个选择,一是定义足以容下最大情况的静态数组,二是使用动态数组。为了简单,我采用了前者,毕竟最大情况也不是大得难以忍受。这样,还需要一对整型变量保存实际雷区的宽和高。
现在可以来看一下全部需要的全局常量,变量和类型:
=================================================================
const
MINE_WINDOW_TITLE = '扫雷'; //窗口标题,供寻找扫雷窗口用
//以下四个,为雷区四边到窗口客户区四边的距离
TOP_MARGIN = 55; //上边距
BOTTOM_MARGIN = 8; //下边距
LEFT_MARGIN = 12; //左边距
RIGHT_MARGIN = 8; //右边距
CELL_WIDTH = 16; //每个方块宽度
CELL_HEIGHT = 16; //每个方块高度
MAX_COLUMN_COUNT = 30; //雷区最大可能的列数
MAX_ROW_COUNT = 24; //雷区最大可能的行数
type
//每个方块的可能状态,包括0~8的数字(0不显示),未知的,已插旗标记为雷的,和标记问号的
TCellState = (cs0, cs1, cs2, cs3, cs4, cs5, cs6, cs7, cs8, csUnknown,
csMarked, csPossible);
//TCellState的集合类型,供分析算法使用
TCellStates = set of TCellState;
//对每个方块的操作种类,包括无操作,左键单击,右键单击,左右键同时单击,和右键双击(用于将问号标记成旗)
TOperation = (opNone, opLeftClick, opRightClick, opBothClick,
opRightDoubleClick);
var
MineWnd: HWND; //保存扫雷窗口的句柄
MineDC: HDC; //保存扫雷窗口的设备上下文
//雷区的实际宽度和高度(方块数)
AreaWidth: Integer
AreaHeight: Integer;
//输入缓冲区
Cells: array[0..MAX_COLUMN_COUNT-1, 0..MAX_ROW_COUNT-1] of TCellState;
//输出缓冲区
Operations: array[0..MAX_COLUMN_COUNT-1, 0..MAX_ROW_COUNT-1] of
TOperation;
=================================================================
如上述,本程序分为了接口层和算法层。上述全局变量和常量,基本都属于接口层的内容。下面,来看接口层的具体实现。其工作的第一步,是要捕获扫雷窗口并取得其信息。这由函数GetMineWindow来完成:
=================================================================
//试图取得可用的扫雷窗口,返回值表示是否成功。若成功,则全局变量
//MineWnd、MineDC、AreaHeight、AreaWidth都得到相应的填充。若失败,则以上变量的值无意义。
function GetMineWindow: Boolean;
var
clientRect: TRect;
begin
result := false;
MineWnd := FindWindow(nil, MINE_WINDOW_TITLE); //检查是否存在“扫雷”窗口,并且必须为当前窗口
if (MineWnd = 0) or (GetForegroundWindow <> MineWnd) then
Exit;
MineDC := GetDC(MineWnd); //取得“扫雷”窗口的设备上下文
if MineDC = 0 then
Exit;
GetClientRect(MineWnd, clientRect); //检查“扫雷”窗口的内容是否全部显示在屏幕上
with TCanvas.Create do
try
Handle := MineDC;
if (ClipRect.Left <> clientRect.Left) or
(ClipRect.Right <> clientRect.Right) or
(ClipRect.Top <> clientRect.Top) or
(ClipRect.Bottom <> clientRect.Bottom) then
Exit;
finally
Free;
end;
//从已获得的clientRect中的数值,根据实测数据计算AreaWidth和AreaHeight的值。
AreaWidth := (clientRect.Right - LEFT_MARGIN - RIGHT_MARGIN) div
CELL_WIDTH;
AreaHeight := (clientRect.Bottom - TOP_MARGIN - BOTTOM_MARGIN) div
CELL_HEIGHT;
//检查游戏是否在进行中,原理为判断“重开始”按钮的图标上的
//某一像素是否是指定的值。该经验由实测得到,只有游戏进行中,该像素才为该值。
if TColor(GetPixel(MineDC, AreaWidth*8 + 8, 30)) <> clBlack then
Exit;
result := true;
end;
=================================================================
理解这个函数的工作过程,有几个要点:
WinAPI函数FindWindow:用来查找当前桌面上的某个窗口。第一个参数是指定该窗口的“窗口类”的名字,这个稍微高深了一点,只有研究过Windows SDK编程才会理解。当它为nil的时候,使用第二个参数,也就是窗口标题栏的字符串来查找。若找到这样一个窗口,则返回值为其窗口句柄,否则为0。
WinAPI函数GetForegroundWindow:无参数,返回桌面上的当前窗口,也就是标题条加亮的窗口的句柄。
WinAPI函数GetDC:给定一个窗口句柄,返回它的设备上下文句柄。“设备上下文”实际上就是一个“画布”,在Delphi中,被封装成了TCanvas类。获得了某个设备上下文句柄,就可以用一个TCanvas型的对象指向它(这个过程是,把句柄赋给TCanvas对象的Handle属性),从而实现画布的各种操作。
WinAPI函数GetClientRect:给定某个窗口句柄,取得它的客户区矩形,这个矩形是一个TRect类型的变量。调用这个函数,要用一个TRect型的变量来接收结果,而不是用返回值。这个结果的Left和Top成员都必定是0,而Right和Bottom成员其实就是窗口客户区的宽和高。
TCanvas类的属性ClipRect:简单的说,在此处,该TRect型属性取得的是该画布实际上被显示在屏幕上的矩形部分。只有该画布不被其它窗口遮挡,并且没有移出桌面边界的时候,这个矩形才完全等于等于窗口的客户区矩形。这用来判断扫雷窗口是否全部可见。
WinAPI函数GetPixel:给定一个设备上下文(画布)句柄和X,Y坐标,取得一个像素的值。这个值是整型的,可以简单的强制转换为TColor类型。
上述库函数,具体说明可以参考MSDN和Delphi自身的帮助文档,可以得到最为权威、详细、正确的说明。
不得不说一下GetMineWindow函数的最后几行,它牵涉到了对“重开始”按钮的hack。注意一下,可以发现那个简单的脸谱总共有5种状态:平时的笑脸,自身被按下时的笑脸,在雷区中按下鼠标时的紧张表情,触雷时的衰脸和胜利时酷酷的表情~——显然,只有在第一种情况时,扫雷外挂才应该动作,其它四种时则应该停止。我编了一个临时程序,找到了一个像素位置,它只有在第一种情况下值为clBlack,其它情况都不是。它的坐标为(AreaWidth*8
+ 8, 30),横坐标是个随方块列数而变的变量,很好理解,因为无论窗口有多宽,该按钮都是水平居中的。
不得不说,捕获“扫雷”窗口以及取得它的数据,是本程序的一个难点。现在这个难点已经解决,接下来,完成接口层已经不是问题了。那么,来看接口层的两个核心过程:
=================================================================
//取得整个雷区每个方块的状态,填入Cells中供分析。
procedure FetchCells;
var
i, j: Integer;
begin
//扫描每个方块,根据指定像素的颜色判断该方块的性质。
//特定像素的颜色与方块性质的对应关系归纳自“扫雷”程序本身的资源。
for i:=0 to AreaWidth-1 do
for j:=0 to AreaHeight-1 do
//首先判断(0, 0)点的像素
case TColor(GetPixel(MineDC, LEFT_MARGIN + i*CELL_WIDTH, TOP_MARGIN +
j*CELL_HEIGHT)) of
clWhite:
//是未挖开的方块,再判断(5, 4)点的像素
case TColor(GetPixel(MineDC, LEFT_MARGIN + i*CELL_WIDTH + 5,
TOP_MARGIN + j*CELL_HEIGHT + 4)) of
clSilver: Cells[i, j] := csUnknown; //未翻开的方块
clRed: Cells[i, j] := csMarked; //已标记为雷的方块
clBlack: Cells[i, j] := csPossible; //标问号的方块
end;
clGray:
//是已挖开的方块,再判断(7, 4)点的像素
case TColor(GetPixel(MineDC, LEFT_MARGIN + i*CELL_WIDTH + 7,
TOP_MARGIN + j*CELL_HEIGHT + 4)) of
clSilver: Cells[i, j] := cs0; //空白,相当于数字0
clBlue: Cells[i, j] := cs1; //数字1
clGreen: Cells[i, j] := cs2; //数字2
clRed: Cells[i, j] := cs3; //数字3
clNavy: Cells[i, j] := cs4; //数字4
clMaroon: Cells[i, j] := cs5; //数字5
clTeal: Cells[i, j] := cs6; //数字6
clBlack: Cells[i, j] := cs7; //数字7
clGray: Cells[i, j] := cs8; //数字8
end;
end;
end;
=================================================================
以上程序中,三个包含 case...of 的行,都用到了前面实测到的数据。执行完之后,扫雷窗口所有方块的状态就原原本本地在输入缓冲区里了。
=================================================================
//将Operations中所记载的对每个方块的操作真正作用于扫雷窗口。
procedure OperateCells;
var
i, j: Integer;
downMsg, upMsg: Cardinal; //按下和抬起鼠标按钮时分别发送的消息
wparam, lparam: Integer; //消息参数
clickCount: Integer; //按键次数,只有取值1或2
begin
//扫描每个方块
for i:=0 to AreaWidth-1 do
for j:=0 to AreaHeight-1 do
begin
if Operations[i, j] = opNone then
Continue;
//根据操作种类,设定发送的消息及其参数
lparam := ((TOP_MARGIN + j*CELL_HEIGHT) shl 16) + (LEFT_MARGIN +
i*CELL_WIDTH);
wparam := IfThen(Operations[i, j] = opBothClick, MK_RBUTTON, 0);
downMsg := IfThen(Operations[i, j] in [opRightClick,
opRightDoubleClick], WM_RBUTTONDOWN, WM_LBUTTONDOWN);
upMsg := IfThen(Operations[i, j] in [opRightClick, opRightDoubleClick],
WM_RBUTTONUP, WM_LBUTTONUP);
//设定发送消息次数,即单击还是双击
clickCount := IfThen(Operations[i, j] = opRightDoubleClick, 2, 1);
//发送消息
repeat
PostMessage(MineWnd, downMsg, wparam, lparam);
PostMessage(MineWnd, upMsg, wparam, lparam);
Dec(clickCount);
until clickCount = 0;
end;
end;
=================================================================
这里需要说的是WM_LBUTTONDOWN、WM_LBUTTONUP、WM_RBUTTONDOWN和WM_RBUTTONUP四个消息。它们是在一个窗口客户区内按下或抬起鼠标左或右按钮时,发给这个窗口的消息。所以,手动地发送这些消息,其实就是模拟鼠标的点击。发送消息用到了WinAPI函数PostMessage,它和大家所熟悉的SendMessage函数的参数是相同的,作用也几乎相同,主要区别是不等待消息的返回,详见MSDN。上述四个消息的WParam和LParam都具有同样的意义:WParam用来指定按下该键的时候,还有哪些其它特定的键(其它鼠标键,或Ctrl,Shift等)被按下。0表示没有其它键被按下,在这里还用到了值MK_RBUTTON,即按下左键时指定右键同时也被按下,用来模拟同时按下左右键的情况。当然,发送RBUTTONDOWN和RBUTTONUP时指定MK_LBUTTON也是同样的效果。而LParam则指定了按下或抬起键时鼠标指针的坐标,它的高16位为Y坐标,低16位为X坐标。给lparam赋值的那一行,就是把X,Y两个值组装成了一个lparam。
另外提一下IfThen函数,这个函数平时并不见有多人使用,但它真的很方便,至少它解决了对于“Delphi中没有C的 ? : 运算符”的抱怨^_^。不错,这就是Delphi的问号运算符,三个参数中
第一个是Boolean型,若为真,返回值就是第二个参数,否则是第三个参数。第二、第三个参数类型相同,并且有多个重载版本。数值版本需要包含Math库,而字符串版本需要StrUtils库。显然,编译上述代码是需要包含Math库的。若不愿,当然你也可以使用
if...then。
至此,接口层全部实现完毕,接下来就可以安心的实现“数学模型”了。
如前所述,算法层的实现,不外乎两种操作:1。如果一个方块的数值等于周围未挖开的方块数目,则把周围所有方块标记为雷;2。如果一个方块的数值等于周围已经标记为雷的方块个数,则在该块上同时单击左右键。实际上,这只是最简单的两种判断(简单到甚至不该称之为“判断”,而只是例行公事而已),而比这更复杂的分析判断还可以有很多,但现在我们追求的是程序的简单易懂,而且,就这两种最简单的判断,已经可以达到很好的效果了,在实际中它们绝对占到了扫雷所用时间的一大多半。更高级的判断,在扫雷外挂的0.2版本里也已经实现了,但在此处若要加以叙述,不免还要大幅增加篇幅。
就来看这个最简单的算法:
=================================================================
//根据Cells中的数据进行判断,把适当的操作填入Operations中
procedure AnalyzeCells;
var
i, j: Integer;
neighborCount: Integer; //保存一个方块周围未挖开的方块的数目
begin
//首先清空输出缓冲区
for i:=0 to AreaWidth-1 do
for j:=0 to AreaHeight-1 do
Operations[i, j] := opNone;
//扫描输入缓冲区,执行两种最简单的判断
for i:=0 to AreaWidth-1 do
for j:=0 to AreaHeight-1 do
begin
//取得一个方块周围未挖开的方块的数目
neighborCount := CountNeighbors(i, j, [csUnknown, csPossible]);
//只有1~8的数字,并且周围存在未挖开的方块,这样的方块才有分析价值
if (Cells[i, j]>cs0) and (Cells[i, j]<=cs8) and (neighborCount > 0) then
//第一种情况
if neighborCount = ord(Cells[i, j])-CountNeighbors(i, j, [csMarked])
then
MarkAllNeighbors(i, j)
//第二种情况
else if ord(Cells[i, j]) = CountNeighbors(i, j, [csMarked]) then
Operations[i, j] := opBothClick;
end;
end;
//将指定方块周围8个方块中,未挖开的,包括已标记问号的,都标记为雷。
procedure MarkAllNeighbors(const x, y: Integer);
var
i,j: Integer;
begin
//扫描以某个坐标为中心的9个方块
for i:=x-1 to x+1 do
for j:=y-1 to y+1 do
begin
//去除中心块,并避免数组越界
if ((i=x) and (j=y)) or (i<0) or (i>=AreaWidth) or (j<0) or
(j>=AreaHeight) then
Continue;
//未挖开的空白则单击右键,未挖开的标问号的,则双击右键
if Cells[i, j] = csUnknown then
Operations[i, j] := opRightClick
else if Cells[i, j] = csPossible then
Operations[i, j] := opRightDoubleClick;
end;
end;
//取得指定方块周围8个方块中等于任一个指定状态的方块的个数。
function CountNeighbors(const x, y: Integer; const targetStates:
TCellStates): Integer;
var
i,j: Integer;
begin
result := 0;
//扫描以某个坐标为中心的9个方块
for i:=x-1 to x+1 do
for j:=y-1 to y+1 do
begin
//去除中心块,并避免数组越界
if ((i=x) and (j=y)) or (i<0) or (i>=AreaWidth) or (j<0) or
(j>=AreaHeight) then
Continue;
//计数指定状态的方块
if Cells[i, j] in targetStates then
Inc(result);
end;
end;
=================================================================
其中,由于枚举TCellState的常量位置的安排,Ord函数对cs0~cs8所取得的值正是0~8,即等于该方块的数值。这个算法可以算是中规中矩,没什么取巧的地方,因此应该不那么难懂。不错,至此扫雷外挂已经完全实现完毕。把上述所有函数和全局内容放在一个单元(可以是一个窗体)里,设好TTimer控件的间隔,就可以很理想的工作了。在外挂类程序的开发中,本例用到的也许是最“笨”的一种方法,但对于平面方格类游戏,其原理具有通用性。它不需对游戏底层数据、协议之类有什么了解,只需要了解游戏的屏幕图形就可以了。本例对于Windows窗口相关的某些API,也是一个较好的熟悉机会,对于初学者会有其意义。这个例子本身并不是最完善的,了解了思想,每个人自可以做出更加完善的程序。比如,应用钩子,这会大幅度减少该程序占用的系统资源。
几天时间终于写完了这堆文章,希望能给适当的人带来适当的收益。欢迎交流,信箱:euth@163.com
[本日志由 admin 于 2006-07-24 00:49 AM 编辑]
上一篇: “单击以激活并使用此控件”的问题解决方案下一篇: 提高ASP性能的最佳选择(续一)
文章来自: jorge
引用通告: 查看所有引用 | 我要引用此文章
Tags: Delphi 外挂
相关日志:
评论: 0 | 引用: 1 | 查看次数: 6104