├── test ├── test_helper.exs └── scenic_snake_test.exs ├── .formatter.exs ├── README.md ├── lib ├── scenic_snake.ex └── scenes │ ├── game_over.ex │ └── game.ex ├── mix.lock ├── .gitignore ├── mix.exs ├── LICENSE └── config └── config.exs /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/scenic_snake_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScenicSnakeTest do 2 | use ExUnit.Case 3 | doctest ScenicSnake 4 | 5 | # TODO 6 | end 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scenic-Snake 2 | 3 | A barebones implementation of Snake in the awesome [Scenic](https://github.com/boydm/scenic) framework. 4 | 5 | ![Scenic Snake](https://raw.githubusercontent.com/gVirtu/scenic-snake/gh-pages/scenic-snake.png) 6 | 7 | TODO document this more in depth. 8 | 9 | # Running 10 | 11 | Clone the repository, and use `mix scenic.run` to run the game. 12 | -------------------------------------------------------------------------------- /lib/scenic_snake.ex: -------------------------------------------------------------------------------- 1 | defmodule ScenicSnake do 2 | @moduledoc """ 3 | Starter application using the Scenic framework. 4 | """ 5 | 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | # load the viewport configuration from config 10 | main_viewport_config = Application.get_env(:scenic_snake, :viewport) 11 | 12 | # start the application with the viewport 13 | children = [ 14 | supervisor(Scenic, viewports: [main_viewport_config]) 15 | ] 16 | 17 | Supervisor.start_link(children, strategy: :one_for_one) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "elixir_make": {:hex, :elixir_make, "0.4.2", "332c649d08c18bc1ecc73b1befc68c647136de4f340b548844efc796405743bf", [:mix], [], "hexpm"}, 3 | "scenic": {:hex, :scenic, "0.8.0", "44bffa479383e1be5986a891c2597ef48ea47485d901c82f8c6bbb74770dcb9e", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "scenic_driver_glfw": {:hex, :scenic_driver_glfw, "0.8.0", "710e02564f7d7ecb590cbed15521a90535f17cfbf9fe225c01237667e90f64e3", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:scenic, "~> 0.8", [hex: :scenic, repo: "hexpm", optional: false]}], "hexpm"}, 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | scenic_snake-*.tar 24 | 25 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ScenicSnake.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :scenic_snake, 7 | version: "0.1.0", 8 | elixir: "~> 1.7", 9 | build_embedded: true, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps() 12 | ] 13 | end 14 | 15 | # Run "mix help compile.app" to learn about applications. 16 | def application do 17 | [ 18 | mod: {ScenicSnake, []}, 19 | extra_applications: [:logger] 20 | ] 21 | end 22 | 23 | # Run "mix help deps" to learn about dependencies. 24 | defp deps do 25 | [ 26 | {:scenic, "~> 0.8.0"}, 27 | {:scenic_driver_glfw, "~> 0.8"} 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018-2019 Giancarlo França 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # Configure the main viewport for the Scenic application 6 | config :scenic_snake, :viewport, %{ 7 | name: :main_viewport, 8 | size: {1280, 720}, 9 | default_scene: {ScenicSnake.Scene.Game, nil}, 10 | drivers: [ 11 | %{ 12 | module: Scenic.Driver.Glfw, 13 | name: :glfw, 14 | opts: [resizeable: false, title: "scenic_snake"] 15 | } 16 | ] 17 | } 18 | 19 | # This configuration is loaded before any dependency and is restricted 20 | # to this project. If another project depends on this project, this 21 | # file won't be loaded nor affect the parent project. For this reason, 22 | # if you want to provide default values for your application for 23 | # 3rd-party users, it should be done in your "mix.exs" file. 24 | 25 | # You can configure your application as: 26 | # 27 | # config :scenic_snake, key: :value 28 | # 29 | # and access this configuration in your application as: 30 | # 31 | # Application.get_env(:scenic_snake, :key) 32 | # 33 | # You can also configure a 3rd-party app: 34 | # 35 | # config :logger, level: :info 36 | # 37 | 38 | # It is also possible to import configuration files, relative to this 39 | # directory. For example, you can emulate configuration per environment 40 | # by uncommenting the line below and defining dev.exs, test.exs and such. 41 | # Configuration from the imported file will override the ones defined 42 | # here (which is why it is important to import them last). 43 | # 44 | # import_config "#{Mix.env()}.exs" 45 | -------------------------------------------------------------------------------- /lib/scenes/game_over.ex: -------------------------------------------------------------------------------- 1 | defmodule ScenicSnake.Scene.GameOver do 2 | @moduledoc """ 3 | This scene is shown when you lose the game. 4 | Nobody wants to see this scene. 5 | """ 6 | 7 | use Scenic.Scene 8 | alias Scenic.Graph 9 | alias Scenic.ViewPort 10 | import Scenic.Primitives, only: [text: 3, group: 2, update_opts: 2] 11 | 12 | @text_opts [id: :gameover, fill: :white, text_align: :center] 13 | 14 | @graph Graph.build(font: :roboto, font_size: 36, clear_color: :black) 15 | |> group(fn(g) -> 16 | text(g, "Game Over!", @text_opts) 17 | end) 18 | 19 | @game_scene ScenicSnake.Scene.Game 20 | 21 | def init( score, opts ) do 22 | viewport = opts[:viewport] 23 | 24 | {:ok, %ViewPort.Status{size: {vp_width, vp_height}}} = ViewPort.info(viewport) 25 | 26 | position = {vp_width / 2, vp_height / 2} 27 | 28 | graph = @graph 29 | |> Graph.modify(:gameover, &update_opts(&1, translate: position)) 30 | |> push_graph() 31 | 32 | state = %{ 33 | graph: graph, 34 | viewport: opts[:viewport], 35 | on_cooldown: true, 36 | score: score 37 | } 38 | 39 | Process.send_after(self(), :end_cooldown, 2000) 40 | 41 | {:ok, state} 42 | end 43 | 44 | # Prevent player from hitting any key instantly, starting a new game 45 | def handle_info(:end_cooldown, state) do 46 | graph = state.graph 47 | |> Graph.modify(:gameover, &text(&1, "Game Over!\n" 48 | <> "You scored #{state.score}.\n" 49 | <> "Press any key to try again.", 50 | @text_opts)) 51 | |> push_graph() 52 | 53 | {:noreply, %{state | on_cooldown: false, graph: graph}} 54 | end 55 | 56 | # If cooldown has passed, we can restart the game. 57 | def handle_input({:cursor_button, {_, :press, _, _}}, _context, %{on_cooldown: false} = state) do 58 | restart_game(state) 59 | {:noreply, state} 60 | end 61 | 62 | def handle_input({:key, _}, _context, %{on_cooldown: false} = state) do 63 | restart_game(state) 64 | {:noreply, state} 65 | end 66 | 67 | def handle_input(_input, _context, state), do: {:noreply, state} 68 | 69 | defp restart_game(%{viewport: vp}) do 70 | ViewPort.set_root(vp, {@game_scene, nil}) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/scenes/game.ex: -------------------------------------------------------------------------------- 1 | defmodule ScenicSnake.Scene.Game do 2 | @moduledoc """ 3 | The scene where the game of Snake happens! 4 | """ 5 | 6 | use Scenic.Scene 7 | alias Scenic.Graph 8 | alias Scenic.ViewPort 9 | import Scenic.Primitives, only: [rrect: 3, text: 3] 10 | 11 | @compile {:inline, draw_tile: 4, move: 3} 12 | 13 | @empty_graph Graph.build(theme: :dark, font: :roboto, font_size: 36) 14 | 15 | @tile_size 40 16 | @tile_radius 8 17 | 18 | @frame_ms 32 19 | 20 | @snake_movement_delay 6 21 | @snake_starting_size 5 22 | @pellet_score 100 23 | 24 | @game_over_scene ScenicSnake.Scene.GameOver 25 | 26 | # Initial parameters for the game scene! 27 | def init(_arg, opts) do 28 | viewport = opts[:viewport] 29 | 30 | # Initializes the graph 31 | graph = @empty_graph 32 | 33 | # calculate the transform that centers the snake in the viewport 34 | {:ok, %ViewPort.Status{size: {vp_width, vp_height}}} = ViewPort.info(viewport) 35 | 36 | # how many tiles can the viewport hold in each dimension? 37 | vp_tile_width = trunc(vp_width / @tile_size) 38 | vp_tile_height = trunc(vp_height / @tile_size) 39 | 40 | # snake always starts centered 41 | snake_start_coords = { 42 | trunc(vp_tile_width / 2), 43 | trunc(vp_tile_height / 2) 44 | } 45 | 46 | # start a very simple animation timer 47 | {:ok, timer} = :timer.send_interval(@frame_ms, :frame) 48 | 49 | state = %{ 50 | viewport: viewport, 51 | tile_width: vp_tile_width, 52 | tile_height: vp_tile_height, 53 | graph: graph, 54 | frame_count: 1, 55 | frame_timer: timer, 56 | score: 0, 57 | had_input: false, 58 | # Game objects 59 | objects: %{snake: %{body: [snake_start_coords], 60 | size: @snake_starting_size, 61 | direction: {1, 0}}, 62 | 63 | pellet: nil}, 64 | } |> randomize_pellet() 65 | 66 | # draw snake and pellet 67 | graph 68 | |> draw_game_objects(state.objects) 69 | |> draw_score(state.score) 70 | |> push_graph() 71 | 72 | {:ok, state} 73 | end 74 | 75 | # A very simple frame counter. The game should run at roughly 30 fps, even though the snake 76 | # doesn't refresh every frame by default. 77 | def handle_info(:frame, %{frame_count: frame_count} = state) do 78 | state = if rem(frame_count, @snake_movement_delay) == 0 do 79 | move_snake(state) 80 | else 81 | state 82 | end 83 | 84 | state.graph |> draw_game_objects(state.objects) |> draw_score(state.score) |> push_graph() 85 | 86 | {:noreply, %{state | frame_count: frame_count + 1}} 87 | end 88 | 89 | def do_nothing(state) do 90 | state 91 | end 92 | 93 | # Keyboard controls 94 | def handle_input({:key, _}, _context, %{had_input: true} = state) do 95 | {:noreply, do_nothing(state)} 96 | end 97 | 98 | def handle_input({:key, {"left", :press, _}}, _context, state) do 99 | {:noreply, update_snake_direction(state, {-1, 0})} 100 | end 101 | 102 | def handle_input({:key, {"right", :press, _}}, _context, state) do 103 | {:noreply, update_snake_direction(state, {1, 0})} 104 | end 105 | 106 | def handle_input({:key, {"up", :press, _}}, _context, state) do 107 | {:noreply, update_snake_direction(state, {0, -1})} 108 | end 109 | 110 | def handle_input({:key, {"down", :press, _}}, _context, state) do 111 | {:noreply, update_snake_direction(state, {0, 1})} 112 | end 113 | 114 | def handle_input(_input, _context, state), do: {:noreply, state} 115 | 116 | # Change the snake's current direction. 117 | defp update_snake_direction(state, direction) do 118 | {old_x, old_y} = state.objects.snake.direction 119 | 120 | # Prevent going backwards and crashing instantly 121 | if (direction in [{-old_x, 0}, {0, -old_y}]) do 122 | state 123 | else 124 | put_in(state, [:objects, :snake, :direction], direction) |> put_in([:had_input], true) 125 | end 126 | end 127 | 128 | # Move the snake to its next position according to the direction. Also limits the size. 129 | defp move_snake(%{objects: %{snake: snake}} = state) do 130 | [head | _] = snake.body 131 | new_head_pos = move(state, head, snake.direction) 132 | 133 | new_body = Enum.take([new_head_pos | snake.body], snake.size) 134 | 135 | state 136 | |> maybe_eat_pellet(new_head_pos) 137 | |> maybe_die() 138 | |> put_in([:objects, :snake, :body], new_body) 139 | |> put_in([:had_input], false) 140 | end 141 | 142 | defp move(%{tile_width: w, tile_height: h}, {pos_x, pos_y}, {vec_x, vec_y}) do 143 | {rem(pos_x + vec_x + w, w), rem(pos_y + vec_y + h, h)} 144 | end 145 | 146 | # We're on top of a pellet! :) 147 | defp maybe_eat_pellet(state = %{objects: %{pellet: pellet_coords}}, snake_head_coords) 148 | when pellet_coords == snake_head_coords do 149 | state 150 | |> randomize_pellet() 151 | |> add_score(@pellet_score) 152 | |> grow_snake() 153 | end 154 | 155 | # No pellet in sight. :( 156 | defp maybe_eat_pellet(state, _), do: state 157 | 158 | # oh no 159 | defp maybe_die(state = %{viewport: vp, objects: %{snake: %{body: snake}}, score: score}) do 160 | # If ANY duplicates were removed, this means we overlapped at least once 161 | if length(Enum.uniq(snake)) < length(snake) do 162 | ViewPort.set_root(vp, {@game_over_scene, score}) 163 | end 164 | state 165 | end 166 | 167 | # Place the pellet somewhere in the map. It should not be on top of the snake. 168 | defp randomize_pellet(state = %{tile_width: w, tile_height: h}) do 169 | pellet_coords = { 170 | Enum.random(0..(w-1)), 171 | Enum.random(0..(h-1)), 172 | } 173 | 174 | validate_pellet_coords(state, pellet_coords) 175 | end 176 | 177 | # Keep trying until we get a valid position 178 | defp validate_pellet_coords(state = %{objects: %{snake: %{body: snake}}}, coords) do 179 | if coords in snake, do: randomize_pellet(state), 180 | else: put_in(state, [:objects, :pellet], coords) 181 | end 182 | 183 | # Increments the player's score. 184 | defp add_score(state, amount) do 185 | update_in(state, [:score], &(&1 + amount)) 186 | end 187 | 188 | # Increments the snake size. 189 | defp grow_snake(state) do 190 | update_in(state, [:objects, :snake, :size], &(&1 + 1)) 191 | end 192 | 193 | # Iterates over the object map, rendering each object 194 | defp draw_game_objects(graph, object_map) do 195 | Enum.reduce(object_map, graph, fn {object_type, object_data}, graph -> 196 | draw_object(graph, object_type, object_data) 197 | end) 198 | end 199 | 200 | # Snake's body is an array of coordinate pairs 201 | defp draw_object(graph, :snake, %{body: snake}) do 202 | Enum.reduce(snake, graph, fn {x, y}, graph -> 203 | draw_tile(graph, x, y, fill: :lime) 204 | end) 205 | end 206 | 207 | # Pellet is simply a coordinate pair 208 | defp draw_object(graph, :pellet, {pellet_x, pellet_y}) do 209 | draw_tile(graph, pellet_x, pellet_y, fill: :yellow, id: :pellet) 210 | end 211 | 212 | # Draw tiles as rounded rectangles to look nice 213 | defp draw_tile(graph, x, y, opts) do 214 | tile_opts = Keyword.merge([fill: :white, translate: {x * @tile_size, y * @tile_size}], opts) 215 | graph |> rrect({@tile_size, @tile_size, @tile_radius}, tile_opts) 216 | end 217 | 218 | # Draw the score HUD 219 | defp draw_score(graph, score) do 220 | graph 221 | |> text("Score: #{score}", fill: :black, font_blur: 2.0, translate: {@tile_size + 4, 222 | @tile_size + 4}) 223 | |> text("Score: #{score}", fill: :white, translate: {@tile_size, @tile_size}) 224 | end 225 | end 226 | --------------------------------------------------------------------------------