📜 ⬆️ ⬇️

Mines in Haskell and Gloss: Rapid Prototyping Interactive Graphics

On Habrahabr, there are already quite a few good articles on Haskel, but, unfortunately, these are for the most part all sorts of introductions to the OP, explanations of some theoretical pieces (such as monads, lambda calculus or families of types) and quite a few practical examples. Without in any way detracting from their usefulness, I will try to shift the imbalance, to contribute to the thankless task of popularizing functionalism and again showing that the language is suitable not only for writing factorials and inefficient quick sorts in two lines, but also for quite practical ones. things like quick prototyping.

The article will try to be relatively real-world , but not to tire the reader with volume or exotic subject areas. “Graphics and games in training are always sexy,” as the great V.S. Lugovskii bequeathed, so I will outline a simple game, beloved by all the people, “Minesweeper”. The development will be conducted “from the top down” - this is an uncommon, but deserving close attention (like Haskel itself) methodology, about which I once read a long time ago in an excellent article about drafts in the Practice of Functional Programming , and since then it has sunk into me In the soul.

Head to foot


The idea is simple: we do not start with the underlying elementary types and functions, combining them into larger ones (as is usually done in everyday development in mainstream languages), but on the contrary, we describe the highest-level entities, gradually breaking them into small ones. When the code is written by one or two programmers, and the formulation of the problem is known in advance — or, conversely, an incomprehensible thing is prototyped, which changes a hundred times during development — this approach, in my opinion, is better than the classical one. Firstly, it allows us not to stray into the dead end of the implementation curve, if we suddenly started with the wrong primitives. Secondly, it follows the KISS principle (or rather, the progressive JPEG method ) - you can stop at any level of development, if you are satisfied with the result, and not go into unnecessary details, which, when “from the bottom up,” may seem paramount. Thirdly, in the case of flexible prototyping, it simplifies the clarification of the problem and requirements during the development process.
(you can read more on Wikipedia )
')
In addition, a distinctive feature of haskell is that, thanks to laziness and deduction of declaration types and definitions of types and functions, you can set almost in any order, and write the code straightforwardly, almost without returning to the beginning, to change or add something ( what import modules). Therefore, the top-down approach there is especially remarkable - you can easily begin with higher-level functions, sometimes putting stubs and adding what they call and use, only later. Developing a small program is easy to do - write a minimum of code in the editor, periodically load it into the REPL and try to compile it successfully, then try to call the implemented functions, edit the creeping bugs and implement the necessary stubs, and so on until the working result satisfies you.

I will not explain Haskel’s syntax, basic functions, and so on, since the article is already detailed and puffy. Therefore, it is focused on a person who already has some experience in the language, and if you are not, start with one of the above introductions. Instead, I will focus on the demonstration of specific libraries and explanations of how thoughts arising in the process of actual development (which I led in parallel with writing the article) fall on the code. For the same reason, the article may look like a stream of consciousness, and I apologize in advance to those to whom this style is sick.

Minefield


So, let's begin. I think no one needs to explain the rules of the Minesweeper, so we will immediately begin to implement, and restrictions will emerge along the way. The game must run as a full binary, so the first thing we have is the entry point - as in many other languages, this is the main function. She has to create the initial playing field and run the game on it:
 main :: IO () main = do let field = createField startGame field 

What does createField do? Somehow creates a field, but how, so far it is not clear. Let for a start it will have a fixed size, which we will specify in constants:
 fieldSize@(fieldWidth, fieldHeight) = (15, 15) :: (Int, Int) mineCount = 40 :: Int createField :: Field createField = undefined startGame :: Field -> IO () startGame = undefined 

Let us leave the undefined plug on the implementation site, because it is not yet clear what to do with these parameters, and think about what Field is. A field in Saper is a two-dimensional array of cells. In Haskele there are, of course, arrays, but with variable arrays (and they will continuously click on the field continuously during the game), it’s not very convenient to work with the field. Therefore, in order not to tinker with mutability in the IO and ST monads, we use a simple persistent alternative - a dictionary, where the keys are the positions of the cells, and the values ​​are their states:
 type Field = Map Cell CellState --    import Data.Map type Cell = (Int, Int) 

What conditions can a cell have?

All these states must be mutually exclusive: for example, it is impossible to put a flag on an open or draw a number on a closed one. A certain algebraic type suggests itself, the constructors of which correspond to possible states:
 data CellState = Closed {hasMine, hasFlag :: Bool} --;  —      | Opened (Maybe Int) --;  —      ( Nothing,     ) 

The type turned out rather clumsy, in it everything is piled up in a heap. This option is also fully implementable; but let us try to think again, and if we succeed, go another way.
Note that two cell states are mixed here:

Let's try to separate the visual, and the presence of mines somehow stored separately. In this case, the closed state becomes unnecessary, since an empty cell can simply not be stored in the dictionary (by the way, everything would have to be stored in the array). Well, we fix the above in the code:
 data CellState = Opened Int --;  — ,    | Mine --;   | Flag --  

It's simple! Minimum of options, minimum of nested parameters. And then Field at the beginning of the game is just an empty Map :
 createField = Data.Map.empty 

That is, now the field is only what is visible on the screen. But then you need to somehow separately store the internal state - a set of mines "under" this field. But this is even easier: mines are determined simply by the set of cells on which they stand, and during the game this value does not change at all:
 type Mines = Set Cell --  import Data.Set 

Check whether everything is in order with the compilation - because the code has solid declarations and almost no definitions, there are no problems yet.

Unlike the cells, the starting set of mines cannot be empty. Since all haskell functions are clean and deterministic, to create a random set, we will have to work either in the IO monad, or drag a pseudo-random generator, or play the same field all our lives. On the one hand, the game still runs in IO and the additional restriction does not interfere; on the other hand, a more generalized and cleaner solution also does not hurt. How better - the question is open, so let there be a second option. So, we need some random generator (in Haskel, they belong to the RandomGen type RandomGen ) and the coordinate of the first RandomGen cell, so as not to accidentally be undermined on the first move:
 createMines :: RandomGen g => g -> Cell -> Mines 

How to evenly choose n random cells of a field is a separate question. In order not to bother, I did not find anything better, how to mix all possible cells and take n first ones. Well, remove the starting one from there:
 createMines g fst = Data.Set.fromList $ take mineCount $ shuffle g $ [(i, j) | i <- [0 .. fieldWidth - 1] , j <- [0 .. fieldHeight - 1] , (i, j) /= fst] 

The shuffle functions that mix the list are not in the standard compiler. Well, an omniscient genie named Hoogl will help us. Ask what he can tell by the shuffle keyword.



Yeah, there is a whole small packet of random-shuffle that specifically solves this problem. We put it:
 $> cabal install random-shuffle 

and find a function that, as a first approximation, does exactly what you need:
 shuffle' :: RandomGen gen => [a] -> Int -> gen -> [a] 

She even has a signature similar to ours. True, it takes some extra argument, which upon closer inspection turns out to be the length of the list being sorted. Why I need it, I still do not understand (perhaps not to consider length again, if it is known in advance), so let's make a wrapper:
 shuffle gl = shuffle' l (fieldWidth * fieldHeight - 1) g -- import System.Random.Shuffle (shuffle'),     

Now it can even be compiled, run and try in the REPL:
 ghci> do {g <- getStdGen; return $ createMines g (0, 0)} fromList [(0,1),(1,10),(2,5),(2,7),(2,12),(2,14),(3,8),(4,10),(6,7),(6,10),(7,11),(7,12),(8,4),(8,12),(9,7),(9,12),(10,14),(12,0),(12,5),(13,8)] 

Fine. Now you can go back to main and start the gameplay. How to show a window and draw something on it strongly depends on the graphical framework used. I will use Gloss , designed to create applications with simple two-dimensional vector graphics.
In the library, not so long ago, the names of some standard functions slightly changed, so if you are not going to code, make sure you have a fresh version.


We direct gloss


Instead of the classic imperative functions of the form “draw it, then draw it”, which can be seen, for example, in the haskel linking to OpenGL, SDL and other popular graphics API, Gloss uses a proprietary OP-shnuyu idea of ​​combinators and a combination of functions. What Gloss can draw on the screen is a Picture, a kind of abstract picture. It has elementary functions for creating images from simple graphic primitives (such as lines, rectangles and raster images), functions for moving and scaling images, combining several images into one, etc., etc. The result is a declarative building complex scenes from simpler ones (again, “from top to bottom”): the scene drawn in the window is a picture that consists of pictures that consist of transformed pictures ... and so this time, until at the very bottom they are hidden under the hood willow opengl. Another important factor - in Gloss there is a small, but very proud subsystem for handling user input events (which distinguishes it from the conceptually similar, but purely static Diagrams), which is very useful to us. You can look at simple examples on the official website of the library , and I will immediately use everything I need in the article.
To launch interactive scenes (which, in addition to rendering, can also change over time and respond to external events), Gloss has a play function, which has an unusually bulky 7-ary type:
 play :: Display -> Color -> Int -> world -> (world -> Picture) -> (Event -> world -> world) -> (Float -> world -> world) -> IO () 

The first two arguments are trivial (the window or fullscreen options, and the default background color). And then the fun begins.
Let us examine a little how this play works. There is a scene, or a certain inner “world”, the state of which is stored in the value of the world type - this is not a type, but a type variable, therefore play polymorphic and can work with any state that we enter. Three callback functions are used to work with the state.

Please note - all functions are clean, except for the play itself. By the way, a similar approach with pure functions that handle different events was used in the Haskel itself in the pre-Donad era (you can read it here ).
Note: if, in addition to standard events, you need to react with the outside world (for example, load files), then there is a similar function that in all its handlers works with the state of the world in the IO monad.

Let's try to describe the state of the world. It should contain at least a field of cells and a set of mines. However, the set of mines is unknown until the player has made the first move, so you need to somehow make a pending initialization. The most straightforward (and far from the best) option is to add the corresponding flag and generator to the state. To avoid using "one-time" fields, I came up with a small hack:
 data GameState = GS { field :: Field , mines :: Either StdGen Mines } 

Then, if mines is Left , this means the first move, and from it we will generate a set that will become Right .
Now you can run the game:
 import Graphics.Gloss.Interface.Pure.Game <...> startGame :: StdGen -> IO () startGame gen = play (InWindow windowSize (240, 160)) (greyN 0.25) 30 (initState gen) renderer handler updater windowSize = both (* (round cellSize)) fieldSize cellSize = 24 initState gen = GS createField (Left gen) both f (a, b) = (fa, fb) -- ,    

The game is static, so updater does nothing. handler now, too, but do not set undefined to achieve a successful window display.
 updater _ = id handler _ = id 

renderer to begin with let draws a whole field with empty white cells:
 renderer _ = pictures [uncurry translate (cellToScreen (x, y)) $ color white $ rectangleSolid cellSize cellSize | x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]] cellToScreen = both ((* cellSize) . fromIntegral) 

Run.



Oops - the field left in the corner. It turns out that Gloss as the coordinate system defaults to the one adopted in mathematics, where the point (0,0) is the center of the screen and the axis oy is directed upwards. Whereas in 2D-graphics, usually (0,0) is the upper left corner, and oy goes down. For the same reason, all primitives are drawn relative to the center, not the angle. Let's deal with this a little later, and first we will try to process some events.

Interactive


An event in Gloss is an algebraic type, the constructors of which are variants of events: pressing various buttons ( EventKey ), moving the cursor ( EventMotion ), changing the size of the window ( EventResize ), each of which carries some other specific parameters. Thanks to the pattern-matching and partial definitions of the function in the form of clauses, quite cunning handlers of various special cases can be organized, and it looks almost like methods of the OnClick(MouseEventArgs e) type OnClick(MouseEventArgs e) in some WinForms.

We are primarily interested in the reaction to the mouse buttons. When the field is empty, we wanted to generate a set of mines:
 handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS { mines = Left gen } = gs { mines = Right $ createMines gen cell } where cell = screenToCell mouse screenToCell = both (round . (/ cellSize)) --        ,      

Run. At the slightest movement, the game falls from incomplete patterns. Indeed - the handler handles only one special case, so you need to leave the most common clause to handle events that do not interest us:
 handler _ gs = gs 

Now the game works stably, but outwardly, nothing has changed, because the mines are not visible. Add the opening of the cell. In general, in the original “Saper” the whole area of ​​cells with zeros is automatically opened, but for the beginning (remember, keep it simple!) We will process only one cell:
 handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS { field = field , mines = Right mines } = gs { field = Data.Map.insert cell opened field } where cell@(cx, cy) = screenToCell mouse opened = if cell `Data.Set.member` mines --,     then Mine else Opened neighbours --  :   ,           1 neighbours = length [ () | i <- [-1 .. 1], j <- [-1 .. 1] , (i, j) /= (0, 0) , (cx + i, cy + j) `Data.Set.member` mines] 

Now the game can be started and clicked, but the cells are still not drawn. We correct this mistake. To begin with, on the site of the opened cells we will draw a number (or @ , if there is a bomb):
 renderer GS { field = field } = pictures $ [uncurry translate (cellToScreen (x, y)) $ drawCell xy | x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]] where drawCell xy = case Data.Map.lookup (x, y) field of Nothing -> color white $ rectangleSolid cellSize cellSize --  Just Mine -> pictures [ color red $ rectangleSolid cellSize cellSize , scale 0.15 0.15 $ color black $ text "@" ] Just (Opened n) -> pictures [ color green $ rectangleSolid cellSize cellSize , scale 0.15 0.15 $ color black $ text $ show n ] 

Let me remind you that the pictures combinator combines a list of several “pictures” into one, while the drawing goes from left to right, that is, the elements in the tail of the list will be drawn over those that are at the beginning.

Here it is also worth noting that the standard vector font in Gloss is frankly outlined and too large (so you have to reduce it with scale ), so as soon as possible you should replace it with raster images. But for the time being, we will postpone it and first make the rest of the game logic, to finally see these altered states of the cells of the field. First, the installation of checkboxes - add a clause with the processing of the right mouse button:
 handler (EventKey (MouseButton RightButton) Down _ mouse) gs@GS { field = field } = case Data.Map.lookup coord field of Nothing -> gs { field = Data.Map.insert coord Flag field } Just Flag -> gs { field = Data.Map.delete coord field } _ -> gs where coord = screenToCell mouse 

and, accordingly, their drawing:
  drawCell xy = case M.lookup (x, y) field of <...> Just Flag -> pictures [ color yellow $ rectangleSolid cellSize cellSize , scale 0.15 0.15 $ color black $ text "?" ] 

Duplex with the creation of cells would be worth it, but I will leave it to optional.



The cells are opened, but the labels are also shifted, move them to the auxiliary function, which will make the text label:
  label = translate (-5) (-5) . scale 0.15 0.15 . color black . text 

In addition, the grid disappeared - after all, the filled geometric shapes do not have a frame. It is necessary to add its drawing separately, and on top of the cells, otherwise they will overlap each other.
 renderer GS { field = field } = pictures $ cells ++ grid where grid = [uncurry translate (cellToScreen (x, y)) $ color black $ rectangleWire cellSize cellSize | x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]] 

Coordinate Projections


Now it is necessary to correct the coordinates. Of course, you can manually transfer them from one system to another (and vice versa - because the mouse coordinates obtained in the events are not tied to the window, but to the absolute coordinate system of the scene, which can be confusing with unaccustomed), but this is tedious. Fortunately, Gloss includes a mechanism that does this for us — viewports. Projection is a transformation, according to which the window is displayed on the scene plane, and it can not only be moved, but also scaled and even rotated. In order for the field to fit entirely on the screen, our projection must simply be shifted half the field plus half the cell (since the cell is also drawn from the center, not from the corner):
 viewPort = ViewPort (both (negate . (/ 2) . (subtract cellSize)) $ cellToScreen fieldSize) 0 1 --   –      

With applyViewPortToPicture you can apply a projection to any image, transforming it as necessary. And for the inverse transformation (from the coordinate system of the image to the coordinate system of the projection), which is needed for processing the cursor position, there is an invertViewPort . Correct our code accordingly:
 screenToCell = both (round . (/ cellSize)) . invertViewPort viewPort renderer GS { field = field } = applyViewPortToPicture viewPort <...> 




Voila! Now the window is drawn as it should, and you can even play in it. In addition, in conjunction with projections, Gloss provides very simple means for scrolling and scaling the screen, which allows you to make, for example, a field of 1000x1000 cells and move along it, as in maps.

However, it is still impossible to lose - the explosion at the mine does not affect anything, and therefore you need to add a flag to the game state, and check in the event handler:
 data GameState = GS { field :: Field , mines :: Either StdGen Mines , gameOver :: Bool --  —  enum-ADT   " ", "", "" } <...> handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS { field = field , mines = Right mines , gameOver = False } = gs { field = Data.Map.insert cell opened field , gameOver = exploded } where cell@(cx, cy) = screenToCell mouse (opened, exploded) = if cell `Data.Set.member` mines --,     then (Mine, True) else (Opened neighbours, False) 

Now the game after a careless step will not allow to make one more.

A little convenience


It remains the most interesting - recursive opening of the whole area of ​​empty cells. Let's make it a kind of search in depth - simulate poking into neighboring cells, if there are no mines in them for sure:
 handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS { field = field , mines = Right mines , gameOver = False } = gs { field = newField , gameOver = exploded } where newField = click cell field exploded = case Data.Map.lookup cell newField of --,     -  Just Mine -> True _ -> False cell@(cx, cy) = screenToCell mouse click :: Cell -> Field -> Field click c@(cx, cy) f | c `Data.Map.member` f = f --    | c `Data.Set.member` mines = put Mine --   | otherwise = let nf = put (Opened neighbours) in if neighbours == 0 then Prelude.foldr click nf neighbourCells --  else nf where put state = Data.Map.insert c state f --  :   ,            1 neighbourCells = [ (cx + i, cy + j) | i <- [-1 .. 1], j <- [-1 .. 1] ] neighbours = length $ Prelude.filter (`Data.Set.member` mines) neighbourCells 

Run, poke, and ... the game hangs. As so, because she compiled! Alas, even Haskel is not able to insure against all runtime errors. Specifically, such a hang often happens when a program enters an infinite loop or recursion. And to be sure - we don’t check the way out of the field, so the search for neighbors will try to bypass the entire infinite plane. Add restrictions:
  neighbourCells = [ (i, j) | i <- [cx - 1 .. cx + 1], j <- [cy - 1 .. cy + 1] , 0 <= i && i < fieldWidth , 0 <= j && j < fieldHeight ] --,   0 <= i < fieldWidth 

Now, finally, you can enjoy the result:



Epilogue


Of course, there is still room for improvement - add a magic combination of two mouse buttons (to uncover the neighbors of a cell that already has all the checkboxes), fasten the normal graphics (the same Gloss can load bitmaps), make the field size and the number of mines customizable (forwarding using the monad Reader), etc., etc. However, I hope I could show that I don’t need to be afraid of Haskel, and prototyping something simple and interactive on it in a hundred lines is quite a lifting task .

The full code is posted on Pastebin .

Thanks for attention!

Source: https://habr.com/ru/post/249803/


All Articles