Добрый день, уважаемые хабровчане. Представляю вашему вниманию свой небольшой проект – сетевой 2D-шутер на C#. Несмотря на то, что визуальная составляющая весьма простая – в наш век уже никого не заинтересуешь 2D-играми, некоторые архитектурные решения могут заинтересовать людей, собирающихся написать свою игру. В статье я расскажу о вариантах реализации ключевых моментов игры.
Задолго до того, как я стал инженером-электронщиком, я разрабатывал чисто софтварные проекты. С того самого момента, как я написал свою первую строчку кода(классе во втором, на Turbo Basic, во «дворце пионеров») меня не покидало желание написать игру. Думаю, с этим сталкивались почти все, кто начинал учиться программировать. Разумеется, знаний тогда не хватало на что-то масштабное, были только простые текстовые игры на том же Turbo Basic а потом и на Quick Basic. Иногда я использовал скудные бейсиковские возможности по выводу графики – была, например, игра, где нужно было управлять зеленым пикселем, убегая от красного и ставя ловушки в виде белых пикселей. Однако время шло, я узнавал все больше, и в итоге, классе в восьмом решил написать 2Д стрелялку для двух человек. Почему именно для двух? Потому что сети тогда не были так распространены, и самый доступный режим многопользовательской игры был Hot-Seat. В то время мы часто сражались с друзьями в Worms и Heroes таким образом, но хотелось драйва реалтайма. Поэтому я решил написать игру, где можно было бы в реал-тайме бегать, стрелять из различного оружия, подбирать ящики с припасами и т.п. – но при этом, можно было бы играть вдвоем. Одному игроку выделялась буквенная часть клавиатуры, другому – цифровая. Так как мышка была одна, она не использовалась, чтобы не давать преимущества одному из игроков. А т.к. монитор был один, боевые действия были ограничены нескроллируемой кратой.
Так появились Goblin Wars.
Игровой процесс
Он же
Игра была написана – да смилостивится надо мной Фон Нейман – на Visual Basic и использовала BitBlt для вывода графики. Всю графику я рисовал сам, в меру своих умений, в 3D Max, включая тайлсет. Озвучивали игру мы вместе с друзьями. У игры даже была предыстория, которая, если кратко, заключалась в том, что раньше на земле жили гоблины, потом пришли люди, которые стали их истреблять, и гоблинам пришлось уйти под землю. С тех пор под крупными городами людей существуют гоблинские города. Гоблины собирают всякий технологический мусор типа старых неработающих телевизоров, выкинутых людьми, и конструируют из них своих роботов и тому подобные поделки. А так как их осталось мало, конфликты, возникающие между городами, решено было разрешать не кровопролитными войнами, а специальными турнирами, на который каждый город посылал лучшего бойца. Эти турниры и были названы Гоблинскими Войнами.
В игре присутствовало достаточно много типов оружия – бомбы, гранаты, пистолет, автомат, ракеты, дистанционные бомбы, мины, фазовое ружье (телепортатор) и – основная фишка игры – фосфорная вошь + оружие против нее – АВО.
После того, как гоблин запускал фосфорную вошь, управление переключалось на нее. Остановить ее было нельзя, только поменять направление движения. Врезаясь в стены или другого гоблина, она взрывалась, нанося большой урон попавшему в эпицентр. Самым веселым было то, что если гоблина, ею управляющего, убивали, либо попадали в вошь из АВО, она становилась «дикой» — ее скорость возрастала в два раза и она начинала неуправляемо бегать по карте, с характерным звуком бросаясь на игроков, которые оказались близко, и пытаясь до них добраться. Кроме того, в ящиках иногда выпадал «бонус», выкидывающий на карту с десяток сразу диких вшей, что также добавляло драйва. После матча выдавалась статистика с рангами:
А вы сможете стать легендарным вошеборцем?
Мы очень часто играли в Гоблинов с друзьями, а также распространили их в школе — на уроках информатики народ из нашего и параллельного класса играл в гоблинов доводя преподавательницу криками из колонок «Любите ли вы вшей?», издаваемых гоблинами. В общем, чем-то эта игра нас захватила, так что даже спустя много лет, в перерывах между старкрафтом 2 мы нет-нет да играли матч-другой в гоблинов, чтобы вспомнить былые времена. Со времен первого релиза Goblin Wars прошло больше десяти лет.
Идея написать вторую версию была у меня уже давно – так как сейчас быстрое сетевое соединение уже не проблема, хотелось иметь возможность поиграть с друзьями по сети, не только 1х1 за одной клавиатурой. Когда я, наконец, созрел для написания, я начал писать GWII на С++. Довел до состояния альфа-версии, и что-то энтузиазм угас. Не так давно, энтузиазм вновь вернулся, и я, выбрав, на этот раз C#, решил-таки реализовать задуманное. Изначально планы были более амбициозные – как минимум заменить вид сверху изометрией. Но так как времени у меня стало совсем мало (работа, прочие проекты), да и художника у меня по-прежнему не наблюдалось, в итоге я решил поступить так: графику по максимуму взять из старых, перерисовав, разве что то, что смотрелось совсем отвратительно (например, тайлы в новой версии стали 64х64 и их просто необходимо было хоть как-то перерисовать), оставить вид сверху, зато реализовать сетевую игру, убрать неиспользуемое оружие (такое как бомбы и гранаты в старых) и т.п.
Сразу представлю скриншот того, что получилось:
В новом свете
А ниже, по просьбе читателей — видео геймплея, тестовый трехминутный матч с другом.
К сожалению, остальные друзья сейчас вне досягаемости, так что пришлось 1х1 играть.
Итак, что же представляют собой Goblin WarsII?
Игра построена по принципу клиент-сервер, все рассчеты ведутся на серверной части, клиентская предназначена исключительно для отрисовки. Отрисовка ведется средствами OpenGL, звук выводится через OpenAL, изображения подгружаются DevIL. Сетевая библиотека — Lindgren network. OpenGL, OpenAL и DevIL подключены к шарпу посредствам обертки Tao Framework.
Никаких готовых движков не использовалось – да, я знаю, можно было бы, наверное, взять готовый 3Д движок и получить лучший результат, но мне хотелось именно постаринке написать все самому и с нуля, насладиться своим «велосипедом».
Архитектуру этого велосипеда я и изложу в следующих главах статьи.
Что я бы хотел отметить прежде всего – игра модульная. Очень модульная. В том смысле, что все подсистемы игры представляют собой отдельные dll-ки, большинство из которых никак не связано друг с другом, а те что связаны знают только об интерфейсах. Это позволяет легко выкинуть одну часть системы и заменить другой. Не нравится сеть на UDP, lindgren network? Пожалуйста, берем одну дллку, Network.dll, и пишем свою, не забывая о реализации интерфейсов INetworkClient, INetworkServer. Например, для реализации сингл-плеера была реализована так называемая Zero-network, которая представляет собой просто связь для клиента и сервера безо всяких сетей – чистый вызов методов интерфейсов. При этом и клиенту и серверу совершенно все равно, работают они с Zero-Network или с настоящей сетью.
То же самое можно сказать и в отношении, например, графики. Архитектура позволяет переписать Graphics.dll, заменив OpenGL вывод чем угодно – хоть WinAPI, хоть D3D. Также заменой одной дллки, в принципе, можно заменить вид сверху на изометрию, если возникнет такое желание.
Ниже представлен гарф зависимостей на уровне сборок:
Архитектура игры
GoblinWarsII.exe и Server.exe – собственно, исполняемые файлы. Содержат всего по несколько строк, т.к. вся логика находится в дллках. Например, весь код сервера выглядит так:
Common.dll содержит общие объявления и вспомогательные классы, такие как специальный таймер. В основном, там лежит описание структур данных, например – enum типов айтемов, которые нужны как серверу, так и клиенту:
Network.dll, понятное дело, содержит логику сети, связующую клиент и сервер воедино.
ServerLogic.dll используется только сервером и содержит всю игровую логику, именно там происходят все расчеты игровых объектов.
Media.dll используется только клиентом и содержит логику отображения серверных объектов на клиенте (позже я остановлюсь на этом подробнее).
И, наконец, Graphics.dll содержит код непосредственно отрисовки объектов.
Почти каждая дллка стартует свой тред обработки и не стопорит другие – ServerLogic – тред расчета игровой логики, Network – треды приема-передачи, Media – тред обработки медиа-объектов (представления серверных объектов на клиенте), Graphics – тред рендера.
Перейдем к рассмотрению конкретной реализации, и начнем мы с реализации сервера.
Итак, как было уже понятно из приведенного выше кода, основное в сервере – класс Game, экземпляр которого создается в бинарнике Server.exe:
Всем остальным системам виден только интерфейс серверной логики, выглядящий примерно вот так:
Архитектура сервера
Таким образом, извне можно запустить игру, добавить либо удалить игроков, получить различную информацию о матче, и, самое главное – получить все состояния игровых объектов.
Именно она возвращает снимок мира, по которому его можно полностью отрисовать. Таким образом, сетевая система в своем треде передачи просто периодически запрашивает у Game снимок мира, чтобы в дальнейшем сериализовать его, сжать и передать клиентам.
Взаимодействие с игровым объектом, представляющем, собственно, плеера(например, передача ему действий, пришедших с клиента), происходит также не напрямую, а через интерфейс IPlayerDescriptor:
В результате получаем то, что и было описано выше – полностью отделенную от всего остального подсистему с игровой логикой. Рассмотрим теперь то, как, собственно, она работает внутри.
«Верхний слой» выглядит довольно стандартно тред-сейф Dictionary <ID_Объекта, Объект> –
и цикл обработки, вызывающийся заданное количество раз в секунду и отсчитывающий время, прошедшее с прошлого просчета:
GameContext это класс, ссылка на экземпляр которого передается в конструктор всех создаваемых игровых объектов, он содержит все необходимые сведения об игре, такие как ссылку на игровую карту, список игроков, а также методы для добавления нового объекта в игру, чтобы не нужно было передавать ссылку на сам
Все поля GameContext неизменяемые, readonly, что исключает «порчу» контекста игровыми объектами.
А вот реализация игровых объектов более интересна. Изначально я предполагал поступить вполне тривиально, создав базовый GameObject и унаследовав от него конкретные реализации, такие как Player, Bomb и т.п. Но этот подход не лишен недостатка. Когда классов становится много, и, особенно, когда становится много подклассов, таких как «телепортируемые объекты», «объекты со здоровьем» — вся эта архитектура становится громоздкой и неудобной.
Малейшее изменение заставляет перекраивать ее с самого начала. Поэтому я обратился к опыту создателя игры Dungeon Siege, который я подчерпнул из его презентации.
Вкратце, суть сводится к следующему: все игровые объекты представляют собой только контейнеры для игровых компонентов и не содержат логики и данных, кроме логики обмена сообщениями. Компоненты же являются законченными блоками, содержащими необходимую логику и данные для какой-то фиксированной задачи. Взаимодействие между объектами происходит посредствам обмена сообщениями, которые роутятся компонентам.
Что это нам дает? Это дает очень, очень гибкий и удобный способ реализации игровой логики. То, чем станет объект в игровом мире, теперь определяется только набором его компонентов и их параметрами конструктора. Один раз реализовав компонент с какой-то частью логики его можно пихнуть в любой игровой объект, не путаясь в способах декомпозиции на классы, в отличие от традиционного подхода.
Более того, теперь все объекты можно сделать одного класса, GameObject, а список компонентов загрузить из какого-нибудь конфига на ходу.
В данном случае, преимуществом последнего пункта я пока не воспользовался – оставил загрузку из конфига на «потом» и таки сделал разные классы объектов, но пусть это вас не смущает – это было сделано только для того, чтобы пока не грузить конфигурацию из внешних файлов, других различий между классами Player, Bomb, Bullet и т.п. не существует, и как только у меня дойдут до этого руки, все они будут заменены единым GameObject.
Давайте теперь рассмотрим поближе, как это все реализуется. Итак, в основе всего лежит интерфейс IGameObject:
Игровой объект может получить только вот такой интерфейс другого объекта, что дает дополнительную защиту от неправильного использования — сторонний объект не сможет ни добавить компоненты объекту, ни как-либо с ними взаимодействовать, только проверить наличие того или иного компонента, для чего и присутствует IComponent[] GetComponents();
Реализация интерфейса, GameObject, содержит логику обмена сообщениями и получения состояния (что используется при получении снепшота мира):
Блокирующий объект необходим для того, чтобы гарантировать неизменность состояния объекта в момент запроса сети.
Сообщения складываются в канкарент-очередть вида
private readonly ConcurrentQueue messageQueue;
Список
Базовый класс компонента содержит, прежде всего, три основных функции:
ProcessMessage должна быть обязательно переопределена в наследнике, так как именно она и отвечает за логику, реализуемую компонентом.
GetState, единственная функция, доступная через интерфейс IComponent внешним системам, возвращает общедоступное состояние объекта, если таковое имеется – например, здоровье или координаты. Если объект не имеет общедоступного состояния, то ее можно не переопределять.
Функция Probe проверяет компонентные зависимости. Если компонент не зависит ни от чего, то можно ее не трогать. Если же есть зависимость – как, напрмер, у компонента Collector зависимость от компонента Inventory, то она должна быть проверена в этой функции. Используется не только для проверки, но и для кеширования ссылки на соответствующие зависимости, чтобы не искать их каждый раз заново.
Сейчас вышесказанное может выглядеть туманно, но давайте рассмотрим примеры, и все должно стать ясно.
Как, например, в такой архитектуре сделать пулю? Ракету?
Итак, первым реализованным компонентом стал SolidBody. Это компонент, отвечающий за физическое воплощение объекта в игровом мире – то есть за его координаты, взаимодействие с картой и размеры.
Как и всем компонентам, в конструктор ему передаются Owner – объект-владелец и gameContext — контекст игры, из которого, в данном случае, он берет ссылку на GameMap.
Остальное уже специфично для СолидБоди – координаты объекта, его физические размеры, угол поворота, а также пара флагов – для оптимизации вычислений объекты, которые не должны сталкиваться друг с другом зовутся semisolid – столкновения просчитываются только для solid-solid и solid-semisolid, два semisolid не сталкиваются. К таковым относятся, например, пули, которые не могут столкнуться друг с другом.
Пуля, безусловно, будет SolidBody, т.к. имеет координаты и размеры. Более того, она будет semisolid, так как нам не нужны бесполезные просчеты столкновений пуль друг с другом, это надо помнить.
Реализацию SolidBody я, на данный момент, опущу, так как она довольно большая.
Отмечу только, что для ускорения расчетов, к каждому тайлу карты привязан список объектов, которые его касаются. При перемещении объекта по карте эти списки обновляются. Таким образом, при просчете столкновений нам не нужно считать расстояния от каждого объекта до каждого, а достаточно сначала быстро отобрать тайлы в интересующем нас радиусе и выполнить расчет расстояния только для объектов, которые их касаются.
Далее, реализуем компонент, являющийся сосредоточием логики для всех пуль, ракет и прочих снарядов – он будет отвечать за простой, равномерный, прямолинейный полет.
Теперь должно уже стать более понятно. Чтобы стало совсем понятно, продемонстрирую еще один компонент, очень простой, и без зависимостей – DieOnTTL. Как следует из названия, компонент отвечает за смерть объекта по истечении заданного срока:
Как, в таком случае, будут выглядеть наши пули? Очень просто. Вот, допустим, пистолетная пуля:
Как я уже сказал, отдельный класс создан только потому, что пока не дошли руки до загрузки конфига извне. Что же делает этот код? Прежде всего, передает в базовый конструктор GameObject игровой тип — GameObjectType.PistolBullet
Именно по этому типу будет идти отрисовка на клиенте, именно его проверяют различные компоненты, которым важны столкновения с пулей и тому подобные взаимодействия.
Все что остается – добавить компоненты, которые сделают пулю пулей. В данном случае параметры передаются извне, в конструктор самого объекта, а оттуда – в конструкторы компонентов. Но никто не мешает жестко записать их прямо тут, в коде, или загрузить вместе со списком компонентов из какой-нибудь XMLки.
Прежде всего – SolidBody, так как пуля – вполне себе физический объект, имеющий координаты и сталкивающийся с другими. Не забудем указать true в параметре semisolid — нам не нужны лишние рассчеты.
Пули должны исчезать после определенного времени полета, даже если ни с чем не столкнутся. Значит добавим уже созданный нами объект DieOnTTL с параметром времени жизни.
Пуля должна лететь вперед. Это мы реализовали в Projectile, добавляем его с соответствующей скоростью.
Пуля должна помирать от столкновения. Добавим DieOnCollide. Его реализацю я опустил, но она вполне тривиальна – сообщения MsgCollide рассылает SolidBody, поэтому нам не нужно ничего заново реализовывать, только проверить MsgCollide.CollidedObject на предмет того, с кем же мы все таки столкнулись. В параметрах тут указано, коллайд с кем нам не игнорить, сказано коллайдить со стенами и указан ID объекта, который нам нужно игнорировать – туда передается ID создавшего эту пулю, parent’а, чтобы пули не ранили самого себя.
И наконец, последнее что нам нужно сделать, это как-то отреагировать на сообщения о том, что мы померли – DecayOnDeath просто тихо убьет объект при получении сообщения MsgDeath.
Хорошо, а если мы хотим ракету? Нет ничего проще:
Делаем то же самое, за исключением того, что ракета у нас чуть побольше, чем пуля (геометрические размеры SolidBody больше), а также ракеты не больше не коллайдят с объектами типа WildLouse, которые, как вы, наверняка заметили, были в списке коллайдящих объектов в конструкторе DieOnCollide у пули, и главное – ракета не тихо мрет при смерти, а громко взрывается, поэтому вместо DecayOnDeath мы добавляем ExplodeOnDeath с параметром радиуса поражения. Все! Мы сделали ракету почти на тех же компонентах – никакого переписывания уже имеющегося кода. Все что нам понадобилось – сделать еще один компонент, отвечающий за взрыв при смерти (он ведет себя также как DecayOnDeath, но создает в точке смерти новый объект, Explosion), который, без сомнения, пригодится еще где-нибудь.
Да, также не забываем указать соответствующий тип объекту — GameObjectType.Rocket.
Телепортирующая пуля, например, отличается только наличием компонента Teleporter:
Он отвечает за телепортацию объектов, содержащих компонент Teleportable. Чтобы сделать объект телепортируемым вам достаточно просто добавить ему Teleportable в список компонентов.
Подавляющее большинство компонентов даже не требуют никакого дополнительного наследования, что делает всю архитектуру очень гибкой и прозрачной.
Приведу список компонентов, используемых в игре:
Это почти полный список основных компонентов игры, не привязанных к реализации. Это часть движка. Также я реализовал несколько компонентов, относящихся непосредственно к GoblinWars – сюда относятся реализации наследников BaseWeapon и BaseModidierManager, реализующие уже конкретную обработку, WildLouseLogic – компонент, отвечащющий за поведение дикой вши и т.п.
Внешние системы, а точнее – сеть, взаимодействует с игроком, как уже было сказано, такими же сообщениями. Для этого сеть дергает соответствующий PlayerDescriptor на предмет метода PerformAction();
Реализация этого метода представляет собой обычное создание сообщений и посылку их PlayerRescriptor.PlayerObject.SendMeddage() в зависимости от требуемого Action.
Вот, в принципе все, что составляет основу ServerLogic.dll. В следующей статье я расскажу про сетевую часть сервера и клиента и ее взаимодействие с остальными подсистемами.
Полный исходный код я пока выкладывать не собираюсь, но если у кого-то возникнут вопросы по реализации, готов ответить. Саму игру на тест выложу в конце последней статьи, если кому интересно.
Если есть художники, готовые чисто ради собственного удовольствия перерисовать графику – буду очень благодарен. Вся графика тут – простые двумерные спрайты, нужно отрисовать гоблина в 8 направлениях, фосфорную вошь в 8 направлениях и, главное – тайлы, а то сейчас их катастрофически не хватает.
Также, в принципе заинтересован в портировании клиента на другие платформы, но один этим заниматься не хочу. Если кто желает попробовать портировать на мобильную систему или в веб, на каком-нибудь HTML5 – это тоже обсуждаемо.
Спасибо за внимание.
Предыстория
Задолго до того, как я стал инженером-электронщиком, я разрабатывал чисто софтварные проекты. С того самого момента, как я написал свою первую строчку кода(классе во втором, на Turbo Basic, во «дворце пионеров») меня не покидало желание написать игру. Думаю, с этим сталкивались почти все, кто начинал учиться программировать. Разумеется, знаний тогда не хватало на что-то масштабное, были только простые текстовые игры на том же Turbo Basic а потом и на Quick Basic. Иногда я использовал скудные бейсиковские возможности по выводу графики – была, например, игра, где нужно было управлять зеленым пикселем, убегая от красного и ставя ловушки в виде белых пикселей. Однако время шло, я узнавал все больше, и в итоге, классе в восьмом решил написать 2Д стрелялку для двух человек. Почему именно для двух? Потому что сети тогда не были так распространены, и самый доступный режим многопользовательской игры был Hot-Seat. В то время мы часто сражались с друзьями в Worms и Heroes таким образом, но хотелось драйва реалтайма. Поэтому я решил написать игру, где можно было бы в реал-тайме бегать, стрелять из различного оружия, подбирать ящики с припасами и т.п. – но при этом, можно было бы играть вдвоем. Одному игроку выделялась буквенная часть клавиатуры, другому – цифровая. Так как мышка была одна, она не использовалась, чтобы не давать преимущества одному из игроков. А т.к. монитор был один, боевые действия были ограничены нескроллируемой кратой.
Так появились Goblin Wars.
Игровой процесс
Он же
Игра была написана – да смилостивится надо мной Фон Нейман – на Visual Basic и использовала BitBlt для вывода графики. Всю графику я рисовал сам, в меру своих умений, в 3D Max, включая тайлсет. Озвучивали игру мы вместе с друзьями. У игры даже была предыстория, которая, если кратко, заключалась в том, что раньше на земле жили гоблины, потом пришли люди, которые стали их истреблять, и гоблинам пришлось уйти под землю. С тех пор под крупными городами людей существуют гоблинские города. Гоблины собирают всякий технологический мусор типа старых неработающих телевизоров, выкинутых людьми, и конструируют из них своих роботов и тому подобные поделки. А так как их осталось мало, конфликты, возникающие между городами, решено было разрешать не кровопролитными войнами, а специальными турнирами, на который каждый город посылал лучшего бойца. Эти турниры и были названы Гоблинскими Войнами.
В игре присутствовало достаточно много типов оружия – бомбы, гранаты, пистолет, автомат, ракеты, дистанционные бомбы, мины, фазовое ружье (телепортатор) и – основная фишка игры – фосфорная вошь + оружие против нее – АВО.
После того, как гоблин запускал фосфорную вошь, управление переключалось на нее. Остановить ее было нельзя, только поменять направление движения. Врезаясь в стены или другого гоблина, она взрывалась, нанося большой урон попавшему в эпицентр. Самым веселым было то, что если гоблина, ею управляющего, убивали, либо попадали в вошь из АВО, она становилась «дикой» — ее скорость возрастала в два раза и она начинала неуправляемо бегать по карте, с характерным звуком бросаясь на игроков, которые оказались близко, и пытаясь до них добраться. Кроме того, в ящиках иногда выпадал «бонус», выкидывающий на карту с десяток сразу диких вшей, что также добавляло драйва. После матча выдавалась статистика с рангами:
А вы сможете стать легендарным вошеборцем?
Мы очень часто играли в Гоблинов с друзьями, а также распространили их в школе — на уроках информатики народ из нашего и параллельного класса играл в гоблинов доводя преподавательницу криками из колонок «Любите ли вы вшей?», издаваемых гоблинами. В общем, чем-то эта игра нас захватила, так что даже спустя много лет, в перерывах между старкрафтом 2 мы нет-нет да играли матч-другой в гоблинов, чтобы вспомнить былые времена. Со времен первого релиза Goblin Wars прошло больше десяти лет.
Goblin Wars II.Net
Идея написать вторую версию была у меня уже давно – так как сейчас быстрое сетевое соединение уже не проблема, хотелось иметь возможность поиграть с друзьями по сети, не только 1х1 за одной клавиатурой. Когда я, наконец, созрел для написания, я начал писать GWII на С++. Довел до состояния альфа-версии, и что-то энтузиазм угас. Не так давно, энтузиазм вновь вернулся, и я, выбрав, на этот раз C#, решил-таки реализовать задуманное. Изначально планы были более амбициозные – как минимум заменить вид сверху изометрией. Но так как времени у меня стало совсем мало (работа, прочие проекты), да и художника у меня по-прежнему не наблюдалось, в итоге я решил поступить так: графику по максимуму взять из старых, перерисовав, разве что то, что смотрелось совсем отвратительно (например, тайлы в новой версии стали 64х64 и их просто необходимо было хоть как-то перерисовать), оставить вид сверху, зато реализовать сетевую игру, убрать неиспользуемое оружие (такое как бомбы и гранаты в старых) и т.п.
Сразу представлю скриншот того, что получилось:
В новом свете
А ниже, по просьбе читателей — видео геймплея, тестовый трехминутный матч с другом.
К сожалению, остальные друзья сейчас вне досягаемости, так что пришлось 1х1 играть.
Итак, что же представляют собой Goblin WarsII?
Игра построена по принципу клиент-сервер, все рассчеты ведутся на серверной части, клиентская предназначена исключительно для отрисовки. Отрисовка ведется средствами OpenGL, звук выводится через OpenAL, изображения подгружаются DevIL. Сетевая библиотека — Lindgren network. OpenGL, OpenAL и DevIL подключены к шарпу посредствам обертки Tao Framework.
Никаких готовых движков не использовалось – да, я знаю, можно было бы, наверное, взять готовый 3Д движок и получить лучший результат, но мне хотелось именно постаринке написать все самому и с нуля, насладиться своим «велосипедом».
Архитектуру этого велосипеда я и изложу в следующих главах статьи.
Архитектура игры: общий обзор
Что я бы хотел отметить прежде всего – игра модульная. Очень модульная. В том смысле, что все подсистемы игры представляют собой отдельные dll-ки, большинство из которых никак не связано друг с другом, а те что связаны знают только об интерфейсах. Это позволяет легко выкинуть одну часть системы и заменить другой. Не нравится сеть на UDP, lindgren network? Пожалуйста, берем одну дллку, Network.dll, и пишем свою, не забывая о реализации интерфейсов INetworkClient, INetworkServer. Например, для реализации сингл-плеера была реализована так называемая Zero-network, которая представляет собой просто связь для клиента и сервера безо всяких сетей – чистый вызов методов интерфейсов. При этом и клиенту и серверу совершенно все равно, работают они с Zero-Network или с настоящей сетью.
То же самое можно сказать и в отношении, например, графики. Архитектура позволяет переписать Graphics.dll, заменив OpenGL вывод чем угодно – хоть WinAPI, хоть D3D. Также заменой одной дллки, в принципе, можно заменить вид сверху на изометрию, если возникнет такое желание.
Ниже представлен гарф зависимостей на уровне сборок:
Архитектура игры
GoblinWarsII.exe и Server.exe – собственно, исполняемые файлы. Содержат всего по несколько строк, т.к. вся логика находится в дллках. Например, весь код сервера выглядит так:
class Program
{
static void Main(string[] args)
{
var game = new Game();
var networkServer = new UDPGameServer(game, int.Parse(args[2]));
var matchParameters = new MatchParameters
{Difficulty = DifficultyLevel.Easy, FragLimit=0, TimeLimit = uint.Parse(args[0]), MapName=args[1]};
game.StartMatch(matchParameters);
networkServer.Start();
while (Console.ReadKey().KeyChar != 'q');
game.Halt();
networkServer.Stop();
}
}
Common.dll содержит общие объявления и вспомогательные классы, такие как специальный таймер. В основном, там лежит описание структур данных, например – enum типов айтемов, которые нужны как серверу, так и клиенту:
public enum ItemType
{
PistolBullet,
AKBullet,
ShotgunBullet,
Rocket,
Louse,
AVOBullet,
PhaseBullet,
Trapped,
StronglyTrapped,
WildLouse,
LouslyTrapped,
PoisonTrapped,
FirstAid,
MegaHealth,
AVOModifier,
Boost,
LousePack,
Equipment,
GunabadianHello,
LouseHunterEquipment
}
Network.dll, понятное дело, содержит логику сети, связующую клиент и сервер воедино.
ServerLogic.dll используется только сервером и содержит всю игровую логику, именно там происходят все расчеты игровых объектов.
Media.dll используется только клиентом и содержит логику отображения серверных объектов на клиенте (позже я остановлюсь на этом подробнее).
И, наконец, Graphics.dll содержит код непосредственно отрисовки объектов.
Почти каждая дллка стартует свой тред обработки и не стопорит другие – ServerLogic – тред расчета игровой логики, Network – треды приема-передачи, Media – тред обработки медиа-объектов (представления серверных объектов на клиенте), Graphics – тред рендера.
Перейдем к рассмотрению конкретной реализации, и начнем мы с реализации сервера.
Архитектура игры: сервер
Итак, как было уже понятно из приведенного выше кода, основное в сервере – класс Game, экземпляр которого создается в бинарнике Server.exe:
var game = new Game();
Всем остальным системам виден только интерфейс серверной логики, выглядящий примерно вот так:
Архитектура сервера
Таким образом, извне можно запустить игру, добавить либо удалить игроков, получить различную информацию о матче, и, самое главное – получить все состояния игровых объектов.
public IList<GameObjectState> GetAllObjectStates()
– одна из самых важных функций игры. Именно она возвращает снимок мира, по которому его можно полностью отрисовать. Таким образом, сетевая система в своем треде передачи просто периодически запрашивает у Game снимок мира, чтобы в дальнейшем сериализовать его, сжать и передать клиентам.
Взаимодействие с игровым объектом, представляющем, собственно, плеера(например, передача ему действий, пришедших с клиента), происходит также не напрямую, а через интерфейс IPlayerDescriptor:
public interface IPlayerDescriptor
{
void PerformAction(PlayerAction action);
Tuple<double, double> GetLookCoords();
long GetId();
double GetLoginTime();
}
В результате получаем то, что и было описано выше – полностью отделенную от всего остального подсистему с игровой логикой. Рассмотрим теперь то, как, собственно, она работает внутри.
«Верхний слой» выглядит довольно стандартно тред-сейф Dictionary <ID_Объекта, Объект> –
private readonly ConcurrentDictionary<ushort, GameObject> gameObjects;
и цикл обработки, вызывающийся заданное количество раз в секунду и отсчитывающий время, прошедшее с прошлого просчета:
private void ObjectProcessingTaskRoutine()
{
quantTimer.Tick();
Statistics.TimeQuantPassed(quantTimer.QuantValue);
if (currentMatchParameters.TimeLimit > 0 && Statistics.MatchTime > currentMatchParameters.TimeLimit)
EndMatch();
foreach(var gameObject in gameObjects)
{
if (!gameObject.Value.Destroyed())
gameObject.Value.Process(quantTimer.QuantValue);
else
gameContext.RemoveObject(gameObject.Key);
}
}
GameContext это класс, ссылка на экземпляр которого передается в конструктор всех создаваемых игровых объектов, он содержит все необходимые сведения об игре, такие как ссылку на игровую карту, список игроков, а также методы для добавления нового объекта в игру, чтобы не нужно было передавать ссылку на сам
ConcurrentDictionary<ushort, GameObject> gameObjects;
Все поля GameContext неизменяемые, readonly, что исключает «порчу» контекста игровыми объектами.
А вот реализация игровых объектов более интересна. Изначально я предполагал поступить вполне тривиально, создав базовый GameObject и унаследовав от него конкретные реализации, такие как Player, Bomb и т.п. Но этот подход не лишен недостатка. Когда классов становится много, и, особенно, когда становится много подклассов, таких как «телепортируемые объекты», «объекты со здоровьем» — вся эта архитектура становится громоздкой и неудобной.
Малейшее изменение заставляет перекраивать ее с самого начала. Поэтому я обратился к опыту создателя игры Dungeon Siege, который я подчерпнул из его презентации.
Вкратце, суть сводится к следующему: все игровые объекты представляют собой только контейнеры для игровых компонентов и не содержат логики и данных, кроме логики обмена сообщениями. Компоненты же являются законченными блоками, содержащими необходимую логику и данные для какой-то фиксированной задачи. Взаимодействие между объектами происходит посредствам обмена сообщениями, которые роутятся компонентам.
Что это нам дает? Это дает очень, очень гибкий и удобный способ реализации игровой логики. То, чем станет объект в игровом мире, теперь определяется только набором его компонентов и их параметрами конструктора. Один раз реализовав компонент с какой-то частью логики его можно пихнуть в любой игровой объект, не путаясь в способах декомпозиции на классы, в отличие от традиционного подхода.
Более того, теперь все объекты можно сделать одного класса, GameObject, а список компонентов загрузить из какого-нибудь конфига на ходу.
В данном случае, преимуществом последнего пункта я пока не воспользовался – оставил загрузку из конфига на «потом» и таки сделал разные классы объектов, но пусть это вас не смущает – это было сделано только для того, чтобы пока не грузить конфигурацию из внешних файлов, других различий между классами Player, Bomb, Bullet и т.п. не существует, и как только у меня дойдут до этого руки, все они будут заменены единым GameObject.
Давайте теперь рассмотрим поближе, как это все реализуется. Итак, в основе всего лежит интерфейс IGameObject:
internal interface IGameObject
{
void SendMessage(ComponentMessageBase msg);
GameObjectState GetState();
IComponent[] GetComponents();
ushort GetId();
GameObjectType GetGOType();
IGameObject GetParent();
void Dispose();
bool Destroyed();
}
Игровой объект может получить только вот такой интерфейс другого объекта, что дает дополнительную защиту от неправильного использования — сторонний объект не сможет ни добавить компоненты объекту, ни как-либо с ними взаимодействовать, только проверить наличие того или иного компонента, для чего и присутствует IComponent[] GetComponents();
Реализация интерфейса, GameObject, содержит логику обмена сообщениями и получения состояния (что используется при получении снепшота мира):
public void SendMessage(ComponentMessageBase msg)
{
messageQueue.Enqueue(msg);
}
public GameObjectState GetState()
{
var states = new List<ComponentState>(components.Count);
lock (lockObj)
{
if (Destroyed())
return null;
foreach (var component in components)
{
var state = component.GetState();
if (state != null)
states.Add(state);
}
}
return new GameObjectState(Id, type, states);
}
public void Process(double quantValue)
{
if (Destroyed())
return;
lock (lockObj)
{
SendMessage(new MsgTimeQuantPassed(this, quantValue)); //Автоматически добавляем сообщение о том, что истек квант времени
while (messageQueue.Count > 0)
{
ComponentMessageBase msg;
messageQueue.TryDequeue(out msg);
foreach (var component in components)
component.ProcessMessage(msg);
}
}
}
Блокирующий объект необходим для того, чтобы гарантировать неизменность состояния объекта в момент запроса сети.
Сообщения складываются в канкарент-очередть вида
private readonly ConcurrentQueue messageQueue;
Список
private readonly List<Component> components;
содержит, понятное дело, все компоненты объекта.Архитектура игры: компоненты
Базовый класс компонента содержит, прежде всего, три основных функции:
public abstract void ProcessMessage(ComponentMessageBase msg);
public virtual ComponentState GetState()
{
return null;
}
public virtual bool Probe()
{
return true;
}
ProcessMessage должна быть обязательно переопределена в наследнике, так как именно она и отвечает за логику, реализуемую компонентом.
GetState, единственная функция, доступная через интерфейс IComponent внешним системам, возвращает общедоступное состояние объекта, если таковое имеется – например, здоровье или координаты. Если объект не имеет общедоступного состояния, то ее можно не переопределять.
Функция Probe проверяет компонентные зависимости. Если компонент не зависит ни от чего, то можно ее не трогать. Если же есть зависимость – как, напрмер, у компонента Collector зависимость от компонента Inventory, то она должна быть проверена в этой функции. Используется не только для проверки, но и для кеширования ссылки на соответствующие зависимости, чтобы не искать их каждый раз заново.
Сейчас вышесказанное может выглядеть туманно, но давайте рассмотрим примеры, и все должно стать ясно.
Как, например, в такой архитектуре сделать пулю? Ракету?
Итак, первым реализованным компонентом стал SolidBody. Это компонент, отвечающий за физическое воплощение объекта в игровом мире – то есть за его координаты, взаимодействие с картой и размеры.
public SolidBody(GameObject owner, GameContext gameContext, double x, double y, double sizeX, double sizeY, byte angle = 0, bool semisolid = false)
Как и всем компонентам, в конструктор ему передаются Owner – объект-владелец и gameContext — контекст игры, из которого, в данном случае, он берет ссылку на GameMap.
Остальное уже специфично для СолидБоди – координаты объекта, его физические размеры, угол поворота, а также пара флагов – для оптимизации вычислений объекты, которые не должны сталкиваться друг с другом зовутся semisolid – столкновения просчитываются только для solid-solid и solid-semisolid, два semisolid не сталкиваются. К таковым относятся, например, пули, которые не могут столкнуться друг с другом.
Пуля, безусловно, будет SolidBody, т.к. имеет координаты и размеры. Более того, она будет semisolid, так как нам не нужны бесполезные просчеты столкновений пуль друг с другом, это надо помнить.
Реализацию SolidBody я, на данный момент, опущу, так как она довольно большая.
Отмечу только, что для ускорения расчетов, к каждому тайлу карты привязан список объектов, которые его касаются. При перемещении объекта по карте эти списки обновляются. Таким образом, при просчете столкновений нам не нужно считать расстояния от каждого объекта до каждого, а достаточно сначала быстро отобрать тайлы в интересующем нас радиусе и выполнить расчет расстояния только для объектов, которые их касаются.
Далее, реализуем компонент, являющийся сосредоточием логики для всех пуль, ракет и прочих снарядов – он будет отвечать за простой, равномерный, прямолинейный полет.
internal class Projectile : Component
{
private SolidBody solidBody; //Ссылка на зависимость, снаряд обязан иметь SolidBody!
private readonly double speed; //Единственный параметр – скорость полета
public Projectile(GameObject owner, double speed)
: base(owner)
{
this.speed = speed;
}
public override void ProcessMessage(ComponentMessageBase msg)
{
//Обработка интересующих нас сообщений – в данном случае, нам интересно только сообщение о прошедшем временнОм кванте.
if (msg.MessageType == MessageTypes.TimeQuantPassed)
ProcessTimeQuantPassed(msg as MsgTimeQuantPassed);
}
//Вот и вся логика. Берем из зависимого СолидБоди текущие координаты, считаем их приращение исходя из того, сколько прошло времени, и говорим СолидБоди сдвинуться
private void ProcessTimeQuantPassed(MsgTimeQuantPassed msg)
{
double dT = msg.MillisecondsPassed;
var solidState = (SolidBodyState)solidBody.GetState();
byte angle = solidState.Angle;
double dX = SpecMath.Cos(angle) * speed * dT,
dY = SpecMath.Sin(angle) * speed * dT;
//Обратите внимание, такое возможно только в пределах одного объекта – помним об инкапсуляции и о том, что извне не получишь ссылку на сам компонент. Внешние объекты не могут напрямую подвинуть другие объекты – только послав им соответствующую мессагу.
solidBody.AppendCoords(dX, dY, angle);
}
//Проверяем компонентную зависимость, снаряд обязан иметь СолидБоди. GetOwnerComponent, как уже сказано выше, доступна только изнутри одного объекта. Извне нельзя получить компонент, только его интерфейс, через который можно лишь взять Стейт. Изнутри возможностей больше.
public override bool Probe()
{
solidBody = GetOwnerComponent<SolidBody>();
return solidBody != null;
}
}
Теперь должно уже стать более понятно. Чтобы стало совсем понятно, продемонстрирую еще один компонент, очень простой, и без зависимостей – DieOnTTL. Как следует из названия, компонент отвечает за смерть объекта по истечении заданного срока:
internal class DieOnTTL : Component
{
private readonly double ttl;
private double lifetime;
public DieOnTTL(GameObject owner, double ttl)
: base(owner)
{
this.ttl = ttl;
lifetime = 0.0;
}
public override void ProcessMessage(ComponentMessageBase msg)
{
if (msg.MessageType == MessageTypes.TimeQuantPassed)
ProcessTimeQuantPassed(msg as MsgTimeQuantPassed);
}
private void ProcessTimeQuantPassed(MsgTimeQuantPassed msg)
{
var dT = msg.MillisecondsPassed;
lifetime += dT;
if (lifetime >= ttl)
//Шлем владельцу компонента соответствующее сообщение
Owner.SendMessage(new MsgDeath(Owner));
}
}
Как, в таком случае, будут выглядеть наши пули? Очень просто. Вот, допустим, пистолетная пуля:
class PistolBullet : GameObject
{
public PistolBullet(GameContext context, IGameObject parent, double x, double y, byte angle, double speed, double ttl)
: base(context, GameObjectType.PistolBullet, parent)
{
AddComponents(
new SolidBody(this, context.GameMap, x, y, 9, 9, angle, true),
new DieOnTTL(this, ttl),
new Projectile(this, speed),
new DieOnCollide(this,new GameObjectType[]{GameObjectType.Player, GameObjectType.WildLouse}, true, new ushort[]{parent.GetId()}),
new DecayOnDeath(this)
);
}
}
Как я уже сказал, отдельный класс создан только потому, что пока не дошли руки до загрузки конфига извне. Что же делает этот код? Прежде всего, передает в базовый конструктор GameObject игровой тип — GameObjectType.PistolBullet
Именно по этому типу будет идти отрисовка на клиенте, именно его проверяют различные компоненты, которым важны столкновения с пулей и тому подобные взаимодействия.
Все что остается – добавить компоненты, которые сделают пулю пулей. В данном случае параметры передаются извне, в конструктор самого объекта, а оттуда – в конструкторы компонентов. Но никто не мешает жестко записать их прямо тут, в коде, или загрузить вместе со списком компонентов из какой-нибудь XMLки.
Прежде всего – SolidBody, так как пуля – вполне себе физический объект, имеющий координаты и сталкивающийся с другими. Не забудем указать true в параметре semisolid — нам не нужны лишние рассчеты.
Пули должны исчезать после определенного времени полета, даже если ни с чем не столкнутся. Значит добавим уже созданный нами объект DieOnTTL с параметром времени жизни.
Пуля должна лететь вперед. Это мы реализовали в Projectile, добавляем его с соответствующей скоростью.
Пуля должна помирать от столкновения. Добавим DieOnCollide. Его реализацю я опустил, но она вполне тривиальна – сообщения MsgCollide рассылает SolidBody, поэтому нам не нужно ничего заново реализовывать, только проверить MsgCollide.CollidedObject на предмет того, с кем же мы все таки столкнулись. В параметрах тут указано, коллайд с кем нам не игнорить, сказано коллайдить со стенами и указан ID объекта, который нам нужно игнорировать – туда передается ID создавшего эту пулю, parent’а, чтобы пули не ранили самого себя.
И наконец, последнее что нам нужно сделать, это как-то отреагировать на сообщения о том, что мы померли – DecayOnDeath просто тихо убьет объект при получении сообщения MsgDeath.
Хорошо, а если мы хотим ракету? Нет ничего проще:
class Rocket : GameObject
{
public Rocket(GameContext context, IGameObject parrent, double x, double y, byte angle, double speed, double ttl, double radius)
: base(context, GameObjectType.Rocket, parrent)
{
AddComponents(
new SolidBody(this, context.GameMap, x, y, 15, 15, angle, true),
new DieOnTTL(this, ttl),
new Projectile(this, speed),
new DieOnCollide(this,new[]{GameObjectType.Player, GameObjectType.WildLouse}, true, new ushort[]{parrent.GetId()}),
new ExplodeOnDeath(this,context, radius)
);
}
}
Делаем то же самое, за исключением того, что ракета у нас чуть побольше, чем пуля (геометрические размеры SolidBody больше), а также ракеты не больше не коллайдят с объектами типа WildLouse, которые, как вы, наверняка заметили, были в списке коллайдящих объектов в конструкторе DieOnCollide у пули, и главное – ракета не тихо мрет при смерти, а громко взрывается, поэтому вместо DecayOnDeath мы добавляем ExplodeOnDeath с параметром радиуса поражения. Все! Мы сделали ракету почти на тех же компонентах – никакого переписывания уже имеющегося кода. Все что нам понадобилось – сделать еще один компонент, отвечающий за взрыв при смерти (он ведет себя также как DecayOnDeath, но создает в точке смерти новый объект, Explosion), который, без сомнения, пригодится еще где-нибудь.
Да, также не забываем указать соответствующий тип объекту — GameObjectType.Rocket.
Телепортирующая пуля, например, отличается только наличием компонента Teleporter:
AddComponents(
new SolidBody(this, context.GameMap, x, y, 30, 30, angle,false, false, true),
new DieOnTTL(this, ttl),
new Projectile(this, speed),
new Teleporter(this, context),
new DieOnCollide(this, new GameObjectType[] {}, true, new ushort[] { parrent.GetId() }),
new PhaseOnDeath(this, context, radius)
);
Он отвечает за телепортацию объектов, содержащих компонент Teleportable. Чтобы сделать объект телепортируемым вам достаточно просто добавить ему Teleportable в список компонентов.
Подавляющее большинство компонентов даже не требуют никакого дополнительного наследования, что делает всю архитектуру очень гибкой и прозрачной.
Приведу список компонентов, используемых в игре:
- AOE – отвечает за Area Of Effect воздействия. Объектам, попавшим в радиус действия рассылается сообщение MsgAOE с полем Effect, которое показывает насколько силен эффект воздействия (он вычисляется в зависимости от расстояния до эпицентра, в конструктор компонента можно передать профиль воздействия, чтобы кастомизировать то, как будет изменяться эффект с расстоянием).
Используется взрывом, телепортирующей пулей. - BaseModifierManager – компонент, отвечающий за модификаторы. То есть за вещи типа «четверного урона», «ускорения» и прочих «плюшек», которые появляются у игрока по нахождении какого-либо айтема и действуют определенное время. Один из компонентов, где требуется наследование, так как базовый вариант предполагает переопределения функций:
– для модификаторов, действующих каждый квант времени (регенерация здоровья, яд)protected virtual void ProcessModifier(ItemType itemType, ModifierDescriptor modifier, double quantValue)
– событие наступающее при приобретении модификатораprotected virtual void ModifierAcquired(ItemType itemType, ModifierDescriptor modifier)
– событие, наступающее при утере модификатораprotected virtual void ModifierLost(ItemType itemType, ModifierDescriptor modifier)
- BaseWeapon – отвечает за оружие, также требует наследования. В базовую логику входит проверка наличия соответствующего айтема, являющегося патроном для оружия, проверка кулдауна – эта информация записана в WeaponDescriptor’ах, обработкой которых и занимается данный компонент. Дергает переопределенные функции наследника
– при успешном выстреле, передавая туда дескриптор оружия, из которого стрельнулиprotected abstract void Shoot(WeaponDescriptor weaponDescriptor, SolidBody solidBody)
– при отсутствии патроновprotected virtual void OutOfAmmo(WeaponDescriptor weaponDescriptor, SolidBody solidBody)
– при кулдаунеprotected virtual void Cooldowning(WeaponDescriptor weaponDescriptor)
- Collectable – компонент, рассылающий столкнувшимся с ним объектам с компонентом Collector сообщение MsgItemsAvailable, кричащее «я тут, подбери меня»
- Collector – компонент, который, услышав MsgItemsAvailable забирает айтемы в инвентарь.
- Различные DecayOn… и DieOn… уже были описаны выше.
- Healthy – отвечает за наличие здоровья и нанесения дамага. Также шлет MsgDeath когда здоровье доходит до 0.
- Inventory – компонент-инвентарь, отвечает за хранение списка айтемов с их количеством. Нужен всему, что юзает айтемы – Коллектору, Weapon и т.п.
- Projectile – простой снаряд.
- SolidBody – было описано выше, физическое воплощение – координаты, столкновения. Кроме того предоставляет статическую функцию FindPath для поиска пути на карте.
- Walker – как Projectile, но для «разумных» объектов – движется в заданном направлении, которое задается мессагой MsgWalk. Останавливается если в MsgWalk в качестве направления указано Direction.Stop
Это почти полный список основных компонентов игры, не привязанных к реализации. Это часть движка. Также я реализовал несколько компонентов, относящихся непосредственно к GoblinWars – сюда относятся реализации наследников BaseWeapon и BaseModidierManager, реализующие уже конкретную обработку, WildLouseLogic – компонент, отвечащющий за поведение дикой вши и т.п.
Внешние системы, а точнее – сеть, взаимодействует с игроком, как уже было сказано, такими же сообщениями. Для этого сеть дергает соответствующий PlayerDescriptor на предмет метода PerformAction();
Реализация этого метода представляет собой обычное создание сообщений и посылку их PlayerRescriptor.PlayerObject.SendMeddage() в зависимости от требуемого Action.
Заключение
Вот, в принципе все, что составляет основу ServerLogic.dll. В следующей статье я расскажу про сетевую часть сервера и клиента и ее взаимодействие с остальными подсистемами.
Полный исходный код я пока выкладывать не собираюсь, но если у кого-то возникнут вопросы по реализации, готов ответить. Саму игру на тест выложу в конце последней статьи, если кому интересно.
Если есть художники, готовые чисто ради собственного удовольствия перерисовать графику – буду очень благодарен. Вся графика тут – простые двумерные спрайты, нужно отрисовать гоблина в 8 направлениях, фосфорную вошь в 8 направлениях и, главное – тайлы, а то сейчас их катастрофически не хватает.
Также, в принципе заинтересован в портировании клиента на другие платформы, но один этим заниматься не хочу. Если кто желает попробовать портировать на мобильную систему или в веб, на каком-нибудь HTML5 – это тоже обсуждаемо.
Спасибо за внимание.