做Java的人几乎都会碰到同一个知识点断层会写类、会写循环、会处理异常但一旦要做一个一直在动的程序就不知道从哪里下手。控制台程序是跑完就结束的静态逻辑而图形界面程序如果没有事件循环也只是一张静止的截图。我第一次把一个带完整碰撞检测玩法的Java飞机躲避炮弹碰撞小游戏跑起来时才真正理解了什么叫程序在持续运转。这个选题非常适合Java初学者、想补图形编程基础的人也适合作为课程设计或面试练手项目它从零到一覆盖了图形界面、键盘事件、状态切换、随机数、碰撞检测和线程调度麻雀虽小五脏俱全。这篇文章把我做这个项目时的完整设计思路、实现代码和踩坑记录都整理出来希望能让想动手的同学少走弯路。1. 一个简单玩法背后的完整知识地图1.1 定下玩法规则躲避、存活、计分这个游戏的核心规则非常直白玩家控制一架飞机在屏幕下方左右移动躲避从上方不断生成的炮弹。飞机一旦碰到炮弹就算失败游戏结束存活时间越长分数越高。为什么这个玩法适合入门因为它不需要美术资源、不需要复杂物理一个矩形代表飞机、一个圆形或方形代表炮弹就能把全部精力放在程序逻辑上。但也正因为玩法简单任何细微的技术问题都会立刻暴露出来——画面闪一下、按键卡一下、炮弹从飞机旁边穿过去玩家马上就会发现。换句话说这个选题天然就是一个很好的编程体检项目哪块基础不扎实做的时候都会现原形。1.2 一个看似普通的选题覆盖了哪些基础知识点我最初以为这不过是个Swing小demo真正动手后才发现它把Java SE阶段绝大多数重要内容都串起来了知识点在游戏里的实际用途Swing组件体系JFrame搭建窗口JPanel作为画布paintComponent与Graphics绘制背景、飞机、炮弹、分数KeyListener键盘事件监听左右方向键控制飞机Random随机数决定炮弹生成位置、速度和方向ArrayList集合批量管理维护屏幕上成百上千颗炮弹Timer或自定义线程驱动游戏循环让画面持续刷新Rectangle与intersects矩形碰撞检测核心枚举与状态机管理游戏开始、运行、结束三个状态我建议做的时候记住一个坐标系常识Swing的绘图坐标系原点在窗口左上角x轴向右y轴向下。不少从数学课转过来的同学习惯把原点当左下角结果飞机一画就跑到屏幕外面这个坑虽然小但很影响心态。1.3 为什么坚持用Swing这份老古董有人会问现在做小游戏为什么不用游戏引擎或者至少用JavaFX我的看法是用Swing做这个项目恰恰是它最大的学习价值。游戏引擎帮你封装了渲染循环、碰撞、粒子系统你拖拖拽拽就能做出华丽效果但被问到你的碰撞是怎么检测的为什么画面不闪时往往答不上来。用Swing就没有这种作弊空间你必须亲自实现主循环、自己写碰撞判断、自己解决性能问题做完之后对图形程序的理解会比单纯用引擎深得多。而且Swing是JDK自带API不需要引入任何第三方依赖在任何标准Java环境里都能直接编译运行作为课堂作业、课程设计都很友好。2. 写代码之前先画架构三个类和一个状态机2.1 对象职责划分Player、Bullet、GamePanel各管各的事我最早写这个游戏时把所有属性都堆在GamePanel里x、y、speed、bullets全写成成员变量结果加难度功能时改得头皮发麻。后来我老老实实做了对象划分整个世界清爽了。建议至少拆成这样Player类只保存飞机的坐标、宽高、移动速度并提供一个绘制自己的draw方法。Bullet类只保存炮弹坐标、尺寸、速度分量vx和vy、存活标记同样自带draw方法。GamePanel类持有Player、炮弹集合、粒子集合、游戏状态负责update和render的整体调度。这样拆的好处很直接数据归数据、行为归行为。后面想加道具、加敌方战机只需要新建对应类而不是去改原有逻辑。某个类的字段也应该尽量私有通过方法访问别图省事把字段全部public否则项目一大你根本不知道哪些地方改了这个值。2.2 游戏循环怎么选Swing Timer还是自定义线程游戏循环是整个程序的发动机。做Java 2D小游戏有两种主流方案我也是都试过之后才确定取舍的方案执行位置优点缺点javax.swing.TimerEDT事件线程写法简单、线程安全不会出现跨线程访问Swing的问题刷新精度受界面任务影响极端情况下会卡自定义Thread while独立工作线程逻辑与渲染分离刷新率更可控更新完UI要切回EDT新手容易踩并发崩溃我的建议是玩具级小游戏用Timer就够了。Timer每隔16到30毫秒触发一次actionPerformed在回调里执行一次update和一次repaint循环就转起来了。想深入理解多线程的同学可以再去尝试自定义线程方案但一定要记住不要在子线程里直接操作Swing组件必须通过SwingUtilities.invokeLater切回EDT。2.3 状态机从一个混乱Demo到可维护项目的分水岭游戏必须有一个明确状态机我用一个枚举搞定public enum GameState { READY, // 开始界面 RUNNING, // 游戏进行中 OVER // 游戏结束 }GamePanel里保存一个currentState变量所有更新和绘制逻辑都先根据当前状态分流。比如update方法里public void update() { switch (currentState) { case RUNNING: updatePlayer(); updateBullets(); updateSpawn(); checkHit(); break; default: break; } }没有状态机的版本我写过一次体验很糟糕玩家已经失败了画面还在生成炮弹、飞机还能继续移动整个场景非常出戏。引入状态机之后按空格开始游戏结束再按空格重新开始这些交互变得非常干净。这个设计虽然只有几行代码但价值极大能让你后续加暂停、加胜利界面都变成小改动。3. 功能逐行落地键盘、炮弹和碰撞检测的实现细节3.1 键盘控制为什么不能在keyPressed里直接改坐标很多初学者会直接在keyPressed事件里写plane.x 10这样确实能动但按一下只动一格完全没有连续移动的手感。问题在于键盘事件是一次性触发的按住不放并不会反复触发。正确做法是让keyPressed和keyReleased只负责记录按键状态真正的坐标变化统一放到update方法里根据状态计算public void keyPressed(KeyEvent e) { if (e.getKeyCode() KeyEvent.VK_LEFT) leftPressed true; if (e.getKeyCode() KeyEvent.VK_RIGHT) rightPressed true; } public void keyReleased(KeyEvent e) { if (e.getKeyCode() KeyEvent.VK_LEFT) leftPressed false; if (e.getKeyCode() KeyEvent.VK_RIGHT) rightPressed false; } private void updatePlayer() { if (leftPressed) plane.x Math.max(0, plane.x - plane.speed); if (rightPressed) plane.x Math.min(panelWidth - plane.width, plane.x plane.speed); }这里用Math.max和Math.min把飞机限制在屏幕范围内避免玩家把飞机移出画面一半再被炮弹打中那种边界体验很糟糕。关于坐标类型我建议字段用double存储只在绘制时转成int这样即使刷新率不太稳定移动也不会出现明显的阶梯感。3.2 炮弹生成随机性和可躲避性之间的平衡炮弹的关键是控制生成节奏。我实现了一个生成冷却器每帧update让spawnCooldown减1减到小于等于0时生成一颗炮弹然后重置冷却时间。private void updateSpawn() { spawnCooldown--; if (spawnCooldown 0) { double spawnX random.nextInt(panelWidth - bulletWidth); Bullet bullet new Bullet(spawnX, -bulletHeight, 0, bulletSpeed); bullets.add(bullet); spawnCooldown currentSpawnInterval; } }生成位置用随机数决定但要注意随机性不能太放飞。炮弹速度分为垂直分量vy和水平分量vx纯下落时vx为0、vy为正斜向炮弹可以给vx一个随机范围。我的建议是vx不要太大否则炮弹横向移动过快玩家根本没有反应空间游戏会变成纯粹拼运气。难度曲线也要提前想清楚我试过一套比较简单有效的经验值初始生成间隔约800毫秒最低下限250毫秒初始炮弹下落速度每帧3到5像素随分数慢慢提到8到10每增加一定分数生成间隔缩短、炮弹速度提升同时偶尔加入斜射炮弹。设计时给自己留一个安全窗口想象玩家的反应时间大约0.3秒如果炮弹从出现到落在飞机高度需要的时间小于这个值那基本就是必中。前期一定要让玩家觉得有惊无险后期再逐步施压这个节奏才是好玩的。3.3 碰撞检测先用矩形近似再谈像素级精确碰撞检测是小游戏的核心之一。最简单的方案是直接使用两个Rectangle对象的intersects方法两个矩形相交即判定碰撞。但这里有一个非常影响体验的细节飞机贴图通常不是填满整个矩形四周会有透明区域。如果不处理就会出现炮弹明明擦着机翼空过去了却提示我被打中了的情况。我的做法是给飞机碰撞盒做内缩private Rectangle getPlayerCollisionBox() { int inset 6; return new Rectangle( (int) plane.x inset, (int) plane.y inset, plane.width - 2 * inset, plane.height - 2 * inset ); }炮弹本身比较小直接使用原始宽高做矩形。遍历所有炮弹只要与飞机碰撞盒相交且炮弹仍处于存活状态就判定为命中public boolean checkHit() { Rectangle playerBox getPlayerCollisionBox(); for (Bullet b : bullets) { if (!b.active) continue; Rectangle bulletBox new Rectangle((int) b.x, (int) b.y, b.width, b.height); if (playerBox.intersects(bulletBox)) { return true; } } return false; }如果追求更高的视觉真实度可以做像素级精细检测先判断矩形相交再取相交区域的图像像素检查两张图对应点的alpha通道是否同时大于0。这个方案效果好但计算量偏大通常适合不带透明通道或者角色轮廓很复杂的场合。对于这个飞机躲避游戏矩形加内缩已经完全够用。4. 实测中绕不开的三个坑闪烁、键盘迟滞与子弹穿模4.1 画面闪烁的根因与修复别跟Swing的默认双缓冲作对我第一次跑起来时画面抖得厉害炮弹一多简直像在闪幻灯片。当时先怀疑是Timer间隔太短于是把间隔拉长到50毫秒闪烁变少了但操作手感明显发黏。后来我静下心一行行排查才发现问题根本不在刷新频率而在绘制方法的写法。我当时的paintComponent大概是这个模式先调用super.paintComponent(g)再手动用fillRect把整个面板涂黑然后再绘制所有游戏元素。问题出在手动清屏这一步——Swing的JPanel本身已经开了双缓冲你在绘制回调里把画面擦掉再重画等于把系统缓冲的完整帧破坏掉屏幕上会短暂出现一块空白看起来就是闪烁。正确做法很简单继承JPanel只重写paintComponent先调用super.paintComponent(g)做基础清屏然后直接绘制游戏内容不要再额外大面积填充覆盖。如果以后要做更复杂的离屏缓冲记住一句话所有的东西先画到一个BufferedImage上最后一次性drawImage贴到显示区域不要在一个绘制过程中反复清屏。排查闪烁还有一个笨但有效的办法把Timer间隔调到100毫秒一帧一帧慢慢观察如果每隔几帧就出现一次空白那基本可以确定是绘制过程中的清屏与绘制不同步导致的。4.2 键盘迟滞和幽灵移动监听器里别做重活另一个让我头疼的问题是键盘有明显的粘滞感有时候按下去要等一小会儿飞机才有反应。我当时的排查链路是先在keyPressed里加打印发现事件按下的瞬间就有回调说明监听本身没问题再看代码发现keyPressed回调里顺手写了日志、new了一个对象还尝试去遍历子弹集合这些操作把EDT线程拖住了后面的事件自然排队延迟。修复方式就是前面说的原则——监听器只记录哪个键被按下/松开所有坐标计算和游戏逻辑放到update里统一处理。事件线程负责快速响应输入游戏循环负责逻辑更新两件事拆开之后手感立刻就正常了。还有一个很隐蔽的坑窗口失焦。玩家玩到一半切到浏览器查攻略再切回来发现飞机自己往左边跑。原因很简单窗口失焦时会触发系统的keyReleased通知但Swing在部分平台下并不可靠按键状态布尔值可能残留为true。解决方案是给窗口加一个窗口焦点监听一旦焦点丢失立刻把leftPressed和rightPressed全部重置为false。4.3 子弹穿模不是玄学是单帧位移超过了碰撞盒玩着玩着会出现一种很离谱的情况子弹速度很快时看起来明明经过了飞机却没有判定碰撞。这在小游戏里有个专用说法叫隧穿本质是碰撞检测的采样频率不够上一帧子弹在飞机左边下一帧已经跑到右边两次采样的位置都没有与飞机矩形相交程序自然认为没撞上。解决隧穿有三条路限速法保证炮弹每帧移动距离小于最小碰撞体边长的一半。这个方法最简单对这个游戏基本够用。微步法把一帧拆成多步每一步都做一次移动和碰撞检测相当于内部提高采样频率。线段检测把子弹上一帧位置和当前位置连成一条线段判断线段是否与飞机矩形相交。我推荐先用限速法。比如子弹速度上限设为每帧8像素而飞机碰撞盒宽度至少20像素那就不存在穿越问题。将来你去做弹幕游戏、高速子弹再升级成线段检测也不迟这也是渐进式开发的思路。5. 从能玩到好玩分数、特效、音效与排行榜5.1 分数显示为什么不能在绘制方法里累加分数系统看起来简单其实有一个常见错误在paintComponent里做score加1。绘制方法会在各种情况下被系统调用比如窗口拖拽、最小化恢复、遮挡更新这些重绘和游戏逻辑完全无关。如果累加写在绘制里你会发现分数会莫名其妙突然跳高。正确做法是在update方法里基于时间或事件加分绘制时只用drawString读取并显示。用存活时间计分最简单elapsedTime deltaTime; score (int) elapsedTime;这样分数和真实秒数挂钩重绘多少次都不会影响数值。5.2 给碰撞一点视觉反馈一个简单的粒子爆炸类如果被炮弹打中只是弹一个游戏结束对话框反馈太弱了玩家甚至不一定看清发生了什么。我加了一个非常轻量的粒子系统效果立刻不一样。定义一个Particle类字段是位置、速度、剩余生命public class Particle { double x, y, vx, vy; int life; public Particle(double x, double y, double vx, double vy, int life) { this.x x; this.y y; this.vx vx; this.vy vy; this.life life; } public void update() { x vx; y vy; life--; } public boolean isAlive() { return life 0; } }在碰撞发生时循环生成10个左右粒子给每个粒子一个随机方向和随机速度再设置生命周期比如20到40帧。绘制时利用life比例让粒子逐渐缩小或变淡就会呈现一个向外扩散又消散的爆炸动画。这个功能代码量很少但玩家感受提升非常明显强烈建议做。5.3 音效和排行榜把IO、序列化也拉进来音效方面用javax.sound.sampled播放wav文件即可但有个性能坑不要在每次爆炸时都新建一个Clip对象创建Clip很耗时会导致游戏瞬间卡顿。正确做法是在游戏初始化时就把爆炸音效的Clip加载好播放时调用stop再start。背景音乐同理提前加载循环播放。排行榜是个非常好的扩展点。用本地文件保存历史最高分可以是Properties、DataOutputStream或者对象序列化实现一个简单的Top5排行。别小看这个功能它迫使你处理文件读写、异常捕获、排序这些标准IO知识点而且排行榜天然给了玩家反复挑战的目标。5.4 让项目持续生长的扩展方向做完基础版本之后可以按这几个方向往前推增加道具系统比如护盾、加速、双倍得分增加炮弹类型比如跟踪弹、扇形弹幕加背景滚动和地形障碍做成双人对抗模式甚至把Swing版改造成网页版或移动端版本。扩展真正的意义不在于堆功能而在于检验你的架构是否经得住变化。一个状态机对象化的好结构加新功能时会很轻松一个所有逻辑都堆在GamePanel里的坏结构加任何功能都会牵一发动全身。这个项目能一直做下去就是因为你总能在重构中体会到软件设计的价值。最后说一点个人体会做这个Java飞机躲避炮弹碰撞小游戏时我一度以为最难的是碰撞检测算法后来发现最难的是手感——刷新频率、键盘响应、碰撞判定、视觉反馈要配合得恰到好处这没有捷径只能反复调参数让不同基础的人试玩。如果你正在学Java我建议不要满足于跑通试着从能玩改到好玩你会在一遍遍调试中真正理解面向对象和事件驱动是怎么回事。这个项目也是我自己做过最有成就感的一个小游戏希望你能在复现过程中找到同样的乐趣。
阅读完成 · 觉得有帮助?