我很久没碰Swing写小游戏了,这次趁周末把打飞机重新撸了一遍。说实话,用Java写这种经典小游戏远没有想象中那么简单,光是线程同步和碰撞检测就能绕晕一堆刚入门的同学。但正因为如此,它是个非常好的练手项目——麻雀虽小,涉及的东西却相当完整:窗口渲染、事件监听、游戏循环、对象管理、碰撞检测,全都有了。如果你正在学Java,想找点Swing之外的实战内容,或者想从纯语法学习过渡到项目开发,这篇应该正好适合。
打飞机这类游戏,精髓在于"无限循环+状态更新+重绘"。游戏每时每刻都在做三件事:响应用户输入、更新所有物体的坐标、把画面重新画一遍。听起来很简单,但要把这三个环节揉进同一个程序里,还要保证画面不闪、操作不卡、碰撞判定准确,就必须要有一个清晰的结构。Java的标准库提供了Swing和AWT,虽然做不出3A大作,但写一个2D小游戏绰绰有余,而且开箱即用,不需要额外的依赖,很适合用来理解游戏开发的基本原理。
我将按照一个完整的开发流程来讲,从整体架构到每段核心代码,再到我实际踩过的坑,最后说说后续怎么接着玩。
1. 游戏整体架构:五个类足够撑起整个游戏
打飞机游戏的核心对象就几样:窗口、游戏面板、玩家飞机、敌机、子弹。不要一上来就设计几十个类,那对一个小游戏来说完全是过度设计。合理的做法是先用最少的类把游戏跑通,后面需要扩展再加。
1.1 类的划分与职责
我这次用了五个类加一个枚举,每个类的职责非常单一:
| 类名 | 职责 | 关键说明 |
|---|---|---|
| GameFrame | 主窗口 | 负责创建JFrame,把GamePanel塞进去 |
| GamePanel | 游戏核心逻辑 | 持有所有游戏对象,负责更新和绘制,是游戏循环的主战场 |
| PlayerPlane | 玩家飞机 | 响应键盘控制,发射子弹 |
| EnemyPlane | 敌机 | 自动向下移动,被子弹击中后消失 |
| Bullet | 子弹 | 由玩家发射,向上飞行,与敌机碰撞 |
| GameState | 游戏状态枚举 | RUNNING, PAUSED, GAME_OVER |
GamePanel是整个游戏的中枢。它同时承担了两个角色:游戏逻辑的容器和绘制画布。也就是说,它继承了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: 0=left, 1=right, 2=up, 3=down, 4=shoot 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 (Iterator<Bullet> bulletIt = bullets.iterator(); bulletIt.hasNext(); ) { Bullet bullet = bulletIt.next(); for (Iterator<EnemyPlane> 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重新设为true,while循环会继续,不需要创建新线程。
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),感受一下游戏手感的变化。改坏了也没关系,因为这恰恰是你理解游戏逻辑的开始。