在大流行期间,Wordle 在 Twitter 上还算比较流行的一款基于网络的益智游戏,要求玩家每天在六次或更短时间内猜出一个新的五个字母的单词,每个人得到的单词都是一样的。
在本教程中,你将在终端上创建自己的 Wordle 克隆。自 2021 年 10 月 Josh Wardle 推出 Wordle 以来,已有数百万人玩过这款游戏。虽然您可以在网络上玩原版游戏,但您将以命令行应用程序的形式创建自己的版本,然后使用 Rich
库使其看起来更漂亮。
书接上回
第 5 步:添加验证和用户反馈
在上一步中,您添加了 Rich 并重写了您的游戏,使用颜色来更好地展示游戏。接下来,您将在此基础上,在用户做错事情时显示一些警告:
请注意,如果您的猜测不是五个字母,或者您重复了之前的猜测,就会收到警告。
在这一步中,您将添加一些功能,以便在用户做出意外举动时为他们提供指导,从而使您的游戏更加人性化。
确保单词表不是空的
理论上,您可以使用任何文本文件作为单词表。如果单词表不包含任何五个字母的单词,那么 get_random_word() 将会失败。但您的用户会看到哪条消息呢?
打开您的 REPL,尝试从一个空单词表中获取一个随机单词:
>>> import wyrdl
>>> wyrdl.get_random_word([])
Traceback (most recent call last):...
IndexError: Cannot choose from an empty sequence
您看到的是回溯和 IndexError。如果没有其他上下文,用户可能不会意识到问题出在单词表上。
单词列表中没有任何有效单词是很难恢复的,但你至少可以提供一个更明确和可操作的错误信息。更新 get_random_word(),检查有效单词列表是否为空:
# wyrdl.py# ...def get_random_word(word_list):if words := [word.upper()for word in word_listif len(word) == 5 and all(letter in ascii_letters for letter in word)]:return random.choice(words)else:console.print("No words of length 5 in the word list", style="warning")raise SystemExit()# ...
您可以使用 walrus 运算符 (:=) 创建有效单词列表,并检查其中是否至少包含一个单词。使用 walrus 运算符时,您要编写一个赋值表达式,作为表达式的一部分进行赋值。
在这种情况下,您会像以前一样将单词列表赋值给单词。但是,现在您会立即在 if 测试中使用该列表来检查它是否为空。如果列表为空,则在 else 子句中打印警告,明确说明问题所在:
>>> import wyrdl
>>> wyrdl.get_random_word(["one", "four", "eleven"])
No words of length 5 in the word list
这样,用户就不会看到回溯。取而代之的是提供可操作的反馈,用户可以利用这些反馈来解决问题。
注意,你在调用 console.print() 时添加了 style=“warning”。这使用的是您之前在自定义主题中初始化控制台时定义的警告样式。
由于你的游戏需要一个密语,你将通过引发 SystemExit 来结束程序。接下来,您将考虑可以恢复的问题。例如,用户猜到的单词不是五个字母。不过首先要考虑哪些单词可以作为有效猜测。
考虑接受哪些词语
原始 Wordle 游戏的挑战之一是,您的猜测必须是字典中的实际单词。目前,您还没有在您的 Wordle 克隆中实施同样的限制。任何字母组合都是有效的猜测。
您可以要求猜测的单词也在您现有的单词列表中。但是,如果你使用的单词列表有限,这可能会让用户感到沮丧,因为他们最终需要先弄清楚单词列表中到底有哪些单词。
更好的办法可能是在检查猜测是否有效时使用第二个全面的单词表。重要的是,任何合理的单词都应被视为有效。在没有广泛字典的情况下,允许五个字母的任意组合可能是更好的用户体验。
在本教程中,您将不会处理为验证猜测而添加第二个单词表的问题。不过,你可以尝试一下。这是一个很好的尝试练习!
验证用户猜测的单词
虽然您不会根据单词表检查用户的猜测,但您应该进行一些验证,并在用户做错事情时提醒他们。在本节中,您将改进在用户猜词时提供的用户反馈。
目前,您在以下代码行中处理用户输入:
guesses[idx] = input("\nGuess word: ").upper()
要改进对猜测的处理,首先要将其重构为一个单独的函数。首先,在文件中添加 guess_word():
# wyrdl.py# ...def guess_word(previous_guesses):guess = console.input("\nGuess word: ").upper()return guess# ...
Rich Console 包含一个 .input() 方法,该方法与输入()函数类似,但允许您为输入提示添加丰富的格式。虽然我们没有利用这一功能,但为了保持一致性,在这里使用 console 也是不错的。
您还将 previous_guesses 作为一个参数,因为您很快就会用它来检查用户是否重复猜测。不过在执行任何检查之前,请更新 main() 以调用新函数:
# wyrdl.py# ...def main():# Pre-processwords_path = pathlib.Path(__file__).parent / "wordlist.txt"word = get_random_word(words_path.read_text(encoding="utf-8").split("\n"))guesses = ["_" * 5] * 6# Process (main loop)for idx in range(6):refresh_page(headline=f"Guess {idx + 1}")show_guesses(guesses, word)guesses[idx] = guess_word(previous_guesses=guesses[:idx])if guesses[idx] == word:break# Post-processgame_over(guesses, word, guessed_correctly=guesses[idx] == word)# ...
您可以创建一个先前猜测的列表,只包含猜测中已经填入的元素。然后将此列表传递给 guess_word()。
现在,使用 previous_guesses 检查用户是否两次做出相同的猜测。如果出现这种情况,就会向用户发出警告,并让他们再次猜测。您可以通过下面的 if 测试来实现:
# wyrdl.py# ...def guess_word(previous_guesses):guess = console.input("\nGuess word: ").upper()if guess in previous_guesses:console.print(f"You've already guessed {guess}.", style="warning")return guess_word(previous_guesses)return guess# ...
使用之前定义的警告样式,您将向用户打印一条信息,告知他们已经猜出了单词。为了让用户进行新的猜测,您将再次调用 guess_word(),并返回该猜测。
注意:正如您在本教程前面学到的,递归调用通常不是在 Python 中创建循环的最佳方式。然而,在本例中,它却非常优雅。典型的缺点并不重要。例如,与用户输入猜测的时间相比,调用函数所花费的时间可以忽略不计。
既然所有的单词都是五个字母,那么您也应该检查所有的猜测是否都是五个字母。为此,您可以添加第二个条件:
# wyrdl.py# ...def guess_word(previous_guesses):guess = console.input("\nGuess word: ").upper()if guess in previous_guesses:console.print(f"You've already guessed {guess}.", style="warning")return guess_word(previous_guesses)if len(guess) != 5:console.print("Your guess must be 5 letters.", style="warning")return guess_word(previous_guesses)return guess# ...
本测试的结构与上一测试相同。您要检查猜测中是否有五个字母。如果没有,则打印警告并让用户进行第二次猜测。
最后,您可以引导用户只使用英文字母。在这种情况下,if
测试要复杂一些,因为您需要检查用户猜测中的每个字母:
# wyrdl.py# ...def guess_word(previous_guesses):guess = console.input("\nGuess word: ").upper()if guess in previous_guesses:console.print(f"You've already guessed {guess}.", style="warning")return guess_word(previous_guesses)if len(guess) != 5:console.print("Your guess must be 5 letters.", style="warning")return guess_word(previous_guesses)if any((invalid := letter) not in ascii_letters for letter in guess):console.print(f"Invalid letter: '{invalid}'. Please use English letters.",style="warning",)return guess_word(previous_guesses)return guess# ...
any()表达式检查猜测中是否有字母不在 ascii_letters 中,ascii_letters 是一个内置的小写和大写字母列表,从 A 到 Z。
注意:如果您添加了自己的单词列表,其中包含使用不同字母的单词,则需要更新此检查以允许用户使用所有字母。
您可以在 any() 中使用 walrus 运算符来收集无效字符的示例。如果用户的猜测中出现了无效字母,那么就像往常一样用 console.print() 报告,并给用户一次新的尝试。
注意:在 any() 中使用 := 功能强大,但其作用可能并不明显。您可以关于此结构的内容,了解详情。
运行您的游戏,并尝试通过不同类型的用户错误来刺激您的代码。当您猜测四个字母的单词或在猜测中包含数字时,您会得到有用的反馈吗?
虽然游戏的核心内容和以前一样,但您的程序现在更加稳固,并会在用户出错时为他们提供指导。如前所述,您可以通过扩展以下部分查看完整的源代码:
# wyrdl.pyimport pathlib
import random
from string import ascii_lettersfrom rich.console import Console
from rich.theme import Themeconsole = Console(width=40, theme=Theme({"warning": "red on yellow"}))def main():# Pre-processwords_path = pathlib.Path(__file__).parent / "wordlist.txt"word = get_random_word(words_path.read_text(encoding="utf-8").split("\n"))guesses = ["_" * 5] * 6# Process (main loop)for idx in range(6):refresh_page(headline=f"Guess {idx + 1}")show_guesses(guesses, word)guesses[idx] = guess_word(previous_guesses=guesses[:idx])if guesses[idx] == word:break# Post-processgame_over(guesses, word, guessed_correctly=guesses[idx] == word)def refresh_page(headline):console.clear()console.rule(f"[bold blue]:leafy_green: {headline} :leafy_green:[/]\n")def get_random_word(word_list):if words := [word.upper()for word in word_listif len(word) == 5 and all(letter in ascii_letters for letter in word)]:return random.choice(words)else:console.print("No words of length 5 in the word list", style="warning")raise SystemExit()def show_guesses(guesses, word):for guess in guesses:styled_guess = []for letter, correct in zip(guess, word):if letter == correct:style = "bold white on green"elif letter in word:style = "bold white on yellow"elif letter in ascii_letters:style = "white on #666666"else:style = "dim"styled_guess.append(f"[{style}]{letter}[/]")console.print("".join(styled_guess), justify="center")def guess_word(previous_guesses):guess = console.input("\nGuess word: ").upper()if guess in previous_guesses:console.print(f"You've already guessed {guess}.", style="warning")return guess_word(previous_guesses)if len(guess) != 5:console.print("Your guess must be 5 letters.", style="warning")return guess_word(previous_guesses)if any((invalid := letter) not in ascii_letters for letter in guess):console.print(f"Invalid letter: '{invalid}'. Please use English letters.",style="warning",)return guess_word(previous_guesses)return guessdef game_over(guesses, word, guessed_correctly):refresh_page(headline="Game Over")show_guesses(guesses, word)if guessed_correctly:console.print(f"\n[bold white on green]Correct, the word is {word}[/]")else:console.print(f"\n[bold white on red]Sorry, the word was {word}[/]")if __name__ == "__main__":main()
您已经很好地实现了 Wordle 克隆。在结束本教程之前,您将在这里和那里调整您的代码,磨平一些尖锐的边缘。
第 6 步:清理游戏和代码
在第 5 步中,您通过添加一些信息来改善用户体验,这些信息可以在用户做错任何事情时提供帮助。在最后一步中,您将添加一个可以帮助用户的功能,即所有字母及其状态的列表:
猜测表下方的字母列表显示了每个字母的当前状态。与往常一样,绿色的字母是正确的,黄色的字母是错误的,灰色的字母是错误的。
好了,最后的调整时间到了。
使用常量为概念命名
魔法值通常会降低代码的可读性。魔力值是一个值,通常是一个数字,在程序中出现时没有任何上下文。例如,请看下面这行代码:
guesses = ["_" * 5] * 6
这里的 "5 "和 "6 "是什么意思?由于您目前正沉浸在游戏中,您可能会立即指出 5 表示单词中的字母数,而 6 指的是允许猜测的次数。不过,如果你几天不碰代码,这一点可能就不再那么明显了。
魔法值的另一个问题是难以更改。假如你想改变一下游戏规则,改猜七个字母的单词。这既麻烦又容易出错。
一个好的做法是用正确命名的常量来替换神奇值。例如,可以定义 NUM_LETTERS = 5,然后用 NUM_LETTERS 替换所有 5 的出现。
注意:Python 对常量没有任何特殊支持。从技术上讲,常量只是一个不改变其值的变量。然而,约定俗成的做法是使用大写字母来表示常量变量的名称。
在代码文件顶部添加几个描述性常量:
# wyrdl.pyimport pathlib
import random
from string import ascii_lettersfrom rich.console import Console
from rich.theme import Themeconsole = Console(width=40, theme=Theme({"warning": "red on yellow"}))NUM_LETTERS = 5
NUM_GUESSES = 6
WORDS_PATH = pathlib.Path(__file__).parent / "wordlist.txt"# ...
有了这些常量,你就可以开始用这些常量替换你的神奇值了。例如,你现在可以将猜测的初始化写成这样:
guesses = ["_" * NUM_LETTERS] * NUM_GUESSES
常量可以帮助您理解代码的作用。继续在代码中添加常量。您可以展开以下部分,查看您可以做出的所有更改:
# wyrdl.pyimport pathlib
import random
from string import ascii_lettersfrom rich.console import Console
from rich.theme import Themeconsole = Console(width=40, theme=Theme({"warning": "red on yellow"}))NUM_LETTERS = 5
NUM_GUESSES = 6
WORDS_PATH = pathlib.Path(__file__).parent / "wordlist.txt"def main():# Pre-processword = get_random_word(WORDS_PATH.read_text(encoding="utf-8").split("\n"))guesses = ["_" * NUM_LETTERS] * NUM_GUESSES# Process (main loop)for idx in range(NUM_GUESSES):refresh_page(headline=f"Guess {idx + 1}")show_guesses(guesses, word)guesses[idx] = guess_word(previous_guesses=guesses[:idx])if guesses[idx] == word:break# Post-processgame_over(guesses, word, guessed_correctly=guesses[idx] == word)def refresh_page(headline):console.clear()console.rule(f"[bold blue]:leafy_green: {headline} :leafy_green:[/]\n")def get_random_word(word_list):if words := [word.upper()for word in word_listif len(word) == NUM_LETTERSand all(letter in ascii_letters for letter in word)]:return random.choice(words)else:console.print(f"No words of length {NUM_LETTERS} in the word list",style="warning",)raise SystemExit()def show_guesses(guesses, word):for guess in guesses:styled_guess = []for letter, correct in zip(guess, word):if letter == correct:style = "bold white on green"elif letter in word:style = "bold white on yellow"elif letter in ascii_letters:style = "white on #666666"else:style = "dim"styled_guess.append(f"[{style}]{letter}[/]")console.print("".join(styled_guess), justify="center")def guess_word(previous_guesses):guess = console.input("\nGuess word: ").upper()if guess in previous_guesses:console.print(f"You've already guessed {guess}.", style="warning")return guess_word(previous_guesses)if len(guess) != NUM_LETTERS:console.print(f"Your guess must be {NUM_LETTERS} letters.", style="warning")return guess_word(previous_guesses)if any((invalid := letter) not in ascii_letters for letter in guess):console.print(f"Invalid letter: '{invalid}'. Please use English letters.",style="warning",)return guess_word(previous_guesses)return guessdef game_over(guesses, word, guessed_correctly):refresh_page(headline="Game Over")show_guesses(guesses, word)if guessed_correctly:console.print(f"\n[bold white on green]Correct, the word is {word}[/]")else:console.print(f"\n[bold white on red]Sorry, the word was {word}[/]")if __name__ == "__main__":main()
检查是否替换了所有 5 的方法之一是更改 NUM_LETTERS 的值。如果你猜了 8 次才猜出一个 6 个字母的单词,你的程序还能运行吗?如果没有,那就是漏掉了一个字母。
添加已用字母概览
Rich 提供的颜色为用户提供了很好的线索,让他们知道自己猜对了哪些字母。但是,要一眼看出用户已经猜出了哪些字母并不容易。为了帮助用户,您可以添加一行,显示字母表中每个字母的状态:
您已经在 show_guesses() 中获得了必要的信息,因此您将扩展该函数以显示单个字母的状态:
# wyrdl.pyimport pathlib
import random
from string import ascii_letters, ascii_uppercase# ...def show_guesses(guesses, word):letter_status = {letter: letter for letter in ascii_uppercase}for guess in guesses:styled_guess = []for letter, correct in zip(guess, word):if letter == correct:style = "bold white on green"elif letter in word:style = "bold white on yellow"elif letter in ascii_letters:style = "white on #666666"else:style = "dim"styled_guess.append(f"[{style}]{letter}[/]")if letter != "_":letter_status[letter] = f"[{style}]{letter}[/]"console.print("".join(styled_guess), justify="center")console.print("\n" + "".join(letter_status.values()), justify="center")# ...
您可以使用 dictionary letter_status 来跟踪每个字母的状态。首先,用所有大写字母初始化字典。然后,在处理每个猜测的每个字母时,用正确样式的字母更新 letter_status。处理完毕后,将所有字母连接起来,并以各自的样式打印出来。
将这些信息呈现在用户面前,会让游戏玩起来更轻松愉快。
干净利落地退出游戏
早些时候,您确保用户不会在单词列表为空的情况下看到难以理解的回溯。随着游戏的不断改进,用户接触到 Python 错误信息的可能性越来越小。
但仍然存在的一种可能性是,他们可以按 Ctrl+C 来提前结束游戏。您不想让他们失去退出游戏的能力。不过,在这种情况下,您可以让游戏干净利落地退出。
当用户键入 Ctrl+C 时,Python 会引发一个 KeyboardInterupt 异常。这是一个异常,您可以用 try … except 块捕获它。但在这种情况下,您不需要对异常进行任何特殊处理。因此,您可以使用 contextlib.suppress()。
通过在主循环外添加上下文管理器,可以确保 Ctrl+C 跳出主循环,运行后处理代码:
# wyrdl.pyimport contextlib
import pathlib
import random
from string import ascii_letters, ascii_uppercase# ...def main():# Pre-processword = get_random_word(WORDS_PATH.read_text(encoding="utf-8").split("\n"))guesses = ["_" * NUM_LETTERS] * NUM_GUESSES# Process (main loop)with contextlib.suppress(KeyboardInterrupt):for idx in range(NUM_GUESSES):refresh_page(headline=f"Guess {idx + 1}")show_guesses(guesses, word)guesses[idx] = guess_word(previous_guesses=guesses[:idx])if guesses[idx] == word:break# Post-processgame_over(guesses, word, guessed_correctly=guesses[idx] == word)# ...
请注意,你要在 suppress() 上下文管理器内缩进整个主循环。如果在循环内发生 KeyboardInterrupt(键盘中断),控制权将立即传出循环,并调用 game_over()。
这样做的效果是,游戏将在向用户显示密语后结束。
这就是本教程的最后一项调整。如果你想查看完整的源代码,请查看下面:
# wyrdl.pyimport contextlib
import pathlib
import random
from string import ascii_letters, ascii_uppercasefrom rich.console import Console
from rich.theme import Themeconsole = Console(width=40, theme=Theme({"warning": "red on yellow"}))NUM_LETTERS = 5
NUM_GUESSES = 6
WORDS_PATH = pathlib.Path(__file__).parent / "wordlist.txt"def main():# Pre-processword = get_random_word(WORDS_PATH.read_text(encoding="utf-8").split("\n"))guesses = ["_" * NUM_LETTERS] * NUM_GUESSES# Process (main loop)with contextlib.suppress(KeyboardInterrupt):for idx in range(NUM_GUESSES):refresh_page(headline=f"Guess {idx + 1}")show_guesses(guesses, word)guesses[idx] = guess_word(previous_guesses=guesses[:idx])if guesses[idx] == word:break# Post-processgame_over(guesses, word, guessed_correctly=guesses[idx] == word)def refresh_page(headline):console.clear()console.rule(f"[bold blue]:leafy_green: {headline} :leafy_green:[/]\n")def get_random_word(word_list):if words := [word.upper()for word in word_listif len(word) == NUM_LETTERSand all(letter in ascii_letters for letter in word)]:return random.choice(words)else:console.print(f"No words of length {NUM_LETTERS} in the word list",style="warning",)raise SystemExit()def show_guesses(guesses, word):letter_status = {letter: letter for letter in ascii_uppercase}for guess in guesses:styled_guess = []for letter, correct in zip(guess, word):if letter == correct:style = "bold white on green"elif letter in word:style = "bold white on yellow"elif letter in ascii_letters:style = "white on #666666"else:style = "dim"styled_guess.append(f"[{style}]{letter}[/]")if letter != "_":letter_status[letter] = f"[{style}]{letter}[/]"console.print("".join(styled_guess), justify="center")console.print("\n" + "".join(letter_status.values()), justify="center")def guess_word(previous_guesses):guess = console.input("\nGuess word: ").upper()if guess in previous_guesses:console.print(f"You've already guessed {guess}.", style="warning")return guess_word(previous_guesses)if len(guess) != NUM_LETTERS:console.print(f"Your guess must be {NUM_LETTERS} letters.", style="warning")return guess_word(previous_guesses)if any((invalid := letter) not in ascii_letters for letter in guess):console.print(f"Invalid letter: '{invalid}'. Please use English letters.",style="warning",)return guess_word(previous_guesses)return guessdef game_over(guesses, word, guessed_correctly):refresh_page(headline="Game Over")show_guesses(guesses, word)if guessed_correctly:console.print(f"\n[bold white on green]Correct, the word is {word}[/]")else:console.print(f"\n[bold white on red]Sorry, the word was {word}[/]")if __name__ == "__main__":main()
你已经写了不少代码。通过一步步构建 Wordle 克隆,你看到了每个部分是如何融入整体的。像这样以迭代的方式实现代码,是保持对程序所有操作的了解的好方法。
结束语
恭喜你 您已经创建了一个功能丰富的 Wordle 克隆,您可以自己玩,也可以与您所有的朋友–至少是那些知道如何在终端运行 Python 程序的朋友–分享。
一路走来,你已经熟悉了 Rich,并学会了如何使用该库为终端应用程序添加色彩和风格。
在这个循序渐进的项目中,你将学会:
- 拥有迭代创建命令行应用程序的良好策略
- 使用 Rich 的控制台在终端创建美观的用户界面
- 读取并验证用户输入
- 处理以字符串、列表和字典表示的数据
- 处理存储在文本文件中的数据
接下来,挑战一下自己的 Wordle 克隆能力吧!您还可以寻找继续开发游戏的方法。请在下面的讨论区分享您的经验。
下一步
虽然您的 Wordle 克隆已经具备了最重要的功能,但您仍有许多方法可以更改或改进项目。您已经在教程中注意到了其中一些:
-
只允许从有效单词列表中猜词: 这将使游戏更具挑战性,因为你不能随便把一些字母扔到一起,检查它们是否出现在密语中。要实现这一点,你需要一个全面的单词列表。
-
创建主题 Wordle 克隆: 本教程中下载的单词表是基于教程本身的单词。根据您感兴趣的主题创建一个单词表可能会更有趣。也许你可以创建一个编程术语、人名或莎士比亚戏剧的列表。
-
添加闪屏: 闪屏或介绍屏幕是让用户做好准备的好方法。为了让您的应用程序更容易使用,您还可以添加一些游戏内说明–例如,解释游戏的目的和不同颜色代表的含义。
尽情探索你自己的 Wordle 变体吧。另外,请记住,在创建其他命令行应用程序时,您可以使用本教程中学到的大部分原理。那么,你下一步要做什么呢?
感谢大家花时间阅读我的文章,你们的支持是我不断前进的动力。期望未来能为大家带来更多有价值的内容,请多多关注我的动态!