
简介一套基于Xcode的Flappy Bird完整工程源码面向iOS开发者与游戏入门者用于学习2D游戏开发项目基于SpriteKit框架完整呈现场景创建、物理模拟、触摸控制、碰撞检测、计分判定等内容通过阅读源码与工程配置可理解Flappy Bird核心机制并据此扩展动画、音效或新玩法。压缩包共58个文件、约507KB以Objective-C源文件、SpriteKit场景文件、图片素材、音频文件及工程配置为主结构清晰便于检索已有177人学习下载适合作为练手项目或教学案例。资源内含完整Xcode项目包括小鸟、管道、地面素材以及粒子特效、界面布局、本地化等细节可快速对照游戏循环与物理引擎的配合或修改参数体验不同难度是掌握SpriteKit开发的实用参考。1. 用 Xcode 做 flappy bird为什么值得认真做一遍二零一三年的 flappy bird 靠一根手指和几根绿色管道火遍了全球。时隔这么多年把 flappy bird 拿到 Xcode 里重做一遍仍然是我最常推荐的 iOS 原生游戏练手项目。它的玩法一句话说清点屏幕让小鸟获得一个向上的冲量不点它就下落穿过管道间隙加一分。这个项目看起来极其简单但它把 iOS 2D 游戏开发必须面对的几件事全部摊开了游戏循环、物理引擎、触摸事件、随机生成、碰撞检测、状态机。适合谁适合刚学完 Swift 基础、想在简历里放一个完整可上架作品的开发者也适合刚接触 SpriteKit 的 2D 游戏方向从业者。我下面所有代码都在 Xcode 14 及以上版本编译通过技术栈是 Swift SpriteKit不涉及任何第三方库。2. 把 flappy bird 拆成 Xcode 里的五个物件场景、小鸟、管道、碰撞、计分2.1 为什么选 SpriteKit 而不是 UIKit 或 SwiftUI很多人第一次想做游戏第一反应是用 SwiftUI 的 withAnimation 去移动小鸟或者用 UIKit 的 CADisplayLink 自己管帧循环。这两种方案不是不行但都不适合 flappy bird 这种实时物理驱动的游戏。它们的共同问题是物理模拟、碰撞检测、纹理动画全部要自己写一个初学项目里面塞进了太多不该由你负责的复杂度。常见做法是选 SpriteKitApple 官方的 2D 游戏框架。它自带物理引擎、粒子系统、场景管理和纹理图集并且和 Xcode 深度集成——你在 Xcode 里可以直接预览 .sks 场景文件。选它的真正理由不是“官方推荐”这种空话而是它把游戏循环的黑匣子打开了一个口子你写的 update(_:) 方法会每帧被调用物理引擎的模拟结果也在同一帧内同步。这意味着你可以用很少的代码拿到确定的、可调试的游戏行为。反过来说如果你坚持用 UIKit 做你很快会遇到两个翻车点一是 UIKit 的动画默认走 Core Animation帧调度和物理模拟不同步小鸟会“飘”二是碰撞检测得自己算矩形相交管道多了之后代码迅速失控。我不建议你用 UIKit 或者 SwiftUI 挑战这个东西除非你只是想证明自己能造轮子。2.2 五个物件SKScene 之外的五个核心类真正动手前先把 SpriteKit 里你绕不开的五个类搞清楚。它们就是 flappy bird 的全部零件SKScene场景。它承载游戏里的所有节点也是你写游戏逻辑的主战场。一个 flappy bird 只需要一个 GameScene。SKSpriteNode精灵节点。用来表示小鸟、管道、背景。凡是能在屏幕上画出来的对象基本都是它。SKPhysicsBody物理体。挂在 SKSpriteNode 上给节点加上质量、体积和碰撞形状。小鸟和管道都有它。SKAction动作。管道移动、小鸟扇翅膀动画、分数增加时的音效播放都可以用 SKAction 串成序列。它是 SpriteKit 里替代“写循环”的标准方案。SKLabelNode文本节点。计分标签就是它。把这五个类物化到具体对象就是一张非常清晰的职责表SKScene 负责组织SKSpriteNode 负责视觉SKPhysicsBody 负责物理碰撞SKAction 负责随时间变化的动作SKLabelNode 负责 UI。新手常犯的错是把所有逻辑都堆在 SKScene 里导致一个文件上千行。后面我会给你一个足够分担压力的目录结构但现在先保持一个场景文件讲清楚核心机制。2.3 GameScene 的骨架代码与运行循环打开 Xcode创建一个新的 iOS App模板选 Game语言选 Swift然后把自动生成的 ShapesScene 文件删掉保留 GameScene.swift 并写入下面的骨架。这段代码把 flappy bird 需要的基础设施准备好了背景、重力、小鸟占位、物理世界。import SpriteKit class GameScene: SKScene, SKPhysicsContactDelegate { private var bird: SKSpriteNode! private var scoreLabel: SKLabelNode! private var score 0 private var isGameOver false private var isGameStarted false override func didMove(to view: SKView) { // 背景颜色用天空蓝省一张背景图 backgroundColor SKColor(red: 0.30, green: 0.65, blue: 0.90, alpha: 1.0) // 物理世界只有一个重力flappy bird 的重力不需要太大 physicsWorld.gravity CGVector(dx: 0, dy: -4.5) physicsWorld.contactDelegate self setupBird() setupScore() setupGround() } override func update(_ currentTime: TimeInterval) { // 游戏循环的入口每帧调用一次 if isGameStarted !isGameOver { // 小鸟飞出屏幕就结束 if bird.position.y size.height || bird.position.y 0 { gameOver() } } } }这里的 didMove(to:) 等价于 iOS 生命周期里的 viewDidLoad游戏一开始只执行一次。physicsWorld.gravity 里的 dy 是竖直方向的重力加速度单位是 m/s²SpriteKit 默认是 -9.8。我写 -4.5 而不是默认值原因后面第 4 章专门讲。update(_:) 是 SpriteKit 的游戏循环入口每一帧都会调用你在这里做“飞出屏幕就结束”这类实时判断但尽量不要在里面做创建节点的操作否则帧率会肉眼可见地掉。2.4 小鸟和管道的物理体怎么配category 与 contact 的位掩码物理体是 flappy bird 最容易写乱的部分。你需要给每个节点设置三个位掩码categoryBitMask 表示自己属于哪一类contactTestBitMask 表示我想和哪些类发生接触回调collisionBitMask 表示我要和哪些类发生物理碰撞。private func setupBird() { bird SKSpriteNode(imageNamed: bird) bird.size CGSize(width: 34, height: 24) bird.position CGPoint(x: size.width * 0.3, y: size.height * 0.7) bird.physicsBody SKPhysicsBody(circleOfRadius: bird.size.width / 2) bird.physicsBody?.affectedByGravity false bird.physicsBody?.allowsRotation false bird.physicsBody?.restitution 0.0 bird.physicsBody?.linearDamping 0.8 // 用位掩码区分角色1 是小鸟2 是管道4 是地面 bird.physicsBody?.categoryBitMask 1 bird.physicsBody?.contactTestBitMask 2 | 4 bird.physicsBody?.collisionBitMask 2 | 4 addChild(bird) }我在这段代码里用 circleOfRadius 做小鸟的物理体而不是用矩形因为小鸟的图片是圆的矩形碰撞体在碰到管道边角时会产生不真实的“隔空碰撞”这是最典型的踩坑点之一。collisionBitMask 设置为 2 | 4意思是小鸟撞到管道和地面都弹不开直接判定失败。contactTestBitMask 和 collisionBitMask 的值一样所以接触回调和碰撞会同时触发你在 didBegin(_:) 里只处理一次游戏结束逻辑即可避免重复扣分。3. 在 Xcode 里从零跑通小游戏的最小实现场景搭建与核心循环3.1 用 Game 模板建项目删掉自带的旋转方块打开 Xcode选择 Create New Project模板选 iOS 下的 Game语言选 SwiftInterface 选 SpriteKit。这个模板会生成一个 GameScene.sks、一个 GameScene.swift 和一个控制它的 GameViewController.swift。模板默认的场景里有一个跟着触摸旋转的方块和一个“Hello, World”标签这些不是我们想要的。最简单的做法是把 GameScene.swift 里的内容全部替换成第 2 章的骨架代码GameScene.sks 直接删掉然后在 GameViewController.swift 里用纯代码创建场景避免 .sks 文件带来的场景尺寸困惑。import UIKit import SpriteKit class GameViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let scene GameScene(size: CGSize(width: 375, height: 667)) scene.scaleMode .aspectFill let skView view as! SKView skView.ignoresSiblingOrder true skView.showsFPS true skView.showsNodeCount true skView.presentScene(scene) } }这里 scaleMode 用 aspectFill 会让场景按屏幕比例缩放并填满整个 view视觉上不会有黑边。showsFPS 和 showsNodeCount 是调试开关打开后屏幕左上角会实时显示帧率和节点数量这是后面调优和排查性能问题的重要依据。用纯代码创建场景比用 .sks 文件更适合这个项目因为场景尺寸、重力这些关键参数都写在代码里寻找问题和调整参数时不用在两个文件之间来回跳。3.2 让小鸟动起来重力、冲量与触摸响应小鸟在点击屏幕之前是静止的点击一次才进入游戏并受到重力影响。这个设计是为了避免玩家打开 App 的瞬间小鸟就开始掉落手忙脚乱点不到第一次。override func touchesBegan(_ touches: SetUITouch, with event: UIEvent?) { if isGameOver { resetGame() return } if !isGameStarted { isGameStarted true bird.physicsBody?.affectedByGravity true } // 每次点击都给小鸟一个向上的冲量并且把横/纵向速度清零 bird.physicsBody?.velocity .zero bird.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 13)) }applyImpulse 是冲量直接改变速度。dy 给 13单位是物理学里的冲量概念实际手感会和重力加速度一起配合决定。关键点在于我在每次点击之前把 velocity 清零否则在快速连点的时候冲量会叠加在之前残留的向上的速度之上小鸟会像火箭一样瞬间冲出屏幕顶。这个问题在一开始会骗到很多人表现是“鸟怎么越点越高”。还有一个细节touchesBegan 里不要用 location 去判断点击区域。flappy bird 的游戏逻辑是全屏可点的任何位置点击都应该让小鸟上跳。你只需要关心触摸事件本身不需要关心触摸位置除非你后面要做暂停按钮。3.3 生成管道与计分SKAction 驱动的随机间隙管道生成是 flappy bird 最核心的循环逻辑。常见做法是用一个 SKAction 序列无限等待固定时长后生成一对管道再用另一个 SKAction 让管道横向移动并最终移出屏幕。private func startSpawningPipes() { let wait SKAction.wait(forDuration: 2.0) let spawn SKAction.run { [weak self] in self?.spawnPipePair() } let sequence SKAction.sequence([wait, spawn]) run(SKAction.repeatForever(sequence), withKey: pipeSpawner) } private func spawnPipePair() { // 管道之间的垂直空隙中心随机偏移范围控制在 -80 到 80 let randomOffset CGFloat.random(in: -80...80) let gapCenterY size.height / 2 randomOffset let gapHeight: CGFloat 150 let pipeWidth: CGFloat 52 let pipeHeight: CGFloat 320 // 上管道底部在 gapCenterY gapHeight/2 以上 let upperPipe SKSpriteNode(color: .green, size: CGSize(width: pipeWidth, height: pipeHeight)) upperPipe.position CGPoint(x: size.width pipeWidth / 2, y: gapCenterY gapHeight / 2 pipeHeight / 2) // 下管道顶部在 gapCenterY - gapHeight/2 以下 let lowerPipe SKSpriteNode(color: .green, size: CGSize(width: pipeWidth, height: pipeHeight)) lowerPipe.position CGPoint(x: size.width pipeWidth / 2, y: gapCenterY - gapHeight / 2 - pipeHeight / 2) for pipe in [upperPipe, lowerPipe] { pipe.physicsBody SKPhysicsBody(rectangleOf: pipe.size) pipe.physicsBody?.categoryBitMask 2 pipe.physicsBody?.contactTestBitMask 1 pipe.physicsBody?.collisionBitMask 1 pipe.physicsBody?.affectedByGravity false pipe.physicsBody?.isDynamic false addChild(pipe) let moveLeft SKAction.moveTo(x: -pipeWidth / 2, duration: 4.0) let remove SKAction.removeFromParent() pipe.run(SKAction.sequence([moveLeft, remove])) } // 计分节点放在间隙正中央只要小鸟穿过就加分 let scoreNode SKNode() scoreNode.position CGPoint(x: size.width pipeWidth, y: gapCenterY) scoreNode.physicsBody SKPhysicsBody(rectangleOf: CGSize(width: 1, height: gapHeight)) scoreNode.physicsBody?.categoryBitMask 3 scoreNode.physicsBody?.contactTestBitMask 1 scoreNode.physicsBody?.collisionBitMask 0 scoreNode.physicsBody?.isDynamic false addChild(scoreNode) let moveLeftScore SKAction.moveTo(x: -pipeWidth, duration: 4.0) scoreNode.run(SKAction.sequence([moveLeftScore, SKAction.removeFromParent()])) }这段代码里有三个值得解释的决策。第一管道用纯色 SKSpriteNode 而不是图片开发阶段可以避免找素材真机调试阶段再替换成纹理图。第二管道和计分节点使用同一个 moveLeft 动作时长保证它们始终在同一水平线上移动计分节点不会和管道错位。第三计分节点的 physicsBody 是一个只有 1 像素宽、gapHeight 高的隐形矩形它的 collisionBitMask 是 0意思是不会和任何物体发生物理碰撞但 contactTestBitMask 为 1所以小鸟穿过间隙时依然会触发 didBegin(_:) 回调从而实现计分。3.4 碰撞判定与游戏结束的完整流程碰撞回调只需要处理两类情况小鸟撞到管道或者小鸟撞到计分节点。撞到管道直接游戏结束撞到计分节点分数加一。func didBegin(_ contact: SKPhysicsContact) { if isGameOver { return } let contactMask contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask if contactMask 1 | 2 { // 小鸟撞到管道 gameOver() } else if contactMask 1 | 3 { // 小鸟穿过计分节点 score 1 scoreLabel.text \(score) } }这里用位或运算合并两个碰撞体的 categoryBitMask 来判断碰撞类型而不是去判断 bodyA 和 bodyB 分别是谁。原因是 SpriteKit 在回调时两个碰撞体的顺序不固定用位运算可以避免“bodyA 是小鸟还是管道”这种方向判断。gameOver 里要做的事情是停止管道生成、把所有管道节点的动作停下来、显示 Game Over 标签。注意停止管道生成不要用 removeAction(forKey:) 之前先判断是否存在否则会出现“点击屏幕时管道还在继续生成”的漏网情况。我的习惯是给生成动作一个固定 key这样 remove 的时候能精确定位。private func gameOver() { isGameOver true removeAction(forKey: pipeSpawner) bird.physicsBody?.velocity .zero bird.removeAction(forKey: fly) let overLabel SKLabelNode(fontNamed: AvenirNext-Bold) overLabel.text Game Over overLabel.fontSize 36 overLabel.position CGPoint(x: size.width / 2, y: size.height / 2) addChild(overLabel) }4. 手感调优三件套重力、冲量、管道间隙的数值玄学4.1 为什么默认重力下小鸟像铅球把 physicsWorld.gravity 保持默认的 -9.8再把 applyImpulse 的 dy 设为 13你会发现小鸟下落特别快点击之后的上升高度很大整体手感和原版 flappy bird 完全不像。原因在于默认重力模拟的是现实世界的自由落体加速度但 2D 游戏的手感不是物理课它要的是“玩家能预判的节奏”。我调这类小游戏的习惯是先定重力再定冲量最后定管道间距。重力决定下落曲线冲量决定一次跳跃的高度管道间距决定玩家对“必须精确穿过”的紧张程度。参数flappy bird 常用值手感影响physicsWorld.gravity.dy-4.5 到 -6.0绝对值越大下落越快越小手感越飘applyImpulse.dy11 到 15越大单次上升越高越小越需要连点管道垂直间隙140 到 170越小难度越高视觉上越挤管道横移时长3.0 到 4.5 秒越长画面越慢越短反应越紧张管道生成间隔1.8 到 2.2 秒间隔越短管道越密这四个数值之间是联动的重力是 -6、冲量是 15单次跳跃高度如果太高管道间隙就得缩小重力是 -4.5、冲量是 11跳跃高度低管道横向移动速度就不能太快否则玩家来不及反应。4.2 用 Xcode 的调试面板直接观察调参效果Xcode 在模拟器或真机运行游戏时左侧调试导航栏的 CPU 图表和屏幕上的 FPS 数字能提供两个维度的信息游戏是否流畅、每帧节点数量是否合理。但对手感来说真正有用的是帧率之外的时间感——你把管道横移时长从 4.0 改成 3.2 秒在模拟器上玩五遍比看任何参数表都直观。这里有个值得注意的坑模拟器上用键盘和鼠标操作触摸反应延迟和真机不同所以手感调优最终一定要在真机上跑一遍。我的做法是先以模拟器为辅调出大体手感再上真机微调冲量这一个参数。冲量对触摸延迟最敏感在模拟器上合适的值放到真机上往往会偏大因为真机触摸响应快玩家点击频率会更高小鸟反而容易越飞越高。4.3 状态机ready、running、gameOver 三态切换没有状态机的 flappy bird 在逻辑上必然出 bug。常见的症状是游戏结束后点击屏幕分数清零了但管道还在继续生成或者游戏还没开始小鸟就开始受重力下坠。这些现象的本质是同一个你没有区分游戏处在哪个阶段。我在代码里用两个布尔值加一个隐式状态管理isGameStarted 表示是否已开始isGameOver 表示是否已结束。三者的关系是初始时 isGameStarted 和 isGameOver 都是 false第一次点击后 isGameStarted 变为 true碰撞后 isGameOver 变为 true点击重置后两个布尔值恢复初始值。private func resetGame() { isGameOver false isGameStarted false score 0 scoreLabel.text 0 bird.position CGPoint(x: size.width * 0.3, y: size.height * 0.7) bird.physicsBody?.velocity .zero bird.physicsBody?.affectedByGravity false removeAllChildren() setupBird() setupScore() setupGround() startSpawningPipes() }这个 resetGame 方法是一个我早期会忽略的后悔药很多初次实现者把重置逻辑写成“从当前状态继续”结果 removeAllChildren 把计分标签也删了或者旧管道的动作还在跑。正确做法是先 removeAllChildren 清空下单再重新执行 setup。代码看起来笨但胜在状态一定干净不会在迭代中翻车。5. Xcode 开发避坑从 Memory Graph 到打包变慢的五个真实问题5.1 Memory Graph 图标显示内存翻倍纹理没复用的血泪经验现象在真机调试时点 Xcode 顶部调试栏的 Memory Graph 图标发现游戏玩一分钟内存占用从 80MB 一路涨到 300MB 以上。打开 Memory Graph 的视图层级能看到管道节点堆积了几十个没有释放。原因SKAction 的 removeFromParent 确实移除了节点但如果你在每帧的 update 里用 SKSpriteNode(imageNamed:) 创建新节点而不是用预加载的纹理那么 SpriteKit 每次都会加载一次图片到内存。典型写法错误是管道节点里用了不同的纹理名比如把上管道和下管道分别命名为 pipe_upper 和 pipe_lower而图片资源里并不存在独立的上/下管道图导致 SpriteKit 反复重新加载同一个图。解决开局用 SKTextureAtlas 或预加载纹理之后创建节点只引用纹理对象不引用图片名字。另外一个更隐蔽的问题是 Memory Graph 图标里看起来一样的节点其实持有不同的纹理引用你需要在 Memory Graph 面板里点击节点查看它的纹理对象地址。如果地址不重复就是纹理重复加载了。这个排查过程看起来玄学但本质还是缓存失效问题。5.2 模拟器上一卡一卡、物理体发飘先怀疑模拟器再怀疑代码现象在 macOS 自带的 iOS 模拟器上运行 flappy bird小鸟落地的轨迹不连贯偶尔还会出现物理体穿透管道的视觉错位。代码在真机上表现正常。原因iOS 模拟器在 Mac 上用 CPU 模拟 GPU 的工作SpriteKit 的物理引擎在模拟器上的帧率不稳定尤其是场景中有大量节点时模拟器的 Metal 渲染会拉低整体刷新率。物理模拟的步进时间不稳定碰撞回调和 render 帧之间出现微小错位看起来就是“穿模”。解决这不是你的代码问题不需要重写物理体参数。你先用模拟器跑通逻辑和 UI手感调优和碰撞验证一律放真机。如果你只有模拟器可用可以把场景里的管道节点数量暂时减半或者把 Xcode 菜单 Debug Simulator Performance 关闭某些图形效果能一定程度缓解卡顿但替代不了真机验证。判断是不是模拟器问题的最快方法是看屏幕左上角的 FPS模拟器上 30 帧波动大真机上稳定 60 帧那就是模拟器性能瓶颈而不是代码瓶颈。5.3 Xcode 打包 iOS 突然很慢从 DerivedData 到 Watch 的排查现象项目本来打包 30 秒某天突然变成五分钟以上甚至卡在“Running 1 of 1 tasks”不动。重试多次也没有改善。原因最常见的是 DerivedData 缓存膨胀。Xcode 每次构建都会向 DerivedData 写入中间文件积累到几十 GB 后编译链接阶段的文件索引和缓存查找会显著变慢。另一个常见因素是 Xcode 把 Apple Watch、Widget 等扩展也纳入了构建哪怕你没有改动它们也会参与编译。还有一个冷门原因系统日志服务堆积Xcode 的构建日志写入变慢。解决先清理 DerivedData。Xcode 菜单 File Workspace Settings 里点击 Advanced能看到 DerivedData 的路径一般是 ~/Library/Developer/Xcode/DerivedData手动删除对应项目文件夹或者用命令行rm -rf ~/Library/Developer/Xcode/DerivedData然后重启 Xcode。如果问题依旧检查项目的 Build Phases确认是否有无用的 Run Script 或辅助 target 被勾选。最后检查 Xcode 的 Build Settings 里 Debug 模式的优化级别是否被误设为整个模块优化这会让首次构建时间暴涨。我一般把 Debug 的 Optimization Level 保持为 NoneRelease 才开 Fastest。5.4 报错 “you should download the command line tools for Xcode 26.3”命令行工具版本不匹配现象在终端里执行 xcodebuild 或者 git 就报错提示需要下载对应版本的 Command Line Tools。但在 Xcode 的 Settings 里明明已经安装了。原因Xcode 升级后系统里旧版本的命令行工具配置没有随之更新xcode-select 指向的路径仍然是一个不存在的开发者目录。或者你同时装了多个 Xcode betaxcode-select 指到了那个 beta 版本上而 beta 版本的命令行工具没装全。解决先看当前的选中路径xcode-select -p如果输出不是 /Applications/Xcode.app/Contents/Developer就手动切换sudo xcode-select -s /Applications/Xcode.app/Contents/Developer然后再执行xcode-select --install它会弹出图形安装界面或直接完成安装。这一步做完终端里的 xcodebuild 和 git 通常就恢复了。这个问题和游戏代码本身无关但在你准备用命令行打包或者写自动化脚本时一定会遇到提前排查可以省掉很多无头绪的等待。5.5 分数重复累加的玄学谁动了我的 contact delegate现象小鸟穿过一个管道间隙分数一次加两分甚至三分偶尔还在游戏结束后继续加分。原因didBegin(_:) 每一帧可能被触发多次尤其是管道和计分节点同时与小鸟发生接触时SpriteKit 会针对每一对接触对象各回调一次。如果你没有在回调里做“同一帧只处理一次”的保护分数就会累加。另一个常见原因是在 gameOver 时没有把 physicsWorld.contactDelegate 置空导致复活后的第一帧又把上一次接触回调带进来。解决在 didBegin 开头加 isGameOver 判断是第一步它阻止游戏结束后的加分但同帧多次回调仍然会造成分数多加。常用的解法是给 count 逻辑加一个瞬时标记private var canAddScore true func didBegin(_ contact: SKPhysicsContact) { guard !isGameOver else { return } let contactMask contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask if contactMask 1 | 3, canAddScore { score 1 scoreLabel.text \(score) canAddScore false } else if contactMask 1 | 2 { gameOver() } }然后在 resetGame 里把 canAddScore 恢复为 true。这个 bool 标记的本质是“同一对管道只允许计一次分”因为 isGameOver 只能阻止跨游戏的重复不能阻止同一帧内多次接触回调。6. 复用这篇脚手架做验证和扩展从 CPU 占用到 GameConfig 配置化6.1 三种验证方式CPU、内存与真机手感当你把 flappy bird 跑起来后验证它是不是“能上线”的代码我会用三个指标。第一是 Xcode Debug 导航栏里的 CPU 图表游戏空闲和游戏中帧率最好都稳定在 55 帧以上如果 CPU 持续 100%说明每帧里做了太多创建和销毁节点的操作。第二是 Memory Graph 图标跑三分钟看内存是否稳定在目标值附近重点检查管道节点是否被移除以及纹理是否有重复加载。第三是手感验证左手拿手机右手点屏幕闭眼连续点 20 次感受每次点击后小鸟的上升高度是否一致如果不一致说明冲量叠加或线性阻尼设置有问题。6.2 把参数板进 GameConfig不再靠改代码调手感在第 4 章你已经看到那些数值组合是玄学但它不应该藏在 GameScene.swift 里。我的习惯是抽一个 GameConfig 结构体把所有可调参数放在一起方便反复尝试不同手感。struct GameConfig { var gravity: CGFloat -4.5 var jumpImpulse: CGFloat 13 var pipeSpeed: CGFloat 3.5 var pipeGap: CGFloat 150 var pipeWidth: CGFloat 52 var spawnInterval: TimeInterval 2.0 }然后在 GameScene 里用 config.gravity、config.jumpImpulse 这样的写法替代裸数字。这样你可以在 Xcode 的调试控制台里快速改参数不需要为了一个手感数值反复 git 提交。对维护来说这比“在第 120 行把 13 改成 14”清晰得多。6.3 把 flappy bird 当脚手架做别的 2D 游戏flappy bird 的核心循环——点击、上升、碰撞、计分——几乎可以原样套用到所有跑酷类和躲避类游戏。把管道换成障碍物把小鸟换成角色重力改成横向推力就是一个新的玩法。如果你愿意还可以在 resetGame 里接 Game Center 排行榜把分数上报到 Apple 的 Game Center 服务走一遍从证书配置到上架的全流程。那是另外一个很长的工程但 flappy bird 是最好的起点。我的习惯是每写完一个小游戏都留一份可运行的最小版本方便以后想验证某个 SpriteKit 特性时快速查阅。把核心参数配置化、把状态机写清楚、在调试时信任 Memory Graph 和 FPS 数字这套工作方式会在你后面接触更复杂的 iOS 游戏时帮上大忙。希望帮到你。本文还有配套的精品资源点击获取