资讯详情

基于Java Swing的华容道游戏:从二维数组建模到BFS自动求解

📅 2026/10/13 13:09:45 | 华诺云谱 👁 阅读
基于Java Swing的华容道游戏:从二维数组建模到BFS自动求解
简介面向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]; // 格子值 棋子id0表示空 private MapInteger, 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 ListString solve(Board board) { String startCode BoardCodec.encode(board); QueueString queue new LinkedList(); SetString visited new HashSet(); MapString, String parentMap new HashMap(); MapString, 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 * CELLint 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根本腾不出时间处理重绘和键盘事件所以看起来像死机。解决把求解过程扔到SwingWorkerVoid, 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层去显示这个习惯也直接写进了这个工程的测试目录里。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑