Sprite Kit是一个在iOS7上制作令人惊喜的2D游戏的新框架,它内置于iOS7 SDK。它拥有材质精灵(以下将直接引用sprite),支持很酷的特效,比如视频、滤镜、遮罩等,内置了物理引擎库,还有很多其他的东西。
在这篇Sprite Kit的初学者教程里,你会从头到尾系统地学到如何为iPhone创建一个简单而有意思的2D游戏。如果你看过我们
在开始之前你需要确保自己安装了最新版本的Xcode(5.X),它包含了对Sprite Kit和iOS7的支持。
Sprite Kit的优点和缺点
在开始之前,我想先指出Sprite Kit 并不是你在iOS平台上制作2D游戏的唯一选择,而且它有一些优缺点是你需要事先注意的。
之后我想再回顾一下iOS上制作2D游戏其他的三种在选择并且与Sprite Kit比较一下各自的优缺点。
Sprite Kit 优点
- 优点1:内置于iOS,因此不需要再额外下载类库也不会产生外部依赖。它是苹果官方编写的,所以可以确信它会被良好支持和持续更新。
- 优点2:它为纹理贴图集和粒子提供了内置的工具。
- 优点3:它可以让你做一些用其他框架很难甚至不可能做到的事情,比如把视频当作sprites来使用或者实现很炫的图片效果和遮罩。
Sprite Kit 缺点
- 缺点1:如果你使用了Sprite Kit那么你的游戏就被限制在iOS系统上了。你可能永远也不会知道自己的游戏是否会在Android平台上变成热门。
- 缺点2:Sprite Kit刚刚起步,所以现阶段它可能没有像其他框架那么多的实用特性,比如Cocos2D 的某些细节功能。在我看来最大的缺失就是不能直接编写OpenGL代码。
Sprite Kit vs Cocos2D-iPhone vs Cocos2D-X vs Unity
现在很多人会有疑问:“那么我到底该选择哪个2D游戏引擎呢?”
你需要根据自己的目的做出选择。这是我的观点:
- 如果你是一个新手或是专注于iOS平台的话就选择Sprite Kit吧。它是iOS内置框架,简单易学而且完全能够胜任你的工作。
- 如果你需要编写自己的OpenGL代码,请继续使用Cocos2D或者尝试其他的引擎,Sprite Kit当前并不支持。
- 如果你想要制作跨平台的游戏,请选择Cocos2D-X或者Unity。Cocos2D-X好在它几乎面面俱到,为2D游戏而构建,你几乎可以用它做任何你想做的事情。Unity好在它可以带给你更大的灵活性(如果你想的话你可以为你的游戏添加一些3D元素),尽管你在用它制作2D游戏时不得不经历一些小麻烦。
在你看完以上的所有内容后,如果你认为Sprite Kit可能正是你要寻找的东西,请继续你的阅读,我们将正式开始Sprite Kit的教程。
Hello, Sprite Kit!
让我们从创建一个简单的Hello World 项目开始,它是用Xcode5内置的Sprite Kit模版创建的。
打开Xcode,选择FileNewProject,接下来选择iOSApplicationSprite Kit Game 模版,然后单击Next:
键入“SpriteKitSimpleGame”做为Product Name,设备选择iPhone,然后单击Next:
把项目保存在你硬盘上的某个位置,然后单击 Create。随后单击运行这个项目。你应该能看到下面的界面:
就像Cocos2D一样,Sprite Kit被组织在scene(场景)之上。scene是一种类似于“层级”或者“屏幕”的概念。举个例子,你可以同时创建两个scene,一个位于游戏的主显示区域,一个可以用作游戏地图展示放在其他区域,两者是并列的关系。
如果你,你会发现Sprite Kit的模版已经默认为你新建了一个scene——MyScene。打开MyScene.m 文件你会看到它包含了一些代码,这些代码实现了两个功能,把一个label放到屏幕上以及在屏幕上随意点按时添加旋转的飞船。
在这篇教程里,你将主要与MyScene打交道。但是在开始之前,你需要做一些小的改动,使得我们的游戏在横评下运行(替代默认的竖屏)。
切换成竖屏方向运行
首先,打开Xcode中target的设定:在项目导航栏中单击SpriteKitSimpleGame项目,选中对应的target。然后在Deployment Info区域内取消Orientation中Portrait(竖屏)的勾选,这样就只有Landscape Left
编译运行项目,你会看到刚刚做的改动已经顺利完成并且生效了:
然而,事实并不如此。让我们试着添加忍者到游戏中来看看为什么这样说,到底还有什么问题呢?
首先,下载
下一步,打开MyScene.m并且用下面的代码替换掉它原有的内容:
#import "MyScene.h" // 1 @interface MyScene () @property (nonatomic) SKSpriteNode * player; @end @implementation MyScene -(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { |
让我们一步一步解释下上面的代码。
- 这里你创建了一个当前类的private(私有访问权限)声明,所以可以为player声明一个私有的变量(即忍者),这就是你即将要添加到scene上0的sprite对象。
- 在控制台输出当前scene的大小,这样做的原因你稍后会看到。
- 设置当前scene的背景颜色,在Sprite Kit中只需要设置当前scene的backgoundColor属性即可。这里设置成白色的。
- 添加一个sprite到scene上面也很简单。你只需要调用spriteNodeWithImageNamed
方法,把对应图片素材的名字作为参数传入即可。然后设置这个sprite的位置,调用addChild方法把它添加到当前scene上。把忍者sprite的位置设置成(100,100),这应该会在屏幕左下角的右上方一点。
编译运行,然后。。。
不对啊,屏幕白茫茫一片,没有忍者。你可能认为就是这样设计的,但这其实是一个有待解决的问题。如果你观察下刚刚在控制台输出的内容,你会看到下面的输出:
SpriteKitSimpleGame[3139:907] Size: {320, 568} |
因此我们的scene 认为它的宽是320而高是568,但这恰好反了。
为了看看到底发生了什么,我们找到ViewController.m 的viewDidLoad方法:
- (void)viewDidLoad { [super viewDidLoad]; // Configure the view. SKView * skView = (SKView *)self.view; skView.showsFPS = YES; skView.showsNodeCount = YES; |
这里从skView的bounds属性获取了size,创建了相应大小的scene。然而,当viewDidLoad方法被调用时,skView还没有被加到view的层级结构上,因而它不能相应方向以及布局的改变。所以skView的bounds属性此时还不是它横屏后的正确值,而是默认竖屏所对应的值,看来这个时候不是初始化scene的好时机。
Note:
解决办法是把初始化代码的运行时机后移。请用下面这个方法替换viewDidLoad:
- (void)viewWillLayoutSubviews { [super viewWillLayoutSubviews]; // Configure the view. SKView * skView = (SKView *)self.view; if (!skView.scene) { skView.showsFPS = YES; skView.showsNodeCount = YES; |
再次编译运行,当当当当,女士们、先生们,忍者终于出现了!
现在游戏的坐标系统已经一切正常,你会把这个忍者放在他应该放的位置,也就是在屏幕左侧面朝中央。为了做这些,切换回MyScene.m并且用下面的代码替换掉已有的那一行设置了忍者位置的代码:
self.player.position = CGPointMake(self.player.size.width/2, self.frame.size.height/2); |
移动的怪物
下一步将要把一些怪物添加到scene上,与现有的忍者形成战斗场景。为了使游戏更有意思,怪兽应该是移动的,否则游戏就毫无挑战性可言了!那么让我们在屏幕的右侧一点创建怪兽们,然后为它们设置action使它们能够向左移动。
在MyScene.m中添加如下方法:
- (void)addMonster { // 创建怪物Sprite SKSpriteNode * monster = [SKSpriteNode spriteNodeWithImageNamed |
我会慢一点把代码讲解清楚,让其尽可能容易理解。第一部分正如之前提到过的:我们需要做一些简单的计算来创建怪物对象。为它们设置合适的位置并且用和忍者sprite(player)一样的方式把它们添加到scene上。在相应的位置出现。
接下来轮到添加actions了。像Cocos2D一样,Sprite Kit提供了一些超级实用的内置actions,比如移动、旋转、淡出、动画等等。这里要在怪物身上添加3种aciton:
- moveTo:duration:
这个action用来让怪物对象从屏幕左侧直接移动到右侧。值得注意的是你可以自己定义移动持续的时间。在这里怪物的移动速度会随机分布在2到4秒之间。 - removeFromParent:
Sprite Kit有一个方便的action能让一个node从它的父母节点上移除。当怪物不再可见时,可以用这个action来把它从scene上移除。移除操作很重要,因为如果不这样做你会面对无穷无尽的怪物而最终它们会耗尽iOS设备的所有资源。 - sequence:
sequence(系列)action允许你把很多action连到一起按顺序运行,同一时间仅仅会执行一个action。用这种方法,你可以先运行 moveTo:
这个action,让怪物先移动,当移动结束时继续运行 removeFromParent:
这个action把怪物从scene上移除。
别忘了还有件事没做呢,你需要调用addMonster方法来创建怪物!为了让游戏再有趣一点,我们让怪物们持续不断地涌现出来。
Sprite Kit不能像Cocos2D一样设置一个每几秒运行一次的回调方法。它也不能传递一个增量时间参数给update方法。然而我们可以用一小段代码来模仿类似的定时刷新方法。首先把这些属性添加到MyScene.m的私有声明里:
@property (nonatomic) NSTimeInterval lastSpawnTimeInterval; @property (nonatomic) NSTimeInterval lastUpdateTimeInterval; |
我们会使用lastSpawnTimeInterval这个属性来记录上一次生成怪物的时间,
下一步,你会编写一个每帧都会调用的方法。这个方法的参数是上次更新后的时间增量。由于它不会被默认调用,你需要在下一步编写另一个方法来调用它。
- (void)updateWithTimeSinceLastU |
在这里你只是简单地把上次更新后的时间增量加给lastSpawnTimeInterval。一旦它的值大于一秒,你就要生成一个怪物然后重置时间。
接下来,添加如下方法来调用上面的updateWithTimeSinceLastU
- (void)update:(NSTimeInterval)currentTime { // 获取时间增量 // 如果我们运行的每秒帧数低于60,我们依然希望一切和每秒60帧移动的位移相同 CFTimeInterval timeSinceLast = currentTime - self.lastUpdateTimeInterval; self.lastUpdateTimeInterval = currentTime; if (timeSinceLast > 1) { // 如果上次更新后得时间增量大于1秒 timeSinceLast = 1.0 / 60.0; self.lastUpdateTimeInterval = currentTime; } |
update: Sprite Kit会在每帧自动调用
这个方法。
这里的代码实际上源自苹果的Adventure范例。它传入当前的时间,我们可以据此来计算出上次更新后的时间增量。值得注意的是这里做了一些必要的检查,如果出现意外致使更新的时间间隔变得超过1秒,这里会把间隔重置为1/60秒来避免奇怪的情况发生。
就是这样,编译运行之,现在你应该看到怪物们在屏幕上欢快地移动着:
发射子弹
到这里,你可以已经迫不及待的为忍者添加一些动作了,那么我们就添加攻击吧。攻击的实现方式有很多种,但在这个游戏里攻击会在玩家点击屏幕时触发,忍者会朝着点按的方向发射一个子弹。
我打算使用moveTo:action动作来实现子弹的前期运行动画,为了实现它需要一些数学运算。这是因为moveTo:需要传入子弹运行轨迹的终点,由于用户点按触发的位置仅仅代表了子弹射出的方向,显然我们不能直接将其当作运行终点。实际上就算子弹超过了触摸点你也应该让子弹保持移动直到子弹超出屏幕为止。
这是一张图片,它标注了这个问题:
就像你看到的,从子弹发射原点到用户触摸点在x轴和y轴上的偏移量会形成一个小三角形。你只要以相同的比例去实现一个顶点在屏幕边缘的大三角形即可。
为了进行这部分的运算,有一些关于向量的基本数学计算方法很有帮助(比如向量间的加减法)。然而,Sprite Kit默认并没有提供,所以你需要自己来实现了。
幸运的是这很容易实现。把下面的方法添加到文件顶部:
static inline CGPoint rwAdd(CGPoint a, CGPoint b) { return CGPointMake(a.x + b.x, a.y + b.y); } static inline CGPoint rwSub(CGPoint a, CGPoint b) { return CGPointMake(a.x - b.x, a.y - b.y); } static inline CGPoint rwMult(CGPoint a, float b) { return CGPointMake(a.x * b, a.y * b); } static inline float rwLength(CGPoint a) { return sqrtf(a.x * a.x + a.y * a.y); } // 让向量的长度(模)等于1 static inline CGPoint rwNormalize(CGPoint a) { float length = rwLength(a); return CGPointMake(a.x / length, a.y / length); } |
这些是向量运算方法的标准实现。如果你对此感到疑惑或者没有学习过向量的数学知识,可以到这里恶补一下
下一步,添加一个新方法:
-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { |
到这里已经做了很多事情,我们来一步一步回顾一下。
- Sprite Kit包括了UITouch类的一个category扩展,有两个方法locationInNode:和previousLocationInNode,这非常酷哦。他们可以让你获取到一次触摸操作相对于某个SKNode对象的坐标体系的坐标。
- 然后你创建了一个子弹并且把它放在忍者发射它的地方。注意你还没有把它添加到scene上,原因是你需要做一些合理性检查,我们的游戏可不允许玩家向后发射子弹。
- 把触摸的坐标和子弹当前的位置做减法来获得相应的向量。
- 如果它在x轴的偏移量小于零就代表玩家在尝试向后发射子弹。这是游戏里不允许的(真正的忍者绝不回头!),不做任何操作直接返回。
- 如果没有向后发射,那么就把子弹添加到scene上吧。
- 调用rwNormalize方法把偏移量转换成一个单位的向量(即长度为1)。这会使得在同一个方向上生成一个固定长度的向量更容易,因为1乘以它本身的长度还是等于它本身的长度。
- 把你想要发射的方向上的单位向量乘以1000,然后赋值给shootAmount.为啥是1000?因为这绝对足够到达屏幕边缘了:)
- 为了知道子弹从哪里飞出屏幕,需要把上一步计算好的shootAmount于当前的子弹位置做加法。
- 最后一步,像之前一样创建
moveTo:
和 removeFromParent 这两个action。
碰撞监测和物理特性一览
现在游戏里有了满天飞的手裏剑,但是你的忍者真正要做的是把怪物打下来。所以让我们添加一些代码来监测子弹是否打到了目标。
Sprite Kit一个好处是它已经内置了物理引擎。物理引擎不仅仅非常有助于模拟现实中的移动,同时也对碰撞监测提供了很好的支持。
让我们把Sprite Kit的物理引擎引入到游戏中来监测怪物和子弹的碰撞。大体上讲,下面是你准备要做的:
- 创建物理体系(physics world)。一个物理体系是用来进行物理计算的模拟空间,它是被默认创建在scene上的,你可以配置一些它的属性,比如重力。
- 为每个sprite创建物理上的外形。在Sprite Kit中,你可以为每个sprite关联一个物理形状来实现碰撞监测功能,并且可以直接设置相关的属性值。这个“形状”就叫做“物理外形”(physics body)。注意物理外形可以不必与sprite自身的形状(即显示图像)一致。相对于sprite自身形状来说,通常物理外形更简单,只需要差不多就可以,并不要精确到每个像素点,而这已经足够适用大多数游戏了。
- 为碰撞的两种sprite(即子弹和怪物)分别设置对应的种类(category)。这个种类是你需要设置的物理外形的一个属性,它是一个“位掩码”(bitmask)用来区分不同的物理对象组。在这个游戏中,你将会有两个种类:一个是子弹的,另一个是怪物的。当这两种sprite的物理外形发生碰撞时,你可以根据category很简单的区分出他们是子弹还是怪物,然后针对不同的sprite来做不同的处理。
- 设置一个关联的代理。还记得刚刚提到的物理体系么?你可以为它设置一个与之相关联的代理,当两个物体发生碰撞时来接收通知。这里你将要添加一些有关于对象种类判断的代码,用来判断到底是子弹还是怪物,然后你会为它们增加碰撞的声音等效果。
现在你理解了战斗(指子弹打怪物的过程)的计划,是时候付诸行动了!
碰撞监测和物理特性的实现
让我们添加两个常量开始。将它们添加到MyScene.m中:
static const uint32_t projectileCategory = 0x1 << 0; static const uint32_t monsterCategory = 0x1 << 1; |
这里设置了两个种类,等下就会用到。一个是子弹的,一个是怪物的。
注意:你可能对这种语法感到奇怪。你只要明白在Sprite Kit中category是一个32位的整型然后被用作掩码就好了。这是种用32位整型表示一个category的简单方式(所以你最多能创建32个category)。这里你用首位来表示子弹,用下一位来表示怪物。
下一步,在initWithSize方法中,把忍者加到scene的代码后面再加入如下两行代码:
self.physicsWorld.gravity = CGVectorMake(0,0); self.physicsWorld.contactDelegate = self; |
这里设置了一个没有重力的物理体系,为了收到两个物体碰撞的消息需要把当前的scene设为它的代理。
在addMonster方法中创建完怪物后添加如下代码:
monster.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:monster.size]; // 1 monster.physicsBody.dynamic = YES; // 2 monster.physicsBody.categoryBitMask = monsterCategory; // 3 monster.physicsBody.contactTestBitMask = projectileCategory; // 4 monster.physicsBody.collisionBitMask = 0; // 5 |
让我们逐行看看上面的代码到底做了什么。
- 为怪物sprite 创建物理外形。在这里,这个外形被定义成和怪物sprite大小一致的矩形,与怪物自身大致相匹配。
- 将怪物物理外形的dynamic(动态)属性置为YES。这表示怪物的移动不会被物理引擎所控制。你可以在这里不受影响而继续使用之前的代码(指之前怪物的移动action)。
- 把怪物物理外形的种类掩码设为刚刚定义的
monsterCategory
。 -
当发生碰撞时,当前怪物对象会通知它contactTestBitMask
这个属性所代表的category。这里应该把子弹的种类掩码projectileCategory赋给它。 -
collisionBitMask
这个属性表示哪些种类的对象与当前怪物对象相碰撞时物理引擎要让其有所反应(比如回弹效果)。你并不想让怪物和子弹彼此之间发生回弹,设置这个属性为0吧。当然这在其他游戏里是可能的。
下一步添加一些相似的代码到touchesEnded:withEvent:方法里,就在设置子弹位置的代码之后:
projectile.physicsBody = [SKPhysicsBody bodyWithCircleOfRadius:projectile.size.width/2]; projectile.physicsBody.dynamic = YES; projectile.physicsBody.categoryBitMask = projectileCategory; projectile.physicsBody.contactTestBitMask = monsterCategory; projectile.physicsBody.collisionBitMask = 0; projectile.physicsBody.usesPreciseCollisionDete |
试试看你是否能理解这里的每行代码,如果不能,请参照之前怪物代码的解释。
再试试你是否能发现两者之间细微的区别并回答下面的问题。
Solution Inside: 它们有何区别? | Show |
---|---|
下一步,添加一个在子弹和怪物发生碰撞后会被调用的方法。注意这个方法不会被自动调用,你将要在稍后调用它。
- (void)projectile:(SKSpriteNode *)projectile didCollideWithMonster:(SKSpriteNode *)monster { NSLog(@"Hit"); [projectile removeFromParent]; [monster removeFromParent]; } |
这里做的都是为了在子弹和怪物发生碰撞时把它们从当前的scene上移除。是不是非常简单?
到了实现接触后代理方法的时候了,将下面的代码添加到文件里:
- (void)didBeginContact:(SKPhysicsContact *)contact { // 1 SKPhysicsBody *firstBody, *secondBody; |
由于你将当前的scene设为了物理体系发生碰撞后的代理( contactDelegate),这个方法会在两个物理外形发生碰撞时被调用(调用的条件还有它们的
contactTestBitMask
s属性也要被正确设置)。
这个方法分成两部分:
- 这个方法传给你发生碰撞的两个物理外形(子弹和怪物),但是不能保证它们会按特定的顺序传给你。所以有一部分代码是用来把它们按各自的种类掩码进行排序的。这样你稍后才能针对对象种类做操作。这部分的代码来源于苹果官方Adventure例子。
- 方法的后一部分是用来检查这两个外形是否一个是子弹另一个是怪物,如果是就调用刚刚写的方法(指把它们从scene上移除的方法)。
最后一步,在MyScene的私有声明上让其实现SKPhysicsContactDelegate
@interface MyScene () |
编译运行,然后子弹在碰到目标(怪物)时它们就会一起消失了!
原文作者:Ray Wenderlich on September 30, 2013