Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

Бот для DirectX-аркады. Часть №1: устанавливаем контакт

Anna | 17.06.2014 | нет комментариев

Вступление

Всякий, играя, хоть раз думал: «вот бы написать программу, которая играла бы за меня!». Но традиционно эта мысль, так мыслью и остается… Непрерывно что-то мешает: незнание с чего начать, ужас перед неподъемностью задачи, шепоток из-за левого плеча «и для чего это? кому это нужно, Дабы программа играла с программой?» и т.д.

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

Теперь начнем с простого. С установления связи между игрой и программой-игроком (ботом). В качестве подопытного кролика берется широкоизвестная игра Zuma.

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

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

Отхождение

При разработке кода для данного цикла статей применяется подход: как дозволено стремительней получить итог за минимум усилий. Такой подход разрешает поддерживать мотивацию на высоком ярусе, и не дает опустить руки при виде неподъемности задачи. Из-за этого:
— во-первых, многие малозначимые (с точки зрения нынешнего итога) моменты будут стремительно пробегаться, оставляя в коде «костыли и подпорки». И только на следующих итерациях эти моменты будут отдельно разбираться, и «костыли» будут заменяться на полновесный код.
— во-вторых, жанр кода огромнее «хакерский», чем типичный C#-ный. В коде будет много лямд, неизвестных данных, трюков, авторского произвола и полное неимение комментариев.

Эмуляция мыши

Windows поддерживает 2 штатных метода эмуляции мыши с поддержкой 4 разных функций WinApi.

1-й метод: посылка программе своих window-сообщений (WM_MOUSEMOVEWM_LBUTTONDOWN и т.д.) с поддержкой функций SendMessage либо PostMessage.

Для DirectX-игр (как в нашем случае) такой метод не подходит, потому что такие программы для опроса мыши применяют DirectInput, тот, что опрашивает мышь напрямую, игнорируя windows-сообщения.

2-й метод: прямая эмуляция поведения мыши с поддержкой функций mouse_event либо SendInput. Данный метод подходит для всяких программ, в том числе и для полноэкранных DirectX-игр. Функция mouse_event примитивней, но она считается устаревшей, SendInput — современнее, но больше массивная. Остановимся на mouse_event.

WinApi-функции из C# вызываются с поддержкой спецтехнологии PInvoke. PInvoke-изложение для большинства распространных WinApi-функций дозволено взять на сайте PInvoke.net. Функция mouse_event не является исключением.

    [DllImport("user32.dll")]
    public static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, UIntPtr dwExtraInfo);
Координаты мыши

Функция mouse_event имеет специфическую специфика: координаты мыши задаются в mickey, а не в пикселях. Перерасчет mickey в пиксели (и обратно)зависит от разрешения основного используемого монитора. (0,0) соответствует левому верхнему углу монитора, а (65535, 65535) нижнему правому, что дает формулы для пересчета mickey в пиксели и обратно: mickey_point = pixel_point * (65536, 65536) / screen_size иpixel_point = mickey_point * screen_size / (65536, 65536).

Основные операции

Суммируя всё вышеперечисленное, получаем следующие операции для управления мышью.
Передвижение курсора мыши в точку (x,y):

      mouse_event(MouseEventFlags.MOVE | MouseEventFlags.ABSOLUTE, x * 65536 / screen_width, y * 65536 / screen_height);

Клик левой кнопкой мыши:

       mouse_event((MouseEventFlags.LEFTDOWN), 0, 0);
       System.Threading.Thread.Sleep(100);
       mouse_event((MouseEventFlags.LEFTUP), 0, 0);

Клик правой кнопкой мыши:

       mouse_event((MouseEventFlags.RIGHTDOWN), 0, 0);
       System.Threading.Thread.Sleep(100);
       mouse_event((MouseEventFlags.RIGHTUP), 0, 0);
Задача: эксклюзивность ввода

При эмуляции мыши через функцию mouse_event присутствует солидное неудобство: mouse_event имитирует мышь для каждой ОС сразу, а не для отдельного приложения. Из этого следует, что пока бот запущен и играется, то немыслима иная работа за компом: отладка бота, энергичный просмотр состояния бота, чтение интернета и т.д. Но есть выход: виртуальная машина!

Перенос игры на виртуальную машину

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

Бота, в различии от самой игры, комфортнее запускать на стержневой машине. Это разрешает перезапускать бота напрямую из Visual Studio, там же его отлаживать, есть куда выводить внутреннее состояние бота и т.д.

Развертывание виртуальной машины (в данном случае применялась Oracle VirtualBox), установка гостевой ОС и перенос игры делается штатным образом за исключением одного момента: для бота нужна вероятность установки связи по сети между хостовой ОС и гостевой ОС. Это делается большинством методов. Один из методов, прокинуть с поддержкой VirtualBox определенный порт из гостевой ОС в хостовую. Иной метод, настроить режим Bridged Adapter, тогда виртуалка для каждой сети будет выглядеть как обыкновенный компьютер, и гостевая ОС будет получать свой ip-адрес через dhcp от роутера. По этому адресу и будет происходит доступ из хостовой ОС в гостевую. (автором, в данном случае, применялся вариант с bridged adapter)

Прокси

imageДля управления мышью на гостевой ОС напишем прокси, представляющий из себя простенький консольный tcp-сервер. Его полный код маленький и представлен под катом. Для облегчения кода и уменьшения зависимостей прокси написан на голом socket-е без применения remoting-а, wcf и т.д.

Код прокси-сервера

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;

namespace InputProxy
{
  class Program
  {
    static void Main(string[] args)
    {
      var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
      socket.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Any, 7001));
      socket.Listen(10);
      for (; ; )
      {
        var client = socket.Accept();
        Console.WriteLine("connected..");
        var thread = new System.Threading.Thread(() =>
          {
            try
            {
              var clientReader = new System.IO.BinaryReader(new NetworkStream(client));
              for (; ; )
              {
                if (client.Poll(1, SelectMode.SelectRead) && client.Available == 0)
                {
                  Console.WriteLine("disconnected..");
                  break;
                }
                if (client.Available > 0)
                {
                  var msgSize = clientReader.ReadInt32();
                  var message = clientReader.ReadBytes(msgSize);
                  var messageReader = new System.IO.BinaryReader(new System.IO.MemoryStream(message));
                  var msgKind = messageReader.ReadInt32();
                  Console.WriteLine("message: kind:{0}, len:{1}", msgKind, message.Length);
                  switch (msgKind)
                  {
                    case 0:
                      {
                        var flags = messageReader.ReadUInt32();
                        var x = messageReader.ReadInt32();
                        var y = messageReader.ReadInt32();
                        var data = messageReader.ReadUInt32();
                        mouse_event(flags, x, y, data, UIntPtr.Zero);
                      }
                      break;
                  }
                }
                else
                  System.Threading.Thread.Sleep(10);
              }
            }
            catch (Exception exc)
            {
              Console.WriteLine(exc);
            }
          }) { IsBackground = true };
        thread.Start();
      }
    }
    [DllImport("user32.dll")]
    public static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, UIntPtr dwExtraInfo);
  }
}

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

Заказчик

Подключение к прокси еще проще, чем код самого прокси.

      var client = new System.Net.Sockets.TcpClient(vm_host, 7001);
      var clientStream = client.GetStream();
      var clientWriter = new System.IO.BinaryWriter(clientStream);

      Action<MouseEventFlags, int, int> mouse_event = (flags, x, y) =>
        {
          var messageStream = new System.IO.MemoryStream();
          var messageWriter = new System.IO.BinaryWriter(messageStream);
          messageWriter.Write(0);
          messageWriter.Write((uint)flags);
          messageWriter.Write(x);
          messageWriter.Write(y);
          messageWriter.Write(0);
          var message = messageStream.ToArray();
          clientWriter.Write(message.Length);
          clientWriter.Write(message);
          clientStream.Flush();
        };

Перехват изображения

Изображение проще каждого захватывать напрямую с экрана. В .net-е для этого есть готовая функцияGraphics.CopyFromScreen. На этом методе и остановимся подробнее.
Во-первых, на выходе хочется получить Bitmap, а не Graphics — это решается с поддержкой вспомогательной функции:

    public static Bitmap GetScreenImage(Rectangle rect)
    {
      var bmp = new Bitmap(rect.Width, rect.Height, PixelFormat.Format32bppArgb);
      using (Graphics graphics = Graphics.FromImage(bmp))
      {
        graphics.CopyFromScreen(rect.Left, rect.Top, 0, 0, rect.Size, CopyPixelOperation.SourceCopy);
      }
      return bmp;
    }

Во-вторых, нужно знать какую часть экрана нужно захватывать. Дозволено, безусловно, захватывать неизменно одну и ту же часть экрана, а игру руками располагать в этой части экрана, но это не спортивно не комфортно. Тем больше автоматизация этого процесса делается минимальными усилиями. В этом нам вновь поможет WinApi и PInvoke, а определеннее две функции: FindWindow и GetWindowRect. FindWindow разрешает по заголовку окна получить handle окна, а GetWindowRect по handle-у возвращает позицию и размер окна на экране.
Pinvoke-изложение обеих функций есть на сайте pinvoke.net: FindWindow и GetWindowRect.

    [DllImport("user32.dll", SetLastError = true)]
    public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);

    [DllImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool GetWindowRect(IntPtr hwnd, out RECT lpRect);

    [StructLayout(LayoutKind.Sequential)]
    public struct RECT
    {
      public int Left;
      public int Top;
      public int Right;
      public int Bottom;
    }

И код захвата изображения окна виртуальной машины получается дальнейшим:

      var vm_left = 8;
      var vm_right = 8;
      var vm_top = 50;
      var vm_bottom = 30;

      var vm_title = "Windows81 [Running] - Oracle VM VirtualBox";

      var handle = FindWindow(null, vm_title);
      if (handle == IntPtr.Zero)
        throw new Exception("Окно не обнаружено");

      RECT rect;
      GetWindowRect(handle, out rect);
      var gameScreenRect = new System.Drawing.Rectangle(rect.Left   vm_left, rect.Top   vm_top, rect.Right - rect.Left - vm_right - vm_left, rect.Bottom - rect.Top - vm_bottom - vm_top);
      var gameBmp = GetScreenImage(gameScreenRect);
Слабое место

Значительным недостатком данного подхода является то, что захватываемое окно, во-первых: обязано целиком располагаться на экране, а во-вторых: обязано располагаться поверх всех остальных окон. Это неудобство нивелируется с поддержкой 2-х (и больше) мониторов :) , тогда окно виртуальной машины располагается на вспомогательном мониторе, ни кому не мешая, оставаясь поверх остальных окон. Также данная задача всецело решается с поддержкой ранее расмотренного метода: переноса функции (завладение экрана) вовнутрь виртуальной машины. Для этого довольно добавить соответствующую функцию в InputProxy.

Зацикливаем игровой процесс

Наконец-то, приступаем непринужденно к решению поставленной на сегодня задаче: зацикливанию игрового процесса — все нужные подзадачи решены. Игровой процесс в Zuma вертится вокруг 3 окон: main, mission и action. Main-окно содержит основное меню, разрешая предпочесть вид игры, mission-окно предлагает предпочесть миссию, а в action-окне происходит сам игровой процесс.
Бот определяет нынешнее окно самым простым методом: по значению цвета в нескольких ключевых точках. Точки выбираются вручную: способом «пристального всматривания».

      var screenChecks =
        new[]
        {
          new
          {
            Name = "main",
            Points = new[]
            {
              new CheckPoint(200, 190, 0xff554a22),
              new CheckPoint(65, 400, 0xfff44c41)
            }
          },
          new
          {
            Name = "mission",
            Points = new[]
            {
              new CheckPoint(200, 190, 0xffb5d0c7),
              new CheckPoint(65, 400, 0xffad7630)
            }
          },
          new
          {
            Name = "action",
            Points = new[]
            {
              new CheckPoint(950, 10, 0xff72554b),
              new CheckPoint(10, 10, 0xff462b1d),
            }
          },
        };

      Func<Bitmap, string> check = image => screenChecks.Where(_check => image.Check(_check.Points)).Select(_check => _check.Name).FirstOrDefault();

Стержневой цикл бота:

      var startButtonPoint = new Point(950, 430);
      var startMissionPoint = new Point(600, 750);

      for (; ; )
      {
        try
        {
          var bmp = GetScreenImage(gameScreenRect);
          var screenName = check(bmp);
          Console.Write(screenName   new string(' ', 20)   new string('x8', 40));
          switch (screenName)
          {
            case "main":
              mouse_event(MouseEventFlags.MOVE | MouseEventFlags.ABSOLUTE, startButtonPoint.X * 65536 / game_width, startButtonPoint.Y * 65536 / game_height);
              System.Threading.Thread.Sleep(400);
              mouse_event(MouseEventFlags.LEFTDOWN, 0, 0);
              System.Threading.Thread.Sleep(150);
              mouse_event(MouseEventFlags.LEFTUP, 0, 0);
              System.Threading.Thread.Sleep(50);
              System.Threading.Thread.Sleep(400);
              break;
            case "mission":
              mouse_event(MouseEventFlags.MOVE | MouseEventFlags.ABSOLUTE, startMissionPoint.X * 65536 / game_width, startMissionPoint.Y * 65536 / game_height);
              System.Threading.Thread.Sleep(10);
              mouse_event(MouseEventFlags.LEFTDOWN, 0, 0);
              System.Threading.Thread.Sleep(150);
              mouse_event(MouseEventFlags.LEFTUP, 0, 0);
              System.Threading.Thread.Sleep(50);
              break;
            case "action":
              mouse_event(MouseEventFlags.LEFTDOWN, 0, 0);
              System.Threading.Thread.Sleep(150);
              mouse_event(MouseEventFlags.LEFTUP, 0, 0);
              System.Threading.Thread.Sleep(50);
              break;
            case null:
              bmp.Save("unknown.bmp");
              break;
          }
        }
        catch (Exception exc)
        {
          Console.WriteLine(exc);
        }
      }

В игровой фазе бот непрерывно кликает, выпуская шары в одну точку. На такой примитивный (скорее даже тупой) тактике бот в первой миссии набирает 1000-2000 очков, и изредка даже всецело набирает полоску Zuma.

Резюме

Поставленная цель исполнена: каркас бота написан — игровой процесс зациклен. Следующие цели: подключить OpenCV, распознать расположение и цвет шаров.

Источник: programmingmaster.ru

Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB