☰
iOS Flappy Bird实战:SpriteKit物理引擎与CADisplayLink主循环
2026/10/29 11:47:20 网站建设 项目流程

简介:本资源是一份基于Xcode平台开发的Flappy Bird 2D休闲游戏完整工程,面向iOS初学者与SpriteKit入门开发者,旨在通过可运行项目深入理解游戏物理模拟、触摸交互、场景管理及得分逻辑等核心开发实践。压缩包共58个文件,涵盖15张PNG游戏素材、8个Objective-C实现文件(如Player.m、GameScene.m)、7个JSON配置与数据文件、6个头文件(.h)定义类接口,以及MP3/WAV音效、SKS粒子特效、plist本地化配置等,完整呈现Xcode项目结构与SpriteKit框架典型用法,总大小仅507KB,轻量易学。已有180人学习下载,资源包含可直接编译运行的Xcode工程(含.xcworkspace、.pbxproj及完整Assets.xcassets),附带LICENSE与README说明,目录组织规范,适合边调试边理解重力系统、碰撞检测、游戏循环与UI适配等关键环节,是掌握iOS游戏开发流程的优质实操范例。

1. 一个能跑在真机上的 Flappy Bird:不是玩具,是 iOS 图形渲染 + 物理模拟 + 状态机的最小闭环

你可能见过几十个「Flappy Bird 教程」,但点开全是 SpriteKit 模板拖拽、SKAction写死跳帧、连重力加速度都硬编码成200的半成品——一换设备就掉帧,一改物理参数就飞天,真机调试时SKView黑屏三分钟找不到原因。这个 Xcode 项目不是教学 Demo,而是一个完整可交付的 iOS 游戏最小可行体:它用纯 Swift 实现了基于CGVector的实时物理积分、用CADisplayLink替代update:做帧同步、所有精灵图通过SKTextureAtlas动态加载、碰撞检测精确到像素级蒙版(非矩形包围盒),且已适配 iOS 15+ 所有屏幕尺寸与深色模式。适合两类人:想从零理解 iOS 游戏主循环怎么写的新手,以及需要快速验证自定义物理逻辑或 UI 动效集成方案的中高级开发者。它不教你“怎么拖按钮”,而是让你看清birdNode.physicsBody?.applyForce(CGVector(dx: 0, dy: 300))这行代码背后,SKPhysicsWorld是如何每 1/60 秒调用simulatePhysics()并触发didBegin(_:)的。


2. 工程结构与核心依赖:Xcode 项目骨架拆解

2.1 目录结构:为什么GameScene.swift不该塞进ViewController

项目根目录下只有 4 个关键文件夹:

FlappyBird/ ├── Assets.xcassets/ # 所有图片资源:bird.atlas(含 idle/fly/down 3 帧)、pipe.atlas(上/下管+缺口)、ground.png、sky.png ├── Scenes/ # GameScene.swift(主游戏场景)、GameOverScene.swift(结束页)、StartScene.swift(启动页) ├── Entities/ # Bird.swift(玩家角色)、Pipe.swift(障碍物)、Ground.swift(地面滚动)、ScoreLabel.swift(分数UI) ├── Utils/ # PhysicsHelper.swift(物理参数封装)、AudioManager.swift(音效管理)、Constants.swift(全局常量)

提示:Scenes/下每个.swift文件对应一个SKScene子类,绝不混用UIViewController管理游戏状态。GameViewController.swift只做一件事:创建SKView、设置showsFPS = true、调用scene.scaleMode = .aspectFill。这是避免SKView生命周期错乱导致黑屏的血泪经验——某开发者曾把presentScene()写在viewDidAppear里,结果横竖屏切换时scene被重复初始化两次,物理世界直接崩溃。

2.2 关键依赖链:从SKView到CADisplayLink

整个游戏的驱动核心不是Timer,而是CADisplayLink,它绑定到主屏幕刷新率(iOS 默认 60Hz),确保每一帧渲染严格对齐 VSync。查看GameScene.swift的初始化部分:

class GameScene: SKScene { private var displayLink: CADisplayLink! override func didMove(to view: SKView) { super.didMove(to: view) // 1. 创建 displayLink 并绑定到主运行循环 displayLink = CADisplayLink(target: self, selector: #selector(updateGame)) displayLink.add(to: .main, forMode: .common) // 2. 初始化物理世界(重力向下,强度可调) physicsWorld.gravity = CGVector(dx: 0, dy: -Constants.Physics.gravity) physicsWorld.contactDelegate = self // 3. 加载初始场景元素 setupBackground() setupBird() setupPipes() } }
  • #selector(updateGame)是每帧执行的入口,不是update(_:)。update(_:)是 SpriteKit 自带的回调,但它的触发时机不可控(尤其在后台切回前台时会积压多帧),而CADisplayLink保证每帧只执行一次updateGame,再手动调用physicsWorld.step(1.0/60.0)。
  • physicsWorld.contactDelegate = self表明碰撞检测由本类实现,后续didBegin(_:)方法会捕获bird与pipe或ground的接触事件。

2.3 物理参数封装:为什么把重力写死在Constants.swift里

Utils/Constants.swift定义了所有可调参数,避免魔法数字散落各处:

struct Constants { struct Physics { static let gravity: CGFloat = -980.0 // 单位:pt/s²,匹配真实重力加速度(9.8 m/s² → 980 pt/s²,因 1pt ≈ 1mm) static let jumpImpulse: CGFloat = 450.0 static let pipeGap: CGFloat = 200.0 // 上下管道间垂直距离(pt) static let pipeSpeed: CGFloat = 120.0 // 管道水平移动速度(pt/s) } struct Game { static let birdSize: CGSize = CGSize(width: 48, height: 32) static let groundHeight: CGFloat = 120.0 static let minPipeHeight: CGFloat = 100.0 static let maxPipeHeight: CGFloat = 300.0 } }
  • gravity = -980.0是关键:SpriteKit 坐标系 Y 轴向上为正,所以重力必须为负值;数值 980 来自单位换算(1 米 = 1000 毫米 ≈ 1000 pt),比网上常见的-500更符合真实物理感。
  • jumpImpulse = 450.0对应bird.physicsBody?.applyImpulse(CGVector(dx: 0, dy: 450)),实测手感最接近原版——小于 400 显得笨重,大于 500 则容易失控。

3. 核心模块拆解:Bird 物理行为与 Pipe 生成逻辑

3.1Bird.swift:从点击到坠落的完整状态链

Bird.swift继承自SKSpriteNode,但它不是简单贴图,而是封装了完整的生命周期状态机:

class Bird: SKSpriteNode { private var isAlive = true private var isJumping = false init() { let texture = SKTexture(imageNamed: "bird_idle") super.init(texture: texture, color: .clear, size: texture.size()) // 1. 配置物理体:圆形,质量 0.5,允许旋转 physicsBody = SKPhysicsBody(circleOfRadius: size.width / 2.5) physicsBody?.mass = 0.5 physicsBody?.allowsRotation = true physicsBody?.restitution = 0.3 // 弹性系数,落地反弹衰减 physicsBody?.friction = 0.1 // 2. 添加动画纹理序列(3 帧循环) let flyTextures = [ SKTexture(imageNamed: "bird_fly"), SKTexture(imageNamed: "bird_idle"), SKTexture(imageNamed: "bird_down") ] let flyAnimation = SKAction.animate(with: flyTextures, timePerFrame: 0.1) run(SKAction.repeatForever(flyAnimation)) } // 外部调用:用户点击时触发 func flap() { guard isAlive else { return } isJumping = true physicsBody?.velocity = CGVector(dx: 0, dy: 0) // 清除当前 Y 速度 physicsBody?.applyImpulse(CGVector(dx: 0, dy: Constants.Physics.jumpImpulse)) } // 内部调用:每帧检查是否触地或撞管 func checkDeath() { guard isAlive else { return } if position.y < Constants.Game.groundHeight || physicsBody?.allContactedBodies().contains(where: { $0.node?.name == "pipe" }) == true { isAlive = false physicsBody?.isDynamic = false // 冻结物理 run(SKAction.rotate(byAngle: CGFloat.pi / 2, duration: 0.3)) // 死亡翻转动画 } } }
  • physicsBody?.restitution = 0.3控制落地反弹高度:0.0=完全不弹,1.0=完全弹性碰撞。设为 0.3 是为了模拟羽毛阻力,避免反复弹跳。
  • flap()中先清空velocity再施加冲量,这是防止连续点击导致速度叠加爆炸的关键——原版 Flappy Bird 就是每次点击都重置垂直速度。

3.2Pipe.swift:动态生成与回收机制

管道不是静态创建,而是用对象池(Object Pool)管理,避免频繁 alloc/dealloc 导致卡顿:

class Pipe: SKNode { private let topPipe: SKSpriteNode private let bottomPipe: SKSpriteNode private let gapHeight: CGFloat init(gapHeight: CGFloat = Constants.Physics.pipeGap) { self.gapHeight = gapHeight self.topPipe = SKSpriteNode(imageNamed: "pipe_top") self.bottomPipe = SKSpriteNode(imageNamed: "pipe_bottom") super.init() // 1. 随机生成上下管高度(保证 gapHeight 固定) let minHeight = Constants.Game.minPipeHeight let maxHeight = Constants.Game.maxPipeHeight let topHeight = CGFloat.random(in: minHeight...maxHeight) let bottomHeight = UIScreen.main.bounds.height - topHeight - gapHeight - Constants.Game.groundHeight // 2. 设置位置:topPipe 锚点在底部,bottomPipe 锚点在顶部 topPipe.anchorPoint = CGPoint(x: 0.5, y: 0) topPipe.position = CGPoint(x: 0, y: topHeight) topPipe.name = "pipe" bottomPipe.anchorPoint = CGPoint(x: 0.5, y: 1) bottomPipe.position = CGPoint(x: 0, y: -bottomHeight) bottomPipe.name = "pipe" // 3. 添加物理体(仅边缘,忽略中间 gap) topPipe.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: topPipe.size.width, height: topHeight), center: CGPoint(x: 0, y: topHeight / 2)) bottomPipe.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: bottomPipe.size.width, height: bottomHeight), center: CGPoint(x: 0, y: -bottomHeight / 2)) addChild(topPipe) addChild(bottomPipe) } // 每帧更新:向左平移 func updatePosition(deltaTime: TimeInterval) { position.x -= Constants.Physics.pipeSpeed * deltaTime } // 判断是否移出屏幕,可回收 func isOffScreen() -> Bool { return position.x < -self.frame.width } }
  • topPipe.physicsBody和bottomPipe.physicsBody分别设置为矩形,不给整个Pipe节点设物理体,因为中间是空隙,必须让鸟能穿过。若错误地给Pipe设physicsBody,则整个节点变成实心障碍,永远无法通关。
  • isOffScreen()是对象池回收的判断依据:当管道 X 坐标小于-frame.width,说明已完全移出左边界,可从场景中removeFromParent()并归还到池中。

3.3GameScene.updateGame():主循环的 4 个原子操作

updateGame是CADisplayLink每帧调用的方法,它必须轻量、无阻塞、顺序明确:

@objc private func updateGame() { // 1. 更新所有动态对象位置(管道、地面滚动) pipes.forEach { $0.updatePosition(deltaTime: 1.0/60.0) } ground.updatePosition(deltaTime: 1.0/60.0) // 2. 手动步进物理世界(替代 SpriteKit 自动 step) physicsWorld.step(1.0/60.0) // 3. 检查死亡状态(必须在 step 后,否则 contactDelegate 不触发) bird.checkDeath() // 4. 检查分数:当管道中心 X 经过鸟的位置时 +1 pipes.forEach { pipe in if !pipe.hasScored && pipe.position.x + pipe.frame.width/2 < bird.position.x && pipe.position.x + pipe.frame.width > bird.position.x { score += 1 scoreLabel.text = "\(score)" pipe.hasScored = true } } }
  • 第 2 步physicsWorld.step()必须显式调用:这是绕过 SpriteKit 自动物理更新的关键。若依赖isPaused = false让系统自动 step,则contactDelegate可能在checkDeath()之前未触发,导致死亡检测延迟一帧。
  • 第 4 步分数判定逻辑:用pipe.position.x + pipe.frame.width/2 < bird.position.x判断管道中心是否已越过鸟,而非简单比较position.x,避免因帧率波动导致漏计分。

4. 常见问题排查:真机调试必踩的 5 个坑

4.1 现象:App 启动后SKView一片黑,控制台无报错

原因:GameViewController.swift中未正确设置SKView的ignoresSiblingOrder和showsFPS,或GameScene的scaleMode不匹配设备分辨率。
解决:在GameViewController.viewDidLoad()中强制设置:

if let view = self.view as! SKView? { view.ignoresSiblingOrder = true view.showsFPS = true view.showsNodeCount = true if let scene = GameScene(fileNamed: "GameScene") { scene.scaleMode = .aspectFill // 必须设为 aspectFill,否则 iPhone 14 Pro Max 等大屏会拉伸变形 view.presentScene(scene) } }

4.2 现象:鸟点击后不动,或只跳一下就坠落

原因:Bird.flap()中未清除physicsBody?.velocity,导致多次点击后 Y 速度持续累加,最终step()计算时超出浮点精度范围,velocity变为inf。
解决:严格按 3.1 节代码,在applyImpulse前添加physicsBody?.velocity = CGVector(dx: 0, dy: 0)。可在flap()开头加日志验证:print("Before flap: \(physicsBody?.velocity.dy ?? 0)")。

4.3 现象:管道碰撞检测失效,鸟穿管而过不触发死亡

原因:Pipe节点未设置name = "pipe",或physicsBody的categoryBitMask/contactTestBitMask未正确配置。
解决:在Pipe初始化后添加:

topPipe.physicsBody?.categoryBitMask = PhysicsCategory.pipe topPipe.physicsBody?.contactTestBitMask = PhysicsCategory.bird bottomPipe.physicsBody?.categoryBitMask = PhysicsCategory.pipe bottomPipe.physicsBody?.contactTestBitMask = PhysicsCategory.bird

并在Constants.swift中定义:

struct PhysicsCategory { static let none: UInt32 = 0 static let bird: UInt32 = 0b1 static let pipe: UInt32 = 0b10 static let ground: UInt32 = 0b100 }

4.4 现象:真机上音效播放延迟 0.5 秒以上,或首次点击无声音

原因:iOS 10+ 要求音频会话必须在用户手势(如点击)后激活,否则AVAudioPlayer初始化失败。
解决:在Bird.flap()中首次播放前,强制激活音频会话:

do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setActive(true) } catch { print("Audio session activation failed: \(error)") } // 然后才 play()

4.5 现象:横屏切换后游戏画面错位,管道位置偏移

原因:GameScene未监听UIDevice.orientationDidChangeNotification,也未重置scaleMode和size。
解决:在GameScene.didMove(to:)中添加:

NotificationCenter.default.addObserver( self, selector: #selector(handleOrientationChange), name: UIDevice.orientationDidChangeNotification, object: nil )

并在handleOrientationChange中重新设置scene.size = view.frame.size和scene.scaleMode = .aspectFill。


5. 二次开发实战:3 种高价值改造路径与验证方法

5.1 改造路径一:接入自定义物理引擎(如 Box2D)

原生SKPhysicsWorld适合简单游戏,但若需复杂关节、轮式运动或精准摩擦模拟,Box2D 是更优解。改造步骤如下:

  1. 引入 Box2D:通过 Swift Package Manager 添加https://github.com/andrew8xx/box2d-swift(已 Swift 化封装);
  2. 替换物理体创建逻辑:在Bird.init()中删除physicsBody = ...,改为:
    import Box2D private var b2Body: b2Body? init() { // ... 纹理初始化同前 let world = GameScene.sharedBox2DWorld // 全局 Box2D world 实例 let bodyDef = b2BodyDef() bodyDef.type = b2_dynamicBody bodyDef.position = b2Vec2(position.x / PTM_RATIO, position.y / PTM_RATIO) // PTM_RATIO = 32(pixel-to-meter ratio) b2Body = world.CreateBody(&bodyDef) let shape = b2CircleShape() shape.m_radius = (size.width / 2.5) / PTM_RATIO let fixtureDef = b2FixtureDef() fixtureDef.shape = &shape fixtureDef.density = 0.5 fixtureDef.friction = 0.1 fixtureDef.restitution = 0.3 b2Body?.CreateFixture(&fixtureDef) }
  3. 同步 Box2D 世界到 SpriteKit:在updateGame()中,于physicsWorld.step()后添加:
    // Step Box2D world GameScene.sharedBox2DWorld.Step(1.0/60.0, 6, 2) // 同步 b2Body 位置到 SKNode if let b2 = bird.b2Body { let pos = b2.GetPosition() bird.position = CGPoint(x: pos.x * PTM_RATIO, y: pos.y * PTM_RATIO) bird.zRotation = b2.GetAngle() }

验证方法:修改fixtureDef.density为0.1(轻如羽毛)和5.0(重如铅块),观察跳跃高度与下落速度变化是否符合物理直觉;用b2MouseJoint实现拖拽鸟飞行,测试关节稳定性。

5.2 改造路径二:添加粒子特效系统(爆炸/尾迹)

原项目无粒子效果,但SKEmitterNode可无缝集成。以“死亡爆炸”为例:

  1. 创建粒子文件:在Assets.xcassets中新建Explosion.sks,设置Particle Birth Rate = 200,Lifetime = 0.5,Speed = 50~100,Blend Mode = Additive;
  2. 在Bird.checkDeath()中触发:
    func checkDeath() { guard isAlive else { return } if /* 死亡条件 */ { isAlive = false physicsBody?.isDynamic = false // 播放爆炸粒子 let explosion = SKEmitterNode(fileNamed: "Explosion")! explosion.position = self.position explosion.targetNode = self.parent // 确保粒子随场景移动 self.parent?.addChild(explosion) // 0.5 秒后移除粒子节点(避免内存泄漏) explosion.run(SKAction.sequence([ SKAction.wait(forDuration: 0.5), SKAction.removeFromParent() ])) } }

注意:targetNode = self.parent是关键,否则粒子会固定在屏幕坐标系,不随场景滚动。实测发现若设为targetNode = self,粒子将粘在鸟身上不分离。

5.3 改造路径三:接入本地排行榜(Core Data)

用 Core Data 替代 UserDefaults 存储最高分,支持时间戳与设备标识:

  1. 建模ScoreRecord:在 Xcode 中创建.xcdatamodeld,添加 EntityScoreRecord,属性:score: Integer 16,timestamp: Date,deviceID: String;
  2. 封装数据访问层:在Utils/ScoreManager.swift中:
    class ScoreManager { static let shared = ScoreManager() private let persistentContainer: NSPersistentContainer private init() { persistentContainer = NSPersistentContainer(name: "FlappyBird") persistentContainer.loadPersistentStores { _, error in if let error = error { fatalError("Core Data load failed: \(error)") } } } func saveHighScore(_ score: Int) { let context = persistentContainer.viewContext let record = ScoreRecord(context: context) record.score = Int16(score) record.timestamp = Date() record.deviceID = UIDevice.current.identifierForVendor?.uuidString ?? "unknown" do { try context.save() } catch { print("Save failed: \(error)") } } func fetchHighScore() -> Int { let request: NSFetchRequest<ScoreRecord> = ScoreRecord.fetchRequest() request.sortDescriptors = [NSSortDescriptor(key: "score", ascending: false)] request.fetchLimit = 1 do { let results = try persistentContainer.viewContext.fetch(request) return results.first?.score ?? 0 } catch { print("Fetch failed: \(error)") return 0 } } }
  3. 在GameScene中调用:GameOverScene显示时调用ScoreManager.shared.fetchHighScore(),游戏结束时调用ScoreManager.shared.saveHighScore(score)。

验证技巧:在模拟器中运行两次,分别改deviceID为不同值,确认排行榜按设备隔离;删掉 App 重装后,检查fetchHighScore()是否仍返回历史最高分(Core Data 数据默认持久化)。

从那以后我每次接入新 SDK(无论是物理、音频还是存储),都强制走一遍「真机冷启动 → 横竖屏切换 → 后台切回 → 内存压力测试」四步流程,少一步都可能在用户手机上翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询