十、井字棋
原文:
inventwithpython.com/invent4thed/chapter10.html译者:飞龙
本章介绍了一个井字棋游戏。井字棋通常由两个人玩。一个玩家是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 轴上下运行。在计算机屏幕上,原点在左上角,坐标向右和向下增加。
在本章中学到的三个数学技巧使得加减正负整数变得容易。第一个技巧是减号会吃掉左边的加号。第二个技巧是两个负号相邻会合并成一个加号。第三个技巧是你可以交换要相加的数字的位置。
本书中的其余游戏都使用这些概念,因为它们都有二维区域。所有图形游戏都需要理解笛卡尔坐标是如何工作的。