资讯详情

Java Swing实战:从零开发打飞机小游戏(架构、碰撞检测与避坑)

📅 2026/10/4 7:41:23 | 华诺云谱 👁 阅读
Java Swing实战:从零开发打飞机小游戏(架构、碰撞检测与避坑)
我很久没碰Swing写小游戏了这次趁周末把打飞机重新撸了一遍。说实话用Java写这种经典小游戏远没有想象中那么简单光是线程同步和碰撞检测就能绕晕一堆刚入门的同学。但正因为如此它是个非常好的练手项目——麻雀虽小涉及的东西却相当完整窗口渲染、事件监听、游戏循环、对象管理、碰撞检测全都有了。如果你正在学Java想找点Swing之外的实战内容或者想从纯语法学习过渡到项目开发这篇应该正好适合。打飞机这类游戏精髓在于无限循环状态更新重绘。游戏每时每刻都在做三件事响应用户输入、更新所有物体的坐标、把画面重新画一遍。听起来很简单但要把这三个环节揉进同一个程序里还要保证画面不闪、操作不卡、碰撞判定准确就必须要有一个清晰的结构。Java的标准库提供了Swing和AWT虽然做不出3A大作但写一个2D小游戏绰绰有余而且开箱即用不需要额外的依赖很适合用来理解游戏开发的基本原理。我将按照一个完整的开发流程来讲从整体架构到每段核心代码再到我实际踩过的坑最后说说后续怎么接着玩。1. 游戏整体架构五个类足够撑起整个游戏打飞机游戏的核心对象就几样窗口、游戏面板、玩家飞机、敌机、子弹。不要一上来就设计几十个类那对一个小游戏来说完全是过度设计。合理的做法是先用最少的类把游戏跑通后面需要扩展再加。1.1 类的划分与职责我这次用了五个类加一个枚举每个类的职责非常单一类名职责关键说明GameFrame主窗口负责创建JFrame把GamePanel塞进去GamePanel游戏核心逻辑持有所有游戏对象负责更新和绘制是游戏循环的主战场PlayerPlane玩家飞机响应键盘控制发射子弹EnemyPlane敌机自动向下移动被子弹击中后消失Bullet子弹由玩家发射向上飞行与敌机碰撞GameState游戏状态枚举RUNNING, PAUSED, GAME_OVERGamePanel是整个游戏的中枢。它同时承担了两个角色游戏逻辑的容器和绘制画布。也就是说它继承了JPanel重写paintComponent方法做渲染同时又通过Runnable接口跑了一个独立的游戏循环线程。这种写法在小型项目中非常常见因为逻辑都集中在一处调试起来很直观。1.2 游戏循环的实现方式游戏循环是整款游戏的发动机。我用了Swing的SwingUtilities.invokeLater启动界面然后通过一个独立的Thread来跑while循环。线程里做了三件事更新游戏状态、重绘界面、控制帧率。关键代码是这样的Override public void run() { while (running) { long startTime System.currentTimeMillis(); update(); // 更新所有游戏对象的状态 repaint(); // 触发重绘 long endTime System.currentTimeMillis(); long duration endTime - startTime; long sleepTime Math.max(1, 16 - duration); // 目标60帧/秒16ms一帧 try { Thread.sleep(sleepTime); } catch (InterruptedException e) { e.printStackTrace(); } } }这里有个细节很多人会忽略游戏的帧率控制不应该通过Thread.sleep(16)硬等来实现因为update和repaint本身也要消耗时间。如果每帧固定睡16ms加上逻辑运行时间实际帧率会低于60。正确的做法是记录本次循环的耗时然后补足到16ms。如果逻辑耗时超过16ms那就直接跳过睡眠保证游戏尽量流畅。为什么这个项目的作者要强调的是System.currentTimeMillis()而不是System.nanoTime()在游戏循环这种毫秒级精度的控制中currentTimeMillis已经完全够用了。nanoTime主要用于性能基准测试或者需要更高精度计时的场景在小游戏中用它反而会因为过度设计增加理解成本。1.3 双缓冲为何能解决闪烁问题初学Swing绘图的人肯定会遇到一个经典问题画面闪烁。根本原因在于屏幕每次只更新一点点内容人眼就会感知到刷新过程。Swing内置了双缓冲机制它先把所有图形绘制到一块隐藏的缓冲区中等一帧完整绘制完成后再把缓冲区一次性地拷贝到屏幕上。这样用户看到的永远是一个完整帧而不是正在绘制中的半成品。在我的代码里只需要在JPanel的构造函数中调用setDoubleBuffered(true)剩下的就交给Swing框架了。不过需要注意如果你重写了paint方法而不是paintComponent双缓冲可能会失效。正确做法是永远重写paintComponent因为paint方法会负责更多底层操作新手在重写paint时很容易漏掉必要的调用。下面是GamePanel构造方法的代码public GamePanel() { setDoubleBuffered(true); setFocusable(true); setBackground(Color.BLACK); // 后面再绑定键盘监听 }public GamePanel() { setDoubleBuffered(true); setFocusable(true); setBackground(Color.BLACK); }setFocusable(true)这行尤其关键。如果忘记设置键盘监听可能永远不响应因为JPanel默认不拥有焦点。这个问题我后面在避坑部分还会再详细说。2. 游戏对象实现飞机、子弹和敌机的完整逻辑有了架构之后下一步就是把各个游戏对象具体实现出来。这一部分是代码量最大的环节也是初学最容易写乱的地方。我会按照玩家飞机、子弹、敌机的顺序来讲每个类都给出完整可用的源码。2.1 玩家飞机键盘控制是核心玩家飞机是最基础的游戏对象。在2D打飞机中它主要有两个动作根据键盘方向键移动按空格键发射子弹。飞机类本身只需要存储坐标、速度、宽高和用于表示飞机的图片或图形。为了不需要额外准备图片素材我用简单的几何图形来绘制飞机其实就是几个矩形组合起来加上一个三角形作为机头。这样游戏代码完全自包含拷贝下来就能跑。玩家飞机的移动逻辑很简单读取键盘状态然后根据方向改变x或y坐标。这里需要特别小心的是飞机的坐标更新不能直接在键盘事件处理器中做因为键盘事件是在事件分发线程EDT中执行的而游戏循环是另一个线程。如果在EDT中直接修改坐标很可能出现多线程并发修改的问题。正确的做法是在键盘事件处理器中只修改一个方向标志游戏循环在update阶段统一读取这些标志并更新坐标。public class PlayerPlane { private int x, y; private int speed 5; private final int width 60; private final int height 50; private boolean leftPressed, rightPressed, upPressed, downPressed; private boolean shooting; public PlayerPlane(int startX, int startY) { this.x startX; this.y startY; } public void update() { if (leftPressed x 0) x - speed; if (rightPressed x GamePanel.WIDTH - width) x speed; if (upPressed y 0) y - speed; if (downPressed y GamePanel.HEIGHT - height) y speed; } public void setDirection(int direction, boolean pressed) { // direction: 0left, 1right, 2up, 3down, 4shoot switch (direction) { case 0: leftPressed pressed; break; case 1: rightPressed pressed; break; case 2: upPressed pressed; break; case 3: downPressed pressed; break; case 4: shooting pressed; break; default: break; } } // 绘制方法机头用三角形机身用矩形 public void draw(Graphics g) { g.setColor(Color.WHITE); // 机头三角形 int[] xs {x width / 2, x width / 2 - 10, x width / 2 10}; int[] ys {y, y 20, y 20}; g.fillPolygon(xs, ys, 3); // 机身 g.fillRect(x 15, y 20, 30, 25); // 机翼 g.fillRect(x, y 25, 15, 15); g.fillRect(x width - 15, y 25, 15, 15); } // getter... }你可能注意到我画的飞机并不精致但这是一个很好的起步方式。**在游戏开发初期不要花太多时间做美术资源先用简单的几何图形把逻辑跑通后面再替换成图片。**这一点无论是做Java小游戏还是做大型游戏都是通用的原则。你可以先专注在逻辑上等版本跑起来了再换图片、加特效。玩家飞机还需要一个发射子弹的方法public Bullet shoot() { int bulletX x width / 2 - 2; int bulletY y - 10; return new Bullet(bulletX, bulletY); }这个方法本身很简单但它的调用时机需要在GamePanel中控制。不能让玩家按住空格就无限发射需要加上射速限制。我用的办法是记录上次发射时间每次发射后与当前时间比较间隔大于300毫秒才允许再次发射。2.2 子弹类简单但不能忽略边界子弹是所有对象中最简单的它只有一个移动方向向上。每一帧update中子弹的y坐标按速度向上移动当y坐标小于0时说明子弹已经飞出了屏幕应当从游戏中移除。public class Bullet { private int x, y; private final int speed 10; private static final int WIDTH 4; private static final int HEIGHT 10; private boolean visible true; public Bullet(int x, int y) { this.x x; this.y y; } public void update() { y - speed; if (y 0) { visible false; } } public void draw(Graphics g) { g.setColor(Color.YELLOW); g.fillRect(x, y, WIDTH, HEIGHT); } public boolean isVisible() { return visible; } public Rectangle getBounds() { return new Rectangle(x, y, WIDTH, HEIGHT); } }这里有一个细节为什么用Rectangle对象来表示碰撞范围因为AWT的Rectangle已经实现了intersects方法可以直接用于碰撞检测。后续碰撞检测的代码会非常简洁bullet.getBounds().intersects(enemy.getBounds())看着简单但实际上这个方法背后的原理是矩形相交判断即两个矩形是否在x轴和y轴方向都存在重叠区域。用现成的工具类比自己手写边界判断要可靠得多。讲到Bullet我得插入一个重要的点对象清理。很多新手在开发游戏的时候会无限制地往集合里添加子弹却忘了把飞出屏幕的子弹或者已经碰撞的子弹移除结果导致内存占用越来越高游戏越来越卡。**每个帧更新完都要遍历集合把不可见的对象清除掉。**我在GamePanel中是这样处理的bullets.removeIf(bullet - !bullet.isVisible());removeIf是Java 8引入的Collection接口方法配合lambda表达式使用非常方便。这也是为什么现在写Java游戏Java 8以下版本基本没法玩——太多新特性让代码变得简洁多了。2.3 敌机生成随机位置与移动逻辑敌机的行为比子弹复杂一些因为除了移动它还需要在屏幕顶部随机生成。生成逻辑如果在游戏循环里直接写会导致代码混乱。我选择在GamePanel中用独立的方法处理敌机的生成和更新。public class EnemyPlane { private int x, y; private final int speed 3; private final int width 45; private final int height 35; private boolean visible true; public EnemyPlane(int x, int y) { this.x x; this.y y; } public void update() { y speed; if (y GamePanel.HEIGHT) { visible false; } } public void draw(Graphics g) { g.setColor(Color.RED); // 敌机相对粗糙的几何图形 int[] xs {x width / 2, x width / 2 - 12, x width / 2 12}; int[] ys {y height, y 15, y 15}; g.fillPolygon(xs, ys, 3); g.fillRect(x 10, y, 25, 15); g.fillRect(x, y 8, 12, 20); g.fillRect(x width - 12, y 8, 12, 20); } public boolean isVisible() { return visible; } public Rectangle getBounds() { return new Rectangle(x, y, width, height); } public int getY() { return y; } }生成敌机的时机要控制好。如果生成太快游戏会变得过于困难如果生成太慢游戏就无聊了。我是这样做的在GamePanel中维护一个生成间隔计数器每过一定帧数生成一个敌机初始间隔是60帧即1秒一个随着游戏时间推进间隔逐渐减小。这样既简单又有效玩家会感受到难度的递进。private void spawnEnemy() { enemySpawnCounter; if (enemySpawnCounter enemySpawnInterval) { enemySpawnCounter 0; int x random.nextInt(WIDTH - 45); enemies.add(new EnemyPlane(x, 0)); // 难度递进: 每隔一段时间降低生成间隔 if (enemySpawnInterval 20) { if (score / 500 enemySpawnInterval / 10) { enemySpawnInterval - 2; } } } }评分标准和难度递进需要敲定一个平衡点。我测试下来初始1秒一个敌机每过500分减少20毫秒左右的生成间隔底线是0.3秒一个这个曲线玩起来是渐入佳境的感觉。你完全可以根据自己的手感调整这些数值。3. 核心机制详解碰撞检测与游戏状态管理打飞机小游戏最核心的机制就是碰撞检测。这个环节写不好游戏体验会非常糟糕子弹穿过了敌机却没判定命中飞机明明没有碰到敌机却莫名其妙爆炸了。下面我会详细拆解碰撞检测的算法设计、矩形边界的使用误区以及游戏状态的可视化切换。3.1 基于矩形相交的碰撞检测两种碰撞需要检测子弹与敌机、玩家飞机与敌机。前者的结果是击毁敌机后者的结果是游戏结束。用Rectangle的intersects方法代码简洁且准确// 子弹与敌机碰撞 for (IteratorBullet bulletIt bullets.iterator(); bulletIt.hasNext(); ) { Bullet bullet bulletIt.next(); for (IteratorEnemyPlane enemyIt enemies.iterator(); enemyIt.hasNext(); ) { EnemyPlane enemy enemyIt.next(); if (bullet.getBounds().intersects(enemy.getBounds())) { bullet.setVisible(false); enemy.setVisible(false); score 10; // 可以在这里加入爆炸效果先忽略 break; } } }这里使用Iterator的remove方法而不是removeIf是因为我们正在遍历的过程中需要同时修改集合。如果直接用for-each循环并在循环体内删除元素会抛出ConcurrentModificationException。这里有个值得注意的点碰撞检测是双循环时间复杂度是O(n*m)n是子弹数m是敌机数。在小游戏中这个量级完全没问题。但如果你后面想扩展成弹幕游戏子弹和敌机数飙升到成千上万这种暴力检测就不够用了需要考虑空间分区或四叉树优化。不过对当前规模来说最朴素的算法就是最好的选择。3.2 碰撞边界是否需要缩小很多做游戏的人会有一个体会玩家飞机的贴图是一个不规则的形状而碰撞检测用的是矩形边界。有时候玩家明明觉得我躲开了游戏却判定被击中这是因为矩形边界比实际飞机图形要大一圈。解决这个问题有两种常见思路。第一种是缩小碰撞矩形比图形本身稍微小一点给玩家更好的容错感受。第二种是使用像素级碰撞检测逐像素检查两个图形的透明度重叠精度极高但性能开销很大小游戏中完全没必要。我选择在创建Rectangle时对边界进行微调public Rectangle getBounds() { // 四周缩小6像素增加容错 return new Rectangle(x 6, y 6, width - 12, height - 12); }这个6像素是我反复测试后得到的数值。太小了容易产生明明碰上了却没检测到的挫败感太大了又会出现看起来没碰却死于空气墙的情况。不过这完全取决于你的游戏节奏可以在开发过程中多试几个值。3.3 游戏状态的可视化切换游戏状态我定义了三个枚举值RUNNING、PAUSED、GAME_OVER。不同的状态对应不同的画面和交互逻辑RUNNING正常游戏敌人继续生成玩家飞机正常移动和射击PAUSED画面冻结所有update逻辑暂停显示暂停提示GAME_OVER玩家飞机与敌机碰撞游戏结束显示得分和重新开始提示状态转移的代码放在了GamePanel的update和keyPressed逻辑中。在update的一开始会先判断当前状态如果不是RUNNING就直接跳过所有更新逻辑这样既省性能又防止游戏结束后敌人继续移动。判决玩家飞机与敌机的碰撞for (EnemyPlane enemy : enemies) { if (player.getBounds().intersects(enemy.getBounds())) { currentState GameState.GAME_OVER; running false; break; } }游戏结束后玩家按R键可以重新开始。重新开始需要做的事情包括清空所有子弹和敌机、重置玩家坐标、重置分数、重置生成间隔、把游戏状态重新设为RUNNING。private void resetGame() { bullets.clear(); enemies.clear(); score 0; enemySpawnInterval 60; enemySpawnCounter 0; player new PlayerPlane(WIDTH / 2 - 30, HEIGHT - 100); currentState GameState.RUNNING; running true; }这里给个测试中出现过的场景你按下R键重新开始时游戏循环线程可能还在上一次的迭代中如果直接重启线程会报IllegalThreadStateException。所以我的实现是把running置回true让原来的线程继续循环而不是重新new一个Thread。这个坑在避坑环节还会再提。4. 键盘监听与多线程协同避坑实战这一部分是从能编译到能玩的分水岭。无数初学者写完了代码逻辑却在交互环节卡住键盘没反应、画面撕裂、程序卡死。这些问题多数出在键盘监听和多线程协同上。4.1 键盘监听的两个坑焦点与线程第一个坑JPanel默认不响应键盘事件。要让它能监听键盘必须调用setFocusable(true)。但仅仅这样还不够当你在界面上添加了按钮等组件后焦点可能会被转移到其他组件上导致键盘又失灵了。最稳妥的做法是在显示窗口时主动请求焦点frame.addWindowFocusListener(new WindowAdapter() { Override public void windowGainedFocus(WindowEvent e) { gamePanel.requestFocusInWindow(); } });第二个坑键盘监听器在EDT中执行如果你在监听器中做耗时操作或者修改正在被游戏循环访问的数据就会引发并发问题。我的解决方案是使用标志位监听器只负责把按键状态记录下来真正的逻辑更新统一放在update()里面public class KeyHandler extends KeyAdapter { Override public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT: player.setDirection(0, true); break; case KeyEvent.VK_RIGHT: player.setDirection(1, true); break; case KeyEvent.VK_UP: player.setDirection(2, true); break; case KeyEvent.VK_DOWN: player.setDirection(3, true); break; case KeyEvent.VK_SPACE: player.setDirection(4, true); break; case KeyEvent.VK_P: togglePause(); break; case KeyEvent.VK_R: if (currentState GameState.GAME_OVER) { resetGame(); } break; } } Override public void keyReleased(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT: player.setDirection(0, false); break; case KeyEvent.VK_RIGHT: player.setDirection(1, false); break; case KeyEvent.VK_UP: player.setDirection(2, false); break; case KeyEvent.VK_DOWN: player.setDirection(3, false); break; case KeyEvent.VK_SPACE: player.setDirection(4, false); break; } } }这种方法把输入事件和游戏状态解耦了避免了并发修改的问题。KeyAdapter是抽象类不用实现Listener接口的所有方法很方便。4.2 发射间隔的精准控制player.setDirection(4, true)是按住空格时一直处于射击状态但如果每帧都发射子弹游戏画面就会被子弹淹没。需要在发射逻辑上做限制。我在GamePanel中维护了上一次发射的时间戳private long lastShootTime 0; private void handleShooting() { if (player.isShooting()) { long now System.currentTimeMillis(); if (now - lastShootTime 300) { Bullet bullet player.shoot(); bullets.add(bullet); lastShootTime now; } } }这样做的效果是玩家按住空格每300毫秒发射一颗子弹。300毫秒是个相对合理的射速如果你想要更爽快的射击感可以调到150或200如果觉得太简单了太无脑可以调高到500。4.3 暂停功能的优雅实现暂停功能虽然看起来简单但很容易写出Bug。常见的错误是暂停后游戏循环还在跑只是画面不更新了结果恢复时所有对象的位置都跳变了一大段。我这里的实现是让update方法直接跳过private void togglePause() { if (currentState GameState.RUNNING) { currentState GameState.PAUSED; } else if (currentState GameState.PAUSED) { currentState GameState.RUNNING; } }然后在update方法的最开头判断if (currentState ! GameState.RUNNING) { return; }由于循环仍然在跑只是不更新逻辑repaINT还是会被调用。这样无论暂停多少秒恢复后所有对象都保持在暂停前的位置不会出现跳变。这里需要注意暂停时应该把射击状态也清除掉。如果你在暂停时松开了空格在恢复之前按住了空格键盘事件的状态是对的keyPressed和keyReleased都会照常触发所以不需要特殊处理。4.4 重启线程的IllegalThreadStateException很多人会在重新开始游戏时写这样的代码new Thread(gamePanel).start();第一次启动时没问题但如果你在游戏结束后再次执行就会抛出IllegalThreadStateException。因为Java的线程不能重复启动一个线程执行完之后它的生命周期就结束了。正确的做法是游戏循环线程在启动后一直运行通过while(running)来控制系统状态。running是volatile变量保证多线程之间的可见性private volatile boolean running true;游戏结束时设置running为false但线程本身不退出或者退出了也没关系重新开始时不重启线程而是让循环继续。在我的实现中resetGame会把running重新设为truewhile循环会继续不需要创建新线程。4.5 画面闪烁测试的必要性代码写完之后你需要实际运行去判断画面是否有闪烁。有些电脑配置高闪烁不明显换台低配电脑可能就非常明显。Swing的双缓冲不总是百分之百有效尤其是在你重写了paint方法或使用了某些自定义组件时。如果你在实际测试中发现闪烁检查几个点是否调用了super.paintComponent(g)这是重绘背景和清除残留图形的关键是否调用了setDoubleBuffered(true)确保双缓冲已开启是否有综合性的绘制代码有可以尝试把所有绘制集中在paintComponent中不要分散到多个方法里Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 绘制背景 g.setColor(Color.BLACK); g.fillRect(0, 0, WIDTH, HEIGHT); // 绘制游戏对象 if (currentState GameState.RUNNING || currentState GameState.PAUSED) { for (Bullet bullet : bullets) { bullet.draw(g); } for (EnemyPlane enemy : enemies) { enemy.draw(g); } player.draw(g); } // 绘制得分 g.setColor(Color.WHITE); g.setFont(new Font(Arial, Font.BOLD, 18)); g.drawString(Score: score, 10, 25); // 绘制状态提示 if (currentState GameState.PAUSED) { g.drawString(PAUSED - Press P to Continue, WIDTH / 2 - 100, HEIGHT / 2); } else if (currentState GameState.GAME_OVER) { g.drawString(GAME OVER - Press R to Restart, WIDTH / 2 - 120, HEIGHT / 2); } }这里有个绘画顺序的细节背景先画然后是子弹再是敌机最后是玩家飞机。谁在上面谁在下面由绘制顺序决定。后绘制的会覆盖先绘制的。如果玩家飞机被敌机覆盖视觉上会错乱所以优先绘制远端的对象。5. 完整源码整合与启动入口前面所有代码都是分散在各个类中的现在把它们整合成完整的源码另外需要一个启动入口类。启动入口的作用是创建窗口并把GamePanel加入。5.1 启动入口类import javax.swing.JFrame; import javax.swing.SwingUtilities; public class GameMain { public static void main(String[] args) { SwingUtilities.invokeLater(new Runnable() { Override public void run() { JFrame frame new JFrame(打飞机小游戏); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); GamePanel gamePanel new GamePanel(); frame.add(gamePanel); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); gamePanel.requestFocusInWindow(); gamePanel.startGame(); } }); } }启动入口类非常简单但它隐含了几个重要的规范。Swing组件创建和操作必须发生在EDT事件分发线程中所以必须使用SwingUtilities.invokeLater。frame.pack()会根据面板的preferredSize自动调整窗口大小所以GamePanel需要重写getPreferredSize方法返回固定尺寸。GamePanel中的固定尺寸定义public static final int WIDTH 600; public static final int HEIGHT 800;在构造函数中设置setPreferredSize(new Dimension(WIDTH, HEIGHT));5.2 完整游戏状态控制方法GamePanel中需要几个控制游戏生命周期的方法public void startGame() { if (gameThread null || !gameThread.isAlive()) { gameThread new Thread(this); gameThread.start(); } } public void stopGame() { running false; }startGame判断线程是否为空或已经死亡避免重复启动。stopGame用于关闭窗口时能安全退出。在JFrame关闭时如果游戏循环是独立线程需要在线程中安全退出frame.addWindowListener(new WindowAdapter() { Override public void windowClosing(WindowEvent e) { gamePanel.stopGame(); } });其实setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)在窗口关闭时会终止整个进程包括所有后台线程。但假设你不止一个窗口或者以后要复用GamePanel主动关闭线程是更好的习惯。5.3 关于源码整理的说明由于整份源码涉及的类和方法较多这里把所有类文件的结构和关键方法罗列了一下代码组织方式和说明如下src/ └── com/game/ ├── GameMain.java // 启动入口 ├── GamePanel.java // 游戏核心逻辑 ├── GameState.java // 游戏状态枚举 ├── PlayerPlane.java // 玩家飞机 ├── EnemyPlane.java // 敌机 └── Bullet.java // 子弹每个文件包含的代码都在上面的章节中给出了核心实现剩下的就是把这些代码整合到一起。GamePanel中的完整update方法如下private void update() { if (currentState ! GameState.RUNNING) { return; } player.update(); handleShooting(); // 更新子弹 for (Bullet bullet : bullets) { bullet.update(); } bullets.removeIf(b - !b.isVisible()); // 生成敌机 spawnEnemy(); // 更新敌机 for (EnemyPlane enemy : enemies) { enemy.update(); } enemies.removeIf(e - !e.isVisible()); // 碰撞检测 checkCollisions(); // 难度递进 updateDifficulty(); }整个更新的顺序是玩家移动 → 发射子弹 → 更新子弹位置 → 生成敌机 → 更新敌机位置 → 碰撞检测。这个顺序不能乱。比如如果你先做碰撞检测再更新位置很可能出现子弹和敌机已经重叠了却要等到下一帧才能被检测出来的情况。因为碰撞检测应该在对象位置完成更新之后进行才能反映最新状态。6. 测试结果与性能观察记录写一点实际测试中的观察这部分对你自己复现很重要。第一我在测试中发现如果游戏循环的sleepTime设置得太小比如低于8ms视觉上画面会很流畅但是CPU占用率会飙升到30%以上。如果设置得太长比如33ms画面会明显卡顿大概30帧/秒的感觉。16ms是我测试下来的最佳平衡点对应60帧/秒CPU占用率和画面流畅度都在可接受范围内。第二子弹和敌机数量达到100个以上时碰撞检测的开销开始变得明显。如果出现卡顿先检查对象集合是否清理干净再检查碰撞检测循环是否做了无谓的重复计算。我在双循环中加入了一个break子弹命中一个敌人后就不再继续循环节省了大量无意义计算。第三画面闪烁问题在启用双缓冲后基本消失。但如果你是第一次写Swing游戏建议先做一个小实验分别在有双缓冲和没有双缓冲的情况下运行同样的代码亲眼看一次闪烁你才能真正理解双缓冲的意义。第四关于键盘按键冲突的问题。在打飞机游戏中玩家可能需要同时按住上和左键让飞机斜向移动。这在Swing中是天然支持的因为每个按键都有独立的keyPressed和keyReleased事件。但要注意有些键盘会限制同时按下的按键数量键位冲突这在机械键盘上比较常见属于硬件限制代码层面无法解决。第五另一个我在使用中踩到过的坑是游戏窗口失去焦点比如点击了其他窗口时键盘状态可能会残留。比如你正在按住左键这时候切到别的窗口keyReleased事件不会被触发当你切回来时飞机还在自动向左移动。解决方案是在窗口失去焦点时清空所有按键状态frame.addWindowFocusListener(new WindowAdapter() { Override public void windowLostFocus(WindowEvent e) { gamePanel.clearKeyStates(); } });这个细节很容易被忽略但对用户体验的影响却很明显。7. 后续扩展从能玩到好玩游戏跑通之后你会发现它还很粗糙。接下来你可以尝试以下几个方向按难度递增排序7.1 增加音效与背景音乐Java标准库不直接支持常见格式的音频播放但可以使用Clip接口播放wav格式的音效。音效文件可以用工具生成网上也有很多免费资源。给子弹增加发射音效、给敌机增加爆炸音效游戏的沉浸感会大幅提升。Clip shootSound AudioSystem.getClip(); shootSound.open(AudioSystem.getAudioInputStream(new File(shoot.wav))); shootSound.start();注意Clip播放必须等文件加载完成而且频繁创建Clip对象会产生大量垃圾对象。比较好的做法是创建一个音效管理器类复用Clip实例。7.2 增加不同种类的敌机现在只有一个敌人类型玩久了会单调。可以设计几种不同的敌人移动速度慢但血量厚的大型敌机、移动轨迹呈S形的中型敌机、移动速度快且可以发射子弹的小型敌机。实现方式上可以给EnemyPlane增加一个类型枚举不同类型的更新逻辑稍有不同。如果是继承体系来设计可以先建一个Enemy基类再派生出不同子类但风险是类会膨胀对小项目来说用枚举和条件判断反而更直观。7.3 Boss战系统打飞机游戏不能没有Boss。设计Boss需要考虑几点Boss血量条如何显示、Boss的射击模式如何变化、击败Boss后的奖励是什么。最简单的方式是每过关一段时间比如每500分屏幕上方出现一个大型敌机它每隔一段时间发射一轮扇形弹幕玩家需要一边躲避一边攻击。Boss的坐标更新逻辑和普通敌机不同它不会向下移动而是在屏幕顶部左右横移。7.4 使用图片替换几何图形把玩家飞机和敌机的几何图形替换成真实图片游戏的视觉体验会有质的提升。要注意的是图片加载最好在游戏启动时完成而不是在每一帧绘制时读取否则会因为磁盘I/O导致性能下降。图片格式首选png因为它支持透明背景可以做出更好的视觉效果。// 加载图片 private Image playerImage; public GamePanel() { try { playerImage ImageIO.read(getClass().getResource(/images/player.png)); } catch (IOException e) { e.printStackTrace(); } } // 绘制 g.drawImage(playerImage, x, y, null);7.5 单位置保存与排行榜如果游戏的目标是做得更完整可以加上本地排行榜把历史最高分保存到一个配置文件中。用Preferences API或者简单的文本文件都可以。Preferences是Java标准库提供的跨平台配置存储方案在Windows上会写入注册表在Linux上会写入用户目录。做游戏排行榜Preferences已经够用了。Preferences prefs Preferences.userNodeForPackage(GameMain.class); int highScore prefs.getInt(highScore, 0); if (score highScore) { prefs.putInt(highScore, score); }这些小功能看着不起眼但每加一个都能学到新东西。游戏开发的乐趣就在于你永远可以用最低的成本把想法变成可以玩的原型然后一步步完善它。我个人实际跑这个项目时最大的感受是写完了怎么实现是第一步真正把这几个类组织成一个可扩展的架构比一次性把代码写出来要花更多心思。建议拿到源码的同学不要只是编译运行一下一定要逐行读代码动手改参数比如把生成间隔改到10帧或者把子弹速度调成20感受一下游戏手感的变化。改坏了也没关系因为这恰恰是你理解游戏逻辑的开始。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑