☰
Java练手项目推荐:带背景音乐的推箱子小游戏完整实现
2026/10/15 17:17:18 网站建设 项目流程

如果让我给初学 Java 的朋友推荐一个适合完整练手的小项目,推箱子小游戏绝对排得上前三。这个游戏看起来只是控制角色推箱子,但真正动手做的时候你会发现,Swing 界面、二维数组、键盘监听、资源加载、音频播放全都要用上,几乎把 Java 基础最常用的知识串了一遍。最近我把带背景音乐的完整版重新写了一遍,把地图设计、移动逻辑、音频循环和资源路径这些容易翻车的细节都填平了,这篇就把完整代码和设计思路一起拆开讲清楚。不管你是刚学完语法想找项目练手,还是课程设计需要交作业,照着这套代码改一改都能直接跑起来。

1. 为什么选推箱子当练习项目:麻雀虽小五脏俱全

1.1 这个项目能让你练会什么

很多人学 Java 学到集合框架和面向对象之后就卡住了,原因很简单:语法学了一堆,不知道往哪里用。推箱子这个项目刚好把这些知识点全部串起来,而且每一块都是"学了马上能用"。

第一是 Swing 界面编程。窗口怎么做、面板怎么绘制、键盘事件怎么监听,这些在纯语法教程里很难体会到,但推箱子天然需要一个图形界面,你就必须去接触 JFrame、JPanel、KeyListener 这一套东西。第二是二维数组的灵活运用。整个游戏的地图就是一张二维表,每个格子的状态用一个数字表示,角色移动、箱子碰撞、通关判断全是围绕数组下标操作进行的,比书本上那种打印九九乘法表有说服力得多。第三是资源管理。图片、音频文件怎么放进项目里,怎么在打包成 jar 之后还能被正确读取,这些会在做背景音乐功能时逼你去搞明白 classpath 的概念。第四是逻辑思维的完整性。一个方向键按下去,背后可能要经过越界检查、墙体判断、箱子碰撞判断、通关检测这一连串流程,代码看起来不长,但每个细节都考验你对状态的把控。

1.2 适合谁来写,做到什么程度算完成

这个项目最适合两类人。一类是刚学完 Java 基础语法、正在找第一个图形项目的初学者,这个难度刚好在"能看懂"和"有挑战"之间;另一类是课程设计选了这个题目的学生,网上有很多版本,但大多没有背景音乐或者代码写得比较乱,这篇可以直接作为完整参考。

做到什么程度算完成呢?我的标准很简单:窗口能打开,角色能用方向键移动,箱子能被推到目的地并变色,所有箱归位后弹出通关提示,背景音乐从程序启动就开始循环播放、关闭窗口时能正常退出。达到这个程度,你就已经拿到了 Java 小游戏开发的一套完整经验。如果你还想加难度,后面可以再做步数统计、计时、撤销上一步、多关卡切换,这些我后面都会提到实现思路。

2. 关卡地图的数据结构:二维数组里藏着所有秘密

2.1 用数字标记地图格子的设计思路

做推箱子第一步不是写代码,而是想清楚地图怎么存。这里我用的是最经典也最适合新手的方案:整张地图就是一个int[][]二维数组,每个数字代表一种格子。我设计了六种状态,比网上常见版本多处理了一个"目的地"的叠加状态:

数字含义说明
0空地人和箱子都可以经过
1墙人和箱子都不能通过
2目的地箱子被推进来就会变成已归位状态
3箱子还没有被推到目的地的箱子
4人角色当前位置
5人站在目的地角色站在目的地上的复合状态
6箱子在目的地箱子已归位的复合状态

为什么要有 5 和 6 这两个"复合状态"?因为目的地本身是一个永久存在的格子,当人或箱子站在上面时,如果直接把地图值覆盖成 4 或 3,那么格子原有的"这是目的地"信息就丢了。等角色走开之后,这一格是应该恢复成空地 0,还是恢复成目的地 2?没有 5 和 6 这两个状态做标记,你就只能再维护一个平行数组去记录哪里是目的地,代码会变得很拧巴。用一个数字同时表达两种信息,虽然看起来有点像"魔法数字",但在这种小型游戏里反而是最直观、最容易调试的写法。

2.2 一张地图的实例与多关卡切换

来看一个最简地图长什么样:

int[][] map = { {1, 1, 1, 1, 1, 1}, {1, 0, 0, 0, 0, 1}, {1, 0, 3, 2, 0, 1}, {1, 0, 0, 4, 0, 1}, {1, 1, 1, 1, 1, 1} };

边界全是墙 1,中间一块空地,箱子 3 在目的地 2 的左侧,角色 4 在下面。你一眼就能看出这关的目标是把箱子向右推一格。这种用数字直接画地图的方式,新手在设计关卡时非常方便,不管多复杂的迷宫,只要照着格子往数组里填数字就行,完全不需要画界面草图。

多关卡切换也很简单,不要让map是单张二维数组,而是用一个三维数组把所有关卡串起来,int[][][] levels。第一维是第几关,第二维是行,第三维是列。通关后把关卡索引加一,重新加载下一张地图,重新定位角色位置,界面重绘即可。拿到完整代码之后,你想加十个关卡,本质上就是往数组里多填几组数字的事。

这里补充一个我的经验:设计地图时左右上下都要留一圈墙,否则角色走到边缘再按方向键会引发数组越界。虽然代码里可以加越界判断兜底,但地图本身四边有墙,运行起来逻辑更干净,调试时也更容易看出问题。

3. 核心移动逻辑:每一次按键后的三重判断

3.1 从目标格到箱子再往后看一格的判断链

移动逻辑是推箱子游戏的心脏,也是初学着最容易写乱的模块。我先把流程总结成一句话:按下方向键,先算角色要去的新坐标,再分情况处理目标格,如果目标是箱子还要继续看箱子后面那一格。

假设方向键按的是右,角色当前位置(px, py),那么新坐标就是(px, py+1)。这时用新坐标去取地图值,按情况分支:

  • 新坐标越界或者目标是墙 1:什么都不做,直接返回。
  • 目标是空地 0 或者目的地 2:角色直接走过去。
  • 目标是箱子 3 或箱子在目的地 6:需要往同方向再看一格(px+2, py+1)。如果后面那格是墙、是另一个箱子、或者越界,说明推不动;否则箱子前进一格,角色站到箱子原来的位置。

这个链条初学者容易漏掉的是最后一种情况里的"继续往后看"。很多人会写成"如果目标格是箱子,就把箱子坐标和角色坐标都往同一个方向加一",但完全没检查箱子后面是什么,结果就是箱子能穿透墙壁。真正的写法是推箱子之前,先把箱子前方的格子取出来判断,全部合法之后才执行赋值操作。

3.2 移动赋值的细节:别把目的地状态丢掉

先给出核心移动方法的完整代码,等会儿我会一行一行解释为什么这样写:

public void move(int dx, int dy) { int nx = px + dx; int ny = py + dy; if (nx < 0 || nx >= map.length || ny < 0 || ny >= map[0].length) { return; } int target = map[nx][ny]; // 撞墙,不移动 if (target == 1) { return; } // 目标是箱子,判断能不能推 if (target == 3 || target == 6) { int tx = nx + dx; int ty = ny + dy; if (tx < 0 || tx >= map.length || ty < 0 || ty >= map[0].length) { return; } int behind = map[tx][ty]; if (behind == 1 || behind == 3 || behind == 6) { return; // 推不动 } // 箱子前进一格,人站到箱子原来的位置 if (behind == 2) { map[tx][ty] = 6; // 箱子推进了目的地 } else { map[tx][ty] = 3; } map[nx][ny] = (target == 6) ? 5 : 4; } else { // 目标是空地或目的地,人直接走过去 map[nx][ny] = (target == 2) ? 5 : 4; } // 人原来的位置要还原成空地或目的地 int old = map[px][py]; map[px][py] = (old == 5) ? 2 : 0; px = nx; py = ny; repaint(); }

重点看两个地方。第一个是map[nx][ny] = (target == 6) ? 5 : 4;:角色走到箱子原来的格子时,如果箱子之前已经在目的地(target 为 6),那么人也是站在目的地上,所以这个格子要设成 5,而不是 4。同理,箱子前进到新位置时,如果新位置本来是目的地 2,就要把箱子标记成 6,表示箱子已归位。第二个是最后三行:人挪走之后,原来的格子必须根据旧值决定恢复成 0 还是 2。如果旧值是 5(人站在目的地),那么人走了之后当然要变回目的地 2。这两处细节一旦漏掉,就会出现"目的地越玩越少"的诡异 bug。

3.3 通关判断:整个二维数组里没有箱子就是赢

通关判断我用了一个很巧但很简洁的思路:遍历整张地图,只要还有值为 3 的格子,就说明还有箱子没被推回目的地,不能通关;一旦地图里一个 3 都不存在了,说明所有箱子都已经变成 6 停在了目的地,玩家获胜。

private boolean isWin() { for (int[] row : map) { for (int cell : row) { if (cell == 3) { return false; } } } return true; }

这里为什么不需要检查 6 的数量是否等于 2 的数量的原因是:所有箱子都在目的地,自然地图上就不存在 3,因为地图状态只有 3 或者 6 两种箱子形态。只要没有 3,就意味着每个箱子都完成了归位。通关之后弹一个对话框提示过关,然后加载下一关。如果已经是最后一关,就提示游戏结束。整个判定逻辑只有几行,但状态设计得好的价值在这里就体现出来了。

4. 让游戏有声音:背景音频的加载与循环播放

4.1 选对 API:为什么用 Clip 而不是 AudioClip

Java 播放音频有两套常用的方案:java.applet.AudioClip和javax.sound.sampled.Clip。很多网上的老代码用 AudioClip,因为它只要一行play()就能出声,看起来特别简单。但 AudioClip 是 applet 时代的遗留接口,你用现代 JDK 编译时会看到已经标记为弃用,而且它对循环播放、错误处理的支持都很弱。我的建议是直接用 Clip 接口,它虽然多写几行,但资源控制更可靠,可以循环播放、可以停止、可以释放。

背景音乐对接 Clip 的核心步骤如下:先把音频文件读成AudioInputStream,再通过AudioSystem.getClip()获得一个录音机对象,接着打开音频流并调用loop(Clip.LOOP_CONTINUOUSLY)让它无限循环。一个最小的实现类大概长这样:

import javax.sound.sampled.*; public class AudioPlayer { private Clip clip; public void playBackgroundMusic(String resourcePath) { try { AudioInputStream ais = AudioSystem.getAudioInputStream( AudioPlayer.class.getResourceAsStream(resourcePath) ); clip = AudioSystem.getClip(); clip.open(ais); clip.loop(Clip.LOOP_CONTINUOUSLY); } catch (Exception e) { System.err.println("背景音乐加载失败: " + e.getMessage()); } } public void stop() { if (clip != null) { clip.stop(); clip.close(); } } }

调用方式只需要在游戏窗口创建时执行一次playBackgroundMusic("/audio/bg.wav"),窗口关闭时执行stop()即可。

这里有个新手很容易踩的大坑:Java 原生的音频 API 不支持 mp3 格式,只支持 wav、aiff 和 au。你网上下载一首 mp3 丢进项目直接播放,会抛UnsupportedAudioFileException。解决方案是先把 mp3 转成 wav 格式。转格式的工具很多,网上随便找一个支持音频转换的就行。我一般会把转换后的 wav 文件放在项目的resources/audio目录下,这个目录会被打进 classpath,运行时用流去读取。

4.2 资源路径的坑:为什么不要用 File 去读

音频加载的另外一个常见问题,是路径写错导致程序一启动就报空指针。直接说结论:在 IDE 里跑的时候,用new File("bg.wav")这种相对路径也许能读到文件,但只要把项目打包成 jar,文件路径就会被压进包里,File再也没办法按原来的相对路径找到音频资源。正确做法是用类加载器去读,也就是上面代码中的AudioPlayer.class.getResourceAsStream(resourcePath)。它把资源当作 classpath 的一部分去查找,不管你在 IDE 里跑还是把 jar 发给别人双击运行,都能正确读到。图片资源也同理,统一放在 classpath 下用流读取,是最稳妥的方案。

关于背景音乐的选择,我给两个建议。第一,音乐文件尽量控制体积,wav 格式一分钟无损音频差不多 10MB,背景音乐用一个简单旋律循环就好,不要塞一首完整的长歌进去。第二,音乐本身不用太讲究,很多游戏开发资源站都提供免费可商用的游戏音效,或者你在自己电脑上用系统自带的录音机录一段简单节奏也行,这个项目的重点是程序逻辑,不追求音乐品质。我记得第一次做的时候随便放了一首自己剪辑的循环音轨,只要音量合适、旋律不刺耳,游戏氛围就完全不一样了。

4.3 声音的开关与音量控制拓展

背景音乐虽然加分,但一直循环播放也挺吵的。我建议在界面上加一个简单的音乐开关,按 M 键可以随时暂停和恢复播放。实现思路是利用 Clip 的两个方法:clip.stop()暂停,clip.start()接着播。如果你是loop()启动的,暂停后调用start()会从暂停位置继续循环,体验很自然。再加一个布尔变量记录当前是否在播放,按键切换就行。

音量调节也可以用 Clip 自带的FloatControl实现,取一个MASTER_GAIN类型的音量控制对象,调用setValue()传入负的 dB 值就能降低音量。这一块属于进阶玩法,代码不超过十行,但能让你的项目在答辩和展示时显得更完整。

5. 完整代码逐块拆解:从窗口骨架到键盘监听

5.1 程序入口与窗口框架

直接给你一套可以运行的完整结构。项目总共四个类加一个资源目录:GameFrame负责窗口,GamePanel负责逻辑和绘制,AudioPlayer负责音乐,MapData统一管理多张地图。程序入口只做一件事:创建一个窗口对象并显示。

public class GameFrame extends JFrame { public GameFrame() { setTitle("推箱子小游戏"); setSize(500, 400); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); setResizable(false); GamePanel panel = new GamePanel(); add(panel); setVisible(true); } public static void main(String[] args) { new GameFrame(); } }

这里我特意没有给JFrame设置布局管理器,因为GamePanel要直接占满整个窗口,默认的BorderLayout放一个组件就够了。setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)这一行一定要写,保证玩家点关闭按钮时整个 Java 进程能退出。千万别setVisible(true)放在添加面板之前,有些环境下窗口已经显示出来了,里面的面板还没准备好,容易出现界面上什么都没有的情况。

5.2 面板的核心组成部分:状态、构造和刷新

GamePanel是整个游戏的中心,它负责持有一份当前地图的引用、记录角色坐标、接收键盘事件、执行移动逻辑、检查通关、重新绘制画面。构造方法里做三件事:加载第一关地图、定位角色初始位置、添加键盘监听。

public class GamePanel extends JPanel { private int[][] map; private int levelIndex = 0; private int px; private int py; private boolean isWinFlag = false; private AudioPlayer bgm = new AudioPlayer(); public GamePanel() { map = MapData.LEVELS[levelIndex]; setFocusable(true); addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { handleKey(e.getKeyCode()); } }); findPlayer(); bgm.playBackgroundMusic("/audio/bg.wav"); } private void findPlayer() { for (int row = 0; row < map.length; row++) { for (int col = 0; col < map[row].length; col++) { if (map[row][col] == 4 || map[row][col] == 5) { px = row; py = col; return; } } } } // 省略移动逻辑、通关逻辑、绘制逻辑 }

注意findPlayer()判断时同时检查了 4 和 5,也就是人站在普通地面和站在目的地两种情况。因为人和目的地叠加的状态是 5,如果只查 4,从目的地出发的角色就会定位失败,坐标全是默认值 0,这是很多新手代码里潜藏的问题。

键盘监听也可以用KeyListener的接口实现方式,但在内部类里用KeyAdapter更省事,不需要实现三个方法,只重写keyPressed就够了。方向键的键码分别是 37、38、39、40,对应左、上、右、下。我还会把 WASD 四个键也接进来,虽然界面上没说明,但很多玩家天然就喜欢按这四个键,接上之后体验会好很多:

private void handleKey(int keyCode) { int dx = 0, dy = 0; switch (keyCode) { case KeyEvent.VK_UP: case KeyEvent.VK_W: dx = -1; break; case KeyEvent.VK_DOWN: case KeyEvent.VK_S: dx = 1; break; case KeyEvent.VK_LEFT: case KeyEvent.VK_A: dy = -1; break; case KeyEvent.VK_RIGHT: case KeyEvent.VK_D: dy = 1; break; default: return; } move(dx, dy); if (isWin()) { nextLevel(); } }

为什么我这里把dx当成行的变化量、dy当成列的变化量?因为int[][]的第一维是第几行,第二维是第几列。按上就是行号减一,按右就是列号加一。这个对应关系搞反是最常见的键盘失灵类的 bug 来源。

5.3 绘制地图:图片加文字标注

绘制部分重写 Swing 的paintComponent方法。为了视觉效果,我给每种格子配了一张小图片,图片资源统一放在resources/images目录下,用ImageIcon加载到内存,绘制时直接画图。如果你没有图片,也不想扣图,可以用fillRect画纯色方块代替,墙上画灰色、目的地画黄色、箱子画棕色、人画红色,效果也不差。有图片版和纯色版代码差别只在paintComponent里,游戏逻辑完全不需要变。

@Override protected void paintComponent(Graphics g) { super.paintComponent(g); int cellSize = 50; for (int row = 0; row < map.length; row++) { for (int col = 0; col < map[row].length; col++) { int value = map[row][col]; // 根据 value 选择对应图片,用 drawImage 画到 (col * cellSize, row * cellSize) // 没有图片时用 g.setColor + g.fillRect 也可以 } } g.setFont(new Font("默认", Font.BOLD, 16)); g.drawString("第 " + (levelIndex + 1) + " 关", 20, 30); }

paintComponent本身不需要调用repaint,它只负责画。真正让你看到角色移动结果的,是每一次move()操作完成之后调用的repaint()。它是 Swing 里请求重绘的标准方式,不是直接把画面刷新,而是告诉 Swing"内容变了,请在事件线程空闲时重新画一次"。你不要尝试在paintComponent里写游戏循环或者加Thread.sleep,那样会直接把界面卡死。

6. 运行时的常见问题:图片不显示、音乐不响、按键失灵

6.1 资源加载失败的根源与统一解法

我教过不少朋友做这个项目,几乎所有人第一次运行都会在资源加载上翻车。最典型的报错是读取图片或音频时抛出NullPointerException,为什么?因为getResourceAsStream找不到资源就返回 null,把这个 null 当成输入流继续操作自然会空指针。检查顺序就三件事:第一,资源文件是否真的在 classpath 里;第二,路径开头的斜杠写法是否正确;第三,资源文件名是不是有大小写或后缀错误。

要知道项目里的源码目录和资源目录是有区别的,把音频文件放在src目录之外的某个文件夹里,IDE 编译时根本不会把它拷进 classpath,运行时自然读不到。所以正确的摆放方式是:在项目下建一个resources目录,右键标记成资源根目录,这样编译后所有文件都会出现在 classpath 里。资源加载用统一的工具方法,不成功时打印一个明确的提示,方便发现问题。

6.2 键盘没反应的常见原因:焦点问题

程序启动后窗口显示出来了,地图也画出来了,但按方向键角色一动不动。这种问题百分之九十是焦点问题。键盘事件只会发给当前拥有焦点的那一个组件,刚创建的窗口焦点可能在根面板上,也可能根本不在你的GamePanel上。解决办法就一行:在构造GamePanel时调用setFocusable(true),然后确保窗口显示后把焦点请求过来。我通常会在GameFrame的构造方法末尾,也就是setVisible(true)之后加一行panel.requestFocusInWindow()。这一行必须放在窗口可见之后,否则请求焦点的命令可能不生效。

如果加了这两行还是没有反应,还有一个隐蔽原因:你往JFrame上添加了按钮或者其他组件,这些组件抢占了焦点。玩家点了某个按钮之后,游戏的键盘监听就失效了。最简单的处理是点按钮之后手动调panel.requestFocusInWindow()把焦点还回来。设计界面时也尽量别放多余的可交互组件,保持单纯的面板焦点。

6.3 窗口关不掉与音乐线程残留

另一个高频问题是:游戏运行正常,音乐也在播,但点击窗口右上角关闭按钮,窗口消失了进程却还在后台运行,IDE 里的控制台红色方块一直亮着。原因就是 Clip 播放音乐之后,后台音频线程没有结束。JFrame 的关闭按钮默认只关闭窗口本身,而音频线程是单独创建的,不通知它停止,Java 进程就不会结束。

解决办法已经在前面写了:在GameFrame里保存一个对面板的引用,重写窗口关闭处理逻辑,先调用bgm.stop()关闭 Clip,再退出程序。如果你的窗口用EXIT_ON_CLOSE,可以在GamePanel里重写removeNotify(),在组件从窗口移除时停止音乐:

@Override public void removeNotify() { super.removeNotify(); bgm.stop(); }

这个办法好处是不需要改窗口的关闭逻辑,面板在窗口销毁时自动收到通知,顺手把音频释放掉。代码简洁,也不容易漏。

6.4 箱子推进死角:设计地图时的自查清单

最后聊一个很隐蔽的体验问题:箱子推着推着到了角落,结果卡死没法通关。比如一个箱子被推到两面墙相交的直角区,角色根本没办法绕到箱子对面把它推出来。这种情况不是代码 bug,是地图设计问题。自查的时候只需要记住一条规则:任意一个箱子,只要它被推进了"走廊尽头""角落"这类三面封闭的区域,这一关就变成了死局。

设计地图时,我习惯先画草图,把目的地放在开阔区域,保证每个箱子从初始位置出发,至少存在一条能到达对应目的地的通路。初始摆放时也尽量让箱子不紧贴墙角。如果你的项目加了"重置关卡"的功能,这个问题的严重性会大大降低,玩家卡死了按一下 R 键就能重来,这也是很多商业推箱子游戏的标配。我自己的版本里就加了重置键,因为第一次设计地图时我自己都能把自己卡死。

6.5 地图越界和数组下标异常

还有一个基础但必须提的防御点:角色在地图边缘,或者箱子被推到最边缘时,判断代码里如果没做边界检查,直接访问下一格,就会抛ArrayIndexOutOfBoundsException。我上面的move()方法里在进入主逻辑前先做了一次目标坐标越界检查,而在推箱子的分支里又对箱子前方的坐标做了第二次越界检查。你拿到代码后可以把这两处if边界判断删掉试试,然后故意把角色推到边上按方向键,马上就能看到红色异常。边界检查虽然长得丑,但它是二维数组类游戏的基本功,绝不能省。

对照一下常见问题的排查顺序:

现象可能原因解决办法
图片没画出来图片资源不在 classpath 或路径错误用类加载器读取,资源目录标记为资源根
背景音乐不响音频是 mp3 或路径读不到转成 wav,用 getResourceAsStream 读取
按键没反应面板没有焦点setFocusable(true) + requestFocusInWindow()
窗口关闭后进程不退出Clip 音频线程未释放在 removeNotify 里调用 clip.stop() 和 close()
箱子穿透墙壁推箱子前没有检查箱子后方格子移动方法中补上 behind 格子的判断
角色走进箱子同一格移动后没有正确更新人和原位置的格子按文中的更新逻辑逐格赋值

写在最后的一点经验

我自己最开始写这个推箱子项目时,花了整整一下午去调移动逻辑,拿着调试器一步步看地图数组每个值的变化。后来想通了:这种小游戏不要急着上对象设计,先用直白的二维数组把流程跑通,观察数组里每个数字的变化规律,比看任何框架代码都管用。背景音乐那一步也吃了不少亏,主要是不了解 mp3 和 wav 的兼容性,又走了 File 路径的弯路,后来统一成类加载器读流之后一劳永逸。其实整个项目真正难的地方不是语法,而是状态之间的转换逻辑。你只要把地图数字的每一层含义都搞清楚,这套代码改成彩色版、加上步数计时、甚至做一个自定义关卡编辑器都不难。做出来之后建议你亲手改一改地图的数字,让朋友试玩一下,看到他们卡关的那一刹那,你会觉得这项目写得值。

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

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

立即咨询