简介:一份基于Java开发的趣味小游戏源码压缩包,定位为适合Java入门者练手、学习游戏编程的完整示例项目。资源共78个文件,包含6个java源文件与7个class编译文件、jpg图片素材48个、gif动图14个,以及其他工程配置类文件,压缩包大小约2.98MB,其中图片资源主要用于角色、场景和界面表现,便于直接查看或调试运行。已有2659人浏览学习。通过这份项目,可以直观了解Java Swing/JavaFX界面的构建、键盘事件监听、游戏主循环设计、碰撞检测、重力跳跃模拟、定时倒计时、收集与积分机制等核心实现,适合正在学习Java面向对象编程或桌面小游戏开发的读者作为参考,帮助串联从界面绘制到游戏逻辑的完整开发思路。
1. 单人通关森林冰火人:一套键盘同时指挥两个角色
如果你一个人也想玩森林冰火人,最痛苦的事情不是手速不够,而是原版从出生点开始就默认你身边坐着另一个玩家。这份Java小游戏资源把经典的双人合作关卡压缩成了单人可跑通的版本:你需要同时控制火娃和冰娃,在熔浆、水池、冰块、宝石组成的迷宫里解谜。整个项目没有依赖第三方引擎,纯Java Swing/Canvas实现,源码包解压后可以直接导入IDE编译运行。它适合正在学Java集合和线程的开发者,也适合需要交一个课程设计、又不想只做计算器的同学。
2. 技术栈与工程结构:Swing、线程模型和资源目录
2.1 为什么用纯Java Swing而不是游戏引擎
森林冰火人这类2D平台解谜游戏,最核心的需求是角色移动、碰撞检测、地图渲染和简单的音效播放。用第三方2D框架当然能做,但为了一个课程设计和源码教学,反而把“Java基础知识”混进了引擎概念里。这个项目选的是JDK自带的Swing和AWT,原因很简单:不需要额外安装环境,双击JAR就能跑,代码量也控制得住。
Swing本身是一个UI框架,但游戏面板GamePanel继承JPanel以后,可以通过重写paintComponent绘制任意内容。配合javax.swing.Timer或者自定义线程,一帧一帧重绘画面,就是一个小游戏循环。这套做法虽然不如游戏引擎自带粒子系统、物理引擎,但对付二维瓦片地图和矩形碰撞完全够用。你反而能看到主循环、事件监听、资源加载这些原始逻辑,对理解游戏引擎底层是很有帮助的。
我一般建议,如果目标是快速做出成品,可以直接用Swing;如果目标是要做商业级独立游戏,再换重型框架不迟。这个项目的取舍是:把复杂度留在算法层,而不是框架层。音效部分用了javax.sound.sampled.Clip,属于JDK内置API,连第三方依赖都没有。
2.2 解压zip之后先看目录:src和resources分得清清楚楚
拿到zip包,解压之后建议先浏览目录树。这个项目用的是常见的IDE友好结构,源码和资源文件分离:
ForestFireIce-Single/ ├── src/ │ └── com/ │ └── fireice/ │ ├── main/ │ │ └── GameLauncher.java │ ├── entity/ │ │ ├── FireBoy.java │ │ ├── IceBoy.java │ │ ├── TileType.java │ │ └── GameObject.java │ ├── game/ │ │ ├── GamePanel.java │ │ ├── LevelManager.java │ │ └── AudioManager.java │ └── ui/ │ ├── HUD.java │ └── MenuPanel.java ├── resources/ │ ├── maps/ │ │ ├── level1.txt │ │ ├── level2.txt │ │ └── level3.txt │ ├── images/ │ │ ├── fireboy.png │ │ ├── iceboy.png │ │ └── tiles.png │ └── audio/ │ ├── bgm.wav │ └── gem.wav └── README.md从目录就能看出分层:main负责启动,entity放角色和物体,game负责游戏循环、关卡和音频,ui放菜单和状态显示。这种分包方式最大的好处是,你想改逻辑时不用在几百行的一个类里翻来翻去。resources和src分离也很重要,地图和图片不再硬编码到Java代码里,新增关卡只加一个txt文件就行。
导入开发工具时,直接把解压后的目录作为已有项目打开,然后把src标记为源码根目录,把resources标记为资源根目录。如果编译报找不到javax.sound,先检查你用的JDK是不是完整的标准版,精简过的JRE会砍掉音频API,换回完整JDK即可。
2.3 游戏主循环:帧率限制和EDT线程的配合
看完了包结构,肯定直接翻GamePanel。游戏的骨架是主循环,这段代码是整个项目跑起来的发动机。核心是实现了Runnable接口,在run方法里用固定步长驱动更新和渲染:
public class GamePanel extends JPanel implements Runnable { private Thread gameThread; private static final int FPS = 60; private static final int UPDATE_INTERVAL = 1000 / FPS; // 约16ms @Override public void run() { long lastTime = System.nanoTime(); double delta = 0; while (gameRunning) { long now = System.nanoTime(); delta += (now - lastTime) / 1000000.0; lastTime = now; if (delta >= UPDATE_INTERVAL) { update(); // 更新所有角色位置和状态 repaint(); // 请求Swing重绘画面 delta %= UPDATE_INTERVAL; } // 简单睡眠让出CPU,避免空转 Thread.sleep(2); } } }这里的关键是update和repaint分离。update负责改角色坐标和碰撞结果,repaint只是通知Swing的EDT线程下一次paintComponent去绘制最新状态。千万不要在gameThread里直接调用paintComponent,那会违反Swing单线程模型,造成界面闪烁甚至崩溃。
参数说明:FPS设定60,UPDATE_INTERVAL就是每帧16.67毫秒。delta变量这里用来累加时间差,相当于一个简易的计时器,保证在不同性能的机器上运动速度一致。Thread.sleep(2)是防止循环空转,如果去掉,CPU占用会一直很高。实际项目里,更稳妥的是用ScheduledExecutorService,不过这个项目的教学重点不在并发,所以Thread够用了。
2.4 启动入口:一段容易踩坑的main方法
GameLauncher里通常会写这样的代码:
public static void main(String[] args) { SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame("森林冰火人 单人版"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); GamePanel panel = new GamePanel(); frame.add(panel); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); panel.startGameThread(); }); }SwingUtilities.invokeLater的目的是把窗口创建放在EDT上,这是Swing应用的标准写法。很多新手直接在main里new JFrame,时间长了会出现诡异的不刷新或卡死问题。startGameThread放在setVisible之后,确保窗口已经显示,游戏线程再开始发repaint请求,否则容易丢失第一帧。
参数说明:pack()是根据内容区域大小自动调整窗口,比setSize()更省心;setLocationRelativeTo(null)让窗口居中。如果你在系统缩放过大的屏幕上发现画面偏小,可以手动setSize(960, 640)然后setResizable(false)。这个项目的窗口大小一般是960x640,适配内部瓦片尺寸。
3. 核心玩法拆解:火娃冰娃的移动、碰撞与单人控制
3.1 角色实体:坐标、速度、朝向和状态
不管叫实体还是GameObject,火娃和冰娃本质上就是一组矩形。FireBoy和IceBoy两个类结构基本一致,差别在于对危险地形的抗性不同。看一下FireBoy的主要字段和构造方法:
public class FireBoy { private float x, y; // 左上角坐标 private float vx, vy; // 水平/垂直速度 private int width = 40; private int height = 64; private boolean onGround; // 是否在地面上 private boolean facingRight = true; private Level level; // 持有当前关卡,用于碰撞检测 public void update() { // 重力 vy += 0.5f; // 水平移动 x += vx; handleCollision(true); // 垂直移动 y += vy; handleCollision(false); } }这里采用的是先水平移动、再垂直移动的策略。水平移动时只做X轴碰撞检测,垂直移动时只做Y轴,这样能避免角色在墙角处被同时卡住。width和height是碰撞箱尺寸,这里取40x64像素,实际绘制用的图片可以比碰撞箱略小,视觉上更宽容。
update里重力常量直接写0.5,单位是像素每帧平方。如果觉得角色跳得太飘,就把常数调大一点比如0.6;太低则落地慢。很多调手感的过程就是在调这三个数:重力、跳跃初速度、移动速度。这个项目没做复杂状态机,就用onGround一个布尔值判断能否跳,也够用了。
3.2 单人控制方案:一套键盘同时指挥两个角色
原版双人是一个键盘一个角色,单人版最大的改动在按键层。我看到这份源码的处理方式是火娃和冰娃各占键盘一侧,火娃用方向键上下左右,冰娃用WASD,一个人两只手正好全覆盖。这就是“单人版”名字的由来:一个人同时操作两个角色,而不是轮流切换。
@Override public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_UP: fireBoy.setUp(true); break; case KeyEvent.VK_DOWN: fireBoy.setDown(true); break; case KeyEvent.VK_LEFT: fireBoy.setLeft(true); break; case KeyEvent.VK_RIGHT: fireBoy.setRight(true); break; case KeyEvent.VK_W: iceBoy.setUp(true); break; case KeyEvent.VK_A: iceBoy.setLeft(true); break; case KeyEvent.VK_D: iceBoy.setRight(true); break; case KeyEvent.VK_S: iceBoy.setDown(true); break; } }注意这里监听的是KeyEvent,不是按键字符。代码里分开处理每个按键的按下和释放事件,setUp(true/false)是修改角色的方向状态位。为什么不直接在keyPressed里改坐标?因为按键按下的那一刻是瞬间事件,如果直接在事件里改坐标,键盘检测不到持续按住,角色就只会动一下。正确做法是把“这个键是否按着”记录下来,update循环里根据这些布尔值施加速度。
这种按键方案学习成本很低,但有个实际问题:当火娃和冰娃要同时往相反方向走时,两只手要协调,手残玩家会翻车。我在跑通第一关失败三次后觉得,可以在GamePanel里加一个“锁定模式”或者“简单模式”,让冰娃自动跟随火娃,或者按空格切换操作对象。不过那份源码里没有做自动跟随,说明作者还是希望保留一点挑战性。
跳跃实现也在这个环节里,一般是keyPressed时判断onGround,如果角色在地面就赋予一个向上的初速度,比如vy=-12。想要更细腻的手感,可以加“跳跃缓冲”和“落地缓冲”,但那个复杂度已经超出这份源码的范围了。
3.3 可交互元素:熔浆、水池、宝石和出口的处理
森林冰火人最核心的机制是水火不容。火娃碰到熔浆没反应,但碰到水会挂;冰娃恰恰相反,碰到熔浆会挂,碰到水池没问题。其他可交互元素还包括宝石、出口、冰块和木箱。这部分逻辑是关卡的核心,可以看LevelManager里的碰撞分支:
public void handleElement(Character c, Tile tile) { switch (tile.getType()) { case LAVA: if (c instanceof IceBoy) { c.die("冰娃进入了熔浆"); } break; case WATER: if (c instanceof FireBoy) { c.die("火娃掉进了水池"); } break; case GEM_FIRE: if (c instanceof FireBoy) { collectedGems++; tile.setPassable(true); playGemSound(); hud.updateGemCount(collectedGems); } break; case GEM_ICE: if (c instanceof IceBoy) { collectedGems++; tile.setPassable(true); playGemSound(); hud.updateGemCount(collectedGems); } break; } }这段代码用instanceof区分角色类型,简单直观。宝石分火宝石和冰宝石,火娃只能捡火宝石,冰娃只能捡冰宝石,这就强迫你同时操作两个角色去不同区域。熔浆和水都是整格判定,而不是只判定角色脚尖,所以刚踩进去一格就会触发死亡,不会给“半身入水”的缓冲机会。
可破坏元素在代码里通常是一个单独的TileType,例如ICE_BLOCK。冰娃可以站在上面,火娃靠近会让它熔化;木箱可以被推动。这种行为在这个项目中不是在角色类里硬编码,而是通过TileType配合碰撞回调完成的,改动时只要在handleElement里加一个case,比在角色类里加if更清晰。
4. 地图与渲染:文本关卡、AABB碰撞和摄像机跟随
4.1 地图文件格式:用字符搭一个关卡
这个项目的地图不是图片拼接,而是纯文本文件。每行字符串代表地图的一行,每个字符代表一个瓦片。这样的好处是任何人都能用记事本编辑关卡,不需要地图编辑器。看一下level1.txt的开头:
11111111111111111111 10000000000000000001 10200200300000400001 10000000000000000001 1000000LLLL0WWWWW001 11111111111111111111实际文件会大很多,这里只展示格式。字符到瓦片的映射在TileType.java里用枚举定义,常见映射是:1=墙,0=空地,L=熔浆,W=水,2=火娃出生点,3=冰娃出生点,4=火宝石,5=冰宝石,6=出口。解析时逐行读取,不能用BufferedReader.readLine后直接当字符数组,要考虑到换行符差异;读取字符串后还要trim掉末尾的\r。
解析代码如下:
public void loadMap(String path) { try (BufferedReader br = new BufferedReader( new InputStreamReader( getClass().getResourceAsStream(path), "UTF-8"))) { String line; int y = 0; while ((line = br.readLine()) != null) { line = line.trim(); for (int x = 0; x < line.length(); x++) { char c = line.charAt(x); tiles[y][x] = TileType.fromChar(c); } y++; } } catch (Exception e) { e.printStackTrace(); } }这里用getResourceAsStream而不是new File,是为了后续打包成JAR不失效。每个字符转成TileType,最后得到二维数组tiles[y][x]。注意数组行列和地图坐标系的方向,y下标是行,x下标是列,绘制时瓦片像素坐标是xtileSize, ytileSize。很多人在碰撞检测里把x和y搞反,就会出现角色在竖直墙上穿来穿去。
4.2 AABB碰撞检测:先X轴再Y轴
碰撞部分直接决定手感。Swing没有内置物理引擎,所以自己写AABB。AABB就是轴对齐包围盒,用两个矩形是否有重叠判断碰撞。角色移动时不能直接把x+=vx然后判断重叠,那样会在高速移动时穿透薄墙。常见做法是把位移拆成两步:
private void moveX(float dx) { x += dx; Rectangle next = getBounds(); for (Tile t : level.getAllSolidTiles()) { if (next.intersects(t.getBounds())) { if (dx > 0) { x = t.getX() - width; } else if (dx < 0) { x = t.getX() + t.getWidth(); } vx = 0; break; } } } private void moveY(float dy) { y += dy; Rectangle next = getBounds(); for (Tile t : level.getAllSolidTiles()) { if (next.intersects(t.getBounds())) { if (dy > 0) { y = t.getY() - height; onGround = true; } else if (dy < 0) { y = t.getY() + t.getHeight(); vy = 0; } break; } } }注意moveX和moveY分别处理一个轴。如果水平移动时撞墙,就贴到墙边而不是穿过去,同时把vx清零。垂直向下移动撞上地面,就把y定位到地面上方,并置onGround=true,这样下一次跳跃才能触发。向上撞头时把vy清零,防止角色吸在屋顶上。这个方案简单有效,是2D平台游戏最常用的做法。
这里有个参数容易踩坑:getBounds()返回的是角色当前的碰撞矩形,但如果在移动之前修改了x,再跟所有固体瓦片做intersects判断,这个矩形是已经移动后的位置,所以修正坐标需要把x回退到接触边界。上面代码里直接根据dx正负吸附到tile边缘,逻辑是对的。如果你发现角色卡在墙里半格,多半是因为tileSize和width的单位不统一,或者刚体边缘相差1像素。
4.3 摄像机跟随和绘制顺序
地图如果大于窗口,就需要摄像机跟随。这个项目是两个角色都出现在画面里,所以摄像机要兼顾两个人的位置。一种常见的算法是取两个角色位置的中点作为观察点,再限制在地图边界内:
int camX = (int) ((fireBoy.x + iceBoy.x) / 2 - panelWidth / 2); int camY = (int) ((fireBoy.y + iceBoy.y) / 2 - panelHeight / 2); camX = Math.max(0, Math.min(camX, mapWidth - panelWidth)); camY = Math.max(0, Math.min(camY, mapHeight - panelHeight));摄像机偏移算出后,渲染时所有世界坐标都要减去camX/camY。paintComponent的绘制顺序也很讲究:先画背景,再画可收集宝石,然后画可碰撞瓦片,再画角色,最后画HUD。如果顺序反了,角色可能会被前景瓦片盖住。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.translate(-camX, -camY); level.drawBackground(g2); level.drawTiles(g2); iceBoy.draw(g2); fireBoy.draw(g2); g2.translate(camX, camY); hud.draw(g2); }g2.translate是整幅画面平移,比每个瓦片绘制时都减偏移量要高效得多。画完世界内容后用translate(camX, camY)恢复原坐标,HUD继续在屏幕绝对位置显示。这里有一个细节:super.paintComponent(g)先填充背景,如果不调用,窗口会残留上一帧。Swing其实已经默认双缓冲,但前提是通过repaint触发paintComponent,外部线程不能乱画。
5. 避坑与常见问题排查:乱码、卡墙、CPU飙升这样处理
5.1 中文乱码:控制台和Swing界面编码不一致
现象:代码里写了中文提示,控制台输出乱码,游戏界面的菜单或对话框变成方块,在中文系统上尤其明显。
原因:javac编译时默认读取平台字符集,中文系统会按GBK读取Java源文件,而开发工具保存的源文件是UTF-8,两者不一致就会导致字符串被错误解码。Swing里的JLabel不指定字体和渲染语言时,中文字体回退也可能失败。
解决:统一编译编码和运行编码。在开发工具里设置文件编码为UTF-8,如果是命令行编译,加参数:
javac -encoding UTF-8 -cp . src/com/fireice/main/GameLauncher.java运行时加参数 -Dfile.encoding=UTF-8。Swing界面建议在main里指定一个支持中文的默认字体,例如用Swing的逻辑字体名:
FontUtil.setDefaultFont(new Font("Dialog", Font.PLAIN, 14));这样中文提示不会因为缺少字体而变成方块。从我拆过的项目看,因为编码踩坑的概率非常高,尤其你直接把源码拖到另一台机器上编译时,先确认开发工具右下角编码显示是不是UTF-8,比反复改代码省时间。
5.2 角色卡墙和穿透:碰撞边界比图片小
现象:角色站在平台上时偶尔往下陷半格,或者从墙缝里被挤出去;高速奔跑时直接穿过薄墙。
原因:角色实际碰撞矩形和绘制图片尺寸不一致,地图瓦片固体区有缺口,遍历碰撞时只检测了第一个相交的瓦片而没有处理多瓦片重叠。另外moveX和moveY分别执行,如果水平时被卡住又直接应用垂直位移,会在墙角处产生微小的穿透。
解决:把碰撞矩形统一成常量,不要直接引用width和height参与绘制。确保每个角色只保留一个public Rectangle getBounds(),所有碰撞检测都以这个矩形为准。移动时,先记录旧坐标,如果碰撞后位置仍然重叠,就直接回退到旧坐标。条件允许时,把固定步长从16ms改成8ms也能减少高速穿墙,但这会牺牲性能,一般不必要。
5.3 高DPI屏幕下Swing画面模糊
现象:在系统开了150%缩放的高分屏上运行,画面边缘发虚,文字不清晰。
原因:Swing本身有高DPI适配,但如果你在main里调了setSize或者手动画了位图而不是矢量图形,系统缩放后pixmap被拉伸就会模糊。尤其自己用BufferedImage绘制角色,画布尺寸固定,高分屏上放大必然糊。
解决:最简单的方式是让渲染基于Graphics2D的缩放,先获取屏幕缩放因子:
double scale = Toolkit.getDefaultToolkit().getScreenResolution() / 96.0; g2.scale(scale, scale);但Swing组件字体一般也能自适应,这里重点是不要直接放大像素图。如果要支持高分屏,最好把瓦片尺寸设计成64px这类偶数,并保持所有图片是2倍图,渲染时按实际DPI缩放到合适大小。如果只是测试环境偶尔模糊,用强制文本抗锯齿也能改善观感。
5.4 CPU占用率直线上升,风扇狂转
现象:游戏运行后CPU占用80%以上,哪怕停在菜单界面也高得离谱。
原因:主循环里没有帧率限制,或者while循环里Thread.sleep(2)没有在真正闲置时让出CPU。另外repaint调用太频繁,导致整个渲染管线每毫秒都在执行。很多从Applet改造过来的项目会犯这个错。
解决:用固定步长,并在没有更新时sleep一段时间。我自己的经验是,再好的设计也扛不住每帧都做全屏重绘。可以加一个fps计数器,实际fps稳定在60左右即可。如果在后台或窗口最小化时,暂停游戏线程或者把sleep提高到50ms,CPU立刻降下来。
5.5 打包JAR后找不到图片和地图文件
现象:在开发工具里跑得好好的,导出成可执行JAR后,图片和地图全部加载失败,点击按钮没有反应或者游戏直接退出。
原因:开发工具运行时,resources目录在文件系统里可以按相对路径new File()读到;打包成JAR后,resources被塞进压缩包,File对象无法直接定位到JAR内部的条目。这个问题在Swing项目里最常见。
解决:全部资源改用ClassLoader.getResourceAsStream()读取,不要用new File()。例如TileType.fromChar完全不需要文件路径,地图加载也改成上面写过的方式。JAR内资源路径以/开头,比如/resources/maps/level1.txt,但注意getResourceAsStream的参数不应该带resources前缀,而是相对于classpath的路径。打包时确认resources目录被包含进JAR,不要用开发工具默认的排除规则。
6. 二次开发:从单人版改回双人协作版,并加新关卡
6.1 改双人协作,最小改动只要换按键
单人版最让人想改的就是把它还原成双人协作。改回双人不难,把键盘平分成左右两半,一人一手,或者直接支持两个手柄。源码里GamePanel的keyPressed已经把两个角色的方向事件分开了,你只要在控制逻辑上为第二个人增加第二套按键,比如让火娃用WASD,冰娃用IJKL。如果想支持多人,就再抽一个KeyBinding类,把按键配置外部化到配置文件里。
case KeyEvent.VK_J: iceBoy.setLeft(true); break; case KeyEvent.VK_L: iceBoy.setRight(true); break; case KeyEvent.VK_I: iceBoy.setUp(true); break; case KeyEvent.VK_K: iceBoy.setDown(true); break;换完键,再把标题改掉,一个本地双人合作模式就回来了。如果你还想加手柄支持,Java也有内置的joystick输入,但通常会引入第三方库,工程量会大一些,建议先把键盘方案跑通。
6.2 新增关卡:地图文件、机关密度和难度曲线
新增关卡只需要在resources/maps目录下加一个levelN.txt,然后在LevelManager里注册新关卡。设计关卡的时候要注意,地图大小和出生点要正确,两个角色出生后要都能动。我习惯用一套参数来把控难度:
| 参数 | 单人版建议 | 双人版建议 |
|---|---|---|
| 地图宽度 | 20-30格 | 28-40格 |
| 熔浆/水池数量 | 3-5个 | 5-8个 |
| 关键机关数量 | 2-3个 | 4-5个 |
| 单一角色独立路径 | 尽量少 | 可以多一些 |
单人版因为一个人控制两个角色,设计关卡时不要让两个角色必须在同一帧完全同步,否则操作难度陡增。给少量“先后到达”的容错空间,比如用冰块挡住火娃的路线,等冰娃开启机关后再融化。
6.3 验证方法:让调试信息可视化
二次开发最怕的就是你改了碰撞参数,结果角色从画面边缘消失了。我一般会在GamePanel里注册一个F3调试开关,按一下就在HUD上显示当前坐标、速度、onGround状态和碰撞体矩形框:
if (debugMode) { g2.setColor(Color.RED); g2.drawRect((int) fireBoy.getX() - camX, (int) fireBoy.getY() - camY, fireBoy.getWidth(), fireBoy.getHeight()); g2.drawString("FireBoy x=" + fireBoy.getX() + " y=" + fireBoy.getY(), 10, 30); }这样调整角色坐标时,你不用靠肉眼猜角色是不是被卡住,直接看矩形框就知道碰撞箱在哪。如果你想改一版属于自己的双人森林冰火人,这份源码是个不错的起点。从那以后我每次改完碰撞逻辑,都会先开F3跑一遍第一关,确认两个角色都不穿墙、不卡死,再去碰新功能。这种习惯能省掉很多无头苍蝇一样的排错时间,希望帮到你。
本文还有配套的精品资源,点击获取