Sparrow-IOS-游戏框架初学者指南-全-

Sparrow IOS 游戏框架初学者指南(全)

原文:zh.annas-archive.org/md5/ed5751a1b07b40a60c49bc69fba2ea52

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

游戏开发可能是软件开发中最具挑战性和最有回报的挑战之一。如果我们从头开始,将需要非常长的时间才能看到任何结果。

With the introduction of the iPhone in 2007 and subsequent devices in the following years, developing applications for mobile devices took off, and more than 1,000,000 apps can now be downloaded from the App Store.

幸运的是,Sparrow,一个开源的 iOS 游戏框架,为我们提供了一系列预定义的类和方法,这将有助于我们的游戏开发过程。

Instead of showing how to develop a part of a game example-by-example during the course of the book, we will learn each stage of game development. With each chapter, our game will mature from being just an idea to a complete entity, while extending our knowledge of Sparrow.

本书涵盖的内容

第一章, Sparrow 入门shows us how to set up Xcode, Sparrow, and our game template that we will use throughout the book. This chapter also sets up our goals and expectations for the kind of game we will develop.

第二章, 显示我们的第一个对象,explains the concept of display objects, which we need to achieve in order to get anything to show up on the screen, and how to manipulate these objects.

第三章, 管理资源和场景,introduces us to the concepts of scene and asset management and how to implement them for our purposes.

第四章, 我们游戏的基础,deals with setting up our game to work on iPhone, iPod Touch, and iPad in the same manner. We will also create the game skeleton in this chapter.

第五章, 美化我们的游戏,covers moving and animating our entities on the screen. We will also learn how to generate sprite sheets, what to consider when using sprite sheets, and how to integrate them into our game.

第六章, 添加游戏逻辑,focuses on getting actual gameplay into our game as well as managing our game-relevant data in separate files.

第七章, 用户界面,shows us how to implement the user interface in our game, for example, displaying text on the screen, structuring our user interface, and updating the user interface to what is currently happening in the game.

第八章, 人工智能和游戏进程,explains what we need to know in order to implement basic artificial intelligence and how we need to apply this for our enemies in the game.

第九章,为我们的游戏添加音频,介绍了如何加载音频以及如何在我们的游戏中集成它们。

第十章,完善我们的游戏,涉及为我们的游戏添加最后的 10%。我们将添加主菜单、开场和教程机制,以获得更流畅的游戏体验。

第十一章,集成第三方服务,探讨了如何集成第三方服务,如 Apple Game Center,以改善玩家的体验。

你需要为本书准备的东西

为了开发 iOS 应用程序,你需要一台 Mac,最好是最新版本的 Mac OS X。尽管 Apple 开发者账户和 iOS 开发者计划不是必需的,但建议使用,因为它允许你在实际的设备上运行示例,例如 iPod Touch、iPhone 和 iPad,并将你的应用程序分发到苹果应用商店。请记住,iOS 开发者计划会带来额外的费用。

在你的系统上不需要安装 Sparrow 和 Xcode;我们将在第一章中介绍安装过程。

本书面向的对象

本书旨在为对游戏开发感兴趣的人、已经涉足游戏开发但尚未为移动设备制作过游戏的人,以及希望在将来将游戏发布到苹果应用商店的人提供帮助。

你需要具备扎实的 Objective-C 理解能力才能跟随书中的示例,并且一些游戏开发经验肯定有帮助,尽管这不是必需的。

惯例

在本书中,你会发现几个标题频繁出现。

为了清楚地说明如何完成一个程序或任务,我们使用:

行动时间 – 标题

  1. 动作 1

  2. 动作 2

  3. 动作 3

指令通常需要一些额外的解释,以便它们有意义,因此它们后面跟着:

刚才发生了什么?

这个标题解释了你刚刚完成的任务或指令的工作原理。

你还会在本书中找到一些其他的学习辅助工具,包括:

快速测验 – 标题

这些是简短的多选题,旨在帮助你测试自己的理解。

有所作为的英雄 – 标题

这些实践挑战为你提供了实验你所学内容的想法。

你也会发现许多文本样式,这些样式可以区分不同类型的信息。以下是一些这些样式的示例,以及它们含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和推特用户名如下所示:"这个类需要从 SPSprite 类继承。"

代码块设置如下:

// Setting the background
SPSprite *background = [[SPSprite alloc] init];
[self addChild:background];

// Loading the logo image and bind it on the background sprite
SPSprite *logo = [SPImage imageWithContentsOfFile:@"logo.png"];
[background addChild:logo];

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

// Setting the background
SPSprite *background = [[SPSprite alloc] init];
[self addChild:background];

// Loading the logo image and bind it on the background sprite
SPSprite *logo = [SPImage imageWithContentsOfFile:@"logo.png"];
[background addChild:logo];

任何命令行输入或输出都应如下编写:

sudo gem install cocoapods
pod setup
touch Podfile
pod install

术语重要 词汇 以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“在选择 目标 位置屏幕上,点击下一步以接受默认目标。”

注意

警告或重要提示会以这样的框中出现。

小贴士

小贴士和技巧会像这样显示。

读者反馈

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

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

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

客户支持

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

下载示例代码

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

错误更正

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

盗版

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

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

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

询问

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

第一章. Sparrow 入门

在我们深入探讨开发概念之前,我们首先需要设置我们的开发环境,并在我们的系统上设置 Sparrow。在本章中,我们将简要了解 Sparrow 实际上是什么,为我们的需求设置 Xcode 和 Sparrow,创建游戏模板,并为我们要开发的游戏设定目标和期望。

理解 Sparrow 的基础知识

Sparrow 是一个游戏框架,对于那些已经有一定 ActionScript、Flash API 和/或 Starling 经验的人来说可能会感到熟悉。与 Starling 的熟悉性并非巧合;Starling 和 Sparrow 的核心开发团队是相同的。Starling 可以被认为是 Sparrow 的 ActionScript 版本。一旦我们详细使用 Sparrow 的不同功能,我们将深入探讨这些方面。

嵌入式系统开放图形库OpenGL ES)是适用于移动设备的图形渲染器,可在各种设备上使用,从 iOS 设备到 Android 设备,甚至到 OUYA 这样的游戏机。OpenGL 可以看作是 OpenGL ES 的较老、更大的兄弟。OpenGL 本身适用于所有桌面平台,如 Windows、Linux、Mac OS X 以及下一代游戏机,如 PlayStation 4。

OpenGL 和 OpenGL ES 是那种让我们在屏幕上施展魔法(无论是绘制纹理,还是在屏幕上有某种几何形状或粒子效果)的库。

Sparrow 将所有 OpenGL 组件从开发者那里抽象出来。我们根本不必担心 OpenGL 的内部工作原理。Sparrow 完全专注于 2D 渲染,并将其放入一系列逻辑上结构化的类和方法中。就编程语言而言,性能密集型图形应用程序的选择通常是 C 或 C++,而 Sparrow 使用 Objective-C 来保持对 Mac 和 iOS 开发者的熟悉度。

Sparrow 不仅是一个 2D 图形引擎,它还提供了在屏幕上创建动画的功能——从简单的效果,如淡入淡出对象,到更复杂的行为,如使用弹跳动画将球从屏幕的左侧移动到右侧。这种机制也被称为补间动画。

除了图形相关功能外,Sparrow 还为我们提供了加载音频文件和在游戏中播放音乐和声音的手段。

当与其他游戏框架直接比较时,Sparrow 不会对我们施加特定的工作流程。因此,一些事情需要手动设置,例如为我们的游戏结构化所有需要的资产,以及管理我们的场景。

系统要求

从硬件角度来看,任何能够运行最新 Mac OS X 的 Mac 都完全可以胜任。

在软件方面,我们需要最新版本的 Mac OS X,并且所有最新的更新都需要安装。在撰写本书时的最低要求是 OS X 10.8 Mountain Lion。

对于我们将要针对的平台,我们至少需要一个运行 iOS 5.0 和 Xcode 4.0 或更高版本的设备。Sparrow 的最新版本内部使用 OpenGL ES 2.0,这仅支持 iPhone 3GS 或更新的设备。

设置 Apple 开发者账户

设置 Apple 开发者账户并加入 iOS 开发者计划对于本书的目的来说是完全可选的,但如果您想在物理设备上测试游戏或将其发布到 Apple App Store,则这是必要的。

随附于 Apple 开发者工具的 iOS 模拟器是一个测试不同功能的好平台。然而,模拟器本身的性能可能会误导。性能是游戏开发中的一个关键因素,因此在实际设备上进行测试应该是优先考虑的。

根据您的 Mac 性能,iOS 模拟器上的应用程序运行速度可能从极慢到相当快。总的来说,不要将模拟器中的性能作为游戏在实际设备上表现良好的参考。

关于 iOS 开发者计划和 Apple 开发者账户的更多信息,请访问 developer.apple.com/

下载 Xcode

Xcode 是开发与 Mac 和 iOS 相关所有内容的默认集成开发环境。Xcode 可免费获取,书写此书时的最新版本是 5.0.2。

第一步是从 Mac App Store 下载 Xcode。点击 dock 上的 Mac App Store 图标以打开 Mac App Store。

使用搜索框搜索 Xcode 并选择适当的结果。

商店页面可能看起来如下截图所示:

下载 Xcode

点击位于标志正下方的 安装 按钮。(如果 Xcode 已经安装,按钮的标题将更改为 已安装。)下载大小约为 2.6 GB,因此根据您的网络连接速度,可能需要一段时间。

总是保持 Xcode 安装更新到最新是一个好主意,因为更新很频繁。

下载 Sparrow

现在 Xcode 已经安装并准备就绪,下一步是获取 Sparrow 的最新稳定版本。访问 Sparrow 的官方主页 sparrow-framework.orggamua.com/sparrow/

书写此书时的最新 Sparrow 版本是 2.0.1。下载页面看起来可能如下截图所示:

下载 Sparrow

点击大蓝色 下载 按钮,下载应该就会开始。

克隆 Git 仓库

如果您熟悉版本控制系统 Git,您也可以通过克隆 Git 仓库来获取最新版本。本节是为已经使用过 Git 的高级用户准备的。

Sparrow 的 GitHub 仓库位于 github.com/Gamua/Sparrow-Framework/,有关流程的更多信息可以在那里找到。通常,Git 命令在终端中输入:

git clone https://github.com/Gamua/Sparrow-Framework

下载可能需要一些时间;当前进度在终端中显示。下载完成后,您将拥有最新的开发版本。只有当您想在 Sparrow 发布前测试其特定功能或您喜欢冒险时才使用此版本。

请勿使用此版本为即将在苹果 App Store 上发布的游戏的生产代码。

要获取 Sparrow 的最新稳定版本,请查看稳定标签:

git checkout tags/v2.0.1

现在如果我们想更新到最新版本,我们可以这样做。

Sparrow 包的内容

下载完成后,将文件解压到您选择的地点。我们可以使用 Mac OS X 默认包含的解压程序,但任何第三方解压程序也应该可以工作。让我们看一下以下截图,看看 Sparrow 包提供了哪些内容:

Sparrow 包的内容

samples 文件夹

samples 文件夹包含三个子文件夹,具体如下:

  • barebone:这是一个最小化项目,是创建您自己的游戏的好起点,也将作为我们游戏的模板

  • demo:这是一个展示 Sparrow 中许多功能的示例应用程序

  • scaffold:该模板提供了一些基础类和比 barebone 示例更多的样板代码

sparrow 文件夹

sparrow 文件夹包含三个子文件夹,具体如下:

  • doc:这个脚本用于生成文档

  • src:这是 Sparrow 框架本身的全部源代码

  • util:这个文件夹包含不同的命令行工具,这些工具将帮助我们处理图形资源时的工作流程

根目录下的 Markdown 文件

Markdown 文件本质上是可以渲染成 HTML 文件的文本文件。具体如下:

  • BUILDING.md:这提供了如何使用库的快速入门指南

  • CHANGELOG.md:这提供了详细的项目列表,说明了版本之间的具体变化

  • LICENSE.md:这是 Sparrow 使用的许可协议

  • README.md:这是一篇简短的介绍,介绍了 Sparrow 是什么

License

Sparrow 是一款免费且开源的软件,这意味着其源代码可以被任何人查看和修改。

和 Mac/iOS 宇宙中的许多其他软件一样,Sparrow 在未经修改的两条款简化版 BSD 许可证下授权,这意味着只要版权声明、条件列表和免责声明与源代码或应用程序一起分发,我们就可以使用 Sparrow。

Note

如果我们要修改源代码,这意味着什么?

修改后的源代码必须包含相同的 LICENSE.md 文件,且该文件的内容不允许更改。

如果我们要使用 Sparrow 开发游戏并在苹果的 App Store 上分发游戏,这意味着什么?

游戏需要将 LICENSE.md 文件的内容包含在游戏本身中。在选项或致谢屏幕中包含文件内容也是一个有效的解决方案。

设置 Sparrow

在你的机器上设置 Sparrow 有两种不同的方式。第一种可能是设置 Sparrow 最简单的方法。

第二种选项是使用CocoaPods,这是一个 Objective-C 的依赖管理系统。从长远来看,CocoaPods 可能是更好的选择,尤其是在处理大型项目和多个依赖项时。设置 CocoaPods 比第一个选项需要更长的时间,并且需要一些关于如何使用终端的知识。

虽然这些选项不是相互排斥的,但最好都尝试一下,并坚持使用最吸引你的那个。

选项 1 - 源树引用

首先,将下载文件夹内的sparrow文件夹复制到您选择的位置。请勿在文件夹名称中使用空格,因为 Xcode 实际上无法在源树中处理它们。

行动时间 - 将 Sparrow 添加为源树引用

要将 Sparrow 添加为源树引用,请按照以下步骤操作:

  1. 打开 Xcode。

  2. 通过顶部菜单栏的Xcode打开 Xcode 设置,然后点击偏好设置…

  3. 导航到位置

  4. 点击源树选项卡。

  5. 点击加号(+)按钮添加一个源树项目。

  6. 名称选项卡中输入SPARROW_SRC

  7. 显示名称选项卡中输入 Sparrow。(或者使用SPARROW_SRC作为显示名称也是可以的。)

  8. 将我们放置sparrow文件夹的绝对路径添加进去。别忘了src后缀。

行动时间 - 添加 Sparrow 作为源树引用

发生了什么?

我们将 Sparrow 源位置作为源树项目添加到 Xcode 中。这只需要做一次,因为所有 Sparrow 项目都使用这个源树项目。

作为下一步,我们将设置我们将贯穿整本书的模板。

行动时间 - 使用裸骨项目作为模板

按照以下步骤使用barebone项目作为模板:

  1. samples | barebone中的barebone应用程序复制到您选择的位置。

  2. 通过双击Barebone.xcodeproj打开 Xcode 中的项目。

  3. 点击项目导航器中的项目名称以使其可编辑。

  4. 将其重命名为PirateGame

  5. 从顶部菜单栏打开产品菜单。

  6. 选择方案管理方案

  7. 方案名称从Barebone重命名为PirateGame

  8. 通过点击播放按钮在 iOS 模拟器中运行项目。确保选择PirateGame而不是Sparrow

行动时间 - 使用裸骨项目作为模板

发生了什么?

我们复制了barebone Sparrow 模板,并将其用作我们游戏的模板。我们重命名了所有项目和方案引用,并运行了模板以查看是否一切顺利。

一切按预期工作时的指示是编译模板时没有错误,屏幕上出现一个红色矩形。

选项 2 – CocoaPods

CocoaPods 是 Objective-C 项目的依赖管理器。它可以处理 Mac OS X 和 iOS 依赖项,类似于其他系统的包管理器。它可以与 Ruby 的包管理器 RubyGems 相比 Ruby 平台,或者与 NPM 相比 Node.js。

CocoaPods 需要 Ruby 和 RubyGems 来运行,这没什么好担心的,因为它们都预安装在每台 Mac OS X 机器上。

在我们开始安装 CocoaPods 之前,我们需要确保命令行工具已安装。

行动时间 – 安装命令行工具

要安装命令行工具,请按照以下步骤操作:

  1. 打开终端。

  2. 输入 xcode-select 并按 Enter 确认。

  3. 如果尚未安装命令行工具,将弹出一个对话框。如果没有弹出对话框,则命令行工具已安装,这里没有需要做的事情。

  4. 如以下截图所示,单击 安装 按钮继续:安装命令行工具的行动时间

发生了什么?

在我们能够安装 CocoaPods 之前,我们需要确保有最新版本的命令行工具。

如果您想触发命令行工具的重新安装,无论它们是否已安装,可以通过输入 xcode-select --install 来实现。

现在命令行工具已安装,我们可以开始安装 CocoaPods。

行动时间 – 安装 CocoaPods

要安装 CocoaPods,请按照以下步骤操作:

  1. 打开终端。

  2. 输入 sudo gem update –system 并按 Enter 确认。

  3. 输入 sudo gem install cocoapods

  4. 输入 pod setup。这可能需要很长时间,所以请耐心等待。

安装 CocoaPods 的行动时间 – 安装 CocoaPods

发生了什么?

由于 CocoaPods 需要通过命令行安装,我们的要求是打开一个终端窗口。

在第二步中,我们将更新 RubyGems 到最新可用版本。

之后,我们触发 CocoaPods 的安装。这也会安装所有依赖项。如果有冲突,我们会收到提示来处理这个冲突。如果您不确定该怎么做,在这种情况下只需按 Enter,最安全的选项将被选择。

最后一步是设置 CocoaPods 所必需的。建议时不时地运行此命令,因为它会更新本地仓库,包含所有可用的最新库规范。

现在 CocoaPods 已安装,我们可以继续设置我们的 Sparrow 模板。

行动时间 – 将 barebone 项目作为模板使用

按照以下步骤使用 barebone 项目作为模板:

  1. samples | barebone 中的 barebone 应用程序复制到您选择的位置。

  2. 打开 Xcode 项目。

  3. 在项目导航器中单击项目名称以使其可编辑。

  4. 将其重命名为 PirateGame

  5. 从顶部菜单栏打开 Product 菜单。

  6. 选择 SchemeManage Schemes

  7. Scheme 名称从 Barebone 更改为 PirateGame

  8. 关闭 Xcode。

  9. 打开任何文本编辑器。

  10. 输入以下代码:

    platform :ios, '5.0'
    
    pod 'Sparrow-Framework', '2.0.1'
    
  11. 将文件保存为 Podfile,在最近复制的 barebone 文件夹中,与 Xcode 项目文件在同一级别。如果您使用的是 TextEdit(OS X 默认文本编辑器),请确保以纯文本格式保存文件,这可以通过在菜单中将 Format 更改为 Make Plain Text 来完成。另外,通过导航到 TextEdit | Preferences… 来禁用 Smart Quotes

  12. 打开终端。

  13. 导航到复制的 barebone 文件夹。

  14. 在终端中执行 pod install 命令。

  15. 使用 Xcode 打开 PirateGame.xcworkspace

  16. 通过右键单击并选择 Delete 来从项目中移除 Sparrow.xcodeproj

  17. 通过点击播放按钮在 iOS 模拟器中运行项目。如果有任何错误,尝试通过在配置中更改 User Header Search Paths 中的 recursivenon-recursive 来更改 Build Settings

使用裸骨项目作为模板的行动时间 – img/1509_01_05.jpg

发生了什么?

我们复制了裸骨 Sparrow 模板并将其用作游戏的模板。我们重命名了所有项目和方案引用。

我们当时需要关闭 Xcode,因为 CocoaPods 将生成一些文件,我们不希望 Xcode 干扰这个过程。

在下一步中,我们必须定义 Podfile,这是 CocoaPods 的规范文件。此文件告诉 CocoaPods 要获取哪些依赖项。

规范是用 Ruby 编写的,但对于不了解 Ruby 编程语言的人来说也很容易理解。

第一条语句设置了 iOS 平台的依赖项。如前所述,CocoaPods 可以处理同一项目中的 Mac OS 和 iOS 依赖项,因此它有一个语句来区分它们是有意义的。因为我们只针对 iOS,所以我们不需要担心 Mac OS 依赖项,所以我们将其省略。

在我们的示例中,Podfile 的第二部分包含了我们项目中需要的所有依赖项。因为我们只有一个依赖项——Sparrow,所以我们只需要定义这一个。

依赖项的格式如下:

pod 'name' 'version'

所有当前可用的依赖项和版本的存储库可以在 GitHub 上找到,网址为 github.com/CocoaPods/Specs

在我们的 Podfile 编写并保存后,我们需要回到终端,并让 CocoaPods 获取我们的依赖项,这就是 pod install 命令所做的事情。CocoaPods 还会生成一个 Pod 文件夹,所有依赖项都存储在这里,以及一个 Xcode 工作空间。

从现在开始,我们不需要打开项目文件,而是需要打开工作空间文件,因为这是 CocoaPods 更新和维护的地方。

如果我们打开项目文件并尝试运行应用程序,应用程序将无法编译。

作为最后一步,我们运行我们的示例。一切正常工作的指示是编译模板时没有错误,并且屏幕上出现一个红色矩形。

在实际设备上运行模板

尽管我们的模板仍然有点基础,但我们可以在实际设备上运行它。对于本节,我们需要一个 Apple 开发者账户,并且我们需要成为 iOS 开发者计划的成员。

行动时间 – 在实际设备上运行模板

要在实际设备上运行模板,请按照以下步骤操作:

  1. 通过顶部菜单栏的 Xcode 打开 Xcode 设置,然后点击 偏好设置…

  2. 导航到 账户

  3. 点击加号图标添加一个新账户。

  4. 从菜单中选择 添加 Apple ID…

  5. 输入所需的凭据,并通过点击 添加 来确认。

  6. 将您的设备连接到您的 Mac。

  7. 通过转到 窗口 | 组织者 打开 Xcode 组织者,以检查设备是否已成功检测到。

  8. 通过点击应用程序名称并选择正确的设备来从菜单中选择设备。

  9. 通过点击播放按钮运行项目。

行动时间 – 在实际设备上运行模板

发生了什么?

我们将一个设备连接到我们的 Mac,并将构建配置设置为设备,以便应用程序在设备上运行而不是在模拟器上。

如预期的那样,红色矩形应该成功显示在设备上,就像在模拟器上一样。

获取 Sparrow 文档文件

Sparrow 框架具有文档功能,也称为 docset,可以集成以获取有关 Sparrow 类和方法的更多信息。

要为 Xcode 5 添加 docset,需要一个名为 Docs for Xcode 的免费应用程序,可以从 Mac App Store 下载。有关 Docs for Xcode 的更多信息,请参阅 documancer.com/xcode/

行动时间 – 将 Sparrow API 文档添加到 Xcode

要添加 Sparrow API 文档,只需按照以下简单步骤操作:

  1. 打开 Docs for Xcode(如果您第一次启动 Docs for Xcode,请授予它对文档文件夹的访问权限)。

  2. 点击 添加订阅

  3. 在文本框中输入 http://doc.sparrow-framework.org/core/feed/docset.atom 并通过点击 添加 来确认。

  4. 重新启动 Xcode(如果 Xcode 已经关闭,请先打开)。

  5. 打开一个 Sparrow 项目,通过按 Alt 键并点击任何类名或方法来打开内联文档。

行动时间 – 将 Sparrow API 文档添加到 Xcode

发生了什么?

我们在 Xcode 的 Docs 中添加了一个 docset 资源订阅,这样在用 Sparrow 开发时,我们可以获得更精确且始终更新的文档。

游戏的想法

找到适合游戏的好想法可能相当棘手。一般来说,找到让你兴奋的东西,以及你可能想玩的东西。一个好的动机是玩游戏,找到你真的很喜欢或可以改进的片段。有一点是肯定的:不要克隆。不要制作克隆,而是创造一些原创的东西。

小贴士

寻找游戏想法

可能最好的找到游戏想法的方式是在游戏节期间,那时你可以在非常短的时间内开发一个游戏。其中最受欢迎的是 Ludum Dare (www.ludumdare.com/compo/) 和 Global Game Jam (globalgamejam.org/)。第一个是一个在线单人竞赛,而 Global Game Jam 你需要团队合作。这两个游戏节共同的特点是提供了一个主题,所有参赛者都应该使用这个主题。

对于不那么具有竞争力的方法,你也可以查看 Twitter @PeterMolydeux,这是一个 Fable、Black & White 和 Populous 的创意大脑 Peter Molyneux 的讽刺账户。有一些关于完全疯狂和/或有趣的游戏想法的推文,其中大多数都会非常有趣。

我们正在开发的游戏类型是一种有点像动作游戏的角色扮演游戏元素。玩家将控制一艘装满海盗的船,这些海盗正在等待船只攻击、夺取和掠夺。

在每次任务之后,我们将回到海盗湾,购买更好的物品,如炮弹,或者雇佣一个更有经验的船员。我们的游戏将被称为 "海盗生存实用指南"。然而,由于名字太长,我们只将游戏模板名称保留为 "PirateGame"。

设定目标和期望

制定某种计划总是一个好主意。虽然大多数游戏开发都是迭代的,但传达游戏愿景并没有什么坏处。

在开发过程中,最重要的是记住范围。尽可能保持范围尽可能小非常重要;在大多数情况下,你仍然需要在开发周期的后期阶段削减游戏元素。

说我们要创造下一个拥有更多级别的 愤怒的小鸟,可能和说我们要仅通过更多武器和任务来开发下一个 魔兽世界 一样不切实际。

让我们把我们的目标和期望列成一个清单。以下是这本书我们设定的目标清单:

  • 书末完成的游戏

  • 对 Sparrow 的开发有了理解

  • 例子对于游戏开发和与 Sparrow 合作是相关的

以下是我们对这本书的期望列表:

  • 游戏已经足够完善,可以发布到苹果应用商店

  • 游戏很有趣

检查我们的游戏元素

大多数独立游戏通常专注于一个机制,并将其打磨到极致。"小鸟飞" 和 "快照" 是很好的例子。

就像所有软件一样,总会有变成功能膨胀的危险,这意味着在开发过程中添加各种功能,而没有计划或平衡。最终,游戏可能拥有我们想要的所有功能,但这些功能可能相互排斥,游戏可能不会有趣。

因此,考虑到范围和限制,让我们列出我们游戏的功能和游戏元素列表:

  • 攻击敌舰

  • 从敌舰收集战利品

  • 升级舰船装备

  • 招募新船员

代码规范

在我们开始编写第一行代码之前,我们应该花点时间确定所有代码示例的代码规范。在 Objective-C 世界中,最常用的代码规范是由苹果公司制定的,我们将尽可能地遵循。

需要记住的最重要规范如下:

  • 保持所有方法名使用驼峰命名法(如myMethodName

  • 方法名应该是描述性的

  • 不要缩写方法名

  • 实例变量应该以下划线开头

这些规范的完整指南可在developer.apple.com/library/mac/documentation/Cocoa/Conceptual/CodingGuidelines/CodingGuidelines.html找到。

突击测验

Q1. Sparrow 框架是什么?

  1. 一个用于 2D 游戏的框架

  2. 一个 3D 图形引擎

  3. 一个场景图库

Q2. CocoaPods 是什么?

  1. Objective-C 的源代码控制

  2. Cocoa 的附加库

  3. Objective-C 包的依赖管理器

Q3. 为了使用 Sparrow 2.x 开发游戏,我们需要至少 iOS 5.0 SDK,最好是一个 iPhone 3GS 或更先进的设备。

  1. 真的

  2. 假的

摘要

在本章中,我们关于为 Sparrow 设置开发环境学到了很多。具体来说,我们涵盖了如何设置 Xcode、Sparrow 游戏框架以及创建我们自己的游戏模板。

我们还涉及了一些通用的游戏开发主题,并学习了如何使用 CocoaPods 进行依赖管理。

现在我们已经设置了游戏模板,我们准备好学习关于显示对象及其使用方法了——这是下一章的主题。

第二章 显示我们的第一个对象

在前一章中,我们安装并配置了 Xcode 开发者工具,还下载了 Sparrow 框架并将其链接到一个示例项目中。我们接着在 iOS 模拟器和真实设备上进行了测试。我们还为本书中将要开发的整个游戏设定了范围。然而,在我们进入游戏开发过程之前,让我们先了解一下 Sparrow 的一些核心概念,并熟悉 Sparrow 中的工作方式。我们将在屏幕上绘制一些对象,并通过应用旋转和缩放变换来操作这些对象。

理解显示对象

如其名所示,显示对象是将在屏幕上显示的东西。我们可以将显示对象视为包含不同类型图形数据的独立图形实体。虽然一开始这可能听起来有些抽象,但每个图像(SPImage)、四边形(SPQuad)或其他几何形状都是从SPDisplayObject类派生出来的,这是 Sparrow 中显示对象的表示。

解释显示对象容器

一个显示对象容器(SPDisplayObjectContainer)从SPDisplayObject继承,增加了拥有一组子显示对象的功能。当你将一个子显示对象添加到一个父显示对象容器中时,你可以将其视为将一个显示对象附加到另一个显示对象上。如果你移动、缩放或旋转父显示对象,所有这些变化都会被其可能拥有的任何子对象继承。这个概念与 Adobe Flash API 中屏幕上对象的管理方式大致相同。父节点和子节点的一整套被称为显示列表,有时也称为显示树。这是因为,就像一棵树一样,它包含许多分支,最终都汇聚到一个单一的树干上,通常被称为根。显示树的另一个名称是场景图。

显示列表按照它们被添加到父显示对象容器中的顺序绘制显示对象。如果我们向与之前添加的显示对象相同的父对象中添加第二个子显示对象,第二个显示对象将被绘制在第一个显示对象的前面。

让我们继续想象自己是一个纸板木偶娃娃。我们需要一个头部、一个躯干和一条腿,左边的手臂和手,右边的也是如此。参考以下显示这个概念的图示:

解释显示对象容器

这个安排的根对象将是身体对象。头部、躯干、腿和手臂将直接绑定到身体上,而手将绑定到每只手臂上。

设置背景颜色

在我们在屏幕上绘制一些对象之前,让我们改变我们应用程序的背景颜色,这个应用程序最终将成为我们的游戏。

行动时间 - 改变背景颜色

让我们看看改变背景颜色的必要步骤:

  1. 如果尚未打开,请打开我们的 Xcode 游戏模板。

  2. 打开Game.m源文件。

  3. 在初始化方法和现有的SPQuad对象之前,添加以下行:

    SPQuad *background = [SPQuad quadWithWidth:Sparrow.stage.width height:Sparrow.stage.height color:0xffffff];
    [self addChild:background];
    
  4. 运行示例。

当示例运行时,我们会在以下屏幕截图所示的白色背景上看到我们的红色矩形:

操作时间 – 更改背景颜色

发生了什么?

在第 1 步,我们打开了我们在上一章中创建的 Xcode 模板,在第 2 步中,我们导航到Game.m文件,这是我们当前的游戏代码所在之处。游戏是一个不断出现的红色矩形。

在第 3 步,在我们绘制红色矩形之前,我们定义了background变量,它是一个指向SPQuad实例的指针。SPQuad类是从SPDisplayObject派生出来的。SPQuad的功能是绘制一个带有背景色的矩形形状。

SPQuad类提供了一些工厂方法,用于执行诸如创建具有宽度和高度的 quad 以及向其添加颜色值等操作。在这个例子中,我们正在创建一个具有预定义宽度和高度以及颜色值0xffffff的 quad。颜色定义为十六进制表示的0xRRGGBB,即REDRED GREENGREEN BLUEBLUE

表面上看,调用[SPQuad quadWithWidth:Sparrow.stage.width height:Sparrow.stage.height]似乎与调用[[SPQuad alloc] initWithWidth:Sparrow.stage.width height:Sparrow.stage.height]]相同,但在底层有一个主要区别。当调用工厂方法时,它返回一个自动释放的对象,这意味着我们没有实例的所有权,它将在某个时刻被销毁。另一方面,如果我们使用 alloc-and-init 组合,我们就拥有所有权,需要自己释放实例。

由于我们的应用程序使用自动引用计数ARC),我们不需要担心自己释放实例。另一方面,Sparrow 本身使用手动引用计数MRC)。

要覆盖整个屏幕,我们需要获取屏幕本身的宽度和高度。这些值作为属性存在于Sparrow.stage对象中。

我们需要将背景添加到Game类中,这正是[self addChild:background]所做的事情。self关键字是对Game类的引用,它是从SPSprite类派生出来的。

现在,我们有一个白色背景,上面有一个红色矩形出现。

我们的Game.m源文件应该包含以下代码:

#import "Game.h" 

@implementation Game

- (id)init
{
    if ((self = [super init]))
    {
        SPQuad *background = [SPQuad quadWithWidth:Sparrow.stage.width height:Sparrow.stage.height color:0xffffff];
        [self addChild:background];

        SPQuad *quad = [SPQuad quadWithWidth:100 height:100];
        quad.color = 0xff0000;
        quad.x = 50;
        quad.y = 50;
        [self addChild:quad];
    }
    return self;
}

@end

小贴士

下载示例代码

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

也有一种更简单的方法来设置背景颜色。虽然在这个特定情况下性能损失并不高,以至于需要担心,但我们可以直接通过Sparrow.stage使用其颜色属性来设置颜色:Sparrow.stage.color = 0xffffff。这包含更少的行,更易于阅读,并且更好地展示了其意图。

什么是舞台?

我们简要地提到了Sparrow.stage的话题,到目前为止,它已经证明有一些有用的属性,可以获取屏幕的宽度和高度,并直接设置背景颜色。

舞台是任何 Sparrow 游戏的顶级元素,逻辑上是显示树的根元素,Sparrow 会为我们自动创建。

创建我们的纸板木偶娃娃

让我们实现本章开头提到的纸板木偶娃娃。移除屏幕上已经画出的红色矩形。

是时候动手制作纸板木偶娃娃了

要创建纸板木偶娃娃,我们需要执行以下步骤:

  1. 如果Game.m文件还没有打开,请打开它。

  2. 添加一个body容器,以下代码:

    SPSprite *body = [[SPSprite alloc] init];
    body.x = 85;
    body.y = 120;
    
    [self addChild:body];
    
  3. 按照以下代码添加torso

    SPQuad *torso = [SPQuad quadWithWidth:150 height:150];
    torso.color = 0xff0000;
    
    [body addChild:torso];
    
  4. 现在添加一个局部变量head,如下所示:

    SPQuad *head = [SPQuad quadWithWidth:80 height:80 color:SP_YELLOW];
    head.x = 35;
    head.y = -70;
    
    [body addChild: head];
    
  5. 按照以下代码添加一个用于arms局部变量的容器:

    SPSprite *arms = [[SPSprite alloc] init];
    
    [body addChild:arms];
    
  6. 按照以下代码添加一个用于legs局部变量的容器:

    SPSprite *legs = [[SPSprite alloc] init];
    legs.y = 140;
    
    [body addChild:legs];
    
  7. 按照以下代码添加左臂:

    SPQuad *leftArm = [SPQuad quadWithWidth:100 height:50 color:0x00ff00];
    leftArm.x = -80;
    
    [arms addChild:leftArm];
    
  8. 按照以下代码添加右臂:

    SPQuad *rightArm = [SPQuad quadWithWidth:100 height:50 color:0x00ff00];
    rightArm.x = 130;
    
    [arms addChild:rightArm];
    
  9. 每个手臂都需要一只手。让我们首先按照以下代码添加左手:

    SPQuad *leftHand = [SPQuad quadWithWidth:40 height:50 color:SP_YELLOW];
    leftHand.x = -80;
    
    [arms addChild:leftHand];
    
  10. 现在为右手使用以下代码:

    SPQuad *rightHand = [SPQuad quadWithWidth:40 height:50 color:SP_YELLOW];
    rightHand.x = 190;
    
    [arms addChild:rightHand];
    
  11. 让我们继续制作腿。我们将首先使用以下代码创建左腿:

    SPQuad *leftLeg = [SPQuad quadWithWidth:50 height:150 color:0x0000ff];
    
    [legs addChild:leftLeg];
    
  12. 我们将使用以下代码创建右腿:

    SPQuad *rightLeg = [SPQuad quadWithWidth:50 height:150 color:0x0000ff];
    rightLeg.x = 100;
    
    [legs addChild:rightLeg];
    
  13. 运行示例。

当我们运行示例时,一个由矩形组成的简单纸板木偶娃娃正对着我们,如下面的截图所示:

动手制作纸板木偶娃娃

发生了什么?

在第 1 步中,我们使用了我们已熟悉的Game.m源文件。

首先,我们需要一个容器对象,在这个例子中我们称之为body。在这种情况下,一个四边形不足以满足需求,因为SPQuad不继承自SPDisplayObjectContainer,因此不能向其添加子对象。我们设置了xy属性,这样body元素的内容就出现在屏幕的中间位置。Sparrow 中的坐标系从屏幕的左上角开始,就像在 Flash 或传统应用程序开发中添加控制元素到窗口时的坐标系一样。来自传统图形开发的开发者可能需要一些时间来适应这一点。例如,在 OpenGL 中,y轴是反转的。然后我们将body元素添加到我们的游戏实例中。

在第 3 步中,我们将torso(一个四边形)添加到身体元素中。如果我们没有指定xy属性,它们的默认值是0

之后,我们添加了头部xy属性是相对于父显示对象测量的。因此,如果我们使用负值,这并不一定意味着元素被绘制在屏幕之外。这取决于父显示对象容器的位置。

虽然我们知道我们可以使用十六进制表示法来使用颜色,但我们在这个步骤中使用SP_YELLOW。这和输入0xffff00有相同的效果。

对于胳膊和腿,我们在第 5 步和第 6 步分别添加了每个的容器。SPSprite是最基本的轻量级容器类,当分组对象时应使用。腿的容器已经向下定位了一点点,所以其子元素只需要水平定位。

在剩余的步骤中,我们添加了每个肢体,当我们最终运行应用程序时,我们得到了一个由矩形组成的纸板木偶娃娃。

尝试一下英雄——改进纸板木偶娃娃

我们可以相当大地改进我们的代码;legsarmshands的代码实际上是一样的,但我们分别定义了每个元素。我们可以尝试分组和简化代码一点。

此外,在当前的布局中,手并没有直接连接到娃娃的胳膊上。相反,它们被绑定到arms容器对象上。所以如果我们移动一个胳膊,手不会随着胳膊移动。

下面是一些解决这些问题的想法:

  • 为了将手连接到胳膊上,我们需要至少两个新的容器对象

  • 创建一个纸板木偶娃娃类,其中其元素是继承自显示对象容器的类

解释宏

虽然我们知道我们可以使用十六进制表示法来使用颜色,但 Sparrow 为最常用的颜色提供了一些简写常量。在上一个例子中,我们使用SP_YELLOW代替了0xffff00的颜色黄色。

为了概括,宏是方便的小函数,允许我们在处理重复性任务时简化工作流程。

Objective-C 中的宏是预处理器指令,它们的工作方式与 C 和 C++中的宏相同。在代码编译之前,预处理器会遍历整个代码,并将所有宏的出现替换为宏的结果。

虽然我们可以用十六进制颜色值表示法写出每种颜色,但有时使用 RGB 值可能更有意义。SP_COLOR宏正是这样做的,它将 RGB 颜色转换为十六进制颜色值。

在本节中,我们将探讨不同类型的宏是什么以及如何使用它们。

角度宏

Sparrow 使用弧度来描述其显示对象的旋转。如果我们想用度来计算,我们需要以下宏:

名称 描述 示例
SP_R2D 将弧度转换为度 SP_R2D(PI);``// 180
SP_D2R 将度转换为弧度 SP_D2R(180);``// PI

颜色宏

如果我们需要创建自定义颜色或将现有颜色分解,以下宏将适合我们的目的:

名称 描述 示例
SP_COLOR_PART_ALPHA``SP_COLOR_PART_RED``SP_COLOR_PART_GREEN``SP_COLOR_PART_BLUE 获取颜色的部分值 SP_COLOR_PART_RED(0xff0000);``// 0xff
SP_COLOR 设置 RGB 颜色 SP_COLOR(255, 255, 0);``// 0xffff00
SP_COLOR_ARGB 设置 ARGB 颜色 SP_COLOR_ARGB(128, 255, 255, 0);``// 0x80ffff00

工具函数

让我们看看最后一组不是与角度或颜色相关的宏:

名称 描述 示例
SP_IS_FLOAT_EQUAL 对两个值进行浮点比较。如果为假返回 0,如果为真返回 1。 SP_IS_FLOAT_EQUAL(0.11, 0.12);``// 0
SP_CLAMP 在两个值之间夹紧。第一个参数是初始值。其他两个参数分别是最小值和最大值。 SP_CLAMP(0.6, 1.0, 2.0);``// 1.0
SP_SWAP 交换两个值。 NSUInteger x = 0;``NSUInteger y = 1;``SP_SWAP(x, y, NSUInteger);``// x = 1; y = 0

Sparrow 中的常量

我们已经了解了 SP_YELLOW,那么让我们看看 Sparrow 中定义了哪些常量。

数学

例如,PI 常量在宏中用于将弧度转换为度数,反之亦然。以下是一些 PI 常量的示例:

名称 描述
PI π 的值
PI_HALF π 值的一半
TWO_PI π 值乘以二

颜色

Sparrow 预定义了 16 种颜色以便于使用,因此我们不必每次都使用宏。这些是最基本的颜色,也定义在许多不同的库和框架中,例如,HTML 4.01。以下表格显示了 Sparrow 中预定义的 16 种颜色:

名称 RGB 值 十六进制值
SP_WHITE 255, 255, 255 0xffffff
SP_SILVER 208, 208, 208 0xc0c0c0
SP_GRAY 128, 128, 128 0x808080
SP_BLACK 0, 0, 0 0x000000
SP_RED 255, 0, 0 0xff0000
SP_MAROON 128, 0, 0 0x800000
SP_YELLOW 255, 255, 0 0xffff00
SP_OLIVE 128, 128, 0 0x808000
SP_LIME 0, 255, 0 0x00ff00
SP_GREEN 0, 128, 0 0x008000
SP_AQUA 0, 255, 255 0x00ffff
SP_TEAL 0, 128, 128 0x008080
SP_BLUE 0, 0, 255 0x0000ff
SP_NAVY 0, 0, 128 0x000080
SP_FUCHSIA 255, 0, 255 0xff00ff
SP_PURPLE 128, 0, 128 0x800080

显示对象的操作

现在我们已经在屏幕上有了我们的纸板木偶娃娃,让我们开始操作屏幕上的对象。

在这个例子中,我们将看看如何旋转、缩放和倾斜对象,然后设置这些对象的起点。

动手实践 - 操作显示对象

执行以下步骤来操作我们之前创建的显示对象:

  1. Game.m 中添加一个新的方法,位于我们用来创建身体部分的 init 方法下面:

    - (void)onLegTouch:(SPTouchEvent *)event
    {
      SPTouch *touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];
      if (touch) {
        SPQuad* target = (SPQuad *) event.target;
    
        float currentRotation = SP_R2D(target.rotation);
        currentRoration = currentRotation + 10;
    
        if (currentRotation >= 360.0)
        {
          currentRotation = currentRotation - 360.0;
        }
        target.rotation = SP_D2R(currentRotation);
      }
    }
    
  2. 接下来,我们还需要在初始化器中设置我们腿部的锚点(枢轴),如下所示:

    leftLeg.pivotX = 25;
    leftLeg.pivotY = 10;
    
    rightLeg.pivotX = 25;
    rightLeg.pivotY = 10;
    
  3. 使用以下代码更新腿部位置:

    SPQuad *leftLeg = [SPQuad quadWithWidth:50 height:150 color:0x0000ff];
    [legs addChild:leftLeg];
    leftLeg.x = 25;
    
    SPQuad *rightLeg = [SPQuad quadWithWidth:50 height:150 color:0x0000ff];
    rightLeg.x = 125;
    [legs addChild:rightLeg];
    
  4. 使用以下代码为腿部设置事件监听器:

    [rightLeg addEventListener:@selector(onLegTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
    [leftLeg addEventListener:@selector(onLegTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
    
  5. 让我们添加另一个方法,当触摸到我们的纸板木偶头部时应该被调用。这个方法应该在初始化器和 onLegTouch 方法下面:

    - (void)onHeadTouch:(SPTouchEvent *)event
    {
        SPTouch *touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];
        if (touch) {
            SPQuad* target = (SPQuad *) event.target;
            target.scaleX = (target.scaleX == 1.0) ? 1.5 : 1.0;
            target.scaleY = (target.scaleY == 1.0) ? 1.5 : 1.0;
        }
    }
    
  6. 我们还需要设置头部的主轴(枢轴):

    head.pivotX = head.width / 2;
    head.pivotY = head.height / 2;
    
  7. 让我们根据以下代码更新头部位置:

    SPQuad *head = [SPQuad quadWithWidth:80 height:80 color:SP_YELLOW];
    head.x = 75;
    head.y = -30;
    [body addChild: head];
    
  8. 让我们添加一个事件监听器,用于头部,如下所示:

    [head addEventListener:@selector(onHeadTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
    
  9. 添加另一个方法,当触摸到手臂时应该被调用。这将在以下代码中展示:

    - (void)onArmsTouch:(SPTouchEvent *)event
    {
        SPTouch *touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];
        if (touch) {
            SPQuad* target = (SPQuad *) event.target;
            target.skewX = (target.skewX == SP_D2R(20)) ? SP_D2R(0) : SP_D2R(20);
            target.skewY = (target.skewY == SP_D2R(20)) ? SP_D2R(0) : SP_D2R(20);
        }
    }
    
  10. 将事件监听器绑定到这个新添加的方法上:

    [arms addEventListener:@selector(onArmsTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
    
  11. 运行示例并触摸我们的纸板木偶的一些肢体。现在我们应该在屏幕上看到我们的纸板木偶,如果我们触摸手臂、腿或头部,我们会看到这些对象旋转、倾斜或缩放。操作时间 – 操作显示对象

发生了什么?

在第 1 步中,我们定义了一个方法,当触摸到一条腿时应该被调用。我们需要获取触摸事件的引用,在 Sparrow 中,这被描述为 SPTouchEvent。为了获取触摸实例(SPTouch),我们在触摸开始阶段查找任何对象的触摸。每个触摸会经过三个阶段:首先 SPTouchPhaseBegan,然后 SPTouchPhaseMoved,最后 SPTouchPhaseEnded。我们需要检查触摸是否有效,因为可以通过在 if-语句中使用它作为条件来检查对象是否被触摸。事件当前的目标在 event.target 中可用,尽管它需要被转换为适当的显示对象类型,在这个案例中是 SPQuad

然后我们获取了被触摸对象的当前旋转,并将其增加 10 度。新的旋转将被设置为四边形。如果旋转大于 360 度,我们将减去 360 度。

显示对象的默认原点是其自身的左上角。如果我们想要不同的原点,我们需要使用显示对象的 pivotXpivotY 属性来修改它。

修改原点也会影响元素的位置;因此,如果我们想要保持相同的位置,我们需要将枢轴值添加到位置值中,这就是第 3 步中发生的事情。

在第 4 步中,我们为每条腿添加了一个事件监听器,所以当我们实际触摸到腿时,会发生某些事情。当使用 addEventListener 时,我们正在绑定一个在事件触发时将被调用的选择器,在我们的案例中,是 SP_EVENT_TYPE_TOUCH。如果指定的对象(在这个步骤中是 self,即 Game 实例)上发生任何触摸,这个事件将被调用。当使用 addEventListener 时,可以将多个选择器绑定到同一个事件。

对于下一步,我们添加了一个用于触摸我们纸板木偶头部的函数。我们还需要执行上一次相同的触摸检查和目标投射。这次当我们触摸头部时,它应该放大到原始大小的 150%,如果我们再次触摸头部,它将缩小回原始大小。

在第 6 步中,我们将原点设置为元素的中心。在第 7 步中,我们需要相应地更新位置,而在第 8 步中,我们将方法绑定到head元素。

我们定义的最后一个方法是我们触摸任何arms元素时会发生什么。如果我们将触摸事件绑定到SPSprite实例,它将为其所有子元素触发。同样的触摸检查也适用于此方法。第一次触摸时,我们将元素倾斜 20 度,当元素再次被触摸时,将其重置到原始状态。

在这里我们使用三元表达式来检查目标是否已经倾斜。我们检查括号内的条件。如果条件评估为true,则执行问号后面的语句;否则,执行冒号后面的语句。其优势是三元表达式是一个表达式,可以一次性赋值给一个变量。如果我们使用if语句,它将转换为以下代码:

if (target.skewX == SP_D2R(20)) {
  target.skewX = SP_D2R(0);
} else {
  target.skewX = SP_D2R(20);
}

if (target.skewY == SP_D2R(20)) {
  target.skewY = SP_D2R(0);
} else {
  target.skewY = SP_D2R(20);
}

第 10 步中,我们将onArmsTouch方法绑定到了arms对象。

当我们运行示例并触摸各种元素时,我们将看到所有的倾斜、缩放和旋转动作。

快速问答

Q1. 显示列表/树的另一个术语是什么?

  1. 显示块

  2. 显示对象

  3. 场景图

Q2. 什么是 Sparrow 阶段?

  1. 一个游戏关卡

  2. 显示树的根元素

  3. Game类上的显示对象

Q3. 宏是什么?

  1. 在运行时评估的函数

  2. 在编译前评估的预处理器指令

  3. 动态常量

摘要

在本章中,我们学到了很多关于如何在屏幕上显示对象以及如何操作它们的知识。

具体来说,我们涵盖了如何在屏幕上显示对象以及使用 Sparrow 提供的宏和常量。另一个重要方面是我们操作了我们在屏幕上绘制的对象。

我们还涉及了一些主题,例如 Sparrow 阶段,并概述了 Sparrow API 的工作方式。

现在我们知道了如何在屏幕上绘制对象,我们准备学习关于资源和场景管理的内容——这是下一章的主题。

第三章。管理资产和场景

在前一章中,我们在屏幕上绘制了第一个显示对象,在我们的案例中是四边形。我们用四边形制作了一个纸板木偶娃娃,并学习了如何使用宏。在开发我们的海盗游戏之前,我们还需要了解最后一件事。在本章中,我们将学习如何管理我们的资产,例如图像、声音和其他类型的文件。我们还将学习如何将元素分组到场景中并显示这些场景。

与资产一起工作

当我们开发游戏时,我们加载文件。我们可能还加载了很多图像。这些图像显示在屏幕上,是任何 2D 游戏的图形。

我们还需要加载音文件以播放音乐和音效。其他通用文件包括文本文件,这些文件可以是本地化文件或游戏信息文件,例如敌人的生命值、攻击强度或类似影响游戏玩法的数据。

与游戏相关的数据可能包括保存的游戏和关卡数据。这种与游戏玩法相关的数据不一定总是纯文本;在某些情况下,它们是二进制文件或使用 XML 或 JSON 等标记语言。在 iOS 和 Mac 世界中,PLIST 文件格式非常常见,包含一种特殊的 XML 格式。

与资产一起工作

在一些游戏中,游戏引擎和游戏框架在处理与游戏玩法相关的数据时会更进一步,以实现更动态的效果。它们允许通过 Lua 和 JavaScript 等语言进行脚本编写。这些脚本在运行时加载和执行。

管理我们的资产

现在我们知道了什么是资产,我们如何为我们的游戏管理它们?在我们到达那里之前,让我们看看我们已经知道的内容以及有效加载资产的前提条件。

首先,我们知道存在不同类型的资产,可以是纯文本文件或二进制文件。

需要记住的一件事是现在移动设备上的内存。虽然它与几年前桌面设备的内存相同,但并非所有这些内存都保留给我们应用程序。我们还应该记住,磁盘上资产的尺寸在内存中可能不与压缩文件相同,特别是如果文件内容在磁盘上被压缩,但在内存中需要解压缩的话。

因此,我们可以做几件事情,如下所示:

  • 限制我们正在加载的资产数量;这可能是一个难题,因为游戏可能需要大量的资产

  • 限制当前加载到内存中的资产数量

  • 缓存已加载的资产,以便我们不会在内存中有两次相同的内容

让我们创建一个基类来管理一组资产。

行动时间 - 创建基类

要创建一个用于管理我们资产的基类,我们需要使用以下步骤:

  1. 如果 Xcode 游戏模板尚未打开,请打开它,然后在Classes文件夹上右键单击,选择New Group,并将组重命名为Assets

  2. 右键点击Assets组并选择新建文件...

  3. 选择Objective-C 类并点击下一步

  4. 在名称字段中输入AssetsDictionary,从子类为条目中选择NSObject,然后点击下一步

  5. 在下一个对话框中,点击创建

  6. 打开AssetsDictionary.h文件。

  7. 添加一个名为_dict的实例变量,它是一个指向NSMutableDictionary的指针,如下面的代码所示:

    @interface AssetsDictionary : NSObject {
        NSMutableDictionary *_dict;
    }
    
  8. 添加一个名为verbose的属性,其类型为BOOL,如下面的代码所示:

    @property BOOL verbose;
    
  9. 添加一个名为registerAsset的实例方法,如下面的代码所示:

    -(id) registerAsset:(NSString *)name withContent:(id)content;
    
  10. 添加另一个名为unregisterAsset的实例方法,如下面的代码所示:

    -(void) unregisterAsset:(NSString *)name;
    
  11. 添加一个名为clear的第三个实例方法,如下面的代码所示:

    -(void) clear;
    
  12. 现在切换到AssetsDictionary.m

  13. 添加一个初始化器,内容如下:

    - (id)init
    {
        if ((self = [super init])) {
            _dict = [[NSMutableDictionary alloc] init];
            _verbose = NO;
        }
    
        return self;
    }
    
  14. 使用以下代码实现registerAsset方法:

    -(id) registerAsset:(NSString *)name withContent:(id)content
    {
      id result;
    
      if ([_dict objectForKey:name] == nil) {
        [_dict setObject:content forKey:name];
    
        result = content;
    
        if (self.verbose) {
          NSLog(@"Asset %@ does not exist. Registering.", name);
        }
      } else {
        result = [_dict objectForKey:name];
    
        if (self.verbose) {
          NSLog(@"Asset %@ already exists. Using cached value.", name);
        }
      }
    
      return result;
    }
    
  15. 实现名为unregisterAsset的方法:

    -(void) unregisterAsset:(NSString *)name
    {
        if ([_dict objectForKey:name] != nil) {
            [_dict removeObjectForKey:name];
        }
    }
    
  16. 实现一个名为clear的方法,该方法应该重置缓存:

    -(void) clear
    {
        [_dict removeAllObjects];
    }
    
  17. 切换到Game.m文件。

  18. import部分导入AssetsDictionary.h文件:

    #import "AssetsDictionary.h"
    
  19. init方法中,添加以下行:

    AssetsDictionary* assets = [[AssetsDictionary alloc] init];
    assets.verbose = YES;
    [assets registerAsset:@"myAsset" withContent:@"test"];
    [assets registerAsset:@"myAsset" withContent:@"test"];
    
  20. 运行示例,你将得到以下输出:行动时间 – 创建基类

发生了什么?

在步骤 1 中,我们从上一章结束的地方打开了我们的 Xcode 模板。然后,我们创建了一个新组,将所有与我们的资源管理相关的所有内容都放在里面。最后,我们将新创建的组重命名。

在步骤 2 中,我们创建了一个新文件。在步骤 3 中,我们从弹出的对话框中选择Objective-C 类。我们希望类名为AssetsDictionary,这是我们在步骤 4 中输入的;我们还确认了它将在硬盘上的保存位置,这是在步骤 5 中完成的。

接着,我们打开了头文件并创建了一个实例变量来存储资源的名称和内容。为此,我们需要它是一个NSMutableDictionary的实例。Objective-C Cocoa 类如NSDictionary可以是可变的或不可变的;可变类的内容可以改变,而不可变类的值固定为声明对象时使用的值。

虽然我们将接口部分放在了头文件中,但也可以将其放在实现部分之前。

在第 8 步中,我们添加了一个名为verbose的属性,其类型为BOOL。如果这个属性设置为YES,一旦一个资源被注册,它应该写一条消息告诉我们该资源是否已经在缓存中。可以说其默认值应该是NO,这样我们的控制台消息框就不会被消息填满。

我们需要定义我们的方法来处理资产的注册和服务。它接受两个参数:资产的名称和资产的内容。它返回资产的内容。由于资产的内容可以是任何东西——但在大多数情况下是某种类的实例——因此 id 类型在这里似乎是最佳选择。id 类型可以代表任何类实例;如果用技术术语来说,它被称为动态类型。

然后,我们定义了两个方法;第一个解释了如何从缓存中删除单个资产(步骤 10),第二个方法解释了如何清除所有资产(步骤 11)。

我们的头文件已经完成;现在,让我们着手实际的实现。首先,切换到 AssetsDictionary.m 文件。在第 13 步中,我们添加了一个初始化器,它为我们做了以下两件事:

  • 设置 _dict 字典。

  • 使用其实例变量 _verboseverbose 属性设置为 NO。这通常不是必需的,因为 NOBOOL 的默认值。

在下一步中,我们实现了 registerAsset 方法。如果键——我们的第一个参数——在字典中不存在,我们将其添加到字典中并返回资产的内容。如果它存在,我们从字典中查找值并返回它。在这两种情况下,如果 verbose 属性设置为 YES,我们将打印一条适当的消息来反映资产当前的状态。

在第 15 步中,我们定义了一个方法,允许我们从缓存中删除单个资产。而在第 16 步中,我们定义了一个方法来清除整个缓存。

现在,AssetsDictionary 类已经准备好投入使用,让我们对其进行测试。在第 17 步中,我们切换到 Game.m 文件,并在第 18 步中随后导入了 AssetsDictionary 头文件。

接下来,在我们的 Game 类的初始化器中,我们定义了 AssetsDictionary 类的一个实例,将 verbose 属性设置为 YES,并将相同的资产注册两次以查看它是否会被正确缓存。在最后一步中,我们运行了示例并查看了控制台中的输出。

来吧,英雄

虽然这个类适用于我们的目的,但我们还可以进一步改进 AssetsDictionary 类。以下是一些建议:

  • 当获取资产的缓存值时,我们会从字典中查找两次该值:第一次是在检查键是否在字典中时,第二次是在获取实际值时。如果资产数量巨大,这可能会在将资产加载到游戏中时产生性能惩罚。

  • 尝试使用 NSCache 而不是 NSMutableDictionary

  • 如果我们想显示进度条以查看当前加载过程已进行到什么程度,我们需要一种方法来获取当前已注册的资产数量。

  • 我们还可以有一个 exists 方法,用于检查资产是否已经被注册,并返回检查结果。

  • 我们可以添加更多接受 NSDictionary 的初始化器,例如。

创建一个纹理管理器

当我们在 Sparrow 中加载图像时,我们通常希望它是一个纹理。纹理是构成图像的像素信息。从概念上讲,它与 ActionScript 3 中的BitmapData类类似。如果我们想将其显示在屏幕上,它需要放在一个几何表示上,这通常是一个四边形。

我们希望我们的纹理管理器以这种方式工作:传入一个文件名,该文件名将被转换为纹理,然后对我们可用。

让我们为纹理管理器使用AssetsDictionary

时间行动 – 管理我们的纹理

要创建我们的纹理管理器,请查看以下步骤:

  1. Assets组中添加一个新的名为TextureManager的 Objective-C 类,它继承自AssetsDictionary

  2. 添加一个实例方法,它将使用文件名注册一个纹理并返回正确的值,如下所示:

    -(SPTexture *) registerTexture:(NSString *)filename;
    
  3. 切换到TextureManager.m并实现以下内容的方法:

    -(SPTexture *) registerTexture:(NSString *)filename
    {
        if ([_dict objectForKey:filename] == nil) {
        return (SPTexture *) [self registerAsset:filename withContent:[SPTexture textureWithContentsOfFile:filename]];
      } else {
        return (SPTexture *) [self registerAsset:filename withContent:nil];
      }
    }
    
  4. 切换到Game.m文件,并在import部分将AssetsDictionary.h导入替换为TextureManager.h文件。

  5. init方法中,将本章中较早进行的AssetsDictionary测试替换为以下行:

    TextureManager* textureAssets = [[TextureManager alloc] init];
    textureAssets.verbose = YES;
    [textureAssets registerTexture:@"Default.png"];
    [textureAssets registerTexture:@"Default.png"];
    
  6. 运行示例,你将得到以下输出:Time for action – managing our textures

发生了什么?

在第一步中,我们创建了一个TextureManager类,它是AssetsDictionary的子类。在第二步中,我们定义了registerTexture实例方法,我们在下一个步骤中实现了它。这一行发生了很多事情,解释如下:

  1. 我们使用文件名的内容创建了一个SPTexture实例。

  2. 我们将此实例注册到我们之前实现的registerAsset中。

  3. 我们返回了被调用方法的結果。

  4. 由于结果是id类型,我们将其转换为SPTexture——这是我们想要的类型。

现在,我们继续切换到Game.m文件。我们将#import "AssetsDictionary.h"行替换为#import "TextureManager.h"

然后,我们删除了测试registerAsset方法的示例。之后,我们设置了相同的测试;然而,这次我们使用TextureManager类和registerTexture方法。我们加载了位于Resources文件夹中的Default.png文件,它目前只是一个黑色图像。Default.png文件是 Sparrow 原始骨架模板的一部分。

当我们运行示例时,它第一次从文件中加载图像,然后使用缓存的結果。

创建声音管理器

现在我们有了纹理管理器,让我们创建一个与之前的代码非常相似的声音管理器。

时间行动 – 实现声音管理器

要实现声音管理器,只需遵循以下简单步骤:

  1. Assets组中添加一个新的名为SoundManager的 Objective-C 类。

  2. 添加一个实例方法,使用文件名注册声音并返回正确的值,如下所示:

    -(SPSound *) registerSound:(NSString *)filename;
    
  3. 使用上一步的方法,以下内容:

    -(SPSound *) registerSound:(NSString *)filename
    {
        if ([_dict objectForKey:filename] == nil) {
        return (SPSound *) [self registerAsset:filename withContent:[SPSound soundWithContentsOfFile:filename]];
      } else {
        return (SPSound *) [self registerAsset:filename withContent:nil];
      }
    }
    

发生了什么?

在第一步中,我们创建了一个 SoundManager 类,它是 AssetsDictionary 的子类。在第二步中,我们定义了 registerSound 方法,我们在下一步中实现了该方法;该方法从文件中加载声音并返回已注册资产的结果。

它与 TextureManager 非常相似,但不同之处在于我们使用 SPSound 加载声音,而不是纹理和 SPTexture

目前,我们只对声音和声音管理做了这些,因为我们没有要加载的声音资产。

创建文件管理器

现在,我们几乎有了我们想要使用的所有类型资产的经理。我们最后需要的是我们数据的管理器。我们知道数据资产可以是几乎所有东西,因此我们需要缩小管理数据资产的使用场景。让我们看看我们现在需要什么:

  • 加载纯文本文件总是一件有用的功能。它可能包含游戏文本或基本关卡布局。

  • NSDictionaryNSMutableDictionary 是我们已使用并将继续使用的类,用于存储数据。我们是否可以加载一个文件,并将其内容转换为类似于 NSDictionary 的结构?JSON 格式与我们找到的 NSDictionary 结构非常相似,幸运的是,自从 iOS 5 以来,我们可以将 JSON 文件转换为 NSDictionary,而无需任何第三方库。

行动时间 – 管理剩余的文件类型

要创建文件资产管理器,请按照以下步骤操作:

  1. 添加一个新的 Objective-C 类 FileManager,它是 Assets 组中 AssetsDictionary 的子类。

  2. 定义一个名为 registerPlainText 的实例方法,如下所示:

    -(NSString *) registerPlainText:(NSString *)filename;
    
  3. 定义另一个实例方法 registerDictionaryFromJSON,如下所示:

    -(NSDictionary *) registerDictionaryFromJSON:(NSString *)filename;
    
  4. 实现 registerPlainText 方法,以下内容:

    if ([_dict valueForKey:filename] == nil) {
      NSString *path = [[NSBundle mainBundle] pathForResource:filename];
      NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
    
      return (NSString *) [self registerAsset:filename withContent:content];
    } else {
      return (NSString *) [self registerAsset:filename withContent:nil];
    }
    
  5. 实现 registerDictionaryFromJSON 方法,以下内容:

    if ([_dict valueForKey:filename] == nil) {
      NSString *path = [[NSBundle mainBundle] pathForResource:filename];
    
      NSData *data = [NSData dataWithContentsOfFile:path];
      NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:nil];
    
      return (NSDictionary *) [self registerAsset:filename withContent:dict];
    } else {
      return (NSDictionary *) [self registerAsset:filename withContent:nil];
    }
    
  6. 通过在 Resources 文件夹上右键单击并选择 New File... 来将 example.json 文件添加到 Resource 文件夹中。从选项卡中选择 Other 并创建一个空文件。用以下内容填充它:

    {
        "name": "example",
        "a": 5,
        "b": 6
    }
    
  7. 现在,将 example.txt 添加到 Resource 文件夹中,其内容如下:

    Hello from text file.
    
  8. 现在我们已经设置了所有数据和 FileManager 类,让我们来试一试。切换到 Game.m,删除测试我们之前资产管理器的代码片段,并导入 FileManager 头文件。

  9. 将以下代码段添加到初始化方法中:

    FileManager* fileAssets = [[FileManager alloc] init];
    fileAssets.verbose = YES;
    NSDictionary *data = [fileAssets registerDictionaryFromJSON:@"example.json"];
    
    NSLog(@"Printing values from dictionary:");
    NSLog(@"%@", data[@"name"]);
    NSLog(@"%@", data[@"a"]);
    NSLog(@"%@", data[@"b"]);
    
    NSLog(@"Loading from text file and displaying as a string:");
    NSLog(@"%@", [fileAssets registerPlainText:@"example.txt"]);
    NSLog(@"%@", [fileAssets registerPlainText:@"example.txt"]);
    
  10. 运行示例,并查看以下输出:行动时间 – 管理剩余的文件类型

发生了什么?

在第一步中,我们创建了一个 FileManager 类,它是 AssetsDictionary 的子类。

在接下来的两个步骤中,我们定义了两个实例方法:一个用于加载纯文本文件,另一个用于加载 JSON 文件。

在第 4 步中,我们实现了 registerPlainText 方法。我们本可以将所有内容放在一行中,但那样会使代码显得有些拥挤,难以阅读。因此,如果资产已注册,我们使用 registerAsset 方法返回它。这次我们不需要传递内容,因为内容已经在字典中。如果没有注册,我们需要首先获取文件名路径。像从 Resource 文件夹中加载的每个资源一样,不借助第三方库,我们需要获取确切的文件位置。如果传递一个文件名,[[NSBundle mainBundle] pathForResource] 方法会给出确切的文件位置。主包代表当前应用程序的应用程序包。在下一行中,我们将文件加载到一个 NSString 中,编码为 UTF-8。然后,我们返回通过 registerAsset 方法传递的结果。

在下一步中,我们实现了 registerDictionaryFromJSON 方法,它的工作方式与 registerPlainText 方法几乎相同。然而,我们不是将文件加载到 NSString 中,而是使用了一个 NSData 对象。然后,我们通过 NSJSONSerialization 类转换文件内容,该类提供了 JSONObjectWithData 方法。现在我们实际上不需要传递任何特殊选项。

我们添加了一个 example.json 文件,它有一个字符串值的键和两个具有数值的键。在 JSON 结构中,键必须用双引号括起来,并且是一个字符串。值可以是数组、字符串、数字、布尔值、null 或对象。如果值是一个对象,它可以有自己的键和值。因此,它可以很好地映射 NSDictionary 的结构。

注意

关于 JSON 格式的更多信息,请查看 json.org/

在下一步中,我们添加了一个 example.txt 文件并添加了一些内容。

在第 8 步中,我们从上一个示例中删除了所有代码片段,并导入了 FileManager 头文件。我们像上一个示例中那样设置了文件管理器。然后,我们用 example.json 作为参数调用了 registerDictionaryFromJSON 方法。我们已经知道,我们可以通过 objectForKey 方法从 NSDictionary 实例中访问值,但我们也可以使用更简洁、更容易阅读的方括号表示法。只需记住,键的方括号表示法需要一个 NSString 实例。另一方面,值可以是任何对象或 @ 文字,例如 @YES@1@"MyValue"。然后,我们加载了 example.txt 文件,并使用 NSLog 显示它。

当我们运行示例时,我们看到了资产何时以及如何被加载,以及加载的资产结果。

我们的 FileManager.h 文件将看起来像以下这样:

#import "AssetsDictionary.h"

@interface FileManager : AssetsDictionary

-(NSString *) registerPlainText:(NSString *)filename;
-(NSDictionary *) registerDictionaryFromJSON:(NSString *)filename;

@end

我们的 FileManager.m 文件将看起来像以下这样:

#import "FileManager.h"

@implementation FileManager

-(NSString *) registerPlainText:(NSString *)filename
{
    if ([_dict valueForKey:filename] == nil) {
        NSString *path = [[NSBundle mainBundle] pathForResource:filename];
        NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];

        return (NSString *) [self registerAsset:filename withContent:content];
    } else {
        return (NSString *) [self registerAsset:filename withContent:nil];
    }
}

-(NSDictionary *) registerDictionaryFromJSON:(NSString *)filename
{
    if ([_dict valueForKey:filename] == nil) {
        NSString *path = [[NSBundle mainBundle] pathForResource:filename];

        NSData *data = [NSData dataWithContentsOfFile:path];
        NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:nil];
        return (NSDictionary *) [self registerAsset:filename withContent:dict];
    } else {
        return (NSDictionary *) [self registerAsset:filename withContent:nil];
    }
}

@end

来试试吧,英雄

我们的管理器工作方式与我们期望的完全一致。如果我们想要将相同的资产作为纯文本加载并从 JSON 文件转换为NSDictionary,则存在一个小问题。由于我们只使用单个字典来处理所有文件元素,如果我们首先使用registerDictionaryFromJSON方法加载一个资产,然后使用registerPlainText方法再次加载相同的资产,我们将得到将NSDictionary转换为NSString,而不是直接将文本文件加载并添加到字典中作为NSString

基本错误处理

对于文件管理器,我们还没有设置任何错误处理。因此,如果文件不存在,应用程序可能会崩溃,我们将无法猜测为什么没有任何事情发生,没有任何线索说明如何进行。现在,我们将向registerPlainText方法添加一些错误处理。

是时候开始基本的错误处理了

为了添加一些基本的错误处理,请参考以下步骤:

  1. 打开FileManager.m文件。

  2. 更新registerPlainText方法以匹配以下代码片段:

    -(NSString *) registerPlainText:(NSString *)filename
    {
        if ([_dict valueForKey:filename] == nil) {
        NSError *error;
    
            NSString *path = [[NSBundle mainBundle] pathForResource:filename];
            NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:&error];
    
        if (error != nil) {
     NSLog(@"Error while loading plain text file: %@", error);
     }
    
            return (NSString *) [self registerAsset:filename withContent:content];
        } else {
            return (NSString *) [self registerAsset:filename withContent:nil];
        }
    }
    

刚才发生了什么?

虽然 Objective-C 中提供了 try-catch 块,但通常不建议使用它们,因为它们相当慢,如果嵌套太深,处理起来也会变得相当困难。

我们首先需要一个指向NSError的指针作为错误对象。在加载文本文件时,我们应用错误处理。如果在加载文件时出现任何错误,错误对象将不再为 nil。如果是这种情况,我们将记录错误。

尝试一下英雄

这是目前最基本的错误处理。以下是一些改进建议:

  • 捕获无法加载 JSON 文件的情况

  • 捕获处理无效 JSON 文件的情况

  • 为文件管理器添加一个NSError参数以注册资产

将所有内容整合在一起

我们现在有几个不同的资产管理器。是时候将它们整合在一起,这样我们就不必在想要使用资产时实例化不同的管理器。

是时候创建资产容器类了

为了将所有的资产管理器放入一个单独的类中,请按照以下步骤操作:

  1. Assets组内创建一个名为Assets的新 Objective-C 类,继承自NSObject

  2. 为每种资产定义一个静态方法,如下所示:

    +(SPTexture *) texture:(NSString *)filename;
    +(SPSound *) sound:(NSString *)filename;
    +(NSString *) plainText:(NSString *)filename;
    +(NSDictionary *) dictionaryFromJSON:(NSString *)filename;
    
  3. Asset.m文件中,导入所有资产管理器,如下所示:

    #import "TextureManager.h"
    #import "SoundManager.h"
    #import "FileManager.h"
    
  4. 对于每个管理器,添加一个适当的类型的静态变量,并将其值设置为nil

    static TextureManager *textureAssets = nil;
    static SoundManager *soundAssets = nil;
    static FileManager *fileAssets = nil;
    
  5. 我们需要重写内部的静态initialize方法。使用以下代码片段:

    +(void) initialize
    {
        if (!textureAssets) {
            textureAssets = [[TextureManager alloc] init];
        }
    
        if (!soundAssets) {
            soundAssets = [[SoundManager alloc] init];
        }
    
        if (!fileAssets) {
            fileAssets = [[FileManager alloc] init];
        }
    }
    
  6. 使用每个资产管理器中的正确实例方法实现每个静态方法,如下所示:

    +(SPTexture *) texture:(NSString *)filename
    {
        return [textureAssets registerTexture:filename];
    }
    
    +(SPSound *) sound:(NSString *)filename
    {
        return [soundAssets registerSound:filename];
    }
    
    +(NSString *) plainText:(NSString *)filename
    {
        return [fileAssets registerPlainText:filename];
    }
    
    +(NSDictionary *) dictionaryFromJSON:(NSString *)filename
    {
        return [fileAssets registerDictionaryFromJSON:filename];
    }
    
  7. 切换到Game.m文件,并将之前的示例更新为使用静态的Assets类:

    NSDictionary *data = [Assets dictionaryFromJSON:@"example.json"];
    
    NSLog(@"Printing values from dictionary:");
    NSLog(@"%@", data[@"name"]);
    NSLog(@"%@", data[@"a"]);
    NSLog(@"%@", data[@"b"]);
    
    NSLog(@"Loading from text file and displaying as a string:");
    NSLog(@"%@", [Assets plainText:@"example.txt"]);
    NSLog(@"%@", [Assets plainText:@"example.txt"]);
    
  8. 运行示例。当我们检查控制台时,我们应该看到以下截图所示的内容:动手实践 – 创建资产容器类

发生了什么?

在第一步中,我们创建了一个 Assets 类,它是 NSObject 的子类。

我们为每个资产管理器实例方法定义了一个静态方法,例如 texture 用于 registerTexturesound 用于 registerSound。然后,我们继续到实现部分。

对于每个资产管理器,我们定义了一个静态变量:textureAssets 用于我们的 TextureManager 类,textureSounds 用于我们的 SoundManager 类,等等。我们将这些实例设置为 nil

我们已经重写了内部的 NSObject 初始化方法,该方法在内部调用一次,我们不需要调用它。

注意

关于 NSObject 的初始化方法如何工作的更多信息,可以在 Apple 文档中找到,网址为 developer.apple.com/library/mac/documentation/cocoa/reference/foundation/classes/NSObject_Class/Reference/Reference.html#//apple_ref/occ/clm/NSObject/initialize

initialize 方法中,如果其值为 nil,则分配和初始化每个实例。

在实现下一步中的每个静态方法时,我们需要调用相应的实例方法,例如纹理方法中的 [textureAssets registerTexture:filename],我们不应忘记我们必须返回实例方法的值。

要在我们的游戏文件中使用静态 Assets 类,我们需要更新对头文件的引用,并使用静态类中的 dictionaryFromJSONplainText 方法。

当我们运行示例时,我们看到了与之前示例类似的结果,其中我们通过 FileManager 加载文件,但在这个例子中,我们没有关于资产状态的任何消息,因为 verbose 标志未设置为 YES

我们的 Assets.h 文件将如下所示:

#import <Foundation/Foundation.h>

@interface Assets : NSObject

+(SPTexture *) texture:(NSString *)filename;
+(SPSound *) sound:(NSString *)filename;
+(NSString *) plainText:(NSString *)filename;
+(NSDictionary *) dictionaryFromJSON:(NSString *)filename;

@end

我们的 Assets.m 文件将如下所示:

#import "Assets.h"
#import "TextureManager.h"
#import "SoundManager.h"
#import "FileManager.h"

static TextureManager *textureAssets = nil;
static SoundManager *soundAssets = nil;
static FileManager *fileAssets = nil;

@implementation Assets

+(void) initialize
{
    if (!textureAssets) {
        textureAssets = [[TextureManager alloc] init];
    }

    if (!soundAssets) {
        soundAssets = [[SoundManager alloc] init];
    }

    if (!fileAssets) {
        fileAssets = [[FileManager alloc] init];
    }
}

+(SPTexture *) texture:(NSString *)filename
{
    return [textureAssets registerTexture:filename];
}

+(SPSound *) sound:(NSString *)filename
{
    return [soundAssets registerSound:filename];
}

+(NSString *) plainText:(NSString *)filename
{
    return [fileAssets registerPlainText:filename];
}

+(NSDictionary *) dictionaryFromJSON:(NSString *)filename
{
    return [fileAssets registerDictionaryFromJSON:filename];
}

@end

在我们继续进行场景管理之前,让我们看看如何在显示图像时使用静态 Assets 类。

动手实践 – 显示图像

要显示图像,我们只需遵循以下步骤:

  1. Game 初始化方法内部,添加以下代码:

    SPImage* image = [SPImage imageWithTexture:[Assets texture:@"Default.png"]]; 
    
  2. 在初始化方法底部,将图像添加到 Game 类的显示树中。

  3. 运行示例,你将看到以下内容:动手实践 – 显示图像

发生了什么?

如我们所知,我们需要 SPImage 类来显示纹理。它可以与 SPQuad 相比,但它不仅仅显示颜色,还在自身上显示纹理。我们使用 Assets 类从 Resources 文件夹获取 Default.png 图像。

在下一步中,我们使用addChild方法将图像添加到游戏类的显示树中。运行示例后,我们应该看到我们的纸板木偶不再可见,因为我们刚刚加载的黑色图像显示在纸板木偶的上方。

我们的Game.m文件应该包含以下内容:

#import "Game.h" 
#import "Assets.h"

@implementation Game

- (id)init
{
    if ((self = [super init]))
    {
        Sparrow.stage.color = 0xffffff;

        SPImage* image = [SPImage imageWithTexture:[Assets texture:@"Default.png"]];

    NSDictionary *data = [Assets dictionaryFromJSON:@"example.json"];

    NSLog(@"Printing values from dictionary:");
    NSLog(@"%@", data[@"name"]);
    NSLog(@"%@", data[@"a"]);
    NSLog(@"%@", data[@"b"]);

    NSLog(@"Loading from text file and displaying as a string:");
    NSLog(@"%@", [Assets plainText:@"example.txt"]);
    NSLog(@"%@", [Assets plainText:@"example.txt"]);

        // Our whole cardboard puppet doll code here

        [self addChild:image];
    }
    return self;
}

@end

来试试英雄

现在我们已经完成了资产管理系统的设置,让我们讨论一下我们可以改进设置的一些方法,它们是:

  • 目前,如果我们将文本文件传递给纹理管理器,它可能会加载,但当我们尝试在屏幕上显示纹理时,可能会得到意外的结果。我们可以检查文件扩展名,并且只有当它具有正确的文件扩展名时才加载资产。

  • 如果我们再进一步,我们可以尝试通过其 MIME 类型自动检测我们想要加载的资产,或者如果这还不够,我们可以尝试通过魔数字节检测文件格式。

  • 我们测试了资产管理器的功能,但如果我们想要更彻底的测试,我们可能需要求助于单元测试。

场景是什么?

在一个典型的游戏中,我们有一个主菜单、一个选项菜单,可能还有一个致谢屏幕,当然还有游戏本身。我们可以将这些所有内容放在一个文件中,但过一段时间后,这将变得难以维护。因此,将它们分组到单独的实体中会是一个好主意,在我们的例子中,这些实体就是场景。

在依赖于许多级别的游戏中,例如点对点游戏,为每个级别创建场景也是一个好主意。

是时候实现场景类了

要创建一个场景类,请按照以下步骤操作:

  1. 创建一个名为Scene的新组。

  2. 创建一个新的名为Scene的 Objective-C 类,它从SPSprite类派生,并将其保存在Scene组中。

  3. 添加一个名为guiLayer的属性,它是一个SPSPrite类型,如下面的代码所示:

    @property SPSprite* guiLayer;
    
  4. 添加另一个属性name,它是一个NSString,如下面的代码所示:

    @property NSString* name;
    
  5. 添加一个名为director的第三个属性,它是一个id,如下面的代码所示:

    @property id director;
    
  6. 添加一个初始化器来初始化类的属性:

    -(id) init
    {
        if ((self = [super init])) {
            self.guiLayer = [[SPSprite alloc] init];
            self.director = nil;
            self.name = @"scene";
        }
    
        return self;
    }
    
  7. 添加一个设置场景名称的第二个初始化器;这应该被命名为initWithName

    -(id) initWithName:(NSString *) name
    {
        self = [self init];
        self.name = name;
    
        return self;
    }
    

刚才发生了什么?

目前,我们没有任何场景,所以我们还不能运行示例。

首先,我们设置了Scene类,它需要是SPSprite的子类,因为它需要被添加到某个地方,并且我们希望允许所有类型的显示对象被添加到scene实例中。

我们定义了三个属性;guiLayer应该是所有与用户界面相关的显示对象将被添加到的精灵,name应该是场景本身的名称,而director应该是其父对象的引用。在init方法中,我们为这些属性设置默认值。我们还添加了一个第二个初始化器,它接受一个参数来设置场景的名称。

创建场景导演

现在我们有了基本的 scene 类,我们需要一些可以实际管理我们想要添加的所有场景的东西。

行动时间 - 使用场景导演管理我们的场景

要创建场景导演,请查看以下步骤:

  1. 创建一个新的名为 SceneDirector 的 Objective-C 类,它从 SPSprite 类派生,并将其保存在 Scene 组中。

  2. 添加一个名为 _dict 的实例变量,其类型为 NSMutableDictionary

  3. 添加一个实例方法,该方法将场景添加到场景导演中,如下所示:

    -(void) addScene:(Scene *)scene;
    
  4. 添加第二个实例方法,这次你还可以定义/覆盖场景的名称:

    -(void) addScene:(Scene *)scene withName:(NSString *)name;
    
  5. 添加一个实例方法,该方法将显示场景并接受 NSString 作为其参数,如下所示:

    -(void) showScene:(NSString *)name;
    
  6. 让我们切换到实现部分。初始化器应该初始化 _dict 变量。

  7. 实现以下代码的 addScene:(Scene *)scene withName:(NSString *)name 方法:

    -(void) addScene:(Scene *)scene withName:(NSString *)name
    {
      scene.name = name;
      _dict[name] = scene;
    
      scene.director = self;
      [self addChild:scene];
    }
    
  8. addScene:(Scene *)scene 方法应按以下代码实现:

    -(void) addScene:(Scene *)scene
    {
        [self addScene:scene withName:scene.name];
    }
    
  9. showScene 方法应包含以下内容:

    -(void) showScene:(NSString *)name
    {
      for (NSString* sceneName in _dict) {
        ((Scene *) _dict[sceneName]).visible = NO;
      }
    
      if (_dict[name] != nil) {
        ((Scene *) _dict[name]).visible = YES;
      }
    }
    

发生了什么?

在第一步中,我们创建了场景导演所需的类。这需要是一个 SPSprite,因为我们希望它的实例被添加到 Game 类中,场景导演应该管理的场景可以很容易地添加到场景导演中。

我们定义了两个添加场景的实例方法:第一个方法接受场景,第二个方法接受场景和一个名称。

我们还需要一个实例来实际显示场景;它以名称作为其参数。

在下一步中,我们实现了场景导演的初始化器。我们需要初始化我们的 NSMutableDictionary。我们可以使用典型的 alloc-and-init 组合,或者,作为替代,使用更简洁的 @{} 语法。

我们首先实现了较长的 addScene 方法;我们将场景名称设置为 name 参数。这将覆盖场景名称,即使已经给出了一个。然后我们使用方括号表示法将场景添加到字典中,这与 [_dict setObject:scene forKey:name] 做的是同样的工作。在下一行中,我们将场景中的 director 属性的引用设置为当前场景导演实例。这是必需的;在其他情况下,我们就没有从场景切换到另一个场景的选项。我们还添加了场景到当前 SceneDirector 实例的显示树中。

在实现较短的 addScene 方法时,我们只需调用较长的 addScene 方法,并将当前场景的名称作为其第二个参数传递。

最后一步是展示已指定为参数的场景。首先,我们遍历字典中的所有元素,并将其可见性设置为NO,这样它就不会显示在屏幕上;是的,即使是我们要展示的场景。然后,我们在字典中特别寻找我们的场景,并将其可见性设置为YES

尝试一下英雄

目前,我们一次性加载所有场景。这目前是可行的,但随着场景数量的增加,我们可能会遇到内存不足的问题。为了解决这个问题,我们可以同时只保留一个场景在内存中。我们可能需要从我们的资产到场景的引用,以便知道哪个资产属于哪个场景。

快速问答

Q1. 二进制数据文件可以被视为资产吗?

  1. 是的

  2. 不是

Q2. 为什么我们应该首先缓存我们的资产以便重用已加载的资产?

  1. 减少 CPU 周期

  2. 为了节省内存

  3. 节省磁盘空间

Q3. 纹理(如SPTexture)可以直接绘制到屏幕上吗?

  1. 是的

  2. 不是

摘要

在本章中,我们学到了很多关于资产和场景管理的内容。

具体来说,我们涵盖了如何处理不同类型的资产、缓存已加载的文件以及实现场景和机制来管理这些场景。

我们还涉及了一些主题,例如纹理和屏幕上显示图像。

现在我们已经了解了如何处理资产和场景,我们准备添加游戏的基础内容——这是下一章的主题。

第四章.我们游戏的基础

在上一章中,我们学习了资源以及如何实现自己的资源管理系统,该系统从应用程序包中加载资源并对其进行缓存。我们使用资源管理系统来加载我们的第一张图像。我们介绍了如何将显示对象分组到场景中,并编写了一个场景导演来管理我们的场景。在本章中,我们将开始设置我们的游戏。我们将了解针对不同设备时应考虑的因素,并开始设置我们的游戏的第一步。这包括创建我们需要的场景和在屏幕上显示静态图像。

注意跨设备兼容性

在开发 iOS 游戏时,我们需要知道要针对哪个设备。除了所有 iOS 设备之间明显的技术差异之外,还有两个因素我们需要积极关注:屏幕尺寸和纹理尺寸限制。

注意

要快速了解 iOS 设备之间的差异,请查看www.iosres.com/上的比较表。

让我们更详细地看看如何处理纹理尺寸限制和屏幕尺寸。

理解纹理尺寸限制

每个图形卡都有一个最大纹理尺寸的限制,它可以显示。如果一个纹理的大小超过了纹理尺寸限制,它将无法加载,并在屏幕上显示为黑色。纹理尺寸限制具有2 的幂维度,是一个正方形,如宽度为 1024 像素,高度也为 1024 像素,或者 2048 x 2048 像素。

当加载纹理时,它们不需要有 2 的幂维度。实际上,纹理不需要是正方形。然而,纹理具有 2 的幂维度是一个最佳实践。

这个限制适用于大图像以及打包在一个大图像中的许多小图像。后者通常被称为精灵表。看看以下示例精灵表,了解其结构:

理解纹理尺寸限制

如何处理不同的屏幕尺寸

虽然屏幕尺寸始终以像素为单位进行测量,但 iOS 坐标系是以点为单位进行测量的。

iPhone 3GS 的屏幕尺寸为 320 x 480 像素,也是 320 x 480 点。在 iPhone 4 上,屏幕尺寸为 640 x 960 像素,但仍然是 320 x 480 点。因此,在这种情况下,每个点代表四个像素:宽度和高度各两个。一个 100 点宽的矩形在 iPhone 4 上将是 200 像素宽,而在 iPhone 3GS 上则是 100 像素。

对于具有大显示屏的设备,如 iPhone 5,它的工作方式类似。而不是 480 点,它是 568 点。

如何处理不同的屏幕尺寸

缩放视口

让我们先解释一下视口这个术语:视口是整个屏幕区域中可见的部分。

我们需要明确我们希望我们的游戏在哪些设备上运行。我们选择我们想要支持的最大的分辨率,并将其缩放到较小的分辨率。这是一个最简单的选项,但它可能不会产生最佳结果;触摸区域和用户界面也会缩放。苹果建议触摸区域至少为 40 点的平方;因此,根据用户界面,某些元素可能会缩放得太多,以至于难以触摸。

看看下面的截图,我们选择了 iPad Retina 分辨率(2048 x 1536 像素)作为我们的最大分辨率,并将屏幕上的所有显示对象缩放到 iPad 分辨率(1024 x 768 像素):

缩放视口

缩放是非 iOS 环境中的一种流行选项,尤其是对于支持从 1024 x 600 像素到全高清分辨率的 PC 和 Mac 游戏。

正如我们将在本章后面学到的那样,Sparrow 和 iOS SDK 提供了一些机制,可以简化处理 Retina 和非 Retina iPad 设备,而无需对整个视口进行缩放。

黑色边框

过去有些游戏是为 4:3 分辨率显示器设计的,但后来被修改为在具有更多屏幕空间的宽屏设备上运行。

因此,选择是将 4:3 分辨率缩放到宽屏,这将扭曲整个屏幕,或者将黑色边框放在屏幕的两侧以保持原始的缩放因子。

黑色边框

显示黑色边框现在被认为是一种不良做法,尤其是在有那么多游戏能够很好地适应不同屏幕尺寸和平台的情况下。

显示非交互式屏幕空间

如果我们的海盗游戏是多人游戏,我们可能会有一个玩家在使用 iPad,另一个玩家在使用 iPhone 5。因此,使用 iPad 的玩家有更大的屏幕和更多的屏幕空间来操控他们的船只。最坏的情况是,如果使用 iPad 的玩家能够将他们的船只移动到 iPhone 玩家看不到的视觉范围之外,这将给 iPad 玩家带来严重优势。

幸运的是,我们不需要竞争性多人游戏功能。然而,为了游戏平衡的目的,我们需要保持一致的屏幕空间,让玩家可以在其中移动他们的船只。我们不想将难度级别与玩家所使用的设备绑定。

显示非交互式屏幕空间

让我们比较之前的截图和黑色边框示例。我们不是展示难看的黑色边框,而是展示更多的背景。

在某些情况下,还可以将一些用户界面元素移动到其他设备上不可见的区域。然而,我们需要考虑我们是否希望在不同设备上保持相同的用户体验,以及移动这些元素是否会给那些没有这种额外屏幕空间的用户带来不利。

重新排列屏幕元素

重新排列屏幕元素可能是解决这个问题的最耗时和最复杂的方法。在这个例子中,我们在纵向模式下屏幕顶部有一个大的用户界面。现在,如果我们保持这种状态在横向模式下,屏幕顶部将只是用户界面,留给游戏本身的空间非常有限。

重新排列屏幕元素

在这种情况下,我们必须明确我们希望在屏幕上看到哪些元素,以及哪些元素占用了过多的屏幕空间。"屏幕空间"(或屏幕空间)是指应用程序或游戏在显示上可用的空间量。然后我们必须重新定位它们,将它们切割成更小的部分,或者两者都要做。

这种技术的最突出例子是 King 的"Candy Crush"(一款流行的热门游戏)。虽然这个概念特别适用于设备旋转,但这并不意味着它不能用于通用应用程序。

选择最佳选项

这些选项之间并不相互排斥。就我们的目的而言,我们将显示非交互式屏幕空间,如果事情变得复杂,我们可能还需要根据我们的需求重新排列屏幕元素。

不同设备之间的差异

让我们来看看不同 iOS 设备之间屏幕尺寸和纹理尺寸限制的差异:

设备 屏幕尺寸(以像素为单位) 纹理尺寸限制(以像素为单位)
iPhone 3GS 480 x 360 2048 x 2048
iPhone 4(包括 iPhone 4S)和 iPod Touch 第 4 代 960 x 640 2048 x 2048
iPhone 5(包括 iPhone 5C 和 iPhone 5S)和 iPod Touch 第 5 代 1136 x 640 2048 x 2048
iPad 2 1024 x 768 2048 x 2048
iPad(第 3 代和第 4 代)和 iPad Air 2048 x 1536 4096 x 4096
iPad Mini 1024 x 768 4096 x 4096

利用 iOS SDK

iOS SDK 和 Sparrow 都可以帮助我们创建通用应用程序。通用应用程序是指针对多个设备的应用程序,特别是针对 iPhone 和 iPad 设备系列的应用程序。

iOS SDK 提供了一个方便的机制来加载特定设备的文件。假设我们正在开发 iPhone 应用程序,并且我们有一个名为my_amazing_image.png的图像。如果我们将此图像加载到我们的设备上,它将被加载——无需质疑。然而,如果它不是通用应用程序,我们只能使用 iPad 和 iPhone Retina 设备上的常规缩放按钮来缩放应用程序。此按钮位于屏幕的右下角。

如果我们想要针对 iPad 进行开发,我们有两个选择:

  • 第一个选项是将图像按原样加载。设备将缩放图像。根据图像质量,缩放后的图像可能看起来很糟糕。在这种情况下,我们还需要考虑设备的 CPU 将执行所有的缩放工作,这可能会根据应用程序的复杂度导致一些减速。

  • 第二种选择是为 iPad 设备添加一个额外的图片。这个图片将使用~ipad后缀,例如,my_amazing_image~ipad.png。在加载所需的图片时,我们仍然使用文件名my_amazing_image.png。iOS SDK 将自动检测提供的图片的不同尺寸,并使用适合设备的正确尺寸。

从 Xcode 5 和 iOS 7 开始,可以使用资源包。资源包可以包含各种图片,这些图片被分组到图片集中。图片集包含针对目标设备的所有图片。这些资源包不再需要带有后缀的文件。这些资源包只能用于启动图片和应用程序图标。但我们不能使用资源包来加载 Sparrow 中使用的纹理。

以下表格显示了为哪种设备需要哪种后缀:

设备 Retina 文件后缀
iPhone 3GS
iPhone 4(包括 iPhone 4S)和 iPod Touch(第 4 代) @2x @2x~iphone
iPhone 5(包括 iPhone 5C 和 iPhone 5S)和 iPod Touch(第 5 代) -568h@2x
iPad 2 ~ipad
iPad(第 3 代和第 4 代)和 iPad Air @2x~ipad
iPad Mini ~ipad

这如何影响我们希望显示的图形?非 Retina 图片的宽度将是 128 像素,高度是 128 像素。Retina 图片,即带有@2x后缀的图片,将是非 Retina 图片的两倍大小,即 256 像素宽和 256 像素高。

利用 iOS SDK

Sparrow 中的 Retina 和 iPad 支持

Sparrow 支持之前表格中显示的所有文件名后缀,并且对于 iPad 设备有一个特殊情况,我们现在将更详细地探讨。

当我们查看我们游戏源代码中的AppDelegate.m时,注意以下行:

[_viewController startWithRoot:[Game class] supportHighResolutions:YES doubleOnPad:YES];

第一个参数supportHighResolutions告诉应用程序如果可用,则加载 Retina 图片(带有@2x后缀)。

doubleOnPad参数是其中一个有趣的参数。如果将其设置为true,Sparrow 将为 iPad 设备使用@2x图片。因此,我们不需要为 iPad 创建一组单独的图片,而是可以使用 Retina iPhone 图片为 iPad 应用程序使用。

在这种情况下,宽度和高度分别是 512 点和 384 点。如果我们针对的是 iPad Retina 设备,Sparrow 引入了@4x后缀,这需要更大的图片,并将坐标系保持在 512 x 384 点。

应用程序图标和启动图片

如果我们谈论的是实际游戏内容的不同尺寸的图片,应用程序图标和启动图片也必须以不同的尺寸存在。

启动图片(也称为启动图像)是在应用程序加载时显示的图片。这些图片也遵循 iOS 的命名方案,因此对于像 iPhone 4 这样的 Retina iPhone 设备,我们将图片命名为Default@2x.png,而对于 iPhone 5 设备,我们将图片命名为Default-568h@2x.png

为了正确的大小应用图标,请查看以下表格:

设备 Retina 应用图标大小
iPhone 3GS 57 x 57 像素
iPhone 4(包括 iPhone 4S)和 iPod Touch 第 4 代 120 x 120 像素
iPhone 5(包括 iPhone 5C 和 iPhone 5S)和 iPod Touch 第 5 代 120 x 120 像素
iPad 2 76 x 76 像素
iPad(第 3 代和第 4 代)和 iPad Air 152 x 152 像素
iPad Mini 76 x 76 像素

核心内容

我们想要支持的设备越多,需要的图形就越多,这直接增加了应用程序的文件大小。当然,将 iPad 支持添加到我们的应用程序不是一项简单的任务,但 Sparrow 做了一些基础工作。

然而,我们需要记住一件事:如果我们只针对 iOS 7.0 及以上版本,我们不再需要包含非 Retina iPhone 图像。在这种情况下,使用@2x@4x就足够了,因为非 Retina 设备的支持将很快结束。

从我们游戏开发开始

现在我们已经足够了解 Sparrow 框架的理论和实践经验,让我们将所有这些知识应用到实际中,通过创建我们的海盗游戏来将理论转化为实践。

注意

如果你错过了我们游戏的任何开发阶段,游戏的源代码也已在 GitHub 上提供,网址为github.com/freezedev/pirategame

我们的游戏由两个主要游戏玩法部分组成:

  • 战场/竞技场:这是我们海盗船与其他船只战斗的场景

  • 海盗湾:海盗湾是与其他船只战斗后的活动中心,如雇佣新船员和升级船只

在本章中,我们将设置所需的场景并加载纹理,将它们显示为图像,并在屏幕上排列实体。

注意

游戏的图形也在 GitHub 上:github.com/freezedev/pirategame-assets。这些图形是用开源 3D 建模软件 Blender 制作的(www.blender.org);需要 2.69 版本才能打开和编辑这些文件。不用担心,我们不需要更新这些文件来完成本书的目的,但如果你想要寻找灵感,你绝对被鼓励这样做。

让我们通过导航到github.com/freezedev/pirategame-assets/releases下载本章所需的图像。这将显示此特定存储库的所有可用版本,如下面的截图所示:

从我们游戏开发开始

请继续下载Graphics.zip包,并将其内容解压缩到你的电脑上的某个位置。此包包含以下图像:

文件名 描述
water.png 这是战场场景的背景。
island.png 这是海盗基地的背景。技术上,它更像是一个岛屿而不是海湾,这就是为什么这个图像被称为岛屿,但其他地方都称之为海盗湾。
house.png 这是我们的海盗的避难所。
tavern.png 这是我们可以雇佣新海盗的建筑。
weaponsmith.png 这将是我们在船上升级额外大炮或弹药的地方。
ship.png 这是我们基本的敌人。
ship_pirate.png 这是我们将要控制的船只。

所有资源都在非 Retina 分辨率下,iPad 2、iPad Mini 和 iPhone/iPod Touch 使用@2x文件后缀,iPad Retina 设备使用@4x

将文件拖放到 Xcode 项目的Resources文件夹中。当弹出对话框时,我们需要勾选Copy items into destination group's folder (if needed),这样我们就不必担心对原始文件的引用。点击Finish开始过程。

到目前为止,图像已经针对横幅模式进行了优化,因此我们现在需要暂时禁用纵向模式。我们需要选择PirateGame项目,并在Deployment Info部分取消勾选PortraitUpside Down,如下面的截图所示。确保为 iPhone 和 iPad 都取消勾选。

从我们游戏开发开始

我们还可以安全地删除仍然存在于我们的Game.m文件中的纸板木偶代码。

创建我们的场景管理器设置

在上一章中,我们创建了一个场景管理器,我们现在将使用它来处理场景。在我们的第一步中,我们需要两个占位符场景,稍后我们将填充它们以包含游戏机制。我们还需要将这些场景添加到场景导演中,并显示这两个场景之一。

行动时间 - 创建我们的场景管理器设置

创建我们的场景管理器设置,我们需要遵循以下步骤:

  1. 如果尚未打开,请打开您的 Xcode 游戏模板。

  2. 右键点击Classes文件夹并选择New Group

  3. 将组重命名为GameScenes

  4. 创建一个新的名为PirateCove的 Objective-C 类,它是Scene类的子类。

  5. 添加一个初始化器,内容如下:

    if ((self = [super init])) {
      NSLog(@"Pirate cove scene created");
    }
    
  6. 创建另一个从Scene类派生的 Objective-C 类。将其命名为Battlefield

  7. 添加一个初始化器,内容如下:

    -(id) init
    {
        if ((self = [super init])) {
            NSLog(@"Battlefield scene created");
        }
    
        return self;
    }
    
  8. 切换到Game.m文件。

  9. PirateCove.hBattlefield.hSceneDirector.h文件添加到import部分,如下面的代码所示:

    #import "SceneDirector.h"
    #import "PirateCove.h"
    #import "Battlefield.h"
    
  10. init方法中,创建PirateCoveBattlefield类的实例,并使用@"piratecove"@"battlefield"分别作为参数调用initWithName方法:

    PirateCove *pirateCove = [[PirateCove alloc] initWithName:@"piratecove"];
    Battlefield *battlefield = [[Battlefield alloc] initWithName:@"battlefield"];
    
  11. 创建场景导演的实例并将其添加到Game类中,如下面的代码所示:

    SceneDirector *director = [[SceneDirector alloc] init];
    [self addChild:director];
    
  12. 将两个场景添加到场景导演中,并显示海盗湾场景:

    [director addScene:pirateCove];
    [director addScene:battlefield];
    
    [director showScene:@"battlefield"];
    
  13. 运行示例,你将得到以下输出:实战时间 – 创建我们的场景管理设置

发生了什么?

在步骤 1 中,我们从上一章结束的地方打开了我们的 Xcode 模板。在步骤 2 中,我们创建了一个新组,所有与我们的游戏场景相关的文件都将放入这个组中。在步骤 3 中,我们重命名了新创建的组。

在步骤 4 中,我们创建了一个新的 Objective-C 类,它继承自 Scene 类。在下一个步骤中,我们添加了初始化方法,其中添加了一个日志消息以查看场景是否已创建。

在步骤 6 和 7 中,我们对战场场景做了同样的操作。

在步骤 8 中,我们切换到 Game.m 文件后,导入了所有需要的源文件,即场景导演的头文件以及我们刚刚创建的两个场景。

在步骤 11 中,我们创建了场景和场景导演的实例。场景导演本身就是一个精灵,因此我们需要将其添加到 Game 类中,该类也继承自 SPSprite

在步骤 12 中,我们将场景实例添加到场景导演中,这意味着场景现在已经在显示树中。然后我们调用 SceneDirector 实例中的方法来显示战场场景。

当我们运行示例时,屏幕上没有看到任何有价值的内容,因为场景中没有内容,但如果我们查看控制台,我们会看到我们的两个场景已经成功创建。

这里是本例的完整源代码:

海盗湾场景 战场场景

| PirateCove.h

#import "Scene.h"

@interface PirateCove : Scene

@end

PirateCove.m

#import "PirateCove.h"

@implementation PirateCove

-(id) init
{
  if ((self = [super init])) {
    NSLog(@"Pirate cove scene created");
  }

    return self;
}

@end

| Battlefield.h

#import "Scene.h"

@interface Battlefield : Scene

@end

Battlefield.m

#import "Battlefield.h"

@implementation Battlefield

-(id) init
{
    if ((self = [super init])) {
        NSLog(@"Battlefield scene created");
    }

    return self;
}

@end

|

Game.m 文件包含以下代码:

#import "Game.h" 
#import "SceneDirector.h"
#import "PirateCove.h"
#import "Battlefield.h"

@implementation Game

- (id)init
{
    if ((self = [super init]))
    {
        Sparrow.stage.color = 0xffffff;
    PirateCove *pirateCove = [[PirateCove alloc] initWithName:@"piratecove"];
    Battlefield *battlefield = [[Battlefield alloc] initWithName:@"battlefield"];

    SceneDirector *director = [[SceneDirector alloc] init];
    [self addChild:director];

    [director addScene:pirateCove];
    [director addScene:battlefield];

    [director showScene:@"battlefield"];
    }
    return self;
}

@end

向战场场景添加图像

现在场景已经准备好使用,让我们向战场场景添加一些船只。

实战时间 – 向战场场景添加图像

让我们查看以下步骤,以向战场场景添加图像:

  1. 打开 Battlefield.m 文件并导入 Assets 头文件:

    #import "Assets.h"
    
  2. 删除日志消息并添加背景图像,如下代码所示:

    SPImage *background = [SPImage imageWithTexture:[Assets texture:@"water.png"]];
    background.x = (Sparrow.stage.width - background.width) / 2;
    background.y = (Sparrow.stage.height - background.height) / 2;
    
  3. 添加海盗船,如下代码所示:

    SPImage *pirateShip = [SPImage imageWithTexture:[Assets texture:@"ship_pirate.png"]];
    pirateShip.x = (Sparrow.stage.width - pirateShip.width) / 2;
    pirateShip.y = (Sparrow.stage.height - pirateShip.height) / 2;
    
  4. 使用以下代码添加敌方船只:

    SPImage *ship = [SPImage imageWithTexture:[Assets texture:@"ship.png"]];
    ship.x = 100;
    ship.y = 100;
    
  5. 将所有子节点添加到显示树中,如下代码所示:

    [self addChild:background];
    [self addChild:pirateShip];
    [self addChild:ship];
    
  6. 运行示例,你将得到以下输出:实战时间 – 向战场场景添加图像

发生了什么?

在步骤 1 中,我们打开了 Battlefield.m 文件,因为如果我们想更改战场场景中的任何内容,就需要这个文件,并且我们导入了 Assets.h 文件以便使用我们的资产管理系统。

在步骤 2 中,我们准备了背景,它应该位于屏幕中央。我们使用我们的资产管理系统从指定的文件中获取纹理,该纹理返回缓存的或新加载的纹理,然后该纹理将用于在屏幕上绘制 SPImage

在步骤 3 中,我们添加了海盗船,它应该在屏幕中央。在下一个步骤中,我们添加了一艘敌舰,它不应该离我们的船太远。

在步骤 5 中,我们将所有显示对象添加到显示树中,当我们运行示例时,我们在屏幕上看到了两艘船。

Battlefield.m 文件将包含以下代码:

#import "Battlefield.h"
#import "Assets.h"

@implementation Battlefield

-(id) init
{
    if ((self = [super init])) {
    SPImage *background = [SPImage imageWithTexture:[Assets texture:@"water.png"]];
    background.x = (Sparrow.stage.width - background.width) / 2;
    background.y = (Sparrow.stage.height - background.height) / 2;

    SPImage *pirateShip = [SPImage imageWithTexture:[Assets texture:@"ship_pirate.png"]];
    pirateShip.x = (Sparrow.stage.width - pirateShip.width) / 2;
    pirateShip.y = (Sparrow.stage.height - pirateShip.height) / 2;

    SPImage *ship = [SPImage imageWithTexture:[Assets texture:@"ship.png"]];
    ship.x = 100;
    ship.y = 100;

    [self addChild:background];
    [self addChild:pirateShip];
    [self addChild:ship];
    }

    return self;
}

@end

在海盗湾场景中排列图像

让我们转到海盗湾场景,为我们的海盗提供一个舒适的小家。在这个示例中,我们将添加一个房子、一个酒馆和一个铁匠到场景中。这些将作为我们可以稍后更新我们的船的地方。

时间行动 – 在海盗湾场景中排列图像

要将图片添加到海盗湾场景中,请按照以下步骤操作:

  1. 打开 PirateCove.m

  2. 使用以下代码行导入 Assets 头文件:

    #import "Assets.h"
    
  3. 删除日志消息并添加背景图片,如下面的代码所示:

    SPImage *background = [SPImage imageWithTexture:[Assets   texture:@"cove.png"]];
    background.x = (Sparrow.stage.width - background.width) / 2;
    background.y = (Sparrow.stage.height - background.height) / 2;
    
  4. 添加我们的海盗船,如下面的代码所示:

    SPImage *pirateShip = [SPImage imageWithTexture:[Assets   texture:@"ship_pirate.png"]];
    pirateShip.x = Sparrow.stage.width - pirateShip.width - 120;
    pirateShip.y = Sparrow.stage.height - pirateShip.height - 10;
    
  5. 添加一个房子,如下面的代码所示:

    SPImage *house = [SPImage imageWithTexture:[Assets   texture:@"house.png"]];
    house.x = 100;
    house.y = 100;
    
  6. 添加一个酒馆,如下面的代码所示:

    SPImage *tavern = [SPImage imageWithTexture:[Assets   texture:@"tavern.png"]];
    tavern.x = 220;
    tavern.y = 40;
    
  7. 添加一个铁匠,如下面的代码所示:

    SPImage *weaponsmith = [SPImage imageWithTexture:[Assets   texture:@"weaponsmith.png"]];
    weaponsmith.x = 350;
    weaponsmith.y = 130;
    
  8. 将所有图片注册到显示树中:

    [self addChild:background];
    [self addChild:pirateShip];
    [self addChild:house];
    [self addChild:tavern];
    [self addChild:weaponsmith];
    
  9. 前往 Game.m 文件,将默认场景更改为海盗湾,如下面的代码所示:

    [director showScene:@"piratecove"];
    
  10. 运行示例,你将得到以下输出:时间行动 – 在海盗湾场景中排列图像

发生了什么?

大多数步骤与战场场景非常相似,所以我们不需要详细解释每个步骤。

在步骤 1 中,我们打开了 PirateCove.m 文件,其中应该包含关于海盗湾的所有内容。在这里我们还需要资产管理系统,所以在步骤 2 中我们导入了它。

在步骤 3 中,我们加载了合适的图片,它应该在屏幕中央。在步骤 4 到 7 中,我们加载了我们在屏幕上想要显示的不同实体,例如海盗船和房子。我们在屏幕上随机地定位它们,但留出足够的空间,以免显得杂乱。

在步骤 8 中,我们将所有显示对象添加到屏幕上。请记住,顺序很重要。如果我们最后添加背景图片,我们只能看到背景,什么也看不到。

我们将场景导演设置为加载海盗湾场景而不是战场场景,当我们运行示例时,我们在屏幕上看到了海盗湾。

快速问答

Q1. 在开发通用应用程序时,我们需要积极注意哪些方面?

  1. 电池电量

  2. 屏幕尺寸和纹理尺寸限制

  3. GPU 内存

Q2. 如果我们想显示后缀为 ~ipad 的图片,它将在哪些设备上加载?

  1. Non-Retina iPad

  2. Retina iPhone

  3. Retina iPad

Q3. 在 iOS 点坐标系统中,256 x 256 像素的图片在 Retina iPhone 上的尺寸是多少?

  1. 128 x 128 pt

  2. 256 x 256 pt

  3. 512 x 512 pt

Q4. 如果将 doubleOnPad 参数设置为 YES,加载 Retina iPad 上的图片需要哪个后缀?

  1. @2x

  2. @3x

  3. @4x

摘要

在本章中,我们学习了 iPad 和 iPhone 设备之间的跨平台设备兼容性。

具体来说,我们涵盖了需要识别的文件名后缀,为哪种设备加载哪个文件,如何在点坐标系统中工作,以及加载图片时的纹理大小限制。

我们还设置了游戏的骨架,其中我们利用我们的资源和场景管理器为不同类型的设备加载了图片。

现在我们游戏的场景已经可用,并且我们在屏幕上放置了一些图片,我们准备美化我们的游戏——这是下一章的主题。

第五章. 美化我们的游戏

在上一章中,我们学习了跨设备兼容性以及如果我们想要同时针对 iPhone 和 iPad,我们需要做什么。然后我们为我们的游戏设置了基础。在本章中,我们将开始为我们的游戏添加动画。

使用缓动动画

假设我们想要将我们的船只移动到屏幕的边缘。我们该如何实现这一点?以下是有两种实现这一点的选项:

  • 将船只移动到我们想要其移动的方向的每一帧

  • 为我们的船只定义两个状态,并让处理器计算动画所需的全部步骤

乍一看,第二个选项似乎更有吸引力。我们首先需要知道船只的初始位置以及动画完成后船只应处的位置。Sparrow 提供了 SPTween 类,它正好能完成这个任务。

我们取两个值,也称为关键帧,并插值所有中间值。名称 "tween" 来自其中间状态。

在这个例子中,我们正在讨论显式移动位置,但在一般情况下,缓动动画并不仅限于动画化实体的位置,还可以用来动画化其颜色或其他任何属性。

在 Sparrow 中,特别是对象的任何数值属性都可以进行动画化。因此,所有在 SPDisplayObject 上可用的属性都可用于 SPTween 类及其动画能力。

如果我们想要实现淡出或淡入效果,我们只需要对显示对象的 alpha 属性进行动画化,从其最大值到最小值或反之。

让我们通过实际移动海盗船来尝试一下。

行动时间 - 移动海盗船

按照以下步骤移动船只:

  1. 如果游戏项目文件尚未打开,请打开我们的游戏项目文件。

  2. 添加一个名为 _pirateShip 的实例变量,类型为 SPImage,如下面的代码行所示:

    SPImage* _pirateShip;
    
  3. Battlefield.m 中将 pirateShip 的引用更新为 _pirateShip

    _pirateShip = [SPImage imageWithTexture:[Assets texture:@"ship_pirate.png"]];
    _pirateShip.x = (Sparrow.stage.width - _pirateShip.width) / 2;
    _pirateShip.y = (Sparrow.stage.height - _pirateShip.height) / 2;
    
  4. Battlefield.m 文件中添加一个名为 onBackgroundTouch 的方法,如下面的代码行所示:

    -(void) onBackgroundTouch: (SPTouchEvent*) event
    
  5. 在此方法中,获取触摸本身:

    SPTouch* touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];
    
  6. 使用以下代码片段完成 onBackgroundTouch 方法:

    if (touch) {
      SPTween* tweenX = [SPTween tweenWithTarget:_pirateShip time:2.0f];
      SPTween* tweenY = [SPTween tweenWithTarget:_pirateShip time:2.0f];
    
      [tweenX animateProperty:@"x" targetValue:touch.globalX - (_pirateShip.width / 2)];
      [tweenY animateProperty:@"y" targetValue:touch.globalY - (_pirateShip.height / 2)];
    
      [Sparrow.juggler addObject:tweenX];
      [Sparrow.juggler addObject:tweenY];
    }
    
  7. 将事件监听器注册到背景图像,如下面的代码行所示:

    [background addEventListener:@selector(onBackgroundTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
    
  8. 切换到 Game.m 文件。

  9. 更新场景导演以显示战场场景。

  10. 运行示例,你将得到以下输出:行动时间 - 移动海盗船

刚才发生了什么?

在第 1 步中,我们从上一章结束的地方打开了我们的 Xcode 模板。为了在整个战场源文件中使用海盗船,我们应该将其移动到 Battlefield 类的实例变量中,这就是我们在第 2 步中做的。

现在,我们需要更新对海盗船的引用,这是第 3 步的任务。

在此之后,我们定义了方法,声明了如果我们触摸背景(在我们的例子中,是屏幕上的水)会发生什么。在第 5 步中,我们获取了当前的触摸。

在第 6 步中,我们实现了实际的补间动画。一旦我们确定我们有了当前的触摸对象(例如,不是像 nil 这样的假值),我们就开始动画化海盗船。

我们创建了两个补间动画:第一个用于海盗船的 x 位置,第二个用于其 y 位置。只要目标和补间动画的持续时间相同,我们实际上可以使用一个单一的补间动画,如下面的代码所示:

if (touch) {
  SPTween* tween = [SPTween tweenWithTarget:_pirateShip time:2.0f]; 

  [tween animateProperty:@"x" targetValue:touch.globalX - (_pirateShip.width / 2)];
  [tween animateProperty:@"y" targetValue:touch.globalY - (_pirateShip.height / 2)];

  [Sparrow.juggler addObject:tween];
}

由于我们稍后将要更改这些属性,我们最好将其留为两个独立的补间动画。

一个补间动画始终需要一个目标,我们将它设置为 _pirateShip 实例变量。我们必须指定的另一个值是补间动画将持续多长时间,这由 time 参数设置。补间动画所需的时间作为 SPTween 实例上的一个属性可用。time 参数是 double 类型,以秒为单位。

tweenX 实例正在绑定到 x 属性。我们需要通过其 NSString 标识符来访问该属性。因此,如果我们想动画化 alpha 属性,我们需要通过 @"alpha" 来访问它。内部,Sparrow 使用运行时类型信息(也称为反射)来在运行时更改属性。

我们将目标值设置为当前的触摸位置,即触摸的 x 坐标。现在,如果我们触摸背景,船的左上角将位于触摸位置。为了感觉更自然,我们应该将其改为船位于触摸的中心。这就是为什么我们从触摸位置减去船宽度的一半。

隐式地,初始值自动设置为要动画化的属性的当前值。

然后,我们分别对 tweenY 和 y 位置做了同样的操作。

为了实际动画化属性,我们将补间动画添加到一个名为 juggler 的对象中,该对象通过 Sparrow.juggler 可用。我们将在本章后面部分看看 juggler 的工作原理。

为了使触摸事件触发,我们将 onBackgroundTouch 方法与背景图像注册。

在第 8 步中,我们打开了 Game.m 文件,并将 show 调用更新为使用战场场景而不是第 9 步中发生的海盗湾场景。

然后,我们运行了示例。如果我们触摸屏幕上的任何地方,船将移动到我们刚刚触摸的位置。

让我们看看我们的源文件。

下面的代码是 Battlefield.h 文件中的代码:

#import "Scene.h"

@interface Battlefield : Scene {
    SPImage *_pirateShip;
}

@end

下面是相应的 Battlefield.m 文件:

#import "Battlefield.h"
#import "Assets.h"

@implementation Battlefield

-(void) onBackgroundTouch: (SPTouchEvent*) event
{
    SPTouch *touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];

  if (touch) {
    SPTween *tweenX = [SPTween tweenWithTarget:_pirateShip time:2.0f];
    SPTween *tweenY = [SPTween tweenWithTarget:_pirateShip time:2.0f];

    [tweenX animateProperty:@"x" targetValue:touch.globalX - (_pirateShip.width / 2)];
    [tweenY animateProperty:@"y" targetValue:touch.globalY - (_pirateShip.height / 2)];

    [Sparrow.juggler addObject:tweenX];
    [Sparrow.juggler addObject:tweenY];
  }
}
-(id) init
{
    if ((self = [super init])) {
        SPImage *background = [SPImage imageWithTexture:[Assets texture:@"water.png"]];
        background.x = (Sparrow.stage.width - background.width) / 2;
        background.y = (Sparrow.stage.height - background.height) / 2;

       _pirateShip = [SPImage imageWithTexture:[Assets texture:@"ship_pirate.png"]];
       _pirateShip.x = (Sparrow.stage.width - _pirateShip.width) / 2;
       _pirateShip.y = (Sparrow.stage.height - _pirateShip.height) / 2;

        SPImage *ship = [SPImage imageWithTexture:[Assets texture:@"ship.png"]];
        ship.x = 100;
        ship.y = 100;

        [background addEventListener:@selector(onBackgroundTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];

        [self addChild:background];
        [self addChild:_pirateShip];
        [self addChild:ship];
    }

    return self;
}

@end

理解过渡

让我们更仔细地看看我们刚刚实现的动画。当我们移动我们的海盗船时,它以恒定的速度移动。这是一个线性过渡,这是每个新创建的 SPTween 实例的默认行为,如果创建实例时没有明确设置过渡值。

创建具有默认过渡的补间的标准方法如下:

SPTween *myTween = [SPTween tweenWithTarget:_pirateShip time:2.0f];

要使用具有非线性过渡的补间,只需将其指定为参数:

SPTween *myTween = [SPTween tweenWithTarget:_pirateShip time:2.0f transition:SP_TRANSITION_EASE_IN_OUT];

在这段代码中,我们使用了一个名为“ease-in-out”的过渡行为,在这种情况下,飞船不会立即移动,而是会花时间开始,在动画即将结束时,它会稍微慢下来一点。

注意

要查看所有可用过渡及其图形表示的完整列表,请查看 Sparrow 手册中的wiki.sparrow-framework.org/_detail/manual/transitions.png?id=manual%3Aanimation

解释杂技演员

杂技演员的目的是动画化其他对象。它是通过将它们保存在一个列表中,并在每一帧调用一个更新方法来实现的。更新方法(advanceTime)传递自上一帧以来经过的毫秒数。我们想要动画化的每个对象都需要添加到 SPJuggler 的一个实例中。

默认杂技演员可以通过 Sparrow.juggler 访问,这是在屏幕上动画化对象的简单方法。

由于 Sparrow.juggler 只是 SPJuggler 的一个实例,因此我们也可以为游戏的主要每个组件分别分离杂技演员。目前,使用默认的杂技演员就足够满足我们的需求了。

更新移动和取消补间

是时候做出我们的第一个游戏决策了。目前,海盗飞船的动画总是 2 秒长,如果玩家触摸屏幕的边缘而不是仅仅在屏幕上移动几个点,这将提供严重的优势。

我们需要引入的是,如果我们移动到屏幕边缘,则可能需要更多时间来推进飞船的某种形式的惩罚。

也是一个好主意,当飞船正在移动时,添加取消动画的可能性。这样,当事情变得紧张时,我们就有了一个从当前战斗中撤退的选项。

现在,我们如何实现当前动画的取消呢?让我们看看以下选项:

  • 通过在屏幕上添加按钮

  • 通过触摸飞船本身

我们应该尽可能地避免屏幕上的控制,因此让我们将此功能添加到触摸事件(当我们触摸海盗飞船时)。

行动时间 - 更新移动

要更新我们飞船的移动,请按照以下步骤操作:

  1. 在初始化器内部,为敌对飞船添加一个补间。我们希望敌对飞船能够自行移动。我们还应该将飞船实例重命名为 enemyShip

    SPImage *enemyShip = [SPImage imageWithTexture:[Assets texture:@"ship.png"]];
    enemyShip.x = 100;
    enemyShip.y = 100;
    
    SPTween *shipTween = [SPTween tweenWithTarget:enemyShip time:4.0f transition:SP_TRANSITION_EASE_IN_OUT];
    [shipTween animateProperty:@"y" targetValue:250];
    shipTween.repeatCount = 5;
    shipTween.reverse = YES;
    shipTween.delay = 2.0f;
    
    [Sparrow.juggler addObject:shipTween];
    
    
  2. 更新 onBackgroundTouch 方法,使其类似于以下代码片段:

    SPTouch *touch = [[event touchesWithTarget:self] anyObject];
    
    if (touch) {
     [Sparrow.juggler removeObjectsWithTarget:_pirateShip];
    
     float targetX = touch.globalX - (_pirateShip.width / 2);
     float targetY = touch.globalY - (_pirateShip.height / 2);
    
     float distanceX = fabsf(_pirateShip.x - targetX);
     float distanceY = fabsf(_pirateShip.y - targetY);
     float penalty = (distanceX + distanceY) / 80.0f;
    
     float shipInitial = 0.25f + penalty;
    
     float speedX = shipInitial + (distanceX / Sparrow.stage.width) * penalty * penalty;
     float speedY = shipInitial + (distanceY / Sparrow.stage.height) * penalty * penalty;
    
     SPTween *tweenX = [SPTween tweenWithTarget:_pirateShip time:speedX];
     SPTween *tweenY = [SPTween tweenWithTarget:_pirateShip time:speedY];
    
     [tweenX animateProperty:@"x" targetValue:targetX];
     [tweenY animateProperty:@"y" targetValue:targetY]; 
    
      [Sparrow.juggler addObject:tweenX];
      [Sparrow.juggler addObject:tweenY];
    }
    
  3. 添加一个名为 onShipStop 的新方法,如下面的代码行所示:

    -(void) onShipStop:(SPTouchEvent*) event
    
  4. 使用所有触摸样板代码实现此方法并停止所有动画:

    SPTouch *touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];
    
    if (touch) {
      [Sparrow.juggler removeObjectsWithTarget:_pirateShip];
    }
    
  5. onShipStop 选择器注册到海盗飞船上:

    [_pirateShip addEventListener:@selector(onShipStop:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
    
  6. 当我们将飞船添加到战场场景中时,将敌对飞船与海盗飞船交换。

  7. 运行示例,你会看到以下结果:行动时间 - 更新移动

发生了什么?

在步骤 1 中,我们在加载敌舰图像的代码下方添加了一个敌舰补间动画。

在创建实例时,我们将动画所需的时间设置为 4 秒,并使用 ease-in-out 过渡来查看与默认线性过渡直接比较时的差异。

这个补间将通过其y属性/位置移动敌舰。我们将目标值设置为250,这大致是屏幕的底部。

当设置repeatCount属性——它接受一个int类型的值时——我们希望动画重复的次数正好与我们设置的属性值相同。

通过将reverse属性设置为YESNO,可以反转补间动画,因为它接受一个BOOL类型的值。如果我们没有在这个示例中设置reverse属性,当重复动画时,补间动画将从其初始值开始。当设置为YES时,动画将在初始值和目标值之间交替。我们应该记住,反转动画算作一个动画周期。

可以通过使用它们的delay属性来延迟补间动画。这个属性也需要一个double类型的值,并且像time属性一样以秒为单位来衡量。

现在,我们需要将动画添加到默认的杂技演员中。

在步骤 2 中,我们更新了触摸事件和动画。首先,我们移除了andPhase参数。之前,我们只能通过在屏幕上轻触来移动船。现在,我们可以通过轻触屏幕或触摸并拖动屏幕来移动船。

在我们知道触摸已经发生之后,我们从杂技演员中移除了之前绑定的所有补间动画。在这里,我们只是确保我们始终有一个新的补间,海盗船动画可能会产生任何随机的副作用,例如同时设置多个补间动画的不同目标值。

在下一行中,我们声明并分配了新位置的变量,然后我们得到了船的位置和触摸位置之间的绝对值。

惩罚是通过距离的总和除以 80 来计算的,这恰好是我们船的点大小。因此,触摸越接近船,这个值就越低,触摸离船越远,这个值就越高。

船的速度,即动画的持续时间,是通过相对于屏幕大小的相对距离乘以平方惩罚来计算的。我们还有一个初始值 250 毫秒,这是动画可能的最短时间。

除了animateProperty方法之外,我们还可以使用简写方法moveToX:y:,它与在xy属性上调用animateProperty相同。

在第 3 步中,我们将onShipStop方法添加到了源文件中,并在接下来的步骤中实现了它。我们还移除了所有带有_pirateShip目标的所有补间动画。因此,如果当前有一个补间动画正在执行,它将被移除。

在第 5 步中,我们将onShipStop事件注册到了海盗船。

目前,如果我们移动到敌舰,敌舰将显示在我们的船的上方。为了使我们的船显示在敌舰上方,我们需要在将它们添加到显示树时交换这两个对象的顺序。

在这个示例之后,我们的Battlefield.m文件应该看起来像以下代码:

#import "Battlefield.h"
#import "Assets.h"

@implementation Battlefield

-(id) init
{
    if ((self = [super init])) {
        SPImage *background = [SPImage imageWithTexture:[Assets texture:@"water.png"]];
        background.x = (Sparrow.stage.width - background.width) / 2;
        background.y = (Sparrow.stage.height - background.height) / 2;

        _pirateShip = [SPImage imageWithTexture:[Assets texture:@"ship_pirate.png"]];
        _pirateShip.x = (Sparrow.stage.width - _pirateShip.width) / 2;
        _pirateShip.y = (Sparrow.stage.height - _pirateShip.height) / 2;

        SPImage *enemyShip = [SPImage imageWithTexture:[Assets texture:@"ship.png"]];
        enemyShip.x = 100;
        enemyShip.y = 100;

        SPTween *shipTween = [SPTween tweenWithTarget:enemyShip time:4.0f transition:SP_TRANSITION_EASE_IN_OUT];
        [shipTween animateProperty:@"y" targetValue:250];
        shipTween.repeatCount = 5;
        shipTween.reverse = YES;
        shipTween.delay = 2.0f;

        [Sparrow.juggler addObject:shipTween];

        [background addEventListener:@selector(onBackgroundTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
        [_pirateShip addEventListener:@selector(onShipStop:) atObject:self forType:SP_EVENT_TYPE_TOUCH];

        [self addChild:background];
        [self addChild:enemyShip];
        [self addChild:_pirateShip];
    }

    return self;
}

-(void) onBackgroundTouch:(SPTouchEvent*) event
{
    SPTouch *touch = [[event touchesWithTarget:self] anyObject];

    if (touch) {
        [Sparrow.juggler removeObjectsWithTarget:_pirateShip];

        float targetX = touch.globalX - (_pirateShip.width / 2);
        float targetY = touch.globalY - (_pirateShip.height / 2);

        float distanceX = fabsf(_pirateShip.x - targetX);
        float distanceY = fabsf(_pirateShip.y - targetY);

        float penalty = (distanceX + distanceY) / 80.0f;

        float shipInitial = 0.25f + penalty;

        float speedX = shipInitial + (distanceX / Sparrow.stage.width) * penalty * penalty;
        float speedY = shipInitial + (distanceY / Sparrow.stage.height) * penalty * penalty;

        SPTween *tweenX = [SPTween tweenWithTarget:_pirateShip time:speedX];
        SPTween *tweenY = [SPTween tweenWithTarget:_pirateShip time:speedY];

        [tweenX animateProperty:@"x" targetValue:targetX];
        [tweenY animateProperty:@"y" targetValue:targetY];

        [Sparrow.juggler addObject:tweenX];
        [Sparrow.juggler addObject:tweenY];
    }
}

-(void) onShipStop:(SPTouchEvent*) event
{
    SPTouch *touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];

    if (touch) {
        [Sparrow.juggler removeObjectsWithTarget:_pirateShip];
    }
}

@end

与精灵图集一起工作

到目前为止,我们已单独加载了每个图像并在屏幕上显示它们。精灵图集是将所有这些较小的图像组合成一个大图像的方法。当我们加载图像时,我们可以使用纹理的方式,就像我们习惯的那样。

当使用多个图像时,每当当前活动的纹理被另一个不同的纹理替换时,就会发生所谓的“纹理切换”。这种操作对性能影响很大,因此应尽可能避免。精灵图集允许我们通过使用相同的图像资产来表示多个不同的图像,从而避免纹理切换,并将绘制调用次数保持在最低。

精灵图集也可以用于精灵动画,其中一系列图像按顺序逐帧显示,这给人类眼睛造成了动画的错觉——就像翻书一样。

纹理图集是精灵图集的一种特殊化,它包含较小的图像,但它还提供了一个包含其子图像确切位置信息的元数据文件。在实践中,"纹理图集"和"精灵图集"通常被用作同义词。

注意

在我们开始之前,请下载本章所需的全部图形资源,链接为github.com/freezedev/pirategame-assets/releases/download/0.5/Graphics_05.zip

学习纹理格式

到目前为止,我们只使用了 PNG 图像。然而,让我们看看 iOS 中是否有其他纹理格式更适合我们的目的。剧透:有的。不谈那些冒失的话,我们将分析哪种纹理格式最适合我们的目的。

以下表格显示了不同文件格式下的海盗船图像。让我们比较一下它们的文件大小:

压缩 文件格式 文件大小
BMP 257 KB
无损 PNG 36.6 KB
依赖 PVR(在这种情况下为 RGBA8888) 257 KB

当我们加载 PNG 文件时,内部会发生什么?图像在加载时被解压缩——这会消耗 CPU 资源。对于其他传统图像格式,如 JPEG,也是如此。一旦图像被解压缩,它就变成了纹理。

PVR 是一种针对 iOS 设备或所有 iOS 设备上使用的 PowerVR GPU 专门优化的纹理格式。例如,在加载 PVR 图像时,它将直接在 GPU 上解码图像,而不是 CPU。

PVR 包含许多不同的图像格式。如果我们追求包括 alpha 通道的无损质量,我们应该选择 RGBA8888 格式。如果我们不需要 alpha 通道,我们应该使用不带 alpha 通道的图像格式。RGBA8888 图像格式未压缩。因此,为了将应用程序大小保持在最小,我们应该使用 pvr.gz 格式,这是一种使用 GZIP 压缩的 PVR 文件。

使用 TexturePacker 创建精灵表

** TexturePacker ** 是一个用于创建精灵表和纹理图集的商业应用程序,可在 www.codeandweb.com/texturepacker 以约 30 美元的价格获得。为了能够创建我们自己的精灵表,我们需要 TexturePacker 的专业版或试用版。TexturePacker 下载窗口如下所示:

Using TexturePacker to create sprite sheets

虽然工作流程相当直观,但让我们通过几个步骤来创建我们自己的纹理图集:

  1. 将图像 0001.png0032.png 拖放到应用程序的 ** Sprites ** 部分中。

  2. 选择 ** Sparrow/Starling ** 作为 ** 数据格式 **。

  3. 选择 ** GZIP compr. PVR ** 作为 ** 纹理格式 **。

  4. 选择 ** RGBA8888 ** 作为 ** 图像格式 **。

  5. 点击 ** AutoSD ** 按钮,并从预设中选择 ** corona @4x/@2x **。

  6. 将数据文件和纹理文件的文件名设置为 ship_pirate_small_cannon{v}.xmlship_pirate_small_cannon{v}.pvr.gz

  7. 点击 ** 发布 ** 按钮。

现在,我们的纹理图集已经为我们的每个支持分辨率生成。让我们看看结果。生成的图像之一输出如下截图:

Using TexturePacker to create sprite sheets

这里是相应的 XML 文件的一个片段:

<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with TexturePacker http://www.codeandweb.com/texturepacker-->
<!-- $TexturePacker:SmartUpdate:c58f88c054e0e917cc6c06d11cc04c15:0af47aa74ca5e538fac63da189c2b7ac:9e0a4549107632fbd952ab702bfc21e4$ -->
<TextureAtlas imagePath="ship_pirate_small_cannon.pvr.gz">
    <SubTexture name="e_0001" x="0" y="0" width="80" height="80"/>
    <SubTexture name="e_0003" x="80" y="0" width="80" height="80"/>
    <SubTexture name="e_0005" x="160" y="0" width="80" height="80"/>
    <SubTexture name="e_0007" x="240" y="0" width="80" height="80"/>

从这个片段中,我们可以看到对原始图像及其子纹理的引用。每个子纹理都有一个名称,它在较大图像中的位置以及其尺寸。

Loading our first texture atlas

现在我们有了纹理图集,让我们使用 Sparrow 加载并显示它。

Time for action – loading our first texture atlas

要加载我们的第一个纹理图集,我们需要遵循以下步骤:

  1. 将必要的文件 (ship_pirate_small_cannon*) 复制到项目中。

  2. 使用以下代码行加载纹理图集:

    SPTextureAtlas* atlas = [SPTextureAtlas atlasWithContentsOfFile:@"ship_pirate_small_cannon.xml"];
    
  3. 从所有以 00 开头的纹理中创建一个数组:

    NSArray* textures = [atlas texturesStartingWith:@"00"];
    
  4. 创建一个电影剪辑对象,并将其放置在原始海盗船的上方,如下面的代码所示:

    SPMovieClip *cannonShip = [SPMovieClip movieWithFrames:textures fps:20.0f];
    cannonShip.x = 200;
    cannonShip.y = 50;
    
  5. 使用以下代码片段播放动画:

    [cannonShip play];
    [Sparrow.juggler addObject:cannonShip];
    
  6. 按以下方式将动画海盗船添加到显示树中:

    [self addChild:background];
    [self addChild:enemyShip];
    [self addChild:_pirateShip];
    [self addChild:cannonShip];
    
  7. 运行示例以查看以下结果:Time for action – loading our first texture atlas

发生了什么?

要使用纹理图集,我们首先将所有相关文件复制到项目中。然后,使用SPTextureAtlas类,我们加载了 XML 文件。

在第 3 步中,我们需要从纹理图集中获取一个数组(或确切地说是一个NSArray),其中包含所有以00开头的图像,在我们的例子中这意味着这个精灵表中的每个图像都将用于动画。

SPMovieClip类是从SPDisplayObject派生出来的,也可以添加到显示树中。它可以播放我们在第 3 步中制作的数组中的动画。fps参数是必要的,因为它设置了动画的速度。

要播放动画本身,需要做两件事:首先,我们需要从电影剪辑中调用play方法;其次,我们需要将电影剪辑添加到表演者中。这正是我们在第 5 步中做的事情。

在下一步中,我们将电影剪辑添加到显示树中,当我们运行示例时,我们有了我们的海盗船,上下移动的敌舰,现在还有第二艘海盗船,它有开火动画。

如果你想查看这个示例的完整源文件,它可以在github.com/freezedev/pirategame/blob/71f42ded614c4917802dcba46a190476ff7b88c4/Classes/Battlefield.m找到。

突击测验

Q1. 什么是补间动画?

  1. 通过设置两个关键帧来定义动画的方法

  2. 由多个精灵组成的动画

  3. 一种优化屏幕上多个显示对象的方法

Q2. 什么是精灵表?

  1. 纸张上的草图

  2. 包含几个较小图像的图像

  3. Sparrow 扩展以使用精灵

Q3. 过渡用于修改动画随时间的变化率。

  1. 正确

  2. 错误

概述

在本章中,我们学习了补间动画和精灵表。

具体来说,我们介绍了如何使用补间动画来动画化显示对象,创建我们自己的精灵表,以及如何动画化这些精灵表。

我们还提到了纹理格式、表演者和过渡效果。

现在我们有了动画,我们的船在移动,让我们添加一些游戏逻辑——这是下一章的主题。

第六章。添加游戏逻辑

在上一章中,我们学习了如何使用 tweens 在屏幕上动画化我们的对象;我们还学习了精灵图集,生成了带有纹理信息的自己的精灵图集,并对其进行了动画化。

让我们快速看一下本章我们将解决哪些主题:

  • 射击炮弹,这是我们游戏的一个基本机制

  • 一旦在战场上展示出炮弹,它应该能够与敌舰相撞

  • 如果足够的炮弹击中敌舰,则摧毁敌舰

扩展资源管理器

在上一章中,我们加载了我们第一个纹理图集,并将每个子纹理作为电影剪辑中的帧进行显示。我们没有使用资源管理器来做这件事,因为我们还没有实现这个功能。

因此,让我们继续并允许我们的资源管理器处理纹理图集。

开始行动时间 - 向资源管理器添加纹理图集

我们可以通过以下步骤扩展我们的资源管理器:

  1. 如果尚未打开,请打开我们的游戏项目文件。

  2. 切换到TextureManager.h文件并声明registerTextureAtlas方法,如下所示:

    -(SPTextureAtlas *) registerTextureAtlas:(NSString *) filename;
    
  3. 切换到TextureManager.m文件并实现registerTextureAtlas方法,如下所示:

    -(SPTextureAtlas *) registerTextureAtlas:(NSString *) filename
    {
        if ([_dict objectForKey:filename] == nil) {
        return (SPTextureAtlas *) [self registerAsset:filename withContent:[SPTextureAtlas atlasWithContentsOfFile:filename]];
      } else {
        return (SPTextureAtlas *) [self registerAsset:filename withContent:nil];
      }
    }
    
  4. 转到Assets.h文件并添加静态方法textureAtlas

    +(SPTextureAtlas *) textureAtlas:(NSString*)filename;
    
  5. Assets.m文件中,通过其TextureManager实例实现以下方法:

    +(SPTextureAtlas *) textureAtlas:(NSString*)filename
    {
        return [textureAssets registerTextureAtlas:filename];
    }
    
  6. 在战场场景(Battlefield.m)中,导航到我们加载纹理图集和获取纹理的位置:

    SPTextureAtlas *atlas = [SPTextureAtlas atlasWithContentsOfFile:@"ship_pirate_small_cannon.xml"];
    
    NSArray *textures = [atlas texturesStartingWith:@"00"];
    

    将前面的代码替换为以下代码行:

    NSArray *textures = [[Assets textureAtlas:@"ship_pirate_small_cannon.xml"] texturesStartingWith:@"00"];
    
  7. 运行示例。我们将在屏幕上看到以下舰船星座:开始行动时间 – 向资源管理器添加纹理图集

发生了什么?

在步骤 1 中,我们从上一章结束的地方打开了我们的 Xcode 模板。为了加载纹理图集,我们需要切换到纹理管理器,我们指定它来加载所有与纹理有关联的内容。在步骤 2 中,我们声明了用于通过资产管理系统中使用纹理图集的方法。为了保持方法名称的一致性,我们将此方法命名为registerTextureAtlas,使其类似于registerTexture。签名类似于registerTexture,但它返回一个SPTextureAtlas实例而不是SPTexture

在下一步中,我们实现了通过filename参数加载纹理的registerTextureAtlas方法,我们使用SPTextureAtlas工厂方法,就像我们学习纹理图集时做的那样。

一旦我们完成了扩展纹理管理器部分,我们需要扩展 Assets 类,我们在第 4 步中通过添加第 5 步中实现的函数头来实现这一点。为了保持命名方案一致,我们把这个方法命名为 textureAtlas。在这个方法中,我们只是调用了我们的纹理管理器的 registerTextureAtlas 方法,并返回了结果。

我们在战场上更新了行,通过资产管理系统加载电影剪辑的纹理图集。而不是两行——一行用于设置纹理图集实例,另一行用于从图集中获取所需的纹理——我们现在只有一行代码,它使用上两步中的 textureAtlas 方法获取我们的纹理图集,然后获取电影剪辑所需的纹理。

当我们在上一步运行示例时,我们得到了与上一章结尾完全相同的结果,这是一个好兆头,表明更改按预期工作。

结构化我们的船只

到目前为止,我们的船只只是 SPImage 的实例,它们在我们的战场场景中直接附加了补间动画。为了将代码重复性降到最低,让我们将船只逻辑重构到它自己的类中。

行动时间 - 创建船只类

为了结构化我们船只的代码,请遵循以下步骤:

  1. 添加一个名为 Entities 的新组。

  2. 在这个组中,添加一个名为 Ship 的新 Objective-C 类,它从 SPSprite 派生出来。

  3. 打开 Ship.h 文件。为船只图像添加一个实例变量,并为船只射击炮弹的电影剪辑添加另一个实例变量,如下面的代码所示:

    SPMovieClip *_shootingClip;
    SPImage *_idleImage;
    
  4. 声明一个名为 initWithContentsOfFile 的备用初始化器,它接受一个 NSString 作为参数:

    -(id)initWithContentsOfFile:(NSString *)filename;
    
  5. 声明一个名为 shoot 的方法,如下面的代码所示:

    -(void) shoot;
    
  6. 声明另一个名为 moveTo 的方法,它接受 x 值作为其第一个参数,y 值作为其第二个参数,如下面的代码所示:

    -(void) moveToX:(float) x andY:(float) y;
    
  7. 声明一个名为 stop 的方法,如下面的代码所示:

    -(void) stop;
    
  8. 使用以下代码定义 Ship 类的默认初始化器:

    -(id) init
    {
      if ((self = [super init])) {
        NSArray *textures = [[Assets textureAtlas:@"ship_pirate_small_cannon.xml"] texturesStartingWith:@"00"];
    
        _shootingClip = [SPMovieClip movieWithFrames:textures fps:20.0f];
    
        if (_idleImage == nil) {
          _idleImage = [[SPImage alloc] init];
        }
    
        [self addChild:_shootingClip];
        [self addChild:_idleImage];
      }
    
      return self;
    } 
    
  9. 现在,定义一个接受 filename 作为参数的备用初始化器,如下面的代码所示:

    -(id) initWithContentsOfFile:(NSString *)filename
    {
      _idleImage = [[SPImage alloc] initWithTexture:[Assets texture:filename]];
    
      return [self init];
    }
    
  10. 使用以下代码实现 shoot 方法:

    -(void) shoot
    {
      [_shootingClip play];
      [Sparrow.juggler addObject:_shootingClip];
    }
    
  11. moveTo 方法的具体内容应如下所示:

    -(void) moveToX:(float)x andY:(float)y
    {
      [self stop];
    
      float targetX = x - (self.width / 2);
      float targetY = y - (self.height / 2);
    
      float distanceX = fabsf(self.x - targetX);
      float distanceY = fabsf(self.y - targetY);
    
      float penalty = (distanceX + distanceY) / 80.0f;
    
      float shipInitial = 0.25f + penalty;
    
      float speedX = shipInitial + (distanceX / 
        Sparrow.stage.width) * penalty * penalty;
      float speedY = shipInitial + (distanceY / Sparrow.stage.height) * penalty * penalty;
    
      SPTween *tweenX = [SPTween tweenWithTarget:self time:speedX];
      SPTween *tweenY = [SPTween tweenWithTarget:self time:speedY];
    
      [tweenX animateProperty:@"x" targetValue:targetX];
      [tweenY animateProperty:@"y" targetValue:targetY];
    
      [Sparrow.juggler addObject:tweenX];
      [Sparrow.juggler addObject:tweenY];
    }
    
  12. 实现名为 stop 的方法,如下面的代码所示:

    -(void) stop
    {
        [Sparrow.juggler removeObjectsWithTarget:self];
    }
    
  13. 切换到 Battlefield.h 文件,并更新类,使实例变量 _pirateShipShip 类型,如下面的代码所示:

    #import "Scene.h"
    #import "Ship.h"
    
    @interface Battlefield : Scene {
     Ship *_pirateShip;
    }
    
  14. 现在,切换到 Battlefield.m 文件。

  15. 在场景中更新 onBackgroundTouch 方法,如下面的代码所示:

    SPTouch *touch = [[event touchesWithTarget:self] anyObject];
    
    if (touch) {
      [_pirateShip moveToX:touch.globalX andY:touch.globalY];
    }
    
  16. 接下来,更新 onShipStop 方法,如下面的代码所示:

    SPTouch *touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];
    
    if (touch) {
      [_pirateShip stop];
    }
    
  17. 更新剩余的从 SPImageShip 类的引用,并删除所有关于炮舰的引用,如下面的代码所示:

    _pirateShip = [[Ship alloc] initWithContentsOfFile:@"ship_pirate.png"];
    _pirateShip.x = (Sparrow.stage.width - _pirateShip.width) / 2;
    _pirateShip.y = (Sparrow.stage.height - _pirateShip.height) / 2;
    
    Ship *ship = [[Ship alloc] initWithContentsOfFile:@"ship.png"];
    ship.x = 100;
    ship.y = 100;
    
  18. 运行示例。我们现在可以在屏幕上看到海盗船和敌舰:动手实践 – 创建船只类

发生了什么?

在游戏开发中,术语实体通常指的是屏幕上的一个对象,它与其他对象进行交互。以一个 2D 动作侧滚动游戏为例:敌舰以及玩家控制的船只都是实体。子弹也是实体。例如,子弹从玩家船只产生时,会与玩家船只进行交互。敌舰会与子弹交互;如果子弹击中敌舰,它需要通过减少生命值或被摧毁来做出反应。同样的情况也适用于玩家船只。

实体也在更高级的游戏开发技术中发挥作用,例如实体-组件模式,其中交互被描述为组件。然后,这些组件被附加到实体上。

我们需要从我们的游戏中提取的是不同游戏元素之间的清晰分离。在步骤 1 中,我们添加了一个名为Entities的新组。在下一步中,我们定义了我们第一个实体Ship,它是SPSprite的子类。在类名前添加前缀也是可能的,就像所有 Sparrow 类都有前缀SP一样。对于我们的游戏,前缀PG是有意义的,因为它代表 PirateGame。

船只有两个实例变量,我们在步骤 3 中声明了它们:一个是我们在战场场景中之前看到的炮弹射击动画,另一个是船只本身的图像。

除了默认初始化器外,我们在步骤 4 中声明了第二个初始化器。该方法接受filename作为参数。我们不想为海盗船创建一个单独的类。我们可以使用相同的类来处理这两种类型。我们只需要为敌舰或海盗船提供不同的filename参数。

我们的船只类需要以下行为:

  • 射击(步骤 5)

  • 将船只移动到特定位置(步骤 6)

  • 停止移动(步骤 7)

我们的Ship.h文件现在看起来像以下代码:

#import "SPSprite.h"

@interface Ship : SPSprite {
    SPMovieClip *_shootingClip;
    SPImage *_idleImage;
}

-(id)initWithContentsOfFile:(NSString *)filename;

-(void) shoot;

-(void) moveToX:(float) x andY:(float) y;
-(void) stop;

@end

在声明了Ship类的所有方法和实例变量之后,我们继续实现方法。在这样做之前,我们在步骤 8 中定义了初始化器:我们初始化了电影片段——使用射击海盗船的纹理图集——以及船只图像本身。与我们所知的不同之处在于,如果图像尚未初始化,我们将初始化它。

在步骤 9 中我们实现的第二个初始化器中,我们使用传递的文件名初始化了图像,并调用了默认初始化器。因此,如果调用了备用初始化器,我们不会用SPImage的新实例覆盖_idleImage实例变量。

到目前为止,Ship.m的完整代码如下:

#import "Ship.h"

#import "Assets.h"

@implementation Ship

-(id) init
{
    if ((self = [super init])) {
        NSArray *textures = [[Assets textureAtlas:@"ship_pirate_small_cannon.xml"] texturesStartingWith:@"00"];

        _shootingClip = [SPMovieClip movieWithFrames:textures fps:20.0f];

        if (_idleImage == nil) {
            _idleImage = [[SPImage alloc] init];
        }

        [self addChild:_shootingClip];
        [self addChild:_idleImage];
    }

    return self;
}

-(id) initWithContentsOfFile:(NSString *)filename
{
    _idleImage = [[SPImage alloc] initWithTexture:[Assets texture:filename]];

    return [self init];
}

在接下来的步骤中,我们实现了船只动作的方法:

  • 射击:播放_shooting电影片段(步骤 10)。

  • 移动:这是我们之前在战场场景中的backgroundTouch方法中拥有的船的移动逻辑。我们不是从船的实例中移除所有补间动画,而是从ship实例中调用了stop方法(步骤 11)。

  • 停止:从当前实例(步骤 12)移除所有补间动画。

在其完整性方面,这些方法看起来像以下代码片段:

-(void) shoot
{
    [_shootingClip play];
    [Sparrow.juggler addObject:_shootingClip];
}

-(void) moveToX:(float)x andY:(float)y
{
    [self stop];

    float targetX = x - (self.width / 2);
    float targetY = y - (self.height / 2);

    float distanceX = fabsf(self.x - targetX);
    float distanceY = fabsf(self.y - targetY);

    float penalty = (distanceX + distanceY) / 80.0f;

    float shipInitial = 0.25f + penalty;

    float speedX = shipInitial + (distanceX / Sparrow.stage.width) * penalty * penalty;
    float speedY = shipInitial + (distanceY / Sparrow.stage.height) * penalty * penalty;

    SPTween *tweenX = [SPTween tweenWithTarget:self time:speedX];
    SPTween *tweenY = [SPTween tweenWithTarget:self time:speedY];

    [tweenX animateProperty:@"x" targetValue:targetX];
    [tweenY animateProperty:@"y" targetValue:targetY];

    [Sparrow.juggler addObject:tweenX];
    [Sparrow.juggler addObject:tweenY];
}

-(void) stop
{
    [Sparrow.juggler removeObjectsWithTarget:self];
}

@end

在最后几步中,我们更新了战场场景。首先,我们更新了头文件。我们需要导入Ship.h文件,并且_pirateShip实例变量不再是SPImage的指针,而是Ship类的指针。

在这一步之后,我们的Battlefield.h文件包含以下内容:

#import "Scene.h"
#import "Ship.h"

@interface Battlefield : Scene {
    Ship* _pirateShip;
}

@end

我们更新了战场场景中的触摸交互:

  • onBackgroundTouch:由于我们将移动逻辑移动到了Ship类,我们只需要调用正确的方法,即moveTo,并传递touchxy坐标(步骤 15)。

  • onShipStop:与moveTo方法类似,我们只需要调用船本身的stop方法(步骤 16)。

Battlefield.m文件中的触摸事件应该类似于以下代码片段:

#import "Battlefield.h"
#import "Assets.h"

@implementation Battlefield

-(void) onBackgroundTouch:(SPTouchEvent*) event
{
    SPTouch *touch = [[event touchesWithTarget:self] anyObject];

    if (touch) {
        [_pirateShip moveToX:touch.globalX andY:touch.globalY];
    }
}

-(void) onShipStop:(SPTouchEvent*) event
{
    SPTouch *touch = [[event touchesWithTarget:self andPhase:SPTouchPhaseBegan] anyObject];

    if (touch) {
        [_pirateShip stop];
    }
}

在下一步中,我们更新了船的初始化器。我们不再需要cannonShip电影剪辑,因为这个是Ship类中的一个实例变量。

让我们看看以下代码中的初始化器,它绑定这些触摸选择器并设置船本身:

-(id) init
{
    if ((self = [super init])) {
        SPImage *background = [SPImage imageWithTexture:[Assets texture:@"water.png"]];
        background.x = (Sparrow.stage.width - background.width) / 2;
        background.y = (Sparrow.stage.height - background.height) / 2;

        _pirateShip = [[Ship alloc] initWithContentsOfFile:@"ship_pirate.png"];
        _pirateShip.x = (Sparrow.stage.width - _pirateShip.width) / 2;
        _pirateShip.y = (Sparrow.stage.height - _pirateShip.height) / 2;

        Ship *ship = [[Ship alloc] initWithContentsOfFile:@"ship.png"];
        ship.x = 100;
        ship.y = 100;

        SPTween *shipTween = [SPTween tweenWithTarget:ship time:4.0f transition:SP_TRANSITION_EASE_IN_OUT];
        [shipTween animateProperty:@"y" targetValue:250];
        shipTween.repeatCount = 5;
        shipTween.reverse = YES;
        shipTween.delay = 2.0f;

        [Sparrow.juggler addObject:shipTween];
        [background addEventListener:@selector(onBackgroundTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
        [_pirateShip addEventListener:@selector(onShipStop:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
        [self addChild:background];
        [self addChild:ship];
        [self addChild:_pirateShip];
    }

    return self;
}

@end

当我们运行示例时,我们在屏幕上看到了两艘船;如果我们触摸任何地方,我们的海盗船会移动到那个点,就像我们预期的那样。如果我们触摸船在移动过程中,它会停止。

扩展船类

现在我们已经完成了船类的基础,让我们考虑我们需要为船类添加什么:

  • 生命值:如果一艘船被击中,生命值应该反映这种状态。船将从一个给定的生命值开始,比如 20,每次被击中都会失去一些。当它最终达到零生命值时,它将被完全摧毁。

  • 方向:这是船面对的方向。根据方向,炮弹将从船的每一边发射。

我们应该避免在不必要的情况下使用文件名,例如现在,当我们初始化船的实例时。

在我们继续进行编码部分之前,让我们从github.com/freezedev/pirategame-assets/releases/download/0.6/Graphics_06.zip下载最新的图片。这些图片包括海盗船和炮弹的更新精灵表,我们将在以后使用。

删除ship_small_cannon文件,并将新文件复制到项目中。这可以轻松完成,但 Xcode 并不总是喜欢替换现有文件。

是时候采取行动了——为船类添加更多功能

让我们按照以下步骤扩展船类:

  1. 打开Ship.h文件。

  2. 添加一个用于船的八个方向的 enum,如下面的代码所示:

    typedef NS_ENUM(NSInteger, ShipDirection) {
        DirectionNorth,
        DirectionSouth,
        DirectionWest,
        DirectionEast,
        DirectionNorthWest,
        DirectionNorthEast,
        DirectionSouthWest,
        DirectionSouthEast
    };
    
  3. 添加一个用于船类型的 enum,如下面的代码所示:

    typedef NS_ENUM(NSInteger, ShipType) {
        ShipPirate,
        ShipNormal
    };
    
  4. _shootingClip 实例变量改为指向 NSArray 类的指针,并移除 _idleImage 实例变量,如下面的代码所示:

    NSArray *_shootingClip;
    
  5. 为船的 hitpoints 添加一个属性,如下面的代码行所示:

    @property int hitpoints;
    
  6. type 添加另一个属性,如下面的代码行所示:

    @property ShipType type;
    
  7. 第三个属性是船的方向:

    @property (nonatomic) ShipDirection direction;
    

    因为我们将需要为这个属性编写自定义的获取器和设置器,所以我们需要一个同名的实例变量,前面加下划线:

    @interface Ship : SPSprite {
        NSArray *_shootingClip;
        ShipDirection _direction;
    }
    
  8. initWithContentsOfFile 方法声明替换为 initWithType。此方法以 ShipType 作为其参数,如下面的代码行所示:

    -(id)initWithType:(ShipType)type;
    
  9. 切换到 Ship.m 文件。

  10. 让我们使用以下代码实现 initWithType 方法:

    -(id) initWithType:(ShipType)type
    {
        if ((self = [super init])) {
            self.hitpoints = 100;
            self.type = type;
    
            SPTextureAtlas *atlas = (type == ShipPirate) ? [Assets textureAtlas:@"ship_pirate_small_cannon.xml"] : [Assets textureAtlas:@"ship_small_cannon.xml"] ;
    
            NSArray *texturesNorth = [atlas texturesStartingWith:@"n_00"];
            // ...
    
            float animationFPS = 12.0f;
    
            SPMovieClip *clipNorth = [SPMovieClip movieWithFrames:texturesNorth fps:animationFPS];
            // ...
    
            _shootingClip = [NSArray arrayWithObjects:clipNorth, clipSouth, clipWest, clipEast, clipNorthWest, clipNorthEast, clipSouthWest, clipSouthEast, nil];
    
            for (SPMovieClip* clip in _shootingClip) {
                clip.loop = NO;
                [self addChild:clip];
            }
    
            self.direction = DirectionSouthWest;
        }
    
        return self;
    }
    
  11. 删除 initWithContentsOfFile 方法,并将默认初始化器更新为使用 initWithType 方法,如下面的代码所示:

    -(id) init
    {
        return [self initWithType:ShipNormal];
    }
    
  12. 通过简单地返回 _direction 实例变量来实现 direction 属性的自定义获取器。

  13. direction 属性的设置器需要以下代码:

    _direction = direction;
    
    for (SPMovieClip* clip in _shootingClip) {
      clip.visible = NO;
    }
    
    ((SPMovieClip *) _shootingClip[_direction]).visible = YES;
    
  14. shoot 方法的内联内容替换为以下行:

    for (SPMovieClip* clip in _shootingClip) {
      [Sparrow.juggler removeObjectsWithTarget:clip];
    }
    
    [_shootingClip[self.direction] play];
    [Sparrow.juggler addObject:_shootingClip[self.direction]];
    
    [_shootingClip[self.direction] addEventListenerForType:SP_EVENT_TYPE_COMPLETED block:^(SPEvent *event)
    {
      [_shootingClip[self.direction] stop];
    }];
    
  15. moveTo 方法中,在创建 tweenXtweenY 对象之后,声明并定义两个变量,这些变量应该检测船将旋转到的方向,如下面的代码所示:

    int signX = 0;
    int signY = 0;
    
  16. 只有在达到某个特定阈值时才更新值,如下面的代码所示:

    if (distanceX > 40) {
      signX = (self.x - targetX) / distanceX;
    }
    
    if (distanceY > 40) {
      signY = (self.y - targetY) / distanceY;
    }
    
  17. 如果 signX 的值为 1signY 仍为 0,则将 direction 更改为 DirectionEast,如下面的代码所示:

    if ((signX == 1) && (signY == 0)) {
      self.direction = DirectionEast;
    }
    
  18. 对所有剩余的方向重复此操作。

  19. 切换到 Battlefield.m 文件。

  20. 更新海盗船和敌船的初始化器。只有海盗船需要从 ShipType 类型中获取 PirateShip 值。

  21. onShipStop 方法内部,添加当海盗船被点击两次时射击以及当船被点击一次时停止的功能,如下面的代码所示:

    if (touch) {
      if (touch.tapCount == 1) {
        [_pirateShip stop];
      } else if (touch.tapCount == 2) {
        [_pirateShip shoot];
      }
    }
    
  22. onShipStop 及其所有引用重命名为 onShipTap

  23. 运行示例。

我们现在看到船正在移动到我们触摸屏幕的方向。

行动时间 – 向船类添加更多功能

发生了什么?

首先,我们打开了船的头文件,然后定义了一个包含所有方向状态的 enum。Objective-C 提供了一个方便的 NS_ENUM 宏,它允许我们做到这一点。作为第一个参数,我们需要 enum 将表示的类型。第二个参数是 enum 类型的名称。这个 enum 类型有八个状态:北、南、西、东以及这些方向的组合。

在第 3 步中,我们定义了一个用于船类型的第二个 enum。它可以是海盗船或没有黑旗的正常敌船。

在第 4 步中,我们重新定义了_shootingClip实例的类型。这种变化的理由是我们将所有电影剪辑保存在一个数组中,并且可以通过索引访问特定的电影剪辑。

在接下来的几个步骤中,我们向类中添加了一些属性,如下所示:

  • hitpoints:这表示船当前有多少生命值(第 5 步)

  • type:这表示船的类型(第 6 步)

  • direction:这表示船面向的方向(第 7 步)

当我们创建船类实例时,只需将文件名添加到每个初始化器调用中,这本来是可以的,但如果我们更改了所有这些文件名,或者屏幕上有不止几艘船,就会变得混乱。这就是为什么我们用initWithType方法替换了initWithContentsOfFile方法。

接下来,我们实现了我们刚刚声明的所有方法。我们从最后声明的方法开始。由于这是我们首选的初始化器,所以我们了解了这里发生的情况:

  • 我们将hitpoints属性设置为100。虽然100或多或少是一个随机数字,但它是一个很好的起点,因为它很容易计算。例如,假设我们需要四次打击来摧毁任何船只;炮弹的破坏潜力是 25。

  • 我们将type属性设置为type参数的值。

  • 我们根据类型将纹理图集设置为海盗船图集或其他选项。三元运算只是编写一个if语句的一种花哨方式,如下面的代码行所示:

    if (type == ShipPirate) { ... } else { ... }
    

    三元运算的优势在于我们可以直接将结果分配给变量。

  • 我们为每个方向获取了纹理。在纹理图集中,射击动画的每个方向都以方向的缩写前缀命名:n代表北,nw代表西北,依此类推。

  • 然后,我们定义了动画的速度。我们将其设置为每秒 12 帧,因为我们不希望动画比平时慢。毕竟,操作大炮是困难的。

  • 就像我们为每个方向创建了一个NSArray实例一样,我们还需要为所有电影剪辑做同样的事情。我们也可以将这一行写成以下形式:

    _shootingClip = @[clipNorth, clipSouth, clipWest, clipEast, clipNorthWest, clipNorthEast, clipSouthWest, clipSouthEast];
    
  • 我们将所有电影剪辑添加到_shootingClip实例变量中。

  • 通过遍历_shootingClip实例变量,我们将所有电影剪辑添加到显示树中。我们还希望电影剪辑只播放一次,这就是为什么我们将loop属性设置为NO

  • 船的默认方向是西南方向。

在第 11 步中,我们通过仅调用带有ShipNormal类型的initWithType初始化器,显著简化了默认初始化器。

我们从为direction属性创建自定义的 getter 和 setter 开始。我们在属性定义中添加了nonatomic关键字。这是一种性能优化方法,可以使生成的访问器更快,但不是线程安全的。由于 Sparrow 应该只用于单线程,所以在我们的游戏中使用nonatomic是安全的。在内部,Objective-C 已经将 getter 和 setter 定义为propertyNamesetPropertyName,或者在我们的情况下,directionsetDirection

要使用我们自己的代码,我们只需覆盖这些方法。direction属性的 getter 相对简单,因为它只需要返回_direction实例变量。

在我们为direction属性自定义的 setter 中,我们首先需要将实例变量_direction设置为参数的值。然后,我们遍历所有电影剪辑并将它们的visible属性设置为NO。接着,我们显示了当前方向的电影剪辑。这与我们通过场景导演展示场景的方式非常相似。

在第 14 步中,我们使用以下步骤更新了shoot方法:

  • 我们从杂技演员中移除了所有来自任何电影剪辑的可动画对象。

  • 我们播放了当前方向的电影剪辑并将其添加到杂技演员中。

  • 我们为电影剪辑添加了一个事件监听器,当电影剪辑动画完成时触发。我们使用了一个块而不是选择器。块(在非 Objective-C 环境中也称为闭包)是一个可以访问非局部变量的函数。因此,虽然我们可以在块内部定义变量,但我们访问变量就像在shoot方法内部声明一个语句一样。块有一定的吸引力,因为我们不需要为几行代码定义一个单独的选择器。在使用块时,我们需要注意一些事情,但 Xcode 通常会警告我们潜在的副作用。

  • 在我们的块内部,我们停止了电影剪辑,因为它不会重置自己。这就像倒带 VHS 磁带一样。

在这个时候,我们更新了当船移动时的方向。为了实现这一点,我们在moveTo方法内部定义了两个变量:signXsignY。它们的默认值都是0

这个想法是将我们从moveTo方法中得到的方向值映射到ShipDirection值。如果signY1,它将映射到DirectionNorth;如果signX-1,它将映射到DirectionWest;如果两者同时具有相同的值,它们将映射到DirectionNorthWest

我们将signX变量设置为对象的x坐标减去目标x坐标,然后除以distanceX。因此,我们的signX值要么是1,要么是-1。对于signY变量也是同样的情况。

现在,如果我们移动船,我们只能得到DirectionNorthWestDirectionNorthEastDirectionSouthEastDirectionSouthWest这样的方向。在一条线上两次点击同一个像素点几乎是不可能的。这就是为什么我们需要一个阈值。只有当距离超过 40 个点时,我们才将signXsignY分别设置为1-1。在这种情况下,40 不是一个随机的数字;根据 Apple 的说法,一个 40 x 40 点的矩形是点击的平均大小。

在步骤 17 和 18 中,我们将signXsignY变量映射到ShipDirection值,并相应地设置了direction属性。

在战场场景中,我们需要创建我们的船实例。对于敌船,我们使用了默认初始化器。

在步骤 21 中,我们更新了onShipStop方法。我们利用touch对象的tapCount属性来查看对象被点击了多少次。如果船被点击了一次,它会停止移动;如果被点击了两次,它会射击。

由于onShipStop方法不仅停止了船,而且在被双击时射击,因此将其重命名为onShipTap是一个好主意。

当我们运行示例时,船会根据我们在屏幕上点击的位置改变方向,当我们双击船时,我们会看到炮弹动画。

射击炮弹

当我们双击我们的船时,动画会播放。然而,有一个明显的东西缺失,那就是炮弹!让我们继续添加一些炮弹。

行动时间 - 允许船射击炮弹

让我们按照以下步骤允许海盗船射击炮弹:

  1. 打开Ship.h文件。

  2. 添加一个只读属性isShooting,它有一个实例变量对应项_isShooting,如下面的代码所示:

    @property (readonly) BOOL isShooting;
    
  3. 为船的左侧和右侧添加一个炮弹。它们都是指向SPImage的指针,如下面的代码所示:

    @property SPImage *cannonBallLeft;
    @property SPImage *cannonBallRight;
    
  4. 切换到Ship.m文件。

  5. initWithType方法内部,将_isShooting实例变量设置为NO,位于方法顶部。

  6. initWithType方法内部,使用cannonball.png图像创建两个炮弹,将它们的visible属性设置为NO,并将它们添加到显示树中。

  7. shoot方法内部,如果_isShooting设置为YES则中止,否则将_isShooting设置为YES,如下所示:

    if (_isShooting) {
      return;
    }
    
    _isShooting = YES;
    
  8. 为动画速度和目标位置设置一些默认值,如下面的代码所示:

    float shootingTime = 1.25f;
    float innerBox = 25.0f;
    float targetPos = 30.0f;
    
  9. 添加对当前方向的影片剪辑的引用,如下面的代码行所示:

    SPMovieClip *currentClip = _shootingClip[self.direction];
    
  10. 为每个炮弹及其相应的xy属性创建一个补间对象:

    SPTween *tweenCbLeftX = [SPTween tweenWithTarget:self.cannonBallLeft time:shootingTime];
    SPTween *tweenCbLeftY = [SPTween tweenWithTarget:self.cannonBallLeft time:shootingTime];
    SPTween *tweenCbRightX = [SPTween tweenWithTarget:self.cannonBallRight time:shootingTime];
    SPTween *tweenCbRightY = [SPTween tweenWithTarget:self.cannonBallRight time:shootingTime];
    
  11. 按照以下代码设置炮弹及其补间属性的方向对:

    switch (self.direction) {
      case DirectionNorth:
      case DirectionSouth:
        self.cannonBallLeft.x = (-self.cannonBallLeft.width / 2) + innerBox;
        self.cannonBallLeft.y = (currentClip.height - self.cannonBallLeft.height) / 2;
    
        self.cannonBallRight.x = (-self.cannonBallRight.width / 2) + currentClip.width - innerBox;
        self.cannonBallRight.y = (currentClip.height - self.cannonBallRight.height) / 2;
    
        [tweenCbLeftX animateProperty:@"x" targetValue:self.cannonBallLeft.x - targetPos];
        [tweenCbRightX animateProperty:@"x" targetValue:self.cannonBallRight.x + targetPos];
    
        break;
    
      default:
        break;
    }
    
  12. DirectionEast/DirectionWest对设置炮弹。

  13. 将两个炮弹设置为在屏幕上可见,并将所有与炮弹相关的补间添加到主杂技师中。

  14. 在移除所有来自电影剪辑的缓动效果之后,立即移除所有来自炮弹的缓动效果。

  15. 一旦电影剪辑播放完毕,将 _isShooting 实例变量设置为 NO 并隐藏两个炮弹。

  16. 运行示例。现在我们的海盗船可以发射炮弹,如下面的截图所示:行动时间 – 允许船发射炮弹

刚才发生了什么?

我们在这个示例中从 Ship 类的头文件开始,添加了一些新的属性,例如:

  • isShooting:这表示船当前是否在射击(步骤 2)

  • cannonBallLeft:这表示从船的左侧发射的炮弹(步骤 3)

  • cannonBallRight:这表示从船的右侧发射的炮弹(步骤 3)

在接下来的步骤中,我们使用以下步骤修改了 initWithType 方法:

  • 我们将 _isShooting 设置为默认值,即 NO(步骤 5)

  • 我们创建了炮弹对象(步骤 6)

  • 我们隐藏了两个炮弹(步骤 6)

  • 我们将炮弹添加到显示树中(步骤 6)

让我们进入 shoot 方法,看看这里有什么变化:

  • 我们只有在船没有射击时才执行该方法,以最小化潜在的副作用并防止有人一直点击船(步骤 7)。

  • 我们在射击发生时定义了变量 shootingTime。它被设置为 1.2 秒,因为这大约是电影剪辑动画的长度(步骤 8)。

  • 变量 innerBox 是船图像边缘到实际图像本身的距离(步骤 8)。

  • 变量 targetPos 存储了炮弹将飞行的距离(步骤 8)。

  • 为了方便,我们定义了 currentClip 变量,这样我们就不必每次想要访问当前方向的动画剪辑时都输入 _shootingClip[self.direction](步骤 9)。

  • 我们为每个坐标和炮弹定义了一个缓动效果,所以到目前为止,我们总共有四个缓动效果(步骤 10)。

  • 在步骤 11 和 12 中,我们设置了炮弹的位置和缓动效果。

  • 我们需要在屏幕上看到炮弹,这就是为什么我们将它们设置为可见。为了看到相应的动画,我们需要将缓动效果添加到杂耍者中(步骤 13)。

  • 在实际播放动画之前,我们还移除了所有来自炮弹的缓动效果(步骤 14)。

  • shoot 方法中最后需要更新的东西是将 _isShooting 实例变量设置为 NO,一旦动画完成,并在同一块中隐藏两个炮弹(步骤 15)。

当我们运行示例并双击我们的海盗船时,电影剪辑播放,两个巨大的炮弹从船的两侧出现。

尝试一下,英雄

到目前为止,船不能斜向射击。请自行实现这个功能。

碰撞检测

在我们实现碰撞检测之前,让我们看看不同类型的碰撞检测:

  • 边界框碰撞:我们检查实体的边界(这是一个矩形)。如果这些矩形相交,则表示发生了碰撞。

  • 边界球体碰撞:我们计算两个实体之间的距离。如果距离小于两个实体半径之和,则这些实体正在碰撞。

  • 像素碰撞:我们检查一个实体的所有像素是否与另一个实体的像素相交。虽然这确实是最详细和全面的碰撞检查,但它也是最占用 CPU 资源的一种。

现在我们已经让海盗船实际上开始射击炮弹了,让我们实现可以击沉敌舰的功能。我们使用边界框碰撞,因为这是最容易实现的碰撞检测类型之一。

行动时间 – 让炮弹与船只碰撞

要检查炮弹是否与敌舰碰撞,请按照以下步骤操作:

  1. 打开 Ship.h 文件。

  2. 我们需要为 hitpoints 属性添加自定义的获取器和设置器,所以让我们将这个属性设置为 nonatomic 并添加一个名为 _hitpoints 的实例变量。

  3. 声明 abortShootinghit 方法。

  4. 切换到 Ship.m 文件。

  5. 自定义的 hitpoints 获取器仅返回实例变量 _hitpoints

  6. hitpoints 的自定义设置器包含以下代码:

    -(void) setHitpoints:(int)hitpoints
    {
        _hitpoints = hitpoints;
        if (_hitpoints <= 0) {
            self.visible = NO;
        }
    }
    
  7. abortShooting 方法包含以下行:

    -(void) abortShooting
    {
        _isShooting = NO;
    
        [Sparrow.juggler removeObjectsWithTarget:self.cannonBallLeft];
        [Sparrow.juggler removeObjectsWithTarget:self.cannonBallRight];
    
        self.cannonBallLeft.visible = NO;
        self.cannonBallRight.visible = NO;
    }
    
  8. hit 方法包含以下内容:

    -(void) hit
    {
        self.hitpoints = self.hitpoints - 25;
    
        for (SPMovieClip* clip in _shootingClip) {
            SPTween *tween = [SPTween tweenWithTarget:clip time:0.3f];
            tween.reverse = YES;
            tween.repeatCount = 2;
    
            [tween animateProperty:@"color" targetValue:SP_RED];
            [Sparrow.juggler addObject:tween];
        }
    }
    
  9. 在战场头文件中,我们需要添加一个名为 _enemyShip 的实例变量,它是指向 Ship 类的指针。

  10. 更新从 ship_enemyShip 的引用。

  11. 添加一个事件监听器来监听 SP_EVENT_TYPE_ENTER_FRAME 事件,如下面的代码所示:

    [self addEventListener:@selector(onEnterFrame:) atObject:self forType:SP_EVENT_TYPE_ENTER_FRAME];
    
  12. 使用以下代码实现 onEnterFrame 方法:

    -(void) onEnterFrame:(SPEvent *)event
    {
      if (_pirateShip.isShooting) {
        SPRectangle *enemyShipBounds = [_enemyShip boundsInSpace:self];
        SPRectangle *ball1 = [_pirateShip.cannonBallLeft boundsInSpace:self];
        SPRectangle *ball2 = [_pirateShip.cannonBallRight boundsInSpace:self];
    
        if ([enemyShipBounds intersectsRectangle:ball1] || [enemyShipBounds intersectsRectangle:ball2]) {
          if (_pirateShip.cannonBallLeft.visible || _pirateShip.cannonBallRight.visible) {
            [_pirateShip abortShooting];
            [_enemyShip hit];
          }
        }
      }
    }
    
  13. 运行示例。当敌舰被击中时,它会短暂地闪烁红色,如下面的截图所示:行动时间 – 让炮弹与船只碰撞

发生了什么?

在步骤 2 中,我们更新了 hitpoints 属性,以便我们可以添加自定义的获取器和设置器。在下一步中,我们声明了 abortShootinghit 方法。我们需要第一个方法来取消当前的射击动画,第二个方法则在船只被击中时执行某些操作。

我们在步骤 5 和 6 中分别定义了自定义的获取器和设置器。对于获取器,我们只是返回了我们在步骤 2 中声明的实例变量 _hitpoints。对于设置器,我们设置了该实例变量;但是当 _hitpoints 等于或低于零时,我们隐藏了船只。

步骤 7 中的 abortShooting 方法设置了 _isShooting 实例变量,从炮弹中移除了所有补间动画,并隐藏了炮弹。

hit 方法从 hitpoints 中减去 25 分,并添加了一个动画,让船只非常短暂地闪烁红色,以便在船只被击中时提供一些视觉反馈。

在接下来的两个步骤中,我们将敌舰实例重构为一个实例变量,而不是初始化器内部的局部变量。我们还更新了所有对敌舰的引用。

在步骤 11 中,我们添加了一个事件监听器。这个事件监听器在每个帧上被调用。在下一个步骤中,我们实现了敌舰和海盗船的炮弹之间的碰撞。

首先,我们需要从每个对象相对于当前场景的边界值。我们需要查看是否有任何炮弹与敌舰相交。为了确保大炮实际上在开火,我们检查了炮弹的可见性,然后从敌舰调用了 hit 方法,从海盗船调用了 abortShooting 方法。后者是必要的,否则检查会再次发生,并且结果为正,以至于敌舰会立即被摧毁,我们甚至看不到那艘闪烁的红船。

当我们运行示例时,我们需要准确击中敌舰四次,它才会消失。每次敌舰被击中,它都会短暂地闪烁红色。

加载游戏相关数据

让我们反思一下此刻我们拥有的游戏相关数据类型。它们是:

  • 每艘船的生命值

  • 炮弹造成的损伤

  • 战场中每艘船的位置

我们应该将这些数据放在一个文件中,并在游戏中加载它。

行动时间 – 避免硬编码值

为了分离和加载我们的游戏相关数据,我们需要遵循以下步骤:

  1. Resources 文件夹中添加一个名为 gameplay.json 的新文件,内容如下:

    {
        "hitpoints": 100,
        "damage": 25,
        "battlefield": {
            "enemy": {
                "x": 100,
                "y": 100
            },
            "pirate": {
                "x": 300,
                "y": 100
            }
        }
    }
    
  2. 打开 Ship.h 文件。

  3. 添加一个名为 maxHitpoints 的属性,如下面的代码行所示:

    @property int maxHitpoints;
    
  4. Ship 初始化器内部,将设置 hitpoints 的代码段替换为以下几行代码:

    self.maxHitpoints = [(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"hitpoints"] intValue];
    
    self.hitpoints = self.maxHitpoints;
    
  5. hit 方法内部,将硬编码的损伤值替换为从 gameplay.json 文件中加载的值,如下面的代码所示:

    self.hitpoints = self.hitpoints - [(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"damage"] intValue];
    
  6. Battlefield.m 文件内部,将硬编码的船只位置替换为来自 gameplay.json 文件的那些,如下面的代码所示:

    NSDictionary *gameplayFile = [Assets dictionaryFromJSON:@"gameplay.json"];
    
    _pirateShip = [[Ship alloc] initWithType:ShipPirate];
    _pirateShip.x = [(NSNumber *) gameplayFile[@"battlefield"][@"pirate"][@"x"] floatValue];
    _pirateShip.y = [(NSNumber *) gameplayFile[@"battlefield"][@"pirate"][@"y"] floatValue];
    
    _enemyShip = [[Ship alloc] init];
    _enemyShip.x = [(NSNumber *) gameplayFile[@"battlefield"][@"enemy"][@"x"] floatValue];
    _enemyShip.y = [(NSNumber *) gameplayFile[@"battlefield"][@"enemy"][@"y"] floatValue];
    
  7. 运行示例。

我们现在不再在代码中硬编码值,而是从文件中加载这些值。因此,船只处于不同的起始位置,如下面的截图所示:

行动时间 – 避免硬编码值

刚才发生了什么?

在步骤 1 中,我们创建了 JSON 文件,其中包含我们稍后将要加载的值。我们目前拥有的值包括生命值、损伤值,当然还有船只的位置。

Ship.h 文件内部,我们添加了一个名为 maxHitpoints 的新属性,它表示任何船只的最大健康值。

在第 4 步中,我们首先使用gameplay.json文件中的hitpoints属性设置maxHitpoints属性。由于属性是从gameplay.json文件加载的,并且其类型为id,我们需要将其转换为更熟悉的数据类型。我们将它转换为指向NSNumber的指针,然后通过intValue方法使用其整数值。

在下一步中,我们对damage属性也做了同样的处理。

在第 6 步中,我们切换到战场场景,并更新了船只的位置,以反映来自gameplay.json文件中的相同位置。

当我们运行示例时,我们的船只位于我们在gameplay.json文件中定义的位置。射击和摧毁敌人按预期工作。

突击测验

Q1. 如何描述事件监听器?

  1. 使用块

  2. 使用选择器

  3. 使用选择器或块

Q2. 何时调用注册到SP_TYPE_EVENT_ENTER_FRAME的事件?

  1. 一旦在添加到显示树的第一帧中

  2. 每一帧一次

  3. 从不

Q3. 使用 Sparrow 无法检测双击。

  1. 正确

  2. 错误

摘要

在本章中,我们学习了如何将基本游戏逻辑元素添加到我们的游戏中。

具体来说,我们介绍了如何构建我们的代码。我们对触摸、事件监听器和碰撞检测有了更深入的了解。

现在我们的小海盗船实际上可以射击并击中东西了,让我们添加用户界面元素——这是下一章的主题。

第七章。用户界面

在前一章中,我们学习了如何将第一个游戏元素添加到我们的游戏中。现在,我们的海盗船可以发射炮弹,如果炮弹击中敌人足够多次,敌人的船就会被摧毁。

在本章中,我们将添加用户界面元素。具体来说,我们将从以下方面改进我们的游戏:

  • 显示和更新每艘船的生命值

  • 在屏幕上添加按钮

  • 在屏幕上显示文本

因此,让我们先在屏幕上添加生命值的视觉表示。

显示每艘船的生命值

默认情况下,每艘船有 100 点生命值,每颗炮弹对生命值造成 25 点伤害。当炮弹击中船时,我们确实有一些视觉反馈,但一旦被击中几次,我们就不知道船有多少生命值了。

行动时间 – 在每艘船顶部放置生命值条

要显示每艘船的生命值,我们只需遵循以下步骤:

  1. 如果游戏项目文件尚未打开,请打开我们的游戏项目文件。

  2. 切换到Ship.h文件。

  3. 添加一个名为_quadHitpoints的实例变量,它是指向SPQuad的指针,如下面的代码行所示:

    SPQuad *_quadHitpoints;
    
  4. 切换到Ship.m文件。在初始化器中创建炮弹图像之后,我们添加一个四边形,它应该是我们的生命值表示的边界,如下面的代码所示:

    float hitpointsHeight = 5.0f;
    SPQuad *hitpointsBorder = [SPQuad quadWithWidth:clipNorth.width height:hitpointsHeight color:SP_BLACK];
    
  5. 我们添加了生命值框的背景,如下面的代码所示:

    uint redColor = SP_COLOR(200, 0, 0);
    SPQuad *quadMaxHitpoints = [SPQuad quadWithWidth:hitpointsBorder.width - 2.0f height:hitpointsHeight - 2.0f color:redColor];
    
  6. 我们将生命值框的背景设置为一点边距,这意味着其位置需要相对于本地坐标系向左和向上各一点:

    quadMaxHitpoints.x = 1.0f;
    quadMaxHitpoints.y = 1.0f;
    
  7. 然后,我们从_quadHitpoints实例变量创建SPQuad,如下面的代码所示:

    uint greenColor = SP_COLOR(0, 180, 0);
    _quadHitpoints = [SPQuad quadWithWidth:hitpointsBorder.width - 2.0f height:hitpointsHeight - 2.0f color:greenColor];
    
  8. 如以下代码所示,我们将生命值设置为与背景相同的坐标:

    _quadHitpoints.x = quadMaxHitpoints.x;
    _quadHitpoints.y = quadMaxHitpoints.y;
    
  9. 然后,我们将所有的生命值四边形添加到显示树中,如下面的代码所示:

    [self addChild:hitpointsBorder];
    [self addChild:quadMaxHitpoints];
    [self addChild:_quadHitpoints];
    
  10. setHitpoints方法内部,在将实例变量设置为参数值的语句之后添加以下代码行:

    _quadHitpoints.scaleX = (float) _hitpoints / self.maxHitpoints;
    
  11. 运行示例。

如以下截图所示,两艘船现在都以其红色和绿色条表示其生命值数量:

行动时间 – 在每艘船顶部放置生命值条

发生了什么?

在第一步中,我们打开了我们在上一章中停止的 Xcode 模板。首先,我们需要一个实例变量来表示我们的生命值。如果我们考虑其他游戏如何显示当前的生命值,在策略游戏中通常,生命值以每个单位上方的绿色和红色条表示。在格斗游戏中,每个玩家的生命值显示在顶部左和右两侧。由于我们可能在屏幕上有多个敌人,最好的表示方法是在每个飞船上方都有红色和绿色的条。条上的绿色部分将是飞船当前拥有的生命值数量,而红色部分是当前缺失的数量。

在第二步中,我们切换到 Ship.h 文件,因为我们想要定义一个实例变量。为了表示生命值,我们选择了 SPQuad 并将我们的变量命名为 _quadHitpoints。为了实际实现生命值机制,我们切换到 Ship.m 文件。我们的生命值条实际上由三个不同的框组成:

  • 围绕生命值的边界

  • 生命值的背景(一个红色条)

  • 实际的击中点生命值条(一个表示为我们的 _quadHitpoints 实例变量的绿色条)

在第五步中,我们定义了一个黑色矩形,它将作为我们的生命值条的边界。矩形的宽度应该是飞船的宽度。我们从 clipNorth 动画剪辑中获取了宽度。实际上,我们也可以从任何其他动画剪辑中获取飞船的宽度。我们将黑色矩形的长度设置为五点。我们不希望生命值条太粗,但它必须易于可见和识别。

在下一步中,我们设置了生命值的背景。我们定义了一个 SPQuad 实例,我们称之为 quadMaxHitpoints。它应该在每一边比黑色矩形小一个点。我们使用了 hitpointsBorder 实例的宽度,并将高度设置为三点。

我们将 quadMaxHitpoints 实例的左和上边距各设置为一点,这样它实际上看起来就像生命值条有一个边界。

然后,我们在第八步初始化了 quadHitpoints 实例变量。它也是一个 SPQuad 类,并且与我们的背景生命值条具有相同的尺寸。

使用 quadMaxHitpoints 实例,我们希望 _quadHitpoints 在边界内显示。因此,我们将 _quadHitpoints 的位置相对于飞船本身向左和向上调整一个点。在这种情况下,我们可以从 quadMaxHitpoints 四边形调整位置。

对于红色和绿色的矩形,我们避免使用鲜艳的颜色,因为这可能会使眼睛疲劳,并分散我们对动作的注意力。此外,由于我们总体上追求较暗的色调,鲜艳的颜色并不适合这个环境。在着色和设计用户界面时,请记住,元素应该在实际设备上进行测试。亮度通常不会达到最大值,尤其是如果设备正在使用电池。在某些情况下,图形甚至会被赋予额外的对比度或被调亮,这样在移动设备上看起来就不会太暗。

在第 10 步中,我们将所有四边形添加到显示树中;所有四边形都是 Ship 类的子类。

在这一点之后,我们的代码片段将看起来像以下这样:

SPMovieClip *clipSouthWest = [SPMovieClip movieWithFrames:texturesSouthWest fps:animationFPS];
SPMovieClip *clipSouthEast = [SPMovieClip movieWithFrames:texturesSouthEast fps:animationFPS];

_shootingClip = [NSArray arrayWithObjects:clipNorth, clipSouth, clipWest, clipEast, clipNorthWest, clipNorthEast, clipSouthWest, clipSouthEast, nil];

self.cannonBallLeft = [SPImage imageWithTexture:[Assets texture:@"cannonball.png"]];
self.cannonBallRight = [SPImage imageWithTexture:[Assets texture:@"cannonball.png"]];

float hitpointsHeight = 5.0f;
 SPQuad *hitpointsBorder = [SPQuad quadWithWidth:clipNorth.width height:hitpointsHeight color:SP_BLACK];

 uint redColor = SP_COLOR(200, 0, 0);
 SPQuad *quadMaxHitpoints = [SPQuadquadWithWidth:hitpointsBorder.width - 2.0fheight:hitpointsHeight - 2.0f color:redColor];
 quadMaxHitpoints.x = 1.0f;
 quadMaxHitpoints.y = 1.0f;

 uint greenColor = SP_COLOR(0, 180, 0);
 _quadHitpoints = [SPQuad quadWithWidth:hitpointsBorder.width - 2.0f height:hitpointsHeight - 2.0f color:greenColor];
 _quadHitpoints.x = quadMaxHitpoints.x;
 _quadHitpoints.y = quadMaxHitpoints.y;

for (SPMovieClip* clip in _shootingClip) {
  clip.loop = NO;
  [self addChild:clip];
}

self.cannonBallLeft.visible = NO;
self.cannonBallRight.visible = NO;

[self addChild:self.cannonBallLeft];
[self addChild:self.cannonBallRight];

[self addChild:hitpointsBorder];
[self addChild:quadMaxHitpoints];
[self addChild:_quadHitpoints];

self.direction = DirectionSouthWest;

如果我们要多次使用生命值创建代码,将这部分代码放入一个单独的方法中是一种最佳实践。

在下一步中,我们更新了生命值设置器。_quadHitpoints 实例将在水平方向上缩放。由于 _hitpointsself.maxHitpoints 都是整数值,我们需要将其转换为浮点值。如果我们不这样做,生命值条要么是红色,要么是绿色,中间没有其他颜色。

在第 10 步之后,setHitpoints 方法将看起来像以下代码片段:

-(void) setHitpoints:(int)hitpoints
{
    _hitpoints = hitpoints;

 _quadHitpoints.scaleX = (float) _hitpoints / self.maxHitpoints;

    if (_hitpoints <= 0) {
        self.visible = FALSE;
    }
}

我们在最后一步运行了示例,并看到敌方飞船以及我们的飞船上方都有生命值条。当飞船移动时,生命值条也会随之移动,当我们击中敌方飞船时,生命值条会相应更新。

在屏幕上添加按钮

现在我们已经在屏幕上有了生命值条,让我们添加一些用户可以与之交互的东西。

暂停和继续游戏

我们将要添加的第一件事是能够随意暂停和继续游戏。这实际上非常重要,尤其是对于移动动作游戏。如果移动设备(iPhone)上有电话进来,而我们没有暂停功能,玩家可能会因为无法暂停游戏而感到沮丧,从而丢失进度或连胜。

在我们开始实现这些按钮之前,让我们下载本章所需的图形资源,这些资源可在github.com/freezedev/pirategame-assets/releases/download/0.7/Graphics_07.zip找到。将提取文件的 contents 复制到项目中。

让我们思考一下在暂停游戏时需要做什么:

  • 显示暂停游戏的按钮

  • 显示继续游戏的按钮

  • 当玩家点击暂停按钮时,停止所有当前缓动

  • 当玩家点击继续按钮时,恢复所有当前缓动

由于这是一个更大的任务,我们将它分为两部分;首先,我们将显示按钮,然后我们将实现功能。

在屏幕上显示暂停和继续按钮

在这个例子中,我们将添加所有需要的按钮,并将它们显示在屏幕的正确位置。

是时候采取行动——将按钮放置在屏幕上

要添加我们的第一个按钮,请按照以下步骤操作:

  1. 打开Battlefield.h文件。

  2. 为每个按钮添加一个实例变量。我们将使用SPButton类型,如下面的代码所示:

    SPButton *_buttonPause;
    SPButton *_buttonResume;
    
  3. 切换到Battlefield.m文件。

  4. 构建我们的两个实例变量,如下面的代码所示:

    _buttonPause = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"button_pause"]];
    _buttonResume = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"button_play"]];
    
  5. 使用以下代码将暂停和恢复按钮的位置设置为屏幕的右上角:

    _buttonPause.x = Sparrow.stage.width - _buttonPause.width - 4.0f;
    _buttonPause.y = 4.0f;
    
    _buttonResume.x = _buttonPause.x;
    _buttonResume.y = _buttonPause.y;
    
  6. 使用以下代码隐藏恢复按钮:

    _buttonResume.visible = NO;
    
  7. 为了后续使用,创建点击暂停和恢复按钮的方法,如下面的代码所示:

    -(void) onButtonPause:(SPTouchEvent *)event
    {
    
    }
    
    -(void) onButtonResume:(SPTouchEvent *)event
    {
    
    }
    
  8. 将新创建的方法绑定到暂停和恢复按钮上,如下面的代码所示:

    [_buttonPause addEventListener:@selector(onButtonPause:) atObject:self forType:SP_EVENT_TYPE_TRIGGERED ];
    [_buttonResume addEventListener:@selector(onButtonResume:) atObject:self forType:SP_EVENT_TYPE_TRIGGERED ];
    
  9. 按照以下方式将两个按钮添加到显示树中:

    [self addChild:_buttonPause];
    [self addChild:_buttonResume];
    
  10. 运行示例以查看结果。我们现在在屏幕上有一个暂停按钮,如下面的截图所示:是时候采取行动——将按钮放置在屏幕上

发生了什么?

在第 1 步中,我们打开了Battlefield.h文件。我们向这个类添加了两个实例变量,一个用于暂停按钮,一个用于恢复按钮。我们使用了SPButton类,它基本上是一个图像,并且可以选择在上面显示一些文本。

接下来,我们切换到Battlefield.m文件。在第 4 步中,我们初始化了两个按钮。我们需要更仔细地查看以下两个点:

  • 我们已经知道,如果我们使用texturesStartingWith方法,我们可以得到一个纹理数组。如果我们只想使用单个纹理,我们需要使用textureByName,并且我们还需要指定正确的名称。

  • SPButton提供了几个工厂方法。我们正在使用的是buttonWithUpState方法,我们必须传递一个SPTexture实例。正常状态是始终可见的纹理。如果我们指定了按下状态,按下状态将在按钮被点击时可见。另一个工厂方法允许我们指定按下状态,甚至是一些文本。

在下一步中,我们将按钮定位在屏幕的右上角。我们留出了一些空间(四个点),这样按钮就不会离屏幕边缘太近。

在第 6 步中,我们隐藏了简历按钮,这样在第一次显示场景时,我们只能看到暂停按钮。

在下一步中,我们添加了一些暂停和恢复游戏的方法。我们目前将这些方法留空,但稍后我们会填充它们。

接下来,我们将这些方法链接到按钮上,以便在触摸这些按钮时调用它们。

要在屏幕上实际显示按钮,我们需要将它们添加到显示树中,我们在第 9 步中已经做到了这一点。

让我们看看初始化器中具体发生了什么变化:

SPTween *shipTween = [SPTween tweenWithTarget:_enemyShip time:4.0f transition:SP_TRANSITION_EASE_IN_OUT];
[shipTween animateProperty:@"y" targetValue:250];
shipTween.repeatCount = 5;
shipTween.reverse = YES;
shipTween.delay = 2.0f;

_buttonPause = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"button_pause"]];
_buttonResume = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"button_play"]];

_buttonPause.x = Sparrow.stage.width - _buttonPause.width - 4.0f;
_buttonPause.y = 4.0f;

_buttonResume.x = _buttonPause.x;
_buttonResume.y = _buttonPause.y;

_buttonResume.visible = NO;

[_buttonPause addEventListener:@selector(onButtonPause:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
[_buttonResume addEventListener:@selector(onButtonResume:) atObject:self forType:SP_EVENT_TYPE_TOUCH];

[Sparrow.juggler addObject:shipTween];

[_background addEventListener:@selector(onBackgroundTouch:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
[_pirateShip addEventListener:@selector(onShipTap:) atObject:self forType:SP_EVENT_TYPE_TOUCH];

[self addEventListener:@selector(onEnterFrame:) atObject:self forType:SP_EVENT_TYPE_ENTER_FRAME];

[self addChild:_background];
[self addChild:_enemyShip];
[self addChild:_pirateShip];

[self addChild:_buttonPause];
[self addChild:_buttonResume];

现在,当我们运行示例时,我们在屏幕右上角看到了暂停按钮。当我们点击按钮时,除了按钮稍微缩小一点外,没有任何反应。

实现暂停和恢复游戏的逻辑

现在我们已经在屏幕上显示了按钮,让我们允许玩家暂停和恢复游戏。我们将利用自己的juggler,如果游戏被暂停,则将其保存到一个变量中,如果没有暂停,我们也前进我们的juggler以及子元素。

操作时间 – 允许玩家暂停和恢复

为了允许玩家暂停和恢复游戏,我们需要遵循以下步骤:

  1. 打开Ship.h文件。

  2. 添加一个名为_juggler的实例变量,它是一个指向SPJuggler的指针,如下面的代码行所示:

    SPJuggler *_juggler;
    
  3. 声明一个名为paused的属性,其类型为BOOL,如下面的代码行所示:

    @property (nonatomic) BOOL paused;
    
  4. 声明一个名为advanceTime的方法,如下面的代码行所示:

    -(void) advanceTime:(double)seconds;
    
  5. 切换到Ship.m文件。

  6. 在初始化器中,使用其实例变量将paused属性设置为NO,如下面的代码所示:

    _isShooting = NO;
    _paused = NO;
    
    SPTextureAtlas *atlas = (type == ShipPirate) ? [Assets textureAtlas:@"ship_pirate_small_cannon.xml"] : [Assets textureAtlas:@"ship_small_cannon.xml"];
    
  7. 在初始化器中使用以下代码行初始化_juggler实例变量:

    _juggler = [SPJuggler juggler];
    
  8. 将所有从Sparrow.juggler_juggler的引用更新。

  9. 使用以下代码行实现advanceTime方法:

    -(void) advanceTime:(double)seconds
    {
        if (!self.paused) {
            [_juggler advanceTime:seconds];
        }
    }
    
  10. 切换到Battlefield.h文件。

  11. 在这里也为juggler添加一个实例变量:

    SPJuggler *_juggler;
    
  12. 使用以下代码行添加一个背景的实例变量:

    SPImage *_background;
    
  13. 添加一个名为paused的属性,其类型为BOOL。由于我们将为这个属性添加自定义的获取器和设置器,我们还需要一个名为_paused的实例变量,如下面的代码所示:

    @interface Battlefield : Scene {
        Ship *_pirateShip;
        Ship *_enemyShip;
    
        SPImage *_background;
    
        SPButton *_buttonPause;
        SPButton *_buttonResume;
    
        SPJuggler *_juggler;
    
        BOOL _paused;
    }
    
    @property (nonatomic) BOOL paused;
    
    
  14. 切换到Battlefield.m文件。

  15. 在初始化器中,将本地背景变量更新为_background实例变量。

  16. Battlefield初始化器中,初始化_juggler实例。这必须在将shipTween添加到juggler之前完成:

    [_buttonPause addEventListener:@selector(onButtonPause:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
    [_buttonResume addEventListener:@selector(onButtonResume:) atObject:self forType:SP_EVENT_TYPE_TOUCH];
    
    _juggler = [SPJuggler juggler];
    
    [Sparrow.juggler addObject:shipTween];
    
  17. 将所有从Sparrow.juggler_juggler的引用更新。

  18. paused属性添加一个自定义 setter,内容如下:

    -(void) setPaused:(BOOL)paused
    {
        _paused = paused;
    
        _buttonResume.visible = _paused;
        _buttonPause.visible = !_paused;
    
        _background.touchable = !_paused;
    
        _pirateShip.paused = _paused;
        _enemyShip.paused = _paused;
    }
    
  19. paused属性添加一个自定义获取器,它返回_paused实例变量:

    -(BOOL) paused
    {
        return _paused;
    }
    
  20. 通过设置paused属性的正确值(在onButtonPause方法中为YES,在onButtonResume方法中为NO)来实现onButtonPauseonButtonResume方法。

  21. onEnterFrame中的参数类型从指向SPEvent更改为指向SPEnterFrameEvent

  22. onEnterFrame方法中添加以下代码行:

    double passedTime = event.passedTime;
    
    [_enemyShip advanceTime:passedTime];
    [_pirateShip advanceTime:passedTime];
    if (!self.paused) {
      [_juggler advanceTime:passedTime];
    }
    
  23. 运行示例。

    现在我们可以点击暂停和恢复按钮。在下面的屏幕截图中,你可以看到当游戏被暂停时,所有动画都会停止,直到我们按下恢复按钮:

    操作时间 – 允许玩家暂停和恢复

发生了什么?

Ship.h文件中,我们通过执行以下任务更新了接口:

  • 添加一个新的juggler实例变量,它是一个指向SPJuggler的指针(步骤 2)。

  • 添加一个类型为BOOLpaused属性(步骤 3)。

  • 声明一个名为advanceTime的方法(步骤 4)。

Sparrow 没有为它的 juggler 提供暂停和恢复方法。我们通过引入我们自己的 juggler 并设置一个标志来处理这个问题,如果游戏被暂停,我们只前进所有的 juggler。

在第 5 步中,我们切换到Ship.m文件,并在初始化器中将paused属性定义为NO,因为默认情况下,船只不应该被暂停。这一步不是必需的,因为 Objective-C 默认将这个实例初始化为NO;这只是作为一个提醒,以便我们在稍后决定更改值时知道查看哪个实例变量,并且我们知道在哪里查找。

在下一步中,我们初始化了_juggler实例变量。在初始化器中,我们确切地在哪里定义 juggler 实例变量并不重要,因为我们不会在初始化方法中向 juggler 添加任何 tween。接下来,我们搜索并替换了所有从Sparrow.juggler_juggler的引用。最简单的方法是使用command + F,从下拉列表中选择Replace,在第一个输入框中输入Sparrow.juggler,在第二个输入框中输入_juggler,并选择All以替换所有引用。

在第 9 步中,我们实现了advanceTime方法,其中我们调用_juggleradvanceTime方法,并将double作为seconds参数传递。这只有在paused属性设置为NO时才会调用。

接下来,我们切换到Battlefield.h文件。在这里,我们需要执行以下步骤:

  • 添加一个与Juggler类类似的实例变量,就像我们在Ship类中做的那样(第 11 步)。

  • 现在,我们需要一个引用背景图像实例的实例变量(第 12 步)。

  • 我们需要一个paused属性。由于我们将要实现自定义的 getter 和 setter,我们还需要一个与该属性对应的实例变量(第 13 步)。

然后,我们切换到Battlefield.m文件。在下一步中,我们将所有从局部背景变量到实例变量_background的引用进行了更新。

在第 16 步中,我们初始化了我们的_juggler实例变量。在这里,初始化这个实例的位置很重要;它应该在[Sparrow.juggler addObject:shipTween];这一行之前。

接下来,我们更新了引用,在战场场景中使用_juggler而不是Sparrow.juggler

然后,我们定义了paused属性的 setter。让我们更仔细地看看到底发生了什么:

  • 我们将_paused实例变量设置为参数的值。

  • 如果_paused设置为YES,则_buttonPause不可见,而_buttonResume可见。如果_paused设置为NO,则情况相反。

  • 每个精灵都有一个touchable属性。如果这个设置为NO,则触摸处理程序不会触发。我们将这个设置为NO,以便游戏可以被暂停。

  • 我们暂停了屏幕上的所有船只。

在第 19 步中,我们定义了paused属性的 getter。它只是返回了_paused实例变量。

然后,我们实现了onButtonPauseonButtonResume方法,我们将paused属性分别设置为YESNO

在第 21 步,我们需要更新onEnterFrame方法中的参数类型。它需要是一个指向SPEnterFrame的指针。

然后,我们在onEnterFrame方法中添加了一些代码。现在,事件是一个指向SPEnterFrame的指针,我们可以通过从事件参数中获取passedTime属性来获取已经过去的时间。然后,我们调用屏幕上所有船只的advanceTime方法,如果场景被暂停,我们调用_juggleradvanceTime方法。

当我们运行示例时,我们现在可以随意暂停和恢复游戏。

来试试吧,英雄

这里有一些我们可以改进的建议:

  • 由于onButtonPauseonButtonResume方法中并没有发生太多事情,我们可以尝试使用代码块或将两个方法合并为一个。

  • 我们可以将Scene类扩展以使用juggler实例变量,这样我们就不需要在需要它的任何地方重新定义一个自定义的 juggler。

  • 目前,我们为每个场景都有自定义的用户界面元素。然而,如果这要改变,我们应该考虑将用户界面逻辑抽象成一个单独的类,并可能将其绑定到Scene类。

放弃当前游戏

到目前为止,我们还没有切换到海盗湾场景的能力。然而,我们应该引入放弃当前战斗的选项。

行动时间 – 放弃游戏

要放弃当前游戏,我们需要遵循以下步骤:

  1. 打开Battlefield.m文件。

  2. 在初始化器中,我们应该在恢复按钮之后立即添加放弃按钮:

    SPButton *buttonAbort = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"button_abort"]];
    
  3. 将放弃按钮定位在右下角:

    buttonAbort.x = Sparrow.stage.width - buttonAbort.width - 4.0f;
    buttonAbort.y = Sparrow.stage.height - buttonAbort.height - 4.0f;
    
  4. 导入SceneDirector类,如下面的代码行所示:

    #import "SceneDirector.h"
    
  5. 使用代码块为放弃按钮添加一个监听器,如下所示:

    [buttonAbort addEventListenerForType:SP_EVENT_TYPE_TRIGGERED block:^(SPEvent *event)
    {
      [((SceneDirector *) self.director) showScene:@"piratecove"];
    }];
    
  6. 按照以下代码将按钮添加到显示树中:

    [self addChild:buttonAbort];
    
  7. 运行示例以查看结果。

    我们现在可以看到如下截图所示的放弃按钮:

    行动时间 – 放弃游戏

刚才发生了什么?

在第 1 步,我们打开了Battlefield.m文件。对于这个示例,我们只需要查看初始化方法。我们初始化放弃按钮的方式与之前初始化暂停和恢复按钮的方式相似,唯一的区别是我们使用了不同的纹理。

在下一步中,我们将放弃按钮定位在右下角。就像我们在暂停和恢复按钮中留出一些空间一样,这里我们也做了同样的事情。

然后,我们在下一步中导入了SceneDirector.h文件。

在第 5 步,我们为放弃按钮添加了一个事件监听器。在事件监听器内部,我们切换到了海盗湾场景。尽管我们确实通过director属性有一个场景导演的引用,但它是一个id类型。因此,我们需要将其重新转换为指向SceneDirector类的指针。

然后,我们将放弃按钮添加到显示树中。

当我们运行示例时,我们看到了中止按钮,当我们点击它时,我们跳转到了海盗湾场景。

向屏幕添加对话框

现在我们已经在屏幕上添加了中止按钮,我们可能会遇到一些问题,如下所述:

  • 我们可能会不小心点击中止按钮

  • 我们没有从海盗湾场景返回到战场场景的方法

因此,为了应对这种情况,至少表面上,让我们添加一个对话框,当我们在中止按钮上点击时应该显示。

动手实践 – 创建对话框类

要添加对话框,我们需要遵循以下步骤:

  1. 文件夹内添加一个新的组,命名为UI

  2. UI组内,添加一个新的 Objective-C 类,命名为Dialog,它继承自SPSprite

  3. 使用以下代码实现对话框初始化器:

    -(id) init
    {
        if ((self = [super init])) {
            SPImage *background = [SPImage imageWithTexture:[[Assets textureAtlas:@"ui.xml"] textureByName:@"dialog"]];
    
            SPButton *buttonYes = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"dialog_yes"] text:@"Yes"];
    
            SPButton *buttonNo = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"dialog_no"] text:@"No"];
    
            buttonYes.x = 24.0f;
            buttonYes.y = background.height - buttonYes.height -40.0f;
    
            buttonNo.x = buttonYes.x + buttonYes.width - 20.0f;
            buttonNo.y = buttonYes.y;
    
            [self addChild:background];
            [self addChild:buttonYes];
            [self addChild:buttonNo];
        }
    
        return self;
    }
    
  4. 切换到Battlefield.m文件。

  5. 导入Dialog.h文件。

  6. 在中止按钮事件之前,初始化对话框,如下面的代码所示:

    Dialog *dialog = [[Dialog alloc] init];
    
    dialog.x = (Sparrow.stage.width - dialog.width) / 2;
    dialog.y = (Sparrow.stage.height - dialog.height) / 2;
    
  7. 对话框默认应该是隐藏的,如下面的代码行所示:

    dialog.visible = NO;
    
  8. 更新中止按钮事件以显示对话框:

    [buttonAbort addEventListenerForType: SP_EVENT_TYPE_TRIGGERED  block:^(SPEvent *event)
    {
      dialog.visible = YES;
    }];
    
  9. 运行示例以查看结果。

    当我们点击中止按钮时,我们现在看到对话框弹出:

    动手实践 – 创建对话框类

发生了什么?

首先,我们结构化了Dialog类。为了将其与游戏逻辑代码分开,我们在一个新的组中创建了它。Dialog类本身应该继承自SPSprite

在第 3 步中,我们为Dialog类定义了初始化器,在那里我们执行了以下操作:

  • 我们为对话框添加了背景图片。

  • 我们添加了按钮。我们调用了SPButton的工厂方法,我们在按钮上放置了一些文本,在我们的例子中是

  • 我们将这些元素放置在对话框的底部。

Battlefield.m文件中,我们导入了Dialog.h文件以便使用Dialog类。

在战场场景的初始化器中,我们需要初始化对话框,我们就在中止按钮事件之前做了这件事。

我们默认将对话框设置为不可见,并更新了中止按钮的事件以显示对话框。

当我们运行示例时,我们在点击中止按钮时看到了对话框。

尝试一下英雄

从可用性的角度来看,将中止按钮放置在屏幕的右下角并不理想。如果我们不小心点击了按钮,现在会显示一个对话框而不是仅仅中止当前战斗。尽管如此,这似乎也不太理想。以下是一些改进这种情况的建议:

  • 将中止按钮(buttonAbort)放置在暂停按钮旁边。所有用户界面元素都会在同一区域。

  • 将暂停按钮和中止按钮组合成一个游戏菜单按钮。点击该按钮将暂停游戏并打开菜单。可以在那里找到中止按钮。

向对话框添加自定义事件

现在对话框已经在屏幕上,我们希望将监听器附加到对话框按钮本身。虽然我们可以简单地附加触摸事件,但 Sparrow 提供了一种定义自定义事件的方法。

添加我们自己的按钮到对话框的行动时间

要为我们的对话框添加自定义事件,我们只需遵循以下步骤:

  1. Dialog.h文件中,我们需要在接口声明之前定义事件名称:

    #define EVENT_TYPE_YES_TRIGGERED @"yesTriggered"
    #define EVENT_TYPE_NO_TRIGGERED  @"noTriggered"
    
  2. 切换到Dialog.m

  3. 将以下监听器注册到我们的按钮上:

    [buttonYes addEventListener:@selector(onButtonYes:) atObject:self forType:SP_EVENT_TYPE_TRIGGERED];
    
    [buttonNo addEventListener:@selector(onButtonNo:) atObject:self forType:SP_EVENT_TYPE_TRIGGERED];
    
  4. 实现以下代码中的onButtonYesonButtonNo方法:

    - (void)onButtonYes:(SPEvent *)event
    {
        SPEvent *localEvent = [SPEvent eventWithType:EVENT_TYPE_YES_TRIGGERED];
        [self dispatchEvent:localEvent];
    }
    
    - (void)onButtonNo:(SPEvent *)event
    {
        SPEvent *localEvent = [SPEvent eventWithType:EVENT_TYPE_NO_TRIGGERED];
        [self dispatchEvent:localEvent];
    }
    
  5. 切换到Battlefield.m

  6. 初始化器中的本地对话框变量需要重构为一个名为_dialogAbort的实例变量。

  7. #import "Dialog.h"语句从Battlefield.m移动到Battlefield.h

  8. Battlefield.m中为两个对话框按钮添加事件监听器,如下所示:

    [_dialogAbort addEventListener:@selector(onDialogAbortYes:) atObject:self forType:EVENT_TYPE_YES_TRIGGERED];
    [_dialogAbort addEventListener:@selector(onDialogAbortNo:) atObject:self forType:EVENT_TYPE_NO_TRIGGERED];
    
  9. 实现相应的函数,如下所示:

    -(void) onDialogAbortYes:(SPEvent *)event
    {
        [((SceneDirector *) self.director) showScene:@"piratecove"];
    }
    
    -(void) onDialogAbortNo:(SPEvent *)event
    {
        self.paused = NO;
        _dialogAbort.visible = NO;
    }
    
  10. 更新中止按钮事件,以便在显示对话框时也暂停游戏:

    [buttonAbort addEventListenerForType:SP_EVENT_TYPE_TOUCH block:^(SPEvent *event)
    {
      self.paused = YES;
      _dialogAbort.visible = YES;
    }];
    
  11. 运行示例以查看结果。当我们点击“中止”按钮时,我们现在可以点击对话框中的按钮:添加我们自己的按钮到对话框的时间 - 行动时间

发生了什么?

在第一步中,我们为我们的按钮定义了事件名称。在Dialog.m文件中,我们需要为我们的对话框添加监听器。我们使用了SP_EVENT_TYPE_TRIGGERED,因此如果任一按钮上触发任何类型的事件,都会调用选择器。

在第 4 步中,我们实现了必要的方法。我们创建了一个自定义事件类型的事件,并在之后分发了该事件。

在下一步中,我们将战场初始化器中的本地对话框变量重构。现在它需要一个名为_dialogAbort的实例变量,它仍然是指向Dialog的指针。我们更新了所有引用和实例变量的初始化部分。因此,我们在头文件中导入了一个语句。

我们随后在我们的对话框上使用自定义事件调用了addEventListener方法。

在第 9 步中,我们实现了当按钮被点击时应触发的函数。如果我们选择“是”,我们需要显示海盗湾场景,如果我们选择“否”,我们需要对话框消失。在这种情况下,我们还需要恢复游戏。

如果我们点击“是”,在恢复游戏时,我们也需要更新中止按钮的事件,以便在显示对话框时实际暂停游戏。

当我们运行示例并点击中止按钮时,我们的对话框弹出,游戏暂停。如果我们点击“否”,对话框关闭,游戏继续。如果我们点击“是”,我们将切换到海盗湾场景。

在屏幕上绘制文本

在屏幕上显示文本有两种方式。我们可以使用 iOS 字体(所谓的系统字体)之一,或者尝试创建一个更适合我们需求的更定制化的字体。

显示我们的第一个文本字段

当我们添加对话框按钮时,我们已经利用 SPButton 的功能在屏幕上绘制了一些文本。然而,我们现在将绘制一些文本用于对话框消息。

是时候给对话框添加文本字段了

要在屏幕上绘制文本,我们需要遵循以下步骤:

  1. 如以下代码行所示,在 Dialog.h 文件中添加一个名为 content 的属性,它是一个指向 SPTextField 的指针:

    @property SPTextField *content;
    
  2. Dialog 初始化器中,创建以下内容实例并将其放置在标题框和按钮之间:

    _content = [SPTextField textFieldWithWidth:background.width - 48.0f height:background.height - 150.0f text:@"Dialog default text"];
    _content.x = 24.0f;
    _content.y = 50.0f;
    
  3. 如以下代码所示,将 content 属性添加到显示树中:

    [self addChild:background];
    [self addChild:buttonYes];
    [self addChild:buttonNo];
    [self addChild:_content];
    
    
  4. 切换到 Battlefield.m 文件。

  5. 为中止对话框添加一个自定义消息,如下面的代码所示:

    _dialogAbort = [[Dialog alloc] init];
    
    _dialogAbort.content.text = @"Would you like to abort the current fight?";
    
    _dialogAbort.x = (Sparrow.stage.width - _dialogAbort.width) / 2;
    _dialogAbort.y = (Sparrow.stage.height - _dialogAbort.height) / 2;
    
  6. 运行示例。

    现在,我们看到了对话框内的文本消息。

    是时候给对话框添加文本字段了

发生了什么?

首先,我们需要一个属性来表示我们将要显示的消息。SPTextField 类的工作方式是这样的:我们定义一个矩形和一些文本,文本将自动在矩形的边界内对齐。默认情况下,文本在水平和垂直方向上居中。如果我们想改变这一点,我们需要将 hAlignvAlign 属性更改为我们想要的值。除了从显示对象继承的所有属性(如颜色或缩放)之外,文本字段还有一个 fontName 属性来使用不同的字体,以及一个 fontSize 属性来设置文本的大小。

在步骤 2 中,我们创建了 _content 实例,其中文本字段应该略小于对话框本身。我们为文本字段设置了一个默认文本,然后更新其位置,使其大致位于对话框的中心。

在步骤 3 中我们将文本字段添加到显示树之后,我们在 Battlefield 初始化器中更新了默认消息,一个自定义消息。

当我们运行示例时,我们在对话框中看到了我们的自定义消息。

解释系统字体

系统字体是 iOS 内置的字体,无需额外安装。选择范围从 Arial 和 Helvetica 到 Verdana,包括轻、粗体和斜体变体。要查看所有可用系统字体的完整列表,请访问 iosfonts.com/

解释位图字体

位图字体与纹理图集非常相似;每个字符都是一个图像。所有这些较小的图像都被放入一个大的图像中。尽管系统字体可以轻松显示 Unicode 字符,但如果我们需要变音符号或类似字符,我们就需要自己添加它们。因此,这将直接增加图像的大小。

一个示例位图字体可能看起来像以下截图:

解释位图字体

数据的一部分可能看起来像以下代码片段:

<font>
  <info face="Arial" size="72" bold="0" italic="0" charset="" unicode="" stretchH="100" smooth="1" aa="1" padding="2,2,2,2" spacing="0,0" outline="0"/>
  <common lineHeight="83" base="65" scaleW="1024" scaleH="512" pages="1" packed="0"/>
  <pages>
    <page id="0" file="font.png"/>
  </pages>
  <chars count="80">
    <char id="97" x="2" y="2" width="45" height="50" xoffset="-1" yoffset="18" xadvance="40" page="0" chnl="15"/>
    <char id="98" x="2" y="54" width="44" height="64" xoffset="1" yoffset="5" xadvance="40" page="0" chnl="15"/>
    <char id="99" x="2" y="120" width="43" height="50" xoffset="-1" yoffset="18" xadvance="36" page="0" chnl="15"/>
    <char id="100" x="2" y="172" width="44" height="64" xoffset="-2" yoffset="5" xadvance="40" page="0" chnl="15"/>
    <char id="101" x="47" y="120" width="46" height="50" xoffset="-1" yoffset="18" xadvance="40" page="0" chnl="15"/>
    <char id="102" x="48" y="54" width="33" height="64" xoffset="-3" yoffset="4" xadvance="20" page="0" chnl="15"/>
    <char id="103" x="83" y="2" width="44" height="65" xoffset="-2" yoffset="18" xadvance="40" page="0" chnl="15"/>
    <char id="104" x="2" y="238" width="42" height="63" xoffset="1" yoffset="5" xadvance="40" page="0" chnl="15"/>

与纹理图集类似,实际数据以 XML 格式表示。

有多种工具可以创建位图字体,每个都有自己的优缺点。Littera 是一个免费的在线工具,可在kvazars.com/littera/找到(需要 Adobe Flash Player);其他流行的商业解决方案包括 71squared 的Glyph Designer和 Stéphane Queraud 的bmGlyph

创建我们自己的位图字体

对于这个例子,我们将使用 bmGlyph,因为它允许我们创建多个缩放位图字体,类似于TexturePacker提供的机制。bmGlyph 解决方案可在 Mac App Store 中找到,价格为$9.99 或您所在地区的等值货币。itunes.apple.com/us/app/bmglyph/id490499048?mt=12

如果你不想使用 bmGlyph,完整的位图字体也包含在图形包中。

行动时间 - 使用 bmGlyph 创建位图字体

要创建我们的第一个位图字体,我们只需遵循以下步骤:

  1. 打开bmGlyph

  2. 将字体设置为Arial Rounded MT Bold

  3. 字体大小设置为72点。

  4. 滚动到颜色工具部分并勾选阴影

  5. 阴影面板中,将x属性设置为2y属性设置为-2,并将半径设置为8

  6. 填充模式部分,选择棕色-黄色并勾选光泽复选框。

  7. 点击发布按钮。

  8. 默认目标中,将文件名和字体名称(强制字体名称)输入为PirateFont。在后缀输入框中添加@4x

  9. 重复并缩放框内点击50按钮并添加后缀@2x

  10. 点击25按钮。

  11. 格式下拉列表中选择Sparrow

  12. 确保在所有目标中PirateFont都显示为文件名强制字体名称

  13. 点击发布然后点击关闭按钮。

    在这些步骤之后,我们应该看到以下屏幕:

    行动时间 – 使用 bmGlyph 创建位图字体

发生了什么?

在我们打开bmGlyph后,设置了位图字体的基本字体、大小、颜色和阴影。要导出位图字体,我们点击了发布按钮,这在第 7 步中已经完成。对于每个单独的图像,我们需要定义一个新的目标并设置缩放。在我们的例子中,这是100%50%25%,分别带有后缀@4x@2x。对于25%的目标,我们不需要后缀。

要导出与 Sparrow 兼容的位图字体,我们需要在格式下拉列表中选择Sparrow。我们需要确保PirateFont文件名强制字体名称中都被写入。如果我们不这样做,字体将不会作为PirateFont可用,而是会替换Arial Rounded MT Bold

当我们点击发布按钮时,我们的字体文件出现在我们想要保存它们的位置。

显示带有位图字体的文本字段

现在我们已经在对话框中显示了一个系统,让我们使用我们新创建的位图字体作为对话框的标题来显示文本。

现在行动起来——使用我们的位图字体进行文本字段

按照以下步骤在SPTextField中显示位图字体:

  1. 我们需要在Dialog.h文件中添加另一个名为title的属性,它也是一个指向SPTextField的指针:

    @property SPTextField *title;
    
  2. 我们注册了位图字体,如下面的代码所示:

    [SPTextField registerBitmapFontFromFile:@"PirateFont.fnt"];
    
  3. 我们使用以下代码行创建并定位_title实例:

    _title = [SPTextField textFieldWithWidth:background.width * 0.6 height:30.0f text:@"Dialog"];
    _title.fontName = @"PirateFont";
    _title.color = SP_WHITE;
    
    _title.x = 24.0f;
    _title.y = 26.0f;
    
  4. 我们需要将_title实例添加到显示树中,如下面的代码所示:

    [self addChild:background];
    [self addChild:buttonYes];
    [self addChild:buttonNo];
    [self addChild:_content];
    [self addChild:_title];
    
    
  5. Battlefield.m文件中,我们用自定义标题替换了默认标题:

    _dialogAbort = [[Dialog alloc] init];
    
    _dialogAbort.title.text = @"Abort this fight?";
    _dialogAbort.content.text = @"Would you like to abort the current fight?";
    
    _dialogAbort.x = (Sparrow.stage.width - _dialogAbort.width) / 2;
    _dialogAbort.y = (Sparrow.stage.height - _dialogAbort.height) / 2;
    
  6. 运行示例以查看结果。

    我们现在的对话框包含了一条消息和标题:

    现在行动起来——使用我们的位图字体进行文本字段

刚才发生了什么?

显示位图字体与在屏幕上显示系统字体非常相似。在我们能够使用任何位图字体之前,我们需要首先注册它。当我们想要使用这种字体显示文本字段时,我们需要更新fontName属性以反映位图字体的名称。

另一件事需要考虑的是,Sparrow 默认将所有文本显示为黑色。我们需要更改这一点才能看到位图字体的颜色效果。

注意

请记住,本章的完整源代码也可以在 GitHub 上找到:github.com/freezedev/pirategame/tree/f742f6026e9ad129546d17e5d9e9728c27ff0733

快速问答

Q1. 为了让自定义的 juggler 显示其 tween,需要调用哪种方法?

  1. advanceTime

  2. update

  3. addJuggler

Q2. 位图字体与纹理图集相似吗?

  1. 是的

Q3. 可以与SPTextField一起使用的字体类型有哪些?

  1. 系统字体

  2. 位图字体

  3. 两者

概述

在本章中,我们学习了如何将用户界面元素添加到屏幕上。

具体来说,我们涵盖了健康条、按钮以及在屏幕上绘制文本,并且我们对 juggler 以及如何通过自定义 getter 和 setter 更新元素有了更深入的了解。

现在我们有了基本用户界面,让我们添加一些人工智能——这是下一章的主题。

第八章。人工智能和游戏进度

在上一章中,我们学习了如何向我们的游戏添加用户界面元素。我们在船只上方添加了生命值表示,添加了按钮,甚至创建了我们的对话框。

在本章中,我们将向我们的游戏添加人工智能。以下是我们将要涵盖的主题:

  • 模糊逻辑和状态机的概念

  • 敌舰应该移动并攻击

  • 在游戏中添加某种进步

  • 赢得和输掉游戏

然而,在我们实际编码之前,让我们看看我们将要实现的人工智能的概念。

理论上的人工智能

敌舰的目标是四处移动,如果它们足够接近,就攻击我们的船只。为了帮助我们实现这一逻辑,我们需要详细研究两个概念;我们将在接下来的章节中讨论这些内容。

解释模糊逻辑

让我们以一辆行驶的火车为例。我们可以使用布尔值来描述其状态。如果设置为 true,它正在快速移动;如果设置为 false,它没有快速移动。

然而,这还不够。假设火车以每小时 80 英里的速度行驶,然后以每小时 100 英里的速度行驶。在这两种速度下,我们的布尔值都将为 true,但我们无法进一步区分它。此外,如果火车没有移动,我们也没有状态。

模糊逻辑描述了一个被放入日常用语中的值区间。让我们退一步,将其与数学逻辑进行比较。二进制(二值)逻辑有两个值:true 和 false。一个如1 + 1 = 2的表达式评估为“true”。表达式“给一加一很可能是两个”在二进制逻辑中可能没有太多意义,但在模糊逻辑中是可能的。

模糊逻辑没有 true 和 false 这两个值,但它有介于两者之间的值,如 bit、quite 或 about。这与人类的思维相似。

为了进一步说明这一点,让我们看看我们的移动火车示例如果以表格形式呈现会是什么样子:

术语 速度
未移动 0 英里/小时
非常慢 1 至 9 英里/小时
几乎快 10 至 49 英里/小时
相当快 50 至 89 英里/小时
非常快 90 至 119 英里/小时

对于我们的游戏,我们可以将此应用于类似的价值:敌舰与我们自己的船只之间的距离。

解释状态机

状态机是一系列状态放入顺序逻辑电路中。这听起来很抽象,所以让我们详细解释一下:首先,状态是一个值,如果不同的状态变得活跃,它就会改变。门有两个状态:锁定和未锁定。如果门是锁着的,它会保持锁定状态,直到它被解锁。

这里有一个更接近我们游戏的例子:我们需要多个状态,例如,移动到玩家等待 3 秒攻击玩家

现在,我们需要将这些状态以某种顺序排列。比如说,敌舰首先移动到玩家那里,然后攻击并等待 3 秒钟。然后,过程再次开始,如下面的图表所示:

解释状态机

到目前为止,我们已经了解了状态和状态机。有限状态机是具有有限状态的状态机。前面的图当然是一个简化的有限状态机如何工作的例子。一些模型也有转换来描述从一种状态移动到另一种状态所采取的动作。在插图上,转换通常伴随着条件,例如“玩家是否在视线中?”

大多数简单的 AI 都采用这种策略。最突出的例子之一是Quake。公平地说,在更复杂和现代的游戏中,使用的 AI 机制各不相同。一个例子是 AI 适应玩家的动作:如果在策略游戏中,玩家选择攻击一个特定的点,AI 就会根据玩家攻击的频率越来越多地适应防御这个位置。

对于我们的目的,有限状态机已经足够。那么,让我们看看敌舰需要哪些状态:

  • 我们希望敌舰四处游荡

  • 我们希望敌舰移动到玩家的附近

  • 我们希望敌舰进行攻击

  • 我们希望敌舰在攻击后稍作等待(让玩家恢复)

让我们将这些状态以如下所示的图表形式表示:

解释状态机

让敌舰移动和攻击

现在我们已经了解了模糊逻辑和状态机,我们可以将这些作为人工智能的机制实现。

移动舰船

首先,我们希望舰船四处移动——既四处游荡又移动到玩家舰船。

行动时间 – 让敌舰移动

为了让敌舰能够移动,我们需要使用以下步骤:

  1. 如果 Xcode 项目尚未打开,请打开我们的 Xcode 项目。

  2. 打开 Battlefield.h 文件。

  3. 定义所有 AI 状态为 enum,如下面的代码所示:

    typedef NS_ENUM(NSInteger, AIState) {
        StateWanderAround,
        StateMoveToPlayer,
        StateAttack,
        StateRecuperate
    };
    
  4. Battlefield 场景内部,添加一个新的实例变量 _aiState,其类型为 AIState

  5. 打开 Ship.h 文件。

  6. 添加一个回调块类型,如下面的代码行所示:

    typedef void(^ShipCallback)(void);
    
  7. Ship 类声明三个新的方法,如下面的代码所示:

    -(void) moveToX:(float)x andY:(float)y withBlock:(ShipCallback) block;
    -(float) checkDistanceToShip:(Ship *)ship;
    -(void) moveToShip:(Ship *)ship withBlock:(ShipCallback) block;
    
  8. 打开 Ship.m 文件。

  9. -(void) moveToX:(float) x andY:(float) y 方法的内容移动到 -(void) moveToX:(float)x andY:(float)y withBlock:(ShipCallback) block 方法中。

  10. 在新的 moveTo 方法中,在 [tweenY animateProperty:@"y" targetValue:targetY]; 行之后添加以下代码:

    __block BOOL isTweenXCompleted = NO;
    __block BOOL isTweenYCompleted = NO;
    
    tweenX.onComplete = ^{
      isTweenXCompleted = YES;
    
      if (isTweenXCompleted && isTweenYCompleted) {
        if (block != nil) {
          [block invoke];
        }
      }
    };
    
    tweenY.onComplete = ^{
      isTweenYCompleted = YES;
    
      if (isTweenXCompleted && isTweenYCompleted) {
        if (block != nil) {
          [block invoke];
        }
      }
    };
    
  11. 使用以下代码实现 checkDistanceToShip 方法:

    -(float) checkDistanceToShip:(Ship *)ship
    {
    SPPoint* p1 = [SPPoint pointWithX:self.x + (self.width / 2) y:self.y + (self.height / 2)];
    SPPoint* p2 = [SPPoint pointWithX:ship.x + (ship.width / 2) y:ship.y + (ship.height / 2)];
    
    float distance = [SPPoint distanceFromPoint:p1 toPoint:p2];
    
    return distance;
    }
    
  12. moveToShip 方法应该具有以下内容:

    -(void) moveToShip:(Ship *)ship withBlock:(ShipCallback)block
    {
        floatrandomX = arc4random_uniform(80) - 40.0f;
        floatrandomY = arc4random_uniform(80) - 40.0f;
    
        [self moveToX:ship.x + randomX andY:ship.y + randomY withBlock:block];
    }
    
  13. 重新实现 moveToX:(float)x andY:(float)y 方法,如下面的代码所示:

    -(void) moveToX:(float)x andY:(float)y
    {
     [self moveToX:x andY:y withBlock: nil];
    }
    
  14. 继续到 Battlefield.m 文件。

  15. 在初始化器内部,将 _aiState 实例变量设置为 StateWanderAround,如下面的代码行所示:

    _aiState = StateWanderAround;
    
  16. 移除补间动画和杂技演员。

  17. 让我们声明一个辅助方法来获取屏幕上的随机位置,如下面的代码所示:

    -(SPPoint *) randomPos
    {
      return [SPPoint pointWithX:((arc4random() % (int) (Sparrow.stage.width - 80.0f)) + 40.0f) y:((arc4random() % (int) (Sparrow.stage.height - 80.0f)) + 40.0f)];
    }
    
  18. 定义一个名为 updateAI 的方法,如下面的代码所示:

    -(void) updateAI: (Ship *)ship withState: (AIState) aiState
    {
    switch (aiState) {
    caseStateWanderAround: {
    SPPoint *point = [self randomPos];
                [ship moveToX:point.x andY:point.y withBlock:^{
    if ([ship checkDistanceToShip:_pirateShip] < 200.0f) {
                        //In sight
                        [self updateAI:ship withState:StateMoveToPlayer];
                    } else {
                        //Not in sight
                        [self updateAI:ship withState:aiState]
                    }
                }];
            }
    break;
    caseStateMoveToPlayer: {
                [ship moveToShip:_pirateShip WithBlock:^{
    if ([ship checkDistanceToShip:_pirateShip] < 100.0f) {
                        // Attack
                        [self updateAI:ship withState:StateAttack];
                    } else {
                        //Not in sight
                        [self updateAI:ship   withState:StateWanderAround];
                    }
                }];
            }
    break;
    default:
    break;
        }
    }
    
  19. 在初始化杂技演员之前的位置调用 updateAI 方法,如下面的代码所示:

    [self updateAI:_enemyShip withState:_aiState];
    
  20. 运行示例。

    我们现在看到我们的船和敌船正在各自移动。

    行动时间 – 让敌船移动

发生了什么?

在第 1 步,我们打开了我们的游戏项目;在下一步中,我们查看了 Battlefield.h 文件。我们之前提到的所有 AI 状态都被放入了 enum 中。在第 4 步,我们定义了一个实例变量来保存默认的 AI 状态。

我们已经在 Ship 类中有一个 moveTo 方法,它允许我们在屏幕上移动任何船。不幸的是,我们目前还没有一种方法来知道移动何时结束。我们可以使用在前面章节中使用过的 Objective-C 语言特性,即块。我们将块定义为参数;当移动结束时,块会被调用。在第 6 步,我们定义了我们的块类型。

在下一步中,我们为我们的 Ship 类声明了通用方法:

  • 移动到某个位置,并在移动完成后使用回调

  • 检查当前船与任何其他船之间的距离

  • 移动到另一艘船,并在操作完成后使用回调

然后,我们准备在第 8 步实现这些方法。我们首先将旧 moveTo 方法的内容移动到带有回调的新方法中。

然后,我们只需在动画完成后调用回调块。由于补间动画可能根据触摸点与船之间的距离具有两种不同的速度,我们需要记录每个补间动画是否完成,对于两个补间动画都这样做。为了检查补间动画是否实际上已完成,我们在补间的 onComplete 属性中添加了一个块。一旦补间完成,该块就会被调用。在这个块内部,我们设置一个布尔值来标记当前的补间已完成,如果两个补间都已完成,我们就调用回调。为了能够在 onComplete 块中使用我们的局部变量,我们需要在它们前面加上 __blocks 前缀。

在第 11 步,我们实现了一个计算两艘船之间距离的方法:我们取了两艘船的中心,将它们转换为 SPPoint,并利用 SPPoint 提供的静态 distanceFromPoint 方法。我们只需要返回结果。

moveToShip 方法调用了带有飞船坐标和附加随机性的 moveTo 方法。我们使用了 arc4random 函数来获取一个随机值。arc4random 函数返回一个介于零和一之间的浮点数。arc4random_uniform 函数接受一个参数,并创建一个介于零和传入参数减一之间的随机数。在第 13 步中,没有回调的 moveTo 方法只是调用了带有回调的 moveTo 版本,回调参数通过 nil 传递。

当我们移动到 Battlefield.m 文件时,我们将 _aiState 实例变量设置为 WanderState AI 状态。然后我们安全地移除了之前负责敌人飞船移动动画的补间和 juggler

在第 17 步中,我们实现了一个获取屏幕上随机位置的方法。我们还设置了一个边距,以确保位置肯定在屏幕的边界内。我们使用了 SPPoint 类的工厂方法来存储 xy 位置。

在下一步中,我们实现了更新 AI 的方法:

  • 如果飞船正在四处游荡,我们会得到一个随机位置并移动到那里。

  • 如果飞船已经移动,它会检查玩家与飞船参数之间的距离是否小于 200 点。然后飞船移动到玩家那里。如果不是这种情况,我们再次调用 updateAI 方法,并使用 WanderState AI 状态。

  • 如果飞船移动到玩家附近,它会再次检查距离。如果距离小于 100 点,它开始攻击,否则它就回到四处游荡的状态。

在初始化器内部,我们使用敌人飞船和我们的默认 AI 状态调用了 updateAI 方法。这应该就是我们之前初始化实例变量 _juggler 的地方。

当我们运行示例时,如果敌人飞船处于正确的状态,它会四处游荡。如果它在视线范围内,它会移动到玩家飞船那里。如果敌人飞船离玩家太近,它就会停止。

攻击其他飞船

现在敌人飞船正在四处游荡,让我们让它攻击我们的飞船。

行动时间 - 敌人应该攻击玩家

为了让敌人攻击玩家的飞船,请按照以下步骤操作:

  1. 打开 Ship.h 文件。

  2. 将我们的 _juggler 实例变量重构为属性,如下面的代码行所示:

    @property SPJuggler *juggler;
    
  3. 使用以下代码行,添加一个名为 shootWithBlock 的方法,该方法应该射击并带有作为参数的回调:

    -(void) shootWithBlock:(ShipCallback) block;
    
  4. 打开 Ship.m 文件,并将 shoot 方法的全部内容移动到 shootWithBlock 方法中。

  5. shootWithBlock 方法中,在 currentClip 变量的完整监听器内部调用回调作为其最后一条语句。

  6. shoot 方法更新为调用 shootWithBlock 方法并传递 nil

  7. 打开 Battlefield.m 文件,并添加一个碰撞检测方法,如下面的代码所示:

    -(void) checkShipCollision: (Ship *) ship1 againstShip: (Ship *) ship2
    {
        SPRectangle *enemyShipBounds = [ship1 boundsInSpace:self];
        SPRectangle *ball1 = [ship2.cannonBallLeft boundsInSpace:self];
        SPRectangle *ball2 = [ship2.cannonBallRight boundsInSpace:self];
    
        if ([enemyShipBounds intersectsRectangle:ball1] || [enemyShipBounds intersectsRectangle:ball2]) {
            if (ship2.cannonBallLeft.visible || ship2.cannonBallRight.visible) {
                [ship2 abortShooting];
                [ship1 hit];
            }
        }
    }
    
  8. onEnterFrame方法内部,将当前的碰撞检测替换为checkShipCollision方法,如下面的代码所示:

    [self checkShipCollision:_pirateShipagainstShip:_enemyShip];
    [self checkShipCollision:_enemyShipagainstShip:_pirateShip];
    
  9. 使用以下代码更新WanderAround AI 状态,增加一个额外的攻击机会:

    if ([ship checkDistanceToShip:_pirateShip] < 200.0f) {
     if ([ship checkDistanceToShip:_pirateShip] < 100.0f) {
     // Attack directly
     [self updateAI:ship withState:StateAttack];
     } else {
     //In sight
     [self updateAI:ship withState:StateMoveToPlayer];
     }
    } else {
      //Not in sight
      [self updateAI:ship withState:aiState];
    }
    
  10. 如以下代码所示,将以下状态添加到我们的updateAI方法中的switch-case语句中:

    case StateAttack: {
      [ship shootWithBlock:^{
        [self updateAI:ship withState:StateRecuperate];
      }];
    }
    case StateRecuperate: {
      [ship.juggler delayInvocationByTime:0.3f block:^{
        [self updateAI:ship withState:StateWanderAround];
      }];
    }
    
  11. 运行示例以查看结果。

    如果敌舰足够接近我们的船并且在攻击状态中,它开始攻击我们的船。参见图表:

    行动时间 – 敌舰应该攻击玩家

发生了什么?

Ship.h文件中,我们将_juggler实例变量重构为一个属性,因为我们需要从战场场景中访问它,并且其访问不应仅限于Ship实例。我们添加了shootWithBlock方法,我们在第 4 步中实现了它,将shoot方法的内容移动到新的shootWithBlock方法中。

然后,我们调用了应该现在是事件监听器中完成currentClip缓动的最后一个回调。在第 6 步中,我们将shoot方法更新为调用带有空块的shootWithBlock方法,就像我们在上一个示例中所做的那样。

由于我们多次使用了碰撞检测,我们在下一步将其放入一个单独的方法中。现在,我们可以通过调用新的碰撞检测来替换我们旧的碰撞检测逻辑。我们需要调用两次,第一次将_pirateShip作为第一个参数,将_enemyShip作为第二个参数。当我们第二次调用checkShipCollision时,参数的顺序需要相反。

在第 9 步中,我们添加了一个额外的状态转换。如果海盗船和敌舰之间的距离小于 100 点,它将直接攻击而不是先移动到玩家那里。在接下来的步骤中,我们添加了以下两个缺失的状态:

  • 在攻击状态中,我们调用了shootWithBlock方法,当射击完成时,我们移动到恢复状态

  • StateRecuperate AI 状态中,我们等待了 0.3 秒,然后继续四处游荡

当我们运行示例时,我们的状态机已经完全完成,所有状态都被使用。

向 AI 添加模糊值

我们的人工智能目前运行良好,但我们还没有任何模糊逻辑。

行动时间 – 使用模糊值增强 AI

要替换我们硬编码的值,我们需要使用以下步骤:

  1. 打开Battlefield.m文件。

  2. 添加一个名为fuzzyValue的新方法,如下面的代码所示:

    -(float) fuzzyValue: (NSString *) value
    {
      if ([value isEqualToString:@"Very near"]) {
        return (float) (arg4random() % 40) + 40.0f;
      } else if ([value isEqualToString:@"Quite near"]) {
        result = (float) (arc4random() % 30) + 70.0f;
      } else {
        result = (float) (arc4random() % 50) + 150.0f;
      }
    }
    
  3. 使用以下代码,将硬编码的值更新为fuzzyValue方法中的值:

    if ([ship checkDistanceToShip:_pirateShip] < [self fuzzyValue:@"Near"]) {
    if ([ship checkDistanceToShip:_pirateShip] < [self fuzzyValue:@"Very near"]) {
    if ([ship checkDistanceToShip:_pirateShip] < [self fuzzyValue:@"Quite near"]) {
    
  4. 运行示例。如果我们插入日志来查看实际的值,我们会看到以下输出:行动时间 – 使用模糊值增强 AI

发生了什么?

本例的目标是将我们的硬编码值替换为类似于模糊逻辑的东西。在步骤 2 中,我们添加了一个检查值并返回新随机值的方法。随机性不是模糊逻辑的必要因素,但在这个案例中使用了它,以便值在特定范围内。

如果我们有多于模糊值,将那些值保存在NSDictionary中是个好主意。这个字典将使用口语术语作为键,并为值提供一个块。在块内部将包含返回随机数的逻辑。如果模糊值被传递进来,我们调用块并得到一个随机数。

接下来,我们使用fuzzyValue方法更新了硬编码的值,并将其放在每次的口语术语中。

当我们运行示例时,AI 的工作方式与之前一样,但现在有了额外的随机性。

来试试英雄

我们可以通过将 AI 逻辑从战场场景移到单独的类中来大大提高 AI。由于我们使用了大量的字符串作为模糊值,可能将它们移动到常量中甚至创建我们自己的宏是个好主意。

添加游戏进度

现在我们已经实现了 AI,让我们给我们的游戏添加一些进度。我们需要添加等级。每个等级应该有一个更多的敌舰,我们可以在等级之间升级我们的船的伤害和生命值。

添加 World 类

我们需要保留一些值,例如当前级别,在一个单独的实体中,我们将描述为World类。

行动时间 – 添加 World 类

为了实现我们的World类,我们需要使用以下步骤:

  1. 添加一个名为World的新 Objective-C 类,它从NSObject派生。

  2. 要添加一个level属性,其类型为int,请执行以下操作:

    • World.h中添加一个名为level的静态变量,如下面的代码行所示:

      static int level;
      
    • 添加一个具有相同名称的静态获取器,它返回静态变量,如下面的代码行所示:

      +(int) level;
      
    • 添加一个静态设置器(setLevel),它设置静态变量,如下面的代码行所示:

      +(void) setLevel:(int)value;
      
  3. 对属性goldhitpointsdamage重复步骤 2。

  4. 我们还需要一个levelMax属性,但这个属性没有设置器。

  5. 我们需要在World.m文件中导入Assets.h文件。

  6. 添加一个需要在World.h中声明的静态reset方法。它应该看起来像以下代码片段:

    +(void) reset
    {
        level = 1;
        levelMax = 3;
        gold = 200;
        damage = [(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"damage"] intValue];
        hitpoints = [(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"hitpoints"] intValue];
    }
    
  7. 我们还需要一个log方法。它需要在World.h中声明,并看起来像以下代码:

    +(void) log
    {
        NSLog(@"Level %d of %d", level, levelMax);
        NSLog(@"Gold: %d", gold);
        NSLog(@"Players' hit points: %d", hitpoints);
        NSLog(@"Players' damage: %d", damage);
    }
    
  8. Game.m中,我们需要在其初始化器中调用World方法,如下面的代码所示:

    [director addScene:battlefield];
    
    [World reset];
    [World log];
    
    [director showScene:@"battlefield"];
    
  9. 运行示例以查看结果。我们现在应该在控制台中看到以下输出:行动时间 – 添加 World 类

刚才发生了什么?

首先,我们创建了 World 类。Objective-C 不支持静态属性。如果我们添加具有 methodName 作为名称的静态方法,并返回一个值,我们可以模仿具有静态属性的行为。我们还需要定义一个名为 setMethodName 的方法,它有一个参数。现在我们可以像访问属性一样访问 methodName。然而,在伪获取器内部,我们只能访问静态变量。

在完成设置后,我们需要在第 5 步中导入 Assets 类。之后,我们添加了一个 reset 方法,该方法从我们的 gameplay.json 文件中加载伤害和生命值。我们将 goldlevellevelMax 变量设置为默认值。在我们的例子中,当前级别是第一个;我们最多有三个级别,初始时 gold 变量的金额是 200。

我们后来实现的 log 方法记录了所有值,除了 levelMax 值。在第 8 步中,我们调用了 reset 方法,并在之后直接调用了 log。当我们运行示例时,我们在控制台中看到了日志输出。

勇敢的尝试者

目前,goldlevellevelMax 变量是直接在代码中设置的。从 gameplay.json 文件中加载它们会更好。

更新场景和对话框类

在我们开始实现进度系统之前,还有一些小事情需要重构。让我们解决这些问题:

  • 如果场景再次显示,我们无法重置场景

  • 对话框中的多行字符串显示不正确

  • 我们无法在 Dialog 类外部访问对话框的按钮

  • 点击按钮后,对话框不会关闭

更新场景和对话框类的时间到了

要添加我们的第一个按钮,请按照以下步骤操作:

  1. 打开 Dialog.h 文件。

  2. 使用以下代码为 按钮添加属性:

    @propertySPButton *buttonYes;
    @propertySPButton *buttonNo;
    
  3. 切换到 Dialog.m

  4. 将所有对局部变量的引用重构为使用属性。

  5. 使用以下代码更新 _title_content 的位置:

    content = [SPTextField textFieldWithWidth:background.width - 96.0f height:background.height - 150.0f text:@"Dialog default text"];
    _content.x = 52.0f;
    _content.y = 66.0f;
    
    [SPTextField registerBitmapFontFromFile:@"PirateFont.fnt"];
    
    _title = [SPTextField textFieldWithWidth:background.width * 0.6 height:30.0f text:@"Dialog"];
    _title.fontName = @"PirateFont";
    _title.color = SP_WHITE;
    
    _title.x = 36.0f;
    _title.y = 26.0f;
    
  6. onButtonYesonButtonNo 中,将 self.visible = NO; 作为第一条语句添加。

  7. Scene.h 中,使用以下代码声明一个名为 reset 的方法:

    -(void) reset;
    
  8. Scene.m 中,实现一个空的 reset 方法。

  9. SceneDirector.m 中,更新 showScene 方法中的这段代码:

    if (_dict[name] != nil) {
      ((Scene *) _dict[name]).visible = YES;
     [((Scene *) _dict[name]) reset];
    
    }
    
  10. 运行示例。

    如果我们将 reset 方法实现到战场场景中,并向战场场景的 reset 方法添加一个日志消息,我们的输出将变为如下:

    更新场景和对话框类的时间到了

发生了什么?

我们首先解决了对话框问题。在第 2 到 4 步中,我们将按钮移动到属性中,并更新了 Dialog 实现内部的所有引用。然后我们更新了标题和消息内容的位置。长字符串不会超过对话框的边界。在第 6 步中,我们在点击任何按钮后隐藏了对话框。

为了使场景能够重置自己,我们首先需要添加 reset 方法,并在 Scene.m 中将其实现为一个空方法。然后我们需要更新场景导演,在场景变为可见后立即调用当前场景的 reset 方法。

如果我们现在运行示例,并且我们在战场场景中实现了 reset 方法并添加了日志消息,我们会看到战场场景中的 reset 方法实际上会被调用。

为海盗湾添加游戏机制

现在我们有了 World 类,并且我们已经更新了 DialogScene 类以适应我们的需求,我们可以在海盗湾中添加一些游戏机制。海盗湾是我们升级船只的地方。

行动时间 – 使海盗湾可玩

要为海盗湾添加游戏机制,请按照以下步骤操作:

  1. 将代码行 [SPTextField registerBitmapFontFromFile:@"PirateFont.fnt"];Dialog.m 移动到 Game.m 文件的开始部分。

  2. PirateCove.m 中添加一个按钮,如下所示:

    SPButton *buttonBattle = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"dialog_yes"]; 
    text:@"Begin battle"];
    
    buttonBattle.y = Sparrow.stage.height - buttonBattle.height - 8.0f;
    buttonBattle.x = (Sparrow.stage.width - buttonBattle.width) / 2;
    
    [buttonBattle addEventListenerForType:SP_EVENT_TYPE_TRIGGERED block:^(SPEvent *event){
      [((SceneDirector *) self.director) showScene:@"battlefield"];
    }];
    
  3. 使用以下代码行将按钮添加到显示树中:

    [self addChild:buttonBattle];
    
  4. 在以下代码中,我们添加了一个文本字段来显示当前的金币数量,首先需要将其声明为实例变量:

    _goldTextField = [SPTextField textFieldWithWidth:Sparrow.stage.width - 16.0f height:30.0f text:@"Gold"];
    _goldTextField.fontName = @"PirateFont";
    _goldTextField.color = SP_WHITE;
    
    _goldTextField.x = 8.0f;
    _goldTextField.y = 8.0f;
    
  5. 使用以下代码行将文本字段添加到显示树中:

    [self addChild:_goldTextField];
    
  6. 添加一个方法来更新屏幕上的金币数量,如下所示:

    -(void) updateGoldTextField
    {
        _goldTextField.text = [NSString stringWithFormat:@"Gold: %d", World.gold];
    }
    
  7. PirateCove.h 文件中,添加一个名为 _dialogUpdateDamage 的实例变量,如下所示:

    Dialog *_dialogUpdateDamage;
    
  8. 添加一个名为 _goldDamage 的实例变量,如下所示:

    int _goldDamage;
    
  9. 在初始化器中,为第一个对话框添加以下代码:

    _dialogUpdateDamage = [[Dialog alloc] init];
    
    _dialogUpdateDamage.title.text = @"Update damage?";
    
    _dialogUpdateDamage.x = (Sparrow.stage.width - _dialogUpdateDamage.width) / 2;
    _dialogUpdateDamage.y = (Sparrow.stage.height - _dialogUpdateDamage.height) / 2;
    
    _dialogUpdateDamage.visible = NO;
    
    [weaponsmith addEventListenerForType:SP_EVENT_TYPE_TOUCH block:^(SPEvent *event){
      if (World.gold < _goldDamage) {
        _dialogUpdateDamage.buttonYes.enabled = NO;
      }
    
      _dialogUpdateDamage.visible = YES;
    }];
    
    [_dialogUpdateDamage addEventListener:@selector(onUpdateDamage:) atObject:self forType:EVENT_TYPE_YES_TRIGGERED];
    
  10. 使用以下代码行将对话框添加到显示树中:

    [self addChild:_dialogUpdateDamage];
    
  11. 添加 onUpdateDamage 方法,如下所示:

    -(void) onUpdateDamage: (SPEvent *) event
    {
    World.damage = World.damage + (int) (World.damage / 10);
    World.gold = World.gold - _goldDamage;
        [self updateGoldTextField];
    }
    
  12. 对升级生命值的对话框重复步骤 7 到 11。

  13. 按照以下方式为海盗湾场景添加一个 reset 方法:

    -(void) reset
    {
        _goldDamage = (150 + (50 * (World.level - 1)));
        _dialogUpdateDamage.content.text = [NSString stringWithFormat:@"Increasing damage costs %d gold. Do you wish to proceed?", _goldDamage];
    
        _goldHitpoints = (200 + (75 * (World.level - 1)));
        _dialogUpdateHitpoints.content.text = [NSString stringWithFormat:@"Increasing hitpoints costs %d gold. Do you wish to proceed?", _goldHitpoints];
    
        [self updateGoldTextField];
    }
    
  14. 更新 Game.m 文件中的语句,以在开始游戏时显示海盗湾。

  15. 运行示例以查看结果。我们现在可以在海盗湾中升级我们的船只,如下面的截图所示:行动时间 – 使海盗湾可玩

发生了什么?

在步骤 1 中,我们将位图字体的注册移动到了 Game 类中。我们只需要它一次。由于我们之前只有一个对话框,所以注册字体的位置并不重要。然而,现在我们有多个对话框,对话框的初始化器会多次注册字体。

在第 2 步中,我们添加了一个按钮,当我们点击它时可以切换到战场场景。在将按钮添加到显示树之后,我们还添加了一个文本字段来显示当前的金币数量。随后我们将文本字段添加到显示树中。我们还添加了一个更新文本字段的方法。

在第 6 步到第 11 步中,我们在屏幕上添加了一个对话框,当点击武器师时会弹出。它检查我们是否有足够的金币可以使用,并且如果有的话,允许我们升级我们的伤害。

在第 13 步中,我们实现了reset方法。目的是使升级船只的成本随着当前级别的提高而越来越高。

添加游戏进度

为添加游戏进度已经一切准备就绪。

尝试成为英雄——将我们的游戏变成真正的游戏

让我们继续实现游戏进度。以下是一些你应该记住的事项:

  • 在创建战场实例之前需要重置World

  • 当玩家达到更高等级时更新金币数量

  • 使用战场场景的reset方法重置位置和生命值

  • 需要有一种方法来跟踪所有沉没的船只

  • 敌人可能应该是类似数组的对象

  • 当战场初始化时,游戏本身不应启动

在考虑了前面的点之后,游戏应该看起来像以下截图所示:

尝试成为英雄——将我们的游戏变成真正的游戏

注意

查看如何实现前面的点,并将这些源文件作为以下练习的基础:

添加胜利和失败条件

本章的最后一件事情是为我们的游戏添加赢和输的条件。现在,我们将只显示一个文本字段,显示我们是否赢了或输了。

行动时间 – 能够赢或输

为了能够赢或输游戏,请按照以下步骤操作:

  1. Ship.h中,使用以下代码行添加一个回调属性:

    @property (nonatomic, copy) ShipCallbackonDead;
    
  2. 如果飞船的击中点数等于或小于零,则此回调属性会被调用,如下面的代码所示:

    if (_hitpoints<= 0) {
      self.visible = FALSE;
    
     if (self.onDead) {
     [_onDead invoke];
     }
    }
    
  3. Battlefield.h文件中,为我们的新文本字段添加两个属性,如下所示:

    @property SPTextField *textGameWon;
    @property SPTextField *textGameLost;
    
  4. 在初始化器中,添加以下代码片段:

    _textGameLost = [SPTextField textFieldWithWidth:Sparrow.stage.width height:Sparrow.stage.height text:@"Game Over"];
    _textGameLost.fontName = @"PirateFont";
    _textGameLost.color = SP_WHITE;
    _textGameLost.visible = NO;
    
    _textGameWon = [SPTextField textFieldWithWidth:Sparrow.stage.width height:Sparrow.stage.height text:@"You won the game. Well done"];
    _textGameWon.fontName = @"PirateFont";
    _textGameWon.color = SP_WHITE;
    _textGameWon.visible = NO;
    
    __weak typeof(self) weakSelf = self;
    _pirateShip.onDead = ^{
      weakSelf.textGameLost.visible = YES;
    };
    //...
    [self addChild:_textGameLost];
    [self addChild:_textGameWon];
    
  5. onEnterFrame方法内部,通过添加赢的条件来更新进度系统,如下所示:

    if (deadCount == World.level) {
     if (World.level == World.levelMax) {
     self.textGameWon.visible = YES;
     } else {
        World.gold = World.gold + (250 * World.level);
        World.level++;
        self.paused = YES;
        [((SceneDirector *) self.director) showScene:@"piratecove"];
      }
    }
    
  6. 运行示例以查看结果。

    如果我们现在赢得或输掉游戏,屏幕上会显示一个文本字段,如下面的截图所示:

    行动时间 – 能够赢或输

发生了什么?

我们需要知道飞船被摧毁的确切时刻,因此我们在步骤 1 和 2 中添加了一个回调。精确到玩家飞船被摧毁的那一刻,我们想要显示一些信息来告知玩家已经输掉了游戏。

然后,我们在步骤 3 和 4 中添加了文本字段。这里唯一需要考虑的是,我们需要在块内部访问self(实例本身)。通常情况下,我们无法在块中访问self的任何属性,但我们需要这样做,因为文本字段是实例本身的属性。因此,我们需要使用__weak关键字来使用不安全的引用。这是一件应该谨慎使用的事情,通常情况下,只有作为最后的手段。我们还需要确保文本字段作为最后一个元素添加到显示树中,这样它们就始终位于所有其他元素之上。在添加输掉条件后,我们在步骤 5 中添加了赢的条件。当我们运行示例时,如果我们输掉或赢掉游戏,我们会看到文本弹出。

技术上,我们也可以在我们赢了之后动态创建文本字段。然而,最佳实践是在一开始就创建所有内容,尤其是在复杂的项目中。

小测验

Q1. SPPoint提供了一个方法来获取两点之间的距离。

  1. True

  2. False

Q2. 有限状态机始终需要转换。

  1. True

  2. False

Q3. 如果我们想在块内部修改一个局部变量,我们需要做什么?

  1. 使其成为一个弱引用

  2. 在变量前加上__block

  3. 将其重构为属性

概述

在本章中,我们学习了人工智能。具体来说,我们涵盖了模糊逻辑和有限状态机,并且还增加了更多的游戏元素。

现在既然我们的游戏功能完整但边缘粗糙,让我们给我们的游戏添加一些音频——这是下一章的主题。

第九章:为我们的游戏添加音频

在前一章中,我们学习了人工智能。我们学习了有限状态机和模糊逻辑的理论。我们将这些元素应用到我们的游戏中。我们还实现了剩余的游戏元素。在本章中,我们将向我们的游戏添加音乐和声音。音频本身是任何游戏的重要方面,因为它构成了玩家体验的一部分。尝试在没有音乐的情况下玩你最喜欢的游戏,你会发现游戏体验完全不同。

在本章中,我们将涵盖以下主题:

  • 加载声音和音乐文件

  • 生成我们自己的声音效果

  • 播放音频

让我们给我们的游戏添加音乐和声音,怎么样?

寻找音乐和声音

在开发游戏时,开发者通常不是全能的,在寻找声音和音乐时可能会遇到困难。苹果自家的 GarageBand 提供了一个简单的方法来创建音乐,可以使用预定义的循环或甚至自己的乐器。另一种可能性是找到能够帮助创建音频文件的有才能的人。可以关注的地方之一是 TIGSource 论坛——一个独立游戏开发者的聚集地,它有一个forums.tigsource.com/index.php?board=43.0的收藏夹部分,以及一个提供付费工作的forums.tigsource.com/index.php?board=40.0部分。

生成声音效果

Bxfr是一个常用于游戏快闪节的程序声音生成器。它可以在www.bfxr.net/在线找到;Windows 和 Mac OS X 的独立版本也可以从这个链接下载。它的目的是通过几步点击生成 8 位声音效果:

生成声音效果

首先,我们需要选择一个类型,然后我们可以通过几个滑块来修改它,例如声音的频率或长度。

完成后,我们可以使用导出 Wav按钮导出声音效果。

了解音频格式

Sparrow 允许加载 iOS 支持的任何音频文件。一些音频编解码器支持硬件辅助解码,而另一些则不支持。

iOS 设备包含专门用于处理某些音频格式(例如,AIFC)的硬件,从而释放出原本用于处理这些昂贵操作的 CPU。硬件辅助方法的缺点是每次只能处理一个文件。例如,你不能同时用它播放背景音乐和声音效果。

关于 iOS 处理音频播放的更多信息,请查看苹果的文档:developer.apple.com/library/ios/documentation/audiovideo/conceptual/multimediapg/UsingAudio/UsingAudio.html

Sparrow 中音频格式的最佳格式是 AIFC 和 CAFF。

让我们看看它们是什么:

  • AIFC 是一种压缩的 音频交换格式 (AIFF) 文件。这通常是背景音乐的最好选择。还有一件事要考虑:如果音频播放是硬件辅助的(如 AIFC 的情况),一次只能播放一个文件。

  • 核心音频文件格式 (CAFF) 是一种未压缩的音频格式。这种格式最适合用于短音效。

这两种格式对 CPU 的占用最小。如果应用程序大小是一个问题,有一种非常规的方法可以解决这个问题:一些设备仍然只有单声道扬声器,所以如果有很多声音文件,将音频文件转换为单声道可能是一个有效的选择。

要转换音频文件,iOS SDK 提供了一个名为 afconvert 的命令行工具。假设我们的音频文件名为 myAudioFile.wav,我们可以使用以下示例:

  • 转换为 CAFF:将文件转换为 CAFF 的命令是 afconvert –f caff –d LE16 myAudioFile.wav

  • 转换为 AIFC:将文件转换为 AIFC 的命令是 afconvert –f AIFC –d ima4 myAudioFile.wav

游戏中的音乐和音效

必要的音频文件再次上传到我们的 GitHub 仓库。为了使用它们,从 github.com/freezedev/pirategame-assets/releases/download/0.9/Audio_09.zip 下载它们,解压文件,并将内容复制到我们的模板中。在将文件复制到项目中时,我们需要确保将它们添加到目标中。

添加音频播放

现在我们已经了解了音频格式,如果需要,我们可以为自己生成声音,如果我们有必要的文件,我们也可以播放一些音频。

启动音频引擎

在我们可以播放任何声音之前,我们需要启动音频引擎。

行动时间 – 获取音频文件播放

执行以下步骤以启动音频引擎:

  1. 如果它还没有打开,请打开我们的 Xcode 项目。

  2. 切换到 Game.m 文件。

  3. 在初始化器中,按照以下方式启动音频引擎;它应该是前几条语句之一:

    [SPAudioEngine start];
    
  4. 添加一个 dealloc 方法来停止音频引擎:

    -(void) dealloc
    {
        [SPAudioEngine stop];
    }
    
  5. 运行示例。

当我们在模拟器中运行此示例时,我们可能会在控制台中看到以下行:

行动时间 – 获取音频文件播放

发生了什么?

要播放任何音频文件,我们需要在应用程序启动时启动音频引擎,在我们的例子中,是从 Game 类的初始化器开始。

音频引擎有不同的操作模式,这会影响我们在运行游戏时 iPod 音乐应用的行为。

如果音频被静音,游戏音频也会被静音。这是默认的操作模式;其他模式包括即使设备被静音,游戏音频也会继续播放,或者 iPod 音乐与游戏音频混合。看看后者在代码中会是什么样子:

[SPAudioEngine start: SPAudioSessionCategory_AmbientSound];

想要了解更多信息,请查看 Sparrow SPAudioEngine 文档,链接为 doc.sparrow-framework.org/v2/Classes/SPAudioEngine.html

当我们运行此示例时,我们在控制台中获取有关音频引擎的一些信息。

尝试一下

目前,音频引擎在游戏开始或停止时启动或停止。如果触发背景和前景事件(如 applicationWillResignActiveapplicationDidBecomeActive),启动和停止引擎也是一个好主意。

在我们的场景中播放音乐

现在音频引擎已经启动并运行,让我们播放背景音乐。

行动时间 - 在我们的场景中播放音乐

执行以下步骤以在我们的场景中播放背景音乐:

  1. 打开 Scene.h 文件。

  2. 添加一个名为 backgroundMusic 的实例变量,它是一个指向 SPSoundChannel 的指针,使用以下代码行:

    SPSoundChannel *backgroundMusic;
    
  3. 声明一个名为 stop 的方法,如下所示:

    -(void) stop;
    
  4. Scene.m 文件中,定义一个空的 stop 方法。

  5. 更新 SceneDirector.m 文件中的 showScene 方法以适应以下代码块:

    -(void) showScene:(NSString *)name
    {
        for (NSString* sceneName in _dict) {
            ((Scene *) _dict[sceneName]).visible = NO;
            [((Scene *) _dict[sceneName]) stop];
        }
        if (_dict[name] != nil) {
            ((Scene *) _dict[name]).visible = YES;
            [((Scene *) _dict[name]) reset];
    
        }
    } 
    
  6. 切换到 PirateCove.m

  7. 在初始化器中,在顶部添加以下行:

    SPSound *sound = [Assets sound:@"music_cove.aifc"];
    backgroundMusic = [sound createChannel];
    backgroundMusic.loop = YES;
    
  8. 更新 reset 方法,使其看起来如下:

    -(void) reset
    {
        [backgroundMusic play];
    
        _goldDamage = (150 + (50 * (World.level - 1)));
        _dialogUpdateDamage.content.text = [NSString stringWithFormat:@"Increasing damage costs %d gold. Do you wish to proceed?", _goldDamage];
    
        _goldHitpoints = (200 + (75 * (World.level - 1)));
        _dialogUpdateHitpoints.content.text = [NSString stringWithFormat:@"Increasing hitpoints costs %d gold. Do you wish to proceed?", _goldHitpoints];
    
        [self updateGoldTextField];
    }
    
  9. 按照以下方式实现场景的 stop 方法:

    -(void) stop
    {
        [backgroundMusic stop];
    }
    
  10. 运行示例,您将看到以下输出。我们现在可以听到背景音乐。行动时间 – 在我们的场景中播放音乐

发生了什么?

首先,我们添加了一个实例变量(backgroundMusic)来存储背景音乐。SPSound 变量存储声音文件的数据,而 SPSoundChannel 则播放声音本身,这与 SPTextureSPImage 之间的关系类似。建议您保留对 SPSoundChannel 的引用。如果我们想因任何原因停止播放声音,这是必需的。

为了让我们在多个场景中拥有背景音乐,我们需要停止当前场景的背景音乐并从下一个场景开始播放音乐,因为我们不希望遇到任何讨厌的副作用。这些副作用是第一个音乐文件将使用硬件编解码器,而第二个将使用软件解码,从而严重影响我们游戏的表现。两个音乐文件都将播放。

如果我们想在场景中停止背景音乐,我们可以利用场景的 reset 方法。现在,我们想在场景被停用时做同样的事情。我们在第 3 步中正好为此目的声明了 stop 方法,并在之后的步骤中实现了它作为一个空方法。在 SceneManager 类中,我们需要在隐藏场景时调用每个场景的 stop 方法。

PirateCove场景的初始化器中,我们创建了一个局部的SPSound变量,通过我们的资产管理系统加载音乐文件。然后我们使用了createChannel方法并将结果保存在实例变量中。我们希望音乐无限循环,所以我们将loop属性设置为YES

在第 8 步中,我们更新了reset方法以播放背景音乐,在第 9 步中,我们覆盖了stop方法并停止了背景音乐。

现在我们运行这个示例,我们可以听到音乐在循环播放。

尝试一下英雄

现在海盗湾场景已经有了一些背景音乐,接下来给战场添加一些音乐。

添加声音效果

我们的音频引擎已经启动并运行;因为我们已经播放了一些音乐,所以我们知道它是有效的,现在是我们添加声音效果的时候了。

行动时间 – 海盗湾中的声音效果

要将声音效果添加到海盗湾场景中,执行以下步骤:

  1. 打开PirateCove.m文件。

  2. 更新onUpdateDamageonUpdateHitpoints方法以播放声音效果,如下面的代码所示:

    -(void) onUpdateDamage: (SPEvent *) event
    {
        World.damage = World.damage + (int) (World.damage / 10);
        World.gold = World.gold - _goldDamage;
        [self updateGoldTextField];
    
        [[Assets sound:@"powerup.caf"] play];
    }
    
    -(void) onUpdateHitpoints: (SPEvent *) event
    {
        World.hitpoints = World.hitpoints + (int) (World.hitpoints / 5);
        World.gold = World.gold - _goldHitpoints;
        [self updateGoldTextField];
    
        [[Assets sound:@"powerup.caf"] play];
    }
    
  3. 运行示例,你将看到以下输出。现在,如果我们成功升级我们的海盗船,我们就能听到声音。行动时间 – 海盗湾中的声音效果

发生了什么?

在海盗湾场景中,我们在onUpdateDamageonUpdateHitpoints方法中添加了声音效果。我们通过资产管理系统获取了增益文件,然后直接播放声音。这种方法对于短声音和不需要后续操作音频通道引用的地方很有用。

现在,当我们运行这个示例时,一旦我们成功升级我们的船,我们就能听到声音效果。

尝试一下英雄

现在请添加以下战场声音效果:

  • 当一艘船被击中时(Ship类中的hit方法)

  • 当一艘船射击时(Ship类中的shoot方法)

  • 当一艘船被摧毁(Ship类中的hitPoints获取器)

快速问答

Q1. AAC 音频文件提供硬件辅助编码。

  1. 真的

  2. 假的

Q2. 如果SPSound只包含声音数据,应该使用哪个类来播放音频文件?

  1. AVAudioSession

  2. SPSoundChannel

  3. SPAudio

Q3. 要播放任何声音,我们需要初始化音频引擎。

  1. 真的

  2. 假的

摘要

在本章中,我们学习了如何加载和播放音频文件。具体来说,我们涵盖了数据格式和在 Sparrow 中音频的基本用法。

现在我们游戏中已经有了一些音频,让我们完善我们的游戏——这是下一章的主题。

第十章。润色我们的游戏

在上一章中,我们为游戏添加了声音和音乐。我们还学习了音频文件格式,甚至如何生成我们自己的音效。

在本章中,我们将润色我们的游戏。在本章中,我们将涵盖以下主题:

  • 改进游戏结束机制

  • 添加一个简约的教程

  • 加载和保存当前游戏状态

磨光是对游戏进行最后润色处理的过程。在软件开发中有一句话,即开发的最后 20%感觉和最初的 80%一样困难。有了这样的动力,让我们来润色我们的游戏,怎么样?

添加额外的场景

我们的游戏仍然感觉边缘粗糙。我们的首要任务是添加更多场景,这应该使游戏感觉更加完整,尤其是在开始游戏和游戏结束时。

游戏结束场景

目前,游戏结束机制有点过于简约。虽然玩家可以输赢游戏,但一旦游戏结束,他们就不能重新开始游戏。玩家需要关闭应用程序并重新打开。

这与直觉相反,因为 iOS 应用的默认行为是冻结应用而不是关闭它。所以,在最坏的情况下,带有游戏结束信息的我们的游戏将停留在内存中,直到设备重启或用户从应用切换器中杀死应用程序。

创建游戏结束场景

作为我们的第一个任务,我们将解耦游戏结束逻辑并将其移动到单独的场景中。我们的游戏结束场景应该显示游戏是赢了还是输了。

行动时间 – 显示游戏结束场景

使用以下步骤创建游戏结束场景:

  1. 如果还没有打开,请打开我们的 Xcode 项目。

  2. GameScenes组内创建一个新的 Objective-C 类。

  3. 将这个类命名为GameOver,它应该是Scene的子类。

  4. 切换到GameOver.h文件。

  5. 使用以下代码行,添加一个名为message的属性:

    @property SPTextField *message;
    
  6. 使用以下代码行,添加另一个属性以指示游戏是否赢了:

    @property (nonatomic) BOOL gameWon;
    
  7. 切换到GameOver.m

  8. 按照以下代码导入SceneDirector.hAssets.hWorld.h文件:

    #import "SceneDirector.h"
    #import "Assets.h"
    #import "World.h"
    
  9. 为这个新场景添加一个初始化器,如下面的代码所示:

    -(id) init
    {
        if ((self = [super init])) {
    
            SPImage *background = [SPImage imageWithTexture:[Assets texture:@"water.png"]];
    
            _message = [SPTextField textFieldWithWidth:Sparrow.stage.width height:Sparrow.stage.height text:@"Game Over" 
                  fontName:@"PirateFont" fontSize:24.0f color:SP_WHITE];
    
            SPTexture *yesButton = [[Assets textureAtlas:@"ui.xml"] textureByName:@"dialog_yes"];
    SPButton *resetButton = [SPButton buttonWithUpState:yesButton text:@"Start over"];
    
            resetButton.x = (Sparrow.stage.width - resetButton.width) / 2;
            resetButton.y = Sparrow.stage.height - resetButton.height - 8.0f;
    
            [resetButton addEventListenerForType:SP_EVENT_TYPE_TRIGGERED block:^(id event) {
                [World reset];
                [(SceneDirector *) self.director showScene:@"piratecove"];
            }];
    
            [self addChild:background];
            [self addChild:_message];
            [self addChild:resetButton];
        }
    
        return self;
    }
    
  10. 添加gameWon属性的 getter,如下面的代码所示:

    -(BOOL) getGameWon
    {
        return _gameWon;
    }
    
  11. 现在,添加gameWon属性的 setter,如下面的代码所示:

    -(void) setGameWon:(BOOL)gameWon
    {
        _gameWon = gameWon;
    
        if (_gameWon) {
            _message.text = @"You won the game. Congratulations.";
        } else {
            _message.text = @"Your ship sank. Try again.";
        }
    }
    
  12. 切换到Game.m

  13. 使用以下代码行导入GameOver.h文件:

    #import "GameOver.h"
    
  14. 然后,使用以下代码创建GameOver场景的一个实例:

    GameOver *gameOver = [[GameOver alloc] initWithName:@"gameover"];
    
  15. 使用以下代码将游戏结束实例添加到场景导演中:

    [director addScene:gameOver];
    
  16. 使用以下代码行默认显示游戏结束场景:

    [director showScene:@"gameover"];
    
  17. 运行示例,我们将看到游戏结束场景,如下面的截图所示:行动时间 – 显示游戏结束场景

发生了什么?

正如我们之前所做的那样,我们打开了我们的 Xcode 项目。然后,我们创建了一个类,这个类将成为我们的游戏结束场景。它被命名为GameOver,是Scene的子类。

GameOver头文件中,我们在第 5 步和第 6 步分别添加了两个属性。第一个属性是将在屏幕上显示的消息。第二个是用来指示游戏是否获胜。稍后我们为这个属性添加了自定义的 getter 和 setter。我们将这个属性标记为非原子性,因为我们实际上不需要线程安全,而且我们只使用了一个线程。

GameOver.m文件中,我们导入了所有必要的头文件,如下所示:

  • Assets.h中的资源管理器,因为我们很可能要加载一个资源

  • SceneDirector.h中的场景导演,因为我们需要切换到另一个场景

  • World.h中的World类,因为我们需要重置游戏中的值

然后,我们添加了初始化器。我们的游戏结束场景由以下内容组成:

  • 以水作为背景

  • 作为message属性的文本字段

  • 重置按钮

在这个例子中,我们使用了SPTextField工厂方法(也称为便利构造函数),它允许我们在一步中定义宽度、高度、文本、字体名称、字体大小和颜色。我们需要考虑的一件事是保持字体大小与原始位图字体大小相似。如果它比原始大小大得多,字体就会变得像素化并且模糊不清。

尽管如此,有一种方法可以绕过这个问题:如果我们将SP_NATIVE_FONT_SIZE设置为字体实例的字体大小,它将自动计算其实际大小,以便尽可能清晰地显示。

我们将重置按钮的触摸事件定义为块,并重置所有游戏中的值,并切换到海盗湾场景。之后,我们将所有显示对象添加到显示树中。

然后,我们为我们的gameWon属性定义了自定义的 getter 和 setter:

  • Getter:这仅仅返回内部的_gameWon

  • Setter:在设置属性值后,我们根据其值更新了消息

Game类中,我们需要创建GameOver场景的实例,然后将其添加到场景导演中。在第 16 步中,我们将默认场景更新为游戏结束场景。

当我们运行上一步的示例时,我们看到了游戏结束场景。

连接游戏结束场景

现在我们有了游戏结束场景,让我们将其集成到游戏中。

是时候让游戏结束场景出现了

要将游戏结束场景纳入游戏,请按照以下步骤操作:

  1. 切换到Battlefield.h文件。

  2. 删除textGameWontextGameLost这两个属性。

  3. 切换到Battlefield.m文件。

  4. 删除对textGameWontextGameLost属性的任何引用。

  5. GameOver.m文件中,添加一个reset方法,如下所示:

    -(void) reset
    {
        self.gameWon = NO;
    }
    
  6. SceneDirector.h文件中,使用以下代码添加一个名为currentScene的属性:

    @property (readonly) Scene *currentScene;
    
  7. SceneDirector.m 文件中,更新 showScene 方法以设置 currentScene 属性,如下面的代码所示:

    -(void) showScene:(NSString *)name
    {
        for (NSString* sceneName in _dict) {
            ((Scene *) _dict[sceneName]).visible = false;
            [((Scene *) _dict[sceneName]) stop];
        }
    
        if (_dict[name] != nil) {
            ((Scene *) _dict[name]).visible = YES;
            [((Scene *) _dict[name]) reset];
            _currentScene = (Scene *) _dict[name];
        }
    }
    
  8. 切换到 Battlefield.m 文件。

  9. 更新 reset 方法以设置船只的可见性,如下面的代码所示:

    -(void) reset
    {
        self.paused = NO;
    
        _pirateShip.x = [(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"battlefield"][@"pirate"][@"x"] floatValue];
        _pirateShip.y = [(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"battlefield"][@"pirate"][@"y"] floatValue];
    
        [_pirateShip reset];
     _pirateShip.visible = YES;
    
        for (int i = 0; i < [_enemyShip count]; i++) {
             ((Ship *) _enemyShip[i]).x = [(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"battlefield"][@"enemy"][i][@"x"] floatValue];
            ((Ship *) _enemyShip[i]).y = [(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"battlefield"][@"enemy"][i][@"y"] floatValue];
            [((Ship *) _enemyShip[i]) reset];
            ((Ship *) _enemyShip[i]).visible = NO;
        }
    
        for (int i = 0; i < World.level; i++) {
            ((Ship *) _enemyShip[i]).visible = YES;
            [self updateAI:_enemyShip[i] withState:_aiState];
        }
    }
    
  10. 更新胜利游戏的条件,如下面的代码所示:

    if (deadCount == World.level) {
      if (World.level == World.levelMax) {
     [(SceneDirector *) self.director showScene:@"gameover"];
     ((GameOver *) ((SceneDirector *) self.director).currentScene).gameWon = YES;
      } else {
        World.gold = World.gold + (250 * World.level);
        World.level++;
        self.paused = YES;
        [((SceneDirector *) self.director) showScene:@"piratecove"];
      }
    }
    
  11. 接下来,更新游戏失败的条件,如下面的代码所示:

    __weak typeof(self) weakSelf = self;
    _pirateShip.onDead = ^{
     [(SceneDirector *) weakSelf.director showScene:@"gameover"];
     ((GameOver *) ((SceneDirector *) weakSelf.director).currentScene).gameWon = NO;
    };
    
  12. Game.m 中,将默认场景切换回海盗湾。

  13. 运行示例。当我们运行示例并实际输掉游戏时,我们会看到以下屏幕:行动时间 – 游戏结束场景出现

发生了什么?

Battlefield 头文件中,我们移除了显示游戏胜利或失败时出现的文本字段属性。然后,我们在 Battlefield.m 中移除了所有引用这些属性的代码部分。

在第 5 步中,我们为 GameOver 场景添加了一个 reset 方法,其中我们将 gameWon 属性设置为 NO。与场景切换的区别在于,我们需要在场景切换后设置 gameWon 属性。为了方便这一操作,我们更新了场景导演。

在下一步中,我们添加了一个只读属性 currentScene,它为我们提供了对当前场景的引用。在此之后,我们更新了 showScene 方法以设置当前场景。这发生在我们将当前场景设置为可见并调用 reset 方法之后。

在战场场景中,我们首先更新了我们的船只的可见性。如果我们没有这样做,敌军船只即使在重置游戏后仍然可见。

在第 10 步和第 11 步中,我们更新了胜利和失败的条件。在这里,我们也导入了 GameOver.h 文件,以便将 currentScene 属性转换为指向 GameOver 类的指针。

我们最后做的事情是将场景切换回海盗湾。当我们运行示例,无论我们输赢游戏,都会显示游戏结束场景,并且我们可以重新开始游戏。

添加主菜单

接下来,我们将添加一个简单的主菜单。

行动时间 – 将主菜单集成到我们的游戏中

使用以下步骤添加主菜单:

  1. 添加一个名为 MainMenu 的新类,它应该是 Scene 的子类。

  2. 切换到 MainMenu.m

  3. 导入 Assets.hSceneDirector.h

  4. 添加主菜单的初始化器,如下面的代码所示:

    -(id) init
    {
        if ((self = [super init])) {
    
            SPImage *background = [SPImage imageWithTexture:[Assets texture:@"water.png"]];
    
        SPTexture *shipTexture = [[Assets textureAtlas:@"ship_pirate_small_cannon.xml"] textureByName:@"ne_0001"];
            SPImage *ship = [SPImage imageWithTexture:shipTexture];
            ship.x = 16.0f;
            ship.y = (Sparrow.stage.height - ship.height) / 2;
    
        SPTexture *dialogTexture = [[Assets textureAtlas:@"ui.xml"] textureByName:@"dialog_yes"];
            SPButton *buttonNewGame = [SPButton buttonWithUpState:dialogTexture text:@"New game"];
    
            buttonNewGame.x = (Sparrow.stage.width - buttonNewGame.width) / 2;
            buttonNewGame.y = 50.0f;
    
            [buttonNewGame addEventListenerForType:SP_EVENT_TYPE_TRIGGERED block:^(id event) {
                [(SceneDirector *) self.director showScene:@"piratecove"];
            }];
    
            SPButton *buttonContinue = [SPButton buttonWithUpState:dialogTexture text:@"Continue"];
    
            buttonContinue.x = (Sparrow.stage.width - buttonContinue.width) / 2;
            buttonContinue.y = 150.0f;
            buttonContinue.enabled = NO;
    
            [self addChild:background];
            [self addChild:ship];
            [self addChild:buttonNewGame];
            [self addChild:buttonContinue];
        }
    
        return self;
    }
    
  5. 切换到 Game.m

  6. 使用以下代码导入 MainMenu.h

    #import "MainMenu.h"
    
  7. 使用以下代码为 MainMenu 类创建一个局部变量,该变量将保存一个 MainMenu 类的实例。

    MainMenu *mainMenu = [[MainMenu alloc] initWithName:@"mainmenu"];
    
  8. mainMenu 实例添加到导演中,如下面的代码所示:

    [director addScene:mainMenu];
    
  9. 更新 showScene 调用以显示主菜单场景,如下面的代码所示:

    [director showScene:@"mainmenu"];
    
  10. 运行示例,我们将看到主菜单,如下面的截图所示:行动时间 – 将主菜单集成到我们的游戏中

发生了什么?

要添加主菜单,我们需要一个从 Scene 继承的类。一旦创建了类,我们就导入了资产管理系统和场景导演。

在步骤 3 中,我们添加了初始化器。我们的主菜单包括以下内容:

  • 与战场和其他场景中使用的相同背景

  • 一艘海盗船

  • 一个启动新游戏的按钮

  • 一个继续游戏的按钮

对于新游戏,我们使用了一个用于触摸事件的块,它切换到海盗湾场景。继续按钮还没有事件并且被禁用。在此之后,我们需要将所有元素添加到显示树中。

在步骤 5 到 9 中,我们以类似于添加游戏结束场景的方式将主菜单添加到我们的游戏类中。

当我们运行示例时,我们看到了主菜单。

来试试英雄

现在主菜单只有两个按钮。通常,主菜单会提供更多选项,例如切换到选项菜单或信用屏幕的按钮。在某些情况下,主菜单甚至有按钮可以导航到社交网站。现在添加选项和信用屏幕,这些可以从主菜单打开。

添加开场场景

一个开场场景是向玩家介绍游戏角色、故事或艺术风格的完美方式。并非所有游戏都需要开场场景;实际上,如果它适合整体游戏和游戏风格,那么使用它是最合适的。

由于我们没有故事或角色,我们将展示两艘船相互靠近、相互射击,最终其中一艘船沉没。

是时候为我们的游戏创建一个开场了

使用以下步骤添加开场场景:

  1. 现在是时候将碰撞检测代码移动到单独的文件中了。创建一个名为 Logic 的新组,并在该组内添加一个名为 Collision 的类,它是 NSObject 的子类。

  2. Collision 类中声明此静态方法,如下面的代码所示:

    +(void) checkShipCollision: (Ship *) ship1 againstShip: (Ship *) ship2 withReferenceToSprite: (SPSprite *) sprite;
    
  3. Collision.m 文件中,使用以下代码实现 checkShipCollision 方法:

    SPRectangle *enemyShipBounds = [ship1 boundsInSpace:sprite];
    SPRectangle *ball1 = [ship2.cannonBallLeft boundsInSpace:sprite];
    SPRectangle *ball2 = [ship2.cannonBallRight boundsInSpace:sprite];
    
    if ([enemyShipBounds intersectsRectangle:ball1] || [enemyShipBounds intersectsRectangle:ball2]) {
      if (ship2.cannonBallLeft.visible || ship2.cannonBallRight.visible) {
        [ship2 abortShooting];
        if (ship1.type == ShipPirate) {
          [ship1 hit: World.damage];
        } else {
          [ship1 hit:[(NSNumber *) [Assets dictionaryFromJSON:@"gameplay.json"][@"damage"] intValue]];
        }
      }
    }
    
  4. 为了使此代码工作,我们需要在 Collision.m 文件中导入 Assets.hWorld.h

  5. Battlefield.m 中,删除碰撞代码,导入 Collision.h,并使用 Collision 类的新方法:

    for (int i = 0; i < World.level; i++) {
     [Collision checkShipCollision:_pirateShip againstShip:_enemyShip[i] withReferenceToSprite:self];
     [Collision checkShipCollision:_enemyShip[i] againstShip:_pirateShip withReferenceToSprite:self];
    
      [_enemyShip[i] advanceTime:passedTime];
      if (((Ship *) _enemyShip[i]).isDead) {
        deadCount++;
      }
    }
    
  6. 通过继承 Scene 并命名为 Intro 来添加开场场景。这应该在 GameScenes 组内完成。

  7. Intro.h 中,导入 Ship.h 并添加两个实例变量,一个用于海盗船,一个用于敌船,如下面的代码所示:

    @interface Intro : Scene {
        Ship *_pirateShip;
        Ship *_enemyShip;
    }
    
  8. 切换到 Intro.m

  9. 使用以下代码为 Intro 类添加一个初始化器:

    -(id) init
    {
        if ((self = [super init])) {
    
            SPImage *background = [SPImage imageWithTexture:[Assets texture:@"water.png"]];
    
            _pirateShip = [[Ship alloc] initWithType:ShipPirate];
            _pirateShip.x = 16.0f;
            _pirateShip.y = ((Sparrow.stage.height - _pirateShip.height) / 2) - 20.0f;
    
            _enemyShip = [[Ship alloc] initWithType:ShipNormal];
            _enemyShip.x = Sparrow.stage.width - _enemyShip.width - 16.0f;
            _enemyShip.y = ((Sparrow.stage.height - _enemyShip.height) / 2) + 20.0f;
    
            [self addEventListener:@selector(onEnterFrame:) atObject:self forType:SP_EVENT_TYPE_ENTER_FRAME];
    
            SPButton *buttonNext = [SPButton buttonWithUpState:[[Assets textureAtlas:@"ui.xml"] textureByName:@"dialog_yes"] text:@"Next"];
    
            buttonNext.x = (Sparrow.stage.width - buttonNext.width) / 2;
            buttonNext.y = Sparrow.stage.height - buttonNext.height - 8.0f;
    
            [buttonNext addEventListenerForType:SP_EVENT_TYPE_TRIGGERED block:^(id event) {
                [(SceneDirector *) self.director showScene:@"piratecove"];
            }];
            [self addChild:background];
            [self addChild:_pirateShip];
            [self addChild:_enemyShip];
            [self addChild:buttonNext];
        }
    
        return self;
    }
    
  10. 添加 onEnterFrame 事件监听器,如下面的代码所示:

    -(void) onEnterFrame: (SPEnterFrameEvent *) event
    {
        double passedTime = event.passedTime;
    
        [Collision checkShipCollision:_pirateShip againstShip:_enemyShip withReferenceToSprite:self];
        [Collision checkShipCollision:_enemyShip againstShip:_pirateShip withReferenceToSprite:self];
    
        [_pirateShip advanceTime:passedTime];
        [_enemyShip advanceTime:passedTime];
    }
    
  11. 添加一个重置方法,如下面的代码所示:

    -(void) reset
    {
        [_pirateShip reset];
        [_enemyShip reset];
    
        [_pirateShip moveToX:Sparrow.stage.width / 2 andY:(Sparrow.stage.height / 2) - 20.0f withBlock:^{
            [_pirateShip.juggler delayInvocationByTime:1.5f block:^{
                [_pirateShip shootWithBlock:^{
                    [_pirateShip shootWithBlock:^{
                        [_pirateShip shootWithBlock:^{
                            [_pirateShip.juggler delayInvocationByTime:1.0f block:^{
                                [_pirateShip shoot];
                            }];
                        }];
                    }];
                }];
            }];
        }];
    
        [_enemyShip moveToX:Sparrow.stage.width / 2 andY:(Sparrow.stage.height / 2) + 20.0f withBlock:^{
            [_enemyShip shoot];
        }];
    }
    
  12. MainMenu.m 中,如果新游戏按钮被触摸,则显示开场场景。

  13. Game.m 中,导入 Intro.h,创建 Intro 类的实例,并将其添加到导演中。

  14. 运行示例。

    当我们开始新游戏时,我们看到简介正在运行,如下所示:

    行动时间 – 为我们的游戏创建一个简介

刚才发生了什么?

由于我们需要在简介和游戏本身中实现碰撞检测,我们将它移动到了自己的类中。当我们移动checkShipCollision方法时,我们添加了一个额外的参数。然后这个参数被作为引用传递给了boundsInSpace方法。为了使这段代码正常工作,我们导入了资产管理类和World类。

在下一步中,我们更新了战场场景中的碰撞。

我们随后添加了一个名为Intro的新场景,我们首先添加了两个实例变量,一个用于我们的船只,一个用于海盗船。在步骤 9 中,我们添加了初始化器,它执行以下操作:

  • 添加水背景

  • 初始化两个船只实例

  • 添加一个按钮来跳过简介

我们随后添加了一个事件监听器来跳过事件监听器并切换到海盗湾场景。我们还添加了一个事件监听器用于进入帧事件。然后我们将所有元素添加到显示树中

在步骤 10 中,我们实现了onEnterFrame事件监听器,它调用碰撞方法并推进两艘船的时间。

reset方法调用这些船只的reset方法,并将船只移动到屏幕中心。敌舰只能射击一次,而海盗船可以多次射击以击毁敌舰。

我们在主菜单中展示了简介场景。之后,我们将Intro类添加到游戏类中,当我们运行示例时,我们在开始新游戏时看到了简介场景。

实现教程机制

实现教程有许多不同的方法。它可能只是显示一个带有控制器的图像,到拥有一个交互式体验,再到每次玩家即将执行动作时显示控制方案。一般来说,后两种选项可以通过有限状态机实现,类似于我们用于人工智能的那个。

为了我们的目的,我们将更新简介场景,在动画播放时显示提示。

行动时间 – 将教程添加到我们的简介场景

按照以下步骤在简介期间显示提示:

  1. Intro.h中添加一个名为message的实例变量:

    SPTextField *_message;
    
  2. 切换到Intro.m

  3. 使用以下代码更新初始化器:

    [buttonNext addEventListenerForType:SP_EVENT_TYPE_TRIGGERED block:^(id event) {
      [(SceneDirector *) self.director showScene:@"piratecove"];
    }];
    
    SPQuad *quad = [SPQuad quadWithWidth:400.0f height:60.0f color:SP_BLACK];
    quad.alpha = 0.8f;
    quad.x = 16.0f;
    quad.y = 16.0f;
    
    _message = [SPTextField textFieldWithWidth:400.0f height:60.0f text:@"Welcome to the battlefield."];
    _message.color = SP_WHITE;
    _message.x = 16.0f;
    _message.y = 16.0f;
    
    [self addChild:background];
    [self addChild:_pirateShip];
    [self addChild:_enemyShip];
    [self addChild:buttonNext];
    [self addChild:quad];
    [self addChild:_message];
    
    
  4. 更新reset方法,如下所示:

    [_pirateShip moveToX:Sparrow.stage.width / 2 andY:(Sparrow.stage.height / 2) - 20.0f withBlock:^{
     _message.text = @"There is your ship (the pirate ship) and at least one enemy";
      [_pirateShip.juggler delayInvocationByTime:2.5f block:^{
        [_pirateShip shootWithBlock:^{
           _message.text = @"Tap anywhere to move your ship.";
          [_pirateShip shootWithBlock:^{
            [_pirateShip shootWithBlock:^{
              _message.text = @"Double-tap on your ship to shoot.";
              [_pirateShip.juggler delayInvocationByTime:2.5f block:^{
                _message.text = @"In-between missions you can upgrade your ship.";
                [_pirateShip shoot];
              }];
            }];
          }];
        }];
      }];
    }];
    
  5. 运行示例,当我们看到简介时,我们现在在屏幕上看到了提示信息:行动时间 – 将教程添加到我们的简介场景

刚才发生了什么?

我们首先添加了一个实例变量来显示我们的提示。然后我们更新了初始化器来初始化这个实例变量,并有一个黑色但略微不透明的背景。我们将这两个元素添加到显示树中。

在步骤 4 中,我们更新了reset方法,以更改消息文本,显示核心游戏元素的工作方式。

当我们运行示例时,在简介期间显示了提示。

加载和保存当前状态

到目前为止我们可以玩游戏,但一旦游戏结束,我们必须从头开始玩游戏。

是时候行动了——加载和保存最后玩的游戏

按照以下步骤加载和保存当前状态:

  1. World.h 中声明序列化和反序列化数据的方法:

    +(NSDictionary *) serialize;
    +(void) deserialize: (NSDictionary *) dict;
    
  2. 使用以下代码实现这些序列化器:

    +(NSDictionary *) serialize
    {
        return @{
                 @"level": [NSNumber numberWithInt:level],
                 @"gold": [NSNumber numberWithInt:gold],
                 @"damage": [NSNumber numberWithInt:damage],
                 @"hitpoints": [NSNumber numberWithInt:hitpoints]
        };
    }
    
    +(void) deserialize: (NSDictionary *) dict
    {
        level = [(NSNumber *) dict[@"level"] intValue];
        gold = [(NSNumber *) dict[@"gold"] intValue];
        damage = [(NSNumber *) dict[@"damage"] intValue];
        hitpoints = [(NSNumber *) dict[@"hitpoints"] intValue];
    }
    
  3. MainMenu.m 中,将 World.h 添加到导入部分并更新初始化器:

    buttonContinue.x = (Sparrow.stage.width - buttonContinue.width) / 2;
    buttonContinue.y = 150.0f;
    buttonContinue.enabled = NO;
    
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    id savedGame = [userDefaults objectForKey:@"game"];
    if (savedGame != nil) {
      [World deserialize:(NSDictionary *) [userDefaults objectForKey:@"game"]];
      buttonContinue.enabled = YES;
    }
    
    [buttonContinue addEventListenerForType:SP_EVENT_TYPE_TRIGGERED block:^(id event) {
      [(SceneDirector *) self.director showScene:@"piratecove"];
    }];
    
    [self addChild:background];
    
  4. AppDelegate.m 中,我们导入 World.h 并添加一个新方法,如下面的代码所示:

    - (void)applicationWillResignActiveNotification:(NSNotification*)notification
    {
        NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
        [userDefaults setObject:[World serialize] forKey:@"game"];
        [userDefaults synchronize];
    }
    
  5. 运行示例以查看结果。当我们开始游戏时,我们现在可以继续游戏:是时候行动了——加载和保存最后玩的游戏

刚才发生了什么?

首先,我们将序列化和反序列化方法添加到我们的 World 类中。序列化方法从 World 类中提取值并将它们放置在 NSDictionary 中。反序列化方法则相反。它从 NSDictionary 中提取值并更新 World 类中的值。

在主菜单场景中,我们检查是否已经保存了某些内容,并在有数据的情况下反序列化数据。我们为我们的 继续 按钮添加了一个事件监听器,它直接切换到海盗湾场景。

在步骤 4 中,一旦应用程序不再活跃,我们就保存了游戏数据。

当我们运行示例时,我们能够继续游戏。

快速问答

Q1. 当我们在 SPTextField 中覆盖位图字体的大小时,它会缩放到那个大小。

Q2. 什么情况下将代码片段封装到它们自己的类或方法中是一个好主意?

  1. 总是,即使只使用一次

  2. 如果代码片段被多次使用

  3. 从不

Q3. NSUserDefaults 提供了一种存储数据的方式。

摘要

在本章中,我们学习了如何润色我们的游戏。具体来说,我们涵盖了添加更多场景,如主菜单和简介,并简要介绍了教程机制。

现在我们几乎感觉我们的游戏就像一个真正的游戏,让我们看看我们如何集成第三方服务——这是下一章的主题。

第十一章。集成第三方服务

在前一章中,我们通过添加额外的场景和消除一些小瑕疵来打磨了我们的游戏。现在游戏结束后,它可以重新启动。既然我们的游戏已经基本完成,我们就需要做一些收尾工作,这些工作不会直接影响游戏,但会影响游戏的分发和玩家的体验。如果用户想要和朋友一起玩,我们就不需要自己实现服务器和网络机制。有一些服务可以处理这些问题;其中之一就是苹果游戏中心。

在本章中,我们将集成第三方服务到我们的游戏中。以下是本章我们将涵盖的主题:

  • 向潜在测试者传达信息

  • 基本游戏中心集成

  • 展示用于分析的不同平台

向潜在测试者传达信息

向潜在测试者分发可能是一项艰巨的任务。首先,我们需要获取每个测试设备的唯一设备标识符UDID)。然后,我们需要编译一个特殊的构建版本,该版本仅限于在我们在构建中使用的配置文件中提供的 UDID 所对应的设备上运行。然后,我们需要将这些特殊构建版本发送给这些测试者,他们需要使用 iTunes 进行安装。在测试者安装了应用程序后,我们无法了解他们实际使用了多长时间,如果应用程序崩溃,他们需要将设备与 iTunes 同步,并在硬盘上搜索崩溃报告并发送给开发者。应用程序在测试者手中崩溃比在实际客户手中崩溃要好得多。

很长一段时间以来,TestFlight 为收集 UDID 和在测试者的设备上安装应用程序提供了一个简单的解决方案。TestFlight 为移动设备提供了一个应用程序,可以直接在设备上安装应用程序,而不是用户手动安装。TestFlight 还有一个网络应用程序,用于管理所有设备,收集崩溃报告,并跟踪会话。

TestFlight 背后的公司于 2014 年 2 月被苹果公司收购,结果是他们的 SDK 不再允许集成到应用程序中。TestFlight 的分发组件目前仍然可用。

Ubertesters 是一个非常类似的服务,它帮助我们收集设备的 UDID,并帮助测试者使用我们的应用程序。在撰写本书时,Ubertesters 仍然处于测试阶段。虽然 Ubertesters 是一项付费服务,但他们确实提供免费计划,只需在他们的网站上注册一个账户即可。以下是从 Ubertesters 网站截取的屏幕截图:

向潜在测试者传达信息

在 Ubertesters 上注册

为了使用 Ubertesters 分发我们的游戏,我们首先需要在 beta.ubertesters.com/sign_up 注册一个账户。在输入字段中输入所有必要的数据。

然后,我们需要创建我们自己的组织,在那里我们可以添加我们的第一个应用程序。当我们第一次注册时,Ubertesters 会引导我们完成此过程。

让我们的应用程序称为 "Pirate Game",并选择 iOS 作为其平台。现在,我们将添加我们自己的设备;这就像在我们的移动 Safari 浏览器中打开 URL beta.ubertesters.com 并遵循屏幕上的说明一样简单。这将在我们的设备上安装 Ubertesters 应用程序并收集设备的 UDID。

然后,我们在 Ubertesters 网页界面上看到我们刚刚注册的设备,在那里我们可以获取设备的相关数据,如设备名称、型号、操作系统、屏幕分辨率、地区和 UDID。

还可以设置空中分发,这意味着允许测试者从您的网站下载构建版本。有关此设置的说明,请参阅以下链接:aaronparecki.com/articles/2011/01/21/1/how-to-distribute-your-ios-apps-over-the-air

集成 Ubertesters

在我们能够获取一些测试者之前,我们需要将 Ubertesters SDK 集成到我们的游戏中。只有集成了 SDK 的包才能提供给测试者。

行动时间 - 集成 Ubertesters

使用以下步骤来集成 Ubertesters:

  1. 如果 Xcode 项目尚未打开,请先打开它。

  2. ubertesters.com/sdk/ubertesters.sdk.ios.zip 下载 Ubertesters SDK。

  3. 将下载的文件内容提取到硬盘上的某个位置。

  4. 将提取的内容拖放到项目文件中。它们应该在根级别,与 产品框架资源 处在同一级别。

  5. 通过点击项目导航器中的项目名称切换到项目配置。在 通用 选项卡中,向下滚动到 链接框架和库

  6. 通过点击加号按钮,选择正确的库,然后点击 添加 来添加以下库:

    • AdSupport.framework

    • CoreImage.framework

    • SystemConfiguration.framework

    • CoreTelephony.framework

    • CoreLocation.framework

    • CoreMotion.framework

  7. 在项目配置中切换到 信息 选项卡。

  8. 自定义 iOS 目标属性 中添加一个新键,通过选择任何项并点击加号按钮。

  9. 将此键称为 ubertesters_project_id

  10. 作为其值,使用 Ubertesters 网站上的 ID,该 ID 位于 SDK 集成 选项卡中的应用程序中。

  11. 切换到 AppDelegate.m

  12. 使用以下代码行导入 Ubertesters 头文件:

    #import <UbertestersSDK/Ubertesters.h>
    
  13. 通过更新以下代码中的 didFinishLaunchingWithOptions 方法来初始化 Ubertesters SDK:

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
    {
        CGRect screenBounds = [UIScreen mainScreen].bounds;
        _window = [[UIWindow alloc] initWithFrame:screenBounds];
    
        _viewController = [[SPViewController alloc] init];
    
        // Enable some common settings here:
        //
        // _viewController.showStats = YES;
        // _viewController.multitouchEnabled = YES;
        // _viewController.preferredFramesPerSecond = 60;
    
        [Ubertesters initialize];
    
        [_viewController startWithRoot:[Game class] supportHighResolutions:YES doubleOnPad:YES];
    
        [_window setRootViewController:_viewController];
        [_window makeKeyAndVisible];
    
        return YES;
    }
    
  14. 运行示例。

    当我们启动示例时,我们没有看到任何可见的变化(参见图表),这是表明一切按预期工作的指标:

    行动时间 – 集成 Ubertesters

发生了什么?

要开始,我们打开了我们的 Xcode 项目,这是我们步骤 1 中所做的事情。

然后,我们下载了 Ubertesters SDK 并解压了下载文件的 contents。在步骤 4 中,我们将解压文件的内容拖动到项目本身中。它应该位于项目文件正下方的根级别,与 SparrowClassesOther SourcesResourcesFrameworksProducts 在同一级别。

为了使 Ubertesters SDK 正常工作,我们需要链接到各种框架。在步骤 5 中,我们通过点击 General 选项卡切换到一般配置,在那里我们可以找到页面底部的链接框架和库。我们添加了步骤 6 中描述的所有框架。

然后,我们在应用程序本身的 PLIST 文件中添加了一个条目。这可以在 Info 选项卡中完成。我们选择了 Custom iOS Target Properties 下的任何条目,并点击了它旁边的加号按钮。然后,我们为这个属性设置了一个键名,我们将其命名为 ubertesters_project_id。我们已经有项目 ID 了;我们在注册应用程序到 Ubertesters 时获得了它。在 Ubertesters 的网页界面中,可以通过点击 Projects,然后点击我们的项目(Pirate Game),之后点击左侧的 SDK Integration 选项卡来检索此 ID。这些步骤是必要的,以便 Ubertesters 知道我们在网页界面中注册的应用程序属于我们的游戏。

要使用 SDK,我们导入了 Ubertesters SDK 头文件并初始化了 SDK 本身。所有这些都在步骤 11 到 13 中的 AppDelegate 文件内完成。我们将 SDK 的初始化添加到 didFinishLaunchingWithOptions 中应用程序启动的地方。

当我们运行示例时,没有看到任何可见的变化,游戏按预期运行。在实际设备上运行游戏时,可能需要更长的时间来加载,因为 Ubertesters SDK 在启动时检查互联网连接。对于生产构建(例如,App Store 发布),不建议包含 SDK;SDK 应该包含为特定测试人员构建的版本。

为测试人员创建构建版本

现在我们已经将 Ubertesters SDK 集成到我们的游戏中,我们可以为我们的测试人员创建一个特殊的构建版本,目前这仅仅是我们自己,或者更具体地说,是我们的设备。

行动时间 – 为测试人员创建构建版本

使用以下步骤为测试人员创建一个特殊的构建版本:

  1. 登录到配置门户developer.apple.com/account/ios/certificate/certificateList.action

  2. 标识符中,点击App IDs。通过点击加号按钮添加一个新的。

  3. App ID 描述中选择PirateGame

  4. 设置一个你选择的App ID 前缀。所有可用的前缀将以下拉列表的形式显示,其中默认为Team ID。如果你不确定使用哪个,请使用默认值。

  5. Bundle ID输入框中,输入我们游戏的 bundle ID。如果你还没有选择,现在是时候做了。确保 bundle ID 与我们的应用程序的App-Info.plist文件中的Bundle identifier相同。建议使用反向域名。点击继续然后点击完成以完成过程。

  6. 通过点击设备选项卡中的所有显示所有设备。

  7. 点击加号按钮添加一个新设备。

  8. UDID中,输入我们设备的 UDID。这可以从 Ubertesters 网络应用程序中获取。

  9. 配置文件中,选择分发并点击带有加号图标的按钮添加一个新的配置文件。

  10. 选择Ad Hoc作为分发类型并点击继续

  11. 选择步骤 5 中创建的App ID并点击继续

  12. 如果没有可用的证书,你将在下一步被要求创建一个新的证书并选择App Store 和 Ad Hoc。按照说明生成证书。如果已经有可用的证书,你将被要求选择其中一个。

  13. 打开新创建的证书并选择我们刚刚添加的设备。

  14. 生成更新的证书。

  15. 在 Xcode 项目中,选择iOS 设备作为目标。这可以通过构建菜单完成,其中显示产品和目标。

  16. 在菜单中,选择产品并点击归档

  17. 通过点击窗口并选择组织者打开 Xcode 组织者。

  18. 选择最新的构建并点击分发

  19. 在弹出的窗口中,选择保存为企业或 Ad Hoc 部署。通过点击下一步进行确认。

  20. 选择我们之前创建的配置文件。

  21. 点击导出并将包保存到硬盘上的某个位置。

在我们创建了构建后,我们返回到 Xcode 组织者,我们可以再次分发构建或验证它,如下面的截图所示:

为 beta 测试者创建构建的行动时间

刚才发生了什么?

为了为测试者创建一个特殊的构建,我们为我们的游戏创建了一个仅针对我们的游戏的 ad-hoc 分发配置文件。这是我们每个应用程序只需做一次的事情,而不是每个构建。

要创建证书,我们需要从苹果开发者会员中心的配置门户。它包括以下三个任务:

  • 创建 App ID:这标识了我们的游戏(步骤 2 到 5)

  • 添加新设备:分发证书可以包含多个目标设备,在创建分发证书之前需要添加(步骤 6 到 9)

  • 创建分发证书:用于签名特殊构建(步骤 10 到 14)

现在证书已生成,我们使用这个证书创建了构建。我们需要选择iOS 设备(或连接的 iOS 设备名称,如果有的话)。我们选择了一个产品来创建存档,这是在第 16 步完成的。存档创建后,我们可以从 Xcode 组织者中选择它。我们想要分发这个构建,所以我们在第 18 步点击了带有分发标签的按钮。

由于我们想要进行临时部署,我们选择了这个选项。临时部署意味着我们将应用程序分发给已知数量的设备,而分发构建(如苹果应用商店)意味着应用程序可以被安装在任何从苹果应用商店获取应用程序的设备上。当我们有选择配置文件选项时,我们选择了我们之前创建的那个。点击导出,我们得到了一个 IPA 文件,我们暂时将其放在了安全的地方。

部署应用程序

我们的特殊构建现在已完成,因此我们可以继续使用 Ubertesters 网页界面部署我们的游戏。

行动时间 – 部署应用程序

要部署应用程序,请执行以下步骤:

  1. 登录到 Ubertesters,网址为 beta.ubertesters.com/sign_in

  2. 点击顶部菜单中的项目

  3. 点击上传修订并选择我们之前创建的特殊构建。

  4. 我们现在可以输入修订标题和描述。

  5. 选择我们刚刚上传的修订。

  6. 点击开始以允许此修订安装到目标设备上。

  7. 在我们注册的设备上,我们现在可以安装我们的游戏。

    在网页界面上,我们可以跟踪我们应用程序的安装情况,如下面的截图所示:

    行动时间 – 部署应用程序

发生了什么?

要使用 Ubertesters 部署应用程序,我们首先登录到 Ubertesters。当我们看到所有可用的项目时,我们可以为每个项目上传一个新的修订。目前,我们只有一个项目。

修订上传后,我们可以选择性地为构建设置标题和描述。上传构建时需要考虑以下两点:

  • Ubertesters SDK 需要集成到应用程序中。

  • 每个上传的修订的包版本必须不同。此设置可以在Info.plist文件中找到。

在应用程序可以安装到目标设备之前,我们需要启动这个过程,我们使用开始按钮来启动。测试阶段可以停止和重新开始每个修订。

当我们在注册的设备上打开 Ubertesters 应用时,我们现在可以下载我们的游戏。当我们的游戏安装后,我们可以像预期的那样运行游戏。

解释游戏中心

游戏中心是苹果公司的一个社交媒体功能,它允许排行榜、成就和匹配。从某种意义上说,它与桌面平台的 Steam 非常相似。自然,游戏中心仅在 iOS 设备上工作。

集成游戏中心认证

我们需要做的第一件事是认证游戏中心,以便能够使用其功能。

行动时间 - 集成游戏中心认证

使用以下步骤来集成游戏中心认证:

  1. 如果 Xcode 项目尚未打开,请打开我们的 Xcode 项目。

  2. GameKit.framework添加到要链接的框架列表中。

  3. 切换到AppDelegate.m

  4. 使用以下代码行导入GameKit头文件:

    #import <GameKit/GameKit.h>
    
  5. didFinishLaunchingWithOptions方法更新为以下代码片段:

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
    {
        CGRect screenBounds = [UIScreen mainScreen].bounds;
        _window = [[UIWindow alloc] initWithFrame:screenBounds];
    
        _viewController = [[SPViewController alloc] init];
    
        [Ubertesters initialize];
    
        [_viewController startWithRoot:[Game class] supportHighResolutions:YES doubleOnPad:YES];
    
     [GKLocalPlayer localPlayer].authenticateHandler = ^(UIViewController *viewController, NSError *error) {
     if ([GKLocalPlayer localPlayer].authenticated) {
     NSLog(@"Already authenticated");
     } else if(viewController) {
     [[Sparrow currentController] presentViewController:viewController animated:YES completion:nil];//present the login form
     } else {
     NSLog(@"Problem while authenticating");
     } 
     };
    
        [_window setRootViewController:_viewController];
        [_window makeKeyAndVisible];
    
        return YES;
    }
    
  6. 运行示例。如果我们尚未认证,我们应该会得到一个登录游戏中心的对话框:行动时间 - 集成游戏中心认证

发生了什么?

要集成游戏中心,我们链接了 GameKit 框架。

我们接下来做的事情是更新AppDelegate类,并且再次是处理应用程序启动后所有事情的方法。在第 4 步中,我们需要导入GameKit头文件。

在下一步中,我们在使用Game类启动视图控制器后立即认证了游戏中心。localPlayer返回与设备交互的当前玩家。

我们添加了一个认证处理程序,该处理程序在游戏中心认证后会被调用。如果玩家已经认证,我们只需登录即可。如果认证失败时发生错误,也会发生相同的情况。

如果玩家未认证,我们通过 Sparrow 当前视图控制器显示游戏中心视图控制器。

游戏中心通过 iTunes Connect 处理。工作流程在developer.apple.com/library/ios/documentation/LanguagesUtilities/Conceptual/iTunesConnectGameCenter_Guide/Introduction/Introduction.html中描述。

如果我们想在游戏中使用成就,我们需要在以下截图所示的 iTunes Connect 窗口中添加我们所有的成就:

发生了什么?

我们需要一个类似于以下代码的代码片段来设置成就:

GKAchievement *achievement = [[GKAchievement alloc] initWithIdentifier: @"sankALotOfShips"];
if (achievement) {
   achievement.percentComplete = 100;
   [achievement reportAchievementWithCompletionHandler:^(NSError *error) {
      if (error != nil) {
        NSLog(@"Error in reporting achievements: %@", error);
      }
   }];
}

假设我们有一个名为sankALotOfShips的成就,正如其名所示,如果我们的船只沉没了很多船只,则应该显示。

我们检索了成就,如果成就存在,我们只需简单地将percentComplete属性调整为100,就可以将成就设置为完成状态。然后我们报告了更新的成就。如果出现错误,我们将错误记录到控制台。

想了解更多关于 Game Center 中成就的信息,请查看developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/GameKit_Guide/Achievements/Achievements.html

来吧,英雄

Game Center 有很多事情可以做。以下是一些可以做的建议:

分析服务的概述

一些基本的分析可能由一个 beta 分发服务(如我们案例中的 Ubertesters)提供。当你在苹果 App Store 中有付费应用时,通常需要详细的信息,例如有多少内购以及关于游戏会话的准确细节。

分析通常是一项付费服务;在大多数情况下,要么是免费版,要么是试用版。让我们看看两个分析服务。

Flurry 分析

Flurry 是一个存在了几年的服务,为多个平台提供支持。它提供以下功能:

  • 用户地理位置数据

  • 崩溃分析

  • 游戏会话统计

Flox

Flox 是由 Sparrow 框架背后的团队 Gamua 开发的一项服务。Flox 是一个相对较新的服务,可在gamua.com/flox/找到。

Flox

Flox 提供了以下功能:

  • 远程日志

  • 排行榜

  • 保存游戏

  • 会话和用户统计

Objective-C 头文件可在github.com/Gamua/Flox-ObjC找到。它甚至提供了 Game Center 集成。

让我们看看 Flox 集成看起来会是什么样子。在注册 Flox 服务之后的下一步是创建一个游戏。我们获得其游戏 ID 和游戏密钥。

在将 Flox SDK 集成到我们的游戏后,我们需要在应用程序代理(AppDelegate.m)中使用以下代码初始化 Flox:

[Flox startWithGameID:@"gameID" key:@"gameKey" version:@"1.0"];

在此之后,我们可以调度将在 Flox Web 界面中显示的事件:

[Flox logEvent:@"GameStarted"];

如果我们想通过 Flox 使用排行榜,我们需要使用 Web 界面创建排行榜本身。如果我们想从排行榜中加载所有分数,以下代码片段将为我们设置:

[Flox loadScoresFromLeaderboard:@"default" timeScope:FXTimeScopeAllTime onComplete:^(NSArray *scores, NSError *error)
{
     NSLog(@"So much scores. Got %d", (int)scores.count);
}];

要保存到排行榜,我们可以使用以下代码片段:

[Flox postScore:World.gold ofPlayer:@"playerName" toLeaderboard:@"default"];

突击测验

Q1. Ubertesters 是什么?

  1. 它允许为测试者分发私有应用程序

  2. 它是一个寻找测试者的平台

  3. 它是一个在线杂志,测试移动应用程序

Q2. 什么是 Game Center?

  1. 苹果为游戏提供的社交功能解决方案,如成就和高分排行榜

  2. 移动游戏出版商

  3. 在多个平台上运行的游戏

Q3. 分析平台通常提供什么?

  1. 用户隐私数据

  2. 匿名游戏会话

  3. 统计数据

摘要

在本章中,我们学习了如何将第三方服务集成到我们的游戏中,特别是为了分发我们的游戏并将其与苹果的 Game Center 集成。

我们的游戏现在完成了。当然,我们还可以添加或更新很多东西,但总的来说,我们在本书的学习过程中掌握了创建可玩游戏的整个过程,同时了解了 Sparrow 框架以及分发我们的应用程序和创建游戏资源。

附录 A. 快速测验答案

第一章,Sparrow 入门

快速测验

Q1 1
Q2 3
Q3 1

第二章,显示我们的第一个对象

快速测验

Q1 3
Q2 2
Q3 2

第三章,管理资源和场景

快速测验

Q1 1
Q2 2
Q3 2

第四章,我们游戏的基础

快速测验

Q1 2
Q2 1 和 3
Q3 1
Q4 3

第五章,美化我们的游戏

快速测验

Q1 1
Q2 2
Q3 1

第六章,添加游戏逻辑

快速测验

Q1 3
Q2 2
Q3 2

第七章,用户界面

快速测验

Q1 1
Q2 1
Q3 3

第八章,人工智能与游戏进程

快速测验

Q1 1
Q2 2
Q3 2

第九章,为我们的游戏添加音频

快速测验

Q1 1
Q2 2
Q3 1

第十章,完善我们的游戏

快速测验

Q1 1
Q2 2
Q3 1

第十一章,集成第三方服务

快速测验

Q1 1
Q2 1
Q3 2

第十二章:后记

如果你打算在苹果应用商店发布游戏,你应该额外投入精力确保所有细节都准确无误。这包括为所有设备创建图标和启动画面,正确获取包标识符、包版本和包名称。如果你的游戏允许从横屏切换到竖屏或反之,你应该确保它这样做时不会产生任何副作用。你还应该再次确认所有图形是否在所有目标设备上正确显示。

posted @ 2025-10-24 10:03  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报