资讯动态

Android中国象棋源码解析:从棋盘绘制到棋规引擎的完整实现

发布时间:2026/10/23 19:39:34 来源:尧图企业网站定制
简介android中国象棋源码是一份包含完整代码与图片资源的游戏工程面向Android初、中级开发者帮助其系统掌握游戏开发中的UI绘制、交互处理与逻辑架构可作为课程设计或个人练手项目参考。压缩包为RAR格式共110个文件约4.47MB涵盖Java源码、XML布局、66张PNG棋子与界面图、MP3音效、数据库文件及可直接安装的APK目录结构清晰便于按模块查阅。已有523人学习/下载。源码实现了基于Canvas的棋盘绘制、全部棋子的走法规则、触摸选棋与移动、胜负判断等核心功能并给出Minimax/Alpha-Beta剪枝的AI对弈思路同时涉及Activity架构、自定义View、资源异步加载、图片缓存及硬件加速等性能优化还包含JUnit单元测试与Espresso UI测试用例。工程中的10个Java源文件分别处理主界面、菜单、帮助、棋盘与规则等模块适合作为Android游戏开发从入门到进阶的实战参考。1. 项目速览一份能跑起来的Android中国象棋源码如果你跟我一样翻过不少android游戏源码就会发现一个尴尬事实很多项目号称“中国象棋”打开一看棋盘是歪的、蹩马腿没写、将死判断靠猜。这份android中国象棋源码是为数不多能把“走子规则—将军检测—胜负判定—悔棋”整条链路闭环的项目界面用自定义View绘制不依赖任何第三方UI库。适合刚学完四大组件、想拿自定义View和状态机练手的开发者也适合做课程设计或毕业设计时直接参考。棋盘是10×9二维数组红黑双方用正负号表示整套逻辑清晰到可以当棋类游戏模板用。2. 工程结构从MainActivity到棋盘View的调用链拿到一份android源码我习惯先不看代码而是把文件树拉出来读一遍。这个项目的结构非常克制没有动辄几十个类的“过度设计”每一层都能一眼看出职责。2.1 目录与职责划分核心源码集中在app/src/main/java目录下划分如下app/src/main/java/com/example/chess/ ├── MainActivity.java // 入口Activity负责初始化引擎与View、处理回调 ├── ChessView.java // 自定义View绘制棋盘网格、文字棋子、选中高亮 ├── ChessEngine.java // 棋规引擎走法判断、将军检测、悔棋栈 ├── Move.java // 一步棋的封装起点、终点、被吃棋子 └── Constant.java // 棋子类型常量、棋盘尺寸、红黑双方标识这个分层的思路值得说一下Activity只做两件事一是创建ChessEngine和ChessView二是把两者“接线”ChessView完全不知道棋规它只负责把int[][]数组画出来把触摸坐标换算成行列回传规则全部收敛在ChessEngine里。这样做的直接好处是如果你想把棋盘换成SurfaceView、或者把引擎换成AI都不用动另外两层。如果项目规模再大一点我一般会在中间加一层Controller或者叫Presenter来管理对局流程但现在这种规模加一层反而是负担。初学者最容易犯的错误是把下棋逻辑写在Activity里结果屏幕旋转一次状态就乱这个项目用引擎持有状态的方式规避掉了。2.2 数组棋盘为什么用int[][]而不是棋子对象很多android象棋项目喜欢给每个棋子建一个类ChessPiece里放id、名字、坐标、颜色然后维护一个List 。这个方案在设计上没问题但在棋类引擎里会有一个致命弱点做“假设性走法”比如检测走这步会不会被将军时需要频繁复制局面对象的拷贝成本远高于二维数组克隆。所以这个项目用了更接近C语言风格的方式一个10×9的int数组0代表空位正数代表红方棋子负数代表黑方棋子绝对值就是棋子类型。public class Constant { public static final int EMPTY 0; public static final int CHE 1; // 车 public static final int MA 2; // 马 public static final int XIANG 3; // 象 public static final int SHI 4; // 士 public static final int JIANG 5; // 将 public static final int PAO 6; // 炮 public static final int BING 7; // 兵 public static final int RED 1; // 正数红方 public static final int BLACK -1; // 负数黑方 public static final int ROWS 10; public static final int COLS 9; }这里有个约定俗成的设计需要理解Math.abs(piece)得到棋子类型piece 0是红方piece 0是黑方。判断“这个子是不是我的”只需一行board[r][c] * currentSide 0其中currentSide用Constant.RED或Constant.BLACK表示当前行棋方。很多新手在这里翻车喜欢用两个Map分别存红方和黑方结果做走法生成时要合并两个数据结构凭空多出一堆bug。正负号方案是我见过的棋类项目里最省心的做法。2.3 MainActivity的生命周期旋转屏幕不丢棋局Android源码里最常见的“能跑但没法用”场景就是一转屏棋局全部复位。这个项目的处理方式是把棋盘数组和悔棋栈都放进EngineActivity在onSaveInstanceState里保存Engine的序列化状态。public class MainActivity extends AppCompatActivity { private ChessEngine engine; private ChessView chessView; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); chessView new ChessView(this); setContentView(chessView); engine new ChessEngine(); if (savedInstanceState ! null) { engine.restoreState(savedInstanceState.getIntArray(board)); } chessView.setEngine(engine); chessView.setOnPieceSelectedListener(new ChessView.OnPieceSelectedListener() { Override public void onPieceSelected(int fromRow, int fromCol) { // 选中棋子后由View回传坐标Activity统一调度 } }); } Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putIntArray(board, engine.getBoardSnapshot()); } }这段代码是典型的“Activity做胶水层”写法。engine.getBoardSnapshot()返回的是int[][]摊平后的一维数组有了它旋转屏幕后引擎和View就能恢复到一模一样的局面。注意一个容易踩的坑View在旋转后也会重新走一遍构造函数和onDraw所以它不能自己保存局面必须由Activity在onCreate里把棋盘数组重新set进去否则就会出现“引擎局面正常、界面还是初始局面”的诡异状态。3. 核心模块拆解棋规引擎的走法生成与将军检测象棋项目的技术含量全部集中在这一个类里。走法合法性判断是棋类引擎的地基地面没打平后面做AI、做残局库都会歪。这一章把ChessEngine拆开讲每一段都是可以抄走的作业。3.1 判断单步走法canMove的完整实现canMove是整个引擎最核心的方法。输入一个起点和终点返回true或false。它在内部按照棋子类型分流每种棋子一套规则public boolean canMove(int fromRow, int fromCol, int toRow, int toCol) { int piece board[fromRow][fromCol]; int target board[toRow][toCol]; if (target * piece 0) return false; // 目标位置是自己人 int type Math.abs(piece); switch (type) { case Constant.CHE: return canCheMove(fromRow, fromCol, toRow, toCol); case Constant.MA: return canMaMove(fromRow, fromCol, toRow, toCol); case Constant.PAO: return canPaoMove(fromRow, fromCol, toRow, toCol); case Constant.XIANG: return canXiangMove(fromRow, fromCol, toRow, toCol); case Constant.SHI: return canShiMove(fromRow, fromCol, toRow, toCol); case Constant.JIANG: return canJiangMove(fromRow, fromCol, toRow, toCol); case Constant.BING: return canBingMove(fromRow, fromCol, toRow, toCol); } return false; }第一个判断target * piece 0利用了正负号设计一句话挡住了“吃自己的子”。接下来分别看两个最典型的实现一个是马一个是炮它们是最容易写错的棋子。private boolean canMaMove(int fromRow, int fromCol, int toRow, int toCol) { int dr Math.abs(toRow - fromRow); int dc Math.abs(toCol - fromCol); if (!(dr dc 3 dr ! 0 dc ! 0)) return false; // 找马腿位置 int legRow fromRow; int legCol fromCol; if (dr 2) legRow fromRow (toRow fromRow ? 1 : -1); if (dc 2) legCol fromCol (toCol fromCol ? 1 : -1); return board[legRow][legCol] Constant.EMPTY; }马的规则是“日”字走法横纵位移分别是2和1但如果有棋子贴在马的前进方向上马就不能动。这段代码先用dr dc 3排除所有非日字走法再根据位移方向算出马腿坐标最后检查马腿是否为空。注意dr 2和dc 2的判断顺序不能反否则横着跳和竖着跳会共用同一个错误马腿。我在第一次写这个函数时把马腿位置固定成了fromRow 1, fromCol 1结果马斜着走也能跳这个血泪经验后面避坑章节还会展开。private boolean canPaoMove(int fromRow, int fromCol, int toRow, int toCol) { int chessesBetween countBetween(fromRow, fromCol, toRow, toCol); if (board[toRow][toCol] Constant.EMPTY) { return chessesBetween 0; // 炮的走法中间无子 } else { return chessesBetween 1; // 炮的吃法中间必须有一个炮架 } } private int countBetween(int r1, int c1, int r2, int c2) { int count 0; if (r1 r2) { int min Math.min(c1, c2), max Math.max(c1, c2); for (int c min 1; c max; c) { if (board[r1][c] ! Constant.EMPTY) count; } } else { int min Math.min(r1, r2), max Math.max(r1, r2); for (int r min 1; r max; r) { if (board[r][c1] ! Constant.EMPTY) count; } } return count; }炮的规则可以一句话概括不走子时中间必须干净吃子时中间必须只有一个炮架。这里把“数中间棋子数”抽成单独方法是为了让canPaoMove的语义一目了然。countBetween只处理两种情况同行或同列因为炮不能斜着走这个前提判断其实应该放在更外层一般会在canMove的公共入口先校验fromRow ! toRow fromCol ! toCol的情况直接返回false。3.2 生成合法走法与局面推演单步判断只能回答“这一步能不能走”但做将军检测和AI搜索时需要的是“当前局面下所有能走的棋”。generateMoves就是干这个的public ListMove generateMoves(boolean redTurn) { ListMove moves new ArrayList(); for (int r 0; r Constant.ROWS; r) { for (int c 0; c Constant.COLS; c) { int piece board[r][c]; if (piece Constant.EMPTY) continue; if ((redTurn piece 0) || (!redTurn piece 0)) { for (int tr 0; tr Constant.ROWS; tr) { for (int tc 0; tc Constant.COLS; tc) { if (canMove(r, c, tr, tc)) { moves.add(new Move(r, c, tr, tc, board[tr][tc])); } } } } } } return moves; }这个实现是O(棋盘面积²)的暴力遍历90个格子嵌套遍历也就8100次判断在手机上跑一遍不到1毫秒完全不需要优化。Move对象里记录了被吃棋子board[tr][tc]这个字段在后面悔棋时至关重要因为撤回一步棋必须把“被吃的子”放回原位。如果你打算后续做AIgenerateMoves的结果可以直接喂给搜索算法不需要额外转换。3.3 将军检测与将死判定将军检测是棋类引擎最容易“糊弄”的地方。常见做法是在走子后检查双方的将是否见面但标准的规则是走完一步棋后如果对方的将处于被攻击状态就算将军。public boolean isInCheck(boolean redTurn) { // 找到当前被攻击的将 int kingRow -1, kingCol -1; for (int r 0; r Constant.ROWS; r) { for (int c 0; c Constant.COLS; c) { int piece board[r][c]; if (redTurn piece Constant.JIANG) { kingRow r; kingCol c; } if (!redTurn piece -Constant.JIANG) { kingRow r; kingCol c; } } } // 看对方所有棋子是否能吃到这个将 for (int r 0; r Constant.ROWS; r) { for (int c 0; c Constant.COLS; c) { int piece board[r][c]; if (piece Constant.EMPTY) continue; boolean isOpponent redTurn ? piece 0 : piece 0; if (isOpponent canMoveTo(r, c, kingRow, kingCol)) { return true; } } } return false; }这里的核心技巧是“借力”检测将军时不是写一套专门的攻击检测逻辑而是遍历对方所有棋子逐个调用canMove看能不能吃到将。由于canMove已经写好了蹩马腿、炮架、塞象眼这些细节isInCheck的实现就只剩20行。这是整个引擎里“复用”体现得最漂亮的地方也是我推荐你保留的核心设计。将死判断稍微绕一点当前方走完一步后如果对方的将处于被将军状态且对方没有任何一步合法的应对走法那就是将死。对应的代码是把对方所有可能的走法逐个模拟一遍只要有一种走法能让自己脱离将军状态就没有死。3.4 悔棋为什么用栈悔棋是这个项目里最简单也最容易被忽略的功能。实现方式是维护一个StackMove走子时压栈悔棋时弹栈public void applyMove(Move move) { history.push(move); board[move.toRow][move.toCol] board[move.fromRow][move.fromCol]; board[move.fromRow][move.fromCol] Constant.EMPTY; } public boolean undoMove() { if (history.isEmpty()) return false; Move move history.pop(); board[move.fromRow][move.fromCol] board[move.toRow][move.toCol]; board[move.toRow][move.toCol] move.capturedPiece; return true; }undoMove恢复被吃棋子时用到的就是Move里保存的capturedPiece字段这就解释了为什么Move要设计成“起点、终点、被吃棋子”三个字段而不是只有两个坐标。这个栈结构还可以延伸出“复盘”功能后面第六章会说到。4. 棋盘绘制与对局控制View刷新与坐标换算如果说引擎是项目的“大脑”那ChessView就是“脸面”。这个View没有用任何图片资源棋盘网格全部是drawLine画出来的棋子全部是drawText画的中文字整个绘制代码不到300行但视觉效果一点不差。这一章讲清楚View的绘制流程和触摸控制。4.1 尺寸计算把棋盘画正的关键自定义View最怕的就是不同屏幕上棋盘变形。这个项目采用了“取短边”策略在onMeasure阶段强制让View的宽高相等并把边长定为宽高中较小的一个Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width MeasureSpec.getSize(widthMeasureSpec); int height MeasureSpec.getSize(heightMeasureSpec); int size Math.min(width, height); setMeasuredDimension(size, size); }这段代码解决了横竖屏切换时棋盘被拉伸的问题。由于象棋棋盘在View内要留出边距给棋子完整绘制我一般会让实际网格区域比View尺寸小两个棋子直径也就是用一个padding变量来约束。所有后续的坐标计算都以这个padding为基准而不是直接用View宽度除以8这样最直观。4.2 onDraw绘制棋盘网格与九宫斜线绘制部分分为两层底层画棋盘上层画棋子。先看棋盘Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); Paint linePaint new Paint(); linePaint.setColor(0xFF8B4513); linePaint.setStrokeWidth(2f); linePaint.setAntiAlias(true); float cellSize (getWidth() - 2 * padding) / 8f; // 画10条横线 for (int r 0; r 10; r) { float y padding r * cellSize; canvas.drawLine(padding, y, getWidth() - padding, y, linePaint); } // 画9条竖线中间河界处断开 for (int c 0; c 9; c) { float x padding c * cellSize; canvas.drawLine(x, padding, x, getHeight() - padding, linePaint); } // 九宫斜线 canvas.drawLine(padding 3 * cellSize, padding, padding 5 * cellSize, padding 2 * cellSize, linePaint); // ... 另外三条斜线同理 }这里有一个细节河界中间的竖线不画。标准棋盘上第0行到第4行和第5行到第9行之间的竖线是不连续的中间有一条河界留白。如果偷懒直接拉一条长竖线懂棋的人一眼就能看出来不专业。绘制顺序也有讲究先画网格再画棋子否则棋子会被网格线盖住边缘。棋子绘制我用的是文字方案棋子本身画实心圆上面覆盖中文字private void drawPiece(Canvas canvas, int piece, int row, int col) { float cx padding col * cellSize; float cy padding row * cellSize; Paint piecePaint new Paint(); piecePaint.setAntiAlias(true); piecePaint.setColor(piece 0 ? 0xFFFFE0B2 : 0xFFD7CCC8); canvas.drawCircle(cx, cy, cellSize * 0.4f, piecePaint); Paint textPaint new Paint(); textPaint.setAntiAlias(true); textPaint.setTextSize(cellSize * 0.5f); textPaint.setTextAlign(Paint.Align.CENTER); textPaint.setColor(0xFF000000); String text pieceToText(piece); Paint.FontMetrics fm textPaint.getFontMetrics(); float baseline cy - (fm.descent fm.ascent) / 2; canvas.drawText(text, cx, baseline, textPaint); }pieceToText方法做一个switch映射把1到7的整数值映射成“车马象士将炮兵”。用文字替代图片的好处有两个一是apk体积小不需要assets目录二是换肤容易改两行颜色配置就能做一套新皮肤。Paint.FontMetrics那几行是为了让文字在圆里垂直居中这是canvas.drawText最常用的居中技巧值得背下来。4.3 触摸事件坐标换算与选中逻辑触摸处理是这个View里最容易出bug的地方错误几乎都出在坐标换算。用户在屏幕上点击的point是像素坐标需要先减去padding偏移再除以cellSize取整才能得到棋盘数组下标。Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() ! MotionEvent.ACTION_UP) return true; float x event.getX(); float y event.getY(); int col (int) ((x - padding) / cellSize); int row (int) ((y - padding) / cellSize); if (row 0 || row 10 || col 0 || col 9) return true; if (selectedRow -1) { // 第一次点击选中棋子 if (engine.getPiece(row, col) ! 0) { selectedRow row; selectedCol col; invalidate(); } } else { // 第二次点击尝试走子 boolean success engine.move(selectedRow, selectedCol, row, col); if (!success) { selectedRow -1; selectedCol -1; } invalidate(); if (listener ! null) { listener.onMoveComplete(engine); } } return true; }注意我专门做了边界检查如果手指点在padding区域外也就是棋盘边缘(x - padding) / cellSize可能计算出一个负数如果不拦截就会数组越界。我之前就翻车过——用户快速滑动屏幕时坐标落在了棋盘左侧的padding上Android直接抛ArrayIndexOutOfBoundsException。这个防护代码不能省。4.4 View与Activity的通信回调接口解耦ChessView里定义了一个回调接口把“走子完成”这个事件抛给Activity。这里有一个精选的设计决策View不直接操作engine而是通过listener通知外部由外部统一处理胜负判断和轮转换。这样View保持了纯净以后如果要在走子后加音效、加动画、加网络同步都只需要在listener实现里加代码不需要动View。public interface OnMoveCompleteListener { void onMoveComplete(ChessEngine engine); }Activity收到回调后做的第一件事是检查胜负。如果引擎返回将死状态就弹一个Dialog否则切换当前行棋方。这个流程把“视图-逻辑-状态”三者分得很清楚也是新手最容易学到的架构习惯。5. 运行与避坑跑通项目时最常见的五个坑代码读完了下一步是把它跑起来。这一章先讲导入步骤然后把我拆这个项目时踩过的坑和常见问题集中写出来。这些坑基本覆盖了你下载别人源码后最常遇到的崩溃和错位问题。5.1 用Android Studio导入并运行打开Android Studio选择Open定位到项目根目录的build.gradle。等待Gradle同步完成后先不要急着点Run检查两处配置# 项目级build.gradle里检查Gradle版本与AS版本是否匹配 # app/build.gradle里检查minSdk与targetSdk我建议把minSdk设为21targetSdk设为当前AS默认支持的版本。如果你本机的Gradle版本和项目里配置的不一致Android Studio会提示升级或降级选“OK”让它自动处理一般不会出问题。连接真机运行或者创建AVD模拟器都行。需要注意如果模拟器是x86架构的ARM镜像加载会比较慢但这类纯绘制的棋类项目没有性能压力跑起来没问题。运行成功后你会看到一个10×9的棋盘点击红方棋子选中再点击目标位置走子双方轮流操作走完一步自动轮换。这就是一个完整的双人对弈棋盘了。5.2 五个高频踩坑记录坑1棋盘画出来整体偏移棋子像“悬”在格子外面现象网格和棋子的位置对不上越靠近边缘偏差越大。原因onDraw里网格绘制用的坐标和drawPiece计算坐标各自维护了一套padding与cellSize两处数值不一致比如绘制棋盘时cellSize用的是View宽度除以8而绘制棋子时又用了一次不同公式。解决把cellSize、padding定义成View成员变量在onSizeChanged里一次性初始化所有绘制方法都只用这套变量。坑2旋转屏幕后棋局变成了初始局面现象转一下手机刚摆好的棋子全部复位。原因Activity没有在onSaveInstanceState里保存棋盘状态或者保存了但onCreate里没有恢复。解决按2.3节代码把engine里的棋盘int数组存入BundleonCreate时读取并set回引擎。还有一个隐蔽点ChessView在旋转后会重新走构造方法需要在View新建后立刻调用view.setEngine(engine)顺序反了的话View会画一次空棋盘界面闪一下。坑3马能直接跳过棋子飞到对面现象马前面明明顶着一个兵点马再点兵后面的位置居然走过去了。原因蹩马腿的判断写错了方向马腿坐标是沿着马前进的方向不是马的旁边。解决参照3.1节的canMaMove实现先判断dr和dc哪个为2然后按toRow fromRow ? 1 : -1计算马腿位置再检查board[legRow][legCol]是否为空。调试时可以打印日志故意在马的路径上放一个子验证返回false。坑4Android 12及以上安装后闪退日志报SecurityException现象真机点图标就崩溃Logcat里看到SecurityException相关错误或者提示不允许启动该Activity。原因targetSdk高版本要求显式声明exported属性更常见的坑是全新的android源码从低版本升级上来时MainActivity没配launcher类别。解决在AndroidManifest.xml的MainActivity节点加android:exportedtrue并检查intent-filter里的MAIN和LAUNCHER两行还在不在。这类问题跟代码逻辑无关属于SDK版本升级带来的兼容伤先查manifest再查代码。坑5模拟器上触摸位置和点击位置差一个格子真机正常现象模拟器里点第0列的车响应的是第1列的子。原因模拟器窗口缩放导致触摸坐标被系统放大了而View没有做坐标归一化。解决这不是代码bug是模拟器分辨率设置问题。把AVD的分辨率调成与屏幕匹配的项目比如1080×2400或者在onTouchEvent里不要直接用rawX而是用getX()——getX()是相对View本身的坐标不受窗口位置影响。如果一定要兼容模拟器的缩放异常可以在onSizeChanged里记录scaleFactor做像素比换算但多数情况下换个模拟器配置就能解决。6. 二次开发从残局练习到人机对战的改造路径源码跑通只是起点真正的乐趣在于改造。基于现有的架构加功能比从零写容易得多。我拆完这份android中国象棋源码后最想做的事就是让电脑能跟人下棋。6.1 给引擎加一个初代人机AIChessEngine已经有了generateMovesAI的搜索框架可以直接搭上去。我一般用一个深度为2的极大极小搜索加上最简单的局面估值public Move findBestMove(boolean redTurn) { ListMove moves generateMoves(redTurn); int bestScore Integer.MIN_VALUE; Move bestMove null; for (Move move : moves) { applyMove(move); int score evaluate(redTurn) - minimax(depth - 1, false); undoMove(); if (score bestScore) { bestScore score; bestMove move; } } return bestMove; }估值函数先按棋子价值算总分车500、马300、炮300、兵100再加一点位置奖励。这个AI的棋力大约相当于会走子的新手但用来学习和验证搜索框架完全够用。6.2 加一个残局复盘功能现有代码里的悔棋栈就是天然的复盘数据源。你可以把每一手move序列在回合结束时追加到一个ArrayList然后加一个“回放”按钮遍历列表逐个applyMove配合Handler延迟刷新棋盘几十行代码就是一个复盘系统。6.3 验证改造的正确性改完代码后我的习惯是打开App和另一个在线象棋网页对同一局面每走一步对比棋盘数组是否一致。如果AI改造后棋力明显下降优先检查估值函数是不是把正负号搞反了这个bug的表现是AI总把自家子送到对方嘴里。从那以后我每次拿到一份棋类项目源码都会强制先跑一遍完整的“将军—应将—将死”用例再动手改代码不然改到一半总怀疑是原有逻辑出问题。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑