<section style="align-items: center; display: grid; grid-gap: 1em; grid-template-columns: 1fr 3fr; margin-top: 1.83em;">
<img src="assets/logo.svg" style="margin-right: 2em;">
<div>
<h1 style="margin-top: 0;">DevJoy</h1>
<p>Easier creation of the Visual Novel-like games or presentations.</p>
<div style="display: flex; gap: 0.5em; flex-wrap: wrap;">
<a href="https://hex.pm/packages/dev_joy">
<img src="https://img.shields.io/hexpm/v/dev_joy?label=&color=midnightblue&logo=data:image/png;base64,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&style=for-the-badge" />
</a>
<a href="https://gitlab.com/ramurix-software/dev-joy">
<img src="https://img.shields.io/badge/source-%23333333.svg?logo=gitlab&style=for-the-badge" />
</a>
<a href="https://hexdocs.pm/dev_joy">
<img src="https://img.shields.io/badge/📖 docs-darkgreen?style=for-the-badge" />
</a>
<a href="https://www.apache.org/licenses/LICENSE-2.0">
<img src="https://img.shields.io/badge/Apache 2.0-darkmagenta?logo=apache&style=for-the-badge" />
</a>
<a href="https://elixir-lang.org">
<img src="https://img.shields.io/badge/elixir-%234B275F.svg?logo=elixir&style=for-the-badge" />
</a>
<a href="https://phoenixframework.org">
<img src="https://img.shields.io/badge/phoenix-%23f05423.svg?logo=phoenixframework&logoColor=white&style=for-the-badge" />
</a>
</div>
</div>
</section>
## 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.
<!-- tabs-open -->
### Dependency
Mix dependency:
```elixir
def deps do
[
{:dev_joy, "~> 2.0"}
]
end
```
Rebar dependency:
```erlang
{deps,[
{dev_joy, "~> 2.0"}
]}.
```
### DSL
```elixir
defmodule MyApp.Scenes.Main do
use DevJoy.Scene
part :main, page_title: "Hello!" do
dialog :john_doe, "Hello world"
end
end
```
### Getting data
```elixir
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
```elixir
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
```elixir
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.
```bash
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.
```elixir
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](js/index.html) for more information.
<!-- tabs-close -->
## Example: Importing data from external sources
```elixir
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](https://hexdocs.pm/dev_joy/DevJoy.API.Item.html) to create the DSL for code samples
<!-- tabs-open -->
### Struct
```elixir
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
```elixir
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
```
<!-- tabs-close -->
## Example: Implementing the [Character](https://hexdocs.pm/dev_joy/DevJoy.Character.html) base module for a Discourse forum API
<!-- tabs-open -->
### Forum module
```elixir
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
```elixir
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
```elixir
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
```
<!-- tabs-close -->