View Source Ecspanse.Projection behaviour (ECSpanse v0.7.3)

The Ecspanse.Projection behaviour is used to build state projections. The projections are defined by invoking use Ecspanse.Projection in their module definition.

Projections are used to build models and query the state of application across multiple entities and their components.

They are designed to be created and used by external clients (such as UI libraries, for example Phoenix LiveView),

The Projections are GenServers and the client that creates them is responsible for storing their pid and using it to communicate with them.

The module invoking use Ecspanse.Projection must implement the mandatory Ecspanse.Projection.project/1 callback. This is responsible for querying the state and building the projection struct.

Info

On server initialization, the on_change/3 callback is called with the initial calculated projection as the new_projection, and the default projection struct as the previous_projection. This is executed even if the calculated projection is the same as the default one. As the on_change/3 callback is generally used to send the projection to the client, this ensures that the client receives the initial projection.

Note

The project/2 callback runs every frame, after executing all systems. Many projections with complex queries may have a negative impact on performance.

Options

  • :fields - a list with all the event struct keys and their initial values (if any) For example: [:pos_x, :pos_y, resources_gold: 0, resources_gems: 0]

Examples

The Projection

  defmodule Demo.Projections.Hero do
    use Ecspanse.Projection, fields: [:pos_x, :pos_y, :resources_gold, :resources_gems]

    @impl true
    def project(%{entity_id: entity_id} = _attrs) do
      {:ok, entity} = fetch_entity(entity_id)
      {:ok, pos} = Demo.Components.Position.fetch(entity)
      {:ok, gold} = Demo.Components.Gold.fetch(entity)
      {:ok, gems} = Demo.Components.Gems.fetch(entity)

      struct!(__MODULE__, pos_x: pos.x, pos_y: pos.y, resources_gold: gold.amount, resources_gems: gems.amount)
    end

    @impl true
    def on_change(%{client_pid: pid} = _attrs, new_projection, _previous_projection) do
      # when the projection changes, send it to the client
      send(pid, {:projection_updated, new_projection})
    end
  end

The Client

  #...
  projection_pid = Demo.Projections.Hero.start!(%{entity_id: entity.id, client_pid: self()})

  projection = Demo.Projections.Hero.get!(projection_pid)

  # ...
  def handle_info({:projection_updated, projection}, state) do
    # received every time the projection changes
    # ...
  end

  # ...
  Demo.Projections.Hero.stop(projection_pid)

Summary

Implemented Callbacks

Gets the projection struct by providing the server pid.

Starts a new projection server and returns its pid.

Stops the projection server by its pid.

Callbacks

Optional callback that is executed every time the projection changes.

The project/1 callback is responsible for querying the state and building the projection struct.

Optional callback that allows the projection to run only when certain conditions are met. This is useful for expensive projections that are not always needed.

Implemented Callbacks

@callback get!(projection_pid :: pid()) :: projection :: struct()

Gets the projection struct by providing the server pid.

Implemented Callback

This callback is implemented by the library and can be used as such.

Examples

  %Demo.Projection.Hero{} = Demo.Projections.Hero.get!(projection_pid)
@callback start!(attrs :: map()) :: projection_pid :: pid()

Starts a new projection server and returns its pid.

It takes a single attrs map argument.

Info

The attrs map is passed to the Ecspanse.Projection.project/1 and Ecspanse.Projection.on_change/3 callbacks.

The caller is responsible for storing the returned pid.

Implemented Callback

This callback is implemented by the library and can be used as such.

Examples

  projection_pid = Demo.Projections.Hero.start!(%{entity_id: entity.id, client_pid: self()})
@callback stop(projection_pid :: pid()) :: :ok

Stops the projection server by its pid.

Implemented Callback

This callback is implemented by the library and can be used as such.

Examples

  Demo.Projections.Hero.stop(projection_pid)

Callbacks

Link to this callback

on_change(attrs, new_projection, previous_projection)

View Source (optional)
@callback on_change(
  attrs :: map(),
  new_projection :: struct(),
  previous_projection :: struct()
) :: any()

Optional callback that is executed every time the projection changes.

It takes the attrs map argument passed to Ecspanse.Projection.start!/1, the new projection and the previous projection structs as arguments. The return value is ignored.

Examples

  @impl true
  def on_change(%{client_pid: pid} = _attrs, new_projection, _previous_projection) do
    send(pid, {:projection_updated, new_projection})
  end
@callback project(attrs :: map()) :: projection :: struct()

The project/1 callback is responsible for querying the state and building the projection struct.

It takes the attrs map argument passed to Ecspanse.Projection.start!/1. It must return the projection struct.

Examples

    @impl true
    def project(%{entity_id: entity_id} = _attrs) do
      {:ok, entity} = fetch_entity(entity_id)
      {:ok, pos} = Demo.Components.Position.fetch(entity)
      {:ok, gold} = Demo.Components.Gold.fetch(entity)
      {:ok, gems} = Demo.Components.Gems.fetch(entity)

      struct!(__MODULE__, pos_x: pos.x, pos_y: pos.y, resources_gold: gold.amount, resources_gems: gems.amount)
    end
Link to this callback

run?(attrs, current_projection)

View Source (optional)
@callback run?(attrs :: map(), current_projection :: struct()) :: boolean()

Optional callback that allows the projection to run only when certain conditions are met. This is useful for expensive projections that are not always needed.

It takes the attrs map argument passed to Ecspanse.Projection.start!/1 and the current projection struct as arguments. It returns a boolean.

Examples

  @impl true
  @doc "Run the projection only if the hero is alive"
  def run?(%{entity_id: entity_id} = _attrs, _current_projection) do
    with  {:ok, entity} = fetch_entity(entity_id),
          {:ok, hero_comp} = Demo.Components.Hero.fetch(entity) do
      hero.state == :alive
    else
      _ -> false
    end
  end