简介:面向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 到 160;deltaTime 在真机上大约 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 通常在 180~260 之间,数值越大越跟手。与之配套的是场景的 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 按钮更友好。参数说明:如果模拟器跑起来黑屏,先按 Command+Shift+H 回到主屏幕看 App 图标是否在;如果图标在但打开白屏,多半是 SKView 的 presentScene 时机不对,检查 GameViewController 的 viewDidLoad。
模拟器机型选择建议:用 iPhone 15(或你手头某一款逻辑分辨率接近 393x852 的机型),这套尺寸和多数场景设计图匹配,避免用 iPad 模拟器跑——物理尺寸不同,你会看到小鸟飞得很慢或管道很大,原因不是代码问题,而是 aspectFill 在不同比例下的实际可视区域不同。
4.2 物理参数的调优区间与手感对应表
手感是玄学,但参数有范围。我整理了一份调试表,按“想要什么效果”直接查参数,比自己瞎试快得多:
| 手感目标 | 主要参数 | 推荐范围 | 副作用 |
|---|---|---|---|
| 更跟手 | jumpPower | 220~260 | 数值过大时连续点击会飞很高 |
| 更飘 | physicsWorld.gravity.dy | -6.0~-7.0 | 下落变慢,难度下降 |
| 更压迫 | 管道移动速度 pipeSpeed | 160~180 | 反应时间变短 |
| 更宽容 | 管道空隙 gapHeight | 300~340 | 管道纹理要配合加高 |
| 更有弹性 | bird.physicsBody.restitution | 0.1~0.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布尔开关,计分后立即置 true,reset 时统一恢复。这份工程里我用节点字典存储每个管道对的 passed 状态,每根管道只允许加分一次。
6. 进阶:把管道改成随机间隔并验证手感的一致性
基础版玩顺之后,我建议你做一个明确的小改动来检验自己对这份工程的理解:把固定的管道生成间隔改成动态随机间隔。这个改动不复杂,但碰到的坑和前面完全覆盖的路径不一样,做完你会对整体手感有更完整的掌控。
先说算法:原版的生成间隔是固定值,比如每 1.5 秒生成一对管道。改成随机后,如果你直接拿Double.random(in: 1.2...2.0)做间隔,难点会明显波动——因为玩家需要连续躲避,间隔太短时反应不过来,间隔太长时节奏断档。更好的做法是阶梯难度:得分在 0~10 用固定 1.6 秒,10~20 用 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 工程了。这份代码我前后改过十几轮,最后发现手感好坏并不在某个单一参数上,而是“跳跃 → 重力 → 旋转 → 管道间距 → 判定时机”五个环节的协作关系。从那以后我每次做这类小游戏,都会强制走一遍“先看物理体轮廓、再测点击响应、最后查节点内存”的流程,这个习惯救过我很多次。希望帮到你。
本文还有配套的精品资源,点击获取