diff --git a/lib/twenty_fourty_eight/application.ex b/lib/twenty_fourty_eight/application.ex index 1e65443..ff904d7 100644 --- a/lib/twenty_fourty_eight/application.ex +++ b/lib/twenty_fourty_eight/application.ex @@ -14,8 +14,8 @@ defmodule TwentyFourtyEight.Application do {Phoenix.PubSub, name: TwentyFourtyEight.PubSub}, # Start the Finch HTTP client for sending emails {Finch, name: TwentyFourtyEight.Finch}, - # Start a worker by calling: TwentyFourtyEight.Worker.start_link(arg) - # {TwentyFourtyEight.Worker, arg}, + {Registry, keys: :unique, name: TwentyFourtyEight.Game.Registry}, + {DynamicSupervisor, name: TwentyFourtyEight.Game.Supervisor, strategy: :one_for_one}, # Start to serve requests, typically the last entry TwentyFourtyEightWeb.Endpoint ] diff --git a/lib/twenty_fourty_eight/game/engine.ex b/lib/twenty_fourty_eight/game/engine.ex new file mode 100644 index 0000000..805638c --- /dev/null +++ b/lib/twenty_fourty_eight/game/engine.ex @@ -0,0 +1,191 @@ +defmodule TwentyFourtyEight.Game.Engine do + @default_options [ + # Number of cells per row and per column. + board_dimensions: {6, 6}, + # 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: 2048 + ] + @valid_moves [:up, :down, :left, :right] + + def init(opts \\ []) do + opts = Keyword.validate!(opts, @default_options) + + # TODO validate that value options are all powers of two and that starting + # and turn start values are both less than winning value. + + %{ + 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] + } + end + + def tick(%{state: :running} = game, move) when move in @valid_moves do + update(game, move) + end + + 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 exhausted?(%{board: %{cells: cells}} = _game) do + # A game board is exhausted if, at the beginning of a turn, there is no + # empty cells left on the board. + cells + |> Map.values() + |> Enum.all?() + end + + 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) + end + + 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) + board = move_values(board, move) + # 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(%{cells: cells} = board, move) do + # For each row, we run a two-pointer algorithm where: + # + # * Pointer #1 iterates through the row. + # * Pointer #2 points to the latest non-empty, non-modified cell behind pointer #1. + # + # As #1 iterates, if its current cell is not empty and: + # + # * Has the same value as the cell of #2: the cell of #1 will be merged into that of #2 (the #2 cell + # value will be doubled and the #1 cell will be emptied) and the #2 pointer will + # be nullified. Or; + # * Does not have the same value as the cell of #2: the #2 pointer + # is updated to point to #1 before #1 continues its iteration. + updates = + rows_for_move(board, move) + |> Enum.map(fn row -> + Enum.map(row, fn coord -> {coord, cells[coord]} end) + end) + |> Enum.flat_map(&merge_row_values(&1)) + |> Enum.into(%{}) + + update_board(board, updates) + end + + defp merge_row_values(row) do + # row is a list of {{row, col}, value} elements. + 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 + + 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!(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(%{}) + + update_board(board, updates) + end + + 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 + + defp rows_for_move(board, :right) do + board + |> rows_for_move(:left) + |> Enum.map(&Enum.reverse/1) + end + + defp rows_for_move(board, :up) do + board + |> rows_for_move(:left) + |> Enum.zip() + |> Enum.map(&Tuple.to_list/1) + end + + defp rows_for_move(board, :down) do + board + |> rows_for_move(:up) + |> Enum.map(&Enum.reverse/1) + end + + defp add_value(%{cells: cells} = board, value) do + # Add the value to a randomly chosen empty coordinate. + random_coord = + cells + |> Map.keys() + |> Enum.filter(fn coord -> is_nil(cells[coord]) end) + |> Enum.random() + + %{board | cells: Map.put(cells, random_coord, value)} + end +end diff --git a/lib/twenty_fourty_eight/game/manager.ex b/lib/twenty_fourty_eight/game/manager.ex new file mode 100644 index 0000000..d6c8df4 --- /dev/null +++ b/lib/twenty_fourty_eight/game/manager.ex @@ -0,0 +1,55 @@ +defmodule TwentyFourtyEight.Game.Manager do + use GenServer, restart: :transient + + alias TwentyFourtyEight.Game.Engine + + @registry TwentyFourtyEight.Game.Registry + @supervisor TwentyFourtyEight.Game.Supervisor + + def new_game(name) when is_binary(name) do + case Registry.lookup(@registry, name) do + [{pid, _value}] -> {:ok, pid} + [] -> DynamicSupervisor.start_child(@supervisor, {__MODULE__, name}) + end + end + + def tick(name, move) do + GenServer.call(via_tuple(name), {:tick, move}) + end + + def state(name) do + GenServer.call(via_tuple(name), :state) + end + + def start_link(name) do + GenServer.start_link(__MODULE__, [name: name], name: via_tuple(name)) + end + + @impl true + def init(name: name) do + IO.puts("Starting manager #{name}") + Process.flag(:trap_exit, true) + {:ok, Engine.init()} + end + + @impl true + def handle_call({:tick, move}, _from, game) do + {:reply, :ok, Engine.tick(game, move)} + end + + @impl true + def handle_call(:state, _from, game) do + response = Map.take(game, [:board, :score, :turns, :state]) + {:reply, response, game} + end + + @impl true + def handle_info({:EXIT, from, reason}, game) do + IO.puts("Trapped exit from #{inspect(from)} because #{inspect(reason)}") + {:stop, reason, game} + end + + defp via_tuple(name) do + {:via, Registry, {@registry, name}} + end +end \ No newline at end of file diff --git a/lib/twenty_fourty_eight_web/components/layouts/app.html.heex b/lib/twenty_fourty_eight_web/components/layouts/app.html.heex index e23bfc8..cc78d44 100644 --- a/lib/twenty_fourty_eight_web/components/layouts/app.html.heex +++ b/lib/twenty_fourty_eight_web/components/layouts/app.html.heex @@ -1,31 +1,6 @@ -
-
-
- - - -

- v<%= Application.spec(:phoenix, :vsn) %> -

-
- -
-
+

Twenty Fourty Eight

<.flash_group flash={@flash} /> <%= @inner_content %>
diff --git a/lib/twenty_fourty_eight_web/controllers/page_html/home.html.heex b/lib/twenty_fourty_eight_web/controllers/page_html/home.html.heex index e9fc48d..637ed6e 100644 --- a/lib/twenty_fourty_eight_web/controllers/page_html/home.html.heex +++ b/lib/twenty_fourty_eight_web/controllers/page_html/home.html.heex @@ -1,222 +1 @@ <.flash_group flash={@flash} /> - -
-
- -

- Phoenix Framework - - v<%= Application.spec(:phoenix, :vsn) %> - -

-

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

- -
-
diff --git a/lib/twenty_fourty_eight_web/live/game_live.ex b/lib/twenty_fourty_eight_web/live/game_live.ex new file mode 100644 index 0000000..077591c --- /dev/null +++ b/lib/twenty_fourty_eight_web/live/game_live.ex @@ -0,0 +1,82 @@ +defmodule TwentyFourtyEightWeb.GameLive do + use TwentyFourtyEightWeb, :live_view + + alias TwentyFourtyEight.Game.Manager, as: GameManager + + def render(assigns) do + ~H""" +
+ Name: <%= @name %> + Turn number: <%= @turns %> + Score: <%= @score %> + +
+ <%= for row <- 1..@num_rows do %> + <%= for col <- 1..@num_cols do %> + <%= if is_nil(@board[{row, col}]) do %> +
 
+ <% else %> +
<%= @board[{row, col}] %>
+ <% end %> + <% end %> + <% end %> +
+
+ """ + end + + def mount(%{"name" => name} = _params, _session, socket) do + # TODO this is called twice, once at GET and once at WS connection. + # no need to create two games! just need to make sure assigns are + # filled in with placeholders + {:ok, pid} = GameManager.new_game(name) + Process.link(pid) + + socket = socket + |> assign_game(name) + {:ok, socket} + end + + def mount(_params, _session, socket) do + name = generate_name() + {:ok, push_navigate(socket, to: "/#{name}")} + end + + def handle_event("move", %{"key" => key}, %{assigns: %{name: name}} = socket) when key in ~w(h j k l) do + :ok = GameManager.tick(name, key_to_move(key)) + {:noreply, assign_game_state(socket, name)} + end + + def handle_event("move", params, socket) do + {:noreply, socket} + end + + defp key_to_move("h"), do: :left + defp key_to_move("j"), do: :down + defp key_to_move("k"), do: :up + defp key_to_move("l"), do: :right + + defp generate_name do + ?a..?z + |> Enum.take_random(6) + |> List.to_string() + end + + defp assign_game(socket, name) do + socket + |> assign(name: name) + |> assign_game_state(name) + end + + defp assign_game_state(socket, name) do + game_state = GameManager.state(name) + {%{cells: board, dimensions: {num_rows, num_cols}}, game_state} = Map.pop(game_state, :board) + socket + |> assign(num_rows: num_rows, num_cols: num_cols, board: board) + |> assign(game_state) + end +end \ No newline at end of file diff --git a/lib/twenty_fourty_eight_web/router.ex b/lib/twenty_fourty_eight_web/router.ex index 62e7bed..32fc53f 100644 --- a/lib/twenty_fourty_eight_web/router.ex +++ b/lib/twenty_fourty_eight_web/router.ex @@ -17,7 +17,8 @@ defmodule TwentyFourtyEightWeb.Router do scope "/", TwentyFourtyEightWeb do pipe_through :browser - get "/", PageController, :home + live "/", GameLive + live "/:name", GameLive end # Other scopes may use custom stacks.