写中国象棋规则,四处最容易错的地方
蹩马腿、塞象眼、炮打隔子、将帅照面。我把走法生成拆成几何层与合法性层,前者只管兵种怎么走,后者统一兜住送将,四处坑各配了断言。
中国象棋 的规则比五子棋复杂一个量级,而且复杂在例外上:每个兵种的走法都有一条例外,例外彼此独立,漏一条不会报错,只会让 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) 而不是「有没有向前」。
坑三:炮打隔子
炮的走法分两段,这也是全篇最容易写错的地方:
- 不吃子时同车:沿直线走,空格随便走,遇到第一个子停下(不能吃它)。
- 吃子必须隔一个:越过第一个子(炮架),之后再遇到的第一个子才能吃。
// 第一段:炮架之前的空格
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;同一列用「进/退/平」表示纵向、横向或不动的差别。
我只做到「能看懂」,没做多子同线时的「前/后」消歧——那是记谱软件的事,为一个玩具写它不划算。

评论
…