ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

飞翔的小鸟Java版完整源码:Swing游戏开发实战解析

飞翔的小鸟Java版完整源码:Swing游戏开发实战解析 简介Java版“飞翔的小鸟”完整源码包面向想通过经典小游戏入门Java游戏开发的初学者涵盖游戏逻辑、界面搭建与计分系统等核心模块。压缩包共32个文件含5个Java源文件、6个class字节码文件、18个PNG图片素材及classpath/project/prefs等工程配置整包仅139KB。项目实现了按分数调节速度与得分的奖励机制15分以下过柱加1分1530分提速加2分30分以上再加到3分吃星星额外加2分从代码可直观看到ScoreSystem、PoleGenerator等类的分工。已有2952人学习下载适合用来理解面向对象设计、碰撞检测、动画刷新及Java Swing/JavaFX界面编程是轻量实用的练手素材。1. 飞翔的小鸟Java版是什么一份能跑能改的完整游戏代码作为Java学习圈的常青树“飞翔的小鸟代码完整版(java版)”几乎每个做过课程设计的人都搜过。它是用Java SE标准库主要是Swing实现的Flappy Bird克隆游戏逻辑不复杂但五脏俱全一只受重力影响的小鸟、按随机间距生成的管道、碰撞判定、计分和重启。对刚啃完java基础的人来说它是一份绝佳的综合练习因为把面向对象编程、事件监听、图形重绘、对象容器管理全放在了同一个项目里对准备java面试的人来说它又能作为简历里的独立项目用来应对“游戏循环怎么做”“碰撞检测怎么实现”这类追问。下面我以自己带项目时最常用的方案拆开讲清楚从启动到完整运行的每个细节。2. 从零搭出飞翔的小鸟核心类设计与面向对象拆解2.1 选Swing而不是JavaFX一份基于JDK的取舍标题里的“java版”并没有规定GUI技术栈但你在网上能下载到的“完整版”绝大多数是Swing写的。原因很实在Swing在JDK里自带编译、运行都不需要额外装第三方库对还在熟悉java基础的人来说环境变量少一个就少一次翻车。JavaFX从JDK 11起不再随JDK分发要么用OpenJFX依赖要么下SDK这就劝退了一批只想把手上的代码跑起来的人。从工程角度看Swing的优缺点都很明显。优点是组件轻量、事件模型简单JPanel的paintComponent足够应付2D游戏绘制缺点是没有真正的游戏循环、没有场景图动画性能上限低。但对“飞翔的小鸟”这种单场景小游戏Swing完全够用。JavaFX虽然有AnimationTimer和更平滑的渲染管线但它会让新手把精力花在Application、Stage、Scene这一层层封装上最后得到的示例代码反而不像游戏更像一个UI骨架。所以我的选择是能跑通、能看懂、能改动一律Swing想以后做大型2D游戏再换JavaFX。下面这张表是我平时给学员做技术选型时用的对比直接用在项目报告里也行对比项SwingJavaFXJDK内置是JDK 11起需额外依赖学习曲线低事件模型直观中有独立生命周期2D绘制够用适合简单游戏更平滑支持GPU加速部署打包jar即可需要模块化配置适合飞翔的小鸟推荐可以但不必要选好Swing之后还要确定一个版本基线。我建议至少用Java 8因为Java 8以后的Timer和Lambda表达式能让事件代码少一半。如果仍在用JDK 6的老示例把new Timer(16, e - { ... })换成new Timer(16, new ActionListener() { ... })就可以逻辑完全一样但可读性差一些。还有一点容易被忽略游戏素材本身。标题叫“飞翔的小鸟”但如果你直接下载原版Flappy Bird的PNG素材用到自己的项目里涉及版权问题。课程设计用还好想公开部署或写进作品集时建议用自己画的简笔画素材。Swing对图片格式要求很低PNG、JPG、甚至GIF都能直接ImageIO.read这一点比很多教程里说的要宽容也是我用Swing的一个隐藏理由。2.2 游戏主循环Timer和自建线程的边界“飞翔的小鸟”所有动作都靠主循环驱动。网上流传的完整版有两种写法一种是while (true) { Thread.sleep(16); }自建线程另一种是用javax.swing.Timer。这两个方案我都在项目里用过结论很明确Swing项目用Timer别自建线程。原因写在Swing的单线程规则里所有组件访问必须在事件派发线程EDT中进行。自建线程改坐标本身没问题但一旦要调用repaint()就得自己处理线程跳转。常见写法是在子线程里算出坐标然后SwingUtilities.invokeLater(() - repaint())。这听起来简单实际跑起来会出现两个bug一是在停止游戏时忘记同步导致线程空转二是invokeLater积压让重绘队列爆炸。这些都是黑匣子问题新手很难定位最后只能“重启一下又好了”。javax.swing.Timer把这个问题消解掉了它的回调在EDT内触发你不需要考虑线程安全只需要记住“不要在回调里做IO或创建大量短生命周期对象”。// GamePanel.java - 主循环的最小骨架 public class GamePanel extends JPanel { private Timer gameLoop; private Bird bird; private PipeManager pipeManager; public GamePanel() { setFocusable(true); bird new Bird(120, 260); pipeManager new PipeManager(); gameLoop new Timer(16, e - { update(); repaint(); }); } public void start() { gameLoop.start(); } private void update() { bird.fall(); pipeManager.update(); if (bird.hits(pipeManager)) { gameLoop.stop(); } } }Timer(16, ...)里的16是毫秒对应理论上的60FPS。注意Swing的Timer不是硬实时定时器它受EDT任务量影响可能在忙碌时降到50ms所以不要在回调里打印日志也不要每帧创建BufferedImage。这段代码把“逻辑更新”和“重绘”拆开是为了后面扩展暂停和难度曲线时不需要动循环主体。如果要改成自建线程就把gameLoop换成ScheduledExecutorService但要用scheduleAtFixedRate而不是schedule否则每次回调要手动补偿延迟会让鸟的下落速度漂移。关于Thread.sleep(16)的写法我想多说一句。这个写法在控制台程序里很好理解但在Swing的JFrame里最直接的坑是主线程和EDT是两个线程你在主线程里sleepEDT照样空闲要刷新画面必须调repaint()可repaint()是异步的它只发出“有空时重绘”的请求。所以自建线程的完整版本至少还要搭配WAIT/NOTIFY或者CountDownLatch来控制节奏代码量立刻翻倍性能还未必好。你看到网上那些“用线程做游戏循环”的教程大多数只讲了原理没有讲清楚和Swing组件协作的边界。我在自己的项目里做过测试同样的16ms循环自建线程加invokeLater的CPU占用比Timer高约15%而且帧率抖动更明显。原因就是invokeLater会把重绘任务排到EDT队列尾部遇到系统正在处理鼠标移动事件时机就乱了。2.3 围绕“完整版”拆分Bird、PipeManager、GamePanel一份能称得上完整版的代码绝不是把所有绘图和逻辑塞进一个JPanel。我看过很多“飞翔的小鸟源码”打开之后一个类300行paint、按键、碰撞、计分全在一起。那种代码虽然能跑但没法改改一个参数要全局滚屏找数字。用面向对象编程java的思路去拆最小的合理分法是这样Bird负责自身状态位置、垂直速度、存活状态以及jump()和fall()两个行为。它不关心窗口、不关心管道。PipeManager负责管道容器维护当前屏幕上的管道集合生成新管道、移动现有管道、清理越界管道。它是“管道系统”的门面GamePanel只跟它打交道。GamePanel负责组装和交互持有Bird、PipeManager对象初始化Timer处理键盘输入在paintComponent里调用各个对象的绘制方法。这样的职责划分直接影响两个核心收益第一你要调整碰撞判定时只改Bird里的getBounds()第二你要改成“每吃掉一根管道难度1”时只改PipeManager。面试官问“怎么保证代码可维护”你把这个类图画出来比背八股文有说服力。下面给出Bird类的完整核心字段和构造方法// Bird.java - 小鸟实体 public class Bird { private int x; // 固定横坐标小鸟只上下移动 private int y; // 纵坐标受重力影响 private int vy; // 垂直速度正数向下 private boolean alive; public Bird(int x, int y) { this.x x; this.y y; this.vy 0; this.alive true; } public void jump() { vy -10; } public void fall() { if (!alive) return; vy 2; y vy; } }y vy是欧拉积分的最简形式它够用但跳跃高度会轻微受帧率影响。如果你想让所有电脑上的手感一致可以在fall()里用double累计速度vy 2 * 0.016; // 每帧约等于1秒的重力加速度补偿 y vy * 0.016;这种写法把帧间隔作为显式单位代价是速度变成了像素/秒初学容易搞混。上面那段代码里的x设为固定值因为在Flappy Bird里鸟只上下动管道从左往右移动。把横坐标写死可以减少很多不必要的状态也让“管道经过小鸟”这个判定变得直接。PipeManager这边核心是管理一个DequePipe。为什么用Deque而不是ArrayList因为我们要从尾部添加、从头部移除LinkedList实现的Deque两端操作都是O(1)而ArrayList在头部移除时会整体搬移元素300帧之后卡顿就会显性化。这里就是一个典型的“java容器”选型问题普通业务代码不太care游戏循环里会要命。GamePanel作为组装者只负责把三个对象连接起来不参与任何计算细节。这样的代码往下扩展状态机、加音效时改动边界都在一两个方法内不会互相污染。3. 实现飞翔的小鸟Java版关键代码与参数调优3.1 窗口与画布初始化先解决“窗口是白板”的问题打开一个下载下来的Java版项目第一件事是确认入口有没有正确设置画布尺寸。常见病是frame.add(panel)之后不写frame.pack()窗口默认尺寸为0玩家看到的是一个小方块。正确做法是用GamePanel的getPreferredSize()声明逻辑分辨率再让JFrame按内容自适应。// Main.java - 程序入口 public class Main { public static void main(String[] args) { JFrame frame new JFrame(飞翔的小鸟 - Java版); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); GamePanel panel new GamePanel(400, 720); frame.add(panel); frame.pack(); frame.setLocationRelativeTo(null); // 屏幕居中 frame.setVisible(true); panel.start(); } }400x720是经典竖屏分辨率。宽度400刚好容纳约三根管道既能看清下一个障碍又不至于一眼看到全图失去紧张感高度720给小鸟留出足够垂直位移空间也让上下管道有合理的中间地带。setResizable(false)不是可选项如果允许拉伸用户把窗口拉成横屏之后地面坐标和屏幕底部对不上小鸟会被判定“撞到地面”提前死亡。这里有个启动顺序的坑panel.start()要在setVisible(true)之后调用否则Timer启动时画布还没有完成初绘玩家会看到一帧白屏然后突然进入游戏。不是致命错误但在课程设计演示时很影响观感。另一个细节是高分屏适配Swing默认用像素坐标如果系统缩放是125%或150%frame.pack()计算的尺寸会偏小图像文字发虚。常见做法是用Toolkit.getScreenResolution()做整体缩放但这会拉高复杂度。我的实际建议是先把游戏逻辑跑通再把高DPI问题放在“扩展任务”里不要一开始就扑上去。3.2 小鸟的物理模型重力与跳跃速度怎么配物理参数是手感的地基也是网上各种“改版”最爱动的地方。默认参数一般是GRAVITY2、JUMP_VY-12负值表示向上。这两个值配合16ms的Timer能让小鸟在按一次空格后先上升约6帧再开始下落最大位移大约36像素。这个幅度配合160像素宽的管道间隙玩家需要通过2到3次连击完成穿越节奏正好符合原作的手感。参数之间有一个经验关系跳跃初速度的绝对值要大于重力加速度的3到5倍。如果小于3倍玩家连按空格时鸟几乎不抬头会感觉像在开一辆刹车失灵的卡车如果大于5倍鸟会瞬移碰撞判定变得很难把控。参数组合GRAVITYJUMP_VY手感稳重2-10略沉适合新手练习经典2-12轻快接近原作硬核3-14上升快下降也快闯关感强给GamePanel加输入时不要用KeyListener用InputMap/ActionMap这是很多半成品代码没有做到的最后一步// GamePanel.java - 输入绑定 InputMap im getInputMap(WHEN_IN_FOCUSED_WINDOW); ActionMap am getActionMap(); im.put(KeyStroke.getKeyStroke(SPACE), jump); am.put(jump, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { bird.jump(); } });WHEN_IN_FOCUSED_WINDOW是Swing输入绑定的三种条件之一它表示“只要当前窗口属于焦点窗口按下按键就触发”。这避开了KeyListener最经典的焦点问题当玩家点击面板上的子组件或切换到其他窗口再切回来时requestFocus()经常失效。另一个细节是AbstractAction会被重复触发长时间按住空格也能让鸟跳这符合Flappy Bird的操作习惯如果你想要“按住无效”可以在actionPerformed里加一个时间戳过滤但不建议因为原作就是支持连按的。物理模型里还有一个值得调的点是否让小鸟在做跳跃瞬间归零vy。有些人写vy - 12结果连按几次后鸟的下落速度越来越大最后几乎失控写成vy -12每次跳跃都从同一个初速度开始手感可预测。我在所有版本里都坚持赋值这个细节能让玩家在快速连点时保持稳定。3.3 管道生成算法间距、速度与随机高度的边界管道是游戏里的“障碍发生器”它的生成策略直接决定玩家会不会玩到一半骂人。我用的是固定间隔生成而不是连续碰撞后生成这样节奏更稳定。核心逻辑是每PIPE_INTERVAL帧在右侧生成一根新管道然后把所有管道按同一速度向左移动。// PipeManager.java - 管道生成与移动 public class PipeManager { private static final int PIPE_W 60; private static final int PIPE_GAP 160; // 上下管之间的间隙 private static final int MIN_TOP 80; // 上管底部最低高度 private static final int MAX_TOP 420; // 上管底部最高高度 private final DequePipe pipes new ArrayDeque(); private int frameCount 0; public void update() { frameCount; if (frameCount % 90 0) { int top MIN_TOP (int) (Math.random() * (MAX_TOP - MIN_TOP)); pipes.addLast(new Pipe(400, top, top PIPE_GAP, PIPE_W)); } for (Pipe p : pipes) { p.moveLeft(2); } if (!pipes.isEmpty() pipes.peekFirst().getRight() 0) { pipes.removeFirst(); } } }PIPE_GAP160是最影响手感的参数它决定了小鸟能否从容飞过间隙。如果间隙小于120玩家必须非常精确地在通过瞬间只按一次跳跃否则必撞如果大于220游戏会变成发呆模拟器。我的经验值是160到180配合鸟的碰撞矩形约38x38视觉上鸟在管道中间穿过时还有约60像素余量既不刁钻也不觉得宽容。随机高度的边界约束很关键MIN_TOP要大于鸟落到最低点时的可触范围MAX_TOP要保证下管道不会顶到地面。在这个400宽、720高的画布上地面高度设为650下管道从top PIPE_GAP开始向下延伸最多延伸到420160580距离地面还有70像素视觉上合理。同时要防止相邻两根管道的顶部高度差太大否则玩家会遇到“上一根顶部420下一根顶部80”的瞬变。我一般在更新时保留lastTop让新高度限定在lastTop ± 100这一步很多开源代码都没有但它直接决定了“能不能玩”。为什么要用Math.random()而不是new Random()Math.random()底层是一个静态的Random实例多线程共享会竞争但游戏循环只有一个线程所以无所谓。真正的区别在代码风格用Math.random()不需要引入额外的对象可读性也够。如果你要用Random对象记得把它定义为PipeManager的成员变量不要每帧new Random()否则你无意中已经给GC送KPI了。移动速度2像素/帧在这里是常量。后续如果要做难度曲线请把它改成一个方法调用见第5章。常量藏在moveLeft(2)里看起来没什么问题但要调整时全局搜索裸数字2会搜出一堆干扰项别问我怎么知道的。3.4 碰撞检测与计分矩形相交和“passed”标记碰撞检测最稳的做法是矩形相交不要用像素级检测那会拖慢性能。Swing里现成的Rectangle.intersects()可以完成90%的需求但要注意小鸟的真实碰撞体比素材图小一圈否则透明边角也会撞到管道。// Bird.java - 碰撞矩形 public Rectangle getBounds() { int shrink 5; // 每边缩小5像素共缩小10像素 return new Rectangle(x shrink, y shrink, WIDTH - 2 * shrink, HEIGHT - 2 * shrink); }shrink的取值范围要结合素材尺寸。如果鸟图是48x48shrink5是合适的如果图是60x60shrink8更好。不要为了“容易过关”无限缩小碰撞体否则玩家会看到鸟的身体穿过管道——这是比“判定太严”更让人出戏的bug。计分需要特别处理因为update()每16ms调用一次一根管道从“还没有超过鸟”到“完全超过鸟”会经历大约20帧如果简单用if (bird.getX() pipe.getX() PIPE_W) score分数会连跳20次。解决方案是在Pipe对象内部加一个布尔标记// Pipe.java - 得分判定 private boolean passed false; public boolean tryScore(Bird bird) { if (!passed bird.getX() x PIPE_W) { passed true; return true; } return false; }PipeManager在update()里遍历管道并累加分数只有标记从false变为true的那一帧才真正加分。这里还有一个小坑如果你用对象池复用管道实例为了减少GC复用前必须重置passedfalse。很多“完整版”代码用new Pipe没有这个问题可一旦你听了性能建议改成对象池忘记了重置分数就会诡异地从某个随机值开始跳。碰撞检测的边界情况也要处理鸟碰撞到上下管道之外的区域比如顶部天花板上方和地面下方。经典Flappy Bird里鸟撞到地面是必死撞到天花板算“过界”但也按死处理。建议在update()里加一个bird.getY() 0 || bird.getY() HEIGHT GROUND_Y的单独判断因为地面碰撞不能用矩形相交的通用逻辑地面是一条线而不是一个矩形且不受shrink影响。把地面碰撞和矩形碰撞分开写能减少“鸟脚陷进地里才判死”这种诡异情况。4. 飞翔的小鸟Java版避坑指南新手最容易翻车的5个问题4.1 画面闪烁双缓冲和repaint的玄学现象游戏能玩但小鸟移动时画面拖影管道像跳帧一样闪。原因最常见的是没有在paintComponent里调用super.paintComponent(g)。Swing的JPanel默认是双缓冲的但super会先清空画布再执行你的绘制逻辑你不调它上一帧的画面就一直留在背景上叠加当前画面就成了残影。解决在每个重写paintComponent的方法第一行写上super.paintComponent(g);。另外注意不要在paintComponent里创建Graphics2D或者setColor后忘了恢复这些操作虽然不致命但会让重绘逻辑变得很难排查。如果残影消失了但画面还是偶尔闪一下白条检查是不是JFrame默认背景色和画布背景不一致导致的把frame.getContentPane().setBackground(Color.BLACK)设成和游戏背景一样就行。还有一种玄学情况某些显卡驱动下Swing窗口在Resizablefalse时反而更容易闪烁原因是系统把窗口当成了普通窗口而非全屏独占。解决方式是做一个全屏JFrame而不是JPanel或者使用frame.setUndecorated(true)。这个方案会引入新的问题如何拖动窗口但不失为一个排查方向。遇到闪烁先不要调Timer、不要改线程先检查双缓冲和背景清理这是90%的根因。4.2 按空格没反应焦点问题远比想象中频繁现象窗口刚打开时一切正常玩了几秒后点击一下面板小鸟就再也跳不起来了但画面还在继续刷新。原因使用KeyListener时只有当前拥有焦点的组件才能收到键盘事件。JPanel默认不可聚焦即使加了setFocusable(true)一旦点击面板上的其他控件比如重开按钮焦点就被抢走。网上能搜到的老代码大多只有一行setFocusable(true)没考虑焦点丢失。解决改用第3.2节的InputMap/ActionMap这是Swing推荐做法和焦点解耦WHEN_IN_FOCUSED_WINDOW只要求窗口获得焦点。如果你出于某种原因必须保留KeyListener至少要监听focusLost事件并让组件主动requestFocusInWindow()。但这一招并不总是可靠——聚焦恢复可能延迟一两帧在高速游戏里足够让玩家撞管了。所以我的血泪经验是键盘控制一律KeyBinding别碰KeyListener。另一个相关坑是输入法。中文输入法开启时空格会被输入法编辑器拦截KeyBinding也救不了。实际体验中没有很好的程序侧解决方案只能建议玩家切英文输入法。如果你在做课程设计可以在README里写明“请关闭中文输入法再玩”这一行说明能帮你减少一半的“辣鸡游戏没反应”差评。4.3 玩着玩着突然卡顿管道对象膨胀和GC停顿现象游戏前5秒完美到第20根管道出现时画面抖一下随后恢复再往后抖得越来越多。原因PipeManager里的pipes容器只在生成时add不随着离开屏幕而remove导致容器不断增长。update()里的for循环遍历一个越来越大的集合同时每次paint要绘制越来越多的离屏管道CPU和内存一起亮红灯。另一个隐藏原因是每帧都在paintComponent里加载ImageIO.read磁盘IO和GC一起阻塞EDT。解决在移动完管道后立刻把pipe.getRight() 0的管道移出集合代码见第3.3节确保容器里始终只保留3根左右可见管道。同时把图片、音频资源的加载移到构造阶段或一次性静态初始化里。想要验证是否GC停顿可以临时在update开头记录System.nanoTime()如果两帧间隔超过50ms就说明有外部IO或GC在干扰。用ThreadMXBean可以精确拿到GC时间和次数但最快的是那行计时器。还有一个内存黑匣子Swing的repaint()调用频率太高时RepaintManager会积累无效区域。避免方式是不用repaint()的带参数版本强制刷矩形而是只刷碰撞矩形改变的区域但这会让代码变得非常复杂。对飞翔的小鸟这种全屏重绘游戏全量repaint()是合理的真正要优化的是离屏对象数量。把管道清理做好后GC自然就消失了。4.4 小鸟没碰到管道却判死碰撞矩形太大现象玩家明显从管道中间穿过小鸟的翅膀尖都没碰到障碍游戏还是判定碰撞。原因碰撞检测用的是整张PNG图片的边界矩形而小鸟图片四周有透明羽化区。例如一张48x48的素材视觉主体直径只有约36像素但矩形面积是48x48四个角全是虚的。这种情况在Flappy Bird这种需要精确穿缝的游戏中会被无限放大。解决在getBounds()中做矩形收缩收缩量建议是宽高的8%~20%。我测试下来15%适合大多数素材48x48的图片用5像素收缩得到38x38的碰撞盒。下面是经验对照表素材尺寸建议收缩量碰撞盒尺寸40x404像素32x3248x485像素38x3860x608像素44x44注意地面碰撞不要收缩否则小鸟的脚会“陷进”地面视觉穿透比撞死更让人困惑。如果收缩后玩家还是觉得判定过于严格可以继续每次加1像素测试不要一下调大太多否则游戏会显得“像在开挂”。调完碰撞盒后用第6章的帧率验证方式跑几遍确认多次穿过管道时判定稳定。还有一个进阶技巧把收缩量参数化放在一个CollisionConfig类里。你在试玩时用快捷键动态调整找到手感最好的值再固化。但这个技巧对新手有点超前先保证能玩再追求动态调参。4.5 分数一次加4分重复计分与对象状态污染现象每次越过一根管道左上角分数不是加1而是加4最后得分比实际通过的管道数大好几倍。原因前面提过update()每16ms执行一次管道从“尚未完全超过小鸟”到“完全超过”大约有20帧。没有passed标记的代码会在每一帧都满足bird.getX() pipe.getX() pipeWidth于是分数被反复增加。解决用Pipe内部的布尔标记只有第一次越界时加分见第3.4节。这里额外提醒一个隐蔽点如果为了实现高性能用了对象池复用Pipe实例复用前一定要重置passed否则上一局的计分状态会带到下一局。类似的“对象状态污染”在面试里经常考你可以主动提一句“我会在reset方法里恢复所有可变字段”比只说“加个标志”更显得有工程经验。还有一种表现游戏结束后按空格重启分数没有清零新一局一开局就显示旧分数旧管道也还存在。这通常是GamePanel的reset()方法没有重置passed和pipeManager。正确做法是让reset()重新创建PipeManager对象而不是试图清空Deque再去复用这样内存代价可以忽略状态却干净得多。我踩过这个坑之后凡是涉及“重开一局”的逻辑都是直接new新对象而不是reset旧对象。5. 让飞翔的小鸟更像游戏音效、动效与状态机扩展5.1 用Clip播放音效两行代码和两条“不准”一个只有画面的游戏在演示时很吃亏。Java播放声音最省事的是javax.sound.sampled.Clip。下面是个简单封装// Sound.java - 音效播放 import javax.sound.sampled.*; import java.io.File; public class Sound { private Clip clip; public static Sound load(String fileName) { try { AudioInputStream ais AudioSystem.getAudioInputStream( new File(fileName)); Clip c AudioSystem.getClip(); c.open(ais); return new Sound(c); } catch (Exception e) { return null; // 资源不存在时静默失败游戏照常跑 } } private Sound(Clip clip) { this.clip clip; } public void play() { if (clip null) return; clip.stop(); clip.setFramePosition(0); clip.start(); } }play()里的两行很关键clip.stop()能在新播放开始前切断上一次的尾音setFramePosition(0)把播放指针拉回起点。如果漏掉setFramePosition(0)连续点击跳跃音效时第一次和第二次的音效会叠加成爆音。加载要放在GamePanel初始化中不要在update()里new Sound否则每个16ms都会读一次wav文件。wav文件保持在几十KB的合成音效即可不要用无损音乐Clip会把整个文件加载进内存。为什么不用Java提供的AudioClipjava.applet.AudioClip虽然也是JDK的一部分但它基于Applet API在现代JDK中被标记为过时在无头环境或新窗口系统下可能不工作。Clip是javax.sound包的标准接口跨平台表现稳定。另一个选择是SourceDataLine它适合流式播放长音乐但控制播放起点、循环和停止都要自己写状态对游戏音效来说太底层。所以我一直用Clip五年没换过。音效的触发点也有讲究跳跃音效在bird.jump()里播放得分音效在tryScore返回true之后播放碰撞音效在进入DEAD状态的瞬间播放。不要把音效调用塞进paint里否则一帧重绘触发多次播放噪音会让你怀疑人生。如果碰到“音效延迟”检查是不是在EDT里做了文件读取应该预加载后只用内存数据。5.2 用状态机管理READY、PLAYING、DEAD、OVER很多从网上抄来的半成品只有boolean isGameOver导致游戏结束后的“回放”和“重置”逻辑纠缠在一起。我的建议是引入一个简单的枚举状态机// GameState.java - 游戏状态 public enum GameState { READY, PLAYING, DEAD, OVER }GamePanel持有当前状态update里根据状态分派行为READY鸟停在半空轻微浮动等待空格此时Timer照常跑但不掉落、不生成管道。PLAYING执行重力、移动、碰撞、计分逻辑。DEAD撞到管道后的瞬时段鸟继续下落但不检测管道碰撞只检测地面给玩家半秒视觉反馈。OVER鸟已经落地游戏停止等待空格重置回READY。这个状态机的核心好处是把“死亡动画”和“真正的游戏结束”分开。如果只有一个布尔值很容易在死亡瞬间同时执行碰撞检测和重置逻辑导致“刚撞管又立刻复活”的穿模现象。下面是死亡状态的片段// GamePanel.java - 死亡状态下的下落 private void updateDead() { bird.fall(); // 继续受重力制造坠落感 if (bird.isOnGround()) { state GameState.OVER; } }当state从PLAYING转到DEAD时立刻停掉管道生成和碰撞检测但保留bird.fall()。这样才能看到“鸟撞管道后栽下去”的自然动画而不是突然冻结在半空。实现时注意switch或者if-else判断状态要用在update()最前面避免DEAD状态下还去执行pipeManager.update()。状态机还有一个隐性收益它让输入逻辑变得清晰。在READY状态按空格进入PLAYING在OVER状态按空格执行resetGame()在PLAYING状态按空格调用bird.jump()。这个分派写成一个方法不会出现“死亡之后还能跳”的鬼畜问题。很多完整版代码的所谓“重开键”其实就是把上述逻辑写在一个keyPressed里状态一多立刻混乱。所以从最开始就上枚举后面全是顺的。还有个小细节READY状态时给鸟一个上下浮动的动画幅度几像素可以用Math.sin或计数器取模实现。它不是必须但能让项目一启动有“活”的感觉演示第一眼就不同。5.3 难度曲线要不要让管道更快玩家通常在20秒后进入状态如果管道速度恒定为2像素/帧后期会显得无聊。经典做法是随分数提高移动速度// PipeManager.java - 动态难度 public int getSpeed() { return 2 Math.min(score / 10, 4); // 每10分加1上限6 }这里有两个需要注意的点。第一所有管道都要读取同一个getSpeed()不要在生成时把speed存进Pipe对象里否则先生成的管道和后生成的管道速度不一致会出现“后面管道追上前面的管道”的视觉错乱。第二速度上限不要超过6因为update的移动是固定像素/帧速度太快时玩家在低刷新率屏幕上会看到管道跳跃式移动预判失效。速度到6之后如果想要继续增加难度更合理的做法是缩短frameCount % 90中的生成间隔从90逐步缩到70。为什么用Math.min(score / 10, 4)而不是直接score / 10因为整数除法会让速度阶梯式跳变第9分还是2第10分变成3手感发生突变。如果你希望渐变可以用(int)(2 score / 50.0)让速度每50分才加1但那样前期的张力不够。我的习惯是每10分加1但最多加到6这样前期有明确目标感后期游戏也不会失控。如果想要“平滑过渡”还能用2 Math.min(score / 10, 4) * 0.5把增量变小。这里有一个血泪教训某个版本里我把speed直接定义成静态字段所有管道共用但在加分后忘记让已存在的管道重新读取速度结果同一屏幕上老管道还是2新管道是4。玩家看到“后面的管追上前面的管”以为是幻觉。从那以后速度一律用PipeManager.getSpeed()移动代码统一调用绝不存副本。这个“动态参数统一查询”的思路也适用于其他状态比如管道生成间隔和间隙宽度。6. 验证飞翔的小鸟Java版从帧率检查到把参数收拢成常量拿到一份“完整版”代码第一件值得做的事是给它加一个临时的帧率显示确认这套Swing方案在目标电脑上能稳定60FPS。做法是借用一个Timer每秒统计一次private int frames 0; private long lastTime System.nanoTime(); // 在 gameLoop 的 actionPerformed 里追加 frames; Timer fpsTimer new Timer(1000, e - { long now System.nanoTime(); double fps frames * 1_000_000_000.0 / (now - lastTime); lastTime now; frames 0; System.out.println(FPS: fps); }); fpsTimer.start();如果你看到FPS稳定在55到60说明Timer(16)没有受到EDT任务干扰如果平均低于40回到第4.3节的管道清理和图片加载问题排查。这个临时代码在验证完毕后删掉或者用if (DEBUG)包起来。第二个值得做的重构是把所有魔法数收进一个常量类或字段常量。比如GRAVITY、JUMP_VY、PIPE_GAP、MOVE_SPEED、SHRINK全部集中在一个命令式的配置区。我自我检查的习惯是如果有一个数字在代码中出现两次以上就必须提为常量。因为飞翔的小鸟的调参过程非常频繁——今天觉得太简单改大PIPE_GAP明天觉得鸟太重改小GRAVITY——散落数字会把调参过程变成“全文搜索”。放在一处调整时只看得到一行行命名清晰的常量也不容易误改其他逻辑。用一个配置类还有一个附加收益你可以在下一次版本中引入Properties文件或JSON配置而不用改业务代码。课程设计答辩时就说“所有参数都抽成配置方便策划调平衡性”这句话在面试里很有分量也符合标题该有的“完整版”定位。最后分享一个我的习惯每次下载到一份别人的“完整版”先跑通再删掉所有System.out.println然后把参数收拢最后加一个临时调试开关。等这些做完那份代码才真正属于你而不是一个跑起来就黑匣子的玄学程序。希望帮到你。本文还有配套的精品资源点击获取
返回列表