Java Swing推箱子小游戏源码拆解:地图解析、碰撞检测与界面绘制
简介基于Java实现的推箱子游戏压缩包定位为Java入门学习与小型游戏开发实践资源面向正在学习Swing/JavaFX界面编程、事件处理、二维数组地图加载与角色移动控制的读者也适合作为高校Java课程设计或自学练手的完整范例。包内共60个文件压缩后仅248KB主要包括1个mytxz.java源文件、6个class编译类、36个map关卡地图、10张gif角色与场景图片、1个mid背景音乐文件并附4份doc开发与使用文档目录中还有可直接运行的bat脚本方便解压后快速体验。游戏角色采用炮炮兵形象界面美观关卡地图丰富可通过阅读源码理解地图读入、箱子推动判定、胜负检测等核心逻辑再结合文档进行二次修改或扩展。目前已有223人学习/下载对希望从零完成一个Java小游戏的开发者来说这是一份轻量但完整的参考资源。1. 为什么一个Swing写的推箱子项目还值得拆一遍很多Java初学者卡在“语法看懂了项目做不出来”这个坎上。与其零散地背java八股文考点不如找一份能真正运行的完整小游戏拆一遍。PP是一个基于Swing写的推箱子小游戏解压后结构干净mytxz.java是入口Readmap.java读取36个地图文件Moving.java处理推动逻辑mainpanel.java负责画面绘制PlayMusic类循环播放MIDI背景音乐。0.gif到9.gif是炮炮兵主题瓦片图压缩包还附带开发文档和测试文档是标准的课程设计交付物。它把“二维数组如何变成可操作的游戏”这条链路完整串了起来地图解析、移动判定、画面刷新、音频加载。适合要交Java课程设计的同学也适合想在java面试里把一个Swing小游戏讲清楚细节的开发者。2. 先读Readmap.map文件与二维矩阵的转换2.1 地图编码约定数字矩阵不是随便定义的包里的maps目录下从1.map一直排到36.map每个文件对应一关。这类推箱子地图文件通常是一行行数字每个数字都代表一种元素。用数字而不是字符比如#表示墙、$表示箱子的好处是五种基础元素只占一个字符位而“箱子在目标点”“玩家在目标点”这类复合状态只需要额外编码两个数字地图解析逻辑完全不用改。编码和瓦片资源的映射关系建议按下面这张表来约定这也是多数同类Java推箱子项目的通用做法数字含义对应瓦片0空地不绘制或透明底1墙体6.gif2箱子2.gif3目标点4.gif4玩家1.gif5箱子在目标点2.gif叠加目标底色6玩家在目标点1.gif叠加目标底色一个有效的地图文件大致长这样1 1 1 1 1 1 0 4 0 1 1 0 2 3 1 1 0 0 0 1 1 1 1 1 1这张5×5地图中第二行第三列的4表示玩家初始位置第三行第三列的2是箱子第三行第四列的3是目标点。玩家向右推箱子一次箱子踩上目标点编码从2变成5这一关就判定胜利。注意地图的最外层必须是墙否则玩家或箱子会直接从数组边界越出去。2.2 Readmap读取示例与参数说明解压包里只保留了Readmap.class没有随包附源码但这类项目的读取逻辑基本是同一套写法逐行读、按空白符拆分、转成int后存入二维数组。下面是我在这个场景下最常用到的实现public int[][] loadMap(String filePath) throws IOException { Listint[] lineRows new ArrayList(); try (BufferedReader br new BufferedReader(new FileReader(filePath))) { String line; while ((line br.readLine()) ! null) { if (line.trim().isEmpty()) { continue; // 跳过空行兼容文件末尾多余换行 } // 按空白符拆分同时兼容空格与Tab分隔 String[] cols line.trim().split(\\s); int[] row new int[cols.length]; for (int i 0; i cols.length; i) { row[i] Integer.parseInt(cols[i]); } lineRows.add(row); } } int[][] map lineRows.toArray(new int[0][]); for (int i 1; i map.length; i) { if (map[i].length ! map[0].length) { throw new IllegalArgumentException( 第 (i 1) 行列数与首行不一致: map[0].length - map[i].length); } } return map; }这里有两个容易被忽略的设计。第一try-with-resources保证文件流在方法结束前一定被关闭这是java基础里反复强调、但很多课程设计里没有落到实处的地方。第二读取完成后对二维数组做了一次矩阵齐整检查如果某一行的列数和第一行不一致说明地图文件本身有缺陷直接抛错比等到绘制时出现ArrayIndexOutOfBoundsException更容易定位。参数filePath是相对运行目录的路径解压后直接填maps/1.map即可。提示用IDE跑这个项目时出现FileNotFoundException十有八九是工作目录Working Directory不对。打印new File(.).getAbsolutePath()看一下当前目录落在哪。2.3 逻辑坐标与像素坐标的换算Readmap把数字矩阵装进int[][]之后地图还停留在“逻辑世界”。map[i][j]中的i表示行方向j表示列方向和屏幕坐标系的x、y并不直接对应。把逻辑坐标换算成绘制坐标是mainpanel的职责换算公式只有一行像素x 列号 * 瓦片边长像素y 行号 * 瓦片边长。项目里的gif是方块图我一般会把所有瓦片统一缩放到同一个TILE_SIZE比如48×48。为什么不直接用原图尺寸因为图片被拉大边缘会糊缩小又会透出地图底色统一尺寸才能让整张棋盘看起来是严丝合缝的。窗口尺寸也可以提前算出来如果某关地图是12列×10行TILE_SIZE取48面板宽576、高480。mainFrame在pack()之后再setResizable(false)可以避免用户拖拽窗口导致绘制区域和地图尺寸错位。2.4 关卡外置的维护价值36张地图用独立文件存放而不是硬编码进Java源码最大的收益是新增关卡不需要动代码。要加一关时新建一个37.map按上面的编码约定写好数字矩阵启动时地图列表就会多出第37关。同理想调整某一关的难度直接改对应.map里的数字位置重跑游戏立刻生效不需要重新编译。这也是为什么Readmap的解析逻辑必须独立成类地图的变更频率远高于代码逻辑与资源分离之后关卡策划和程序开发可以并行。项目里36个.map文件整整齐齐排成一条配合5.1节要写的校验方法一个人完全能hold住。3. 让角色推得动箱子Moving模块的碰撞判定与胜利逻辑3.1 推箱子规则的本质是一个状态迁移Moving.class是整个项目最核心的部分。每按一次方向键本质是一次状态迁移已知玩家当前位置P方向上的邻居格N以及N后面的格子NN。推动能否成功取决于这三格的状态组合规则可以浓缩成四条N是墙不允许移动。N是空地或目标点玩家直接走到N原位恢复成空地或目标点。N是箱子且NN是空地或目标点箱子被推到NN玩家走到N。N是箱子且NN是墙、边界或另一个箱子推不动。注意玩家只能推箱子不能拉箱子也不能和箱子交换位置。这条规则看似简单真正写代码时最烦人的是复合状态的还原当玩家或箱子离开目标点目标点本身必须重新出现否则会出现“目标点被踩没了”的经典bug。3.2 move()方法与四种方向的统一处理方向编号直接约定为0上、1下、2左、3右用位移数组统一处理避免写四个几乎一样的分支。我在类似项目里常用的实现如下public class Moving { public static final int EMPTY 0; public static final int WALL 1; public static final int BOX 2; public static final int TARGET 3; public static final int PLAYER 4; public static final int BOX_ON_TARGET 5; public static final int PLAYER_ON_TARGET 6; private int[][] map; private int playerRow, playerCol; private int stepCount; public boolean move(int dir) { int[] dr {-1, 1, 0, 0}; int[] dc {0, 0, -1, 1}; int nr playerRow dr[dir]; int nc playerCol dc[dir]; if (map[nr][nc] WALL) { return false; // 前方是墙禁止移动 } if (map[nr][nc] BOX || map[nr][nc] BOX_ON_TARGET) { int nnr nr dr[dir]; int nnc nc dc[dir]; if (map[nnr][nnc] WALL || map[nnr][nnc] BOX || map[nnr][nnc] BOX_ON_TARGET) { return false; // 箱子前方被占用推不动 } // 箱子落点目标点上落地为5否则为2 map[nnr][nnc] (map[nnr][nnc] TARGET) ? BOX_ON_TARGET : BOX; // 箱子原位置复合状态恢复为TARGET否则恢复为EMPTY map[nr][nc] (map[nr][nc] BOX_ON_TARGET) ? TARGET : EMPTY; } // 玩家原位置同理处理踩目标点的情况 map[playerRow][playerCol] (map[playerRow][playerCol] PLAYER_ON_TARGET) ? TARGET : EMPTY; playerRow nr; playerCol nc; map[playerRow][playerCol] (map[playerRow][playerCol] TARGET) ? PLAYER_ON_TARGET : PLAYER; stepCount; return true; } }整个方法只有三个关键步骤。第一步检查邻居格如果是墙直接失败第二步处理箱子箱子被推走之后原位置要根据之前是不是BOX_ON_TARGET来决定恢复成TARGET还是EMPTY第三步移动玩家玩家离开时同样要检查自己站的位置是不是PLAYER_ON_TARGET。两步走完步数计数自增返回true告诉上层画面需要重绘。注意目标点被推开后必须恢复为TARGET而不是EMPTY。一旦在这里写错会出现目标点在地图上消失、关卡永远无法通关的现象这是推箱子实现里最常见的逻辑死角。位移数组dr/dc的写法在方向键处理里非常实用。dir从0到3dr[dir]和dc[dir]分别给出行和列的偏移量四个方向共用一套计算逻辑新增斜向移动也只需要扩展数组长度。3.3 isWin()胜利判定与步数统计胜利条件不复杂所有箱子都落在目标点上。一个简洁的判定方法是遍历整个地图只要还存在裸箱子值为2说明至少有一个箱子没到位public boolean isWin() { for (int i 0; i map.length; i) { for (int j 0; j map[i].length; j) { if (map[i][j] BOX) { return false; // 还有裸箱子没有到达目标点 } } } return true; }为什么这样可以因为移动逻辑里已经约束了箱子只能落在目标点或空地上箱子一旦到达目标点就会变成5号状态。遍历下来如果一个2都没有所有箱子必然都在目标点上。这个判断甚至不需要单独记录箱子坐标集合省掉一份同步维护的列表。步数统计我习惯直接放在move()里自增界面层每次移动成功后刷新标签。重绘和步数刷新要放在同一个if块里保证画面和数据同步更新不然会出现步数变了、画面没动一步的割裂感。3.4 如果要做悔棋存档点该记什么不少从这个小项目延伸出来的课程设计会加“悔棋”和“重开本关”。悔棋的常规做法是维护一个历史栈每次移动前把关键状态压栈撤销时反向恢复。我一般会设计一个ActionRecord类记录三个信息玩家移动前的坐标、被推箱子的坐标、这两个位置在被修改前的格子类型。public class ActionRecord { int playerRow, playerCol; int boxRow, boxCol; int playerCellTypeBefore; int boxCellTypeBefore; }记录的三个字段已经足够完成一次精确撤销恢复玩家原位和箱子原位时直接把记录里的格子类型写回地图即可不需要反向执行一次移动逻辑。注意每次进入新关卡前要把栈清空否则上一关的移动记录会串到下一关撤销时把地图改得乱七八糟。4. 炮炮兵上屏Swing重绘、键盘事件与MIDI背景音乐4.1 JFrame和JPanel各管一段mytxz.java作为程序入口负责创建mainFrame窗口和mainpanel面板然后把面板挂到窗口的contentPane上。Swing的组件层级决定了JFrame只管外框、标题、关闭按钮和窗口尺寸真正画游戏画面的是contentPane里的JPanel。很多初学者喜欢把所有绘制代码都写进JFrame在默认主题下看不出问题换到带边框的系统主题就会发现地图整体下移、画面被标题栏挤压就是因为没有遵循“JFrame是壳、JPanel是肉”的分工。窗口初始化有几项参数值得固定下来setTitle设置窗口标题setDefaultCloseOperation填JFrame.EXIT_ON_CLOSE让关窗即退出setLocationRelativeTo(null)把窗口居中。窗口大小不要写死先由面板的preferredSize决定再pack()这样无论地图是6列还是20列窗口都能自动适配。4.2 用ImageIcon预加载gif并绘制地图paintComponent是Swing绘制的核心回调地图上每一帧的画面都从这里出来。绘制前先把0.gif到9.gif全部加载到Image数组里避免每画一帧都去磁盘读一次文件public class MainPanel extends JPanel { private static final int TILE_SIZE 48; private Image[] tiles new Image[10]; private int[][] map; private Moving moving; public MainPanel(Moving moving) { this.moving moving; this.map moving.getMap(); // 预加载瓦片图绘制时不再访问磁盘 for (int i 0; i 9; i) { tiles[i] new ImageIcon(pic/ i .gif).getImage(); } setPreferredSize(new Dimension(map[0].length * TILE_SIZE, map.length * TILE_SIZE)); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); for (int row 0; row map.length; row) { for (int col 0; col map[row].length; col) { int cell map[row][col]; Image img null; if (cell Moving.WALL) img tiles[6]; else if (cell Moving.BOX) img tiles[2]; else if (cell Moving.TARGET) img tiles[4]; else if (cell Moving.PLAYER) img tiles[1]; else if (cell Moving.BOX_ON_TARGET) img tiles[5]; else if (cell Moving.PLAYER_ON_TARGET) img tiles[1]; if (img ! null) { g.drawImage(img, col * TILE_SIZE, row * TILE_SIZE, this); } } } } }TILE_SIZE是全局瓦片边长所有图片按它统一铺开想放大缩小画面只需要改这一处常量。tiles数组一次性预加载10张图片数组下标正好对应地图编码查找效率高。注意ImageIcon.getImage()取到的是动图首帧所以gif虽然能播放但在这里绘制出的实际是静态画面对推箱子这种回合制游戏完全够用。绘制顺序也藏了小心思先画墙、目标点、箱子这些地形最后画玩家。代码里if-else的顺序天然保证了玩家永远显示在箱子、目标点之上不需要额外的图层管理。5号状态的箱子在目标点上用的是tiles[5]视觉效果和2号箱子拉开差异玩家一眼能看出箱子就位了。4.3 键盘监听方向键如何驱动move()键盘事件通过KeyListener挂到面板上方向键按下后转换成0到3的方向编号交给Moving.move()处理panel.addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { int dir -1; switch (e.getKeyCode()) { case KeyEvent.VK_UP: dir 0; break; case KeyEvent.VK_DOWN: dir 1; break; case KeyEvent.VK_LEFT: dir 2; break; case KeyEvent.VK_RIGHT: dir 3; break; default: return; } if (moving.move(dir)) { panel.repaint(); // 只有移动成功才重绘 stepLabel.setText(步数 moving.getStepCount()); if (moving.isWin()) { JOptionPane.showMessageDialog(frame, 通关); } } } }); panel.setFocusable(true);dir统一用0到3的数字正好对应Moving.move()的参数约定避免了在按键层堆砌四次if嵌套。move()返回true才执行repaint和步数刷新无效按键不会浪费一次绘制这个细节在低配机器上连续快速按方向键时能省不少CPU。最后一行setFocusable(true)经常被漏掉。JPanel默认不接收键盘焦点不设置的话按键事件会被窗口吞掉表现就是游戏“完全不听键盘”。如果界面上还有按钮或输入框抢焦点需要在窗口获得焦点后主动调用panel.requestFocusInWindow()保证方向键随时可用。4.4 PlayMusic加载ppt.mid的正确姿势musics/ppt.mid是MIDI格式的背景音乐体积小适合回合制游戏循环。javax.sound.midi包把播放接口封装得很简单import javax.sound.midi.*; import java.io.File; public class PlayMusic { private Sequencer sequencer; public void playLoop(String path) { try { File file new File(path); Sequence seq MidiSystem.getSequence(file); sequencer MidiSystem.getSequencer(); sequencer.open(); sequencer.setSequence(seq); sequencer.setLoopCount(Sequencer.LOOP_CONTINUOUSLY); // 无限循环 sequencer.start(); } catch (Exception e) { System.err.println(MIDI播放失败: e.getMessage()); } } public void stop() { if (sequencer ! null sequencer.isRunning()) { sequencer.stop(); sequencer.close(); // 释放系统声音资源 } } }setLoopCount设成LOOP_CONTINUOUSLY后音乐会从头到尾循环播放不用自己监听播放结束事件。open()之后才能拿到系统音频设备设备被其他程序占用时会抛MidiUnavailableException捕获后打日志即可不影响游戏主流程。stop()里的close()是关键游戏窗口关闭后必须释放sequencer否则声音设备一直被占用关掉游戏还在后台响。MIDI的缺点是音色依赖系统软波表同一个.mid在不同Windows机器上听感不一样。如果想换成WAV或MP3需要改用javax.sound.sampled的AudioSystem那是另一套读取和播放流程换的时候要注意。5. 三十六关扩展关卡校验与运行排错5.1 自定义关卡前先跑一遍格式校验新增地图时最容易出三个问题行列不齐、数字越界、箱子数不等于目标数。前两个会让解析阶段直接报错最后一个更隐蔽箱子比目标点少时玩家会把所有箱子推到位但永远无法通关。我习惯在main()里对所有.map统一跑一遍校验有问题直接打印关卡名public static void checkMap(int[][] map, String name) { for (int i 0; i map.length; i) { if (map[i].length ! map[0].length) { throw new IllegalStateException(name 第 (i 1) 行列数不一致); } } int box 0, target 0, player 0; for (int[] row : map) { for (int v : row) { if (v 0 || v 6) throw new IllegalStateException(name 含非法值 v); if (v Moving.BOX) box; if (v Moving.TARGET) target; if (v Moving.PLAYER) player; } } if (box ! target) throw new IllegalStateException(name 箱子数 box 目标数 target); if (player ! 1) throw new IllegalStateException(name 玩家数 player); }这段检查放在第2章loadMap调用之后。split和Integer.parseInt只负责语法层面的转换不会验证箱子数与目标数之间的逻辑关系所以校验需要单独写一遍。循环处理36个地图时能一次性把全部关卡的问题暴露出来不用一关一关手工进游戏试。5.2 常见运行报错与处理方式现象直接原因处理方式双击bat闪退系统找不到java命令安装JDK并配置JAVA_HOMECMD执行java -version验证界面汉字变乱码编译与运行编码不一致统一UTF-8bat里加-Dfile.encodingUTF-8地图显示不全或越界map文件行列不齐先跑5.1的校验方法定位到具体行修改按键无反应焦点不在JPanel上确认setFocusable(true)点击界面后再按方向键MIDI不出声系统软波表停用或设备被占换一个.mid测试检查sequencer.open()异常最后说一个定位逻辑bug的习惯遇到“箱子穿墙”这类诡异现象时在move()调用前把map[][]逐行打印出来移动完再打印一次对照地图文件逐格比对。变量的中间状态一旦可见大多数问题一眼就能看出来。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →