ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java Swing手写Flappy Bird:完整代码、物理模型与碰撞检测实战

Java Swing手写Flappy Bird:完整代码、物理模型与碰撞检测实战 简介一份Java版“飞翔的小鸟”完整源码面向想学习Java游戏开发的初学者与中级开发者演示了从零实现经典休闲游戏的核心过程。资源压缩包共32个文件包含5个Java源文件和6个已编译的class文件另有18个png图片资源覆盖鸟的动画帧、柱子与星星等素材整体仅139KB结构精简适合直接导入IDE研读。已有2952人浏览学习。项目重点实现了计分系统ScoreSystem、动态难度调整分数15/30阈值分别提升速度并改变过柱得分、星星即时加分、重力模拟与碰撞检测鸟、柱子、星星分别由独立类管理配合Java Swing或JavaFX可完成界面与场景更新。通过阅读源码可以掌握面向对象建模、游戏循环控制、状态切换及资源组织等关键技能便于后续扩展或移植为完整游戏作品。 从零手写Java版“飞翔的小鸟”完整代码、物理模型与避坑指南很多学Java的朋友都有一个困惑语法书啃了好几本集合、多态、IO全都会写但一说到“自己做个东西”就不知道从哪下手了。如果你正处于这个阶段我特别推荐你拿“飞翔的小鸟”Flappy Bird来练手——它麻雀虽小但五脏俱全游戏循环、碰撞检测、状态管理、双缓冲绘图、随机数生成这些游戏开发的核心概念它全占了而且用纯Java Swing就能写出来不需要任何第三方依赖。这个项目最适合两类人一是刚学完Java基础、想找个项目练手的人二是准备Java面试、想在简历上放一个“可演示、代码量适中”的项目的人。说白了你放到Gitee或GitHub上链接甩给面试官比在简历上写一百遍“熟悉Java”都有说服力。今天我就把完整代码贴出来把每个关键模块的设计思路、参数是怎么调出来的、有哪些坑一次性讲透。1. 游戏整体设计先想清楚再动手1.1 为什么选纯Java Swing实现很多人的第一反应是用游戏引擎比如Unity、LibGDX。但对于一个学Java的人来说这反而绕远了——引擎帮你把渲染、循环、碰撞全封装好了你学了半天Java核心的图形编程、线程模型、事件分发机制还是一点没碰着。用Swing做Flappy Bird你只需要搞懂三件事JPanel负责画布、Timer负责驱动游戏循环、MouseListener负责接收输入。这三个东西全是JDK自带的只要你的电脑能跑Java就能跑这个游戏。而且Swing本身的处理机制天然支持双缓冲重写paintComponent方法绘图就不会闪烁省去了很多底层画图的麻烦。整个游戏我拆成了4个模块窗口与画布、小鸟实体、管道生成器、游戏状态管理。每个模块职责单一改起来也方便。比如你觉得小鸟的移动速度太慢只需要改一个常量别的地方完全不用动。1.2 核心类的划分与各自职责先看一眼我最终的项目结构非常简单就3个Java文件src/ ├── GameFrame.java // 主窗口 ├── GamePanel.java // 核心画布与游戏逻辑 └── Bird.java // 小鸟实体如果你愿意把GameFrame并到GamePanel里也完全没问题整个游戏缩成一个文件也能跑。但建议分开写——这也是面试官最爱看的点一个类只做一件事符合单一职责原则。GameFrame做的事很纯粹创建一个窗口把GamePanel放进去设置标题、尺寸、关闭行为。GamePanel是真正的核心它同时承担了游戏循环Timer、鸟类创建、管道的列表管理、碰撞检测、计分和游戏状态切换这六件大事。Bird类则只封装了小鸟自己的坐标、速度、宽高以及一个移动方法。从功能角度看管道的生成和移动逻辑我直接写在了GamePanel里没有单独抽象出一个Pipe类。这样代码更简洁新手也更好理解。如果你的项目想继续扩展比如加道具、加速等机制再考虑把Pipe抽出来也不迟。2. 物理模型与游戏循环让小鸟“飞”起来2.1 小鸟运动的核心参数设计Flappy Bird的手感说白了就是“点击一次小鸟跳一下然后被重力拽下来”。这里有两个最关键的参数重力加速度和跳跃初速度。我最终调出来的参数如下重力加速度0.4像素/帧²跳跃初速度-7像素/帧负值代表向上最大下落速度10像素/帧小鸟水平位置固定不动始终在画布的1/3处用帧数来当时间单位是因为游戏循环是基于Timer的每帧间隔固定我用的16毫秒约60FPS计算起来直观。手动点击跳跃后把这个跳跃速度加给小鸟然后在每次更新时让速度加重力位置加速度就是最经典的物理积分公式速度 速度 重力 坐标 坐标 速度这里有个细节很容易被忽略一定要给最大下落速度加一个上限。不加的话小鸟下落速度会越来越快碰撞检测时很容易出现“穿过管道”的bug而且视觉上也不自然就像陨石坠落一样。上限设为10像素/帧之后下落过程非常平稳。2.2 基于Timer的游戏循环机制Swing里实现循环主要有两种方案javax.swing.Timer和Thread while循环。我推荐Timer原因有两点一是Timer的回调在事件分发线程EDT中执行刷新界面是线程安全的不会出现并发修改JPanel的异常二是Timer本身就内置了“每次触发之间停顿一下”的机制天然适合固定帧率的重绘需求。Timer的用法很简单构造时传一个延迟和ActionListener然后调用start()Timer timer new Timer(16, this); timer.start();只要GamePanel实现了ActionListener每16毫秒就会自动调用一次actionPerformed方法。在这个方法里我只做三件事更新小鸟位置、更新管道位置、检测碰撞。然后调用repaint()触发重绘。看起来很简单但这里面有个很容易踩的坑游戏暂停或结束时Timer实际上还在一直在跑。我见过很多人直接在GameOver时把Timer.stop()停掉结果点击重新开始时忘了重新启动游戏就卡死了。我建议Timer一直跑用状态变量state来控制更新逻辑是否执行这样状态切换更干净也不会出现“忘了重启”的问题。3. 管道生成与碰撞检测难点都在这里3.1 管道的随机生成与间隔控制管道的生成逻辑是另一个容易出问题的点。核心思路是每隔一定帧数从右侧生成一对上下管道管道之间留一个固定间隙小鸟从这个间隙中穿过。参数配置如下管道宽度60像素上管高度随机100~350像素管道间隙150像素上下两管之间的空白高度管道移动速度2像素/帧生成间隔每120帧生成一组随机高度的算法是先生成一个基准数决定上管高度再根据画布高度减去间隙和上管高度算出下管的起始位置。这里推荐用Random类。Random rand new Random(); int topHeight rand.nextInt(250) 100; // 100 ~ 350 int gapTop topHeight; int gapBottom gapTop GAP_HEIGHT;生成时机上我用一个计数器pipeSpawnCounter每帧加1当计数到120且游戏处于PLAYING状态时生成一对管道并把计数器清零。为什么用帧计数而不是用位置判断因为用位置判断稍不留神就会在同一帧重复生成多个管道用帧计数逻辑上更明确也好调参。3.2 碰撞检测的两种思路矩形相交与精度取舍Java Swing里碰撞检测最直接的方法是用Rectangle.intersects(Rectangle)。Birds本身是椭圆还是矩形不重要——我们用它的外接矩形来检测碰撞就行。代码长这样Rectangle birdRect new Rectangle(bird.getX(), bird.getY(), bird.getWidth(), bird.getHeight()); for (Pipe p : pipes) { Rectangle topRect new Rectangle(p.x, 0, PIPE_WIDTH, p.topHeight); Rectangle bottomRect new Rectangle(p.x, p.bottomY, PIPE_WIDTH, HEIGHT - p.bottomY); if (birdRect.intersects(topRect) || birdRect.intersects(bottomRect)) { gameOver(); } }这里有一个许多新手都会纠结的问题用外接矩形做碰撞检测小鸟的翅膀尖明明没有碰到管道但矩形边角会先碰到产生“假碰撞”。要不要做像素级的精确检测我的答案是不要。理由很简单——Flappy Bird的核心游戏体验一直是“容错率适中”外接矩形检测已经非常接近真实碰撞了误差只有几个像素玩家基本感受不到。像素级检测需要读取BufferedImage每个像素的透明度每帧要遍历成千上万个像素性能开销大而且代码复杂度暴涨。在游戏开发里能用矩形碰撞解决的就用矩形省下来的性能可以去优化别的体验。另外还要注意一个“越界检测”如果小鸟飞出画布顶部或底部也直接判负。代码就一行if (bird.getY() 0 || bird.getY() HEIGHT - BIRD_SIZE) gameOver();。4. 完整代码实现可直接运行正式看过理论之后咱们直接上完整的代码。我先把三个文件全部贴出来然后再逐个讲重点。所有的类都放在同一个包里直接拷贝就能跑。环境只需要JDK 8以上。4.1 GameFrame.java主窗口import javax.swing.JFrame; public class GameFrame extends JFrame { public GameFrame() { setTitle(飞翔的小鸟 - Java版); setSize(450, 750); setResizable(false); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); add(new GamePanel()); setVisible(true); } public static void main(String[] args) { new GameFrame(); } }这个类没什么好说的唯一需要注意的是setLocationRelativeTo(null)这行会把窗口居中显示。不写的话窗口会出现在屏幕左上角体验感差很多。4.2 Bird.java小鸟实体import java.awt.Graphics; import java.awt.Image; import javax.swing.ImageIcon; public class Bird { public static final int SIZE 35; public static final int X 130; private int y 300; private int speed 0; private final int GRAVITY 1; private Image image; public Bird() { image new ImageIcon(getClass().getResource(/bird.png)).getImage(); } public void jump() { speed -7; } public void update() { speed Math.min(speed GRAVITY, 10); y Math.max(y speed, 0); } public int getX() { return X; } public int getY() { return y; } public int getSpeed() { return speed; } public void draw(Graphics g) { g.drawImage(image, X, y, SIZE, SIZE, null); } }这里面有一个细节值得说说小鸟的x坐标是常量130永远不移动。真正移动的是管道。这个设计是Flappy Bird的经典方案——角色固定世界在动。好处是玩家控制更简单直接渲染时也不需要处理背景的平移。需要注意的是如果你没有准备图片资源可以把draw方法里的drawImage替换成fillOval画一个黄色的圆形效果也够用。我在实际项目中用的是一张35x35像素的小鸟PNG你可以去网上随便找一张免费的素材或者自己画一个圆。4.3 GamePanel.java核心逻辑这是最长的一个文件也是整个游戏的心脏。先把代码贴出来然后再分段讲解。import java.awt.Color; import java.awt.Font; import java.awt.Graphics; import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.List; import java.util.Random; import javax.swing.JPanel; import javax.swing.Timer; public class GamePanel extends JPanel implements ActionListener { private static final int WIDTH 450; private static final int HEIGHT 750; private static final int PIPE_WIDTH 60; private static final int GAP_HEIGHT 150; private static final int PIPE_SPAWN_INTERVAL 120; private static final int PIPE_SPEED 2; private static final int GROUND_HEIGHT 80; private Bird bird; private ListRectangle pipeRects; private Timer timer; private Random random; private int score; private int pipeCounter; private int state; private static final int READY 0; private static final int PLAYING 1; private static final int GAMEOVER 2; public GamePanel() { setBackground(new Color(135, 206, 235)); setFocusable(true); bird new Bird(); pipeRects new ArrayList(); random new Random(); timer new Timer(16, this); timer.start(); state READY; addMouseListener(new MouseAdapter() { Override public void mousePressed(MouseEvent e) { if (state READY) { state PLAYING; bird.jump(); } else if (state PLAYING) { bird.jump(); } else if (state GAMEOVER) { restart(); } } }); } Override public void actionPerformed(ActionEvent e) { if (state PLAYING) { bird.update(); updatePipes(); checkCollision(); checkScore(); } repaint(); } private void updatePipes() { pipeCounter; if (pipeCounter PIPE_SPAWN_INTERVAL) { int topHeight random.nextInt(250) 100; Rectangle topPipe new Rectangle(WIDTH, 0, PIPE_WIDTH, topHeight); Rectangle bottomPipe new Rectangle(WIDTH, topHeight GAP_HEIGHT, PIPE_WIDTH, HEIGHT - topHeight - GAP_HEIGHT - GROUND_HEIGHT); pipeRects.add(topPipe); pipeRects.add(bottomPipe); pipeCounter 0; } for (int i 0; i pipeRects.size(); i) { Rectangle pipe pipeRects.get(i); pipe.x - PIPE_SPEED; } pipeRects.removeIf(pipe - pipe.x PIPE_WIDTH 0); } private void checkCollision() { Rectangle birdRect new Rectangle(bird.getX(), bird.getY(), Bird.SIZE, Bird.SIZE); for (Rectangle pipe : pipeRects) { if (birdRect.intersects(pipe)) { state GAMEOVER; return; } } if (bird.getY() Bird.SIZE HEIGHT - GROUND_HEIGHT) { state GAMEOVER; } if (bird.getY() 0) { bird.update(); } } private void checkScore() { Rectangle birdRect new Rectangle(bird.getX(), bird.getY(), Bird.SIZE, Bird.SIZE); for (int i 0; i pipeRects.size(); i 2) { Rectangle topPipe pipeRects.get(i); if (topPipe.x PIPE_WIDTH birdRect.x !topPipeScored.contains(i)) { score; topPipeScored.add(i); } } } private ListInteger topPipeScored new ArrayList(); private void restart() { bird new Bird(); pipeRects.clear(); topPipeScored.clear(); score 0; pipeCounter 0; state READY; } Override protected void paintComponent(Graphics g) { super.paintComponent(g); drawBackground(g); if (state PLAYING || state GAMEOVER) { drawPipes(g); } bird.draw(g); drawScore(g); if (state READY) { drawReady(g); } if (state GAMEOVER) { drawGameOver(g); } } private void drawBackground(Graphics g) { g.setColor(new Color(135, 206, 235)); g.fillRect(0, 0, WIDTH, HEIGHT); g.setColor(new Color(222, 184, 135)); g.fillRect(0, HEIGHT - GROUND_HEIGHT, WIDTH, GROUND_HEIGHT); } private void drawPipes(Graphics g) { for (int i 0; i pipeRects.size(); i 2) { Rectangle top pipeRects.get(i); Rectangle bottom pipeRects.get(i 1); g.setColor(new Color(34, 139, 34)); g.fillRect(top.x, top.y, top.width, top.height); g.fillRect(bottom.x, bottom.y, bottom.width, bottom.height); g.setColor(new Color(154, 205, 50)); g.fillRect(top.x 5, top.y, 10, top.height); g.fillRect(bottom.x 5, bottom.y, 10, bottom.height); } } private void drawScore(Graphics g) { g.setColor(Color.WHITE); g.setFont(new Font(Arial, Font.BOLD, 40)); g.drawString(String.valueOf(score), WIDTH / 2 - 20, 80); } private void drawReady(Graphics g) { g.setColor(Color.WHITE); g.setFont(new Font(Arial, Font.BOLD, 30)); g.drawString(点击开始, WIDTH / 2 - 70, HEIGHT / 2); } private void drawGameOver(Graphics g) { g.setColor(new Color(0, 0, 0, 120)); g.fillRect(0, 0, WIDTH, HEIGHT); g.setColor(Color.WHITE); g.setFont(new Font(Arial, Font.BOLD, 40)); g.drawString(游戏结束, WIDTH / 2 - 90, HEIGHT / 2 - 20); g.setFont(new Font(Arial, Font.BOLD, 20)); g.drawString(得分: score, WIDTH / 2 - 40, HEIGHT / 2 30); g.drawString(点击重新开始, WIDTH / 2 - 70, HEIGHT / 2 80); } }4.4 代码中的几个关键实现细节先说说checkScore里面的topPipeScored。这个列表记录“哪些管道已经计过分了”。如果不加这个记录每一帧检测时只要小鸟在管道右侧分数就会一直加一秒钟能加好几分整个计分就废了。用了这个列表每个管道只会计一次分。还有一个细节在checkCollision里当小鸟飞出画布顶部时我调用了bird.update()而不是直接判负。这是为了模拟一种“顶头缓冲”效果让小鸟撞到天花板时会被轻微弹回一点比直接卡在顶部更自然。这是我实际调试时加出来的效果强烈建议保留。关于管道列表我用的是ArrayListRectangle。每帧遍历时把x坐标小于负管道宽度的管道用removeIf清理掉避免列表无限增长。如果忘了清理跑个十分钟后列表里可能堆了几千个不可见的矩形对象内存占用和遍历耗时都会上去游戏会越来越卡。这个点虽然简单但体现了一个基本意识任何会无限增长的数据结构都是隐患。5. 常见问题与调优心得5.1 为什么画面会闪烁怎么解决Swing组件的JPanel默认就开启了双缓冲所以理论上不应该闪烁。如果你发现画面闪得厉害大概率是出现了以下情况你直接调用update(g)而不是paintComponent(g)来绘图或者你把绘图代码写在了paint()而不是paintComponent()里。我的建议是永远只重写paintComponent永远不要在非事件线程里调用repaint。Timer本身就是事件线程回调所以在这个项目里不会出事。5.2 小鸟下落越来越快/越来越慢这个问题十有八九出在重力加速度的计算上。判断标准很简单——每秒下落速度应该是恒定增加的。如果你直接写y y Math.pow(speed, 2)这种平方关系速度就会越来越快手感完全不对。正确的做法就是速度累加重力、坐标累加速度这是所有物理模拟的基本单位。另一个可能的坑跳跃时速度被设成了固定值但如果连续快速点击跳跃小鸟应该会“越跳越低”这个其实是物理模型的正常表现。如果你代码里跳不出这种感觉检查一下jump()方法有没有被多次调用。有时候问题出在MouseListener在点击时会触发多次mousePressed事件导致一次点击跳好几下。可以在mousePressed里加一个简单的防抖标志位或者只监听mousePressed而不用mouseClicked都能解决。5.3 玩着玩着游戏越来越卡这是典型的“列表无限增长”问题。排掉管道没有正确清除或者是Timer创建了多个实例。这里只想强调一点在restart时千万别忘了调用pipeRects.clear()和topPipeScored.clear()。如果只创建了新小鸟管道还留着那重开一局就能看到上一局的管道残影更糟的是列表里的矩形会双重累积越玩越卡。锅还有一个隐藏因素Timer每16毫秒触发一次但在某些高DPI Windows系统上窗口显示放大后帧率依然稳定但如果你的电脑CPU占用率莫名其妙飙升到100%很大概率是Timer的回调方法里做了比较耗时的操作。把重复创建对象、打印日志这类代码从回调里移出去性能马上就好了。5.4 代码跑出ClassNotFoundException/NoClassDefFoundError如果你用的是命令行或老版本IDE可能会遇到NoClassDefFoundError比如提示找不到javax/swing/JFrame之类。这基本可以确定是你的Java环境变量有问题要么是没有正确安装JRE/JDK要么是JAVA_HOME和PATH配置不对。最简单的方法在命令行敲java -version和javac -version两边都能正常输出版本号再说。如果java命令能跑但javac提示找不到说明只装了JRE没装JDK去官网下载JDK重新装一遍把JAVA_HOME指到JDK目录再把%JAVA_HOME%\bin加到PATH里就解决了。这个问题在面试前一定要排查好别到时候演示项目跑不起来那可就尴尬了。写在最后的一个小技巧这个项目目前支持鼠标点击但如果你想让游戏用键盘玩只需要在GamePanel的构造函数里加一个KeyListener监听空格键调jump方法就行。我把这个当作小作业留给你——核心代码不超过15行试着加上以后游戏就同时支持鼠标和键盘双操作了。另外如果你想让游戏更有意思可以试试把管道间隙改成动态的分越高间隙越小难度就上来了。参数GAP_HEIGHT改成随分数递减的表达式例如150 - score但记得设置下限否则难度会无限制上涨玩家十秒就心态爆炸。亲手调一调这些参数你会对游戏“手感”这个词有更深的体会。祝你的小鸟越飞越远。本文还有配套的精品资源点击获取
返回列表