用Python非侵入式自动化原版Minecraft

似乎博客又有一个月没有更了呢而且一个月前挖的坑似乎还没有填完? 持续咕咕咕

我前几天玩Minecraft挖矿的时候我就一直在思考一个问题

如何自动化MC里面像挖矿这么无脑的操作呢?

其实现成的想法还是很多的:

  1. 用粘液块和红石(Slimestone)实现半自动化(例如SciCraft里面的盾构)。SciCraft用这个方法挖了103000颗钻石,优点是纯原版,缺点是其实还是很肝,而且技术性非常强。
  2. 利用mod的解决方案:像IC2,Mekanism这些mod其实都有自动化的解决方案。优点是比较简单。缺点是非原版,通用性比较差,而且自动挖矿在这些mod里面基本上也是大后期了,搭这一套措施很肝。
  3. 利用客户端mod的解决方案:利用FreeCam或者透视这种客户端mod可以在服务端原版的情况下达到类似自动化的效果,但是缺点是这个是作弊。
  4. Carpet Mod:这个mod的/player命令是在服务端生成一个假的玩家实体并进行操作,好处就是简单,快捷,不会掉线。而且Carpet现在搭了Fabric的顺风车更新得非常勤快。缺点就是服务端要装Carpet,如果不是自己开服基本用不了,而且说实话Carpet的自动化比较简单(本来是配合SciCraft那一套设计的)。
  5. MineRL:直接利用强化学习解决一切自动化问题。优点是几乎啥都可以干,缺点是技术含量非常非常高,而且训练的硬件要求非常高。说实话我觉得是overkill。

这些方案各有优缺点,其实对于我这种玩偶尔玩第三方服务器的人2、3、4基本上就可以否决掉了,1的话门槛比较高,5的话没有那硬件资源。所以这几天我自己试验了一个适合我需求的方案,还算不错。

初步设计

简单来说呢,我的想法是这个样子的

  1. 不断截取游戏窗口
  2. 通过OCR读取F3界面的字符
  3. 获得玩家的位置
  4. 通过闭环控制器控制玩家行动
  5. 通过模拟键盘和鼠标事件来进行实际的控制

这个解决方案和MineRL靠的比较近,都是比较贴近于实际玩游戏的过程。我最看重的一个有点就是这个解决方案完全不需要修改客户端,而且因为F3界面格式万年不变而且从很早版本就有了导致这个解决方案的版本兼容性超强,即使是玩第三方服务器第三方客户端也丝毫不虚。那么剩下来的就是用Python实现这一连串过程了。

用到的第三方库

import cv2 as cv
import numpy as np

import win32gui
import win32ui
import win32con

import keyboard
import mouse

上面是图像处理标准操作,中间是Win32标准操作,下面是鼠标和按键模拟用的(其实用win32api也可以解决,但是我还是想保留一点OS兼容性)。

游戏窗口截取

因为我用的是Windows所以这个部分理所应当地用Pywin32来做,我个人对于Win32 API不是特别地熟悉(常年面向MSDN编程),Pywin32做接口之后就更搞不清楚了,于是上网上东抄抄西抄抄就做完了。我把这部分代码理了一下,通用性还是比较高的。

Hwnd = int

def get_window_with_title(keyword: str) -> Hwnd:
    """
    Gets the first window whose title contains the keyword, if any
    !!! WIN 32 ONLY !!!
    :param keyword: the keyword
    :return: the first window whose title contains keyword, None if not found
    """

    def callback(hwnd: Hwnd, extra: List[Hwnd]):
        if win32gui.IsWindowVisible(hwnd) and win32gui.IsWindowEnabled(hwnd) \
                and keyword in win32gui.GetWindowText(hwnd):
            extra.append(hwnd)

    ret: List[Hwnd] = []
    win32gui.EnumWindows(callback, ret)
    return ret[0] if ret else None


def capture_screen_of(hwnd: Hwnd, rect=None) -> np.ndarray:
    """
    Capture screen of hwnd, using win32 API
    !!! WIN 32 ONLY !!!
    :param hwnd: the window handle
    :param rect: the rectangle, default value None, which means to capture the whole window
    :return: the screen capture (RGBA) in numpy array format
    """
    window_dc = win32gui.GetWindowDC(hwnd)
    dc_object = win32ui.CreateDCFromHandle(window_dc)
    compat_dc = dc_object.CreateCompatibleDC()
    bitmap = win32ui.CreateBitmap()
    if not rect:
        rect = win32gui.GetWindowRect(hwnd)
        rect = (0, 0, rect[2] - rect[0], rect[3] - rect[1])
    width, height = rect[2] - rect[0], rect[3] - rect[1]
    start = (rect[0], rect[1])
    bitmap.CreateCompatibleBitmap(dc_object, width, height)
    compat_dc.SelectObject(bitmap)
    compat_dc.BitBlt((0, 0), (width, height), dc_object, start, win32con.SRCCOPY)
    img = np.frombuffer(bitmap.GetBitmapBits(True), dtype='uint8')
    img.shape = (height, width, 4)
    dc_object.DeleteDC()
    compat_dc.DeleteDC()
    win32gui.ReleaseDC(hwnd, window_dc)
    win32gui.DeleteObject(bitmap.GetHandle())
    return img

(出于常年写静态语言的习惯还是加上了type annotation)

OCR

这是最头大的部分了,我一开始的想法非常天真:Tesseract OCR,G家这么好的轮子不用干啥。结果

  1. Tesseract下载下来好几十MB,这瞬间让整个项目的dependencies复杂了起来。
  2. Tesseract似乎很慢,跑一次F3界面要跑0.5秒,我这算法要是按照2FPS的速度跑那玩家的反射弧要长出天际了。其实考虑到Tesseract很多都是在用LSTM这种速度也不奇怪。
  3. Tesseract对于Minecraft默认像素风字体的支持不好。如果要提升效果就要额外train。

这三个因素加起来最终还是把Tesseract枪毙了。Python常见的OCR解决方案似乎只有这一家,所以只能自己设计OCR算法了。

其实在我的这个场景里OCR的要求不高,因为

  1. 因为是截屏所以像素亮度很平整,也不会有扭曲或者畸变
  2. 我可以获取到Minecraft的字体文件

这两个因素在一起让这里的OCR退化成了template matching。

从客户端jar文件里提取出来字体文件本身不是一件特别复杂的事,唯一可能复杂一点的地方就是要把字体位图当中透明色变成灰色:

font_atlas_file = "unicode_page_00.png" # Minecraft的Unicode字体位图
font_atlas = cv.imread(font_atlas_file, cv.IMREAD_UNCHANGED)
font_atlas = cv.threshold(font_atlas[:, :, 3], 127, 255, cv.THRESH_BINARY)[1]

接下来是文字提取。经过我的测试Minecraft的F3界面文字在灰度图像下的前景色在220-225左右非常固定,而且文字背后半透明灰色的遮罩使文字的周围不会出现干扰。这样提取文字部分只需要threshold一下就解决了,运用OpenCV的代码非常简短:

f3_color = (221, 225)
...
# cap是截图
img = cv.cvtColor(cap, cv.COLOR_BGR2GRAY if cap.shape[2] == 3 else cv.COLOR_BGRA2GRAY)
img = cv.threshold(img, f3_color[1], 255, cv.THRESH_TOZERO_INV)[1]
img = cv.threshold(img, f3_color[0] - 1, 255, cv.THRESH_BINARY)[1]

接下来只要对于字符集当中的每一个文字,从字体位图里面找到点阵字体然后跑模板匹配就行了

chars: List[Tuple[int, int, str]] = []
for ch in charset:
    font = bitmap_fonts[ord(ch)]
    x = cv.matchTemplate(img, font, cv.TM_CCORR_NORMED)
    # noinspection PyTypeChecker
    chars.extend(zip(*np.where(x > match_threshold), repeat(ch)))

最后是把识别出来的单个字符串联成行。这一部分的主要思路是把纵坐标在一定范围内的字符按照横坐标排序然后顺次拼接就行了。下面的代码还可以做亿点优化,但是这一部分不是本算法的瓶颈(上面的模式匹配才是),所以我懒了:

img_width = img.shape[1]
chars.sort(key=lambda tup: tup[0] * img_width + tup[1])
last_y = -1 - max_line_height_deviation
lines: List[List[Tuple[int, int, str]]] = []
for ch in chars:  
    if ch[0] - last_y > max_line_height_deviation:
        last_y = ch[0]
        lines.append([])
    lines[-1].append(ch)
ret: List[str] = []
for line in lines: 
    line_str: str = ""
    error = True
    for (i, ch) in enumerate(line):
        if i > 0 and ch[1] - line[i - 1][1] > whitespace_width:  
            line_str += ' '
        line_str += ch[2]
        error &= ch[2] in ['.', '-', '_']  
    if not error:
            ret.append(line_str)
return ret

注意要集中处理的是有些字符会因为字体的原因容易被错误识别,例如., _, -(后两个在MC的字体里面根本就是一样的)。这一部分要去除。同时因为空格无论怎么样都不会被模板匹配识别出来,所以要通过字间距的差别来插入。

这个算法的运行时间是和字符集大小成正比的,因此字符集的精简很重要。在这里我其实只采用了0123456789XYZFLa./-这些字符,这些数字和符号确保可以正常识别数字,XYZ确保可以识别坐标,Fa确保可以识别朝向(Facing),L确保可以识别指向(Looking at)。总体来说这一部分的算法虽然简陋但是堪堪够用。在精简字符集后可以跑到20FPS,已经是Tesseract的10倍多了。

在试验过程中比较有意思的一个发现是Minecraft的默认ASCII字体渲染是一个比较复杂的过程,似乎不是按照ascii.png上的内容直接来的(我的理解是似乎加了一些hint),导致模板匹配的思路在ASCII字体的时候是行不通的,必须在设置里面强制开启Unicode字体。

读取玩家信息

在OCR之后读取玩家信息就是水到渠成的事情了。我的OCR已经把行都划好了,只需要选取特定字符开头的行做一下字符串分割和parse就完成了。

闭环控制

最后一步当然是通过一个闭环控制器来控制玩家的运动。这一部分出于我在机器人社的经验我糊了一个P controller ID部分懒得写了来控制玩家的朝向,再写了个bang bang control来控制玩家的XZ坐标(在生存模式下Y坐标一般来说是控制不了的)。稍微比较烦的地方就是MC对于方位角的表示方式,需要一些分类讨论和转换。控制频率100Hz,效果差强人意,已经足够作为一个proof-of-concept了:

def control_thread_func(coord_tol: float = 0.5, yaw_tol: float = 2, pitch_tol: float = 2):
    """
    The control thread. If abs(error_target) <= x_target, then the target will be considered reached
    :param coord_tol: coordinate tolerance
    :param yaw_tol: yaw tolerance
    :param pitch_tol: pitch tolerance
    :return: runs forever
    """
    kp_yaw, kp_pitch = 0.5, 0.4 
    global target_yaw
    global target_pitch
    global reached_yaw
    global reached_pitch
    global reached_coord
    w, a, s, d = False, False, False, False # 记录WASD四个键状态
    move_threshold = 0.5 # 如果在一个坐标分量上的偏差小于这个值那就不按这个分量上的按键了
    turn_threshold = 1.5 # 每一次鼠标至少要动这么多

    def key_cond(val, key, cond):
        if val and not cond:
            keyboard.release(key)
            return False
        if not val and cond:
            keyboard.press(key)
            return True
        return val

    while True:
        if not in_control or not player_info:
            time.sleep(0.05)
            continue

        yaw, pitch = player_info.facing
        coord = xyz2xz(player_info.coord)

        # 坐标控制
        error_coord = math.hypot(target_coord[0] - coord[0], target_coord[1] - coord[1])
        reached_coord = error_coord < coord_tol
        theta = math.atan2(target_coord[0] - coord[0], target_coord[1] - coord[1])
        ws = math.cos(theta + math.radians(yaw)) * error_coord
        ad = math.sin(theta + math.radians(yaw)) * error_coord
        w = key_cond(w, "w", ws > move_threshold)
        s = key_cond(s, "s", ws < -move_threshold)
        a = key_cond(a, "a", ad > move_threshold)
        d = key_cond(d, "d", ad < -move_threshold)

        # 摄像头朝向控制
        target_pitch = max(-90, min(90, target_pitch))
        if auto_yaw and not reached_coord: 
            target_yaw = -math.degrees(math.atan2(target_x - x, target_z - z))
        if target_yaw > 180:
            target_yaw -= 180
        if target_yaw < -180:
            target_yaw += 180
        error_yaw = target_yaw - yaw
        if abs(error_yaw - 360) < abs(error_yaw):
            error_yaw -= 360
        if abs(error_yaw + 360) < abs(error_yaw):
            error_yaw += 360
        error_pitch = target_pitch - pitch
        reached_yaw = abs(error_yaw) < yaw_tol
        reached_pitch = abs(error_pitch) < pitch_tol
        if not reached_yaw:
            d_yaw = kp_yaw * error_yaw
            if abs(d_yaw) < turn_threshold:
                d_yaw = math.copysign(turn_threshold, error_yaw)
        else:
            d_yaw = 0
        if not reached_pitch:
            d_pitch = kp_pitch * error_pitch
            if abs(d_pitch) < turn_threshold:
                d_pitch = math.copysign(turn_threshold, error_pitch)
        else:
            d_pitch = 0
        mouse.move(d_yaw, d_pitch, absolute=False)

        time.sleep(0.01)

测试

写完之后我写了一个简单的挖矿脚本,可以挖1x2的矿道并且插上火把点亮:

def wait_turn():
    time.sleep(0.01)
    while player_info and (not reached_yaw or not reached_pitch):
        time.sleep(0.05)


def wait_coord():
    time.sleep(0.01)
    while player_info and not reached_coord:
        time.sleep(0.05)

def start_mining():
    global in_control
    global target_coord
    global target_yaw
    global target_pitch
    if player_info is None:
        return
    if player_info.target_block is None:
        return
    direction = xyz2xz(player_info.target_block - np.floor(player_info.coord))
    target_coord = xyz2xz(player_info.coord)
    target_yaw = -math.degrees(math.atan2(direction[0], direction[1]))
    in_control = True
    target_pitch = 0
    moved = 0
    wait_turn()
    try:
        while True:
            moved += 1
            mouse.press() # 开始挖摄像头正对的方向
            while np.linalg.norm(xyz2xz(player_info.target_block - np.floor(player_info.coord))) < 1.:
                time.sleep(0.01)
            mouse.release() # 挖完了
            if moved % 5 == 0: # 每挖五格就插一个火把(在副手)
                target_yaw += 30
                wait_turn()
                mouse.right_click()
                time.sleep(0.1)
                target_yaw -= 30
            target_pitch = 40 # 低头
            wait_turn()
            mouse.press() # 开始挖下面的那一格
            while np.linalg.norm(xyz2xz(player_info.target_block - np.floor(player_info.coord))) < 1.:
                time.sleep(0.01)
            mouse.release()
            target_coord = xyz2xz(np.floor(player_info.coord)) + np.array([0.5, 0.5]) + 0.7 * direction # 前进!
            target_pitch = 0 # 双眼平视前方
            wait_turn()
            wait_coord()
    except Exception:
        in_control = False
        mouse.release()
        print("exit")
        return

这个脚本运行的非常顺畅(虽然并不总是成功,这和玩家坐标控制的精度有很大关系)。已经初步有了实用价值。看起来我这个技术路线总体还是可行的。当然现在的功能非常简陋,但是F3的信息非常丰富,如果拓展一下用途还是非常丰富的。当然最重要的还是这个脚本可以适用于所有有F3界面的Minecraft版本,版本兼容性堪称完美,爽到。

写这个脚本花了2个小时,就是为了挖一个简单的矿,这么算来似乎有点亏?!