Ciele
- Naučiť sa používať Cabal na definovanie projektov.
- Naučiť sa použivať moduly.
- Naučiť sa používať externé knižnice.
- Naučiť sa pracovať s realistickými údajovými štruktúrami.
- Vyskúšať použitie monád na prácu s I/O a ošetrenie chýb.
Úvod
Na dnešnom cvičení si vyskúšame aplikovať doteraz získané vedomostí na projekte hry Mínu, ktorá je témou 3. zadania. Skúsime vytvoriť projekt postupne, aby ste sa zoznámili s jeho dotailami.
Postup
Krok 1: Inicializácia projektu
Na definovanie a kompiláciu komplexného programu alebo knižnice sa používa nástroj cabal-install. Je to nástroj pre zostavenie a zároveň správca závislostí.
Úloha 1.1
Aktualizujte databázu balíkov pre Haskell pomocou príkazu cabal update
.
Úloha 1.2
Vytvorte nový adresár s názvom tuke-minesweeper
a so základnými súbormi projektu.
mkdir tuke-minesweeper
cd tuke-minesweeper
V tomto adresári tuke-minesweeper
vytvorte tri súbory. Prvý z nich je tuke-minesweeper.cabal
, ktorý definuje vlastnosti projektu:
cabal-version: >=1.10
name: tuke-minesweeper
version: 0.1.0.0
synopsis: Simple console minesweeper game
license: MIT
author: Sergej Chodarev
build-type: Simple
executable minesweeper
main-is: Main.hs
build-depends: base >=4.13 && <5
default-language: Haskell2010
ghc-options: -Wall
Ďalší súbor je Setup.hs
, ktorý v našom prípade obsahuje iba predvolenú logiku:
import Distribution.Simple
main = defaultMain
Tretím súborom je kód našej aplikácie — Main.hs
:
module Main where
main :: IO ()
main = putStrLn "Hello, Haskell!"
Poznámka
Pre vytvorenie projektu je možné použiť aj príkaz cabal init
. Tento príkaz vygeneruje kostru projektu automaticky.
Úloha 1.3
Preložte a spustite program. Na preloženie programu slúži príkaz cabal build
a na jeho spustenie cabal run minesweeper
.
Poznámka
Väčšinou stačí použiť iba run
, ktorý automaticky preloži program, ak boli zmenené zdrojové kódy.
Krok 2: Doplnenie modulu a závislostí
Úloha 2.1
Pridajte do súboru tuke-minesweeper.cabal
ďalší modul s názvom Minesweeper.Board
a doplňte závislosti podľa vzoru:
executable minesweeper
main-is: Main.hs
other-modules: Minesweeper.Board
build-depends: base >=4.13 && <5,
containers ==0.6.*
default-language: Haskell2010
Týmto sme definovali, že okrem modulu Main
sa kód nášho programu bude nachádzať v jednom ďalšom module. Zároveň sme zväčšili rozsah podporovaných verzií štandardnej knižnice Haskellu a doplnili knižnicu containers.
Krok 3: Moduly
Úloha 3.1
Vytvorte súbor Minesweeper/Board.hs
, ktorý bude obsahovať definíciou hracieho poľa hry Míny.
Kód modulu musí obsahovať hlavičku definujúcu jeho názov a zoznam exportovaných symbolov:
module Minesweeper.Board
(
-- * Board
-- ** Types
Config(..)
, Board
, Pos(..)
, Tile(..)
-- ** Board creation
, createBoard
, smallBoard
) where
Zápis Config(..)
znamená, že exportovaný je dátový typ spolu so všetkými jeho konštruktormi. Na rozdiel od toho, typ Board
neexportuje konštruktory, takže v iných moduloch nie je možné ani vytvárať jeho hodnoty, ani ich rozkladať pomocou porovnávania vzorov. Prístup k jeho hodnotam je teda možný iba pomocou poskytnutých funkcií, čo zabezpečí skrytie jeho implementácie.
Za hlavičkou modulu nasledujú importy iných modulov:
import Data.Set (Set, member, notMember) -- most used set operations
import qualified Data.Set as Set -- the rest of operations
Následne máme definície dátových typov:
-- Board definition ---------------------------------------------------------
-- | Configuration of the board.
data Config = Config
{ configSize :: Pos -- ^ board size
, configNumMines :: Int -- ^ number of mines
} deriving (Eq, Show)
{- | Complete state of the board. It includes the configuration and hidden
properties.
The structure of the board is not exported. To create the board, use
'createBoard' smart constructor.
-}
data Board = Board
{ bConfig :: Config
, bMines :: Set Pos
, bOpened :: Set Pos
, bMarked :: Set Pos
} deriving (Show) -- only for testing
-- | Position of the tile or size of the board.
data Pos = Pos
{ posRow :: Int
, posCol :: Int
} deriving (Eq, Ord, Show)
-- | Tile state.
data Tile
= Open Int -- ^ Open tile with number of neighbour mines
| Closed -- ^ Closed tile
| Marked -- ^ Tile marked for mine
| Exploded -- ^ Exploded (opened) mine
deriving (Eq, Show)
-- Board creation -----------------------------------------------------------
-- | Create a new board with random mines.
createBoard :: Config -> [Pos] -> Board
createBoard config positions = Board
{ bConfig = config
, bMines = Set.fromList positions
, bOpened = Set.empty
, bMarked = Set.empty
}
-- | Configuration for a small board.
smallBoard :: Config
smallBoard = Config (Pos 8 8) 10
Úloha 3.2
Upravte modul Main
, tak aby ste si vyskúšali prácu s hracím poľom.
module Main where
import Minesweeper.Board
main :: IO ()
main = putStrLn (show exampleBoard)
where
exampleBoard = createBoard smallBoard [Pos x y | x <-[1, 3 .. 10], y <- [2, 4]]
Krok 4: Doplnenie funkcií
Úloha 4.1
Doplňte deklaráciu ďalších exportovaných funkcií v module Minesweeper.Board
:
-- ** Properties
, boardSize
, boardNumMines
, boardNumMarked
, validPos
, tileState
, isExploded
, isOpen
, isMarked
, isClosed
, neighbours
Úloha 4.2
Doplňte implementáciu chýbajúcich funkcií:
isClosed
– je poliťko na zadanej pozícií ešte zatvorené?neighbours
– zoznam pozícií okolitých políčok (s ošetrením okrajov hracieho poľa)clue
– číslo, ktoré sa zobrazí na políčku, ak je otvorené
Môžete použiť príklady definícií hodnôt a typové definície:
-- | Size of the board.
boardSize :: Board -> Pos
boardSize = configSize . bConfig
-- | Number of mines on the board.
boardNumMines :: Board -> Int
boardNumMines = configNumMines . bConfig
-- | Number of marked tiles on the board.
boardNumMarked :: Board -> Int
boardNumMarked = Set.size . bMarked
-- | Check if the position is within bounds of the board.
validPos :: Board -> Pos -> Bool
validPos board (Pos row col) =
row >= 1 && row <= maxRow && col >= 1 && col <= maxCol
where
(Pos maxRow maxCol) = boardSize board
-- | Get state of the tile at specific position.
tileState :: Board -> Pos -> Tile
tileState board pos
| isExploded board pos = Exploded
| isOpen board pos = Open (clue board pos)
| isMarked board pos = Marked
| otherwise = Closed
-- | Is tile on the position an opened mine?
isExploded :: Board -> Pos -> Bool
isExploded board pos = pos `member` bOpened board && pos `member` bMines board
-- | Is tile on the position open?
isOpen :: Board -> Pos -> Bool
isOpen board pos = pos `member` bOpened board && not (pos `member` bMines board)
-- | Is tile on the position marked?
isMarked :: Board -> Pos -> Bool
isMarked board pos = pos `member` bMarked board
-- | Is tile on the position closed?
isClosed :: Board -> Pos -> Bool
isClosed board pos = undefined
-- | Get positions of neighbour tiles of specified tile.
neighbours :: Board -> Pos -> [Pos]
neighbours = undefined
clue :: Board -> Pos -> Int
clue = undefined
Krok 5: I/O v zadaní
Posledná úloha je pomôckou pre implementáciu prvej časti posledného zadania. Preto už použijeme úplnú verziu projektu.
Úloha 5.1
Vyklonujte si kompletnú kostru zadnia z gitu:
git clone git@git.kpi.fei.tuke.sk:kpi/fp/project-minesweeper.git
Úloha 5.2
Implementujte modul Minesweeper.HumanSolver
z projektu.
Hlavná funkcia humanSolver
bude riešiť vstup a výstup. V prípade chybného vstupu tiež pomocou rekurzie zabezpečí opakované čítanie vstupu.
-- | Read a command from a user.
humanSolver :: Solver
humanSolver board = do
putStrLn "Please enter your choice: <O A1> for open, <M A1> for mark"
input <- getLine
...
Funkcia humanSolver
sa dá implementovať rôznymi spôsobmi. Aby bolo možné zobraziť používateľovi presné hlasenie o tom, kde vo vstupe urobil chybu, odporúčame použiť typ Either na reprezentáciu výsledku funkcie alebo chyby. Teda môžeme spojiť v jednom riešení dva použitia monád: monádu IO
pre realizáciu vstupu a výstupu, a monádu Either
pre ošetrovanie chýb.
O spracovanie vstupu od používateľa sa bude starať samostatná funkcia parseInput
. Ta dostane hraciu plochu (kvôli validácií ťahov) a reťazec zadaný používateľom. Jej výsledkom je buď zadaný ťah, alebo hlásenie o chybe.
parseInput :: Board -> String -> Either String Move
Samotná funkcia však iba normalizuje vstup a kontroluje, či nie je prázdny. O jednotlivé kroky spracovania sa budú starať pomocné funkcie, ktoré už nebudú priamo pracovať s IO
:
-- Remove unnecessary spaces and normalize case.
normalizeInput :: String -> String
-- Parse whole command and return either a specific error string or the Move.
-- Expects nonempty normalized string.
parseMove :: Board -> String -> Either String Move
-- Parse beginning of the move: O (for open) or M (for mark).
-- Return either an error message or Move constructor.
parseCommand :: Char -> Either String (Pos -> Move)
-- Parse position
parsePosition :: String -> Either String Pos
Poznámka
Takéto rozdelenie funkcií je iba odporúčaním.