272 lines
14 KiB
TeX
272 lines
14 KiB
TeX
\documentclass[11pt]{article}
|
|
|
|
\usepackage[margin=0.5in]{geometry}
|
|
\usepackage{titling}
|
|
\usepackage{listings}
|
|
\usepackage{graphicx}
|
|
\usepackage{hyperref}
|
|
\usepackage{xcolor}
|
|
\usepackage{url}
|
|
\usepackage{enumitem}
|
|
|
|
\definecolor{lightblue}{RGB}{0,130,186}
|
|
\definecolor{darkgreen}{RGB}{0,114,0}
|
|
\definecolor{darkpurple}{RGB}{125,0,183}
|
|
|
|
\lstset{
|
|
language=Haskell,
|
|
tabsize=4,
|
|
basicstyle=\color{black}\footnotesize\ttfamily,
|
|
keywordstyle=\color{blue}\footnotesize\ttfamily, % style for keywords
|
|
identifierstyle=\color{purple}\footnotesize\ttfamily,
|
|
commentstyle=\color{lightblue}\footnotesize\ttfamily,
|
|
numbers=left, % where to put the line-numbers
|
|
numberstyle=\footnotesize\ttfamily, % the size of the fonts that are used for the line-numbers
|
|
showstringspaces=false
|
|
}
|
|
|
|
\hypersetup{
|
|
colorlinks,
|
|
citecolor=black,
|
|
filecolor=black,
|
|
linkcolor=black,
|
|
urlcolor=black
|
|
}
|
|
|
|
\setlength{\droptitle}{-5em}
|
|
\setlength{\parindent}{0cm}
|
|
\setlength{\righthyphenmin}{62}
|
|
\setlength{\lefthyphenmin}{62}
|
|
|
|
\title{CSU44012 Topics in Functional Programming\\Assignment \#2\\Minesweeper}
|
|
\author{Jack Harley jharley@tcd.ie | Student No. 16317123}
|
|
\date{January 2021}
|
|
|
|
\begin{document}
|
|
\maketitle
|
|
|
|
\includegraphics[width=12cm]{screenshot.png}
|
|
|
|
\tableofcontents
|
|
|
|
\newpage
|
|
|
|
\setlength{\parskip}{1em}
|
|
|
|
\section{Introduction}
|
|
I have implemented a fully functional Minesweeper game in Haskell with the Threepenny GUI serving the interface, and also a probability based autosolver which can perform a move by clicking an "Autoplay" button.
|
|
|
|
The code is well commented and I have also documented and explained key parts of it in this PDF.
|
|
|
|
Stack successfully builds and executes the solution binary, serving the GUI at \url{http://localhost:8023}.
|
|
|
|
I attempted to integrate it into an Electron application, so that the interface would launch automatically into an Electron window (embedded Chrome, see \url{https://www.electronjs.org/}) but unfortunately realized partway through that the effort required to get it working was likely to be disproportionate to the improvement in functionality.
|
|
|
|
\section{Design and Implementation}
|
|
I will cover a few of the more important functions in some detail in this section. The comments included in the source files should be sufficient to explain the simpler function.
|
|
|
|
\subsection{Basic Minesweeper Model}
|
|
The model for the game is implemented in Minesweeper.hs.
|
|
|
|
I modelled the game board as an ADT with 4 fields:
|
|
\begin{lstlisting}
|
|
data Board = Board { size :: Int, mines :: Grid, uncovered :: Grid, flagged :: Grid }
|
|
\end{lstlisting}
|
|
|
|
\textbf{size} defines the horizontal and vertical length in squares of the game grid (all grids are squares).
|
|
\textbf{mines}, \textbf{uncovered} and \textbf{flagged} hold data structures that respectively indicate the squares that have mines, have been uncovered by the user and have been flagged by the user.
|
|
|
|
The Grid type is a 2D list of Booleans with the outer list denoting rows and the inner list denoting columns:
|
|
|
|
\begin{lstlisting}
|
|
type Grid = [[Bool]]
|
|
\end{lstlisting}
|
|
|
|
With this structure, you can determine whether the 2nd row down, 4th column across has a mine with the following simple expression: (N.B. rows and columns are 0-indexed)
|
|
\begin{lstlisting}
|
|
(mines !! 1) !! 3
|
|
\end{lstlisting}
|
|
|
|
And indeed this is how the hasMine function is implemented in my code:
|
|
\begin{lstlisting}
|
|
hasMine :: Board -> Square -> Bool
|
|
hasMine b (r,c) | validSquare b (r,c) = (mines b !! r) !! c
|
|
\end{lstlisting}
|
|
|
|
Throughout my implementation, particular squares are referred to with a 2-tuple defining first the row and then the column as 0-indexed integers, with (0,0) being the square at the top left of the board:
|
|
|
|
\begin{lstlisting}
|
|
type Square = (Int, Int)
|
|
\end{lstlisting}
|
|
|
|
\newpage
|
|
|
|
\subsection{Creating a Game}
|
|
A fresh game board is initialised with the createBoard function:
|
|
|
|
\begin{lstlisting}
|
|
createBoard :: Int -> Float -> StdGen -> Board
|
|
createBoard size mineRatio rng = Board size (seedGrid rng mineRatio (createGrid False size))
|
|
(createGrid False size)
|
|
(createGrid False size)
|
|
\end{lstlisting}
|
|
|
|
The function requires a size (number of squares in both horizontal and vertical directions), a "mine ratio" and a random number generator instance. It then produces a Board instance with three initialised grids. The uncovered and flagged grids are initialised with all False values (since the user will not have uncovered or flagged any squares yet).
|
|
|
|
The mines grid is initialised with all False values, but then the seedGrid function is used to randomly seed mines into the grid by making a random decision with probability of the provided mineRatio for every square on the grid.
|
|
|
|
For example, with a mine ratio of 0.1, every square will have a one in ten chance of having a mine, and after the decisions have been made for every square, roughly one tenth of the grid will have mines.
|
|
|
|
seedGrid works by splitting the random number generator repeatedly, one instance for each row of the grid, and then calling seedList on each row. The seeded rows are then joined back together at the end of the recursion. The full source for seedGrid, seedList and seedList' can be found in the appendix and project files.
|
|
|
|
\subsection{Handling Game Moves}
|
|
\subsubsection{Uncover}
|
|
Uncover is triggered in the UI by left clicking on a square. It triggers the following function:
|
|
|
|
\begin{lstlisting}
|
|
uncover :: Board -> Square -> Board
|
|
uncover b (r,c) | not $ validSquare b (r,c) = b
|
|
| isUncovered b (r,c) = b
|
|
| hasMine b (r,c) = let Board s m u f = b
|
|
in Board s m (createGrid True s) f
|
|
| otherwise = let Board s m u f = b
|
|
(rowsA, row : rowsB) = splitAt r u
|
|
(cellsA, _ : cellsB) = splitAt c row
|
|
newRow = cellsA ++ True : cellsB
|
|
newRows = rowsA ++ newRow : rowsB
|
|
in uncoverAdjacentsIfSafe (Board s m newRows f) (r,c)
|
|
\end{lstlisting}
|
|
|
|
The first guard handles cases where the provided Square is not valid (lies outside the edge of the Board), in this case, the Board is returned unchanged.
|
|
|
|
The second guard handles cases where the provided Square is already uncovered, and again the board is returned unchanged.
|
|
|
|
The third guard handles cases where a user clicks on a mine. In this case the game has ended and the player has lost, therefore the function simply replaces the uncovered Grid with an all True Grid. The user will therefore immediately see the entire grid, including all of the mines.
|
|
|
|
The final guard handles normal cases where the Square clicked is safe. It reconstructs the uncovered Grid, replacing the Square at (r,c) with a True status. It then also calls the uncoverAdjacentsIfSafe function on the modified Board:
|
|
|
|
\newpage
|
|
|
|
\begin{lstlisting}
|
|
uncoverAdjacentsIfSafe :: Board -> Square -> Board
|
|
uncoverAdjacentsIfSafe b (r,c) | adjacentMines b (r,c) == 0 = uncoverAll b $ adjacentSquares (r,c)
|
|
| otherwise = b
|
|
\end{lstlisting}
|
|
|
|
uncoverAdjacentsIfSafe checks if the newly uncovered Square has 0 adjacent mines, and if so, uncovers all of them. This can trigger recursion where large parts of the Board will be uncovered.
|
|
|
|
\subsubsection{Flag}
|
|
Flag is triggered in the UI by right clicking on a square. It is intended to be used when a user wants to mark a square they think has a mine. It triggers the following function:
|
|
|
|
\begin{lstlisting}
|
|
flag :: Board -> Square -> Board
|
|
flag b (r,c) | not $ validSquare b (r,c) = b
|
|
| isUncovered b (r,c) = b
|
|
| isFlagged b (r,c) = b
|
|
| otherwise = let Board s m u f = b
|
|
(rowsA, row : rowsB) = splitAt r f
|
|
(cellsA, _ : cellsB) = splitAt c row
|
|
newRow = cellsA ++ True : cellsB
|
|
newRows = rowsA ++ newRow : rowsB
|
|
in Board s m u newRows
|
|
\end{lstlisting}
|
|
|
|
Flag works similarly to uncover.
|
|
|
|
If the square is not valid, already uncovered or already flagged then the board is returned unchanged. We could combine these cases into a single guard but I think readability is better with them separately.
|
|
|
|
Then we use the same procedure to replace the flagged Grid with a new grid, with the right clicked square's status changed to True.
|
|
|
|
\subsection{Program Startup}
|
|
The entry point for the program is the main function. It calls startGUI with the setup function as a parameter. ThreePenny then initialises using the setup function.
|
|
|
|
Setup registers two stylesheets: the minified bootstrap.min.css I use in all of my web related projects and a minesweeper.css file I wrote to give some styling to the page and the game board.
|
|
|
|
Setup then creates a new pseudo random number generator and initialises a new game board. It then stores the game board state into a global IORef. This IORef will be modified when updating state due to a user action, and read from during a re-render of the board. The unwrapped Board instance (b) is also maintained and passed to the rendering functions for the initial render:
|
|
|
|
\begin{lstlisting}
|
|
rng <- liftIO newStdGen
|
|
let b = createBoard 20 0.08 rng :: Board
|
|
iob <- liftIO $ newIORef b
|
|
\end{lstlisting}
|
|
|
|
The body of the page is then rendered, one part to note is the custom JS I include at the bottom of the body:
|
|
|
|
\begin{lstlisting}
|
|
mkElement "script" # set (attr "src") "/static/custom.js"]
|
|
\end{lstlisting}
|
|
|
|
This custom.js script is a one-liner which prevents right clicks from opening a menu when playing the game:
|
|
|
|
\begin{lstlisting}
|
|
document.addEventListener('contextmenu', event => event.preventDefault());
|
|
\end{lstlisting}
|
|
|
|
\newpage
|
|
|
|
\subsection{Rendering the Game Board}
|
|
|
|
The board itself is rendered as a table with ID "table" in a div with ID "gameCont":
|
|
|
|
\begin{lstlisting}
|
|
UI.div # set UI.id_ "gameCont" #+ [mkElement "table" # set UI.id_ "table" #+ rows iob b 0]
|
|
\end{lstlisting}
|
|
|
|
This calls the rows function with an IORef Board and an already unwrapped copy of the Board. Rows then recursively calls cells to render each row of cells, and cells calls the cell function to render the individual cells:
|
|
|
|
\begin{lstlisting}
|
|
rows iob b r | r < size b = (mkElement "tr" #+ cells iob b r 0) : rows iob b (r+1)
|
|
| otherwise = []
|
|
|
|
cells iob b r c | c < size b = cell iob b (r,c) : cells iob b r (c+1)
|
|
| otherwise = []
|
|
\end{lstlisting}
|
|
|
|
The cell function receives an (r,c) pair and renders that specific cell, calling functions from Minesweeper.hs to determine the desired background colour, text colour and text to display in each cell.
|
|
|
|
Two event handlers are also attached on to the cell to handle left and right clicks, triggering an update of the IORef Board by uncovering or flagging a square respectively.
|
|
|
|
\begin{lstlisting}
|
|
cell iob b (r,c) = do
|
|
cell <- mkElement "td" #. (squareBgColour b (r,c) ++ " " ++ squareTextColour b (r,c))
|
|
#+ [string $ squareAscii b (r,c)]
|
|
|
|
on UI.click cell $ \_ -> do
|
|
liftIO $ modifyIORef' iob $ \oldB -> uncover oldB (r,c)
|
|
refresh iob
|
|
|
|
on UI.contextmenu cell $ \_ -> do
|
|
liftIO $ modifyIORef' iob $ \oldB -> flag oldB (r,c)
|
|
refresh iob
|
|
|
|
return cell
|
|
\end{lstlisting}
|
|
|
|
You will note that after an update to the IORef Board due to a player move, the refresh function is called. This function reads an up to date copy of the Board state from the IORef Board and re-renders the game board, replacing the old copy:
|
|
|
|
\begin{lstlisting}
|
|
refresh iob = do
|
|
b <- liftIO $ readIORef iob
|
|
|
|
table <- getElementById w "table"
|
|
let table' = fromJust table
|
|
|
|
cont <- getElementById w "gameCont"
|
|
let cont' = return $ fromJust cont
|
|
cont' #+ [mkElement "table" # set UI.id_ "table" #+ rows iob b 0]
|
|
|
|
delete table'
|
|
\end{lstlisting}
|
|
|
|
\newpage
|
|
|
|
\section{Reflection}
|
|
This was an interesting project and I have learned a lot from it.
|
|
|
|
On reflection I'm not hugely happy with the core data structures I chose to represent the game state and believe I fell into the trap of thinking like an imperative programmer when designing them. 2D lists initially seemed like an intuitive and efficient way to store the state. However, when it came to writing the uncover and flag functions I realised it was not a particularly optimal choice. Modification of a single element required splitting the lists up two levels deep which made for some overly complex code. I also suspect that this approach is not particularly efficient for data access or modification.
|
|
|
|
If I was to entirely redesign the project I would try implementing it with a different core data structure. Possible options include a 2D Data.Array structure that would function somewhat similarly to the current approach, or possible an association list or Data.Map based structure (which would be indexed by the (row,col) tuples).
|
|
|
|
I found working with ThreePenny difficult initially, there are limited examples on the web for usage and it took me quite a bit of time wrestling with it before I had some code with a reasonable structure for the main interface setup section. I would've liked to see a method of including static HTML into a page without having to write all of it in the ThreePenny eDSL and an ability to prevent the default action triggered by a browser event occurring (in my case right click opening a context menu) without having to embed a custom script.
|
|
|
|
\end{document} |