简介一份基于Xcode的Flappy Bird游戏源码与开发学习资料面向iOS初学者、游戏开发爱好者以及需要上手SpriteKit的开发者旨在通过复刻经典游戏掌握iOS 2D游戏从设计到实现的全过程。包内含五十八个文件主要有Objective-C源码、SpriteKit场景与粒子文件、图片音效素材、工程配置和说明文档整体压缩后仅507KB内容精炼、目录清晰方便按模块查阅。目前已有177人学习下载。该资源覆盖游戏循环、物理模拟、管道随机生成、碰撞检测、计分逻辑与触摸事件处理等核心模块并涉及使用Interface Builder布局界面和Auto Layout适配依托可运行的Xcode工程读者能对照源码理解小鸟受重力影响、障碍物动态生成及游戏结束判定等关键实现也很适合作为休闲游戏开发的入门实战项目还能为复现其他2D小游戏提供参考。资源中附带的图片与音效素材可直接用于游戏能减少开发时的素材准备工作。1. 为什么用 Xcode 写 flappy bird一个不靠引擎的轻量原型某开发者接了一个休闲小游戏的预研需求第一反应是打开跨平台引擎后来发现为了一个上下跳跃的 Demo 拖进整套编辑器太重光加载工程就要等半分钟。于是他改用 Xcode 自带的 SpriteKit 重写了一个 flappy bird 原型整个工程只有几个 Swift 文件从工程里直接跑起来就能玩。这个项目就是一个完整的 Xcode/SpriteKit 复刻版点击屏幕给小鸟一个上抛冲量管道从右侧按固定节奏生成碰到地面或管道就结束飞过一根管道自动加一分。它适合两类人一是想弄清楚 SpriteKit 场景、物理体和接触事件怎么配合的开发者二是需要快速验证玩法手感、又不想被重型引擎拖慢节奏的原型团队。2. 工程骨架先看清SpriteKit 场景、物理世界和游戏循环2.1 目录结构哪里是场景、哪里是资源拿到源码包先别急着编译。我习惯先把目录铺开看一遍弄清楚每个文件存在的意义。这个项目的目录很克制核心入口就三个flappy-bird-xcode/ ├── flappy_bird.xcodeproj ├── flappy_bird/ │ ├── AppDelegate.swift │ ├── ViewController.swift │ ├── GameScene.swift │ ├── GameScene.sks │ ├── Assets.xcassets/ │ │ ├── AppIcon.appiconset/ │ │ ├── background.imageset/background.png │ │ ├── bird.imageset/bird.png │ │ ├── pipeTop.imageset/pipeTop.png │ │ └── pipeBottom.imageset/pipeBottom.png │ └── Info.plist └── README.mdAppDelegate.swift 负责应用启动和生命周期回调这是所有 iOS 工程的标配基本不用大改。ViewController.swift 才是入口它拿到 SKView创建 GameScene然后 presentScene。GameScene.sks 是 SpriteKit 的可视化场景文件保存场景尺寸和初始节点但真正玩法逻辑全在 GameScene.swift 里。很多第一次接触 SpriteKit 的人容易在 .sks 和 .swift 之间迷失。你只需要记住一句话能放在代码里的节点尽量放在代码里。这个项目没有把小鸟和管道做成 .sks 里的可视化节点而是在代码里动态创建好处是方便控制复用和重置不会出现“sks 里有个看不见的节点一直找不到”的尴尬局面。ViewController 的 present 逻辑大致长这样import UIKit import SpriteKit class ViewController: UIViewController { IBOutlet var skView: SKView! override func viewDidLoad() { super.viewDidLoad() if let scene GameScene(fileNamed: GameScene) { scene.scaleMode .resizeFill skView.presentScene(scene) } skView.showsFPS true skView.showsPhysics false } }GameScene(fileNamed:) 会按 GameScene.sks 里存的尺寸初始化场景。scaleMode 设置成 .resizeFill意思是场景的 contentSize 会匹配 SKView 的 bounds竖屏休闲游戏最常用就是这个。showsFPS 打开后左上角会显示帧率后面调性能时用它看是否有掉帧showsPhysics 用来画物理体调试轮廓正式发布前记得关掉。如果 .sks 文件缺失或名称对不上fileNamed 会返回 nil然后游戏白屏。所以如果你要重命名场景文件一定要同时改 fileNamed 里的字符串。这种字符串错误不会在编译时报出来只会运行到这一行才崩或白屏遇到类似问题先查这里。2.2 游戏循环SKView 和 SKAction 替你管了时间2D 游戏的核心是循环渲染、更新逻辑、响应用户输入。用 SpriteKit 时这个循环已经存在不需要自己开线程。SKView 驱动 SKScene 的每一帧SKScene 会给所有节点执行 action也会推进 physicsWorld 的物理模拟。你要做的只是在 didMove(to:) 里组装好规则然后在合适的回调里响应。override func didMove(to view: SKView) { physicsWorld.gravity CGVector(dx: 0, dy: -4.0) physicsWorld.contactDelegate self setupBackground() setupBird() startPipes() }这里 gravity 的竖直分量设成 -4.0而不是真实世界的 -9.8这是故意为之。dy 越大小鸟掉得越快点击反应窗口越短游戏会显得很“硬”。dy 越小下落越轻休闲感更强。SpriteKit 里重力单位是 pt/s²负号表示向下。你可以把它当成一个手感参数来调后面二次开发时重点改它。接触事件是 SpriteKit 物理世界提供的另一套机制。SKPhysicsWorld 的 contactDelegate 设置成 GameScene 自己之后只要两个物理体发生接触didBegin(_:) 就会回调。但注意不是所有接触都会触发回调必须有一方的 contactTestBitMask 包含对方类别。这是新手最容易忽略的配置后面避坑章节会专门展开。游戏循环中最容易犯的错误是“套娃动作”。比如你直接在 startPipes 里 run 一个 SKAction.repeatForever没有用 key 管理重置游戏时又调用一次 startPipes旧动作流还在新动作又启动管道生成频率直接翻倍。我的习惯是给所有重要的长周期动作都加 keyfunc startPipes() { removeAction(withKey: pipeSpawning) let spawn SKAction.sequence([ SKAction.run { [weak self] in self?.spawnPipePair() }, SKAction.wait(forDuration: 1.8) ]) run(SKAction.repeatForever(spawn), withKey: pipeSpawning) }withKey 相当于给动作流起一个名字。removeAction(withKey:) 可以在任何时刻停掉它。SKAction.wait(forDuration:) 的 1.8 秒是两对管道之间的生成间隔间隔越短同屏管道越多难度越大。这里用了 [weak self] 防止闭包持有 self 造成循环引用注意 GameScene 被移除时不要强行保活节点。2.3 物理体与接触掩码三组位掩码各管一件事物理体不是挂上就完事。一个 SKPhysicsBody 有 categoryBitMask、contactTestBitMask、collisionBitMask 三组位掩码。不理解这三者的关系后面的碰撞判定就完全靠猜。我用一组常量集中管理掩码方便后期加新类别时不至于用裸数字写到到处都是enum CollisionMask { static let bird: UInt32 1 0 static let pipe: UInt32 1 1 static let ground: UInt32 1 2 static let score: UInt32 1 3 }每一类占一个 bit用位运算组合。categoryBitMask 表示“我是哪一类”contactTestBitMask 表示“我想被谁碰到时收到通知”collisionBitMask 表示“我不想和谁发生物理反弹”。给角色配置时通常是这样bird.physicsBody SKPhysicsBody(circleOfRadius: bird.size.width * 0.5) bird.physicsBody?.categoryBitMask CollisionMask.bird bird.physicsBody?.contactTestBitMask CollisionMask.pipe | CollisionMask.ground | CollisionMask.score bird.physicsBody?.collisionBitMask CollisionMask.ground bird.physicsBody?.isDynamic true bird.physicsBody?.allowsRotation false这里让小鸟只和地面做真正的物理碰撞和管道、计分节点都只触发接触回调但不产生反作用力。换句话说小鸟从管道中穿过去但 didBegin 会告诉你“它碰到了管道”然后你手动结束游戏。这个设计比让物理引擎把小鸟弹飞要容易控制得多。didBegin 回调里要注意 bodyA、bodyB 顺序不固定。不能假设 bodyA 是小鸟也不能假设 bodyB 是管道。安全做法是把两者的掩码合并起来再判断func didBegin(_ contact: SKPhysicsContact) { let mask contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask if mask CollisionMask.bird 0 { return } if mask CollisionMask.pipe ! 0 || mask CollisionMask.ground ! 0 { gameOver() } else if mask CollisionMask.score ! 0 { score 1 updateScoreLabel() } }为什么把 score 放在 else if 里因为如果计分节点和管道位置重叠或者小鸟在穿过计分节点时又碰到管道边缘同一帧可能同时满足两个条件。先判断死亡再判断加分至少能保证“撞上管道时不会先加一分再死”这个细节直接影响玩家反馈的准确度。关于位运算mask 是 bodyA 和 bodyB 的 category 按位或。如果 bodyA 是 bird(1)bodyB 是 pipe(2)mask 3。3 与 bird(1) 相与非零与 pipe(2) 相与非零与 ground(4) 相与为 0这样就能区分。如果 bodyA 是 ground(4)bodyB 是 bird(1)mask 5依然能正确识别。所以这个写法无论顺序怎么变都稳定。3. 核心模块拆解小鸟上抛、管道生成与碰撞判定3.1 小鸟每次点击都清空速度再给一个脉冲小鸟的移动完全交给物理引擎重力让鸟向下点击给一个向上的脉冲。但如果只是在鸟的 physicsBody 上 applyImpulse而不清空之前的 velocity第二次点击时旧的下落速度还在脉冲会被部分抵消跳跃手感忽高忽低。所以正确顺序是先把 velocity 置零再 applyImpulse。override func touchesBegan(_ touches: SetUITouch, with event: UIEvent?) { guard !gameOver else { resetGame() return } guard let body bird?.physicsBody else { return } body.velocity .zero body.applyImpulse(CGVector(dx: 0, dy: 280)) }dy: 280 是上抛脉冲力度单位是 kg·pt/s但它和 physicsWorld.gravity -4.0 是配对的重力越小需要的脉冲也越小。如果你改了 gravity就要重新调 280 这个值。常见做法是把这几个参数提成属性后面二次开发部分会专门讲。allowsRotation 默认是 true小鸟会被力矩影响一边下落一边翻转视觉上很飘。关掉它让鸟保持固定朝向死亡时再手动加一个旋转动作效果更好控制。private func setupBird() { let bird SKSpriteNode(imageNamed: bird) bird.size CGSize(width: 40, height: 30) bird.position CGPoint(x: size.width * 0.3, y: size.height * 0.6) bird.name bird bird.zPosition 10 let physics SKPhysicsBody(circleOfRadius: bird.size.width * 0.5) physics.categoryBitMask CollisionMask.bird physics.contactTestBitMask CollisionMask.pipe | CollisionMask.ground | CollisionMask.score physics.collisionBitMask CollisionMask.ground physics.isDynamic true physics.allowsRotation false physics.usesPreciseCollisionDetection true bird.physicsBody physics addChild(bird) }usesPreciseCollisionDetection 是专门用来防穿透的开关。管道和计分节点都很薄特别是计分节点只有 2 点宽如果小鸟速度过快一帧内可能从计分节点的一侧直接穿到另一侧导致接触回调没触发。打开这个开关后物理引擎用更小的步长检测“路径上的接触”代价是 CPU 消耗略增但对于这个项目两三个物理体的规模来说可以忽略。3.2 管道时间驱动生成位置随机动作自动回收管道的生成用 SKAction 定时驱动不用 Timer。原因是 Timer 不归 SKScene 管理当 SKView 暂停、app 进入后台时 Timer 仍可能继续跑恢复时管道位置会跳变。SKAction 挂在场景上随场景一起暂停和恢复行为更一致。private func spawnPipePair() { let topTexture SKTexture(imageNamed: pipeTop) let bottomTexture SKTexture(imageNamed: pipeBottom) let topPipe SKSpriteNode(texture: topTexture) let bottomPipe SKSpriteNode(texture: bottomTexture) let pipeWidth: CGFloat 70 let pipeHeight: CGFloat 320 let gapHeight: CGFloat 150 let gapCenter size.height * 0.5 CGFloat.random(in: -40...40) topPipe.size CGSize(width: pipeWidth, height: pipeHeight) bottomPipe.size CGSize(width: pipeWidth, height: pipeHeight) topPipe.position CGPoint(x: size.width 80, y: gapCenter gapHeight / 2 pipeHeight / 2) bottomPipe.position CGPoint(x: size.width 80, y: gapCenter - gapHeight / 2 - pipeHeight / 2) topPipe.name pipe bottomPipe.name pipe topPipe.zPosition 5 bottomPipe.zPosition 5 let topBody SKPhysicsBody(rectangleOf: topPipe.size) let bottomBody SKPhysicsBody(rectangleOf: bottomPipe.size) topBody.categoryBitMask CollisionMask.pipe bottomBody.categoryBitMask CollisionMask.pipe topPipe.physicsBody topBody bottomPipe.physicsBody bottomBody let moveLeft SKAction.moveBy(x: -size.width - 200, y: 0, duration: 4.0) let remove SKAction.removeFromParent() topPipe.run(SKAction.sequence([moveLeft, remove])) bottomPipe.run(SKAction.sequence([moveLeft, remove])) }创建一个管道对只做四件事定大小、定位置、加物理体、跑移动动作。管道离屏位置是 size.width 80这样从右边完全看不见。moveBy 的横向移动距离是 -size.width - 200确保它一直移动到屏幕左侧之外才触发 removeFromParent。duration 4.0 是管道横穿整个屏幕的秒数越小游戏节奏越快。gapHeight 150 是上下两根管道之间的通道高度通道越窄越难。gapCenter 在场景高度一半的基础上加一个 -40...40 的随机偏移让每一组管道的位置都有变化。随机范围越大玩家需要调整高度的时间窗口越短。为了难度可调可以把这些参数提成属性而不是塞在方法里let pipeSpeed: TimeInterval 4.0 let pipeGap: CGFloat 150 let pipeWidth: CGFloat 70 let pipeSpawnInterval: TimeInterval 1.8然后在 spawnPipePair 里替换对应的硬编码。后续加“难度递增”时只要在这个属性的 getter 里按时间变化即可比如分数越高pipeGap 越小。注意一点上下两根管道使用了同一张纹理但通过 size 拉伸成 320 的高度这要求美术给的原图最好是可拉伸的纯色或渐变图案否则高度一拉伸就变形。3.3 计分判定用一个 2 点宽的透明节点检测穿越管道碰到小鸟判定死亡这没有问题。但“飞过管道”怎么计分有人会在 update 里比较管道 x 坐标和小鸟 x 坐标这样容易出现一帧跨过多个计分点或漏判的情况。SpriteKit 的做法是生成一个透明计分节点跟着管道一起移动小鸟穿过它时触发一次 contact。let scoreNode SKSpriteNode(color: .clear, size: CGSize(width: 2, height: gapHeight)) scoreNode.position CGPoint(x: size.width 80, y: gapCenter) scoreNode.name scoreNode let scoreBody SKPhysicsBody(rectangleOf: scoreNode.size) scoreBody.isDynamic false scoreBody.categoryBitMask CollisionMask.score scoreBody.contactTestBitMask CollisionMask.bird scoreNode.physicsBody scoreBody addChild(scoreNode) scoreNode.run(SKAction.sequence([moveLeft, remove]))计分节点是一个 2 点宽、gapHeight 高的透明矩形。isDynamic false 表示它不响应物理力但碰撞检测依然生效。categoryBitMask 设为 scorecontactTestBitMask 设为 bird这样只有小鸟经过它时才会触发 didBegin。为什么宽度是 2 而不是 1太薄可能会因为碰撞检测的精度问题在高速移动时漏掉穿越帧2 点宽已经是比较稳妥的最小值。它和管道移动用的是同一个 moveLeft 动作所以管道和计分节点始终在同一 x 位置计分点不会漂移。在 didBegin 里加分之后还要防止同一根管道重复加分。最简单的方法是把计分节点自身标记为已计分private func handleScore(with node: SKNode) { guard node.name ! scored else { return } score 1 scoreLabel?.text \(score) node.name scored }创建 scoreNode 时 name 是 scoreNode加到分之后改成 scored。由于 name 是节点属性不需要额外维护一个数组。要注意 didBegin 收到的 contact 里只能拿到 bodyA.node 和 bodyB.node所以判断时要小心如果 bodyA 是 birdbodyB 是 scoreNode那么 node 取 contact.bodyB.node 而不是固定取 bodyA.node。实际项目里可以更省事在加分分支中直接把 scoreNode.physicsBody nil让这个节点失去物理体也能避免重复加分。但这样把“状态”和“物理体”耦合在一起不如改 name 直观。4. 跑起来Xcode 工程配置、模拟器与真机调试4.1 从 Game 模板创建工程我建议不要直接把整个源码包拖到一个空工程里而是用 Xcode 自带模板生成一个 SpriteKit 工程再把源码文件替换成项目文件。这样 xcodeproj 的签名配置、Info.plist 都是 Xcode 生成的能避开不少环境问题。具体步骤Xcode 里选择 Create a new Xcode project模板选 iOS 下的 GameGame Technology 选 SpriteKit。填写产品名界面保持 Storyboard因为 ViewController 需要挂一个 SKView。生成后把本项目里的 GameScene.swift、GameScene.sks 复制进去勾选 Copy items if needed。打开 Assets.xcassets把图片资源拖进去图片文件名要严格对应代码里的 imageNamed。Xcode 会为每个图片集自动生成 1x、2x、3x 的占位。如果只放一张图记得把其他格子删掉。图片尽量用 PNG不要用 JPEG因为透明背景只有 PNG 能保留。还有一种情况如果源码包里的 GameScene.sks 是用旧版本 Xcode 创建的新版本打开时可能提示 Scene 版本升级直接选 Upgrade 即可。如果升级后场景尺寸变化检查一下 GameScene.sks 的 Scene Size 是不是你想要的竖屏尺寸比如 375x667。这个尺寸决定了坐标系范围后续所有 position 都是在这个坐标系里计算的。4.2 编译运行模拟器优先工程配置好后顶部 Scheme 选一个 iPhone 模拟器CommandR 编译运行。第一次因为要索引和编译会慢一些后面增量编译就快了。运行后如果看到场景背景但没有鸟先检查 Assets 里有没有 bird 这张图图片名是不是 bird。模拟器对触控的模拟已经很成熟直接把鼠标当手指点击屏幕即可不需要连接真机。有一个很重要的坑模拟器上 app 的帧率通常跟随 Mac 的显示刷新率不一定反映真机性能所以你在模拟器上测到的管道速度、物理手感只能作为参考。最终手感一定要真机验证。真机调试需要设置开发团队签名。在 Signing Capabilities 里选择你的开发者账号Bundle Identifier 改成你账号下没重复的 ID。如果只是本地跑免费开发者账号也能跑但签名有效期一般只有一周到期后重新运行一次工程即可。真机调试时尽量关闭模拟器。真机和模拟器同时开跑Xcode 有时会把控制台输出混在一起找 bug 费劲。我一般是 CmdR 跑真机前先 CmdQ 关掉模拟器保持环境干净。4.3 调试输出和辅助工具真机或模拟器跑起来后我习惯看三个指标。第一是左上角的 fps。如果长期低于 55说明节点太多或者物理体太多。这个项目节点总数很少正常能稳定 60。如果出现掉帧优先检查是不是开了 showsPhysics然后检查是不是有旧的管道节点没被清理。第二是物理体轮廓。把 ViewController 里 skView.showsPhysics true可以看到所有物理体的碰撞边界。如果管道碰撞框比图片大一圈你会立刻发现而不是等玩家误触才知道。调完记得关掉不然真机上会看到一堆绿线圈影响视觉判断。第三是场景尺寸。在 GameScene 里加一行 print(size)和 SKView 的实际宽高对比。如果场景尺寸是 375x667 而设备是 390x844说明场景定义尺寸和最终渲染尺寸不一致部分元素会错位。出现这种情况优先检查 .sks 里的 Scene Size再把 scaleMode 改成 .resizeFill。运行时想快速看节点层级可以用 Debug View Hierarchy。SpriteKit 节点不直接显示在 UIKit 的视图层级里但 SKView 会变成一个节点你至少能确认场景和层级关系是否完整。更实用的调试方式还是在代码里打印 node.name 和 position配合断点看状态。真机调试最常见的报错是找不到开发团队报 “Signing for xxx requires a development team”。选好团队后还有可能出现 provisioning profile 相关的报错这时到 Build Settings 里把 Provisioning Profile 设为 Automatic让 Xcode 自动管理签名再 Build 一次。5. 避坑指南坐标零点、物理体和性能的三个翻车现场上一步能跑起来只是开始。真正让新手卡住半天的不全是语法而是 SpriteKit 的默认坐标系和物理体配置。这里总结四个我实际翻过的车每条都按“现象 → 原因 → 解决”把它写透。5.1 小鸟掉出屏幕外却没有判定失败现象不点击屏幕小鸟一路往下掉穿过地面边界后消失游戏既不结束也不重置管道还在继续生成。原因地面节点没有物理体或者地面的位置和尺寸没有覆盖到小鸟的下落轨迹。SpriteKit 的坐标系原点在左下角y 轴向上不像 UIKit 那样原点在左上。如果直接把背景图放在 scene 底部背景图只是图片不是物理体小鸟当然会穿过去。解决给地面单独建一个 SKNode用透明色当碰撞体let ground SKSpriteNode(color: .clear, size: CGSize(width: size.width, height: 60)) ground.position CGPoint(x: frame.midX, y: 0) ground.physicsBody SKPhysicsBody(rectangleOf: ground.size) ground.physicsBody?.isDynamic false ground.physicsBody?.categoryBitMask CollisionMask.ground ground.physicsBody?.contactTestBitMask CollisionMask.bird addChild(ground)注意 position.y 0 代表地面的中心 y 在场景底部如果 height 是 60则地面覆盖从 y0 到 y60。场景原点就是屏幕左下角这一点和很多其他 2D 引擎不同。如果你把 y 设成 30碰撞体就会从屏幕外的 -30 延伸到 30露出一半在外面小鸟还是可能掉出去。5.2 小鸟和管道明明叠在一起游戏却不结束现象图片上小鸟已经撞到管道但 didBegin 没有触发小鸟直接穿过去。原因最常见的是没有给管道设置 contactTestBitMask。contact 触发要求一方或双方的 contactTestBitMask 包含对方类别。如果你只在 bird 上写了 contactTestBitMask pipe|ground|score但 bird 的 categoryBitMask 是 birdpipe 的 categoryBitMask 不是 pipe那么 bird 的 contactTestBitMask 中的任何 bit 都匹配不到管道系统不会认为需要通知接触。另一个原因是 physicsBody 的尺寸使用了 SKTexture 原始图片尺寸而显示图片被拉伸过碰撞体和视觉位置偏离。因为管道图片是一根短柱子代码里把 size 拉长到 320如果物理体没同步修改就会出现“视觉撞上但物理体没撞上”的情况。解决统一先给管道设 categoryBitMask再给管道设 contactTestBitMask两者都指向 birdtopBody.categoryBitMask CollisionMask.pipe topBody.contactTestBitMask CollisionMask.bird bottomBody.categoryBitMask CollisionMask.pipe bottomBody.contactTestBitMask CollisionMask.bird同时保证物理体尺寸和节点 size 一致。调试时打开 showsPhysics看碰撞框是否贴合图片。如果碰撞框和图片偏移不要靠猜直接输出现有 size 和 physicsBody 的 size 对比。5.3 同一套代码两台设备管道速度不一样现象在 Mac 模拟器上管道横移 4 秒到左侧在旧手机上却要 6 秒新手机上只要 3 秒。原因如果把节点的 position 更新放在 update 里并且每帧固定移动 dx 5那帧率高的设备每秒移动的像素更多。iOS 设备的刷新率不统一有 60Hz、90Hz、120Hz移动速度就分裂了。另外如果用 Timer 或 CADisplayLink 驱动也可能出现不等间隔。解决项目里管道移动是用 SKAction.moveBySpriteKit 会按动作的 duration 自动换算每帧位移和实际帧率解耦所以不会有问题。如果你改成自己写 update就要引入时间差override func update(_ currentTime: TimeInterval) { if lastUpdateTime 0 { lastUpdateTime currentTime } let delta currentTime - lastUpdateTime pipeNode.position.x - pipeMoveSpeed * CGFloat(delta) lastUpdateTime currentTime }pipeMoveSpeed 单位是点/秒。注意第一次调用时 lastUpdateTime 是 0要初始化否则 delta 会非常大画面会闪一下。这个“首帧大跳跃”是自写更新最常见的 bug比委托给 SKAction 更容易翻车。5.4 重置游戏后管道越刷越快甚至同屏出现三对管道现象第一次游戏结束点击屏幕重置新一局里管道生成节奏明显加快有时候同屏冒出一大堆管道。原因startPipes() 里用 run(repeatForever) 启动生成动作但从来没有 removeAction。重置时再次调用 startPipes旧动作流继续跑新动作流又叠加每 1.8 秒同时生成两组管道。管道移动动作也一样在管道节点上 run 了 sequence重置时旧节点还没移除完新节点又开始了。解决在 startPipes 开头先 removeAction(withKey: pipeSpawning)在 gameOver 时给所有名称为 pipe 的节点 removeAllActions 并 removeFromParent。更温和的方式是重置时 enumerateChildNodes 递归查找并清理private func clearPipes() { enumerateChildNodes(withName: //pipe) { node, _ in node.removeAllActions() node.removeFromParent() } }这里的 “//pipe” 是 SpriteKit 的节点查询语法两个斜杠表示从根节点出发递归查找不仅查直接子节点。如果管道和计分节点嵌套在某个容器节点下面也能清理到。重置时清完旧节点再调用 startPipes就不会叠加。6. 二次开发把 flappy bird 改成你自己的跳跃小游戏别急着改玩法。先做一次“手感测试”把 gravity、impulse、pipeGap、spawnInterval 四个参数提成属性然后在真机上反复调。你会发现一个游戏好不好玩一半取决于这些数字而不是美术。var gravityY: CGFloat -4.0 var impulseY: CGFloat 280 var pipeGap: CGFloat 150 var spawnInterval: TimeInterval 1.8然后用它们替换之前的字面量。如果不想每次重新编译可以用 UserDefaults 从启动参数读取if UserDefaults.standard.object(forKey: impulse) ! nil { impulseY CGFloat(UserDefaults.standard.float(forKey: impulse)) }这样调试时改一次参数重跑就能试不同手感比在代码里改完再编译快很多。验证方法很简单连续玩五局记录你期望的手感和实际感受重点对比“每次点击能不能稳定跳进 gap”“下落时有没有过快的压迫感”。改了 gravity 一定要改 impulse不知道公式就凭感觉多试几个值。把鸟换成矩形也很简单用 SKSpriteNode(color: .white, size:) 替代图片物理体从 circleOfRadius 改成 rectangleOf。注意如果你的角色尺寸变大pipeGap 也要变大。不要只改视觉效果不动物理体否则体验会骗人。如果你想让玩法更接近重力跑酷可以在 moveLeft 动作之外再叠加一个向上或旋转的动作。记住所有需要同时发生的动作要用 SKAction.group而不是 sequence否则它们会排队执行而不是同时进行。我现在的习惯是拿到任何一个 SpriteKit 原型都会强制自己先检查三件事物理掩码有没有设全、所有长周期动作有没有 key、重置时会不会残留旧节点。这三件事跑通再谈表现和画面。希望帮到你。本文还有配套的精品资源点击获取