Сегодня мы поучимся программировать на языке Haskell простейшие интерактивные игры. Для этих целей воспользуемся одной наработкой, которая ранее была описана мною в заметке «Утилита для работы с N-граммами». Ну а разработаем мы консольную игру «в слова» — довольно простую программу, которая будет ожидать от игрока ввода слов на ту или иную тему так, чтобы первая буква введённого слова совпадала с последней буквой того слова, которое назовёт программа.main, являющуюся точкой входа в программу. Все остальные функции и другие программные сущности напишем в дополнительных модулях, которые будут описаны ниже, а в главный модуль просто включены:import WGCommons
import WGFile
import WGPlay
import WGType
main :: IO ()
main = do (userName, gameLevel) <- greetings
iniData <- loadIniFile "wordsgame.ini"
scoreTable <- loadScoreTableFile "scoretable.ini"
play $ GC (fst gameLevel ++ " " ++ userName) gameLevel iniData scoreTable
farewell
greetings, которая выводит приветственное сообщение, также запрашивает у игрока его имя и уровень, на котором он желал бы поиграть. Их и возвращает. Функции для чтения данных из файлов возвращают эти данные. Цикл игры ничего не возвращает, равно как и функция вывода прощального сообщения на экран.play, которая вызывается из точки входа main, выглядит так:play :: GameConfiguration -> IO ()
play gc =
do putStr "Выбирайте тему - "
putStr (intercalate ", " (map fst $ iniData gc) ++ ": ")
theme <- getLine
case lookup (capitalize $ map toLower theme) (iniData gc) of
Just fn -> playAndSaveScoreTable theme fn (iniData gc)
Nothing -> do putStr "Такая тема мне неизвестна. Начнём новый сеанс обучения? "
yesNo <- getLine
if yesNo `isElementOf` yesWords
then do putStr (userName gc ++
", введите имя файла для сохранения данных: ")
fn <- getLine
let newIniData = (theme, fn) : iniData gc
saveIniFile "wordsgame.ini" newIniData
playAndSaveScoreTable theme fn newIniData
else play gc
where
playAndSaveScoreTable theme' fileName' iniData' =
do score <- playOneRound theme' fileName' (snd $ gameLevel gc)
putStr ("\nБлагодарю за прекрасную игру. Вы набрали " ++ show score ++
" " ++ setProperCase score ("очк", "о", "а", "ов") ++ ". ")
putStrLn ("Вы заняли " ++ show (place score (scoreTable gc)) ++
" место в общем зачёте и " ++
show (placeInTheme theme' score (scoreTable gc)) ++
" место по теме \"" ++ theme' ++ "\".\n")
let newScoreTable = (theme', userName gc, score) : scoreTable gc
saveScoreTableFile "scoretable.ini" newScoreTable
willYouPlayAgain iniData' newScoreTable
willYouPlayAgain iniData' scoreTable' =
do putStr (userName gc ++ ", ещё будете играть? ")
yesNo <- getLine
when (yesNo `isElementOf` yesWords) $
play gc{iniData = iniData', scoreTable = scoreTable'}
play из функции main. В конфигурацию игры входит имя игрока, выбранный им уровень игры, таблица очков и информация из файла инициализации.playAndSaveScoreTable. Рассмотрим её также подробно. Эта функция довольно проста — она вызывает функцию playOneRound, которую мы рассмотрим позже. Эта функция запускает один раунд игры. После окончания этого раунда, рассматриваемая функция выводит на экран набранные очки, положение игрока в общем зачёте и в зачёте по выбранной теме, после чего сохраняет набранные очки в файле и предлагает игроку сыграть ещё раз. Это делается при помощи функции willYouPlayAgain.play. Всё, что она делает, так это то, что запрашивает у игрока ответ на вопрос о том, будет ли он играть ещё один раунд. Если игрок отвечает положительно, то рекурсивно запускается функция play с обновлённой игровой информацией.playOneRound, напишем две маленькие служебные функции, которые возвращают занятое игроком место по итогам одного раунда игры. Они очень простые и в целом даже очень похожи:place :: Int -> ScoreTableFile -> Int
place score scoreTable = 1 + length (takeWhile (> score) $
reverse $
sort $
map (\(_, _, r) -> r) scoreTable)
placeInTheme :: String -> Int -> ScoreTableFile -> Int
placeInTheme theme score scoreTable = 1 + length (takeWhile (> score) $
reverse $
sort $
map (\(_, _, r) -> r) $
filter (\(t, _, _) -> t == theme) scoreTable)
playOneRound. Она оказывается очередной обёрткой:playOneRound :: String -> FilePath -> [Char] -> IO Int
playOneRound theme fileName gl = do putStr ("\nОтлично! Играем в \"" ++ theme ++ "\". ")
wl <- loadWordsData fileName
gc <- playGame gl wl
saveWordsData fileName (gameData gc)
return $ score gc
playGame). После игры она сохраняет новые слова в файле со словарём и возвращает набранные пользователем очки.playGame, которая, собственно, и отвечает за игру. Вот её определение:playGame :: [Char] -> WordDB -> IO RoundConfiguration
playGame gl wl = do putStrLn "Начинаем игру."
strategy <- getRandomElement strategies
rc <- makeOneStep gl $ RC wl strategy "" [] 0
putStrLn ("\nНазванные слова: " ++
intercalate " - " (reverse $ namedWords rc) ++ ".")
return rc
makeOneStep), которая отвечает за вывод слова роботом и получение слова от игрока. После игры на экран выводится полный список введённых в процессе игры слов. Ну и результат (текущая конфигурация) игры возвращается наверх для обработки. Рассмотрим же функцию makeOneStep, которая, по сути, является «сердцем» этого модуля. Вот её определение:makeOneStep :: [Char] -> RoundConfiguration -> IO RoundConfiguration
makeOneStep gl rc =
do (myWN, newStrategy) <- generateWordName gl rc
if myWN == ""
then do putStr ("У меня нет вариантов. Научите (слово на букву " ++
[lastLetter $ currentWord rc] ++"): ")
getUserAnswer newStrategy (currentWord rc) (namedWords rc) 2
else do putStr ("Мой вариант - " ++ map toUpper myWN ++
". Называйте слово на букву " ++ [lastLetter myWN] ++ ": ")
getUserAnswer newStrategy myWN (myWN : namedWords rc) 1
where
getUserAnswer ns s nl dp =
do userWN <- getUserWordName $ lastLetter s
if userWN `elem` nl
then do putStr "Такое слово уже называлось. Попробуйте ещё раз: "
getUserAnswer ns s nl dp
else if userWN `isElementOf` surrenderWords
then return rc{currentWord = s, namedWords = nl}
else makeOneStep gl $ RC (updateWordList (gameData rc) userWN)
ns
userWN
(userWN:nl)
(score rc + dp)
generateWordName, которую мы рассмотрим позже). Ей передаётся на вход список символов, на которые может заканчиваться слово (этот список зависит от уровня игры и определяется стратегией), а также конфигурация игры, сложившаяся после предыдущего раунда. На выходе у этой функции пара — новое слово и новая стратегия игры. Если слово, которое придумал бот, пустое, то это значит, что у бота нет ответа, и игра предлагает уже игроку назвать слово, начисляя ему за это 2 очка (за просто называние слова — 1 очко). Если же слово у бота имеется, то оно выводится на экран, а игроку теперь надо назвать слово, оканчивающееся на последнюю букву слова, названного ботом.getUserAnswer, которая получает на вход новую стратегию, по которой играет бот, текущее названное слово, список всех названных ранее слов и приращение к количеству очков игрока. Как уже сказано выше — если у бота есть вариант, то приращение равно 1, а если нет, то 2. В этой локальной функции у игрока запрашивается слово на определённую букву, после чего проверяется выполнение двух условий: названное игроком слово ещё не называлось и пользователь не ввёл какое-нибудь из специальных слов, свидетельствующих о том, что он сдаётся (и после этого игра заканчивается). Если всё нормально, то из этой локальной функции вновь рекурсивно запускается основная функция makeOneStep с обновлённой конфигурацией игры.getUserWordName, которая запрашивает слово у игрока. Вот её определение:getUserWordName :: Char -> IO WordName
getUserWordName ' ' = getLine
getUserWordName fl = do userWN' <- getLine
let userWN = capitalize $ map toLower userWN'
if userWN `isElementOf` surrenderWords ||
userWN /= "" && head userWN == toUpper fl
then return userWN
else do putStr ("Будьте внимательны. Назовите слово на букву " ++
[fl] ++ " или сдавайтесь: ")
getUserWordName fl
generateWordName :: [Char] -> RoundConfiguration -> IO (WordName, GameStrategy)
generateWordName gl rc
| null $ currentWord rc = do wordPair <- getRandomElement $ gameData rc
wn <- getRandomElement $ snd wordPair
return (wn, strategy rc)
| null $ gameData rc = return ("", strategy rc)
| null wl' = return ("", strategy rc)
| null wl'' = return ("", strategy rc)
| otherwise = do wn <- strategy rc (currentWord rc) wl''
if null wn
then do newStrategy <- getRandomElement strategies
generateWordName gl rc{strategy = newStrategy}
else return (wn, strategy rc)
where
wl' = filter (\x -> fst x == lastLetter (currentWord rc)) (gameData rc)
wl'' = [wn | wn <- snd $ head wl',
wn `notElem` namedWords rc,
lastLetter wn `elem` gl]
firstWordStrategy :: GameStrategy
firstWordStrategy _ = return . head
randomWordStrategy :: GameStrategy
randomWordStrategy _ = getRandomElement
sameLetterStrategy :: GameStrategy
sameLetterStrategy wn wl | wn == "" = return ""
| wl' == [] = return ""
| otherwise = getRandomElement wl'
where
ll = last wn
wl' = filter (\l -> lastLetter l == ll) wl
certainLetterStrategy :: Char -> GameStrategy
certainLetterStrategy c _ wl = do let wl' = filter (\l -> lastLetter l == c) wl
if wl' == []
then return ""
else getRandomElement wl'
hardLetterStrategy :: GameStrategy
hardLetterStrategy _ wl = do let wl' = filter (\l -> lastLetter l `elem` hardLetters) wl
if wl' == []
then return ""
else getRandomElement wl'
where
hardLetters = "ёйы"
strategies :: [GameStrategy]
strategies = firstWordStrategy :
randomWordStrategy :
sameLetterStrategy :
hardLetterStrategy :
map certainLetterStrategy ['А'..'Я']
strategies определено 33 + 4 = 37 стратегий. Вот их описания:firstWordStrategy — наипростейшая стратегия. Просто выбирает самое первое слово из списка возможных слов.randomWordStrategy — простая стратегия. Выбирает случайное слово из списка возможных слов.sameLetterStrategy — хитрая стратегия. Выбирает слово так, чтобы оно оканчивалось на ту же самую букву, что и начинается, то есть даёт игроку задачу назвать слово на ту же букву, что и игрок дал боту.certainLetterStrategy — наглая стратегия. Всегда даёт игроку задачу называть слова на одну и ту же букву. Если буква обычная в начале слова, то игра может быть длительной и занимательной. Если же боту взбрело в его искусственный разум давать игроку задачи называть слова на такую букву, как, например, «Щ», то игра закончится довольно быстро в пользу бота.hardLetterStrategy — убийственная стратегия. Ну а эта стратегия выбирает одну из самых неприятных букв русского языка и даёт игроку задачу назвать слово на эту букву.updateWordList :: WordDB -> WordName -> WordDB
updateWordList [] wn = [(toUpper $ head wn, [wn])]
updateWordList (wl@(wlf, _):wls) wn@(wnh:_) | wlf == wnh = updateWordList' wl wn : wls
| otherwise = wl : updateWordList wls wn
where
updateWordList' (fl, []) wn' = (fl, [wn'])
updateWordList' (fl, wl') wn' | wn' `elem` wl' = (fl, wl')
| otherwise = (fl, wn':wl')
isKnown :: String -> IniFile -> Bool
isKnown theme = any (\(t, fp) -> t == theme)
True, если введённая игроком тема игры известна боту. Но, вроде бы, этот предикат даже и не используется в программе. Кто первый найдёт эту закладку и напишет в комментарии — тому приз. Всё.WGFile собраны все функции, которые работают с файлами. Сюда же внесены функции, осуществляющие синтаксический анализ файлов. Рассмотрим их все более или менее подробно.loadFile :: FilePath -> FileManager a -> IO a
loadFile filePath fm = catch readContents
(\e -> do putStrLn (msgIOError fm ++ "\n")
return $ parser fm "")
where
readContents = do cnt <- readFile filePath
let {
fileData = parser fm cnt;
qnt = quantifier fm fileData
}
if validator fm fileData
then putStrLn (msgParseError fm ++ "\n")
else putStrLn (msgSuccess fm ++ show qnt ++ " " ++
setProperCase qnt (unit fm) ++ ".\n")
return fileData
FileManager, которая содержит все необходимые функции и данные (описание этой структуры в разделе ниже).loadIniFile :: FilePath -> IO IniFile
loadIniFile filePath = loadFile filePath iniFileManager
where
iniFileManager = FM
{
parser = parseIniFile,
validator = null,
quantifier = length,
unit = ("тем", "а", "ы", ""),
msgIOError = "ОШИБКА: Невозможно открыть файл инициализации. " ++
"Всё моё обучение начинается с самого начала " ++
"(либо Вам необходимо вручную восстановить " ++
"файл инициализации).",
msgParseError = "ОШИБКА: Файл инициализации повреждён. " ++
"Всё моё обучение начинается с самого начала " ++
"(либо Вам необходимо вручную восстановить " ++
"файл инициализации).",
msgSuccess = "Файл инициализации обработан. Загружено "
}
loadScoreTableFile :: FilePath -> IO ScoreTableFile
loadScoreTableFile filePath = loadFile filePath stFileManager
where
stFileManager = FM
{
parser = parseScoreTableFile,
validator = null,
quantifier = length,
unit = ("запис", "ь", "и", "ей"),
msgIOError = "ОШИБКА: Невозможно открыть файл с таблицей " ++
"рекордов. Ведение рекордов начинается с самого начала.",
msgParseError = "ОШИБКА: Файл с таблицей рекордов повреждён. " ++
"Ведение рекордов начинается с самого начала.",
msgSuccess = "Файл с таблицей рекордов обработан. Загружено "
}
loadWordsData :: FilePath -> IO WordDB
loadWordsData filePath = loadFile filePath dataFileManager
where
dataFileManager = FM
{
parser = parseWordsData,
validator = null,
quantifier = numberOfWords,
unit = ("слов", "о", "а", ""),
msgIOError = "ОШИБКА: Невозможно открыть файл с данными. " ++
"Моё обучение по выбранной теме начинается " ++
"с самого начала.",
msgParseError = "ОШИБКА: Файл с данными повреждён. Моё " ++
"обучение по выбранной теме начинается с " ++
"самого начала.",
msgSuccess = "Файл с данными прочитан успешно. Загружено "
}
FileManager, которая передаётся на вход обобщённой функции loadFile. Да, и здесь надо отметить, что запись validator = nullnull из стандартного модуля Prelude, и предикат этот возвращает значение True для пустых списков.Parsec. Но сейчас будет так:parseIniFile :: String -> IniFile
parseIniFile = map parseIniPair . lines
parseIniPair :: String -> (WordName, FilePath)
parseIniPair s = (theme, dropWhile (not . Data.Char.isAlpha) rest)
where
(theme, rest) = break (== ':') s
parseScoreTableFile :: String -> ScoreTableFile
parseScoreTableFile = map parseScoreTableLine . lines
parseScoreTableLine :: String -> (WordName, String, Int)
parseScoreTableLine s = (theme, name, result)
where
(theme, rest) = break (== ':') s
(name, rest') = break (== ':') $ dropWhile (not . Data.Char.isAlpha) rest
result = read $ dropWhile (not . Data.Char.isDigit) rest'
:). Ну и, наконец, синтаксические анализаторы для файла с базой слов:parseWordsData :: String -> WordDB
parseWordsData = map parseWordsList . lines
parseWordsList :: String -> WordList
parseWordsList (w:ws) = (w, parseWords $ dropWhile (not . isGoodSymbol) ws)
parseWords :: String -> [WordName]
parseWords "" = []
parseWords wl = word : parseWords (dropWhile (not . isGoodSymbol) rest)
where
(word, rest) = break (== ',') wl
saveFile :: FilePath -> String -> IO ()
saveFile = writeFile
writeFile получает на вход путь к файлу и строку, которая становится содержимым файла. Ну а вот конкретизированные функции для трёх типов файлов:saveIniFile :: FilePath -> IniFile -> IO ()
saveIniFile filePath iniData
= saveFile filePath
(concatMap (\(theme, path) -> theme ++ ": " ++ path ++ "\n") $
sortBy (comparing fst) iniData)
saveScoreTableFile :: FilePath -> ScoreTableFile -> IO ()
saveScoreTableFile filePath scoreTable
= saveFile filePath
(concatMap (\(theme, userName, score) -> theme ++ ": " ++ userName ++
": " ++ show score ++ "\n") $
sortBy themeAndScore scoreTable)
where
themeAndScore (theme1, _, score1) (theme2, _, score2)
| theme1 == theme2 = reverseOrder $ compare score1 score2
| otherwise = compare theme1 theme2
reverseOrder LT = GT
reverseOrder EQ = EQ
reverseOrder GT = LT
saveWordsData :: FilePath -> WordDB -> IO ()
saveWordsData filePath wl
= saveFile filePath
(convertToString $ sortBy (comparing fst) $ improve wl)
improve :: WordDB -> WordDB
improve = map (second sort)
convertToString :: WordDB -> String
convertToString = concatMap wordLine
where
wordLine (c, wl) = c : (": " ++ intercalate ", " wl ++ "\n")
WGCommons находятся определения всех сервисных функций, которые так или иначе требуются в программе. Просто сборная солянка. Например, вот константная функция, которая возвращает список возможных уровней игры:gameLevels :: [GameLevel]
gameLevels = [("Неофит", "АБВГДЗИКЛМНОПРСТФХ"),
("Адепт", "АБВГДЕЖЗИКЛМНОПРСТУФХЦЧШЩЭЮЯ"),
("Апологет", "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЫЭЮЯ")]
getRandomElement :: [a] -> IO a
getRandomElement l = fmap (l !!) $ randomRIO (0, length l - 1)
isGoodSymbol :: Char -> Bool
isGoodSymbol c = Data.Char.isAlpha c || c `elem` "чё-ЧЁ"
toUpper :: Char -> Char
toUpper c | Data.Char.isAlpha c = Data.Char.toUpper c
| c == 'ё' = 'Ё'
| c == 'ч' = 'Ч'
| c == 'я' = 'Я'
| otherwise = c
toLower :: Char -> Char
toLower c | Data.Char.isAlpha c = Data.Char.toLower c
| c == 'Ё' = 'ё'
| c == 'Ч' = 'ч'
| c == 'Я' = 'я'
| otherwise = c
setProperCase :: Int -> WordParadigm -> String
setProperCase i (root, ns, gs, gp) | iMod100 > 10 && iMod100 < 20 = root ++ gp
| iMod10 == 1 = root ++ ns
| iMod10 >= 2 && iMod10 <= 4 = root ++ gs
| otherwise = root ++ gp
where
iMod10 = i `mod` 10
iMod100 = i `mod` 100
WordParadigm в следующем разделе). Все числительные, оканчивающиеся на 1, кроме 11 и основанных на нём, требуют слова в именительном падеже единственного числа. Все числительные, оканчивающиеся на 2, 3 или 4, кроме 12, 13 и 14 и основанных на них, требуют слова в родительном падеже единственного числа. Остальные числительные, в том числе и 11, 12, 13 и 14, требуют слова в родительном падеже множественного числа. Таков русский язык.type IniFile = [(WordName, FilePath)]
type ScoreTableFile = [(WordName, String, Int)]
type WordName = String
type WordList = (Char, [WordName])
type WordDB = [WordList]
Data.Map был бы лучшим решением для этих целей.type WordParadigm = (String, String, String, String)
type GameLevel = (WordName, [Char])
type GameStrategy = WordName -> [WordName] -> IO WordName
data FileManager a = FM
{
parser :: String -> a,
validator :: a -> Bool,
quantifier :: a -> Int,
unit :: WordParadigm,
msgIOError :: String,
msgParseError :: String,
msgSuccess :: String
}
parser представляет функцию для синтаксического анализа содержимого файла. Поле validator представляет предикат, который проверяет семантическую правильность прочитанных данных. Поле quantifier также является функцией и представляет способ подсчёта некоторого количественного параметра (например, для файла инициализации это длина списка, то есть количество тем, известных боту; а для базы слов — количество слов в базе, но считать это количество надо иначе, нежели просто в виде длины списка). Поле unit представляет единицу измерения того, что записано в файле, в том числе и для правильного склонения. Три поля с префиксом msg — это строки для вывода сообщений об ошибке ввода-вывода, ошибке синтаксического анализа и сообщения об успешном чтении файла.data GameConfiguration = GC
{
userName :: String,
gameLevel :: GameLevel,
iniData :: IniFile,
scoreTable :: ScoreTableFile
}
data RoundConfiguration = RC
{
gameData :: WordDB,
strategy :: GameStrategy,
currentWord :: WordName,
namedWords :: [WordName],
score :: Int
}
State для передачи состояния из функции в функцию. Было бы неплохо переделать код так, чтобы использовалась эта монада.WGCommons.Data.Map).Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.
комментарии (10)