用Java Swing实现飞翔的小鸟:从物理模拟到碰撞检测的完整指南

发布时间:2026/9/9 15:20:04
用Java Swing实现飞翔的小鸟:从物理模拟到碰撞检测的完整指南 简介这是一份采用Java实现的《飞翔的小鸟》完整源码包适合正在学习Java游戏开发或希望理解休闲游戏核心机制的学生与开发者。项目包含完整的游戏逻辑、界面绘制与计分系统并实现了动态难度调整分数低于15时正常速度过柱得1分15至30之间加速且得2分超过30继续提速并得3分吃到星星额外加2分。通过分析Bird类、柱子和星星生成器的组织方式能够理解面向对象设计、游戏循环、碰撞检测以及基于Swing或JavaFX的界面渲染等关键知识点。资源共32个文件以png图片素材、class编译文件和java源码为主包体仅139KB麻雀虽小五脏俱全便于快速解压阅读。已有2952人下载学习适合用来对照实战代码提升Java游戏编程能力。1. 为什么拿飞翔的小鸟当Java练手项目而不是贪吃蛇或飞机大战如果要我列一个适合Java新手的练手项目榜单飞翔的小鸟一定排在前三。别觉得它只是个小游戏仔细拆开看里面正好把Java基础里最常被问到的几块知识全占了Swing窗口程序、基于Timer的游戏循环、重力与速度的物理模型、Rectangle碰撞检测、KeyListener键盘事件、ArrayList集合操作最后还有JFrame的打包启动。一套写下来你对Java到底能做什么的理解会比刷一百道控制台练习题都深。为什么不是贪吃蛇贪吃蛇的难点主要在蛇身坐标的维护上运动逻辑单一写完往往只有哦队列是这么用的一个收获。飞机大战的问题则是敌机生成和子弹管理偏繁琐容易把新手的精力消耗在琐碎细节上。飞翔的小鸟处在一个很舒服的中间态规则极简点一下跳一下碰到管道就死穿过一根加一分但实现它必须把物理模拟、状态管理、画面渲染、事件响应四件事全部想清楚而这恰恰是很多初学者最薄弱的环节。如果你正在准备Java相关的面试或想夯实基础这个小游戏也是不错的讲得出细节的项目。面试时提到集合、线程、面向对象设计你可以拿这个项目当例子讲清楚Timer的回调机制、为什么用ActionListener接口、状态字段怎么设计这比干背八股文里的概念要有说服力得多。项目类型覆盖的知识点是否涉及完整游戏循环学生管理系统集合、文件IO、CRUD否贪吃蛇数组/集合、键盘事件勉强飞翔的小鸟精确计时、重力模拟、碰撞检测、状态机是2. 动手前先理清三个核心问题画什么、怎么动、怎么判定撞了新手一上来最容易干的事是找图片素材、调窗口皮肤结果卡在游戏逻辑怎么写上。我的建议是反过来先用最简单的图形一个黄色圆形当小鸟绿色矩形当管道把整个逻辑跑通再去换精致素材。逻辑通了换皮只是几分钟的事。先解决画什么。Java里做这种2D小游戏主流方案是继承JPanel重写paintComponent(Graphics g)方法。系统每次需要重绘窗口时都会调它你在方法里用Graphics2D往面板上画背景、画管道、画小鸟、画分数就行。Swing自带双缓冲paintComponent里画多少内容都不会像老式AWT那样闪屏这一点省心不少。再解决怎么动。游戏本质是一个每秒钟执行约60次的循环我用Swing的Timer来实现new Timer(16, this)16毫秒约等于60帧。Timer每触发一次就调用actionPerformed(ActionEvent e)这个回调就是游戏的心跳每次都做两件事先更新所有游戏对象的状态包括小鸟位置、管道x坐标、是否碰撞再调用repaint()触发重绘。这样更新逻辑和绘制画面被清晰地分开后面每段代码都能对号入座。最后解决怎么判定撞了。AWT提供了现成的Rectangle类它有intersects(Rectangle)方法能判断两个矩形是否相交。我把小鸟看成一个矩形上下两根管道也分别看成矩形每次心跳时检查相交情况。这个方案实现起来十几行代码对小游戏来说准确度完全够用。把这三个问题想明白剩下的就是往骨架里填肉。3. 完整代码逐段拆解从窗口到一只会飞的鸟下面把完整代码拆成六个小块按顺序拼到一个类里就是一份能编译运行的完整版本。代码不引入任何第三方库JDK 8以上直接能跑。3.1 窗口尺寸、成员变量与游戏状态import javax.swing.*; import java.awt.*; import java.awt.event.*; import java.util.ArrayList; import java.util.List; import java.util.Random; public class FlappyBird extends JPanel implements ActionListener, KeyListener { public static final int WIDTH 450; public static final int HEIGHT 700; private boolean running false; private boolean gameOver false; private int score 0; private int birdX 100; private double birdY HEIGHT / 2.0; private int birdSize 34; private double velocity 0; private final double GRAVITY 0.4; private final double JUMP_POWER -7; private final ListPipe pipes new ArrayList(); private int pipeWidth 70; private int pipeGap 200; private int pipeSpeed 4; private int pipeSpawnCounter 0; private final Random random new Random(); private Timer timer; public FlappyBird() { setPreferredSize(new Dimension(WIDTH, HEIGHT)); setFocusable(true); addKeyListener(this); timer new Timer(16, this); startGame(); } }birdY我特意声明成double因为速度velocity也是double。每帧执行birdY velocity时如果强转成int微小位移会被截断快速下落时画面会一顿一顿手感很涩。管道属性我单独抽了内部类Pipe它只需要x坐标、间隙中心y坐标、一个是否已计分的标记比用一堆平行数组清晰得多。游戏状态用running和gameOver两个布尔字段表示。running为false时不执行任何更新这一个判断放在actionPerformed里省掉大量如果结束就不动的散乱条件。想重新开始只需在startGame()里重置所有状态。3.2 重力与跳跃让小鸟动得像真的private void startGame() { birdY HEIGHT / 2.0; velocity 0; score 0; pipes.clear(); gameOver false; running true; timer.start(); } private void update() { velocity GRAVITY; birdY velocity; if (birdY 0) { birdY 0; velocity 0; } if (birdY HEIGHT - birdSize - 60) { birdY HEIGHT - birdSize - 60; gameOver true; running false; } }这里的物理模型很简单但几乎所有同类游戏都用它速度每帧增加一个重力加速度位置每帧增加当前速度。这是匀加速运动的离散版本只不过把时间量化成一帧一帧。点空格做的不是向上飞一段而是给velocity赋一个负值让小鸟获得向上的初速度之后重力逐渐把这个速度磨没再转为下落。细节在边界判断里。birdY HEIGHT - birdSize - 60中的60是地面高度不减的话小鸟会掉出窗口底部之外才判定死亡视觉上钻进地面一截。撞天花板也做了钳制防止小鸟飞出画布后坐标丢失。这两条判断各补一次很多人写到这里会漏。3.3 管道生成、移动与移除private void updatePipes() { pipeSpawnCounter; if (pipeSpawnCounter 80) { pipeSpawnCounter 0; int gapCenterY random.nextInt(HEIGHT - 320) 160; pipes.add(new Pipe(WIDTH, gapCenterY)); } for (Pipe p : pipes) { p.x - pipeSpeed; } pipes.removeIf(p - p.x pipeWidth 0); } static class Pipe { int x; int gapCenterY; boolean passed; Pipe(int x, int gapCenterY) { this.x x; this.gapCenterY gapCenterY; } }管道生成的关键是控制好两个数字生成间隔80帧约1.3秒一根移动速度4像素每帧。两者相乘得到相邻管道的水平间距320像素比屏幕宽度450小一截玩家看到的是屏幕里同时存在一到两根管道的密度不空也不挤。如果把间隔调大而速度不变屏幕会出现大段空白难度反而会下降得很不自然。gapCenterY的随机范围限制在160到540之间用random.nextInt(HEIGHT - 320) 160生成。管道间隙是200最靠上的情况上方管道高60像素最靠下的情况下方管道顶部离地面还有100像素怎么随机都不会生成一根缝隙一半埋在地里的管道。这个随机范围直接决定游戏是否公平是很容易忽略的点。移除管道用removeIf(p - p.x pipeWidth 0)当管道整体滑出左边界后才从集合删除List长度不会无限增长长时间玩也不会因对象堆积而卡顿。这个小写法用到了Java 8的lambda表达式准备面试的话可以顺带复习Predicate接口。3.4 碰撞判定与计分private void checkCollisionAndScore() { Rectangle birdRect new Rectangle(birdX, (int) birdY, birdSize, birdSize); for (Pipe p : pipes) { Rectangle topRect new Rectangle(p.x, 0, pipeWidth, p.gapCenterY - pipeGap / 2); Rectangle bottomRect new Rectangle(p.x, p.gapCenterY pipeGap / 2, pipeWidth, HEIGHT - (p.gapCenterY pipeGap / 2)); if (birdRect.intersects(topRect) || birdRect.intersects(bottomRect)) { gameOver true; running false; return; } if (!p.passed p.x pipeWidth birdX) { p.passed true; score; } } }碰撞检测拆成上下两个矩形上方管道从(p.x, 0)开始高度到间隙上沿下方管道从间隙下沿开始一直伸到画布底部。注意下方矩形的高度要写成HEIGHT - (p.gapCenterY pipeGap / 2)因为Rectangle的坐标原点在左上角从间隙下沿到窗口底部的距离才是它的高度。栽在这行的新手不少算出负高度的都有。计分逻辑藏在一个容易被忽略的判断里管道创建时带passed标记只有当管道右边缘越过小鸟的x坐标才加一分并置标记。这样既不会出现穿过一根管道加好几百分的bug也不会重复计分。我把碰撞检测和计分放在同一个遍历里因为两者都要访问管道列表一趟循环解决两个需求代码更清爽。3.5 绘制层把所有状态画成画面Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d (Graphics2D) g; g2d.setColor(new Color(135, 206, 235)); g2d.fillRect(0, 0, WIDTH, HEIGHT); g2d.setColor(new Color(222, 184, 135)); g2d.fillRect(0, HEIGHT - 60, WIDTH, 60); g2d.setColor(new Color(139, 69, 19)); g2d.drawLine(0, HEIGHT - 60, WIDTH, HEIGHT - 60); for (Pipe p : pipes) { g2d.setColor(new Color(34, 139, 34)); g2d.fillRect(p.x, 0, pipeWidth, p.gapCenterY - pipeGap / 2); g2d.fillRect(p.x, p.gapCenterY pipeGap / 2, pipeWidth, HEIGHT - (p.gapCenterY pipeGap / 2)); g2d.setColor(Color.BLACK); g2d.drawRect(p.x, 0, pipeWidth, p.gapCenterY - pipeGap / 2); g2d.drawRect(p.x, p.gapCenterY pipeGap / 2, pipeWidth, HEIGHT - (p.gapCenterY pipeGap / 2)); } g2d.setColor(new Color(255, 215, 0)); g2d.fillOval(birdX, (int) birdY, birdSize, birdSize); g2d.setColor(Color.BLACK); g2d.drawOval(birdX, (int) birdY, birdSize, birdSize); g2d.fillOval(birdX 20, (int) birdY 8, 5, 5); g2d.setColor(new Color(255, 140, 0)); g2d.fillRect(birdX 26, (int) birdY 15, 10, 5); g2d.setColor(Color.WHITE); g2d.setFont(new Font(Arial, Font.BOLD, 40)); g2d.drawString(String.valueOf(score), WIDTH / 2 - 20, 80); if (gameOver) { g2d.setColor(new Color(0, 0, 0, 120)); g2d.fillRect(0, 0, WIDTH, HEIGHT); g2d.setColor(Color.WHITE); g2d.setFont(new Font(Arial, Font.BOLD, 30)); g2d.drawString(Game Over, WIDTH / 2 - 90, HEIGHT / 2 - 20); g2d.setFont(new Font(Arial, Font.PLAIN, 18)); g2d.drawString(按 R 重新开始, WIDTH / 2 - 70, HEIGHT / 2 30); } }绘制顺序非常重要先铺底色再画游戏元素最后画UI文字。因为后画的会盖住先画的如果把管道画在背景之前背景就会把管道遮掉。游戏结束的遮罩放最后保证它永远在最上层。半透明遮罩用new Color(0, 0, 0, 120)alpha通道120既能压暗画面又能让下面的管道隐约可见观感比纯黑一块好很多。小鸟是圆和矩形拼出来的简笔画黄色身体、黑色轮廓、眼睛、橙色嘴。不需要任何图片资源照抄就能跑。理解drawOval的坐标系后把这个简笔画替换成一张PNG图片也就十行代码的事。3.6 键盘事件与主入口Override public void actionPerformed(ActionEvent e) { if (!running) return; update(); updatePipes(); checkCollisionAndScore(); repaint(); } Override public void keyPressed(KeyEvent e) { int code e.getKeyCode(); if (code KeyEvent.VK_SPACE) { if (!gameOver) { velocity JUMP_POWER; } } else if (code KeyEvent.VK_R) { startGame(); } } Override public void keyReleased(KeyEvent e) {} Override public void keyTyped(KeyEvent e) {} public static void main(String[] args) { JFrame frame new JFrame(飞翔的小鸟); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); FlappyBird game new FlappyBird(); frame.add(game); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); }actionPerformed是整个游戏的中枢把状态更新、管道更新、碰撞计分三个方法依次调用最后repaint。拆成独立小方法是为了让每个方法只做一件事出问题好定位。跳跃判断里有个细节只有gameOver为false时才允许跳避免死后按空格导致小鸟在结算画面里乱动。R键重置调用startGame()和开局初始化走同一个入口逻辑不写两遍。main方法里JPanel添加到JFrame调用pack()让窗口按首选尺寸自适应再居中显示。窗口禁用resizable是因为游戏按固定画布设计拉伸会导致坐标对不上出现明明没碰到管子却死了的错觉。4. 实测最容易踩的五个坑焦点、坐标、路径、线程和重绘4.1 键盘没反应焦点没给对面板addKeyListener了keyPressed也写了但运行起来按空格毫无反应。原因在于JPanel默认得不到键盘焦点焦点可能在JFrame或其他组件上。解决办法是在构造器里调用setFocusable(true)窗口显示后最好再调一次game.requestFocusInWindow()。如果界面里还有其他组件换成Key Bindings更稳妥KeyListener是低级API焦点一丢就失灵。4.2 坐标轴方向记住y轴向下AWT的坐标原点在左上角x轴向右y轴向下。这意味着向上跳在代码里是让birdY减小而不是增大跳跃初速度是负数。习惯数学坐标系的人第一次写都会搞反表现就是越按空格小鸟越往下出溜。解法就是忘掉数学课的y轴把y理解为离顶部像素数。4.3 图片加载别用相对路径把圆形换成小鸟图片时很多人写ImageIO.read(new File(bird.png))然后发现IDE里跑没事打成jar包就报NullPointerException。相对路径依赖当前工作目录双击jar时工作目录不可控。正确做法是FlappyBird.class.getResourceAsStream(/bird.png)把图片放进resources目录别相信任何相对路径。4.4 Timer别重复创建有个常见写法是在开始游戏按钮里new Timer(...)每点一次就新建一个旧的还在跑画面越来越快。Timer必须在构造器里只创建一次startGame()里只调start()。start()本身是幂等的对已经在跑的Timer再调用不会叠加放心用。4.5 重绘与线程为什么选Swing TimerSwing Timer的回调跑在事件分发线程EDT上在回调里直接改状态然后repaint是线程安全的。如果图省事用while(true) { Thread.sleep(16); }做主循环就必须用SwingUtilities.invokeLater把更新和重绘丢回EDT否则画面会撕裂、闪烁甚至偶发崩溃。小游戏里这不是大事但养成UI操作必须在EDT的意识对后面写正经桌面程序很有帮助。5. 手感调参为什么我的版本比原版难玩代码跑通之后你大概率会遇到一个挫败感很强的问题这版本怎么这么难或者反过来怎么这么简单问题出在四个参数的比例上。GRAVITY、JUMP_POWER、pipeGap、pipeSpeed四者相互关联只调一个参数很难得到理想手感。我给的参数组合是实测过的GRAVITY0.4、JUMP_POWER-7、pipeGap200、pipeSpeed4。可以算一下跳跃高度向上速度从7降到0需要约17帧这17帧里上升距离大约61像素不到管道间隙的三分之一。所以玩家每次跳跃只能向上走一小段必须连续点按才能穿过间隙这正是原版那种连续扑腾的手感来源。把GRAVITY改成2.0跳跃高度缩到12像素左右难度陡增反过来把跳跃初速度改成-12一按空格飞到屏幕顶难度又太低。调参建议是先固定pipeGap在180到220之间再调GRAVITY和JUMP_POWER。想让游戏更飘同步减小两者更沉同步增大。管道速度影响整体节奏速度越快容错时间越短要和间隙配合着调间隙小速度就要慢。我自己的调试方法是把Timer间隔临时改成100毫秒观察每帧运动轨迹手感调好后再改回16毫秒。如果想让游戏在不同刷新率的电脑上表现一致就要引入按帧耗时归一化的思路比如记录上次更新的System.nanoTime()用经过的毫秒数乘以速度替代每帧固定位移。这个改动有点抽象但它是让游戏从只能在自己电脑上玩变成换台电脑手感一样的关键一步。6. 扩展方向与我的建议基础版做完之后有几个性价比很高的扩展方向。第一是历史最高分用文件流把score存到本地启动时读回来游戏结束比较并覆盖顺便复习一波IO流和异常处理。第二是音效用javax.sound.sampled的Clip播放跳跃和失败音效很多初学者第一次发现Java能播声音体验很加分。第三是难度递升让pipeSpeed和随机范围随score变化每5分提一档游戏就从练手程序变成有设计感的作品。想再认真一点可以把管道改成贴图、给小鸟加两帧翅膀动画、加暂停按钮。但如果目标是学Java而不是做游戏我强烈建议先把这个版本重新默写一遍打开编辑器不看我上面的代码把类和方法一个一个敲出来。默写的过程会逼你回忆每个判断和参数的含义那才是真正消化。我做这个项目最大的体感是小游戏特别适合建立用程序表达想法的信心。从空白编辑器敲出一个能跑、能玩、能和朋友比分数的东西这种正反馈比任何课程作业都强烈。别急着找完整版代码整体复制把它当成综合题卡住了再来对照收获会翻倍。本文还有配套的精品资源点击获取