Итак, обещанное продолжение моей первой статьи из песочницы, в котором будет немного технических деталей по реализации простой многопользовательской игры с возможностью играть с клиентов на разных платформах.
Предыдущую часть я закончил тем, что в последней версии моей игры «Магический Yatzy» в качестве инструмента клиент-серверного взаимодействия я использую WebSocket’ы. Теперь немного технических подробностей.
1. Общее
В общем, все выглядит как показано на этой схеме:
Выше представлена схема взаимодействия между тремя клиентами на различных платформах и сервером. Рассмотрим каждую часть по-подробнее.
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