简介:在Java图形界面开发领域,Swing是构建桌面小游戏最经典的框架之一。游戏循环的稳定性、物理模拟的真实性以及碰撞检测的精准度,共同决定了一款休闲游戏的体验质量。通过剖析基于Swing Timer的定时刷新机制、重力加速度与跳跃初速度的数值模型、矩形相交判定算法,可以系统掌握Java游戏开发的底层逻辑。这类知识不仅适用于飞行躲避类游戏,也能迁移到其他2D小游戏的工程实践中。本文从工程结构、资源加载、主循环设计、碰撞优化到手感调参,完整拆解一个可运行的飞翔的小鸟JAVA版游戏案例,帮助初学者避开图片路径失效、画面闪烁、键盘焦点丢失等典型陷阱,并给出难度曲线的扩展思路。
1. 飞翔的小鸟 Java 版:一份能跑的完整代码到底包含什么
从搜索框敲下「java 飞翔的小鸟 游戏 编程」的人,一半是课程设计要交 Demo 的学生,一半是刚啃完 Java 基础想找个完整案例练手的新手。这两类人最常见的翻车现场一模一样:资源下载后一运行,图片路径报 NPE、中文乱码、键盘没反应,只能对着报错干瞪眼。这份飞翔的小鸟代码完整版(java 版)的价值不在于画面多精致,而是把 Swing 小游戏必需的骨架凑齐了——游戏循环、重力模拟、随机管道、碰撞检测、计分与状态切换。它纯 JDK 自带库实现,不用 Maven 拉依赖、不用配特殊环境,解压就能跑。学生党拿它当课程设计骨架改改界面交作业,初学者把它当源码教材,每读一个类,事件监听和面向对象这两块就算真落地了。
2. 运行环境与工程结构:JDK 怎么选、资源文件放哪
这类 Swing 小游戏对 JDK 版本非常不敏感,但「能用」和「省心」是两码事。我先说结论:首选 JDK 8。原因有两个,一是绝大多数课程设计和老教材都以 JDK 8 为目标环境,很多网传代码根本没按模块系统拆分,JDK 9 以后直接用 javac 编译会出现模块访问报错;二是 JDK 8 的 javax.swing 不需要额外配置,而 JDK 9+ 如果按模块化方式管理工程,还得在 module-info.java 里补 requires java.desktop 这类声明。如果你机器上已经装了 JDK 17 也没关系,普通 classpath 工程照样能跑,但与其排查模块问题,不如直接装一个 JDK 8 来得稳。至于下载渠道,去 java 官网 jdk 下载页面选一个 8u 的 Windows/Linux 安装包就行。
IDE 我一般推荐 IntelliJ IDEA Community 版,Eclipse 的工作空间概念对刚入门的人是个不必要的负担。用 IDEA 打开代码包时,只需要把它当成普通 Java 工程导入,不要选 Maven/Gradle 模板,因为这份代码根本不需要那套依赖管理。这里顺带说一句,凡是需要你配一堆 plugin 和 dependency 才跑得起来的小游戏,本身就是工程化过度,纯 Swing 代码正确的打开方式就是「一个 JDK、一个 IDE、一个入口类」。
2.1 JDK 版本与 IDE 选型
JDK 版本的坑值得再展开一点。很多人用 JDK 17 跑 8 写的代码,遇到 javax.swing 相关编译错误,第一反应是代码有问题,其实多半是模块化的问题。下面这个表格是我实际遇到的几种情况:
| 场景 | JDK 8 | JDK 11+ | 建议 |
|---|---|---|---|
| javac 直接编译 | 无模块限制,直接过 | 报 package javax.swing is not visible | 加 --add-modules java.desktop 或换 8 |
| IDEA 普通工程 | 无感知 | 无感知 | 两者都行 |
| 打包成可执行 jar | 正常 | 需在 MANIFEST 处理 | 优先 8 |
其实就算报错也有后悔药:在 javac 后面加一句 --add-modules java.desktop 就能编过,或者在 IDEA 里把 Module 的依赖加上 java.desktop。但是这门课如果老师要求的就是 JDK 8 环境,你没必要给自己找事,直接统一版本最省事。判断代码包到底是哪个 JDK 写的,有个土办法:看代码里有没有用 var 关键字或者 record 这类明显的新语法,有就是 11+,没有就默认 8 处理。
2.2 工程目录与入口类职责
下载解压后,先别急着点运行,花两分钟把目录结构认一遍。这类 Swing 小游戏普遍是单线程多类设计,典型的目录长这样:
flappy-bird/ ├── src/ │ ├── GameFrame.java # 入口:创建窗口并启动游戏 │ ├── GamePanel.java # 画布:主循环、绘制、碰撞检测 │ ├── Bird.java # 小鸟实体:坐标、速度、跳跃逻辑 │ ├── Pipe.java # 管道实体:位置、间隙、移动逻辑 │ └── res/ │ ├── bg.png # 背景图 │ ├── bird.png # 小鸟图 │ └── pipe.png # 管道图 └── out/ # 编译输出目录,首次编译后生成这份结构里最容易被新手忽略的是 res 目录。它必须放在 src 根下,而不是和 src 平级,也不是随便放哪都行,因为后面代码里加载资源走的是 classpath,而 src 根目录在编译后会变成 classpath 根目录。资源放在 src/res 下就意味着 classpath 根目录下会有一个 res 文件夹,getResource("/res/bg.png") 才能找到。
入口类的职责也要说清楚:main 方法放在 GameFrame 里,只做三件事——创建 JFrame、把 GamePanel 塞进去、调用 start() 启动主循环。凡是看到 main 方法里又写窗口又写游戏逻辑又写图片加载的代码,读起来会很痛苦,你接手后第一件事就是把它拆开。完整版代码如果作者没拆,你自己按这个结构拆一遍,也是一个非常好的重构练习。
2.3 素材不走相对路径:图片资源的三条放置规则
接下来是资源加载,这是 Swing 小游戏里最常见的翻车点。很多初版代码喜欢写ImageIO.read(new File("src/res/bg.png")),这种写法在 IDE 里碰巧能跑,但只要你把工程打包成 jar 分给别人,立刻找不到文件。正确姿势是走 classpath:
// 错误示例:用文件系统相对路径,打包即失效 // Image bg = ImageIO.read(new File("src/res/bg.png")); // 正确写法一:getResource 返回 URL,适合图片这类资源 Image bg = ImageIO.read(GamePanel.class.getResource("/res/bg.png")); // 正确写法二:getResourceAsStream 返回流,适合配置文件或网络环境 // Image bg = ImageIO.read(GamePanel.class.getResourceAsStream("/res/bg.png"));重点解释两个细节。第一是路径前那个反斜杠不是随手的,它表示从 classpath 根目录开始找,没有它就是在当前类所在包下找,包名一复杂就出错。第二是 getResource 返回 URL,如果资源不存在会返回 null,ImageIO.read(null) 不会给你一个亲切的「文件不存在」提示,而是直接抛 NPE,所以很多人看到 NPE 以为是小鸟对象没初始化,其实是图片没加载到。
习惯上我会把资源加载写成一个静态方法,统一处理 null 和抛异常:
public static Image loadImage(String path) throws IOException { URL url = GamePanel.class.getResource(path); if (url == null) { throw new IOException("资源不存在: " + path); } return ImageIO.read(url); }这层包装有两个好处。一是报错信息可读,资源路径写错了能立刻看出来是哪个文件的问题;二是后续代码里所有图片加载都走这一个入口,不会出现一个类用 File、一个类用 getResource 的混乱局面。你拿到完整版代码后,如果发现里面有用到 ImageIO.read 的地方,建议全部统一成这种写法,能省掉后面反复排查资源问题的力气。
3. 核心代码拆解:游戏循环、重力模型与碰撞判定的实现
这一章是整份代码最值钱的部分。很多所谓「完整版」代码,功能是能跑,但读起来一团乱麻——一个类里又画背景又管碰撞又算分数,你根本没法改。合格的做法是把每一块职责拆开,然后用一条主循环把它们串起来。下面我按「循环怎么转、小鸟怎么跳、管道怎么来、碰撞怎么判」四个问题来讲,你拿着完整代码对照着看,很快就能定位到对应的方法。
3.1 游戏主循环:Swing Timer 为什么比线程 + sleep 稳
小游戏和普通业务系统最大的区别就是有一个「每帧都在转」的主循环。Java 里做循环有三种常见方案,但适合 Swing 的只有一个:用 javax.swing.Timer。
public class GamePanel extends JPanel { private static final int FRAME_INTERVAL = 16; // 毫秒,约 60 FPS private Bird bird; private List<Pipe> pipes; private Timer timer; public GamePanel() { bird = new Bird(100, 300); pipes = new LinkedList<>(); // Timer 的回调在 EDT 线程执行,能安全操作 Swing 组件 timer = new Timer(FRAME_INTERVAL, e -> { bird.update(); for (Pipe pipe : pipes) { pipe.move(); } checkCollision(); repaint(); // 请求重绘,paintComponent 会被调用 }); } public void start() { timer.start(); } }这段代码逻辑不复杂,但有一个知识点值得停下来想清楚:为什么不能用while (true) { ... Thread.sleep(16); repaint(); }?因为 Swing 的组件有一套自己的线程模型,所有 UI 更新必须在事件分发线程(EDT)上执行。你在普通子线程里调用 repaint(),轻则画面不刷新,重则界面卡死,这是 Swing 开发的铁律。Timer 的设计目标就是解决这个问题,它的回调天然跑在 EDT 上,所以在回调里改 bird、改 pipes、调 repaint 都是安全的。
FRAME_INTERVAL 参数是控制帧率的关键,把 16 改成 25 就是 40 FPS,改成 33 就是 30 FPS。帧率低的小游戏画面会肉眼可见地一顿一顿,所以一般不要低于 30。这里还要提一句,如果你看到代码里用的是 java.util.Timer 或 new Thread + sleep,那这个版本大概率会有闪烁或者卡顿问题,参照这一节改成 Swing Timer 就行。
3.2 重力与跳跃:两个参数决定一套手感
小鸟的飞行手感,本质是两个数字的组合:重力和跳跃初速度。这是物理模拟里最简化的版本,不考虑加速度曲线,只用匀变速直线运动,对初学者来说完全够用。
public class Bird { private double x, y; // 坐标 private double velocity; // 垂直速度,单位:像素/帧 private final double gravity = 0.3; // 重力加速度:每帧速度增加量 private final int radius = 15; // 绘制半径,供 paintComponent 使用 public Bird(int x, int y) { this.x = x; this.y = y; } // 玩家按空格触发一次扇翅 public void flap() { velocity = -6.5; // 负值表示向上,绝对值决定跳跃力度 } // 每一帧都调用 public void update() { velocity += gravity; // 速度逐帧增加,模拟重力加速 y += velocity; // 速度叠加到坐标上 } }理解这段代码有一个先后顺序要绕明白:先改速度,再改坐标。flap() 直接把速度设成 -6.5,相当于给小鸟一个瞬间向上的冲量;之后每一帧 update() 先让速度朝正方向(向下)增加 0.3,再把速度叠到 y 坐标上。所以小鸟的轨迹是「快速上升 → 到达顶点 → 加速下落」,整体是一条抛物线。
想调整手感,只需要改两个数字:gravity 越大,小鸟坠得越快,操作越急躁;flap 的绝对值越大,每次跳跃抬升越高。我自己调试的时候习惯把 gravity 调到 0.25、flap 调到 -7,这样跳跃更有韧性一点,接近原版 Flappy Bird 的节奏。如果你的画布是 600 像素高,gravity 0.3 搭配 flap -6.5 大约能让小鸟跳起 70 像素,这个幅度在管道间隙 140 像素的场景下刚好能过关,数值选得太大反而让游戏变得太简单。
3.3 管道生成与移动:链表容器与随机间隙
管道和背景不一样,它需要无限生成,但又不能无限创建对象,否则内存和 GC 都会横插一脚。常见做法是用一个链表装所有「活着」的管道,不停地从右侧生成、左侧移除。这正好是一个 Java 容器使用的标准场景。
public class Pipe { public static final int WIDTH = 60; // 管道宽度 public static final int GAP_HEIGHT = 140; // 上下管道之间的间隙 private static final int SPEED = 2; // 每帧左移像素 private int x; // 管道左侧 x 坐标 private int gapCenter; // 间隙中心的 y 坐标 public Pipe(int startX, int gapCenter) { this.x = startX; this.gapCenter = gapCenter; } public void move() { x -= SPEED; } public boolean isOffScreen() { return x + WIDTH < 0; // 完全移出屏幕左侧说明可以回收 } public int getGapTop() { return gapCenter - GAP_HEIGHT / 2; } public int getGapBottom() { return gapCenter + GAP_HEIGHT / 2; } }gapCenter 的随机生成范围直接决定游戏难度。如果画布高度是 600,背景地面大约在 520 的位置,那么 gapCenter 在 180 到 380 之间随机比较合理——太靠上小鸟容易撞地,太靠下小鸟要飞很高才够得着。
生成和管理这些管道,我一般会在 GamePanel 里维护一个 LinkedList ,每帧移动管道并检查状态:
private void spawnAndRecyclePipes() { // 只有当最新一根管道已经走到画布中央偏右时,才生成下一根 if (pipes.isEmpty() || pipes.getLast().getX() < getWidth() - 260) { pipes.add(new Pipe(getWidth(), 180 + new Random().nextInt(200))); } // 移除已经离开屏幕的管道 pipes.removeIf(pipe -> pipe.isOffScreen()); }这里有两个参数值得记一下。260 是相邻管道的水平间距,间距太小会导致连续两个间隙叠在一起,玩家根本没有反应时间;间距太大游戏会显得空旷。180 + nextInt(200) 是随机范围,也就是 gapCenter 在 180~380 之间波动,你也可以根据自己对难度预期改成 150 + nextInt(260) 之类。removeIf 这一步看着简单,少了它,管道 List 会无限膨胀,几十分钟后游戏就会明显变卡。
3.4 碰撞检测:矩形相交与上下边界
碰撞检测的实现可以直接决定玩家对游戏的评价——明明没撞上却死了,比难还让人恼火。Swing 提供的 Rectangle 类自带 intersects 方法,所以绝大多数实现都把它拆成两个矩形来碰撞。
private void checkCollision() { Rectangle birdBox = bird.getCollisionBox(); // 内缩后的碰撞盒 for (Pipe pipe : pipes) { // 上管道:从画布顶部到间隙上沿 Rectangle upperBox = new Rectangle(pipe.getX(), 0, Pipe.WIDTH, pipe.getGapTop()); // 下管道:从间隙下沿到画布底部 Rectangle lowerBox = new Rectangle(pipe.getX(), pipe.getGapBottom(), Pipe.WIDTH, getHeight() - pipe.getGapBottom()); if (birdBox.intersects(upperBox) || birdBox.intersects(lowerBox)) { gameOver(); return; } } // 上下边界:飞出屏幕也算失败 if (bird.getY() < 0 || bird.getY() > GROUND_Y) { gameOver(); } }碰撞判定分三层,每一层都有讲究。第一层是拆成上下两个矩形,上管道从 y=0 到间隙上沿,下管道从间隙下沿到画布底,这样用一个循环就能同时检测两根管子。第二层是 birdBox 必须用内缩后的碰撞盒,这一点放到第 5 章避坑里详细说,这里先记住结论:图片 60x60,碰撞盒用 30x30 的矩形才接近真实边界。第三层是上下边界用坐标直接判断而不是再建矩形,因为撞到天花板不一定要碰撞盒,y < 0 就足够准确。
这里还要提醒一个隐藏 bug:管道通过小鸟之后,如果不加标记,检查循环里依然会检测这根管道,但此时小鸟已经飞到管道左边,矩形不重叠,不影响游戏。真正的隐患是加分逻辑——如果写的是「每根管道经过一次加分」,没有标记的话重复判断会导致分数一次加多次。完整代码里一般会写一个 hasPassed 字段,你在读代码的时候可以重点找一下。
4. 把代码跑起来:导入、编译、操作与三处手感调参
前面把代码结构讲完了,这一章解决「怎么让它真的跑起来」。老实说,一个纯 Swing 的 Java 小游戏,运行方式简单到没有悬念:要么 IDE 里点运行按钮,要么命令行 javac/java 两连。下面把两种方式都写一遍,再附上操作逻辑和我常用的三处调参位置。
4.1 命令行编译与运行
先给不喜欢开 IDE 或者想在 Linux 服务器上验证的同学。假设你已经把代码解压到 flappy-bird 目录,并且 JDK 8 已经加到 PATH 里,那么:
cd flappy-bird # 编译所有源码到 out 目录,-encoding 防止 Windows 下中文注释乱码 javac -encoding UTF-8 -d out src/*.java # 运行入口类,-cp 指定 classpath 根目录为 out java -cp out GameFrame这里的 -encoding UTF-8 是我特别叮嘱的。Windows 控制台默认编码是 GBK(或者新系统里的代码页 936),如果源码文件本身是 UTF-8 保存的,不加这个参数,注释和字符串里的中文就会变成乱码,严重的时候直接编译报错「非法字符」。加了之后编译阶段不再有编码问题。另外注意 javac 和 java 是连着两步,很多人只编译不运行,或者编译完忘了 -cp out,结果报「找不到或无法加载主类 GameFrame」,这一类问题第 5 章再系统讲。
还有个常见变体是代码包里直接带了 run.bat 这种启动脚本。脚本意思是一样的,但内容多半写的是java -jar flappy-bird.jar或者java -cp src GameFrame。前者需要先把资源打包进 jar,后者 classpath 指向 src 会让 IDE 工程和命令行混在一起,新手直接双击大概率起不来。我拿到这类脚本的习惯是:先看懂里面两个路径参数,改成java -cp out GameFrame再执行。
4.2 IDEA 导入与启动
用 IDEA 打开这个工程也有一个容易忽略的细节:导入的时候不要选 Maven 或 Gradle 模板。步骤是 File → New → Project from Existing Sources → 选中解压目录 → 保持默认 Create project from existing sources → 下一步时把 JDK 选成 8 → 完成。IDEA 会自己识别 src 目录为源码根目录,res 目录如果没自动识别为资源根目录,你需要右键 res 目录 → Mark Directory as → Resources Root。
代码在不同 JDK 之间切换也有讲究。如果导入后右下角提示 JDK 版本不对,就到 Project Structure → Project 里把 SDK 切到 8,Modules 页签里同样确认一下 Language level 选 8。做完这两步,一般就不会出现「代码里用了 List.of 之类新 API,编译却报错」的尴尬。启动时直接选中 GameFrame,点旁边的绿色运行按钮即可。
4.3 操作逻辑与游戏状态切换
跑起来以后,交互逻辑非常简单:按空格起跳。但这份代码里的游戏状态切换值得单独讲,因为它在几乎所有同类小游戏中都是模板级的存在。
public enum GameState { READY, RUNNING, OVER } private void handleKeyPress(KeyEvent e) { if (e.getKeyCode() != KeyEvent.VK_SPACE) { return; } switch (state) { case READY: case OVER: resetGame(); // 重置小鸟位置、清空管道、分数归零 state = GameState.RUNNING; timer.start(); break; case RUNNING: bird.flap(); break; } }三个状态把游戏生命周期分得很干净:READY 是启动等待画面,此时按空格开始;RUNNING 是正常游戏,空格触发跳跃;OVER 是碰撞后的结束画面,再按空格则是重开。用枚举而不是 int 常量来表示状态,在业务代码里也是好习惯,它的好处是编译器会帮你检查,不会出现 state == 2 这种魔法数字没人读得懂的问题。
值得注意的是 READY 和 OVER 两个状态共用了一段重置逻辑,这是刻意的。死完直接重开,不需要玩家再点一次鼠标或回车,符合这类小游戏的交互习惯。如果你想把「死后必须再等一秒才能重开」加进去,可以在 OVER 状态里加一个时间戳判断,简单但有效。
4.4 手感调参:gravity、flap 和 gapHeight
游戏能跑只是第一步,手感才是决定它是否「好玩」的关键。这份完整版代码里的几个参数,分别控制坠落速度、跳跃高度和通过难度,对应关系如下:
| 参数 | 位置 | 常见值 | 调整方向与影响 |
|---|---|---|---|
| gravity | Bird 类字段 | 0.3 | 越大坠落越快,操作需要更频繁 |
| flap 初速度 | Bird.flap() 中 | -6.5 | 绝对值越大跳得越高 |
| pipeSpeed | Pipe 类常量 | 2 | 越大整体游戏节奏越快 |
| gapHeight | Pipe 类常量 | 140 | 越小越难钻,越大越简单 |
调参最忌讳一次性乱改。我的做法是每次只改一个参数,跑三局,感受差别后再动下一个。先调 flap 初速度让自己觉得「跳得起来」,再调 gravity 找到「坠落不飘不坠」的中间值,最后调 gapHeight 和管道速度控制难度。如果改了 Bird 里的参数发现没有生效,先检查是不是有多个 Bird 类文件——有些版本会把旧类留在同包下面,IDE 编译时用了旧的。
这一章说完整理完,其实还缺一个验证手感的自动化手段,放到最后一章专门讲,这里先不展开。
5. 避坑指南:启动失败、画面闪烁、按键不灵的五个常见问题
运行这一类 Swing 小游戏 Demo,新手踩坑的概率比想象中高很多,而且坑位非常集中。这一章把我拆过多个 Java 小游戏代码后最常遇到的五个问题按「现象 → 原因 → 解决」写出来,每条都可以直接对照着排查。
5.1 图片加载失败:报错 NPE 的根源是资源路径
现象:窗口能弹出来,背景和小鸟全部是空白,控制台抛 NullPointerException,定位到 ImageIO.read 那行。
原因:图片路径写错或资源目录没被识别。多数初版代码用的是new File("src/res/bg.png")这种相对路径,它依赖「当前工作目录」恰好是工程根目录,只要启动方式一变(比如从命令行 java -cp out 启动),路径立刻失效。还有一种情况是 IDEA 里 res 目录没有被标成 Resources Root,编译后 classpath 里根本没有 res 文件夹。
解决:把资源加载统一改成GamePanel.class.getResource("/res/bg.png"),然后在 IDEA 里右键 res 目录,Mark Directory as → Resources Root。改完以后重新编译再跑,九成九的 NPE 就没了。如果还不放心,可以在加载方法里先打印 url 看有没有拿到非 null 值。
5.2 画面闪烁:把定时任务和重绘搅在一起
现象:游戏能玩,但画面疯狂闪烁,尤其管道移动的时候像在跳帧。
原因:闪烁的本质是同一帧画面被重复绘制或者绘制时机不对。最常见的情况是作者用了 java.util.Timer 或 new Thread,在子线程里 Thread.sleep(16) 然后调 repaint(),这会让重绘请求在两个线程之间乱跳,Swing 的双缓冲机制在这种情况下形同虚设。
解决:主循环换成 javax.swing.Timer,让回调稳定地在 EDT 上执行。另一个常见元凶是 JPanel 没有开启双缓冲。JPanel 默认是双缓冲的,但你如果在构造方法里调用过 setDoubleBuffered(false) 或把它塞进了 Canvas 组合,就要检查一下。一般情况下「Swing Timer + JPanel 默认双缓冲」就是最稳的组合。
5.3 键盘没反应:焦点根本不在画布上
现象:启动后画面正常,按空格完全没有反应,用鼠标点一下窗口画面才恢复响应。
原因:JPanel 默认不可聚焦,键盘事件只会派发给当前拥有焦点的组件。窗口刚启动时焦点在 JFrame 上,KeyListener 却加在 JPanel 上,事件根本没到它手里。
解决:在 GamePanel 构造方法加setFocusable(true),在 GameFrame 的窗口显示事件里调gamePanel.requestFocusInWindow()。这样窗口一弹出焦点就在画布上。还有个附带问题:按键用 keyPressed 而不是 keyReleased 判断时,长按空格会触发系统键盘自动重复,小鸟连续扑腾,操作会变得不可控。习惯上我会在 keyPressed 里加一个 300 毫秒的去抖标记,或者改用 keyReleased 触发跳跃。
5.4 游戏速度忽快忽慢:循环节奏没对齐
现象:管道移动速度时快时慢,有时一场游戏玩到一半突然加速。
原因:用 Thread.sleep 控制帧率时,sleep 只保证「最少等待」,不保证精确周期,加上 GC、窗口拖动带来的绘制开销,每一帧实际时间间隔是乱的。还有一种可能是代码里用了 System.currentTimeMillis() 计算时间差,但没在游戏开始前初始化上一次时间戳,导致第一帧 delta 异常大。
解决:最省心的方案还是固定周期 Swing Timer。如果你确实想要更精确的帧间间隔,可以用「记录上次更新时间 + 累加 delta」的方式,但注意要对 delta 做上限钳制,防止窗口最小化后恢复时一帧追回几秒的差距。下面是一段常用模板:
long lastTime = System.nanoTime(); double accumulator = 0; while (running) { long now = System.nanoTime(); accumulator += (now - lastTime) / 1_000_000_000.0; lastTime = now; accumulator = Math.min(accumulator, 0.05); // 防止卡顿后追帧 while (accumulator >= 0.016) { tick(); accumulator -= 0.016; } }这段的 0.016 就是 60 fps 的帧周期秒数。累加器的意义是把物理更新和渲染帧率解耦,积累够一个帧周期就更新一次。0.05 的上限是必须的,否则电脑卡了 3 秒恢复后会瞬间模拟上百帧,小鸟直接穿到地图外面。
5.5 碰撞判定太严:原图矩形直接判断的后果
现象:小鸟看起来只擦到管道边缘一两个像素,甚至完全没碰到,却判定死亡。
原因:图片加载后是矩形 Bitmap,小鸟素材四周通常有透明像素,直接用整个图片尺寸建 Rectangle,碰撞盒会比实际小鸟大一圈,视觉上自然产生「空气墙」。
解决:把 Bird 的碰撞盒改成内缩矩形。一般做法是让碰撞盒宽度和高度都是图片边长的一半左右,然后以小鸟中心为基准计算:
public Rectangle getCollisionBox() { // 60x60 的图片,碰撞盒用 30x30,留出透明边 int half = 30 / 2; return new Rectangle((int) x - half, (int) y - half, 30, 30); }这里 30 不是随便选的数字,它是「视觉边界」和「操作难度」的平衡点。太小会让玩家觉得游走在作弊边缘,太大就是空气墙。调试这个值有个土办法:把碰撞盒用半透明颜色画出来,跑几局看盒子和图片边缘差多少,这在第 6 章会给出具体代码。另外前面提过的已通过管道二次检测,也在碰撞逻辑里一并检查一下,确认每根管道只判定一次。
6. 验证与扩展:把碰撞盒画出来,再给它加点难度曲线
游戏做完不是终点,你怎么证明它「做对了」,才是代码能力的分水岭。这一章给两个实用技巧:一个是把碰撞盒可视化,用来验证手感时判断碰撞判定是否符合直觉;另一个是给游戏加难度曲线,让分数和管道速度联动,这是把它从「课程设计 Demo」升级到「可玩作品」的第一步。
6.1 可视化碰撞盒:三行代码看清死因
在 GamePanel 的 paintComponent 里,绘制完所有素材后,用半透明红色把 birdBox 画出来:
g2.setColor(new Color(255, 0, 0, 100)); // 最后一位是透明度 g2.fill(birdBox); // birdBox 就是碰撞检测用的 Rectangle跑起来以后,小鸟核心区域会被一个半透明红块标出来。你一眼就能看出两件事:一是红块是否和图片边缘对齐,如果偏大就把内缩系数调小;二是死之前红块到底和管道重叠了多少,能直接验证「是不是空气墙」。调试完记得加一个开关控制这段绘制,比如静态 boolean DEBUG=true,平时关闭,需要排查时打开。
6.2 难度曲线:分数驱动管道加速
静态难度的游戏玩三局就没意思了。一个最简单的动态难度方案是:每得 10 分,管道速度增加 0.3,间隙缩小 2 像素。实现时把 Pipe.SPEED 改成实例变量而不是静态常量:
public void onScoreChanged(int score) { // 每 10 分加速一次,速度上限设为 4,防止后期变成弹幕游戏 if (score % 10 == 0) { speed = Math.min(2 + score / 10 * 0.3, 4); } }调用时机放在加分逻辑里,也就是检测到小鸟通过管道成功得分的那一刻。注意处理一个边界:如果分数从 19 跳到 20,mod 10 为 0 才触发加速,这个判断没加的话每过一根管道都在加速,后段基本没法玩。难度曲线的调法也没有标准答案,我的习惯是把「通关率」控制在 30%~40%,这个数值是一个可玩性比较常见的区间。
讲到这里,这份 Java 版飞翔的小鸟的完整脉络就清晰了。说句实在话,我每次拆别人小游戏代码,第一件事从来不是跑起来,而是先把入口类和主循环找出来,确定它是 Swing Timer 还是线程 sleep,然后才敢碰后面那些类。这两个点不确认,后面所有调试都会踩在第 5 章那几个坑上。从那以后,我拿到任何人的 Java 小游戏代码,都是先画碰撞盒、再调两个手感参数,最后才谈扩展功能,顺序错了永远不知道一个 Demo 到底死于哪一帧。如果要用它来准备 Java 面试,鸟类拆类、管道容器管理、Timer 线程模型都是现成的聊资。希望帮到你。
本文还有配套的精品资源,点击获取