Python小游戏之连连看
基本介绍
最近学了些Python的语法,打算使用Python开发一款中老年最爱玩的经典小游戏——连连看。
本游戏的使用规则和正常的连连看没有区别:点击开始新游戏后出现游戏界面,选中的两个图像可以消除出现成功提示音,反之则出现失败提示音。所有图像全部消除完成后游戏胜利,未在规定时间内消除所有头像则游戏失败。
开发思路
开发使用的基本语言是Python,使用到的模块包括Tkinter、Pygame、Numpy、Pillow等。
Tkinter是Python 进行窗口视窗设计的模块,在该游戏中将作为为窗口视图干活的老大哥。
Pygame是SDL多媒体库的Python包装模块,在该游戏中将打理音频文件。
Pillow是图像处理标准库,在该游戏中打理图像。
Numpy是 Python 语言的一个扩展程序库,支持大量的维度数组与矩阵运算,在该游戏中处理数据。
开发的核心思路如下:
初始化地图
首先,要做10行*10列的连连看,我一共准备了25个小头像,即每个小头像应该出现的次数是100/25=4次。那么就可以确定内外层循环的阈值,外层循环应该是25,内层循环应该为4,内外层循环后应该生成了100个随机数。
这100个随机数在一维数组中,地图中用行和列的坐标共同确定一个图像的具体位置,所以将一维数组转换成二维数组。
这里将一维数组转换成二维数组使用的是numpy中的reshape方法:
numpy.array(records).reshape((a, b))
依次生成n个自然数,并且以a行b列的数组形式显示。
def initMap(self):
# 25个小头像放在records数组中 ,数组下标为0,1,2...24
records = []
# 范围左开右闭
for i in range(0, int(self._iconCount)):
for j in range(0, 4):
# 此时得到的数组应该是 0-24 每个数字出现4次且顺序出现。
# [0,0,0,0,1,1,1,1....24,24,24,24]
records.append(i)
# 所有元素随机排序
# 此时是将上面的数组位置随机 [18,16,24,3,8,8....]
random.shuffle(records)
# 将一维数组转换成二维数组
self._map = numpy.array(records).reshape((10, 10))
最终生成的随机二维数组如下:
提取小Icon
imgaes文件夹中共有25张图片,命名分别为s0.jpg,s1.jpg... s24.jpg,这里_icons数组中记录了所有0-24索引对应的图片地址。
def extractSmallIconList(self):
for index in range(0, int(self._iconCount)):
filePath = 'images/s' + str(index) + '.jpg'
self._icons.append(ImageTk.PhotoImage(file=filePath))
创建背景画布
通过 Canvas 控件创建一个简单的图形编辑器,让用户可以达到自定义图形的目的,就像使用画笔在画布上绘画一样,可以绘制各式各样的形状。
self.canvas = tk.Canvas(self.window, bg='white', width=self._gameWidth, height=self._gameHeight)
给背景画布绑定事件
self.canvas.bind('<Button-1>', self.clickCanvas)
绘制小头像
绘制小头像使用Tkinter中的canvas.create_image函数,在绘制图片时需要知道图片绘制的坐标。所以通过调用getOriginCoordinate
函数获取坐标点。正常来说,设置图片的宽度和高度都为70,绘制图片的坐标应该是(0,0),(0,70),(0,140)...(0,630),但是这里设置了边距为25,所以小头像的坐标往右平移25即坐标变为(25,25),(25,95),(25,165)...(25,655),(95,25),(95,95),(95,165)...(95,655)。
def drawMap(self):
for row in range(0, self._gameSize):
for column in range(0, self._gameSize):
x, y = self.getOriginCoordinate(row, column)
# 绘制小头像的坐标(25,25),(25,95),(25,165)...(25,655),(95,25),(95,95),(95,165)...(95,655)
# 找到绘制头像的坐标后绘制图片,anchor = 'nw' 表示将图片的左上角作为锚定点, tags标识该图片,在删除时使用
self.canvas.create_image((x, y), image=self._icons[self._map[row, column]], anchor='nw',
tags='image%d%d' % (row, column))
获取图片坐标函数:
# 获取row的x轴起始坐标
def getX(self, row):
return self._margin + row * self._iconWidth
# 获取column的y轴起始坐标
def getY(self, column):
# 边框距离 + 列 * 图像高度
return self._margin + column * self._iconHeight
# 获取row,column点位的左上角原点坐标 获取坐标x,y
def getOriginCoordinate(self, row, column):
return self.getX(row), self.getY(column)
画布触发事件
点击画布可以获得点击的坐标,根据坐标可以计算出图像点位,即点击的位置属于第几行、第几列的小头像。点击某个小头像时,使用canvas的create_rectangle
函数可以将该头像设置边框。
第一次点击的头像需要进行记录,当第二次点击的头像和第一次点击的头像是相同的小头像并且满足可消除条件时,将从canvas中删除这两个小头像(每个小头像都对应一个图片tags,根据图片tags可以删除该图像),同时还需要将这两个小头像对应的地图上的点位清空(赋值为EMPTY)。
def clickCanvas(self, event):
if self._isGameStart:
point = self.getGamePoint(event.x, event.y)
if self._isFirst:
self.playMusic()
self._isFirst = False
self.drawSelectedArea(point)
# 记录第一次点击的点位
self._oldPoint = point
else:
# 两次点击的点位相同
if point.isEqual(self._oldPoint):
self.canvas.delete('rectSelectedOne')
self._isFirst = True
else:
# 两次点击的点位不同
typeData = self.getLinkType(self._oldPoint, point)
if typeData['type'] != self.NONE_LINK:
self.clearLinkedBlocks(self._oldPoint, point)
self.canvas.delete('rectSelectedOne')
self._isFirst = True
# else:
self.playMusic('audios/error.mp3')
def clearLinkedBlocks(self, p1, p2):
self.canvas.delete('image%d%d' % (p1.row, p1.column))
self.canvas.delete('image%d%d' % (p2.row, p2.column))
# 删除小头像后 将地图上的点位清空
self._map[p1.row][p1.column] = self.EMPTY
self._map[p2.row][p2.column] = self.EMPTY
# 消除音效
self.playMusic('audios/success.mp3')
根据点击的x坐标和y坐标获得点位位置:
def getGamePoint(self, x, y):
point_row = 0
point_col = 0
for row in range(0, self._gameSize):
# x1和x2是获取行边界坐标
x1 = self.getX(row)
x2 = self.getX(row + 1)
if x1 < x < x2:
point_row = row
for col in range(0, self._gameSize):
y1 = self.getY(col)
y2 = self.getY(col + 1)
if y1 < y < y2:
point_col = col
return Point(point_row, point_col)
点位对象:
# 游戏中的点位
class Point:
def __init__(self, row, column):
self.row = row
self.column = column
def isEqual(self, point):
if self.row == point.row and self.column == point.column:
return True
else:
return False
头像相连消除逻辑
相连规则
两个小头像是否可消除取决于小头像是否可正常相连,那么小头像相连又有几种规则。
规则如下:
- 直线相连。
- 一个角相连。
- 两个角相连。
- 多个角相连。
最笨的方法就是去挨个实现上面四种情况:直线相连、一个角相连、两个角相连、多个角相连。
直线相连
直线相连的实现方式很简单,直接获取到起始点和结束点的坐标,如果两个点位之间有不为空的点位则两个点位不能被消除,反之则可以被消除。
这里需要注意的是需要考虑到垂直方向和水平方向且需要考虑到起点和终点的坐标大小。
直连判断函数:
def isStraightLink(self, p1, p2):
# 水平方向判断
success = True
if p1.row == p2.row:
# 判断哪个点在左,哪个点在右
if p1.column > p2.column:
start = p2.column
end = p1.column
else:
start = p1.column
end = p2.column
for column in range(start + 1, end):
# 如果两个直连点之间的点位有不为空的,则两个点不能被消除
if self._map[p1.row][column] != self.EMPTY:
# return False
success = False
# 垂直方向判断
if p1.column == p2.column:
# 判断哪个点在左,哪个点在右
if p1.row > p2.row:
start = p2.row
end = p1.row
else:
start = p1.row
end = p2.row
for row in range(start + 1, end):
# 如果两个直连点之间的点位有不为空的,则两个点不能被消除
if self._map[row][p1.column] != self.EMPTY:
success = False
return success
一个角相连
两个点位P1和P2有一个角相连,则要取一个中间点P3,首先P3这个点一定是已经消除头像的点位,其次要判断P1是否与P3直线相连并且P2和P3直线相连。
一个角相连实现函数:
def isOneCornerLink(self, p1, p2):
pointCorner = Point(p1.row, p2.column)
if self.isEmptyInMap(pointCorner) and self.isStraightLink(p1, pointCorner) and self.isStraightLink(p2,
pointCorner):
return pointCorner
pointCorner = Point(p2.row, p1.column)
if self.isEmptyInMap(pointCorner) and self.isStraightLink(p1, pointCorner) and self.isStraightLink(p2,
pointCorner):
return pointCorner
return False
A *算法
拆分规则的方式特别麻烦,要针对四种规则进行分别判断,分别实现。那么有没有更简单的方式去判断两个点位是否可以相连呢?这个时候A *算法老大哥就举手了,用我啊,我多简单啊。
A*算法通过下面这个函数来计算每个节点的优先级。
其中:
- f(n)是节点n的综合优先级。当我们选择下一个要遍历的节点时,我们总会选取综合优先级最高(值最小)的节点。
- g(n) 是节点n距离起点的代价。
- h(n)是节点n距离终点的预计代价,这也就是A*算法的启发函数。关于启发函数我们在下面详细讲解。
A * 算法在运算过程中,每次从优先队列中选取f(n)值最小(优先级最高)的节点作为下一个待遍历的节点,直到找到终点,或者遍历了所有可行走节点后还是没找到终点(此时就无连通路径)。
A * 算法关注点在于探索出一条起始点A到终止点B的路径,如果可以找到A、B两点可正常通行的路径,并且这两点是相同的头像,那么这两点可以消除。反之,则不能消除。
如图所示,绿色方块A为起点,绿色方块B为终点,红色方块为障碍物。
那么寻找起点A与起点B之间连通路径的流程大概是这样:
- 以A节点为父节点,寻找周围上、右、下、左四个相邻节点,计算每个相邻节点的代价。
节点a.1代价:1+6
节点a.3代价:1+4
节点a.4代价:1+6
节点1代价:1+4
- 节点a.3代价和节点1代价相同,按照顺时针顺序选择节点1作为下一个代价最低节点,找到它周围的四个相邻节点,计算每个节点的代价。(此处因为节点1右侧为障碍、左侧为已经遍历过的节点所以排除掉)
节点1.1代价:2+5
节点2代价:2+3
- 节点2代价要低于节点1.1代价,所以当前代价最低节点为节点2,然后找到节点2周围的四个相邻节点,计算每个节点的代价。
节点3.3代价:4+5
节点3.4代价:4+5
节点3代价:3+4
- 同上,节点3为当前代价最低节点,计算周围节点代价
节点4.3代价:5+4
节点4代价:4+3
- 同上,节点4为当前代价最低节点,计算周围节点代价
节点5代价:5+2
- 节点5为最低节点代价,周围节点6可到达终点B
最终找到的连通路径为1-》2-》3-》4-》5-》6-》B,A和B可相连。
那么写代码时梳理一下整个流程大致如下:
- 从起点 A 开始,并把它就加入到一个由方格组成的 open list( 开放列表 ) 中。这个 open list 有点像是一个购物单。现在 open list 里只有一项,它就是起点 A ,后面会慢慢加入更多的项。 open list 里的格子是路径可能会是沿途经过的,也有可能不经过。
- 查看与起点 A 相邻的方格 ( 忽略障碍 ) ,把其中可走的方格加入到 open list 中。把起点 A 设置为这些方格的父亲。
- 把 A 从 open list 中移除,加入到 close list 中, close list 中的每个方格都是现在不需要再关注的。
- 从open list中寻找代价最低的节点(此处简化为起点到当前的节点距离+当前节点到终点的估算距离),找到该代价最低的节点后将该节点从open list中移除,并加入到close list中。
- 检查代价最低节点相邻节点是否可行。如果相邻节点是已经检查过的节点或者是障碍点或者是已经越界的点都不是可行的点,要找到真正可行的节点,如果找到的节点不在open list中,则将其加入到open list中,并且设置这些可行节点的父节点为上一步找到的代价最低节点。
- 重复4,5两步直到结束。结束的条件是我们将终点加入到close list中,或者open list为空(此时表示未找到连通路径)
# A*算法
class AStar:
def __init__(self, map_data, start_node, end_node, pass_tag):
"""
初始化函数
:param map_data: 地图
:param start_node: 起始节点
:param end_node: 终止节点
:param pass_tag: 可行走标记
"""
# 探索节点列表
self.openList = []
# 已探索节点列表
self.closeList = []
# 地图
self.map = map_data
# 开始节点
self.startNode = start_node
# 终止节点
self.endNode = end_node
# 可行走标记
self.passTag = pass_tag
# 查找代价最低节点
def findMinFNode(self):
"""
查找代价最低节点
:return: Node
"""
oneNode = self.openList[0]
for node in self.openList:
if node.g + node.h < oneNode.g + oneNode.h:
oneNode = node
return oneNode
# 节点是否在closeList中
def nodeInCloseList(self, near_node):
for node in self.closeList:
if node.point.isEqual(near_node.point):
return node
return False
# 节点是否在openList中
def nodeInOpenList(self, near_node):
for node in self.openList:
if node.point.isEqual(near_node.point):
return node
return False
# 查找邻居节点
def searchNearNode(self, min_f_node, offset_x, offset_y):
"""
:param min_f_node: 最小代价节点
:param offset_x: x轴偏移量
:param offset_y: y轴偏移量
:return: Node或None
"""
# 越界检查
if nearNode.point.row < 0 or nearNode.point.column < 0 or nearNode.point.row > len(
self.map) - 1 or nearNode.point.column > len(self.map[0]) - 1:
return
# 障碍检查
if self.map[nearNode.point.row][nearNode.point.column] != self.passTag and not nearNode.point.isEqual(
self.endNode.point):
return
# 判断是否在close list中
if self.nodeInCloseList(nearNode):
return
# 如果找到的可行节点不在openList中
if not self.nodeInOpenList(nearNode):
self.openList.append(nearNode)
nearNode.father = min_f_node
# 计算g值
# 根据当前可行节点min_f_node一直向上找到起始节点就是g值
step = 1
node = nearNode.father
while not node.point.isEqual(self.startNode.point):
step += 1
node = node.father
nearNode.g = step
return nearNode
def start(self):
# 判断是否为空节点
if self.map[self.endNode.point.row][self.endNode.point.column] == self.passTag:
return
# 1.将起点加入openlist
self.openList.append(self.startNode)
while (True):
# 2.从openlist中查找代价最低的节点
minFNode = self.findMinFNode()
# 3.将代价最低的节点从openlist中移除,并加入closelist
self.openList.remove(minFNode)
self.closeList.append(minFNode)
# 4.查找四个邻居节点(此处为顺时针方向:上、右、下、左)
# 向上查找
self.searchNearNode(minFNode, 0, -1)
# 向右查找
self.searchNearNode(minFNode, 1, 0)
# 向下查找
self.searchNearNode(minFNode, 0, 1)
# 向左查找
self.searchNearNode(minFNode, -1, 0)
# 5.判断是否终止
endNode = self.nodeInCloseList(self.endNode)
if endNode:
path = []
node = endNode
while not node.point.isEqual(self.startNode.point):
path.append(node)
if node.father:
node = node.father
# 找到的path是从终点到起点的,所以做一个排序
path.reverse()
return path
if len(self.openList) == 0:
return None
总结
总结一下整体思路:在10 * 10 的地图上生成二维数组,行和列可以确定坐标,根据坐标可以在画布(Canvas)上绘制小头像,每个小头像有一个头像唯一标识(使用行坐标和列坐标作为标识信息)。点击画布触发点击事件,根据点击的区域可以确定点位(即确定行和列)。记录下第一次点击的点位和第二次点击的点位,通过A* 算法找两个点位之间是否有连通路径,即确定两个头像是否可消除。若头像可消除则删除画布上对应的头像,并将二维数组对应的两个点位置为EMPTY。在规定时间内,所有头像均被消除则游戏胜利,反之游戏失败。
附:游戏界面
转载自:https://juejin.cn/post/7195435275886002235