麻将移动动画
根据游戏逻辑,麻将被选中后,是可以再点击桌面上的空位,进行移动的。要实现麻将的移动,需要有以下几点功能需要实现:
点击空格产生移动
为了实现上面第一点功能,我们可以为桌子上的“空格”构造一个 Sprite 子类对象,这里设计叫 Point 类。你可以理解为桌子上铺了一个桌布,这个桌布是一堆圆点构成的,每个格子的桌布就是一个 Point 对象。
class Point(pygame.sprite.Sprite):
'''桌布上的空格'''
def __init__(self, table: Table):
pygame.sprite.Sprite.__init__(self)
self.image = pygame.image.load("point.png")
self.rect = self.image.get_rect()
self.table = table
self.pos = []
def update(self):
if self.table == None or self.table.director == None:
return
# 判断鼠标点击事件
for event in self.table.director.events:
if event.type != pygame.MOUSEBUTTONDOWN:
continue
# 检测精灵是否被点击
mouse_pos = pygame.mouse.get_pos()
if self.rect.collidepoint(mouse_pos) == False:
return
if self.table.heap[self.pos[0]][self.pos[1]] != None:
return
if self.table.is_show_edge == False:
return
# 获取牌堆代码
selected_mahjong = self.table.heap[self.table.edge.pos[0]][self.table.edge.pos[1]]
deck = selected_mahjong.search_deck(self.pos[0],self.pos[1])
if deck != None:
if self.move_deck_check(deck) == True:
self.move_deck(deck)
else:
self.table.show_text('必须要有能消除的牌才能移动')
else:
self.table.show_text('不能选择不同行、列的空点移动')
self.table.hide_edge()
通过在 Point 对象的 update() 中,编写鼠标点击事件判断,就可以发起“移动”功能。这个与上一篇介绍“选中麻将”的做法是一样的。每个 Point 对象,在每一帧,都会检测一次,自己是否被鼠标点击。
在其他的一些游戏引擎中,往往会在更底层的框架里,去实现鼠标点击或者其他“碰撞检测”的功能。用法一般是:在那些“可以”被玩家点击的对象身上,添加一个“可点击”的标记,然后在游戏中,一旦这种“可点击”的对象被创建出来,就会被底层代码放入一个“点击检测”的列表,由底层引擎每帧去检测它们是否有被点击到。如果有点击到,就会发起一次对这些对象的某个预设方法的调用。
实现移动动画
麻将的动画,实际上是通过每帧重绘“移动中”的麻将的图像来实现的。也就是说,每个麻将,现在都需要有一个“移动中”的状态,而不仅仅是直接根据 Table.heap 的坐标,直接显示在屏幕上了。因此我们需要设计一个管理和维持这个状态的属性:is_moving,当需要移动麻将的时候,调用 Table.move() 方法,为 is_moving 赋予 True 这个值,表示开始显示移动动画。然后,在 Table.update() 中,对于 Table.heap 中的所有 Mahjong 对象,都会调用 show() 方法。只需要在 Mahjong.show() 中不断的修改 self.rect 的 x,y 属性值,直到这两个值等于移动目的地应该显示的值,就停止修改,把 is_moving 改回 False 值。
每个麻将,在桌上的位置坐标(2个元素的一个数组[x,y]),除了在 Table.heap 中记录,我们也可以给 Mahjong 增加一个 pos 属性用以记录。我们可以通过 pos 数值中的坐标,计算出麻将牌最后应该显示的“目的地”位置;然后我们通过在 show() 方法中,不断修改 Mahjong.rect.left/.top 的值去逼近这个目的位置,就可以实现动画了。因为根据之前的设计,所有在 Table.heap 里面的 Mahjong 对象,都会被显示,我们只需要增加一个判断:如果 Mahjong.is_move 为 True 的,就不去根据 heap 中的坐标去调整 Mahjong.rect 的值,而是按原来那个对象的值去显示就好了。
具体实现,首先增加一个 Table.move() 方法,具体功能就是设置麻将的新位置,关键是对 Mahjong.pos 进行赋值。这个 pos 属性就是后续显示移动动画,关键的“目的坐标”的计算依据。
def move(self, src: list[int], dst: list[int]):
theMajiang = self.heap[src[0]][src[1]]
if theMajiang == None:
return
self.heap[src[0]][src[1]] = None
self.heap[dst[0]][dst[1]] = theMajiang
theMajiang.pos = dst
theMajiang.is_moving = True
pass
上述方法的 src 和 dst 参数,代表了把桌上 src 坐标的麻将,移动到 dst 坐标去,这两个参数都是“两个元素的列表”类型,如 [3,2]
然后我们根据 Mahjong.pos 和 Mahjong.is_moving,在 Mahjong.show() 中添加不断修改 Mahjong.rect.left/top 的代码,从而实现移动。
def show(self):
'''显示麻将牌'''
if self.is_moving == False:
self.rect.left = self.rect.width*self.pos[0]
self.rect.top = self.rect.height*self.pos[1]
return
# 显示移动动画
dst_left = self.pos[0]*self.rect.width
dst_top = self.pos[1]*self.rect.height
move_left = get_direct(self.rect.left, dst_left)*Mahjong.moving_speed
move_top = get_direct(self.rect.top, dst_top)*Mahjong.moving_speed
# 由于 moving_speed 可能大于 1,因此如果最后一程小于 moving_speed,就需要减少移动距离了
if abs(dst_left - self.rect.left) < abs(move_left):
move_left = dst_left - self.rect.left
if abs(dst_top - self.rect.top) < abs(move_top):
move_top = dst_top - self.rect.top
self.rect.left += move_left
self.rect.top += move_top
# 到达目的地之后就停止移动
if self.rect.left == dst_left and self.rect.top == dst_top:
self.is_moving = False
上面的代码,先计算出移动目的地的显示坐标:dst_left/dst_top,这两个变量会用来判断,是否应该停止移动。然后计算 move_left/move_top 这两个变量,用来决定本帧(当前)此麻将对象应该显示在什么位置。注意计算的时候,由于移动速度 moving_speed 未必和麻将的宽度、高度是因数关系,所以可能出现:移动之后的位置 move_left/move_top 越过了 dst_left/dst_top 的情况,所以增加了上图 14-17 行的代码,确保不会出现这种情况。最后通过 += 操作,修改 rect 的 left/top 就可以了。
选择整队麻将
上面的代码,只是实现了单个麻将的移动。但是本游戏的逻辑,是需要实现整队麻将的移动。因此我们需要有办法,通过先点击一个麻将,然后点击一个空位,来实现选中一队麻将。之后再对这对麻将里面的每个对象,进行移动操作即可。
由于此过程必须先选中一个麻将,所以对于“选择整队麻将”的功能,适合放到 Mahjong 类中,所以我们定义了 Mahjong.search_deck() 方法:
def search_deck(self, logic_x:int, logic_y:int):
'''搜索以本对象为起点, logic_x/logic_y 为终点的牌堆'''
# 搜索方向必须是水平或者垂直
if self.pos[0] != logic_x and self.pos[1] != logic_y:
return None
di = 0 # 垂直搜索方向,+1 是往下,-1 是往上
dj = 0 # 水平搜索方向,+1 是往右,-1 是往左
if logic_y != self.pos[1]:
di = int((logic_y - self.pos[1])/abs(logic_y - self.pos[1]))
if logic_x != self.pos[0]:
dj = int((logic_x - self.pos[0])/abs(logic_x - self.pos[0]))
deck = [] # 返回结果列表
if dj != 0:
for direction_x in range (self.pos[0], logic_x, dj):
if self.table.heap[direction_x][logic_y] == None:
break
deck.append(self.table.heap[direction_x][logic_y])
if di != 0:
for direction_y in range (self.pos[1], logic_y, di):
if self.table.heap[logic_x][direction_y] == None:
break
deck.append(self.table.heap[logic_x][direction_y])
return deck
此方法从被选中的麻将开始,按照点击的空白点的方向,依次从 Table.heap 中取出麻将,最后放在 deck 变量中返回。这里由于有水平、垂直两个方向,所以有两端类似的代码,是可以想办法重构成只有一段的。
这里需要注意的是,此 search_deck() 返回 None 表示选择的方法不合法,需要调用处代码进行判断处理。如果有更复杂的“不合法”操作的处理方案,就不应该仅仅通过返回 None 来表达了,就可能需要专门做一个包含错误的返回值了。在复杂的游戏开发中,我们可能使用异常、错误码返回值等手段来实现各种“错误”的传递和处理。这里由于是入门项目,所以没有做的更复杂。
模拟移动后检查是否可消除
由于游戏的设计,并不允许随意移动,而是要求移动的一堆麻将中,必须要有可以消除的,才能移动。所以我们不能在移动麻将之后,再一个个判断“是否有可以消除”,而是应该在移动之前,就遍历移动的整队麻将,挨个检查到达目的地之后,是否可以消除。
如图,“二条”和“八条”这一队,就可以往上移动,直到“八条”碰到上面的“八万”。因为移动到这个位置之后,移动了的“二条”可以和右侧的“二条”可以消除。
计算移动后的位置
要实现上述的功能,我们需要分几步来实现这个功能:
在 Point 类上添加 move_deck_check() 方法,用这个方法进行上面的判断。
def move_deck_check(self, deck):
'''检查是否可以移动牌堆'''
head = deck[len(deck)-1]
# 判断选择的牌(堆头部)是否可以移动
if self.table.can_do(head.pos, self.pos) == False:
self.table.show_text('只能通过横线或者竖线移动且中间不能有阻隔!')
return False
head_x = head.pos[0]
head_y = head.pos[1]
# 移动后的位置
dst_x = None
dst_y = None
result = False
is_hr = (head_x == self.pos[0]) # 水平移动还是垂直移动
direct = 0 # 1上,2下,3左,4右
for i in range(len(deck)):
moving_card = deck[len(deck)-1-i] # 当前要移动的牌。从牌堆尾部开始移动
# 获得将要检查的牌的位置
if is_hr:
if head_y < self.pos[1]:
dst_x = self.pos[0]
dst_y = self.pos[1]-i
direct = 2
elif head_y > self.pos[1]:
dst_x = self.pos[0]
dst_y = self.pos[1]+i
direct = 1
else:
if head_x < self.pos[0]:
dst_x = self.pos[0]-i
dst_y = self.pos[1]
direct = 4
elif head_x > self.pos[0]:
dst_x = self.pos[0]+i
dst_y = self.pos[1]
direct = 3
# 预计牌所在位置可否消除
if self.table.can_erase(dst_x, dst_y, moving_card.symbol, direct) == True:
result = True
break
return result
上面这段代码,重点是对于 dst_x 和 dst_y 的计算。deck 参数存放了所有需要移动的麻将牌,而 self 这个 Point 对象,就是 deck 里面的多个麻将要移动到的目的地。根据 deck 里面的第一个麻将的 pos 属性,以及目的空位 pos 属性的值,就可以计算出:
有了上面的两个方向,剩下的就是根据 deck 里面的顺序,从第一个麻将牌开始,依次从目的地位置,倒排过去即可。
上图就是以往左移动为例,说明了 dst_x 的计算过程。
判断是否可以消除
一旦获得了 dst_x/dst_y 作为移动后的位置,以及将要移动的麻将对象的图案,以及移动的方向,我们就可以编写一个函数,用以检查,是否这张麻将牌在新的位置上,有可以与之消除的其他麻将。我们在 Table 类上添加 can_erase() 方法,用来完成这个功能:
def can_erase(self, dst_x, dst_y, symbol:list[int], direct):
'''返回以 dst_x,dst_y 为中心的四个方向上,是否有可消除的目标为 symbol 相同花色的牌; direct 为上下左右1234'''
# 判断查找路线中间有没有牌,如果有则取出;没有情况:碰到边界
if direct == 1 or direct == 2:
for x in range(dst_x-1, -1, -1):
current = self.heap[x][dst_y]
if current == None:
continue
if current.symbol == symbol:
return True
break
for x in range(dst_x+1, self.cols, 1):
current = self.heap[x][dst_y]
if current == None:
continue
if current.symbol == symbol:
return True
break
if direct == 3 or direct == 4:
for y in range(dst_y-1, -1, -1):
current = self.heap[dst_x][y]
if current == None:
continue
if current.symbol == symbol:
return True
break
for y in range(dst_y+1, self.rows, 1):
current = self.heap[dst_x][y]
if current == None:
continue
if current.symbol == symbol:
return True
break
return False
这段代码看似很长,实际处理的过程很简单:
如果此函数返回 True,就可以对选择的整堆牌,调用 Point.move_deck() 方法,让整个牌桌呈现新的状态即可。
# 获取牌堆代码
selected_mahjong = self.table.heap[self.table.edge.pos[0]][self.table.edge.pos[1]]
deck = selected_mahjong.search_deck(self.pos[0],self.pos[1])
if deck != None:
if self.move_deck_check(deck) == True:
self.move_deck(deck)
else:
self.table.show_text('必须要有能消除的牌才能移动')
else:
self.table.show_text('不能选择不同行、列的空点移动')
而 Point.move_deck() 方法,就是对 deck 中的每个麻将,调用 Table.move() 方法。其中 self.pos 表示队尾的麻将最终移动到的位置,其他麻将,根据所在队列中的位置,依照移动方向挨个计算新位置。
def move_deck(self, deck):
'''移动牌堆'''
head = deck[len(deck)-1]
head_x = head.pos[0]
head_y = head.pos[1]
is_hr = (head_x == self.pos[0]) # 水平移动还是垂直移动
for i in range(len(deck)):
moving_card = deck[len(deck)-1-i] # 当前要移动的牌。从牌堆尾部开始移动
if is_hr:
if head_y < self.pos[1]:
self.table.move(moving_card.pos,[self.pos[0],self.pos[1]-i])
elif head_y> self.pos[1]:
self.table.move(moving_card.pos,[self.pos[0],self.pos[1]+i])
else:
if head_x < self.pos[0]:
self.table.move(moving_card.pos,[self.pos[0]-i,self.pos[1]])
elif head_x > self.pos[0]:
self.table.move(moving_card.pos,[self.pos[0]+i,self.pos[1]])
总结
至此,整个游戏的核心玩法开发就完成了。虽然现在还没有游戏难度控制、标题画面和 GameOver 画面等。但是这些,都不会比游戏玩法更难实现。
在这个游戏的开发过程中,使用 pygame 的能力其实并不复杂,最复杂的还是游戏逻辑的实现。使用什么样的数据结构,去表达游戏逻辑,是一个游戏程序的核心问题。