十、井字棋
本章介绍了一个井字棋游戏。井字棋通常由两个人玩。一个玩家是X,另一个玩家是O。玩家轮流放置他们的X或O。如果一个玩家在一行、一列或对角线上获得了三个标记,他们就赢了。当棋盘填满时,没有玩家获胜,游戏以平局结束。
本章并没有介绍太多新的编程概念。用户将与一个简单的人工智能对战,我们将使用现有的编程知识来编写它。*人工智能(AI)*是一个可以智能地响应玩家动作的计算机程序。玩井字棋的 AI 并不复杂;它实际上只是几行代码。
让我们从程序的一个示例运行开始。玩家通过输入他们想要占据的空间的数字来进行移动。为了帮助我们记住列表中的哪个索引对应哪个空间,我们将对棋盘进行编号,就像键盘的数字键盘一样,如图 10-1 所示。
图 10-1:棋盘的编号就像键盘的数字键盘一样。
本章涉及的主题
- 人工智能
- 列表引用
- 短路评估
None
值
井字棋的示例运行
当用户运行井字棋程序时,他们看到的是这样的。玩家输入的文本是粗体。
Welcome to Tic-Tac-Toe! Do you want to be X or O? X The computer will go first. O| | -+-+- | | -+-+- | | What is your next move? (1-9) 3 O| | -+-+- | | -+-+- O| |X What is your next move? (1-9) 4 O| |O -+-+- X| | -+-+- O| |X What is your next move? (1-9) 5 O|O|O -+-+- X|X| -+-+- O| |X The computer has beaten you! You lose. Do you want to play again? (yes or no) no
井字棋的源代码
在一个新文件中,输入以下源代码并将其保存为tictactoe.py。然后按 F5 运行游戏。如果出现错误,请使用在线 diff 工具将你输入的代码与本书代码进行比较。
tictactoe.py
# Tic-Tac-Toe import random def drawBoard(board): # This function prints out the board that it was passed. # "board" is a list of 10 strings representing the board (ignore index 0). print(board[7] + '|' + board[8] + '|' + board[9]) print('-+-+-') print(board[4] + '|' + board[5] + '|' + board[6]) print('-+-+-') print(board[1] + '|' + board[2] + '|' + board[3]) def inputPlayerLetter(): # Lets the player type which letter they want to be. # Returns a list with the player's letter as the first item and the computer's letter as the second. letter = '' while not (letter == 'X' or letter == 'O'): print('Do you want to be X or O?') letter = input().upper() # The first element in the list is the player's letter; the second is the computer's letter. if letter == 'X': return ['X', 'O'] else: return ['O', 'X'] def whoGoesFirst(): # Randomly choose which player goes first. if random.randint(0, 1) == 0: return 'computer' else: return 'player' def makeMove(board, letter, move): board[move] = letter def isWinner(bo, le): # Given a board and a player's letter, this function returns True if that player has won. # We use "bo" instead of "board" and "le" instead of "letter" so we don't have to type as much. return ((bo[7] == le and bo[8] == le and bo[9] == le) or # Across the top (bo[4] == le and bo[5] == le and bo[6] == le) or # Across the middle (bo[1] == le and bo[2] == le and bo[3] == le) or # Across the bottom (bo[7] == le and bo[4] == le and bo[1] == le) or # Down the left side (bo[8] == le and bo[5] == le and bo[2] == le) or # Down the middle (bo[9] == le and bo[6] == le and bo[3] == le) or # Down the right side (bo[7] == le and bo[5] == le and bo[3] == le) or # Diagonal (bo[9] == le and bo[5] == le and bo[1] == le)) # Diagonal def getBoardCopy(board): # Make a copy of the board list and return it. boardCopy = [] for i in board: boardCopy.append(i) return boardCopy def isSpaceFree(board, move): # Return True if the passed move is free on the passed board. return board[move] == ' ' def getPlayerMove(board): # Let the player enter their move. move = ' ' while move not in '1 2 3 4 5 6 7 8 9'.split() or not isSpaceFree(board, int(move)): print('What is your next move? (1-9)') move = input() return int(move) def chooseRandomMoveFromList(board, movesList): # Returns a valid move from the passed list on the passed board. # Returns None if there is no valid move. possibleMoves = [] for i in movesList: if isSpaceFree(board, i): possibleMoves.append(i) if len(possibleMoves) != 0: return random.choice(possibleMoves) else: return None def getComputerMove(board, computerLetter): # Given a board and the computer's letter, determine where to move and return that move. if computerLetter == 'X': playerLetter = 'O' else: playerLetter = 'X' # Here is the algorithm for our Tic-Tac-Toe AI: # First, check if we can win in the next move. for i in range(1, 10): boardCopy = getBoardCopy(board) if isSpaceFree(boardCopy, i): makeMove(boardCopy, computerLetter, i) if isWinner(boardCopy, computerLetter): return i # Check if the player could win on their next move and block them. for i in range(1, 10): boardCopy = getBoardCopy(board) if isSpaceFree(boardCopy, i): makeMove(boardCopy, playerLetter, i) if isWinner(boardCopy, playerLetter): return i # Try to take one of the corners, if they are free. move = chooseRandomMoveFromList(board, [1, 3, 7, 9]) if move != None: return move # Try to take the center, if it is free. if isSpaceFree(board, 5): return 5 # Move on one of the sides. return chooseRandomMoveFromList(board, [2, 4, 6, 8]) def isBoardFull(board): # Return True if every space on the board has been taken. Otherwise, return False. for i in range(1, 10): if isSpaceFree(board, i): return False return True print('Welcome to Tic-Tac-Toe!') while True: # Reset the board. theBoard = [' '] * 10 playerLetter, computerLetter = inputPlayerLetter() turn = whoGoesFirst() print('The ' + turn + ' will go first.') gameIsPlaying = True while gameIsPlaying: if turn == 'player': # Player's turn drawBoard(theBoard) move = getPlayerMove(theBoard) makeMove(theBoard, playerLetter, move) if isWinner(theBoard, playerLetter): drawBoard(theBoard) print('Hooray! You have won the game!') gameIsPlaying = False else: if isBoardFull(theBoard): drawBoard(theBoard) print('The game is a tie!') break else: turn = 'computer' else: # Computer's turn move = getComputerMove(theBoard, computerLetter) makeMove(theBoard, computerLetter, move) if isWinner(theBoard, computerLetter): drawBoard(theBoard) print('The computer has beaten you! You lose.') gameIsPlaying = False else: if isBoardFull(theBoard): drawBoard(theBoard) print('The game is a tie!') break else: turn = 'player' print('Do you want to play again? (yes or no)') if not input().lower().startswith('y'): break
程序设计
图 10-2 显示了井字棋程序的流程图。程序首先要求玩家选择他们的字母,X或O。谁先行动是随机选择的。然后玩家和计算机轮流进行移动。
图 10-2:井字棋的流程图
流程图左侧的框显示了玩家回合时发生的事情,右侧的框显示了计算机回合时发生的事情。玩家或计算机进行移动后,程序会检查他们是否赢了或导致了平局,然后游戏会切换回合。游戏结束后,程序会询问玩家是否想再玩一次。
将棋盘表示为数据
首先,你必须想出如何将棋盘表示为变量中的数据。在纸上,井字棋棋盘被绘制为一对水平线和一对垂直线,每个九个空间中有一个X、O或空格。
在程序中,井字棋棋盘被表示为一个字符串列表,就像猜词游戏的 ASCII 艺术一样。每个字符串代表棋盘上的九个空间中的一个。这些字符串要么是'X'
代表X玩家,要么是'O'
代表O玩家,要么是一个单个空格' '
代表空白空间。
请记住,我们的棋盘布局就像键盘上的数字键盘一样。因此,如果一个包含 10 个字符串的列表存储在一个名为board
的变量中,那么board[7]
将是棋盘上的左上角空间,board[8]
将是顶部中间空间,board[9]
将是顶部右侧空间,依此类推。程序会忽略列表中索引为0
的字符串。玩家将输入 1 到 9 的数字来告诉游戏他们想要移动到哪个空间。
与游戏人工智能进行策略
AI 需要能够查看棋盘并决定它将移动到哪种类型的空间。为了清楚起见,我们将在井字棋棋盘上标记三种类型的空间:角落、侧面和中心。图 10-3 中的图表显示了每个空间是什么。
AI 玩井字棋的策略将遵循一个简单的算法——一系列指令来计算结果。单个程序可以利用几种不同的算法。算法可以用流程图表示。井字棋 AI 的算法将计算最佳移动,如图 10-4 所示。
图 10-3:侧面、角落和中心空间的位置
图 10-4:方框代表“获取计算机移动”的五个步骤。指向左边的箭头指向“检查计算机是否赢了”方框。
AI 的算法有以下步骤:
- 查看计算机是否可以赢得比赛。如果可以,就走这一步。否则,转到步骤 2。
- 查看玩家是否可以进行一步棋,导致计算机输掉比赛。如果可以,就移动到那里阻止玩家。否则,转到步骤 3。
- 检查角落空间(空间 1、3、7 或 9)是否有空闲。如果有,就移动到那里。如果没有空闲的角落空间,就转到步骤 4。
- 检查中心是否空闲。如果是,就移动到那里。如果不是,就转到步骤 5。
- 在任何一个侧面空间上移动(空间 2、4、6 或 8)。如果执行到步骤 5,就没有更多的步骤了,因为侧面空间是唯一剩下的空间。
所有这些都发生在图 10-2 中的“获取计算机移动”框中。您可以将这些信息添加到图 10-4 中的框中。
这个算法在getComputerMove()
和getComputerMove()
调用的其他函数中实现。
导入随机模块
前几行是由注释和导入random
模块的行组成,以便您可以在以后调用randint()
函数:
# Tic-Tac-Toe import random
您之前已经见过这两个概念,所以让我们继续进行程序的下一部分。
在屏幕上打印棋盘
在代码的下一部分,我们定义一个绘制棋盘的函数:
def drawBoard(board): # This function prints out the board that it was passed. # "board" is a list of 10 strings representing the board (ignore index 0). print(board[7] + '|' + board[8] + '|' + board[9]) print('-+-+-') print(board[4] + '|' + board[5] + '|' + board[6]) print('-+-+-') print(board[1] + '|' + board[2] + '|' + board[3])
drawBoard()
函数打印由board
参数表示的游戏棋盘。请记住,棋盘表示为包含 10 个字符串的列表,其中索引为1
的字符串是井字棋棋盘上空间 1 的标记,依此类推。索引为0
的字符串被忽略。游戏的许多函数通过将包含 10 个字符串的列表作为棋盘来工作。
确保字符串中的间距正确;否则,在屏幕上打印时,棋盘会看起来很奇怪。以下是一些示例调用(带有board
参数)到drawBoard()
以及函数将打印什么。
>>> drawBoard([' ', ' ', ' ', ' ', 'X', 'O', ' ', 'X', ' ', 'O']) X| | -+-+- X|O| -+-+- | | >>> drawBoard([' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']) | | -+-+- | | -+-+- | |
程序将每个字符串取出,并根据键盘数字键的顺序放在棋盘上,如图 10-1 所示,因此前三个字符串是棋盘的底行,接下来的三个字符串是中间行,最后三个字符串是顶行。
让玩家选择 X 或 O
接下来,我们将定义一个函数来为玩家分配X或O:
def inputPlayerLetter(): # Lets the player enter which letter they want to be. # Returns a list with the player's letter as the first item and the computer's letter as the second. letter = '' while not (letter == 'X' or letter == 'O'): print('Do you want to be X or O?') letter = input().upper()
inputPlayerLetter()
函数询问玩家是否想成为X或O。while
循环的条件包含括号,这意味着括号内的表达式首先被评估。如果letter
变量设置为'X'
,表达式将这样评估:
如果letter
的值是'X'
或'O'
,那么循环的条件是False
,并且让程序执行继续超出while
块。如果条件是True
,程序将继续要求玩家选择一个字母,直到玩家输入X或O。第 21 行使用upper()
字符串方法自动将input()
调用返回的字符串更改为大写字母。
下一个函数返回一个包含两个项的列表:
# The first element in the list is the player's letter; the second is the computer's letter. if letter == 'X': return ['X', 'O'] else: return ['O', 'X']
第一项(索引为0
的字符串)是玩家的字母,第二项(索引为1
的字符串)是计算机的字母。if
和else
语句选择适当的列表进行返回。
决定谁先走
接下来,我们创建一个使用randint()
来选择玩家或计算机先行的函数:
def whoGoesFirst(): # Randomly choose which player goes first. if random.randint(0, 1) == 0: return 'computer' else: return 'player'
whoGoesFirst()
函数进行虚拟抛硬币,以确定是计算机先走还是玩家先走。抛硬币是通过调用random.randint(0, 1)
来完成的。函数有 50%的概率返回0
,50%的概率返回1
。如果这个函数调用返回0
,whoGoesFirst()
函数返回字符串'computer'
。否则,函数返回字符串'player'
。调用这个函数的代码将使用返回值来确定谁将首先行动。
在棋盘上放置标记
makeMove()
函数很简单:
def makeMove(board, letter, move): board[move] = letter
参数是board
、letter
和move
。变量board
是包含 10 个字符串的列表,表示棋盘的状态。变量letter
是玩家的字母('X'
或'O'
)。变量move
是玩家想要走的棋盘位置(是从1
到9
的整数)。
但是等等——在第 37 行,这段代码似乎改变了board
列表中的一个项目为letter
中的值。然而,由于这段代码在一个函数中,当函数返回时,board
参数将被遗忘。那么对board
的更改也应该被遗忘了吧?
实际上,情况并非如此,因为当你将它们作为参数传递给函数时,列表是特殊的。实际上,你传递的是对列表的引用,而不是列表本身。让我们了解一下列表和对列表的引用之间的区别。
列表引用
在交互式 shell 中输入以下内容:
>>> spam = 42 >>> cheese = spam >>> spam = 100 >>> spam 100 >>> cheese 42
从你目前所知的结果来看是有意义的。你将42
赋给spam
变量,然后将spam
中的值赋给变量cheese
。当你稍后将spam
覆盖为100
时,这不会影响cheese
中的值。这是因为spam
和cheese
是存储不同值的不同变量。
但是列表不是这样工作的。当你将一个列表分配给一个变量时,你实际上是将一个列表引用分配给变量。引用是一个指向存储某些数据的位置的值。让我们看一些代码,这将使这更容易理解。在交互式 shell 中输入以下内容:
➊ >>> spam = [0, 1, 2, 3, 4, 5] ➋ >>> cheese = spam ➌ >>> cheese[1] = 'Hello!' >>> spam [0, 'Hello!', 2, 3, 4, 5] >>> cheese [0, 'Hello!', 2, 3, 4, 5]
代码只改变了cheese
列表,但似乎cheese
和spam
列表都发生了变化。这是因为spam
变量不包含列表值本身,而是包含对列表的引用,如图 10-5 所示。列表本身不包含在任何变量中,而是存在于它们之外。
图 10-5:spam列表在➊处创建。变量不存储列表,而是存储对列表的引用。
注意,cheese = spam
将spam
中的列表引用复制到cheese
➋,而不是复制列表值本身。现在spam
和cheese
都存储一个引用,指向相同的列表值。但只有一个列表,因为列表本身没有被复制。图 10-6 显示了这种复制。
图 10-6:spam和cheese变量存储对同一列表的两个引用。
因此,➌处的cheese1] = 'Hello!'
行更改了spam
引用的相同列表。这就是为什么spam
返回与cheese
相同的列表值。它们都有引用,指向相同的列表,如[图 10-7 所示。
图 10-7:更改列表会更改所有引用该列表的变量。
如果你想要spam
和cheese
存储两个不同的列表,你必须创建两个列表而不是复制一个引用:
>>> spam = [0, 1, 2, 3, 4, 5] >>> cheese = [0, 1, 2, 3, 4, 5]
在前面的例子中,spam
和cheese
存储两个不同的列表(即使这些列表在内容上是相同的)。现在,如果您修改其中一个列表,它不会影响另一个,因为spam
和cheese
引用了两个不同的列表:
>>> spam = [0, 1, 2, 3, 4, 5] >>> cheese = [0, 1, 2, 3, 4, 5] >>> cheese[1] = 'Hello!' >>> spam [0, 1, 2, 3, 4, 5] >>> cheese [0, 'Hello!', 2, 3, 4, 5]
图 10-8 显示了此示例中变量和列表值的设置方式。
字典的工作方式相同。变量不存储字典;它们存储对字典的引用。
图 10-8:spam和cheese变量现在分别存储对两个不同列表的引用。
在 makeMove()中使用列表引用
让我们回到makeMove()
函数:
def makeMove(board, letter, move): board[move] = letter
当将列表值传递给board
参数时,函数的局部变量实际上是对列表的引用的副本,而不是列表本身的副本。因此,对此函数中board
的任何更改也将应用于原始列表。即使board
是局部变量,makeMove()
函数也会修改原始列表。
letter
和move
参数是您传递的字符串和整数值的副本。由于它们是值的副本,如果您在此函数中修改letter
或move
,则在调用makeMove()
时使用的原始变量不会被修改。
检查玩家是否获胜
isWinner()
函数中的第 42 到 49 行实际上是一个很长的return
语句:
def isWinner(bo, le): # Given a board and a player's letter, this function returns True if that player has won. # We use "bo" instead of "board" and "le" instead of "letter" so we don't have to type as much. return ((bo[7] == le and bo[8] == le and bo[9] == le) or # Across the top (bo[4] == le and bo[5] == le and bo[6] == le) or # Across the middle (bo[1] == le and bo[2] == le and bo[3] == le) or # Across the bottom (bo[7] == le and bo[4] == le and bo[1] == le) or # Down the left side (bo[8] == le and bo[5] == le and bo[2] == le) or # Down the middle (bo[9] == le and bo[6] == le and bo[3] == le) or # Down the right side (bo[7] == le and bo[5] == le and bo[3] == le) or # Diagonal (bo[9] == le and bo[5] == le and bo[1] == le)) # Diagonal
bo
和le
名称是board
和letter
参数的快捷方式。这些更短的名称意味着您在此函数中输入的内容更少。请记住,Python 不在乎您给变量取什么名字。
在 Tic-Tac-Toe 中有八种可能的获胜方式:您可以在顶部、中部或底部行中有一条线;您可以在左侧、中间或右侧列中有一条线;或者您可以在两个对角线中的任何一个上有一条线。
条件的每一行都检查给定行的三个空格是否等于提供的字母(与and
运算符结合)。您使用or
运算符组合每一行以检查八种不同的获胜方式。这意味着只有八种方式中的一种必须为True
,我们才能说拥有le
中字母的玩家是赢家。
假设le
是'O'
,bo
是[' ', 'O', 'O', 'O', ' ', 'X', ' ', 'X', ' ', ' ']
。棋盘看起来是这样的:
X| | -+-+- |X| -+-+- O|O|O
以下是第 42 行return
关键字后的表达式的评估方式。首先,Python 用每个变量的值替换变量bo
和le
:
返回((‘X’ == ‘O’ and ’ ’ == ‘O’ and ’ ’ == ‘O’)或
(’ ’ == ‘O’ and ‘X’ == ‘O’ and ’ ’ == ‘O’)或
(‘O’ == ‘O’ and ‘O’ == ‘O’ and ‘O’ == ‘O’)或
(‘X’ == ‘O’ and ’ ’ == ‘O’ and ‘O’ == ‘O’)或
(’ ’ == ‘O’ and ‘X’ == ‘O’ and ‘O’ == ‘O’)或
(’ ’ == ‘O’ and ’ ’ == ‘O’ and ‘O’ == ‘O’)或
(‘X’ == ‘O’ and ‘X’ == ‘O’ and ‘O’ == ‘O’)或
(’ ’ == ‘O’ and ‘X’ == ‘O’ and ‘O’ == ‘O’))
接下来,Python 评估括号内的所有==
比较为布尔值:
返回((False 和 False 和 False)或
(False 和 False 和 False)或
(True 和 True 和 True)或
(False 和 False 和 True)或
(False 和 False 和 True)或
(False 和 False 和 True)或
(False 和 False 和 True)或
(False 和 False 和 True))
然后 Python 解释器评估括号内的所有表达式:
返回((False)或
(False)或
(True)或
(False)或
(False)或
(False)或
(False)或
(False))
由于现在每个内部括号中只有一个值,您可以去掉它们:
返回(False 或
False 或
True 或
False 或
False 或
False 或
False 或
False)
现在 Python 评估由所有这些or
运算符连接的表达式:
返回(True)
再次去掉括号,你会得到一个值:
返回 True
因此,对于bo
和le
的这些值,表达式将求值为True
。这是程序如何判断玩家是否赢得了比赛。
复制棋盘数据
getBoardCopy()
函数允许您轻松地复制表示游戏中井字棋棋盘的给定 10 个字符串列表。
def getBoardCopy(board): # Make a copy of the board list and return it. boardCopy = [] for i in board: boardCopy.append(i) return boardCopy
当 AI 算法计划其移动时,有时需要对棋盘的临时副本进行修改,而不更改实际棋盘。在这些情况下,我们调用此函数来复制棋盘的列表。新列表在第 53 行创建。
现在,boardCopy
中存储的列表只是一个空列表。for
循环将遍历board
参数,将实际棋盘中的字符串值的副本附加到复制的棋盘中。getBoardCopy()
函数建立了实际棋盘的副本后,它会返回对boardCopy
中这个新棋盘的引用,而不是对board
中原始棋盘的引用。
检查棋盘上的空格是否空闲
给定一个井字棋棋盘和一个可能的移动,简单的isSpaceFree()
函数返回该移动是否可用:
def isSpaceFree(board, move): # Return True if the passed move is free on the passed board. return board[move] == ' '
请记住,棋盘列表中的空格标记为单个空格字符串。如果空格的索引处的项目不等于' '
,则该空格已被占用。
让玩家输入移动
getPlayerMove()
函数要求玩家输入他们想要移动的空格的数字:
def getPlayerMove(board): # Let the player enter their move. move = ' ' while move not in '1 2 3 4 5 6 7 8 9'.split() or not isSpaceFree(board, int(move)): print('What is your next move? (1-9)') move = input() return int(move)
第 65 行的条件是,如果or
运算符左侧或右侧的表达式中的任何一个为True
,则条件为True
。循环确保在玩家输入 1 到 9 之间的整数之前,执行不会继续。它还检查传递给board
参数的井字棋棋盘中输入的空格是否已被占用。while
循环内的两行代码只是要求玩家输入 1 到 9 的数字。
左侧的表达式检查玩家的移动是否等于'1'
、'2'
、'3'
,依此类推,直到'9'
,方法是创建包含这些字符串的列表(使用split()
方法),并检查move
是否在此列表中。在这个表达式中,'1 2 3 4 5 6 7 8 9'.split()
求值为['1', '2', '3', '4', '5', '6', '7', '8', '9']
,但前者更容易输入。
右侧的表达式检查玩家输入的移动是否是棋盘上的空格,通过调用isSpaceFree()
。请记住,如果您传递的移动在棋盘上是可用的,isSpaceFree()
将返回True
。请注意,isSpaceFree()
期望move
是一个整数,因此int()
函数返回move
的整数形式。
not
运算符被添加到两侧,以便当这些要求中的任何一个未满足时,条件为True
。这会导致循环一遍又一遍地要求玩家输入一个数字,直到他们输入一个合适的移动。
最后,第 68 行返回玩家输入的移动的整数形式。input()
返回字符串,因此调用int()
函数返回字符串的整数形式。
短路求值
你可能已经注意到getPlayerMove()
函数中可能存在问题。如果玩家输入了'Z'
或其他非整数字符串会怎么样?在or
左侧的表达式move not in '1 2 3 4 5 6 7 8 9'.split()
会返回False
,然后 Python 会评估or
运算符右侧的表达式。
但是调用int('Z')
会导致 Python 出错,因为int()
函数只能接受数字字符的字符串,如'9'
或'0'
,而不能接受'Z'
之类的字符串。
要查看此类错误的示例,请在交互式 shell 中输入以下内容:
>>> int('42') 42 >>> int('Z') Traceback (most recent call last): File "<pyshell#3>", line 1, in <module> int('Z') ValueError: invalid literal for int() with base 10: 'Z'
但是当您玩井字游戏并尝试输入'Z'
作为您的移动时,不会发生此错误。这是因为while
循环的条件被短路了。
短路意味着表达式只评估了一部分,因为表达式的其余部分不会改变表达式的评估结果。以下是一个很好的短路示例的简短程序。在交互式 shell 中输入以下内容:
>>> def ReturnsTrue(): print('ReturnsTrue() was called.') return True >>> def ReturnsFalse(): print('ReturnsFalse() was called.') return False >>> ReturnsTrue() ReturnsTrue() was called. True >>> ReturnsFalse() ReturnsFalse() was called. False
当调用ReturnsTrue()
时,它打印'ReturnsTrue() was called.'
,然后还显示ReturnsTrue()
的返回值。ReturnsFalse()
也是一样。
现在在交互式 shell 中输入以下内容:
>>> ReturnsFalse() or ReturnsTrue() ReturnsFalse() was called. ReturnsTrue() was called. True >>> ReturnsTrue() or ReturnsFalse() ReturnsTrue() was called. True
第一部分是有道理的:表达式ReturnsFalse() or ReturnsTrue()
调用了这两个函数,因此您会看到这两个打印消息。
但第二个表达式只显示'ReturnsTrue() was called.'
,而不是'ReturnsFalse() was called.'
。这是因为 Python 根本没有调用ReturnsFalse()
。由于or
运算符的左侧是True
,ReturnsFalse()
返回什么并不重要,因此 Python 不会调用它。评估被短路了。
对于and
运算符也是一样。现在在交互式 shell 中输入以下内容:
>>> ReturnsTrue() and ReturnsTrue() ReturnsTrue() was called. ReturnsTrue() was called. True >>> ReturnsFalse() and ReturnsFalse() ReturnsFalse() was called. False
同样,如果and
运算符的左侧是False
,那么整个表达式就是False
。右侧是True
或False
都无关紧要,因此 Python 不会评估它。False and True
和False and False
都求值为False
,因此 Python 短路了评估。
让我们回到井字游戏程序的第 65 到 68 行:
while move not in '1 2 3 4 5 6 7 8 9'.split() or not isSpaceFree(board, int(move)): print('What is your next move? (1-9)') move = input() return int(move)
由于or
运算符左侧的条件部分(move not in '1 2 3 4 5 6 7 8 9'.split()
)求值为True
,Python 解释器知道整个表达式将求值为True
。右侧的表达式求值为True
或False
都无关紧要,因为or
运算符的两侧只需要一个值为True
整个表达式才为True
。
因此,Python 停止检查表达式的其余部分,甚至不会评估not isSpaceFree(board, int(move))
部分。这意味着只要move not in '1 2 3 4 5 6 7 8 9'.split()
为True
,int()
和isSpaceFree()
函数就不会被调用。
对于程序来说,这很好,因为如果条件的右侧是True
,那么move
不是单个数字的字符串。这将导致int()
给我们一个错误。但是如果move not in '1 2 3 4 5 6 7 8 9'.split()
求值为True
,Python 会短路not isSpaceFree(board, int(move))
,并且不会调用int(move)
。
从移动列表中选择移动
现在让我们看一下程序后面的 AI 代码中稍后会用到的chooseRandomMoveFromList()
函数:
def chooseRandomMoveFromList(board, movesList): # Returns a valid move from the passed list on the passed board. # Returns None if there is no valid move. possibleMoves = [] for i in movesList: if isSpaceFree(board, i): possibleMoves.append(i)
请记住,board
参数是表示井字游戏板的字符串列表。第二个参数movesList
是一个可能的空间的整数列表,可以从中选择。例如,如果movesList
是[1, 3, 7, 9]
,那么chooseRandomMoveFromList()
应该返回一个角落空间的整数。
然而,chooseRandomMoveFromList()
首先检查空间是否有效进行移动。possibleMoves
列表最初为空列表。然后for
循环遍历movesList
。导致isSpaceFree()
返回True
的移动使用append()
方法添加到possibleMoves
中。
此时,possibleMoves
列表中包含movesList
中的所有移动,这些移动也是空闲空间。然后程序检查列表是否为空:
if len(possibleMoves) != 0: return random.choice(possibleMoves) else: return None
如果列表不为空,则至少有一个可能在棋盘上进行的移动。
但是这个列表可能是空的。例如,如果movesList
是[1, 3, 7, 9]
,但是由board
参数表示的棋盘已经有所有的角落空间被占据,那么possibleMoves
列表将是[]
。在这种情况下,len(possibleMoves)
的值为0
,函数返回值为None
。
None 值
None
值表示缺少值。None
是数据类型NoneType
的唯一值。当你需要一个表示“不存在”或“以上都不是”的值时,你可以使用None
值。
例如,假设你有一个名为quizAnswer
的变量,它保存了用户对某个判断题的答案。该变量可以保存用户的答案为True
或False
。但是如果用户没有回答这个问题,你不希望将quizAnswer
设置为True
或False
,因为那样看起来就像用户回答了这个问题。相反,如果用户跳过了这个问题,你可以将quizAnswer
设置为None
。
顺便说一句,None
不像其他值一样在交互式 shell 中显示出来:
>>> 2 + 2 4 >>> 'This is a string value.' 'This is a string value.' >>> None >>>
第一个两个表达式的值作为输出打印在下一行,但是None
没有值,所以没有打印出来。
似乎不返回任何东西的函数实际上返回None
值。例如,print()
返回None
:
>>> spam = print('Hello world!') Hello world! >>> spam == None True
在这里,我们将print('Hello world!')
赋值给spam
。print()
函数,像所有函数一样,有一个返回值。即使print()
打印一个输出,函数调用也会返回None
。IDLE 不会在交互式 shell 中显示None
,但是你可以看出spam
被设置为None
,因为spam == None
的值为True
。
创建计算机的 AI
getComputerMove()
函数包含 AI 的代码:
def getComputerMove(board, computerLetter): # Given a board and the computer's letter, determine where to move and return that move. if computerLetter == 'X': playerLetter = 'O' else: playerLetter = 'X'
第一个参数是board
参数的井字棋棋盘。第二个参数是计算机使用的字母——在computerLetter
参数中是'X'
或'O'
。前几行只是将另一个字母分配给一个名为playerLetter
的变量。这样,相同的代码可以用于计算机是X还是O。
记住井字棋 AI 算法是如何工作的:
- 看看计算机是否可以进行一步获胜的移动。如果可以,就进行该移动。否则,转到步骤 2。
- 看看玩家是否可以进行一步导致计算机输掉游戏的移动。如果可以,计算机应该移动到那里来阻止玩家。否则,转到步骤 3。
- 检查是否有任何一个角落(空格 1、3、7 或 9)是空的。如果没有角落空间是空的,转到步骤 4。
- 检查中心是否空闲。如果是,就移动到那里。如果不是,转到步骤 5。
- 在任何一侧移动(空格 2、4、6 或 8)。没有更多的步骤,因为如果执行到这一步,侧面空间是唯一剩下的空间。
该函数将返回一个表示计算机移动的整数,从1
到9
。让我们逐步了解代码中如何实现这些步骤。
检查计算机是否可以在一步内获胜
在任何其他操作之前,如果计算机可以在下一步获胜,它应该立即进行获胜的移动。
# Here is the algorithm for our Tic-Tac-Toe AI: # First, check if we can win in the next move. for i in range(1, 10): boardCopy = getBoardCopy(board) if isSpaceFree(boardCopy, i): makeMove(boardCopy, computerLetter, i) if isWinner(boardCopy, computerLetter): return i
从第 92 行开始的for
循环遍历从 1 到 9 的每个可能的移动。循环内的代码模拟了如果计算机进行了该移动会发生什么。
循环中的第一行(第 93 行)复制了board
列表。这样做是为了循环内的模拟移动不会修改存储在board
变量中的真实井字棋棋盘。getBoardCopy()
返回一个相同但是独立的棋盘列表值。
第 94 行检查空格是否空闲,如果是,就模拟在棋盘的副本上进行移动。如果这个移动导致计算机获胜,函数返回该移动的整数。
如果没有空格导致获胜,循环结束,程序执行继续到第 100 行。
检查玩家是否可以在一步内获胜
接下来,代码将模拟人类玩家在每个空格上的移动:
# Check if the player could win on their next move and block them. for i in range(1, 10): boardCopy = getBoardCopy(board) if isSpaceFree(boardCopy, i): makeMove(boardCopy, playerLetter, i) if isWinner(boardCopy, playerLetter): return i
该代码类似于第 92 行的循环,只是玩家的字母放在了棋盘副本上。如果isWinner()
函数显示玩家可以通过一步走棋获胜,那么计算机将返回相同的走法来阻止这种情况发生。
如果人类玩家无法在一步走棋中获胜,for
循环结束,执行继续到第 108 行。
检查角落、中心和侧面空格(按顺序)
如果计算机无法获胜并且不需要阻止玩家的移动,它将移动到角落、中心或侧面空格,具体取决于可用的空格。
计算机首先尝试移动到其中一个角落空间:
# Try to take one of the corners, if they are free. move = chooseRandomMoveFromList(board, [1, 3, 7, 9]) if move != None: return move
使用列表[1, 3, 7, 9]
调用chooseRandomMoveFromList()
函数确保函数返回其中一个角落空间的整数:1、3、7 或 9。
如果所有角落空间都被占据,chooseRandomMoveFromList()
函数将返回None
,执行将继续到 113 行:
# Try to take the center, if it is free. if isSpaceFree(board, 5): return 5
如果没有一个角落是可用的,114 行将移动到中心空间(如果它是空的)。如果中心空间不是空的,执行将继续到 117 行:
# Move on one of the sides. return chooseRandomMoveFromList(board, [2, 4, 6, 8])
这段代码还调用了chooseRandomMoveFromList()
,只是你给它传递了一个侧面空间的列表:[2, 4, 6, 8]
。这个函数不会返回None
,因为侧面空间是可能剩下的唯一空间。这结束了getComputerMove()
函数和 AI 算法。
检查棋盘是否已满
最后一个函数是isBoardFull()
:
def isBoardFull(board): # Return True if every space on the board has been taken. Otherwise, return False. for i in range(1, 10): if isSpaceFree(board, i): return False return True
如果board
参数中的 10 个字符串列表在每个索引(除了被忽略的索引0
)中都有'X'
或'O'
,则此函数返回True
。for
循环让我们检查board
列表上的索引1
到9
。一旦它在棋盘上找到一个空格(也就是说,当isSpaceFree(board, i)
返回True
时),isBoardFull()
函数将返回False
。
如果执行成功通过循环的每次迭代,那么没有空格。然后 124 行将执行return True
。
游戏循环
127 行是第一个不在函数内的代码行,因此它是运行此程序时执行的第一行代码。
print('Welcome to Tic-Tac-Toe!')
这行在游戏开始前问候玩家。然后程序在 129 行进入while
循环:
while True: # Reset the board. theBoard = [' '] * 10
while
循环一直循环,直到执行遇到break
语句。第 131 行在名为theBoard
的变量中设置了主井字棋棋盘。棋盘开始为空,我们用包含 10 个单个空格字符串的列表表示。与其输入完整的列表,第 131 行使用列表复制。输入[' '] * 10
比[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']
更短。
选择玩家的标记和谁先走
接下来,inputPlayerLetter()
函数允许玩家输入他们想要成为X还是O:
playerLetter, computerLetter = inputPlayerLetter()
该函数返回一个包含两个字符串的列表,要么['X', 'O']
,要么['O', 'X']
。我们使用多重赋值将playerLetter
设置为返回列表中的第一项,将computerLetter
设置为第二项。
从那里,whoGoesFirst()
函数随机决定谁先走,返回字符串'player'
或字符串'computer'
,然后第 134 行告诉玩家谁将先走:
turn = whoGoesFirst() print('The ' + turn + ' will go first.') gameIsPlaying = True
gameIsPlaying
变量跟踪游戏是否仍在进行中,或者是否有人赢了或打成平局。
运行玩家回合
第 137 行的循环将在gameIsPlaying
设置为True
时在玩家回合和计算机回合的代码之间来回执行:
while gameIsPlaying: if turn == 'player': # Player's turn drawBoard(theBoard) move = getPlayerMove(theBoard) makeMove(theBoard, playerLetter, move)
turn
变量最初由 133 行的whoGoesFirst()
调用设置为'player'
或'computer'
。如果turn
等于'computer'
,那么 138 行的条件为False
,执行跳转到 156 行。
但是如果第 138 行的评估结果为True
,第 140 行调用drawBoard()
并将theBoard
变量传递给打印出井字棋棋盘。然后getPlayerMove()
让玩家输入他们的移动(并确保它是有效的移动)。makeMove()
函数将玩家的X或O添加到theBoard
。
现在玩家已经下完了棋,程序应该检查他们是否赢得了比赛:
if isWinner(theBoard, playerLetter): drawBoard(theBoard) print('Hooray! You have won the game!') gameIsPlaying = False
如果isWinner()
函数返回True
,if
块的代码会显示获胜的棋盘,并打印一条消息告诉玩家他们赢了。gameIsPlaying
变量也被设置为False
,以便执行不会继续到计算机的回合。
如果玩家在上一步没有赢,也许他们的移动填满了整个棋盘并打成平局。程序接下来用一个else
语句检查这个条件:
else: if isBoardFull(theBoard): drawBoard(theBoard) print('The game is a tie!') break
在这个else
块中,如果isBoardFull()
函数返回True
,表示没有更多的移动可供选择。在这种情况下,从第 149 行开始的if
块会显示平局的棋盘,并告诉玩家发生了平局。然后执行跳出while
循环并跳转到第 173 行。
如果玩家没有赢得比赛或打成平局,程序会进入另一个else
语句:
else: turn = 'computer'
第 154 行将turn
变量设置为’computer’,以便程序在下一次迭代中执行计算机的回合代码。
运行计算机的回合
如果行 138 的条件不是’player’,那么就是计算机的回合。这个else
块中的代码与玩家回合的代码类似:
else: # Computer's turn move = getComputerMove(theBoard, computerLetter) makeMove(theBoard, computerLetter, move) if isWinner(theBoard, computerLetter): drawBoard(theBoard) print('The computer has beaten you! You lose.') gameIsPlaying = False else: if isBoardFull(theBoard): drawBoard(theBoard) print('The game is a tie!') break else: turn = 'player'
第 157 到 171 行几乎与第 139 到 154 行的玩家回合的代码相同。唯一的区别是这段代码使用计算机的字母并调用getComputerMove()
。
如果比赛没有赢得或打成平局,第 171 行将turn
设置为玩家的回合。在while
循环内没有更多的代码行,所以执行会跳回到第 137 行的while
语句。
询问玩家是否再玩一次
最后,程序询问玩家是否想再玩一局:
print('Do you want to play again? (yes or no)') if not input().lower().startswith('y'): break
在第 137 行的while
语句开始的while
块之后,立即执行 173 到 175 行。当比赛结束时,gameIsPlaying
被设置为False
,所以此时游戏会询问玩家是否想再玩一次。
not input().lower().startswith('y')
表达式如果玩家输入的内容不以’y’开头,则为True
。在这种情况下,break
语句执行。这将跳出从第 129 行开始的while
循环。但是因为在该while
块之后没有更多的代码行,程序终止并结束游戏。
摘要
创建一个带有 AI 的程序归结为仔细考虑 AI 可能遇到的所有可能情况,以及在每种情况下它应该如何做出反应。井字棋 AI 很简单,因为井字棋中可能的移动不像国际象棋或跳棋那样多。
我们的计算机 AI 检查是否有可能获胜的移动。否则,它会检查是否必须阻止玩家的移动。然后 AI 简单地选择任何可用的角落空间,然后中心空间,然后侧面空间。这是计算机要遵循的一个简单算法。
实现我们的 AI 的关键是复制棋盘数据并在副本上模拟移动。这样,AI 代码可以看到移动是否导致胜利或失败。然后 AI 可以在真正的棋盘上进行移动。这种模拟对于预测什么是或不是一个好的移动是有效的。
十一、BAGELS 推理游戏
原文:
inventwithpython.com/invent4thed/chapter11.html
译者:飞龙
Bagels 是一个推理游戏,玩家试图猜出计算机生成的随机三位数(不重复数字)。每次猜测后,计算机会给玩家三种类型的线索:
Bagels 猜测的三个数字中没有一个在秘密数字中。
Pico 一个数字在秘密数字中,但是猜测的数字位置不对。
费米 猜测中有一个正确的数字在正确的位置。
计算机可以给出多个线索,这些线索按字母顺序排序。如果秘密数字是 456,玩家的猜测是 546,线索将是“fermi pico pico”。 “fermi”来自 6,“pico pico”来自 4 和 5。
在本章中,您将学习一些 Python 提供的新方法和函数。您还将了解增强赋值运算符和字符串插值。虽然它们不能让您做任何以前做不到的事情,但它们是使编码更容易的好快捷方式。
本章涵盖的主题
random.shuffle()
函数- 增强赋值运算符,
+=
,-=
,*=
,/=
sort()
列表方法join()
字符串方法- 字符串插值
- 转换说明符
%s
- 嵌套循环
Bagels 的示例运行
当用户运行 Bagels 程序时,用户看到的文本如下。玩家输入的文本显示为粗体。
I am thinking of a 3-digit number. Try to guess what it is. The clues I give are... When I say: That means: Bagels None of the digits is correct. Pico One digit is correct but in the wrong position. Fermi One digit is correct and in the right position. I have thought up a number. You have 10 guesses to get it. Guess #1: 123 Fermi Guess #2: 453 Pico Guess #3: 425 Fermi Guess #4: 326 Bagels Guess #5: 489 Bagels Guess #6: 075 Fermi Fermi Guess #7: 015 Fermi Pico Guess #8: 175 You got it! Do you want to play again? (yes or no) no
Bagels 的源代码
在一个新文件中,输入以下源代码并将其保存为bagels.py。然后按 F5 运行游戏。如果出现错误,请将您输入的代码与书中的代码进行比较,使用在线 diff 工具www.nostarch.com/inventwithpython#diff
。
bagels.py
import random NUM_DIGITS = 3 MAX_GUESS = 10 def getSecretNum(): # Returns a string of unique random digits that is NUM_DIGITS long. numbers = list(range(10)) random.shuffle(numbers) secretNum = '' for i in range(NUM_DIGITS): secretNum += str(numbers[i]) return secretNum def getClues(guess, secretNum): # Returns a string with the Pico, Fermi, & Bagels clues to the user. if guess == secretNum: return 'You got it!' clues = [] for i in range(len(guess)): if guess[i] == secretNum[i]: clues.append('Fermi') elif guess[i] in secretNum: clues.append('Pico') if len(clues) == 0: return 'Bagels' clues.sort() return ' '.join(clues) def isOnlyDigits(num): # Returns True if num is a string of only digits. Otherwise, returns False. if num == '': return False for i in num: if i not in '0 1 2 3 4 5 6 7 8 9'.split(): return False return True print('I am thinking of a %s-digit number. Try to guess what it is.' % (NUM_DIGITS)) print('The clues I give are...') print('When I say: That means:') print(' Bagels None of the digits is correct.') print(' Pico One digit is correct but in the wrong position.') print(' Fermi One digit is correct and in the right position.') while True: secretNum = getSecretNum() print('I have thought up a number. You have %s guesses to get it.' % (MAX_GUESS)) guessesTaken = 1 while guessesTaken <= MAX_GUESS: guess = '' while len(guess) != NUM_DIGITS or not isOnlyDigits(guess): print('Guess #%s: ' % (guessesTaken)) guess = input() print(getClues(guess, secretNum)) guessesTaken += 1 if guess == secretNum: break if guessesTaken > MAX_GUESS: print('You ran out of guesses. The answer was %s.' % (secretNum)) print('Do you want to play again? (yes or no)') if not input().lower().startswith('y'): break
Bagels 的流程图
图 11-1 中的流程图描述了游戏中发生的事情以及每个步骤发生的顺序。
Bagels 的流程图非常简单。计算机生成一个秘密数字,玩家试图猜出该数字,计算机根据他们的猜测给出线索。这一过程一遍又一遍地进行,直到玩家赢了或输了。游戏结束后,无论玩家赢还是输,计算机都会询问玩家是否想再玩一次。
图 11-1:Bagels 游戏的流程图
导入随机数和定义 getSecretNum()
在程序开始时,我们将导入random
模块并设置一些全局变量。然后我们将定义一个名为getSecretNum()
的函数。
import random NUM_DIGITS = 3 MAX_GUESS = 10 def getSecretNum(): # Returns a string of unique random digits that is NUM_DIGITS long.
我们使用常量变量NUM_DIGITS
代替整数3
作为答案中数字的数量。玩家猜测次数也是一样,我们使用常量变量MAX_GUESS
代替整数10
。现在很容易改变猜测次数或秘密数字的数量。只需更改第 3 行或第 4 行的值,程序的其余部分仍将正常工作,无需进行其他更改。
getSecretNum()
函数生成一个只包含唯一数字的秘密数字。如果秘密数字中没有重复的数字,Bagels 游戏会更有趣,比如'244'
或'333'
。我们将使用一些新的 Python 函数来实现这一点。
洗牌一个独特的数字集
getSecretNum()
的前两行对一组不重复的数字进行了洗牌:
numbers = list(range(10)) random.shuffle(numbers)
第 8 行的list(range(10))
求值为[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
,因此numbers
变量包含所有 10 个数字的列表。
使用 random.shuffle()函数更改列表项顺序
random.shuffle()
函数会随机更改列表项的顺序(在本例中是数字列表)。此函数不返回值,而是修改您传递给它的列表原地。这类似于第 10 章中的井字棋游戏中的makeMove()
函数修改了传递给它的列表,而不是返回具有更改的新列表。这就是为什么您不编写像numbers = random.shuffle(numbers)
这样的代码。
尝试通过将以下代码输入交互式 shell 来尝试使用shuffle()
函数:
>>> import random >>> spam = list(range(10)) >>> print(spam) [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] >>> random.shuffle(spam) >>> print(spam) [3, 0, 5, 9, 6, 8, 2, 4, 1, 7] >>> random.shuffle(spam) >>> print(spam) [9, 8, 3, 5, 4, 7, 1, 2, 0, 6]
每次在spam
上调用random.shuffle()
时,spam
列表中的项目都会被洗牌。您将看到我们如何使用shuffle()
函数来生成下一个秘密数字。
从洗牌后的数字中获取秘密数字
秘密数字将是洗牌后的整数列表的前NUM_DIGITS
位的字符串:
secretNum = '' for i in range(NUM_DIGITS): secretNum += str(numbers[i]) return secretNum
secretNum
变量最初为空字符串。第 11 行的for
循环迭代NUM_DIGITS
次。在循环的每次迭代中,从洗牌列表中的索引i
处提取整数,将其转换为字符串,并连接到secretNum
的末尾。
例如,如果numbers
指的是列表[9, 8, 3, 5, 4, 7, 1, 2, 0, 6]
,那么在第一次迭代中,numbers[0]
(即9
)将被传递给str()
;这将返回'9'
,它将被连接到secretNum
的末尾。在第二次迭代中,numbers[1]
(即8
)发生了同样的情况,在第三次迭代中,numbers[2]
(即3
)也是如此。返回的secretNum
的最终值是'983'
。
请注意,此函数中的secretNum
包含一个字符串,而不是整数。这可能看起来很奇怪,但请记住,您不能连接整数。表达式9 + 8 + 3
计算结果为20
,但您想要的是'9' + '8' + '3'
,计算结果为'983'
。
增强赋值运算符
第 12 行的+=
运算符是增强赋值运算符之一。通常,如果要将值添加或连接到变量,您将使用类似以下的代码:
>>> spam = 42 >>> spam = spam + 10 >>> spam 52 >>> eggs = 'Hello ' >>> eggs = eggs + 'world!' >>> eggs 'Hello world!'
增强赋值运算符是一种可以使您摆脱重新输入变量名的快捷方式。以下代码与先前的代码执行相同的操作:
>>> spam = 42 >>> spam += 10 # The same as spam = spam + 10 >>> spam 52 >>> eggs = 'Hello ' >>> eggs += 'world!' # The same as eggs = eggs + 'world!' >>> eggs 'Hello world!'
还有其他增强赋值运算符。将以下内容输入交互式 shell:
>>> spam = 42 >>> spam -= 2 >>> spam 40
语句spam –= 2
与语句spam = spam – 2
相同,因此表达式计算结果为spam = 42 – 2
,然后为spam = 40
。
还有用于乘法和除法的增强赋值运算符:
>>> spam *= 3 >>> spam 120 >>> spam /= 10 >>> spam 12.0
语句spam *= 3
与spam = spam * 3
相同。因此,由于spam
之前设置为40
,完整表达式将是spam = 40 * 3
,计算结果为120
。表达式spam /= 10
与spam = spam / 10
相同,spam = 120 / 10
计算结果为12.0
。请注意,在除法后,spam
变成了浮点数。
计算要给出的线索
getClues()
函数将根据guess
和secretNum
参数返回一个包含 fermi、pico 和 bagels 线索的字符串。
def getClues(guess, secretNum): # Returns a string with the Pico, Fermi, & Bagels clues to the user. if guess == secretNum: return 'You got it!' clues = [] for i in range(len(guess)): if guess[i] == secretNum[i]: clues.append('Fermi') elif guess[i] in secretNum: clues.append('Pico')
最明显的步骤是检查猜测是否与秘密数字相同,我们在第 17 行中进行了检查。在这种情况下,第 18 行返回'You got it!'
。
如果猜测与秘密数字不同,程序必须找出给玩家什么线索。clues
中的列表将从空开始,并根据需要添加'Fermi'
和'Pico'
字符串。
程序通过循环遍历guess
和secretNum
中的每个可能的索引来执行此操作。这两个变量中的字符串将具有相同的长度,因此第 21 行可以使用len(guess)
或len(secretNum)
中的任何一个,并且效果相同。当i
的值从0
变化到1
到2
等时,第 22 行检查guess
的第一个、第二个、第三个等字符是否与secretNum
相应索引处的字符相同。如果是,第 23 行将字符串'Fermi'
添加到clues
中。
否则,第 24 行检查guess
中第i
个位置的数字是否存在于secretNum
中的任何位置。如果是,你就知道这个数字在秘密数字中的某个位置,但不在同一个位置。在这种情况下,第 25 行将'Pico'
添加到clues
中。
如果循环后clues
列表为空,那么你就知道guess
中根本没有正确的数字:
if len(clues) == 0: return 'Bagels'
在这种情况下,第 27 行返回字符串'Bagels'
作为唯一的线索。
sort()列表方法
列表有一个名为sort()
的方法,它可以按字母顺序或数字顺序排列列表项。当调用sort()
方法时,它不会返回排序后的列表,而是在原地对列表进行排序。这就像shuffle()
方法的工作方式一样。
你绝对不想使用return spam.sort()
,因为那会返回值None
。相反,你需要另外一行spam.sort()
,然后再加上return spam
这一行。
在交互式 shell 中输入以下内容:
>>> spam = ['cat', 'dog', 'bat', 'anteater'] >>> spam.sort() >>> spam ['anteater', 'bat', 'cat', 'dog'] >>> spam = [9, 8, 3, 5.5, 5, 7, 1, 2.1, 0, 6] >>> spam.sort() >>> spam [0, 1, 2.1, 3, 5, 5.5, 6, 7, 8, 9]
当我们对字符串列表进行排序时,字符串按字母顺序返回,但当我们对数字列表进行排序时,数字按数字顺序返回。
在第 29 行,我们对clues
使用sort()
:
clues.sort()
你希望按字母顺序对clue
列表进行排序的原因是为了摆脱会帮助玩家更轻松猜出秘密数字的额外信息。如果clues
是['Pico', 'Fermi', 'Pico']
,那会告诉玩家猜测的中间数字在正确的位置。由于另外两个线索都是Pico
,玩家会知道他们只需要交换第一个和第三个数字就能得到秘密数字。
如果线索总是按字母顺序排序,玩家就无法确定Fermi
线索指的是哪个数字。这使得游戏更加困难和有趣。
join()字符串方法
join()
字符串方法将一组字符串作为一个单独的字符串连接在一起返回。
return ' '.join(clues)
方法调用的字符串(在第 30 行,这是一个单个空格,' '
)出现在列表中的每个字符串之间。要查看示例,请在交互式 shell 中输入以下内容:
>>> ' '.join(['My', 'name', 'is', 'Zophie']) 'My name is Zophie' >>> ', '.join(['Life', 'the Universe', 'and Everything']) 'Life, the Universe, and Everything'
因此,第 30 行返回的字符串是clue
中的每个字符串与每个字符串之间的单个空格组合在一起。join()
字符串方法有点像split()
字符串方法的相反。split()
从分割的字符串返回一个列表,而join()
从组合的列表返回一个字符串。
检查字符串是否只包含数字
isOnlyDigits()
函数有助于确定玩家输入了有效的猜测:
def isOnlyDigits(num): # Returns True if num is a string of only digits. Otherwise, returns False. if num == '': return False
第 34 行首先检查num
是否为空字符串,如果是,则返回False
。
然后for
循环遍历字符串num
中的每个字符:
for i in num: if i not in '0 1 2 3 4 5 6 7 8 9'.split(): return False return True
i
的值将在每次迭代中有一个单个字符。在for
块内部,代码检查i
是否存在于'0 1 2 3 4 5 6 7 8 9'.split()
返回的列表中。(split()
的返回值等同于['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
。)如果i
不存在于该列表中,你就知道num
中有一个非数字字符。在这种情况下,第 39 行返回False
。
但是,如果执行继续超过for
循环,那么你就知道num
中的每个字符都是一个数字。在这种情况下,第 41 行返回True
。
开始游戏
在所有函数定义之后,第 44 行是程序的实际开始:
print('I am thinking of a %s-digit number. Try to guess what it is.' % (NUM_DIGITS)) print('The clues I give are...') print('When I say: That means:') print(' Bagels None of the digits is correct.') print(' Pico One digit is correct but in the wrong position.') print(' Fermi One digit is correct and in the right position.')
print()
函数调用告诉玩家游戏规则以及 pico、fermi 和 bagels 线索的含义。第 44 行的print()
调用在末尾添加了% (NUM_DIGITS)
,并在字符串内部使用了%s
。这是一种称为字符串插值的技术。
字符串插值
字符串插值,也称为字符串格式化,是一种编码快捷方式。通常,如果你想在另一个字符串中使用变量中的字符串值,你必须使用+
连接运算符:
>>> name = 'Alice' >>> event = 'party' >>> location = 'the pool' >>> day = 'Saturday' >>> time = '6:00pm' >>> print('Hello, ' + name + '. Will you go to the ' + event + ' at ' + location + ' this ' + day + ' at ' + time + '?') Hello, Alice. Will you go to the party at the pool this Saturday at 6:00pm?
如您所见,键入连接多个字符串可能会耗费时间。相反,您可以使用字符串插值,它允许您在字符串中放置类似%s
的占位符。这些占位符称为转换说明符。一旦放入转换说明符,您可以在字符串的末尾放置所有变量名。每个%s
都将被最后一行的变量替换,替换的顺序与输入变量的顺序相同。例如,以下代码与前面的代码执行相同的操作:
>>> name = 'Alice' >>> event = 'party' >>> location = 'the pool' >>> day = 'Saturday' >>> time = '6:00pm' >>> print('Hello, %s. Will you go to the %s at %s this %s at %s?' % (name, event, location, day, time)) Hello, Alice. Will you go to the party at the pool this Saturday at 6:00pm?
请注意,第一个变量名用于第一个%s
,第二个变量用于第二个%s
,依此类推。您必须具有与变量数量相同的%s
转换说明符。
使用字符串插值而不是字符串连接的另一个好处是,插值适用于任何数据类型,而不仅仅是字符串。所有值都会自动转换为字符串数据类型。如果将整数连接到字符串,将会出现错误:
>>> spam = 42 >>> print('Spam == ' + spam) Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: Can't convert 'int' object to str implicitly
字符串连接只能组合两个字符串,但spam
是一个整数。您必须记住将spam
替换为str(spam)
而不是spam
。
现在将其输入交互式 shell:
>>> spam = 42 >>> print('Spam is %s' % (spam)) Spam is 42
使用字符串插值,这种转换到字符串的操作是由程序自动完成的。
游戏循环
第 51 行是一个无限的while
循环,其条件为True
,因此它将一直循环,直到执行break
语句:
while True: secretNum = getSecretNum() print('I have thought up a number. You have %s guesses to get it.' % (MAX_GUESS)) guessesTaken = 1 while guessesTaken <= MAX_GUESS:
在无限循环中,您从getSecretNum()
函数中获得一个秘密数字。这个秘密数字被分配给secretNum
。请记住,secretNum
中的值是一个字符串,而不是一个整数。
第 53 行使用字符串插值告诉玩家秘密数字中有多少位数字。第 55 行将变量guessesTaken
设置为1
,标记这是第一次猜测。然后第 56 行有一个新的while
循环,只要玩家还有猜测次数,就会循环。在代码中,这是当guessesTaken
小于或等于MAX_GUESS
时。
请注意,第 56 行的while
循环位于第 51 行开始的另一个while
循环内。这些内部循环称为嵌套循环。任何break
或continue
语句,例如第 66 行的break
语句,只会中断或继续最内层的循环,而不会中断或继续任何外部循环。
获取玩家的猜测
guess
变量保存了从input()
返回的玩家猜测。代码不断循环并要求玩家猜测,直到他们输入有效的猜测:
guess = '' while len(guess) != NUM_DIGITS or not isOnlyDigits(guess): print('Guess #%s: ' % (guessesTaken)) guess = input()
有效的猜测只包含数字,并且与秘密数字的位数相同。从第 58 行开始的while
循环检查猜测的有效性。
第 57 行将guess
变量设置为空字符串,因此第 58 行的while
循环条件第一次检查时为False
,确保执行进入从第 59 行开始的循环。
获取玩家猜测的线索
在执行超过从第 58 行开始的while
循环后,guess
包含一个有效的猜测。现在程序将guess
和secretNum
传递给getClues()
函数:
print(getClues(guess, secretNum)) guessesTaken += 1
它返回一串线索,这些线索在第 62 行显示给玩家。第 63 行使用增强赋值运算符进行加法,递增guessesTaken
。
检查玩家是赢了还是输了
现在我们要弄清楚玩家是赢了还是输了游戏:
if guess == secretNum: break if guessesTaken > MAX_GUESS: print('You ran out of guesses. The answer was %s.' % (secretNum))
如果guess
的值与secretNum
相同,玩家已经正确猜出了秘密数字,并且第 66 行跳出了从第 56 行开始的while
循环。如果不是,则执行继续到第 67 行,程序检查玩家是否已经用完了猜测次数。
如果玩家还有更多的猜测,执行会跳回到第 56 行的while
循环,让玩家再猜一次。如果玩家用完了猜测次数(或程序在第 66 行的break
语句中跳出循环),执行将继续到第 70 行。
要求玩家再玩一次
第 70 行询问玩家是否想再玩一次:
print('Do you want to play again? (yes or no)') if not input().lower().startswith('y'): break
玩家的响应由input()
返回,对其调用lower()
方法,然后对其调用startswith()
方法来检查玩家的响应是否以y
开头。如果不是,则程序会跳出从第 51 行开始的while
循环。由于在此循环之后没有更多的代码,程序终止。
如果响应确实以y
开头,程序不会执行break
语句,并且执行会跳回到第 51 行。然后程序会生成一个新的秘密数字,这样玩家就可以玩一个新游戏。
摘要
贝果是一个简单的游戏,但很难赢。但如果你继续玩,最终会发现使用游戏给出的线索来猜测的更好的方法。这很像您在继续编程时会变得更好一样。
本章介绍了一些新的函数和方法——shuffle()
、sort()
和join()
,以及一些方便的快捷方式。增强赋值运算符在想要改变变量的相对值时需要输入更少的内容;例如,spam = spam + 1
可以缩短为spam += 1
。通过字符串插值,您可以在字符串中放置%s
(称为转换说明符),使您的代码更易读,而不是使用许多字符串连接操作。
在第 12 章中,我们不会进行任何编程,但是后面章节中的游戏将需要笛卡尔坐标和负数的概念。这些数学概念不仅在我们将要制作的声纳寻宝、反转棋和躲避者游戏中使用,而且在许多其他游戏中也会用到。即使你已经了解这些概念,也请阅读第 12 章进行简要复习。
十二、笛卡尔坐标系
原文:
inventwithpython.com/invent4thed/chapter12.html
译者:飞龙
本章介绍了您将在本书的其余部分中使用的一些简单数学概念。在二维(2D)游戏中,屏幕上的图形可以向左、向右、向上或向下移动。这些游戏需要一种将屏幕上的位置转换为程序可以处理的整数的方法。
图 12-2:相同的棋盘,但行和列都有数字坐标
本章涵盖的主题
您可以将棋盘视为笛卡尔坐标系。通过使用行标签和列标签,您可以给出一个坐标,该坐标仅适用于棋盘上的一个空间。如果您在数学课上学过笛卡尔坐标系,您可能知道数字用于行和列。使用数字坐标的棋盘将看起来像图 12-2。
注意,要使黑色骑士移动到白色骑士的位置,黑色骑士必须向上移动两个空间并向右移动四个空间。但是,您不需要查看棋盘来弄清楚这一点。如果您知道白色骑士位于(5,6),黑色骑士位于(1,4),您可以使用减法来弄清楚这一信息。
- 像素
- 加法的交换律
- x 轴和 y 轴
这就是笛卡尔坐标系的用武之地。坐标是表示屏幕上特定点的数字。这些数字可以存储为程序变量中的整数。
引用国际象棋棋盘上特定位置的常见方法是用字母和数字标记每一行和列。图 12-1 显示了一个每一行和列都有标记的国际象棋棋盘。
图 12-1:一个带有黑色骑士(a,4)和白色骑士(e,6)的样本棋盘
- 笛卡尔坐标系
棋盘上空间的坐标是行和列的组合。在国际象棋中,骑士棋子看起来像马头。图 12-1 中的白色骑士位于点(e,6),因为它在列 e 和行 6,黑色骑士位于点(a,4),因为它在列 a 和行 4。
沿列向左和向右的数字是x 轴的一部分。沿行向上和向下的数字是y 轴的一部分。坐标始终以 x 坐标优先,然后是 y 坐标。在图 12-2 中,白色骑士的 x 坐标为 5,y 坐标为 6,因此白色骑士位于坐标(5,6),而不是(6,5)。同样,黑色骑士位于坐标(1,4),而不是(4,1),因为黑色骑士的 x 坐标为 1,y 坐标为 4。
- 负数
- 绝对值和
abs()
函数
从白色骑士的 x 坐标中减去黑色骑士的 x 坐标:5-1=4。黑色骑士必须沿 x 轴移动四个空间。现在从白色骑士的 y 坐标中减去黑色骑士的 y 坐标:6-4=2。黑色骑士必须沿 y 轴移动两个空间。
网格和笛卡尔坐标
通过对坐标数字进行一些数学运算,您可以计算出两个坐标之间的距离。
负数
笛卡尔坐标系也使用负数——小于零的数。数前的减号表示它是负数:-1 小于 0。-2 小于-1。但 0 本身既不是正数也不是负数。在图 12-3 中,你可以看到数轴上正数向右增加,负数向左减少。
图 12-3:带有正数和负数的数轴
数轴对于理解减法和加法很有用。你可以将表达式 5+3 看作白色骑士从位置 5 开始向右移动 3 个空格。正如你在图 12-4 中所看到的,白色骑士最终停在位置 8。这是有道理的,因为 5+3 等于 8。
图 12-4:将白色骑士向右移动会增加坐标。
你通过将白色骑士向左移动来进行减法。因此,如果表达式是 5-6,白色骑士从位置 5 开始向左移动 6 个空格,如图 12-5 所示。
图 12-5:将白色骑士向左移动会减少坐标。
白色骑士最终停在位置-1。这意味着 5-6 等于-1。
如果你加或减一个负数,白色骑士的移动方向与正数相反。如果你加一个负数,骑士向左移动。如果你减去一个负数,骑士向右移动。表达式-1-(-4)将等于 3,如图 12-6 所示。注意-1-(-4)的答案与-1+4 相同。
图 12-6:骑士从-6 开始向右移动 4 个空格。
你可以将 x 轴看作一个数轴。再加上一个上下移动的 y 轴。如果将这两个数轴放在一起,就会得到一个像图 12-7 中的笛卡尔坐标系。
添加一个正数(或减去一个负数)会使骑士在 y 轴上向上移动,或者在 x 轴上向右移动,而减去一个正数(或加上一个负数)会使骑士在 y 轴上向下移动,或者在 x 轴上向左移动。
中心的(0,0)坐标被称为原点。你可能在数学课上使用过这样的坐标系。正如你将要看到的,这些坐标系有很多小技巧,可以帮助你更容易地计算坐标。
图 12-7:将两个数轴放在一起可以创建笛卡尔坐标系。
计算机屏幕的坐标系
你的计算机屏幕由像素组成,是屏幕可以显示的最小颜色点。计算机屏幕通常使用一个坐标系,原点(0,0)位于左上角,向下和向右增加。你可以在图 12-8 中看到这一点,该图显示了一个分辨率为 1920 像素宽和 1080 像素高的笔记本电脑屏幕。
没有负坐标。大多数计算机图形在屏幕上使用这种坐标系,你将在本书的游戏中使用它。对于编程来说,了解坐标系的工作原理很重要,无论是数学使用的还是计算机屏幕使用的。
图 12-8:计算机屏幕上的笛卡尔坐标系
数学技巧
当你面前有一个数轴时,加减负数就很容易。即使没有数轴,也可以很容易。以下是三个技巧,可以帮助你自己加减负数。
技巧 1:减号吃掉它左边的加号
当你看到一个减号和左边有一个加号时,你可以用减号替换加号。想象减号“吃掉”左边的加号。答案仍然是一样的,因为加上一个负值就等同于减去一个正值。所以 4 + -2 和 4 - 2 都等于 2,如下所示:
技巧 2:两个负号合并成一个加号
当你看到两个负号相邻而没有数字在它们中间时,它们可以合并成一个加号。答案仍然是一样的,因为减去一个负值就等同于加上一个正值:
技巧 3:两个相加的数字可以交换位置
你总是可以交换加法中的数字。这就是加法的交换律。这意味着像 6 + 4 到 4 + 6 的交换不会改变答案,当你数一下图 12-9 中的方块时就会发现。
图 12-9:加法的交换律让你可以交换数字。
假设你正在加一个负数和一个正数,比如-6 + 8。因为你在加数字,所以你可以交换数字的顺序而不改变答案。这意味着-6 + 8 和 8 + -6 是一样的。然后当你看 8 + -6 时,你会发现减号可以吃掉左边的加号,问题变成了 8 - 6 = 2,如下所示:
你已经重新排列了问题,这样就更容易解决,而不需要使用计算器或计算机。
绝对值和 abs()函数
一个数的绝对值是没有负号的数。因此,正数不变,但负数变成正数。例如,-4 的绝对值是 4。-7 的绝对值是 7。5 的绝对值(已经是正数)就是 5。
你可以通过减去它们的位置并取差的绝对值来计算两个对象之间的距离。想象一下,白色骑士在位置 4,黑色骑士在位置-2。距离将是 6,因为 4 - -2 是 6,6 的绝对值是 6。
无论数字的顺序如何,它都适用。例如,-2 - 4(即负二减四)是-6,-6 的绝对值也是 6。
Python 的abs()
函数返回一个整数的绝对值。在交互式 shell 中输入以下内容:
>>> abs(-5) 5 >>> abs(42) 42 >>> abs(-10.5) 10.5
-5 的绝对值是 5。正数的绝对值就是这个数,所以 42 的绝对值是 42。即使是带小数的偶数也有绝对值,所以-10.5 的绝对值是 10.5。
总结
大多数编程不需要理解很多数学。直到这一章,我们一直在使用简单的加法和乘法。
笛卡尔坐标系用于描述二维区域中某个位置的位置。坐标有两个数字:x 坐标和 y 坐标。x 轴左右运行,y 轴上下运行。在计算机屏幕上,原点在左上角,坐标向右和向下增加。
在本章中学到的三个数学技巧使得加减正负整数变得容易。第一个技巧是减号会吃掉左边的加号。第二个技巧是两个负号相邻会合并成一个加号。第三个技巧是你可以交换要相加的数字的位置。
本书中的其余游戏都使用这些概念,因为它们都有二维区域。所有图形游戏都需要理解笛卡尔坐标是如何工作的。
十三、声纳寻宝
原文:
inventwithpython.com/invent4thed/chapter13.html
译者:飞龙
本章的声纳寻宝游戏是第一个使用你在第 12 章中学到的笛卡尔坐标系的游戏。这个游戏还使用了数据结构,这只是说它有包含其他列表和类似复杂变量的列表值。随着你编写的游戏变得更加复杂,你需要将数据组织成数据结构。
在本章的游戏中,玩家在海洋的各个地方放置声纳设备来寻找沉没的宝藏。声纳是船只用来在海底找到物体的技术。这个游戏中的声纳设备告诉玩家最近的宝藏箱离他有多远,但没有告诉他方向。但是通过放置多个声纳设备,玩家可以找出宝藏箱的位置。
本章涵盖的主题
- 数据结构
- 毕达哥拉斯定理
remove()
列表方法isdigit()
字符串方法sys.exit()
函数
有 3 个宝箱可供寻找,玩家只有 20 个声纳设备来找到它们。想象一下,你在图 13-1 中看不到宝箱。因为每个声纳设备只能找到离宝箱的距离,而不是宝箱的方向,所以宝藏可能在声纳设备周围的环上的任何地方。
图 13-1:声纳设备的圆环触碰到(隐藏的)宝箱。
但是多个声纳设备一起工作可以将宝藏箱的位置缩小到环交叉的确切坐标处(见图 13-2)。
Figure 13-2: Combining multiple rings shows where treasure chests could be hidden.
声纳寻宝的示例运行
当用户运行声纳寻宝程序时,用户看到的内容如下。玩家输入的文本以粗体显示。
S O N A R ! Would you like to view the instructions? (yes/no) no 1 2 3 4 5 012345678901234567890123456789012345678901234567890123456789 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4 5 `~``~````~`~`~~``~~~~``````~```~~~~````````~``~~~`~~``~~````5 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7 8 `~``~~`~`~~`~~`~~``~```~````~`~```~``~````~~~````~~``~~``~~` 8 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9 10 `~~~~~~`~``~``~~~``~``~~~~`~``~```~`~~``~~~~~~``````~~`~``~~ 10 11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11 12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12 13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13 14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14 012345678901234567890123456789012345678901234567890123456789 1 2 3 4 5 You have 20 sonar device(s) left. 3 treasure chest(s) remaining. Where do you want to drop the next sonar device? (0-59 0-14) (or type quit) 25 5 1 2 3 4 5 012345678901234567890123456789012345678901234567890123456789 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4 5 `~``~````~`~`~~``~~~~````5`~```~~~~````````~``~~~`~~``~~````5 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7 8 `~``~~`~`~~`~~`~~``~```~````~`~```~``~````~~~````~~``~~``~~` 8 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9 10 `~~~~~~`~``~``~~~``~``~~~~`~``~```~`~~``~~~~~~``````~~`~``~~ 10 11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11 12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12 13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13 14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14 012345678901234567890123456789012345678901234567890123456789 1 2 3 4 5 Treasure detected at a distance of 5 from the sonar device. You have 19 sonar device(s) left. 3 treasure chest(s) remaining. Where do you want to drop the next sonar device? (0-59 0-14) (or type quit) 30 5 1 2 3 4 5 012345678901234567890123456789012345678901234567890123456789 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4 5 `~``~````~`~`~~``~~~~````5`~``3~~~~````````~``~~~`~~``~~````5 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7 8 `~``~~`~`~~`~~`~~``~```~````~`~```~``~````~~~````~~``~~``~~` 8 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9 10 `~~~~~~`~``~``~~~``~``~~~~`~``~```~`~~``~~~~~~``````~~`~``~~ 10 11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11 12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12 13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13 14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14 012345678901234567890123456789012345678901234567890123456789 1 2 3 4 5 Treasure detected at a distance of 3 from the sonar device. You have 18 sonar device(s) left. 3 treasure chest(s) remaining. Where do you want to drop the next sonar device? (0-59 0-14) (or type quit) 25 10 1 2 3 4 5 012345678901234567890123456789012345678901234567890123456789 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4 5 `~``~````~`~`~~``~~~~````5`~``3~~~~````````~``~~~`~~``~~````5 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7 8 `~``~~`~`~~`~~`~~``~```~````~`~```~``~````~~~````~~``~~``~~` 8 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9 10 `~~~~~~`~``~``~~~``~``~~~4`~``~```~`~~``~~~~~~``````~~`~``~~ 10 11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11 12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12 13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13 14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14 012345678901234567890123456789012345678901234567890123456789 1 2 3 4 5 Treasure detected at a distance of 4 from the sonar device. You have 17 sonar device(s) left. 3 treasure chest(s) remaining. Where do you want to drop the next sonar device? (0-59 0-14) (or type quit) 29 8 1 2 3 4 5 012345678901234567890123456789012345678901234567890123456789 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4 5 `~``~````~`~`~~``~~~~````X`~``X~~~~````````~``~~~`~~``~~````5 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7 8 `~``~~`~`~~`~~`~~``~```~````~X~```~``~````~~~````~~``~~``~~` 8 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9 10 `~~~~~~`~``~``~~~``~``~~~X`~``~```~`~~``~~~~~~``````~~`~``~~ 10 11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11 12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12 13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13 14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14 012345678901234567890123456789012345678901234567890123456789 1 2 3 4 5 You have found a sunken treasure chest! You have 16 sonar device(s) left. 2 treasure chest(s) remaining. Where do you want to drop the next sonar device? (0-59 0-14) (or type quit) --snip--
声纳寻宝的源代码
在新文件中输入以下源代码并将文件保存为sonar.py。然后按 F5(或 OS X 上的 FN-F5)运行它。如果在输入此代码后出现错误,请使用在线 diff 工具将您输入的代码与本书代码进行比较,网址为www.nostarch.com/inventwithpython#diff
。
sonar.py
# Sonar Treasure Hunt import random import sys import math def getNewBoard(): # Create a new 60x15 board data structure. board = [] for x in range(60): # The main list is a list of 60 lists. board.append([]) for y in range(15): # Each list in the main list has 15 single-character strings. # Use different characters for the ocean to make it more readable. if random.randint(0, 1) == 0: board[x].append('~') else: board[x].append('`') return board def drawBoard(board): # Draw the board data structure. tensDigitsLine = ' ' # Initial space for the numbers down the left side of the board for i in range(1, 6): tensDigitsLine += (' ' * 9) + str(i) # Print the numbers across the top of the board. print(tensDigitsLine) print(' ' + ('0123456789' * 6)) print() # Print each of the 15 rows. for row in range(15): # Single-digit numbers need to be padded with an extra space. if row < 10: extraSpace = ' ' else: extraSpace = '' # Create the string for this row on the board. boardRow = '' for column in range(60): boardRow += board[column][row] print('%s%s %s %s' % (extraSpace, row, boardRow, row)) # Print the numbers across the bottom of the board. print() print(' ' + ('0123456789' * 6)) print(tensDigitsLine) def getRandomChests(numChests): # Create a list of chest data structures (two-item lists of x, y int coordinates). chests = [] while len(chests) < numChests: newChest = [random.randint(0, 59), random.randint(0, 14)] if newChest not in chests: # Make sure a chest is not already here. chests.append(newChest) return chests def isOnBoard(x, y): # Return True if the coordinates are on the board; otherwise, return False. return x >= 0 and x <= 59 and y >= 0 and y <= 14 def makeMove(board, chests, x, y): # Change the board data structure with a sonar device character. Remove treasure chests from the chests list as they are found. # Return False if this is an invalid move. # Otherwise, return the string of the result of this move. smallestDistance = 100 # Any chest will be closer than 100. for cx, cy in chests: distance = math.sqrt((cx - x) * (cx - x) + (cy - y) * (cy - y)) if distance < smallestDistance: # We want the closest treasure chest. smallestDistance = distance smallestDistance = round(smallestDistance) if smallestDistance == 0: # xy is directly on a treasure chest! chests.remove([x, y]) return 'You have found a sunken treasure chest!' else: if smallestDistance < 10: board[x][y] = str(smallestDistance) return 'Treasure detected at a distance of %s from the sonar device.' % (smallestDistance) else: board[x][y] = 'X' return 'Sonar did not detect anything. All treasure chests out of range.' def enterPlayerMove(previousMoves): # Let the player enter their move. Return a two-item list of int xy coordinates. print('Where do you want to drop the next sonar device? (0-59 0-14) (or type quit)') while True: move = input() if move.lower() == 'quit': print('Thanks for playing!') sys.exit() move = move.split() if len(move) == 2 and move[0].isdigit() and move[1].isdigit() and isOnBoard(int(move[0]), int(move[1])): if [int(move[0]), int(move[1])] in previousMoves: print('You already moved there.') continue return [int(move[0]), int(move[1])] print('Enter a number from 0 to 59, a space, then a number from 0 to 14.') def showInstructions(): print('''Instructions: You are the captain of the Simon, a treasure-hunting ship. Your current mission is to use sonar devices to find three sunken treasure chests at the bottom of the ocean. But you only have cheap sonar that finds distance, not direction. Enter the coordinates to drop a sonar device. The ocean map will be marked with how far away the nearest chest is, or an X if it is beyond the sonar device's range. For example, the C marks are where chests are. The sonar device shows a 3 because the closest chest is 3 spaces away. 1 2 3 012345678901234567890123456789012 0 ~~~~`~```~`~``~~~``~`~~``~~~``~`~ 0 1 ~`~`~``~~`~```~~~```~~`~`~~~`~~~~ 1 2 `~`C``3`~~~~`C`~~~~`````~~``~~~`` 2 3 ````````~~~`````~~~`~`````~`~``~` 3 4 ~`~~~~`~~`~~`C`~``~~`~~~`~```~``~ 4 012345678901234567890123456789012 1 2 3 (In the real game, the chests are not visible in the ocean.) Press enter to continue...''') input() print('''When you drop a sonar device directly on a chest, you retrieve it and the other sonar devices update to show how far away the next nearest chest is. The chests are beyond the range of the sonar device on the left, so it shows an X. 1 2 3 012345678901234567890123456789012 0 ~~~~`~```~`~``~~~``~`~~``~~~``~`~ 0 1 ~`~`~``~~`~```~~~```~~`~`~~~`~~~~ 1 2 `~`X``7`~~~~`C`~~~~`````~~``~~~`` 2 3 ````````~~~`````~~~`~`````~`~``~` 3 4 ~`~~~~`~~`~~`C`~``~~`~~~`~```~``~ 4 012345678901234567890123456789012 1 2 3 The treasure chests don't move around. Sonar devices can detect treasure chests up to a distance of 9 spaces. Try to collect all 3 chests before running out of sonar devices. Good luck! Press enter to continue...''') input() print('S O N A R !') print() print('Would you like to view the instructions? (yes/no)') if input().lower().startswith('y'): showInstructions() while True: # Game setup sonarDevices = 20 theBoard = getNewBoard() theChests = getRandomChests(3) drawBoard(theBoard) previousMoves = [] while sonarDevices > 0: # Show sonar device and chest statuses. print('You have %s sonar device(s) left. %s treasure chest(s) remaining.' % (sonarDevices, len(theChests))) x, y = enterPlayerMove(previousMoves) previousMoves.append([x, y]) # We must track all moves so that sonar devices can be updated. moveResult = makeMove(theBoard, theChests, x, y) if moveResult == False: continue else: if moveResult == 'You have found a sunken treasure chest!': # Update all the sonar devices currently on the map. for x, y in previousMoves: makeMove(theBoard, theChests, x, y) drawBoard(theBoard) print(moveResult) if len(theChests) == 0: print('You have found all the sunken treasure chests! Congratulations and good game!') break sonarDevices -= 1 if sonarDevices == 0: print('We\'ve run out of sonar devices! Now we have to turn the ship around and head') print('for home with treasure chests still out there! Game over.') print(' The remaining chests were here:') for x, y in theChests: print(' %s, %s' % (x, y)) print('Do you want to play again? (yes or no)') if not input().lower().startswith('y'): sys.exit()
程序设计
在尝试理解源代码之前,玩几次游戏以了解发生了什么。声纳寻宝游戏使用列表的列表和其他复杂的变量,称为数据结构。数据结构存储值的排列以表示某些东西。例如,在第 10 章中,井字棋板数据结构是一个字符串列表。字符串代表X、O或空格,列表中字符串的索引代表棋盘上的空格。声纳寻宝游戏将有类似的数据结构来表示宝箱和声纳设备的位置。
导入 random、sys 和 math 模块
在程序开始时,我们导入random
、sys
和math
模块:
# Sonar Treasure Hunt import random import sys import math
sys
模块包含exit()
函数,用于立即终止程序。sys.exit()
调用后的代码行将不会运行;程序就像已经到达了结尾一样停止。此函数稍后在程序中使用。
math
模块包含sqrt()
函数,用于找到一个数字的平方根。平方根背后的数学在第 186 页的“寻找最近的宝箱”中有解释。
创建新游戏板
每个新游戏的开始都需要一个新的board
数据结构,由getNewBoard()
创建。声纳寻宝游戏板是一个 ASCII 艺术海洋,周围有 x 和 y 坐标。
当我们使用board
数据结构时,我们希望能够以与访问笛卡尔坐标相同的方式访问其坐标系统。为此,我们将使用一个列表的列表来调用棋盘上的每个坐标,如board[x][y]
。x 坐标在 y 坐标之前——要获取坐标(26, 12)处的字符串,您访问board[26][12]
,而不是board[12][26]
。
def getNewBoard(): # Create a new 60x15 board data structure. board = [] for x in range(60): # The main list is a list of 60 lists. board.append([]) for y in range(15): # Each list in the main list has 15 single-character strings. # Use different characters for the ocean to make it more readable. if random.randint(0, 1) == 0: board[x].append('~') else: board[x].append('`')
board
数据结构是一个字符串列表的列表。第一个列表代表 x 坐标。由于游戏板宽 60 个字符,因此这个第一个列表需要包含 60 个列表。在第 10 行,我们创建了一个for
循环,将 60 个空列表附加到其中。
但board
不仅仅是 60 个空列表的列表。这 60 个列表中的每一个代表游戏板的一个 x 坐标。棋盘上有 15 行,因此这 60 个列表中的每一个必须包含 15 个字符串。第 12 行是另一个for
循环,它添加了 15 个表示海洋的单字符字符串。
海洋将是一堆随机选择的'~'
和'
字符串。波浪(~
)和反引号(`
)字符——键盘上 1 键旁边的波浪符号~
将用于海洋波浪。要确定使用哪个字符,第 14 至 17 行应用此逻辑:如果random.randint()
的返回值为0
,则添加'~'
字符串;否则,添加'`'
字符串。这将使海洋看起来随机而波涛汹涌。
举个小例子,如果board
设置为[['~', '~', '`'], [['~', '~', '`'], [['~', '~', '`'], ['~', '`', '`'], ['`', '~', '`']]
,那么它绘制的棋盘将如下所示:
~~~~` ~~~`~
最后,函数返回第 18 行的`board`变量中的值: ```py return board
绘制游戏板
接下来,我们将定义drawBoard()
方法,每当我们实际绘制新棋盘时都会调用它:
def drawBoard(board):
带有边缘坐标的完整游戏板如下所示:
1 2 3 4 5 012345678901234567890123456789012345678901234567890123456789 0 ~~~`~``~~~``~~~~``~`~`~`~`~~`~~~`~~`~``````~~`~``~`~~```~`~` 0 1 `~`~````~~``~`~```~```~```~`~~~``~~`~~~``````~`~``~~``~~`~~` 1 2 ```~~~~`~`~~```~~~``~````~~`~`~~`~`~`~```~~`~``~~`~`~~~~~~`~ 2 3 ~~~~`~~~``~```~``~~`~`~~`~`~~``~````~`~````~```~`~`~`~`````~ 3 4 ~```~~~~~`~~````~~~~```~~~`~`~`~````~`~~`~`~~``~~`~``~`~``~~ 4 5 `~```~`~`~~`~~~```~~``~``````~~``~`~`~~~~`~~``~~~~~~`~```~~` 5 6 ``~~`~~`~``~`````~````~~``~`~~~~`~~```~~~``~`~`~~``~~~```~~~ 6 7 ``~``~~~~~~```~`~```~~~``~`~``~`~~~~~~```````~~~`~~`~~`~~`~~ 7 8 ~~`~`~~```~``~~``~~~``~~`~`~~`~`~```~```~~~```~~~~~~`~`~~~~` 8 9 ```~``~`~~~`~~```~``~``~~~```~````~```~`~~`~~~~~`~``~~~~~```9 10 `~~~~```~`~````~`~`~~``~`~~~~`~``~``~```~~```````~`~``~`````10 11 ~~`~`~~`~``~`~~~````````````````~~`````~`~~``~`~~~~`~~~`~~`~ 11 12 ~~`~~~~```~~~`````~~``~`~`~~``````~`~~``~```````~~``~~~`~~`~ 12 13 `~``````~~``~`~~~```~~~~```~~`~`~~~`~```````~~`~```~``~`~~~~ 13 14 ~~~``~```~`````~~`~`~``~~`~``~`~~`~`~``~`~``~~``~`~``~```~~~ 14 012345678901234567890123456789012345678901234567890123456789 1 2 3 4 5
drawBoard()
函数中的绘制有四个步骤:
- 创建一个字符串变量,其中 1、2、3、4 和 5 之间用宽间隔分隔。这些数字标记了 x 轴上的 10、20、30、40 和 50 的坐标。
- 使用该字符串在屏幕顶部显示 x 轴坐标。
- 打印海洋的每一行以及屏幕两侧的 y 轴坐标。
- 在底部再次打印 x 轴。在所有侧面都有坐标使得更容易看到在哪里放置声纳设备。
在棋盘顶部绘制 x 坐标
drawBoard()
的第一部分在棋盘顶部打印 x 轴。因为我们希望棋盘的每个部分都是均匀的,所以每个坐标标签只能占用一个字符空间。当坐标编号达到 10 时,每个数字有两位数,因此我们将十位数放在单独的一行上,如图 13-3 所示。x 轴的组织方式是第一行显示十位数,第二行显示个位数。
图 13-3:用于打印游戏板顶部的间距
22 到 24 行创建了棋盘第一行的字符串,这是 x 轴的十位部分:
# Draw the board data structure. tensDigitsLine = ' ' # Initial space for the numbers down the left side of the board for i in range(1, 6): tensDigitsLine += (' ' * 9) + str(i)
第一行上标记十位数的数字之间都有 9 个空格,数字 1 前面有 13 个空格。22 到 24 行创建了一个包含此行的字符串,并将其存储在名为tensDigitsLine
的变量中:
# Print the numbers across the top of the board. print(tensDigitsLine) print(' ' + ('0123456789' * 6)) print()
要打印游戏板顶部的数字,首先打印tensDigitsLine
变量的内容。然后,在下一行打印三个空格(以便这一行正确对齐),然后打印字符串'0123456789'
六次:('0123456789' * 6)
。
绘制海洋
32 到 44 行打印海洋波浪的每一行,包括沿着两侧标记 y 轴的数字:
# Print each of the 15 rows. for row in range(15): # Single-digit numbers need to be padded with an extra space. if row < 10: extraSpace = ' ' else: extraSpace = ''
for
循环打印了 0 到 14 行,以及棋盘两侧的行号。
但我们与 x 轴遇到的问题相同。只有一位数字的数字(例如 0,1,2 等)在打印时只占用一个空间,但是两位数字(例如 10,11 和 12)占用两个空间。如果坐标大小不同,行将不对齐。棋盘将如下所示:
8 ~~`~`~~```~``~~``~~~``~~`~`~~`~`~```~```~~~```~~~~~~`~`~~~~` 8 9 ```~``~`~~~`~~```~``~``~~~```~````~```~`~~`~~~~~`~``~~~~~```9 10 `~~~~```~`~````~`~`~~``~`~~~~`~``~``~```~~```````~`~``~`````10 11 ~~`~`~~`~``~`~~~````````````````~~`````~`~~``~`~~~~`~~~`~~`~ 11
解决方法很简单:在所有个位数前面加一个空格。第 34 到 37 行将变量extraSpace
设置为空格或空字符串。总是打印extraSpace
变量,但它有一个仅用于个位数行号的空格字符。否则,它是一个空字符串。这样,打印时所有的行都会排成一行。
在海洋中打印一行
board
参数是整个海浪的数据结构。第 39 到 44 行读取board
变量并打印一行:
# Create the string for this row on the board. boardRow = '' for column in range(60): boardRow += board[column][row] print('%s%s %s %s' % (extraSpace, row, boardRow, row))
在第 40 行,boardRow
以一个空白字符串开始。第 32 行上的for
循环为要打印的当前海浪行设置row
变量。在这个循环的第 41 行是另一个for
循环,它迭代当前行的每一列。我们通过在这个循环中连接board[column][row]
来生成boardRow
,这意味着将board[0][row]
, board[1][row]
, board[2][row]
等连接到board[59][row]
。这是因为该行包含从索引0
到索引59
的 60 个字符。
第 41 行的for
循环遍历整数0
到59
。在每次迭代中,将板数据结构中的下一个字符复制到boardRow
的末尾。循环结束时,boardRow
中包含了整行的 ASCII 艺术波浪。然后在第 44 行打印boardRow
中的字符串以及行号。
在棋盘底部绘制 X 坐标
第 46 行到 49 行与第 26 行到 29 行类似:
# Print the numbers across the bottom of the board. print() print(' ' + ('0123456789' * 6)) print(tensDigitsLine)
这些线在板的底部打印 x 坐标。
创建随机宝箱
游戏会随机决定隐藏宝箱的位置。宝箱被表示为包含两个整数的列表的列表。这两个整数分别是一个宝箱的 x 和 y 坐标。例如,如果宝箱的数据结构是[[2, 2], [2, 4], [10, 0]]
,那么这意味着有三个宝箱,一个在(2, 2)
,另一个在(2, 4)
,第 三个在(10, 0
)。
getRandomChests()
函数会在随机分配的坐标上创建一定数量的宝箱数据结构:
def getRandomChests(numChests): # Create a list of chest data structures (two-item lists of x, y int coordinates). chests = [] while len(chests) < numChests: newChest = [random.randint(0, 59), random.randint(0, 14)] if newChest not in chests: # Make sure a chest is not already here. chests.append(newChest) return chests
numChets
参数告诉函数要生成多少个宝库。第 54 行的while
循环将迭代,直到所有箱子都被分配了坐标。选择两个随机整数作为第 55 行的坐标。x 坐标可以是从 0 到 59 的任何位置,y 坐标可以是 0 到 14\的任何位置。[random.randint(0, 59), random.randint(0, 14)]
表达式的计算结果为一个列表值,如[2, 2]
或[2, 4]
或[10, 0]
。如果这些坐标还不存在于chests
列表中,则将它们附加到第 57 行的chests
中。
确定移动是否有效
当玩家输入他们想要放置声纳设备的 x 和 y 坐标时,我们需要确保这些数字是有效的。如前所述,移动有效有两个条件:x 坐标必须在 0 到 59 之间,y 坐标必须在 1 到 14 之间。
isOnBoard()
函数使用一个带有and
运算符的简单表达式,将这些条件组合成一个表达式,并确保表达式的每个部分都是True
:
def isOnBoard(x, y): # Return True if the coordinates are on the board; otherwise, return False. return x >= 0 and x <= 59 and y >= 0 and y <= 14
因为我们使用了 and 布尔运算符,如果坐标中有一个是无效的,那么整个表达式的值就会被求值为 False。
在棋盘上放置一个移动
在声纳寻宝游戏中,游戏板被更新以显示一个数字,代表每个声纳设备到最近宝藏箱的距离。因此,当玩家通过给程序提供 x 和 y 坐标来进行移动时,游戏板会根据宝藏箱的位置而改变。
def makeMove(board, chests, x, y): # Change the board data structure with a sonar device character. Remove treasure chests from the chests list as they are found. # Return False if this is an invalid move. # Otherwise, return the string of the result of this move.
makeMove()
函数接受四个参数:游戏板的数据结构,宝藏箱的数据结构,x 坐标和 y 坐标。makeMove()
函数将返回一个描述移动响应的字符串值:
- 如果坐标直接落在宝藏箱上,
makeMove()
返回'You have found a sunken treasure chest!'
。 - 如果坐标距离宝藏箱不超过 9 个单位,
makeMove()
返回'Sonar did not detect anything. All treasure chests out of range.'
(其中%s
被替换为整数距离)。 - 否则,
makeMove()
将返回'You have found a sunken treasure chest!'
。
给定玩家想要放置声纳设备的坐标以及宝藏箱的 x 和 y 坐标列表,你需要一个算法来找出哪个宝藏箱最近。
寻找最近的宝藏箱
68 到 75 行是一个算法,用于确定哪个宝藏箱离声纳设备最近。
smallestDistance = 100 # Any chest will be closer than 100. for cx, cy in chests: distance = math.sqrt((cx - x) * (cx - x) + (cy - y) * (cy - y)) if distance < smallestDistance: # We want the closest treasure chest. smallestDistance = distance
x
和y
参数是整数(比如3
和5
),它们一起表示玩家猜测的游戏板上的位置。chests
变量将具有诸如[5, 0], [0, 2], [4, 2]
的值,表示三个宝藏箱的位置。[图 13-4 说明了这个值。
要找到声纳设备与宝藏箱之间的距离,我们需要进行一些数学运算, 以找到两个 x 和 y 坐标之间的距离。假设我们将声纳设备放在(3, 5)处,并想找到到(4, 2)处宝藏箱的距离。
图 13-4:由[[5, 0], [0, 2], [4, 2]]表示的宝箱
要找到两组 x 和 y 坐标之间的距离,我们将使用毕达哥拉斯定理。这个定理适用于直角三角形——一个角是 90 度的三角形,就像矩形中找到的那种角。毕达哥拉斯定理说,三角形的对角线可以从水平和垂直两边的长度计算出来。图 13-5 显示了在声纳设备(3, 5)和宝箱(4, 2)之间绘制的直角三角形。
图 13-5:在声纳设备上画了一个直角三角形和一个宝藏箱的板子
毕达哥拉斯定理是a² + b² = c²,其中a是水平边的 长度,b是垂直边的长度,c是对角边的长度,或者叫做斜边。这些长度都是平方的,意味着这个数字被自己相乘。将一个数字的平方根称为找到这个数字的平方根,这就是我们要从c²得到c的过程。
让我们使用毕达哥拉斯定理来找到声纳设备在(3, 5)处 和宝藏箱在(4, 2)处之间的距离:
- 要找到a,请从第一个 x 坐标 3 中减去第二个 x 坐标 4:3 - 4 = -1。\
- 要找到a²,请将a乘以a:-1 × -1 = 1。 (负数乘以负数总是正数。)\
- 要找到b,请从第一个 y 坐标 5 中减去第二个 y 坐标 2:5 - 2 = 3。
- 要找到b²,请将b乘以b:3 × 3 = 9。
- 要找到c²,请将a²和b²相加:1 + 9 = 10。
- 要从c²得到c,您需要找到c²的平方根。
我们在第 5 行导入的math
模块有一个名为sqrt()
的平方根函数。在交互式 shell 中输入以下内容:
>>> import math >>> math.sqrt(10) 3.1622776601683795 >>> 3.1622776601683795 * 3.1622776601683795 10.000000000000002
注意,将一个平方根乘以自身会得到一个平方数。(10
末尾的额外2
来自于sqrt()
函数中不可避免的轻微不精确性。)\
通过将c²传递给sqrt()
,我们可以知道声纳设备距离宝藏箱 3.16 个单位。游戏会将其四舍五入为 3。
让我们再次看一下 68 到 70 行:
smallestDistance = 100 # Any chest will be closer than 100. for cx, cy in chests: distance = math.sqrt((cx - x) * (cx - x) + (cy - y) * (cy - y))
第 69 行的for
循环内的代码计算了每个宝箱的距离。第 68 行在循环开始时给smallestDistance
赋予了一个不可能的长距离100
,这样你找到的至少一个宝箱将会在第 73 行被放入smallestDistance
。因为cx - x
代表宝箱和声纳设备之间的水平距离a,(cx - x) * (cx - x)
是我们毕达哥拉斯定理计算的a²。它被加到(cy - y) * (cy - y)
,即b²。这个和是c²,并传递给sqrt()
来得到宝箱和声纳设备之间的距离。
我们想要找到声纳设备和最近宝箱之间的距离,所以如果这个距离小于最小距离,它会在第 73 行被保存为新的最小距离:
if distance < smallestDistance: # We want the closest treasure chest. smallestDistance = distance
当for
循环结束时,你就知道smallestDistance
保存着声纳设备与游戏中所有宝藏箱之间的最短距离。
使用 remove()列表方法删除数值
remove()
列表方法会移除匹配传入参数的第一个数值。例如,将以下内容输入到交互式 shell 中:
>>> x = [42, 5, 10, 42, 15, 42] >>> x.remove(10) >>> x [42, 5, 42, 15, 42]
x
列表中的10
值已被移除。
现在将以下内容输入到交互式 shell 中:
>>> x = [42, 5, 10, 42, 15, 42] >>> x.remove(42) >>> x [5, 10, 42, 15, 42]
请注意,只有第一个42
值被移除了,第二个和第三个仍然存在。remove()
方法只会移除你传递给它的值的第一个出现的位置。
如果你尝试移除列表中不存在的值,你会收到一个错误:
>>> x = [5, 42] >>> x.remove(10) Traceback (most recent call last): File "<stdin>", line 1, in <module> ValueError: list.remove(x): x not in list
与append()
方法类似,remove()
方法是在列表上调用的,不会返回一个列表。你应该使用像x.remove(42)
这样的代码,而不是x = x.remove(42)
。
让我们回到游戏中找到声纳设备和宝箱之间的距离。smallestDistance
等于0
的唯一时机是当声纳设备 的 x 和 y 坐标与宝箱的 x 和 y 坐标相同时。这意味着玩家已经正确猜到了宝箱的位置。
if smallestDistance == 0: # xy is directly on a treasure chest! chests.remove([x, y]) return 'You have found a sunken treasure chest!'
当发生这种情况时,程序使用remove()
列表方法从chests
数据结构中移除这个宝箱的两个整数列表。然后函数返回'You have found a sunken treasure chest!'
。
但是如果smallestDistance
不是0
,玩家没有猜中宝箱的确切位置,那么从第 81 行开始的else
块将被执 行:
else: if smallestDistance < 10: board[x][y] = str(smallestDistance) return 'Treasure detected at a distance of %s from the sonar device.' % (smallestDistance) else: board[x][y] = 'X' return 'Sonar did not detect anything. All treasure chests out of range.'
如果声纳设备到宝藏箱的距离小于 10,第 83 行用smallestDistance
的字符串版本标记棋盘。如果不是,则用'X'
标记棋盘。这 样,玩家就知道每个声纳设备离宝藏箱有多近。如果玩家看到一个 0,他们就知道他们离得很远。
获取玩家的移动
enterPlayerMove()
函数收集玩家下一步移动的 x 和 y 坐标:
def enterPlayerMove(previousMoves): # Let the player enter their move. Return a two-item list of int xy coordinates. print('Where do you want to drop the next sonar device? (0-59 0-14) (or type quit)') while True: move = input() if move.lower() == 'quit': print('Thanks for playing!') sys.exit()
previousMoves
参数是一个包含前几次玩家放置声纳设备的位置的二维整数列表。这些信息将被用来确保玩家不能在已经放置过 声纳设备的地方再次放置。
while
循环会一直询问玩家下一步的移动,直到他们输入一个没有放置声纳设备的位置。玩家也可以输 入'quit'
来退出游戏。在这种情况下,第 96 行调用sys.exit()
函数立即终止程序。
假设玩家没有输入'quit'
,代码会检查输入是否是由一个空格分隔的两个整数。第 98 行调用split()
方法来将move
的新值分割。
move = move.split() if len(move) == 2 and move[0].isdigit() and move[1].isdigit() and isOnBoard(int(move[0]), int(move[1])): if [int(move[0]), int(move[1])] in previousMoves: print('You already moved there.') continue return [int(move[0]), int(move[1])] print('Enter a number from 0 to 59, a space, then a number from 0 to 14.')
如果玩家输入了像'1 2 3'
这样的值,那么split()
返回的列表将是'1','2','3'
。在这种情况下,表达式len(move) == 2
将是False
(move 中的列表应该只有两个数字,因为它表示一个坐标),整个表达式将立即求值为False
。Python 不会检查表达式的 其余部分,因为它采用了短路运算(这在第 139 页的“短路评估”中有描述)。
如果列表的长度是2
,那么这两个值将分别在索引move[0]
和move[1]
。要检查这些值是否是数字(如'2'
或'17'
),你可以使用类似于“[检查字符串是否只包含数字]”第 158 页的isOnlyDigits()
函数。但是 Python 已经有一个可以做到这一点的方法。
字符串方法isdigit()
如果字符串完全由数字组成,则返回True
。否则,它返回False
。在交互式 shell 中输入以下内容:
>>> '42'.isdigit() True >>> 'forty'.isdigit() False >>> ''.isdigit() False >>> 'hello'.isdigit() False >>> x = '10' >>> x.isdigit() True
move[0].isdigit()
和 move[1].isdigit()
必须都为 True
才能使整个条件为 True
。第 99 行条件的最后部分调用 isOnBoard()
函数来检查 x 和 y 坐标是否存在于棋盘上。
如果整个条件为 True
,第 100 行检查移动是否存在于 previousMoves
列表中。如果存在,那么第 102 行的 continue
语句会导致执行返回到第 92 行 while
循环的开始,然后再次要求玩家移动。如果不存在,第 103 行返回一个包含 x 和 y 坐标的两个整数的列表。
为玩家打印游戏说明
showInstructions()
函数是一对 print()
调用,用于打印多行字符串:
def showInstructions(): print('''Instructions: You are the captain of the Simon, a treasure-hunting ship. Your current mission --snip-- Press enter to continue...''') input()
input()
函数给玩家一个机会在打印下一个字符串之前按下 ENTER 键。这是因为 IDLE 窗口一次只能显示那么多文本,我们不希望玩家不得不向上滚动以阅读文本的开头。玩家按下 ENTER 键后,函数返回到调用该函数的行。
游戏循环
现在我们已经输入了游 戏将调用的所有函数,让我们进入游戏的主要部分。在运行程序后玩家看到的第一件事是由第 159 行打印的游戏标题。这是程序的主要部 分,它首先提供玩家说明,然后设置游戏将使用的变量。
print('S O N A R !') print() print('Would you like to view the instructions? (yes/no)') if input().lower().startswith('y'): showInstructions() while True: # Game setup sonarDevices = 20 theBoard = getNewBoard() theChests = getRandomChests(3) drawBoard(theBoard) previousMoves = []
表达式 input().lower().startswith('y')
允许玩家请求说明,如果玩家输入以 'y'
或 'Y'
开头的字符串,则该表达式 的值为 True
。例如:
如果这个条件是True
,则在第 163 行调用showInstructions()
。否则,游戏开始。
在第 167 到 171 行设置了几个变量;这些 在表 13-1 中有描述。
表 13-1: 主游戏循环中使用的变量
变量 | 描述 |
sonarDevices |
玩家剩余的声纳设备(和回合)数量。 |
theBoard |
用于这个游戏的棋盘数据结构。 |
theChests |
宝箱数据结构的列表 。getRandomChests() 在棋盘上的随机位置返回三个宝藏宝箱的列表。 |
previousMoves |
玩家在游戏中所做的所有 x 和 y 移动的 列表。 |
我们很快就要使用这些变量,所以在继续之前,请确保查看它们的描述!
为玩家显示游戏状态
第 173 行的while
循 环会在玩家剩余声纳设备时执行,并打印一条消息告诉他们剩下多少声纳设备和宝藏。
while sonarDevices > 0: # Show sonar device and chest statuses. print('You have %s sonar device(s) left. %s treasure chest(s) remaining.' % (sonarDevices, len(theChests)))
打印剩余设备数量后,while
循环继续执行。
处理玩家的移动
第 177 行仍然是while
循环的一部分,并使用多重赋值将x
和y
变量分配给表示玩家移动坐标的两项列表,该列表由enterPlayerMove()
返回。我们将传入previousMoves
,以便enterPlayerMove()
的代码可以确保玩家不会重复之前的移动。
x, y = enterPlayerMove(previousMoves) previousMoves.append([x, y]) # We must track all moves so that sonar devices can be updated. moveResult = makeMove(theBoard, theChests, x, y) if moveResult == False: continue
然后将x
和y
变量附加到previousMoves
列表的末尾。previousMoves
变量是玩家在这个游戏中所做的每一步移动的 x 和 y 坐标的列表。这个列表稍后在程序的第 177 和 186 行中使用。
x
、y
、theBoard
和theChests
变量都传递给第 180 行的makeMove()
函数。这个函数对棋盘上放置声纳设备进行必要的修改。
如果makeMove()
返回False
,那么传递给它的x
和y
值存在问题。continue
语句将执行返回到第 173 行的while
循环的开始,再次要求玩家输入 x 和 y 坐标。
寻找沉没的宝藏箱
如果makeMove()
没有返回False
,它会返回一个表示该移动结果的字符串。如果这个字符串是'You have found a sunken treasure chest!'
,那么棋盘上所有的声纳设备都应该更新以侦测到下一个最近的宝藏箱。
else: if moveResult == 'You have found a sunken treasure chest!': # Update all the sonar devices currently on the map. for x, y in previousMoves: makeMove(theBoard, theChests, x, y) drawBoard(theBoard) print(moveResult)
所有声纳设备的 x 和 y 坐标都在previousMoves
中。通过在第 186 行对previousMoves
进行迭代,您可以再次将所有这些 x 和 y 坐标 传递给makeMove()
函数,以便在棋盘上重新绘制这些值。因为程序在这里不打印任何新文本,玩家并不会意识到程序正在重做所有先前的移动。它看起来只是棋盘自己更新了。
检查玩家是否获胜
请记住,makeMove()
函数修改了您发送给它的theChests
列表。因为theChests
是一个列表,所以在函数内部对其进行的任何更改都将在函数执行返回后持续存在。当找到宝藏箱时,makeMove()
函数会从theChests
中移除物品,因此最终(如果玩家继续猜对)所有的宝藏箱都将被移除。(请记住,“宝藏箱”指的是theChests
列表中包含的 x 和 y 坐标的两个项目列表。)
if len(theChests) == 0: print('You have found all the sunken treasure chests! Congratulations and good game!') break
当板上所有的宝箱都被找到并从theChests
中移除后,theChests
列表的长度将为0
。当这种情况发生时,代码会向玩家显示 祝贺的消息,然后执行break
语句来跳出这个while
循环。执行将会移动到第 197 行,即while
块之后的第一行。
检查玩家 是否输了
第 195 行是从第 173 行开始的while
循环的最后一行。
sonarDevices -= 1
该程序减少了sonarDevices
变量,因为玩家使用了一个声纳设备。如果玩家继续错过宝藏,最终sonarDevices
将减少到0
。 在这一行之后,执行会跳转回到第 173 行,这样它可以重新评估while
语句的条件(即sonarDevices > 0
)。
如果sonarDevices
是0
,那么条件将是False
,执行将继续在第 197 行的while
块之外。但在那之前,条件将保持为True
,玩家可以继续猜测。
if sonarDevices == 0: print('We\'ve run out of sonar devices! Now we have to turn the ship around and head') print('for home with treasure chests still out there! Game over.') print(' The remaining chests were here:') for x, y in theChests: print(' %s, %s' % (x, y))
197 行是while
循环之外的第一行。当执行到这一点时,游戏结束了。如果sonarDevices
是0
,你就知道玩家在找到所有宝箱 之前用完了声纳设备并且输了。
198 到 200 行会告诉玩家他们输了。201 行的for
循环将遍历theChests
中剩下的宝箱,并显示它们 的位置,这样玩家就可以看到宝箱隐藏的地方。
使用 sys.exit()函数终止程序
无论输赢,程序都会让玩家决定是否要继续 玩。如果玩家没有输入'yes'
或'Y'
,或者输入了以字母y开头的其他字符串,那么not input().lower().startswith('y')
就会被求值为True
,并且sys.exit()
函数就会被执行。这会导致程序终止。
print('Do you want to play again? (yes or no)') if not input().lower().startswith('y'): sys.exit()
否则,执行跳转回到第 165 行的while
循环的开始,开始新的游戏。
总结
还记得我们的井字游戏是如何用 1 到 9 编号的吗?这种坐标系统对于少于 10 个空间的棋盘可能还可以。但是声纳寻宝棋盘有 900 个空间!我们在第 12 章中学到的笛卡尔坐标系真的让所有这些空间都变得可管理,特别是当我们的游戏需要找到棋盘上两点之间的距离时。
使用笛卡尔坐标系的游戏中的位置可以存储在一个列表的列表中,其中第一个索引是 x 坐标,第二个索引是 y 坐标。这样就可以很容易地使用board[x][y]
访问坐标。
这些数据结构(比如用于海洋和宝藏位置的数据结构)使得能够将复杂的概念表示为数据,而你的游戏程序主要是关于修改这些数据结构。
在下一章中,我们将把字母表示为数字。通过将文本表示为数字,我们可以对其进行数学运算,以加密或解密秘密消息。
十四、凯撒密码
原文:
inventwithpython.com/invent4thed/chapter14.html
译者:飞龙
本章的程序并不是一个真正的游戏,但它仍然很有趣。该程序将把普通英语转换成秘密代码。它还可以将秘密代码转换回普通英语。只有知道秘密代码的人才能理解这些消息。
由于这个程序操纵文本将其转换为秘密消息,你将学习几个新的用于操纵字符串的函数和方法。你还将学习程序如何能够像处理数字一样处理文本字符串。
本章涉及的主题
- 密码学和密码
- 密文、明文、密钥和符号
- 加密和解密
- 凯撒密码
find()
字符串方法- 密码分析
- 穷举法技术
密码学和加密
编写秘密代码的科学称为密码学。几千年来,密码学使得发送只有发件人和收件人能够阅读的秘密消息成为可能,即使有人捕获信使并阅读了编码消息。秘密代码系统称为密码。本章程序使用的密码称为凯撒密码。
在密码学中,我们称要保密的消息为明文。假设我们有一条看起来像这样的明文消息:
There is a clue behind the bookshelf.
将明文转换为编码消息称为加密明文。明文被加密成密文。密文看起来像随机字母,所以我们不能通过查看密文来理解原始明文是什么。以下是前面的示例加密成密文:
aolyl pz h jsBl ilopuk Aol ivvrzolsm.
如果你知道用于加密消息的密码,你可以将密文解密回明文。(解密是加密的反向操作。)
许多密码使用密钥,这些密钥是让你解密使用特定密码加密的密文的秘密值。可以将密码看作是门锁。只有用特定的钥匙才能打开它。
如果你对编写密码学程序感兴趣,可以阅读我的书使用 Python 黑客秘密密码。可以从http://inventwithpython.com/hacking/免费下载。
凯撒密码的工作原理
凯撒密码是有史以来最早的密码之一。在这种密码中,你通过用“移位”字母替换消息中的每个字母来加密消息。在密码学中,加密的字母称为符号,因为它们可以是字母、数字或任何其他符号。如果你将字母 A 向后移动一个空格,你会得到字母 B。如果你将字母 A 向后移动两个空格,你会得到字母 C。图 14-1 显示了一些字母向后移动三个空格的示例。
图 14-1:凯撒密码将字母向后移动三个空格。这里,B 变成了 E。
要获得每个移位后的字母,可以画一排方框,每个方框内写上字母表中的每个字母。然后在下面再画一排方框,但是从某个特定的空格开始写字母。当你到达明文字母表的末尾时,回到 A。图 14-2 显示了一个字母向后移动三个空格的示例。
图 14-2:整个字母表向后移动三个空格
凯撒密码中,你将字母移动的空格数(在 1 到 26 之间)就是密钥。除非你知道密钥(用于加密消息的数字),否则你将无法解密秘密代码。图 14-2 中的示例显示了密钥 3 的字母转换。
注意
虽然有 26 个可能的密钥,但使用 26 加密消息将导致密文与明文完全相同!
如果您使用密钥 3 加密明文单词 HOWDY,则:
- 字母 H 变成 K。
- 字母 O 变成 R。
- 字母 W 变成 Z。
- 字母 D 变成 G。
- 字母 Y 变成 B。
因此,使用密钥 3 的 HOWDY 的密文变为 KRZGB。要使用密钥 3 解密 KRZGB,我们从底部的框返回到顶部。
如果您想将小写字母与大写字母区分开,那么请将另外 26 个框添加到已有的框中,并用 26 个小写字母填充它们。现在使用密钥 3,字母 Y 变成 b,如图 14-3 所示。
图 14-3:整个字母表,现在包括小写字母,向后移动三个空格
密码的工作方式与仅使用大写字母时相同。实际上,如果您想要使用另一种语言字母表的字母,您可以写下这些字母的框,以创建您的密码。
凯撒密码的示例运行
这是凯撒密码程序加密消息的示例运行:
Do you wish to encrypt or decrypt a message? encrypt Enter your message: The sky above the port was the color of television, tuned to a dead channel. Enter the key number (1-52) 13 Your translated text is: gur FxL noBIr Gur CBEG JnF Gur pByBE Bs GryrIvFvBA, GHArq GB n qrnq punAAry.
现在运行程序并解密刚刚加密的文本:
Do you wish to encrypt or decrypt a message? decrypt Enter your message: gur FxL noBIr Gur CBEG JnF Gur pByBE Bs GryrIvFvBA, GHArq GB n qrnq punAAry. Enter the key number (1-52) 13 Your translated text is: The sky above the port was the color of television, tuned to a dead channel.
如果您没有使用正确的密钥解密,文本将无法正确解密:
Do you wish to encrypt or decrypt a message? decrypt Enter your message: gur FxL noBIr Gur CBEG JnF Gur pByBE Bs GryrIvFvBA, GHArq GB n qrnq punAAry. Enter the key number (1-52) 15 Your translated text is: Rfc qiw YZmtc rfc nmpr uYq rfc amjmp md rcjctgqgml, rslcb rm Y bcYb afYllcj.
凯撒密码的源代码
输入此凯撒密码程序的源代码,然后将文件保存为cipher.py。
如果在输入此代码后出现错误,请使用在线差异工具将您输入的代码与本书代码进行比较,网址为www.nostarch.com/inventwithpython#diff
。
cipher.py
# Caesar Cipher SYMBOLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' MAX_KEY_SIZE = len(SYMBOLS) def getMode(): while True: print('Do you wish to encrypt or decrypt a message?') mode = input().lower() if mode in ['encrypt', 'e', 'decrypt', 'd']: return mode else: print('Enter either "encrypt" or "e" or "decrypt" or "d".') def getMessage(): print('Enter your message:') return input() def getKey(): key = 0 while True: print('Enter the key number (1-%s)' % (MAX_KEY_SIZE)) key = int(input()) if (key >= 1 and key <= MAX_KEY_SIZE): return key def getTranslatedMessage(mode, message, key): if mode[0] == 'd': key = -key translated = '' for symbol in message: symbolIndex = SYMBOLS.find(symbol) if symbolIndex == -1: # Symbol not found in SYMBOLS. # Just add this symbol without any change. translated += symbol else: # Encrypt or decrypt. symbolIndex += key if symbolIndex >= len(SYMBOLS): symbolIndex -= len(SYMBOLS) elif symbolIndex < 0: symbolIndex += len(SYMBOLS) translated += SYMBOLS[symbolIndex] return translated mode = getMode() message = getMessage() key = getKey() print('Your translated text is:') print(getTranslatedMessage(mode, message, key))
设置最大密钥长度
加密和解密过程是彼此的相反,但它们共享大部分相同的代码。让我们看看每行是如何工作的:
# Caesar Cipher SYMBOLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' MAX_KEY_SIZE = len(SYMBOLS)
MAX_KEY_SIZE
是一个常量,存储SYMBOLS
的长度(52
)。这个常量提醒我们,在这个程序中,密码中使用的密钥应该始终在 1 和 52 之间。
决定加密或解密消息
getMode()
函数让用户决定他们想要使用程序的加密或解密模式:
def getMode(): while True: print('Do you wish to encrypt or decrypt a message?') mode = input().lower() if mode in ['encrypt', 'e', 'decrypt', 'd']: return mode else: print('Enter either "encrypt" or "e" or "decrypt" or "d".')
第 8 行调用input()
让用户输入他们想要的模式。然后在这个字符串上调用lower()
方法,以返回字符串的小写版本。从input().lower()
返回的值存储在mode
中。if
语句的条件检查存储在mode
中的字符串是否存在于['encrypt', 'e', 'decrypt', 'd']
列表中。
只要mode
等于'encrypt'
、'e'
、'decrypt'
或'd'
,此函数将返回mode
中的字符串。因此,getMode()
将返回字符串mode
。如果用户输入的内容不是'encrypt'
、'e'
、'decrypt'
或'd'
,则while
循环将再次询问他们。
从玩家获取消息
getMessage()
函数只是从用户那里获取要加密或解密的消息并返回它:
def getMessage(): print('Enter your message:') return input()
input()
的调用与return
结合在一起,这样我们只使用一行而不是两行。
从玩家获取密钥
getKey()
函数让玩家输入他们将用于加密或解密消息的密钥:
def getKey(): key = 0 while True: print('Enter the key number (1-%s)' % (MAX_KEY_SIZE)) key = int(input()) if (key >= 1 and key <= MAX_KEY_SIZE): return key
while
循环确保函数保持循环,直到用户输入有效的密钥。这里的有效密钥是介于整数值1
和52
之间的值(记住MAX_KEY_SIZE
是52
,因为SYMBOLS
变量中有 52 个字符)。getKey()
函数然后返回这个密钥。第 22 行将key
设置为用户输入的整数版本,因此getKey()
返回一个整数。
加密或解密消息
getTranslatedMessage()
函数执行实际的加密和解密:
def getTranslatedMessage(mode, message, key): if mode[0] == 'd': key = -key translated = ''
它有三个参数:
mode
这将设置函数为加密模式或解密模式。
message
这是要加密(或解密)的明文(或密文)。
key
这是在这个密码中使用的密钥。
第 27 行检查mode
变量中的第一个字母是否为字符串'd'
。如果是,则程序处于解密模式。解密和加密模式之间唯一的区别是,在解密模式中,密钥设置为其负版本。例如,如果key
是整数22
,那么解密模式将其设置为-22
。原因在“加密或解密每个字母”中的第 205 页中有解释。
translated
变量将包含结果的字符串:密文(如果您正在加密)或明文(如果您正在解密)。它起初为空字符串,并且已加密或解密的字符连接到其末尾。但是,在我们开始将字符连接到translated
之前,我们需要加密或解密文本,我们将在getTranslatedMessage()
的其余部分中进行。
使用 find()字符串方法查找传递的字符串
为了将字母移动以进行加密或解密,我们首先需要将它们转换为数字。SYMBOLS
字符串中每个字母的数字将是它出现的索引。由于字母 A 在SYMBOLS[0]
处,数字0
将表示大写字母 A。如果我们想要使用密钥 3 对其进行加密,我们只需使用 0 + 3 来获取加密字母的索引:SYMBOLS[3]
或'D'
。
我们将使用find()
字符串方法,该方法在调用该方法的字符串中查找传递的字符串的第一次出现。在交互式 shell 中输入以下内容:
>>> 'Hello world!'.find('H') 0 >>> 'Hello world!'.find('o') 4 >>> 'Hello world!'.find('ell') 1
'Hello world!'.find('H')
返回0
,因为在字符串'Hello world!'
的第一个索引处找到了'H'
。请记住,索引从0
开始,而不是1
。代码'Hello world!'.find('o')
返回4
,因为小写的'o'
首次出现在'Hello'
的末尾。find()
方法在第一次出现后停止查找,所以'world'
中的第二个'o'
不重要。您还可以查找多于一个字符的字符串。字符串'ell'
从索引1
开始找到。
如果找不到传递的字符串,则find()
方法返回-1
:
>>> 'Hello world!'.find('xyz') -1
让我们回到凯撒密码程序。第 31 行是一个for
循环,它对message
字符串中的每个字符进行迭代:
for symbol in message: symbolIndex = SYMBOLS.find(symbol) if symbolIndex == -1: # Symbol not found in SYMBOLS. # Just add this symbol without any change. translated += symbol
find()
方法用于第 32 行获取symbol
中字符串的索引。如果find()
返回-1
,则symbol
中的字符将被添加到translated
中而不会发生任何更改。这意味着任何不属于字母表的字符,例如逗号和句号,都不会被更改。
加密或解密每个字母
一旦找到了字母的索引号,将密钥添加到数字中将执行移位并为您提供加密字母的索引。
第 38 行执行此加法以获得加密(或解密)字母。
# Encrypt or decrypt. symbolIndex += key
请记住,在第 28 行,我们将key
中的整数变为负数以进行解密。现在添加密钥的代码将对其进行减法,因为添加负数与减法相同。
但是,如果此加法(或减法,如果key
为负数)导致symbolIndex
超过SYMBOLS
的最后一个索引,我们需要将其环绕到列表的开头0
。这由从第 40 行开始的if
语句处理:
if symbolIndex >= len(SYMBOLS): symbolIndex -= len(SYMBOLS) elif symbolIndex < 0: symbolIndex += len(SYMBOLS) translated += SYMBOLS[symbolIndex]
第 40 行检查symbolIndex
是否已经超过了SYMBOLS
字符串的最后一个索引,方法是将其与SYMBOLS
字符串的长度进行比较。如果是,第 41 行将SYMBOLS
的长度从symbolIndex
中减去。如果symbolIndex
现在是负数,则索引需要环绕到SYMBOLS
字符串的另一侧。第 42 行检查在添加解密密钥后symbolIndex
的值是否为负数。如果是,第 43 行将SYMBOLS
的长度添加到symbolIndex
中。
symbolIndex
变量现在包含了正确加密或解密符号的索引。SYMBOLS[symbolIndex]
将指向这个索引的字符,并且这个字符将被添加到第 45 行的translated
的末尾。
执行回到第 31 行,重复这个过程,直到message
中的下一个字符。一旦循环结束,函数将在第 46 行返回加密(或解密)字符串translated
:
return translated
getTranslatedMessage()
函数中的最后一行返回translated
字符串。
开始程序
程序的开始调用了之前定义的三个函数,以从用户那里获取mode
、message
和key
:
mode = getMode() message = getMessage() key = getKey() print('Your translated text is:') print(getTranslatedMessage(mode, message, key))
这三个值被传递给getTranslatedMessage()
,其返回值(translated
字符串)被打印给用户。
扩展符号
如果你想加密数字、空格和标点符号,只需将它们添加到第 2 行的SYMBOLS
字符串中。例如,你可以通过将第 2 行更改为以下内容,让你的密码程序加密数字、空格和标点符号:
SYMBOLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz 123 4567890!@#$%^&*()'
请注意,SYMBOLS
字符串在小写字母z
后面有一个空格字符。
如果你愿意,你可以在这个列表中添加更多的字符。而且你不需要改变你的程序的其余部分,因为所有需要字符列表的代码行都只使用SYMBOLS
常量。
只需确保每个字符在字符串中只出现一次。此外,您需要使用与加密时相同的SYMBOLS
字符串来解密您的消息。
暴力破解技术
这就是整个凯撒密码。然而,虽然这种密码可能会愚弄一些不懂密码学的人,但对于了解密码分析的人来说,它不会保密消息。虽然密码学是制作密码的科学,但密码分析是破译密码的科学。
密码学的整个目的是确保如果其他人拿到了加密消息,他们无法弄清原始文本。假设我们是密码破译者,我们手上只有这个加密文本:
LwCjBA uiG vwB jm xtmiAivB, jCB kmzBiqvBG qA ijACzl.
暴力破解是一种尝试每个可能的密钥直到找到正确密钥的技术。因为只有 52 个可能的密钥,所以对于密码分析家来说,编写一个解密每个可能密钥的黑客程序将是很容易的。然后他们可以寻找解密为普通英语的密钥。让我们给程序添加一个暴力破解的功能。
添加暴力破解模式
首先,更改第 7、9 和 12 行,这些行在getMode()
函数中,看起来像下面这样(修改部分用粗体标出):
def getMode(): while True: print('Do you wish to encrypt or decrypt or brute-force a message?') mode = input().lower() if mode in ['encrypt', 'e', 'decrypt', 'd', 'brute', 'b']: return mode else: print('Enter either "encrypt" or "e" or "decrypt" or "d" or "brute" or "b".')
这段代码将允许用户选择暴力破解作为一种模式。
接下来,对程序的主要部分进行以下更改:
mode = getMode() message = getMessage() if mode[0] != 'b': key = getKey() print('Your translated text is:') if mode[0] != 'b': print(getTranslatedMessage(mode, message, key)) else: for key in range(1, MAX_KEY_SIZE + 1): print(key, getTranslatedMessage('decrypt', message, key))
如果用户不处于暴力破解模式,他们会被要求输入一个密钥,然后进行原始的getTranslatedMessage()
调用,并打印出翻译后的字符串。
然而,如果用户处于暴力破解模式,那么getTranslatedMessage()
循环迭代从1
一直到MAX_KEY_SIZE
(即52
)。请记住,range()
函数返回一个整数列表,最多到第二个参数,但不包括第二个参数,这就是为什么我们要加上+1
。然后程序将打印出消息的每种可能的翻译(包括在翻译中使用的密钥号)。以下是对这个修改后程序的示例运行:
Do you wish to encrypt or decrypt or brute-force a message? brute Enter your message: LwCjBA uiG vwB jm xtmiAivB, jCB kmzBiqvBG qA ijACzl. Your translated text is: 1 KvBiAz thF uvA il wslhzhuA, iBA jlyAhpuAF pz hizByk. 2 JuAhzy sgE tuz hk vrkgygtz, hAz ikxzgotzE oy ghyAxj. 3 Itzgyx rfD sty gj uqjfxfsy, gzy hjwyfnsyD nx fgxzwi. 4 Hsyfxw qeC rsx fi tpiewerx, fyx givxemrxC mw efwyvh. 5 Grxewv pdB qrw eh sohdvdqw, exw fhuwdlqwB lv devxug. 6 Fqwdvu ocA pqv dg rngcucpv, dwv egtvckpvA ku cduwtf. 7 Epvcut nbz opu cf qmfbtbou, cvu dfsubjouz jt bctvse. 8 Doubts may not be pleasant, but certainty is absurd. 9 Cntasr lZx mns ad okdZrZms, ats bdqsZhmsx hr Zartqc. 10 BmsZrq kYw lmr Zc njcYqYlr, Zsr acprYglrw gq YZqspb. 11 AlrYqp jXv klq Yb mibXpXkq, Yrq ZboqXfkqv fp XYproa. 12 zkqXpo iWu jkp Xa lhaWoWjp, Xqp YanpWejpu eo WXoqnZ. --snip--
在查看每一行后,你会发现第八条消息不是无意义的,而是普通的英语!密码分析家可以推断出这个加密文本的原始密钥必须是8
。这种暴力破解方法在凯撒大帝和罗马帝国时代是很难做到的,但今天我们有计算机可以在短时间内快速地处理数百万甚至数十亿个密钥。
总结
计算机擅长做数学。当我们创建一个系统将某些信息转换为数字(就像我们用文本和序数或空间和坐标系这样做时),计算机程序可以快速高效地处理这些数字。编写程序的一个重要部分是弄清楚如何将要操作的信息表示为 Python 能理解的值。
虽然我们的凯撒密码程序可以加密消息,使它们对那些需要用铅笔和纸来解密的人保持秘密,但该程序无法对那些知道如何让计算机处理信息的人保密。(我们的穷举模式证明了这一点。)
在第 15 章中,我们将创建反转棋(也称为黑白棋或奥赛罗)。这个游戏的人工智能比第 10 章中玩井字游戏的人工智能要先进得多。事实上,它非常优秀,以至于大部分时间你都无法打败它!