Introduction

View Source

DevJoy

Easier creation of the Visual Novel-like games or presentations.

Setup

Except drawing you only have to provide a content to the game and fetch it. To "draw" your game all you need to do is to convert structs into hologram or phoenix templates or scenic graphs and style them respectively.

Dependency

Mix dependency:

def deps do
  [
    {:dev_joy, "~> 2.0"}
  ]
end

Rebar dependency:

{deps,[
  {dev_joy, "~> 2.0"}
]}.

DSL

defmodule MyApp.Scenes.Main do
  use DevJoy.Scene

  part :main, page_title: "Hello!" do
    dialog :john_doe, "Hello world"
  end
end

Getting data

defmodule MyApp.PageLive do
  use MyAppWeb, :live_view

  alias MyApp.Scenes.Main

  def handle_event("btn-click", _params, socket) do
    Main.get_part_item(:main, 1)
    {:noreply, socket}
  end
end

LiveView

defmodule MyApp.PageLive do
  use MyAppWeb, :live_view
  use DevJoy.Session.LiveView
  use DevJoy.Session.Notifier

  alias DevJoy.Session
  alias MyApp.Scenes.Main

  # …

  def handle_event("btn-click", _params, socket) do
    Session.get({Main, :main, 1})
    {:noreply, socket}
  end

  def handle_info({:dev_joy, :item_changed, item}, socket) do
    {:noreply, assign(socket, item: item)}
  end
end

Render

defmodule MyApp.PageLive do
  use MyAppWeb, :live_view

  # …

  alias DevJoy.Scene.Dialog

  # …

  def render(%{item: %Dialog{}} = assigns) do
    ~H"""
    {@item.content}
    """
  end
end

I18n

By default all template files and their locale translations are stored in i18n directory inside project's priv directory.

PROJECT_PATH="/path/to/project/"
I18N_DIR="i18n"
SCENE_MODULE="MyApp.Scenes.Main"
mkdir -p "$PROJECT_PATH/priv/$I18N_DIR/fr/LC_MESSAGES"
touch "$PROJECT_PATH/priv/$I18N_DIR/fr/LC_MESSAGES/$SCENE_MODULE.po"
xdg-open "$PROJECT_PATH/priv/$I18N_DIR/fr/LC_MESSAGES/$SCENE_MODULE.po"

If translation is not found or is empty the msgid is used instead.

scene = Main
default_locale = "en"
# get locale from cookie, db, headers, params or session

available_locales = scene.get_locales()
verified_locale = if locale in available_locales, do: locale, else: default_locale
DevJoy.API.Storage.set_locale(verified_locale)
assign(conn_or_socket, available_locales: available_locales, locale: locale)

# …
push_navigate(socket, to: some_path_with_locale(socket.assigns.locale))

JS

See JavaScript documentation for more information.

Example: Importing data from external sources

Mix.install [:dev_joy, :nimble_csv]

priv =
  __DIR__
  |> Path.absname()
  |> Path.join("script_priv")
  |> tap(&File.mkdir_p!/1)

defmodule MainScene do
  use DevJoy.Scene, priv: priv

  alias NimbleCSV.RFC4180, as: CSV

  data = CSV.parse_string("""
  string id,dialog content
  john_doe,Hello world!
  """)

  part :main do
    for [name, content] <- data do
      # Note: prefer String.to_existing_atom/1 instead
      dialog String.to_atom(name), content
    end
  end
end

dialog = MainScene.get_part_item(:main, 1)
# => %DevJoy.Scene.Dialog{…}

dialog.character.id
# => :john_doe

dialog.content
# => "Hello world!"

Example: Using Item API to create the DSL for code samples

Struct

defmodule MyApp.CodeSample do
  @moduledoc """
  The code sample is visually represented as a snipped containing the highlighted text

  > ### Usage {: .info}
  > 
  > The [`code sample`](`t:t/0`) always requires its [`content`](`t:content/0`) and [`language`](`t:lang/0`).
  > You can also optionally specify additional [`data`](`t:data/0`).
  > 
  >     defmodule MyApp.Scenes.Main do
  >       use DevJoy.Scene
  >
  >       import MyApp.DSL
  > 
  >       part :main do
  >         code :elixir, ~S[IO.puts("Hello world!")]
  > 
  >         code :elixir, [some: :data], ~S[IO.puts("Hello world!")]
  >     
  >         code :elixir, [some: :data] do
  >           ~S[IO.puts("Hello world!")]
  >         end
  >       end
  >     end
  """

  @enforce_keys ~w[content lang]a

  @doc """
  The [`code sample`](`t:t/0`) structure contains the following keys:

  - [`content`](`t:content/0`) - a content of the code sample
  - [`data`](`t:data/0`) - a keyword list of additional code sample data
  - [`lang`](`t:lang/0`) - a programming language of the code sample
  """
  defstruct [:content, :lang, data: []]

  @typedoc """
  The type representing the [`code sample`](`__struct__/0`) structure.
  """
  @typedoc section: :main
  @type t :: %__MODULE__{
          content: content(),
          data: data(),
          lang: lang()
        }

  @typedoc """
  The type representing the content of the code sample.
  """
  @typedoc section: :field
  @type content :: String.t()

  @typedoc """
  The type representing the additional data of the code sample.
  """
  @typedoc section: :field
  @type data :: Keyword.t()

  @typedoc """
  The type representing the lang of the code sample.
  """
  @typedoc section: :field
  @type lang :: atom
end

DSL

defmodule MyApp.DSL do
  alias MyApp.CodeSample

  @doc """
  A callback implementations generator for the `MyApp.CodeSample` struct.

  > ### Usage {: .info}
  >     defmodule MyApp.Scenes.Main do
  >       use DevJoy.Scene
  >
  >       import MyApp.DSL
  > 
  >       part :main do
  >         code :elixir, ~S[IO.puts("Hello world!")]
  > 
  >         code :elixir, [some: :data], ~S[IO.puts("Hello world!")]
  >     
  >         code :elixir, [some: :data] do
  >           ~S[IO.puts("Hello world!")]
  >         end
  >       end
  >     end
  """
  defmacro code(lang, data \\ [], content)

  @spec code(
    Scene.macro_input(CodeSample.lang()),
    Scene.macro_input(CodeSample.data()),
    do: Scene.macro_input(CodeSample.content())
  ) :: Macro.output()
  defmacro code(lang, data, do: block) do
    fields = [content: block, data: data, lang: lang]
    Item.generate(CodeSample, fields, __CALLER__, [:content])
  end

  @spec code(
    Scene.macro_input(CodeSample.lang()),
    Scene.macro_input(CodeSample.data()),
    Scene.macro_input(CodeSample.content())
  ) :: Macro.output()
  defmacro code(lang, data, content) do
    fields = [content: content, data: data, lang: lang]
    Item.generate(CodeSample, fields, __CALLER__, [:content])
  end
end

Example: Implementing the Character base module for a Discourse forum API

Forum module

defmodule MyApp.Forum do
  @moduledoc "Fetches forum data using username and Discourse API."

  @behaviour DevJoy.Character

  alias DevJoy.API.Item
  alias DevJoy.Character

  @avatar_size "48"

  @impl DevJoy.Character
  def get_character_fields(id) do
    {:ok, _app_list} = Application.ensure_all_started(:req)
    fetch_forum_data(id)
  end

  @spec fetch_forum_data(Character.id()) :: Item.fields()
  defp fetch_forum_data(id) do
    :dev_joy
    |> Application.get_env(__MODULE__, endpoint: "https://elixirforum.com")
    |> Keyword.fetch!(:endpoint)
    |> then(fn endpoint ->
      endpoint
      |> Path.join("u/#{id}.json")
      |> Req.get!()
      |> then(& &1.body["user"])
      |> then(&[avatar: avatar_from_template(&1["avatar_template"], endpoint), full_name: &1["name"]])
    end)
  end

  @spec avatar_from_template(String.t(), String.t()) :: Character.portrait()
  defp avatar_from_template(template, endpoint) do
    template
    |> String.replace("{size}", @avatar_size)
    |> then(&Path.join(endpoint, &1))
  end
end

Cache module

defmodule MyApp.Forum.Cache do
  @moduledoc "Caches character base fields using `Agent`."

  alias DevJoy.API.Item
  alias DevJoy.Character

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

  @spec get(Character.id()) :: Item.fields() | nil
  def get(id), do: Agent.get(__MODULE__, & &1[id])

  @spec put(Character.id(), Item.fields()) :: :ok
  def put(id, data), do: Agent.update(&Map.put(&1, id, data))
end

Forum module with ForumCache support

defmodule MyApp.Forum do
  @moduledoc "Fetches forum data using username and Discourse API."

  @behaviour DevJoy.Character

  alias DevJoy.API.Item
  alias DevJoy.Character
  alias MyApp.Forum.Cache

  @avatar_size "48"

  @impl DevJoy.Character
  def get_character_fields(id) do
    # The cache has to be started at compile-time when defined in same app
    # and this function is called by scene DSL at compile-time
    Process.whereis(Cache) || Cache.start_link()

    id
    |> Cache.get()
    |> then(&(&1 || fetch_forum_data(id)))
  end

  @spec fetch_forum_data(Character.id()) :: Item.fields()
  defp fetch_forum_data(id) do
    {:ok, _app_list} = Application.ensure_all_started(:req)

    :dev_joy
    |> Application.get_env(__MODULE__, endpoint: "https://elixirforum.com")
    |> Keyword.fetch!(:endpoint)
    |> then(fn endpoint ->
      endpoint
      |> Path.join("u/#{id}.json")
      |> Req.get!()
      |> then(& &1.body["user"])
      |> then(&[avatar: avatar_from_template(&1["avatar_template"], endpoint), full_name: &1["name"]])
    end)
    |> tap(&Cache.put(id, &1))
  end

  @spec avatar_from_template(String.t(), String.t()) :: Character.avatar()
  defp avatar_from_template(template, endpoint) do
    template
    |> String.replace("{size}", @avatar_size)
    |> then(&Path.join(endpoint, &1))
  end
end