Our User module works great for the purpose it has been created.
However we need to to prepare for the future when we'll implement our NPC's and battle system.
We will also probably need to store other details in a "global" repository in memory.
Yes, ETS works great for this, however that's for a future date.
We can copy paste the user module and modify it each time we need to store data, but there is an easier and better way.
Creating another Module DataStorage on which our User extends.
This way our application will make calls to the DataStorage module
DataStorage module
the DataStorage module is responsible for handling all our data in a special process. We will then implement the user model on top of that.
To view ALL The code in the Fossil Respository for the 0x06 Modifications to State click here
User Module
Our User Module has now become tiny. Apart from the documentation and tests which are required to see if everything works alright.
Our User Module can now work perfectly fine with whatever changes we will do to our Elixir Phoenix:Project Ideas:Life Beyond Apocalypse Zombie:DataStorage in the future without breaking any functionality.
Thanks to the Tests we did earlier we can find out if our implementation works by typing
mix test
Implementing a progressbar system to show our health and energy
GameUtilities.ex
def progress_bar(current, maximum, char \\\\ "#", level \\\\ 25) do \tcurrent_nr = round((current/maximum)*level) \tmax_nr = round(maximum/maximum*level) - current_nr \tfull_bar = String.duplicate(char, current_nr) \tempty_bar = String.duplicate("-", max_nr) \t"[#{full_bar}#{empty_bar}]" end
Player Status Function
Previously our move_to misbehaved for certain moves. Now we've fixed it.
Our user should have a maximum of 30 items in his inventory. So when searching we should limit this.
We need a way to inform a user that he needs to rest when he goes below 25% of his max_energy.
We also need a way to inform him that he can't do anything else if he gets to 0.
IT would be great if we could combine all these with the decreasing of energy.. So you know, we have better code
We still have a slight issue..For our code to be modular we should return every data to the function above and it should return above and the calling function should decide how to show our text
But our :rest atom is different, it will be interpreted as an OK but we still need to show it. And probably within a different colour so that the user knows what's awaiting for him.
We could complicate each caller to handle the text. For now we should just show it directly and figure out later how to fix this.
Our movement did not use any energy so we not have to extend it to actually use energy.
Now we stumble on a problem.
Our use_energy function will decrease the enegy used.
But our user structure still has the old energy which will then be used to append the new x and y positions. This will mean that even though we decrease energy, it will be set back to it's old value.
This can only mean that we need to concieve a function that updates only the requested values without us having to call User.set multiple times in a row.
We can create 2 different fuctions set_many and get_many or we could use pattern matching and the power of functions elixir provides us to make it extremely simple.
data_storage.ex
#Server API
#Server APIdef new(data_structure) do receive do {:get, key, pid} -> if is_list(key) do return_data = Map.take(data_structure,key) else return_data = Map.get(data_structure,key) end send pid, {:value, return_data} {:get_struct,pid} -> send pid, {:data_structure, data_structure} {:set, key, value} -> data_structure = Map.put(data_structure,key,value) {:set, mapped_values} -> data_structure = Map.merge(data_structure, mapped_values) {:set_struct, structure} -> data_structure = structure {:incr,key,incr} -> data_structure = Map.update(data_structure,key,incr, &(incr+&1)) {:append,key,item} -> data_structure = Map.update(data_structure, key, [item], &(&1 ++ [item])) end new(data_structure) end# ...@doc """ Updates the structure with a map %{key1: "value1", key2: "value2"} """ def set(process, map) do send(process, {:set, map}) end
Our get now has 2 different implementations depending if we receive a list or just a simple key.
TEST CASE
iex> User.start("Lord Praslea")iex> User.set(:energy,47)iex> User.set(:health,33)iex> User.get(~W/energy health/a)%{energy: 47, health: 33}
For our set it's a little bit different, we now have to implement 2 differnt set functions which verify if you transfer a key and value or just a map
We could use %{user | x: x, y: y} but i think it's better to use different functions this timeTEST CASE within the documentation for a certain module
@doc """Update multiple key, value pairs by using a map## Examplesiex> User.start("Lord Praslea")iex> User.set(%{energy: 25, health: 30, experience: 70})iex> User.get(:energy)25iex> User.get(:health)30iex> User.get(:experience)70"""
Use Energy code
def use_energy(decrease_energy) do user = User.get_struct() {reason, msg} = has_enough_energy(user, decrease_energy) if reason in [:ok, :rest] do User.incr(:energy, -decrease_energy) end {reason, msg} end def has_enough_energy(%User{energy: energy, max_energy: max_energy}, decrease) when (energy - decrease) <= (max_energy*0.25) and (energy - decrease) > 0 do msg = "You are getting low on energy. You should find a safehouse to rest. \\#{energy - decrease} energy left." IO.ANSI.format([:yellow, msg]) |> IO.puts {:rest, msg } end def has_enough_energy(%User{energy: energy}, decrease) when (energy - decrease) <= 0 do {:not_enough_energy, "You don't have enough energy to perform this action." } end def has_enough_energy(%User{energy: energy}, decrease) when (energy - decrease) > 0, do: {:ok, nil}
Move and decrease energy
Back to our move function with it's extra utility functions, here's how a new version looks like.
It may look like overkill and you might think well yeah, we could have used a simple IF statement. We opted however to use a few helper functions.
It really depends on what your preferences are, in this case an IF would have made us type less but for the sake of the example i've included both.
Remove from comments the one you would like to use.
def move(location) do IO.puts "Move to #{location}" user = User.get_struct() where_to = move_to(location,user) case verify_bounds(where_to,generate_map()) do {:ok, msg, {x,y}} -> # User.use_energy(-1) # |> move_the_player(msg, {x, y}) {reason, energy_msg} = User.use_energy(1) if reason in [:rest, :ok] do move_the_player({reason, energy_msg}, msg, {x, y}) else {:error, energy_msg} end {:error, msg} -> IO.puts IO.ANSI.red <> msg <> IO.ANSI.reset {:error, msg} end end
Why writing tests is good. We might create a function and think it's OK.
First i have written it like this
(energy - decrease) <= (max_energy*0.25)
Search and decrease energy
Modifying our search function to fit the new way of decreasing makes us include a new if.
IF elixir had a specific statement to return early we could avoid the extra if and also avoiding creating a new subfunction.
We've modified the :false to :error in search and search_item together with the cli.exe file
def search() do if length(User.get(:items)) < @item_limit do {reason, energy_msg} = User.use_energy(1) if reason != :not_enough_energy do search_item(GameUtilities.rand(1,10)) else {:error, energy_msg} end else {:error, "Before you start searching again, be sure you drop something since you're already carrying too many things (30 items limit)"} end end
in cli.ex
# .... defp execute_command(["search"]) do case GameItems.search() do {:ok, _item, msg} -> IO.puts IO.ANSI.format([:green, msg]) {:error, msg} -> IO.puts IO.ANSI.format([:red, msg]) end read_command() end
We're will now implement the part where using an item will decrease energy.
Use an item and decrease energy
def use_item(item_type, item) when item_type in [:energy,:health] do user = User.get_struct() if(item_type == :health, do: maximum = :max_health ,else: maximum = :max_energy ) {atom, value} = cap_at_maximum(Map.get(user,maximum), Map.get(user,item_type), item.value) if atom in [:maximum,:ok] do {reason, energy_msg} = User.use_energy(1) if reason != :not_enough_energy do User.set(item_type,value) drop_item(item) else {:error, energy_msg} end end {:ok, use_item_maximum_text(atom) |> EEx.eval_string([item: item, user: user, maximum: maximum])} end
Refactoring a little bit
As you can see by just adding the use_energy function call need to ad at least 4 extra lines to know when to return an error and to know when to do our action
Hmm, this doesn't seem efficient and is counter intuitive. We do this because we have multiple states and want to inform the user if he needs to rest, if he has enough energy or if it's ok.
Also because we want to prepare our app to be able to run in a commandline, client server environment and maybe via websocket and JSON?
Well, I think we can fix this.
There are 2 options
We can either just return :error or :ok. If the user needs to be informed of a rest, so be it. We will for now just PUT the message to the screen.
In the future if the user is in the commandline we will just update the user structure to point commandline, socket, websocket, json.. etc So we know the correct course of action for the :rest option
Otherwise we will just do an if and return the message
We could rename the use_energy function to verify_energy.. If we get :ok we can do our function, if we get :error we can just return the message
In case we succeed, we can then decrease the energy manually. We have to handle the energy verfification in one place and the decreasing in another place but our code will become easier to understand and to maintain in the long run.
user.ex
def verify_energy(decrease_energy) do user = User.get_struct() has_enough_energy(user, decrease_energy) end def use_energy(energy) do User.incr(:energy, -energy) end def has_enough_energy(%User{energy: energy, max_energy: max_energy}, decrease) when(energy - decrease) <= (max_energy*0.25) and (energy - decrease) > 0 do msg = "You are getting low on energy. You should find a safehouse to rest. \\#{energy - decrease} energy left." IO.ANSI.format([:yellow, msg]) |> IO.puts{:ok, msg } end def has_enough_energy(%User{energy: energy}, decrease) when(energy - decrease) <= 0 do{:error, "You don't have enough energy to perform this action." } enddef has_enough_energy(%User{energy: energy}, decrease) when (energy - decrease) > 0, do: {:ok, nil}
items.ex
def use_item(item) do use_item_energy(User.verify_energy(1), item)enddef use_item_energy({:ok, _} ,item) do use_item(item.type, item)enddef use_item_energy({:error, energy_msg} , _item) do\t{:error, energy_msg}enddef use_item(item_type, item) when item_type in [:energy,:health] do user = User.get_struct() if(item_type == :health, do: maximum = :max_health ,else: maximum = :max_energy ) {atom, value} = cap_at_maximum(Map.get(user,maximum), Map.get(user,item_type), item.value) if atom in [:maximum,:ok] do\t User.use_energy(1)\t User.set(item_type,value)\t drop_item(item) end {:ok, use_item_maximum_text(atom)\t |> EEx.eval_string([item: item, user: user, maximum: maximum])}end
game_map.ex
def move(location) do#Logger.debug "Move to #{location}"user = User.get_struct()where_to = move_to(location,user){reason, energy_msg} = User.verify_energy(1)if reason == :ok do case verify_bounds(where_to,get_map()) do\t{:ok, msg, {x,y}} ->\t User.use_energy(1)\t move_the_player({reason, energy_msg}, msg, {x, y})\t {:error, msg} ->\t\tIO.puts IO.ANSI.red <> msg <> IO.ANSI.reset\t\t{:error, msg}\t end\telse\t {reason, energy_msg}\tend end
Game map Refactoring by preparing for NPC
Our NPC's will move at random times.. THey will move in all of the 8 directions.
Our player only moves in 4 directions, we need to add 4 other directions.
Let's include north-west, north-east, south-west, south-east directions.
And also le'ts include the ability so that the user types in the location to go in that direction
We;ll also implement sorthand commands like nw, w, e, sw,se, ne,n, s.
And the numbers 1 all the way through 9 depending on the location on your numpad
This way movement becomes Number One thing you can do in the game.
YOU MIGHT HAVE NOTICED THAT SOMETIMES SOME TESTS FAIL. If you run mix test again they will succeed
This has to do with the state of our user since our tests are usually run in paralel..
We should for each test restart our User
It's usually the commandline tests that fail.