简介:这份 Android 中国象棋游戏源码面向希望从完整项目中理解移动端棋类游戏实现的开发者,尤其适合具备 Java 与 Android 基础、想练习自定义 View、触摸事件处理和游戏状态判断的学习者。压缩包为 rar 格式,共 110 个文件,约 4.47MB,包含 66 个 png 棋子与界面图片、10 个 java 源码及 20 个 class 文件、3 个 mp3 音效,另有 xml 配置、db 数据、apk 与 dex 等构建产物,覆盖界面绘制、资源管理到可运行安装包等环节。已有 523 人学习,读者可据此梳理棋盘绘制、棋子移动规则、胜负判断、音效加载与工程目录组织方式,并参考打包结果完成自己的 Android 游戏练习。源码将 Activity、自定义视图与规则逻辑分层组织,适合作为学习 Android 游戏开发、理解资源与代码协作关系的实践案例。
1. 一个把「马」走成「田」的下午
前几年接了个手机端中国象棋 android 游戏的活,甲方一句话:能下、能悔棋、能换难度。我拍胸脯说三天,结果卡在走法校验上——马腿漏判、炮隔两个子照样吃、将帅照面没拦住,测试同事随手走了三步就把我方老将吃掉了。那会儿我手里正缺一份能把规则和界面分得干净的 android源码。后来翻到这份 android中国象棋 源码,最直接的感受是:棋盘、规则、搜索、界面是四层,各干各的活,走法生成器是纯 Java 类,不开模拟器就能写单元测试反复验证。如果打算把自定义 View、触摸事件、极小化极大搜索串起来练一遍,它是个体量小、环节完整的 android游戏 样本。
2. 工程结构和技术选型:纯 Java 单 Module 的 Android 工程
2.1 技术栈判断:为什么这类项目不引引擎
这份源码最稳的一点是没引入任何游戏引擎、物理库或脚本运行时,纯粹用 Android SDK 完成全部工作:绘制用Canvas+Paint,动画用ValueAnimator或直接重绘,触摸用onTouchEvent,局面搜索跑在Thread或AsyncTask里,棋谱存档进SharedPreferences或一个纯文本文件。放弃引擎看上去"土",但换来了三样东西:APK 体积小,一次invalidate()的可控粒度细,每个模块都能写 JUnit 测试。中国象棋的棋盘是固定 9 列 10 行交叉点,棋子只有 32 枚、走法规则封闭,恰好卡在"引擎太重、纯 View 刚好"的分界线上。
常见做法是单 Activity 承载一切:MainActivity只干三件事——初始化视图、注册按钮回调、把生命周期转给一个GameController。棋盘层再往上拆,就是下面这套目录。
2.2 目录拆解:四个包各自负责什么
| 包/文件 | 职责 | 是否适合单独测试 |
|---|---|---|
activity/MainActivity.java | 页面装配、按钮事件、流程调度 | 否,依赖 Android 运行时 |
view/BoardView.java | 落子棋盘绘制、触摸换算、选中态反馈 | 否,依赖Canvas |
engine/Board.java | 棋盘数组、初始局面、走子/撤销 | 是 |
engine/MoveGenerator.java | 各兵种伪合法走法生成 | 是 |
engine/RuleChecker.java | 合法性过滤、将军、绝杀、将帅照面 | 是 |
engine/SearchEngine.java | Alpha-Beta 搜索与局面评估 | 是 |
game/GameController.java | 回合切换、悔棋栈、胜负判定 | 是 |
game/History.java | 棋谱记录与回放 | 是 |
utils/Chess.java | 棋子编码与红黑判定常量 | 是 |
这张表看起来像标准的分层,真正让工程可维护的是engine包里没有任何android.*的 import。我第一次跑通之后顺手加了个MoveGeneratorTest,把开局局面下红方合法走法数断言成 44,一次就跑到电脑上连续测了一百多个残局。
我一般会强制一条规则:engine和game两个包禁止引用Context、Bitmap、Log这类类。日志改用System.out或一个自建的Debug工具接口,让上层实现。听着很啰嗦,但这正是这份源码比一般"作业级"象棋工程干净的地方。
2.3 构建配置:三个版本号要先对齐
拿到源码第一件事不是点运行,是看build.gradle。这类项目的坑几乎都在版本上——compileSdk与targetSdk差了几个大版本,minSdk设成 8,buildscript里还是十年前的插件地址。稳妥的改法是把版本对齐到当前主流:
// app/build.gradle android { namespace "com.example.cchess" // 老工程用 package 属性,新插件必须写 namespace compileSdk 34 defaultConfig { applicationId "com.example.cchess" minSdk 21 // 自定义 View + Canvas 这套接口在 21 已完全稳定 targetSdk 34 versionCode 1 versionName "1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' testImplementation 'junit:junit:4.13.2' }namespace与新插件版本必须同时改,只改一处会直接报清单解析失败;minSdk定在 21 是因为 19 以下在部分机型上invalidate()与触摸坐标精度的表现不一致,遇到过几台不认账的低端机。改完之后./gradlew :app:assembleDebug走一遍,先确认能出 APK,再去模拟器里点。
注意:如果原工程用的是
buildToolsVersion固定值(例如28.0.3),删掉这一行比改成新值更安全,让插件自己匹配。
清单文件通常只有一条Activity,需要额外补的是屏幕朝向固定与全屏控制,象棋界面横竖都能玩,但强行portrait能省掉一整类布局重算问题:
<!-- AndroidManifest.xml --> <activity android:name=".activity.MainActivity" android:screenOrientation="portrait" android:configChanges="keyboardHidden|orientation|screenSize" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>configChanges里的三项不加,旋屏或切分屏时 Activity 重建,正在搜索的 AI 线程会带着旧局面引用继续跑,回来时棋盘已经换了个对象,这是后面第 5 章要细说的一类问题。
3. 棋盘绘制与触摸落子:Canvas 与坐标系的往返
3.1 逻辑坐标:9 列 10 行与 8 格宽 9 格高
象棋棋盘是"交叉点"模型,棋子落在线的交点上,一共 9 条竖线、10 条横线,横向 8 个格子,纵向 9 个格子。工程里如果按"格子"算,就会出现棋盘少一格、棋子全部偏半格的经典现象。所以第一件事是把这两套数对齐:
// BoardView 中维护的三个几何参数 private float originX; // 棋盘左上角第一颗交点的 x private float originY; // 棋盘左上角第一颗交点的 y private float cellSize; // 相邻两条线之间的像素距离 // 9 列 -> 横向 8 个间隔;10 行 -> 纵向 9 个间隔 cellSize = Math.min(boardWidth / 8f, boardHeight / 9f); originX = (getWidth() - cellSize * 8f) / 2f; originY = (getHeight() - cellSize * 9f) / 2f;cellSize取两个方向的较小值,棋盘就永远是等比缩放,横向拉伸导致的棋子变椭圆问题也一并解决。originX/originY负责居中——很多工程偏偏就把棋盘贴在左上角,砍掉这两行代码确实省事,但换到平板上看起来像没做完。
3.2 绘制顺序:底图、九宫斜线、河界文字、棋子、选中框
onDraw的执行顺序决定叠压关系,顺序错了会出现河界文字压在棋子上、选中框被棋子盖掉这类问题。稳妥的顺序是下面五步:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawGrid(canvas); // 1. 十条横线 + 九条竖线,河界处竖线断开 drawPalace(canvas); // 2. 双方九宫的交叉斜线 drawRiverText(canvas); // 3. 「楚河」「汉界」 drawPieces(canvas); // 4. 32 枚棋子 drawSelection(canvas); // 5. 选中框 + 上一步落点标记 }画竖线时河界(第 4 行和第 5 行之间)要断开,只保留左右两条边线。判断方式很简单:竖线索引为 0 或 8 时整条画满,其余在originY + cellSize * 4到originY + cellSize * 5之间留空。这一段最容易翻车的是把河界位置算成第 5 行,河就整体下移一格——记住是从 0 开始数,中间那条河在第 4 行和第 5 行之间。
棋子本身用canvas.drawCircle加Paint写字即可,圆形棋子用两层圆(外圈描边、内圈填充)看上去才不像贴图缺了边框:
private void drawPiece(Canvas canvas, int row, int col, int piece) { float cx = originX + col * cellSize; float cy = originY + row * cellSize; float radius = cellSize * 0.45f; // 留 5% 缝隙,相邻棋子不会粘在一起 paint.setStyle(Paint.Style.FILL); paint.setColor(0xFFFFF3D6); // 棋子底色 canvas.drawCircle(cx, cy, radius, paint); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(cellSize * 0.04f); paint.setColor(Chess.isRed(piece) ? 0xFFCC2222 : 0xFF222222); canvas.drawCircle(cx, cy, radius, paint); // 红黑两色描边区分阵营 paint.setStyle(Paint.Style.FILL); paint.setTextSize(cellSize * 0.55f); paint.setTextAlign(Paint.Align.CENTER); // drawText 的 y 是基线,不校正的话棋子上的字会整体偏下 Paint.FontMetrics fm = paint.getFontMetrics(); float baseline = cy - (fm.ascent + fm.descent) / 2f; canvas.drawText(pieceName(piece), cx, baseline, paint); }最后一行的基线校正值得单独拎出来说。drawText的 y 参数不是文字中心而是基线,直接传cy会让所有字向下沉差不多四分之一个字高。视觉上像是"棋子字没居中",实际上是一条坐标系没换算过来。
3.3 触摸反算:从像素回到行列,再回到棋子
onTouchEvent里拿到的event.getX()是相对当前 View 的像素坐标,要变成 0~8 的列和 0~9 的行,得走一遍round:
@Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() != MotionEvent.ACTION_UP) return true; int col = Math.round((event.getX() - originX) / cellSize); int row = Math.round((event.getY() - originY) / cellSize); // 越界保护:点在棋盘外的留白区域直接忽略 if (col < 0 || col > 8 || row < 0 || row > 9) return true; // 再判一次距离,防止点在两个交点正中间被强行吸附 float dx = event.getX() - (originX + col * cellSize); float dy = event.getY() - (originY + row * cellSize); if (Math.hypot(dx, dy) > cellSize * 0.5f) return true; controller.onTouchCell(row, col); // 交给控制器,View 不管规则 invalidate(); return true; }这里有三个参数值得盯:Math.round而不是强转(int),强转是截断,负坐标会往 0 方向取整,越界判断就失效;第二段的Math.hypot距离阈值决定"点歪了算不算",取cellSize * 0.5f是我试过的比较舒服的值,太小会点不中,太大容易误触相邻点;第三,ACTION_UP而不是ACTION_DOWN,是因为玩家按下后常常滑一下再松手,用DOWN会提前落子。
3.4 刷新时机:只在局面真的变了才 invalidate
绘制和逻辑一旦耦合,最先失控的就是刷新。常见的错误写法是定时器里每秒invalidate()六十次,CPU 一直有事干。正确做法是只在局面变化时刷新:走子成功、悔棋、AI 算出结果、选中状态切换,四个入口各自调一次。BoardView只暴露setBoard(board)和setSelected(row, col)这类方法,控制器改完数据再通知刷新。
// GameController 中走完一步之后 board.move(move); history.push(move); view.setBoard(board); // 内部只做一件事:invalidate() view.setSelected(-1, -1); // 清掉选中态AI 在后台线程算完之后同样走这条路径,但要注意切回主线程。常见做法是在SearchEngine里返回一个Move,控制器用Handler或runOnUiThread包一层再更新 View,别在子线程里碰任何 View 状态。
4. 走法生成与规则校验:从「马走日」到「将帅照面」
4.1 棋盘表示:一维数组和二维数组的取舍
象棋盘 9×10,二维int[10][9]最直观,board[row][col]读写都顺。也有的工程用一维int[90],索引row * 9 + col,好处是能直接传进搜索函数、缓存友好。这份源码走的是二维路线,理由是走法生成里到处要判越界,二维写if (nr < 0 || nr > 9 || nc < 0 || nc > 8) continue;比一维反算行列可读性高得多。真要跑深度搜索再来优化,先把正确性做对。
棋子编码用带符号的整数,正数红方、负数黑方,绝对值代表兵种:
public final class Chess { public static final int EMPTY = 0; public static final int KING = 1; // 将/帅 public static final int ADVISOR= 2; // 士/仕 public static final int BISHOP = 3; // 象/相 public static final int KNIGHT = 4; // 马 public static final int ROOK = 5; // 车 public static final int CANNON = 6; // 炮 public static final int PAWN = 7; // 卒/兵 public static boolean isRed(int p) { return p > 0; } public static boolean isBlack(int p) { return p < 0; } public static int typeOf(int p) { return Math.abs(p); } public static boolean sameSide(int a, int b) { return a != 0 && b != 0 && (a > 0) == (b > 0); } }带符号编码的好处是"这枚子是敌是友"只需要一次符号比较,不用再额外存颜色字段;坏处是兵种判断每次都要Math.abs。这点开销对 32 枚棋子来说完全可以忽略。
初始局面用一个字符串常量写清楚,比硬编码一长串数字好维护:
// 大写红、小写黑,'.' 表示空位;行序从上(黑方底线)到下(红方底线) private static final String[] INIT_ROWS = { "rnbakabnr", ".........", ".c.....c.", "p.p.p.p.p", ".........", ".........", "P.P.P.P.P", ".C.....C.", ".........", "RNBAKABNR" };4.2 伪合法走法:五种兵种各自的约束
走法生成分两步:先生成"伪合法"走法,不判断走完之后自己是否被将;再做一次合法性过滤。这种拆分是整个工程里最值钱的设计,因为它让"马走日"和"不能送将"这两类规则彻底解耦。
马是最容易写错的一个,核心是马腿:
private static final int[][] KNIGHT_DELTA = { {-2, -1}, {-2, 1}, {-1, -2}, {-1, 2}, { 1, -2}, { 1, 2}, { 2, -1}, { 2, 1} }; private void addKnightMoves(int r, int c, int side, List<Move> out) { for (int[] d : KNIGHT_DELTA) { int nr = r + d[0], nc = c + d[1]; if (!inBoard(nr, nc)) continue; // 马腿:竖直走两格看行中点,水平走两格看列中点 int legR = r + (d[0] == 2 ? 1 : d[0] == -2 ? -1 : 0); int legC = c + (d[1] == 2 ? 1 : d[1] == -2 ? -1 : 0); if (board[legR][legC] != Chess.EMPTY) continue; if (Chess.sameSide(board[nr][nc], side)) continue; out.add(new Move(r, c, nr, nc)); } }legR/legC那两行是这段的全部关键。方向向量d[0]为 ±2 时,马腿在竖直方向的中间格;d[1]为 ±2 时,马腿在水平方向的中间格;另外那个分量保持 0,所以legC或legR自然落在原点。把它写成"马腿永远在 r+1 或 c+1"是新手最常见的错误,结果只有向下、向右的马腿生效。
炮比车多一层"翻山"逻辑:
private void addCannonMoves(int r, int c, int side, List<Move> out) { for (int[] dir : ORTHO) { // 上下左右四个方向 int nr = r + dir[0], nc = c + dir[1]; boolean jumped = false; // 是否已经越过炮架 while (inBoard(nr, nc)) { int target = board[nr][nc]; if (!jumped) { if (target == Chess.EMPTY) { out.add(new Move(r, c, nr, nc)); // 空格:平移 } else { jumped = true; // 碰到炮架,切到吃子模式 } } else { if (target != Chess.EMPTY) { if (!Chess.sameSide(target, side)) { out.add(new Move(r, c, nr, nc)); } break; // 翻山后只取第一个子,立刻停 } } nr += dir[0]; nc += dir[1]; } } }break那一行是血泪经验:不写的话,炮会一路吃穿整条线,把炮架后面的所有敌子全列进合法走法表里。这一点在人工对弈时不容易发现,因为人不会那样走,但 AI 会——搜索到深一层就把它当成真实着法,整个评估全歪。
士象要在九宫和己方半场范围内,卒过河之前只能前进:
private void addPawnMoves(int r, int c, int side, List<Move> out) { int forward = (side == RED) ? -1 : 1; // 红方向上(行号减小),黑方相反 addIfValid(r + forward, c, side, out); // 永远可以进一步 boolean crossedRiver = (side == RED) ? r <= 4 : r >= 5; if (crossedRiver) { // 过河后才能横走 addIfValid(r, c - 1, side, out); addIfValid(r, c + 1, side, out); } // 注意:卒永远不能后退,这里没有 r - forward 的分支 }红方在上、黑方在下是很多工程的约定,行 0 是黑方底线、行 9 是红方底线,所以红方前进是行号减小。如果初始局面字符串排反了,就会出现"红兵倒着走"的现象,而且因为己方棋子互不攻击,两边的兵都能过河,很难第一时间发现。
4.3 合法性过滤:走完之后自己不能在被将状态
伪合法走法生成完毕,接下来一步一验:假设走这一步,看己方将帅是否被攻击。被攻击就丢掉。
public List<Move> legalMoves(Board board, int side) { List<Move> pseudo = generator.generate(board, side); List<Move> legal = new ArrayList<>(); for (Move m : pseudo) { int captured = board.pieceAt(m.toRow, m.toCol); board.apply(m); // 落子并更新将帅位置 boolean safe = !isKingAttacked(board, side); board.undo(m, captured); // 原样撤回来 if (safe) legal.add(m); } return legal; } private boolean isKingAttacked(Board board, int side) { int[] king = board.findKing(side); // 找不到说明已经被吃,返回被将 if (king == null) return true; // 敌方任意伪合法走法能否落到将帅格上,就等价于被将 List<Move> enemyMoves = generator.generate(board, -side); for (Move m : enemyMoves) { if (m.toRow == king[0] && m.toCol == king[1]) return true; } return false; }这套写法性能很差但极难写错,apply与undo必须严格配对,captured要原样回填——这就是悔棋功能的基础。等确定规则没有遗漏,再换成"从将帅位置反向扫描"的高效版本,把复杂度降下来。将帅照面单独判:
// 同一列、中间无子,双方将帅直接照面 -> 对走子方非法 private boolean kingsFacing(Board board) { int[] red = board.findKing(Chess.RED); int[] black = board.findKing(-Chess.RED); if (red == null || black == null) return false; if (red[1] != black[1]) return false; // 不在同一列 int from = Math.min(red[0], black[0]) + 1; int to = Math.max(red[0], black[0]); for (int r = from; r < to; r++) { if (board.pieceAt(r, red[1]) != Chess.EMPTY) return false; } return true; }kingsFacing返回 true 的时候,走子方这一步就是非法的,不需要再判是否被将。很多人把它写在"被将"判断里,结果导致己方主动把中间的子挪开、让将帅照面时判定不成立,一局棋就卡在那里走不动了。
4.4 搜索:Alpha-Beta 剪枝和局面评估
AI 部分的关键不是搜索写得多花哨,而是评估函数和着法排序。裸的极小化极大在象棋上跑 4 层就要几十秒,加剪枝后同样的深度能压到几百毫秒。核心结构如下:
private int alphaBeta(Board board, int depth, int alpha, int beta, int side) { if (depth == 0) return evaluator.evaluate(board, side); List<Move> moves = ruleChecker.legalMoves(board, side); if (moves.isEmpty()) { // 无棋可走:被将死或被困毙,越早被杀分数越低,避免"拖时间" return ruleChecker.inCheck(board, side) ? -MATE + depth : -MATE + depth; } orderMoves(moves, board); // 吃子优先,MVV-LVA int best = -INF; for (Move m : moves) { int captured = board.pieceAt(m.toRow, m.toCol); board.apply(m); int score = -alphaBeta(board, depth - 1, -beta, -alpha, -side); board.undo(m, captured); if (score > best) best = score; if (best > alpha) alpha = best; if (alpha >= beta) break; // 剪掉,对方不会让步到这个局面 } return best; }orderMoves是把"吃子"着法排前面,再按"用小子吃大子"优先。排序做不做,剪枝效率差三到五倍,这是纯经验参数。另一个易忽略的点是-alphaBeta(..., -beta, -alpha, ...)里的负号翻转,这个"负极大"写法让红黑双方共用一个搜索函数,但初学时常把两个参数传成alpha, beta原样,剪枝就一直不生效,表现出来是"AI 能下,就是慢得离谱"。
evaluate用子力价值打底,加上位置权重:
private static final int[] VALUE = {0, 10000, 200, 200, 400, 900, 450, 100}; // 空 将 士 象 马 车 炮 卒把士象价值定低是刻意的,它们几乎不参与进攻,价值高会让 AI 为了保象放弃攻势。真正拉开水平的是位置分,比如马在中心加 20 分、在棋盘角减 20 分,卒过河加 30 分。这些参数没有标准答案,调法就是固定残局反复对局,看哪类局面下得差就补权重。
5. 避坑与排查:五类「能编译但不能玩」的问题
5.1 启动闪退与界面错位
现象:APK 装上去点开直接闪退,logcat 里一条NullPointerException,堆栈指向onDraw的某一行。原因:BoardView被setContentView解析并测量时,onDraw可能先于控制器里的board字段初始化被调用,board.pieceAt(...)直接空指针。解决:onDraw开头加一句if (board == null) return;,同时把控制器注入从构造里挪到onCreate的findViewById之后,保证数据先于第一次绘制到位。
现象:棋盘画出来了,但右边或下面被裁掉一截,棋子画在屏幕外。原因:onMeasure没做,父布局给的宽高比和 9:10 不一致,cellSize只按宽度算。解决:在onMeasure里按 9:10 的比例约束铺满短边,或者在onSizeChanged里重算cellSize = Math.min(w / 8f, h / 9f)并同步originX/originY,保证居中。
5.2 走子规则与状态不同步
现象:AI 走出一步明显违规的棋,比如炮隔两个子吃子,或者马跳过挡路的兵。原因:MoveGenerator里炮的循环少了break,马腿判的是(r + d[0]/2, c)而没有处理水平方向。解决:给MoveGenerator单独写 JUnit 用例,固定几组残局断言合法走法数量与具体坐标,把生成器当成纯函数来测;这一步花的时间会在后面十倍的调试时间里赚回来。
现象:悔棋按钮按下去,棋子回到原位但轮次不对,或者红黑颜色反了。原因:悔棋只撤销了棋盘数据,没有回退sideToMove和History栈。解决:apply/undo必须成对,一个Move里带上captured棋子编码,undo时一并还原,回合切换集中放在控制器的一个方法里,禁止各处直接改side字段。
现象:将帅照面时游戏没结束,双方还能继续走。原因:kingsFacing只在"被将"判断里调用,或者用了同行判断(应该同列)。解决:把照面判定提到合法性过滤层,与"走后不能送将"并列,两个条件任一成立则该着法非法。
5.3 AI 卡死与性能抖动
现象:点"人机对战"后界面卡住几秒,有时直接 ANR。原因:搜索在主线程跑,深度一加到 4 层就超时。解决:搜索跑在独立线程(ExecutorService或一个Thread),结果用Handler.post回主线程;同时加迭代加深与时间上限,比如每步最多 800ms,超时就用当前最好着法。
现象:同一个局面下每次点"AI 走一步",耗时相差十几倍。原因:没做着法排序,剪枝命中率随机。解决:orderMoves按"吃子价值 - 被吃子价值"降序排,吃子着法优先,再加一层历史启发。排序不用完美,方向对了就行。
现象:AI 在必败局面下不肯认输,一直挪将帅拖时间。原因:无棋可走时返回的-MATE + depth是固定值,搜索没区分"被将死"和"困毙",也没让"晚输"排到"早输"前面。解决:被杀时按剩余深度加偏移,让搜索倾向于选择更晚失败的路线,同时在控制器里检测重复局面(长将)并判和。
6. 二次开发:难度分级、棋盘换肤与棋谱回放
想让它从"能下"变成"有点意思",我通常按三个方向改。难度分级不要只调搜索深度,那是最粗糙的做法:把SearchEngine抽出一个Level枚举,每级同时控制深度、评估权重扰动和"随手棋"概率。入门级深度 1 加 15% 随机选次优着法,中高级深度 3 到 4、不加噪声。写起来就是给alphaBeta外面套一层:
public Move think(Board board, int side, Level level) { List<Move> moves = ruleChecker.legalMoves(board, side); if (moves.isEmpty()) return null; if (level.randomRate > 0 && random.nextFloat() < level.randomRate) { return moves.get(random.nextInt(moves.size())); // 低难度故意走次优 } return searchBest(board, side, level.depth); }棋盘换肤的改造点不在绘制代码,而在资源抽取:把棋子半径、底色、描边色、字体大小全部收进一个BoardTheme对象,BoardView只认 theme 不认常量。换成"木纹棋盘"或"极简线框"时只替换一个实例,不需要动onDraw。棋谱回放则依赖History栈:每一步记(fromRow, fromCol, toRow, toCol, captured),回放时先reset到初始局面,再按栈逐帧apply,每帧之间用Handler.postDelayed隔 300ms,就能看整盘棋的走法轨迹。这套东西一旦跑通,导出标准 FEN 字符串也只是多写一个序列化函数的事。
我的教训是:改之前先把engine包的整体跑一遍单元测试,再动手改规则。有一回我为了加"困毙判和",直接在legalMoves里塞了段特殊判断,导致马的走法少了一半,AI 下得像不会走马。从那以后我每次动引擎代码,都强制先把那组残局断言跑一遍,绿了再提。希望帮到你。
本文还有配套的精品资源,点击获取