十五、反转棋游戏
原文:
inventwithpython.com/invent4thed/chapter15.html
译者:飞龙
协议:CC BY-NC-SA 4.0
在本章中,我们将制作反转棋,也称为黑白棋或奥赛罗。这个双人棋盘游戏是在网格上进行的,因此我们将使用带有 x 和 y 坐标的笛卡尔坐标系。我们的游戏版本将具有比第 10 章中的井字棋 AI 更先进的计算机 AI。事实上,这个 AI 非常强大,几乎每次你玩都会打败你。(每次我和它对战时都输!)
本章涵盖的主题
-
如何玩反转棋
-
bool()
函数 -
在反转棋棋盘上模拟移动
-
编写反转棋 AI
如何玩反转棋
反转棋有一个 8×8 的棋盘和一面是黑色,一面是白色的棋子(我们的游戏将使用O和X)。起始棋盘看起来像图 15-1。
图 15-1:起始的反转棋棋盘上有两个白色棋子和两个黑色棋子。
两名玩家轮流在棋盘上放置自己选择的颜色(黑色或白色)的棋子。当玩家在棋盘上放置一个棋子时,任何对手的棋子如果处于新棋子和玩家颜色的其他棋子之间,就会被翻转。例如,当白棋玩家在空格(5, 6)放置一个新的白棋,如图 15-2 中所示,黑棋在(5, 5)处于两个白棋之间,因此它会翻转成白色,如图 15-3 中所示。游戏的目标是以自己的颜色结束比对手的颜色拥有更多的棋子。
图 15-2:白棋放置了一个新的棋子。
图 15-3:白棋的移动导致黑棋的一个棋子翻转。
黑棋接下来可以做类似的移动,将黑棋放在(4, 6)处,这将翻转(4, 5)处的白棋。这将导致棋盘看起来像图 15-4。
图 15-4:黑棋放置了一个新的棋子,翻转了白棋的一个棋子。
只要它们处于玩家新棋子和该颜色的现有棋子之间,所有方向的棋子都会被翻转。在图 15-5 中,白棋在(3, 6)处放置一个棋子,并在两个方向上翻转了黑棋(由线标记)。结果如图 15-6 所示。
每名玩家可以在一两步内迅速翻转棋盘上的许多棋子。玩家必须始终进行至少翻转一个棋子的移动。游戏在任一玩家无法移动或棋盘完全填满时结束。拥有自己颜色的棋子最多的玩家获胜。
图 15-5:白棋在(3, 6)处的第二步将翻转黑棋的两个棋子。
图 15-6:白棋的第二步后的棋盘。
我们为这个游戏制作的 AI 将寻找它可以采取的棋盘上的任何角落移动。如果没有角落移动可用,计算机将选择夺取最多棋子的移动。
反转棋的示例运行
当用户运行反转棋程序时,用户看到的内容如下。玩家输入的文本是粗体。
Welcome to Reversegam!
Do you want to be X or O?
x
The player will go first.12345678+--------+
1| |1
2| |2
3| |3
4| XO |4
5| OX |5
6| |6
7| |7
8| |8+--------+12345678
You: 2 points. Computer: 2 points.
Enter your move, "quit" to end the game, or "hints" to toggle hints.
5312345678+--------+
1| |1
2| |2
3| X |3
4| XX |4
5| OX |5
6| |6
7| |7
8| |8+--------+12345678
You: 4 points. Computer: 1 points.
Press Enter to see the computer's move.--snip--12345678+--------+
1|OOOOOOOO|1
2|OXXXOOOO|2
3|OXOOOOOO|3
4|OXXOXXOX|4
5|OXXOOXOX|5
6|OXXXXOOX|6
7|OOXXOOOO|7
8|OOXOOOOO|8+--------+12345678
X scored 21 points. O scored 43 points.
You lost. The computer beat you by 22 points.
Do you want to play again? (yes or no)
no
如你所见,AI 在打败我时表现得非常出色,43 比 21。为了帮助玩家,我们将编程游戏提供提示。玩家可以将hints
作为他们的移动输入,这将切换提示模式。在提示模式下,玩家可以在棋盘上看到所有可能的移动,显示为句点(.),就像这样:
12345678+--------+
1| |1
2| . |2
3| XO. |3
4| XOX |4
5| OOO |5
6| . . |6
7| |7
8| |8+--------+12345678
如你所见,根据棋盘上显示的提示,玩家可以在(4, 2)、(5, 3)、(4, 6)或(6, 6)处移动。
反转棋的源代码
与我们之前的游戏相比,反转棋是一个庞大的程序。它将近 300 行长!但不用担心:其中许多是注释或空行,用来分隔代码并使其更易读。
与我们其他的程序一样,我们将首先创建几个函数来执行与反转棋相关的任务,主要部分将调用这些函数。大约前 250 行代码是为这些辅助函数而写的,最后 30 行代码实现了反转棋游戏本身。
如果在输入此代码后出现错误,请使用在线 diff 工具将您的代码与本书代码进行比较,网址为www.nostarch.com/inventwithpython#diff
。
reversegam.py
# Reversegam: a clone of Othello/Reversi
import random
import sys
WIDTH = 8 # Board is 8 spaces wide.
HEIGHT = 8 # Board is 8 spaces tall.
def drawBoard(board):# Print the board passed to this function. Return None.print(' 12345678')print(' +--------+')for y in range(HEIGHT):print('%s|' % (y+1), end='')for x in range(WIDTH):print(board[x][y], end='')print('|%s' % (y+1))print(' +--------+')print(' 12345678')def getNewBoard():# Create a brand-new, blank board data structure.board = []for i in range(WIDTH):board.append([' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '])return boarddef isValidMove(board, tile, xstart, ystart):# Return False if the player's move on space xstart, ystart isinvalid.# If it is a valid move, return a list of spaces that would becomethe player's if they made a move here.if board[xstart][ystart] != ' ' or not isOnBoard(xstart, ystart):return Falseif tile == 'X':otherTile = 'O'else:otherTile = 'X'tilesToFlip = []for xdirection, ydirection in [[0, 1], [1, 1], [1, 0], [1, -1],[0, -1], [-1, -1], [-1, 0], [-1, 1]]:x, y = xstart, ystartx += xdirection # First step in the x directiony += ydirection # First step in the y directionwhile isOnBoard(x, y) and board[x][y] == otherTile:# Keep moving in this x & y direction.x += xdirectiony += ydirectionif isOnBoard(x, y) and board[x][y] == tile:# There are pieces to flip over. Go in the reversedirection until we reach the original space, noting allthe tiles along the way.while True:x -= xdirectiony -= ydirectionif x == xstart and y == ystart:breaktilesToFlip.append([x, y])if len(tilesToFlip) == 0: # If no tiles were flipped, this is not avalid move.return Falsereturn tilesToFlipdef isOnBoard(x, y):# Return True if the coordinates are located on the board.return x >= 0 and x <= WIDTH - 1 and y >= 0 and y <= HEIGHT - 1def getBoardWithValidMoves(board, tile):# Return a new board with periods marking the valid moves the playercan make.boardCopy = getBoardCopy(board)for x, y in getValidMoves(boardCopy, tile):boardCopy[x][y] = '.'return boardCopydef getValidMoves(board, tile):# Return a list of [x,y] lists of valid moves for the given playeron the given board.validMoves = []for x in range(WIDTH):for y in range(HEIGHT):if isValidMove(board, tile, x, y) != False:validMoves.append([x, y])return validMovesdef getScoreOfBoard(board):# Determine the score by counting the tiles. Return a dictionarywith keys 'X' and 'O'.xscore = 0oscore = 0for x in range(WIDTH):for y in range(HEIGHT):if board[x][y] == 'X':xscore += 1if board[x][y] == 'O':oscore += 1return {'X':xscore, 'O':oscore}def enterPlayerTile():# Let the player enter which tile they want to be.# Return a list with the player's tile as the first item and thecomputer's tile as the second.tile = ''while not (tile == 'X' or tile == 'O'):print('Do you want to be X or O?')tile = input().upper()# The first element in the list is the player's tile, and the secondis the computer's tile.if tile == 'X':return ['X', 'O']else:return ['O', 'X']def whoGoesFirst():# Randomly choose who goes first.if random.randint(0, 1) == 0:return 'computer'else:return 'player'def makeMove(board, tile, xstart, ystart):# Place the tile on the board at xstart, ystart and flip any of theopponent's pieces.# Return False if this is an invalid move; True if it is valid.tilesToFlip = isValidMove(board, tile, xstart, ystart)if tilesToFlip == False:return Falseboard[xstart][ystart] = tilefor x, y in tilesToFlip:board[x][y] = tilereturn Truedef getBoardCopy(board):# Make a duplicate of the board list and return it.boardCopy = getNewBoard()for x in range(WIDTH):for y in range(HEIGHT):boardCopy[x][y] = board[x][y]return boardCopydef isOnCorner(x, y):# Return True if the position is in one of the four corners.return (x == 0 or x == WIDTH - 1) and (y == 0 or y == HEIGHT - 1)def getPlayerMove(board, playerTile):# Let the player enter their move.# Return the move as [x, y] (or return the strings 'hints' or'quit').DIGITS1TO8 = '1 2 3 4 5 6 7 8'.split()while True:print('Enter your move, "quit" to end the game, or "hints" totoggle hints.')move = input().lower()if move == 'quit' or move == 'hints':return moveif len(move) == 2 and move[0] in DIGITS1TO8 and move[1] inDIGITS1TO8:x = int(move[0]) - 1y = int(move[1]) - 1if isValidMove(board, playerTile, x, y) == False:continueelse:breakelse:print('That is not a valid move. Enter the column (1-8) andthen the row (1-8).')print('For example, 81 will move on the top-right corner.')return [x, y]def getComputerMove(board, computerTile):# Given a board and the computer's tile, determine where to# move and return that move as an [x, y] list.possibleMoves = getValidMoves(board, computerTile)random.shuffle(possibleMoves) # Randomize the order of the moves.# Always go for a corner if available.for x, y in possibleMoves:if isOnCorner(x, y):return [x, y]# Find the highest-scoring move possible.bestScore = -1for x, y in possibleMoves:boardCopy = getBoardCopy(board)makeMove(boardCopy, computerTile, x, y)score = getScoreOfBoard(boardCopy)[computerTile]if score > bestScore:bestMove = [x, y]bestScore = scorereturn bestMovedef printScore(board, playerTile, computerTile):scores = getScoreOfBoard(board)print('You: %s points. Computer: %s points.' % (scores[playerTile],scores[computerTile]))def playGame(playerTile, computerTile):showHints = Falseturn = whoGoesFirst()print('The ' + turn + ' will go first.')# Clear the board and place starting pieces.board = getNewBoard()board[3][3] = 'X'board[3][4] = 'O'board[4][3] = 'O'board[4][4] = 'X'while True:playerValidMoves = getValidMoves(board, playerTile)computerValidMoves = getValidMoves(board, computerTile)if playerValidMoves == [] and computerValidMoves == []:return board # No one can move, so end the game.elif turn == 'player': # Player's turnif playerValidMoves != []:if showHints:validMovesBoard = getBoardWithValidMoves(board,playerTile)drawBoard(validMovesBoard)else:drawBoard(board)printScore(board, playerTile, computerTile)move = getPlayerMove(board, playerTile)if move == 'quit':print('Thanks for playing!')sys.exit() # Terminate the program.elif move == 'hints':showHints = not showHintscontinueelse:makeMove(board, playerTile, move[0], move[1])turn = 'computer'elif turn == 'computer': # Computer's turnif computerValidMoves != []:drawBoard(board)printScore(board, playerTile, computerTile)input('Press Enter to see the computer\'s move.')move = getComputerMove(board, computerTile)makeMove(board, computerTile, move[0], move[1])turn = 'player'print('Welcome to Reversegam!')playerTile, computerTile = enterPlayerTile()while True:finalBoard = playGame(playerTile, computerTile)# Display the final score.drawBoard(finalBoard)scores = getScoreOfBoard(finalBoard)print('X scored %s points. O scored %s points.' % (scores['X'],scores['O']))if scores[playerTile] > scores[computerTile]:print('You beat the computer by %s points! Congratulations!' %(scores[playerTile] - scores[computerTile]))elif scores[playerTile] < scores[computerTile]:print('You lost. The computer beat you by %s points.' %(scores[computerTile] - scores[playerTile]))else:print('The game was a tie!')print('Do you want to play again? (yes or no)')if not input().lower().startswith('y'):break
导入模块和设置常量
与我们其他的游戏一样,我们首先通过导入模块来开始这个程序:
# Reversegam: a clone of Othello/Reversi
import random
import sys
WIDTH = 8 # Board is 8 spaces wide.
HEIGHT = 8 # Board is 8 spaces tall.
第 2 行导入random
模块以使用其randint()
和choice()
函数。第 3 行导入sys
模块以使用其exit()
函数。
第 4 行和第 5 行设置了两个常量,WIDTH
和HEIGHT
,用于设置游戏棋盘。
游戏棋盘数据结构
让我们弄清楚棋盘的数据结构。这个数据结构是一个列表的列表,就像第 13 章中的 Sonar Treasure Hunt 游戏中的那个一样。列表的列表被创建,以便board[x][y]
将表示 x 轴(左/右)上位置为x
,y 轴(上/下)上位置为y
的空间上的字符。
这个字符可以是' '
(代表空位置的空格),'.'
(代表提示模式中的可能移动),或者是'X'
或'O'
(代表瓷砖的字母)。每当看到名为board
的参数时,它意味着是这种类型的列表-列表数据结构。
重要的是要注意,虽然游戏棋盘的 x 和 y 坐标范围是从 1 到 8,但列表数据结构的索引范围是从 0 到 7。我们的代码需要进行轻微调整以解决这个问题。
在屏幕上绘制棋盘数据结构
棋盘数据结构只是一个 Python 列表值,但我们需要一种更好的方式来在屏幕上呈现它。drawBoard()
函数接受一个棋盘数据结构,并在屏幕上显示它,以便玩家知道瓷砖放在哪里:
def drawBoard(board):# Print the board passed to this function. Return None.print(' 12345678')print(' +--------+')for y in range(HEIGHT):print('%s|' % (y+1), end='')for x in range(WIDTH):print(board[x][y], end='')print('|%s' % (y+1))print(' +--------+')print(' 12345678')
drawBoard()
函数根据board
中的数据结构打印当前游戏棋盘。
第 8 行是对每个棋盘执行的第一个print()
函数调用,并打印了沿着棋盘顶部的 x 轴的标签。第 9 行打印了棋盘的顶部水平线。第 10 行的for
循环将循环八次,每次为一行。第 11 行打印了位于棋盘左侧的 y 轴标签,并且它有一个end=''
关键字参数,以打印空行而不是新行。
这样,第 12 行的另一个循环(也循环八次,每次为行中的每一列)将打印每个位置以及board[x][y]
中存储的X
、O
、.
或空格,取决于存储在board[x][y]
中的内容。第 13 行的print()
函数调用在这个循环内部也有一个end=''
关键字参数,以便不打印换行符。这将在屏幕上产生一个看起来像'1|XXXXXXXX|1'
的单行(如果每个board[x][y]
值都是'X'
)。
内部循环完成后,第 15 行和第 16 行的print()
函数调用打印底部水平线和 x 轴标签。
当第 13 行的for
循环打印行八次时,它形成整个棋盘:
12345678+--------+
1|XXXXXXXX|1
2|XXXXXXXX|2
3|XXXXXXXX|3
4|XXXXXXXX|4
5|XXXXXXXX|5
6|XXXXXXXX|6
7|XXXXXXXX|7
8|XXXXXXXX|8+--------+12345678
当然,棋盘上的一些空格将是另一位玩家的标记(O
),如果提示模式打开,则为句点(.
),或者为空位置的空格。
创建一个新的棋盘数据结构
drawBoard()
函数将在屏幕上显示一个棋盘数据结构,但我们也需要一种方法来创建这些棋盘数据结构。getNewBoard()
函数返回一个包含八个列表的列表,每个列表包含八个' '
字符串,表示一个没有移动的空白棋盘:
def getNewBoard():# Create a brand-new, blank board data structure.board = []for i in range(WIDTH):board.append([' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '])return board
第 20 行创建包含内部列表的列表。for
循环在这个列表内添加了八个内部列表。这些内部列表有八个字符串,表示棋盘上的八个空格。总之,这段代码创建了一个有 64 个空格的棋盘——一个空白的反转棋棋盘。
检查移动是否有效
给定棋盘的数据结构、玩家的棋子以及玩家移动的 x 和 y 坐标,isValidMove()
函数应该在这些坐标上允许反转棋游戏规则的移动时返回True
,如果不允许则返回False
。为了使移动有效,它必须在棋盘上,并且至少翻转对手的一个棋子。
这个函数在棋盘上使用了几个 x 和 y 坐标,所以xstart
和ystart
变量跟踪原始移动的 x 和 y 坐标。
def isValidMove(board, tile, xstart, ystart):# Return False if the player's move on space xstart, ystart isinvalid.# If it is a valid move, return a list of spaces that would becomethe player's if they made a move here.if board[xstart][ystart] != ' ' or not isOnBoard(xstart, ystart):return Falseif tile == 'X':otherTile = 'O'else:otherTile = 'X'tilesToFlip = []
第 28 行检查 x 和 y 坐标是否在游戏棋盘上,并且使用isOnBoard()
函数(我们稍后在程序中定义)检查空格是否为空。这个函数确保 x 和 y 坐标都在棋盘的0
到WIDTH
或HEIGHT
之间,减去1
。
玩家的棋子(无论是人类玩家还是计算机玩家)在tile
中,但这个函数需要知道对手的棋子。如果玩家的棋子是X
,那么显然对手的棋子是O
,反之亦然。我们在 31 到 34 行使用if-else
语句来实现这一点。
最后,如果给定的 x 和 y 坐标是有效的移动,isValidMove()
将返回一个列表,其中包含此移动将翻转的所有对手的棋子。我们创建一个新的空列表tilesToFlip
,用于存储所有棋子的坐标。
检查每个八个方向
为了使移动有效,它需要至少翻转对手的一个棋子,通过将当前玩家的新棋子夹在玩家旧棋子之间。这意味着新棋子必须与对手的一个棋子相邻。
第 37 行的for
循环遍历了一个列表,表示程序将检查对手棋子的方向:
for xdirection, ydirection in [[0, 1], [1, 1], [1, 0], [1, -1],[0, -1], [-1, -1], [-1, 0], [-1, 1]]:
游戏棋盘是一个笛卡尔坐标系,有 x 和 y 方向。有八个方向要检查:上、下、左、右和四个对角线方向。列表 37 行的列表中的每个八个两项列表用于检查这些方向中的一个。程序通过将两项列表中的第一个值添加到 x 坐标,将第二个值添加到 y 坐标来检查一个方向。
因为 x 坐标向右增加,所以可以通过将1
添加到 x 坐标来检查右方向。因此,[1, 0]
列表将1
添加到 x 坐标,将0
添加到 y 坐标。检查左方向则相反:你需要从 x 坐标中减去1
(即添加-1
)。
但是,要对角线检查,你需要对坐标进行加法或减法。例如,将1
添加到 x 坐标并将-1
添加到 y 坐标将导致检查向上右对角线方向。
图 15-7 显示了一个图表,以便更容易记住每个两项列表代表哪个方向。
图 15-7:每个两项列表表示八个方向之一。
第 37 行的for
循环遍历了每个两项列表,以便检查每个方向。在for
循环内,x
和y
变量分别在第 38 行使用多重赋值设置为与xstart
和ystart
相同的值。xdirection
和ydirection
变量设置为两项列表中的值,并根据正在检查的方向改变x
和y
变量。
for xdirection, ydirection in [[0, 1], [1, 1], [1, 0], [1, -1],[0, -1], [-1, -1], [-1, 0], [-1, 1]]:x, y = xstart, ystartx += xdirection # First step in the x directiony += ydirection # First step in the y direction
xstart
和ystart
变量将保持不变,以便程序可以记住它最初从哪个空格开始。
记住,要移动有效,必须在棋盘上并且紧邻对手的棋子之一。(否则,没有对手的棋子可以翻转,移动必须翻转至少一个棋子才有效。)第 41 行检查了这个条件,如果不是True
,则执行返回到for
语句以检查下一个方向。
while isOnBoard(x, y) and board[x][y] == otherTile:# Keep moving in this x & y direction.x += xdirectiony += ydirection
但是,如果第一个检查的空格确实有对手的棋子,那么程序应该在该方向上检查更多的对手的棋子,直到达到玩家的棋子之一或棋盘的末端。使用xdirection
和ydirection
再次检查相同方向的下一个棋子,使x
和y
成为要检查的下一个坐标。因此,程序在第 43 和 44 行改变了x
和y
。
找出是否有可以翻转的棋子
接下来,我们检查是否有可以翻转的相邻棋子。
if isOnBoard(x, y) and board[x][y] == tile:# There are pieces to flip over. Go in the reversedirection until we reach the original space, noting allthe tiles along the way.while True:x -= xdirectiony -= ydirectionif x == xstart and y == ystart:breaktilesToFlip.append([x, y])
第 45 行的if
语句检查坐标是否被玩家自己的棋子占据。这个棋子将标记由玩家的棋子围绕对手的棋子形成的夹心的末端。我们还需要记录所有应该翻转的对手的棋子的坐标。
while
循环在第 48 和 49 行反向移动x
和y
。直到x
和y
回到原始的xstart
和ystart
位置,xdirection
和ydirection
从x
和y
中减去,并且每个x
和y
位置都被附加到tilesToFlip
列表中。当x
和y
达到xstart
和ystart
位置时,第 51 行中断了循环的执行。由于原始的xstart
和ystart
位置是一个空格(我们确保这是在第 28 和 29 行的情况下),因此第 41 行while
循环的条件将是False
。程序继续执行到第 37 行,for
循环检查下一个方向。
for
循环在所有八个方向上执行此操作。循环结束后,tilesToFlip
列表将包含我们所有对手的棋子的 x 和 y 坐标,如果玩家在xstart
,ystart
上移动,这些棋子将被翻转。记住,isValidMove()
函数只是检查原始移动是否有效;它实际上不会永久改变游戏棋盘的数据结构。
如果八个方向中没有一个最终翻转了对手的棋子,那么tilesToFlip
将是一个空列表:
if len(tilesToFlip) == 0: # If no tiles were flipped, this is not avalid move.return Falsereturn tilesToFlip
这表明这个移动是无效的,isValidMove()
应该返回False
。否则,isValidMove()
返回tilesToFlip
。
检查有效坐标
isOnBoard()
函数从isValidMove()
中调用。它简单地检查给定的 x 和 y 坐标是否在棋盘上。例如,x 坐标为4
,y 坐标为9999
将不在棋盘上,因为 y 坐标只能到7
,这等于WIDTH - 1
或HEIGHT - 1
。
def isOnBoard(x, y):# Return True if the coordinates are located on the board.return x >= 0 and x <= WIDTH - 1 and y >= 0 and y <= HEIGHT - 1
调用此函数相当于第 72 行的布尔表达式,检查x
和y
是否在0
和WIDTH
或HEIGHT
减去1
之间,即7
。
获取所有有效移动的列表
现在让我们创建一个提示模式,显示一个标有所有可能移动的棋盘。getBoardWithValidMoves()
函数返回一个游戏棋盘数据结构,其中对于所有有效移动的空格都有句点(.
):
def getBoardWithValidMoves(board, tile):# Return a new board with periods marking the valid moves the playercan make.boardCopy = getBoardCopy(board)for x, y in getValidMoves(boardCopy, tile):boardCopy[x][y] = '.'return boardCopy
此函数创建一个名为boardCopy
的重复游戏board
数据结构(由第 64 行的getBoardCopy()
返回),而不是修改传递给它的board
参数中的数据结构。第 66 行调用getValidMoves()
以获取玩家可以进行的所有合法移动的 x 和 y 坐标列表。在这些空格中,用句点标记板副本并返回。
getValidMoves()
函数返回一个两项列表的列表。这两项列表保存了board
参数中给定的tile
的所有有效移动的 x 和 y 坐标:
def getValidMoves(board, tile):# Return a list of [x,y] lists of valid moves for the given playeron the given board.validMoves = []for x in range(WIDTH):for y in range(HEIGHT):if isValidMove(board, tile, x, y) != False:validMoves.append([x, y])return validMoves
此函数使用嵌套循环(第 73 和 74 行)来检查每个 x 和 y 坐标(共 64 个),通过调用该空间上的isValidMove()
并检查它是否返回False
或可能移动的列表(在这种情况下,移动是有效的)。每个有效的 x 和 y 坐标都附加到validMoves
列表中。
调用 bool()函数
您可能已经注意到,尽管此函数返回一个列表,程序仍会检查第 75 行的isValidMove()
是否返回False
。要理解这是如何工作的,您需要更多了解布尔值和bool()
函数。
bool()
函数类似于int()
和str()
函数。它返回传递给它的值的布尔值形式。
大多数数据类型都有一个被视为该数据类型的False
值的值。其他每个值都被视为True
。例如,整数0
,浮点数0.0
,空字符串,空列表和空字典在作为if
或循环语句的条件时都被视为False
。所有其他值都是True
。在交互式 shell 中输入以下内容:
>>> bool(0)
False
>>> bool(0.0)
False
>>> bool('')
False
>>> bool([])
False
>>> bool({})
False
>>> bool(1)
True
>>> bool('Hello')
True
>>> bool([1, 2, 3, 4, 5])
True
>>> bool({'spam':'cheese', 'fizz':'buzz'})
True
条件会自动解释为布尔值。这就是为什么第 75 行的条件能够正确工作。对isValidMove()
函数的调用要么返回布尔值False
,要么返回非空列表。
如果想象整个条件都放在对bool()
的调用中,那么第 75 行的条件False
就变成了bool(False)
(当然,求值为False
)。将非空列表作为bool()
的参数放置,将返回True
。
获取游戏板的分数
getScoreOfBoard()
函数使用嵌套的for
循环来检查板上的所有 64 个位置,并查看哪个玩家的瓷砖(如果有)在上面:
def getScoreOfBoard(board):# Determine the score by counting the tiles. Return a dictionarywith keys 'X' and 'O'.xscore = 0oscore = 0for x in range(WIDTH):for y in range(HEIGHT):if board[x][y] == 'X':xscore += 1if board[x][y] == 'O':oscore += 1return {'X':xscore, 'O':oscore}
对于每个X
瓷砖,代码在第 86 行增加xscore
。对于每个O
瓷砖,代码在第 88 行增加oscore
。然后函数以字典形式返回xscore
和oscore
。
获取玩家的选择
enterPlayerTile()
函数询问玩家想要成为哪种瓷砖,X或O:
def enterPlayerTile():# Let the player enter which tile they want to be.# Return a list with the player's tile as the first item and thecomputer's tile as the second.tile = ''while not (tile == 'X' or tile == 'O'):print('Do you want to be X or O?')tile = input().upper()# The first element in the list is the player's tile, and the secondis the computer's tile.if tile == 'X':return ['X', 'O']else:return ['O', 'X']
for
循环将一直循环,直到玩家输入大写或小写的X
或O
。然后enterPlayerTile()
函数返回一个两项列表,玩家的选择是第一项,计算机的选择是第二项。稍后,第 241 行调用enterPlayerTile()
并使用多重赋值将这两个返回的项放入两个变量中。
确定谁先走
whoGoesFirst()
函数随机选择谁先走,并返回字符串'computer'
或字符串'player'
:
def whoGoesFirst():# Randomly choose who goes first.if random.randint(0, 1) == 0:return 'computer'else:return 'player'
在板上放置一个瓷砖
当玩家想要在板上放置一个瓷砖并根据反转棋的规则翻转其他瓷砖时,将调用makeMove()
函数:
def makeMove(board, tile, xstart, ystart):# Place the tile on the board at xstart, ystart and flip any of theopponent's pieces.# Return False if this is an invalid move; True if it is valid.tilesToFlip = isValidMove(board, tile, xstart, ystart)
此函数直接修改了传递的board
数据结构。对board
变量的更改(因为它是一个列表引用)将应用于全局范围。
大部分工作由第 115 行的isValidMove()
完成,它返回需要翻转的瓷砖的 x 和 y 坐标的列表(在一个两项列表中)。请记住,如果xstart
和ystart
参数指向无效移动,isValidMove()
将返回布尔值False
,第 117 行对此进行了检查:
if tilesToFlip == False:return Falseboard[xstart][ystart] = tilefor x, y in tilesToFlip:board[x][y] = tilereturn True
如果isValidMove()
的返回值(现在存储在tilesToFlip
中)为False
,那么makeMove()
也会在第 118 行返回False
。
否则,isValidMove()
返回一个列表,其中包含要放置瓷砖的棋盘空间(tile
中的'X'
或'O'
字符串)。第 120 行设置玩家移动的空间。第 121 行的for
循环设置了所有在tilesToFlip
中的瓷砖。
复制棋盘数据结构
getBoardCopy()
函数与getNewBoard()
不同。getNewBoard()
函数创建一个只有空格和四个起始瓷砖的空白游戏棋盘数据结构。getBoardCopy()
创建一个空白游戏棋盘数据结构,然后使用嵌套循环将board
参数中的所有位置复制到副本棋盘数据结构中。AI 使用getBoardCopy()
函数,以便可以对游戏棋盘副本进行更改,而不会改变真实的游戏棋盘。这种技术也被 Tic-Tac-Toe 程序在第 10 章中使用过。
def getBoardCopy(board):# Make a duplicate of the board list and return it.boardCopy = getNewBoard()for x in range(WIDTH):for y in range(HEIGHT):boardCopy[x][y] = board[x][y]return boardCopy
调用getNewBoard()
设置boardCopy
为一个新的游戏棋盘数据结构。然后两个嵌套的for
循环将board
中的 64 个瓷砖复制到boardCopy
中的重复棋盘数据结构中。
确定空间是否在角落
isOnCorner()
函数返回True
,如果坐标位于角落空间,坐标为(0, 0),(7, 0),(0, 7),或(7, 7):
def isOnCorner(x, y):# Return True if the position is in one of the four corners.return (x == 0 or x == WIDTH - 1) and (y == 0 or y == HEIGHT - 1)
否则,isOnCorner()
返回False
。我们稍后会在 AI 中使用这个函数。
获取玩家的移动
调用getPlayerMove()
函数,让玩家输入他们下一步移动的坐标(并检查移动是否有效)。玩家还可以输入hints
来打开提示模式(如果关闭了)或关闭提示模式(如果打开了)。最后,玩家可以输入quit
来退出游戏。
def getPlayerMove(board, playerTile):# Let the player enter their move.# Return the move as [x, y] (or return the strings 'hints' or'quit').DIGITS1TO8 = '1 2 3 4 5 6 7 8'.split()
DIGITS1TO8
常量变量是列表['1', '2', '3', '4', '5', '6', '7', '8']
。getPlayerMove()
函数多次使用DIGITS1TO8
,这个常量比完整的列表值更易读。不能使用isdigit()
方法,因为这将允许输入 0 和 9,这在 8×8 棋盘上是无效的坐标。
while
循环会一直循环,直到玩家输入有效的移动:
while True:print('Enter your move, "quit" to end the game, or "hints" totoggle hints.')move = input().lower()if move == 'quit' or move == 'hints':return move
第 146 行检查玩家是否想要退出或切换提示模式,第 147 行返回字符串'quit'
或'hints'
。在input()
返回的字符串上调用lower()
方法,以便玩家可以输入HINTS
或Quit
,并且命令仍然可以被理解。
调用getPlayerMove()
的代码将处理玩家想要退出或切换提示模式时的操作。如果玩家输入坐标进行移动,第 149 行的if
语句会检查移动是否有效:
if len(move) == 2 and move[0] in DIGITS1TO8 and move[1] inDIGITS1TO8:x = int(move[0]) - 1y = int(move[1]) - 1if isValidMove(board, playerTile, x, y) == False:continueelse:break
游戏期望玩家输入他们的移动的 x 和 y 坐标作为两个数字,中间没有任何东西。第 149 行首先检查玩家输入的字符串的大小是否为2
。之后,它还检查move[0]
(字符串中的第一个字符)和move[1]
(字符串中的第二个字符)是否是存在于DIGITS1TO8
中的字符串。
请记住,游戏棋盘数据结构的索引范围是从 0 到 7,而不是从 1 到 8。当在drawBoard()
中显示棋盘时,代码会打印 1 到 8,因为非程序员习惯于从 1 开始而不是从 0 开始。因此,为了将move[0]
和move[1]
中的字符串转换为整数,第 150 行和第 151 行分别从x
和y
中减去 1。
即使玩家输入了正确的移动,代码也需要检查移动是否符合反转棋的规则。这是通过isValidMove()
函数完成的,该函数接收游戏棋盘数据结构、玩家的瓷砖以及移动的 x 和 y 坐标。
如果isValidMove()
返回False
,第 153 行的continue
语句会执行。然后执行返回到while
循环的开始,并再次要求玩家输入有效的移动。否则,玩家输入了有效的移动,执行需要跳出while
循环。
如果第 149 行的if
语句条件为False
,则玩家没有输入有效的移动。第 157 和 158 行指导他们如何正确输入移动:
else:print('That is not a valid move. Enter the column (1-8) andthen the row (1-8).')print('For example, 81 will move on the top-right corner.')
之后,执行回到第 143 行的while
语句,因为第 158 行不仅是else
块中的最后一行,也是while
块中的最后一行。while
循环将一直循环,直到玩家输入有效的移动。如果玩家输入 x 和 y 坐标,第 160 行将执行:
return [x, y]
最后,如果第 160 行执行,getPlayerMove()
将返回一个包含玩家有效移动的 x 和 y 坐标的两项列表。
获取计算机的移动
getComputerMove()
函数是实现 AI 算法的地方:
def getComputerMove(board, computerTile):# Given a board and the computer's tile, determine where to# move and return that move as an [x, y] list.possibleMoves = getValidMoves(board, computerTile)
通常,您会使用getValidMoves()
的结果进行提示模式,它将在棋盘上打印。
以向玩家显示他们可以进行的所有潜在移动。但如果使用计算机 AI 的棋子(在computerTile
中)调用getValidMoves()
,它还将找到计算机可以进行的所有可能移动。AI 将从此列表中选择最佳移动。
首先,random.shuffle()
函数将随机化possibleMoves
列表中的移动顺序:
random.shuffle(possibleMoves) # Randomize the order of the moves.
我们要对possibleMoves
列表进行洗牌,因为这将使 AI 变得不那么可预测;否则,玩家可以简单地记住赢得比赛所需的移动,因为计算机的响应总是相同的。让我们来看看算法。
使用角落移动进行策略
在反转棋中,角落移动是个好主意,因为一旦在角落放置了一个棋子,它就永远不能翻转。第 169 行循环遍历possibleMoves
中的每个移动。如果其中任何一个在角落上,程序将返回该空间作为计算机的移动:
# Always go for a corner if available.for x, y in possibleMoves:if isOnCorner(x, y):return [x, y]
由于possibleMoves
是一个包含两个项目的列表,我们将在for
循环中使用多重赋值来设置x
和y
。如果possibleMoves
包含多个角落移动,将始终使用第一个移动。但由于在第 166 行对possibleMoves
进行了洗牌,列表中第一个角落移动是随机的。
获取最高得分移动的列表
如果没有角落移动,程序将循环遍历可能移动的整个列表,并找出哪个结果得分最高。然后,bestMove
设置为代码迄今为止找到的得分最高的移动,bestScore
设置为最佳移动的分数。这将重复,直到找到得分最高的可能移动。
# Find the highest-scoring move possible.bestScore = -1for x, y in possibleMoves:boardCopy = getBoardCopy(board)makeMove(boardCopy, computerTile, x, y)score = getScoreOfBoard(boardCopy)[computerTile]if score > bestScore:bestMove = [x, y]bestScore = scorereturn bestMove
第 174 行首先将bestScore
设置为-1
,以便代码检查的第一个移动将设置为第一个bestMove
。这确保了在返回时,bestMove
将设置为possibleMoves
中的移动之一。
在第 175 行,for
循环将x
和y
设置为possibleMoves
中的每个移动。在模拟移动之前,第 176 行通过调用getBoardCopy()
创建一个重复的游戏棋盘数据结构。您需要一个可以修改的副本,而不会改变存储在board
变量中的真实游戏棋盘数据结构。
然后,第 177 行调用makeMove()
,传递重复的棋盘(存储在boardCopy
中)而不是真正的棋盘。这将模拟如果进行了该移动,真实棋盘上会发生什么。makeMove()
函数将处理在重复棋盘上放置计算机的棋子和翻转玩家的棋子。
第 178 行调用getScoreOfBoard()
并使用重复的棋盘,它返回一个字典,其中键是'X'
和'O'
,值是分数。当循环中的代码找到一个比bestScore
更高的分数时,第 179 至 181 行将把该移动和分数存储为bestMove
和bestScore
中的新值。在possibleMoves
完全迭代完成后,将返回bestMove
。
例如,假设getScoreOfBoard()
返回字典{'X':22, 'O':8}
,computerTile
为'X'
。那么getScoreOfBoard(boardCopy)[computerTile]
将求值为{'X':22, 'O':8}['X']
,然后求值为22
。如果22
大于bestScore
,则将bestScore
设置为22
,并将bestMove
设置为当前的x
和y
值。
当这个for
循环结束时,你可以确定bestScore
是移动可能获得的最高分数,并且该移动存储在bestMove
中。
尽管代码总是选择这些平局中的第一个,但由于在第 166 行对列表顺序进行了洗牌,选择看起来是随机的。这确保了当有多个最佳移动时,AI 不会是可预测的。
将分数打印到屏幕上
showPoints()
函数调用getScoreOfBoard()
函数,然后打印玩家和计算机的分数:
def printScore(board, playerTile, computerTile):scores = getScoreOfBoard(board)print('You: %s points. Computer: %s points.' % (scores[playerTile],scores[computerTile]))
请记住,getScoreOfBoard()
返回一个字典,键为'X'
和'O'
,值为X和O玩家的分数。
这就是反转棋游戏的所有函数。playGame()
函数中的代码实现了实际的游戏,并根据需要调用这些函数。
开始游戏
playGame()
函数调用我们编写的先前函数来进行单场比赛:
def playGame(playerTile, computerTile):showHints = Falseturn = whoGoesFirst()print('The ' + turn + ' will go first.')# Clear the board and place starting pieces.board = getNewBoard()board[3][3] = 'X'board[3][4] = 'O'board[4][3] = 'O'board[4][4] = 'X'
playGame()
函数传递给playerTile
和computerTile
字符串'X'
或'O'
。第 190 行确定先手玩家。turn
变量包含字符串'computer'
或'player'
,以跟踪轮到谁了。第 194 行创建一个空白的棋盘数据结构,而第 195 到 198 行设置了棋盘上的初始四个方块。游戏现在准备好开始了。
检查僵局
在获取玩家或计算机的轮次之前,我们需要检查他们是否有可能移动。如果没有,那么游戏就陷入僵局并应该结束。(如果只有一方没有有效移动,轮次将跳到另一方。)
while True:playerValidMoves = getValidMoves(board, playerTile)computerValidMoves = getValidMoves(board, computerTile)if playerValidMoves == [] and computerValidMoves == []:return board # No one can move, so end the game.
第 200 行是运行玩家和计算机轮次的主循环。只要这个循环不断循环,游戏就会继续。但在运行这些轮次之前,第 201 和 202 行检查双方是否可以通过获取有效移动列表来进行移动。如果这两个列表都为空,那么任何一方都无法进行移动。第 205 行通过返回最终棋盘退出playGame()
函数,结束游戏。
运行玩家的轮次
如果游戏不是陷入僵局,程序通过检查turn
是否设置为字符串'player'
来确定是否轮到玩家:
elif turn == 'player': # Player's turnif playerValidMoves != []:if showHints:validMovesBoard = getBoardWithValidMoves(board,playerTile)drawBoard(validMovesBoard)else:drawBoard(board)printScore(board, playerTile, computerTile)
第 207 行开始一个包含代码的elif
块,如果是玩家的轮次,则运行该代码。(从第 227 行开始的elif
块包含计算机的轮次的代码。)
只有当玩家有有效移动时,所有这些代码才会运行,第 208 行通过检查playerValidMoves
不为空来确定。我们通过在第 211 或 213 行调用drawBoard()
在屏幕上显示棋盘。
如果提示模式打开(即showHints
为True
),则棋盘数据结构应该在玩家可以移动的每个有效空间上显示。
,这是通过getBoardWithValidMoves()
函数实现的。它传递一个游戏棋盘数据结构,并返回一个也包含句点(.
)的副本。第 211 行将此棋盘传递给drawBoard()
函数。
如果提示模式关闭,那么第 213 行将board
传递给drawBoard()
。
在向玩家打印游戏棋盘之后,您还希望通过在第 214 行调用printScore()
来打印当前分数。
接下来,玩家需要输入他们的移动。getPlayerMove()
函数处理这个,并且它的返回值是玩家移动的 x 和 y 坐标的两项列表:
move = getPlayerMove(board, playerTile)
当我们定义getPlayerMove()
时,我们已经确保玩家的移动是有效的。
getPlayerMove()
函数可能返回字符串'quit'
或'hints'
,而不是棋盘上的移动。行 217 到 222 处理这些情况:
if move == 'quit':print('Thanks for playing!')sys.exit() # Terminate the program.elif move == 'hints':showHints = not showHintscontinueelse:makeMove(board, playerTile, move[0], move[1])turn = 'computer'
如果玩家输入quit
作为他们的移动,那么getPlayerMove()
将返回字符串'quit'
。在这种情况下,第 219 行调用sys.exit()
来终止程序。
如果玩家输入hints
作为他们的移动,那么getPlayerMove()
将返回字符串'hints'
。在这种情况下,您希望打开提示模式(如果它关闭了)或关闭提示模式(如果它打开了)。
第 221 行的showHints = not showHints
赋值语句处理了这两种情况,因为not False
求值为True
,not True
求值为False
。然后continue
语句将执行移动到循环的开始(turn
没有改变,所以它仍然是玩家的回合)。
否则,如果玩家没有退出或切换提示模式,第 224 行调用makeMove()
在棋盘上进行玩家的移动。
最后,第 225 行将turn
设置为'computer'
。执行流程跳过了else
块,到达了while
块的末尾,所以执行跳回到第 200 行的while
语句。不过,这一次将是计算机的回合。
运行计算机的回合
如果turn
变量包含字符串'computer'
,那么计算机的回合代码将运行。它类似于玩家回合的代码,有一些变化:
elif turn == 'computer': # Computer's turnif computerValidMoves != []:drawBoard(board)printScore(board, playerTile, computerTile)input('Press Enter to see the computer\'s move.')move = getComputerMove(board, computerTile)makeMove(board, computerTile, move[0], move[1])
在使用drawBoard()
打印棋盘后,程序还通过调用第 230 行的showPoints()
打印当前得分。
第 232 行调用input()
来暂停脚本,以便玩家可以查看棋盘。这与在第 4 章中的 Jokes 程序中使用input()
暂停的方式非常相似。与在调用input()
之前使用print()
调用打印字符串不同,您可以通过将字符串传递给input()
来做同样的事情。
玩家查看了棋盘并按下 ENTER 后,第 233 行调用getComputerMove()
来获取计算机下一步移动的 x 和 y 坐标。这些坐标使用多重赋值存储在变量x
和y
中。
最后,x
和y
以及游戏棋盘数据结构和计算机的棋子被传递给makeMove()
函数。这将在board
上反映出计算机的移动。第 233 行调用getComputerMove()
得到了计算机的移动(并将其存储在变量x
和y
中)。第 234 行调用makeMove()
在棋盘上进行了移动。
接下来,第 235 行将turn
变量设置为'player'
:
turn = 'player'
在第 235 行之后while
块中没有更多的代码,所以执行会回到第 200 行的while
语句。
游戏循环
这就是我们为反转棋制作的所有函数。从第 239 行开始,程序的主要部分通过调用playGame()
来运行游戏,但它还显示最终得分,并询问玩家是否想再玩一次:
print('Welcome to Reversegam!')playerTile, computerTile = enterPlayerTile()
程序从第 239 行开始欢迎玩家,并询问他们是否想成为X或O。第 241 行使用多重赋值技巧将playerTile
和computerTile
设置为enterPlayerTile()
返回的两个值。
第 243 行的while
循环运行每一局游戏:
while True:finalBoard = playGame(playerTile, computerTile)# Display the final score.drawBoard(finalBoard)scores = getScoreOfBoard(finalBoard)print('X scored %s points. O scored %s points.' % (scores['X'],scores['O']))if scores[playerTile] > scores[computerTile]:print('You beat the computer by %s points! Congratulations!' %(scores[playerTile] - scores[computerTile]))elif scores[playerTile] < scores[computerTile]:print('You lost. The computer beat you by %s points.' %(scores[computerTile] - scores[playerTile]))else:print('The game was a tie!')
它通过调用playGame()
开始。这个函数调用直到游戏结束才返回。playGame()
返回的棋盘数据结构将传递给getScoreOfBoard()
来计算X和O的棋子数量,以确定最终得分。第 249 行显示最终得分。
如果玩家的棋子比电脑的棋子多,第 251 行祝贺玩家获胜。如果电脑赢了,第 253 行告诉玩家他们输了。否则,第 255 行告诉玩家游戏是平局。
询问玩家是否再玩一次
游戏结束后,询问玩家是否想再玩一次:
print('Do you want to play again? (yes or no)')if not input().lower().startswith('y'):break
如果玩家没有输入以字母y开头的回复,比如yes
或YES
或Y
,那么第 258 行的条件将求值为True
,并且第 259 行将跳出从第 243 行开始的while
循环,从而结束游戏。否则,这个while
循环会自然循环,并且再次调用playGame()
开始下一局游戏。
摘要
反转棋 AI 可能看起来几乎是无敌的,但这并不是因为计算机比我们聪明;它只是快得多!它遵循的策略很简单:如果可以的话,移动到角落,否则进行可以翻转最多瓦片的移动。人类也可以做到这一点,但要计算每个可能的有效移动会翻转多少瓦片是很耗时的。对于计算机来说,计算这个数字很简单。
这个游戏类似于声纳寻宝游戏,因为它使用网格作为游戏板。它也像井字游戏,因为有一个人工智能为计算机规划最佳移动。本章只介绍了一个新概念:在条件的上下文中,空列表、空字符串和整数0
都会求值为False
。除此之外,这个游戏使用了你已经了解的编程概念!
在第 16 章中,你将学习如何让人工智能在计算机游戏中相互对战。
十六、反转棋 AI 模拟
原文:
inventwithpython.com/invent4thed/chapter16.html
译者:飞龙
协议:CC BY-NC-SA 4.0
来自第 15 章的反转棋 AI 算法很简单,但几乎每次我玩都会被它打败。因为计算机可以快速处理指令,它可以轻松地检查棋盘上的每个可能位置并选择得分最高的移动。用这种方式找到最佳移动需要我花费很长时间。
反转棋程序有两个函数,getPlayerMove()
和 getComputerMove()
,它们都以格式 [x, y]
的两项列表返回所选的移动。这两个函数还具有相同的参数,游戏棋盘数据结构和一种类型的棋子,但返回的移动来自不同的来源——玩家或反转棋算法。
当我们用对 getPlayerMove()
的调用替换为对 getComputerMove()
的调用时会发生什么?然后玩家就不必输入移动;移动已经为他们决定好了。计算机正在与自己对战!
在本章中,我们将制作三个新程序,其中计算机将与自己对战,每个程序都基于第 15 章中的反转棋程序:
-
模拟 1:AISim1.py 将对 reversegam.py 进行更改。
-
模拟 2:AISim2.py 将对 AISim1.py 进行更改。
-
模拟 3:AISim3.py 将对 AISim2.py 进行更改。
从一个程序到下一个程序的微小更改将向您展示如何将“玩家对计算机”游戏转变为“计算机对计算机”的模拟。最终的程序 AISim3.py 与 reversegam.py 共享大部分代码,但目的完全不同。该模拟不让我们玩反转棋,而是教会我们更多关于游戏本身的知识。
您可以自己键入这些更改,也可以从书籍网站*https://www.nostarch.com/inventwithpython/*下载它们。
本章涵盖的主题
-
模拟
-
百分比
-
整数除法
-
round()
函数 -
计算机对战游戏
让计算机自己对战
我们的 AISim1.py 程序将进行一些简单的更改,以便计算机与自己对战。getPlayerMove()
和 getComputerMove()
函数都接受一个棋盘数据结构和玩家的棋子,然后返回要进行的移动。这就是为什么 getComputerMove()
可以替换 getPlayerMove()
而程序仍然能正常工作。在 AISim1.py 程序中,getComputerMove()
函数被调用来代替 X
和 O
玩家。
我们还使程序停止打印进行的移动的游戏板。由于人类无法像计算机那样快速阅读游戏板,因此打印每一步并不有用,因此我们只在游戏结束时打印最终的游戏板。
这些只是对程序的最小更改,因此它仍会说诸如“玩家将先行。”之类的话,即使计算机正在扮演计算机和玩家的角色。
模拟 1 的示例运行
当用户运行 AISim1.py 程序时,用户看到的内容如下。玩家输入的文本是粗体。
Welcome to Reversegam!
The computer will go first.12345678+--------+
1|XXXXXXXX|1
2|OXXXXXXX|2
3|XOXXOXXX|3
4|XXOOXOOX|4
5|XXOOXXXX|5
6|XXOXOXXX|6
7|XXXOXOXX|7
8|XXXXXXXX|8+--------+12345678
X scored 51 points. O scored 13 points.
You beat the computer by 38 points! Congratulations!
Do you want to play again? (yes or no)
no
模拟 1 的源代码
将旧的 reversegam.py 文件另存为 AISim1.py 如下:
-
选择 文件 另存为。
-
将此文件另存为 AISim1.py,以便您可以在不影响 reversegam.py 的情况下进行更改。(此时,reversegam.py 和 AISim1.py 仍具有相同的代码。)
-
对 AISim1.py 进行更改并保存该文件以保留任何更改。(AISim1.py 将有新更改,reversegam.py 将保持原始的未更改的代码。)
这个过程将创建一个我们的反转棋源代码的副本作为一个新文件,你可以对其进行更改,同时保持原始的反转棋游戏不变(你可能想再玩一次来测试它)。例如,将AISim1.py中的第 216 行更改为以下内容(更改部分用粗体标出):
move = getComputerMove(board, playerTile)
现在运行程序。请注意,游戏仍然会询问你是否想成为X还是O,但不会要求你输入任何移动。当你用getComputerMove()
函数替换getPlayerMove()
函数时,你不再调用任何从玩家那里获取输入的代码。玩家在原始计算机的移动后仍然按 ENTER 键(因为在第 232 行有input('Press Enter to see the computer\'s move.')
),但游戏会自己进行!
让我们对AISim1.py进行一些其他更改。更改以下粗体标记的行。更改从第 209 行开始。这些更改大多数只是注释掉代码,这意味着将代码转换为注释,以便它不会运行。
如果在输入此代码后出现错误,请使用在线差异工具比较你输入的代码和书中的代码,网址为www.nostarch.com/inventwithpython#diff
。
AISim1.py
elif turn == 'player': # Player's turnif playerValidMoves != []:#if showHints:# validMovesBoard = getBoardWithValidMoves(board,playerTile)# drawBoard(validMovesBoard)#else:# drawBoard(board)#printScore(board, playerTile, computerTile)move = getComputerMove(board, playerTile)#if move == 'quit':# print('Thanks for playing!')# sys.exit() # Terminate the program.#elif move == 'hints':# showHints = not showHints# continue#else:makeMove(board, playerTile, move[0], move[1])turn = 'computer'elif turn == 'computer': # Computer's turnif computerValidMoves != []:#drawBoard(board)#printScore(board, playerTile, computerTile)#input('Press Enter to see the computer\'s move.')move = getComputerMove(board, computerTile)makeMove(board, computerTile, move[0], move[1])turn = 'player'print('Welcome to Reversegam!')playerTile, computerTile = ['X', 'O'] #enterPlayerTile()
去除玩家提示,添加电脑玩家
如你所见,AISim1.py程序与原始的反转棋程序基本相同,只是我们用getComputerMove()
替换了getPlayerMove()
的调用。我们还对打印到屏幕的文本进行了一些更改,以使游戏更容易跟踪。当你运行程序时,整个游戏在不到一秒的时间内就完成了!
再次,大部分的更改只是将代码注释掉。由于计算机是在自己对弈,程序不再需要运行代码来获取玩家的移动或显示棋盘的状态。所有这些都被跳过,所以棋盘只在游戏的最后才显示。我们注释掉代码而不是删除它,因为如果需要以后重用代码,通过取消注释来恢复代码更容易。
我们注释掉了 209 到 214 行,因为我们不需要为玩家绘制游戏棋盘,因为他们不会玩游戏。我们还注释掉了 217 到 223 行,因为我们不需要检查玩家是否输入quit
或切换提示模式。但是我们需要将 224 行向右缩进四个空格,因为它在我们刚刚注释掉的else
块中。229 到 232 行也为玩家绘制游戏棋盘,所以我们也注释掉了这些行。
唯一的新代码在第 216 行和 241 行。在第 216 行,我们只是用getComputerMove()
替换了getPlayerMove()
的调用,如前所述。在 241 行,我们不再询问玩家是否想成为X还是O,而是始终将'X'
分配给playerTile
,将'O'
分配给computerTile
。(尽管这两个玩家都由计算机扮演,所以如果你愿意,你可以将playerTile
重命名为computerTile2
或secondComputerTile
。)现在我们让计算机自己对弈,我们可以继续修改我们的程序,使其做更有趣的事情。
让电脑自己玩几次
如果我们创建了一个新的算法,我们可以将其与getComputerMove()
中实现的 AI 进行比较,看看哪一个更好。然而,在这样做之前,我们需要一种评估玩家的方法。我们不能仅仅根据一场比赛来评估哪个 AI 更好,所以我们应该让 AI 们多次对弈。为此,我们将对源代码进行一些更改。按照以下步骤制作AISim2.py:
-
选择文件另存为。
-
将此文件另存为AISim2.py,这样你就可以在不影响AISim1.py的情况下进行更改。(此时,AISim1.py和AISim2.py仍然具有相同的代码。)
模拟 2 的示例运行
当用户运行AISim2.py程序时,他们看到的是这样的。
Welcome to Reversegam!
#1: X scored 45 points. O scored 19 points.
#2: X scored 38 points. O scored 26 points.
#3: X scored 20 points. O scored 44 points.
#4: X scored 24 points. O scored 40 points.
#5: X scored 8 points. O scored 56 points.
--snip--
#249: X scored 24 points. O scored 40 points.
#250: X scored 43 points. O scored 21 points.
X wins: 119 (47.6%)
O wins: 127 (50.8%)
Ties: 4 (1.6%)
由于算法包含随机性,你的运行结果不会完全相同。
模拟 2 的源代码
更改AISim2.py中的代码以匹配以下内容。确保按照数字顺序逐行更改代码。如果在输入此代码后出现错误,请使用在线 diff 工具将你输入的代码与书中的代码进行比较,网址为www.nostarch.com/inventwithpython#diff
。
AISim2.py
turn = 'player'NUM_GAMES = 250
xWins = oWins = ties = 0
print('Welcome to Reversegam!')playerTile, computerTile = ['X', 'O'] #enterPlayerTile()for i in range(NUM_GAMES): #while True:finalBoard = playGame(playerTile, computerTile)# Display the final score.#drawBoard(finalBoard)scores = getScoreOfBoard(finalBoard)print('#%s: X scored %s points. O scored %s points.' % (i + 1,scores['X'], scores['O']))if scores[playerTile] > scores[computerTile]:xWins += 1 #print('You beat the computer by %s points!Congratulations!' % (scores[playerTile] -scores[computerTile]))elif scores[playerTile] < scores[computerTile]:oWins += 1 #print('You lost. The computer beat you by %s points.'% (scores[computerTile] - scores[playerTile]))else:ties += 1 #print('The game was a tie!')#print('Do you want to play again? (yes or no)')#if not input().lower().startswith('y'):# breakprint('X wins: %s (%s%%)' % (xWins, round(xWins / NUM_GAMES * 100, 1)))
print('O wins: %s (%s%%)' % (oWins, round(oWins / NUM_GAMES * 100, 1)))
print('Ties: %s (%s%%)' % (ties, round(ties / NUM_GAMES * 100, 1)))
如果这让你感到困惑,你可以随时从书的网站www.nostarch.com/inventwithpython/
下载AISim2.py源代码。
跟踪多场比赛
模拟中我们想要的主要信息是在一定数量的比赛中X的获胜次数、O的获胜次数和平局次数。这些可以在第 237 和 238 行创建的四个变量中进行跟踪。
NUM_GAMES = 250
xWins = oWins = ties = 0
常量NUM_GAMES
确定计算机将玩多少场比赛。你已经添加了变量xWins
、oWins
和ties
来跟踪X赢得比赛、O赢得比赛和平局的次数。你可以将赋值语句链接在一起,将ties
设置为0
,oWins
设置为ties
,然后将xWins
设置为oWins
。这将把这三个变量都设置为0
。
NUM_GAMES
在for
循环中使用,取代了第 243 行的游戏循环:
for i in range(NUM_GAMES): #while True:
for
循环运行了NUM_GAMES
次游戏。这取代了以前循环直到玩家表示不想再玩另一场比赛的while
循环。
在第 250 行,一个if
语句比较了两个玩家的得分,而在if-elif-else
块的 251 到 255 行中,在每场比赛结束后递增了xWins
、oWins
和ties
变量。
if scores[playerTile] > scores[computerTile]:xWins += 1 #print('You beat the computer by %s points!Congratulations!' % (scores[playerTile] -scores[computerTile]))elif scores[playerTile] < scores[computerTile]:oWins += 1 #print('You lost. The computer beat you by %s points.'% (scores[computerTile] - scores[playerTile]))else:ties += 1 #print('The game was a tie!')
我们注释掉了最初在代码块中打印的消息,现在每场比赛只打印得分的一行摘要。我们将在代码的后面使用xWins
、oWins
和ties
变量来分析计算机之间的表现。
注释掉 print()函数调用
你还注释掉了 247 行和 257 到 259 行。通过这样做,你从程序中删除了大部分print()
函数调用,以及对drawBoard()
的调用。我们不需要看到每场比赛,因为比赛太多了。程序仍然完整地运行每场比赛,使用我们编写的 AI,但只显示结果得分。在运行所有比赛后,程序会显示每一方赢得了多少场比赛,251 到 253 行打印了一些关于比赛运行的信息。
向屏幕打印东西会减慢计算机的速度,但现在你已经删除了那些代码,计算机可以在大约一两秒内运行完整的反转棋游戏。每次程序打印出最终得分的那一行时,它都会运行整个游戏(逐个检查大约 50 到 60 个移动,选择得分最高的那个)。现在计算机不需要做太多工作,它可以运行得更快。
程序在最后打印的数字是统计数据——用于总结比赛过程的数字。在这种情况下,我们展示了每场比赛的结果得分以及每个方块的胜利和平局的百分比。
使用百分比来评估 AI
百分比是总量的一部分。整体的百分比可以从 0%到 100%不等。如果你有整个馅饼的 100%,你将拥有整个馅饼;如果你有 0%的馅饼,你将一点馅饼都没有;如果你有馅饼的 50%,你将拥有一半。
我们可以用除法来计算百分比。要获得百分比,将你拥有的部分除以总数,然后乘以 100。例如,如果X赢得了 100 场比赛中的 50 场,你将计算表达式50/100
,其结果为0.5
。将这个数乘以100
就得到了百分比(在这种情况下是 50%)。
如果X在 200 场比赛中赢了 100 场,你可以用100 / 200
来计算百分比,这也等于0.5
。当你将0.5
乘以100
来得到百分比时,你会得到 50%。赢得 200 场比赛中的 100 场与赢得 100 场比赛中的 50 场是相同的百分比(即相同的部分)。
在 261 到 263 行,我们使用百分比来打印有关比赛结果的信息:
print('X wins: %s (%s%%)' % (xWins, round(xWins / NUM_GAMES * 100, 1)))
print('O wins: %s (%s%%)' % (oWins, round(oWins / NUM_GAMES * 100, 1)))
print('Ties: %s (%s%%)' % (ties, round(ties / NUM_GAMES * 100, 1)))
每个print()
语句都有一个标签,告诉用户打印的数据是X赢得、O赢得还是平局。我们使用字符串插值来插入赢得或平局的比赛次数,然后插入赢得或平局占总比赛的百分比,但你可以看到我们不是简单地将xWins
、oWins
或ties
除以总比赛次数并乘以100
。这是因为我们希望每个百分比只打印一位小数,这是我们无法用普通除法做到的。
除法求值为浮点数
当你使用除法运算符(/
)时,表达式将总是求值为浮点数。例如,表达式10 / 2
求值为浮点数值5.0
,而不是整数值5
。
这很重要要记住,因为使用+
加法运算符将整数添加到浮点数值时,结果也总是求值为浮点数值。例如,3 + 4.0
求值为浮点数值7.0
,而不是整数7
。
将以下代码输入到交互式 shell 中:
>>> spam = 100 / 4
>>> spam
25.0
>>> spam = spam + 20
>>> spam
45.0
在这个例子中,spam
中存储的值的数据类型始终是浮点数值。你可以将浮点数值传递给int()
函数,该函数返回浮点数值的整数形式。但这将总是将浮点数值向下舍入。例如,表达式int(4.0)
,int(4.2)
和int(4.9)
都会求值为4
,而不是5
。但在AISim2.py中,我们需要将每个百分比四舍五入到十分位。由于我们不能简单地进行除法,我们需要使用round()
函数。
round()函数
round()
函数将浮点数四舍五入到最接近的整数。将以下内容输入到交互式 shell 中:
>>> round(10.0)
10
>>> round(10.2)
10
>>> round(8.7)
9
>>> round(3.4999)
3
>>> round(2.5422, 2)
2.54
round()
函数还有一个可选的第二个参数,你可以指定要将数字舍入到的位置。这将使舍入后的数字成为浮点数,而不是整数。例如,表达式round(2.5422, 2)
求值为2.54
,round(2.5422, 3)
求值为2.542
。在AISim2.py的 261 到 263 行,我们使用带有参数1
的round()
来找到X和O赢得或平局的比赛的百分比,直到小数点后一位,这给我们准确的百分比。
比较不同的 AI 算法
只需做一些改动,我们就可以让计算机自己玩数百场比赛。现在,每个玩家赢得大约一半的比赛,因为两者都完全相同的算法进行移动。但如果我们添加不同的算法,我们可以看到不同的 AI 是否会赢得更多比赛。
让我们添加一些新的带有新算法的函数。但首先,在AISim2.py中选择文件另存为,将这个新文件保存为AISim3.py。
我们将getComputerMove()
函数重命名为getCornerBestMove()
,因为这个算法首先尝试在角落移动,然后选择翻转最多瓷砖的移动。我们将称这种策略为角落最佳算法。我们还将添加其他几个实现不同策略的函数,包括一个最差移动算法,它得到最低分的移动;一个随机移动算法,它得到任何有效的移动;以及一个角边最佳算法,它与角落最佳 AI 相同,只是在角落移动后寻找边移动,然后再进行最高分的移动。
在AISim3.py中,第 257 行对getComputerMove()
的调用将被更改为getCornerBestMove()
,第 274 行的getComputerMove()
将变为getWorstMove()
,这是我们将为最差移动算法编写的函数。这样,我们将有常规的角落最佳算法与一个故意选择翻转最少棋子的移动的算法对抗。
模拟 3 的源代码
当您将AISim3.py的源代码输入到已重命名的AISim2.py的副本中时,请确保按照数字顺序逐行编写代码,以便行号匹配。如果在输入此代码后出现错误,请使用www.nostarch.com/inventwithpython#diff
上的在线差异工具将您输入的代码与书中的代码进行比较。
AISim3.py
def getCornerBestMove(board, computerTile):
--snip--
def getWorstMove(board, tile):# Return the move that flips the least number of tiles.possibleMoves = getValidMoves(board, tile)random.shuffle(possibleMoves) # Randomize the order of the moves.# Find the lowest-scoring move possible.worstScore = 64for x, y in possibleMoves:boardCopy = getBoardCopy(board)makeMove(boardCopy, tile, x, y)score = getScoreOfBoard(boardCopy)[tile]if score < worstScore:worstMove = [x, y]worstScore = scorereturn worstMovedef getRandomMove(board, tile):possibleMoves = getValidMoves(board, tile)return random.choice(possibleMoves)def isOnSide(x, y):return x == 0 or x == WIDTH - 1 or y == 0 or y == HEIGHT - 1def getCornerSideBestMove(board, tile):# Return a corner move, a side move, or the best move.possibleMoves = getValidMoves(board, tile)random.shuffle(possibleMoves) # Randomize the order of the moves.# Always go for a corner if available.for x, y in possibleMoves:if isOnCorner(x, y):return [x, y]# If there is no corner move to make, return a side move.for x, y in possibleMoves:if isOnSide(x, y):return [x, y]return getCornerBestMove(board, tile) # Do what the normal AIwould do.def printScore(board, playerTile, computerTile):
--snip--move = getCornerBestMove(board, playerTile)
--snip--move = getWorstMove(board, computerTile)
运行AISim3.py的结果与AISim2.py的输出类型相同,只是不同的算法将玩游戏。
模拟 3 中 AI 的工作原理
getCornerBestMove()
、getWorstMove()
、getRandomMove()
和getCornerSideBestMove()
这些函数彼此相似,但使用略有不同的策略来玩游戏。其中一个使用新的isOnSide()
函数。这类似于我们的isOnCorner()
函数,但它在选择最高得分的移动之前检查棋盘边缘的空格。
角落最佳 AI
我们已经有了一个 AI 的代码,它选择在角落移动,然后选择可能的最佳移动,因为这就是getComputerMove()
所做的。我们只需将getComputerMove()
的名称更改为更具描述性的名称,因此将第 162 行更改为将我们的函数重命名为getCornerBestMove()
:
def getCornerBestMove(board, computerTile):
由于getComputerMove()
不再存在,我们需要将第 257 行的代码更新为getCornerBestMove()
:
move = getCornerBestMove(board, playerTile)
这就是我们需要为这个 AI 做的所有工作,让我们继续。
最差移动 AI
最差移动 AI 只是找到得分最低的移动并返回。它的代码很像我们在原始getComputerMove()
算法中用来找到得分最高的移动的代码:
def getWorstMove(board, tile):# Return the move that flips the least number of tiles.possibleMoves = getValidMoves(board, tile)random.shuffle(possibleMoves) # Randomize the order of the moves.# Find the lowest-scoring move possible.worstScore = 64for x, y in possibleMoves:boardCopy = getBoardCopy(board)makeMove(boardCopy, tile, x, y)score = getScoreOfBoard(boardCopy)[tile]if score < worstScore:worstMove = [x, y]worstScore = scorereturn worstMove
getWorstMove()
的算法从第 186 和 187 行开始与原始算法相同,但是在第 190 行开始有所不同。我们设置一个变量来保存worstScore
,而不是bestScore
,并将其设置为64
,因为这是棋盘上的总位置数,也是整个棋盘填满时可能获得的最高分数。第 191 到 194 行与原始算法相同,但是第 195 行检查score
是否小于worstScore
,而不是检查score
是否更高。如果score
更小,则worstMove
被替换为算法当前正在测试的棋盘上的移动,并且worstScore
也会更新。然后函数返回worstMove
。
最后,第 274 行的getComputerMove()
需要更改为getWorstMove()
:
move = getWorstMove(board, computerTile)
当这样做并运行程序时,getCornerBestMove()
和getWorstMove()
将相互对抗。
随机移动 AI
随机移动 AI 只是找到所有有效的可能移动,然后随机选择一个。
def getRandomMove(board, tile):possibleMoves = getValidMoves(board, tile)return random.choice(possibleMoves)
它使用getValidMoves()
,就像所有其他 AI 一样,然后使用choice()
来返回返回列表中的一个可能移动。
检查边缘移动
在我们深入算法之前,让我们看一下我们添加的一个新的辅助函数。isOnSide()
辅助函数类似于isOnCorner()
函数,只是它检查移动是否在棋盘的边缘:
def isOnSide(x, y):return x == 0 or x == WIDTH - 1 or y == 0 or y == HEIGHT - 1
它有一个布尔表达式,检查传递给它的坐标参数的x
值或y
值是否等于0
或7
。任何带有 0 或 7 的坐标都在棋盘的边缘。
我们将在角落-边缘最佳 AI 中使用这个函数。
角落-边缘最佳 AI
角落-边缘最佳 AI 的工作方式与角落最佳 AI 非常相似,因此我们可以重用我们已经输入的一些代码。我们在getCornerSideBestMove()
函数中定义了这个 AI:
def getCornerSideBestMove(board, tile):# Return a corner move, a side move, or the best move.possibleMoves = getValidMoves(board, tile)random.shuffle(possibleMoves) # Randomize the order of the moves.# Always go for a corner if available.for x, y in possibleMoves:if isOnCorner(x, y):return [x, y]# If there is no corner move to make, return a side move.for x, y in possibleMoves:if isOnSide(x, y):return [x, y]return getCornerBestMove(board, tile) # Do what the normal AIwould do.
第 210 和 211 行与我们的最佳角落 AI 中的相同,第 214 到 216 行与我们原始的getComputerMove()
AI 中检查角落移动的算法相同。如果没有角落移动,那么第 219 到 221 行将使用isOnSide()
辅助函数检查侧面移动。一旦所有角落和侧面移动都已检查可用性,如果仍然没有移动,那么我们将重用我们的getCornerBestMove()
函数。由于之前没有角落移动,当代码到达getCornerBestMove()
函数时仍然不会有任何移动,因此此函数将只查找最高得分的移动并返回。
表 16-1 回顾了我们制作的新算法。
表 16-1: 用于反转棋 AI 的函数
函数 | 描述 |
---|---|
getCornerBestMove() | 如果有角落可用,则进行角落移动。如果没有角落,则找到得分最高的移动。 |
getCornerSideBestMove() | 如果有角落可用,则进行角落移动。如果没有角落,则在侧面占用空间。如果没有侧面可用,则使用常规的getCornerBestMove() 算法。 |
getRandomMove() | 随机选择一个有效的移动。 |
getWorstMove() | 采取导致翻转瓷砖最少的位置。 |
现在我们有了我们的算法,我们可以让它们相互对抗。
比较 AI
我们编写程序,使角落最佳 AI 与最差的移动 AI 对抗。我们可以运行程序来模拟 AI 彼此对抗的表现,并使用打印的统计数据进行分析。
除了这两个 AI,我们还制作了一些其他 AI,但我们没有调用它们。这些 AI 存在于代码中,但没有被使用,因此如果我们想看看它们在比赛中的表现,我们需要编辑代码来调用它们。由于我们已经设置了一个比较,让我们看看最差的移动 AI 对抗角落最佳 AI 的表现如何。
最差的移动 AI vs. 角落最佳 AI
运行程序,将getCornerBestMove()
函数与getWorstMove()
函数进行比较。毫不奇怪,每轮翻转最少瓷砖的策略将输掉大部分比赛:
X wins: 206 (82.4%)
O wins: 41 (16.4%)
Ties: 3 (1.2%)
令人惊讶的是,有时最差的移动策略确实有效!getCornerBestMove()
函数中的算法并非总是能够赢得比赛,而是大约 80%的时间。大约五分之一的时间会输!
这就是运行模拟程序的威力:您可以发现新颖的见解,如果您只是自己玩游戏,那么要意识到这一点将需要更长的时间。计算机速度更快!
随机移动 AI vs. 角落最佳 AI
让我们尝试不同的策略。将第 274 行的getWorstMove()
调用更改为getRandomMove()
:
move = getRandomMove(board, computerTile)
现在运行程序,它将看起来像这样:
Welcome to Reversegam!
#1: X scored 32 points. O scored 32 points.
#2: X scored 44 points. O scored 20 points.
#3: X scored 31 points. O scored 33 points.
#4: X scored 45 points. O scored 19 points.
#5: X scored 49 points. O scored 15 points.
--snip--
#249: X scored 20 points. O scored 44 points.
#250: X scored 38 points. O scored 26 points.
X wins: 195 (78.0%)
O wins: 48 (19.2%)
Ties: 7 (2.8%)
与最差的移动算法相比,随机移动算法getRandomMove()
对抗角落最佳算法的表现略好一些。这是有道理的,因为做出明智的选择通常比随机选择更好,但随机选择略好于故意选择最差的移动。
角落侧面最佳 AI vs. 角落最佳 AI
如果有角落空间可用,选择角落空间是一个好主意,因为角上的瓷砖永远不会被翻转。在侧面空间放置瓷砖似乎也是一个不错的主意,因为它被包围和翻转的方式较少。但是,这种好处是否超过了放弃会翻转更多瓷砖的移动?让我们通过将角落最佳算法与角落侧面最佳算法进行比较来找出答案。
将第 274 行的算法更改为使用getCornerSideBestMove()
:
move = getCornerSideBestMove(board, computerTile)
然后再次运行程序:
Welcome to Reversegam!
#1: X scored 27 points. O scored 37 points.
#2: X scored 39 points. O scored 25 points.
#3: X scored 41 points. O scored 23 points.
--snip--
#249: X scored 48 points. O scored 16 points.
#250: X scored 38 points. O scored 26 points.
X wins: 152 (60.8%)
O wins: 89 (35.6%)
Ties: 9 (3.6%)
哇!这是意想不到的。选择侧面空间而不是翻转更多瓷砖的空间似乎是一个糟糕的策略。侧面空间的好处不足以抵消翻转对手更少瓷砖的成本。我们能确定这些结果吗?让我们再次运行程序,但这次通过在AISim3.py中将第 278 行更改为NUM_GAMES = 1000
来玩 1,000 场比赛。现在,程序可能需要几分钟才能在您的计算机上运行,但如果您手动操作,可能需要几周的时间!
您将看到,1,000 场比赛的更准确的统计数据与 250 场比赛的统计数据几乎相同。似乎选择翻转最多瓷砖的移动比选择侧面移动更好。
我们刚刚使用编程找出了哪种游戏策略最有效。当您听说科学家使用计算机模型时,他们正在做的就是这个。他们使用模拟重新创建一些真实世界的过程,然后在模拟中进行测试,以了解更多关于真实世界的信息。
摘要
本章没有涵盖新游戏,而是对反转棋进行了各种策略建模。如果我们认为在反转棋中采取侧面移动是一个好主意,我们将不得不花费几周,甚至几个月的时间仔细玩反转棋游戏,并写下结果来测试这个想法。但是,如果我们知道如何编写计算机来玩反转棋,那么我们可以让它为我们尝试不同的策略。如果您仔细想想,计算机在几秒钟内执行了数百万行我们的 Python 程序!您对反转棋模拟的实验可以帮助您更多地了解如何在现实生活中玩它。
事实上,这一章将成为一个很好的科学展览项目。您可以研究哪种移动方式导致对其他移动方式的最多胜利,并且您可以对哪种是最佳策略提出假设。运行几次模拟后,您可以确定哪种策略最有效。通过编程,您可以将任何棋盘游戏的模拟制作成一个科学展览项目!这一切都是因为您知道如何逐步指导计算机做这些事情,一行一行地。您可以用计算机的语言并让它为您进行大量的数据处理和数值计算。
这本书中的基于文本的游戏就到此为止了。尽管它们很简单,但只使用文本的游戏也可以很有趣。但是,大多数现代游戏使用图形、声音和动画使它们更加令人兴奋。在本书的其余章节中,您将学习如何使用名为pygame
的 Python 模块创建具有图形的游戏。
十七、创建图形
原文:
inventwithpython.com/invent4thed/chapter17.html
译者:飞龙
协议:CC BY-NC-SA 4.0
到目前为止,我们所有的游戏都只使用了文本。文本作为输出显示在屏幕上,玩家输入文本作为输入。只使用文本使得编程变得容易学习。但在本章中,我们将使用pygame
模块制作一些更加令人兴奋的高级图形程序。
第 17 章、第 18 章、第 19 章和第 20 章将教你如何使用pygame
制作具有图形、动画、鼠标输入和声音的游戏。在这些章节中,我们将编写演示pygame
概念的简单程序的源代码。然后在第 21 章,我们将整合我们学到的所有概念来创建一个游戏。
本章涵盖的主题
-
安装
pygame
-
pygame
中的颜色和字体 -
锯齿和抗锯齿图形
-
属性
-
pygame.font.Font
、pygame.Surface
、pygame.Rect
和pygame.PixelArray
数据类型 -
构造函数
-
pygame
的绘图功能 -
表面对象的
blit()
方法 -
事件
安装 pygame
pygame
模块帮助开发者通过在计算机屏幕上更容易绘制图形或向程序添加音乐来创建游戏。该模块不随 Python 一起提供,但与 Python 一样,可以免费下载。在www.nostarch.com/inventwithpython/
下载pygame
,并按照你的操作系统的说明进行操作。
安装程序文件下载完成后,打开它并按照说明进行操作,直到pygame
安装完成。要检查它是否安装正确,输入以下内容到交互式 shell 中:
>>> import pygame
如果按下回车后什么都没有出现,那么你就知道pygame
已经成功安装了。如果出现错误ImportError: No module named pygame
,请尝试重新安装pygame
(并确保你正确输入了import pygame
)。
注意
在编写 Python 程序时,不要将文件保存为 pygame.py*。如果你这样做,*import pygame
行将导入你的文件而不是真正的pygame
模块,你的代码将无法工作。
pygame 中的 Hello World
首先,我们将制作一个新的pygame
Hello World 程序,就像你在书的开头创建的那样。这一次,你将使用pygame
在图形窗口中显示Hello world!
,而不是作为文本。在本章中,我们将只是使用pygame
在窗口上绘制一些形状和线条,但你将很快使用这些技能来制作你的第一个动画游戏。
pygame
模块与交互式 shell 不兼容,所以你只能在文件编辑器中编写使用pygame
的程序;你不能通过交互式 shell 逐条发送指令。
此外,pygame
程序不使用print()
或input()
函数。没有文本输入或输出。相反,pygame
通过在单独的窗口中绘制图形和文本来显示输出。pygame
的输入来自键盘和鼠标通过事件,这些在“事件和游戏循环”中有介绍,第 270 页。
pygame Hello World 的示例运行
当你运行图形化的 Hello World 程序时,你应该会看到一个新的窗口,看起来像图 17-1。
图 17-1: pygame Hello World 程序
使用窗口而不是控制台的好处是文本可以出现在窗口的任何位置,而不仅仅是在你打印的先前文本之后。文本也可以是任何颜色和大小。窗口就像一个画布,你可以在上面画任何你喜欢的东西。
pygame Hello World 的源代码
将以下代码输入文件编辑器并将其保存为pygameHelloWorld.py。如果在输入此代码后出现错误,请将你输入的代码与本书代码进行比较,使用在线差异工具www.nostarch.com/inventwithpython#diff
。
pygame HelloWorld.py
import pygame, sys
from pygame.locals import *# Set up pygame.
pygame.init()# Set up the window.
windowSurface = pygame.display.set_mode((500, 400), 0, 32)
pygame.display.set_caption('Hello world!')# Set up the colors.
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)# Set up the fonts.
basicFont = pygame.font.SysFont(None, 48)# Set up the text.
text = basicFont.render('Hello world!', True, WHITE, BLUE)
textRect = text.get_rect()
textRect.centerx = windowSurface.get_rect().centerx
textRect.centery = windowSurface.get_rect().centery# Draw the white background onto the surface.
windowSurface.fill(WHITE)# Draw a green polygon onto the surface.
pygame.draw.polygon(windowSurface, GREEN, ((146, 0), (291, 106),(236, 277), (56, 277), (0, 106)))# Draw some blue lines onto the surface.
pygame.draw.line(windowSurface, BLUE, (60, 60), (120, 60), 4)
pygame.draw.line(windowSurface, BLUE, (120, 60), (60, 120))
pygame.draw.line(windowSurface, BLUE, (60, 120), (120, 120), 4)# Draw a blue circle onto the surface.
pygame.draw.circle(windowSurface, BLUE, (300, 50), 20, 0)# Draw a red ellipse onto the surface.
pygame.draw.ellipse(windowSurface, RED, (300, 250, 40, 80), 1)# Draw the text's background rectangle onto the surface.
pygame.draw.rect(windowSurface, RED, (textRect.left - 20,textRect.top - 20, textRect.width + 40, textRect.height + 40))# Get a pixel array of the surface.
pixArray = pygame.PixelArray(windowSurface)
pixArray[480][380] = BLACK
del pixArray# Draw the text onto the surface.
windowSurface.blit(text, textRect)# Draw the window onto the screen.
pygame.display.update()# Run the game loop.
while True:for event in pygame.event.get():if event.type == QUIT:pygame.quit()sys.exit()
导入 pygame 模块
让我们逐行查看这些代码,并找出它们的作用。
首先,你需要导入pygame
模块,这样你就可以调用它的函数。你可以在同一行上导入多个模块,用逗号分隔模块名。第 1 行导入了pygame
和sys
模块:
import pygame, sys
from pygame.locals import *
第二行导入了pygame.locals
模块。这个模块包含许多与pygame
一起使用的常量变量,比如QUIT
,它帮助你退出程序,以及K_ESCAPE
,它代表 ESC 键。
第 2 行还允许你在不必在每个从模块调用的方法、常量或其他内容前面输入pygames.locals.
的情况下使用pygames.locals
模块。
如果你的程序中有from sys import *
而不是import sys
,你可以在你的代码中调用exit()
而不是sys.exit()
。但大多数情况下最好使用完整的函数名,这样你就知道函数在哪个模块中。
初始化 pygame
所有pygame
程序在导入pygame
模块后必须调用pygame.init()
,但在调用任何其他pygame
函数之前:
# Set up pygame.
pygame.init()
这将初始化pygame
,使其准备好使用。你不需要知道init()
做了什么;你只需要记住在使用任何其他pygame
函数之前调用它。
设置 pygame 窗口
第 8 行通过在pygame.display
模块中调用set_mode()
方法创建了一个*图形用户界面(GUI)*窗口。(display
模块是pygame
模块内的一个模块。即使pygame
模块也有自己的模块!)
# Set up the window.
windowSurface = pygame.display.set_mode((500, 400), 0, 32)
pygame.display.set_caption('Hello world!')
这些方法帮助设置pygame
运行的窗口。就像声纳寻宝游戏一样,窗口使用一个坐标系统,但窗口的坐标系统是以像素为单位的。
像素是计算机屏幕上最小的点。屏幕上的单个像素可以以任何颜色点亮。屏幕上的所有像素共同工作,显示你看到的图片。我们将使用元组创建一个宽度为 500 像素、高度为 400 像素的窗口。
元组
元组值类似于列表,只是它们使用圆括号而不是方括号。此外,像字符串一样,元组是不可修改的。例如,将以下内容输入交互式 shell:
>>> spam = ('Life', 'Universe', 'Everything', 42)
➊ >>> spam[0]'Life'>>> spam[3]42
➋ >>> spam[1:3]('Universe', 'Everything')
➌ >>> spam[3] = 'Hello'
➍ Traceback (most recent call last):File "<stdin>", line 1, in <module>TypeError: 'tuple' object does not support item assignment
正如你从示例中看到的,如果你想获取元组的一个项目➊或一系列项目➋,你仍然使用方括号,就像你对待列表一样。然而,如果你尝试将索引为3
的项目更改为字符串'Hello'
➌,Python 将引发错误 ➍。
我们将使用元组来设置pygame
窗口。pygame.display.set_mode()
方法有三个参数。第一个是一个包含两个整数的元组,用于窗口的宽度和高度,以像素为单位。要设置一个 500x400 像素的窗口,可以使用元组(500, 400)
作为set_mode()
的第一个参数。第二个和第三个参数是高级选项,超出了本书的范围。只需分别为它们传递0
和32
。
表面对象
set_mode()
函数返回一个pygame.Surface
对象(我们将其简称为Surface
对象)。对象只是数据类型的值,具有方法的引用。例如,字符串在 Python 中是对象,因为它们有数据(字符串本身)和方法(如lower()
和split()
)。Surface
对象表示窗口。
变量存储对对象的引用,就像它们为列表和字典存储引用一样(参见“列表引用”第 132 页)。
第 9 行的set_caption()
方法只是将窗口的标题设置为'Hello World!'
。标题位于窗口的左上角。
设置颜色变量
像素的三原色是红色、绿色和蓝色。通过组合不同数量的这三种颜色(这是您的计算机屏幕所做的),您可以形成任何其他颜色。在pygame
中,颜色由三个整数的元组表示。这些称为RGB 颜色值,我们将在程序中使用它们来为像素分配颜色。由于我们不想每次在程序中使用特定颜色时都重写一个三数元组,我们将创建常量来保存以颜色命名的元组:
# Set up the colors.
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
元组中的第一个值确定颜色中有多少红色。值为0
表示颜色中没有红色,值为255
表示颜色中有最大量的红色。第二个值是绿色,第三个值是蓝色。这三个整数形成一个 RGB 元组。
例如,元组(0, 0, 0)
没有红色、绿色或蓝色。结果颜色完全是黑色,就像第 12 行一样。元组(255, 255, 255)
具有最大量的红色、绿色和蓝色,导致白色,就像第 13 行一样。
我们还将使用红色、绿色和蓝色,这些颜色在第 14 至 16 行中分配。元组(255, 0, 0)
表示最大量的红色但没有绿色或蓝色,因此结果颜色是红色。类似地,(0, 255, 0)
是绿色,(0, 0, 255)
是蓝色。
您可以混合红色、绿色和蓝色的数量来获得任何颜色的任何阴影。表 17-1 列出了一些常见颜色及其 RGB 值。网页www.nostarch.com/inventwithpython/
列出了更多不同颜色的元组值。
**表 17-1:**颜色及其 RGB 值
颜色 | RGB 值 |
---|---|
黑色 | (0, 0, 0) |
蓝色 | (0, 0, 255) |
灰色 | (128, 128, 128) |
绿色 | (0, 128, 0) |
酸橙色 | (0, 255, 0) |
紫色 | (128, 0, 128) |
红色 | (255, 0, 0) |
青色 | (0, 128, 128) |
白色 | (255, 255, 255) |
黄色 | (255, 255, 0) |
我们将只使用我们已经定义的五种颜色,但在您的程序中,您可以使用任何这些颜色,甚至可以编造不同的颜色。
在 pygame 窗口上写文本
在窗口上写文本与我们在基于文本的游戏中所做的print()
有些不同。为了在窗口上写文本,我们需要先进行一些设置。
使用字体来设置文本样式
字体是以单一风格绘制的完整的字母、数字、符号和字符集。每当我们需要在pygame
窗口上打印文本时,我们都会使用字体。图 17-2 显示了相同句子以不同字体打印的情况。
图 17-2:不同字体的示例
在我们之前的游戏中,我们只告诉 Python 打印文本。用于显示此文本的颜色、大小和字体完全由您的操作系统确定。Python 程序无法更改字体。但是,pygame
可以在计算机上的任何字体上绘制文本。
第 19 行通过调用pygame.font.SysFont()
函数并传入两个参数创建了一个pygame.font.Font
对象(简称Font
对象):
# Set up the fonts.
basicFont = pygame.font.SysFont(None, 48)
第一个参数是字体的名称,但我们将传递None
值以使用默认系统字体。第二个参数是字体的大小(以点为单位)。我们将在默认字体上以 48 点的大小在窗口上绘制'Hello world!'
。生成像Hello world!
这样的文本的字母图像称为渲染。
渲染字体对象
您存储在basicFont
变量中的Font
对象具有一个名为render()
的方法。此方法将返回一个带有文本绘制的Surface
对象。render()
的第一个参数是要绘制的文本字符串。第二个参数是一个布尔值,用于确定是否反锯齿字体。反锯齿会使文本略微模糊,使其看起来更平滑。图 17-3 显示了有无反锯齿的线条样子。
图 17-3:锯齿线和反锯齿线的放大视图
在第 22 行,我们传递True
以使用反锯齿:
# Set up the text.
text = basicFont.render('Hello world!', True, WHITE, BLUE)
第 22 行的第三个和第四个参数都是 RGB 元组。第三个参数是文本将呈现的颜色(在本例中为白色),第四个是文本背后的背景颜色(蓝色)。我们将Surface
对象分配给变量text
。
一旦我们设置了Font
对象,我们就需要将其放置在窗口上的某个位置。
使用 Rect 属性设置文本位置
pygame.Rect
数据类型(简称Rect
)表示特定大小和位置的矩形区域。这是我们用来设置窗口上对象位置的方法。
要创建一个新的Rect
对象,您需要调用函数pygame.Rect()
。请注意,pygame.Rect()
函数与pygame.Rect
数据类型具有相同的名称。具有与其数据类型相同名称的函数,用于创建其数据类型的对象或值,称为构造函数。pygame.Rect()
函数的参数是左上角的 x 和 y 坐标的整数,然后是宽度和高度,都以像素为单位。函数名称与参数如下:pygame.Rect(left
,top,width,height)。
当我们创建Font
对象时,已经为其创建了一个Rect
对象,所以现在我们只需要检索它。为此,我们使用text
上的get_rect()
方法,并将Rect
分配给textRect
:
textRect = text.get_rect()
textRect.centerx = windowSurface.get_rect().centerx
textRect.centery = windowSurface.get_rect().centery
方法与对象相关联,属性是与对象相关联的变量。Rect
数据类型具有许多属性,用于描述它们所代表的矩形。为了在窗口上设置textRect
的位置,我们需要将其中心x
和y
值分配给窗口上的像素坐标。由于每个Rect
对象已经具有存储Rect
中心的 x 和 y 坐标的属性,分别称为centerx
和centery
,我们只需要分配这些坐标值。
我们希望将textRect
放在窗口的中心,因此我们需要获取windowSurface Rect
,获取其centerx
和centery
属性,然后将这些属性分配给textRect
的centerx
和centery
属性。我们在第 24 和 25 行中执行此操作。
有许多其他Rect
属性可以使用。表 17-2 是名为myRect
的Rect
对象的属性列表。
表 17-2: Rect 属性
pygame.Rect 属性 | 描述 |
---|---|
myRect.left | 矩形左侧的 x 坐标的整数值 |
myRect.right | 矩形右侧的 x 坐标的整数值 |
myRect.top | 矩形顶部的 y 坐标的整数值 |
myRect.bottom | 矩形底部的 y 坐标的整数值 |
myRect.centerx | 矩形中心的 x 坐标的整数值 |
myRect.centery | 矩形中心的 y 坐标的整数值 |
myRect.width | 矩形的宽度的整数值 |
myRect.height | 矩形的高度的整数值 |
myRect.size | 两个整数的元组:(width, height) |
myRect.topleft | 两个整数的元组:(left, top) |
myRect.topright | 两个整数的元组:(right, top) |
myRect.bottomleft | 两个整数的元组:(left, bottom) |
myRect.bottomright | 一个包含两个整数的元组:(right, bottom) |
myRect.midleft | 一个包含两个整数的元组:(left, centery) |
myRect.midright | 一个包含两个整数的元组:(right, centery) |
myRect.midtop | 一个包含两个整数的元组:(centerx, top) |
myRect.midbottom | 一个包含两个整数的元组:(centerx, bottom) |
Rect
对象的好处是,如果您修改了这些属性中的任何一个,所有其他属性也将自动修改。例如,如果您创建一个宽 20 像素,高 20 像素,并且左上角坐标为(30, 40)
的Rect
对象,那么右侧的 x 坐标将自动设置为50
(因为 20 + 30 = 50)。
或者,如果您改变left
属性为100
,那么pygame
将自动将right
属性更改为120
(因为 20 + 100 = 120)。该Rect
对象的每个其他属性也将被更新。
关于方法、模块和数据类型的更多信息
在pygame
模块内部有font
和surface
模块,而在这些模块内部有Font
和Surface
数据类型。pygame
程序员以小写字母开头命名模块,以大写字母开头命名数据类型,以便更容易区分数据类型和模块。
请注意,Font
对象(存储在第 23 行的text
变量中)和Surface
对象(存储在第 24 行的windowSurface
变量中)都有一个名为get_rect()
的方法。从技术上讲,这是两种不同的方法,但是pygame
的程序员给它们取了相同的名字,因为它们都做同样的事情:返回表示Font
或Surface
对象的大小和位置的Rect
对象。
用颜色填充 Surface 对象
对于我们的程序,我们希望用颜色白色填充存储在windowSurface
中的整个表面。fill()
函数将使用您传递的颜色完全覆盖表面。 (请记住,WHITE
变量在第 13 行被设置为值(255, 255, 255)
。)
# Draw the white background onto the surface.
windowSurface.fill(WHITE)
请注意,在pygame
中,当您调用fill()
方法或任何其他绘图函数时,屏幕上的窗口不会改变。相反,这些将改变Surface
对象,您必须使用pygame.display.update()
函数将新的Surface
对象渲染到屏幕上才能看到更改。
这是因为修改计算机内存中的Surface
对象比修改屏幕上的图像要快得多。在所有绘图函数绘制到Surface
对象之后,一次性绘制到屏幕上要比在屏幕上绘制更有效。
pygame 的绘图函数
到目前为止,我们已经学会了如何用颜色填充pygame
窗口并添加文本,但是pygame
还有一些函数可以让您绘制形状和线条。每个形状都有自己的函数,您可以将这些形状组合成不同的图片用于您的图形游戏。
绘制多边形
pygame.draw.polygon()
函数可以绘制任何多边形形状。多边形是一个具有直线边的多边形。圆和椭圆不是多边形,因此我们需要使用不同的函数来绘制这些形状。
函数的参数按顺序如下:
-
要在上面绘制多边形的
Surface
对象。 -
多边形的颜色。
-
一个包含 x 和 y 坐标的点的元组的元组,以便按顺序绘制。最后一个元组将自动连接到第一个元组以完成形状。
-
可选的多边形线宽的整数。如果没有这个,多边形将被填充。
在第 31 行,我们在白色的Surface
对象上绘制了一个绿色的多边形。
# Draw a green polygon onto the surface.
pygame.draw.polygon(windowSurface, GREEN, ((146, 0), (291, 106),(236, 277), (56, 277), (0, 106)))
我们希望我们的多边形被填充,所以我们不给出最后一个可选的整数线宽。图 17-4 显示了一些多边形的示例。
图 17-4:多边形的示例
绘制线条
pygame.draw.line()
函数只是从屏幕上的一个点到另一个点绘制一条线。pygame.draw.line()
的参数依次为:
-
要在其上绘制线的
Surface
对象。 -
线的颜色。
-
一个包含两个整数的元组,表示线的一端的 x 和 y 坐标。
-
一个包含两个整数的元组,表示线的另一端的 x 和 y 坐标。
-
可选的,表示线的宽度的整数。
在 34 到 36 行,我们三次调用pygame.draw.line()
:
# Draw some blue lines onto the surface.
pygame.draw.line(windowSurface, BLUE, (60, 60), (120, 60), 4)
pygame.draw.line(windowSurface, BLUE, (120, 60), (60, 120))
pygame.draw.line(windowSurface, BLUE, (60, 120), (120, 120), 4)
如果不指定width
参数,它将采用默认值1
。在 34 和 36 行,我们传递4
作为宽度,因此线条将有 4 像素厚。在 34、35 和 36 行的三个pygame.draw.line()
调用绘制了Surface
对象上的蓝色 Z。
绘制圆
pygame.draw.circle()
函数在Surface
对象上绘制圆。它的参数依次为:
-
要在其上绘制圆的
Surface
对象。 -
圆的颜色。
-
一个包含两个整数的元组,表示圆的中心的 x 和 y 坐标。
-
圆的半径的整数(即大小)。
-
可选的,表示线的宽度的整数。宽度为
0
意味着圆将被填充。
第 39 行在Surface
对象上绘制了一个蓝色圆:
# Draw a blue circle onto the surface.
pygame.draw.circle(windowSurface, BLUE, (300, 50), 20, 0)
这个圆的中心位于 x 坐标 300 和 y 坐标 50。圆的半径为 20 像素,填充为蓝色。
绘制椭圆
pygame.draw.ellipse()
函数类似于pygame.draw.circle()
函数,但它绘制的是一个椭圆,类似于一个扁平的圆。pygame.draw.ellipse()
函数的参数依次为:
-
要在其上绘制椭圆的
Surface
对象。 -
椭圆的颜色。
-
一个包含四个整数的元组,表示椭圆的
Rect
对象的左上角和宽度和高度。 -
可选的,表示线的宽度的整数。宽度为
0
意味着椭圆将被填充。
第 42 行在Surface
对象上绘制了一个红色椭圆:
# Draw a red ellipse onto the surface.
pygame.draw.ellipse(windowSurface, RED, (300, 250, 40, 80), 1)
椭圆的左上角位于 x 坐标 300 和 y 坐标 250。形状宽 40 像素,高 80 像素。椭圆的轮廓宽 1 像素。
绘制矩形
pygame.draw.rect()
函数将绘制一个矩形。pygame.draw.rect()
函数的参数依次为:
-
要在其上绘制矩形的
Surface
对象。 -
矩形的颜色。
-
一个包含四个整数的元组,分别表示左上角的 x 和 y 坐标以及矩形的宽度和高度。作为第三个参数的四个整数的元组,也可以传递一个
Rect
对象。
在 Hello World 程序中,我们希望绘制的矩形在text
的四周可见 20 像素。请记住,在第 23 行,我们创建了一个textRect
来包含我们的文本。在第 45 行,我们将矩形的左上点设置为textRect
的左上点减 20(我们减去因为随着向左和向上移动,坐标会减小):
# Draw the text's background rectangle onto the surface.
pygame.draw.rect(windowSurface, RED, (textRect.left - 20,textRect.top - 20, textRect.width + 40, textRect.height + 40))
矩形的宽度和高度等于textRect
的宽度和高度加 40。我们使用 40 而不是 20,因为左上角向后移动了 20 像素,所以您需要弥补这个空间。
着色像素
第 48 行创建了一个pygame.PixelArray
对象(简称PixelArray
对象)。PixelArray
对象是一个颜色元组的列表,表示您传递给它的Surface
对象。
PixelArray
对象为您提供了高像素级别的控制,因此如果您需要向屏幕绘制非常详细或自定义的图像,而不仅仅是大形状,这是一个不错的选择。
我们将使用PixelArray
将windowSurface
上的一个像素涂黑。当您运行pygame
Hello World 时,您可以在窗口的右下角看到这个像素。
第 48 行将windowSurface
传递给pygame.PixelArray()
调用,因此在第 49 行将BLACK
赋值给pixArray[480][380]
将使坐标(480, 380)
处的像素变为黑色:
# Get a pixel array of the surface.
pixArray = pygame.PixelArray(windowSurface)
pixArray[480][380] = BLACK
pygame 模块将自动修改 windowSurface 对象以进行此更改。
PixelArray 对象中的第一个索引是 x 坐标。第二个索引是 y 坐标。PixelArray 对象使得可以轻松地将 Surface 对象上的单个像素设置为特定颜色。
每次从 Surface 对象创建 PixelArray 对象时,该 Surface 对象都会被锁定。这意味着不能在该 Surface 对象上进行任何 blit()方法调用(下面描述)。要解锁 Surface 对象,必须使用 del 运算符删除 PixelArray 对象:
del pixArray
如果忘记这样做,将收到错误消息,内容为pygame.error: Surfaces must not be locked during blit
。
Surface 对象的 blit()方法
blit()
方法将一个 Surface 对象的内容绘制到另一个 Surface 对象上。由render()
方法创建的所有文本对象都存在于它们自己的 Surface 对象上。pygame 绘图方法都可以指定要在其上绘制形状或线条的 Surface 对象,但我们的文本存储在 text 变量中,而不是绘制到 windowSurface 上。为了在我们希望出现的 Surface 上绘制文本,我们必须使用 blit()方法:
# Draw the text onto the surface.
windowSurface.blit(text, textRect)
第 53 行将存储在 windowSurface 变量中的 Surface 对象上的’Hello world!’ Surface 对象绘制到 text 变量(在第 22 行定义)上。
blit()的第二个参数指定了 text 表面应该在 windowSurface 上绘制的位置。在第 23 行调用 text.get_rect()得到的 Rect 对象被传递给这个参数。
将 Surface 对象绘制到屏幕上
由于在 pygame 中直到调用函数 pygame.display.update()才会实际绘制到屏幕上,我们在第 56 行调用它来显示我们更新的 Surface 对象:
# Draw the window onto the screen.
pygame.display.update()
为了节省内存,你不希望在每个绘图函数之后都更新屏幕;相反,你希望在所有绘图函数调用之后只更新一次屏幕。
事件和游戏循环
在以前的游戏中,所有程序都会立即打印所有内容,直到达到input()
函数调用。在那时,程序将停止并等待用户输入并按 ENTER 键。但是 pygame 程序不断通过游戏循环运行,该循环每秒执行大约 100 次代码行。
游戏循环不断检查新事件,更新窗口的状态,并在屏幕上绘制窗口。事件是由 pygame 在用户按键,点击或移动鼠标,或执行程序识别的其他一些动作时生成的。Event 是 pygame.event.Event 数据类型的对象。
第 59 行是游戏循环的开始:
# Run the game loop.
while True:
while 语句的条件设置为 True,以便它永远循环。循环退出的唯一时间是如果事件导致程序终止。
获取事件对象
函数pygame.event.get()
检查自上次调用pygame.event.get()
以来生成的任何新的 pygame.event.Event 对象(简称为 Event 对象)。这些事件作为 Event 对象的列表返回,然后程序将执行这些事件以响应事件执行某些操作。所有 Event 对象都有一个名为 type 的属性,告诉我们事件的类型。在本章中,我们只需要使用 QUIT 事件类型,该类型告诉我们用户何时退出程序:
for event in pygame.event.get():if event.type == QUIT:
在第 60 行,我们使用 for 循环遍历 pygame.event.get()返回的事件对象列表中的每个 Event 对象。如果事件的 type 属性等于我们在程序开始时导入的 pygame.locals 模块中的常量变量 QUIT,那么你就知道 QUIT 事件已经生成。
当用户关闭程序窗口或计算机关闭并尝试终止所有运行中的程序时,pygame
模块会生成QUIT
事件。接下来,我们将告诉程序在检测到QUIT
事件时该做什么。
退出程序
如果生成了QUIT
事件,程序将调用pygame.quit()
和sys.exit()
:
pygame.quit()sys.exit()
pygame.quit()
函数有点像init()
的相反。在退出程序之前,您需要调用它。如果您忘记了,可能会导致 IDLE 在程序结束后挂起。第 62 和 63 行退出pygame
并结束程序。
摘要
在本章中,我们涵盖了许多新主题,这些主题将使我们能够做的事情比以前的游戏要多得多。与仅通过调用print()
和input()
来处理文本不同,pygame
程序有一个空白窗口—由pygame.display.set_mode()
创建—我们可以在上面绘制。pygame
的绘图函数让您可以在窗口中以许多颜色绘制形状。您还可以创建各种大小的文本。这些绘图可以位于窗口内的任何 x 和 y 坐标,而不像print()
创建的文本。
尽管代码更复杂,但pygame
程序比文本游戏更有趣。接下来,让我们学习如何创建具有动画图形的游戏。
十八、让图形动起来
原文:
inventwithpython.com/invent4thed/chapter18.html
译者:飞龙
协议:CC BY-NC-SA 4.0
现在您已经学会了一些pygame
技巧,我们将编写一个程序来使盒子在窗口中反弹。这些盒子颜色和大小不同,只能沿对角线方向移动。为了使盒子动起来,我们将在游戏循环的每次迭代中将它们移动几个像素。这将使它看起来像盒子在屏幕上移动。
本章涵盖的主题
-
使用游戏循环来使对象动画化
-
改变对象的方向
动画程序的示例运行
当您运行动画程序时,它将类似于图 18-1。方块将从窗口的边缘反弹。
图 18-1:动画程序的屏幕截图
动画程序的源代码
将以下程序输入文件编辑器并保存为animation.py。如果在输入此代码后出现错误,请使用在线差异工具比较您输入的代码与本书代码的差异,网址为www.nostarch.com/inventwithpython#diff
。
animation.py
import pygame, sys, time
from pygame.locals import *# Set up pygame.
pygame.init()# Set up the window.
WINDOWWIDTH = 400
WINDOWHEIGHT = 400
windowSurface = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT),0, 32)
pygame.display.set_caption('Animation')# Set up direction variables.
DOWNLEFT = 'downleft'
DOWNRIGHT = 'downright'
UPLEFT = 'upleft'
UPRIGHT = 'upright'MOVESPEED = 4# Set up the colors.
WHITE = (255, 255, 255)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)# Set up the box data structure.
b1 = {'rect':pygame.Rect(300, 80, 50, 100), 'color':RED, 'dir':UPRIGHT}
b2 = {'rect':pygame.Rect(200, 200, 20, 20), 'color':GREEN, 'dir':UPLEFT}
b3 = {'rect':pygame.Rect(100, 150, 60, 60), 'color':BLUE, 'dir':DOWNLEFT}
boxes = [b1, b2, b3]# Run the game loop.
while True:# Check for the QUIT event.for event in pygame.event.get():if event.type == QUIT:pygame.quit()sys.exit()# Draw the white background onto the surface.windowSurface.fill(WHITE)for b in boxes:# Move the box data structure.if b['dir'] == DOWNLEFT:b['rect'].left -= MOVESPEEDb['rect'].top += MOVESPEEDif b['dir'] == DOWNRIGHT:b['rect'].left += MOVESPEEDb['rect'].top += MOVESPEEDif b['dir'] == UPLEFT:b['rect'].left -= MOVESPEEDb['rect'].top -= MOVESPEEDif b['dir'] == UPRIGHT:b['rect'].left += MOVESPEEDb['rect'].top -= MOVESPEED# Check whether the box has moved out of the window.if b['rect'].top < 0:# The box has moved past the top.if b['dir'] == UPLEFT:b['dir'] = DOWNLEFTif b['dir'] == UPRIGHT:b['dir'] = DOWNRIGHTif b['rect'].bottom > WINDOWHEIGHT:# The box has moved past the bottom.if b['dir'] == DOWNLEFT:b['dir'] = UPLEFTif b['dir'] == DOWNRIGHT:b['dir'] = UPRIGHTif b['rect'].left < 0:# The box has moved past the left side.if b['dir'] == DOWNLEFT:b['dir'] = DOWNRIGHTif b['dir'] == UPLEFT:b['dir'] = UPRIGHTif b['rect'].right > WINDOWWIDTH:# The box has moved past the right side.if b['dir'] == DOWNRIGHT:b['dir'] = DOWNLEFTif b['dir'] == UPRIGHT:b['dir'] = UPLEFT# Draw the box onto the surface.pygame.draw.rect(windowSurface, b['color'], b['rect'])# Draw the window onto the screen.pygame.display.update()time.sleep(0.02)
移动和反弹盒子
在这个程序中,我们将有三个不同颜色的盒子在窗口中移动并在墙壁上反弹。在接下来的章节中,我们将使用这个程序作为基础,制作一个我们可以控制其中一个盒子的游戏。为此,首先我们需要考虑我们希望盒子如何移动。
每个盒子将沿着四个对角方向移动。当盒子撞到窗口的边缘时,它应该反弹并沿着新的对角方向移动。盒子将如图 18-2 所示反弹。
图 18-2:盒子将如何反弹
盒子在反弹后移动的新方向取决于两件事:反弹前的移动方向和反弹的墙壁。盒子可以有八种可能的反弹方式:每个墙壁有两种不同的方式。例如,如果一个盒子向下和向右移动,然后从窗口的底边反弹,我们希望盒子的新方向是向上和向右。
我们可以使用Rect
对象来表示盒子的位置和大小,使用三个整数的元组来表示盒子的颜色,以及使用一个整数来表示盒子当前移动的四个对角方向中的哪一个。
游戏循环将调整Rect
对象中盒子的 x 和 y 位置,并在每次迭代时在屏幕上绘制所有盒子的当前位置。随着程序执行在循环上迭代,盒子将逐渐在屏幕上移动,看起来它们平滑地移动和反弹。
设置常量变量
第 1 至 5 行只是设置我们的模块并初始化pygame
,就像我们在第 17 章中所做的那样:
import pygame, sys, time
from pygame.locals import *# Set up pygame.
pygame.init()# Set up the window.
WINDOWWIDTH = 400
WINDOWHEIGHT = 400
windowSurface = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT),0, 32)
pygame.display.set_caption('Animation')
在第 8 和 9 行,我们定义了窗口宽度和高度的两个常量,然后在第 10 行,我们使用这些常量设置了windowSurface
,它将代表我们的pygame
窗口。第 11 行使用set_caption()
将窗口的标题设置为’动画’。
在这个程序中,您会看到窗口宽度和高度的大小不仅用于调用set_mode()
。我们将使用常量变量,这样如果您想要更改窗口的大小,只需更改第 8 和 9 行。由于窗口的宽度和高度在程序执行期间不会改变,因此常量变量是一个好主意。
方向的常量变量
我们将为框可以移动的四个方向使用常量变量:
# Set up direction variables.
DOWNLEFT = 'downleft'
DOWNRIGHT = 'downright'
UPLEFT = 'upleft'
UPRIGHT = 'upright'
你可以使用任何你想要的值来代替使用常量变量来表示这些方向。例如,你可以直接使用字符串'downleft'
来表示向下和向左的对角方向,并在每次需要指定该方向时重新输入该字符串。然而,如果你不小心拼错了'downleft'
字符串,你的程序会表现得很奇怪,即使程序不会崩溃。
如果你使用常量变量而不小心拼错了变量名,Python 会注意到没有该名称的变量,并用错误使程序崩溃。这仍然是一个相当糟糕的 bug,但至少你会立即知道并且可以修复它。
我们还创建了一个常量变量来确定框移动的速度:
MOVESPEED = 4
常量变量MOVESPEED
中的值4
告诉程序每次通过游戏循环迭代时每个框应该移动多少像素。
颜色的常量变量
第 22 到 25 行设置了颜色的常量变量。记住,pygame
使用三个整数值的元组来表示红色、绿色和蓝色的数量,称为 RGB 值。整数的范围是从0
到255
。
# Set up the colors.
WHITE = (255, 255, 255)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
常量变量用于可读性,就像pygame
Hello World 程序中一样。
设置框数据结构
接下来我们将定义框。为了简化,我们将设置一个字典作为数据结构(参见“字典数据类型”第 112 页)。字典将具有键'rect'
(值为Rect
对象)、'color'
(值为三个整数的元组)和'dir'
(值为一个方向常量变量)。我们现在只设置了三个框,但是你可以通过定义更多的数据结构来设置更多的框。我们稍后将使用的动画代码可以用来为你在设置数据结构时定义的任意数量的框进行动画。
变量b1
将存储一个这样的框数据结构:
# Set up the box data structure.
b1 = {'rect':pygame.Rect(300, 80, 50, 100), 'color':RED, 'dir':UPRIGHT}
这个框的左上角位于 x 坐标300
和 y 坐标80
。它的宽度为50
像素,高度为100
像素。它的颜色是RED
,初始方向是UPRIGHT
。
第 29 和 30 行创建了另外两个类似的数据结构,用于不同大小、位置、颜色和方向的框:
b2 = {'rect':pygame.Rect(200, 200, 20, 20), 'color':GREEN, 'dir':UPLEFT}
b3 = {'rect':pygame.Rect(100, 150, 60, 60), 'color':BLUE, 'dir':DOWNLEFT}
boxes = [b1, b2, b3]
如果你需要从列表中检索一个框或值,你可以使用索引和键。输入boxes[0]
将访问b1
中的字典数据结构。如果我们输入boxes[0]['color']
,那将访问b1
中的'color'
键,因此表达式boxes[0]['color']
将求值为(255, 0, 0)
。你可以通过从boxes
开始引用任何框数据结构中的任何值。然后,b1
、b2
和b3
这三个字典被存储在boxes
变量的列表中。
游戏循环
游戏循环处理移动框的动画。动画是通过绘制一系列具有轻微差异的图片来实现的,这些图片依次显示。在我们的动画中,图片将是移动的框,而轻微的差异将在每个框的位置上。每个框在每张图片中移动 4 个像素。图片显示得如此之快,以至于框看起来像是在屏幕上平稳地移动。如果一个框撞到窗口的边缘,那么游戏循环将通过改变它的方向使框弹起。
现在我们知道游戏循环将如何工作一点点,让我们来编写代码吧!
处理玩家退出时
当玩家通过关闭窗口退出时,我们需要像我们之前做pygame
Hello World 程序那样停止程序。我们需要在游戏循环中这样做,以便我们的程序不断检查是否有QUIT
事件发生。第 34 行开始循环,第 36 到 39 行处理退出:
# Run the game loop.
while True:# Check for the QUIT event.for event in pygame.event.get():if event.type == QUIT:pygame.quit()sys.exit()
之后,我们要确保windowSurface
已经准备好被绘制。稍后,我们将使用rect()
方法在windowSurface
上绘制每个框。在游戏循环的每次迭代中,代码重新绘制整个窗口,新的框每次都会稍微移动一些像素。当我们这样做时,我们并不是重新绘制整个Surface
对象;相反,我们只是向windowSurface
添加Rect
对象的绘制。但是当游戏循环迭代以再次绘制所有Rect
对象时,它会重新绘制每个Rect
,而不会擦除旧的Rect
绘制。如果我们让游戏循环继续在屏幕上绘制Rect
对象,最终我们会得到一串Rect
对象,而不是平滑的动画。为了避免这种情况,我们需要在每次游戏循环迭代时清除窗口。
为了做到这一点,第 42 行填充整个Surface
为白色,以便擦除先前在其上绘制的任何东西:
# Draw the white background onto the surface.windowSurface.fill(WHITE)
如果不调用windowSurface.fill(WHITE)
来将整个窗口涂白,然后在新位置绘制矩形,你将得到一串Rect
对象。如果你想尝试一下并看看会发生什么,你可以在第 42 行的开头加上#
注释掉该行。
一旦windowSurface
填满,我们就可以开始绘制所有的Rect
对象。
移动每个框
为了更新每个框的位置,我们需要在游戏循环内迭代boxes
列表:
for b in boxes:
在for
循环内,你将把当前的框称为b
,以使代码更容易输入。我们需要根据框已经移动的方向来改变每个框,因此我们将使用if
语句来通过检查框数据结构内的dir
键来确定框的方向。然后我们将根据框移动的方向改变框的位置。
# Move the box data structure.if b['dir'] == DOWNLEFT:b['rect'].left -= MOVESPEEDb['rect'].top += MOVESPEEDif b['dir'] == DOWNRIGHT:b['rect'].left += MOVESPEEDb['rect'].top += MOVESPEEDif b['dir'] == UPLEFT:b['rect'].left -= MOVESPEEDb['rect'].top -= MOVESPEEDif b['dir'] == UPRIGHT:b['rect'].left += MOVESPEEDb['rect'].top -= MOVESPEED
设置每个框的left
和top
属性的新值取决于框的方向。如果方向是DOWNLEFT
或DOWNRIGHT
,则要增加top
属性。如果方向是UPLEFT
或UPRIGHT
,则要减少top
属性。
如果框的方向是DOWNRIGHT
或UPRIGHT
,则要增加left
属性。如果方向是DOWNLEFT
或UPLEFT
,则要减少left
属性。
这些属性的值将根据存储在MOVESPEED
中的整数的数量增加或减少,该整数存储了每次游戏循环迭代中框移动的像素数。我们在第 19 行设置了MOVESPEED
。
例如,如果b['dir']
设置为'downleft'
,b['rect'].left
设置为40
,b['rect'].top
设置为100
,那么第 46 行的条件将为True
。如果MOVESPEED
设置为4
,那么第 47 和 48 行将改变Rect
对象,使得b['rect'].left
为36
,b['rect'].top
为104
。然后改变Rect
值会导致第 86 行的绘图代码在其先前位置的稍微向下和向左绘制矩形。
弹跳框
在第 44 到 57 行移动框之后,我们需要检查框是否已经超出了窗口的边缘。如果是,你想要让框弹跳。在代码中,这意味着for
循环将为框的'dir'
键设置一个新值。框将在游戏循环的下一次迭代中朝着新的方向移动。这使得看起来像框已经从窗口的一侧弹开了。
在第 60 行的if
语句中,我们确定如果框的Rect
对象的top
属性小于0
,则框已经移动到窗口的顶部边缘:
# Check whether the box has moved out of the window.if b['rect'].top < 0:# The box has moved past the top.if b['dir'] == UPLEFT:b['dir'] = DOWNLEFTif b['dir'] == UPRIGHT:b['dir'] = DOWNRIGHT
在这种情况下,方向将根据框移动的方向进行更改。如果框是向UPLEFT
移动的,那么现在将移动到DOWNLEFT
;如果它是向UPRIGHT
移动的,那么现在将移动到DOWNRIGHT
。
第 66 到 71 行处理了框移动到窗口底部边缘之外的情况:
if b['rect'].bottom > WINDOWHEIGHT:# The box has moved past the bottom.if b['dir'] == DOWNLEFT:b['dir'] = UPLEFTif b['dir'] == DOWNRIGHT:b['dir'] = UPRIGHT
这些行检查bottom
属性(而不是top
属性)是否大于WINDOWHEIGHT
中的值。记住,y 坐标从窗口顶部的0
开始,并在底部增加到WINDOWHEIGHT
。
第 72 到 83 行处理了方块在撞击边缘时的行为。
if b['rect'].left < 0:# The box has moved past the left side.if b['dir'] == DOWNLEFT:b['dir'] = DOWNRIGHTif b['dir'] == UPLEFT:b['dir'] = UPRIGHTif b['rect'].right > WINDOWWIDTH:# The box has moved past the right side.if b['dir'] == DOWNRIGHT:b['dir'] = DOWNLEFTif b['dir'] == UPRIGHT:b['dir'] = UPLEFT
第 78 到 83 行与第 72 到 77 行类似,但是检查方块的右侧是否移动到了窗口的右边缘。记住,x 坐标从窗口的左边缘开始为0
,并在窗口的右边缘增加到WINDOWWIDTH
。
在它们的新位置上在窗口上绘制方块
每当方块移动时,我们需要通过调用pygame.draw.rect()
函数在windowSurface
上绘制它们的新位置:
# Draw the box onto the surface.pygame.draw.rect(windowSurface, b['color'], b['rect'])
你需要将windowSurface
传递给函数,因为它是要在其上绘制矩形的Surface
对象。将b['color']
传递给函数,因为它是矩形的颜色。最后,传递b['rect']
,因为它是具有矩形位置和大小的Rect
对象。
第 86 行是for
循环的最后一行。
在屏幕上绘制窗口
在for
循环之后,boxes
列表中的每个方块都将被绘制,因此你需要调用pygame.display.update()
来在屏幕上绘制windowSurface
:
# Draw the window onto the screen.pygame.display.update()time.sleep(0.02)
计算机可以移动、弹跳和绘制方块得非常快,以至于如果程序以全速运行,所有的方块看起来都像一片模糊。为了使程序运行得足够慢,以至于我们能看到方块,我们需要添加time.sleep(0.02)
。你可以尝试注释掉time.sleep(0.02)
这一行,并运行程序看看它是什么样子。调用time.sleep()
将在每次方块移动之间暂停程序 0.02 秒,或 20 毫秒。
在这一行之后,执行返回到游戏循环的开始,并重新开始整个过程。这样,方块们不断地移动一点,撞到墙上,并在屏幕上以它们的新位置被绘制出来。
总结
本章介绍了创建计算机程序的全新方式。之前章节的程序会停下来等待玩家输入文本。然而,在我们的动画程序中,程序会不断更新数据结构,而不需要等待玩家的输入。
记住我们在 Hangman 和 Tic-Tac-Toe 游戏中有代表棋盘状态的数据结构。这些数据结构被传递给drawBoard()
函数以在屏幕上显示。我们的动画程序类似。boxes
变量保存了一个代表要绘制到屏幕上的方块的数据结构列表,并且这些方块是在游戏循环内绘制的。
但是没有调用input()
,我们怎么从玩家那里获取输入呢?在第 19 章中,我们将介绍程序如何知道玩家何时按下键盘上的按键。我们还将学习一个叫做碰撞检测的新概念。