☰
飞机大战小游戏项目源码java:Swing游戏循环与碰撞检测实战
2026/10/8 16:32:56 网站建设 项目流程

简介:这是一份面向Java初学者与课程设计学习者的飞机大战小游戏项目源码,适合用来练习面向对象编程、Swing图形界面与游戏循环等核心知识点,也可作为毕业设计或实训作业的参考实现。压缩包为zip格式,共73个文件,约774KB,其中包含7个java源文件与10个class编译文件,另有52张jpg图片资源用于飞机、爆炸动画等素材,以及project、classpath、prefs等Eclipse工程配置文件,导入后即可直接运行调试。目前已有444人学习下载,说明该案例在入门练手中具有一定参考价值。项目按src、bin、images等目录组织,代码与素材分离,结构清晰,便于读者理解游戏主循环、碰撞检测、敌机生成与分数统计等模块的实现思路,也能在此基础上自行扩展道具、关卡与音效功能。

1. 飞机大战小游戏项目源码java:从一份能跑的工程里拆出可复用的骨架

很多人搜「飞机大战小游戏项目源码java」,真正想要的不是一份压缩包,而是一套能看懂、能改、能跑起来的 Java 桌面小游戏骨架。飞机大战这个题材足够经典:玩家飞机跟随鼠标或键盘移动,敌机按时间轴生成,子弹与碰撞判定驱动整个游戏循环,分数和生命值收尾。它麻雀虽小,却把游戏开发里最核心的几件事全占了——渲染循环、输入响应、对象管理、碰撞检测、状态切换。对刚学完 Java 基础、想找一个课程设计案例源码练手的人来说,它比贪吃蛇复杂一点,比完整 RPG 简单得多,正好卡在「能独立写完」的区间里。这篇笔记就按一线做法,把这份源码工程该长什么样、每个类怎么落地、参数怎么调、哪里最容易翻车,一层层拆开讲清楚,让你拿到手就能复现,而不是对着别人的代码发呆。

2. 先定技术选型:Swing 还是 JavaFX,为什么多数飞机大战源码选 Swing

2.1 两种 GUI 方案在飞机大战里的真实差别

Java 做桌面小游戏,绕不开 Swing 和 JavaFX 两条路。网上流传的飞机大战源码,绝大多数是 Swing 写的,原因很实际:JDK 自带、零额外依赖、javax.swing.Timer直接就能驱动游戏循环,新手 clone 下来配好环境变量就能跑。JavaFX 从 JDK 11 起被移出标准库,要单独引依赖、配模块路径,对只想跑通一个课程设计的人来说,门槛凭空高了一截。

但 Swing 也不是没代价。它的渲染是单线程 EDT(Event Dispatch Thread)模型,所有绘制和事件都在同一个线程里排队,一旦你在paintComponent里写了耗时逻辑,画面立刻卡成幻灯片。JavaFX 有独立的渲染线程和场景图,动画更顺,但对飞机大战这种每秒 60 帧、几十个精灵的规模,Swing 完全够用。所以选型结论很直接:练手、课程设计、想快速看到效果,选 Swing;要做带粒子特效、复杂 UI 动画的商用级小游戏,再考虑 JavaFX。

这里有个容易被忽略的点:Swing 的Timer和javax.swing.Timer是两回事。游戏循环要用javax.swing.Timer,它把ActionListener的回调丢到 EDT 上执行,天然和绘制线程一致,不用自己处理线程同步。如果你用java.util.Timer或者自己开Thread,就得手动SwingUtilities.invokeLater把刷新请求转回 EDT,否则会出现画面撕裂甚至随机崩溃。这是血泪经验,别问我是怎么知道的。

2.2 一份标准飞机大战工程的目录结构

不管源码来自哪里,一个结构清晰的飞机大战工程,包结构基本长这样:

src/ main/ java/ com/example/plane/ GameFrame.java // 主窗口,继承 JFrame GamePanel.java // 游戏面板,继承 JPanel,核心循环在这 entity/ GameObject.java // 所有游戏对象的抽象基类 HeroPlane.java // 玩家飞机 EnemyPlane.java // 敌机 Bullet.java // 子弹 manager/ EnemyManager.java // 敌机生成与回收 CollisionDetector.java // 碰撞检测 util/ ImageLoader.java // 图片资源加载与缓存 GameConfig.java // 常量配置 resources/ images/ hero.png enemy.png bullet.png background.png

这个结构的好处是职责单一:GamePanel只管循环和绘制,实体类只管自己的状态和移动,EnemyManager管生成节奏,CollisionDetector管判定。新手常见的翻车写法是把所有逻辑塞进一个GamePanel里,几百行下去,改一个敌机速度要翻半天。分开之后,调参就是改GameConfig里的常量,加新敌机类型就是继承EnemyPlane,扩展性完全不一样。

提示:包名com.example.plane只是示例,实际用你自己的域名倒写。资源目录放在resources下,打包成 jar 后要用getClass().getResourceAsStream()读取,不能再用new File(),否则 jar 一运行就报找不到图片。

2.3 环境准备与最小可运行验证

动手之前先把环境确认一遍,避免跑起来一堆红叉:

# 确认 JDK 版本,Swing 项目 JDK 8 到 21 都能跑 java -version # 确认编译命令可用 javac -version # 如果要用 Maven 管理,初始化一个标准工程 mvn archetype:generate -DgroupId=com.example -DartifactId=plane-game -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false

java -version输出里重点看主版本号,JDK 8 和 JDK 17 在 Swing 上几乎没有行为差异,但如果你用了var或者switch表达式这类新语法,就得保证编译和运行是同一个版本。Maven 那条命令生成的是最朴素的工程骨架,pom.xml里不需要加任何游戏相关依赖,Swing 全在 JDK 里。生成后把src/main/java下的示例类删掉,按上面的包结构建目录即可。

验证环境是否真的能跑图形界面,写一个最小的窗口类:

import javax.swing.JFrame; import javax.swing.SwingUtilities; public class SmokeTest { public static void main(String[] args) { // 所有 Swing 组件必须在 EDT 上创建 SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame("冒烟测试"); frame.setSize(480, 700); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setLocationRelativeTo(null); // 居中显示 frame.setVisible(true); }); } }

这段代码的逻辑很简单:SwingUtilities.invokeLater保证窗口创建发生在 EDT 上,这是 Swing 的硬性要求,直接在主线程new JFrame()虽然多数时候也能显示,但在某些系统上会出现组件渲染不全的玄学问题。setLocationRelativeTo(null)让窗口居中,setDefaultCloseOperation保证点关闭按钮时进程真正退出,而不是留一个后台线程挂着。跑通这个窗口,说明你的图形环境没问题,可以进入下一步。

3. 核心循环与实体系统:把飞机大战的骨架搭起来

3.1 用 javax.swing.Timer 驱动 60 帧游戏循环

游戏的心跳是循环。飞机大战里所有东西——飞机移动、子弹飞行、敌机生成、碰撞判定——都挂在同一个循环上。Swing 里最稳的驱动方式是javax.swing.Timer:

import javax.swing.*; import java.awt.*; public class GamePanel extends JPanel { private static final int FPS = 60; private static final int FRAME_DELAY = 1000 / FPS; // 约 16ms private Timer gameTimer; public GamePanel() { setPreferredSize(new Dimension(480, 700)); setBackground(Color.BLACK); setFocusable(true); // 让面板能接收键盘事件 initGame(); startGameLoop(); } private void startGameLoop() { gameTimer = new Timer(FRAME_DELAY, e -> { updateGame(); // 先更新所有对象状态 repaint(); // 再请求重绘 }); gameTimer.start(); } private void updateGame() { // 移动玩家、子弹、敌机,检测碰撞,生成新敌机 } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 绘制背景、玩家、子弹、敌机、分数 } }

FRAME_DELAY取1000 / 60得到约 16 毫秒,这是 60 帧的常见目标。但要注意,Timer的间隔是「至少等这么久」,实际帧率受 EDT 上其他任务影响,不可能精确到 16ms。所以所有移动逻辑必须用「每帧位移量」而不是「每秒位移量」来写,否则帧率一波动,飞机速度就跟着变。比如玩家速度定义成speed = 5,意思是每帧移动 5 像素,而不是每秒 5 像素。这是新手最容易踩的坑之一,后面避坑章节还会细说。

updateGame()和paintComponent()的顺序不能反。先更新状态再绘制,画面才是当前帧的真实状态;如果先绘制再更新,你会看到所有对象慢一帧,快速移动时尤其明显。setFocusable(true)是为了让面板能拿到键盘焦点,否则你按方向键毫无反应,这个坑我见过太多人卡住。

3.2 抽象 GameObject 基类:统一移动、绘制与边界判定

所有游戏对象——玩家、敌机、子弹——都有共同属性:坐标、宽高、图片、是否存活。抽一个基类出来,后面所有实体都省事:

import java.awt.*; public abstract class GameObject { protected int x, y; // 左上角坐标 protected int width, height; // 碰撞盒尺寸 protected Image image; protected boolean alive = true; public GameObject(int x, int y, int width, int height, Image image) { this.x = x; this.y = y; this.width = width; this.height = height; this.image = image; } // 每帧更新自身状态,子类按需覆写 public abstract void update(); // 统一绘制入口 public void draw(Graphics g) { if (alive && image != null) { g.drawImage(image, x, y, width, height, null); } } // 矩形碰撞盒,用于碰撞检测 public Rectangle getBounds() { return new Rectangle(x, y, width, height); } public boolean isAlive() { return alive; } public void setAlive(boolean alive) { this.alive = alive; } }

getBounds()返回一个Rectangle,这是 Swing 自带的矩形类,后面碰撞检测直接用Rectangle.intersects()就行,不用自己写坐标比较。alive标志位是对象回收的关键:子弹飞出屏幕、敌机被击毁、玩家被撞,都只是把alive置为false,真正的移除交给管理器统一处理。这种「标记删除」模式避免了在遍历集合时直接remove导致的ConcurrentModificationException,是游戏开发里的标准做法。

draw方法里判断image != null是防御性写法。有时候图片加载失败,image会是 null,如果不判断,drawImage会抛空指针,整个游戏循环直接崩掉。宁可少画一个精灵,也不能让循环挂掉。

3.3 玩家飞机与键盘输入:让移动手感不飘

玩家飞机是唯一受输入控制的实体,手感好不好全看这里:

import java.awt.*; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; public class HeroPlane extends GameObject { private static final int SPEED = 5; private boolean left, right, up, down; public HeroPlane(int x, int y, Image image) { super(x, y, 60, 60, image); } public void setDirection(int keyCode, boolean pressed) { switch (keyCode) { case KeyEvent.VK_LEFT -> left = pressed; case KeyEvent.VK_RIGHT -> right = pressed; case KeyEvent.VK_UP -> up = pressed; case KeyEvent.VK_DOWN -> down = pressed; } } @Override public void update() { if (left) x -= SPEED; if (right) x += SPEED; if (up) y -= SPEED; if (down) y += SPEED; // 边界限制,防止飞出面板 x = Math.max(0, Math.min(x, 480 - width)); y = Math.max(0, Math.min(y, 700 - height)); } }

关键在setDirection用布尔标志记录按键状态,而不是在keyPressed里直接改坐标。为什么?因为键盘的keyPressed事件有系统级的重复延迟,按住不放时事件触发频率不稳定,直接改坐标会导致移动一顿一顿的。用标志位 + 每帧统一更新,移动就丝滑了。这是手感调优的核心技巧,很多源码没写对,玩起来就是飘。

边界限制用Math.max和Math.min夹住坐标,保证飞机不会跑出面板。注意这里的480和700是面板尺寸,实际项目里应该从GameConfig读,避免硬编码。KeyAdapter的绑定在GamePanel里做:

addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { hero.setDirection(e.getKeyCode(), true); } @Override public void keyReleased(KeyEvent e) { hero.setDirection(e.getKeyCode(), false); } });

keyPressed和keyReleased成对使用,按下置 true,松开置 false,这样才不会有「松了键还在动」的问题。

3.4 敌机生成节奏与子弹发射的参数怎么定

敌机和子弹是游戏节奏的两个旋钮。敌机生成太快,玩家来不及反应;太慢,又没挑战。常见做法是用一个帧计数器控制生成间隔:

public class EnemyManager { private int spawnCounter = 0; private static final int SPAWN_INTERVAL = 40; // 每 40 帧生成一架 private final List<EnemyPlane> enemies = new ArrayList<>(); public void update() { spawnCounter++; if (spawnCounter >= SPAWN_INTERVAL) { spawnCounter = 0; spawnEnemy(); } enemies.forEach(EnemyPlane::update); enemies.removeIf(e -> !e.isAlive()); } private void spawnEnemy() { int x = (int) (Math.random() * (480 - 50)); enemies.add(new EnemyPlane(x, -50, 50, 50, ImageLoader.load("enemy.png"))); } }

SPAWN_INTERVAL = 40在 60 帧下约等于每 0.67 秒出一架敌机,这是偏温和的节奏,适合练手。想加难度就减小这个值,比如 25 帧约 0.4 秒一架。spawnCounter用帧计数而不是毫秒计时,和游戏循环天然同步,不用处理时间漂移。removeIf配合alive标志,一行搞定回收,比手动遍历删除干净得多。

子弹发射同理,用一个冷却计数器:

private int fireCooldown = 0; private static final int FIRE_INTERVAL = 12; // 每 12 帧一发 public void tryFire() { if (fireCooldown <= 0) { bullets.add(new Bullet(hero.getCenterX(), hero.getY(), ...)); fireCooldown = FIRE_INTERVAL; } }

FIRE_INTERVAL = 12约每秒 5 发,手感适中。调太小子弹会糊成一条线,调太大又显得火力不足。这两个参数是飞机大战调优最常动的地方,建议都放进GameConfig统一管理。

4. 碰撞检测与资源管理:决定游戏能不能玩下去的两件事

4.1 矩形碰撞检测的实现与性能边界

飞机大战的碰撞判定用矩形就够了,不需要像素级精确。核心就一句Rectangle.intersects():

public class CollisionDetector { public static void checkAll(HeroPlane hero, List<Bullet> bullets, List<EnemyPlane> enemies) { // 子弹打敌机 for (Bullet b : bullets) { if (!b.isAlive()) continue; for (EnemyPlane e : enemies) { if (!e.isAlive()) continue; if (b.getBounds().intersects(e.getBounds())) { b.setAlive(false); e.setAlive(false); // 加分、播放爆炸效果 break; // 一颗子弹只打一架 } } } // 敌机撞玩家 for (EnemyPlane e : enemies) { if (e.isAlive() && hero.getBounds().intersects(e.getBounds())) { e.setAlive(false); hero.takeDamage(); } } } }

逻辑上分两层:子弹对敌机、敌机对玩家。每层都先判断alive,避免已经销毁的对象参与判定。子弹命中后break跳出内层循环,因为一颗子弹只应该打掉一架敌机,不 break 的话一颗子弹能连穿好几架,游戏就失衡了。

性能上,这是 O(子弹数 × 敌机数) 的双重循环。子弹几十发、敌机十几架的时候,每帧几百次矩形相交判断,对现代 CPU 来说毫无压力。但如果敌机上百、子弹几百,就该考虑空间划分了,比如按屏幕分格,只检测同格内的对象。飞机大战这个规模用不上,但你要知道边界在哪——当对象总数超过几百,双重循环就会开始吃帧,那时候再优化不迟。

4.2 图片资源加载与缓存:避免每帧读磁盘

新手最容易犯的性能错误,是在paintComponent里new ImageIcon("hero.png")。这意味着每秒 60 次磁盘 IO,游戏不卡才怪。正确做法是启动时加载一次,缓存起来:

import javax.imageio.ImageIO; import java.awt.Image; import java.io.InputStream; import java.util.HashMap; import java.util.Map; public class ImageLoader { private static final Map<String, Image> CACHE = new HashMap<>(); public static Image load(String name) { return CACHE.computeIfAbsent(name, key -> { try (InputStream is = ImageLoader.class.getResourceAsStream("/images/" + key)) { if (is == null) { System.err.println("图片未找到: " + key); return null; } return ImageIO.read(is); } catch (Exception e) { e.printStackTrace(); return null; } }); } }

computeIfAbsent保证同一张图只加载一次,之后全走内存。用getResourceAsStream而不是new File,是因为打包成 jar 后资源在压缩包里,文件路径方式读不到。/images/开头的斜杠表示从 classpath 根目录找,对应resources/images/目录。加载失败返回 null,配合前面draw里的 null 判断,游戏不会因为缺一张图就崩。

注意:ImageIO.read是同步阻塞的,如果图片很大或者很多,启动时会卡一下。飞机大战的图都很小,几十 KB 级别,可以忽略。但如果你的资源上百张,就该考虑异步加载加一个 loading 界面了。

4.3 游戏状态机:开始、进行、结束三态切换

一个完整的飞机大战不能一上来就开打,得有开始界面和结束界面。用枚举做状态机最清晰:

public enum GameState { READY, // 开始界面,等待按键 RUNNING, // 游戏进行中 GAME_OVER // 结束界面,显示分数 }

GamePanel里持有一个state字段,updateGame和paintComponent都按状态分支:

private void updateGame() { switch (state) { case READY -> { /* 等待空格键开始 */ } case RUNNING -> { hero.update(); enemyManager.update(); bulletManager.update(); CollisionDetector.checkAll(hero, bullets, enemyManager.getEnemies()); if (hero.getHp() <= 0) state = GameState.GAME_OVER; } case GAME_OVER -> { /* 等待 R 键重开 */ } } }

状态机的好处是所有逻辑分支一目了然,不会出现「游戏结束了敌机还在动」这种 bug。重开的时候记得把所有集合清空、玩家血量重置、分数归零,否则上一局的对象会残留到下一局。这个重置逻辑单独写一个resetGame()方法,别散在各处。

5. 避坑与排查:飞机大战源码跑不起来时先看这几条

5.1 画面卡顿、闪烁,飞机拖影

现象:游戏跑起来画面一顿一顿,快速移动的飞机后面拖一条影子。

原因:两个可能。一是没开双缓冲,Swing 默认在JPanel上是开启双缓冲的,但如果你重写了paint而不是paintComponent,或者手动getGraphics()绘制,双缓冲就失效了。二是游戏循环里做了耗时操作,比如每帧读图片、每帧 new 对象。

解决:确保重写的是paintComponent并调用super.paintComponent(g);图片用ImageLoader缓存;对象池化,子弹和敌机复用而不是每帧 new。检查updateGame里有没有Thread.sleep或者数据库、网络调用,有就挪出去。

5.2 按方向键飞机不动

现象:窗口显示正常,但键盘怎么按飞机都不动。

原因:面板没有获得焦点。Swing 里只有拥有焦点的组件才能接收键盘事件,JPanel默认不可聚焦。

解决:setFocusable(true)并且requestFocusInWindow()。如果面板被其他组件盖住,还要确保焦点没被抢走。另一个常见原因是KeyListener加在了JFrame上而不是GamePanel上,事件被窗口拦截了。统一加在GamePanel上最稳。

5.3 打包成 jar 后图片全部丢失

现象:IDE 里跑得好好的,mvn package打成 jar 双击运行,背景全黑,图片一个都不显示。

原因:用了new File("src/main/resources/images/hero.png")这种相对路径。jar 里资源不是文件系统上的文件,File找不到。

解决:全部改成getClass().getResourceAsStream("/images/hero.png")。同时确认pom.xml里resources目录被正确打包,Maven 默认会打包src/main/resources,如果你自定义了目录,要显式配置<resources>。

5.4 碰撞判定不灵敏,明明撞上了却没反应

现象:子弹擦着敌机边缘飞过,视觉上碰到了,但没判定命中。

原因:碰撞盒尺寸和图片显示尺寸不一致。getBounds()用的是width和height,如果这两个值和drawImage里的缩放尺寸对不上,碰撞盒就偏了。

解决:把碰撞盒尺寸和绘制尺寸统一,都在构造函数里定死。如果想让判定更宽松(对玩家友好),可以把碰撞盒缩小一点,比如实际 60×60 的飞机用 50×50 的判定盒。反过来想加难度就放大。这个值调一次就有手感了。

5.5 游戏运行一段时间后越来越卡

现象:刚开局很流畅,玩一两分钟后明显掉帧。

原因:对象泄漏。子弹和敌机标记alive = false后没有被真正移除,集合越来越大,每帧遍历和碰撞检测的开销线性增长。

解决:每个管理器在update末尾调用removeIf(e -> !e.isAlive()),把死对象清出去。检查所有往集合里add的地方,确认都有对应的回收逻辑。这是最隐蔽的坑,因为前几十秒根本看不出来。

6. 进阶技巧:把飞机大战源码改成能拿得出手的课程设计

6.1 用对象池替代频繁 new,帧率稳如老狗

前面提到对象泄漏,进阶做法是干脆不销毁,用对象池复用。子弹和敌机是高频创建销毁的对象,池化后 GC 压力骤降:

public class BulletPool { private final Deque<Bullet> pool = new ArrayDeque<>(); public Bullet obtain(int x, int y) { Bullet b = pool.pollFirst(); if (b == null) { b = new Bullet(x, y, ImageLoader.load("bullet.png")); } else { b.reset(x, y); // 复用前重置状态 } return b; } public void recycle(Bullet b) { b.setAlive(false); pool.offerFirst(b); } }

reset方法把坐标、存活标志重新设一遍,让旧对象像新的一样。池子用ArrayDeque做栈,pollFirst取、offerFirst还,都是 O(1)。这个技巧在对象数量大的时候效果明显,飞机大战里子弹最多几十发,收益不算夸张,但写进课程设计里是加分项,面试聊到也能说清楚。

6.2 加一个帧率显示,调参不再靠猜

调游戏节奏最怕凭感觉。加一个实时 FPS 显示,所有参数调整都有依据:

private int frameCount = 0; private long lastFpsTime = System.currentTimeMillis(); private int fps = 0; private void updateFps() { frameCount++; long now = System.currentTimeMillis(); if (now - lastFpsTime >= 1000) { fps = frameCount; frameCount = 0; lastFpsTime = now; } }

在paintComponent里用g.drawString("FPS: " + fps, 10, 20)画到左上角。正常应该稳定在 60 左右,如果掉到 30 以下,说明有性能问题,回去查对象泄漏或者耗时操作。这个数字是你调参时的后悔药,没有它,你永远不知道卡顿是代码问题还是心理作用。

6.3 难度曲线:让游戏越玩越紧张

固定生成间隔的游戏,玩久了会腻。加一个随时间递减的间隔,难度自然爬升:

private int currentInterval = 40; private static final int MIN_INTERVAL = 12; public void update() { spawnCounter++; if (spawnCounter >= currentInterval) { spawnCounter = 0; spawnEnemy(); // 每生成 10 架,间隔减 2 帧,最低到 12 if (currentInterval > MIN_INTERVAL && spawnCount % 10 == 0) { currentInterval -= 2; } } // ... }

currentInterval从 40 帧逐步降到 12 帧,敌机从每 0.67 秒一架变成每 0.2 秒一架,压力肉眼可见地涨。MIN_INTERVAL是下限,防止难度失控到没法玩。这个曲线具体怎么设,取决于你想要多硬核,我一般从 40 起步、每 10 架减 2,实测下来前两分钟体验比较顺。

6.4 从源码到课程设计:还差哪几块

一份能交课程设计的飞机大战,光能跑还不够,通常还要补这几块:分数持久化(用文件或 SQLite 存最高分)、音效(javax.sound.sampled播放 wav)、多种敌机(继承EnemyPlane,不同血量速度)、道具掉落(击毁敌机概率掉双发子弹或加血)。这几块每一块都是独立的扩展点,不会动到核心循环,正好用来展示你的设计能力。

我自己的习惯是先把核心循环和碰撞跑通,确认 60 帧稳定,再往上加功能。顺序反了的话,一旦卡顿你分不清是新功能的问题还是底层的问题。这套骨架搭好之后,加什么都是锦上添花。希望帮到你。

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

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

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

立即咨询