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

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)

      result = struct!(__MODULE__, pos_x: pos.x, pos_y: pos.y, resources_gold: gold.amount, resources_gems: gems.amount)
      {:ok, result}
    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.

Implemented Callbacks

@callback get!(projection_pid :: pid()) :: t()

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

  %Ecspanse.Projection{state: :ok, result: %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)

Types

@type projection_result() :: projection_result_success() | any() | nil
Link to this type

projection_result_success()

View Source
@type projection_result_success() :: struct()
@type projection_state() :: :loading | :ok | :error | :halt
@type t() :: %Ecspanse.Projection{
  error?: boolean(),
  halted?: boolean(),
  loading?: boolean(),
  ok?: boolean(),
  result: projection_result(),
  state: projection_state()
}

Callbacks

Link to this callback

on_change(attrs, new_projection, previous_projection)

View Source (optional)
@callback on_change(attrs :: map(), new_projection :: t(), previous_projection :: t()) ::
  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()) ::
  :loading
  | {:loading, any()}
  | {:ok, projection_result_success()}
  | :error
  | {:error, any()}
  | :halt

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 one of the following:

  • :loading - the projection is in the :loading state, the result is nil
  • {:loading, result :: any()} - the projection is in the :loading state, the result is the given value
  • {:ok, success_projection :: struct()} - the projection is in the :ok state, the result is the implemented projection struct
  • :error - the projection is in the :error state, the result is nil
  • {:error, result :: any()} - the projection is in the :error state, the result is the given value
  • :halt - the projection is in the :halt state, the result is the existing projection result

Info

Returning :halt is very useful for expensive projections that need to run just in certain conditions. For example, a projection that calculates the distance between two entities should only run when both entities exist. If one of the entities is removed, the projection can be set to :halt and should not be recalculated until the entity is added again.

Examples

    @impl true
    def project(%{entity_id: entity_id} = _attrs) do
      # ...
      cond do
        enemy_entity_missing?(entity_id) ->
          :loading
        enemy_location_component_missing?(entity_id) ->
          :halt
        true ->
          {:ok, struct!(__MODULE__, pos_x: comp_pos.x, pos_y: comp_pos.y}}
      end
    end

    # the client

    <div :if={@enemy_projection.loading?}>Spinner</div>
    <div :if={@enemy_projection.ok?}>Show Enemy</div>