ПрограммированиеСтатьиОбщее

Начала применения спецификаций для игровых объектов.

Автор:

В статье “Compile-time vs run-time: назад в будущее”, недавно опубликованной на GameDev.ru, обсуждалась возможность использования спецификации отображаемого объекта. Одним из результатов применения данного подхода является автоматически построенный интерфейс к отображаемому объекту для доступа к нему из игровых структур и объектов. Здесь игровой объект (ИО) рассматривается, как контроллер, управляющий параметрами отображаемого объекта (положением, анимацией, позициями подобъектов, видимостью и т.д.).

Применение спецификации для ИО является развитием общей идеи применения спецификаций в программировании игр. Спецификация, в рассматриваемом здесь виде, по сути своей, есть декларативный язык, т.е. она не представляет собой что-либо революционное в программировании, классические примеры: PROLOG для экспертных систем, языки реляционного исчисления для построения запросов к БД, Yacc и Lexx для парсеров.  Последние два еще и имеют реализацию, схожую с предлагаемой ниже: в результате их работы получается код на языке высокого уровня.

Для спецификации ИО был разработан декларативный язык, названный GOB. Описание ИО в GOB файле представлено следующим набором:
·  Интерфейсы
·  Состояние
·  Параметры инициализации
·  Ссылки на смежные ИО
·  Множества, в которые входит ИО
·  Маски на стандартные игровые сообщения, обрабатываемые ИО

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

При проектировании GOB-ов рассматривалось решение следующих задач:
значительное сокращение записи декларации ИО и записи поддержки стандартной функциональности ИО. Упрощение, стандартизация и формализация разработки ИО. А также автоматизация проверки целостности связей между ИО и целостности данных, инициализирующих ИО.

Под стандартной функциональностью ИО подразумевается:
·  Прием стандартных игровых сообщений
·  Управление ресурсами (загрузка/выгрузка данных и моделей)
·  Интеграция «скриптов» или возможность полностью реализовать ИО на «скриптах»
·  сериализация/десериализация состояния ИО

Пример GOB файла:

/* Комментарий как в С */
// Комментарий как в С++

game AQUARIUM // аналог namespace
{
  genotype sl; //genotype задает тип генерации ИО: для С++ (cpp) или для скриптов (sl)

  include "Priorities.hpp"; // include для С++
  using   HitPoints; //using для скриптов

  struct STOMACH // пример декларации структуры
  {
    FLOAT Weight(2);
    FLOAT Volume(0.3);
    INT Bowels[3~4](0);
  };

  struct CHILD //еще один пример декларации структуры
  {
    enum Size<Small,Medium,Big>(Big);
    COLOR Color(1,0,0,1);
    STOMACH Stomach; // просто пример того, что одна структура может быть членом другой
  };

  interface FISH // декларация интерфейса, аналог абстрактного класса в С++
  {
    void Hit( const HIT_POINTS& p ); //эти функции будут виртуальными
    bool IsDead() const;
  };

  role FISH { KIND_FISH, EVIL_FISH }; /* декларация наследников от FISH.
Если бы мы писали на С++, это бы выглядело так:
class KIND_FISH : public FISH {...};
class EVIL_FISH : public FISH {…}; 
*/

  /* Все объекты, поддерживающие интерфейс FISH, 
      могут быть проверены на столкновение как множество FISH, 
      с получением соответствующего интерфейса */
  intersecting_group<FISH>; 


  /* На все объекты, поддерживающие интерфейс FISH можно будет выйти 
      через статический  контейнер, содержащий ссылки на эти объекты */
  collectable<FISH>;  


  /* Объект EVIL_FISH должен быть в единственном 
      экземпляре со статическим доступом */
  singleton<EVIL_FISH>; 

  object KIND_FISH
  {
    /* интерфейс, полученный в результате преобразования файла 
        спецификации модели. Соотносим модель контроллеру */
    model KIND_FISH_MODEL;

    observer<FISH> TargetFish; // Умная ссылка с контролем уничтожения объекта

    // Декларации реакций на стандартные игровые события
    game_started<0>;  // в скобках стоит приоритет
    game_loop<GLPR_FIRST>;   // в скобках стоит приоритет 
    dialog_mode; // информационное событие о входе/выходе в диалог

    // параметры инициализации
    start
    {
       // int ModelInstance; // добавляется автоматически, если у контроллера есть модель
       // SCRIPT_NAME ScriptName; //добавляется автоматически, если genotype sl

       /* 0 – значение по умолчанию, <0..3> ограничение диапазоном значений */
       INT ControllerID<0~3>(0);
       FLOAT EvilCoef<0,1,2>(0); // ограничением является конечное множество значений
       STOMACH Stomach;// структура Stomach

       /*массив структур, 1-минимальное количество элементов массива, 8-макимальное*/
       CHILD Children[1~8];

       /* Строка возможной длиной от 4 до 16, ограничение аналогично принятому в xsd:
       [a-z][A-Z]* */
       STRING Characteristic<"([a-zA-Z ])*">[4~16]("Bad girl"); 
    };

    // состояние объекта
    state
    {
       VECTOR3 V(0,0,0);
       VECTOR3 A(0,0,0);
       FLOAT IdleStartTime(0);
    };

    /* ключевое слово temp применяется для декларации переменной, 
        неподлежащей сериализации. Например, для хранения hash значения */
    temp INT TempTest(0);

    /* для удобства написания ИО на С++ введены приватные методы */ 
    private void PrivateTest( const FLOAT f, const INT* const pi );
    private void PrivateTest2( const INT* const pi ) const;
  };

}

В описании ИО model указывает на контролируемый отображаемый объект. В нашем случае, моделью может быть 3d объект или GUI элемент, или ее вообще может не быть, если объект исполняет сервисные функции.

Observer указывает на связь между ИО. Связывание может быть на этапе построения данных или во время исполнения игры. В первом случае указывается маска имени ИО на который ссылается observer. Например, сервисный объект “эскадрилье” управляет стратегией поведения группы самолетов, для этого ему понадобятся ссылки на ИО самолетов, запись в GOB файле будет выглядеть так:

observer<PLANE> Planes(“RedSquadron??”);

Во втором варианте, связывание происходит за счет функционирования ИО, например получение самолетом новой цели, через механизмы итерирования по множеству целей.

Важным замечанием является то, что в отличие от С++, где интерфейс класса определяет проектировщик класса, в GOB введена возможность внешнего определения интерфейса. Т.е. в файле x.gob мы можем указать, что класс A из файла a.gob должен наследоваться от класса B, определенного в файле b.gob. Это достигается за счет введения синтаксической единицы “role” и декларации множеств объектов  в произвольных файлах. Таким образом, у проектировщиков ИО, помимо стандартного прямого проектирования классов, есть возможность обратного (от потребностей) проектирования. Такой вариант записи поддерживаемых интерфейсов позволяет получить легко читаемый код. Например, в GOB файле, где описан ИО “Пушка”, указывается интерфейс к “Цели”  и множество ИО, которые могут быть “Целями” данной “Пушки” (поддерживают соответствующий интерфейс). Эта запись показывает, что ждет “Пушка “от “Целей”. В классическом варианте записи мы могли бы указать домыслы “Целей” о том, что думает о них “Пушка”. Четкого мнения, какой из двух вариантов записи (весьма условное деление)  предпочтительней пока нет. Но дополнительная гибкость не вредит, и мы надеемся через какое-то время придти к более формальному определению “role”.

Результатами преобразования xxx.gob, в зависимости от genotype, являются:
xxx.hpp (описание классов соответствующих ИО),
xxx.cpp (файл с “to-do” методами, принадлежащими классам из xxx.hpp),
xxx.sl (декларация интерфейсов к ИО, для доступа из «скриптов»).
xxxFactory.cpp (фабрики объектов)
В том случае если genotype указывает на то, что вы планируете реализовывать ИО на «скриптах» результатом будет следующий набор файлов:
xxx.hpp (описание классов и вызов «скриптовых» функций),
xxx.sl (декларация «скриптовых» функций для вызовов из C++ и интерфейсов к ИО для скриптов),
xxxImp.sl (файл с “to-do” методами).
xxxFactory.cpp (фабрики объектов)
xxx.cpp и xxxImp.sl генерируются только в том случае, если нет раннее созданных соответствующих файлов.
При генерации рассматриваются сразу все файлы с расширением gob из каталога игровых «исходников» и его подкаталогов. Это позволяет реализовать описанную в предыдущем параграфе функциональность, избавиться от лишних включений файлов и генерировать код, проверяющий целостность данных.

В качестве формата хранения данных, инициализирующих ИО, выбран xml. Все внешние утилиты, сохраняющие ИО придерживаются этого соглашения. Соблюдения синтаксиса xml обязательно, в случае ошибки, при загрузке ИО выдается Fatal Error. Если тэг не удается распознать или значение в теле тэга неверно, сообщение об ошибки не выдается, но в log пишется соответствующее замечание. Если тэг поля не представлен, используется значение по умолчанию (из GOB файла).  Для контроля целостности данных используются xml схемы xsd. Один xsd файл генерируется по всем GOB файлам. Форматы xml и xsd выбраны не случайно, такой выбор освобождает от необходимости создания собственных утилит для редактирования данных и проверки ввода.

Пример хранения инициализирующих данных в формате xml:

<?xml version="1.0" encoding="UTF-8"?>
<!-- edited with XMLSPY v5 rel. 4 U (http://www.xmlspy.com) by Ivan (Jaleco) -->
<AQUARIUM xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
   xsi:noNamespaceSchemaLocation="Aquarium.xsd" Level="Test">
  <KIND_FISH Name="TestFish" LoadOnStart="true">
    <ModelInstance>1</ModelInstance>
    <ScriptName>KindFish.pkg</ScriptName>
    <ControllerID/>
    <EvilCoef/>
    <Stomach>
      <Weight/>
      <Volume/>
      <Bowels>2</Bowels>
      <Bowels/>
      <Bowels/>
    </Stomach>
    <Children>
      <Size>Medium</Size>
      <Color>1,0,2,1</Color>
      <Stomach>
        <Weight/>
        <Volume>0.9</Volume>
        <Bowels/>
        <Bowels/>
        <Bowels>4</Bowels>
      </Stomach>
    </Children>
    <Characteristic>Good girl</Characteristic>
  </KIND_FISH>
</AQUARIUM>

Для объекта в xml файле указываются атрибуты Name и LoadOnStart. Первый нужен для построения прямых связей между объектами, второй - вспомогательный механизм для выполнения целевых подгрузок во время игры.

Пример того, как выше представленный xml код может выглядеть в редакторе:

Изображение

Дизайнер игры получает удобный инструмент в виде редактора xml для изменения параметров ИО, с помощью этого инструмента и поддержки чтения параметров ИО прямо во время исполнения игры, серьезно сокращается время настройки свойств объекта. Например, при написании физики автомобиля все параметры, такие как: максимальное ускорение, коэффициент сопротивления воздуха, минимальный радиус поворота и т.д. можно вынести в секцию «start» ИО. Затем дизайнер игры настраивает эти параметры, играя в игру.

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

Несколько слов по реализации поддержки формата gob или аналогичного ему. Первое о чем все начинают говорить – это парсер. Мы применили Flex и Bison, бесплатно распространяемые генераторы парсеров. Полученный в результате их использования код легко дополняется синтаксическим анализом с внятными сообщениями об ошибках, а также семантическим анализом. Затем нами были написаны: генерация скриптовых файлов, cpp и hpp файлов, xsd файла. Наибольшие сложности, как ни странно, вызвала реализация фабрик, создающих ИО по файлу xml. В нашей сегодняшней реализации игра все-таки от части является «классически data driven»: у нас в явном виде присутствуют фабрики игровых объектов и файлы данных, инициализирующих ИО и определяющих множество ИО.  Содержание уровня игры полностью зависит от xml файла данных  и подключенных исходных файлов ИО и их фабрик.  Можно было пойти дальше в развитии управления игровыми объектами с помощью спецификаций и по множеству GOB файлов автоматически получать загрузчик уровня игры и редактор инициализирующих данных. Возможно когда-нибудь, мы так и сделаем, и  сгенерированный код загрузки игры будет выглядеть примерно так:

AQUARIUM::LoadLevel(2);// загрузка второго уровня игры 

Сейчас же решение об использовании xml было принято для упрощения реализации, не исключено, что оно является наиболее подходящим для загрузки ИО, но и альтернатива: полностью перейти на управление с помощью спецификаций, тоже имеет право на жизнь.
Тут надо сделать очередное замечание о недостатках «динамизирующих» техник. Именно такой недостаток проявился в реализации с использованием xml. Фабрики привели к тому, что прямое связывание между объектами (см. observer) требует построения связей после загрузки ИО. Чего можно было бы избежать, если бы мы генерировали код загрузки уровня игры, получив возможность проверять целостность прямых связей между объектами на этапе обработки данных после ввода, а не на этапе загрузки.

Пример заголовочного файла, полученного в результате преобразования GOB файла. Последующая модификация заголовочных файлов не предполагается, программистами меняются только cpp и скриптовые файлы, в которых, собственно, и “находится” поведение объектов.

#include "GameObject.hpp"
#include "Priorities.hpp"

namespace AQUARIUM
{
  typedef GFIXED_ARRAY<INT,4> INT_BOWELS_4;
  typedef enum { Small, Medium, Big } ENUM_SIZE;
  typedef GFIXED_ARRAY<CHILD,8> CHILD_CHILDREN_8;
  typedef FIXED_STRING<16> STRING_CHARACTERISTIC_16;
  typedef FIXED_STRING<15> STRING_SCRIPTNAME_15;

  struct STOMACH
  {
    FLOAT m_Weight;
    FLOAT m_Volume;
    INT_BOWELS_4 m_Bowels;

    STOMACH::STOMACH()
        : m_Weight(2)
        , m_Volume(0.3)
        , m_Bowels(0)
    {}
  };

  struct CHILD
  {
    ENUM_SIZE m_Size;
    COLOR m_Color;
    STOMACH m_Stomach;

    CHILD::CHILD()
        : m_Size(Big)
        , m_Color(1,0,0,1)
    {}
  };

  class FISH
  {
  public:
    virtual void Hit( const HIT_POINTS& p ) =0;
    virtual bool IsDead() const =0;
  };

  struct KIND_FISH_START
  {
    UINT m_ModelInstance;
    STRING_SCRIPTNAME_15 m_ScriptName;
    INT m_ControllerID;
    FLOAT m_EvilCoef;
    STOMACH m_Stomach;
    CHILD_CHILDREN_8 m_Children;
    STRING_CHARACTERISTIC_16 m_Characteristic;

    KIND_FISH_START::KIND_FISH_START()
        : m_ScriptName(Fish.pkg)
        , m_ControllerID(0)
        , m_EvilCoef(0)
        , m_Characteristic(Bad girl)
    {}
  };

  struct KIND_FISH_STATE
  {
    VECTOR3 m_V;
    VECTOR3 m_A;
    FLOAT m_IdleStartTime;

    KIND_FISH_STATE::KIND_FISH_STATE()
        : m_V(0,0,0)
        , m_A(0,0,0)
        , m_IdleStartTime(0)
    {}
  };

  class KIND_FISH
      : public GAME::GAME_OBJECT<KIND_FISH_START,KIND_FISH_STATE,KIND_FISH_MODEL, FISHIMP>
      , public FISH
      , public COLLECTABLE<KIND_FISH>
      , public GAME::STARTED_GO<0>
      , public GAME::GAME_LOOPED_GO<GLPR_FIRST>
      , public GAME::DIALOG_MODED_GO
  {
  public:
    //Events:
    void GameStarted();
    void GameLoop( const float dt );
    void DialogMode( const bool Active );

    //virtual functions from FISH
    void Hit( const HIT_POINTS& p );
    bool IsDead() const;
  };

Используя спецификации ИО, мы автоматически получаем сериализацию / десериализацию всей игры. Т.е. в любой момент времени игра может быть сохранена на диск и затем прочитана без потери информации. ИО будут иметь те же состояния, что и до сохранения. Это достигается за счет строгого выделения параметров инициализации и состояния ИО. Данная возможность применяется на этапе тестирования игры. Так уж получается, что часто “бета-тестер” и команда разработчик географически разнесены и на четкое объяснение, где именно произошла ошибка уходит много времени. Получив слепок игры в момент ошибки, разработчик может не только посмотреть место, где именно находится «баг», но и в случае, если ошибка программиста, сразу приступить к отладке.

Применение спецификаций ИО избавляет от необходимости дублировать описание данных в исходном коде ИО и файле данных. Сейчас многие разработчики описывают параметры объекта прямо в 3d редакторах (например, user defined properties  в 3d studio Max, мы их тоже когда-то использовали), а затем получают эти данные в игре, обращаясь к ним через некий ID. Очевидно, вероятность ошибки увеличивается вдвое по сравнению с изложенным здесь подходом, где верификация введенных данных получается автоматически за счет генерации проверяющего кода (здесь xsd, но возможны иные варианты реализации).

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

1.  Выделение изменяемых свойств модели в соответствии с дизайн документом, написание спецификации для модели, занесение спецификации в БД
2.  Проектирование интерфейсов и выделение множеств ИО
3.  Спецификация состояния и инициализирующих параметров ИО
4.  Алгоритмизация поведения
5.  Тестирование модели и ИО, настройка параметров. На этом же шаге производится коррекция  скриптов и модели.

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

#движок, #игровые объекты, #спецификации

29 августа 2003 (Обновление: 15 апр 2010)