Состояние (State)

Общая инфа

Состояние (State) — шаблон проектирования, который позволяет объекту изменять свое поведение в зависимости от внутреннего состояния.

Когда применяется данный паттерн?

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

UML-диаграмма данного шаблона проектирования предлагает следующую систему:

Паттерн Состояние в C# и .NET

Формальное определение паттерна на C#:

class Program
{
    static void Main()
    {
        Context context = new Context(new StateA());
        context.Request(); // Переход в состояние StateB
        context.Request();  // Переход в состояние StateA
    }
}
abstract class State
{
    public abstract void Handle(Context context);
}
class StateA : State
{
    public override void Handle(Context context)
    {
        context.State = new StateB();
    }
}
class StateB : State
{
    public override void Handle(Context context)
    { 
        context.State = new StateA();
    }
}
 
class Context
{
    public State State { get; set; }
    public Context(State state)
    {
        this.State = state;
    }
    public void Request()
    {
        this.State.Handle(this);
    }
}

Участники паттерна

  • State: определяет интерфейс состояния
  • Классы StateA и StateB — конкретные реализации состояний
  • Context: представляет объект, поведение которого должно динамически изменяться в соответствии с состоянием. Выполнение же конкретных действий делегируется объекту состояния

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

class Program
{
    static void Main(string[] args)
    {
        Water water = new Water(WaterState.LIQUID);
        water.Heat();
        water.Frost();
        water.Frost();
 
        Console.Read();
    }
}
enum WaterState
{
    SOLID,
    LIQUID,
    GAS
}
class Water
{
    public WaterState State { get; set; }
 
    public Water(WaterState ws)
    {
        State = ws;
    }
 
    public void Heat()
    {
        if(State==WaterState.SOLID)
        {
            Console.WriteLine("Превращаем лед в жидкость");
            State = WaterState.LIQUID;
        }
        else if (State == WaterState.LIQUID)
        {
            Console.WriteLine("Превращаем жидкость в пар");
            State = WaterState.GAS;
        }
        else if (State == WaterState.GAS)
        {
            Console.WriteLine("Повышаем температуру водяного пара");
        }
    }
    public void Frost()
    {
        if (State == WaterState.LIQUID)
        {
            Console.WriteLine("Превращаем жидкость в лед");
            State = WaterState.SOLID;
        }
        else if (State == WaterState.GAS)
        {
            Console.WriteLine("Превращаем водяной пар в жидкость");
            State = WaterState.LIQUID;
        }
    }
}

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

class Program
{
    static void Main(string[] args)
    {
        Water water = new Water(new LiquidWaterState());
        water.Heat();
        water.Frost();
        water.Frost();
 
        Console.Read();
    }
}
 class Water
    {
        public IWaterState State { get; set; }
 
        public Water(IWaterState ws)
        {
            State = ws;
        }
 
        public void Heat()
        {
            State.Heat(this);
        }
        public void Frost()
        {
            State.Frost(this);
        }
    }
 
interface IWaterState
{
    void Heat(Water water);
    void Frost(Water water);
}
 
class SolidWaterState : IWaterState
{
    public void Heat(Water water)
    {
        Console.WriteLine("Превращаем лед в жидкость");
        water.State = new LiquidWaterState();
    }
 
    public void Frost(Water water)
    {
        Console.WriteLine("Продолжаем заморозку льда");
    }
}
class LiquidWaterState : IWaterState
{
    public void Heat(Water water)
    {
        Console.WriteLine("Превращаем жидкость в пар");
        water.State = new GasWaterState();
    }
 
    public void Frost(Water water)
    {
        Console.WriteLine("Превращаем жидкость в лед");
        water.State = new SolidWaterState();
    }
}
class GasWaterState : IWaterState
{
    public void Heat(Water water)
    {
        Console.WriteLine("Повышаем температуру водяного пара");
    }
 
    public void Frost(Water water)
    {
        Console.WriteLine("Превращаем водяной пар в жидкость");
        water.State = new LiquidWaterState();
    }
}

Таким образом, реализация паттерна Состояние позволяет вынести поведение, зависящее от текущего состояния объекта, в отдельные классы, и избежать перегруженности методов объекта условными конструкциями, как if..else или switch. Кроме того, при необходимости мы можем ввести в систему новые классы состояний, а имеющиеся классы состояний использовать в других объектах.

Подробнее

Пришло время исповедаться: я немного перестарался с этой главной. Предполагалось что она посвящена шаблону проектирования Состояние (State)GOF. Но я не могу говорить о его применении в играх не затрагивая концепцию конечных автоматов (finite state machines) (или «FSM»). Но как только я в нее углубился, я понял что мне придется вспомнить иерархическую машину состояний (hierarchical state machine)или иерархический автомат и автомат с магазинной памятью(pushdown automata).

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

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

Все мы там были

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

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    yVelocity_ = JUMP_VELOCITY;
    setGraphics(IMAGE_JUMP);
  }
}

Заметили баг?

Здесь нет никакого кода, предотвращающего «прыжок в воздухе»; продолжайте нажимать B пока она в воздухе и она будет подлетать снова и снова. Проще всего решить это добавлением булевского флага isJumping_ в Heroine, который будет следить за тем когда героиня прыгнула:

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    if (!isJumping_) {
      isJumping_ = true;
      // Прыжок...
    }
  }
}

Дальше мы захотели добавить героине игрока возможность пригибаться, когда она находится на земле и вставать когда кнопка отжимается:

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    // Прыгаем если уже не прыгнули...
  } else if (input == PRESS_DOWN) {
    if (!isJumping_) {
      setGraphics(IMAGE_DUCK);
    }
  } else if (input == RELEASE_DOWN) {
    setGraphics(IMAGE_STAND);
  }
}

А здесь баг заметили?

С помощью этого кода игрок может:

  1. Нажать вниз для приседания.
  2. Нажать B для прыжка из сидячей позиции.
  3. Отпустить вниз, находясь в воздухе.

При этом героиня переключится на графику стояния прямо в воздухе. Придется добавить еще один флаг…

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    if (!isJumping_ && !isDucking_) {
      // Прыжок...
    }
  } else if (input == PRESS_DOWN) {
    if (!isJumping_) {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    }
  } else if (input == RELEASE_DOWN) {
    if (isDucking_) {
      isDucking_ = false;
      setGraphics(IMAGE_STAND);
    }
  }
}

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

void Heroine::handleInput(Input input) {
  if (input == PRESS_B) {
    if (!isJumping_ && !isDucking_) {
      // Прыжок...
    }
  } else if (input == PRESS_DOWN) {
    if (!isJumping_) {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    } else {
      isJumping_ = false;
      setGraphics(IMAGE_DIVE);
    }
  } else if (input == RELEASE_DOWN) {
    if (isDucking_) {
      // Стояние...
    }
  }
}

Снова ищем баги. Нашли?

У нас есть проверка на то чтобы было невозможно прыгнуть в воздухе, но не во время подката. Добавляем еще один флаг…

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

Конечные автоматы — наше спасение

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

Поздравляю, вы только что создали конечный автомат(finite state machine). Они пришли из области компьютерных наук, называемой теория автоматов(automata theory), в семейство структур которой также входит знаменитая машина Тьюринга. FSM- простейший член этого семейства.

Суть заключается в следующем:

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

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

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

    Перечисления и переключатели

    Одна из проблем нашего старого класса Heroine заключается в том что он допускает некорректную комбинацию булевских ключей: isJumping_ и isDucking_ не могут быть правдой одновременно. А если у вас есть несколько булевских флагов, только один из которых может быть true, не лучше ли заменить их все на enum.

    В нашем случае с помощью enum можно полностью описать все состояния нашей FSM таким образом:

    enum State {
      STATE_STANDING,
      STATE_JUMPING,
      STATE_DUCKING,
      STATE_DIVING
    };

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

    void Heroine::handleInput(Input input) {
      switch (state_) {
      case STATE_STANDING:
        if (input == PRESS_B) {
          state_ = STATE_JUMPING;
          yVelocity_ = JUMP_VELOCITY;
          setGraphics(IMAGE_JUMP);
        } else if (input == PRESS_DOWN) {
          state_ = STATE_DUCKING;
          setGraphics(IMAGE_DUCK);
        }
        break;
    
      case STATE_JUMPING:
        if (input == PRESS_DOWN) {
          state_ = STATE_DIVING;
          setGraphics(IMAGE_DIVE);
        }
        break;
    
      case STATE_DUCKING:
        if (input == RELEASE_DOWN) {
          state_ = STATE_STANDING;
          setGraphics(IMAGE_STAND);
        }
        break;
      }
    }

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

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

    Добавляем в Heroine поле chargeTime_ для хранения времени зарядки. Допустим у нас уже есть метод update(), вызываемый на каждом кадре. Добавим в него следующий код:

    void Heroine::update() {
      if (state_ == STATE_DUCKING) {
        chargeTime_++;
        if (chargeTime_ > MAX_CHARGE) {
          superBomb();
        }
      }
    }

    Каждый раз когда мы приседаем заново нам нужно обнулять этот таймер. Для этого нам нужно изменить handleInput():

    void Heroine::handleInput(Input input) {
      switch (state_) {
      case STATE_STANDING:
        if (input == PRESS_DOWN) {
          state_ = STATE_DUCKING;
          chargeTime_ = 0;
          setGraphics(IMAGE_DUCK);
        }
        // Обработка оставшегося ввода...
        break;
    
        // Другие состояния...
      }
    }

    В конце концов для добавления этой атаки с подзарядкой нам пришлось изменить два метода и добавить поле chargeTime_ в Heroine, даже если оно используется только в состоянии приседания. Хотелось бы иметь весь этот код и данные в одном месте. Банда четырех может нам в этом помочь.

    Шаблон состояние

    Для людей, хорошо разбирающихся в объектно-ориентированной парадигме, каждое условное ветвление — это возможность для использования динамической диспетчеризации (другими словами вызова виртуального метода в C++). Думаю нам нужно спуститься в эту кроличью нору еще глубже. Иногда if — это все что нам нужно.

    В нашем примере мы уже добрались до той критической точки, когда нам стоит обратить внимание на что-то объектно-ориентированное. Это подводит нас к шаблону Состояние. Цитирую банду четырех:

    Позволяет объектам менять свое поведение в соответствии с изменением внутреннего состояния. При этом объект будет вести себя как другой класс.

    Не очень то и понятно. В конце концов и switch с этим справляется. Применительно к нашему примеру с героиней шаблон будет выглядеть следующим образом:

    Интерфейс состояния

    Для начала определим интерфейс для состояния. Каждый бит поведения, зависящий от состояния — т.е. все что мы раньше реализовывали при помощи switch — превращается в виртуальный метод этого интерфейса. В нашем случае это handleInput() и update().

    class HeroineState {
      public:
        virtual~HeroineState() {}
      virtual void handleInput(Heroine & heroine, Input input) {}
      virtual void update(Heroine & heroine) {}
    };

    Классы для каждого из состояний

    Для каждого состояния мы определяем класс, реализующий интерфейс. Его методы определяют поведение героини в данном состоянии. Другими словами берем все варианты из switch в предыдущем примере превращаем их в класс состояния. Например:

    class DuckingState: public HeroineState {
      public: DuckingState(): chargeTime_(0) {}
    
      virtual void handleInput(Heroine & heroine, Input input) {
        if (input == RELEASE_DOWN) {
          // Переход в состояние стояния...
          heroine.setGraphics(IMAGE_STAND);
        }
      }
    
      virtual void update(Heroine & heroine) {
        chargeTime_++;
        if (chargeTime_ > MAX_CHARGE) {
          heroine.superBomb();
        }
      }
    
      private: int chargeTime_;
    };

    Обратите внимание что мы перенесли chargeTime_ из класса самой героини в класс DuckingState. И это очень хорошо, потому что этот кусок данных имеет значение только в этом состоянии и наша модель данных явно об этом свидетельствует.

    Делегирование к состоянию

    Дальше мы даем Heroine указатель на текущее состояние, избавляемся от здоровенного switch и делегируем его работу состоянию.

    class Heroine {
      public:
        virtual void handleInput(Input input) {
          state_ - > handleInput( * this, input);
        }
    
      virtual void update() {
        state_ - > update( * this);
      }
    
      // Другие методы...
      private:
        HeroineState * state_;
    };

    Чтобы «изменить состояние» нам нужно просто сделать так чтобы state_ указывал на другой объект HeroineState. В этом собственно и заключается шаблон Состояние.

    А где же эти объекты состояния?

    Я вам кое-что не сказал. Чтобы изменить состояние, нам нужно присвоить state_ новое значение, указывающее на новое состояние, но откуда этот объект возьмется? В нашем примере с enum думать не о чем: значения enum — это просто примитивы наподобие чисел. Но теперь наши состояния представлены классами и это значит что нам нужны указатели на реальные экземпляры. Существует два самых распространенных ответа:

    Статические состояния

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

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

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

    class HeroineState {
      public:
        static StandingState standing;
      static DuckingState ducking;
      static JumpingState jumping;
      static DivingState diving;
    
      // Остальной код...
    };

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

    if (input == PRESS_B) {
      heroine.state_ = & HeroineState::jumping;
      heroine.setGraphics(IMAGE_JUMP);
    }

    Экземпляры состояний

    Иногда предыдущий вариант не взлетает. Статическое состояние не подойдет для состояния присядки. У него есть поле chargeTime_ и оно специфично для героини, которая будет приседать. Это еще худо бедно сработает в нашем случае, потому что у нас всего одна героиня, но если мы захотим добавить кооператив для двух игроков, у нас будут большие проблемы.

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

    // Состояние стояния:
    if (input == PRESS_DOWN) {
      delete heroine.state_;
      heroine.state_ = new DuckingState();
      // Остальной код...
    }

    Это позволит каждому FSM иметь собственный экземпляр состояния. Когда у меня получается я предпочитаю использовать статические состояния, потом что они не занимают память и такты процессора, выделяя объекты при каждом изменении состояния. Для состояний, которые не представляют из себя нечто большее, чем просто состояние — это как раз то что нужно.

    Действия для входа и выхода

    Шаблон Состояние предназначен для инкапсуляции всего поведения и связанных с ним данных внутри одного класса. У нас довольно неплохо получается, но остались некоторые невыясненные детали. Мы перенесли в DuckingState обработку пользовательского ввода и обновление времени подзарядки, но весь связанный с приседанием код остался снаружи. Там же в состоянии стояния, где мы начинаем приседать, выполняется и инициализация:

    // В стоячем состоянии:
    if (input == PRESS_DOWN) {
      // Состояние зарядки...
      chargeTime_ = 0;
      setGraphics(IMAGE_DUCK);
    }

    Состояние приседания должно само позаботиться о сбросе времени зарядки (в конце концов именно в этом объекте находится это поле) и проигрывании анимации. Мы можем добиться этого, добавив в состояние входное действие (entry action):

    class DuckingState: public HeroineState {
      public: virtual void enter(Heroine & heroine) {
        chargeTime_ = 0;
        heroine.setGraphics(IMAGE_DUCK);
      }
    
      // Остальной код...
    };

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

    void Heroine::changeState(HeroineState * state) {
      delete state_;
      state_ = state;
      state_ - > enter( * this);
    }

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

    if (input == PRESS_DOWN) {
      heroine.changeState(new DuckingState());
    }

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

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

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

    И чего же мы добились?

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

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

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

    Машина конкурентных состояний или конкурентный автомат (Concurrent State Machines)

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

    Если мы захотим вместить такое поведение в рамки FSM, нам придется удвоить количество состояний. Для каждого из состояний нам придется завести еще одно такое же, но уже для героини с оружием: стояние, стояние с оружием, прыжок, прыжок с оружием…. Ну вы поняли.

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

    Проблема здесь в том что мы смешиваем две части состояния — что мы делаем и что держим в руках в один автомат. Чтобы смоделировать все возможные комбинации нам нужно завести состояние для каждой пары. Решение очевидно: нужно завести два отдельных конечных автомата.

    Наш первый конечный автомат с действиями мы оставим без изменений. А в дополнение к нему создадим еще один автомат для описания того что героиня держит. Теперь у Heroine будет две ссылки на «состояние», по одной для каждого автомата.

    class Heroine {
      // Остальной код...
    
      private:
        HeroineState * state_;
      HeroineState * equipment_;
    };

    Когда героиня делегирует ввод состояниям, она передает перевод обеим конечным автоматам:

    void Heroine::handleInput(Input input) {
      state_ - > handleInput( * this, input);
      equipment_ - > handleInput( * this, input);
    }

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

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

    Иерархическая машина состояний или иерархический автомат

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

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

    Если бы это был просто объектно-ориентированный код, а не конечный автомат, можно было бы использовать такой прием разделения кода между состояниями, как наследование. Можно определить класс для состояния «на земле», который будет обрабатывать подпрыгивание и приседание. Стояние, ходьба, бег и скатывание для него наследуется и добавляет свое дополнительное поведение.

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

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

    class OnGroundState: public HeroineState {
      public: virtual void handleInput(Heroine & heroine, Input input) {
        if (input == PRESS_B) {
          // Подпрыгнуть...
        } else if (input == PRESS_DOWN) {
          // Присесть...
        }
      }
    };

    А теперь каждый подкласс будет его наследовать:

    class DuckingState: public OnGroundState {
      public: virtual void handleInput(Heroine & heroine, Input input) {
        if (input == RELEASE_DOWN) {
          // Встаем...
        } else {
          // Ввод не обработан. Поэтому передаем его выше по иерархии.
          OnGroundState::handleInput(heroine, input);
        }
      }
    };

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

    Текущее состояние будет находится вверху стека, под ним его суперсостояние, дальше суперсостояние для этого суперсостояния и т.д. И когда вам нужно будет реализовать специфичное для состояния поведение, вы начнете с верха стека спускаться по нему вниз, пока состояние его не обработает. (А если не обработает — значит вы его просто игнорируете).

    Автомат с магазинной памятью (Pushdown Automata)

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

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

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

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

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

    Что нам на самом деле нужно — так это возможность хранить состояние в котором мы находились до стрельбы и после стрельбы вспоминать его снова. Здесь нам снова может помочь теория автоматов. Соответствующая структура данных называется Автомат с магазинной памятью (Pushdown Automata).

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

    1. Вы можете поместить(push) новое состояние в стек. Текущее состояние всегда будет находиться вверху стека, так что это и есть операция перехода в новое состояние. Но при этом старое состояние остается прямо под текущим в стеке, а не исчезает бесследно.
    2. Вы можете извлечь (pop) верхнее состояние из стека. Состояние пропадает и текущим становится то что находилось под ним.

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

    Насколько они реально полезны?

    Даже с этим расширением конечных автоматов, их возможности все равно довольно ограничены. В AI сегодня преобладает тренд использования вещей типа деревьев поведения (behavior trees) и систем планирования (planning systems). И если вам интересна именно область AI, вся эта глава должна просто раздразнить ваш аппетит. Чтобы его удовлетворить вам придется обратиться к другим книгам.

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

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

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

    Добавить комментарий

    Ваш адрес email не будет опубликован. Обязательные поля помечены *