This is part of the Elixir Life Beyond Apocalypse Zombie Game creation tutorial.
Refer to the introduction for more information.
So now that have thought about how our game will look like it's time to get started!
But where to start? It looks pretty complex and overwhelming at first sight.
Let's first create a basic User module to contain the basis structure of our User.
We will then create a simple random map where we can move around.
Small and steady!
Let's use mix to create a new project.
mix new life_beyond_apocalypse --sup
For now we save all files in lib/life_beyond_apocalypse, in later stages we will move our files to specialized folders.
user.exdefmodule User do defstruct name: "", health: 30, energy: 100, x: 5, y: 5, items: %{}, coins: 0, map: "" def map() domap = ~w"""#=#=#=#=#@@@@#=#=#=#=#@@@@#=#=#=#=#@@#@#=#=#=#=#@@@@#=#=#=#=#===##=#=#=#=#@@@@#=#=#=#=#@@@@"""Enum.map(map, fn (x) -> String.split(x," ", trim: true ) end) endend
We first create a structure map for the user which will contain various information.
Then we write the map function.
Notice how we create a heredoc with a sigil
~w will create a list from our individual rows.
Afterwards we use Enum.map to split each row into a list of items. The trim: true option is just to make sure we don't have an empty list item at the end of each list.
Now we can handle the movement. However we have one little problem.
Elixir variables are immutable. Which means once declared we cannot modify a variable. We thus need to create a new variable each time we modify something in a old variable.
This means there is no global data which we can change. It's a good thing since we don't pollute our programs with useless data.
But how do we solve the need to save data?
We could read and write to a file.
We could create a new process to store the data or use Agents and Genserver but this is too advanced at the moment.
Or we could pass the User structure map to every function, returning us a tuple with the new user map. This is the best solution for our current needs.
Moving
def move(where_to, user) dolocation = move_to(where_to,user) case verify_bounds (location) do {:ok, msg, {x,y}} ->\tuser = Map.put(user, :x, x)\tuser = Map.put(user, :y, y) {:error, msg} ->\t IO.puts msg\tenduser end defp move_to("west", %User{x: x, y: y}) dox = x-1{x,y} end defp move_to("east", user) do x = user.x + 1 {x,user.y} end defp move_to("north", user) do y = user.y - 1 {user.x,y} end defp move_to("south", user) do y = user.y + 1{user.x,y}\t end #In case we get an invalid location, we just sit put defp move_to(where_to, user) do {user.x,user.y} end defp verify_bounds({x,y}) when x>=1 and y>=1 and 13 >= x and 7>=y do{:ok, "You moved to #{x},#{y}", {x,y}} end defp verify_bounds({_x,_y}) do{:error, "You are at the edge of the map"} end
Now we have a handful of functions.
We have a function move which takes 2 variables, our location as a string "west","east","north"
And the user structure which gets returned as the function result.
IT then calls the move_to private function. But behold, we have 4 different move_to function definitions.
How can this work? It will surely give us an error or it will only know the last compiled function (like some interpreted languages do).
Pattern matching is what makes Elixir great, since we can redefine the same function multiple times to do various things.
For now what we're doing is simple and may seem counter intuitive. However it's better to use multiple such functions instead of weird if/else structures.
It makes the code readable and maintainable.
move_to returns a tuple with the new location which we then send to verify_bounds which has also 2 function definitions.
The first function definition verifies if we're within bounds by using when. When is actually a sort of if.
We hard code the maximum value for now to make things easier, later on we will update it.
It returns a tuple containing the atom :ok with a text indicating the movement.
The second definition of the function returns a tuple with the first value the error atom. And a text that we're already at the edge of the map.
Clean and easy to separate our verifications.
Back to our move function we have a case for the bounds so we verify if we can move or not.
Remembering that Elixir data is immutable we have to redefine the x and y values.
We're using Map.put for this, and since Map.put returns a new map object we need to overwrite our current user map.
By now you're observing that we haven't any IF/Else structures. This is because Elixir and Erlang actually encourage you to define the same function for each case you have.
Keeping the code cleaner and easier to understand.
We could have done everything within one function but we wouldn't have learned anything useful.
Let's test it out
user = %User{name: "Andrei"}user = User.move("south",user)%User{coins: 0, energy: 100, health: 30, items: %{}, map: "", name: "Andrei",x: 5, y: 6}iex(100)> user = User.move("east",user)%User{coins: 0, energy: 100, health: 30, items: %{}, map: "", name: "Andrei",x: 6, y: 6}iex(101)> user = User.move("east",user)%User{coins: 0, energy: 100, health: 30, items: %{}, map: "", name: "Andrei",x: 7, y: 6}
So it seems to work and returns the proper values.
Let's now create a function that shows the map with the location where you are at in color.
By default iex comes with ANSI coloring. Depending on your operating system it could be on/off.
The windows cmd.exe has different coloring than the Linux or Mac OS x ones.
This won't matter when we use telnet, because telnet can work with ANSI coloring.
If you are however on windows and have coloring issues, try the cygwin bash.
To turn coloring on use:
Application.put_env(:elixir,:ansi_enabled,true)
We can use IO.ANSI.format to format the colouring.. We will also change the texts we received with some colours.
But there is an issue.. working with lists and updating them becomes very daunting.
Something that in a multidimensional array should be easy becomes extremely difficult.
So we will create a few helper functions.
def show_map(%User{map: map, x: x, y: y} = user) do text = IO.ANSI.format_fragment([:bright, :green, get(user.map, x-1, y-1), :reset, :white]) map = set(map, x-1, y-1, text ) IO.puts IO.ANSI.white IO.puts map end def get(arr, x, y) do arr |> Enum.at(x) |> Enum.at(y) end def set(arr, x, y, value) do List.replace_at(arr, x, List.replace_at(Enum.at(arr, x), y, value) ) end
Let's test it out again:
User.show_map(%User{})
** (Protocol.UndefinedError) protocol Enumerable not implemented for "". This protocol is implemented for: Date.Range, File.Stream, Function, GenEvent.Stream, HashDict, HashSet, IO.Stream, List, Map, MapSet, Range, Stream
Oh yeah, we forgot to add the user map. But while looking at the user map we saw that there are no newlines.. Let's update the map() functon too.
def map() domap = ~w"""#=#=#=#=#@@@@#=#=#=#=#@@@@#=#=#=#=#@@#@#=#=#=#=#@@@@#=#=#=#=#===##=#=#=#=#@@@@#=#=#=#=#@@@@"""Enum.map(map, fn (x) -> String.split(x,"", trim: true ) end)|> Enum.map(fn (x) -> List.insert_at(x,-1,"\\n") end)end
Testing yet again, this time we've added the map to the user structure.
user = %User{name: "Andrei", map: User.map()}User.show_map(user)user = User.move("east",user)user = User.move("south",user)user = User.move("east",user)User.show_map(user)
Usually we should have written tests by now to test the functionality of our game. But we're not yet done with how we arranged our code. Maybe we want to rename some functions.
One thing you should always do as a programmer is rewrite your code. It's something that should be done at all times to make the code more readable and beautiful.
Sometimes you will see new ways to do various things and simplify the code.. Or you might want to restructure it in some other way than before.
We call this refactoring.
Download the full sourcecode for 0x01 here
Notice that all we did up until now had to do with movement and map stuff.
Why not move it over to it's own Map module?
Our User module will be empty but we will add more things later!
You can look at the source code and conclude that it looks much better. We've inlined the move_to functions.
We've also modified the show_map to read the map() function instead of sending the map with the user structure map.
What we will do in the future is put a map_id and varying on that map we will draw the map.
Move on to the next tutorial on how to use the 0x02 Command Line!