文章目录
前言
本篇分享pubg自动识别+压枪宏实现的完整思路,同样的思路可套用在其他FPS游戏上,开发语言使用python3.9。
项目完整代码见:https://github.com/Cjy-CN/pubgRecognizeAndGunpress
一、明确压枪宏的功能需求
自动压枪简单的理解就是控制鼠标下移。但是,鼠标下移量的多少却是多方面因素的共同影响结果,以pubg(绝地求生)为例,每一发子弹射出时的后座,除了每把枪的弹道表,还与枪械配件(倍镜、握把、枪托)、射击时的人物姿势(站、蹲、趴)、开火状态(全自动、连发、单发)相关联。因此,要实现压枪的需求,就必须解决背包中的配枪、配件识别,按下开火键时的人物姿势、开火模式识别,最后才是如何调用硬件驱动以实现游戏内鼠标指针下移操作。
二、实现游戏内的鼠标指针下移
由于绝地求生屏蔽了硬件驱动外的其他鼠标输入,因此我们无法直接通过py脚本来控制游戏内鼠标操作。为了实现游戏内的鼠标下移,我使用了罗技鼠标的驱动(ghub),而py通过调用ghub的链接库文件,将指令操作传递给ghub,最终实现使用硬件驱动的鼠标指令输入给游戏,从而绕过游戏的鼠标输入限制。值得一提的是,我们只是通过py代码调用链接库的接口将指令传递给罗技驱动的,跟实际使用的是何种鼠标没有关系,所以即便用户使用的是雷蛇、卓威、双飞燕等鼠标,对下面的代码并无任何影响。
【本章节代码对应项目中ghub.py模块】
1.驱动安装和链接库的加载
罗技驱动使用LGS_9.02.65_X64(请自行找资源安装,官网新版罗技驱动没找到对应的链接库文件),链接库文件在项目链接里面可以找到。下面是载入链接库的代码。
try:
gm = CDLL(r'./ghub_device.dll')
gmok = gm.device_open() == 1
if not gmok:
print('未安装ghub或者lgs驱动!!!')
else:
print('初始化成功!')
except FileNotFoundError:
print('缺少文件')
2.通过罗技驱动控制键鼠
demo代码如下:
#按下鼠标按键
def press_mouse_button(button):
if gmok:
gm.mouse_down(button)
#松开鼠标按键
def release_mouse_button(button):
if gmok:
gm.mouse_up(button)
#点击鼠标
def click_mouse_button(button):
press_mouse_button(button)
release_mouse_button(button)
#按下键盘按键
def press_key(code):
if gmok:
gm.key_down(code)
#松开键盘按键
def release_key(code):
if gmok:
gm.key_up(code)
#点击键盘按键
def click_key(code):
press_key(code)
release_key(code)
# 鼠标移动
def mouse_xy(x, y, abs_move = False):
if gmok:
gm.moveR(int(x), int(y), abs_move)
鼠标移动的函数中,xy就是移动的横纵距离。
鼠标点击函数中,传入参数1,2,3分别代表鼠标左、中、右键
键盘按键函数中,传入的参数采用的是键盘按键对应的键码
三、实现键盘、鼠标监听
前面说到,要实现压枪就要对各种配件、状态做出识别。那么在写识别的函数之前,我们先要解决的是何时识别的问题。如果识别使用多线程\多进程的一直持续检测,无疑是一种巨大的开销,因此就需要对键盘、鼠标的状态进行监听。只有按下特定按键时,才触发特定相应的识别请求。
【本章节代码对应项目中monitor.py模块】
1、引入库
这里我使用的钩子是Pynput,其他可使用的库还有Pyhook3
由于我们只需要对绝地求生这个窗口进行监听,而不是想在任何界面都去让我们的鼠标在按下左键时自动下移,因此还引入win32gui进行当前窗口的判断。
from pynput import mouse,keyboard
from win32gui import GetwindowText, GetForegroundWindow
2、键盘监听
在pubg中,Tab键是打开背包,因此键盘对应检测Tab键。代码都很浅显。只有以下需要注意
- Listener中绑定on_press和on_release的函数( on_key_press、on_key_release),它们返回False的时候是结束监听,下文鼠标监听的函数同理,所以不要随便返回False
- 键盘的特殊按键采用keyboard.Key.tab这种写法,普通按键用keyboard.KeyCode.from_char(‘c’)这种写法
def start_key_listen(dict):
def on_key_press(key):
nonlocal dict
if '绝地求生' in GetwindowText(GetForegroundWindow()):
if key == keyboard.Key.tab:
if dict['key_pressed']:
return True
dict['key_pressed'] = True
dict['bag_signal'] = True
elif key == keyboard.KeyCode.from_char('1'):
dict['switch'] = 0
elif key == keyboard.KeyCode.from_char('2'):
dict['switch'] = 1
if dict['fire_signal']:
# 开火过程中才对卧(z),蹲(c/ctrl),切换开火模式(b)的按键进行检测,检测到则更新当前开火状态
if key == keyboard.KeyCode.from_char(
'z') or key == keyboard.Key.ctrl or key == keyboard.KeyCode.from_char(
'b') or key == keyboard.KeyCode.from_char('c'):
firestate_struct = get_firestate()
dict['posture'] = firestate_struct.posture
dict['firemode'] = firestate_struct.firetype
def on_key_release(key):
nonlocal dict
dict['key_pressed'] = False
with keyboard.Listener(on_press=on_key_press, on_release=on_key_release) as listener:
listener.join()
传进start_key_listen的参数dict是由multiprocessing.Manager().dict()创建的多进程共享变量,该变量是进程安全的
pynput实现键盘监听需要我们先定义两个函数:一个是检测到按下按键触发的函数on_press,一个是检测到松开按键触发的函数on_release。然后把这两个函数作为创建Listener的两个参数。
这里有一点非常坑,on_press和on_release的参数只能有一个key,这个key就是对应键盘按下的哪颗按键。但这是不足以满足我们的需求的,因为我们应该在钩子函数内部,在按下指定按键时对信号量做出修改,但因为参数的限制,我们无法把信号量传进函数内部,这里我也是想了很久,最后才想到用嵌套函数的写法解决这个问题。
另外,钩子函数本身是阻塞的。也就是说钩子函数在执行的过程中,用户正常的键盘/鼠标操作是无法输入的。所以在钩子函数里面必须写成有限的操作(即O(1)时间复杂度的代码),也就是说像背包内配件及枪械识别,还有下文会讲到的鼠标压枪这类时间开销比较大或者持续时间长的操作,都不适合写在钩子函数里面。这也解释了为什么在检测到Tab(打开背包)、鼠标左键按下时,为什么只是改变信号量,然后把这些任务丢给别的进程去做的原因。
3、鼠标监听
def on_button_click(x,y,button,pressed):
global fire_signal
if button == button.left:
if pressed and '绝地求生' in GetwindowText(GetForegroundWindow()):
fire_signal = True
else:
fire_signal = False
else:
pass
return True
def start_mouse_listen():
with mouse.Listener(on_click=on_button_click) as listener:
listener.join()
四、自动识别枪械配件及关键画面信息
【本章节代码对应项目recognize.py模块】
1、背包信息的识别
背包画面的截图如上,我们的识别工作其实就是对红框中的内容进行识别,我没有把弹夹框上是因为弹夹不影响后座力。
在选框上其实是有几个值得注意的地方:
- 选的框在足够囊括画面信息的前提下应该越小越好。注意到,对于枪械的识别我的识别区域是枪械名字而不是枪械的图形,并且对于Beryl M762这种名字长的枪,我并没有把它的名字截全,这就是因为这个小框里的图片内容已经足够表达出这把枪械的完整信息。如果你详细查看了我github项目里面截取的demo图片,你会发现倍镜的图也没截全,准确的说只是截了倍镜的一部分。这是因为框越大,与枪械信息无关的图片内容在框中的占比就越大,这就是图片噪声,噪声越大的图片对于我采用简单算法完成图片识别工作的误差影响就越大。
- 一号武器位和二号武器位的图片区域应该对齐。这里说的“对齐”是指比如对于AKM这把枪,当它位于一号武器位和二号武器位的时候,你截出来的图片应该保证是一样的,不能有位置上的偏差。当然这个的根本原因是采用了较为简单的图形算法,而使用简单算法的目的是减少时间上的开销。
(这里顺带提一下为什么不用ocr吧。我看其他人写配件识别都去调用opencv的ocr,这其实是一个很笨的写法,首先OCR是一个泛用的文字识别算法,解决的是字体、大小都不确定下的文字识别问题。但是,我们这个游戏内的枪械其实是一个十分有限的集合,枪也不过二三十把枪,枪械的字体、位置、大小都是固定的,因此完全可以把这些固定的字体当做图片,然后比较出最相似的类别来确定当前是什么枪。这样做除了更小的算力开销,识别的准确率也要高于通用的文字识别库)
- 如刚刚上文所述,在开发阶段,先把枪、配件的图截一遍,保存到项目里面(为了提高识别的准确率,防止半透明背景的干扰,可以对图片进行一次自适应二值化处理),作为demo图片,用于之后与用户装备截图的信息进行比较。
- 比较用户装备截图与预存的demo图,选择最相似的作为比对结果,先上代码再解释
def current_equipment():
gun1 = ''
gun2 = ''
gun1_distance = 11 #武器识别的汉明距离阈值
gun2_distance = 11
# print('识别当前配枪')
gun_path = './picture/gun/' #预先截取的demo图片路径
equi1_path = './picture/equiment/im_png' #当前武器图片路径
equi2_path = './picture/equiment/im_png'
content = os.listdir(gun_path)
for each in content:
demopath = gun_path+each
tmp_dist1 = compare2pic(equi1_path,demopath,10)
tmp_dist2 = compare2pic(equi2_path, demopath, 10)
if tmp_dist1 < gun1_distance:
gun1 = str(each)[:-4]
gun1_distance = tmp_dist1
if tmp_dist2 < gun2_distance:
gun2 = str(each)[:-4]
gun2_distance = tmp_dist2
print('1号武器是:'+gun1)
print('2号武器是:' +gun2)
return [gun1,gun2]
def compare2pic(equi, demo, threshold):
equi_hash = get_hash(equi)
demo_hash = get_hash(demo)
distance = get_Hamming(equi_hash, demo_hash)
if distance <= threshold:
return distance
return threshold+1
#图像哈希
def get_hash(img):
hash = ''
image = Image.open(img)
image = np.array(image.resize((9, 8), Image.ANTIALIAS).convert('L'), 'f')
for i in range(8):
for j in range(8):
if image[i, j] > image[i, j + 1]:
hash += '1'
else:
hash += '0'
hash = ''.join(map(lambda x: '%x' % int(hash[x: x + 4], 2), range(0, 64, 4))) # %x:转换无符号十六进制
return hash
#汉明距离
def get_Hamming(hash1, hash2):
Hamming = 0
for i in range(len(hash1)):
if hash1[i] != hash2[i]:
Hamming += 1
return Hamming
识别原理:缩小图片->转换灰度图->哈希计算
- current_equipment():这个函数就是拿截取到的当前用户装备的图片,跟预先截取的目录里面的文件一个个进行比较。然后取汉明距离最小的作为比较结果(最相似的图片)。如果比较出来最小的汉明距离都比原先设置的比对阈值要大,那么就是没有匹配项。
- get_hash(img):在这个函数中,先是把读取的图片缩小为(9*8),使压缩后图片一个点可以代表原图多个点信息,增加运算效率的同时,也是压缩了原图半透明的背包界面背景,相当于降噪。然后将图片转换为灰度图,这里的功能也是进一步降低半透明背景的影响,并且由于我们检测的枪械名称是白色的,灰度化的处理也会强化了这部分白色枪械名称的信息。然后是哈希转化为二进制,这里用相邻点灰度比较的方法进行哈希,原理直观理解成丐版的边缘检测,很适用于pubg背包界面中枪械名称与背景对比大的场景和二值化后的图片比较。
2、开火状态识别
【这部分对应recognize.py下的get_firestate()函数】
开火状态识别主要是判断开火状态是什么姿势,有没有子弹,射击是全自动、单发还是连发。
以上的这几个信息,各位可以在开镜模式下,屏幕的正下方看到。
这一部分的代码在取检测点时不是使用比较图片的方式,而是直接在屏幕上取固定坐标的像素点,判断白色或者红色(红色是指子弹打完时子弹那里会有一个红色的0)。唯一注意一点,白色的话也不是全白,所以把像素点取下来后,计算灰度值(用RGB均值算)不要把判断设置成255的全白,差不多220、230就行了。具体代码不贴了,项目里也有,没什么内容可以介绍。
这里顺带提一个很好用的工具,微信截图。在整个项目的开发过程中,我频繁使用微信截图,因为不仅能判断当前鼠标的坐标,还能显示坐标点的RGB值。
五、实现压枪函数
【本章节代码对应项目gun_press.py模块】
1、获取弹道表、配件的参数
这一部分就不是编程的工作了,都是苦力活。主要获取的内容是每发子弹射出间隔时间、每把枪打出第几发时对应的后坐力、各配件对后坐力的影响系数。对于弹道的获取这里给一个简单的实现思路
假设一把新的枪有40发,子弹间隔、弹道未知:
子弹间隔:记录下按下鼠标左键的时间和松开鼠标左键的时间,记住从按下到松开这段时间不要打空弹夹,就假设你打了15发,那么用(松开时间-按下时间)/15就是这把枪每发的间隔
弹道:直接预设40发的后座都是40,然后尝试压枪,压不住就加,压过了就减。
2、编写压枪函数
文章前面已经介绍了背包信息、状态信息的获取方法,键鼠钩子的设置以及压枪函数应该在一个独立的线程\进程内运行。
那么,首先要把前面步骤识别到的信息传进我们的压枪线程\进程里面
传进fire的参数dict是由multiprocessing.Manager().dict()创建的多进程共享变量,该变量是进程安全的
def fire(dict):
Guns = []
while True:
if dict['bag_signal']:
if is_bag_open():
Guns = recognize_equiment()
dict['bag_signal'] = False
if dict['fire_signal']:
if not bullet_check():
continue
start_time = round(time.perf_counter(), 3) * 1000
firestate_struct = get_firestate()
dict['posture'] = firestate_struct.posture
dict['firemode'] = firestate_struct.firetype
if len(Guns) > dict['switch']:
gun = Guns[dict['switch']]
if gun.name == 'None':
continue
i = 0
if gun.single == False: #不是单发的枪
while True:
posture_ratio = gun.posture_states[dict['posture']]
down = gun.para_range[i] * posture_ratio * gun.k
i += 1
if i == gun.maxBullets or not dict['fire_signal']:
break
mouse_xy(0, down)
elapsed = (round(time.perf_counter(), 3) * 1000 - start_time)
sleeptime = gun.interval - elapsed
time.sleep(sleeptime/1000)
start_time = round(time.perf_counter(), 3) * 1000
这个是多进程写法的压枪,用dict传入信号,dict是一个字典,字典里包含了背包状态检测的信号,开火(按下左键的信号)。起初是用多线程写法,但多线程进游戏后鼠标会有移动缓慢的bug。
首先在该函数中拥有一个局部变量Guns,用来存储检测到背包打开后识别的枪械结果。
在开始压枪时,会先获取当前人物的身姿和开火状态,然后判断当前持枪,在确定手上有持枪后才会进入到压枪步骤
在压枪函数中,通过检测开火信号量来进入压枪的流程。压枪的总数值多少通过以下代码计算
down = gun.para_range[i] * posture_ratio * gun.k
gun.para_range[i]:是第i颗子弹的后座
posture_ratio:是当前的姿势系数
k:各配件系数累乘后的结果(k值计算在背包识别过程完成)
在压枪之后需要设置一个进程的sleeptime防止过度压枪
sleeptime=枪械子弹射击间隔时间’-每颗子弹压枪过程中代码执行时间’