跳到主要内容

中国象棋游戏开发教程

· 阅读需 8 分钟
ahKevinXy
作者

中国象棋比五子棋更适合作为“规则引擎”练习:棋盘不大,只有 10 x 9,但每种棋子的走法都不同,还要处理马腿、象眼、炮架、九宫、过河兵、将帅照面、不能送将等规则。

这次在游戏模块新增了一个本地中国象棋页面:/game/puzzle/chinese-chess。它支持双人对战和人机对战,AI 使用一层候选走法生成加启发式评分,适合作为网页棋类算法入门版本。

更新日期:2026-07-07。

一、核心数据结构

棋盘用二维数组表示:

type XiangqiSide = 'red' | 'black'
type PieceType =
| 'general'
| 'advisor'
| 'elephant'
| 'horse'
| 'chariot'
| 'cannon'
| 'soldier'

type XiangqiPiece = {
side: XiangqiSide
type: PieceType
}

type Board = Array<Array<XiangqiPiece | null>>

这里没有把棋子编码成字符串,是为了让规则函数更清楚。比如判断某个棋子是不是红方的马,只需要看:

piece.side === 'red' && piece.type === 'horse'

一次移动记录为:

type XiangqiMove = {
from: Point
to: Point
piece: XiangqiPiece
captured: XiangqiPiece | null
}

captured 会保留被吃掉的棋子,用来显示棋谱、判断是否吃到将帅,也方便后续做悔棋和复盘。

二、棋盘初始化

中国象棋棋盘有 10 行 9 路:

const rowCount = 10
const colCount = 9

黑方在上,红方在下。初始化时先放后排:

const backRank = [
'chariot',
'horse',
'elephant',
'advisor',
'general',
'advisor',
'elephant',
'horse',
'chariot',
]

再补炮和兵卒:

board[2][1] = createPiece('black', 'cannon')
board[2][7] = createPiece('black', 'cannon')
board[7][1] = createPiece('red', 'cannon')
board[7][7] = createPiece('red', 'cannon')

for (const c of [0, 2, 4, 6, 8]) {
board[3][c] = createPiece('black', 'soldier')
board[6][c] = createPiece('red', 'soldier')
}

这种初始化方式的好处是直观,和真实棋盘位置一一对应。

三、单个棋子的走法校验

第一层算法只判断“这枚棋子从 A 到 B 是否符合自身走法”,不关心是否会让己方被将。

车走直线,路径不能有棋子:

from.r === to.r || from.c === to.c

然后用 countPiecesBetween 统计中间棋子数:

countPiecesBetween(board, from, to) === 0

马走日字,但要判断马腿:

const leg =
absR === 2
? { r: from.r + Math.sign(dr), c: from.c }
: { r: from.r, c: from.c + Math.sign(dc) }

如果马腿位置有棋子,就不能走。

炮最特别,分为不吃子和吃子两种:

const screenCount = countPiecesBetween(board, from, to)
return target ? screenCount === 1 : screenCount === 0

没有目标棋子时,炮像车一样直走;吃子时,中间必须刚好隔一个炮架。

相和士

相走田字,不能过河,还要判断象眼:

absR === 2 && absC === 2 && staysHome && !board[eye.r][eye.c]

士只能在九宫内斜走一步:

isInPalace(to, side) && absR === 1 && absC === 1

将帅

将帅只能在九宫内上下左右一步,但还有一个特殊规则:如果同一路上中间没有棋子,将帅可以直接照面。

target?.type === 'general' &&
from.c === to.c &&
countPiecesBetween(board, from, to) === 0

这条规则也会用于判断“将帅照面”是否合法。

兵卒

兵卒只能向前,过河后可以左右走,不能后退:

const movesForward = dc === 0 && dr === forward
const movesSideways = isAcrossRiver(from, side) && dr === 0 && absC === 1

四、合法走法过滤:不能送将

只校验棋子走法还不够。比如红方把中间棋子挪开,让黑将和红帅直接照面,这步棋在真实规则里是不允许的。

所以完整合法走法要分三步:

  1. 检查棋子自己的走法。
  2. 复制棋盘并应用这一步。
  3. 判断新局面里己方是否被将。

代码结构是:

function isLegalMove(board, from, to, side) {
const move = buildMove(board, from, to)

if (!move || !isLegalPieceMove(board, from, to, move.piece)) {
return false
}

const next = applyMove(board, move)

if (generalsFace(next)) {
return false
}

return !isGeneralInCheck(next, side)
}

这里的关键不是某一个棋子的规则,而是“试走一步,再验证王是否安全”。这是棋类程序里非常通用的模式,国际象棋、将棋、中国象棋都可以用。

五、将军检测

判断某一方是否被将,可以反过来想:

找到这一方的将帅,然后看对方任意棋子能不能按自身走法攻击到它。

伪代码如下:

function isGeneralInCheck(board, side) {
const general = findGeneral(board, side)

for (const enemyPiece of enemyPieces) {
if (isLegalPieceMove(board, enemyPiece.from, general, enemyPiece)) {
return true
}
}

return false
}

注意这里用的是 isLegalPieceMove,不是 isLegalMove。因为检测攻击范围时,只关心对方棋子能不能攻击到将帅,不需要再递归判断对方走完以后自己是不是安全。

六、生成全部合法走法

AI 和将死判断都需要生成某一方所有合法走法。最简单可靠的方式是暴力枚举:

for (const from of allBoardPoints) {
if (board[from.r][from.c]?.side !== side) continue

for (const to of allBoardPoints) {
if (isLegalMove(board, from, to, side)) {
moves.push(buildMove(board, from, to))
}
}
}

中国象棋只有 90 个交叉点,这种枚举方式在网页小游戏里完全够用。它的优势是实现简单、边界清晰,也方便后续接入更强的搜索算法。

七、胜负判断

当前实现有两种结束方式:

  1. 吃到对方将帅,直接胜利。
  2. 对方没有任何合法走法,并且正在被将,判定将死。

逻辑是:

const nextLegalMoves = generateLegalMoves(nextBoard, nextTurn)

if (nextLegalMoves.length === 0) {
if (isGeneralInCheck(nextBoard, nextTurn)) {
winner = currentSide
} else {
status = 'draw'
}
}

这一步会同时覆盖“被将死”和“无合法走法和棋”。

八、简单 AI 设计

这个版本的 AI 没有做深层搜索,而是使用一层候选走法评分:

生成黑方所有合法走法
-> 对每一步试走
-> 计算吃子价值
-> 判断是否将军或将死
-> 判断对方下一手最大反击
-> 加一点位置分
选择分数最高的一步

评估函数的核心权重:

const pieceValue = {
general: 10000,
chariot: 900,
cannon: 450,
horse: 420,
elephant: 220,
advisor: 210,
soldier: 120,
}

吃到高价值棋子会加分:

if (move.captured) {
score += pieceValue[move.captured.type] * 10
}

如果能将军,会额外加分:

if (isGeneralInCheck(next, opponent)) {
score += 520
}

如果对方下一手能吃到我方大子,则扣分:

const opponentBestCapture = opponentMoves.reduce(
(best, candidate) =>
Math.max(best, candidate.captured ? pieceValue[candidate.captured.type] : 0),
0,
)

score -= opponentBestCapture * 1.4

这不是最强 AI,但已经具备基础棋感:会吃子、会将军、会避免明显送大子,也会让兵卒过河往前压。

九、为什么不一开始就上极大极小搜索

中国象棋的分支数比较高,直接做深层搜索会遇到性能和剪枝问题。更稳的路线是:

  1. 先把走法生成写正确。
  2. 再写一层评分 AI。
  3. 然后引入 minimaxnegamax
  4. 最后加 alpha-beta 剪枝、置换表和开局库。

本次页面把第 1 步和第 2 步做好,后续可以在 chooseAiMove 上继续升级。

十、页面交互

React 层只做三件事:

  1. 记录棋盘、回合、模式、历史和状态。
  2. 点击棋子时调用 isLegalMove 判断。
  3. 人机模式下轮到黑方时调用 chooseAiMove

人机触发逻辑很轻:

if (mode === 'ai' && turn === 'black') {
const aiMove = chooseAiMove(board, 'black')
playMove(aiMove, 'ai')
}

棋谱导出则把每一步的起点、终点、棋子和吃子信息保存成 JSON,方便以后扩展复盘模式。

总结

中国象棋网页游戏的难点不在棋盘 UI,而在规则引擎:

  1. 每个棋子先独立校验自身走法。
  2. 走法通过后必须试走并检查不能送将。
  3. 将军检测通过“对方是否能攻击到将帅”实现。
  4. AI 先从合法走法生成开始,再做评估函数。
  5. 未来增强 AI 时,继续在合法走法和评估函数之上叠搜索即可。

这套结构清晰、可维护,也很适合作为后续做更强棋类 AI 的基础。