MIT 6.0001 PS2:用 Python 实现猜单词“Hangman”游戏

请注意,本文编写于 215 天前,最后修改于 148 天前,其中某些信息可能已经过时。

本文是我对 MIT 6.0001 课程(Introduction to Computer Science and Programming in Python)配套作业 Problem Set 2 的解答。仅供参考。

问题描述

使用 Python 写一个猜单词游戏(Hangman),基本规则是,开始时计算机随机选择一个英文单词,用户拥有一定数量的机会,如 6 次机会。每轮游戏用户要猜测一个字母,如果该字母在计算机选定的单词中,则计算机写出该单词中所有出现此字母的位置;否则用户的剩余机会递减。如果用户猜出单词,则用户胜利,显示得分;如果机会用完,则用户失败,显示正确的单词。

详细的要求见原题,从 MIT OCW 下载原题:Problem Set 2 (ZIP) (This file contains: 1 .txt file, 1 .py file and 1 .pdf file.)

求解思路

Problem 1 - Basic Hangman

将压缩包内提供的hangman.pywords.txt放置在同一目录下,不要修改任何代码,直接运行hangman.py,得到如下结果,符合预期。

# python hangman.py
Loading word list from file...
   55900 words loaded.

打开hangman.py阅读,可以看到已经提供了load_wordschoose_word这两个函数,它们的功能分别是从文件中读取单词到列表中、从列表中随机地选择一个单词。

Problem 2 - Hangman Part 1: Three helper functions

首先编写is_word_guessed函数。该函数的功能是判断用户已经猜过的字母是否能够猜出完整的单词,它接受两个参数,一个字符串secret_word表示待猜测的单词,一个字符串构成的列表letters_guessed表示用户已经猜过的字母。返回布尔值,猜出单词返回 True,否则返回 False。

实现该函数的思路是,只需遍历secret_word中的每个字母,判断它们是否都在letters_guessed中,如果全都在,则返回 True;只要有一个字母不在之中,则返回 False。定义如下:

def is_word_guessed(secret_word, letters_guessed):
    for letter in secret_word:
        if letter not in letters_guessed:
            return False  # 只要有任一字母未被猜中,说明还未猜出整个单词

    return True  # 所有字母都被猜中,代表整个单词已被猜出

然后编写get_guessed_word函数。该函数的功能是返回一个由已猜出的字母和下划线空格构成的字符串。它接受两个参数,一个字符串secret_word表示带猜测的单词,一个字符串构成的列表letters_guessed表示用户已经猜过的字母。返回字符串,已经猜出的字母直接用该字母表示,未猜出的字母用'_ '(下划线加空格)表示。注意下划线后跟一个空格,是为了避免多个下划线连续出现时连在一起,难以看出究竟有多少个下划线。

该函数的思路是,遍历secret_word中的每个字母,如果该字母出现在letters_guessed中,则向结果尾部添加该字母;否则向结果尾部添加'_ '。定义如下:

def get_guessed_word(secret_word, letters_guessed):
    guessed_word = ''  # 初始化为空字符串
    for letter in secret_word:
        if letter not in letters_guessed:
            guessed_word += '_ '  # 尚未猜中的字母用'_ '表示
        else:
            guessed_word += letter  # 已被猜中的字母直接显示

    return guessed_word

接着编写get_available_letters函数。该函数的功能是获取用户可以猜测的字母(也就是尚未被猜过的字母)。它接受一个列表参数letters_guessed,返回 26 个字母减去letters_guessed的结果。

该函数首先利用string.ascii_lowercase构造含有 26 个小写字母的列表(从 a 到 z 的顺序),然后将出现在letters_guessed中的那些字母删除,最后使用字符串的join方法将列表拼接成字符串。定义如下:

def get_available_letters(letters_guessed):
    # available_letters: list
    available_letters = list(string.ascii_lowercase)  # 初始化为全部小写字母构成的列表
    for letter in letters_guessed:
        if letter in available_letters:
            available_letters.remove(letter)  # 将那些已猜中过的字母删除

    return ''.join(available_letters)  # 返回string

Problem 3 - Hangman Part 2: The Game

辅助函数都已完成,可以开始编写游戏交互过程的代码了,该过程被实现在hangman函数中。该函数只接受一个字符串参数secret_word,表示待猜测的单词,在函数中需要使用input等函数和用户进行交互。

首先初始化三个变量,分别是用户剩余猜测次数、剩余警告次数和一猜过的字母列表,如下:

guesses_remaining = 6  # 剩余猜测次数
warnings_remaining = 3  # 剩余警告次数
letters_guessed = []  # list,已猜过的字母

交互过程的主体是一个 while 循环,只要剩余猜测次数不为 0,并且用户尚未猜出单词(is_word_guessed的返回值为 False),就一直执行循环。循环中便是与用户交互的内容。在这个 while 循环内,要依次完成以下步骤:

  1. 提示用户剩余猜测次数,以及打印尚未猜过的字母:

    print('You have', guesses_remaining,
          'guesses' if guesses_remaining > 1 else 'guess', 'left.')
    print('Available letters:', get_available_letters(letters_guessed))
  1. 提示并接收用户输入。由于 words.txt 中的待猜测单词全部由小写字母组成,因此需要将用户输入中的大写字母转换为小写字母。如下:

    current_guess = input('Pleaes guess a letter: ').lower()

如果用户什么都没输入就按下了回车,或者输入了超过 1 个字符,那么这些输入被认为是不合法的,应该打印一条错误信息,并立即要求用户重新输入:

if len(current_guess) != 1:
    print('You must input exactly one letter! Try again.')
    print('----------------')
    continue
  1. 检查用户输入的字符是否符合要求(按题目所给的 User Input Requirements 处理用户输入)。按照以下情况分类讨论并处理:

    • 输入的不是字母(如数字或特殊字符等),首先要提示用户输入的不是字母。如果用户剩余警告次数不为 0,那么递减剩余警告次数,并显示警告信息;如果剩余警告次数为 0,那么直接递减剩余猜测次数,也就是用户的猜测机会减少了 1 个。

      if not current_guess.isalpha():
          print('Opps! That is not a valid letter.', end=' ')
          if warnings_remaining > 0:  # 剩余警告次数不为0
              warnings_remaining -= 1
              print(
                  'You have',
                  warnings_remaining,
                  'warnings' if warnings_remaining > 1 else 'warning',
                  'left:',
                  end=' ')
          else:  # 剩余警告次数为0
              guesses_remaining -= 1
              print(
                  'You have no warnings left so you lose one guess:',
                  end=' ')
          print(get_guessed_word(secret_word, letters_guessed))
    • 输入了一个曾经猜过的字母。与上一情况的处理类似,只是显示的提示信息有所不同。代码如下:

      elif current_guess in letters_guessed:
          print('Oops! You have already guessed that letter.', end=' ')
          if warnings_remaining > 0:  # 剩余警告次数不为0
              warnings_remaining -= 1
              letters_guessed.append(current_guess)
              print(
                  'You have',
                  warnings_remaining,
                  'warnings' if warnings_remaining > 1 else 'warning',
                  'left:',
                  end=' ')
          else:  # 剩余警告次数为0
              guesses_remaining -= 1
              print(
                  'You have no warnings left so you lose one guess:',
                  end=' ')
          print(get_guessed_word(secret_word, letters_guessed))
    • 输入了一个新猜的字母,也即“合法输入”。如果该字母在待猜测单词中,那么用户将不会损失机会——剩余猜测次数维持不变。否则,若输入了辅音字母,则剩余猜测次数减少 1;若输入了元音字母(a、e、i、o、u),则剩余猜测次数减少 2。

      else:
          letters_guessed.append(current_guess)  # 添加到已经猜过的字母列表
          if current_guess in secret_word:
              print('Good guess:',
                      get_guessed_word(secret_word, letters_guessed))
          else:
              print('Oops! That letter is not in my word:',
                      get_guessed_word(secret_word, letters_guessed))
              if isvowel(current_guess):
                  guesses_remaining -= 2  # 元音字母,剩余猜测次数减2
              else:
                  guesses_remaining -= 1  # 辅音字母,剩余猜测次数减1
  1. 为了提高用户体验,我为每次循环尾部添加了一个 0.5 秒的延迟,这样用户可以先看到本次猜测的结果,再看到下次猜测前的提示信息,这样子就不至于一下显示很多行信息导致屏幕滚动,使得用户难以看清。

    time.sleep(0.5)  # 提高交互体验的延迟

注意,要使用sleep函数,需要在文件头部引入time模块。

当剩余猜测次数递减到小于等于 0(小于 0 的情况是存在的,因为若当剩余 1 次时用户猜错的是元音字母的话,次数将减为 -1),或用户正确猜出了整个单词,就应当退出 while 循环并显示游戏结果。如果用户猜出了单词,那么打印祝贺信息,并显示最终得分;若用完了机会还没有猜出,则打印失败信息,并告知用户正确的单词是什么。

得分的计算公式是:剩余猜测次数 * 待猜测单词中不重复的字母个数。在 Python 中统计字符串中不重复的字符个数的方法有多种,我采用的方法是利用 set 类没有重复元素的特性——将字符串转换为 set 后获取其长度即可。

if is_word_guessed(secret_word, letters_guessed):
    total_score = guesses_remaining * len(set(secret_word))  # 使用set来统计不重复字母的个数
    print('Congratulations, you won!')
    print('Your total score for this game is:', total_score)
else:
    print('Sorry, you ran out of guesses. The word was', '`' + secret_word + '`.')

Problem 4 - Hangman Part 3: The Game with Hints

首先编写match_with_gaps函数。该函数的功能是将一个部分猜出的单词和一个完整单词相比较,如果匹配则返回 True,否则返回 False。完成该函数的思路是将部分猜出的单词中的每个已猜出的字母和完整单词中对应位置的字母相比较,若全部相等,则接着检查每个未猜出的字母是否和已猜出的字母重复,如果没有重复,那么就说明两参数匹配,返回 True;其他情况全部返回 False。

基于以上思路,编写如下代码:

def match_with_gaps(my_word, other_word):
    my_word = my_word.replace(' ', '')  # 去除空格
    if len(my_word) != len(other_word):
        return False  # 长度不同,不匹配
    else:
        for i in range(len(my_word)):
            if my_word[i].isalpha() and my_word[i] != other_word[i]:
                return False  # 是已猜出的字母但不相同,不匹配
            if my_word[i] == '_' and other_word[i] in my_word:
                return False  # 是未猜出的下划线,但待匹配单词中有已猜出的字母,不匹配
        return True  # 其他情况都是匹配

然后编写show_possible_matches函数。该函数的功能是,根据部分猜出的单词获取与之匹配的全部单词。实现该函数的思路是,遍历 words.txt 中的全部单词,逐一地和部分猜出的单词比较(使用match_with_gaps比较),如果匹配则打印它,否则跳过。当然,如果一个匹配都不存在,那么直接显示“No matches found”。

基于以上思路,编写如下代码:

def show_possible_matches(my_word):
    # 遍历wordlist,找出所有与当前猜测所匹配的单词
    possible_maches = [
        word for word in wordlist if match_with_gaps(my_word, word)
    ]
    if len(possible_maches):
        for match in possible_maches:
            print(match, end=' ')
    else:
        print('No matches found')
    print()  # 换行

借助 Problem 3 中的hangman函数,加上上面编写的这两个新的函数,可以写出hangman_with_hints函数。该函数和hangman函数非常相似,只是新增了当用户输入的是星号(*)时,显示提示。新增代码如下:

if current_guess == '*':
    print('Possible word matches are:')
    show_possible_matches(get_guessed_word(secret_word, letters_guessed))
    continue

运行测试

测试一:猜出来了

这之中使用了提示功能(输入星号)。单词为“expand”,有 6 个不同的字母,猜出来时仍然剩余 3 次猜测机会,因此最终得分为 6 * 3 = 18。

Loading word list from file...
   55900 words loaded.
Welcome to the game Hangman!
I am thinking of a word that is 6 letters long.
You have 3 warnings left.
----------------
You have 6 guesses left.
Available letters: abcdefghijklmnopqrstuvwxyz
Pleaes guess a letter: e
Good guess: e_ _ _ _ _
----------------
You have 6 guesses left.
Available letters: abcdfghijklmnopqrstuvwxyz
Pleaes guess a letter: a
Good guess: e_ _ a_ _
----------------
You have 6 guesses left.
Available letters: bcdfghijklmnopqrstuvwxyz
Pleaes guess a letter: t
Oops! That letter is not in my word: e_ _ a_ _
----------------
You have 5 guesses left.
Available letters: bcdfghijklmnopqrsuvwxyz
Pleaes guess a letter: d
Good guess: e_ _ a_ d
----------------
You have 5 guesses left.
Available letters: bcfghijklmnopqrsuvwxyz
Pleaes guess a letter: s
Oops! That letter is not in my word: e_ _ a_ d
----------------
You have 4 guesses left.
Available letters: bcfghijklmnopqruvwxyz
Pleaes guess a letter: s
Oops! You have already guessed that letter. You have 2 warnings left: e_ _ a_ d
----------------
You have 4 guesses left.
Available letters: bcfghijklmnopqruvwxyz
Pleaes guess a letter: *
Possible word matches are:
errand expand
----------------
You have 4 guesses left.
Available letters: bcfghijklmnopqruvwxyz
Pleaes guess a letter: r
Oops! That letter is not in my word: e_ _ a_ d
----------------
You have 3 guesses left.
Available letters: bcfghijklmnopquvwxyz
Pleaes guess a letter: x
Good guess: ex_ a_ d
----------------
You have 3 guesses left.
Available letters: bcfghijklmnopquvwyz
Pleaes guess a letter: n
Good guess: ex_ and
----------------
You have 3 guesses left.
Available letters: bcfghijklmopquvwyz
Pleaes guess a letter: p
Good guess: expand
----------------
Congratulations, you won!
Your total score for this game is: 18

测试二:没猜出来

Loading word list from file...
   55900 words loaded.
Welcome to the game Hangman!
I am thinking of a word that is 5 letters long.
You have 3 warnings left.
----------------
You have 6 guesses left.
Available letters: abcdefghijklmnopqrstuvwxyz
Pleaes guess a letter: p
Oops! That letter is not in my word: _ _ _ _ _
----------------
You have 5 guesses left.
Available letters: abcdefghijklmnoqrstuvwxyz
Pleaes guess a letter: q
Oops! That letter is not in my word: _ _ _ _ _
----------------
You have 4 guesses left.
Available letters: abcdefghijklmnorstuvwxyz
Pleaes guess a letter: t
Oops! That letter is not in my word: _ _ _ _ _
----------------
You have 3 guesses left.
Available letters: abcdefghijklmnorsuvwxyz
Pleaes guess a letter: s
Oops! That letter is not in my word: _ _ _ _ _
----------------
You have 2 guesses left.
Available letters: abcdefghijklmnoruvwxyz
Pleaes guess a letter: e
Good guess: _ _ _ e_
----------------
You have 2 guesses left.
Available letters: abcdfghijklmnoruvwxyz
Pleaes guess a letter: r
Good guess: _ r_ e_
----------------
You have 2 guesses left.
Available letters: abcdfghijklmnouvwxyz
Pleaes guess a letter: v
Oops! That letter is not in my word: _ r_ e_
----------------
You have 1 guess left.
Available letters: abcdfghijklmnouwxyz
Pleaes guess a letter: m
Oops! That letter is not in my word: _ r_ e_
----------------
Sorry, you ran out of guesses. The word was `orbed`.

写在后面

完成实验本身的要求并不难,因为 MIT6_0001F16_Pset2.pdf 文档中已经给出了充足的提示。在以上要求之外,我还做了一些额外的考虑以增加程序的严谨性和鲁棒性;还使用了一些技巧来使得程序更加简洁;还有一些其他值得注意的方面,现总结如下。

  • 考虑了用户输入不止一个字符的情况。在hangmanhangman_with_hints函数中的 while 循环里,在用户输入后首先判断输入的字符数量,如果不是 1,那么提示用户必须输入 1 个字符,然后立即用continue结束本次循环,并且不消耗警告次数或者猜测次数。
  • 考虑提示语中的名词单复数问题。程序和用户的交互过程中会打印剩余警告次数和剩余猜测次数,当次数大于 1 时,显示名词的复数形式;当次数为 1 时,显示单数形式。为了解决此问题,我使用了 Python 中类似“三目运算符”的语句,举例如下:

    print('You have', guesses_remaining, 'guesses' if guesses_remaining > 1 else 'guess', 'left.')

其中'guesses' if guesses_remaining > 1 else 'guess'是关键部分,它相当于 C/C++ 和其它语言中的三目条件运算符:guesses_remaining > 1 ? "guesses" : "guess"

  • 统计字符串中不重复的字符个数。由于我们只是需要不重复的字符个数,而不需要保持任何顺序,因此使用 set 可以大大简化操作。将 string 转换为 set,就能自动地使每个存在的字符仅仅保留一份,然后再使用len()获取该 set 的大小即可方便地统计出字符串中不重复的字符个数了。
  • 选择性地使用解析列表。本程序中存在许多关于列表的循环,它们可以使用列表解析来代替。如show_possible_matches函数中用到了possible_maches = [word for word in wordlist if match_with_gaps(my_word, word)]来选取wordlist中符合条件的元素组成一个新的列表。但有些其他地方并没有使用列表解析,而是采用了传统的循环方式,这是因为涉及到的变量名较长,且逻辑较为复杂,如果使用列表解析则会造成单行代码过长,反而使代码看起来更臃肿,违背了使用列表解析的初衷。
  • 比较严格地遵守了 PEP 8 编码规范。如缩进均使用 4 个空格(而不是制表符 Tab)、导入模块使用多行 import、表达式和语句中按规范使用空格等。我使用了 Autopep8 来自动格式化代码。之所以用了“比较严格”这种保守的词汇,是因为在我的代码中,有少数几行地长度超过了 79 个字符,这是不符合 PEP 8 规范的。之所以要违反规范,是因为若截断这些行,将严重降低代码的可读性。

完整代码

本地下载:mit_6.0001_hangman.zip


Comments

添加新评论

已有 4 条评论

你好,问个与文章无关的问题。
在你的这个文章中,“运行测试测试一:猜出来了”下的代码块是显示正常的,而在我的网站例如“https://www.ccode.fun/c/wyshszejz.html”中代码只显示了六十行,下面的好像被遮住了,请问你是自己解决的还是没有改动过。(我的出现问题的浏览器为安卓系统的chrome浏览器、微信内置浏览器、qq内置浏览器,电脑端均显示正常。)如果你有空,麻烦回复一下。

Jed Jed 回复 @半叶子

不好意思我没有改动过哦,你可以问问主题作者

能告诉我下你现在用的什么版本的VOID吗

Jed Jed 回复 @半叶子

大更新前的最后一个版本。从首页样式可以看出来我不是新版的主题