Небольшая предыстория.


imageОднажды вечером подошел ко мне сын и сказал, что хочет поиграть в Марио. Летом у бабушки на даче он любил «зарубиться» в дождливую погоду. А за окном как раз дождь. Не долго думая я скачал ему первый попавшийся эмулятор 8-ми битной приставки и игру. Однако, оказалось, что удовольствие от игры на клавиатуре совсем не то. Идти покупать джойстик было уже поздно. И тогда я подумал, что можно обойтись и без него. Под рукой у нас была старенькая Nokia Lumia, ее размеры и форма примерно совпадали с нашими нуждами. Было решено написать джойстик. Сын отправился рисовать дизайн на листе бумаги в клеточку, а папа пошел варить кофе и думать, как бы осуществить эту идею с наименьшими временными затратами.

Я решил пойти по пути наименьшего (с моей точки зрения) сопротивления. Эмулятору приставки в настройках надо указывать нажатые кнопки, значит наше приложение должно нажимать кнопки. Нажатие кнопок можно эмулировать при помощи старого доброго WINAPI.

Конечной идеей стало клиент-серверное приложение. Клиент (телефон) при нажатии на кнопку посылает запрос на сервер, который, в свою очередь, в зависимости от того что пришло эмулирует нажатие или отпускание кнопки клавиатуры. Связь же осуществляется через сокеты. Вроде все просто. Начинаем делать.

Серверная часть


Поставили на форму textbox c одноименным именем textBox. В нем будем показывать, что приходит с телефона.

image

Начинаем работать с сокетами.

Первым делом подключаем их:

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, которые сын сам и расставил, в соответствии с собственным же дизайном. По его же идее от стандартного фона было решено отказаться. Дизайн закончен. Осталось сделать чтобы все это заработало.

image

Подрубаем сокеты:

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);
        }

Результат


image

Плюсы: Марио бегает и прыгает, спасает принцессу. Сын доволен.

Дальнейшие планы:

1. Сделать поиск сервера на телефоне.
2. Разобраться с залипаниями (хоть товарищи и говорят, что залипания добавляют реалистичности, и вовсе не баг, а фича).
3. Перерисовать дизайн.
4. Добавить возможность играть вдвоем
5. Причесать код. (не нравится мне количество if в серверной части и то что для каждой кнопки свои почти одинаковые события в клиенте)

Ссылка для тех, кому интересно, на gitHub.

Комментарии (8)


  1. Newbilius
    29.12.2015 15:06
    +3

    Статья классная. Но. Но. Но… Не смогу удержаться от вопроса.

    Однако, оказалось, что удовольствие от игры на клавиатуре совсем не то.

    Т.е., удовольствие управления экране без нормальной тактильной отдачи (кроме вибрации) — ВЫШЕ, чем во время игры на клавиатуре (с настоящими кнопками)?! Я очень люблю играть в игры. Поэтому поверить не смогу… На реальном геймпаде/клавиатуре можно нажимать на ощупь, с экраном же это намного сложнее.


    1. babilonsuxx
      29.12.2015 17:30
      +1

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


  1. IL_Agent
    29.12.2015 16:06
    +1

    5. Причесать код.

    Предполагаю, что код был бы более причёсан, если бы использовался WCF, а не сокеты.


    1. Dywar
      29.12.2015 19:17

      Опередили.
      Для кода — Справочник по набору правил анализа кода, работает в Community (поставить галочку в настройках проекта Code Analysis на нужных правилах).


      1. IL_Agent
        29.12.2015 21:58

        еще pvs studio можно прикрутить


      1. 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.


  1. Sykoku
    30.12.2015 13:51

    «GotFocus»?
    Мне казалось, проще освоить не нажатия, а вращения/наклоны. Для движения это более естественно. А кнопки — да, на экран — телефонными не очень удобно пользоваться.


  1. Darthman
    30.12.2015 17:12

    При всём уважении, вы делаете геймпад, а никак не джойстик.