From e89f3536e4075edd658e5da760b03d8fa2eecfd4 Mon Sep 17 00:00:00 2001 From: Alex Pearwin Date: Fri, 27 Oct 2023 21:31:19 +0100 Subject: [PATCH] Checkpoint. --- assets/css/app.css | 27 ++++++++- lib/twenty_fourty_eight/application.ex | 3 +- lib/twenty_fourty_eight/game/game.ex | 47 +++++++++++++++ lib/twenty_fourty_eight/game/manager.ex | 4 +- .../components/core_components.ex | 2 +- .../components/layouts/root.html.heex | 2 +- .../controllers/game_controller.ex | 14 ++++- .../controllers/game_html.ex | 27 ++++++++- .../controllers/game_html/new.html.heex | 1 - lib/twenty_fourty_eight_web/live/game_live.ex | 57 ++++++++++--------- .../20231027172144_create_game_table.exs | 27 +++++++++ .../controllers/error_html_test.exs | 3 +- .../controllers/error_json_test.exs | 4 +- 13 files changed, 177 insertions(+), 41 deletions(-) create mode 100644 lib/twenty_fourty_eight/game/game.ex delete mode 100644 lib/twenty_fourty_eight_web/controllers/game_html/new.html.heex create mode 100644 priv/repo/migrations/20231027172144_create_game_table.exs diff --git a/assets/css/app.css b/assets/css/app.css index dce23b9..f880f41 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -49,7 +49,32 @@ h1 { color: var(--text-color); } -.game { +a { + position: relative; + text-decoration: none; +} + +/* Animate a solid underline sliding in and out on hover. */ +a::after { + content: ''; + position: absolute; + width: 100%; + height: 0.075em; + bottom: 0.1em; + left: 0; + z-index: -1; + background-color: var(--text-color); + transform: scaleX(0); + transform-origin: bottom right; + transition: transform 0.25s ease-out; +} + +a:hover::after { + transform: scaleX(1); + transform-origin: bottom left; +} + +.cozy { padding: 8px; background-color: #fbcfe8; border-radius: 8px; diff --git a/lib/twenty_fourty_eight/application.ex b/lib/twenty_fourty_eight/application.ex index ff904d7..c9d7041 100644 --- a/lib/twenty_fourty_eight/application.ex +++ b/lib/twenty_fourty_eight/application.ex @@ -10,7 +10,8 @@ defmodule TwentyFourtyEight.Application do children = [ TwentyFourtyEightWeb.Telemetry, TwentyFourtyEight.Repo, - {DNSCluster, query: Application.get_env(:twenty_fourty_eight, :dns_cluster_query) || :ignore}, + {DNSCluster, + query: Application.get_env(:twenty_fourty_eight, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: TwentyFourtyEight.PubSub}, # Start the Finch HTTP client for sending emails {Finch, name: TwentyFourtyEight.Finch}, diff --git a/lib/twenty_fourty_eight/game/game.ex b/lib/twenty_fourty_eight/game/game.ex new file mode 100644 index 0000000..05d68f7 --- /dev/null +++ b/lib/twenty_fourty_eight/game/game.ex @@ -0,0 +1,47 @@ +defmodule TwentyFourtyEight.Game.Game do + use Ecto.Schema + import Ecto.Changeset + + alias TwentyFourtyEight.Repo + + @slug_length 8 + @slug_alphabet String.graphemes( + "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + ) + + schema "game" do + field :num_rows, :integer, default: 6 + field :num_cols, :integer, default: 6 + field :starting_number, :integer, default: 2 + field :turn_start_number, :integer, default: 1 + field :winning_number, :integer, default: 2048 + field :slug, :string + timestamps() + end + + def changeset(attrs) do + %__MODULE__{} + |> cast(attrs, [:num_rows, :num_cols, :starting_number, :turn_start_number, :winning_number]) + |> validate_inclusion(:num_rows, 1..6) + |> validate_inclusion(:num_cols, 1..6) + |> validate_inclusion(:starting_number, [1, 2, 4]) + |> validate_inclusion(:turn_start_number, [1, 2, 4]) + |> validate_inclusion(:winning_number, [1024, 2048]) + end + + def insert(changeset) do + changeset + |> cast(%{slug: generate_slug()}, [:slug]) + |> unique_constraint(:slug) + |> Repo.insert() + end + + def get_by_slug(slug) do + Repo.get_by(__MODULE__, slug: slug) + end + + defp generate_slug() do + 1..@slug_length + |> Enum.map_join("", fn _ -> Enum.random(@slug_alphabet) end) + end +end diff --git a/lib/twenty_fourty_eight/game/manager.ex b/lib/twenty_fourty_eight/game/manager.ex index cf3a354..3c9e780 100644 --- a/lib/twenty_fourty_eight/game/manager.ex +++ b/lib/twenty_fourty_eight/game/manager.ex @@ -6,7 +6,7 @@ defmodule TwentyFourtyEight.Game.Manager do @registry TwentyFourtyEight.Game.Registry @supervisor TwentyFourtyEight.Game.Supervisor - def get_game(name) 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}) @@ -52,4 +52,4 @@ defmodule TwentyFourtyEight.Game.Manager do defp via_tuple(name) do {:via, Registry, {@registry, name}} end -end \ No newline at end of file +end diff --git a/lib/twenty_fourty_eight_web/components/core_components.ex b/lib/twenty_fourty_eight_web/components/core_components.ex index 12e3649..7d9954c 100644 --- a/lib/twenty_fourty_eight_web/components/core_components.ex +++ b/lib/twenty_fourty_eight_web/components/core_components.ex @@ -201,7 +201,7 @@ defmodule TwentyFourtyEightWeb.CoreComponents do def simple_form(assigns) do ~H""" <.form :let={f} for={@for} as={@as} {@rest}> -
+
<%= render_slot(@inner_block, f) %>
<%= render_slot(action, f) %> diff --git a/lib/twenty_fourty_eight_web/components/layouts/root.html.heex b/lib/twenty_fourty_eight_web/components/layouts/root.html.heex index aa00f2a..48a0d93 100644 --- a/lib/twenty_fourty_eight_web/components/layouts/root.html.heex +++ b/lib/twenty_fourty_eight_web/components/layouts/root.html.heex @@ -12,7 +12,7 @@ -

Twenty Fourty Eight.

+

Twenty Fourty Eight.

<.flash_group flash={@flash} /> <%= @inner_content %> diff --git a/lib/twenty_fourty_eight_web/controllers/game_controller.ex b/lib/twenty_fourty_eight_web/controllers/game_controller.ex index 3875516..d8f3355 100644 --- a/lib/twenty_fourty_eight_web/controllers/game_controller.ex +++ b/lib/twenty_fourty_eight_web/controllers/game_controller.ex @@ -1,11 +1,19 @@ defmodule TwentyFourtyEightWeb.GameController do use TwentyFourtyEightWeb, :controller + alias TwentyFourtyEight.Game.Game + def new(conn, _params) do - render(conn, :new) + changeset = Game.changeset(%{}) + render(conn, :new, changeset: changeset) end - def create(conn, _params) do - redirect(conn, to: ~p"/#{id}") + def create(conn, params) do + changeset = Game.changeset(params["game"]) + + case Game.insert(changeset) do + {:ok, game} -> redirect(conn, to: ~p"/#{game.slug}") + {:error, changeset} -> render(conn, :new, changeset: changeset) + end end end diff --git a/lib/twenty_fourty_eight_web/controllers/game_html.ex b/lib/twenty_fourty_eight_web/controllers/game_html.ex index 25ec568..f2cc1be 100644 --- a/lib/twenty_fourty_eight_web/controllers/game_html.ex +++ b/lib/twenty_fourty_eight_web/controllers/game_html.ex @@ -1,5 +1,30 @@ defmodule TwentyFourtyEightWeb.GameHTML do use TwentyFourtyEightWeb, :html - embed_templates "game_html/*" + def new(assigns) do + ~H""" +
+ <.simple_form :let={f} for={@changeset} action={~p"/"}> + <.input field={f[:num_rows]} type="select" options={1..6} label="Number of rows" /> + <.input field={f[:num_cols]} type="select" options={1..6} label="Number of columns" /> + <.input field={f[:starting_number]} type="select" options={[1, 2, 4]} label="First number" /> + <.input + field={f[:turn_start_number]} + type="select" + options={[1, 2, 4]} + label="Number added at each turn" + /> + <.input + field={f[:winning_number]} + type="select" + options={[1024, 2048]} + label="Number required to win" + /> + <:actions> + <.button>Start game + + +
+ """ + end end diff --git a/lib/twenty_fourty_eight_web/controllers/game_html/new.html.heex b/lib/twenty_fourty_eight_web/controllers/game_html/new.html.heex deleted file mode 100644 index 3558e9a..0000000 --- a/lib/twenty_fourty_eight_web/controllers/game_html/new.html.heex +++ /dev/null @@ -1 +0,0 @@ -Yo! \ No newline at end of file diff --git a/lib/twenty_fourty_eight_web/live/game_live.ex b/lib/twenty_fourty_eight_web/live/game_live.ex index 8d9c165..6b35740 100644 --- a/lib/twenty_fourty_eight_web/live/game_live.ex +++ b/lib/twenty_fourty_eight_web/live/game_live.ex @@ -1,6 +1,7 @@ defmodule TwentyFourtyEightWeb.GameLive do use TwentyFourtyEightWeb, :live_view + alias TwentyFourtyEight.Game.Game alias TwentyFourtyEight.Game.Manager, as: GameManager # Support arrows keys as well as hjkl (Vim) and wasd (gaming). @@ -12,7 +13,7 @@ defmodule TwentyFourtyEightWeb.GameLive do def render(assigns) do ~H""" -
+
Name <%= @name %>
Score <%= @score %>
@@ -25,42 +26,37 @@ defmodule TwentyFourtyEightWeb.GameLive do end def mount(%{"name" => name} = _params, _session, socket) do - {:ok, pid} = GameManager.get_game(name) - # This will kill the game when the LV dies, e.g. after the initial GET - # request (before the socket has connected). - # Process.link(pid) - - socket = socket - |> assign_game(name) - {:ok, socket} - end + case Game.get_by_slug(name) do + nil -> + {:ok, + socket + |> put_flash(:error, "Could not find game with ID #{name}.") + |> redirect(to: ~p"/")} + + game -> + # TODO change game to die after no interaction (or put similar logic in the manager?) + {:ok, _pid} = GameManager.get_game(name, game) - def mount(_params, _session, socket) do - name = generate_name() - {:ok, push_navigate(socket, to: "/#{name}")} + {:ok, assign_game(socket, name)} + end end @doc """ Handle known key events whilst the game is running. """ - def handle_event("move", %{"key" => key}, %{assigns: %{name: name, state: :running}} = socket) when key in @known_keys 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)} end - def handle_event("move", params, socket), do: {:noreply, socket} + def handle_event("move", _params, socket), do: {:noreply, socket} 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 defp key_to_move(right) when right in @right_keys, 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) @@ -70,6 +66,7 @@ defmodule TwentyFourtyEightWeb.GameLive do 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) @@ -81,16 +78,20 @@ defmodule TwentyFourtyEightWeb.GameLive do defp board(assigns) do ~H""" -
- <%= for row <- 1..@num_rows do %> - <%= for col <- 1..@num_cols do %> - <.cell value={@cell_values[{row, col}]} /> - <% end %> - <% end %> +
+ <.cell :for={cell <- cell_indicies(@num_rows, @num_cols)} value={@cell_values[cell]} />
""" end + defp cell_indicies(num_rows, num_cols) do + for row <- 1..num_rows, col <- 1..num_cols, do: {row, col} + end + defp cell(assigns) do ~H""" <%= if is_nil(@value) do %> @@ -100,4 +101,4 @@ defmodule TwentyFourtyEightWeb.GameLive do <% end %> """ end -end \ No newline at end of file +end diff --git a/priv/repo/migrations/20231027172144_create_game_table.exs b/priv/repo/migrations/20231027172144_create_game_table.exs new file mode 100644 index 0000000..a099825 --- /dev/null +++ b/priv/repo/migrations/20231027172144_create_game_table.exs @@ -0,0 +1,27 @@ +defmodule TwentyFourtyEight.Repo.Migrations.CreateGameTable do + use Ecto.Migration + + def change do + create table(:game) do + add :num_rows, :integer, default: 6, null: false + add :num_cols, :integer, default: 6, null: false + add :starting_number, :integer, default: 2, null: false + add :turn_start_number, :integer, default: 1, null: false + add :winning_number, :integer, default: 2048, null: false + add :score, :integer, default: 0, null: false + add :turns, :integer, default: 0, null: false + add :state, :string, default: "new", null: false + add :slug, :string, null: false + add :board, :json + + timestamps() + end + + create constraint(:game, "started_game_must_have_board", + check: "state = 'new' OR board IS NOT NULL", + comment: "Board must be non-null out of the new state." + ) + + create unique_index(:game, [:slug]) + end +end diff --git a/test/twenty_fourty_eight_web/controllers/error_html_test.exs b/test/twenty_fourty_eight_web/controllers/error_html_test.exs index 06e43bc..ab69462 100644 --- a/test/twenty_fourty_eight_web/controllers/error_html_test.exs +++ b/test/twenty_fourty_eight_web/controllers/error_html_test.exs @@ -9,6 +9,7 @@ defmodule TwentyFourtyEightWeb.ErrorHTMLTest do end test "renders 500.html" do - assert render_to_string(TwentyFourtyEightWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + assert render_to_string(TwentyFourtyEightWeb.ErrorHTML, "500", "html", []) == + "Internal Server Error" end end diff --git a/test/twenty_fourty_eight_web/controllers/error_json_test.exs b/test/twenty_fourty_eight_web/controllers/error_json_test.exs index 87afb79..130a55b 100644 --- a/test/twenty_fourty_eight_web/controllers/error_json_test.exs +++ b/test/twenty_fourty_eight_web/controllers/error_json_test.exs @@ -2,7 +2,9 @@ defmodule TwentyFourtyEightWeb.ErrorJSONTest do use TwentyFourtyEightWeb.ConnCase, async: true test "renders 404" do - assert TwentyFourtyEightWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + assert TwentyFourtyEightWeb.ErrorJSON.render("404.json", %{}) == %{ + errors: %{detail: "Not Found"} + } end test "renders 500" do