In Episode 164 we built a module to evaluate L systems. The episode was titled fractals, but until we take the next step it's not immediately clear why. The next step is to generate Logo programs from the resulting output. There are no Logo interpreters that I can find for Erlang or Elixir, so I suppose we'll just go ahead and build our own. Let's get started.
So I've built a basic app that can render to a wx canvas, using Extris' window
module as a foundation. You can find it tagged before_episode_166
in the logo
repo I've linked to in the resources section. Let's look at the README.
((( do it )))
So here you can see a couple of example bits of code. We're going to implement the second bit. We don't need a parser yet to show our fractals, but we'll probably build a parser for the logo language later. For now, we're just going to provide functions into a genserver that provide the same basic semantics.
Let's open up the test file and start defining the genserver:
# We want to have an instance for each of our tests, so let's refactor this to
# use a setup...
setup do
{:ok, pid} = Logo.Instance.start
{:ok, pid: pid}
end
test "can start a logo instance", meta do
assert is_pid(meta[:pid])
end
Alright, now let's start to implement the functions from the README. The first thing we need to support is setting the color.
alias Logo.Instance
#...
test "can set the color", meta do
turtle = meta[:pid]
|> Instance.color({255, 0, 0})
|> Instance.get_turtle
assert {255, 0, 0} = turtle.color
end
Alright, so we get an error. Let's open up the Logo.Instance module and add the functions necessary for this to pass:
defmodule Logo.Instance do
use GenServer
alias __MODULE__
# So we know we need a turtle to store the color
defmodule Turtle do
defstruct [
color: nil
]
end
# Public API
def start do
# We want to pass in a new instance of our server's turtle when we start it
# up
GenServer.start(Instance, %Turtle{}, [])
end
# We'll implement the public color function. Note that we're returning the
# pid so that this will be a chainable API
def color(pid, {r, g, b}) do
:ok = GenServer.cast(pid, {:color, {r, g, b}})
pid
end
# `get_turtle` won't be part of the chainable API since it's a call rather than a
# cast.
def get_turtle(pid) do
GenServer.call(pid, :get_turtle)
end
# Server callbacks
def init(turtle) do
{:ok, turtle}
end
# Handling the color cast is trivial
def handle_cast({:color, {r, g, b}}, turtle) do
{:noreply, %Turtle{turtle|color: {r, g, b}}}
end
# So is the get_turtle call
def handle_call(:get_turtle, _from, turtle) do
{:reply, turtle, turtle}
end
end
Alright, run the tests, and they should pass. This isn't a complicated bit of
code at all at this point, so I don't really think it deserves any explanation.
We'll power through and implement the rest of the API mentioned in the README.
Next is pen_down
.
test "can put the pen down", meta do
turtle = meta[:pid]
|> Instance.pen_down
|> Instance.get_turtle
assert turtle.pen_down
end
There's our test. We'll implement it rapidly.
defmodule Turtle do
# Here we're just going to track a boolean that specifies whether the pen is
# down or not
defstruct [
color: nil,
pen_down: false
]
end
#...
# And a function to put it down
def pen_down(pid) do
:ok = GenServer.cast(pid, :pen_down)
pid
end
#...
# And in our handle_cast we'll update the turtle to put the pen down
def handle_cast(:pen_down, turtle) do
{:noreply, %Turtle{turtle|pen_down: true}}
end
Run the tests and they'll pass.
Next we need to implement forward. This requires us to start tracking x and y coordinates for our turtle. We'll start with the test:
test "can move forward", meta do
turtle = meta[:pid]
|> Instance.forward(400)
|> Instance.get_turtle
assert 400 = turtle.x
end
If we run the test, obviously there's no forward function. We'll add it:
# We need to add coordinates for the turtle
defmodule Turtle do
defstruct [
color: nil,
pen_down: false,
x: 0,
y: 0
]
end
# Here's the public function
def forward(pid, amount) do
:ok = GenServer.cast(pid, {:forward, amount})
pid
end
# And the function that updates the state
def handle_cast({:forward, amount}, turtle) do
{:noreply, %Turtle{turtle|x: turtle.x + amount}}
end
Run the tests and they'll pass. There are two other real issues to handle. The first is that we need to handle modifying the angle at which the turtle is pointing. This is easy enough and continues to be a typical basic state update. Let's add the test:
test "can rotate", meta do
turtle = meta[:pid]
|> Instance.right(90)
|> Instance.get_turtle
assert 90 = turtle.angle
turtle = meta[:pid]
|> Instance.right(360)
|> Instance.get_turtle
assert 90 = turtle.angle
end
Alright, so there's no right
function yet so we'll add that, along with an
angle key in the Turtle:
defmodule Turtle do
defstruct [
color: nil,
pen_down: false,
x: 0,
y: 0,
angle: 0
]
end
def right(pid, angle) do
:ok = GenServer.cast(pid, {:right, angle})
pid
end
def handle_cast({:right, angle}, turtle) do
# Now this is the only quirky bit. Since we always want this to be
# positive, I'm adding 360 to whatever it is. Then I'm using rem to make
# sure it cycles at 360.
angle = rem(360 + turtle.angle + angle, 360)
{:noreply, %Turtle{turtle|angle: angle}}
end
Now we can run the tests and they'll pass. It's not in the readme, but let's go
ahead and implement a left
function as well:
test "can rotate", meta do
turtle = meta[:pid]
|> Instance.right(90)
|> Instance.get_turtle
assert 90 = turtle.angle
turtle = meta[:pid]
|> Instance.right(360)
|> Instance.get_turtle
assert 90 = turtle.angle
turtle = meta[:pid]
|> Instance.left(20)
|> Instance.get_turtle
assert 70 = turtle.angle
end
Run the tests and they'll fail. Now we implement it:
# Now we don't want to implement a `left` cast, since our `right` cast was
# really just setting a delta on the angle. We'll change its name and then
# just use the same server API to implement left
def right(pid, angle) do
:ok = GenServer.cast(pid, {:angle_delta, angle})
pid
end
def left(pid, angle) do
:ok = GenServer.cast(pid, {:angle_delta, -1 * angle})
pid
end
def handle_cast({:angle_delta, angle}, turtle) do
angle = rem(360 + turtle.angle + angle, 360)
{:noreply, %Turtle{turtle|angle: angle}}
end
Run the tests, and they'll pass. Now this is almost good enough. However, when we go forward we just increment X. That's not right. We need to do trig here folks! First we'll need a function to convert degrees to radians:
def radians(degrees) do
degrees * (:math.pi/180)
end
And I'm adding a test after the fact to show that this works, although honestly this has been proved by things a lot more rigorous than our test suite :)
test "converting degrees to radians" do
assert (3 * :math.pi)/2 == Instance.radians(270)
end
I know that's not the right place for this function, but it'll have to do for now. Next we need to add a test that makes the turtle walk forward at an angle and verifies that it ends up at the right place. The easiest thing to do is to turn him 45 degrees and walk him (square root of 2) forward. This should place him at the coordinates (1, 1).
test "walking at an angle", meta do
turtle = meta[:pid]
|> Instance.right(45)
|> Instance.forward(:math.sqrt(2))
|> Instance.get_turtle
assert 1 = turtle.x
assert 1 = turtle.y
end
Run the tests, and they fail because our forward function is broken and awful. Let's fix it with math.
def handle_cast({:forward, amount}, turtle) do
delta_x = amount * :math.cos(radians(turtle.angle))
delta_y = amount * :math.sin(radians(turtle.angle))
{:noreply, %Turtle{turtle|x: turtle.x + delta_x, y: turtle.y + delta_y}}
end
So here we're moving our turtle forward by the appropriate amount based on its
current angle. If we run our tests, they'll fail because floats aren't precise.
Let's change them to use assert_in_delta
instead.
test "can move forward", meta do
turtle = meta[:pid]
|> Instance.forward(400)
|> Instance.get_turtle
assert_in_delta(400, turtle.x, 0.00001)
end
test "walking at an angle", meta do
turtle = meta[:pid]
|> Instance.right(45)
|> Instance.forward(:math.sqrt(2))
|> Instance.get_turtle
assert_in_delta(1, turtle.x, 0.00001)
assert_in_delta(1, turtle.y, 0.00001)
end
This is conceptually what we need to handle the logo language as far as our current use of it goes. However, it doesn't, you know, draw anything yet. Logo without drawing is pointless, right?
As I'd mentioned, I already built a basic wx canvas before we started this episode. But honestly, even though I was a math major, doing trig again makes me tired. So we'll start drawing the turtle's tracks on that canvas in the next episode - but you should probably go ahead and try to implement it as an exercise for yourself. See you soon!