写中国象棋规则,四处最容易错的地方

蹩马腿、塞象眼、炮打隔子、将帅照面。我把走法生成拆成几何层与合法性层,前者只管兵种怎么走,后者统一兜住送将,四处坑各配了断言。

中国象棋 的规则比五子棋复杂一个量级,而且复杂在例外上:每个兵种的走法都有一条例外,例外彼此独立,漏一条不会报错,只会让 AI 走出一步蠢棋。

我把生成拆成两层,这个结构本身就消掉了大半错误。

两层:几何层与合法性层

层 函数 管什么
几何层 pseudoMoves 按兵种怎么走、路上有没有挡
合法性层 legalMoves 走完之后自己的将安全吗

合法性层的做法很笨但很稳:把每个疑似走法试走一遍,走完检查自己是否被将军、两将是否照面,不合法就丢弃。

把全局约束统一在一处兜住,比在每个兵种里各写一遍「这步会不会送将」可靠得多。

象棋里没有「不许送将」这条独立规则,它是「不能让自己的将处于被吃状态」的自然推论。这种全局约束天生适合事后统一过滤。

坑一:蹩马腿

马走日字,但如果马身边那一格正交位置有子(无论敌我),这个方向就走不了。八个方向各有一条马腿:

const HORSE = [
  [-2, -1, -1, 0], [-2, 1, -1, 0],
  [2, -1, 1, 0], [2, 1, 1, 0],
  [-1, -2, 0, -1], [1, -2, 0, -1],
  [-1, 2, 0, 1], [1, 2, 0, 1],
];

每项是「终点偏移 + 马腿偏移」。写成一张表而不是八个 if,是因为马腿与终点必须成对,分开写很容易让某两个方向共用一个错误的腿。

测试方式:空棋盘上马有 8 步;四个马腿方向各堵一次,每次都应剩 6 步。堵少了说明腿没对,堵多了说明堵错方向。

坑二:塞象眼

象走田字,中间那一格叫象眼,有子就走不了。另外象不过河——红象只能在 5 到 9 行活动。

这里有个我一开始想错的点:以为象只能往前。实际上象可以退回本方底线,只要落点还在自己半场。判据是ownHalf(side, row) 而不是「有没有向前」。

坑三:炮打隔子

炮的走法分两段,这也是全篇最容易写错的地方:

  1. 不吃子时同车:沿直线走,空格随便走,遇到第一个子停下(不能吃它)。
  2. 吃子必须隔一个:越过第一个子(炮架),之后再遇到的第一个子才能吃。
// 第一段:炮架之前的空格
while (inBoard(nr, nc) && !board[index(nr, nc)]) {
  out.push({ from: i, to: index(nr, nc), captured: null });
  nr += dr; nc += dc;
}
// 越过炮架
if (!inBoard(nr, nc)) continue;
nr += dr; nc += dc;
// 第二段:炮架之后的第一个子
while (inBoard(nr, nc)) {
  const t = board[index(nr, nc)];
  if (t) {
    if (t.side !== side) out.push({ from: i, to: index(nr, nc), captured: t });
    break;
  }
  nr += dr; nc += dc;
}

要注意的不只是「能不能吃」,还有炮架之后的空格也不能落——那些位置在炮架后面,规则上炮根本到不了。我最初漏了这句 break,炮就能飞到炮架后面的空位,看起来像长了翅膀。

坑四:将帅照面

两将同列、中间无子时不能形成这个局面——因为下一步走子的一方等于把两个将互相暴露了。实现上和送将一样,走完试一下即可。

顺带一提,这个约束会让人工构造测试局面时翻车:随手把两个将摆在同列,整个局面就是非法的,于是legalMoves返回空数组,看起来像走法生成坏了。我调试时在这里浪费了一轮,写断言时专门留了注释说明基准局面必须让两将不同列。

将军检测同样有四处

判断某一方是否被将军,不能去枚举对方所有走法(太慢),要从将的位置反推:

兵种 反推方式
车 四个方向第一个遇到的子
炮 四个方向越过第一个子之后遇到的子
将 四个方向第一个遇到的子(照面)
马 八个马位上有马,且马腿没被蹩

马的检测要注意方向:马在 (nr, nc)、将在 (kr, kc),马腿是马身边朝向将的那一格,也就是 (nr + lr, nc + lc),其中 (lr, lc) 仍用走法表里的那项——反推和正推能共用同一张表,是把它写成表而不是 if 链的又一个好处。

AI:先看一步杀

象棋的将只能待在九宫里,一手杀比五子棋常见得多。深度不够的搜索会漏掉它,所以每个根候选先试走一遍看对方是否直接无解,是的话立刻返回,不进搜索。

这个预检查还有个副作用:它让「必杀」的分值是确定的极大值,界面上显示 90000 一眼就知道是杀棋,不用去猜搜索分值。

记谱

顺手实现了中文记谱(炮二平五)。规则本身很规整:红方从右往左数一到九,黑方从左往右数 1 到 9;同一列用「进/退/平」表示纵向、横向或不动的差别。

我只做到「能看懂」,没做多子同线时的「前/后」消歧——那是记谱软件的事,为一个玩具写它不划算。

← 返回文章列表

评论

…