简介:这是一份面向Java初学者与游戏开发爱好者的「飞翔的小鸟」完整源码工程,适合想通过经典小游戏理解面向对象设计与游戏循环机制的开发者练手。压缩包共32个文件,约139KB,包含5个java源文件、6个class编译文件,以及18个png图片素材,另有classpath、project、prefs等Eclipse工程配置,导入后即可直接运行调试。资源围绕鸟的运动模型、柱子与星星的生成移动、碰撞检测、动画绘制及计分系统展开,其中分数与难度动态联动:低于15分正常速度过柱加1分,15至30分提速且加2分,超过30分再次提速并加3分,吃到星星额外奖励2分,可帮助读者理解速度调节与奖励机制的代码落地方式。目前已有2952人学习下载,适合作为课程设计、Java GUI练习或游戏编程入门的参考案例。
1. 从一份 Java 版飞翔的小鸟源码说起:它到底能跑出什么
如果你手头正好有一份 Java 版飞翔的小鸟完整代码,第一反应大概率是「这玩意儿能直接跑吗」。我拿到这份源码时也是这个心态——一个用 Java 写的横版飞行躲避游戏,玩家控制一只小鸟在管道间隙中穿行,撞到管道或落地就结束,分数随通过管道数递增。听起来简单,但它麻雀虽小五脏俱全:游戏主循环、碰撞检测、图像渲染、音效播放、分数存档,一个都没少。适合谁?刚学完 Java 基础语法、想找一个能跑起来又有成就感的练手项目的人;或者想拿一个现成的 2D 游戏框架改玩法、换素材的开发者。它不依赖复杂引擎,纯 Java SE 加 Swing/AWT 就能跑,代码量适中,逻辑清晰,拿来当第一个「完整项目」再合适不过。
2. 项目结构与技术选型:为什么用 Swing 而不是 JavaFX
2.1 目录结构长什么样
拿到源码包,解压后通常能看到这样的结构(不同版本可能略有差异,但核心不会变):
FlyingBird/ ├── src/ │ ├── com/ │ │ └── game/ │ │ ├── main/ │ │ │ └── GameMain.java // 程序入口,main 方法 │ │ ├── core/ │ │ │ ├── GamePanel.java // 游戏主面板,继承 JPanel │ │ │ ├── GameLoop.java // 游戏循环线程 │ │ │ └── GameState.java // 游戏状态枚举(准备/运行/结束) │ │ ├── entity/ │ │ │ ├── Bird.java // 小鸟实体 │ │ │ ├── Pipe.java // 管道实体 │ │ │ └── Ground.java // 地面滚动 │ │ ├── util/ │ │ │ ├── ImageLoader.java // 图片资源加载 │ │ │ ├── SoundPlayer.java // 音效播放 │ │ │ └── ScoreManager.java // 分数读写 │ │ └── config/ │ │ └── GameConfig.java // 常量配置(宽高、重力、速度) │ └── resources/ │ ├── images/ // 小鸟、管道、背景、地面图 │ ├── sounds/ // 跳跃、得分、撞击音效 │ └── score.dat // 最高分存档(运行时生成) ├── lib/ // 第三方 jar(如果有) └── README.md这个结构是典型的「按职责分包」:core管流程,entity管对象,util管工具,config管常量。新手容易把所有类堆在一个包下,但这份源码做了分层,后续改起来不会牵一发动全身。
2.2 技术选型:Swing + AWT + 多线程
为什么是 Swing 而不是 JavaFX?原因很实际:Swing 是 JDK 自带的,不需要额外引入模块或依赖,javac和java命令直接就能编译运行。JavaFX 从 JDK 11 开始被移出标准库,要单独配 SDK 和模块路径,对新手不友好。这个项目用到的核心类就几个:
JFrame:顶层窗口JPanel:游戏画布,重写paintComponent做渲染Graphics2D:画图、抗锯齿、旋转Timer或Thread:驱动游戏循环ImageIO:加载图片资源Clip:播放音效
常见做法是用一个独立线程跑游戏循环,每帧repaint()触发重绘。也有版本用javax.swing.Timer,每 16 毫秒触发一次actionPerformed,本质一样,但Timer的回调在 EDT(事件调度线程)上执行,渲染更安全,缺点是逻辑和渲染耦合在同一线程,复杂游戏会卡。这个项目体量小,两种都行。
2.3 游戏主循环的实现方式
游戏循环是这类项目的灵魂。我一般会这样写:
// GameLoop.java public class GameLoop implements Runnable { private final GamePanel panel; private volatile boolean running = true; private static final int FPS = 60; private static final long FRAME_TIME = 1000 / FPS; // 每帧毫秒数 public GameLoop(GamePanel panel) { this.panel = panel; } @Override public void run() { long lastTime = System.currentTimeMillis(); while (running) { long now = System.currentTimeMillis(); long elapsed = now - lastTime; if (elapsed >= FRAME_TIME) { panel.update(); // 更新逻辑:小鸟位置、管道移动、碰撞检测 panel.repaint(); // 触发重绘 lastTime = now; } else { try { Thread.sleep(FRAME_TIME - elapsed); // 让出 CPU,控制帧率 } catch (InterruptedException e) { Thread.currentThread().interrupt(); running = false; } } } } public void stop() { running = false; } }这段代码的关键点:FRAME_TIME决定帧率,60 FPS 对应约 16 毫秒。update()和repaint()分离,逻辑更新和画面渲染各司其职。volatile保证running变量在多线程间的可见性,避免停止指令被缓存导致线程关不掉。Thread.sleep在帧间隔未到时让出 CPU,不然循环会空转吃满一个核。
参数怎么改?想跑更快就把FPS调大,但注意update()里的位移量是按帧算的,帧率变了速度也会变。正确做法是把速度改成「每秒多少像素」,乘以elapsed秒数,这样帧率无关。很多新手改帧率后发现小鸟飞太快,就是踩了这个坑。
3. 核心模块拆解:小鸟、管道与碰撞检测
3.1 小鸟实体:重力、跳跃与旋转
小鸟的运动逻辑是整个游戏的手感来源。核心就两个变量:垂直速度velocity和重力加速度gravity。
// Bird.java public class Bird { private double x, y; // 坐标 private double velocity; // 垂直速度(像素/帧) private static final double GRAVITY = 0.5; // 重力加速度 private static final double JUMP_FORCE = -8.0; // 跳跃初速度(向上为负) private double rotation = 0; // 旋转角度,用于渲染 public void update() { velocity += GRAVITY; // 每帧速度增加 y += velocity; // 位置随速度变化 // 根据速度调整旋转角度,上升时抬头,下降时低头 rotation = Math.toDegrees(Math.atan2(velocity, 10)); if (rotation < -30) rotation = -30; if (rotation > 90) rotation = 90; } public void jump() { velocity = JUMP_FORCE; // 点击时重置速度 } public Rectangle getBounds() { return new Rectangle((int) x, (int) y, 34, 24); // 碰撞箱 } }逻辑说明:GRAVITY每帧给velocity累加,y再累加velocity,形成加速下落。jump()把velocity直接设为负值,小鸟瞬间向上,然后重力慢慢把它拉回来。旋转角度用atan2根据速度方向算,上升时角度为负(抬头),下降时为正(低头),并做了上下限裁剪,避免翻转过头。
参数调整:GRAVITY越大下落越快,JUMP_FORCE绝对值越大跳得越高。这两个值直接决定难度,调的时候要一起动,不然手感会怪。碰撞箱Rectangle的宽高通常比图片实际尺寸小一圈,因为小鸟图片边缘有透明像素,用整张图做碰撞会显得「明明没碰到却死了」,这是血泪经验。
3.2 管道生成与移动
管道成对出现,上下各一根,中间留空隙。生成逻辑是定时在屏幕右侧外新增一对,然后每帧向左移动,移出屏幕左侧后回收。
// Pipe.java public class Pipe { private int x; private int gapY; // 空隙上边缘的 y 坐标 private static final int GAP_HEIGHT = 120; // 空隙高度 private static final int PIPE_WIDTH = 52; private static final int SPEED = 3; // 每帧左移像素 public void update() { x -= SPEED; } public boolean isOffScreen() { return x + PIPE_WIDTH < 0; } public Rectangle getTopBounds() { return new Rectangle(x, 0, PIPE_WIDTH, gapY); } public Rectangle getBottomBounds() { return new Rectangle(x, gapY + GAP_HEIGHT, PIPE_WIDTH, GameConfig.HEIGHT - gapY - GAP_HEIGHT); } }gapY是随机生成的,范围要保证空隙不贴顶也不贴地。常见做法是gapY = 50 + random.nextInt(300),具体范围看屏幕高度。SPEED决定管道移动快慢,和帧率绑定的话同样有帧率问题,严谨点应该用时间差。
管道的碰撞箱分上下两个Rectangle,分别对应上管和下管。检测时只要小鸟的碰撞箱和任意一个相交,就判定撞击。
3.3 碰撞检测与分数判定
碰撞检测用Rectangle.intersects,简单直接。分数判定则是看小鸟的 x 坐标是否越过了管道的右边缘,且这根管道还没被计过分。
// GamePanel.java 中的 update 片段 for (Pipe pipe : pipes) { pipe.update(); // 碰撞检测 if (bird.getBounds().intersects(pipe.getTopBounds()) || bird.getBounds().intersects(pipe.getBottomBounds())) { gameState = GameState.GAME_OVER; SoundPlayer.play("hit"); } // 得分判定:小鸟中心越过管道右边缘,且该管道未计分 if (!pipe.isScored() && bird.getX() > pipe.getX() + Pipe.PIPE_WIDTH) { pipe.setScored(true); score++; SoundPlayer.play("score"); } } // 边界检测:落地或飞出顶部 if (bird.getY() + 24 > GameConfig.HEIGHT - Ground.HEIGHT || bird.getY() < 0) { gameState = GameState.GAME_OVER; }这里有个细节:得分判定用的是小鸟的 x 坐标和管道右边缘比较,而不是碰撞箱。因为碰撞箱可能因为旋转或缩放有偏差,用逻辑坐标更稳。isScored标记防止同一根管道重复计分,这是新手常漏的——不加标记的话,小鸟停在管道边缘会疯狂加分。
4. 避坑与常见问题:跑不起来先看这几条
4.1 图片加载报 NullPointerException
现象:运行后窗口一片黑,控制台抛NullPointerException,定位到ImageLoader或paintComponent里的drawImage。
原因:图片路径写的是相对路径,但工作目录和源码目录不一致。比如代码里写new File("resources/images/bird.png"),而 IDE 运行时的工作目录是项目根目录,不是src目录。
解决:用类加载器从 classpath 读,getClass().getResourceAsStream("/images/bird.png"),注意开头的斜杠表示从 classpath 根开始。同时确认resources目录被标记为资源根(IDEA 里右键 Mark as Resources Root),编译后图片会被复制到target/classes下。
4.2 窗口闪烁严重
现象:小鸟和管道移动时画面抖动、闪烁。
原因:直接在paintComponent里画,没有双缓冲。Swing 的JPanel默认是开启双缓冲的,但如果你重写了paint而不是paintComponent,或者手动调用了getGraphics(),双缓冲就失效了。
解决:只重写paintComponent(Graphics g),在里面用super.paintComponent(g)清屏,然后所有绘制都通过这个g完成。不要自己getGraphics(),也不要在paint里画。如果还闪,检查是不是在循环里调了revalidate()或setVisible()。
4.3 音效播放一次后不再响
现象:第一次跳跃有声音,后面就没了。
原因:Clip播放完后需要调用setFramePosition(0)回到开头,否则下次start()没反应。另外Clip对象如果被垃圾回收,声音也会断。
解决:把Clip缓存到Map里,播放前先stop()再setFramePosition(0)再start()。或者用SourceDataLine自己写播放逻辑,但那样代码量大,小游戏用Clip够了。
4.4 最高分存档丢失
现象:每次重启游戏,最高分都归零。
原因:存档文件写在了相对路径,或者写入流没关闭,数据还在缓冲区。
解决:用绝对路径或用户目录,比如System.getProperty("user.home") + "/flyingbird_score.dat"。写入时用try-with-resources确保流关闭:
try (DataOutputStream dos = new DataOutputStream( new FileOutputStream(scoreFile))) { dos.writeInt(highScore); } catch (IOException e) { e.printStackTrace(); }4.5 游戏结束后无法重新开始
现象:撞管道后画面卡住,点击没反应。
原因:游戏循环线程还在跑,但gameState变成GAME_OVER后update()里没有处理重置逻辑,或者重置时没有把管道列表、小鸟位置、速度归位。
解决:在GAME_OVER状态下监听点击事件,调用reset()方法:清空pipes列表、小鸟坐标回到初始位置、velocity归零、score归零、gameState改回READY或RUNNING。注意reset()要在游戏循环线程外调用,或者加锁,避免和update()并发修改集合。
5. 二次开发与改造思路:从换皮到换玩法
5.1 换素材:最快看到效果的改造
想把这个项目变成自己的,最直接的就是换图片和音效。resources/images下的图片按原名替换即可,注意尺寸比例。小鸟图建议 34x24 左右,管道图宽度 52,地面图高度固定。替换后如果碰撞感觉不对,调Bird.getBounds()里的宽高和Pipe的PIPE_WIDTH。音效替换成.wav格式,Clip对 mp3 支持不好,常见做法是统一转 wav。
5.2 改玩法:加入道具和难度曲线
想让它不那么单调,可以加道具系统。比如随机生成护盾,吃到后 5 秒内免疫一次碰撞。实现思路:新增Item实体,在管道生成时按概率附带,update()里检测小鸟和道具的碰撞,命中后给Bird加一个invincible标记和倒计时。碰撞检测里判断invincible为真时跳过撞击判定。
难度曲线方面,现在的SPEED是常量,可以改成随分数递增:speed = BASE_SPEED + score / 10,但要有上限,不然最后快到没法玩。GAP_HEIGHT也可以随分数缩小,从 120 慢慢降到 90,增加压迫感。
5.3 改架构:从 Swing 迁移到 LibGDX
如果想让游戏跑在更多平台,或者想要更好的性能和渲染效果,可以考虑迁移到 LibGDX。核心改动是把GamePanel换成ApplicationAdapter,paintComponent换成render(),Thread循环换成 LibGDX 内置的渲染循环。实体类基本不用动,碰撞检测的Rectangle换成 LibGDX 的Rectangle即可。迁移后可以打包成桌面、Android、HTML5 多端运行。不过这是个大工程,建议先把 Swing 版玩透再动。
5.4 验证改造是否成功
每次改完,按这个清单过一遍:窗口能正常打开、小鸟能跳、管道能移动、碰撞能触发、分数能增加、最高分能保存、游戏结束后能重开。任何一项挂了,先看控制台报错,再检查对应模块。我一般会在update()开头加一行System.out.println("State: " + gameState + " Score: " + score),跑一遍看状态流转对不对,确认没问题再删掉。
从那以后我每次拿到这类小游戏源码,都强制先跑通默认版本,再动一行代码,不然改出问题都不知道是原来就有的还是自己引入的。希望这份拆解能帮你少走点弯路,顺利把这只小鸟飞起来。
本文还有配套的精品资源,点击获取