۱۳ - ۱۲قدم سوم: ساخت یه پازل
قدم بعدی، طراحی پایه و نحوهی بازی کردن ِه. باید به طریقی لغت رو از بازیکن مخفی کنیم (در عین حال باید تعداد حروفش رو براشون مشخص کنیم) و یه راه هم برای گرفتن حرفی که حدس زدن داشته باشیم. اگه اون حرف جزئی از لغت بود، بذاریمش توی لغت، اگر هم نبود، باید بره تو یه لیست "حدس زدهها." پایان بازی رو هم باید تعیین کنیم.
با یه نوعداده برای پازلمون شروع میکنیم. Puzzle
یه ضرب از یک String
، یه لیستِ Maybe Char
، و یه لیستِ Char
هست:
data Puzzle =
Puzzle String [Maybe Char] [Char]
-- [1] [2] [3]
۱.
لغتی که قراره حدس زده بشه.
۲.
حرفهایی که تا الان پر کردیم.
۳.
حروفی که تا اینجا حدس زدیم.
بعد یه نمونه از تایپکلاسِ Show
برای نوعداده ِ Puzzle
تعریف میکنیم. اگه به خاطر داشته باشین، تابعِ show
چیزهای نوشتاری که به دردِ آدمها بخوره درست میکنه، که مسلماً برای تعامل با این بازی لازمِه. به خاطرِ نیازِ خاصی که از این تابع داریم، نمونهش رو خودمون باید تعریف کنیم.
ببینید چطور آرگومانِ show
با تعریفِ نوعدادهمون در بالاتر هماهنگه. اینجا discovered
همون لیستِ Maybe Char
، و guessed
اسمیه که به لیستِ Char
ها دادیم، اما با خودِ String
کاری نکردیم:
instance Puzzle where
show (Puzzle _ discovered guesses) =
(intersperse ' ' $
fmap renderPuzzleChar discovered)
++ " Guessed so far: " ++ guessed
این دو چیز از پازلمون رو نشون میده: یکی لیستِ Maybe Char
که هم حرفهاییاند که تا اینجا درست حدس زدیم و هم بقیهی حرفهای پازلاند که با خط تیره نشون داده میشن (با یک فاصله بین هر کدوم)؛ و یکی هم لیستِ Char
که یادآوری میکنه کدوم حرفها رو تا اینجا حدس زدیم. پایینتر renderPuzzleChar
رو میگیم.
اول یه تابع مینویسیم که لغتِ پازل رو به یه لیست از Nothing
تبدیل میکنه. این اولین قدم برای مخفی کردنِ لغت از بازیکنه. به کمکِ اطلاعات زیر، خودتون این تابع رو تعریف کنین:
یه تایپ سیگنچر بهتون دادیم. آرگومان اول یه String
ِه، که همون لغتِ مورد نظره. یه مقدار با تایپِ Puzzle
هم برمیگردونه. یادتون باشه که تایپِ Puzzle
یه ضرب از سه چیزه.
اولین مقدار در خروجی، میشه همون String
که ورودیِ تابع هم بود.
مقدار دوم هم میشه نتیجهی حاصل از نگاشت ِ یه تابع روی آرگومانِ String
. برای تابعی که نگاشت میشه، const
شاید گزینهی بدی نباشه. این تابع همیشه آرگومان اولش رو برمیگردونه – آرگومان دومش تأثیری رو خروجیش نداره.
برای این تابع، آرگومان سومِ Puzzle
میتونه یه لیستِ خالی باشه.
ببینیم چه میکنین:
freshPuzzle :: String -> Puzzle
freshPuzzle = undefined
حالا یه تابع لازم داریم که لغتِ Puzzle
رو ببینه و بگه که آیا حرف ِ حدسی جزء یکی از المانهای اون لیست هست یا نه. چندتا راهنمایی:
این دو آرگومان میخواد، که یکی از اونها هم تایپِ Puzzle
ِه که ضرب از سه تایپه. اما در این تابع آرگومانِ اولِ Puzzle
برامون مهمه.
با خط تیره میتونیم بیاهمیت بودنِ مقادیر مورد نظر رو برای تابع مشخص کنیم تا نادیده گرفته بشن. این که از خط تیره استفاده کنین یا اون مقادیر رو نامگذاری کنین، تأثیری روی نتیجهی تابع نمیذاره. اما اینطور صراحت، کُدتون رو تمیز و خواناییش رو بیشتر میکنه.
تابعِ استانداردِ elem
اینطور کار میکنه:
Prelude> :t elem
elem :: Eq a => a -> [a] -> Bool
Prelude> elem 'a' "julie"
False
Prelude> elem 3 [1..5]
True
خوب، این هم از تایپ سیگنچر:
charInWord :: Puzzle -> Char -> Bool
charInWord = undefined
تابع بعدی خیلی شبیه اونیه که الان نوشتین، اما این دفعه با String
از Puzzle
کاری نداریم. این بار میخوایم ببینیم آیا کاراکترِ حدس زده شده یکی از المانهای لیستِ guessed
هست یا نه.
ما به شما ایمان داریم:
alreadyGuessed :: Puzzle -> Char -> Bool
alreadyGuessed = undefined
خیلی خوب، تا اینجا تونستیم یه لغت برای بازیمون انتخاب کنیم، و تشخیص بدیم که آیا یه کاراکتر حدس زده شده جزئی از لغت هست یا نه. اما در طول بازی باید بقیهی لغت رو از دید بازیکن مخفی کنیم. هر چی باشه کامپیوترها یه کم احمقاند، نمیدونن چطور رازداری کنن. بالاتر که برای Puzzle
نمونه ِ Show
نوشتیم، یه تابعی به اسمِ renderPuzzleChar
رو روی آرگومانِ دومِِ Puzzle
نگاشت کردیم. الان روی اون تابع کار میکنیم.
هدف اینه که با استفاده از Maybe
، دو خروجیِ مختلف داشته باشیم. در نمونه ِ Show
این تابع روی یه String
نگاشت شده، پس در هر لحظه روی یه حرف کار میکنه. اگه اون حرف هنوز به درستی حدس زده نشده، یه مقدارِ Nothing
هست و باید با یه خط تیره نشون داده بشه. اما اگه حدس زده شده باشه، میخوایم خودِ حرف رو نشون بدیم تا بازیکن جایگاه حروفی که به درستی حدس زده رو بدونه:
Prelude> renderPuzzleChar Nothing
'_'
Prelude> renderPuzzleChar (Just 'c')
'c'
Prelude> let n = Nothing
Prelude> let daturr = [n, Just 'h', n, Just 'e', n]
Prelude> fmap renderPuzzleChar daturr
"_h_e_"
نوبتِ شماست. یادتون باشه که لازم نیست اون نگاشت کردن رو اینجا انجام بدین:
renderPuzzleChar :: Maybe Char -> Char
renderPuzzleChar = undefined
قسمت بعدی یه کوچولو پیچیدهتره. باید کاراکتری که به درستی حدس زده شده رو داخلِ String
وارد کنیم. البته هیچ چیزش براتون جدید نیست، اما یه کم شاید متراکم نوشته شده باشه. ما هم با شمارهگذاری براتون بازش میکنیم (البته واضحه که لازم نیست شما تو کُدِ خودتون این کامنتها رو بنویسین):
filledInCharacter :: Puzzle -> Char -> Puzzle
filledInCharacter (Puzzle word
-- [1]
filledInSoFar s) c =
-- ^-----------^ [2]
-- م. تا به اینجا پرشده
Puzzle word newFilledInSoFar (c : s)
-- [ 3 ]
where zipper guessed wordChar guessChar =
-- [4] [5] [6] [7]
if wordChar == guessed
then Just wordChar
else guessChar
-- [ 8 ]
newFilledInSoFar =
-- [9]
zipWith (zipper c)
word filledInSoFar
-- [ 10 ]
۱.
اولین آرگومانْ Puzzle
به همراهِ سه آرگومانِشه، که s
لیستِ کاراکترهای حدسزدهست.
۲.
این c
آرگومانِ Char
یا همون کاراکتریه که بازیکن در این نوبت حدس زده.
۳.
خروجیمون همون Puzzle
ِ ورودیه که filledInSoFar
ِش با newFilledInSoFar
عوض شده، و c هم به اولِ لیستِ s
اضافه شده (cons شده).
۴.
تابعِ zipper
با در نظر گرفتن کاراکتر حدس زده شده، کاراکترهای داخل لغت، و کاراکترهایی که قبلاً حدس زده شده بودن، تصمیم میگیره چه خروجیای بده. اگه کاراکتری که بازیکن حدس زده یکی از حروفِ لغتِ مورد نظر باشه، اونموقع Just wordChar
برمیگردونیم تا اون حرف از پازل رو پر کنه. در غیر اینصورت، خودِ guessChar
رو برمیگردونیم. به این خاطر guessChar
رو دست نخورده برمیگردونیم چون ممکنه یا یه کاراکتری رو داشته باشه که قبلاً به درستی حدس زده شده بوده، یا ممکنه یکی از Nothing
هایی باشه که نه الان، نه قبلاً درست حدس زده نشده بوده.
۵.
guessed
کاراکتریه که حدس زده شده.
۶.
wordChar
کاراکترهای لغت مورد نظره – نه اونهایی که بازیکنها حدس زدن یا حدس نزدن، کاراکترهای داخل کلمه که قراره بازیکنها حدس بزنن.
۷.
guessChar
هم لیستیه که همهی کاراکترهای حدس زده شده رو نگه میداره.
۸.
این بیانیهی if-then-else
بررسی میکنه ببینه آیا کاراکترِ حدس زده شده یکی از کاراکترهای کلمهی مورد نظر هست یا نه. اگه مساویاند، اون کاراکتر رو میذاره زیرِ Just
، چون لغتِ پازل یه لیست از مقادیرِ Maybe
ِه.
۹.
newFilledInSoFar
با استفاده از توابعِ zipWith
و zipper
کاراکترهای پازل رو پر میکنه و حالت ِ جدیدِ پازل رو برمیگردونه. تابعِ zipper
اول به کاراکتری که بازیکن حدس زده اعمال میشه، چون تغییری نمیکنه. بعدش هم بینِ دو لیست زیپ میشه. یکی از لیستها word
ِه که همون لغتِ موردِ نظره. لیست دوم هم حالت ِ شروعیِ پازل (filledInSoFar
) با تایپِ [Maybe Char]
هست. این لیستیه که میگه کدوم حروف از word
تا اینجا حدس زده شدن.
۱۰.
اینجا با zipWith
لیستِ newFilledInSoFar
رو درست میکنیم. شاید این تابع از فصل لیستها یادتون باشه. این تابع مقادیر لیستهای word
و filledInSoFar
رو با تابعِ zipper
که بالاتر تعریف کردیم زیپ میکنه (یعنی به موازات، یک المان از word
رو به عنوانِ یکی از آرگومانهاش، و یک المان از filledInSoFar
رو به عنوانِ آخرین آرگومانش میگیره).
در مرحلهی بعد، یه بلوک ِ do
ِ بزرگ با بیانیهی case داریم، که هر کدوم از حالتهاش، باز خودشون یه بلوک ِ do
دارن. چرا که نه؟
اول به بازیکن میگه چه حدسی زده. بیانیهی case بسته به کاراکترِ حدس زده شده، سه حالت رو در نظر میگیره:
اگه کاراکترِ حدس زده شده قبلاً حدس زده شده بوده؛
اگه اون کاراکتر یکی از حروفِ لغتِ موردِ نظره و باید جاگذاری بشه؛
یا اینکه نه قبلاً حدس زده شده بوده، و نه داخل کلمهی پازله.
برخلاف ظاهرش که ممکنه پیچیده به نظر برسه، بیشترش گرامرهایی اند که قبلاً دیدین. اگه قدم به قدم نگاهِش کنین، متوجه میشین چه خبره:
handleGuess :: Puzzle -> Char -> IO Puzzle
handleGuess puzzle guess = do
putStrLn $ "Your guess was: " ++ [guess]
case (charInWord puzzle guess
, alreadyGuess puzzle guess) of
(_, True) -> do
putStrLn "You already guess that\
\ character, pick \
\ something else!"
return puzzle
(True, _) -> do
putStrLn "This character was in the\
\ word, filling in the word\
\ accordingly"
return puzzle
(False, _) -> do
putStrLn "This character wasn't in\
\ the word, try again."
return (fillInCharacter puzzle guess)
خیلی عالی. بعدش باید یه راهی طراحی کنیم که بازی بعد از یه تعداد حدس متوقف شه. داربازی به طور معمول بعد از یه تعداد حدسِ غلط تموم میشه، اما ما برای سادگی، همهی حدسها رو میشماریم، چه درست و چه غلط. باز هم با توجه به کارهایی که تا اینجا کردیم، گرامر ِ این بخش باید براتون آشنا باشه:
gameOver :: Puzzle -> IO ()
gameOver (Puzzle wordToGuess _ guessed) =
if (length guessed) > 7 then
do putStrLn "You lose!"
putStrLn $
"The word was: " ++ wordToGuess
exitSuccess
else return ()
دقت کنین با این کُد، اگه حتی آخرین (هفتمین) حدسْ درست باشه و لغت رو تکمیل کنه، باز هم پیغام میده که بازیکن باخته. البته میشه کاری کرد که به داربازیِ واقعی نزدیکتر بشه، ما هم شما رو تشویق میکنیم که تلاشتون رو بکنین.
کارِ دیگهای که باید انجام بدیم، راهی برای خروج از بازی بعد از بُردنه. اوایلِ این پروژه گفتیم چطور میشه از ترکیب isJust
و all
استفاده کرد. اینجا اون رَوِش رو در عمل میبینید. حتماً یادتون هست که لغتِ پازل یه لیست از مقادیرِ Maybe
بود. پس هر وقت همهی کاراکترها با یه Just Char
بجای Nothing
مشخص شده باشن، بازیکن بازی رو بُرده، و از بازی خارج میشیم:
gameWin :: Puzzle -> IO ()
gameWin (Puzzle _ filledInSoFar _) =
if all isJust filledInSoFar then
do putStrLn "You win!"
exitSuccess
else return ()
بعدش دستورات برای اجرای بازی رو باید بنویسیم. اینجا با استفاده از forever
کاری میکنیم که این سری از اجراییهها تا بینهایت تکرار بشن:
runGame :: Puzzle -> IO ()
runGame puzzle = forever $ do
gameOver puzzle
gameWin puzzle
putStrLn $
"Current puzzle is: " ++ show puzzle
putStr "Guess a letter: "
guess <- getLine
case guess of
[c] -> handleGuess puzzle c >>= runGame
_ ->
putStrLn "Your guess must\
\ be a single character"
و در آخر، با main
همهی اینها رو کنارِ هم میذاریم: یه لغت از لیستِ لغاتی که ساختیم میگیره، یه پازلِ تازه ایجاد میکنه، و runGame
که این بالا تعریف کردیم رو تا زمانی اجرا میکنه که یا همهی حروفِ لغت رو حدس بزنین، یا هفت تا حدستون تموم بشه، هر کدوم اول پیش بیاد:
main :: IO ()
main = do
word <- randomWord'
let puzzle =
freshPuzzle (fmap toLower word)
runGame puzzle