ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介: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源码里最常见的“能跑但没法用”场景,就是一转屏棋局全部复位。这个项目的处理方式是把棋盘数组和悔棋栈都放进Engine,Activity在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 List<Move> generateMoves(boolean redTurn) { List<Move> 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],这个字段在后面悔棋时至关重要,因为撤回一步棋必须把“被吃的子”放回原位。如果你打算后续做AI,generateMoves的结果可以直接喂给搜索算法,不需要额外转换。

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 悔棋为什么用栈

悔棋是这个项目里最简单也最容易被忽略的功能。实现方式是维护一个Stack<Move>,走子时压栈,悔棋时弹栈:

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设为21,targetSdk设为当前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数组存入Bundle,onCreate时读取并set回引擎。还有一个隐蔽点:ChessView在旋转后会重新走构造方法,需要在View新建后立刻调用view.setEngine(engine),顺序反了的话View会画一次空棋盘,界面闪一下。

坑3:马能直接跳过棋子飞到对面

现象:马前面明明顶着一个兵,点马再点兵后面的位置,居然走过去了。原因:蹩马腿的判断写错了方向,马腿坐标是沿着马前进的方向,不是马的旁边。解决:参照3.1节的canMaMove实现,先判断dr和dc哪个为2,然后按toRow > fromRow ? 1 : -1计算马腿位置,再检查board[legRow][legCol]是否为空。调试时可以打印日志,故意在马的路径上放一个子,验证返回false。

坑4:Android 12及以上安装后闪退,日志报SecurityException

现象:真机点图标就崩溃,Logcat里看到SecurityException相关错误,或者提示不允许启动该Activity。原因:targetSdk高版本要求显式声明exported属性,更常见的坑是全新的android源码从低版本升级上来时,MainActivity没配launcher类别。解决:在AndroidManifest.xml的MainActivity节点加android:exported="true",并检查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 给引擎加一个初代人机AI

ChessEngine已经有了generateMoves,AI的搜索框架可以直接搭上去。我一般用一个深度为2的极大极小搜索,加上最简单的局面估值:

public Move findBestMove(boolean redTurn) { List<Move> 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总把自家子送到对方嘴里。从那以后,我每次拿到一份棋类项目源码,都会强制先跑一遍完整的“将军—应将—将死”用例再动手改代码,不然改到一半总怀疑是原有逻辑出问题。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进