Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: [#24] Add food for snake #29

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use Mix.Config
config :snake, :viewport, %{
name: :main_viewport,
size: {704, 608},
default_scene: {Snake.Scene.Game4, nil},
default_scene: {Snake.Scene.Game5, nil},
drivers: [
%{
module: Scenic.Driver.Glfw,
Expand Down
Binary file added docs/images/05-add-food-for-snake.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions docs/tutorial/02-draw-a-snake.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,17 +213,17 @@ state = %{

snake = %{body: [{9, 9}, {10, 9}, {11, 9}]}

graph = draw_object(@graph, snake)
graph = draw_snake(@graph, snake)

{:ok, state, push: graph}
```

To keep things organized, we'll implement our own `draw_object/2` helper function which will turn our abstract snake object into actual drawable objects for Scenic. The function takes two arguments: the graph and our snake object.
To keep things organized, we'll implement our own `draw_snake/2` helper function which will turn our abstract snake object into actual drawable objects for Scenic. The function takes two arguments: the graph and our snake object.

For each square of the snake's body, we fill a cell on our grid.

```elixir
defp draw_object(graph, %{body: snake}) do
defp draw_snake(graph, %{body: snake}) do
Enum.reduce(snake, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
Expand Down
8 changes: 4 additions & 4 deletions docs/tutorial/03-let-the-snake-move.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def init(_arg, opts) do
}

snake = %{body: [{11, 9}, {10, 9}, {9, 9}]}
graph = draw_object(@graph, snake)
graph = draw_snake(@graph, snake)

# start timer
{:ok, _timer} = :timer.send_interval(@frame_ms, :frame)
Expand Down Expand Up @@ -61,10 +61,10 @@ state = %{
}
```

We'll also turn our `draw_object/2` function into a `draw_objects/2` function. Instead of just the snake object, we'll pass the whole state as the second argument.
We'll also need to adapt our `draw_snake/2` function to pattern match on the whole state as argument instead of just the snake object.

```elixir
defp draw_objects(graph, %{snake: %{body: body}}) do
defp draw_snake(graph, %{snake: %{body: body}}) do
Enum.reduce(body, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
Expand Down Expand Up @@ -136,7 +136,7 @@ The next step will be to update our `handle_info/2` callback with the function w
```elixir
def handle_info(:frame, state) do
new_state = move_snake(state)
graph = draw_objects(@graph, new_state)
graph = draw_snake(@graph, new_state)

{:noreply, new_state, push: graph}
end
Expand Down
151 changes: 151 additions & 0 deletions docs/tutorial/05-add-food-for-snake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
### 5. Add food for snake

Our snake can move free, but it's still missing an another important piece of the game: the food pellet.

The food pellet is basically an object composed by one single tile and therefore can be represented by a pair of coordinates `{x,y}`.

In order to introduce it, we need to:

- Add the food to the initial state of the game, its coordinates must not overlap with the snake's body.
- Draw the food pellet each game tick as we are doing for the snake body, but its value won't change (at least for the moment).

For addressing the first point, we can simply update the initial state in the `init/2` function, and that's all.

```elixir
def init(_arg, opts) do
viewport = opts[:viewport]

{:ok, %ViewPort.Status{size: {vp_width, vp_height}}} = ViewPort.info(viewport)

number_of_columns = div(vp_width, @tile_size)
number_of_rows = div(vp_height, @tile_size)

state = %{
width: number_of_columns,
height: number_of_rows,
snake: %{body: [{9, 9}, {10, 9}, {11, 9}], direction: {1, 0}}
pellet: %{5, 5}
}

# start timer
{:ok, _timer} = :timer.send_interval(@frame_ms, :frame)

{:ok, state, push: @graph}
end
```

Then, we need to update our application to draw the food pellet each game tick. If you look at the `init/2` function above, there is a timer that periodically sends a message, this is intercepted by our `handle_info/2` callback, which is in charge of moving the snake and then drawing it.

👆We need to update the `handle_info/2` to draw the food pellet as well.

```elixir
def handle_info(:frame, state) do
new_state = move_snake(state)
graph = @graph
|> draw_snake(new_state)
|> draw_pellet(new_state)

{:noreply, new_state, push: graph}
end

# Pellet is simply a coordinate pair
defp draw_pellet(graph, %{pellet: {x, y}}) do
draw_tile(graph, x, y, fill: :orange)
end

# Snake's body is a list of coordinate pairs
defp draw_snake(graph, %{snake: %{body: body}}) do
Enum.reduce(body, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
end
```

Yay! Everything now is in place, let's run the game and verify that works.

$ mix scenic.run

Great! But there is room for minor improvements. The `draw_snake` and `draw_pellet` functions have some traits in common, maybe we can generalize them.

```elixir
def handle_info(:frame, state) do
new_state = move_snake(state)
graph = @graph
|> draw_object(:snake, new_state)
|> draw_object(:pellet, new_state)

{:noreply, new_state, push: graph}
end

# Pellet is simply a coordinate pair
defp draw_object(graph, :pellet, %{pellet: {x, y}}) do
draw_tile(graph, x, y, fill: :orange)
end

# Snake's body is a list of coordinate pairs
defp draw_object(graph, :snake, %{snake: %{body: body}}) do
Enum.reduce(body, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
end
```

Ok, but we can do better. What if we update the state to keep all the objects that need to be drawn at each game tick under the key `:object`, then we can map over them and draw them with the help of the pattern matching. Easier to do than to explain.

```elixir
def init(_arg, opts) do
viewport = opts[:viewport]

{:ok, %ViewPort.Status{size: {vp_width, vp_height}}} = ViewPort.info(viewport)

number_of_columns = div(vp_width, @tile_size)
number_of_rows = div(vp_height, @tile_size)

state = %{
width: number_of_columns,
height: number_of_rows,
objects: %{
snake: %{body: [{9, 9}, {10, 9}, {11, 9}], direction: {1, 0}}
pellet: %{5, 5}
},
}

# start timer
{:ok, _timer} = :timer.send_interval(@frame_ms, :frame)

{:ok, state, push: @graph}
end

# ...

def handle_info(:frame, state) do
new_state = move_snake(state)
graph = @graph |> draw_objects(new_state.objects)

{:noreply, new_state, push: graph}
end

defp draw_objects(graph, objects) do
Enum.reduce(objects, graph, fn {type, object}, graph ->
draw_object(graph, type, object)
end)
end

# Pellet is simply a coordinate pair
defp draw_object(graph, :pellet, {x, y}) do
draw_tile(graph, x, y, fill: :orange)
end

# Snake's body is a list of coordinate pairs
defp draw_object(graph, :snake, %{body: body}) do
Enum.reduce(body, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
end
```

And here we are, probably let's indirect than before, but we are exploring the elixir power here! 💪

Ah, and don't forget to change all the functions that read or update the snake body since we nest it under the `:object` key in the state.

![Add food for snake](./../images/05-add-food-for-snake.gif)
4 changes: 2 additions & 2 deletions lib/scenes/current/game2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ defmodule Snake.Scene.Game2 do

snake = %{body: [{9, 9}, {10, 9}, {11, 9}]}

graph = draw_object(@graph, snake)
graph = draw_snake(@graph, snake)

{:ok, state, push: graph}
end

defp draw_object(graph, %{body: snake}) do
defp draw_snake(graph, %{body: snake}) do
Enum.reduce(snake, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
Expand Down
4 changes: 2 additions & 2 deletions lib/scenes/current/game3.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ defmodule Snake.Scene.Game3 do

def handle_info(:frame, state) do
new_state = move_snake(state)
graph = draw_objects(@graph, new_state)
graph = draw_snake(@graph, new_state)

{:noreply, new_state, push: graph}
end
Expand Down Expand Up @@ -62,7 +62,7 @@ defmodule Snake.Scene.Game3 do
{x, y}
end

defp draw_objects(graph, %{snake: %{body: body}}) do
defp draw_snake(graph, %{snake: %{body: body}}) do
Enum.reduce(body, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
Expand Down
4 changes: 2 additions & 2 deletions lib/scenes/current/game4.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ defmodule Snake.Scene.Game4 do

def handle_info(:frame, state) do
new_state = move_snake(state)
graph = draw_objects(@graph, new_state)
graph = draw_snake(@graph, new_state)

{:noreply, new_state, push: graph}
end
Expand Down Expand Up @@ -62,7 +62,7 @@ defmodule Snake.Scene.Game4 do
{x, y}
end

defp draw_objects(graph, %{snake: %{body: body}}) do
defp draw_snake(graph, %{snake: %{body: body}}) do
Enum.reduce(body, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
Expand Down
116 changes: 116 additions & 0 deletions lib/scenes/current/game5.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Game at the end of Chapter 5
defmodule Snake.Scene.Game5 do
use Scenic.Scene

import Scenic.Primitives, only: [rounded_rectangle: 3]

alias Scenic.ViewPort
alias Scenic.Graph

@graph Graph.build(clear_color: :dark_sea_green)
@tile_size 32
@tile_radius 8
@frame_ms 192

def init(_arg, opts) do
viewport = opts[:viewport]

{:ok, %ViewPort.Status{size: {vp_width, vp_height}}} = ViewPort.info(viewport)

number_of_columns = div(vp_width, @tile_size)
number_of_rows = div(vp_height, @tile_size)

state = %{
width: number_of_columns,
height: number_of_rows,
objects: %{
pellet: {5, 5},
snake: %{body: [{9, 9}, {10, 9}, {11, 9}], direction: {1, 0}}
}
}

# start timer
{:ok, _timer} = :timer.send_interval(@frame_ms, :frame)

{:ok, state, push: @graph}
end

def handle_info(:frame, state) do
new_state = move_snake(state)
graph = @graph |> draw_objects(new_state.objects)

{:noreply, new_state, push: graph}
end

defp move_snake(%{objects: %{snake: snake}} = state) do
%{body: body, direction: direction} = snake

# new head's position
[head | _] = body
new_head = move(state, head, direction)

# place a new head on the tile that we want to move to
# and remove the last tile from the snake tail
new_body = List.delete_at([new_head | body], -1)

state
|> put_in([:objects, :snake, :body], new_body)
end

defp move(%{width: w, height: h}, {pos_x, pos_y}, {vec_x, vec_y}) do
# We use the remainder function `rem` to make the snake appear from the opposite side
# of the screen when it reaches the limits of the graph.
x = rem(pos_x + vec_x + w, w)
y = rem(pos_y + vec_y + h, h)
{x, y}
end

defp draw_objects(graph, objects) do
Enum.reduce(objects, graph, fn {type, object}, graph ->
draw_object(graph, type, object)
end)
end

# Pellet is simply a coordinate pair
defp draw_object(graph, :pellet, {x, y}) do
draw_tile(graph, x, y, fill: :orange)
end

# Snake's body is a list of coordinate pairs
defp draw_object(graph, :snake, %{body: body}) do
Enum.reduce(body, graph, fn {x, y}, graph ->
draw_tile(graph, x, y, fill: :dark_slate_gray)
end)
end

# draw tiles as rounded rectangles to look nice
defp draw_tile(graph, x, y, opts) do
tile_opts = Keyword.merge([fill: :black, translate: {x * @tile_size, y * @tile_size}], opts)
rounded_rectangle(graph, {@tile_size, @tile_size, @tile_radius}, tile_opts)
end

# Keyboard controls
def handle_input({:key, {"left", :press, _}}, _context, state) do
{:noreply, update_snake_direction(state, {-1, 0})}
end

def handle_input({:key, {"right", :press, _}}, _context, state) do
{:noreply, update_snake_direction(state, {1, 0})}
end

def handle_input({:key, {"up", :press, _}}, _context, state) do
{:noreply, update_snake_direction(state, {0, -1})}
end

def handle_input({:key, {"down", :press, _}}, _context, state) do
{:noreply, update_snake_direction(state, {0, 1})}
end

# Ignore all the other inputs
def handle_input(_input, _context, state), do: {:noreply, state}

# Change the snake's current direction.
defp update_snake_direction(state, direction) do
put_in(state, [:objects, :snake, :direction], direction)
end
end