简介:这份资源是面向计算机相关专业学生与Java初学者的推箱子游戏完整项目,基于Java语言结合Swing图形界面库,在Eclipse开发环境下设计与实现,可作为课程设计、毕业设计选题或Swing桌面编程练手案例。压缩包整体约54.04MB,内含项目全套源码、配套文档与辅导视频,源码均经过测试校正,可百分百成功运行,方便读者直接导入Eclipse调试与二次开发。项目围绕推箱子核心玩法展开,涵盖地图关卡设计、角色与箱子移动逻辑、碰撞与胜利判定、界面绘制与事件响应等模块,文档部分对设计思路与关键代码进行说明,辅导视频则辅助理解实现流程。目前已有939人浏览学习,适合需要完整可运行案例来梳理Swing界面开发与游戏逻辑组织方式的读者参考借鉴。
1. 从一份课程设计说起:Java Swing 推箱子到底能练到什么
很多人第一次接触 Java Swing 推箱子,是在课程设计或者实训周里。题目看着简单——一个地图、一个搬运工、几个箱子、几个目标点,方向键推来推去。但真动手写,你会发现它把 Java 基础、面向对象编程、事件驱动、图形绘制、文件读写全串了一遍。这也是为什么它常年出现在 Java 课程设计选题里,比单纯写个冒泡排序 java 练习有意思得多。
这篇笔记面向两类人:一是正在做 Java Swing 课程设计、需要一份能跑起来、能讲清楚原理的推箱子实现的同学;二是已经会写 Java 基础代码,但没系统做过桌面 GUI 项目、想借这个题目把 Swing 事件模型和 MVC 分层吃透的开发者。我会按「地图怎么建模 → Swing 界面怎么搭 → 移动和碰撞怎么判 → 撤销和关卡怎么存 → 怎么在 Eclipse 里跑通并排错」的顺序讲,源码结构、关键参数、常见翻车点都会落到具体代码上。Eclipse 作为开发环境,导入、编译、运行这几步也会单独说清楚,避免卡在环境上。
2. 地图与角色建模:用二维数组把推箱子规则装进去
2.1 为什么用二维数组而不是一堆坐标对象
推箱子地图是典型的网格结构,常见做法是用一个二维整型数组表示每个格子的类型。相比给每个元素建对象,二维数组在判断「某个方向下一格是什么」时最直接,索引就是坐标,不用遍历查找。我一般会定义这样一套格子编码:
| 编码 | 含义 | 是否可通行 | 是否可推 |
|---|---|---|---|
| 0 | 空地 | 是 | 否 |
| 1 | 墙 | 否 | 否 |
| 2 | 目标点 | 是 | 否 |
| 3 | 箱子 | 是 | 是 |
| 4 | 人 | 是 | 否 |
| 5 | 箱子已在目标点 | 是 | 是 |
| 6 | 人站在目标点 | 是 | 否 |
这套编码的好处是:判断「箱子是否已归位」只需要看值是不是 5,判断「人是否在目标点」看是不是 6。地图数据本身可以直接写成字符串数组,加载时转成 int,方便手改关卡。
// 关卡用字符表示,加载时映射成整型编码 // # 墙 @ 人 $ 箱子 . 目标点 * 箱子在目标 + 人在目标 空格 空地 public class LevelLoader { public static int[][] parse(String[] rows) { int[][] map = new int[rows.length][rows[0].length()]; for (int r = 0; r < rows.length; r++) { for (int c = 0; c < rows[r].length(); c++) { switch (rows[r].charAt(c)) { case '#': map[r][c] = 1; break; case '.': map[r][c] = 2; break; case '$': map[r][c] = 3; break; case '@': map[r][c] = 4; break; case '*': map[r][c] = 5; break; case '+': map[r][c] = 6; break; default: map[r][c] = 0; break; } } } return map; } }这段代码把文本关卡转成整型地图。rows是每一行的字符串,要求每行长度一致,否则数组会错位。实际项目里我会在解析前先校验所有行长度相同,长度不一致直接抛异常,比运行到一半数组越界好排查。
2.2 玩家和箱子的状态怎么存
地图数组里虽然存了人和箱子,但移动时如果每次都去扫描整个数组找人,效率低也不好写。常见做法是额外维护玩家坐标playerRow、playerCol,以及一个箱子坐标集合。箱子数量一般不多,用List<int[]>或者Set<Point>都行。我倾向用List<int[]>,因为箱子数量固定,遍历判断碰撞时顺序稳定,调试时打印也直观。
public class GameState { private int[][] map; private int playerRow, playerCol; private List<int[]> boxes = new ArrayList<>(); public GameState(int[][] map) { this.map = map; for (int r = 0; r < map.length; r++) { for (int c = 0; c < map[r].length; c++) { if (map[r][c] == 4 || map[r][c] == 6) { playerRow = r; playerCol = c; } else if (map[r][c] == 3 || map[r][c] == 5) { boxes.add(new int[]{r, c}); } } } } }这里有个细节:地图里 4 和 6 都表示人,区别只是脚下是不是目标点。初始化时统一按人处理,移动时再根据脚下格子决定写回 4 还是 6。箱子同理,3 和 5 都进集合,判断归位时看它当前所在格是不是目标点。这样状态和地图是两份数据,移动时先改状态再刷新地图,逻辑清晰。
2.3 移动与碰撞判定的核心逻辑
推箱子的规则就三条:前方是空地或目标点,人直接走;前方是箱子,且箱子前方也是空地或目标点,人推着箱子走;其他情况都不动。写成代码时,先算出目标格,再分情况处理。
public boolean move(int dr, int dc) { int nr = playerRow + dr, nc = playerCol + dc; if (map[nr][nc] == 1) return false; // 墙,直接挡回 int[] box = findBox(nr, nc); if (box != null) { // 前方是箱子 int br = nr + dr, bc = nc + dc; if (map[br][bc] == 1 || findBox(br, bc) != null) return false; // 箱子推不动 // 移动箱子 box[0] = br; box[1] = bc; } // 移动玩家 playerRow = nr; playerCol = nc; refreshMap(); return true; }dr、dc是方向增量,上是-1,0,下是1,0,左是0,-1,右是0,1。findBox遍历箱子集合找匹配坐标。注意箱子推动前要先判断箱子前方,不能先改箱子再判断,否则状态就乱了。refreshMap负责把玩家和箱子重新写回地图数组,保证绘制时读到的地图是最新的。
3. Swing 界面搭建:JFrame、JPanel 与键盘事件怎么接
3.1 窗口结构:一个画布加一个状态栏
Swing 做推箱子,界面不需要复杂。常见结构是JFrame作为主窗口,中间放一个自定义JPanel负责画地图,底部放一个JLabel显示步数和关卡。菜单栏可以加「重开本关」「上一关」「下一关」。Eclipse 里新建 Java 项目后,直接建这几个类就行,不需要额外插件。如果你在 Eclipse 里找不到 WindowsBuilder,也不用装,手写布局反而更可控。
public class GameFrame extends JFrame { private GamePanel panel; private JLabel status = new JLabel("步数: 0"); public GameFrame() { setTitle("推箱子"); setDefaultCloseOperation(EXIT_ON_CLOSE); setLayout(new BorderLayout()); panel = new GamePanel(this); add(panel, BorderLayout.CENTER); add(status, BorderLayout.SOUTH); pack(); setLocationRelativeTo(null); setResizable(false); } public void updateStatus(int steps) { status.setText("步数: " + steps); } }pack()会根据面板首选大小自动调整窗口,setLocationRelativeTo(null)让窗口居中。setResizable(false)是因为地图格子大小固定,窗口拉伸后绘制会错位,干脆锁死。状态栏更新走updateStatus,面板每次移动后调用。
3.2 用 paintComponent 画格子,而不是堆 JLabel
新手容易想到用JLabel网格来显示地图,每个格子一个标签。格子一多,组件数量爆炸,刷新还卡。正确做法是继承JPanel,重写paintComponent,用Graphics直接画矩形和图形。这样刷新只重绘一次,性能好,控制也细。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); int size = 40; // 每格像素 for (int r = 0; r < map.length; r++) { for (int c = 0; c < map[r].length; c++) { int x = c * size, y = r * size; switch (map[r][c]) { case 1: g.setColor(Color.DARK_GRAY); g.fillRect(x, y, size, size); break; case 2: g.setColor(Color.YELLOW); g.fillOval(x + 10, y + 10, 20, 20); break; case 3: g.setColor(Color.ORANGE); g.fillRect(x + 5, y + 5, 30, 30); break; case 4: g.setColor(Color.BLUE); g.fillOval(x + 5, y + 5, 30, 30); break; case 5: g.setColor(Color.GREEN); g.fillRect(x + 5, y + 5, 30, 30); break; case 6: g.setColor(Color.CYAN); g.fillOval(x + 5, y + 5, 30, 30); break; default: g.setColor(Color.WHITE); g.fillRect(x, y, size, size); break; } g.setColor(Color.LIGHT_GRAY); g.drawRect(x, y, size, size); } } }size是格子边长,40 像素在 1080P 屏上比较舒服。每种编码对应一种颜色和形状,墙用深灰方块,目标点用黄色圆,箱子用橙色方块,人用蓝色圆。箱子归位后画绿色,人站目标点画青色。最后统一画浅灰网格线,方便看清格子边界。paintComponent里不要做耗时计算,地图数据提前准备好,这里只负责画。
3.3 键盘事件绑定与重绘时机
Swing 里接键盘有两种方式:KeyListener和Key Bindings。KeyListener要求组件获得焦点,焦点一丢按键就失效,这是新手最常见的翻车点。更稳的是用InputMap加ActionMap,把按键绑定到动作上,不依赖焦点。
private void bindKeys() { InputMap im = getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am = getActionMap(); im.put(KeyStroke.getKeyStroke("UP"), "up"); am.put("up", new AbstractAction() { public void actionPerformed(ActionEvent e) { if (state.move(-1, 0)) { steps++; repaint(); frame.updateStatus(steps); } } }); // 下、左、右同理,方向增量分别是 (1,0)、(0,-1)、(0,1) }WHEN_IN_FOCUSED_WINDOW表示只要窗口在前台就响应,不用管哪个组件有焦点。每次移动成功后repaint()触发重绘,同时更新步数。如果移动失败(撞墙或推不动),不重绘也不计步,这样步数统计才准确。repaint()是异步的,Swing 会合并多次请求,不用手动调paintComponent。
4. 撤销、关卡与存档:把后悔药做进项目里
4.1 撤销功能用命令栈还是状态快照
撤销是推箱子项目里最能体现设计功力的功能。两种做法:一是记录每一步的移动命令,撤销时反向执行;二是每步存一份完整地图快照,撤销时直接恢复。命令栈省内存但反向逻辑容易写错,尤其是推箱子这种「人动箱子也动」的操作。我一般用状态快照,因为地图不大,存几十步完全没压力,逻辑还简单。
public class History { private Deque<int[][]> stack = new ArrayDeque<>(); private static final int MAX = 100; public void push(int[][] map) { if (stack.size() >= MAX) stack.removeLast(); // 限制深度,防止内存涨 stack.push(copy(map)); } public int[][] undo() { return stack.isEmpty() ? null : stack.pop(); } private int[][] copy(int[][] src) { int[][] dst = new int[src.length][]; for (int i = 0; i < src.length; i++) dst[i] = src[i].clone(); return dst; } }每次移动前把当前地图压栈,撤销时弹出恢复。MAX限制历史深度,避免玩久了内存一直涨。copy用逐行clone,比Arrays.copyOf更直观。注意压栈时机是「移动前」,这样撤销才能回到上一步。如果移动失败就不压栈,否则撤销会出现无效步骤。
4.2 关卡文件格式与加载
关卡存成纯文本最省事,一行一个关卡,或者一个文件一关。常见做法是每关一个.txt,第一行是关卡名,后面是地图。加载时读文件、解析、初始化状态。Eclipse 里资源文件放在src下或者单独建resources目录,用getClass().getResourceAsStream读,打成 jar 后也能正常加载。
public static String[] loadLevel(String path) throws IOException { try (BufferedReader br = new BufferedReader( new InputStreamReader(GameState.class.getResourceAsStream(path), StandardCharsets.UTF_8))) { List<String> lines = new ArrayList<>(); String line; while ((line = br.readLine()) != null) { if (!line.isEmpty()) lines.add(line); } return lines.toArray(new String[0]); } }用getResourceAsStream而不是FileInputStream,是因为前者从 classpath 读,项目打包后路径不会失效。编码固定 UTF-8,避免中文关卡名乱码。空行过滤掉,防止地图解析时多出空行导致行长度不一致。
4.3 通关判定与关卡切换
通关判定很简单:所有箱子都在目标点上。因为地图里箱子归位后编码是 5,所以只要遍历地图,没有 3 就算通关。切换关卡时重置状态、清空历史、刷新界面。
public boolean isWin() { for (int[] row : map) for (int v : row) if (v == 3) return false; // 还有箱子没归位 return true; }每次移动后调一次isWin,返回 true 就弹提示或者自动进下一关。注意判定要在refreshMap之后,否则地图还是旧数据。关卡切换时记得把步数归零、历史清空,不然撤销会跨关卡,出现「后悔药吃错关」的尴尬。
5. 避坑与排查:Eclipse 里跑推箱子最容易翻车的 5 个点
5.1 键盘按了没反应
现象:窗口出来了,地图也画了,但按方向键人不动。原因:用了KeyListener但面板没获得焦点,或者焦点被状态栏抢走。解决:改用InputMap+ActionMap,条件用WHEN_IN_FOCUSED_WINDOW。如果坚持用KeyListener,要在面板构造里调setFocusable(true)并requestFocusInWindow(),但焦点一旦转移还是会失效,不推荐。
5.2 地图画出来错位或者只画了一部分
现象:格子大小不一致,或者右边、下边被截断。原因:面板首选大小没设对,pack()按默认大小算,地图超出部分画不出来。解决:重写getPreferredSize,返回new Dimension(cols * size, rows * size),让pack()按地图实际尺寸调整窗口。另外确认所有关卡行长度一致,长度不一会导致数组越界或者绘制错位。
5.3 撤销之后箱子位置对不上
现象:撤销一步,人回去了,但箱子还在推过去的位置。原因:压栈时机不对,存的是移动后的地图,或者恢复时只恢复了地图没恢复箱子集合。解决:压栈放在移动前,存完整地图快照;恢复时用快照重新初始化GameState,让玩家坐标和箱子集合都从地图重建,不要只改地图数组。
5.4 Eclipse 里中文乱码
现象:关卡名或者状态栏中文显示成方块。原因:Eclipse 工作空间编码和文件编码不一致,常见是工作空间 GBK、文件 UTF-8。解决:在Window > Preferences > General > Workspace把Text file encoding设为 UTF-8,项目右键Properties > Resource也设 UTF-8。读文件时显式指定StandardCharsets.UTF_8,不要依赖平台默认编码。
5.5 打包成 jar 后关卡加载失败
现象:Eclipse 里跑得好好的,导出 jar 双击运行报空指针,关卡读不到。原因:用了FileInputStream按相对路径读,jar 里文件不在文件系统上。解决:改用getClass().getResourceAsStream("/levels/level1.txt"),路径以/开头表示从 classpath 根目录找。导出 jar 时确认资源文件被勾选打包进去。
6. 进阶技巧:用 BFS 自动求解验证关卡可解性
关卡设计多了,难免手滑设计出无解关卡。与其自己反复试,不如写个自动求解器验证。推箱子状态空间不大时,广度优先搜索(BFS)就能跑。状态用「玩家位置 + 所有箱子位置」表示,去重用HashSet<String>。这一步也是很多 Java 算法练习里会考的方向,和蓝桥杯算法题目里常见的搜索题思路一致。
// 状态编码:玩家行列 + 排序后的箱子坐标,拼成字符串做去重键 private String encode(int pr, int pc, List<int[]> boxes) { boxes.sort((a, b) -> a[0] != b[0] ? a[0] - b[0] : a[1] - b[1]); StringBuilder sb = new StringBuilder(pr + "," + pc); for (int[] b : boxes) sb.append(";").append(b[0]).append(",").append(b[1]); return sb.toString(); } public boolean solvable(int[][] start) { Queue<int[][]> queue = new LinkedList<>(); Set<String> visited = new HashSet<>(); queue.offer(start); while (!queue.isEmpty()) { int[][] cur = queue.poll(); GameState s = new GameState(cur); if (s.isWin()) return true; String key = encode(s.playerRow, s.playerCol, s.boxes); if (!visited.add(key)) continue; for (int[] d : new int[][]{{-1,0},{1,0},{0,-1},{0,1}}) { int[][] next = copy(cur); GameState ns = new GameState(next); if (ns.move(d[0], d[1])) queue.offer(next); } } return false; }encode把状态转成字符串,箱子坐标先排序保证同一状态编码一致,否则顺序不同会被当成两个状态,去重失效。BFS 逐层扩展,找到通关状态就返回 true。箱子多、地图大时状态会爆炸,实际用的时候限制一下搜索深度或者箱子数量,超过阈值就放弃验证,别让程序卡死。
几个调参经验:格子大小 40 像素适合 10×10 以内的地图,再大地图窗口会超出屏幕;历史栈深度 100 步够用,再多内存涨得明显;BFS 验证建议只对箱子数小于等于 4 的关卡跑,超过就人工检查。我自己的习惯是每设计一个新关卡,先跑一遍求解器,确认可解再放进关卡列表,省得玩到一半发现死局,那种感觉比踩坑还难受。
希望帮到你。
本文还有配套的精品资源,点击获取