简介:这份 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 而不是 SceneKit
Flappy Bird 的所有元素都在一个平面上:鸟只在 y 轴动,管道只在 x 轴平移,碰撞是矩形和圆的近似判定。用 3D 引擎做这件事,你要额外处理相机、光照、节点朝向,注意力会被大量跟玩法无关的概念吃掉。SceneKit 本身没问题,只是对这个场景属于杀鸡用牛刀。而 UIKit 手写CADisplayLink看着轻量,实际要自己维护固定步长循环、自己做 AABB 检测、自己管节点层级,写到最后发现又造了一遍 SpriteKit。
三种方案的差异摊开看更直观:
| 维度 | SpriteKit | SceneKit | UIKit + 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.dy | physicsWorld.gravity | -6.0 ~ -12.0 | 下落更快,整体更“沉” |
| bird.mass | physicsBody.mass | 0.8 ~ 2.0 | 冲量效果被稀释 |
| 拍翅冲量 dy | applyImpulse | 6 ~ 14 | 单跳更高,容易撞顶 |
| 管道缝隙高度 | 上下管道间距 | 鸟身高 2.6 ~ 3.2 倍 | 容错变大,难度降低 |
| 管道穿越时长 | SKAction.moveBy | 2.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: Set<UITouch>, 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用了.resizeFill,scene.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时置isPaused,update里的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三个开关打开,在真机上跑一遍再谈改逻辑——模拟器上的流畅和真机完全是两回事,这个顺序颠倒过一次,浪费了整整一个下午。工程本身不大,改几行参数就能看到明显差别,拿来练手正合适。希望帮到你。
本文还有配套的精品资源,点击获取