Current section
Files
Jump to
Current section
Files
lib/dev_joy/scene.ex
# credo:disable-for-this-file Credo.Check.Refactor.ModuleDependencies
defmodule DevJoy.Scene do
@moduledoc """
Scene defines DSL which stores scene parts within `c:get_part/0` and `c:get_part/1` function.
## Examples
In order to use the `DevJoy` DSL simply add `use DevJoy.Scene` at the top of your module
defmodule MyApp.SceneName do
use DevJoy.Scene
# parts DSL goes here
end
The optional translation is done at runtime, so you can use it as same as any other module.
To enable runtime translation all you need to do is to pass your gettext module as shown below.
defmodule MyApp.Gettext do
use Gettext, otp_app: :my_app
end
defmodule MyApp.SceneWithGettext do
use DevJoy.Scene, gettext_module: MyApp.Gettext
# parts DSL goes here
end
"""
alias DevJoy.Attribute
alias DevJoy.Character
alias DevJoy.Gettext
alias DevJoy.Scene
alias DevJoy.Scene.Asset
alias DevJoy.Scene.Element.Challenge
alias DevJoy.Scene.Element.Choice
alias DevJoy.Scene.Element.Dialog
alias DevJoy.Scene.Element.Menu
alias DevJoy.Scene.Part
alias Macro.Env
@typedoc "Fields to cast into struct"
@type struct_fields :: Keyword.t()
@typedoc "Module to which fields are casted"
@type struct_module :: module
@doc "Returns a main part of scene"
@callback get_part() :: Part.t()
@doc "Returns a part of scene"
@callback get_part(Part.name()) :: Part.t()
@doc false
@spec __using__(opts :: Keyword.t()) :: Macro.output()
defmacro __using__(opts \\ [otp_app: :dev_joy]) do
quote do
import Scene, only: :macros
require Attribute
Attribute.put(:gettext_module, unquote(opts[:gettext_module]))
@behaviour Scene
@impl Scene
def get_part, do: get_part(:main)
end
end
@doc "A DSL for `#{inspect(Asset)}` struct"
@spec asset(Asset.path(), Asset.data()) :: Macro.output()
defmacro asset(path, data \\ []) do
update_attribute(:elements, __CALLER__, Asset, data: data, path: path)
end
@doc "A DSL for `#{inspect(Challenge)}` struct"
@spec challenge(Challenge.type(), Challenge.data()) :: Macro.output()
defmacro challenge(type, data) do
update_attribute(:elements, __CALLER__, Challenge, data: data, type: type)
end
@doc "A DSL for `#{inspect(Choice)}` struct"
@spec choice(Choice.content(), Macro.input()) :: Macro.output()
defmacro choice(content, action) do
update_attribute(:choices, __CALLER__, Choice, action: action, content: content)
end
@doc """
Returns a quoted anonymous function to place it within `c:get_part/1` function.
Once called, function fetches character and passes it along with choices
to the function defined in second argument.
The function in 2nd argument should call an action for one of passed choices.
This allows to follow a choice based on a runtime condition.
Unlike in `menu/2` or `question/4` the choices are not supposed to be displayed as buttons
and therefore their content could be an atom and so since it does not need to be translated
it should simplify the condition in passed function.
## Example
defmodule MyApp do
def some_func(_character, choices) do
Enum.random(choices).action()
end
end
defmodule MyApp.SceneWithCondition do
use DevJoy.Scene
part :condition do
condition :john_doe, &MyApp.some_func/2 do
choice :choiceA, goto(:partA)
choice :choiceB, goto(:partB)
end
end
part :partA do
# elements for part A goes here
end
part :partB do
# elements for part B goes here
end
end
"""
@spec condition(Character.id(), (Character.t(), [Choice.t(atom)] -> term), do: Macro.input()) ::
Macro.output()
defmacro condition(character_id, func, do: block) do
quote do
Attribute.put(:choices, [])
unquote(block)
character_id = unquote(character_id)
func = unquote(Macro.escape(func))
:choices
|> Attribute.delete_and_reverse()
|> DevJoy.escape_all_but_anonymous_function_ast()
|> then(fn choices ->
quote bind_quoted: [character_id: character_id, choices: choices, func: func] do
fn ->
character_id
|> Character.get_fields!()
|> then(&struct(Character, &1))
|> then(&func.(&1, choices))
end
end
end)
|> then(&Attribute.update(:elements, fn list -> [&1 | list] end))
end
end
@doc "A DSL for `#{inspect(Dialog)}` struct"
@spec dialog(Character.id(), Dialog.side(), Dialog.content()) :: Macro.output()
defmacro dialog(character_id, side \\ :left, content) when side in ~w[left right]a do
character_id
|> Character.get_fields!()
|> then(
&update_attribute(:elements, __CALLER__, Dialog, character: &1, content: content, side: side)
)
end
@doc "The same as `goto/2`, but defaults to current scene and `:main` part."
@spec goto :: Macro.output()
defmacro goto, do: do_goto(__CALLER__.module, :main)
@doc "The same as `goto/2`, but defaults to current scene."
@spec goto(Part.name()) :: Macro.output()
defmacro goto(part_name), do: do_goto(__CALLER__.module, part_name)
@doc """
Returns a quoted anonymous function to place it within `c:get_part/1` function.
Once called, function returns a data of specified part.
This allows to easily navigate to other part or even scene.
## Example
defmodule MyApp.SceneName do
use DevJoy.Scene
part :partA do
goto(:partB)
end
part :partB do
goto(MyApp.AnotherSceneName, :partA)
end
end
"""
@spec goto(Part.scene(), Part.name()) :: Macro.output()
defmacro goto(scene, part_name), do: do_goto(scene, part_name)
@spec do_goto(Part.scene(), Part.name()) :: Macro.output()
defp do_goto(scene, part_name) do
func =
scene
|> then("e do: fn -> unquote(&1).get_part(unquote(part_name)) end)
|> Macro.escape()
quote bind_quoted: [func: func] do
if Attribute.has?(:choices) do
func
else
Attribute.update(:elements, fn list -> [func | list] end)
end
end
end
@doc "A DSL for `#{inspect(Menu)}` struct"
@spec menu(Menu.title(), do: Macro.input()) :: Macro.output()
defmacro menu(title, do: block) do
update_elements(block, __CALLER__, Menu, title: title)
end
@doc "A DSL for `#{inspect(Part)}` struct"
@spec part(Part.name(), do: Macro.input()) :: Macro.output()
defmacro part(name, do: block) do
[
quote do
Attribute.put(:elements, [])
Attribute.put(:current_part_name, unquote(name))
unquote(block)
Attribute.delete(:current_part_name)
@impl Scene
end,
quote bind_quoted: [name: name] do
[data: Attribute.delete_and_reverse(:elements), name: name, scene: __MODULE__]
|> then(&struct(Part, &1))
|> DevJoy.escape_all_but_anonymous_function_ast()
|> then(fn ast ->
gettext_module = Attribute.get(:gettext_module)
if gettext_module do
def get_part(unquote(name)) do
Gettext.nested_translate(unquote(ast), unquote(gettext_module))
end
else
def get_part(unquote(name)), do: unquote(ast)
end
end)
end
]
end
@doc """
A DSL for `#{inspect(Dialog)}` struct with support for choices
## Example
defmodule MyApp.SceneWithQuestion do
use DevJoy.Scene
part :question do
question :john_doe, "Example question" do
choice "Example answer", goto(:part_name)
end
end
end
"""
@spec question(Character.id(), Dialog.side(), Dialog.content(), do: Macro.input()) ::
Macro.output()
defmacro question(character_id, side \\ :left, content, do: block) when side in ~w[left right]a do
character_id
|> Character.get_fields!()
|> then(
&update_elements(block, __CALLER__, Dialog, character: &1, content: content, side: side)
)
end
@spec update_attribute(:choices | :elements, Env.t(), struct_module, struct_fields) ::
Macro.output()
defp update_attribute(name, caller, module, fields) do
quote do
backend = Attribute.get(:gettext_module)
fields = unquote(fields)
module = unquote(module)
if backend do
caller = unquote(Macro.escape(caller))
msgctxt = Attribute.get(:current_part_name)
Gettext.extract(backend, __MODULE__, msgctxt, caller, module, fields)
end
fields
|> Keyword.update(:character, nil, &struct(Character, &1))
|> then(&struct(module, &1))
|> then(&fn list -> [&1 | list] end)
|> then(&Attribute.update(unquote(name), &1))
end
end
@spec update_elements(Macro.input(), Env.t(), struct_module, struct_fields) :: Macro.output()
defp update_elements(block, caller, module, fields) do
quote do
backend = Attribute.get(:gettext_module)
fields = unquote(fields)
module = unquote(module)
if backend do
caller = unquote(Macro.escape(caller))
msgctxt = Attribute.get(:current_part_name)
Gettext.extract(backend, __MODULE__, msgctxt, caller, module, fields)
end
Attribute.put(:choices, [])
unquote(block)
[{:choices, Attribute.delete_and_reverse(:choices)} | fields]
|> Keyword.update(:character, nil, &struct(Character, &1))
|> then(&struct(module, &1))
|> then(&Attribute.update(:elements, fn list -> [&1 | list] end))
end
end
end