Еще не все новогодние салаты были съедены, “Ирония судьбы” уже просмотрена, а до начала рабочей недели еще целая вечность и нужно было придумать себе развлечение на оставшиеся праздники. Предвкушая ностальгию я открыл Lineage 2, одну из самых популярных MMORPG “нулевых” на СНГ пространстве. Однако, самому играть уже не хотелось и пришла идея автоматизировать это дело. За подробностями под кат!
Введение
В школьные годы мы с друзьями играли в разные MMORPG игрушки, но самой залипательной для нас была Lineage 2. Суть игры состоит в том, чтобы 80% времени повторять одни и те же действия по убийству монстров и, время от времени, сражаться с другими игроками за этих самых монстров. К сожалению, времени на такие занятия у меня уже нет, поэтому было принято решение заняться автоматизацией! Как раз недавно попалась статья по OpenCV, которая вдохновила меня немного разобраться в этой теме - там автор определял наличие помидора на картинке :)
Сказано - сделано! И вот уже открыт гугл в поисках готовых реализаций и каково же было мое удивление, что я сразу же нашел релазицию хабре. Мои идеи совпали с идеями автора, вот только код написан на Python.. (Пост)
Небольшое отступление: я мобильный разработчик, который никогда не трогал этого вашего Питона.. К сожалению, за два вечера у меня не получилось запустить код из репозитория автора. Сначала были конфликты в версии самого питона, потом какие-то непонятные ошибки с библиотеками из либы, в конце тулза AutoHotPy для работы с мышью и клавиатурой вообще отказалась работать. В итоге было принято решение написать свою реализацию на Kotlin с блекджеком и гномками! (Гномы - одна из рас в игре Lienage 2)
Поехали!
Для работы с окном игры будем использовать опенсорсную либу Java Native Access (JNA). Создаем новый проект в нашей любимой IDE, качаем два джарника с гитхаба JNA и JNA Platform, кладем их в наш проект и не забываем подключить их с помощью gradle:
implementation(files("lib/jna-5.12.1.jar"))
implementation(files("lib/jna-platform-5.12.1.jar"))
Определяем окно игры
Здесь ничего сложного, в списке окон находим окно с названием Lineage, получаем его координаты и далаем активным:
private fun detektWindow(windowName: String): Rectangle {
val user32 = MyUser32.instance
val rect = Rectangle(0, 0, 0, 0)
var windowTitle = ""
val windows = WindowUtils.getAllWindows(true)
windows.forEach {
if (it.title.contains(windowName)) {
rect.setRect(it.locAndSize)
windowTitle = it.title
}
}
val tst: WinDef.HWND = user32.FindWindow(null, windowTitle)
user32.ShowWindow(tst, User32.SW_SHOW)
user32.SetForegroundWindow(tst)
return rect
}
Поиск цели
Моя идея была такая же как и у автора из упомянутой статьи, однако некоторые детали у меня не сработали и пришлось подбирать реализацию под себя. Наш алгоритм действий будет следующими: делаем скриншот игры, с помощью фильтрации OpenCV находим имена монстров, наводимся на них мышкой, атакуем пока у монстра не закончится здоровье, переключаемся на следующего монстра и так до бесконечности! Весело, не правда ли? Погнали!
Устанавливаем OpenCV по гайду с офф сайта, скачиваем и закидываем в проект openCV-...jar и opencv_java..dll. Не забываем их подключить к проекту через gradle
implementation(files("lib/opencv-460.jar"))
Делаем скриншот окна игры
На скриншоте видно, что помимо имен монстров у нас есть еще другие белые объекты, которые могут помешать: радар, чат, имя нашего персонажа и т.д. Для этого модифицируем наш скриншот и закрашиваем ненужные области в черный цвет:
Здесь уже вступает в игру OpenCV. Чтобы начать гриндить, нам необходимо найти цели. Как работаем - нам нужно провести фильтрацию так, чтобы на экране остались только белые прямоугольные объекты (так выглядят имена монстров). Идея следующая, мы помним что картинка состоит из пикселей, поэтому мы выполняем пороговое преобразование всех пикселей на картинке таким образом, чтобы туда попали только белые пиксели:
Imgproc.threshold(source, source, 252.0, 255.0, Imgproc.THRESH_BINARY)
далее нужно выполнять несколько формологических преобразований (размытие и растягивание), которые еще раз фильтранут все белые объекты по заданным размерам и преобразуют в белые прямоугольники путем размытия и растягивания (более подробное описание в офф. доке)
private fun findPossibleTargets(rectangle: Rectangle): List<MatOfPoint> {
val capture: BufferedImage = Robot().createScreenCapture(rectangle)
fillBlackExcess(capture, rectangle)
val source: Mat = img2Mat(capture)
Imgproc.cvtColor(source, source, Imgproc.COLOR_BGR2GRAY)
Imgproc.threshold(source, source, 252.0, 255.0, Imgproc.THRESH_BINARY)
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(10.0, 1.0))
Imgproc.morphologyEx(source, source, Imgproc.MORPH_CLOSE, kernel)
Imgproc.erode(source, source, kernel)
Imgproc.dilate(source, source, kernel)
val points: MutableList<MatOfPoint> = mutableListOf()
Imgproc.findContours(source, points, Mat(), Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE)
return points
.sortedBy { it.toList().maxBy { it.y }.y }
.filter {
val maxX = it.toList().maxBy { it.x }.x
val minX = it.toList().minBy { it.x }.x
val width = (maxX - minX)
val maxY = it.toList().maxBy { it.y }.y
val minY = it.toList().minBy { it.y }.y
val height = (maxY - minY)
width > 30 && width < 200 && height < 30
}
}
Сравнение объектов
Окей, наводиться мы научились, теперь нам нужно определить находится ли мышь на монстре или же на каком-то объекте флоры.
Т.к. флора и фауна мира Lineage 2 достаточно разнообразна, нам необходимо удостовериться что белый прямоугольник это наша желаемая цель в виде монстра, а не какая-то белая стена или трава. Для этого снова делаем скриншот, достаем наш шаблон, переводим обе картинки в серый и используем метод matchTemplate из OpenCV.
Работает он приблизительно следующим образом: наше шаблонное изображение последовательно накладывается на исходное изображение и между ними вычисляется корреляция, результат мы получаем на выходе в виде значения от 0.0 до 1.0. (Более подробно про метод в доке).
P.S. для тех кто будет пробовать реализацию - учтите, что на разных хрониках и серверах будут разные шаблоны у монстров, поэтому придется самостоятельно подготавливать эти изображения.
private fun isMouseSelectingAMob(rectangle: Rectangle): Boolean {
Thread.sleep(100L)
val minMatchThreshold = 0.8
val capture: BufferedImage = Robot().createScreenCapture(rectangle)
val thresholdScreen: Mat = img2Mat(capture)
Imgproc.cvtColor(thresholdScreen, thresholdScreen, Imgproc.COLOR_BGR2GRAY)
val template: Mat = Imgcodecs.imread("./src/main/resources/$TARGET_TEMPLATE_NAME.png")
Imgproc.cvtColor(template, template, Imgproc.COLOR_BGR2GRAY)
Imgproc.matchTemplate(thresholdScreen, template, thresholdScreen, Imgproc.TM_CCOEFF_NORMED)
val value = Core.minMaxLoc(thresholdScreen).maxVal
return value > minMatchThreshold
}
Эмуляция мыши/клавиатуры
Для начала нам нужно научиться эмулировать движение мыши и нажатие клавиатуры. К сожалению готовой, библиотеки на Java/Kotlin я не нашел, поэтому будем использовать написанную на языке С либу с названием Interception (https://github.com/oblitum/Interception). Тут я вспоминаю, что я мобильный разработчик и не умею в С, но быстро преободряюсь потому что написать обертку на Kotlin оказалось достаточно просто. Устанавливаем по гайду, закидываем файлы interception.dll и interception.h в проект. Interception работает в отдельном потоке, полностью перехватывает управление над мышью и клавиатурой, с помощью команд эмулирует движение и нажатие, однако чтобы вернуть управление обратно, нам нужно явно прописать это, задать определенную кнопку, иначе придется перезагружать весь компьютер ????
override fun run() {
var device: Int
while (Interception.interception_receive(
context,
Interception.interception_wait(context).also { device = it },
emptyStroke,
1
) > 0
) {
val strokeCode = emptyStroke.code
val keyboardEscShort = INTERCEPTION_FILTER_KEYBOARD_ESC.toShort()
if (device == KEYBOARD_DEVICE_ID && strokeCode == keyboardEscShort) {
println("finish program")
exitProcess(0)
}
if (!emptyStroke.isInjected) {
Interception.interception_send(context, device, emptyStroke, 1)
}
}
Interception.interception_destroy_context(context)
}
Из интересного - мышь и клавиатура имеют свои ID, по которым принимаются и отправляются команды. Подобрать их можно простым перебором для мыши используется зачение от 11 до 20, а для клавиатуры от 1 до 10. Я заметил, что, время от времени, их ID могут меняться, хотя я физически не переставлял их в другие порты. Если кто-то из читателей знает почему так происходит, то расскажите пожалуйста в комментариях :)
Движение
Окей, двигать мышь и нажимать клавиши мы научились, но наше движение выглядит как телепортация и может вызывать подозрение у админов сервера. Автор упомянутой статьи верно подметил, что есть так называемый Алгоритм Брезенхэма, который хоть и не полностью, но все же хоть немного напоминает человеческое движение. Берем реализацию с Вики, переводим на Kotlin, немного модифицируем движение, т.к. Interception двигает мышь не по абсолютным значением, а по относительным текущего расположения мыши.
Распознание количества здоровья монстра
Находить объекты и атаковать их мы научились, но нам нужно понимать когда мы закончили с текущим монстром и нужно приниматься за следующего. Для этого нам нужно определять оставшееся здоровье монстра. Этот фокус состоит из двух этапов. На первом этапе, после того как мы выбрали нашу цель, мы также по специальному шаблону находим прямоугольинк, который относится к окошку со здоровьем монстра, поэтому подробности опустим.
А вот второй этап рассмотрим немного подробнее. Т.к. окно здоровья монстра содержит полоску из нескольких цветов, где один из них красный (текущее оставшееся здоровье монстра) и бурый (потерянное здоровье монстра), то мы можем найти разницу и понимать жив ли еще монстр или нет. Для этого указываем нижнее и верхнее значение красного цвета, используем фунцию inRange для фильтрации по нужному промежутку цветов и находим все объекты с помощью контурного анализа:
private fun checkHpBar(hpBarMat: Mat): Int {
val lower = Scalar(0.0, 150.0, 90.0)
val upper = Scalar(10.0, 255.0, 255.0)
val subImageMat: Mat = hpBarMat.clone()
Imgproc.cvtColor(subImageMat, subImageMat, Imgproc.COLOR_BGR2HSV)
Core.inRange(subImageMat, lower, upper, subImageMat)
val remainingContours: MutableList<MatOfPoint> = mutableListOf()
Imgproc.findContours(subImageMat, remainingContours, subImageMat, Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE)
val remainingLeftX = remainingContours.firstOrNull()?.toList()?.minBy { it.x }?.x ?: 0.0
val remainingRightX = remainingContours.firstOrNull()?.toList()?.maxBy { it.x }?.x ?: 0.0
val totalHpBarWidth = subImageMat.width()
val remainingHpBarWidth = remainingRightX - remainingLeftX
val percentHpRemaining = remainingHpBarWidth * 100 / totalHpBarWidth
if (percentHpRemaining < 1) return 0
return percentHpRemaining.toInt()
}
Более подробно про inRange и findCountours.
Готово! Все необходимые функции у нас есть, осталось закодить алгоритм.
Наши действия:
ищем монстра путем поиска всех белых прямоугольников
проверяем жив ли он, если нет - возвращаемя на пункт №1
начинаем атаковать
когда здоровье монстра равняется нулю - возвращаемся на пункт №1
Повторять пока не надоест!
Результат нашей работы на ютубе:
Заключение
Понятное дело, что кликер получился не идеальным и любое внешнее воздействие на персонажа полностью руинит наш код, однако у меня не было цели написать самого оптимального бота. Использование OpenCV позволяет не только определять помидоры и находить монстров в MMORPG, но также открывает огромный простор для применения в различных сферах ограниченный лишь воображением. Моей целью было разобраться в базовых вещах и применить на примере. Следующим этапом хотелось бы попробовать уже современное машинное зрение с использованием нейронок, но это уже когда-то в следующий раз. Делитесь в комментариях в каких еще необычных сферах можно было бы использовать компьютерное зрение ????
Комментарии (24)
Green21
17.01.2023 16:14Тоже в свое время хотел кликер такой написать, чтобы в одной игрушке участвовать в боях с крипами)
Если кто знает подобные библиотеки для C# - буду признателен)
okovalevski
18.01.2023 15:25+1OpenCV под c # работает без проблем, управление мышью и клавиатурой искать не боле пары минут. В matchTemplate очень много времени тратится на просмотр всего массива, исходный код на c # легко перенести, сделав выход из цикла когда порог превышен поиск ускоряется в несколько раз.
paulmiller
17.01.2023 16:41+9С нежностью вспоминаю бота L2Walker. Его создатели в своё время потрясли меня до глубины души.
Основным отличием от всех остальных ботов было отсутствие необходимости грузить и держать запущенным клиент игры. Это позволяло на одном очень среднем ПК запускать почти два десятка ботов (т.е. две полные пати). Внутри имелась карта вид сверху с возможность управлять персонажем, показывались откаты всех скиллов, был даже свой скриптовый язык с помощью которого можно было автоматизировать выполнение квестов.
Помимо собственно руководства роботами и нечестной наживы, L2Walker помогал тонко понимать механики игры. Как работает тот или иной скилл, как наилучшим образом выстроить последовательность использования умений и прочее.
Svbakulin
17.01.2023 16:55ох да, было время :) пытался вспомнить название того бота, напомнили )
Его использование было само по себе скиллом, всегда восхищался (по тем временам) как относительно простая программа может автоматизировать казалось бы сложный процесс. Он ж и PVP умел если не ошибаюсь...
paulmiller
17.01.2023 17:03PVP он умел, но надо было слишком много всего предусмотреть. Вероятность победить была низкая, особенно для пати в катах на фарме. Их кайтили, расфлагивали, закидывали париками и прочее, пока они безуспешно пытались догнать оппонента.
Поэтому опытные ботоводы отключали напрочь все галочки, позволявшие флагнуться об мимокрокодила )
Firsto
17.01.2023 17:06+2Это позволяло на одном очень среднем ПК запускать почти два десятка ботов (т.е. две полные пати)
был даже свой скриптовый язык с помощью которого можно было автоматизировать выполнение квестов.
Самый кайф был автоматизировать всё настолько, что только что созданные персонажи сами бежали выполнять квесты, качаться, получать профессии, качаться дальше, закупаться ресурсами в городе и качаться дальше, а на выходе через пару недель получалась полная пачка персонажей 52+ для фарма какого-нибудь там Закена. :-)
paulmiller
17.01.2023 17:36+3Как то была статья на хабре, что проводят собеседования в компанию с помощью игры Factorio. Знали бы они про старых ботоводов, для которых автоматизировать фарм рецептов ТТ бижи в Хотспингс было делом получаса за чашечкой чая :)
DistortNeo
17.01.2023 18:03+2У меня был в разработке собственный ingame-бот. Его отличительная фишка — система глобальной навигации по множеству локаций и телепортов.
Это были не жёстко зашитые сценарии, а поиск кратчайшего пути в графе для того, чтобы добираться в заданную точку. Нажал кнопочку — и твой персонаж сам добирается до пати-лидера практически в любой точке мира, не нужно самому продираться через толпу торгашей у гк. А ещё это очень выручало, когда тебе надо уйти в АФК, а пати постоянно перемещается.
Также это позволяло ботам не тупить в локациях со сложной геометрией, не упираться в препятствия и не видеть в чате кучу мусора, что цель не может быть атакована (например, при дистанционной атаке при наличии колонны посередние), а интеллектуально их оббегать.
vanxant
17.01.2023 23:05+8О да, играть в квадратики было намного интереснее, чем сама лодва. В четыре руки и 18 окон мы с женой и замки умудрялись брать у кожаных мешков.
Antharas
17.01.2023 17:25+1Имхо нет смысла заморачиваться с распознанием, есть next target. Достаточно реализовать цикличные макросы с таймингами и частотой нажатий по заданному шаблону клавиш клавиатуры, бонусом это можно делать в свернутом окне через User32.SendMessage, что не умеет макросная мышка. Но есть вероятность словить бан хамер, если защитное ПО (Frost точно это делает на ру) проверит стек вызова - драйвер мыши/клавиатуры решает проблему и прямые команды через COM.
MrStuff88 Автор
17.01.2023 23:39Согласен, но это перестает работать если цели стоят не очень близко. Также на некоторых серверах блокируют возможность спама next target.
DistortNeo
18.01.2023 09:59Также на некоторых серверах блокируют возможность спама next target.
Next Target — это исключительно клиентская фишка, как её на сервере блокируют-то?
EugeneH
17.01.2023 19:02+8Я тоже как-то решил написать кликер для одной малоизвестной мобильной MMORPG в которую залипал. Потом добавил контроль HP/MP через OpenCV, потом решил научить бота ходить, и понеслось...
В итоге ботописательство стало для меня развлечением и самоцелью, игра ушла на второй план. Получился монструозный комбайн c кучей машинного зрения, запутанной А* навигацией, управлением через minitouch, сканированием памяти эмулятора через ReadProcessMemory, который делает в игре вообще всё.
В конце концов разработчикам удалось забанить эмуляторы андройда и я смог вернуться к нормальной жизни. Одно время даже статью подумывал написать, но теперь точно не до этого.
Dreamer_other
18.01.2023 09:46Создавая боты и другие несанкционированные модификации для компьютерных игр не забывайте, что в России уже есть практика возбуждения уголовных дел по статье 273 против разработчиков и распространителей такого рода софта.
DistortNeo
18.01.2023 10:12+3Пока есть только одно дело, и в нём есть: пострадавшая строна (издатель игры), распространение читерского софта за деньги.
GennPen
18.01.2023 20:35Для тех ботов, которые вмешиваются в данные игры это понятно. Но по сути она не работает для тех случаев, которые берут и передают данные не вмешиваясь в процесс игры.
artorias0608
20.01.2023 11:13Разве боты являются "несанкционированными модификациями", да и впринципе "модификациями"?
Я думал что модификация это что-то, где есть вмешательство в исходный код, я ошибаюсь?
Боты - это ведь инструмент автоматизации, точно так же, как и скрипты. Ты на работе скрипт пишешь, ты тоже являешься разработчиком "несанкционированной модификации"?
Мне реально не понятно.
Interreto
18.01.2023 11:26+1Я сейчас пишу бот под одну новую онлайн игрулю, так вот, полупрозрачности и прочие анимационные свистоперделки на интерфейсе - своеобразная защита от распознование, усложняет написания бота.
DistortNeo
Как-то у вас всё просто. Когда я в своё время занимался подобным, античит (GameGuard) блокировал подобные непотребства, не давая отправлять нажатия. В итоге я быстро переключился на уровень сетевых пакетов.
GennPen
А я делал аппаратный эмулятор (на ардуине или стм32, не помню уже). Смысл был такой, что по COM-порту ему отправляешь команды что нажать или на сколько и куда передвинуть мышку, а он уже все это делал на аппаратном уровне.
EugeneH
Некоторые идут дальше.
GennPen
На счет взятия видео и обработки с помощью другого компьютера была идея, до реализации так дело и не дошло.
hbrmrk
PiZero, PCI плата видеозахвата, щепотка шаманства на сетевом уровне, веб-сервер, OCR на принципах теории информации. Меня так однажды чуть на работу не взяли: просто за архитектуру