简介:Android中国象棋项目源码包,面向有一定Android基础、想通过完整实例学习游戏开发的读者,也可用于课程设计或毕业设计参考。压缩包共111个文件、约4.44MB,包含Java源码、XML布局、PNG棋盘棋子素材、MP3音效、编译后的class文件以及可直接安装的APK,结构完整、导入Android Studio即可查看运行。源码覆盖界面搭建、棋盘绘制、走子规则、人机AI与打谱功能等模块,核心类如ChessActivity、GameView、GuiZe等逻辑清晰,配合资源文件可快速理解Android应用从界面到业务的开发流程。同时,包内附带的PNG图片和MP3音频覆盖了棋子、棋盘及操作音效,适合直接替换素材进行界面定制;APK可快速安装体验,方便对照代码调试。目前已有285人学习下载,适合需要参考完整棋类项目来掌握自定义View、事件处理与基础AI算法的开发者。
1. 这套Android中国象棋源码,先拆成“引擎+界面”两层看
在GitHub或Gitee上搜“Android中国象棋源码”,结果大多是课程设计和毕业设计遗留物:MainActivity里new一个大对象,棋盘画在一个自定义View里,AI则是最常见的那版alpha-beta。这套源码真正的价值不在安卓本身,而在于它把两类难题打包在了一起:一类是象棋逻辑(棋子怎么动、怎么吃、怎么判将军),另一类是Android工程问题(棋盘怎么画、触摸怎么判、搜索线程怎么不卡界面)。很多人下载后跑起来棋能下,但换个手机就错位、AI一步卡死、手指点了没反应——这些坑几乎都集中在坐标换算和线程隔离上。这篇从源码结构一路拆到参数与调试,适合交课设、想研究象棋AI,或者准备把这套老代码迁移到新版Android Studio的人。
2. 棋盘表示与走法生成:源码里最先要读的两个类
拿到源码别急着开MainActivity。任何一个能完整下完一盘棋的象棋项目,最先要读的都是棋盘类和走法生成类。老源码里它们往往叫ChessBoard与MoveGenerator,或者干脆把走法生成写在Board.genMoves()里。这两个类决定了后续AI搜索的效率和棋规是否正确,也决定了你能不能在源码基础上加新功能——比如变体规则、残局摆盘。
2.1 棋盘数据结构:为什么是int[90]而不是二维数组
常见做法是给棋盘开一个int[90]的一维数组,行优先编号:棋盘第row行第col列对应index = row * 9 + col,用0到89表示整个10x9棋盘。为什么不用int[10][9]?因为在搜索过程中每次makeMove/unmakeMove都要复制局面或恢复局面,一维数组用System.arraycopy拷贝一次也就360字节,而且计算索引比二维数组少一次解引用,对缓存更友好。
public class Board { public static final int ROWS = 10; public static final int COLS = 9; private final int[] squares = new int[90]; private int sideToMove; // 0 红,1 黑 private int ply; // 已走步数,将死时算“早死早好”用 public int index(int row, int col) { return row * COLS + col; } public void setPiece(int row, int col, int piece) { squares[index(row, col)] = piece; } public int getPiece(int row, int col) { return squares[index(row, col)]; } }棋子编码建议用对称结构,空位为0,红方1到7(帅仕相马车炮兵),黑方9到15(将士象马车炮卒),8号位空出来不用。这样判断敌我只要看两枚棋子是否分居1到7和9到15两侧,不需要临时换算符号,也方便后面做zobrist哈希。ply这个字段常见但容易被忽略,它参与将死局面的分数计算,让AI优先走“更快将死对方”的变例。
| 编码 | 含义 | 编码 | 含义 |
|---|---|---|---|
| 0 | 空位 | 8 | 保留 |
| 1 | 红帅 | 9 | 黑将 |
| 2 | 红仕 | 10 | 黑士 |
| 3 | 红相 | 11 | 黑象 |
| 4 | 红马 | 12 | 黑马 |
| 5 | 红车 | 13 | 黑车 |
| 6 | 红炮 | 14 | 黑炮 |
| 7 | 红兵 | 15 | 黑卒 |
2.2 走法生成:方向增量表与棋子分类
走法生成的代码风格最能看出源码作者水平。差的写法是七种棋子各写一个函数,每个函数里全是if else;好一点的写法是方向增量表和步长上限组合,用一套滑动循环覆盖车、炮、兵三类棋子。
private static final int[] ROW_STEP = {-1, 1, 0, 0}; private static final int[] COL_STEP = {0, 0, -1, 1}; void generateRookMoves(int r, int c, int side, List<Move> moves) { for (int d = 0; d < 4; d++) { int nr = r + ROW_STEP[d]; int nc = c + COL_STEP[d]; while (inBoard(nr, nc)) { int target = squares[index(nr, nc)]; if (target == 0) { moves.add(new Move(r, c, nr, nc)); } else { if (isEnemy(target, side)) { moves.add(new Move(r, c, nr, nc, target)); } break; // 碰到棋子就停,车不能越子 } nr += ROW_STEP[d]; nc += COL_STEP[d]; } } }方向数组的四个元素分别表示上、下、左、右,inBoard统一做行列越界检查。这段代码把“空位走、敌子吃、自家子挡”三种情况合并进一个循环,每遇到一个非空棋子就终止当前方向的延伸,逻辑上和车的行为完全一致。
2.2.1 车炮共用滑动生成,炮吃子要翻山
炮和车的走法高度重合,唯一区别是炮不能吃紧邻的目标,必须隔一个“炮架”。实现时可以让炮先走“空步”循环到第一个棋子之前,再跳过这个炮架,继续向后找第一个可吃目标。
void generateCannonMoves(int r, int c, int side, List<Move> moves) { for (int d = 0; d < 4; d++) { int nr = r + ROW_STEP[d]; int nc = c + COL_STEP[d]; while (inBoard(nr, nc) && squares[index(nr, nc)] == 0) { moves.add(new Move(r, c, nr, nc)); // 炮平移,和车一样 nr += ROW_STEP[d]; nc += COL_STEP[d]; } if (!inBoard(nr, nc)) continue; // 该方向到头了 nr += ROW_STEP[d]; // 跳过炮架 nc += COL_STEP[d]; while (inBoard(nr, nc) && squares[index(nr, nc)] == 0) { nr += ROW_STEP[d]; // 炮架后面还有空位,继续找 nc += COL_STEP[d]; } if (inBoard(nr, nc) && isEnemy(squares[index(nr, nc)], side)) { moves.add(new Move(r, c, nr, nc, squares[index(nr, nc)])); } } }注意炮的生成分两段循环:第一段把炮架之前的所有空位都算成平移走法,第二段在越过炮架后只找一个目标。Move类的第5个参数记录被吃棋子,方便搜索阶段做元帅的“送将”合法性检查。
2.2.2 马腿、象眼与将帅照面
马腿是所有走法生成器出错率最高的地方,因为腿的偏移量和马跳的方向不是一套坐标。马的8个落脚点有8个对应的绊脚点,可以独立建表。
private static final int[] HORSE_ROW = {-2, -2, -1, 1, 2, 2, 1, -1}; private static final int[] HORSE_COL = {-1, 1, 2, 2, 1, -1, -2, -2}; private static final int[] LEG_ROW = {-1, -1, 0, 0, 1, 1, 0, 0}; private static final int[] LEG_COL = {0, 0, 1, 1, 0, 0, -1, -1}; void generateHorseMoves(int r, int c, int side, List<Move> moves) { for (int i = 0; i < 8; i++) { int nr = r + HORSE_ROW[i]; int nc = c + HORSE_COL[i]; if (!inBoard(nr, nc)) continue; int legR = r + LEG_ROW[i]; int legC = c + LEG_COL[i]; if (squares[index(legR, legC)] != 0) continue; // 蹩马腿 int target = squares[index(nr, nc)]; if (target == 0 || isEnemy(target, side)) { moves.add(new Move(r, c, nr, nc, target)); } } }LEG表里存的是“从马当前位置到绊脚点的偏移”,和HORSE表的下标一一对应,查表时务必同步遍历。象眼就是田字中心,偏移量取马步的一半,即(HORSE_ROW[i] / 2, HORSE_COL[i] / 2),但象不能过河,生成后要额外判断落点是否在己方半场。
将帅照面比较隐蔽:如果两将在同一列且中间没有遮挡,红帅可以直接“飞”到黑将所在格吃子。标准做法是在生成将帅走法时检查同列是否可见对方将,能看见就生成一条吃将走法。漏掉这一条,AI经常在明显赢棋的局面里不主动吃将,用户会误以为程序坏了。
2.3 perft测试:验证走法生成有没有写错
合上源码,先写一个perft计数器。perft是象棋引擎的标准自检手段:从初始局面出发,递归生成所有走法并统计叶子节点数量。中国象棋初始局面的深度1走法总数是44(红方所有棋子的合法走法数),深度2是1920。这两个数字在你的生成器里能对上,说明马腿、象眼、炮架和兵的过河逻辑基本正确。
long perft(Board board, int depth) { if (depth == 0) return 1; List<Move> moves = new ArrayList<>(); board.generateAllMoves(moves); if (depth == 1) return moves.size(); long nodes = 0; for (Move mv : moves) { board.makeMove(mv); if (!board.inCheck(opposite(board.sideToMove()))) { nodes += perft(board, depth - 1); } board.unmakeMove(mv); } return nodes; }这里的inCheck过滤的是“走完这一步后,我方老将正被将军”的非法局面(送将)。perft跑挂时,排查顺序建议固定为:先检查兵卒过河方向,再检查马腿和象眼,然后看炮的隔子判定,最后才怀疑将帅照面,因为越独立的逻辑越容易被一眼看穿。
3. 象棋AI引擎:评估函数、alpha-beta搜索与三个加速参数
象棋AI的骨架是一个带剪枝的深度优先搜索,剪枝的前提是有一个能给局面打分的评估函数。源码里这两块通常被封装成Evaluator和SearchEngine两个类。很多课程设计只写搜索不写评估,导致AI只会随机走子或机械吃子,用户玩两局就删了。这一章先把评估函数讲到能用的程度,再给一份可以直接嵌入工程的alpha-beta代码,最后附三个性价比最高的加速参数。
3.1 评估函数:分值表、位置表与机动性
评估函数输出的分数是“红方视角”,正数代表红优,负数代表黑优,搜索层取反后自然适配红黑双方。最简单的估值由三部分相加:子力价值差、位置价值差、机动性差。
| 棋子 | 子力参考分 | 说明 |
|---|---|---|
| 兵/卒 | 100 | 过河后位置分显著上升 |
| 士/仕 | 450 | 值略高于相,因为能护帅 |
| 象/相 | 450 | 不过河,残局性能下降 |
| 马/傌 | 600 | 有位置分波动,开局别冒进 |
| 炮/砲 | 900 | 开局强于马,残局弱于马 |
| 车/俥 | 1200 | 棋盘上最活跃的大子 |
| 将/帅 | 10000 | 被吃即输,必须远超其他 |
位置表是评估函数的第二块拼图。下面的简化表以红方马为例,表示马位于某格时的加分,黑方使用时把row镜像翻转即可。
private static final int[] HORSE_POS = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 15, 25, 30, 25, 15, 0, 0, 0, 0, 20, 45, 55, 45, 20, 0, 0 }; public int evaluate(int side) { int score = 0; for (int i = 0; i < 90; i++) { int piece = squares[i]; if (piece == 0) continue; int row = i / COLS, col = i % COLS; int val = PIECE_VALUE[normalize(piece)] + POSITION_VALUE[normalize(piece)][row][col]; score += isRed(piece) ? val : -val; } return side == RED ? score : -score; }这段代码把所有棋子的子力分和位置分直接累加,红方加、黑方减,返回前再按走子方取一次符号。POSITION_VALUE建议用int[7][10][9],7对应7种棋子,初始局面把将帅放在任何位置都是0分,其余棋子按经验填。最后加一条轻量机动性规则:生成全部走法后,统计己方合法走法数和对方合法走法数之差,乘一个小系数(比如5)计入总分。这样AI会倾向走出“让对手子力受压”的棋,而不是简单贪吃。
3.2 走法搜索:negamax框架下的alpha-beta剪枝
搜索框架好不好用,看它怎么处理两个细节:alpha-beta的传参顺序,以及“走后被将军”的非法走法。象棋的negamax实现里,每层返回的是轮到走棋一方的分数,所以递归时传-beta, -alpha,返回值取负,这是最常见的实现,抄的时候别顺手写成minimax的正负号混搭。
int alphaBeta(int depth, int alpha, int beta, int side) { List<Move> moves = new ArrayList<>(); board.generateAllMoves(moves); if (moves.isEmpty()) { return side == RED ? -(MATE_VALUE - board.ply) : (MATE_VALUE - board.ply); } orderMoves(moves); // 吃子走法优先 int best = -INFINITY; for (Move mv : moves) { board.makeMove(mv); if (board.inCheck(side)) { // 走后老将被将军,非法 board.unmakeMove(mv); continue; } int score = -alphaBeta(depth - 1, -beta, -alpha, opposite(side)); board.unmakeMove(mv); best = Math.max(best, score); alpha = Math.max(alpha, score); if (alpha >= beta) break; // 剪枝 } return best; }MATE_VALUE设成一个远大于任何评估分的大数,比如100000,减去board.ply是为了让AI在多种将死方式里选择最快的那一条。orderMoves是剪枝效果的关键:先走吃子着法,特别是有吃车、吃炮可能的着法先搜索,能明显提高beta截断概率。inCheck(side)的实现在源码里通常是扫描对方的车、炮、马、兵、将是否能攻击到己方老将位置,这个检测本身就是个小型走法生成器,效率直接决定搜索速度。
3.3 两个立刻见效的加速手段:历史启发与置换表
搜索深度不够是象棋AI“弱”的主要原因。给alpha-beta加上历史启发是成本最低的加速:每次剪枝发生时,把导致剪枝的走法记入历史表,后续走法排序时优先尝试历史分数高的走法。
private void orderMoves(List<Move> moves) { moves.sort((a, b) -> { int sa = history[a.from][a.to] + captureScore(a); int sb = history[b.from][b.to] + captureScore(b); return sb - sa; }); } private int captureScore(Move mv) { if (mv.captured == 0) return 0; return PIECE_VALUE[normalize(mv.captured)] * 8 - PIECE_VALUE[normalize(mv.piece)]; }captureScore这个排序公式就是MVV-LVA:优先吃价值大的子,同样吃子时用价值小的子去吃,防止AI拿车换马这种亏本交换。历史表是一个int[90][90]的二维数组,每次剪枝时给对应走法加一个固定值,比如depth * depth,让深层剪枝的走法获得更高权重。
置换表是第二个大加速,核心是64位zobrist哈希。每枚棋子在每个位置都有独立的64位随机数,加上走子方随机数,make/unmake时用异或增量更新,哈希碰撞概率可以忽略。表项保存上一步搜索的深度和分数,进入搜索时先查表,满足深度和边界条件就直接复用。
class TTEntry { long hash; int depth, score, flag; // 0精确 1下界 2上界 } TTEntry probe(int depth, int alpha, int beta) { TTEntry e = table[(int) (hash & TABLE_MASK)]; if (e == null || e.hash != hash || e.depth < depth) return null; if (e.flag == 0) return e; if (e.flag == 1 && e.score >= beta) return e; if (e.flag == 2 && e.score <= alpha) return e; return null; }TABLE_MASK配合2的幂次表大小使用,比如table = new TTEntry[1 << 20],这样索引可以用位运算完成。写置换表时最容易犯的错是忽略hash校验,只凭索引命中就直接返回分数,后果是AI在相同局面下偶尔走出完全不同的棋,且很难复现。表项少于64K时命中率会明显下降,建议至少开1M项。
4. Android界面层:棋盘View绘制、手势交互与线程隔离
象棋源码里UI部分最常见的写法是一个继承自View的ChessBoardView,它负责三件事:画棋盘、画棋子、接收触摸输入。另一个不可见但极重要的角色是搜索线程调度——这部分写不好,AI思考时界面会卡到系统弹ANR对话框。
4.1 棋盘View的绘制顺序与安全区计算
onDraw的绘制顺序从底到顶依次是:背景色、网格线、九宫斜线、炮兵标记点、棋子。坐标换算的第一步是算出每个格子的边长,同时要处理安全和留边:棋盘是9列10行,长宽比接近1:1.1,直接用整个View宽高会导致棋子被切边,正确做法是按比例居中。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float cell = Math.min((getWidth() - padding * 2) / 8f, (getHeight() - padding * 2) / 9f); startX = (getWidth() - cell * 8) / 2f; startY = (getHeight() - cell * 9) / 2f; Paint line = new Paint(Paint.ANTI_ALIAS_FLAG); line.setColor(0xFF6B4A2F); line.setStrokeWidth(dp(1)); for (int i = 0; i < 10; i++) { canvas.drawLine(startX, startY + i * cell, startX + 8 * cell, startY + i * cell, line); } for (int j = 0; j < 9; j++) { if (j == 0 || j == 8) { canvas.drawLine(startX + j * cell, startY, startX + j * cell, startY + 9 * cell, line); } else { canvas.drawLine(startX + j * cell, startY, startX + j * cell, startY + 4 * cell, line); canvas.drawLine(startX + j * cell, startY + 5 * cell, startX + j * cell, startY + 9 * cell, line); } } drawPalaceLines(canvas, line, cell); drawStaticMarks(canvas, cell); // 炮位、兵位小十字 drawPieces(canvas, cell); }startX和startY是棋盘左上角的坐标,后续触摸事件反算行列时要用同一组值,否则点和棋盘错位。绘制竖线时把中间第5行到第6行断开,这是楚河汉界的视觉特征,也是新手复制代码时最容易漏掉的地方。棋子绘制建议用两段:先画一个带边框的圆,再画汉字,字体大小设为cell * 0.6f左右比较协调。
| 换算目标 | 公式 | 注意 |
|---|---|---|
| View坐标转行列 | col = round((x - startX) / cell) | 四舍五入,不要用floor |
| 行列转View中心 | cx = startX + col * cell | 画棋子用中心点 |
| 棋盘内判定 | row in [0,9] and col in [0,8] | 超出直接忽略 |
| 落子合法性 | 目标格 ∈ 当前选中子的合法走法 | 触摸不改规则,只查表 |
4.2 触摸事件:选中、高亮与落子的四段式处理
象棋触摸交互比五子棋麻烦,因为它有“选中”和“落子”两个状态:先点一个己方棋子,高亮它的合法走法,再点目标位置完成走子;如果点的是另一个己方棋子,则切换选中对象。推荐在onTouchEvent里分ACTION_DOWN和ACTION_UP两次处理,中间不做任何逻辑,防止滑动误触。
@Override public boolean onTouchEvent(MotionEvent event) { if (aiThinking) return true; // AI思考中锁输入 if (event.getAction() == MotionEvent.ACTION_UP) { int col = Math.round((event.getX() - startX) / cell); int row = Math.round((event.getY() - startY) / cell); if (!isInsideBoard(row, col)) return true; int piece = board.getPiece(row, col); if (selectedPiece == 0) { if (piece != 0 && isPlayerSide(piece)) { selectedRow = row; selectedCol = col; legalMoves = moveGenerator.movesFrom(row, col); invalidate(); } } else { handleDrop(row, col); // 吃子、落子或换选 } } return true; }handleDrop内部要做三步:先查目标格是否在当前选中子的合法走法集合里,是就走子并切换轮到AI;不是且目标格是己方子,则重新选中并刷新高亮;都不是就当误触忽略。高亮渲染在onDraw的drawPieces之前执行,用半透明绿色小圆点画在合法走法集合的每个目标格中心,这样玩家能看到“能往哪走”。这里务必复用第2章的生成器结果,不要在UI层再写一遍规则判断。
4.3 AI搜索线程与主线程隔离:别把引擎跑在onDraw里
搜索必须放在后台线程。讲一个我亲眼见过的翻车写法:有人图省事直接在onTouchEvent里调用searchBestMove,结果AI思考时整个界面冻结,Android Studio的Logcat里刷出一屏“Skipped 60 frames!”或直接ANR。标准做法是单线程Executor + 回调主线程更新棋盘。
private final ExecutorService aiExecutor = Executors.newSingleThreadExecutor(); private void requestAiMove() { aiThinking = true; progressBar.setVisibility(View.VISIBLE); // android进度条提示玩家等待 aiExecutor.execute(() -> { Move best = aiEngine.searchBestMove(maxDepth); runOnUiThread(() -> { aiThinking = false; progressBar.setVisibility(View.GONE); boardView.applyMove(best); }); }); }这段代码里有三个容易被忽略的约束:搜索线程和UI线程不能同时读写Board对象,否则置换表和走法列表会错乱;aiExecutor避免每次走子都新建线程,单线程天然串行化所有搜索请求;runOnUiThread保证棋盘更新发生在主线程。如果源码里用的是new Thread().start(),建议改成这种写法,顺带解决连续点击导致的并发搜索问题。
5. Android象棋的AI难度分级与日志验证开关
源码自带的AI往往只有“固定深度搜索”一个档位,玩家要么被虐要么觉得AI太菜。用三个参数就能在不动搜索引擎骨架的情况下做出可感知的难度差异:搜索深度、随机扰动、评估裁剪。最后再加一个日志开关,用来确认AI到底是在真思考还是在乱走。
5.1 难度分级:深度、随机扰动与评估裁剪
| 难度 | 搜索深度 | 随机扰动 | 备注 |
|---|---|---|---|
| 入门 | 1 | 前3个候选随机 | 会吃子但经常送子 |
| 普通 | 2 | 关闭 | 能守住基本子力 |
| 困难 | 5 | 关闭 | 分支少时自动加深 |
随机扰动实现起来最简单:在搜索返回最佳走法时,不是直接取分数最高的那条,而是先按分数排序,然后从前N条里随机选一条,N随难度调大。这样入门难度不会被对手摸透套路,玩起来反而比每步都吃最大子的“贪心AI”更有意思。评估裁剪是指当局面剩余子力总量低于某个阈值(比如只剩两车两炮一将)时,把搜索深度加1到2层,残局分支少,多搜几层的时间开销远小于中局。
有个细节值得注意:难度分级不要只改深度,否则入门难度会表现为“每步都贪吃”,中级难度一旦走到优势局面就各种乱走。配合随机扰动后,低难度AI的行为更接近真人水平,观感上更像在下棋而不是在送子。
5.2 打开引擎日志,判断AI是不是在“瞎走”
给SearchEngine加一个可开关的日志入口,搜索结束后打印关键统计,这是判断AI质量最快的手段。
long startTime = System.currentTimeMillis(); int bestScore = search(rootDepth, -INFINITY, INFINITY, side); long elapsed = System.currentTimeMillis() - startTime; int pruneRate = (int) ((cutCount * 100L) / max(1, nodeCount)); Log.d("XQEngine", String.format("depth=%d score=%d nodes=%d time=%dms prune=%d%%", rootDepth, bestScore, nodeCount, elapsed, pruneRate));日志怎么看:第一,同一局面重复搜索,分数波动应该在一个兵值(约100分)以内,波动超过300分说明置换表或评估函数有bug;第二,剪枝率低于20%说明走法排序效果太差,优先检查MVV-LVA的排序公式;第三,nodes/ms过低时要怀疑评估函数里创建了大量临时对象,触发GC拖慢搜索。把这些参数打印出来后,再配合Android Studio的调试器在alphaBeta入口加条件断点,就可以单步追踪某个具体局面下AI的选子逻辑,排查速度比盲调快很多。
本文还有配套的精品资源,点击获取