ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于JAVA的华容道小游戏:BFS自动求解与Swing界面实现解析

基于JAVA的华容道小游戏:BFS自动求解与Swing界面实现解析

简介:一份基于JAVA的华容道小游戏压缩包,非常适合Java初学者通过实战巩固语言基础,也可用于课程设计、毕设参考或自学项目,对应huarongdao标签。包体数据:共3个文件,全部为.java源代码,压缩包仅2KB,分别承担游戏主窗口与事件响应、棋子对象定义和棋盘状态与移动逻辑,结构紧凑,便于快速定位核心代码。目前已有414人学习浏览,在同类型入门小项目中具备一定参考热度。项目以二维数组表示棋盘格子,借助Swing组件搭建图形界面,并通过ActionListener监听玩家点击,完整展示了从界面搭建到游戏规则判定的编码流程;同时还涉及面向对象建模、合法移动判断、边界与阻挡处理等内容,并可结合Timer类实现移动动画、用异常处理增强健壮性。对于想从控制台程序过渡到图形交互应用的初学者,这份源码提供了可运行、可修改的清晰范例,既能帮助理解GUI编程与事件驱动机制,也能作为二次开发的基础框架。

1. 基于JAVA的华容道小游戏解析:从界面到搜索,一个被低估的入门项目

有段时间我在帮一位学弟做课程设计,他拿来的题目就是“基于JAVA的华容道小游戏”。我原本以为这又是一个堆JButton的作业级Demo,结果打开代码看了一晚上,发现事情没那么简单:项目里不仅有标准的棋盘建模、鼠标拖拽移动,还藏着一套完整的BFS自动求解器,甚至附带了自定义关卡数据和最少步数校验。对新手来说,它是接触Swing编程、理解MVC拆分最好的过渡项目;对熟手来说,里面“界面线程与搜索线程怎么协作”“棋盘状态如何编码去重”这两件事,恰好是很多生产项目里相似问题的缩小版。这篇文章就按我拆包时的顺序,把项目结构、核心算法、运行方法和二次开发思路全部过一遍,保证你拿到手能跑通,也能改出自己的版本。

2. 项目结构与模型设计:MVC变体下的华容道数据层

打开源码包第一眼,包结构干净得像被整理过。与我预想中所有代码堆在一个Main类里不同,这份工程分成了model、search、ui、util四个包,主入口在ui.MainFrame。对于一个课程设计级别的华容道来说,这种分层明显是刻意为之:model只管棋盘状态和移动规则,search只做路径搜索,ui负责渲染和鼠标事件,util放编码工具。好处很直接——自动求解器不依赖任何Swing类,用单元测试可以直接验证BFS逻辑正确性,不用把窗口弹出来手动点。

2.1 技术选型与包结构:为什么是Swing而不是JavaFX

界面层用的是Java Swing,JFrame做窗口,JPanel做画布,鼠标事件直接绑定在画布上。这个选择在今天看不算新潮,但放在华容道这个场景里非常务实:华容道棋盘是固定行列的格子,没有任何复杂样式,Swing的GridBagLayout配合自定义paintComponent重绘完全够用,且JDK自带、不用引额外的依赖。JavaFX虽然更好看,但打包体积、运行环境要求都更高,很多同学在课程设计答辩现场才换机器演示,Swing的兼容性显得更重要。

完整包结构如下:

src/ ├── model/ │ ├── Block.java // 单个块(曹操、将军、小兵) │ ├── Board.java // 棋盘数据与移动规则 │ ├── GameState.java // 一局游戏的完整状态 │ └── MoveAction.java // 一次移动的动作描述 ├── search/ │ ├── Solver.java // 求解器接口 │ └── BfsSolver.java // 广度优先搜索实现 ├── ui/ │ ├── MainFrame.java // 主窗口,启动入口 │ ├── BoardPanel.java // 棋盘的绘制与鼠标事件 │ └── ControlPanel.java // 按钮、步数显示、自动求解按钮 └── util/ └── BoardCodec.java // 局面编码与解码

每个类职责单一,没有出现“万能类”。尤其BoardCodec这个工具类,是我在拆包时最欣赏的部分——它把棋盘转成字符串,既用于文件的存取,也用于BFS判重。很多作业里的华容道项目跳过这一步,直接在搜索里用二维数组做equals判重,性能又差又容易出错。

2.2 棋盘与块的建模:内存里怎么描述曹操和五个横竖块

先看Block.java,这个类负责描述棋盘上一个矩形块:

public class Block { private final int id; // 块编号,1~12 private int row; // 左上角所在行,0起始 private int col; // 左上角所在列,0起始 private final int width; // 占宽,单位:格 private final int height; // 占高,单位:格 private final Color color; // 渲染颜色 public Block(int id, int row, int col, int width, int height, Color color) { this.id = id; this.row = row; this.col = col; this.width = width; this.height = height; this.color = color; } public boolean occupies(int r, int c) { return r >= row && r < row + height && c >= col && c < col + width; } }

这里的关键参数是row、col、width、height,全部以“格”为单位,而不是像素。例如曹操这个块,经典布局下左上角在(1, 1),宽2格、高2格,那么在画布上它的像素范围就是col * tileWidth到(col + width) * tileWidth。把像素换算放到UI层做,模型层就永远不会被窗口尺寸干扰,这也是后面能轻松做窗口缩放的原因之一。

Board.java内部维护两个数据结构:一个是int[][] grid,它是一个4行5列的二维数组,grid[r][c]存的是块的编号,0表示空格;另一个是List<Block> blocks,按编号索引。用二维数组的好处是坐标判断非常快——判断某个格子能不能走,只需要查数组里对应位置是否为0。occupies方法则用于反向判断一个块是否覆盖了某些格子,这两个操作配合起来就是整个移动规则的地基。

2.3 局面编码与初始化:怎么把4×5棋盘变成一串文本

华容道的经典布局是固定的,但这份代码把关卡数据外置成了文本数组,这给二次开发留了很大余地。初始化代码大致是:

public class BoardFactory { public static Board createClassicLayout() { Board board = new Board(4, 5); // 4行5列 board.addBlock(new Block(1, 0, 0, 1, 1, Color.YELLOW)); // 左上小兵 board.addBlock(new Block(2, 0, 1, 2, 1, Color.BLUE)); // 横块1 board.addBlock(new Block(3, 0, 3, 1, 1, Color.YELLOW)); // ... 继续添加其他块 board.setExitRow(1); // 出口在(1, 3)和(2, 3),曹操必须移动到这两行 return board; } }

初始化的顺序有讲究:先建棋盘、再加块、最后设置出口。出口的位置固定在第1行和第2行的第3列,也就是4行5列棋盘底部正中那个缺口。setExitRow之后,胜利判定只需要检查曹操(id为1的那个块)是否满足block.row == exitRow && block.col == 3。

BoardCodec的编码方法也是华容道项目里值得抄的部分:

public class BoardCodec { public static String encode(Board board) { StringBuilder sb = new StringBuilder(); for (int r = 0; r < board.getRows(); r++) { for (int c = 0; c < board.getCols(); c++) { sb.append(board.getGridValue(r, c)).append(','); } } return sb.toString(); } }

这个编码把整个棋盘序列化成“0,1,2,2,3,4,… ”这样的字符串,每个数字是格子里的块编号。它的核心价值在于:BFS搜索时,HashSet<String>判断一个局面是否访问过,用的是字符串哈希,比逐格比较二维数组快一个量级,而且调试时直接打印字符串就能看到整个棋盘状态,比打印二维数组直观得多。我一般在日志里输出编码结果,搜索结束后再解码回棋盘,整个链路非常顺畅。

3. 核心模块拆解:移动判定、自动求解与界面刷新的完整链路

华容道表面上是拖拽块的游戏,但代码里最核心的部分其实是三个:移动判定、自动求解、UI联动。这三块分别对应Board.canMove/move、BfsSolver、BoardPanel的鼠标事件处理。下面按数据流顺序逐个拆。

3.1 移动判定:越界检查与占位冲突

canMove是移动的第一步,它接收目标偏移量dx和dy,返回是否能移动。这个方法的厉害之处在于把所有的边界条件收敛在一起:

public boolean canMove(Block block, int dx, int dy) { int newRow = block.getRow() + dy; int newCol = block.getCol() + dx; // 第一步:检查目标位置是否越界 if (newRow < 0 || newRow + block.getHeight() > rows || newCol < 0 || newCol + block.getWidth() > cols) { return false; } // 第二步:检查目标区域内是否全是空格(或属于自己) for (int r = newRow; r < newRow + block.getHeight(); r++) { for (int c = newCol; c < newCol + block.getWidth(); c++) { int occupied = grid[r][c]; if (occupied != 0 && occupied != block.getId()) { return false; // 被其他块挡住 } } } return true; }

参数说明:dx、dy只能是-1、0、1三个值,且不会同时非零,也就是说这个游戏不支持斜向移动,一次只动一行或一列。第一步的越界检查考虑了块本身的长宽,比如曹操2×2,它的newRow必须小于rows - 2,否则底边就出棋盘了。第二步的检查逻辑用occupied != block.getId()排除了自己占的格子,这样块在原地或贴着自身移动时不会误判为冲突。

实际执行移动时,move方法会先更新grid数组——把块原来覆盖的格子全部置0,再把新位置覆盖的格子置成块的id,最后更新Block的row和col。这个顺序不能反,否则会出现边清边写互相覆盖的脏数据。我在调试时犯过一次这个错,具体现象是块移动后棋盘上多出一块残影,后来查了才发现是旋转移位顺序问题。

3.2 最少步数求解:把华容道变成BFS

自动求解的部分是这份代码的亮点,也是我判断“这项目不是随便糊的作业”的核心依据。BfsSolver用标准的广度优先搜索,目标状态是曹操到达出口。代码骨架如下:

public class BfsSolver implements Solver { public List<MoveAction> solve(Board board) { String start = BoardCodec.encode(board); Queue<Board> queue = new LinkedList<>(); Map<String, String> parent = new HashMap<>(); // 状态编码 -> 上一状态编码 Map<String, MoveAction> actionFrom = new HashMap<>(); queue.offer(board); parent.put(start, null); while (!queue.isEmpty()) { Board current = queue.poll(); String currentCode = BoardCodec.encode(current); if (current.isSolved()) { return reconstructPath(parent, actionFrom, start, currentCode); } for (Block block : current.getBlocks()) { for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { if (dx == 0 && dy == 0) continue; if (Math.abs(dx) + Math.abs(dy) != 1) continue; if (current.canMove(block, dx, dy)) { Board next = current.clone(); next.move(next.findBlock(block.getId()), dx, dy); String nextCode = BoardCodec.encode(next); if (!parent.containsKey(nextCode)) { queue.offer(next); parent.put(nextCode, currentCode); actionFrom.put(nextCode, new MoveAction(block.getId(), dx, dy)); } } } } } } return Collections.emptyList(); // 无解 } }

这里要重点看两个设计决策:一是parent和actionFrom两张Map代替了常见的“节点对象+父节点指针”结构,因为状态越多,对象越庞大,Map查询的性价比更高;二是clone的使用——BFS必须基于当前局面做试移,不能直接修改原棋盘的grid,所以Board实现了深拷贝。我见过不少华容道实现直接把move写在原棋盘上,导致搜索到一半状态全乱掉,整个BFS变成死循环。克隆方法里要手动新建二维数组和块列表,不能简单Object.clone(),否则只是浅拷贝。

reconstructPath的写法也值得一提,它从目标状态反向找parent,最后反转列表就是我们需要的正序步骤。搜索到解后,UI层会按这个路径列表每隔200毫秒执行一步,看起来就像AI在自动演示。

3.3 界面层:Swing事件与后台搜索的坑

BoardPanel负责接收鼠标事件并翻译成移动请求。关键点是鼠标坐标到格子坐标的换算:

public void mouseReleased(MouseEvent e) { int row = e.getY() / tileHeight; int col = e.getX() / tileWidth; Block clicked = board.findBlockAt(row, col); if (clicked == null) return; // 尝试四个方向的移动 tryMove(clicked, row - clicked.getRow(), col - clicked.getCol()); }

findBlockAt遍历块列表调用occupies方法判断当前鼠标所在格子属于哪个块。这里有个细节:换算用的是e.getY()而不是e.getPoint(),因为在JPanel内部监听鼠标事件时,坐标已经相对于面板左上角了。很多新手在这里直接用e.getLocationOnScreen()这种屏幕坐标,结果点击位置永远偏移一个窗口标题栏的高度。

自动求解执行时,MainFrame会启动一个SwingWorker来做BFS搜索。搜索算法执行完成后,再回到EDT线程逐步回放路径。这样做的原因很朴素:BFS可能跑几百毫秒到几秒,如果直接在主线程搜索,窗口会卡死,MacOS上还会出现“应用无响应”的转圈,Windows下则是整个窗口白屏。SwingWorker的doInBackground跑搜索、done()里取结果的写法,是这个项目里最值得抄的一份代码。

4. 运行与避坑指南:从javac到IDEA,5个常见问题

很多同学下载这个项目后在IDE里一跑就报错,然后开始怀疑代码有问题。其实大部分问题集中在编码、当前目录、Java版本三个点上。下面先说两种标准运行方式,再列我踩过的坑。

4.1 编译运行两种姿势:命令行和IDEA

命令行方式适合没有IDE的环境,在项目根目录执行:

javac -encoding UTF-8 -d out src/model/*.java src/search/*.java src/ui/*.java src/util/*.java java -cp out ui.MainFrame

-encoding UTF-8是必须的,因为源码文件里的中文注释和块名称是用UTF-8保存的,不加这个参数在Windows默认编码下编译会直接乱码或者报“非法字符”错。-d out指定字节码输出目录,-cp out让运行时能找到MainFrame类。

IDEA方式更简单:File → New → Project from Existing Sources,选到项目根目录,Import project from external model选Eclipse或直接Next到底,最后设置Project SDK为JDK 8及以上,直接运行ui.MainFrame即可。这里要提醒一句:如果IDEA提示“No JDK specified”,千万别选JRE,JRE没有javac编译器,运行窗口类也会因为缺少某些API报NoClassDefFoundError。

4.2 运行前的小验证:测一下求解器是否正常

我拆包之后第一件事不是开游戏,而是先写了个main方法测试BFS能不能跑通经典布局:

public class SolverTest { public static void main(String[] args) { Board board = BoardFactory.createClassicLayout(); Solver solver = new BfsSolver(); long start = System.currentTimeMillis(); List<MoveAction> path = solver.solve(board); System.out.println("耗时: " + (System.currentTimeMillis() - start) + "ms"); System.out.println("步数: " + path.size()); } }

正常输出的步数应该在100以内,耗时不超过500毫秒(取决于机器和搜索深度)。如果输出的步数明显异常,或者耗时几十秒出不来,那问题多半出在编码去重上——检查一下BoardCodec.encode是不是漏了空格分隔符,导致“12”和“1,2”两种不同状态被当成同一个。

4.3 避坑记录:5个血泪经验

坑1:中文块名变成乱码方块

现象:运行后棋盘上的“曹操”“关羽”等汉字全部变成空心方块。

原因:源码文件用UTF-8编码保存,但javac在Windows下默认按GBK编译,中文字符被错误解码。

解决:编译命令加-encoding UTF-8;如果你用IDEA,在Settings → File Encodings里把Project Encoding设为UTF-8。这个坑我在第一次运行时必踩,后来已经养成了写编译脚本的习惯。

坑2:点击块没有反应

现象:鼠标在块上按下再松开,块纹丝不动。

原因:可能是监听了mousePressed而不是mouseReleased,或者坐标换算错了。我接手这份代码时用的就是mousePressed,导致只能触发不能确认拖拽结束。

解决:改用mouseReleased处理移动逻辑,并确认换算公式是e.getY() / tileHeight,不是e.getY() / getHeight()。后者会出现一行有多个格子但点哪里都判定成第一行的情况。

坑3:自动求解时窗口卡死

现象:点击“自动求解”按钮后,窗口变成白色,标题栏显示“未响应”,过一会恢复。

原因:BFS搜索直接在EDT线程中执行,长时间计算阻塞了事件分发。

解决:所有搜索逻辑放进SwingWorker的doInBackground,搜索期间禁用求解按钮,done()回调里再执行路径回放。

坑4:窗口缩小时棋盘错位

现象:把窗口拖小后,棋盘方块挤在一起,鼠标点击位置对不上格子。

原因:tileWidth和tileHeight用的初始化时的固定值,窗口尺寸变化后没有重新计算。

解决:在BoardPanel的paintComponent开头动态计算格子大小:tileWidth = getWidth() / cols; tileHeight = getHeight() / rows;这样窗口缩放时整体等比缩放,鼠标换算也永远正确。

坑5:存档后读档,棋盘整体错乱

现象:保存游戏后再加载,发现块的位置全部偏了一格或互相重叠。

原因:存档时只存了grid数组,没有保存块的长宽颜色信息,或者保存时行列顺序弄反了。

解决:存档文件用BoardCodec.encode输出当前局面,读档时用BoardCodec.decode重建块列表。解码时要保证块的id与grid中的编号一一对应,我建议存两条记录:一条是块定义,一条是局面编码,这样就算改了关卡配置,旧存档也兼容。

5. 二次开发扩展:把华容道改成可配置关卡与A*求解器

到这里,这份资源对你来说已经不只是作业,而是可以自己动手改的玩具。我最推荐的三步改造路线是:第一步给游戏加“撤销”按钮,第二步把BFS替换成A*,第三步把关卡外置成文件。

撤销功能是最容易上手的第一步,核心思路是维护一个Deque<Board>栈,每次移动前把当前状态压栈。注意入栈时必须是克隆,否则栈里所有状态指向同一个对象。代码就一行:

undoStack.push(board.clone());

A替换BFS则是算法层面的升级,估价函数可以设计为曹操到出口的曼哈顿距离加上若干惩罚值。这里有个技巧,BoardCodec.encode的状态编码可以直接复用于A的gScore判断,f = g + h就能套进PriorityQueue。实测在同样布局下,A*搜索速度通常比BFS快3到5倍,但代码量也明显增加,适合想做进阶的同学去练手。

关卡外置是最有意思的一步。你可以把BoardFactory.createClassicLayout改成读取文本文件的逻辑,文件格式就用“每行一个块定义”,格式为id,row,col,width,height。改完后,你只需要新增一个文本文件就能出一张新地图,不需要重新编译代码。这就是我前面强调模型与UI分离的价值——数据文件驱动,界面逻辑零改动。

从那以后我给任何Swing项目做改动,都会先确认数据层能否独立出来。哪怕是写一个课程设计,我也会逼自己把棋盘状态和绘制逻辑拆到两个类里,下次改东西时只动其中一处,而不是在paintComponent里角落找坐标偏移。这份华容道项目最大的价值就在于此——它把面向对象、算法、事件驱动三件事揉进了不到两千行代码里,照着跑一遍,比我讲一百句“要分层”都管用。希望帮到你。

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

RELATED READING

延伸阅读

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