if Code.ensure_loaded?(Phoenix.LiveComponent) and Code.ensure_loaded?(Jason) do
defmodule DicExWeb.DiceRoller do
@moduledoc """
A self-contained LiveComponent that renders the pixel-art 3D dice roller.
Drop it into any LiveView (inline or as a modal) and it manages its own state:
the current expression, the quick dice tray, and the last roll result. The 3D
animation is driven by the `DiceRoller` JavaScript hook which lives in
`priv/static/dic_ex.min.js`.
## Usage
# in your LiveView template (inline)
<.live_component module={DicExWeb.DiceRoller} id="dice-roller" />
# as a modal — wrap it however you like
<.modal :if={@show_roller}>
<.live_component module={DicExWeb.DiceRoller} id="dice-roller" />
</.modal>
## Options
* `default` — initial expression (default `"1d20"`).
* `theme` — `"obsidian"` (default), `"arcane"` or `"dnd"`. Tunes the pixel palette.
* `engine` — `"3d"` (default; Three.js + Rapier physics) or `"2d"` (canvas,
no physics: the die tumbles in 2D and lands on the authoritative value).
Both engines share the exact same Elixir roll; this only swaps the hook.
* `rng` — RNG module for the rolls (default `nil` ⇒ `DicEx.RNG.Default`,
reproducible via `:seed`). Pass `DicEx.RNG.Entropy` for cryptographic,
non-replayable randomness. Applies to whichever engine is selected.
* `on_roll` — a `pid` (or registered name) to notify of every roll via
`send/2` with `{:dic_ex_rolled, %{result: result, component: id}}`.
The host uses this to feed the AI game master the structured outcome.
## Receiving rolls
<.live_component module={DicExWeb.DiceRoller} id="roller"
on_roll={self()} />
def handle_info({:dic_ex_rolled, %{result: result}}, socket) do
# result is a %DicEx.Result{}; feed its JSON map to the LLM
{:noreply, socket}
end
"""
use Phoenix.LiveComponent
# The component is only defined when Phoenix LiveView is available so the
# pure-rolling core stays usable without any web dependency.
alias DicEx.{Result, Theme}
defp quick_dice, do: ~w(d4 d6 d8 d10 d12 d20)
# The LiveView hook name is what actually swaps the render engine. Both
# hooks speak the same dic_ex:roll / dic_ex:settled contract, so the rest of
# the component (state, roll logic, reveal) is identical for 2D and 3D.
defp hook_name("2d"), do: "DiceRoller2D"
defp hook_name(_), do: "DiceRoller"
# Theme can be a built-in name or a custom palette map. We resolve it once
# into the chrome CSS vars (inline) and the canvas palette (JSON for the JS
# hooks), so hosts can skin the roller without touching the bundle.
defp resolve_theme(theme) do
palette = Theme.resolve(theme)
name = if is_binary(theme) or is_atom(theme), do: to_string(theme), else: nil
{palette, name}
end
defp style_vars(palette) do
palette
|> Theme.css_vars()
|> Enum.map_join("; ", fn {k, v} -> "#{k}: #{v}" end)
end
@impl true
def render(assigns) do
~H"""
<div
id={@id}
class={["dicex-roller", "dicex-engine-#{@engine}", @theme_class]}
style={@style_vars}
phx-hook={hook_name(@engine)}
data-palette={@canvas_palette}
phx-target={@myself}
>
<div
class="dicex-stage"
id={"#{@id}-stage"}
data-dicex-stage
phx-update="ignore"
>
</div>
<div class="dicex-controls">
<div class="dicex-tray">
<button
:for={d <- quick_dice()}
type="button"
class="dicex-die-btn"
title={"añadir " <> d}
phx-click="add-die"
phx-target={@myself}
phx-value-die={d}
>
{d}
</button>
</div>
<form phx-submit="roll" phx-target={@myself} class="dicex-form">
<input
class="dicex-input"
type="text"
name="expression"
value={@expression}
placeholder="1d4+2d6, 2d20kh1+5, 8d6! ..."
autocomplete="off"
/>
<button type="button" class="dicex-clear-btn" phx-click="clear" phx-target={@myself}>
limpiar
</button>
<button type="submit" class="dicex-roll-btn">Tirar</button>
</form>
</div>
<div class="dicex-result" :if={@rolling}>
<div class="dicex-total dicex-rolling-total">tirando…</div>
</div>
<div class="dicex-result" :if={not @rolling and @result}>
<div class="dicex-total">{@result.total}</div>
<div class="dicex-breakdown">
<span :for={r <- breakdown(@result)} class={["dicex-pill", r.kept && "dicex-kept", !r.kept && "dicex-dropped"]}>
{r.value}
</span>
</div>
<div class="dicex-expression">{@result.expression}</div>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:expression, "1d20")
|> assign(:result, nil)
|> assign(:pending_result, nil)
|> assign(:rolling, false)
|> assign(:roll_nonce, 0)
|> assign_theme("obsidian")
|> assign(:engine, "3d")
|> assign(:rng, nil)
|> assign(:on_roll, nil)}
end
# Collapse a theme option into the three assigns the template needs:
# the resolved palette, the optional built-in class name, and the JSON
# canvas palette shipped to the JS hooks.
defp assign_theme(socket, theme) do
{palette, name} = resolve_theme(theme)
socket
|> assign(:theme, theme)
|> assign(:theme_class, if(name, do: "dicex-theme-#{name}"))
|> assign(:style_vars, style_vars(palette))
|> assign(:canvas_palette, Jason.encode!(Theme.canvas_palette(palette)))
end
# Fallback only: if the dice never report settling (scene failed to init,
# tab was backgrounded, ...) reveal anyway after a generous timeout. The
# "dic_ex:settled"/"dic_ex:landed" events are the primary, in-sync paths.
@reveal_fallback 6000
# The fallback timer carries the roll's nonce so it can't overwrite a later
# roll's result if it fires stale (e.g. roll A settled, roll B started,
# then A's timer wakes).
@impl true
def update(%{reveal: result, nonce: nonce}, socket) do
{:ok,
if(socket.assigns.roll_nonce == nonce, do: maybe_reveal(socket, result), else: socket)}
end
def update(%{id: id} = opts, socket) do
socket =
socket
|> assign(:id, id)
|> assign_theme(Map.get(opts, :theme, "obsidian"))
|> assign(:engine, Map.get(opts, :engine, "3d"))
|> assign(:rng, Map.get(opts, :rng))
|> assign(:on_roll, Map.get(opts, :on_roll))
socket =
case Map.fetch(opts, :default) do
{:ok, default} -> assign(socket, :expression, default)
:error -> socket
end
{:ok, socket}
end
# Tray buttons accumulate dice into the hand, so mixed pools like
# "1d4+2d6" build up naturally (click d4, d6, d6). Repeated clicks on the
# same plain trailing term increment its count instead of stacking terms.
@impl true
def handle_event("add-die", %{"die" => die}, socket) do
{:noreply, assign(socket, :expression, append_die(socket.assigns.expression, die))}
end
def handle_event("clear", _params, socket) do
{:noreply, assign(socket, :expression, "")}
end
# Primary reveal path: the JS hook reports that every die has finished its
# settle animation, so we reveal the result exactly in sync with the dice.
# The event name carries this component's id so several rollers can coexist
# in the same LiveView without cross-firing each other's reveal.
def handle_event("dic_ex:settled:" <> _id, _params, socket) do
{:noreply, maybe_reveal(socket, socket.assigns[:pending_result])}
end
# Physics-truth (3D) reveal: the hook reports the face values that actually
# landed up. We re-evaluate the expression with those values piped through
# the deterministic RNG, so modifiers (kh/dl/explode…) are still applied and
# the revealed result matches exactly what the player sees on the table.
def handle_event("dic_ex:landed:" <> _id, %{"values" => values}, socket) do
result = reroll_with(socket.assigns.expression, values)
{:noreply, maybe_reveal(socket, result)}
end
def handle_event("roll", %{"expression" => expression}, socket) do
perform_roll(expression, socket)
end
def handle_event("roll", _params, socket) do
perform_roll(socket.assigns.expression, socket)
end
defp perform_roll(expression, socket) do
case DicEx.roll_e(expression, roll_opts(socket.assigns[:rng])) do
{:ok, result} ->
# animation kicks off immediately; the result is revealed when the dice
# report settling/landing, with a fallback timer as safety. The nonce
# tags this roll so the timer can't reveal a stale result.
id = socket.assigns.id
nonce = socket.assigns.roll_nonce + 1
Task.start(fn ->
Process.sleep(@reveal_fallback)
send_update(__MODULE__, %{id: id, reveal: result, nonce: nonce})
end)
{:noreply,
socket
|> assign(:rolling, true)
|> assign(:result, nil)
|> assign(:pending_result, result)
|> assign(:roll_nonce, nonce)
|> push_roll(result)}
{:error, reason} ->
{:noreply, push_event(socket, error_event(socket.assigns.id), %{message: reason})}
end
end
defp roll_opts(nil), do: []
defp roll_opts(rng), do: [rng: rng]
# Re-evaluates the expression forcing the supplied face values as the base
# rolls (used by the physics-truth 3D engine so the result matches the
# landed dice). Explodes that need more rolls than provided fall back to 1.
defp reroll_with(expression, values) do
DicEx.roll(expression, rng: {DicEx.RNG.Deterministic, values})
end
# Per-instance event names. `push_event/3` broadcasts to every hook on the
# LiveView, so we suffix the roll/error channels with the component id and
# each hook only listens for its own. (`dic_ex:theme` stays global — a theme
# change should reach every roller on the page.)
defp roll_event(id), do: "dic_ex:roll:#{id}"
defp error_event(id), do: "dic_ex:error:#{id}"
defp push_roll(socket, %Result{} = result) do
push_event(socket, roll_event(socket.assigns.id), Result.to_roll_event(result))
end
# Reveal the pending result exactly once. Guards against the settle event
# and the fallback timer both firing, and against a stale pending result.
defp maybe_reveal(socket, nil), do: socket
defp maybe_reveal(%{assigns: %{rolling: false}} = socket, _result), do: socket
defp maybe_reveal(socket, result) do
notify_host(socket, result)
socket
|> assign(:result, result)
|> assign(:rolling, false)
|> assign(:pending_result, nil)
end
@rolled_msg :dic_ex_rolled
defp notify_host(socket, %Result{} = result) do
payload = {@rolled_msg, %{result: result, component: socket.assigns.id}}
case socket.assigns[:on_roll] do
nil -> :ok
{name, node} when is_atom(name) and is_atom(node) -> send({name, node}, payload)
target when is_pid(target) or is_atom(target) -> send(target, payload)
end
end
defp append_die("", die), do: "1" <> die
defp append_die(expr, die) do
sides = String.trim_leading(die, "d")
case Regex.run(~r/(\d+)d#{sides}$/, expr) do
[full, count] ->
String.replace(expr, full, "#{String.to_integer(count) + 1}d#{sides}", global: false)
_ ->
expr <> "+1" <> die
end
end
# Flattens the result into a render-friendly list of {value, kept} pills.
defp breakdown(%Result{groups: groups}) do
Enum.flat_map(groups, fn
%{kind: :dice, rolls: rolls} -> Enum.map(rolls, &%{value: &1.value, kept: &1.kept})
%{kind: :modifier, subtotal: n} -> [%{value: n, kept: true}]
end)
end
end
end