森林冰火人Java小游戏源码解析:Swing游戏循环与碰撞检测实践 简介一款基于Java的趣味小游戏源码与素材包面向Java初学者和游戏开发爱好者演示了从界面搭建、角色控制到碰撞检测的完整流程。压缩包内共有78个文件包含48张jpg图片、14个gif动效、6个java源码文件、7个class编译文件以及classpath、prefs、project等工程配置整体大小仅2.98MB目录按src与bin分离结构清晰可直接导入IDE进行运行与修改。游戏以森林场景中的单人探险为主线通过键盘控制角色行走跳跃、收集水晶、累计积分并设有倒计时失败机制能够帮助读者理解Game Loop、KeyEvent监听、Timer定时器及命中判定等核心知识点图片与动效则用于渲染角色和场景。目前已有2660人学习下载非常适合作为Java课程设计或小游戏开发的入门参考素材与代码分离的组织方式也更方便二次开发与功能扩展。1. 森林冰火人单人版一个能跑的Java小游戏能拆出多少东西很多人以为森林冰火人这种考验双角色配合的小游戏多少得配个游戏引擎实际上一个纯Java工程套上Swing就能完整跑起来。这份名叫“java小游戏——森林冰火人单人版”的zip压缩包装的就是整套可编译的游戏源码界面、角色、关卡、碰撞和胜利判定都在里面解压之后用IDEA打开就能进游戏。单人版的说法指玩家在键盘上同时控制两个角色——火人用WASD冰人用方向键一个人两只手分工干活。这游戏对正在学Java但苦于没有项目练手的人特别合适拿来当课设或者二次开发的基底都不亏。源码的结构不算复杂但麻雀虽小五脏俱全有游戏主循环、重力系统、矩形碰撞检测、文本关卡加载、胜负判定这些完整逻辑。后续我会按“这项目能干什么、核心代码怎么实现、怎么跑起来、哪些地方容易踩坑、怎么往上加功能”的顺序把这些模块一个个拆开讲重点说清楚哪些参数能改、改完会有什么效果。2. 技术栈和工程结构Swing窗口、游戏线程和一份文本地图2.1 技术选型为什么用Swing而不是上LibGDX做2D小游戏首先要选渲染层。这个项目用的是JDK自带的Swing配套AWT的键盘事件没有引入任何第三方引擎原因很直接游戏循环和碰撞检测才是这阶段的练习重点外带依赖只会增加配置成本。Swing里负责游戏画面的是一个全屏JPanelJFrame只扮演外壳角色。把面板填进窗口之前要记得放在ContentPane层否则默认布局器会把面板顶到奇怪的位置。另一个绕不开的技术点是线程。Swing的事件分发线程EDT不能长时间占住否则窗口会卡成“未响应”。常规做法是单独起一个游戏循环线程每隔一帧更新角色状态再由repaint触发重绘。绘制动作本身还是回到EDT上执行所以不会有线程安全问题。这里常见的一个混用错误是直接在paintComponent里调用update()那等于把逻辑和绘制全压在EDT上掉帧的同时还容易越积越卡后面避坑章会专门讲。渲染层上手成本是否需要第三方库适合场景AWT低否基础图形练习Swing中否本类2D小游戏双缓冲完善JavaFX中高JDK 11以上需单独引入复杂动画和UILibGDX高是商业级2D游戏这个项目用Swing不是因为它最强大而是因为在“能跑、能改、能交作业”这个目标下它是综合成本最低的选择。接下来按目录结构往下看。2.2 目录结构与类职责源码分包很清爽基本一个类干一件事没有把一堆逻辑塞进同一个文件里。标准布局大致是这样的src/ ├── game/ │ ├── GameFrame.java // 主窗口标题、尺寸、main入口 │ ├── GamePanel.java // 游戏面板游戏循环、键盘监听、绘制 │ ├── Player.java // 角色类冰人与火人共用owner区分 │ ├── Block.java // 静态方块墙、水、岩浆、出口 │ ├── LevelManager.java // 读取res/levels下的文本地图 │ ├── CollisionUtil.java // 碰撞检测与方向修正 │ └── res/ │ └── levels/ │ └── level1.txt // 第一关纯文本二维数组类名职责关键字段/方法GameFrame创建窗口装配面板main()setContentPane()GamePanel游戏循环线程处理键盘事件重写paintComponentrun()update()paintComponent()Player记录角色坐标、速度、状态区分冰火vxvyonGroundownerBlock描述一个格子的类型和碰撞矩形typesolidrectLevelManager把文本地图变成Block二维数组loadLevel(String path)CollisionUtil移动角色并修正碰撞位置move(Player, Block[][])这样拆分的好处很直接以后想加一个敌人只需要新建Enemy类并放进update循环不需要动Player和碰撞工具。想换地图改res/levels下的txt文件即可不需要动Java代码。2.3 关卡地图格式一份txt就是一张房间地图用文本二维数组表达每一行对应关卡的一行格子每个字符对应一种方块。一个简易的level1.txt长这样#################### #B F# # D D # ###### W ######### # L # # E X # ####################字符和方块类型的映射如下#是实心墙空格是可通行区域W是水L是岩浆B是冰人出生点F是火人出生点E是冰出口X是火出口D是宝石。规则很直观火人踩进水面直接死亡冰人踩进岩浆直接死亡这是原版的核心机制。读取地图的代码一般长这样try (BufferedReader reader new BufferedReader( new InputStreamReader( getClass().getResourceAsStream(/levels/level1.txt), StandardCharsets.UTF_8))) { String line; int row 0; while ((line reader.readLine()) ! null) { char[] cells line.toCharArray(); for (int col 0; col cells.length; col) { buildBlock(cells[col], col, row); } row; } } catch (IOException e) { e.printStackTrace(); }这里用getResourceAsStream而不是new File是因为资源文件会被编译进classes目录用相对路径容易受“当前工作目录”影响。row和col是格子坐标渲染时会乘以TILE_SIZE换算成像素坐标。所有非空字符都会被构建成Block对象#、W、L这些是实心的空格和出生点则不参与碰撞计算。2.4 主窗口装配顺序先面板后显示入口方法的写法也比较套路化public static void main(String[] args) { SwingUtilities.invokeLater(() - { GameFrame frame new GameFrame(); frame.setTitle(森林冰火人 单人版); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setContentPane(new GamePanel()); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); }); }为什么要用SwingUtilities.invokeLater因为Swing组件必须在EDT线程里创建和操作直接在main线程里new JFrame在高分屏或某些Linux桌面环境下会出现组件绘制不完整的问题。这里有个装配顺序的细节GamePanel在构造函数里就会加载地图、创建角色、启动游戏循环线程所以setContentPane(new GamePanel())要放在setVisible(true)之前否则窗口先显示出来面板还没准备好第一帧就是一片空白。提示如果运行后窗口白屏且控制台没有报错多半是面板对象的初始化放在setVisible之后执行了把装配顺序换一下就好。3. 核心模块拆解游戏循环、重力与碰撞的完整链路3.1 固定步长的游戏循环别用sleep凑帧数游戏循环是整个工程的地基。很多初学者喜欢这样写while (true) { update(); repaint(); sleep(16); }乍看没问题实际上帧率完全取决于sleep精度和系统负载显示器刷新率一变游戏速度就忽快忽慢。这个项目里我一般采用固定时间步长的写法Override public void run() { long last System.nanoTime(); double nanoPerFrame 1_000_000_000.0 / FPS; // FPS 60 double acc 0; while (running) { long now System.nanoTime(); acc (now - last) / nanoPerFrame; last now; // 最多补两帧防止窗口被拖拽时瞬间追帧导致“瞬移” while (acc 1) { update(); acc--; } repaint(); } }System.nanoTime取的是相对时间戳适合做帧间隔计算而System.currentTimeMillis精度不够单帧误差可达几毫秒。累加器acc记录的是“理论上应该执行了多少帧”每次accumulate超过1就执行一次update这样即使某帧卡了一下也不会让游戏逻辑瞬间跳一大步最多追两帧就丢弃旧帧。这里的FPS是每秒逻辑更新次数不直接等于显示器刷新率。逻辑更新稳定在60次/秒重力、速度这些参数就都可以按“每帧”来设计不用换算秒速。这个设计极大降低了调参的心智负担。3.2 角色移动与重力参数跳跃手感是调出来的移动和重力一起讲因为它们都在update()里协同工作。Player类里vx、vy表示水平速度和垂直速度onGround表示是否站在地面上。核心更新逻辑如下private void updatePlayer(Player p) { if (p.state Player.State.DEAD) return; // 水平方向根据按键状态设置速度 p.vx 0; if (keys[p.KEY_LEFT]) p.vx - MOVE_SPEED; if (keys[p.KEY_RIGHT]) p.vx MOVE_SPEED; // 垂直方向跳跃瞬间给初速度落地后归零 if (keys[p.KEY_JUMP] p.onGround) { p.vy JUMP_VELOCITY; p.onGround false; } if (!p.onGround) { p.vy GRAVITY; if (p.vy MAX_FALL) p.vy MAX_FALL; } // 真正的位移交给碰撞模块处理 CollisionUtil.move(p, blocks); }这些参数数值不是随便拍的它们共同决定操作手感。我常用的默认值如下参数默认值作用TILE_SIZE40一个格子的像素边长MOVE_SPEED4每帧水平移动像素数GRAVITY0.55每帧垂直速度增量JUMP_VELOCITY-11起跳初速度负值向上MAX_FALL12最大下落速度防止穿模JUMP_VELOCITY -11配合GRAVITY 0.55跳跃最高点大约在11秒帧后换算成高度约为1111/(20.55)个像素约等于3.5个格子能跳过两格宽的坑但跳不过三格高的墙这个手感比较接近原版。MAX_FALL 12小于TILE_SIZE 40意味着就算角色以最大速度下落单帧位移也不会超过一个格子碰撞检测只要基于格子判断就不会出现穿透。如果想改难度优先调MOVE_SPEED和JUMP_VELOCITY这两个参数对手感影响最大。3.3 碰撞检测先分方向移动再按方向修正碰撞是这种平台跳跃游戏最容易出玄学bug的地方。最简单的思路是移动后检测矩形是否相交先水平后垂直再根据移动方向把角色推出墙体public static void move(Player p, Block[][] blocks) { // 先处理水平移动 p.x p.vx; Rectangle afterX new Rectangle(p.x, p.y, p.width, p.height); for (Block[] row : blocks) { for (Block b : row) { if (b null || !b.solid) continue; if (afterX.intersects(b.rect)) { // 根据vx方向把角色推出墙体 if (p.vx 0) p.x b.rect.x - p.width; else if (p.vx 0) p.x b.rect.x b.rect.width; p.vx 0; } } } // 再处理垂直移动 p.y p.vy; Rectangle afterY new Rectangle(p.x, p.y, p.width, p.height); for (Block[] row : blocks) { for (Block b : row) { if (b null || !b.solid) continue; if (afterY.intersects(b.rect)) { if (p.vy 0) { p.y b.rect.y - p.height; p.onGround true; } else if (p.vy 0) { p.y b.rect.y b.rect.height; } p.vy 0; } } } }理解这段代码的关键在“按方向修正”。水平移动后如果发生了相交vx 0说明角色从左边撞进墙所以把x坐标直接对齐到墙的左边缘减掉角色宽度vx 0则对齐到墙的右边缘。垂直方向同理vy 0是下落落地后y坐标对齐到墙顶同时标记onGround truevy 0是撞头把y对齐到墙底。如果不用这种方式而是直接用Rectangle.intersects判断后随便往一个方向推就会出现角色被墙吸住、或者卡在两个方块中间疯狂抖动的情况。这也是我见过最多的“角色抽搐”问题的根源。3.4 胜负判定与死亡逻辑先判死亡还是先判过关任何时候都要先检查角色是否活着再检查是否过关。常见的一套逻辑是这样的public void checkLevelState(ListPlayer players, LevelManager level) { boolean allExited true; for (Player p : players) { if (p.state ! Player.State.ALIVE) { level.reload(); // 重新加载当前关卡 return; } if (!p.isOnExit()) { allExited false; } } if (allExited) { level.nextLevel(); // 两个角色都站在对应出口上过关 } }isOnExit()的实现是拿角色矩形与当前出口矩形做相交判断冰人只认冰出口E火人只认火出口X。这里要注意两个细节一是死亡判定要放在过关判定之前否则角色踩进岩浆的同一帧如果也碰到了出口会出现“死了还过关”的bug二是死亡后不要直接销毁对象而是把状态置为DEAD再由LevelManager重置整个关卡这样后续动画和重置逻辑都好写。获胜条件之所以要求两个角色都站在出口上是因为单人版的核心体验就是双角色协作只把一个送到出口是没有意义的。4. 从源码到可运行三步跑起来并调出舒服的难度4.1 环境准备JDK和编码项目基于Java SE不需要Maven或Gradle满足JDK 8及以上就能运行。先检查环境java -version javac -version如果两行命令都能正常输出版本号说明环境没问题。这里最容易翻车的是源码注释里的中文乱码Windows下javac默认使用GBK解码源文件而源码可能是UTF-8编码。编译时统一指定编码能避免这个坑javac -encoding UTF-8 -d out src/game/*.java-encoding UTF-8让编译器按UTF-8读取源文件-d out把编译产物放到out目录后面的src/game/*.java是待编译的源文件列表。4.2 IDEA导入项目推荐路径在IDEA里导入现有源码比新建工程更省事步骤大致如下解压zip后选择File - New - Project from Existing Sources。在弹出的窗口里选中解压出来的根目录。一路Next到SDK选择处选一个JDK 8以上的版本。确认源码根目录是src并把src/game/res标记为Resources Root。打开GameFrame.java直接运行main方法。IDEA如果没把res目录标记为资源根目录运行时会报NullPointerException因为getResourceAsStream找不到level1.txt。Eclipse用户反而简单一些只要把src作为源文件夹双击运行GameFrame即可Eclipse会默认把res下的文件复制到输出目录。4.3 命令行手动编译理解classpath不用IDE时纯命令行也完全能跑。在项目根目录执行cd 源码目录 javac -encoding UTF-8 -d out src/game/*.java java -cp out game.GameFrame-cp out告诉JVM从out目录加载类game.GameFrame是主类全类名。如果源码没有分包、都放在默认包里直接把src/game/*.java换成src/*.java运行类名也相应去掉包前缀。想打成一个可执行jar包用于分发可以用jar --create --file forest.jar --main-class game.GameFrame -C out . java -jar forest.jarJDK 8及以下用jar cfe forest.jar game.GameFrame -C out .写法稍有不同。这样打出的jar依赖系统已安装的Java运行环境双击运行或命令行执行都行。4.4 参数调优把难度调低再做调试第一次跑通后建议先做一轮难度调试。把GamePanel.java里的静态常量调整一下想达到的效果改哪个参数推荐值跳得更高JUMP_VELOCITY-13到-14下落更快更爽快GRAVITY0.7到0.8人物移动更慢MOVE_SPEED2到3画面更紧凑TILE_SIZE32TILE_SIZE改小后同尺寸窗口能显示更多格子地图实际会显得更“挤”。调试碰撞时还可以临时给角色和方块画上边框矩形g.setColor(Color.RED); g.drawRect(p.x, p.y, p.width, p.height); for (Block[] row : blocks) { for (Block b : row) { if (b ! null b.solid) { g.drawRect(b.rect.x, b.rect.y, b.rect.width, b.rect.height); } } }这样碰撞修正是否生效、角色有没有嵌进墙一眼就能看出来。调试完再删掉这段绘制代码即可。5. 常见问题与避坑闪烁、穿墙、按键失灵的现场记录5.1 画面疯狂闪烁或出现残影现象游戏运行后画面闪得像雪花屏拖动窗口时还能看到之前帧的残留。原因没有利用Swing的双缓冲机制。Swing本身默认开启了双缓冲但如果你在JFrame的paint方法里直接画图或者把this.setIgnoreRepaint(true)写上去了就会绕过双缓冲导致所有绘制内容直接写到屏幕上闪烁自然就出现了。解决所有自定义绘制都放在JPanel的paintComponent(Graphics g)里方法第一行老老实实调用super.paintComponent(g)清屏然后不要重写paint方法更不要加setIgnoreRepaint(true)。这样绘制会先被Swing缓冲再一次性显示闪烁问题基本消失。5.2 角色落地时陷进地面半格现象角色从高处跳下脚没完全站在墙顶而是陷进方块里走路时上下抖动。原因下落处理是“先移动后修正”单帧下落距离大于角色脚部到墙顶的距离时修正逻辑把y坐标对齐到了墙顶减角色高度但此时角色和墙仍有相交区域下一帧又被判定为没落地继续下落产生抖动循环。解决落地修正时把y坐标对齐到墙顶减去角色高度并把onGround置true同时在vy 0分支里让速度归零。另外检查MAX_FALL是否小于TILE_SIZE如果改大导致单帧位移超过一个格子就需要跳过中间空心网格那才是真正的穿墙。5.3 按一次跳跃键却连跳两次现象轻点一下跳跃键角色有时候会跳两次高度明显异常。原因KeyListener的keyPressed在系统检测到按键自动重复时会连续触发一个跳跃事件被当成了好几帧的跳跃输入或者跳跃键在keyPressed里直接调用updatePlayer但下一帧按键状态标志位还没复位。解决把键盘状态抽象成布尔数组keyPressed只负责置truekeyReleased只负责置falseupdate循环统一读取布尔数组。跳跃逻辑额外加一个条件只有onGround为true且当前这一帧之前没有按过跳跃键时才起跳落地后onGround重新变为true才允许下一次跳跃。5.4 运行时报资源加载空指针现象运行后控制台直接抛NullPointerException堆栈指向BufferedReader或ImageIO.read那几行。原因最常见的是res目录没有被拷到输出目录或者用了new File(src/game/res/...)这种相对路径写法而命令行工作目录不在项目根目录。解决统一用getClass().getResourceAsStream(/levels/level1.txt)读取资源并确保IDE里把res目录标记为Resources Root。IDEA的Project Structure里选中res目录点一下“Resources”图标即可。这样做的好处是资源路径从classpath根开始计算不管从哪个目录启动都不会丢。6. 二次开发双人同屏、关卡编辑器和渲染升级6.1 双人同屏两套键位并存现在Player类里本来就区分了冰火两个角色键盘监听改成同时维护两套按键状态即可if (code KeyEvent.VK_W) { keys[PLAYER_FIRE][ACTION_UP] true; } else if (code KeyEvent.VK_UP) { keys[PLAYER_ICE][ACTION_UP] true; }change完之后把原来的单人按键清空逻辑保留再在GamePanel里用两个玩家对象分别绑定各自的按键数组。想让朋友也玩只需要让第二个玩家拿一个手柄或另接键盘把方向键和WASD分开绑定。这里有个坑Swing同一个窗口只支持一个键盘焦点双人同屏要保证焦点始终在面板上不能让玩家切到别的窗口。6.2 关卡编辑器把txt变成可视化生成器地图本质上是二维字符数组所以做一个可视化编辑器并不复杂。编辑面板里用鼠标点击格子来切换方块类型把当前二维数组按行拼接成字符串写回文件即可。核心保存函数可以参照这样public void saveLevel(String path, char[][] grid) { try (BufferedWriter writer new BufferedWriter( new OutputStreamWriter( new FileOutputStream(path), StandardCharsets.UTF_8))) { for (char[] row : grid) { writer.write(row); writer.newLine(); } } catch (IOException e) { e.printStackTrace(); } }在此基础上还可以加一个校验步骤统计地图里B、F、E、X是否各只有一个避免生成出无法过关的关卡。6.3 从Swing走向JavaFX如果觉得Swing的绘制方式不够爽快可以把渲染层换成JavaFX的Canvas游戏循环和碰撞逻辑原封不动搬过去只需要把paintComponent里的绘制代码换成Canvas的GraphicsContext。JavaFX动画有自己的AnimationTimer与固定步长循环搭配效果更好这也是之后想做大一点的项目时最平滑的升级路径。做完这些小改造这个工程就已经从“跑通Demo”变成“能拿出去给别人玩”的状态了。我这些年调小游戏翻车最多的就是碰撞修正方向那一块从那以后我每次加新关卡或新角色都强制走一遍“先分轴移动再按方向修正”的流程确保单帧位移小于最小的墙块尺寸。希望这次拆解对你手上有帮助祝跑通顺利。本文还有配套的精品资源点击获取