
简介这是一份面向Java初学者与课程设计学习者的飞机大战小游戏项目源码适合用来练习面向对象编程、Swing图形界面与游戏循环等核心知识点。压缩包共73个文件约774KB其中7个java源文件承载游戏主逻辑与实体类10个class为编译产物52个jpg图片构成飞机、爆炸动画与背景等素材另有project、classpath及prefs等Eclipse工程配置文件导入后即可直接运行调试。目录按src与bin分离源码与字节码plane、explode、images等模块划分清晰便于按功能定位代码。目前已有444人学习下载读者可借此理解碰撞检测、敌机生成、分数统计与资源加载等实现思路并在此基础上替换素材、调整难度或扩展道具系统是入门Java小游戏开发的实用参考案例。1. 飞机大战小游戏项目源码java一份能跑、能改、能写进简历的实战底稿很多人第一次搜「飞机大战小游戏项目源码java」心里想的其实不是「我要抄一份代码交作业」而是「我想找一个结构清楚、能编译、能改出自己东西的 Java 小游戏项目顺便把面向对象、线程、Swing 这些知识点串一遍」。这个诉求非常合理因为飞机大战几乎是 Java 课程设计和自学练手最经典的题材它足够小一天能读懂又足够完整能覆盖继承、多态、集合、定时器、碰撞检测、双缓冲绘图这些核心考点。但现实是网上流传的所谓「源码」大量是半成品——图片路径写死、主循环用Thread.sleep硬扛、碰撞检测用矩形硬套、关掉窗口线程还在跑。你拿到的不是底稿是坑。这篇笔记就按一线做法把一份可复现的 Java 飞机大战从工程结构、核心循环、碰撞判定到打包运行讲透让你拿到手就能改改完敢往简历上写。适合有 Java 基础、做过课程设计、想补一个完整小项目的人。2. 先把工程骨架立住Java 飞机大战的目录结构与技术选型2.1 为什么用 Swing 而不是 JavaFX 或 LibGDX选型这件事直接决定你后面踩多少坑。飞机大战这种 2D 竖版射击游戏核心需求只有三个定时刷新画面、响应键盘、画图片。Swing 的JPanel配合javax.swing.Timer就能全部搞定JDK 自带零依赖javac编译完java直接跑课程设计环境里最稳。JavaFX 从 JDK 11 起被移出标准库要额外配模块很多学校机房装的是 JDK 8一跑就报NoClassDefFoundError。LibGDX 是给商业 2D 游戏用的带 Gradle 构建和资源管线学习成本对一个小游戏来说过高。所以常见做法是Swing 做渲染和输入java.util.Timer或Swing Timer做游戏主循环ImageIO读图纯 Java 集合管理对象。这套组合在 JDK 8 到 JDK 21 上都能跑跨平台无压力。需要提醒的是Swing 不是线程安全的所有 UI 更新必须在事件分发线程EDT里做。这一点后面讲主循环时会重点说因为这是新手翻车最多的地方。2.2 一份能直接建起来的目录结构不要把所有类堆在一个包里那是「能跑但没法看」的典型。我一般会按职责分四个包结构如下plane-war/ ├── src/ │ └── com/ │ └── planewar/ │ ├── Main.java // 程序入口 │ ├── core/ │ │ ├── GamePanel.java // 主面板负责循环与渲染 │ │ └── GameConfig.java // 常量配置 │ ├── entity/ │ │ ├── GameObject.java // 所有游戏对象基类 │ │ ├── Hero.java // 玩家飞机 │ │ ├── Enemy.java // 敌机 │ │ ├── Bullet.java // 子弹 │ │ └── Explosion.java // 爆炸动画 │ └── util/ │ └── ImageLoader.java // 图片加载与缓存 └── res/ ├── hero.png ├── enemy.png ├── bullet.png └── bg.pngentity包放所有会动的东西统一继承GameObject这样碰撞检测和绘制就能用多态批量处理。core包放循环和配置util包放工具类。资源目录res和src平级编译时通过 classpath 读取不要用绝对路径。2.3 基类 GameObject 与常量配置先写基类把「位置、宽高、存活状态、绘制方法」抽出来这是整个项目能扩展的前提package com.planewar.entity; import java.awt.Graphics; import java.awt.Image; public abstract class GameObject { protected int x, y; // 左上角坐标 protected int width, height; // 碰撞盒尺寸 protected Image image; // 显示图片 protected boolean alive true; // 存活标记false 时从集合移除 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 boolean intersects(GameObject other) { return this.alive other.alive this.x other.x other.width this.x this.width other.x this.y other.y other.height this.y this.height other.y; } public boolean isAlive() { return alive; } public void setAlive(boolean alive) { this.alive alive; } public int getX() { return x; } public int getY() { return y; } public int getWidth() { return width; } public int getHeight() { return height; } }这段代码的关键点有三个。第一alive标记是对象池和集合清理的基础不要用null判断容易空指针。第二intersects用的是 AABB轴对齐包围盒矩形相交公式是标准的四边不重叠取反比逐像素检测快几十倍对飞机大战完全够用。第三draw里统一做了alive判断子类不用重复写。常量配置单独放一个类避免魔法数字散落各处package com.planewar.core; public final class GameConfig { public static final int WIDTH 480; // 窗口宽 public static final int HEIGHT 800; // 窗口高 public static final int FPS 60; // 目标帧率 public static final int FRAME_DELAY 1000 / FPS; // 每帧毫秒 public static final int HERO_SPEED 5; // 玩家移动速度 public static final int BULLET_SPEED 10;// 子弹速度 public static final int ENEMY_SPEED 2; // 敌机基础速度 private GameConfig() {} }参数说明WIDTH/HEIGHT决定画布尺寸竖版射击一般用 480×800 或 400×700别用全屏否则坐标换算麻烦。FPS设 60 是显示器和 Swing 定时器的舒适区设 30 会明显卡顿设 120 在低配机器上反而掉帧。FRAME_DELAY由 FPS 反推注意整数除法1000/60 得 16实际帧率约 62可以接受。速度参数后面调难度就改这里不要写死在实体类里。3. 主循环与渲染Swing Timer 怎么用才不卡不闪3.1 游戏主循环的两种写法与选择游戏主循环的本质是「每隔固定时间更新所有对象状态然后重绘画面」。Java 里常见两种写法一种是while(true)加Thread.sleep另一种是javax.swing.Timer。前者是很多教程的写法但它在 Swing 里是错的——你在子线程里调repaint()Swing 会把重绘请求丢进 EDT 队列子线程和 EDT 抢资源画面撕裂、输入延迟、关窗口卡死全来了。正确做法是用javax.swing.Timer它的回调本身就在 EDT 里执行update和repaint天然同步不需要额外加锁。package com.planewar.core; import javax.swing.*; import java.awt.*; public class GamePanel extends JPanel { private Timer timer; public GamePanel() { setPreferredSize(new Dimension(GameConfig.WIDTH, GameConfig.HEIGHT)); setDoubleBuffered(true); // 开启双缓冲消除闪烁 setFocusable(true); // 能接收键盘事件 initGame(); startLoop(); } private void initGame() { // 初始化玩家、敌机集合、子弹集合等 } private void startLoop() { timer new Timer(GameConfig.FRAME_DELAY, e - { updateGame(); // 更新逻辑 repaint(); // 请求重绘EDT 自动处理 }); timer.start(); } private void updateGame() { // 遍历所有实体调用 update()清理死亡对象生成新敌机 } Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 先画背景再画实体顺序决定遮挡关系 } }逻辑说明setDoubleBuffered(true)让 Swing 在内存里画好整帧再一次性贴到屏幕这是消除闪烁的关键很多人画面一闪一闪就是漏了这句。setFocusable(true)保证面板能拿到键盘焦点否则按键没反应。Timer的回调里先updateGame再repaint顺序不能反否则画的是上一帧状态。paintComponent里第一句必须调super.paintComponent(g)否则背景不刷新会拖出残影。3.2 双缓冲与背景滚动飞机大战的背景要往下滚制造飞机在前进的感觉。做法是准备两张背景图一张画在(0, y)一张画在(0, y - HEIGHT)每帧y 2当y HEIGHT时归零。这样两张图首尾相接无缝循环。private int bgY 0; private void drawBackground(Graphics g) { g.drawImage(bgImage, 0, bgY, GameConfig.WIDTH, GameConfig.HEIGHT, null); g.drawImage(bgImage, 0, bgY - GameConfig.HEIGHT, GameConfig.WIDTH, GameConfig.HEIGHT, null); bgY 2; if (bgY GameConfig.HEIGHT) { bgY 0; } }参数说明滚动速度2是像素每帧60 帧下约 120 像素每秒视觉上比较舒服。想更快就调大但别超过 5否则背景糊成一片。两张图的 y 坐标差正好是一个HEIGHT这是无缝的前提差一点就会看到接缝。3.3 键盘输入与玩家移动键盘响应用KeyAdapter监听keyPressed和keyReleased用一个boolean数组记录按键状态在update里根据状态移动而不是在事件回调里直接改坐标。这样做的原因是事件回调触发频率取决于系统按键重复率直接改坐标会导致移动速度不均匀按住时一顿一顿的。private boolean[] keys new boolean[256]; // 在 GamePanel 构造里注册 addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { keys[e.getKeyCode()] true; } Override public void keyReleased(KeyEvent e) { keys[e.getKeyCode()] false; } }); // 在 updateGame 里处理移动 private void updateHero() { if (keys[KeyEvent.VK_LEFT]) hero.moveLeft(); if (keys[KeyEvent.VK_RIGHT]) hero.moveRight(); if (keys[KeyEvent.VK_UP]) hero.moveUp(); if (keys[KeyEvent.VK_DOWN]) hero.moveDown(); if (keys[KeyEvent.VK_SPACE]) hero.shoot(); }逻辑说明keys数组用键码做下标256 足够覆盖常用键。keyPressed只置位keyReleased只清位移动逻辑统一在updateHero里按帧执行速度就均匀了。hero.moveLeft()内部要做边界判断x不能小于 0也不能大于WIDTH - width否则飞机飞出屏幕。4. 碰撞检测与对象管理飞机大战最容易写崩的两块4.1 矩形碰撞的边界与优化第 2 章基类里的intersects已经给了标准 AABB 实现这里说清楚它的边界。AABB 假设所有对象都是矩形飞机图片如果有透明边角视觉上没碰到但判定命中了玩家会觉得「玄学掉血」。解决办法有两个一是把碰撞盒调小比如实际图片 60×60碰撞盒用 40×40居中放置二是用圆形碰撞取中心点距离判断。飞机大战用第一种就够了改width/height即可。// 在 Hero 构造里图片 60x60碰撞盒缩到 40x40 居中 super(x, y, 40, 40, heroImage); // 绘制时按图片实际大小画碰撞按 40x40 算参数说明碰撞盒比图片小多少取决于图片透明边距。一般缩到 70% 左右手感最好。缩太多会出现「明明撞上了却没死」缩太少会出现「擦边就死」。这个值要自己试没有标准答案。4.2 用集合管理子弹和敌机避免 ConcurrentModificationException子弹和敌机是动态增删的用ArrayList存遍历时删除会抛ConcurrentModificationException。这是新手必踩的坑。正确做法是用Iterator显式删除或者用removeIf。private ListBullet bullets new ArrayList(); private ListEnemy enemies new ArrayList(); private void updateBullets() { IteratorBullet it bullets.iterator(); while (it.hasNext()) { Bullet b it.next(); b.update(); if (!b.isAlive() || b.getY() -b.getHeight()) { it.remove(); // 用迭代器删除安全 } } } private void checkCollisions() { for (Bullet b : bullets) { for (Enemy e : enemies) { if (b.intersects(e)) { b.setAlive(false); e.setAlive(false); // 播放爆炸加分 } } } // 统一清理死亡对象 bullets.removeIf(b - !b.isAlive()); enemies.removeIf(e - !e.isAlive()); }逻辑说明updateBullets里用Iterator.remove()这是遍历中删除的唯一安全方式。checkCollisions里先标记alive false循环结束后统一removeIf清理避免在嵌套循环里删元素。子弹飞出屏幕上边界y -height也要清理否则集合无限增长跑几分钟就内存泄漏。4.3 敌机生成与难度曲线敌机不能一上来就满屏要有节奏。常见做法是用一个计数器每帧累加达到阈值就生成一架阈值随分数降低。private int spawnCounter 0; private int spawnInterval 60; // 每 60 帧生成一架 private void spawnEnemy() { spawnCounter; if (spawnCounter spawnInterval) { spawnCounter 0; int x (int) (Math.random() * (GameConfig.WIDTH - 50)); enemies.add(new Enemy(x, -50, enemyImage)); // 分数越高生成越快最低 20 帧 spawnInterval Math.max(20, 60 - score / 100); } }参数说明spawnInterval初始 60 帧60 帧下约每秒一架。score / 100是每 100 分缩短 1 帧最低 20 帧封顶防止难度失控。敌机初始y -50在屏幕上方外慢慢往下飞进入视野才可见。5. 避坑与排查Java 飞机大战源码最常见的 5 个翻车点5.1 图片加载报 NullPointerException现象运行后画面全黑控制台报NullPointerException定位到drawImage。原因ImageIO.read返回null通常是资源路径写错。用new File(res/hero.png)在 IDE 里能跑打包成 jar 后就找不到因为 jar 内路径不是文件系统路径。解决统一用 classpath 读取getClass().getResourceAsStream(/res/hero.png)资源目录要加入 classpath。IDE 里把res标记为资源根目录。5.2 关掉窗口后程序还在后台跑现象点了关闭按钮窗口没了但任务管理器里 Java 进程还在。原因Timer没停或者用了非守护线程。解决给窗口加WindowListener在windowClosing里调timer.stop()然后System.exit(0)。Swing Timer本身不会阻止 JVM 退出但如果你自己开了Thread要设setDaemon(true)。5.3 按键没反应或延迟现象按方向键飞机不动或者要按好几下才动。原因面板没拿到焦点。JPanel默认不可聚焦必须setFocusable(true)并requestFocusInWindow()。如果面板放在JFrame里还要确保没有其他组件抢焦点。解决构造里加setFocusable(true)JFrame显示后调一次panel.requestFocusInWindow()。5.4 子弹和敌机越打越卡现象玩了一两分钟帧率明显下降。原因死亡对象没从集合移除集合越来越大每帧遍历开销线性增长。解决所有实体在alive false后必须从集合删除子弹飞出屏幕也要删。用removeIf每帧清理一次不要攒着。5.5 爆炸动画播完不消失现象爆炸图一直停在原地。原因爆炸动画用帧序列播完最后一帧没设alive false。解决Explosion类里维护currentFrame和frameCounter播完最后一帧后setAlive(false)由主循环统一清理。别在动画类里自己删自己职责要清晰。6. 从能跑到能交打包、调参与写进简历的进阶技巧把游戏跑起来只是第一步真正让它变成「作品」的是打包和调参。打包用 JDK 自带的jar命令先把 class 文件和资源目录整理好# 编译输出到 out 目录 javac -encoding UTF-8 -d out src/com/planewar/**/*.java # 拷贝资源到 out cp -r res out/ # 打包指定入口类 jar -cfe plane-war.jar com.planewar.Main -C out .参数说明-cfe里c是创建f指定文件名e指定入口类。-C out .表示切换到 out 目录再打包这样 jar 内路径才是com/planewar/...和res/...。打包完java -jar plane-war.jar就能跑发给别人也不用装 IDE。调参这块我一般会做一个隐藏的调试模式按F1切换显示碰撞盒按F2无敌按F3加 1000 分。这些不影响正常玩但自己测边界、测难度曲线时省大量时间。碰撞盒用g.setColor(Color.RED); g.drawRect(x, y, width, height)画出来一眼就能看出判定偏没偏。写进简历时别只写「实现了飞机大战」。要写清楚你解决了什么用 Swing Timer 替代 Thread.sleep 解决 EDT 线程安全问题用 AABB 加碰撞盒缩放优化判定手感用迭代器安全删除解决并发修改异常用双缓冲消除画面闪烁。这些才是面试官想听的。我见过太多简历写「熟悉 Java 面向对象」一问项目就说不清主循环怎么写的很吃亏。最后一个习惯每改一个参数先想清楚它影响哪一帧、哪个集合、哪个边界。飞机大战代码量不大但每一行都连着画面。我调敌机生成间隔时从 60 改到 20忘了同步改清理逻辑结果敌机堆满屏幕帧率掉到个位数血泪经验。希望帮到你。本文还有配套的精品资源点击获取