使用 Python 创造你自己的计算机游戏(游戏编程快速上手)第四版:第十章到第十四章

本文涉及的产品
密钥管理服务KMS,1000个密钥,100个凭据,1个月
简介: 使用 Python 创造你自己的计算机游戏(游戏编程快速上手)第四版:第十章到第十四章


十、井字棋



本章介绍了一个井字棋游戏。井字棋通常由两个人玩。一个玩家是X,另一个玩家是O。玩家轮流放置他们的XO。如果一个玩家在一行、一列或对角线上获得了三个标记,他们就赢了。当棋盘填满时,没有玩家获胜,游戏以平局结束。

本章并没有介绍太多新的编程概念。用户将与一个简单的人工智能对战,我们将使用现有的编程知识来编写它。*人工智能(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 显示了井字棋程序的流程图。程序首先要求玩家选择他们的字母,XO。谁先行动是随机选择的。然后玩家和计算机轮流进行移动。

图 10-2:井字棋的流程图

流程图左侧的框显示了玩家回合时发生的事情,右侧的框显示了计算机回合时发生的事情。玩家或计算机进行移动后,程序会检查他们是否赢了或导致了平局,然后游戏会切换回合。游戏结束后,程序会询问玩家是否想再玩一次。

将棋盘表示为数据

首先,你必须想出如何将棋盘表示为变量中的数据。在纸上,井字棋棋盘被绘制为一对水平线和一对垂直线,每个九个空间中有一个XO或空格。

在程序中,井字棋棋盘被表示为一个字符串列表,就像猜词游戏的 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 的算法有以下步骤:

  1. 查看计算机是否可以赢得比赛。如果可以,就走这一步。否则,转到步骤 2。
  2. 查看玩家是否可以进行一步棋,导致计算机输掉比赛。如果可以,就移动到那里阻止玩家。否则,转到步骤 3。
  3. 检查角落空间(空间 1、3、7 或 9)是否有空闲。如果有,就移动到那里。如果没有空闲的角落空间,就转到步骤 4。
  4. 检查中心是否空闲。如果是,就移动到那里。如果不是,就转到步骤 5。
  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

接下来,我们将定义一个函数来为玩家分配XO

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()函数询问玩家是否想成为XOwhile循环的条件包含括号,这意味着括号内的表达式首先被评估。如果letter变量设置为'X',表达式将这样评估:

如果letter的值是'X''O',那么循环的条件是False,并且让程序执行继续超出while块。如果条件是True,程序将继续要求玩家选择一个字母,直到玩家输入XO。第 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的字符串)是计算机的字母。ifelse语句选择适当的列表进行返回。

决定谁先走

接下来,我们创建一个使用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。如果这个函数调用返回0whoGoesFirst()函数返回字符串'computer'。否则,函数返回字符串'player'。调用这个函数的代码将使用返回值来确定谁将首先行动。

在棋盘上放置标记

makeMove()函数很简单:

def makeMove(board, letter, move):
    board[move] = letter

参数是boardlettermove。变量board是包含 10 个字符串的列表,表示棋盘的状态。变量letter是玩家的字母('X''O')。变量move是玩家想要走的棋盘位置(是从19的整数)。

但是等等——在第 37 行,这段代码似乎改变了board列表中的一个项目为letter中的值。然而,由于这段代码在一个函数中,当函数返回时,board参数将被遗忘。那么对board的更改也应该被遗忘了吧?

实际上,情况并非如此,因为当你将它们作为参数传递给函数时,列表是特殊的。实际上,你传递的是对列表的引用,而不是列表本身。让我们了解一下列表和对列表的引用之间的区别。

列表引用

在交互式 shell 中输入以下内容:

>>> spam = 42
>>> cheese = spam
>>> spam = 100
>>> spam
100
>>> cheese
42

从你目前所知的结果来看是有意义的。你将42赋给spam变量,然后将spam中的值赋给变量cheese。当你稍后将spam覆盖为100时,这不会影响cheese中的值。这是因为spamcheese是存储不同值的不同变量。

但是列表不是这样工作的。当你将一个列表分配给一个变量时,你实际上是将一个列表引用分配给变量。引用是一个指向存储某些数据的位置的值。让我们看一些代码,这将使这更容易理解。在交互式 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列表,但似乎cheesespam列表都发生了变化。这是因为spam变量不包含列表值本身,而是包含对列表的引用,如图 10-5 所示。列表本身不包含在任何变量中,而是存在于它们之外。

图 10-5:spam列表在处创建。变量不存储列表,而是存储对列表的引用。

注意,cheese = spamspam中的列表引用复制到cheese ➋,而不是复制列表值本身。现在spamcheese都存储一个引用,指向相同的列表值。但只有一个列表,因为列表本身没有被复制。图 10-6 显示了这种复制。

图 10-6:spamcheese变量存储对同一列表的两个引用。

因此,➌处的cheese1] = 'Hello!'行更改了spam引用的相同列表。这就是为什么spam返回与cheese相同的列表值。它们都有引用,指向相同的列表,如[图 10-7 所示。

图 10-7:更改列表会更改所有引用该列表的变量。

如果你想要spamcheese存储两个不同的列表,你必须创建两个列表而不是复制一个引用:

>>> spam = [0, 1, 2, 3, 4, 5]
>>> cheese = [0, 1, 2, 3, 4, 5]

在前面的例子中,spamcheese存储两个不同的列表(即使这些列表在内容上是相同的)。现在,如果您修改其中一个列表,它不会影响另一个,因为spamcheese引用了两个不同的列表:

>>> 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:spamcheese变量现在分别存储对两个不同列表的引用。

在 makeMove()中使用列表引用

让我们回到makeMove()函数:

def makeMove(board, letter, move):
    board[move] = letter

当将列表值传递给board参数时,函数的局部变量实际上是对列表的引用的副本,而不是列表本身的副本。因此,对此函数中board的任何更改也将应用于原始列表。即使board是局部变量,makeMove()函数也会修改原始列表。

lettermove参数是您传递的字符串和整数值的副本。由于它们是值的副本,如果您在此函数中修改lettermove,则在调用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

bole名称是boardletter参数的快捷方式。这些更短的名称意味着您在此函数中输入的内容更少。请记住,Python 不在乎您给变量取什么名字。

在 Tic-Tac-Toe 中有八种可能的获胜方式:您可以在顶部、中部或底部行中有一条线;您可以在左侧、中间或右侧列中有一条线;或者您可以在两个对角线中的任何一个上有一条线。

条件的每一行都检查给定行的三个空格是否等于提供的字母(与and运算符结合)。您使用or运算符组合每一行以检查八种不同的获胜方式。这意味着只有八种方式中的一种必须为True,我们才能说拥有le中字母的玩家是赢家。

假设le'O'bo[' ', 'O', 'O', 'O', ' ', 'X', ' ', 'X', ' ', ' ']。棋盘看起来是这样的:

X| |
-+-+-
 |X|
-+-+-
O|O|O

以下是第 42 行return关键字后的表达式的评估方式。首先,Python 用每个变量的值替换变量bole

返回((‘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

因此,对于bole的这些值,表达式将求值为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运算符的左侧是TrueReturnsFalse()返回什么并不重要,因此 Python 不会调用它。评估被短路了。

对于and运算符也是一样。现在在交互式 shell 中输入以下内容:

>>> ReturnsTrue() and ReturnsTrue()
ReturnsTrue() was called.
ReturnsTrue() was called.
True
>>> ReturnsFalse() and ReturnsFalse()
ReturnsFalse() was called.
False

同样,如果and运算符的左侧是False,那么整个表达式就是False。右侧是TrueFalse都无关紧要,因此 Python 不会评估它。False and TrueFalse 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。右侧的表达式求值为TrueFalse都无关紧要,因为or运算符的两侧只需要一个值为True整个表达式才为True

因此,Python 停止检查表达式的其余部分,甚至不会评估not isSpaceFree(board, int(move))部分。这意味着只要move not in '1 2 3 4 5 6 7 8 9'.split()Trueint()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的变量,它保存了用户对某个判断题的答案。该变量可以保存用户的答案为TrueFalse。但是如果用户没有回答这个问题,你不希望将quizAnswer设置为TrueFalse,因为那样看起来就像用户回答了这个问题。相反,如果用户跳过了这个问题,你可以将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!')赋值给spamprint()函数,像所有函数一样,有一个返回值。即使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 算法是如何工作的:

  1. 看看计算机是否可以进行一步获胜的移动。如果可以,就进行该移动。否则,转到步骤 2。
  2. 看看玩家是否可以进行一步导致计算机输掉游戏的移动。如果可以,计算机应该移动到那里来阻止玩家。否则,转到步骤 3。
  3. 检查是否有任何一个角落(空格 1、3、7 或 9)是空的。如果没有角落空间是空的,转到步骤 4。
  4. 检查中心是否空闲。如果是,就移动到那里。如果不是,转到步骤 5。
  5. 在任何一侧移动(空格 2、4、6 或 8)。没有更多的步骤,因为如果执行到这一步,侧面空间是唯一剩下的空间。

该函数将返回一个表示计算机移动的整数,从19。让我们逐步了解代码中如何实现这些步骤。

检查计算机是否可以在一步内获胜

在任何其他操作之前,如果计算机可以在下一步获胜,它应该立即进行获胜的移动。

# 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',则此函数返回Truefor循环让我们检查board列表上的索引19。一旦它在棋盘上找到一个空格(也就是说,当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()函数将玩家的XO添加到theBoard

现在玩家已经下完了棋,程序应该检查他们是否赢得了比赛:

if isWinner(theBoard, playerLetter):
                drawBoard(theBoard)
                print('Hooray! You have won the game!')
                gameIsPlaying = False

如果isWinner()函数返回Trueif块的代码会显示获胜的棋盘,并打印一条消息告诉玩家他们赢了。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

译者:飞龙

协议:CC BY-NC-SA 4.0

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 *= 3spam = spam * 3相同。因此,由于spam之前设置为40,完整表达式将是spam = 40 * 3,计算结果为120。表达式spam /= 10spam = spam / 10相同,spam = 120 / 10计算结果为12.0。请注意,在除法后,spam变成了浮点数。

计算要给出的线索

getClues()函数将根据guesssecretNum参数返回一个包含 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'字符串。

程序通过循环遍历guesssecretNum中的每个可能的索引来执行此操作。这两个变量中的字符串将具有相同的长度,因此第 21 行可以使用len(guess)len(secretNum)中的任何一个,并且效果相同。当i的值从0变化到12等时,第 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循环内。这些内部循环称为嵌套循环。任何breakcontinue语句,例如第 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包含一个有效的猜测。现在程序将guesssecretNum传递给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

译者:飞龙

协议:CC BY-NC-SA 4.0

本章介绍了您将在本书的其余部分中使用的一些简单数学概念。在二维(2D)游戏中,屏幕上的图形可以向左、向右、向上或向下移动。这些游戏需要一种将屏幕上的位置转换为程序可以处理的整数的方法。

图 12-2:相同的棋盘,但行和列都有数字坐标

本章涵盖的主题

您可以将棋盘视为笛卡尔坐标系。通过使用行标签和列标签,您可以给出一个坐标,该坐标仅适用于棋盘上的一个空间。如果您在数学课上学过笛卡尔坐标系,您可能知道数字用于行和列。使用数字坐标的棋盘将看起来像图 12-2。

注意,要使黑色骑士移动到白色骑士的位置,黑色骑士必须向上移动两个空间并向右移动四个空间。但是,您不需要查看棋盘来弄清楚这一点。如果您知道白色骑士位于(5,6),黑色骑士位于(1,4),您可以使用减法来弄清楚这一信息。

  • 像素
  • 加法的交换律
  • x 轴和 y 轴

这就是笛卡尔坐标系的用武之地。坐标是表示屏幕上特定点的数字。这些数字可以存储为程序变量中的整数。

引用国际象棋棋盘上特定位置的常见方法是用字母和数字标记每一行和列。图 12-1 显示了一个每一行和列都有标记的国际象棋棋盘。

图 12-1:一个带有黑色骑士(a,4)和白色骑士(e,6)的样本棋盘

  • 笛卡尔坐标系

棋盘上空间的坐标是行和列的组合。在国际象棋中,骑士棋子看起来像马头。图 12-1 中的白色骑士位于点(e,6),因为它在列 e 和行 6,黑色骑士位于点(a,4),因为它在列 a 和行 4。

沿列向左和向右的数字是x 轴的一部分。沿行向上和向下的数字是y 轴的一部分。坐标始终以 x 坐标优先,然后是 y 坐标。在图 12-2 中,白色骑士的 x 坐标为 5,y 坐标为 6,因此白色骑士位于坐标(5,6),而不是(6,5)。同样,黑色骑士位于坐标(1,4),而不是(4,1),因为黑色骑士的 x 坐标为 1,y 坐标为 4。

  • 负数
  • 绝对值和abs()函数

从白色骑士的 x 坐标中减去黑色骑士的 x 坐标:5-1=4。黑色骑士必须沿 x 轴移动四个空间。现在从白色骑士的 y 坐标中减去黑色骑士的 y 坐标:6-4=2。黑色骑士必须沿 y 轴移动两个空间。

网格和笛卡尔坐标

通过对坐标数字进行一些数学运算,您可以计算出两个坐标之间的距离。

负数

笛卡尔坐标系也使用负数——小于零的数。数前的减号表示它是负数:-1 小于 0。-2 小于-1。但 0 本身既不是正数也不是负数。在图 12-3 中,你可以看到数轴上正数向右增加,负数向左减少。

图 12-3:带有正数和负数的数轴

数轴对于理解减法和加法很有用。你可以将表达式 5+3 看作白色骑士从位置 5 开始向右移动 3 个空格。正如你在图 12-4 中所看到的,白色骑士最终停在位置 8。这是有道理的,因为 5+3 等于 8。

图 12-4:将白色骑士向右移动会增加坐标。

你通过将白色骑士向左移动来进行减法。因此,如果表达式是 5-6,白色骑士从位置 5 开始向左移动 6 个空格,如图 12-5 所示。

图 12-5:将白色骑士向左移动会减少坐标。

白色骑士最终停在位置-1。这意味着 5-6 等于-1。

如果你加或减一个负数,白色骑士的移动方向与正数相反。如果你加一个负数,骑士向左移动。如果你减去一个负数,骑士向右移动。表达式-1-(-4)将等于 3,如图 12-6 所示。注意-1-(-4)的答案与-1+4 相同。

图 12-6:骑士从-6 开始向右移动 4 个空格。

你可以将 x 轴看作一个数轴。再加上一个上下移动的 y 轴。如果将这两个数轴放在一起,就会得到一个像图 12-7 中的笛卡尔坐标系。

添加一个正数(或减去一个负数)会使骑士在 y 轴上向上移动,或者在 x 轴上向右移动,而减去一个正数(或加上一个负数)会使骑士在 y 轴上向下移动,或者在 x 轴上向左移动。

中心的(0,0)坐标被称为原点。你可能在数学课上使用过这样的坐标系。正如你将要看到的,这些坐标系有很多小技巧,可以帮助你更容易地计算坐标。

图 12-7:将两个数轴放在一起可以创建笛卡尔坐标系。

计算机屏幕的坐标系

你的计算机屏幕由像素组成,是屏幕可以显示的最小颜色点。计算机屏幕通常使用一个坐标系,原点(0,0)位于左上角,向下和向右增加。你可以在图 12-8 中看到这一点,该图显示了一个分辨率为 1920 像素宽和 1080 像素高的笔记本电脑屏幕。

没有负坐标。大多数计算机图形在屏幕上使用这种坐标系,你将在本书的游戏中使用它。对于编程来说,了解坐标系的工作原理很重要,无论是数学使用的还是计算机屏幕使用的。

图 12-8:计算机屏幕上的笛卡尔坐标系

数学技巧

当你面前有一个数轴时,加减负数就很容易。即使没有数轴,也可以很容易。以下是三个技巧,可以帮助你自己加减负数。

技巧 1:减号吃掉它左边的加号

当你看到一个减号和左边有一个加号时,你可以用减号替换加号。想象减号“吃掉”左边的加号。答案仍然是一样的,因为加上一个负值就等同于减去一个正值。所以 4 + -2 和 4 - 2 都等于 2,如下所示:

技巧 2:两个负号合并成一个加号

当你看到两个负号相邻而没有数字在它们中间时,它们可以合并成一个加号。答案仍然是一样的,因为减去一个负值就等同于加上一个正值:

技巧 3:两个相加的数字可以交换位置

你总是可以交换加法中的数字。这就是加法的交换律。这意味着像 6 + 4 到 4 + 6 的交换不会改变答案,当你数一下图 12-9 中的方块时就会发现。

图 12-9:加法的交换律让你可以交换数字。

假设你正在加一个负数和一个正数,比如-6 + 8。因为你在加数字,所以你可以交换数字的顺序而不改变答案。这意味着-6 + 8 和 8 + -6 是一样的。然后当你看 8 + -6 时,你会发现减号可以吃掉左边的加号,问题变成了 8 - 6 = 2,如下所示:

你已经重新排列了问题,这样就更容易解决,而不需要使用计算器或计算机。

绝对值和 abs()函数

一个数的绝对值是没有负号的数。因此,正数不变,但负数变成正数。例如,-4 的绝对值是 4。-7 的绝对值是 7。5 的绝对值(已经是正数)就是 5。

你可以通过减去它们的位置并取差的绝对值来计算两个对象之间的距离。想象一下,白色骑士在位置 4,黑色骑士在位置-2。距离将是 6,因为 4 - -2 是 6,6 的绝对值是 6。

无论数字的顺序如何,它都适用。例如,-2 - 4(即负二减四)是-6,-6 的绝对值也是 6。

Python 的abs()函数返回一个整数的绝对值。在交互式 shell 中输入以下内容:

>>> abs(-5)
5
>>> abs(42)
42
>>> abs(-10.5)
10.5

-5 的绝对值是 5。正数的绝对值就是这个数,所以 42 的绝对值是 42。即使是带小数的偶数也有绝对值,所以-10.5 的绝对值是 10.5。

总结

大多数编程不需要理解很多数学。直到这一章,我们一直在使用简单的加法和乘法。

笛卡尔坐标系用于描述二维区域中某个位置的位置。坐标有两个数字:x 坐标和 y 坐标。x 轴左右运行,y 轴上下运行。在计算机屏幕上,原点在左上角,坐标向右和向下增加。

在本章中学到的三个数学技巧使得加减正负整数变得容易。第一个技巧是减号会吃掉左边的加号。第二个技巧是两个负号相邻会合并成一个加号。第三个技巧是你可以交换要相加的数字的位置。

本书中的其余游戏都使用这些概念,因为它们都有二维区域。所有图形游戏都需要理解笛卡尔坐标是如何工作的。

十三、声纳寻宝

原文:inventwithpython.com/invent4thed/chapter13.html

译者:飞龙

协议:CC BY-NC-SA 4.0

本章的声纳寻宝游戏是第一个使用你在第 12 章中学到的笛卡尔坐标系的游戏。这个游戏还使用了数据结构,这只是说它有包含其他列表和类似复杂变量的列表值。随着你编写的游戏变得更加复杂,你需要将数据组织成数据结构。

在本章的游戏中,玩家在海洋的各个地方放置声纳设备来寻找沉没的宝藏。声纳是船只用来在海底找到物体的技术。这个游戏中的声纳设备告诉玩家最近的宝藏箱离他有多远,但没有告诉他方向。但是通过放置多个声纳设备,玩家可以找出宝藏箱的位置。

本章涵盖的主题

  • 数据结构
  • 毕达哥拉斯定理
  • remove() 列表方法
  • isdigit() 字符串方法
  • sys.exit() 函数

有 3 个宝箱可供寻找,玩家只有 20 个声纳设备来找到它们。想象一下,你在图 13-1 中看不到宝箱。因为每个声纳设备只能找到离宝箱的距离,而不是宝箱的方向,所以宝藏可能在声纳设备周围的环上的任何地方。

图 13-1:声纳设备的圆环触碰到(隐藏的)宝箱。

但是多个声纳设备一起工作可以将宝藏箱的位置缩小到环交叉的确切坐标处(见图 13-2)。

Figure 13-2: Combining multiple rings shows where treasure chests could be hidden.

声纳寻宝的示例运行

当用户运行声纳寻宝程序时,用户看到的内容如下。玩家输入的文本以粗体显示。

S O N A R !
Would you like to view the instructions? (yes/no)
no
             1         2         3         4         5
   012345678901234567890123456789012345678901234567890123456789
 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0
 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1
 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2
 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3
 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4
 5 `~``~````~`~`~~``~~~~``````~```~~~~````````~``~~~`~~``~~````5
 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6
 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7
 8 `~``~~`~`~~`~~`~~``~```~````~`~```~``~````~~~````~~``~~``~~` 8
 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9
10 `~~~~~~`~``~``~~~``~``~~~~`~``~```~`~~``~~~~~~``````~~`~``~~ 10
11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11
12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12
13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13
14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14
   012345678901234567890123456789012345678901234567890123456789
             1         2         3         4         5
You have 20 sonar device(s) left. 3 treasure chest(s) remaining.
Where do you want to drop the next sonar device? (0-59 0-14) (or type quit)
25 5
             1         2         3         4         5
   012345678901234567890123456789012345678901234567890123456789
 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0
 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1
 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2
 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3
 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4
 5 `~``~````~`~`~~``~~~~````5`~```~~~~````````~``~~~`~~``~~````5
 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6
 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7
 8 `~``~~`~`~~`~~`~~``~```~````~`~```~``~````~~~````~~``~~``~~` 8
 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9
10 `~~~~~~`~``~``~~~``~``~~~~`~``~```~`~~``~~~~~~``````~~`~``~~ 10
11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11
12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12
13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13
14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14
   012345678901234567890123456789012345678901234567890123456789
             1         2         3         4         5
Treasure detected at a distance of 5 from the sonar device.
You have 19 sonar device(s) left. 3 treasure chest(s) remaining.
Where do you want to drop the next sonar device? (0-59 0-14) (or type quit)
30 5
             1         2         3         4         5
   012345678901234567890123456789012345678901234567890123456789
 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0
 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1
 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2
 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3
 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4
 5 `~``~````~`~`~~``~~~~````5`~``3~~~~````````~``~~~`~~``~~````5
 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6
 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7
 8 `~``~~`~`~~`~~`~~``~```~````~`~```~``~````~~~````~~``~~``~~` 8
 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9
10 `~~~~~~`~``~``~~~``~``~~~~`~``~```~`~~``~~~~~~``````~~`~``~~ 10
11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11
12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12
13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13
14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14
   012345678901234567890123456789012345678901234567890123456789
             1         2         3         4         5
Treasure detected at a distance of 3 from the sonar device.
You have 18 sonar device(s) left. 3 treasure chest(s) remaining.
Where do you want to drop the next sonar device? (0-59 0-14) (or type quit)
25 10
             1         2         3         4         5
   012345678901234567890123456789012345678901234567890123456789
 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0
 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1
 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2
 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3
 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4
 5 `~``~````~`~`~~``~~~~````5`~``3~~~~````````~``~~~`~~``~~````5
 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6
 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7
 8 `~``~~`~`~~`~~`~~``~```~````~`~```~``~````~~~````~~``~~``~~` 8
 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9
10 `~~~~~~`~``~``~~~``~``~~~4`~``~```~`~~``~~~~~~``````~~`~``~~ 10
11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11
12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12
13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13
14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14
   012345678901234567890123456789012345678901234567890123456789
             1         2         3         4         5
Treasure detected at a distance of 4 from the sonar device.
You have 17 sonar device(s) left. 3 treasure chest(s) remaining.
Where do you want to drop the next sonar device? (0-59 0-14) (or type quit)
29 8
             1         2         3         4         5
   012345678901234567890123456789012345678901234567890123456789
 0 ~`~``~``~``~`~`~```~`~``````~```~`~~~`~~```~~`~~~~~`~~`~~~~` 0
 1 ~~~~~`~~~~~````~``~~```~``~`~`~`~``~~```~~~`~`~```~~~~`~`~~` 1
 2 ```~````~``~`~`~~~``~~`````~~``~``~``~~```~~``~~`~~~````~~`~ 2
 3 `````~~``````~`~~~~~```~~``~~~`~`~~~~~~`````~`~```~~~``~``~` 3
 4 ~~~`~~~`~`~~~``~~~`~`~``~~~~``~~~~``~~~~`~`~``~~```~``~~`~`~ 4
 5 `~``~````~`~`~~``~~~~````X`~``X~~~~````````~``~~~`~~``~~````5
 6 ~`~```~~`~~```~````````~~```~```~~~~``~~~`~`~~`~``~~~`~~`~`` 6
 7 ~`~~~```~``~```~`~```~~~~~~~`~~`~`~~~~``~```~~~`~```~``~``~` 7
 8 `~``~~`~`~~`~~`~~``~```~````~X~```~``~````~~~````~~``~~``~~` 8
 9 ~`~``~~````~~```~`~~```~~`~``~`~~``~`~`~~~~`~`~~`~`~```~~```9
10 `~~~~~~`~``~``~~~``~``~~~X`~``~```~`~~``~~~~~~``````~~`~``~~ 10
11 ~``~~~````~`~~`~~~`~~~``~``````~`~``~~~~`````~~~``````~`~`~~ 11
12 ~~~~~``~`~````~```~`~`~`~~`~~`~``~~~~~~~`~~```~~``~~`~~~~```12
13 `~~```~~````````~~~`~~~```~~~~~~~~`~~``~~`~```~`~~````~~~``~ 13
14 ```~``~`~`~``~```~`~``~`~``~~```~`~~~``~~``~```~`~~`~``````~ 14
   012345678901234567890123456789012345678901234567890123456789
             1         2         3         4         5
You have found a sunken treasure chest!
You have 16 sonar device(s) left. 2 treasure chest(s) remaining.
Where do you want to drop the next sonar device? (0-59 0-14) (or type quit)
--snip--

声纳寻宝的源代码

在新文件中输入以下源代码并将文件保存为sonar.py。然后按 F5(或 OS X 上的 FN-F5)运行它。如果在输入此代码后出现错误,请使用在线 diff 工具将您输入的代码与本书代码进行比较,网址为www.nostarch.com/inventwithpython#diff

sonar.py

# Sonar Treasure Hunt
import random
import sys
import math
def getNewBoard():
    # Create a new 60x15 board data structure.
    board = []
    for x in range(60): # The main list is a list of 60 lists.
        board.append([])
        for y in range(15): # Each list in the main list has
               15 single-character strings.
            # Use different characters for the ocean to make it more
                   readable.
            if random.randint(0, 1) == 0:
                board[x].append('~')
            else:
                board[x].append('`')
    return board
def drawBoard(board):
    # Draw the board data structure.
    tensDigitsLine = '    ' # Initial space for the numbers down the left
           side of the board
    for i in range(1, 6):
        tensDigitsLine += (' ' * 9) + str(i)
    # Print the numbers across the top of the board.
    print(tensDigitsLine)
    print('   ' + ('0123456789' * 6))
    print()
    # Print each of the 15 rows.
    for row in range(15):
        # Single-digit numbers need to be padded with an extra space.
        if row < 10:
            extraSpace = ' '
        else:
            extraSpace = ''
        # Create the string for this row on the board.
        boardRow = ''
        for column in range(60):
            boardRow += board[column][row]
        print('%s%s %s %s' % (extraSpace, row, boardRow, row))
    # Print the numbers across the bottom of the board.
    print()
    print(' ' + ('0123456789' * 6))
    print(tensDigitsLine)
def getRandomChests(numChests):
    # Create a list of chest data structures (two-item lists of x, y int
           coordinates).
    chests = []
    while len(chests) < numChests:
        newChest = [random.randint(0, 59), random.randint(0, 14)]
        if newChest not in chests: # Make sure a chest is not already
               here.
            chests.append(newChest)
    return chests
def isOnBoard(x, y):
    # Return True if the coordinates are on the board; otherwise, return
           False.
    return x >= 0 and x <= 59 and y >= 0 and y <= 14
def makeMove(board, chests, x, y):
    # Change the board data structure with a sonar device character.
           Remove treasure chests from the chests list as they are found.
    # Return False if this is an invalid move.
    # Otherwise, return the string of the result of this move.
    smallestDistance = 100 # Any chest will be closer than 100.
    for cx, cy in chests:
        distance = math.sqrt((cx - x) * (cx - x) + (cy - y) * (cy - y))
        if distance < smallestDistance: # We want the closest treasure
               chest.
            smallestDistance = distance
    smallestDistance = round(smallestDistance)
    if smallestDistance == 0:
        # xy is directly on a treasure chest!
        chests.remove([x, y])
        return 'You have found a sunken treasure chest!'
    else:
        if smallestDistance < 10:
            board[x][y] = str(smallestDistance)
            return 'Treasure detected at a distance of %s from the sonar
                   device.' % (smallestDistance)
        else:
            board[x][y] = 'X'
            return 'Sonar did not detect anything. All treasure chests
                   out of range.'
def enterPlayerMove(previousMoves):
    # Let the player enter their move. Return a two-item list of int
           xy coordinates.
    print('Where do you want to drop the next sonar device? (0-59 0-14)
           (or type quit)')
    while True:
        move = input()
        if move.lower() == 'quit':
            print('Thanks for playing!')
            sys.exit()
        move = move.split()
        if len(move) == 2 and move[0].isdigit() and move[1].isdigit() and
               isOnBoard(int(move[0]), int(move[1])):
            if [int(move[0]), int(move[1])] in previousMoves:
                print('You already moved there.')
                continue
            return [int(move[0]), int(move[1])]
        print('Enter a number from 0 to 59, a space, then a number from
               0 to 14.')
def showInstructions():
    print('''Instructions:
You are the captain of the Simon, a treasure-hunting ship. Your current
       mission
is to use sonar devices to find three sunken treasure chests at the
       bottom of
the ocean. But you only have cheap sonar that finds distance, not
       direction.
Enter the coordinates to drop a sonar device. The ocean map will be
       marked with
how far away the nearest chest is, or an X if it is beyond the sonar
       device's
range. For example, the C marks are where chests are. The sonar device
       shows a
3 because the closest chest is 3 spaces away.
                    1         2         3
          012345678901234567890123456789012
        0 ~~~~`~```~`~``~~~``~`~~``~~~``~`~ 0
        1 ~`~`~``~~`~```~~~```~~`~`~~~`~~~~ 1
        2 `~`C``3`~~~~`C`~~~~`````~~``~~~`` 2
        3 ````````~~~`````~~~`~`````~`~``~` 3
        4 ~`~~~~`~~`~~`C`~``~~`~~~`~```~``~ 4
          012345678901234567890123456789012
                    1         2         3
(In the real game, the chests are not visible in the ocean.)
Press enter to continue...''')
    input()
    print('''When you drop a sonar device directly on a chest, you
           retrieve it and the other
sonar devices update to show how far away the next nearest chest is. The
       chests
are beyond the range of the sonar device on the left, so it shows an X.
                    1         2         3
          012345678901234567890123456789012
        0 ~~~~`~```~`~``~~~``~`~~``~~~``~`~ 0
        1 ~`~`~``~~`~```~~~```~~`~`~~~`~~~~ 1
        2 `~`X``7`~~~~`C`~~~~`````~~``~~~`` 2
        3 ````````~~~`````~~~`~`````~`~``~` 3
        4 ~`~~~~`~~`~~`C`~``~~`~~~`~```~``~ 4
          012345678901234567890123456789012
                    1         2         3
The treasure chests don't move around. Sonar devices can detect treasure
       chests
up to a distance of 9 spaces. Try to collect all 3 chests before running
       out of
sonar devices. Good luck!
Press enter to continue...''')
    input()
print('S O N A R !')
print()
print('Would you like to view the instructions? (yes/no)')
if input().lower().startswith('y'):
    showInstructions()
while True:
    # Game setup
    sonarDevices = 20
    theBoard = getNewBoard()
    theChests = getRandomChests(3)
    drawBoard(theBoard)
    previousMoves = []
    while sonarDevices > 0:
        # Show sonar device and chest statuses.
        print('You have %s sonar device(s) left. %s treasure chest(s)
               remaining.' % (sonarDevices, len(theChests)))
        x, y = enterPlayerMove(previousMoves)
        previousMoves.append([x, y]) # We must track all moves so that
               sonar devices can be updated.
        moveResult = makeMove(theBoard, theChests, x, y)
        if moveResult == False:
            continue
        else:
            if moveResult == 'You have found a sunken treasure chest!':
                # Update all the sonar devices currently on the map.
                for x, y in previousMoves:
                    makeMove(theBoard, theChests, x, y)
            drawBoard(theBoard)
            print(moveResult)
        if len(theChests) == 0:
            print('You have found all the sunken treasure chests!
                   Congratulations and good game!')
            break
        sonarDevices -= 1
    if sonarDevices == 0:
        print('We\'ve run out of sonar devices! Now we have to turn the
               ship around and head')
        print('for home with treasure chests still out there! Game
               over.')
        print('    The remaining chests were here:')
        for x, y in theChests:
            print('    %s, %s' % (x, y))
    print('Do you want to play again? (yes or no)')
    if not input().lower().startswith('y'):
        sys.exit()

程序设计

在尝试理解源代码之前,玩几次游戏以了解发生了什么。声纳寻宝游戏使用列表的列表和其他复杂的变量,称为数据结构。数据结构存储值的排列以表示某些东西。例如,在第 10 章中,井字棋板数据结构是一个字符串列表。字符串代表XO或空格,列表中字符串的索引代表棋盘上的空格。声纳寻宝游戏将有类似的数据结构来表示宝箱和声纳设备的位置。

导入 random、sys 和 math 模块

在程序开始时,我们导入randomsysmath模块:

# Sonar Treasure Hunt
import random
import sys
import math

sys模块包含exit()函数,用于立即终止程序。sys.exit()调用后的代码行将不会运行;程序就像已经到达了结尾一样停止。此函数稍后在程序中使用。

math模块包含sqrt()函数,用于找到一个数字的平方根。平方根背后的数学在第 186 页的“寻找最近的宝箱”中有解释。

创建新游戏板

每个新游戏的开始都需要一个新的board数据结构,由getNewBoard()创建。声纳寻宝游戏板是一个 ASCII 艺术海洋,周围有 x 和 y 坐标。

当我们使用board数据结构时,我们希望能够以与访问笛卡尔坐标相同的方式访问其坐标系统。为此,我们将使用一个列表的列表来调用棋盘上的每个坐标,如board[x][y]。x 坐标在 y 坐标之前——要获取坐标(26, 12)处的字符串,您访问board[26][12],而不是board[12][26]

def getNewBoard():
    # Create a new 60x15 board data structure.
    board = []
    for x in range(60): # The main list is a list of 60 lists.
        board.append([])
        for y in range(15): # Each list in the main list has
              15 single-character strings.
            # Use different characters for the ocean to make it more
                  readable.
          if random.randint(0, 1) == 0:
              board[x].append('~')
          else:
              board[x].append('`')

board数据结构是一个字符串列表的列表。第一个列表代表 x 坐标。由于游戏板宽 60 个字符,因此这个第一个列表需要包含 60 个列表。在第 10 行,我们创建了一个for循环,将 60 个空列表附加到其中。

board不仅仅是 60 个空列表的列表。这 60 个列表中的每一个代表游戏板的一个 x 坐标。棋盘上有 15 行,因此这 60 个列表中的每一个必须包含 15 个字符串。第 12 行是另一个for循环,它添加了 15 个表示海洋的单字符字符串。

海洋将是一堆随机选择的'~''字符串。波浪(~)和反引号(`)字符——键盘上 1 键旁边的波浪符号~将用于海洋波浪。要确定使用哪个字符,第 14 至 17 行应用此逻辑:如果random.randint()的返回值为0,则添加'~'字符串;否则,添加'`'字符串。这将使海洋看起来随机而波涛汹涌。

举个小例子,如果board设置为[['~', '~', '`'], [['~', '~', '`'], [['~', '~', '`'], ['~', '`', '`'], ['`', '~', '`']],那么它绘制的棋盘将如下所示:

~~~~`
~~~`~
最后,函数返回第 18 行的`board`变量中的值:
```py
    return board

绘制游戏板

接下来,我们将定义drawBoard()方法,每当我们实际绘制新棋盘时都会调用它:

def drawBoard(board):

带有边缘坐标的完整游戏板如下所示:

1         2         3         4         5
   012345678901234567890123456789012345678901234567890123456789
 0 ~~~`~``~~~``~~~~``~`~`~`~`~~`~~~`~~`~``````~~`~``~`~~```~`~` 0
 1 `~`~````~~``~`~```~```~```~`~~~``~~`~~~``````~`~``~~``~~`~~` 1
 2 ```~~~~`~`~~```~~~``~````~~`~`~~`~`~`~```~~`~``~~`~`~~~~~~`~ 2
 3 ~~~~`~~~``~```~``~~`~`~~`~`~~``~````~`~````~```~`~`~`~`````~ 3
 4 ~```~~~~~`~~````~~~~```~~~`~`~`~````~`~~`~`~~``~~`~``~`~``~~ 4
 5 `~```~`~`~~`~~~```~~``~``````~~``~`~`~~~~`~~``~~~~~~`~```~~` 5
 6 ``~~`~~`~``~`````~````~~``~`~~~~`~~```~~~``~`~`~~``~~~```~~~ 6
 7 ``~``~~~~~~```~`~```~~~``~`~``~`~~~~~~```````~~~`~~`~~`~~`~~ 7
 8 ~~`~`~~```~``~~``~~~``~~`~`~~`~`~```~```~~~```~~~~~~`~`~~~~` 8
 9 ```~``~`~~~`~~```~``~``~~~```~````~```~`~~`~~~~~`~``~~~~~```9
10 `~~~~```~`~````~`~`~~``~`~~~~`~``~``~```~~```````~`~``~`````10
11 ~~`~`~~`~``~`~~~````````````````~~`````~`~~``~`~~~~`~~~`~~`~ 11
12 ~~`~~~~```~~~`````~~``~`~`~~``````~`~~``~```````~~``~~~`~~`~ 12
13 `~``````~~``~`~~~```~~~~```~~`~`~~~`~```````~~`~```~``~`~~~~ 13
14 ~~~``~```~`````~~`~`~``~~`~``~`~~`~`~``~`~``~~``~`~``~```~~~ 14
   012345678901234567890123456789012345678901234567890123456789
             1         2         3         4         5

drawBoard()函数中的绘制有四个步骤:

  1. 创建一个字符串变量,其中 1、2、3、4 和 5 之间用宽间隔分隔。这些数字标记了 x 轴上的 10、20、30、40 和 50 的坐标。
  2. 使用该字符串在屏幕顶部显示 x 轴坐标。
  3. 打印海洋的每一行以及屏幕两侧的 y 轴坐标。
  4. 在底部再次打印 x 轴。在所有侧面都有坐标使得更容易看到在哪里放置声纳设备。
在棋盘顶部绘制 x 坐标

drawBoard()的第一部分在棋盘顶部打印 x 轴。因为我们希望棋盘的每个部分都是均匀的,所以每个坐标标签只能占用一个字符空间。当坐标编号达到 10 时,每个数字有两位数,因此我们将十位数放在单独的一行上,如图 13-3 所示。x 轴的组织方式是第一行显示十位数,第二行显示个位数。

图 13-3:用于打印游戏板顶部的间距

22 到 24 行创建了棋盘第一行的字符串,这是 x 轴的十位部分:

# Draw the board data structure.
    tensDigitsLine = '    ' # Initial space for the numbers down the left
          side of the board
    for i in range(1, 6):
        tensDigitsLine += (' ' * 9) + str(i)

第一行上标记十位数的数字之间都有 9 个空格,数字 1 前面有 13 个空格。22 到 24 行创建了一个包含此行的字符串,并将其存储在名为tensDigitsLine的变量中:

# Print the numbers across the top of the board.
    print(tensDigitsLine)
    print('   ' + ('0123456789' * 6))
    print()

要打印游戏板顶部的数字,首先打印tensDigitsLine变量的内容。然后,在下一行打印三个空格(以便这一行正确对齐),然后打印字符串'0123456789'六次:('0123456789' * 6)

绘制海洋

32 到 44 行打印海洋波浪的每一行,包括沿着两侧标记 y 轴的数字:

# Print each of the 15 rows.
    for row in range(15):
        # Single-digit numbers need to be padded with an extra space.
        if row < 10:
            extraSpace = ' '
        else:
            extraSpace = ''

for循环打印了 0 到 14 行,以及棋盘两侧的行号。

但我们与 x 轴遇到的问题相同。只有一位数字的数字(例如 0,1,2 等)在打印时只占用一个空间,但是两位数字(例如 10,11 和 12)占用两个空间。如果坐标大小不同,行将不对齐。棋盘将如下所示:

8 ~~`~`~~```~``~~``~~~``~~`~`~~`~`~```~```~~~```~~~~~~`~`~~~~` 8
9 ```~``~`~~~`~~```~``~``~~~```~````~```~`~~`~~~~~`~``~~~~~```9
10 `~~~~```~`~````~`~`~~``~`~~~~`~``~``~```~~```````~`~``~`````10
11 ~~`~`~~`~``~`~~~````````````````~~`````~`~~``~`~~~~`~~~`~~`~ 11

解决方法很简单:在所有个位数前面加一个空格。第 34 到 37 行将变量extraSpace设置为空格或空字符串。总是打印extraSpace变量,但它有一个仅用于个位数行号的空格字符。否则,它是一个空字符串。这样,打印时所有的行都会排成一行。

在海洋中打印一行

board 参数是整个海浪的数据结构。第 39 到 44 行读取board变量并打印一行:

# Create the string for this row on the board.
    boardRow = ''
    for column in range(60):
        boardRow += board[column][row]
    print('%s%s %s %s' % (extraSpace, row, boardRow, row))

在第 40 行,boardRow以一个空白字符串开始。第 32 行上的for循环为要打印的当前海浪行设置row变量。在这个循环的第 41 行是另一个for循环,它迭代当前行的每一列。我们通过在这个循环中连接board[column][row]来生成boardRow,这意味着将board[0][row], board[1][row], board[2][row]等连接到board[59][row]。这是因为该行包含从索引0到索引59的 60 个字符。

第 41 行的for循环遍历整数059。在每次迭代中,将板数据结构中的下一个字符复制到boardRow的末尾。循环结束时,boardRow中包含了整行的 ASCII 艺术波浪。然后在第 44 行打印boardRow中的字符串以及行号。

在棋盘底部绘制 X 坐标

第 46 行到 49 行与第 26 行到 29 行类似:

# Print the numbers across the bottom of the board.
    print()
    print('   ' + ('0123456789' * 6))
    print(tensDigitsLine)

这些线在板的底部打印 x 坐标。

创建随机宝箱

游戏会随机决定隐藏宝箱的位置。宝箱被表示为包含两个整数的列表的列表。这两个整数分别是一个宝箱的 x 和 y 坐标。例如,如果宝箱的数据结构是[[2, 2], [2, 4], [10, 0]],那么这意味着有三个宝箱,一个在(2, 2),另一个在(2, 4),第 三个在(10, 0)。

getRandomChests()函数会在随机分配的坐标上创建一定数量的宝箱数据结构:

def getRandomChests(numChests):
    # Create a list of chest data structures (two-item lists of x, y int
          coordinates).
    chests = []
    while len(chests) < numChests:
        newChest = [random.randint(0, 59), random.randint(0, 14)]
        if newChest not in chests: # Make sure a chest is not already
              here.
            chests.append(newChest)
    return chests

numChets参数告诉函数要生成多少个宝库。第 54 行的while循环将迭代,直到所有箱子都被分配了坐标。选择两个随机整数作为第 55 行的坐标。x 坐标可以是从 0 到 59 的任何位置,y 坐标可以是 0 到 14\的任何位置。[random.randint(0, 59), random.randint(0, 14)]表达式的计算结果为一个列表值,如[2, 2][2, 4][10, 0]。如果这些坐标还不存在于chests列表中,则将它们附加到第 57 行的chests中。

确定移动是否有效

当玩家输入他们想要放置声纳设备的 x 和 y 坐标时,我们需要确保这些数字是有效的。如前所述,移动有效有两个条件:x 坐标必须在 0 到 59 之间,y 坐标必须在 1 到 14 之间。

isOnBoard()函数使用一个带有and运算符的简单表达式,将这些条件组合成一个表达式,并确保表达式的每个部分都是True

def isOnBoard(x, y):
    # Return True if the coordinates are on the board; otherwise, return
          False.
    return x >= 0 and x <= 59 and y >= 0 and y <= 14

因为我们使用了 and 布尔运算符,如果坐标中有一个是无效的,那么整个表达式的值就会被求值为 False。

在棋盘上放置一个移动

在声纳寻宝游戏中,游戏板被更新以显示一个数字,代表每个声纳设备到最近宝藏箱的距离。因此,当玩家通过给程序提供 x 和 y 坐标来进行移动时,游戏板会根据宝藏箱的位置而改变。

def makeMove(board, chests, x, y):
    # Change the board data structure with a sonar device character.
          Remove treasure chests from the chests list as they are found.
    # Return False if this is an invalid move.
    # Otherwise, return the string of the result of this move.

makeMove() 函数接受四个参数:游戏板的数据结构,宝藏箱的数据结构,x 坐标和 y 坐标。makeMove() 函数将返回一个描述移动响应的字符串值:

  • 如果坐标直接落在宝藏箱上,makeMove() 返回'You have found a sunken treasure chest!'
  • 如果坐标距离宝藏箱不超过 9 个单位,makeMove() 返回'Sonar did not detect anything. All treasure chests out of range.'(其中 %s 被替换为整数距离)。
  • 否则,makeMove() 将返回'You have found a sunken treasure chest!'

给定玩家想要放置声纳设备的坐标以及宝藏箱的 x 和 y 坐标列表,你需要一个算法来找出哪个宝藏箱最近。

寻找最近的宝藏箱

68 到 75 行是一个算法,用于确定哪个宝藏箱离声纳设备最近。

smallestDistance = 100 # Any chest will be closer than 100.
    for cx, cy in chests:
        distance = math.sqrt((cx - x) * (cx - x) + (cy - y) * (cy - y))
        if distance < smallestDistance: # We want the closest treasure
              chest.
            smallestDistance = distance

xy参数是整数(比如35),它们一起表示玩家猜测的游戏板上的位置。chests变量将具有诸如[5, 0], [0, 2], [4, 2]的值,表示三个宝藏箱的位置。[图 13-4 说明了这个值。

要找到声纳设备与宝藏箱之间的距离,我们需要进行一些数学运算, 以找到两个 x 和 y 坐标之间的距离。假设我们将声纳设备放在(3, 5)处,并想找到到(4, 2)处宝藏箱的距离。

图 13-4:由[[5, 0], [0, 2], [4, 2]]表示的宝箱

要找到两组 x 和 y 坐标之间的距离,我们将使用毕达哥拉斯定理。这个定理适用于直角三角形——一个角是 90 度的三角形,就像矩形中找到的那种角。毕达哥拉斯定理说,三角形的对角线可以从水平和垂直两边的长度计算出来。图 13-5 显示了在声纳设备(3, 5)和宝箱(4, 2)之间绘制的直角三角形。

图 13-5:在声纳设备上画了一个直角三角形和一个宝藏箱的板子

毕达哥拉斯定理是a² + b² = c²,其中a是水平边的 长度,b是垂直边的长度,c是对角边的长度,或者叫做斜边。这些长度都是平方的,意味着这个数字被自己相乘。将一个数字的平方根称为找到这个数字的平方根,这就是我们要从c²得到c的过程。

让我们使用毕达哥拉斯定理来找到声纳设备在(3, 5)处 和宝藏箱在(4, 2)处之间的距离:

  1. 要找到a,请从第一个 x 坐标 3 中减去第二个 x 坐标 4:3 - 4 = -1。\
  2. 要找到a²,请将a乘以a:-1 × -1 = 1。 (负数乘以负数总是正数。)\
  3. 要找到b,请从第一个 y 坐标 5 中减去第二个 y 坐标 2:5 - 2 = 3。
  4. 要找到b²,请将b乘以b:3 × 3 = 9。
  5. 要找到c²,请将a²和b²相加:1 + 9 = 10。
  6. 要从c²得到c,您需要找到c²的平方根。

我们在第 5 行导入的math模块有一个名为sqrt()的平方根函数。在交互式 shell 中输入以下内容:

>>> import math
>>> math.sqrt(10)
3.1622776601683795
>>> 3.1622776601683795 * 3.1622776601683795
10.000000000000002

注意,将一个平方根乘以自身会得到一个平方数。(10末尾的额外2来自于sqrt()函数中不可避免的轻微不精确性。)\

通过将c²传递给sqrt(),我们可以知道声纳设备距离宝藏箱 3.16 个单位。游戏会将其四舍五入为 3。

让我们再次看一下 68 到 70 行:

smallestDistance = 100 # Any chest will be closer than 100.
    for cx, cy in chests:
        distance = math.sqrt((cx - x) * (cx - x) + (cy - y) * (cy - y))

第 69 行的for循环内的代码计算了每个宝箱的距离。第 68 行在循环开始时给smallestDistance赋予了一个不可能的长距离100,这样你找到的至少一个宝箱将会在第 73 行被放入smallestDistance。因为cx - x代表宝箱和声纳设备之间的水平距离a(cx - x) * (cx - x)是我们毕达哥拉斯定理计算的a²。它被加到(cy - y) * (cy - y),即b²。这个和是c²,并传递给sqrt()来得到宝箱和声纳设备之间的距离。

我们想要找到声纳设备和最近宝箱之间的距离,所以如果这个距离小于最小距离,它会在第 73 行被保存为新的最小距离:

if distance < smallestDistance: # We want the closest treasure
              chest.
            smallestDistance = distance

for循环结束时,你就知道smallestDistance保存着声纳设备与游戏中所有宝藏箱之间的最短距离。

使用 remove()列表方法删除数值

remove()列表方法会移除匹配传入参数的第一个数值。例如,将以下内容输入到交互式 shell 中:

>>> x = [42, 5, 10, 42, 15, 42]
>>> x.remove(10)
>>> x
[42, 5, 42, 15, 42]

x列表中的10值已被移除。

现在将以下内容输入到交互式 shell 中:

>>> x = [42, 5, 10, 42, 15, 42]
>>> x.remove(42)
>>> x
[5, 10, 42, 15, 42]

请注意,只有第一个42值被移除了,第二个和第三个仍然存在。remove()方法只会移除你传递给它的值的第一个出现的位置。

如果你尝试移除列表中不存在的值,你会收到一个错误:

>>> x = [5, 42]
>>> x.remove(10)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: list.remove(x): x not in list

append()方法类似,remove()方法是在列表上调用的,不会返回一个列表。你应该使用像x.remove(42)这样的代码,而不是x = x.remove(42)

让我们回到游戏中找到声纳设备和宝箱之间的距离。smallestDistance等于0的唯一时机是当声纳设备 的 x 和 y 坐标与宝箱的 x 和 y 坐标相同时。这意味着玩家已经正确猜到了宝箱的位置。

if smallestDistance == 0:
        # xy is directly on a treasure chest!
        chests.remove([x, y])
        return 'You have found a sunken treasure chest!'

当发生这种情况时,程序使用remove()列表方法从chests数据结构中移除这个宝箱的两个整数列表。然后函数返回'You have found a sunken treasure chest!'

但是如果smallestDistance不是0,玩家没有猜中宝箱的确切位置,那么从第 81 行开始的else块将被执 行:

else:
        if smallestDistance < 10:
            board[x][y] = str(smallestDistance)
            return 'Treasure detected at a distance of %s from the sonar
                  device.' % (smallestDistance)
        else:
            board[x][y] = 'X'
            return 'Sonar did not detect anything. All treasure chests
                  out of range.'

如果声纳设备到宝藏箱的距离小于 10,第 83 行用smallestDistance的字符串版本标记棋盘。如果不是,则用'X'标记棋盘。这 样,玩家就知道每个声纳设备离宝藏箱有多近。如果玩家看到一个 0,他们就知道他们离得很远。

获取玩家的移动

enterPlayerMove()函数收集玩家下一步移动的 x 和 y 坐标:

def enterPlayerMove(previousMoves):
    # Let the player enter their move. Return a two-item list of int
          xy coordinates.
    print('Where do you want to drop the next sonar device? (0-59 0-14)
          (or type quit)')
    while True:
        move = input()
        if move.lower() == 'quit':
            print('Thanks for playing!')
            sys.exit()

previousMoves参数是一个包含前几次玩家放置声纳设备的位置的二维整数列表。这些信息将被用来确保玩家不能在已经放置过 声纳设备的地方再次放置。

while循环会一直询问玩家下一步的移动,直到他们输入一个没有放置声纳设备的位置。玩家也可以输 入'quit'来退出游戏。在这种情况下,第 96 行调用sys.exit()函数立即终止程序。

假设玩家没有输入'quit',代码会检查输入是否是由一个空格分隔的两个整数。第 98 行调用split()方法来将move的新值分割。

move = move.split()
            if len(move) == 2 and move[0].isdigit() and move[1].isdigit() and
               isOnBoard(int(move[0]), int(move[1])):
            if [int(move[0]), int(move[1])] in previousMoves:
                print('You already moved there.')
                continue
            return [int(move[0]), int(move[1])]
        print('Enter a number from 0 to 59, a space, then a number from
               0 to 14.')

如果玩家输入了像'1 2 3'这样的值,那么split()返回的列表将是'1','2','3'。在这种情况下,表达式len(move) == 2将是False(move 中的列表应该只有两个数字,因为它表示一个坐标),整个表达式将立即求值为False。Python 不会检查表达式的 其余部分,因为它采用了短路运算(这在第 139 页的“短路评估”中有描述)。

如果列表的长度是2,那么这两个值将分别在索引move[0]move[1]。要检查这些值是否是数字(如'2''17'),你可以使用类似于“[检查字符串是否只包含数字]”第 158 页的isOnlyDigits()函数。但是 Python 已经有一个可以做到这一点的方法。

字符串方法isdigit()如果字符串完全由数字组成,则返回True。否则,它返回False。在交互式 shell 中输入以下内容:

>>> '42'.isdigit()
True
>>> 'forty'.isdigit()
False
>>> ''.isdigit()
False
>>> 'hello'.isdigit()
False
>>> x = '10'
>>> x.isdigit()
True

move[0].isdigit()move[1].isdigit() 必须都为 True 才能使整个条件为 True。第 99 行条件的最后部分调用 isOnBoard() 函数来检查 x 和 y 坐标是否存在于棋盘上。

如果整个条件为 True,第 100 行检查移动是否存在于 previousMoves 列表中。如果存在,那么第 102 行的 continue 语句会导致执行返回到第 92 行 while 循环的开始,然后再次要求玩家移动。如果不存在,第 103 行返回一个包含 x 和 y 坐标的两个整数的列表。

为玩家打印游戏说明

showInstructions() 函数是一对 print() 调用,用于打印多行字符串:

def showInstructions():
    print('''Instructions:
You are the captain of the Simon, a treasure-hunting ship. Your current
       mission
--snip--
Press enter to continue...''')
    input()

input()函数给玩家一个机会在打印下一个字符串之前按下 ENTER 键。这是因为 IDLE 窗口一次只能显示那么多文本,我们不希望玩家不得不向上滚动以阅读文本的开头。玩家按下 ENTER 键后,函数返回到调用该函数的行。

游戏循环

现在我们已经输入了游 戏将调用的所有函数,让我们进入游戏的主要部分。在运行程序后玩家看到的第一件事是由第 159 行打印的游戏标题。这是程序的主要部 分,它首先提供玩家说明,然后设置游戏将使用的变量。

print('S O N A R !')
print()
print('Would you like to view the instructions? (yes/no)')
if input().lower().startswith('y'):
showInstructions()
while True:
    # Game setup
    sonarDevices = 20
    theBoard = getNewBoard()
    theChests = getRandomChests(3)
    drawBoard(theBoard)
    previousMoves = []

表达式 input().lower().startswith('y') 允许玩家请求说明,如果玩家输入以 'y''Y' 开头的字符串,则该表达式 的值为 True。例如:

如果这个条件是True,则在第 163 行调用showInstructions()。否则,游戏开始。

在第 167 到 171 行设置了几个变量;这些 在表 13-1 中有描述。

表 13-1: 主游戏循环中使用的变量

变量 描述
sonarDevices 玩家剩余的声纳设备(和回合)数量。
theBoard 用于这个游戏的棋盘数据结构。
theChests 宝箱数据结构的列表 。getRandomChests()在棋盘上的随机位置返回三个宝藏宝箱的列表。
previousMoves 玩家在游戏中所做的所有 x 和 y 移动的 列表。

我们很快就要使用这些变量,所以在继续之前,请确保查看它们的描述!

为玩家显示游戏状态

第 173 行的while循 环会在玩家剩余声纳设备时执行,并打印一条消息告诉他们剩下多少声纳设备和宝藏。

while sonarDevices > 0:
        # Show sonar device and chest statuses.
        print('You have %s sonar device(s) left. %s treasure chest(s)
               remaining.' % (sonarDevices, len(theChests)))

打印剩余设备数量后,while循环继续执行。

处理玩家的移动

第 177 行仍然是while循环的一部分,并使用多重赋值将xy变量分配给表示玩家移动坐标的两项列表,该列表由enterPlayerMove()返回。我们将传入previousMoves,以便enterPlayerMove()的代码可以确保玩家不会重复之前的移动。

x, y = enterPlayerMove(previousMoves)
        previousMoves.append([x, y]) # We must track all moves so that
               sonar devices can be updated.
        moveResult = makeMove(theBoard, theChests, x, y)
        if moveResult == False:
            continue

然后将xy变量附加到previousMoves列表的末尾。previousMoves变量是玩家在这个游戏中所做的每一步移动的 x 和 y 坐标的列表。这个列表稍后在程序的第 177 和 186 行中使用。

xytheBoardtheChests变量都传递给第 180 行的makeMove()函数。这个函数对棋盘上放置声纳设备进行必要的修改。

如果makeMove()返回False,那么传递给它的xy值存在问题。continue语句将执行返回到第 173 行的while循环的开始,再次要求玩家输入 x 和 y 坐标。

寻找沉没的宝藏箱

如果makeMove()没有返回False,它会返回一个表示该移动结果的字符串。如果这个字符串是'You have found a sunken treasure chest!',那么棋盘上所有的声纳设备都应该更新以侦测到下一个最近的宝藏箱。

else:
            if moveResult == 'You have found a sunken treasure chest!':
                # Update all the sonar devices currently on the map.
                for x, y in previousMoves:
                    makeMove(theBoard, theChests, x, y)
                drawBoard(theBoard)
                print(moveResult)

所有声纳设备的 x 和 y 坐标都在previousMoves中。通过在第 186 行对previousMoves进行迭代,您可以再次将所有这些 x 和 y 坐标 传递给makeMove()函数,以便在棋盘上重新绘制这些值。因为程序在这里不打印任何新文本,玩家并不会意识到程序正在重做所有先前的移动。它看起来只是棋盘自己更新了。

检查玩家是否获胜

请记住,makeMove()函数修改了您发送给它的theChests列表。因为theChests是一个列表,所以在函数内部对其进行的任何更改都将在函数执行返回后持续存在。当找到宝藏箱时,makeMove()函数会从theChests中移除物品,因此最终(如果玩家继续猜对)所有的宝藏箱都将被移除。(请记住,“宝藏箱”指的是theChests列表中包含的 x 和 y 坐标的两个项目列表。)

if len(theChests) == 0:
            print('You have found all the sunken treasure chests!
                   Congratulations and good game!')
            break

当板上所有的宝箱都被找到并从theChests中移除后,theChests列表的长度将为0。当这种情况发生时,代码会向玩家显示 祝贺的消息,然后执行break语句来跳出这个while循环。执行将会移动到第 197 行,即while块之后的第一行。

检查玩家 是否输了

第 195 行是从第 173 行开始的while循环的最后一行。

sonarDevices -= 1

该程序减少了sonarDevices变量,因为玩家使用了一个声纳设备。如果玩家继续错过宝藏,最终sonarDevices将减少到0。 在这一行之后,执行会跳转回到第 173 行,这样它可以重新评估while语句的条件(即sonarDevices > 0)。

如果sonarDevices0,那么条件将是False,执行将继续在第 197 行的while块之外。但在那之前,条件将保持为True,玩家可以继续猜测。

if sonarDevices == 0:
        print('We\'ve run out of sonar devices! Now we have to turn the
               ship around and head')
        print('for home with treasure chests still out there! Game
               over.')
        print('    The remaining chests were here:')
        for x, y in theChests:
            print(' %s, %s' % (x, y))

197 行是while循环之外的第一行。当执行到这一点时,游戏结束了。如果sonarDevices0,你就知道玩家在找到所有宝箱 之前用完了声纳设备并且输了。

198 到 200 行会告诉玩家他们输了。201 行的for循环将遍历theChests中剩下的宝箱,并显示它们 的位置,这样玩家就可以看到宝箱隐藏的地方。

使用 sys.exit()函数终止程序

无论输赢,程序都会让玩家决定是否要继续 玩。如果玩家没有输入'yes''Y',或者输入了以字母y开头的其他字符串,那么not input().lower().startswith('y')就会被求值为True,并且sys.exit()函数就会被执行。这会导致程序终止。

print('Do you want to play again? (yes or no)')
    if not input().lower().startswith('y'):
        sys.exit()

否则,执行跳转回到第 165 行的while循环的开始,开始新的游戏。

总结

还记得我们的井字游戏是如何用 1 到 9 编号的吗?这种坐标系统对于少于 10 个空间的棋盘可能还可以。但是声纳寻宝棋盘有 900 个空间!我们在第 12 章中学到的笛卡尔坐标系真的让所有这些空间都变得可管理,特别是当我们的游戏需要找到棋盘上两点之间的距离时。

使用笛卡尔坐标系的游戏中的位置可以存储在一个列表的列表中,其中第一个索引是 x 坐标,第二个索引是 y 坐标。这样就可以很容易地使用board[x][y]访问坐标。

这些数据结构(比如用于海洋和宝藏位置的数据结构)使得能够将复杂的概念表示为数据,而你的游戏程序主要是关于修改这些数据结构。

在下一章中,我们将把字母表示为数字。通过将文本表示为数字,我们可以对其进行数学运算,以加密或解密秘密消息。

十四、凯撒密码

原文:inventwithpython.com/invent4thed/chapter14.html

译者:飞龙

协议:CC BY-NC-SA 4.0


本章的程序并不是一个真正的游戏,但它仍然很有趣。该程序将把普通英语转换成秘密代码。它还可以将秘密代码转换回普通英语。只有知道秘密代码的人才能理解这些消息。

由于这个程序操纵文本将其转换为秘密消息,你将学习几个新的用于操纵字符串的函数和方法。你还将学习程序如何能够像处理数字一样处理文本字符串。

本章涉及的主题

  • 密码学和密码
  • 密文、明文、密钥和符号
  • 加密和解密
  • 凯撒密码
  • find()字符串方法
  • 密码分析
  • 穷举法技术

密码学和加密

编写秘密代码的科学称为密码学。几千年来,密码学使得发送只有发件人和收件人能够阅读的秘密消息成为可能,即使有人捕获信使并阅读了编码消息。秘密代码系统称为密码。本章程序使用的密码称为凯撒密码

在密码学中,我们称要保密的消息为明文。假设我们有一条看起来像这样的明文消息:

There is a clue behind the bookshelf.

将明文转换为编码消息称为加密明文。明文被加密成密文。密文看起来像随机字母,所以我们不能通过查看密文来理解原始明文是什么。以下是前面的示例加密成密文:

aolyl pz h jsBl ilopuk Aol ivvrzolsm.

如果你知道用于加密消息的密码,你可以将密文解密回明文。(解密是加密的反向操作。)

许多密码使用密钥,这些密钥是让你解密使用特定密码加密的密文的秘密值。可以将密码看作是门锁。只有用特定的钥匙才能打开它。

如果你对编写密码学程序感兴趣,可以阅读我的书使用 Python 黑客秘密密码。可以从http://inventwithpython.com/hacking/免费下载。

凯撒密码的工作原理

凯撒密码是有史以来最早的密码之一。在这种密码中,你通过用“移位”字母替换消息中的每个字母来加密消息。在密码学中,加密的字母称为符号,因为它们可以是字母、数字或任何其他符号。如果你将字母 A 向后移动一个空格,你会得到字母 B。如果你将字母 A 向后移动两个空格,你会得到字母 C。图 14-1 显示了一些字母向后移动三个空格的示例。

图 14-1:凯撒密码将字母向后移动三个空格。这里,B 变成了 E。

要获得每个移位后的字母,可以画一排方框,每个方框内写上字母表中的每个字母。然后在下面再画一排方框,但是从某个特定的空格开始写字母。当你到达明文字母表的末尾时,回到 A。图 14-2 显示了一个字母向后移动三个空格的示例。

图 14-2:整个字母表向后移动三个空格

凯撒密码中,你将字母移动的空格数(在 1 到 26 之间)就是密钥。除非你知道密钥(用于加密消息的数字),否则你将无法解密秘密代码。图 14-2 中的示例显示了密钥 3 的字母转换。

注意

虽然有 26 个可能的密钥,但使用 26 加密消息将导致密文与明文完全相同!

如果您使用密钥 3 加密明文单词 HOWDY,则:

  • 字母 H 变成 K。
  • 字母 O 变成 R。
  • 字母 W 变成 Z。
  • 字母 D 变成 G。
  • 字母 Y 变成 B。

因此,使用密钥 3 的 HOWDY 的密文变为 KRZGB。要使用密钥 3 解密 KRZGB,我们从底部的框返回到顶部。

如果您想将小写字母与大写字母区分开,那么请将另外 26 个框添加到已有的框中,并用 26 个小写字母填充它们。现在使用密钥 3,字母 Y 变成 b,如图 14-3 所示。

图 14-3:整个字母表,现在包括小写字母,向后移动三个空格

密码的工作方式与仅使用大写字母时相同。实际上,如果您想要使用另一种语言字母表的字母,您可以写下这些字母的框,以创建您的密码。

凯撒密码的示例运行

这是凯撒密码程序加密消息的示例运行:

Do you wish to encrypt or decrypt a message?
encrypt
Enter your message:
The sky above the port was the color of television, tuned to a dead channel.
Enter the key number (1-52)
13
Your translated text is:
gur FxL noBIr Gur CBEG JnF Gur pByBE Bs GryrIvFvBA, GHArq GB n qrnq punAAry.

现在运行程序并解密刚刚加密的文本:

Do you wish to encrypt or decrypt a message?
decrypt
Enter your message:
gur FxL noBIr Gur CBEG JnF Gur pByBE Bs GryrIvFvBA, GHArq GB n qrnq punAAry.
Enter the key number (1-52)
13
Your translated text is:
The sky above the port was the color of television, tuned to a dead channel.

如果您没有使用正确的密钥解密,文本将无法正确解密:

Do you wish to encrypt or decrypt a message?
decrypt
Enter your message:
gur FxL noBIr Gur CBEG JnF Gur pByBE Bs GryrIvFvBA, GHArq GB n qrnq punAAry.
Enter the key number (1-52)
15
Your translated text is:
Rfc qiw YZmtc rfc nmpr uYq rfc amjmp md rcjctgqgml, rslcb rm Y bcYb afYllcj.

凯撒密码的源代码

输入此凯撒密码程序的源代码,然后将文件保存为cipher.py

如果在输入此代码后出现错误,请使用在线差异工具将您输入的代码与本书代码进行比较,网址为www.nostarch.com/inventwithpython#diff

cipher.py

# Caesar Cipher
SYMBOLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
MAX_KEY_SIZE = len(SYMBOLS)
def getMode():
    while True:
        print('Do you wish to encrypt or decrypt a message?')
        mode = input().lower()
        if mode in ['encrypt', 'e', 'decrypt', 'd']:
            return mode
        else:
            print('Enter either "encrypt" or "e" or "decrypt" or "d".')
def getMessage():
    print('Enter your message:')
    return input()
def getKey():
    key = 0
    while True:
        print('Enter the key number (1-%s)' % (MAX_KEY_SIZE))
        key = int(input())
        if (key >= 1 and key <= MAX_KEY_SIZE):
            return key
def getTranslatedMessage(mode, message, key):
    if mode[0] == 'd':
        key = -key
    translated = ''
    for symbol in message:
        symbolIndex = SYMBOLS.find(symbol)
        if symbolIndex == -1: # Symbol not found in SYMBOLS.
            # Just add this symbol without any change.
            translated += symbol
        else:
            # Encrypt or decrypt.
            symbolIndex += key
            if symbolIndex >= len(SYMBOLS):
                symbolIndex -= len(SYMBOLS)
            elif symbolIndex < 0:
                symbolIndex += len(SYMBOLS)
            translated += SYMBOLS[symbolIndex]
    return translated
mode = getMode()
message = getMessage()
key = getKey()
print('Your translated text is:')
print(getTranslatedMessage(mode, message, key))

设置最大密钥长度

加密和解密过程是彼此的相反,但它们共享大部分相同的代码。让我们看看每行是如何工作的:

# Caesar Cipher
SYMBOLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
MAX_KEY_SIZE = len(SYMBOLS)

MAX_KEY_SIZE是一个常量,存储SYMBOLS的长度(52)。这个常量提醒我们,在这个程序中,密码中使用的密钥应该始终在 1 和 52 之间。

决定加密或解密消息

getMode()函数让用户决定他们想要使用程序的加密或解密模式:

def getMode():
    while True:
        print('Do you wish to encrypt or decrypt a message?')
        mode = input().lower()
        if mode in ['encrypt', 'e', 'decrypt', 'd']:
            return mode
        else:
            print('Enter either "encrypt" or "e" or "decrypt" or "d".')

第 8 行调用input()让用户输入他们想要的模式。然后在这个字符串上调用lower()方法,以返回字符串的小写版本。从input().lower()返回的值存储在mode中。if语句的条件检查存储在mode中的字符串是否存在于['encrypt', 'e', 'decrypt', 'd']列表中。

只要mode等于'encrypt''e''decrypt''d',此函数将返回mode中的字符串。因此,getMode()将返回字符串mode。如果用户输入的内容不是'encrypt''e''decrypt''d',则while循环将再次询问他们。

从玩家获取消息

getMessage()函数只是从用户那里获取要加密或解密的消息并返回它:

def getMessage():
    print('Enter your message:')
    return input()

input()的调用与return结合在一起,这样我们只使用一行而不是两行。

从玩家获取密钥

getKey()函数让玩家输入他们将用于加密或解密消息的密钥:

def getKey():
    key = 0
    while True:
        print('Enter the key number (1-%s)' % (MAX_KEY_SIZE))
        key = int(input())
        if (key >= 1 and key <= MAX_KEY_SIZE):
            return key

while循环确保函数保持循环,直到用户输入有效的密钥。这里的有效密钥是介于整数值152之间的值(记住MAX_KEY_SIZE52,因为SYMBOLS变量中有 52 个字符)。getKey()函数然后返回这个密钥。第 22 行将key设置为用户输入的整数版本,因此getKey()返回一个整数。

加密或解密消息

getTranslatedMessage()函数执行实际的加密和解密:

def getTranslatedMessage(mode, message, key):
    if mode[0] == 'd':
        key = -key
    translated = ''

它有三个参数:

mode这将设置函数为加密模式或解密模式。

message这是要加密(或解密)的明文(或密文)。

key这是在这个密码中使用的密钥。

第 27 行检查mode变量中的第一个字母是否为字符串'd'。如果是,则程序处于解密模式。解密和加密模式之间唯一的区别是,在解密模式中,密钥设置为其负版本。例如,如果key是整数22,那么解密模式将其设置为-22。原因在“加密或解密每个字母”中的第 205 页中有解释。

translated变量将包含结果的字符串:密文(如果您正在加密)或明文(如果您正在解密)。它起初为空字符串,并且已加密或解密的字符连接到其末尾。但是,在我们开始将字符连接到translated之前,我们需要加密或解密文本,我们将在getTranslatedMessage()的其余部分中进行。

使用 find()字符串方法查找传递的字符串

为了将字母移动以进行加密或解密,我们首先需要将它们转换为数字。SYMBOLS字符串中每个字母的数字将是它出现的索引。由于字母 A 在SYMBOLS[0]处,数字0将表示大写字母 A。如果我们想要使用密钥 3 对其进行加密,我们只需使用 0 + 3 来获取加密字母的索引:SYMBOLS[3]'D'

我们将使用find()字符串方法,该方法在调用该方法的字符串中查找传递的字符串的第一次出现。在交互式 shell 中输入以下内容:

>>> 'Hello world!'.find('H')
0
>>> 'Hello world!'.find('o')
4
>>> 'Hello world!'.find('ell')
1

'Hello world!'.find('H')返回0,因为在字符串'Hello world!'的第一个索引处找到了'H'。请记住,索引从0开始,而不是1。代码'Hello world!'.find('o')返回4,因为小写的'o'首次出现在'Hello'的末尾。find()方法在第一次出现后停止查找,所以'world'中的第二个'o'不重要。您还可以查找多于一个字符的字符串。字符串'ell'从索引1开始找到。

如果找不到传递的字符串,则find()方法返回-1

>>> 'Hello world!'.find('xyz')
-1

让我们回到凯撒密码程序。第 31 行是一个for循环,它对message字符串中的每个字符进行迭代:

for symbol in message:
        symbolIndex = SYMBOLS.find(symbol)
        if symbolIndex == -1: # Symbol not found in SYMBOLS.
            # Just add this symbol without any change.
            translated += symbol

find()方法用于第 32 行获取symbol中字符串的索引。如果find()返回-1,则symbol中的字符将被添加到translated中而不会发生任何更改。这意味着任何不属于字母表的字符,例如逗号和句号,都不会被更改。

加密或解密每个字母

一旦找到了字母的索引号,将密钥添加到数字中将执行移位并为您提供加密字母的索引。

第 38 行执行此加法以获得加密(或解密)字母。

# Encrypt or decrypt.
            symbolIndex += key

请记住,在第 28 行,我们将key中的整数变为负数以进行解密。现在添加密钥的代码将对其进行减法,因为添加负数与减法相同。

但是,如果此加法(或减法,如果key为负数)导致symbolIndex超过SYMBOLS的最后一个索引,我们需要将其环绕到列表的开头0。这由从第 40 行开始的if语句处理:

if symbolIndex >= len(SYMBOLS):
                symbolIndex -= len(SYMBOLS)
            elif symbolIndex < 0:
                symbolIndex += len(SYMBOLS)
            translated += SYMBOLS[symbolIndex]

第 40 行检查symbolIndex是否已经超过了SYMBOLS字符串的最后一个索引,方法是将其与SYMBOLS字符串的长度进行比较。如果是,第 41 行将SYMBOLS的长度从symbolIndex中减去。如果symbolIndex现在是负数,则索引需要环绕到SYMBOLS字符串的另一侧。第 42 行检查在添加解密密钥后symbolIndex的值是否为负数。如果是,第 43 行将SYMBOLS的长度添加到symbolIndex中。

symbolIndex变量现在包含了正确加密或解密符号的索引。SYMBOLS[symbolIndex]将指向这个索引的字符,并且这个字符将被添加到第 45 行的translated的末尾。

执行回到第 31 行,重复这个过程,直到message中的下一个字符。一旦循环结束,函数将在第 46 行返回加密(或解密)字符串translated

return translated

getTranslatedMessage()函数中的最后一行返回translated字符串。

开始程序

程序的开始调用了之前定义的三个函数,以从用户那里获取modemessagekey

mode = getMode()
message = getMessage()
key = getKey()
print('Your translated text is:')
print(getTranslatedMessage(mode, message, key))

这三个值被传递给getTranslatedMessage(),其返回值(translated字符串)被打印给用户。

扩展符号

如果你想加密数字、空格和标点符号,只需将它们添加到第 2 行的SYMBOLS字符串中。例如,你可以通过将第 2 行更改为以下内容,让你的密码程序加密数字、空格和标点符号:

SYMBOLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz 123
     4567890!@#$%^&*()'

请注意,SYMBOLS字符串在小写字母z后面有一个空格字符。

如果你愿意,你可以在这个列表中添加更多的字符。而且你不需要改变你的程序的其余部分,因为所有需要字符列表的代码行都只使用SYMBOLS常量。

只需确保每个字符在字符串中只出现一次。此外,您需要使用与加密时相同的SYMBOLS字符串来解密您的消息。

暴力破解技术

这就是整个凯撒密码。然而,虽然这种密码可能会愚弄一些不懂密码学的人,但对于了解密码分析的人来说,它不会保密消息。虽然密码学是制作密码的科学,但密码分析是破译密码的科学。

密码学的整个目的是确保如果其他人拿到了加密消息,他们无法弄清原始文本。假设我们是密码破译者,我们手上只有这个加密文本:

LwCjBA uiG vwB jm xtmiAivB, jCB kmzBiqvBG qA ijACzl.

暴力破解是一种尝试每个可能的密钥直到找到正确密钥的技术。因为只有 52 个可能的密钥,所以对于密码分析家来说,编写一个解密每个可能密钥的黑客程序将是很容易的。然后他们可以寻找解密为普通英语的密钥。让我们给程序添加一个暴力破解的功能。

添加暴力破解模式

首先,更改第 7、9 和 12 行,这些行在getMode()函数中,看起来像下面这样(修改部分用粗体标出):

def getMode():
    while True:
        print('Do you wish to encrypt or decrypt or brute-force a
              message?')
        mode = input().lower()
        if mode in ['encrypt', 'e', 'decrypt', 'd', 'brute', 'b']:
            return mode
        else:
            print('Enter either "encrypt" or "e" or "decrypt" or "d" or
                  "brute" or "b".')

这段代码将允许用户选择暴力破解作为一种模式。

接下来,对程序的主要部分进行以下更改:

mode = getMode()
message = getMessage()
if mode[0] != 'b':
    key = getKey()
print('Your translated text is:')
if mode[0] != 'b':
    print(getTranslatedMessage(mode, message, key))
else:
    for key in range(1, MAX_KEY_SIZE + 1):
        print(key, getTranslatedMessage('decrypt', message, key))

如果用户不处于暴力破解模式,他们会被要求输入一个密钥,然后进行原始的getTranslatedMessage()调用,并打印出翻译后的字符串。

然而,如果用户处于暴力破解模式,那么getTranslatedMessage()循环迭代从1一直到MAX_KEY_SIZE(即52)。请记住,range()函数返回一个整数列表,最多到第二个参数,但不包括第二个参数,这就是为什么我们要加上+1。然后程序将打印出消息的每种可能的翻译(包括在翻译中使用的密钥号)。以下是对这个修改后程序的示例运行:

Do you wish to encrypt or decrypt or brute-force a message?
brute
Enter your message:
LwCjBA uiG vwB jm xtmiAivB, jCB kmzBiqvBG qA ijACzl.
Your translated text is:
1 KvBiAz thF uvA il wslhzhuA, iBA jlyAhpuAF pz hizByk.
2 JuAhzy sgE tuz hk vrkgygtz, hAz ikxzgotzE oy ghyAxj.
3 Itzgyx rfD sty gj uqjfxfsy, gzy hjwyfnsyD nx fgxzwi.
4 Hsyfxw qeC rsx fi tpiewerx, fyx givxemrxC mw efwyvh.
5 Grxewv pdB qrw eh sohdvdqw, exw fhuwdlqwB lv devxug.
6 Fqwdvu ocA pqv dg rngcucpv, dwv egtvckpvA ku cduwtf.
7 Epvcut nbz opu cf qmfbtbou, cvu dfsubjouz jt bctvse.
8 Doubts may not be pleasant, but certainty is absurd.
9 Cntasr lZx mns ad okdZrZms, ats bdqsZhmsx hr Zartqc.
10 BmsZrq kYw lmr Zc njcYqYlr, Zsr acprYglrw gq YZqspb.
11 AlrYqp jXv klq Yb mibXpXkq, Yrq ZboqXfkqv fp XYproa.
12 zkqXpo iWu jkp Xa lhaWoWjp, Xqp YanpWejpu eo WXoqnZ.
--snip--

在查看每一行后,你会发现第八条消息不是无意义的,而是普通的英语!密码分析家可以推断出这个加密文本的原始密钥必须是8。这种暴力破解方法在凯撒大帝和罗马帝国时代是很难做到的,但今天我们有计算机可以在短时间内快速地处理数百万甚至数十亿个密钥。

总结

计算机擅长做数学。当我们创建一个系统将某些信息转换为数字(就像我们用文本和序数或空间和坐标系这样做时),计算机程序可以快速高效地处理这些数字。编写程序的一个重要部分是弄清楚如何将要操作的信息表示为 Python 能理解的值。

虽然我们的凯撒密码程序可以加密消息,使它们对那些需要用铅笔和纸来解密的人保持秘密,但该程序无法对那些知道如何让计算机处理信息的人保密。(我们的穷举模式证明了这一点。)

在第 15 章中,我们将创建反转棋(也称为黑白棋或奥赛罗)。这个游戏的人工智能比第 10 章中玩井字游戏的人工智能要先进得多。事实上,它非常优秀,以至于大部分时间你都无法打败它!

相关文章
|
18天前
|
人工智能 数据可视化 数据挖掘
探索Python编程:从基础到高级
在这篇文章中,我们将一起深入探索Python编程的世界。无论你是初学者还是有经验的程序员,都可以从中获得新的知识和技能。我们将从Python的基础语法开始,然后逐步过渡到更复杂的主题,如面向对象编程、异常处理和模块使用。最后,我们将通过一些实际的代码示例,来展示如何应用这些知识解决实际问题。让我们一起开启Python编程的旅程吧!
|
17天前
|
存储 数据采集 人工智能
Python编程入门:从零基础到实战应用
本文是一篇面向初学者的Python编程教程,旨在帮助读者从零开始学习Python编程语言。文章首先介绍了Python的基本概念和特点,然后通过一个简单的例子展示了如何编写Python代码。接下来,文章详细介绍了Python的数据类型、变量、运算符、控制结构、函数等基本语法知识。最后,文章通过一个实战项目——制作一个简单的计算器程序,帮助读者巩固所学知识并提高编程技能。
|
5天前
|
Unix Linux 程序员
[oeasy]python053_学编程为什么从hello_world_开始
视频介绍了“Hello World”程序的由来及其在编程中的重要性。从贝尔实验室诞生的Unix系统和C语言说起,讲述了“Hello World”作为经典示例的起源和流传过程。文章还探讨了C语言对其他编程语言的影响,以及它在系统编程中的地位。最后总结了“Hello World”、print、小括号和双引号等编程概念的来源。
98 80
|
4天前
|
分布式计算 大数据 数据处理
技术评测:MaxCompute MaxFrame——阿里云自研分布式计算框架的Python编程接口
随着大数据和人工智能技术的发展,数据处理的需求日益增长。阿里云推出的MaxCompute MaxFrame(简称“MaxFrame”)是一个专为Python开发者设计的分布式计算框架,它不仅支持Python编程接口,还能直接利用MaxCompute的云原生大数据计算资源和服务。本文将通过一系列最佳实践测评,探讨MaxFrame在分布式Pandas处理以及大语言模型数据处理场景中的表现,并分析其在实际工作中的应用潜力。
24 2
|
17天前
|
小程序 开发者 Python
探索Python编程:从基础到实战
本文将引导你走进Python编程的世界,从基础语法开始,逐步深入到实战项目。我们将一起探讨如何在编程中发挥创意,解决问题,并分享一些实用的技巧和心得。无论你是编程新手还是有一定经验的开发者,这篇文章都将为你提供有价值的参考。让我们一起开启Python编程的探索之旅吧!
41 10
|
19天前
|
机器学习/深度学习 人工智能 数据挖掘
探索Python编程的奥秘
在数字世界的海洋中,Python如同一艘灵活的帆船,引领着无数探险者穿梭于数据的波涛之中。本文将带你领略Python编程的魅力,从基础语法到实际应用,一步步揭开Python的神秘面纱。
37 12
|
18天前
|
IDE 程序员 开发工具
Python编程入门:打造你的第一个程序
迈出编程的第一步,就像在未知的海洋中航行。本文是你启航的指南针,带你了解Python这门语言的魅力所在,并手把手教你构建第一个属于自己的程序。从安装环境到编写代码,我们将一步步走过这段旅程。准备好了吗?让我们开始吧!
|
19天前
|
关系型数据库 开发者 Python
Python编程中的面向对象设计原则####
在本文中,我们将探讨Python编程中的面向对象设计原则。面向对象编程(OOP)是一种通过使用“对象”和“类”的概念来组织代码的方法。我们将介绍SOLID原则,包括单一职责原则、开放/封闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。这些原则有助于提高代码的可读性、可维护性和可扩展性。 ####
|
17天前
|
人工智能 数据挖掘 开发者
探索Python编程之美:从基础到进阶
本文是一篇深入浅出的Python编程指南,旨在帮助初学者理解Python编程的核心概念,并引导他们逐步掌握更高级的技术。文章不仅涵盖了Python的基础语法,还深入探讨了面向对象编程、函数式编程等高级主题。通过丰富的代码示例和实践项目,读者将能够巩固所学知识,提升编程技能。无论你是编程新手还是有一定经验的开发者,这篇文章都将为你提供有价值的参考和启示。让我们一起踏上Python编程的美妙旅程吧!
|
20天前
|
机器学习/深度学习 人工智能 自然语言处理
探索未来编程:Python在人工智能领域的深度应用与前景###
本文将深入探讨Python语言在人工智能(AI)领域的广泛应用,从基础原理到前沿实践,揭示其如何成为推动AI技术创新的关键力量。通过分析Python的简洁性、灵活性以及丰富的库支持,展现其在机器学习、深度学习、自然语言处理等子领域的卓越贡献,并展望Python在未来AI发展中的核心地位与潜在变革。 ###

热门文章

最新文章