Android中国象棋源码:规则引擎与Alpha-Beta搜索落地
简介:这份 Android 中国象棋源码基于 Java 与 Android Studio 构建,面向希望借助完整可运行项目入门移动游戏开发的初学者与进阶者,覆盖棋盘绘制、棋子走子规则、触摸事件响应、胜负判定与 AI 对弈等核心环节,可用于课程设计、自学练手或二次开发参考。压缩包为 rar 格式,共 110 个文件、约 4.47MB,其中 66 个 png 提供棋子与棋盘图像素材,10 个 java 与 20 个 class 构成游戏主体逻辑,另有 3 个 mp3 音效、3 个 xml 布局配置及 2 个 db 数据文件,代码与资源分层清晰。目前已有 523 人学习。读者可从中理解自定义 View 配合 Canvas 的界面绘制思路、MotionEvent 的落子交互处理、按棋种拆分规则类的实现方式,以及异步加载、图片缓存与硬件加速等优化手段,也能对照测试用例验证游戏逻辑。
1. 一份 Android 中国象棋源码,真正值钱的是规则引擎那一层
很多人拿到 Android 中国象棋源码,第一反应是跑起来看界面像不像样:棋盘好不好看、棋子圆不圆、点下去有没有音效。我的习惯反过来,先翻规则类在哪个包里。画棋盘、画棋子是最没门槛的部分,Canvas 画十几条线、三十二个圆,一天能写完;真正决定这份源码能不能用、能不能改成别的棋类、能不能接 AI 的,是走法生成、胜负判定和搜索这三块。Android 中国象棋这个场景有个特殊性:棋盘只有 90 个交叉点,状态空间比国际象棋小一个量级,但规则里的“蹩马腿”“塞象眼”“炮翻山”“将帅照面”全是非标准几何约束,写成 if-else 很容易漏。下面按一份典型工程的真实结构,从渲染走到规则,再走到 AI 和排错,能照着改。
2. 工程分层与棋盘渲染:从 onDraw 的三段式绘制说起
一份能长期维护的棋类工程,分层不会太花哨,但边界必须清楚。我见过的大部分 Android 中国象棋源码是四五层:ui(Activity、对话框)、view(自定义 BoardView)、model(棋盘数组、棋子常量、走法对象)、rule(走法生成、合法性校验)、ai(评估与搜索)。差一点的版本会把规则塞进 BoardView 的onTouchEvent里,这类代码能跑,但你一动 AI 就得大改。判断一份源码值不值得留,看rule包是不是纯 Java、有没有import android.*,没有就是好底子。
2.1 数据表示:一维数组还是二维数组
棋盘状态最常见的两种存法,一种int[10][9],一种int[90]。二维数组可读性好,board[row][col]一眼知道在哪儿;一维数组在搜索里更快,因为拷贝一个int[90]比拷贝十个小数组少一层指针跳转,还能直接System.arraycopy。我在改这类源码时,习惯把内部表示统一成一维,外层包一个Board类提供get(row, col),用起来跟二维没区别,但搜索时省事。
// Board.java —— 内部一维存储,外部按行列访问,搜索时可直接 System.arraycopy public final class Board { public static final int ROWS = 10, COLS = 9, SIZE = ROWS * COLS; private final int[] cells = new int[SIZE]; public int get(int row, int col) { return cells[row * COLS + col]; } public void set(int row, int col, int piece) { cells[row * COLS + col] = piece; } public Board copy() { Board b = new Board(); System.arraycopy(cells, 0, b.cells, 0, SIZE); return b; } }这里的关键是row * COLS + col这个映射必须全工程一致,一旦某处写成col * ROWS + row,症状是棋子整体转置,而且只有部分棋子表现异常,极难查。参数上ROWS=10、COLS=9是固定的,别做成可配置,象棋没有变体棋盘。
2.2 坐标系换算:9 列 10 行对应的是 8 格 9 格
这是新手翻车率最高的一处。棋盘有 9 条竖线、10 条横线,交叉点 90 个,但相邻线之间的“格子”只有 8 列、9 行。你按 9 和 10 去算格宽,画出来的棋盘会比屏幕宽一格,右边直接被裁掉;按 8 和 9 算,才刚好塞满。另外象棋棋盘是长方形(8:9),手机屏幕也是长方形,但比例不匹配,所以必须取min(cellW, cellH)强制正方形格子,再把棋盘整体居中,否则棋子会被拉成椭圆。
// BoardView.java —— 几何参数只在尺寸变化时算一次,绘制和触摸都复用 private int mCell; // 格子边长(像素),横竖相同 private int mOriginX; // 左上角交叉点的屏幕 x private int mOriginY; // 左上角交叉点的屏幕 y private float mPieceR; // 棋子半径 @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); int pad = dp2px(8); int cellW = (w - pad * 2) / 8; // 8 格宽,不是 9 int cellH = (h - pad * 2) / 9; // 9 格高,不是 10 mCell = Math.min(cellW, cellH); // 锁死正方形,防止棋子变扁 mOriginX = (w - mCell * 8) / 2; // 棋盘水平居中 mOriginY = (h - mCell * 9) / 2; // 棋盘垂直居中 mPieceR = mCell * 0.45f; // 留 10% 间隙,棋子不粘连 }mCell取min之后,棋盘在长边方向会多出空白,这是正常的,不要为了填满去拉伸。mPieceR用 0.45 而不是 0.5,是因为 0.5 时相邻棋子边界正好相切,配上 2px 描边会糊成一团。
坐标反算就是x = mOriginX + col * mCell、y = mOriginY + row * mCell。反过来col = round((x - mOriginX) / mCell)。这里必须用round不能用(int)强转,强转是向下取整,会让交叉点右下方半个格子内的点击全部算到上一个点,表现为“点左边一格才生效”。
2.3 onDraw 的三段式顺序与重绘触发点
棋盘绘制有个顺序依赖:先画底纹和木纹背景,再画线,最后画棋子。顺序反了棋子会被线压住,看着像破图。河界“楚河汉界”四个字和九宫斜线要在线之后、棋子之前画,不然文字会在棋子上面飘。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawBackground(canvas); // 1. 底色 + 木纹 drawGrid(canvas); // 2. 10 横 9 竖 + 九宫斜线 + 兵炮位十字标记 drawRiverText(canvas); // 3. 楚河汉界 drawPieces(canvas); // 4. 棋子必须最后画 drawLastMoveHint(canvas); // 5. 上一步的起止点高亮框 }drawPieces里别每次都new Paint()、new RectF(),这些对象在onDraw里创建会触发 GC,落子动画一多就卡顿丢帧。标准做法是在构造函数里初始化好mPiecePaint、mTextPaint、mRect,绘制时只改颜色和文字。棋子上的字用Paint.setTextAlign(Align.CENTER)配合Paint.FontMetrics垂直居中,比手写y + textSize/3稳,换字体也不跑偏。
重绘时机只有两个:走子之后和悔棋之后,都调invalidate()。别在onTouchEvent的ACTION_MOVE里调,手指一滑就重绘几十次。如果工程里有落子动画(棋子平移 150ms),用ValueAnimator更新一个mAnimOffset,在onDraw里给正在移动的棋子加偏移,Animator结束再落数组,这样视觉和状态分离,悔棋时不会和动画打架。
提示:
onSizeChanged里算出来的mCell一定要存成员变量,不要在onDraw里重算,否则每次重绘多两次除法,虽然不多,但棋盘加棋子三十多个元素,累积起来在低端机上能看出掉帧。
3. 落子与规则校验:从触摸事件到走法表的完整链路
渲染是皮肤,规则是骨架。这一步写错,后面 AI 再强都是错的——AI 搜索完全依赖走法生成,走法生成漏一个“蹩马腿”,AI 就会走出马穿墙的棋,用户第一眼就发现。所以规则这块我建议先写纯逻辑、跑通单测,再接 View。
3.1 ACTION_UP 反算交叉点与误触阈值
触摸处理有两个决定:用ACTION_DOWN还是ACTION_UP,要不要做误触判定。用DOWN的问题是手指按下瞬间就落子,用户拖动查看棋盘时容易误走;用UP更符合“点选—确认”两段式交互,也支持“先点自己的子、再点目标点”的经典操作。误触判定是算点击位置到最近交叉点的距离,超过半个格子就当没点。
@Override public boolean onTouchEvent(MotionEvent e) { if (e.getAction() != MotionEvent.ACTION_UP) return true; // 只认抬手 int col = Math.round((e.getX() - mOriginX) / mCell); // round,不是强转 int row = Math.round((e.getY() - mOriginY) / mCell); if (col < 0 || col > 8 || row < 0 || row > 9) return true; // 棋盘外,忽略 // 误触阈值:偏离交叉点超过半格视为无效 float dx = e.getX() - (mOriginX + col * mCell); float dy = e.getY() - (mOriginY + row * mCell); if (dx * dx + dy * dy > (mCell * 0.5f) * (mCell * 0.5f)) return true; handleTap(row, col); return true; }getX()拿的是相对于本 View 的坐标,如果 BoardView 外面还套了ScrollView或带 padding 的容器,必须用getX()而不是getRawX(),后者是屏幕绝对坐标,一减mOriginX就整体偏移。这个坑很典型:在原生 Activity 里测试正常,换到ConstraintLayout加个padding就永远点不中。
handleTap里是两态状态机:mSelected == null时尝试选中自己的子;否则判断目标点是否可走。可走就执行、入栈、切换行棋方、触发 AI;不可走就重选或取消选中。状态机一定要有“点空白处取消选中”的分支,不然用户点错了没有退路。
3.2 七种棋子的走法生成与合法性过滤
走法判定拆成两层:第一层是“几何上能不能走到”,第二层是“走完之后自己会不会被将”。很多轻量实现只做第一层,结果允许送将、允许将帅照面,AI 一搜就出鬼棋。
几何层按棋子类型分派,用switch比一堆if清晰。下面是最容易写错的三个:车、炮、马。
// Rule.java —— 纯几何判定,不修改棋盘 static boolean checkChe(int[] b, int fr, int fc, int tr, int tc) { if (fr != tr && fc != tc) return false; // 车只能走直线 if (fr == tr) { int step = tc > fc ? 1 : -1; for (int c = fc + step; c != tc; c += step) if (b[fr * 9 + c] != 0) return false; // 路径有子即挡 return true; } int step = tr > fr ? 1 : -1; for (int r = fr + step; r != tr; r += step) if (b[r * 9 + fc] != 0) return false; return true; } static boolean checkPao(int[] b, int fr, int fc, int tr, int tc) { if (fr != tr && fc != tc) return false; int count = 0; // 炮架数量 if (fr == tr) { int step = tc > fc ? 1 : -1; for (int c = fc + step; c != tc; c += step) if (b[fr * 9 + c] != 0) count++; } else { int step = tr > fr ? 1 : -1; for (int r = fr + step; r != tr; r += step) if (b[r * 9 + fc] != 0) count++; } // 目标为空:只能平移,中间不能有子;目标有子:必须正好一个炮架 return b[tr * 9 + tc] == 0 ? count == 0 : count == 1; } static boolean checkMa(int[] b, int fr, int fc, int tr, int tc) { int dr = tr - fr, dc = tc - fc; if (Math.abs(dr) == 2 && Math.abs(dc) == 1) return b[(fr + dr / 2) * 9 + fc] == 0; // 马腿在纵向中点 if (Math.abs(dr) == 1 && Math.abs(dc) == 2) return b[fr * 9 + (fc + dc / 2)] == 0; // 马腿在横向中点 return false; }炮的判定是“数中间有几个子”而不是“路径是否为空”,这是炮和车的本质区别,写成车那套就变成炮不能吃子。马的蹩腿是看起点到终点方向上的第一个邻格,不是看终点附近,(fr + dr/2, fc)这个位置算错一格,马就能斜穿。
象的判定除了|dr|==2 && |dc|==2,还要加两条:象眼(fr+dr/2, fc+dc/2)必须为空;不能过河。红方 row 从 9 往下减,tr < 5就是过河了。士和将都有九宫限制,红方九宫是 row 7~9、col 3~5,黑方是 row 0~2、col 3~5,把这两个矩形写成常量数组比写四个不等式好维护。
第二层的合法性过滤本质是“试走 + 撤销”:在数组上落子、判断己方将是否被攻击、再恢复。
// 试走一步,检查己方是否被将军,用于过滤送将走法 static boolean isLegal(int[] b, Move m, boolean redTurn) { int captured = b[m.toRow * 9 + m.toCol]; b[m.toRow * 9 + m.toCol] = b[m.fromRow * 9 + m.fromCol]; b[m.fromRow * 9 + m.fromCol] = 0; boolean ok = !isKingAttacked(b, redTurn) && !isKingFacing(b); b[m.fromRow * 9 + m.fromCol] = b[m.toRow * 9 + m.toCol]; b[m.toRow * 9 + m.toCol] = captured; // 必须还原被吃子 return ok; }isKingFacing就是“将帅照面”规则:两个将帅在同一列且中间无子,属于非法局面。这条特别容易被漏掉,因为它不是“谁能吃谁”的问题,而是一个全局约束。漏了之后 AI 会利用它走出“白脸将”直接取胜的棋,人类玩家看了会觉得这程序不讲道理。
3.3 走法栈、悔棋与将军提示
悔棋的实现取决于栈里存了什么。只存from/to是不够的,你还得知道这步吃了什么子、是不是吃子后触发了某种状态。所以Move对象里至少要有fromRow, fromCol, toRow, toCol, capturedPiece, movedPiece五个字段。
public final class Move { public final int fromRow, fromCol, toRow, toCol; public final int movedPiece; // 走的什么子 public final int capturedPiece; // 吃掉什么子,0 表示空 public Move(int fr, int fc, int tr, int tc, int moved, int captured) { ... } }AI 走一步、人类走一步,栈里就是两条记录。悔棋按钮只弹一条的话,人类退回去之后立刻又被 AI 走回来,看起来像“悔棋没生效”。正确做法是一次弹两条(如果栈顶是人类走的那步),并且校验栈里剩余的步数。这是交互细节,不是算法问题,但用户感知极强。
将军提示用同一套isKingAttacked:每次走完检查对方将是否被攻击,是就播提示音、高亮将的位置。判断“将死”还要多一步——把对方所有合法走法生成一遍,如果一条都没有且正在被将军,才是将死;一条都没有但没被将军,是困毙,象棋规则里同样判负。
注意:走法生成函数在搜索里会被调用几十万次,不要在函数内部
new ArrayList之外再new任何对象,走法列表用外部传入的MoveList复用,或者直接把走法编码成一个int(from * 90 + to)放进预先分配的数组里。
4. AI 走法搜索:极大极小 + Alpha-Beta 在手机上怎么落地
规则跑通之后,AI 是最容易写出“看起来能用、实际很蠢”或者“很强但每步卡两秒”的部分。Android 中国象棋的 AI 难点不在算法本身,在于手机 CPU 和搜索深度之间的矛盾:深度 4 的全宽搜索在中低端机上轻松上百毫秒到几百毫秒,深度 6 就得上秒。所以真正能用的方案是“固定时间预算的迭代加深”,深度不是定死的,时间才是。
4.1 评估函数:子力价值加位置分
评估函数决定 AI 的“棋感”。最基础的版本只看子力差,红方所有子的价值减去黑方所有子的价值。这套子力表在各类象棋引擎里取值大同小异,差异主要在兵和炮。
| 棋子 | 常量名 | 基础分值 | 过河/位置加成 | 说明 |
|---|---|---|---|---|
| 将/帅 | JIANG | 10000 | 无 | 被吃即终局,分值只为让搜索收敛 |
| 车 | CHE | 900 | 无 | 最强子力,两条线控制力大 |
| 炮 | PAO | 450 | 中局略高 | 依赖炮架,残局价值下降 |
| 马 | MA | 400 | 中心区域 +30 | 边马价值明显偏低 |
| 士 | SHI | 200 | 无 | 只守九宫 |
| 象 | XIANG | 200 | 无 | 只守本方半场 |
| 兵/卒 | BING | 100 | 过河 +100 | 过河兵价值翻倍是共识 |
只按子力评,AI 会走出“车换马”的亏本棋,因为它不知道位置好坏。加一张位置表(PST)能明显改善:同样是马,在中心(4,4)附近比在边角强。位置表是int[90]常量数组,红黑各一张(黑方那张是红方上下翻转),评估时score += pst[index]。这套东西不需要训练,靠经验值填就行,网上各类开源象棋引擎的 PST 数值可以互相参考。
static int evaluate(int[] b) { int score = 0; for (int i = 0; i < 90; i++) { int p = b[i]; if (p == 0) continue; int type = Math.abs(p); int v = VALUE[type] + PST[type][p > 0 ? i : FLIP[i]]; // 黑方位置表上下翻转 score += p > 0 ? v : -v; // 红正黑负 } return score; }FLIP[i]是预计算好的镜像索引数组,FLIP[row * 9 + col] = (9 - row) * 9 + col。每次评估都临时算9 - row会多出乘法和减法,预处理成常量数组能省一点,搜索几十万节点时这点开销是看得见的。
4.2 Alpha-Beta 骨架与剪枝前提
极大极小加 Alpha-Beta 是标配,但很多人写完发现剪枝没效果,原因通常是两条:没有按“估值排序”生成走法,或者忘了在窗口外提前返回。吃子走法应该优先搜索,因为吃子往往直接改变估值,能更快收紧窗口。
private int alphaBeta(int[] b, int depth, int alpha, int beta, boolean redTurn) { if (depth == 0) return evaluate(b); MoveList list = generateLegalMoves(b, redTurn); if (list.size() == 0) return redTurn ? -MATE + (MAX_DEPTH - depth) : MATE - (MAX_DEPTH - depth); sortByCaptureFirst(list, b); // 吃子优先,剪枝效率翻倍 for (int i = 0; i < list.size(); i++) { Move m = list.get(i); int captured = doMove(b, m); int score = -alphaBeta(b, depth - 1, -beta, -alpha, !redTurn); // 负极大值写法 undoMove(b, m, captured); if (score >= beta) return beta; // 剪枝 if (score > alpha) alpha = score; } return alpha; }负极大值写法(-alphaBeta(..., -beta, -alpha, ...))的好处是红黑两方共用一份代码,不用写两套max和min。容易写错的是-beta, -alpha的顺序,写反了剪枝窗口变成负区间,搜索直接返回垃圾值,表现为 AI 出招莫名其妙。
将死分数的处理要注意:MATE - (MAX_DEPTH - depth)让“更早将死”得分更高,否则 AI 会拖着不杀,在必胜局面下反复绕圈直到超时。这个细节不写,用户会觉得 AI 在戏耍他。
4.3 迭代加深与时间预算控制
手机上的正确做法是迭代加深:从深度 2 开始搜,搜完一层看时间够不够,够就加深一层,超时就用上一层的完整结果。这样无论设备快慢都能保证在预算内出招,而且浅层结果可以作为深层搜索的走法排序依据,越搜越快。
private static final long TIME_BUDGET_MS = 700; // 单步思考上限,留余量给 UI private static final int MAX_DEPTH = 6; public Move think(int[] board, boolean redTurn) { Move best = null; long deadline = System.currentTimeMillis() + TIME_BUDGET_MS; for (int d = 2; d <= MAX_DEPTH; d++) { Move cur = searchRoot(board, d, redTurn, deadline); if (cur == null) break; // 本层超时,丢弃不完整结果 best = cur; // 只在整层搜完后才更新 if (System.currentTimeMillis() > deadline) break; } return best; }两个关键点:best只在整层搜索完成后更新,半层的结果可能因为剪枝不完整而是错的;超时检查不要放在每个节点上,currentTimeMillis()本身有开销,每 2048 个节点查一次就够。
线程模型上必须把搜索放到子线程,主线程只负责收结果后invalidate()。Java 里用HandlerThread加Handler,Kotlin 里用Dispatchers.Default协程,本质一样。搜索期间要禁掉用户的落子输入,否则用户趁 AI 思考时点一下,棋盘状态就乱了。加个mIsAiThinking标志位,onTouchEvent开头直接 return。
提示:置换表(Zobrist 哈希)能把重复局面的搜索成本砍掉一大截,但它对内存敏感。手机上开 1<<16 条(约 2MB)就够,条目里存
hash、depth、score、flag、bestMove,替换策略用“深度优先”而不是简单覆盖,否则浅层结果会把深层结果挤掉。
5. 排查清单:跑不起来、点不准、AI 卡死的五个血泪现场
5.1 棋盘画出来是扁的,棋子压在格子里
现象:竖线间距明显小于横线间距,棋子圆心落在格子中央而不是交叉点上,整体看着像被横向压缩。
原因基本是两个。一是onSizeChanged里用(w - pad*2) / 9算竖线间距、用(h - pad*2) / 10算横线间距,除数取错了一格;二是虽然cellW、cellH都算对了,但没有取min强制相等,导致横竖不等距。棋子落在格子中央,则是坐标反算时写成mOriginX + col * mCell + mCell / 2,多加了半格。
解决:先验证“9 条线 8 个间隔”这条不变式,把cellW和cellH的除数分别打印出来看是不是 8 和 9;再确认mCell = Math.min(cellW, cellH)存在;最后全局搜mCell / 2,凡是出现在坐标计算里的都删掉,棋盘坐标永远是交叉点,不是格子中心。
5.2 点击永远偏一格或永远点不中
现象:明明点在马上,选中的是旁边的炮;或者只有点击交叉点左上方一小块区域才生效。
原因有三种。第一是坐标用了getRawX(),在有 padding 或外层容器的布局里,绝对坐标减去mOriginX会整体偏移一个 padding 的距离。第二是反算用了(int)强转代替Math.round,所有点被截断到左上方向的格子。第三是把mOriginX当成棋盘左边界而不是左上角交叉点,两者差半个格子。
解决:把三者逐个排除。先确认用getX()/getY();再把强转改成round;最后打日志把(col, row)和实际棋子类型一起输出,点三个不同位置看是不是线性偏移,线性偏移就是常量错了,非线性就是mCell错了。
5.3 AI 每走一步卡两秒,还有概率 ANR
现象:AI 思考期间界面完全冻结,按返回键没反应,连续走十几步后弹“应用无响应”。
原因是搜索跑在主线程,且没有时间上限——深度定死为 6,在低端机上单步要两三秒。另外常见的放大器是每层递归都new ArrayList存走法列表,一次搜索分配几十万个小对象,GC 频繁触发,实际耗时比纯计算翻倍。
解决:把搜索整个搬到HandlerThread,主线程只更新 UI;把固定深度改成“迭代加深 + 700ms 时间预算”;走法列表改成预分配的MoveList,内部用int[]装编码后的走法,按需扩容,递归时复用同一个实例池。三步做完,单步耗时通常能降到 300ms 以内。
5.4 悔棋按一次没反应,要按两次才退回去
现象:人类走一步、AI 走一步,点悔棋,棋盘没变化;再点一次,退回两步之前。
原因:走法栈里人类和 AI 的记录一视同仁,悔棋逻辑只弹一条,弹掉的是 AI 那步,界面看不出变化。有些实现还会在悔棋后立刻触发 AI 重新思考,等于白退。
解决:悔棋逻辑先判断栈顶是不是 AI 的走法,如果是,连弹两条;如果栈里只剩人类一步,弹一条。悔棋后必须把状态重置为“等待人类落子”并取消 AI 的待执行任务,同时清掉上一步高亮和选中状态。如果工程里这个类叫mHistory,顺手给它加个size()的断言,退到空栈还继续弹就是空指针。
5.5 马能穿墙、炮能隔空吃、将帅照面不判负
现象:AI 或者人类可以走出明显违规的棋,比如马被自己的兵挡住还能跳过去;炮和目标之间没有任何子却能吃掉对方;两个将帅在同一列对视,程序不判负。
原因:马腿判定取错了格子,写成了终点方向的邻格而不是起点方向的邻格;炮的判定复用了车的“路径为空”逻辑,没有单独数炮架;将帅照面压根没写,因为在很多简化版里这条规则被认为“不影响下棋”。
解决:马腿位置严格是(fr + dr/2, fc)和(fr, fc + dc/2),用单测把八个方向都覆盖一遍;炮抽出独立的checkPao,返回值取决于“目标是空还是有子”,两种情况判定条件不同;照面规则写进isLegal的全局校验里,和“己方是否被将军”并列,任何一步走完都要过这两道检查。
6. 把规则引擎抽成纯 JVM 模块,用 Perft 验走法
前面所有排错里,最难查的永远是走法生成——它不出异常、不崩溃,只是悄悄多生成或少生成几步棋,AI 强弱会因此偏移,但你很难定位。我的做法是把model和rule两个包抽成一个不依赖 Android 的纯 Java 模块,用 JUnit 在电脑上跑,然后用 Perft 做冒烟验证。
Perft 的思路很朴素:从某个局面出发,递归枚举所有合法走法到指定深度,统计叶子节点数。走法生成只要错一处,节点数就对不上参考值。中国象棋初始局面的 perft(1) 是 44,这是一个公认值,可以直接当断言用。
// PerftTest.java —— 不依赖 Android,直接在 JVM 上跑 static long perft(int[] b, int depth, boolean redTurn) { if (depth == 0) return 1; long nodes = 0; MoveList list = MoveGen.generate(b, redTurn); for (int i = 0; i < list.size(); i++) { int mv = list.get(i); int from = mv / 90, to = mv % 90; int captured = b[to]; b[to] = b[from]; b[from] = 0; nodes += perft(b, depth - 1, !redTurn); b[from] = b[to]; b[to] = captured; // 完整还原,包括被吃子 } return nodes; } @Test public void initialPositionHas44Moves() { int[] board = FenParser.parse("rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR r"); assertEquals(44L, perft(board, 1, true)); }走法用from * 90 + to编码成一个int,比Move对象省内存,也方便在数组里排序。断言通过之后,再往 depth 2、3 递进,把每一层的数字记下来,以后每次改动规则代码都重跑一遍——只要数字变了,就说明你改动影响了走法生成,哪怕是“看起来无关”的重构。
移植时这套东西的价值更大。比如想把棋盘换成 Compose 或者做成跨端,只要rule模块的单测还是绿的,渲染层随便换。同理,想加“残局挑战”模式,也就是换一个初始 FEN 字符串,规则和 AI 一行不用改。
从我第一次因为一个蹩马腿写错、被 AI 走出马穿墙的棋开始,我每次拿到一份新的棋类源码,都先把规则模块单独抠出来跑一遍 perft,再动任何 View 代码。这个习惯救过我很多次,因为规则错了,后面调 AI 的所有努力都是在给错误打补丁。希望帮到你。
本文还有配套的精品资源,点击获取