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

我前几天玩 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 个小时,就是为了挖一个简单的矿,这么算来似乎有点亏?!