ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java Swing游戏开发:从Flappy Bird学GUI状态机与双缓冲绘图

Java Swing游戏开发:从Flappy Bird学GUI状态机与双缓冲绘图 简介这是一份面向Java初学者与数据结构入门者的课程设计级小游戏项目基于Swing GUI实现经典‘飞翔的小鸟’玩法兼顾算法逻辑训练如碰撞检测、状态机控制、计分机制与GUI编程实践。资源共61个文件包含7个核心Java源码文件含主程序、游戏面板、实体类等、8个编译后class文件、37张UI资源图角色动画帧、背景、按钮、字体及提示图以及4个配置XML和README.md说明文档整体压缩包仅827KB轻量易部署。已有259人下载学习适合课堂大作业参考、课设答辩素材或自学项目复现。下载即得完整可运行工程——含已验证的目录结构src/com/...标准包路径、全部图片资源、启动入口与清晰注释无需额外配置即可直接导入IDE运行是理解面向对象设计、事件驱动机制与游戏循环原理的优质练手案例。1. 为什么用 Java 写《飞翔的小鸟》不是“怀旧彩蛋”而是练透 GUI、事件循环与状态机的黄金靶场你可能在面试题里见过它「手写一个 Flappy Bird 简化版」也可能在课程设计清单上扫过它「Java GUI 小项目选题」但真正把它跑起来、调通重力加速度、让碰撞检测不漏帧、把分数逻辑和游戏状态切换写得清清楚楚——这根本不是“玩具代码”而是一次对 Java 基础能力的全链路压力测试。它不依赖 Spring、不碰数据库、不走 HTTP只靠java.awtjavax.swingTimer三件套就能暴露出你对线程调度、绘图双缓冲、键盘事件吞吐、对象生命周期管理的真实掌握程度。我带过的实习生里80% 能写出“小鸟能跳”但只有不到 20% 能说清为什么repaint()不在 EDT 外调用会卡顿为什么KeyAdapter比KeyListener更安全为什么GamePanel的paintComponent()里必须先super.paintComponent(g)这篇笔记不讲“怎么画一只鸟”而是带你用最朴素的 Java SE 技术栈把《飞翔的小鸟》拆成可验证、可调试、可延展的状态驱动系统——适合正在啃《Head First Java》第 12 章的初学者也适合想补全 GUI 实战断层的后端开发者。它不教八股文但每一步都在夯实你简历里那句“熟悉 Java 核心机制”的底气。2. 从零搭起游戏骨架用 Swing Timer 控制主循环拒绝 Thread.sleep() 硬等Java GUI 游戏最常翻车的第一步就是用while(true) { ... Thread.sleep(16); }自己造轮子。这不是“能跑就行”而是埋下线程阻塞、EDT 饥饿、输入延迟的定时炸弹。Swing 提供了更健壮的方案javax.swing.Timer—— 它天然绑定事件分发线程EDT保证所有回调都在 UI 线程安全执行且支持暂停/重启/精度调节。我们不用自己管线程同步只要告诉 Timer“每 16ms 执行一次游戏逻辑更新”。2.1 创建 GameLoopTimer 是心脏不是装饰// GamePanel.java public class GamePanel extends JPanel implements ActionListener { private static final int FPS 60; private static final int DELAY_MS 1000 / FPS; // ≈16ms private Timer gameTimer; private Bird bird; private ListPipe pipes; private int score; public GamePanel() { setPreferredSize(new Dimension(400, 600)); setBackground(Color.CYAN); setFocusable(true); requestFocusInWindow(); // 确保键盘事件能被捕获 initGame(); gameTimer new Timer(DELAY_MS, this); // 绑定 ActionListener gameTimer.start(); } Override public void actionPerformed(ActionEvent e) { updateGame(); // 更新小鸟位置、管道移动、碰撞检测 repaint(); // 触发重绘在EDT中安全 } }逻辑说明Timer构造时传入DELAY_MS16ms和this实现了ActionListener意味着每 16ms 触发一次actionPerformed()。这个方法里做两件事updateGame()纯逻辑计算无 UI 操作和repaint()异步请求重绘。repaint()不会立即执行绘图而是向 EDT 队列投递一个PaintEvent由 Swing 在合适时机调用paintComponent()。这是 Swing 线程模型的基石设计绕不开。参数说明FPS 60是行业通用目标帧率对应DELAY_MS 16。实际运行中Timer 并非绝对精准受 GC、系统负载影响但 Swing 会尽力补偿。若需更高精度如音画同步才需考虑java.util.concurrent.ScheduledExecutorService但对本项目属于过度设计。2.2 初始化游戏世界Bird、Pipe 与状态容器小鸟不是一张图片而是一个有物理属性的对象// Bird.java public class Bird { private int x 50; private int y 300; private int width 34; private int height 24; private double velocity 0; // 垂直速度单位像素/帧 private static final double GRAVITY 0.5; // 模拟重力加速度 private static final double JUMP_FORCE -10; // 向上跳跃初速度 public void update() { velocity GRAVITY; y velocity; // 地面碰撞检测简化版 if (y 550 - height) { y 550 - height; velocity 0; } } public void jump() { velocity JUMP_FORCE; } // getter/setter 略 }管道是成对出现的上下柱体用ListPipe管理每帧向左移动并在移出屏幕后移除// Pipe.java public class Pipe { private int x; private int topHeight; // 上管道高度从顶部开始 private int bottomY; // 下管道顶部Y坐标 private int width 60; private int gap 150; // 上下管道间空隙高度 private boolean passed; // 是否已被小鸟穿过用于计分 public Pipe(int x) { this.x x; // 随机生成上管道高度保证空隙在屏幕内 topHeight (int)(Math.random() * 200) 50; bottomY topHeight gap; passed false; } public void update() { x - 3; // 每帧向左移动3像素 } // 碰撞检测检查小鸟矩形是否与上下管道矩形相交 public boolean collidesWith(Bird bird) { Rectangle birdRect new Rectangle(bird.getX(), bird.getY(), bird.getWidth(), bird.getHeight()); Rectangle topPipe new Rectangle(x, 0, width, topHeight); Rectangle bottomPipe new Rectangle(x, bottomY, width, 600 - bottomY); return birdRect.intersects(topPipe) || birdRect.intersects(bottomPipe); } // 判断小鸟是否已穿过此管道用于计分 public boolean isPassed(Bird bird) { if (!passed bird.getX() x width) { passed true; return true; } return false; } }关键点Pipe的collidesWith()使用Rectangle.intersects()进行轴对齐矩形碰撞检测比手算坐标边界更鲁棒。isPassed()的逻辑是当小鸟的x坐标超过管道右边界x width时标记为已通过并返回true—— 这个true会被主循环捕获用于score。注意passed标志位防止同一管道重复计分。2.3 主循环核心updateGame() 的原子性与顺序updateGame()是游戏逻辑的中枢必须保证操作顺序不可颠倒private void updateGame() { // 1. 更新小鸟状态受重力/跳跃影响 bird.update(); // 2. 更新所有管道位置 for (Pipe pipe : pipes) { pipe.update(); } // 3. 生成新管道每隔约120帧即2秒 if (frameCount % 120 0) { pipes.add(new Pipe(400)); // 从右侧进入 } // 4. 移除移出屏幕的管道x -60 pipes.removeIf(pipe - pipe.getX() -60); // 5. 碰撞检测小鸟撞管道 or 撞地面/天花板 if (bird.getY() 0 || bird.getY() 550 - bird.getHeight()) { gameOver true; } for (Pipe pipe : pipes) { if (pipe.collidesWith(bird)) { gameOver true; break; } } // 6. 计分检测小鸟是否穿过新管道 for (Pipe pipe : pipes) { if (pipe.isPassed(bird)) { score; } } }为什么顺序重要必须先bird.update()再pipe.update()否则碰撞检测用的是旧位置必须先pipe.update()再removeIf()否则刚生成的管道就被删了碰撞检测必须在所有update()之后否则检测的是上一帧状态计分isPassed()必须在碰撞检测之后避免小鸟撞管瞬间还被计分。这就是状态机的严谨性——每一帧都是确定性快照顺序错一环逻辑就崩。3. 绘图不闪烁、不撕裂双缓冲与 paintComponent() 的正确打开方式新手最容易犯的错在paint()方法里直接绘图或者忘记调用super.paintComponent(g)。结果就是画面闪烁、背景残留、文字模糊。Swing 的绘图机制要求你必须遵循“双缓冲”规范而paintComponent()就是那个被框架自动调用的、安全的绘图入口。3.1 为什么必须重写 paintComponent()而不是 paint()paint()是顶层方法负责调用paintComponent()、paintBorder()、paintChildren()三步。如果你重写paint()就必须手动调用这三者否则边框、子组件都不显示。而paintComponent()只负责绘制组件自身内容是 Swing 推荐的、最轻量的绘图钩子。更重要的是Swing 默认为JPanel启用双缓冲Double Buffering但这个缓冲区只在paintComponent()中生效。一旦你重写paint()却没调用super.paint()双缓冲就失效了。3.2 正确的 paintComponent() 实现清屏 → 绘背景 → 绘元素 → 绘UIOverride protected void paintComponent(Graphics g) { super.paintComponent(g); // 【关键】调用父类实现完成双缓冲初始化和背景擦除 Graphics2D g2d (Graphics2D) g; g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 1. 绘制天空背景渐变蓝 GradientPaint skyGradient new GradientPaint(0, 0, Color.CYAN, 0, 300, Color.LIGHT_GRAY); g2d.setPaint(skyGradient); g2d.fillRect(0, 0, getWidth(), getHeight()); // 2. 绘制地面棕色条带 g2d.setColor(Color.DARK_GRAY); g2d.fillRect(0, 550, getWidth(), 50); // 3. 绘制小鸟简化为黄色圆圈红色三角形 g2d.setColor(Color.YELLOW); g2d.fillOval(bird.getX(), bird.getY(), bird.getWidth(), bird.getHeight()); // 小鸟朝向根据速度方向画小三角形嘴 int[] xPoints {bird.getX() 25, bird.getX() 35, bird.getX() 25}; int[] yPoints {bird.getY() 10, bird.getY() 12, bird.getY() 14}; g2d.setColor(Color.RED); g2d.fillPolygon(xPoints, yPoints, 3); // 4. 绘制所有管道 for (Pipe pipe : pipes) { // 上管道绿色 g2d.setColor(Color.GREEN); g2d.fillRect(pipe.getX(), 0, pipe.getWidth(), pipe.getTopHeight()); // 下管道绿色 g2d.fillRect(pipe.getX(), pipe.getBottomY(), pipe.getWidth(), 600 - pipe.getBottomY()); // 管道边缘高亮模拟3D效果 g2d.setColor(Color.DARK_GREEN); g2d.drawRect(pipe.getX(), 0, pipe.getWidth(), pipe.getTopHeight()); g2d.drawRect(pipe.getX(), pipe.getBottomY(), pipe.getWidth(), 600 - pipe.getBottomY()); } // 5. 绘制分数抗锯齿字体 g2d.setColor(Color.WHITE); g2d.setFont(new Font(Arial, Font.BOLD, 24)); String scoreText Score: score; FontMetrics fm g2d.getFontMetrics(); int textX (getWidth() - fm.stringWidth(scoreText)) / 2; g2d.drawString(scoreText, textX, 50); // 6. 绘制游戏结束提示 if (gameOver) { g2d.setColor(new Color(0, 0, 0, 180)); // 半透明黑色遮罩 g2d.fillRect(0, 0, getWidth(), getHeight()); g2d.setColor(Color.RED); g2d.setFont(new Font(Arial, Font.BOLD, 48)); String gameOverText GAME OVER; fm g2d.getFontMetrics(); textX (getWidth() - fm.stringWidth(gameOverText)) / 2; g2d.drawString(gameOverText, textX, getHeight() / 2); } }关键注释super.paintComponent(g)是强制步骤它做了三件事① 调用clearRect()擦除上一帧残留② 应用双缓冲策略将绘图指令先写入内存图像再一次性刷到屏幕③ 为后续Graphics2D操作准备上下文。漏掉它必然闪烁。setRenderingHint(... ANTIALIAS_ON)开启抗锯齿让圆形、文字边缘平滑。所有绘图操作fillRect,drawString都基于g2d而非原始g因为Graphics2D支持高级渲染控制。分数和 GAME OVER 文字使用FontMetrics动态计算居中位置避免硬编码坐标导致适配问题。3.3 键盘事件为什么 KeyAdapter 比 KeyListener 更安全KeyListener要求组件必须focusable且requestFocusInWindow()但焦点容易被其他组件抢走。KeyAdapter是KeyListener的适配器类只重写需要的方法如keyPressed且 Swing 推荐用getInputMap()getActionMap()的 InputMap/ActionMap 机制替代直接 addKeyListener但对本项目KeyAdapter已足够简洁安全// 在 GamePanel 构造函数中添加 this.addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() KeyEvent.VK_SPACE || e.getKeyCode() KeyEvent.VK_UP) { if (gameOver) { resetGame(); // 按空格重启 } else { bird.jump(); // 小鸟跳跃 } } } });为什么安全KeyAdapter是抽象类内部已实现keyTyped()和keyReleased()的空方法你只需关注keyPressed()addKeyListener(this)会将监听器绑定到GamePanel实例而GamePanel已setFocusable(true)并requestFocusInWindow()确保键盘事件能送达检查e.getKeyCode()而非e.getKeyChar()因为方向键VK_UP没有字符只有键码。4. 避坑指南那些让 Java 小鸟飞不起来的 5 个真实血泪经验写完代码一运行——小鸟不动、管道不生成、按空格没反应、分数乱跳、窗口闪退……别慌这些坑我全踩过。以下是生产环境即你的 IDE 控制台里最常出现的 5 个问题按现象、原因、解法结构化呈现拒绝玄学排查。4.1 现象小鸟完全静止velocity始终为 0update()像没执行原因Timer未启动或actionPerformed()根本没被回调。常见于忘记调用gameTimer.start()GamePanel构造函数中initGame()抛出异常如NullPointerException导致后续gameTimer new Timer(...)语句未执行Timer的DELAY_MS设为 0 或负数Timer 直接失效。解决在GamePanel构造函数末尾加日志System.out.println(GamePanel initialized, timer started: gameTimer.isRunning());在actionPerformed()开头加日志System.out.println(Frame frameCount , bird y bird.getY());检查initGame()中bird和pipes是否被正确实例化bird new Bird(); pipes new ArrayList();。4.2 现象管道生成后立刻消失或只生成一根就停止原因frameCount计数器未定义或未自增。frameCount % 120 0依赖一个全局帧计数器如果忘了声明private int frameCount 0;或在updateGame()结尾忘了frameCount条件永远为假。解决在GamePanel类成员中声明private int frameCount 0;在updateGame()方法末尾添加frameCount;临时将120改为30测试看管道是否高频生成确认逻辑通路。4.3 现象按空格小鸟不跳但控制台打印keyPressed日志原因KeyAdapter绑定到了错误的组件或焦点被抢占。GamePanel必须是当前焦点拥有者否则键盘事件发不到它身上。常见于JFrame添加了其他JButton或JTextField它们默认可聚焦抢走了焦点GamePanel的setFocusable(true)调用位置错误必须在addKeyListener()之前requestFocusInWindow()调用过早窗口尚未显示焦点申请失败。解决确保GamePanel是JFrame的唯一内容面板frame.setContentPane(gamePanel);将setFocusable(true)和requestFocusInWindow()放在gamePanel添加到frame之后、frame.setVisible(true)之前在keyPressed()中加日志System.out.println(Key pressed: e.getKeyCode());确认事件到达。4.4 现象游戏运行几秒后卡死CPU 占用 100%控制台无报错原因paintComponent()中发生无限递归或死循环。最隐蔽的元凶是在paintComponent()里调用了repaint()因为repaint()会再次触发paintComponent()形成死循环。解决严格审查paintComponent()方法体禁止出现任何repaint()、update()、paint()调用确保所有触发重绘的操作只在actionPerformed()或用户事件回调中调用repaint()用jstack命令jstack pid查看线程堆栈确认是否卡在paintComponent调用链中。4.5 现象小鸟穿过管道但分数不增加或分数狂涨每帧1原因isPassed()逻辑缺陷。常见错误isPassed()返回true后未设置passed true导致每帧都返回trueisPassed()的判断条件错误如bird.getX() x应为x width计分逻辑放在updateGame()开头此时管道位置还是旧的检测失效。解决检查Pipe.isPassed()方法确认passed标志位只在首次穿过时设为true在updateGame()中确保计分循环在pipe.update()之后、且在removeIf()之前临时在isPassed()中加日志System.out.println(Pipe at x passed? (bird.getX() x width));。提示所有日志输出务必用System.out.println()不要用System.err.println()因为后者可能被 IDE 过滤或颜色干扰导致你以为没输出。5. 让小鸟真正“活”起来状态机驱动、资源加载与可维护性升级写到这一步你的小鸟已经能飞、能跳、能撞管、能计分——但这只是 MVP。真正的工程化落地需要把“能跑”升级为“好维护、易扩展、真稳定”。本章不讲花哨特效而是聚焦三个实战中决定项目寿命的关键动作用枚举管理游戏状态、用ImageIO加载真实图片资源、用Properties解耦配置参数。每一步都来自我重构 7 个学生作业后的血泪教训。5.1 用 GameState 枚举替代布尔标志终结 if(gameOver) 的意大利面条gameOver布尔变量看似简单但随着需求增加暂停、菜单、关卡选择你会疯狂添加isPaused、inMenu、levelComplete……最终updateGame()里全是嵌套if-else。用枚举定义清晰状态让逻辑一目了然// GameState.java public enum GameState { RUNNING, // 正常游戏进行中 PAUSED, // 暂停状态空格键切换 GAME_OVER, // 游戏结束显示分数 MENU // 主菜单按M键进入 } // GamePanel.java 中替换 private GameState currentState GameState.MENU; // 初始状态为菜单 // 在 actionPerformed() 中 Override public void actionPerformed(ActionEvent e) { switch (currentState) { case RUNNING: updateGame(); break; case PAUSED: // 暂停时不更新逻辑只重绘显示暂停提示 break; case GAME_OVER: // 可加入动画效果如分数上升动画 break; case MENU: // 绘制菜单按钮等待用户选择 break; } repaint(); } // 键盘事件处理升级 this.addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { switch (currentState) { case RUNNING: if (e.getKeyCode() KeyEvent.VK_SPACE) { bird.jump(); } else if (e.getKeyCode() KeyEvent.VK_P) { currentState GameState.PAUSED; } break; case PAUSED: if (e.getKeyCode() KeyEvent.VK_P) { currentState GameState.RUNNING; } else if (e.getKeyCode() KeyEvent.VK_R) { resetGame(); } break; case GAME_OVER: if (e.getKeyCode() KeyEvent.VK_SPACE) { resetGame(); } break; } } });好处新增状态如LEVEL_COMPLETE只需在enum中加一行switch语句自动提醒你补全分支updateGame()方法瘦身只专注RUNNING状态下的逻辑状态流转清晰避免gameOver !isPaused这类难懂的布尔组合。5.2 用 ImageIO 加载 PNG 图片告别 System.out.println(Bird) 的 ASCII 艺术硬编码的fillOval和fillPolygon是教学演示上线必须换真实资源。ImageIO.read()是 Java SE 内置的、无需额外依赖的图片加载方案支持 PNG带透明通道、JPEG// GamePanel.java 中添加资源加载 private BufferedImage birdImage; private BufferedImage pipeImage; private BufferedImage backgroundImg; private void loadResources() { try { // 加载小鸟图片假设 resources/bird.png 存在 birdImage ImageIO.read(getClass().getResource(/resources/bird.png)); // 加载管道图片 pipeImage ImageIO.read(getClass().getResource(/resources/pipe.png)); // 加载背景 backgroundImg ImageIO.read(getClass().getResource(/resources/background.png)); } catch (IOException e) { e.printStackTrace(); // 降级如果图片加载失败仍用绘图代码兜底 System.err.println(Warning: Failed to load images, using fallback drawing.); } } // 在 paintComponent() 中替换绘图逻辑 Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d (Graphics2D) g; // 绘制背景图片拉伸填充 if (backgroundImg ! null) { g2d.drawImage(backgroundImg, 0, 0, getWidth(), getHeight(), null); } else { // 降级绘制 g2d.setColor(Color.CYAN); g2d.fillRect(0, 0, getWidth(), getHeight()); } // 绘制小鸟图片 if (birdImage ! null) { g2d.drawImage(birdImage, bird.getX(), bird.getY(), bird.getWidth(), bird.getHeight(), null); } else { // 降级绘制 g2d.setColor(Color.YELLOW); g2d.fillOval(bird.getX(), bird.getY(), bird.getWidth(), bird.getHeight()); } // 绘制管道图片上下各一个翻转上管道 if (pipeImage ! null) { for (Pipe pipe : pipes) { // 上管道垂直翻转 AffineTransform tx AffineTransform.getScaleInstance(1, -1); tx.translate(0, -pipe.getTopHeight()); Graphics2D g2dTop (Graphics2D) g2d.create(); g2dTop.transform(tx); g2dTop.drawImage(pipeImage, pipe.getX(), -pipe.getTopHeight(), pipe.getWidth(), pipe.getTopHeight(), null); g2dTop.dispose(); // 下管道正常绘制 g2d.drawImage(pipeImage, pipe.getX(), pipe.getBottomY(), pipe.getWidth(), 600 - pipe.getBottomY(), null); } } }资源路径说明getClass().getResource(/resources/bird.png)表示从 classpath 根目录找resources/bird.pngEclipse/IDEA 中将resources文件夹标记为 “Sources Root”图片就会被打包进 jarImageIO.read()返回BufferedImage可直接用drawImage()绘制null参数表示不使用ImageObserverGUI 绘图无需监听加载进度。5.3 用 properties 文件解耦配置告别硬编码的魔法数字GRAVITY 0.5、JUMP_FORCE -10、PIPE_SPEED 3……这些数字散落在代码各处改一个要搜全项目。用config.properties文件集中管理# config.properties gravity0.5 jump_force-10 pipe_speed3 pipe_gap150 game_width400 game_height600// GameConfig.java public class GameConfig { private static final Properties props new Properties(); static { try (InputStream is GameConfig.class.getResourceAsStream(/config.properties)) { props.load(is); } catch (IOException e) { e.printStackTrace(); } } public static double getGravity() { return Double.parseDouble(props.getProperty(gravity, 0.5)); } public static double getJumpForce() { return Double.parseDouble(props.getProperty(jump_force, -10)); } public static int getPipeSpeed() { return Integer.parseInt(props.getProperty(pipe_speed, 3)); } // 其他 getter... }// Bird.java 中使用 private static final double GRAVITY GameConfig.getGravity(); private static final double JUMP_FORCE GameConfig.getJumpForce(); // Pipe.java 中使用 private int speed GameConfig.getPipeSpeed();为什么值得做策划想调平衡性改config.properties不用动 Java 代码不重新编译想快速测试不同重力值写个脚本批量替换gravity值跑自动化测试团队协作时配置变更一目了然不会因某人手改GRAVITY 0.6导致版本冲突。最后送你一句我压箱底的习惯每次git commit前我会花 30 秒检查GamePanel.java里有没有System.out.println()。它不该出现在交付代码里但它是你调试时最忠实的战友。希望这篇笔记帮你把《飞翔的小鸟》从“Java 课设作业”变成“你能随时拿出来讲清楚每一行为什么这么写的底气”。希望帮到你。本文还有配套的精品资源点击获取
返回列表