Техническая препарация одной игры, созданной независимыми разработчиками

в 19:39, , рубрики: flash, flex, game development, php, метки: , ,

Здравствуй, суровый, но справедливый хабр!

Хочу вместе с тобой препарировать одну игру, написанную мной совместно с моим хорошим другом. По механике игра – это бой в реальном времени между двумя игроками, у каждого из которых колода карт. А карты, в свою очередь, генерируют бойцов, которые уже самостоятельно прут на бункер противника, попутно кроша на фарш вражеских воинов. Помимо боя в игре есть магазин с картами; штаб, где можно формировать колоду и качать персонажей; арена, где можно запустить квест или реальный бой; ну и банк, где можно добыть игровую валюту. Напомню, мы независимые разработчики, поэтому ограничены в ресурсах и многие решения не идеальны.
Как начинали придумывать игру здесь: habrahabr.ru/post/142490/

Начнём препарацию.

Социальная платформа

Следуя принципу KISS, мы решили сразу делать игру для соцсетей потому, что там есть уже готовый механизм авторизации и широкие возможности распространения. В общем, проще всего быть услышанным в них. Выбрали Вконтакте из-за более молодой аудитории и большего опыта работы с этой сетью конкретно у нас.
Всё взаимодействие с соцсетью вынесли в отдельный модуль с синглтоном, чтобы в дальнейшем менять соцсети, как перчатки. Для этого у нас есть единый интерфейс, который должен реализовать каждый конкретный класс для каждой соцсети. Игра взаимодействует с соцсетью через синглтон с этим интерфейсом.

Flash-платформа

Выбрав Вконтакте, нам на выбор осталось две технологии: flash и iframe (html+javascript). Если честно, то игры на html я не перевариваю, к тому же я flash-разработчик много лет. Поэтому здесь даже обсуждений не было: сразу выбрали flash.

Flex

Внутри flash-технологии был выбор: писать ли на чистом actionscript или использовать flex-фреймворк. Чистый actionscript даёт более быстрый код, а flex обладает преимуществом быстрой и гибкой разработки для интерфейсов. Например, добавить окно со статистикой в архитектуре flex быстрее, проще и надёжнее, чем на чистом actionscript. Мы выбрали соломоново решение: все интерфейсы и окружение игры сделали на flex, а непосредственно бой (основное действие в игре) на чистом actionscript.
В сторону 3d движков не смотрели, решили не усложнять, но очень интересно, конечно.

Следующая информация для флешистов. (Именно флешистов, ибо flasher на жаргоне – это чувак, который ходит голый в плаще в парках… а дальше вы знаете). Если Вы не флешист, Вам можно переходить к следующему разделу.

При построении интерфейсов на flex, мы сразу делали скинованные компоненты. Для этого каждый компонент должен наследоваться от SkinnableComponent.

   /**
	 * Представление карты.
	 * */
	public class Card extends SkinnableComponent
	{				
		/** Модель данных для кнопки. */
		private var _model:CardModel = new CardModel();
		
		public function set model(value:CardModel):void
		{
			_model = value;
			modelChanged = true;	

			this.invalidateProperties();
		}
		
		public function get model():CardModel
		{
			return _model;
		}
		
		/** Флаг, показывающий была ли изменена модель. Нужен для оптимизации. Используется в commitProperties */
		private var modelChanged:Boolean = false;
				
		
		[SkinPart(required="true")]
		/** Навзвание юнита. */
		public var nameLabel:Label;	
			
		public function Card()
		{
			super();			
	
		}
		
		
		/**
		 *  @private
		 */ 
		override protected function partAdded(partName:String, instance:Object):void
		{
			super.partAdded(partName, instance);
			
			if (instance == nameLabel)
			{
				if(model)
					nameLabel.text = model.name;
			}
		}
		
		/**
		 *  @private
		 */ 
		override protected function commitProperties():void
		{
			super.commitProperties();
			
			if(this.modelChanged)
			{
				if(model)
					nameLabel.text = model.name;
            }
        }

Здесь стоит обратить внимание на следующие куски кода:

[SkinPart(required="true")]
/** Навзвание юнита. */
public var nameLabel:Label;

Это элемент интерфейса, который будет в конкретном скине. Этот факт отмечается метатегом SkinPart. Параметр метатега required показывает, должен ли этот конкретный компонент быть реализован в скине или его можно опустить.

Этот метод необходимо переопределить для инициализации элементов нашего скина. Как делать, думаю, понятно из кода.

override protected function partAdded(partName:String, instance:Object):void

Ещё имеет смысл переопределить этот метод:

override protected function commitProperties():void

При изменении данных лучше всего присваивать значения новых данных компонентам в этом методе. Это скажется на производительности этих компонентов в лучшую сторону. Дело в том, что этот метод вызывается только при перерисовке экрана, таким образом, откладывая ресурсоёмкое изменение компонента до тех пор, пока оно действительно не понадобится.

Мы храним данные отдельно от компонентов, в моделях:

       /** Модель данных для кнопки. */
		private var _model:CardModel = new CardModel();
		
		public function set model(value:CardModel):void
		{
			_model = value;
			modelChanged = true;	

			this.invalidateProperties();
		}
		
		public function get model():CardModel
		{
			return _model;
		}

Заметьте, при изменении данных, мы их лишь сохраняем, помечаем модель данных изменённой и вызываем метод invalidateProperties(). Этот метод говорит flex, что данные изменились и надо вызвать commitProperties(). Самостоятельно commitProperties() вызывать нельзя, он вызовется, как я уже говорил, при перерисовке экрана.

Модель данных у нас – это просто структура с полями, описывающими модель.

    /**
	 * Модель данных для карты.
	 * */
	public class CardModel
	{		
		/** Имя карты. */
		public var name:String = "";	
    }

Скин может выглядеть так:

<?xml version="1.0" encoding="utf-8"?>
<s:Skin xmlns:fx="http://ns.adobe.com/mxml/2009" 
		xmlns:s="library://ns.adobe.com/flex/spark" 
		xmlns:mx="library://ns.adobe.com/flex/mx" 
		xmlns:components="view.components.*">
	<!-- host component -->
	<fx:Metadata>
		[HostComponent("view.components.Card")]
	</fx:Metadata>
		
	<s:Label id="nameLabel"/>
	
</s:Skin>

Здесь стоит обратить внимание на блок Metadata. Здесь указывается «родной» компонент для скина.

Используя схему с разделением компонента на его механику и скин, мы отказываемся от связывания (Binding), которое считается плохой практикой. А ещё мы теперь можем для одного компонента иметь несколько вариантов представления, менять их на лету, и безболезненно их легко переделывать, вплоть до полной смены дизайна.

Flash XML Graphics

Ещё одна технология, которую мы использовали, это FXG или Flash XML Graphics. Это векторный формат для графики, который разработан Adobe, основанный на XML. Он удобен в mxml. Имея картинку в этом формате, в mxml код она добавляется одним единственным тегом. Ну и используя векторную графику, размер приложения у нас получился 3 мб. Правда тормозит, падла. Чтобы не тормозило, обвешали всё значением cashAsBitmap равным true.

Архитектура

При построении архитектуры мы ставили целью быстрое создание клонов игры в будущем. Посему мы разделили игру на следующие уровни:

  • Ядро
  • Логика
  • Модели данных
  • Представление
  • Скины

Ядро берёт на себя общение с сервером. Логика хранит игровую механику – основу для клонов. Модели описывают данные и больше ничего. Представление – это уровень интерфейсов и интерфейсной механики. А скины – это уже то, что видно.
Здесь просматривается архитектура mvc, но мы не стали использовать готовые фреймворки (pureMVC, mate), реализующие эту архитектуру, чтобы упростить разработку. Решили лишь идейно придерживаться принципа разделения логики и представления.

Математическая модель боя

Здесь интересен вопрос синхронизации игрового мира у двух клиентов. Ведь у нас игровые юниты не по клеткам ходят. Они имеют вещественные координаты на плоскости, разные размеры, сталкиваются друг с другом и обходят возникающие препятствия.
Для синхронизации мы используем точное моделирование событий игрового мира, которое достигается двумя моментами:

  1. Любое состояние мат. модели описывается целочисленными данными
  2. Время режется на фиксированные промежутки времени по 20 мс, и пересчет мат. модели возможен только на +20 мс. Если надо больше – мат. модель пересчитывается несколько раз. Если меньше – вообще не пересчитывается.

Мат. модель у нас работает отдельно сама-по-себе, она никак не связана с представлением боя на экране. Таким образом, мы можем совершенно спокойно делать другое представление на других технологиях, и когда оно будет готово – предоставить клиенту выбор. Например, мы планируем в будущем сделать спрайтовую графику через stage3d.

Сервер

Серверную часть мы решили делать на PHP. На самом деле выбор был следующий – реализовывать клиент-серверный протокол на сокетах или на HTTP. И сделав выбор в пользу скорости разработки и гарантии стабильной работы у клиента, вы выбрали последнее. А именно PHP – т.к. в этой области у нас имеется огромный опыт.

Передача сообщений от сервера клиентам, и от клиента другому клиенту, происходит через механизм периодического опроса сервера. При возрастании игрового онлайна это дает большую нагрузку на сервер, поэтому все механизмы опроса мы выделили на отдельные сервера. Всего у нас 3 типа серверов:

  1. Main-сервер. Он такой один у нас. Он хранит данные всех игроков, обрабатывает изменения этих данных (например, при покупке чего-либо в игровом в магазине), и распределяет зашедших в игру игроков по Lobby- и Battle-серверам.
  2. Lobby-сервер. Каждому игроку, зашедшему в игру, сразу назначается наименее загруженный из доступных Lobby-серверов. Тем не менее, каждый клиент получает полный список всех Lobby-серверов, и на них на всех рассылает номер своего Lobby-сервера. Таким образом, если какой-то другой клиент захочет отправить нам (клиенту) некое сообщение, допустим приглашение вступить в бой, то он со своего Lobby-сервера узнает, на каком сервере находимся мы, и после этого отправит сообщение уже конкретно на наш Lobby-сервер. Мы же, в свою очередь, просто опрашивая раз в 5 секунд свой Lobby-сервер, узнаем о входящем сообщении. Аналогично Main-сервер может отправить любому игроку требуемое сообщение.
  3. Battle-сервер. Когда на Main-сервере находятся два игрока, желающие вступить в бой друг с другом, им назначается наименее загруженный из доступных Battle-серверов. После чего, через механизм Lobby-серверов, им рассылаются приглашения вступить в бой, с указанием конкретного Battle-сервера, ключей от уже созданного боя, и всего прочего. На этом Battle-сервере игроки уже спокойно обмениваются информацией с частотой опроса 2 секунды, и совершенно не мешают работе остальной серверной системы.

Друг с другом сервера общаются также через http-протокол. Но есть одна тонкость – во многих случаях используется отправка http-запроса без ожидания ответа. Например при создании боя: создаем уникальный guid-ключ, отправляем команду выбранному Battle-серверу создать бой с этим ключом, не ожидая ответа рассылаем Lobby-серверам игроков приглашения в бой на выбранный Battle-сервер с этим же ключом, и, снова не дожидаясь ответа, завершаем обработку запроса.

Благодаря такой системе, мы имеем возможность динамически на горячую менять производительность серверной площадки (за исключением Main-сервера). Мы просто вводим в строй дополнительные сервера при необходимости. И таким образом, мы хостимся на дешевых VPS-серверах, и у нас нет необходимости заранее оплачивать дорогой и мощный сервер. К тому же, хостер за доплату может увеличить производительность VPS, даже не выключая его.

На счет Main-сервера: мы надеемся, что наибольшую часть нагрузки с него мы сняли, введя систему из Battle- и Lobby-серверов, и по началу для него хватит одной самой мощной VPS-ки, далее будет перевод его на настоящее серверное железо. Ну и походу сбора статистики мы поймем, есть ли необходимость дальнейшей оптимизации серверной части игры.

MMO-технология

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

Здесь интересно то, что на этот раз мы решили отказаться от таблиц самих инвайтов, списков пропущенных инвайтов и прочей лабуды. Уже наелись с этим в прошлом проекте. Дело в том, что кажется, что это просто, а на практике возникает огромное количество непредвиденных ситуаций. А если игрок оффнлайн? А если он вроде-как-онлайн, но ему только что перерезали интернет и об этом еще никто не знает? А если он вообще удалил приложение, но остался в базе? А если он нажал принять инвайт, а я успел отменить и уже кинул другому игроку? А если я кинул инвайт и обновил страницу? Ну и т.д. Возникает довольно большая путаница.

Поэтому мы сделали все проще: у нас в таблице игроков есть всего два поля: state и opponentId. state определяет, свободен ли игрок, кинул ли он инвайт, видит ли он сообщение о входящем инвайте, находится ли он в бою. opponentId – полагаю, понятно, зачем нужен. Кидать инвайт можно только свободным игрокам. И даже после внезапной перезагрузки страницы браузера, клиент быстро восстанавливает все диалоги, касающиеся приглашений в бой.

Бой со случайным противником реализован через подбор противника по рейтингу. Игрок отправляет заявку. Заявка лежит в базе данных 15 секунд, после чего попадает в обработку. Обработка происходит скриптом, срабатывающим раз в 10 секунд. Этот скрипт осуществляет выбор пар заявок с ближайшими рейтингами. Но не точно самыми ближайшими – используется рандом в неком диапазоне. А диапазон постепенно расширяется. В первую очередь обрабатываются заявки с самым низким рейтингом – для того, что бы обеспечить наилучшую обработку вновь прибывших клиентов. Т.к. заявки очень быстро расходятся, то оптимизации этот скрипт не требует. Поэтому об алгоритмах здесь писать не буду – все просто и линейно.
Задержка в 15 секунд нужна, т.к. без неё каждая вторая заявка будет стартовать бой с первой, и никакие рейтинги учитываться не будут.

В заключении

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

Автор: Stepik

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


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