资讯动态

Java实战:用A*搜索与Swing从零实现数字华容道石头迷阵

发布时间:2026/10/17 19:02:30 来源:尧图企业网站定制
简介一份面向Java初学者与课程设计人群的数字华容道/石头迷阵游戏完整源码包。项目采用经典滑块谜题玩法通过打乱数字方块顺序、点击移动与空白块交换完成按序排列目标覆盖界面绘制、图片切分、移动判定与胜利检测等核心逻辑可直接解压导入IDEA运行学习也可作为毕业设计或课堂实训的参考模板。压缩包共22个文件以18个PNG图片素材为主覆盖数字块、背景及胜利画面等视觉元素另有3个Java源文件实现主界面与核心逻辑1个Iml文件为IDEA工程配置整体仅372KB结构紧凑便于理解代码与资源文件的关系。目前已有1018人浏览学习在同类课程设计与项目实战资源中具有较高参考热度。通过这份代码读者能掌握Swing/JFrame窗口编程、图片资源加载与切分、事件监听、随机布局及状态管理的基本写法同时获得一套可二次开发的小游戏框架理解从设计到实现的完整流程。1. 数字华容道实战这可能是你练 Java 算法与 UI 最好的“小玩具”你翻开任何一个 Java 面试题库十有八九能看到“数字华容道”的影子。它就是那个十五宫格的滑块游戏要求把打乱的数字按顺序归位。市面上教程很多但大部分只贴一段杂乱代码没讲清背后的状态搜索和 UI 联动思路你抄下来也改不动。这篇文章要带你用 Java 从零实现数字华容道也就是升级版的石头迷阵——添加石头障碍块让解局逻辑更贴近真实五子棋、迷宫题目里的路径代价问题。适合三类人准备 Java 面试、做课程设计、想真正吃透 A* 搜索与 Swing 事件模型的读者。你不需要多深的前置知识但要有动手跟完的耐心。我会把算法选型、参数细节和翻车重灾区都摆出来照着落地即可。2. 先立住算法底座状态表示、可解性与 A* 搜索的选型理由2.1 为什么不能让“随机洗牌”直接决定胜局很多新手写数字华容道的第一步是生成一个随机排列然后让玩家去拼。这是一个隐蔽的大坑——只有 50% 的随机排列是可解的另外一半怎么挪都无解。这个结论来自排列的逆序数奇偶性。经典 4x4 十五数字谜15-puzzle里空白格在右下角时要求排列逆序数为偶数才可解如果空白格位置变化可解性判断要把「空白格所在行号」与「逆序数奇偶性」合并考虑。石头迷阵如果有多个空白位或障碍格不参与移动可解性会更复杂实战中干脆用“从终局反向走”的方式生成新游戏天然保证可解。这个“反向走”的做法非常稳妥先把棋盘恢复为终局状态然后随机把空白格与相邻数字格交换几百次。每一次交换都是合法移动因此从终局出发得到的任何盘面都一定存在一条解谜路径。这比直接随机排列再校验逆序数省心得多。我在项目里用这个方法后再没收到过“无解开局”的工单。2.2 状态设计二维数组不是最优一维 int 数组更适合搜索在完成正常的键盘/鼠标交互之前你得先定义清楚棋盘的数据结构。很多初学者用一个int[4][4]二维数组表示盘面0 表示空位。这在显示逻辑上很直观board[row][col]直接对应界面格子但要进入 A* 搜索时二维数组的拷贝开销和相等判断会拖慢性能。高频做法是另维护一个一维int[16]索引 index 与行、列互相换算// 一维索引转行、列 int row index / 4; int col index % 4; // 行、列转一维索引 int index row * 4 col;这种表示被搜索算法视为“状态”配合自定义的equals()与hashCode()在开放表里判重才高效。你后面做界面渲染时再从一维状态映射到二维不改动核心模型。注意实体类上要重写这两个方法否则 HashSet 去重会落到引用比较上A* 可能死循环。2.3 A* 搜索是主流解法的原因启发式函数衡量“离目标还有多远”如果只做玩家手动玩的游戏根本不需要 A*键盘事件直接改写盘面就行。但项目实战里加“自动求解”能极大提升完成度和面试谈资。A* 的评价函数为f(n) g(n) h(n)g是从起点到当前状态的实际步数h是当前状态到终局的启发式估计。四个方向移动的代价均为 1所以g很容易累加关键在于h的设计。数字华容道主流的h有两种曼哈顿距离启发式把每个数字当前位置到目标位置的横向距离加纵向距离求和以及线性冲突启发式同一行/列里两个数字的移动方向互相“阻挡”时额外加 2 的惩罚。线性冲突启发式会让搜索剪枝效率显著提升因此我建议直接采用这个更强的变体private int heuristic(int[] board) { int manhattan 0; for (int i 0; i board.length; i) { int val board[i]; if (val 0) continue; // 目标位置数字 val 应该在索引 val 处 int targetIdx val; manhattan Math.abs(i / 4 - targetIdx / 4) Math.abs(i % 4 - targetIdx % 4); } // 线性冲突检测这里只演示一行列冲突同理 for (int row 0; row 4; row) { for (int a 0; a 4; a) { int idxA row * 4 a; if (board[idxA] 0) continue; int targetRow board[idxA] / 4; if (targetRow ! row) continue; for (int b a 1; b 4; b) { int idxB row * 4 b; if (board[idxB] 0) continue; if (board[idxB] / 4 ! row) continue; // 数字大的本应位于更右但实际在左冲突 if (board[idxA] board[idxB]) { manhattan 2; } } } } return manhattan; }这里有个细节不能忽略数字值直接作为目标索引的前提是终局状态为“从左到右、从上到下依次 1 到 150 在末尾”。如果你的终局是“15 在右上”等变体就必须维护一张目标位置映射表否则启发式函数完全失效A* 会变成没头的苍蝇。参数上越强的启发式意味着更多计算量但在 4x4 数字谜上线性冲突带来的分支削减远超这点开销。2.4 A* 主循环的实现骨架开放表、闭合表与前驱链实现 A* 不只算一个f值还得把搜索路径还原出来否则你只知道“解得了”不能把步骤播给玩家看。我一般用PriorityQueueNode作为开放表Node里持有当前状态、g值、f值以及父节点指针闭合表用HashSetState存已经扩展过的状态找到一个状态等于目标状态时顺着父节点链回溯得到路径再反转即为移动序列。// 核心搜索代码骨架伪代码改 Java 后 PriorityQueueNode open new PriorityQueue(Comparator.comparingInt(n - n.f)); MapState, Integer gScore new HashMap(); // 记录最低 g SetState closed new HashSet(); open.add(new Node(startState, 0, heuristic(startState), null)); gScore.put(startState, 0); while (!open.isEmpty()) { Node current open.poll(); if (current.state.equals(goalState)) { return buildPath(current); // 回溯父节点得到 ListDirection } closed.add(current.state); for (Direction dir : Direction.values()) { State next current.state.move(dir); // 返回新状态非法移动返回 null if (next null || closed.contains(next)) continue; int tentativeG current.g 1; if (tentativeG gScore.getOrDefault(next, Integer.MAX_VALUE)) { gScore.put(next, tentativeG); open.add(new Node(next, tentativeG, tentativeG heuristic(next.state), current)); } } } return null; // 说明不可解理论上前述洗牌法不会走到这里参数上要注意PriorityQueue的排序器只用f字段遇到两个节点f相同时顺序不稳定但这不会影响正确性只会影响搜索步数的微小波动。若你发现搜索路径很长通常是因为gScore比较策略写错了方向把tentativeG与当前节点的g比较而不是与既有最优值比较这会导致已找到的较优路线被更差路线覆盖。3. 游戏可玩化改造从 Swing 界面到键盘监听、随机布局生成3.1 Swing 是 Java 课设的稳妥选项石头元素如何画进棋盘控件选择上常见做法是JPanel重写paintComponent配合BufferedImage做双缓冲避免界面闪烁。每一格绘制时判断board[row][col]为 0 绘制空白为 -1石头绘制灰色纹理为其他数字绘制圆角矩形和居中数字。石头块就是不可移动的障碍物这会让玩家路线规划产生质的改变也贴住“石头迷阵”的名称。这里给出绘制的核心片段Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g.create(); // 开启抗锯齿与双缓冲 g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { int val board[row][col]; int x col * CELL_SIZE; int y row * CELL_SIZE; if (val 0) { g2.setColor(new Color(230, 230, 230)); g2.fillRect(x, y, CELL_SIZE, CELL_SIZE); } else if (val STONE) { g2.setColor(new Color(120, 120, 120)); g2.fillRect(x, y, CELL_SIZE, CELL_SIZE); g2.setColor(new Color(90, 90, 90)); g2.drawLine(x 4, y 4, x CELL_SIZE - 4, y CELL_SIZE - 4); } else { g2.setColor(new Color(60, 130, 210)); g2.fillRoundRect(x 4, y 4, CELL_SIZE - 8, CELL_SIZE - 8, 12, 12); g2.setColor(Color.WHITE); g2.setFont(new Font(SansSerif, Font.BOLD, 20)); String text String.valueOf(val); FontMetrics fm g2.getFontMetrics(); // 让数字居中x (格子宽-文字宽)/2 int textX x (CELL_SIZE - fm.stringWidth(text)) / 2; int textY y (CELL_SIZE - fm.getHeight()) / 2 fm.getAscent(); g2.drawString(text, textX, textY); } } } g2.dispose(); }参数解读CELL_SIZE通常设为 90 像素太大会超出屏幕太小则触屏操作误触ROWS与COLS若做成 4x4 则CELL_SIZE可以适当增加。石头数量我用 2 块起步再往上每加一块状态空间和玩家计算量都会翻倍新手建议 2~4 块即可。整个 JPanel 放进JFrame时要调用setPreferredSize而不是直接setSize否则布局管理器可能把面板压缩成一条线。3.2 键盘与鼠标双向操作别再让鼠标点击事件被按钮“吃掉”数字华容道项目里事件监听写不好最常见的问题有三个键盘方向键没反应、点击只对部分格子生效、快速连按导致界面卡死。第一个问题通常是因为JFrame没有获得焦点需要在frame.setVisible(true)之后调用frame.requestFocusInWindow()。第二个问题是因为你给格子加了MouseListener但点击区域受透明组件影响处理方式是在paintComponent里根据鼠标坐标换算行列而不是为每个数字单独创建JButton。第三种情况要记得Timer限速或者直接在移动方法里加“当前动画未完成则忽略新事件”的布尔锁。// 鼠标换算示例panel 上点击位置 - 棋盘行列 panel.addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { int col e.getX() / CELL_SIZE; int row e.getY() / CELL_SIZE; // 如果点击的是空白格相邻的数字执行移动 if (isAdjacentToEmpty(row, col)) { moveTile(row, col); } } }); // 键盘监听方向键移动空白格与相邻数字格交换 panel.addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { int emptyRow getEmptyRow(), emptyCol getEmptyCol(); switch (e.getKeyCode()) { case KeyEvent.VK_UP - moveTile(emptyRow 1, emptyCol); case KeyEvent.VK_DOWN - moveTile(emptyRow - 1, emptyCol); case KeyEvent.VK_LEFT - moveTile(emptyRow, emptyCol 1); case KeyEvent.VK_RIGHT - moveTile(emptyRow, emptyCol - 1); } repaint(); } });这里的逻辑说明方向键映射采用“让玩家觉得数字在移动”还是“让空白格反向移动”其实是一个产品细节。我采用空白格反向移动的实现按“上”会把空白格下方的数字移到空白位置视觉上等效于手指推数字向上。参数上isAdjacentToEmpty判断两格坐标差的绝对值之和等于 1 即可如果你加了石头块必须额外判断移动目标不是石头否则会出现覆盖石头的非法状态。3.3 新游戏布局生成反向洗牌次数与石头位置的重试策略上一章在算法部分提到反向洗牌现在细说工程实现。洗牌次数太小小于 30 次容易撞上近乎还原的盘面玩家一眼能看出答案洗牌次数太大超过 500 次会引入大量重复状态浪费时间。我在 4x4 棋盘上取 120 到 200 之间的随机数按这个次数执行“随机方向移动空白格”这保证随机性又保证可解。要注意洗牌时不能只记Random方向还要排除“往回来一步”的无意义步骤// 洗牌逻辑每次随机选一个合法方向避免立即反向 int lastDir -1; for (int i 0; i shuffleSteps; i) { ListInteger legalDirs new ArrayList(); for (int d 0; d 4; d) { if (canMoveEmpty(d) d ! (lastDir ^ 1)) { legalDirs.add(d); } } if (legalDirs.isEmpty()) { legalDirs.add(lastDir); // 兜底避免死循环 } int dir legalDirs.get(random.nextInt(legalDirs.size())); moveEmpty(dir); lastDir dir; }石头位置生成则用重试策略先在棋盘内随机选择坐标若该坐标已经是 0 或已设置石头重新采样。石头不能占据初始空白格否则空白格被困在一个封闭区域里游戏直接无解。更稳妥的做法是先选空白格位置再选择石头坐标然后跑一遍 BFS 验证从空白格能到达所有非石头的数字格子。这个 BFS 验证成本很低但能彻底避免“石头围城”这种玄学问题。4. 石头迷阵的规则改造与代码组织让项目不只有“能玩”4.1 石头块参与寻路A* 里把石头视为不可通行格子的改法如果只做数字华容道搜索代码其实已经完整但标题里提到“石头迷阵”就要让石头块成为界面上不可移动的障碍物。此时 A* 的相邻节点扩展必须过滤掉指向石头的状态。具体实现是move(dir)方法里先计算空白格的新位置若新位置越界或board[newRow][newCol] STONE直接返回null。启发式函数保持不变因为每个数字的目标位置仍固定石头只是挡路的。这样搜索器不需要额外改动核心 diff 只有一行// 原来是next current.state.move(dir) // 现在在 move 内部检查 if (board[targetRow][targetCol] STONE) { return null; }这里想提醒一件事“石头迷阵”也可能被理解为滑块谜题的另一种变体即石头块本身也要被推到目标位置。两类玩法差异很大我这边按“石头是障碍”讲解是因为它更贴合多数课程设计题目描述而且既能练 A* 又能练 UI 渲染。如果你需要做“推箱子风格”的石头需把 push 判定加进移动逻辑复杂度会提升一个量级建议单独另起一个类不要在数字搜索器里混写。4.2 项目模块划分core、ui、search 三层别把所有代码塞进 Main很多初学者一个Main.java写完所有代码调试时改一处要滚动几百行。这个项目虽小但分层对后续扩展和面试讲项目都有利。常见做法是拆成core棋盘状态与移动规则、searchA* 与启发式、uiSwing 面板与事件、Main启动入口四个包。每个包之间只通过公开方法交互比如ui不直接访问core的字段而是调用moveTile(row, col)返回布尔值search则接受int[16]状态与石头掩码。// core/BoardManager.java 核心接口示例 public class BoardManager { private final int[] board; // 一维盘面 private final boolean[] stoneMap; // 石头掩码 private int emptyIndexId; public boolean isLegalMove(int row, int col) { ... } public boolean moveTile(int row, int col) { ... } public int[] snapshot() { return board.clone(); } // 防御性拷贝 public static int[] restoreGoalState() { ... } }参数说明stoneMap用布尔数组比直接塞一个魔法值进board更清晰因为board里的数字 1~15 是移动主体0 是空位石头是例外值。防御性拷贝snapshot()必须使用clone()不能直接返回内部数组引用否则外部搜索器修改数组会污染界面状态。这一条是我被血泪教育过的曾经因为返回了引用自动求解过程中界面棋盘被搜索器“改成一团乱麻”。4.3 自动求解按钮与步数回放的线程注意事项加入“自动求解”按钮后最经典的翻车现场是界面冻结。原因很简单你在 Swing 事件派发线程里直接运行 A*搜索耗时几千毫秒到几秒不等界面没法重绘。解决方式是把搜索任务丢到SwingWorker或普通Thread里计算计算完毕后用SwingUtilities.invokeLater切回事件线程刷新界面。下面这个骨架可以直接抄SwingWorkerListDirection, Void solver new SwingWorker() { Override protected ListDirection doInBackground() throws Exception { return AstarSolver.solve(currentState, stoneMap); } Override protected void done() { try { ListDirection steps get(); playSteps(steps); // 逐帧播放移动动画 } catch (InterruptedException | ExecutionException e) { statusLabel.setText(求解失败 e.getCause().getMessage()); } } }; solver.execute();参数与播放细节playSteps建议每 80~150 毫秒走一步太快玩家看不清楚太慢显得卡顿。动画过程中要设置按钮不可用防止玩家点击“新游戏”把正在播放的步骤和现有盘面状态搞混。播放结束恢复按钮可用状态这是最容易忽略的边界条件。5. Java 项目实战避坑手册五个高频翻车点与解决方案5.1 现象逆序数判断写错导致一半新游戏无解我见过不少人在isSolvable方法里只数逆序数不考虑空白格位置。在 4x4 标准终局下空白格在右下角只看逆序数奇偶性可行但如果你的终局设计成空白格在左上角或石头迷阵加了障碍块让空白格位置漂移这个判断直接失效。解决方式如果是自定义终局就用“从终局反向洗牌生成”如果坚持随机排列必须按“空白格所在行到目标行的距离之奇偶性与逆序数奇偶性同或”的方式判断否则游戏无解率接近 50%——这是数学定理不是 bug。5.2 现象A* 搜索极慢几秒钟无法解答原因通常有两个启发式函数未乘任何大于 1 的系数但精度不足或闭合表判重只用了数组equals而没有重写hashCode。每次从PriorityQueue取出节点后都做一遍Arrays.equals成本会随着扩展节点数上升呈线性恶化。解决方式为状态封装一个不可变对象重写equals与hashCode或者直接用short数组编码成一个long用原始类型做键这在 Java 项目实战里是最省心的性能优化。如果你发现解出来的步数比理论最优值长很多那就要检查gScore更新逻辑是不是只更新了“值”但没把对应节点重新入队。5.3 现象棋盘移动动画出现“穿模”数字重叠或者石头被推动最普遍的根因是移动方法只修改了数值但没有同步更新 UI 线程持有的currentState。例如搜索器用了一个副本状态UI 保存了另一个状态两者失同步后所有动画坐标都不一致。解决方式抽象出唯一的BoardManager作为数据源移动操作全部走moveTile方法并在方法内统一更新数据与通知观察者界面上不再允许任何地方直接写board[i] x。5.4 现象按钮上的字体和数字变模糊界面缩放后布局错乱这是因为没有为JFrame设定合理的布局并让面板尺寸自适应。常见错误是在paintComponent里写死坐标当窗口拉伸时画面错位。解决方式在frame.setResizable(false)固定窗口尺寸或者在componentResized事件里重算CELL_SIZE。对于课设演示固定尺寸更省心也能避免高分屏下的字体渲染模糊。如果你的开发机是 macOS注意CELL_SIZE不要低于 60否则数字会叠在一起。5.5 现象项目在命令行javac编译通过但java -jar运行报 “Could not find or load main class”这种错误大概率是Main-Class没在MANIFEST.MF里配置或打包时把core、search等内部包漏了。解决方式统一用 Maven 或 Gradle 管理项目配置maven-jar-plugin或shadow插件打可执行 fat jar。如果坚持手动javac记住编译命令行里必须把源码根目录作为-sourcepath并且jar cfe手动指定主类全限定名例如com.example.Main。这不算多高深的技术但在答辩演示当天翻车的概率极高。6. 进阶验证自动测例、步数统计与分析工具附我的调试习惯完成一个能玩、能自动解的数字华容道项目后你应该再做一件大多数教程没讲的事写一个可重复运行的自动验证入口。我的做法是写一个VerifySolver类遍历一批随机生成的盘面调用 A* 求解断言能找到可行解且步数不为 0同时统计步数分布与求解耗时。这个机制能保证你在修改启发式或移动逻辑时不会悄悄引入回归 bug。对面试来说这个测试类比界面截图更有说服力。// VerifySolver 核心批量验证可解性 for (int i 0; i 200; i) { int[] state BoardGenerator.generateShuffled(120); int steps AstarSolver.solve(state, stoneMap).size(); if (steps 0) { System.err.println(FAIL at case i : 无解); return; } if (i 5) { System.out.println(case i 最少步数 steps); } } System.out.println(全部 200 个用例通过);参数说明这里的洗牌步数 120 是最小边界你要覆盖小于预期步数的场景才能测出手牌是否偏离最优值。输出“最少步数”是相对 A* 的闭包解不是数学最优解除非你另写 IDA*但面试里说清楚区别就足够。统计耗时用System.nanoTime()但不要打印每次耗时只打印平均与最大避免热词误伤性能分析。一个更值得做的验证是手动比对把 A* 解出的步骤在 UI 上以 300 毫秒每步播放你观察数字是不是真的按路径归位。曾经我发现搜索器输出路径没问题但播放逻辑在“向上移动空白格”时写反了方向导致画面倒退这种 bug 只有肉眼播放才能暴露。所以我后来坚持在自动验证之外保留人工抽查流程。参数层面的调优还有一个点容易被忽略石头迷阵的石头数量变化会影响解的难度层级。我建议做三个预置难度4x4 无石头为入门4x4 两块石头為进阶5x5 三块石头为挑战。5x5 状态下 A* 的状态空间膨胀极快建议把启发式换成曼哈顿线性冲突的强化版并限制求解时间超时时给玩家提示“此盘面过于复杂建议手动尝试”。这种做法既体现了对算法边界的认知也不会让用户苦等。最后分享我的调试习惯先用一个格式化输出方法把int[16]打印成 4x4 矩阵每步状态都输出到日志然后对比 A* 路径上的每步状态是否满足移动合法性。这个习惯帮我抓出过至少三次移动规则写反的问题。做完这些你可以自信地说这个 Java 项目实战作品能讲算法、讲工程化、讲测试而不是只给面试官看一个能动的界面。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑