多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java Swing实现飞翔的小鸟:游戏循环、碰撞检测与状态机全解析

Java Swing实现飞翔的小鸟:游戏循环、碰撞检测与状态机全解析 简介这是一份基于Java与GUI技术开发的『飞翔的小鸟』小游戏完整项目包专门面向初学Java、数据结构与算法的同学也适合作为课程设计大作业或课外练手项目。项目代码经过测试可直接运行游戏交互流畅不仅覆盖了窗口绘制、键盘事件监听、小鸟状态切换、管道生成与碰撞检测等核心逻辑还能帮助学习者将教科书里的Java语法、面向对象思想和数据结构知识还原到真实场景中例如用集合类管理动态生成的管道、用数组保存分数数据等训练动手调试能力而不是停留在书本示例上。压缩包内共61个文件总大小仅827KB整体目录按源码、资源与配置分层7个Java源文件对应程序核心代码8个class文件是编译产物便于核对37张PNG素材涵盖背景、小鸟、管道、分数面板等游戏元素另有XML配置文件、GIF效果演示和README说明文档方便快速理解项目整体结构并进行二次修改。目前已有260人浏览学习虽然体积不大但完整覆盖了从素材设计、编码实现到测试运行的整套流程对刚接触Java游戏开发或需要参考课程设计思路的同学很有借鉴价值。1. 飞翔的小鸟游戏用 Java 实现课程设计和面试都能拿得出手的 2D 小项目很多人第一次接触“飞翔的小鸟”Java 实现是在课程设计选题列表里看到这个名字心想“这不就是个 flappy bird 吗能有多难”。真打开压缩包开始读代码才发现麻雀虽小五脏俱全游戏循环、碰撞检测、状态机、贴图动画、音效播放一个都不少。这个项目最值得做的点在于它用一个下午就能跑通却能把 Java 基础、Swing 界面编程、多线程、甚至是简单设计模式全串起来。如果你正准备应付 Java 课程设计或者想在面试前找个能讲清楚架构的小项目这个方向比写一百道冒泡排序 Java 题都管用。它解决的是“从语法到能玩”的最后一公里问题适合刚学完 Java 基础、想知道一个完整小游戏怎么组装起来的人。2. 飞翔的小鸟拆开看游戏循环、碰撞检测与状态机三件事先别急着写 JFrame动手之前必须把游戏骨架想清楚。很多人的翻车都是从直接塞代码开始的写到一半发现跳跃手感不对、管道间距改不动、想加个暂停功能要动几十处。我给你一个常见做法把整个游戏拆成三个模块——游戏循环、碰撞检测、游戏状态机。这个拆法不是论文里的玄学是你在改需求时最省力的结构。2.1 游戏循环Swing Timer 还是自建线程游戏循环是整只小鸟的心脏。每 16 毫秒刷新一次画面就是 60 FPS每 30 毫秒刷新就是三十多帧。常见实现有两种第一种是javax.swing.Timer它跑在 EDT事件分发线程上不会和界面绘制打架适合简单小游戏。第二种是new Thread配合while(true)自己写循环好处是控制更自由坏处是要自己处理线程安全否则界面控件会在意想不到的地方崩给你看。我一般建议新手用 Swing Timer因为飞翔的小鸟逻辑并不复杂Timer 足够还能避开“线程里改组件状态导致偶发异常”这种血泪坑。核心代码框架像这样Timer timer new Timer(16, e - { update(); // 更新逻辑小鸟下落、管道移动、分数检测 repaint(); // 请求重绘 }); timer.start();这段代码的关键是16这个参数单位毫秒一帧的时间。update()里写游戏逻辑repaint()只负责把当前画面画出来。逻辑和绘制分离后面调参才顺手。如果你想做得更专业可以把update里的耗时操作抽出去但这个小游戏里没有耗时的东西别过度设计。使用 Swing Timer 还有一个隐性的好处它每次触发都在 EDT 上你在update()里直接操作ArrayList、JPanel的字段不会出现并发修改异常。这比自建线程省心太多。2.2 碰撞检测别用像素级用矩形包围盒飞翔的小鸟最容易被问到的面试题之一就是“怎么检测小鸟撞到管道”。用像素遍历检测那是最笨的办法每帧读图片每个像素的透明度性能差到爆。实际项目里几乎清一色用矩形碰撞把小鸟和管道都简化成一个矩形判断两个矩形是否相交。公式只有四行判断小鸟右边界大于管道左边界、小鸟左边界小于管道右边界、小鸟下边界大于管道上边界、小鸟上边界小于管道下边界。四个条件同时满足就是撞了。private boolean hit(Rectangle bird, Rectangle pipe) { return bird.x pipe.x pipe.width bird.x bird.width pipe.x bird.y pipe.y pipe.height bird.y bird.height pipe.y; }注意这里用的是Rectangle对象的坐标和宽高不是像素。小鸟的图片有羽毛、有留白如果直接用整个图片矩形你会觉得小鸟还没碰到管子就死了这就是“碰撞面积过大”问题。常见做法是把碰撞矩形缩小一圈专门留一个hitBox字段往里缩几个像素手感立刻不一样。缩多少是个参数问题我一般缩 20% 到 30%具体得看你的贴图留白有多大。2.3 游戏状态机等待、飞行、结束没有状态机的飞翔的小鸟代码会写得像一团乱麻。你要处理“按空格起飞”“撞到结束”“结束之后按任意键重开”三种场景如果不用状态区分会出现撞了管道还能继续按空格飞、游戏还没开始小鸟就往下掉这些怪相。设计一个简单的枚举状态enum GameState { READY, // 等待开始 RUNNING, // 飞行中 GAMEOVER // 结束 }逻辑里所有操作都先看当前状态。比如空格键按下时if (state GameState.READY) { state GameState.RUNNING; bird.jump(); } else if (state GameState.RUNNING) { bird.jump(); } else if (state GameState.GAMEOVER) { resetGame(); }这样写的价值在于你永远知道程序此刻在哪一个阶段而不是用一坨布尔变量互相制约。很多 Java 面试题喜欢问状态模式这个项目就是现成的例子。你甚至可以在项目说明里写“用枚举实现简单状态机”比空谈设计模式有说服力得多。3. 从零搭一个能飞的窗口JFrame、JPanel 与主循环的正确姿势拆完设计开始写代码。飞翔的小鸟 Java 实现最常出现的配套环境是 IDEA但不管你在哪个编辑器里从新建项目到跑通步骤是一样的。3.1 窗口和画布为什么继承 JPanel 而不是 JFrame我见过大量课程设计代码是继承JFrame然后往里面塞东西这不叫错但不够好。更好的做法是主类继承JPanel重写paintComponent()做所有绘制然后把这个面板塞进一个JFrame窗口里。原因是JFrame自带标题栏和边框在它上面直接画东西容易受布局影响而JPanel是一块干净画布。public class BirdGame extends JPanel implements ActionListener { private static final int WIDTH 400; private static final int HEIGHT 640; public static void main(String[] args) { JFrame frame new JFrame(飞翔的小鸟 - Java实现); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setSize(WIDTH, HEIGHT); frame.setResizable(false); frame.add(new BirdGame()); frame.setVisible(true); } }frame.setResizable(false)很重要窗口可拉伸会让游戏坐标系乱掉管道间距按像素写死之后一拉窗口就全乱了。setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)保证点红叉时进程能退掉不然会出现“窗口关了IDEA 控制台还一直亮着小红点”的尴尬局面。3.2 小鸟的跳跃与重力加速度是手感的关键飞翔的小鸟核心手感就两个字重力。重力是每帧给小鸟的垂直速度加一个增量让它越掉越快跳跃则是把垂直速度瞬间设成一个负值让它向上冲。这两个参数直接决定游戏难易度。private double y; // 小鸟中心 y 坐标 private double velocity; // 当前垂直速度 private static final double GRAVITY 0.4; private static final double JUMP_SPEED -6.5; private static final int BIRD_X 80; // 小鸟固定水平位置 public void jump() { velocity JUMP_SPEED; } public void update() { velocity GRAVITY; y velocity; }GRAVITY0.4、JUMP_SPEED-6.5是我试过比较顺手的初始值。注意单位是“像素每帧”不是“像素每平方秒”。因为 16ms 一帧0.4 意味着每帧速度增加 0.4 像素/帧下落逐渐加速。跳跃速度设 -6.5 表示按一下立刻向上 6.5 像素/帧的速度之后被重力慢慢抵消。不同显示器分辨率下如果缩放比例异常手感会变这个后面避坑章节展开。BIRD_X固定 80小鸟只在 y 方向动水平方向不动这是 flappy bird 的经典做法。管道从右侧向左移动给玩家造成“小鸟在飞”的错觉实现成本最低。3.3 管道生成间距与上下边界必须可配管道是这个游戏里最大的变量。生成逻辑不复杂但两个参数决定生死管道宽度、上下管道之间的缺口高度。缺口太小玩家想骂人太大游戏太简单。public class Pipe { int x; int gapY; // 缺口中心点的 y 坐标 int width 60; int gapHeight 160; } // 每 1.5 秒生成一根新管道 if (pipeSpawnTimer 0) { int gapY 120 random.nextInt(320); pipes.add(new Pipe(WIDTH, gapY)); pipeSpawnTimer 90; // 90 帧 }gapY的取值范围要留出裕量上管道至少要有 80 像素高下管道同理否则会出现无法通过的管道。pipeSpawnTimer 90表示每 90 帧一根以 60 FPS 算就是 1.5 秒。这个间隔要配合小鸟的飞行速度来调鸟横向不动、管道移动速度一般每帧 3 像素90 帧正好让两根管道间隔为 270 像素玩家有足够反应时间。管道移动和渲染都围绕这个x坐标变化pipe.x - 3; // 每帧左移 3 像素速度 3、宽度 60、缺口 160、间隔 90 帧这组参数组合在一起就是一个能玩但需要练习的难度。想调简单就把缺口调到 180 或 200间隔帧数加大到 110。4. 碰撞检测与计分三个最容易写糊的边界细节游戏能跑、小鸟能飞接下来就是把它变得“可玩”。计分和碰撞是新手翻车重灾区不是算法不会是触发时机和边界处理没想清楚。4.1 矩形碰撞的“自杀式”误差处理直接用Rectangle.intersects()当然可以但是飞翔的小鸟场景里管道是一个整体矩形还是上下分开的两个矩形我见过有人把上下管道合成一个大矩形结果小鸟整个飞进缺口右下角不小心蹭到管道边缘以外的空白区域也判死亡玩家会觉得游戏疯了。正确做法是每根管道拆成两个矩形上管道矩形从顶部到gapY - gapHeight / 2下管道矩形从gapY gapHeight / 2到底部。然后分别和鸟的碰撞矩形比较。Rectangle birdBox bird.getHitBox(); Rectangle upPipeBox new Rectangle(pipe.x, 0, pipe.width, pipe.gapY - pipe.gapHeight / 2); Rectangle downPipeBox new Rectangle(pipe.x, pipe.gapY pipe.gapHeight / 2, pipe.width, HEIGHT - pipe.gapY - pipe.gapHeight / 2); if (birdBox.intersects(upPipeBox) || birdBox.intersects(downPipeBox)) { gameOver(); }这里最容易漏的是HEIGHT - pipe.gapY - pipe.gapHeight / 2这个高度计算。如果面板高度是 640gapY300gapHeight160那么下管道的高就是640 - 300 - 80 260刚刚好。如果你写死一个固定高度 300换个分辨率或者改面板尺寸就全错位了。4.2 计分触发只加一次不是每帧都加计分的逻辑是小鸟的 x 坐标固定 80超过了某根管道的 x 坐标且这根管道还没被计过分就加一分。最蠢的写法是每帧判断x pipe.x就加一秒加六十次分数瞬间爆炸。for (Pipe pipe : pipes) { if (!pipe.passed BIRD_X pipe.x pipe.width) { score; pipe.passed true; } }pipe.passed这个布尔字段是关键。管道第一次被越过时置 true之后不再参与计分。注意判断条件是BIRD_X pipe.x pipe.width要越过管道的右边界才算通过而不是碰到左边界就算。否则玩家会觉得明明管子还没完全过去就加分了很出戏。4.3 游戏结束与重开所有状态必须复位游戏结束之后的“按空格重开”是初学者最容易写崩的地方。常见症状重开后旧管道还在、小鸟还在之前的位置、分数没清零、甚至速度还是死之前的速度。原因很简单——你没有把每一个可变字段恢复回去。private void resetGame() { pipes.clear(); score 0; y START_Y; velocity 0; state GameState.READY; }如果管道数组里有对象没清干净残留的管道会继续移动并参与碰撞游戏重开后画面一片混乱。pipes.clear()是最容易漏的。还有一个小细节如果在GAMEOVER状态下还继续让update()跑重力小鸟会一直往下掉出屏幕重开时才突然复位到顶部视觉上很突兀。常见做法是把游戏结束时的update()停掉或者让小鸟在结束状态下落到地面就停住。5. 飞翔的小鸟 Java 实现常见问题与避坑排查这个项目看起来小但跑起来的问题一点不少。我把自己和身边人踩过的坑按“现象 → 原因 → 解决”整理出来照着排雷能省两小时。5.1 画面闪烁或撕裂现象小鸟和管道快速移动时画面明显在闪眼睛很难受。原因默认的paintComponent在重绘前会把整个组件背景清掉然后重新画造成背景已经擦了但图形还没画上去的间隙刷新率一高就闪。解决用双缓冲Java Swing 里最省事的做法是构造函数里调用setDoubleBuffered(true)。如果你用的是JPanel其实默认就是双缓冲但如果你在普通Component上画画就需要手动开。另一种可能是你的update()和repaint()里干了太多重活比如每帧加载图片或创建新对象导致绘制跟不上主循环。把图片加载移到构造阶段用字段持有BufferedImage别在绘制方法里ImageIO.read。5.2 按空格没反应或延迟明显现象启动游戏后按空格小鸟过了一两百毫秒才跳起来或者完全没反应。原因三个常见来源。一是JPanel没获得焦点按键事件根本没派发到面板上二是你在keyPressed里写的是if (e.getKeyCode() KeyEvent.VK_SPACE)但监听器加在了JFrame上面而焦点在一个JButton上三是逻辑处理里写了线程阻塞或大量循环。解决在面板构造里写setFocusable(true)然后向面板而不是 JFrame 注册键盘监听。要是还不响应试试requestFocus()。代码顺序是setFocusable(true); addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() KeyEvent.VK_SPACE) { handleAction(); } } });注意我这个写法是匿名内部类你要是用 lambda 也能写但KeyAdapter只重写一个方法时 lambda 没问题。响应延迟多半是每帧执行的update()里做了太多计算或打印日志松开后你会发现System.out.println是第一个拖慢帧率的元凶。5.3 运行出现中文乱码现象窗口标题、游戏内提示变成“???”或一堆看不懂的符号。原因源码文件编码和运行时 JVM 的默认编码不一致。Windows 上 IDEA 默认 UTF-8但控制台或系统默认 GBK编译后字符串常量被按错误编码读取。解决在三处统一编码。第一源码文件另存为 UTF-8第二在javac命令或者 IDEA 编译选项里加-encoding UTF-8第三JFrame 标题等字符串在代码里直接写字面量软件问题不大。运行时的-Dfile.encodingutf-8加在 VM options 里也能顶一阵但根治还是统一文件编码。你要是用String拼接中文成逻辑条件尤其谨慎编码一乱字符串内容对不上逻辑直接出错。5.4 窗口关闭IDEA 控制台进程不退现象点掉窗口程序看起来结束了但 IDEA 里红色方块还亮着再点才能停掉。原因JFrame.EXIT_ON_CLOSE没有设置默认是HIDE_ON_CLOSE只是隐藏窗口进程还在后台跑。Swing Timer 如果还在运行EDT 线程也不会退。解决构造窗口时设置frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);。这里有个注意点如果项目里同时有非守护线程在跑EXIT_ON_CLOSE只是调System.exit()依然会强制结束所以没问题。如果你用了自建的Thread做游戏循环建议在关闭时先停掉 Timer 再退出否则可能存盘之类的收尾逻辑没执行。对于这个小游戏直接 EXIT 就行不用过度处理。5.5 游戏刚开始很流畅越玩越卡现象玩了十几分钟帧率逐渐下降管道移动出现明显卡顿。原因管道对象只增不减每帧遍历所有管道做碰撞检测和绘制。玩得越久pipes数组越长计算量线性增长。更隐蔽的是你用了ArrayList.remove()在遍历时删对象没处理ConcurrentModificationException导致异常被吞掉后逻辑越走越慢。解决管道完全移出屏幕左侧后立刻移除遍历使用Iterator或者removeIf。pipes.removeIf(pipe - pipe.x pipe.width 0);removeIf在ArrayList上是安全删除底层用迭代器不会抛并发修改异常。另外加分逻辑里已经置passed true的管道只要还在屏幕内就不要重复处理判断条件加上pipe.x pipe.width 0才做碰撞检测减少无效计算。6. 让它更像成品调难度曲线、加音效、给自己的项目写“测试用例”到了这一步你的飞翔的小鸟已经通关了。但交出去之前我建议再花一小时做三件低成本高回报的事这也是面试官最喜欢追问的细节。第一把难度做成动态的。每得 5 分管道移动速度加 0.2缺口高度减 2 像素但设一个下限比如缺口最小 120。这样玩家不会觉得一成不变你也能在项目说明里写“支持动态难度曲线”。实现上就是在update()里根据score / 5计算当前速度不要每帧都改只在加分时修改即可。注意速度变了管道生成间隔也要跟着微调否则会出现前后间距忽大忽小。第二加一个简单的音效。用javax.sound.sampled.AudioInputStream播放 wav 格式的跳跃声和碰撞声文件不要太大几十 KB 就行。代码不用复杂但要注意音频文件格式必须是 16bit PCM 的 wavMP3 在原生 Java 里播放要用第三方库别引进来增加复杂度。音频加载也要放在构造阶段避免每次跳跃都读磁盘。第三写一个“非图形界面”的验证逻辑。把碰撞检测、计分、状态转换从 UI 代码里抽出来放到一个纯 Java 类里然后写几个main方法断言的测试比如构造一个即将碰撞的场景调用update()断言状态变成GAMEOVER。这一步看起来多余但面试时你说“我写了单元测试验证核心逻辑”比说“我调好了参数”有说服力得多。我自己的经验是飞翔的小鸟 Java 实现最大的坑不是技术是“什么都想加”。我给课程设计加过道具系统、皮肤系统最后代码写了一千多行维护起来痛不欲生交上去反而因为老师只看核心逻辑而扣分。后来学乖了先保证一个 300 行的干净版本再在文档里写清楚扩展方案比堆功能强。做完这个项目后你去翻开那些必背的 Java 面试题再看 setTimeout、双缓冲、内部类、枚举这些概念会忽然觉得它们不再是死记硬背的黑匣子。希望这个拆解能帮你的飞翔的小鸟早日飞起来。本文还有配套的精品资源点击获取
返回列表