一般电脑上的游戏操作主要依靠键盘和鼠标,而达成自动操作键鼠一般不会真正控制实体的键鼠,因为难度太大了,所以一般采用软件模拟的方式来控制虚拟的键盘和鼠标,最终目的都是让游戏系统准确的知道你的操作意图。
比较常用的虚拟键盘和鼠标的pip库是keyboard
和mouse
,小巧又不失强大,基本的控制、捕捉、热键设置都能实现。当然如果追求性能和游戏兼容性可以使用另一个库pydirectinput
,使用了Windows的DirectX驱动中的DirectInput API,基本所有windows平台的游戏都会用这个API来获取玩家的键鼠输入,所有这个库是键鼠操作的终极方案,因为大部分游戏不支持mouse
库直接移动角色的镜头视角。
不过pydirectinput
使用起来不那么方便,偶尔有些小bug,所以这个项目还是以keyboard
和mouse
为主。使用pip安装很简单:
pip install keyboard mouse
安装完成后在代码中导入库:
import keyboard
import mouse
游戏角色主要有改变镜头视角(鼠标移动)、移动(WASD)、采集/射击(鼠标左键)三个基本操作。像上面提到的,因为游戏使用了DirectInput来获取鼠标输入,鼠标移动的操作无法使用mouse
库的虚拟鼠标来完成,而且鼠标移动的控制算法相对复杂,本项目为了简化操作,使用模拟器的键盘映射来实现键盘控制角色视角,所以只需要keyboard
库即可执行所有操作。
视角的左右旋转和上下俯仰被映射到键盘上的↑ ↓ ← →
键,对应keyboard
库的up down left right
键值。角色的移动就是常用的WASD四键,对应keyboard
库的w a s d
键值。鼠标左键映射到键盘上的回车,对应keyboard
库的enter
键值。
keyboard
库的使用很简单,执行一次按键操作(以←
键为例):
keyboard.press_and_release('left')
一直按住不放:
keyboard.press('left')
松开按键:
keyboard.release('left')
结合time
模块,构造一个持续任意时间的按键操作的函数:
import time, keyboard
def pressKey(key,t):
keyboard.press(key)
time.sleep(t)
keyboard.release(key)
后面将以这个函数为基础控制镜头视角的移动,以实现将镜头中心瞄准到目标物体的中心。
上一篇中我们使用yolo目标检测识别出了游戏场景中的目标,现在我们就利用识别出的目标信息,将我们的视角瞄准到目标上,便于后续的采集或者射击等操作。
瞄准目标本质上是一个闭环控制过程,经典的闭环控制算法是比例积分微分控制(PID control),这里因为我们对瞄准的精度要求不是很高,可以只采用其中的比例控制来实现。比例控制算法简单而且响应快,缺点是无法精确控制而且容易发生震荡(oscillation)和超调(overshoot),这些可以通过加入积分和微分控制进行优化。而为了寻求最简方法,我们直接扩大目标的范围来避免震荡和超调。
整个控制逻辑非常简单,我们以屏幕中心点为控制目标(640x360),计算识别出的物体中心点与屏幕中心点的差值,通过左右移动角色视角,使这个差值控制在-50~50的范围以内,当然这个范围可以根据实际情况调整,范围越小越精确同时也越容易震荡和超调。识别出了多个物体的话按照近大远小原则,选出距离最近的也就是bbox框面积最大的物体。具体的控制动作为按下左键或者右键,通过控制按键的持续时间来控制视角移动量的大小,进而控制目标差值的大小。也就是按的时间越长视角移动越大,距离目标的差值越小,越接近目标。这个按键时间是按照设定的参数成比例变化的,所以叫比例控制,这个比例也根据实际情况调整(这里用0.0003),比例越大变化的越快,响应就越快,同时越容易震荡和超调。
有了基本逻辑,代码实现非常简单了:
while True:
img, _ = getScreenshot()
bboxes = getDetection(img)
bbox = getLargestBox(bboxes,['tree'])
if bbox.shape[0]!=0:
x0,y0,x1,y1 = int(bbox[0]),int(bbox[1]),int(bbox[2]),int(bbox[3])
cx = (x0+x1)/2
diff = cx-640
key = None
if diff>50:
key = 'right'
if diff<-50:
key = 'left'
if key!=None:
pressKey(key,abs(diff)*0.0003)
其中getScreenshot()
,getDetection()
,getLargestBox()
这几个函数的定义已经在上一篇的最后一章给出,这里不再赘述。
实机演示的效果如下(放慢0.5倍):
可以看到无论鼠标如何干扰,镜头中心始终都能瞄准到目标物的中心。
实现了目标瞄准之后,再进一步实现靠近目标然后执行采集的动作,就是一次完整的自动采集流程了。但是执行采集的时机需要恰到好处,也就是角色要靠得足够近目标时才可以执行采集的动作。这时就需要根据游戏画面中给出的反馈来进行以上判断,而这个游戏也恰巧提供了一个有效的视觉反馈我们可以充分利用。
以采集树木为例,当角色已经进入树木的采集范围时,画面右侧位置会弹出一个标明“树木”的标签同时下方显示“斧头”的标识,这时角色可以开始采集,而如果角色不在采集范围则没有这些标签也就不可以采集。以下对比图中可以很清楚看到这个现象:
这两种反馈只需要选一个来进行识别即可,因为“斧头”标识是半透明的图标识别难度较大,所以我们选择难度较小的“树木”标签进行识别。
识别“树木”标签有两种方式,一种是把标签作为图像进行二分类(有或无),另一种则是把标签作为文本进行文字识别(OCR)。二分类的方式需要采集多个场景的标签图像来手动打标并进行训练,比较费时而且鲁棒性较低。OCR则是一个相对成熟的领域,而且我们的标签文本是比较规整的字体,所以采用这种方式既不用训练准确率还更高。
OCR近几年发展非常迅速,从最早的TesseractOCR到现在国产开源的PaddleOCR准确率和效率一直在提升。PaddleOCR是基于百度飞桨(PaddlePaddle)深度学习框架的文字识别应用,其算法一种不断更新,有基于服务器的大模型也有端侧优化的轻量模型可选,准确率和识别速度都非常不错,而且对中文文本的支持非常好(毕竟国产的),因此我们就直接选用PaddleOCR来完成识别标签中文字的任务。
首先安装paddleocr
库:
pip install paddleocr
然后加载ocr模型(默认的轻量模型已经足够):
from paddleocr import PaddleOCR
ocr = PaddleOCR(use_angle_cls=False, lang="ch", show_log=False)
use_angle_cls=False
设置不用识别文本角度,因为我们的标签都是正的。
我们定义一个函数来获取ocr的识别结果,输入是图像数据:
def getOcrText(img):
result = ocr.ocr(img,cls=False)
return result
同样的,用cls=False
来设置不识别文本角度。
结合这个函数,我们构造一个判断标签是否存在的函数:
def getLabelExist(img,name):
result = getOcrText(img)
for re in result:
text = re[1][0]
if name==text:
return True
return False
这个函数对比了ocr的识别结果和输入的文字,如果匹配上了就输出True,否则为False。利用这个函数做判断再加上键盘的控制,就基本可以实现自动采集了。大体逻辑就是先截取完整图像中的标签区域,然后判断标签区域是否存在文字,如果不存在就一直按住W键前进直到该区域检测到标签文字,然后松开W键停下来并按下ENTER键来进行采集。代码非常简单,示例如下:
while True:
img, _ = getScreenshot()
label = img[330:380,800:960]
cut = getLabelExist(label,"树木")
if cut:
keyboard.release('w')
keyboard.press_and_release('enter')
else:
keyboard.press('w')
执行这段代码的实机演示如下(右下角是放大后的标签区域):
因为标签区域的尺寸很小,使用轻量ocr识别一帧所需的时间很少,博主的设备识别一帧仅需8ms,完全可以胜任实时检测的任务。
最后我们把瞄准目标和自动采集两个流程合并以后就可以实现自动随机采集我们指定的资源了。使用多线程执行这两个流程可以提高运行效率,实现的代码如下:
import numpy as np
import time
from screen import *
from control import *
from ocr import *
global label
global img_src
global cut
global bboxes
label = np.zeros((160,50),np.uint8)
bboxes = np.array([])
cut = False
def checkLabel():
global label
global cut
escape_cnt = 0
while True:
cut = getLabelExist(label,"树木")
if cut:
keyboard.release('w')
keyboard.press_and_release('enter')
escape_cnt = 0
else:
keyboard.press('w')
escape_cnt+=1
if escape_cnt%100==0:
keyboard.press_and_release('space')
if escape_cnt%500==0:
pressKey('left',0.5)
# if np.random.rand()<0.005:
# pressKey('left',0.5)
def aimTarget():
global bboxes
global cut
while True:
bbox = getLargestBox(bboxes,['tree'])
if bbox.shape[0]!=0 and not cut:
x0,y0,x1,y1 = int(bbox[0]),int(bbox[1]),int(bbox[2]),int(bbox[3])
cx = (x0+x1)/2
key = None
if cx-50>640:
key = 'right'
if cx+50<640:
key = 'left'
if key!=None:
pressKey(key,abs(cx-640)*0.0003)
time.sleep(0.03)
def getMonitor():
global img_src,label
while True:
img_src, _ = getScreenshot()
label = img_src[330:380,800:960]
if __name__ == '__main__':
t1 = threading.Thread(target=getMonitor,args=(),daemon=True)
t1.start()
t2 = threading.Thread(target=checkLabel,args=(),daemon=True)
t2.start()
t3 = threading.Thread(target=aimTarget,args=(),daemon=True)
t3.start()
cv2.namedWindow("",cv2.WINDOW_NORMAL)
cv2.resizeWindow("",960,540)
cv2.moveWindow("",1560,0)
while True:
img = img_src.copy()
bboxes = getDetection(img)
img = drawBBox(img.copy(),bboxes)
cv2.imshow("", img)
if cv2.waitKey(1) & 0xFF == ord("q"):
cv2.destroyAllWindows()
break
代码中使用了一个escape_cnt
计数器来触发跳跃和掉头操作,用来防止撞墙、卡在缝里或者被低矮物体挡住。最终演示视频展示在下面的链接
https://www.bilibili.com/video/BV18g411d74b/
可以看到基本可以实现随机采集树木,但是很明显效率并不算高,而且非常容易卡在一个地方很久才脱离,况且体力耗光时没有采取措施。再者说树木是这个场景中非常丰富的资源,才使得随机采集的几率较高,如果是石头或者浆果之类不是特别密集的资源,使用这种方法是基本采集不到的。
针对这些问题,我们应该继续优化我们的采集策略,来提高采集效率并在遇到情况时准确采取措施。