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

в 19:54, , рубрики: .net, client-server, DirectX, PInvoke, бот игры, метки: , , , , , ,

Введение

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

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

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

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

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

Отступление

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

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

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

Первый способ: посылка программе своих window-сообщений (WM_MOUSEMOVE, WM_LBUTTONDOWN и т.д.) с помощью функций SendMessage или PostMessage.

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

Второй способ: прямая эмуляция поведения мыши с помощью функций 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);
Слабое место

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

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

Наконец-то, приступаем непосредственно к решению поставленной на сегодня задаче: зацикливанию игрового процесса — все необходимые подзадачи решены. Игровой процесс в Zuma крутится вокруг трех окон: 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, распознать положение и цвет шаров.

Автор: DrReiz

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js