消除麻将
根据游戏规则,两张相同图案的麻将,如果互相之间没有其他麻将牌被直线阻隔(中间的距离可以无限),可以通过先后点击选择这两张麻将,消除这两张牌。
要实现以上功能,需要分步完成以下几个能力:
选中麻将
对于麻将类 Mahjong 的 update() 方法,增加对于用户输入事件的检测和处理,就能完成“选中麻将”的功能:
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.is_show_edge == False:
self.table.show_edge(self.pos) # 选中第一张牌
else:
# 选中第二张牌
......
第一篇介绍的 Director 类,会在每一帧,都通过 pygame 把所有的用户输入事件,存放到 Director.events 属性中,所以每个 Sprite 的子类对象,都可以在 update() 函数中去检测判断:用户有什么输入。
由于 mahjong.MainScenario 类,在 start() 方法中,构建 Table 这个 Group 的子类对象时,传入了 director 参数
table = Table(self.director)
因此每个 Mahjong 对象,都可以通过其 table 属性获得 director 对象,从而获得每帧更新的用户事件(们):self.table.director.events
桌上所有的 Mahjong 对象,由于存放在 Table 这个 Group 里面,所以每帧其 update() 都会被调用。也就是说,每帧、每个麻将对象,都可以在 update() 里检测一遍:“我”有没有被鼠标点中。用户在此刻的所有操作,会被 pygame 放入 events 列表,需要我们通过循环迭代语句,获取其中的每个事件。
通过 event.type 属性,判断 pygame.MOUSEBUTTONDOWN 就可以知道是否有鼠标按钮按下的事件;随后可以通过 pygame.mouse.get_pos() 可以获得鼠标当前的位置;最后通过 Sprite.rect.collidepoint(pos) 可以判断当前 Sprite 对象是否有“碰撞”到某个 pos 点位置。当前的 Sprite 就是麻将对象,所以我们就判断鼠标是否“点击”到了当前的麻将。
显示选中特效
对于选中的麻将,我们希望是:
所有需要控制显示的对象,都继承 Sprite 实现一个类,通过构造器来实现加载某个图像数据。此对象的 image/rect 属性通过加载一个图片作为框框显示,这个图片需要是中间透明的,所以使用的 png 格式。
我们可以建立一个类 Edge,用来显示“选中框”。此类有的 pos 属性是一个数组,记录选中的麻将牌的桌上坐标。
class Edge(pygame.sprite.Sprite):
'''提示的点'''
def __init__(self, table):
pygame.sprite.Sprite.__init__(self)
self.image = pygame.image.load("edge.png")
self.rect = self.image.get_rect()
self.table = table # 所在桌子
self.pos = [0,0] # 标记框框套在哪个麻将上面,记录在桌上的坐标
def update(self):
pass
Table 类添加一个属性 edge,持有此 Edge 对象;另外一个属性 is_show_edge 记录框框是否已经显示。
在每帧都调用的 Table.show() 方法里面,根据 is_show_edge 属性,来决定是否 add(self.edge),就可以实现根据 is_show_edge 来显示/消失这个框框。
由于每次显示 Edge 对象的位置不一样,所以在 Table 上增加了一个 show_edge() 方法,用来修改 Table.edge 的位置:
def show_edge(self, loc: list[int]):
self.edge.rect.left = self.edge.rect.width*loc[0]
self.edge.rect.top = self.edge.rect.height*loc[1]
self.edge.pos = loc
self.is_show_edge = True
其中 loc 参数表示被选中麻将的坐标,会记录到 Edge.pos 上,同时根据此坐标计算并修改 edge.rect 的位置,并且对 is_show_edge 赋值为 True;当点击事件触发“点击第二张牌”的时候,此属性会被置为 False。
选中第二个牌的处理
点击第二张牌后,需要判断是否可以消除,代码在 Mahjong.update():
def update(self):
# 判断事件和选中第一张牌
......
else:
# 选中第二张牌
i = self.table.edge.pos[0] # 选中的第一张牌的 X 坐标
j = self.table.edge.pos[1] # 选中的第一张牌的 Y 坐标
selected = self.table.heap[i][j] # 获得选中的第一张牌对象
x = self.pos[0]
y = self.pos[1]
# 判断:两个牌直接是否有阻隔 and 被选中的牌不能是空 and 两张牌的图案是一样的 and 不能选中两次是同一张牌
if self.table.can_do([i,j],[x,y]) and selected != None and selected.symbol == self.symbol and self.table.heap[i][j] != self.table.heap[x][y]:
# 消除两张牌
self.table.heap[i][j] = None
self.table.heap[x][y] = None
#显示特效
self.bomb.show(self.rect.left,self.rect.top)
selected.bomb.show(selected.rect.left,selected.rect.top)
else:
self.table.show_text('直线相连的两张同样的牌才能消除!')
self.table.hide_edge()
由于 Table.show_edge() 会在 table.edge.pos 记录被选中的第一张麻将的坐标,所以第二张麻将被选中的时候,可以通过这个坐标(i,j)从 table.heap 这个二维数组获得被选中的麻将。
下面就是几个情况,判断是否可以消除,具体判断:
如果可以消除,通过对 heap[x][y] 的值赋值 None 就表示了消除。在 Table.show() 里面,会跳过为 None 的 heap 成员,因此就可以作为消除牌的功能实现。
如果不能消除,这里调用了一个 Table.show_text() 方法,用于显示提示文字,后续会介绍如何显示。
显示爆炸效果
在上述逻辑中,通过了以下代码实现“显示”爆炸效果:
self.bomb.show(self.rect.left,self.rect.top)
selected.bomb.show(selected.rect.left,selected.rect.top)
由于在 MainSenario 的 start() 方法中,为每个麻将对象,都添加了一个爆炸对象属性 Mahjong.bomb,所以被选中的两个麻将对象,都可以调用 self.bomb.show() 这个方法,传入了需要显示的坐标。一旦调用这个方法,Bomb 类就会自己通过 Bomb.update() 方法,显示一段时间“爆炸”的图片。如果想内存占用的小一点,也可以在 MainSenario.start() 方法中只构造两个 Bomb 对象,然后在需要爆炸的时候,再显示到对应的位置。
具体的方法是:
class Bomb(pygame.sprite.Sprite):
'''消除特效'''
def __init__(self, effect:pygame.sprite.Group):
pygame.sprite.Sprite.__init__(self)
self.image = pygame.image.load("Bomb_effect.png")
self.effect = effect
self.counter = 0
self.rect = self.image.get_rect()
def show(self,left,top):
self.effect.add(self)
self.counter = 30
self.rect.left = left
self.rect.top = top
def update(self):
self.counter -= 1 # 倒计时减一
if self.counter == 0: # 结束显示特效
self.effect.remove(self)
显示文字提示
文字提示,实际上也是一种 Sprite 对象,也需要对 image/rect 进行赋值,和上面的图像不同的是,文字的 image 需要通过选择字体和文字内容进行绘制。如果要显示一段文字在游戏画面上,只需要:
# 获得字体 SimSun 32 号
font = pygame.font.SysFont("SimSun", 32)
# 用font字体渲染内容为 hello world 的橙色文字
text = font.render("hello world", True, pygame.Color("orange"))
# 获得渲染后的文字(图像)大小
rect = self.text.get_rect()
# 生成一个刚好是上面文字图像大小的 image 对象
image = pygame.Surface((rect.width, rect.height))
# 把文字绘制在 image 对象的 0,0 位置
image.blit(text, (0,0))
从上面的代码可以看出,我们可以选择文字的字体、颜色,还可以选择和其他内容共同“画”在一个图形上。
由于本游戏只需要在一个地方显示文字,而且字体只需要一种,所以在 Table 对象的属性中构造好字体对象 font、显示文字对象这两个对象 text_sprite。另外,这个提示文字需要自动消失,所以还需要两个属性来记录文字显示了几秒 show_text_time,以及何时开始 start_ticks。这个自动消失的功能和上面的爆炸特效功能类似,但是这里使用了不同方法,纯粹为了学习。
class Table(pygame.sprite.Group):
'''控制生成的桌面'''
def __init__(self, director: scenario.Director):
.......
# 构建提示语句
self.font = pygame.font.SysFont("SimSun", 32)
self.text_sprite = pygame.sprite.Sprite()
self.text = ""
self.show_text_time = 0 # 文字需要显示几秒
self.start_ticks = 0 # 文字开始显示的时间
然后写一个 show_text() 的方法,用来在桌上显示文字:
def show_text(self, content:str, time:int = 2):
'''显示提示文字在屏幕上面 time 秒'''
self.text = self.font.render(content, True, pygame.Color("orange"))
self.show_text_time = time
self.start_ticks = pygame.time.get_ticks() #starter tick
rect = self.text.get_rect()
self.text_sprite.rect = rect
self.text_sprite.image = pygame.Surface((rect.width, rect.height))
self.text_sprite.image.blit(self.text, (0,0))
print("show text:", content)
pass
这里需要注意的是 self.show_text_time = time 这句,是记录了当前文字要显示多少秒,这个值会在 update() 中逐渐减少,用以让文字自动消失。下面是 Table.show() 的代码段:
def show(self):
self.empty()
# 画麻将牌
......
# 画框框
......
# 画文字
if self.show_text_time > 0:
seconds = (pygame.time.get_ticks()-self.start_ticks)/1000 #calculate how many seconds
if seconds<self.show_text_time: # if more than 10 seconds close the game
self.add(self.text_sprite)
else:
self.remove(self.text)
self.show_text_time = 0
pass
这里可以看到,每次 update() 调用 show(),然后都会判断一下 show_text_time,用以决定是否要显示文字提示。由于 self.start_ticks 记录了启动显示的时间,所以根据 pygame.time.get_ticks() 返回的当前时间(毫秒数),就能知道已经显示了多久。显示和消失也是用 add() 和 remove() 控制。由于 Table.show() 的第一行是 self.empty(),会清空所有在 table 这个 Group 里的 Sprite,所以下面要显示的内容,都必须要调用 self.add()。
从上面的代码可以看到,游戏程序的所有“动态能力”,基本实现思想都是:
因此,游戏系统的动画,也大多数是如此实现,是通过一帧帧的逻辑,来决定如何显示下一个画面,从而形成一个动画。由于 udpate() 函数每帧都要调用,所以尽量减少在这个函数中构建新的对象,或者进行特别慢的操作如等待加载磁盘文件、等待网络响应等。因为如果 update() 特别慢,整个游戏的运行就会感觉特别卡。
下一篇介绍如何实现麻将的移动动画,以及复杂的游戏逻辑判断。