前言
前一段时间扫雷游戏挺火的,可惜问哥没有赶上热度。使用图形化开发不难,但是要想解释清楚还是要花不少时间。问哥还是想循序渐进地从零基础开始和大家一点点进步,这也是问哥写下这个系列的初衷。但是即使在控制台界面,我们依然可以用文本搭建一个扫雷的小游戏。下面跟着我来一起试试看吧。
知识点
- time.time()计时函数
- 笛卡尔坐标系
- 递归函数
第7个游戏:扫雷(文字版)
1. 玩法简介
扫雷的规则大家应该都知道吧。在我们这个文字版的扫雷小游戏中,稍微有一些操作的不同。比如无法用鼠标,只能通过给定坐标的位置去打开方格。方格被打开后显示的数字表示其周围的8个方格隐藏了几颗雷。如果周围8个方格都没有地雷,则递归地向8个方向自动依次打开,直到有方格有地雷为止。
如此重复上述操作,直到玩家“踩了雷”或者打开了所有非地雷的方块,游戏将判定失败或胜利。如果胜利的话还将显示玩家用了多少秒。然后询问是否再开一局,或者游戏结束。
游戏截图
2. 游戏流程
按照问哥的惯例,我们还是先画出游戏的基本流程,这样在后面用程序实现的时候,就能够更好地了解我们的进度,以及有没有遗漏的地方。
关于扫雷小游戏,问哥画出的流程图是这样的:
3. 搭建框架
和往常一样,有了流程图,我们先把游戏的主体框架搭出来,然后再把各种功能用自定义函数(子程序)去完成。
while True:
print('+----------------------------------+')
print('| 欢迎来玩扫雷小游戏 |')
print('+----------------------------------+')
难度选择()
初始化雷池()
埋雷()
绘制雷池()
开始计时()
while True:
玩家选择方格位置()
if 玩家输入不正确:
continue
挖雷()
if 玩家踩雷了() or 玩家赢了():
break
绘制雷池()
if not 继续玩吗():
break
可以看出,游戏有两个嵌套while循环完成。外层循环用来判断玩家是否在结束一局游戏后还要再来一局;内层循环是游戏的主体,不断地通过接收玩家输入方格的坐标来判断是不是雷。如果玩家准确无误地打开了所有非地雷的方块,游戏胜利。反之,只要玩家输入的坐标和地雷的坐标相同,则判定玩家“踩到雷”了,游戏失败。
而在这样的框架里,很多事情我们交给子程序去完成,比如判断胜利或失败,问哥放在了同一个语句里。因为不管是胜利还是失败,都要跳出内循环,所以胜利或失败后的分支任务,比如打印信息,就交给子程序去办好了。
关于难度的选择,问哥是这样定义的:数字1代表简单,2代表困难。如果是简单模式,则在屏幕上绘制10x10的雷池,只有10颗地雷。而如果是困难模式,则绘制10x20的雷池,埋20颗地雷。所以只需要用玩家选择的数字乘以10就可以自动得出雷的数量和雷池的大小。为了把雷池大小和地雷数量分开,这里定义了两个变量level和num_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
感谢大家读到这里,我们下次再见啦!^_^