The built-in pieces are modules implementing Ascii.Piece, and yours can
be too. A piece module works everywhere a slug does: Ascii.new/2,
Ascii.render/3, Ascii.stream/2, Ascii.Player, the renderers
and the ascii/1 component.
The contract
A piece has three functions:
meta/0returns anAscii.Meta: its name, what you see, its size in cells, its frame rate, the default of every option, and, for a coloured piece, its palette (and a ground, for a scene).init/1receives the options (the defaults merged with what was asked for, already checked) and returns a state. Precompute here.frame/3receives the state, the play timetin seconds and anAscii.Env, and returns{text, colors, state}.
A first piece
defmodule MyApp.Pieces.Wave do
@behaviour Ascii.Piece
@cols 48
@rows 9
@impl true
def meta do
%Ascii.Meta{
name: "wave",
category: :generative,
note: "a sine wave rolling by",
cols: @cols,
rows: @rows,
fps: 20,
options: %{speed: 1.0, crest: "~"},
bounds: %{speed: {0.1, 10}},
palette: ["#1d4e89", "#7fd1ff"]
}
end
@impl true
def init(%{speed: speed, crest: crest}) do
# Options are checked against the defaults, so crest is a string; keep its
# first character, so every line stays @cols wide.
%{speed: speed, crest: crest |> String.codepoints() |> List.first("~")}
end
@impl true
def frame(%{speed: speed, crest: crest} = state, t, env) do
heights = for x <- 0..(@cols - 1), do: round((@rows - 1) * (1 + :math.sin(x / 5 + t * speed)) / 2)
text =
Enum.map_join(0..(@rows - 1), "\n", fn y ->
for h <- heights, into: "", do: if(h == y, do: crest, else: " ")
end)
colors =
if env.color?,
do: for(y <- 0..(@rows - 1), _h <- heights, into: <<>>, do: <<if(y < @rows / 2, do: 1, else: 0)>>)
{text, colors, state}
end
end
{:ok, anim} = Ascii.new(MyApp.Pieces.Wave, options: %{"speed" => "3", "crest" => "≈"})
{frame, _anim} = Ascii.frame(anim, 1.0)
{length(frame.lines), frame.lines |> Enum.map(&String.length/1) |> Enum.uniq()}
#=> {9, [48]}Options come in through Ascii.Options like a built-in piece's: only the
keys in meta.options, of the same kind as their defaults, and numbers
clamped to meta.bounds (or ±1,000,000 without one). Still, init/1 should
make any value it is given safe to draw, as crest is here.
Rules every piece keeps
- Exact size. The text is
rowslines of exactlycolscharacters (code points), joined by"\n", with no newline at the end. Every renderer relies on it.textmay be iodata. - Colours only when asked. Return
nilcolours unless the piece has a palette andenv.color?is true; then a binary ofcols * rowsbytes, row by row, each an index into the palette. Withenv.color?false a coloured piece draws its one-ink version, as text. - Paper. On a light page (
env.paper) dense characters must still mean dark, so a shaded piece reads its ramp from the other end:ramp = if env.paper, do: String.reverse(@ramp), else: @ramp. - A function of its inputs. A frame depends only on the state,
tand the env: no processes, no process dictionary, no wall clock. Pieces that show the time setclock: truein their meta and readenv.now(nilmeans the system clock). Random pieces draw from a seeded generator kept in the state (or seeded from the options), so the same calls give the same frames. - Any
t.tusually grows by1/fps, but a player can pause, skip, or start over. A piece that steps a simulation should step by how fartmoved, cap a long jump, and cope withtgoing backwards. - Cheap frames. Do the expensive work in
init/1. Upstream's budget is 4 ms a frame for text pieces, and 10 ms for 200×100 scenes.
The built-in pieces use :math too, and Ascii.JSMath only where
JavaScript's numbers behave differently from Elixir's (Math.round rounds
halves up, % keeps the dividend's sign), to match upstream. Your own piece
has no upstream to match, so it needs neither.
Options from outside, a size, and live data
A few more things a piece can say, all optional:
boundsin its meta: the range a numeric option is clamped to when it comes from outside, such as%{speed: {0.1, 10}}.shapesin its meta: for an option whose default cannot show what it takes, an example of what it does. An empty list takes strings unless told otherwise, so a list of numbers says%{values: [0.0]}.size/1: the frame's size for the options,{cols, rows}, for a piece that fits its data or a width. Every frame must be that size.update/2: the state for new options while it plays (seeAscii.put_options/2), keeping what it can, so a needle swings on from where it was. Without it,init/1starts afresh.
defmodule MyApp.Pieces.Level do
@behaviour Ascii.Piece
@impl true
def meta do
%Ascii.Meta{
name: "level",
category: :data,
note: "a bar that eases to its value",
cols: 20,
rows: 1,
fps: 20,
options: %{value: 0, width: 20},
bounds: %{value: {0, 100}, width: {4, 200}}
}
end
@impl true
def size(%{width: width}), do: {trunc(width), 1}
@impl true
def init(%{value: value} = options), do: Map.merge(options, %{shown: value * 1.0, at: nil})
# A new value: ease to it from what was shown, when it was shown.
@impl true
def update(state, options), do: options |> init() |> Map.merge(Map.take(state, [:shown, :at]))
# Eases toward the value by how far play time moved since the last frame.
@impl true
def frame(%{value: value, shown: shown, width: width} = state, t, _env) do
step = min(1.0, max(0.0, t - (state.at || t)) * 6)
shown = shown + (value - shown) * step
full = round(trunc(width) * shown / 100)
text = [String.duplicate("█", full), String.duplicate("░", trunc(width) - full)]
{text, nil, %{state | shown: shown, at: t}}
end
end
anim = Ascii.new!(MyApp.Pieces.Level, options: %{value: 100, width: 10})
{frame, anim} = Ascii.frame(anim, 0.0)
frame.text
#=> "██████████"
anim = Ascii.put_options!(anim, value: 0)
{frame, _anim} = Ascii.frame(anim, 0.05)
frame.text
#=> "███████░░░"Testing it
Check the contract over a run of times, in colour and not, on paper and not, including a step backwards:
# needs: your test suite
defmodule MyApp.Pieces.WaveTest do
use ExUnit.Case, async: true
for color <- [true, false], paper <- [false, true] do
test "frames fit the meta (color: #{color}, paper: #{paper})" do
{:ok, anim} = Ascii.new(MyApp.Pieces.Wave, color: unquote(color), paper: unquote(paper))
meta = anim.meta
Enum.reduce([0.0, 0.5, 1.0, 2.5, 10.0, 1.0], anim, fn t, anim ->
{frame, anim} = Ascii.frame(anim, t)
assert length(frame.lines) == meta.rows
assert Enum.all?(frame.lines, &(length(String.codepoints(&1)) == meta.cols))
if unquote(color), do: assert(byte_size(frame.colors) == meta.cols * meta.rows), else: assert(frame.colors == nil)
anim
end)
end
end
endUsing it
alias MyApp.Pieces.Wave
Ascii.render!(Wave, 0.5) |> String.split("\n") |> length()
#=> 9
Ascii.stream!(Wave) |> Enum.take(3) |> Enum.map(& &1.t)
#=> [0.0, 0.05, 0.1]Pass the module wherever a slug goes, in a template:
<Ascii.Phoenix.Components.ascii piece={MyApp.Pieces.Wave} />
<Ascii.Phoenix.player socket={@socket} id="wave" piece={MyApp.Pieces.Wave} />or by name to the mix task, which looks for it among the modules of your project and its dependencies:
mix ascii.play MyApp.Pieces.Wave --option speed=3
A piece module reaches the player's LiveView in its session, which only your code writes. A string there, as from params, is always a slug: nothing from outside can name a module.