Недавно я выпустил в свет свой первый законченный «домашний» проект — ремейк игры «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.
Если же вам захочется ознакомиться с игрой, не собирая её, то бинарные сборки лежат на сайте проекта.

Спасибо за внимание!

Комментарии (28)


  1. vedenin1980
    07.05.2015 17:08
    +5

    … до чего дошёл Прогресс...

    Блин, увидев заголовок со словом Прогресс — вздрогнул (особенно написанного с большой буквы). Сразу полезли мысли, что опять будут шутки на тему этого злосчастного корабля, которые наводнили интернет. К счастью, это оказалось лишь фигура речи… Уфффф…


  1. kibitzer
    07.05.2015 17:17
    +3

    На спектруме всегда удивляло, когда разрушался замок, из него начинали бить фонтаны воды и потом он тонул :) Т.е. поведение замка было точно таким же как и кораблей.


    1. MaxEdZX Автор
      07.05.2015 17:21

      Ага, это было забавно :) Впрочем, есть же ещё и последние уровни… У меня замки тоже тонут, правда, без фонтанов. Будем считать, что разваливаются.


  1. 3dm
    07.05.2015 21:05

    Вы очень качественно подошли к своему pet проекту. Очень круто!


    1. MaxEdZX Автор
      07.05.2015 22:21

      Спасибо. Это, во многом, заслуга художника Олега Павлова, который сделал мне такую хорошую графику. Я был приятно удивлён уже первым набросками кораблей.


  1. gigimon
    07.05.2015 22:56

    Отличная работа!

    Я у вас на сайте увидел второй проект, Open Horizon, но не нашел по нему никакой информации, ремейк какой делаете? И, как я понял из статьи, это его вы успешно похоронили?


    1. MaxEdZX Автор
      07.05.2015 23:03

      Open Horizon делает мой друг, с которым мы делим блог на двоих (ему не хотелось заводить свой). Проект жив и здоров, скоро, возможно, будет третья демо-версия.


      1. gigimon
        07.05.2015 23:04

        это opensource версия какой-то игры?


        1. MaxEdZX Автор
          07.05.2015 23:09

          Да, Ace Combat: Assault Horizon


  1. Cupper
    07.05.2015 23:20

    А чем вас Boost.Serialization не устроил? Я его юзал и в полне доволен.


    1. MaxEdZX Автор
      07.05.2015 23:24

      Я его юзал в районе 1.35-1.37, каждую версию интерфейс немного (порой, поначалу, незаметно) ломался, вся система в целом была подвержена внезапным access violation'ам при неправильно произнесённых заклинаниях… Может сейчас уже стало лучше — даже наверняка, потому что хотя бы интерфейс должен был устаканиться. Но тогда было что-то не очень весело дебажить падения в недрах буста. С другой стороны, в конце концов у меня тоже всё заработало, и довольно неплохо, несмотря на то, что вообще-то я эту сериализацию использовал не совсем для того, для чего она предназначалась (а именно для пересылки данных по сети).


      1. Cupper
        08.05.2015 21:09

        Я не помню какую именно версию я использовал, но это было 4 года назад. И я как раз использовал для пересылки данных по сети. (text or xml base form)


        1. MaxEdZX Автор
          08.05.2015 22:29

          Это уже сильно после моих попыток, я в районе 2008ого занимался этим. Пересылал бинарные данные через сеть на asio. Там забавно было то, что пришлось сделать вид, что у меня один непрерывный архив, в который я постоянно что-то дописываю — иначе Boost лепил бы каждый раз заголовок, а он очень большой (по сравнению с размером сериализованного класса).


          1. Cupper
            09.05.2015 06:57

            Я вам открою секрет, бинарные даные они платформозависимые, независимо от версии буста которую вы уипользовали. У них это было еще в доке сказано.


            1. MaxEdZX Автор
              09.05.2015 09:35

              Ну, меня тогда только WIndows волновал… Но вообще не знал, подлянка выходит, спасибо. Я вообще хотел свой тип архива сделать, но вот эту задача совсем не осилил тогда.


  1. encyclopedist
    08.05.2015 02:22
    +4

    Корабли в английском языке — женского рода. Так что не it's captain, а her. Для русскоязычного человека очень противоестественно, но вот так.


    1. MaxEdZX Автор
      08.05.2015 09:30
      +1

      Спасибо, упустил.


    1. vedenin1980
      08.05.2015 09:38
      +1

      Для русскоязычного человека очень противоестественно

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


  1. JeriX
    08.05.2015 07:55
    +1

    Поздравляю с релизом!
    Расскажите пожалуйста в двух словах как делали трейлер?


    1. MaxEdZX Автор
      08.05.2015 09:35

      Трейлер я делал не сам, мне помог человек с IndieDB, откликнувшийся на объявление, но пожелавший остаться анонимным. У меня уже была своя версия, которой я был недоволен, он взял её, и сильно допилил, в результате чего получилось то, что можно видеть сейчас.

      В плане редактирования видео, лично мне очень понравился редактор VSDC Free Video Editor. И бесплатный, и возможности достаточно широкие, и логичный и понятный для программиста подход — создаём объекты, указываем время жизни, меняем свойства, и т.п. До этого, трейлеры для предыдущих версия 0.x делал просто в VirtualDub, но это было такое мучение каждый раз…


  1. AlienZzzz
    08.05.2015 20:27

    сейчас прогресс дошел до того, что это можно сделать в Хроме — идея хороша, но бинарник имхо уже не айс


    1. MaxEdZX Автор
      08.05.2015 20:58

      Кому айс, кому нет… Я вот не могу играть в браузере — неприятно почему-то. Впрочем, веб-версию этой игры может быть когда-нибудь сделаю, благо опыт работы с Emscripten есть. Но не сейчас.


      1. AlienZzzz
        08.05.2015 21:01

        www.chromeexperiments.com — поражают возможности)


        1. MaxEdZX Автор
          08.05.2015 21:05

          Ага, ещё бы они работали в каждом браузере и на каждой платформе одинаково… А то там даже Input API чуть разный везде, не говоря уже про графику. То есть, браузер — это не +1 платформа, а +2, как минимум, если брать только Хром и ФФ.


          1. AlienZzzz
            08.05.2015 21:20

            если брать только Хром и ФФ. — так нужно это и брать)


            1. MaxEdZX Автор
              08.05.2015 22:31

              Я в прошлом году библиотеку Allegro портировал на Emscripten, и запускал в браузере демо-проект: http://zxstudio.org/projects/allegro/skater/skater_r.html

              Потом, правда, порт забросил — попытался прикрутить Asyncify к нему, а у меня компилятор начал падать…


              1. AlienZzzz
                09.05.2015 02:02

                =) у меня с со спектрумом ассоциации добрые с игрухой Soldier Of Fortune(http://www.worldofspectrum.org/infoseekid.cgi?id=0004631) =)


                1. MaxEdZX Автор
                  09.05.2015 09:38

                  Не играл. У меня, пожалуй, лучшие воспоминания, кроме Dr. Destructo, о Target Renegade 2, Way of the Exploding Fist и Dizzy 4. Хотя, конечно, ещё много чего интересного было! Вот ещё русская поделка на тему Silk Worm под названием Main Blow (Главный Удар) очень запомнилась.