Entity-Component-System (ECS) adalah pola desain arsitektur terdistribusi dan komposisi yang banyak digunakan dalam pengembangan game. Hal ini memungkinkan pemisahan perilaku spesifik domain secara fleksibel, yang mengatasi banyak kelemahan warisan berorientasi objek tradisional.

Elixir adalah bahasa dinamis dan fungsional yang dibangun di atas Erlang VM yang dirancang untuk membangun aplikasi yang skalabel dan dapat dipelihara.

Dalam artikel ini, temukan bagaimana kita dapat menggunakan ECS dan Elixir dalam pendekatan baru untuk menyusun program kita di luar paradigma pewarisan berbasis kelas.

Kode sumber untuk implementasi ECS saya di Elixir adalah sumber terbuka di Github.

Kelemahan warisan berbasis kelas

Cara tradisional untuk membangun dunia permainan adalah dengan memiliki hierarki objek permainan berorientasi objek yang memodelkan dunia. Namun, bahkan objek sederhana pun dapat memiliki sejumlah besar fungsi yang tidak terpakai. Perhatikan contoh di bawah ini:

Kami sedang membangun mesin permainan, dan kami mendapati hierarki kelas di bawah ini:

Kami memiliki basis GameObject, yang disubkelaskan oleh Animal. Animal disubkelaskan oleh Bunny dan Whale, masing-masing dengan perilaku khusus masing-masing hop() dan swim(). Kami juga memiliki Killer Whale, yang merupakan subkelas dari Whale daripada kill().

Mari kita coba memperkenalkan hewan baru ke dunia kita:

Kami ingin Killer Bunny dapat menjadi hop() dan kill(), tetapi Killer Bunny harus mewarisi kelas mana?

Untuk bahasa/platform yang hanya memiliki satu warisan, kami kurang beruntung. Kita harus memindahkan hop() dan kill() ke beberapa superclass seperti Animal yang kemudian dapat diwarisi oleh Killer Bunny. Namun, semua subkelas Animal lainnya akan mewarisi hal-hal yang tidak mereka perlukan. Whale mewarisi hop(); Bunny mewarisi swim() dan kill(). Seiring waktu, Animal akan menjadi objek dewa dengan serangkaian perilaku yang sangat besar.

Warisan berganda juga tidak berhasil. Misalkan Killer Bunny mewarisi dari Bunny dan Killer Whale. Killer Bunny akan mewarisi swim(), yang merupakan fungsi yang tidak diperlukan.

Kami menghadapi sejumlah masalah lain:

Fungsi kaku: Hanya Killer Whale yang dapat kill(). Kita tidak bisa berubah pikiran nanti dan membuat hewan lain kill() dengan sangat mudah. Perilaku hanya tersedia untuk kelas yang diberi kode khusus untuk mendukung perilaku tersebut. Seiring bertambahnya jumlah entitas game, kami menghadapi kesulitan yang lebih besar dalam menemukan tempat dalam hierarki untuk menempatkan entitas baru.

Masalah Berlian: “Masalah berlian” (terkadang disebut sebagai “berlian kematian yang mematikan”) adalah ambiguitas yang muncul ketika dua kelas B dan C mewarisi dari A, dan kelas D mewarisi dari keduanya. B dan C. Jika ada metode di A yang telah ditimpa oleh B dan C, dan D tidak menimpanya, maka versi metode manakah yang diwarisi oleh D: versi B, atau versi C?

Antipola Blob: Dengan pewarisan, game akan berakhir dengan kelas root tunggal yang besar atau node daun lainnya dengan banyak fungsi. Subkelas menjadi terbebani dengan fungsionalitas yang tidak dibutuhkan.

Ringkasan

Kesulitan yang disebutkan di atas telah mengganggu pengembang game sejak lama, dan Entity Component Systems berupaya mengatasi gangguan ini. Kita akan belajar tentang ECS ​​di bagian selanjutnya.

Sistem Komponen Entitas

Ada tiga abstraksi utama dalam ECS: Entitas, Komponen, dan Sistem.

Kami akan memeriksa masing-masing secara detail, mulai dari Component.

Komponen

Kualitas atau aspek suatu entitas.

Komponen adalah objek data minimal yang dapat digunakan kembali dan dihubungkan ke entitas untuk mendukung beberapa perilaku. Komponen menandai entitas dengan satu kualitas. Komponen itu sendiri tidak memiliki perilaku. Biasanya, ini diimplementasikan sebagai struct atau kamus.

Bayangkan kita memiliki Bunny entitas di dunia kita:

Kita dapat mendefinisikan kelinci tidak lebih dari sebuah agregasi/kumpulan komponen independen. Pada contoh di atas, Kelinci 'terdiri' dari komponen seperti Physical dan Seeing.

Setiap komponen mendukung beberapa perilaku. Sebagai ilustrasi, Seeing memiliki atribut sight_radius untuk mendukung perilaku penglihatan. Namun perlu diperhatikan, bahwa Komponen itu sendiri tidak memiliki perilaku. Setiap komponen hanyalah sebuah objek data minimal.

Kesatuan

Agregasi atau wadah komponen.

Entitas hanyalah penjumlahan dari komponen-komponennya. Entitas diimplementasikan sebagai ID unik global yang terkait dengan kumpulan Komponen. Perhatikan bahwa Entitas itu sendiri tidak memiliki data atau perilaku aktual. Setiap Komponen memberikan data Entitas untuk mendukung beberapa perilaku.

Mari kita lihat Bunny kita lagi:

Lihat kotak putus-putus di sekitar komponen kita? Itu adalah entitas Bunny - tidak lebih dari sebuah wadah komponen. Kita dapat mendefinisikan entitas sebagai agregasi dari subset komponen apa pun, seperti ini Carrot:

Dan ini Ghost:

Suatu entitas tidak lebih dari sekedar kumpulan komponen.

Beberapa implementasi ECS memungkinkan Anda mengubah kumpulan komponen entitas pada waktu proses. Hal ini memungkinkan Anda untuk “bermutasi” entitas dengan cepat. Misalnya, kita dapat memiliki komponen Poisoned yang membuat entitas yang diberi tag dengan komponen ini kehilangan kesehatan seiring waktu. Kita dapat menambah dan menghilangkan komponen ini secara dinamis untuk menimbulkan dan menyembuhkan racun. Anda mungkin juga mendapatkan efek status 'buta' yang menghapus Seeing komponen entitas yang terkena dampaknya.

Hingga saat ini, kami belum menyentuh logika atau perilaku apa pun. Entitas hanyalah kumpulan komponen; Komponen hanyalah objek data. Dari mana asal perilaku di ECS? Mereka berasal dari Sistem.

Sistem

Sistem menghidupkan entitas dan komponen.

Sistem menghitung Komponen atau kelompok Komponen, memperbarui statusnya berdasarkan aturan internal atau peristiwa eksternal. Cara untuk memikirkan Perilaku adalah sebagai perubahan dari satu keadaan ke keadaan lainnya. Mari kita lihat contohnya:

Perilaku: “Kelinci di pohon jatuh karena gravitasi.”

Bagaimana kita menerapkan perilaku di atas? Kita dapat membuatnya agar Placeable Komponen dengan nilai z lebih dari 0 berkurang seiring waktu menjadi 0.

Perilaku: “Makhluk hidup menua.”

Bagaimana kita menerapkan perilaku di atas? Kita dapat membuatnya agar Living Komponen memiliki nilai age yang meningkat seiring waktu.

Kami menciptakan Sistem khusus untuk setiap perilaku yang ingin kami dukung. A GravitySystem menyebutkan seluruh Placeable Komponen; a TimeSystem menyebutkan seluruh Living komponen. Ingatlah bahwa Sistem beroperasi pada Komponen, bukan Entitas.

Aliran Data dalam Entitas-Komponen-Sistem

Untuk lebih memperkuat pemahaman Anda tentang pola ini, mari kita lihat aliran data umum dalam arsitektur ini:

Setiap Sistem mendengarkan beberapa aliran peristiwa seperti waktu atau masukan pemain, dan memperbarui status Komponennya sebagai respons terhadap peristiwa tersebut dan beberapa aturan internal. Keadaan yang terus berubah ini tersedia untuk diakses oleh Entitas yang menjadi bagiannya, dan dengan demikian menghasilkan perilaku.

Contoh lain: Misalkan pemain menekan tombol “pindah ke kiri”. PlayerInputSystem mengeksekusi dan mendeteksi penekanan tombol, memperbarui komponen Motion. MotionSystem mengeksekusi dan "melihat" Gerakan entitas ke kiri, menerapkan gaya Fisika ke kiri. RenderSystem mengeksekusi dan membaca posisi entitas saat ini, dan menggambarnya sesuai dengan definisi Spasial (yang mungkin mencakup informasi wilayah tekstur/animasi).

Pengantar Sistem Entitas

Analogi spreadsheet

Cara lain untuk memikirkan ECS adalah sebagai tabel relasional, seperti spreadsheet:

Sistem Komponen Entitas dapat divisualisasikan sebagai tabel dengan kolom komponen dan baris entitas. Untuk mengoperasikan satu komponen, kami memilih kolomnya dan melihat setiap sel. Untuk mengoperasikan suatu entitas, kami memilih barisnya dan melihat setiap sel.

Keuntungan ECS

Sekarang kita memiliki pemahaman yang lebih baik tentang arsitektur Entitas-Komponen-Sistem, mari kita pikirkan bagaimana pendekatan ini dibandingkan dengan pewarisan berbasis kelas.

Prinsip pemisahan yang baik, tanggung jawab tunggal: setiap perilaku atau domain dipisahkan satu sama lain dalam komponen dan/atau sistem independen. Tidak seperti objek dewa monolitik dalam pewarisan berbasis kelas, kita dapat mengekstrak subset fungsionalitas apa pun dan merakitnya dalam kombinasi apa pun. ECS juga mendorong antarmuka kecil.

Definisi objek composability dan runtime: Semua jenis objek game dapat dibuat dengan menambahkan komponen yang benar ke suatu entitas. Hal ini juga memungkinkan pengembang untuk dengan mudah menambahkan fitur dari satu jenis objek ke jenis objek lainnya, tanpa masalah ketergantungan apa pun. Misalnya, kita bisa melakukan Entity.build([FlyingComponent, SeeingComponent]) saat runtime.

Dapat diuji: Setiap komponen dan sistem menurut definisinya adalah sebuah unit. Kami juga dapat mengganti komponen dengan komponen tiruan atau demo untuk pengujian.

Dapat Diparalelkan: Dalam banyak implementasi ECS di dunia nyata seperti MMO, Sistem diimplementasikan sebagai sistem terdistribusi atau kumpulan pekerja yang dapat mendistribusikan pekerjaan di antara mereka sendiri. Hal ini memungkinkan kami menskalakan ukuran simulasi secara horizontal dengan meningkatkan jumlah pekerja sistem di kumpulan kami.

Pemisahan data dan perilaku: komponen menyimpan data, sistem menyimpan perilaku. Tidak ada pembauran keduanya. Properti ini memungkinkan Anda memasang dan memainkan perilaku berbeda untuk diterapkan pada data yang sama.

Tantangan ECS

Terlepas dari fleksibilitas yang diberikan, ECS menimbulkan sejumlah tantangan yang tidak sepele:

ECS adalah pola yang relatif tidak diketahui: Karena pola desain ini sebagian besar terbatas pada pengembangan game, mendiskusikan cara menggunakan ECS untuk domain di luar ECS seperti untuk membuat aplikasi web dapat menjadi tantangan. Ada sedikit sumber daya yang tersedia untuk menerapkan pola ini ke domain lain, jika ada.

Menangani komunikasi antarproses: Bagaimana kita menangani komunikasi antara sistem dan komponen? Kami memerlukan semacam bus pesan atau sistem terbitkan-langganan agar bagian-bagian ECS kami dapat berkomunikasi satu sama lain. Tergantung pada bahasa atau platform di mana ECS diterapkan, hal ini dapat menyebabkan lonjakan kompleksitas. Biaya iterasi melalui komponen dan entitas juga dapat mengakibatkan penurunan kinerja.

Komunikasi antar-komponen: Apa yang terjadi ketika sistem perlu mengakses dan memodifikasi data di beberapa komponen? Komponen mungkin perlu berbagi status dengan komponen lain dan berkomunikasi satu sama lain sebelum berkomunikasi dengan sistem. Misalnya, kita memiliki komponen Position dan Sound dalam suatu entitas. Kita dapat memiliki PositionalSoundSystem yang perlu berkomunikasi dengan kedua komponen. Kami mungkin memerlukan saluran terpisah untuk komunikasi antar komponen guna mendukung kasus penggunaan ini.

Komunikasi antar sistem: Apa yang terjadi jika dua sistem perlu mengakses dan memodifikasi komponen yang sama? Katakanlah kita memiliki dua sistem: satu mengalikan atribut x dengan -1, yang lain menambahkan x dengan 10. Tergantung pada urutan penerapan kedua sistem, hasil akhirnya akan berbeda. Kecuali operasinya bersifat asosiatif, kita mungkin perlu memperkenalkan cara untuk memastikan bahwa urutan sistem sudah benar.

Tidak didefinisikan secara konkrit seperti pola desain lain seperti MVC: Ada banyak cara untuk mengimplementasikan ECS. Setiap bahasa atau platform akan memiliki abstraksi berbeda yang tersedia, sehingga menghasilkan varian ECS yang berbeda.

ECS di dunia nyata

Selain sebagai arsitektur populer untuk video game, aplikasi ECS saat ini juga digunakan untuk simulasi terdistribusi skala besar. Ini termasuk lalu lintas kota secara real-time, jaringan telekomunikasi internet, dan simulasi fisika. Ini juga digunakan untuk membangun backend multipemain besar-besaran untuk video game dengan jumlah entitas yang sangat besar.

Salah satu startup khususnya, sedang membangun ECS-as-a-service yang disebut SpatialOS.

Implementasi ECS di Elixir

Di bagian ini, kita akan melihat salah satu kemungkinan implementasi Sistem Komponen Entitas di Elixir. Saya akan mulai dengan menyebutkan secara singkat mengapa Elixir (juga Erlang) dan primitif konkurensinya cocok untuk pola ECS.

Mulai saat ini, karena Elixir dikompilasi ke bytecode Erlang, yang saya maksud dengan Elixir adalah Erlang.

Model Aktor

Salah satu abstraksi utama Elixir adalah proses - proses ini serupa dengan aktor dalam model aktor. Aktor adalah entitas komputasi yang dapat:

  • Kirim pesan
  • Terima pesan
  • Melahirkan aktor-aktor baru

Pada diagram di atas, Aktor A mengirimkan pesan 1 dan 2 ke Aktor C, yang kemudian diterimanya. Menanggapi pesan-pesan ini, Aktor C dapat mengirimkan pesan baru, atau menelurkan aktor baru dan menunggu untuk menerima pesan dari aktor tersebut.

# 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 juga memiliki abstraksi tingkat tinggi untuk membangun aktor yang disebut 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

Pertimbangkan bagaimana Anda dapat mengimplementasikan ECS dengan bantuan para aktor.

Contoh Penggunaan

Berikut tampilan penerapan kami saat digunakan:

# 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)

Implementasi ECS saya di Elixir adalah open source di Github. Anda dapat mengkloning dan menjalankannya melalui iex -S mix dari folder root. Anda harus menginstal Elixir di mesin Anda.

Penerapan

Entitas adalah struct dengan string acak id dan daftar komponen. Kita dapat membuat entitas dan memperluasnya dengan menambahkan komponen. Keduanya dapat dilakukan saat runtime.

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

Di bawah ini adalah entitas sebenarnya, Bunny:

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

Kode di atas memperkenalkan gagasan 'prefab', yang merupakan pabrik yang nyaman untuk entitas dengan kumpulan komponen yang sama. Menggunakan cetakan membuat Anda tidak perlu mengetik terlalu banyak, dan bertindak sebagai fasad.

Komponen

Modul Component dan Component.Agent menyediakan fasilitas untuk mendapatkan dan mengatur status. Setiap Komponen didukung oleh Aktor (Agen - semacam GenServer.) Komponen seperti TimeComponent mengimplementasikan perilaku Component (antarmuka.)

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

Di bawah ini adalah komponen sebenarnya, TimeComponent, yang mengimplementasikan perilaku 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

Sistem dan Registri

Sistem menyebutkan semua komponen dari jenisnya.

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

Beberapa hal yang perlu diperhatikan:

  • dispatch mengambil tindakan eksternal, mengevaluasinya berdasarkan aturan internal, dan mengembalikan keadaan baru. Bagian ini sebagian besar terinspirasi oleh pengalaman saya dengan reduksi Redux dan update Elm.
  • Metode components mengembalikan kumpulan komponen yang akan dihitung oleh Sistem ini. Setiap kali Komponen dipakai, ia mendaftarkan agennya ke Registry yang melacak semua komponen aktif. Registry itu sendiri adalah seorang aktor, seperti yang ditunjukkan di bawah ini:
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

Dan itu saja!

Implementasi ECS khusus ini mungkin agak sulit. Ada banyak jenis ECS, dan ini tentu saja bukan satu-satunya cara untuk membuat ECS berfungsi. Masukan Anda sangat kami harapkan!

Sebagai penutup

ECS adalah pola arsitektur yang diabaikan yang mengatasi beberapa kelemahan pewarisan gaya OOP, dan sangat cocok untuk sistem terdistribusi.

Bercabang ke domain asing (seperti pengembangan game) adalah sumber ide dan pola baru yang bermanfaat untuk menulis perangkat lunak yang lebih baik.

Terima kasih sudah membaca! Saya harap artikel ini bermanfaat atau menarik bagi Anda. Beri tahu saya pendapat Anda melalui komentar di bawah!

Awalnya diterbitkan di yos.io pada 17 September 2016.