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

Minesweeper

examples/minesweeper.livemd

Minesweeper

Mix.install([
  {:kino, "~> 0.13.2"}
])

TODO

  • [x] Board
  • [x] Square
  • [x] Board.neighbors
  • [x] Board.member?
  • [x] Board.at
  • [x] Board.neighbors returning Squares
  • [x] Board.add_counts
  • [x] Board.reveal({x, y})
  • [x] Board.reveal (automatically reveal all connected)
  • [x] Cleanup reveal flow
  • [x] Interaction with Kino
  • [x] Flag squares
  • [x] Unflag squares
  • [x] Recognize defeat
  • [x] Recognize victory (via flags instead of reveals)

Victory via flags instead of reveals is a bit easier, I’ll playtest to see how it feels.

Maybe

  • [ ] Group board functions into submodules
    • Board.Action.reveal
    • Board.Info.unflagged_mines

Modules

defmodule Square do
  defstruct [:coordinates, :state, contains: :empty, adjacent_mines: nil]

  def new(x, y) do
    %__MODULE__{coordinates: {x, y}, state: :hidden}
  end

  def mine?(%__MODULE__{contains: :mine}), do: true
  def mine?(%__MODULE__{}), do: false

  def empty?(%__MODULE__{contains: :empty}), do: true
  def empty?(%__MODULE__{}), do: false

  def flag?(%__MODULE__{state: :flagged}), do: true
  def flag?(%__MODULE__{}), do: false

  def unicode(%__MODULE__{state: :flagged}), do: "🚩"
  def unicode(%__MODULE__{state: :hidden}), do: "⬜️"
  def unicode(%__MODULE__{contains: :empty, adjacent_mines: 0}), do: "🟩"

  def unicode(%__MODULE__{contains: :empty, adjacent_mines: mines}) when mines > 0 do
    ["#{mines}", "️", "⃣"] |> List.to_string()
  end

  def unicode(%__MODULE__{contains: :mine}), do: "💣"
  def unicode(%__MODULE__{contains: :explosion}), do: "💥"

  def reveal(square = %__MODULE__{state: :hidden, contains: :mine}) do
    square
    |> Map.put(:state, :revealed)
    |> Map.put(:contains, :explosion)
  end

  def reveal(square = %__MODULE__{state: :hidden}) do
    Map.put(square, :state, :revealed)
  end

  def reveal(square = %__MODULE__{}), do: square

  def peek(square = %__MODULE__{state: :hidden}) do
    Map.put(square, :state, :revealed)
  end

  def peek(square = %__MODULE__{}) do
    square
  end

  def calculate_neighbors({row, col}) do
    for drow <- -1..1, dcol <- -1..1, {drow, dcol} !== {0, 0} do
      {drow + row, dcol + col}
    end
  end

  def calculate_cardinals(%Square{coordinates: {row, col}}) do
    for {drow, dcol} <- [{0, -1}, {0, +1}, {-1, 0}, {+1, 0}] do
      {drow + row, dcol + col}
    end
  end

  def calculate_cardinals({row, col}) do
    for {drow, dcol} <- [{0, -1}, {0, +1}, {-1, 0}, {+1, 0}] do
      {drow + row, dcol + col}
    end
  end

  def mine(square = %__MODULE__{}) do
    Map.put(square, :contains, :mine)
  end

  def toggle_flag(square = %__MODULE__{state: :flagged}) do
    Map.put(square, :state, :hidden)
  end

  def toggle_flag(square = %__MODULE__{state: :hidden}) do
    Map.put(square, :state, :flagged)
  end

  def toggle_flag(square = %__MODULE__{state: _any}) do
    square
  end
end
defmodule Board do
  defstruct width: 20, height: 20, mine_count: 5, mines: [], grid: [], result: nil

  def new(width, height) do
    default_mine_count = %__MODULE__{}.mine_count
    new(width, height, default_mine_count)
  end

  def new(width, height, mine_count) when is_number(mine_count) do
    %__MODULE__{width: width, height: height, mine_count: mine_count}
    |> generate_grid()
    |> add_mines()
    |> add_counts()
  end

  def from_pattern(pattern) when is_list(pattern) do
    height = pattern |> Enum.count()
    width = pattern |> Enum.at(0) |> Enum.count()
    board = Board.new(width, height, 0)

    for col <- 0..(width - 1), row <- 0..(height - 1), reduce: board do
      acc ->
        token = pattern |> Enum.at(row) |> Enum.at(col)

        case token do
          "M" ->
            square = Board.at(acc, {row, col})
            bomb = Square.mine(square)
            Board.update(acc, bomb)

          _ ->
            acc
        end
        |> add_counts()
    end
  end

  def unicode_lists(board = %__MODULE__{}) do
    board.grid
    |> Enum.map(&amp;Square.unicode/1)
    |> Enum.chunk_every(board.width)
    |> Enum.map(fn row ->
      Enum.intersperse(row, " ")
    end)
    |> Enum.intersperse("\n")
  end

  def unicode(board = %__MODULE__{}) do
    board
    |> unicode_lists()
    |> Enum.join()
  end

  def render(board = %__MODULE__{}) do
    board
    |> unicode_lists()
    |> IO.puts()
  end

  def render(board = %__MODULE__{}, :counts) do
    board.grid
    |> Enum.map(&amp;"|#{&amp;1.adjacent_mines}|")
    |> Enum.chunk_every(board.width)
    |> Enum.map(fn row ->
      Enum.intersperse(row, " ")
    end)
    |> Enum.intersperse(?\n)
    |> IO.puts()
  end

  def neighbors(board = %__MODULE__{}, coordinates) do
    Square.calculate_neighbors(coordinates)
    |> Enum.filter(&amp;Board.member?(board, &amp;1))
    |> Enum.map(&amp;Board.at(board, &amp;1))
  end

  def neighboring_mines(board = %__MODULE__{}, coordinates) do
    neighbors(board, coordinates)
    |> Enum.filter(&amp;Square.mine?/1)
  end

  def connected(board = %__MODULE__{}, square = %Square{}) do
    square
    |> Square.calculate_cardinals()
    |> Enum.filter(&amp;Board.member?(board, &amp;1))
    |> Enum.map(&amp;Board.at(board, &amp;1))
    |> Enum.filter(fn square ->
      square.contains == :empty
    end)
  end

  def member?(board = %__MODULE__{}, coordinates) do
    Board.at(board, coordinates)
    |> case do
      nil -> false
      _ -> true
    end
  end

  def at(board = %__MODULE__{}, coordinates) do
    board.grid
    |> Enum.find(fn s ->
      s.coordinates == coordinates
    end)
  end

  def reveal(board = %__MODULE__{result: :loss}, _target), do: board

  def reveal(board = %__MODULE__{result: :win}, _target), do: board

  def reveal(board = %__MODULE__{}, {row, col}) do
    target = Board.at(board, {row, col})
    reveal(board, target)
  end

  def reveal(board = %__MODULE__{}, target = %Square{state: :hidden}) do
    do_reveal(board, target)
  end

  def reveal(board = %__MODULE__{}, _target), do: board

  def peek(board = %__MODULE__{}, :all) do
    %{board | grid: board.grid |> Enum.map(&amp;Square.peek/1)}
  end

  def peek(board = %__MODULE__{}, {row, col}) do
    target = Board.at(board, {row, col})

    cond do
      is_nil(target) ->
        board

      target.state == :hidden ->
        board |> Board.update(Square.peek(target))

      true ->
        board
    end
  end

  def toggle_flag(board = %__MODULE__{}, {row, col}) do
    target = Board.at(board, {row, col})
    updated_target = Square.toggle_flag(target)
    updated_board = Board.update(board, updated_target)

    if Square.flag?(updated_target) do
      Board.check_for_win(updated_board)
    else
      updated_board
    end
  end

  def update(board = %__MODULE__{}, square = %Square{}) do
    coordinates = square.coordinates

    new_grid =
      update_in(
        board.grid,
        [Access.filter(&amp;match?(%Square{coordinates: ^coordinates}, &amp;1))],
        fn _old -> square end
      )

    %{board | grid: new_grid}
  end

  def flags(board = %__MODULE__{}) do
    board.grid |> Enum.filter(&amp;Square.flag?/1)
  end

  def check_for_win(board = %__MODULE__{}) do
    flags = Board.flags(board)
    check_for_win(board, board.mines, flags)
  end

  def check_for_win(board = %__MODULE__{}, mines, flags) when length(mines) != length(flags) do
    board
  end

  def check_for_win(board = %__MODULE__{}, mines, flags) do
    mine_coordinates = mines |> Enum.map(&amp; &amp;1.coordinates) |> Enum.sort()
    flag_coordinates = flags |> Enum.map(&amp; &amp;1.coordinates) |> Enum.sort()

    if mine_coordinates == flag_coordinates do
      board = Board.peek(board, :all)
      %{board | result: :win}
    else
      board
    end
  end

  # -- private --

  defp do_reveal(board = %__MODULE__{}, target = %Square{}) do
    new_square = Square.reveal(target)
    coordinates = target.coordinates

    new_grid =
      update_in(
        board.grid,
        [Access.filter(&amp;match?(%Square{coordinates: ^coordinates}, &amp;1))],
        fn _old -> new_square end
      )

    new_board = %{board | grid: new_grid}

    cond do
      new_square.contains == :explosion ->
        handle_explosion(new_board, new_square)

      new_square.adjacent_mines == 0 ->
        connected = Board.connected(board, new_square)

        Enum.reduce(connected, new_board, fn square, board ->
          Board.reveal(board, square)
        end)

      true ->
        new_board
    end
  end

  defp handle_explosion(board = %__MODULE__{}, _revealed = %Square{}) do
    %{board | result: :loss}
  end

  defp generate_grid(board = %__MODULE__{width: 0}) do
    %{board | grid: []}
  end

  defp generate_grid(board = %__MODULE__{height: 0}) do
    %{board | grid: []}
  end

  defp generate_grid(board = %__MODULE__{}) do
    grid =
      for row <- 0..(board.height - 1), col <- 0..(board.width - 1) do
        Square.new(row, col)
      end

    %{board | grid: grid}
  end

  defp add_mines(board = %__MODULE__{}) do
    mine_coordinates =
      board.grid
      |> Enum.map(&amp; &amp;1.coordinates)
      |> Enum.take_random(board.mine_count)
      |> MapSet.new()

    mined_grid =
      board.grid
      |> Enum.map(fn s ->
        if MapSet.member?(mine_coordinates, s.coordinates) do
          Square.mine(s)
        else
          s
        end
      end)

    mines = mined_grid |> Enum.filter(&amp;Square.mine?/1)

    %{board | grid: mined_grid, mines: mines}
  end

  defp add_counts(board = %__MODULE__{}) do
    grid_with_counts =
      board.grid
      |> Enum.map(fn square ->
        adjacent_mines = Enum.count(Board.neighboring_mines(board, square.coordinates))
        %{square | adjacent_mines: adjacent_mines}
      end)

    %{board | grid: grid_with_counts}
  end
end

Usage

board = Board.new(3, 5, 1)
%{board | grid: board.grid |> Enum.map(&amp;Map.put(&amp;1, :state, :revealed))}
|> Board.render(:counts)
Board.render(board)
board
|> Board.peek(:all)
|> Board.render()
board
|> Board.reveal({0, 0})
|> Board.render()
Board.at(board, {0, 1})
Board.neighbors(board, {0, 0})
Board.neighboring_mines(board, {0, 0})
Board.reveal(board, {0, 0})
# {1, 1}
# want {1, 0}, {1, 2}, {0, 1}, {2, 1}
# {+0, -1}, {+0, +1}, {-1, +0}, {+1, +0}
Square.calculate_cardinals({3, 3})
Board.from_pattern([
  ["E", "E", "E", "E", "E"],
  ["E", "E", "M", "E", "E"],
  ["E", "E", "E", "E", "E"],
  ["E", "E", "E", "E", "E"]
])
|> Board.reveal({3, 0})
|> Board.render()

Kino

unicode = Board.unicode(board)
kino_board = Kino.Text.new(unicode)
frame = Kino.Frame.new()
Kino.Frame.render(frame, kino_board)

Interactive

inputs = [
  target: Kino.Input.text("Target e.g. 3,4"),
  action: Kino.Input.select("Action", [{:reveal, "Reveal"}, {:flag, "Flag/Unflag"}])
]

form = Kino.Control.form(inputs, submit: "Do The Thing")
Kino.animate(form, Board.new(7, 7, 2), fn event, board ->
  target =
    event[:data][:target]
    |> String.trim()
    |> String.replace(~r/[^\d,]/, "", global: true)
    |> String.split(",", trim: true)
    |> Enum.map(&amp;(Integer.parse(&amp;1) |> elem(0)))
    |> case do
      [row, col] -> {row, col}
      _ -> {-1, -1}
    end

  board =
    case event[:data][:action] do
      :flag ->
        Board.toggle_flag(board, target)

      :reveal ->
        Board.reveal(board, target)
    end

  render =
    case board.result do
      :loss ->
        IO.puts("BOOM")
        board |> Board.unicode() |> Kino.Text.new()

      :win ->
        IO.puts("You win!")
        board |> Board.unicode() |> Kino.Text.new()

      nil ->
        board |> Board.unicode() |> Kino.Text.new()
    end

  {:cont, render, board}
end)