Entity-Component-System (ECS) — это шаблон распределенного и композиционного архитектурного проектирования, который в основном используется в разработке игр. Он обеспечивает гибкое разделение поведения, зависящего от предметной области, что устраняет многие недостатки традиционного объектно-ориентированного наследования.

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

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

Исходный код моей реализации ECS в Elixir находится в открытом доступе на Github.

Недостатки наследования на основе классов

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

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

У нас есть база GameObject, которая является подклассом Animal. Animal является подклассом Bunny и Whale, каждый со своим особым поведением hop() и swim() соответственно. У нас также есть Killer Whale, который является подклассом Whale, чем kill().

Давайте попробуем ввести в наш мир новое животное:

Мы хотим, чтобы Killer Bunny мог hop() и kill(), но от какого класса должен наследоваться Killer Bunny?

Для языков/платформ только с одинарным наследованием нам не повезло. Нам пришлось бы переместить и hop(), и kill() в некий суперкласс, такой как Animal, который Killer Bunny сможет наследовать. Однако все остальные подклассы Animal наследуют то, что им не нужно. Whale наследует hop(); Bunny наследует swim() и kill(). Со временем Animal станет божественным объектом с огромным набором действий.

Множественное наследование тоже не работает. Предположим, что Killer Bunny наследуется как от Bunny, так и от Killer Whale. Killer Bunny унаследует swim(), что является ненужной функциональностью.

Мы сталкиваемся с рядом других проблем:

Жесткая функциональность: только Killer Whale может kill(). Мы не можем потом передумать и сделать других животных kill() очень легко. Поведение доступно только для классов, которые были специально закодированы для поддержки этого поведения. По мере роста количества игровых сущностей мы сталкиваемся с большими трудностями в поиске места в иерархии для размещения новых сущностей.

Проблема бриллианта. «Проблема бриллианта» (иногда называемая «смертоносным бриллиантом смерти») — это двусмысленность, которая возникает, когда два класса B и C наследуют от A, а класс D наследует от обоих. B и C. Если в A есть метод, который был переопределен B и C, и D не переопределяет его, то какую версию метода наследует D: версию B или версию C?

Антипаттерн Blob: при наследовании игры заканчиваются огромным единым корневым классом или каким-то другим конечным узлом с большим количеством функций. Подклассы перегружены ненужной функциональностью.

Резюме

Упомянутые выше трудности долгое время преследовали разработчиков игр, и Entity Component Systems пытается исправить эти неудобства. Мы узнаем об ECS в следующем разделе.

Системы компонентов сущностей

В ECS есть три ключевых абстракции: Сущности, Компоненты и Системы.

Мы подробно рассмотрим каждый, начиная с Component.

Компонент

Качества или аспекты сущности.

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

Представьте, что в нашем мире есть сущность Bunny:

Мы можем определить кроликов как не более чем агрегацию/набор независимых компонентов. В приведенном выше примере кролик «состоит» из таких компонентов, как Physical и Seeing.

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

Организация

Агрегация или контейнер компонентов.

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

Давайте снова посмотрим на наш Bunny:

Видите пунктирную рамку вокруг наших компонентов? Это сущность Bunny — не более чем контейнер компонентов. Мы можем определить сущности как агрегацию любого подмножества компонентов, например Carrot:

А это Ghost:

Сущность — это не более чем набор компонентов.

Некоторые реализации ECS позволяют изменять коллекцию компонентов сущности во время выполнения. Это позволяет вам «мутировать» объекты на лету. Например, у нас может быть компонент Poisoned, который заставляет объекты, помеченные этим компонентом, со временем терять здоровье. Мы можем динамически добавлять и удалять этот компонент, чтобы наносить и лечить яд. У вас также может быть эффект «слепого» статуса, который удаляет компонент Seeing объектов, с которыми он сталкивается.

До этого момента мы не касались никакой логики или поведения. Сущности — это просто совокупность компонентов; Компоненты — это просто объекты данных. Откуда берется поведение в ECS? Они приходят из систем.

Система

Системы оживляют сущности и компоненты.

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

Поведение: «Кролик с дерева падает под действием силы тяжести».

Как мы реализуем вышеуказанное поведение? Мы можем сделать так, чтобы Placeable Компонентов со значением z больше 0 уменьшалось со временем до 0.

Поведение: «Живые существа стареют».

Как мы реализуем вышеуказанное поведение? Мы можем сделать так, чтобы значение Living компонентов со временем увеличивалось на age.

Мы создаем специальную систему для каждого поведения, которое мы хотим поддерживать. GravitySystem перечисляет все Placeable Компоненты; a TimeSystem перечисляет все компоненты Living. Имейте в виду, что Системы работают с Компонентами, а не с Сущностями.

Поток данных в Entity-Component-System

Чтобы еще больше закрепить ваше понимание шаблона, давайте рассмотрим типичный поток данных в этой архитектуре:

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

Еще один пример. Допустим, игрок нажимает клавишу "переместиться влево". PlayerInputSystem выполняет и обнаруживает нажатие клавиши, обновляя компонент Motion. MotionSystem выполняет и «видит» движение объекта слева, применяя физическую силу слева. RenderSystem выполняет и считывает текущую позицию объекта и рисует его в соответствии с пространственным определением (которое может включать в себя область текстуры/информацию об анимации).

Введение в системы сущностей

Аналогия с электронной таблицей

Другой способ представить ECS как реляционную таблицу, например, электронную таблицу:

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

Преимущества ЭКС

Теперь, когда мы лучше понимаем архитектуру Entity-Component-System, давайте подумаем, чем этот подход отличается от наследования на основе классов.

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

Компонуемость и определение объекта во время выполнения. Любой тип игрового объекта можно создать, добавив в объект правильные компоненты. Это также может позволить разработчику легко добавлять функции одного типа объекта к другому без каких-либо проблем с зависимостями. Например, мы можем сделать Entity.build([FlyingComponent, SeeingComponent]) во время выполнения.

Тестируемость: каждый компонент и система по определению являются единым целым. Мы также можем заменить компоненты фиктивными или демонстрационными компонентами для тестирования.

Распараллеливание: во многих реальных реализациях ECS, таких как MMO, система реализована как распределенная система или рабочий пул, который может распределять работу между собой. Это позволяет нам горизонтально масштабировать размер наших симуляций, увеличивая количество системных рабочих в нашем пуле.

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

Проблемы ЭКС

Несмотря на гибкость, которую это дает нам, ECS создает ряд нетривиальных проблем:

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

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

Взаимодействие между компонентами. Что происходит, когда системе требуется доступ и изменение данных в нескольких компонентах? Компонентам может потребоваться поделиться состоянием с другими компонентами и взаимодействовать друг с другом, прежде чем взаимодействовать с системами. Например, скажем, у нас есть компоненты Position и Sound в объекте. У нас может быть PositionalSoundSystem, которому нужно взаимодействовать с обоими компонентами. Нам может понадобиться отдельный канал для межкомпонентной связи для поддержки этого варианта использования.

Взаимодействие между системами. Что происходит, когда двум системам требуется доступ и изменение одного и того же компонента? Допустим, у нас есть две системы: одна умножает атрибут x на -1, другая добавляет x на 10. В зависимости от порядка применения двух систем конечный результат будет разным. Если операции не являются ассоциативными, нам может понадобиться ввести способ, обеспечивающий правильный порядок систем.

Не такое конкретное определение, как другие шаблоны проектирования, такие как MVC: существует множество способов реализации ECS. Для каждого языка или платформы будут доступны разные абстракции, что приведет к различным вариантам ECS.

ЭКС в реальном мире

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

В частности, один стартап занимается созданием ECS-as-a-service под названием SpatialOS.

Реализация ECS в Elixir

В этом разделе мы рассмотрим одну из возможных реализаций Entity-Component-System в Elixir. Я начну с краткого упоминания, почему Elixir (также Erlang) и его примитивы параллелизма хорошо подходят для шаблона ECS.

С этого момента, поскольку Elixir компилируется в байт-код Erlang, когда я говорю Elixir, я также имею в виду Erlang.

Модель актера

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

  • Отправка сообщений
  • Получать сообщения
  • Создавать новых актеров

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

# Try running this in an Elixir interpreter (iex)

# We first spawn a new actor that listens for messages, returning a process id (pid)
jeff = spawn(fn ->
        receive do
          {sender, message} -> IO.puts "Received '#{message}' from process #{inspect sender}"
        end
       end)

# We send a message to jeff's pid, adding our own pid in the message
send jeff, {self(), “Hello world”}

# send, receive, and spawn are built-in Elixir primitives

В Elixir также есть абстракции более высокого уровня для создания акторов под названием GenServers:

defmodule Stack do
  use GenServer

  # Callbacks

  def handle_call(:pop, _from, [h | t]) do
    {:reply, h, t}
  end

  def handle_cast({:push, item}, state) do
    {:noreply, [item | state]}
  end
end

# Start the server
{:ok, pid} = GenServer.start_link(Stack, [:hello])

# This is the client
GenServer.call(pid, :pop)
#=> :hello

GenServer.cast(pid, {:push, :world})
#=> :ok

GenServer.call(pid, :pop)
#=> :world

Подумайте, как вы можете реализовать ECS с помощью акторов.

Пример использования

Вот как наша реализация будет выглядеть при использовании:

# Instantiates a new entity with a set of parameterized components
# TimeComponent is a component that counts up
> bunny = ECS.Entity.build([TimeComponent.new(%{age: 0})])

# We trigger the TimeSystem to enumerate over all TimeComponents
# In the real world this could be in response to an event stream such as player input
> TimeSystem.process

# We pull the latest state of the components
> bunny = ECS.Entity.reload(bunny)

# We can repeat this process
> TimeSystem.process
> bunny = ECS.Entity.reload(bunny)

# Modifies an existing entity at runtime by adding a new component to it
bunny = ECS.Entity.add(bunny, TimeComponent.new(%{age: 10}))

# We can repeat this process, and both TimeComponents will receive state updates
TimeSystem.process
bunny = ECS.Entity.reload(bunny)

Моя реализация ECS в Elixir находится в открытом доступе на Github. Можно клонировать и запустить через iex -S mix из корневой папки. На вашем компьютере должен быть установлен Эликсир.

Выполнение

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

defmodule ECS.Entity do
  @moduledoc """
    A base for creating new Entities.
  """

  defstruct [:id, :components]

  @type id :: String.t
  @type components :: list(ECS.Component)
  @type t :: %ECS.Entity{
    id: String.t,
    components: components
  }

  @doc "Creates a new entity"
  @spec build(components) :: t
  def build(components) do
    %ECS.Entity{
      id: ECS.Crypto.random_string(64),
      components: components
    }
  end

  @doc "Add components at runtime"
  def add(%ECS.Entity{ id: id, components: components}, component) do
    %ECS.Entity{
      id: id,
      components: components ++ [component]
    }
  end

  @doc "Pulls the latest component states"
  @spec reload(t) :: t
  def reload(%ECS.Entity{ id: _id, components: components} = entity) do
    updated_components = components
      |> Enum.map(fn %{id: pid} ->
        ECS.Component.get(pid)
      end)

    %{entity | components: updated_components}
  end
end

Ниже приведен реальный объект, Bunny:

# A bunny prefab
defmodule Bunny do
  def new do
    ECS.Entity.build([TimeComponent.new(%{age: 0})])
  end
end

В приведенном выше коде представлена ​​идея "префаба", который представляет собой удобную фабрику для сущностей с общим набором компонентов. Использование префаба избавляет вас от лишнего ввода и действует как фасад.

Компонент

Модули Component и Component.Agent предоставляют средства для получения и установки состояния. Каждый Компонент поддерживается Актером (Агентом — разновидностью GenServer). Такие компоненты, как TimeComponent, реализуют поведение Component (интерфейс).

ddefmodule ECS.Component do
  @moduledoc """
    A base for creating new Components.
  """

  defstruct [:id, :state]

  @type id :: pid()
  @type component_type :: String.t
  @type state :: map()
  @type params :: map()
  @type t :: %ECS.Component{
    id: id, # Component Agent ID
    state: state
  }

  @callback new(state) :: t # Component interface

  defmacro __using__(_options) do
    quote do
      @behaviour ECS.Component # Require Components to implement interface
    end
  end

  @doc "Create a new agent to keep the state"
  @spec new(component_type, state) :: t
  def new(component_type, initial_state) do
    {:ok, pid} = ECS.Component.Agent.start_link(initial_state)
    ECS.Registry.insert(component_type, pid) # Register component for systems to reference
    %{
      id: pid,
      state: initial_state
    }
  end

  @doc "Retrieves state"
  @spec get(id) :: t
  def get(pid) do
    state = ECS.Component.Agent.get(pid)
    %{
      id: pid,
      state: state
    }
  end

  @doc "Updates state"
  @spec update(id, state) :: t
  def update(pid, new_state) do
    ECS.Component.Agent.set(pid, new_state)
    %{
      id: pid,
      state: new_state
    }
  end
end
defmodule ECS.Component.Agent do
  @moduledoc """
    Create a simple Agent that gets and sets.
    Each component instantiates one to keep state.
  """

  @doc "Starts a new bucket. Returns {:status, pid}"
  def start_link(initial_state \\ %{}, opts \\ []) do
    Agent.start_link((fn -> initial_state end), opts)
  end

  @doc "Gets entire state from pid"
  def get(pid) do
    Agent.get(pid, &(&1))
  end

  @doc "Gets a value from the `pid` by `key`"
  def get(pid, key) do
    Agent.get(pid, &Map.get(&1, key))
  end

  @doc "Overwrites state with new_state."
  def set(pid, new_state) do
    Agent.update(pid, &Map.merge(&1, new_state))
  end

  @doc "Updates the `value` for the given `key` in the `pid`"
  def set(pid, key, value) do
    Agent.update(pid, &Map.put(&1, key, value))
  end
end

Ниже приведен фактический компонент TimeComponent, который реализует поведение Component:

defmodule TimeComponent do
  @moduledoc """
    A component for keeping an age for something.
    {id: pid, state: state} = TimeComponent.new(%{age: 1})
  """
  use ECS.Component

  @component_type __MODULE__

  @doc "Initializes and validates state"
  def new(%{age: _age} = initial_state) do
    ECS.Component.new(@component_type, initial_state)
  end
end

Система и реестр

Systems перечисляет все компоненты своего типа.

defmodule TimeSystem do
  @moduledoc """
    Increments ages of TimeComponents
  """

  def process do
    components()
      |> Enum.each(fn (pid) -> dispatch(pid, :increment) end)
  end

  # dispatch() is a pure reducer that takes in a state and an action and returns a new state
  defp dispatch(pid, action) do
    %{id: _pid, state: state} = ECS.Component.get(pid)

    new_state = case action do
      :increment ->
        Map.put(state, :age, state.age + 1)
      :decrement ->
        Map.put(state, :age, state.age - 1)
      _ ->
        state
    end

    IO.puts("Updated #{inspect pid} to #{inspect new_state}")
    ECS.Component.update(pid, new_state)
  end

  defp components do
    ECS.Registry.get(:"Elixir.TimeComponent")
  end
end

Несколько вещей, на которые стоит обратить внимание:

  • dispatch принимает внешнее действие, оценивает его на основе внутреннего правила и возвращает новое состояние. Эта часть во многом была вдохновлена ​​моим опытом работы с редюсерами Redux и update от Elm.
  • Метод components возвращает набор компонентов, которые эта система будет перечислять. Всякий раз, когда компонент создается, он регистрирует своего агента в Registry, который отслеживает все активные компоненты. Реестр сам по себе является действующим лицом, как показано ниже:
defmodule ECS.Registry do
  @moduledoc """
    Component registry.
    iex> {:ok, r} = ECS.Registry.start
    iex> ECS.Registry.insert("test", r)
    :ok
    iex> ECS.Registry.get("test")
    [#PID<0.87.0>]
  """

  def start do
    Agent.start_link(fn -> %{} end, name: __MODULE__)
  end

  def insert(component_type, component_pid) do
    Agent.update(__MODULE__, fn(registry) ->
      components = (Map.get(registry, component_type, []) ++ [component_pid])
      Map.put(registry, component_type, components)
    end)
  end

  def get(component_type) do
    Agent.get(__MODULE__, fn(registry) ->
      Map.get(registry, component_type, [])
    end)
  end
end

И это все!

Эта конкретная реализация ECS может быть немного грубой по краям. Существует множество разновидностей ECS, и это, безусловно, не единственный способ заставить ECS работать. Будем рады вашим отзывам!

В заключение

ECS — это забытый архитектурный шаблон, который преодолевает некоторые недостатки наследования в стиле ООП и отлично подходит для распределенных систем.

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

Спасибо за чтение! Я надеюсь, что вы нашли эту статью полезной или интересной. Дайте мне знать ваши мысли через комментарии ниже!

Первоначально опубликовано на yos.io 17 сентября 2016 г.