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

Комментарии (24)


  1. DistortNeo
    17.01.2023 16:11
    +1

    Как-то у вас всё просто. Когда я в своё время занимался подобным, античит (GameGuard) блокировал подобные непотребства, не давая отправлять нажатия. В итоге я быстро переключился на уровень сетевых пакетов.


    1. GennPen
      17.01.2023 19:19

      А я делал аппаратный эмулятор (на ардуине или стм32, не помню уже). Смысл был такой, что по COM-порту ему отправляешь команды что нажать или на сколько и куда передвинуть мышку, а он уже все это делал на аппаратном уровне.


      1. EugeneH
        17.01.2023 19:24

        Некоторые идут дальше.


        1. GennPen
          17.01.2023 19:38

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


        1. hbrmrk
          18.01.2023 10:41

          PiZero, PCI плата видеозахвата, щепотка шаманства на сетевом уровне, веб-сервер, OCR на принципах теории информации. Меня так однажды чуть на работу не взяли: просто за архитектуру


  1. Green21
    17.01.2023 16:14

    Тоже в свое время хотел кликер такой написать, чтобы в одной игрушке участвовать в боях с крипами)

    Мои слева, противник справа)
    Мои слева, противник справа)

    Если кто знает подобные библиотеки для C# - буду признателен)


    1. okovalevski
      18.01.2023 15:25
      +1

      OpenCV под c # работает без проблем, управление мышью и клавиатурой искать не боле пары минут. В matchTemplate очень много времени тратится на просмотр всего массива, исходный код на c # легко перенести, сделав выход из цикла когда порог превышен поиск ускоряется в несколько раз.


    1. LeonardPowers
      20.01.2023 11:08

      EmguCV?


  1. paulmiller
    17.01.2023 16:41
    +9

    С нежностью вспоминаю бота L2Walker. Его создатели в своё время потрясли меня до глубины души.

    Основным отличием от всех остальных ботов было отсутствие необходимости грузить и держать запущенным клиент игры. Это позволяло на одном очень среднем ПК запускать почти два десятка ботов (т.е. две полные пати). Внутри имелась карта вид сверху с возможность управлять персонажем, показывались откаты всех скиллов, был даже свой скриптовый язык с помощью которого можно было автоматизировать выполнение квестов.

    Помимо собственно руководства роботами и нечестной наживы, L2Walker помогал тонко понимать механики игры. Как работает тот или иной скилл, как наилучшим образом выстроить последовательность использования умений и прочее.


    1. Svbakulin
      17.01.2023 16:55

      ох да, было время :) пытался вспомнить название того бота, напомнили )

      Его использование было само по себе скиллом, всегда восхищался (по тем временам) как относительно простая программа может автоматизировать казалось бы сложный процесс. Он ж и PVP умел если не ошибаюсь...


      1. paulmiller
        17.01.2023 17:03

        PVP он умел, но надо было слишком много всего предусмотреть. Вероятность победить была низкая, особенно для пати в катах на фарме. Их кайтили, расфлагивали, закидывали париками и прочее, пока они безуспешно пытались догнать оппонента.
        Поэтому опытные ботоводы отключали напрочь все галочки, позволявшие флагнуться об мимокрокодила )


    1. Firsto
      17.01.2023 17:06
      +2

      Это позволяло на одном очень среднем ПК запускать почти два десятка ботов (т.е. две полные пати)

      был даже свой скриптовый язык с помощью которого можно было автоматизировать выполнение квестов.

      Самый кайф был автоматизировать всё настолько, что только что созданные персонажи сами бежали выполнять квесты, качаться, получать профессии, качаться дальше, закупаться ресурсами в городе и качаться дальше, а на выходе через пару недель получалась полная пачка персонажей 52+ для фарма какого-нибудь там Закена. :-)


      1. paulmiller
        17.01.2023 17:36
        +3

        Как то была статья на хабре, что проводят собеседования в компанию с помощью игры Factorio. Знали бы они про старых ботоводов, для которых автоматизировать фарм рецептов ТТ бижи в Хотспингс было делом получаса за чашечкой чая :)


      1. DistortNeo
        17.01.2023 18:03
        +2

        У меня был в разработке собственный ingame-бот. Его отличительная фишка — система глобальной навигации по множеству локаций и телепортов.


        Это были не жёстко зашитые сценарии, а поиск кратчайшего пути в графе для того, чтобы добираться в заданную точку. Нажал кнопочку — и твой персонаж сам добирается до пати-лидера практически в любой точке мира, не нужно самому продираться через толпу торгашей у гк. А ещё это очень выручало, когда тебе надо уйти в АФК, а пати постоянно перемещается.


        Также это позволяло ботам не тупить в локациях со сложной геометрией, не упираться в препятствия и не видеть в чате кучу мусора, что цель не может быть атакована (например, при дистанционной атаке при наличии колонны посередние), а интеллектуально их оббегать.


    1. vanxant
      17.01.2023 23:05
      +8

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


  1. Antharas
    17.01.2023 17:25
    +1

    Имхо нет смысла заморачиваться с распознанием, есть next target. Достаточно реализовать цикличные макросы с таймингами и частотой нажатий по заданному шаблону клавиш клавиатуры, бонусом это можно делать в свернутом окне через User32.SendMessage, что не умеет макросная мышка. Но есть вероятность словить бан хамер, если защитное ПО (Frost точно это делает на ру) проверит стек вызова - драйвер мыши/клавиатуры решает проблему и прямые команды через COM.


    1. MrStuff88 Автор
      17.01.2023 23:39

      Согласен, но это перестает работать если цели стоят не очень близко. Также на некоторых серверах блокируют возможность спама next target.


      1. DistortNeo
        18.01.2023 09:59

        Также на некоторых серверах блокируют возможность спама next target.

        Next Target — это исключительно клиентская фишка, как её на сервере блокируют-то?


  1. EugeneH
    17.01.2023 19:02
    +8

    Я тоже как-то решил написать кликер для одной малоизвестной мобильной MMORPG в которую залипал. Потом добавил контроль HP/MP через OpenCV, потом решил научить бота ходить, и понеслось...

    В итоге ботописательство стало для меня развлечением и самоцелью, игра ушла на второй план. Получился монструозный комбайн c кучей машинного зрения, запутанной А* навигацией, управлением через minitouch, сканированием памяти эмулятора через ReadProcessMemory, который делает в игре вообще всё.

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


  1. Dreamer_other
    18.01.2023 09:46

    Создавая боты и другие несанкционированные модификации для компьютерных игр не забывайте, что в России уже есть практика возбуждения уголовных дел по статье 273 против разработчиков и распространителей такого рода софта.


    1. DistortNeo
      18.01.2023 10:12
      +3

      Пока есть только одно дело, и в нём есть: пострадавшая строна (издатель игры), распространение читерского софта за деньги.


    1. GennPen
      18.01.2023 20:35

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


    1. artorias0608
      20.01.2023 11:13

      Разве боты являются "несанкционированными модификациями", да и впринципе "модификациями"?

      Я думал что модификация это что-то, где есть вмешательство в исходный код, я ошибаюсь?

      Боты - это ведь инструмент автоматизации, точно так же, как и скрипты. Ты на работе скрипт пишешь, ты тоже являешься разработчиком "несанкционированной модификации"?

      Мне реально не понятно.


  1. Interreto
    18.01.2023 11:26
    +1

    Я сейчас пишу бот под одну новую онлайн игрулю, так вот, полупрозрачности и прочие анимационные свистоперделки на интерфейсе - своеобразная защита от распознование, усложняет написания бота.