资讯详情 Java实战:手写石头迷阵游戏,详解碰撞检测与BFS寻路
📅 2026/10/15 17:11:40
石头迷阵这个项目是我用纯Java完整实现的一个小游戏玩法介于推箱子和迷宫寻路之间。玩家在布满石块的地图里移动把指定石头推到目标点同时还要保证自己不被卡死最终通关。整块项目涉及二维数组地图建模、键盘交互、碰撞检测、自动寻路和关卡系统用到的都是Java基础技能但连起来做完整之后对面向对象设计和逻辑思维的要求一下就上来了。这篇文章把我实际开发中遇到的坑、选的方案、最后稳定的实现方式全部摊开写适合刚学完Java语法、想找项目练手的人也适合准备做课程设计或者作品集的人直接参考。1. 石头迷阵到底是个什么项目1.1 核心玩法与需求拆解很多人第一次听到“石头迷阵”会误以为只是迷宫寻路。其实我做的这个版本结合了两层玩法地图中有若干可以推动的石头每个石头有对应的目标坑位玩家需要把石头推到目标位置。但难点在于石头只能被推动而无法拉动一旦推过头或者推进死角关卡就废了只能重来。这本质上就是推箱子只不过地图元素和视觉上都围绕石头、迷宫墙来做配合更复杂的路径关系玩起来更像一个“石头阵”。需求拆解下来有这么几个核心点一张可复现的地图需要能定义墙、空地、石头、目标点、玩家出生点。玩家移动时有碰撞检测墙不能走石头可以推但石头前方必须是空地或目标点。判定胜利的条件是每个目标点都有石头覆盖而不是所有石头都到位。界面需要有足够反馈比如移动步数、重置当前关、提示下一步。最好有“自动寻路提示”功能在玩家迷路时高亮一条可行路径。如果只是做一个最简版其实一个控制台程序加一个二维数组就够了。但为了让项目看起来完整、可演示我用Swing做了图形界面并且把地图数据抽出来放到了独立资源文件里后续想加关卡不需要改代码。1.2 为什么选Java而不是其他语言选Java不是因为它适合做游戏相反如果我想做高性能渲染或者华丽特效肯定首选引擎或者C。但石头迷阵这种逻辑密集型小游戏恰好是Java最擅长的场景状态简单、逻辑明确、跨平台一致性好而且Swing对键盘事件的支持足够成熟。外加Java的面向对象特性非常适合把“地图”“角色”“石头”“游戏状态”这些概念映射成类写出来的代码结构清晰后期扩展难度低。更实际的原因是这个项目需要被用作文档和演示作品评委或面试官大概率不一定跑一个完整工程而是会重点看代码逻辑。Java语法严谨、强制类型检查写出来的碰撞检测、寻路算法很容易被逐行看懂。如果换成脚本语言可能写得爽但阅读者不一定有同样的语言习惯。Java的标准库自带队列、集合、栈等数据结构写BFS寻路的时候直接LinkedList就能搞定不需要额外依赖。对新手来说这种自己动手不依赖第三方库的质感非常重要。2. 地图与数据结构的选型2.1 二维数组比对象地图更省心一开始我考虑过用二维对象数组每个格子放一个GameObject对象通过多态来实现不同元素的差异。但实际编码后发现石头迷阵的地图逻辑本质上是一张二维网格所有交互都发生在网格坐标上。用对象数组虽然看起来“面向对象”却会引入大量无意义的继承关系调试时需要不断判断对象类型反而费劲。最终我选择了int[][]二维数组用不同数值表示不同元素。这样做的好处非常直接地图数据可以用字面量写进资源文件解析特别简单。坐标运算就是数组索引运算越界判断一眼就能看出来。状态变更本质上就是修改数组元素的值性能好、便于调试。判断胜利条件时只需要遍历目标点集合检查对应数组位置的值。地图大小我设定为统一20x20每格用40像素渲染窗口就是800x800刚好可以完整放下。实际项目里还可以动态根据地图尺寸调整窗口这个后面会说。2.2 地图符号与坐标系统约定我定义了一张常量对照表代码里看起来是public static final int WALL 0; public static final int FLOOR 1; public static final int PLAYER 2; public static final int STONE 3; public static final int TARGET 4; public static final int STONE_ON_TARGET 5; public static final int PLAYER_ON_TARGET 6;地图文件里的每一行是数字串比如111011...加载时逐字符转成int。注意坐标系统一定要定死——数组第一维是行y第二维是列x。这个问题一开始不重视后面写移动逻辑时经常出现x和y错位很让人抓狂。我建议所有方法签名都用(int row, int col)这样明确的名称哪怕传参顺序改变了也能很快发现问题。为了简单我直接把玩家和初始石头都写在同一个地图数组里。运行时维护两个变量playerRow和playerCol记录当前玩家位置地图数组中玩家位置保持FLOOR或TARGET每次移动时更新地图和坐标变量。也就是说地图数据是“物件层”玩家坐标是“实体层”两层分开管理。2.3 关卡文件的加载与校验为了让项目能够扩展我没有把地图硬编码在代码里而是放进了外部txt文件。格式约定很简单第一行是地图行数和列数后面每行是数字串。我用Java的标准Scanner逐行读取解析成二维数组。Scanner scanner new Scanner(new File(level1.txt)); int rows scanner.nextInt(); int cols scanner.nextInt(); scanner.nextLine(); int[][] map new int[rows][cols]; for (int i 0; i rows; i) { String line scanner.nextLine(); for (int j 0; j cols; j) { map[i][j] line.charAt(j) - 0; } }这里必须注意读文件时的行末空格、换行符都可能让charAt位置错乱。我在写解析时砍掉所有空白字符line line.replaceAll(\\s, );加载完不是直接开玩还要做一层校验。至少要检查三点有没有玩家、有没有目标点、石头的数量是否等于目标点数量。这个校验帮我在设计第六关时省了不少事因为经常发生手误导致地图里少了一个目标但界面一直判定无法通关。3. 移动逻辑与碰撞检测的实现3.1 玩家和石头的移动规则移动逻辑是整个游戏的核心几乎所有坑都集中在“推石头”的边界条件上。规则可以拆成两个层面玩家可以向上下左右移动一格。如果移动目标位是空地或目标点直接移动。如果移动目标位有石头则检查石头前方一格同一方向是否为空地或目标点是则石头和玩家都往前移动一格否则玩家不动。这里有个隐藏语义石头只能推不能拉。如果玩家向左走左边是石头那石头也得向左移动石头左边必须为空地否则玩家这个动作无效。这种情况和“推箱子”完全一致。移动时还要考虑玩家站在目标点上的情况。玩家离开目标点时原来所在位置应该恢复为TARGET而不是FLOOR玩家站上去时则变成PLAYER_ON_TARGET。同理石头被推出目标点后目标位置应恢复为TARGET。如果不处理这两种状态画面上会出现目标点“消失”的现象一眼看上去像bug。3.2 检测前方物体三步判断法我把移动检测写成一个独立方法逻辑上用三步判断法可以避免大量嵌套ifpublic boolean tryMove(int dRow, int dCol) { int newPlayerRow playerRow dRow; int newPlayerCol playerCol dCol; int targetCell map[newPlayerRow][newPlayerCol]; if (targetCell WALL) return false; if (targetCell STONE || targetCell STONE_ON_TARGET) { int stoneNextRow newPlayerRow dRow; int stoneNextCol newPlayerCol dCol; int stoneNextCell map[stoneNextRow][stoneNextCol]; if (stoneNextCell WALL || stoneNextCell STONE || stoneNextCell STONE_ON_TARGET) { return false; } moveStone(newPlayerRow, newPlayerCol, stoneNextRow, stoneNextCol); } movePlayer(playerRow, playerCol, newPlayerRow, newPlayerCol); return true; }第一步判断目标位是否是墙第二步判断目标位是否有石头如果有就继续看石头前方第三步才是真正执行移动。这个方法没有直接修改地图而是先做完整判断再执行避免移动过程中产生中间状态导致逻辑混乱。实际运行中这个设计让撤销操作也容易实现只要在移动前记录一个状态快照撤销时恢复快照即可。3.3 推动石头时的边界处理推动石头最容易被忽略的一个情况是石头穿过目标点后继续前进。比如石头原本在目标点A处你把它推开目标点A应该重新变成TARGET而石头本来所在的位置玩家要站的位置要根据新位置再次判断如果石头之前是STONE_ON_TARGET那么玩家站上去后这个位置应该仍然算作目标点即变成PLAYER_ON_TARGET。具体代码中我写了一个辅助函数专门负责“重绘格子”private void setCell(int row, int col, int type) { if (type STONE_ON_TARGET || type PLAYER_ON_TARGET) { // 保持这种复合状态不覆盖目标属性 } else if (type STONE) { map[row][col] isTarget(row, col) ? STONE_ON_TARGET : STONE; } else if (type PLAYER) { map[row][col] isTarget(row, col) ? PLAYER_ON_TARGET : PLAYER; } }这里要维护一个targetPositions列表里面存放所有目标点的坐标。isTarget(row, col)以这个列表为准而不是看地图数组的值。因为一旦地图数组中某个位置变成了FLOOR你无法判断它原本是不是目标点。这个设计是我踩过坑之后才补上的最初把目标点信息直接存到数组里结果石头反复移动时目标点丢失卡关率极高。4. 图形界面与交互设计4.1 Swing界面的搭建与画布刷新界面我用Swing的JFrameJPanel重写面板的paintComponent方法绘制地图。这个方案的好处是足够轻不需要引入任何外部渲染库。绘制时根据二维数组的值用不同颜色和图形表示不同元素墙深灰色实心矩形地板浅灰色填充目标点中间画一个空心的圆形标记石头画一个带边框的圆形模拟石头质感玩家画一个实心圆点关键点是刷新画布不能在每帧手动创建新的Graphics对象而是在移动结束后调用panel.repaint()。Swing的绘制模型是自动的repaint()只是通知系统重新调用paintComponent所以绘制逻辑必须完全基于地图数组的当前状态不能依赖上一次绘制结果。Override protected void paintComponent(Graphics g) { super.paintComponent(g); for (int row 0; row rows; row) { for (int col 0; col cols; col) { drawCell(g, row, col); } } }4.2 键盘监听与按键映射键盘监听器用的是KeyAdapter在keyPressed中判断方向键或WASD。这里有一个Swing常见的坑焦点问题。如果界面上还有其他按钮控件按下方向键时焦点可能被按钮抢走导致键盘监听失效。我一开始把“重新开始”“提示”按钮放在面板上方结果玩两关之后键盘突然不响应。解决方案是让游戏面板自身获取焦点并且在点击按钮后把焦点重新还给面板gamePanel.setFocusable(true); gamePanel.requestFocusInWindow();同时我做了按键映射表把方向键和WASD统一成一个方向向量switch (keyCode) { case KeyEvent.VK_UP: case KeyEvent.VK_W: tryMove(-1, 0); break; case KeyEvent.VK_DOWN: case KeyEvent.VK_S: tryMove(1, 0); break; case KeyEvent.VK_LEFT: case KeyEvent.VK_A: tryMove(0, -1); break; case KeyEvent.VK_RIGHT: case KeyEvent.VK_D: tryMove(0, 1); break; }这里要注意方向键和WASD在同一事件里处理时不能重复触发移动。因为一个keyPressed事件只会进来一次所以不存在重复问题。但如果加入了按键检测线程就要做防抖处理。4.3 动画和视觉反馈的取舍一开始我试图给移动加平滑动画让玩家“滑”过去而不是瞬间跳跃。但在Swing里做这种动画很容易和键盘响应产生冲突——你必须保证动画播放期间不能接收下一次移动输入否则坐标已经变了动画还在插值画面会错乱。实际上对于石头迷阵这种重逻辑的游戏瞬间切换位置是更好的选择玩家玩的是思考不是手感。为了不至于太干瘪我做了两个很小的反馈措施一是玩家移动时播放一个很短的“咔哒”声石头推动时播放更低沉的声音二是如果推动失败前面是墙画面边框闪烁一下红色。这比动画更有效也能让操作反馈更加明确。音效我用的是AudioClip并预加载短小wav文件避免每次播放都重新加载IO导致卡顿。5. 寻路提示功能从BFS到自动求解5.1 为什么需要寻路提示石头迷阵玩到后面玩家经常会卡在“知道目标点在哪但不知道怎么绕过去”的状态。我决定加一个提示功能按下H键高亮一条从玩家当前位置到某一目标点的可行路径。注意这个路径不是指推石头的完整解法而是指“人”在无障碍条件下走到某个目标点附近的最短路径。对于纯迷宫部分这个提示已经足够帮助玩家。要算最短路径最合适的就是BFS广度优先搜索。数组地图本身就是一个天然图结构每个格子是节点上下左右相邻格子如果都不是墙就是一条边。BFS可以保证第一次到达目标点时走过的步数一定是最短的。5.2 BFS求最短路径BFS实现的核心是队列加visited数组。我用LinkedList作为队列每个状态保存坐标和当前路径。实际操作时我不会把整条路径塞进每个状态而是记录每个格子的前驱节点最后回溯路径。public Listint[] findPath(int startRow, int startCol, int targetRow, int targetCol) { Queueint[] queue new LinkedList(); boolean[][] visited new boolean[rows][cols]; int[][] preRow new int[rows][cols]; int[][] preCol new int[rows][cols]; queue.offer(new int[]{startRow, startCol}); visited[startRow][startCol] true; int[] dr {-1, 1, 0, 0}; int[] dc {0, 0, -1, 1}; while (!queue.isEmpty()) { int[] cur queue.poll(); if (cur[0] targetRow cur[1] targetCol) { // 回溯路径 Listint[] path new ArrayList(); int r targetRow, c targetCol; while (!(r startRow c startCol)) { path.add(0, new int[]{r, c}); int pr preRow[r][c]; int pc preCol[r][c]; r pr; c pc; } path.add(0, new int[]{startRow, startCol}); return path; } for (int i 0; i 4; i) { int nr cur[0] dr[i]; int nc cur[1] dc[i]; if (inBounds(nr, nc) !visited[nr][nc] map[nr][nc] ! WALL map[nr][nc] ! STONE map[nr][nc] ! STONE_ON_TARGET) { visited[nr][nc] true; preRow[nr][nc] cur[0]; preCol[nr][nc] cur[1]; queue.offer(new int[]{nr, nc}); } } } return null; }这个函数返回的路径我在paintComponent里单独高亮用半透明颜色覆盖途经格子。路径计算是按下H键时触发的只在通关或重置时清空所以性能上没有压力。5.3 更进一步的自动求解思路做完寻路提示后我又尝试过完整的自动求解——不只是玩家走路而是把推箱子的全过程用程序解出来。这个难度明显高很多因为状态空间不再只是一个坐标而是“玩家坐标所有石头坐标”的组合。我用BFS搜索状态每个状态保存玩家位置和一个石头位置列表移动时枚举所有可能的推石头动作。可是地图一复杂状态数迅速膨胀耗时明显。后来我换了个思路先对每个目标点从远到近分配石头逐块解决。这种做法不是严格最优但大部分关卡都能被解出来。实现时我把地图、玩家、石头封装成一个GameState对象搜索时用equals判断是否重复状态。由于状态对象需要用来存入HashSet必须重写hashCode。这里我学到的教训就是如果状态包含二维数组hashCode最好基于数组内容计算而不是基于对象引用否则去重逻辑失效搜索会陷入死循环。这个模块最终没有集成到正式版本里但调试经历让我对状态空间的理解深入了很多。6. 关卡数据与难度曲线设计6.1 手写关卡的格式约定石头迷阵一共设计了8个关卡每关都写在单独的txt资源文件里。关卡地图并非随机生成因为随机生成很容易产生无解关卡手工设计才能真正控制体验节奏。每一关我用一个棋谱式的草稿本先画出来再转换成数字串。格式约定如下0墙1地板2玩家初始位置3普通石头4目标点例如一关最简单的地图可以表示成7 7 1111111 1000001 1030001 1000001 1004001 1020001 1111111解析时需要特别注意的是关卡文件里不能存在中文字符或不可见字符不然line.replaceAll(\\s, )处理不了。我还加了一个容错机制如果某行长度不等于列数直接抛出异常并在控制台提示是哪一关的问题。这个保护让我设计新关卡时能立刻发现自己写错了行列数。6.2 难度曲线如何设定难点设计上我用“三步递进”的方式前两关让玩家理解推动规则地图空旷目标点就在附近三四关加入墙体绕行玩家需要规划路径才能把石头推到位后四关开始出现多个石头和目标点并且故意制造“死角陷阱”——比如目标点贴着墙边如果从错误方向推石头石头会被顶到墙边再也无法调整。一开始我靠感觉设计结果第三关我自己都玩不过去因为石头旁边就是墙推过去之后石头贴墙没有回头路。后来我制定了一条规则每个石头在初始位置上至少存在一条推入目标点的理论路径。检测方法很笨但很有效——针对每个石头枚举所有可能的移动序列深度限制在10步以内如果10步内都无法把石头推进任何一个目标点就调整地图。这个自动检测脚本是我用Java写的独立工具不包含在游戏项目里但帮我省了很多事。6.3 关卡编辑器的构想手写txt文件毕竟太容易出错后来我顺手做了一个关卡编辑器Demo用鼠标在网格上绘制墙、地板、石头和目标点然后保存成游戏可识别的txt文件。这个编辑器本身并不复杂难的是交互状态的区分——比如点击是放石头还是放墙需要快捷键切换。我用数字键1到4切换当前绘制元素鼠标点击时对应把格子改成当前元素右键取消元素。编辑器的代码放在独立主类里和游戏主体完全隔离。虽然这个工具没做得很精致但设计第6到第8关时我确实直接用编辑器拿鼠标点点画画比手写字符串快了一倍多。如果你也想复制这个项目我建议一开始就写一个简单的关卡编辑器不要靠纯手工否则改一个坐标要重新数一遍数组下标太浪费时间。7. 踩坑实录与性能优化7.1 常见bug清单针对这个项目我整理了一份常见问题和对应的排查方向直接把踩过的坑放在这里现象原因解决方案键盘没反应点击按钮后方向键无效JPanel没有焦点调用setFocusable(true)和requestFocusInWindow()石头推到目标点上之后目标点消失复合状态没处理地图数组被直接覆盖成了石头用独立的目标点坐标集合判断不要依赖数组值玩家站在目标点上然后移动目标点没恢复移动时没有检查原位置是否是目标点恢复位置时调用isTarget(row, col)判断关卡txt读取时乱码或列数不对数字串中含有空格或换行符解析前过滤所有空白字符并校验长度走到地图边界时数组越界没有先判断inBounds移动检测第一步就加边界判断石头推动后连锁反应异常在移动过程中修改了地图数组后续继续读取先完成全部条件判断和坐标计算再统一修改地图这个表格里最核心的经验是地图数组的状态变化必须放在所有判断结束之后一步到位不要边判断边修改。我曾经在判断石头前方时顺手修改了地图数组导致后续判断读到的是修改之后的值追踪了半天才发现是顺序问题。7.2 地图渲染卡顿的优化Swing渲染20x20的地图正常情况下完全不会卡顿但最初我在paintComponent里用了BufferedImage画图形时每帧重新创建对象导致垃圾回收频繁偶尔会有肉眼可见的闪烁。解决方案是把静态背景预渲染成一个BufferedImage只有地图元素变动时才重画局部或者干脆整体重画但复用同一个图像对象private BufferedImage canvas; private void renderMap() { canvas new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g2d canvas.createGraphics(); // 画所有格子 g2d.dispose(); }然后在paintComponent里直接调用g.drawImage(canvas, 0, 0, null)。因为地图只有在移动时才会变化所以renderMap只在移动函数里调用不要放在paintComponent内部。这个改动让整个界面稳定在60帧以上虽然20x20的格子根本用不着优化但养成这种渲染分离的习惯以后做更复杂的游戏渲染也能直接用。另外绘制圆形石头时阴影效果不要用Graphics2D的复杂渐变直接用两个颜色圆叠加就够先画暗色大圆再画亮色小圆。实测这种画法在低性能机器上也能流畅运行而且视觉上不会比厚重设计差。7.3 关于Java项目结构的建议我把整个项目分成了五个类尽量保持职责单一避免一个几百行的上帝类。包结构如下Main程序入口创建主窗口、加载关卡、绑定键盘。GamePanel负责绘制和键盘事件但不处理游戏逻辑。GameEngine维护地图数组、玩家坐标、执行移动逻辑、判定输赢。LevelLoader负责解析txt关卡文件、基础校验。PathFinder提供BFS寻路被GamePanel调用高亮路径。按照这个结构如果以后想换图形界面完全不用改GameEngine和LevelLoader想新增能力只需扩展PathFinder。这也是我做项目时比较看重的一点代码结构不要为了“看起来多”而堆砌而是要让每一个新增功能都能落到合适的位置减少大改。我在实际开发过程中发现写完移动逻辑和界面后最容易膨胀的是GameEngine类既要管地图又要管玩家还要判断胜负。后来我引入了GameState类把当前的地图数组、玩家坐标、步数、胜负状态全部封装进去GameEngine只负责对GameState进行操作。这样在做撤销、重做、自动演示时只需保存状态快照不会出现外部类直接修改内部变量的风险。7.4 最后一次分享的经验做到了这一步石头迷阵这个项目已经从“能玩”变成了“完善”。回头再看最值钱的并不是JFrame怎么画圆也不是BFS怎么写而是“如何把一个大任务拆成可以验证的小步骤”。我每实现一个功能都会临时加一段debug输出观察移动前后的地图数组确认逻辑对了再画界面。这种调试方式看起来笨但比任何测试框架都直观。另一个体会是游戏项目的完成度很大程度上取决于你对边缘情况的处理。比如玩家站在目标点上推石头、石头从目标点上推下来、两石头相撞、地图边界等。这些场景不一定会频繁出现但一旦出现而你没处理玩家体验就会崩溃。写代码之前把能想到的所有边缘情况列成清单再逐个编写测试用例开发效率会明显提高。如果你也想做一个类似的Java项目建议从控制台版本开始先把移动逻辑和胜负判断跑通再套界面。我当初就是因为一上来就做界面导致逻辑改一点就要重新编译看效果浪费了不少时间。控制台版本用简单的数字输出每一步打印地图这样调试速度快得多。等到逻辑稳定了再替换成Swing绘制你会发现整个流程顺滑很多。这算是我在这个石头迷阵项目里最有价值的一段经历也希望你看完这篇能找到自己的节奏做出属于你自己的版本。