IOS-游戏编程秘籍-全-
IOS 游戏编程秘籍(全)
原文:
zh.annas-archive.org/md5/c407d66ebd203082bff87651463b665b译者:飞龙
前言
由于 iOS 设备在市场上备受青睐,游戏也开始统治这一领域。iOS 设备为我们提供了一个真正强大的游戏平台,使得所有游戏开发者都能为这一领域开发出令人惊叹的游戏。
本书为您提供了解决在 iOS 设备上游戏编程中遇到的所有常见问题的简单、直接的方法。本书详细介绍了广泛的主题,并提供了实用的解释。本书从简单的游戏动画和 SpriteKit 的介绍开始,然后扩展到与物理引擎、3D 游戏编程、游戏人工智能以及最终学习多人游戏编程的视野。
本书包含超过 45 个有趣的游戏食谱,您需要学习和在您的下一个游戏中实现这些食谱。本书为初学者、中级和高级玩家提供了一个一站式解决方案。您对游戏开发的每个步骤都有完全的控制权。一旦食谱完成,我们还在每个章节的末尾提供了解决方案套件。
本书涵盖内容
第一章,iOS 游戏开发,通过了解默认游戏模板和开发迷你游戏,让您开始学习游戏开发技术。
第二章,SpriteKit,通过游戏项目的解剖结构解释了 SpriteKit 的基本结构。然后在章节的后面部分,我们将更深入地探讨场景、精灵和节点。到本章结束时,我们将能够构建一个具有无限滚动的迷你游戏。
第三章,动画和纹理,帮助我们探索 iOS 游戏中可以实现的动画深度。我们将学习如何创建纹理图集并在其上创建动画。在本章中,我们还将探索角色动画和视差背景。
第四章,粒子系统与游戏性能,使我们学习和理解游戏中粒子效果和发射系统的结构。此外,我们还将探讨本章中游戏性能的评估。
第五章,将音乐添加到 iOS 游戏及 iCloud 简介,教导我们添加游戏音乐的各种方法。我们将探索将音乐添加到游戏中的各种事件,如背景音乐和特定事件上的各种其他声音效果。在本章结束时,我们将了解如何在游戏中集成 iCloud。
第六章, 物理模拟,让我们开始使用游戏中的物理引擎,以增加游戏的现实感。在本章中,我们将通过创建一个小游戏来学习一些令人兴奋的现实世界模拟。
第七章, 为游戏添加现实感,通过解释物理关节、接触检测和碰撞的细节来扩展你在物理模拟方面的知识。在本章中,我们将探索物理的深度及其对整体游戏开发过程的影响。
第八章, 游戏数学和物理简介,在章节的前半部分复习你的基本数学技能,然后继续解释它们在游戏中的应用。本章解释了在游戏中使用的数学和物理的各个方面和属性。
第九章, 自主移动代理,揭示了游戏中最有趣的部分,即人工智能。在本章中,我们将实际在我们的游戏中实现各种人工智能行为。我们将探索、寻找、逃跑、徘徊、到达、追逐、躲避行为。除此之外,我们还将学习群体行为,如对齐、凝聚力和分离。
第十章, 使用 OpenGL 进行 3D 游戏编程,帮助你探索 3D 游戏编程。在本章中,我们将学习 OpenGL 的基础知识,然后在章节的后续部分我们将学习创建一个可工作的 3D 游戏模型。
第十一章, 开始多人游戏,从多人游戏的基础知识开始,包括多人游戏的结构。你将学习在两个 iOS 设备之间建立连接,然后还将学习从一个设备向另一个设备发送和接收数据。
第十二章, 实现多人游戏,创建了一个多人游戏,两个玩家可以同时进行游戏。在本章中,我们将使用在介绍章节中学到的所有方法。
你需要这本书什么
你需要以下设置才能开始使用 SpriteKit 进行 iOS 游戏编程:
-
运行 Snow Leopard(OS X 10.6.8 或更高版本)的基于 Intel 的 Macintosh
-
Xcode
-
你必须注册为 iPhone 开发者才能在你的设备上测试示例项目
-
配备 7.0 或更高版本的 iOS 设备
这本书面向谁
如果你愿意学习游戏编程来开发自己的游戏,那么这本书适合你。在这本书中,你将了解游戏开发的各个垂直领域。这本书将教你一步步编写自己的游戏。
本书使用 Objective-C 作为其主要语言,因此对 Objective-C 的基本知识是必须的。本书假设你理解面向对象编程和编程的基础。
本书旨在让你立即开始使用游戏编程,因此你应该熟悉 iPhone/iPad 本身。iPhone 是一个很好的编程平台。它看起来很漂亮,手感很好。本书教你关于各种易于使用的入门方法来开始游戏编程。
章节
在这本书中,你会发现一些频繁出现的标题(准备就绪、如何操作、工作原理、还有更多、参见)。
为了清楚地说明如何完成食谱,我们使用以下这些部分:
准备就绪
本节将告诉你对食谱的期望是什么,并描述如何设置任何软件或任何为食谱所需的初步设置。
如何操作…
本节包含遵循食谱所需的步骤。
工作原理…
本节通常包含对上一节发生事件的详细解释。
还有更多…
本节包含有关食谱的附加信息,以便让读者对食谱有更多的了解。
参见
本节提供了对其他有用信息的链接,这些信息对食谱很有帮助。
惯例
在这本书中,你会发现许多不同风格的文本,用于区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词如下所示:“在 AppDelegate.h 文件中,找到 application:didFinishLaunchingWithOptions: 方法,以及我们注册推送通知的地方。”
以下是这样显示代码块:
SKAction *sequence = [SKAction sequence:@[[SKAction rotateByAngle:degreeToRadian(-3.0f) duration:0.2],[SKAction rotateByAngle:0.0 duration:0.1],[SKAction rotateByAngle:degreeToRadian(3.0f) duration:0.2]]];
[touchedNode runAction:[SKAction repeatActionForever:sequence]];
新术语和重要词汇将以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:“选择驱动器上的位置以保存项目,然后点击创建。”
注意
警告或重要注意事项将显示在这个框框中。
提示
小贴士和技巧如下所示。
读者反馈
我们欢迎读者的反馈。让我们知道你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大利益的标题。
要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。
如果你在某个主题上有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com您购买的所有 Packt 出版物的账户中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从:www.packtpub.com/sites/default/files/downloads/8255OS_ColorImages.pdf下载此文件。
错误清单
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误部分下的现有错误清单中。
要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分下。
盗版
在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过链接将疑似盗版材料发送给我们,并联系<copyright@packtpub.com>。
我们感谢您的帮助,以保护我们的作者和我们为您提供有价值内容的能力。
询问
如果您在这本书的任何方面遇到问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。
第一章:iOS 游戏开发
自从 iOS 设备推出以来,游戏开发一直吸引着越来越多的开发者。市场上提供了各种游戏引擎,允许开发者为 iOS 设备开始开发他们的游戏。
在本章中,我们将重点关注以下主题:
-
开始使用 SpriteKit 游戏框架
-
开发使用 SpriteKit 的迷你游戏
简介
苹果推出了其首个游戏引擎,允许您创建 iOS 游戏而无需依赖第三方游戏库。这是一个非常强大的框架,类似于其他 iOS 框架,在用法上与其他框架相似。它也非常容易采用和学习。它还支持许多功能,如物理模拟、纹理图集、重力、恢复系数、动画、粒子发射器、游戏中心支持等。此外,它还提供了非常丰富的 SpriteKit 开发者文档,位于苹果开发中心。这些文档非常实用且编写得很好。您可能需要首先了解游戏开发的解剖结构,才能开始使用 SpriteKit 进行游戏开发。这里有两个基本且最重要的术语;一个是场景,另一个是精灵。场景可以被认为是游戏中的层。因此,在任何游戏中,都有各种层,如得分层、HUD 层和游戏玩法层,它们可以作为不同的场景。然而,场景中的任何对象,如玩家或敌人,都可以被认为是精灵。
开始使用 SpriteKit 游戏框架
随着 iOS 7.0 的发布,苹果推出了其自己的原生 2D 游戏框架,称为 SpriteKit。SpriteKit 是一个优秀的 2D 游戏引擎,它支持精灵、动画、滤镜、遮罩,最重要的是它提供了物理引擎,为游戏提供真实世界的模拟。
苹果提供了一个名为“冒险游戏”的示例游戏,用于开始使用 SpriteKit。此示例项目的下载网址为bit.ly/Rqaeda。
此示例项目展示了该框架的能力。然而,该项目理解起来比较复杂,对于学习来说,您可能只想做一些简单的东西。为了更深入地理解基于 SpriteKit 的游戏,我们将在这本书中构建一系列迷你游戏。为了理解 SpriteKit 游戏编程的基础,我们将在本章中构建一个迷你蚂蚁杀戮游戏。
准备工作
要开始使用 SpriteKit 进行 iOS 游戏开发,您有以下先决条件:
-
您需要 Xcode 5.x 版本
-
目标设备系列应为 iOS 7.0+
-
您应该运行 OS X 10.8.X 或更高版本
如果所有上述要求都得到满足,那么你就可以开始 iOS 游戏开发了。所以让我们从使用 iOS 原生游戏框架进行游戏开发开始。我们将在本章中构建一个小游戏,并在每一章中继续添加更多功能和改进。
如何操作...
让我们开始构建 AntKilling 游戏。按照以下步骤创建你的新 SpriteKit 项目:
-
启动 Xcode。导航到 文件 | 新建 | 项目...。
![如何操作...]()
-
然后在提示窗口中,导航到 iOS | 应用 | SpriteKit 游戏 并点击 下一步。
![如何操作...]()
-
在提示窗口中填写所有项目详细信息,并以
AntKilling作为项目名称,提供你的 组织名称,设备为 iPhone,以及 类前缀 为AK。点击 下一步。![如何操作...]()
-
在驱动器上选择一个位置以保存项目,并点击 创建。
-
然后构建示例项目以检查示例项目的输出。一旦使用播放按钮构建并运行项目,你应该能在你的设备上看到以下内容:
![如何操作...]()
它是如何工作的...
以下是对入门项目的观察:
-
如你所见,SpriteKit 的示例项目播放了一个带有背景色的标签。
-
SpriteKit 的工作原理是场景,可以理解为游戏的层或屏幕。可以同时运行多个场景;例如,在游戏中可以同时运行游戏玩法场景、HUD 场景和得分场景。
现在我们可以查看入门项目的更多详细安排。以下是对观察的总结:
-
在主目录中,你已经有了一个默认创建的场景,名为 AKMyScene。
-
现在点击
AKMyScene.m以探索代码,在屏幕上添加标签。你应该能看到以下截图类似的内容:![如何工作...]()
-
现在我们将在下一节中更新此文件,用我们的代码创建 AntKilling 游戏。
-
我们必须满足一些先决条件才能开始编写代码,例如将朝向锁定为横向,因为我们想要一个横向的游戏。
-
要更改游戏的朝向,导航到 AntKilling 项目设置 | 目标 | 通用。你应该能看到以下截图类似的内容:
![如何工作...]()
-
现在在 通用 选项卡中,取消选中 纵向 以从设备朝向中,使最终设置应类似于以下截图:
![如何工作...]()
-
现在构建并运行项目。你应该能够看到应用以横向模式运行。
![如何工作...]()
-
屏幕的右下角显示了节点的数量和帧率。
使用 SpriteKit 开发小游戏
现在你已经对 SpriteKit 有了足够的了解。为了更深入地探索这个主题,让我们创建一个迷你游戏,这将帮助你更详细地理解这些概念。我们将创建一个杀蚂蚁游戏。在这个游戏中,我们将在屏幕上放置一只蚂蚁;当你点击它时,蚂蚁会进行动画。
准备工作
我们将使用上一节中创建的项目。为了创建一个迷你游戏,我们需要更新我们从入门项目获取的源文件。现在,是时候更新 AKMyScene 以包含我们的蚂蚁精灵了。
在进入更新代码的步骤之前,请下载本章的所有资源,并检查assets文件夹,其中包含本项目中使用的所有图像。
如何操作...
按照以下步骤依次执行以创建一个迷你游戏:
-
打开
Resources文件夹并将它们添加到你的 Xcode 项目中。 -
在将资源添加到 Xcode 时,请确保选定的目标是AntKilling,并且已勾选Copy items into destination group's folder (if needed)。
![如何操作...]()
-
现在,从
AKMyScene.m中删除所有现有代码,使其看起来类似于以下截图:![如何操作...]()
-
现在,首先,我们创建了一个私有接口来声明私有变量:
@interface AKMyScene () @property (nonatomic) SKSpriteNode *ant; @end -
然后,在
init方法中,我们打印了一个日志来打印屏幕的大小:NSLog(@"Size: %@", NSStringFromCGSize(size)); -
现在,我们将使用以下代码行将屏幕背景色更改为白色:
self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0]; -
然后,我们将使用以下代码行中的
backgroundColor属性将屏幕背景色更改为白色。self.backgroundColor = [SKColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0]; -
在以下代码行中,我们使用
spriteNodeWithImageNamed方法创建了一个精灵对象,并将图像名称传递给它。然后我们将它定位到屏幕的100, 100位置,这是屏幕的左下角。然后最终将其添加为子对象。self.ant = [SKSpriteNode spriteNodeWithImageNamed:@"ant.jpg"]; self.ant.position = CGPointMake(100, 100); [self addChild:self.ant];
小贴士
下载示例代码
你可以从你购买的所有 Packt Publishing 书籍的账户中下载示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
在游戏中,我们需要保留两套图像,一套用于正常显示,另一套用于视网膜显示。在前面代码块中,我们提供了精灵的名称为ant.jpg,这将自动引用视网膜设备的ant@2x.jpg。
现在构建并运行你的应用程序;你应该能看到以下截图类似的内容:

现在你可以看到,屏幕颜色已经变为白色,但屏幕上没有蚂蚁。这意味着代码中出了问题。所以,现在让我们检查我们的日志,它应该打印以下内容:
2014-07-22 19:13:27.019 AntKilling[1437:60b] Size: {320, 568}
因此,场景大小是错误的。场景应该打印宽度为 568,高度为 320,但它打印的是相反的。为了调试这个问题,导航到你的AKViewController.m viewDidLoad方法。你可以在AntKilling/AntKilling/AKViewController.m中找到这个函数的完整代码。
因此,从这个方法中,我们可以看到我们的场景正在从视图的边界中获取大小,并且这个viewDidLoad方法在视图被添加到视图层次结构之前就被调用了。所以它没有响应布局变化。因此,由于不一致的视图边界,我们的场景以错误的边界开始。
为了解决这个问题,我们必须将场景启动代码移动到viewWillLayoutSubviews方法中。在从viewDidLoad方法中移除代码并将其粘贴到viewWillLayoutSubviews之后,你可以在AntKilling/AntKilling/AKViewController.m中找到这个函数的完整代码。
现在,再次构建并运行应用程序;你应该能看到以下输出:

它是如何工作的...
所以,恭喜!你已经解决了问题。你的蚂蚁现在出现在屏幕上的指定位置。如果你仔细观察,你可以看到状态栏在游戏的顶部,这看起来并不好。为了从屏幕上移除状态栏,打开你的AntKilling-Info.plist文件,并添加一个值为NO的UIViewControllerBasedStatusBarAppearance属性。你的.plist文件应该看起来像以下截图:

再次构建并运行你的项目;你现在应该能看到没有状态栏的游戏:

现在看起来完美了;我们的蚂蚁正如预期的那样居住在屏幕上。所以现在我们的下一个目标是在我们点击它时让蚂蚁动起来。为了实现这个目标,我们需要在AKMyScene.m文件中添加以下代码,就在你的initWithSize方法下面:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint positionInScene = [touch locationInNode:self];
SKSpriteNode *touchedNode = (SKSpriteNode *)[self nodeAtPoint:positionInScene];
if (touchedNode == self.ant) {
SKAction *sequence = [SKAction sequence:@[[SKAction rotateByAngle:degreeToRadian(-3.0f) duration:0.2],
[SKAction rotateByAngle:0.0 duration:0.1],
[SKAction rotateByAngle:degreeToRadian(3.0f) duration:0.2]]];
[touchedNode runAction:[SKAction repeatActionForever:sequence]];
}
}
float degreeToRadian(float degree) {
return degree / 180.0f * M_PI;
}
你可以在AntKilling/AntKilling/AKMyScene.m中找到这个函数的完整代码。
那么,现在让我们逐行分析一下到目前为止我们做了什么。首先,我们添加了- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event方法来抓取场景上的所有触摸。
现在在函数的第一行允许你使用CGPoint positionInScene = [touch locationInNode:self];来抓取触摸。
在下一行,我们抓取了触摸并将其转换为CGPoint positionInScene = [touch locationInNode:self];位置。
在下一行,我们获取了被触摸的精灵:
SKSpriteNode *touchedNode = (SKSpriteNode *)[self nodeAtPoint:positionInScene];
现在,一旦你有了精灵对象,比较并检查选中的对象是否是蚂蚁虫。如果是蚂蚁虫,那么通过添加以下代码行来动画化对象:
SKAction *sequence = [SKAction sequence:@[[SKAction rotateByAngle:degreeToRadian(-3.0f) duration:0.2],[SKAction rotateByAngle:0.0 duration:0.1],[SKAction rotateByAngle:degreeToRadian(3.0f) duration:0.2]]];
[touchedNode runAction:[SKAction repeatActionForever:sequence]];
使用SKAction类,你可以执行各种动画序列,如rotation(旋转)、moveBy(移动)、moveTo(移动到)等等。此外,所有旋转方法都接受弧度作为角度。因此,为了实现旋转,我们必须在传递给任何rotate函数之前将度数转换为弧度。
现在,这段代码将会使选定的精灵开始动画。构建并运行项目,你将看到蚂蚁在点击时开始动画。
你很快就会注意到,当你轻敲蚂蚁时,它会开始动画,但没有办法停止这个动画。所以现在让我们添加一个方法,当你点击场景中的任何地方时,可以停止这个动画。导航到- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)事件方法,并将其更新为以下代码:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint positionInScene = [touch locationInNode:self];
SKSpriteNode *touchedNode = (SKSpriteNode *)[self nodeAtPoint:positionInScene];
if (touchedNode == self.ant) {
SKAction *sequence = [SKAction sequence:@[[SKAction rotateByAngle:degreeToRadian(-3.0f)duration:0.2],
[SKAction rotateByAngle:0.0 duration:0.1],
[SKAction rotateByAngle:degreeToRadian(3.0f) duration:0.2]]];
[touchedNode runAction:[SKAction repeatActionForever:sequence]];
} else {
[self.ant removeAllActions];
}
}
现在,如果你仔细观察,你会发现我们添加了一个if-else条件来检查触摸是否在蚂蚁上,这允许它进行动画;当触摸在屏幕外的任何地方时,停止所有动作。为了在精灵上停止所有动作,我们可以在精灵上使用removeAllActions方法。
第二章:SpriteKit
在本章中,我们将介绍以下食谱:
-
学习 SpriteKit 的基础知识 – 飞船教程
-
理解场景、节点和精灵
-
游戏项目的解剖学
-
在精灵上应用动作
-
添加无限滚动
-
移动角色
本章详细解释了 SpriteKit。我们将从对 SpriteKit 基础的讨论开始,然后我们将学习游戏项目的解剖学。继续前进,我们将学习场景、精灵和节点。这将为我们提供对 SpriteKit 基本结构模型的更深入理解。然后我们将通过向精灵添加一些动作来探索 SpriteKit 的深度。继续前进,我们将向本章中创建的游戏添加无限滚动。
简介
SpriteKit 是一个图形渲染和动画框架,具有用于动画任意纹理图像(称为 Sprites)的功能。它有一个渲染循环,渲染帧的内容。作为一个过程,每个帧的内容(即输入)被给出,处理,然后最终由渲染循环渲染。
基本上,你的游戏识别帧的内容以及在该帧中内容如何改变。
作为游戏行业的新手,SpriteKit 表现得非常好,因为它采用了 cocos2d 的基础知识,cocos2d 是一个广泛使用的 2D 游戏引擎。它编写得很好,文档齐全,并且与 iOS 深度集成。然而,即使你对游戏开发领域不熟悉,这本书也会为你提供一本入门级开发指南。每一章都包含一个食谱,以确保你学习到游戏开发的所有概念。
现在是两个最基本的概念:场景和精灵。iOS 游戏由场景组成,而场景则包含精灵。
要开始使用 SpriteKit,我们将创建一个小游戏,这将指导我们了解 SpriteKit 的所有概念。
学习 SpriteKit 的基础知识 – 飞船教程
在本节中,我们将学习和探索 SpriteKit 的基本概念。我们还将开发一个迷你游戏,这将有助于通过一些稳健的实现来理解这些概念。学习 SpriteKit 的最佳方式是看到它在实际中的应用。
准备工作
要构建 SpriteKit 游戏,首先你需要了解 SpriteKit 项目的结构。你可以从一个包含 SKScene 和 SKNode 的起始项目开始。这将为你提供构建基本游戏所需的设置。
如何做到这一点...
为了理解游戏编程的基本概念,让我们创建一个名为 FlyingSpaceship 的新项目,使用 SpriteKit 游戏模板。该项目将展示 SpriteKit 项目的结构。项目的最终目标是屏幕上可见一艘飞船,在接下来的主题中我们可以让它飞起来。
我们将遵循与 第一章 中相同的步骤,iOS 游戏开发,并最终将飞船添加到屏幕上:
-
启动 Xcode 并导航到 文件 | 新建 | 项目。然后从提示窗口导航到 iOS | 应用程序 | SpriteKit 游戏,点击 下一步。
![如何操作...]()
-
在提示窗口中填写所有项目详情,并将项目名称设置为
FlyingSpaceship,组织名称,设备选择 iPhone,类前缀设置为FS。点击 下一步,如图所示:![如何操作...]()
-
选择一个位置在驱动器上保存项目,并点击 创建。
-
因此,项目中将创建
FSViewController和FSMyScene文件,同时项目目录中也有Spaceship.png文件。项目目录应类似于以下截图:![如何操作...]()
-
前往 常规 选项卡,取消勾选 纵向 以便最终方向为横屏。
-
将类型转换代码
UIView到SKView以及将FSMyScene呈现到SKView从FSViewController的(void)viewDidLoad中删除。 -
实现
- (void)viewWillLayoutSubviews并将viewDidLoad中的代码复制到viewWillLayoutSubviews中。 -
最终,代码将看起来像这样:
![如何操作...]()
-
现在,让我们转到
FSMyScene.m,删除在init方法中添加的默认代码以及触摸检测方法。 -
在私有接口中为
SKSpriteNode创建一个名为 spaceship 的属性:@interface FSMyScene() @property (nonatomic, strong) SKSpriteNode* spaceShipSprite; @end -
在
FSMyScene文件的init方法中将此spaceShipSprite添加到其中:self.spaceShipSprite = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship"]; self.spaceShipSprite.position = CGPointMake(self.spaceShipSprite.size.width, size.height/2); [self addChild:self.spaceShipSprite];默认提供的
Spaceship.png是合适的,因此删除并添加 Starter kit 的Resources文件夹中提供的Spaceship.png。 -
现在如果您运行应用程序,飞船在黑色背景上看起来不好,因此将
FSMyScene文件的背景颜色设置为天空颜色,在它的init方法中。self.backgroundColor = [UIColor colorWithRed:135.0/255.0 green:206.0/255.0 blue:235.0/255.0 alpha:1.0];因此,我们最终达到了目标,将飞船放置到了天空之中。
最终的
FSMyScene类看起来像这样:![如何操作...]()
在前面的截图,您将在 .m 文件中观察到 update: 方法。此方法在屏幕上渲染每一帧时自动调用。如果游戏的帧率为 60,则此方法每秒将执行 60 次。任何实时计算都可以在此方法中执行,因此可以在此方法中处理如计算玩家实时位置等动作。
Starter kit 游戏 FlyingSpaceship 看起来是这样的:

它是如何工作的...
SpriteKit 的结构基本上是从 UIKit 框架继承和派生出来的。操作系统通过将 UIKit 视图控制器的视图类型转换为 SpriteKit 视图(称为 SKView)来提供从 UIKit 到 SpriteKit 的平滑过渡。在此之后,你就可以开始使用 SpriteKit 了。如图所示,创建一个场景,向其中添加一些节点(即作为玩家的精灵、背景等),你就构建了游戏环境。你还可以通过向节点应用一些动作(旋转、移动、缩放等)来使环境更加生动。
因此,结合这些,这个场景具有不同类型的节点和一些应用的动作,这构成了你的 SpriteKit 的基本结构,以及你想要构建的游戏的基本结构。

还有更多...
SpriteKit 可用于 iOS 和 OS X 平台上的游戏开发。它使用宿主设备的可用图形硬件以高帧率渲染复合 2D 图像。SpriteKit 还有其他一些功能,支持以下类型的内容,包括:
-
可以是任何形式的精灵,如无纹理或有纹理的矩形
-
文本
-
基于任意 CGPath 的形状
-
视频
如果你想了解更多信息,请访问苹果的开发者链接 developer.apple.com/library/ios/documentation/GraphicsAnimation/Conceptual/SpriteKit_PG/Introduction/Introduction.html。
你也可以通过尝试更改太空船的位置并为其背景应用各种颜色来尝试修改你刚刚创建的示例。
理解场景、节点和精灵
整个游戏被组织成场景,这些场景由SKScene对象表示的内容构成。
场景是一个包含所有内容的实体,即将要渲染的节点和精灵。它还实现了内容的设置或每一帧的更新结构。
SKScene类是SKNode的子类,它是 SpriteKit 的基本构建块。SpriteKit 中的每个实体都是继承或派生自节点(SKNode)。因此,SKScene是其他节点的根节点,用于在场景中填充内容。
与 UIKit 类似,每个节点的位置是根据其父坐标系统指定的。节点还具有内容项或实体应具备的基本属性,如移动、旋转、缩放、淡出等。最重要的是,所有节点对象都是响应对象,响应UIResponder的委托。这用于检测场景中的输入触摸以移动对象以及根据游戏玩法的一些其他内容。
现在,精灵由 SKSpriteNode 对象表示。它们是带有图像的节点。我们可以指定内容或纹理图像,就像我们必须要制作一些玩家或敌人一样。SKSpriteNode 也继承自 SKNode。此外,其内容可以更改和动画化。精灵通过添加一些动作到场景中创建并添加,以使游戏场景更加生动。
准备工作
要理解 SpriteKit 的这些元素,我们需要创建一个空白项目,就像我们在本章入门项目中做的那样。正如入门套件中所示,这里有一个基本的 SKScene 和 SKNode。因此,我们现在将介绍这些术语及其示例代码片段。
如何做到这一点...
正如我们在入门套件中所做的那样,遵循相同的步骤创建一个 SKScene 并向其中添加一个 SKSpriteNode 方法:
-
从 Xcode 创建 SpriteKit 游戏模板。
-
将默认的 ViewController 和场景为您创建。
-
通过启用
showsFPS和showsNodeCount属性为YES,将 ViewController 视图类型转换为SKView。// Configure the view. SKView * skView = (SKView *)self.view; skView.showsFPS = YES; skView.showsNodeCount = YES; -
使用
SKScene的类方法创建一个场景,指定场景的大小,然后将其显示在之前类型转换的SKView上。// Create and configure the scene. SKScene * scene = [SKScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [skView presentScene:scene];所有这些都应该在
- (void)viewWillLayoutSubviews方法中完成。 -
现在我们必须向之前创建的场景中添加一些精灵。通过调用类方法创建一个
SKSpriteNode对象,并指定精灵的图像。现在指定它需要放置的位置,最后将其添加到场景中。SKSpriteNode * spriteNode = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship.png"]; spriteNode.position = CGPointMake(100,100); [self addChild:spriteNode];
它是如何工作的...
如在 学习 SpriteKit 基础 – 飞船教程 菜谱的 How it works... 部分的结构块图中所述,它与 UIKit 框架深度关联。为了构建游戏,我们应该有一个环境,即我们的场景,以及一些在环境中可见的实体,即精灵。因此,为了使其工作,或者说为了在屏幕上显示某些内容,我们需要创建一个环境(即场景),并在其上添加实体(即精灵),如下所示:
-
当我们将 UIView 转换为
SKView时,我们就进入了 SpriteKit 的领域:SKView * skView = (SKView *)self.view; -
为了调试目的,我们启用两个布尔参数以显示 FPS(每秒帧数)和 NodesCount(添加到场景中的节点数):
skView.showsFPS = YES; skView.showsNodeCount = YES; -
在创建场景时,我们需要指定场景的大小,这正好是内容大小和缩放模式,以便场景适合
SKView(即缩放透视),这里使用的是SKSceneScaleModeAspectFill模式,以便按照SKView的宽高比进行适配:SKScene * scene = [SKScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; -
要使场景内容在视图中可见,我们需要在
SKView上显示场景:// Present the scene. [skView presentScene:scene]; -
现在关于精灵的工作原理。通过类方法创建一个精灵对象,该对象实例化一个具有图像内容的节点:
SKSpriteNode * spriteNode = [SKSpriteNode spriteNodeWithImageNamed:@"Spaceship.png"]; -
以下代码行指定了精灵需要放置的确切位置:
spriteNode.position = CGPointMake(100,100); Lastly, to make the sprite visible, it is added to SKScene as a child: [self addChild:spriteNode];
游戏项目的结构
在本节中,我们将了解游戏项目的基础知识。这包括理解游戏项目的基本架构和工作流程。在这里,我们将了解场景和层及其在游戏中的重要性。
准备中
完整的游戏开发依赖于前面提到的三个核心组件:场景、节点和精灵。我们需要对这些组件有控制权,才能有效地开始游戏开发。
如何做...
在内部,生命周期按照场景执行——节点被添加,并在这些节点上应用动作。它还包括将一些物理体附加到节点上,支持裁剪,将动画和效果应用到内容的所有或部分,检测力与碰撞,在 OpenGL 中绘制,以及许多其他事情。
除了所有这些,SKScene 中还有一个覆盖的更新方法,该方法在每个游戏帧中都会被调用,并使用当前时间间隔作为参数。在那里,你可以添加你的实际游戏逻辑,指定在什么时间做什么,以及许多其他事情,因为它是通过每个渲染的帧调用的。
例如,我们可以跟踪当前时间和最后更新时间之间的时间差。
-
由于在更新方法中接收到了当前时间间隔,因此定义时间差和最后更新时间的属性。
@property (nonatomic, assign) NSTimeInterval lastUpdatedTime; @property (nonatomic, assign) NSTimeInterval diffTime; -
现在通过从当前时间减去最后更新时间来计算时间差,并将 lastUpdatedTime 更新为当前时间。
self.diffTime = currentTime - self.lastUpdatedTime; self.lastUpdatedTime = currentTime; -
最后,更新方法看起来是这样的:
- (void)update:(CFTimeInterval)currentTime { /* Called before each frame is rendered */ self.diffTime = currentTime - self.lastUpdatedTime; self.lastUpdatedTime = currentTime; }
现在这是我们要添加最大游戏逻辑的地方——所有添加、删除、动画化和更新节点、精灵和动作都将在这个方法内部进行。我们还可以利用 currentTime 通过简单的浮点变量来维护一些计时器(通过 diffTime 更新它们,并根据我们的游戏设计或逻辑在需要时触发时间事件)。
它是如何工作的...
我们在屏幕上看到的所有运行内容都只是由时间间隔驱动的帧,这些帧是通过在作为游戏主场景的 SKView 上添加子 SKScene 来实现的。
如以下图所示,存在一个帧循环,它描述了每个帧的游戏项目的执行周期:

以下是对前面图中一些方法的解释:
-
在
SKScene的更新方法中被调用,我们可以添加、删除、动画化和更新不同类型的节点和动作。 -
SKScene根据一些生命周期调用(如didEvaluateActions)评估当前帧正在运行的动作。 -
SKScene有自己的物理模拟,因此如果向其中添加了一些物体,物理模拟也会被评估,例如碰撞检测、应用力等。 -
所提到的所有方法都贡献于最终渲染的 SKView,它被显示为用户看到的帧。因此,这些帧的常规运行使游戏看起来像一个环境。
在精灵上应用动作
精灵只是没有生命的静态图像。因此,动作为精灵添加了生命,使你的游戏变得生动。动作通过移动精灵和以不同的方式动画它们来帮助构建游戏玩法。动作是一个使场景看起来生动的对象。
动作应用于节点和精灵,例如,我们想要移动一些作为精灵的对象,因此我们创建一个移动动作并在该精灵上运行它。SpriteKit 会自动将精灵的位置以动画的形式改变,直到动作完成。
所有动作都是使用名为SKAction的类实现的,不同类型的动作是通过SKAction类提供的类方法实例化的,这些方法提供了各种动画功能。
这里是 SpriteKit 中可用的最常见动作:
-
应用变换(平移、旋转和缩放)
-
改变可见性(淡入和淡出)
-
改变精灵的内容
-
改变精灵的颜色
-
移除精灵
-
调用一个块或选择器
-
重复和排序动作
准备工作
要在精灵上应用不同的动作并看到它们动画化,我们需要了解场景、精灵以及 SpriteKit 项目的整体生命周期。我们还需要了解一些应用于任何实体(如移动、旋转、缩放)的基本动作,还有许多其他特殊效果可以探索。
如何做到这一点...
有许多动作可以应用于节点和精灵,以下列出了一些:
要理解这一点,我们将以宇宙飞船作为精灵来应用不同的动作。
SpriteKit 框架提供了几个单独的动作。以下是一些解释:
-
移动动作:要移动一个精灵,调用以下所示类方法,指定精灵需要移动的位置和所需时间。然后,在精灵上调用
runAction方法,使用创建的移动动作。SKAction* moveAction = [SKAction moveTo:CGPointMake(100,100) duration:1.0]; [self.spaceShipSprite runAction:moveAction]; -
旋转动作:要旋转一个精灵,我们必须指定一个弧度角度,这将使精灵在指定的时间内旋转到或绕该角度旋转。因此,指定角度为度数,将其转换为弧度,然后将其输入到函数中,从而将该动作应用于精灵。
CGFloat angleInDegree = 90.0; CGFloat angleInRadian = angleInDegree * M_PI/180.0; SKAction* rotateAction = [SKAction rotateByAngle:angleInRadian duration:2.0]; [self.spaceShipSprite runAction:rotateAction]; -
缩放动作:要缩放一个精灵,我们必须指定一个缩放因子,这将根据缩放因子在一段时间内增加或减小精灵的大小。
SKAction* scaleAction = [SKAction scaleBy:2.0 duration:2.0]; [self.spaceShipSprite runAction:scaleAction]; -
淡入淡出动作:要通过动画使精灵可见或不可见,有淡入和淡出精灵的方法。目前,以下代码展示了淡出,它接受一个参数或淡出的时间。
SKAction* fadeOutAction = [SKAction fadeOutWithDuration:1.0]; [self.spaceShipSprite runAction:fadeOutAction];
在 SpriteKit 中,还有许多其他动作,用于提供延迟、更改内容、调用对象或选择器、调用块以及许多特殊效果。
与单个动作类似,还有序列和重复动作,这些都属于 SpriteKit 提供的不同类别的动作。序列动作用于按照我们想要的顺序运行动作。如下面的代码所示,创建了两个动作——一个用于精灵淡出,另一个用于淡入。因此,这两个动作按照我们想要的顺序被输入到序列动作中,并运行我们要求的序列:
SKAction* fadeOutAction = [SKAction fadeOutWithDuration:1.0];
SKAction* fadeInAction = [SKAction fadeInWithDuration:1.0];
SKAction* sequenceAction = [SKAction sequence:@[fadeOutAction, fadeInAction]];
[self.spaceShipSprite runAction:sequenceAction];
重复动作允许动作在固定的时间数内重复或无限重复。因此,使用前面的序列动作,我们做了这两件事。
-
按规律重复三次动画序列:
SKAction* repeatThreeTimesAction = [SKAction repeatAction:sequenceAction count:3]; [self.spaceShipSprite runAction:repeatThreeTimesAction]; -
无限重复动画序列:
SKAction* repeatForeverAction = [SKAction repeatActionForever:sequenceAction]; [self.spaceShipSprite runAction:repeatForeverAction];
另一种动作类型是组动作。在游戏中,我们可能需要多次重复一系列动作,这意味着在任意时间间隔内以特定顺序运行动作。如前所述,创建了两个动作,一个用于精灵淡出,另一个用于淡入。因此,这两个动作按照我们想要的顺序被输入到序列动作中,并运行我们要求的序列。
当我们需要在同一时间运行多个动作时,使用组动作。因此,我们可以创建一个通过淡出移动精灵的组函数:
SKAction* moveAction = [SKAction moveTo:CGPointMake(100,100) duration:1.0];
SKAction* fadeOutAction = [SKAction fadeOutWithDuration:1.0];
SKAction *groupAction = [SKAction group:@[moveAction, fadeOutAction]];
[self.spaceShipSprite runAction:groupAction];
它是如何工作的...
我们之前讨论的所有动作都将按照相同的流程工作。以下是所有动作的基本结构,我们将其应用于精灵上:
-
决定我们想要应用的动作,它们的执行顺序,以及是否需要重复某些动作。
-
对于每个动作,无论其类型如何,都要指定其相应的参数和持续时间。
-
动作最终确定后,只需在要动画的精灵上调用
runAction并使用构建的动作即可。
添加无限滚动
现在我们已经准备好了我们的飞船。是时候在游戏中添加更多内容了。因此,我们的下一个目标是添加无限滚动,这样我们就可以让我们的飞船在太空中无限移动。在这个菜谱中,我们将学习如何将无限滚动添加到游戏中。
准备工作
对于无限滚动背景,你需要了解之前展示的 SpriteKit 的结构。你应该了解渲染循环,在特定帧中更新方法是如何工作的,以及SKScene如何评估动作和物理模拟,从而在SKView中渲染所有内容。现在使用这个循环,你实现天空无限滚动,给人一种飞船飞行的感觉。
如何做...
现在是采取行动的时候了;按照以下步骤添加无限滚动背景到你的游戏中。
-
导入
Resources文件夹中提供的SpaceBackground.png文件。 -
在
FSMyScene中添加一个初始化无限背景的功能。 -
为了启用滚动,我们必须连续添加两个相同的背景精灵节点。
-
在函数中,运行一个
for循环,为两个背景节点指定位置、一个名称(标签),然后添加到SKMyScene。 -
因此,
initalizingScrollingBackground函数看起来是这样的:- (void)initalizingScrollingBackground { for (int index = 0; index < 2; index++) { SKSpriteNode *spaceBGNode = [SKSpriteNode spriteNodeWithImageNamed:@"SpaceBackground.png"]; { spaceBGNode.position = CGPointMake(index * spaceBGNode.size.width, 0); spaceBGNode.anchorPoint = CGPointZero; spaceBGNode.name = @"SpaceBG"; [self addChild:spaceBGNode]; } } } -
将此方法添加到
init方法中,并将添加飞船的代码移动到另一个名为addSpaceship的不同方法中。注意
在游戏编程中,对象的层是通过它们添加的顺序来制作的。所以对于前面的例子,飞船应该在
SpaceBackground之后添加,以给人一种飞船在背景之上的外观。
屏幕上显示的视图顺序可以通过改变它们的z坐标来改变;具有最高z坐标的视图将始终位于顶部。这意味着我们可以明确地定义我们希望保持在顶层的哪一层,以及我们希望保持在底层的哪一层,这将在以下步骤中解释:
-
初始背景已经添加,但它没有滚动。这可以通过在Anatomy of game projects配方中讨论的更新方法来实现。
-
为了做到这一点,需要一些数学来实现这个功能。构建一些内联函数和常量,用于无限移动背景。这是所需的代码:
static const float SPACE_BG_VELOCITY = 100.0; static inline CGPoint CGPointAdd(const CGPoint a, const CGPoint b) { return CGPointMake(a.x + b.x, a.y + b.y); } static inline CGPoint CGPointMultiplyScalar(const CGPoint a, const CGFloat b) { return CGPointMake(a.x * b, a.y * b); } -
只需将这些代码行添加到
FSMyScene的实现之前。 -
现在真正的做法是在更新方法中,迭代在
FSMyScene中添加的所有节点,通过初始化函数中分配的名称识别SpaceBackground节点,并调整其位置以启用无限滚动。所有这些都在一个名为moveSpaceBackground的函数中完成。- (void)moveSpaceBackground { [self enumerateChildNodesWithName:@"SpaceBG" usingBlock: ^(SKNode *node, BOOL *stop) { SKSpriteNode * spaceBGNode = (SKSpriteNode *) node; CGPoint bgVelocity = CGPointMake(-SPACE_BG_VELOCITY, 0); CGPoint amtToMove = CGPointMultiplyScalar(bgVelocity,self.diffTime); spaceBGNode.position = CGPointAdd(spaceBGNode.position, amtToMove); //Checks if Background node is completely scrolled of the screen, if yes then put it at the end of the other node if (spaceBGNode.position.x <= -spaceBGNode.size.width) { spaceBGNode.position = CGPointMake(spaceBGNode.position.x + spaceBGNode.size.width*2, spaceBGNode.position.y); } }]; } -
最后,在游戏场景的更新方法中每次调用此方法。之后,你应该看到飞船在天空中飞行,有一些漂亮的白色云朵。
它是如何工作的...
无限滚动的实现分为三个部分。为了完成我们游戏的无限滚动,我们需要遵循以下步骤:
-
初始化 SpaceBackground:连续添加两个空间背景,以便它们同时移动,给人一种无限滚动背景的感觉。
-
SpaceBackground 移动代码:在这里,使用 SKScene 的块方法来迭代场景中的所有节点。
[self enumerateChildNodesWithName:@"SpaceBG" usingBlock: ^(SKNode *node, BOOL *stop) { }];
在这个迭代中,通过名称识别 SpaceBgNode,以便更新其位置。
SKSpriteNode * spaceBGNode = (SKSpriteNode *) node;
使用CGPointMultiplyScalar内联函数计算要移动的距离,该函数使用常量值SPACE_BG_VELOCITY和从每一帧的更新方法中获得的时间的差值。
CGPoint bgVelocity = CGPointMake(-SPACE_BG_VELOCITY, 0);
CGPoint amtToMove = CGPointMultiplyScalar(bgVelocity,self.diffTime);
之后,计算出的距离被添加到 SpaceBGNode 的当前位置。
spaceBGNode.position = CGPointAdd(spaceBGNode.position, amtToMove);
启用滚动的最后但最重要的步骤是将SpaceBGNode的位置设置为屏幕的右侧,每次它到达屏幕的左侧边缘时。
if (spaceBGNode.position.x <= -spaceBGNode.size.width)
{
spaceBGNode.position =
CGPointMake(spaceBGNode.position.x + spaceBGNode.size.width*2,
spaceBGNode.position.y);
}
下一个任务是更新每一帧以使其无限地在场景中移动。现在为了使其规律地移动,每个帧在FSMyScene的更新方法中调用moveSpaceBackground方法。
- (void)update:(CFTimeInterval)currentTime
{
/* Called before each frame is rendered */
self.diffTime = currentTime - self.lastUpdatedTime;
self.lastUpdatedTime = currentTime;
[self moveSpaceBackground];
}
更新循环将在每一帧执行。因此,为了在每一步移动我们的背景,我们在更新循环中调用了moveSpaceBackground方法。使用这种无限滚动的技术,我们还可以实现视差游戏,这在当今非常常见。在视差滚动游戏中,背景和玩家将在不同的层中,并且它们将以不同的速度同时移动。这将使用户感觉到玩家相对于背景的实时移动。
移动角色
最有趣的部分是让某个角色变得生动,这是我们将在本部分做的。我们将检测屏幕上的触摸,然后对一些节点应用一些酷炫的动作,即移动宇宙飞船上下飞行。
准备工作
要使角色移动,你应该知道可以应用于节点(SKNode)的基本动作(SKAction)。
如何做到这一点...
现在,我们已经让宇宙飞船在无限空间中移动,是时候给游戏添加更多乐趣了。我们将现在给我们的宇宙飞船添加上下运动。按照以下步骤给宇宙飞船添加上下运动:
-
在
FSMyScene中声明一些动作属性,即上升和下降动作。@property (nonatomic, strong) SKAction* moveUpAction; @property (nonatomic, strong) SKAction* moveDownAction; -
在
FSMyScene的touchLocation实现上方定义宇宙飞船在屏幕触摸时移动的距离和所需时间。static const float SPACE_BG_ONE_TIME_MOVE_DISTANCE = 30.0; static const float SPACE_BG_ONE_TIME_MOVE_TIME = 0.2; -
在启动器项目中的
addSpaceShip方法中,将上升和下降动作分配给相应的属性。self.moveUpAction = [SKAction moveByX:0 y:SPACE_BG_ONE_TIME_MOVE_DISTANCE duration:SPACE_BG_ONE_TIME_MOVE_TIME]; self.moveDownAction = [SKAction moveByX:0 y:-SPACE_BG_ONE_TIME_MOVE_DISTANCE duration:SPACE_BG_ONE_TIME_MOVE_TIME]; -
现在实现
UIResponder的一个代理方法,该方法检测触摸和 UI 事件。该方法输入NSSet形式的触摸,从中取出任何触摸并将其转换为相对于发生触摸事件的场景的位置。- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { UITouch *touch = [touches anyObject]; CGPoint touchLocation = [touch locationInNode:self.scene]; }
现在,使用这个touchLocation和SpaceShip位置,代码决定何时对宇宙飞船应用上升或下降动作。它还检查屏幕边界,以确保宇宙飞船不会移动到屏幕外。
这就是代码的样子:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint touchLocation = [touch locationInNode:self.scene];
CGPoint spaceShipPosition = self.spaceShipSprite.position;
CGFloat minYLimitToMove = SPACE_BG_ONE_TIME_MOVE_DISTANCE;
CGFloat maxYLimitToMove =
self.frame.size.height - SPACE_BG_ONE_TIME_MOVE_DISTANCE;
if(touchLocation.y > spaceShipPosition.y)
{
if (spaceShipPosition.y < maxYLimitToMove)
{
[self.spaceShipSprite runAction:self.moveUpAction];
}
}
else
{
if (spaceShipPosition.y > minYLimitToMove)
{
[self.spaceShipSprite runAction:self.moveDownAction];
}
}
}
它是如何工作的...
当用户触摸屏幕时,会调用UIResponder的一个代理方法。
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
}
在这个方法中,触摸的位置被转换为SKScene的局部坐标。根据检测到的位置,它与宇宙飞船的位置进行比较,并对其应用上升或下降动作。
最后,作为本章的成果,你拥有一个小型的基本游戏,其环境是一片蓝天白云,背景以无限滚动的方式移动,一艘宇宙飞船在直线运动中上下飞行。
这就是游戏现在的样子:

还有更多...
可以有很多其他的动画来动画化飞船。所有之前讨论的动画动作都可以用于飞船。这些动作的整合结果可以在游戏中的几个地方使用。在下一章中,我们将更详细地学习动画和效果。
参见
本章已经向您介绍了可以在精灵和节点上进行的动画制作。您还可以访问苹果的开发者文档以获取更多详细信息。您在本章中学到的知识已经足够让您开始对精灵进行动画和动作的制作。
第三章。动画和纹理
在 第二章 中,你学习了 SpriteKit 的基本结构和其构建块,如场景、节点、精灵等。我们通过触摸屏幕在空中获得了一个飞行的飞船,现在在本章中,我们将转向为用户提供完整游戏体验。
你将了解如何使用动画改变精灵的内容,如何使用纹理(存储精灵数据)来渲染精灵,如何使用纹理图集创建一个包含所有游戏资产的大图像,一些角色(飞船)动画,最后但同样重要的是,为充满动画的游戏创建一个视差背景。
在本章中,我们将关注以下食谱:
-
动画化你的纹理
-
创建纹理图集
-
使用纹理图集添加动画
-
碰撞检测
-
视差背景
简介
使用 SpriteKit 将动画添加到我们的游戏中是一个简单的任务,正如我们在上一章中所做的那样,我们在那里添加了一些 SKAction 函数来使飞船移动。这些动画与它们的运动、方向以及某种程度上与它们的变换有关。然而,现在我们要讨论的动画是精灵内容的变化。动画将通过每秒多次动画化同一精灵的多个图像(即,帧),给精灵带来一种活生生的感觉。这有点类似于我们捕捉视频的方式,它只是每秒快照的序列。这类动画需要大量的图像来完成,从而增加了这些纹理的内存大小。纹理由一个 SKTexture 对象表示,该对象与精灵一起创建和附加。纹理对象在可见时自动加载精灵数据(称为纹理数据),并用于在相应场景中渲染精灵。当精灵被移除或在场景中不可见时,它将删除纹理数据,因此自动内存管理得到了简化。
技术上,所有这些都意味着精灵有显示帧,这些帧有不同的纹理,并且每个帧之间通过固定的延迟来改变。而我们将在入门和解决方案套件中要做的一切都将从我们在上一章中离开的地方继续。
动画化你的纹理
在这个食谱中,我们将查看如何创建和附加纹理到精灵。我们还将借助纹理进行一些内容更改(即,帧更改动画)。
准备工作
要开始使用要动画化的纹理,我们应该了解场景、节点和精灵。由于精灵是通过图像创建并添加到场景中的,这会占用更多内存,因此纹理应该是解决这个问题的一个方案。
如何做...
现在,由于纹理的内存管理更加优化,我们可以开始通过固定时间改变精灵的帧进行动画。为此,我们将展示一个环境中的道具,这是一个水平旋转 360 度的硬币。
以下是将硬币看起来像水平旋转 360 度的步骤:
-
首先,复制
Project_Resources文件夹中提供的所有硬币图像(即帧),这些图像与工具包一起提供。总共有六个硬币图像,每个图像都水平旋转了一定角度。 -
在
FSMyScene中添加一个名为addCoin的方法,在其中我们使用纹理(Coin1.png)创建一个硬币精灵,初始图像要显示在天空背景上。SKTexture* coinInitialTexture = [SKTexture textureWithImageNamed:@"Coin1.png"]; SKSpriteNode* coinSprite = [SKSpriteNode spriteNodeWithTexture:coinInitialTexture]; -
要在屏幕上显示硬币,指定其位置;目前,位置设置为屏幕中心,然后将其添加到
FSMyScene。coinSprite.position = CGPointMake(self.frame.size.width/2,self.frame.size.height/2); [self addChild:coinSprite]; -
同样,为硬币的其余帧创建纹理以添加帧动画。
SKTexture* coin2Texture = [SKTexture textureWithImageNamed:@"Coin2.png"]; SKTexture* coin3Texture = [SKTexture textureWithImageNamed:@"Coin3.png"]; SKTexture* coin4Texture = [SKTexture textureWithImageNamed:@"Coin4.png"]; SKTexture* coin5Texture = [SKTexture textureWithImageNamed:@"Coin5.png"]; SKTexture* coin6Texture = [SKTexture textureWithImageNamed:@"Coin6.png"];将所有硬币纹理组合在一起创建一个纹理数组。
NSArray *coinAnimationTextures = @[coinInitialTexture,coin2Texture,coin3Texture,coin4Texture,coin5Texture,coin6Texture]; -
使用
SKAction类的类方法为coinAnimation创建一个SKAction类,并在FSMyScene上添加的硬币精灵上运行该动作。SKAction *coinAnimation = [SKAction animateWithTextures:coinAnimationTexturestimePerFrame:0.2]; [coinSprite runAction:coinAnimation];
添加所有这些代码行使我们的addCoin方法:

它是如何工作的...
当我们使用SKTexture创建纹理对象,就像我们在前一节中所做的那样,纹理将图像(即帧)数据存储到其中,然后进一步转发以创建精灵。这有助于内存管理,因为当精灵被移除时,与之关联的数据(即纹理)也会被移除,从而释放内存。
SKTexture* spaceShipTexture = [SKTexture textureWithImageNamed:@"Spaceship.png"];
self.spaceShipSprite = [SKSpriteNode spriteNodeWithTexture:spaceShipTexture];
同样,使用纹理在屏幕中心添加硬币,其初始帧看起来像这样:

现在我们将使用多个纹理来了解帧动画是如何工作的。我们刚刚使用一些硬币图像创建了一些纹理,这些纹理在视觉上按水平旋转角度递增的顺序设计。因此,创建了一个与纹理顺序相同的数组。
NSArray *coinAnimationTextures = @[coinInitialTexture,coin2Texture,coin3Texture,coin4Texture,coin5Texture,coin6Texture];
使用SKAction类的类方法,将硬币动画纹理数组作为输入,帧延迟为 0.2 秒。
SKAction *coinAnimation = [SKAction animateWithTextures:coinAnimationTexturestimePerFrame:0.2];
[coinSprite runAction:coinAnimation];
前面的函数接受纹理,并以纹理提供的顺序以 0.2 秒的延迟显示它们。
因此,包含动画代码的整体addCoin方法给人一种硬币在屏幕中心水平旋转一周的感觉,这就是场景看起来像这样的原因:

创建纹理图集
纹理图集是将所有应用程序资源(即图像)组合成一个或多个较大图像的方法,以提高应用程序的性能,以便应用程序可以在单个渲染场景的绘制调用中绘制多个图像。例如,如果我们有多个图像文件需要加载到精灵中,SpriteKit 将为每个精灵执行一次绘制调用。然而,如果我们将所有必需的图像组合到一个图像文件中,那么 SpriteKit 可以在使用非常少的内存的情况下,在一个绘制调用中渲染所有精灵。建议为任何游戏项目创建所有必需图像的纹理图集。
Xcode 具有为您的图像集合构建纹理图集的能力,使其成为一个更大的图像,从而提高性能。在创建纹理图集时,应保持纹理过多或过少的平衡,以免内存负载增加。
准备中
要创建纹理图集,我们应该了解精灵和纹理是什么,最重要的是如何使用纹理创建精灵。我们将以动画你的纹理食谱为参考开始。在这个食谱中,我们将学习如何为用于动画的硬币图像和太空船创建纹理图集。
如何操作...
以下是为图像集合创建纹理图集的步骤:
-
在
FlyingSpaceship的启动项目中创建一个存储项目的系统文件夹。注意
它不应是一个 Xcode 组文件夹;它必须是一个系统文件夹。
-
在那个文件夹中,添加所有硬币的图像以及之前在应用程序包中添加的太空船图像。
-
右键单击
Resources文件夹,然后点击将文件添加到"FloatingSpaceship"。![如何操作...]()
-
打开一个查找器视图。从那里,选择
FSGame.atlas并点击添加按钮。每次我们构建项目时,编译器都会寻找命名约定为name.atlas的文件夹。因此,文件夹被识别,并且该文件夹中的所有图像都被组合成一个或多个大图像。![如何操作...]()
-
在将
FSGame.atlas文件添加到项目后,Resources文件夹看起来像这样:![如何操作...]()
-
现在,为了启用纹理图集的生成,转到项目的构建设置并搜索类型
Spritekit;搜索结果将看起来像这样:![如何操作...]()
-
现在,您可以在SpriteKit 部署选项部分看到启用纹理图集生成字段。将该布尔值设置为是。
![如何操作...]()
因此,每次我们构建项目时,编译器都会生成一个属性列表,通过其名称访问纹理图集的图像,这是我们给文件夹的。
它是如何工作的...
在创建纹理图集之后,最重要的部分是我们将如何能够访问纹理图集中的图像。这是通过启用 bool 值Enable Texture Atlas Generation来完成的。之后,每次我们构建项目时,编译器都会寻找具有类似name.atlas命名约定的文件夹。因此,文件夹被识别,并且该文件夹中的所有图像都被组合成一个或多个大图像。
在这里,Xcode 在设置 bool 值后生成一个.plist文件。之后,使用纹理图集名称在代码中获取纹理图集,并从那里我们可以获取任何图像,这些图像被放在那个文件夹中,即纹理图集。
更多内容...
每次我们创建纹理图集时,总是存在使用过多纹理或较少图像之间的权衡。当使用较少图像时,SpriteKit 仍然需要做出许多绘图调用以渲染每一帧。而对于许多纹理,图像数量的增加可能会增加纹理数据,从而对内存造成负载。因此,这取决于我们如何选择;我们可以相对容易地在两种选择之间切换。因此,可以尝试不同的配置以获得最佳性能。
使用纹理图集添加动画
在学习如何使用纹理从App Bundle加载图像之前,我们就已经了解了动画。因为我们有一个纹理图集(即一个较大的组合图像),我们将通过该纹理图集加载图像。在FSMyScene文件中添加的所有精灵都通过App Bundle中的图像加载,所以现在我们将通过精灵中的纹理图集加载所有图像。最后,将使用纹理图集加载的图像应用一些动画。
准备工作
在使用纹理图集加载图像进行动画之前,我们应该了解使用图像组合创建纹理图集的过程,并在固定延迟后进行一些帧变化的动画。所以这里我们将做之前做的同样的硬币旋转动画,但现在使用纹理图集。这个配方将被称为使用纹理图集添加动画。之后,我们将以随机方式在FSMyScene中从一端到另一端(从右到左)动画化一组硬币,给人一种硬币在天空中移动的感觉。
如何操作…
首先,我们将用纹理图集图像在App Bundle中创建纹理的过程替换掉使用图像创建纹理的过程。执行以下步骤:
-
通过指定其名称,即
FSGame(纹理图集的名称),创建一个SKTextureAtlas对象。SKTextureAtlas *textureAtlas = [SKTextureAtlas atlasNamed:@"FSGame"];注意
应该从项目包中移除之前添加的图像,以避免冗余。
SKTexture* spaceShipTexture = [SKTexture textureWithImageNamed:@"Spaceship.png"]; -
现在通过传递要设置到精灵中的太空船图像来使用纹理图集对象创建纹理。
SKTexture* spaceShipTexture = [textureAtlas textureNamed:@"Spaceship.png"]; -
在 FSMyScene 的
addCoin方法中,使用前面的过程通过textureAtlas`对象为所有硬币纹理创建纹理。SKTextureAtlas *textureAtlas = [SKTextureAtlas atlasNamed:@"FSGame"]; SKTexture* coinInitialTexture = [textureAtlas textureNamed:@"Coin1.png"]; SKTexture* coin2Texture = [textureAtlas textureNamed:@"Coin2.png"]; SKTexture* coin3Texture = [textureAtlas textureNamed:@"Coin3.png"]; SKTexture* coin4Texture = [textureAtlas textureNamed:@"Coin4.png"]; SKTexture* coin5Texture = [textureAtlas textureNamed:@"Coin5.png"]; SKTexture* coin6Texture = [textureAtlas textureNamed:@"Coin6.png"]; -
一旦所有纹理都创建完毕,使用与
addCoin相同的代码来添加和动画硬币。 -
让我们使硬币动画更加生动和自然。将用于动画纹理的动作与一个固定延迟一起传递给另一个
SKAction,使其无限重复,从而给人一种硬币持续旋转(永不结束)的感觉。SKAction *rotateAction = [SKAction animateWithTextures:coinAnimationTextures timePerFrame:0.2]; SKAction *coinRepeatForeverAnimation = [SKAction repeatActionForever:rotateAction]; [coinSprite runAction:coinRepeatForeverAnimation]; -
经过一些调整后,从数组中移除最后一个纹理,这样当
repeatForever动作运行时,第一个图像将在最后一个图像之后出现,因此不需要最后一个纹理。NSArray *coinAnimationTextures = @[coinInitialTexture,coin2Texture,coin3Texture,coin4Texture,coin5Texture,coin6Texture];
现在我们已经构建了一个永远旋转的硬币,可以用作游戏中的道具或可收集物品。
制作可收集硬币的步骤如下:
-
为了使硬币从屏幕的左侧移动到右侧,我们必须计算初始和最终位置。
CGFloat coinInitialPositionX = self.frame.size.width + coinSprite.size.width/2; CGFloat coinInitialPositionY = arc4random() % 320; CGPoint coinInitialPosition = CGPointMake(coinInitialPositionX, oinInitialPositionY); CGFloat coinFinalPositionX = -coinSprite.size.width/2; CGFloat coinFinalPositionY = coinInitialPositionY; CGPoint coinFinalPosition = CGPointMake(coinFinalPositionX, coinFinalPositionY); -
之后,设置初始位置为硬币精灵的位置。
coinSprite.position = coinInitialPosition; -
硬币的初始位置被设置,现在我们必须将硬币从初始位置动画到最终位置。这可以通过向硬币精灵添加一个移动
SKAction并指定其最终目的地来实现。SKAction *coinMoveAnimation = [SKAction moveTo:coinFinalPosition duration:5.0]; [coinSprite runAction:coinMoveAnimation];
最后,我们的addCoin方法已经完全准备好用于游戏。为了移动作为可收集物品的硬币,执行以下步骤:
-
为了使这些硬币在场景中作为可收集物品移动,需要在更新方法中进行一些重构。更新
diffTime 和lastUpdatedTime,如下面的代码所示:if (self.lastUpdatedTime) { self.diffTime = currentTime - self.lastUpdatedTime; } else { self.diffTime = 0; } self.lastUpdatedTime = currentTime; -
现在通过在 FSMyScene 的私有接口中声明一个名为
lastCoinAdded的属性,使用currentTime创建一个计时器功能。@property (nonatomic, assign) NSTimeInterval lastCoinAdded; -
因此,这是在更新方法中添加的计时器,通过检查
currentTime和lastCoinAdded的差值来 1。因此,每过 1.0 秒,就会添加一个硬币,动画从屏幕左侧移动到右侧。if( currentTime - self.lastCoinAdded > 1) { self.lastCoinAdded = currentTime + 1; [self addCoin]; }
最后,我们的更新方法已经准备好在设定延迟后动画多个硬币。
它是如何工作的…
在我们之前是使用来自App Bundle的图片来创建精灵,但现在我们将使用纹理图集来获取图片并将其传递给精灵。之前名为FSGame.atlas的纹理图集包含了多个硬币和宇宙飞船的图片。内部代码加载这些帧并将它们存储在一个数组中。
-
SpriteKit 首先搜索图像文件,如果找不到,它会在应用包中构建的纹理图集中搜索。如果我们想显式地使用纹理图集,可以使用
SKTextureAtlas类。通过指定其名称来获取纹理图集:SKTextureAtlas *textureAtlas = [SKTextureAtlas atlasNamed:@"FSGame"]; -
然后,我们可以使用图集对象来获取创建精灵所需的图片。
SKTexture* spaceShipTexture = [textureAtlas textureNamed:@"Spaceship.png"];
现在,我们将了解硬币是如何被转换成可收集物品的。为了移动硬币,需要决定其初始和最终位置。
-
在x维度上的初始位置固定为框架宽度加上硬币的一半,这样它就被添加到屏幕外,而y维度则使用
arc4random()函数从 0 到 320 随机选择。CGFloat coinInitialPositionX = self.frame.size.width + coinSprite.size.width/2; CGFloat coinInitialPositionY = arc4random() % 320; CGPoint coinInitialPosition = CGPointMake(coinInitialPositionX, coinInitialPositionY); -
对于最终位置,x 轴设置为自身宽度的一半的负值,y 轴与初始位置 x 相同。
CGFloat coinFinalPositionX = -coinSprite.size.width/2; CGFloat coinFinalPositionY = coinInitialPositionY; CGPoint coinFinalPosition = CGPointMake(coinFinalPositionX, coinFinalPositionY);![如何工作…]()
-
现在可收集物品已准备好添加到场景中。但是,为了使多个硬币在场景中从左向右移动,必须实现一个计时器。计时器看起来像这样:
if( currentTime - self.lastCoinAdded > 1) { self.lastCoinAdded = currentTime + 1; [self addCoin]; }
在完成所有这些实现后,可以看到多个硬币从左向右移动,如下面的截图所示:

碰撞检测
我们已经将游戏与可收集物品集成在一起。让我们看看宇宙飞船将如何收集这些可收集物品,即硬币。在角色动画中,我们将对相互碰撞的宇宙飞船和硬币进行动画处理。
准备工作
在继续对场景中的实体应用复杂动画之前,必须理解动作(即 SKAction)和场景的更新函数(SKScene)。这样,在更新过程中,我们可以检测硬币与宇宙飞船之间的碰撞,并对它们进行一些动画处理。
如何做…
检测碰撞并对两个实体(硬币和宇宙飞船)进行动画的以下步骤包括:
-
编写一个
detectSpaceShipCollisionWithCoins方法,我们将遍历硬币对象。- (void)detectSpaceShipCollisionWithCoins { [self enumerateChildNodesWithName:@"Coin" usingBlock: ^(SKNode *node, BOOL *stop) { }]; } -
在枚举中,使用
CGRectIntersectsRect()确定宇宙飞船的帧与任何硬币的帧相交。[self enumerateChildNodesWithName:@"Coin" usingBlock: ^(SKNode *node, BOOL *stop) { if (CGRectIntersectsRect(self.spaceShipSprite.frame, node.frame)) { } }]; -
当检测到碰撞时,通过名为
spaceShipCollidedWithCoin的函数通知场景,硬币与宇宙飞船发生了碰撞。[self spaceShipCollidedWithCoin:node];
在所有这些之后,detectSpaceShipCollisionWithCoins 方法看起来如下:

-
在检测到碰撞后,调用
spaceShipCollidedWithCoin函数,该函数调用两个其他函数,这些函数实现了碰撞宇宙飞船和硬币的动画方法。此方法的定义如下:- (void)spaceShipCollidedWithCoin:(SKNode*)coinNode { [self runSpaceshipCollectingAnimation]; [self runCollectedAnimationForCoin:coinNode]; } -
为宇宙飞船编写的动画看起来像是将硬币吸入自身。创建了两个动作
scaleUp和scaleDown,分别用于缩放因子 1.4 和 1.0,每个动作播放 0.2 秒。- (void)runSpaceshipCollectingAnimation { SKAction* scaleUp = [SKAction scaleTo:1.4 duration:0.2]; SKAction* scaleDown = [SKAction scaleTo:1.0 duration:0.2]; } -
之后,形成这两个动画数组以用于创建序列动作。
NSArray* scaleSequenceAnimations = [NSArray arrayWithObjects:scaleUp, scaleDown, nil]; SKAction* spaceShipCollectingAnimation = [SKAction sequence:scaleSequenceAnimations];最后,形成的序列动作在宇宙飞船上运行。
[self.spaceShipSprite runAction:spaceShipCollectingAnimation]; -
对于硬币,动画应该看起来像是被宇宙飞船带走时正在消失。因此,创建了两个核心动画
fadeOut和scaleDown,每个动画的缩放因子为 0.2,时间间隔为 0.4,形成一个动画数组。- (void)runCollectedAnimationForCoin:(SKNode*)coinNode { SKAction* coinFadeOutAnimation = [SKAction fadeOutWithDuration:0.4]; SKAction* scaleDownAnimation = [SKAction scaleTo:0.2 duration:0.4]; NSArray* coinAnimations = [NSArray arrayWithObjects:coinFadeOutAnimation, scaleDownAnimation, nil]; } -
使用这些动画,形成一个组动画。
SKAction* coinGroupAnimation = [SKAction group:coinAnimations]; -
关于硬币,当它与宇宙飞船相撞时,动画结束后必须从场景中移除。因此,在之前创建的组动画完成后,使用积木创建一个动作来移除硬币。
SKAction* coinAnimationFinishedCallBack = [SKAction customActionWithDuration:0.0 actionBlock:^(SKNode *node,CGFloat elapsedTime) { [node removeFromParent]; }];removeFromParent function, which is similar to removeFromSuperview in UIKit. -
当动画准备就绪时,使用数组创建其序列动作。
NSArray* coinAnimationsSequence = [NSArray arrayWithObjects:coinGroupAnimation, coinAnimationFinishedCallBack, nil]; SKAction* coinSequenceAnimation = [SKAction sequence:coinAnimationsSequence];因此,当先前的复杂动作在硬币上运行时,硬币看起来就像是要消失一样。
[coinNode runAction:coinSequenceAnimation]; -
由于所有动画和碰撞检测的代码都已完成,调用
detechSpaceShipCollisionWithCoins方法,以便在每一帧检测碰撞,并通过游戏角色(即飞船)收集硬币。[self detectSpaceShipCollisionWithCoins];
工作原理…
本节最重要的部分是碰撞检测。它是通过CGRectIntersectsRect方法完成的,其中枚举硬币并检查它们的框架是否与飞船框架相交。如果相交,则在硬币和飞船上播放两个不同的动画。
[self enumerateChildNodesWithName:@"Coin"usingBlock: ^(SKNode *node, BOOL *stop)
{
if (CGRectIntersectsRect(self.spaceShipSprite.frame, node.frame))
{
}
}];
当检测发生时,游戏看起来是这样的:

现在我们来谈谈动画。飞船的动画很简单。为了给收集硬币的感觉,我们按顺序使用了scaleUp和scaleDown动画。
SKAction* scaleUp = [SKAction scaleTo:1.4 duration:0.2];
SKAction* scaleDown = [SKAction scaleTo:1.0 duration:0.2];
NSArray* scaleSequenceAnimations = [NSArray arrayWithObjects:scaleUp, scaleDown, nil];
SKAction* spaceShipCollectingAnimation = [SKAction sequence:scaleSequenceAnimations];
然而,对于硬币,创建了一个复杂的动作,包括以下三个动作:
-
FadeOutAction使硬币在 0.4 秒内淡出,看起来像是消失。SKAction* coinFadeOutAnimation = [SKAction fadeOutWithDuration:0.4]; -
ScaleDownAction将硬币缩小,使其在 0.4 秒内缩入飞船。SKAction* scaleDownAnimation = [SKAction scaleTo:0.2 duration:0.4]; -
一个
CallBack函数,用于在先前的动画结束后从场景中移除硬币。SKAction* coinAnimationFinishedCallBack = [SKAction customActionWithDuration:0.0 actionBlock:^(SKNode *node,CGFloat elapsedTime) { [node removeFromParent]; }];
在创建所有这些动作之后,创建了一组fadeOut和scaleDown,这是一个由一系列组动画和回调动作组成的动画序列,它应用于硬币。
每当发生碰撞时,飞船会上下缩放,而硬币会淡出,如下截图所示:

视差背景
现在我们的小游戏 FlyingSpaceship 即将结束。为了给游戏环境增添感觉,我们将引入一个视差滚动背景。视差滚动背景由多个背景层(即节点)组成;同时动画它们会给人一种动态背景的感觉。为了给游戏添加一些酷炫的飞行物,我们将添加两层背景:以节点形式出现的SpaceBlueSky和SpaceWhiteMist。
准备工作
首先,要使用这个酷炫的功能,我们应该了解在前一章中创建的滚动背景,并具备对精灵、节点和数学的基本知识。我们将在 FlyingSpaceship 游戏中制作视差背景的配方。
如何操作…
为了创建具有不同滚动速度的多个滚动背景,我们将创建一个类来完成视差背景。创建名为FSParallaxNode的视差背景类所涉及的步骤如下:
-
通过在FlyingSpaceship项目上右键单击来创建一个新文件。
![如何操作…]()
-
在Cocoa Touch部分选择Objective-C Class。
![如何操作…]()
-
将类命名为
FSParallaxNode并点击下一步。![如何操作…]()
-
现在要创建类,请选择要创建的
FlyingSpaceship文件夹,并点击创建。![如何操作…]()
-
现在,我们需要在
FSParallaxNode的头部添加两个方法。首先,在init方法中,我们需要指定imageFiles,即要滚动的图片,画布大小以及图片滚动的速度。- (id)initWithBackgrounds:(NSArray *)imageFiles size:(CGSize)size speed:(CGFloat)velocity;其次,有一个名为从场景更新中调用的更新方法,其中添加了
FSParallaxNode方法,使得滚动变得无限。- (void)updateForDeltaTime:(NSTimeInterval)diffTime; -
在
FSParallaxNode.m中,在其私有接口中声明一些属性以存储所有背景节点、背景数量和该视差节点的速度。@property (nonatomic, strong) NSMutableArray* backgrounds; @property (nonatomic, assign) NSInteger noOfBackgrounds; @property (nonatomic, assign) CGFloat velocity;在
init方法的定义中,首先分配函数中传递的所有参数,例如velocity。现在我们使用imageFiles计数来分配noOfBackgrounds,并创建一个容量为noOfBackgrounds的背景数组。- (id)initWithBackgrounds:(NSArray *)imageFiles size:(CGSize)size speed:(CGFloat)velocity { if (self = [super init]) { self.velocity = velocity; self.noOfBackgrounds = [imageFiles count]; self.backgrounds = [NSMutableArray arrayWithCapacity:self.noOfBackgrounds]; }]; } return self; } -
使用
imageFiles,使用块进行枚举。在枚举过程中,使用imageFiles类添加背景节点,将它们添加到背景数组中,并在FSParallaxNode上。[imageFiles enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { SKSpriteNode *backgroundNode = [SKSpriteNode spriteNodeWithImageNamed:obj]; [self.backgrounds addObject:backgroundNode]; [self addChild:backgroundNode]; }]; -
还需要指定
backgroundNode的大小,这是通过init方法传递的,其anchorPoint为CGPointZero,其位置根据idx整数,节点名称为background。backgroundNode.size = size; backgroundNode.anchorPoint = CGPointZero; backgroundNode.position = CGPointMake(size.width * idx, 0.0); backgroundNode.name = @"background";
在完成所有这些之后,我们的init方法就准备好了,如下面的截图所示:

现在,让我们看看如何滚动添加到FSParallaxNode上的这些背景;这将通过一个实例更新方法来完成。
-
必须进行一些
cleanUp操作,或者换句话说,需要移动一些代码。将FSMyScene中使用的两个静态方法复制到用于某些数学计算的FSParallaxNode类中,移除SpaceBackground方法的初始化以及从FSMyScene中更新的移动背景调用。从FSMyScene文件中剪切移动背景的方法代码,并将其粘贴到FSParallaxNode的updateForDeltaTime函数中。现在我们将对方法进行一些调整。 -
SKParallax节点是所有添加到其上的背景节点的父节点。因此,使用速度,通过init和diffTime方法(这些方法将通过FSMyScene的更新方法传递),我们计算出父节点的位置,即FSParallax节点。- (void)updateForDeltaTime:(NSTimeInterval)diffTime { CGPoint bgVelocity = CGPointMake(self.velocity, 0.0); CGPoint amtToMove = CGPointMultiplyScalar(bgVelocity,diffTime); self.position = CGPointAdd(self.position, amtToMove); } -
现在,枚举背景,即所有添加到父节点上的节点。在这个枚举过程中,找到相对于父节点的单个背景的位置。之后,检查背景的位置是否小于其宽度的负值(即到达了左端),然后改变该背景的位置到其右端。
SKNode *backgroundScreen = self.parent; [self.backgrounds enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { SKSpriteNode *bg = (SKSpriteNode *)obj; CGPoint bgScreenPos = [self convertPoint:bg.position toNode:backgroundScreen]; if (bgScreenPos.x <= -bg.size.width) { bg.position = CGPointMake(bg.position.x + (bg.size.width * self.noOfBackgrounds), bg.position.y); } }]; -
最后,我们的更新方法构建完成,它提供了多个背景无限滚动的功能。
![如何操作…]()
到目前为止,提供视差滚动背景功能的
FSParallaxNode类已经创建,现在是时候在FSMyScene中创建对象以创建一个酷炫的环境了。 -
我们将添加两个背景层:BlueSky 和 WhiteMist,因此为每个背景创建两个对象。
@property (nonatomic, strong) FSParallaxNode*spaceBlueSkyParallaxNode; @property (nonatomic, strong) FSParallaxNode*spaceWhiteMistParallaxNode;添加一个名为
addParallaxNodes的方法,并在FSMyScene的init方法的第一行调用它。[self addParallaxNodes]; -
对于两个视差节点,我们必须为它们的相对速度添加以下两个常量。
static const float SPACE_BLUE_SKY_BG_VELOCITY = 20.0; static const float SPACE_WHITE_MIST_BG_VELOCITY = 100.0;在
addParallaxNodes中,创建一个blueSkyParallaxBackgroundImages数组,并通过传递场景大小和滚动速度创建一个FSParallax对象。- (void)addParallaxNodes { NSArray *blueSkyParallaxBackgroundNames = @[@"SpaceBackground.png", @"SpaceBackground.png",]; self.spaceBlueSkyParallaxNode = [[FSParallaxNode alloc] initWithBackgrounds:blueSkyParallaxBackgroundNames size:self.frame.size speed:-SPACE_BLUE_SKY_BG_VELOCITY]; self.spaceBlueSkyParallaxNode.position = CGPointMake(0, 0); [self addChild:self.spaceBlueSkyParallaxNode]; } -
与蓝天背景类似,我们还需要实现雾图像。为了使游戏更具现实感,我们将添加
mistParallaxBackgroundImages函数。NSArray *mistParallaxBackgroundNames = @[@"SpaceWhiteMist.png", @"SpaceWhiteMist.png",]; self.spaceWhiteMistParallaxNode = [[FSParallaxNode alloc] initWithBackgrounds:mistParallaxBackgroundNamessize:self.frame.size speed:-SPACE_WHITE_MIST_BG_VELOCITY]; self.spaceWhiteMistParallaxNode.position = CGPointMake(0, 0); [self addChild:self.spaceWhiteMistParallaxNode]; -
一旦节点被添加到场景中,它们需要更新以实现滚动。因此,为两个节点调用
FSMyScene的updateForDeltaTime函数。if (self.spaceBlueSkyParallaxNode) { [self.spaceBlueSkyParallaxNode updateForDeltaTime:self.diffTime]; } if (self.spaceWhiteMistParallaxNode) { [self.spaceWhiteMistParallaxNode updateForDeltaTime:self.diffTime]; }
经过所有这些步骤后,带有视差滚动的背景的动态环境已经准备好用于游戏。
它是如何工作的…
由于我们在上一章中已经完成了背景的滚动,现在是时候为它创建一个单独的类,称为FSParallaxNode。在这个类中,相同的多个背景图像被放置并在场景的每次更新中滚动。这意味着滚动是基于添加到FSParallaxNode的所有背景节点的位置来进行的。
使用这个FSParallaxNode,创建了两个对象:BlueSkyBackground和WhiteMistBackground。所有这些都在init方法中作为普通节点添加到场景中。为了使它们滚动,更新方法调用updateForDeltaTime函数,其中类检查屏幕左侧达到的各个背景位置,并改变位置,使其再次从屏幕右侧开始。这个整个算法使这些单独的背景滚动,形成一个完整的视差滚动背景。视差滚动背景看起来真的很酷,如下面的截图所示:

第四章. 粒子系统与游戏性能
在本章中,我们将关注以下食谱:
-
粒子系统的安装
-
在游戏中使用粒子系统
-
粒子发射器集成
-
游戏性能分析
简介
在第三章,动画和纹理中,我们构建了一个通过宇宙飞船在透视无限滚动背景中收集金币的全游戏。现在,我们将通过引入一些粒子系统来向游戏中添加一些飞行物,使游戏中的效果看起来更加生动和美丽。此外,我们还将了解一些性能分析术语和工具。在本章中,将使用一些性能和监控工具进行性能级别分析,以提高游戏 FlyingSpaceship 的稳定性和性能。
粒子系统的安装
粒子系统是一组称为粒子的独立微小对象集合。它们作为发射节点添加到场景和节点中,控制其在场景中的位置和运动。这些粒子系统用于向场景添加一些酷炫的效果,如火焰、烟雾、爆炸、雨、水等,效果非常强烈。
在本节中,我们将讨论如何安装和创建粒子系统工具,以便在接下来的章节中添加到游戏中特定的效果。
准备工作
在 iOS 7 之前,有开源的粒子编辑器用于创建粒子系统。然而,从 iOS 7 开始,有一个内置的粒子编辑器支持使用名为粒子编辑器的工具创建粒子系统,该工具集成在 Xcode 5.0 中。因此,本节的前提条件是拥有 iOS 7 和 Xcode 5 来创建一些酷炫的粒子效果。
如何操作
对于粒子系统的创建和实现,我们将使用粒子编辑器。这个编辑器不需要安装,因为它内置在 Xcode 中,将用于创建粒子系统文件,然后可以作为节点的一部分添加到实现中。
因此,我们可以从第三章的解决方案套件开始,即 动画和纹理,并从这里继续。以下是为创建 Sprite Kit 粒子文件所涉及的步骤:
-
打开 Xcode,转到 文件 | 新建 | 文件。
![如何操作]()
-
然后在 iOS 部分 的 资源 部分中选择 SpriteKit 粒子文件,然后按 下一步。
![如何操作]()
-
从提供的粒子系统模板列表中选择 Fire 粒子系统模板,然后点击 下一步 创建火焰效果,该效果将用于描绘到目前为止构建的游戏中宇宙飞船的推力。
![如何操作]()
-
将文件命名为
FireParticle并点击 创建。![如何操作]()
-
因此,创建了一个名为
FireParticle.sks的文件,它是粒子文件,并创建了一个名为spark.png的样本粒子图像。Spark.png文件是自动生成的,用作粒子系统的 sks 文件。![如何操作]()
-
如前一个屏幕截图所示,当我们选择
FireParticle.sks文件时,在详细面板中创建的具有火焰效果的粒子系统看起来是这样的:![如何操作]()
-
使用右侧的检查器面板,我们可以根据我们的需求自定义默认的粒子系统。你可以向下滚动以探索检查器中的所有属性,因此你可以相应地更新设置。
![如何操作]()
现在我们有了粒子系统,我们可以直接看到当从检查器更改某些值时的结果。实现也只需要几行代码,无需在代码中更改任何粒子系统值,只需选择文件并将其添加到任何节点或场景中。
让我们尝试并理解粒子系统文件的参数或属性。
如前一个屏幕截图中的列表所示,有一个背景部分可以改变编辑器背景的颜色。它只是在游戏构建时保存,但在运行时并不使用。
在此之下是一个粒子纹理,我们可以选择任何图像或资产用于粒子系统的渲染。它应该具有较小的尺寸和内存,因为粒子的数量将与使用的图像数量相等,这会降低帧率或性能。降低帧率反过来会减慢游戏速度,因此需要明智地使用。
一些属性用于确定粒子的生命周期,例如出生率、寿命和要发射的最大数量。粒子生成部分有位置范围、角度、速度和加速度。
除了所有这些之外,它还有缩放、旋转和透明度来改变粒子的变换。最后但同样重要的是,颜色修饰部分,其中可以完成我们粒子的混合和颜色。
因此,包含FireParticle文件并显式导入图像spark.png的粒子系统是该章节的入门套件。
工作原理
如前所述,创建整个粒子系统或粒子文件非常简单。但努力在于微调粒子发射器的属性,可以说类似于粒子系统的解剖。每个粒子系统都有许多不同的属性,影响单个粒子和整个粒子系统的外观、感觉和行为。
因此,要玩粒子系统文件,以下值相应地更改,从而产生我们想要的粒子效果。以下是一个属性列表,描述了如果更改这些属性会发生什么。
-
背景:这是粒子编辑器的背景。它仅在构建时保存,但在运行时不会反映出来。
-
粒子纹理:这是一个用作粒子图像的图像或资产纹理,用于渲染整个粒子系统。
-
粒子出生率:这是系统每秒生成的粒子数量。
-
最大粒子数:这是发射器必须总共生成的粒子数量;对于值为零的情况,将生成无限流粒子。
-
生命周期:这是粒子在从屏幕消失前的平均寿命;该值以秒为单位插入。
-
位置范围:这是粒子的平均起始位置。
-
发射器角度:这是粒子的平均初始方向。这意味着粒子必须以该角度发射。
-
粒子速度和范围:这是粒子应该移动的速度,还有一个
SpeedRange参数;例如,如果它等于 50 且速度为 100,则速度值将在 50(100 - 50)到 150(100 + 50)之间变化。 -
加速度:这些用于 x(水平)和 y(垂直)加速度。
-
Alpha:这是粒子的初始 Alpha 值,它还有一个范围参数可以指定。
-
缩放:这是为粒子提供的初始缩放因子,即粒子的大小。
-
旋转:这是粒子的初始旋转。
-
颜色混合:在本节中,通过指定粒子的平均初始颜色,提供了一些混合模式。
还有更多
在游览安装和了解粒子编辑器之后,需要对不同粒子系统中的编辑器进行许多调整,以产生生动和酷炫的粒子效果。
在接下来的章节中,我们将调整 FireParticle 文件的值来构建太空船的推力,这在未来将被添加到游戏的空间环境中。
参见
为了更好地理解并学习关于 Xcode 的粒子编辑器以及调整粒子文件值,您可以访问以下链接:
在游戏中使用粒子系统
现在粒子编辑器的完整游览已经结束,我们可以使用粒子发射器了。我们将为我们的游戏构建一些东西,即使用相同的 FireParticle 文件来模拟太空船的推力,以及当太空船捡起硬币时使用默认烟雾模板的碰撞效果。
在本节中,我们将使用 Xcode 的粒子编辑器编辑粒子文件,以产生游戏粒子效果,例如太空船的推力,并创建一个新的名为 SmokeParticle 的粒子文件,用于硬币与太空船之间的碰撞效果。此外,我们还将讨论和了解一些初始的代码级别类。
准备就绪
我们应该熟悉 Xcode 的粒子编辑器,了解如何更改任何粒子文件的值,并创建一些可以在游戏中的某些地方使用的良好粒子系统。由于粒子编辑器的基本元素已在上一节中介绍,现在我们将更改 FireParticle 文件的某些值,并构建更多的粒子系统。
如何做
让我们从与粒子系统创建相关的文件开始,并更改其属性值。在创建粒子系统时,会创建两个文件:
-
一个 sks 文件,即粒子文件,用于创建一个
SKEmitterNode对象,可以添加到任何节点或场景中。 -
导入了一个默认文件
spark.png,它用于在粒子编辑器中为特定的 sks 文件指定粒子图像。它可以是从外部导入的任何图像。
由于创建粒子文件的全过程已经清楚,我们将进一步调整 FireParticle 文件的某些属性,以创建太空船的推力。
-
首先,由于太空船正朝屏幕的右侧移动,我们必须使推力从右向左产生。在
FireParticle文件中,默认的角度值约为 89 度;将其更改为 180 度,使其看起来是从右向左。![如何做]()
-
如前一张快照所示,粒子更多,与太空船相比出生率非常高。因此,我们需要将粒子数减少到 50,出生率减少到 0.5,从而创建一个粒子系统更小、生命周期更短的粒子系统,如下一张快照所示:
![如何做]()
-
进行一些调整,使其看起来像推力,将其速度设置为
50,将初始 alpha 值设置为180,最后也是最重要的,将比例设置为0.2,以便与太空船的比例相匹配。 -
现在我们已经建立了太空船的推力,它看起来是这样的:
![如何做]()
-
以下是在 Xcode 的粒子编辑器中设置的值,用于使用默认的
FireParticle文件创建太空船的推力。![如何做]()
在此之后,当太空船拾取硬币时,还需要创建一个名为碰撞效果的粒子效果。以下是为硬币和太空船的碰撞效果创建 SmokeParticle 文件(粒子系统)的步骤:
-
打开 Xcode 并转到 文件 | 新建 | 文件。
-
在 iOS 的 资源 部分中选择 SpriteKit 粒子文件,然后点击 下一步。
-
为粒子系统选择烟雾模板,以创建一个继承的烟雾效果,然后点击下一步。
![如何操作]()
-
将文件命名为
SmokeParticle并点击创建。![如何操作]()
-
因此,创建了一个
SmokeParticle.sks文件,并且显式导入的spark.png文件也将被这个粒子文件使用。![如何操作]()
-
在
SmokeParticle文件的粒子编辑器中,创建的默认效果将看起来像这样:![如何操作]()
-
现在为了产生白色烟雾效果,我们将更改粒子文件的属性。设置以下值以创建所需的效果:
-
颜色混合因子设置为
0 -
生命周期设置为
2 -
粒子设置为
20 -
角度设置为
0 -
速度设置为
20 -
最大粒子数设置为
1 -
缩放设置为
0.2
-
因此,在粒子编辑器中,碰撞硬币和太空船时将显示的小白烟雾效果如下:

在所有更改完成后,粒子编辑器的编辑器检查器将看起来像这样:

到目前为止,FireParticle和SmokeParticle文件都已准备好添加到游戏中,但我们应该了解用于将这些文件作为节点添加到某些节点或场景中的类。要使用的节点类是SKEmitterNode。
SKEmitterNode是SKNode的子类,它自动创建小粒子作为精灵并在屏幕上渲染。这些发射节点可以用来创建烟雾、火花、雨以及其他许多粒子效果。
例如,一个SKEmitterNode对象,我们既可以使用从粒子编辑器设置的属性的自定义 sks 粒子文件,也可以直接创建SKEmitterNode类的对象并在代码中程序化地更改属性。这意味着有两种方法可以做到这一点:
-
通过从包含预定义属性值的包中获取粒子文件路径来创建一个
SKEmitterNode对象,然后将其添加到任何其他节点或场景中 -
创建一个
SKEmitterNode对象,就像创建任何其他节点一样,设置所有属性,如粒子图像、生命周期、速度等,然后将其添加到任何其他节点或场景中
它是如何工作的
每当创建一个 SpriteKit 粒子文件时,都会使用导入的默认图像,例如 spark.png,作为该粒子系统中的粒子。我们也可以导入我们自己的任何图像来创建个性化的粒子系统。
如前所述,在制作太空船推力的过程中,我们更改了FireParticle文件的一些值以产生火焰推力:
-
我们将角度值更改为 180,以便推力的方向可以正确地按照太空船的方向,即从右向左移动
-
我们降低了生命周期和出生率以匹配太空船的容量,以便它可以按照其大小发射
-
我们还更改了粒子的初始大小,以匹配释放推力的机械
-
我们调整了速度、alpha 等参数
如前所述,在制作碰撞效果的过程中,我们更改了SmokeParticle文件的一些值以产生烟雾效果:
-
要使其变白,我们将Color Blend更改为 0
-
要使其快速消失,将LifeTime更改为 2,将Speed更改为 20
-
为了减少粒子的强度,将Particles的数量分配为 20,并且为了播放一次,将最大粒子数分配为 1
-
将角度设置为 0,比例设置为 0.2,使烟雾从左向右缓慢移动
因此,如何让发射粒子系统表现、放置和构建,完全取决于我们。
说到帮助添加该粒子系统的类,即SKEmitterNode,它通过文件或代码本身在游戏中添加粒子系统。我们将在下一节中在代码级别执行这两种选项。
还有更多
在彻底使用粒子编辑器后,我们真的可以构建一些粒子系统,这些系统可以作为环境变化添加,例如制作雪效果,然后随机添加到场景中,营造出下雪的感觉。使用粒子系统编辑器可以构建更多类似这样的酷炫变化。
在下一节中,我们将添加我们之前在游戏中构建的太空船的推力,以增强太空船的感觉和活力。
参考信息
关于SKEmitterNode及其相关属性的更详细文档,您可以访问以下链接 developer.apple.com/library/ios/documentation/SpriteKit/Reference/SKEmitterNode_Ref/index.html。
粒子发射器集成
现在,我们已经准备好并装备好将粒子系统添加到我们的游戏《FlyingSpaceship》中。在本节中,我们将取FireParticle和SmokeParticle文件,为相应的文件创建一个SKEmitterNode对象,并在某些事件中将它添加到相应的实体上。
在完成所有这些之后,游戏中将出现太空船的推力,使我们的角色变得更加强大。此外,当太空船拾取硬币时,还会看到碰撞烟雾效果。
准备工作
我们将首先在游戏中添加发射器。我们还应该阅读前几节中提供的SKEmitterNode类的文档。然后我们可以继续添加本章入门套件中太空船推力添加的代码。
如何操作
继续使用相同的 Xcode 项目,现在我们将为这一章创建我们的解决方案包,即第四章。
以下是在我们的游戏中添加太空船推力的步骤:
-
打开
FSMyScene类,并添加一个名为addSpaceShipThrust的方法。要获取一个粒子文件,首先我们需要文件的路径来从包中选取。找到路径的方法需要粒子文件的名称和类型。
NSString *emitterPath = [[NSBundle mainBundle] pathForResource:@"FireParticle" ofType:@"sks"]; -
然后,我们可以使用
NSKeyedUnarchiver实例化一个新的SKEmitterNode对象,该对象将返回一个SKEMitterNode对象,用于提供的路径。SKEmitterNode *emitterNode = [NSKeyedUnarchiver unarchiveObjectWithFile:emitterPath]; -
一旦创建了发射器对象,指定其位置,这个位置必须是飞船的中心,因为推力就在那里。
emitterNode.particlePosition = CGPointMake(-self.spaceShipSprite.frame.size.width/2,0); -
最后,在完成所有配置后,只需将
emitterNode对象添加到SpaceShipSprite。[self.spaceShipSprite addChild:emitterNode]; -
在
addSpaceShip方法之后立即调用addSpaceShipThrust方法。 -
前面的
SKEmitterNode完全使用SpriteKit Particle文件创建。但我们可以直接在代码中调整SKEmitterNode的属性。 -
由于我们想在添加发射器节点后增加推力的速度,因此,为此,我们可以编辑发射器节点对象的
speed属性。emitterNode.speed = 500.0f; -
通过使用粒子编辑器创建
FireParticle文件,并使用SKEmitterNode类将其添加到代码中,我们的游戏角色(即飞船)的后面出现了推力。
在我们的游戏中,在硬币和飞船碰撞时添加烟雾效果的步骤如下:
-
打开
FSMyScene类,并添加一个名为addCoinCollisionEffectWithSpaceShip的方法。 -
重复我们在创建飞船推力时执行的步骤。指定文件名和类型以获取路径。
NSString *emitterPath = [[NSBundle mainBundle] pathForResource:@"SmokeParticle" ofType:@"sks"]; -
然后,我们可以使用
NSKeyedUnarchiver实例化一个新的SKEmitterNode对象,该对象将返回一个SKEMitterNode对象,用于提供的路径。SKEmitterNode *emitterNode = [NSKeyedUnarchiver unarchiveObjectWithFile:emitterPath]; -
一旦创建了发射器对象,指定其位置,这个位置将是飞船的中心。
emitterNode.particlePosition = CGPointMake(0,0); -
最后,在完成所有配置后,只需将
emitterNode对象(即SmokeEffect对象)添加到SpaceShipSprite。[self.spaceShipSprite addChild:emitterNode]; -
将所有这些行添加到
addCoinCollisionEffectWithSpaceShip方法中,看起来是这样的。 -
现在在名为
spaceShipCollidedWithCoin的方法中调用此方法。[self addCoinCollisionEffectWithSpaceShip]; -
调用添加
Coin CollisionEffect后,spaceShipCollidedWithCoin看起来是这样的。
通过使用粒子编辑器创建SmokeParticle文件,并使用SKEmitterNode类将其添加到飞船的代码中,烟雾碰撞效果出现在硬币和飞船的碰撞点。
工作原理
这就是我们在添加SKEmitterNode到SKSprite时的工作原理,以及前述步骤的结果,如下面的截图所示,为飞船添加了推力:

同样,为了添加碰撞烟雾效果,我们需要在前面章节中创建的 ParticleSystem 文件。对于这种效果,过程与前面描述的太空船推力类似。以下是实现碰撞烟雾效果后的两个快照。
-
硬币与太空船碰撞:
![如何工作]()
-
产生的碰撞烟雾效果:
![如何工作]()
更多内容
当太空船产生推力时,我们可以在这些实体或发生任何事件时添加各种这样的小粒子效果。同样,我们也可以在太空船捡起硬币时添加粒子效果。如果需要,我们还可以构建一个可以改变整个环境感觉的粒子效果,例如在前面章节中讨论的创建雨或雪。
参见
使用 Xcode 的粒子编辑器可以执行各种操作。要了解更多关于粒子系统的信息,请访问 developer.apple.com/library/ios/documentation/SceneKit/Reference/SCNParticleSystem_Class/index.html。
游戏性能分析
在构建游戏时,为了创建一个实时游戏环境,会进行大量的处理和分析,因此对游戏性能分析有巨大的需求,这对于游戏的平稳运行或软件产品的运行都是必要的。
从用户的角度来看,如果游戏变得缓慢或停止响应,技术上降低帧率,用户可能会对他们正在玩的游戏感到沮丧,并会寻找替代方案。因此,游戏必须达到良好的性能水平,游戏开发者可以通过进行性能分析来实现这一点,这也有助于轻松识别问题并修复它们。
因此,在软件性能分析领域,开发者需要使用特定的工具和性能文档,以便他们可以识别和修复常见性能问题,从而构建一个性能稳定且更好的游戏。
在这个菜谱中,我们查看一些应用程序的性能工具,如何使用它们,确定丢失的池,并修复它们,从而保持应用程序更好的性能水平。
准备工作
为了进行性能分析,我们应该了解一些术语,例如绘图代码、启动时间初始化代码、文件访问代码、应用程序足迹、内存分配代码、基本的优化技巧、基于事件的处理器、提高程序任务的并发性、使用加速框架、现代化应用程序等等。
如何操作
Xcode 包含了多个图形应用程序和命令行工具,用于收集性能指标。有许多可用的工具,例如仪器、分析工具、监控工具、硬件分析工具、额外的命令行工具等等。
所有这些工具都用于收集性能数据,但其中一些使用频率更高,例如内置的调试导航器检查器、工具和许多其他工具。因此,在本节中,你将了解调试导航器和工具的仪器。
调试导航器
在 Xcode 项目的项目导航器中,调试导航器位于面板的第六位,显示正在运行的应用程序的 CPU 利用率和 内存 利用率。
分析应用程序利用率类型的步骤如下:
-
首先,打开任何项目,或者我们可以打开本章解决方案套件中的自己的项目,然后按 command + R 运行应用程序 FlyingSpaceship。
-
点击调试导航器;会出现一个类似这样的面板:
![调试导航器]()
-
在这里,显示在面板中的总 CPU 利用率百分比,即 72% 和使用的 内存,即 54 MB。为了进一步分析这些指标,我们可以点击相应的行以查看适当的图表。
在 CPU 中,显示了三个不同部分的图表:
-
CPU 利用率部分:
![调试导航器]()
-
随时间利用部分:
![调试导航器]()
-
线程部分:
![调试导航器]()
在 内存 中,显示了两个不同部分的图表:
-
内存利用率:
![调试导航器]()
-
基于时间的内存图表:
![调试导航器]()
仪器
Instruments 是一套功能强大的分析工具,具有图形用户界面。Instruments 帮助了解我们应用程序的运行时行为。它一次只显示我们程序的一个方面,因此我们可以使用多个仪器配置每个性能分析会话,每个仪器收集特定的性能指标。
谈到用户界面,所有数据都是并排显示的,以便可以从一个仪器关联到另一个仪器,识别我们应用程序行为中遵循的趋势。这些指标可以使用仪器收集:
-
基于核心数据的应用程序
-
文件系统的读写操作
-
与内存相关的分配和对象对应的统计数据
-
内存泄漏信息
-
Cocoa 分发的有关事件的信息
-
应用程序在运行时的样本
-
与垃圾收集代码相关的统计数据
使用仪器的步骤如下:
-
首先,打开任何项目,或者我们可以从本章解决方案套件中打开自己的项目,然后按 command + R 运行应用程序 FlyingSpaceship。
-
点击调试导航器的 CPU 利用率部分。
-
然后点击此部分的顶部最右边的 Profile in Instruments 按钮。Xcode 将弹出一个窗口询问我们是否要将相同的会话传输到仪器或重新启动。我们可以选择我们希望分析的选项。
![Instruments]()
-
假设我们选择传输,那么 Xcode 将默认打开仪器,并在用户界面中插入一个时间分析器,显示不同线程的运行时间。
![Instruments]()
我们也可以通过按下 command + i 来运行仪器,它将打开仪器以选择要分析的指标,类似于以下内容:

现在执行以下步骤:
-
现在假设我们选择 Allocations 类别来查看这些应用程序的分配。
-
但分配不是可读格式;因此,点击 Statistics 按钮,并选择 Call Trees 以使调用按调用顺序排列。
![Instruments]()
-
之后,为了更好的可读性,请勾选 Show Obj-C Only 复选框。
![Instruments]()
-
点击这两个按钮后,我们就可以真正查看应用程序中分配是如何发生的。您可以使用这些方法查看和分析您的分配。
-
为了进一步分析,有许多更多可以通过点击顶部栏按钮 Library 来包含的指标。
![Instruments]()
这就是库的外观,有许多选择其他指标选项:

通过使用这个库,我们可以包含多个指标,如泄漏、时间分析器,并在仪器的侧面板中集中查看它们。
还有更多
为了更好地理解代码更改如何影响性能,请使用本节中列出的工具,例如仪器。并且,为了更好地理解前面讨论的术语,请阅读以下提供的文档:
参见
要检查任何应用程序的性能水平并提高它,请使用以下链接中记录的工具和初始性能评估流程:
第五章. 为 iOS 游戏添加音乐及 iCloud 简介
在本章中,我们将关注以下食谱:
-
为游戏添加音乐
-
添加背景和声音效果
-
iCloud 简介
-
iCloud 与 iOS 游戏的集成
简介
在第四章中,我们创建了一个完整的游戏,通过 FlyingSpaceship 收集金币,包含精灵、场景、透视无限滚动背景、粒子效果等,除了音乐。现在我们继续前进,添加游戏中最有趣的部分,即音乐。音乐和声音效果为游戏带来了参与感和乐趣;没有音乐的游戏是不存在的。因此,我们将集成一些酷炫和令人惊叹的背景音乐和声音效果到之前章节中构建的 FlyingSpaceship 游戏中。此外,我们还将介绍苹果最近发布的新技术 iCloud 及其框架。使用 iCloud,我们可以轻松且安全地将应用数据(如数据库)存储和检索到 iCloud,这是苹果提供的。
为游戏添加音乐
没有音乐或电影的完整,游戏也是如此。在这个主题中,我们的最终目标是集成一些平滑和令人惊叹的声音效果,使其看起来像一款完整的游戏,用户可以享受。因此,在 iOS 开发中,有许多方法可以将音频集成到应用或游戏中。有些是系统声音服务,AVAudioPlayer,音频队列服务和 OpenAL。所有这些都在应用中用于某些目的和效用。
在这个食谱中,我们将讨论 iOS 提供的不同方式集成音乐和声音效果。在接下来的部分中,我们将集成背景音乐和一些在 FlyingSpaceship 游戏中特定事件发生时的声音效果,这将是本章的入门套件。
准备工作
在开始介绍在游戏中集成音乐和声音效果的技术方法之前,我们应该了解音频是如何添加到视频、电影或需要音频的任何地方的。在游戏中,必须有背景音乐和一些在需要用户注意的事件中的声音效果。所有这些音乐和声音效果都应根据游戏的主题来决定。因此,本节的前提是具备音乐和声音效果的常识。
如何操作...
现在我们将探讨在我们的应用中实现声音服务的一些方法。
系统声音服务
这是一个播放音频文件简单的方法。为了使用系统声音服务播放音频声音并了解其工作原理,以下是一些涉及步骤:
-
将音频文件添加到项目中,并使用
mainBundle获取音频文件的路径:NSString *samplePath = [[NSBundle mainBundle] pathForResource:@"sample-sound" ofType:@"caf"]; -
在这里,我们使用了
.caf格式的音频文件。这是推荐的苹果格式。 -
使用路径,形成一个
NSURL,它将被用来创建一个SystemSoundID:NSURL *sampleURL = [NSURL fileURLWithPath:samplePath]; -
然后使用之前形成的
NSURL和代码中声明的名为sampleSound的SystemSoundID属性创建一个systemSoundID:AudioServicesCreateSystemSoundID((__bridge CFURLRef)sampleURL, &self.sampleSound); -
最后,使用
systemSoundID,即self.sampleSound,播放音频文件。AudioServicesPlaySystemSound(self.sampleSound); -
要使用
AVAudioPlayer播放音频声音,需要 AVFoundation 框架,因此必须导入该框架,并将音频文件添加到项目中,然后使用mainBundle获取音频文件的路径。要导入框架,请添加以下代码行:#import <AVFoundation/AVFoundation.h> -
现在创建一个
AVAudioPlayer实例,内容为要播放的文件的NSURL,并带有错误。NSError *error; AVAudioPlayer *backgroundAudioPlayer = [AVAudioPlayer alloc] initWithContentsOfURL:file error:&error]; -
然后,在
AVAudioPlayer对象上调用prepareToPlay方法,以便准备播放音频文件。[backgroundAudioPlayer prepareToPlay]; -
在播放音频文件之前,我们可以设置
volume和numberOfLoops,最后我们可以播放音频文件。backgroundAudioPlayer.volume = 1.0; backgroundAudioPlayer.numberOfLoops = -1; [backgroundAudioPlayer play];
AVAudioPlayer
音频队列服务
音频队列服务是高级音频功能,因为它是一种记录和播放音频的方法。它允许您的应用程序使用麦克风和扬声器进行硬件录制和播放,而无需了解硬件接口。它提供了精细的时间控制,用于安排播放和同步。有关音频队列服务的更多信息,请参阅更多内容部分。
首先,在需要低延迟精细控制音频的情况下,以上方法并不适用;在这种情况下,仅适合使用 OpenAL,这是一个由 iOS 支持的跨平台音频库。学习 OpenAL 具有陡峭的学习曲线。因此,要理解和实现它,请参阅更多内容部分。
它是如何工作的…
系统声音服务特别用于播放音频警报和简单的游戏音效,例如游戏中移动角色的点击声。使用此方法播放的每个声音都会分配一个systemSoundID。所有跟踪都基于此 ID,例如停止、暂停以及可以对音频应用的不同操作。我们只需添加以下几行代码即可播放声音:
NSString *samplePath = [[NSBundle mainBundle] pathForResource:@"sample-sound" ofType:@"caf"];
NSURL *sampleURL = [NSURL fileURLWithPath:samplePath];
AudioServicesCreateSystemSoundID((__bridge CFURLRef)sampleURL, &self.sampleSound);
AudioServicesPlaySystemSound(self.sampleSound);
sampleSound被声明为一个SystemSoundID属性,以便可以在dealloc方法中稍后处理声音。如果在AudioServicesPlaySystemSound方法之后立即处理声音,则声音将永远不会播放。
系统声音服务有一些缺点,例如仅支持.caf、.aif和.wav音频文件格式,声音长度不能超过 30 秒,并且一次只能播放一个声音。
更多内容…
除了系统声音服务和AVAudioPlayer之外,还有两种更高级的音频播放方式:用于播放和记录的音频队列服务以及用于精细控制时序的 OpenAL。您可以探索 Apple 的 Core Audio 概述和音频会话编程指南。
参见
为了更好地理解和学习音频队列服务和 OpenAL,您可以访问以下链接:
添加背景和音效
在了解了一些在应用程序中集成音频的方法之后,最常用且最简单的方法是 AVAudioPlayer。基本上,在本菜谱中,我们将添加将永远运行的背景音乐,以及在需要用户注意或用户需要被告知某些变化的具体事件上的音效。添加背景音乐和音效将在前几章中构建的 FlyingSpaceship 游戏中完成。
准备工作
在开始添加背景音乐和某些事件上的音效之前,我们应该对 AVAudioPlayer 类和 AVFoundation 框架有一个很好的理解。因此,本节的前提是了解如何使用 AVAudioPlayer 类播放音频,如前一道菜谱中讨论的那样。
如何做到这一点...
我们已经构建了完整的游戏 FlyingSpaceship,其中将在接下来的步骤中添加背景音乐和音效。为了完成这两项任务,将两个音频文件 background-music.caf 和 coin-collected-sound.caf 添加到项目的资源文件夹中。现在执行以下步骤:
-
现在,我们将为游戏添加背景音乐,使其成为一个完整的游戏。首先,在 FlyingSpaceship 的
FSMyScene文件中导入模块AVFoundation。@import AVFoundation; -
声明一个名为
backgroundAudioPlayer的属性,作为AVAudioPlayer对象。@property (nonatomic, strong) AVAudioPlayer *backgroundAudioPlayer; -
如 AVAudioPlayer 部分的代码片段中所述,使用
background-music.caf文件创建路径和NSURL文件。NSString *samplePath = [[NSBundle mainBundle] pathForResource:@"background-music.caf" ofType:nil]; NSURL *file = [NSURL fileURLWithPath:samplePath]; -
只需将属性
self.backgroundAudioPlayer初始化为新的AVAudioPlayerobject。这还需要一个错误对象和之前创建的音频文件。所有错误都将记录在我们传递的参数中对应的对象里。NSError *error; self.backgroundAudioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:file error:&error]; if (error) { NSLog(@"Error in audio play %@",[error userInfo]); return; } -
在此之后,调用该对象的
prepareToPlay,设置一些音量,例如 1.0,为了无限播放,将numberOfLoops设置为-1。[self.backgroundAudioPlayer prepareToPlay]; self.backgroundAudioPlayer.numberOfLoops = -1; self.backgroundAudioPlayer.volume = 1.0; -
最后,在这之后,播放永不结束的背景音乐。
[self.backgroundAudioPlayer play]; -
然后,将代码收集到一个名为
startBackgroundMusic的函数中,其代码如下:![如何做到这一点…]()
-
要播放背景音乐,请在
FSMyScene的initWithSize方法中调用startBackgroundMusic函数。 -
现在,编译并运行项目;你应该能够听到游戏中的背景音乐。
-
现在我们将在游戏中添加一个收集硬币时的音效。为此,声明一个名为
coinCollectedAudioPlayer的属性,作为 AVAudioPlayer 对象。@property (nonatomic, strong) AVAudioPlayer *coinCollectedAudioPlayer; -
如 AVAudioPlayer 部分的代码片段中所述,创建一个路径和一个
NSURL文件,使用coin-collected-sound.caf文件。NSString *samplePath = [[NSBundle mainBundle] pathForResource:@"coin-collected-sound.caf" ofType:nil]; NSURL *file = [NSURL fileURLWithPath:samplePath]; -
将带有错误参数的前一个音效文件分配给
self.coinCollectedAudioPlayer属性的AVAudioPlayer对象。创建此对象后,检查是否有错误,如果有,打印错误消息,然后从那里返回。NSError *error; self.coinCollectedAudioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:file error:&error]; if (error) { NSLog(@"Error in audio play %@",[error userInfo]); return; } -
之后,调用该对象的
prepareToPlay,设置一些音量,例如 1.0,一旦将numberOfLoops设置为1,就播放一次。[self.coinCollectedAudioPlayer prepareToPlay]; self.coinCollectedAudioPlayer.numberOfLoops = 1; self.coinCollectedAudioPlayer.volume = 1.0; -
最后,在完成所有这些之后,播放当太空船收集到硬币时必须播放的音效。
[self.coinCollectedAudioPlayer play]; -
在完成所有这些之后,将代码收集到一个名为
playCoinCollectedSoundEffect的函数中,它看起来像这样:![如何操作…]()
-
要使硬币与太空船碰撞时播放,当检测到碰撞时调用
spaceShipCollidedWithCoin函数时,调用playCoinCollectedSoundEffect函数。 -
在所有背景音乐和音效集成之后,我们这一章的入门套件就准备好了。
它是如何工作的…
AVAudioPlayer 的工作原理以及背景音乐和音效的播放已在前面的话题中解释。这里背景音乐和音效之间的区别是 AVAudioPlayer 对象的 numberOfLoops 属性。对于背景音乐是 -1,对于太空船收集硬币的音效是 1。
还有更多…
使用相同的 AVAudioPlayer,我们可以一起播放多个音效,例如当太空船移动并且收集到硬币时播放移动的声音。所以,在音乐和音效的进一步增强上,没有限制。
参见
要更好地理解和学习 iOS 中的 Core Audio,请访问链接 developer.apple.com/library/mac/documentation/MusicAudio/Conceptual/CoreAudioOverview/CoreAudioEssentials/CoreAudioEssentials.html。
iCloud 简介
苹果推出了一种名为 iCloud 的技术,该技术利用了使用新的 CloudKit 框架构建应用程序的能力。使用 iCloud,我们可以轻松且安全地将我们的应用程序数据以数据库的形式存储和检索在苹果构建的云中。CloudKit 框架为用户提供了一种使用他们的 iCloud Apple ID 匿名登录应用程序的方法,而无需共享他们的个人信息。最重要的是,CloudKit 让开发者专注于客户端应用程序开发,而 iCloud 本身则处理服务器端应用程序逻辑。它还提供了认证的、私有的和公共数据库存储服务,这些服务免费提供,存储限制非常高。
在本教程中,我们将介绍 iCloud 及其框架 CloudKit。我们将了解如何在 Xcode、iTunes Connect 中启用 iCloud 服务,以及如何通过开发设备的配置文件来集成 iCloud。在下一节中,我们将将 iCloud 服务集成到我们的游戏 FlyingSpaceship 中。
准备工作…
在开始使用 iCloud 和 CloudKit 框架进行设置之前,我们应该了解一些 Xcode 功能、iTunes Connect 和配置文件的特性。此外,我们还必须了解 iOS 中的核心数据、存储和检索数据,以便顺利使用和集成 CloudKit 框架。这些都是开始使用新技术 iCloud 及其框架的先决条件。
要将 iCloud 集成到任何应用程序中,在 Xcode 中进行设置之前,以下步骤是必须的:
-
安装了 Xcode 6 或更高版本的 Mac 计算机
-
iOS 或 Mac 开发者计划的会员资格
-
在会员中心创建代码签名标识和配置文件的权限
-
最后,在所有这些之后,我们的 Xcode 项目应该可以无错误地构建
如何操作…
在本教程中,我们将学习如何在我们的应用程序中启用 CloudKit 的步骤。CloudKit 是由苹果公司提供的一个应用程序服务。它仅适用于通过 App Store 或 Mac App Store 分发的应用程序。CloudKit 需要从我们的 Xcode 项目中执行一些额外的配置。我们的应用程序必须经过配置和代码签名才能访问 CloudKit 服务。因此,我们将为我们的 FlyingSpaceship 游戏启用 CloudKit。按照以下步骤在 Xcode 项目中为我们的游戏启用 CloudKit:
-
打开我们想要启用和使用 CloudKit 服务的 Xcode 项目(FlyingSpaceship)。
-
在项目导航器中点击项目,我们可以看到已选中常规部分。
-
选择FlyingSpaceship目标,然后选择下一部分功能。
![如何操作…]()
-
现在,点击第一行,即iCloud,将打开一个类似的部分:
![如何操作…]()
-
现在,在右侧,切换到 iCloud,将发生一些加载。
![如何操作…]()
-
一旦加载完成,iCloud 将被启用,并显示如下截图所示选项:
![如何操作…]()
-
如我们所见,iCloud 提供了三个服务;根据应用程序的需要启用它们。目前,我们将启用 CloudKit。
![如何操作…]()
-
此外,还有一个名为CloudKit 仪表板的按钮可见。点击此按钮,我们将被重定向到苹果 iTunes Connect 中应用程序 FlyingSpaceship 的 CloudKit 仪表板。CloudKit 仪表板的侧边栏、要添加的记录类型和容器部分看起来如下。
![如何操作…]()
-
仪表板用于执行许多数据库管理任务,例如修改模式和记录,如前一个屏幕截图所示。一个应用程序的容器数据库存在于开发和生产环境中。使用仪表板,我们可以对记录进行创建、删除、修改等操作。
-
要探索登录仪表板并点击左侧列中的选项,该列有许多操作,如以下屏幕截图所示:
![如何操作…]()
如何工作…
iCloud 技术提供了一种简单且安全的方式来创建一个应用程序,该应用程序将结构化应用程序和用户数据存储在称为 iCloud 的服务器上。使用 CloudKit 框架,不同设备上由不同用户启动的 iCloud 应用程序实例可以访问应用程序数据库中存储的资产。在为任何应用程序启用 iCloud 之后,我们可以为我们的应用程序创建模型对象,这些对象在多个设备上运行的应用程序之间持久存在并共享。这些数据或模型对象以记录的形式存储在数据库中,并且可以被授权用户访问。
iCloud 是苹果公司提供的一项免费服务,允许用户通过 Apple ID 在其所有设备上访问其个人数据。它通过结合基于网络的专用 API 和 OS 的全面支持来实现这一切。苹果公司通过提供服务器基础设施、备份和用户账户来鼓励构建启用 iCloud 的应用程序。
下图是 iCloud 核心思想的图示,它解决了多个设备之间同步的问题。使用 iCloud 应用程序的用户无需考虑其设备的同步。当用户采用 iCloud 存储时,如以下图所示,所有更改都会自动出现在连接到该 iCloud 账户的所有设备上。

还有更多…
iCloud 支持许多种类的存储。存储类型包括:
-
如用户偏好、设置和简单的应用程序状态数据之类的键值存储
-
如文字处理文档和绘图之类的 iCloud 文档存储
-
结构化内容的多种设备数据库解决方案的核心数据存储
-
CloudKit 存储用于我们自行管理和共享结构化数据
要在 iCloud 上存储数据,我们可以根据我们的需求和能力使用这些方法中的任何一种。继续前进,我们可以选择这些存储类型并创建一个 iCloud 应用程序,以在多台设备上共享存储。
参考以下内容…
如需了解更多关于存储类型的信息,请访问链接 developer.apple.com/library/ios/documentation/DataManagement/Conceptual/CloudKitQuickStart/Introduction/Introduction.html#//apple_ref/doc/uid/TP40014987-CH1-SW1。
将 iCloud 与 iOS 游戏集成
在这个菜谱中,您将学习将 iCloud 与 iOS 游戏集成的步骤。iCloud 集成在应用开发中扮演着重要角色,因为它帮助我们支持各种功能并增强跨设备同步。在这个菜谱中,我们将探索并集成游戏中的几个 iCloud 功能。
准备工作
到目前为止,我们已经完成了与 iCloud 玩耍的初始设置。为了将 iCloud 集成到任何应用中,我们必须注册 iOS 或 Mac 开发者计划的会员资格,并拥有设备配置文件和 AppID。为了完成这项工作并开始集成部分,我们必须查看这两个链接:
在完成所有配置部分后,我们可以在游戏 FlyingSpaceship 中开始通过 CloudKit 集成 iCloud。
如何操作...
iCloud 允许您轻松地从其安全服务器存储和检索数据。这也提供了在多个应用程序之间共享保存数据的附加功能。为了保存这些数据,我们的 iCloud 应用将这些数据放置在一个特殊的本地文件系统,称为 iCloud 容器。它也被称为ubiquity container,作为相应 iCloud 存储的本地表示。这些数据完全独立于我们应用的其他数据;由操作系统保存。
对于某些 iCloud 服务,我们的应用并不直接与 iCloud 服务器通信,相反,操作系统管理所有这些数据上传和下载,为连接到 iCloud 账户的设备。然而,CloudKit 提供了管理这些活动的功能。以下是为使用这些服务所需的步骤:
-
配置对应用 iCloud 容器的访问。这涉及到请求权限并程序化初始化这些容器。
-
设计应用以相应地处理 iCloud 服务的响应,例如当用户从 iCloud 注销,以及在其他设备上的我们应用实例可以编辑数据时。
-
使用适当的 iCloud API 进行读写操作。
-
当需要时,操作系统根据应用的设计协调数据到 iCloud 和从 iCloud 的过渡。
我们已经简要讨论了 iCloud 容器,现在是时候在我们的应用程序中实现 iCloud 容器了。要实现它们,我们将打开 Xcode 项目的 能力 选项卡,该选项卡管理我们应用程序的权限和容器。当我们在这个选项卡中启用 iCloud 时,Xcode 将我们的应用程序配置为默认的 iCloud 容器,其名称基于应用程序的包标识符。这个默认容器被大多数应用程序使用,如下面的截图所示:

如果我们想要构建一些需要相互共享数据的应用程序,那么我们可以启用 指定自定义容器 选项,并且可以通过以下步骤完成:
-
首先,在浏览器中打开
developer.apple.com/,然后点击 会员中心。 -
在会员中心的六个部分中,转到 证书、标识符 和 配置文件 部分,然后在 iOS 部分转到 标识符,那里有一个标识符类型的列表。如何操作…
-
点击 iCloud 容器 选项卡,我们可以在那里看到一个默认容器。如何操作…
-
现在,要添加另一个特定的 iCloud 容器,点击页面右上角的加号按钮,然后输入 ID
iCloud.com.mb.FlyingSpaceshipShared。为容器添加一些描述。如何操作… -
执行完所有前面的步骤后,我们可以在会员中心找到我们的自定义 AppID。如何操作…
-
当特定的容器创建后,现在转到我们应用程序 Xcode 项目的 能力 选项卡。我们可以在 iCloud 部分看到一个额外的容器。如何操作…
-
现在要使用特定的容器,选择 指定自定义容器 单选按钮,然后选择
iCloud.com.mb.FlyingSpaceshipShared容器。
现在,我们将为 iCloud 准备我们的代码。我们将把一些初始设置合并到代码中,以便应用程序使用 iCloud 服务。首先,当用户第一次启动启用 iCloud 的 FlyingSpaceship 游戏时,我们应该邀请他们使用 iCloud。选择应该是全部或无。因此,为了邀请用户使用 iCloud,以下是一些初始设置的步骤:
-
在我们的应用程序启动的
application:didFinishLaunchingWithOptions方法中,从NSFileManager获取ubiquityIdentityToken。NSFileManager* fileManager = [NSFileManager defaultManager]; id currentiCloudToken = fileManager.ubiquityIdentityToken; -
然后,通过使用
NSFileManager获取的ubiquityIdentityToken属性在用户默认数据库中存档 iCloud 可用性。if (currentiCloudToken) { NSData *newTokenData = [NSKeyedArchiver archivedDataWithRootObject:currentiCloudToken]; [[NSUserDefaults standardUserDefaults]setObject:newTokenData forKey:@"com.mb.FlyingSpaceship.UbiquityIdentityToken"]; } else { [[NSUserDefaults standardUserDefaults] removeObjectForKey: @"com.mb.FlyingSpaceship.UbiquityIdentityToken"]; } -
现在,保存的
currentiCloudToken函数是代表当前活动 iCloud 账户的唯一令牌。使用这个令牌,我们可以比较检测当前账户是否与上一个账户不同。 -
当用户启用飞行模式时,iCloud 将无法访问,但当前 iCloud 账户将保持登录状态,并且
ubiquityIdentityToken包含当前 iCloud 账户的令牌。 -
对于从 iCloud 注销的用户,
ubiquityIdentityToken的值设置为nil。因此,为了接收通知,我们应该注册为NSUbiquityIdentityDidChangeNotification通知的观察者,其中接收令牌。这是一个关于 iCloud 可用性变化的通知,我们可以在通知选择器iCloudAccountAvailabilityChanged中相应地处理它。[[NSNotificationCenter defaultCenter] addObserver:selfselector:@selector(iCloudAccountAvailabilityChanged:)name:NSUbiquityIdentityDidChangeNotification object:nil]; -
在存档 iCloud 令牌并注册 iCloud 通知后,我们的应用程序就准备好显示一个警报视图,以向用户展示使用 iCloud 的邀请,并提供两个选项:仅本地和使用 iCloud。为此,首先在检索令牌时保存一个布尔变量
FirstLaunchWithiCloudAvailable:BOOL firstLaunchWithiCloudAvailable = [[NSUserDefaults standardUserDefaults] objectForKey:@"FirstLaunchWithiCloudAvailable"]; if (firstLaunchWithiCloudAvailable == NO) { [[NSUserDefaults standardUserDefaults] setObject:[NSNumber numberWithBool:YES] forKey:@"FirstLaunchWithiCloudAvailable"]; } [[NSUserDefaults standardUserDefaults] synchronize]; -
此外,始终在
didFinishLauchingWithOptions中调用showiCloudInviteAlertView方法。在此方法中,如果当前存在令牌(只有在用户登录到 iCloud 账户时才会存在,否则返回NIL)并且FirstLaunchWithiCloudAvailable布尔值为 YES,则向用户显示警报视图以邀请其使用 iCloud。- (void)showiCloudInviteAlertView { BOOL firstLaunchWithiCloudAvailable = [[NSUserDefaults standardUserDefaults] objectForKey:@"FirstLaunchWithiCloudAvailable"]; if (currentiCloudToken && firstLaunchWithiCloudAvailable) { UIAlertView *alertView = [[UIAlertView alloc] initWithTitle: @"Choose Storage Option" message: @"Should documents be stored in iCloud and available on all your devices?" delegate: self cancelButtonTitle: @"Local Only" otherButtonTitles: @"Use iCloud", nil]; [alertView show]; } }
在进行所有这些更改后,didFinishLauchingWithOptions:方法将看起来像这样:

它是如何工作的…
如果我们开始探索 iCloud 容器,那么我们会注意到,根据应用程序对 iCloud 集成的设计,要么选择默认容器,要么创建自定义容器;iCloud 服务应相应配置。如果我们不创建任何自定义容器,则将配置默认容器,其名称将基于应用程序的包 ID。
并且为了共享数据,我们使用 iCloud 的能力标签页中的指定自定义容器标识符复选框来添加一个或多个容器 ID。我们需要指定为自定义容器创建的 ID。对于多个容器 ID,第一个 ID 是应用程序的主要 iCloud 容器。
关于自定义 iCloud 容器,当设备登录到同一应用程序的 iCloud 账户时,将提供共享功能。
现在我们将讨论为 iCloud 准备代码的方法。如果用户已登录到应用程序的 iCloud 账户,则仅返回ubiquityIdentityToken;否则返回nil。此令牌是通过NSFileManager对象检索的。如果存在,它还会根据应用程序对 iCloud 集成的设计保存下来,以供应用程序进一步使用。
我们正在使用NSUbiquityIdentityDidChangeNotification订阅通知,以获取ubiquityIdentityToken中所有更改的回调。例如,每当用户注销时,它都会提供回调。
有时 iCloud 可能无法供我们的应用程序使用;在这种情况下,当应用程序在后台运行时,账户将不可用。因此,应用程序必须删除所有指向用户特定 iCloud 存储的引用,并刷新依赖于 iCloud 存储的用户界面。
在令牌保存并注册了UbiquityIdentityChange的通知后,应用就准备好显示一个使用 iCloud 的警告。根据用户的选取,代码中会使用相关的 iCloud API,例如键值存储、iCloud 文档存储和 CloudKit 存储,以进一步处理 iCloud 数据。
更多内容…
如前文所述,iCloud 技术中提供了许多 iCloud 存储 API,例如键值存储、iCloud 文档存储和 CloudKit 存储。从所有这些中,正确的 API 选择取决于需要完成的目的。因此,作为一个试验,可以将飞行飞船游戏的用户数据存储在 iCloud 服务器上,并使用之前提到的任何合适的 API 进行共享。
为了学习更多并做出适当的决策,我们可以查看以下链接:
参见
要获取更多信息以及将 iCloud 集成到任何应用中,我们可以访问以下链接:
第六章:物理模拟
在本章中,我们将涵盖以下主题:
-
物理模拟简介
-
将物理引擎集成到游戏中
-
添加现实世界模拟
简介
在第五章中,我们学习了如何将音乐添加到我们的游戏中,以及如何与 iCloud 集成。现在在本章中,我们的主要重点是通过对物理模拟的介绍来为游戏添加现实感。SpriteKit 无缝集成了物理引擎,在本章中我们将探索并尝试将物理添加到我们的游戏中。基本上,SpriteKit 被分为以下两个主要组件:
-
你在屏幕上看到的图形界面,包括 UI 界面、动画、音效等
-
第二个是物理物理世界,它决定了游戏对象之间的交互和行为
物理模拟简介
我们都喜欢具有真实效果和动作的游戏。在本章中,我们将学习如何使我们的游戏更加逼真。你是否曾经想过如何为游戏对象提供真实效果?正是物理为游戏及其角色提供了真实效果。在本章中,我们将学习如何在游戏中使用物理。
在使用 SpriteKit 开发游戏时,你需要经常更改游戏的世界。世界是游戏中主要的对象,它包含所有其他游戏对象和物理模拟。我们还可以根据需要更新游戏世界的重力。默认世界重力为 9.8,这也是地球的重力,世界重力使得所有物体在创建后立即落向地面。
可以使用以下链接了解更多关于 SpriteKit 的信息:
准备工作
第一项任务是创建世界并向其中添加物体,这些物体可以按照物理原理进行交互。你可以以精灵的形式创建游戏对象,并将物理体关联到它们。你还可以设置对象的各种属性来指定其行为。
如何操作...
在本节中,我们将学习用于开发游戏的基本组件。我们还将学习如何设置游戏配置,包括重力、边界等世界设置。
-
第一步是将重力应用到场景中。每个场景都有一个与之关联的物理世界。我们可以使用以下代码行来更新场景中物理世界的重力:
self.physicsWorld.gravity = CGVectorMake(0.0f, 0.0f);目前,我们已经将场景的重力设置为 0,这意味着物体将处于自由落体状态。它们在世界中不会因为重力而受到任何力的作用。
-
在几个游戏中,我们还需要为游戏设置边界。通常,视图的边界可以充当物理世界的边界。以下代码将帮助我们设置游戏的边界,这将与游戏场景的边界一致:
// 1 Create a physics body that borders the screen SKPhysicsBody* gameBorderBody = [SKPhysicsBody bodyWithEdgeLoopFromRect:self.frame]; // 2 Set physicsBody of scene to gameBorderBody self.physicsBody = gameBorderBody; // 3 Set the friction of that physicsBody to 0 self.physicsBody.friction = 0.0f;在代码的第一行中,我们初始化了一个
SKPhysicsBody对象。这个对象用于将物理模拟添加到任何SKSpriteNode。我们创建了一个gameBorderBody,其尺寸等于当前场景的帧。然后,我们将该物理对象分配给当前场景的
physicsBody(每个SKSpriteNode对象都有一个physicsBody属性,通过它可以关联物理体到任何节点)。在此之后,我们更新了
physicsBody.friction。这一行代码更新了我们世界的摩擦属性。摩擦属性定义了一个物理体与另一个物理体之间的摩擦值。在这里,我们将它设置为0,以便使物体能够自由移动,而不会减速。 -
每个游戏对象都是继承自
SKSpriteNode类,这使得物理体可以保持对节点的控制。让我们举一个例子,使用以下代码创建一个游戏对象:// 1 SKSpriteNode* gameObject = [SKSpriteNode spriteNodeWithImageNamed: @"object.png"]; gameObject.name = @"game_object"; gameObject.position = CGPointMake(self.frame.size.width/3, self.frame.size.height/3); [self addChild:gameObject]; // 2 gameObject.physicsBody = [SKPhysicsBody bodyWithCircleOfRadius:gameObject.frame.size.width/2]; // 3 gameObject.physicsBody.friction = 0.0f;我们已经熟悉了代码的前几行,其中我们创建了精灵引用并将其添加到场景中。现在在下一行代码中,我们将物理体与该精灵关联。我们初始化了一个具有半径的圆形物理体,并将其与精灵对象关联。
然后,我们可以更新物理体的各种其他属性,例如摩擦、恢复、线性阻尼等。
-
物理体属性还允许你施加力。要施加力,你需要提供你想要施加力的方向。
[gameObject.physicsBody applyForce:CGVectorMake(10.0f, -10.0f)];在代码中,我们在世界的右下角施加力。为了提供方向坐标,我们使用了
CGVectorMake,它接受物理世界的向量坐标。 -
你也可以施加冲量而不是力。冲量可以定义为在特定时间间隔内作用的力,并且等于在该时间间隔内产生的线性动量变化。
[gameObject.physicsBody applyImpulse:CGVectorMake(10.0f, -10.0f)]; -
在创建游戏时,我们经常使用静态对象。要创建一个矩形静态对象,我们可以使用以下代码:
SKSpriteNode* box = [[SKSpriteNode alloc] initWithImageNamed: @"box.png"]; box.name = @"box_object"; box.position = CGPointMake(CGRectGetMidX(self.frame), box.frame.size.height * 0.6f); [self addChild:box]; box.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:box.frame.size]; box.physicsBody.friction = 0.4f; // make physicsBody static box.physicsBody.dynamic = NO;所以所有代码都是相同的,除了一个特殊的属性,它是动态的。默认情况下,此属性设置为
YES,这意味着所有物理体默认都是动态的,并且可以在将此布尔值设置为NO后转换为静态。静态体不会对任何力或冲量做出反应。简单来说,动态物理体可以移动,而静态物理体则不能。
将物理引擎集成到游戏中
从本节开始,我们将开发一个具有动态移动身体和静态身体的迷你游戏。游戏的基本概念将是创建一个无限弹跳的球,并使用移动的拍子来给球提供方向。
准备中...
要使用物理引擎开发迷你游戏,首先创建一个新的项目。打开 Xcode,转到文件 | 新建 | 项目,然后导航到iOS | 应用程序 | SpriteKit 游戏。在弹出的窗口中,提供产品名称为PhysicsSimulation,导航到设备 | iPhone,然后点击下一步,如图所示:

点击下一步并将项目保存在您的硬盘上。
保存项目后,您应该能够看到以下截图类似的内容:

在项目设置页面中,只需取消勾选设备方向部分的纵向,因为我们只为这个游戏支持横向模式。
图形和游戏不能长期分离;您还需要为这个游戏准备一些图形。下载图形文件夹,将其拖动并导入到项目中。确保勾选了如果需要,将项目组文件夹中的项目复制到目标文件夹,然后点击完成按钮。它应该类似于以下截图:

如何操作...
现在您的项目模板已准备好用于基于物理的迷你游戏。我们需要更新游戏模板项目以开始编写游戏逻辑代码。按照以下步骤将基本物理对象集成到游戏中。
-
打开文件
GameScene.m。这个类创建了一个将被连接到游戏中的场景。从这个类中删除所有代码,并添加以下函数:-(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { SKSpriteNode* background = [SKSpriteNode spriteNodeWithImageNamed:@"bg.png"]; background.position = CGPointMake(self.frame.size.width/2, self.frame.size.height/2); [self addChild:background]; } }这个
initWithSize方法创建了一个指定大小的空白场景。init函数中编写的代码允许您在游戏中将背景图像放置在屏幕中央。 -
现在当您编译并运行代码时,您将观察到背景图像在场景中放置不正确。要解决这个问题,打开
GameViewController.m。从这个文件中删除所有代码,并添加以下函数;-(void)viewWillLayoutSubviews { [super viewWillLayoutSubviews]; // Configure the view. SKView * skView = (SKView *)self.view; if (!skView.scene) { skView.showsFPS = YES; skView.showsNodeCount = YES; // Create and configure the scene. GameScene * scene = [GameScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [skView presentScene:scene]; } }为了确保视图层次结构正确布局,我们实现了
viewWillLayoutSubviews方法。它不能在viewDidLayoutSubviews方法中完美工作,因为那时场景的大小是未知的。 -
现在编译并运行应用程序。您应该能够正确地看到背景图像。它看起来类似于以下截图:
![如何操作...]()
-
因此,现在我们已经放置了背景图像,让我们给世界添加重力。打开
GameScene.m,并在initWithSize方法的末尾添加以下代码行:self.physicsWorld.gravity = CGVectorMake(0.0f, 0.0f);这行代码将设置世界的重力为 0,这意味着将没有重力。
-
现在我们已经移除了重力以使物体自由下落,因此创建一个围绕世界的边界非常重要,这将包含世界中的所有物体,并防止它们离开屏幕。添加以下代码行以在屏幕周围添加不可见的边界来保持物理对象:
// 1 Create a physics body that borders the screen SKPhysicsBody* gameborderBody = [SKPhysicsBody bodyWithEdgeLoopFromRect:self.frame]; // 2 Set physicsBody of scene to borderBody self.physicsBody = gameborderBody; // 3 Set the friction of that physicsBody to 0 self.physicsBody.friction = 0.0f;在第一行,我们创建了一个基于边缘的物理边界对象,具有屏幕大小的框架。这种类型的物理体没有质量或体积,并且不受力和冲量的影响。然后我们将对象与场景的物理体关联起来。在最后一行,我们将物体的摩擦力设置为 0,以便物体与边界表面之间实现无缝交互。最终的文件应该看起来像以下截图:
![如何做……]()
-
现在我们有了准备好的表面来容纳物理世界对象。让我们使用以下代码行创建一个新的物理世界对象:
// 1 SKSpriteNode* circlularObject = [SKSpriteNode spriteNodeWithImageNamed: @"ball.png"]; circlularObject.name = ballCategoryName; circlularObject.position = CGPointMake(self.frame.size.width/3, self.frame.size.height/3); [self addChild:circlularObject]; // 2 circlularObject.physicsBody = [SKPhysicsBody bodyWithCircleOfRadius:circlularObject.frame.size.width/2]; // 3 circlularObject.physicsBody.friction = 0.0f; // 4 circlularObject.physicsBody.restitution = 1.0f; // 5 circlularObject.physicsBody.linearDamping = 0.0f; // 6 circlularObject.physicsBody.allowsRotation = NO;在这里,我们已经创建了精灵,并将其添加到场景中。然后在后续步骤中,我们将圆形物理体与精灵对象关联起来。最后,我们改变该物理体的属性。
-
现在编译并运行应用程序;你应该能在屏幕上看到圆形球体,如下面的截图所示:
![如何做……]()
-
圆形球被添加到屏幕上,但它没有任何作用。因此,是时候在代码中添加一些动作了。在
initWithSize方法的末尾添加以下代码行:[circlularObject.physicsBody applyImpulse:CGVectorMake(10.0f, -10.0f)];这将对物理体施加力,进而移动相关的球体精灵。
-
现在编译并运行项目。你应该能看到球体移动,然后与边界碰撞并弹回,因为边界和球体之间没有摩擦力。所以现在我们在游戏中有了无限弹跳的球体。
![如何做……]()
它是如何工作的……
在创建物理体时,使用了一些属性来定义它们在物理世界中的行为。以下是对前面代码中使用到的属性的详细描述:
-
恢复系数属性定义了物体的弹跳性。将恢复系数设置为
1.0f,意味着球体碰撞将与任何物体完美弹性。这意味着球体会以等于冲击力的力量弹回。 -
线性阻尼属性允许模拟流体或空气摩擦。这是通过减少物体的线性速度来实现的。在我们的例子中,我们不希望球体在移动时减速,因此我们将恢复系数设置为
0.0f。
还有更多……
你可以在苹果的开发者文档中详细了解所有这些属性:developer.apple.com/library/IOs/documentation/SpriteKit/Reference/SKPhysicsBody_Ref/index.html。
添加真实世界模拟
现在我们将在游戏中添加一些真正的模拟。我们将添加更多物理实体,并使它们相互交互。这将帮助我们理解各种物理对象之间的物理交互。
如何操作...
现在我们已经放置了无限弹跳的球。为了使游戏更有趣,让我们向其中添加更多元素。
-
首先,我们将静态块添加到游戏中。为此,在
initWithSize方法的末尾添加以下代码行:SKSpriteNode* block = [[SKSpriteNode alloc] initWithImageNamed: @"block.png"]; block.name = paddleCategoryName; block.position = CGPointMake(CGRectGetMidX(self.frame), block.frame.size.height * 0.6f); [self addChild:block]; block.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:block.frame.size]; block.physicsBody.restitution = 0.1f; block.physicsBody.friction = 0.4f; // make physicsBody static block.physicsBody.dynamic = NO;首先,我们创建块精灵。然后,我们将物理实体与之关联,并更改物理实体对象的各项参数。这里需要注意的最重要的事情是
block.physicsBody.dynamic = NO。默认情况下,所有物理实体都是动态的;因此,要创建静态实体,我们只需将physicsBody.dynamic布尔值设置为NO。 -
添加代码后,最终文件应类似于以下截图:
![如何操作...]()
-
现在编译并运行代码。你应该能够在屏幕上看到具有静态实体的块。仔细观察,当球与块碰撞时,它会弹回,你可以无限地玩它。
![如何操作...]()
-
是时候添加更多动作使游戏更吸引人了。现在我们将使挡板块根据触摸移动。以下函数将帮助我们完成这项任务:
-(void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event; -(void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event; -(void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event;这些方法在您触摸屏幕时提供回调,并提供被触摸的对象列表。有三个回调用于触摸开始、触摸移动和触摸结束动作。
-
现在,在
GameScene.m文件中的@implementation行之前添加以下代码:@interface GameScene() @property (nonatomic) BOOL isPaddleTapped; @end因此,我们创建了一个属性来保存用户在挡板上的触摸状态。
-
现在我们将在
GameScene.m文件中实现touchesBegan:withEvent函数。在init方法旁边添加以下函数:-(void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event { UITouch* touch = [touches anyObject]; CGPoint touchLocation = [touch locationInNode:self]; SKPhysicsBody* body = [self.physicsWorld bodyAtPoint:touchLocation]; if (body && [body.node.name isEqualToString: paddleCategoryName]) { NSLog(@"touch began on paddle"); self.isPaddleTapped = YES; } }此函数将监听触摸开始事件,并使用它来找到用户在场景中触摸的位置的实体。在下一行,我们获取触摸位置的物理实体。
-
现在编译并运行代码。当您触摸挡板时,应该能够看到日志演示触摸在挡板上的工作。
![如何操作...]()
-
现在,让我们继续实现
touchesMoved:withEvent并在touchesBegan旁边添加以下函数:-(void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event { if (self.isPaddleTapped) { // 2 Get touch location UITouch* touch = [touches anyObject]; CGPoint touchLocation = [touch locationInNode:self]; CGPoint previousLocation = [touch previousLocationInNode:self]; // 3 Get node for paddle SKSpriteNode* paddle = (SKSpriteNode*)[self childNodeWithName: paddleCategoryName]; // 4 Calculate new position along x for paddle int paddleX = paddle.position.x + (touchLocation.x - previousLocation.x); // 5 Limit x so that the paddle will not leave the screen to left or right paddleX = MAX(paddleX, paddle.size.width/2); paddleX = MIN(paddleX, self.size.width - paddle.size.width/2); // 6 Update position of paddle paddle.position = CGPointMake(paddleX, paddle.position.y); } }初始时,检查挡板是否被触摸。如果是,则根据用户的触摸位置更新挡板位置。在重新定位时,我们只需确保挡板的Y位置不发生变化。
-
最后,在
GameScene.m文件中的touchedMoved之后添加以下函数:-(void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event { self.isPaddleTapped = NO; }在触摸结束函数中,只需关闭
isPaddleTapped标志。 -
现在编译并运行游戏。你应该能够移动挡板块来击打球并保持其移动。你应该轻触挡板来将其移动到屏幕的左右两侧。
第七章. 为游戏添加现实感
在本章中,我们将涵盖以下食谱:
-
在世界中创建物理体
-
物理关节
-
检测接触和碰撞
简介
在前面的章节中,你学习了游戏物理模拟的结构。我们已经探索了物理引擎的各个部分,包括其与游戏的集成以及游戏引擎基础的操作。你还学习了创建静态和动态体的方法。现在,在本章中,我们将重点关注通过高级物理集成为游戏添加更多现实感。这包括与关节连接的许多物理体的操作。你还将学习检测两个物理体之间碰撞的方法。整体目标是在本章中创建一个迷你游戏,它将包含所有食谱,并有助于更好地理解这些部分。游戏将分为以下三个部分:
-
创建包含一些物理体的物理世界游戏
-
然后,我们将继续前进,使用各种类型的关节将物理体连接起来
-
然后,最终,你将学习各种检测世界中各种物理体之间碰撞和接触的方法
在世界中创建物理体
在本食谱中,我们将创建一个新的游戏项目,并将其设置成适用于所有食谱。这个游戏项目将包含创建物理世界和一些物理体对象。我们将使用这些物理对象在它们之间添加关节。
准备就绪
要开发一个使用物理引擎的迷你游戏,首先创建一个新的项目。打开 Xcode,转到文件 | 新建 | 项目,导航到iOS | 应用程序 | SpriteKit Game。在弹出的窗口中,将产品名称指定为Physics Joints,导航到设备 | iPhone,并点击下一步,如下面的截图所示:

点击下一步,并将项目保存到你的硬盘上。
保存项目后,你应该能够看到项目设置。在这个项目设置页面中,只需取消选中设备方向部分中的纵向,因为我们只支持本游戏的横屏模式。最终屏幕应类似于以下截图:

如何操作...
现在我们的项目模板已经准备好包含一些高级物理行为。为了适应这些行为,我们还需要在项目中调整一些代码。按照以下步骤更新项目以满足我们的要求:
-
打开本章代码包中可用的
GameScene.m文件;这个类创建了一个将被插入到游戏中的场景。从这个类中删除所有代码,并仅添加以下函数:-(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { self.backgroundColor = [SKColor colorWithRed:0.15 green:0.15 blue:0.3 alpha:1.0]; } return self; }这个
initWithSize方法创建了一个指定大小的空场景。init函数中编写的代码改变了场景的背景颜色。我们可以调整 RGB 值以获得所需的背景颜色。 -
现在打开
GameViewController.m文件。从该文件中删除所有代码,并添加以下函数:-(void)viewWillLayoutSubviews { [super viewWillLayoutSubviews]; // Configure the view. SKView * skView = (SKView *)self.view; if (!skView.scene) { skView.showsFPS = YES; skView.showsNodeCount = YES; // Create and configure the scene. GameScene * scene = [GameScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [skView presentScene:scene]; } } -
现在编译并运行应用程序。你应该能够正确地看到背景颜色。这看起来类似于以下截图:
![如何操作...]()
-
现在我们已经设置了背景颜色,所以让我们向世界添加重力。打开
GameScene.m文件,并在initWithSize方法的末尾添加以下代码行:self.physicsWorld.gravity = CGVectorMake(0.0f, -0.5f);这行代码将设置世界的重力为-0.5,这意味着所有物理对象在游戏场景中都将受到向地面的力。
-
现在我们已经应用了一些重力,使物体被拉向地面。因此,为世界添加一些边界是很重要的,这将保持世界中的所有物体,并防止它们离开屏幕。添加以下代码行以在屏幕周围添加不可见的边界以保持物理对象:
self.PhysicsBody = [SKPhysicsBody bodyWithEdgeLoopFromRect:self.frame]; self.physicsBody.friction = 0.0f; -
在第一行,我们创建了一个基于屏幕大小的框架的边缘物理边界对象。这种物理体没有质量或体积,它们也不会受到力和冲量的影响。然后我们将对象与场景的物理体关联起来。在最后一行,我们将物体的摩擦力更改为 0,以使物体与边界表面的交互无损耗。
-
现在我们已经准备好在世界上创建物理体。在
initWithSize方法之后添加以下方法:-(void)update:(CFTimeInterval)currentTime { /* Called before each frame is rendered */ }这是将在游戏执行过程中的每一帧调用的更新方法。因此,所有需要定期更新的操作都将在这个方法中编码。
-
是时候在世界上创建物理对象了。所有物理对象都被称为体。现在添加以下方法来在物理世界中创建体。
-(void)createPhysicsBodiesOnScene:(SKScene*)scene { //Adding Rectangle SKSpriteNode* backBone = [[SKSpriteNode alloc] initWithColor:[UIColor whiteColor] size:CGSizeMake(20, 200)]; backBone.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/2.0); backBone.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:backBone.size]; [scene addChild:backBone]; //Adding Square SKSpriteNode* head = [[SKSpriteNode alloc] initWithColor:[SKColor grayColor] size:CGSizeMake(40, 40)]; head.position = CGPointMake(backBone.position.x, backBone.position.y-40); head.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:head.size]; [scene addChild:head]; }上述代码将创建两个物理体,一个矩形和一个正方形。我们已经调整了物体相对于彼此的位置。
-
现在,在
initWithSize方法的末尾添加以下代码行以在游戏场景中添加物理体:[self createPhysicsBodiesOnScene:self];在这里,我们通过调用
initWithSize中的实例方法来创建物体。 -
现在,编译并运行应用程序。你应该能够看到在世界上创建的两个物理体,并且由于重力作用,它们将落向地面。这看起来类似于以下截图:
![如何操作...]()
物理关节
我们已经看到了物理引擎的许多有趣特性。然而,我们可以通过使用关节将物理体相互连接来使我们的游戏更加有趣。所有物理模拟和力都将考虑它们连接的方式后应用于体上。
准备中
将两个物理体连接在一起的方法有很多种。它们根据连接的物体位置和位置而有所不同。根据连接物体的方式,关节被分为以下类型:
-
销轴:这种类型的关节将两个物理体连接/固定在一起,这样它们都可以独立地围绕它们的锚点旋转。关节看起来类似于以下图示:
![准备中]()
-
限制关节:在这种类型的关节中,两个物体总是保持彼此之间的最大固定距离。这就像两个物体通过一根固定最大距离的绳子连接在一起。
![准备中]()
-
弹簧关节:这种类型的关节将两个物体连接起来,就像它们通过弹簧连接在一起一样。这使得它们以完美的弹性方式行为。弹簧的长度可以通过两个物体之间的初始距离来定义。
![准备中]()
-
滑动关节:这种类型的关节允许两个物体相对于彼此滑动。滑动轴可以由用户明确定义。
![准备中]()
-
固定关节:这种类型的关节通过提供的参考点将两个物理体融合在一起。这些关节可以用来创建复杂物体,这些物体以后可以被分解成碎片。
![准备中]()
如何操作…
现在,我们将再次打开我们的工作项目,以集成和实现项目中所有类型的关节。以下步骤将提供逐步实现关节和理解它们的更深入的方法。
-
要实现销轴,打开
GameScene.m文件,并在其中添加以下函数:-(void)createPinJointOnScene:(SKScene*)scene { //Adding Rectangle SKSpriteNode* backBone = [[SKSpriteNode alloc] initWithColor:[UIColor whiteColor] size:CGSizeMake(20, 200)]; backBone.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/2.0); backBone.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:backBone.size]; backBone.physicsBody.categoryBitMask = GFPhysicsCategoryRectangle; backBone.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:backBone]; //Adding Square SKSpriteNode* head = [[SKSpriteNode alloc] initWithColor:[SKColor grayColor] size:CGSizeMake(40, 40)]; head.position = CGPointMake(backBone.position.x+5, backBone.position.y-40); head.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:head.size]; head.physicsBody.categoryBitMask = GFPhysicsCategorySquare; head.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:head]; //Pinning Rectangle and Square NSLog(@"Head position %@", NSStringFromCGPoint(head.position)); SKPhysicsJointPin* pin =[SKPhysicsJointPin jointWithBodyA:backBone.physicsBody bodyB:head.physicsBody anchor:CGPointMake(head.position.x-5, head.position.y)]; [scene.physicsWorld addJoint:pin]; }在代码的前五行中,我们创建了一个带有物理体的矩形精灵。我们也为这个精灵指定了碰撞和类别掩码。
类似地,在以下代码行中,我们将创建一个带有物理体的正方形精灵。对于这个精灵,我们也指定了类别和碰撞掩码。
然后,最后,在代码的最后三行中,我们将两个物体通过销轴连接在一起。我们创建了一个
SKPhysicsJointPin类的对象,并将矩形和正方形物体以及它们将围绕其旋转的锚点提供给它。 -
现在,将
createPhysicsBodiesOnScene函数调用替换为createPinJointOnScene。在init函数的末尾添加以下代码:[self createPinJointOnScene:self];最终的函数看起来应该类似于以下截图:
![如何操作…]()
-
现在,编译并运行项目,你应该能够看到通过销轴连接的两个物理体。你可以看到它们通过锚点相互连接。
![如何操作…]()
-
现在,我们将实现固定连接;打开
GameScene.m文件,并添加以下函数以实现固定连接:-(void)createFixedJointOnScene:(SKScene*)scene { //Adding Rectangle SKSpriteNode* backBone = [[SKSpriteNode alloc] initWithColor:[UIColor whiteColor] size:CGSizeMake(20, 200)]; backBone.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/2.0); backBone.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:backBone.size];backBone.physicsBody.categoryBitMask = GFPhysicsCategoryRectangle; backBone.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:backBone]; //Adding Square SKSpriteNode* head = [[SKSpriteNode alloc] initWithColor:[SKColor grayColor] size:CGSizeMake(40, 40)]; head.position = CGPointMake(backBone.position.x+5, backBone.position.y-40); head.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:head.size]; head.physicsBody.categoryBitMask = GFPhysicsCategorySquare; head.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:head]; //Pinning Rectangle and Square NSLog(@"Head position %@", NSStringFromCGPoint(head.position)); SKPhysicsJointFixed* pin =[SKPhysicsJointFixed jointWithBodyA:backBone.physicsBody bodyB:head.physicsBody anchor:CGPointMake(head.position.x-5, head.position.y)]; [scene.physicsWorld addJoint:pin]; }现在,我们已经使用固定连接将两个物理体连接在一起。在上一个函数中,我们提供了两个体以及它们所连接的锚点。
-
现在,将
createPinJointOnScene函数调用替换为createFixedJointOnScene。在init函数的末尾添加以下代码:[self createFixedJointOnScene:self]; -
现在,编译并运行项目,你应该能够看到两个物理体通过固定连接连接在一起。你会观察到,两个体通过指定的锚点连接在一起。
![如何操作…]()
-
现在,要在我们的示例项目中实现滑动连接,打开
GameScene.m文件,并在末尾添加以下函数:-(void)createSlidingJointOnScene:(SKScene*)scene { //Adding Rectangle SKSpriteNode* backBone = [[SKSpriteNode alloc] initWithColor:[UIColor whiteColor] size:CGSizeMake(20, 200)]; backBone.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/2.0); backBone.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:backBone.size]; backBone.physicsBody.categoryBitMask = GFPhysicsCategoryRectangle; backBone.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; backBone.physicsBody.affectedByGravity = NO; backBone.physicsBody.allowsRotation = NO; [scene addChild:backBone]; //Adding Square SKSpriteNode* head = [[SKSpriteNode alloc] initWithColor:[SKColor grayColor] size:CGSizeMake(40, 40)]; head.position = CGPointMake(backBone.position.x, backBone.position.y-40); head.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:head.size]; head.physicsBody.categoryBitMask = GFPhysicsCategorySquare; head.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:head]; //Pinning Rectangle and Square NSLog(@"Head position %@", NSStringFromCGPoint(head.position)); SKPhysicsJointSliding* pin =[SKPhysicsJointSliding jointWithBodyA:backBone.physicsBody bodyB:head.physicsBody anchor:head.position axis:CGVectorMake(0, 1)]; pin.shouldEnableLimits = YES; pin.upperDistanceLimit = 200; pin.lowerDistanceLimit = -100; [scene.physicsWorld addJoint:pin]; } -
现在,你会注意到我们正在创建两个物理体,在最后一个部分中,我们使用滑动连接将它们连接起来。然而,为了看到滑动连接的效果,我们将在正方形体上应用冲量。添加以下函数以应用冲量:
-(void)applyImpulseUpwards:(NSTimer*)timer { NSDictionary* dict = [timer userInfo]; SKPhysicsBody* body = dict[@"body"]; CGVector impulse = CGVectorMake(0, [dict[@"impulse"] intValue]); [body applyImpulse:impulse]; } -
要添加冲量实现,我们将在
createSlidingJointOnScene函数的末尾添加以下代码行。[NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(applyImpulseUpwards:) userInfo:@{@"body":head.physicsBody,@"impulse":@(25)} repeats:YES];现在,正方形体会每 5 秒经历一次冲量。
-
现在,将
createFixedJointOnScene函数调用替换为createSlidingJointOnScene。在init函数的末尾添加以下代码:[self createSlidingJointOnScene:self]; -
现在,编译并运行项目,你应该能够看到两个物理体相互滑动。
![如何操作…]()
-
现在我们将着手在我们的示例项目中实现弹簧连接。打开
GameScene.m文件,并在文件末尾添加以下函数:-(void)createSpringJointOnScene:(SKScene*)scene { SKSpriteNode* backBone = [[SKSpriteNode alloc] initWithColor:[UIColor whiteColor] size:CGSizeMake(20, 200)]; backBone.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/2.0); backBone.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:backBone.size]; backBone.physicsBody.categoryBitMask = GFPhysicsCategoryRectangle; backBone.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:backBone]; //Adding Square SKSpriteNode* head = [[SKSpriteNode alloc] initWithColor:[SKColor grayColor] size:CGSizeMake(40, 40)]; head.position = CGPointMake(backBone.position.x, backBone.position.y+backBone.size.height/2.0+40); head.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:head.size]; head.physicsBody.categoryBitMask = GFPhysicsCategorySquare; head.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:head]; //Pinning Rectangle and Square NSLog(@"Head position %@", NSStringFromCGPoint(head.position)); SKPhysicsJointSpring* pin =[SKPhysicsJointSpring jointWithBodyA:backBone.physicsBody bodyB:head.physicsBody anchorA:head.position anchorB:CGPointMake(backBone.position.x, backBone.position.y+backBone.size.height/2.0)]; pin.damping = 0.5; pin.frequency = 0.5; [scene.physicsWorld addJoint:pin]; }要在两个物理体之间应用弹簧连接,我们已提供两个物理体以及两个锚点作为函数参数。我们还可以提供额外的参数,例如阻尼和频率。
-
现在,将
createSlidingJointOnScene函数调用替换为createSpringJointOnScene。在init函数的末尾添加以下代码:[self createSpringJointOnScene:self]; -
现在,编译并运行项目,你应该能够看到两个物理体相互滑动。
![如何操作…]()
-
我们在示例项目中接下来的一种连接类型是极限连接。现在打开
GameScene.m文件,并在文件末尾添加以下函数:-(void)createLimitJointOnScene:(SKScene*)scene { SKLabelNode* label = [SKLabelNode labelNodeWithFontNamed:@"Futura-Medium"]; label.text = @"An upward impulse is applied to the square every few seconds."; label.fontSize = 14; label.position = CGPointMake(220, scene.view.frame.size.height-100); [scene addChild:label]; SKSpriteNode* backBone = [[SKSpriteNode alloc] initWithColor:[UIColor whiteColor] size:CGSizeMake(20, 200)]; backBone.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/2.0); backBone.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:backBone.size]; backBone.physicsBody.categoryBitMask = GFPhysicsCategoryRectangle; backBone.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:backBone]; //Adding Square SKSpriteNode* head = [[SKSpriteNode alloc] initWithColor:[SKColor grayColor] size:CGSizeMake(40, 40)]; head.position = CGPointMake(backBone.position.x, backBone.position.y+backBone.size.height/2.0+40); head.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:head.size]; head.physicsBody.categoryBitMask = GFPhysicsCategorySquare; head.physicsBody.collisionBitMask = GFPhysicsCategoryWorld; [scene addChild:head]; //Pinning Rectangle and Square NSLog(@"Head position %@", NSStringFromCGPoint(head.position)); SKPhysicsJointLimit* pin =[SKPhysicsJointLimit jointWithBodyA:backBone.physicsBody bodyB:head.physicsBody anchorA:head.position anchorB:CGPointMake(backBone.position.x, backBone.position.y+backBone.size.height/2.0)]; pin.maxLength = 100; [scene.physicsWorld addJoint:pin]; [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(applyImpulseUpwards:) userInfo:@{@"body":head.physicsBody,@"impulse":@(50)} repeats:YES]; }在倒数第二个部分,我们在我们创建的两个物理体上应用了极限连接。要应用极限连接,我们必须传递两个体以及创建连接的锚点。现在,当连接对象初始化后,我们可以将连接添加到物理世界中。
如我们之前在滑动连接中看到的,在其中一个体上应用了额外的冲量。同样,这里我们也要在正方形体上应用冲量以测试极限连接的行为。
-
现在,将
createSpringJointOnScene函数调用替换为createLimitJointOnScene。在init函数的末尾添加以下代码:[self createLimitJointOnScene:self]; -
现在编译并运行项目,你应该能够看到两个物理体相互滑动。如何做到这一点…
检测接触和碰撞
我们通过在节点上添加SKPhysicsBody函数来对节点应用物理模拟。当场景处理每一帧时,它为场景中的所有物理体执行所有与物理相关的计算。它还计算场景中任何物理体上施加的所有自定义力,这最终在游戏中产生逼真的效果。在现实世界的任何游戏中开发时,碰撞检测都是一个重要的部分,因为在几乎所有的游戏中,我们都会检查两个物理体的碰撞。例如,在任何战争游戏中,我们可能需要检查子弹是否与玩家发生了碰撞。
准备工作
物理体有多种形状,可以用来将物理应用到场景中。这些形状被定义为节点的个人空间。当一个节点的形状与另一个节点的形状相交时,会调用-didBeginContact方法,并可能应用物理。现在,为了实现碰撞检测,我们需要了解物理体的以下属性:
-
categoryBitMask:这个属性定义了物理体的类别。我们可以根据需求有自定义的类别。例如,在战争游戏中,我们可以有玩家、子弹和敌人作为类别。所有物理体都可以基于这些类别。 -
collisionBitMask:这个属性添加了一个掩码,定义了哪些物理体可以与这个物理体碰撞。这将帮助物理引擎评估并仅在代理方法中抛出所需的结果。例如,子弹只能与敌人碰撞,而不能与任何玩家碰撞。 -
contactTestBitMask:这个属性定义了指定哪些物理体类别与这个物理体产生交叉通知的掩码。
如何做到这一点…
现在,我们将再次打开我们的工作项目来实现一个处理碰撞和接触检测的示例。以下步骤将提供逐步实现和理解项目中碰撞检测的方法:
-
要实现碰撞检测,打开
GameScene.m文件,并在文件末尾添加以下函数:- (void)createCollisionDetectionOnScene:(SKScene*)scene { collisionLabel = [SKLabelNode labelNodeWithFontNamed:@"Futura-Medium"]; collisionLabel.text = @"Collision detected"; collisionLabel.fontSize = 18; collisionLabel.fontColor = [SKColor whiteColor]; collisionLabel.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/1.2); collisionLabel.alpha = 0.0f; [scene addChild:collisionLabel]; SKSpriteNode* backBone = [[SKSpriteNode alloc] initWithColor:[UIColor whiteColor] size:CGSizeMake(20, 200)]; backBone.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/2.0); backBone.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:backBone.size]; backBone.physicsBody.categoryBitMask = GFPhysicsCategoryRectangle; backBone.physicsBody.collisionBitMask = GFPhysicsCategorySquare; backBone.physicsBody.contactTestBitMask = GFPhysicsCategorySquare; backBone.physicsBody.dynamic = YES; [scene addChild:backBone]; //Adding Square SKSpriteNode* head = [[SKSpriteNode alloc] initWithColor:[SKColor grayColor] size:CGSizeMake(40, 40)]; head.position = CGPointMake(backBone.position.x, backBone.position.y+backBone.size.height/2.0+40); head.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:head.size]; head.physicsBody.categoryBitMask = GFPhysicsCategorySquare; head.physicsBody.collisionBitMask = GFPhysicsCategoryRectangle; head.physicsBody.contactTestBitMask = GFPhysicsCategoryRectangle; head.physicsBody.dynamic = YES; [scene addChild:head]; [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(applyImpulseUpwards:) userInfo:@{@"body":head.physicsBody,@"impulse":@(50)} repeats:YES]; }现在我们已经熟悉了这个函数中的代码。我们将创建两个物理体并将它们添加到场景中。最后,在最后一节中,我们将每 5 秒对物理体施加一个冲量。
在这里,我们正在为每个物理体更新三个额外的参数。我们正在更新物理体的
categoryBitMask、collisionBitMask和contactTestBitMask。如前所述,我们正在更新categoryBitMask以向物理体提供特定的类别。同时,我们提供信息来定义它可以检测碰撞的物理体。 -
现在,我们必须添加一个代理方法,当两个物理体相互碰撞时将被调用。我们已经添加了日志来检查正在碰撞的物理体。我们可以使用它们的
categoryBitMask来识别物理体。![如何操作…]()
-
现在,将
createLimitJointOnScene函数调用替换为createCollisionDetectionOnScene。在init函数的末尾添加以下代码:[self createCollisionDetectionOnScene:self]; -
此外,我们还需要订阅接触检测的代理回调。要订阅,请添加以下代码行:
self.physicsWorld.contactDelegate = self; -
我们还必须在接口文件中声明接触代理。因此,打开
GameScene.h并在接口声明行末尾添加以下代码:<SKPhysicsContactDelegate> -
最终的代码文件应该看起来类似于以下截图:
![如何操作…]()
-
现在编译并运行项目,你应该能够在屏幕上看到物理体,并在 Xcode 调试窗口中看到日志。你可以看到碰撞已经被检测并在日志中打印出来。你还可以注意到,具有类别掩码
2和4的物理体之间发生了碰撞。我们有一个类别掩码为2的矩形物理体和一个类别掩码为4的正方形物理体,它们正在相互碰撞。![如何操作…]()
-
现在,我们将使碰撞检测更加直观。为此,让我们在屏幕上添加一个标签,当物理体相互碰撞时,该标签将闪烁。为此,在
@implementation GameScene代码之前添加以下代码行:SKLabelNode* collisionLabel; -
现在,在
createCollisionDetectionOnScene函数的开始处添加以下代码行:collisionLabel = [SKLabelNode labelNodeWithFontNamed:@"Futura-Medium"]; collisionLabel.text = @"Collision detected"; collisionLabel.fontSize = 18; collisionLabel.fontColor = [SKColor whiteColor]; collisionLabel.position = CGPointMake(CGRectGetWidth(self.frame)/2.0, CGRectGetHeight(self.frame)/1.2); collisionLabel.alpha = 0.0f; [scene addChild:collisionLabel]; -
现在,为了在碰撞时对标签进行淡入和淡出,在
didBeginContact方法的末尾添加以下代码行:SKSpriteNode *firstNode, *secondNode; firstNode = (SKSpriteNode *)contact.bodyA.node; secondNode = (SKSpriteNode *) contact.bodyB.node; if (firstNode.physicsBody.categoryBitMask == GFPhysicsCategoryRectangle && secondNode.physicsBody.categoryBitMask == GFPhysicsCategorySquare) { SKAction *fadeIn = [SKAction fadeAlphaTo:1.0f duration:0.2]; SKAction *fadeOut = [SKAction fadeAlphaTo:0.0f duration:0.2]; [collisionLabel runAction:fadeIn completion:^{ [collisionLabel runAction:fadeOut]; }]; } -
在这里,我们正在检查矩形和正方形的物理体。一旦我们收到这两个物理体碰撞的回调,我们可以让标签淡入一秒钟,然后再次淡出。这将产生一个很好的效果,以显示物理体相互碰撞时的情况。
-
现在编译并运行项目,你应该能够看到通过销轴连接的物理体。你可以看到它们通过锚点相互连接。
![如何操作…]()
第八章. 游戏数学和物理简介
本章将涵盖以下内容:
-
勾股定理
-
使用向量
-
物理
简介
在本章中,我们将学习所有将在本书接下来的几章中使用的基本数学概念。在学习游戏物理时,掌握一些基本的数学和物理知识总是好的,因为它们是制作逼真游戏的主要关键组件。例如,当我们把球扔到地上时,它会来回弹跳,直到停止。为了实现这种条件,我们必须通过更新物理参数(如恢复力、力、弹跳、摩擦等)来对物理体应用某些条件。在下一章中,我们将使用本章学到的所有物理和数学概念。
勾股定理
最常用的三角形是直角三角形。直角三角形有许多有趣的性质,可以在游戏中使用,使生活更简单。其中一个著名的性质是,直角三角形的斜边平方等于另外两边的平方和。
准备中
三角形的斜边是直角三角形的最长边,如下所示:

如果斜边表示为 h,勾股定理可以写成以下形式:
h² = a² + b²
如果你取两边的平方根,你将得到以下:
h = sqrt(a²+b²)
这意味着如果我们知道直角三角形的任意两边的长度,我们就可以很容易地找到第三边的长度。
当与游戏的人工智能(AI)一起工作时,我们将经常使用勾股定理来计算哪个代理更接近对象。如果边 A 比边 B 长,那么它总是更长,无论长度是否平方。现在,我们可以避免取平方根来比较距离,而只需比较平方值。
如何操作
这里是勾股定理的一个实际应用:
假设我们有一个位于 X(8,4)位置的枪手和他的目标位于 Y(2,1)位置。枪手只能射出最大距离为 10 个单位的子弹。因此,为了确定他是否能击中目标,必须计算他们之间的距离。这可以通过勾股定理很容易地确定。首先,计算以下图中所示的 YZ 和 XZ 边的长度:

要找到 XZ 的距离,按照以下方式从枪手位置的 y 分量减去目标的 y 分量:
XZ = 4 - 1 = 3
要找到 YZ 的距离,我们做同样的操作,但使用 x 分量:
YZ = 8 - 2 = 6
现在已知 YZ 和 XZ,可以使用勾股定理计算出枪手到目标的距离,如下所示:

完全在目标范围内。让目标被击中!
如果你知道直角三角形的一边的长度和剩下的两个角中的一个,你可以使用三角学确定三角形的其余信息。首先,看看以下图示。它显示了直角三角形每边的名称。

以下图示了其含义:
-
sin(θ) = 对边/斜边
-
cos(θ) = 邻边/斜边
-
tan(θ) = 对边/邻边
通过以下示例,我们可以看到正弦、余弦和正切函数如何被利用:

我们想要计算对边的长度,已知邻边的长度和角度。从这里我们知道,一个角度的正切等于对边除以邻边。稍微调整一下方程,我们得到以下结果:
o = aTan(θ)
因此,为了得到 o,我们只需要拿起计算器(确定正切值)并输入以下数字:
o = 6Tan(0.9)
= 7.56
使用向量
在设计我们游戏的 AI 时,我们将频繁使用向量数学。向量无处不在,从计算游戏代理应该向哪个方向射击到表达人工神经网络的输入和输出。你应该很好地了解它们。
让我们以点 P 为例:
P = (x, y)
当写成如下形式时,二维向量看起来几乎相同:
V = (x, y)
然而,尽管相似,向量表示两个分量:方向和幅度。以下图的右侧显示了位于原点的向量 (9, 6):

箭头的方向表示向量的方向,线的长度代表向量的幅度。一个向量可以表示车辆的速率。向量的幅度代表车辆的速率,方向代表车辆的航向。
仅从两个数字(x, y)中就能获得如此多的信息。向量也不限于二维。它们可以是任何大小。例如,你可以使用三维向量 (x, y, z) 来表示在三维空间中移动的车辆,如直升机。让我们看看你可以用向量做什么。
如何做
向量可以用多种方式使用,以下列出了一些:
-
向量相乘非常简单。你只需将每个分量乘以相应的值。例如,向量 V (4, 5) 乘以 2 得到 (8, 10)。
-
向量的幅度就是它的长度。在先前的例子中,向量 V (4, 5) 的幅度是从起点到点 P(4, 5) 的距离,如下图所示:
![如何做]()
这可以通过勾股定理轻松计算,如下所示:
幅度 =
![如何做]()
如果你有一个三维向量,那么你会使用类似的方程:
大小 =
![如何操作]()
数学家用两个垂直线围绕一个向量来表示其长度,如下所示:
大小 = |V|
-
归一化向量:当一个向量被归一化时,它保留了其方向,但其大小被重新计算,使其长度为 1(长度为 1)。为此,你需要将向量的每个分量除以向量的模。数学家将公式写成以下形式:
N = V/|V|
因此,为了将向量(4, 5)归一化,你需要做以下操作:
新 X = 4 /6.403 = 0.62
新 Y = 5 /6.403 = 0.78
-
分解向量:可以使用三角学将一个向量分解为两个独立的向量,一个平行于x轴,另一个平行于y轴。看看表示喷气式战斗机在点 V 处的推力的向量 V,如下所示:
![如何操作]()
为了将 V 分解为 x/y 分量,我们需要找到Oa和Ob。这将给出飞机推力沿y轴的分量和沿x轴的分量。另一种说法是,Oa是沿x轴作用的推力量,Ob是沿y轴的分量。
首先,让我们计算沿y轴的推力量:Oa。从三角学中,我们知道:
cos(θ) = 邻边 / 斜边 = Oa / |V|
重新排列后,我们得到:
Oa = |V| Cos(θ) = y 分量
为了计算 Ob,使用以下方程:
sin(θ) = 对边 / 斜边 = Ob / |V|
给出:
Ob = |V| sin(θ) = x 分量
点积:点积给出了两个向量之间的角度——在编程 AI 时你将经常需要计算的角度。给定两个二维向量 u 和 v,方程看起来如下:
u.v = ux vx + uy uy //方程(1)
.(点)符号表示点积。方程(1)并没有给出一个角度。我承诺会给你一个角度,所以你将得到一个!这是计算点积的另一种方法:
u.v = |u| |v| cos(θ)
重新排列后,我们得到:
cos(θ) = u.v / |u| |v|
记住,围绕向量的垂直线表示其大小。现在是你发现归一化向量的一个有用用途的时候了。如果 v 和 u 都是归一化的,那么方程可以极大地简化为:
cos(θ) = u.v / 11 = u.v*
将方程(1)中的右侧代入方程中,我们得到:
cos(θ) = u.v = ux vx + uy uy
这给我们一个关于向量之间角度的方程。
它是如何工作的
这里有一些你刚刚学到的向量方法一起工作的例子。比如说,你有一个游戏代理,埃里克·巨魔,他站在位置 T(原点)并朝向由归一化向量 H(方向)给出的方向。他可以闻到位置 P 的无助公主,并且非常想在她被撕成碎片之前用他的棍子打她,使她变得柔软一些。为了做到这一点,他需要知道他必须旋转多少弧度才能面对她。以下图显示了这种情况:

你发现你可以使用点积来计算两个向量之间的角度。然而,在这个问题中,你只有一个向量开始,H。因此,我们需要确定一个向量——指向公主的向量 TP。这是通过从点 T 中减去点 P 来计算的。因为 T 在原点 (0, 0),在这个例子中,P–T= P。然而,P–T 的答案是向量,所以我们用粗体表示它,并称之为 P。
我们知道巨魔需要转动的角度的余弦值等于 H 和 P 的点积,前提是两个向量都进行了归一化。H 已经归一化,所以我们只需要归一化 P。记住,为了归一化一个向量,其分量被除以其大小。
因此,P 的法线(NP)是:
Np = P / |P| // 方程式 (2)
现在可以使用点积来确定角度:
cos(θ) = Np.H // 方程式 (3)
所以:
θ = cos^(-1)(Np.H) // 方程式 (4)
为了阐明这个过程,让我们再次做一遍,但这次有一些数字。比如说,巨魔位于原点 T (0, 0) 并朝向 H (1, 0)。公主站在点 P (4, 5)。巨魔需要转动多少弧度才能面对公主?我们知道我们可以使用方程式 (4) 来计算角度,但首先我们需要确定巨魔和公主之间的向量 TP 并对其进行归一化。为了获得 TP,我们从 P 中减去 T,得到向量 (4,5)。为了归一化 TP,我们将其除以其大小,得到 NTP (0.62,0.78)。最后,我们将数字代入方程式 (4),如下所示:
θ = cos^(-1)(Ntp.H)
θ = cos^(-1) *((0.62 1) + (0.78 * 0))
θ = cos^(-1) (0.62)
θ = 0.902 弧度
物理学
物理学是研究物质和能量性质的科学分支。物理学的主题包括力学、热学、光和其他辐射、声音、电、磁和原子的结构。
如何做
-
时间: 时间是一个标量量(完全由其大小指定,没有方向)以秒为单位:
时间 = 距离 / 速度
-
距离: 距离的标准单位——一个标量量——是米,缩写为 m:
距离=速度 * 时间
-
质量: 质量是一个以千克为单位的标量量,缩写为 kg。质量是某种数量的度量。
-
速度: 速度是一个矢量量(一个具有大小和方向的量),表示距离随时间的变化率。速度的标准测量单位是米每秒,缩写为 m/s。这可以用以下数学公式表示:
v = ∆x / ∆t
希腊字母的大写字母 ∆,读作 delta,在数学中用来表示数量的变化。
-
加速度: 加速度是一个矢量量,表示速度随时间的变化率,其单位是每秒平方米,写作 m/s²。加速度可以用以下数学公式表示:
a = ∆v / ∆t
它是如何工作的
上述物理属性以下列方式使用:
-
时间: 在游戏中,我们经常需要评估玩家以给定速度到达某个物体所需的时间。在这种情况下,我们使用勾股定理来评估物体与玩家之间的距离,然后我们将使用 时间 = 距离/速度 公式来评估玩家以给定速度覆盖一定距离所需的时间。
-
距离: 同样,为了评估玩家与物体之间的距离,我们可以使用 距离 = 速度 * 时间 公式来评估物体与玩家之间的距离,考虑到速度和时间是已知因素。
-
质量: 这是一个在处理物理引擎时经常使用的属性。当我们想要展示具有不同运动行为的两个物体时,我们可以改变它们的质量属性。考虑这样一种情况,我们想要有两个物理对象,如子弹和球。在这种情况下,子弹的质量将比球低得多,以便即使施加很小的力或冲量,子弹也能以很高的速度移动。
-
速度: 每当我们通过矢量对任何物理体施加力时,我们通过提供大小和方向给物理对象,从而改变该对象的速度。
第九章. 自主导航代理
在本章中,我们将介绍以下食谱:
-
导航行为简介
-
实现寻找
-
实现逃离
-
实现到达
-
实现规避
-
实现徘徊
-
实现避障
-
避障
简介
游戏很有趣,因为它们在每个级别都具有挑战性。为了使游戏有趣,重要的是在每个级别增加游戏的难度。在游戏中击败某物总是很有趣的。在本章中,我们将学习创建游戏对象自主导航行为的各种方法。本章包含一系列食谱来展示人工智能游戏对象。为了详细了解所有概念,建议阅读上一章,它为您提供了数学和物理的把握。每个食谱都将使您了解和学习特定的自主导航行为。到本章结束时,您将能够理解和实现各种自主导航游戏行为。
导航行为简介
AI 角色是一种旨在用于计算机游戏以展示虚拟现实的自主导航代理。这些代理代表故事或游戏中的角色,并具有执行一些预设动作的能力。这些角色的动作由人类玩家或参与者实时指导。在游戏中,自主导航角色有时被称为非玩家或人工智能角色。AI 角色始终具有某些自主导航机器人的方面,并具有一些预定义的技能,例如他们可能会寻找游戏中的某些角色,或者他们可能会规避游戏中的角色。所有这些行为都被称为导航行为。
准备就绪
在本章中,我们将创建一个新的游戏来演示所有导航行为。为了开始实现,让我们创建一个新的项目。打开 Xcode,转到文件 | 新建 | 项目,然后选择iOS | 应用程序 | SpriteKit 游戏。在弹出的窗口中提供产品名称为SteeringBehaviors,选择设备 | iPhone,然后点击下一步,如图所示:

如何做
现在我们已经有了我们的工作样本项目,我们需要更新游戏模板项目以开始编写游戏逻辑。执行以下步骤以开始使用游戏的基本代码流程:
-
打开
GameViewController.m文件并更新viewDidLoad方法;从这个类中删除所有代码,使其看起来类似于以下代码行:- (void)viewDidLoad { [super viewDidLoad]; // Configure the view. SKView * skView = (SKView *)self.view; skView.showsFPS = YES; skView.showsNodeCount = YES; // Create and configure the scene. SKScene * scene = [GameScene sceneWithSize:skView.bounds.size]; scene.scaleMode = SKSceneScaleModeAspectFill; // Present the scene. [skView presentScene:scene]; } -
打开
GameScene.m文件;这个类创建一个场景,该场景将被插入到游戏中。现在,从这个类中删除所有代码,并添加以下函数:-(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { self.backgroundColor = [SKColor colorWithRed:0.15 green:0.15 blue:0.3 alpha:1.0]; } } -
现在,编译并运行应用程序;您应该能够正确地看到背景图像。这看起来类似于以下截图:
![如何做]()
-
现在,我们需要一个 AI 角色,我们将在这个角色上实现所有我们的 AI 行为。因此,我们将创建一个新的
SKSpriteNode子类,命名为Player。转到 文件 | 新建 | 文件,然后选择 iOS | 源 | Cocoa Touch Classes,并点击 下一步。现在,将 子类为 改为 SKSpriteNode,并将 类名 设置为Player。最后的屏幕应该类似于以下截图:![如何操作]()
-
现在,打开
Player.m文件,并在@implementation之后添加以下代码:+ (Player*) playerObject { // Create a new critter, and give it a name Player* obj = [Player spriteNodeWithColor:[SKColor whiteColor] size:CGSizeMake(30, 30)]; obj.name = @"GamePlayer"; return obj; } - (void) update:(float)deltaTime { } -
现在,在
Player.h文件中使用以下代码添加两个方法的声明:+ (Player*) playerObject; - (void) update:(float)deltaTime; -
现在,打开你的
GameScene.m文件,并在initWithSize方法之后添加以下代码:- (Player *)createPlayer { Player *plyr = [Player playerObject]; [self addChild:plyr]; return plyr; } -
现在,在
createPlayer方法之后添加touchesBegan方法。此方法将提供所有触摸事件。 -
因此,到目前为止还没有发生任何事情。让我们让游戏在第一个状态下工作。现在,添加以下代码:
Player *newPlayer = [self createPlayer]; newPlayer.position = [touch locationInNode:self];
最终的文件应该类似于以下截图:

现在,编译并运行项目。触摸任何位置,你将看到在触摸位置创建了一个方块。在点击动作中向场景中添加了多个精灵。输出应该类似于以下截图:

现在我们有一个单独的玩家类。这种方法将帮助我们隔离游戏角色的所有功能。同样,当我们将游戏提升到下一个级别时,这些方法非常有用,因为我们可以为各种类型的 AI 角色隔离行为。
实现寻找
为了实现玩家寻找行为,我们需要一个派生力,它将引导代理向目标位置移动。在寻找行为中,我们的角色会因为作用在玩家上的力更大而超过目标,这将使玩家超过目标然后返回目标。它将在一段时间后停止。
准备就绪
寻找行为类似于以下截图:

上一张图片解释了将要用于实现寻找行为的算法。在我们的情况下,我们需要寻找目标并遵循以下算法:
Vector desiredVelocity = targetVector – player.locationVector;
desiredVelocity.normalize;
*desiredVelocity = player.maxSpeed;
return (desiredVelocity – agent.locationVector);
如何操作
现在,我们将重新开始项目以实现寻找行为。现在,按照以下步骤实现寻找行为:
-
打开
Player.m文件,并在导入语句之后添加enum函数:typedef enum : NSUInteger { Seek, Arrive, Flee, Wander, Evade } SteeringBehaviorType; -
然后,在接口之后添加以下代码:
@property (assign) SteeringBehaviorType behaviourType; @property (assign) CGPoint target;enum包含我们要实现的行为。每次我们添加任何行为,我们都必须将其添加到这个enum中。目标属性将保存我们希望玩家寻找的位置,而
behaviourType将告诉我们要实现哪种行为。 -
现在,为了实现寻找,添加以下函数:
- (void) seek:(CGPoint )target deltaTime:(float)deltaTime { // Work out the direction to this position GLKVector2 myPosition = GLKVector2Make(self.position.x, self.position.y); GLKVector2 targetPosition = GLKVector2Make(target.x, target.y); GLKVector2 offset = GLKVector2Subtract(targetPosition, myPosition); // Reduce this vector to be the same length as our movement speed offset = GLKVector2Normalize(offset); offset = GLKVector2MultiplyScalar(offset, 10); [self.physicsBody applyForce:CGVectorMake(offset.x, offset.y)]; } -
现在,在更新函数中添加以下代码:
if (self.behaviourType == Seek) { [self seek:self.target deltaTime:deltaTime]; }如果玩家的行为设置为寻找,则将执行此函数。
-
现在,我们已经准备好测试之前几步中编写的函数。因此,打开
GameScene.m文件,并在实现文件顶部创建newPlayer对象的实例。实现代码应类似于以下截图:@implementation GameScene { float lastTime; Player * newplayer; SteeringBehaviorType behaviourType; } -
还需要在之前编写的
init方法中添加以下代码行:self.physicsWorld.gravity = CGVectorMake(0, 0); newplayer = [self createPlayer]; newplayer.position = CGPointMake(size.width/2, size.height/2); behaviourType = Seek; newplayer.behaviourType = behaviourType; if (behaviourType == Seek) { newplayer.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:CGSizeMake(30, 30)]; newplayer.physicsBody.friction = 1.0f; newplayer.physicsBody.linearDamping = 1.0f; }最终的初始化函数应类似于以下截图:
![如何做]()
我们已经创建了一个
newPlayer对象,并将其与物理体关联起来。 -
现在,我们的玩家已经准备好寻找目标。无论我们在屏幕上点击哪里,玩家都会寻找那个位置。在
touchesBegain:withEvent方法中,添加以下代码行:for (UITouch *touch in touches) { CGPoint location = [touch locationInNode:self]; NSLog(@"%@", NSStringFromCGPoint(location)); newplayer.target = location; } -
因此,现在我们已经准备好实现寻找行为。所以,最后,我们将在场景的每次更新时调用玩家的更新方法,以便玩家可以寻找用户点击屏幕上的位置。为此,请添加以下代码:
-(void)update:(CFTimeInterval)currentTime { /* Called before each frame is rendered */ if (!CGPointEqualToPoint(newplayer.target, CGPointZero)) { float deltaTime = currentTime - lastTime; [newplayer update:deltaTime]; lastTime = currentTime; } }在上述代码行中,我们正在对每个
newPlayer对象调用更新函数。 -
现在,编译并运行项目;你将看到玩家位于屏幕中央,并且它会寻找你点击屏幕上的位置。输出应类似于以下截图:
![如何做]()
这完成了我们对玩家对象的寻找行为。
更多内容
你可以在苹果开发者文档中详细了解所有这些属性:developer.apple.com/library/IOs/documentation/SpriteKit/Reference/SKPhysicsBody_Ref/index.html。
实现逃避
逃避行为是寻找行为的相反,它将车辆引导到目标相反的方向。我们不会产生指向目标的力,而是将玩家推离目标,因为对象必须从目标处逃离。
准备工作
为了实现逃避行为,我们需要逃离到目标,并遵循以下算法:
Vector desiredVelocity = player.locationVector - targetVector;
desiredVelocity.normalize;
desiredVelocity *= player.maxSpeed;
return (desiredVelocity – agent.locationVector);
在前面的算法中,我们正在计算将使对象从屏幕上逃离所需的力。首先,我们将计算方向向量以确定与玩家相反的方向,这样我们的对象就可以从目标处逃离。现在,在第二步中,我们将向量归一化并增加其大小到最大速度。使用此算法,我们将实现对象的逃避行为。
如何做
执行以下步骤以在游戏中实现逃避行为:
-
打开
Player.m文件,并在寻找函数之后添加以下代码行:- (void) flee:(CGPoint )target deltaTime:(float)deltaTime { // Work out the direction to this position GLKVector2 myPosition = GLKVector2Make(self.position.x, self.position.y); GLKVector2 targetPosition = GLKVector2Make(target.x, target.y); GLKVector2 offset = GLKVector2Subtract(targetPosition, myPosition); // Reduce this vector to be the same length as our movement speed offset = GLKVector2Normalize(offset); offset = GLKVector2MultiplyScalar(offset, -10); [self.physicsBody applyForce:CGVectorMake(offset.x, offset.y)]; } -
现在,在更新函数中 seek 代码之后添加以下代码:
if (self.behaviourType == Flee) { [self flee:self.target deltaTime:deltaTime]; } -
现在,我们的函数已经准备好让玩家逃离。这个函数将接受一个目标,从该目标逃离。所以,我们再次使用相同的方法,当用户点击屏幕时,我们将使对象从点击点逃离。最终的
Player.m文件应类似于以下截图:![如何操作]()
-
现在我们已经准备好了
Player.m类来执行逃离行为。所以,打开GameScene.m文件,并取以下代码行:behaviourType = Seek;然后,将其替换为以下代码行:
behaviourType = Flee;此外,还需要添加以下代码:
if (behaviourType == Seek)然后,将上述代码中的
if条件更新为:if (behaviourType == Seek || behaviourType == Flee) -
init函数应类似于以下截图:![如何操作]()
-
那就结束了。现在编译并运行项目。你应该能看到玩家,当你点击玩家附近的任何地方时,你会看到玩家会从点击的位置逃离。
还有更多
你可以在苹果开发者文档中更详细地阅读所有这些属性,链接为developer.apple.com/library/IOs/documentation/SpriteKit/Reference/SKPhysicsBody_Ref/index.html。
实现到达
到达(Arrive)与寻找(Seek)类似。寻找(Seek)和到达(Arrive)之间的唯一区别在于,在到达(Arrive)中,玩家会在目标位置停止。然而,在寻找(Seek)中,它会超过目标位置,然后再寻找。
准备工作
到达(Arrive)的技术定义是以零速度到达目标。到达行为将与寻找行为保持一致,唯一的区别是它不会超过目标。
在这种方法中,当玩家在停止半径之外时,它会以最大速度向目标移动,而一旦玩家进入停止半径,玩家的期望速度就会降至零。
如何操作
执行以下步骤以实现到达行为:
-
打开
Player.m文件,并在文件末尾添加以下代码行:- (void) arrive:(CGPoint )target deltaTime:(float)deltaTime { // Work out the direction to this position GLKVector2 myPosition = GLKVector2Make(self.position.x, self.position.y); GLKVector2 targetPosition = GLKVector2Make(target.x, target.y); GLKVector2 offset = GLKVector2Subtract(targetPosition, myPosition); // Reduce this vector to be the same length as our movement speed offset = GLKVector2Normalize(offset); offset = GLKVector2MultiplyScalar(offset, 5); // Add this to our current position CGPoint newPosition = self.position; newPosition.x += offset.x; newPosition.y += offset.y; self.position = newPosition; } -
现在,我们必须调用此函数,直到我们的玩家不在停止半径内。所以,我们将在目标点周围创建一个盒子,一旦玩家进入这个盒子,我们就停止调用到达函数。为了实现这一点,在更新方法中添加以下代码行:
if (self.behaviourType == Arrive) { int boxWidth = 20; CGRect targetRect = CGRectMake(self.target.x - boxWidth, self.target.y - boxWidth, boxWidth*2, boxWidth*2); if (!CGRectContainsPoint(targetRect, self.position)) { [self arrive:self.target deltaTime:deltaTime]; } }最终的更新函数应类似于以下截图:
![如何操作]()
-
现在,是时候检查我们在
Player.m文件中编写的到达函数了。打开GameScene.m文件。取以下代码行:behaviourType = Flee;然后,将其替换为以下代码行:
behaviourType = Arrive; -
现在,编译并运行项目;你应该能在屏幕中央看到我们的玩家。现在,点击屏幕上的任何位置为玩家提供目标。点击后,玩家将到达你点击的目标位置,并超过目标。输出应该类似于以下截图:
![如何操作]()
红色指针显示玩家必须到达的目标位置。
还有更多
你可以在苹果开发者文档中更详细地了解所有这些属性:developer.apple.com/library/IOs/documentation/SpriteKit/Reference/SKPhysicsBody_Ref/index.html。
实现规避
规避类似于逃离。逃离和规避之间的唯一区别在于,在规避中,玩家将在从目标位置逃离到安全位置后停止,而在逃离中,它只是从目标位置跑出屏幕,永远不会回来。
准备工作
技术上,规避是从目标逃到安全位置。规避行为将与逃离行为相同,唯一的区别是它不会无限逃离目标。
在这种方法中,当目标位置在玩家的安全范围内时,玩家将逃离,直到目标位置不再在其安全范围之外。因此,在我们的情况下,我们将使玩家逃离,直到远离目标,然后将其速度降至零。
如何操作
执行以下步骤以实现规避行为:
-
打开
Player.m文件,并在文件末尾添加以下代码行:- (void) evade:(CGPoint )target deltaTime:(float)deltaTime { GLKVector2 myPosition = GLKVector2Make(self.position.x, self.position.y); GLKVector2 targetPosition = GLKVector2Make(target.x, target.y); GLKVector2 offset = GLKVector2Subtract(targetPosition, myPosition); // Reduce this vector to be the same length as our movement speed offset = GLKVector2Normalize(offset); // Note the minus sign - we're multiplying by the inverse of our movement speed, // which means we're moving away from it offset = GLKVector2MultiplyScalar(offset, -5); // Add this to our current position CGPoint newPosition = self.position; newPosition.x += offset.x; newPosition.y += offset.y; self.position = newPosition; } -
现在,在
update方法中添加以下代码:if (self.behaviourType == Evade) { int boxWidth = 100; CGRect targetRect = CGRectMake(self.target.x - boxWidth, self.target.y - boxWidth, boxWidth*2, boxWidth*2); if (CGRectContainsPoint(targetRect, self.position)) { [self evade:self.target deltaTime:deltaTime]; } } -
之后,更新函数应该看起来类似于以下截图:
![如何操作]()
在前面的函数中,我们在目标位置向量上绘制了一个矩形。每当玩家在这个矩形内时,就不安全。因此,它将逃离以将玩家移出矩形到安全位置。因此,我们将执行我们的规避函数,直到玩家在矩形内。
-
现在,是时候测试我们在
Player.m中编写的规避函数了。所以,打开GameScene.m文件。输入以下代码行:behaviourType = Arrive;然后,将此替换为以下代码行:
behaviourType = Evade; -
最终的
init方法应该类似于以下截图:![如何操作]()
-
现在,编译并运行项目以查看对象玩家在动作中的表现。点击玩家附近的任何位置,它将逃离以保持与你点击的位置一定的距离,如图所示:
![如何操作]()
还有更多
你可以在苹果开发者文档中详细了解所有这些属性,网址为developer.apple.com/library/IOs/documentation/SpriteKit/Reference/SKPhysicsBody_Ref/index.html。
实现游荡
你可能在游戏中经常观察到,一些角色只是随机地在他们的环境中移动。这些角色正在等待某个事件发生。例如,在任何战争游戏中,敌军士兵只是在城堡里四处游荡,试图捕捉玩家,他们会一直游荡,直到找到玩家。一旦玩家进入他们的附近,他们就会改变行为去寻找。因此,角色的游荡能力使它们看起来更加愉悦和逼真。
使游戏对象遵循路径将使其显得不真实,并会影响整体游戏玩法,使其更具预测性。因此,这些游荡行为为游戏增添了更多的乐趣和逼真性。
游荡转向行为产生逼真的移动,使玩家感觉角色只是在行走,并使整个环境感觉更加生动。
准备工作
实现游荡行为有多种方法。如下所示:
-
使用寻找和随机性来实现游荡。在这种方法中,游荡结合了两种行为,寻找和随机性。这意味着技术上,游荡只是在世界中的某些随机点和目标上寻找。
-
第二种方法是在角色前方评估一个虚拟点,并在其前方画一个圆,然后获取圆周上的点。现在,让对象寻找该位置。以下图像将更好地解释这种方法:
![准备工作]()
我们将实现第一种方法,因此我们将遵循以下方法:
Get targetVector
while (true) {
Seek (targetVector);
get anotherTargetVector;
}
如何做
现在,我们将实现游荡行为,并将我们的项目推进得更远。按照以下步骤实现游荡行为:
-
打开
Player.m文件,并在文件末尾添加以下代码行:int myRandom() { return (arc4random() % 2 ? 1 : -1); } - (void)wanderWithDeltaTime:(float)deltaTime { int boxWidth = 20; CGRect targetRect = CGRectMake(self.target.x - boxWidth, self.target.y - boxWidth, boxWidth*2, boxWidth*2); if (!CGRectContainsPoint(targetRect, self.position)) { [self seek:self.target deltaTime:deltaTime]; } else { int offsetX = self.scene.size.width; int offsetY = self.scene.size.height; self.target = CGPointMake(arc4random() % offsetX, arc4random() % offsetY); } }我们已经实现了本节开头看到的算法。在这段代码中,我们在玩家前方获取一个随机局部点,然后让玩家寻找该位置。这将产生游荡行为。
-
现在,在
update方法中添加以下代码行:if (self.behaviourType == Wander) { [self wanderWithDeltaTime:deltaTime]; }最终的
update函数应类似于以下截图:![如何做]()
-
现在,是时候测试我们在
Player.m中编写的evade函数了。因此,打开GameScene.m文件,并添加以下代码行:behaviourType = Evade;然后,将其替换为以下代码行:
behaviourType = Wander;还在寻找和逃跑的 if 条件之后添加以下 if 条件:
if (behaviourType == Wander) { newplayer.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:CGSizeMake(30, 30)]; SKPhysicsBody* borderBody = [SKPhysicsBody bodyWithEdgeLoopFromRect:self.frame]; self.physicsBody = borderBody; self.physicsBody.friction = 0.0f; newplayer.physicsBody.friction = 1.0f; newplayer.physicsBody.linearDamping = 1.0f; }最终文件应类似于以下截图:
![如何做]()
-
现在,编译并运行项目以查看玩家对象的实际效果。点击屏幕上的任何位置,玩家将开始在屏幕上游荡,如下面的截图所示:
![如何实现]()
还有更多
你可以在苹果开发者文档中更详细地阅读所有这些属性:developer.apple.com/library/IOs/documentation/SpriteKit/Reference/SKPhysicsBody_Ref/index.html。
实现墙壁避障
如果 AI 角色在游荡时撞到墙壁,它们看起来会有些奇怪。因此,我们必须使它们更加智能,以便它们可以寻找墙壁并相应地做出反应或改变方向。
游荡行为返回一个力,将 AI 从墙壁推开以避免碰撞。
准备中
将采用以下方法来实现墙壁避障行为:
-
创建感知墙壁的触须
-
我们将在 AI 前方使用一个触须来感知墙壁
-
当检测到墙壁时,在反射向量上施加力
如何实现
按照以下算法在项目中实现墙壁避障行为:
-
该技术的视觉解释如下截图所示:
![如何实现]()
-
创建将感知墙壁的触须:
Front_Feeler = player->Get velocity; // vector Front_Feeler = Front_Feeler.normalize(); // vector这将在玩家前方投射触须。
Front_Feeler = Front_Feeler * FeelerLength; // vector Front_Feeler = Front_Feeler + player->location; -
现在我们已经得到了
Front_Feeler,让我们在相同方向上施加力。这个力会将玩家推开墙壁。 -
为了进一步微调行为,你还可以在玩家两侧额外添加两个触须。这将帮助玩家进行更平滑的转弯并看起来更真实。
避障
如果游戏对象表现出群体行为,那么避免彼此碰撞非常重要。此外,路径上可能有多个障碍物,角色必须智能地避开。
准备中
将采用以下方法来实现避障行为:
-
在玩家对象前方创建三个触须
-
让所有三个触须感知路径上的障碍物
-
如果触须感知到任何障碍物,则将玩家重新定向到相反方向
如何实现
按照以下算法在项目中实现避障行为。
-
该技术的视觉解释如下截图所示:
![如何实现]()
-
创建将感知墙壁的触须:
Front_Feeler = player->Get velocity; // vector Front_Feeler = Front_Feeler.normalize(); // vector Left_Feeler = player->Get velocity; // vector Left_Feeler = Left_Feeler.normalize(); // vector Right_Feeler = player->Get velocity; // vector Right_Feeler = Right_Feeler.normalize(); // vector This will project the feeler in front of the player. Front_Feeler = Front_Feeler * FeelerLength; // vector Front_Feeler = Front_Feeler + player->location; Left_Feeler = Left_Feeler * FeelerLength; // vector Left_Feeler = Left_Feeler + player->location; Left_Feeler->x = Left_Feeler -> x - player-> width; Right_Feeler = Right_Feeler * FeelerLength; // vector Right_Feeler = Right_Feeler + player->location; Right_Feeler->x = Right_Feeler -> x + player-> width;现在我们有了左、右和前触须。所以,当这些触须感知到它们路径上的任何物体时,它们会将玩家推开障碍物。
还有更多
有许多其他转向行为可以使游戏变得非常有趣。还有各种群体行为,在亲身体验这些行为之后可以进行探索。以下是一些行为:
-
集群行为:当物体以某种共同的行为在群体中表现时,这种行为被称为集群行为
-
对齐:当物体表现出某种导致特定特征与附近的代理对齐的行为时,这被称为对齐
-
凝聚力:在这种行为中,物体被引导向所有物体的质量中心——即,在一定半径内代理的平均位置
-
分离:在这种行为中,物体被引导远离所有邻居
你可以在以下链接中了解更多关于这些行为的信息:
第十章. 使用 OpenGL 进行 3D 游戏编程
在本章中,我们将关注以下食谱:
-
介绍 OpenGL
-
使用 OpenGL 构建一个迷你三维动画游戏
简介
在前面的章节中,你深入学习了物理模拟的结构。现在,我们将探索游戏中最有趣的部分,即向你的游戏中添加三维对象。在本章中,我们将开始探索 OpenGL 的基础知识。然后,我们将通过制作一些三维模型逐步深入探索 OpenGL。在本章中,我们将通过使用二维模型项目开始学习,然后我们将增强项目以适应三维模型。
介绍 OpenGL
OpenGL 代表开放图形库。这是一个广泛使用的库,用于可视化二维和三维对象。这是一个标准的多功能二维和三维内容创建图形库。它在各种领域中使用,如机械设计、建筑设计、游戏、原型设计、飞行模拟等。OpenGL 用于配置和提交三维图形数据到设备。所有数据都以矩阵的形式准备,并转换为顶点,这些顶点被转换和组装以生成二维光栅化图像。二维图形有两个轴,即 x 轴和 y 轴;然而,在三维图形的情况下,我们有三维轴,即 x 轴、y 轴和 z 轴,其中 z 轴是深度轴。
这个库旨在将普通函数调用编译成图形命令,这些命令将在图形渲染硬件上执行。所有图形硬件都设计用来执行图形命令。OpenGL 以非常快的速度绘制和渲染视图。
准备工作
Xcode 提供了一个内置的 OpenGL-ES 项目模板;然而,我们认为对于初学者来说,从该模板开始可能会感到困惑。一种典型的方法是逐步编写代码,以便理解代码的工作方式和每个函数的功能。
在这里,我们将从头开始编写所有代码,这将帮助你理解使用 OpenGL 渲染视图时每行代码的细节。要添加框架,请执行以下步骤:
-
启动 Xcode。创建一个单视图应用程序,点击下一步。将你的项目命名为 OpenGLSample,点击下一步,选择一个文件夹来保存它,然后点击创建。
-
现在,我们将向项目中添加所有必需的框架。要添加框架,第一步是添加我们 OpenGL 项目所需的两个框架。它们是
OpenGLES.frameworks和GLKit.framework。 -
要在 Xcode 6 中添加这些框架,请单击 Groups & Files 树中的 OpenGLSample 项目,并选择OpenGLSample目标。展开Link Binary with the Libraries部分,单击加号按钮,并选择OpenGLES.framework。同样,为GLKit.framework也重复此操作,如下所示:
![准备就绪]()
如何操作
要在我们的应用程序中创建三维纹理,请执行以下步骤:
-
打开
Main.storyboard文件并选择视图控制器。您将看到在视图控制器中已经添加了一个视图。 -
选择视图,打开 Identity 检查器,并将视图的类设置为
GLKView,如下所示:![如何操作]()
-
打开
ViewController.h,导入GLKit/GLKit.h,并将视图控制器的主类从UIViewController更改为GLKViewController。GLKViewController除了提供所有原生视图控制器功能外,还提供了 OpenGL 渲染循环。代码如下:#import"GLKit/GLKit.h" @interface ViewController : GLKViewController -
修改
viewController.m。在viewDidLoad中添加以下代码:GLKView* view = (GLKView*)self.view; view.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2]; -
添加以下方法:
(void)glkView:(GLKView *)view drawInRect:(CGRect)rect{ glClearColor(0.0, 0.5, 0.0, 1.0); glClear(GL_COLOR_BUFFER_BIT); }代码的第一行调用
glClearColor,这实际上告诉 OpenGL 将清除颜色设置为 RGBA 值(0.0,0.5,0.0,1.0)——即完全不透明,深绿色。下一行指示 OpenGL 实际清除颜色缓冲区——即,它用上一行设置的清除颜色填充整个屏幕。 -
现在,编译并运行项目。您应该会看到以下截图类似的内容:
![如何操作]()
-
现在,是时候创建 OpenGL 上下文了。在 OpenGL 中绘制三角形比绘制正方形更容易,因为三角形总是共面的,也就是说,形状中的所有点都在同一个平面上。因此,要绘制一个正方形,我们首先绘制两个共享边的三角形。
-
为了通知 OpenGL 顶点的位置,我们使用一个结构体来表示顶点和数组。这将稍后在
GLView中显示如下:typedef struct { GLKVector3 position; } Vertex; const Vertex SquareVertices[] = { {-1, -1 , 0},// vertex 0: bottom left {1, -1 , 0}, // vertex 1: bottom right {1, 1 , 0}, // vertex 2: top right {-1, 1 , 0}, // vertex 4: top left }; -
现在我们需要定义哪个三角形使用哪个顶点。在 OpenGL 中,我们通过为每个顶点编号,然后每次给出三个数字来描述三角形:
const GLubyte SquareTriangles[] = { 0, 1, 2, // BL->BR->TR 2, 3, 0 // TR->TL->BL };在这种情况下,
Glubyte是第一个三角形使用顶点0、1和2,第二个三角形使用顶点2、3和0的类型。请注意,两个三角形都使用了顶点0和2。这意味着它们共享一条边,这意味着两个三角形之间不会有任何间隙。 -
SquareVertices和SquareTriangles数组都需要存储在缓冲区中,以便 OpenGL 可以使用它们进行渲染,如下所示:@interface ViewController () { GLuint _vertexBuffer; GLuint _indexBuffer; GLKBaseEffect* _squareEffect; } @end -
首先,我们使用 OpenGL 上下文设置
GLKView,如下所示。因为,如果我们不这样做,我们的所有 OpenGL 命令都不会有任何作用。GLKView* view = (GLKView*)self.view; view.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2]; [EAGLContext setCurrentContext:view.context]; -
接下来,我们从顶点缓冲区开始创建缓冲区:
glGenBuffers(1, &_vertexBuffer); glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer); -
然后将顶点缓冲区填充顶点信息:
glBufferData(GL_ARRAY_BUFFER, sizeof(SquareVertices), SquareVertices, GL_STATIC_DRAW); -
然后对索引缓冲区做同样的事情,你可能还记得它存储了两个三角形将使用哪些顶点的信息:
glGenBuffers(1, &_indexBuffer); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, _indexBuffer); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(SquareTriangles), SquareTriangles, GL_STATIC_DRAW);一旦完成,所有信息都已传递给 OpenGL。GLKit 提供了一些效果,这些是包含颜色、位置、方向等信息的对象。在这个活动中,我们将使一个正方形变成红色,并将其显示在屏幕中间。
-
第一步是创建效果对象,然后向其提供一个投影矩阵。投影矩阵控制屏幕上物体的整体大小。在这种情况下,我们创建了一个使用屏幕宽高比和 60 度视场的投影矩阵:
_squareEffect = [[GLKBaseEffect alloc] init]; float aspectRatio = self.view.bounds.size.width/self.view.bounds.size.height; float fieldOfViewDegrees = 60.0; GLKMatrix4 projectionMatrix = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(fieldOfViewDegrees),aspectRatio, 0.1, 10.0); _squareEffect.transform.projectionMatrix = projectionMatrix; -
现在,我们需要提供一个模型视图矩阵。模型视图矩阵控制对象相对于摄像机的位置:
GLKMatrix4 modelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -6.0f); _squareEffect.transform.modelviewMatrix = modelViewMatrix; //Set the constant color red for the effects. _squareEffect.useConstantColor = YES; _squareEffect.constantColor = GLKVector4Make(1.0, 0.0, 0.0, 1.0); -
在
viewDidLoad方法之后创建drawInRect方法:- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect { } -
实际的渲染工作是在
glkView:drawInRect方法中完成的。在这个过程中发生的第一件事是清除视图,通过用黑色填充屏幕:glClearColor(0.0, 0.0, 0.0, 1.0); glClear(GL_COLOR_BUFFER_BIT); -
现在调用
prepareToDraw。它以这种方式配置 OpenGL,使得我们绘制的任何内容都将使用效果的设置:[_squareEffect prepareToDraw]; -
我们首先告诉 OpenGL 我们将要处理位置,然后告诉 OpenGL 在顶点数据中找到位置信息的位置:
glEnableVertexAttribArray(GLKVertexAttribPosition); glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, 0, 0); -
最后,我们需要知道我们要求 OpenGL 绘制多少个顶点。这可以通过取整个索引数组的大小,并将其除以该数组中一个元素的大小来得出:
int numberOfTriangles = sizeof(SquareTriangles)/sizeof(SquareTriangles[0]); glDrawElements(GL_TRIANGLES, numberOfTriangles, GL_UNSIGNED_BYTE, 0);
因此,我们的最终实现类可能看起来像以下代码:
#import "ViewController.h"
typedef struct {
GLKVector3 position;
} Vertex;
const Vertex SquareVertices[] = {
{-1, -1 , 0}, {1, -1 , 0}, {1, 1 , 0}, {-1, 1 , 0}
};
const GLubyte SquareTriangles[] = {
0, 1, 2,
2, 3, 0
};
@interface ViewController () {
GLuint _vertexBuffer;
GLuint _indexBuffer;
GLKBaseEffect* _squareEffect;
}
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
GLKView* view = (GLKView*)self.view;
view.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
[EAGLContext setCurrentContext:view.context];
glGenBuffers(1, &_vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(SquareVertices), SquareVertices,
GL_STATIC_DRAW);
glGenBuffers(1, &_indexBuffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, _indexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(SquareTriangles),
SquareTriangles, GL_STATIC_DRAW);
_squareEffect = [[GLKBaseEffect alloc] init];
float aspectRatio = self.view.bounds.size.width/self.view.bounds.size.height;
float fieldOfViewDegrees = 60.0;
GLKMatrix4 projectionMatrix = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(fieldOfViewDegrees),aspectRatio, 0.1, 10.0);
_squareEffect.transform.projectionMatrix = projectionMatrix;
GLKMatrix4 modelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -6.0f);
_squareEffect.transform.modelviewMatrix = modelViewMatrix;
_squareEffect.useConstantColor = YES;
_squareEffect.constantColor = GLKVector4Make(1.0, 0.0, 0.0, 1.0);
}
- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
glClearColor(0.0, 0.0, 0.0, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
[_squareEffect prepareToDraw];
glEnableVertexAttribArray(GLKVertexAttribPosition);
glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, 0, 0);
int numberOfVertices = sizeof(SquareTriangles)/sizeof(SquareTriangles[0]);
glDrawElements(GL_TRIANGLES, numberOfVertices, GL_UNSIGNED_BYTE, 0);
}
@end
运行项目,你将在黑色屏幕上看到红色的正方形框,如下面的截图所示:

使用 OpenGL 构建一个迷你 3D 动画游戏
在这个菜谱中,我们将加载一个纹理并将其应用到正方形上。稍后,我们将制作一个立方体,最后我们将学习如何通过在三维空间中旋转我们的立方体来实现三维动画。
如何操作
现在,我们将从之前留下的地方开始,并加载所有纹理。要加载纹理,请遵循以下步骤:
-
首先,在我们的顶点结构中,我们需要包含纹理坐标信息:
typedef struct { GLKVector3 position; // the location of each vertex in space GLKVector2 textureCoordinates; // the texture coordinates for each vertex } Vertex; const Vertex SquareVertices[] = { {{-1, -1 , 0}, {0,0}}, // bottom left {{1, -1 , 0}, {1,0}}, // bottom right {{1, 1 , 0}, {1,1}}, // top right {{-1, 1 , 0}, {0,1}}, // top left }; -
接下来,在我们的项目中添加一个图像(任何图像),将其重命名为
Texture.png,然后在viewDidLoad中添加以下代码:NSString* imagePath = [[NSBundle mainBundle] pathForResource:@"Texture" ofType:@"png"]; NSError* error = nil; GLKTextureInfo* texture = [GLKTextureLoader textureWithContentsOfFile:imagePath options:nil error:&error]; if (error != nil) { NSLog(@"Problem loading texture: %@", error); } _squareEffect.texture2d0.name = texture.name; -
要修改之前正方形的颜色,请删除以下行:
_squareEffect.useConstantColor = YES; _squareEffect.constantColor = GLKVector4Make(1.0, 0.0, 0.0, 1.0); -
最后,在
glkView:drawInRect中渲染时,我们指示 OpenGL 在顶点信息中找到纹理坐标的位置:glEnableVertexAttribArray(GLKVertexAttribTexCoord0); glVertexAttribPointer(GLKVertexAttribTexCoord0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, textureCoordinates));当正方形被渲染时,你会在其上看到你的图像,如下面的截图所示:
![如何操作]()
-
现在我们将其制作成一个立方体。立方体由八个顶点组成,因此我们需要为每个顶点提供信息,包括其位置和纹理坐标。从现在开始,将
SquareVertices重命名为CubeVertices以遵循更好的命名约定。const Vertex CubeVertices[] = { {{-1, -1, 1}, {0,0}}, // bottom left front {{1, -1, 1}, {1,0}}, // bottom right front {{1, 1, 1}, {1,1}}, // top right front {{-1, 1, 1}, {0,1}}, // top left front {{-1, -1, -1}, {1,0}}, // bottom left back {{1, -1, -1}, {0,0}}, // bottom right back {{1, 1, -1}, {0,1}}, // top right back {{-1, 1, -1}, {1,1}}, // top left back }; const GLubyte CubeTriangles[] = { 0, 1, 2, // front face 1 2, 3, 0, // front face 2 4, 5, 6, // back face 1 6, 7, 4, // back face 2 7, 4, 0, // left face 1 0, 3, 7, // left face 2 2, 1, 5, // right face 1 5, 6, 2, // right face 2 7, 3, 6, // top face 1 6, 2, 3, // top face 2 4, 0, 5, // bottom face 1 5, 1, 0, // bottom face 2 }; -
下一个步骤完全是出于美观考虑:立方体将会旋转,以展示它实际上是一个三维物体。
选择以下行:
GLKMatrix4 modelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -6.0f); _squareEffect.transform.modelviewMatrix = modelViewMatrix;替换为以下内容:
GLKMatrix4 modelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -6.0f); modelViewMatrix = GLKMatrix4RotateX(modelViewMatrix,GLKMathDegreesToRadians(45)); modelViewMatrix = GLKMatrix4RotateY(modelViewMatrix,GLKMathDegreesToRadians(45)); _squareEffect.transform.modelviewMatrix = modelViewMatrix;然而,为了绘制我们的立方体,需要添加并启用深度缓冲区。深度缓冲区是提供三维和更逼真外观的对象所必需的。
-
在调用 EAGLContext 的
setCurrentContext方法后立即添加以下代码:view.drawableDepthFormat = GLKViewDrawableDepthFormat24; glEnable(GL_DEPTH_TEST); -
最后,将
glClear(GL_COLOR_BUFFER_BIT);行替换为glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);行。 -
编译并运行项目以查看三维立方体,如图所示:
![如何操作]()
-
现在我们将把它提升到下一个层次,通过添加代码来旋转一个立方体。现在我们将动画化视图中的移动,例如旋转。在接口部分将以下实例变量添加到
ViewController类中:float rotation; -
接下来,将以下方法添加到类中:
- (void) update { NSTimeInterval timeInterval = self.timeSinceLastUpdate; float rotationSpeed = 15 * timeInterval; rotation += rotationSpeed; GLKMatrix4 modelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -6.0f); modelViewMatrix = GLKMatrix4RotateX(modelViewMatrix, GLKMathDegreesToRadians(45)); modelViewMatrix = GLKMatrix4RotateY(modelViewMatrix, GLKMathDegreesToRadians(rotation)); _squareEffect.transform.modelviewMatrix = modelViewMatrix; } -
现在,更新函数应该看起来类似于以下截图:
![如何操作]()
-
现在编译并运行应用程序。您会发现您的立方体以我们指定的角度旋转,如图所示:
![如何操作]()
参考以下内容
到目前为止,我们已经学习了使用 OpenGL 创建二维和三维模型的各种方法。OpenGL 是三维游戏编程的骨架,因此是一个非常广泛的主题。我们只是对 OpenGL 进行了简要的浏览,要了解更多信息,您可以参考 developer.apple.com/opengl/。
第十一章。开始多人游戏
在本章中,我们将关注以下食谱:
-
多人游戏的结构
-
多人游戏的设置
-
为玩家分配角色
简介
到目前为止,在本书中,我们已经做了很多与游戏相关酷炫的事情,例如 SpriteKit、视差滚动背景、使用自主移动代理的物理模拟、使用 OpenGL 进行三维游戏编程等等。所有这些都是为了制作单人游戏,意味着一次只能有一个人玩。但现在,我们将向前迈进,制作一个多人游戏,这个游戏可以同时吸引多个人。多人游戏本身对用户来说就更有吸引力、更有趣,因为实时竞争加入了进来,使得游戏体验对用户来说更加愉快。所以,现在是时候了解与多人游戏相关的内容了。在第十二章《实现多人游戏》中,我们将创建一个多人游戏。为了游览多人游戏开发,整体议程将分为以下部分:
-
创建一个示例多人游戏以了解多人游戏的结构和各种状态。
-
使用 SpriteKit 和苹果的 Multipeer Connectivity 框架设置相同的多人游戏。之后,使用同一框架的
MCBrowserViewController进行玩家之间的握手或连接建立。 -
通过发送和接收网络数据包为玩家分配角色。
多人游戏的结构
在单人游戏中,只有一个玩家,所以谈论游戏作为一个维护所有游戏行为的对象,而如果我们理解多人游戏的结构,我们会看到它完全不同。在多人游戏中,有多个玩家在玩同一款游戏,所以从技术上讲,对于每个设备,都有一个玩家正在积极驱动该设备上的游戏。这被称为本地玩家,而所有其他玩家都被视为该设备的远程玩家。理想情况下,本地玩家的活动应该更新在远程玩家的设备上,这是多人开发中最主要的挑战。本地玩家的更新被称为在其他设备上同步游戏,这是由游戏对象完成的,该游戏对象位于游戏中。游戏对象(即运行在设备上的游戏实例)的责任是使所有设备上的游戏看起来与实时游戏一样。
因此,在接下来的这一节中,我们将创建一个全新的多人游戏,名为 TankRace,使用 SpriteKit,其中将实例化游戏会话。我们将结合多人游戏状态及其解释和必要性。所有会话和多人相关的过程都将使用 iOS 7 中引入的 Multipeer Connectivity 框架完成,该框架是 iOS 6 中 GameKit 的一部分。
准备工作
要使用 SpriteKit 开发坦克大战多人游戏,首先创建一个新的项目。打开 Xcode,转到文件 | 新建 | 项目 | iOS | 应用程序 | SpriteKit 游戏。在弹出的窗口中,将产品名称输入为TankRace,转到设备 | iPhone,然后点击下一步,如下截图所示:

点击下一步,并将项目保存在你的硬盘上。
一旦项目保存,你应该能够看到项目设置。在项目设置页面,只需从设备方向部分勾选纵向,并取消勾选所有其他选项,因为我们只支持这款游戏的纵向模式。同时将部署目标设置为 7.0,以便支持一系列设备。
变化如下所示:

让我们更仔细地看看 SpriteKit 提供的是什么结构:

在GameViewController的viewDidLoad方法中,编写了一段代码,将其视图转换为SKView,并在SKView上呈现一个场景,即GameScene,如下所示。项目本身实现了unarchiveFromFile方法来获取GameScene.sks文件,我们可以在创建的项目中看到它。为了不显示 FPS 和节点,注释掉以下代码中的两行:
- (void)viewDidLoad
{
[super viewDidLoad];
// Configure the view.
SKView * skView = (SKView *)self.view;
// skView.showsFPS = YES;
// skView.showsNodeCount = YES;
/* Sprite Kit applies additional optimizations to improve rendering performance */
skView.ignoresSiblingOrder = YES;
// Create and configure the scene.
GameScene *scene = [GameScene unarchiveFromFile:@"GameScene"];
scene.scaleMode = SKSceneScaleModeAspectFill;
// Present the scene.
[skView presentScene:scene];
}
如何操作...
在开始多人游戏代码之前,我们应该让游戏为多人游戏做好准备。首先,进入GameScene类,并在通常设置场景的覆盖方法didMoveToView中删除示例SKLabelNode添加代码。其次,从touchesBegan:withEvent方法中删除for循环,该方法负责添加SKSpriteNode及其动作。
我们的项目现在可以开始多人游戏了。多人游戏可以通过多种方式开发。它们可以通过蓝牙、Wi-Fi、互联网或 GameCenter 进行游戏。所有这些技术都允许我们互联设备并在设备之间共享数据。这使我们能够实时显示玩家的移动。你可能已经看到了多人游戏中的响应速度。它们非常流畅。在本节中,我们将探讨更多关于多人游戏及其在 iOS 中的实现。在这里,我们将为本地玩家实例化一个会话(即 MCSession),该会话将进一步连接到本食谱中的另一个玩家。此外,为了指导用户触摸,我们将添加一个信息标签,显示“点击连接”,并进一步实现 MCSession 的代理,随后将解释多人游戏状态。以下是实现此任务的步骤:
-
打开
GameScene.m文件,创建一个具有InfoLabel属性和所有相关会话内容的接口。同时让GameScene遵循MCSessionDelegate,接口看起来如下:@interface GameScene() <MCSessionDelegate> @property (nonatomic, strong) MCSession* gameSession; @property (nonatomic, strong) MCPeerID* gamePeerID; @property (nonatomic, strong) NSString* serviceType; @property (nonatomic, strong) MCAdvertiserAssistant* advertiser; @property (nonatomic, strong) SKLabelNode* gameInfoLabel; @end在这里,
gameSession是用于玩多人游戏的会话,gamePeerID是本地玩家的唯一 ID,在将来将作为连接到此设备的远程玩家的唯一 ID。这就是为什么它被称为 peerID。ServiceType是特别分配给游戏的唯一 ID;在这里,服务类型将是 TankRace,而广告商是一个处理所有传入邀请给用户的类,并处理所有用户响应的类。声明了一个gameInfoLabel属性,它将被创建来指导用户与其他玩家连接。 -
添加一个名为
addGameInfoLabelWithText的方法,它可以用来显示任何带有pragma标记的 GameInfo。#pragma mark - Adding Assets Methods - (void)addGameInfoLabelWithText:(NSString*)labelText { if (self.gameInfoLabel == nil) { self.gameInfoLabel = [SKLabelNode labelNodeWithFontNamed:@"Chalkduster"]; self.gameInfoLabel.text = labelText; self.gameInfoLabel.fontSize = 32; self.gameInfoLabel.position = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame)); self.gameInfoLabel.zPosition = 100; [self addChild:self.gameInfoLabel]; } } -
为不同的 GameInfo 文本声明哈希定义。
#define kConnectingDevicesText @"Tap to Connect" #define kGameStartedText @"Game Started" #define kConnectedDevicesText @"Devices Connected" -
从
GameScene的didMoveToView方法中调用addGameInfoLabelWithText。使用文本哈希定义kConnectingDevicesText和pragma标记,如下所示。#pragma mark - Overridden Methods -(void)didMoveToView:(SKView *)view { /* Setup your scene here */ [self addGameInfoLabelWithText:kConnectingDevicesText]; } -
在
GameScene的私有接口中声明一个enum,GameState,以及与之对应的属性。同时,将游戏初始状态设置为kGameStatePlayerToConnect,因为要开始多人游戏,玩家首先需要连接才能玩游戏。将这些行添加到哈希定义之上:typedef enum { kGameStatePlayerToConnect, kGameStatePlayerAllotment, kGameStatePlaying, kGameStateComplete, } GameState; -
在
GameScene的私有接口中添加此gameState属性:@property (nonatomic, assign) GameState gameState; -
在
GameScene的didMoveToView中将gameState赋值为kGameStatePlayerToConnect:self.gameState = kGameStatePlayerToConnect; -
创建一个名为
instantiateMCSession的方法,并添加如下代码中的pragma标记:#pragma mark - Networking Related Methods - (void)instantiateMCSession { if (self.gameSession == nil) { UIDevice *device = [UIDevice currentDevice]; MCPeerID* peerID = [[MCPeerID alloc] initWithDisplayName:device.name]; self.gameSession = [[MCSession alloc] initWithPeer:peerID]; self.gameSession.delegate = self; self.serviceType = @"TankFight"; // should be unique self.advertiser = [[MCAdvertiserAssistant alloc] initWithServiceType:self.serviceTypediscoveryInfo:nil session:self.gameSession]; [self.advertiser start]; } }pragma marks. Using pragma marks we can make our code much more readable and also provide logical grouping to our methods. It is a good programming practice to follow. -
实现所有具有以下
pragma标记的MCSessionDelegate:#pragma mark - MCSessionDelegate Methods - (void)session:(MCSession *)session peer:(MCPeerID *)peerID didChangeState:(MCSessionState)state { // A peer has changed state - it's now either connecting, connected, or disconnected. if (state == MCSessionStateConnected) { NSLog(@"state == MCSessionStateConnected"); } else if (state == MCSessionStateConnecting) { NSLog(@"state == MCSessionStateConnecting"); } else if (state == MCSessionStateNotConnected) { NSLog(@"state == MCSessionStateNotConnected"); } } - (void)session:(MCSession *)session didReceiveData:(NSData *)data fromPeer:(MCPeerID *)peerID { // Data has been received from a peer. // Do something with the received data, on the main thread [[NSOperationQueue mainQueue] addOperationWithBlock:^{ // Process the data }]; } - (void)session:(MCSession *)session didStartReceivingResourceWithName:(NSString *)resourceName fromPeer:(MCPeerID *)peerID withProgress:(NSProgress *)progress { // A file started being sent from a peer. (Not used in this example.) } - (void)session:(MCSession *)session didFinishReceivingResourceWithName:(NSString*)resourceName fromPeer:(MCPeerID *)peerID atURL:(NSURL *)localURL withError:(NSError *)error { // A file finished being sent from a peer. (Not used in this example.) } - (void)session:(MCSession *)session didReceiveStream:(NSInputStream *)stream withName:(NSString *)streamName fromPeer:(MCPeerID *)peerID { // Data started being streamed from a peer. (Not used in this example.) }这些都是在
GameScene类中实现的MCSession的代理方法,其中前两种使用得最多。第一个用于确定游戏状态的变化,例如,是否已连接、正在连接或未连接。后者用于接收数据,因此可以在上述实现中的操作队列块中处理这些数据。 -
现在根据
GameScene的gameState,在touchBegan:withEvent中添加instantiateMCSession,并使用pragma标记。#pragma mark - Touch Methods -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { /* Called when a touch begins */ if (self.gameState == kGameStatePlayerToConnect) { [self instantiateMCSession]; } else if (self.gameState == kGameStatePlaying) { } }在
touchesBegan方法中,如果状态是kGameStatePlayerToConnect,则表示用户已触摸以开始游戏,即技术上需要完成玩家的连接,而在游戏的其他状态下,将根据触摸相应地处理。经过所有这些步骤,已经完成了游戏初始会话的设置,并理解了多人游戏架构。
它是如何工作的...
在前面的设置中,我们使用了 Multipeer Connectivity 框架,通过 MCSession 实例来设置一个多人游戏的结构,这个实例将存在于每个用于玩游戏设备上。我们还实现了所有其代理方法,这些方法会通知 GameScene 游戏状态的变化,也将在接收来自某些网络数据包的传入部分时使用。现在,在这一节中,我们放置了一个标签 Tap to connect,点击屏幕时将实例化一个会话。现在构建项目。首先你会看到以下启动屏幕,然后是带有标签 Tap to connect 的初始 GameScene:

多人游戏的设置
在这个菜谱中,我们将编写设置我们的多人游戏的代码。所有配置和会话管理器都将包含在本节中。我们将深入研究创建和维护会话的各种概念。
准备工作
在开始这个菜谱之前,我们应该了解 Multipeer Connectivity 框架中的 MCSession、MCPeerId、广告商和服务类型术语。在这个菜谱中,我们将建立玩家之间的连接,从而他们可以在未来进行通信,让玩家玩游戏,我们将在下一章中这样做。
如何操作
现在,点击屏幕后,已经实例化了一个具有服务类型的 MCSession;我们可以使用这个会话和服务类型来展示 MCBrowserViewController 并在玩家(即设备)之间建立连接。MCBrowserViewController 是完全配备和设计用于连接 Multipeer Connectivity 框架中提供的会话的多个玩家。以下是涉及的步骤:
-
首先,创建一个
GameScene的协议GameSceneDelegate和其在GameScene中的代理对象,该对象将被设置为GameViewController以便在用户触摸屏幕时使用其代理方法。GameViewController可以被通知展示MCBrowserViewController。声明协议代码和GameSceneDelegate对象,如下所示:@protocol GameSceneDelegate <NSObject> - (void)showMCBrowserControllerForSession:(MCSession*)sessionserviceType:(NSString*)serviceType; @end @property (nonatomic, weak) id<GameSceneDelegate> gameSceneDelegate; -
当用户触摸的屏幕上的
gameState为kGameStatePlayerToConnect时,我们调用instantiateMCSession方法,该方法还通知gameSceneDelegate通过传递创建的gameSession和serviceType属性来展示MCBrowserViewController:if (self.gameSceneDelegate && [self.gameSceneDelegate respondsToSelector:@selector(showMCBrowserControllerForSession:serviceType:)]) { [self.gameSceneDelegate showMCBrowserControllerForSession:self.gameSession serviceType:self.serviceType]; } -
代理方法必须由
GameViewController调用,在同一个控制器上,MCBrowserViewController也必须展示,它也将有自己的代理方法。现在,是时候声明GameViewController的私有接口,并遵循MCBrowserViewControllerDelegate和GameSceneDelegate,如下代码片段所示:@interface GameViewController() <MCBrowserViewControllerDelegate, GameSceneDelegate> @property (nonatomic, strong) GameScene* gameScene; @end -
在
GameViewController的viewDidLoad方法中,将本地场景对象替换为self.gameScene,并将GameScene对象的gameSceneDelegate属性设置为GameViewController,如下所示:// Create and configure the scene. self.gameScene = [GameScene unarchiveFromFile:@"GameScene"]; self.gameScene.scaleMode = SKSceneScaleModeAspectFill; self.gameScene.gameSceneDelegate = self; // Present the scene. [skView presentScene:self.gameScene]; -
实现如下
GameSceneDelegate的代理方法:- (void)showMCBrowserControllerForSession:(MCSession*)session serviceType:(NSString*)serviceType { MCBrowserViewController* viewController = [[MCBrowserViewController alloc] initWithServiceType:serviceType session:session]; viewController.minimumNumberOfPeers = 2; viewController.maximumNumberOfPeers = 2; viewController.delegate = self; [self presentViewController:viewController animated:YES completion:nil]; }在这个方法中,
MCBrowserViewController在GameViewController上呈现,并设置了其代理,并将对等体限制为2。 -
向
GameScene添加两个公共方法,用于调用MCBrowserViewController的取消和完成操作。-
在
GameScene.h文件中,声明公共方法,如下所示:#pragma mark - Public Methods - (void)startGame; - (void)discardSession; -
在
GameScene.m文件中,定义公共方法,例如:- (void)startGame { self.gameInfoLabel.text = kConnectedDevicesText; } - (void)discardSession { self.gameState = kGameStatePlayerToConnect; self.gameSession = nil; self.gamePeerID = nil; self.serviceType = nil; self.advertiser = nil; }
-
-
现在我们将在
GameScene文件中添加两个公共方法。这些方法将分别在MCBrowserViewControllerDelegate的取消和完成操作中调用:#pragma mark - MCBrowserViewControllerDelegate Methods - (void)browserViewControllerDidFinish:(MCBrowserViewController *)browserViewController { // The MCSession is now ready to use. [self dismissViewControllerAnimated:YES completion:nil]; if (self.gameScene) { [self.gameScene startGame]; } } - (void)browserViewControllerWasCancelled:(MCBrowserViewController *)browserViewController{ // The user cancelled. [self dismissViewControllerAnimated:YES completion:nil]; if (self.gameScene) { [self.gameScene discardSession]; } }在这两个代理方法中,首先关闭
MCBrowserViewController,并通知GameScene适当更改。
现在当两位设备玩家点击屏幕时,MCBrowserViewController 打开,玩家尝试使用此控制器提供的默认行为相互连接,完成后向玩家显示相应的文本。因此,这个整个实现完成了本章的入门套件。
工作原理
现在我们将了解如何使用 MCBrowserViewController 建立连接,以下步骤(在下图中,左侧是模拟器设备,右侧是 iPhone 5s):
-
两位玩家点击屏幕,打开
MCBrowserViewController,搜索附近的对等体,取消和完成按钮放置在导航栏上。在这里,完成按钮是禁用的,因为最初没有人连接到设备。![工作原理]()
-
一旦检测到对等体,它会在列表中显示设备的名称。
![工作原理]()
-
之后,两位玩家都按下他们想要连接的设备名称,搜索对等体的操作停止。因此,根据这个设备选择,发送一个连接到它的请求。
![工作原理]()
-
根据另一用户的回复,更新表格行右侧的对等体状态;它可以是连接中,已连接。当设备连接时,状态变为已连接,并且完成按钮被启用。
![工作原理]()
-
当玩家选择完成或取消时,我们显示相应的文本,点击完成按钮显示设备已连接,点击取消按钮显示点击连接。现在,设备在逻辑上是连接的,并且共享相同的会话。这个会话将由用户在多人游戏中进一步使用来玩游戏。
![工作原理]()
在这个过程中,我们还将看到一些网络延迟,所以如果设备没有连接,尝试通过取消控制器并再次点击屏幕来刷新控制器以重新连接。
分配玩家角色
在这个配方中,我们将通过为玩家分配角色,将我们的游戏模板提升到下一个步骤。这意味着我们将从逻辑上划分用户并为他们分配角色。这将为玩家提供个体身份。
准备工作
在开始分配或我们也可以称之为玩家身份的分配之前(即第一位玩家和第二位玩家),我们应该熟悉多对等连接框架。我们还必须具备网络数据包发送和接收的基本知识。在本节中,一旦使用前面配方中描述的MCBrowserViewController连接,我们将为玩家分配第一位和第二位玩家的身份。
如何操作
为了完成玩家的分配,以下是需要遵循的步骤:
-
为了设置这个目的,添加一些枚举、哈希定义常量和属性,如下所示:
-
声明一个名为
NetworkPacketCode的enum,目前我们只添加了KNetworkPacketCodePlayerAllotment数据包代码,未来可以添加更多数据包代码,用于从游戏中发送和接收数据包。typedef enum { KNetworkPacketCodePlayerAllotment, // More to be added while creating the game } NetworkPacketCode; -
添加在玩家角色正在决定时显示给玩家的文本。
// Blue is the First and Red is the Second Player #define kFirstPlayerLabelText @"You're First Player" #define kSecondPlayerLabelText @"You're Second Player" -
在
GameScene.m中添加最大数据包大小常量以及一些属性,如gamePacketNumber、gameUniqueIdForPlayerAllocation,以便在发送数据包时使用。#define kMaxTankPacketSize 1024 int gameUniqueIdForPlayerAllocation; @property (nonatomic, assign) int gamePacketNumber;
-
-
现在为了从一个设备向另一个设备发送数据,我们有一个封装的数据容器,称为数据包。现在这个数据包通过网络发送,其他玩家的设备将相应地更新视图和位置。为此,创建一个方法来发送带有头部
NetworkPacketCode和数据的数据包,指定要发送数据包的peerId以及数据包是否应该使用可靠服务发送。- (void)sendNetworkPacketToPeerId:(MCPeerID*)peerId forPacketCode:(NetworkPacketCode)packetCode withData:(void *)data ofLength:(NSInteger)length reliable:(BOOL)reliable { // the packet we'll send is resued static unsigned char networkPacket[kMaxTankPacketSize]; const unsigned int packetHeaderSize = 2 * sizeof(int); // we have two "ints" for our header if(length < (kMaxTankPacketSize - packetHeaderSize)) { // our networkPacket buffer size minus the size of the header info int *pIntData = (int *)&networkPacket[0]; // header info pIntData[0] = self.gamePacketNumber++; pIntData[1] = packetCode; if (data) { // copy data in after the header memcpy( &networkPacket[packetHeaderSize], data, length ); } NSData *packet = [NSData dataWithBytes: networkPacket length: (length+8)]; NSError* error; if(reliable == YES) { [self.gameSession sendData:packet toPeers:[NSArray arrayWithObject:peerId] withMode:MCSessionSendDataReliableerror:&error]; } else { [self.gameSession sendData:packet toPeers:[NSArray arrayWithObject:peerId]withMode:MCSessionSendDataUnreliableerror:&error]; } if (error) { NSLog(@"Error:%@",[error description]); } } }在这里,
networkPacket通过一个头部和数据创建。声明了一个变量pIntData,它是包含NetworkPacketCode和gamePacketNumber的头部,以便为数据包分配一个唯一的数字,以序列化网络数据包,用于同步或正确更新游戏。一旦创建数据包,就调用MCSession的sendData方法,传递要发送的数据包,peerID数据包需要发送到的对等方,模式可以是MCSessionSendDataUnreliable或MCSessionSendDataReliable,以及error来检查在发送数据包时是否发生错误。此方法将在游戏中各个地方重复使用,以向相同游戏的对等方发送数据包。
-
生成一个随机数并将其存储在上述声明的变量
gameUniqueIdForPlayerAllocation中,这将有助于决定哪位是第一位和第二位玩家。在GameScene的didMoveToView方法中添加此行。gameUniqueIdForPlayerAllocation = arc4random(); -
将以下代码添加到
MCSession的接收数据代理方法中,根据其NetworkPacketCode处理接收到的数据包,如下所示:- (void)session:(MCSession *)session didReceiveData:(NSData *)data fromPeer:(MCPeerID *)peerID { // Data has been received from a peer. // Do something with the received data, on the main thread [[NSOperationQueue mainQueue] addOperationWithBlock:^{ // Process the data unsigned char *incomingPacket = (unsigned char *)[data bytes]; int *pIntData = (int *)&incomingPacket[0]; NetworkPacketCode packetCode = (NetworkPacketCode)pIntData[1]; switch( packetCode ) { case KNetworkPacketCodePlayerAllotment: { NSInteger gameUniqueId = pIntData[2]; if (gameUniqueIdForPlayerAllocation > gameUniqueId) { self.gameInfoLabel.text = kFirstPlayerLabelText; } else { self.gameInfoLabel.text = kSecondPlayerLabelText; } break; } default: break; } }]; }在接收数据时,应在
mainQueue操作块中处理。在这个块中,我们将从pIntData指针变量中移除头部并获取数据包中发送的NetworkPacketCode。在这个代码中,我们将检查发送的包的类型。然后我们将根据其类型解析数据包。在这里,一个名为KNetworkPacketCodePlayerAllotment的玩家分配包类型被传递,因此检索到的数据是gameUniqueId。如前所述,在didMoveToView中,我们为两个设备分配了一个随机数给变量gameUniqueIdForPlayerAllocation。因此,对于两个设备,生成了不同的数字,并且在从两个设备发送分配数据包时,这作为数据(将在下一点讨论要发送的分配数据包)传递。最后,为了决定哪个是第一和第二玩家,将比较本地gameUniqueIdForPlayerAllocation的值和包中发送的值,在这个比较中,一个将被分配为第一玩家,另一个将被分配为第二玩家,通过更改gameInfoLabel的适当文本来通知用户,如代理方法中所示。 -
请从
GameScene的公共方法startGame中删除以下写有内容的行,因为现在gameInfoLabel将根据接收到的数据包设置。self.gameInfoLabel.text = kConnectedDevicesText; -
所有这些早期过程都是在用户点击完成按钮时开始的。这个按钮是玩家已经连接的指示,并且将调用
MCSession的代理方法didChangeState,并带有名为MCSessionStateConnected的MCSessionState,并且由于连接状态的方法中已经内置了检查协议,所以在if语句中添加以下代码:- (void)session:(MCSession *)session peer:(MCPeerID *)peerID didChangeState:(MCSessionState)state { // A peer has changed state - it's now either connecting, connected, or disconnected. if (state == MCSessionStateConnected) { NSLog(@"state == MCSessionStateConnected"); // Remember the current peer self.gamePeerID = peerID; // Make sure we have a reference to the game session and it is set up self.gameSession = session; self.gameSession.delegate = self; self.gameState = kGameStatePlayerAllotment; self.gameInfoLabel.text = kGameStartedText; [self sendNetworkPacketToPeerId:self.gamePeerID forPacketCode:KNetworkPacketCodePlayerAllotment withData:&gameUniqueIdForPlayerAllocation ofLength:sizeof(int) reliable:YES]; } else if (state == MCSessionStateConnecting) { NSLog(@"state == MCSessionStateConnecting"); } else if (state == MCSessionStateNotConnected) { NSLog(@"state == MCSessionStateNotConnected"); } }在这个方法中,设置所有来自该方法的属性,因为它是远程玩家信息,并将游戏状态本地设置为
kGameStatePlayerAllotment。然后,我们将分配玩家的数据包发送到peerID,对于该 ID 已经建立了连接,使用NetworkPacketCode和数据部分,这将如之前讨论的那样在远程端接收。
最后,我们完成了为多人游戏连接两个玩家并为他们分配唯一身份以进一步识别以构建游戏的工作。这个方法作为本章的解决方案集。
如何工作
玩家的整个分配取决于数据包中发送的动作和数据,以及接收端根据发送端设定的规范如何解析。为了完成玩家身份的分配,我们使用了一个随机数变量,该变量是本地生成的并在分配数据包中传递。在接收端,编写了分配逻辑,检查本地设置的随机数和远程传递的随机数。基于这个比较,确定了第一和第二玩家。
在两个设备上显示了一些文本,告知玩家他们的身份,如下所示:

还有更多
在上一节中,我们使用了多对等连接框架。我们还可以使用 GameKit 框架。有关更多信息,请参阅developer.apple.com/library/ios/documentation/GameKit/Reference/GameKit_Collection/index.html。
参见
为了更好地理解和学习多对等连接框架,请访问developer.apple.com/library/prerelease/ios/documentation/MultipeerConnectivity/Reference/MultipeerConnectivityFramework/index.html。
第十二章。实现多人游戏
在本章中,我们将关注以下主题:
-
创建我们的 TankRace 环境
-
玩家的移动
-
实现游戏玩法
简介
在前面的章节中,我们介绍了玩家的握手过程,如连接建立和为玩家分配唯一标识。现在,我们将继续探索更多关于多人游戏的内容,并创建一个名为 TankRace 的多人游戏。在创建游戏的过程中,我们将实现以下功能:
-
创建视觉游戏设置,例如添加玩家、背景和其他资产
-
实现玩家在触摸屏幕时的移动,并同步设备上的玩家
-
实现游戏玩法,即玩家操作上的胜利和失败逻辑
创建我们的 TankRace 环境
游戏的名字本身暗示它是一个赛车游戏,其中将有两位玩家和名为蓝色坦克和红色坦克的坦克。在 iPhone 的纵向模式下,坦克将被放置在屏幕的两侧,并将有两个终点线,一个蓝色终点线和一条红色终点线,蓝色坦克(比如说第一位玩家)和红色坦克(比如说第二位玩家)分别要穿越。玩家的移动将基于触摸行为,第一个穿越自己终点线的坦克的玩家将获胜,另一个将失败。我们将创建游戏环境,即 TankRace,它将包括添加玩家、游戏背景和终点线。
准备工作
在前面的章节中,你已经学习了如何添加精灵和背景,以及更新它们的属性,如位置、旋转等。现在,我们将添加游戏中需要的所有资产,使其可玩。
如何操作
添加游戏中所需所有资产的操作步骤如下:
-
将本章代码包中提供的资源(
BlueTank.png、RedTank.png和Background.png)拖放到项目中,添加文件后,项目导航器将看起来像这样:![如何操作]()
-
现在,在这里,我们将使用 GameKit,这是一个创建社交游戏的好框架。这个框架提供了各种功能,如点对点连接、游戏中心和游戏内语音聊天。导入 GameKit 并声明一些枚举、结构、属性和常量,以便在接下来的代码中使用,如下所示:
-
导入
GameKit以使用 CGPoint 存储数据结构:#import <GameKit/GameKit.h> -
为玩家添加两个
NetworkPacketCode以移动,并为游戏结束时添加一个包代码,例如,一个表示游戏失败的包:typedef enum { KNetworkPacketCodePlayerAllotment, KNetworkPacketCodePlayerMove, KNetworkPacketCodePlayerLost, } NetworkPacketCode; -
声明一个名为
TankInfo的结构,它将被用作发送坦克信息的数据库结构;相同的结构将在远程玩家的接收端进行同步。typedef struct { CGPoint tankPreviousPosition; CGPoint tankPosition; CGPoint tankDestination; CGFloat tankRotation; CGFloat tankDirection; } TankInfo; -
对于坦克的移动,添加
Speed和TurnSpeed常量const float kTankSpeed = 1.0f; const float kTankTurnSpeed = 0.1f; -
将第一和第二位玩家的标签文本分别改为蓝色和红色
// Blue is the First and Red is the Second Player #define kFirstPlayerLabelText @"You're Blue" #define kSecondPlayerLabelText @"You're Red" -
添加当有人获胜或失败时的文本哈希定义。
#define kGameWonText @"You Won" #define kGameLostText @"You Lost"
-
-
向
GameScene的私有接口添加一些属性,如要维护和更新的坦克局部数据结构,以便它可以发送到远程端。同时,声明所有在游戏中使用的SKSpriteNodes和SKShapeNodes。TankInfo tankStatsForLocal; @property (nonatomic, strong) SKSpriteNode* redTankSprite; @property (nonatomic, strong) SKSpriteNode* blueTankSprite; @property (nonatomic, strong) SKShapeNode* blueFinishLine; @property (nonatomic, strong) SKShapeNode* redFinishLine; @property (nonatomic, strong) SKSpriteNode* localTankSprite; @property (nonatomic, strong) SKSpriteNode* remoteTankSprite; -
现在,我们将在场景中添加一些节点,因此
GameScene需要正确的大小。点击GameScene.sks并从 Xcode 右上角工具栏的最后一个图标打开右侧面板。从那里,将GameScene的大小更改为320 x 568,这是 iPhone 4 英寸的大小。![如何操作]()
-
在
adding assets方法的pragma标记下添加一个方法addGameBackground,在其中创建一个SKSpriteNode,并添加之前在项目中添加的图像Background.png。保持背景的 z 位置为0,因为它将在游戏中的所有其他节点下方。- (void)addGameBackground { SKSpriteNode *gameBGNode = [SKSpriteNode spriteNodeWithImageNamed:@"Background.png"]; { gameBGNode.position = CGPointMake(self.frame.size.width/2,self.frame.size.height/2); gameBGNode.zPosition = 0; [self addChild:gameBGNode]; } } -
在
adding assets方法下添加两个不同的方法pragma标记,用于玩家,即蓝色和红色坦克,分别使用BlueTank.png和RedTank.png图像作为SpriteNodes。- (void)addBlueTank { self.blueTankSprite = [SKSpriteNode spriteNodeWithImageNamed:@"BlueTank.png"]; self.blueTankSprite.position = CGPointMake(self.frame.size.width/2,self.frame.size.height * 0.95); self.blueTankSprite.zRotation = M_PI; self.blueTankSprite.zPosition = 2; [self addChild:self.blueTankSprite]; } - (void)addRedTank { self.redTankSprite = [SKSpriteNode spriteNodeWithImageNamed:@"RedTank.png"]; self.redTankSprite.position = CGPointMake(self.frame.size.width/2,self.frame.size.height * 0.05); self.redTankSprite.zRotation = 0.0; self.redTankSprite.zPosition = 2; [self addChild:self.redTankSprite]; } -
此外,在
adding assets方法的pragma标记下创建两个方法,用于完成线,两个玩家都必须达到以获胜。- (void)addBLueFinishLine { CGRect frame = CGRectMake(0, self.frame.size.height * 0.15, self.frame.size.width, 1); self.blueFinishLine = [SKShapeNode shapeNodeWithRect:frame]; { self.blueFinishLine.strokeColor = [UIColor blueColor]; self.blueFinishLine.zPosition = 2; [self addChild:self.blueFinishLine]; } } - (void)addRedFinishLine { CGRect frame = CGRectMake(0, self.frame.size.height * 0.85, self.frame.size.width, 1); self.redFinishLine = [SKShapeNode shapeNodeWithRect:frame]; { self.redFinishLine.strokeColor = [UIColor redColor]; self.redFinishLine.zPosition = 1; [self addChild:self.redFinishLine]; } } -
这里我们使用
SKNodeShape类对象,用于在屏幕上使用核心图形路径绘制任何所需的形状。因此,我们在游戏场景的两端添加红色和蓝色线条。 -
要在数据结构和
GameScene中设置坦克的初始位置,我们将编写一个方法,即resetLocalTanksAndInfoToInitialState。#pragma mark - Game Updation Methods - (void)resetLocalTanksAndInfoToInitialState { if (self.localTankSprite == self.blueTankSprite && self.remoteTankSprite == self.redTankSprite) { tankStatsForLocal.tankPosition = CGPointMake(self.frame.size.width/2,self.frame.size.height * 0.95); tankStatsForLocal.tankRotation = M_PI; self.localTankSprite.position = tankStatsForLocal.tankPosition; self.localTankSprite.zRotation = tankStatsForLocal.tankRotation; self.remoteTankSprite.position = CGPointMake(self.frame.size.width/2,self.frame.size.height * 0.05); self.remoteTankSprite.zRotation = 0.0; } else if (self.localTankSprite == self.redTankSprite && self.remoteTankSprite == self.blueTankSprite) { tankStatsForLocal.tankPosition = CGPointMake(self.frame.size.width/2,self.frame.size.height * 0.05); tankStatsForLocal.tankRotation = 0.0; self.localTankSprite.position = tankStatsForLocal.tankPosition; self.localTankSprite.zRotation = tankStatsForLocal.tankRotation; self.remoteTankSprite.position = CGPointMake(self.frame.size.width/2,self.frame.size.height * 0.95); self.remoteTankSprite.zRotation = M_PI; } } -
在这种方法中,局部数据结构
tankStatsForLocal和局部玩家的属性位置、zRotation使用唯一的tankStatsForLocal被设置为游戏的初始状态。远程玩家的位置和zRotation在游戏的初始状态中硬编码。所有这些设置都是基于在设备上谁属于蓝色和红色,谁是本地和远程坦克。 -
要动画化玩家身份文本,添加一个方法
hideGameInfoLabelWithAnimation。- (void)hideGameInfoLabelWithAnimation { SKAction* gameInfoLabelHoldAnimationCallBack = [SKAction customActionWithDuration:2.0 actionBlock:^(SKNode *node,CGFloat elapsedTime) { }]; SKAction* gameInfoLabelFadeOutAnimation = [SKAction fadeOutWithDuration:1.0]; SKAction* gameInfoLabelRemoveAnimationCallBack = [SKAction customActionWithDuration:0.0actionBlock:^(SKNode *node,CGFloat elapsedTime) { [node removeFromParent]; self.gameInfoLabel = nil; }]; NSArray* gameLabelAnimationsSequence = [NSArray arrayWithObjects:gameInfoLabelHoldAnimationCallBack,gameInfoLabelFadeOutAnimation,gameInfoLabelRemoveAnimationCallBack, nil]; SKAction* gameInfoSequenceAnimation = [SKAction sequence:gameLabelAnimationsSequence]; [self.gameInfoLabel runAction:gameInfoSequenceAnimation]; } -
使用精灵
SKAction创建一系列动画,最初有延迟,然后淡出标签,最后使用回调将其移除。这是在用户连接时完成的。对于标签的淡入淡出,我们使用之前在 第三章 中使用的相同动画代码,动画和纹理。 -
现在,编辑
MCSession的didChangeState代理方法,当状态变为连接时,如以下所示:- (void)session:(MCSession *)session didReceiveData:(NSData *)data fromPeer:(MCPeerID *)peerID { // Data has been received from a peer. // Do something with the received data, on the main thread [[NSOperationQueue mainQueue] addOperationWithBlock:^{ // Process the data unsigned char *incomingPacket = (unsigned char *)[data bytes]; int *pIntData = (int *)&incomingPacket[0]; NetworkPacketCode packetCode = (NetworkPacketCode)pIntData[1]; switch( packetCode ) { case KNetworkPacketCodePlayerAllotment: { NSInteger gameUniqueId = pIntData[2]; if (gameUniqueIdForPlayerAllocation > gameUniqueId) { self.gameInfoLabel.text = kFirstPlayerLabelText; self.localTankSprite = self.blueTankSprite; self.remoteTankSprite = self.redTankSprite; } else { self.gameInfoLabel.text = kSecondPlayerLabelText; self.localTankSprite = self.redTankSprite; self.remoteTankSprite = self.blueTankSprite; } [self resetLocalTanksAndInfoToInitialState]; break; } case KNetworkPacketCodePlayerMove: { break; } case KNetworkPacketCodePlayerLost: { break; } default: break; } }]; } -
在接收
MCSession方法中,在switch案例中添加所有NetworkPacketCode,并在接收到KNetworkPacketCodePlayerAllotment数据包时进行更改。在分配玩家时,根据相应的身份名称设置,并根据gameUniqueIdForPlayerAllocation分配本地和远程精灵对象。最后,调用一个私有方法resetLocalTanksAndInfoToInitialState,在其中设置本地和远程精灵及其本地数据结构的初始状态。所有这些步骤将在会话中的连接设备上执行,这将确保两个设备保持同步。 -
现在一旦设置了显示玩家身份的
gameInfoLabel,然后在GameScene的startGame方法中插入代码以改变游戏状态,并通过MCBrowerViewController的代理方法来隐藏gameInfoLabel,就可以通过隐藏来动画化游戏状态。- (void)startGame { if (self.gameState == kGameStatePlayerAllotment) { self.gameState = kGameStatePlaying; [self hideGameInfoLabelWithAnimation]; } } -
当该方法由代理方法调用时,它会检查玩家是否已经分配,然后更改状态为播放,并动画化隐藏标签,标签上写着你是蓝色或你是红色。
在添加所有这些游戏资产之后,其环境已经设置好,玩家的身份或可以说名字,已经被分配为蓝色和红色。所有这些构成了本章的入门套件。
如何工作
整个部分都是关于使用 SpriteKit 作为添加游戏资源的方式,以及作为多人游戏的一部分,玩家被分配为蓝色和红色。GameScene上节点添加的工作部分已经在第十一章中解释过,即开始多人游戏,并且由于这些添加的结果,游戏在两个设备上看起来如下:

在多人游戏部分,当用户按下完成按钮时,连接建立,两名玩家都连接上了。此外,当用户按下完成按钮时,已经为玩家分配了名称,如以下图像所示,两种设备上均为蓝色和红色。这些标签位于两个不同的设备上,并且将在分配时进行动画:

玩家的移动
一旦多人游戏的环境准备就绪,就是实际操作的时候了:当用户触摸或拖动屏幕时让玩家移动。由于这是一个多人游戏,移动需要与远程设备同步,这是一个很好的挑战,也是一次神奇的经历。
准备工作
在开始本节之前,我们应该了解SKScene的触摸方法,如touchBegan、touchMoved和touchEnded,因为所有的移动都将基于触摸进行。此外,我们还应该擅长数学,因为坦克的移动需要使其头部指向触摸方向,并且需要与远程设备同步。在本节中,我们将实现坦克的移动,并通过发送和接收网络数据包来同步其他设备上的移动。
如何实现
以下是在通过发送和接收数据包使坦克在触摸时移动并在远程设备上同步的步骤:
-
在触摸屏幕时,会调用
SKScene的这些方法,这些方法将用于在本地设备上移动坦克。-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event -(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event -
第一个方法已经实现。让我们在游戏状态为
kGameStatePlaying时,在触摸开始时添加一些更新的坦克信息,如下所示:UITouch *thumb = [[event allTouches] anyObject]; CGPoint thumbPoint = [thumb locationInNode:self]; // hold to move, second finger to fire if(thumb.tapCount==0) { tankStatsForLocal.tankDestination = thumbPoint; tankStatsForLocal.tankDirection = atan2( thumbPoint.y - tankStatsForLocal.tankPosition.y, thumbPoint.x - tankStatsForLocal.tankPosition.x ) - (M_PI/2.0); // keep us 0-359 if(tankStatsForLocal.tankDirection < 0) tankStatsForLocal.tankDirection += (2.0*M_PI); else if(tankStatsForLocal.tankDirection > (2.0*M_PI)) tankStatsForLocal.tankDirection -= (2.0*M_PI) [self updateLocalTank]; } -
在通过
touch方法接收的事件中,获取了一个UITouch对象,并使用它来确定触摸位置相对于GameScene的位置。然后,如果触摸的tapCount为0,即用户正在拖动代码以移动,则执行玩家。在这段代码中,tankStatsForLocal根据触摸在屏幕上的位置进行更新。目的地设置为触摸位置,方向通过使用向量数学,结合触摸点和坦克的当前位置来计算。为了保持坦克的方向角度在 0 到 359 度之间,一旦计算出方向,就需要进行额外的检查。完成所有这些后,为了更新坦克的实际位置和旋转,将实现一个名为updateLocalTank的方法,我们将在稍后讨论它。 -
在
GameScene中实现另外两个触摸方法,即touchMoved和touchEnded。 -
在
touchesMoved中,如果游戏状态是kGameStatePlaying,则执行与touchesBegan方法相同的代码,如前一点所述。- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { if (self.gameState == kGameStatePlaying) { if([touches count] == 1) { UITouch *thumb = [[event allTouches] anyObject]; CGPoint thumbPoint = [thumb locationInNode:self]; tankStatsForLocal.tankDestination = thumbPoint; tankStatsForLocal.tankDirection = atan2( thumbPoint.y - tankStatsForLocal.tankPosition.y, thumbPoint.x - tankStatsForLocal.tankPosition.x ) - (M_PI/2.0); // keep us 0-359 if(tankStatsForLocal.tankDirection < 0) tankStatsForLocal.tankDirection += (2.0*M_PI); else if(tankStatsForLocal.tankDirection > (2.0*M_PI)) tankStatsForLocal.tankDirection -= (2.0*M_PI); [self updateLocalTank]; } } } -
在
touchesEnded中,我们不应该做其他两个触摸方法中所做的;在这里,我们只更新tankDestination和tankDirection到本地数据结构中,然后调用相同的updateLocalTank方法来更新最终的位置和旋转。-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { if (self.gameState == kGameStatePlaying) { if([touches count] == [[event touchesForView:self.view] count]) { tankStatsForLocal.tankDestination = tankStatsForLocal.tankPosition; tankStatsForLocal.tankDirection = tankStatsForLocal.tankRotation; [self updateLocalTank]; } } } -
在所有触摸方法中,都会调用
updateLocalTank方法来更新最终的位置和旋转。在为本地坦克更新这些属性后,将发送一个网络数据包以与远程坦克玩家同步。- (void)updateLocalTank { if( (fabs(tankStatsForLocal.tankPosition.x - tankStatsForLocal.tankDestination.x)>kTankSpeed) || (fabs(tankStatsForLocal.tankPosition.y - tankStatsForLocal.tankDestination.y)>kTankSpeed) ) { // check facing float ad = tankStatsForLocal.tankDirection - tankStatsForLocal.tankRotation; if(fabs(ad) > kTankTurnSpeed) { // we need to turn, work out which way (find the closest 180) while(ad > M_PI) { ad -= (2.0 * M_PI); } while(ad < -M_PI) { ad += (2.0 * M_PI); } if(ad < 0) { tankStatsForLocal.tankRotation -= kTankTurnSpeed; if(tankStatsForLocal.tankRotation < 0) tankStatsForLocal.tankRotation += (2.0*M_PI); } else if(ad > 0) { tankStatsForLocal.tankRotation += kTankTurnSpeed; if(tankStatsForLocal.tankRotation > (2.0*M_PI)) tankStatsForLocal.tankRotation -= (2.0*M_PI); } } else { tankStatsForLocal.tankRotation = tankStatsForLocal.tankDirection; // if facing move along line towards destination float dx = tankStatsForLocal.tankPosition.x - tankStatsForLocal.tankDestination.x; float dy = tankStatsForLocal.tankPosition.y - tankStatsForLocal.tankDestination.y; float at = atan2( dy, dx ); // 1.0 is the "speed" tankStatsForLocal.tankPosition.x -= kTankSpeed * cos(at); tankStatsForLocal.tankPosition.y -= kTankSpeed * sin(at); } } else { tankStatsForLocal.tankPosition.x = tankStatsForLocal.tankDestination.x; tankStatsForLocal.tankPosition.y = tankStatsForLocal.tankDestination.y; } tankStatsForLocal.tankPreviousPosition = self.localTankSprite.position; self.localTankSprite.position = tankStatsForLocal.tankPosition; self.localTankSprite.zRotation = tankStatsForLocal.tankRotation; // Send NetworkPacket for syncing the data at both the players [self sendNetworkPacketToPeerId:self.gamePeerID forPacketCode:KNetworkPacketCodePlayerMove withData:&tankStatsForLocal ofLength:sizeof(TankInfo) reliable:YES]; }初始时,检查目的地向量与
kTankSpeed的差异不要超过。然后,通过tankStatsForLocal的目的地更新位置,如果差异大于,则编写代码进行转弯;即计算坦克方向和tankRotation之间的角度差异。 -
如果差异大于
kTankTurnSpeed,则找到最近的 180 度旋转,并根据该旋转减去或加上kTankTurnSpeed以调整旋转。如果差异不大于面向,则围绕线移动到目的地。将旋转设置为方向,并使用当前位置、目的地和kTankSpeed计算坦克的位置。所有这些计算都应该分配给
tankStatsForLocaldata结构。完成所有这些后,将本地数据结构的tankPreviousPosition设置为本地玩家的当前精灵位置。更新tankStatsForLocalstructure中计算的位置和旋转。为了同步由该方法产生的玩家移动,我们需要向其他玩家发送一个包含NetworkPacketCode为KNetworkPacketCodePlayerMove的数据包,数据部分将在tankStatsForLocal结构中,并且这个数据包应该不可靠地发送,因为它发送得非常频繁。 -
在
MCSessiondidReceiveData的委托方法中,接收到了类型为KNetworkPacketCodePlayerMove的玩家移动数据包。case KNetworkPacketCodePlayerMove: { // received move event from other player, update other player's position/destination info TankInfo *ts = (TankInfo *)&incomingPacket[8]; self.remoteTankSprite.position = ts->tankPosition; self.remoteTankSprite.zRotation = ts->tankRotation; break; } -
在这里,
TankInfo是包含用户所有位置和旋转相关数据的结构。TankInfo结构通过网络发送以同步两个设备。
数据在TankInfo变量ts中解析,该变量包含发送的坦克位置和旋转。因此,它是远程坦克的数据,所以使用接收到的属性更新它。结果,我们将在远程设备中看到坦克移动,就像用户在另一台设备中驾驶坦克一样。
如何工作
坦克的移动是通过向量数学实现的,使用用户触摸的点、坦克在触摸时的朝向等因素。我们最终在我们的游戏中实现了多人行为,其中坦克根据设备中本地坦克的移动进行远程移动,这可以在以下快照中看到。远程设备的同步完全依赖于网络。
如果网络较弱,用户可能会在设备之间同步时遇到一些延迟。

实现游戏玩法
现在是时候实现游戏玩法了,在这个游戏中,玩家(坦克)将如何赢得或输掉名为 TankRace 的游戏将得到确定。游戏玩法是这样的,哪个玩家首先到达他们自己颜色相同的一侧的终点线,就赢得比赛,而其他玩家则输掉。
准备工作
在我们开始之前,我们必须知道如何从 SpriteKit 中的精灵检测碰撞,以及一些关于 SpriteKit 标签的玩法。在本节中,我们将实现游戏玩法,并在设备之间没有连接时显示一个警告。
如何实现
下面是实现游戏玩法和决定谁赢谁输所涉及的步骤:
-
我们将通过使用名为
CGRectIntersectsRect的方法在GameScene的更新方法中检测碰撞。在此方法中,我们可以传递两个节点的框架,它将检查这两个框架是否相互交叉。通过这个检查,我们只为本地玩家碰撞进行检查,如果发生碰撞,则更新游戏状态为kGameStateComplete,并显示到达终点的本地玩家你赢了。此外,由于游戏已经结束,为了自动启动游戏,调用一个名为restartGameAfterSomeTime的方法,我们将在继续进行时了解它。 -
之后,本地玩家将显示正确的结果,游戏将重新启动,但由于这是一个多人游戏,碰撞的反应也应该反映在另一设备上。因此,传递一个带有名为
KNetworkPacketCodePlayerLost的NetworkPacketCode的数据包,该数据包将被发送给已输掉游戏的另一玩家。以下是实现此功能的代码:#pragma mark - Update Loop Method -(void)update:(CFTimeInterval)currentTime { /* Called before each frame is rendered */ CGRect blueFinishLineFrame = CGRectMake(0, self.frame.size.height * 0.15, self.frame.size.width, 1); CGRect redFinishLineFrame = CGRectMake(0, self.frame.size.height * 0.85, self.frame.size.width, 1); if (self.localTankSprite == self.blueTankSprite && CGRectIntersectsRect(self.localTankSprite.frame, blueFinishLineFrame)) { self.gameState = kGameStateComplete; [self addGameInfoLabelWithText:kGameWonText]; [self restartGameAfterSomeTime]; [self sendNetworkPacketToPeerId:self.gamePeerID forPacketCode:KNetworkPacketCodePlayerLost withData:nil ofLength:0 reliable:YES]; } else if(self.localTankSprite == self.redTankSprite && CGRectIntersectsRect(self.localTankSprite.frame, redFinishLineFrame)) { self.gameState = kGameStateComplete; [self addGameInfoLabelWithText:kGameWonText]; [self restartGameAfterSomeTime]; [self sendNetworkPacketToPeerId:self.gamePeerID forPacketCode:KNetworkPacketCodePlayerLost withData:nil ofLength:0 reliable:YES]; } } -
当前面的游戏丢失的数据包发送给另一玩家时,会调用名为
MCSession的代理方法didReceiveData,数据包的头部有一个名为KNetworkPacketCodePlayerLost的NetworkPacketCode。当接收到此数据包时,游戏状态将变为kGameStateComplete,游戏信息标签显示为你输了,以通知其他用户游戏失败。此外,我们调用一个名为restartGameAfterSomeTime的方法,该方法将游戏重置为其初始状态,玩家可以再次开始游戏。case KNetworkPacketCodePlayerLost: { self.gameState = kGameStateComplete; [self addGameInfoLabelWithText:kGameLostText]; [self restartGameAfterSomeTime]; break; }使用这两段代码后,大约一名玩家获胜,另一名玩家失败,两个设备上的游戏看起来如下所示:
![如何操作]()
-
由于
restartGameAfterSomeTime在发送和接收数据包时使用,让我们按照以下代码所示编写此方法:- (void)restartGameAfterSomeTime { [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(restartGame) userInfo:nil repeats:NO]; } - (void)restartGame { gameUniqueIdForPlayerAllocation = arc4random(); self.gameState = kGameStatePlayerToConnect; self.gameInfoLabel.text = kConnectingDevicesText; [self resetLocalTanksAndInfoToInitialState]; } -
在此方法中,将触发一个 2.0 秒的
NSTimer,调用一个名为restartGame的函数,在此函数中,gameUniqueIdForPlayerAllocation将再次生成,游戏状态设置为kGameStatePlayerToConnect,标签文本更改为点击连接。为了视觉初始状态,我们调用名为resetLocalTanksAndInfoToInitialState的方法,在此方法中,设置本地数据结构和坦克的视觉属性。 -
每当游戏完成
restartGameAfterSomeTime方法时,在本地和远程设备上都会调用此方法,以设置游戏的初始状态,看起来如下所示:![如何操作]()
-
有时,由于网络弱,连接可能会丢失,屏幕会卡住,因此不会向用户显示任何消息。因此,我们将添加一个警告,说明存在网络问题,并且要玩游戏,请在两个设备上重新启动您的应用,如下所示的方法:
- (void)showNetworkDisconnectAlertView { UIAlertView* alertView = [[UIAlertView alloc] initWithTitle:@"Network Disconnected" message:@"Sorry due some network problem devices are disconnected. To start game again kill apps in both devices and restart the app!!" delegate:self cancelButtonTitle:@"OK" otherButtonTitles:nil, nil]; [alertView show]; } - (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex { // do nothing } -
要显示网络已断开连接的警告,当
MCSession的代理方法didChangeState被调用,并且状态变化为MCSessionStateNotConnected时,调用showNetworkDisconnectAlertView。它应该在当前游戏状态为kGameStatePlaying时显示。else if (state == MCSessionStateNotConnected) { NSLog(@"state == MCSessionStateNotConnected"); if (self.gameState == kGameStatePlaying) { [self showNetworkDisconnectAlertView]; } } -
每当游戏断开连接时,
showNetworkDisconnectAlertView被调用,并显示如图所示的警告视图:![如何操作]()
在实现所有这些游戏逻辑之后,或者说游戏机制之后,我们已经完成了制作多人游戏,这是本章的解决方案套件。
它是如何工作的
本节全部关于游戏玩法和游戏机制;它分为两部分,一是检测碰撞以决定胜者,二是游戏结束后重置游戏到初始状态。
为了实现这一点,我们使用框架交集方法检测碰撞,并通过发送数据包将本地玩家宣布为胜者,其他玩家为败者。由于游戏在这里结束,为了帮助用户重新开始游戏,在宣布胜者和败者的同时,我们也重置游戏到其初始状态,以便玩家可以再次开始游戏。
更多内容
我们使用了多点连接框架,尽管我们也可以使用 GameKit 框架;有关更多信息,请使用以下链接:
developer.apple.com/library/ios/documentation/GameKit/Reference/GameKit_Collection/index.html
参见
为了更好地理解和学习多点连接框架,您可以访问以下链接:






























































































































浙公网安备 33010602011771号