Pull to refresh

Написание бота для флэшевой игры

Reading time 11 min
Views 74K

Зачем?


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

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

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

Ну да ладно, это лирика. На самом деле я сразу попробовал выбить скидку, однако, весь мой геймерский опыт не помог мне с первого раза получить даже 17%. Сыграв несколько раз, я всё же набрал требуемые 17000 очков, но было ясно, что даже 20000 являются недостижимой планкой, не говоря уже о заветных 25000. Проклятые кроты лезут со всех щелей, но быстро прячутся обратно. При этом за «исцеление» крота даётся 100-200 очков, так что пропускать их нельзя. Не знаю, под силу ли это человеку.

Решение пришло в голову сразу же – нужно писать бота, который пройдёт игру за меня! Процесс написания бота на C# подкатом.

Как?


Концепция

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

Глаза

Для начала возьмём OpenCV, чтобы захватывать кадры с экрана и распознавать объекты… СТОП. Мне же не нужно какое-то суперприложение, мне просто нужно получить эту скидку. Стоит ли возиться с OpenCV? Может, проще запустить поток, который в бесконечном цикле будет делать скриншот экрана и просматривать его? Например, так:

Bitmap bmpScreen = new Bitmap(Screen.PrimaryScreen.Bounds.Width, Screen.PrimaryScreen.Bounds.Height);
Graphics g = Graphics.FromImage(bmpScreen); 
            
while (true)
{
   g.CopyFromScreen(Screen.AllScreens[0].Bounds.X, Screen.AllScreens[0].Bounds.Y, 0, 0,
bmpScreen.Size, CopyPixelOperation.SourceCopy);
}

Руки

А как «лечить» кротов? Очевидно, нужно при запуске найти окно браузера и слать ему сообщение WM_CLICK с нужными параметрами. Впрочем, можно сделать всё проще – физически передвигать курсор на нужное место экрана и эмулировать нажатие клавиш.

Импортируем соответствующие функции WinAPI

[DllImport("user32.dll")]
static extern bool GetCursorPos(ref Point lpPoint);
[DllImport("user32.dll")]
static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")]
static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, IntPtr dwExtraInfo);


И напишем функцию для клика

            static public void MouseClick()
            {
                Point ptCoords = new Point();
                GetCursorPos(ref ptCoords);

                uint x = (uint)ptCoords.X;
                uint y = (uint)ptCoords.Y;

                System.IntPtr ptr = new IntPtr();

                mouse_event(MOUSEEVENTF_LEFTDOWN, x, y, 0, ptr);
                mouse_event(MOUSEEVENTF_LEFTUP, x, y, 0, ptr);
            }



Теперь, когда все служебные функции есть, осталось написать логику.

Мозги

Пройдя вручную несколько раз первый тур, я сделал несколько наблюдений:
  1. Кроты появляются в одних и тех же местах.
  2. Дополнительные цели (робот и НЛО) отпугивают кротов, поэтому их надо «лечить» в первую очередь. Притом появляются они в строго определённых местах.
  3. За ракету «Меди» даётся 500 очков, и зависает она в одном месте


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

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

А как записывать координаты? Наиболее удобный для пользователя способ – сделать хоткей, по нажатию которого координаты курсора сохраняются, например, в файл. Тогда при появлении крота нужно навести на него курсор и нажать хоткей. Скажу честно, что сперва я так и сделал. Впоследствии оказалось, что куда как проще наделать скриншотов с кротами, измерить положение каждого крота в графическом редакторе, и эти координаты попросту захардкодить. Получилось что-то вроде этого

List<Point> m_lpTargets = new List<Point>();


            m_lpTargets.Add(new Point(557, 623)); //верхняя лунка
            m_lpTargets.Add(new Point(261, 654)); //левая лунка
            m_lpTargets.Add(new Point(352, 486)); //подпрыгивающий слева крот
            m_lpTargets.Add(new Point(450, 500)); //ракета
            m_lpTargets.Add(new Point(592, 698)); //нижняя лунка
            m_lpTargets.Add(new Point(756, 631)); //правая лунка
            m_lpTargets.Add(new Point(373, 514)); //НЛО
            m_lpTargets.Add(new Point(481, 440)); //подпрыгивающий посередине крот



Добавим на форму кнопку Aim, которая будет делать эталонный скриншот

        private void btnAim_Click(object sender, EventArgs e)
        {
            m_bmpReference = new Bitmap(Screen.PrimaryScreen.Bounds.Width,
                                            Screen.PrimaryScreen.Bounds.Height);
            Graphics g = Graphics.FromImage(m_bmpReference);

            g.CopyFromScreen(Screen.AllScreens[0].Bounds.X,
                     Screen.AllScreens[0].Bounds.Y,
                     0, 0,
                     m_bmpReference.Size,
                     CopyPixelOperation.SourceCopy);

        }


Теперь добавляем в главный цикл код нашего бота и запускаем!

                foreach (Point pt in m_lptTargets)
                {
                    if (m_bmpReference.GetPixel(pt.X, pt.Y) != bmpScreen.GetPixel(pt.X, pt.Y))
                    {
                        MouseMoveTo(pt, 10, 200);
                        MouseClick();
                    }
                }

Что получилось?


Первый результат

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

Главный рубильник

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

        private void Form1_Resize(object sender, EventArgs e)
        {
            if (WindowState == FormWindowState.Minimized)
            {
                m_objAimingThread.Abort();
            }
        }


Теперь, чтобы остановить бота нужно просто нажать Win+D.

Первые проблемы

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

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

Я рассудил так — раз в ручном режиме такой проблемы не возникает, то нужно просто делать паузу, а может и вообще эмулировать движение мыши до цели.

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

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

Новая концепция

Итак, кроты появляются в разных местах, поэтому отслеживать их по координатам невозможно. Неужели придётся делать распознавание образов? Не хотелось бы. Попробую перед этим ещё одну «тупую» реализацию. Что общего у всех кротов? Скафандры и ботинки видны не у всех. Но у всех кротов видна голова в шлеме. К тому же шлем имеет весьма необычный цвет… Что если сканировать всё изображение на предмет наличия этого голубоватого оттенка? Попробуем!

Color clHelmet = Color.FromArgb(102, 142, 193);

                for (int j = 400; j < 880; j += 1)
                    for (int i = 200; i < 850; i += 1)
                    {
                        if (bmpScreen.GetPixel(i, j) == clHelmet)
                        {
Point ptTarget = new Point(i, j);

                                MouseMoveTo(ptTarget);
                                MouseClick();
                                
                            }
                        }
                    }


На скриншоте я посмотрел координаты рабочей области флешки и цвет шлема. Теперь на своём Core i5 я просто перебираю в цикле все пиксели с помощью супер-тормозного метода GetPixel, и стреляю при совпадении цвета с эталонным. При таком подходе время выполнения одного цикла составляет около 200 миллисекунд, что кажется допустимым значением. Запускаем!

И новые проблемы


Всё оказалось не так просто – на шлеме есть не одна точка эталонного цвета, поэтому бот при появлении крота начинает лупить в белый свет как в копеечку, игнорируя других пациентов. Решение нашлось довольно просто – будем сохранять координаты последнего выстрела, и при обработке текущего кадра игнорировать пиксели в окрестности этих координат.

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

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

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

                Point ptLastHit = new Point(0,0);
               
                for (int j = 400; j < 880; j += 2)
                    for (int i = 200; i < 850; i += 2)
                    {
                        if (bmpScreen.GetPixel(i, j) == clHelmet)
                        {
                            Point ptTarget = new Point(i, j);

                            if (Distance(ptLastHit,ptTarget) > 70)
                            {
                                ptLastHit = ptTarget;

                                MouseMoveTo(ptTarget);
                                MouseClick();
                                
                                ptTarget.Offset(20, 20);
                                MouseMoveTo(ptTarget);
                                MouseClick();

                                ptTarget.Offset(-40, 0);
                                MouseMoveTo(ptTarget);
                                MouseClick();                                
                            }
                        }
                    }


Сортировка пациентов

Теперь всё хорошо – кроты успешно исцеляются, но вернулась проблема с кротами из лунок. Помните, они не исчезали, если стрелять по ним без задержки? Я вернул задержку выстрела в 250мс, но при такой задержке становится невозможным попасть в бегущих кротов… Решение нашлось довольно быстро – забьём в код координаты лунок, и будем давать задержку на стрельбу только в том случае, если цель появляется в этих областях.

Ещё для увеличения эффективности стрельбы по прыгающим кротам стоит останавливать обработку кадра после поражения первой найденной цели. Поясню – пусть на экране находтся два крота, тогда пока мы стреляем по одному, второй уже успевает немного сместиться, в то время как на старом кадре у нас остались его прежнее изображение. В этот момент мы сталкиваемся с одной из глобальных проблем всех си-подобных языков – невозможность прерывания двух вложенных циклов с помощью команды break. Я в таки случаях совершаю ужасное кармическое преступление, используя оператор goto.

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

                g.CopyFromScreen(Screen.AllScreens[0].Bounds.X,
                                     Screen.AllScreens[0].Bounds.Y,
                                     0, 0,
                                     bmpScreen.Size,
                                     CopyPixelOperation.SourceCopy);

                foreach (Point pt in m_lptTargets)
                {
                    if (m_bmpReference.GetPixel(pt.X, pt.Y) != bmpScreen.GetPixel(pt.X, pt.Y))
                    {
                        
                        Point ptMouse = new Point();

                        GetCursorPos(ref ptMouse);

                        if (ptMouse != pt)
                        {
                            MouseMoveTo(pt);
                            MouseClick();
                        }
                    }
                }                

                //лунки
                Rectangle hole1 = new Rectangle(539, 612, 60, 50);
                Rectangle hole2 = new Rectangle(577, 690, 60, 50);
                Rectangle hole3 = new Rectangle(738, 621, 60, 50);
                Rectangle hole4 = new Rectangle(243, 641, 60, 50);
                //луна
                Rectangle hole5 = new Rectangle(379, 415, 45, 40);

                int iDelay = 5;

                Color clHelmet = Color.FromArgb(102, 142, 193);

                DateTime tmNow = DateTime.Now;

                Point ptLastHit = new Point(0,0);
               
                for (int j = 400; j < 880; j += 2)
                    for (int i = 200; i < 850; i += 2)
                    {
                        if (bmpScreen.GetPixel(i, j) == clHelmet)
                        {
                            Point ptTarget = new Point(i, j);

                            if (hole1.Contains(ptTarget) || hole2.Contains(ptTarget) || hole3.Contains(ptTarget) || hole4.Contains(ptTarget) || hole5.Contains(ptTarget))
                            {
                                iDelay = 200;
                            }

                            if (Distance(ptLastHit,ptTarget) > 70)
                            {
                                ptLastHit = ptTarget;
                                Thread.Sleep(iDelay);

                                MouseMoveTo(ptTarget);
                                MouseClick();
                                
                                ptTarget.Offset(20, 20);
                                MouseMoveTo(ptTarget);
                                MouseClick();

                                ptTarget.Offset(-40, 0);
                                MouseMoveTo(ptTarget);
                                MouseClick();

                                goto next;
                            }
                        }
                    }
            next: 
           
            }


Каков итог?


В общем, эта программа уже успешно набирала 21-22 тысячи очков. Для получения 25 нужно было менять некоторые магические значения типа задержек и координат выстрелов для «очереди». В определённый момент звёзды сложились удачно, и я перевалил заветную отметку в 25000, для этого потребовалась пара десятков запусков.

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

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

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

UPD. В комментариях изложены ещё способы получения скидки. К моему стыду, о самом простом из них я даже не подумал… Получается, пост как иллюстрация того, что не нужно делать сложно, когда можно сделать просто, удался именно благодаря комментаторам. Действительно, бота можно было и не писать… Надеюсь, это послужит уроком не только мне, но и остальным несчастным, страдающим компьютерной болезнью, выявленной и классифицированной полсотни лет назад.

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

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

Абсолютно бесполезное занятие. Ведь у нас уже были таблицы арктангенсов. Но если вы когда-нибудь работали с компьютерами, вы понимаете, что это за болезнь — восхищение от возможности увидеть, как много можно сделать. Френкель подцепил эту болезнь впервые, бедный парень; бедный парень, который изобрел всю эту штуку.
Tags:
Hubs:
+112
Comments 75
Comments Comments 75

Articles