۱۳ - ۱۲قدم سوم: ساخت یه پازل
قدم بعدی، طراحی پایه و نحوهی بازی کردن ِه. باید به طریقی لغت رو از بازیکن مخفی کنیم (در عین حال باید تعداد حروفش رو براشون مشخص کنیم) و یه راه هم برای گرفتن حرفی که حدس زدن داشته باشیم. اگه اون حرف جزئی از لغت بود، بذاریمش توی لغت، اگر هم نبود، باید بره تو یه لیست "حدس زدهها." پایان بازی رو هم باید تعیین کنیم.
با یه نوعداده برای پازلمون شروع میکنیم. 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