用Java Swing实现飞翔的小鸟:从物理模拟到碰撞检测的完整指南
2026/9/9 15:20:01 网站建设 项目流程

简介:这是一份采用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 List<Pipe> 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 Timer

Swing Timer的回调跑在事件分发线程(EDT)上,在回调里直接改状态然后repaint是线程安全的。如果图省事用while(true) { Thread.sleep(16); }做主循环,就必须用SwingUtilities.invokeLater把更新和重绘丢回EDT,否则画面会撕裂、闪烁甚至偶发崩溃。小游戏里这不是大事,但养成"UI操作必须在EDT"的意识,对后面写正经桌面程序很有帮助。

5. 手感调参:为什么我的版本比原版难玩

代码跑通之后,你大概率会遇到一个挫败感很强的问题:这版本怎么这么难?或者反过来,怎么这么简单?问题出在四个参数的比例上。GRAVITY、JUMP_POWER、pipeGap、pipeSpeed四者相互关联,只调一个参数很难得到理想手感。

我给的参数组合是实测过的:GRAVITY=0.4、JUMP_POWER=-7、pipeGap=200、pipeSpeed=4。可以算一下跳跃高度:向上速度从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而不是做游戏,我强烈建议先把这个版本重新默写一遍,打开编辑器不看我上面的代码,把类和方法一个一个敲出来。默写的过程会逼你回忆每个判断和参数的含义,那才是真正消化。

我做这个项目最大的体感是:小游戏特别适合建立"用程序表达想法"的信心。从空白编辑器敲出一个能跑、能玩、能和朋友比分数的东西,这种正反馈比任何课程作业都强烈。别急着找完整版代码整体复制,把它当成综合题,卡住了再来对照,收获会翻倍。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询