资讯动态

JAVA实现中国象棋游戏:核心规则与走法生成全解析

发布时间:2026/10/26 8:35:09 来源:尧图企业网站定制
简介这是一套面向Java初学者与游戏开发爱好者的《中国象棋》完整项目源码适合用于课程设计、毕业设计或Java图形界面与算法练习。项目围绕棋盘界面绘制、棋子移动规则、音效播放与AI对弈展开核心亮点是采用极大极小值搜索算法实现不同搜索深度的对手AI并支持人机对弈、人人对弈及AI对AI三种模式同时完整实现了照将、应将、将死等象棋规则判定。资源包共207个文件约6.73MB包含37个java源文件、54个class编译文件、32张jpg图片、24个wav音效、23个xml配置及properties等资源覆盖界面、逻辑、音效与AI模块。目前已有3336人学习下载读者可从中获取完整可运行的源码结构、棋子走法实现思路、搜索算法落地方式与音效集成方案便于二次开发与算法调优。1. 从一张棋盘说起JAVA 实现《中国象棋》游戏到底要写多少行代码很多人第一次听到「JAVA 实现《中国象棋》游戏-全部源码」这个标题脑子里冒出来的画面是拖拽棋子、动画飞过去、将军弹窗。真动手写才发现难点根本不在界面而在规则判定马腿被别、象眼被塞、将帅照面、炮翻山、过河兵不能回头这些逻辑只要错一条整盘棋就是玄学。我见过不少 java 课程设计案例源码界面做得挺花哨走一步马直接穿墙评审老师一眼就看出问题。这篇笔记讲的就是怎么用纯 Java 把中国象棋从零搭起来包括棋盘数据结构、走法生成、合法性校验、胜负判定以及一个能跑起来的最小 Swing 界面。适合正在做 java 课程设计、想拿一个完整项目练面向对象编程 java 的在校生也适合工作几年、想回头补一补算法和状态管理的 java 工程师。全部源码的思路是核心逻辑和界面彻底分离规则层不依赖任何 UI 库这样你后面想换成 JavaFX、命令行甚至服务端对战都不用重写规则。2. 棋盘与棋子怎么建模面向对象拆解与数据结构选型2.1 为什么用 10×9 二维数组而不是 List中国象棋棋盘是 9 列 10 行坐标固定没有增删节点的需求二维数组是最自然的选择。用ListPiece存棋子看着灵活但每次判断某个位置有没有子、是什么子都要遍历一遍走法生成里这种查询是高频操作性能差距会很明显。我一般会定义一个Board类内部持有Piece[][] board new Piece[10][9]行是 y0 到 9列是 x0 到 8红方在下y 7 到 9黑方在上y 0 到 2。坐标约定必须一开始就定死否则后面写走法时你会被自己绕晕。我的习惯是board[y][x]y 从上往下增大x 从左往右增大。红方棋子初始在 y9、y7、y6 这几行黑方在 y0、y2、y3。这个约定写进注释团队协作时能省掉大量扯皮。public class Board { public static final int ROWS 10; public static final int COLS 9; // board[y][x]y 从上到下 0..9x 从左到右 0..8 private final Piece[][] board new Piece[ROWS][COLS]; public Piece get(int x, int y) { if (x 0 || x COLS || y 0 || y ROWS) return null; return board[y][x]; } public void set(int x, int y, Piece p) { board[y][x] p; if (p ! null) { p.setX(x); p.setY(y); } } }这段代码的关键点是边界检查放在get里走法生成时大量越界探测可以直接调get返回 null 就代表出界或空位省掉一堆 if。set里同步更新棋子的 x、y避免棋子和棋盘状态不一致——这是新手最容易翻车的地方棋子对象自己记的坐标和数组下标对不上后面判断走法全是错的。2.2 棋子继承体系抽象类还是枚举棋子类型固定七种帅/将、仕/士、相/象、马、车、炮、兵/卒。红黑各一套行为对称但方向相反。常见做法是抽一个Piece抽象类定义canMove(Board, int toX, int toY)抽象方法每种棋子一个子类。这样面向对象味道足扩展新棋子也方便。但如果你追求代码量少、逻辑集中也可以用枚举PieceType加一个Side枚举把走法判断写在一个MoveValidator里用 switch 分发。两种都能用我倾向抽象类因为每种棋子的走法差异大塞进一个 switch 会变成几百行的巨型方法后期维护痛苦。public abstract class Piece { protected final Side side; protected int x, y; public Piece(Side side, int x, int y) { this.side side; this.x x; this.y y; } // 只判断走法形状不判断是否会导致己方被将军 public abstract boolean canMove(Board board, int toX, int toY); public Side getSide() { return side; } public int getX() { return x; } public int getY() { return y; } public void setX(int x) { this.x x; } public void setY(int y) { this.y y; } }参数说明board传进来是为了探测路径上有没有阻挡toX/toY是目标格。注意canMove只负责「形状合法」比如马走日、象走田不负责「走完之后自己是否被将军」。这个职责分离很重要否则你会把将军判定和走法判定搅在一起代码没法看。2.3 马腿、象眼、炮架三个最容易写错的规则马走日字但「马腿」被别住就不能走。判断方法是马从 (x,y) 到 (toX,toY)先算出横向和纵向的偏移如果 |dx|2说明是横着跳马腿在 (xdx/2, y)如果 |dy|2马腿在 (x, ydy/2)。这个中间格有子走法非法。象走田字dx 和 dy 绝对值都是 2象眼在 (xdx/2, ydy/2)有子则不能走。另外象不能过河红象 y 必须 ≥5黑象 y 必须 ≤4。炮的走法分两种不吃子时和车一样直线走、路径无阻挡吃子时必须隔一个棋子炮架且目标格是敌方棋子。很多人写炮的时候只写了一种结果炮不能吃子或者能隔空吃子。public class Horse extends Piece { public Horse(Side side, int x, int y) { super(side, x, y); } Override public boolean canMove(Board board, int toX, int toY) { int dx toX - x, dy toY - y; if (!((Math.abs(dx) 2 Math.abs(dy) 1) || (Math.abs(dx) 1 Math.abs(dy) 2))) return false; // 马腿位置 int legX (Math.abs(dx) 2) ? x dx / 2 : x; int legY (Math.abs(dy) 2) ? y dy / 2 : y; if (board.get(legX, legY) ! null) return false; Piece target board.get(toX, toY); return target null || target.getSide() ! this.side; } }逻辑说明先判断是不是日字再算马腿最后判断目标格不能是己方棋子。参数上dx/2在 Java 里对负数取整是向零取整-2/2-1正好是马腿方向不用额外处理符号。3. 走法生成与合法性校验从「能走」到「走得对」3.1 伪合法走法与将军过滤的两层结构走法生成分两层第一层生成所有「形状合法」的走法叫伪合法走法第二层对每个伪合法走法做一次试走如果走完之后己方老将被攻击就丢弃这个走法。这个结构是象棋引擎的标准做法也是避免「自杀式走法」的唯一可靠方式。试走的实现很简单把目标格的棋子暂存把起点棋子移到目标格调用isKingAttacked(side)然后还原。注意一定要还原包括棋子的 x、y 和数组内容否则棋盘状态就脏了。public Listint[] generateLegalMoves(Board board, Side side) { Listint[] legal new ArrayList(); for (int y 0; y Board.ROWS; y) { for (int x 0; x Board.COLS; x) { Piece p board.get(x, y); if (p null || p.getSide() ! side) continue; for (int ty 0; ty Board.ROWS; ty) { for (int tx 0; tx Board.COLS; tx) { if (!p.canMove(board, tx, ty)) continue; // 试走 Piece captured board.get(tx, ty); board.set(tx, ty, p); board.set(x, y, null); boolean safe !isKingAttacked(board, side); // 还原 board.set(x, y, p); board.set(tx, ty, captured); if (safe) legal.add(new int[]{x, y, tx, ty}); } } } } return legal; }参数说明side是当前走棋方。这段代码是 O(棋子数 × 90 × 攻击判定)性能一般但对中国象棋的规模完全够用一秒钟能生成几万次。如果你后面想写 AI 搜索再考虑优化比如只遍历有棋子的位置、缓存攻击信息。3.2 将帅照面一个容易被忽略的非法状态中国象棋有个特殊规则将和帅不能在同一条纵线上直接照面中间没有任何棋子。这个规则在走法校验里必须单独处理否则会出现双方老将隔着空棋盘对视的荒唐局面。判断方法找到双方老将的坐标如果 x 相同检查它们之间 y 范围内有没有棋子没有就是照面非法。这个检查要放在isKingAttacked里或者单独做一个isKingFacing方法在试走之后一起判断。private boolean isKingFacing(Board board) { int[] redKing findKing(board, Side.RED); int[] blackKing findKing(board, Side.BLACK); if (redKing null || blackKing null) return false; if (redKing[0] ! blackKing[0]) return false; int minY Math.min(redKing[1], blackKing[1]); int maxY Math.max(redKing[1], blackKing[1]); for (int y minY 1; y maxY; y) { if (board.get(redKing[0], y) ! null) return false; } return true; }逻辑说明同列且中间无子就是照面。注意这个规则对双方都适用红方走完导致照面红方非法黑方同理。很多 java 课程设计案例源码漏掉这条答辩时被问一句就露馅。3.3 胜负判定将死与困毙的区别一局结束有两种情况将死被将军且无合法走法和困毙没被将军但无合法走法。中国象棋规则里困毙也算输这点和国际象棋不同国际象棋困毙是逼和。实现上很简单生成当前方的所有合法走法如果为空再看是否被将军被将军是将死否则是困毙两种情况都判当前方输。public GameState checkGameState(Board board, Side sideToMove) { Listint[] moves generateLegalMoves(board, sideToMove); if (!moves.isEmpty()) return GameState.PLAYING; boolean inCheck isKingAttacked(board, sideToMove); return inCheck ? GameState.CHECKMATE : GameState.STALEMATE; }参数说明sideToMove是轮到走棋的一方。返回值用枚举表示界面层根据状态弹提示。注意每次走完棋都要调一次不要只在界面按钮里判断逻辑层自己保证状态正确。4. 界面与交互用 Swing 把棋盘画出来并响应点击4.1 棋盘绘制重写 paintComponent 的坐标换算Swing 里画棋盘核心是JPanel的paintComponent。棋盘 9 列 10 行交叉点才是落子位置所以画线时横线 10 条、竖线 9 条但竖线在河界处要断开除了最左最右两条边线。每个交叉点的像素坐标是margin x * cellSize和margin y * cellSizecellSize 一般取 60 到 80 像素看窗口大小。棋子用圆形加文字画红方用红色黑方用黑色。文字用「帅仕相马车炮兵」和「将士象马车炮卒」字体大小约为 cellSize 的一半。Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 画横线 for (int y 0; y 10; y) { g2.drawLine(margin, margin y * cell, margin 8 * cell, margin y * cell); } // 画竖线河界处断开 for (int x 0; x 9; x) { if (x 0 || x 8) { g2.drawLine(margin x * cell, margin, margin x * cell, margin 9 * cell); } else { g2.drawLine(margin x * cell, margin, margin x * cell, margin 4 * cell); g2.drawLine(margin x * cell, margin 5 * cell, margin x * cell, margin 9 * cell); } } // 画棋子 for (int y 0; y 10; y) { for (int x 0; x 9; x) { Piece p board.get(x, y); if (p ! null) drawPiece(g2, p, x, y); } } }参数说明margin是棋盘边距cell是格子像素大小。RenderingHints开抗锯齿不然棋子边缘锯齿明显。注意paintComponent里不要做耗时计算棋盘状态从Board读就行。4.2 鼠标点击落子像素坐标转棋盘坐标鼠标点击事件里把像素坐标反算成棋盘坐标x Math.round((e.getX() - margin) / (float) cell)y 同理。然后判断这个位置有没有己方棋子有就选中再点另一个位置就尝试走子。选中状态用一个selectedX/selectedY记录重绘时高亮。addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { int bx Math.round((e.getX() - margin) / (float) cell); int by Math.round((e.getY() - margin) / (float) cell); if (bx 0 || bx 8 || by 0 || by 9) return; if (selected null) { Piece p board.get(bx, by); if (p ! null p.getSide() currentSide) { selected new int[]{bx, by}; } } else { int sx selected[0], sy selected[1]; Piece p board.get(sx, sy); if (p.canMove(board, bx, by) isLegalMove(sx, sy, bx, by)) { makeMove(sx, sy, bx, by); currentSide (currentSide Side.RED) ? Side.BLACK : Side.RED; } selected null; } repaint(); } });逻辑说明第一次点击选中己方棋子第二次点击尝试走子。isLegalMove内部做试走和将军校验和前面generateLegalMoves用的是同一套逻辑不要重复实现。走完切换走棋方重绘。4.3 悔棋与重开状态快照的简单实现悔棋最简单的做法是每次走子前把整个棋盘深拷贝一份压栈悔棋时弹栈还原。棋盘只有 90 个格子拷贝成本可以忽略。重开就是重新初始化棋盘清空历史栈。private final DequePiece[][] history new ArrayDeque(); private void makeMove(int sx, int sy, int tx, int ty) { history.push(copyBoard()); Piece p board.get(sx, sy); board.set(tx, ty, p); board.set(sx, sy, null); } private Piece[][] copyBoard() { Piece[][] copy new Piece[10][9]; for (int y 0; y 10; y) for (int x 0; x 9; x) copy[y][x] board.get(x, y); return copy; }参数说明history用ArrayDeque当栈push压入pop弹出。注意拷贝的是引用棋子对象本身没复制但棋子坐标在set时会更新还原时要把坐标也还原——所以更稳妥的做法是拷贝时记录每个棋子的坐标或者还原后遍历一遍重设坐标。这个坑我在第一次写的时候踩过悔棋之后棋子位置对了但内部坐标乱了再走一步就出错。5. 避坑与排查中国象棋实现里最容易翻车的 5 个点5.1 现象马能穿墙走法完全不按日字原因canMove里只判断了目标格没判断马腿或者马腿坐标算错。常见错误是dx/2对负数处理不当或者把马腿算成了目标格旁边的格子。解决把马腿坐标打印出来和棋盘对照。正确算法是|dx|2 时马腿在 (xdx/2, y)|dy|2 时马腿在 (x, ydy/2)。写个单元测试把马放在中央枚举所有 8 个日字目标逐个验证。5.2 现象炮不能吃子或者隔着两个子也能吃原因炮的走法分支写错了。不吃子时要求路径全空吃子时要求路径上恰好一个棋子。很多人把两个分支合并成一个导致逻辑混乱。解决把炮的canMove拆成两个私有方法canMoveWithoutCapture和canMoveWithCapture分别判断。路径计数用一个变量从起点到终点逐格扫描遇到棋子就加一最后根据是否吃子判断计数。5.3 现象走完一步后老将被对方棋子攻击但程序没判负原因走法生成时只判断了形状合法没有做试走和将军过滤。或者isKingAttacked只检查了车和炮漏了马、兵这些。解决isKingAttacked要覆盖所有敌方棋子的攻击。最省事的做法是遍历敌方所有棋子对每个棋子调canMove看能否吃到老将位置。虽然性能一般但正确性有保证。注意炮的攻击判定和走法判定不同要单独处理。5.4 现象悔棋之后棋盘状态错乱再走一步就崩原因悔棋还原时只还原了数组没有还原棋子的内部 x、y 坐标。棋子对象自己记的坐标和数组下标不一致后续走法判断全错。解决还原时遍历整个棋盘对每个棋子重设坐标或者干脆在Board.set里统一维护坐标还原时也走set。我现在的习惯是棋盘状态只信数组棋子坐标每次从数组反推不单独存。5.5 现象界面卡死点击没反应原因走法生成或将军判定里写了死循环或者递归没有终止条件。常见于将帅照面判断里范围算错导致循环不结束。解决在走法生成和攻击判定里加日志打印当前处理的棋子和坐标。用 IDE 的调试器打断点看卡在哪一行。另外 Swing 的事件处理不要在 EDT 里做耗时操作如果后面加 AI 搜索要放到SwingWorker里。6. 进阶技巧用位棋盘加速走法生成与一个可验证的测试习惯当你把基础版本跑通之后如果想让走法生成快一个数量级可以试试位棋盘bitboard。核心思路是用 long 的 64 位表示棋盘但中国象棋有 90 个格子一个 long 不够常见做法是用两个 long 或者一个long[2]每个 bit 对应一个格子。红方一个位棋盘黑方一个位棋盘棋子类型再各一个。位棋盘的好处是走法生成可以用位运算批量算。比如车的走法可以用预计算的「射线掩码」加Long.numberOfTrailingZeros快速找到阻挡位置。马和象的走法可以直接查表因为它们的走法只和位置有关和棋盘其他子无关除了马腿和象眼这两个也可以用位运算处理。不过位棋盘对新手来说门槛不低我建议先把数组版本写对、写稳再考虑优化。下面给一个位棋盘初始化的片段让你感受一下思路public class BitBoard { // 用两个 long 表示 90 个格子index y * 9 x private long low; // bit 0..63 private long high; // bit 64..89 public void set(int x, int y) { int idx y * 9 x; if (idx 64) low | (1L idx); else high | (1L (idx - 64)); } public boolean get(int x, int y) { int idx y * 9 x; return idx 64 ? ((low idx) 1L) 1L : ((high (idx - 64)) 1L) 1L; } }参数说明idx是线性下标low存 0 到 63high存 64 到 89。set用或运算置位get用移位和与运算取值。这个结构本身很简单难的是基于它写走法生成需要预计算大量掩码表。比位棋盘更值得养成的习惯是每写一个棋子的走法就配一组单元测试。我一般会建一个MoveTest类用 JUnit 把马、象、炮、车的边界情况各写五六个用例比如马在角落、象在河界、炮在底线。每次改完规则跑一遍几分钟的事能省掉大量手动点界面验证的时间。这个习惯是我做了几个棋类项目之后才养成的早期靠肉眼测一个马腿 bug 查了一晚上血泪经验。还有一个实用技巧把棋盘状态序列化成字符串方便复现 bug。比如用 FEN 类似的格式红方大写、黑方小写空位用数字。出问题时把字符串贴到测试里比描述「马在左边第三格」清楚得多。这个后悔药希望你一开始就备好。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑