使用Pixi.js实现《围追堵截》桌游(二)

前言

上一篇文章中,我们已经实现并初始化了游戏所需要的所有绘制类。本篇文章将会从游戏规则的角度出发创建这些绘制类的管理类。

之所以要将绘制实体与管理类用不同的类实现,是为了保证游戏中计算相关的逻辑与绘制实体的逻辑解耦,这样即使后续更换绘制引擎,只要在相应的绘制类内实现所需要的方法,就可以保障游戏正常运行。

接下来,我们从对管理类的分析入手,继续实现整个游戏逻辑。

游戏管理对象分析与实现

棋盘

思考一个问题:在我们的游戏内,棋盘除了绘制格子和缝隙之外,还承载着哪些信息?

首先,棋盘上存在元素坐标信息,注意这里的坐标并不是元素的实际坐标,而是元素的索引坐标grid1

如上图所示,当在棋盘上指出一个索引坐标时可以获取该索引位置的实际元素。也就是说可以将棋盘看做一个二维数组,并用数组中不同大小的数值表示棋盘中的不同类型的元素,在本游戏中使用的数值与对象的对应关系如下:

方格:1(低代价)
缝隙:2(高代价)
挡板:0(不可通行)

最终如图所示,一个 3*3 的棋盘会被抽象成如下的样子: grid1

注意,一个 3 * 3 的棋盘,最终在实际绘制的时候是一个 5 * 5 的二维矩阵,原因请参考上一篇文章。

其次,游戏中需要判断玩家是否用挡板挡住了所有的路线导致违规,当任何一个玩家的所有可行进路线为空时说明存在违规的情况,需要终止游戏进程。

而正如前文提到的那样,棋盘的管理对象 Grid 中存储着代表棋盘的二维数组,使用它可以很方便的进行路径的计算。本游戏采用 A* 算法来进行判断:输入代表棋盘的二维数组以及当前玩家和对手玩家的索引位置,输出所有可行进路线。

总结一下,棋盘管理类需要两个组成部分:1. 一个代表着棋盘的二维数组。2. 一个用 A* 计算路径的方法,它的输入是起点和终点的索引坐标。


角色

试着想一下:当我们以现实世界中《围追堵截》桌游的玩家身份开始一场游戏时,在游戏中我们会关注哪些东西呢?

我们来试着总结一下:


- 是否满足获胜条件了?
- 当前是自己的回合吗?
- 己方棋子可移动位置
- 己方剩余挡板的数量
- 应该阻挡还是移动棋子?
- 应该阻挡哪里让对方付出更多代价?
- 应该移动哪里让对方无法阻挡?
- 下一步怎么走?
- ...

可见在游戏过程中我们需要关注的内容还是比较多的,而除了前面四点是固定的信息内容外,其他部分会因为玩家决策的不同而产生差异,而这种差异正是游戏的魅力所在。

把决策部分交给屏幕前的玩家。我们只关注固定的信息部分:是否已经获胜当前是否为自己的回合己方棋子可移动位置己方挡板剩余数量

是否已经获胜:根据游戏规则,到达对手出发时所在行即获胜。如果使用游戏内的索引坐标来描述这条规则,就是当前角色索引坐标的 y 值是否等于对手出发时所在行的 y 值。根据这个描述,我们在初始化时角色管理类实例的时候,可以将该目标值作为预设条件设置到实例里面,并在每回合结束的时候通过该值进行判断:

// constructor()
 this.targetY = targetY;

// 每回合结束调用该方法判断是否有玩家获胜
 isWin() {
     return this.y === this.targetY;
 }

当前是否为自己的回合:现实世界的游戏中当前为谁的回合制是一件双方默认都遵守的规则。而在游戏中想要实现回合制则需要对用户的操作进行一定限制,同时对当前回合玩家进行一些提示才能实现。

本游戏中使用棋子的可移动位置闪烁来提示用户当前是哪位玩家的回合,同时对界面 UI 也会进行限制。

toggleSelected(isOpen) {
    // 控制棋子的绘制对象动画,例如闪烁动画,改变颜色等;启用/禁用棋子的交互事件,从而达到回合制的目的
}

己方挡板剩余数量:在每个角色管理实例中初始化一个挡板管理实例,这个实例对外暴露两个方法:检查当前剩余挡板数量方法,以及减少一个挡板方法。同时初始化的时候给予每位玩家 10 块挡板。

remain = MaxBlocksNum;

checkRemain() {
    return this.remain > 0;
}

decreaseRemain() {
    if (this.remain <= 0) return 0;
    this.remain--;
    return this.remain;
}

己方棋子可移动位置: 根据游戏规则,己方棋子每回合移动步数为 1 格,而可移动的位置同时会受到棋盘边缘,挡板位置,对方棋子三个因素的影响,下面来逐步实现:

第一步,需要知道棋子周围所有格子和缝隙的信息,如下图所示: player1

需要注意的是一些边界值情况,例如当棋子处于棋盘边缘的时候只能获取到两个方向的信息

第二步,根据上面收集到的格子和缝隙信息,根据缝隙里面是否已经存在阻挡墙,以确定对应的格子是否可以到达: player2

第三步,需要从当前棋子所有可行进的格子中判断是否和对方棋子所在格子存在交叉的情况,如果不存在,那么目前的结果就是我们的所有可行进方向了。而如果有交叉的情况,根据游戏规则可知,若对方棋子处于我们的行进路线上,则可以借助对方棋子进行 “跳跃”,所以当发生交叉的情况我们还需要进一步进行处理。

第四步,先获取对方棋子所在位置周围所有的可行进格子,这一步除了需要排除掉己方玩家所在位置这一步操作外和获取己方棋子可行进格子时一模一样: player3

第五步,根据游戏规则可知,借助对方棋子进行 “跳跃” 时,如果对方背后没有阻挡墙或没到达棋盘边界,则只可以落在对方背后的格子内,否则才可以落在对方两侧的位置: player4

我们根据己方棋子和对方棋子所在的方位,计算出“对方棋子背后”的方格是否存在于对方棋子可行进的格子内。

/**
 * 获取对手棋子背后的格子
 * const deltaX = crossB.x - this.x;
 * const deltaY = crossB.y - this.y;
 * const backBX = crossB.x + deltaY;
 * const backBY = crossB.y + deltaX;
 * 化简后可得:
 */
const backX = 2 * crossB.x - this.x
const backY = 2 * crossB.y - this.y

如果对手棋子背后的格子存在且可行进,则单独将对方棋子背后的格子加入到己方棋子可行进方格中,此时的结果就是所有可以前进的方向。 如果不存在或无法行进,则将所有对方棋子可到达的格子加入到己方可行进ÏÏÏÏÏÏ的方格中,此时的结果就是所有己方棋子可行进的格子了。


挡板

在描述角色管理类的时候我们已经初始化了挡板管理类的实例,本游戏在实现这里的时候偷了个懒,并没有将计算逻辑从挡板的绘制类中剥离出来放到挡板管理类中,下面说一说如果不偷懒的话应该如何来实现该类:

之前已经提到过挡板管理类需要来管理挡板的剩余数量,这里不再赘述。

在现实世界中玩《围追堵截》桌游的时候挡板的位置是不可以随意摆放的,比如以下几种情况不允许出现:

-挡板不能超出边界
- 挡板必须正好挡住两个方格
- 挡板不允许互相交叠

block1

那么在挡板管理类中就应该对这些情况进行判断,如果判断条件不通过,则不允许进行挡板的绘制。

挡板不能超出边界,对于这个问题可以通过对于输入的绘制起始坐标以及挡板类型进行判断,如果其超出了允许绘制的边界,我们可以将允许绘制的边界坐标返回,从而避免挡板超出棋盘范围

/**
 * 获取真正的绘制起点信息
 * @param {*} indexX
 * @param {*} indexY
 * @param {*} direct
 * @param {*} getSideEffect
 */

if (direct === GapDirect.horizontal) {
  info.x = Math.min(indexX, boardCol - CurrentEdgeIndexOffset)
  info.y = indexY
}
else {
  info.x = indexX
  info.y = Math.min(indexY, boardRow - CurrentEdgeIndexOffset)
}

挡板必须正好挡住两个方格,这个问题我们通过绘制时候的限制其实就可以解决,不再赘述。

挡板不允许互相交叠,可以计算当前挡板绘制后会影响哪些索引坐标,判断所影响的索引坐标中是否已经被绘制了挡板,如果已经绘制则不允许再次绘制就可以避免出现交叠的情况。

info.effectGaps = this.getEffectGaps(info.x, info.y, direct);
info.isHitWithExistBlocks = this.getHitWithExistBlocks(info.effectGaps);

// 如果与已存在的挡板发生交叉,则不允许绘制
if (info.isHitWithExistBlocks) {
    return;
}else{
    // 绘制挡板
    drawBlock()
    ...
}

以上内容就是对游戏管理对象的分析与实现,到目前为止我们已经有了符合游戏内容的绘制实例,以及符合游戏规则的管理实例。下面就是将二者结合起来,再加如一些交互,回合机制和部分 UI 交互我们的游戏基本就大功告成了,那么下一篇文章见 (¬◡¬)✧