Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Hangman - Structs

hangman/05-structs.livemd

Hangman - Structs

Structs

Hasta ahora hemos manejado un mapa como contenedor de nuestro estado, veamos el concepto de struct en Elixir.

Los structs definen una estructura de datos con llaves o keys predefinidas. Dichas llaves son verificadas en tiempo de compilación, esto quiere decir que si cometes un error al escribir el nombre de la llave (a.k.a typo), obtendrás un error por parte del compilador y podrás arreglarlo inmediatamente. Los structs son definidos dentro de módulos, haciendo uso de la palabra reservada defstruct con una lista de átomos. Definamos la estructura User con campos :name y :age:

defmodule User do
  defstruct [:name, :age]
end

Ahora, podemos crear estructuras usando la notación %User{...}

user = %User{name: "John Connor", age: 37}

Podemos acceder a los campos de la estructura usando la sintaxis struct.field:

user.name

También podemos aplicar pattern matching sobre estructuras:

%User{age: age} = user
age

Finalmente, veamos que pasa si cometemos un error al escribir un campo de una estructura:

%User{agge: age} = user

Con toda esta introducción, ahora estamos listos para definir la estructura Hangman.State, la cual contendrá el estado de nuestro juego.

defmodule Hangman.State do
  @moduledoc """
  The Hangman game state
  """

  @enforce_keys [:word, :goal]
  defstruct [
    :word,
    :goal,
    misses: MapSet.new(),
    matches: MapSet.new(),
    limit: 5,
    mask: "_",
    completed?: false
  ]

  @type t :: %__MODULE__{
          word: String.t(),
          goal: MapSet.t(),
          misses: MapSet.t(),
          matches: MapSet.t(),
          limit: pos_integer(),
          mask: String.t(),
          completed?: boolean()
        }

  @doc """
  Creates the initial game state
  """
  @spec new(String.t()) :: t()
  def new(word) when is_binary(word) do
    word = String.downcase(word)
    goal = word |> String.codepoints() |> MapSet.new()
    struct!(__MODULE__, word: word, goal: goal)
  end
end
defmodule Hangman do
  @moduledoc """
  The famous Hangman game
  """

  alias Hangman.GameLogic
  alias Hangman.State
  alias Hangman.View

  @doc """
  Starts the game
  """
  @spec start_game() :: {String.t(), State.t()}
  def start_game do
    word = "hangman"

    word
    |> State.new()
    |> View.format_response()
  end

  @doc """
  Lets the user to take a guess
  """
  @spec take_a_guess(String.t(), State.t()) :: {String.t(), State.t()}
  def take_a_guess(letter, %State{limit: limit, completed?: false} = state) when limit > 0 do
    letter
    |> String.downcase()
    |> GameLogic.guess(state)
    |> View.format_response()
  end

  def take_a_guess(_letter, %State{} = state), do: View.format_response(state)
end

Con esto, ahora obtenemos varias garantías en tiempo de compilación, por ejemplo:

%Hangman.State{}

Nota que además podemos inyectar valores por omisión.

%Hangman.State{word: "word", goal: MapSet.new()}

Pasemos a actualizar nuestra vista y lógica del juego para manejar nuestra struct

defmodule Hangman.GameLogic do
  @moduledoc """
  Main logic for the game
  """

  alias Hangman.State

  @doc """
  Returns the game state after the user takes a guess
  """
  @spec guess(String.t(), State.t()) :: State.t()
  def guess(letter, %State{} = state) do
    %{goal: goal, matches: matches, misses: misses, limit: limit} = state

    if MapSet.member?(goal, letter) do
      matches = MapSet.put(matches, letter)
      completed? = MapSet.equal?(matches, goal)
      %{state | matches: matches, completed?: completed?}
    else
      %{state | misses: MapSet.put(misses, letter), limit: limit - 1}
    end
  end
end
defmodule Hangman.View do
  @moduledoc """
  Presentation layer for the Hangman game
  """

  alias Hangman.State

  @doc """
  Returns a human-friendly response
  """
  @spec format_response(State.t()) :: {String.t(), State.t()}
  def format_response(%State{limit: limit, completed?: false} = state) when limit > 0 do
    {mask_word(state), state}
  end

  def format_response(%State{limit: limit, word: word} = state) when limit > 0 do
    {"You won, word was: #{word}", state}
  end

  def format_response(%State{word: word} = state) do
    {"Game Over, word was: #{word}", state}
  end

  ## Helpers
  defp mask_word(%{matches: matches, mask: mask, word: word} = _state) do
    if MapSet.size(matches) > 0 do
      matches = Enum.join(matches)
      String.replace(word, ~r/[^#{matches}]/, mask)
    else
      String.replace(word, ~r/./, mask)
    end
  end
end

Retos

  • Hasta ahora todas nuestras pruebas que hemos hecho han sido manuales. Elixir incluye un framework para desarrollo de pruebas unitarias llamado ExUnit. ¿Puedes generar algunas pruebas unitarias para nuestro proyecto?
  • Si estás siguiendo este tutorial en Livebook y ya lo tienes instalado, revisa la sección Explore y lee el cuaderno Elixir and Livebook, en dicho documento encontrarás una sección sobre como ejecutar pruebas unitarias en Livebook.