This is part of the Elixir Life Beyond Apocalypse Zombie Game creation tutorial.
In the last tutorial(Elixir - 0x02 Command Line Fun - LBA Game) we created a command-line application.
Due to the immutability of Elixir and Erlang we had to pass the user structure map to every function.
However, passing the user struct form one function to another only makes things more complex and makes everything error prone.
Elixir has a solution for this.
Storing data in Elixir can be done by creating a new process and storing the data there.
We will be using spawn, send and receive for the whole thing.
Later on we will upgrade our codebase to use Agents.
Normally when we work with processes we always need to work with a PID. This means that whenever we want to send information to the process (to get back a response) we need to pass the pid.
This makes it again a little bit difficult since we would need to pass the pid instead of the user struct.
However thanks to Elixir we can name our processes! This can help us a lot.
We update user.ex as follows
defmodule User do defstruct name: "", health: 30, energy: 100, x: 5, y: 5, items: %{}, coins: 0, map_id: 1#Server API def new(user) do user = receive do {:get, key, pid} -> send pid, {:value, Map.get(user,key)} user {:update,key,value} -> Map.put(user,key,value) end new(user) end#Client API def start(player_name) do pid = spawn(User, :new, [%User{name: player_name}]) Process.register(pid, :user) end def get(key) do send(:user, {:get, key, self()}) receive do {:value, value} -> value end end def update(key,value) do send(User, {:update, key, value}) endend
So, whenever we call start a new process will be created and it will be registered to the atom of :user. Experiment by changing this to :user_process or whatever atom you like.
The spawn command then creates a new process by running the User.new function by passing it a User struct.
The new() command listens for commands with the receive function.
Whenever we have a tuple with :get we send back a response with the value of the key requested.
Note that we're expecting a pid as the last argument, otherwise the process wouldn't know where to send it back to!
The :update clause doesn't require a pid since we;re just updating the data and we won't send anything back.
We then define 2 API functions which will do the work for us when getting and updating data.
Note that whenever we run get() we're sending a packet to the :user process and then expect a response with a value.
If we would create a real live application we would certainly need to catch errors and/or put a timeout for the receive command.
But for now this is enough.
Be aware that even though we use the same module for both processes new is run in the :user process, get and update are ran in our current process.
This means that the client is our current process and :user is the server process.
Before we integrate this in our CLI module let's test it in iex.
iex -S mix
It seems to work just fine.
However, we forgot to implement a function to retrieve and update the whole user structure map.
Let's do this now.
I've renamed the update function to set so we know they're setters and getters. I've also added a timeout after 5 seconds with an error tuple in the case someting fails.
defmodule User do defstruct name: "", health: 30, energy: 100, x: 5, y: 5, items: %{}, coins: 0, map_id: 1#Server API def new(user) do receive do {:get, key, pid} ->\tsend pid, {:value, Map.get(user,key)} {:get_struct,pid} ->\tsend pid, {:user, user} {:set,key,value} -> user = Map.put(user,key,value) {:set_struct, struct} -> user = structendnew(user) end#Client API def start(player_name) do pid = spawn(User, :new, [%User{name: player_name}]) Process.register(pid, :user_pid) end def get(key) dosend(:user_pid, {:get, key, self()})receive do {:value, value} -> value 5000 -> {:error, "Did not respond on time"}end end def get_struct() dosend(:user_pid, {:get_struct, self()})receive do {:user, user} -> user\t5000 -> {:error, "Did not respond on time"}end end def set(key,value) dosend(:user_pid, {:set, key, value}) end def set_struct(user) dosend(:user_pid, {:set_struct, user}) endend
Running it:
We've implemented a way to save the User state.
We also have a way to save the game state in the future. All we need to do is just copy the User module to a new module and modify the structure.
Or we could create a general module to handle this.
Either way, we can now refactor the GameMap and CLI modules. You will get used to hearing that we're going to refactor our code.
Refactoring is sometimes viewed as a necessary evil by some programmers who think their code is perfect.
However, code refactoring is a necessity in programming.
In other languages whenever you have a function that is longer than 20 lines of code then you should rewrite it.
Lucky for us, Elixir makes it easy to write readable and good code.
IF you ever have more than 20 lines of code in Elixir then probably you're doing someting wrong or could simplify it.
LifeBeyondAlpocalypse.CLI in CLI.ex
Notice how we've removed references to the user struct?
We start the :user state process in the main function and that's it. The rest should be handled by the correct functions.
This way each module has it's own role.
defmodule LifeBeyondApocalypse.CLI do @tag ~S""" _ _ __ ____ _| | (_)/ _| ___ | __ ) ___ _ _ ___ _ __ __| || | | | |_ / _ \\ | _ \\ / _ \\ | | |/ _ \\| '_ \\ / _` || |___| | _| __/ | |_) | __/ |_| | (_) | | | | (_| ||_____|_|_| \\___| |____/ \\___|\\__, |\\___/|_| |_|\\__,_| |___/ _ _ / \\ _ __ ___ ___ __ _| |_ _ _ __ ___ ___ / _ \\ | '_ \\ / _ \\ / __/ _` | | | | | '_ \\/ __|/ _ \\ / ___ \\| |_) | (_) | (_| (_| | | |_| | |_) \\__ \\ __//_/ \\_\\ .__/ \\___/ \\___\\__,_|_|\\__, | .__/|___/\\___| |_| |___/|_|""" @commands %{ "quit" => "Quits the game", "help" => "?<topic>? - Shows help screen and help topics about various commands", "move" => "<location> - Moves to location. Valid options are: (w)est, (e)ast, (s)outh, (n)orth ", "map" => "Shows the map with your current location colored in", } def main(_args) do IO.puts(@tag) name = read_text("What is your name dear adventurer?") IO.puts "Welcome to LifeBeyondApocalypse #{name}!" User.start(name) read_command("To get started type in a command, or help") end defp read_text(text) do IO.gets("\\n#{text} > ") |> String.trim end defp read_command(text \\\\ "") do IO.gets("\\n#{text} > ") |> String.trim |> String.downcase |> String.split(" ") |> execute_command end defp execute_command(["quit"]) do IO.puts "\\nThanks for playing Life Beyond Apocalypse. Have a nice day!" end defp execute_command(["help"]) do print_help_message() read_command() end defp execute_command(["move" | location]) do GameMap.move(List.to_string(location) ) read_command() end defp execute_command(["map"]) do GameMap.show_map() read_command() end defp execute_command(_unknown) do IO.puts("\\nUnknown command. Try help <topic>.") print_help_message() read_command() end defp print_help_message() do IO.puts("\\nLife Beyond Apocalypse supports the following commands:\\n") @commands |> Enum.map(fn({command, description}) -> IO.puts(" #{command} - #{description}") end) IO.puts "Type help <command> to find out more about a specific command" endend
But it's not yet working properly untill we modify the game_map.ex file with the GameMap module.
For the move_to function we could remove the user and ask the process for the details or just pass the user value, since they're private functions we chose to pass the user structure we received earilier.
defmodule GameMap do import GameUtilities def generate_map() do map = ~w""" #=#=#=#=#@@@@ #=#=#=#=#@@@@ #=#=#=#=#@@#@ #=#=#=#=#@@@@ #=#=#=#=#===# #=#=#=#=#@@@@ #=#=#=#=#@@@@ """ {x, y} = {length(map), String.length(Enum.at(map,1))} map = Enum.map(map, fn (x) -> String.split(x,"", trim: true ) end) |> Enum.map(fn (x) -> List.insert_at(x,-1,"\\n") end) %{map: map, x: x, y: y} end def move(location) do user = User.get_struct() where_to = move_to(location,user) case verify_bounds(where_to,generate_map()) do {:ok, msg, {x,y}} -> IO.puts IO.ANSI.format([:green, msg]) User.set_struct(%User{ user | x: x, y: y}) {:error, msg} -> IO.puts IO.ANSI.red <> msg <> IO.ANSI.reset end end defp move_to("west", %User{x: x, y: y}), do: {x-1,y} defp move_to("east", user), do: {user.x + 1 ,user.y} defp move_to("north", user), do: {user.x, user.y - 1 } defp move_to("south", user), do: {user.x,user.y + 1} #In case we get an invalid location, we just sit put # defp move_to(_where_to, user), do: {user.x,user.y} defp verify_bounds({x,y}, %{x: max_x, y: max_y}) when x>=1 and y>=1 and max_x>= x and max_y>=y do {:ok, "You moved to #{x},#{y}", {x,y}} end defp verify_bounds({_x,_y},_mapinfo) do {:error, "You are at the edge of the map and can't move further in this direction."} end def show_map() do %User{x: x, y: y} = User.get_struct() map = generate_map() text = IO.ANSI.format_fragment([:bright, :green, get(map.map, x - 1, y - 1), :reset, :white]) IO.write IO.ANSI.format([:white, set(map.map, x - 1, y - 1, text )]) end end
We've also moved the get and set list utilities to it's own module. Note the import GameUtilities in the GameMap module
defmodule GameUtilities do 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) ) endend
Download the full sourcecode here for 0x03 here
In the next tutorial we'll advance once again and add more features.