Твоя программа потопила мой линкор!
Clarity Battleship («Морской бой» от Clarity) — многопользовательская игра, в которой игроки создают программу «искусственного интеллекта», необходимую для командования их флотом и победы в морском сражении. Игра сделана с применением WPF и WCF.
Брайан Дагерти (Bryan Dougherty)
Сложность: средняя
Необходимое время: 1-3 часа для создания собственного флота
Затраты : нулевые
ПО : Visual Basic или Visual C# Express Editions
Оборудование:
Загрузки: загрузить (EN)
Clarity Battleship
Мы, сотрудники Clarity Consulting, любим дружеские состязания. Несколько раз в году мы устраиваем конкурсы программирования под названием «Tech Challenges». Недавно я организовал один из таких конкурсов, взяв за основу классическую настольную игру «Морской бой».
Алгоритм нашей игры не совсем соответствует настольному прототипу. Цель проста — потопить все корабли противников. Однако поскольку мы создаем игру в виде программы, я решил сделать ее немного интересней, с тем чтобы победа зависела больше от стратегии, чем от удачи. Как и в настольной игре, игровое поле (поле боя) представлено координатной сеткой. Но в отличие от классического варианта, в игре может одновременно принимать участие несколько команд. Каждому флоту выделяется район на поле боя.
В нашем турнире участвовало шесть команд за раз, а в одной игре — Королевской баталии — сражалось сразу одиннадцать флотилий. Ниже представлен пример игрового поля с тремя командами. Красными клетками обозначены повреждения. В нижней части экрана выводятся рассылаемые всем сообщения или информация о потопленных флотилиях.
В начале игры команда расставляет свои корабли и дает им приказы следующего рода:
- атаковать определенные позиции на поле боя;
- выполнить разведывательные действия в определенной области;
- распространить сведения среди противников или участников сговора в «открытом радиодиапазоне» (дезинформация и сепаратные сговоры допускаются и приветствуются!).
Игра проводится псевдо-поэтапно. Команды получают уведомления от игры, когда их корабли готовы выполнить приказы. Они также извещаются о поломках и потерях кораблей. Игра заканчивается, когда остается единственная команда.
Архитектура приложения
Теперь, когда у вас есть представление о ходе игры, давайте поговорим о реализующей ее программе. Для меня одним из мотивов, побудивших написать эту игру, было желание в увлекательной форме изучить Windows Communication Foundation (WCF) и Windows Presentation Foundation (WPF) — новые компоненты .NET Framework 3.0.
WCF предоставляет новый унифицированный механизм клиент-серверного взаимодействия, значительно упрощающий работу благодаря стандартизации взаимодействующих компонентов. В качестве подходящей исходной точки для изучения WCF я рекомендую эту статью (EN).
WPF обеспечивает более удобные пользовательские интерфейсы и упрощает контакт программиста и графического дизайнера благодаря применению XAML. Как заметно по моему простенькому (хотя, он вполне крутой) интерфейсу пользователя, я больше сосредоточился на WCF, но и преимущества WPF старался не упустить.
Обзор
Приложение «Морской бой» состоит из трех частей. Проект Battleship.Components — это совместно используемая сборка, содержащая классы для поддержки взаимодействия и реализующая логику игры, а также определения классов для всех кораблей, таких как авианосец (AircraftCarrier), линкор (Battleship) и др. Battleship.Gameboard с помощью WPF отображает события, происходящие в зоне военных действий. И наконец, каждая команда, желавшая принять участие в схватке, создала собственное консольное приложение.
Приведенная ниже схема иллюстрирует взаимосвязь перечисленных компонентов.
WCF-взаимодействия
Как я уже сказал, при разработке я сосредоточился на использовании WCF для управления взаимодействием между флотилиями и самой игрой. Прежде всего, меня заинтересовала предлагаемая WCF функция двунаправленных связей, которая хорошо вписывалась в наш сценарий. Традиционная веб-служба может принимать сообщения от клиента и отправлять ему ответы. Однако она сама не может инициировать отправку сообщения клиенту. Например, с помощью веб-службы легко реализовать отправку флотилией приказа в адрес игры (типа: «Линкору атаковать координату 7,2»). Между тем, механизм веб-служб не позволяет сообщить другой команде, что ее корабль потоплен.
WCF позволяет мне это сделать без написания особо объемного кода. Мой код достаточно сложен, но основная причина этого в том, что я хотел как можно больше скрыть коммуникационный слой от разработчиков программ-флотилий, с тем чтобы они сосредоточились на реализации своих стратегий. В следующих разделах описан процесс взаимодействия.
Серверная часть
Хотя Battleship.GameBoard — это приложение Windows Forms, по сути его следует рассматривать как сервер в данной системе. Дело в том, что здесь реализована служба WCF, к которой обращаются флотилии (Fleets).
Определение службы начинается с определения ее интерфейса. Подобно WSDL-определению для веб-служб, интерфейс сообщает потребителям о контракте, т. е. входных и выходных данных WCF-службы. Обращающиеся к этой службе клиенты будут запрашивать выполнение различных выполняемых ею операций.
В данном приложении, контракт службы определяет интерфейс IBattleshipGameService:
1: [ServiceContract(CallbackContract=typeof(IBattleshipGameServiceCallback))]
2: public interface IBattleshipGameService
3: {
4: [OperationContract(IsOneWay = true)]
5: void RegisterFleet(Fleet fleet);
6:
7: [OperationContract(IsOneWay = true)]
8: void ExecuteOrder(string shipID, Order order);
9: }
Интерфейс показывает, что потребители могут обращаться к двум методам: RegisterFleet и ExecuteOrder. Атрибут OperationContract указывает на то, что это именно методы. Параметр IsOneWay указывает, что ответ вызывающей стороне отправляться не будет. Если от RegisterFleet ответ нам не нужен, то от ExecuteOrder мы, вроде бы, должны ждать возврата результата.
На самом же деле, после регистрации флотилии служба поддерживает канал для связи с этой флотилией. И ExecuteOrder лишь ставит приказ в очередь на обработку, а не выполняет его сразу. Перед постановкой в очередь алгоритм игры проверяет, не накопилось ли слишком много ожидающих исполнения приказов. Это позволяет защититься от некорректных действий команд, которые могут перегрузить сервер приказами. Ниже мы подробней рассмотрим схему взаимодействия с клиентом.
Теперь уделим внимание конфигурационному файлу приложения.
1: <configuration>
2: <system.serviceModel>
3: <services>
4: <service name="Clarity.Battleship.Components.GameSide.BattleshipGameService"
5: behaviorConfiguration="BattleshipGameServiceBehavior">
6: <endpoint address="https://localhost:8088/BattleshipGameService"
7: binding="wsDualHttpBinding"
8: contract="Clarity.Battleship.Components.GameSide.IBattleshipGameService"/>
9: </service>
Это описание службы. Атрибут contract указывает на то, что служба соответствует интерфейсу IBattleshipGameService. Атрибут binding показывает, что будет использоваться WSDualHttpBinding. Это тип канала, позволяющий и серверам, и клиентам отправлять и получать сообщения.
При наличии интерфейса и определения конфигурации можно строить конкретную реализацию службы. Это сделано в классе BattleshipGameService. В нем реализован описанный выше интерфейс IBattleshipGameService. То есть именно в этом классе содержится код, выполняющий регистрацию флотилий и выполнение приказов.
[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single,UseSynchronizationContext = false)]
public class BattleshipGameService : IBattleshipGameService
Обратите внимание на указанный в определении этого класса атрибут ServiceBehavior со значением InstanceContextMode. WCF использует этот атрибут для определения способа выполнения операций при поступлении запросов к службе. В данном случае атрибут указывает, что служба должна выполняться как одноэкземплярная. Другими словами, все обращения будут обрабатываться единственным экземпляром BattleshipGameService. Далее рассмотрим фрагмент конструктора класса Game, демонстрирующий реальный запуск службы.
Uri u = new Uri("https://localhost:8088/BattleshipService");
_gameHost = new ServiceHost(new BattleshipGameService(), new Uri[] { u });
_gameHost.Open();
Создается ServiceHost (чье назначение соответствует его названию: он управляет службой), которому передается экземпляр нашей службы и ее URI. (Я знаю, что наличие URI избыточно, поскольку он есть в конфигурационном файле, но я применяю именно такой конструктор.) Поскольку хост службы BattleShipGameService будет запускать лишь один ее экземпляр, мы сможем отслеживать этот экземпляр с помощью закрытого свойства (что более удобно) и подключить обработчики его событий:
1: private BattleshipGameService BattleshipGameService
2: {
3: get
4: {
5: return (BattleshipGameService)_gameHost.SingletonInstance;
6: }
7: }
8:
9: this.BattleshipGameService.FleetRegistered +=
10: new FleetRegisteredEventHandler(BattleshipGameService_FleetRegistered);
11: this.BattleshipGameService.OrderExecuted +=
12: new OrderExecutedEventHandler(BattleshipGameService_OrderExecuted);
Это позволит нам элегантно переадресовывать поступающие обращения классу Game. То есть служба работает как промежуточное звено, а реальную работу по управлению игрой выполняет класс Game.
Клиентская часть
Теперь посмотрим, что происходит на клиентской стороне. В каждом консольном приложении создается класс, производный от FleetCommander (есть подробная инструкция и исходный код, который могут загрузить участники). Класс FleetCommander представляет все корабли флотилии, которой управляют авторы этого класса.
Чтобы избежать мошенничества (кто-то может назвать его «творческим программированием»), каждому кораблю при запуске игры в классе Game присваивается уникальный идентификатор. Благодаря этому игре известно, какие приказы являются неподдельными. Чтобы скрыть эти идентификаторы и прочие подробности от FleetCommander (командующего флотилией) и сделать интерфейс простым и интуитивно понятным, метод, реализующий исполнение приказов, недоступен классам, реализующим корабли. Следующий фрагмент кода демонстрирует, как FleetCommander должен реагировать на событие, указывающее на готовность корабля к исполнению приказов. В данном случае командующий приказывает атаковать позицию с координатами _targetX, _targetY.
1: public override void OnShipAwaitingOrders(Ship ship, Order lastOrder)
2: {
3: Order orderToExecute = new Order();
4: orderToExecute.Coordinate = new Coordinate(_targetX, _targetY);
5: orderToExecute.Type = OrderType.Attack;
6: ship.ExecuteOrder(orderToExecute);
На самом деле, метод Ship.ExecuteOrder генерирует событие, обрабатываемое базовым классом FleetCommander. FleetCommander вызывает соответствующий метод клиентского WCF-класса, который обращается к BattleshipGameService.
Часть описания WCF-клиента показана ниже. Как видите, это класс, производный от DuplexClientBase. Он управляет каналом, по которому в обоих направлениях передаются сообщения. Можно рассматривать его как аналог класса-прокси веб-службы.
public partial class BattleshipGameServiceClient :
System.ServiceModel.DuplexClientBase<IBattleshipGameService>, IBattleshipGameService
{
Итак, мы рассмотрели, как сообщения отправляются серверу, но вы можете спросить: «Как FleetCommander определяет, что тот или иной корабль ожидает приказа?», т. е. непонятно, как программа уведомляется о чем-либо.
Поясняю: у каждого FleetCommander есть экземпляр класса-слушателя, получающего уведомления от службы игры. Способ установки этого слушателя полностью аналогичен настройке службы. Опять же, мы начинаем с интерфейса, в данном случае это — IBattleshipGameServiceCallback.
1: public interface IBattleshipGameServiceCallback
2: {
3: [OperationContract(IsOneWay = true)]
4: void OnFleetUpdated(Fleet fleet, FleetUpdateReason reason);
5:
6: [OperationContract(IsOneWay = true)]
7: void OnGameStateChanged(GameInfo gameInfo);
8:
9: [OperationContract(IsOneWay=true)]
10: void OnOrderExecuted(string shipID, Order lastOrder);
11:
12: [OperationContract(IsOneWay = true)]
13: void OnMessageReceived(string data, Coordinate c);
14:
15: [OperationContract(IsOneWay = true)]
16: void OnFleetSunk(string fleetName);
17: }
Как и в предыдущем случае, атрибуты OperationContract используются для указания доступных методов. И так же, как и раньше, нам надо конкретизировать реализацию интерфейса. Определяем FleetCommanderListener:
public class FleetCommanderListener : IBattleshipGameServiceCallback
FleetCommanderListener используется при создании BattleshipGameServiceClient. В приведенном ниже описании конструктора вы видите, что для класса используется входной параметр callbackInstance. BattleshipGameServiceClient может управлять взаимодействием с сервером через интерфейс IBattleshipGameService, а также прослушивать сообщения интерфейса IBattleshipGameServiceCallback!
public BattleshipGameServiceClient(System.ServiceModel.InstanceContext callbackInstance) :
base(callbackInstance)
Последний фрагмент головоломки — файл конфигурации клиента.
1: <system.serviceModel>
2: <client>
3: <endpoint name="Clarity.Battleship.Components.IBattleshipGameService"
4: address="https://localhost:8088/BattleshipGameService"
5: binding="wsDualHttpBinding"
6: bindingConfiguration="BattleshipBinding"
7: contract="Clarity.Battleship.Components.GameSide.IBattleshipGameService" />
8: </client>
9: <bindings>
10: <!—настройка привязки, поддерживающей двунаправленное взаимодействие -->
11: <wsDualHttpBinding>
12: <binding name="BattleshipBinding"
13: clientBaseAddress="https://localhost:6088/BattleshipGameClient/">
14: </binding>
15: </wsDualHttpBinding>
16: </bindings>
17: </system.serviceModel>
Как видно из приведенного выше примера, описание конфигурации выглядит несложно. Аналогично серверной конфигурации, мы указываем, что используем wsDualHttpBinding. Дополнительные данные — clientBaseAddress — это значение, указывающее серверу, как отвечать клиенту.
Общая картина
Прочитав все это, вы можете сказать: «А ведь он говорил, что требуется немного пользовательского кода». Полагаю, это действительно так в терминах WCF. Хотя это и нетривиальная задача, но и не такая уж сложная: описать пару интерфейсов, создать их реализацию и сформировать конфигурационные файлы. Наиболее сложный момент — построение модели обработки событий, скрывающей детали от команд.
На следующей диаграмме все сведено воедино, и она может помочь разобраться в последовательности выполнения приказа.
1. Класс, производный от FleetCommander обращается к методу ExecuteOrder класса Ship (корабль) (например, «Атаковать позицию с координатами 7,2»).
2. Ship генерирует событие, обрабатываемое базовым классом FleetCommander, который, в свою очередь, обращается к BattleshipGameServiceClient.
3. Клиентский класс WCF выполняет WCF-вызов для выполнения приказа.
4. Единственный экземпляр службы генерирует событие для класса Game с данными о приказе.
5. Управляющий всей последовательностью игры класс Game ставит приказ в очередь.
6. Обработав приказ из очереди, класс Game обращается к WCF-обработчику OnOrderExecuted, который вызывается на FleetCommanderListener.
7. В завершении FleetCommanderListener генерирует событие OrderExecuted для класса, производного от FleetCommander (например, «Атака вашего линкора позиции 7,2 привела к попаданию»), который вызывает метод OnShipAwaitingOrders, и цикл начинается заново.
Пользовательский интерфейс в стиле WPF
Последняя вещь, о которой надо рассказать, — это интерфейс пользователя. Я решил немного поэкспериментировать с WPF. Сборка Gameboard содержит класс Battlefield, реализующий окно WPF (по аналогии с формой Windows Form). Главный компонент игрового поля — элемент управления Grid. Это координатная сетка, которая прекрасно вписывается в экран, независимо от числа участников.
В начале игры на это поле добавляются клетки и изображения кораблей. В процессе игры цвет клеток меняется, отражая происходящее в каждой позиции: выстрел, разведку или поражение цели.
Ниже приведен фрагмент фонового кода для игрового поля, в котором цвет клетки, связанной с приказом, меняется в зависимости от результатов его выполнения.
1: Rectangle r = _rectangles[_lastOrderStarted.Order.Coordinate];
2: if (e.Order.Result == OrderResult.ShipHit)
3: {
4: r.Fill = Brushes.Red;
5: _rectangleFills[e.Order.Coordinate] = r.Fill;
6: }
7: else if (e.Order.Result == OrderResult.ShipSunk)
8: {
9: r.Fill = Brushes.Red;
10: _rectangleFills[e.Order.Coordinate] = r.Fill;
11: }
12: else if (e.Order.Result == OrderResult.Miss)
13: {
14: r.Fill = _rectangleFills[e.Order.Coordinate];
15: }
16: else
17: {
18: r.Fill = _rectangleFills[e.Order.Coordinate];
19: }
Завершение
Надеюсь, вам понравилась игра и вы узнали нечто новое. Рекомендую загрузить код и построить собственный флот. Среди загружаемых файлов есть документ «Clarity Battleship – Building a FleetCommander» (построение FleetCommander для Clarity Battleship), в котором приведена пошаговая инструкция. Вы можете оставить здесь свою реализацию флота, чтобы другие пользователи могли с ним сразиться. Победных баталий!
Брайан Дагерти (Bryan Dougherty) — глава Clarity Consulting, консалтинговой фирмы, занимающейся разработкой ПО из г. Чикаго, штат Иллинойс. Многие годы он является MCP. Брайану присвоена степень бакалавра в области информационных технологий в Северо-западном университете (США). Он ведет блог (EN), посвященный программированию.