Введение
Каждый, играя, хоть раз думал: «вот бы написать программу, которая играла бы за меня!». Но обычно эта мысль, так мыслью и остается… Постоянно что-то мешает: незнание с чего начать, страх перед неподъемностью задачи, шепоток из-за левого плеча «и зачем это? кому это надо, чтобы программа играла с программой?» и т.д.
В данном цикле статей я собираюсь показать, что, во-первых: «не так страшен черт, как его малюют», а во-вторых: позже собираюсь ответить и на вопрос: «зачем это надо?».
Сейчас начнем с простого. С установления взаимосвязи между игрой и программой-игроком (ботом). В качестве подопытного кролика берется широкоизвестная игра 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)
Прокси
Для управления мышью на гостевой ОС напишем прокси, представляющий из себя простенький консольный 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