Pull to refresh

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

Reading time8 min
Views17K

Еще не все новогодние салаты были съедены, “Ирония судьбы” уже просмотрена, а до начала рабочей недели еще целая вечность и нужно было придумать себе развлечение на оставшиеся праздники. Предвкушая ностальгию я открыл 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"))  

Делаем скриншот окна игры

Основное окно Lineage 2
Основное окно Lineage 2

На скриншоте видно, что помимо имен монстров у нас есть еще другие белые объекты, которые могут помешать: радар, чат, имя нашего персонажа и т.д. Для этого модифицируем наш скриншот и закрашиваем ненужные области в черный цвет:

Закрашены "ненужные" помехи
Закрашены "ненужные" помехи

Здесь уже вступает в игру 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. ищем монстра путем поиска всех белых прямоугольников

  2. проверяем жив ли он, если нет - возвращаемя на пункт №1

  3. начинаем атаковать

  4. когда здоровье монстра равняется нулю - возвращаемся на пункт №1

Повторять пока не надоест!

Результат нашей работы на ютубе:

Ссылка на исходники

Заключение

Понятное дело, что кликер получился не идеальным и любое внешнее воздействие на персонажа полностью руинит наш код, однако у меня не было цели написать самого оптимального бота. Использование OpenCV позволяет не только определять помидоры и находить монстров в MMORPG, но также открывает огромный простор для применения в различных сферах ограниченный лишь воображением. Моей целью было разобраться в базовых вещах и применить на примере. Следующим этапом хотелось бы попробовать уже современное машинное зрение с использованием нейронок, но это уже когда-то в следующий раз. Делитесь в комментариях в каких еще необычных сферах можно было бы использовать компьютерное зрение 🙂

Tags:
Hubs:
Total votes 40: ↑38 and ↓2+36
Comments24

Articles