Skip to content

Commit

Permalink
Checkpoint.
Browse files Browse the repository at this point in the history
  • Loading branch information
alexpearce committed Oct 27, 2023
1 parent 6ebd2ce commit e89f353
Show file tree
Hide file tree
Showing 13 changed files with 177 additions and 41 deletions.
27 changes: 26 additions & 1 deletion assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion lib/twenty_fourty_eight/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
47 changes: 47 additions & 0 deletions lib/twenty_fourty_eight/game/game.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
defmodule TwentyFourtyEight.Game.Game do

Check warning on line 1 in lib/twenty_fourty_eight/game/game.ex

View workflow job for this annotation

GitHub Actions / test

Modules should have a @moduledoc tag.
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
4 changes: 2 additions & 2 deletions lib/twenty_fourty_eight/game/manager.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -52,4 +52,4 @@ defmodule TwentyFourtyEight.Game.Manager do
defp via_tuple(name) do
{:via, Registry, {@registry, name}}
end
end
end
2 changes: 1 addition & 1 deletion lib/twenty_fourty_eight_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ defmodule TwentyFourtyEightWeb.CoreComponents do
def simple_form(assigns) do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
<div class="mt-10 space-y-8 bg-white">
<div>
<%= render_slot(@inner_block, f) %>
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
<%= render_slot(action, f) %>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</script>
</head>
<body>
<h1>Twenty Fourty Eight.</h1>
<h1><a href={~p"/"}>Twenty Fourty Eight</a>.</h1>
<.flash_group flash={@flash} />
<%= @inner_content %>
</body>
Expand Down
14 changes: 11 additions & 3 deletions lib/twenty_fourty_eight_web/controllers/game_controller.ex
Original file line number Diff line number Diff line change
@@ -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
27 changes: 26 additions & 1 deletion lib/twenty_fourty_eight_web/controllers/game_html.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
defmodule TwentyFourtyEightWeb.GameHTML do
use TwentyFourtyEightWeb, :html

embed_templates "game_html/*"
def new(assigns) do
~H"""
<div class="cozy">
<.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</.button>
</:actions>
</.simple_form>
</div>
"""
end
end

This file was deleted.

57 changes: 29 additions & 28 deletions lib/twenty_fourty_eight_web/live/game_live.ex
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -12,7 +13,7 @@ defmodule TwentyFourtyEightWeb.GameLive do

def render(assigns) do
~H"""
<div class="game">
<div class="cozy">
<div class="stats">
<div><b>Name</b> <code><%= @name %></code></div>
<div><b>Score</b> <%= @score %></div>
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -81,16 +78,20 @@ defmodule TwentyFourtyEightWeb.GameLive do

defp board(assigns) do
~H"""
<div class="board" style={"grid-template-columns: repeat(#{@num_cols}, 1fr);"} phx-window-keyup="move">
<%= for row <- 1..@num_rows do %>
<%= for col <- 1..@num_cols do %>
<.cell value={@cell_values[{row, col}]} />
<% end %>
<% end %>
<div
class="board"
style={"grid-template-columns: repeat(#{@num_cols}, 1fr);"}
phx-window-keyup="move"
>
<.cell :for={cell <- cell_indicies(@num_rows, @num_cols)} value={@cell_values[cell]} />
</div>
"""
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 %>
Expand All @@ -100,4 +101,4 @@ defmodule TwentyFourtyEightWeb.GameLive do
<% end %>
"""
end
end
end
27 changes: 27 additions & 0 deletions priv/repo/migrations/20231027172144_create_game_table.exs
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion test/twenty_fourty_eight_web/controllers/error_html_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion test/twenty_fourty_eight_web/controllers/error_json_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit e89f353

Please sign in to comment.