Игровые агенты, управляемые состояниями

06.08.2012

[Данный туториал является переводом главы из книги Мэта Баклэнда - "Программирование ИИ на примерах". Перевод на русский язык осуществил Romanosov.]

Обзор

Конечные автоматы (англ. finite state machines, FSMs) являются для программистов ИИ инструментом внедрения в игрового агента иллюзии интеллекта. КА существуют в той или иной форме практически в каждой игре на полках магазинов с самого раннего периода компьютерных игр, и, несмотря на рост популярности более эзотерических разновидностей агента, они будут использоваться и там и тут в течение ещё долгого времени. Вот несколько причин этому:

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

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

Малая вероятность вычислительных издержек. Конечные автоматы вряд ли отнимут драгоценное время процессора, так как по существу они работают по правилам жёсткого программирования. Они не имеют никакого «мышления» в районе if-this-then-that (если произошло то, тогда выполнить это).

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

Гибкость. КА игрового агента легко регулируются и оптимизируются, обеспечивая поведение, требуемое игровому дизайнеру. Расширение границ поведения путём добавления новых правил и состояний также является простым делом. Более того, с развитием навыков программирования искусственного интеллекта вы обнаружите, что конечные автоматы составляют прочную основу, которую вы сможете комбинировать с другими методами, такими как нечёткая логика и нейронные сети.

Что же представляет из себя конечный автомат?

История гласит, что конечным автоматом называют техническое устройство, используемое математиками для решения различных задач. Самым известным конечным автоматом предположительно считается сконструированная Аланом Тьюрингом абстрактная вычислительная «машина Тьюринга», о которой он написал в своей работе «О вычислимых числах» 1936-го года. Эта машина, ставшая началом современных программируемых компьютеров, могла считывать, записывать или стирать символы на бесконечной ленте. К счастью, мы, будучи программистами ИИ, можем пройти мимо формального математического определения КА. Более наглядное определение следующее:

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

Следовательно, идея, лежащая в основе конечного автомата — расщепление поведения объекта на легко управляемые «куски» или состояния. К примеру, выключатель света на стене является простейшим конечным автоматом. Он имеет два состояния: включение и выключение. Переход между состояниями осуществляется своеобразным вводом данных — нажатием вашего пальца по выключателю. Щелчок выключателя вверх заставляет его перейти из состояния «выкл.» во «вкл.», щелчок выключателя вниз — из «вкл.» в «выкл.». Такой конечный автомат не выводит никаких выходных данных и не имеет никаких действий, связанных с состоянием «выкл.» (если не считать, что не горящая лампочка является действием), но когда он находится в состоянии «вкл.», ток способен проникнуть через выключатель и осветить вашу комнату вольфрамовой нитью в лампочке. См. Рисунок 2.1.

Рисунок 2.1: Выключатель света как конечный автомат.
Рисунок 2.1: Выключатель света как конечный автомат. Обратите внимание, что выключатели в Европе и многих других частях мира перевёрнуты.

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

  • В игре Pac-Man тактики приведений внедрены в качестве конечного автомата. Приведения имеют одинаковое состояние Evade (избегание), в то время как состояние Chase (преследование) у каждого из них различно, действия заложены индивидуально для каждого приведения. Съедение игроком большой белой точки — условие, по которому происходит переход состояния из Chase в Evade. Затем истекание некоторого периода времени — условие, по которому происходит обратное: из Evade в Chase.
  • Боты в играх подобных Quake являются конечными автоматами. Они имеют такие состояния, как FindArmor (найти броню), FindHealth (найти здоровье), SeekCover (искать укрытие) и Run-away (бежать прочь). Даже оружия в Quake реализованы как простейшие конечные автоматы. Например ракета может содержать такие состояния как Move (движение), TouchObject (соприкосновение с объектом) и Die (разрушение).
  • Игроки в спортивных симуляторах, таких как FIFA2002 являются конечными автоматами. Они имеют такие состояния, как Strike (удар), Dribble (ведение мяча), ChaseBall (погоня за мячом) и MarkPlayer (пометка игрока). Помимо игроков, сами команды часто являются КА и могут содержать состояния KickOff (мяч в центр поля), Defend (защита) и WalkOutOnField (выход на поле).
  • Неигровые персонажи в таких RTS (стратегии в реальном времени), как Warcraft, задействованы конечными автоматами. Они имеют такие состояния, как MoveToPosition (перейти к позиции), Patrol (патрулирование) и FollowPath (следовать пути).

Внедрение конечных автоматов

Существуют различные способы реализации конечных автоматов. Наивный подход заключается в использовании рядов формулировок if-then или более опрятной схемы — с оператором выбора. Если выразить состояния, использование оператора выбора с типом перечисления будет выглядеть примерно так:

enum StateType{RunAway, Patrol, Attack};
void Agent::UpdateState(StateType CurrentState)
{
  switch(CurrentState)
  {
  case state_RunAway:
    EvadeEnemy();
    if (Safe())
    {
      ChangeState(state_Patrol);
    }
    break;
  case state_Patrol:
    FollowPatrolPath();
    if (Threatened())
    {
      if (StrongerThanEnemy())
    {
        ChangeState(state_Attack);
      }
      else
      {
        ChangeState(state_RunAway);
      }
    }
    break;
  case state_Attack:
    if (WeakerThanEnemy())
    {
      ChangeState(state_RunAway);
    }
    else
    {
      BashEnemyOverHead();
    }
    break;
  }//конечная
}

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

Более того, вам, как программисту ИИ, часто будет необходимо, чтобы состояние выполнило конкретное действие, когда объект только перешёл в него или только вышел из него. Например, когда агент переходит в состояние RunAway (бежать прочь), вы возможно возжелаете заставить его замахать руками и закричать «А-а-а-а-а!» Когда же он наконец убежит и, обретя спасение, сменит состояние на Patrol (патруль), вы возможно захотите, чтобы он отдышался, протёр лоб и сказал: «Фух, еле ноги унёс!» Подобные действия происходят при входе и выходе из состояния RunAway, то есть не при каких-то своих состояниях. Поэтому, такая дополнительная полезность должна идеально пристроиться в структуре ваших конечных автоматов. Реализация всего этого в пределах оператора выбора либо структуры if-then будет сопровождаться скрежетом зубов и тошнотой, к тому же сам код будет выглядеть ужасно.

Таблицы перехода состояний

Хорошим способом продумать состояния и влияний на их смену является составление таблицы перехода состояний. Название говорит само за себя: таблица с условиями, благодаря которым осуществляется смена состояний, и самими состояниями. Таблица 2.1 показывает на примере распределение условий и состояний, расписанных в коде выше.

Таблица 2.1: Простой пример таблицы перехода состояний

Текущее состояние Условие Следующее состояние
RunAway
(бежать прочь)
Safe
(безопасно)
Patrol
(патруль)
Attack
(атаковать)
WeakerThanEnemy
(слабее врага)
RunAway
(бежать прочь)
Patrol
(патруль)
Threatened AND StrongerThanEnemy
(встревожен И сильнее врага)
Attack
(атаковать)
Patrol
(патруль)
Threatened AND WeakerThanEnemy
(встревожен И слабее врага)
RunAway
(бежать прочь)

Такая таблица может быть запрошена агентом регулярно, что позволяет переходить в необходимые состояния под влиянием внешнего игрового пространства. Каждое состояние можно реализовать отдельным объектом либо каким-то другим действием за пределами агента, чтобы обеспечить оптимизированную и гибкую структуру. Такой подход гораздо менее склонен к «принципу спагетти», чем if-then/switch, что мы поняли из предыдущей темы.

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

Представим робота-котёнка. Красивого и милого: усы-проволоки, живот со слотами для картриджей, которые вставляются туда, подобно тем же состояниям. В каждом из картриджей вшита своя программа, по которой наш робот выполняет определённую последовательность действий. Последовательности подразумевают определённое поведение; например: «играть с ниткой», «есть рыбу» или «мочиться на ковре». Без картриджа в брюхе котёнок будет неподвижной скульптурой из металла, которая может только стоять в комнате и украшать интерьер.

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

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

IF Kitty_Hungry AND NOT Kitty_Playful
SWITCH_CARTRIDGE eat_fish

Все правила проверяются каждое мгновение и указания на замену картриджа передаются роботу.

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

Заложенные правила

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

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

Давайте рассмотрим такой подход заложенным в контексте видеоигры. Как картриджи котёнка: состояния в качестве объектов, содержащие алгоритм, способствующий переходу состояний. Кроме того, все объекты состояний будут пользоваться одним общим интерфейсом — чисто виртуальным классом, названным State. Примерно так можно интегрировать простой интерфейс:

class State
{
public:
  virtual void Execute (Troll* troll) = 0;
};

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

class Troll
{
  /* АТРИБУТЫ ОПУЩЕНЫ */
  State* m_pCurrentState;
public:
  /* ИНТЕРФЕЙС К АТРИБУТАМ ОПУЩЕН */
  void Update()
  {
    m_pCurrentState->Execute(this);
  }
  void ChangeState(const State* pNewState)
  {
    delete m_pCurrentState;
    m_pCurrentState = pNewState;
  }
};

Когда для Troll вызывается приём Update, он, в свою очередь, вызывает приём Execute текущего типа состояния указателем this. Текущее состояние может использовать интерфейс Troll, чтобы запросить своего обладателя о регулировке его атрибутов или о осуществления перехода состояния. Другими словами, поведение класса Troll может полностью зависеть от логики текущего состояния. Лучше всего показать это на примере, так что давайте создадим пару состояний, и пусть наш тролль убегает от врагов, когда встревожен, и засыпает, когда чувствует безопасность.

//----------------------------------State_RunAway
class State_RunAway : public State
{
public:
  void Execute(Troll* troll)
  {
    if (troll->isSafe())
    {
      troll->ChangeState(new State_Sleep());
    }
    else
    {
      troll->MoveAwayFromEnemy();
    }
  }
};
//----------------------------------State_Sleep
class State_Sleep : public State
{
public:
  void Execute(Troll* troll)
  {
    if (troll->isThreatened())
    {
      troll->ChangeState(new State_RunAway())
    }
    else
    {
      troll->Snore();
    }
  }
};

Как вы можете видеть, при обновлении, тролль будет вести себя по-разному в зависимости от состояний, на которые указывает m_pCurrentState. Оба состояния интегрированы как объекты и оба обеспечивают правила осуществления перехода состояний. Всё опрятно и чётко.

Такая структура известна как шаблон проектирования состояний и способствует прекрасному методу внедрения поведения, основанного на состояниях. Несмотря на то, что это отсылка к математическому определению КА, она интуитивна, проста для программирования и легко расширяема. Также запредельно просто добавить начальные и конечные действия к каждому из состояний. Всё, что нужно сделать — ввести приёмы Enter и Exit и отредактировать ChangeState агента соответственно. Очень скоро вы увидите примеры кода, делающего такие действия.

Проект «West World»

В качестве практического примера создания агентов, использующих конечные автоматы, мы рассмотрим игровой мир с агентами, обитающими в старом золотодобывающем городе в стиле Вестерн, названном West World. Изначально здесь будет только один житель — золотоискатель Боб, он же Miner Bob – но позже также появится и его жена. Перекати-поля, звенящие инструменты шахтёра и пыльные бури мы будем только представлять себе, так как West World будет всего лишь текстовым консольным приложением. Смены состояний и выходные данные их действий будут выводиться в консольном окне. Я использую чисто текстовый подход, потому что его достаточно, чтобы ясно продемонстрировать механизм конечных автоматов, и не потребуется никакой лишней программной суматохи и сложного игрового пространства.

В West World есть четыре локации: gold mine — золотое дно; bank — банк, куда Боб будет вкладывать найденные самородки; saloon — салун, где он будет утолять жажду и home-sweet-home — его дом, где он будет отдыхать от тяжёлого рабочего дня. Именно то, куда Боб идёт и что делает, дойдя до туда, определяется его текущим состоянием. Он будет менять состояния в зависимости от переменных, таких как жажда, утомление, количество золота, найденное им в процессе рубки золотого дна.

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

Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Полные карманы золота, покидаю шахту
Шахтёр Боб: Направляюсь в банк. Да, детка
Шахтёр Боб: Сдаю золото на хранение. Общие сбережения теперь: 3
Шахтёр Боб: Покидаю банк
Шахтёр Боб: Топаю в шахту
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Полные карманы золота, покидаю шахту
Шахтёр Боб: Вах, пить захотелось. Топаю в салун
Шахтёр Боб: Попиваю крепкий хороший ликёр
Шахтёр Боб: Выхожу из салуна, дела ок, настроение тоже
Шахтёр Боб: Топаю в шахту
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Полные карманы золота, покидаю шахту
Шахтёр Боб: Направляюсь в банк. Да, детка
Шахтёр Боб: Сдаю золото на хранение. Общие сбережения теперь: 4
Шахтёр Боб: Покидаю банк
Шахтёр Боб: Топаю в шахту
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Полные карманы золота, покидаю шахту
Шахтёр Боб: Вах, пить захотелось. Топаю в салун
Шахтёр Боб: Попиваю крепкий хороший ликёр
Шахтёр Боб: Выхожу из салуна, дела ок, настроение тоже
Шахтёр Боб: Топаю в шахту
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Полные карманы золота, покидаю шахту
Шахтёр Боб: Направляюсь в банк. Да, детка
Шахтёр Боб: Сдаю золото на хранение. Общие сбережения теперь: 5
Шахтёр Боб: Уху! Более чем достаточно на сегодня. Пора домой к моей любимой леди
Шахтёр Боб: Покидаю банк
Шахтёр Боб: Иду домой, весело размахивая киркой
Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...
Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...
Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...
Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...
Шахтёр Боб: Вот это сон, чёрт возьми! Время снова добывать золото

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

Класс BaseGameEntity

Все обитатели городка West World происходят из базового класса BaseGameEntity. Это простой класс, содержащий закрытый член для хранения идентификатора. Также он устанавливает чисто виртуальную функцию-член — Update — которая должна быть заложена во всех подклассах. Update является функцией, которая вызывается при шаге обновления и используется подклассами для обновления их конечных автоматов вместе с другими данными, которые также нужно постоянно обновлять.

Объявление класса BaseGameEntity выглядит следующим образом:

class BaseGameEntity
{
private:
  //у каждого компонента есть свой идентификационный номер
  int          m_ID;
  //следующий действующий идентификатор. Каждый раз,
  //когда обрабатывается BaseGameEntity,
  //его значение обновляется
  static int  m_iNextValidID;
  //вызывается внутри конструктора для проверки правильности идентификатора.
  //Обработанное значение должно быть больше или равно
  //следующему действительному значению, прежде чем ему будет присвоен
  //идентификатор и увеличится следующее действительное значение.
  void SetID(int val);
public:
  BaseGameEntity(int id)
  {
    SetID(id);
  }
  virtual ~BaseGameEntity(){}
  //все компоненты должны выполнять функцию обновления
  virtual void Update()=0;
  int          ID()const{return m_ID;}
};

По причинам, что станут ясными в этой главе позже, очень важно каждому компоненту в игре присвоить свой идентификатор. На примере выше, идентификатор, переданный конструктору, тестируется приёмом SetID, чтобы удостовериться, уникален ли он. Если это не так, произойдёт сбой программы, и она закроется. В примере, данном в этой главе, компоненты будут использовать числовое значение в качестве уникального идентификатора. Они могут быть найдены в файле EntityNames.h, ent_Miner_Bob и ent_Elsa.

Класс Miner

Класс Miner происходит из класса BaseGameEntity и содержит члены данных, представляющие различные атрибуты, которыми обладает Miner: здоровье, уровень усталости, местоположение и т. п. Как в примере с троллем, рассмотренным ранее, Miner обладает указателем к инстанции класса State вдобавок к методу переключения на тот State, на который ведёт указатель.

class Miner : public BaseGameEntity
{
private:
  //указатель к инстанции State
  State*               m_pCurrentState;
  // место, где в настоящий момент находится шахтёр
  location_type         m_Location;
  //количество золота в карманах шахтёра
  int                   m_iGoldCarried;
  //количество сбережений, вложенных в банк
  int                   m_iMoneyInBank;
  //чем выше значение, тем больше шахтёр испытывает жажду
  int                   m_iThirst;
  //чем больше значение, тем больше шахтёр чувствует утомление
  int                   m_iFatigue;
public:
  Miner(int ID);
  //это должно осуществляться
  void Update();
  //этот метод сменяет текущее состояние на новое
  void ChangeState(State* pNewState);
  /* большая часть интерфейса опущена */
};

Приём Miner::Update прост: он просто увеличивает значение m_iThirst перед вызовом приёма Execute текущего состояния. Это выглядит следующим образом:
void Miner::Update()

{
  m_iThirst += 1;
  if (m_pCurrentState)
  {
    m_pCurrentState->Execute(this);
  }
}

Теперь вы видите, как работает класс Miner, теперь рассмотрим каждое из возможных состояний шахтёра.

Состояния класса Miner

Золотоискатель может перейти в одно из четырёх состояний. Ниже перечислены имена этих состояний с последующим описанием действий и переходов, происходящих внутри состояний:

  • EnterMineAndDigForNugget. Если шахтёр не у источника золота, он направляется к нему. Если он уже там, он добывает золотые самородки. Когда его карманы наполнены, Боб сменяет состояние на VisitBankAndDepositGold, а когда в процессе добывания он начинает испытывать жажду, он сменяет состояние на QuenchThirst.
  • VisitBankAndDepositGold. Будучи в этом состоянии, шахтёр идет в банк и вкладывает туда всё золото, что имеет в карманах. Как только он решит, что на сегодня хватит, он сменяет состояние на GoHomeAndSleepTilRested. Если он ещё не принял такого решения, состояние сменяется на EnterMineAndDigForNugget.
  • GoHomeAndSleepTilRested. Будучи в этом состоянии, шахтёр возвращается в его хижину и спит там до тех пор, пока его уровень утомления не спадёт до нормы. Затем он сменяет состояние на EnterMineAndDigForNugget.
  • QuenchThirst. Когда шахтёр хочет пить (горное дело — тяжёлое дело, как бы), он сменяет состояние на это и отправляется в салун отведать виски. Когда жажда утолена, он сменяет состояние на EnterMineAndDigForNugget.

Иногда бывает сложно понимать ход логики состояний из простого описания, поэтому иногда не помешает взять бумагу и ручку и нарисовать схему перехода состояний для игровых агентов. На Рисунке 2.2 изображена схема перехода состояний для золотоискателя. Каждое из самих состояний заключено в скруглённые прямоугольники, стрелки между ними означают доступные переходы.

Рисунок 2.2: Схема перехода состояний шахтёра Боба
Рисунок 2.2: Схема перехода состояний шахтёра Боба

Такая диаграмма более наглядна и найти ошибки в ходе выполнения становится легче.

Снова к шаблонам перехода состояний

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

Ранее в главе упоминалось, что хорошо будет привязать к состояниям начальные и конечные действия. Это позволяет программировать логику, которая выполняется один раз при входе в состояние и выходе из него и значительно увеличивает гибкость конечного автомата. Усвоив эти детали, рассмотрим на расширенный базовый класс State.

class State
{
public:
  virtual ~State(){}
  //это будет выполняться при входе в состояние
  virtual void Enter(Miner*)=0;
  //это будет вызываться функцией обновления шахтёра с каждым разом
  virtual void Execute(Miner*)=0;
  //это будет выполняться при выходе из состояния
  virtual void Exit(Miner*)=0;
}

Эти дополнительные приёмы вызываются только тогда, когда Miner сменяет состояние. Когда происходит переход состояний, метод Miner::ChangeState сначала вызывает метод Exit текущего состояния, затем назначает новое состояние взамен текущему и в конце вызывает приём Enter для нового состояние (которое теперь является текущим). Думаю, увидеть это в коде будет лучшим вариантом, нежели просто в описании, поэтому рассмотрим пример:

void Miner::ChangeState(State* pNewState)
{
  //убеждаемся, что оба состояния действительны перед тем, как
  //вызывать их приёмы
  assert (m_pCurrentState && pNewState);
  //вызываем приём exit текущего состояния
  m_pCurrentState->Exit(this);
  //сменяем состояние на новое
  m_pCurrentState = pNewState;
  //вызываем приём Enter нового состояния
  m_pCurrentState->Enter(this);
}

Обратите внимание, как Miner передаёт указатель this каждому состоянию, позволяя им использовать интерфейс класса для получения соответствующих данных.

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

Каждое из четырёх состояний, в которые может перейти Miner, образуясь из класса State, дают нам четыре конкретных класса: EnterMineAndDigForNugget, VisitBankAndDepositGold, GoHomeAndSleepTilRested, и QuenchThirst. Указатель Miner::m_pCurrentState может вести к одному из этих четырёх состояний. Когда вызывается приём Update класса Miner, он в свою очередь вызывает Execute текущего активного состояния с указателем this в качестве параметра. Эти классовые отношения будет легче понять, изучив упрощенную UML-схему классов на Рисунке 2.3.

Рисунок 2.3: UML-схема классов реализации конечного автомата Miner Bob.
Рисунок 2.3: UML-схема классов реализации конечного автомата Miner Bob.

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

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

Часто бывает полезным перестраховаться насчёт того, что объект создан только один раз и он доступен глобально. Например, если игровой проект содержит мир со многими видами компонентов — игроки, монстры, снаряды, цветочные горшки и т. д. — обычно существует «объект-менеджер», который берёт на себя создание, удаление объектов и управления ими. Нужно только иметь одну инстанцию объекта — синглтон — и станет удобным сделать его доступным глобально, так как много других объектов будут запрашивать доступ к нему.

Шаблон синглтонов обеспечивает оба эти качества. Есть много способов реализации синглтонов. Я предпочитаю статический метод, Instance, который возвращает указатель к статической инстанции класса. Ниже приведён пример:

/* ------------------ MyClass.h -------------------- */
#ifndef MY_SINGLETON
#define MY_SINGLETON
class MyClass
{
private:
  // член данных
  int m_iNum;
  //конструктор является приватным
  MyClass(){}
  //конструктор копирования и назначение должны быть приватными
  MyClass(const MyClass &);
  MyClass& operator=(const MyClass &);
public:
  //строго говоря, деструкторы синглтона должен быть приватными, но некоторые
  //компиляторы конфликтуют с этим, поэтому я оставил их общедоступными во всех
  //остальных примерах книги
  ~MyClass();
  //методы
  int GetVal()const{return m_iNum;}
  static MyClass* Instance();
};
#endif
/* -------------------- MyClass.cpp ------------------- */
//это должно находиться в файле .cpp; иначе, инстанция будет создана
//для каждого файла, которые включает в себя header
MyClass* MyClass::Instance()
{
  static MyClass instance;
  return &instance;
}

Переменные-члены и приёмы теперь могут быть доступны с помощью приёма Instance примерно так:

int num = MyClass::Instance()->GetVal();

Так как я ленив и не хочу расписывать весь синтаксис каждый раз, когда мне нужен доступ к синглтону, я использую #define:

#define MyCls MyClass::Instance()

Используя этот новый синтаксис я могу просто написать следующее:

int num = MyCls->GetVal();

Намного проще, не правда ли?

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

В этом состоянии шахтёр должен сменять локацию и оказываться у золотого дна. Он должен добывать золотые самородки до тех пор, пока его карманы не наполнятся ими, затем сменять состояние на VisitBankAndDepositNugget. Когда шахтёр начинает испытывать жажду в процессе добывания, он должен сменить состояние на QuenchThirst.

Так как конкретные состояния просто реализуют интерфейс, определённый в виртуальном базовом классе State, их объявления являются очень простыми:

class EnterMineAndDigForNugget : public State
{
private:
  EnterMineAndDigForNugget(){}
  /* конструктор копирования и операция назначения опущены */
public:
  //это синглтон
  static EnterMineAndDigForNugget* Instance();
  virtual void Enter(Miner* pMiner);
  virtual void Execute(Miner* pMiner);
  virtual void Exit(Miner* pMiner);
};

Очевидно, что это только формальность. Давайте рассмотрим каждый из методов по очереди.

EnterMineAndDigForNugget::Enter

Код для приёма Enter состояния EnterMineAndDigForNugget выглядит следующим образом:

void EnterMineAndDigForNugget::Enter(Miner* pMiner)
{
  //если шахтёр ещё не у золотого дна, ему будет необходимо
  //сменить локацию, и оказаться там
  if (pMiner->Location() != goldmine)
  {
    cout << "\n" << GetNameOfEntity(pMiner->ID()) << ": "
         << "Топаю в шахту";
 
    pMiner->ChangeLocation(goldmine);
  }
}

Этот метод вызывается, когда шахтёр впервые входит в состояние EnterMineAndDigForNugget. Это обеспечивает нахождение шахтёра у золотого дна. Агент запоминает эту локацию в качестве перечисляемого типа, метод ChangeLocation изменяет его значение, способствуя переключению локации.

EnterMineAndDigForNugget::Execute

Приём Execute несколько сложнее и содержит логику, которая может менять состояние шахтёра (не забывайте, что Execute — это метод, вызываемый с каждым обновлением от Miner::Update).

void EnterMineAndDigForNugget::Execute(Miner* pMiner)
{
  //шахтёр добывает золото, пока не наберёт больше, чем значние MaxNuggets.
  //Если ему захотелось пить во время его работы, он прекращает её и
  //сменят состояние на то, что поведёт его в салун за виски.
  pMiner->AddToGoldCarried(1);
  //горное дело — тяжёлая работа
  pMiner->IncreaseFatigue();
  cout << "\n" << GetNameOfEntity(pMiner->ID()) << ": "
       << "Раздобыл самородок";
  if (pMiner->PocketsFull())
  {
    pMiner->ChangeState(VisitBankAndDepositGold::Instance());
  }
  //если испытывает жажду, идёт утолять её
  if (pMiner->Thirsty())
  {
    pMiner->ChangeState(QuenchThirst::Instance());
  }
}

Обратите внимание, как метод Miner::ChangeState вызывается методом Instance состояния QuenchThirst или VisitBankAndDepositGold, который обеспечивает указатель к уникальной инстанции этого класса.

EnterMineAndDigForNugget::Exit

Приём Exit состояния EnterMineAndDigForNugget выводит нам сообщение о том, что золотоискатель покидает шахту.

void EnterMineAndDigForNugget::Exit(Miner* pMiner)
{
    cout << "\n" << GetNameOfEntity(pMiner->ID()) << ": "
       << "Полные карманы золота, покидаю шахту";
}

Я надеюсь, что изучение этих трёх методов поможет устранить проблемы, с которыми вы могли столкнуться, и что теперь вы усвоили, как каждое состояние может влиять на поведение агента или осуществлять переход в другое состояние. Можете загрузить проект WestWorld1 в ваш IDE и просканировать код. И в частности, взгянуть на все состояния в файле MinerOwnedStates.cpp и изучить класс Miner, чтобы ознакомиться с его переменными-членами. Помимо всего прочего, убедитесь, что вы понимаете, как работает шаблон проектирования состояний, прежде чем читать дальше. Если чувствуется маленькая неуверенность насчёт этого, то, пожалуйста, уделите время, чтобы возвращаться к предыдущим страницам, пока не почувствуете себя комфортно с концепципей.

Теперь вы видите, как шаблон проектирования состояния обеспечивает весьма гибкий механизм для агентов, основаных на состояниях. Очень просто добавить новые состояния, когда это необходимо. На деле, если вы так пожелаете, вы можете сменить всю архитектуру состояний агента на другую. Это может быть полезным, если у вас сложный проект, которую лучше сообразить в качестве набора нескольких отдельных небольших конечных автоматов. К примеру, конечный автомат для шутера от первого лица (FPS) вроде Unreal 2, как правило, обширен и сложен. При разработке ИИ для такого рода игры вы можете предпочесть несколько небольших автоматов, учитывая такие функции, как «защитить флаг» или «исследовать карту», которые могут быть включены и выключены при необходимости. Шаблон проектирования состояний облегчает эту работу.

Делаем базовый класс состояния многоразовым

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

template <class entity_type>
class State
{
public:
  virtual void Enter(entity_type*)=0;
  virtual void Execute(entity_type*)=0;
  virtual void Exit(entity_type*)=0;
  virtual ~State(){}
};

Объявление для конкретного состояния (в качестве примера возьмём состояние шахтёра EnterMineAndDigForNugget) теперь выглядит следующим образом:

class EnterMineAndDigForNugget : public State<⁢Miner>
{
public:
  /* ОПУЩЕНО */
};

Скоро вы увидите, как это нам поможет в конечном счёте.

Глобальные состояния и блипы состояний

В большинстве случаев при разработке КА вы закончите тем, что ваш код будет дублироваться в каждом состоянии. Например, в популярной игре Sims производства Maxis симы могут испытывать различные нужды, такие как, например, поход в ванную. Подобные нужды могут настигнуть сима в любой момент, в каком состоянии он бы ни был. В нашем же случае, чтобы одарить золотоискателя таким поведением, нужно к каждому из его состояний добавить дубликат условной логики, или же поместить его в функцию Miner::Update. Когда нам приемлемо второе решение, лучше всего создать глобальное состояние, которое будет вызываться при каждом обновлении КА. Таким образом, вся логика для автомата будет заложена в пределах состояний, а не в классе агента, который владеет автоматом.

Чтобы ввести глобальное состояние, необходима дополнительная переменная-член:

//заметьте, что State — теперь шаблон класса, которому нам нужно объявить тип объекта
State<Miner>* m_pGlobalState;

Вдобавок к глобальному поведению, иногда будет удобно заставить агент входить в состояние с условием, что при выходе из него агент возвращается в предыдущее состояние. Такое поведение я называю блипом состояния. На примере той же Sims: вы можете утверждать, что ваш агент может ходить в ванную в любой момент, но убедитесь, что он возвращается в предыдущее состояние. Чтобы придать автомату функциональность такого типа, надо, чтобы он учитывал в себе предыдущее состояние, и блип состояния мог возвращаться к нему. Это делается легко, так как всё, что необходимо — это ещё одна переменная-член и ещё немного логики в методе Miner::ChangeState.

Теперь, чтобы реализовать эти дополнения, класс Miner получил ещё две переменных-членов и один метод. В итоге получилось примерно так (посторонние детали опущены):

class Miner : public BaseGameEntity
{
private:
  State<Miner>*   m_pCurrentState;
  State<Miner>*   m_pPreviousState;
  State<Miner>*   m_pGlobalState;
  ...
public:
  void ChangeState(State<Miner>* pNewState);
  void RevertToPreviousState();
  ...
};

Хм, похоже, пришло время немного навести порядки.

Создание класса конечного автомата

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

Теперь, когда мы знаем это, давайте взглянем на шаблон класса StateMachine:

template <class entity_type>
class StateMachine
{
private:
  //указатель к агенту, которые владеет инстанцией
  entity_type*          m_pOwner;
  State<entity_type>*   m_pCurrentState;
  //данные о предыдущем состоянии, в котором агент был
  State<entity_type>*   m_pPreviousState;
  //этот алгоритм состояния вызывается каждый раз при обновлении КА
  State<entity_type>*   m_pGlobalState;
public:
  StateMachine(entity_type* owner):m_pOwner(owner),
                                   m_pCurrentState(NULL),
                                   m_pPreviousState(NULL),
                                   m_pGlobalState(NULL)
  {}
  //эти методы используются для инициализации КА
  void SetCurrentState(State<entity_type>* s){m_pCurrentState = s;}
  void SetGlobalState(State<entity_type>* s) {m_pGlobalState = s;}
  void SetPreviousState(State<entity_type>* s){m_pPreviousState = s;}
  //вызываем это, чтобы обновлять КА
  void Update()const
  {
    //если существует глобальное состояние, вызываем его метод выполнения
    if (m_pGlobalState)   m_pGlobalState->Execute(m_pOwner);
    //same for the current state
    if (m_pCurrentState) m_pCurrentState->Execute(m_pOwner);
  }
  //переходим в новое состояние
  void  ChangeState(State<entity_type>* pNewState)
  {
    assert(pNewState &&
           "<StateMachine:ChangeState>: попытка переключиться в нулевое состояние");
    //сохраняем данные о предыдущем состоянии
    m_pPreviousState = m_pCurrentState;
    //вызываем метод Exit действующего состояния
    m_pCurrentState->Exit(m_pOwner);
    //сменяем состояние на новое
    m_pCurrentState = pNewState;
    //вызываем метод Enter нового состояния
    m_pCurrentState->Enter(m_pOwner);
  }
  //сменяем состояние обратно на предыдущее
  void RevertToPreviousState()
  {
    ChangeState(m_pPreviousState);
  }
  //аксессоры
  State<entity_type>*  CurrentState()  const{return m_pCurrentState;}
  State<entity_type>*  GlobalState()   const{return m_pGlobalState;}
  State<entity_type>*  PreviousState() const{return m_pPreviousState;}
  //возвращает значение ИСТИНА, если тип текущего состояния равен типу
  //класса, переданному в качестве параметра.
  bool  isInState(const State<entity_type>& st)const;
};

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

Усовершенствованный класс Miner выглядит таким образом:

class Miner : public BaseGameEntity
{
private:
  //инстанция класса конечного автомата
  StateMachine<Miner>*  m_pStateMachine;
  /* ПОСТОРОННИЕ ДЕТАЛИ ОПУЩЕНЫ */
public:
  Miner(int id):m_Location(shack),
                m_iGoldCarried(0),
                m_iMoneyInBank(0),
                m_iThirst(0),
                m_iFatigue(0),
                BaseGameEntity(id)
  {
    //устанавливаем конечный автомат
    m_pStateMachine = new StateMachine<Miner>(this);
    m_pStateMachine->SetCurrentState(GoHomeAndSleepTilRested::Instance());
    m_pStateMachine->SetGlobalState(MinerGlobalState::Instance());
  }
    ~Miner(){delete m_pStateMachine;}
  void Update()
  {
    ++m_iThirst;
    m_pStateMachine->Update();
  }
  StateMachine<Miner>* GetFSM()const{return m_pStateMachine;}
  /* ПОСТОРОННИЕ ДЕТАЛИ ОПУЩЕНЫ */
};

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

Иерархия класса теперь выглядит так, как показано на Рисунке 2.4.

Рисунок 2.4: Обновлённая схема
Рисунок 2.4: Обновлённая схема

Знакомство с Эльзой

Для демонстрации кое-чего нового, я создал для этой главы ещё один проект — WestWorldWithWoman. В West World появился ещё один обитатель — Эльза, жена золотоискателя. Пока она многого не делает: она лишь озабочена уборкой хижины и опорожнением мочевого пузыря (ну, кофе много пьёт). Схема смены состояний Эльзы показана на Рисунке 2.5.

Рисунок 2.5: смена состояний Эльзы
Рисунок 2.5: смена состояний Эльзы. Глобальное состояние на рисунке не показано, так как оно фактически осуществляется при любом состоянии и никогда не изменяется.

Когда загрузите проект в IDE, заметьте, что состояние VisitBathroom реализовано как состояние-блип (т. е. оно всегда возвращает к предыдущему состоянию). Также обратите внимание, что определено глобальное состояние WifesGlobalState, которое содержит логику, необходимую для визитов Эльзы в ванную. Логика находится в глобальном состоянии, потому что Эльза может почувствовать зов природы в любое время в любом состоянии.

Ниже приведён возможный пример выходных данных проекта WestWorldWithWoman. Действия Эльзы выделены курсивом.

Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Полные карманы золота, покидаю шахту
Шахтёр Боб: Направляюсь в банк. Да, детка
Эльза: Я в туалет. Пойду носик припудрю
Эльза: Ах! Сладкое облегчение!
Эльза: Покидаю нужник

Шахтёр Боб: Сдаю золото на хранение. Общие сбережения теперь: 4
Шахтёр Боб: Покидаю банк
Шахтёр Боб: Топаю в шахту
Эльза: Я в туалет. Пойду носик припудрю
Эльза: Ах! Сладкое облегчение!
Эльза: Покидаю нужник

Шахтёр Боб: Раздобыл самородок
Эльза: Убираю пол
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Полные карманы золота, покидаю шахту
Шахтёр Боб: Вах, пить захотелось. Топаю в салун
Эльза: Убираю пол
Шахтёр Боб: Попиваю крепкий хороший ликёр
Шахтёр Боб: Выхожу из салуна, дела ок, настроение тоже
Шахтёр Боб: Топаю в шахту
Эльза: Заправляю кроватку
Шахтёр Боб: Раздобыл самородок
Шахтёр Боб: Полные карманы золота, покидаю шахту
Шахтёр Боб: Направляюсь в банк. Да, детка
Эльза: Я в туалет. Пойду носик припудрю
Эльза: Ах! Сладкое облегчение!
Эльза: Покидаю нужник

Шахтёр Боб: Сдаю золото на хранение. Общие сбережения теперь: 5
Шахтёр Боб: Уху! Более чем достаточно на сегодня. Пора домой к моей любимой леди
Шахтёр Боб: Покидаю банк
Шахтёр Боб: Иду домой, весело размахивая киркой
Эльза: Я в туалет. Пойду носик припудрю
Эльза: Ах! Сладкое облегчение!
Эльза: Покидаю нужник

Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...

Возможности обмена сообщениями для КА

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

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

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

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

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

Структура телеграмм

Сообщение — это всего лишь перечисляемый тип. Оно может быть о чём угодно. Агенты могут отправлять такие сообщения, как Msg_ReturnToBase, Msg_MoveToPosition, или Msg_HelpNeeded. Вместе с сообщением также должна содержаться дополнительная информация. Например: кто отправитель, кто получатель, текст сообщения, отметка времени и т. д. Вся соответствующая информация должно содержаться в одной структуре Telegram. Ниже приведён код. В переменных-членах его показано, какие виды информации могут передаваться вместе с сообщениями.

struct Telegram
{
  //компонент, который отправил телеграмму
  int          Sender;
  //компонент, который должен получить телеграмму
  int          Receiver;
  //само сообщение. Всё, что выше, перечислено в файле "MessageTypes.h"
  int          Msg;
  //сообщение может быть отправлено немедленно, либо отложено на некоторый
  //период времени. Если отсрочка необходима, это поле помечается временем,
  //когда сообщение должно быть отослано.
  double       DispatchTime;
  //сообщение может сопровождаться дополнительной информацией
  void*        ExtraInfo;
  /* КОНСТРУКТОРЫ ОПУЩЕНЫ */
};

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

Шахтёр Боб и Эльза на связи

Для этой главы я сделал общение между Бобом и Эльзой простым. Они используют только два сообщения, которые перечислены как:

enum message_type
{
  Msg_HiHoneyImHome,
  Msg_StewReady
};

Золотоискатель будет отправлять сообщение Msg_HiHoneyImHome своей жене, чтобы дать её знать, что он вернулся домой с работы. Msg_StewReady будет использоваться Эльзой, чтобы дать ей самой знать, когда вытаскивать еду из духовки, и затем, чтобы сообщить Бобу, еда подана к столу.

Схема состояний Эльзы теперь выглядит так, как на Рисунке 2.6.

Рисунок 2.6: Новая схема состояний Эльзы
Рисунок 2.6: Новая схема состояний Эльзы

Прежде чем я покажу вам, как телеграммные события обрабатываются агентами, мы рассмотрим, как они создаются, управляются и рассылаются.

Распоряжение сообщений и их отправка

Создание, рассылка сообщений управление ими осуществляется классом MessageDispatcher. Когда агенту нужно отправить сообщение, он вызывает MessageDispatcher::DispatchMessage вместе со всей необходимой информацией: тип сообщения, когда оно должно быть отправлено, идентификатор получателя и т. д. MessageDispatcher использует информацию для создания Telegram, который либо отправляется немедленно, либо в надлежащем порядке ожидает отправки в нужное время.

Перед тем как отправить сообщение, MessageDispatcher должен уведомиться об указателе отправителя, ведущему к объекту-получателю. Следовательно, должен присутствовать установленный этим классом некий род базы данных с инстанциированными объектами, чтобы обращаться к ним — своего рода телефонная книга, где указатели к агентам ссылаются перекрёстно благодаря их идентификаторам. База данных, использованная в демо — класс-синглтон под названием EntityManager. Его объявление выглядит следующим образом:

class EntityManager
{
private:
  //дабы поберечь немного пальцы...
  typedef std::map<int, BaseGameEntity> EntityMap;
private:
  //для быстрого нахождения компоненты храним в std::map, где
  //указатели к ним ссылаются перекрёстно идетификационным
  //номером
  EntityMap           m_EntityMap;
  EntityManager(){}
  //конструктор копирования и назначение должны быть приватными
  EntityManager(const EntityManager&);
  EntityManager& operator=(const EntityManager&);
public:
  static EntityManager* Instance();
  //этот метод хранит указатель к компоненту в std::vector
  //m_Entities на индексе позиции выражается идентификатором компонента
  //(так делается для быстрого доступа)
  void            RegisterEntity(BaseGameEntity* NewEntity);
  //возвращает указатель к компоненту с идентификатором, данным
  //в качестве параметра
  BaseGameEntity* GetEntityFromID(int id)const;
  //этот метод удаляет компонент из перечня
  void            RemoveEntity(BaseGameEntity* pEntity);
};
//обеспечиваем простой доступ к инстанции EntityManager
#define EntityMgr EntityManager::Instance()

Когда компонент создан, он регистрируется менеджером компонентов:

Miner* Bob = new Miner(ent_Miner_Bob); //перечисленный идентификатор
EntityMgr->RegisterEntity(Bob);

Теперь клиент может запрашивать указатель конкретного компонента, передав его идентификатор методу EntityManager::GetEntityFromID следующим образом:

Entity* pBob = EntityMgr->GetEntityFromID(ent_Miner_Bob);

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

Класс MessageDispatcher

Синглтон MessageDispatcher контролирует отправку сообщений. Рассмотрим объявление этого класса:

class MessageDispatcher
{
private:
  //std::set используется как контейнер для отложенных сообщений
  //по причине пользы автоматической сортировки и избегания
  //дубликатов. Сообщения сортируются по времени их отправки.
  std::set<Telegram> PriorityQ;
  //этот метод используется DispatchMessage или DispatchDelayedMessages.
  //Этот метод вызывает функцию-член обработки сообщений получающего
  //компонента pReceiver) и только что созданное сообщение
  void Discharge(Entity* pReceiver, const Telegram& msg);
  MessageDispatcher(){}
public:
  //этот класс является синглтоном
  static MessageDispatcher* Instance();
  //отправляем сообщение другому агенту.
  void DispatchMessage(double      delay,
                       int         sender,
                       int         receiver,
                       int         msg,
                       void*       ExtraInfo);
  //рассылаем отложенные сообщения. Этот метод вызывается каждый раз через
  //основной игровой цикл.
  void DispatchDelayedMessages();
};
//дабы облегчить нашу жизнь...
#define Dispatch MessageDispatcher::Instance()

Класс MessageDispatcher обрабатывает сообщения, которые отправляются немедленно, либо отложенные сообщения, которые доставляются через указанное время в будущем. Оба типа сообщений создаются и управляются одним методом DispatchMessage. Давайте пройдёмся по исходнику. (Во вспомогательном файле этот метод содержит несколько строк кода для вывода текста в консоль. Здесь я их убрал для большей ясности.)

void MessageDispatcher::DispatchMessage(double     delay,
                                        int        sender,
                                        int        receiver,
                                        int        msg,
                                        void*      ExtraInfo)
{

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

  //получаем указатель к получателю сообщения
  Entity* pReceiver = EntityMgr->GetEntityFromID(receiver);
  //создаём телеграмму
  Telegram telegram(0, sender, receiver, msg, ExtraInfo);
  //если задержки нет, отправить сообщение немедленно
  if (delay <= 0.0)
  {
    //отправляем телеграмму получателю
    Discharge(pReceiver, telegram);
  }

После того, как указатель к получателю есть у менеджера компонентов и создан Telegram с помощью надлежащей информации, сообщение готово к отправке. Если сообщению положена немедленная отправка, метод Discharge вызывается сразу же. Метод Discharge передаёт свежесозданный Telegram методу обработки сообщений компонента-получателя (подробнее об этом чуть позже). Большинство сообщений, отправляемых вашими агентами, будут создаваться и немедленно отправляться именно так. Например, тролль ударяет человека по голове булавой и отправляет ему мгновенное сообщение о том, что по нему был нанесён удар. Затем человек реагирует и действует соответственно, проигрывая нужную анимацию и звуки.

//else подсчитывает время, в которое сообщение должно быть отправлено
  else
  {
    double CurrentTime = Clock->GetCurrentTime();
    telegram.DispatchTime = CurrentTime + delay;
    //и помещает сообщение в очередь
    PriorityQ.insert(telegram);
  }
}

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

Телеграммы сортируются по отношению ко времени, и рассмотреть Telegram.h, можно заметить, что операторы < и == перегружены. Также стоит заметить, что телеграммы, отсрочка которых отличается меньше, чем на четверть секунды, считаются идентичными. Это предотвращает возникновение похожих телеграмм, сбивающихся в кучи, затем доставляющихся всех сразу, тем самым забивая агент идентичными сообщениями. Конечно, этот временной порог может быть другим в зависимости от игры. Игры с большим количеством действия, и высокой частотой отправки сообщений потребует более короткий интервал.

Телеграммы, добавленные в очередь, проверяются методом DispatchDelayedMessages с каждым обновлением. Эта функция проверяет «переднюю часть» приоритетной очереди на наличие сообщений, у которых истекло время задержки. Если есть такие, они доставляются получателю и удаляются из очереди. Код этого метода выглядит следующим образом:

void MessageDispatcher::DispatchDelayedMessages()
{
  //сначала достаём текущее время компьютера
  double CurrentTime = Clock->GetCurrentTime();
  //теперь быстренько глядим, нет ли сообщений с истёкшей задержкой.
  //и удаляем все сообщения из очереди, чьё время настало
  //для отправки
  while( (PriorityQ.begin()->DispatchTime DispatchTime > 0) )
  {
    //читаем телеграмму из передней части очереди
    Telegram telegram = *PriorityQ.begin();
    //ищем получателя
    Entity* pReceiver = EntityMgr->GetEntityFromID(telegram.Receiver);
    //отправляем телеграмму получателю
    Discharge(pReceiver, telegram);
    //and remove it from the queue
    PriorityQ.erase(PriorityQ.begin());
  }
}

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

Обработка сообщений

После того, как система для создания и отправки сообщений заняла своё место в коде, обрабатывать их сравнительно просто. Класс BaseGameEntity должен быть изменён так, чтобы подклассы могли получать сообщения. Это достигается путём объявления ещё одного чисто виртуального класса HandleMessage, который должны реализовывать все производные классы. Исправленный базовый класс BaseGameEntity теперь должен выглядеть так:

class BaseGameEntity
{
private:
  int         m_ID;
  /* ПОСТОРОННИЕ ДЕТАЛИ ОПУЩЕНЫ ДЛЯ НАГЛЯДНОСТИ*/
public:
  //все подклассы могут обмениваться между собой сообщениями.
  virtual bool  HandleMessage(const Telegram& msg)=0;
 /* ПОСТОРОННИЕ ДЕТАЛИ ОПУЩЕНЫ ДЛЯ НАГЛЯДНОСТИ*/
};

Вдобавок, базовый класс State также нужно изменить так, чтобы состояния BaseGameEntity могли допускать обработку сообщений. Обновлённый класс State включает в себя дополнительный метод OnMessage:

template <class entity_type>
class State
{
public:
  //это исполняется, если агент получает сообщение от
  //отправителя
  virtual bool OnMessage(entity_type*, const Telegram&)=0;
  /* ПОСТОРОННИЕ ДЕТАЛИ ОПУЩЕНЫ ДЛЯ НАГЛЯДНОСТИ*/
};

В конечном счёте, класс StateMachine изменён и теперь содержит метод HandleMessage. Когда телеграмма получена компонентом, она первым делом направляется у его текущему состоянию. Если в текущем состоянии нет нужного кода для взаимодействия с сообщением, то оно направляется к обработчику сообщений глобального состояния компонента. Вы наверное заметили, что OnMessage возвращает тип данных bool. Это нужно для того, чтобы выяснить, успешно ли обработано сообщение, и затем разрешить коду направить сообщение соответственно.

Ниже приведён листинг метода StateMachine::HandleMessage:

  bool  StateMachine::HandleMessage(const Telegram& msg)const
  {
    //сначала проверяем состояние на действительность и возможность
    //обработки сообщения
    if (m_pCurrentState && m_pCurrentState->OnMessage(m_pOwner, msg))
    {
      return true;
    }
    //если это не так, и было реализовано глобальное состояние, отправляем
    //сообщение глобальному состоянию
    if (m_pGlobalState && m_pGlobalState->OnMessage(m_pOwner, msg))
    {
      return true;
    }
    return false;
  }

А здесь показано, как класс Miner направляет сообщение, отправленное ему.

bool Miner::HandleMessage(const Telegram& msg)
{
  return m_pStateMachine->HandleMessage(msg);
}

На Рисунке 2.7 изображена обновлённая структура классов.

Структура классов, включающая сообщения
Рисунок 2.7: Структура классов, включающая сообщения

Эльза готовит ужин

На этом этапе самое время взглянуть на конкретный пример того, как работает обмен сообщениями, поэтому давайте рассмотрим, какую роль эта полезность может играть в проекте «West World». В финальной версии этого примера WestWorldWithMessaging будет последовательность сообщений, которая происходит следующим образом:

  1. Шахтёр Боб возвращается в хижину и отправляет Эльзе Msg_HiHoneyImHome, уведомляя о том, что он дома.
  2. Эльза получает сообщение Msg_HiHoneyImHome, из-за чего прекращает делать то, что она делала в тот момент и сменяет состояние на CookStew.
  3. Когда Эльза входит в состояние CookStew, она ставит тушёное мясо в духовку и отправляет отложенное сообщение Msg_StewReady самой себе, давая знать, что через некоторое время мясо нужно будет вытащить из духовки. (Обычно готовка мяса в духовке отнимает как минимум час, но в виртуальном пространстве Эльза может и за долю секунды умудриться!)
  4. Эльза получает сообщение Msg_StewReady от самой себя. Она реагирует на это сообщение путём вытаскивания пищи из печи о отправки сообщения Шахтёру Бобу, уведомляя его о том, что кушать подано. Боб отреагирует на это сообщение, только если он находится в состоянии GoHomeAndSleepTilRested (будучи в этом состоянии, он находится дома). Если он где-то вне дома, например у золотого дна, то сообщение будет отклонено.
  5. Шахтёр Боб получает сообщение Msg_StewReady и сменяет состояние на EatStew.

Позвольте мне продемонстрировать, как можно реализовать эти пять шагов.

Шаг первый

Шахтёр Боб возвращается в хижину и отправляет Эльзе Msg_HiHoneyImHome, уведомляя о том, что он дома.

Добавляем методу Enter состояния GoHomeAndSleepTilRested код, способствующий отправке сообщений Эльзе. Ниже приведён листинг:

void GoHomeAndSleepTilRested::Enter(Miner* pMiner)
{
  if (pMiner->Location() != shack)
  {
    cout << "\n" << GetNameOfEntity(pMiner->ID()) << ": "
        << "Иду домой, весело размахивая киркой";
    //даём жене знать, что мы дома
    Dispatch->DispatchMessage(SEND_MSG_IMMEDIATELY,  //временная задержка
                              pMiner->ID(),          //ID отправителя
                              ent_Elsa,              //ID/имя получателя
                              Msg_HiHoneyImHome,     //само сообщение
                              NO_ADDITIONAL_INFO);   //нет доп. информации
  }
}

Как вы можете видеть, первое, что делает Боб, когда сменяет состояние — сменяет локацию. После этого он отправляет сообщение Msg_HiHoneyImHome Эльзе путём вызова метода DispatchMessage класса-синглтона MessageDispatcher. Поскольку сообщения отправляется немедленно, первый параметр DispatchMessage равен нулю. Дополнительная информация, привязанная к телеграмме, отсутствует. (Константы SEND_MSG_IMMEDIATELY и NO_ADDITIONAL_INFO в файле MessageDispatcher.h определяются значением 0 для разборчивости.)

Совет. Вам не обязательно ограничивать системы обмена сообщениями для персонажей, таких как орки, лучники или маги. Условившись тем, что объекты являются производными от класса, который обеспечивает им уникальный идентификатор (как BaseGameEntity), появляется возможность отправлять сообщения им. Такие объекты, как сундуки с сокровищами, ловушки, магические двери и даже деревья вполне могут пользоваться возможностью получать и обрабатывать сообщения.Например, можно вывести класс OakTree из класса BaseGameEntity и осуществить функцию обработки сообщения для реакции на такие сообщения, как HitWithAxe (удар топором) или StormyWeather (ветреная погода). Дуб может реагировать на эти сообщения, опрокидываясь или шелестя листьями и покачиваясь. Возможности, которые можно состряпать, используя системы сообщений такого рода, практически бесконечны.

Шаг второй

Эльза получает сообщение Msg_HiHoneyImHome, из-за чего прекращает делать то, что она делала в тот момент и сменяет состояние на CookStew.

Так как она никогда не покидает хижину, Эльза может реагировать на Msg_HiHoneyImHome будучи в любом состоянии. Проще всего будет позволить позаботиться о сообщении глобальному состоянию. (Следует помнить, что глобальное состояние выполняется с каждым обновлением, вместе с текущим состоянием.)

bool WifesGlobalState::OnMessage(MinersWife* wife, const Telegram& msg)
{
  switch(msg.Msg)
  {
  case Msg_HiHoneyImHome:
   {
    cout << "\nСообщение получил(а) " << GetNameOfEntity(wife->ID())
         << " во время: " << Clock->GetCurrentTime();
 
    cout << "\n" << GetNameOfEntity(wife->ID()) <<
         ": Привет, дорогой. Давай я тебе приготовлю поесть";
   }
   return true;
  }//конечная
  return false;
}

Шаг третий

Когда Эльза входит в состояние CookStew, она ставит тушёное мясо в духовку и отправляет отложенное сообщение Msg_StewReady самой себе, давая знать, что мясо нужно вытащить их духовки до того, как оно перегорит, иначе Боб будет расстроен.

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

void CookStew::Enter(MinersWife* wife)
{
  //если мясо ещё не готовится, ставим его в духовку
  if (!wife->Cooking())
  {
    cout << "\n" << GetNameOfEntity(wife->ID())
        << ": Ставлю мясо в духовку";
    DispatchMessage(1.5,                  //временная задержка
                              wife->ID(),           //ID отправителя
                              wife->ID(),           //ID получателя
                              Msg_StewReady,        //само сообщение
                              NO_ADDITIONAL_INFO);  //нет доп. информации
    wife->SetCooking(true);
  }
}

Шаг четвёртый

Эльза получает сообщение Msg_StewReady от самой себя. Она реагирует на это сообщение путём вытаскивания пищи из печи о отправки сообщения Шахтёру Бобу, уведомляя его о том, что кушать подано. Боб отреагирует на это сообщение, только если он находится в состоянии GoHomeAndSleepTilRested (он должен находиться дома, чтобы получить сообщение).

У персонажа Боба нет ушей, но путём сообщения он может «услышать», как Эльза зовёт его к столу, если он дома. Следовательно, Боб отреагирует на сообщение, только если он в состоянии GoHomeAndSleepTilRested.

bool CookStew::OnMessage(MinersWife* wife, const Telegram& msg)
{
  switch(msg.Msg)
  {
    case Msg_StewReady:
    {
      cout << "\nСообщение получил(а) " << GetNameOfEntity(wife->ID()) <<
           " за время: " << Clock->GetCurrentTime();
      cout << "\n" << GetNameOfEntity(wife->ID())
           << ": Всё готово! Пойдём есть";
           DispatchMessage(SEND_MSG_IMMEDIATELY,
                                wife->ID(),
                                ent_Miner_Bob,
                                Msg_StewReady,
                                NO_ADDITIONAL_INFO);
      wife->SetCooking(false);
      wife->GetFSM()->ChangeState(DoHouseWork::Instance());
    }
    return true;
  }//конечная
  return false;
}

Шаг пятый

Шахтёр Боб получает сообщение Msg_StewReady и сменяет состояние на EatStew.

Когда Боб получает сообщение Msg_StewReady, он перестаёт делать то, что делал, и садится за стол готовым опустошить миску горячего и вкусного мясного блюда.

bool GoHomeAndSleepTilRested::OnMessage(Miner* pMiner, const Telegram& msg)
{
   switch(msg.Msg)
   {
   case Msg_StewReady:
     cout << "\nСообщение получил(а) " << GetNameOfEntity(pMiner->ID())
     << " за время: " << Clock->GetCurrentTime();
 
     cout << "\n" << GetNameOfEntity(pMiner->ID())
          << ": Ок, иду, дорогуша!";
 
     GetFSM()->ChangeState(EatStew::Instance());
     return true;
   }//конечная
   return false; //отправляем сообщение глобальному обработчику
}

Вот пример выходных данных программы WestWorldWithMessaging. Здесь прекрасно видно, где происходят действия с сообщениями.

Шахтёр Боб: Направляюсь в банк. Да, детка
Эльза: Убираю пол
Шахтёр Боб: Сдаю золото на хранение. Общие сбережения теперь: 5
Шахтёр Боб: Уху! Более чем достаточно на сегодня. Пора домой к моей любимой леди
Шахтёр Боб: Покидаю банк
Шахтёр Боб: Иду домой, весело размахивая киркой
Мгновенную телеграмму отправил(а) Шахтёр Боб получателю Эльза за время: 4.20062. Текст сообщения: HiHoneyImHome
Эльза получил(а) сообщение за время: 4.20062

Эльза: Привет, дорогой. Давай я тебе приготовлю поесть
Эльза: Ставлю мясо в духовку

Сообщение для Эльза запланировано за время 4.20062. Текст сообщения: StewReady
Эльза: Копошусь над едой
Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...
Эльза: Копошусь над едой
Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...
Эльза: Копошусь над едой
Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...
Эльза: Копошусь над едой
Сообщение из очереди готово к отправке получателю Эльза. Текст сообщения: StewReady
Эльза получил(а) сообщение за время: 5.10162

Эльза: Всё готово! Пойдём есть
Мгновенную телеграмму отправил(а) Шахтёр Боб получателю Эльза за время: 5.10162. Текст сообщения: StewReady
Шахтёр Боб получил(а) сообщение за время: 5.10162

Шахтёр Боб: Ок, иду, дорогуша!
Шахтёр Боб: Как дииивно пааахнет с кухни, Эльза!
Эльза: Ставлю еду на стол
Эльза: Пора дальше заниматься домашними делами!

Шахтёр Боб: А вкусно-то как!
Шахтёр Боб: Спасиба, моя леди! Пойду вернусь к своим делам
Эльза: Мою посуду
Шахтёр Боб: Х-р-р-р... П-ш-ш-ш...
Эльза: Заправляю кроватку
Шахтёр Боб: Моя усталость иссякла. Время снова добывать золото
Шахтёр Боб: Топаю в шахту

Подведём итоги

В этой главе был показан опыт, который нужен, чтобы создать очень гибкие и расширяемые конечные автоматы для ваших игр. Как вы видели, дополнение в виде сообщений значительно выразило иллюзию интеллекта — выходные данные из программы WestWorldWithMessaging буквально говорят о том, что в ней теперь действуют и взаимодействуют два реальных человека. Более того, этот пример был очень простым. Сложность поведения, которую можно воссоздать с помощью конечных автоматов, ограничена лишь вашей фантазией. И вам не обязательно ограничивать агенты лишь одним автоматом. Часто бывает удобным задействовать два КА параллельно: один, например, контролирует перемещение персонажа, другой — выбор оружия, прицеливание и выстрелы. А порой можно позволить самим состояниям содержать конечные автоматы. Это называют иерархическим конечным автоматом. К примеру, игровой агент имеет состояния Explore (исследование), Combat (сражение), и Patrol (патрулирование). В свою очередь, состояние Combat может обладать конечным автоматом, который управляет состояниями, необходимыми в сражении: Dodge (уклонение), ChaseEnemy (погоня за врагом), и Shoot (стрельба).

Навык мастера ставит

Перед тем, как ринуться программировать собственные конечные автоматы, можно неплохо попрактиковаться, добавив в проект WestWorldWithMessaging ещё какого-нибудь персонажа. Например, можно всунуть Завсегдатая, который оскорбляет Шахтёра Боба, из-за чего начинается драка. Прежде чем писать код, возьмите карандаш и бумагу и разрисовывайте схемы перехода состояний для каждого нового персонажа. Успехов!

Заполнен: Туториалы
Присвоен тэг:

avatar

Об Авторе ()

Интересуюсь игростроем: много лет сидит в голове проект-долгострой, над которым хочу теперь начать работать серьёзно и с набитой рукой (раньше клепал в конструкторе :D ). Потихоньку начинаю вникать в написание собственных программ (ну и игр в дальнейшем), помимо работы в движках. Иногда моделирую: работал в 3ds Max, теперь осваиваю Blender, когда есть настроение. На неком уровне увлекаюсь растровой и векторной графикой (последнее познать ещё предстоит). Пока готовлюсь к реализации проекта (надеюсь накопить энтузиазма к этому времени), изучаю сайтостроение, собираюсь изучать C(++)(#) и пишу летсплеи на своём канале YouTube. Такой вот лентяй. :D

Комментирование закрыто.

Наверх