麻将移动动画
根据游戏逻辑,麻将被选中后,是可以再点击桌面上的空位,进行移动的。要实现麻将的移动,需要有以下几点功能需要实现:
- 检测鼠标点击事件,开始进入移动的逻辑。这一点通过 Table 上的“空格”对象进行“点击判断”就可以了。
- 判断目标地点是否可以移动。如果没有选中麻将,不能移动;如果目标地点与被选中的麻将,不在纵横的直线上,就应该不可以移动。
- 修改麻将的位置,并且显示一段从起点到终点的动画。
点击空格产生移动
为了实现上面第一点功能,我们可以为桌子上的“空格”构造一个 Sprite 子类对象,这里设计叫 Point 类。你可以理解为桌子上铺了一个桌布,这个桌布是一堆圆点构成的,每个格子的桌布就是一个 Point 对象。
通过在 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 属性就是后续显示移动动画,关键的“目的坐标”的计算依据。
上述方法的 src 和 dst 参数,代表了把桌上 src 坐标的麻将,移动到 dst 坐标去,这两个参数都是“两个元素的列表”类型,如 [3,2]
然后我们根据 Mahjong.pos 和 Mahjong.is_moving,在 Mahjong.show() 中添加不断修改 Mahjong.rect.left/top 的代码,从而实现移动。
上面的代码,先计算出移动目的地的显示坐标: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() 方法:
此方法从被选中的麻将开始,按照点击的空白点的方向,依次从 Table.heap 中取出麻将,最后放在 deck 变量中返回。这里由于有水平、垂直两个方向,所以有两端类似的代码,是可以想办法重构成只有一段的。
这里需要注意的是,此 search_deck() 返回 None 表示选择的方法不合法,需要调用处代码进行判断处理。如果有更复杂的“不合法”操作的处理方案,就不应该仅仅通过返回 None 来表达了,就可能需要专门做一个包含错误的返回值了。在复杂的游戏开发中,我们可能使用异常、错误码返回值等手段来实现各种“错误”的传递和处理。这里由于是入门项目,所以没有做的更复杂。
模拟移动后检查是否可消除
由于游戏的设计,并不允许随意移动,而是要求移动的一堆麻将中,必须要有可以消除的,才能移动。所以我们不能在移动麻将之后,再一个个判断“是否有可以消除”,而是应该在移动之前,就遍历移动的整队麻将,挨个检查到达目的地之后,是否可以消除。
如图,“二条”和“八条”这一队,就可以往上移动,直到“八条”碰到上面的“八万”。因为移动到这个位置之后,移动了的“二条”可以和右侧的“二条”可以消除。
计算移动后的位置
要实现上述的功能,我们需要分几步来实现这个功能:
- 计算整队麻将移动后,每个麻将“应该”到达的位置
- 在新的位置上,判断是否可以消除
- 在垂直于移动方向的 +1 方向(往下、往右)判断
- 在垂直于移动方向的 -1 方向(往上、往左)判断
在 Point 类上添加 move_deck_check() 方法,用这个方法进行上面的判断。
上面这段代码,重点是对于 dst_x 和 dst_y 的计算。deck 参数存放了所有需要移动的麻将牌,而 self 这个 Point 对象,就是 deck 里面的多个麻将要移动到的目的地。根据 deck 里面的第一个麻将的 pos 属性,以及目的空位 pos 属性的值,就可以计算出:
- 水平还是垂直方向移动
- 是 +1 还是 -1 方向移动
有了上面的两个方向,剩下的就是根据 deck 里面的顺序,从第一个麻将牌开始,依次从目的地位置,倒排过去即可。
上图就是以往左移动为例,说明了 dst_x 的计算过程。
判断是否可以消除
一旦获得了 dst_x/dst_y 作为移动后的位置,以及将要移动的麻将对象的图案,以及移动的方向,我们就可以编写一个函数,用以检查,是否这张麻将牌在新的位置上,有可以与之消除的其他麻将。我们在 Table 类上添加 can_erase() 方法,用来完成这个功能:
这段代码看似很长,实际处理的过程很简单:
- 根据 direct 的不同进行计算,只判断垂直于移动方向上的牌
- 从 [dst_x, dst_y] 出发,在 +1/-1 的方向上分别进行检查
- 获取从 [dst_x, dst_y] 出发,遍历检查方向上的每一张牌,直到碰到坐标边界:[0,0] 或者 [Table.cols,Table.rows]
- 如果是检查的位置没有牌,则检查下一个位置
- 如果检查的位置有牌,图案相同则返回 True,不同则退出此方向的检查。
如果此函数返回 True,就可以对选择的整堆牌,调用 Point.move_deck() 方法,让整个牌桌呈现新的状态即可。
而 Point.move_deck() 方法,就是对 deck 中的每个麻将,调用 Table.move() 方法。其中 self.pos 表示队尾的麻将最终移动到的位置,其他麻将,根据所在队列中的位置,依照移动方向挨个计算新位置。
总结
- 游戏的主要程序结构:主循环+每帧 update()
- pygame 的功能:
- 画图:Groups/Sprite/Surface
- 输入:evens
- 使用类、数组等数据结构进行游戏逻辑的存放和计算,最后根据这些数据结构用以显示
至此,整个游戏的核心玩法开发就完成了。虽然现在还没有游戏难度控制、标题画面和 GameOver 画面等。但是这些,都不会比游戏玩法更难实现。
在这个游戏的开发过程中,使用 pygame 的能力其实并不复杂,最复杂的还是游戏逻辑的实现。使用什么样的数据结构,去表达游戏逻辑,是一个游戏程序的核心问题。
最后发一下最终完成版的小游戏版本,已经上传机核。