Skip to content

Instantly share code, notes, and snippets.

@knewter

knewter/166.md Secret

Created May 31, 2015 01:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save knewter/fc10f3ed0b536356d272 to your computer and use it in GitHub Desktop.
Save knewter/fc10f3ed0b536356d272 to your computer and use it in GitHub Desktop.

Episode 166: Implementing Logo, Part 1

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.

Project

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

Summary

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!

Resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment