Авторизация  

   

Подпишитесь на нас  

Читать @TheGamedev_ru

   

Поиск по сайту  

   

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

Цели и задачи

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

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

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

Инструменты и методология

Первое, что я сделал – это собрал воедино все то, что упоминалось в посте «Выбор инструментов разработки», за исключением Lua, поскольку скрипты мне понадобятся еще не скоро.

Язык (С++ 11), библиотека (Qt 5.2.1) и среда разработки (QtCreator 3.0.1) у меня уже были под рукой, Git в качестве системы контроля версий (ранее я писал, что отказался от Mercurial в его пользу) поставился и был подружен с IDE без проблем. С GoogleTest все оказалось несколько сложнее.

Скомпилировать его из исходников и написать простейший «Hello, GoogleTest!» было не трудно, однако для организации адекватного процесса тестирования пришлось повозиться. Общую структуру проекта я опишу ниже, а некоторые технические детали можно будет посмотреть в коде готовой Змейки, который я выложу сразу же по готовности.

Я хотел также работать через TDD (Test Driven Development), но идея себя не оправдала. Произошло это главным образом потому, что трудно использовать TDD, когда с самого начала пишешь относительно высокоуровневый код, не дающий на выходе, однако, интуитивно понятных данных какого-либо фундаментального типа. Это в полной мере относится к рассмотренной в прошлом посте фабрике, которая еще и является только адаптацией полностью готового и описанного компонента.

Обзор программного кода

Должен заметить, что в настоящий момент в проекте не так-то просто разглядеть тот факт, что создаваемое приложение – игровое. Только при более внимательном рассмотрении можно заметить, что часть сущностей (классы состояний, элементы enum-ов) имеют как-то относящиеся к играм имена. Это прямое следствие того, что я сейчас фокусируюсь на отдельных элементах архитектуры, а не на геймплее.

Структура проекта такова. В основе его лежит Qt-шный проект типа subdirs, в который входят подпроекты Build (типа app, в настоящий момент содержит только один исходный файл с пустой функцией main), Lib (типа lib, статичная библиотека; весь код, отвечающий за логику приложения находится здесь) и Test (типа app, содержит исходный файл с тестами). Сейчас почти вся работа ведется над кодом Lib, а Test просто подключает его как библиотеку. Точно так же будет поступать Build – грубо говоря, это будет одна функция main, содержащая строку World.Run();.

В проекте имеется абстрактный класс состояний GameState, имеющий чисто виртуальные функции onEnter, onExit, Run и функцию sendEvent, которая вызывает функцию onEvent менеджера состояний. Также есть несколько производных классов, но пока они представляют из себя заглушки для проверки работы менеджера.

Основная же работа ведется как раз над последним. В первую очередь я сделал его синглтоном (конкретно – синглтоном Мейерса), поскольку у игры, очевидно, может быть только один менеджер состояний; при этом синглтон позволяет объектам классов состояний не хранить у себя указатель на менеджер, а каждый раз при посылке события свободно получать его через соответствующий интерфейс.

Как работает менеджер состояний

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

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

Теперь, имея на руках этот тип нового состояния, менеджер создает новый объект этого типа, передавая ему заодно некоторую информацию, которая необходима для инициализации данного объекта состояния (эта информация содержится в посланном сообщении). Однако, в таблице переходов разумно хранить только идентификатор следующего состояния (принадлежащего перечислению StateType), поэтому для создания объекта необходимо использовать фабрику.

Из двух приведенных в прошлом посте вариантов я выбрал реализацию фабрики, описанную в книге Александреску «Современное проектирование на C++», поскольку при написании собственных фабричных методов мы можем извлекать из полученного сообщения информацию, требующуюся конкретному подклассу состояния.

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

В конкретных фабричных методах (типа createXState, создающего объект класса XState) мы получаем указатель на StateEvent. Поскольку здесь речь идет об известном типе, мы также знаем, какой подкласс сообщения содержит необходимую нам информацию. Мы приводим указатель к типу YEvent, извлекаем из него нужные сведения и передаем их конструктору XState, после чего возвращаем указатель на вновь созданный объект.

Итак, получив сообщение от текущего состояния, менеджер определяет тип следующего состояния, и по этому типу создает объект, которому и передаст управление. Но что вообще подразумевается под «активным состоянием»?

Как игровое состояние выполняет свою работу

Поскольку текущее состояние начинает выполнять свою работу по вызову функции Run, а менеджер должен иметь возможность в любой момент остановить этот процесс извне, то очевидно, что работа этой функции должна происходить в отдельном потоке. Следовательно, сделать состояние активным – значит запустить для него новыйпоток и подключить его к устройствам ввода-вывода (см. предыдущий пост); сделать неактивным – только отключить его от этих устройств; напрочь удалить – остановить поток и удалить объект состояния.

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

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

Осуществить этот подход нам поможет идиома pimpl (pointer to implementation). Суть ее заключается в том, что мы определяем в нашем классе вложенный класс impl, который и будет выполнять всю работу, а функции нашего класса только обращаются к нему. При этом объявление и определение impl находится в отдельном cpp-файле, который подключается к cpp-файлу внешнего класса.

Это приводит к тому, что пользователю виден исключительно внешний, платформенно-независимый интерфейс. В то же время код, зависящий от платформы, сосредоточен в одном файле. Если же мы вместо него подключим другой файл, обеспечивающий работу с другой платформой, но имеющий тот же самый интерфейс, то внешний класс сможет использовать его без изменения своего кода.

Таким образом, создавая разные реализации MyClass::impl, можно полностью сосредоточить в нем платформенно-зависимый код. Мультиплатформенность при этом будет обеспечиваться только написанием по мере надобности новых реализаций вложенного класса, в клиентском же коде ничего менять не придется.

В нашем случае мы имеем класс Thread, содержащийся в Thread.h и Thread.cpp, и класс Thread::impl, который целиком находится в файле ThreadImplQThread.cpp (последний подключается в Thread.cpp вместе с Thread.h). Функции-члены Threadделегируют все вызовы Thread::impl, не делая о нем никаких предположений, за исключением открытого интерфейса (или его части).

Это приводит к дублированию довольно большого объема кода, но зато позволяет подготовить приложение к портированию на различные платформы. Кроме того, идиома pimpl позволяет нам надежно разделить уровни абстракции – тот, что относится к предметной области (например, управлению потоками, необходимому в нашей конкретной задаче) и технические детали реализации.

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

Описание и реализация обобщенных функторов приводится все в той же книге Александреску, в главе 5. Если вкратце, то обобщенный функтор – это некий объект, который имеет полную семантику значений (а, значит, может вести себя как простая переменная и, в частности, передаваться в функцию в качестве аргумента) и позволяет простым способом вызывать все, что ведет себя как функции (а именно: просто функции, указатели и ссылки на них, объекты классов с перегруженным operator()функторы, функции-члены).

Понимание обобщенных функторов

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

Итак, класс Functor – это тот самый класс обобщенного функтора, который реализует семантику значений и по вызову operator() производит требуемые действия. При этом конкретная специализация Functor задает список аргументов и тип возвращаемого значения той вызываемой сущности, которая в нем скрыта (смотрите, однако, раздел 5.8). Кпримеру, пусть у нас есть функция filter, работающая с контейнером значений типа double:

typedefFunctorbool, TYPELIST_1(double)> FilterPred;
Container filter(Iter from, Iter to, FilterPred pred)
	{
	//...
	}

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

И пусть есть следующие объекты (назначение которых должно быть понятно из названий):

bool Positive(double);
class LessThan
	{
	public:
	LessThan(double val);
	bool operator()(double) const;
 
	private:
	double Val;
	};
class Vector
	{
	public:
	//...
	bool contain(double) const;
	//...
	};

Тогда мы можем передать в функцию filter третьим аргументом предикаты, хранящие в себе функцию (Positive), функтор (объект класса LessThan) и указатель на функцию-член (объект класса Vector и указатель на функцию-член Vector::contain). Это возможно, потому что все три предиката будут иметь один и тот же тип – Functor, параметризованный типом возвращаемого значения bool и списком аргументов TYPELIST_1(double).

Реализовать такую общность помогает делегирование вызова Functor::operator(/**/) члену spImpl_ – указателю на «интерфейс» FunctorImpl. Последний является чисто абстрактным классом, который наследуют FunctorHandler и MemHandler.

FunctorHandler – это шаблонный класс, который реализует работу с функциями, ссылками и указателями на них, а также с функторами. MemHandler – шаблонный класс, работающий с функциями-членами.

Универсальность работы обобщенного функтора (даже если мы рассматриваем только конкретную его специализацию – FilterPred с конкретными типом возвращаемого значения и списком аргументов) достигается двумя видами полиморфного поведения.

Во-первых, FunctorHandler и MemHandler приводят к единому виду два разных типа вызова – простой и через объект класса. А именно – к вызову operator() класса, определяющегося теми же шаблонными параметрами, что и Functor. Не будь функций-членов – можно было бы обойтись без наследования от FunctorImpl.

Во-вторых, наследники FunctorImpl являются шаблонными классами, специализированными также типами вызываемых сущностей. Так, два FunctorHandler, один из которых содержит функцию Positive, а второй – функтор LessThan, определены именно этими параметрами. Однако, они унаследованы от одного и того же FunctorImpl, поэтому указатель в FilterPred может указывать на любого из них.

Может показаться странным, что шаблонные классы – а вернее, разные специализации шаблонного класса – являются наследником одного и того же нешаблонного класса (Functor – это уже вполне конкретный класс), но на самом деле в этом нет ничего удивительного. FunctorHandler и FunctorHandler – два обычных нешаблонных класса, пусть и разные. Но разве нас смущает, что разные классы Circle и Rect наследуются от одного и того же класса Figure?

Итак, когда мы определяем обобщенный функтор, мы первым делом задаем ему тип возвращаемого значения и список аргументов – и вот у нас есть объект конкретного класса Functor. Затем мы передаем ему указатель на объект класса, являющегося наследником FunctorImpl (этот указатель также имеет вполне конкретный тип).

При этом этот объект либо работает с функторо-подобными объектами (FunctorHandler), либо с функциями-членами (MemHandler). Чтобы получить уже полностью работоспособный объект, мы должны специализировать эти шаблонные классы либо типом а-ля функтор, либо парой «класс и указатель на функцию-член» соответственно. После этого можно вызывать Functor::operator().

На этом с идеологией обобщенных функторов все. Надеюсь, вышеприведенный текст поможет вам легче понять пятую главу «Современного проектирования на C++» и сэкономит от получаса до нескольких дней вашего времени. И, возможно, какое-то количество нервных клеток.

Возвращаясь к работе игровых состояний

Хорошо, теперь мы в достаточной степени понимаем принципы, лежащие в основе обобщенных функторов и сможем их применить для работы с потоками. Теперь класс Thread может принимать указатель на функцию-член и создавать отдельный поток, в котором будет крутиться функция Run текущего состояния.

Следующий шаг работы менеджера состояний выглядит следующим образом: создав новый объект класса, производного от GameState, он обращается к потоку, в котором выполняется предыдущее состояние, закрывает его, а сам объект состояния уничтожает. Вновь созданный же становится текущим, создается новый поток, которому в функцию RunUntilCancelled передается функтор с указателем на функцию-член Run.

В принципе на этом реализацию менеджера, работающего как детерминированный конечный автомат (ДКА), можно завершить (вопрос о взаимодействии с устройствами ввода-вывода рассмотрим позже).

Однако, как мы помним, у нас есть еще стек состояний. Работа с ним реализуется довольно просто – в классе StateEvent есть член StackType, который определяет, что делать с текущим состоянием менеджеру, получившему это сообщение. В зависимости от его значения мы либо просто удаляем старое состояние (предварительно закрыв его поток), либо помещаем его в стек (отлучив предварительно от ввода-вывода, но поток не трогая), либо удаляем, но текущим состоянием станет то, что было на верхушке стека.

Расписывать тут было бы особенно нечего, если бы не одно «но». Игровое состояние оказывается тесно связано с потоком, в котором оно выполняется. Это значит, что в стек нужно помещать не только GameState, но и Thread – иначе как мы потом будем останавливать поток, когда нужно будет это состояние удалить?

Здесь могут быть различные варианты решения проблемы. Можно помещать состояние и поток в разные стеки – но тогда теряется связь между ними и возникает опасность возможной «рассинхронизации» двух стеков. Можно наследовать класс состояния от класса потока – но это выглядит слишком противоестественно и алогично, чтобы всерьез рассматривать такую возможность.

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

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

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

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

Twitter
Нравится
SocButtons v1.5
   
© Создание игры - взгляд изнутри. The Gamedev. При использовании материалов сайта ссылка на источник обязательна.