本篇分享PUBG自动识别+压枪宏实现的完整思路,同样的思路可套用在其他FPS游戏上,开发语言使用Python3.9。
项目完整代码见:https://github.com/Cjy-CN/PUBGRecognizeAndGunpress
自动压枪简单的理解就是控制鼠标下移。但是,鼠标下移量的多少却是多方面因素的共同影响结果,以PUBG(绝地求生)为例,每一发子弹射出时的后座,除了每把枪的弹道表,还与枪械配件(倍镜、握把、枪托)、射击时的人物姿势(站、蹲、趴)、开火状态(全自动、连发、单发)相关联。因此,要实现压枪的需求,就必须解决背包中的配枪、配件识别,按下开火键时的人物姿势、开火模式识别,最后才是如何调用硬件驱动以实现游戏内鼠标指针下移操作。
由于绝地求生屏蔽了硬件驱动外的其他鼠标输入,因此我们无法直接通过py脚本来控制游戏内鼠标操作。为了实现游戏内的鼠标下移,我使用了罗技鼠标的驱动(ghub),而py通过调用ghub的链接库文件,将指令操作传递给ghub,最终实现使用硬件驱动的鼠标指令输入给游戏,从而绕过游戏的鼠标输入限制。值得一提的是,我们只是通过py代码调用链接库的接口将指令传递给罗技驱动的,跟实际使用的是何种鼠标没有关系,所以即便用户使用的是雷蛇、卓威、双飞燕等鼠标,对下面的代码并无任何影响。
【本章节代码对应项目中ghub.py模块】
罗技驱动使用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('缺少文件')
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模块】
这里我使用的钩子是Pynput,其他可使用的库还有Pyhook3
由于我们只需要对绝地求生这个窗口进行监听,而不是想在任何界面都去让我们的鼠标在按下左键时自动下移,因此还引入win32gui进行当前窗口的判断。
from pynput import mouse,keyboard
from win32gui import GetWindowText, GetForegroundWindow
在PUBG中,Tab键是打开背包,因此键盘对应检测Tab键。代码都很浅显。只有以下需要注意
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(打开背包)、鼠标左键按下时,为什么只是改变信号量,然后把这些任务丢给别的进程去做的原因。
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模块】
背包画面的截图如上,我们的识别工作其实就是对红框中的内容进行识别,我没有把弹夹框上是因为弹夹不影响后座力。
在选框上其实是有几个值得注意的地方:
(这里顺带提一下为什么不用ocr吧。我看其他人写配件识别都去调用opencv的ocr,这其实是一个很笨的写法,首先OCR是一个泛用的文字识别算法,解决的是字体、大小都不确定下的文字识别问题。但是,我们这个游戏内的枪械其实是一个十分有限的集合,枪也不过二三十把枪,枪械的字体、位置、大小都是固定的,因此完全可以把这些固定的字体当做图片,然后比较出最相似的类别来确定当前是什么枪。这样做除了更小的算力开销,识别的准确率也要高于通用的文字识别库)
在解决完背包点位信息的截取,下面来说说具体是怎么做识别
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
识别原理:缩小图片->转换灰度图->哈希计算
【这部分对应recognize.py下的get_firestate()函数】
开火状态识别主要是判断开火状态是什么姿势,有没有子弹,射击是全自动、单发还是连发。
以上的这几个信息,各位可以在开镜模式下,屏幕的正下方看到。
这一部分的代码在取检测点时不是使用比较图片的方式,而是直接在屏幕上取固定坐标的像素点,判断白色或者红色(红色是指子弹打完时子弹那里会有一个红色的0)。唯一注意一点,白色的话也不是全白,所以把像素点取下来后,计算灰度值(用RGB均值算)不要把判断设置成255的全白,差不多220、230就行了。具体代码不贴了,项目里也有,没什么内容可以介绍。
这里顺带提一个很好用的工具,微信截图。在整个项目的开发过程中,我频繁使用微信截图,因为不仅能判断当前鼠标的坐标,还能显示坐标点的RGB值。
【本章节代码对应项目gun_press.py模块】
这一部分就不是编程的工作了,都是苦力活。主要获取的内容是每发子弹射出间隔时间、每把枪打出第几发时对应的后坐力、各配件对后坐力的影响系数。对于弹道的获取这里给一个简单的实现思路
假设一把新的枪有40发,子弹间隔、弹道未知:
子弹间隔:记录下按下鼠标左键的时间和松开鼠标左键的时间,记住从按下到松开这段时间不要打空弹夹,就假设你打了15发,那么用(松开时间-按下时间)/15就是这把枪每发的间隔
弹道:直接预设40发的后座都是40,然后尝试压枪,压不住就加,压过了就减。
文章前面已经介绍了背包信息、状态信息的获取方法,键鼠钩子的设置以及压枪函数应该在一个独立的线程\进程内运行。
那么,首先要把前面步骤识别到的信息传进我们的压枪线程\进程里面
传进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=枪械子弹射击间隔时间’-每颗子弹压枪过程中代码执行时间’