diff --git a/config/config.exs b/config/config.exs index 47d53c4..abef014 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/docs/images/05-add-food-for-snake.gif b/docs/images/05-add-food-for-snake.gif new file mode 100644 index 0000000..1da4a63 Binary files /dev/null and b/docs/images/05-add-food-for-snake.gif differ diff --git a/docs/tutorial/02-draw-a-snake.md b/docs/tutorial/02-draw-a-snake.md index 0ce15c3..80b77cb 100644 --- a/docs/tutorial/02-draw-a-snake.md +++ b/docs/tutorial/02-draw-a-snake.md @@ -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) diff --git a/docs/tutorial/03-let-the-snake-move.md b/docs/tutorial/03-let-the-snake-move.md index 04761f9..f1dcbdc 100644 --- a/docs/tutorial/03-let-the-snake-move.md +++ b/docs/tutorial/03-let-the-snake-move.md @@ -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) @@ -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) @@ -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 diff --git a/docs/tutorial/05-add-food-for-snake.md b/docs/tutorial/05-add-food-for-snake.md new file mode 100644 index 0000000..cdb2c79 --- /dev/null +++ b/docs/tutorial/05-add-food-for-snake.md @@ -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) diff --git a/lib/scenes/current/game2.ex b/lib/scenes/current/game2.ex index 61bf89b..ced8868 100644 --- a/lib/scenes/current/game2.ex +++ b/lib/scenes/current/game2.ex @@ -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) diff --git a/lib/scenes/current/game3.ex b/lib/scenes/current/game3.ex index cc8caf4..29ee256 100644 --- a/lib/scenes/current/game3.ex +++ b/lib/scenes/current/game3.ex @@ -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 @@ -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) diff --git a/lib/scenes/current/game4.ex b/lib/scenes/current/game4.ex index 65cbe2f..852284d 100644 --- a/lib/scenes/current/game4.ex +++ b/lib/scenes/current/game4.ex @@ -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 @@ -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) diff --git a/lib/scenes/current/game5.ex b/lib/scenes/current/game5.ex new file mode 100644 index 0000000..aef8f94 --- /dev/null +++ b/lib/scenes/current/game5.ex @@ -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