
简介这份Java版“飞翔的小鸟”完整源码面向有一定编程基础、想以经典小游戏为切入点学习游戏开发的学习者有助于理解完整项目结构并动手改造。资源共32个文件以png图片素材、class编译文件和java源文件为主另有工程配置文件整体约139KB麻雀虽小但功能完整。源码包含小鸟类、计分系统、柱子与星星生成器等模块尤其实现了分数驱动的动态难度机制15分前后速度与得分规则逐级变化星星奖励还会额外加分便于直观体会游戏循环、碰撞检测和界面渲染的配合方式。已有2952人学习下载适合作为Java Swing或LibGDX入门练习的参考也可用于课程设计或兴趣开发。1. 飞翔的小鸟 Java 版从一份“完整代码”到你能跑起来的项目飞翔的小鸟代码完整版java版在绝大多数情况下是 Java 学习者的第三个练手项目——前两个通常是记事本和计算器。它看起来是个玩具但真做起来要处理窗口、循环、碰撞和状态切换正好把 java 基础里的面向对象、事件监听和线程模型串成一条线。这套东西拿来交课程设计、放进简历或者作为面试前复习“面向对象编程 java”的活例子比背八股文可靠得多。不过网上流传的“完整版”质量参差不齐有的缺音效、有的碰撞判定诡异甚至双击桌面图标根本跑不起来。下面我按自己从零实现一个能玩版本的经验把选型、代码、参数和踩坑一次性讲透读者可以照着重现也可以直接拿去改造成自己的项目。2. 为什么用 Swing/AWT 做飞翔的小鸟选型理由与类设计2.1 从 AWT 到 Swing轻量级游戏界面到底该怎么选先解决一个最让新手犹豫的问题用 Swing、AWT 还是 JavaFX我的结论是除非你被要求必须用 JavaFX否则做飞翔的小鸟一律用 Swing AWT 的轻量级组合。Swing 是 JDK 自带的标准库只需要装好 JDK不需要额外引入第三方依赖JavaFX 虽然界面更现代但官方已经把它从标准 JDK 中拆出去要单独配置 SDK 和插件对课程设计和快速上手不够友好。AWT 组件是重量级组件刷新界面时要频繁调用底层系统资源做帧动画容易闪烁Swing 的 JPanel 和 JFrame 是轻量级组件绘制过程完全由 Java 控制默认还能开启双缓冲。实际写代码时我们仍然用 AWT 的 Graphics2D 做绘制但把它放在 Swing 的 paintComponent 里速度和稳定性都会好很多。换句话说Swing 提供容器和线程模型AWT 提供画布和颜色、矩形这些基础工具类两者配合是这类小游戏的默认方案。还有一点值得提很多网上教程喜欢用 Canvas Thread.sleep 做游戏循环然后在一个 whiletrue里重绘。这种写法在 Swing 里很容易踩雷因为 Swing 的界面刷新必须在事件分发线程EDT上执行你用独立线程直接画会引发界面卡死。所以真正负责任的做法是用 Swing Timer 驱动游戏循环或者自己用线程加 SwingUtilities.invokeLater 兜底。第二章后面会展开讲。2.2 拆成 Bird、Pipe、GamePanel 三个类面向对象在游戏里的真实用法飞翔的小鸟虽然小但代码组织方式决定后期好不好改。我最常用的拆法是四到五个类入口类负责启动窗口GamePanel 负责所有绘制和游戏状态Bird 负责小鸟的位置、垂直速度和跳跃动作Pipe 负责每根管道的坐标、宽度和移动如果计分逻辑复杂还可以单独拆一个 Score。这样拆的好处是每个类的职责单一和“面向对象编程 java”课程里讲高内聚低耦合时对上了。这里没有用继承去强行做“小鸟是一个什么物体”的模型而是用组合。Bird 类里持有 x、y、vy、radius 四个字段提供 jump() 和 update() 两个行为Pipe 类持有 x、gapY、width、height提供 move()。GamePanel 持有 Bird 和 List 在定时器的每次回调里依次调用它们的更新方法。这样做能让测试更简单我后面会在第六部分说怎么验证。类之间通过什么方式通信常见做法是给 GamePanel 实现 ActionListener在 actionPerformed 里统一处理按键和时钟回调。因为 Swing Timer 和 JPanel 都在 EDT 上状态变更不需要加锁。如果想把代码写得再优雅一点可以用 Controller 接口把输入和逻辑解耦但飞翔的小鸟这个规模不需要过度设计。我的经验是类和文件先拆干净后续加暂停、重新开始、最高分都只是在 GamePanel 里加状态判断不会动到 Bird 和 Pipe。2.3 游戏循环与定时器别用 Thread.sleep用 Swing Timer小游戏的核心是游戏循环每一帧更新一次位置然后重绘屏幕。在 Swing 里最直接的循环就是 whiletrue里写 Thread.sleep(16)然后调 repaint()。但我强烈建议别这么干。原因有两个第一sleep 的精度不可控垃圾回收或系统调度会让时间漂移游戏突然变慢或变快第二如果你在非 EDT 线程里直接调用 repaintSwing 内部不保证线程安全轻则掉帧重则抛异常。安全好用的替代方案是 javax.swing.Timer。创建 Timer(16, this)在 actionPerformed 里写“更新 重绘”这就明确定义了每帧约 16 毫秒也就是 60 FPS。Timer 的事件回调在 EDT 上执行不会和 Swing 的绘制机制打架。代码看起来像这样Timer timer new Timer(16, new ActionListener() { Override public void actionPerformed(ActionEvent e) { update(); repaint(); } }); timer.start();这段代码的逻辑很简单每 16 毫秒触发一次 actionPerformed先更新游戏状态再请求重绘。有一个参数新手容易忽略Timer 默认是无限循环但如果你在某次触发里停下来要主动调用 timer.stop()。另外如果你想让游戏在某些情况下暂停可以在 update() 里判断 state而不是在 Timer 外面再加开关。这样能避免多线程里常见的“Run 和 Stop 同时操作”的问题。3. 把最小可跑的飞翔的小鸟搭出来核心代码与参数调整3.1 创建项目与主窗口初始化面板和事件监听一开始项目用最普通的目录就行一个src目录四个 Java 文件。主入口是一个继承 JFrame 的 Main 类也可以把 JFrame 放在 GamePanel 里但分开更清爽。下面是我常用的初始化写法public class Main { public static void main(String[] args) { JFrame frame new JFrame(飞翔的小鸟 - Java版); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setSize(400, 700); frame.setResizable(false); frame.setLocationRelativeTo(null); GamePanel gamePanel new GamePanel(); frame.add(gamePanel); frame.pack(); frame.setVisible(true); gamePanel.startGame(); } }逻辑说明JFrame 是窗口容器setDefaultCloseOperation 保证关窗口时进程退出setSize(400, 700) 定义了窗口逻辑尺寸不缩放画面因为小游戏最容易翻车的地方就是窗口拉伸后坐标对不上。设置 setResizable(false) 是为了防止用户拖动窗口改变游戏分辨率。最后把 GamePanel 加进 ContentPane调用 startGame 启动 Timer。这里的参数要看屏幕和像素密度调整。400x700 是竖屏手机比例适合小鸟游戏桌面屏幕比较宽时可以改成 420x720但不能太宽否则管道间隔比例会变得很奇怪。如果你用的是高分屏还可以给 JFrame 设置缩放因子但绝对不要在代码里写死绝对字体大小否则换电脑就变形。setLocationRelativeTo(null) 是让窗口居中这块属于观感细节但课程设计老师很看重这个。3.2 小鸟的物理模型重力、跳跃速度与角度游戏手感好不好全部取决于小鸟的物理参数。常见实现是记录 y 坐标和垂直速度 vy每帧执行vy gravity; y vy;。跳跃时直接把 vy 设为负值比如 -6让小鸟向上冲。我给一组经过反复调整、接近原版手感的值public class Bird { public double x 100; public double y 300; public double vy 0; public double gravity 0.4; public double jumpSpeed -6.5; public int radius 15; public void update() { vy gravity; y vy; } public void jump() { vy jumpSpeed; } }逻辑说明update 每帧被 GamePanel 调用。重力是正数表示向下加速跳跃把垂直速度重置为负值于是先上升然后又被重力拉回来。这里有个关键点y 是 double 而不是 int因为浮点累加才能产生足够平滑的运动曲线。如果用 int重力 0.4 在帧间会被截断小鸟要么不掉要么以肉眼可见的台阶式运动。参数调整要配合帧率。我的 Timer 是 16 毫秒一帧也就是约 60 FPS。在这个前提下gravity0.4、jumpSpeed-6.5 能产生一条很好看的抛物线。如果帧率变成 30那同样的重力会导致每帧位移翻倍手感就会突然失控。所以完整版里最忌讳的是把 Timer 间隔和物理参数分开做改了这个忘了那个。碰到这种情况建议把物理参数全写成常量并且用“每帧多少像素”描述比如GRAVITY_PER_FRAME 0.4让后人一眼知道该成对调整。3.3 管道生成与移动随机高度怎么定才不堵路管道是逻辑最多的部分。每一根管道由上下两根柱子组成中间留一个可以让小鸟通过的缺口。每根管道只需要记录 x 坐标和缺口的中心 y 坐标 gapY柱子上下各延伸出去。下面是管道类的基础结构public class Pipe { public double x; public double gapY; public int pipeWidth 60; public int gapHeight 160; public Pipe(double x, double gapY) { this.x x; this.gapY gapY; } public void move(double speed) { x - speed; } public boolean isOffScreen() { return x pipeWidth 0; } }管道移动很简单每帧让 x 减掉速度即可速度单位是“像素每帧”。生成和派发可以放在 GamePanel 里。常见做法是维护一个 List 每过一定帧数或者当最后一根管道走出某个阈值时生成一根新的管道。随机 gapY 的范围需要计算不能直接new Random().nextInt(height)那样缺口可能出现在屏幕顶部或底部小鸟根本没路径可走。安全范围是gapY 大于管道缺口一半加上边缘预留小于屏幕高度 - 缺口一半 - 预留。示例private static final int PIPE_SPEED 3; private static final int PIPE_INTERVAL 260; private int frameCount 0; public void updatePipes() { frameCount; if (frameCount PIPE_INTERVAL) { int maxGapY 700 - 80 - 80 - gapHeight / 2; int minGapY 80 gapHeight / 2; int gapY minGapY random.nextInt(Math.max(1, maxGapY - minGapY)); pipes.add(new Pipe(400, gapY)); frameCount 0; } for (Pipe p : pipes) { p.move(PIPE_SPEED); } pipes.removeIf(Pipe::isOffScreen); }逻辑说明frameCount 是自增的帧计数器当达到 260 帧约等于 4.3 秒生成一根管道。PIPE_SPEED 3 表示每帧左移 3 像素在 60 FPS 下每秒移动 180 像素玩家有足够反应时间。PIPE_INTERVAL 是管道之间的水平距离值越大越容易260 是一个初学友好的数值。gapHeight 是缺口高度 160加 15 像素的溢出判断能略微缓解碰撞判定过严的问题。参数调整的核心原则是管道速度越快、缺口越小、生成间隔越短难度越高。如果你想做初学者模式就把速度降到 2间隔调到 320缺口加到 200。三个参数要同时改只改一个会顾此失彼。这里特别提醒一下一定要在管子移出屏幕后及时 removeIf否则 List 越拉越长内存堆积到一定程度会触发 GC导致界面卡顿。3.4 碰撞检测与计分实际上比你想的简单但也有讲究碰撞检测最可靠的做法是使用 AWT 的 Rectangle 矩形重叠检测。小鸟用一个小正方形或圆形近似管道上下各用一个矩形。用矩形的好处是无需自己写距离公式JDK 已经封装好intersects(Rectangle)。代码如下public boolean collides(Renderable r) { Rectangle birdRect new Rectangle((int) x - radius, (int) y - radius, radius * 2, radius * 2); Rectangle topPipe new Rectangle((int) pipe.x, 0, pipe.pipeWidth, (int) (pipe.gapY - pipe.gapHeight / 2)); Rectangle bottomPipe new Rectangle((int) pipe.x, (int) (pipe.gapY pipe.gapHeight / 2), pipe.pipeWidth, 700); return birdRect.intersects(topPipe) || birdRect.intersects(bottomPipe); }这段代码展示了碰撞检测的常见实现每次碰撞检测都新建三个 Rectangle然后调用 intersects。它的好处是逻辑非常直观坏处是频繁 new 对象会带来一点开销但在小游戏里可以忽略。如果你追求性能可以把 Rectangle 对象缓存下来或者直接用坐标比较。计分逻辑更简单给每根管道一个scored布尔字段初始为 false。当小鸟的 x 坐标大于管道的 x pipeWidth 时说明已经安全通过如果!scored则加一分并把 scored 置为 true。这样保证每根管道只计一次分不会因为连续几帧都在同一个位置而重复计数。要注意的是得分判断应该在 GamePanel 的 update 里做而不是在 paint 里做否则画面刷新频率会影响分数。关于碰撞宽容度很多完整版有一个 UI 上的隐蔽坑小鸟的圆形视觉比碰撞矩形小玩家看到羽毛好像蹭到管道却没死或者看起来还远却被判定死亡。解决方法是把 birdRect 的尺寸缩小一点比如改成半径的 80%。我一般会在 Bird 里提供getBounds()方法返回一个缩小后的矩形所有碰撞都用它。这样既不给玩家“作弊”的感觉又不会因为像素边缘误判让人暴躁。4. 飞翔的小鸟完整版怎么组织文件从四个类到一个能交作业的项目4.1 文件清单与包结构main 方法放哪里很多新手会问“完整版”到底该有哪些文件。其实核心运行需求就四个 Java 类Main.java、GamePanel.java、Bird.java、Pipe.java。但要做到能交作业我建议再加一个 Score.java 和一个 GameState.java。文件清单和对应职责如下文件职责Main.java创建窗口启动程序GamePanel.java持有游戏对象列表处理 Timer、按键、绘制Bird.java小鸟位置、速度、物理更新、碰撞形状Pipe.java管道坐标、移动、是否计分GameState.java枚举READY、RUNNING、GAME_OVERScore.java分数记录最高分存取包结构可以写成com.course.flappybird这是 Java 项目里最常见的命名习惯。main 方法放哪里不重要但最好放在 Main 类里不要塞进 GamePanel因为 GamePanel 应该只负责表现层。如果你没有使用包名那编译时会生成默认包也能跑但我建议养成用包名的习惯不然以后导入其他库或做单元测试会出现类名冲突。关于“完整版”和半成品的区别我这里多说一句。网上很多版本只有游戏运行主逻辑没有开始画面、没有游戏结束画面、没有重新开始按钮。一个能交给老师检查的完整版至少要满足打开程序出现标题界面按空格开始死亡后出现“GAME OVER”和分数再按空格重新开始。这三件事靠 GameState 枚举就能优雅解决它本身就是面向对象设计评审里一个很好的加分点。4.2 把代码从零到跑通的完整步骤编译、运行、调整窗口尺寸不管用命令行还是 IDE第一步都是先装好 JDK。如果你还没配好环境去官网下载 JDK 17 或更高版本安装时把 JAVA_HOME 和 PATH 配上。这一步听起来基础但我见过太多人代码写好了一直报“找不到主类”或“java不是内部或外部命令”全是因为环境变量没配对。命令行编译运行的步骤很简单前提是当前目录在项目根目录下源文件都在 src 里mkdir -p out javac -encoding UTF-8 -d out src/com/course/flappybird/*.java java -cp out com.course.flappybird.Main逻辑说明javac把 java 文件编译到 out 目录-encoding UTF-8是防止中文注释乱码。运行用java -cp out指定类路径后面跟完整类名。如果你用 IDEA 或 Eclipse就不用敲命令但要知道编译输出目录和运行时依赖的概念面试常问。窗口尺寸的调整有一个容易被忽略的地方GamePanel 的getPreferredSize()应该返回游戏画布的尺寸而不是 JFrame 的尺寸。否则frame.setSize(400,700)和实际面板尺寸不一致画面会被拉伸或裁切。正确做法是重写方法Override public Dimension getPreferredSize() { return new Dimension(400, 700); }这样frame.pack()会自动按面板尺寸调整窗口大小。如果你要改画面比例只改 GamePanel 的宽高和一个常量其他坐标都基于它计算不要到处写死 400、700。所有高度相关的计算比如管道随机 gapY、底部管道矩形的高度都应该引用面板的实际高度否则换窗口尺寸时必翻车。5. 飞翔的小鸟 Java 版避坑指南我遇到的 5 个真实问题5.1 画面闪烁得像翻书双缓冲没做好现象游戏运行时背景、小鸟和管道一闪一闪尤其是在快速移动时特别明显看起来像老式翻书动画。原因paint 方法里直接画图而系统在每次重绘时先擦除背景再执行绘制中间有一个短暂的空窗期人眼就感知到了闪烁。解决使用 JPanel 并重写paintComponent只要不手动覆盖paint方法Swing 默认开了双缓冲如果你为了省事直接在 JFrame 上画图那必然闪。完整的做法是把所有绘制放在一个 BufferedImage 上一次绘制完成后再把整幅图像原样贴到屏幕上。但飞翔的小鸟画面简单JPanel 自带双缓冲已经足够。如果还闪检查是不是在repaint()前调用了update()和clear()导致整块面板被擦白两次。记住Swing 的重绘应该只调repaint()不要调update()。5.2 小鸟掉得比想象快物理步长和像素比例要一起调现象把 gravity 设为 0.5 后小鸟在几帧内就砸到地面跳跃的弧线一点都不优雅甚至按一下空格就直接撞到上边界。原因gravity 的单位是“坐标/帧”而帧率并不稳定在显示器刷新率 120Hz 的环境下Timer 实际触发频率可能更高导致重力累计过快。更常见的因素是窗口高度不一样同样的像素位移在矮窗口里显得更夸张。解决不要只调 gravity先把 Timer 间隔固定为 16并把游戏更新和绘制分开。如果你想让游戏在不同刷新率下行为一致可以计算 deltaTime把物理参数从“每帧”改成“每秒”用速度值乘以 delta 秒。对于课程设计最简单的方法是写死 60 FPS并在代码注释里注明“物理参数基于 60 FPS 标定”。这样至少换电脑时别人知道怎么改。5.3 键盘按了没反应焦点和 key binding 的问题现象GamePanel 里实现了 KeyListener启动后按空格键没有反应但鼠标点击一下窗口后再按就正常了。原因Swing 键盘事件只会发送给当前拥有输入焦点的组件JFrame 刚启动时焦点默认在窗口或某个按钮上GamePanel 没有获取焦点。解决在 GamePanel 构造器里设置setFocusable(true)并在窗口显示后调用gamePanel.requestFocusInWindow()。如果这样做还不稳定我建议直接用 Key Bindings。gamePanel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) .put(KeyStroke.getKeyStroke(SPACE), jump); gamePanel.getActionMap().put(jump, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { gamePanel.jump(); } });逻辑说明Key Bindings 比 KeyListener 更可靠因为它不要求组件真正持有焦点只要窗口在当前系统桌面是活动窗口就行。上面的代码把空格键绑定到名为 jump 的动作上动作内部调用 GamePanel 的 jump 方法。推荐所有 Swing 小游戏都用 Key Bindings这是血泪经验KeyListener 在 Linux 和 macOS 下的焦点行为差异很大容易成为“在老师电脑上跑不起来”的空难现场。5.4 管道缝隙对不上随机高度的范围要根据屏幕高度算现象有时管道缺口出现在屏幕顶部或底部小鸟还没来得及调整方向就撞上有时两根管道之间的水平距离很近基本没有反应时间。原因生成管道时直接用random.nextInt(500)之类写死的范围没有考虑面板高度和缺口高度。解决用我前面示例代码里的上下限公式确保缺口中心距离上下边界都至少有 80 像素的余量。这是整个游戏里最值得背下来的逻辑。还有一个相关坑面板高度并不是 700因为 JFrame 的标题栏占了一部分。如果 GamePanel 的高度实际是 672你按 700 算随机范围管道底部就会被截断。最稳妥的办法是在 GamePanel 初始化时读取getHeight()把所有依赖高度的数值都放进去不要在静态常量里写死。5.5 游戏结束界面黑屏状态切换与重绘时机不对现象小鸟撞到管道后整个画面突然变成一片空白或者停留在最后一帧重新开始也不刷新。原因游戏结束后你在某个非 EDT 线程里执行了重置操作或者 GameState 已经切到 GAME_OVER但重绘逻辑在 update 里没有提前返回导致部分对象位移成了 null。解决在 GamePanel 的 actionPerformed 里明确区分状态if (state GameState.RUNNING) { bird.update(); updatePipes(); checkCollision(); } repaint();这样的逻辑保证不管游戏在什么状态界面都会持续重绘只是 RUNNING 时才更新物理。游戏结束后要让玩家看到最终画面就不能在状态切换到 GAME_OVER 时把 bird 或 pipes 清空。我见过有人为了“重新开始”方便直接 new 一个新 GamePanel 再塞进 JFrame旧面板泄漏不说还制造莫名其妙的黑屏。正确做法是只重置数据保留面板本身。6. 从能玩到好用验证方法、参数调优和扩展方向6.1 三个值得扩展的功能点重新开始、音效、最高分如果你已经把核心循环跑通下一步最值得加的功能是重新开始。在 GAME_OVER 界面按空格后需要重置小鸟位置、清空管道、分数归零并把状态切回 READY 或 RUNNING。一个常见误用是直接调用new GamePanel()替换旧面板那样会让 JFrame 的监听器和资源越积越多。正确做法是在 GamePanel 内部提供resetGame()把所有对象恢复初始值。音效是一个性价比很高的加分项但坑也出奇地多。常见做法是用javax.sound.sampled播放 wav 文件资源文件放在src/main/resources或res目录。加载时一定要用getClass().getResource(/flap.wav)来获取相对路径而不是new File(flap.wav)。后者在你用 IDE 运行时可能没问题但打成 jar 包后路径就失效了。音效不是必需品但加上后游戏质感和答辩演示效果立刻提升一个档次。最高分可以存在本地配置文件里。最简单的方式是用 Properties 文件退出时写入启动时读取。这里要说一个常见的翻车点不要在主线程里读写文件最好用 SwingWorker 异步处理否则文件读写卡住会导致界面短暂黑屏。不过对课程设计来说直接同步读写问题也不大只要别反复频繁写磁盘。6.2 验证方法把代码当成一个小系统来测我自己的习惯是每次改完参数先跑一个“自动演示模式”让小鸟不跳跃观察它从屏幕顶端落到地面的帧数再写一个测试类模拟固定输入验证管道生成范围永远在合理区间。这两个验证能搞定 80% 的隐藏 bug。如果你学过 JUnit还可以给 Bird 和 Pipe 写单元测试比如bird.jump()后 y 方向速度应该变成负数pipe.move(3)后 x 应该减少 3。这些测试不复杂但能让自己心里有底也方便答辩时展示工程意识。最后一个建议把参数集中到一个常量类里比如GameConstants.java然后在 GamePanel 中引用。这样后续调难度时不用满目录找数字只改一个文件。这是我从被多个“硬编码 700”坑过之后养成的习惯。希望这篇笔记里的代码与排错经验能让你少走一些我走过的弯路更快把飞翔的小鸟做成一个能演示、能扩展、能写进简历的完整项目。希望帮到你。本文还有配套的精品资源点击获取