
很多学 Java 的朋友都会遇到同一个尴尬语法看着都懂但一上手写项目就不知道怎么把类、对象、事件这些知识串起来。我的建议一直很直接——别急着啃框架先拿一个小游戏练手。今天分享的就是一个用纯 Java Swing 开发的打飞机小游戏附完整源码只要 JDK 1.8 以上环境不依赖任何第三方库用一个记事本写完之后直接用 javac 编译就能跑。它麻雀虽小但把面向对象设计、事件驱动编程、图形绘制、碰撞检测、计时器调度这些 Java 开发中绕不开的知识点全串起来了。如果你刚学完 Java 基础、准备做课程设计或者单纯想找点编程乐趣这个项目都很适合。这个游戏的玩法很简单方向键控制飞机上下左右移动空格键发射子弹敌机随机从屏幕上方往下冲打中一架得 10 分被撞到就少一条命总共 3 条命用完了显示 Game Over按 R 重新开始。整个代码量我不卖关子单文件 300 多行你复制过去保存成PlaneGame.java就能跑。下面我先把设计和原理讲透再给完整源码最后把我实际开发过程中踩过的坑和扩展思路一起列出来。1. 写代码前先想清楚这几件事1.1 为什么用 Swing 而不是游戏引擎现在提起做游戏很多人第一反应是 Unity、Godot、LibGDX再不济也是 Python 的 pygame。但作为 Java 学习路径上的练手项目我反而强烈推荐先用最“土”的 Swing 写一次。原因很简单Swing 是 JDK 自带的 GUI 框架不需要安装任何额外环境你只要会 Java 基础语法就能上手。更重要的是Swing 小游戏把几个 Java 后端开发也常用的机制全部暴露在了表面事件监听键盘按键是怎么被程序感知的、线程调度定时器怎么驱动游戏循环、绘制模型窗口是怎么一帧一帧刷新的。这些东西平时学的时候都是零散的知识点做一个小游戏就能把它们缝合成一个整体。用游戏引擎当然也能做但引擎封装太深出了问题你根本不知道底层发生了什么反而不利于理解原理。我不是说游戏引擎没用而是想表达打基础阶段越接近底层越有价值。等你有过一两次这种“从零手写游戏循环”的经验再去看 Unity 里的 Update、FixedUpdate、碰撞体这些概念你会理解得比别人快得多。1.2 游戏里的核心对象和状态流转写代码之前先把游戏拆解成对象这是面向对象设计的第一步。这个打飞机游戏的核心对象有四个玩家飞机Player、敌机Enemy、子弹Bullet、爆炸效果Explosion。玩家飞机负责响应键盘输入在屏幕底部移动并且可以发射子弹。敌机从屏幕顶部随机位置出现持续向下移动。子弹从玩家飞机向上飞出碰到敌机就触发碰撞。爆炸效果不是游戏逻辑必需但它能大幅提升手感让玩家明确知道自己是否打中了。除了这些实体对象还要有游戏状态。我的实现里用boolean gameOver来标记游戏是否结束用score和lives记录分数和生命值。整个游戏的核心循环可以概括为四步处理输入、更新对象位置、碰撞检测、重新绘制。这个“输入-更新-检测-绘制”的循环就是所有游戏的骨架不管是打飞机还是 3A 大作本质都是一样的。1.3 帧率、速度和手感的平衡写小游戏最容易忽略的是手感。所谓手感就是速度、帧率、生成密度这些参数之间的配合。我在代码里把刷新间隔设为 16 毫秒也就是大约每秒 60 帧。为什么是 60 帧因为这是目前绝大多数显示器的刷新率在这个帧率下画面更新足够流畅人眼不会感到明显卡顿。参数方面我调整了好几轮玩家移动速度是每帧 6 像素换算成每秒就是 360 像素在 700 像素高的窗口里大约两秒能从底部跑到顶部这个速度既不会让人觉得迟钝也不会快到失去控制。子弹速度是每帧 10 像素比玩家快这样才有“子弹打出去”的感觉。发射间隔控制在 300 毫秒也就是每秒大约三发太密会太简单太疏则会让人着急。敌机生成概率用的是random.nextInt(100) 3即每帧有 3% 概率生成一架结合速度随机从 2 到 5 之间取游戏难度会逐渐累积但又不至于一上来就手忙脚乱。参数这种东西不需要精确的理论计算更多是靠试玩感受。我的经验是先把数值拍一个初始值然后跑起来玩十分钟哪里不舒服就调整哪里直到自己觉得“这游戏还挺好玩”为止。2. 核心实现原理把这些搞懂就通了一半2.1 JFrame JPanel游戏画面到底画在哪Swing 里有两个容易混淆的概念JFrame是窗口JPanel是面板。一个完整的 Swing 程序通常用JFrame创建窗口然后在窗口里添加各种组件其中JPanel是一种轻量级容器可以承载绘制操作。我的代码里定义了一个PlaneGame类它继承自JPanel并重写了paintComponent(Graphics g)方法。为什么要继承JPanel而不是直接在JFrame上画因为JFrame最核心的职责是管理窗口生命周期和装饰样式直接在上面画虽然也能画但一旦涉及布局、重绘、双缓冲等机制容易引入各种奇怪问题。JPanel天生就是用来“画内容”的语义更清晰。paintComponent是 Swing 绘制体系的入口当一个组件需要重绘时比如窗口被遮挡后重新暴露、或者调用repaint()Swing 会自动调用这个方法。所有游戏画面的渲染逻辑都放在这里面。2.2 双缓冲原理与绘图顺序有一个新手很容易踩的坑画面闪烁。闪烁的本质是绘制过程中发生了“先擦除、再绘制”的视觉间隙——你看到了一瞬间的空白背景。Swing 的JPanel默认启用了双缓冲机制系统先把整帧内容绘制到内存中的一张“幕后图像”上等所有图形都画完了再把这张图像一次性拷贝到屏幕上。这样屏幕上永远不会出现绘制了一半的中间状态也就消除了闪烁。理解了双缓冲你才能正确理解绘制顺序。paintComponent里的代码是分图层画的顺序很重要先画背景星空再画子弹、敌机、玩家、爆炸效果最后画 UI 文字。如果顺序反了比如先画玩家再画背景那么背景就会把玩家盖住。这种“先底层、后顶层”的画家算法是所有 2D 图形渲染的基本规则。2.3 Timer 驱动的游戏循环游戏要动起来必须有一个固定频率的“心跳”。我用的方案是javax.swing.Timer它的构造参数第一个是延迟时间毫秒第二个是ActionListener。在这里就是每过 16 毫秒Swing 的事件线程就会执行一次actionPerformed方法在这个方法里更新所有对象的位置然后调用repaint()触发重绘。为什么不直接用while(true) { Thread.sleep(16); }原因有几个第一Swing 的组件操作必须在事件分发线程EDT中执行你在自己创建的线程里调用repaint()或者修改组件的状态本质上是不安全的可能导致界面卡死甚至崩溃。第二Timer会自动处理帧率控制你不需要手动处理休眠和中断。第三Timer不会因为一帧绘制耗时过长而连续堆积回调。当然Timer也有缺点它严格依赖 EDT 调度如果某帧逻辑过重帧率会掉。但对于这种几十个对象的小游戏完全够用。2.4 键盘监听与按键状态管理键盘控制是交互的核心。我用了KeyListener接口的keyPressed和keyReleased回调最关键的细节是保存按键状态而不是在按下的瞬间执行动作。也就是说按下方向键时把leftPressed设为 true松开时设为 false然后在游戏循环里通过读取这些布尔变量来决定飞机是否移动。这样做的好处很直接你可以按住方向键实现持续移动而不是按一下动一下。这里有个新手最容易忽略的点JPanel默认不拥有键盘焦点如果不调用setFocusable(true)监听器收不到任何按键事件。这是 Swing 事件机制里非常经典的一个坑很多人写完代码发现“键盘没反应”90% 都是这个问题。具体原理可以这样理解键盘事件只会发送给当前拥有焦点的组件JPanel默认不能获得焦点所以必须先明确告诉 Swing“这个组件可以接受焦点”。2.5 碰撞检测用矩形相交模型解决问题碰撞检测听起来很高级但在 2D 游戏里有一个极其高效的简化模型把每个游戏对象的外包围盒看作矩形两个矩形有重叠区域就说明碰撞发生。java.awt.Rectangle类自带的intersects(Rectangle r)方法就是干这个的源码内部就是四个不等式比较判断两个矩形的左右边界、上下边界是否有重叠。它的时间复杂度是 O(1)非常快。在子弹打敌机的检测中我用双层循环遍历所有子弹和敌机一旦发现某个子弹与某架敌机相交就移除敌机、移除子弹、加分、生成爆炸效果。玩家被敌机撞击也是用同样的判定。这种矩形模型在大多数 2D 小游戏中已经足够精准了。有些团队会追求像素级碰撞用位图逐像素比对但性能和开发成本都不划算。做游戏要时刻记得一个原则视觉效果够用就行背后算法越简单越好。3. 完整源码实现与运行说明3.1 文件结构和依赖说明整个项目只包含一个 Java 文件PlaneGame.java。为了让代码便于复制和阅读我把主要逻辑放在了主类里然后把玩家、敌机、子弹、爆炸这四个游戏对象定义为独立类。这样做的第一好处是零配置直接javac编译即可第二好处是每个类的职责单一符合面向对象的基本设计原则。运行环境要求最低 JDK 8因为代码里用到了 Lambda 表达式。如果用的是 16、17、21 这些新版本也没问题Swing 在 JDK 里一直保持着良好的向后兼容性。代码里没有使用任何第三方库不需要 Maven 或者 Gradle 工程结构。3.2 完整源码下面是完整的PlaneGame.java源码我已经在我的电脑上编译运行过可以直接使用。import javax.swing.*; import java.awt.*; import java.awt.event.*; import java.util.ArrayList; import java.util.Iterator; import java.util.Random; /** * Java Swing 打飞机小游戏 * 运行方式javac PlaneGame.java java PlaneGame */ public class PlaneGame extends JPanel implements ActionListener { public static final int WIDTH 480; public static final int HEIGHT 700; private Timer timer; private Random random new Random(); private Player player; private ArrayListEnemy enemies; private ArrayListBullet bullets; private ArrayListExplosion explosions; private ArrayListPoint stars; private int score; private int lives; private boolean gameOver; private boolean leftPressed, rightPressed, upPressed, downPressed, spacePressed; private long lastShootTime; public PlaneGame() { setPreferredSize(new Dimension(WIDTH, HEIGHT)); setBackground(new Color(15, 15, 35)); setFocusable(true); initGame(); initStars(); addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { handleKeyPress(e.getKeyCode()); } Override public void keyReleased(KeyEvent e) { handleKeyRelease(e.getKeyCode()); } }); timer new Timer(16, this); timer.start(); } private void initGame() { player new Player(WIDTH / 2 - Player.SIZE / 2, HEIGHT - 120); enemies new ArrayList(); bullets new ArrayList(); explosions new ArrayList(); score 0; lives 3; gameOver false; lastShootTime 0; } private void initStars() { stars new ArrayList(); for (int i 0; i 80; i) { stars.add(new Point(random.nextInt(WIDTH), random.nextInt(HEIGHT))); } } private void handleKeyPress(int keyCode) { if (keyCode KeyEvent.VK_LEFT) leftPressed true; else if (keyCode KeyEvent.VK_RIGHT) rightPressed true; else if (keyCode KeyEvent.VK_UP) upPressed true; else if (keyCode KeyEvent.VK_DOWN) downPressed true; else if (keyCode KeyEvent.VK_SPACE) spacePressed true; else if (keyCode KeyEvent.VK_R gameOver) { initGame(); } } private void handleKeyRelease(int keyCode) { if (keyCode KeyEvent.VK_LEFT) leftPressed false; else if (keyCode KeyEvent.VK_RIGHT) rightPressed false; else if (keyCode KeyEvent.VK_UP) upPressed false; else if (keyCode KeyEvent.VK_DOWN) downPressed false; else if (keyCode KeyEvent.VK_SPACE) spacePressed false; } Override public void actionPerformed(ActionEvent e) { if (!gameOver) { updateGame(); } repaint(); } private void updateGame() { player.update(leftPressed, rightPressed, upPressed, downPressed); // 玩家射击 if (spacePressed) { long now System.currentTimeMillis(); if (now - lastShootTime 300) { bullets.add(new Bullet(player.x player.width / 2 - 3, player.y - 12)); lastShootTime now; } } // 随机生成敌机 if (random.nextInt(100) 3) { int enemyX random.nextInt(WIDTH - Enemy.SIZE); int enemySpeed 2 random.nextInt(4); enemies.add(new Enemy(enemyX, -Enemy.SIZE, enemySpeed)); } // 更新子弹 for (IteratorBullet it bullets.iterator(); it.hasNext(); ) { Bullet b it.next(); b.update(); if (b.y b.height 0) { it.remove(); } } // 更新敌机 for (IteratorEnemy it enemies.iterator(); it.hasNext(); ) { Enemy e it.next(); e.update(); if (e.y HEIGHT) { it.remove(); } } // 子弹击中敌机 for (IteratorBullet bit bullets.iterator(); bit.hasNext(); ) { Bullet b bit.next(); boolean hit false; for (IteratorEnemy eit enemies.iterator(); eit.hasNext(); ) { Enemy en eit.next(); if (b.getBounds().intersects(en.getBounds())) { hit true; eit.remove(); score 10; explosions.add(new Explosion(en.x Enemy.SIZE / 2, en.y Enemy.SIZE / 2)); break; } } if (hit) { bit.remove(); } } // 敌机撞击玩家 for (IteratorEnemy eit enemies.iterator(); eit.hasNext(); ) { Enemy en eit.next(); if (player.getBounds().intersects(en.getBounds())) { eit.remove(); lives--; explosions.add(new Explosion(en.x Enemy.SIZE / 2, en.y Enemy.SIZE / 2)); if (lives 0) { gameOver true; break; } } } // 更新爆炸效果 for (IteratorExplosion it explosions.iterator(); it.hasNext(); ) { Explosion ex it.next(); ex.update(); if (ex.life 0) { it.remove(); } } // 星空滚动 for (Point p : stars) { p.y 2; if (p.y HEIGHT) { p.y 0; p.x random.nextInt(WIDTH); } } } Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 星空背景 g2.setColor(new Color(255, 255, 255, 160)); for (Point p : stars) { g2.fillOval(p.x, p.y, 2, 2); } // 子弹 for (Bullet b : bullets) { b.draw(g2); } // 敌机 for (Enemy e : enemies) { e.draw(g2); } // 玩家 if (!gameOver) { player.draw(g2); } // 爆炸 for (Explosion ex : explosions) { ex.draw(g2); } drawUI(g2); if (gameOver) { drawGameOver(g2); } } private void drawUI(Graphics2D g2) { g2.setColor(Color.WHITE); g2.setFont(new Font(Dialog, Font.BOLD, 18)); g2.drawString(Score: score, 10, 28); g2.drawString(Lives: lives, WIDTH - 100, 28); } private void drawGameOver(Graphics2D g2) { g2.setColor(new Color(0, 0, 0, 180)); g2.fillRect(0, HEIGHT / 2 - 80, WIDTH, 120); g2.setColor(Color.RED); g2.setFont(new Font(Dialog, Font.BOLD, 40)); String over GAME OVER; FontMetrics fm g2.getFontMetrics(); g2.drawString(over, (WIDTH - fm.stringWidth(over)) / 2, HEIGHT / 2 - 20); g2.setColor(Color.WHITE); g2.setFont(new Font(Dialog, Font.PLAIN, 16)); String tip Press R to Restart; FontMetrics fm2 g2.getFontMetrics(); g2.drawString(tip, (WIDTH - fm2.stringWidth(tip)) / 2, HEIGHT / 2 25); } public static void main(String[] args) { SwingUtilities.invokeLater(() - { JFrame frame new JFrame(Java Swing Plane Game); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); frame.add(new PlaneGame()); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); }); } } class Player { public static final int SIZE 44; int x, y; int width SIZE; int height 46; int speed 6; Player(int x, int y) { this.x x; this.y y; } Rectangle getBounds() { return new Rectangle(x, y, width, height); } void update(boolean left, boolean right, boolean up, boolean down) { if (left x 0) x - speed; if (right x PlaneGame.WIDTH - width) x speed; if (up y 200) y - speed; if (down y PlaneGame.HEIGHT - height - 40) y speed; } void draw(Graphics2D g) { // 机身 g.setColor(new Color(0, 200, 255)); int[] bodyX {x width / 2, x width - 6, x width - 10, x 10, x 6}; int[] bodyY {y, y 14, y height, y height, y 14}; g.fillPolygon(bodyX, bodyY, 5); // 机翼 g.setColor(new Color(30, 140, 255)); int[] wingLX {x 8, x - 8, x 10}; int[] wingLY {y 20, y 36, y 36}; g.fillPolygon(wingLX, wingLY, 3); int[] wingRX {x width - 8, x width 8, x width - 10}; int[] wingRY {y 20, y 36, y 36}; g.fillPolygon(wingRX, wingRY, 3); // 驾驶舱 g.setColor(Color.WHITE); g.fillOval(x width / 2 - 5, y 8, 10, 12); } } class Enemy { public static final int SIZE 36; int x, y; int width SIZE; int height SIZE; int speed; Color color; Enemy(int x, int y, int speed) { this.x x; this.y y; this.speed speed; color new Color(200 new Random().nextInt(56), 40, 40); } Rectangle getBounds() { return new Rectangle(x, y, width, height); } void update() { y speed; } void draw(Graphics2D g) { g.setColor(color); int[] xs {x width / 2, x, x width}; int[] ys {y height, y, y}; g.fillPolygon(xs, ys, 3); g.setColor(Color.YELLOW); g.fillOval(x width / 2 - 3, y 10, 6, 6); } } class Bullet { int x, y; int width 6; int height 14; int speed 10; Bullet(int x, int y) { this.x x; this.y y; } Rectangle getBounds() { return new Rectangle(x, y, width, height); } void update() { y - speed; } void draw(Graphics2D g) { g.setColor(Color.YELLOW); g.fillRoundRect(x, y, width, height, 3, 3); g.setColor(Color.ORANGE); g.fillRoundRect(x 1, y height - 4, width - 2, 4, 2, 2); } } class Explosion { int x, y; int life 18; int maxLife 18; Explosion(int x, int y) { this.x x; this.y y; } void update() { life--; } void draw(Graphics2D g) { if (life 0) return; float ratio (float) life / maxLife; int radius (int) (38 * (1 - ratio) 10); g.setColor(new Color(255, (int) (200 * ratio), 50, (int) (200 * ratio))); g.fillOval(x - radius / 2, y - radius / 2, radius, radius); } }3.3 编译运行步骤拿到这段代码之后怎么跑起来这里分两种方式说明。如果你用的是 IDEA 或 Eclipse直接新建一个 Java 工程把PlaneGame.java复制进 src 目录然后运行 main 方法就行。如果你更喜欢命令行操作也非常简单。先把代码保存为PlaneGame.java然后打开终端切换到文件所在目录执行javac PlaneGame.java java PlaneGame编译时如果报编码错误多半是因为你的源码文件保存成了 UTF-8 而系统默认编码不是 UTF-8这时候只要加上-encoding UTF-8参数即可javac -encoding UTF-8 PlaneGame.java启动之后你会看到一个 480x700 的窗口背景是深蓝色星空底部有一架青色飞机。用上下左右方向键控制移动按住空格连续射击。每击中一架红色敌机左上角的分数就会增加 10 分右上角是剩余生命值。被敌机撞到会播放一次爆炸动画并且扣一条命三条命用完后画面中央会出现 Game Over 提示按 R 键可以重新开始。3.4 几个关键代码块的替代实现方案这里补充一个可以替换的实现方案帮助理解。如果你不想用KeyListener还可以使用KeyBindingsSwing 的键绑定 API。KeyBindings的优势是它不需要组件先获得焦点也不用自己维护按键状态的标志位而是直接把按键事件映射到Action对象。在游戏开发中KeyListener简单直接适合入门工程化的游戏界面里KeyBindings更健壮因为它天然支持按键映射和自定义 Action。我给这个项目保留KeyListener的唯一理由是它足够直观新手看代码的时候能一眼看懂回调逻辑不会被KeyBindings的复杂封装劝退。4. 踩坑实录和排查技巧4.1 我在开发中遇到的四个典型问题写这个小游戏的时候代码虽然不长但我在调试过程中踩了不少坑。这里挑几个最有代表性的问题连同排查思路一起列出来你如果遇到类似问题可以直接参考。第一个问题是键盘没反应。当时我把KeyListener加上了也实现了回调方法但启动后按方向键完全无效果。排查方法是用System.out.println在回调用打印日志发现回调根本没被触发。最后定位到原因JPanel默认不能获得键盘焦点。解决方法是调用setFocusable(true)。这个坑非常经典几乎所有 Swing 新手都会遇到一次记住它能为以后省下大量调试时间。第二个问题是并发修改异常。我最初的写法是直接用for (Bullet b : bullets)遍历集合然后在循环体里对同一个集合执行remove()。运行之后偶尔会抛出ConcurrentModificationException。原因是增强 for 循环的迭代器会在元素数量发生变化时检测到结构修改然后主动抛出异常。解决办法有两个方向一是用Iterator的remove()方法而不是集合的remove()二是先把需要移除的元素收集到一个临时列表循环结束后统一移除。我在最终代码里统一使用了Iterator方案这样代码更紧凑性能也更好。第三个问题是画面重绘不流畅。第一次跑起来时我明显感觉到飞机移动有轻微的一卡一卡感。排查后发现不是帧率问题而是我在actionPerformed里除了更新逻辑还直接调用了绘制代码导致绘制事件被切到了主线程之外和 Swing 自己的repaint机制产生了竞争。正确做法是actionPerformed里只做逻辑更新所有绘制全部交给paintComponent通过repaint()触发。这样事件的调度权始终保持在 Swing 的事件分发线程手里画面自然就顺畅了。第四个问题和游戏循环的边界有关游戏结束后Timer 其实还在运行玩家对象已经不绘制了但星空和背景仍然在动。我一开始把actionPerformed里的更新逻辑整个停了但后来又发现如果完全停止更新按 R 重新开始时有些状态没有完全重置比如旧的爆炸动画列表还存在。后来我把initGame()设计成一个完整的重置入口重新开始时直接调用这个方法所有对象重新初始化视觉效果和逻辑状态都回归初始。这是很多小游戏都容易犯的毛病——只重置了部分状态导致“脏数据”残留。4.2 常见问题速查表为了让你排查问题更方便我把上面这些常见问题的症状、原因和解决方案整理成一个对照表症状根本原因解决方案按键完全没反应JPanel 默认无法获得键盘焦点调用setFocusable(true)运行时报ConcurrentModificationException遍历集合时直接删除元素改用Iterator.remove()或先用临时列表收集再统一删除飞机移动一卡一卡逻辑更新和绘制混乱不在 EDT 中正常重绘逻辑更新统一放actionPerformed绘制统一在paintComponent用repaint()触发重新开始后出现残留爆炸或敌机只重置了部分游戏状态把initGame()做成完整重置方法并在 R 键处理中调用4.3 还能怎么扩展从“能玩”到“好玩”现在这个版本是一个功能闭环的完整小游戏但距离“好玩”还有很大的提升空间。我列几个实际可行的扩展方向你可以根据自己的兴趣和时间挑一个尝试。如果你想提升游戏深度可以考虑增加关卡系统随着分数提高逐步提高敌机生成概率、增加敌机速度或者在指定分数触发 Boss 战。Boss 的设计思路并不复杂它本质上就是一个血量更厚的敌机需要被子弹击中多次才会爆炸同时它还会左右移动甚至发射子弹。这会考验你对Enemy类的扩展能力和碰撞检测的多类型处理能力。如果你想丰富游戏表现可以加入道具系统比如随机掉落“火力增强”道具玩家捡到后从单发变成双发甚至三发或者“护盾”道具可以抵挡一次碰撞。这些道具在实现上只是增加了一个新的游戏对象类型但会让整个游戏的策略性一下子提升不少。声音效果和图片资源也可以加上用AudioClip或者Clip接口播放击中、爆炸的音效用图片替换现在用Graphics2D绘制的几何图形视觉表现力会更强。如果你更想锻炼代码架构能力建议按 MVC 思想重构代码把游戏逻辑从PlaneGame面板中剥离出去单独做一个GameModel类负责维护所有对象和状态GamePanel只负责渲染模型键盘监听转换成控制指令发给模型。重构之后你会发现自己对类和类之间职责边界的理解会深入一层。这也是从这个一百多行小游戏迈向更大型项目的一条很自然的路径。写在最后说实话做完这个打飞机小游戏对我而言最有价值的不是“我终于有了一个能玩的游戏”而是我把 Java 语法中零散的知识点真正缝合了起来。面向对象里的封装和职责分配事件驱动编程里键盘事件怎么被捕获和分发线程调度里 Timer 为什么不能直接用 Thread.sleep 代替图形界面里绘制顺序和双缓冲为什么重要——这些概念在书本上都是孤立的只有动手写下每个类、跑通每一帧之后你才会真正感觉到它们是一个整体。如果你自己动手实现了这个项目我建议你不要满足于“跑起来就行”可以试着改一个参数、增加一个功能、或者把代码重构一遍。改坏了大不了重新复制一份源码但每次尝试都会让你对 Java 的理解更深一层。这是任何教程和视频都替代不了的经验。