☰
Java Swing开发石头迷阵:递归回溯迷宫生成与BFS寻路全解析
2026/10/15 17:11:47 网站建设 项目流程

JAVA石头迷阵这个项目,说起来有点意思。它最开始只是我随手写的一个迷宫生成算法Demo——一个二维数组、一个栈、十来行递归,跑出个黑白格路径就完事了。结果玩了两天觉得干巴巴的,就动手给迷宫铺上石头纹理、加了角色、加了推石头解谜,最后硬是整成一个小而完整的Swing游戏:石头迷阵。

所谓“迷阵”,不只是普通的迷宫寻路,它在经典迷宫的基础上叠加了推箱子式的方块解谜:路上会有可推动的石头,你需要把它们推到指定位置,或者借助它们垫脚,才能打开下一段路的开关。整个项目用Java开发,依赖纯JDK的Swing和2D图形接口,不接任何第三方库,能跑Java的地方就能跑,非常适合拿来当Java课程设计、面试作品,或者纯粹想练练算法和图形界面手感的人。下面我把这个项目的玩法、算法、渲染、寻路以及踩过的坑一次说清楚。

1. 为什么叫“石头迷阵”:项目定位与功能拆解

1.1 从迷宫游戏到“石头”主题

大多数迷宫游戏的地图是抽象的矩形通道,玩家看到的是一堆灰格子,玩起来没有沉浸感。我做这个项目时,想要一个视觉上“有重量感”的迷宫:墙体是石块堆砌的质感,地面是压实的石板,出口立着一面旗子,角色是一个简单的小人。这样迷宫看起来像一座远古遗迹,走在里面才有一点探险的味道。

主题定下来后,我把玩法也做了统一:不仅墙是石头,迷宫里还散落着可推动的石头方块。这些石头不是普通障碍物——靠近它时按方向键可以推动,如果石头后面是空地,它会往前滑一格;如果后面是墙、另一个石头或者机关,你就推不动。这样一来,“石头”从单纯的美术素材变成了玩法核心,项目名称“石头迷阵”也就名副其实了。

1.2 最终功能清单

我整理了一下,最终交付的版本包括以下功能:

模块具体功能
地图生成递归回溯法随机生成迷宫,每次开局地形不同
地图渲染Swing双缓冲绘制,石块墙、石板路、出口旗、角色动画帧
角色控制键盘方向键移动,支持长按连续移动,碰撞检测判定
推石头解谜可推动的石头方块,推入凹陷标记点计分,全部推到位触发出口解锁
寻路提示BFS计算最短路径,按F键显示白色脚印提示路线
步数统计记录移动步数、推动石头次数,通关后结算评价
关卡配置通过文本文件配置地图,支持新增关卡,无需改代码
重置重开R键重置当前关卡,Esc退出到选关界面

功能看着多,但核心代码量其实不大,主程序加算法加渲染大约一千行上下。我现在回头看,这个项目最值得讲的部分有两个:一个是迷宫怎么“长”出来,另一个是推石头碰撞怎么判定得干净利落。下面分开说。

2. 迷宫怎么“长”出来:生成算法的选型与实现

2.1 三种迷宫生成算法,我为什么选了递归回溯

市面上迷宫生成的主流算法有这么几种:递归回溯(DFS)、Prim、Kruskal。网上随便一搜都是代码,但实际用起来差别不小。

递归回溯的生成过程很直观:从起点开始,沿着当前格子随机找一个没访问过的邻居,打通墙壁走过去,如果周围没有走过的路了,就原路返回继续找,直到所有格子都被访问过。它生成的迷宫有一条很明显的主干道,分支相对少,整体视觉上比较“爽”,走到终点路径感强,适合玩家探索。

Prim算法生成的是均质迷宫,通道特别密,分支多而碎,走起来像绕迷宫,解谜感强但视觉上显得太密集。Kruskal生成的迷宫偏向“横平竖直”,对玩家来说有点太规整,趣味性弱。

结合游戏体验来说,石头迷阵的主要玩法是探索和推石头,不是极限解谜,所以我选了递归回溯。它生成速度快(几百毫秒搞定),地图样式也符合“远古遗迹”的审美——主干道能一眼看出,把石头推到目标位的操作空间也更足。

2.2 递归回溯生成器的关键实现

迷宫用二维数组表示,我习惯用一个int[][] map,每个格子的值代表类型:0是地面,1是石块墙,2是可推动的石头,3是目标凹陷,4是出口。生成阶段只处理墙和地面。

核心挖墙逻辑用栈模拟递归,避免深递归导致栈溢出:

// 迷宫坐标按“偶数格”处理:map[row][col],行列都是偶数时为可通行格 int[][] map = new int[height][width]; boolean[][] visited = new boolean[height][width]; Deque<int[]> stack = new ArrayDeque<>(); int startRow = 1, startCol = 1; visited[startRow][startCol] = true; stack.push(new int[]{startRow, startCol}); int[][] dirs = {{-2,0},{2,0},{0,-2},{0,2}}; while (!stack.isEmpty()) { int[] current = stack.peek(); int row = current[0], col = current[1]; // 收集所有未访问的、坐标合法的邻居 List<int[]> neighbors = new ArrayList<>(); for (int[] dir : dirs) { int nr = row + dir[0], nc = col + dir[1]; if (nr > 0 && nr < height - 1 && nc > 0 && nc < width - 1 && !visited[nr][nc]) { neighbors.add(new int[]{nr, nc}); } } if (!neighbors.isEmpty()) { // 随机选一个邻居,打通中间的墙 int[] next = neighbors.get(rng.nextInt(neighbors.size())); map[(row + next[0]) / 2][(col + next[1]) / 2] = 0; visited[next[0]][next[1]] = true; stack.push(next); } else { stack.pop(); } }

这里有个细节值得说一下:为什么用栈而不是递归函数?因为地图大一点、数据量上来之后,递归深度可能达到几千层,JVM默认栈大小扛不住。用ArrayDeque模拟栈(后进先出)在逻辑上和递归完全等价,还避开了栈溢出的风险。我在做这个项目时一开始用的就是递归,本地跑20x20的图还没事,改成60x60后直接StackOverflowError,果断换成了显式栈,之后就没再出过问题。

2.3 随机种子的取舍:可复现和可玩性的平衡

迷宫生成一定要用随机,但调试时随机就很折磨人——有时候你定位到一个渲染Bug,重开一局地图变了,不知道是Bug还是地图本身的问题。所以我加了一个Random构造器:生成算法接收一个可以设置的Random实例,默认是系统时间seed,但调试时用固定seed(比如new Random(42)),这样每次生成的地图都一致,方便排查逻辑问题。

后来我又在固定seed的同时做了地图哈希值显示:每次开局把当前地图的哈希值打印在控制台,如果玩家说“我这边卡关了”,我让他把哈希值发过来,我这边用同一个seed可以还原出一模一样的地图,排查效率直接拉满。

3. 从算法到画面:Swing渲染层的地图数据通路

3.1 JFrame、JPanel和双缓冲,别在这上面省事

Swing的渲染层次其实不复杂:JFrame是窗口,JPanel是画布。核心是把所有绘制逻辑塞到JPanel的paintComponent(Graphics g)方法里,然后在JFrame里setContentPane(new GamePanel(...))。

双缓冲这个点务必注意:如果直接在你的JPanel上裸画,快速移动时画面会闪得很厉害。Swing的JPanel默认就是双缓冲的,但如果你重写了paintComponent又去手动调super.paintComponent(g),什么都不干只是清空背景,闪屏会少很多。如果你用了JLabel、JButton这些组件堆叠来做游戏画面,我劝你趁早换回纯paint,组件一多性能急转直下,移动时残影严重。

我的做法是:JPanel保持默认双缓冲,paintComponent里第一步调super.paintComponent(g)清屏,然后按地图数组逐格绘制。绘制之前预加载好所有BufferedImage,严禁在paint方法里读图片——那是一次读取+解码+颜色转换的完整体力活,多做几次游戏就像PPT翻页。

3.2 地图数据模型:二维数组和屏幕坐标的换算

地图数据是二维数组,屏幕坐标是以像素为单位的坐标系,中间需要一个换算函数。我用的这种:

private void drawMap(Graphics2D g2d, int[][] map) { int tileSize = 40; // 每格40x40像素 for (int row = 0; row < map.length; row++) { for (int col = 0; col < map[0].length; col++) { int value = map[row][col]; int x = col * tileSize; int y = row * tileSize; BufferedImage img = getImageForValue(value); if (img != null) { g2d.drawImage(img, x, y, tileSize, tileSize, null); } } } }

这里tileSize的单位是“格子的像素宽”。我踩过一个坑:一开始我把地图行列和屏幕坐标混在一起,角色移动时把屏幕坐标当成数组下标去取格子,结果角色走到地图边缘就直接数组越界。后来我强制自己把数据层和渲染层隔离开——角色在地图数据里存的是[row, col]坐标,渲染时才乘tileSize得到像素坐标。这样碰撞检测只查数组,不碰像素,天然避免越界问题。

另外还有一点关于图片资源的处理。石头墙壁的纹理我是自己用程序画的,没有外部的图片文件。因为外部图片一旦放到别的目录,路径就变成运行时踩坑的重灾区。程序化生成纹理的好处是和代码打包在这里,一个jar跑遍所有环境,不依赖外部资源路径。如果你坚持要用外部图片,我建议放到classpath资源目录下,用getClass().getResource("/images/wall.png")读取,而不是用new File("images/wall.png")——后者换个当前工作目录就崩。

3.3 键盘监听和连续移动的手感

角色移动的经典坑就是:按键一下动一步,长按不连续;或者长按倒是连续了,但按得很急时鬼畜抖动。根源在于你把移动逻辑直接写在keyPressed事件里。键盘事件不是高频轮询,它只在键被按下和松开时触发,keyPressed触发一次等于动一步,长按反而要靠系统的按键重复机制,而这个机制的速度和延迟你是控制不了的。

正确做法是把按键状态记录在一个Set里:keyPressed时把按下的键加进去,keyReleased时移除。同时,游戏循环里每隔一段时间(比如每帧)查这个Set,看哪些方向键还按着,持续移动。我用的主循环是Swing Timer,每16毫秒触发一次(大约60FPS),移动速度也按这个节奏来。

private final Set<Integer> pressedKeys = new HashSet<>(); @Override public void keyPressed(KeyEvent e) { pressedKeys.add(e.getKeyCode()); } @Override public void keyReleased(KeyEvent e) { pressedKeys.remove(e.getKeyCode()); } @Override public void actionPerformed(ActionEvent e) { if (pressedKeys.contains(KeyEvent.VK_UP)) tryMove(-1, 0); if (pressedKeys.contains(KeyEvent.VK_DOWN)) tryMove(1, 0); if (pressedKeys.contains(KeyEvent.VK_LEFT)) tryMove(0, -1); if (pressedKeys.contains(KeyEvent.VK_RIGHT)) tryMove(0, 1); repaint(); }

这样改完,长按连续移动、斜方向同时按两个键抢同一帧,行为都是可控的。还有一个细节:keyPressed里如果对事件调用了e.consume(),可以防止某些平台上方向键同时触发焦点转移,这个也可以加上。

4. 推石头和碰撞判定:看着简单,细节全是坑

4.1 为什么“下一格坐标”这种判定会出Bug

角色移动的碰撞检测,很多人直觉上会写:先算角色下一步要走到的坐标,然后看那个坐标是不是墙,是墙就不动,不是墙就走过去。这在纯寻路迷宫里完全没问题,但加了推石头功能就出事了。

因为可推动的石头也是一个格子。角色走到石头格子上,得先判断石头能不能被推走,石头能不能推走又要看石头后面那一格是不是空地。如果你把碰撞检测只写一层“看目标格子是不是墙”,就会出现两种情况:一是角色直接“踩”到石头上,穿模;二是石头明明是空心的,但因为你只顾着查角色目标格,没查石头后面一格,导致推不动。

正确的思路是把移动判定拆成三层:

  1. 目标位置是墙:什么都不做。
  2. 目标位置是空地或出口:角色移过去。
  3. 目标位置是可推动石头:检查石头前方的格子,如果前方是空地,则角色和石头同时移动一格;如果前方是墙、石头、目标凹陷或边界,则整个动作取消。

4.2 推石头碰撞的完整实现

实现方式如下,tryMove方法接收方向参数,返回boolean表示是否发生移动:

private boolean tryMove(int dRow, int dCol) { int newRow = playerRow + dRow; int newCol = playerCol + dCol; if (newRow < 0 || newRow >= map.length || newCol < 0 || newCol >= map[0].length) { return false; } int targetValue = map[newRow][newCol]; // 目标位置是不可通行的墙 if (targetValue == WALL) { return false; } // 目标位置是可推动的石头 if (targetValue == PUSHABLE_STONE) { int beyondRow = newRow + dRow; int beyondCol = newCol + dCol; if (beyondRow < 0 || beyondRow >= map.length || beyondCol < 0 || beyondCol >= map[0].length) { return false; } int beyondValue = map[beyondRow][beyondCol]; if (beyondValue == WALL || beyondValue == PUSHABLE_STONE || beyondValue == OUTLET_FLAG) { return false; } // 推动石头,角色和石头各前移一格 map[beyondRow][beyondCol] = tentativelyTreat(beyondValue, PUSHABLE_STONE); map[newRow][newCol] = PLAYER; map[playerRow][playerCol] = GROUND; playerRow = newRow; playerCol = newCol; pushCount++; return true; } // 目标位置是空地或出口时,正常移动 map[playerRow][playerCol] = GROUND; map[newRow][newCol] = PLAYER; playerRow = newRow; playerCol = newCol; return true; }

这里有一个很容易被忽略的细节:石头推走之后,原来的位置要变成玩家位置,玩家原来的位置要变回地面,而石头原来的位置如果是目标凹陷(3),推完之后不能直接覆盖成地面,否则石头就“消失”了,凹陷目标也失效了。实际中我把“目标凹陷”和“石头停留”分成两个状态:石头进入凹陷后,格子类型变成“已占位的石头”,它仍然占着这一格、不能继续被推走,同时通关判定时统计所有凹陷是否都被占位。如果你省掉这个状态,推石头和关卡判定很容易拧巴成一团。

4.3 推石头的“死角”设计:为什么需要重置键

推箱子玩法看似简单,一个致命问题是石头一旦推到边界角落,就永远推不出来了。纯推箱子游戏会把玩家推进无解局面,只能重开。石头迷阵里我干脆把这个“无解感”做成了惊喜要素:部分关卡故意让某块石头卡在一个看似死路的位置,但只要你从另一个方向靠近,会发现石头后面还有一个隐藏凹陷,石头可以继续推过去。这种设计让玩家“卡关”后瞬间想通,比简单的无脑路径要耐玩。

但无论如何,操作失误还是会发生。所以R键重置、Esc键退出这两个快捷键必须有。我当时在第一版连重置键都没加,玩着玩着自己把自己关进死角,只能杀进程重开,体验极差。加完重置键,游戏才算真正能好好玩。

5. 一键寻路提示:BFS在迷宫里为什么够用

5.1 BFS与A*的取舍

寻路提示功能要实现的是:玩家按F键,显示从当前位置到出口的最短路径。迷宫里所有走的步数权重都是1,没有沼泽、陷阱这种消耗差异,这就是一个无权图上的最短路径问题。BFS天然就是O(V+E)的最优解法,不需要启发式函数辅助。A*在无权图上虽然也能用,但h(n)如果直接取曼哈顿距离,和BFS的实际搜索范围没有本质区别,还多了一堆open list、closed list和排序的开销,纯属给自己加戏。

如果未来给地图加上消耗差异,比如某些石头地面移动要算3步,某些石板路算1步,那BFS就不够了,得换成Dijkstra或者A*。我当时的实现是BFS,逻辑简单、调试省心,孤立的迷宫节点全都能覆盖。

5.2 BFS寻路的实现细节

BFS寻路核心是维护一个队列和一张前驱表:

private List<int[]> bfsPath(int startRow, int startCol, int targetRow, int targetCol, int[][] map) { int rows = map.length, cols = map[0].length; boolean[][] visited = new boolean[rows][cols]; int[][] prevRow = new int[rows][cols]; int[][] prevCol = new int[rows][cols]; Queue<int[]> queue = new ArrayDeque<>(); Arrays.fill(prevRow[0], -1); queue.offer(new int[]{startRow, startCol}); visited[startRow][startCol] = true; int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}}; while (!queue.isEmpty()) { int[] cur = queue.poll(); if (cur[0] == targetRow && cur[1] == targetCol) { break; } for (int[] d : dirs) { int nr = cur[0] + d[0], nc = cur[1] + d[1]; if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; if (visited[nr][nc]) continue; if (map[nr][nc] == WALL || map[nr][nc] == PUSHABLE_STONE) continue; visited[nr][nc] = true; prevRow[nr][nc] = cur[0]; prevCol[nr][nc] = cur[1]; queue.offer(new int[]{nr, nc}); } } // 从出口倒推路径 List<int[]> path = new ArrayList<>(); int r = targetRow, c = targetCol; while (!(r == startRow && c == startCol)) { if (r == -1) return List.of(); // 无路径 path.add(new int[]{r, c}); int pr = prevRow[r][c]; int pc = prevCol[r][c]; r = pr; c = pc; } Collections.reverse(path); return path; }

这里有两点需要注意。第一,前驱表一定要用两个int数组来存前驱的row和col,而不是用一个int存“移动方向编码”。方向编码在回溯时容易出错,而且调试很不直观。用两个数组存前驱坐标,回溯时一步一行,非常清晰。第二,出口本身在地图里的值可能是出口旗所在格,如果要显示“到终点”的路径,目标格允许玩家到达,BFS遍历时不能把出口排除掉。

5.3 在Swing线程里跑寻路,别阻塞UI线程

寻路和渲染不能混在同一个线程里直接跑。如果地图特别大(比如80x80),BFS本身计算量不小,如果直接在按键事件里执行并且立刻调用repaint,界面会卡顿一两秒,体验非常糟糕。

我的做法是用SwingWorker在后台线程执行BFS,完成后把路径结果传回UI线程再重绘。这个细节在调画面时很容易被忽略,但作为棋牌类小游戏,按一个键卡半秒和按一个键立刻出结果,玩家对游戏品质的感知差距是巨大的。

6. 测试与调优:从能跑到玩得上手,差的不是一点

6.1 手动测试用例表

纯游戏的逻辑不像业务系统那么好写自动化单测,手动测试占了绝大部分。我整理了下面这张表,每次改完逻辑按顺序过一遍:

测试项操作预期结果
基本移动按上下左右角色按方向移动一格
长按连动按住右键不放角色持续向右移动
撞墙角色紧贴墙面向墙移动角色不移动、画面无异常
推石头(前方空位)角色靠近石头并将石头推向空地角色和石头同时前移一格
推石头(前方墙角)把石头推到这个墙角角色不移动,石头不动,步数不增
推石头进凹陷把石头推到目标凹陷位置石头停留在凹陷中,目标计数+1
出口解锁条件所有目标凹陷都被占位出口可通行,走到出口出现结算画面
重置玩到一半按R地图恢复初始布局,步数清零
寻路提示按F白色脚印从角色位置连到出口
快速连按高频交替按上下左右无卡顿、无数组越界
窗口缩放拖拽窗口大小画面和逻辑正常,地图不扭曲

这张表帮我抓了不少莫名其妙的Bug。最大一个:出口解锁后,如果角色站在出口格上再按R重置,会出现角色和新地图的地形重叠。原因是重置逻辑里没把角色坐标恢复,只重生成地图数组。这个只能在手动测试里发现,因为单测时不太会去模拟这种交互顺序。

6.2 渲染性能优化:只画看得见的格子

最初版本的drawMap是整个地图全量绘制。20x20的地图没感觉,但到了50x50(2500格)每帧绘制压力就上来了,尤其是后面加了石头的阴影和角色动画帧,帧率肉眼可见地掉。优化方案是“视口裁剪”:只绘制当前窗口可见范围内的格子区间。

int visibleColStart = Math.max(0, (cameraX - 50) / tileSize); int visibleColEnd = Math.min(map[0].length - 1, (cameraX + viewportWidth) / tileSize + 1); int visibleRowStart = Math.max(0, (cameraY - 50) / tileSize); int visibleRowEnd = Math.min(map.length - 1, (cameraY + viewportHeight) / tileSize + 1);

配合一个简单的cameraX、cameraY跟随角色移动,角色走在大型迷宫中时,画面只渲染视野内的格子,帧率根本不会随着地图规模变大而崩。这个裁剪逻辑不复杂,但收益极大,大尺寸迷宫配上它才算真正能玩。

6.3 一个必须说的小技巧:地图哈希与关卡配置分离

我把关卡配置做成了文本文件格式,每行表示一格,字符映射到不同地形:#是墙、空格是地面、P是玩家、S是可推动石头、T是目标凹陷、E是出口。新加一关只需要改文本文件,不用改Java代码。

这里劝一句,如果你要把做好的项目当作业交上去,关卡配置用外部文本文件是加分项,但要注意交付时一并打包配置文件,否则换台电脑运行直接报错说找不到关卡文件。我一般建议文本内嵌成resource,这样不会丢。

7. 扩展方向与个人复盘

石头迷阵做完之后,回头审视自己写的代码,有几个地方是我觉得如果重做一遍会更完善的:一是角色和推石头逻辑目前全部写在主类的tryMove里,代码行数虽然不多,但和渲染、生成逻辑耦合度偏高,如果想要更多玩法扩展,需要拆成独立的GameState类;二是目前没有独立的音效模块,推石头成功、通关这两个关键反馈点没有音效,感官体验少了半口气;三是石头和出口的联动还比较死,目前是所有凹陷占位后出口才解锁,如果做成“石头进入指定凹陷才会触发对应出口”,关卡设计的可能性会更大。

对于想在这个项目基础上接着做点什么的朋友,我觉得有三个方向值得考虑。第一个是做成关卡编辑器,玩家可以在图形界面里自己画地图、摆石头、定目标,然后保存成关卡文件继续玩,这比写死地图有意思得多;第二个是加一个游戏内计时和步数排行榜,配上简单的序列化存档,能让游戏有反复挑战的欲望;第三个是把渲染层换成JavaFX或者封装一个简单的API接口,让核心的迷宫生成和推石头逻辑可以被Web前端或者移动端复用——这也是很多人在改编游戏时的经典做法。

最后说一点我的真实体会。这种小游戏项目,最锻炼人的地方不是算法本身,而是“从能跑到玩得上手”的那段路。迷宫生成算法网上到处都有,拷贝下来跑通不算本事,难的是让玩家在49步的时候推那块石头时不觉得卡手、按快捷键时有即时反馈、踩进死角时有重置的出口。石头迷阵玩到今天,每一次改动的成就感都来自这些看不见的小细节。希望这篇分享也能让你的石头迷阵走得更远。

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

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

立即咨询