7 мая 2015

Разработка Return of Dr. Destructo: до чего дошёл Прогресс

Разработка игр
Из песочницы
Недавно я выпустил в свет свой первый законченный «домашний» проект — ремейк игры «Island of Dr. Destructo» (также известной как просто Destructo) с ZX Spectrum. В этом посте я хотел бы рассказать немного о том, как шла разработка и поделиться некоторыми интересными замечаниями о кросс-платформенной разработке и архитектуре кода.



Во всех своих домашних проектах я использовал простое средство поддержания мотивации — вёл файл Progress.txt, в котором записывал, что было сделано в каждый день. В сочетании с рекомендованным многими писателями подходом «ни дня без строчки» этот метод даёт лично для меня очень неплохие результаты. В первый, активный период разработки «Return of Dr. Destructo», мне удалось работать над игрой почти каждый день на протяжении года. Подобный файл бывает интересно перечитывать некоторое время спустя, вспоминая, что ты делал месяц, пол года, или год назад. Прямо таки «перечитывал пейджер, много думал...», как шутили в 90-х. Сейчас мы и займёмся этим вместе — и не бойтесь, я постараюсь выбирать только места, о которых можно рассказать что-то кроме сухих строчек «сделал фичу, исправил баг» и сопровожу всё это некоторым количеством картинок.

Свой Progress.txt я вёл на английском, но для этой статьи все записи будут переведены на русский.

02.08.11: Возился с небом, водой, солнцем и луной
Разработка проекта началась в 2011 году, после того, как очередной, больший по масштабам домашний проект опять протух. Захотелось сделать что-нибудь, что я точно смогу довести до конца что-нибудь простое, но всё равно интересное. Создать версию «Island of Dr. Destructo» для PC было моей давней задумкой. Эта игра очень запомнилась с детства, когда она попалась мне в числе прочих на кассете «игр про самолётики», привезённой с Царицынского рынка. Основной особенностью, поразившей меня тогда, был разрушаемый уровень: каждый сбитый враг, каждая брошенная бомба вырывали из вражеского корабля кусок, причём не какой-то заранее выбранный авторами игры, а вот конкретный, именно в том месте, где было попадание! Такое и сейчас-то редко встречается в играх, а тогда — ну, это было просто ах!

Разработку я начал с того, что сделал отрисовку неба и воды. В исходной игре была смена времени суток, которая выглядела как проход солнца и луны по небу, а ещё, ночью появлялись звёзды. Мне, конечно, захотелось немного осовременить картинку, поэтому небо стало градиентным, а звёзды стали проявляться постепенно. Параметры этих процессов задаются ключевыми точками, между которыми игра производит линейную интерполяцию соответствующих значений.

В воде отражаются солнце, луна и звёзды. Изначально, я хотел отражать вообще всё, что находится на экране — вражеские самолёты, самолёт игрока, цель уровня. Но оказалось, что это некрасиво и неудобно.

Времена суток





10.08.11: Закончил рефакторинг
Пока я возился с небом, весь код жил в паре классов, создававшихся и вызывавшихся из main(), но дальше пришла пора подумать об архитектуре. Весь мой предыдущий показывал, что хардкорное ООП очень плохо подходит для игровой механики: сложные красивые иерархии классов и изолированные слои абстракции слишком негибки для этой области, в которой часто небольшое изменение постановки задачи ведёт к тому, что надо разом нарушить несколько абстракций и связать то, что раньше было независимо, или наоборот.

С другой стороны, отказываться от инкапсуляции полностью и складывать всё в одну кучу — тоже прямой путь в ад. Как раз в тот момент, когда я начинал писать «Return of Dr. Destructo», на работе начальник рассказывал про компонентный подход. Надо сказать, понимал я его слабо (как выяснилось потом). Но на основе того понимания, которое было, некоторую архитектуру я, всё-таки, измыслил. Забегая вперёд, скажу, что она оказалась достаточно удачной: ни разу я не переписывал её большие куски, а количество совсем уж мерзких костылей осталось минимальным. С другой стороны, если у вас при мысли о компонентной архитектуре перед глазами возникает Unity, то скажу сразу — у меня получилось не совсем так.

Итак, как организована архитектура игры. Всё, что относится к какой-то одной подсистеме — звуку, графике, физики, механике — вынесено в отдельный компонент. Есть и объединяющий их все компонент GameObject, который ничего более не умеет, а только содержит ID других компонентов. Именно ID — я не стал пользоваться какими-либо видами ссылок, за что поплатился — код доступа к компонентам объектов вышел неудобным. Однако, в отличие от того же Unity, компонент — штука весьма тупая. Это просто структура с данными, лежащая себе в каком-то массиве. Методов она не содержит (за исключением, быть может, каких-то простейших вспомогательных), а все данные в ней публичны.

Во время расчёта кадра, все компоненты одного типа последовательно обрабатываются соответствующим Процессором. Процессор для физики — рассчитывает перемещения и столкновения, процессор для графики — меняет таймеры анимаций и рисует кадры, и так далее. При этом, процессор всегда работает с одним типом компонентов — другие ему не доступны.

Поскольку процессоры не имеют доступа к «чужим» компонентам, то вынужденно происходит дублирование данных. Например, у объекта должны быть физические, и графические координаты. В более сложной игре они могли бы ещё и не совпадать, но тут они всегда одинаковы, если не произошла ошибка. Тем не менее, общего места для их хранения нет, поэтому приходится в некоторый момент копировать их из физического компонента (который их меняет) в графический (которому они нужны, чтобы знать, где рисовать объект). Таким копированием, а также другим межкомпонентным взаимодействием, занимается код игровых состояний. В основном, вся подобная механика живёт в классе GameStateLevel, отвечающем за то, что происходит на экране во время уровня.

Файл этого класса, на первый взгляд, может немного напугать своими двумя тысячами строк, но на деле всё не так уж плохо, просто тут же описано ещё несколько вспомогательных классов, которые давно пора бы вынести в отдельные файлы, но руки так и не дошли. В главном же классе состояния основной набор методов — всякие вещи типа ControlsToPhysics, PhysicsToSound и т.п., отвечающие за перенос необходимых данных из одного компонента в другой с применением преобразований.

В отличие от механической части игры, игровые состояния и элементы UI написаны в более привычной объектно-ориентированной парадигме. Состояния представляют собой Pushdown Automata, хорошо описанные в Game Programming Patterns: есть некоторый стэк, на который состояние можно положить, или снять. В моей реализации есть две особенности: во-первых, ввод и обновление по времени получает только самый верхний объект состояния, а рисуются — все (это чтобы можно было, например, рисовать состояние Обучающего Режима поверх обычного состояния Уровня); во-вторых, снимать состояния можно не только с вершины стэка, но и с середины — при этом будут сняты все состояния выше удаляемого, поскольку они считаются «дочерними».

Моя игра довольно проста, поэтому игровые состояния и состояния UI у меня совпадают. В общем случае, это не так, и даже в своей разработке я сталкивался со случаями, когда это было неудобно!

12.08.11: Начал работать над системой десериализации XML (базовые вещи уже работают)
16.08.11: Вместо десериализации вышла какая-то хрень. Всю проклятую систему надо переписывать с нуля
18.08.11: Пока закончил возиться с десериализаций (но она всё равно чертовски уродлива)

С++ и (де)сериализация данных объектов — бесконечная тема. Во всяком случае, пока в очередном стандарте не прикрутят хоть какой-то reflection. До начала написания «Return of Dr. Destructo» у меня был опыт работы уже с несколькими самописными (не мной) системами, а также с Boost.Serialization (о, это был тот ещё опыт...). Поэтому я понимал, что красиво, удобно и просто задача не решается. Но писать бесконечные циклы по элементам в XML файле мне тоже не хотелось, поэтому я решил сделать свою систему для загрузки данных из XML в однотипные именованные объекты.

С виду, задача у меня была проще, чем общий случай: мне была нужна только десериализация, обратный процесс — нет. Мне нужна были поддержка только одного формата — XML. И я не ставил себе того сложновыполнимого условия, что имя десериализуемого члена класса должно упоминаться только один раз при его объявлении (типа Deserializable m_someField). Более того, по задумке, код десериализации должен был быть вынесен в отдельный класс. Но было и некоторое усложнение: в тех случаях, когда десериализация работала с именованными объектами (например, описаниями анимаций), нужна была поддержка наследования, чтобы можно было полностью скопировать ранее загруженный объект, и потом поменять в нём некоторые поля.

Небольшое лирическое отступление на тему «зачем всё это нужно». Во-первых, определение: «Прототипом объекта» в моём личном лексиконе, тянущимся с первой работы, называется класс, содержащий общие для всех объектов этого типа данные. В зависимости от надобности, данные из прототипа могут быть скопированы, и позже изменены, либо же объект может держать ссылку на прототип, и тогда эти данные будут неизменны. Вот данные для этих самых прототипов мне и надо было загрузить из XML файлов.

Надо сказать, что результаты получились рабочими (вся игра активно использует этот набор классов). С другой стороны, он ужасен в смысле удобства, и может лучше было бы всё-таки написать всю десериализацию руками. Посудить сами:

Страшный и ужасный пример десериализации графического прототипа объекта
  // Объявляем десериализатор именованного объекта типа SGraphicsProto со строковым ID. Это прототип графического компонента.
class GraphicDeserializer : public XMLNamedObjectDeserializer<SGraphicsProto, std::string>
{
      // Объявляем десериализатор для одной анимации (у неё ID не строковый, а числовой, AnimationID)
	class AnimDeserializer : public XMLNamedObjectDeserializer<SAnimProto, AnimationID>
	{
          // Объявляем десериализатор для одного кадра, у него ID нет вообще, наследовать кадры не получится
		class FrameDeserializer : public XMLObjectDeserializer<SAnimFrame>
		{
		public:
			FrameDeserializer() : XMLObjectDeserializer<SAnimFrame>( "Frame", false )
			{}

              // Функция Bind связывает поля свежевыделенного объекта SAnimFrame с атрибутами XML-тэга
			void Bind( SAnimFrame & object )
			{
                  // Attrib_Value - обычные атрибуты, которые будут прочитаны в соответствующее поля объекта без изменений
				Attrib_Value( "X", false, object.x );
				Attrib_Value( "Y", false, object.y );
				Attrib_Value( "W", true, object.w );
				Attrib_Value( "H", true, object.h );
				Attrib_Value( "FlipH", true, object.flipH );
				Attrib_Value( "FlipV", true, object.flipV );
                  // Attrib_SetterValue - а эти атрибуты будут записаны при помощи ф-ий SetX2 и SetY2, которые, на самом деле,
                  // превратят из в W и H - просто иногда было удобнее указывать размеры кадра так.
				Attrib_SetterValue<SAnimFrame, int>( "X2", true, object, &SAnimFrame::SetX2 );
				Attrib_SetterValue<SAnimFrame, int>( "Y2", true, object, &SAnimFrame::SetY2 );
			}
		}m_frameDes;

	public:
		AnimDeserializer()
			: XMLNamedObjectDeserializer<SAnimProto, AnimationID>( "Animation", false, "ID" )
		{
              // Запоминаем, что внутри объекта анимации надо читать кадры
			SubDeserializer( m_frameDes );
		}

          // Аналогичная операция для объекта анимации
		void Bind( SAnimProto & object )
		{
			Attrib_Value( "FPS", false, object.m_fps );
			Attrib_Value( "Dir", true, object.m_dir );
			Attrib_Value( "Reverse", true, object.m_reverse );
			Attrib_Value( "FlipV", true, object.m_flipV );
			Attrib_Value( "FlipH", true, object.m_flipH );
			Attrib_Value( "OneShot", true, object.m_oneShot );
			Attrib_Value( "SoundEvent", true, object.m_soundEvent );
              // Прочитанные кадры надо добавлять в анимацию при помощи функции AddFrame
			m_frameDes.SetReceiver( object, &SAnimProto::AddFrame );
		}
	};

private:
      // XMLDataDeserializer - класс для чтения данных без создания новых объектов
	XMLDataDeserializer m_imgDes;
	XMLDataDeserializer m_bgDes;
	XMLDataDeserializer m_capsDes;
      // А вот если мы встретили описание анимации - то новый объект надо будет выделить и заполнить
	AnimDeserializer m_animDes;

	void Bind( SGraphicsProto & object )
	{
          // Это значение читается напрямую из тэга
		Attrib_Value( "Layer", false, object.m_layerID );
          // Добавлять новые анимации в SGraphicsProto будем функцией SetAnim
		m_animDes.SetReceiver( object, &SGraphicsProto::SetAnim );
          // Анимации - именованные, а значит их можно наследовать! Чтобы это работало,
          // указываем функцию, которая умеет по имени достать ранее прочитанную анимацию
		m_animDes.SetGetter<SGraphicsProto>( object, &SGraphicsProto::GetAnim );
          // Ну, а из этих тэгов мы будем читать по одному атрибуту.
		m_imgDes.Attrib_Value( "Path", false, object.m_image );
		m_bgDes.Attrib_Value( "Path", false, object.m_imageBg );
		m_capsDes.Attrib_SetterValue<SGraphicsProto, int>( "ID", false, object, &SGraphicsProto::SetCaps );
	}

public:
	GraphicDeserializer()
		: XMLNamedObjectDeserializer<SGraphicsProto, std::string>( "Graphic", true, "Name")
		, m_imgDes( "Image", false )
		, m_bgDes( "Bg", true )
		, m_capsDes( "Caps", false )
	{
		SubDeserializer( m_imgDes ); 
		SubDeserializer( m_bgDes ); 
		SubDeserializer( m_animDes ); 
		SubDeserializer( m_capsDes ); 
	}
};

  // Корневой десериализатор, который, по сути, проверит, что файл начинается с тэга Graphics и передаст управление десериализатору графики
class GraphicsDeserializer : public RootXMLDeserializer
{
public:
	GraphicDeserializer m_graphicDes;

	GraphicsDeserializer()
		: RootXMLDeserializer( "Graphics" )
	{
		SubDeserializer( m_graphicDes ); 
	}
};

  // А так выглядит использование подготовленного ранее класса:
void GraphicsProtoManager::LoadResources()
{
	GraphicsDeserializer root;
      // Графические объекты целиком тоже можно наследовать, поэтому им нужны Set и Get функции
	root.m_graphicDes.SetReceiver<GraphicsProtoManager>( *this, &GraphicsProtoManager::AddResource );
	root.m_graphicDes.SetGetter<GraphicsProtoManager>( *this, &GraphicsProtoManager::GetResource );
      // XMLDeserializer умеет загружать файл и передавать управление корневому десериализатору
	XMLDeserializer des( root );
      // Поехали!
	des.Deserialize( "Data/Protos/graphics.xml" );	
}



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

19.08.11: Сделал прототипы для двух вражеских самолётиков и выдрал спрайты для них. Загрузил их в игру в тестовом режиме

Речь пойдёт вот о чём: на экране нужно было рисовать какие-то объекты. А сам я художник исключительно от слова худо, и времени учиться этому ремеслу нет. Поэтому, было принято решения выдрать всю интересующую меня графику из оригинальной игры, а потом уже заменить её на что-нибудь более интересное. С большими статичными объектами всё было просто: делаем скриншот окна эмулятора и вырезаем оттуда всё, что нужно. Но что делать с анимированными самолётами? Если ловить скриншотами разные кадры анимации — надоест ОЧЕНЬ быстро… К счастью, мне помог эмулятор EmuZWin, обладающий полезной функций просмотра памяти, более того — просмотра памяти в графическом виде. С его помощью, перебрав разные размеры объектов, удалось получить почти все интересующие меня спрайты:

Исходная графика





Остальное было делом техники и терпения — каждый кадр надо было найти, вырезать, сложить в текстуру, покрасить в нужный цвет и подобрать параметры анимации, чтобы выглядело похоже на оригинал.

25.09.11: ВЫПУЩЕНА ВЕРСИЯ 0.4 (именно так, большими буквами, как в файле)

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

Версия 0.4 во всей красе


23.11.11: Начал работу над объектом сбитого вертолёта

О, вертолёты! Если вы играли в оригинал, вы должны ненавидеть их так же, как и я. Мало того, что эти твари внезапно меняют направление движения и стреляют ракетами, так после того, как их собьёшь, они становятся ещё опаснее! Сбитый вертолёт, в отличие от большинства других врагов, сохраняет возможность сталкиваться с игроком и убивать его, а падает он не просто так, а быстрым зиг-загом. Мотивация этого дизайнерского решения проста: обычные опасные самолётики проще всего сбивать снизу, потому что так в них проще попасть, и сохраняется возможность отвернуть от столкновения, если попасть не удалось. А вот вертолёты (и позже — бомбардировщики) как раз заставляют игрока менять тактику.

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

02.12.11: Закончил работу над автоприцеливанием

Добавив в игру уже несколько уровней и новых врагов, я сравнил её с оригиналом, и пришёл к выводу, что играть стало сильно сложнее. Дело в том, что в оригинальной игре столкновения определялись достаточно грубо, поэтому попасть по врагам было проще. И экран был меньше, теснее. В новой же игре отклонение от нужного курса в несколько градусов могло стать причиной промаха, зачастую — фатального. Компенсировать это точностью управления не удалось (может быть, ума не хватило), поэтому я решил добавить в игру автоприцеливание: настройку сложности, которая позволяла бы вылетающим патронам немного отклоняться от прямой, и лететь на перехват ближайшей цели. Пришлось долго возиться с подбором параметров — ведь что считать ближайшей целью? — но результат сделал игру более приятной, поэтому в финальной версии автоприцеливание включено по умолчанию.

04.12.11: Сделал брифинг между уровнями, добавил комментарии по ходу уровня

Есть у меня нехорошая привычка — стараться добавить в любую игру историю. В своё время, мы с приятелем даже арканоид делали с историей! На деле, оно, конечно, зачастую зазря — не везде надо пихать диалоги и персонажей. Но в «Return of Dr. Destructo» я удержаться не смог. Частично, брифинги перед уровнями родились из дизайнерской необходимости: мне хотелось как-то разбить череду сменяющих друг друга кораблей, замков и островов моментами расслабления. Кроме того, хотелось иметь возможность как-то рассказать игроку, что его ждёт на следующем уровне в плане новых врагов. Ведь по внешнему виду совершенно невозможно понять, какие из них тебя при столкновении собьют, а какие безопасны. Последнюю проблему, к сожалению, решить таким образом толком не удалось. Поэтому игроки вынуждены страдать так же, как страдал я в далёких 90-х.

Разговоры по ходу уровня тоже родились из необходимости: в исходной игре, чтобы потопить цель, нужно было пробить в ней три дырки до дна. Каждая дырка отображалась фонтанчиком воды. В моём ремейке так сделать не получалось, потому что теперь падающие враги выбивали из цели не аккуратные кирпичики, а круглые дырки в произвольных местах. Но как-то дать понять игроку, что он стал ближе к победе, было надо. В результате, я принял спорное решение на каждом уровне показывать три текстовых сообщения, каждое из которых соответствует примерно трети прогресса в деле утопления цели. Позже, добавился ещё и индикатор повреждений, постепенно заполняющийся в левой нижней части UI, где отображается название уровня.

Разговорчики в строю в финальной версии




06.02.12: Закончил работу над компонентом Музыки, использовал библиотеку irrKlang вместо звукового API Allegro

Ещё одно признание — в игры я обычно играю с отключёнными звуками и музыкой. Ну, уж с музыкой-то точно. Даже любимые мелодии надоедают, если крутить их непрерывно, а у нас с авторами большинства игр вкусы в музыке совсем расходятся (почему бы кому-нибудь не сделать игрушку с саундтрэком из классического рокабилли? Хотя бы гонки!). Поэтому вставку музыки и звуков в свою игру я откладывал так долго, как только было можно. Но пришёл тот момент…

Вообще, всю игру я написал с использованием библиотеки Allegro. Она, может быть, не так распространена, как SDL, но мне больше нравится её API, и вообще — я с Allegro вместе ещё с DOS-версии, которую нашёл в 11ом классе, когда впервые начал изучать C после QuickBasic и FutureLibrary.

Но звуковой API Allegro мне, поначалу, показался слишком сложным. Хотелось простого: создать звук, сыграть звук. Поэтому, после некоторых поисков, я выбрал для воспроизведения звука библиотеку irrKlang, API которой больше соответствовал моим запросам. Это оказалось ошибкой: irrKlang подтекал при проигрывании треккерных файлов (а музыка в игре в формате it), автор проблему признавать и чинить отказывался, сорцов не было, а под Linux так и вообще творились какие-то ужасы. Поэтому потом пришлось её выпиливать, и таки разбираться с тем, как работать со звуком в Allegro (оказалось — ничего страшного).

Кстати, почему музыка в формате Impluse Tracker? Я, вообще, не фанат треккеров, в том смысле, что никогда под них музыку не писал, и не слушал её специально. Слышать-то, конечно, слышал — сами знаете, где…

Я не музыкант, грамоты нотной не знаю, музыкальной теории не обучен. Но кое-как умею играть на гитаре. Поэтому решил, что для своей игры музыку попробую написать сам, тем более, что у меня валялась одна готовая где-то на четверть композиция. Музыку я писал в честно купленном Guitar Pro 5, но дальше была беда: GP умел экспортировать результаты работы только в WAV или MIDI. Wav'ы, даже пожатые в OGG или MP3, мне не нравились: получалось, что музыка у меня будет занимать больше, чем вся остальная игра вместе взятая. А MIDI проигрывать Allegro (и irrKlang) не умели. Пришлось наладить сложный процесс — выгружать мелодию из Guitar Pro в MIDI, а потом MPTracker'ом конвертировать её в треккерный формат, понятный имеющимся библиотекам. Извращение? Несомненно! Работает? Да!

Первый трек, сейчас играющий во время уровня, был написан по воспоминаниям о PC-Speaker музыке из игры Prehistorik, но не той, из самого начала, от которой быстрее начинает биться сердце любого школьника 90ых, «тада-тта, тада-тта», а, кажется, из третьего, лесного уровня. Честно говоря, я потом так и не нашёл, просматривая записи на Youtube, тот фрагмент, который, как мне казалось, я помнил, и по мотивам которого сочинил свою мелодию.

Второй трек — тоже «по мотивам», на сей раз, рокабильной инструменталки «Mohawk Twist» группы Jackals. Вряд ли вы о ней слышали. /hipster mode off

Послушать треки отдельно, если не хочется играть в игру, можно тут:
Game Music 1: .it .ogg
Game Music 2: .it .ogg

09.03.12: Начал перевод AI на Lua

На самом деле, AI в игре нет. В том смысле, что AI — это же что-то интерактивное, он должен реагировать на игровую ситуацию, принимать решения, действовать… Враги же в игре летают по строго заданным правилам: пролететь 500 пикселей прямо, потом снизиться на 100 пикселей, потом дальше снова лететь прямо уже до конца. И тому подобное. Поэтому, на деле имеет место быть не AI, а скрипты поведения. Но это длинно, поэтому везде далее это будет называться AI.

Изначально, как и все остальные игровые данные, AI описывался XMLем, как-то примерно так:

<FlyStraight Trigger="Delta_X" TriggerParam="$P1">
    <Random ID="P1" From="100" To="900"/>
</FlyStraight>

<FlyDown Trigger="Delta_Y" TriggerParam="100">
</FlyDown>

<FlyStraight Trigger="Forever">
</FlyStraight>


Для простых скриптов этого хватало, хотя и было уже неудобно. Однако, ближе к концу игры стали появляться враги с более сложным поведением, которое требовало циклов, математики и условных операторов. Реализовывать всё это в XML показалось мне невероятно плохой идеей, поэтому, с некоторым сожалением, весь предыдущий AI был выброшен на помойку, а в игре угнездились Lua и Luabind.

Но общий принцип работы хотелось оставить тот же. Каждая строчка скрипта должна были задавать некоторое простое поведение (лететь вниз, лететь вверх, стрелять, развернуться), которое должно было продолжаться, пока не сработает указанный триггер. Чтобы поддержать эту концепцию в Lua, я воспользовался механизмом корутин и Lua-тредов.

Корутины — это вообще моя давняя мечта в плане скриптов. Они позволяют прервать выполнение скрипта на любом месте, вернуть управление в вызывающий код, а потом, когда захочется, продолжить скрипт с того месте, где в прошлый раз закончили, с сохранением всего состояния. Нынче корутины можно писать даже и на C (хоть и непросто делать это кросс-платформенно), а в новом стандарте C++ вроде бы даже готовые языковые механизмы есть. Но в Lua всё уже готовое и удобное. Мешает только одно: при выходе из корутины, состояние виртуальной машины Lua сохраняется в стейт Lua, но следующий вызов (рассчёт AI для другого объекта) затрёт это состояние своим. Можно делать много стейтов ВМ, но это дорого. Тут-то на помощь и приходят Lua-треды. В платформенном смысле слова, они тредами не являются, то есть, не порождают платформенных потоков, и не выполняются одновременно. Зато они предоставляют возможность делать легковесные копии состояния ВМ Lua, как раз пригодные для корутин.

В результате, мой новый AI стал выглядеть как-то так:

function height_change_up( context )
    local dx = RandomInt( 100, 900 )
    local dy = 100
      
    context:Control( Context.IDLE,		Trigger_DX( dx )			)
    context:Control( Context.CTRL_VERT_UP,	Trigger_DY( dy )			)
    context:Control( Context.IDLE,		Trigger_Eternal()			)
end


Что, согласитесь, гораздо проще писать, и даже читать. Все функции context на самом деле объявлены в Luabind как yield, то есть, возвращают управление до следующего resume. Который будет сделан, как только в C++ коде выполнится условие указанного триггера.

Пару слов о Luabind: это тот ещё ад. Спорить о недостатках и достоинствах его синтаксиса и оверхеде я не буду, а вот тот факт, что его нынче довольно сложно собрать — приходится признать. Активная разработка исходным разработчиком давно заброшена, да и новая ветка отнюдь не процветает. Так что если будете интегрировать Lua и C++ — рассматривайте более современные альтернативы, которые, хотя бы, не требуют такого количества Boost'a…

19.04.12: Добавил подсчёт статистики для двух видов Достижений

Честно говоря, уже и не помню, почему решил добавить в эту игру Достижения. Кажется, для того, чтобы заставить игрока опять-таки отклоняться от оптимальной тактики для их получения. Например, достижение «High Flyer» требует, чтобы игрок провёл много времени в верхней половине экрана на одном из поздних уровней. А это — ОЧЕНЬ непростая задача! Обычно, опытный игрок будет крутиться в самом низу экрана, выныривая оттуда для атак на выбранные вражеские самолётики. А тут — вот хочешь не хочешь, а надо летать в опасной зоне, где плотность врагов максимальна.

Делать какую-либо интеграцию с социальными сетями, чтобы ачивками можно было делиться, я не планировал. Зато была идея сделать возможность выгружать «орденскую планку» в виде PNG файла, с которым игрок уже мог сделать что угодно — вставить на форум, залить на Фейсбук… Но и эта идея в результате не была реализована (точнее, была убрана из финальной версии). Думаю, никто по ней особенно скучать не будет…

Экран достижений в финальной версии



01.06.12: ВЫПУЩЕНА ВЕРСИЯ 0.9

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

Результаты самолечения

Для начала, я попробовал замоделить самолёт игрока в Wings3D (самый дружелюбный к программисту 3д редактор, как мне кажется). Результат получился не то чтобы плохой, но и ничего хорошего


Попытка осовременить корабль из первого уровня — опять же, нельзя сказать, чтобы совсем всё плохо было, но сразу видно — "озвучено нарисовано профессиональными программистами!"


27.07.12: Начал работу над обучающим режимом

Опыты на живых людях показали, что игроки не понимают цели игры. Многие думают, что корабль внизу экрана надо защищать, а не разрушать. Я и сам припомнил, что не сразу догадался, что в этой игре надо делать, когда играл в оригинал. Но потом разобрался, и это стало для меня очевидным. Для других — нет. А времена, нынче, не те, если игрок не поймёт, что в игре делать, он её закроет…

Поэтому пришлось добавлять в игру обучалку. Вообще говоря, обучающий режим в игре — это, обычно, одна из самых мерзких и костылявых частей, поскольку нарушает всю гейм-механику, лезет в код UI и делает прочие гадости, не предусмотренные архитектурой. В процессе добавления обучающего режима в игру я ранее участвовал уже трижды, и повторять этот опыт не хотел. Поэтому решил обойтись малой кровью: текстом и рамочками показать игроку, что здесь как. Да, «show, don't tell», но… Кто не любит читать тексты в играх — тот мне не друг!

В результате, обучающий режим был создан довольно быстро и без особых вторжений в механику. Потом, правда, уже в 2014-2015ом, его пришлось доделывать, чтобы показывать схему управления, так как не все сразу лезут в Options, а раскладка клавиш очевидной не выходила. Но это уже чуть-чуть другая история…

18.08.14: Начал работу над поддержкой геймпадов в игре
19.08.14: Геймпад заработал

Ха-ха-ха! Заработал он, ага… Нет, не поймите меня неправильно — добавить поддержку геймпада в игру действительно было очень просто, благодаря библиотеке Allegro. Но тут возник маленький конфуз: во-первых, в меню настроек управления это выглядело как-то так: «Fire: Button 11». А во-вторых, в Обучающем режиме надо было как-то эту самую Button 11 нарисовать, чтобы игроку было понятно, что нажимать, чтобы стрелять. Нет, некоторые игры так и оставляют «Press [Button 11] to fire». Но это уродство, потому что игрок ну никак не обязан знать, что за кнопка у него идёт на геймпаде в режиме XInput под индексом 11 (тем более, что под Linux, например, та же кнопка может иметь совершенно другой индекс!).

С другой стороны, механизма, который позволил бы легко и кросс-платформенно сказать, что «Стик 2 Ось 1» — это правый стик, вертикальная ось — нет. Проблема частично решена в SDL введением базы данных с описанием разных геймпадов, но она никак не совместима с Allegro, из-за некоторых расхождений в работе с джойстиками.

Кроме того, в каждой из десктопных ОС (Windows, Linux, MacOS X) есть по два API для работы с геймпадами, каждое со своими закидонами.

Виндовые DirectInput и XInput Allegro абстрагирует достаточно хорошо, но только вот XInput'овские константы типа «XINPUT_GAMEPAD_A» она переводит в индексы, и снова получаем «Button 11».

Под Linux, мне удалось заставить работать свой Logitech F300 только в режиме XInput, причём, внезапно, триггеры там ходят не от 0 до 1, как в Windows, а от -1 до 1, причём -1 — это нейтральной значение (триггер отпущен). Почему так — из кода драйвера я так и не понял. А документация утверждает, что значения у ABS триггеров таки должны быть положительные. Но приходят отрицательные…

Под MacOS X, новый модный API поддерживает геймпады стандарта XInput, правда, почему-то не поддерживает кнопки Start и Back (не говоря уже о Guide/X-Box). А старый способ работы с геймпадами (применённый в Allegro) — через HID Manager — та ещё чёрная магия. Кстати, если будете сами заниматься этой темой, то можете удивиться — почему это значения от правого стика приходят через GD_Z и GD_Rx? Вроде бы, как-то нелогично, почему не GD_Rx и GD_Ry? Ответ просто — потому что R — это отнюдь не «Right», как вы могли бы подумать, а вовсе даже «Rotational». Стандарт USB HID ничего знать не знает про геймпады с двумя стиками. Это диавольское изобретение на PC появилось слишком поздно. Зато он знает про контроллеры для самолётных симуляторов, у которых стик только один, зато могут быть дополнительные оси вращения, вот эти самые Rx, Ry и Rz. Хитрые авторы геймпадов просто используют первые попавшиеся четыре оси для передачи значений от левого и правого стиков, не заморачиваясь их исходным предназначением.

03.09.14: Перевёл сборку на CMake, реструктурировал репозиторий

Изначально, игра собиралась только под Windows. Когда мне захотелось собрать её под Linux (в районе версии 0.9, или раньше), я нашёл утилиту MakeItSo, которая преобразовала мне файл Visual Studio в Makefile. Правда, его потом пришлось допиливать ручками, и вообще…

Короче, когда зашёл вопрос о сборках на трёх платформах сейчас, и ещё мобильных — в будущем, я решил привести всё в порядок, и использовать CMake для генерации проектов под все платформы. В целом, опыт с CMake оказался очень положительным. Единственный минус — отсутствует поддержка установки многих параметров в проектах для Visual Studio под новые платформы (Android, через Tegra NSight или в VS2015, Emscripten). Проблему можно было бы решить добавлением ключевого слова для подключения к проекту props-файлов, но в mailing list CMake говорят, что это противоречить идеологии… Конечно, у CMake есть и другие минусы, но он лучше всех альтернатив, будь то написание файла сборки руками под каждую платформу, или использование gyp. Наибольшей проблемой стала сборка под MacOS X, поскольку идея app bundle CMake поддерживается несколько костыльно: нельзя просто так взять и указать, какую директорию куда надо положить внутри .app — приходится пробегаться по всем файлам, и выставлять им свойства.

27.10.14: Интегрировал в игру Google Breakpad

Про интеграцию Google Breakpad я в своём блоге написал целую статью. Вкратце, не ожидал я от Google такого неудобства, я бы даже сказал, непрофессионализма в плане организации кода. Сам код, я не знаю, может и хорош, может и плох — особо не смотрел. Но то, что ради подключения небольшой, по сути, библиотеки предлагается выкачивать к себе и таскать весь огромный репозиторий с тестами, сорцами под другие платформы и прочей фигнёй — это всё очень неудобно.

Но главное, что в конце концов интегрировать Breakpad и поднять у себя на хосте сервер мне удалось. Правда, ни одного крэш-репорта я так и не получил (кроме тестовых) — то ли так хорошо игра написана, что не падает, то ли всё-таки система отправки крэшей не работает!

26.02.15: Доделал эффект потери жизни

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

А эффект с трещинами на стекле пришёл мне в голову как воспоминание об ещё одной игре с ZX Spectrum — Night Gunner.

Game over



13.04.15: ЗАКОНЧИЛ ВЕРСИЮ 1.0

13-го апреля я собрал все билды под все платформы и залил их на сайт. С этого момента, Return of Dr. Destructo отправилась в свободное плавание. Но история игры пока не закончена — сейчас я работаю над портированием игры на мобильные платформы. Так что файл Progress.txt пока не закрыт окончательно!



Код проекта доступен на Github под лицензией MIT, а ресурсы — CC-BY-SA.
Если же вам захочется ознакомиться с игрой, не собирая её, то бинарные сборки лежат на сайте проекта.

Спасибо за внимание!
Теги:инди-игрыистория создания
Хабы: Разработка игр
+37
24,9k 91
Комментарии 28
Похожие публикации
Разработка приложений на Kotlin
2 декабря 202020 900 ₽Нетология
Vue.js Продвинутая веб-разработка
11 января 202127 000 ₽Loftschool
Product Owner: Разработка требований в Agile
12 декабря 20209 000 ₽Школа системного анализа
Системный анализ и Разработка требований в ИТ-проектах
4 января 202125 000 ₽Школа системного анализа
Лучшие публикации за сутки