۱۳ - ۱۲قدم سوم: ساخت یه پازل

قدم بعدی، طراحی پایه و نحوه‌ی بازی کردن ِه. باید به طریقی لغت رو از بازیکن مخفی کنیم (در عین حال باید تعداد حروف‌ش رو براشون مشخص کنیم) و یه راه هم برای گرفتن حرفی که حدس زدن داشته باشیم. اگه اون حرف جزئی از لغت بود، بذاریمش توی لغت، اگر هم نبود، باید بره تو یه لیست "حدس زده‌ها." پایان بازی رو هم باید تعیین کنیم.

با یه نوع‌داده برای پازل‌مون شروع می‌کنیم. ‏‎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