Небольшая предыстория.
Однажды вечером подошел ко мне сын и сказал, что хочет поиграть в Марио. Летом у бабушки на даче он любил «зарубиться» в дождливую погоду. А за окном как раз дождь. Не долго думая я скачал ему первый попавшийся эмулятор 8-ми битной приставки и игру. Однако, оказалось, что удовольствие от игры на клавиатуре совсем не то. Идти покупать джойстик было уже поздно. И тогда я подумал, что можно обойтись и без него. Под рукой у нас была старенькая Nokia Lumia, ее размеры и форма примерно совпадали с нашими нуждами. Было решено написать джойстик. Сын отправился рисовать дизайн на листе бумаги в клеточку, а папа пошел варить кофе и думать, как бы осуществить эту идею с наименьшими временными затратами.
Я решил пойти по пути наименьшего (с моей точки зрения) сопротивления. Эмулятору приставки в настройках надо указывать нажатые кнопки, значит наше приложение должно нажимать кнопки. Нажатие кнопок можно эмулировать при помощи старого доброго WINAPI.
Конечной идеей стало клиент-серверное приложение. Клиент (телефон) при нажатии на кнопку посылает запрос на сервер, который, в свою очередь, в зависимости от того что пришло эмулирует нажатие или отпускание кнопки клавиатуры. Связь же осуществляется через сокеты. Вроде все просто. Начинаем делать.
Серверная часть
Поставили на форму textbox c одноименным именем textBox. В нем будем показывать, что приходит с телефона.
Начинаем работать с сокетами.
Первым делом подключаем их:
using System.Net;
using System.Net.Sockets;
Заводим сокет и буфер в который все будет приходить:
public partial class ServerForm : Form
{
private Socket _serverSocket, _clientSocket;
private byte[] _buffer;
Пишем функцию, которая запускает наш сервер.
private void StartServer()
{
try
{
_serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_serverSocket.Bind(new IPEndPoint(IPAddress.Any, 3333));
_serverSocket.Listen(0);
_serverSocket.BeginAccept(new AsyncCallback(AcceptCallback), null);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, Application.ProductName, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
И соответственно стартуем его в самом начале.
public ServerForm()
{
InitializeComponent();
StartServer();
}
Подключаем работу с клавиатурой и пишем пару функций: одна нажимает клавишу, другая — отпускает.
[ DllImport("user32.dll")]
private static extern void keybd_event(byte bVk, byte bScan, int dwFlags, int dwExtraInfo);
private const int KEYEVENTF_EXTENDEDKEY = 1;
private const int KEYEVENTF_KEYUP = 2;
public static void KeyDown(Keys vKey)
{
keybd_event((byte)vKey, 0, KEYEVENTF_EXTENDEDKEY, 0);
}
public static void KeyUp(Keys vKey)
{
keybd_event((byte)vKey, 0, KEYEVENTF_KEYUP, 0);
}
Пробуем получить что-нибудь и, если все в порядке, то дописываем в textBox то что получили и нажимаем (отпускаем) кнопку.
private void ReceiveCallback(IAsyncResult AR)
{
try
{
int received = _clientSocket.EndReceive(AR);
Array.Resize(ref _buffer, received);
string text = Encoding.ASCII.GetString(_buffer);
// нажимаем или отпускаем кнопку
AppendToTextBox(text);
// ------------------
Array.Resize(ref _buffer, _clientSocket.ReceiveBufferSize);
_clientSocket.BeginReceive(_buffer, 0, _buffer.Length, SocketFlags.None, new AsyncCallback(ReceiveCallback), null);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, Application.ProductName, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
Функция для эмуляции нажатия и отпускания кнопки в зависимости от того что пришло:
private void AppendToTextBox(string text)
{
MethodInvoker invoker = new MethodInvoker(delegate
{
string text_before = text;
string exitW = text;
//нажимаем
if (text == "a")
{
KeyUp(Keys.D);
KeyDown(Keys.A);
textBox.Text += text + " ";
}
//отжимаем
if (text == "a1" )
{
KeyUp(Keys.A);
textBox.Text += text + " ";
}
});
this.Invoke(invoker);
}
В процессе тестирования было обнаружено, что кнопка «вперед» может по непонятным причинам залипать. В таком случае игрок рефлекторно нажимает «назад», пытаясь притормозить. Для того чтобы была такая возможность при нажатии «назад» мы на всякий случай поднимаем кнопку «вперед».
if (text == "a")
{
KeyUp(Keys.D);
KeyDown(Keys.A);
textBox.Text += text + " ";
}
Причина залипания кнопки непонятна.
Клиентская часть
Поиграть в Mario хотелось все сильнее, поэтому было решено настройку подключения и подключение сделать на том же экране.
Сверху разместили поля для ввода ip адреса и порта кнопку подключения и textblock, показывающий статус. В качестве кнопок управления были взяты стандартные AppBarButton, которые сын сам и расставил, в соответствии с собственным же дизайном. По его же идее от стандартного фона было решено отказаться. Дизайн закончен. Осталось сделать чтобы все это заработало.
Подрубаем сокеты:
using Windows.Networking.Sockets;
using Windows.Networking;
using Windows.Storage.Streams;
Открываем новый сокет:
StreamSocket clientSocket = new StreamSocket();
Пробуем подключиться:
private async void btnConnect_Click(object sender, RoutedEventArgs e)
{
HostName host = new HostName(textBoxIP.Text);
string port = textBoxPort.Text;
if (connected)
{
StatusText.Text = "уже подключен";
return;
}
try
{
StatusText.Text = "попытка подключения ...";
await clientSocket.ConnectAsync(host, port);
connected = true;
StatusText.Text = "подключение установлено" + Environment.NewLine;
}
catch (Exception exception)
{
if (SocketError.GetStatus(exception.HResult) == SocketErrorStatus.Unknown)
{
throw;
}
StatusText.Text = "не удалось установить подключение: ";
closing = true;
clientSocket.Dispose();
clientSocket = null;
}
}
Отправляем данные на сервер:
private async void sendkey(string key)
{
if (!connected)
{
StatusText.Text = "необходимо подключение";
return;
}
try
{
StatusText.Text = "попытка отправки данных ...";
DataWriter writer = new DataWriter(clientSocket.OutputStream);
writer.WriteString(key);
await writer.StoreAsync();
StatusText.Text = "отправка успешна" + Environment.NewLine;
writer.DetachStream();
writer.Dispose();
}
catch (Exception exception)
{
if (SocketError.GetStatus(exception.HResult) == SocketErrorStatus.Unknown)
{
throw;
}
StatusText.Text = "Не удалось оправить данные ";
closing = true;
clientSocket.Dispose();
clientSocket = null;
connected = false;
}
}
Внезапно возникла проблема. События Click у кнопки возникает не в момент ее нажатия, а в момент ее отпускания после нажатия. После непродолжительного копания в msdn, было решено использовать GotFocus в качестве нажатия, а Click в качестве отпускания кнопки, впоследствии для этих же целей было добавлено событие PointerReleased (это позволило отпускать одну кнопку при зажатой другой). При отпускании кнопки фокус все равно остается на ней, чтобы этого избежать, мы его передаем любому другому элементу, в данном случае это кнопка, не участвующая в управлении с именем btnConnect:
//посылаем значения кнопок
//вверх
private void gotFocusUp(object sender, RoutedEventArgs e)
{
sendkey("w");
}
private void lostFocusUp(object sender, RoutedEventArgs e)
{
sendkey("w1");
btnConnect.Focus(FocusState.Programmatic);
}
private void lostFocusUp(object sender, PointerRoutedEventArgs e)
{
sendkey("w1");
btnConnect.Focus(FocusState.Programmatic);
}
Результат
Плюсы: Марио бегает и прыгает, спасает принцессу. Сын доволен.
Дальнейшие планы:
1. Сделать поиск сервера на телефоне.
2. Разобраться с залипаниями (хоть товарищи и говорят, что залипания добавляют реалистичности, и вовсе не баг, а фича).
3. Перерисовать дизайн.
4. Добавить возможность играть вдвоем
5. Причесать код. (не нравится мне количество if в серверной части и то что для каждой кнопки свои почти одинаковые события в клиенте)
Ссылка для тех, кому интересно, на gitHub.
Комментарии (8)
IL_Agent
29.12.2015 16:06+15. Причесать код.
Предполагаю, что код был бы более причёсан, если бы использовался WCF, а не сокеты.Dywar
29.12.2015 19:17Опередили.
Для кода — Справочник по набору правил анализа кода, работает в Community (поставить галочку в настройках проекта Code Analysis на нужных правилах).Dywar
30.12.2015 14:54Может завести все кнопки в enum (централизованное управление типом возможных нажатий)?
[Flags] public enum Buttons { Up = 1, Left = 2, Right = 4, Down = 8, ... } var buttons = Buttons.Left | Buttons.Up; // два нажатия сразу. Преобразование в строку даст их перечисление через запятую, что бы передавать текстом.
Спарсить назад через Enum.TryParse.
Sykoku
30.12.2015 13:51«GotFocus»?
Мне казалось, проще освоить не нажатия, а вращения/наклоны. Для движения это более естественно. А кнопки — да, на экран — телефонными не очень удобно пользоваться.
Newbilius
Статья классная. Но. Но. Но… Не смогу удержаться от вопроса.
Т.е., удовольствие управления экране без нормальной тактильной отдачи (кроме вибрации) — ВЫШЕ, чем во время игры на клавиатуре (с настоящими кнопками)?! Я очень люблю играть в игры. Поэтому поверить не смогу… На реальном геймпаде/клавиатуре можно нажимать на ощупь, с экраном же это намного сложнее.
babilonsuxx
Спасибо за замечание. Я полностью разделяю ваше мнение про тактильные ощущения, но удовольствие от игры не получал маленький сын. Ему оказалось сложно играть на клавиатуре, постоянно смотрел на какую кнопку нажать, путался в буквах. А на экране телефона таких проблем не возникло.