Pull to refresh
0

Гена против Сандро: история автоматизации одной сетевой партии в Героях 3

Reading time 25 min
Views 13K


Сандро не мог поверить своей удаче. Прошло уже две недели после катастрофы с прыжком в экспериментальный портал, который забрал в небытие всю его немаленькую армию. Лишь один захолустный скелет чудом избежал этой же участи.


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


Должно быть, судьба решила вознаградить Сандро за все его страдания, потому что теперь он стоял на окраине двух вкусных, многолюдных деревень и не мог поверить своим глазницам. Если бы у него на черепе осталось бы хоть немного мускулов, он бы даже попробовал улыбнуться. Целых две деревни с крестьянами и никакой охраны, ни одного даже завалявшегося копейщика.


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


Сандро не подозревал, что исход всех его приключений давно предопределён. Вся его история окончится через несколько минут (хоть для него это будет казаться целой неделей) — ведь именно столько занимает прогон автотеста кроссплатформенной игровой партии по сети в Героях 3. Действиями Сандро управляет платформа Testo, которая готова прогонять его историю снова и снова.


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


Disclaimer

Уважаемые друзья! В этой статье, помимо привычных технических вещей, вы также найдёте историю противостояния некроманта Сандро и никудышного рыцаря Гены в сеттинге нашей любимой и обожаемой игры Герои меча и магии 3. Эта история изобилует большим количеством художественных вымыслов относительно игровых механик этого замечательного произведения игростроения. Пожалуйста, не стоит относиться к этим вымыслам слишком серьёзно: я точно так же, как и вы, люблю и ценю Героев 3, и лишь предлагаю взглянуть на привычные всем вещи с новой, забавной стороны. Любые совпадения случайны, я отказываюсь от любых намёков на какие-либо реальные вещи в реальном мире.


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


Всего ещё год назад Гена был самым несчастным рыцарем во всей Эрэфии. После выпуска из рыцарского военного ВУЗа его по распределению отправили служить на передовую. У Гены никогда не было склонности к военному искусству (он с трудом мог отличить обычного грифона от королевского), но всегда был талант сомелье. Он и поступил-то в военный ВУЗ только по указке своих родителей – рыцарей в пятидесятом поколении. Они же помогли замять резонансное дело, когда Гена умудрился пропить всех вверенных ему грифонов в одной из таверн. О военной карьере, понятное дело, речи больше идти не могло, и Гену вместо виселицы отправили от греха подальше дослуживать свой рыцарский контракт в самые дальние далёкие тылы, которые только были в Эрэфии – охранять замок Балашов и его окрестности.


Сложно в это поверить, но на новом месте Гена освоился лучше некуда: всего за месяц он познакомился со всеми местными малочисленными гарнизонами (благо его талант сомелье к этому очень располагал, как и десятки бочек вина в погребах замка) и узнал у местных патрулей, что защищать Балашов ему, фактически, не от кого: последнее враждебное существо (волка) в этих краях видели аж 2 года назад.


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


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


Начальные условия


Для запуска Героев я решил использовать проект VCMI. В этом замечательном открытом проекте все ключевые компоненты Героев 3 были с нуля переписаны на С++. Проект является кроссплатформенным, поэтому с его помощью вы можете играть в Героев по сети на Макбуке с другом, который едет в метро и сидит в своём телефоне на Андроиде. Как раз-таки этой кроссплатформенностью я и воспользовался для этой статьи.


Похождения Сандро и Гены происходят вот на такой карте, которую я накидал в редакторе за 15 минут (все ссылки доступны в конце статьи):



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



Игра происходит по сети в режиме "человек против человека". Сандро выбрал Ubuntu Desktop 20.04 в качестве машины для запуска, а Гена решил не рисковать и придерживаться проверенного варианта с Windows 7. Сандро на правах более опытного пользователя создаёт конференцию и выступает в качестве сервера, а Гене лишь нужно подключиться к Сандро по сети.


За развёртывание, настройку стенда, установку VCMI и прогон всей игровой партии отвечает платформа Testo. Возможно, вы уже видели на Хабре краткое описание этой платформы, или примеры автотестов для антивируса Dr. Web (без малейшего доступа к исходникам). Помимо тестов, Testo можно применять и для автоматического развёртывания интересных виртуальных стендов (например, контроллер домена AD вместе с рабочей станцией).


Краткое описание Testo

Testo — это новый фреймворк для системных (End-To-End) автотестов, который позволяет автоматизировать взаимодействие с виртуальными машинами с помощью скриптов на специальном, простом для понимания языке. Testo построен на распознавании объектов на экране с помощью нейросетей, поэтому для его работы не требуется устанавливать специальные агенты внутрь виртуальных машин.


В Testo все действия с виртуальными машинами находятся внутри тестов, которые выстраиваются в дерево "от простого к сложному". В нашем случае такое дерево будет выглядеть вот так:



Благодаря механизму кеширования в Testo, все эти тесты вовсе не нужно будет прогонять с нуля каждый раз. Поэтому самые долгие и неинтересные тесты *install и *configure (установка и первичная настройка ОС) прогонятся вовсе только один раз, после чего вечно будут закешированы. Разбирать эти тесты я не буду, но желающие смогут найти исходники этих тестов в ссылках в конце статьи.


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




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


Сам дом оказался тоже незапертым. Не то, чтобы это могло стать препятствием, конечно же. Но так эффект от бесшумного появления Сандро в комнате с кучей крестьян оказался максимально удовлетворительным для него. Двадцать с лишним пар глаз мгновенно уставились на него. Весёлые разговоры мгновенно смолкли, оставив лишь гнетущую тишину. Сандро любил гнетущую тишину – как и всё гнетущее. Прошептав своим замогильным голосом «Вы пойдёте со мной», Сандро развернулся и неспешно вышел обратно во двор. Он знал, что ни один тщедушный смертный не посмеет ему перечить, и что эти крестьяне теперь в его власти до самой своей смерти. Да и после смерти тоже. Особенно после смерти.


Летом ночи не такие длинные, а сделать нужно ещё очень много. Сандро направился к следующему дому, наслаждаясь тишиной и повиновением своих новых скелетов, пусть пока что в своих бесполезных кожаных оболочках.


Установка VCMI


Похоже, дела у Сандро идут вполне неплохо.


Что ж, первые тесты прошли (где-то за кадром), пришло время заняться установкой VCMI. За это отвечают два теста: ubuntu_install_vcmi и win7_install_vcmi. В них происходит установка самого дистрибутива VCMI и копируются почти все необходимые файлы с оригинальными данными Героев 3 в папки VCMI (Чтобы VCMI мог их подхватить). Точнее, копируются все файлы, кроме папки Maps (в которой лежит только одна карта, скриншот которой вы видели выше). Эта папка будет скопирована чуть позже. Вот так выглядит этот процесс на примере Ubuntu:



Уважаемые читатели, автор в курсе про правило «не выкладывать код скриншотами», но тесты написаны на языке Testo-lang, и Хабр не поддерживает подсветку этого языка. Я считаю, что лучше уж выложить скриншот, зато код не будет сливаться в одно месиво. Ссылки на все тесты доступны в конце статьи, вы всегда можете увидеть их текстовое представление там.

Пробежимся вкратце по тесту:


  1. Сам VCMI устанавливается из репозитория ppa:vcmi/ppa.
  2. Нужно один раз запустить VCMI Launcher, чтобы он создал свои служебные папки в ~/.local/share.
  3. Копируем с хоста папки Data, Mp3, Mods/vcmi в служебные папки VCMI. Папки Data и Mp3 содержат файлы с данными оригинальных Героев. Папка Mods/vcmi содержит мод vcmi, который позволяет устанавливать нормальное разрешение экрана.
  4. Копируем файл vcmi_settings.json с настройками VCMI, чтобы не нужно было прокликивать настройки с помощью мышки.

А что же с папкой Maps? Почему бы её тоже сразу не скопировать? Не буду вас перегружать сейчас деталями этого решения, вы сами всё увидите ближе к концу статьи.


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




«Некромант? Здесь, в Балашове? Что вообще происходит?» — эти и многие другие мысли наперебой мелькали в голове у Гены, пробиваясь сквозь пелену худшего похмелья в его не такой уж и долгой жизни.


Он сидел в комнате для совещаний, закрыв лицо руками, и не верил своим ушам. Помимо него в комнате находилось ещё двое: крестьянин из Вилларибо и единственный штатный монах во всём Балашове по имени Станислав. Станислав, в отличие от Гены, был отправлен в Балашов на пенсию, а не «чтобы не отсвечивать». Навоевался монах в свои годы, и просто хотел на старости лет отдохнуть.


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


— Откуда он тут взялся? — это оказался единственный вопрос, который был способен сгенерировать Генин разум в этой ситуации.


— Я не знаю, да это и неважно. Важно лишь то, что все крестьяне из обоих деревень скоро станут скелетами, если уже не стали. Это больше 400 скелетов. С этой армией он наверняка захочет захватить наш замок, — Станислав был мрачен, но непоколебим.


— И что нам теперь делать?


— Нам нечем защищаться. У нас нет войск. Все, кто могут держать оружие, слишком далеко – они не успеют вернуться в замок до атаки некроманта.


— Так и как же быть?


— Достраивать защитные и тренировочные сооружения тоже не получится – уж лучше стать скелетом, чем потом объясняться с руководством почему мы всё это построили без согласования.


— И какие у нас варианты?


— Нам поможет только один вариант – это магия. Мы построим гильдию магов, и у нас появится шанс отбить наш замок с помощью правильной магической стратегии. Хорошо, что за строительство гильдии магов отвечает монастырская коллегия, у меня там есть пара знакомых.


— Так каков в итоге план?


— Мы построим гильдию магов первого уровня и выдадим тебе книгу заклинаний. С этой книгой ты пойдёшь на срочные курсы повышения магической квалификации. За пару дней тебе придётся стать мало-мальски достойным магом. Пока ты будешь просвещаться, мы достроим гильдию магов до приемлемого уровня. Если нам повезёт с заклинаниями, то у нас будет шанс.


Замыленные месяцем возлияний мозговые шестерёнки в голове у Гены наконец-то со скрипом прокрутились, и он с ужасом осознал: ему придётся изучать магию. МАГИЮ! Магию изучают ЗУБРИЛЫ! Сам же Гена всегда был свято уверен, что жизнь слишком коротка, чтобы тратить её на изучение каких-то ветхих непонятных книг.


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


— Я, я не могу изучать м-магию. Мне нельзя магию! Мне нельзя доверять книгу заклинаний, я даже грифонов в таверне умудрился пропить! Нам лучше сдать замок и эвакуироваться!


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


Запуск сетевой игры


За запуск сетевой игры отвечает тест launch_game. Это первый тест, куда "сходятся" отдельные ветки Ubuntu и Windows 7. Это и понятно, ведь для запуска сетевой игры требуется наличие в тесте обоих машин (ранее мы могли всё делать по отдельности). Ubuntu (Сандро) выступает в качестве сервера, а Windows (Гена) — в качестве клиента. Так что тест выглядит следующим образом:


1) Копируем папку Maps с хоста на Ubuntu и запускаем VCMI. Создаём новую конференцию для игры по сети.


Сниппет


2) Копируем папку Maps с хоста на Windows 7 и запускаем VCMI. Подключаемся к конференции по IP-адресу.


Сниппет


3) Каждый игрок выбирает себе начальный бонус, после чего игра запускается.


Сниппет


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


Но дальше начинается сама игра. И если вы думаете, что вся партия будет выглядеть как простой набор прокликиваний по куче картинок, то вы заблуждаетесь. Нет, здесь начинается самая интересная часть нашей статьи. Благодаря небольшому тюнингу языка и специальным нейросетям я смог научить платформу Testo неплохо ориентироваться в мире иконок и моделей Героев 3.




— Просто поставь подпись в ведомости вот здесь, — пробормотал Игорь, — теперь давай сюда свою книгу, сейчас мы её разблокируем…


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


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


— В смысле «могу»?! Я же ничего не знаю! Я не умею в МАГИЮ!


— Конечно можешь. Я же только что проштамповал книгу печатью базовой мудрости.


Гена просто молчал.


— Ты что, вообще не знаешь, как работает магия?


Больше молчания.


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


По умолчанию в книгу можно проставлять только слабенькие заклинания, 1-2 уровней. Чтобы заносить заклинания помощнее, нужно разблокировать книгу специальными печатями. Станислав попросил меня проставить тебе печать базовой мудрости, так что вот – теперь и заклинания третьего уровня можно заносить.


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


Гена никогда ещё не получал так много информации в такие сжатые сроки. Но он знал, что ради родного Балашова (и родных феечек) он должен стать лучше! Он должен попробовать вникнуть!


— А пользоваться то этой книгой как?


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


На слове «малограмотные» Гена непроизвольно сглотнул слюну. Слово «аутентификация» он решил просто проигнорировать.


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


Гена сомневался, что он хоть один вопрос сможет осилить, но решил снова промолчать.


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


Гене нравилось слово «шпаргалка», но не нравилось слово «зубрить». Зубрить шпаргалки – это же почти и есть «Учиться»! Но выбора у Гены не было, так что он спрятал в рюкзак свою книгу заклинаний и поплёлся проставлять оставшиеся печати.


Похождения на карте и не только



Начинается игра, и возникает вопрос: а как автоматизировать действия героев на карте? Сначала я пошёл довольно топорным путём: каждый пункт назначения Сандро и Гены был оформлен в виде изображения-шаблона, который затем передавался на вход действию mouse click img. То же самое касалось любых других действий: нажатие иконок, постройка зданий и так далее. То есть я применял довольно прямолинейный (и распространённый) способ управлять действиями на экране виртуалки — поиск изображения по образцу.


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



Так что я подумал, а почему бы не сделать отдельную нейросеть для работы исключительно с объектами в Героях 3? За счёт того, что этой нейросети придётся работать с ограниченным набором объектов, она сможет довольно качественно их распознавать, даже несмотря на анимацию, вариативность цветов и так далее.


А чтобы этой нейросетью можно было удобно пользоваться, я решил добавить новую возможность в язык Testo-lang. Теперь действия wait и mouse click помимо текста и картинок поддерживают возможность ожидания и кликов по объектам Героев!


Вот так выглядел один из фрагментов теста до преобразований:



А вот так — после:



Стало ли удобнее? На первый взгляд может показаться, что разница минимальна, но это не так: за каждой картинкой в img скрывается ручная работа: требуется сделать скриншот виртуальной машины и вырезать оттуда требуемый шаблон. Поиск с помощью homm3 работает на основе нейросетей, так что никаких действий с моей стороны не требуется — только пользуйся. К тому же нейросети берут на себя заботы по возможной анимированности искомых объектов и помогут ещё в ряде случаев (подробности будут дальше).


Уважаемые читатели! Функционал платформы Testo по поддержке работы с объектами Героев 3 я выполнил в режиме Proof Of Concept и исключительно just for fun. Этот функционал очень сильно недоделан и не подходит для полноценного использования с Героями 3. В конце статьи доступна экспериментальная сборка Testo, на которой вы сможете запустить примеры, но доделывать полноценный вариант я не решился из-за сомнительной ценности и больших трудозатрат.

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




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


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


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


Обучение геройской сети


Итак, мы решили создать нейросеть, чтобы облегчить себе прокликивание различных объектов. С чего же нам начать? Создание любой нейросети начинается с данных для обучения. В нашем случае мы решаем задачу, которая называется object detection. Соответственно, для обучения нам понадобятся скриншоты Героев, для каждого скриншота должен быть список объектов, которые изображены на нём, их классы (например, "герой" или "город") и их координаты (так называемый bounding box, сокращённо bbox). Причем данных нам понадобится много: гигабайты, а ещё лучше — десятки гигабайт. Как же нам создать такой большой датасет? Давайте рассмотрим основные варианты:


  1. Можно вручную наснимать 100 тысяч скриншотов Героев и так же вручную их разметить. По понятным причинам этот вариант отметаем сразу.
  2. Мы можем воспользоваться тем фактом, что VCMI движок перед отрисовкой очередного кадра и так знает, где какие объекты находятся. То есть теоретически мы можем подправить исходники VCMI таким образом, чтобы он нагенерил нам большое количество скриншотов и сохранил информацию об объектах куда-нибудь в файл, например. Исходники VCMI довольно хорошего качества, однако реализовать такой план не так-то просто, потому что логика отрисовки объектов тесно переплетена с механикой игры. Нельзя просто так взять и сказать "отрисуй мне вот эту карту". К сожалению, придётся отказаться от этого плана из-за слишком больших трудозатрат.
  3. Есть ещё один компромиссный вариант, который подходит для большинства задач класса object detection. Идея заключается в том, чтобы вручную разметить относительно небольшое количество скриншотов (допустим — 100 штук), а затем разнообразить их путём отрисовки в случайных местах тех объектов, которые мы собирается потом детектить. Разумеется, дополнительно можно применить и другие способы искажения изображений: кадрирование, масштабирование, отражение слева-направо, изменения контраста и яркости, инверсия цветов и т.д. Этот вариант вполне рабочий, а главное его можно реализовать всего за пару дней.

Сказано — сделано. Разметить 100 скриншотов не представляет никакой сложности, особенно, если использовать подходящий инструмент разметки. Можно использовать любой из свободных инструментов, например — LabelImg. Но я использую собственный интрумент разметки на базе Electron и KonvaJS. Использование своего инструмента удобнее с той точки зрения, что его легче затачивать именно под решение своих задач.



Итак, с разметкой разобрались, теперь нужно определиться с тем, какие объекты мы будем рисовать поверх скриншотов. Я решил, что нейросети стоит применять только там, где плохо подходит стандартный и уже встроенный в Testo детект изображений (wait img). Поэтому для определения зоны ответственности для новой нейросети я руководствовался следующими принципами:


  1. В первую очередь, конечно, это анимированные объекты, потому что иначе действие wait img просто не будет работать. К таким объектам относятся, например, анимированные иконки построек в меню города.
  2. Объекты, у которых может быть разный фон (иначе понадобится вырезать новую картинку на каждый новый фон). Пример такого объекта — иконка героя или любого другого объекта на карте.
  3. Объекты, у которых может быть много вариаций. Например — кнопка ОК. У неё в игре есть где-то 5-6 разных вариантов отрисовки. Вырезать 5-6 картинок — это не проблема, но каждый раз подбирать, какая же картинка лучше подходит в данном конкретном случае — нет никакого желания.

Для того, чтобы извлечь оригиналы иконок объектов с альфа каналом я использовал проект lodextract. На вход ему подаются .lod файлы из оригинальной версии игры, а на выходе он выдаёт неимоверно большое количество .png картинок (десятки тысяч), например, вот таких:



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



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


Процесс искажения изображений я разделяю на два этапа. Наиболее сложные операции, в том числе — отрисовка объектов поверх скриншота, я делаю до обучения нейросети. Результат этого этапа сохраняется на диск, получается около 100Гб данных. Приятным бонусом в такой схеме является то, что можно любым удобным способом открывать файлы на диске и проверять, что данные сгенерировались правильно. Второй этап происходит "на лету" уже во время обучения сети. Сюда входят простые операции, такие как изменение контраста или инверсия цветов.


Таким образом мы плавно подошли к обучению нейросети. Для начала нам нужно определиться с её архитектурой. Я выбрал yolov3-tiny, но на самом деле это не так уж важно. Для нашей очень простой задачи, впринципе, подойдёт любая архитектура, выполняющая детект объектов. Гораздо бОльшее значение здесь имеет датасет. Нейросети имеют удивительное свойство "выдавать результат" несмотря на их небольшой размер или неоптимальную архитектуру. Главное не давать сети зацикливаться на косвенных признаках объектов. Например, нейросеть может подумать, что коричневые пиксели — это учёный (scholar), если на скриншотах в датасете больше не встречается объектов коричневого цвета. Поэтому так важно иметь большой и качественный датасет. А архитектура нейросети — это второстепенный вопрос.


Я не буду останавливаться детально на архитекруте Yolo, тем более, что есть подробная статья на эту тему. Обратим внимание только на входные и выходные данные.



На вход сети подаётся набор картинок, а на выходе для каждой картинки мы получаем по несколько тысяч векторов (для больших картинок, типа FullHD). Это одна из особенностей работы нейросетей: мы в какой-то степени использум брутфорс, нежели какую-ту изощренную логику. Каждый вектор состоит из следующих значений:


  • вероятность, что этот вектор описывает какой-то объект на картинке (objectness score). Для большинства векторов это значение будет около нуля.
  • координаты центра объекта (x, y), а также его ширина и высота (w, h)
  • набор значений, описывающих вероятности того, что этот объект принадлежит к каждому из классов (class scores)

То есть выход нейросети требует ещё некоторой постобработки. Нужно отфильтровать вектора с низким objectness score, а также объединить вектора, которые указывают на один и тот же объект. Эти операции не добавляются в модель нейросети, потому что они плохо подходят под архитектуру GPU (у нас получится размерность выходных данных всё время разная). К этому вопросу мы вернёмся чуть позже, а пока предлагаю мельком взглянуть на код нейросети, написанный с ипользованием фреймвока PyTorch:


Код модели
import torch
import torch.nn as nn
import torch.nn.init as init
import torch.nn.functional as F
from dataset import classes_names

def Conv(in_ch, out_ch, kernel_size, activation='leaky'):
    seq = nn.Sequential(
        nn.Conv2d(in_ch, out_ch, kernel_size=kernel_size, padding=kernel_size//2),
        nn.BatchNorm2d(out_ch),
    )
    if activation == 'leaky':
        seq.add_module("2", nn.LeakyReLU(0.1))
    elif activation == 'linear':
        pass
    else:
        raise "Unknown activation"
    return seq

def MaxPool(kernel_size, stride):
    if kernel_size == 2 and stride == 1:
        return nn.Sequential(
            nn.ZeroPad2d((0, 1, 0, 1)),
            nn.MaxPool2d(kernel_size, stride)
        )
    else:
        return nn.MaxPool2d(kernel_size, stride)

# Псевдо-слой сети, служит для конкатенации выходов из нескольких других слоёв 
class Route(nn.Module):
    def __init__(self, layers_indexes):
        super().__init__()
        self.layers_indexes = layers_indexes

class Upsample(nn.Module):
    def __init__(self, scale_factor, mode="nearest"):
        super().__init__()
        self.scale_factor = scale_factor
        self.mode = mode

    def forward(self, x):
        x = F.interpolate(x, scale_factor=self.scale_factor, mode=self.mode)
        return x

num_classes = len(classes_names)

# Псевдо-слой сети, служит для нормализации выходов предыдущего слоя и 
# преобразования x,y,w,h в пиксели
class Yolo(nn.Module):
    def __init__(self, anchors):
        super().__init__()
        self.anchors = anchors
        self.mse_loss = nn.MSELoss()
        self.bce_loss = nn.BCELoss()
        self.obj_scale = 1
        self.noobj_scale = 100

    # Вычесление intersection over union двух bbox-ов
    def bbox_wh_iou(self, w1, h1, w2, h2):
        inter_area = torch.min(w1, w2) * torch.min(h1, h2)
        union_area = w1 * h1 + w2 * h2 - inter_area
        return inter_area / (union_area + 1e-16)

    def forward(self, x, img_w, img_h):
        B, C, H, W = x.shape

        num_anchors = len(self.anchors)

        prediction = x.view(B, num_anchors, num_classes + 5, H, W) \
            .permute(0, 1, 3, 4, 2) \
            .contiguous() # преобразуем к формату (B, num_anchors, H, W, num_classes + 5)

        # размеры одной ячейки (ширина и высота) в пикселях
        stride_x = img_w / W
        stride_y = img_h / H

        # номера столбцов и строк
        grid_x = torch.arange(W).repeat(H, 1).view([1, 1, H, W]).to(x.device)
        grid_y = torch.arange(H).repeat(W, 1).t().view([1, 1, H, W]).to(x.device)

        # размемы якорей (ширина и высота) в пикселях 
        anchor_w = x.new_tensor([anchor[0] for anchor in self.anchors]).view((1, num_anchors, 1, 1))
        anchor_h = x.new_tensor([anchor[1] for anchor in self.anchors]).view((1, num_anchors, 1, 1))

        # преобразуем x,y,w,h в пиксели
        pred_x = (prediction[..., 0].sigmoid() + grid_x) * stride_x
        pred_y = (prediction[..., 1].sigmoid() + grid_y) * stride_y
        pred_w = prediction[..., 2].exp() * anchor_w
        pred_h = prediction[..., 3].exp() * anchor_h

        # приводим вероятности к диапазону [0, 1]
        pred_conf = prediction[..., 4].sigmoid()
        pred_cls = prediction[..., 5:].sigmoid()

        # из матрицы делаем список
        # это позволит объединить вместе выходы Yolo разных размеров
        return torch.cat(
            (
                pred_x.view(B, -1, 1),
                pred_y.view(B, -1, 1),
                pred_w.view(B, -1, 1),
                pred_h.view(B, -1, 1),
                pred_conf.view(B, -1, 1),
                pred_cls.view(B, -1, num_classes),
            ),
            -1,
        )

class Model(nn.Module):
    def __init__(self):
        super().__init__()

        # размеры якорей
        anchors_1 = [(81,82), (135,169), (344,319)]
        anchors_2 = [(10,14), (23,27), (37,58)]

        # список всех слоёв сети
        self.module_list = nn.ModuleList([
            Conv(3, 16, 3),
            MaxPool(2, 2),
            Conv(16, 32, 3),
            MaxPool(2, 2),
            Conv(32, 64, 3),
            MaxPool(2, 2),
            Conv(64, 128, 3),
            MaxPool(2, 2),
            Conv(128, 256, 3),
            MaxPool(2, 2),
            Conv(256, 512, 3),
            MaxPool(2, 1),
            Conv(512, 1024, 3),
            #############
            Conv(1024, 256, 1),

            Conv(256, 512, 3),
            Conv(512, (num_classes + 5) * len(anchors_1), 1, activation='linear'),
            Yolo(anchors_1),

            Route([-4]),
            Conv(256, 128, 1),
            Upsample(2),
            Route([-1, 8]),
            Conv(128 + 256, 256, 3),
            Conv(256, (num_classes + 5) * len(anchors_2), 1, activation='linear'),
            Yolo(anchors_2)
        ])

    def forward(self, img):
        layer_outputs = []
        yolo_outputs = []

        x = img
        # просто применяем очередной слой к выходу от предыдущего слоя
        # (или объединяем выходы нескольких слоёв в случае с Route) 
        for module in self.module_list:
            if isinstance(module, Route):
                x = torch.cat([layer_outputs[i] for i in module.layers_indexes], 1)
            elif isinstance(module, Yolo):
                x = module(x, img.shape[3], img.shape[2])
                yolo_outputs.append(x)
            else:
                x = module(x)
            layer_outputs.append(x)

        return torch.cat(yolo_outputs, 1)

У нас получилась модель, которая на диске занимает примерно 35Мб. На самом деле это перебор для такой простой залачи, как детект объектов в Героях. Я уверен, что можно безболезненно уменьшить размер модели где-то до 5Мб, при этом не потеряв в точности детекта. Но на это нет времени, история Гены и Сандро ждёт своего продолжения. Двигаемся дальше.


Имея датасет и код нейросети, обучить её не составяет труда. Запускаем обучение и уходим погулять часа на 3.



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


Сеть обучена, замечательно. Однако возникает резонный вопрос, как это дело интегрировать в конечный продукт (если он не на питоне)? Самый простой, наверное, способ — это слинковаться с libpytorch. Более того, мы могли бы вместо того, чтобы писать код нейросети на питоне — написать его сразу на С++ и даже получить некоторый прирост производительности, благо PyTorch предоставляет C++ frontend. Однако, не очень то хочется тащить за собой весь PyTorch, ведь он даже в заархивированном виде весит целый гигабайт. Поэтому я использую OnnxRuntime. Он позволяет сократить размер дистрибутива в два раза, а также увеличить производительность работы нейросетей. Как следует из названия, этот проект позволяет загружать обученные модели нейросетей в формате onnx и запускать их, так что нам для начала нужно экспортировать нашу модель в этот формат:


model = Model()
model.load_state_dict(torch.load("path_to_model.pt", map_location=torch.device('cpu')))
model.eval()

x = torch.randn(1, 3, 480, 640)
output = model(x)

torch.onnx.export(model, x, "model.onnx",
    input_names=["input"],
    output_names=["output"],
    dynamic_axes={
        'input': {
            2: 'height',
            3: 'width'
        },
        'output': {
            2: 'height',
            3: 'width'
        }
    },
    opset_version=11
)

Я указал здесь параметр dynamic_axes, это позволит подавать на вход сети картинки любого размера. Вообще, с экспортом в формат onnx нужно быть очень осторожным. Когда мы пишем код модели на питоне — то мы используем привычные нам циклы, условия и переменные. А формат onnx описывает граф с вершинами и ребрами. Сконвертировать одно в другое — это совершенно нетривиальная задача. Убедиться в том, что экспорт прошел успешно, можно с помощью просмотрщика формата onnx, например с помощью Netron. Но скорее всего, если что-то пойдёт не так, PyTorch выдаст предупреждение. Экспортировав модель в формат onnx, мы можем загрузить её из С++/C#/Java/NodeJS. Ниже пример для питона:


import onnxruntime
import numpy
ort_session = onnxruntime.InferenceSession("model.onnx")
x = numpy.rand(1, 3, 480, 640)
ort_inputs = {"input": x}
ort_outs = ort_session.run(None, ort_inputs)

Вот здесь как раз можно выполнить постобработку результатов работы нейросети. Векторы с низким objectness score просто отбрасываем, а к остальным применяем алгоритм Non-Maximum Suppression. Давайте, наконец, запустим нашу свежеобученную нейросеть и посмотрим, как она работает:



Я обучал нейросеть детектить только те объекты, которые мне нужны для теста, поэтому на скриншоте выше подсвечены не все объекты. Ура! Вроде всё работает, а значит мы можем автоматизировать похождения Гены и Сандро по карте:



Битва


Гена стоял на стене замка и в тайне надеялся, что он до сих пор находится в страшном алкогольном сне. За свою недолгую военную карьеру Гена так и не успел повидать не то, что настоящего некроманта, но даже простого скелета. Сейчас же скелетов перед ним было столько, что они даже не помещались в одно поле зрения.


Гена тоскливо взглянул назад, где сиротливо стояли его «войска» — все его 20 феечек, которые неделю назад решили проводить его до города. Гене пришлось включить всё своё природное обаяние и пообещать феечкам целые груды золота и запасов вина чтобы они согласились остаться оборонять город. Гена заверил их, что до них никто и пальцем не дотронется, исход битвы должна решить только магия.


Перед боем Станислав рассказал Гене, как нужно использовать магию: сначала необходимо притормозить скелетов с помощью замедления (и не забывать следить за тем, чтобы эффект заклинания не кончился), а затем использовать заклинание «Уничтожить нежить» до тех пор, пока Гене будет хватать знаний. Знаний у Гены конечно прибавилось после всех шпаргалок с ответами, которые он добывал всю неделю. К тому же перед боем он хлебнул воды из какого-то магического фонтана, после чего у него на удивление сильно обострилась память, так что ответы из шпаргалок прочно сидели у него в голове.


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




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


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



Битва будет протекать по такому сценарию:


  1. Сандро будет атаковать на автопилоте.
  2. Гена кастует экспертное замедление и перемещает феечек наверх карты.
  3. Пока Сандро медленно продвигает своих скелетов вперёд, Гена кастует "Уничтожение нежити".
  4. Через 5 ходов Гена обновляет замедление и перемещает феечек в низ карты.
  5. Гена продолжает кастовать "Уничтожение нежити" пока у него не кончится мана или пока не кончатся скелеты.

Тест может закончиться тремя исходами:


  1. Бой заканчивается поражением Гены меньше, чем за 13 ходов — провал.
  2. Бой длится 13 ходов и больше — провал.
  3. Гена побеждает быстрее, чем за 13 ходов — успех.

Вот так это выглядит в виде теста:



Часть действий я инкапсулировал в виде макросов cast_slow_routine и blue_battle_routine, чтобы тест не выглядел слишком загромождённым.


Исход битвы, пожалуй, не буду спойлерить — лучше посмотрите сами.



Гена с ужасом смотрел в книгу заклинаний. До сих пор ему удавалось отвечать на все всплывающие вопросы, и ряды скелетов уменьшались прямо на глазах. Гена уже предвкушал свою первую в жизни победу, да ещё какую! Воистину эпическую! Гена уже начал задумываться о том, какую вечеринку он закатит после этой победы, какая слава его ждёт. Его не смущали ни разрушенные стены замка, ни скелеты, которые уже были внутри города. Ещё буквально одно заклинание – и всё будет кончено. Так он думал пол минуты назад.


«Сколько раз за один ход королевский грифон может ответить на атаку противника?» — гласила надпись на книге. Это был первый вопрос, которого не было в шпаргалках Гены.


За всё своё время рыцарства Гена так и не удосужился узнать хоть что-нибудь об этих ужасных страшных созданиях. Его познания о грифонах ограничивались тем, что если пропить их в таверне, тебя сошлют в Балашов.


Гена стал судорожно подбирать правильный ответ. Книга допускала 2 неверные попытки, после чего заклинание блокировалось на какое-то время. У Гены этого времени не было: оставшиеся скелеты уже ломились к нему на стену замка. От фей тоже было мало проку: они забились в угол и ждали, когда Гена, наконец, прочтёт завершающее заклинание.


Варианты «2» и «3» раза не понравились книге заклинаний.




Гена стоял со связанными руками перед преобразователем скелетов и думал, что же пошло не так. Как победа умудрилась выскользнуть из его рук? Если бы только он был немного умнее… Если бы он хоть немного слушал на лекциях своих преподавателей, которые не раз и не два рассказывали про этих проклятых грифонов… Было ли бы этого достаточно чтобы победить злого Сандро? Эх, чего уж теперь гадать, пора шагать в преобразователь.


А где же хеппи энд?


Действительно, не можем же мы завершить такую эпичную сагу на такой негативной ноте. Новый год же! Как мы увидели, Гена вполне себе уверенно шёл к успеху, пока у него неожиданно закончилась мана. Может быть, надо просто сделать Гену изначально несколько "умнее"? Так давайте! Откроем редактор карт и добавим Гене немного "знаний":



Ну а теперь я наконец то могу ответить на вопрос, почему же я в тестах копирую папку Maps не в тесте install_vcmi, а в тесте launch_game. Как только мы поменяли файл Villaribo_and_Villabadjo_lose.h3m, Testo сбросит кеш того теста, где этот файл задействован (т.е. launch_game). Потерявший кеш тест (и его потомки) запустится заново. Если бы я копировал Maps в тестах install_vcmi, то именно эти тесты потеряли бы кеш. А значит, пришлось бы заново прогонять установку vcmi и копирование других папок (Data, Mp3 и прочее).


Я просто построил дерево тестов таким образом, чтобы редактирование карты не запускало повторную установку vcmi.



А вот и хэппи энд подъехал! Оказывается, Гене всего лишь нужно было лучше слушать преподавателей в военном ВУЗе!


Заключение


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


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


Репозиторий со всеми необходимыми сценариями и артефактами (в том числе с экспериментальной сборкой Testo) можно найти вот тут:


https://github.com/testo-lang/testo-articles/tree/master/HOMM3

Tags:
Hubs:
+40
Comments 11
Comments Comments 11

Articles

Information

Website
testo-lang.ru
Registered
Founded
Employees
2–10 employees
Location
Россия