Final touches

This commit is contained in:
Jack Harley 2021-01-31 22:32:05 +00:00
parent b7859f8ea7
commit 6860d44700
5 changed files with 77 additions and 37 deletions

Binary file not shown.

View File

@ -4,10 +4,10 @@
\usepackage{titling}
\usepackage{listings}
\usepackage{graphicx}
\usepackage{hyperref}
\usepackage{xcolor}
\usepackage{url}
\usepackage{enumitem}
\usepackage{hyperref}
\definecolor{lightblue}{RGB}{0,130,186}
\definecolor{darkgreen}{RGB}{0,114,0}
@ -54,7 +54,7 @@
\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.
I have implemented a fully functional Minesweeper game in Haskell with the Threepenny GUI serving the interface, and also an autosolver/autoplayer 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.
@ -125,16 +125,11 @@ createBoard size mineRatio rng = Board size (seedGrid rng mineRatio (createGrid
\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)
uncover b s | not $ validSquare b s = b
| isUncovered b s = b
| hasMine b s = Board (size b) (mines b) (createGrid True (size b)) (flagged b)
| otherwise = uncoverRecurse
(Board (size b) (mines b) (modSquare (uncovered b) s True) (flagged b)) s
\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.
@ -143,39 +138,32 @@ uncover b (r,c) | not $ validSquare b (r,c) = b
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:
The final guard handles normal cases where the Square clicked is safe. It reconstructs the uncovered Grid, replacing the Square at s with a True status using the modSquare function. It then also calls the uncoverRecurse 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
uncoverRecurse :: Board -> Square -> Board
uncoverRecurse b s | adjacentMines b s == 0 = uncoverAll b $ adjacentSquares b s
| 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.
uncoverRecurse 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:
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 functions as a toggle, so a user can also unflag a square. 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
flag b s | not $ validSquare b s = b
| isUncovered b s = b
| isFlagged b s = Board (size b) (mines b) (uncovered b) (modSquare (flagged b) s False)
| otherwise = Board (size b) (mines b) (uncovered b) (modSquare (flagged b) s True)
\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.
If the square is not valid or already uncovered then the board is returned unchanged. If the square is already flagged then we toggle it to unflagged, and if it doesn't match any of the guards then we toggle it to flagged.
\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.
@ -249,19 +237,69 @@ 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]
when (isJust table) $ delete (fromJust table)
delete table'
-- workaround
newTable <- getElementById w "table"
when (isNothing newTable) $ do
liftIO $ putStrLn "Render failed, triggering repeat"
refresh iob
\end{lstlisting}
The final section of this refresh function is a workaround for an issue I had with ThreePenny. ThreePenny occasionally (approx 1 in every 20 moves or so) fails to re-render the table when asked, instead producing an empty container. Despite extensive debugging I have been unable to pinpoint the cause. I suspect there is a bug in ThreePenny itself at this point, though I cannot rule out a mistake of my own. The workaround tests if the new table was created, and if not, repeats the refresh (this always fixes the problem). Theoretically I suspect the issue could occur twice in a row (though I have not seen this happen), in the case that it does the function should recursively run until a successful render occurs.
\newpage
\subsection{Autosolver}
My autosolving code is implemented in Autosolver.hs. When a user clicks the Autoplay button it fires a call to playAutoMove, which calls nextMove to determine a move and then plays it, returning the modified board:
\begin{lstlisting}
playAutoMove :: Board -> Board
playAutoMove b | fst (nextMove b) == Uncover = uncover b $ snd (nextMove b)
| fst (nextMove b) == Flag = flag b $ snd (nextMove b)
| otherwise = b
nextMove :: Board -> (MoveType, Square)
nextMove b | not . null $ uncoverStrat b = (Uncover, head $ uncoverStrat b)
| not . null $ flagStrat b = (Flag, head $ flagStrat b)
| not . null $ uncoverStratFallback b = (Uncover, head $ uncoverStratFallback b)
| otherwise = (None, (0,0))
\end{lstlisting}
The prioritisation for strategies is shown in nextMove, first it will attempt a move from the uncoverStrat, if none are available then it will try a move from flagStrat, and finally it will fallback to the uncoverStratFallback, which simply uncovers the first covered square on the board.
The strategies work by returning a list of possible moves that could be made in the form of 2-tuples with the first element being either Uncover, Flag or None, and the second the square to perform the move on.
uncoverStrat looks for an uncovered square with at least 1 covered square adjacent to it, and any possible mines have already been accounted for by flagging. It then uncovers all adjacent squares (since they must be safe).
flagStrat looks for an uncovered square with at least 1 covered square adjacent to it, where the number of adjacent squares is equal to the number of adjacent mines. It can then flag all of those squares (since they are guaranteed to be mines).
Together these two strategies consistently solve any game with a relatively low mine ratio.
As an example of the code, here is the uncoverStrat:
\begin{lstlisting}
uncoverStrat :: Board -> [Square]
uncoverStrat b =
filter (not. isFlagged b) $
filter (isCovered b) $
concatMap (adjacentSquares b) $
filter (\s -> adjacentCovereds b s > adjacentFlags b s) $
filter (\s -> adjacentFlags b s == adjacentMines b s) $
filter (\s -> adjacentMines b s > 0) $
uncoveredSquares b
\end{lstlisting}
As you can see it works quite elegantly (in my opinion) by progressively filtering and transforming sets of squares to a safe set to uncover. The logic for each step of the processes are detailed in textual form in comments above the strategies.
\newpage
\section{Reflection}
This was an interesting project and I have learned a lot from it.
This was an interesting project and I have learned a lot from it. I think Haskell worked excellently for the project, it continues to feel slightly magical to me after I've finished producing a set of functions and they all neatly slot into each other in such an intuitive way.
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.
@ -269,4 +307,6 @@ refresh iob = do
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.
In general, I'm also not a huge fan of user interfaces in HTML using a browser/embedded Chromium (Electron) to execute them. I think these apps are quite wasteful in terms of memory usage. If I produce a GUI app in the future with Haskell I think I'll look into GTK/Qt bindings, particularly since I have experience using them with other languages.
\end{document}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -15,7 +15,7 @@ nextMove b | not . null $ uncoverStrat b = (Uncover, head $ uncoverStrat b)
| not . null $ uncoverStratFallback b = (Uncover, head $ uncoverStratFallback b)
| otherwise = (None, (0,0))
-- filter:
-- Strategy:
-- uncovered squares
-- WITH at least one adjacent mine
-- WHERE the number of adjacent mines == number of adjacent flags
@ -33,7 +33,7 @@ uncoverStrat b =
filter (\s -> adjacentMines b s > 0) $
uncoveredSquares b
-- filter:
-- Strategy:
-- covered squares
-- WHICH are not flagged
uncoverStratFallback :: Board -> [Square]
@ -41,7 +41,7 @@ uncoverStratFallback b =
filter (not. isFlagged b) $
coveredSquares b
-- filter:
-- Strategy:
-- uncovered squares
-- WITH at least one adjacent mine
-- WHERE the number of adjacent mines == number of adjacent covered squares

View File

@ -99,7 +99,7 @@ setup w = void $ do
cont' #+ [mkElement "table" # set UI.id_ "table" #+ rows iob b 0]
when (isJust table) $ delete (fromJust table)
-- For some reason, ocassionally threepenny will fail to render the table after a change.
-- Ocassionally threepenny will fail to render the table after a change.
-- Despite extensive debugging I cannot determine why, I believe there may be some type of
-- bug in threepenny causing this, the underlying data structures all appear fine and
-- simply forcing a second refresh always fixes it.