精通-IOS-游戏开发-全-

精通 IOS 游戏开发(全)

原文:zh.annas-archive.org/md5/4366caba07a87589dcf8a606e611cf4c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎!我们都热爱游戏开发,我们中的许多人梦想着想要玩的游戏,但遗憾的是,我们从未看到它们发布。你在这里是为了制作那个游戏,那个你计划、绘制、渴望创造已久的游戏。借助这本书的帮助和你的精彩想法,你将成功创建你的梦想游戏。在这本书中,你将学习如何使用你可用的免费软件来创建、开发和发布你的精彩作品。

欢迎并享受这次旅程!

本书涵盖的内容

第一章, 在 Xcode 和游戏开发中期待什么,涵盖了如何成为苹果开发者,如何下载 Xcode 和 iOS 开发工具包,以及你对游戏开发行业的期待。

第二章, 创建素材,展示了如何使用你可用到的各种工具来创建精灵和各种其他图像、音效,甚至为你的游戏制作视频。

第三章, 发射!从开发开始,讲述了我们的项目是如何开始的。你将学习如何为我们的项目导入各种框架,创建我们的角色并让他移动。

第四章, 继续前进!添加更多功能,讨论了如何在我们的项目即将完成时,为我们的游戏添加一些酷炫效果,例如火焰和雨的粒子效果。

第五章, 虫子清除 – 测试和调试,涵盖了测试我们的游戏并进行调试,以确保在将我们的游戏移植后没有问题。

第六章, 使我们的游戏更高效,展示了我们如何使我们的游戏在所有设备上运行得更好,现在它已经完美运行。通过这样做,我们将提高电池寿命、性能和整体用户体验。

第七章, 部署和盈利,讨论了如何向世界展示我们的游戏,并通过添加盈利选项来增加我们的收入渠道。

第八章, 单独行动太危险了,带上朋友吧!,讲述了在经过大量规划后,我们如何使我们的游戏成为多人游戏,因为我们现在准备更新我们的游戏。我们将加入多人连接功能,以便找到一起玩的朋友!

你需要为这本书准备什么

对于这本书,你只需要 Xcode,这是苹果公司为 iOS 开发的软件开发工具包,以及任何图像创建软件,例如 Gimp 或 Photoshop。

这本书适合谁

这本书是为那些对 iOS 开发有一定经验但真正想磨练技能的人而写的。

习惯用法

在这本书中,您将找到许多不同风格的文章,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:"我们将在MultiplayerHelper.m文件的顶部添加另一个NSString类。"

代码块设置如下:

- (void)match:(GKMatch *)match didFailWithError:(NSError *)error {

    if (_match != match) return;

    NSLog(@"Match failed with error: %@", error.localizedDescription);
    _matchStarted = NO;
    [_delegate matchEnded];
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

- (void)update:(NSTimeInterval)delta {

    CGPoint gravity = CGPointMake(0.0, -450.0);

    CGPoint gravityStep = CGPointMultiplyScalar(gravity, delta);

 CGPoint movingForward = CGPointMake(750.0, 0.0);
 CGPoint movingForwardStep = CGPointMultiplyScalar(walking, delta);

    self.velocity = CGPointAdd(self.velocity, gravityStep);

新术语重要词汇将以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:"对于这个项目,我们显然将选择iOS | 应用程序 | 单视图应用程序,然后点击下一步。"

注意事项

警告或重要注意事项将以这样的框显示。

小贴士

小贴士和技巧如下所示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。

要向我们发送一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件的主题中提及书名。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从您在www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误更正

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。

海盗行为

在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者以及为我们提供有价值内容方面的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章:在 Xcode 和游戏开发中可以期待什么

欢迎来到移动游戏开发的世界!无论你是经验丰富的开发者还是新手开发者,你都在一个激动人心、快速发展的行业中。你购买这本书是为了掌握 iOS 游戏开发,这正是你将在本书结束时做到的!然而,你需要从某个地方开始,对吧?让我们看看本章将讨论什么:

  • 注册成为 iOS 开发者

  • 为开发设置你的 Mac

  • 从游戏开发市场可以期待什么

移动游戏开发行业非常激动人心!准备好迈出游戏开发的第一步。我们将帮助你注册成为 Apple 开发者,设置你的 Mac 开发环境,并参观 Xcode 的一些功能。我们将看看源文件(包含我们代码的文件)是什么样的。然后,我们将讨论游戏开发市场,以及它有多么令人惊叹。我在开玩笑,这是一个有趣的市场!

让我们直接进入主题,好吗?

注册成为 iOS 开发者

就像许多平台(Mac、PC、Android、Blackberry 和 Windows Mobile)一样,iOS 要求你注册成为开发者。别担心!这很简单,只需访问developer.apple.com/ios/,你将看到主页,如下面的截图所示:

注册成为 iOS 开发者

滚动到找到iOS Apps。点击iOS Apps按钮,你将看到 iOS 开发者中心。熟悉这个网站以及我们将在本书后面介绍的 iTunes Connect,你在游戏开发过程中会大量使用这些网站。

如果你还没有开发者账户,点击免费注册文本,以便你可以注册一个新的 Apple ID。苹果的另一个优点是,他们的账户都是关联的,所以当你注册开发者账户时,你可以使用你的 Apple ID。在下一页,你将被要求使用你的 Apple ID 登录或创建一个新的。只需按照提示操作,填写所有信息,并支付年度费用(99 美元/119 加拿大元)。完成之后,你应该可以访问 SDK。如果你没有被重定向回 iOS 开发者中心,请访问developer.apple.com/ios并滚动到下载部分。截至本书编写时,Xcode 的当前版本是 6.2。点击下载按钮。你将被带到下载页面;然而,你将被转到 Mac AppStore。Xcode 现在托管在 Mac AppStore 上,这要好得多,因为它在需要时自动更新 Xcode。你将看到 Xcode 安装页面,如下面的截图所示:

注册成为 iOS 开发者

点击 获取 按钮下载 Xcode。下载完成后(除非你有光纤高速互联网,否则可能需要一段时间:它是 2.5 GB。AppStore 会自动为你安装 Xcode。

现在,你应该在你的 dock 上看到一个闪亮的新的图标。当你打开它时,可能会提示你进行额外的安装以支持调试或其他功能,请继续让它下载所需的任何额外组件,如图所示:

注册成为 iOS 开发者

现在,你将看到 Xcode 欢迎屏幕。从这里,你可以创建新项目、打开旧项目,甚至查看最近的项目列表。

我们将开始设置我们的游戏项目,所以点击 创建新的 Xcode 项目,然后新的项目向导将弹出。在这里,你有许多空的项目模板可供选择,但针对这个项目,我们将在 iOS 选择下的 应用程序 中选择 游戏 模板。点击 游戏,然后点击 下一步。下一屏幕将要求提供所有游戏信息,它将如下所示:

  • 产品名称: 这是项目或游戏的名称。

  • 组织名称: 这是你在 Apple 开发者网站上注册时使用的公司或个人名称。

  • 组织标识符: 这将显示在你的捆绑标识符(稍后讨论证书)中,通常证书读取为 com.yourcompany.yourproduct,但你也可以自定义它为 yourcompany.yourproduct

  • 捆绑标识符: 这是你的项目的标识符。你将在我们稍后安装的开发者证书上看到它,你将在上传到 AppStore 时也会看到它。

  • 语言: 在开发时,我们现在可以选择两种语言:Swift 和 Objective-C。对于这本书,我们将使用 Objective-C。

  • 游戏技术: 这包括你在开发时可以使用的各种“套件”,例如 SceneKit、SpriteKit、OpenGL ES 和 Metal。对于这个项目,我们将选择 SpriteKit。

  • 设备: 默认设备是通用;但是,如果你想选择目标设备,你可以这样做。有时,由于屏幕尺寸较小,iPad 应用程序在 iPhone 上看起来可能不合适,由于分辨率不同,东西可能不会正确地适应,并且不会正确显示。

按照以下截图所示填写所有信息字段:

注册成为 iOS 开发者

总结一下,对于这个项目,我们将选择 Objective-CSpriteKit通用。当你填写完所有信息后,点击 下一步 按钮,并将你的项目保存在一个容易记住的位置。

现在,你已经进入了 Xcode!看到这些内容感到困惑吗?别担心,实际上导航起来相当简单。

以下截图显示了常规项目设置,这是你加载 Xcode 项目时看到的第一件事:

注册成为 iOS 开发者

对于这个项目,我们只想支持横向方向;因此,从 部署信息 下的 设备方向 部分取消选择 纵向 选项,并保留两个横向方向的选择。

如果你愿意,可以为我们的游戏添加不同的功能。点击屏幕中央顶部栏上的 功能 按钮,你将看到许多可以添加到你的应用程序中的不同选项。例如,我们可以添加 游戏中心 用于排行榜和成就,或者我们可以通过点击按钮从关闭切换到开启来添加 iCloud 功能。我们可以使用 iCloud 在远程存储游戏保存数据,如下面的截图所示:

注册成为 iOS 开发者

在顶部栏的各个部分中,有很多设置和变量可以进行更改。我们将在本书稍后讨论其中的一些。

让我们浏览一下我们的项目文件,以便熟悉文件的功能。在左侧栏中,你会看到 .h.m 文件,这些是所有编程工作的地方。

.h 文件是你的头文件,我们将声明变量(例如整数和布尔值)和出口(如果我们通过故事板构建,我们会声明按钮、标签等。我们会在头文件中声明它们,并在故事板中连接它们)。

别担心,我们将在本书稍后进行更多详细讨论。

.m 文件是你的主要文件,大部分的编码工作都在这里进行。在头文件中声明的声明只要文件集相同,就可以访问,例如,你可以在 AppDelegate.m 文件中访问来自 AppDelegate.h 文件的整数。有方法可以从其他文件访问变量,例如框架;或者只要将头文件导入到你正在工作的文件中。这些是我们稍后将要讨论的内容。我不想在这个时候让你感到困惑。

让我们来看看 AppDelegate.hAppDelegate.m 文件,从 .h 文件开始,你将看到以下文本:

//
//AppDelegate.h
//SuperSolderBoys
//
//Created by Miguel DeQuadros on 2015-03-25.
//Copyright (c) 2015 Miguel DeQuadros. All rights reserved.
//

#import <UIKit/UIKit.h>

@interface AppDelegate : UIResponder <UIApplicationDelegate>

@property (strong, nonatomic) UIWindow *window;

@end

注释中的文本会有所变化(你可以通过在注释后输入 // 来注释文本)。当你看到文本为绿色时,你就知道文本已被注释。

几乎所有的代码文件都会以导入文件开始。在前面代码中看到的 AppDelegate 类显示了 #import <UIKit/UIkit.h>,这将允许你访问来自 UIKit 头文件的命令和函数。你可以在这里导入任何其他头文件,例如,如果你想创建一个可以通过 PayPal 进行支付的应用程序,你可以导入 PayPal SDK,然后通过输入 #import <PayPal/PayPal.h> 或需要导入的任何文件来将其导入到你的文件中。当你开始输入时,Xcode 将显示你可以导入的文件列表。

现在,我们将查看下一行,即 @interface AppDelegate : UIResponder <UIApplicationDelegate>。这取决于您正在使用的界面类型(您是否正在处理应用程序代理、主菜单的视图控制器,或者 PayPal 支付视图控制器);它可以是此文件中的 ApplicationDelegate 方法,也可以是 UIViewControllerSKScene 方法。同样,这取决于您正在使用的界面类型。您将在本书中看到不同界面的各种变体。

最后,我们将看到最后高亮显示的行,即 @property (strong, nonatomic) UIWindow *window;。这是您声明的地方。这一行声明了应用程序窗口。属性是我们类中使用的各种项目的声明。例如,属性可以是整数、布尔值、窗口(如前所述),甚至按钮。

最后,我们有 @end 行,这仅仅是文件的结束。

现在,让我们看看 .m 文件:

//
//  AppDelegate.m
//  SuperSolderBoys
//
//  Created by Miguel DeQuadros on 2015-03-25.
//  Copyright (c) 2015 Miguel DeQuadros. All rights reserved.
//

#import "AppDelegate.h"

@interface AppDelegate ()

@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    return YES;
}

- (void)applicationWillResignActive:(UIApplication *)application {
    // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
}

- (void)applicationDidEnterBackground:(UIApplication *)application {
    // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
    // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}

- (void)applicationWillEnterForeground:(UIApplication *)application {
    // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background.
}

- (void)applicationDidBecomeActive:(UIApplication *)application {
    // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}

- (void)applicationWillTerminate:(UIApplication *)application {
    // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}

@end

在这个文件中,当应用程序达到某些状态时,您将调用各种函数。如果您阅读了一些函数,您将看到注释文本(要注释文本,请在代码行前输入 //),说明每个状态的作用,例如当应用程序终止时,或者当它进入后台时。有了这些函数,您可以保存数据,记录信息,甚至暂停游戏。

看起来很简单,对吧?一旦您掌握了方法,事情确实会变得简单!

现在,我们必须将我们的开发者账户添加到 Xcode 的账户偏好设置中。这使得签名证书和配置文件变得容易得多。

要添加您的账户,请点击最上面的工具栏中的 Xcode(您可以看到文件、编辑等所有这些功能),然后点击 偏好设置。或者,您可以按 CMD +,以快速访问菜单。

当偏好设置窗口出现时,点击 账户 选项卡,这是窗口中的第二个选项卡。

在窗口的左下角,点击 + 按钮以添加账户。当下拉菜单出现时,选择 添加 Apple ID

再次强调,Xcode 使用您的正常 Apple ID 登录来登录开发者会员中心和 iTunes Connect,这非常方便,因为您不需要记住多个登录信息。

注册成为 iOS 开发者

现在我们已经将账户添加到 Xcode 中,我们需要将所有开发者证书添加到我们的密钥链中。

前往 developer.apple.com,点击 会员中心,并使用您的 Apple ID 登录。在 技术资源和工具 部分下,点击 证书、标识符和配置文件 图标。

现在,我们将看到 iOS、Mac 和 Safari 的选择。这些将显示每个平台的证书,组织得非常整洁。

在 iOS 下点击证书标签页,如果你的会员资格是新的,你将看到一个入门网页。只需按照所有步骤操作,这些步骤将引导你创建和安装证书。关于这方面的更多内容将在本书的后续部分进行讨论。

当你安装好所有证书后,你就完成了!现在我们已经安装并设置了 Xcode,让我们谈谈资产创建。

为你的精彩游戏创建资产

实际上,你可以使用大量程序来创建游戏资产。等等!什么是资产?!简单来说,资产是你游戏中使用的各种图像、音效或视频。例如,我们有精灵。等等!什么是精灵?!精灵是你游戏中使用的图像;这可以包括角色、平台、背景、物品和对象。资产也可以是音效、音乐和视频。

对于精灵,我喜欢使用 Adobe Photoshop,特别是如果我制作的游戏是像素艺术 2D 游戏。它非常出色,因为你可以利用图层为每个需要移动的独立身体部分进行动画。然而,如果我要制作看起来更逼真的东西,我会使用 3D Studio Max 来创建我的模型,然后将它们渲染成小图像文件。

这些软件套件并不便宜。3DS Max 超过 3000 美元,Adobe Suite 过去大约是 1600 美元,尽管他们现在提供月度计划。

如果你正在寻找免费选项,可以查看用于图像创建的Gimp等程序。我使用过它:它的布局就像 Photoshop 一样,使用起来同样简单且功能强大。或者,使用基于浏览器的图像处理程序Pixlr,它与 Photoshop 非常相似。

如果你想要做一些酷炫的 3D 内容,可以查看BlenderWings3DDAZ Studio

对于音乐,许多人喜欢使用老式的Garage Band。有很多乐器选项、预制的循环,以及使用电脑键盘作为音乐键盘的能力。

对于音频处理,开发者和其他人之间的主要选择是Audacity。你可以通过添加淡入淡出、点击、静音、反转音频、添加更多内容来操纵音频文件。它相当酷。

对于视频,我个人喜欢使用3DS Max,然后将其导入到iMovie中。iMovie 应用程序很棒,因为它可以将你的视频文件导出以供 iPhone 使用,所以我们不需要担心文件兼容性或屏幕尺寸。你可以使用 Blender 在短时间内为你的介绍创建一些令人惊叹的 3D 动画,或者你可以使用Adobe Flash来动画化它们,它还可以将它们导出为 iPhone 和 iPad 的特定格式。

我们将在下一章中更多地讨论资产的实际创建。同时,让我们谈谈游戏开发场景,以及当你进入时可以期待什么。

进入游戏开发市场

正如我在本章引言中提到的,你已经进入了一个超级激动人心的市场。游戏开发市场发展迅速,总是变化,非常有趣,有很大的赚钱潜力,正如 Doge 将会解释的:

进入游戏开发市场

然而,虽然这是一个有趣且赚钱潜力巨大的行业,但并非所有事情都那么有趣,也不是一蹴而就的成功。

在我开发游戏超过十年间,我了解到你以为会卖得好的,可能并不会卖得好。我制作的第一款游戏叫做iMMUNE,当时我在编程时觉得它相当不错。经过多次试玩测试,以及收集人们的意见后,我觉得这款游戏已经准备好发布了,并且希望它能取得巨大的成功,就像我们所有人一样。

发布的那天过去了,我几乎没有看到任何销售。自然地,我感到非常沮丧,但现在我意识到我在开发和发布游戏时犯了很多错误。让我来解释一下发生了什么——这个想法是从我的祖母那里来的,她(就像大多数祖母一样)因为身体不适而需要服用很多药物。有一天,我们讨论了整个猪流感疫情,我那时的青少年大脑自动想,“嘿!我们都吃药,现在的猪流感情况相当疯狂。为什么不利用这个机会呢?”我并不是对世界健康状况漠不关心,我只是认为,在当前情况下,人们会搜索猪流感或药品,我的游戏将会被很多人看到。

因此,我为 iMMUNE 想出了一个主意。这个主意很简单,你扮演一颗药丸,穿越人体,你必须与病毒战斗,最终在游戏结束时摧毁猪流感病毒。以下是 iMMUNE 的截图:

进入游戏开发市场

我认为这是一个很好的主意,周围的人也这么认为。因此,在经过数月的匆忙赶工,并且承认遗漏了一些主要功能,比如带有帮助按钮的主菜单之后,我犯的第一个错误是将游戏上传到 AppStore。我开始公开宣传游戏,用截图和预告片以及预告片来制造炒作。

当时,我觉得,“你知道的,我已经在这款游戏上投入了大约九个月的时间,我希望从中得到一些合理的回报。我将价格定为 2.99 美元。”这是我犯的第二处错误。

这款游戏于 2009 年 8 月 5 日发布,大约在开发后一年。我开始看到一星的评价出现。啊!讨厌的人总是会讨厌。这是我犯的第三个错误。

几天后,我的最好朋友买了这款游戏并给我打电话,这是我们之间的对话:

:嘿,我刚刚买了你的游戏。我怎么玩?

:哦,只需倾斜设备来移动角色,然后按下准星按钮来射击病毒。然后...

我很确定他在我试图解释的时候睡着了。(我会把这个错误和我的第一个错误联系起来。)正如以下图片所示,用户界面没有很好地标记或解释。界面只是随意拼凑在一起的。

进入游戏开发市场

屏幕上有太多东西需要读者理解,但没有任何解释。屏幕上有太多元素需要玩家理解,但都没有解释。这是一个不幸的错误。

不必说,我继续使用名为GameSalad的程序创作了续集,我为此写了两本书。我喜欢这个程序。然而,当我创建 iMMUNE 2 时,GameSalad 还处于非常早期的开发阶段,这意味着它会经常崩溃,而且缺少了很多功能。简而言之,我也把这件事搞砸了。我试图改进第一个程序中的错误。

那么,出了什么问题?让我为你分析一下。

  • 错误 1:我急于推出游戏,遗漏了关键功能。正如所提到的,我没有包括主菜单,这本身并不是什么糟糕的事情。糟糕的是,我没有告诉玩家该做什么,就直接把他们扔进了游戏。当玩家感到沮丧时,他们去 YouTube 上看如何玩游戏,结果发现如果设备倾斜得太远,主要角色就会跑出屏幕。再次强调,我忘记写的那段代码,“如果玩家位置 > 屏幕高度 = 停止移动”,完全被忽略了。我是怎么错过那一点的?

  • 错误 2:我设定了看似合理的价格$2.99。然而,$2.99 能买到什么?一杯咖啡,或者一张多伦多枫叶队的球票,考虑到他们最近的表现?$2.99 能买到很多东西,只需去一家一元店看看就知道了。人们愿意为应用支付$2.99 吗?不。他们曾经这样做过吗?实际上并没有。这就是为什么大多数游戏都采用免费增值的支付结构。游戏可以免费下载,但你可以用一角钱购买 1800 颗胡萝卜种子。

  • 错误 3:当我开始看到一星差评涌入时,我想这只是人们“讨厌”而已,因为他们确实会这样!我的意思是,看吧,我的游戏相当棒。对吧?对吧?不。

我从这些导致我浪费了一年的时间的关键错误中学到了什么?首先,不要急于推进项目。高预算的项目为了赶工期而匆忙完成,而我们作为玩家,讨厌这样的游戏,因为它太不完善了。检查每一行代码,彻底进行游戏测试,以确保一切正常运作。当然,有些东西你可能会错过,因为 14 岁的孩子用油腻的手指以每分钟 1000 次的速率砸着他们的二手 iPad,这不是游戏应该被玩的方式。确保它第一次就能正常工作。我建议在发布前进行焦点小组测试吗?

高昂的价格标签是一个重大的错误。我想从开发游戏所花费的时间中获得大量的回报,这就是为什么我把价格定得太高——太高了。我想我们第一次开发游戏时都有这样的心态,我们没有考虑到我们的价格无法支撑市场。我应该把它定价得尽可能低,当时是 0.99 美元,“免费增值”并不是真正的事情。我觉得我已经为游戏投入了相当多的努力,就像下面的图片所示,我甚至放进了会根据你如何握持设备而倾斜的胃酸。我觉得我已经制作了一个相当不错的、高质量的游戏。哦,我错了。

进入游戏开发市场

最后,我应该阅读评论,并认真对待它们。我意识到我对玩家所做的一切,以及这并不公平。我的游戏有点问题,我承认了。我努力修复它,并将其更新到 1.1 版本。我添加了主菜单、帮助屏幕、游戏开始时的帮助弹出窗口,并修复了那个愚蠢的 bug,其中角色会从屏幕边缘漂浮出去而不是仅仅停止。我还将价格标签降低到 0.99 美元。你可能猜得到我接下来要说什么。太晚了。在我 MMUNE 1.1 版本发布给公众时,对之前错误的冷漠和普遍的厌恶最终导致了游戏的失败。

但是,新玩家怎么办?他们不会在游戏更新时看到新的截图和游戏玩法吗?是的,他们会——但这个时间段比死星上的热力排气口还要短。AppStore 是一个很好的地方,但应用就像僵尸一样蜂拥而至“瑞克·格里姆斯”,你会看到你的应用出现在新列表中,也许一个小时左右,它就下降了十个位置或更多。

这是我在游戏开发市场的个人经历。我有过成功,但那是在失败之后。当我将我的最新游戏paceRoads发布在DesuraIndieRoyale上时,我很幸运地被我的开发者关系联系人告知在游戏发布前降低我之前设定的价格,这最终导致了游戏的成功。

我知道这一切听起来像厄运和阴霾,好像我在“讨厌”这个市场。但,我不是,我爱游戏开发和整个开发者社区。我只是不希望你犯和我一样的错误。

我谈论在市场上赚钱的巨大潜力,而且确实如此!如果你有时间,可以查找Trism的创作者,或者看看愤怒的小鸟。不必多说了,但那些通常是拥有数十亿美元用于广告的巨大公司。像我们这样的普通人没有那么多钱,所以我们的创作,尽管可能很棒,也可能在蜂拥而至中迷失。我们将在第七章部署和盈利中讨论如何克服这一点。

不要让这些话让你气馁,因为游戏开发和市场实际上非常有趣。如果你有任何问题,你可以在开发者论坛上询问一群比你更有知识的人,你将得到答案——而且是一个很好的答案。我甚至有一个开发者请求我发送我的项目给他,以便他能看到问题所在,然后他重新编写了导致我麻烦的代码集。

当我说你进入了一个激动人心的世界时,请相信我!游戏开发不仅是你技术知识的表达,也是你创造力的体现。

在我之前的那本书《Packt Publishing 出版的《GameSalad Essentials》中,我推荐过这本书,在这本书中我还会再次推荐它,请看看电影《Indie Game The Movie》。你将看到一些在市场上取得巨大成功的开发者的真实世界经历,你将看到他们所经历的挣扎和困难。我强烈推荐观看这部电影。

我抱怨够了!让我们进入有趣的部分,下一章我们将开始为我们的游戏创建资产。

摘要

在这一章中,我们注册成为 iOS 开发者,在我们的 Mac 上设置了 Xcode,并讨论了设置证书,以便我们可以开始开发我们棒极了的游戏!我们对 Xcode 和编码文件的工作原理进行了快速概述,并讨论了一些我们可以用来为我们的游戏创建资产(我使用和免费替代品)的程序。然后,我们讨论了我个人在游戏开发市场的经验,预期什么,以及要避免哪些错误。

现在我们已经完成了所有这些,让我们开始创建资产吧!

第二章.创建资源

现在你已经将 Xcode 设置好了,我知道你迫不及待地想要开始开发你那令人惊叹的新游戏!然而,正如你所知,我们必须循序渐进!所有游戏都有资源,例如 3D 模型、图像或超级酷炫的音效。如果你对这类东西一无所知,那么你来到了正确的地点!在本章中,我们将讨论以下内容:

  • 为你的游戏创建或获取资源

  • 创建优化的资源

  • 设计你的游戏

这是你可以开始真正发挥创意的地方!一旦你开始设计你那令人惊叹的新项目的基石,乐趣真的开始了。

有些资源很容易创建,有些则需要更多的努力,有些可能需要支付他人为你创建,还有些可能需要下载免费资源,如下面的图片所示:

创建资源

足够的介绍,让我们开始吧!

让我们谈谈资源

资源是游戏的基石。它们将基本上组成你的游戏,无论你是创建 3D 大片,还是像本书中我们将要做的 2D 平台游戏。

你将需要创建、购买或为你创建的项目资源。让我们来分解一下。

精灵

什么?!别担心,不是像《奥德赛》中的水精灵那样。当谈到视频游戏资源时,精灵基本上是游戏中使用的图像。看到本章引言中的马里奥图像了吗?那就是精灵。精灵还可以用于特殊效果,如 2D 镜头光晕或粒子。精灵通常创建在包含角色及其动画的精灵图中,如下面的图片所示,这是一张从许多《洛克人》系列中撕下来的精灵图:

精灵

精灵可以非常容易地创建,也可以非常复杂,需要花费数小时来绘制。是的,绘制。精灵可以使用任何图像软件绘制,只需使用鼠标,或者平板电脑(我强烈推荐购买一个,因为它可以让精灵创建变得容易得多)。

另一方面,精灵可以使用 3D 动画程序渲染。我曾在一些早期的游戏中使用过 3D Studio Max,我对结果非常满意。

  • 最佳精灵大小:根据设备而异,但一个 32x32 像素的图像在 3GS 上看起来不错,但在视网膜 iPad 上则几乎看不见。

  • 文件类型:在我看来,PNG 通常是最好的文件格式,因为它们具有很好的压缩(文件大小小=性能更好)、质量和支持透明度,这在绘制你的角色时是必不可少的,你不想让他们周围有一个白色的框!

  • 推荐软件:我偏爱 Adobe Photoshop,但正如我在其他书中多次提到的,有很多免费的替代品。例如,Gimp 就是一个完美的例子。

音效

BOOM! KABLAMO! PAFF! 嗯,这些话泡并不是游戏必需的!音效和您创建的精灵一样重要。现在,除非您有专业的音频录制设备,否则音效的创作可能会很棘手。对我来说,我在互联网上搜索免版税音效。相信我,有很多可供您使用的音效,从卡通音效到枪声和爆炸声。 www.freesound.org 是一个我多次为我的游戏使用的优秀网站。

文件类型

iPhone 音频 SDK 文档指出,iPhone 支持多种音频格式,然而每种格式都有其缺点:

  • MP3:这是一种高度压缩但也是高质量的编码格式,可以保留音乐和语音的丰富性。然而,iPhone 一次只能播放一个 MP3 流,因为它需要使用硬件解码器。这使得 MP3 不能作为音效的格式,因为它会打断背景音乐和/或正在播放的用户音乐。

  • WAV:iPhone 仅支持 PCM 编码且不需要任何压缩代码的 WAV 文件。虽然 WAV 支持许多不同的采样率和比特深度,但它会产生非常大的文件(16 位/44.1kHz 立体声音频为 180k/sec)。当您需要快速播放许多效果时,这种大尺寸变得难以承受。

  • AIFF:这与 WAV 文件有类似的问题——支持 Apple 无损压缩。

  • 自定义:您可以使用 iPhone 的低级 API 从任何来源播放自己的音频,但这对在游戏中播放简单的音效来说似乎有些过度。

  • 推荐软件:对于处理音效和重写/转换它们,我迄今为止找到的最好的软件是Audacity。它是免费的,易于使用,并支持主要的音频格式。以下图片显示了 Audacity 的标志:文件类型

音乐

另一个重要的资产是音乐。我喜欢音乐,希望我的游戏里能一直播放着歌曲。然而,我理解在许多游戏中,音乐可以用来设定氛围,并为接下来的场景激发情感。创作音乐并不困难,只需要有良好的听觉和一点点的创造力。您可以自己创作音乐,或者下载免版税音频。您总是想确保它是免版税的,否则可能会让某人非常(法律上)生气。

格式

如前所述,音乐的最佳格式是 MP3。它只允许一个音轨,因此不会被其他音效打断,提供高质量、低文件大小。

最佳软件

使用 Garage Band 进行创作,以及任何编辑,再次使用 Audacity。

视频

亲自来说,我喜欢为我的游戏创建和播放剪辑场景视频。我觉得它们在游戏之间提供了一个很好的休息,但,嘿,那只是我。我知道许多人一看到就开始跳过。

以下图片是从我的一个名为LOST的项目介绍视频中抓取的:

视频

文件类型

M4V(许多程序,包括 QuickTime,可以将任何视频转换为 M4V,但如果你想有更多的控制权,请使用Handbrake)。

最佳软件

我经常使用 3DS Max 进行我的视频创作,然后将其导入iMovie进行编辑。如果你在寻找一个 2D 解决方案,试试 Adobe Flash(我相信它曾经被称为FlashMX)。对于视频转换,使用 Handbrake,它有大量的设置可以调整,以调整视频以达到所需的画质和文件大小。

创建优化资源

让我们从创建优化精灵以获得最佳性能开始。这将如何影响性能?将游戏想象成一部视频,将你的设备想象成 DVD 播放器,甚至电视。游戏逐帧播放,你的设备显示每一帧。然而,它必须渲染每一帧。这发生得如此之快,以至于你永远不会注意到,但现在想象一下,如果你的设备必须渲染大图像(让我们想想拨号互联网加载一张大照片)。这可能需要更长的时间,因为设备必须更努力地工作,所以电池会更快地耗尽。

这只是可能减慢性能的一个原因;我们将在后面进一步讨论其他原因。

在下面的图中,我画了我上一本书中的角色设计,我在 Photoshop 中画了他:

创建优化资源

供你参考,他的名字是凯文。看起来不像什么,对吧?以下是绘画的详细信息:

  • 图像大小:52px x 52px

  • 色彩空间:RGB

  • 压缩:目前没有

  • 文件类型:未保存

我继续将图像保存为三种不同的文件类型:位图、PNG 和 JPEG。在下面的图中,我们可以看到不同的文件类型,从左到右分别是 PNG、JPEG 和位图:

创建优化资源

那么,我们在这里看到的是什么?让我们来分析一下。我们将从 JPEG 开始,这是一种广泛使用的图像格式:

  • 文件大小:29KB,当你考虑到我们现在有数 TB 的硬盘时,这相当小。然而,JPEG 确实声称有最大的文件大小。

  • Alpha 通道:不,如前所述,JPEG 不允许透明度,如果你只是创建一个填充整个框且没有背景的精灵,那就没问题,但对我们这里的这个小角色来说,JPEG 对我们没有任何帮助。

除了这些,JPEG 与 PNG 文件相同:

  • 文件大小:9KB,比其他文件小得多,这意味着性能提升!

  • Alpha 通道:是的!这个家伙在这里是赢家!

然后是位图:

  • 文件大小:17KB,再次非常小,在这种情况下是中等文件大小

  • Alpha 通道:再次,没有

  • 色彩配置文件:不存在

实际上,位图是基本图像文件,关于实际图像的信息不多。

太棒了!所以看起来 PNG 是赢家!我进一步测试了我的理论:我将这三张图片导入到一个空的 SpriteKit 项目中,想看看渲染每张图片之间的差异。想知道我的发现吗?当然你想知道!这完全关乎科学!:

  • BMP: 不能像其他两张图片那样导入到 Images.xcassets 部分,这真是个遗憾...但我找到了解决办法。CPU 使用率高达 16%,内存使用量在 7.6MB 到 7.8MB 之间(与 PNG 非常相似),当精灵创建时的帧率下降到 56FPS,这是一个完全不可察觉的下降。

  • JPEG: 在 Xcode 的调试器中,只需简单地渲染精灵文件旋转如陀螺,CPU 使用率似乎在 12% 左右(我使用的是 16GB 的 iPhone 5S),内存使用量为 7.9MB,帧率保持在 60FPS,这是你能达到的最佳效果。

  • PNG: 再次,在调试器中,渲染 PNG 图像旋转如陀螺,CPU 使用率在 11% 到 12% 之间波动,内存使用量在 7.5MB 到 7.8MB 之间,比 JPEG 略低,但仅渲染一张图片时并不明显,帧率保持在稳定的 60FPS。

下面是研究结果的样子:

创建优化资源

对于每种文件类型,我在屏幕上创建了大约 200 张图片,所有图片都以相同的速度旋转,并且从未看到帧率有任何波动,但确实看到了 CPU 和内存(大约增加了 5-10%)的适度增加。

总体来说,这些数据并不差。似乎 PNG 的渲染比其他格式更容易,这在渲染内存中的多个对象或图像时差异很大。

视频转换

当你使用没有 iOS 预设的第三方程序转换视频时,iOS 支持以下格式和设置:

  • H.264 视频: 最高 1.5 Mbps,640 x 480 像素,每秒 30 帧,H.264 基线版本的低复杂度,带有 AAC-LC 音频,最高 160 Kbps,48 kHz,立体声音频,文件格式为 .m4v.mp4.mov

  • H.264 视频: 最高 768 Kbps,320 x 240 像素,每秒 30 帧,基线配置文件,最高 Level 1.3,带有 AAC-LC 音频,最高 160 Kbps,48 kHz,立体声音频,文件格式为 .m4v.mp4.mov

  • MPEG-4 视频: 最高 2.5 Mbps,640 x 480 像素,每秒 30 帧,简单配置文件,带有 AAC-LC 音频,最高 160 Kbps,48 kHz,立体声音频,文件格式为 .m4v.mp4.mov

注意

更多信息,请参阅 developer.apple.com/library/ios/documentation/Miscellaneous/Conceptual/iPhoneOSTechOverview/MediaLayer/MediaLayer.html

记住,开发者论坛和 iOS 开发文档将是你的最佳朋友!

音频转换

Audrey Tam 有一篇关于 iOS 音频编码的精彩文章。以下是文章中在raywenderlich.com (www.raywenderlich.com/69365/audio-tutorial-ios-file-data-formats-2014-edition)找到的快速摘录:

"实际上,只有少数几种(格式)是首选的编码方式。要知道使用哪种,你首先需要记住这一点:"

"你可以快速且无问题地播放线性 PCM、IMA4 以及一些其他未压缩或仅压缩的格式。"

"对于更高级的压缩方法,如 AAC、MP3 和 ALAC,iPhone 确实有硬件支持快速解压缩数据——但问题是它一次只能处理一个文件。因此,如果你同时播放这些编码中的多个,它们将在软件中解压缩,这会很慢。"

所以,为了选择你的数据格式,这里有一些通常适用的规则:

如果空间不是问题,只需使用线性 PCM 对所有内容进行编码。这不仅是你音频播放最快的方式,而且你可以同时播放多个声音而不会遇到任何 CPU 资源问题。

如果空间是问题,你很可能会想为背景音乐使用 AAC 编码,为音效使用 IMA4 编码。

如果你游戏中有很多音频文件正在播放(谁没有呢?),你可以通过将它们导入 iTunes,然后右键单击并选择创建 AAC 版本来节省大量时间和猜测。iTunes 将创建一个新的 AAC 版本,你可以找到并导入到你的项目中,而且它将 100%有效。无需猜测!

我知道,有时试图弄清楚使用哪种格式以及如何转换东西可能会让人感到困惑,但苹果通过在 iTunes 中直接包含音频转换功能,使得事情变得相对简单。

现在让我们简单谈谈设计。

如何设计你的游戏

游戏设计可以非常有趣,尤其是当你有一个非常好的想法,并且你只是继续滚动并添加更多内容时。毫无疑问,你已经有了一个游戏的想法,但总是把事情写下来是一个好主意。我经常告诉人们,写下它,否则你可能会完全忘记,或者某些关键细节。我自己也这样做过,我在凌晨 2 点醒来时有一个关于新游戏的绝佳想法,但我没有写下来,到早上我完全忘记了那个绝佳的想法。这不仅有助于记住事情,还有助于规划和加速创建和开发过程。本书中我们将要创建的游戏将以《contra》的风格进行,它将是一款平台射击游戏,我们将添加允许与朋友一起玩的能力!但这将在本书的后面部分介绍。我们还将一起进行规划过程。以下是一个模板游戏设计文档,它将概述我们计划在本书中为游戏做的事情。

一些这些标题将被省略,这是可以的,因为我们可以随着进展了解某些细节。

游戏设计文档

在创建游戏设计文档时,我们将使用以下模式:

  • 标题页

    • 游戏名称:Adesa
  • 游戏概述

    • 游戏概念:一款如《contra》的游戏,2D 平台射击游戏。玩家是一个卡通太空士兵,在飞船爆炸后被发射到太空中。

    • 类型:平台射击游戏。

    • 目标受众:从儿童到成人(游戏将不包含任何暴力画面)。

    • 游戏流程摘要:游戏将是一款横版卷轴游戏,玩家将通过触摸控制进行操作,无论是在游戏中还是在菜单中。

    • 外观和感觉:8 位 2D 的乐趣。

  • 游戏玩法

    • 游戏进度:逐级,没有玩家升级

    • 任务/挑战结构:定位丢失的设备 → 找到离开星球的方法

    • 谜题结构:NA

    • 目标:游戏的目标是什么?

      寻找丢失的设备

      在找到被摧毁的补给后寻找食物

      逃离被囚禁的居民并夺回被盗的设备

      到达通讯中继站

      与通讯官战斗

      收音机广播称坠毁是故意的

      开始回收零件以建造一艘船

      建造飞船

      与居民战斗以防止你的飞船被摧毁

      返回家园(太空游戏玩法?)

      与宿敌战斗

  • 机制:游戏规则是什么,无论是隐含的还是明确的?这是游戏运行下的宇宙模型。把它想象成一个真实世界的模拟,所有这些部分是如何相互作用的?这实际上可以是一个非常大的部分。

    • 物理:正常

    • 游戏中的移动:跑和跳

    • 对象:一些可以通过推动移动(例如,走进它们)

    • 动作,包括使用的任何开关和按钮,与对象的交互,以及使用的通信方式:需要按下的按钮(确切的方式将在开发过程中确定),通信将通过屏幕文本进行

    • 战斗:用 telefuser 射击敌人(需要记住这个名字)

    • 经济:无

    • 游戏选项:可能的难度、Facebook 集成以发布成就、多人游戏

    • 作弊和彩蛋:随着开发的继续,将逐步揭晓

  • 故事、背景和角色

    • 故事和叙述:简单而甜蜜,你的飞船在太空任务中爆炸,把你抛入浩瀚的太空。你降落在未探索的世界,必须找到你的补给品并战斗回到家园。你发现爆炸是计划好的,因为主要敌人想要你在太空军中的位置。

    • 游戏世界

      • 世界的一般外观和感觉:阴暗而忧郁,几乎像森林一样。
    • 角色:杰夫,主要玩家,是太空军的首脑。莫莉,副指挥官,是第二个可玩角色。内莫是你的宿敌,他计划摧毁你的飞船以取代你在太空军中的位置。

  • 关卡

    • 关卡:每个关卡应包括摘要、所需的开场材料(以及如何提供)、目标和关卡中发生事件的细节。根据游戏的不同,这可能包括地图的物理描述、玩家需要采取的关键路径,以及哪些遭遇是重要的或偶然的(参见 3.1.4 节中的关卡进度想法)。

    • 训练关卡:找到你的装备将让玩家熟悉控制台,并逐渐让他们接触战斗。

  • 接口

    • 视觉系统:如果你有 HUD,上面有什么?健康、生命和枪管热度

    • 控制系统:屏幕触摸控制

    • 音频、音乐、音效:将在稍后完成

    • 帮助系统:主菜单和训练关卡的帮助屏幕

  • 人工智能

    • 对手和敌人 AI:简单的节奏行星居民战斗者,一看到你就射击

    • 非战斗和友好角色:N/A

  • 技术

    • 目标硬件:iPhone 和 iPad

    • 开发硬件和软件,包括游戏引擎:SpriteKit

    • 网络需求:在线活动所需的蓝牙和可能的无线需求

  • 游戏美术:正在开发中

看吧?这并不难!它相当深入,但并非所有内容都是必需的。我实际上删除了很多行,因为它们不适用于我们即将创建的游戏。现在游戏基本上已经规划好了,你可以利用另外两个设计文档。第一个是关卡设计表,第二个是分镜脚本。

关卡设计表为你提供了一个网格,你可以用它来在纸上创建你的关卡:

游戏设计文档

本书资源部分为您提供了空白模板。我建议您将它们打印出来并放入活页夹中,这样您就可以拥有一个如图所示的“游戏设计手册”:

游戏设计文档

我使用分镜来设计游戏中将发生的故事场景。这不是必需的,但嘿,把事情规划好总是好事,这样开发过程就可以快速而顺利地进行。

我喜欢规划过程!我认为我们差不多可以起飞并进入开发阶段了,不是吗?是的!不过这要等到下一章,我已经在本章中说了很多了。当涉及到设备性能和优化时,有很多东西需要学习,而且可能会相当复杂。我完全是自学成才的,所以我希望你也学到了一些东西!

奔向更高处,让我们开始我们精彩游戏的发展吧!

摘要

在本章中,我们讨论了资产创建、使用哪些程序以及最佳格式。然后我们进行了文件类型渲染的精彩比较,以展示哪种文件类型更高效且对系统更友好。接着我们讨论了游戏开发的设计方面。我们查看了一个游戏设计文档,以及关卡设计和分镜脚本文档。在下一章,我们将开始开发!

第三章。发射!从开发开始

现在真正的乐趣开始了!我们将开始我们的游戏开发!你和我一样兴奋吗?如果你不兴奋,你应该兴奋!看看我们将要涵盖的内容:

  • 在 Xcode 中创建 SpriteKit 项目

  • 级别设计和实现

  • 重力 - 玩家移动

  • 碰撞检测

我们将涵盖所有这些以及更多内容!

系好安全带,因为我们即将全速前进,而且我们不会回头!好吧,也许我们会一两次,但你知道我的意思。让我们开始!让我们创建我们的项目!

在 Xcode 中创建 SpriteKit 项目

让我们立即打开 Xcode 并点击文件 | 新建 | 项目。然后您将看到新项目向导,它看起来像这样:

在 Xcode 中创建 SpriteKit 项目

对于这个项目,我们显然将选择iOS | 应用程序 | 单视图应用程序,然后点击下一步

一旦您完成这些,您将需要填写一些关于项目的详细信息,例如产品名称、组织名称等。请参阅以下截图:

在 Xcode 中创建 SpriteKit 项目

填写所需的字段(如果尚未填写),然后点击下一步。首先,我们将对项目进行一些更改。例如,在左侧侧边栏中找到LaunchScreen.xib文件,并通过按键盘上的Delete键将其删除。

注意

.xib和 Storyboard 文件是您可以使用它们来创建视图界面的接口文件。在这些文件中,您可以创建按钮、文本标签和其他用户界面元素。

您将看到一个弹出窗口询问您是否想要删除引用或将它移动到垃圾桶。由于我们将编写玩家将看到的所有内容,所以我们不需要它,可以安全地将其移动到垃圾桶。

然后点击左侧侧边栏中的主项目文件;在屏幕中间,您将看到所有项目设置。我们也将对这个部分进行一些更改。

在 Xcode 中创建 SpriteKit 项目

首先,在应用程序图标和启动图像展开中,找到启动屏幕文件下拉菜单。在它说LaunchScreen.xib的地方,只需选择文本并按以下截图所示删除它。同样,因为我们将要编程一切,所以我们不需要它在我们的情况下。

在 Xcode 中创建 SpriteKit 项目

稍微向下看,您将看到链接的框架和库展开。

框架是您可以添加以进一步增强您应用程序功能的扩展。您可以添加的内容示例包括 CoreGraphics 和 SpriteKit;甚至可以下载并添加 Facebook API 或 Cocos2D 以获得更多功能。

这是我们添加各种框架的地方(如果您还不知道的话)。我们将添加五个框架和一个库。为此,点击该部分底部的+按钮。我们将添加的文件如下:

  • libz.dlib

  • CoreGraphics.framework

  • UIKit.framework

  • SpriteKit.framework

  • GLKit.framework

  • Foundation.framework

下面是链接的框架和库的截图:

在 Xcode 中创建 SpriteKit 项目

我们的游戏将充分利用这些框架,正如我们将在本章中看到的那样。我喜欢保持事物整洁有序,而 Xcode 只是将文件扔到左侧侧边栏的顶部。我选择了刚刚添加的新框架,右键单击它们,选择从选择新建组,并将该文件夹命名为Frameworks。我在开发时喜欢保持良好的组织;我甚至确保所有代码段都正确缩进。

在您添加了新的框架之后,您必须下载本书资源部分中的两个文件夹。它们包括免费在线提供的额外框架,这些框架将帮助我们进一步,尤其是在我们的关卡设计中。

框架是SKTUtils,它实际上是SpriteKit的扩展,以及JSTileMap,我们将用它来制作地图。

SKUtils框架是SpriteKit的扩展,它增加了更多的视觉效果以及额外的物理计算(例如,在更复杂的物理计算中使用π(pi))。

JSTileMap框架允许我们将瓦片地图文件导入到 Xcode 中。正如您稍后会发现的那样,Xcode 本身不支持地图文件。

下载完成后,只需选择这两个文件夹,并将它们拖到项目左侧的栏中,如下面的截图所示:

在 Xcode 中创建 SpriteKit 项目

确保您点击了如果需要则复制项目复选框;这将把这些文件夹复制到您的项目文件夹中,这样任何更改都不会影响原始文件。

导入SKTUtils框架时,我发现它向我抛出了 20 个错误,其中大部分是完全无法理解的。所以在经过深思熟虑和调试后,我意识到(请注意,我在写本章的过程中意识到),我们需要添加一个前缀头文件。前缀头文件是为了更快地预编译头文件而创建的。所以不是逐个编译每个头文件,而是预先编译一次。

点击文件 | 新建 | 文件。一旦出现向导,点击iOS下的其他,选择PCH 文件(预编译头文件),并将其命名为类似yourProjectName-Prefix.pch的名称。

创建该文件后,点击它进行编辑;我们将填充以下代码:

#import <Availability.h>

#ifndef __IPHONE_5_0
#warning "This project uses features only available in iOS SDK 5.0 and later."
#endif

#ifdef __OBJC__
    #import <UIKit/UIKit.h>
    #import <Foundation/Foundation.h>
#endif

我们需要定义我们刚刚在 Xcode 中创建的 PCH 文件,否则它将变得毫无用处,因为它将不会做任何事情!

一旦我们填写完那个文件,我们将点击左侧栏中的我们的项目。现在你将在导航栏顶部看到标签:通用能力信息构建设置构建阶段构建规则。点击 构建设置。向下滚动以找到 Apple LLVM 6.1 – 语言 并将 预编译前缀头 选择更改为 。下面,双击 前缀头 部分的空字段并填写它以符合你的项目。我命名为 Adesa,所以我填写了 Adesa/Adesa-Prefix.pch,如下面的截图所示:

在 Xcode 中创建 SpriteKit 项目

接下来!

现在我们必须添加更多的主和头文件;一组将被称为 Player,另一组将被称为 GameLevelScene。不确定如何添加这些?没问题!只需点击 文件 | 新建 | 文件 或按 Command + N。对于这些文件,我们将选择 Cocoa Class 文件并点击 下一步,如下面的截图所示:

在 Xcode 中创建 SpriteKit 项目

再次填写 类名 的名称,再次一个将是 Player,另一个是 GameLevelScene。我知道设置东西有点无聊,但我们必须这样做。现在,让我们开始编辑我们的代码!

编辑我们的代码文件

我们需要对源代码文件进行一些修改。我们将从 ViewController 接口文件集开始,即 .h.m 文件,也就是控制一个定义视图的文件。我们将从 .h 文件开始;目前,它应该看起来像这样:

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController

@end

我们需要在 #import <UIKit/UIkit.h> 行下面直接添加 #import<SpriteKit/SpriteKit.h>

提示

什么?!

我们刚刚插入到代码中的这一行将 SpriteKit 框架导入到该头文件中,这样我们就可以在正在工作的头文件中访问 SpriteKit 的功能。

目前这个 .h 文件就到这里;在我们设置项目的时候,我们将在文件之间来回跳动。

接下来是 ViewController.m 文件,它应该被编辑成如下所示:

#import "ViewController.h"
#import "GameLevelScene.h"

@implementation ViewController

-(void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    //Configure the view.
    SKView * skView = (SKView *)self.view;
    skView.showsFPS = YES;
    skView.showsNodeCount = YES;

    //Create and Configure the scene.
    SKScene * scene = [GameLevelScene sceneWithSize: skView.bounds.size];
    scene.scaleMode = SKSceneScaleModeAspectFill;

    //Present (or show) le scene.
    [skView presentScene:scene];
}

- (BOOL)shouldAutorotate
{
    return YES;
}

- (NSUInteger)supportedInterfaceOrientations
{
    return UIInterfaceOrientationMaskLandscape;
}

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}

@end

我知道你在想:这一切意味着什么? 我将从开始的地方开始,让那些没有编码经验的人更容易理解:

#import "ViewController.h"
#import "GameLevelScene.h"

我们再次看到导入行;我们导入 ViewController 头文件,所有声明都将在这里。接下来是 GameLevelScene 头文件;这个文件将托管游戏场景,我们可能会在这个文件上花费大部分时间,如下面的代码所示:

-(void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    //Configure the view.
    SKView * skView = (SKView *)self.view;
    skView.showsFPS = YES;
    skView.showsNodeCount = YES;

    //Create and Configure the scene.
    SKScene * scene = [GameLevelScene sceneWithSize: skView.bounds.size];
    scene.scaleMode = SKSceneScaleModeAspectFill;

    //Present le scene.
    [skView presentScene:scene];
}

通过这段代码,我们设置了 SpriteKit 视图。第一行,SKView * skView = (SKView *)self.view; 声明了一个新的 SpriteKit 视图实例,命名为 skView,并将其放置在 ViewController 头文件视图中的 self.view

接下来的两行完全是可选的,因为它们更多是为了测试目的,您在发布应用程序之前会删除它们。第一行显示每秒帧数,下一行显示场景中的节点或对象数量。如果您不想看到这些行,现在可以随意删除它们。

下一行实际上创建并配置了场景。正如SKScene * scene = [GameLevelScene scenewithSize: skView.bound.size]所示,数据正在从我们还需要设置的GameLevelScene文件集中提取。目前它将抛出一个错误,表示没有已知类方法sceneWithSize,但请放心,因为我们还没有在GameLevelScene文件集中进行任何声明。

之后,我们看到场景的缩放设置为AspectFill。您可以选择适应或拉伸,但在这个例子中我们将使用适应填充。如果您喜欢,可以更改它。

最后,我们展示场景!

让我们跳转到GameLevelScene文件。我们再次从头文件开始,它应该如下所示:

#import <SpriteKit/SpriteKit.h>

@interface GameLevelScene : SKScene

@end

简而言之,我们将GameLevelScene类更改为SpriteKit场景;因此,当我们构建项目时,之前提到的错误将消失。接下来是.m文件,它将如下所示:

#import "GameLevelScene.h"

@implementation GameLevelScene

-(id)initWithSize:(CGSize)size {

  if (self = [super initWithSize:size]) {

  }
  return self;
}

@end

再次强调,这只是一个简单的场景初始化和设置大小。现在让我们构建我们的项目!目前您不需要连接设备;我们将在模拟器上运行。如果您正在使用较旧的电脑,我建议选择 iPhone 4S 作为模拟器,如图所示,主要是因为它加载时间较短,屏幕尺寸足够小,可以很好地适应您的屏幕。

编辑我们的代码文件

简单地点击之前截图中的按钮(位于播放和停止按钮旁边的按钮),然后选择您喜欢的设备用于模拟器。

现在,让我们构建项目。点击顶部的Play按钮,或者按cmd + B(或者按cmd + R来构建和运行项目)。

如果一切顺利,它应该会成功构建,iOS 模拟器应该会出现。哎呀!你遇到了一个看起来像这样的错误吗?

0x22d9a <+138>: movl   %eax, -0x10(%ebp)
2015-04-30 11:33:20.916 Adesa[25543:4462832] -[UIView setShowsFPS:]: unrecognized selector sent to instance 0x7a660940
2015-04-30 11:33:20.918 Adesa[25543:4462832] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[UIView setShowsFPS:]: unrecognized selector sent to instance 0x7a660940'
*** First throw call stack:
(
(LOTS OF WRITING HERE)
)
libc++abi.dylib: terminating with uncaught exception of type NSException
(lldb)

我们还需要在 Storyboard 中对UIViewController头文件进行更改。

在项目左侧的栏中找到Main.storyboard文件(记得我们之前讨论过 Storyboard 和.xib文件?),然后点击它。我们可以在这个文件中安排应用程序的 UI,但在这个项目中我们可能不会使用它。现在您将看到一个空白 iOS 布局,非常适合更改。

我们在这里要做的就是启动侧边栏上的ViewController Scene展开,就在所有我们的项目文件所在的栏旁边,然后展开View Controller展开,点击View,如图所示:

编辑我们的代码文件

现在看看最右边的栏,你将看到顶部栏有六个不同的按钮。点击第三个按钮,你会看到 显示身份检查器。现在你将看到按钮下方的一个名为 自定义类 的部分。在这个部分的 文本框中,我们将输入 SKView,如以下截图所示:

编辑我们的代码文件

让我们再次尝试运行我们的项目。它构建成功了吗?太好了!它打开了 iOS 模拟器吗?太棒了!它是否显示了一个带有帧率和屏幕底部打印的零节点的空白设备?太神奇了!我们正走在正确的轨道上!

对于我们的最终修改,我们将快速编辑 Player 文件夹。此刻,我们只需编辑头文件。将其修改为以下内容:

#import <SpriteKit/SpriteKit.h>

@interface Player : SKSpriteNode

@end

我们现在将致力于“暂停以产生效果。”

级别设计和实现

这是我们可以玩得很开心的地方!我们现在将开始设计我们的级别。你可以花几个小时为每个级别添加细节,使它们看起来完美。记住,设计良好的级别会给玩家留下深刻印象,并给你的游戏带来专业的外观。

不幸的是,Xcode 并没有级别设计的拖放便捷性,因此我们将使用一个名为 Tiled Map Editor 的第三方程序来创建我们的地图,该程序可以在 www.mapeditor.org 上免费下载。

我在本书的 Resources 部分包括了我们的级别精灵表以及一个构建好的级别。你还记得本章中包含的 JSTileMap 库吗?它就是用来显示这些地图的,因为 SpriteKit 不支持 TMXTileMaps。真是太遗憾了!

无论如何,继续前进!打开我包含的级别,名为 level1.tmx,感受一下程序的工作方式。

Tiled 的侧边栏显示了不同层级的层次。在这种情况下,我们有 危险,这包括像刺和其他可能对我们的玩家健康造成损害的物体。然后是 墙壁,相当直观,接着是 背景,用于场景元素,如云和树。

级别设计和实现

尝试对级别进行一些编辑,使其成为你自己的小杰作。以下截图显示了你需要的所有工具;图像中选中的工具是印章工具,它允许你在选定的层上放置选定的图像。然后你有油漆桶和橡皮擦工具。试试看!完成之后,我们将把级别编程到我们的游戏中。

级别设计和实现

让我们打开我们的 GameLevelScene.m 文件,并在文件顶部 #import "GameLevelScene.h" 之下添加 #import "JSTileMap.h"

在我们刚刚插入的导入下面,我们将添加以下行:

@interface GameLevelScene()
@property (nonatomic, strong) JSTileMap *map
@end

这是在我们的 GameLevelScene 类中添加一个用于我们将会使用的地图的私有变量。

现在,我们将实际加载地图。在 (id)initWithSize:(CGSize)size 代码块中,在 if 语句内,添加以下代码以更改天空的颜色以及加载地图:

self.backgroundColor = [SKColor colorWithRed:.25 green:.0 blue:.25 alpha:1.0];

self.map = [JSTileMap mapNamed:@"level1.tmx"];
[self addChild:self.map];

运行项目以查看您的精彩关卡现在已显示在屏幕上。我决定使用深紫色来设计这些关卡,因为这正是我想要的环境。你可以选择任何你喜欢的颜色,但请记得相应地调整颜色。

如果由于某种原因它没有正确显示或你遇到了错误,请确保你的 GameLevelScene.m 文件现在看起来像这样:

#import "GameLevelScene.h"
#import "JSTileMap.h"

@interface GameLevelScene()
@property (nonatomic, strong) JSTileMap *map;
@end

@implementation GameLevelScene

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        //CUS ITS SKY FALL!...This makes the background purple...
        self.backgroundColor = [SKColor colorWithRed:.25 green:.0 blue:.25 alpha:1.0];

        self.map = [JSTileMap mapNamed:@"level1.tmx"];
        [self addChild:self.map];
    }
    return self;
}

@end

看起来相当酷吧?Tiled 是一款很棒的程序,你可以添加比我这里更多的细节。

关卡设计和实现

虽然有些东西缺失,对吧?嗯...哦,是的!我们的玩家!

重力 – 玩家移动

现在,导入我在本书资源部分提供的 Player 图片(在 sprites.atlas 文件中)。除非你有自己的!那么无论如何,请使用你的。

让我们回到我们的 GameLevelScene.m 文件并导入另一个文件。这次,将是 #import "Player.h"。然后,在之前添加的 @interface 部分之后,我们将添加另一个与刚刚添加的地图属性相似的属性:@property (nonatomic, strong) Player *player1。我使用 player1,因为我们将在以后添加一些多人游戏功能!

然后,在 initWithSize 函数内,我们将添加以下代码:

self.player = [[Player alloc] initWithImageNamed:@"P1Idle"];
self.player.position = (CGPointMake(100, 50);
self.player.zPosition = 15;
[self.map addChild: self.player];

当我们构建并运行我们的项目时,我们应该得到以下图像中所示的结果。我认为它开始看起来相当酷了!

重力 – 玩家移动

稍等一下,牛仔!我们刚才做了什么?好吧,让我来解释一下发生了什么。我们刚才添加的代码将我们的太空人加载为一个精灵对象,将其定位在地图上,然后将其添加到地图对象中。

小贴士

这里有一些需要注意的事情,因为你可能想知道为什么我们的小家伙被添加到地图中,而不是直接添加到场景中。好吧,让我告诉你!这完全关乎控制。我喜欢完全控制(而且我的妻子说我可以说这个),我们想要精确控制地图中的哪些层在我们的小家伙之前和之后。例如,我们可以将背景对象,如树木和山丘,设置在后面,但如果我们想的话,我们可以将我们的太空人定位在它们后面。所以然后他需要成为地图类的子对象,或子类。

现在我们需要让这个家伙移动!首先,让我们在场景中添加一些重力。回到我们的 GameLevelScene.m 文件,我们再次在相同的位置添加另一个属性,即在代码的 Interface 部分中。它将是这样的:

@property (nonatomic, assign) NSTimeInterval previousTime;

请参考以下屏幕截图以确保您的代码填写正确:

重力 – 玩家移动

然后,我们将编写一个update方法,并将其放置在-(id)initWithSize:(CGSize)size {代码行之前。为了创建这个方法,我们将添加以下代码:

- (void)update:(NSTimeInterval)currentTime
{

    NSTimeInterval delta = currentTime - self.previousTime;

    if (delta > 0.02) {
        delta = 0.02;
    }

    self.previousTime = currentTime;

    [self.player1 update:delta];
}

这将导致当前抛出错误,表示没有可见的@interface用于声明更新选择器的玩家。是不是很困惑?让我们一步一步来解释我们在这里做了什么。

首先,我们添加了这个更新方法,它是自动构建到 SpriteKit 场景或SKScene对象中的。我们只需要编写代码!然后,在场景渲染之前,我们将调用每一帧。更新方法为我们提供了一个计时器值,即程序的当前时间。

其次,我们获取delta值,即当前时间减去前一时间。delta值是什么?它本质上是从上次调用更新以来间隔的时间。有了这个delta时间值,我们可以创建平滑的运动、重力和其他力,以及平滑的动画。

然后我们有一个if语句;如果delta值超过 0.02,它将被保持在 0.02。有时我们的设备会滞后、卡顿或变慢,尤其是在第一次启动游戏时,设备有很多要加载的内容;因此,这个delta值可能相当大。我们将其保持在一致值以减少物理行为异常的可能性。为什么会发生这种情况?

就像之前提到的,这个delta值通过保持整洁一致的价值来创建平滑的运动和重力。如果这个值远远超过 0.02,我们编写的运动或任何外部力将不会正常工作,可能会发生我们不希望发生的事情。这可以称为预防措施,这样我们就不创建任何破坏游戏的 bug。在if语句之后,我们将当前时间(例如 0.02)设置为前一时间,以便设备可以确定delta值。让我们澄清一下,为了确定时间前进的速度,我们有当前时间和前一时间,所以在时间前进之前,我们将当前时间设置为前一时间,然后当前时间前进。

分解来看,时钟开始计时,当前时间是 0.05,在时间前进之前,我们将前一时间变量设置为 0.05,然后当前时间将增加到 0.06。然后设备将计算delta值。明白了吗?我知道这听起来可能很多。

然后我们来到导致错误的代码行。它调用错误是因为我们没有在我们的Player文件集(或类)中实现update方法。现在让我们来做这件事!

进入我们的Player.h文件,并更改代码,使其看起来像这样:

#import <SpriteKit/SpriteKit.h>

@interface Player : SKSpriteNode //(These lines should be here already)

@property (nonatomic, assign) CGPoint velocity;
-(void) update:(NSTimeInterval)delta;
@end

哇,哇,朋友,这没有道理!在编写游戏时,有很多物理和数学知识需要应用到编码中。在@property (nonatomic, assign) CGPoint velocity;这一行,我们创建了一个属性,用来测量玩家移动的速度。

你问什么是CGPointCG代表CoreGraphics,这是 iOS 设备使用的主要图形渲染框架,point是屏幕上的一个点,所以CGPoint包含对象的定位值;在这种情况下,velocity现在将有一个xy值,使我们能够计算出玩家移动的确切速度和方向。很有趣!

让我们跳转到Player.m类文件,并将代码更改为以下内容:

#import "Player.h"

#import "SKTUtils.h"

@implementation Player

- (instancetype)initWithImageNamed:(NSString *)name {
    if (self == [super initWithImageNamed:name]) {
        self.velocity = CGPointMake(0.0, 0.0);
    }
    return self;
}

- (void)update:(NSTimeInterval)delta {

    CGPoint gravity = CGPointMake(0.0, -450.0);

    CGPoint gravityStep = CGPointMultiplyScalar(gravity, delta);

    self.velocity = CGPointAdd(self.velocity, gravityStep);
    CGPoint velocityStep = CGPointMultiplyScalar(self.velocity, delta);

    self.position = CGPointAdd(self.position, velocityStep);
}

@end

让我们分析一下我们刚才做了什么。我知道事情可能会很复杂,但别担心!我在这里帮助你!

首先,我们将SKUtils框架导入到Player类中。之后,我们创建了一个新的initWithImageNamed方法,并将速度变量初始化为0.0。然后我们声明了重力力的值。每次更新方法运行时,我们都会增加玩家向下的速度 450 点。如果玩家一开始是静止的,一秒后他将以 450 像素每秒的速度移动,两秒后这个值将翻倍,以此类推。

听起来很简单……对吧?

接下来,我们使用CGPointMulitplyScalar将加速度减小到当前时间步的大小。记住,CGPointMulitplyScalar通过一个浮点值增加CGPoint的值,并返回CGPoint结果。这很好,因为当设备延迟或由于某些奇怪的原因帧率下降时,我们仍然会得到一致的加速度值。

self.velocity =...代码块中,我们计算当前时间的重力,然后加到玩家的当前速度上。有了新的速度计算,我们得到了单次时间步的速度。

最后,所有速度都计算完毕后,我们使用CGPointAdd函数来改变玩家的位置。正如你所见,CGPointAdd等于玩家的当前位置,加上重力。

好吧!让我们运行我们的项目!

重力 – 玩家移动

什么?他穿过地面掉下去了?嗯,我想这意味着我们现在需要检测碰撞了?

碰撞检测

我们都知道,碰撞检测对任何游戏都是必不可少的。无论是一场比赛曲棍球,还是愤怒的八岁小孩的《愤怒的小鸟》,游戏都需要检测曲棍球、子弹、剑、地面上的脚——你叫得出名字的,都有大量的碰撞需要检测。对于我们的游戏,我们只将检测玩家和敌人、平台之间简单的箱子碰撞,以及子弹与敌人碰撞。

我们将使事情变得非常简单;首先,我们将检测玩家的边界框。

注意

碰撞框是什么?简单!想象一下你创建精灵的时候;你的图像周围有一个框吗?你可以将碰撞框想象成那样,就是你的精灵适合其中的小框或者在一个定义的空间内。你可以(我们也会)调整碰撞框的大小以适应精灵;你可以根据需要将其放大或缩小。

解释完这些后,让我们跳到我们的Player.h文件,并添加以下行:

-(CGRect)collisionBox;

我们刚刚创建了一个名为collisionBox的核心图形矩形。很简单,对吧?

现在将以下代码添加到Player.m文件中:

-(CGRect)collisionBox {
  return CGRectInset(self.frame, 2, 0);
}

CGRectInset的值会减小矩形的大小,或者我们的碰撞框,通过最后两个括号内的数字,分别是20。我们将玩家的框架设置为基本大小,然后在玩家的每一边减少两个像素。如果你愿意,你不必缩小边界框,可以将这两个值保留为0

现在事情将开始变得稍微复杂一些。我们需要检测我们关卡中的各种图像,并选择我们想要玩家与之碰撞的图像以及那些我们不想要的。让我们快速跳到我们的GameLevelScene.m类,并添加以下代码:

-(CGRect)tileRectFromTileCoords:(CGPoint)tileCoords {
  float levelHeightInPixels = self.map.mapSize.height * self.map.tileSize.height;

这个第一个代码块定位像素原点坐标;我们这样做是为了知道在场景中确切地放置地图的位置。我们需要翻转高度坐标,因为 SpriteKit 的原点在屏幕的左下角,而瓷砖地图的原点在左上角。为了检测原点,我们需要添加以下代码:

  CGPoint origin = CGPointMake(tileCoords.x * self.map.tileSize.width, levelHeightInPixels - ((tileCoords.y + 1) * self.map.tileSize.height));

  return CGRectMake(origin.x, origin.y, self.map.tileSize.width, self.map.tileSize.height);
}

接下来我们将1添加到瓷砖坐标上。我们为什么要这样做呢?实际上,瓷砖坐标系统从0开始,所以如果我们有 50 个瓷砖,第 50 个瓷砖的实际坐标将是 49。因此,我们需要加一以获得正确的值。

- (NSInteger)tileGIDAtTileCoord:(CGPoint)coord forLayer:(TMXLayer *)layer {
  TMXLayerInfo *layerInfo = layer.layerInfo;
  return [layerInfo tileGidAtCoord:coord];
}

这个下一个方法访问我们保存的地图层信息,这些信息保存在Tiled Map Editor中。记得我们有三层:背景、墙壁和危险区域吗?如果你想有超过三个层,绝对可以这样做。我在这例子中只做三个,因为我们将要程序化地添加敌人和一些特殊效果。这段代码将访问这些层。

对于我们的碰撞系统,我们将检测玩家周围的八个瓷砖。在接下来的代码块中,我们将检测周围的瓷砖,这将检查CGRect(或核心图形矩形)与玩家碰撞边界框的碰撞。

让我们回到我们的GameLevelScene.m文件,并添加以下代码:

- (void)checkForAndResolveCollisionsForPlayer:(Player *)player forLayer:(TMXLayer *)layer {

    NSInteger indices[8] = {7, 1, 3, 5, 0, 2, 6, 8};
    for (NSUInteger i = 0; i < 8; i++) {
        NSInteger tileIndex = indices[i];

        CGRect playerRect = [player collisionBox];

        CGPoint playerCoord = [layer coordForPoint:player.position];

        NSInteger tileColumn = tileIndex % 3;
        NSInteger tileRow = tileIndex / 3;
        CGPoint tileCoord = CGPointMake(playerCoord.x + (tileColumn - 1), playerCoord.y + (tileRow - 1));

        NSInteger gid = [self tileGIDAtTileCoord:tileCoord forLayer:layer];

        if (gid) {

            CGRect tileRect = [self tileRectFromTileCoords:tileCoord];

            NSLog(@"GID %ld, Tile Coord %@, Tile Rect %@, player rect %@", (long)gid, NSStringFromCGPoint(tileCoord), NSStringFromCGRect(tileRect), NSStringFromCGRect(playerRect));
            //after this is where we write our collision resolving
        }

    }
}

哇!这么多代码!让我们来分解一下。第一个代码块创建了一个数组,显示了围绕我们酷炫的小玩家角色的瓷砖位置。正如你所见,我们找到了八个周围的瓷砖,然后将这些值存储在tileIndex变量中。

现在,记得我之前提到的瓦片坐标是颠倒的吗?注意瓦片的顺序?71350268。瓦片 7 是位于我们玩家直接下方的瓦片,所以需要立即确定。我们需要知道他是否在地面上;如果是,他可以跳跃,但如果没有,就不能跳跃!如果我们不立即解决这个瓦片,玩家可能会在没有角色接触地面的情况下跳跃——如果他们足够快地按下跳跃按钮。

然后,我们检索了我们之前编写的玩家碰撞框,并找到了玩家的确切瓦片位置。我们这样做是为了然后定位我们玩家的周围瓦片。在我们定位了玩家的位置之后,我们然后把之前创建的 tileIndex 变量除以找到玩家周围的行和列值。

小贴士

让我们通过一个例子来分解这个过程。

假设 tileIndex 的值为 3;则 tileColumn 的值将是 0(3 % 3 = 0),而 tileRow 的值将是 1(3 / 3 = 1)。

如果我们的太空人的位置被发现在瓦片坐标(5010),则 tileIndex 3 的周围瓦片将是 50 + (0 – 1) 和 10 + (1 – 1) 或 4910,分别。这等于我们太空人瓦片位置的左侧瓦片。

我知道这可能会有些令人困惑,但别担心;你很快就会明白的!

在下一步中,我们查找在之前实例中找到的 tileIndex 坐标处的瓦片的 GID 值。

哇,停下!在广阔的体育世界中,GID 是什么?

GID 是表示从瓦片集中图像索引的数字。每个 TMXLayer 类都有一个瓦片集,其中的图像以网格形式排列。简单来说,GID 就是特定图像的位置。

接下来,我们判断 GID 是否有值为 0。没有瓦片。它只是空白空间,所以我们不需要解决或测试碰撞。然而,如果 GID 中有值,我们就获取该瓦片的 CGRect 位置。然后我们简单地记录结果。这不是必需的代码块,但在事情不正常时非常有帮助——你可以查看调试器来查看发生了什么。以下图显示了如何处理瓦片:

碰撞检测

大号粗体数字表示处理瓦片碰撞的顺序,从底部开始,然后是顶部,接着是左侧,最后是右侧,然后是角落。小号数字表示这些瓦片在 tileIndex 变量中的存储顺序。

接下来,我们将回到我们的 GameLevelScene.m 文件,并添加以下行:

// Add to the @interface section with all our other properties
@property (nonatomic, strong) TMXLayer *walls;

// Add to the init method, after the map is added to the layer
self.walls = [self.map layerNamed:@"walls"];

// Add to the bottom of the update method
[self checkForAndResolveCollisionsForPlayer:self.player1 forLayer:self.walls];

如果你现在运行项目,它将直接崩溃到 SIGABRT信号终止)的深渊。SIGABRT 表示,尽管你的代码中没有显示错误,但你的应用程序在尝试运行代码段时失败了。你将能够在控制台日志中看到发生了什么以及为什么发生。我们将在本书的后面进一步讨论调试。我们需要做更多的工作。

当我们的 Player 类更新其位置,而 GameLevelScene 类检测到碰撞时,我们希望玩家停止。因此,我们需要创建一个新的变量。

这个变量将允许 Player 类执行所有其位置计算,而 GameLevelScene 类将在检测到碰撞后更新位置。

让我们转到我们的 Player.h 文件并添加这个新属性:

@property (nonatomic, assign) CGPoint desiredPosition;

我们还需要修改 Player.m 文件中的 collisionBox 方法,现在它应该如下所示:

-(CGRect)collisionBox {
    CGRect boundingBox = CGRectOffset(self.frame, 2, 0);
    CGPoint difference = CGPointSubtract(self.desiredPosition, self.position);
    return CGRectOffset(boundingBox, difference.x, difference.y);
}

这创建了一个基于期望位置的碰撞边界框。层现在将使用这个边界框进行碰撞检测。

现在让我们滚动到 update 方法并找到这一行:

self.position = CGPointAdd(self.position, velocityStep);

用以下内容替换它:

self.desiredPosition = CGPointAdd(self.position, velocityStep);

现在,这将更新我们的 desiredPosition 属性而不是实际位置属性。

回到我们的 GameLevelScene.m 文件,查找我们的 -(void)checkForAndResolveCollisionsForPlayer:(Player *)player forLayer:(TMXLayer *)layer 方法。我们写了 CGPoint playerCoord = [layer coordForPoint:player.position];我们必须将 player.positionplayer.desiredPosition 改变过来。

回到我们的 checkForAndResolveCollisionsForPlayer 方法,在注释掉的文本 //after this is where we write our collision resolving 之后,我们需要添加我们的碰撞解决代码。为了避免混淆,checkForAndResolveCollisionsForPlayer 方法应该看起来像这样:

- (void)checkForAndResolveCollisionsForPlayer:(Player *)player forLayer:(TMXLayer *)layer
{
    NSInteger indices[8] = {7, 1, 3, 5, 0, 2, 6, 8};
    player.onGround = NO;  ////Here
    for (NSUInteger i = 0; i < 8; i++) {
        NSInteger tileIndex = indices[i];

        CGRect playerRect = [player collisionBox];
        CGPoint playerCoord = [layer coordForPoint:player.desiredPosition];

        NSInteger tileColumn = tileIndex % 3;
        NSInteger tileRow = tileIndex / 3;
        CGPoint tileCoord = CGPointMake(playerCoord.x + (tileColumn - 1), playerCoord.y + (tileRow - 1));

        NSInteger gid = [self tileGIDAtTileCoord:tileCoord forLayer:layer];
        if (gid != 0) {
            CGRect tileRect = [self tileRectFromTileCoords:tileCoord];
            //NSLog(@"GID %ld, Tile Coord %@, Tile Rect %@, player rect %@", (long)gid, NSStringFromCGPoint(tileCoord), NSStringFromCGRect(tileRect), NSStringFromCGRect(playerRect));

            if (CGRectIntersectsRect(playerRect, tileRect)) {
                CGRect intersection = CGRectIntersection(playerRect, tileRect);

                if (tileIndex == 7) {
                    //tile is directly below Player
                    player.desiredPosition = CGPointMake(player.desiredPosition.x, player.desiredPosition.y + intersection.size.height);
                    player.velocity = CGPointMake(player.velocity.x, 0.0);
                    player.onGround = YES;
                } else if (tileIndex == 1) {
                    //tile is directly above Player
                    player.desiredPosition = CGPointMake(player.desiredPosition.x, player.desiredPosition.y - intersection.size.height);
                } else if (tileIndex == 3) {
                    //tile is left of Player
                    player.desiredPosition = CGPointMake(player.desiredPosition.x + intersection.size.width, player.desiredPosition.y);
                } else if (tileIndex == 5) {
                    //tile is right of Player
                    player.desiredPosition = CGPointMake(player.desiredPosition.x - intersection.size.width, player.desiredPosition.y);
                    //3
                } else {
                    if (intersection.size.width > intersection.size.height) {
                        //tile is diagonal, but resolving collision vertically

                        player.velocity = CGPointMake(player.velocity.x, 0.0);
                        float intersectionHeight;
                        if (tileIndex > 4) {
                            intersectionHeight = intersection.size.height;
                            player.onGround = YES;
                        } else {
                            intersectionHeight = -intersection.size.height;
                        }
                        player.desiredPosition = CGPointMake(player.desiredPosition.x, player.desiredPosition.y + intersection.size.height );
                    } else {
                        //tile is diagonal, but resolving horizontally
                        float intersectionWidth;
                        if (tileIndex == 6 || tileIndex == 0) {
                            intersectionWidth = intersection.size.width;
                        } else {
                            intersectionWidth = -intersection.size.width;
                        }

                        player.desiredPosition = CGPointMake(player.desiredPosition.x  + intersectionWidth, player.desiredPosition.y);
                    }
                }
            }
        }
    }

    player.position = player.desiredPosition;
}

我们刚才做了什么?

我们使用了 CGRectIntersectsRect 方法来查看玩家和瓦片矩形是否碰撞。然后,我们使用我们的 tileIndex 确定那个瓦片的精确位置,并检查是否是垂直或水平碰撞。我们还创建了一个变量来确定移动玩家所需的距离,以便他不再与瓦片碰撞。然后,我们检查玩家是否需要向上或向下移动。当确定这一点后,我们要么从玩家中添加要么从玩家中减去碰撞高度。

我们还设置了布尔值(真或假陈述),用于检测玩家是否与地面碰撞;如果是,让他停止,并将 onGround 布尔值设置为 true

最后,我们将玩家的位置设置以最终解决碰撞。

现在,在我们的 Player.h 文件中,我们需要添加一个 onGround 布尔属性。在所有其他属性中,添加以下代码行:

@property (nonatomic, assign) BOOL onGround;

既然我们的这个小人物已经正常工作,我们就将进入编程他的移动!让我们让他跳舞吧!(实际上我不会编程让他跳舞;然而,如果你想要这样做,绝对欢迎。我不会评判。)

让我们的玩家跳舞!

对于这个游戏,我们将使控制非常简单。触摸屏幕的右侧,玩家将向前移动;触摸左侧,他将跳跃。你可以使用相同的方法让他前后移动,但这就是我们在这个例子中的做法。

在我们的Player.h文件中,添加以下属性:

@property (nonatomic, assign) BOOL walking;
@property (nonatomic, assign) BOOL jumping;

跳转到我们的GameLevelScene.m文件,我们将添加以下方法:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInNode:self];
        if (touchLocation.x > self.size.width / 2.0) {
            self.player1.jumping = YES;
        } else {
            self.player1.walking = YES;
        }
    }
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    for (UITouch *touch in touches) {

        float halfWidth = self.size.width / 2.0;
        CGPoint touchLocation = [touch locationInNode:self];
        
        //get previous touch and convert it to node space
        CGPoint previousTouchLocation = [touch previousLocationInNode:self];

        if (touchLocation.x > halfWidth && previousTouchLocation.x <= halfWidth) {
            self.player1.walking = NO;
            self.player1.jumping = YES;
        } else if (previousTouchLocation.x > halfWidth && touchLocation.x <= halfWidth) {
            self.player1.walking = YES;
            self.player1.jumping = NO;
        }
    }
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {

    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInNode:self];
        if (touchLocation.x < self.size.width / 2.0) {
            self.player1.walking = NO;
        } else {
            self.player1.jumping = NO;
        }
    }
}

这是一组相当简单的方法。我们在设备的屏幕上设置了两个触摸区域,每个区域占屏幕宽度的一半。一旦触摸其中一个,就会触发相应的布尔值,无论是行走还是跳跃,然后我们将在Player类中检测这些布尔值是否被触发。简单?是的!

我们需要在(id)initWithSize代码块中添加一小行代码,以便在我们的应用中启用触摸控制。将此行添加到该方法中的任何位置:

self.userInteractionEnabled = YES;

这一行只是给了我们检测用户交互的能力。如果我们现在运行我们的应用并触摸屏幕的边缘,我们的玩家将绝对不做任何事情。因为他是个叛逆者?其实不是。我们只设置了触摸;我们还没有告诉他当收到这些触摸时该做什么。

让我们跳转到我们的Player.m类并编辑update方法,这样我们就可以让他移动了。update方法应该看起来像这样,其中高亮显示的代码是我们添加的新行:

- (void)update:(NSTimeInterval)delta {

    CGPoint gravity = CGPointMake(0.0, -450.0);

    CGPoint gravityStep = CGPointMultiplyScalar(gravity, delta);

 CGPoint movingForward = CGPointMake(750.0, 0.0);
 CGPoint movingForwardStep = CGPointMultiplyScalar(walking, delta);

    self.velocity = CGPointAdd(self.velocity, gravityStep);

 self.velocity = CGPointMake(self.velocity.x *0.9, self.velocity.y);

 //here he shall fly!

 if (self.walking) {

 self.velocity = CGPointAdd(self.velocity, movingForwardStep);
}

 CGPoint minimumMovement = CGPointMake(0.0, -450);
 CGPoint maximumMovement = CGPointMake(120.0, 250.0);
 self.velocity = CGPointMake(Clamp(self.velocity.x, minimumMovement.x, maximumMovement.x), Clamp(self.velocity.y, minimumMovement.y, maximumMovement.y));

    CGPoint velocityStep = CGPointMultiplyScalar(self.velocity, delta);

    self.desiredPosition = CGPointAdd(self.position, velocityStep);
}

看起来很简单?不?好的,让我们更详细地解释一下。首先,我们添加了一个前进的“力”,当用户触摸屏幕时将添加,这个力以每秒 750 点的速度相对于帧步长添加,以实现平滑的运动。嗯,很平滑!

接下来,我们控制了前进的力,以模仿地面的摩擦力,这样当玩家停止移动时,他会短暂地滑行而不是立即停止。

接下来,我们检查屏幕是否被触摸,如果是,则添加速度!

然后是限制。限制?想想当你把一块木头夹在工作台上时,那东西是不会动的。限制方法也是一样;我们正在“限制”或限制玩家的最大和最小水平和垂直速度。玩家不会超出这些限制。

现在,我们将添加跳跃方法。回到我们的update代码块,就在if (self.walking)语句之上,我们将添加以下代码来让我们的玩家跳跃:

CGPoint jumpForce = CGPointMake(0.0, 310.0);
float jumpTime = 150.0;

if (self.jumping && self.onGround) {
  self.velocity = CGPointAdd(self.velocity, jumpForce);
} else if (!self.jumping && self.velocity.y > jumpTime) {
  self.velocity = CGPointMake(self.velocity.x, jumpTime);
}

这个跳跃系统与 超级马里奥 的类似,如果你按住跳跃按钮,他会加速到某个点,然后 jumpTime 函数会阻止他进一步加速。然而,如果在跳跃达到 jumpTime 函数截止之前玩家停止按跳跃按钮,跳跃将会减少。

让我们玩家跳舞!

看到了吗?你正在朝着创建下一个 超级马里奥 的方向前进!如果你运行你的项目并点击屏幕的边缘,他会四处移动并跳跃!但你知道吗,我不喜欢我们的玩家只能向前移动。如果他卡住了怎么办?(就像在我的关卡中,他几乎可以立即卡住。哎呀!)所以让我们回到我们的 Player.h 类,并添加另一个属性:

@property (nonatomic, assign) BOOL goingBackwards;

然后转到我们的 GameLevelScene.m 文件,在那里我们对之前添加的 touchesBegan 函数和 TouchesMoved 代码块进行轻微的修改:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInNode:self];
        if (touchLocation.x > self.size.width / 2.0) {
            self.player1.jumping = YES;
        }        
        else {
            if (touchLocation.x < self.size.width / 2.0) {
                if (touchLocation.y > self.size.height / 2){
                    self.player1.goingBackwards = YES;
        self.player1.xScale = -1.0;
                }
                if (touchLocation.y < self.size.height / 2){
                    self.player1.walking = YES;
            self.player1.xScale = 1.0;
                }
            }

        }
    }
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    for (UITouch *touch in touches) {

        float halfWidth = self.size.width / 2.0;
        CGPoint touchLocation = [touch locationInNode:self];

        //get previous touch and convert it to node space
        CGPoint previousTouchLocation = [touch previousLocationInNode:self];

        if (touchLocation.x > halfWidth && previousTouchLocation.x <= halfWidth) {
            self.player1.walking = NO;
            self.player1.goingBackwards = NO;
            self.player1.jumping = YES;
        } else if (previousTouchLocation.x > halfWidth && touchLocation.x <= halfWidth) {
            //self.player1.walking = YES;
            self.player1.goingBackwards = NO;
            self.player1.jumping = NO;
        }
        else if (previousTouchLocation.x > halfWidth && touchLocation.x <= halfWidth) {
            if (touchLocation.y > self.size.height / 2){
            self.player1.walking = NO;
            //self.player1.goingBackwards = YES;
            self.player1.jumping = NO;
            }
        }
    }
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {

    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInNode:self];
        if (touchLocation.x < self.size.width / 2.0) {
            //self.player1.walking = NO;
            if (touchLocation.x < self.size.width / 2.0) {
                if (touchLocation.y > self.size.height / 2){
                    self.player1.goingBackwards = NO;
                }
                if (touchLocation.y < self.size.height / 2){
                    self.player1.walking = NO;
                }
            }

        } else {
            self.player1.jumping = NO;
        }
    }
}

因此现在,我们不再有一个屏幕被一个行走按钮和一个跳跃按钮平分,而是屏幕被分成三部分,行走按钮作为屏幕的一半,而这半部分在高度上被分割,如下面的截图所示:

让我们玩家跳舞!

让我们转到我们的 Player.m 文件,并对我们的 update 方法做一些非常小的调整。我们将添加以下行:

    CGPoint movingBackward = CGPointMake(-750.0, 0.0);
    CGPoint movingBackwardStep = CGPointMultiplyScalar(movingBackward, delta);

这些行是我们在创建向前行走方法时的反转,因此有 -750 的值。现在在我们的 if (self.walking) 方法下,我们将添加以下 if 语句:

    if (self.goingBackwards) {
        self.velocity = CGPointAdd(self.velocity, movingBackwardStep);
    }
  //The below value has to be changed to allow a negative x value to walk backwards.
    CGPoint minimumMovement = CGPointMake(-750.0, -450);

这同样是对向前行走运动的反转。测试一下看看是否工作;如果工作,他应该会像受惊的猫一样后退。

现在我们将使屏幕随着玩家向屏幕边缘移动而滚动。转到我们的 GameLevelScene.m 类,我们添加以下代码:

//Add this in the import section
#import "SKTUtils.h"

//Then add this wherever you like, after any of the methods
- (void)setViewpointCenter:(CGPoint)position {
  NSInteger x = MAX(position.x, self.size.width / 2);
  NSInteger y = MAX(position.y, self.size.height / 2);
  x = MIN(x, (self.map.mapSize.width * self.map.tileSize.width) - self.size.width / 2);
  y = MIN(y, (self.map.mapSize.height * self.map.tileSize.height) - self.size.height / 2);
  CGPoint actualPosition = CGPointMake(x, y);
  CGPoint centerOfView = CGPointMake(self.size.width/2, self.size.height/2);
  CGPoint viewPoint = CGPointSubtract(centerOfView, actualPosition);
  self.map.position = viewPoint;
}

//Then add this in the Update method
[self setViewpointCenter:self.player1.position];

这个方块将屏幕的位置限制在玩家达到视图中心时。

测试一下看看是否工作!

让我们玩家跳舞!

这看起来越来越好了!但如果你注意到了,他不会死。遗憾的是,我们确实希望他在碰到尖刺或掉进那些可怕的山谷时死去,不是吗?

让我们在 TMX 地图中整合危险层。为此,我们必须转到我们的 GameLevelScene.m 文件,并添加以下检测方法:

//Add this at the top of the code
@property (nonatomic, strong) TMXLayer *hazards;

//Add this in the initWithSize method after we set up the Walls
self.hazards = [self.map layerNamed:@"hazards"];

//add this in the checkForAndResolveCollisionsForPlayer
[self handleHazardCollisions:self.player1];

//Add this anywhere!

- (void)handleHazardCollisions:(Player *)player
{
    NSInteger indices[8] = {7, 1, 3, 5, 0, 2, 6, 8};

    for (NSUInteger i = 0; i < 8; i++) {
        NSInteger tileIndex = indices[i];

        CGRect playerRect = [player collisionBox];
        CGPoint playerCoord = [self.hazards coordForPoint:player.desiredPosition];

        NSInteger tileColumn = tileIndex % 3;
        NSInteger tileRow = tileIndex / 3;
        CGPoint tileCoord = CGPointMake(playerCoord.x + (tileColumn - 1), playerCoord.y + (tileRow - 1));

        NSInteger gid = [self tileGIDAtTileCoord:tileCoord forLayer:self.hazards];
        if (gid != 0) {
            CGRect tileRect = [self tileRectFromTileCoords:tileCoord];
            if (CGRectIntersectsRect(playerRect, tileRect)) {
                [self gameOver:0];
            }
        }
    }
}

这基本上是我们用于 checkForAndResolveCollisionsForPlayer 函数的相同代码。我们只添加了一个 gameOver 方法,当它等于 0 时,玩家死亡,当它是 1 时,玩家通关关卡。

目前你会看到错误提示。我们还没有整合我们的游戏结束功能,所以现在让我们来做这件事。再次在我们的 GameLevelScene.m 文件中,让我们添加以下代码:

//add this at the top with all the other properties
@property (nonatomic, assign) BOOL gameOver;

//Put this in the update method
if (self.gameOver) return;

//Then add this method anywhere in the GameLevelScene
-(void)gameOver:(BOOL)won {

    self.gameOver = YES;

    NSString *gameText;
    if (won) {
        gameText = @"Level Complete!";
    } else {
        gameText = @"You have failed!";
    }

    SKLabelNode *endGameLabel = [SKLabelNode labelNodeWithFontNamed:@"AvenirNext-Heavy"];
    endGameLabel.text = gameText;
    endGameLabel.fontSize = 40;
    endGameLabel.position = CGPointMake(self.size.width / 2.0, self.size.height / 1.7);
    [self addChild:endGameLabel];

    UIButton *replay = [UIButton buttonWithType:UIButtonTypeCustom];
    replay.tag = 321;
    UIImage *replayImage = [UIImage imageNamed:@"replay"];
    [replay setImage:replayImage forState:UIControlStateNormal];
    [replay addTarget:self action:@selector(replay:) forControlEvents:UIControlEventTouchUpInside];
    replay.frame = CGRectMake(self.size.width / 2.0 - replayImage.size.width / 2.0, self.size.height / 2.0 - replayImage.size.height / 2.0, replayImage.size.width, replayImage.size.height);
    [self.view addSubview:replay];
}

//Add this into the checkForAndResolveCollisionsForPlayer, after CGPoint playerCoord = [layer coordForPoint:player1.desiredPosition]; 
if (playerCoord.y >= self.map.mapSize.height - 1) {
  [self gameOver:0];
  return;
}

让我们像往常一样分解它。首先,我们通过布尔值设置新游戏,我们每次玩家与危险碰撞,或者后来与敌人碰撞时都会使用这个布尔值。

之后,我们设置了一个关卡胜利和关卡失败的字符串(或文本),以便在玩家要么通关要么失败时弹出。我使用了Avenir Next Heavy作为字体。你可以为你的游戏使用很多字体。如果你想查看所有可用的字体,请访问iosfonts.com

然后,我们创建了一个UIButton,用户可以点击来重新开始关卡。别忘了添加我在这章资源部分提供的replay.pngreplay@2x.png图像文件。

最后,我们添加了一个检查玩家位置的方法;如果他在地图下方——换句话说,如果他掉进了地图上的一个洞或裂缝——我们就会调用游戏结束。

但我们的这个小家伙不仅仅会经历死亡!他偶尔也需要赢一次,对吧?嗯,我们还需要添加这些方法!别担心,这很简单!

在我们的GameLevelScene.m文件中,我们需要添加一个新方法:

-(void)didHeWin {
  if (self.player1.position.x > 3200.0) {
    [self gameOver:1];
  }
}

这是一个基于位置的胜利,所以一旦我们的玩家到达地图上的x值为3200,我们就会宣布胜利。我们可以在 TMX 地图上有一个胜利层并将其集成,但这似乎更容易!最后,在handleHazardCollisions部分,我们需要检查我们的这个小家伙是否已经赢了:

[self didHeWin];

我们现在测试一下看看它是否工作,你将看到以下屏幕:

让我们玩家跳舞!

当我们失败一个关卡时,我们会落在尖刺上,哎呀!但正如以下截图所示,我们到达了关卡终点:

让我们玩家跳舞!

摘要

哇!它开始看起来很棒了,不是吗?在这一章中我们做了很多工作!包括我们的关卡设计,想出一种方法将我们的地图集成到项目中,创建我们的小玩家,并让他移动、碰撞和跳跃,我们确实做了很多。

现在,让我们休息一下!在下一章中,我们将进行更多的创作,比如添加令人惊叹的音乐和音效,稍微润色一下游戏,也许还会添加一些菜单、粒子效果,甚至一些敌人!

去拿一杯你喜欢的美酒,下一章见!

第四章:继续前进!添加更多功能

在上一章中,我们取得了许多成果!我们解决了关卡创建、将关卡导入 Xcode 以及让我们的关卡和玩家在游戏中显示出来。你休息得很好,准备好应对更多的编程酷炫了吗?让我们看看本章我们将做什么:

  • 添加酷炫音效

  • 角色动画

  • 玩转粒子

  • 菜单

  • 添加一些敌人

我们的游戏看起来相当酷,但在我们甚至可以远程考虑发布它之前,还有很多工作要做!

让我们深入探讨,好吗?

首先,我们结束了刚刚讨论的章节,即赢得关卡和关卡中的死亡,然而如果你测试了它,你死亡或通关并点击了重玩按钮,游戏崩溃了吗?这是因为我们需要添加一个最终的方法:

- (void)replay:(id)sender
{
    [[self.view viewWithTag:321] removeFromSuperview];
    [self.view presentScene:[[GameLevelScene alloc] initWithSize:self.size]];

}

这段代码块简单地从屏幕上移除按钮并重置游戏。让我们继续!

添加酷炫音效

是的!我们将给我们的这个小家伙一些声音,特别是跳跃和死亡时的声音。但这还不是全部!不,不!我们还将让一些有趣的旋律在我们的关卡中播放。

让我们打开我们的GameLevelScene.m文件,并导入 SpriteKit 音频框架来播放声音!在文件顶部,所有我们的导入方法都在那里,添加以下行:

#import "SKTAudio.h"

我还包含了一些音频供我们使用,所以如果你还没有将它们导入到你的项目中,现在就去做吧,或者如果你喜欢,你可以使用你自己的音乐。一旦你的文件被导入到项目中,回到我们的GameLevelScene.m文件,在我们的-(id)initWithSize方法中,我们将添加以下代码行以播放音乐:

[[SKTAudio sharedInstance] playBackgroundMusic:@"BackgroundAudio.mp3"]; //change the file name to whatever file you imported

测试项目,现在你应该在背景中听到一些摇滚乐了!太酷了!

我认为我们现在应该为跳跃动作创建一个音效,不是吗?

跳跃(不是字面意思),转到我们的Player.m文件,我们将找到我们让玩家跳舞的代码块。这个方法在update方法中,在这个方法中,我们将找到'if (self.jumping && self.onGround)'语句。在if语句中,我们将在语句开大括号(即{)之后添加以下代码:

[self runAction:[SKAction playSoundFileNamed:@"jump.wav" waitForCompletion:NO]];

让我们稍微分解一下这个函数。我们告诉self运行一个动作,这里的self是指从 SKNode 继承的玩家类,这也是runAction方法来源的地方。然后,我们声明这个动作为一个 SpriteKit 动作,用于播放声音文件。我们声明声音文件,然后告诉 SpriteKit 不要等待完成。

太棒了!现在,每当玩家点击跳跃按钮时,角色都会发出一点哔哔声。你可以将这些方法应用到你想播放声音的任何地方,无论是死亡、射击、行走——你名字叫什么!

注意我们使用了一个不同的方法来播放背景音乐,而不是跳跃声音效果,你知道为什么吗?

当你玩游戏时,会有很多声音效果同时播放。如果我们使用播放音乐相同的方法来播放声音效果,可能会停止播放音乐,以便播放一个新调用的声音效果,因为我们一次只能播放一个背景音乐文件。所以当我们播放声音效果时,我们不会干扰我们的背景音乐,因为它在不同的频道上播放。

这样,音乐将连续播放,除非你在播放时接到电话,对此我们无能为力。

我们的游戏现在正在成形,但我不喜欢我们的玩家在行走或跳跃时只是保持静止。让我们添加一些动画!

角色动画

几乎所有游戏都包含角色或对象动画。它们为对象或角色增添了活力,并使游戏看起来更具吸引力。以我们正在制作的游戏为例。假设我们推出游戏进行销售,玩家一直保持他的闲置姿势,无论是死亡、行走还是跳跃——他就这样站着。

这样看起来不太合适,不是吗?我们需要改变这一点!

我们已经导入了包含创建角色动画所需所有图像的 Sprite 图集文件,这节省了大量工作。Sprite 图集是 SpriteKit 将所有精灵图像放置的地方。我们项目中不再有图像随机导入各个地方,Sprite 图集将它们组织得井井有条。

我们将进入Player.m类,并在@implementation Player行下方添加以下代码行,使我们的实现看起来如下:

@implementation Player
{
  NSArray *walkingAnimation
}

我们需要添加一个图像数组,这些图像将组成我们的行走动画或任何其他您想要创建的动画。

现在,在我们的initWithImageNamed方法中,我们需要创建数组,在图集中定位行走图像,然后将它们添加到数组中。为此,将以下代码直接添加到我们的init方法的开始括号下:

    NSMutableArray *walkingFrames = [NSMutableArray array];
    SKTextureAtlas *playerAtlas = [SKTextureAtlas atlasNamed:@"sprites"];

    int numberOfImages = 8;
    for (int i=1; i <= numberOfImages/2; i++) {
        NSString *imageName = [NSString stringWithFormat:@"P1Walking%d", i];
        SKTexture *temporaryTexture = [playerAtlas textureNamed: imageName];
        [walkingFrames addObject:temporaryTexture];
    }
    walkingAnimation = walkingFrames;

让我们讨论一下刚才发生的事情,因为这里有很多令人困惑的专业术语!

在第一行,我们将集合添加到数组中,以存储图集中所有的行走图像。接下来,我们加载包含所有图像的纹理图集。很棒的是,SpriteKit 会自动加载我们使用的设备的正确分辨率,因此有@2x 图像来弥补用于视网膜显示屏的高分辨率图像。

接下来,我们告诉 Xcode 在图集中搜索名为P1Walking的图像;%d会自动搜索所有名为P1Walking的图像。因此,这将从图像0开始,然后是123,以此类推。最后,我们将这些图像添加到我们的行走动画数组中。

现在,我们需要添加一个动作来触发动画,并在调用时停止它。我们需要将以下方法添加到我们的Player.m文件中:

-(void)playWalkingAnim {
[self runAction:[SKAction repeatActionForever:[SKAction animateWith  Textures:walkingAnimation
  timePerFrame:0.1f
  resize:NO
  restore:YES]]
  withKey:@"PlayerWalking"];

return;
}

-(void)PlayerStoppedMoving {
 [self removeAllActions];
}

我们添加了一个动作键PlayerWalking,以便在需要时停止动画。

我们在玩家触摸上一章中创建的行走区域时调用此方法一次。让我们转到我们的Player.h文件,并创建另一个属性。与我们的其他属性一样,添加以下内容:

@property (nonatomic, assign) BOOL animateWalking;

现在,为了让我们的小家伙真正地动起来,我们需要跳转到GameLevelScene.m文件。在我们的touchesBegan方法中,当我们调用walkinggoingBackwards布尔值时,我们需要添加以下代码:

self.player1.animateWalking = YES;

所以,现在我们的touchesBegan方法将看起来像这样(新代码被突出显示):

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInNode:self];
        if (touchLocation.x > self.size.width / 2.0) {
            self.player1.jumping = YES;
        }        
        else {
            if (touchLocation.x < self.size.width / 2.0) {
//This will check our touch position on the left hand side of the screen
                if (touchLocation.y > self.size.height / 2){
//This checks if we are in the top left hand corner of the screen, if so trigger walking backward
                    self.player1.goingBackwards = YES;
 self.player1.animateWalking = YES;
                    self.player1.xScale = -1.0;
                }
                if (touchLocation.y < self.size.height / 2){
//This checks if we are touching the bottom left corner. If we are trigger walking forward.
                    self.player1.walking = YES;
 self.player1.animateWalking = YES;
                    self.player1.xScale = 1.0;
                }
            }

        }
    }
}

这将使布尔值true在玩家触摸这些行走区域时设置。我们还需要在触摸结束时将它们设置为false,因此我们将滚动到我们的touchesEnded方法,并添加以下代码,将我们的行走和goingBackwards方法设置为no

self.player1.animateWalking = NO;

现在,让我们回到我们的Player.m文件,并在我们的update部分添加以下代码行:

 if (self.animateWalking) {
        [self actionForKey:@"PlayerWalking"];
    }
    else {
        [self PlayerStoppedMoving];

    }
    if (![self actionForKey:@"PlayerWalking"]) {

        [self playWalkingAnim];
    }

现在当玩家触摸行走区域时,它会将animateWalking布尔值设置为true,这将调用PlayerWalking动作。当这个动作被调用时,我们将使用行走动画来动画化角色,而当玩家从行走区域移开手指时,动画将停止。

呼!对于这么简单的事情来说,代码确实很多,但结果看起来很棒!

角色动画

逐步,我们的小游戏正在逐渐成形!让我们让这个关卡看起来有点阴暗和沉闷。让我们玩一些粒子来增加深度!

玩转粒子

SpriteKit 让粒子效果变得非常简单,简单到实际上,许多常见的粒子效果,如火焰、烟雾和雨,在创建我们的粒子文件时都是预制的模板。

在 Xcode 中,要创建一个新的粒子发射器,只需导航到文件 | 新建 | 文件。在iOS部分的模板创建器中(忽略我在上一张截图中选择的是 OS X 的事实)和资源部分下,选择SpriteKit 粒子文件,如下面的截图所示:

玩转粒子

这就是创建效果非常简单的地方,只需从粒子模板中选择

玩转粒子

是的,创建粒子就是这么简单!

现在,我们只需要保存它,我刚刚在ADESA项目文件夹中做了这件事。现在,你将在 Xcode 的项目资源管理器中看到我们的新粒子发射器文件。点击它,你应该会看到以下类似的截图:

玩转粒子

如果你对粒子效果的外观不完全满意,我们可以对其进行大量调整。

在右侧栏的顶部,点击显示 SK 节点检查器按钮,可以看到我们可以做的所有调整。我这里唯一改变的是位置范围,我将X的值改为1500,以便它横跨整个屏幕。

玩转粒子

如果您想进行其他调整,让我告诉您所有这些选项的作用。

背景

提供背景选项,以便您可以测试粒子在各种颜色背景下的可见性。更改此选项对粒子没有影响。

粒子纹理

粒子纹理是发射器将用于粒子的图像文件。提供的标准图像只是一个柔软的白色球体,因此您得到一个平滑柔软的粒子。这张图片很棒,因为它可以用于几乎所有类型的粒子。

粒子出生率

粒子出生率是指发射器发射新粒子的速率。值越高,新粒子喷出的速度越快。(警告:粒子越少,性能越好)。还可以指定要发射的粒子总数。如果将值设置为0,这将导致粒子无限期地发射。如果指定最大值,当场景中的粒子达到该值时,发射器将停止。

粒子生命周期

粒子生命周期控制粒子存活的时间长度(以秒为单位)。范围属性可以用来改变粒子生命周期的持续时间,例如,如果您创建一个爆炸,您可以使用更大的范围,以便有一些粒子比其他粒子更长时间可见。

粒子位置范围

粒子位置范围选项定义了粒子创建的位置(不言而喻,对吧?)。XY值可以用来声明一个区域,该区域位于节点中心,粒子将从该区域随机创建。

角度

角度选项与粒子从创建点出发沿逆时针方向移动的角度有关,其中0度等于向右移动。当我们设置范围值时,它将改变粒子发射的方向。

粒子速度

再次,粒子速度选项相当直观。它涉及粒子在创建时的移动速度。当我们设置范围值时,它将改变粒子发射的方向。

粒子加速度

加速度属性控制粒子在发射后加速或减速的速度。我再次以爆炸为例,使用此选项,您可以让它们快速飞出,但让碎片减速。

粒子尺度

粒子尺度选项显然是指粒子的尺寸,这可以通过范围设置进行变化。

粒子旋转

粒子旋转控制粒子旋转的速度。同样,您可以让碎片在飞行过程中旋转。

粒子颜色

发射器创建的粒子在其生命周期中可以改变颜色。要在生命周期时间轴中添加新颜色,请点击颜色渐变中颜色要改变的位置并选择新颜色(想想在 Photoshop 或 Illustrator 中创建渐变)。您也可以通过双击标记来更改现有颜色,以显示颜色选择。

要从渐变中移除一种颜色,请点击并向下拖动它。

颜色混合选项控制粒子纹理图像中的颜色数量以及它们如何与颜色渐变中的主颜色混合。

因子选项越大,颜色混合越多,其中0表示不混合。

粒子混合模式

混合模式选项控制粒子图像与场景混合的方式。可用选项如下:

  • Alpha: 这在粒子图像中混合透明背景。

  • 加法: 这将粒子像素添加到相应的背景图像像素。

  • 减法: 这将从相应的背景图像像素中减去粒子像素。

  • 乘法: 这将粒子像素乘以相应的背景图像像素。这导致粒子效果更暗。

  • 乘以 2: 这比标准乘法模式创建的粒子效果更暗。

  • 屏幕: 这会反转像素,然后乘以并再次反转它们。这导致粒子效果更亮(非常适合火焰效果或火花)。

  • 替换: 这导致与背景不混合。仅使用粒子的颜色。

要将我们的粒子实现到场景中,让我们转到我们的GameLevelScene.m文件,并在initWithSize方法中的if (self = [super initWithSize:size])括号内添加以下代码块:

NSString *rainParticles =
        [[NSBundle mainBundle] pathForResource:@"Rain" ofType:@"sks"];

        SKEmitterNode *rainEmitter =
        [NSKeyedUnarchiver unarchiveObjectWithFile:rainParticles];
        
        rainEmitter.position = CGPointMake(0, self.scene.size.height);

        [self addChild:rainEmitter];

编译并运行以查看出色的结果!

粒子混合模式

看起来很棒,但现在它在场景中,我不太喜欢雨的外观;我想调整它,使其看起来更逼真。让我们回到粒子编辑器(再次,通过点击项目资源管理器中的粒子文件)并增加粒子的出生率。

目前,出生率是 150,但我想让它汹涌,所以我将把它增加到 2500。我还会将比例从 0.1 减小到 0.02,因为我认为雨滴看起来太大。我还会将速度改为 500,以便看起来更猛烈。

粒子混合模式

看起来确实好多了!虽然纸张上很难看出效果,但屏幕上的效果看起来很棒。

现在我们让雨看起来很棒,让我们在我们的破损船只中添加一些火焰和烟雾效果。创建一个新的粒子文件,在模板创建器中选择雨而不是火焰,然后保存。

我们将根据需要以不同的方式实现火焰效果,使其与地图一起滚动。对于雨,我们将其添加到当前屏幕上,这样它就不会随着场景移动,而是与火焰一起移动。如果我们以同样的方式将雨添加到场景中,那么粒子将根据屏幕而不是整个地图保持在同一位置。

回到我们的GameLevelScene.m文件,在添加我们的雨发射器代码的同一位置,添加以下方法:

        NSString *fireParticles =
        [[NSBundle mainBundle] pathForResource:@"Flames"              ofType:@"sks"];

        SKEmitterNode *fireEmitter =
        [NSKeyedUnarchiver unarchiveObjectWithFile:fireParticles];
        fireEmitter.position = CGPointMake(25, 50);

        [_map addChild:fireEmitter];

看看代码中的区别?我们不是将fireEmitter作为selfGameLevelScene类的子项添加,而是直接将其添加到地图的25x50y位置,因此现在当玩家开始滚动场景时,火焰会保持原位,燃烧着那艘不幸坠毁的船只。

粒子混合模式

忽略这里截图中的帧率急剧下降;每次我截图时,帧率都会急剧下降,因为我正在运行一台稍微旧一点的 iMac。不过火焰看起来还不错!

然而,我注意到当屏幕上有火焰时,帧率几乎保持在 30fps 的恒定值。这就是性能和电池消耗开始发挥作用的地方。

我在 Xcode 中运行 iPhone 4S 模拟器,因为它可以适当地显示在我的屏幕上,这意味着拥有较老设备的用户将难以运行这款游戏,尤其是在屏幕上出现图形密集型元素,如粒子效果时。

然而,在我的 iPhone 5S 上运行时,游戏的平均帧率达到了 60fps。在我们发布游戏之前,我们必须考虑所有这些因素。我们将在本书的后面讨论这个问题。

现在,让我们创建一个菜单系统!

创建菜单和多个关卡

我们的游戏看起来真的很不错,但与其简单地将玩家投入游戏,让我们来谈谈菜单。

菜单非常重要(显然)。它是进入游戏玩法的基础,其缺失可能会让玩家感到困惑。我们不想这样,对吧?

由于我们在关卡开发上已经变得非常疯狂,我们没有关注游戏的结构;也就是说,我们没有考虑菜单或其他任何功能就完成了所有事情。

首先,我为您添加了一些更多需要导入到项目中才能使以下代码正常工作的图片。如果您有自己的图片,只需相应地更改按钮图片的名称即可。

现在,我们必须对我们的GameLevelScene.m文件进行一些实质性的修改。首先,我们需要创建一个新的整型变量。这个变量将计算我们的关卡数,其中关卡0是我们的主菜单。因此,在@interface GameLevelScene ()方法中,我们开始声明时,让我们添加一个额外的声明:

@property (nonatomic, assign) NSInteger level;

现在,让我们进行一些编辑!

让我们从initWithSize方法开始。我们将编辑它,使其看起来如下:

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {

        self.userInteractionEnabled = YES;
        self.backgroundColor = [SKColor colorWithRed:.0 green:.0 blue:.0 alpha:1.0];

        if (_level == 0){
        SKLabelNode *playLabel = [SKLabelNode labelNodeWithFontNamed:@"AvenirNext-Heavy"];
        playLabel.text = @"Adesa";
        playLabel.fontSize = 40;
        playLabel.position = CGPointMake(self.size.width / 2.0, self.size.height / 1.7);
        [self addChild:playLabel];

        SKSpriteNode *playButton = [SKSpriteNode spriteNodeWithImageNamed:@"play"];
        playButton.position = CGPointMake(self.size.width /2.0 , self.size.height / 2.5);
        playButton.name = @"playButton";
        [self addChild:playButton];
        }

    }
    return self;
}

我们原本在该处所有的初始化代码现在都不见了。我们放置了一个漂亮的标签和按钮。我们还把背景改成了黑色,但那部分并不重要。注意我们是如何命名playButton的?当我们检测到玩家触摸时,这会很有用,你很快就会看到。

现在,让我们继续向下滚动到我们的touchesBegan方法,并在for方法内添加以下代码:

SKNode *node = [self nodeAtPoint:touchLocation];

        if ([node.name isEqualToString:@"playButton"]) {
            _level = 1;
            [self removeAllChildren];

            [[SKTAudio sharedInstance] playBackgroundMusic:@"BackgroundAudio.mp3"];
            self.map = [JSTileMap mapNamed:@"level1.tmx"];
            [self addChild:self.map];
            NSString *rainParticles =
            [[NSBundle mainBundle] pathForResource:@"Rain" ofType:@"sks"];

            SKEmitterNode *rainEmitter =
            [NSKeyedUnarchiver unarchiveObjectWithFile:rainParticles];

            rainEmitter.position = CGPointMake(0, self.scene.size.height);

            [self addChild:rainEmitter];

            NSString *fireParticles =
            [[NSBundle mainBundle] pathForResource:@"Flames" ofType:@"sks"];

            SKEmitterNode *fireEmitter =
            [NSKeyedUnarchiver unarchiveObjectWithFile:fireParticles];
            fireEmitter.position = CGPointMake(25, 50);

            [_map addChild:fireEmitter];

            self.player1 = [[Player alloc] initWithImageNamed:@"P1idle"];
            self.player1.position = CGPointMake(100, 50);
            self.player1.zPosition = 15;
            [self.map addChild:self.player1];
            self.walls = [self.map layerNamed:@"walls"];
            self.hazards = [self.map layerNamed:@"hazards"];
        }

在这里,我们在触摸的位置创建一个新的 SpriteKit 节点。然后我们检测该节点是否触摸了我们的播放按钮,然后设置所有关卡 1 的内容。

我还会改变游戏结束的处理方式。到目前为止,当玩家死亡时,屏幕会弹出显示你已死亡,然后是大的重放按钮。如果我们保持原样,那么这个重放按钮方法会重置整个游戏,我不认为人们希望在关卡中死亡时丢失他们的进度。

对代码进行以下更改。我们将删除高亮显示的代码并用以下文本替换:

-(void)gameOver:(BOOL)won {
    if (_level > 0){
    self.gameOver = YES;

    NSString *gameText;
    if (won) {
        gameText = @"Level Complete!";

    } else {
        gameText = @"You have died!";
        //add the following lines of code here:
 self.player1.position = CGPointMake(100, 50);
 self.player1.zPosition = 15;
 [self setViewpointCenter:self.player1.position];
 self.gameOver = NO;
    }
}
}
 UIButton *replay = [UIButton buttonWithType:UIButtonTypeCustom];
 replay.tag = 321;
 UIImage *replayImage = [UIImage imageNamed:@"replay"];
 [replay setImage:replayImage forState:UIControlStateNormal];
 [replay addTarget:self action:@selector(replay:) forControlEvents:UIControlEventTouchUpInside];
 replay.frame = CGRectMake(self.size.width / 2.0 - replayImage.size.width / 2.0, self.size.height / 2.0 - replayImage.size.height / 2.0, replayImage.size.width, replayImage.size.height);
 [self.view addSubview:replay];

}
}

- (void)replay:(id)sender
{
 [[self.view viewWithTag:321] removeFromSuperview];
 //  [self.view presentScene:[[GameLevelScene alloc] initWithSize:self.size]];

}

现在,当你测试它并且玩家死亡时,他只是简单地传送到关卡开始处,而不是弹出重放按钮,这可能会在一段时间后变得侵扰性。

在构建菜单后,当你构建并运行项目时,主菜单应该看起来像这样:

创建菜单和多个关卡

我们现在将实现多个关卡,因为我非常确信玩家会厌倦一遍又一遍地玩同一个关卡。我在这本书的资源部分为你创建了一个新关卡。对于这个关卡,我们的玩家将找到他丢失的装备,但为了找到它,玩家需要在关卡中绕过一个小陷阱。

一旦你将level2.tmx文件导入到你的项目中,打开GameLevelScene.m文件并向下滚动到我们的didHeWin方法,并编辑它如下所示:

-(void)didHeWin {
    if (self.player1.position.x > 3200.0) {

        if (_level == 1) {
        [self.map removeFromParent];
        self.map = [JSTileMap mapNamed:@"level2.tmx"];
        [self addChild:self.map];

        }
        self.player1 = [[Player alloc] initWithImageNamed:@"P1idle"];
        self.player1.position = CGPointMake(100, 50);
        self.player1.zPosition = 15;
        [self.map addChild:self.player1];
        self.walls = [self.map layerNamed:@"walls"];
        self.hazards = [self.map layerNamed:@"hazards"];
    }
}

这个新方法仍然检测玩家的位置,但现在我们设置了一个二级if语句来检测玩家所在的关卡。在这种情况下,如果关卡等于 1,我们就从视图中移除当前地图,并将level2.tmx文件作为子项添加到视图中。之后,我们将玩家重新定位到关卡的开始位置并再次检查地图层。相当简单,对吧?

不要忘记重新导入tileSet.pngtileSet@2x.png文件,使用本章提供的文件,因为它们包含新的瓦片。如果你不重新导入这些文件,Xcode 可能会抛出错误,或者我们的新瓦片根本不会显示出来!

现在,你可以构建并运行我们的项目;现在,当玩家到达关卡末尾时,它应该切换到下一个关卡,正如你从以下图像中可以看到的,看起来相当酷!

创建菜单和多个关卡

是的!我们的游戏越来越有形了!然而,尽管我们的游戏看起来很酷,但没有敌人,它相当无聊!让我们给我们的太空人一些竞争。

创建敌人

这款游戏不仅仅是探险游戏!我们需要让这款游戏变得紧张刺激!

好吧,导入我包含的 Squiggy 图像集,或者你也可以使用你自己的。

创建敌人

在我们的GameLevelScene.m文件中,我们将添加一个新的方法来开始生成随机的敌人。这些敌人一开始很简单,但我们将遇到一些真正的坏蛋,它们会试图杀死你!

无论如何,在GameLevelScene.m文件中的任何位置,添加以下方法:

- (void)addSquiggy {

    SKSpriteNode * squiggy = [SKSpriteNode spriteNodeWithImageNamed:@"Squiggy"];

    int minY = squiggy.size.height / 2;
    int maxY = self.frame.size.height - squiggy.size.height / 2;
    int rangeY = maxY - minY;
    int actualY = (arc4random() % rangeY) + minY;
    squiggy.xScale = -1.0;
    squiggy.position = CGPointMake(self.player1.position.x + 1000 + squiggy.size.width/2, actualY);

    squiggy.name = @"squiggy";

    [self.map addChild:squiggy];

    // Setting the Speed of SQUIGGY!
    int minDuration = 10.0;
    int maxDuration = 20.0;
    int rangeDuration = maxDuration - minDuration;
    int actualDuration = (arc4random() % rangeDuration) + minDuration;

    SKAction * actionMove = [SKAction moveTo:CGPointMake(-squiggy.size.width/2, actualY) duration:actualDuration];
    SKAction * actionMoveDone = [SKAction removeFromParent];
    [squiggy runAction:[SKAction sequence:@[actionMove, actionMoveDone]]];
}

这个方法创建了一个 Squiggy 精灵节点。然后,我们创建一个随机的y轴坐标来生成我们的 Squiggy。为什么是随机的y轴?y你问?(啊,双关语)。我们的 Squiggy 当然是一种飞行生物!我们还让他出现在屏幕可见边缘之外,这样看起来就像他正在飞入场景,因此我们将 Squiggy 放置在玩家位置 1000px 之外。

我们将把他作为地图的子项添加,因为我们希望他随着地图滚动,同时给他一个名字,这样我们就可以进行碰撞检测。

然后,我们为 Squiggies 设置一个随机的速度范围,然后创建 SpriteKit 的动作来让它们飞行。

我们现在需要在GameLevelScene.m类中实现两个新的属性。这些新类将控制 Squiggies 的生成速率。因此,在类文件顶部,添加以下实现:

@property (nonatomic) NSTimeInterval lastSpawnTimeInterval;
@property (nonatomic) NSTimeInterval lastUpdateTimeInterval;

我们将使用lastSpawnTimeInterval方法来跟踪自我们生成 Squiggy 以来经过的时间,以及lastUpdateTimeInterval方法来检测自上次更新以来经过的时间。

我们现在将创建一个新的更新方法,但别担心,它不会干扰我们的主要update方法。你可以将这个方法添加到GameLevelScene.m文件的任何位置:

- (void)updateWithTimeSinceLastUpdate:(CFTimeInterval)timeSinceLast {

    self.lastSpawnTimeInterval += timeSinceLast;
    if (self.lastSpawnTimeInterval > 2) {
        self.lastSpawnTimeInterval = 0;
        [self addSquiggy];
    }
}

//Then add this method into our regular update method to call our new updateWithTimeSinceLastUpdate method

       CFTimeInterval timeSinceLast = currentTime - self.lastUpdateTimeInterval;
    self.lastUpdateTimeInterval = currentTime;
    if (timeSinceLast > 1) {
        timeSinceLast = 1.0 / 60.0;

    }

    [self updateWithTimeSinceLastUpdate:timeSinceLast];

构建、运行和预测散布阵雨和 Squiggies!结果看起来相当酷!我们需要让玩家在接触 Squiggy 时受伤。嘿,它们看起来很可爱,但它们很危险!

创建敌人

我们在这里需要做一些与物理相关的工作,所以请耐心等待。

GameLevelScene.m类的顶部,在@interface#import部分之间,添加以下两行代码:

static const uint32_t playerCategory = 0x1 << 0;
static const uint32_t enemyCategory = 0x1 << 1;

这些行设置了两个类别,一个用于敌人,一个用于玩家。当我们加入射击功能时,我们将为玩家和敌人的弹丸添加另一个类别。

现在,我们需要设置世界的物理,因此在我们initWithSize方法内部,我们需要添加以下代码行:

self.physicsWorld.gravity = CGVectorMake(0,0);
self.physicsWorld.contactDelegate = self;

上述代码设置了世界重力,并将场景设置为当两个对象碰撞(或物理体)时将被通知的委托。

物理体是级别内的任何东西,例如由物理控制的角色、对象或敌人。

在我们的addSquiggy方法内部,我们需要在创建实际的 Squiggy 精灵之后添加以下代码行:

squiggy.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:squiggy.size];
    squiggy.physicsBody.dynamic = YES;
    squiggy.physicsBody.categoryBitMask = enemyCategory;
    squiggy.physicsBody.contactTestBitMask = playerCategory;
    squiggy.physicsBody.collisionBitMask = 0;

你问这段代码是做什么的?好吧,让我来解释一下!

第一行为我们的 Squiggy 精灵创建了一个物理体。这个体是围绕精灵的矩形。下一行将精灵设置为动态,这意味着 SpriteKit 物理引擎将不会影响我们的 Squiggy 的运动。唯一控制它的是我们的代码。第三行将我们的 Squiggy 放入enemyCategory方法中。

然后,我们告诉引擎,如果两个对象之间发生碰撞,应该通知我们的类别方法。显然,我们选择了playerCategory方法。

接下来和最后的行我们添加的是一个有点难以理解的东西,我们将collisionBitMask值设置为0。碰撞位掩码定义了每个对象在发生碰撞时的响应。在我们的情况下,我们将其设置为0,这意味着它们在反弹或弹跳方面不会相互反应。

当我们创建玩家时,我们需要添加类似的代码,所以让我们创建一个新的方法,我们将每次我们的玩家被创建时调用它(即在他死后,或者生成新关卡等):

 -(void)setUpPlayerPhysics{
    self.player1.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:self.player1.size];
    self.player1.physicsBody.dynamic = YES;
    self.player1.physicsBody.categoryBitMask = playerCategory;
    self.player1.physicsBody.contactTestBitMask = enemyCategory;
    self.player1.physicsBody.collisionBitMask = 0;
}

这就是我们用来设置敌人物理的方法;我们只是改变了分类。

现在,在didHeWin方法的底部,以及在我们触摸按钮后设置玩家后的touchesBegan方法中,添加以下代码行:

 [self setUpPlayerPhysics];

超级简单!现在我们需要实际检测两个对象之间的碰撞。

我们需要在我们的GameLevelScene.m类文件中的任何地方添加以下方法:

- (void)didBeginContact:(SKPhysicsContact *)contact
{
    SKPhysicsBody *firstBody, *secondBody;

    uint32_t collision = (contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask);

    if (collision == (playerCategory | enemyCategory)) {
        [contact.bodyA.node removeFromParent];
        [contact.bodyB.node removeFromParent];
        [self gameOver:0];
    }

    else if (collision == (bulletCategory | enemyCategory)) {
        for (SKSpriteNode *playerBullet in _playerBullets) {
            if (playerBullet.hidden == NO) {
                [contact.bodyA.node removeFromParent];
                [contact.bodyB.node removeFromParent];
            }
        }
    }

    if (contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask)
    {
        firstBody = contact.bodyA;
        secondBody = contact.bodyB;

    }
    else
    {
        firstBody = contact.bodyB;
        secondBody = contact.bodyA;

    }
}

我们仍然需要添加bulletCategory函数,所以如果你现在构建项目,它将显示错误。别担心,我们将在几分钟内添加它。最后,我们将对我们的GameOver方法做一些轻微的调整:

-(void)gameOver:(BOOL)won {
    if (_level > 0){
    self.gameOver = YES;
        [_player1 removeFromParent];
    NSString *gameText;
    if (won) {
        gameText = @"Level Complete!";

    } else {
        gameText = @"You have died!";
        self.player1 = [[Player alloc] initWithImageNamed:@"P1idle"];
        self.player1.position = CGPointMake(100, 50);
        self.player1.zPosition = 15;
        [self setUpPlayerPhysics];
        [self.map addChild:self.player1];
        [self setViewpointCenter:self.player1.position];
        self.gameOver = NO;
    }
}
}

在这里,我们更改了我们的代码,每次我们的玩家被杀死时,他都会从场景中移除,并在关卡开始处创建一个新的玩家。此外,物理设置再次进行。

在测试了几种方法之后,上述方法是效果最好的。我只是将我们的玩家传送到了正常起始位置前方大约 100px 的位置。不知道为什么。但这确实有效!

现在让我们开始射击!

首先,我们将添加另一个类别,就像我们之前做的那样;这将如下所示:

static const uint32_t bulletCategory = 0x1 << 2;

我们现在将使用这个方法来处理玩家的子弹。此外,我们还想添加一个新的定义,所以就在#import部分下面,添加以下代码行:

#define kNumBullets 20

这个定义将帮助我们创建一个用于所有子弹的数组,我们将在稍后进行。

@implementation行上方和属性声明@end行之前,添加以下两行代码:

NSMutableArray *_playerBullets;
int _nextPlayerBullet;

我们创建了一个玩家子弹数组,这将允许我们一次创建多个子弹。然后我们在数组中定义了下一个子弹的编号。

在我们的setUpPlayerPhysics方法内部,添加以下代码块(不要忘记导入本书资源部分中所有位于资源部分的图像):

  #pragma mark - Setup the bullets
    _playerBullets = [[NSMutableArray alloc] initWithCapacity:kNumBullets];
    for (int i = 0; i < kNumBullets; ++i) {
        SKSpriteNode *playerBullet = [SKSpriteNode spriteNodeWithImageNamed:@"Bullet"];
        playerBullet.hidden = YES;
        [_playerBullets addObject:playerBullet];
        [self.map addChild:playerBullet];
    }

欢迎来到数组!这一切意味着什么?简单!我们取_playerBullets数组,用预定义的子弹数量初始化它;kNumBullets变量,它是20。所以现在数组有20个值。然后,对于数组中的每个条目,从020,添加一个子弹。这真的很简单。

我们将创建一个新的方法,如下所示:

-(void) startTheGame {
    for (SKSpriteNode *playerBullet in _playerBullets) {
        playerBullet.hidden = YES;
    }
}

当玩家按下播放按钮时,我们将调用前面的方法,所以在我们touchesBegan方法中,在if ([node.name isEqualToString:@"playButton"]) {语句的底部,添加以下代码:

[self startTheGame];

现在,我们需要创建一个新的触摸区域。所以,在我们的touchesBegan方法中,我们需要编辑我们的if (touchLocation.x > self.size.width / 2.0) {语句,改为以下内容:

 if (touchLocation.x > self.size.width / 2.0) {
            if (touchLocation.y < self.size.height / 2.0) {
            self.player1.jumping = YES;
            }
            else if (touchLocation.y > self.size.height / 2.0) {
                NSLog(@"PEW");
                if (touchLocation.x > self.size.width / 2.0) {
                    if (touchLocation.y < self.size.height / 2.0) {
                        self.player1.jumping = YES;
                    }
                    else if (touchLocation.y > self.size.height / 2.0) {
                        if (self.player1.xScale == - 1.0) {
                            SKSpriteNode *bullet = [_playerBullets objectAtIndex:_nextPlayerBullet];
                            _nextPlayerBullet++;
                            if (_nextPlayerBullet >= _playerBullets.count) {
                                _nextPlayerBullet = 0;
                            }

                            bullet.position = CGPointMake(_player1.position.x-bullet.size.width/2,_player1.position.y+0);
                            bullet.hidden = NO;
                            [bullet removeAllActions];

                            CGPoint location = CGPointMake(_player1.position.x - 1000, _player1.position.y);
                            SKAction *bulletMoveAction = [SKAction moveTo:location duration:2.5];

                            SKAction *bulletDoneAction = [SKAction runBlock:(dispatch_block_t)^() {
                                bullet.hidden = YES;
                            }];

                            SKAction *moveBulletActionWithDone = [SKAction sequence:@[bulletMoveAction,bulletDoneAction]];
                            [bullet runAction:moveBulletActionWithDone withKey:@"bulletFired"];
                        }

                        else {
                            SKSpriteNode *bullet = [_playerBullets objectAtIndex:_nextPlayerBullet];
                            _nextPlayerBullet++;
                            if (_nextPlayerBullet >= _playerBullets.count) {
                                _nextPlayerBullet = 0;
                            }

                            bullet.position = CGPointMake(_player1.position.x+bullet.size.width/2,_player1.position.y+0);
                            bullet.hidden = NO;
                            [bullet removeAllActions];

                            CGPoint location = CGPointMake(_player1.position.x + 1000, _player1.position.y);
                            SKAction *bulletMoveAction = [SKAction moveTo:location duration:2.5];

                            SKAction *bulletDoneAction = [SKAction runBlock:(dispatch_block_t)^() {
                                bullet.hidden = YES;
                            }];

                            SKAction *moveBulletActionWithDone = [SKAction sequence:@[bulletMoveAction,bulletDoneAction]];
                            [bullet runAction:moveBulletActionWithDone withKey:@"bulletFired"];
                        }
                    }
                }
            }
        }

在这里,我们从数组中选择了其中一个子弹。然后,我们将该子弹的位置设置为玩家的位置。接下来,我们将子弹的最终位置设置在屏幕之外,这就是为什么我们添加了两种方法,一种是在玩家向后走时按按钮,另一种是向前走。

现在,当你测试它时,你会看到以下输出:

创建敌人

很多“砰砰”声!它还会根据你面对的方向射击,所以这真的很酷。

现在,回到我们的setUpPlayerPhysics方法,在那里我们设置子弹,我们需要为每个子弹设置physicsBody值。在for循环中,在我们将子弹作为子项添加到地图之前,添加以下代码:

playerBullet.hidden = YES;
        playerBullet.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:playerBullet.size];
        playerBullet.physicsBody.dynamic = YES;
        playerBullet.physicsBody.categoryBitMask = bulletCategory;
        playerBullet.physicsBody.contactTestBitMask = enemyCategory;
        playerBullet.physicsBody.collisionBitMask = 0;

这与我们的之前的physicsBody设置相同,只是我们将子弹的分类更改为bulletCategory

现在,当你测试你的项目时,你应该能够射击、死亡和杀戮,而毫不在意!这不是很棒吗?

创建敌人

如果它对你有效,那就太好了!我很高兴为你感到高兴!我们在本章中做了很多工作,我们的游戏甚至还没有接近完成。为什么不尝试用本章学到的知识创建自己的关卡呢?生成一些新的敌人,也许尝试一些射击的坏蛋?如果不这样做,我会在本书的后面部分介绍一些更疯狂的坏蛋。现在,让我们休息一下!

我们的游戏运行效率很高,几乎以恒定的 60 fps 运行!太棒了!如果你没有正确设置,以下是你现在应该有的完整源代码:

//  GameLevelScene.m

#import "GameLevelScene.h"
#import "JSTileMap.h"
#import "Player.h"
#import "SKTUtils.h"
#import "SKTAudio.h"
#define kNumBullets 20

static const uint32_t playerCategory = 0x1 << 0;
static const uint32_t enemyCategory = 0x1 << 1;
static const uint32_t bulletCategory = 0x1 << 2;

@interface GameLevelScene() <SKPhysicsContactDelegate>
@property (nonatomic, strong) JSTileMap *map;
@property (nonatomic, strong) Player *player1;
@property (nonatomic, assign) NSTimeInterval previousTime;
@property (nonatomic, strong) TMXLayer *walls;
@property (nonatomic, strong) TMXLayer *hazards;
@property (nonatomic, assign) BOOL gameOver;

@property (nonatomic) NSTimeInterval lastSpawnTimeInterval;
@property (nonatomic) NSTimeInterval lastUpdateTimeInterval;
@property (nonatomic, assign) NSInteger level;
@end

NSMutableArray *_playerBullets;
int _nextPlayerBullet;

@implementation GameLevelScene

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {

        self.userInteractionEnabled = YES;
        self.backgroundColor = [SKColor colorWithRed:.0 green:.0 blue:.0 alpha:1.0];
        self.physicsWorld.gravity = CGVectorMake(0, 0);
        self.physicsWorld.contactDelegate = self;

        if (_level == 0){
        SKLabelNode *playLabel = [SKLabelNode labelNodeWithFontNamed:@"AvenirNext-Heavy"];
        playLabel.text = @"Adesa";
        playLabel.fontSize = 40;
        playLabel.position = CGPointMake(self.size.width / 2.0, self.size.height / 1.7);
        [self addChild:playLabel];

        SKSpriteNode *playButton = [SKSpriteNode spriteNodeWithImageNamed:@"play"];
        playButton.position = CGPointMake(self.size.width /2.0 , self.size.height / 2.5);
        playButton.name = @"playButton";
        [self addChild:playButton];

        }

    }
    return self;
}

-(void) startTheGame {
    for (SKSpriteNode *playerBullet in _playerBullets) {
        playerBullet.hidden = YES;
    }
}

- (void)addSquiggy {

    SKSpriteNode * squiggy = [SKSpriteNode spriteNodeWithImageNamed:@"Squiggy"];
    squiggy.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:squiggy.size];
    squiggy.physicsBody.dynamic = YES;
    squiggy.physicsBody.categoryBitMask = enemyCategory;
    squiggy.physicsBody.contactTestBitMask = playerCategory;
    squiggy.physicsBody.collisionBitMask = 0;

    int minY = squiggy.size.height / 2;
    int maxY = self.frame.size.height - squiggy.size.height / 2;
    int rangeY = maxY - minY;
    int actualY = (arc4random() % rangeY) + minY;
    squiggy.xScale = -1.0;
    squiggy.position = CGPointMake(self.player1.position.x + 1000 + squiggy.size.width/2, actualY);
    [self.map addChild:squiggy];

    // Setting the Speed of SQUIGGY!
    int minDuration = 10.0;
    int maxDuration = 20.0;
    int rangeDuration = maxDuration - minDuration;
    int actualDuration = (arc4random() % rangeDuration) + minDuration;

    SKAction * actionMove = [SKAction moveTo:CGPointMake(-squiggy.size.width/2, actualY) duration:actualDuration];
    SKAction * actionMoveDone = [SKAction removeFromParent];
    [squiggy runAction:[SKAction sequence:@[actionMove, actionMoveDone]]];
}

- (void)updateWithTimeSinceLastUpdate:(CFTimeInterval)timeSinceLast {

    self.lastSpawnTimeInterval += timeSinceLast;
    if (self.lastSpawnTimeInterval > 2) {
        self.lastSpawnTimeInterval = 0;
        [self addSquiggy];

    }
}

-(CGRect)tileRectFromTileCoords:(CGPoint)tileCoords {
    float levelHeightInPixels = self.map.mapSize.height * self.map.tileSize.height;
    CGPoint origin = CGPointMake(tileCoords.x * self.map.tileSize.width, levelHeightInPixels - ((tileCoords.y + 1) * self.map.tileSize.height));
    return CGRectMake(origin.x, origin.y, self.map.tileSize.width, self.map.tileSize.height);
}

- (NSInteger)tileGIDAtTileCoord:(CGPoint)coord forLayer:(TMXLayer *)layer {
    TMXLayerInfo *layerInfo = layer.layerInfo;
    return [layerInfo tileGidAtCoord:coord];
}

- (void)handleHazardCollisions:(Player *)player
{
    [self didHeWin];

    NSInteger indices[8] = {7, 1, 3, 5, 0, 2, 6, 8};

    for (NSUInteger i = 0; i < 8; i++) {
        NSInteger tileIndex = indices[i];

        CGRect playerRect = [player collisionBox];
        CGPoint playerCoord = [self.hazards coordForPoint:player.desiredPosition];

        NSInteger tileColumn = tileIndex % 3;
        NSInteger tileRow = tileIndex / 3;
        CGPoint tileCoord = CGPointMake(playerCoord.x + (tileColumn - 1), playerCoord.y + (tileRow - 1));

        NSInteger gid = [self tileGIDAtTileCoord:tileCoord forLayer:self.hazards];
        if (gid != 0) {
            CGRect tileRect = [self tileRectFromTileCoords:tileCoord];
            if (CGRectIntersectsRect(playerRect, tileRect)) {
                [self gameOver:0];
            }
        }
    }
}

-(void)gameOver:(BOOL)won {
    if (_level > 0){
    self.gameOver = YES;
        [_player1 removeFromParent];
    NSString *gameText;
    if (won) {
        gameText = @"Level Complete!";

    } else {
        gameText = @"You have died!";
        self.player1 = [[Player alloc] initWithImageNamed:@"P1idle"];
        self.player1.position = CGPointMake(100, 50);
        self.player1.zPosition = 15;
        [self setUpPlayerPhysics];
        [self.map addChild:self.player1];
        [self setViewpointCenter:self.player1.position];
        self.gameOver = NO;
    }
}
}

-(void)setUpPlayerPhysics{
    self.player1.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:self.player1.size];
    self.player1.physicsBody.dynamic = YES;
    self.player1.physicsBody.categoryBitMask = playerCategory;
    self.player1.physicsBody.contactTestBitMask = enemyCategory;
    self.player1.physicsBody.collisionBitMask = 0;

#pragma mark - Setup the bullets
    _playerBullets = [[NSMutableArray alloc] initWithCapacity:kNumBullets];
    for (int i = 0; i < kNumBullets; ++i) {
        SKSpriteNode *playerBullet = [SKSpriteNode spriteNodeWithImageNamed:@"Bullet"];
        playerBullet.hidden = YES;
        playerBullet.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:playerBullet.size];
        playerBullet.physicsBody.dynamic = YES;
        playerBullet.physicsBody.categoryBitMask = bulletCategory;
        playerBullet.physicsBody.contactTestBitMask = enemyCategory;
        playerBullet.physicsBody.collisionBitMask = 0;

        [_playerBullets addObject:playerBullet];
        [self.map addChild:playerBullet];
    }
}

-(void)didHeWin {
    if (self.player1.position.x > 3200.0) {

        if (_level == 1) {
        [self.map removeFromParent];
        self.map = [JSTileMap mapNamed:@"level2.tmx"];
        [self addChild:self.map];
        }
        self.player1 = [[Player alloc] initWithImageNamed:@"P1idle"];
        self.player1.position = CGPointMake(100, 50);
        self.player1.zPosition = 15;
        [self.map addChild:self.player1];
        self.walls = [self.map layerNamed:@"walls"];
        self.hazards = [self.map layerNamed:@"hazards"];
        [self setUpPlayerPhysics];
    }
}

- (void)checkForAndResolveCollisionsForPlayer:(Player *)player forLayer:(TMXLayer *)layer
{

    NSInteger indices[8] = {7, 1, 3, 5, 0, 2, 6, 8};
    player.onGround = NO;
    for (NSUInteger i = 0; i < 8; i++) {
        NSInteger tileIndex = indices[i];

        CGRect playerRect = [player collisionBox];
        CGPoint playerCoord = [layer coordForPoint:player.desiredPosition];
        if (playerCoord.y >= self.map.mapSize.height - 1) {
            [self gameOver:0];
            return;
        }

        NSInteger tileColumn = tileIndex % 3;
        NSInteger tileRow = tileIndex / 3;
        CGPoint tileCoord = CGPointMake(playerCoord.x + (tileColumn - 1), playerCoord.y + (tileRow - 1));

        NSInteger gid = [self tileGIDAtTileCoord:tileCoord forLayer:layer];
        if (gid != 0) {
            CGRect tileRect = [self tileRectFromTileCoords:tileCoord];

            if (CGRectIntersectsRect(playerRect, tileRect)) {
                CGRect intersection = CGRectIntersection(playerRect, tileRect);

                if (tileIndex == 7) {

                    player.desiredPosition = CGPointMake(player.desiredPosition.x, player.desiredPosition.y + intersection.size.height);
                    player.velocity = CGPointMake(player.velocity.x, 0.0);
                    player.onGround = YES;
                } else if (tileIndex == 1) {

                    player.desiredPosition = CGPointMake(player.desiredPosition.x, player.desiredPosition.y - intersection.size.height);
                } else if (tileIndex == 3) {

                    player.desiredPosition = CGPointMake(player.desiredPosition.x + intersection.size.width, player.desiredPosition.y);
                } else if (tileIndex == 5) {

                    player.desiredPosition = CGPointMake(player.desiredPosition.x - intersection.size.width, player.desiredPosition.y);

                } else {
                    if (intersection.size.width > intersection.size.height) {

                        player.velocity = CGPointMake(player.velocity.x, 0.0);
                        float intersectionHeight;
                        if (tileIndex > 4) {
                            intersectionHeight = intersection.size.height;
                            player.onGround = YES;
                        } else {
                            intersectionHeight = -intersection.size.height;
                        }
                        player.desiredPosition = CGPointMake(player.desiredPosition.x, player.desiredPosition.y + intersection.size.height );
                    } else {

                        float intersectionWidth;
                        if (tileIndex == 6 || tileIndex == 0) {
                            intersectionWidth = intersection.size.width;
                        } else {
                            intersectionWidth = -intersection.size.width;
                        }

                        player.desiredPosition = CGPointMake(player.desiredPosition.x  + intersectionWidth, player.desiredPosition.y);
                    }
                }
            }
        }
    }

    player.position = player.desiredPosition;
    [self handleHazardCollisions:self.player1];
}

- (void)didBeginContact:(SKPhysicsContact *)contact
{
    SKPhysicsBody *firstBody, *secondBody;

    uint32_t collision = (contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask);

    if (collision == (playerCategory | enemyCategory)) {
        [contact.bodyA.node removeFromParent];
        [contact.bodyB.node removeFromParent];
        [self gameOver:0];
    }

    else if (collision == (bulletCategory | enemyCategory)) {
        for (SKSpriteNode *playerBullet in _playerBullets) {
            if (playerBullet.hidden == NO) {
                [contact.bodyA.node removeFromParent];
                [contact.bodyB.node removeFromParent];
            }
        }
    }

    if (contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask)
    {
        firstBody = contact.bodyA;
        secondBody = contact.bodyB;

    }
    else
    {
        firstBody = contact.bodyB;
        secondBody = contact.bodyA;

    }
}

- (void)setViewpointCenter:(CGPoint)position {
    NSInteger x = MAX(position.x, self.size.width / 2);
    NSInteger y = MAX(position.y, self.size.height / 2);
    x = MIN(x, (self.map.mapSize.width * self.map.tileSize.width) - self.size.width / 2);
    y = MIN(y, (self.map.mapSize.height * self.map.tileSize.height) - self.size.height / 2);
    CGPoint actualPosition = CGPointMake(x, y);
    CGPoint centerOfView = CGPointMake(self.size.width/2, self.size.height/2);
    CGPoint viewPoint = CGPointSubtract(centerOfView, actualPosition);
    self.map.position = viewPoint;
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{

    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInNode:self];

        SKNode *node = [self nodeAtPoint:touchLocation];

        if ([node.name isEqualToString:@"playButton"]) {
            _level = 1;
            [self removeAllChildren];
            self.backgroundColor = [SKColor colorWithRed:.25 green:.0 blue:.25 alpha:1.0];
            [[SKTAudio sharedInstance] playBackgroundMusic:@"BackgroundAudio.mp3"];
            self.map = [JSTileMap mapNamed:@"level1.tmx"];
            [self addChild:self.map];
            NSString *rainParticles =
            [[NSBundle mainBundle] pathForResource:@"Rain" ofType:@"sks"];

            SKEmitterNode *rainEmitter =
            [NSKeyedUnarchiver unarchiveObjectWithFile:rainParticles];

            rainEmitter.position = CGPointMake(0, self.scene.size.height);

            [self addChild:rainEmitter];

            NSString *fireParticles =
            [[NSBundle mainBundle] pathForResource:@"Flames" ofType:@"sks"];

            SKEmitterNode *fireEmitter =
            [NSKeyedUnarchiver unarchiveObjectWithFile:fireParticles];
            fireEmitter.position = CGPointMake(25, 50);

            [_map addChild:fireEmitter];

            self.player1 = [[Player alloc] initWithImageNamed:@"P1idle"];
            self.player1.position = CGPointMake(100, 50);
            self.player1.zPosition = 15;
            [self.map addChild:self.player1];
            self.walls = [self.map layerNamed:@"walls"];
            self.hazards = [self.map layerNamed:@"hazards"];
            [self setUpPlayerPhysics];
            [self startTheGame];
        }

        if (touchLocation.x > self.size.width / 2.0) {
            if (touchLocation.y < self.size.height / 2.0) {
            self.player1.jumping = YES;
            }
            else if (touchLocation.y > self.size.height / 2.0) {
                NSLog(@"PEW");
                if (touchLocation.x > self.size.width / 2.0) {
                    if (touchLocation.y < self.size.height / 2.0) {
                        self.player1.jumping = YES;
                    }
                    else if (touchLocation.y > self.size.height / 2.0) {
                        if (self.player1.xScale == - 1.0) {
                            SKSpriteNode *bullet = [_playerBullets objectAtIndex:_nextPlayerBullet];
                            _nextPlayerBullet++;
                            if (_nextPlayerBullet >= _playerBullets.count) {
                                _nextPlayerBullet = 0;
                            }

                            bullet.position = CGPointMake(_player1.position.x-bullet.size.width/2,_player1.position.y+0);
                            bullet.hidden = NO;
                            [bullet removeAllActions];

                            CGPoint location = CGPointMake(_player1.position.x - 1000, _player1.position.y);
                            SKAction *bulletMoveAction = [SKAction moveTo:location duration:2.5];

                            SKAction *bulletDoneAction = [SKAction runBlock:(dispatch_block_t)^() {
                                bullet.hidden = YES;
                            }];

                            SKAction *moveBulletActionWithDone = [SKAction sequence:@[bulletMoveAction,bulletDoneAction]];
                            [bullet runAction:moveBulletActionWithDone withKey:@"bulletFired"];
                        }

                        else {
                            SKSpriteNode *bullet = [_playerBullets objectAtIndex:_nextPlayerBullet];
                            _nextPlayerBullet++;
                            if (_nextPlayerBullet >= _playerBullets.count) {
                                _nextPlayerBullet = 0;
                            }

                            bullet.position = CGPointMake(_player1.position.x+bullet.size.width/2,_player1.position.y+0);
                            bullet.hidden = NO;
                            [bullet removeAllActions];

                            CGPoint location = CGPointMake(_player1.position.x + 1000, _player1.position.y);
                            SKAction *bulletMoveAction = [SKAction moveTo:location duration:2.5];

                            SKAction *bulletDoneAction = [SKAction runBlock:(dispatch_block_t)^() {
                                bullet.hidden = YES;
                            }];

                            SKAction *moveBulletActionWithDone = [SKAction sequence:@[bulletMoveAction,bulletDoneAction]];
                            [bullet runAction:moveBulletActionWithDone withKey:@"bulletFired"];
                        }
                    }
                }
            }
        }

        else {
            if (touchLocation.x < self.size.width / 2.0) {
                if (touchLocation.y > self.size.height / 2){
                    self.player1.goingBackwards = YES;
                    self.player1.animateWalking = YES;
                    self.player1.xScale = -1.0;
                }
                if (touchLocation.y < self.size.height / 2){
                    self.player1.walking = YES;
                    self.player1.animateWalking = YES;
                    self.player1.xScale = 1.0;
                }
            }

        }
    }
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    for (UITouch *touch in touches) {

        float halfWidth = self.size.width / 2.0;
        CGPoint touchLocation = [touch locationInNode:self];

        CGPoint previousTouchLocation = [touch previousLocationInNode:self];

        if (touchLocation.x > halfWidth && previousTouchLocation.x <= halfWidth) {
            self.player1.walking = NO;
            self.player1.goingBackwards = NO;
            self.player1.jumping = YES;
        } else if (previousTouchLocation.x > halfWidth && touchLocation.x <= halfWidth) {

            self.player1.goingBackwards = NO;
            self.player1.jumping = NO;
        }
        else if (previousTouchLocation.x > halfWidth && touchLocation.x <= halfWidth) {
            if (touchLocation.y > self.size.height / 2){
            self.player1.walking = NO;
            self.player1.jumping = NO;
            }
        }
    }
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {

    for (UITouch *touch in touches) {
        CGPoint touchLocation = [touch locationInNode:self];
        if (touchLocation.x < self.size.width / 2.0) {
            if (touchLocation.x < self.size.width / 2.0) {
                if (touchLocation.y > self.size.height / 2){
                    self.player1.goingBackwards = NO;
                    self.player1.animateWalking = NO;
                }
                if (touchLocation.y < self.size.height / 2){
                    self.player1.walking = NO;
                    self.player1.animateWalking = NO;
                }
            }

        } else {
            self.player1.jumping = NO;
        }
    }
}

- (void)update:(NSTimeInterval)currentTime
{

    CFTimeInterval timeSinceLast = currentTime - self.lastUpdateTimeInterval;
    self.lastUpdateTimeInterval = currentTime;
    if (timeSinceLast > 1) {
        timeSinceLast = 1.0 / 60.0;
        self.lastUpdateTimeInterval = currentTime;
    }

    [self updateWithTimeSinceLastUpdate:timeSinceLast];

    if (self.gameOver) return;

    [self setViewpointCenter:self.player1.position];
    NSTimeInterval delta = currentTime - self.previousTime;

    if (delta > 0.02) {
        delta = 0.02;
    }

    self.previousTime = currentTime;

    [self.player1 update:delta];

    [self checkForAndResolveCollisionsForPlayer:self.player1 forLayer:self.walls];
}

@end //(which is fitting because this is the end of the chapter... and end of the page :D)

摘要

让我们回顾一下本章中我们所学到的内容。我们在游戏中添加了一些音乐和酷炫的声音效果。然后,我们给玩家添加了一个合适的行走动画,这样他就不会看起来像是在空中漂浮!然而,如果你不想要这种效果,你可以节省很多打字。接下来,我们制作了一些看起来非常酷炫的粒子效果!我们让雨下起来,还让被毁的船只燃烧起来!

我们简要讨论了菜单,以及我们如何创建一个简单的主菜单。我们将在准备发布时进一步完善它。

然后,敌人!我们创造了我们的 Squiggies,那些既可爱又危险的敌人。事情将会变得可怕,所以系好安全带,享受这段旅程吧。

注意:只有我们的玩家会感到可怕,而我们不会,因为我们才是创造混乱的人。

第五章:虫子消除——测试和调试

我还在努力从上一章中恢复过来!哎呀,我们做了多少工作!现在是我们稍微放慢一点节奏的时候了。毕竟,编码之后,我们将讨论测试和调试。调试是整个开发过程中的关键步骤,因为虫子的出现可能会破坏你的应用体验。你花费了无数小时在上面,如果有虫子,人们会认为这是不专业的。这当然不是理想的。让我们看看本章我们将讨论什么:

  • 在模拟器上测试我们的代码

  • 在各种设备上测试我们的项目

  • 为测试人员设置 TestFlight

  • 消除那些虫子

我们将在这个章节中放慢节奏,很快就会继续编码!所以,让我们放松一下,把这个章节当作一个休息。但别以为这会轻松过关!不,不!虽然这并不像我们上一章那样充满动作,正如我提到的,这个主题在所有项目中都非常关键,所以我们非常重视这一点。

嘣!

接下来!

测试我们的项目

毫无疑问,你已经对 iOS 模拟器(一个实际上非常有用,而且我发现比 Android SDK 模拟器更容易访问的功能)进行了操作,这很棒,但实际上你可以用它做很多事情,而无需在物理设备上安装任何东西。

让我们打开我们的设备模拟器。为此,在 Xcode 中,点击顶部栏上的Xcode,然后点击打开开发者工具,最后点击iOS 模拟器

现在,你将看到一个像素级的任何你选择的 iOS 设备的模拟:

测试我们的项目

如我之前提到的,我喜欢使用 iPhone 4S 模拟器,因为它足够小,可以适合我的屏幕,但你完全可以选择你喜欢的设备。你的模拟器设置的是你不想用的设备吗?想换成其他设备,比如闪亮的 iPhone 6+?没问题!

简单地点击顶部栏上的硬件。然后,在设备下,你可以选择任何你喜欢的酷炫苹果移动设备!当你选择一个新设备时,你需要给新设备一些时间来启动。

让我们探索!

如果你点击文件,你可以截图或至少看到你可以按什么键组合来创建设备屏幕的截图(这对于上传到 Facebook 或 iTunes 来说很棒)。

当你点击编辑时,你可以复制文本,复制屏幕,粘贴,开始录音,并查看表情符号。

回到硬件,再次你可以选择你的设备,你可以旋转设备向左或向右,触发设备摇晃手势,触发主页和锁定按钮,或者强制重启。

你还可以模拟内存警告,触发通话状态栏,以及选择你的键盘设置以及外部设备。

调试设置下,你可以选择减慢动画,例如打开和关闭等 UI 动画。

你还可以在屏幕上做一些有趣的彩色处理!

什么?

是的!您可以着色混合层、复制的图像、错位的图像和离屏渲染的项目。换句话说,您可以突出显示它们,使它们看起来如图所示。有点酷,对吧?

那么,为什么有人会使用这个功能呢?简单来说;有时候在调试时精灵可能会丢失,而你又想跟踪它们。使用这个功能,你可以轻松跟踪它们的移动。如果你屏幕上有许多精灵在飞舞,这是一个很棒的功能可以利用。如图所示,所有图像和精灵都被跟踪,并且清晰地标出:

测试我们的项目

接下来,您还可以打开系统日志,这对于调试来说非常棒。您还可以触发 iCloud 同步,并设置设备模拟位置。

您可以稍微探索一下设备,但您会注意到一些设置和应用程序缺失。例如,您无法为设备选择背景、打电话或发送消息。您有手机来处理这类事情!

接下来,我们将构建并运行我们的项目(再次,通过点击Xcode中的播放按钮)。我们仍然应该有 SpriteKit 帧率标签,但这并不像我们希望的那样深入。

要获取更多技术统计信息,点击返回到Xcode,然后在顶部的栏上点击调试。将鼠标悬停在附加到进程上,以找到我们正在工作的项目名称。它将显示在可能的目标下。当您点击您的项目时,Xcode 将显示一些技术信息,如图所示:

测试我们的项目

在其他细节中,Xcode 会告诉我们应用程序的进程标识符PID)、CPU内存磁盘网络的使用情况,如图所示:

测试我们的项目

是的,这些功能相当不错,但再次强调,仍然没有达到开发者希望看到的那么深入。想要获得一些真正酷的深入监控吗?

回到Xcode,在顶部的栏上点击Xcode,向下滚动到打开开发者工具,并选择Instruments

现在是时候疯狂一下,做一些深入测试了:

测试我们的项目

看看所有这些令人惊叹的选择!让我们逐一了解这里所有的选项,以帮助您进行调试:

  • 活动监视器:类似于我们之前看到的性能数据,我们对应用程序内部发生的事情有更多的了解,同时能够记录发生的事情,并回溯我们的日志以查看性能下降的点。

  • 分配:这一部分跟踪应用程序的虚拟内存,并将其分解为类名。

  • 自动化:这个功能执行一个简单的脚本,将模拟与用户界面的交互,当然,所有操作都会被记录。

  • Cocoa 布局: 这一节监控在 Xcode 的 Storyboard 编辑器中通过布局 UI 创建的 NSLayoutConstraints,以查看哪些东西位置不正确。

  • 核心动画: 这项指标衡量的是应用图形性能和 CPU 使用情况。

  • 核心数据: 这项指标衡量的是核心数据系统的活动。换句话说,就是磁盘或内存的使用情况。

接下来,让我们看看一些更重要的事项:

  • 能量诊断: 这是一个优秀的工具,用于监控电池使用情况

  • 内存泄漏: 这项指标衡量内存使用情况,并检查是否有内存泄漏

  • OpenGL ES 分析: 这项指标和分析我们的项目渲染,并检测OGLES(我喜欢这样称呼它)的正确性以及性能问题

真的很酷,不是吗?在下一章中,当我们讨论如何使我们的游戏更高效时,我们将更深入地探讨这一点。

现在我们已经了解了模拟器,并且查看了一些苹果提供的出色工具,让我们继续安装我们的应用到实际设备上,这实际上非常简单。

您只需使用闪电线缆或 30 针连接器(如果您有较旧的设备)通过 USB 将您的设备连接到您的开发计算机。

在 Xcode 中,在您选择要使用的 iOS 模拟器的地方,点击方案工具栏,然后从菜单中选择您的设备。

Xcode 将自动为您注册设备,以便用作开发设备。与过去您必须手动通过 developer.apple.com 添加设备的 UUID 号码的方式相比,苹果已经真正简化了这一过程。

您的设备可能在方案编辑器中被禁用,这意味着它不是一个合格的设备。例如,假设您在撰写这本书的时候安装了刚刚发布的 iOS 9 测试版。如果您安装了它,并且安装了较旧的 Xcode 版本,那么您的设备将不符合条件。您将不得不安装 Xcode 的最新版本。

现在,只需简单地点击运行按钮,Xcode 就会将您的应用安装到设备上。就这么简单!

您可能会遇到 Xcode 询问是否允许在您的密钥链中执行 codesign。点击始终允许。您不希望每次点击运行按钮时都弹出这个提示。通过点击始终允许,Xcode 将获得在您的设备上自动运行项目的权限,而无需询问。

就像我们将调试器和日志连接到我们的 iOS 模拟器一样,您可以将它们链接到运行在您的设备上的项目。

实际上,由于你在模拟器上运行,你可能会得到不同的结果。设备可能会根据设备类型以及你安装的 flappy 克隆数量、存储的消息数量、剩余的存储空间等因素,运行得更好或更差。通常情况下(如果可能的话),最好有一个只安装了你的项目的开发专用设备。这样,你就知道你的应用程序每次都会以 100% 的性能运行。

现在我们已经了解了如何将调试与我们的项目链接起来,并且我们知道如何在设备上安装我们的应用程序,是时候讨论如何获取测试人员来帮助你了。

设置 TestFlight 用户

你可能会问,为什么我要设置 TestFlight 用户?简单来说,你可能可以连续测试你的代码数小时,却仍然找不到某些错误。相信我,我以前就做过,结果在数千人安装了应用程序之后,才发现有一个可能通过让其他人以破坏性的方式玩游戏就能发现的破坏游戏错误的 bug。

测试人员,或 TestFlight 用户可以是想要帮助你测试你的游戏的你的朋友,甚至是将测试你的游戏并提供真实反馈的焦点小组中的成员。让我们讨论如何设置 TestFlight 用户。

什么是 TestFlight 用户?

你可以将 TestFlight 用户定义为测试人员。当你准备好让用户开始测试时,你可以设置 TestFlight 用户,当您推送新版本进行测试时,他们将在设备上收到通知。从那里,他们可以记录他们看到的任何问题,这样你就会知道是否需要修复。

我们将首先使用您的开发者 Apple ID 登录到 itunesconnect.apple.com,然后你会看到以下页面:

什么是 TestFlight 用户?

从主屏幕,只需点击用户和角色按钮。在下一页的顶部栏上,你会看到一个TestFlight 测试人员按钮。点击它开始添加测试人员!

现在,你将处于 TestFlight 测试人员页面。从这里,你可以看到您的内部测试人员(包括你以及你的团队中的其他人)以及您的外部测试人员。

要添加人员来为你进行测试,点击外部按钮。在下一页,你将看到旁边有一个蓝色加号按钮的文本外部测试者,正如你在以下截图中所见:

什么是 TestFlight 用户?

内部测试者

通过与您在 iTunes Connect 中分配了技术或管理员角色的最多 25 名团队成员共享您的测试版构建,快速获取反馈。每位成员最多可以在 10 台设备上进行测试。

外部测试者

一旦您准备好,您可以邀请最多 1,000 名不属于您开发组织的用户测试您打算在 App Store 上公开发布的应用。提供给外部测试人员的应用需要Beta App Review,并且在测试开始之前必须遵守完整的App Store 审查指南。对于包含重大更改的新版本的应用,需要进行审查。一次可以测试最多 10 个应用,无论是内部还是外部。

然后,您将能够添加所需数量的测试人员。除了输入他们的电子邮件地址,以及可选的他们的名字和姓氏,您还可以将这些人员添加到组中,这有助于保持多个测试人员的组织,如下面的截图所示:

外部测试人员

然后,用户将收到一封电子邮件,要求他们设置账户。从那里,他们可以在他们的设备上设置 TestFlight。我们现在将讨论 TestFlight 用户如何开始测试您的杰作。

使用 TestFlight 作为测试人员

安装

您可以在最多 10 台设备上使用 TestFlight 测试多个开发者的多个应用;您可以测试的应用数量没有限制。

TestFlight(如以下图标所示),只能用于在运行 iOS 8 或更高版本的 iPhone、iPad 和 iPod touch 上测试 iOS 应用,而不是 Mac 应用。

安装

测试

一旦测试人员接受了邀请,他们就能下载应用的测试版本。

如果他们已经在设备上安装了实时应用,则测试版应用将替换实时版本。

当他们下载了测试版应用后,他们将在其名称旁边看到一个橙色圆点,以标识其为测试版。

TestFlight 应用将在每次有新版本可用时通知测试人员,并提供关于他们应该关注应用哪些部分的说明。

测试人员可以通过在 TestFlight 的应用详情视图中的提供反馈按钮轻松提供反馈。将自动打开一封带有应用和设备详情的电子邮件,测试人员可以在其中添加更多详细信息并附上截图。

测试版应用是有有效期的。测试期从发布给测试人员的当天开始,为期 30 天。在 TestFlight 中,每个应用的剩余天数显示在打开按钮下方。

如果测试版应用有内购项目,您不需要购买它们,因为使用测试构建进行的内购是免费的。

拒绝测试

如果测试人员不接受您的电子邮件邀请,则测试版应用将不会被安装,他们也不会被列为测试人员。

此外,测试人员可以通过邀请电子邮件底部的链接取消测试,通知您(开发者)他们希望从 TestFlight 中移除。

如果测试人员接受了邀请但他们不想测试应用,他们可以在 TestFlight 的应用详情页面中删除自己作为测试人员。因为,你知道,人们都是自私的。

所以实际上,苹果已经创建了一个很棒的系统,允许你轻松快速地测试你的应用。

请记住,每次你想测试你应用的最新版本时,你都需要上传新版本,并将新版本提交进行审核(虽然这听起来有些奇怪,我知道)。这确实看起来有些重复,但嘿,这是苹果,他们知道自己在做什么。

现在,让我们来谈谈如何消灭虫子!

消灭那些虫子!

这是一件很繁琐的事情。说实话,调试会花费你很多小时来解决问题,尤其是当你刚开始并且对代码不是很熟悉的时候。相信我,这需要一些时间,但不用担心;这是值得的。

让我们拿我们创建的应用来说,我把它发送给一个朋友去测试,以下是他在测试后发送给我的设备日志片段:

Size (256.000000, 256.000000)
2015-06-11 21:24:04.357 Adesa[11289:1870205] Layer background has zPosition -60.000000
2015-06-11 21:24:04.363 Adesa[11289:1870205] Layer walls has zPosition -40.000000
2015-06-11 21:24:04.364 Adesa[11289:1870205] Layer hazards has zPosition -20.000000
2015-06-11 21:24:06.725 Adesa[11289:1870205] PEW
2015-06-11 21:24:21.971 Adesa[11289:1870205] PEW
2015-06-11 21:24:22.158 Adesa[11289:1870205] PEW
2015-06-11 21:24:22.391 Adesa[11289:1870205] PEW
2015-06-11 21:24:22.511 Adesa[11289:1870205] PEW
2015-06-11 21:24:22.911 Adesa[11289:1870205] PEW
2015-06-11 21:24:23.110 Adesa[11289:1870205] PEW
2015-06-11 21:24:23.976 Adesa[11289:1870205] PEW
2015-06-11 21:24:24.145 Adesa[11289:1870205] PEW
2015-06-11 21:24:24.344 Adesa[11289:1870205] PEW
2015-06-11 21:24:37.587 Adesa[11289:1870205] PEW
2015-06-11 21:24:46.171 Adesa[11289:1870205] PEW
2015-06-11 21:24:46.304 Adesa[11289:1870205] PEW
2015-06-11 21:24:46.470 Adesa[11289:1870205] PEW
2015-06-11 21:24:46.671 Adesa[11289:1870205] PEW
2015-06-11 21:24:46.821 Adesa[11289:1870205] PEW
2015-06-11 21:25:04.079 Adesa[11289:1870205] PEW
2015-06-11 21:25:04.310 Adesa[11289:1870205] PEW
2015-06-11 21:25:05.054 Adesa[11289:1870205] PEW
2015-06-11 21:25:05.222 Adesa[11289:1870205] PEW
2015-06-11 21:25:05.422 Adesa[11289:1870205] PEW
2015-06-11 21:25:05.974 Adesa[11289:1870205] unexpected nil window in _UIApplicationHandleEventFromQueueEvent, _windowServerHitTestWindow: <UIClassicWindow: 0x7fa9ebb01370; frame = (0 0; 320 568); userInteractionEnabled = NO; gestureRecognizers = <NSArray: 0x7fa9e9708080>; layer = <UIWindowLayer: 0x7fa9e9505e70>>
2015-06-11 21:25:22.856 Adesa[11289:1870205] PEW
2015-06-11 21:25:23.073 Adesa[11289:1870205] PEW
2015-06-11 21:25:23.223 Adesa[11289:1870205] PEW
Message from debugger: Terminated due to signal 15

他在测试时没有发现任何问题。他注意到的只是当游戏刚开始时,设备(iPhone 5S)似乎变慢了,而且他的手机同时显示了雨和火焰。这是我们将在下一章考虑的事情。

在调试时,还有一些其他的事情需要记住。正如我们之前讨论的,当你点击工作区工具栏中的运行按钮并且你的应用成功构建后,Xcode 会运行你的应用并开始一个调试会话。你可以使用图形工具,如数据提示和快速查看,直接在源编辑器中调试你的应用,以查看变量的值。

调试区域和调试导航器让你可以检查运行中的应用的当前状态并控制其执行。

创建一个高质量的应用(比如,拜托,你已经创建并将会创建更多优秀质量的应用……但让我们继续……)需要你尽量减少应用对用户设备的冲击。使用我们在之前讨论过的调试仪表,在调试导航器中获取你对应用资源消耗的洞察,当你发现问题时,使用工具来测量和分析你应用的性能。我们将在下一章中进一步讨论这一点。

如果你正在开发 iOS 应用,请在设计和早期测试阶段使用 iOS 模拟器来查找主要问题。

你可以配置 Xcode 以帮助你专注于调试任务。例如,当你的代码遇到断点时,你可以让 Xcode 自动播放一个警报声音,并创建一个名为调试的窗口标签,Xcode 在这里显示调试区域、调试导航器和你的断点处的代码。

Xcode 允许你逐行执行你的代码,以查看程序在执行特定阶段的程序状态,这是一个真正棒的功能,可以帮助你找出问题代码。

您还可以使用调试区域来控制代码的执行,查看程序变量和寄存器,查看其控制台输出,并与调试器交互。此外,您还可以使用调试区域导航到渲染帧的 OpenGL 调用,并查看特定调用处的渲染状态信息。以下图像分解了用户界面以及每一项的功能。别担心,我会在图像之后解释一切:

挤压那些虫子!

您可以通过点击调试区域工具栏中的暂停按钮(在“暂停”和“继续”之间切换)来挂起应用的执行。

要设置断点,打开源代码文件,并点击您希望执行暂停的行旁边的空白处空白处中的蓝色箭头(再次)表示断点。

当您的应用暂停时,当前正在执行的代码行将以绿色突出显示。

您可以使用位于调试区域顶部的栏中的Step OverStep IntoStep Out按钮来逐步执行您的代码。点击Step Over将执行当前代码行,包括任何方法。

如果当前代码行调用一个方法,Step Into将从当前行开始执行,并在达到被调用方法的第 1 行时停止。Step Out按钮将执行当前方法或函数的其余部分。

当执行暂停时,调试导航器打开以显示堆栈跟踪(报告应用执行过程中某一时间点的活动堆栈帧)。在调试导航器中选择一个项目,以在编辑区域和调试区域查看有关该项目的信息。在继续调试时,展开或折叠线程以显示或隐藏堆栈帧。

在源代码编辑器中的任何变量上悬停,以查看显示变量值的提示信息。点击变量旁边的检查器图标,将打印对象的 Objective-C 描述到调试区域控制台,并在额外的弹出窗口中显示该描述。

点击快速查看图标以查看变量的图形显示。您可以为您的对象实现自定义的快速查看显示。

当你在连接的设备上构建和运行 OpenGL ES 应用时,调试区域工具栏包括一个帧捕获按钮。点击此按钮以捕获一个帧。您可以使用 OpenGL ES 帧捕获执行以下操作:

  • 检查 OpenGL ES 状态信息

  • 反射 OpenGL ES 对象,如视图纹理和着色器

  • 步骤遍历每个绘制调用之前的调用状态(当前调试状态信息),并观察每次调用时的变化

  • 步骤遍历绘制调用(应用当前渲染状态)以查看图像是如何构建的

  • 通过检查辅助编辑器查看每个绘制调用使用的对象

  • 编辑着色器(如图像的渲染方式,例如颜色信息、光晕、镜头光晕等特殊效果),以查看对应用程序的影响。

左侧的调试导航器显示了渲染树的部分,而主调试视图显示了渲染帧的颜色和深度来源,以及其他图像来源。

点击调试区域顶部的工具栏中的调试视图层次结构按钮,以检查暂停应用的视图层次结构的 3D 渲染。您可以执行以下操作:

  • 通过在画布上点击和拖动来旋转渲染。什么是画布?那是在 Xcode 中看到应用渲染的地方。

  • 使用左下角的滑块来增加或减少视图层之间的间距。

  • 使用右下角的滑块来更改可见视图的范围。将左侧的手柄移动到最底部的可见视图。将右侧的手柄移动到最顶部的可见视图,以获得所需的视图。毕竟,场景中还有更多的事情在进行,某些视图不会显示出来!

  • 通过点击显示剪裁内容按钮来揭示所选视图的任何剪裁内容。

  • 您还可以通过点击显示剪裁内容按钮来揭示所选视图的任何剪裁内容。如果您的视图由于屏幕尺寸较小而被剪裁,您可以这样做。

  • 使用放大 (+)缩小 (-)按钮来增加和减少放大倍数。

如本书中所述,iOS 模拟器可以帮助你在设计和早期测试阶段发现应用中的主要问题,因为它正是这样一个模拟器。并不是每个人都能负担得起每一款新的苹果设备!

当我坐在我的 27 英寸 iMac、13 英寸 MacBook Pro 前面,用我的 5S 发短信,给我的 iPad 充电,目前正在考虑购买苹果手表并穿着我的苹果商店衬衫时……嗯……难怪我没有钱。

接下来!

在 iOS 模拟器的每个模拟环境中,主屏幕提供了访问包含在 iOS 设备上的应用(如 Safari、联系人、地图和 Passbook)的途径。

您可以在 iOS 模拟器中通过这些应用对您应用的交互进行初步测试。例如,就像我们做的那样,如果您正在测试一个游戏,请使用 iOS 模拟器来测试该游戏是否正确使用 Game Center。

iOS 模拟器中的无障碍检查器可以帮助您测试您应用的可用性,无论个人的限制或残疾如何。

无障碍检查器显示您应用中每个可访问元素的信息,并允许您模拟与这些元素的 VoiceOver 交互。要启动无障碍检查器,请点击 iOS 模拟器上的按钮。点击设置,然后转到通用 | 辅助功能。将无障碍检查器开关设置为开启

您可以通过更改语言在 iOS 模拟器中测试您应用的本地化。在设置中,导航到通用 | 国际 | 语言

现在,这些显然是一些相当超前的步骤,你可以采取。我认为并不是每个游戏都需要无障碍功能(或者每个开发者都会将其包含在他们的应用中),但如果你的应用将在全球范围内被看到,并且你希望每个人都能使用它,那么这些都是需要记住的好事情。

虽然你可以在 iOS 模拟器中测试你应用的基本行为,但模拟器作为一个测试平台在多个方面都是有限的。为什么是这样?考虑以下因素:

  • iOS 模拟器是一个在 Mac 上运行的应用,它可以访问计算机的内存,这比设备上的内存大得多。

  • iOS 模拟器在 Mac CPU 上运行,而不是在 iOS 设备的处理器上。

  • iOS 模拟器不会运行设备上运行的所有线程。

  • iOS 模拟器无法模拟硬件功能,例如加速度计、陀螺仪、摄像头或接近传感器。所以不要把你的电脑扔来模拟加速度计控制!请不要……请不要问……我保证我没有这么做。

在开发你的应用时,确保你在你打算支持的所有的 iOS 设备和 iOS 版本上运行和测试它。这样做是常识,因为你可以为每个设备调整性能。

我知道这需要记住很多东西,而且你可能每次测试应用或构建新应用时都不会考虑这些。如果在你自己的开发中遇到问题,这些信息是很好的背景知识。

我们确实讨论了一些性能问题,比如在 iPhone 4S 上渲染大量粒子会降低性能,几乎会导致设备完全崩溃。这就是我们将在下一章讨论的内容——如何管理性能并尽量减少电池消耗!

摘要

让我们看看本章我们讨论了什么。嗯,我们没讨论什么?!我们讨论了所有关于调试的事情。我们一开始就谈到了在 iOS 模拟器上安装和测试我们的应用。鉴于它的局限性,我们接着在物理设备上进行测试,因为嘿,当你试图测试一个像我们创建的平台游戏时,同时点击两个区域是非常困难的。

我们接着讨论了设置 TestFlight,以便我们可以让测试者帮助我们找到隐藏在我们代码深处的贪婪的虫子。再次强调,一个七岁的孩子猛击屏幕肯定比一个 20 岁以上的尊重设备的开发者更快地找到虫子。

我们接着讨论了调试的乐趣以及调试的所有优秀技巧和窍门。我们还涵盖了需要注意的事项,以及各种方式来逐行测试我们的代码,以及减慢动画速度,以便我们可以看到事情发生的过程。

这章的内容需要阅读很多,我们将在下一章中进一步探讨。

别担心;我们离最好的部分只有两章的距离:盈利。此外,我们还将回到创建游戏。我没有忘记这一点!

我将在下一章见到你!

第六章:使我们的游戏更高效

我们已经讨论了很多调试技术,而且有很多。然而,并非所有这些技术都能提高效率。我们所说的效率是什么意思?你的应用可以运行得很好,但这并不意味着它会让设备运行得很好。过热、电池耗尽过快以及其他类似的事情可能会破坏游戏体验。

考虑到这一点,让我们了解一下本章将涵盖的内容:

  • 优化我们的游戏

  • 防止电池耗尽

  • 防止卡顿(或至少最小化它)

这是你游戏开发中的一个关键步骤,因为你不希望人们因为太卡顿(或者因为设备在渲染当前场景时出现问题而导致应用卡顿)或者耗尽电池而抱怨并删除你的应用。

我知道你可能从未遇到过这样的问题,但这种情况比不常见的情况要多。

如果你下载了一个非常卡顿的游戏,在玩了一个小时后电池耗尽,会发生什么?毫无疑问,你会从手机上删除它或要求退款。

那么,让我们深入了解如何优化这些内容!

管理效果

到目前为止,我们一直在随意编写代码,没有考虑我们的效果在旧设备上的运行情况。

不要害怕!因为我们可以检测玩家正在使用的设备,并调整屏幕上同时出现的粒子效果和敌人数量。

首先,在我们的 GameLevelScene.m 文件中,我们将在顶部添加以下代码行:

#import <sys/utsname.h>

这个框架将允许我们访问检测我们正在工作的确切设备。

@implementation 行下面,添加以下函数:

NSString* deviceName()
{
    struct utsname systemInfo;
    uname(&systemInfo);

    return [NSString stringWithCString:systemInfo.machine
                              encoding:NSUTF8StringEncoding];
}

现在,如果你想查看哪个设备出现,你可以在初始化部分将 deviceName() 函数添加到 NSLog 函数中,它看起来像这样:

NSLog(deviceName());

因此,你的日志现在将显示你正在运行的设备,如下图所示:

管理效果

不确定 x86_64 是什么意思?在这种情况下,因为我正在运行模拟器,x86_64 将会在 64 位 iMac 上显示,或者如果你正在运行 32 位机器(对于较旧的 Mac),你将看到 i386

下面是每个设备将显示的内容概述:

  • i386 在 32 位机器上

  • x86_64 在 64 位机器上

  • iPod1,1 在第一代 iPod Touch 上

  • iPod2,1 在第二代 iPod Touch 上

  • iPod3,1 在第三代 iPod Touch 上

  • iPod4,1 在第四代 iPod Touch 上

  • iPhone1,1 在 iPhone 上

  • iPhone1,2 在 iPhone 3G 上

  • iPhone2,1 在 iPhone 3GS 上

  • iPad1,1 在 iPad 上

  • iPad2,1 在 iPad 2

  • iPad3,1 在第三代 iPad 上

  • iPhone3,1 在 iPhone 4(GSM)

  • iPhone3,3 在 iPhone 4(CDMA/Verizon/Sprint)

  • iPhone4,1 在 iPhone 4s

  • iPhone5,1 在 iPhone 5(型号 A1428,AT&T/Canada)

  • iPhone5,2 在 iPhone 5(型号 A1429,其他所有设备)

  • iPad3,4 在第四代 iPad 上

  • iPad2,5 在 iPad Mini 上

  • iPhone5,3 在 iPhone 5c(型号 A1456, A1532 | GSM)

  • iPhone5,4 在 iPhone 5c(型号 A1507、A1516、A1526(中国)、A1529 | 全球)

  • iPhone6,1 在 iPhone 5s(型号 A1433、A1533 | GSM)

  • iPhone6,2 在 iPhone 5s(型号 A1457、A1518、A1528(中国)、A1530 | 全球)

  • iPad4,1 在第 5 代 iPad(iPad Air)- Wifi

  • iPad4,2 在第 5 代 iPad(iPad Air)- Cellular

  • iPad4,4 在第 2 代 iPad Mini - Wifi

  • iPad4,5 在第 2 代 iPad Mini - Cellular

  • iPhone7,1 在 iPhone 6 Plus 上

  • iPhone7,2 在 iPhone 6

非常全面,不是吗?然而,当涉及到为所有设备开发时,它会很有帮助。

现在,我们可以开始设置根据我们运行的设备来改变我们的粒子发射的功能。例如,正如我们在之前的屏幕截图中所看到的,我们正在运行 x86_64,换句话说,就是一台 Mac 电脑。所以,为了测试这个功能(在通过模拟器运行我们的游戏时),在我们的 GameLevelScene.m 文件中,向下滚动到 - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 函数,并在我们创建雨发射器的 [self addChild:rainEmitter]; 函数之前添加以下代码块:

 if ([deviceName()  isEqual: @"x86_64"]) 
{
  rainEmitter.particleBirthRate = 150;
}

你现在可以调整出生率数字,看看什么看起来好,表现良好。如果你调整出生率并测试你的项目,你会看到粒子相应地调整,如下面的屏幕截图所示:

管理效果

例如,在先前的图像中,出生率设置为 15,000。虽然它看起来像是一场倾盆大雨,但帧率会大大下降。请注意,当我截图时,帧率会大幅下降,但你应该明白它平均大约是 25fps,因为设备需要渲染的量非常大。

现在,如果我们将其降低到 1500,我们会看到相当不同的结果:

管理效果

它看起来不像之前那么猛烈,但我们看到帧率有显著提高。

不想增加雨量?让我们尝试以相同的方式调整屏幕上的火焰效果,就像我们通过添加以下代码减少雨量一样:

if ([deviceName()  isEqual: @"x86_64"]) 
{
  fireEmitter.particleBirthRate = 100;
}

这将原始的出生率从设置为 200 降低到 100。虽然 100 的差异可能看起来像是一个很大的数字,而且我们的粒子看起来不会那么好;但这足以减少延迟,并且不会影响火焰本身的视觉吸引力。

很好!截图时稳定的 60 fps!看起来也很不错!

管理效果

现在,请记住,这是为我们的 Mac 上的 iOS 模拟器设置的,所以你甚至可以期待应用程序的运行会有一些不同,但它确实能给你一个很好的想法,了解它将如何表现。

希望我们已经决定在游戏中支持哪些设备,我们现在将添加测试支持设备的函数。对于这个例子,我将只支持 iPhone 4S 及以上,包括 iPad 第 4 代及以上。

测试这些设备要添加的代码如下:

if ([deviceName()  isEqual: @"x86_64"]) {//ios sim
                rainEmitter.particleBirthRate = 1500;
            }
            else if ([deviceName()  isEqual: @"iPhone4,1"]) {//4s
                rainEmitter.particleBirthRate = 1000;
            }
            else if ([deviceName()  isEqual: @"iPad3,4"]) {//4th gen ipad
                rainEmitter.particleBirthRate = 1000;
            }
            else if ([deviceName()  isEqual: @"iPad2,5"]) {//ipad mini
                rainEmitter.particleBirthRate = 1000;
            }
            else if ([deviceName()  isEqual: @"iPad4,1"]) {//ipad air
                rainEmitter.particleBirthRate = 1200;
            }
            else if ([deviceName()  isEqual: @"iPad4,2"]) {//ipad air cellular
                rainEmitter.particleBirthRate = 1200;
            }
            else if ([deviceName()  isEqual: @"iPad4,4"]) {//ipad mini w. retina
                rainEmitter.particleBirthRate = 1300;
            }
            else if ([deviceName()  isEqual: @"iPad4,5"]) {//ipad mini w. retina cellular
                rainEmitter.particleBirthRate = 1300;
            }
            else if ([deviceName()  isEqual: @"iPhone5,1"]) {//iphone 5 at&t/canada
                rainEmitter.particleBirthRate = 1200;
            }
            else if ([deviceName()  isEqual: @"iPhone5,2"]) {//iphone 5 world-wide
                rainEmitter.particleBirthRate = 1200;
            }
            else if ([deviceName()  isEqual: @"iPhone5,3"]) {//iphone 5c gsm
                rainEmitter.particleBirthRate = 1300;
            }
            else if ([deviceName()  isEqual: @"iPhone5,4"]) {//iphone 5c china/global
                rainEmitter.particleBirthRate = 1300;
            }

            else if ([deviceName()  isEqual: @"iPhone6,1"]) {//iphone 5s gsm
                rainEmitter.particleBirthRate = 1500;
            }
            else if ([deviceName()  isEqual: @"iPhone6,2"]) {//iphone 5s china/global
                rainEmitter.particleBirthRate = 1500;
            }
            else if ([deviceName()  isEqual: @"iPhone7,1"]) {//iphone 6 plus
               rainEmitter.particleBirthRate = 2500;
           }
            else if ([deviceName()  isEqual: @"iPhone7,1"]) {//iphone 6
               rainEmitter.particleBirthRate = 2500;
           }

我知道写和测试这些内容确实很多。这些数字都是估算的,因为我并不拥有所有这些设备。然而,我确实在每个模拟器上测试了它们,并且它们似乎都能以稳定的帧率运行。

我们从一个 if 语句开始,检查我们正在运行哪个设备。如果我们没有检测到检查的设备,我们使用 else if 语句。这将导致代码检查下一行以查找设备。当它找到正在运行的设备时,它将相应地调整出生率。

当涉及到火焰效果或你在游戏中运行的任何其他效果时,你也可以这样做。

你也可以通过更改你的更新计时器函数来简单地限制生成的敌人数量,如下面的代码所示:

- (void)updateWithTimeSinceLastUpdate:(CFTimeInterval)timeSinceLast {

    self.lastSpawnTimeInterval += timeSinceLast;
    if (self.lastSpawnTimeInterval > 2) {
        self.lastSpawnTimeInterval = 0;
        [self addSquiggy];

    }
}

简单地调整 if (self.lastSpawnTimeInterval > 2) 语句到一个更高的数字将减少一次生成的敌人数量,因为我们正在增加生成新敌人的时间。所以,间隔越高,生成敌人所需的时间就越长。

通过减少这些简单的事情,我们将极大地提高设备的性能,特别是那些变得疲惫且无法像以前那样运行的旧设备。

记住,更好的性能(设备运行不那么费力)等于更长的电池寿命、更长的播放时间和更满意的客户。

我们有其他很多方法可以优化事物;例如,正如我们在本书前面提到的,我们可以查看我们创建的精灵,如果它们是大型文件,我们可以尝试保存其他格式以节省空间,并使设备更容易渲染它们。

你来这里是为了掌握 iOS 开发,你想要学习一切!所以,让我来解释一切。

我们首先讨论的是如何防止,或者至少如何限制我们的应用在后台运行的数量。

电池管理 – 在后台做更少的事情

当用户没有积极使用你的游戏/应用时,设备会将其置于后台状态。如果应用没有执行重要任务,例如完成用户发起的任务(例如发送照片或更新新闻源)或在特别声明的后台执行模式下运行,设备最终可能会挂起应用。

为了进一步节省电池寿命,你的应用不应该等待被设备挂起。一旦应用通知状态已更改,它应该立即开始暂停活动(参考以下图像)。

当你的应用完成任何排队的任务时,它应该通知设备后台活动已完成。未能这样做会导致应用保持活跃并浪费不必要的能量,这可以从以下图像中完美总结出来:。

电池管理 – 在后台做更少的事情

为什么电池的续航时间不长?这困扰着我们所有人...好吧,是的,随着每一款新 iPhone 的发布,它们正在改善电池使用效率,但它们在操作系统中添加了更多消耗电池的功能,所以你实际上并没有真正得到任何好处。

《iOS 应用能效指南》中的“后台应用能耗的常见原因”部分指出:

执行不必要的后台活动(如音乐、Facebook 或新闻应用在后台持续运行)会消耗能量。以下是在后台刷新应用中浪费能量的常见原因:

  • 在后台活动完成后不通知系统

  • 播放静音音频

  • 执行位置更新

  • 与蓝牙配件交互

这些都是无论你是在创建游戏还是应用时,都应该在开发时记住的事情:

  • 在后台活动完成后通知系统:对于应用,如果你正在从网站获取信息,你需要暂停它直到应用恢复,或者设置一个刷新间隔。

  • 播放静音音频:对于我们的游戏,当应用进入后台时,声音和音乐需要暂停。

  • 执行位置更新:你是否注意到了状态栏上那个讨厌的箭头图标?那等于电池消耗。当应用进入后台时,关闭位置服务。

  • 与蓝牙配件交互:这适用于应用和游戏,特别是对于像蓝牙扬声器甚至蓝牙键盘或游戏手柄这样密集型的设备。确保当应用进入后台时,蓝牙断开连接并进入休眠模式。别忘了当应用重新进入前台时恢复!

首先,你可以在需要时扫描外围设备,例如蓝牙控制器或其他设备。为此,请使用以下代码块:

-(void)scanForDevice {

myCentralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil options:nil]; //this creates the Bluetooth Manager

[myCentralManager scanForPeripheralsWithServices:nil options:nil];
//this will scan for a broadcasting device
}

- (void)centralManager:(CBCentralManager *)central
     didDiscoverPeripheral:(CBPeripheral *)peripheral
     advertisementData:(NSDictionary *)advertisementData
     RSSI:(NSNumber *)RSSI {       
// Connect to the newly discovered device

      // Stop scanning for devices
      [myCentralManager stopScan];
    }

这很简单,对吧?我知道,这需要吸收很多东西。但请相信我,这些都是非常重要的事情。

然后,当你完成蓝牙设备的操作后,只需使用以下两个方法来断开连接:

//This will Unsubscribe you from a characteristic value
[peripheral setNotifyValue:NO forCharacteristic:interestingCharacteristic];

// Disconnect from the device
[myCentralManager cancelPeripheralConnection:peripheral];

当你的应用变得不活跃或移动到后台时,你如何挂起活动?通过在你的应用代理中实现UIApplicationDelegate方法(如果尚未实现,因为我们的应用已经在AppDelegate.m文件中实现了这个方法),可以让设备在应用变得不活跃或从前台过渡到后台时接收通知并挂起活动。

当应用进入非活动状态时,会调用applicationWillResignActive方法,例如当电话推销员打电话来清理你的通风管道,收到短信,或者玩家切换到另一个应用,你的应用开始过渡到后台状态。

这是你想要暂停任何活动、保存数据并为任何挂起做好准备的地方:

- (void)applicationWillResignActive:(UIApplication *)application {

}

现在有一个重要的事情需要记住,那就是当应用进入后台时,你不想仅仅依赖于保存数据。你总是希望在游戏过程中在适当的时机保存数据,比如在关卡结束时。如果你严格依赖于在状态变化时保存数据,比如关卡变化,或者玩家暂停或退出游戏,游戏可能无法可靠地保存,玩家可能会丢失一些信息。例如,假设你正在玩你最喜欢的游戏,但游戏只有在完成 3 个关卡后才会保存,你可能会丢失重要的高分或收集到的物品。此外,玩家也不希望不得不重玩那些已经完成的关卡。

另一个状态,applicationDidEnterBackground:在应用进入后台时立即被调用。使用这个方法,你可以立即停止操作、动画和更新方法,以下代码所示:

- (void)applicationDidEnterBackground:(UIApplication *)application {

}

这个方法只调用几秒钟,所以如果应用需要更多时间来完成调用的请求,你将不得不使用beginBackgroundTaskWithExpirationHandler:方法请求更多的执行时间,这个方法在需要额外时间时被调用。

当后台任务完成后,你需要调用endBackgroundTask:方法来让设备知道任务已完成。如果你不调用该方法,iOS 会允许应用有更多的时间来完成任何额外的功能。如果在 iOS 提供的额外时间内,任何保存或卸载方法没有完成,应用将被挂起。

进一步来说,如果你的应用在进入后台时仍然在处理任何方法,并且应用被挂起,所有重要的性能调整、保存游戏可能都不会发生,这可能会破坏用户的体验。

现在我们已经讨论了当应用进入后台时应该做什么,现在是时候讨论当应用恢复时应该做什么(或如何做)了。

我们有两种方法可以让我们调用可能已经断开连接的任何重新连接方法,例如蓝牙设备、位置服务等等:

- (void)applicationWillEnterForeground:(UIApplication *)application {
    // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background.
}

这个方法在应用从后台过渡到活动状态之前立即被调用。开始恢复操作、加载数据、重新初始化用户界面,并让应用为用户做好准备:

- (void)applicationDidBecomeActive:(UIApplication *)application {
    // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}

ApplicationDidBecomeActive函数在应用被设备启动或从后台或非活动状态过渡到活动状态后立即被调用。本质上,我们正在全面恢复任何被中断的操作。

这些功能是在开发计时器、蓝牙连接等时需要记住的几个重要事项。

另一个需要注意的事情是避免使用极端的图形和动画。正如你在我们的游戏中看到的,我们没有使用任何密集的动画。相反,我们的大多数角色每个动画有两到四张图像。

如果你使用的是仅包含标准窗口和控制的程序,比如笔记应用,你实际上不需要担心内容更新,因为系统 API 是以最大效率为前提创建的。

如果你使用的是自定义窗口和控制,比如Shazam,你真的必须确保你绘制所有内容的代码是高效的。

例如,你不想总是刷新屏幕上的所有内容,特别是隐藏的项目,或者过度使用动画。我知道它们看起来很好,但它们确实会浪费电池电量。

请记住,每次应用将图像和文本绘制到屏幕上时,都需要 CPU 使用、GPU 使用,并且屏幕需要保持活跃。因此,你显示和刷新的内容越多,电池耗尽得越快,这就是为什么某些应用会耗尽电池寿命的原因。

因此,你应该注意过度更新或甚至低效的内容绘制,因为这会从电池中消耗大量电量。

这里还有一些需要注意的事情。你应该始终减少应用中视图的数量(所有这些都会从电池中消耗电量)。

如果可能的话,你应该减少屏幕上使用的特效数量,例如不透明度、透明度等。如果需要使用特效,应避免在频繁刷新的项目上使用,因为这会再次消耗电池电量,因为不透明度对象和下面的内容都需要更新。

关于动画,每个动画最多使用 60fps,任何更快的速度在渲染时都会带来麻烦,因为它需要更多的 CPU/GPU 来提升帧率。此外,尽量保持所有动画速度在相同的帧率,这样引擎就不会在 60fps 渲染角色和在 30fps 渲染敌人时感到吃力,例如。虽然动画中的额外帧看起来很棒,但这些额外的帧效率低下,需要额外的计算,这会导致更多的电池耗电。

在开发游戏时,甚至建议只使用 SpriteKit、SceneKit 和 Metal,因为这些框架是为了提供最佳性能和效率而设计的。

这并不意味着你不能使用框架,例如 Cocos2d。实际上,Cocos2D 是建立在 SpriteKit 框架之上的,并且同样易于使用且功能强大。

在测试你的应用时,请密切关注我们在上一章中讨论的调试工具。

注意这些迹象:

  • 电池耗尽

  • 当你期望应用处于空闲状态时的活动

  • 响应慢或用户界面不灵敏

  • 主线程上的大量工作

  • 动画使用量高

  • 视图不透明度使用量高

  • 交换

  • 内存停滞和缓存未命中

  • 内存警告

  • 锁竞争

  • 过度上下文切换

  • 定时器使用过度

  • 过度绘制到屏幕

  • 过度或重复的小磁盘 I/O

  • 高开销的通信,例如带有小数据包和缓冲区的网络活动

  • 防止设备休眠

在上一章中,我们没有查看使用 Xcode 中的 Instruments 测量能量使用情况。

Instruments 为我们提供了设备使用的图形时间线。您将能够收集有关应用 CPU 使用、网络使用、磁盘使用和图形使用的相关信息。

通过查看这个时间线,您将能够分析您应用的性能,并找出可能导致延迟、减速或帧率下降的某些应用中的点,这样您就可以在这些确切的时刻进行调整。

要访问这些能量诊断,启动 Instruments,将其连接到您的应用,然后点击能量诊断模板。

能量诊断模板监控影响 iOS 设备能量使用的因素,如前所述,包括 CPU 和网络活动、屏幕亮度等等。然后,您可以看到使用率最高的区域,并检查您是否可以减少这些区域的影响。例如,您可能会发现将某些蓝牙任务推迟到更节能的时间的机会,比如设备插上电源或连接到 Wi-Fi 时。

这都是可选的;当您开始使用调试 Instruments 时,您将看到哪些方法适合您,以及何时使用这些方法是最合适的。正如您可以从以下图像中看到的那样,您有如此多的优秀工具可供使用,以使您的应用尽可能高效:

电池管理 – 在后台做更少的事情

在我们的精彩游戏运行时,点击选择配置文件模板用于,然后选择您正在运行的设备(这是测试电池使用的唯一方法),然后选择您的应用。

双击能量诊断配置文件模板。然后,在新弹出的窗口顶部,点击红色记录按钮开始记录我们的能量使用。像平时一样玩游戏。不用担心,Instruments会在您玩游戏时记录所有数据,这样您就可以在记录能量使用情况的同时寻找 bug。

太棒了!

当您完成测试后,只需单击停止按钮或按Command + R键即可停止记录。

现在,您将有一个记录所有已记录数据的完整日志。滚动查看日志中是否有任何峰值。如果有,请返回并检查那些出现峰值的代码区域,以检查是否存在导致峰值的任何问题。

作为一个旁注,能量使用模板显示了从 0 到 20 的读数,这将表明您的应用在那时正在使用多少能量:

电池管理 – 在后台做更少的事情

假设您有一些很棒的粒子在游戏中四处飞舞,并且您在游戏过程中连接了一个蓝牙设备,您无疑会看到您的能量使用始终很高。这并不意味着您的应用有问题。这只意味着您的应用需要更多的电量来运行您所实现的所有功能。

我将以我经常使用的一个应用为例。这个应用来自一个充满有趣照片的网站。无论如何,当我使用这个应用时,我的手机几分钟内就会变热,电池电量开始急剧下降。这难道意味着应用有问题吗?不!尤其是当你开始使用它时了解应用在做什么。

应用不仅需要从服务器加载照片,还使用你的 Wi-Fi 和位置服务,并显示广告——所有这些同时保持你登录到他们的网站,这样你就可以对照片进行评论。

现在,这并不等同于理想的用户体验,但如果你想访问照片,你需要预期这种性能。所以如果你的设备变热或电池消耗得有点多,不要担心——这是设备使用过程中的正常副作用。

没有访问你的电脑并需要记录能量消耗?直接在你的设备上记录使用情况!

如果你已经将你的设备连接到 Xcode,你现在将能够访问设备上的开发者选项,如下面的截图所示:

电池管理 – 在后台做更少的事情

从这个选项中,你可以在 Instruments 部分下选择 Logging,然后点击 Energy 来启用记录,然后点击 Start Recording 按钮开始记录你的能量使用情况,如下面的截图所示:

电池管理 – 在后台做更少的事情

再次强调,使用你的应用的方式应该和平时一样,Instruments 会为你记录数据,在你操作时进行记录。

最后,回到这个设置。你会看到 Start Recording 按钮现在已更改为 Stop Recording。只需点击 Stop Recording

现在,回到 Xcode Instruments 中,在 Energy Diagnostics 选项下,导航到 File | Import Logged Data from Device

现在,你将看到与你在 Xcode 中直接运行测试时相同的数据日志。

呼!这有很多需要考虑的!

我知道我一直在说很多,这些都是极其重要的信息。

在我测试应用的过程中,一切运行得都非常完美。事实上,唯一似乎拖慢设备速度的问题就是那些粒子!我们通过检查我们正在运行的设备并相应地调整我们的疯狂粒子来程序化地解决了这个问题。

摘要

我们在本章中讨论了很多内容,其中很多是关于如何管理设备性能和电池耗电的最佳实践。

你现在已经做好了应对测试过程并确保你的设备尽可能高效运行的准备,这样就不会有客户因为你的应用对他们的设备造成了极大的影响而退回应用或给出低评分。

男孩们和女孩们,为下一章感到兴奋吧!我们将讨论部署您的应用程序并从中盈利的奇妙之处,这样您就可以赚得数不尽的美元!好吧,可能不会那么多,但至少足够支付您的开发成本就足够了。

准备好,因为现在是最有趣的时候,您将看到所有辛勤工作的成果汇聚在一起。

下一章见,我的朋友们!

第七章。部署和赚钱

哇!你到目前为止已经做了很多工作了!而且我们还没有完成!现在是时候让你的辛勤工作得到回报了。在本章中,我们将讨论在开发最后阶段需要注意的以下事项:

  • 准备应用部署

  • 赚钱(赚更多的钱!)

你所有的辛勤工作都归结于此,现在是时候通过赚取你应得的辛苦钱来大放异彩了!当然,赚钱的方式远不止简单地为你的应用收费,你知道的,让我们直接深入探讨吧?

让我们开始赚钱...

准备部署

终于到了!终于到了;你所有的努力都归结于这次部署。

对我来说,这始终是开发中最激动人心的时刻,尤其是当你收到苹果公司的电子邮件,说你的应用正在审查时。

然后,当它发布时,只看到你的应用在 AppStore 上就非常激动人心!我永远不会忘记我发布的第一个应用,当我看到我的第一个应用并四处向我的整个家庭展示的那一刻。我终于做到了。现在轮到你了。

首先,我们将在 iTunes Connect 中创建我们的应用。只需访问itunesconnect.apple.com并使用与你的开发者账户关联的 Apple ID 登录,如下面的截图所示:

准备部署

登录后,点击我的应用图标:

准备部署

从这里,你现在将能够看到你创建的所有应用(如果有):

准备部署

iTunes Connect标志下方,只需点击+按钮开始创建一个新应用。你现在将看到一个下拉菜单出现,询问你想创建什么类型的应用。我们将创建一个新的 iOS 应用。

接下来,你会看到一个新弹出的窗口,要求你提供一些关于应用的基本信息,例如应用名称、应用语言、包标识符(我将在下一秒讨论它)、版本号以及应用的 SKU(对于 SKU,我实际上只是使用我填写它的时间,但你可以使用你喜欢的任何东西):

准备部署

没有包标识符?只需点击在开发者门户上注册新的包标识符按钮,位于包标识符选择框下方。

从那里,只需填写所有信息,例如应用 ID 描述、前缀、后缀以及你想要包含在应用中的服务。

一旦填写完毕,返回应用创建屏幕并选择新的包标识符(如果它还没有出现,只需取消创建并重试)。

你的应用现在已经创建好了!嗯,有点儿...

下一页需要你填写有关应用的完整信息——也就是说,描述、关键词、截图、版权信息等。

现在,是时候将你的应用提交到 AppStore 进行审查了!

有两种方法可以做到这一点。一种是通过 Xcode 直接进行,第二种是通过应用程序加载器(以下图像中都可以看到这两个应用程序图标);我们将讨论如何进行。

准备部署

无论你选择哪种方法上传你的应用程序,两种方法都需要你进行存档。在你存档应用程序之前,我强烈建议再次进行测试,以确保一切按预期工作。

要在 Xcode 中创建存档,请确保在方案选择下选择了 iOS 设备。为了回顾,方案是在模拟器或你想要运行项目的 iOS 设备上的预设。以下截图显示了 iOS 设备 选项:

准备部署

一旦选择了,只需在工具栏上点击 产品,然后选择 存档。通过存档,你实际上是将整个项目放入一个单一的、压缩的文件中,以便你可以将其上传到应用商店。

准备部署

现在,你只需要等待...根据你的应用程序大小,你可能需要等待一段时间。

一旦完成,你将遇到存档窗口,该窗口将显示你为你的出色应用创建的所有存档列表:

准备部署

现在,通过 iTunes 验证过程验证您的应用程序是个好主意。

你怎么做?很简单!点击在 存档 窗口中看到的 验证 按钮:

准备部署

如果一切顺利,你将看到一个窗口显示已验证。然而,如果你像我一样,你将遇到错误,如下面的截图所示:

准备部署

果然,我犯了一个错误,没有使用正确的配置文件。

一切都完成后,我们可以上传它!

简单地点击大型的 提交到 App Store 按钮!

在出现的对话框中,从弹出菜单中选择一个团队,然后点击 选择

如果需要,Xcode 将为你创建一个分发证书和分发配置文件。分发配置文件的名称以文本 XC 开头。

在出现的对话框中,检查应用程序、其权限和配置文件,然后点击 提交

Xcode 将随后将存档上传到 iTunes Connect。如果出现对话框表示找不到应用程序记录,请点击 完成,在 iTunes Connect 中创建应用程序记录,并重复这些步骤。

如果发现问题,点击 完成 并在继续之前修复它们。

如果没有发现问题,点击 提交 以上传你的应用程序。

现在,你已经将你的应用程序上传到 App Store!等待一封电子邮件,说明你的应用程序正在审查!

这是上传您的应用程序最简单的方法,但您也可以通过ApplicationLoader选项上传您的应用程序。只需打开ApplicationLoader标签页;您将看到模板选择器,它将允许您上传应用程序或上传内购内容,如下面的截图所示:

准备部署

在我们点击交付您的应用程序之前,我们需要在 Xcode 中将项目导出为存档。

在 Xcode 中,点击组织者中的窗口,我们将回到我们应用程序的存档。现在,我们不会点击提交到 AppStore,而是点击导出

接下来,它将询问您如何保存应用程序——为 iOS App Store 部署保存为 Ad Hoc 部署保存为企业部署保存

选择为 iOS AppStore 部署保存,然后点击下一步,如下面的截图所示:

准备部署

再次,它将询问您希望使用哪个开发团队;选择您正在使用的团队。

它将再次运行验证测试,然后询问您希望将存档保存到何处。

简单选择一个容易记住的位置,然后返回到Application Loader并点击交付您的应用程序

选择您刚刚创建的应用程序。应用程序加载器将搜索应用程序,并显示一个包含所有应用程序详细信息的窗口,例如名称、版本号、SKU 号、主要语言、版权、类型和 Apple ID。

点击下一步Application Loader开始上传您的应用程序二进制文件到 App Store。

一旦完成,您只需等待即可!

您可以随时登录到 iTunes Connect,以了解您应用程序的状态。

现在,是时候赚点钱啦!

赚钱的技巧

仅通过向您的应用程序收取 0.99 美元(或您所在国家的类似转换)来赚钱并不是唯一的方法。实际上,根据我的经验,这是从您的应用程序中赚钱最糟糕的方法之一。

事实上,我通过内购广告赚到的钱比通过应用程序购买的资金要多。

您能做的最好的事情就是将您的应用程序免费提供,然后加入某种广告渠道,如iAdsAdmobChartboost或任何其他 iOS 兼容的广告 API。

让我们来看看一些广告 API 以及您如何注册它们并将它们集成到您的游戏中。

iAds

由于您已经注册为 iOS 开发者,您不需要进行任何注册流程。然而,您需要(如果您还没有的话)设置适当的 iAds 合同。

要做到这一点,请登录到itunesconnect.apple.com并点击协议、税务和银行图标:

iAds

在下一屏幕,如果你还没有设置,你会看到请求合同。只需请求iAd 应用网络合同。我建议你请求所有协议,但在这个章节的这一部分,我们只需要 iAds。

一旦你点击请求,你将被要求提供法人实体信息——换句话说,就是有权同意合同的个人的信息。完成后,只需同意并返回合同屏幕。

当你回到合同屏幕时,你将需要填写你的银行信息、税务信息等,如下截图所示:

iAds

你兴奋了吗?我知道我很兴奋!以下是一些关于 iAds 的快速信息:

  • 横幅视图仅使用屏幕的一部分来显示横幅广告:我们都见过,那些在我们玩游戏或使用应用时出现在底部的微小广告。

  • 全屏广告为 iPad 应用提供更大的广告:我们甚至可以显示全屏广告!当玩家击败一个关卡时,这可能会很有用。你可以显示全屏广告,这样就不会打扰玩家的游戏体验。

  • 您可以在用户与广告互动时暂停非必要活动:一旦用户点击广告,它将启动一个全屏的交互式体验。这会遮挡下面发生的一切,但幸运的是,我们可以暂停游戏中的一切,这样我们的玩家的进度就不会因为广告显示而受阻,我们的玩家也不会遇到敌人。那不会很有趣...

  • 取消广告会对你的应用产生负面影响:如果你的应用需要用户的注意力,你可以通过编程取消交互式广告体验,并强制用户回到你的应用。然而,苹果建议你只有在绝对必要时才这样做,因为这可能会影响你的填充率。

现在我们可以开始集成!

这真的很简单,所以让我们先添加 iAds 框架到我们的项目中。你还记得如何添加框架吗?以下截图显示了如何进行此操作:

iAds

如果你不记得,不要担心!在你的 Xcode 中,在侧边栏中选择你的项目。然后,在中心窗口中,选择你的目标(见上一张截图),在“常规”部分,向下滚动到窗口底部,你会看到链接的框架和库

iAds

简单地点击+按钮来添加一个新的框架。在搜索栏中,输入截图显示的“iAd”。

iAds

然后,点击确定按钮!

就这么简单!iAd 框架已正式集成到我们的项目中!现在,我们唯一要做的就是插入显示广告的代码。

我们将进入ViewController.h文件,并调整代码,使其看起来如下所示:

#import <UIKit/UIKit.h>
#import <SpriteKit/SpriteKit.h>
#import <iAd/iAd.h>

@interface ViewController : UIViewController <ADBannerViewDelegate> {
    ADBannerView *adView;
}

@end

为了解释,我们将 iAd 框架导入到ViewController类中,然后声明了ViewController类和AdBannerViewDelegate来控制 iAds。

然后,我们声明了ADBannerView变量为adView

让我们进入我们的ViewController.m文件,并更改以下方法以显示我们的广告。加粗的文本是新增的内容:

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:@"hideAd" object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:@"showAd" object:nil];
    // Configure the view.
    SKView * skView = (SKView *)self.view;
    skView.showsFPS = NO;
    skView.showsNodeCount = NO;

    // Create and configure the scene.
    SKScene * scene = [GameLevelScene sceneWithSize:skView.bounds.size];
    scene.scaleMode = SKSceneScaleModeAspectFill;

    // Present the scene.
    [skView presentScene:scene];
    self.canDisplayBannerAds = YES;

    adView = [[ADBannerView alloc] initWithFrame:CGRectZero];
    adView.frame = CGRectOffset(adView.frame, 0, 0.0f);
    adView.delegate=self;
    [self.view addSubview:adView];

}

- (void)handleNotification:(NSNotification *)notification
{
    if ([notification.name isEqualToString:@"hideAd"]) {
        adView.hidden = YES;
    }else if ([notification.name isEqualToString:@"showAd"]) {
        adView.hidden = NO;
    }
}

现在,我们将转到我们的GameLevelScene.m文件。让我们滚动到我们的initWithSize方法并添加以下代码(再次强调加粗的文本是新增的):

-(id)initWithSize:(CGSize)size {
    if (self = [super initWithSize:size]) {
        /* Setup your scene here */
        NSLog(deviceName());

        self.userInteractionEnabled = YES;
        self.backgroundColor = [SKColor colorWithRed:.0 green:.0 blue:.0 alpha:1.0];
        self.physicsWorld.gravity = CGVectorMake(0, 0);
        self.physicsWorld.contactDelegate = self;

        if (_level == 0){
        [[NSNotificationCenter defaultCenter] postNotificationName:@"showAd" object:nil];
        SKLabelNode *playLabel = [SKLabelNode labelNodeWithFontNamed:@"AvenirNext-Heavy"];
        playLabel.text = @"Adesa";
        playLabel.fontSize = 40;
        playLabel.position = CGPointMake(self.size.width / 2.0, self.size.height / 1.7);
        [self addChild:playLabel];

        SKSpriteNode *playButton = [SKSpriteNode spriteNodeWithImageNamed:@"play"];
        playButton.position = CGPointMake(self.size.width /2.0 , self.size.height / 2.5);
        playButton.name = @"playButton";
        [self addChild:playButton];
        }

    }
    return self;
}

然后,我们将进一步滚动到我们的touchesBegan:方法,并在if (node.name isEqualToString:@"playButton"])方法中添加以下代码:

[[NSNotificationCenter defaultCenter] postNotificationName:@"hideAd" object:nil];

感到困惑?没问题!

我们所做的是设置一个通知中心来接受某些,嗯,通知。这是在不同类文件之间调用方法的一种简单方式。我们只是在我们ViewController类中设置了通知中心,以接收显示和隐藏横幅的通知。

为什么我们这样做?

标签页视图只能在ViewController类中显示。在我们的游戏中,我们只有一个视图控制器,而我们的GameLevelScene不是一个ViewController类;它是一个显示在ViewController类上的 SpriteKit 场景。因此,如果你在我们的场景中设置并显示标签页视图,它将不会显示,因为它是一个 SpriteKit 场景,而不是ViewController类。

有道理吗?

一旦你完成了所有的编码,测试一下会发生什么!

iAds

然后,当你点击播放按钮时,你会看到以下内容:

iAds

全部没有了!

请记住,直到游戏发布之前,实际上不会有广告出现;在此之前,它将显示占位符广告。

就这样!你现在正走在赚取广告收入的道路上!

现在,让我们谈谈其他的广告渠道。

AdMob

下载框架后(你可以在谷歌上轻松找到),将其解压缩到一个容易再次找到的地方。

我们所需要做的就是右键点击左侧栏上的我们的项目,然后选择将文件添加到(你的项目名称)

定位你刚刚下载的GoogleMobileAds.framework,并将其添加。

AdMob SDK 依赖于以下 iOS 开发框架,这些框架可能不是你的项目的一部分:

  • AdSupport

  • AudioToolbox

  • AVFoundation

  • CoreGraphics

  • CoreMedia

  • CoreTelephony

  • EventKit

  • EventKitUI

  • MessageUI

  • StoreKit

  • SystemConfiguration

一旦你导入了这些框架并引用了 AdMob SDK,Xcode 将自动链接到所需的框架。

按照谷歌自己的文档,在你的ViewController类中添加以下代码来设置广告:

@import GoogleMobileAds;

#import "ViewController.h"

@implementation ViewController

- (void)viewDidLoad {
 [super viewDidLoad];

 NSLog(@"Google Mobile Ads SDK version: %@", [GADRequest sdkVersion]);
}

- (void)didReceiveMemoryWarning {
 [super didReceiveMemoryWarning];
}

@end

现在我们项目已经引用了 SDK,让我们将其中的横幅广告添加进去。

GADBannerViewGoogleAd 广告视图)可以从故事板或代码中创建。对于常规应用,事物在 Storyboard 编辑器中组合,但相同的方法也适用于我们的游戏等,只需记住将代码保留在您的 ViewController 类中:

AdMob

打开主故事板,在底部的右上角的 对象库 中搜索 UIView,并将一个 UIView 元素拖动到您的视图控制器中。然后,在右上角的 身份检查器 按钮中,我们将更改此视图的类为 GADBannerView,如前图所示:

GADBannerView 类需要在代码中引用以加载广告。通过导航到 视图 | 辅助编辑器 | 显示辅助编辑器 打开 辅助编辑器

AdMob

在辅助编辑器中,确保显示 ViewController.h 文件。接下来,按住 Control 键(或只需右键单击并拖动),点击 GADBannerView 元素,并将光标拖动到 ViewController.h 文件上。

这是将您的故事板中的出口链接到代码的最简单方法。这已经非常简化,并从编程中省去了一堆工作;以前,我们不得不手动输入出口,然后在故事板编辑器中手动链接它,这并不有趣。我们将通过以下操作添加以下代码,通过这样做,我们将导入 Google 广告框架:

@import GoogleMobileAds;

@interface ViewController : UIViewController

@property (weak, nonatomic) IBOutlet GADBannerView *bannerView;

然后添加以下代码,并对您 ViewController.m 文件中已有的方法进行必要的调整。

现在,要显示广告,只需在 ViewDidLoad 部分或您希望它出现的地方插入以下方法:

 NSLog(@"Google Mobile Ads SDK version: %@", [GADRequest sdkVersion]);
 self.bannerView.adUnitID = @"ca-app-pub-3940256099942544/2934735716";
 self.bannerView.rootViewController = self;
 [self.bannerView loadRequest:[GADRequest request]];

这(几乎)就这么简单!

需要做出一些更改。首先,您需要通过网站创建一个新的 AppID 变量。您只需将 ID 复制并粘贴到 self.bannerView.adUnitID = 后面即可。

实现 Chartboost!

导入框架将取决于您导入的框架。以下是 Chartboost 所需的框架:

  • StoreKit

  • CoreGraphics

  • UIKit

  • 基础

您只需添加这三个中的一个。其他三个应包含在 SpriteKit 项目中。对于 Chartboost,我们需要在 AppDelegate 类中开始初始化应用 ID 和应用签名,这两个都在 Chartboost 网站上设置。在 AppDelegate.m 文件的顶部,我们需要导入以下内容:

#import <Chartboost/Chartboost.h>
#import "AppDelegate.h"
#import <CommonCrypto/CommonDigest.h>
#import <AdSupport/AdSupport.h>

然后,在我们的 Application didFinishLaunchingWithOptions 方法中,我们需要添加以下代码块:

 [Chartboost startWithAppId:@"YOUR_CHARTBOOST_APP_ID"
                  appSignature:@"YOUR_CHARTBOOST_APP_SIGNATURE"
                      delegate:self];

这里有一个说明,Chartboost 必须以这种方式初始化,因为它们记录应用启动来跟踪您的分析。如果您不这样做,您将不会获得任何广告。然后您会哭泣。想知道接下来是什么吗?以下代码将展示广告:

[Chartboost showInterstitial:CBLocationHomeScreen];

真的是这么简单!当您想要显示广告时,只需添加那行代码,然后搞定!我现在就能闻到收入的气息了!

你知道,你们都是一群了不起的听众,我真心希望你们从这本书中学到了很多。

我真的觉得你将能够轻松地创建自己的完整游戏,并通过它们轻松地赚钱。

但你知道什么...我们还没有完成。不,不!我们实际上将要讨论如何更新你的应用,以及添加多人游戏功能!

系好安全带,这一章将会非常精彩!

独自一人去太危险了...下面的图片看起来熟悉吗?

实现 Chartboost!

摘要

在这一章中,你学习了如何在 App Store 上部署你的应用,以及如何通过整合广告渠道,如 iAds、Admob 和 Chartboost,在你的发布的应用中赚钱。现在,你可以开发并部署尽可能多的应用。享受开发、部署和通过你的应用赚钱吧。

第八章。独自一人去太危险了,带上一个朋友吧!

你做到了!你已经正式发布了你的游戏,而且它做得很好!但现在你想要添加更多功能并推送更新,对吧?因为重复同样的事情有点单调,我们不想失去人们的兴趣。这就是本章我们将要讨论的内容:

  • 多人游戏集成

  • 游戏中心集成(我们混合多人游戏和游戏中心)

  • 将更新推送到 AppStore

现在,我们可以让我们的玩家玩得更开心,因为,说实话,谁不喜欢和朋友一起玩呢?这将会是一大堆工作,但嘿,这绝对值得!

让我们这么做...

多人游戏集成

我相信我们所有人都记得和朋友们坐在老式 CRT 电视前,加载上超级马里奥 3(在插入之前吹一下卡带以确保它能工作)并一起玩上好几个小时。

然后,引入了分屏多人游戏,这让大家震惊。“我们可以同时一起玩吗?我们不需要轮流玩?太酷了!”

现在,我们处于蓝牙/在线多人游戏的时代。我们不再和朋友们坐在同一个房间里玩游戏;不,我们现在是不合群的。但这不是问题;当你玩完之后,这会留下更少的混乱要清理。这就是我们将要集成的——多人游戏与匹配——这将非常酷!

然而,为了做到这一点,我们确实需要启用并将游戏中心集成到我们的游戏中。为了开始做这件事,让我们在 Xcode 中打开我们的项目。在左侧选择我们的项目后,点击屏幕中央的能力标签,如下面的截图所示:

多人游戏集成

然后,浏览一下我们可以添加的功能列表,找到游戏中心(通常在这个列表中是第三个)。点击箭头展开选项。你会看到一个写着关闭的按钮,点击这个按钮来打开游戏中心

我们不能运行我们的游戏并期望游戏中心自动工作,不,不!为了做到这一点,我们实际上需要在我们的应用打开时让用户进行身份验证。

为了确保我们的应用中一切井井有条,我们将在项目中创建一个新的组(或文件夹),并将其命名为Multiplayer

然后,我们必须创建一个新的类(通过导航到文件 | 新建 | 文件)并创建一个新的.h文件以及一个新的.m文件,并将它们都命名为MultiplayerHelper。然后,将这两个文件拖入我们刚刚创建的Multiplayer文件夹中。

我们现在将MultiplayerHelper.h中的代码替换为以下代码:

@import GameKit;

@interface MultiplayerHelper : NSObject

@property (nonatomic, readonly) UIViewController *authenticationViewController;
@property (nonatomic, readonly) NSError *lastError;

+ (instancetype)sharedGameKitHelper;

@end

这段代码导入GameKit API(我们将使用它来连接两个玩家)并定义了两个属性——一个是视图控制器,我们将用它来显示游戏中心的认证,另一个用于跟踪在交互游戏中心时发生的最后一个错误(如果有)。

现在,让我们转到我们的.m文件,并将代码更改为以下内容:

#import "MultiplayerHelper.h"

@import GameKit;

@implementation MultiplayerHelper {
    BOOL _enableGameCenter;
}

+ (instancetype)sharedMultiplayerHelper
{
    static MultiplayerHelper *sharedMultiplayerHelper;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedMultiplayerHelper = [[MultiplayerHelper alloc] init];
    });
    return sharedMultiplayerHelper;
}

- (id)init
{
    self = [super init];
    if (self) {
        _enableGameCenter = YES;
    }
    return self;
}

- (void)authenticateLocalPlayer
{

    GKLocalPlayer *localPlayer = [GKLocalPlayer localPlayer];

    localPlayer.authenticateHandler  =
    ^(UIViewController *viewController, NSError *error) {

        [self setLastError:error];

        if(viewController != nil) {

            [self setAuthenticationViewController:viewController];
        } else if([GKLocalPlayer localPlayer].isAuthenticated) {

            _enableGameCenter = YES;
        } else {

            _enableGameCenter = NO;
        }
    };
}

- (void)setAuthenticationViewController:(UIViewController *)authenticationViewController
{
}

- (void)setLastError:(NSError *)error
{
}

@end

我知道一开始这看起来可能有点令人畏惧,但让我们把它分解一下。一次,我们得到GKLocalPlayer类的实例,它代表当前认证的玩家。然后我们给GKLocalPlayer一个认证处理程序,GameKit API 将调用它。

我们设置了一个方法来存储可能出现的任何错误,以便使用setLastError:方法进行易于调试。

接下来,我们检查玩家是否在 GC 应用或设备中的任何其他地方登录到游戏中心。如果没有,GameKit API 将尝试对用户进行认证。这就是我们显示认证窗口的地方(我们都知道游戏中心的登录弹出窗口,不是吗?)。尽快对用户进行认证是理想的。没有人想在游戏过程中或输入游戏设置时被认证窗口打断。

或者,如果玩家已经登录,Game Kit本地玩家的authenticated属性将被设置为true

最后,最后一个方法只是关闭游戏中心。谁知道呢,也许玩家不想被它打扰,所以所有功能都被关闭了。

由于游戏中心认证发生在应用的背景中,游戏可以在应用打开时随时进行认证,无论玩家是否在导航屏幕或与 Boss 战斗。这不是我们想要的。为了防止这种情况破坏我们的游戏,我们将使用一些技巧。基本上,我们将让游戏中心创建一个通知,而当前视图将负责显示它。例如,如果通知在主菜单中调用,没问题,我们会立即调用它。然而,如果我们处于关卡中间,并且通知被调用,我们需要在方便的时候显示认证窗口,比如玩家暂停或死亡时。

要做到这一点,我们需要定义通知。因此,在我们的MultiplayerHelper.m文件中,我们将在文件顶部直接添加以下行,位于@import GameKit行下方:

NSString *const PresentAuthenticationViewController = @"present_authentication_view_controller";

在我们的setAuthenticationViewController:方法中向下进一步,添加以下代码块:

 if (authenticationViewController != nil) {
        _authenticationViewController = authenticationViewController;
        [[NSNotificationCenter defaultCenter]
         postNotificationName:PresentAuthenticationViewController
         object:self];
    }

这个方法所做的只是存储并发送通知到当前视图控制器。你明白了吗?我知道这听起来可能很多,不是吗?

现在我们向下滚动到我们的-(void)setLastError:方法,并在括号内添加以下代码:

_lastError = [error copy];
  if (_lastError) {
    NSLog(@"MultiplayerHelper ERROR: %@",
          [[_lastError userInfo] description]);
  }

这将仅在控制台中有任何异常发生时记录日志。我们在错误之前设置了 MultiplayerHelper,所以在日志中我们实际上会看到 MultiplayerHelper Error: "连接失败"。这样就可以更容易地看到抛出的错误。很多时候连接会失败,通过这样做,我们不仅能够知道错误是什么,还能知道导致错误的原因。这是一个很好的实践;在调试问题时会使事情变得容易得多!

然后,我们需要跳转到我们的 MultiplayerHelper.h 文件,并在 interface 方法上方添加一个外部链接方法:

extern NSString *const PresentAuthenticationViewController;

这将允许我们通过项目的其他部分访问此函数,因此我们可以在需要时显示认证视图。

最后,我们需要在 @end 之上添加一个用于验证本地玩家的声明:

(void)authenticateLocalPlayer;

这就是我们需要验证玩家身份的所有内容!

目前,你的 MultiplayerHelper.h 文件的内容应该如下所示:

@import GameKit;
extern NSString *const PresentAuthenticationViewController;
@interface MultiplayerHelper : NSObject

@property (nonatomic, readonly) UIViewController *authenticationViewController;
@property (nonatomic, readonly) NSError *lastError;

- (void)authenticateLocalPlayer;
+ (instancetype)sharedMultiplayerHelper;

@end

此外,你的 MultiplayerHelper.m 文件的内容应该如下所示:

#import "MultiplayerHelper.h"

@import GameKit;

NSString *const PresentAuthenticationViewController = @"present_authentication_view_controller";

@implementation MultiplayerHelper {
    BOOL _enableGameCenter;
}

+ (instancetype)sharedMultiplayerHelper
{
    static MultiplayerHelper *sharedMultiplayerHelper;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedMultiplayerHelper = [[MultiplayerHelper alloc] init];
    });
    return sharedMultiplayerHelper;
}

- (id)init
{
    self = [super init];
    if (self) {
        _enableGameCenter = YES;
    }
    return self;
}

- (void)authenticateLocalPlayer
{

    GKLocalPlayer *localPlayer = [GKLocalPlayer localPlayer];

    localPlayer.authenticateHandler  =
    ^(UIViewController *viewController, NSError *error) {

        [self setLastError:error];

        if(viewController != nil) {

            [self setAuthenticationViewController:viewController];
        } else if([GKLocalPlayer localPlayer].isAuthenticated) {

            _enableGameCenter = YES;
        } else {

            _enableGameCenter = NO;
        }
    };
}

- (void)setAuthenticationViewController:(UIViewController *)authenticationViewController
{
    if (authenticationViewController != nil) {
        _authenticationViewController = authenticationViewController;
        [[NSNotificationCenter defaultCenter]
         postNotificationName:PresentAuthenticationViewController
         object:self];
    }
}

- (void)setLastError:(NSError *)error
{
    _lastError = [error copy];
    if (_lastError) {
        NSLog(@"MultiplayerHelper ERROR: %@",
              [[_lastError userInfo] description]);
    }
}

@end

在所有这些问题都解决(希望一切对你来说都运行得完美),我们将跳转到我们的 ViewController.m 文件,并在 (void)viewDidAppear: 方法内添加以下函数:

  [[NSNotificationCenter defaultCenter]
     addObserver:self
     selector:@selector(showAuthenticationViewController)
     name:PresentAuthenticationViewController
     object:nil];

    [[MultiplayerHelper sharedMultiplayerHelper]
     authenticateLocalPlayer];

再次,我们将使用 NSNotificationCenter 方法,这将允许每个 ViewController 类发送一个通知,在这种情况下是显示认证视图控制器,以便可以根据在 ViewController 类中的调用位置进行不同的处理。

现在,我们需要添加一个实际显示认证视图控制器的方法。在 ViewController.m 文件中,添加以下函数:

- (void)showAuthenticationViewController
{
    MultiplayerHelper *multiplayerHelper =
    [MultiplayerHelper sharedMultiplayerHelper];

    [self presentViewController:
     multiplayerHelper.authenticationViewController
                                         animated:YES
                                       completion:nil];
}
- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

当你在几秒钟后运行并测试游戏时,你会看到以下页面:

多人游戏集成

当你输入你的凭据时,你将登录,然后你会看到以下页面:

多人游戏集成

我们还没有完成!我们还需要搜索其他玩家来一起玩。Game Center 的好处是匹配系统已经直接集成到 API 中,所以我们不需要进行任何复杂的编程或 GUI 创建。

让我们回到我们的 MultiPlayerHelper.h 文件,以便我们可以在 @import 行之后添加以下代码块进行以下更改:

@protocol MultiPlayerHelperDelegate
-(void)matchStarted;
-(void)matchEnded;
-(void)match:(GKMatch *)match didReceiveData:(NSData *)data fromPlayer:(NSString *)playerID;
@end

然后,我们必须修改我们的 @interface 行,以便我们可以支持我们刚刚创建的匹配协议:

@interface MultiplayerHelper : NSObject<GKMatchmakerViewControllerDelegate, GKMatchDelegate>

然后,我们在 @interface 行之后添加以下函数:

@property (nonatomic, strong) GKMatch *match;
@property (nonatomic, assign) id <MultiPlayerHelperDelegate> delegate;

- (void)findMatchWithMinPlayers:(int)minPlayers maxPlayers:(int)maxPlayers
                 viewController:(UIViewController *)viewController
                 delegate:(id<MultiPlayerHelperDelegate>)delegate;

哇,哇!冷静一下,牛仔!这是我以前从未见过的技巧!让我们来分解一下!

首先,我们添加了一个名为 MultiPlayerHelperDelegate 的新协议。这是为了在发生某些事件时通知其他对象和函数,例如新游戏开始或结束。

接下来,MultiplayerHelper 类定义了两个新的协议。第一个协议是 GKMatchmakerViewControllerDelegate 函数,它使 MultiplayerHelper 类能够在找到新的匹配时通知玩家。第二个协议是 GKMatchDelegate 函数,用于 Game Center 通知 MultiplayerHelper 如果有新数据传入或我们失去了连接。

最后,下一个动作允许 MultiplayerHelper 类寻找可以一起玩的人。

简单,对吧?YES!

现在,我们将跳转到 GameKitHelper.m 文件,以便我们可以添加更多函数!

第一个函数必须添加在我们的 @implementation 行内:

BOOL _matchStarted;

这是一个简单的 truefalse 声明,当有新的匹配开始时我们将调用它。现在,我们将添加以下函数(我是在 (void)authenticateLocalPlayer 方法之后添加的):

- (void)findMatchWithMinPlayers:(int)minPlayers maxPlayers:(int)maxPlayers
                 viewController:(UIViewController *)viewController
                       delegate:(id<MultiPlayerHelperDelegate>)delegate {

    if (!_enableGameCenter) return;

    _matchStarted = NO;
    self.match = nil;
    _delegate = delegate;
    [viewController dismissViewControllerAnimated:NO completion:nil];

    GKMatchRequest *request = [[GKMatchRequest alloc] init];
    request.minPlayers = minPlayers;
    request.maxPlayers = maxPlayers;

    GKMatchmakerViewController *mmvc =
    [[GKMatchmakerViewController alloc] initWithMatchRequest:request];
    mmvc.matchmakerDelegate = self;

    [viewController presentViewController:mmvc animated:YES completion:nil];
}

这是允许找到匹配的功能。我们设置它,如果玩家没有登录到 Game Center,它将无效化并什么都不做。

然后,我们开始寻找新的匹配。此方法允许我们自定义我们想要的匹配类型,例如,匹配中所需的玩家数量最小或最大。你可以创建一个 GUI,让匹配器可以像在典型的 FPS 游戏中那样自定义匹配。

然后,我们通过将委托设置为我们的 MultiplayerHelper 对象来创建一个新的 Game Kit MatchMakerViewController 实例,然后将其弹出屏幕。

最后,MatchMakerViewController 函数开始搜索。它将发送一些回调,我们现在将添加。在刚刚添加的方法之后插入以下内容;第一个将在用户取消搜索朋友时被调用:

- (void)matchmakerViewControllerWasCancelled:(GKMatchmakerViewController *)viewController {
    [viewController dismissViewControllerAnimated:YES completion:nil];
}

下一个是在匹配过程失败时;我们将在日志中显示发生了什么:

- (void)matchmakerViewController:(GKMatchmakerViewController *)viewController didFailWithError:(NSError *)error {
    [viewController dismissViewControllerAnimated:YES completion:nil];
    NSLog(@"Well that didn't work. Here's why: %@", error.localizedDescription);
}

现在,我们已经找到了一个匹配!

- (void)matchmakerViewController:(GKMatchmakerViewController *)viewController didFindMatch:(GKMatch *)match {
    [viewController dismissViewControllerAnimated:YES completion:nil];
    self.match = match;
    match.delegate = self;
    if (!_matchStarted && match.expectedPlayerCount == 0) {
        NSLog(@"Ready to start playing!");
    }
}

#pragma mark GKMatchDelegate

以下代码块告诉我们匹配是否收到了玩家发送的数据:

- (void)match:(GKMatch *)match didReceiveData:(NSData *)data fromPlayer:(NSString *)playerID {
    if (_match != match) return;

    [_delegate match:match didReceiveData:data fromPlayer:playerID];
}

这告诉我们连接是否发生变化:

- (void)match:(GKMatch *)match player:(NSString *)playerID didChangeState:(GKPlayerConnectionState)state {
    if (_match != match) return;

    switch (state) {
        case GKPlayerStateConnected:

            NSLog(@"Player connected!");

            if (!_matchStarted && match.expectedPlayerCount == 0) {
                NSLog(@"Ready to start match!");
            }

            break;
        case GKPlayerStateDisconnected:

            NSLog(@"Player disconnected!");
            _matchStarted = NO;
            [_delegate matchEnded];
            break;
    }
}

这将告诉我们游戏由于某些奇怪的原因无法开始:

- (void)match:(GKMatch *)match connectionWithPlayerFailed:(NSString *)playerID withError:(NSError *)error {

    if (_match != match) return;

    NSLog(@"Failed to connect to player with error: %@", error.localizedDescription);
    _matchStarted = NO;
    [_delegate matchEnded];
}

最后,这告诉我们是否在连接任何玩家时出现了错误:

- (void)match:(GKMatch *)match didFailWithError:(NSError *)error {

    if (_match != match) return;

    NSLog(@"Match failed with error: %@", error.localizedDescription);
    _matchStarted = NO;
    [_delegate matchEnded];
}

我们将在 MultiplayerHelper.m 文件的顶部添加另一个 NSString 类。在 present_authentication_view_controller 定义下方添加以下声明:

NSString *const LocalPlayerIsAuthenticated = @"local_player_authenticated";

然后,我们将滚动到 authenticatedLocalPlayer 方法,我们将编辑它,使其看起来如下:

- (void)authenticateLocalPlayer
{

    GKLocalPlayer *localPlayer = [GKLocalPlayer localPlayer];

    if (localPlayer.isAuthenticated) {
        [[NSNotificationCenter defaultCenter] postNotificationName:LocalPlayerIsAuthenticated object:nil];
        return;
    }

    localPlayer.authenticateHandler  =
    ^(UIViewController *viewController, NSError *error) {

        [self setLastError:error];

        if(viewController != nil) {

            [self setAuthenticationViewController:viewController];
        } else if([GKLocalPlayer localPlayer].isAuthenticated) {

            _enableGameCenter = YES;
            [[NSNotificationCenter defaultCenter] postNotificationName:LocalPlayerIsAuthenticated object:nil];
        } else {

            _enableGameCenter = NO;
        }
    };
}

同样,我们在这里创建了一个新的通知(NSString 类),稍后当玩家被认证时将被调用,因为我们将立即处理那个通知的调用。

让我们回到我们的 MultiPlayerHelper.h 文件,我们将在此 @import 行下方添加以下代码行:

extern NSString *const LocalPlayerIsAuthenticated;

再次强调,这是一个外部链接,我们稍后会访问。目前,我们将更改我们的 ViewController.h 文件:

#import <UIKit/UIKit.h>
#import <SpriteKit/SpriteKit.h>
#import <iAd/iAd.h>
#import "MultiplayerHelper.h"

@interface ViewController : UIViewController <ADBannerViewDelegate, MultiPlayerHelperDelegate> {
    ADBannerView *adView;
}

@end

我们需要调整ViewController接口以实现MultiPlayerHelperDelegate方法。

我们现在需要调整我们的ViewController.m文件,添加以下添加和更改。首先,我们将添加以下代码块到我们的(void)viewDidAppear函数中:

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(playerAuthenticated)
name:LocalPlayerIsAuthenticated object:nil];

以下代码展示了我们如何接受我们之前创建的通知以验证玩家:

- (void)playerAuthenticated {
    [[MultiplayerHelper sharedMultiplayerHelper] findMatchWithMinPlayers:2 maxPlayers:2 viewController:self delegate:self];
}

现在,在dealloc函数下添加以下定义:

#pragma mark MultiPlayerHelperDelegate

- (void)matchStarted {    
    NSLog(@"Game started");        
}

- (void)matchEnded {    
    NSLog(@"Game ended");    
}

- (void)match:(GKMatch *)match didReceiveData:(NSData *)data fromPlayer:(NSString *)playerID {
    NSLog(@"Received data");
}

这些将简单地记录通过MultiPlayerHelperDelegate方法传递的每个通知,所以当游戏开始或结束或接收到数据时,你将在控制台中看到日志。

现在运行它,你将看到以下输出:

多人集成

太棒了!!这部分看起来相当酷,但我们还有很长的路要走。

游戏中心集成

匹配师,匹配师,为我找一个匹配对象!现在是开始寻找我们的朋友的时候了!

让我们转到我们的MultiplayerHelper.h文件,以便我们可以在@interface行之后添加一个新的字典来存储我们找到的玩家。就在@interface行之后,添加以下声明:

@property(nonatomic, strong) NSMutableDictionary *playersDict;

这个字典允许游戏工具包轻松查找玩家数据。

现在,我们将转到我们的MultiplayerHelper.m文件并做一些新的更改。首先,我们将在验证我们的本地玩家后添加一个新的方法:

- (void)lookupPlayers {

    NSLog(@"Looking up %lu players...", (unsigned long)_match.playerIDs.count);

    [GKPlayer loadPlayersForIdentifiers:_match.playerIDs withCompletionHandler:^(NSArray *players, NSError *error) {

        if (error != nil) {
            NSLog(@"Error retrieving player info: %@", error.localizedDescription);
            _matchStarted = NO;
            [_delegate matchEnded];
        } else {

            // fill up that there dictionary
            _playersDict = [NSMutableDictionary dictionaryWithCapacity:players.count];
            for (GKPlayer *player in players) {
                NSLog(@"Found this person to play with: %@", player.alias);
                [_playersDict setObject:player forKey:player.playerID];
            }
            [_playersDict setObject:[GKLocalPlayer localPlayer] forKey:[GKLocalPlayer localPlayer].playerID];

            // Let me know if the match can start k?
            _matchStarted = YES;
            [_delegate matchStarted];
        }
    }];
}

接下来,我们需要实际调用这个方法,我们将在两个不同的地方调用它。在第一个方法中,我们将调整以下代码:

- (void)matchmakerViewController:(GKMatchmakerViewController *)viewController didFindMatch:(GKMatch *)match {
    [viewController dismissViewControllerAnimated:YES completion:nil];
    self.match = match;
    match.delegate = self;
    if (!_matchStarted && match.expectedPlayerCount == 0) {
        NSLog(@"Ready to play!");
        [self lookupPlayers];
    }
}

我们需要调整另一个方法:

- (void)match:(GKMatch *)match player:(NSString *)playerID didChangeState:(GKPlayerConnectionState)state {
    if (_match != match) return;

    switch (state) {
        case GKPlayerStateConnected:
            // handle a new player connection.
            NSLog(@"Player connected!");

            if (!_matchStarted && match.expectedPlayerCount == 0) {
                NSLog(@"LET'S PLAY YA");
                [self lookupPlayers];
            }

            break;
        case GKPlayerStateDisconnected:
            // a player just disconnected.
            NSLog(@"Player disconnected!");
            _matchStarted = NO;
            [_delegate matchEnded];
            break;
    }
}

现在,如果你要在两个设备上测试我们的代码,你应该在控制台看到以下内容:

2015-10-26 18:52:13.867 ADESA[787:60b] Ready to start match!
2015-10-26 18:52:13.874 ADESA[787:60b] Looking up 1 players...
2015-10-26 18:52:13.894 ADESA[787:60b] Found player: miigman
2015-10-26 18:52:13.895 ADESA[787:60b] Match has started successfully

就这样!现在完成我们的多人集成所需的所有工作就是处理两个连接设备之间的控制。

现在我们已经将多人功能全部集成,接下来就轮到你了,让两个玩家都出现!(你不会以为我会让你那么容易做到吧,对吧?)

大部分工作已经为你完成了;只需记住如何在多个类之间工作。想要一个提示吗?发送消息!例如,当你按下跳跃按钮时,向MultiPlayerHelper方法发送消息使player2GameScene类中移动。哦,别忘了设置player2

我知道你能做到!

这将是你的最后一个挑战。

当你完成时...

正在向 AppStore 推送更新!

现在你已经完成了你那令人惊叹的新多人游戏,是时候将你游戏的最新版本推送到 AppStore 了!你还记得我们最初是如何推送我们的游戏的吗?步骤非常相似!

首先,简单地登录到itunesconnect.apple.com,然后在我的应用部分点击你想要更新的应用。

向 AppStore 推送更新!

一旦你选择了应用,你将看到一个中间有i的大蓝色圆圈,要求你创建一个新的应用版本,如果你想要更改应用信息。那么我该如何做到这一点呢?!

很简单,尽量保持冷静。

在侧边栏中,只需点击显示版本或平台处的+按钮,如下面的截图所示:

向 AppStore 推送更新!

在撰写这本书的时候,它会问你想要创建新的 iOS 版本还是 tvOS 版本。

对于这本书(因为我们没有涵盖 tvOS)。

ITunes connect 将要求你输入新版本号。当你点击完成时,它将出现在当前版本上方,显示为准备销售

一旦点击新版本,你将被要求填写新版本信息和上传新截图,如下面的截图所示:

向 AppStore 推送更新!

你可以更改应用的描述和联系方式,并选择你希望应用何时发布。当你填写完所有内容并上传了所有新截图后,你可以退出浏览器并再次打开 Xcode。

我知道,我们之前使用了 Application Loader,但 Xcode 似乎是一个更简单的方法,因为你可以直接通过它构建和提交。

你还记得我们是怎么做的吗?没问题!

点击我们项目顶部的方案,确保我们已选择设备构建(否则,将不可用存档选项),如下面的截图所示:

向 AppStore 推送更新!

一旦选择了设备构建,导航到产品 | 存档。Xcode 现在将构建我们的应用并将其存档以供 AppStore 提交。完成后,它将显示包含我们应用和过去构建的其他应用的组织者。最近的构建将自动选中。在侧边栏中,只需点击上传到 App Store

向 AppStore 推送更新!

Xcode 将验证我们的包,签名,然后存档它。

向 AppStore 推送更新!

一旦完成,只需点击上传按钮!Xcode 将自动将你的项目文件上传到 AppStore。在上传之前,别忘了更改列表中的版本号,否则上传后会出现错误。

现在你只需要等待你那令人惊叹的新版本发布!

我为你们感到非常自豪!你们已经走了这么长的路!

我没有忘记……我只是错过了它

在实现我们游戏的多玩家功能时,你遇到任何问题吗?它显示它没有被 Game Center 识别吗?真傻!我忘记提到一个相当关键的步骤……哎呀!嘿,这种情况是会发生的!

所以,如果你在设备或模拟器上运行你的应用时遇到了那个错误,那是因为你的游戏还没有在 Game Center 注册。

你是怎么做到这一点的?

再次登录到 itunesconnect.apple.com,进入我的应用,然后打开你的应用。向下滚动,如果你在多人游戏兼容性部分看到一个带有文本点击+以选择此应用版本的多人游戏兼容性的灰色框,那么你就知道我之前哪里做错了。

我没有忘记...我只是错过了它

简单地点击+按钮,选择你的应用以添加多人游戏兼容性。

你完成了!

伙计们,我非常高兴你们能陪我走这么远!我知道,有时可能会感到困惑或信息量很大,但你们做到了!

我希望你能通过我所传授的所有知识和技巧,掌握 iOS 开发!

祝你在游戏开发生涯中一切顺利,永远不要放弃你的梦想和热情。以下图片展示了游戏全部完成时的最终结果。

你完成了!

摘要

我们讨论了实现多人游戏集成!我们为我们的游戏设置了 GameCenter,甚至已经准备好了玩家认证和匹配搜索。完成这个精彩项目的任务完全取决于你。我们还讨论了如何上传我们游戏的更新版本,这样全世界的数百万玩家都可以享受你的最新更新。

posted @ 2025-10-26 08:56  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报