ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Android中国象棋源码拆解:从棋盘模型到Alpha-Beta搜索

Android中国象棋源码拆解:从棋盘模型到Alpha-Beta搜索

简介:这份面向Android初学者与进阶开发者的中国象棋游戏源码以Java语言编写,可用于理解棋类游戏从棋盘绘制到规则判定的完整实现路径,弥补只看教程、缺少可运行工程参照的短板。压缩包共110个文件、约4.47MB,以66张png棋子与棋盘图片、10个java源文件及20个class文件为主体,另含3段mp3音效、3个xml布局与配置、2个db数据文件,并附带可安装的apk与dex等构建产物,便于先运行程序,再对照源码查看具体实现。已有523人学习下载。工程把棋盘绘制、棋子走法规则、触摸事件响应、胜负判断等功能拆分为独立类,配合资源目录与清单文件,可直接观察自定义View绘图、MotionEvent处理与游戏状态检测的实现方式,也能据此改写棋盘样式、补充走法或尝试加入对弈AI,是练习Android游戏开发与项目结构梳理的实用素材。

1. 从一份 android 中国象棋源码说起

很多人硬盘里都存着一份 android 中国象棋源码,下载完就再没打开过。原因不复杂:这类 android 游戏项目看起来简单,摆个棋盘、画几个圆、点两下走个子,真拆开才发现走法规则、将军判定、坐标映射、悔棋栈全缠在一起,稍不留神就翻车。它其实是一份相当完整的 Android 练习素材:自定义 View 的绘制与触摸、一维数组上的棋类规则引擎、简单的人机搜索,三条主线都能落到真实代码里。适合刚学完 Canvas 想找个完整项目练手的人,也适合想给自家 App 塞一个棋类小游戏模块的开发者。下面按「结构 → 模型 → 绘制 → 避坑 → 改造」的顺序,把它拆到能直接上手改的程度。

2. 工程结构与技术选型:Gradle 脚本到 ChessBoardView 的落点

2.1 包结构与各模块职责

这类象棋源码的包结构通常不复杂,但分层是否清晰,直接决定你二次开发时改一处会不会崩一片。合格的做法是把「规则」和「界面」彻底隔开:规则层只认棋盘数组和坐标,完全不知道 Android 的存在;界面层只负责把数组画出来、把手势翻译成坐标。我见过的大部分可维护版本,目录大致长这样:

目录 / 文件职责改动频率
core/Board.java棋盘数组、棋子编码、落子与回滚低
core/Move.java一步棋的数据载体低
core/MoveGenerator.java各棋子走法生成中
core/RuleChecker.java将军、将帅照面、困毙判定中
ui/widget/ChessBoardView.java绘制棋盘棋子、处理触摸高
ui/GameActivity.java对局流程、悔棋、重开、模式切换高
ai/Engine.java人机对战搜索入口中
data/RecordStore.java棋谱保存、残局读取低

这个划分的价值在于:你想换一套皮肤,只动ChessBoardView;你想加一个更强的 AI,只动ai包;你想做在线对战,只需要把Move序列化后发给对端。规则层是纯 Java,甚至可以单独抽出来跑单元测试,不依赖模拟器。

2.2 技术选型:纯 Java 加原生 View,而不是游戏引擎

象棋是个回合制、静态网格、没有物理和粒子效果的游戏,用 Unity 或 Cocos 属于杀鸡用牛刀,包体从几百 KB 涨到几十 MB,接入到已有 App 里也麻烦。这套源码走的是最朴素的路:一个继承View的自定义控件,onDraw里用Canvas画线画圆,onTouchEvent里把像素坐标换算成棋盘行列。

这种选型的实际收益有三点。第一,安装包增量极小,棋盘底图如果用手绘而非图片资源,几乎不占体积。第二,和普通 Android 页面共存毫无障碍,GameActivity里放一个ChessBoardView再加几个按钮就是一个完整界面。第三,调试直观,棋盘画歪了就是坐标算错,不像引擎那样出问题只能靠猜。

代价也要说清楚:所有视觉细节都得自己写。棋子高光、落子阴影、选中呼吸效果,没有现成组件可用。如果项目里已经全面转向 Jetpack Compose,把这套View塞进去得用AndroidView包一层,反而绕;那种情况下更合理的做法是把core包原样保留,绘制部分重写成Canvas的 Compose 版本。

2.3 编译环境与第一次运行

拿到源码先别急着点 Run,看一眼build.gradle。这类项目常见的配置是这样:

android { compileSdk 34 // 编译用 SDK,跟本机已装版本对齐 defaultConfig { applicationId "com.example.chess" minSdk 21 // 最低支持 Android 5.0,覆盖面足够 targetSdk 34 versionCode 1 versionName "1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } }

几个参数的实际含义:compileSdk只影响编译期能用哪些 API,跟运行设备无关;minSdk 21决定了Canvas上一些新方法(比如drawRoundRect的部分重载)能不能直接用;sourceCompatibility设成 1.8 是为了能用 lambda,很多老源码停留在 1.7,导入后会一片红。

导入流程按这个顺序走:

  1. 用 Android Studio 打开项目根目录,等 Gradle Sync 完成。
  2. 如果报Gradle version x.x is not supported,改gradle/wrapper/gradle-wrapper.properties里的distributionUrl,升到与当前 AS 匹配的版本。
  3. 如果满屏Cannot resolve symbol androidx,说明源码还停留在android.support时代,用 AS 的 Refactor → Migrate to AndroidX 走一遍。
  4. 连一台 API 21 以上的真机或开模拟器,直接 Run。

跑起来后先别改代码,做三件事确认基线:双人对战能不能完整下完一盘、悔棋能不能连续回退、切到人机模式时界面会不会卡住。这三条是后面所有改造的前提。

3. 棋盘数据模型:90 个交叉点的一维数组与走法生成器

3.1 一维数组的编码约定

象棋棋盘是 9 列 10 行,共 90 个交叉点。用二维数组int[10][9]也能写,但一维数组在搜索时更省事:拷贝快、索引连续、做置换表时能把整个局面哈希成一个 long。这套源码用的是int[90],下标约定index = row * 9 + col,row从 0 到 9,黑方底线在上,红方底线在下。

public final class Board { public static final int EMPTY = 0; public static final int KING = 1, ADVISOR = 2, BISHOP = 3, KNIGHT = 4, ROOK = 5, CANNON = 6, PAWN = 7; public static final int ROWS = 10, COLS = 9; private final int[] cells = new int[ROWS * COLS]; public static int index(int row, int col) { return row * COLS + col; } public static boolean onBoard(int row, int col) { return row >= 0 && row < ROWS && col >= 0 && col < COLS; } // 正数代表红方,负数代表黑方,绝对值是棋子类型 public int get(int row, int col) { return onBoard(row, col) ? cells[index(row, col)] : EMPTY; } public void set(int row, int col, int piece) { if (onBoard(row, col)) cells[index(row, col)] = piece; } public static int sideOf(int piece) { return Integer.signum(piece); } public static int typeOf(int piece) { return Math.abs(piece); } }

用正负号区分阵营是这套编码最省事的地方:判断是不是自己的子,一句sideOf(piece) == side就够;判断是不是空位,piece == EMPTY;取棋子类型,Math.abs。红帅记1,黑将记-1,红车记5,黑车记-5,读代码时不用来回查表。

提示:不要用 0 表示红方、1 表示黑方再额外存一个类型字段。两个字段在搜索里要多一次数组访问,而且make/unmake时容易漏改其中一个,导致棋子「变色」这种极难排查的问题。

3.2 七种棋子的走法生成:马腿、象眼、炮架

走法生成是整个项目的核心。写法上不要给每种棋子单独写一个canMove,而是在一个统一的generate里把当前方所有合法着法塞进列表,搜索和界面共用同一份逻辑,避免出现「界面能走、AI 不认」的两套规则。

马的走法最容易出错,八个方向每个都有一条对应的马腿:

// 每行四个数:行增量、列增量、马腿行偏移、马腿列偏移 private static final int[][] KNIGHT_STEPS = { {-2, -1, -1, 0}, {-2, 1, -1, 0}, { 2, -1, 1, 0}, { 2, 1, 1, 0}, {-1, -2, 0, -1}, { 1, -2, 0, -1}, {-1, 2, 0, 1}, { 1, 2, 0, 1} }; private void genKnight(Board b, int row, int col, int side, List<Move> out) { for (int[] s : KNIGHT_STEPS) { int nr = row + s[0], nc = col + s[1]; if (!Board.onBoard(nr, nc)) continue; // 马腿被占则这个方向作废,注意腿的坐标是起点加偏移 if (b.get(row + s[2], col + s[3]) != Board.EMPTY) continue; int target = b.get(nr, nc); if (Board.sideOf(target) != side) out.add(new Move(row, col, nr, nc)); } }

四个参数的顺序别记反:前两个是终点相对起点的位移,后两个是马腿相对起点的位移。写错一个方向的表现是「马能从某个特定角度跳过棋子」,单测很难覆盖,只能靠人工摆棋。

炮的生成分两段,第一段是平移,第二段是翻山吃子:

private void genCannon(Board b, int row, int col, int side, List<Move> out) { for (int[] d : ORTHO) { // 上下左右四个方向 int r = row + d[0], c = col + d[1]; while (Board.onBoard(r, c) && b.get(r, c) == Board.EMPTY) { out.add(new Move(row, col, r, c)); // 第一段:空格随便走 r += d[0]; c += d[1]; } if (!Board.onBoard(r, c)) continue; // 走出边界,这个方向结束 // 此时 (r,c) 是炮架,越过它继续找第一个棋子 r += d[0]; c += d[1]; while (Board.onBoard(r, c)) { int p = b.get(r, c); if (p != Board.EMPTY) { if (Board.sideOf(p) != side) out.add(new Move(row, col, r, c)); break; // 无论敌我,只吃第一个 } r += d[0]; c += d[1]; } } }

车用的是同一套ORTHO方向,但没有第二段,遇到任何棋子就停。兵卒要看是否过河:红兵row <= 4时可以横走,黑卒row >= 5时可以横走,其余情况只能向前一步。士和象除了走法本身,还要加范围约束,士限制在col 3~5且本方九宫内,象不能过河。

3.3 将军检测与合法走法过滤

走法生成做完,还要剔除「走完之后自己被将军」的着法。常见实现是先不管,交给上层做过滤:

public static boolean isChecked(Board b, int side) { int kingPos = -1; for (int i = 0; i < 90; i++) { int p = b.cells[i]; if (Board.typeOf(p) == Board.KING && Board.sideOf(p) == side) { kingPos = i; break; } } if (kingPos < 0) return true; // 将都不在了,直接判负 List<Move> replies = new ArrayList<>(); MoveGenerator.generate(b, -side, replies); // 对方所有着法 for (Move m : replies) { if (Board.index(m.toRow, m.toCol) == kingPos) return true; } return false; }

参数说明:b是当前局面,side是待检测方。做法是反向思维,不判断自己会不会被吃,而是枚举对方所有能走的位置,看有没有一个落点是自己的将。这种写法简单、不易漏,代价是要多生成一遍着法,在深度搜索里会成为性能瓶颈,后面第 6 章会提到怎么优化。

过滤合法着法的时候有个细节:落子、检测、回滚三步必须严格成对,中间任何异常返回都要保证棋盘恢复原状,否则搜索树会越走越乱,表现为「AI 想很久,然后走一步根本不存在的棋」。

4. 绘制与交互:ChessBoardView 的 onDraw 和 onTouchEvent

4.1 像素坐标与棋盘行列的双向映射

棋盘是 8 格宽、9 格高,也就是 9 条竖线、10 条横线。屏幕给的空间通常不是这个比例,硬拉伸会让棋子变成椭圆。稳妥的算法是先算出单位格长,再让棋盘在控件内居中:

private float cellW, cellH, originX, originY; private void computeMetrics() { float w = getWidth() - getPaddingLeft() - getPaddingRight(); float h = getHeight() - getPaddingTop() - getPaddingBottom(); // 横向 8 个间隔,纵向 9 个间隔,取较小值保证比例不变 float unit = Math.min(w / 8f, h / 9f); cellW = cellH = unit; originX = getPaddingLeft() + (w - unit * 8f) / 2f; originY = getPaddingTop() + (h - unit * 9f) / 2f; }

originX/originY是第一行第一列那个交叉点的像素位置,后面的绘制和触摸都以它为基准。反向换算就一句col = round((x - originX) / cellW),row同理。之所以用round而不是整除,是因为用户点的是「离交叉点最近的那一格」,不是「右下方的格子」。

这段代码最好在onSizeChanged里调用一次并缓存,不要在onDraw里每帧重算。虽然开销不大,但onDraw会被频繁调用,能省则省。

4.2 onDraw 的分层顺序

绘制顺序决定了视觉上的遮挡关系,正确顺序是:背景、网格、九宫斜线、楚河汉界文字、棋子、选中高亮。把高亮放最后,是因为它的圆环要压在棋子上面才看得清。

@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawBackground(canvas); // 木色底 + 外框 drawGrid(canvas); // 9 竖 10 横 drawPalace(canvas); // 上下两个九宫的交叉斜线 drawRiverText(canvas); // "楚河""汉界" drawPieces(canvas); // 32 枚棋子 drawSelection(canvas); // 选中框 + 可落点提示 }

棋盘线用Paint的STROKE模式,宽度设成unit * 0.02f左右,这个比例在手机和平板上都不会显得突兀。棋子的画法是先画一个实心圆做底色,红方偏米白配红字,黑方偏米白配黑字,再用drawText把字画在圆心。文字居中靠Paint.FontMetrics:

Paint.FontMetrics fm = textPaint.getFontMetrics(); float baseline = cy - (fm.ascent + fm.descent) / 2f; canvas.drawText(name, cx, baseline, textPaint);

如果直接写canvas.drawText(name, cx, cy, paint),文字会整体偏下半个字高,棋盘一密集看起来就像棋子都歪了一点点,非常难受。

4.3 触摸状态机与悔棋栈

触摸处理不要写成「点一下就走子」,中间必须有选中状态。完整的状态机只有三个状态:无选中、选中己方棋子、落下。第二次点击时判断落点是否在合法着法列表里,在就执行,不在就把选中切到新的己方棋子上。

private void handleTap(int row, int col) { if (gameOver) return; int piece = board.get(row, col); if (selected == null) { if (Board.sideOf(piece) == currentSide) { // 只能选自己人 selected = new Point(row, col); legalTargets = MoveGenerator.findFrom(board, row, col); // 高亮用 invalidate(); } return; } Move move = matchMove(legalTargets, row, col); // 落点在列表里才算合法 if (move != null) { history.push(board.snapshot()); // 悔棋靠这行 board.make(move); currentSide = -currentSide; selected = null; legalTargets = null; checkGameEnd(); } else if (Board.sideOf(piece) == currentSide) { selected = new Point(row, col); // 改选另一个子 legalTargets = MoveGenerator.findFrom(board, row, col); } invalidate(); }

悔棋的关键是history这个栈。很多源码为了省事,悔棋时只是把棋子移回去,结果吃掉的子回不来,或者轮次没切换,点两次悔棋就乱套。快照式的做法最稳:每次落子前存一份完整棋盘加当前轮次,悔棋直接restore,不用逐条反推。90 个 int 一份快照,存一百步也就 36 KB,完全不用心疼内存。

5. 避坑与排查:五个让棋盘行为诡异的真实起因

5.1 规则判定类的三个坑

坑一:马能跳过河,也能跳过棋子。

现象:走马的时候,明明马腿上有子,仍然能跳过去,而且只在某几个方向出现。

原因:KNIGHT_STEPS里马腿偏移写反了,把{dr, dc, legDr, legDc}的后两位写成了终点坐标的某种变形,导致四个方向对、四个方向错。

解决:把八个方向逐个人工摆一遍,做成一张单测表格,每条用例断言「腿上有子时该方向着法数为 0」。这个测试十几行代码,能挡住后面所有的重构误伤。

坑二:炮隔着两个子也能吃。

现象:炮和目标之间有两个棋子时,依然判定可以吃。

原因:翻山后找到第一个棋子判断完没break,循环继续往后跑,遇到第二个敌子又生成了一次着法。

解决:翻山后的循环里,无论遇到的棋子是敌是友,处理完立即跳出。敌子加着法,友子什么都不做,但都要停。

坑三:将帅照面没判,双方可以互相吃将。

现象:一方把将走到和对方将同一条竖线上,中间无子,游戏没有结束,下一步对方直接把将吃了。

原因:只做了「被将军」检测,没做「双将照面」这一条象棋特有规则。

解决:在isChecked里加一段同列扫描,两将之间没有棋子时,同样视为被将军。这段逻辑要加在过滤合法着法的路径上,否则 AI 会利用这个漏洞走出非法棋。

5.2 绘制与交互类的两个坑

坑四:棋子总是偏半个格,越往右下偏得越多。

现象:点击交叉点,落子却出现在旁边;在屏幕左边还行,右边误差明显。

原因:originX用了getWidth() / 9f当单位格长,但横向只有 8 个间隔。少了这一格,误差会随着列号线性放大。

解决:横向除以 8,纵向除以 9,并且先取Math.min再算原点,不要用控件宽度直接除。改完在左上、右下各点一次验证。

坑五:悔棋之后轮到谁走是乱的。

现象:悔一步棋,棋盘回去了,但该谁走没变,导致同一方连走两步。

原因:快照里只存了棋盘数组,没存currentSide;或者用了逐条反推的方式,忘了把被吃的子恢复。

解决:快照对象里同时存int[] cells和int side,restore时一起还原。这个坑最典型的症状是「悔棋后 AI 不动了」,因为轮次错乱让 AI 以为还没到自己。

注意:这五个问题里,前三个属于规则层,后两个属于界面层。排查时先用固定残局在规则层跑单测,确认走法生成没问题,再去怀疑绘制和触摸。顺序反了会在onDraw里来回改,最后发现根本是MoveGenerator的锅。

6. 二次开发:给这套源码接一个能打的 Alpha-Beta 引擎

原始的人机对战如果是「随机走一步合法的」或者「只吃子不思考」,体验很差。想在ai包里补一个像样的搜索,最直接的是负极大值形式的 Alpha-Beta:

public int search(Board board, int depth, int alpha, int beta, int side) { if (depth == 0) return evaluator.evaluate(board, side); List<Move> moves = MoveGenerator.generateLegal(board, side); if (moves.isEmpty()) return -MATE + (MAX_DEPTH - depth); // 无棋可走即被将死 moves.sort((a, b) -> scoreMove(b) - scoreMove(a)); // 吃子优先,提高剪枝率 int best = -INF; for (Move m : moves) { board.make(m); int v = -search(board, depth - 1, -beta, -alpha, -side); board.unmake(m); best = Math.max(best, v); alpha = Math.max(alpha, v); if (alpha >= beta) break; // 剪枝 } return best; }

depth是剩余搜索层数,alpha/beta是剪枝窗口,side是当前行棋方。走法排序那行别省,同样深度下剪枝率能差好几倍。评估函数先做最土的版本就够跑:子力分值加简单位置分,将 10000、车 900、炮 450、马 400、士象 200、兵 100,过河兵翻倍,马在边角扣 20。

验证手段比优化手段更重要。我会准备三样东西:一是 10 个固定残局,每个残局只允许一条正确胜法,搜索深度开到 5 必须走对;二是走法计数的自检,对初始局面按深度 1/2/3 统计节点数并和手算结果比对,数字对不上说明生成器有漏或重复;三是一个计时器,记录每层搜索耗时,一旦某层突然从 200ms 跳到 2s,基本就是剪枝没生效或者置换表写错了。

改造顺序建议固定:先让规则层能通过残局单测,再接搜索,最后调评估权重。跳过第一步直接调权重,会出现「AI 变强了但会走非法棋」的玄学现象,查起来极其痛苦。从那以后我每次动规则层的代码,都强制先跑一遍那 10 个残局的单测再谈别的。希望帮到你。

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

RELATED READING

延伸阅读

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