We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Chapter 13: Establish Boundaries and APIs
It’s Alive: Plan User Interactions
Remember that the boundary for the code is the place where uncertainty lives. We need to have checks and order for things like: a player trying to place a piece out of bounds, for trying to place a piece on top of and other piece, etc.
The Basic rules work like this:
• The user can pick up pieces, manipulate them, and drop them on the
board.
• The user can place pieces until the whole puzzle board is covered.
Now for every action we need to check some things. Most basically we will test to see what the user is trying to do, if that is not a available it will “fail” if it is allowed we can move onto the next bit. Ill go over the entire logic here but we will go into each step as we go.
• When: The user clicks a point
– If: The point is part of a shape on the palette and there is no active
shape
– Then: Make the clicked shape the active shape and center it on the
board
• When: The user clicks a point
– If: The point is part of an existing shape placed on the board and
There is no active shape
– Then: Make the clicked shape the active shape and center it on the
board
• When: The user types an arrow key to move or the user types the shift
key to rotate or the user types the enter key to flip
– If: The middle point of the pentomino is on the board
– Then: Move the pentomino
– Else: Don’t move the pentomino and report an error
• When: The user hits the space bar to drop a pentomino
– If: All points cover the board and no points overlap existing
pentominoes
– Then: Drop the pentomino
– Else: Don’t drop the pentomino and report an error
• When: The user clicks anything else on the SVG
– Then: Do nothing
Process User Interactions in the Core
We now need to work on the logic for when a user is clicking.
def pick(board, :board), do: board
def pick(%{active_pento: pento} = board, sname) when not is_nil(pento) do
if pento.name == sname do
%{board | active_pento: nil}
else
board
end
end
def pick(board, shape_name) do
active =
board.completed_pentos
|> Enum.find(&(&1.name == shape_name))
|> Kernel.||(new_pento(board, shape_name))
completed = Enum.filter(board.completed_pentos, &(&1.name != shape_name))
%{board | active_pento: active, completed_pentos: completed}
end
This is the logic for when we are clicking on a new pento from the palette. The first one is for when we are trying to click on something that is not within the board. The next one is from when we are clicking when there is an active pento. The last is for when we are trying to pickup a piece from the board while there is no active piece.
I want to go over the last one as it has the most and is not readily understandable. First we are trying to find the pentos on the board and return the first one that matches and if there is no match we will create a new pento with that shape and place it in the center of the board. Lastly we will be sure to remove the pento from the completed as well.
Okay now let’s look at the new_pento/2 so we can go over that logic.
defp new_pento(board, shape_name) do
Pentomino.new(name: shape_name, location: midpoints(board))
end
defp midpoints(board) do
{xs, ys} = Enum.unzip(board.points)
{midpoint(xs), midpoint(ys)}
end
defp midpoint(i), do: round(Enum.max(i) / 2.0)
There isn’t much here but we will need to be sure that we always put the new pento in the middle of the board of any size.
Okay so now we can build the function to deal with the drop of a pento.
def drop(%{active_pento: nil} = board), do: board
def drop(%{active_pento: pento} = board) do
board
|> Map.put(:active_pento, nil)
|> Map.put(:completed_pentos, [pento | board.completed_pentos])
end
Looking at this we see that there is 2 types one where we have an active pento and one where we don’t. Keep in mind that this is just the drop and there is no logic checks for validity.
Okay so now we can look into the legal_drop?/1 and the other functions that we will need for that.
def legal_drop?(%{active_pento: pento}) when is_nil(pento), do: false
def legal_drop?(%{active_pento: pento, points: board_points} = board) do
points_on_board =
Pentomino.to_shape(pento).points
|> Enum.all?(fn point -> point in board_points end)
no_overlapping_pentos =
!Enum.any?(board.completed_pentos, &Pentomino.overlapping?(pento, &1))
points_on_board and no_overlapping_pentos
end
def legal_move?(%{active_pento: pento, points: points} = _board) do
pento.location in points
end
This goes in the Pento.Game.Pentomino.ex module
def overlapping?(pento1, pento2) do
{p1, p2} = {to_shape(pento1).points, to_shape(pento2).points}
Enum.count(p1 -- p2) != 5
end
So the first function is relatively simple if we take the functions at face value. We are building the points on the Pento. Then sending the pento into a list of points on the board then see if that list is contained in the board. Then we need to check that there is no overlapping with the new pento.
I want to go over the overlapping?/2. This is responsible for taking the pento and the completed pentos and seeing if there are overlapping. The list subtraction will remove any values that are in p1 and p2. If the count after is != 5 we know there is some overlap.
Lastly we need to check if a move is legal. This will simply check to see if the pento is on the board.
Build a Game Boundary Layer
Let’s create a new file that will be the boundary for the game. pento/lib/pento/game.ex
defmodule Pento.Game do
alias Pento.Game.{Board, Pentomino}
@messages %{
out_of_bounds: "Out of bounds!",
illegal_drop: "Oops! You can't drop out of bounds or on another piece."
}
end
Let’s now implement the maybe_move/2 so that we can start to fill out the functionality of the code. It should work like this:
• Take in a board struct and some attempted move.
• If the move is legal, apply it to the board state and return an ok-tagged
tuple with the new board struct.
• If the move is not legal, return an error-tagged tuple with the unchanged
board struct.
def maybe_move(%{active_pento: p} = board, _m) when is_nil(p) do
{:ok, board}
end
def maybe_move(board, move) do
new_pento = move_fn(move).(board.active_pento)
new_board = %{board | active_pento: new_pento}
if Board.legal_move?(new_board),
do: {:ok, new_board},
else: {:error, @messages.out_of_bounds}
end
defp move_fn(move) do
case move do
:up -> &Pentomino.up/1
:down -> &Pentomino.down/1
:left -> &Pentomino.left/1
:right -> &Pentomino.right/1
:flip -> &Pentomino.flip/1
:rotate -> &Pentomino.rotate/1
end
end
def maybe_drop(board) do
if Board.legal_drop?(board) do
{:ok, Board.drop(board)}
else
{:error, @messages.illegal_drop}
end
end
As usual we need to have a function that will “work” when there is no active pento. Next we need to have a check for the move. We first build the move and then the board if we have that new move. We then try and either add the new board to the assigns or we show a message and do nothing with the assigns.
Let’s go over the move_fn/1 so that we can see what is happening there. It will leverage the Pentomino.{move/trans}/1 functions on the piece. The original function that is calling that maybe_move/2 will need to have access to the atom of the move/transformation.
Last we have the maybe_drop/1 This is relatively simple. Keep in mind that the board we pass will have access to the active pento (and its location), as well as all the completed pentos. So it is simply will check if it works then drop the piece if there is no issue.
Extend the Game Live View
defmodule PentoWeb.GameLive.Board do
use PentoWeb, :live_component
alias Pento.Game.{Board, Pentomino}
alias Pento.Game
import PentoWeb.GameLive.{Colors, Component}
Add this in and then we can start to redo some of the hard coded pentos and deal with all the events.
def assign_board(%{assigns: %{puzzle: puzzle}} = socket) do
board =
puzzle
|> String.to_existing_atom()
|> Board.new()
assign(socket, board: board)
end
Add Help with JavaScript
def render(assigns) do
~H"""
<section class="mx-auto max-w-4xl px-4 py-8">
<h1 class="font-heavy text-3xl mb-6">Welcome to Pento!</h1>
<GameInstructions.show />
<.help />
<.live_component module={Board} puzzle={@puzzle} id="board-component" />
</section>
"""
end
def help(assigns) do
~H"""
<div class="relative">
<.help_button />
<.help_page />
</div>
"""
end
attr(:class, :string, default: "h-8 w-8 text-slate hover:text-slate-400")
def help_button(assigns) do
~H"""
<button
phx-click={JS.toggle(to: "#info", in: "fade-in", out: "fade-out")}
class="text-slate hover:text-slate-400"
>
<.icon name="hero-question-mark-circle-solid" class="h-8 w-8" />
</button>
"""
end
def help_page(assigns) do
~H"""
<div
id="info"
class="absolute right-0 top-10 bg-base-100 border-2 border-base-300 text-base-content
p-4 z-10 w-80 shadow-lg rounded hidden"
>
<ul class="list-disc list-inside">
<li>Click on a pento to pick it up</li>
<li>Drop a pento with a space</li>
<li>Pentos can't overlap</li>
<li>Pentos must be fully on the board</li>
<li>Rotate a pento with shift</li>
<li>Flip a pento with enter</li>
<li>Place all the pentos to win</li>
</ul>
</div>
"""
end
Build a Picker Control Navigation
Okay let’s start by adding in a new file pento/lib/pento_web/live/game_live/picker.ex This will help a use pick the game that they want.
defmodule PentoWeb.GameLive.Picker do
use PentoWeb, :live_view
alias Pento.Game.Board
import PentoWeb.GameLive.{Colors, Component}
def mount(_params, _session, socket) do
{:ok, assign_boards(socket)}
end
def assign_boards(socket) do
assign(
socket,
:boards,
Board.puzzles()
|> Enum.map(&{&1, Board.new(&1)})
)
end
def render(assigns) do
~H"""
<h1 class="font-heavy text-4xl text-center mb-6">
Choose a Puzzle
</h1>
<%= for {puzzle, board} <- @boards do %>
<.row board={board} puzzle={puzzle} />
<% end %>
"""
end
attr(:board, :any, required: true)
attr(:puzzle, :atom, required: true)
def row(assigns) do
~H"""
<.link navigate={~p"/game/#{@puzzle}"}>
<div class="grid grid-cols-2 hover:bg-slate-200">
<div>
<h3 class="text-2xl">Pieces</h3>
</div>
<div>
<h3 class="text-2xl">
{@puzzle |> to_string |> String.capitalize()} Puzzle
</h3>
</div>
<.palette shape_names={@board.palette} />
<.board board={@board} />
</div>
</.link>
"""
end
attr(:board, :any, required: true)
def board(assigns) do
~H"""
<div>
<.canvas view_box={"0 0 400 #{height(@board) * 10 + 25}"}>
<.shape
points={Board.to_shape(@board).points}
fill={color(:purple)}
name="board"
/>
</.canvas>
</div>
"""
end
defp height(board) do
board.points
|> Enum.map(fn {_, y} -> y end)
|> Enum.max()
end
end
Ill go over this
Now you need to add in the route to the router.ex
live("/game/:puzzle", GameLive)
live("/play", GameLive.Picker)
Add Some Puzzles to the board
Let’s add in a few more puzzles
def puzzles(), do: ~w[tiny small ball donut default wide widest medium skew]a
def new(palette, points, hole \\ []) do
%__MODULE__{palette: palette(palette), points: points -- hole}
end
def new(:tiny), do: new(:small, rect(5, 3))
def new(:small), do: new(:medium, rect(7, 5))
def new(:widest), do: new(:all, rect(20, 3))
def new(:wide), do: new(:all, rect(15, 4))
def new(:medium), do: new(:medium, rect(12, 5))
def new(:default), do: new(:all, rect(10, 6))
def new(:skew), do: new(:small, skewed_rect())
def new(:donut) do
new(:all, rect(8, 8), for(x <- 4..5, y <- 4..5, do: {x, y}))
end
def new(:ball) do
new(:all, rect(8, 8), for(x <- [1, 8], y <- [1, 8], do: {x, y}))
end
def new(_), do: new(:default)
...
defp palette(:medium), do: [:t, :y, :l, :p, :n, :v, :u]