[Из песочницы] Пишем бота-кликера на Python для Lineage 2
Предисловие
Как можно развлечься в новогодние праздники? Поиграть в компьютерные игры? Нет! Лучше написать бота, который это будет делать за тебя, а самому пойти лепить снеговика и пить глинтвейн.
Когда-то в школьные годы был увлечен одной из популярных 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)
Морфологические преобразования:
- Фильтровать будем по прямоугольнику размером 50×5. Такой прямоугольник подошел лучше всех.
- Убираем шум внутри прямоугольников с текстом (по сути закрашиваем всё между букв белым)
- Еще раз убираем шум, размывая и растягивая с применением фильтра
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. Неужели придется вспоминать математику?
Немного поразмыслив, всё-таки решил погуглить. Оказалось, что ничего придумывать не надо — задачу решает алгоритм Брезенхэма, один из старейших алгоритмов в компьютерной графике:
Прямо с Википедии можно взять и реализацию
Логика работы
Все инструменты есть, осталось самое простое — написать сценарий.
- Если монстр жив, продолжаем атаковать
- Если нет цели, найти цель и начать атаковать
- Если не удалось найти цель, немного повернемся
- Если 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, прикоснулся к компьютерному зрению, написал свой первый слабоумный искусственный интеллект и получил массу удовольствия.
Надеюсь, было интересно и вам.