简介:本资源是一份基于Xcode平台开发的Flappy Bird完整iOS游戏工程,面向iOS初学者与SpriteKit入门开发者,旨在通过经典小游戏实践掌握2D游戏开发核心流程。包内共58个文件,涵盖15张PNG游戏素材(如小鸟、管道、背景)、8个Objective-C实现文件(含Player.m、GameScene.m等关键逻辑)、7个JSON配置与数据文件、6个头文件(.h)定义类接口,以及MP3/WAV音效、SKEmitterNode粒子效果(.sks)、本地化strings和项目构建配置(.pbxproj、.xcscheme等),总大小仅507KB,轻量易读。已有180人学习下载,资源结构清晰,直接打开即可编译运行,包含完整物理碰撞检测、分数系统、游戏状态管理及UI适配逻辑,特别适合理解SpriteKit框架下重力模拟、触摸响应、场景切换与资源组织方式,是iOS游戏开发入门不可多得的可运行范例。
1. 一个能跑在 macOS 上的 Flappy Bird:不是玩具,是 iOS 图形渲染与事件驱动的最小可运行闭环
上周帮某高校嵌入式课设小组调一个「跑在真机上的小游戏」,他们卡在 Core Animation 和 UIKit 事件响应的时序上——手指按下去,鸟没跳,或者跳了两帧就卡死。最后发现,他们抄的网上教程全是 Swift Playground 模拟器代码,一上真机 Xcode 就报CALayer bounds contains NaN。而这个 Flappy Bird(Xcode)项目,恰恰是少数几个从头到尾用 Objective-C + UIKit + Core Graphics 实现、全程在真机上调试通过、无任何模拟器特供逻辑的完整工程。它不带 SpriteKit,不依赖第三方引擎,所有碰撞检测手写,重力加速度用CADisplayLink精确控制,连管道生成和复用都做了对象池管理。适合想搞懂「iOS 原生动画怎么和用户手势对齐」「为什么touchesBegan:里不能直接改frame」「drawRect:里画图和CALayer渲染谁先谁后」的开发者。如果你正被UIView动画卡顿、hitTest:失效、CADisplayLink掉帧折磨,这个项目就是你的调试沙盒。
2. 工程结构与技术栈:Objective-C 为主,UIKit 为骨,Core Graphics 为血
这个项目不是用 Swift 重写的教学 Demo,而是典型的「老派 iOS 工程」:.xcodeproj直接打开就能编译,没有 CocoaPods,没有 SPM,所有依赖全在Frameworks组里硬链UIKit.framework和CoreGraphics.framework。整个工程只有 7 个源文件,但每个都承担明确职责。我把它拆成三层来看:输入层(Touch & Timer)、逻辑层(Game State & Physics)、渲染层(View & Drawing)。这种分层不是靠协议或抽象类,而是靠#import顺序和方法命名约定——比如所有以update开头的方法只在GameViewController.m里调用,所有以draw开头的只在GameView.m的drawRect:里触发。这种“土办法”反而让新手一眼看清数据流向。
2.1 目录结构与核心文件职责
项目根目录下是标准 Xcode 工程结构:
FlappyBird/ ├── FlappyBird.xcodeproj/ ├── FlappyBird/ │ ├── AppDelegate.h/m // 仅初始化 window,无游戏逻辑 │ ├── GameViewController.h/m // 主控制器:持有 gameModel、gameView、displayLink │ ├── GameView.h/m // 自定义 UIView 子类:负责 drawRect: 渲染、响应 hitTest │ ├── GameModel.h/m // 纯数据模型:birdPosition、pipeArray、score、isGameOver │ └── Utils.h/m // 两个工具函数:randomFloatBetween:and:、rectIntersectsRect:注意:没有SceneDelegate,说明最低部署目标是 iOS 12(Xcode 11+ 默认启用 SceneDelegate,但此项目显式禁用了)。Info.plist里UIApplicationSceneManifest键值为NO,这是关键线索——很多新手 clone 下来编译失败,就是因为 Xcode 新建工程默认开 SceneDelegate,而这里所有生命周期方法(如viewWillAppear:)都写在GameViewController里,根本没走UIWindowScene流程。
2.2 技术选型深挖:为什么不用 SpriteKit?为什么坚持 Objective-C?
SpriteKit 当然能更快做出 Flappy Bird,但它把SKPhysicsBody、SKAction、SKScene全部封装成黑匣子。而这个项目里,重力是这么算的:
// GameModel.m - (void)updatePhysicsWithDeltaTime:(CFTimeInterval)deltaTime { self.birdVelocityY += self.gravity * deltaTime; // gravity = -800.0f self.birdPositionY += self.birdVelocityY * deltaTime; // 地面碰撞检测(非物理引擎,纯 if 判断) if (self.birdPositionY < self.groundY) { self.birdPositionY = self.groundY; self.birdVelocityY = 0.0f; self.isGameOver = YES; } }看到没?deltaTime来自CADisplayLink的timestamp差值,不是SKAction的 duration;groundY是硬编码的self.view.bounds.size.height - 100,不是SKPhysicsBody的边界。这种写法让你清楚知道:每一帧的位移 = 当前速度 × 时间差,而时间差由屏幕刷新率决定。Objective-C 的@property (nonatomic, assign)语义也比 Swift 的var更直白地暴露内存管理意图——birdPositionY是float,不是NSNumber*,避免装箱拆箱带来的性能抖动。
提示:
CADisplayLink的frameInterval默认为 1(即每帧回调),但真机上可能因 CPU 负载掉帧。项目里没做帧率补偿,所以deltaTime是真实间隔。这是故意为之——让你看到“不补偿帧率”的后果:鸟下落变慢或变快。二次开发时,你得自己加if (deltaTime > 0.033) deltaTime = 0.033;这类 clamp。
2.3 渲染链路:从drawRect:到屏幕像素的完整路径
GameView继承自UIView,重写了drawRect:。但注意:它没有设置layerClass,也没有开启shouldRasterize,所有绘制都走 CPU 软渲染(Core Graphics)。这意味着:
drawRect:里调用CGContextFillRect()会触发 CPU 光栅化,再传给 GPU;- 每次
setNeedsDisplay都会清空之前内容,所以管道移动靠的是“重绘全部”,不是CALayer平移; birdPositionY改变后,必须手动调用[self.gameView setNeedsDisplay],否则画面不动。
关键代码段:
// GameView.m - (void)drawRect:(CGRect)rect { CGContextRef ctx = UIGraphicsGetCurrentContext(); // 1. 绘制背景(纯色渐变,非图片) CGContextSetFillColorWithColor(ctx, [UIColor colorWithRed:0.4 green:0.6 blue:0.9 alpha:1.0].CGColor); CGContextFillRect(ctx, self.bounds); // 2. 绘制地面(硬编码高度) CGContextSetFillColorWithColor(ctx, [UIColor brownColor].CGColor); CGRect groundRect = CGRectMake(0, self.bounds.size.height - 100, self.bounds.size.width, 100); CGContextFillRect(ctx, groundRect); // 3. 绘制鸟(简化为黄色圆) CGContextSetFillColorWithColor(ctx, [UIColor yellowColor].CGColor); CGRect birdRect = CGRectMake(self.gameModel.birdPositionX - 15, self.gameModel.birdPositionY - 15, 30, 30); CGContextFillEllipseInRect(ctx, birdRect); // 4. 绘制管道(循环遍历 gameModel.pipeArray) for (PipeModel *pipe in self.gameModel.pipeArray) { // 上管道 CGContextSetFillColorWithColor(ctx, [UIColor greenColor].CGColor); CGContextFillRect(ctx, pipe.upperRect); // 下管道 CGContextFillRect(ctx, pipe.lowerRect); } }这段代码揭示了一个常被忽略的事实:drawRect:不是“画一次就完事”,而是每帧都执行的重绘函数。birdRect的坐标直接来自gameModel,说明模型层和视图层是强耦合的——这在小项目里是合理取舍,但也是你二次开发时最该解耦的地方。
3. 编译与真机运行:Xcode 12+ 兼容性避坑指南
这个项目在 Xcode 15 上打开会报 3 类典型错误,不是代码问题,而是工程配置漂移。我挨个试过,下面这些操作是必须且仅需执行一次的。别信网上说的“删 DerivedData 就行”,那只是缓存问题,治标不治本。
3.1 必须修改的 3 项 Build Settings
| 设置项 | 原始值(Xcode 15 默认) | 正确值 | 为什么 |
|---|---|---|---|
| iOS Deployment Target | iOS 17.0 | iOS 12.0 | GameViewController里用了viewWillLayoutSubviews,iOS 13+ 才有viewWillTransitionToSize:,但项目没用后者;设为 12.0 才能兼容CADisplayLink的旧 API |
| Architectures | Standard architectures (Apple Silicon, Intel) | arm64 | 真机只认 arm64,模拟器才需要 x86_64;设成 Standard 会导致 Archive 失败 |
| Enable Hardened Runtime | Yes | No | 项目没签名,硬启用会报Library not loaded: @rpath/...;发布时再开,开发阶段关掉 |
注意:修改后必须 Clean Build Folder(不是 Clean),然后重启 Xcode。因为 Xcode 会缓存
Info.plist解析结果,不重启的话 Deployment Target 修改不生效。
3.2 真机调试三步验证法
- USB 连接后,在 Xcode Organizer 里确认设备已信任:点开 Window → Devices and Simulators,看设备列表是否显示 “This device is not trusted.”。如果是,手机弹窗点“信任”,再拔插 USB。
- 证书配置:在 Signing & Capabilities 页,Team 选 “None”,Bundle Identifier 改成唯一值(如
com.yourname.flappy),不要勾选 Automatically manage signing。因为项目没用 Push Notification 或 iCloud,不需要证书。 - 运行前检查
main.m:确保是@autoreleasepool { int retVal = UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); },不是@autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass([SceneDelegate class])); }。后者是 SceneDelegate 模式,会 crash。
3.3 常见问题排查:现象 → 原因 → 解决
现象 1:App 启动后黑屏,Xcode 控制台无日志
原因:GameViewController的viewDidLoad里调用了[self.gameView setupGame],但gameView是懒加载的,此时为nil,导致后续CADisplayLink未启动。
解决:在GameViewController.m的viewDidLoad末尾加[self.gameView setupGame];,并确保gameView的IBOutlet已在 Storyboard 中正确连线(检查GameViewController.xib里View的 Custom Class 是否设为GameView)。
现象 2:点击屏幕鸟不跳,但控制台打印touchesBegan
原因:GameView的userInteractionEnabled默认为NO(因为它是纯绘制 View,不响应事件)。
解决:在GameView.m的initWithFrame:里加self.userInteractionEnabled = YES;,并在hitTest:withEvent:中返回self(否则触摸事件传不到GameViewController)。
现象 3:真机上鸟下落极慢,像在糖浆里游
原因:CADisplayLink的timestamp在某些 iOS 版本上返回的是绝对时间戳(单位:秒),而项目里用的是CFAbsoluteTimeGetCurrent()差值,但deltaTime计算时误用了毫秒单位。
解决:打开GameViewController.m,找到displayLinkCallback:方法,将:
CFTimeInterval deltaTime = self.lastTimestamp - timestamp; // 错!顺序反了改为:
CFTimeInterval deltaTime = timestamp - self.lastTimestamp; // 对!当前减上次 self.lastTimestamp = timestamp;并确保self.lastTimestamp初始化为CFAbsoluteTimeGetCurrent()。
现象 4:管道生成位置错乱,有时重叠有时飞出屏幕外
原因:PipeModel的upperRect和lowerRect高度计算用了arc4random_uniform(200),但arc4random_uniform返回uint32_t,而CGRect的origin.y是CGFloat,类型转换丢失精度。
解决:在GameModel.m的generateNewPipe方法中,把:
CGFloat gapY = arc4random_uniform(200) + 100;改为:
CGFloat gapY = (CGFloat)(arc4random_uniform(200) + 100);4. 核心模块拆解:一个请求从触屏到画面更新的完整链路
我们以「用户点击屏幕,鸟向上跳」为例,追踪从硬件中断到像素刷新的每一环。这不是理论路径,而是这个项目里真实发生的 7 步调用栈。
4.1 触摸事件:touchesBegan:如何触发物理更新
GameViewController是UIWindow的 rootViewController,它的view是GameView。当用户点击:
- 系统捕获
UITouch,调用GameView的touchesBegan:withEvent:(因为userInteractionEnabled = YES) GameView不处理,转发给nextResponder,即GameViewControllerGameViewController.m的touchesBegan:withEvent:被调用:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self.gameModel jump]; // 关键:只改模型状态 [self.gameView setNeedsDisplay]; // 关键:触发重绘 }注意:这里没调用任何动画 API,也没改birdPositionY。jump方法只做一件事:
// GameModel.m - (void)jump { self.birdVelocityY = -300.0f; // 向上初速度 }所有状态变更都在模型层,视图层只负责读取。
4.2 渲染调度:CADisplayLink怎么驱动drawRect:
GameViewController在viewDidLoad里创建了CADisplayLink:
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkCallback:)]; [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];displayLinkCallback:每帧执行:
- (void)displayLinkCallback:(CADisplayLink *)displayLink { CFTimeInterval deltaTime = displayLink.timestamp - self.lastTimestamp; self.lastTimestamp = displayLink.timestamp; [self.gameModel updatePhysicsWithDeltaTime:deltaTime]; // 更新位置/速度 [self.gameModel updatePipesWithDeltaTime:deltaTime]; // 移动管道 [self.gameView setNeedsDisplay]; // 标记需重绘 }关键点:setNeedsDisplay不会立即执行drawRect:,而是把GameView加入UIView的重绘队列,等下一个 RunLoop 的BeforeWaiting阶段,系统统一调用所有标记 View 的drawRect:。这就是为什么jump和updatePhysics必须分离——模型更新和视图重绘是异步的。
4.3 碰撞检测:手写矩形相交算法的实战细节
GameModel.m的checkCollision方法是纯数学:
- (BOOL)checkCollision { CGRect birdRect = CGRectMake(self.birdPositionX - 15, self.birdPositionY - 15, 30, 30); for (PipeModel *pipe in self.pipeArray) { if (CGRectIntersectsRect(birdRect, pipe.upperRect) || CGRectIntersectsRect(birdRect, pipe.lowerRect)) { return YES; } } // 地面和天花板 if (self.birdPositionY < 30 || self.birdPositionY > self.groundY) { return YES; } return NO; }CGRectIntersectsRect是 Core Graphics 内置函数,但注意:pipe.upperRect的origin.y是从屏幕顶部算起,而birdRect的origin.y也是顶部起算,所以比较是有效的。很多新手误以为origin.y是底部,导致碰撞永远不触发——这是血泪经验:iOS 坐标系原点在左上角,不是左下角。
提示:
birdRect宽高固定为 30×30,但实际鸟是圆形。项目用矩形包围盒(AABB)做粗筛,没做像素级碰撞。如果要升级,可在drawRect:里用CGPathCreateWithEllipseInRect生成路径,再用CGPathContainsPoint精检,但性能会降 20%。
5. 二次开发实战:从改颜色到加音效的 4 种改造路径
现在你已经跑通了,下一步不是“加新功能”,而是理解每个改动会牵动哪几层。我按侵入性从低到高列 4 种常见需求,每种都给出可粘贴的代码、影响范围和验证方式。
5.1 最小改动:改鸟的颜色(零风险)
目标:把黄色鸟改成红色
改哪里:只动GameView.m的drawRect:里一行
// GameView.m 第 62 行左右 // CGContextSetFillColorWithColor(ctx, [UIColor yellowColor].CGColor); CGContextSetFillColorWithColor(ctx, [UIColor redColor].CGColor); // 替换这行影响范围:仅渲染层,不涉及模型或逻辑
验证:Clean → Build → Run,看鸟是不是红的。如果还是黄的,说明你改错了文件(确认是GameView.m,不是GameModel.m)。
5.2 中等改动:加跳跃音效(需引入 AVFoundation)
目标:点击时播放.caf格式音效
步骤:
- 把
jump.caf文件拖进 Xcode 工程,勾选 “Copy items if needed” 和 “Add to targets” - 在
GameViewController.h顶部加:
#import <AVFoundation/AVFoundation.h>- 在
GameViewController.m的@interface扩展里声明:
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;- 在
viewDidLoad里初始化(加在[self.gameView setupGame];后):
NSString *soundPath = [[NSBundle mainBundle] pathForResource:@"jump" ofType:@"caf"]; NSURL *soundURL = [NSURL fileURLWithPath:soundPath]; self.audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:soundURL error:nil]; self.audioPlayer.volume = 0.5f;- 在
touchesBegan:withEvent:里加播放:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self.gameModel jump]; [self.gameView setNeedsDisplay]; [self.audioPlayer play]; // 就这一行 }影响范围:输入层(加了音频播放)+ 工程配置(需链接AVFoundation.framework)
验证:真机测试,耳机听声音;模拟器可能无声,属正常。
5.3 高风险改动:把重力加速度从 -800 改成 -1200(影响物理逻辑)
目标:鸟下落更快,游戏更难
改哪里:GameModel.m的gravity属性:
// GameModel.m 第 25 行 // static const float kGravity = -800.0f; static const float kGravity = -1200.0f; // 改这里连锁反应:
jump方法的初速度-300.0f可能不够,鸟刚起跳就掉下来 → 需同步改jump里的self.birdVelocityY = -450.0f;- 地面碰撞的
self.groundY值可能需微调,否则鸟脚陷进地面 → 查GameModel.m的init方法,找self.groundY = self.viewHeight - 100;,把100改成120
验证:改完必须测三组:
- 单次点击,看鸟最高点是否比原来低;
- 连续点击,看是否出现“点一下跳两下”的抖动(说明
deltaTime补偿不足); - 碰撞管道时,看
isGameOver是否及时触发(加NSLog(@"Game Over")在checkCollision里)。
5.4 架构升级:用协议解耦GameView和GameModel
目标:让GameView不直接访问gameModel.birdPositionY,而是通过协议获取
步骤:
- 新建
GameModelProtocol.h:
@protocol GameModelProtocol <NSObject> - (CGPoint)birdPosition; - (NSArray<PipeModel *> *)pipes; - (NSInteger)score; @end- 在
GameModel.h里让其遵循协议:
@interface GameModel : NSObject <GameModelProtocol>- 在
GameView.h里声明属性:
@property (nonatomic, weak) id<GameModelProtocol> gameModel;- 在
GameView.m的drawRect:里,把所有self.gameModel.xxx改成[self.gameModel xxx]
好处:以后可以轻松替换GameModel为MockGameModel(用于单元测试),或NetworkGameModel(联机版)。
验证:编译通过后,运行看鸟是否还动;如果不动,说明GameViewController里没给gameView.gameModel赋值,去viewDidLoad里补上self.gameView.gameModel = self.gameModel;。
从那以后我每次接手 Objective-C 老项目,都强制走一遍「模型层 → 控制器 → 视图层」的数据流手绘,哪怕只画三个方框加箭头。因为只要箭头方向对了,加功能就是填空,而不是猜谜。希望帮到你。
本文还有配套的精品资源,点击获取