Паттерн конечные автоматы для Ash Entity System фреймворк

в 9:00, , рубрики: Action Script, actionscript 3.0, game development, gamedevelopment

Два года назад познакомился с замечательнейшим фреймворком для разработки игр на Actionscript Ash (почти сразу после знакомства с ним, пришло понимание, что фреймворк подойдет под любой язык. А еще чуть-чуть позже появились порты фреймворка под другие языки — на момент написания перевода имеется 7 штук). Некоторое время распирало желание поделиться находкой с русскоязычным сообществом, пока меня не опередили переводом на Хабре. Теперь вдогонку хочу выложить перевод касательно реализации почти незаменимого паттерна Finite-State Machine для Ash Entity System фреймворка.

Примечание: именования базовых понятий из Entity System фреймворка (ES-фреймворк) буду писать с заглавной буквы — чтоб избежать возможных двусмысленностей: Система(System), Компонент(Component), Нод(Node), Сущность(Entity)

Паттерн конечные автоматы для Ash Entity System фреймворк

Finite state machines (FSM) — одна из главных конструкций в геймдеве. В течении игры игровые объкты могут многократно менять свои состояния и эффективное управление этими состояниями очень важно.

Сложность с FSM в ES-фреймворке можно выразить одним предложением — FSM не работает с ES-фреймворком. Любой ES-фреймворк использует подход, ориентированный на данные (data-oriented paradigm), в котором игровые cущности не являются инкапсулированными ООП объектами. Таким образом у вас не получится использовать FSM или его варианты. Все данные — в Компонентах, вся логика — в Системах.

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

При разработке Stick Tennis я встал перед перед проблемой управления состояниями. Последовательность изменений этих состояний была похожа на нечто подобное…

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

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

Пример

Давайте представим игровой персонаж — охранник. Охранник патрулирует вдоль какого-то маршрута следования, внимательно оглядываясь по сторонам. При обнаружении врага, атакует его.

В традиционной ООП-ориентированной FSM мы должны создать классы для каждого состояния.

public class PatrolState
{
    private var guard : Character;
    private var path : Vector.<Point>;

    public function PatrolState( guard : Character, path : Vector.<Point> )
    {
        this.guard = guard;
        this.path = path;
    }

    public function update( time : Number ) : void
    {
        moveAlongPath( time );
        var enemy : Character = lookForEnemies();
        if( enemy )
        {
            guard.changeState( new AttackState( guard, enemy ) );
        }
    }
}

public class AttackState
{
    private var guard : Character;
    private var enemy : Character;

    public function AttackState( guard : Character, enemy : Character )
    {
        this.guard = guard;
        this.enemy = enemy;
    }

    public function update( time : Number ) : void
    {
        guard.attack( enemy );
        if( enemy.isDead )
        {
            guard.changeState( new PatrolState( guard, PatrolPathFactory.getPath( guard.id ) );
        }
    }
}

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

public class PatrolSystem extends ListIteratingSystem
{
    public function PatrolSystem()
    {
        super( PatrolNode, updateNode );
    }

    private function updateNode( node : PatrolNode, time : Number ) : void
    {
        moveAlongPath( node );
        var enemy : Enemy = lookForEnemies( node );
        if( enemy )
        {
            node.entity.remove( Patrol );
            var attack : Attack = new Attack();
            attack.enemy = enemy;
            node.entity.add( attack );
        }
    }
}

public class AttackSystem extends ListIteratingSystem
{
    public function AttackSystem()
    {
        super( AttackNode, updateNode );
    }

    private function updateNode( node : PatrolNode, time : Number ) : void
    {
        attack( node.entity, node.attack.enemy );
        if( node.attack.enemy.get( Health ).energy == 0 )
        {
            node.entity.remove( Attack );
            var patrol : Patrol = new Patrol();
            patrol.path = PatrolPathFactory.getPath( node.entity.name );
            node.entity.add( patrol );
        }
    }
}

За поведение персонажа-охранника будет отвечать PatrolSystem, если в него включен Компонент PatrolComponent, И за поведение при атаке будет отвечать AttakSystem если в него включен Компонент AttackComponent. Меняя эти Компоненты, мы можем переключать состояния персонажа-охранника.

Эти Компоненты и Ноды будут выглядеть примерно так…

public class Patrol
{
    public var path : Vector.<Point>;
}
public class Attack
{
    public var enemy : Entity;
}
public class Position
{
    public var point : Point;
}
public class Health
{
    public var energy : Number;
}
public class PatrolNode extends Node
{
    public var patrol : Patrol;
    public var position : Position;
}
public class AttackNode extends Node
{
    public var attack : Attack;
}

Итак, меняя Компоненты Сущности, мы меняем состояния Сущности и Системы, отвчающие за их поведение.

Другой пример

Тут представлю другой пример, более сложный из игры Asteroids example game, с помощью которой я иллюстрирую работу Ash Framework. Мне надо было добавить еще одно состояние для космического корабля — гибель корабля. Вместо того, чтоб просто удалить космический корабль в момент гибели, я показываю короткую анимацию его разрушения. Пока я демонстрирую эту анимацию, игрок лишен возможности управлять кораблем, а сам корабль не участвует в обработке столкновений с другими объектами.

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

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

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

for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next )
{
    for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next )
    {
        if ( Point.distance( asteroid.position.position, spaceship.position.position )
            <= asteroid.position.collisionRadius + spaceship.position.collisionRadius )
        {
            creator.destroyEntity( spaceship.entity );
            break;
        }
    }
}

Код проверяет, не столкнулся ли корабль с астероидом, и если это произошло, удаляет корабль из игры. С другой стороны GameManager обрабатывает ситуацию, когда в игре нет космического корабля, и создает новый. Если все “жизни” исчерпаны — конец игры. Так вот — вместо простого удаления корабля из игры, мы должны изменить его состояние — показать разлетающиеся обломки. Давайте попробуем…

Мы можем лишить игрока управления, просто удалив из Сущности космического корабля (spaceship entity) Компоненты MotionControls и GunControls. Так же мы должны удалить Компоненты Motion и Gun так как они все равно не используются без соответсвующих контроллеров. Т.е. мы измеяем код выше:

for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next )
{
    for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next )
    {
        if ( Point.distance( asteroid.position.position, spaceship.position.position )
            <= asteroid.position.collisionRadius + spaceship.position.collisionRadius )
        {
          spaceship.entity.remove( MotionControls );
            spaceship.entity.remove( Motion );
            spaceship.entity.remove( GunControls );
            spaceship.entity.remove( Gun );
          break;
        }
    }
}

Затем нам надо сменить внешний вид корабля (на обломки) и отменить ообработку столкновений:

for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next )
{
    for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next )
    {
        if ( Point.distance( asteroid.position.position, spaceship.position.position )
            <= asteroid.position.collisionRadius + spaceship.position.collisionRadius )
        {
            spaceship.entity.remove( MotionControls );
            spaceship.entity.remove( Motion );
            spaceship.entity.remove( GunControls );
            spaceship.entity.remove( Gun );
            spaceship.entity.remove( Collision );
               spaceship.entity.remove( Display );
               spaceship.entity.add( new Display( new SpaceshipDeathView() ) );
            break;
        }
    }
}

И наконец, нам надо обеспечить удаление обломков корабля спустя некоторый период времени. Для этого нам потребуется новая Система и Компонент:

public class DeathThroes
{
    public var countdown : Number;
        
    public function DeathThroes( duration : Number )
    {
        countdown = duration;
    }
}
public class DeathThroesNode extends Node
{
    public var death : DeathThroes;
}
public class DeathThroesSystem extends ListIteratingSystem
{
    private var creator : EntityCreator;
    
    public function DeathThroesSystem( creator : EntityCreator )
    {
        super( DeathThroesNode, updateNode );
        this.creator = creator;
    }

    private function updateNode( node : DeathThroesNode, time : Number ) : void
    {
        node.death.countdown -= time;
        if ( node.death.countdown <= 0 )
        {
            creator.destroyEntity( node.entity );
        }
    }
}

Мы добавляем DeathThroesSystem в игру с самого начала, и в нужный момент она среагирует на “смерть” Сущности. Остается добавить DeathThroes Компонент в Сущность космического корабля.

for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next )
{
    for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next )
    {
        if ( Point.distance( asteroid.position.position, spaceship.position.position )
            <= asteroid.position.collisionRadius + spaceship.position.collisionRadius )
        {
            spaceship.entity.remove( MotionControls );
            spaceship.entity.remove( Motion );
            spaceship.entity.remove( GunControls );
            spaceship.entity.remove( Gun );
            spaceship.entity.remove( Collision );
            spaceship.entity.remove( Display );
            spaceship.entity.add( new Display( new SpaceshiopDeathView() ) );
            spaceship.entity.add( new DeathThroes( 5 ) );
            break;
        }
    }
}

В итоге мы получаем необходимое состояние космического корабля. Сам же переход между состояниями был обеспечен “перетасовкой” компонентов космического корабля.

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

Главное правило Entity System Architecture — состояние Сущности инкапсулировано в наборе Компонентов. Если вы хотите изменить поведение Сущности, вы должны изменить набор Компонентов. Изменение набора Компонентов в свою очередь изменит набор Систем — и поведение Сущности изменится.

Стандартизированный код для FSM

Для простоты работы с FSM, я добавил во фреймворк соответствующий набор классов - standard state machine classes. Набор этих классов поможет определять новые состояния, управлять ими.

FSM — это экземпляр класса EntityStateMachine. При создании экземпляра, вы передаете ему ссылку на Сущность, состояниями которой он будет управлять. Сам экземпляр EntityStateMachine обычно хранится в каком-нибудь Компоненте в Сущности и таким образом любая Система имеет к нему доступ.

var stateMachine : EntityStateMachine = new EntityStateMachine( guard );

FSM хранит состояния, и эти состояния можно менять, вызывая метод EntityStateMachine.changeState(). Каждое конкретное состояние идентифицируется строкой (именем), которое ассоциируется с состоянием при его создании и используется при вызове EntityStateMachine.changeState(stateName).

var patrolState : EntityState = stateMachine.createState( "patrol" );
var attackState : EntityState = stateMachine.createState( "attack" );

За что отвечает добавленное в FSM состояние

  1. добавляет необходимые Компненты при переходе Сущности в это состояние;
  2. удаляет ранее добавленные Компоненты при выходе из текущего состояния.

Метод EntityStateMachine.add() устанавливает тип Компонента, необходимый для определямого состояния и следует правилам, указывающим как именно создавать этот Компонент.

var patrol : Patrol = new Patrol();
patrol.path = PatrolPathFactory.getPath( node.entity.name );
patrolState.add( Patrol ).withInstance( patrol );
attackState.add( Attack );

Имеется четрые стандартных метода:

entityState.add( type: Class );
Без всяких указаний FSM создаст новый экземпляр Компонента данного типа, для последующего добавления его к Сущности. И это будет повторятся каждый раз при повторных возвращених в данное состояние.
entityState.add( type: Class ).withType( otherType: Class );
Это указание создает новый экземпляр otherType каждый раз при переходе в данное состояние. otherType должен быть типа type либо расширением типа type
entityState.add( type: Class ).withInstance( instance: * );
этот метод дает возможность использовать один и тот-же экземпляр класса Компонента при возвращении в данное состояние.

И наконец

entityState.add( type: Class ).withSingleton();
или
entityState.add( type: Class ).withSingleton( otherType: Class );
создаст синглетон и будет использовать его при каждом возвращении в данное состояние. Это тоже самое, что использовать метод withInstance , но метод withSingleton не создаст экземпляр класса, пока не потребуется. Если otherType не указан, создается синглетон типа type. Если otherType указан, он должен быть типа type, либо расширять его.

Наконец, вы можете сами определять Компонент, реализуя интерфейс IComponentProvider
entityState.add( type: Class ).withProvider( provider: IComponentProvider );
Интерфейс IComponentProvider определен следующим образом

public interface IComponentProvider
{
    function getComponent() : *;
    function get identifier() : *;
}

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

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

Назад к примерам

Если мы применим эти новые инструменты к примеру с космичским кораблем, код примет следующий вид

var fsm : EntityStateMachine = new EntityStateMachine( spaceshipEntity );

fsm.createState( "playing" )
   .add( Motion ).withInstance( new Motion( 0, 0, 0, 15 ) )
   .add( MotionControls )
       .withInstance( new MotionControls( Keyboard.LEFT, Keyboard.RIGHT, Keyboard.UP, 100, 3 ) )
   .add( Gun ).withInstance( new Gun( 8, 0, 0.3, 2 ) )
   .add( GunControls ).withInstance( new GunControls( Keyboard.SPACE ) )
   .add( Collision ).withInstance( new Collision( 9 ) )
   .add( Display ).withInstance( new Display( new SpaceshipView() ) );

fsm.createState( "destroyed" )
   .add( DeathThroes ).withInstance( new DeathThroes( 5 ) )
   .add( Display ).withInstance( new Display( new SpaceshipDeathView() ) );

var spaceshipComponent : Spaceship = new Spaceship();
spaceshipComponent.fsm = fsm;
spaceshipEntity.add( spaceshipComponent );
fsm.changeState( "playing" );

В результате смена текущего Состояния упростится максимально:

for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next )
{
    for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next )
    {
        if ( Point.distance( asteroid.position.position, spaceship.position.position )
            <= asteroid.position.collisionRadius + spaceship.position.collisionRadius )
        {
            spaceship.spaceship.fsm.changeState( "destroyed" );
            break;
        }
    }
}

Автор: meiciuc

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js