Простейшая реализация кроссплатформенного мультиплеера на примере эволюции одной .NET игры. Часть вторая, более техническая

в 10:28, , рубрики: .net, game development, WebSocket, Windows 8, windows phone, разработка под windows phone, метки: , ,

Итак, обещанное продолжение моей первой статьи из песочницы, в котором будет немного технических деталей по реализации простой многопользовательской игры с возможностью играть с клиентов на разных платформах.
Предыдущую часть я закончил тем, что в последней версии моей игры «Магический Yatzy» в качестве инструмента клиент-серверного взаимодействия я использую WebSocket’ы. Теперь немного технических подробностей.

1. Общее

В общем, все выглядит как показано на этой схеме:

image

Выше представлена схема взаимодействия между тремя клиентами на различных платформах и сервером. Рассмотрим каждую часть по-подробнее.

2. Сервер

Сервер у меня на базе MVC4, работающего как «Cloud service» в Windows Azure. Почему такой выбор. Все просто:
1) Ничего кроме .NET я не знаю.
2) WebSocket у меня только для взаимодействий, касающихся игры, все остальное, такое как проверка статуса сервера, получение/сохранение очков и прочее – через WebApi – поэтому MVC.
3) У меня есть подписка на сервисы Azure.

Согласно схеме выше – сервер состоит из трех частей:
1) ServerGame – реализация всей логики игры;
2) ServerClient – своего рода посредник между игрой и сетевой частью;
3) WSCommunicator – часть, ответственная за сетевое взаимодействие с клиентом – прием/отправка команд.

Конкретная реализация ServerGame и ServerClient зависит от конкретной игры, которую вы разрабатываете. В общем случае ServerClient получает комманду от клиента, обрабатывает ее и оповещает игру о действии клиента. В тоже время он следит за изменением состояния игры (ServerGame) и оповещает (отправляет информацию через WSCommunicator) своего клиента о любых изменениях.
Например, касательно моей игры в кости: в свой ход пользователь на Windows 8 клиенте закрепил несколько костей (сделал так, чтобы их значение не изменилось при следующем броске). Эта информация была передана на сервер и ServerClient оповестил об этом класс ServerGame, который сделал необходимые изменения в состоянии игры. Об этом изменении были оповещены все другие ServerClient’ы, подключенные к данной игре (в рассматриваемом случае – WP и Android), а они в свою очередь отправили информацию на устройства для оповещения пользователей через UI.
Следует сказать, что в самом классе ServerGame ничего «серверного» нету. Это обычный .NET класс, имеющий общий интерфейс с ClientGame. Таким образом мы может подставить его вместо ClientGame в клиентской программе и таким образом получить локальную игру. Именно так и работает локальная игра в моем «книффеле»– когда из одной UI странички возможна как локальная так и сетевая игра.
WSCommunicator – как я уже сказал, класс ответственный за сетевое взаимодействие. Конкретно этот реализует это взаимодействие посредством WebSocket’ов. В .NET 4.5 появилась собственная реализация вебсокетов. Основным в этой реализации является класс WebSocket, WSCommunicator по сути является оберткой над ним, реализующей открытие/закрытие соединения, попытки переподключения, отправки/получения данных в определенном формате.
Теперь немного кода. Для первоначального соединения используется Http Handler. Физическую страницу добавлять не обязательно. Достаточно задать параметры в WebConfig’e:

…
<system.webServer>
    <handlers>
      <remove name="ExtensionlessUrlHandler-Integrated-4.0" />
      <add name="app" path="app.ashx" verb="*" type="Sanet.Kniffel.Server.ClientRequestHandler" />
      <add name="ExtensionlessUrlHandler-Integrated-4.0" path="*." verb="*" type="System.Web.Handlers.TransferRequestHandler" resourceType="Unspecified" requireAccess="Script" preCondition="integratedMode,runtimeVersionv4.0" />
    </handlers>
  </system.webServer>
…

Таким образом, при обращении к страничке (виртуальной) «app.ashx» на сервере будет вызван код из класса «Sanet.Kniffel.Server.ClientRequestHandler». Вот этот код:

public class ClientRequestHandler : IHttpHandler
    {
        public void ProcessRequest(HttpContext context)
        {
            if (context.IsWebSocketRequest) //обращение через WebSocket
                context.AcceptWebSocketRequest(new Func<AspNetWebSocketContext, Task>(MyWebSocket));
            else                                                    //обращение через Http
                context.Response.Output.Write("Здесь ничего нет...");
        }

        public async Task MyWebSocket(AspNetWebSocketContext context)
        {
            string playerId = context.QueryString["playerId"];
            if (playerId == null) playerId = string.Empty;
            
            try
	    {
		WebSocket socket = context.WebSocket;
                //новый класс, унаследованный от WSCommunicator'а и имеющий дополнительный функционал по подключению клиента к игре
		ServerClientLobby clientLobby = null;
                if (!string.IsNullOrEmpty(playerId))
		{
			//проверяем не подключен ли уже клиент с таким айди
			if ( !ServerClientLobby.playerToServerClientLobbyMapping.TryGetValue(playerId, out clientLobby))
			{
                                //если нет - создаем новый
				clientLobby = new ServerClientLobby(ServerLobby, playerId);
				ServerClientLobby.playerToServerClientLobbyMapping.TryAdd(playerId,  clientLobby);
				}
			}
		        else 
			{
				//запрос с пустым айди оставляем без внимания
                                return;
			}

			//устанавливаем новый вебсокет и запускаем
			clientLobby.WebSocket = socket;
			await clientLobby.Start();
            
		}
		catch (Exception ex)
		{
			//что-то пошло не так...
		}
        }
     }

Думаю, с учетом комментариев все должно быть понятно. Метод WSCommunicator.Start() запускает «режим ожидания» команды от клиента. Вот как это выглядит ():

 public async Task Start()
        {
            if (Interlocked.CompareExchange(ref isRunning, 1, 0) == 0)
            {
               await Run();
            }
        }

        protected virtual async Task Run()
        {
            while (WebSocket != null && WebSocket.State == WebSocketState.Open)
            {
                try
                {
                    string result = await Receive();
                    if (result == null)
                    {
                        return;
                    }
                }
                catch (OperationCanceledException) //это нормально при отмене операции
                { }
                catch (Exception e)
                {
                    //что-то непоправимое
                    //закрываем соединение
                    CloseConnections();
                    //оповещаем всех, что этот клиент отключен от игры
                    OnReceiveCrashed(e);
                }
            }
            
        }

Это общая часть, дальнейшее описание сервера опускаю, так как оно будет в большей степени зависеть от игры, которую вы делаете. Скажу только, что команды через WebSocket передаются (в том числе) в текстовом формате. Конкретная реализация этих команд опять таки в основном зависит от игры. При получении команды от клиента, она будет обработана методом WSCommunicator.Receive(), для отправки клиенту — WSCommunicator.Send(). Все, что между – опять же зависит от логики игры.

3. Клиент

3.1 WinRT.

Если бы клиент был на полноценной .NET 4.5, то для него можно было бы использовать тот же класс WSCommunicator, что и на серевере с небольшими лишь дополнениями – вместо класса WebSocket необходим был бы класс ClientWebSocket, плюс добавить логику по запросу на соединение с сервером. Но в WinRT используется своя реализация вебсокетов с классами StreamWebSocket и MessageWebSocket. Для передачи текстовых сообщений используется второй. Вот код по установлению соединения с сервером с его использованием:

public async Task<bool> ConnectAsync(string id, bool isreconnect = false)
        {
            try
            {
                //работаем с локальной копией вебсокета, чтобы избежать его закрытия из другого потока во время асинхронной операции
                //(маловероятно, но возможно)
                MessageWebSocket webSocket = ClientWebSocket;

                // Проверяем что не подключены
                if (!IsConnected)
                {
                    //получаем адрес сервера (ws://myserver/app.ashx")
                    var uri = ServerUri();
                    webSocket = new MessageWebSocket();
                    webSocket.Control.MessageType = SocketMessageType.Utf8;
                    //устанавливаем обработчики
                    webSocket.MessageReceived += Receive;
                    webSocket.Closed += webSocket_Closed;
                        
                    await webSocket.ConnectAsync(uri);
                    ClientWebSocket = webSocket; //устанавливаем в переменную класса только после успешного подключения
                    if (Connected != null)
                        Connected();             //сообщаем, что мы подключились
                    return true;
                }
                return false;
            }
            catch (Exception e) 
            {
                //что-то не так
                return false;
            }
        }

Далее все как на сервере: WSCommunicator.Receive() получает сообщения с сервера, WSCommunicator.Send() – отправляет. GameClient работает в соответствии с данными, получаемыми с сервера и от пользователя.

3.2 Windows Phone, Xamarin и Silverlight (а также .NET 2.0)

Во всех этих платформах нет поддержки вебсокетов «из коробки». К счастью есть отличная опенсорс библиотека WebSocket4Net, которую я упоминал в предыдущей статье. Заменив в WSCommunicatare класс вебсокета на реализованный в этой библиотеке, мы получим возможность подключения к серверу с указанных платформ. Вот как изменится код по установке соединения:

public async Task<bool> ConnectAsync(string id, bool isreconnect = false)
        {
           try
            {
                //работаем с локальной копией вебсокета, чтобы избежать его закрытия из другого потока во время асинхронной операции
                //(маловероятно, но возможно)
                WebSocket webSocket = ClientWebSocket;

                // Проверяем что не поделючены
                if (!IsConnected)
                {
                    //получаем адресс сервера (ws://myserver/app.ashx")
                    var uri = ServerUri();
                    webSocket = new WebSocket(uri.ToString());
                    //устанавливаем обработчики
                    webSocket.Error += webSocket_Error;
                    webSocket.MessageReceived += Receive;
                    webSocket.Closed += webSocket_Closed;
                    //соединение не асинхронное, поэтому "асинхронизируем" его принудительно
                    var tcs = new TaskCompletionSource<bool>();
                    webSocket.Opened += (s, e) => 
                    {
                        //устанавливаем в переменную класса только после успешного подключения
                        ClientWebSocket = webSocket;
                        if (Connected != null)
                            Connected();        //сообщаем, что мы подключились

                        else tcs.SetResult(true);
                                             
                    };
                    webSocket.Open();

                    return await tcs.Task;

                }

                return false;
            }
            catch (Exception ex)
            {
                //что-то не так
                return false;
            }
            
        }

Как видим отличия есть, но их не так много, основное -это не асинхронное открытие соединения с сервером, но это легко исправить (правда для поддержки async await в старых версиях .NET необходимо установить Microsoft.Bcl пакет с нугета).

Вместо заключения

Прочитал, что написал и понимаю, что вопросов, возможно, больше чем ответов. К сожалению описать все в одной статье физически не возможно, а она и так уже получается не самой короткой… но я буду продолжать тренироваться.

Автор: antonby

Источник


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


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