ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Xcode重写Flappy Bird:SpriteKit物理引擎与2D游戏开发实战

用Xcode重写Flappy Bird:SpriteKit物理引擎与2D游戏开发实战 简介一份基于Xcode开发的Flappy Bird完整工程包适合Swift/Objective-C初学者以及想复刻经典休闲游戏的iOS开发者。项目围绕SpriteKit框架实现2D物理模拟与触控交互涵盖小鸟飞行、管道随机生成、碰撞检测、计分系统及游戏结束回调等核心模块可从中学习游戏循环、物理引擎调参、UI适配与调试方法。压缩包共58个文件包含Objective-C源码.h/.m、SpriteKit场景文件.sks、图片素材.png、音效.mp3/.wav及工程配置文件.plist/.pbxproj等整体仅507KB结构紧凑便于快速定位代码与资源。目前已有177人学习浏览适合作为入门iOS游戏开发的实践参考既可直接运行体验也可逐模块对照分析实现细节。1. 用 Xcode 重写 Flappy BirdSpriteKit 到底替你省了多少事Flappy Bird 这种玩法极简的小游戏恰恰是验证一个游戏框架是否顺手的最佳试金石。基于 Xcode 自带的 SpriteKit 物理引擎整个项目的核心代码也就三百行上下小鸟的 Flap 跳跃本质是一次脉冲叠加到重力场管道的移动是 SKAction 的线性位移碰撞则是物理体的 contactTestBitMask 按位匹配。这篇文章是我自己拆过的完整 Flappy BirdXcode 工程复现笔记适合刚接触 iOS 开发、想用 SpriteKit 入门 2D 游戏的人也适合那些想快速搭一个可玩 Demo 再扩展成其他休闲小游戏的老手。你会看到从场景搭建、物理参数调到打包上架的一整条链路其中证书、性能分析这些坑我也一并记录在案。2. 搭建场景与让小鸟飞起来物理参数与状态机的前置设计2.1 SpriteKit 场景初始化物理世界的重力、调试绘制与节点层级打开 Xcode新建工程时选择 Game 模板模板默认会生成 GameScene.swift并把 scene 的 scaleMode 设置为 .aspectFill。我一般先清掉模板里的乱码代码从 SKScene 的子类开始把物理世界和节点层级一次理干净。import SpriteKit class GameScene: SKScene, SKPhysicsContactDelegate { private var bird SKSpriteNode() private var groundNode SKSpriteNode() private var scoreLabel SKLabelNode() private var bgNode SKSpriteNode() private var isGameOver false private var score 0 override func didMove(to view: SKView) { backgroundColor SKColor(red: 0.80, green: 0.90, blue: 0.95, alpha: 1) // 物理世界重力与接触代理 physicsWorld.gravity CGVector(dx: 0, dy: -5.0) physicsWorld.contactDelegate self setupBackground() setupGround() setupBird() setupScoreLabel() } }这段代码里最关键的是physicsWorld.gravity的设置。Flappy Bird 的手感Game Feel有很大一部分来自重力加速度的取值系统默认是(0, -9.8)那是地球重力小鸟下坠非常快玩家反应时间极短游戏变成纯受虐。我一般会把竖直重力调到-5.0到-6.5之间配合 Flap 脉冲速度让下降曲线有一点“飘”的感觉。注意一个细节如果 gravity 的绝对值太小比如-2.0小鸟会像在月球上失重感太强玩家会觉得控制失灵。这个参数没有标准值要在真机上反复试手感模拟器和真机的帧率差异会让手感完全不同。场景层级我也顺带说明一下groundNode放在场景底部bird作为场景子节点直接参与物理模拟。背景节点用普通的 SKNode 挂载在场景上层即可。如果你打算后面接入多张背景图做视差滚动建议单独建一个 BgLayer用枚举管理 zPosition避免和管道、UI 节点互相遮挡。2.2 小鸟的 Flap 动作applyImpulse、velocity 重置与角度修正小鸟的 Flap 是整款游戏的核心交互。点击屏幕时要给小鸟一个向上的冲量同时把角度拉平不然小鸟会在每次 Flap 后以诡异的姿态翻转。以下是工程项目里主要的一段触控处理逻辑override func touchesBegan(_ touches: SetUITouch, with event: UIEvent?) { if isGameOver { resetGame() return } // 物理体存在时才允许 Flap guard let body bird.physicsBody else { return } // 重置竖直方向速度避免叠加导致失控 body.velocity.dy 0 // 施加脉冲数值决定单次 Flap 的弹跳高度 body.applyImpulse(CGVector(dx: 0, dy: 20)) // 角度修正让鸟头略微上扬 bird.run(SKAction.rotate(toAngle: 0.3, duration: 0.1)) }velocity.dy 0这一步非常重要我第一次写的时候只调用了applyImpulse结果连续点击屏幕后重力累积小鸟直接冲出屏幕。先清零再给脉冲相当于每次 Flap 都重置运动状态玩家点击的节奏感才稳定。applyImpulse的dy参数和重力是两个共同影响手感的核心数值我给的dy: 20是配合上面gravity: -5使用的。如果你把重力调到-6.5建议把脉冲同步提高到24左右。角度修正用SKAction.rotate(toAngle:)0.3弧度约等于 17 度。注意这里有个坑小鸟在下落过程中会受重力影响自然旋转你要在update(_ currentTime:)里持续检测让角度随速度变化归位否则小鸟会头朝下旋转到玩家的血压飙升。常见做法是在 update 里判断body.velocity.dy 0时执行一个缓慢的 rotate 动作将角度逐渐拉回到-0.5弧度左右。2.3 地面滚动与背景循环避开 SpriteKit 处理不透明大图的坑地面滚动是 Flappy Bird 里最容易出“视觉穿帮”的地方。我的做法是基于两段相同尺寸的地面图片交替位移当一段完全移出屏幕后瞬间拉回终点形成无限循环。private func setupGround() { let texture SKTexture(imageNamed: ground) let groundHeight: CGFloat 80 for i in 0...1 { let ground SKSpriteNode(texture: texture) ground.size CGSize(width: frame.width 1, height: groundHeight) ground.anchorPoint CGPoint(x: 0, y: 0.5) ground.position CGPoint(x: CGFloat(i) * frame.width, y: 0) ground.zPosition 20 groundNode.addChild(ground) } let moveLeft SKAction.moveBy(x: -frame.width, y: 0, duration: 4.0) let reset SKAction.moveBy(x: frame.width, y: 0, duration: 0) let sequence SKAction.sequence([moveLeft, reset]) groundNode.run(SKAction.repeatForever(sequence)) }这里两个地面节点位置分别是x 0和x frame.width总宽度刚好覆盖整屏加一个重叠余量。移动的 duration 是地面滚动速度我用的4.0秒你可以根据游戏难度调整想要节奏更快就缩到3.0。这个速度要和管道的移动速度保持一定协调感如果地面快、管道慢玩家视觉上会觉得很分裂。关于frame.width 1这个 1是经验之谈。两段图片对齐时像素边缘偶尔出现一条细缝加 1 个点宽可以有效遮盖 SpriteKit 纹理采样的边缘色差。地面节点不要放物理体Flappy Bird 里地面通常作为碰撞体存在但这个碰撞体建议单独建一个矩形物理体而不是直接用有纹理的地面节点去挂物理体——纹理不透明区域的边缘会干扰碰撞盒判定。后面避坑章我会展开讲。2.4 状态机设计Ready、Playing、GameOver 三段不可互相跳过的闭环Flappy Bird 看着简单但状态机的边界极容易写乱。我在工程里用了最简单的 Int 枚举管理状态用 switch 收敛所有交互入口避免按钮、碰撞回调、计时器在错误状态下乱触发。enum GameState { case ready case playing case gameOver } func switchState(to newState: GameState) { state newState switch state { case .ready: bird.position CGPoint(x: frame.width * 0.35, y: frame.height * 0.6) bird.physicsBody?.isDynamic false readyLabel.isHidden false case .playing: bird.physicsBody?.isDynamic true bird.physicsBody?.velocity .zero readyLabel.isHidden true startSpawningPipes() case .gameOver: bird.physicsBody?.isDynamic false stopSpawningPipes() checkAndSaveHighScore() } }ready状态时小鸟不能参与物理模拟isDynamic false可以直接让重力失效但小鸟照样能静止在场景中。这个写法比把gravity临时改成 0 要干净因为你不再需要记住恢复。gameOver状态下小鸟也不参与模拟但要注意这会让小鸟瞬间冻结在空中看起来不自然。常见做法是我们稍后要在 didBegin 碰撞回调里给小鸟加一个翻转下坠动画动画播完再切到 gameOver 的静态状态。这个先冻结再动画的顺序关系我在避坑章会再指出一个容易踩的误用。3. 管道生成与碰撞判定把游戏循环跑通像素级回收别省3.1 管道的 SKNode 包装与随机间距一套参数控制一套手感Flappy Bird 里管道是最核心的障碍物。一个管道组合包含上管道、下管道和中间的缺口。我把上下两段管道的 SKSpriteNode 挂在一个 SKNode 下由这个 SKNode 作为整体移动和与小鸟做碰撞检测。这样小鸟在缺口穿行时检测逻辑只关心一个节点不用分别处理上下管道。private func spawnPipePair() { let pipeNode SKNode() pipeNode.position CGPoint(x: frame.width 80, y: 0) pipeNode.zPosition 10 pipeNode.name pipe let gapHeight: CGFloat 150 // 上下管道缺口高度 let pipeWidth: CGFloat 56 let pipeSpeed: CGFloat 3.0 // 伏笔避坑章会专门说 // 随机缺口中心高度控制难度 let minY frame.height * 0.25 let maxY frame.height * 0.6 let gapCenterY CGFloat.random(in: minY...maxY) let topHeight frame.height - gapCenterY - gapHeight / 2 let bottomHeight gapCenterY - gapHeight / 2 // 上管道anchorPoint 设到底部让 position 就是管道底边的位置 let topPipe SKSpriteNode(color: .green, size: CGSize(width: pipeWidth, height: topHeight)) topPipe.anchorPoint CGPoint(x: 0.5, y: 1.0) topPipe.position CGPoint(x: 0, y: gapCenterY gapHeight / 2) let bottomPipe SKSpriteNode(color: .green, size: CGSize(width: pipeWidth, height: bottomHeight)) bottomPipe.anchorPoint CGPoint(x: 0.5, y: 0) bottomPipe.position CGPoint(x: 0, y: gapCenterY - gapHeight / 2) // 物理体只设在管道节点上用最简矩形 topPipe.physicsBody SKPhysicsBody(rectangleOf: topPipe.size) topPipe.physicsBody?.isDynamic false bottomPipe.physicsBody SKPhysicsBody(rectangleOf: bottomPipe.size) bottomPipe.physicsBody?.isDynamic false pipeNode.addChild(topPipe) pipeNode.addChild(bottomPipe) // 先给左右移动的动作然后根据 state 决定是否移动 let moveLeft SKAction.moveBy(x: -pipeSpeed * frame.width, y: 0, duration: 4.0 * frame.width / 300) let moveDone SKAction.removeFromParent() pipeNode.run(SKAction.sequence([moveLeft, moveDone])) addChild(pipeNode) }注意上管道的 anchorPoint 是(0.5, 1.0)下管道是(0.5, 0)。这样设置后管道的 position 直接按缺口边缘坐标来放不需要关心管道自身的尺寸在这个逻辑里的偏移。gapHeight 是难度调节最直接的参数150 是入门级想加到 180 会让通过率明显上升但游戏也会变得无聊想要折磨玩家就缩到 120配合鸟的碰撞圆半径 12容错空间会非常小。pipeSpeed这里我用了一个反直觉的构造moveBy(x: -pipeSpeed * frame.width, ...)然后 duration 是 4 倍固定帧宽。实际上管道速度由moveBy的位移量和 duration 共同决定。如果你想要稳定的视觉速度应该保持位移量固定例如-frame.width * 1.2直接调 duration 来控制速度。我这里写成这样确实有点绕避坑章里专门讲了为什么这么写容易把速度改乱。3.2 管道的循环生成与回收moveDone 之后物理体跟着销毁管道生成不能一次性铺满全屏那会耗内存。我用scheduleSpawn来控制生成间隔。常见做法是使用一个定时器每隔 2 秒左右调一次 spawnPipePair。private func startSpawningPipes() { let spawnAction SKAction.run { [weak self] in self?.spawnPipePair() } let waitAction SKAction.wait(forDuration: 2.0) let sequence SKAction.sequence([spawnAction, waitAction]) let repeatAction SKAction.repeatForever(sequence) // 挂到隔离节点方便 gameOver 时立刻停止 spawnRunner.run(repeatAction, withKey: pipeSpawn) } private func stopSpawningPipes() { spawnRunner.removeAction(forKey: pipeSpawn) }等待时间我用的 2.0 秒。这里有个容易掉进的黑匣子等待时间不是管道间距真正决定两个管道之间距离的是生成间隔乘以管道移动速度。如果 wait 是 2 秒、管道每秒移动 120 点那两根管道之间的视觉间距就是 240 点。所以调难度时要么固定 wait 调 moveDuration要么固定 moveDuration 调 wait不要两个参数同时乱改否则你会发现管道要么互相重叠要么间距大到根本没威胁。spawnRunner是一个独立的 SKNode不挂到管道节点上也不会被 pipe 的移动动作影响。这样 Stop 的时候用removeAction(forKey:)可以精准移除 spawn 动作不会误伤小鸟身上正在跑的其它 SKAction。游戏结束的时候已有管道还会继续向左移动通常我会在 gameOver 时遍历节点把管道全部移屏外但更稳的做法是保留现有管道只停生成让它们继续跑完 removeFromParent 流程。3.3 碰撞判定物理接触代理与 categoryBitMask 的位运算设计碰撞检测在这份资源里有标准玩法但我见过不少新手工程跑不起来因为它们只用了frame.intersects()做矩形相交检测。那个方法在圆形小鸟和矩形管道上会出现明显的“贴脸判定”问题——小鸟的视觉身体是椭圆旋转后又变矩形intersects 的阈值非常别扭。SprinkteKit 的物理接触回调可以用圆与矩形的交集与视觉贴合度更高。struct PhysicsCategory { static let bird: UInt32 1 static let pipe: UInt32 2 static let ground: UInt32 4 // 把 ground 也设成单独类别方便区分地面碰撞单独处理 } // 在 setupBird 里设置小鸟物理体 bird.physicsBody SKPhysicsBody(circleOfRadius: 12) bird.physicsBody?.categoryBitMask PhysicsCategory.bird bird.physicsBody?.contactTestBitMask PhysicsCategory.pipe | PhysicsCategory.ground bird.physicsBody?.collisionBitMask 0 // 在 setupGround 里设置地面物理体 groundNode.physicsBody SKPhysicsBody(rectangleOf: CGSize(width: frame.width, height: 80)) groundNode.physicsBody?.isDynamic false groundNode.physicsBody?.categoryBitMask PhysicsCategory.ground // 在 spawnPipePair 里设置管道物理体 topPipe.physicsBody?.categoryBitMask PhysicsCategory.pipe topPipe.physicsBody?.contactTestBitMask PhysicsCategory.birdcategoryBitMask是身份contactTestBitMask是“我跟谁碰了要通知我”collisionBitMask是“我跟谁碰了要发生物理拒止”。小鸟的collisionBitMask 0意味着它不跟任何物体发生刚体碰撞反弹否则小鸟撞上管道会被弹开游戏无法结束。管道两侧同为isDynamic false也不会响应碰撞。contactTestBitMask pipe | ground用位或组合了两个类别。当你踩坑时最典型的现象是didBegin回调被连续触发——同一帧里小鸟同时碰到上管道和下管道回调执行两次。解决方法是加一个isGameOver状态锁让碰撞处理幂等。func didBegin(_ contact: SKPhysicsContact) { guard !isGameOver else { return } let otherBody contact.bodyA.categoryBitMask PhysicsCategory.bird ? contact.bodyB : contact.bodyA if otherBody.categoryBitMask PhysicsCategory.pipe { gameOver() } else if otherBody.categoryBitMask PhysicsCategory.ground { gameOver() } }这段代理方法的顺序判断是重点contact.bodyA与bodyB的先后次序在 SpriteKit 里并不稳定如果直接写bodyA bird会导致偶发漏判。用 categoryBitMask 取另一侧节点并对isGameOver做守卫是最稳的写法。4. 计分、音效与游戏结束从“能碰死”到“能玩”的最后一步4.1 分数判定逻辑用位置比较替代接触回调Flappy Bird 的计分和碰撞是两条线经过管道缺口中心时加分而不是碰到管道时加分。这里我踩过一次坑试图用 contact 回调去找 scoreNode结果每次穿过缺口时因为小鸟和上下管道都接触分数暴涨。正确的路径是用一个“通过判定”的监听节点或纯位置比较。func update(_ currentTime: TimeInterval) { guard state .playing else { return } // 遍历现有管道找到最接近小鸟右侧的那根 enumerateChildNodes(withName: pipe) { node, _ in guard let pipeNode node as? SKNode else { return } // 缺口中心已经在小鸟左侧说明穿越完成 if pipeNode.position.x bird.position.x !pipeNode.userDataPassed { pipeNode.userDataPassed true score 1 scoreLabel.text \(score) } } }这种写法的好处是不生成额外节点不会引入新的物理体。userDataPassed这里用了一个 UserData 字典如果你想让代码更类型安全可以用一个自定义 SKNode 子类挂一个var passed: Bool属性。注意update里遍历所有管道是高频操作Flappy Bird 同一屏管道数量不超过 3 根性能没问题。4.2 音效与动画的触发时机SKAction.playSoundFileNamed 的预加载隐患音效用SKAction.playSoundFileNamed(flap.wav, waitForCompletion: false)是最省事的方法。但要注意首次调用时 SpriteKit 才会把音频文件解码加载这会造成第一次点击时产生一个瞬时卡顿。对应解决方式是在 didMove 里预播放一次静音音效或者直接使用SKAudioNode。private func setupSounds() { // 预加载关键音效 flapSound SKAction.playSoundFileNamed(flap.wav, waitForCompletion: false) scoreSound SKAction.playSoundFileNamed(score.wav, waitForCompletion: false) hitSound SKAction.playSoundFileNamed(hit.wav, waitForCompletion: false) } // 触发 Flap 音效 run(flapSound)音效触发时机非常影响手感Flap 音效应该在applyImpulse之前执行这样声音刚好覆盖到触觉反馈的瞬间。如果放在最后你会觉得小鸟的起跳“慢半拍”。加分音效同理应该在score 1之后立刻播放不需要等待物理帧同步。waitForCompletion: false表示不等音频播完就返回防止连续点击时音效互相打断。4.3 gameOver 动画翻转坠落播放完再切状态别把逻辑写在物理回调里撞到管道时立刻把isDynamic false游戏会变得非常生硬。我一般先进入一种“临死状态”关掉输入、停止生成管道但鸟保持物理模拟给一个向上翻身力和一个向下的坠落等小鸟落出屏幕外再正式切gameOver。private func triggerHitSequence() { // 停止管道生成但小鸟仍然参与物理 stopSpawningPipes() // 让小鸟向上弹一下再坠落 bird.physicsBody?.velocity.dy 120 bird.physicsBody?.applyAngularImpulse(4.0) // 延时后再彻底冻结 let wait SKAction.wait(forDuration: 1.0) let pauseBird SKAction.run { [weak self] in self?.switchState(to: .gameOver) } run(SKAction.sequence([wait, pauseBird])) }applyAngularImpulse(4.0)给小鸟一个自旋角速度模拟被撞翻的效果。这里角度方向要小心如果velocity.dy是正的而applyAngularImpulse是负的鸟会向后空翻异常别扭玩家会一眼看出物理不合理。坠落过程中不要移除物理体保持和小鸟位置同步即可。4.4 最高分持久化UserDefaults 的写入时机与归档选择最高分记录是让玩家“再来一把”的钩子。用 UserDefaults 存一个整数即可不需要 Core Data。private func checkAndSaveHighScore() { let defaults UserDefaults.standard let savedBest defaults.integer(forKey: bestScore) if score savedBest { defaults.set(score, forKey: bestScore) bestScoreLabel.text Best: \(score) } }写入时机要小心不要在分数加一的瞬间每次写那会造成无意义的磁盘 IO。gameOver 时一次性比较最高分即可。如果你后续想要跨设备同步可以把最高分放到 iCloud Key-Value但 Flappy Bird 这种轻量游戏完全没必要。5. 避坑与排查模拟器正常真机翻车五个常见问题的定位路径5.1 真机掉帧与纹理闪烁现象模拟器流畅运行一上真机小鸟的纹理偶尔出现半透明闪烁管道边缘能看到不规则的“毛边”。原因模拟器使用的渲染管线与真机 GPU 不一致最典型的原因是纹理没有使用图集Texture Atlas并且图片尺寸不是 2 的幂。SpriteKit 对非图集的零散大图在真机上要实时上传到 GPU带宽占用突然升高导致帧率波动。解决把小鸟所有帧动画和管道素材放进 Assets.xcassets 中同名的纹理图集文件夹。另外设置view.isAsynchronous false可以临时规避掉帧但这不是根本解法资源和图集才是。5.2 碰撞回调重复触发导致一次死亡计两次分现象小鸟撞到管道didBegin被连续调用了两次接着因为gameOver()内部有弹窗或状态切换逻辑分数被扣了两遍或最高分被污染。原因一段管道由上、下两根 SKSpriteNode 分别挂物理体小鸟同时碰到两者接触回调触发两个 contact。还有可能是接触判定在当前物理帧内没有立即结束。解决在didBegin入口加guard !isGameOver并且把管道物理体从上下两根改挂到父容器 pipeNode 上用一个矩形物理体覆盖两根管子中间的缺口区域。这样物理体数量减半回调次数自然减少。同时也建议在gameOver()里把contactTestBitMask置 0。5.3 Memory Graph 图标闪红运行时内存只升不降现象Xcode 的 Memory Graph 工具里场景中的 SKTexture 数量不断上升即使管道已经removeFromParent内存占用依然稳步增加。原因最常见的是 SKAction 的强引用循环。bird.run(flapAction)里如果闭包捕获了self而后 self 又通过bird持有 action就会导致SKNode - SKAction - self(GameScene) - SKNode的循环引用。另一个原因是管道节点已被移出场景但仍被某个 action 持有。解决所有闭包用[weak self]并检查 action 是否通过withKey管理。需要周期性从父节点移除的管道节点在moveDone里removeFromParent后立刻调用pipeNode.removeAllActions()确保动作释放。Memory Graph 里筛SKAction类名就能看到残留对象。5.4 Xcode 打包突然很慢每次 Build 耗时长到怀疑人生现象同样的工程没改什么代码某天开始 Xcode 打包慢到 3-5 分钟尤其是最后几步一直在等待。原因多数情况下是 Build Phases 中加入了脚本Run Script脚本里可能执行了资源同步、版本戳写入等操作。脚本本身没问题但每次编译都会跑如果脚本里有网络下载或大规模文件操作就会把打包拖垮。另一个隐蔽原因是 DerivedData 目录缓存膨胀某些.modulemap文件损坏导致反复重编。解决打开工程 TARGET - Build Phases逐个查看 Run Script 的输出把不必要的脚本注释掉。清理 DerivedData在终端执行rm -rf ~/Library/Developer/Xcode/DerivedData/*注意这会强制全量重新编译首次会变慢但后续就正常了。如果特定版本 Xcode 一直慢尝试在 Xcode Settings 里把 Build System 从 New Build System 切到 Legacy多数时候可以绕开系统 bug。5.5 提示 You should download the command line tools for Xcode现象在终端里跑 git、pod、xcodebuild 等命令时报错you should download the command line tools for xcode但 Xcode 明明已经装好了。原因Xcode 的 Developer Directory 路径没有被设置。最常见于升级 Xcode 后xcode-select还指向旧版本路径或已卸载的 CommandLineTools。也可以出现在从 App Store 下载 Xcode 却从未打开过第一次同意协议的情况下。解决终端执行sudo xcode-select -s /Applications/Xcode.app/Contents/Developer然后xcodebuild -version验证。如果还报错执行sudo xcodebuild -license accept同意协议。注意 Command Line Tools 和 Xcode 是两套工具链如果平时只做 iOS 开发装 Xcode 就够了不建议额外去装 Command Line Tools 造成版本冲突。6. 进阶性能验证、内存排查与证书配置到上架把 Flappy Bird 当试炼场6.1 用 Instruments 验证帧率与能耗而不是靠眼睛说“流畅”模拟器里看起来流畅不等于真机没问题。连接真机Xcode 顶部 Network 里选择你的设备Profile 调出 Instruments选 Game Performance 模板里面会同时显示 Core Animation FPS、CPU 使用率和内存轨道。Flappy Bird 这种轻量项目真机帧率要达到 60 FPSCPU 占用不超过 20%如果帧率低于 55先看 CPU 磁道是不是被 SKPhysicsWorld 的 step 消耗光了再看磁盘轨道是不是在连续读取音频文件。典型的 CPU 异常峰值是音频每次播完重新解码处理方法是提前把所有音效压成 m4a 或 wav 格式用AVAudioPlayer或SKAction预加载到内存。6.2 用 Memory Graph 检查循环引用顺手养成强制习惯Debug 运行时点 Debug Memory Graph 按钮进入内存对象图搜索GameScene、SKNode、SKAction三个类。重点看 SKNode 的引用链是否挂在 GameScene 之外比如某个 SKAction 的 block 参数里有一个灰色的小图标指向 GameScene那就是循环引用。我每次写完一个 SpriteKit 游戏都会强制自己把 Memory Graph 的 SKAction 和 SKNode 全部分组过一遍有残留就直接点进去看调用栈。这个习惯帮我避开了至少三次审核内存崩溃的问题。6.3 证书配置到上架全流程开发者账号、Bundle Identifier、描述文件与 App Store Connect步骤操作注意点1在有付费开发者账号状态下打开 Xcode - Settings - Accounts登录 Apple ID不需要创建 CSRXcode 自动管理证书2工程 TARGET - Signing Capabilities勾选 Automatically manage signing选择你的 TeamXcode 自动生成 Development 与 Distribution 证书3设置 Bundle Identifier例如 com.yourname.flappybird必须唯一测试时可以乱写但上架审核前要改成自己域名反写4连接真机选择设备点 Run首次会在设备安装开发描述文件如果弹出 Unavailable到设备通用设置信任此开发者5Product - Archive成功后在 Organizer 窗口点 Distribute App选 App Store Connect再选 Upload6登录 App Store Connect新建 App填写隐私、年龄分级、截图截图需要 6.7 和 5.5 英寸各一套可以用模拟器截但状态栏要处理干净7提交审核等待邮箱反馈常见被拒原因是使用被注册的素材名称Flappy Bird 重名要小心最好换个标题Flappy Bird 这种游戏上架有一点特殊风险App Store 对和原版玩法极度相似的游戏有一定审查压力尤其资源名甚至图标都和原版接近时。做个人开发者向的作品强烈建议把主题换掉比如换成小鸟穿过城市大楼或太空中的管道联盟玩法保留美术重画。界面图标和启动页不要直接沿用 Game 工程模板的默认图标因为那个图标几乎等于告诉审核员“我是从模板改的”。6.4 Last Tip真机调试永远是第一优先级触感与延迟决定手感的一半从那以后我每次调 Flappy Bird 手感都强制自己走一遍真机流程重力强度、Flap 脉冲、音效延迟、碰撞回调顺序一个一个参数改每个参数只动一个变量跑三局再改下一个。模拟器只是用来验证逻辑最终视觉和手感全都以真机为准。这道工序看起来很笨但它能把“好像有点怪”诉诸一个可量化的对比同一次跳跃在不同重力下能飞多高碰撞时音效和画面是否都在同一帧。希望帮到你。本文还有配套的精品资源点击获取
返回列表