
简介这是一份基于 Java 技术栈的中国象棋游戏完整源码工程适合 Java 游戏开发、课程设计或棋类算法研究的读者参考。项目围绕完整对局流程展开从棋盘界面绘制与棋子图片加载到不同棋子的走法校验、吃子与将军规则再到落子音效与菜单操作均由 Java 代码实现。AI 对弈部分采用极大极小值搜索算法并提供可调搜索深度从而改变电脑棋力方便对比不同层级的博弈效果。压缩包共 207 个文件包含 37 个 Java 源文件、54 个 class 编译文件、32 张 JPG 棋子棋盘图片、24 个 WAV 音效文件及 23 个 XML 配置整体约 6.73MB可按源码、素材、配置分类查阅。目前该资源已有 3336 人学习下载借助这份工程读者能学习棋类状态表示、合法走法生成、胜负判断和博弈树搜索的落地写法也能参考事件驱动界面、音频播放等 Java 实战技巧适合用于深入理解棋类游戏开发全流程。1. JAVA 实现中国象棋为什么说它不是图书管理系统的难度期末课程设计里最常见的题目除了图书管理系统就是棋类游戏。中国象棋这个题目看似普通真要自己从零写一套能完整对弈的源码会发现它比图书管理系统难一个量级棋盘上九十个落点、七种棋子每种棋子的走法都带不同的拦截规则还要判将、判送、判困毙。用 JAVA 做的话核心难点不是语法而是把“规则”这个抽象的东西落到代码里。这篇文章要讲的是我重写这类项目时的一套稳定结构棋盘怎么建模、走法怎么生成、AI 怎么加、哪些规则最容易翻车。适合刚学完 Java 基础想找练手项目的开发者也适合拿它当课程设计源码、但怕答辩被追问实现细节的同学。2. 棋盘建模与棋子状态从 90 个落点到行棋规则的 Java 映射先说结论棋子本身不该是一个个带着坐标和存活状态的对象棋盘才应该是主体。这不是玄学而是后面所有功能的地基。如果你把棋子做成 32 个对象每次吃子都要遍历对象列表找目标并标记死亡移动时还得维护两套集合到了给 AI 生成走法的时候每个搜索节点都要复制这份对象图代码会越写越绕。把棋盘建模成一个int[10][9]数组就能用非常朴素的方式表达“落点”这件事数组元素是棋子编码0 表示空红方棋子用正整数黑方棋子用负整数。这个正负约定有一个很大的红利判断“能不能吃子”只需要看目标格子的值和当前棋子是否异号相乘小于零就是敌人判断“是不是自己人”只看符号相同。后面做局面评估时红方统计正值、黑方统计负值不需要再单独维护阵营表。唯一的代价是黑方棋子在做规则推导时所有纵坐标方向都要反过来但这一层在 MoveRule 里统一处理棋手不会感知到。2.1 用 int[10][9] 定棋盘红方为正向黑方索引对称常见做法是用二维数组行数 10、列数 9对应真实棋盘的 10 条横线和 9 条竖线。我习惯把board[y][x]作为统一坐标写法因为打印棋盘时外层循环走行、内层循环走列和屏幕显示的顺序一致调试的时候少一层换算。初始化布局时记住红方在下方、黑方在上方河界在行 4 和行 5 之间。public class ChessBoard { public static final int ROWS 10; public static final int COLS 9; public static final int[][] START_LAYOUT new int[ROWS][COLS]; private int[][] board; static { // 黑方在上方y0红方在下方y9 START_LAYOUT[0][0] ChessConstants.BLACK_CAR; START_LAYOUT[0][1] ChessConstants.BLACK_HORSE; START_LAYOUT[0][2] ChessConstants.BLACK_ELEPHANT; START_LAYOUT[0][3] ChessConstants.BLACK_GUARD; START_LAYOUT[0][4] ChessConstants.BLACK_KING; START_LAYOUT[0][5] ChessConstants.BLACK_GUARD; START_LAYOUT[0][6] ChessConstants.BLACK_ELEPHANT; START_LAYOUT[0][7] ChessConstants.BLACK_HORSE; START_LAYOUT[0][8] ChessConstants.BLACK_CAR; START_LAYOUT[2][1] ChessConstants.BLACK_CANNON; START_LAYOUT[2][7] ChessConstants.BLACK_CANNON; START_LAYOUT[3][0] ChessConstants.BLACK_PAWN; START_LAYOUT[3][2] ChessConstants.BLACK_PAWN; START_LAYOUT[3][4] ChessConstants.BLACK_PAWN; START_LAYOUT[3][6] ChessConstants.BLACK_PAWN; START_LAYOUT[3][8] ChessConstants.BLACK_PAWN; // 红方对称 START_LAYOUT[9][0] ChessConstants.RED_CAR; START_LAYOUT[9][1] ChessConstants.RED_HORSE; START_LAYOUT[9][2] ChessConstants.RED_ELEPHANT; START_LAYOUT[9][3] ChessConstants.RED_GUARD; START_LAYOUT[9][4] ChessConstants.RED_KING; START_LAYOUT[9][5] ChessConstants.RED_GUARD; START_LAYOUT[9][6] ChessConstants.RED_ELEPHANT; START_LAYOUT[9][7] ChessConstants.RED_HORSE; START_LAYOUT[9][8] ChessConstants.RED_CAR; START_LAYOUT[7][1] ChessConstants.RED_CANNON; START_LAYOUT[7][7] ChessConstants.RED_CANNON; START_LAYOUT[6][0] ChessConstants.RED_PAWN; START_LAYOUT[6][2] ChessConstants.RED_PAWN; START_LAYOUT[6][4] ChessConstants.RED_PAWN; START_LAYOUT[6][6] ChessConstants.RED_PAWN; START_LAYOUT[6][8] ChessConstants.RED_PAWN; } public ChessBoard() { board new int[ROWS][COLS]; for (int y 0; y ROWS; y) { System.arraycopy(START_LAYOUT[y], 0, board[y], 0, COLS); } } // 深拷贝AI 搜索时每个节点都要用 public ChessBoard(ChessBoard other) { board new int[ROWS][COLS]; for (int y 0; y ROWS; y) { System.arraycopy(other.board[y], 0, board[y], 0, COLS); } } }这个构造方式有两个细节值得说。第一START_LAYOUT用 static 块初始化一次之后每次 new 棋盘都从它复制避免构造方法里写 32 行重复赋值。第二深拷贝构造函数必须手动二维复制System.arraycopy只能复制一行不能直接other.board.clone()因为那只是浅拷贝一改就全串。2.2 面向对象拆解把七种棋子统一成 MoveRule 接口很多课程设计代码会把每种棋子写成一个类车类、马类、炮类再让它们继承一个抽象基类。这样写没有问题但我建议把“走法生成”做成接口而不是把“棋子对象”做成抽象类。原因很简单棋盘上同一个位置可能先后存在不同颜色的同种棋子如果把棋子实例存进棋盘还得考虑销毁和重建而 MoveRule 是无状态的只需要按棋子类型取一个规则实例输入坐标和棋盘即可返回所有合法落点。public interface MoveRule { /** * 返回从 (x, y) 出发的走法。 * color: 1 红方-1 黑方 * type: 棋子绝对类型用于区分车马炮等 */ Listint[] getMoves(int[][] board, int x, int y, int color, int type); } public class MoveRules { private static final MoveRule[] RULES new MoveRule[8]; static { RULES[Math.abs(ChessConstants.RED_CAR)] new CarRule(); RULES[Math.abs(ChessConstants.RED_HORSE)] new HorseRule(); RULES[Math.abs(ChessConstants.RED_CANNON)] new CannonRule(); RULES[Math.abs(ChessConstants.RED_ELEPHANT)] new ElephantRule(); RULES[Math.abs(ChessConstants.RED_GUARD)] new GuardRule(); RULES[Math.abs(ChessConstants.RED_KING)] new KingRule(); RULES[Math.abs(ChessConstants.RED_PAWN)] new PawnRule(); } public static MoveRule of(int absType) { return RULES[absType]; } }这里用Math.abs统一红黑两方而不是分别写两个规则。车的直线、马的日字、炮的翻山红黑双方只是方向不同走法骨架完全一样。所以接口里传入 color具体规则内部再把“向前”解释成红方的 y1 或黑方的 y-1。这也是面向对象设计里“多态”最典型的落地场景上层调用方根本不用关心棋子是什么颜色只调一个getMoves剩下的由具体规则类分派。Java 里的Listint[]在高版本 JDK 上可以直接用坐标用两个 int 表示比自定义 Point 对象更省后面 AI 搜索要频繁 new 走法对象这个选择能省不少开销。3. 走法生成与将军检测用 Java 把马腿炮架写成可控代码这一章是整个源码的核心。走法生成解决了“这颗子能走到哪”将军检测解决了“这步棋走完自己是不是就死了”。两者合起来才是合法走法集合。如果分开写代码的可读性会好很多而且排查问题时能一眼定位是“规则写错了”还是“过滤漏了”。我把七种棋子的走法分成两批车、炮是直线扫描型马、象、士、帅、兵是跳步/条件型。直线型用循环一路探出去遇到空位继续、遇到敌子可吃再停、遇到己子直接停。跳步型提前定义好相对位移数组再逐项检查边界和“绊腿”条件。两批规则都只做“落点是否合法”的判断不处理“走完后是否送将”送将过滤统一放到外面。3.1 直线型车和炮的扫描区别只在“隔子”状态车的实现最简单四个方向一直走遇空加入遇敌可吃遇己阻挡。public class CarRule implements MoveRule { Override public Listint[] getMoves(int[][] board, int x, int y, int color, int type) { Listint[] out new ArrayList(); int[][] dirs {{1,0},{-1,0},{0,1},{0,-1}}; for (int[] d : dirs) { int nx x d[0], ny y d[1]; while (isOnBoard(nx, ny)) { if (board[ny][nx] 0) { out.add(new int[]{nx, ny}); } else { if (board[ny][nx] * color 0) { out.add(new int[]{nx, ny}); } break; } nx d[0]; ny d[1]; } } return out; } }炮的规则多一个开关没有炮架时只能走空位遇到第一个子后把开关置为“隔子状态”此后继续往前走遇到第二个子时如果是敌人就能吃无论吃不吃都要停。public class CannonRule implements MoveRule { Override public Listint[] getMoves(int[][] board, int x, int y, int color, int type) { Listint[] out new ArrayList(); int[][] dirs {{1,0},{-1,0},{0,1},{0,-1}}; for (int[] d : dirs) { int nx x d[0], ny y d[1]; boolean mount false; while (isOnBoard(nx, ny)) { if (board[ny][nx] 0) { if (!mount) out.add(new int[]{nx, ny}); } else { if (!mount) { mount true; } else { if (board[ny][nx] * color 0) { out.add(new int[]{nx, ny}); } break; } } nx d[0]; ny d[1]; } } return out; } }这两个类的区别只在mount一个布尔量。写的时候容易踩的坑是忘记break炮遇第二个子后如果不断开还会继续往后扫描把隔着两个子的敌子也算进去。标准规则里炮最多隔一个子第二个子之后视线必须截断。3.2 跳步型马腿、象眼和九宫约束马的相对位移有八个每个位移的两个分量一个是 2、一个是 1。马腿就落在分量绝对值为 2 的方向上、距离原位置一格的那一点。比如马要走到(x2, y1)腿在(x1, y)要走到(x1, y2)腿在(x, y1)。写成代码时用移位而不是逐个判断会清爽很多。public class HorseRule implements MoveRule { private static final int[][] STEPS { {-2,-1}, {-2,1}, {-1,-2}, {-1,2}, {1,-2}, {1,2}, {2,-1}, {2,1} }; Override public Listint[] getMoves(int[][] board, int x, int y, int color, int type) { Listint[] out new ArrayList(); for (int[] s : STEPS) { int nx x s[0], ny y s[1]; if (!isOnBoard(nx, ny)) continue; int legX Math.abs(s[0]) 2 ? x s[0] / 2 : x; int legY Math.abs(s[1]) 2 ? y s[1] / 2 : y; if (board[legY][legX] ! 0) continue; if (board[ny][nx] 0 || board[ny][nx] * color 0) { out.add(new int[]{nx, ny}); } } return out; } }legX和legY的计算是关键。s[0] / 2在 Java 里对负数也是整除比如-2 / 2 -1正好取到马腿那一格。判断马腿有没有子用board[legY][legX] ! 0即可不需要区分敌我因为有子就绊腿。象的规则和马腿是同一种思路只是象眼在斜对角的中点而且多一个“不能过河”的约束。红方象只能在 y 大于等于 5 的区域活动黑方象只能在 y 小于等于 4 的区域活动。士和帅的活动范围限制在九宫内九宫的列坐标固定是 3、4、5红方行是 7 到 9黑方行是 0 到 2。兵要注意过河判定红方兵在 y6 起始走到 y4 才算过河黑方卒走到 y5 才算过河。过河前只能向前过河后才能左右移动。这里的数值经常被写反我的建议是在代码里单独抽一个boolean crossedRiver(color, y)方法把color1 ? y4 : y5这个逻辑放进去语义一眼能读出来。3.3 将军检测与送将过滤isInCheck 兜住所有走法有了原始走法后还不能直接给 UI 或 AI 用。必须过滤掉“走完这一步自己的帅反而陷入被将军状态”的送将走法。常见做法是三步临时移动、调isInCheck判断己方、再撤销移动。isInCheck 不需要替对方生成全部走法而是从己方帅的位置反向扫描四条直线和八个马位只要发现车、炮、马或者兵在攻击范围内就说明被将军。public class RuleChecker { public int[] findKing(int[][] board, int color) { int kingType color 1 ? ChessConstants.RED_KING : ChessConstants.BLACK_KING; for (int y 0; y ChessBoard.ROWS; y) { for (int x 0; x ChessBoard.COLS; x) { if (board[y][x] kingType) { return new int[]{x, y}; } } } return null; } public boolean isInCheck(int[][] board, int color) { int[] k findKing(board, color); if (k null) return true; int x k[0], y k[1]; // 直线方向车和炮 int[][] dirs {{1,0},{-1,0},{0,1},{0,-1}}; for (int[] d : dirs) { int nx x d[0], ny y d[1]; boolean firstSeen false; while (isOnBoard(nx, ny)) { int v board[ny][nx]; if (v 0) { nx d[0]; ny d[1]; continue; } if (!firstSeen) { firstSeen true; if (v * color 0 Math.abs(v) ChessConstants.RED_CAR) { return true; } } else { if (v * color 0 Math.abs(v) ChessConstants.RED_CANNON) { return true; } break; } nx d[0]; ny d[1]; } } // 马位 int[][] horseSteps {{-2,-1},{-2,1},{-1,-2},{-1,2},{1,-2},{1,2},{2,-1},{2,1}}; for (int[] s : horseSteps) { int nx x s[0], ny y s[1]; if (!isOnBoard(nx, ny)) continue; int v board[ny][nx]; if (v * color 0 Math.abs(v) ChessConstants.RED_HORSE) { int legX Math.abs(s[0]) 2 ? x s[0] / 2 : x; int legY Math.abs(s[1]) 2 ? y s[1] / 2 : y; if (board[legY][legX] 0) return true; } } // 兵卒贴身 int pawnStep color 1 ? -1 : 1; if (isOnBoard(x, y pawnStep) board[y pawnStep][x] * color 0 Math.abs(board[y pawnStep][x]) ChessConstants.RED_PAWN) { return true; } // 将帅对脸 int ny y (color 1 ? -1 : 1); while (isOnBoard(x, ny)) { int v board[ny][x]; if (v ! 0) { if (v * color 0 Math.abs(v) ChessConstants.RED_KING) { return true; } break; } ny (color 1 ? -1 : 1); } return false; } }这段代码把将帅对脸也并进了 isInCheck所以生成走法时不需要在 KingRule 里单独处理“帅不能直接面对将”这个例外让上层调用统一过滤即可。过滤过程的 apply 和 undo 要保存被吃掉的棋子避免悔棋和 AI 搜索时把棋盘改坏。public final class MoveUtil { public static int apply(int[][] board, Move m) { int captured board[m.toY][m.toX]; board[m.toY][m.toX] board[m.fromY][m.fromX]; board[m.fromY][m.fromX] 0; return captured; } public static void undo(int[][] board, Move m, int captured) { board[m.fromY][m.fromX] board[m.toY][m.toX]; board[m.toY][m.toX] captured; } }我见过不少项目把 isInCheck 当成黑匣子只在界面上弹“将军”提示。但它在源码里更重要的用途是给走法生成做后置过滤这一层不写AI 会在残局阶段走出各种白白送帅的昏棋。4. 简易人机对弈在全部源码里补一个不拉胯的走法搜索很多公开的中国象棋源码只支持双人对战一旦要做人机就得自己补 AI。AI 的入门写法是极小化极大搜索配合 Alpha-Beta 剪枝。对课程设计来说搜索深度两到三层就够用再深就会明显卡顿。先把评估函数写好让电脑知道吃车比吃兵划算、杀帅比吃车划算AI 才有基本棋感。4.1 先写评估函数不能让它只会乱吃子评估函数把当前棋盘换算成一个整数正值表示红方优势负值表示黑方优势。最简单的做法是给每种棋子一个基础价值再累加。车最贵炮和马次之士象是守家子兵前期便宜后期有用帅是反正不能丢的绝对价值。棋子基础价值帅/将10000车1000马450炮450士200象200兵/卒100public class Evaluator { private static final int[] PIECE_VALUE new int[8]; static { PIECE_VALUE[6] 10000; // 帅 PIECE_VALUE[1] 1000; // 车 PIECE_VALUE[2] 450; // 马 PIECE_VALUE[3] 450; // 炮 PIECE_VALUE[5] 200; // 士 PIECE_VALUE[4] 200; // 象 PIECE_VALUE[7] 100; // 兵 } public int evaluate(int[][] board, int side) { int total 0; for (int y 0; y ChessBoard.ROWS; y) { for (int x 0; x ChessBoard.COLS; x) { int v board[y][x]; if (v 0) continue; int abs Math.abs(v); if (v 0) total PIECE_VALUE[abs]; else total - PIECE_VALUE[abs]; } } return total * side; } }这里有个容易绕晕的参数side表示当前轮到谁走1 或 -1。AI 作为黑方时side-1评估值就是整个总分的相反数这样上层搜索不需要关心自己到底是红还是黑统一追求最大值即可。如果你还要提高棋力可以在此基础上加位置表比如马在中场比在边角更有价值、兵过河后价值翻倍但那个属于逐步调优不是第一版必须做的事。4.2 Alpha-Beta 剪枝搜索树的加速核心搜索的思路是把当前所有合法走法挨个试一遍每试一步就换对手视角继续往下看直到深度用尽。Alpha-Beta 剪枝的作用是砍掉肯定不会被选中的分支。中国象棋的分支因子不低三层搜索已经能感觉到明显剪枝收益。public class AiPlayer { private final RuleChecker checker new RuleChecker(); private final Evaluator evaluator new Evaluator(); public Move search(int[][] board, int depth, int color) { ListMove moves generateLegalMoves(board, color); if (moves.isEmpty()) return null; sortMovesByCapture(board, moves); // 吃子优先剪枝更有效 Move bestMove moves.get(0); int bestScore Integer.MIN_VALUE; for (Move m : moves) { int captured MoveUtil.apply(board, m); int score -alphaBeta(board, depth - 1, Integer.MIN_VALUE, Integer.MAX_VALUE, -color); MoveUtil.undo(board, m, captured); if (score bestScore) { bestScore score; bestMove m; } } return bestMove; } private int alphaBeta(int[][] board, int depth, int alpha, int beta, int color) { ListMove moves generateLegalMoves(board, color); if (depth 0 || moves.isEmpty()) { if (moves.isEmpty() checker.isInCheck(board, color)) { return -100000 depth; } return evaluator.evaluate(board, color); } for (Move m : moves) { int captured MoveUtil.apply(board, m); int score -alphaBeta(board, depth - 1, -beta, -alpha, -color); MoveUtil.undo(board, m, captured); if (score beta) return beta; if (score alpha) alpha score; } return alpha; } }这段代码里负号代表“换边”自己走一步后后续局面对手要最大化而自己这一层取反后正好统一成极大化。根节点的最佳分数用Integer.MIN_VALUE起步不会漏掉负值局面。如果你想让 AI 更强还可以在进入递归前先按吃子价值排序我用的是冒泡排序因为每层走法数量不算多排序的开销远小于剪枝省下的节点数代码也最直观。private void sortMovesByCapture(int[][] board, ListMove moves) { for (int i 0; i moves.size() - 1; i) { for (int j 0; j moves.size() - i - 1; j) { int a Math.abs(board[moves.get(j).toY][moves.get(j).toX]); int b Math.abs(board[moves.get(j 1).toY][moves.get(j 1).toX]); if (a b) { Move t moves.get(j); moves.set(j, moves.get(j 1)); moves.set(j 1, t); } } } }走法排序是 Alpha-Beta 剪枝的加速器。先搜吃大子的走法更容易提前找到一个足够好的上界把后面的分支大片剪掉。排序的标准可以用被吃棋子的价值也可以用“吃完后是不是马上要被吃回来”这种更深的策略但第一版先用吃子价值排序完全够用。5. 中国象棋的 Java 实现避坑马脚、象眼、将帅对脸和浅拷贝血泪史这一章是整篇文章最值钱的段落。走法生成和 AI 搜索的代码结构清楚之后剩下的全是边界条件。下面这几条是我重写国象? 不重写中国象棋项目时踩过的真实问题每一条按“现象、原因、解决”展开。5.1 马脚、象眼与浅拷贝走法生成器的三个高频雷区现象一马能越过紧贴着的棋子跳过去。我最早写 HorseRule 时判断马腿用的是“目标点和起点之间的横纵差各取半”结果在边界附近比如(0,0)的马往(1,2)跳时马腿坐标算出来是(0,1)逻辑看似没错但遇到(0,1)处有己方车时却仍然放行了。原因在于马腿的判断必须区分“横向跳两格”和“纵向跳两格”。s[0]2时腿在横向中间格s[1]2时腿在纵向中间格。如果统一用“两点坐标取平均”在负方向位移时比较容易取成出发点的另一个对角出现死角。解决方法是严格按Math.abs(s[0]) 2 ? x s[0] / 2 : x来算先确认位移主轴再取腿。现象二象能塞自己的象眼。象的走法是斜两步如果只用“目标点没有出界”判断会出现一只象从河界一侧直接跨到另一侧甚至跳过自己棋子落地的现象。原因是象眼不在目标点而在斜向两步的中点。比如从(2,0)走到(4,2)象眼是(3,1)这个位置有子就堵住了。解决方法是把legX x s[0]/2、legY y s[1]/2作为单独变量检查同时补上“不能过河”的边界判断红方y 5的落点直接丢弃。现象三AI 搜索时棋盘被越改越乱。表现是走一步之后棋盘上多出一个棋子或者吃掉的子又“活”了。搜到第二层时局面完全错乱。原因通常是浅拷贝。ArrayListint[]里存的坐标数组或者int[][]数组直接用clone()复制出来的都只是引用顶层改了某个格子所有副本一起变。解决方法是写专门的深拷贝构造器逐行System.arraycopy并且在 apply 时把被吃棋子的原值保存在局部变量里undo 时用这个值恢复。这个坑用一句话概括就是Java 的引用传递不是后悔药任何会变更 board 的操作都得带上完整的回滚信息。5.2 将帅对脸和困毙隐藏最深的两个胜负判定坑现象四AI 或者人走出“将帅碰面”的一步界面上不提示非法。中国象棋里将和帅不能在同一条竖线上直接相对中间没有其他棋子时谁先走到那条线上谁就算送将。但很多走法生成器只在 KingRule 里限制了九宫范围没有检查纵向相对关系导致残局阶段出现“帅隔着空列瞪将”的奇观。原因是对脸是一个双方联动规则不能塞进单个 KingRule 里判断必须在将军检测阶段统一扫描纵向直线。解决方法是像我 3.3 节里那样从己方帅的位置向对方方向一路扫描如果整条竖线只有双方主帅、没有其他棋子就判定被将军。这样走法生成器根本不需要关心对脸只要走完后调用 isInCheck非法走子自然被过滤掉。现象五AI 遇到“无棋可走但不是被将军”的局面直接当成输棋。中国象棋规则里如果一方没有被将军但没有任何合法走法这叫困毙应判和棋。但很多简洁实现只在moves.isEmpty()时返回“失败”AI 就会在优势局面下把和棋走成负分。原因是没有区分“无子可动”和“被将军卡死”。解决方法是判断无子可动后再调一次isInCheck是将军则是将死不是将军则是困毙。AI 评估里也要给困毙一个单独的分数比如返回 0而不是返回负极大值。这个细节在双人对战中不容易暴露一旦接上 AI 搜索树就会频繁引发错误胜负判断。6. 验证路径与进阶改造从控制台源码到带悔棋的 Swing 棋盘6.1 先做 perft 再开 UI用固定节点数验证走法生成我现在的习惯是写完整套走法生成器后不急着搞界面先写一个 perft 函数。perft 就是单纯按合法走法往下展开统计某个深度下的总节点数不评估、不剪枝只看走法生成器是不是算准了。实现很简单public long perft(int[][] board, int depth, int color) { if (depth 0) return 1; ListMove moves generateLegalMoves(board, color); long nodes 0; for (Move m : moves) { int captured MoveUtil.apply(board, m); nodes perft(board, depth - 1, -color); MoveUtil.undo(board, m, captured); } return nodes; }跑初始局面时第 1 层是 44第 2 层数字会增长到千级第 3 层到万级。这个数字是否对得上可以用自己手边靠谱的引擎或网上公开的 perft 表交叉验证。对不上的话几乎一定是马、炮或者将对脸的规则写错了。这一步能省掉后面在界面里肉眼找错的痛苦。6.2 悔棋的最小实现把历史走法存成“后悔药”悔棋是棋类源码里最容易被轻视的功能。实现上不需要保存整盘棋的完整快照每走一步时把 Move 和被吃的棋子压进两个栈就行悔棋时逆序弹出、调用 undo。DequeMove historyStack new ArrayDeque(); DequeInteger capturedStack new ArrayDeque(); public void playMove(Move m, int[][] board, int color) { int captured MoveUtil.apply(board, m); historyStack.push(m); capturedStack.push(captured); } public boolean undoMove(int[][] board) { if (historyStack.isEmpty()) return false; Move m historyStack.pop(); int captured capturedStack.pop(); MoveUtil.undo(board, m, captured); return true; }这是我最近做带界面版本时一直沿用的结构。悔棋状态下能不能反复 undo 到开局、会不会把 AI 的思考缓存清掉这些都可以在栈上叠策略但最小实现就是上面这几行。最后说一个经验遇到这种规则密集的项目不要一开始就堆 UI 和线程先把“走法生成 → 合法性过滤 → 胜负判定”这条主链用纯控制台程序跑通再往 Swing 面板上搬棋子。这样做最大的好处是棋盘渲染和鼠标事件不会混进规则判断里出了问题你能确定是闪现还是规则错。希望帮到你。本文还有配套的精品资源点击获取