☰
基于Java Swing的华容道游戏:从二维数组建模到BFS自动求解
2026/10/13 13:09:36 网站建设 项目流程

简介:面向Java初学者的华容道小游戏项目,压缩包共3个java源文件,整体仅2KB,代码精简完整,适合课程实践或自学入门。项目基于Swing组件构建图形界面,用JFrame做主窗口、JPanel绘制棋盘、JButton触发操作;棋盘状态用二维数组存储,移动逻辑包含越界判断、阻挡检测与合法性校验,并完成胜利条件判定。事件处理通过ActionListener接口实现,Timer类控制棋子移动动画;同时提供错误提示与异常输入拦截,三个Java源文件分别承担棋盘、人物与主流程逻辑,分工清晰,便于阅读和二次修改。整体覆盖Java基础语法、类与对象、事件驱动编程、算法设计和调试技巧等核心知识点,已有414人学习/下载。对想快速上手GUI编程、完成课程设计或深入理解小游戏开发流程的初学者,这份资源是可直接运行、易于扩展的入门样例。

1. 基于JAVA的华容道小游戏:一个能跑能解的Swing棋盘

如果只用一个项目检验Java基础有没有学透,我会选华容道,而不是贪吃蛇。原因很简单:华容道集齐了面向对象建模、集合选型、事件监听、自定义绘制、搜索算法这五块东西,体量却只有几百行代码,特别适合做课程设计作业、面试前练手或者给培训班做结课项目。这个工程包里有完整的Swing界面、鼠标键盘交互、步数和计时统计,还附带了BFS自动求解器。新手可以直接运行源码,照着注释摸清游戏循环怎么写;老手则可以把求解器单独拆出来,用来生成可解乱序开局。核心思路是:棋盘用二维数组存状态,棋子用坐标模型描述,再用自定义JPanel画出来,最后把搜索算法挂到“提示”按钮上。

2. 棋盘与棋子的数据模型:二维数组编码和Swing绘制

2.1 为什么用二维数组加棋子表,而不是直接摆按钮

初写华容道的人最容易想到的做法是用一堆JButton拼棋盘,每颗棋子一个按钮,然后靠按钮布局来限定位置。这个方案做静态演示没问题,但一涉及拖动、重排、状态回溯就会变得非常痛苦,因为按钮的位置和逻辑位置被强耦合了。

我采用的方案是把棋盘抽象成二维数组,数组中每个格子只存“占用棋子ID”,棋子本身再维护一张表,记录id、行、列、宽、高。这样棋盘的物理位置和绘制逻辑完全分离,移动棋子时只需要改格子编号和棋子坐标,界面重绘即可反映最新状态。实现文件里核心类大概是这样的结构。

public class Piece { final int id; // 全局唯一,1号固定为曹操 int row, col; // 左上角所在行、列 final int width; // 占几列 final int height; // 占几行 public Piece(int id, int row, int col, int width, int height) { this.id = id; this.row = row; this.col = col; this.width = width; this.height = height; } public Piece copy() { return new Piece(id, row, col, width, height); } }

这段代码把“一颗棋子的全部信息”放在一个不可变骨架类里。id用于区分曹操、关羽、四个竖将和四个兵;row和col是逻辑坐标,而不是像素坐标,像素坐标在绘制时才换算。copy方法用来生成移动前快照,后面BFS算法里回溯路径时要频繁复制整盘状态,有这个copy方法会方便很多。

棋盘本身的建模在BoardContainer里完成。棋盘逻辑上是一个5行4列的网格,正好对应传统华容道的20格。

public class Board { public static final int ROWS = 5; public static final int COLS = 4; private int[][] grid = new int[ROWS][COLS]; // 格子值 = 棋子id,0表示空 private Map<Integer, Piece> pieces = new HashMap<>(); public void reset() { // 标准横刀立马风格开局,数字含义见注释 int[][] layout = new int[][] { {1, 2, 2, 3}, {1, 2, 2, 3}, {4, 5, 5, 6}, {7, 0, 0, 8}, {7, 9, 10, 8} }; for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { grid[r][c] = layout[r][c]; } } pieces.put(1, new Piece(1, 0, 0, 1, 2)); // 左侧竖将 pieces.put(2, new Piece(2, 0, 1, 2, 2)); // 曹操,2x2 pieces.put(3, new Piece(3, 0, 3, 1, 2)); // 右侧竖将 pieces.put(4, new Piece(4, 2, 0, 1, 1)); // 左上兵 pieces.put(5, new Piece(5, 2, 1, 2, 1)); // 关羽,横刀 pieces.put(6, new Piece(6, 2, 3, 1, 1)); // 右上兵 pieces.put(7, new Piece(7, 3, 0, 1, 2)); // 左下竖将 pieces.put(8, new Piece(8, 3, 3, 1, 2)); // 右下竖将 pieces.put(9, new Piece(9, 4, 1, 1, 1)); // 左下兵 pieces.put(10, new Piece(10, 4, 2, 1, 1)); // 右下兵 } }

这个开局里曹操被两竖一横围住,正下方两个空格就是腾挪空间,非常接近经典“横刀立马”的初始形态。特别注意width和height的赋值方向:width指的是横向占几列,height是纵向占几行,和row、col的维度一一对应,写反了就会出现棋子错位或者重叠,这点后面避坑章节还会再强调。用HashMap存棋子是因为棋子数量少,读取时能按id直接命中,比遍历List更清晰。

2.2 在JPanel上按格子绘制棋盘

界面绘制直接写在自定义JPanel的paintComponent里。每一格先画底色,再根据格子里的棋子ID找对应Piece对象,算出它在像素坐标系里的矩形,然后填充颜色并绘制文字标签。

public class BoardPanel extends JPanel { private Board board; public static final int CELL = 70; // 每个格子边长70像素 @Override protected void paintComponent(Graphics g) { super.paintComponent(g); for (Piece p : board.getPieces().values()) { int x = p.col * CELL; int y = p.row * CELL; int w = p.width * CELL; int h = p.height * CELL; g.setColor(p.id == 2 ? new Color(220, 80, 80) : new Color(90, 140, 200)); g.fillRoundRect(x + 2, y + 2, w - 4, h - 4, 10, 10); g.setColor(Color.WHITE); g.drawString("P" + p.id, x + 20, y + h / 2 + 8); } } }

绘制时有个容易忽略的点:fillRoundRect的括号参数是x、y、宽、高,而不是起止坐标,所以如果直接传p.col和p.col + p.width就会画出一个两倍大的矩形。我习惯先把左上角像素坐标算出来,再单独算宽高,像上面代码这样,肉眼检查公式时不容易出错。曹老板在这里被涂成红色,占用2x2格子,目标是把他的左上行到第三行第一列,也就是出现在网格的最下一排中间,就算脱逃成功。

3. 移动判定与事件交互:点选、方向控制与胜负结算

3.1 移动判定的核心逻辑

每一回合的移动都分四步:找到当前棋子、清空它原来占用的格子、把新位置填上该棋子ID、更新Piece对象里的row和col。这里真正的难点在于移动合法性判断,而不是移动本身。一个棋子能往某个方向走,要求它整个目标区域都在棋盘内,并且覆盖到的每一个格子上都没有其他棋子占用。

public boolean canMove(Piece p, int dr, int dc) { int nr = p.row + dr; int nc = p.col + dc; // 任何一格越界都不能动 if (nr < 0 || nc < 0 || nr + p.height > Board.ROWS || nc + p.width > Board.COLS) { return false; } // 目标区域内不能出现其他棋子的编号 for (int r = nr; r < nr + p.height; r++) { for (int c = nc; c < nc + p.width; c++) { if (grid[r][c] != 0 && grid[r][c] != p.id) { return false; } } } return true; }

dr和dc是方向增量,上移为-1,0,下移为1,0,左移为0,-1,右移为0,1。为什么第一步先做边界判断而不是格子占用判断?因为数组越界会直接抛异常,而格子占用只是返回false,先做边界能让两个条件各自独立,排查问题时更好定位。占用判断里不能只看目标格子是否为0,因为棋子自己可能还没“让位”,如果目标区域有一部分是自己原来占的格,这种情况属于原地踏步,但网格里记录的还是自己的ID,直接当成障碍就会错误判定。

真正执行移动的时候,要先清空旧格子再填新格子,顺序不能反。如果先填新格子,棋子向右移动一列时,新格子恰好是原来右侧的障碍所在,就会把它覆盖成当前棋子ID,导致棋子永久消失。

public boolean movePiece(int id, int dr, int dc) { Piece p = pieces.get(id); if (!canMove(p, dr, dc)) { return false; } // 1. 清掉旧位置 for (int r = p.row; r < p.row + p.height; r++) { for (int c = p.col; c < p.col + p.width; c++) { grid[r][c] = 0; } } // 2. 更新坐标 p.row += dr; p.col += dc; // 3. 写入新位置 for (int r = p.row; r < p.row + p.height; r++) { for (int c = p.col; c < p.col + p.width; c++) { grid[r][c] = p.id; } } return true; }

这段逻辑顺下来以后,判断“是否胜利”就只需要检查曹操的row是否等于3且col是否等于1。曹操height和width都是2,所以只要左上角进入最后两行且占据中间两列,就代表他从底部的出口走了出来。胜利判定用窄条件而不是遍历全盘,跑得快,语义也清楚。

3.2 鼠标点选、方向键移动和状态栏

交互层的设计目标是让玩家只靠鼠标也能玩,只靠键盘也能玩。鼠标点击棋盘时先换算逻辑坐标,然后遍历棋子表找到“包含该坐标的棋子”,把它设为selectedId。再次点击同一颗棋子则取消选中,点击其他棋子则切换选择。

addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { int row = e.getY() / BoardPanel.CELL; int col = e.getX() / BoardPanel.CELL; Integer hitId = findPieceAt(row, col); if (hitId != null) { selectedId = hitId; } else { selectedId = null; } panel.repaint(); } });

键盘监听用KeyAdapter绑定在面板上,需要调用setFocusable(true)并且requestFocus(),否则按键事件不会到达这个面板。方向键只对选中棋子生效,每次按下direction这个动作都会尝试移动并刷新步数。界面顶部放一个“步数:0 用时:0秒”的JLabel,每成功移动一次就step加一并更新文本。

计时用一个javax.swing.Timer来做,delay设为1000毫秒,每秒把seconds计数加一。真正的游戏循环其实不是传统意义上的while循环,而是“事件驱动”,棋盘状态一变就repaint一次,这个思路对从控制台程序转过来的人需要稍微适应一下,刚接触时总觉得少了一个主线程,其实Swing的事件分发线程就是主线程。卡死、界面冻结这类问题很多都出在这里,后面避坑章节会专门讲。

4. BFS自动求解:状态编码、最短路径与“提示一步”

4.1 把棋盘编码成字符串

自动求解器使用的是广度优先搜索,核心前提是能够用字符串唯一代表一盘棋的状态,同时能从这个编码还原出棋局。我采用的编码方式是按行按列把二维数组里的所有格子拼接成字符串,每个格子一个字符,因为id只会是0到10,所以用一位数字就能表达,加一个分隔符反而会让状态串变长,没必要。这样一盘5x4的棋局最终变成20字符的定长串。

public static String encode(Board board) { StringBuilder sb = new StringBuilder(); int[][] grid = board.getGrid(); for (int r = 0; r < Board.ROWS; r++) { for (int c = 0; c < Board.COLS; c++) { sb.append(grid[r][c]); } } return sb.toString(); }

编码长度固定、内容合规,Spring里的字符串相等对比又非常稳定,所以用一个HashSet 就能完成去重。BFS里的状态不只是“棋盘长什么样”,还包含“怎么走到这步的”,所以要用两个Map:一个是visit记录这条状态走过没有,另一个是parent记录这个状态的上一个状态和移动描述。common的做法是单独定义内部类Step,保存prevState、移动棋子id和方向,这样回溯的时候可以一路从目标状态走回初始状态。

4.2 搜索流程与提示按钮的实现

BFS的骨架在工程包里的Solver类里。开始搜索之前先把初始状态设成parent为null,放入队列。队列每弹出一个状态,就按棋子ID顺序尝试四个方向,然后把每个可行移动生成的新状态入队。搜索到的目标状态编码,要求曹操在第3行第1列,对应编码里的第十一位开始的两个格子为2。

public List<String> solve(Board board) { String startCode = BoardCodec.encode(board); Queue<String> queue = new LinkedList<>(); Set<String> visited = new HashSet<>(); Map<String, String> parentMap = new HashMap<>(); Map<String, String> moveMap = new HashMap<>(); queue.offer(startCode); visited.add(startCode); parentMap.put(startCode, null); moveMap.put(startCode, ""); while (!queue.isEmpty()) { String code = queue.poll(); if (isWinCode(code)) { return buildPath(code, parentMap, moveMap); } // 每个状态有4颗棋子的候选方向 for (PieceInfo p : dencodePieces(code)) { for (int dir = 0; dir < 4; dir++) { String nextCode = tryMove(code, p.id, dir); if (nextCode != null && !visited.contains(nextCode)) { visited.add(nextCode); parentMap.put(nextCode, code); moveMap.put(nextCode, "move " + p.id + " dir " + dir); queue.offer(nextCode); } } } } return null; }

isWinCode的判断不通用,因为曹操的ID在不同开局里可能变。我在工程包里的取法是把这个ID作为成员变量注入Solver,并让编码器在生成字符串时额外记住曹操ID,然后isWinCode里检查曹操对应位置。这里的tryMove不会修改原棋盘,而是decode之后复制一份再移动,如果移动失败就返回null。为什么必须复制?因为queue里还要保留当前状态继续尝试其他方向,直接改棋盘就串了。消耗方面,最差情况能到数十万状态,但每个状态只是20字符字符串加一次复制,HashSet去重后内存占用通常不会超过百兆,单机演示完全扛得住。

提示按钮就挂在JButton的ActionListener里复用这个solver,传入当前Board快照,拿到List之后取出第一项,在棋盘上高亮要移动的棋子并自动走掉一步。人工玩的时候装成是“AI给建议”,本质其实是全盘搜索后在公开秘笈。

5. 避坑记录:坐标映射、碰撞检测与Swing线程的常见问题

5.1 行列坐标写反导致棋子斜着画

现象:棋子绘制在棋盘上呈对角线分布,每个棋子的位置好像同时偏移了一格,点选命中的棋子也不是鼠标真正指着的那个。

原因:二维数组下标是grid[row][col],但Swing的绘制定位是x从左向右、y从上向下。绘制代码里如果写成int x = p.row * CELL,int y = p.col * CELL,数组行列和像素坐标就完全反了,整个棋盘沿主对角线翻转。

解决:在BoardPanel里增加一个私有坐标转换方法,所有绘制、鼠标命中、方向计算只调用这一个入口。比如int px = col * CELL; int py = row * CELL,同时给变量命名为col和row而不是x和y,保证从源头就不会混。

5.2 移动棋子时自覆盖,棋子凭空消失

现象:一颗竖将向右移动一列后,棋盘上少了一个棋子,但世界状态里还能看到它的ID。

原因:movePiece里先清空旧格子再写入新格子时,如果新格子的计算是p.col + p.width,那覆盖的是原来的右侧格,而不是移动后的右侧范围。另外,部分实现会直接grid[r][c] = p.id,把旧位置的残留值盖掉后再补写新位置,导致旧位置有两格变成ID、新位置只有一格是ID。

解决:严格按“清空旧区域、更新坐标、写入新区域”三步执行。写完后立刻调用一个断言方法,统计map中棋子的占有格子总数是否仍等于20,不等于就说明覆盖了。

5.3 Swing回调里直接跑BFS导致界面卡死

现象:点击“提示”按钮后整个窗口立刻冻结,状态栏不刷新,约几秒后才恢复,如果开局复杂则永久白屏。

原因:按钮的回调方法运行在Swing事件分发线程上,BFS搜索需要大量创建字符串和遍历队列,几百万次操作一跑,EDT根本腾不出时间处理重绘和键盘事件,所以看起来像死机。

解决:把求解过程扔到SwingWorker<Void, List >的后台线程里执行,done回调再切回EDT取结果。这个改动量不大,但新手十有八九会忽略,毕竟是第一次接触线程模型。

5.4 BFS去重用对象引用导致搜索爆炸

现象:求解器跑出来的结果需要几百上千步,明显超过已知最优步数,且中途出现多次重复回到同一种布局。

原因:自定义的Board对象没有重写hashCode和equals,用HashSet判重时,new出来的不同对象即使内容一模一样,也被当作不同状态,BFS退化成了没有去重的状态树,状态数呈指数级上升。

解决:不要拿对象去重,先用编码器转成字符串再用字符串判重。字符串本身是不可变的,StringBuilder拼接完生成的新串可以可靠去重。这也是模块里encode方法被单独抽出来的原因。

5.5 随机打乱生成的开局无法通关

现象:自己设置了随机开局函数,把曹操随机放一个位置、棋子随机排列,结果求解器返回null,游戏永远走不到终点。

原因:华容道和数字华容道一样受奇偶不变式约束,并不是所有20格摆法都可达。随机摆放大概率生成无解状态,尤其是曹操被堵在死角时更明显。

解决:生成新开局时不直接乱摆,而是从标准开局出发连续随机走N步合法移动,N取50到100。这样生成的新棋局必然跟初始状态同构,可达性天然满足。这个方法也在UI的“随机开局”菜单里用到,每次生成后还可以顺手交给Solver验证一次。

6. 验证与进阶:用求解器反测乱序开局,再看扩展方向

这个工程包里的东西全部跑通后,建议你再做两个验证动作。第一个动作是手动验证求解器:先选择“随机开局”,再立即点“自动解”。如果BFS能在几秒内返回步数,说明这个乱序不是最坏复杂度;如果偶尔出现非常慢的情况,可以把距离调低到30步重新生成,那基本就是一到两秒内出结果。

第二个动作是把求解器当成测试工具而不是游戏外挂。每次修改棋盘布局或者新增棋子类型后,先跑一遍BFS看是否无解。这比用鼠标手动玩几十遍可靠得多,也最能体现自动搜索的价值。不算验证时间,整个模块看下来最大的收益其实是:数据、逻辑、界面三者完全分层,Solver不依赖任何绘制代码,所以它也能被单独拿去跑单元测试,或者嵌入到Web版、移动版的后端里去。

在此基础上扩展的话,我会优先做文本关卡文件而不是继续在代码里加功能。把棋盘布局用五行字符串写到一个txt里,运行程序时按文件名加载,这样就可以把网上流传的名局都导入进来,比如让曹操从中间位置直接溜到出口的近路局,或者用大量横将构成的绕行局。其次是录屏回放功能:每次移动把move命令存进List,通关后按定时器逐条重放,做演示或者交作业录像都很实用。

想起有次给某开发者review代码,里面用了随机摆盘生成开局,结果测试时发现按部就班解不开,查了一下午才发现是奇偶性搞的鬼。从那以后我每次改布局或者新增生成器,都强制先跑一遍BFS验证可达,再交给UI层去显示,这个习惯也直接写进了这个工程的测试目录里。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询