在前面的文章中,我们把任务流程简单的整理了,并以伪代码的方式给出了一部分函数的定义,但是直接这样开发,也有一些其他的问题。
从功能上看可以按照那些接口来进行封装,但是更好的方式是采用分层设计的思路: 使用三层设计,其中的底层是所有通用的子功能(实现层);中间层则是一些特定于游戏的功能(游戏层);上层则是便于调用的接口(接口层)。
将接口、游戏、实现进行分层带来了很多优点:
a. 接口层专注于调用,无论是脚本还是应用程序,都可以方便快捷的进行调用;
b. 游戏层承接于接口层,它往往反应的是游戏本身特有的一系列功能组合,同时它调用的子功能则可能是独立于特定游戏的;
c. 基础实现反而和游戏与接口不相干,它往往实现一些通用的子功能;
和一开始的直觉相反,一开始我认为游戏是最上层,但是实践之后发现,特定于游戏的部分可能位于中间层会更好一些,因为接口应该足够简单,这样使用非常方便;同时底层的子功能涉及未来的复用,也不是那么容易封装的。
不过在开始具体的开发之前,先确定最上层的调用方式,一开始我认为使用python脚本语言是一件简单的事情,但是后来我发现python不是非常适合,因为游戏辅助本身很冷门,这导致很多功能不能随心所欲的定制,故最终我采用了C++和脚本两套实现方案。在这里,我们不会纠结于上层具体使用的到底是程序还是脚本,因为和外挂不一样的是,这个辅助本身在物理上和游戏不在一个电脑上同时运行,所以根本无需考虑游戏会来检测,这种情况下哪怕直接使用MFC这样的框架也没关系了。
在前面的讨论中,我们给出过一个设计图,它在逻辑上分为三个模块,但是,这样分解是为了便于从宏观上理解整体结构,实际上不会真的设计三个子模块,然后将它们一一和实际功能对应;
同样的,也不会真的分三层去分别设计模块,这个虽然比逻辑分解模块现实,但是实际上三层设计不是死板的按照三个层次去编码实现,相反,模块设计对于模块来说是实现高内聚低耦合很好的方案,但是它们不会严格按照三层去设计,三层设计中底层模块和顶层模块之间会存在直接跳过中间层的情况以及一部分中间层不可避免和上层重叠的情况。
在我设计和实现这部分的时候,我意识到这个问题,设计和实现之间存在差异,这种情况很常见,所以我经常写很多小模块,把功能实现,然后在实现功能之后额外花一些时间来优化和重构子模块,根据实践来将模块重新划分,再次编码整理,这比起一开始就设计良好的方案或者敏捷开发要笨并花费更多的时间,但实践证明这可以让代码精简并且强壮。
这个文档将首先划分出最基础的子功能,然后将这些子功能实现难点一一列出,最后以部分代码的方式给出实现细节,由于没有那么多时间,所以这个文档会在两三天之内陆续更新完。
12-16更新: 我写完一部分之后发现,底层的子功能设计的面很广,例如获取图像、文字识别、鼠标移动这几个点,每个点要从头开始讲起,那会让这个文章越来越长,所以我只能保证这些子功能是可以实现并且我会给出一部分实现的代码,大家来看这个系列不是为了看怎么编译opencv、tesseract之类的;另外我会把每个子功能会遇到的难点简单的说一下,这也算仁至义尽了。
编号 | 接口描述 | 接口名称和参数 | 返回值 |
1 | 从流中获取一张图像 | get_stream_image(flags, ref image) | bool |
2 | 从图像中截取一个区域 | get_rect_image(ref in_img, ref out_img) | bool |
3 | 在图像中定位单个特征 | get_template_pos(ref image, string temp, ref x, ref y) | int |
4 | 在图像中定位多个特征 | get_mutil_template_pos(ref image, string temp, ref x[], ref y[], ref count) | int |
5 | 将图像中的文字提取 | get_image_text(inage, str) | bool |
6 | 控制鼠标移动到特定位置 | move_to_point(handle, x, y) | bool |
7 | 格式化鼠标和键盘的输出 | send_hid_msg(msg) | bool |
8 | 分析文字信息并解析任务 | get_task_info(str) | string |
9 | 判断图像中是否存在某个特征 | is_image_flags(image) | bool |
11
这里分两种情况,windows平台和安卓平台,它们获取屏幕截图的方式是不一样,不过这里我们分别讨论一下吧。
windows平台截图的部分源代码如下:
// rect 是需要截取的矩形
int w = rect.right - rect.left;
int h = rect.bottom - rect.top;
HDC DeskDC = GetDC(DeskWnd); // 获取窗口DC
HBITMAP DeskBmp = ::CreateCompatibleBitmap(DeskDC, w, h); // 兼容位图
HDC memDC = ::CreateCompatibleDC(DeskDC); // 兼容DC
SelectObject(memDC, DeskBmp); // 把兼容位图选入兼容DC中
BitBlt(memDC, 0, 0, w, h, DeskDC, rect.left, rect.top, SRCCOPY);// 拷贝DC
BITMAP bmp_info;
GetObject(DeskBmp, sizeof(BITMAP), &bmp_info); // 根据位图句柄,获取位图信息
DWORD data_size = bmp_info.bmWidthBytes*bmp_info.bmHeight; // 计算位图数据大小
if(size != data_size || pData == NULL) assert(0);
BITMAPFILEHEADER bfh; // 初始化位图文件头
ZeroMemory(&bfh, sizeof(BITMAPFILEHEADER));
bfh.bfType = 0x4d42;
bfh.bfSize = data_size+54;
bfh.bfOffBits = 54;
BITMAPINFOHEADER bih;//位图信息头
ZeroMemory(&bih, sizeof(BITMAPINFOHEADER));
bih.biSize = sizeof(BITMAPINFOHEADER);
bih.biWidth = bmp_info.bmWidth;
bih.biHeight = bmp_info.bmHeight;
bih.biPlanes = 1;
bih.biBitCount = 32;
bih.biCompression = BI_RGB;
bih.biSizeImage = data_size;
bih.biXPelsPerMeter = 0;
bih.biYPelsPerMeter = 0;
bih.biClrUsed = 0;
bih.biClrImportant = 0;
::GetDIBits(memDC, DeskBmp, 0, bmp_info.bmHeight, pData, (BITMAPINFO *)&bih, DIB_RGB_COLORS);
// 代码到这里已经在pData中保存了位图数据指针,后续自行处理,注意释放资源
这里的图像是镜像图,自己转换一下就好,注意它是32位的,在windows平台下只能出来32位数据,自己转换一下。
安卓平台会麻烦一些,下面简单介绍一个:
// 使用ADB命令保存截图并传输回主机上
adb shell /system/bin/screencap -p /sdcard/screen.png
adb pull /sdcard/screen.png D:\\image
// 对adb封转并编程调用并不难,但是很冗长,就不贴代码了。
在这里,我们测试过,对于这种RPG的游戏来说,5f/s是足够处理的,所以无论使用什么方案,速度是可以的,当然,也有许多安卓投屏软件是开源的,用它们也是可行的,但是这里有一个额外的地方需要注意:
也许应该尽可能截取图像而不是使用视频流解析,原因在于视频流式有损压缩,很可能损失一些细节,这也许会增加后续图像处理的难度;当然如果需要设计一台主机上连接100个安卓手机这样的例外来说,使用H.264压缩也是可以接受的,这两种情况根据实际情况来妥协和取舍。
在图像中截取一部分区域是非常常见的操作,这部分直接使用opencv即可:
// Mat src(bmp_info.bmHeight, bmp_info.bmWidth, CV_8UC4, pData);
Mat src = imread(...);
Rect rect;
rect.x = 640, rect.y = 196, rect.width = 166, rect.height = 180;
// 此时des就是xrc中rect对应的矩形区域
Mat des(src, rect);
这里直接使用opencv的函数即可:
bool bRet = false;
int match_method = TM_SQDIFF;
/// 创建输出结果的矩阵
int result_cols = src.cols - temp.cols + 1;
int result_rows = src.rows - temp.rows + 1;
Mat result;
result.create( result_cols, result_rows, CV_32FC1 );
/// 进行匹配和标准化
matchTemplate( src, temp, result, match_method );
normalize( result, result, 0, 1, NORM_MINMAX, -1, Mat() );
/// 通过函数 minMaxLoc 定位最匹配的位置
double minVal; double maxVal; Point minLoc; Point maxLoc;
Point matchLoc;
minMaxLoc( result, &minVal, &maxVal, &minLoc, &maxLoc, Mat() );
/// 对于方法 SQDIFF 和 SQDIFF_NORMED, 越小的数值代表更高的匹配结果. 而对于其他方法, 数值越大匹配越好
if( match_method == TM_SQDIFF || match_method == TM_SQDIFF_NORMED )
{
matchLoc = minLoc;
float fd = result.at(matchLoc);
bRet = (fd < 0.1);
}
else
{
matchLoc = maxLoc;
float fd = result.at(matchLoc);
bRet = (fd > 0.9);
}
if (bRet)
{
out.left = matchLoc.x, out.top = matchLoc.y, out.right = matchLoc.x + temp.cols, out.bottom = matchLoc.y + temp.rows;
}
注意: 这个函数自己就存在问题,在某种情况下,它会出现找不到模板的异常的情况,这个可以从opencv官方的文档上发现并自行设计修改方案。
目前来看,文字提取基本上是tesseract这套东西了。
char* outText;
tesseract::TessBaseAPI* api = new tesseract::TessBaseAPI();
if (api->Init(NULL, "mhxyeng"))
{
fprintf(stderr, "Could not initialize tesseract.\n");
exit(1);
}
string str("F:\\work\\ocr\\tesseract_test\\Debug\\ocr.bmp");
int w = 0, h = 0, bits = 0;
void* pData = NULL;
Pix* image = read_bmp(str.c_str(), w, h, bits, &pData);
api->SetImage(image);
outText = api->GetUTF8Text();
printf("OCR output length = %d:\n%s", strlen(outText), outText);
这部分自行查找对应资料即可。
根据方案的不一样,我们会设计不同的鼠标移动方案,这里简单的分为运控方式和HID方式吧,由于这个问题要清晰的解释其实会造成整个文档的臃肿,所以这里我们简单的解释原理即可。
运控方案
机械手方案并不是那种常见的机械臂,比如说下面这种:
这种是工业用的机械臂,价格都不需要标出来,它的稳定性、负载 、精度都是工业级的,但是,我知道在辅助这个领域得到应用的,硬件成本是200~800元,毕竟一个游戏帐号每天的就能赚只是十几块钱,它是靠无限的叠加数量的。
所以用的是下面这种:
这种电机十几块钱,这种东西没什么精度可言,不过辅助的话,精度在毫米级也没事,我感觉用个一年也会坏了吧?我没有具体试过。一般就是几个这个电机搭配一下,然后找人设计个简单的结构,这个和工业自动化还是有一点区别的。
有了电机和结构件,还需要运动控制器和计算机,一般这种情况就是弄个最便宜控制器和最便宜的linux开发板,价格在100元上下那种,这样的话成本就是80 + 16 *4 + 100 = 244,再加56块钱作为一些散件的钱差不多了,所以300的成本搭建一整套理论上是可行的。
一般来说3个轴就够了,使用x、y、z即可,当把手机固定放在平台上的时候,我们往往会以某个位置来作为设备原点对应下的手机原点,这其实就是三个坐标系: 设备坐标系、手机屏幕坐标系、游戏空间坐标系。
在这里我们先假设这个硬件平台已经搭建成功了,同时在linux端已经完成了对控制卡的编程,封装了一个SDK。
这种SDK的接口其实非常简单:
// 初始化运控系统
int init_motion_system(ip_addr, port);
// 释放运控系统
int uninit_motion_system(ip_addr, port);
// 全局设备回零
int all_axis_go_home();
typedef int (*PCALLBACK)(int flags, void *pParam);
// 设置状态回调,在运动状态变化时候触发回调
void set_motion_call_back(PCALLBACK func, void *pUserData);
// 获取当前z轴的设备坐标
int get_z_points(int &x, int &y);
// 设置当前z轴的设备坐标
int set_z_points(int x, int y);
// 对z轴发送按下指令,模拟单击
int send_click();
/*
注意:
1. 真实的运控系统本身有一大波参数的设定,例如轴的加减速、限位等等,但是这里完全可以使用固定值;
2. 实际上我们是以xy联动的方式,就是每次移动都考虑x和y同时移动,当x为0的时候就是x不移动;同时z轴控制点击的时候最好使用触屏笔先测试好参数,不要把屏幕戳坏了。
3. 此时一定要建立坐标系和坐标转换,因为这样我们可以不考虑当前位置,直接给出目标位置即可;轴的运动有绝对运动、相对运动、坐标运动,我们简陋的机构最好还是绝对运动,编程将要移动的位置转换为脉冲让轴走脉冲就好;
4. 如果使用好的控制卡是可以实现联动的,但是实现太麻烦的话,先走x再走y也可以,速度其实并不慢,和人手移动差不多。
*/
在前面第三步,使用道具中,我们可以先查找红色旗子的位置,图像查找函数会返回模板的图像坐标(x0, y0),我们先将坐标转换为手机原点的相对坐标(x1, y1),再将基于手机原点的坐标转换为设备坐标(x2, y2),然后获取当前的z轴坐标(x3,y3),我们就可以获取到z轴需要移动多少距离才能到(x2, y2),这个距离除与脉冲比可以得到脉冲数,也就是调用set_z_points时候传递的参数。
HID设备
HID设备会简单许多,可以使用软件的HID方案或者硬件的HID方案,这种方案其实已经非常成熟了,就不赘述了。
实际上,有开源的单片机鼠标方案,不过鼠标宏的硬件成本其实也不便宜,也要两百的。
子功能的封装这部分文档其实很难写,因为这套辅助方案,最麻烦的就是技术的广度和深度不好把控,一些细节问题如果延伸,是一件很麻烦的事情,这个文档系列类似于一个技术方案如何变为一个产品,这部分不是简简单单放出代码就可以解决的问题。
这个文档接近7000字,但是5个基本功能可能只讲解了20%,如果讲解非常仔细,可能需要四五万字才能将这些技术的来源和实现讲清楚,当然代码并没有那么多,我统计了我的方案里面的非开源部分的代码,大概只有3000不到。
要知道技术方案往往只考虑可行性,例如在把鼠标移动到特定位置这部分,如果讨论鼠标的制作,其实有大量的开源项目可以参考,但是如果要把一个技术方案转变为可以商用的产品,这里面技术方案仅仅占一小部分,要考虑产品的稳定性、成本、生产、装配、利润等各方面的,技术可能是核心,但是它的内容只占20%。