简介:这是一份基于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; }GRAVITY=0.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这个高度计算。如果面板高度是 640,gapY=300,gapHeight=160,那么下管道的高就是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.encoding=utf-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 的 wav,MP3 在原生 Java 里播放要用第三方库,别引进来增加复杂度。音频加载也要放在构造阶段,避免每次跳跃都读磁盘。
第三,写一个“非图形界面”的验证逻辑。把碰撞检测、计分、状态转换从 UI 代码里抽出来,放到一个纯 Java 类里,然后写几个main方法断言的测试:比如构造一个即将碰撞的场景,调用update(),断言状态变成GAMEOVER。这一步看起来多余,但面试时你说“我写了单元测试验证核心逻辑”,比说“我调好了参数”有说服力得多。
我自己的经验是,飞翔的小鸟 Java 实现最大的坑不是技术,是“什么都想加”。我给课程设计加过道具系统、皮肤系统,最后代码写了一千多行,维护起来痛不欲生,交上去反而因为老师只看核心逻辑而扣分。后来学乖了,先保证一个 300 行的干净版本,再在文档里写清楚扩展方案,比堆功能强。做完这个项目后,你去翻开那些必背的 Java 面试题,再看 setTimeout、双缓冲、内部类、枚举这些概念,会忽然觉得它们不再是死记硬背的黑匣子。
希望这个拆解能帮你的飞翔的小鸟早日飞起来。
本文还有配套的精品资源,点击获取