资讯动态

Xcode跑通Flappy Bird:SpriteKit 2D物理与碰撞实战

发布时间:2026/10/24 1:22:01 来源:尧图企业网站定制
简介这份 Flappy Bird Xcode 工程面向 iOS 游戏开发入门者及希望复刻经典 2D 玩法的开发者围绕 SpriteKit 场景组织、物理模拟、触摸交互、碰撞计分与游戏状态切换等环节提供可参考的完整项目骨架。压缩包共 58 个文件约 507KB以 png 图片素材、Objective-C 源码与头文件、json 和 plist 配置、sks 场景文件及 mp3/wav 音效为主另含工程文件、scheme、prefix 头文件与测试 Target素材、代码和场景资源相对齐全。已有 179 人学习。工程内可见游戏场景、玩家、管道和新游戏场景等源码模块便于梳理小鸟受力飞行、管道生成与移动、碰撞检测、得分更新和游戏结束回调的实现关系同时可观察场景初始化、资源加载、音效播放与界面元素的组织方式。配合图片、音效、界面资源与单元测试文件可作为理解 Flappy Bird 类游戏骨架、进行二次开发或课程练习的基础工程也能帮助读者对照学习 Objective-C 与 SpriteKit 的协作方式。1. 从一份 Xcode 工程说起Flappy Bird 复刻到底在练什么搜falppy bird或者flappy bird xcode的人动机往往很朴素想找一个不用从零搭、能一口气跑起来的 2D 小项目。这份工程就是干这个的——Swift SpriteKit 写的 Flappy Bird 复刻点屏拍翅、管道滚动、碰撞判定、计分到 Game Over 重开整条链路都在里面。它解决的不是“做个游戏”这种大词而是让你在真机上亲眼看到重力、冲量、接触回调、场景状态机这四样东西怎么咬合。适合已经跑通过 Hello World、想找第二个练手项目的 iOS 新手也适合手上有个干净 2D 场景、想调物理参数的老手。工程不大坑一点不少下面按拆工程的顺序往下走。2. 工程骨架拆解SpriteKit 选型、目录分层与 SKScene 接管2.1 为什么这类工程默认是 SpriteKit 而不是 SceneKitFlappy Bird 的所有元素都在一个平面上鸟只在 y 轴动管道只在 x 轴平移碰撞是矩形和圆的近似判定。用 3D 引擎做这件事你要额外处理相机、光照、节点朝向注意力会被大量跟玩法无关的概念吃掉。SceneKit 本身没问题只是对这个场景属于杀鸡用牛刀。而 UIKit 手写CADisplayLink看着轻量实际要自己维护固定步长循环、自己做 AABB 检测、自己管节点层级写到最后发现又造了一遍 SpriteKit。三种方案的差异摊开看更直观维度SpriteKitSceneKitUIKit CADisplayLink坐标系2D 场景坐标3D 世界坐标视图坐标物理引擎内置 2D 刚体内置 3D 刚体无需自写动作系统SKAction 平移/缩放/序列SCNAction自己写定时器接触检测contactDelegate 回调物理接触代理自己算上手成本低中高中但后期维护重还有一层新手很少注意到SpriteKit 的物理世界默认按固定时间步长推进逻辑推进和屏幕刷新率是解耦的。这句话的实际含义是你在 ProMotion 设备上跑到 120Hz鸟的下落速度不会翻倍换成自己写update里按帧位移60Hz 和 120Hz 就是两套手感模拟器上还看不出来。这也是我建议这类小游戏直接用 SpriteKit 的核心原因省掉的不只是代码量是一整类难查的时序问题。2.2 目录怎么分层场景、节点、玩法、资源四条线拿到工程先别急着改代码先看目录。一个能长期维护的 SpriteKit 工程分组通常是按职责而不是按文件类型切的分组职责改动频率Scenes/场景类管生命周期、节点编排低Nodes/BirdNode、PipeNode、GroundNode 等中Gameplay/状态机、计分、难度曲线高Assets.xcassets纹理、图集、启动图低Sounds/音效与音频文件低入口 Controller承载 SKView、呈现场景几乎不改把节点单独拎出来的理由很实在鸟和管道各自带物理体配置、动作序列、复位逻辑全塞进 GameScene 会让这个文件迅速膨胀到七八百行改一个参数要在几百行里翻。我一般习惯一个节点一个文件节点对外只暴露spawn、reset、markScored这寥寥几个方法场景只负责“什么时候调用谁”。这样你调管道缝隙时不用重看鸟的逻辑调鸟的冲量时也不会误碰计分。资源图集是另一个容易被忽略的点。同一类纹理比如管道上下两段、背景几层打进同一个 atlas 里绘制时能合批节点数没变但 draw call 会明显下降。判断有没有做对打开skView.showsDrawCount正常情况这个数字应该远小于节点数。2.3 从 ViewController 到 SKScene接管顺序与三个必设参数SpriteKit 工程的入口还是 ViewController只是 view 的类被换成了 SKView真正的游戏世界在场景对象里。这段接管代码不长但几个参数一旦写错后面调半天玩法都调不明白import UIKit import SpriteKit final class GameViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() // storyboard 里 view 的 Custom Class 必须设成 SKView否则这里直接返回 nil 黑屏 guard let skView view as? SKView else { return } // 调试三件套开发期全开发布前务必确认全部为 false skView.showsFPS true skView.showsNodeCount true skView.showsPhysics false // 绘制顺序只以 zPosition 为准避免节点前后关系随添加顺序漂移 skView.ignoresSiblingOrder true // 逻辑分辨率固定不跟屏幕像素挂钩 let scene GameScene(size: CGSize(width: 375, height: 667)) scene.scaleMode .aspectFill skView.presentScene(scene) } override var prefersStatusBarHidden: Bool { true } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .portrait } }逻辑说明view as? SKView是整套流程的第一道门storyboard 里没改 Custom Class 的话这里返回 nil你看到的是一片纯黑很容易被误判成场景没加载。ignoresSiblingOrder true让绘制顺序只看zPosition管道和鸟的前后遮挡关系就不再依赖谁先addChild真机上偶发的前后错乱基本都是这个开关没开。参数说明scene.size是逻辑坐标系尺寸所有节点坐标基于它跟设备物理像素无关建议取一个和主流机型比例接近的值比如 375×667 或 390×844。scaleMode的四个取值里.aspectFill等比铺满并裁切、.aspectFit等比显示并留黑边、.fill会拉伸变形、.resizeFill会随视图尺寸改变scene.size。新手最常踩的是最后一个——用了它同一份按固定 size 硬编码的管道缝隙换个屏幕比例就全歪了第五章会细说。3. 核心玩法落代码重力、拍翅冲量与管道生成的参数账3.1 物理世界怎么配重力、质量、冲量三件套Flappy Bird 的手感本质是一句话恒定向下的加速度加上每次点击给一个瞬时向上的速度。落到 SpriteKit 就是两个参数对——世界级的physicsWorld.gravity和刚体级的applyImpulse。手感不对九成是这两个数没配平重力太大鸟下坠快到来不及反应冲量太大点一下直接窜出屏幕顶质量设大了同样的冲量只能推出很小一段位移。先把经验区间摆出来方便对着工程里的现值做比对参数设置位置常用区间调大的后果gravity.dyphysicsWorld.gravity-6.0 ~ -12.0下落更快整体更“沉”bird.massphysicsBody.mass0.8 ~ 2.0冲量效果被稀释拍翅冲量 dyapplyImpulse6 ~ 14单跳更高容易撞顶管道缝隙高度上下管道间距鸟身高 2.6 ~ 3.2 倍容错变大难度降低管道穿越时长SKAction.moveBy2.2 ~ 3.0 秒节奏拉长压力变小private func setupPhysicsWorld() { // x 轴给 0避免鸟被横向力量带偏y 轴负值就是恒定向下的重力 physicsWorld.gravity CGVector(dx: 0, dy: -9.0) physicsWorld.contactDelegate self // 圆形物理体半径取视觉宽度的 0.42 倍 let radius birdNode.size.width * 0.42 let body SKPhysicsBody(circleOfRadius: radius) body.isDynamic true // 受重力和冲量影响 body.allowsRotation false // 关掉旋转否则鸟会一路翻滚 body.mass 1.0 body.restitution 0.0 // 撞管道不反弹 body.linearDamping 0.0 // 空气阻力为 0保持干脆手感 body.categoryBitMask PhysicsCategory.bird body.contactTestBitMask PhysicsCategory.pipe | PhysicsCategory.ground body.collisionBitMask PhysicsCategory.ground // 只与地面发生物理碰撞 birdNode.physicsBody body }逻辑说明圆形物理体是这类游戏的关键取舍。矩形体在鸟身和管道口形成锐角接触时会出现“贴图看着还有缝判定已经死了”的玄学圆形体把接触范围收缩到半径内容错更接近玩家直觉。collisionBitMask只留地面意味着撞到管道不会产生物理上的弹开撞了之后怎么处理完全交给接触回调你才能自己决定是直接 Game Over 还是加一个击退动画。参数说明半径取视觉宽度 0.42 倍而不是 0.5 倍是有意让判定圈比贴图小一圈玩家会觉得“刚才那下居然没死”这是手游常见的宽容处理。linearDamping必须为 0一旦设到 0.1 以上上升段会明显乏力人的第一反应是去加大冲量最后变成一点就飞出场外参数彻底跑偏这种血泪经验每次调手感都能想起来。3.2 管道生成定时累加、对象池与“别每帧 addChild”管道生成有两种典型写法用SKAction.repeatForever串一个等待再生成的动作或者在update(_:)里用时间差累加。前者代码短但场景暂停切后台、弹暂停面板时动作的计时行为要单独推敲后者多写几行换来对暂停恢复的完全掌控我一般选后者。比生成方式更容易翻车的是节点的生命周期。每根管道都SKNode()新建、飞出屏幕再removeFromParent()看似干净实际会产生持续的节点创建与纹理绑定开销玩上两分钟就能看出帧率曲线在抖。稳妥做法是对象池预先建固定数量循环复用。private var pipePool: [PipeNode] [] private let poolSize 6 // 屏幕同时可见约 3 根留一倍余量 private var spawnAccumulator: TimeInterval 0 private let spawnInterval: TimeInterval 1.6 private func preparePipePool() { for _ in 0..poolSize { let pipe PipeNode() pipe.isHidden true pipe.physicsBody?.isDynamic false // 管道是静态体不能被撞飞 addChild(pipe) pipePool.append(pipe) } } override func update(_ currentTime: TimeInterval) { guard gameState .playing else { return } // 用时间差累加而不是依赖动作计时暂停时自然停住恢复后不会补生成一堆 let dt lastUpdateTime 0 ? 0 : currentTime - lastUpdateTime lastUpdateTime currentTime spawnAccumulator dt if spawnAccumulator spawnInterval { spawnAccumulator 0 spawnPipe() } } private func spawnPipe() { guard let pipe pipePool.first(where: { $0.isHidden }) else { return } pipe.isHidden false pipe.reset(forSceneSize: size) // 节点内部负责随机缝隙位置与坐标复位 }逻辑说明用update(_:)的currentTime差值累加好处是scene.isPaused true时计时自然停住恢复后不会一次性补出好几根管道。对象池里用first(where: isHidden)取空闲节点是 O(n)但 n 只有个位数代价可以忽略换成队列实现也行。回收判断不要挂在SKAction的 completion 上动作被打断时 completion 不触发节点会永远留在屏外。参数说明poolSize按“屏幕同时可见数量 × 2”给太少会出现管道还没回收就被复用、导致位置瞬移太多则是无谓的常驻节点。spawnInterval和管道穿越时长共同决定难度常见比例是让相邻两根管道的水平间距约为屏宽的 0.55~0.7 倍玩家才有稳定的节奏感太快会变成纯拼反应。3.3 碰撞判定三个 bitMask 分别管什么新手最容易把categoryBitMask、collisionBitMask、contactTestBitMask三个都设成同一个值结果要么鸟被管道弹飞要么完全不触发didBegin。三者的分工其实很清楚第一个是“我是谁”第二个是“我会被谁弹开”第三个是“我和谁接触时要通知代理”。下面这段把职责拆开struct PhysicsCategory { static let none: UInt32 0 static let bird: UInt32 1 0 // 1 static let pipe: UInt32 1 1 // 2 static let ground: UInt32 1 2 // 4 static let score: UInt32 1 3 // 8计分传感器 } extension GameScene: SKPhysicsContactDelegate { func didBegin(_ contact: SKPhysicsContact) { // 位或拼出本次接触的组合标识避开 bodyA / bodyB 顺序不确定 let mask contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask if mask PhysicsCategory.bird | PhysicsCategory.ground { triggerGameOver() } else if mask PhysicsCategory.bird | PhysicsCategory.pipe { triggerGameOver() } else if mask PhysicsCategory.bird | PhysicsCategory.score { // 计分也可以走传感器但第四章会讲为什么我更倾向在 update 里判 } } }逻辑说明1 n保证每个类别只占一位位或的结果才能唯一标识一组碰撞。用mask一次比较省掉“bodyA 是鸟还是 bodyB 是鸟”的嵌套判断回调里代码量能少一半。传感器节点isSensor true不产生物理碰撞只触发回调适合做穿透类判定。参数说明传感器的isDynamic可以保留 true但collisionBitMask必须为 0否则鸟会被弹开contactTestBitMask要显式包含bird漏写就是完全不触发。地面用SKPhysicsBody(edgeLoopFrom:)性能更好但它没有厚度高速下落时有极小概率穿透稳妥做法仍是给一条薄矩形静态体。4. 计分、状态机与音效让鸟从“能飞”到“能玩通”4.1 计分触发为什么不要在 didBegin 里直接加分一根管道有上下两段一次穿越可能触发两次接触鸟在缝隙里抖动时还可能反复进出同一根管道的接触区域。didBegin只在一对物体“开始接触”时调一次但“开始接触”和“完整穿过”是两回事直接在里面score 1结果就是分数忽多忽少。常见做法有两种给每根管道挂一个传感器节点放在缝隙中央接触时加分并用标记挡住重复或者在update里判断鸟的横坐标是否越过了管道右边缘。我更倾向后者因为判分逻辑和管道生成、回收放在同一处出问题好回溯。final class PipeNode: SKNode { private(set) var hasScored false func reset(forSceneSize size: CGSize) { hasScored false // 复位位置、随机缝隙中心 y、恢复为未计分状态 } func markScored() { hasScored true } } override func update(_ currentTime: TimeInterval) { guard gameState .playing else { return } for pipe in pipePool where !pipe.isHidden !pipe.hasScored { // 鸟越过管道右边缘即算通过条件是幂等的重复执行不会多加分 if birdNode.position.x pipe.position.x pipe.pipeWidth * 0.5 { pipe.markScored() score 1 scoreLabel.text \(score) } } }逻辑说明把“越过右边缘”作为判分条件配合hasScored标记整个判断是幂等的——同一根管道无论被检查多少次都只加一分鸟贴着缝隙飞也不再抖出重复分。代价是每帧遍历一次管道池量级只有个位数可以放心。参数说明pipe.position.x pipe.pipeWidth * 0.5里的 0.5 是取管道半宽前提是管道节点的锚点保持默认中心点。如果工程里把锚点改成(0, 0.5)这里就要换成pipe.position.x锚点差异是“计分提前一截”最常见的原因改之前先看一眼anchorPoint。4.2 状态机Ready / Playing / GameOver 三态怎么写才不会乱新手常把逻辑全铺在touchesBegan里用一两个布尔量判断“开始了没有”“死了没有”改到第三版就彻底乱了。换成枚举加 switch把“点屏幕”这个动作在不同状态下的含义拆开代码会立刻清晰enum GameState { case ready // 鸟悬停等第一次点击 case playing case gameOver // 结算等待重开 } private var gameState: GameState .ready private var gameOverTime: TimeInterval 0 override func touchesBegan(_ touches: SetUITouch, with event: UIEvent?) { guard touches.count 1 else { return } // 多指连点直接忽略 switch gameState { case .ready: gameState .playing birdNode.physicsBody?.isDynamic true birdNode.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 9.0)) startBackgroundScroll() case .playing: birdNode.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 9.0)) playFlapSound() case .gameOver: // 死亡瞬间的连点不应直接开新局加冷却 guard currentTime - gameOverTime 0.5 else { return } restartGame() } }逻辑说明.ready状态下鸟的isDynamic必须为 false并手动固定在屏幕上方三分之一处让玩家先看清管道节奏这是原版体验里最舒服的一点。.gameOver的冷却不是可选项——真机上死亡瞬间很容易连点两下没有冷却就会出现“点一下就重开了”的错觉玩家会以为是闪退。参数说明冲量dy: 9.0要和 3.1 节的重力配对-9.0 的重力配 9.0 的冲量大约对应一次上升三分之一屏高可以按这个比例微调。冷却别超过 0.8 秒再长手感就发顿currentTime直接取update(_:)里存下来的最新值即可别在触点回调里另取时间源。4.3 音效与触感预加载、音频会话和静音开关SKAction.playSoundFileNamed写起来最省事但它首次调用时会同步读文件第一次点屏幕可能出现轻微卡顿甚至没声音。稳妥做法是启动时用 AVAudioPlayer 预建一个小池子轮转播放。另一个常被忽略的点是音频会话分类设错会让静音键失效。private var flapPlayers: [AVAudioPlayer] [] private var playerIndex 0 private func prepareAudio() { // .ambient 让静音键生效mixWithOthers 不打断用户正在听的音乐 try? AVAudioSession.sharedInstance().setCategory(.ambient, options: [.mixWithOthers]) try? AVAudioSession.sharedInstance().setActive(true) for _ in 0..3 { if let url Bundle.main.url(forResource: flap, withExtension: wav), let player try? AVAudioPlayer(contentsOf: url) { player.prepareToPlay() flapPlayers.append(player) } } } private func playFlapSound() { guard !flapPlayers.isEmpty else { return } let player flapPlayers[playerIndex % flapPlayers.count] playerIndex 1 player.currentTime 0 player.play() }逻辑说明连点拍翅时同一个播放器还没播完就被play()会直接截断听感上像丢了半声。三个播放器轮转足以撑住每秒五次以内的点击再快人也听不出差别反而浪费内存。prepareToPlay()提前解码第一次播放的延迟基本无感这一行是最划算的优化。参数说明.ambient分类保证静音键拨下时不出声这类休闲游戏就应该这样设成.playback会无视静音键体验和审核上都容易挨批。options: [.mixWithOthers]表示不与用户后台音乐抢音频通道默认都该带上。触感用UIImpactFeedbackGenerator(style: .light)在撞击时impactOccurred()记得提前prepare()否则第一次震动的延迟肉眼可见。5. 避坑与排查Xcode 跑这份工程最容易翻车的五处5.1 画面与坐标类问题第 1 条模拟器上管道位置正常真机上缝隙偏了或者画面被裁。现象同一份代码在小屏机和全面屏上管道上下不对称鸟的初始位置压到屏幕边缘甚至跑到可视区外。 原因scaleMode用了.resizeFillscene.size会跟着视图尺寸变化而管道缝隙是按初始 size 硬编码的绝对值尺寸一变全部错位。 解决要么固定逻辑分辨率配.aspectFill接受裁切要么把所有坐标改成按size.height的比例计算比如缝隙中心取size.height * 0.5 CGFloat.random(in: -0.15...0.15) * size.height。验证方式很土但有效至少在小屏、标准屏、全面屏三种模拟器上各跑一遍别只信默认机型。第 2 条鸟直接穿过地面掉出屏幕或者.ready状态就开始往下掉。现象游戏开始后鸟没被地面挡住或者还没点屏幕鸟已经落到底部。 原因地面节点只加了视觉 Sprite 没加物理体或者鸟的collisionBitMask里没包含 ground也可能是.ready状态下忘了把isDynamic置为 false。 解决地面用静态矩形或edgeLoopFrom建物理体并设categoryBitMask ground检查鸟的collisionBitMask是否包含ground。这两个掩码一个是“我能被谁弹”一个是“我是谁”设反了就会出现鸟穿过地面的情况。5.2 逻辑与性能类问题第 3 条鸟擦过管道口被判死玩家觉得“明明没碰到”。现象贴图边缘还留着几像素空隙却直接 Game Over录屏逐帧看更明显。 原因管道物理体用了SKPhysicsBody(texture:size:)精确轮廓边缘毛刺会把判定放大或者鸟用的是矩形体四角比视觉大一圈。 解决鸟改圆形体半径取视觉宽度的 0.42 倍管道用矩形体但内缩 2~4 点SKPhysicsBody(rectangleOf: CGSize(width: w - 4, height: h))。纹理轮廓看着精确实际绘制开销大、边缘还抖得不偿失打开showsPhysics看一眼轮廓就知道差在哪。第 4 条玩久了掉帧节点数一路涨到几百。现象前三十秒稳定 60fps两分钟后掉到 30 以下showsNodeCount的数字持续上升。 原因管道用removeFromParent()销毁但生成端没有限流背景滚动的repeatForever动作挂在节点上节点销毁后动作引用没清每根管道都SKTexture(imageNamed:)新建纹理。 解决先开showsNodeCount和showsDrawCount看曲线走向再把管道改成对象池复用纹理在准备阶段一次性缓存场景切换时removeAllActions()主动清。别指望 ARC 帮你收动作SpriteKit 的动作是挂在节点上的强引用节点活着动作就活着。第 5 条切后台再回来管道一股脑涌出或者直接 Game Over。现象按 Home 键再切回屏幕上瞬间多出好几根管道或者鸟的位置完全错乱。 原因场景没有在willResignActive时置isPausedupdate里的currentTime把后台那段时间也累加进去了恢复时一次性补生成。 解决监听willResignActiveNotification把scene.isPaused true回前台时把lastUpdateTime重置为 0让恢复后第一帧的 dt 等于 0。这条对所有基于时间差累加的逻辑都成立跟引擎无关属于必须做的防护。6. 收尾技巧把这份工程改成你自己的参数验证台把工程跑通只是起点真正的价值在于把它当成一个可以反复做实验的台子。我的习惯是先开showsPhysics看清所有物理体轮廓再动参数——不看轮廓就调手感等于闭眼改代码。调完一轮用下面这个小工具做比例布局把硬编码坐标一次性清掉后面换任何屏幕比例都不用重来extension CGSize { /// 按比例取点x、y 都是 0~1 的相对值 func relative(_ x: CGFloat, _ y: CGFloat) - CGPoint { CGPoint(x: width * x, y: height * y) } } #if DEBUG private var frameSamples: [TimeInterval] [] private func sampleFrame(_ dt: TimeInterval) { guard dt 0 else { return } frameSamples.append(dt) if frameSamples.count 60 { frameSamples.removeFirst() } let avg frameSamples.reduce(0, ) / Double(frameSamples.count) // 低于 50fps 就打日志别等玩家反馈卡顿才发现 if avg 1.0 / 50.0 { print(掉帧平均 \(Int(1 / avg)) fps) } } #endifrelative的入参都是 0~1 的相对值管道缝隙中心、鸟的初始高度、计分标签位置全过一遍这个函数.resizeFill带来的错位问题会自然消失。sampleFrame做的是六十帧滑动平均单帧抖动会被抹平留下的才是真实趋势只在 Debug 编译发布包里不占开销。最后给一张我调参时最常对照的表比盲试快得多观察到的现象优先怀疑的参数调整方向一点就窜到屏顶拍翅冲量 dy每次减 1.0来不及反应就落地gravity.dy往 -7 附近收管道间隔忽大忽小spawnInterval 与移动时长比例固定为 0.6 倍屏宽擦边被判死鸟的物理体半径、管道内缩量半径下调、管道内缩 2~4 点两分钟后掉帧节点数、draw call查对象池与纹理缓存从那以后我每次拿到这类 2D 物理小工程都先把showsFPS、showsNodeCount、showsPhysics三个开关打开在真机上跑一遍再谈改逻辑——模拟器上的流畅和真机完全是两回事这个顺序颠倒过一次浪费了整整一个下午。工程本身不大改几行参数就能看到明显差别拿来练手正合适。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑