From 4a0ac2e061aa60fa6de7936c97bec4e08b3692d4 Mon Sep 17 00:00:00 2001 From: Alex Pearwin Date: Fri, 27 Oct 2023 23:34:25 +0100 Subject: [PATCH] Checkpoint. --- assets/css/app.css | 18 ++++- lib/twenty_fourty_eight/game/engine.ex | 77 +++++++++++++------ lib/twenty_fourty_eight/game/game.ex | 4 + lib/twenty_fourty_eight/game/manager.ex | 13 ++-- lib/twenty_fourty_eight_web/live/game_live.ex | 23 ++++-- 5 files changed, 94 insertions(+), 41 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index f880f41..c4302c5 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -49,13 +49,17 @@ h1 { color: var(--text-color); } -a { +h1 a { position: relative; text-decoration: none; } +h1 a:hover { + text-decoration: none; +} + /* Animate a solid underline sliding in and out on hover. */ -a::after { +h1 a::after { content: ''; position: absolute; width: 100%; @@ -69,11 +73,19 @@ a::after { transition: transform 0.25s ease-out; } -a:hover::after { +h1 a:hover::after { transform: scaleX(1); transform-origin: bottom left; } +a { + color: var(--text-color); +} + +a:hover { + text-decoration: underline; +} + .cozy { padding: 8px; background-color: #fbcfe8; diff --git a/lib/twenty_fourty_eight/game/engine.ex b/lib/twenty_fourty_eight/game/engine.ex index 805638c..40e2411 100644 --- a/lib/twenty_fourty_eight/game/engine.ex +++ b/lib/twenty_fourty_eight/game/engine.ex @@ -1,5 +1,6 @@ defmodule TwentyFourtyEight.Game.Engine do - @default_options [ + # TODO obstacles + @default_new_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. @@ -10,26 +11,34 @@ defmodule TwentyFourtyEight.Game.Engine do # Value of the piece which, when present on the board, results in a win. winning_number: 2048 ] + @all_options Keyword.keys(@default_new_options) ++ [:board, :score, :turns, :state] @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]), + def init(%{state: :new} = opts) do + opts + # |> Keyword.validate!(@default_new_options) + |> Map.merge(%{ + board: starting_board({opts.num_rows, opts.num_cols}, opts.starting_number), score: 0, turns: 0, - state: :running, - turn_start_number: opts[:turn_start_number], - winning_number: opts[:winning_number] + state: :running + }) + |> init() + end + + def init(opts) do + %{ + board: opts.board, + score: opts.score, + turns: opts.turns, + state: opts.state, + 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) + apply_move(game, move) end defp starting_board({num_rows, num_cols}, starting_number) do @@ -57,17 +66,37 @@ defmodule TwentyFourtyEight.Game.Engine do |> 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} + defp apply_move(%{board: board} = game, move) do + updated_board = board |> merge_values(move) |> move_values(move) + + # Only need to update state if the board changed. + if Map.equal?(board, updated_board) do + game + else + turn_score = compute_score(board, updated_board) + apply_turn(game, updated_board, turn_score) + end + end + + defp compute_score(%{cells: cells_before} = _board_before, %{cells: cells_after} = _board_after) do + value_counts_before = cells_before |> Map.values() |> Enum.frequencies() + value_counts_after = cells_after |> Map.values() |> Enum.frequencies() + + # Any values not present after a move must be due to merges. + # Credit merges as the sum of all disappearing values. + value_counts_before + |> Enum.map(fn + {nil, _count} -> 0 + + {value, count} -> + difference = count - Map.get(value_counts_after, value, 0) + if difference > 0, do: value * difference, else: 0 + end) + |> Enum.sum() + end + + defp apply_turn(%{score: score, turns: turns, turn_start_number: turn_start_number} = game, board, turn_score) do + game = %{game | board: board, turns: turns + 1, score: score + turn_score} if won?(game) do %{game | state: :won} diff --git a/lib/twenty_fourty_eight/game/game.ex b/lib/twenty_fourty_eight/game/game.ex index 05d68f7..17aeb69 100644 --- a/lib/twenty_fourty_eight/game/game.ex +++ b/lib/twenty_fourty_eight/game/game.ex @@ -16,6 +16,10 @@ defmodule TwentyFourtyEight.Game.Game do field :turn_start_number, :integer, default: 1 field :winning_number, :integer, default: 2048 field :slug, :string + field :score, :integer + field :turns, :integer + field :state, Ecto.Enum, values: [:new, :running, :won, :exhausted] + field :board, :map timestamps() end diff --git a/lib/twenty_fourty_eight/game/manager.ex b/lib/twenty_fourty_eight/game/manager.ex index 3c9e780..edd161f 100644 --- a/lib/twenty_fourty_eight/game/manager.ex +++ b/lib/twenty_fourty_eight/game/manager.ex @@ -6,10 +6,10 @@ defmodule TwentyFourtyEight.Game.Manager do @registry TwentyFourtyEight.Game.Registry @supervisor TwentyFourtyEight.Game.Supervisor - def get_game(name, _state) when is_binary(name) do + def get_game(name, state) when is_binary(name) do case Registry.lookup(@registry, name) do [{pid, _value}] -> {:ok, pid} - [] -> DynamicSupervisor.start_child(@supervisor, {__MODULE__, name}) + [] -> DynamicSupervisor.start_child(@supervisor, {__MODULE__, {name, state}}) end end @@ -21,15 +21,14 @@ defmodule TwentyFourtyEight.Game.Manager do GenServer.call(via_tuple(name), :state) end - def start_link(name) do - GenServer.start_link(__MODULE__, [name: name], name: via_tuple(name)) + def start_link({name, state}) do + GenServer.start_link(__MODULE__, state, name: via_tuple(name)) end @impl true - def init(name: name) do - IO.puts("Starting manager #{name}") + def init(state) do Process.flag(:trap_exit, true) - {:ok, Engine.init()} + {:ok, Engine.init(state)} end @impl true diff --git a/lib/twenty_fourty_eight_web/live/game_live.ex b/lib/twenty_fourty_eight_web/live/game_live.ex index 6b35740..efe31bc 100644 --- a/lib/twenty_fourty_eight_web/live/game_live.ex +++ b/lib/twenty_fourty_eight_web/live/game_live.ex @@ -4,18 +4,18 @@ defmodule TwentyFourtyEightWeb.GameLive do alias TwentyFourtyEight.Game.Game alias TwentyFourtyEight.Game.Manager, as: GameManager - # Support arrows keys as well as hjkl (Vim) and wasd (gaming). - @up_keys ["ArrowUp", "w", "k"] - @down_keys ["ArrowDown", "s", "j"] - @left_keys ["ArrowLeft", "a", "h"] - @right_keys ["ArrowRight", "d", "l"] + # Support arrows keys, hjkl, and wasd for movement. + @up_keys ~w(ArrowUp w k) + @down_keys ~w(ArrowDown s j) + @left_keys ~w(ArrowLeft a h) + @right_keys ~w(ArrowRight d l) @known_keys @up_keys ++ @down_keys ++ @left_keys ++ @right_keys def render(assigns) do ~H"""
-
Name <%= @name %>
+
Name <%= @name %>
Score <%= @score %>
Turns <%= @turns %>
@@ -37,6 +37,10 @@ defmodule TwentyFourtyEightWeb.GameLive do # TODO change game to die after no interaction (or put similar logic in the manager?) {:ok, _pid} = GameManager.get_game(name, game) + if connected?(socket) do + Phoenix.PubSub.subscribe(TwentyFourtyEight.PubSub, name) + end + {:ok, assign_game(socket, name)} end end @@ -47,11 +51,16 @@ defmodule TwentyFourtyEightWeb.GameLive do def handle_event("move", %{"key" => key}, %{assigns: %{name: name, state: :running}} = socket) when key in @known_keys do :ok = GameManager.tick(name, key_to_move(key)) - {:noreply, assign_game_state(socket, name)} + Phoenix.PubSub.broadcast(TwentyFourtyEight.PubSub, name, {:update, name}) + {:noreply, socket} end def handle_event("move", _params, socket), do: {:noreply, socket} + def handle_info({:update, name}, socket) do + {:noreply, assign_game_state(socket, name)} + end + defp key_to_move(up) when up in @up_keys, do: :up defp key_to_move(down) when down in @down_keys, do: :down defp key_to_move(left) when left in @left_keys, do: :left