From bd484f6922e2b060321286c4afd43af26c7b8f1c Mon Sep 17 00:00:00 2001 From: Alex Pearwin Date: Wed, 25 Oct 2023 14:54:58 +0100 Subject: [PATCH] Refactor. --- game.exs | 271 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 157 insertions(+), 114 deletions(-) diff --git a/game.exs b/game.exs index 7d644e8..13c613e 100644 --- a/game.exs +++ b/game.exs @@ -1,43 +1,68 @@ defmodule Game do - # Size of each board side. - @board_size 4 - # Value of the singular piece present at the beginning of the game. - @starting_number 2 - # Value of the piece randomly inserted into the board at the beginning of - # each turn. - @turn_number 1 - # Value of the piece which, when present on the board, results in a win. - @winning_number 2048 - # Size of each cell, to accommodate the largest (i.e. winning) number. - @cell_size (@winning_number |> Integer.to_string() |> String.length()) + 2 - - def init do - board = starting_board() - %{board: board, score: 0, turns: 0, terminated: false} - end - - def run(%{terminated: false} = state) do - state = if won?(state) do - %{state | terminated: true} - else - IO.puts(view(state)) - move = get_move() - state = update(state, move) - %{state | terminated: exhausted?(state)} - end - - run(state) + @default_options [ + # Number of cells per row and per column. + board_dimensions: {3, 2}, + # Value of the singular piece present at the beginning of the game. + starting_number: 2, + # Value of the piece randomly inserted into the board at the beginning of + # each turn. + turn_start_number: 1, + # Value of the piece which, when present on the board, results in a win. + winning_number: 4 + ] + @cell_size 5 + + def init(opts \\ []) do + opts = Keyword.validate!(opts, @default_options) + + game = %{ + board: starting_board(opts[:board_dimensions], opts[:starting_number]), + score: 0, + turns: 0, + state: :running, + turn_start_number: opts[:turn_start_number], + winning_number: opts[:winning_number], + # Size of each cell, to accommodate the largest (i.e. winning) number. + cell_render_size: (opts[:winning_number] |> Integer.to_string() |> String.length()) + 2 + } + + render_game(game) + game + end + + def tick(%{state: :running} = game) do + # Get input. + move = get_move() + # Update state. + game = update(game, move) + # Render. + render_game(game) + game + end + + def run(%{state: :running} = game) do + game + |> tick() + |> run() + end + + def run(%{state: :won}) do + IO.puts("You won!") + :ok end - def run (%{terminated: true} = state) do - IO.puts(view(state)) - IO.puts("End!") + def run(%{state: :exhausted}) do + IO.puts("You lost!") :ok end - defp starting_board do - empty_board = for x <- 1..@board_size, y <- 1..@board_size, into: %{}, do: {{x, y}, nil} - add_value(empty_board, @starting_number) + defp starting_board({num_rows, num_cols}, starting_number) do + empty_cells = + for row <- 1..num_rows, col <- 1..num_cols, into: %{}, do: {{row, col}, nil} + + board = %{cells: empty_cells, dimensions: {num_rows, num_cols}} + + add_value(board, starting_number) end defp get_move() do @@ -52,122 +77,140 @@ defmodule Game do defp input_to_move("k"), do: :up defp input_to_move("l"), do: :right - defp exhausted?(%{board: board} = _state) do + defp exhausted?(%{board: %{cells: cells}} = _game) do # A game board is exhausted if, at the beginning of a turn, there is no - # space left on the board. - board + # empty cells left on the board. + cells |> Map.values() |> Enum.all?() end - defp won?(%{board: board} = _state) do - # A game is won if, at the beginning of a turn, the game contains the - # winning number. - board + defp won?(%{board: %{cells: cells}, winning_number: winning_number} = _game) do + # A game is won if, at the end of a turn, a cell contains the winning + # number. + cells |> Map.values() - |> Enum.any?(fn value -> value == @winning_number end) + |> Enum.any?(fn value -> value == winning_number end) end - defp view(%{board: board, turns: turns, score: score} = _state) do + defp render_game(%{board: board, turns: turns, score: score} = _game) do rendered_board = render_board(board) - """ + + IO.puts(""" Turn ##{turns}. Score: #{score}. #{rendered_board} - """ + """) end - defp render_board(board) do - Enum.map(1..@board_size, fn row -> - Enum.map(1..@board_size, fn col -> - render_cell(board, {row, col}) + defp render_board(%{cells: cells, dimensions: {num_rows, num_cols}}) do + Enum.map(1..num_rows, fn row -> + Enum.map(1..num_cols, fn col -> + render_cell(cells, {row, col}) end) |> Enum.join("") end) |> Enum.join("\n") end - defp render_cell(board, coordinate) do - board + defp render_cell(cells, coordinate) do + cells |> render_value(coordinate) |> String.pad_leading(1) |> String.pad_trailing(@cell_size - 1) end - defp render_value(board, coordinate) do - case board[coordinate] do + defp render_value(cells, coordinate) do + case cells[coordinate] do nil -> "-" value -> Integer.to_string(value) end end - defp update(%{board: board, turns: turns} = state, move) do - # 1. Merge pieces. + defp update( + %{board: board, turns: turns, turn_start_number: turn_start_number, state: :running} = + game, + move + ) do + # TODO Increase score (by the sum of newly merged pieces). board = merge_values(board, move) - # 2. Move pieces. board = move_values(board, move) - # 3. Insert new piece if pieces were merged or moved. - # TODO gross that we check exhaustion here as well as the main game loop; - # would ideally insert the new element at the start of the main loop. - board = if exhausted?(state), do: board, else: add_value(board, @turn_number) - # 4. Increment turn number if pieces were merged or moved. - # 5. Increase score (by the sum of newly merged pieces). - %{state | board: board, turns: turns + 1} + # TODO only increment turns and check for wins/exhaustion if the move + # actually modified the board. + game = %{game | board: board, turns: turns + 1} + + if won?(game) do + %{game | state: :won} + else + if exhausted?(game) do + %{game | state: :exhausted} + else + %{game | board: add_value(board, turn_start_number)} + end + end end - defp merge_values(board, move) do + defp merge_values(%{cells: cells} = board, move) do updates = - board - |> rows_for_move(move) - |> Enum.map(fn row -> - Enum.map(row, fn coord -> {coord, board[coord]} end) - end) - |> Enum.flat_map(fn row -> - new_row = Enum.into(row, %{}) - {new_row, _} = Enum.reduce(row, {new_row, nil}, fn {coord, current_value}, {new_row, last_non_empty_coord} -> - if is_nil(current_value) do - {new_row, last_non_empty_coord} - else - if current_value == new_row[last_non_empty_coord] do - {%{new_row | last_non_empty_coord => 2 * current_value, coord => nil}, nil} - else - {new_row, coord} - end - end + rows_for_move(board, move) + |> Enum.map(fn row -> + Enum.map(row, fn coord -> {coord, cells[coord]} end) end) - Map.to_list(new_row) - end) - |> Enum.into(%{}) + |> Enum.flat_map(fn row -> + new_row = Enum.into(row, %{}) + + {new_row, _} = + Enum.reduce(row, {new_row, nil}, fn {coord, current_value}, + {new_row, last_non_empty_coord} -> + if is_nil(current_value) do + {new_row, last_non_empty_coord} + else + if current_value == new_row[last_non_empty_coord] do + {%{new_row | last_non_empty_coord => 2 * current_value, coord => nil}, nil} + else + {new_row, coord} + end + end + end) + + Map.to_list(new_row) + end) + |> Enum.into(%{}) - Map.merge(board, updates) + update_board(board, updates) end - defp move_values(board, move) do + defp move_values(%{cells: cells} = board, move) do # Conceptually, for each 'row' of values being moved: # 1. Create a new row with all non-empty cells. # 2. Pad the row up to the board size with empty cells. - updates = rows_for_move(board, move) - |> Enum.flat_map(fn row -> - # Non-empty cells in the same order they appear in the row. - values = - row - |> Enum.map(&Map.fetch!(board, &1)) - |> Enum.filter(& &1) - - # Empty cells needed to pad out the new row. - padding = List.duplicate(nil, Enum.count(row) - Enum.count(values)) - - # Zip the original coordinates with the new values. - Enum.zip(row, values ++ padding) - end) - |> Enum.into(%{}) + updates = + rows_for_move(board, move) + |> Enum.flat_map(fn row -> + # Non-empty cells in the same order they appear in the row. + values = + row + |> Enum.map(&Map.fetch!(cells, &1)) + |> Enum.filter(& &1) + + # Empty cells needed to pad out the new row. + padding = List.duplicate(nil, Enum.count(row) - Enum.count(values)) + + # Zip the original coordinates with the new values. + Enum.zip(row, values ++ padding) + end) + |> Enum.into(%{}) - Map.merge(board, updates) + update_board(board, updates) end - defp rows_for_move(board, :left) do - for row <- 1..@board_size do - for col <- 1..@board_size, do: {row, col} + defp update_board(%{cells: cells} = board, updates) do + %{board | cells: Map.merge(cells, updates)} + end + + defp rows_for_move(%{dimensions: {num_rows, num_cols}} = _board, :left) do + for row <- 1..num_rows do + for col <- 1..num_cols, do: {row, col} end end @@ -190,17 +233,17 @@ defmodule Game do |> Enum.map(&Enum.reverse/1) end - defp add_value(board, value) do + defp add_value(%{cells: cells} = board, value) do # Add the value to a randomly chosen empty coordinate. - random_coord = - board - |> Map.keys() - |> Enum.filter(fn coord -> is_nil(board[coord]) end) - |> Enum.random() + random_coord = + cells + |> Map.keys() + |> Enum.filter(fn coord -> is_nil(cells[coord]) end) + |> Enum.random() - %{board | random_coord => value} + %{board | cells: Map.put(cells, random_coord, value)} end end -state = Game.init() -Game.run(state) \ No newline at end of file +game = Game.init() +Game.run(game)