
简介面向iOS游戏开发初学者的Flappy Bird Xcode工程源码包基于SpriteKit框架用Objective-C编写可直接用Xcode打开编译完整呈现经典小游戏从物理模拟到交互响应的开发链路。压缩包共58个文件、约507KB内容以Objective-C源码.m/.h为主体搭配SpriteKit场景粒子文件.sks、PNG贴图素材和mp3/wav音效另有Xcode工程配置、语言资源目录与简短的说明文档。已有177人学习/浏览适合希望通过复现游戏掌握物理引擎、碰撞检测、触摸事件、计分与结束判定等核心机制的开发者。包中游戏主场景、管道管理与新游戏场景等模块分工明确分别负责重力模拟、随机管道生成与障碍移动、游戏结束与重新开始同时带有点击上升、计分更新等完整实现配合粒子特效和音频资源便于对照理解每个模块的职责也适合在此基础上做UI定制或玩法扩展。整体目录结构清晰对想了解SpriteKit项目组织方式或动手实践iOS游戏开发的人都有直接参考价值。1. 用 Xcode 实现的 Flappy Bird它不是简单复刻而是一份可改的工程样板第一次在 Xcode 里打开这份 flappy bird 工程时我以为是拿来即跑的样板项目。实际跑起来才发现麻雀虽小五脏俱全Swift SpriteKit 的场景管理、物理碰撞、计时循环、音效触发、状态切换全都齐了。对刚开始接触 iOS 游戏开发的人它的价值不在“能玩”而在“能拆”——你可以在几分钟内找到小鸟跳跃、管道生成、计分判定这些核心代码在哪然后一行一行改参数看效果。我做模拟项目X时直接把这里的碰撞掩码方案抄了过去省下半天排查物理体配置的时间。适合谁看想搞懂 SpriteKit 物理系怎么组织的新手以及需要一份可改的手游原型做二次开发的熟手。2. 工程结构与运行链路先看这份代码在哪一帧跑起来在改任何参数之前得先搞懂这份工程的代码骨架。它不是一个 “All in one” 的单文件项目而是按 SpriteKit 的标准分层组织入口控制器负责加载场景场景节点负责承载游戏元素物理引擎负责碰撞update 循环负责每帧状态推进。这一章带你沿着“启动 → 生成场景 → 物理体注册 → 每帧更新”这条链路走一遍读完你就知道手该往哪伸。2.1 入口与场景流转谁把 GameScene 推上屏幕入口部分通常由 AppDelegate 和 GameViewController 组成。AppDelegate 管应用生命周期GameViewController 才是把 SpriteKit 场景铺到屏幕上的角色。Scene 分成两个主要类型菜单场景和游戏场景很多实现里甚至用同一个场景类配不同状态变量来做区分。切换场景时常用做法是// 从菜单场景进入游戏场景 let transition SKTransition.doorway(withDuration: 0.8) let gameScene GameScene(size: CGSize(width: 375, height: 667)) gameScene.scaleMode .aspectFill view.presentScene(gameScene, transition: transition)代码说明SKTransition 只做转场动画真正的场景内容由 GameScene 的 size 决定。size 用逻辑分辨率而不是物理像素375x667 对应经典 4.7 寸屏的逻辑尺寸scaleMode 用 aspectFill 是为了在刘海屏上不拉伸变形。参数说明doorway 转场有一种“开门”的立体效果如果追求干脆利落可以换成 .fade(withDuration: 0.3)presentScene 是 SKView 的实例方法调用后当前场景被替换旧场景释放与否取决于你是否强引用它。从这份工程的设计看菜单场景和游戏场景各自持有不同的节点树比“单场景切状态”更清晰代价是跨场景传参要手动处理。它把最高分存在 UserDefaults在菜单场景的 didMove(to view:) 里读出来展示。这种入口组织是 SpriteKit 项目的标准范式等你想加设置页、选关页时照这个模式往下套就行。2.2 物理引擎与碰撞体系categoryBitMask 是一切的起点Flappy Bird 手感的一半来自物理引擎参数另一半来自碰撞掩码配置。SpriteKit 的物理世界是模拟重力的你创建的任何 SKPhysicsBody 都会被重力牵引碰撞响应则由三个掩码配合决定categoryBitMask自己是哪一类、contactTestBitMask想和哪一类产生 contact 回调、collisionBitMask哪一类会把自己弹开。这份工程里小鸟、管道、地面各占一个掩码位enum CollisionType: UInt32 { case bird 0b1 // 1 case pipe 0b10 // 2 case ground 0b100 // 4 }代码说明枚举的原始值用二进制位错开这样掩码可以按位或组合。为什么不用 1、2、3因为 3 在二进制里是 0b11会同时命中第 1 和第 2 位后续想单独判断就麻烦了。参数说明场景内物理体数量不多用 UInt32 即 32 个类别位绰绰有余如果项目里超过 16 种碰撞类型就要考虑层级动态切换而不是静态掩码。管道对小鸟的碰撞掩码是双向的小鸟的 contactTestBitMask 包含 .pipe管道的 contactTestBitMask 也包含 .bird。这样碰撞发生时哪一方先触发回调取决于物理引擎的内部调度所以代码里不能假设“一定是小鸟这边先收到 contact”应该两边都注册代理并做幂等处理。这份工程在 didBegin(_ contact:) 里用“先判断 node 名再判断是否已 gameOver”的方式做了防重入细节值得抄。2.3 update 循环与节奏控制deltaTime 决定移动是否跟手SpriteKit 的 update(_ currentTime:) 每帧调用一次但 currentTime 是绝对时间戳直接用两个时间戳相减会得到累积误差。正确做法是在 didMove(to view:) 里记录上一次时间戳每帧做差private var lastUpdateTime: TimeInterval 0 override func update(_ currentTime: TimeInterval) { if lastUpdateTime 0 { lastUpdateTime currentTime } let deltaTime currentTime - lastUpdateTime // 用 deltaTime 控制管道移动距离速度点/秒* 时间差秒 pipeNode.position.x - pipeSpeed * CGFloat(deltaTime) lastUpdateTime currentTime }代码说明管道移动不在 physicsBody 上施加力而是直接改 position.x这是经典移动方式。好处是管道不会受重力影响坏处是它不参与物理碰撞所以管道碰撞体必须单独挂在管道节点上。参数说明pipeSpeed 建议单位是“点/秒”比如 120 到 160deltaTime 在真机上大约 1/60模拟器上可能跳到 1/30如果你发现模拟器里管道飞得特别快先怀疑帧率差异。如果你用 SKAction.moveBy 而不是手动改 position就不需要手动计算 deltaTime。但手动控制的好处是能在同一帧里响应暂停、减速、加速、复活重排等逻辑——做进阶版时你会感谢这个选择。3. 核心逻辑逐段拆解鸟的跳跃、管道与计分三块主流程中间这一段是整份工程的心脏。我会把小鸟起跳、管道生成洗牌、碰撞计分这段逻辑从 scene 里抽出来讲透。每讲一块就给你一段可以直接粘回去改的代码顺带说明它为什么这么写、参数改哪里、改了之后手感怎么变。3.1 小鸟节点与“两段式跳跃”实现小鸟在屏幕上的物理行为本质上是一个带旋转的刚体。点击屏幕时给一个向上的冲量松手后重力把它拉回。很多改版把点击做成“直接改位置”或“持续加速”手感都很飘——正确做法是只用瞬时冲量后续交给重力场自己玩。// 小鸟起跳先清掉之前的垂直速度再给一个向上冲量 bird.physicsBody?.velocity.dy 0 bird.physicsBody?.applyImpulse(CGVector(dx: 0, dy: jumpPower))代码说明先归零速度再施加冲量能保证连续点击时每次跳跃都是满力度否则弹簧效应会出现“越点越飞不上去”。参数说明jumpPower 通常在 180260 之间数值越大越跟手。与之配套的是场景的 gravity一般在 physicsWorld.gravity 里设成 (0, -9.8)如果想营造月球手感就调小重力想让游戏更“压手”就调大重力。第二个细节是旋转动画。原版小鸟点击后抬头下落时低头这个动作如果交给物理体绕 z 轴转动会和实际下落速度脱节。常见做法是在 update 里实时计算目标角度let targetAngle max(min(bird.physicsBody!.velocity.dy * 0.02, .pi / 4), -3 * .pi / 4) bird.zRotation bird.zRotation (targetAngle - bird.zRotation) * 0.2代码说明用速度的 dy 映射旋转角velocity.dy 为正时小鸟抬头为负时低头。0.02 是缩放系数乘出来的角度控制在 -135° 到 45° 之间避免鸟头翻转成“倒挂”这种反直觉效果。0.2 是插值系数值越大转向越快值越小越平滑。参数说明想优化手感时优先调这两个系数而不是改物理参数——很多翻车现场是旋转跟手速度不匹配造成的。3.2 管道生成与移动位置洗牌与节点复用管道是 Flappy Bird 里唯一需要动态生成销毁的对象。最省性能的做法不是每根管道都新建 SKSpriteNode而是维护一个管道池预先创建若干组管道节点用 active 标记控制显示和隐藏。这份工程用了比较简单粗暴的方式——每次生成新的移出屏幕后 removeFromParent()。在小游戏规模下没问题但你想把这份代码扩展成无尽模式时建议改成池化func spawnPipePair() { let gapY CGFloat.random(in: -80...80) // 相对屏幕中线的偏移 let gapCenter frame.midY gapY let topPipe SKSpriteNode(color: .green, size: CGSize(width: 62, height: 320)) topPipe.position CGPoint(x: frame.width 40, y: gapCenter gapHeight / 2 160) topPipe.physicsBody SKPhysicsBody(rectangleOf: topPipe.size) topPipe.physicsBody?.categoryBitMask CollisionType.pipe.rawValue topPipe.physicsBody?.contactTestBitMask CollisionType.bird.rawValue topPipe.physicsBody?.collisionBitMask 0 addChild(topPipe) }代码说明gapHeight 是上下管道之间的空隙经典值是 260 到 320越大游戏越简单。gapY 用随机偏移控制每对管道的高度差取值在 -80 到 80 之间如果你想让难度渐进可以把这个范围和当前得分挂钩。物理体直接用 rectangleOf 轮廓比用纹理 alpha 生成物理体省性能且碰撞精度足够。collisionBitMask 0 表示管道不参与物理反弹只触发 contact 回调。参数说明管道宽度 62 在 375 宽的屏幕上占比约 1/6速度取 150 点/秒。两个值配合决定游戏的“压迫感”——想调难就加宽管道、减小空隙、提高速度三选二即可三个全调容易让玩家觉得纯拼运气。3.3 计分与状态机切换gameOver 一击必杀的标准写法计分的核心不只是“加分”而是要保证加分只触发一次。管道对碰撞事件会触发 didBegin(_ contact:)但小鸟穿过空隙后不会主动通知场景所以主流写法是计数节点放在小鸟身后小鸟每穿过一次空隙节点位置和管道上方对齐碰撞回调里判断 node 名匹配才加分。func didBegin(_ contact: SKPhysicsContact) { guard !isGameOver else { return } let bodyA contact.bodyA.node let bodyB contact.bodyB.node if bodyA?.name bird bodyB?.name pipe { enterGameOver() } else if bodyB?.name bird bodyA?.name pipe { enterGameOver() } else if bodyA?.name bird bodyB?.name ground { enterGameOver() } }代码说明guard 用来防重入因为一次物理接触可能触发多次回调。bodyA/bodyB 的顺序不定所以两两组合都要判断。scoreNode 的加分逻辑单独写在另一处利用“节点名是否为 passed”标记每通过一组管道只加一次分。状态机一般是枚举ready → playing → gameOver → gameOverDone。ready 是开场等待第一下点击playing 时 update 里的移动逻辑才执行gameOver 后所有移动停掉、小鸟继续掉到地面。这份工程在 gameOver 后没有立刻重置场景而是等待 0.8 秒再显示菜单这样玩家能看清死因手感更自然。4. 在 Xcode 里跑起来从模拟器到真机的配置与调试再漂亮的代码跑不起来了也白搭。这一章是实操配置覆盖模拟器选择、物理参数调优、调试可视化开关三个部分。照做之后你可以在 5 分钟内从“刚刚 clone 下来”变成“能修改参数并观察手感变化”。4.1 首次运行的三个关键配置用 Xcode 直接打开工程第一件事不是点 Run而是检查三个地方签名真机需要模拟器不需要、Target 的 iOS 版本、以及资源文件是否被正确拖入 Bundle。常见错误是把音频文件放到工程目录里但没勾选 “Copy Bundle Resources”导致运行时 SKAction.playSoundFileNamed 直接崩。确认无误后这样跑# 在有 xcodebuild 的环境下命令行也能跑模拟器 xcodebuild -project FlappyBird.xcodeproj -scheme FlappyBird -sdk iphonesimulator -configuration Debug build代码说明这里的 FlappyBird 是工程 Target 名换成你自己的。命令行跑的好处是能接 CI坏处是看不到现场崩溃信息新人先用 Xcode 界面的 Run 按钮更友好。参数说明如果模拟器跑起来黑屏先按 CommandShiftH 回到主屏幕看 App 图标是否在如果图标在但打开白屏多半是 SKView 的 presentScene 时机不对检查 GameViewController 的 viewDidLoad。模拟器机型选择建议用 iPhone 15或你手头某一款逻辑分辨率接近 393x852 的机型这套尺寸和多数场景设计图匹配避免用 iPad 模拟器跑——物理尺寸不同你会看到小鸟飞得很慢或管道很大原因不是代码问题而是 aspectFill 在不同比例下的实际可视区域不同。4.2 物理参数的调优区间与手感对应表手感是玄学但参数有范围。我整理了一份调试表按“想要什么效果”直接查参数比自己瞎试快得多手感目标主要参数推荐范围副作用更跟手jumpPower220260数值过大时连续点击会飞很高更飘physicsWorld.gravity.dy-6.0-7.0下落变慢难度下降更压迫管道移动速度 pipeSpeed160180反应时间变短更宽容管道空隙 gapHeight300340管道纹理要配合加高更有弹性bird.physicsBody.restitution0.10.3撞管道后会弹开整体节奏变化表格里每一项改动都要试玩至少五局。特别说明gravity 改为 -6.0 时小鸟的弧线更平缓很多新手上手会觉得“这鸟怎么不往下掉”其实只是重力调低了。restitution 往大调会让你撞到管道后弹回去看起来更合理但碰撞判定会多触发几次配合 guard 防重入才不会重复计分。4.3 打开 SpriteKit 调试可视化少走弯路SKView 自带几个调试开关能让你直接看到物理体和帧率不必肉眼看动画猜。在 GameViewController 里加三行skView.showsFPS true // 右上角显示帧率 skView.showsNodeCount true // 显示当前场景节点数量 skView.showsPhysics true // 高亮物理体轮廓代码说明showsPhysics 会把所有 SKPhysicsBody 的轮廓画成蓝色虚线一眼看出管道物理体是否对齐、小鸟碰撞体是不是比图片大一圈。showsNodeCount 用来查泄漏——如果节点数量一直涨说明管道生成了但没释放。参数说明这三个开关在 release 包记得关掉调试开关会拉低帧率尤其 showsPhysics 在 iPhone 老机型上可能掉到 30fps 以下。跑起来之后的排查顺序先看 showsPhysics 确认碰撞体位置再看 showsFPS 确认帧率最后看 showsNodeCount 确认内存稳定。这三个数据比 console 日志直观得多我修复大部分手感问题都是靠它们。5. 避坑与常见问题五次翻车后的排查记录这份工程我前后跑了五个版本踩过不少坑。有些坑是 SpriteKit 自身的特性有些是这份工程自身的写法导致的。下面按“现象 → 原因 → 解决”整理改工程时遇到对应问题可以直接翻本节。5.1 碰撞后小鸟穿管而过没有触发死亡现象小鸟明明撞上了管道却从纹理缝隙里穿过去继续飞sometimes 还会穿到管道中间卡住。 原因管道节点本身没有物理体只给管道图片的某个子节点挂了 physicsBody而接触回调判断的是节点名。如果节点名挂在父节点上子节点碰撞时 contact.bodyA.node 指向的是子节点名字对不上回调直接跳过。 解决把碰撞体挂到与 node 名相同的节点上或者改回调逻辑用contact.bodyA.node?.parent?.name一级一级向上排查。我在工程里统一成“物理体节点 逻辑节点”不再区分父子节点碰撞回调直接读 node.name简单粗暴但不出错。5.2 模拟器流畅真机卡顿掉帧现象模拟器跑起来 60fps 稳稳的一上真机就掉到 40fps 左右尤其管道多的时候。 原因模拟器用的是 Mac 的 CPU/GPU性能远超真机且模拟器不执行 Metal 的部分优化。真机上掉帧多数是节点数过多或纹理未合图。 解决先打开 showsNodeCount如果节点超过 300 就检查管道池是否没复用。另外把所有 PNG 图片放进 Texture Atlas减少 draw call 次数。我习惯把小鸟帧动画图片放进 atlas 后绘制开销降了 40%。5.3 音效延迟点击跳跃声音慢半拍现象点击屏幕后跳跃动作开始了半秒才有声音手感极差。 原因SKAction.playSoundFileNamed 在每次点击时都重新加载音频文件导致 I/O 堵塞。这份工程用了它的直接调用第一次点击时尤甚。 解决预加载音频在 didMove(to view:) 里用 SKAction 先播放一次音量极低的同款音效或者用 AVAudioPlayer 预加载到内存。更简单的方法是把音效文件转成 m4a 或 wav 并用SKAction.playSoundFileNamed配合await预执行。从那以后我每次加新音效都会先跑一遍“点击即播”测试。5.4 切后台再回来物理世界错乱现象游戏切到桌面再切回来小鸟直接飞上天或管道瞬移一大截。 原因应用进后台时SpriteKit 的 update 停止但 currentTime 还在走回到前台后deltaTime 当前时间 - 旧时间算出几十秒的差值移动距离瞬间爆表。 解决在applicationDidEnterBackground里暂停场景applicationWillEnterForeground里恢复。同时把 lastUpdateTime 重置为 0这样第一帧的 deltaTime 会被判定为 0避免跳变。5.5 双击屏幕造成跳跃加计分同时触发现象玩家快速连点两次得分在第二次点击后异常加了两分或跳跃失败。 原因双击时两次点击落在同一帧内计分节点的 passed 判断还没刷新导致加分段执行两遍。 解决在加分支加一个hasPassedThisPair布尔开关计分后立即置 truereset 时统一恢复。这份工程里我用节点字典存储每个管道对的 passed 状态每根管道只允许加分一次。6. 进阶把管道改成随机间隔并验证手感的一致性基础版玩顺之后我建议你做一个明确的小改动来检验自己对这份工程的理解把固定的管道生成间隔改成动态随机间隔。这个改动不复杂但碰到的坑和前面完全覆盖的路径不一样做完你会对整体手感有更完整的掌控。先说算法原版的生成间隔是固定值比如每 1.5 秒生成一对管道。改成随机后如果你直接拿Double.random(in: 1.2...2.0)做间隔难点会明显波动——因为玩家需要连续躲避间隔太短时反应不过来间隔太长时节奏断档。更好的做法是阶梯难度得分在 010 用固定 1.6 秒1020 用 1.4 秒20 以上用 1.2 秒再加随机扰动。func nextSpawnInterval() - TimeInterval { let base max(1.2, 1.8 - Double(score) * 0.02) return base Double.random(in: -0.1...0.1) }代码说明base 随得分线性递减保证难度单调上升随机抖动只在 ±0.1 秒内避免非酋开局连续两个最短间隔。验证方法打开 showsFPS 和 showsNodeCount连续跑三分钟记录管道生成时刻确认最小间隔不小于 1.1 秒最大间隔不超过 2.0 秒。如果发现某一段特别难把抖动幅度降为 0.05。这一步完成后你手上就有一套能调手感、能控制难度、能排查性能瓶颈的 Flappy Bird 工程了。这份代码我前后改过十几轮最后发现手感好坏并不在某个单一参数上而是“跳跃 → 重力 → 旋转 → 管道间距 → 判定时机”五个环节的协作关系。从那以后我每次做这类小游戏都会强制走一遍“先看物理体轮廓、再测点击响应、最后查节点内存”的流程这个习惯救过我很多次。希望帮到你。本文还有配套的精品资源点击获取