ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Android中国象棋源码解析:从棋盘绘制到AI对弈

Android中国象棋源码解析:从棋盘绘制到AI对弈

简介:这款Android中国象棋完整源码采用Java编写,包含全部代码和图片资源,遵循Activity、Service等Android基本组件规范,面向游戏开发初学者与进阶者,覆盖棋盘绘制、走子规则、触摸交互、胜负判断与AI对弈的完整流程,适合课程设计、毕业设计或实战练手。压缩包共110个文件,大小仅4.47MB,其中66个png提供棋子、背景等图像素材,10个java源文件承载主要逻辑,另有可直接安装的Chess.apk、编译后的class字节码、xml配置和3个mp3音效,目录结构清晰,便于按模块研读。已有523人学习下载,说明其具备不错的参考热度。结合ChessActivity、GameView、GuiZe等关键类,可深入理解自定义View绘图、MotionEvent事件处理以及象棋走子规则与胜负判断逻辑。项目还包含欢迎界面、帮助界面与教程线程,读者能借鉴小型游戏的场景切换、资源组织及基础AI搜索剪枝思路,并结合apk体验实际运行效果。

1. 为什么建议直接拿这份 Android 中国象棋源码而不是从零画棋盘

做第一个象棋 App 的时候,我把大部分时间花在“把棋盘画出来”而不是“把棋下对”上,后来翻到这份 Android 中国象棋源码,发现棋盘绘制、棋子实体、走法校验甚至简单的电脑对手都已经是完整闭环。它解决的不是“有没有棋盘贴图”,而是“为什么我的兵过河不能横走、马被蹩腿不报错、AI 走一步卡三秒”这一串真问题。适合两类人:一类是做 Android 游戏练手,想看懂自定义 View 与状态机怎么配合;另一类是课程设计或毕设要做象棋对弈,需要一个能跑、能改、能答辩的底子。以下是拆包过程中比较关键的部分,按从易到难的顺序讲。

2. 源码模块拆解:棋盘绘制、棋子实体与着法生成

2.1 棋盘绘制:用自定义 View 画九宫格而不是切图

拿到源码先看 View 层。这份资源里棋盘不是一张 JPG,而是直接在自定义 View 的 onDraw 里用 Paint 画出来的。好处是棋盘尺寸随屏幕宽高缩放,不需要为不同分辨率准备多套切图;坏处是坐标换算必须认真看,否则红黑双方会上下颠倒。

棋盘本质上是一个 10×9 的网格,横线 10 条、竖线 9 条,交叉点是落子位置。源码里通常用两个坐标数组或直接从 View 宽高算出:

@Override protected void onDraw(Canvas canvas) { // 棋盘四周边距,格子大小由宽度决定 float margin = 20f; float cellSize = (getWidth() - margin * 2) / 8f; Paint paint = new Paint(); paint.setColor(Color.BLACK); paint.setStrokeWidth(2f); // 画横线 for (int row = 0; row < 9; row++) { canvas.drawLine(margin, margin + row * cellSize, margin + 8 * cellSize, margin + row * cellSize, paint); } // 画竖线,左右边界要留出河界开口 for (int col = 0; col < 9; col++) { canvas.drawLine(margin + col * cellSize, margin, margin + col * cellSize, margin + 8 * cellSize, paint); } // 九宫斜线按 row/col 判断后补 drawPalace(canvas, margin, cellSize); }

这段代码做的是把所有交叉点绘制成棋盘网格。cellSize 由 View 宽度除以 8 得到,所以棋盘会跟随屏幕宽度等比例缩放;你如果遇到横屏崩溃,通常就是这里没有写死宽高比。九宫斜线只需要在左边 3 列和右边 3 列画两条斜线,坐标直接用 (0,0)-(2,2) 这类相对位置换算,不需要单独算表。

如果源码里用的是 ImageButton 或 Button 当棋子,我建议你替换成自定义 View 加 onTouchEvent 命中检测。Button 棋子数量多,事件分发容易错位,而且每次移动都要重新布局,棋盘越复杂越难维护。自定义 View 里只需在 onTouchEvent 中根据落点换算行列:x / cellSize、y / cellSize,命中哪个交叉点就选中哪颗棋子,逻辑干净很多。

棋子绘制一般复用同一个 Paint:先画一个带底色圆形,再画汉字。源码里常见的是用 Typeface 或者直接 drawText。这块逻辑不复杂,但注意象棋汉字不是 Unicode 排列的,最好用数组维护“车马炮将士相帅”的顺序。

2.2 棋子建模:常量表与棋子对象的职责

看完整份源码后你会发现,棋子建模是整个程序的地基。类型用常量,阵营用标记,坐标用棋盘数组下标。我建议你先把这份常量表抄到自己的项目里,后续所有逻辑都依赖它。

常量名值说明
TYPE_ROOK0车
TYPE_HORSE1马
TYPE_CANNON2炮
TYPE_ELEPHANT3相/象
TYPE_GUARD4仕/士
TYPE_KING5帅/将
TYPE_PAWN6兵/卒

棋子对象一般长这样:

public class ChessPiece { public int id; // 棋子唯一编号 public int type; // 上面常量表里的类型 public int camp; // 0 红方,1 黑方 public int x, y; // 当前在棋盘数组中的坐标 public boolean dead; // 是否已被吃 }

这个类没有放绘制相关字段,意味着它只负责“棋子是什么、在哪”,负责“怎么画”的是棋盘 View。职责分离之后,走法校验、AI 搜索都只需要操作 ChessPiece,不需要碰任何 UI 代码。这个设计也是这份源码值得复用的原因之一。如果你拿到的是把棋子也做成了 ImageView 的版本,改造成 AI 会非常痛苦。

2.3 着法生成与合法性校验:源码里最值钱的一段

着法生成,简单说就是把当前某个棋子所有能去的位置全部算出来。车和炮的走法都是沿直线扫描,区别在于炮吃子必须隔一个棋子。车的扫描代码直接看:

private List<Move> scanLine(int[][] board, int x, int y, int dx, int dy, int camp) { List<Move> moves = new ArrayList<>(); int nx = x + dx, ny = y + dy; while (nx >= 0 && nx < 10 && ny >= 0 && ny < 9) { int target = board[nx][ny]; if (target == 0) { moves.add(new Move(x, y, nx, ny)); } else if (isOpponent(target, camp)) { moves.add(new Move(x, y, nx, ny)); // 吃子 break; } else { break; // 自己人挡住 } nx += dx; ny += dy; } return moves; }

dx、dy 是方向向量,四个方向分别传 (1,0)、(-1,0)、(0,1)、(0,-1)。board 里 0 表示空位,正负号表示阵营。炮的代码要麻烦一点,需要标记是否已经越过炮架:

private List<Move> scanCannon(int[][] board, int x, int y, int dx, int dy, int camp) { List<Move> moves = new ArrayList<>(); int nx = x + dx, ny = y + dy; boolean jumped = false; while (nx >= 0 && nx < 10 && ny >= 0 && ny < 9) { int target = board[nx][ny]; if (target == 0) { if (!jumped) moves.add(new Move(x, y, nx, ny)); } else { if (!jumped) { jumped = true; // 第一个棋子作为炮架 } else { if (isOpponent(target, camp)) moves.add(new Move(x, y, nx, ny)); break; } } nx += dx; ny += dy; } return moves; }

这段代码把炮的过子逻辑拆成了两段:没遇炮架前只能走空位,遇到第一个炮架后标记 jumped,往后遇到的第一个对方棋子才能吃,遇到自己人或再遇到障碍就停止。炮最容易写错的是把“隔一个子吃”理解成“隔任意多个子吃”,对照这段代码逐行检查就能定位。

马、象、士、将这些棋子的走法更简单,但校验的坑都在“阻挡”上。马走日要检查蹩马腿,象走田要检查塞象眼,将帅只能走直线一步且不能出九宫。这些规则如果你在 copy 代码时漏掉其中一条,在界面上表现为“这步棋能走但实际不能走”,用户会直接判你程序写错。

合法性校验还有最后一层:走完这一步之后,己方将/帅不能被将军。所以源码里的 checkLegal 通常会这样做:先临时走子,再调一次 isChecked(camp),如果自己被将军就撤销并判定非法。这一步往往被初学者省略,结果就是 AI 能把自己老将送到对面嘴里。

3. 规则逻辑怎么落地:坐标体系、吃子判定与三个隐蔽角落

3.1 棋盘存储:二维数组比对象列表更适合做规则校验

源码里 board[][] 用什么类型,决定你后面所有算法怎么写。这份源码用的是二维 int 数组,10 行 9 列,0 代表空位,正数代表红方棋子编号,负数代表黑方棋子编号。好处是查找目标位置 O(1),坏处是每次移动棋子,board 和 ChessPiece 的坐标要同步更新,而这两个地方是两套数据。

初始化棋局时,可以直接用一个 String 数组描述初始局面,解析时按字符映射到棋子类型,这样以后摆残局用例也方便:

private String[] initialBoard = { "rheakaehr", // 黑方底线 "......... ", " c c ", // 炮 " p p p p p", // 卒 "......... ", "......... ", " P P P P P", " C C ", "......... ", "RHEAKAEHR" // 红方底线 };

每个字符对应一个棋子:R 红车、H 红马、E 红相、A 红仕、K 红帅、C 红炮、P 红兵,小写为黑方。解析时逐字符生成 ChessPiece,同时写入 board 数组。这么做比硬编码几十个棋子位置更直观,也比较容易做棋谱复盘和单元测试。

3.2 吃子、照将与胜负判定

吃子逻辑很简单:目标位置如果是对方棋子,从活跃列表里移除或标记 dead=true,然后 board 更新为当前棋子编号。需要留心的是,真人对弈时,同一个棋子对象可能被吃一次之后又重新摆上(复盘或悔棋),所以 dead 标记比直接置 null 更安全。

胜负判定核心是 isKingCaptured():判断对方阵营里是否存在 KING 类型的存活棋子。大多数源码都会在 makeMove 之后立刻检查这个条件,如果老将被吃直接弹胜利提示。另一个容易漏掉的是“将帅照面”,也就是双方老将之间没有棋子阻挡时,轮到谁走都不能让老将自动见面。

private boolean isKingFace() { ChessPiece redKing = findKing(RED); ChessPiece blackKing = findKing(BLACK); if (redKing.y != blackKing.y) return false; // 不在同一列 int col = redKing.y; int count = 0; for (int row = redKing.x - 1; row > blackKing.x; row--) { if (board[row][col] != 0) count++; } return count == 0; }

这里的判断基于红王在上、黑王在下且同列。正规写法是从红王出发向上扫,遇到的第一个非空棋子如果正好是黑王,就说明照面。count 判断在残局里更直观,但要注意循环方向与两王上下位置一致,别把 row 的起始和终止写反。

3.3 三个最容易写错的细节:过河、塞象眼、蹩马腿

第一,兵的走法不是永远向前。红方兵在跨过河界之前只能前进,过了河之后才能横移,而且不能后退。很多人会把“过了河可以左右走”写成“可以上下左右走”,结果兵出现在老将旁边。源码里判断过河,红方看 row 是否小于 5,黑方看 row 是否大于 4,写一个 isCrossedRiver(camp, row) 方法单独管理。

第二,象走田的“田”字中心是唯一障碍。它和炮架不同,炮架不管距离多远只要隔一个子就能吃,象的中心点是固定的四个方向斜两格。这里建议用偏移表处理:把四个方向的 dx、dy 和中心点的 dx/2、dy/2 放在同一个数组里,写代码时不容易漏:

private int[][] elephantMove = { {2, 2, 1, 1}, // 右下 {2, -2, 1, -1}, // 左下 {-2, 2, -1, 1}, // 右上 {-2, -2, -1, -1} // 左上 };

数组中前两个是目标位置偏移,后两个是象眼检查位置。遍历时先检查象眼和棋盘的边界,再检查目标位置是否可走。红方象不能越河,黑方同理,这个限制通常直接加在目标位置的 row 范围里。

第三,马的蹩马腿方向是“马步起点到终点之间那条短边”。同样是偏移表,马走八个方向,每个方向要判断的是起点旁边的那个格。比如向右上走,要检查起点右边一格;向左上走,要检查起点左边一格。如果不按偏移表写,改成大量 if-else,几乎必然出现某个方向判断错误。

这三个细节恰好在 AI 搜索时会被反复触发。因为电脑每走一步都会生成所有合法着法,任何一个规则错误都会让 AI 走出违反规则的棋。你可以把它想象成数据结构里的旋转细节,直接决定你的引擎好不好使。

4. 人机AI的实现思路:搜索框架、评估函数与难度参数

4.1 极小极大搜索的工程化写法

电脑下棋的基本框架是搜索树:假设当前轮到红方,红方走一步,黑方再走一步,双方都选择对自己最有利的走法,最终倒推回当前局面。源码里常见的是 negamax 变体,因为象棋是零和游戏,红方得分就是黑方失分。实现如下:

private int alphaBeta(int depth, int alpha, int beta, int camp) { if (depth == 0) { return evaluateBoard(); } List<Move> moves = generateLegalMoves(camp); if (moves.isEmpty()) { return camp == RED ? MATE : -MATE; } for (Move move : moves) { makeMove(move); int score = -alphaBeta(depth - 1, -beta, -alpha, 1 - camp); unmakeMove(move); alpha = Math.max(alpha, score); if (alpha >= beta) { break; // 剪枝,不再继续搜这条分支 } } return alpha; }

这段代码的关键在负号递归:当前层返回的分数,对上一层来说要取反,因为黑白双方立场相反。alpha、beta 是窗口,随着搜索不断收紧,一旦 alpha >= beta 就说明当前分支已经无望,直接剪掉可以节省大量时间。建议把 MATE 设成 100000 或 99999,避免评估值把将死判断冲掉。

我一般还会维护一张置换表(transposition table)把已搜索局面的分数缓存起来,这份源码里未必有,但如果你发现 AI 在残局阶段反复搜索相同局面,加一层 HashMap 缓存能明显提速。注意缓存键要用“棋子编号与棋盘数组整体 hashcode”,不能只存棋局字符串,否则 hash 碰撞会丢正确性。

影响剪枝效率的另一个细节是着法排序。把吃子、将军这类强着排在前面,剪枝会提前命中。可以简单按目标位置是否有棋子排序:

moves.sort((a, b) -> { boolean aAttack = board[a.toX][a.toY] != 0; boolean bAttack = board[b.toX][b.toY] != 0; return aAttack == bAttack ? 0 : (aAttack ? -1 : 1); });

这段排序代码在进入 alphaBeta 循环前执行一次。吃子着法优先搜索,会让更好的走法更早抬高 alpha 下限,后续分支剪得更快。不需要做复杂的历史启发,这一步就足以让深度 3 的搜索在手机端达到可接受速度。

4.2 评估函数:基础价值加位置修正

搜索到叶子节点之后,需要给局面打一个分数。最朴素的评估是“我方棋子总值 - 对方棋子总值”。以下是源码里常见的基础价值表:

棋子基础价值说明
帅/将10000不能被吃
车900直线长程子
炮450隔子打击
马400八面威风但会被别腿
兵/卒200过河后可加 100~150
相/象110防守子
仕/士110九宫防守

纯价值表不够,因为同一个棋子在不同位置的价值天差地别。源码里通常会给车、马、炮附加“位置价值表”:比如马在边角比在中路差很多,兵过河之后位置价值上涨。评估函数大致是:

private int evaluateBoard() { int score = 0; for (ChessPiece piece : pieceList) { if (piece.dead) continue; int base = BASE_VALUE[piece.type]; int posDelta = POS_VALUE[piece.type][piece.x][piece.y]; int value = piece.camp == RED ? base + posDelta : -(base + posDelta); score += value; } return score; }

如果源码里没有位置表,你可以用一个 10×9 的 int 二维数组去描述每个棋子的加分区域,比如把“兵过河后的行”和“马的中路区域”单独加分。这个表不用做得很精确,AI 级别从“乱走”到“看出来会吃子”的提升是立竿见影的。

4.3 调难度看哪里:搜索深度、随机扰动和耗时控制

源码的 AI 难度通常只看一个参数:搜索深度。深度 1 等于电脑只看自己走一步,基本是瞎走;深度 3 已经能看懂吃子与基本防守;深度 4 以上才有可能走出战术组合。但深度不是无脑上调的:深度每加 1,节点数量大约乘以 30~40,手机端如果不开剪枝优化,深度 5 就可能卡到 10 秒。

我一般把难度做成三档:

难度深度附加行为
简单2评估函数加入随机抖动 ± 30
中等3无随机抖动
困难4启用置换表缓存

在 Android 上,AI 计算不能放在主线程,否则点击棋子后界面直接卡死。常见做法是单独的 Thread 跑 alphaBeta,算完之后通过 handler.post 回主线程更新棋盘。如果你拿到的源码没处理这一步,真机上一走 AI 就 ANR,那是线程问题不是搜索问题,别把锅甩给算法。

5. 编译运行与排查避坑:把源码跑起来的一线记录

5.1 环境准备:SDK 与 Gradle 版本对齐

源码适配的环境是 Android Studio + JDK,导入时第一个坑是 Gradle 版本。老项目用老 Gradle,新版 Android Studio 打开会提示升级,我一般建议先按源码里的 gradle-wrapper.properties 版本走,不要一上来就升到最新。JDK 版本不匹配会报“Unsupported class file major version”,解决办法是切换项目的 Java SDK 为 11 或 17,视 Gradle 要求而定。

先把 local.properties 里 sdk.dir 配置正确再试编译:

# 以本机实际 SDK 路径为准 sdk.dir=/Users/你的用户名/Library/Android/sdk

如果源码里工程文件缺少 gradle-wrapper.jar,先不要手动下载,优先用 Android Studio 打开后让它补全依赖。手工下载容易把 Gradle 版本弄混,导致编译链不一致。

5.2 真机调试:从安装到走完一步棋

编译通过后,先别急着看 AI 下棋。建议按下面的顺序验证规则层:先双人模式,红车吃黑马,重点看“吃子后 dead 标记和 board 数组是否都更新”;再找一个兵走到对方底线,确认不能后退;最后红黑对调方位,看看黑方棋子是从下往上走还是从上往下走。这套流程走完,再开人机模式。如果双人模式都走不对,后面的 AI 搜索必然全盘错乱。

真机运行时注意日志过滤:

adb logcat | grep -E "AndroidRuntime|chess|Move"

关键是抓两个崩溃点:一个在触摸事件触发 makeMove 后产生的空指针,另一个在 AI 计算线程里访问 View 更新导致的 CalledFromWrongThreadException。前者通常来自棋子对象未初始化,后者需要把 UI 更新统一切回主线程。

5.3 避坑记录:五条值得抄进笔记的问题

现象一:编译报“SDK location not found”。原因:项目根目录没有 local.properties,或者里面路径失效。解决:手工创建 local.properties 并写入本机 sdk.dir。

现象二:模拟器正常,真机棋盘左边溢出。原因:cellSize 用 px 计算后未考虑屏幕宽度变化。解决:棋盘整体宽度改为 min(可用宽度, 可用高度),高度方向留黑边。

现象三:点击棋子后 App 崩溃,Log 显示数组越界。原因:棋子坐标已经越界,board 数组的 nx、ny 没有做越界检查。解决:在 scanLine 进入 while 之前先检查 x、y 合法,并给每个方向扫描加上边界判断。这个错最容易出现在马和象的偏移表里,方向数是 8 和 4,少写一个方向反而会让棋子凭空消失。

现象四:AI 走完一步后棋盘不刷新。原因:makeMove 后调用 invalidate() 的位置放在了子线程。解决:所有 UI 刷新集中在主线程,搜索线程只负责返回最佳 Move。

现象五:AI 把老将往对方嘴里送。原因:合法性校验里漏了“走子后是否被将军”,生成着法时只查了静态规则。解决:makeMove 之后临时回退,检查 isChecked,如果自己被将军,这着不算合法。

这五条几乎覆盖了从环境到规则再到线程的全部常见翻车点。尤其是最后一个,排查起来最费时间,因为你单看某个棋子的走法时,它看起来永远是对的,只有组合走到老将旁边才会暴露。

6. 进阶改造:给源码加悔棋栈与双档难度

拿到源码能跑起来只是第一步。真正让这份 Android 中国象棋源码变成“自己的作品”,建议做两件最小改造:悔棋和历史步法记录。这两个功能正好能检验你对 makeMove 和棋盘数据同步的理解是否到位。

6.1 用栈记录历史走法

在 makeMove 里把每一步压入栈,悔棋时弹出并回退棋盘数组。

private Stack<StepRecord> history = new Stack<>(); private void makeMove(Move move, int camp) { int captured = board[move.toX][move.toY]; board[move.toX][move.toY] = board[move.fromX][move.fromY]; board[move.fromX][move.fromY] = 0; history.push(new StepRecord(move, captured, camp)); } private boolean undoLastMove() { if (history.isEmpty()) return false; StepRecord record = history.pop(); board[record.move.fromX][record.move.fromY] = board[record.move.toX][record.move.toY]; board[record.move.toX][record.move.toY] = record.captured; // 别忘了同步 ChessPiece 的 x、y 坐标 return true; }

注意悔棋只回退棋盘数组是不够的,ChessPiece 的 x、y 如果不同步,AI 下次搜索时读到的棋子位置与实际画面不一致。我见过不少改造项目在这里把“棋盘数组”和“棋子对象”彻底拆成了两套数据,悔棋后棋盘画面正常,AI 一思考立刻东一步西一步。

6.2 把 AI 拆成异步入口

发现源码还能改造成双档难度,核心是把搜索深度抽象成参数,由界面开关控制,并且把搜索过程包进线程。

private void startAiThink(int depth) { new Thread(() -> { Move best = searchBestMove(depth, currentCamp); mainHandler.post(() -> applyMove(best)); }).start(); }

最小改动是新建一个 Thread,不要在 main 线程里直接调用 alphaBeta。参数 depth 可以在界面做两个按钮,分别传 2 和 4 就行。这样改完,在真机上跑“简单档”和“困难档”,体会会很明显。

6.3 验证改造是否破坏了原规则

改造完之后,我会强制自己按一个固定顺序验证:先双人模式连续走 20 步常规开局,看是否有棋子消失;再走一个“兵过河横移”,确认过河判定没被悔棋逻辑破坏;最后人机对弈下三步然后悔棋,确认棋盘数组、棋子对象、UI 三者一致。这一套走完才算没把源码改出新 bug。如果你需要这份能跑的底子,直接按项目标题去资源站搜就行,整理包里的棋盘与棋子实体都有注释,能省掉不少时间。

从那以后,我每次接手这类带规则引擎的 Android 源码,都会强制把“棋盘数组、棋子对象、UI 刷新”三者的同步关系写在纸上,再开始改代码。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表