8 January 2018

Пишем бота-кликера на Python для Lineage 2

PythonProgrammingGame development
Sandbox


Предисловие


Как можно развлечься в новогодние праздники? Поиграть в компьютерные игры? Нет! Лучше написать бота, который это будет делать за тебя, а самому пойти лепить снеговика и пить глинтвейн.


Когда-то в школьные годы был увлечен одной из популярных MMORPG — Lineage 2. В игре можно объединяться в кланы, группы, заводить друзей и сражаться с соперниками, но в общем игра наполнена однообразными действиями: выполнением квестов и фармом (сбор ресурсов, получение опыта).


В итоге решил, что бот должен решать одну задачу: фарм. Для управления будут использоваться эмулированные клики мыши и нажатия клавиш клавиатуры, а для ориентирования в пространстве — компьютерное зрение, язык программирования — Python.


Вообще, создание бота для L2 дело не новое и их готовых есть довольно много. Делятся они на 2 основные группы: те, которые внедряются в работу клиента и кликеры.


Первые — это жёсткий чит, в плане игры пользоваться ими слишком уж неспортивно. Второй вариант интереснее, учитывая, что его можно будет с некоторыми доработками применить к любой другой игре, да и реализация будет интереснее. Те кликеры, которых я находил, по разным причинам не работали, либо работали нестабильно.


Внимание: вся информация здесь изложена только в познавательных целях. Особенно для разработчиков игр, чтобы помочь им лучше бороться с ботами.


Итак, к делу.


Работа с окном


Тут все просто. Будем работать со скриншотами из окна с игрой.
Для этого определим координаты окна. С окном работаем с помощью модуля win32gui. Нужное окно определим по заголовку — “Lineage 2”.


Код методов получения положения окна
def get_window_info():
    # set window info
    window_info = {}
    win32gui.EnumWindows(set_window_coordinates, window_info)
    return window_info

# EnumWindows handler
# sets L2 window coordinates
def set_window_coordinates(hwnd, window_info):
    if win32gui.IsWindowVisible(hwnd):
        if WINDOW_SUBSTRING in win32gui.GetWindowText(hwnd):
            rect = win32gui.GetWindowRect(hwnd)
            x = rect[0]
            y = rect[1]
            w = rect[2] - x
            h = rect[3] - y
            window_info['x'] = x
            window_info['y'] = y
            window_info['width'] = w
            window_info['height'] = h
            window_info['name'] = win32gui.GetWindowText(hwnd)
            win32gui.SetForegroundWindow(hwnd)

Получаем картинку нужного окна с помощью ImageGrab:


def get_screen(x1, y1, x2, y2):
    box = (x1 + 8, y1 + 30, x2 - 8, y2)
    screen = ImageGrab.grab(box)
    img = array(screen.getdata(), dtype=uint8).reshape((screen.size[1], screen.size[0], 3))
    return img

Теперь будем работать с содержимым.


Поиск монстра


Самое интересное. Те реализации, которые я находил, мне не подошли. Например, в одном из популярных и даже платном это сделано через игровой макрос. И “игрок” должен для каждого типа монстра прописывать в макросе типа “/target Monster Name Bla Bla”.


В нашем случае мы последуем такой логике: в первую очередь найдём все тексты белого цвета на экране. Белый текст может быть не только названием монстра, но и именем самого персонажа, именем NPC или других игроков. Поэтому надо навести курсор на объект и если появится подсветка с нужным нам паттерном, то можно атаковать цель.


Вот исходная картинка, с который будем работать:



Закрасим черным своё имя, чтобы не мешало и переведем картинку в ч/б. Исходная картинка в RGB — каждый пиксель это массив из трёх значений от 0 до 255, когда ч/б — это одно значение. Так мы значительно уменьшим объем данных:


img[210:230, 350:440] = (0, 0, 0)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)


Найдем все объекты белого цвета (это белый текст с названиями монстров)


ret, threshold1 = cv2.threshold(gray, 252, 255, cv2.THRESH_BINARY)


Морфологические преобразования:


  1. Фильтровать будем по прямоугольнику размером 50x5. Такой прямоугольник подошел лучше всех.
  2. Убираем шум внутри прямоугольников с текстом (по сути закрашиваем всё между букв белым)
  3. Еще раз убираем шум, размывая и растягивая с применением фильтра

kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (50, 5))
closed = cv2.morphologyEx(threshold1, cv2.MORPH_CLOSE, kernel)
closed = cv2.erode(closed, kernel, iterations=1)
closed = cv2.dilate(closed, kernel, iterations=1)


Находим середины получившихся пятен


(_, centers, hierarchy) = cv2.findContours(closed, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

Работает, но можно сделать прикольнее (например, для монстров, имена которых не видны, т.к. находятся далеко) — с помощью TensorFlow Object Detection, как тут, но когда-нибудь в следующей жизни.


Теперь наводим курсор на найденного монстра и смотрим, появилась ли подсветка с помощью метода cv2.matchTemplate. Осталось нажать ЛКМ и кнопку атаки.


Клик


С поиском монстра разобрались, бот уже может найти цели на экране и навести на них мышь. Чтобы атаковать цель, нужно кликнуть левой кнопкой мыши и нажать «атаковать» (на кнопку «1» можно забиндить атаку). Клик правой кнопкой мыши нужен для того, чтобы вращать камеру.


На сервере, где я тестировал бота, я вызвал клик через AutoIt, но он почему-то не сработал.


Как оказалось, игры защищаются от автокликеров разными способами:


  • поиск процессов, которые эмулируют клики
  • запись кликов и определение, какого цвета объект, на который кликает бот
  • определение паттернов кликов
  • определение бота по периодичности кликов

А некоторые приложения, как клиент этого сервера, могут определять источник клика на уровне ОС. (будет здорово, если кто-нибудь подскажет как именно).


Были перепробованы некоторые фреймворки, которые могут кликать (в т.ч. pyautogui, robot framework и что-то еще), но ни один из вариантов не сработал. Проскользнула мысль соорудить устройство, которое будет нажимать кнопку (кто-то даже так делал). Похоже, что нужен клик максимально хардварный. В итоге стал смотреть в сторону написания своего драйвера.


На просторах интернета был найден способ решения проблемы: usb-устройство, которое можно запрограммировать на подачу нужного сигнала — Digispark.


Ждать несколько недель с Алиэкспресса не хочется, поэтому поиски продолжились.


В итоге была найдена замечательная библиотека на C
Нашлась для неё и обёртка на Python


Библиотека у меня не завелась на питоне 3.6 — вываливалась ошибка Access violation что-то там. Поэтому пришлось соскочить на питон 2.7, там всё заработало like a charm.


Движение курсора


Библиотека может посылать любые команды, в том числе, куда переместить мышь. Но выглядит это как телепортация курсора. Нужно сделать движение курсора плавным, чтобы нас не забанили.


По сути задача сводится к тому, чтобы перемещать курсор из точки A в точку B с помощью обертки AutoHotPy. Неужели придется вспоминать математику?


Немного поразмыслив, всё-таки решил погуглить. Оказалось, что ничего придумывать не надо — задачу решает алгоритм Брезенхэма, один из старейших алгоритмов в компьютерной графике:


Прямо с Википедии можно взять и реализацию


Логика работы


Все инструменты есть, осталось самое простое — написать сценарий.


  1. Если монстр жив, продолжаем атаковать
  2. Если нет цели, найти цель и начать атаковать
  3. Если не удалось найти цель, немного повернемся
  4. Если 5 раз никого не удалось найти — идём в сторону и начинаем заново

Из более-менее интересного опишу, как я получал статус здоровья жертвы. В общих чертах: находим по паттерну с помощью OpenCV элемент управления, показывающий статус здоровья цели, берём полоску высотой в один пиксель и считаем в процентах, сколько закрашено красным.


Код метода получения уровня здоровья жертвы
def get_targeted_hp(self):
        """
        return victim's hp
        or -1 if there is no target
        """

        hp_color = [214, 24, 65]
        target_widget_coordinates = {}
        filled_red_pixels = 1

        img = get_screen(
            self.window_info["x"],
            self.window_info["y"],
            self.window_info["x"] + self.window_info["width"],
            self.window_info["y"] + self.window_info["height"] - 190
        )

        img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

        template = cv2.imread('img/target_bar.png', 0)
        # w, h = template.shape[::-1]

        res = cv2.matchTemplate(img_gray, template, cv2.TM_CCOEFF_NORMED)
        threshold = 0.8
        loc = np.where(res >= threshold)
        if count_nonzero(loc) == 2:
            for pt in zip(*loc[::-1]):
                target_widget_coordinates = {"x": pt[0], "y": pt[1]}
                # cv2.rectangle(img, pt, (pt[0] + w, pt[1] + h), (255, 255, 255), 2)

        if not target_widget_coordinates:
            return -1

        pil_image_hp = get_screen(
            self.window_info["x"] + target_widget_coordinates['x'] + 15,
            self.window_info["y"] + target_widget_coordinates['y'] + 31,
            self.window_info["x"] + target_widget_coordinates['x'] + 164,
            self.window_info["y"] + target_widget_coordinates['y'] + 62
        )

        pixels = pil_image_hp[0].tolist()
        for pixel in pixels:
            if pixel == hp_color:
                filled_red_pixels += 1

        percent = 100 * filled_red_pixels / 150
        return percent

Теперь бот понимает, сколько HP у жертвы и жива ли она еще.


Основная логика готова, вот как теперь он выглядит в действии:
Для занятых я ускорил на 1.30



Остановка работы


Вся работа с курсором и клавиатурой ведется через объект autohotpy, работу которого в любой момент можно остановить нажатием кнопки ESC.


Проблема в том, что всё время бот занят выполнением цикла, отвечающим за логику действий персонажа и обработчики событий объекта и autohotpy не начинают слушать события, пока цикл не закончится. Работу программы не остановить и с помощью мыши, т.к. бот управляет ей и уводит курсор куда ему нужно.


Нам это не подходит, поэтому пришлось разделить бота на 2 потока: слушание событий и выполнение логики действий персонажа.


Создадим 2 потока


        # init bot stop event
        self.bot_thread_stop_event = threading.Event()

        # init threads
        self.auto_py_thread = threading.Thread(target=self.start_auto_py, args=(auto_py,))
        self.bot_thread = threading.Thread(target=self.start_bot, args=(auto_py, self.bot_thread_stop_event, character_class))

        # start threads
        self.auto_py_thread.start()
        self.bot_thread.start()

и теперь вешаем обработчик на ESC:


auto_py.registerExit(auto_py.ESC, self.stop_bot_event_handler)

при нажатии ESC устанавливаем событие


self.bot_thread_stop_event.set()

и в цикле логики персонажа проверяем, установлено ли событие:


while not stop_event.is_set():

Теперь спокойно останавливаем бота по кнопке ESC.


Заключение


Казалось бы, зачем тратить время на продукт, который не приносит никакой практической пользы?


На самом деле компьютерная игра с точки зрения компьютерного зрения — почти то же самое, что и снятая на камеру реальность, а там возможности для применения огромны. Отличный пример описан в статье про подводных роботов, которые лазером стреляют по лососям. Также статья может помочь разработчикам игр в борьбе с ботоводами.


Ну а я ознакомился с Python, прикоснулся к компьютерному зрению, написал свой первый слабоумный искусственный интеллект и получил массу удовольствия.
Надеюсь, было интересно и вам.


P.S. Ссылка на репозиторий

Tags:pythonbotailineage 2computer vision
Hubs: Python Programming Game development
+64
95k 308
Comments 75
Popular right now