前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用 PyGame 入门专业游戏开发(四)

用 PyGame 入门专业游戏开发(四)

作者头像
韩伟
发布2023-12-12 17:20:27
1760
发布2023-12-12 17:20:27
举报
文章被收录于专栏:韩伟的专栏韩伟的专栏

麻将移动动画

根据游戏逻辑,麻将被选中后,是可以再点击桌面上的空位,进行移动的。要实现麻将的移动,需要有以下几点功能需要实现:

  1. 检测鼠标点击事件,开始进入移动的逻辑。这一点通过 Table 上的“空格”对象进行“点击判断”就可以了。
  2. 判断目标地点是否可以移动。如果没有选中麻将,不能移动;如果目标地点与被选中的麻将,不在纵横的直线上,就应该不可以移动。
  3. 修改麻将的位置,并且显示一段从起点到终点的动画。

点击空格产生移动

为了实现上面第一点功能,我们可以为桌子上的“空格”构造一个 Sprite 子类对象,这里设计叫 Point 类。你可以理解为桌子上铺了一个桌布,这个桌布是一堆圆点构成的,每个格子的桌布就是一个 Point 对象。

代码语言:javascript
复制
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 属性就是后续显示移动动画,关键的“目的坐标”的计算依据。

代码语言:javascript
复制
    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 的代码,从而实现移动。

代码语言:javascript
复制
    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() 方法:

代码语言:javascript
复制
    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 来表达了,就可能需要专门做一个包含错误的返回值了。在复杂的游戏开发中,我们可能使用异常、错误码返回值等手段来实现各种“错误”的传递和处理。这里由于是入门项目,所以没有做的更复杂。

模拟移动后检查是否可消除

由于游戏的设计,并不允许随意移动,而是要求移动的一堆麻将中,必须要有可以消除的,才能移动。所以我们不能在移动麻将之后,再一个个判断“是否有可以消除”,而是应该在移动之前,就遍历移动的整队麻将,挨个检查到达目的地之后,是否可以消除。

如图,“二条”和“八条”这一队,就可以往上移动,直到“八条”碰到上面的“八万”。因为移动到这个位置之后,移动了的“二条”可以和右侧的“二条”可以消除。

计算移动后的位置

要实现上述的功能,我们需要分几步来实现这个功能:

  1. 计算整队麻将移动后,每个麻将“应该”到达的位置
  2. 在新的位置上,判断是否可以消除
    1. 在垂直于移动方向的 +1 方向(往下、往右)判断
    2. 在垂直于移动方向的 -1 方向(往上、往左)判断

在 Point 类上添加 move_deck_check() 方法,用这个方法进行上面的判断。

代码语言:javascript
复制
    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 属性的值,就可以计算出:

  1. 水平还是垂直方向移动
  2. 是 +1 还是 -1 方向移动

有了上面的两个方向,剩下的就是根据 deck 里面的顺序,从第一个麻将牌开始,依次从目的地位置,倒排过去即可。

上图就是以往左移动为例,说明了 dst_x 的计算过程。

判断是否可以消除

一旦获得了 dst_x/dst_y 作为移动后的位置,以及将要移动的麻将对象的图案,以及移动的方向,我们就可以编写一个函数,用以检查,是否这张麻将牌在新的位置上,有可以与之消除的其他麻将。我们在 Table 类上添加 can_erase() 方法,用来完成这个功能:

代码语言:javascript
复制
    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

这段代码看似很长,实际处理的过程很简单:

  1. 根据 direct 的不同进行计算,只判断垂直于移动方向上的牌
  2. 从 [dst_x, dst_y] 出发,在 +1/-1 的方向上分别进行检查
  3. 获取从 [dst_x, dst_y] 出发,遍历检查方向上的每一张牌,直到碰到坐标边界:[0,0] 或者 [Table.cols,Table.rows]
    1. ‍如果是检查的位置没有牌,则检查下一个位置
    2. 如果检查的位置有牌,图案相同则返回 True,不同则退出此方向的检查。

如果此函数返回 True,就可以对选择的整堆牌,调用 Point.move_deck() 方法,让整个牌桌呈现新的状态即可。

代码语言:javascript
复制
            # 获取牌堆代码
            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 表示队尾的麻将最终移动到的位置,其他麻将,根据所在队列中的位置,依照移动方向挨个计算新位置。

代码语言:javascript
复制
    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]])

总结

  1. 游戏的主要程序结构:主循环+每帧 update()
  2. pygame 的功能:
    1. 画图:Groups/Sprite/Surface
    2. 输入:evens
  3. 使用类、数组等数据结构进行游戏逻辑的存放和计算,最后根据这些数据结构用以显示

至此,整个游戏的核心玩法开发就完成了。虽然现在还没有游戏难度控制、标题画面和 GameOver 画面等。但是这些,都不会比游戏玩法更难实现。

在这个游戏的开发过程中,使用 pygame 的能力其实并不复杂,最复杂的还是游戏逻辑的实现。使用什么样的数据结构,去表达游戏逻辑,是一个游戏程序的核心问题。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-12-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 韩大 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档