通过游戏编程学Python(7)— 井字棋(下)
通过游戏编程学Python(7)— 井字棋(上)
通过游戏编程学Python(番外篇)— 单词小测验


前言

前一段时间扫雷游戏挺火的,可惜问哥没有赶上热度。使用图形化开发不难,但是要想解释清楚还是要花不少时间。问哥还是想循序渐进地从零基础开始和大家一点点进步,这也是问哥写下这个系列的初衷。但是即使在控制台界面,我们依然可以用文本搭建一个扫雷的小游戏。下面跟着我来一起试试看吧。


知识点

  1. time.time()计时函数
  2. 笛卡尔坐标系
  3. 递归函数

第7个游戏:扫雷(文字版)

1. 玩法简介

扫雷的规则大家应该都知道吧。在我们这个文字版的扫雷小游戏中,稍微有一些操作的不同。比如无法用鼠标,只能通过给定坐标的位置去打开方格。方格被打开后显示的数字表示其周围的8个方格隐藏了几颗雷。如果周围8个方格都没有地雷,则递归地向8个方向自动依次打开,直到有方格有地雷为止。

如此重复上述操作,直到玩家“踩了雷”或者打开了所有非地雷的方块,游戏将判定失败或胜利。如果胜利的话还将显示玩家用了多少秒。然后询问是否再开一局,或者游戏结束。

游戏截图

在这里插入图片描述

2. 游戏流程

按照问哥的惯例,我们还是先画出游戏的基本流程,这样在后面用程序实现的时候,就能够更好地了解我们的进度,以及有没有遗漏的地方。

关于扫雷小游戏,问哥画出的流程图是这样的:

Yes
No
No
No
Yes
Yes
Yes
No
游戏开始
玩家选择难度,决定棋盘(雷池)的大小
根据难度生成地雷坐标
绘制棋盘
玩家决定打开哪个方格
判断玩家输入是否正确
判断是否“踩雷”
判断是否胜利
绘制胜利棋盘
游戏结束,询问是否再开一局
显示所有地雷
游戏结束

3. 搭建框架

和往常一样,有了流程图,我们先把游戏的主体框架搭出来,然后再把各种功能用自定义函数(子程序)去完成。

while True:
    print('+----------------------------------+')
    print('|        欢迎来玩扫雷小游戏        |')
    print('+----------------------------------+')
    难度选择()
    初始化雷池()
    埋雷()
    绘制雷池()
    开始计时()
    while True:
        玩家选择方格位置()
        if 玩家输入不正确:
            continue
        挖雷()
        if 玩家踩雷了() or 玩家赢了():
            break
        绘制雷池()
    if not 继续玩吗():
        break

可以看出,游戏有两个嵌套while循环完成。外层循环用来判断玩家是否在结束一局游戏后还要再来一局;内层循环是游戏的主体,不断地通过接收玩家输入方格的坐标来判断是不是雷。如果玩家准确无误地打开了所有非地雷的方块,游戏胜利。反之,只要玩家输入的坐标和地雷的坐标相同,则判定玩家“踩到雷”了,游戏失败。

而在这样的框架里,很多事情我们交给子程序去完成,比如判断胜利或失败,问哥放在了同一个语句里。因为不管是胜利还是失败,都要跳出内循环,所以胜利或失败后的分支任务,比如打印信息,就交给子程序去办好了。

关于难度的选择,问哥是这样定义的:数字1代表简单,2代表困难。如果是简单模式,则在屏幕上绘制10x10的雷池,只有10颗地雷。而如果是困难模式,则绘制10x20的雷池,埋20颗地雷。所以只需要用玩家选择的数字乘以10就可以自动得出雷的数量和雷池的大小。为了把雷池大小和地雷数量分开,这里定义了两个变量levelnum_mines。所以,如果你想增加地雷的数量,只要修改num_mines变量的值就可以了。

而在内循环开始前(正式游戏开始前),还需要记录游戏开始的时间。这里调用了time模块里的time()方法,记录下当前的时间。然后如果玩家胜利的话,再拿那时的时间减去这个开始时间,就能得出玩家耗时多少秒了。

关于最后询问玩家是否再玩一局,老套路了,不需要子程序,直接一条判断语句就好。

综上所述,新的主程序框架如下:

import time # 导入计时模块
while True:
    print('+----------------------------------+')
    print('|        欢迎来玩扫雷小游戏        |')
    print('+----------------------------------+')
    player_choose = input('请选择游戏难度(1-简单,2-困难):')
    if player_choose!='1' and player_choose!='2':
        break # 如果玩家输入1或2以外的数字则游戏结束
    level = 10 * int(player_choose) # 乘以10表示地雷的数量和雷池的大小
    num_mines = 10 * int(player_choose)
    board = new_board(level) # 初始化雷池(空的地图)
    mines = lay_mines(num_mines, level) # 随机生成地雷
    draw_board(board,level) # 绘制雷池
    start_time = time.time() # 记录游戏开始的时间
    while True: # 游戏正式开始
        move = is_valid_move(input('请选择位置:'), level)
        if not move:
            continue # 如果玩家输入不正确,直接回到循环最初重新输入
        row = move[0]
        column = move[1]
        dig_mine(row, column, mines, level)
        if is_lose(board, mines, level) or is_win(board, mines, level, start_time): 
            break # 判断是否胜利或失败,结果都是退出当前游戏
        draw_board(board,level)

    if not input('继续玩吗?(y-继续 | n-退出):').lower().startswith('y'):
        break

除了几个自定义函数以外,这里还有几个重要的变量,board, row, column,不要着急,马上就会介绍到。

4. 如何表示雷池

参考我们上一章井字棋的经验,我们知道用数组(Python里是列表)来表示棋子是一个不错的选择,于是我们可以继续套用这个经验。只不过,在扫雷这个游戏里,我们需要准确地判断10x10或者10x20个位置,显然我们需要更有效地组织数据。

观察画出来的雷池,或者思考一下想要画的雷池是不是这个样子:

简单模式

在这里插入图片描述

困难模式

在这里插入图片描述
可以看到,问哥想要实现的雷池是一个坐标系,横坐标是从大写字母A到J,或者到T(取决于选择的游戏难度),纵坐标是数字0到9。而雷池中的每一个“方块”都可以用0A,1B,2C 这样的数字加字母组合来表示。而在python中,我们可以用二维列表来实现这样的坐标系。

所谓的二维列表,就是列表里面套列表,列表的元素本身也是列表。我们可以定义一个二维列表变量board,用row来表示每一行的坐标,就可以表示为board[row],比如board[0]就可以表示上图中从0A到0T的二十个方块位置,而具体到每列的位置就可以继续使用索引,比如board[row][column]。所以可以用board[0][0]表示0A,board[1][1]表示1B。

对于初始化的棋盘,我们用字符“-”来填充,于是程序实现如下:

def new_board(level):
    # 棋盘(雷池)初始化
    board = []
    for x in range(10):
        board.append([]) # 把一个列的空列表作为元素添加到board中
        for y in range(level):
            board[x].append('-') # 一个列的每个元素初始化赋值为“-”
    return board

而后面当玩家选择“打开”对应坐标的“方块”时,就可以根据雷的数量来更新board列表里的值,也可以通过检查雷的坐标是不是和玩家的选择相同,来判断玩家是不是“踩雷了”。

5. 生成地雷

有了坐标系,我们就可以向雷池“埋雷”了。
在这里插入图片描述
“埋雷”的方法很简单,就是随机生成10或20个雷的坐标。同样地,要用到random模块。代码实现如下:

import random
def lay_mines(num_mines, level):
    # 埋雷,得到雷的坐标
    mines = []
    i = 0
    while i < num_mines:
        mine = [random.randint(0, 9), random.randint(0, level-1)]
        if mine not in mines: # 如果随机生成的坐标有重复,则跳过
            mines.append(mine)
            i += 1
    return mines

为了得到10或20个完全不同的坐标,必须在每一步使用 in 来判断随机生成的雷是不是和列表中的坐标相同,如果相同,则继续循环;不同,则加入mines的二维列表里。最后把地雷的坐标二维列表返回主程序。

6. 绘制雷池

现在,我们可以使用循环来绘制雷池了。如果前面展示的雷池形状你已经理解了,那这一步就很简单了。只不过,为了绘制字母坐标和分割线(为了更美观一些),问哥又把它分成了三个自定义函数。具体实现如下:

def draw_dot(level):
    # 画出装饰边框
    print('  ', end=' ')
    for i in range(level):
        print('-', end='-')
    print('-')

def draw_head(level):
    # 画出横坐标的英文字母
    print('   ', end=' ')
    for i in range(level):
        print(chr(i+65), end=' ')
    print()

def draw_board(board,level):
    # 画出棋盘(雷池)
    draw_head(level)
    draw_dot(level)
    for i in range(10):
        print(i,end=' | ')
        for j in range(level):
            print(board[i][j], end = ' ')
        print('| ' + str(i))
    draw_dot(level) # 调用上面绘制分割线的子程序
    draw_head(level) # 调用上面绘制字母坐标的子程序

问哥这里使用了我们讲过的ASCII编码来方便循环。我们知道大写字母“A”所对应的ASCII码是65,那我们就可以使用chr(65)来生成字母“A”这个字母,然后随着循环的累加,chr(66)生成“B”,chr(67)生成“C”等等,直到生成10个或20个大写字母(根据游戏难度的不同)。

7. 获取玩家的选择

因为坐标系稍微比较复杂,需要玩家输入、且只能输入两个字符,一个是数字,一个是字母,而字母从A到J(10列),或从A到T(20列)还不确定。还记得问哥之前说过,永远不要判断玩家会输入什么样的内容,我们只能通过程序来约束。所以,在这个游戏里,我们必须定义一个自定义函数,来判断玩家的输入是否合法有效,否则则回到循环去要求玩家重新输入。

而且,我们还要给玩家一定的自由度,比如当玩家输入3H、3h、h3、H3,无论数字和字母的顺序,字母大写还是小写,我们都认为是有效的。

于是,程序可以这样实现:

def is_valid_move(move:str, level:int):
    # 判断玩家输入格式是否正确
    warning = '输入有误,请输入正确格式,比如3H、3h、h3、H3:'
    if len(move)!=2: # 如果玩家输入了少于或多于2个字符,则直接返回False,输入无效
        print(warning)
        return False
    if move[0].isdigit(): # 如果第一个字符是数字
    	# 判断第二个字符是不是在大写字母A到大写字母J或T之间,如果不是则返回False
        if any([ord(move[1].upper())>64+level, ord(move[1].upper())<65]):
            print(warning)
            return False
        else:
            move = move.upper()
    else: # 如果第一个字符不是数字
    	# 判断第一个字符是不是在大写字母A到大写字母J或T之间,同时第二个字符在0到9之间
        if any([ord(move[1])>57, ord(move[1])<48, ord(move[0].upper())>(64+level), ord(move[0].upper())<65]):
            print(warning)
            return False
        else:
            move = move[::-1].upper()
    # 通过ord()函数将字符转回数字,再转换成我们需要的坐标数字
    row = ord(move[0])-48
    column = ord(move[1])-65
    if board[row][column] != '-': # 如果这个坐标的值不是初始值,那一定是“打开”过了
        print('你已经打开过这个方块了,换一个试试吧')
        return False
    # 将row和column的坐标值合并成元组返回主程序
    return (row, column)

8. 如何挖雷

现在我们终于可以来实现游戏的核心了——挖雷。

根据游戏的玩法。当玩家选中某个方格后,电脑需要依次检查该方格周围8个方格内是否包含地雷,然后将总数显示在方格内。

周围8个方格的坐标相对于玩家选中的方格来说,等于横纵坐标各自加减1,如下图所示。
在这里插入图片描述
于是,当我们得到玩家输入的坐标后,就可以依次向该坐标8个方向依次“移动”一位,再去检查移动后的新坐标在不在地雷坐标列表里,从而判断是否有雷。而“移动”的方法,就是依次在玩家选中的坐标上加减1。比如,如果玩家选择的方块坐标为(x, y),则检查左上方是否有雷,只需要检查坐标(x-1, y-1)是不是在地雷列表里。

这样的话,我们只需要预先定义一个表示8个方位的坐标差列表,然后通过循环依次向这8个方向移动,从而检查是否有雷就可以了。于是,在游戏中我们先定义一个坐标差列表常量,用CO表示。(CO是英文单词coordinate坐标的简写)

CO = [[0, 1], [1, 1], [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1]]

然后,将玩家选择的方块坐标依次向这8个方向“移动”,同时还要检查,有没有移出边界。我们的雷池只有10x10,或者10x20的大小,所以必须要检查移动后的位置有没有在雷池内。

if 0<=new_x<=9 and 0<=new_y<level:

最后,使用一个局部变量sum_mine计数,来计算玩家选择的方块周围有多少颗雷。代码实现如下:

def dig_mine(x, y, mines, level):
    sum_mine = 0
    for xdirection, ydirection in CO:
        new_x = x
        new_y = y
        new_x += xdirection
        new_y += ydirection
        if 0<=new_x<=9 and 0<=new_y<level: # 判断有没有超出雷池
            if [new_x, new_y] in mines: # 判断是不是地雷
                sum_mine += 1
    board[x][y] = sum_mine # 在玩家的方格里写上雷的数量

递归

写到这里,挖雷的基本效果已经达到了,但是还有个重要的点,如果8个方向都没有雷呢?当然问哥不想在方块里写上0,而是想让电脑继续帮玩家去“打开”那8个方向的方块,直到探到雷为止。

不知道大家记不记得windows版本的挖雷里,点到地雷为空的地方,然后空出一大片的那种“快感”。同样地,我们在文本版的扫雷里也可以实现这种“一点一大片”的效果。

接着上面的程序,如果8个方向都没有地雷,sum_mine 为0,但我们要做的,就是让程序以这8个方向为原点,继续去执行“挖雷”的程序。所以,我们可以使用递归,让程序调用自己dig_mine())。

同样地,我们还要判断新的坐标有没有超出雷池。

    if sum_mine == 0:
        for xdirection, ydirection in CO:
            new_x = x
            new_y = y
            new_x += xdirection
            new_y += ydirection
            if 0<=new_x<=9 and 0<=new_y<level:
                dig_mine(new_x, new_y, mines,level)

但是使用递归常常需要很小心,因为会存在死循环的情况。如下图所示,如果原点是0,当向左上方移动,递归调用 dig_mine() 时,新的坐标(-1, -1)会默认再次检查8个方向的方块,当然也包括原点。这样一来,新的坐标又会调用 dig_mine() 再次检查原点,如此形成了一个死循环。
在这里插入图片描述
我们必须加一个判断:已经打开过的方块就不需要再打开了。

    if sum_mine == 0:
        board[x][y] = ' ' # 打开的方块赋值为空格
        for xdirection, ydirection in CO:
            new_x = x
            new_y = y
            new_x += xdirection
            new_y += ydirection
            if 0<=new_x<=9 and 0<=new_y<level:
                if board[new_x][new_y] != ' ': # 检查方块是否已经打开
                    dig_mine(new_x, new_y, mines,level)

于是,我们把周围没有雷的方块赋值为空格,并且在程序里,检查8个方向的方块的值是不是空格。如果是,则表示这个地方已经检查过了。如此,递归就不至于无休止的查询下去,也就实现了一点一大片的效果:
在这里插入图片描述

踩雷

在挖雷过程中,肯定会发生玩家选择的坐标恰恰就是地雷的坐标的情况。这时,就定义为玩家“踩雷了”,游戏结束。如何给踩雷做个标记呢?

我们可以用字母x来表示地雷,如果玩家选择的坐标和地雷列表的某个“雷”的坐标相同,则把相应位置的board二维数组赋值为x,然后后面就可以通过检查board里是不是有x,来判断玩家是不是“踩雷”了,从而执行结束游戏的操作。

全部子程序实现代码如下:

def dig_mine(x, y, mines, level):
    # 扫雷,判断该坐标有没有雷
    if [x, y] in mines:
        board[x][y] = 'x' # 如果是雷,则将该二维数组元素赋值为x
        return
    sum_mine = 0
    for xdirection, ydirection in CO:
        new_x = x
        new_y = y
        new_x += xdirection
        new_y += ydirection
        if 0<=new_x<=9 and 0<=new_y<level:
            if [new_x, new_y] in mines:
                sum_mine += 1
    board[x][y] = sum_mine 
    if sum_mine == 0:
        board[x][y] = ' '
        for xdirection, ydirection in CO:
            new_x = x
            new_y = y
            new_x += xdirection
            new_y += ydirection
            if 0<=new_x<=9 and 0<=new_y<level:
                if board[new_x][new_y] != ' ':
                    dig_mine(new_x, new_y, mines,level)

9. 判定胜利或失败

借着上面挖雷的判断,遍历地雷列表里的坐标,如果发现雷池的二维列表board中有元素的值为“x”,则代表玩家“踩雷”了,打印出失败信息,并且通过循环把剩下的地雷坐标都赋值为“x”,来显示所有地雷的位置。最后返回主程序:

def is_lose(board, mines, level):
    # 判断是否踩到了雷
    for i, j in mines:
        if board[i][j] == 'x':
            for x, y in mines: 
                board[x][y] = 'x' # 把所有地雷都赋值为x
            draw_board(board, level) # 将所有地雷显示出来
            print('BOOM!你踩到雷了呜呜呜 T_T')
            return True
    return False

对玩家是否胜利的检查稍微复杂那么一点点。因为我们游戏的规则是:玩家把所有非地雷的方块都打开。这也就意味着,我们需要让电脑检查雷区里除了地雷坐标以外,所有坐标的值,如果为初始值“-”,就说明玩家还没有打开所有方块,游戏并没有结束。反之,如果除了地雷坐标,其他所有的地方都不再是“-”(可能是空格和数字),而玩家又没有“踩雷”(先检查),则表示玩家胜利了。除了打印出胜利的信息,我们还让电脑把所有地雷都绘制成“O”,表示地雷已成功排除。
在这里插入图片描述
同样,这里我们还要再次调用time.time()方法,来记录当前时间。再减去游戏开始时记录的时间,就可以显示出我们用了多少秒。(记得用round()四舍五入取整)

实现代码如下:

def is_win(board, mines, level, start_time):
    # 判断雷池里还有没有雷
    for i in range(10):
        for j in range(level):
            if [i, j] not in mines and board[i][j] == '-':
                return False
    for i, j in mines:
        board[i][j] = 'O' # 将所有地雷都绘制成O
    draw_board(board, level)
    # 计算耗时多少
    duration = round(time.time() - start_time)
    print(f'哇!你赢了,真厉害,用时{duration}秒')
    return True

完整代码

import random
import time

def draw_dot(level):
    # 画出装饰边框
    print('  ', end=' ')
    for i in range(level):
        print('-', end='-')
    print('-')

def draw_head(level):
    # 画出横坐标的英文字母
    print('   ', end=' ')
    for i in range(level):
        print(chr(i+65), end=' ')
    print()

def draw_board(board,level):
    # 画出棋盘(雷池)
    draw_head(level)
    draw_dot(level)
    for i in range(10):
        print(i,end=' | ')
        for j in range(level):
            print(board[i][j], end = ' ')
        print('| ' + str(i))
    draw_dot(level)
    draw_head(level)

def new_board(level):
    # 棋盘(雷池)初始化
    board = []
    for x in range(10):
        board.append([])
        for y in range(level):
            board[x].append('-')
    return board

def lay_mines(num_mines, level):
    # 埋雷,得到雷的坐标
    mines = []
    i = 0
    while i < num_mines:
        mine = [random.randint(0, 9), random.randint(0, level-1)]
        if mine not in mines:
            mines.append(mine)
            i += 1
    return mines

def dig_mine(x, y, mines, level):
    # 扫雷,判断该坐标有没有雷
    if [x, y] in mines:
        board[x][y] = 'x'
        return
    sum_mine = 0
    for xdirection, ydirection in CO:
        new_x = x
        new_y = y
        new_x += xdirection
        new_y += ydirection
        if 0<=new_x<=9 and 0<=new_y<level:
            if [new_x, new_y] in mines:
                sum_mine += 1
    board[x][y] = sum_mine 
    if sum_mine == 0:
        board[x][y] = ' '
        for xdirection, ydirection in CO:
            new_x = x
            new_y = y
            new_x += xdirection
            new_y += ydirection
            if 0<=new_x<=9 and 0<=new_y<level:
                if board[new_x][new_y] != ' ':
                    dig_mine(new_x, new_y, mines,level)
    

def is_valid_move(move:str, level:int):
    # 判断用户输入格式是否正确
    warning = '输入有误,请输入正确格式,比如3H、3h、h3、H3:'
    if len(move)!=2:
        print(warning)
        return False
    if move[0].isdigit():
        if any([ord(move[1].upper())>64+level, ord(move[1].upper())<65]):
            print(warning)
            return False
        else:
            move = move.upper()
    else:
        if any([ord(move[1])>57, ord(move[1])<48, ord(move[0].upper())>(64+level), ord(move[0].upper())<65]):
            print(warning)
            return False
        else:
            move = move[::-1].upper()
    row = ord(move[0])-48
    column = ord(move[1])-65
    if board[row][column] != '-':
        print('你已经打开过这个方块了,换一个试试吧')
        return False
    return (row, column)


def is_win(board, mines, level, start_time):
    # 判断雷池里还有没有雷
    for i in range(10):
        for j in range(level):
            if [i, j] not in mines and board[i][j] == '-':
                return False
    for i, j in mines:
        board[i][j] = 'O'
    draw_board(board, level)
    # 计算耗时多少
    duration = round(time.time() - start_time)
    print(f'哇!你赢了,真厉害,用时{duration}秒')
    return True

def is_lose(board, mines, level):
    # 判断是否踩到了雷
    for i, j in mines:
        if board[i][j] == 'x':
            for x, y in mines:
                board[x][y] = 'x'
            draw_board(board, level)
            print('BOOM!你踩到雷了呜呜呜 T_T')
            return True
    return False


# 游戏从这里开始
CO = [[0, 1], [1, 1], [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1]]

while True:
    print('+----------------------------------+')
    print('|        欢迎来玩扫雷小游戏        |')
    print('+----------------------------------+')
    player_choose = input('请选择游戏难度(1-简单,2-困难):')
    if player_choose!='1' and player_choose!='2':
        break
    level = 10 * int(player_choose)
    num_mines = 10 * int(player_choose)
    board = new_board(level)
    mines = lay_mines(num_mines, level)
    draw_board(board,level)
    start_time = time.time()
    while True:
        move = is_valid_move(input('请选择位置:'), level)
        if not move:
            continue
        row = move[0]
        column = move[1]
        dig_mine(row, column, mines, level)
        if is_lose(board, mines, level) or is_win(board, mines, level, start_time):
            break
        draw_board(board,level)

    if not input('继续玩吗?(y-继续 | n-退出):').lower().startswith('y'):
        break

print('游戏结束,欢迎下次再来玩^o^')

总结与思考

由此可见,用Python实现扫雷小游戏并不难,只是无法使用鼠标,让游戏性降低了不少。不过因为游戏的规则是完全一样的,等以后学了图形化,直接就可以将扫雷的程序流程和算法复制过去。所以我们现在不用着急,先把思路理清,以后上手才更快。

不知不觉问哥又肝了一万字。。。T_T

感谢大家读到这里,我们下次再见啦!^_^


Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐