Cocos2dx-示例第二版-全-

Cocos2dx 示例第二版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Cocos2d-x 结合了使用最受欢迎且经过测试验证的 2D 游戏框架之一的优点,以及 C++ 的强大功能和便携性。因此,你得到了最好的交易。不仅该框架易于使用且快速实现,它还允许你的代码针对多个系统。

本书将向你展示如何使用该框架快速实现你的想法,并让 Cocos2d-x 帮助你翻译所有那些 OpenGL 乱码,让你专注于有趣的部分:制作精灵跳跃并相互碰撞!

本书包含七个游戏示例,其中两个基于物理,使用 Box2D,一个使用 Lua 绑定和新的 Cocos Code IDE。在每个示例中,你将了解更多关于框架和可以快速添加粒子效果、动画、声音、UI 元素以及所有各种奇妙事物的神奇代码行。

不仅如此,你还将学习如何针对 iOS 和 Android 设备以及多种屏幕尺寸。

本书涵盖的内容

第一章,安装 Cocos2d-x,指导你下载和安装 Cocos2d-x 框架。它还检查了基本 Cocos2d-x 应用程序的内幕以及部署到 iOS 和 Android 设备。

第二章,你加 C++ 加 Cocos2d-x,解释了框架中的主要元素。它还涵盖了在 C++ 中开发时的语法差异,以及在使用 Cocos2d-x 开发时的内存管理差异。

第三章,你的第一场游戏 – 橄榄球,通过使用 Cocos2d-x 构建橄榄球游戏来启动我们的游戏开发教程。你将学习如何加载精灵的图像,显示文本,管理触摸,并添加声音到你的游戏。

第四章,与精灵一起玩 – 天空防御,展示了 Cocos2d-x 中动作的强大功能,并展示了如何使用它们构建整个游戏。它还介绍了精灵图集的概念以及构建针对不同屏幕分辨率的通用应用程序的步骤。

第五章,在线 – 火箭穿越,为我们游戏开发工具箱添加了两个新元素:如何绘制基本图形,如线条、曲线和圆形,以及如何使用粒子系统通过特殊效果来改善游戏的外观。

第六章,快速简单的精灵 – 维多利亚时代交通高峰,展示了您如何使用 Cocos2d-x 快速实现游戏想法,通过快速构建带有占位符精灵的游戏原型进行进一步的测试和开发。在本章使用的游戏示例中,您还将学习如何构建侧滚动平台游戏。

第七章,添加外观 – 维多利亚时代交通高峰,继续上一章的项目,为游戏添加最终细节,包括菜单和可玩教程。

第八章,物理模拟入门 – Box2D,介绍了流行的 Box2D API,指导您在开发台球游戏时使用 Box2D 的过程。您将学习如何创建实体以及如何管理它们之间的交互方式。

第九章,水平提升 – 因纽特人,教您如何加载游戏关卡的外部数据,如何将游戏相关数据本地存储以及如何使用多个场景结构化您的游戏。我们使用第二个 Box2D 游戏来说明这些主题,以及一些新概念,例如使用事件分发器来更好地结构化您的游戏。

第十章,Lua 入门!,将引导您使用 Lua 和新的 Cocos Code IDE 开发跨平台的三消游戏。您将看到 C++版本及其 Lua 绑定之间的调用是多么相似,以及如何在 Lua 中开发游戏是多么容易。

附录 A,使用 Cocos2d-x 进行矢量计算,更详细地介绍了第五章,在线 – 火箭穿越中使用的某些数学概念。

附录 B,快速问答答案,提供了某些章节中出现的快速问答的答案。

您需要为本书准备的材料

为了运行本书中开发的游戏,您将需要用于 iOS 设备的 Xcode,用于 Android 的 Eclipse,以及用于 Lua 游戏的 Cocos Code IDE。尽管教程在本书的每一章都描述了使用 Xcode 的开发过程,但您将看到如何将代码导入 Eclipse,并从那里进行开发和部署。

本书面向的对象

你对游戏充满热情。你可能已经使用过 Cocos2d(框架的 Objective-C 版本)并渴望学习其 C++版本。或者,你对一些其他基于 C 的语言有些了解,例如 Java、PHP 或 Objective-C,并想学习如何用 C++开发 2D 游戏。你可能已经是 C++开发者,并想知道关于 Cocos2d-x 的所有炒作是什么。如果你符合任何这些情况,欢迎加入我们!

部分

在这本书中,你会发现几个频繁出现的标题(行动时间、发生了什么?、快速问答和尝试一下英雄)。

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

行动时间 – 标题

  1. 动作 1

  2. 动作 2

  3. 动作 3

指示通常需要一些额外的解释以确保它们有意义,因此它们后面跟着以下部分:

发生了什么?

本节解释了你刚刚完成的任务或指示的工作原理。

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

快速问答 – 标题

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

尝试一下英雄 – 标题

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

规范

你还会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将以如下方式显示:“对于背景音乐音量,你必须使用setBackgroundMusicVolume。”

代码块将以如下方式设置:

CCScene* GameLayer::scene()
{
   // 'scene' is an autorelease object
   CCScene *scene = CCScene::create();

   GameLayer *layer = GameLayer::create();

   scene->addChild(layer);

   return scene;
}

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

//add score display
_player1ScoreLabel = CCLabelTTF::create("0", "Arial", 60);
_player1ScoreLabel->setRotation(90);
this->addChild(_player1ScoreLabel);

任何命令行输入或输出将以如下方式书写:

sudo ./install-templates-xcode.sh -u

新术语重要词汇将以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的词汇,将以如下方式显示:“在对话框中,在iOS菜单下选择cocos2d-x,并选择cocos2dx模板。”

注意

警告或重要注意事项将以如下方式显示。

小贴士

小贴士和技巧将以如下方式显示。

读者反馈

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

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

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

客户支持

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

下载示例代码

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

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/8852OS_GraphicsBundle.pdf下载此文件。

勘误

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

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分的勘误下。

侵权

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

请通过<copyright@packtpub.com>与我们联系,并提供疑似侵权材料的链接。

我们感谢您的帮助,以保护我们的作者和我们为您提供有价值内容的能力。

询问

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

第一章. 安装 Cocos2d-x

在本章中,我们将让你的机器开始运行,以便你能够充分利用本书中的示例。这包括有关下载框架和创建项目的信息,以及 Cocos2d-x 应用程序基本结构的概述。

我还会向你推荐一些额外的工具,你可以考虑获取以帮助你的开发过程,例如用于构建精灵表、粒子效果和位图字体的工具。尽管这些工具是可选的,而且你只需通过跟随本书中给出的示例就可以学习如何使用精灵表、粒子效果和位图字体,但你可能仍会考虑这些工具用于你的项目。

你在本章中将学习的内容如下:

  • 如何下载 Cocos2d-x

  • 如何运行你的第一个多平台应用程序

  • 基本项目的外观以及如何熟悉它

  • 如何使用测试项目作为主要参考来源

下载和安装 Cocos2d-x

本书中的所有示例都是在 Mac 上使用 Xcode 和/或 Eclipse 开发的。最后一章的示例使用 Cocos2d-x 自带的 IDE 进行脚本编写。虽然你可以使用 Cocos2d-x 在其他平台上使用不同的系统开发你的游戏,但示例是在 Mac 上构建的,并部署到 iOS 和 Android。

Xcode 是免费的,可以从 Mac App Store (developer.apple.com/xcode/index.php) 下载,但为了在 iOS 设备上测试你的代码并发布你的游戏,你需要一个 Apple 开发者账户,这需要每年支付 99 美元。你可以在他们的网站上找到更多信息:developer.apple.com/

对于 Android 部署,我建议你从 Google 获取 Eclipse 和 ADT 套件,你可以在 developer.android.com/sdk/installing/installing-adt.html 找到。你将能够免费在 Android 设备上测试你的游戏。

因此,假设你有互联网连接,让我们开始吧!

行动时间 – 下载,下载,下载

我们首先下载必要的 SDK、NDK 和一些通用的小工具:

  1. 访问 www.cocos2d-x.org/download 并下载 Cocos2d-x 的最新稳定版本。对于本书,我将使用 Cocos2d-x-3.4 版本。

  2. 将文件解压缩到你的机器上某个你可以记住的地方。我建议你将我们现在要下载的所有文件都添加到同一个文件夹中。

  3. 顺便下载 Code IDE 也一样。我们将在本书的最后一章中使用它。

  4. 然后,前往developer.android.com/sdk/installing/installing-adt.html下载 Eclipse ADT 插件(如果你还没有安装 Eclipse 或 Android SDK,请分别从eclipse.org/downloads/developer.android.com/sdk/installing/index.html?pkg=tools下载)。

    注意

    如果你在安装 ADT 插件时遇到任何问题,你可以在developer.android.com/sdk/installing/installing-adt.html找到完整的说明。

  5. 现在,对于 Apache Ant,请访问ant.apache.org/bindownload.cgi并查找压缩文件的链接,然后下载.zip版本。

  6. 最后,前往developer.android.com/tools/sdk/ndk/index.html下载针对目标系统的最新 NDK 版本。按照同一页上的安装说明提取文件,因为某些系统不允许这些文件自动解压。提醒一下:你必须使用 NDK r8e 以上的版本与 Cocos2d-x 3.x 一起使用。

发生了什么?

你已经成功下载了在机器上设置 Cocos2d-x 和开始开发所需的所有内容。如果你使用的是 Mac,你可能需要更改系统偏好设置中的安全设置,以允许 Eclipse 运行。此外,通过转到窗口-Android SDK 管理器菜单,在 Eclipse 中打开 Android SDK 管理器,并安装至少版本 2.3.3 的包以及你可能希望针对的任何更高版本。

此外,请确保你的机器上已安装 Python。在终端或命令提示符中,只需输入单词python并按回车键。如果你还没有安装,请访问www.python.org/并按照那里的说明操作。

因此,到这一步结束时,你应该有一个文件夹,其中包含了 Cocos2d-x、CocosIDE、Android SDK、NDK 和 Apache Ant 的所有提取文件。

现在,让我们安装 Cocos2d-x。

行动时间 - 安装 Cocos2d-x

打开终端或命令提示符,导航到 Cocos2d-x 提取的文件夹:

  1. 你可以通过输入cd(即cd后跟一个空格)并将文件夹拖到终端窗口中,然后按Enter键来完成此操作。在我的机器上,这看起来是这样的:

    cd /Applications/Dev/cocos2d-x-3.4
    
  2. 接下来,输入python setup.py

  3. Enter。你将被提示输入 NDK、SDK 和 Apache ANT 根目录的路径。你必须将每个文件夹都拖到终端窗口中,确保删除路径末尾的任何额外空格,然后按Enter。所以对于 NDK,我得到:

    /Applications/Dev/android-ndk-r10c
    
  4. 接下来,是 SDK 的路径。再次,我将存储在 Eclipse 文件夹中的文件夹拖动:

    /Applications/eclipse/sdk
    
  5. 接下来是 ANT 的路径。如果您已经在您的机器上正确安装了它,路径将类似于usr/local/bin,设置脚本会为您找到它。否则,您可以使用您下载并解压的版本。只需指向其中的bin文件夹:

    /Applications/Dev/apache-ant-1.9.4/bin
    
  6. 最后一步是将这些路径添加到您的系统中。按照窗口中的最后一条指令操作:请执行以下命令:"source /Users/YOUR_USER_NAME/.bash_profile" 以使添加的系统变量生效。您可以将引号内的命令复制,粘贴,然后按Enter键。

发生了什么?

您现在已经在您的机器上安装了 Cocos2d-x,并且准备开始使用了。是时候创建我们的第一个项目了!

Hello-x World-x

让我们创建计算机编程中的老生常谈:hello world示例。

开始行动 - 创建应用程序

再次打开终端并按照以下简单步骤操作:

  1. 您应该已经将 Cocos2d-x 控制台路径添加到您的系统中。您可以通过在终端中使用cocos命令来测试这一点。为了创建一个名为HelloWorld的新项目,使用 C++作为其主要语言并将其保存在您的桌面上,您需要运行以下命令,将YOUR_BUNDLE_INDETIFIER替换为您选择的包名,将PATH_TO_YOUR_PROJECT替换为您希望保存项目的路径:

    cocos new HelloWorld -p YOUR_BUNDLE_IDENTIFIER -l cpp -d PATH_TO_YOUR_PROJECT
    
    
  2. 例如,在我的机器上,我输入的行如下:

    cocos new HelloWorld -p com.rengelbert.HelloWorld -l cpp -d /Users/rengelbert/Desktop/HelloWorld
    
    

    然后按Enter。如果您选择不提供目录参数(-d),Cocos 控制台将项目保存在Cocos2d-x文件夹内。

  3. 现在,您可以前往您的桌面或您选择保存项目的任何位置,导航到HelloWorld项目中的proj.ios_mac文件夹。在该文件夹中,您将找到 Xcode 项目文件。一旦在 Xcode 中打开项目,您就可以点击运行按钮,这样就完成了。开始行动 - 创建应用程序

注意

当您在 Xcode 中运行cocos2d-x应用程序时,程序通常会发布一些关于您的代码或最可能的是框架的警告。这些警告大多会引用已弃用的方法或不符合当前 SDK 更近和更严格规则的语句。但这没关系。尽管这些警告确实令人烦恼,但可以忽略。

发生了什么?

您已经创建了您的第一个 Cocos2d-x 应用程序。命令行上使用的参数是:

  • -p 用于包或捆绑标识符

  • -l 用于语言,这里您有cpplua或 JavaScript 选项

现在让我们在 Android 上运行这个应用程序。

提示

下载示例代码

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

开始行动 - 部署到 Android

我们将在 Eclipse 中打开项目:

  1. 打开 Eclipse。

  2. 我们需要修复 NDK 的路径;在您的系统中,这一步可能是可选的,但在任何情况下,它必须只执行一次。在 Eclipse 中,转到 Eclipse-首选项,然后在 C/C++ 选项下选择 构建环境

  3. 您需要添加 NDK 路径,并且它必须命名为 NDK_ROOT。为了做到这一点,您必须点击 添加…,并使用 NDK_ROOT 作为名称,然后点击 字段以确保鼠标光标在其中处于活动状态,然后拖动您下载的 NDK 文件夹到该字段中。在我的机器上,结果看起来像这样:部署到 Android 的时间 - 部署到 Android

  4. 点击 应用。重启 Eclipse 可能是个好主意。(如果您在 首选项 中看不到 C/C++ 选项,这意味着您没有安装 CDT 插件。请查找有关如何安装它们的完整说明,请参阅 www.eclipse.org/cdt/。)

  5. 现在,我们准备将项目导入到 Eclipse 中。选择 文件 | 导入…

  6. 在对话框中,选择 Android 选项,然后选择 将现有 Android 代码导入工作空间 选项并点击 下一步部署到 Android 的时间 - 部署到 Android

  7. 点击 浏览 按钮,导航到 HelloWorld 项目,并选择其中的 proj.android 文件夹,然后点击 下一步

  8. 您应该看到项目正在编译。整个框架库将被编译,并且基础模板中使用的类也将被编译。

  9. 很遗憾,在框架的 3.4 版本中,这里有一个额外的步骤。在 3.3 版本中已经没有了,但现在又回来了。您必须将项目引用的 Cocos2d-x 库导入到 Eclipse 的包资源管理器中。重复步骤 8,但不是选择 proj.android 文件夹,而是选择 cocos2d/cocos/platform/android/java,然后点击 下一步

  10. 这将选择一个名为 libcocos2dx 的库;点击 完成

  11. 一旦完成,运行一个构建以检查您的项目是否未能生成正确的资源文件可能是个好主意。因此,导航到 项目 | 构建所有

  12. 现在,连接您的 Android 设备并确保 Eclipse 已经识别它。您可能需要在设备上打开 开发 选项,或者在与计算机连接并 Eclipse 运行时重启您的设备。

  13. 右键单击您的项目文件夹,然后选择 运行方式 | Android 应用程序

发生了什么?

您已运行了您的第一个 Cocos2d-x 应用程序在 Android 上。对于您的 Android 构建不需要模拟器;这是浪费时间。如果您没有现成的设备,考虑投资一个。

或者,您可以在终端(或命令提示符)中打开项目的根文件夹,并使用 Cocos2d-x 控制台的 compile 命令:

cocos compile -p android

Cocos2d-x 背后的人宣布,他们将在框架的未来版本中移除构建 Python 脚本,所以做好准备并了解如何在没有它的情况下进行操作是好的。

当您使用 Eclipse 工作时,您很快就会面临可怕的java.lang.NullPointerException错误。这可能与 ADT、CDT 或 NDK 中的冲突有关!

注意

当您遇到这个错误时,您除了重新安装 Eclipse 指向的任何东西作为罪魁祸首外别无选择。这可能在更新后发生,或者如果您出于某种原因安装了使用 NDK 或 ADT 路径的另一个框架。如果错误与特定项目或库相关,只需从 Eclipse 的项目资源管理器中删除所有项目,然后重新导入它们。

现在让我们来查看示例应用程序及其文件。

文件夹结构

首先是Classes文件夹;它将包含您的应用程序类,并且完全用 C++编写。在其下方是Resources文件夹,您可以在其中找到应用程序使用的图像、字体和任何类型的媒体。

ios文件夹包含了您的应用程序与 iOS 之间的必要底层连接。对于其他平台,您将在各自的平台文件夹中找到必要的链接文件。

维护这种文件结构很重要。因此,您的类将放入Classes文件夹,而所有您的图像、声音文件、字体和关卡数据应放置在Resources文件夹中。

文件夹结构

现在让我们来查看基本应用程序的主要类。

iOS 链接类

AppControllerRootViewController负责在 iOS 中设置 OpenGL,并通知底层操作系统您的应用程序即将说Hello... To the World

这些类是用 Objective-C 和 C++混合编写的,正如所有漂亮的括号和.mm扩展名所示。您对这些类几乎不会做任何修改;再次,这将在 iOS 处理您的应用程序的方式中反映出来。因此,其他目标将需要相同的指令或根本不需要,具体取决于目标。

例如,在AppController中,我可以添加对多点触控的支持。在RootViewController中,我可以限制应用程序支持的屏幕方向,例如。

AppDelegate 类

这个类标志着您的 C++应用程序第一次与底层操作系统通信。它试图映射我们想要分发和监听的主要移动设备事件。从现在开始,您所有的应用程序都将用 C++编写(除非您需要针对特定目标的其他东西)并且从这一点开始,您可以添加针对不同目标的条件代码。

AppDelegate中,您应该设置Director对象(它是 Cocos2d-x 功能强大的单例管理对象),以按照您想要的方式运行您的应用程序。您可以:

  • 移除应用程序状态信息

  • 改变应用程序的帧率

  • 告诉Director您的高清图像在哪里,您的标准定义图像在哪里,以及使用哪个

  • 您可以更改应用程序的整体缩放比例,使其最适合不同的屏幕

  • AppDelegate 类也是开始任何预加载过程的最佳位置

  • 最重要的是,在这里你告诉 Director 对象以哪个 Scene 开始你的应用程序

在这里,你将处理操作系统决定杀死、推到一边或倒挂晾干你的应用程序时会发生什么。你所需做的只是将你的逻辑放在正确的事件处理程序中:applicationDidEnterBackgroundapplicationWillEnterForeground

HelloWorldScene 类

当你运行应用程序时,你会在屏幕上看到一个写着 Hello World 和一个角上的数字;那些是你决定在 AppDelegate 类周围显示的显示统计信息。

实际的屏幕是由名为 HelloWorldScene 的奇特类创建的。它是一个 Layer 类,它创建自己的场景(如果你不知道 LayerScene 类是什么,不用担心;你很快就会知道)。

当它初始化时,HelloWorldScene 在屏幕上放置了一个按钮,你可以按下它来退出应用程序。实际上,这个按钮是一个 Menu 对象的一部分,该对象只有一个按钮,按钮有两个图像状态,当按下该按钮时有一个回调事件。

Menu 对象自动处理针对其成员的触摸事件,所以你不会看到任何代码漂浮。然后,还有必要的 Label 对象来显示 Hello World 消息和背景图像。

谁生谁?

如果你之前从未使用过 Cocos2d 或 Cocos2d-x,初始 scene() 方法的实例化方式可能会让你感到头晕。为了回顾,在 AppDelegate 中你有:

auto scene = HelloWorld::createScene();
director->runWithScene(scene);

Director 需要一个 Scene 对象来运行,你可以将其视为你的应用程序,基本上。Scene 需要显示一些内容,在这种情况下,一个 Layer 对象就可以。然后说 Scene 包含一个 Layer 对象。

在这里,通过 Layer 派生类中的静态方法 scene 创建了一个 Scene 对象。因此,层创建了场景,场景立即将层添加到自身。嗯?放松。这种类似乱伦的实例化可能只会发生一次,当它发生时,你无权过问。所以你可以轻松地忽略所有这些有趣的举动,并转过身去。我保证在第一次之后,实例化将变得容易得多。

寻找更多参考资料

按照以下步骤访问 Cocos2d-x 参考资料的最好来源之一:它的 Test 项目。

是时候行动了——运行测试样本

你可以像打开任何其他 Xcode/Eclipse 项目一样打开测试项目:

  1. 在 Eclipse 中,你可以从下载的 Cocos2d-x 文件夹中导入测试项目。你会在 tests/cpp-tests/proj.android 中找到它。

  2. 你可以按照之前的步骤构建此项目。

  3. 在 Xcode 中,你必须打开位于 build 文件夹中的 Cocos2d-x 框架文件夹内的测试项目文件:build/cocos2d_tests.xcodeproj

  4. 一旦在 Xcode 中打开项目,您必须在运行按钮旁边选择正确的目标,如下所示:执行测试样本的时间

  5. 为了实际查看测试中的代码,您可以导航到tests/cpp-tests/Classes以查看 C++测试或tests/lua-tests/src以查看 Lua 测试。更好的是,如果您有一个像TextWrangler或类似程序,您可以在磁盘浏览器窗口中打开这些整个目录,并将所有这些信息准备好以便在您的桌面上直接引用。

发生了什么?

使用测试样本,您可以可视化 Cocos2d-x 中的大多数功能,了解它们的作用,以及看到一些初始化和自定义它们的方法。

我将经常引用测试中找到的代码。像编程一样,总是有完成同一任务的不同方法,所以有时在向您展示一种方法之后,我会引用另一种您可以在Test类中找到的方法(并且那时您可以轻松理解)。

其他工具

接下来是您可能需要花费更多钱来获取一些极其有用的工具(并做一些额外的学习)的部分。在这本书的示例中,我使用了其中四个:

  • 一个帮助构建精灵图集的工具:我将使用TexturePacker(www.codeandweb.com/texturepacker)。还有其他替代品,如Zwoptex(zwopple.com/zwoptex/),它们通常提供一些免费功能。Cocos2d-x 现在提供了一个名为CocosStudio的免费程序,它与SpriteBuilder(以前称为CocosBuilder)有些相似,并提供构建精灵图集、位图字体以及许多其他好东西的方法。在撰写本文时,Windows 版本在某种程度上优于 Mac 版本,但它们都是免费的!

  • 一个帮助构建粒子效果的工具:我将使用粒子设计师(www.71squared.com/en/particledesigner)。根据您的操作系统,您可能在网上找到免费工具。Cocos2d-x 捆绑了一些常见的粒子效果,您可以自定义它们。但盲目地做这个过程我不推荐。CocosStudio 也允许您创建自己的粒子效果,但您可能会发现其界面有点令人望而却步。它确实需要自己的教程书籍!

  • 一个帮助构建位图字体的工具:我将使用 Glyph Designer(www.71squared.com/en/glyphdesigner)。但还有其他选择:bmGlyph(价格不那么昂贵)和 FontBuilder(免费)。构建位图字体并不特别困难——远不如从头开始构建粒子效果困难——但做一次就足以让您快速获取这些工具之一。再次提醒,您也可以尝试 CocosStudio。

  • 生成音效的工具:毫无疑问——Windows 上的 sfxr 或其 Mac 版本 cfxr。两者都是免费的(分别见www.drpetter.se/project_sfxr.htmlthirdcog.eu/apps/cfxr)。

摘要

你刚刚学习了如何安装 Cocos2d-x 并创建一个基本应用程序。你也学到了足够的基本 Cocos2d-x 应用程序结构,可以开始构建你的第一个游戏,并且你知道如何部署到 iOS 和 Android。

在阅读本书中的示例时,请将Test类放在身边,你很快就会成为 Cocos2d-x 专家!

但首先,让我们回顾一下关于框架及其本地语言的一些内容。

第二章. 你、C++ 和 Cocos2d-x

本章将针对两种类型的开发者:害怕 C++ 但不会向朋友承认的原始 Cocos2d 开发者,以及从未听说过 Cocos2d 且认为 Objective-C 看起来很奇怪的 C++ 程序员。

我将概述 Objective-C 开发者应该注意的主要语法差异,以及在使用 Cocos2d-x 开发时涉及的一些代码风格更改,C++ 开发者应该了解。但首先,让我们快速介绍一下 Cocos2d-x 以及它是什么以及它所涉及的一切。

您将学习以下主题:

  • Cocos2d-x 是什么以及它能为您做什么

  • 如何在 C++ 中创建类

  • 如何在 Cocos2d-x 和 C++ 中管理您的对象内存

  • 从 Ref 获取的内容

Cocos2d-x – 简介

那么,什么是 2D 框架呢?如果我要用尽可能少的词来定义它,我会说是在循环中的矩形。

在 Cocos2d-x 的核心,您会发现 Sprite 类以及这个类所做的工作,简单来说,就是保持对两个非常重要的矩形的引用。一个是图像(或纹理)矩形,也称为源矩形,另一个是目标矩形。如果您想让图像出现在屏幕中央,您将使用 Sprite。您将传递有关图像源是什么以及在哪里显示的信息,以及您想在屏幕上的哪个位置显示它。

对于第一个矩形,即源矩形,不需要做太多工作;但在目标矩形中可以改变很多东西,包括其在屏幕上的位置、大小、不透明度、旋转等。

Cocos2d-x 将负责所有必要的 OpenGL 绘制工作,以显示您想要的位置和方式显示您的图像,并且它将在渲染循环内完成这些工作。您的代码很可能会利用同一个循环来更新自己的逻辑。

几乎任何您能想到的 2D 游戏都可以使用 Cocos2d-x 和一些精灵以及循环来构建。

注意

在框架的 3.x 版本中,Cocos2d-x 和其对应版本 Cocos2d 之间存在轻微的分离。它放弃了 CC 前缀,转而使用命名空间,采用了 C++11 的特性,因此它变得更加易于使用。

容器

在 Cocos2d-x 中,也很重要的是容器(或节点)的概念。这些都是可以包含精灵(或其他节点)的对象。在某些时候,这非常有用,因为通过改变容器的一些方面,您会自动改变其子节点的某些方面。移动容器,所有子节点都会随之移动。旋转容器,嗯,您应该能想象出来!

容器包括:SceneLayerSprite。它们都继承自一个名为 node 的基本容器类。每个容器都会有其独特之处,但基本上您将按照以下方式排列它们:

  • Scene: 这将包含一个或多个 Node,通常是 Layer 类型。将应用程序拆分为多个场景很常见;例如,一个用于主菜单,一个用于设置,一个用于实际游戏。技术上,每个场景都将作为你应用程序中的独立实体行为,几乎就像子应用程序一样,你可以在场景之间切换时运行一系列过渡效果。

  • Layer: 这很可能会包含 Sprite。有一些专门的 Layer 对象旨在为你,开发者,节省时间,例如创建菜单(Menu)或彩色背景(LayerColor)。每个场景可以有多个 Layer,但良好的规划通常使这变得不必要。

  • Sprite: 这将包含你的图像,并作为子元素添加到由 Layer 派生的容器中。在我看来,这是 Cocos2d-x 中最重要的类,如此重要,以至于在应用程序初始化后,当创建了一个 Scene 和一个 Layer 对象时,你只需使用精灵就能构建整个游戏,而无需在 Cocos2d-x 中使用另一个容器类。

  • Node: 这个超级类对所有容器模糊了其自身与 Layer,甚至 Sprite 之间的界限。它有一系列专门的子类(除了之前提到的那些),例如 MotionStreakParallaxNodeSpriteBatchNode,仅举几例。经过一些调整,它可以表现得就像 Layer 一样。但大多数时候,你会用它来创建自己的专用节点或作为多态中的通用参考。

导演和缓存类

在容器之后,是无所不知的 Director 和包罗万象的缓存对象。Director 对象管理场景,并了解你应用程序的所有信息。你将调用它来获取这些信息,并更改一些事情,如屏幕大小、帧率、缩放因子等等。

缓存是收集器对象。其中最重要的有 TextureCacheSpriteFrameCacheAnimationCache。这些负责存储关于我之前提到的两个重要矩形的关键信息。但 Cocos2d-x 中使用的任何重复数据类型都将保存在某种缓存列表中。

Director 和所有缓存对象都是单例。这些是特殊类型的类,它们只实例化一次;并且这个实例可以被任何其他对象访问。

其他东西

在基本容器、缓存和 Director 对象之后,是框架剩余的 90%。在这其中,你会发现:

  • 动作:动画将通过这些来处理,它们是多么美妙啊!

  • 粒子:粒子系统,让你的快乐倍增。

  • 专用节点:用于菜单、进度条、特殊效果、视差效果、瓦片地图等等。

  • 宏、结构和辅助方法:数百个节省时间的神奇逻辑片段。你不需要知道它们全部,但很可能你会编写一些可以用宏或辅助方法轻松替换的代码,并在后来发现时感到非常愚蠢。

你知道 C++吗?

别担心,C 部分很简单。第一个加号过得非常快,但那个第二个加号,哎呀!

记住,这是 C。如果你使用原始 Cocos2d 在 Objective-C 中编码过,即使你大部分时间看到的是在括号中,你也已经熟悉了古老的 C 语言。

但 C++也有类,就像 Objective-C 一样,这些类在接口文件中声明,就像在 Objective-C 中一样。所以,让我们回顾一下 C++类的创建。

类接口

这将在.h文件中完成。我们将使用文本编辑器来创建这个文件,因为我不想任何代码提示和自动完成功能干扰你学习 C++语法的基础知识。所以至少现在,打开你最喜欢的文本编辑器。让我们创建一个类接口!

是时候行动了——创建接口

接口,或头文件,只是一个带有.h扩展名的文本文件。

  1. 创建一个新的文本文件,并将其保存为HelloWorld.h。然后,在顶部输入以下行:

    #ifndef __HELLOWORLD_H__
    #define __HELLOWORLD_H__
    #include "cocos2d.h" 
    
  2. 接下来,添加命名空间声明:

    using namespace cocos2d;
    
  3. 然后,声明你的类名和任何继承的类名:

    class HelloWorld : public cocos2d::Layer {
    
    
  4. 接下来,我们添加属性和方法:

    protected:
    int _score;
    
    public:
    
        HelloWorld();
        virtual ~HelloWorld();
    
        virtual bool init();
        static cocos2d::Scene* scene();
        CREATE_FUNC(HelloWorld);
        void update(float dt);
        inline int addTwoIntegers (int one, int two) {
            return one + two;
        }
    };
    
  5. 我们通过关闭#ifndef语句来完成:

    #endif // __HELLOWORLD_H__
    

刚才发生了什么?

你在 C++中创建了一个头文件。让我们回顾一下重要的信息:

  • 在 C++中,你包含,而不是导入。Objective-C 中的import语句检查是否需要包含某些内容;include则不检查。但我们通过在顶部使用定义的巧妙方式完成相同的事情。还有其他方法可以运行相同的检查(例如使用#pragma once),但这个是在你创建的任何新的 Xcode C++文件中添加的。

  • 你可以通过声明在类中使用的命名空间来使你的生活更简单。这些在有些语言中类似于包。你可能已经注意到,由于命名空间声明,代码中所有对cocos2d::的使用都是不必要的。但我想要展示的是,通过添加命名空间声明可以去掉的部分。

  • 所以接下来,给你的类起一个名字,你可以选择从其他类继承。在 C++中,你可以有任意多的超类。你必须声明你的超类是否是公开的。

  • 你在花括号之间声明你的publicprotectedprivate方法和成员。HelloWorld是构造函数,~HelloWorld是析构函数(它将执行 Objective-C 中的dealloc所做的操作)。

  • virtual 关键字与重写有关。当你将一个方法标记为 virtual 时,你是在告诉编译器不要将方法的所有权固定下来,而是将其保留在内存中,因为执行将揭示明显的主人。否则,编译器可能会错误地决定一个方法属于超类而不是其继承类。

    此外,将所有析构函数设置为 virtual 是一种良好的实践。你只需要在超类中使用一次关键字来标记潜在的覆盖,但通常的做法是在所有子类中重复 virtual 关键字,这样开发者就知道哪些方法是覆盖(C++11 添加了一个 override 标签,这使得这种区分更加清晰,你将在本书的代码中看到它的例子)。在这种情况下,init 来自 Layer,而 HelloWorld 想要覆盖它。

        virtual bool init();
    
  • 噢,是的,在 C++ 中,你必须在你的接口中声明重写。没有例外!

inline 方法对你来说可能是新的。这些方法在它们被调用的地方由编译器添加到代码中。所以每次我调用 addTwoIntegers,编译器都会用接口中声明的方法的行来替换它。所以 inline 方法就像方法内部的表达式一样工作;它们不需要在栈中占用自己的内存。但是如果你在程序中调用了一个两行的 inline 方法 50 次,这意味着编译器将向你的代码中添加一百行。

类实现

这将在一个 .cpp 文件中完成。所以让我们回到我们的文本编辑器,为我们的 HelloWorld 类创建实现。

是时候采取行动——创建实现

实现是一个具有 .cpp 扩展名的文本文件:

  1. 创建一个新的文本文件,并将其保存为 HelloWorld.cpp。在顶部,让我们先包含我们的头文件:

    #include "HelloWorld.h"
    
  2. 接下来,我们实现构造函数和析构函数:

    HelloWorld::HelloWorld () {
        //constructor
    }
    
    HelloWorld::~HelloWorld () {
        //destructor
    }
    
  3. 然后是我们的静态方法:

    Scene* HelloWorld::scene() {
        auto scene = Scene::create();
    
        auto layer = HelloWorld::create();
    
        scene->addChild(layer);
    
        return scene;
    }
    
  4. 然后是我们的两个剩余的公共方法:

    bool HelloWorld::init() {
        // call to super
        if ( !Layer::init() )
        {
            return false;
        }
    
        //create main loop 
        this->scheduleUpdate();
    
        return true;
    }
    
    void HelloWorld::update (float dt) {
        //the main loop
    }
    

刚才发生了什么?

我们为我们的 HelloWorld 类创建了实现。以下是需要注意的最重要部分:

  • 在这里,HelloWorld:: 作用域解析不是可选的。你接口中声明的每个方法都属于需要正确作用域解析的新类。

  • 当调用超类如 Layer::init() 时,也需要作用域解析。在标准 C++ 库中没有内置的 super 关键字。

  • 你使用 this 而不是 self。当你试图通过指向对象的指针(指针是你在内存中找到实际对象的信息)来访问对象的属性或方法时,使用 -> 符号。使用 .(点)符号通过其实例(构成实际对象的内存块)来访问对象的方法和属性。

  • 我们创建了一个 update 循环,它通过调用 scheduleUpdate 简单地接受一个浮点数作为其 delta 时间值。你将在本书后面的部分看到更多与此相关的选项。

  • 如果编译器足够清楚对象的类型,你可以使用auto关键字作为对象的类型。

  • 当然,inline方法不会在类中实现,因为它们只存在于接口中。

至此,语法讲解就到这里。C++是现有最广泛的语言之一,我不希望给你留下我已经涵盖了所有内容的印象。但这是一个由开发者为开发者制作的语言。相信我,你将感到与它一起工作非常自在。

之前列出的信息在我们开始构建游戏后将会更加清晰。但现在,让我们直面这个令人畏惧的大怪物:内存管理。

实例化对象并管理内存

Cocos2d-x 中没有自动引用计数(ARC),因此忘记内存管理的 Objective-C 开发者可能会在这里遇到问题。然而,关于内存管理的规则在 C++中非常简单:如果你使用new,你必须删除。C++11 通过引入特殊的内存管理的指针(这些是std::unique_ptrstd::shared_ptr)使这一点变得更加容易。

然而,Cocos2d-x 会添加一些其他选项和命令来帮助进行内存管理,类似于我们在 Objective-C(没有 ARC)中使用的那些。这是因为 Cocos2d-x,与 C++不同,与 Objective-C 非常相似,有一个根类。这个框架不仅仅是 Cocos2d 的 C++端口,它还将 Objective-C 的一些概念移植到 C++中,以重新创建其内存管理系统。

Cocos2d-x 有一个Ref类,它是框架中每个主要对象的根。它允许框架拥有autorelease池和retain计数,以及其他 Objective-C 等效功能。

当实例化 Cocos2d-x 对象时,你基本上有两个选项:

  • 使用静态方法

  • C++和 Cocos2d-x 风格

使用静态方法

使用静态方法是推荐的方式。Objective-C 的三阶段实例化过程,包括allocinitautorelease/retain,在这里被重新创建。例如,一个扩展SpritePlayer类可能具有以下方法:

Player::Player () {
    this->setPosition  ( Vec2(0,0) );
}

Player* Player::create () {

    auto player = new Player();
    if (player && player->initWithSpriteFrameName("player.png")) {
        player->autorelease();
        return player;
    }
    CC_SAFE_DELETE(player);
    return nullptr;
}

对于实例化,你调用静态的create方法。它将创建一个新的Player对象,作为Player的空壳版本。构造函数内部不应该进行任何主要初始化,以防你可能因为实例化过程中的某些失败而需要删除该对象。Cocos2d-x 有一系列用于对象删除和释放的宏,就像之前使用的CC_SAFE_DELETE宏一样。

然后,通过其可用方法之一初始化超类。在 Cocos2d-x 中,这些init方法返回一个boolean值表示成功。现在,你可以开始用一些数据填充Player对象。

如果成功,那么在之前的步骤中没有完成的情况下,使用正确的数据初始化你的对象,并以一个autorelease对象的形式返回它。

因此,在你的代码中,对象将按照以下方式实例化:

auto player = Player::create();
this->addChild(player);//this will retain the object

即使 player 变量是类的一个成员(比如说,m_player),你也不必保留它以保持其作用域。通过将对象添加到某个 Cocos2d-x 列表或缓存中,对象会自动保留。因此,你可以继续通过其指针来引用该内存:

m_player = Player::create();
this->addChild(m_player);//this will retain the object
//m_player still references the memory address 
//but does not need to be released or deleted by you

C++ 和 Cocos2d-x 风格

在这个选项中,你会按照以下方式实例化之前的 Player 对象:

auto player = new Player();
player->initWithSpriteFrameName("player.png");
this->addChild(player);
player->autorelease();

在这种情况下,Player 可以没有静态方法,并且 player 指针在将来不会访问相同的内存,因为它被设置为自动释放(所以它不会长时间存在)。然而,在这种情况下,内存不会泄漏。它仍然会被 Cocos2d-x 列表(addChild 命令负责这一点)保留。你仍然可以通过遍历添加到 this 的子项列表来访问该内存。

如果你需要指针作为成员属性,你可以使用 retain() 而不是 autorelease()

m_player = new Player();
m_player->initWithSpriteFrameName("player.png");
this->addChild(m_player);
m_player->retain();

然后,在某个时候,你必须释放它;否则,它将会泄漏:

m_player->release();

硬核的 C++ 开发者可能会选择忘记所有关于 autorelease 池的事情,而仅仅使用 newdelete

Player * player = new Player();
player->initWithSpriteFrameName("player.png");
this->addChild(player);
delete player;//This will crash!

这将不会起作用。你必须使用 autoreleaseretain 或者让之前的代码不带 delete 命令,并希望不会出现任何泄漏。

C++ 开发者必须记住,Ref 是由框架管理的。这意味着对象正在被内部添加到缓存和 autorelease 池中,即使你可能不希望这种情况发生。例如,当你创建那个 Player 精灵时,你使用的 player.png 文件将被添加到纹理缓存或精灵帧缓存中。当你将精灵添加到图层时,精灵将被添加到该图层的所有子项列表中,而这个列表将由框架管理。我的建议是,放松并让框架为你工作。

非 C++ 开发者应该记住,任何没有从 Ref 派生的类都应该以通常的方式管理,也就是说,如果你 创建 一个新对象,你必须在某个时候删除它:

MyObject* object = new MyObject();
delete object;

使用 Ref 得到的东西

使用 Ref 你可以得到托管对象。这意味着 Ref 派生对象将有一个引用计数属性,该属性将用于确定对象是否应该从内存中删除。每当对象被添加到或从 Cocos2d-x 集合对象中移除时,引用计数都会更新。

例如,Cocos2d-x 附带了一个 Vector 集合对象,它通过在对象被添加到或从其中移除时增加和减少引用计数来扩展 C++ 标准库向量 (std::vector) 的功能。因此,它只能存储 Ref 派生对象。

再次强调,每个 Ref 派生类都可以像在 ARC 之前 Objective-C 中管理事物一样进行管理——使用 retain 计数和 autorelease 池。

然而,C++自带了许多自己非常棒的动态列表类,类似于你在 Java 和 C#中找到的类。但对于Ref派生对象,你可能最好使用 Cocos2d-x 管理的列表,或者记得在适用的情况下保留和释放每个对象。如果你创建了一个不扩展Ref的类,并且需要将这个类的实例存储在列表容器中,那么请选择标准库中的那些。

在本书接下来的示例中,我将主要在框架内部进行编码,因此你们将有机会看到许多cocos2d::Vector的使用示例,例如,但我也将在一些游戏中使用一个或两个std::vector实例。

总结

希望非 C++开发者现在已经了解到这个语言没有什么可怕的地方,而核心 C++开发者也没有对根类及其保留和自动释放的概念嘲笑得太多。

所有根类为 Java 和 Objective-C 等语言带来的东西将永远是一个无意义的问题。那些在你背后进行的令人毛骨悚然的底层操作无法关闭或控制。它们不是可选的,而根对象这种强制性质自从垃圾收集器等概念首次出现以来就一直困扰着 C++开发者。

话虽如此,Ref对象的内存管理非常有帮助,我希望即使是那些最不信任的开发者也会很快学会对此表示感谢。

此外,Cocos2d-x 非常出色。那么,让我们现在就创建一个游戏吧!

第三章。你的第一个游戏 – 桌面冰球

我们将构建一个桌面冰球游戏,以介绍使用 Cocos2d-x 构建项目的所有主要方面。这包括设置项目的配置、加载图像、加载声音、为多个屏幕分辨率构建游戏以及管理触摸事件。

哦,你还需要叫一个朋友。这是一个双人游戏。继续吧,我在这里等你。

到本章结束时,你将知道:

  • 如何构建仅适用于 iPad 的游戏

  • 如何启用多点触控

  • 如何支持视网膜和非视网膜显示屏

  • 如何加载图像和声音

  • 如何播放音效

  • 如何创建精灵

  • 如何扩展 Cocos2d-x 的Sprite

  • 如何创建标签并更新它们

不再拖延...让我们开始吧。

游戏配置

游戏将具有以下特点:

  • 由于是双人游戏,它必须支持多点触控

  • 由于是双人游戏,它必须在大型屏幕上玩

  • 由于我们想利用这一点,它必须支持视网膜显示屏

  • 由于我是在纵向模式下构建的艺术品,它必须仅在纵向模式下播放

所以让我们创建我们的项目!

行动时间 – 创建你的游戏项目

我将首先在 Xcode 中构建游戏,然后展示如何将项目带到 Eclipse,但文件夹结构保持不变,所以你可以使用任何你想要的 IDE,这里的说明将是相同的:

  1. 打开终端,创建一个名为AirHockey的新 Cocos2d-x 项目,使用 C++作为其主要语言。我把我的保存在桌面上,所以我要输入的命令看起来像这样:

    cocos new AirHockey -p com.rengelbert.AirHockey -l cpp -d /Users/rengelbert/Desktop/AirHockey
    
    
  2. 一旦创建项目,导航到其proj.ios_mac文件夹,双击AirHockey.xcodeproj文件。(对于 Eclipse,你可以遵循我们创建HelloWorld项目时采取的相同步骤来导入项目。)

  3. 项目导航器中选择顶部项目,并确保选择了iOS目标,通过导航到通用 | 部署信息,设置目标设备为iPad,并将设备方向设置为纵向颠倒行动时间 – 创建你的游戏项目

  4. 保存你的项目更改。

刚才发生了什么?

你创建了一个针对 iPad 的 Cocos2d-x 项目,你现在可以设置它,使用我之前描述的其余配置。

所以我们现在就来做。

行动时间 – 制定规则

我们将更新RootViewController.mm文件。

  1. 前往ios文件夹中的RootViewController.mm,查找shouldAutorotateToInterfaceOrientation方法。将方法内的行更改为:

    return UIInterfaceOrientationIsPortrait( interfaceOrientation );
    
  2. supportedInterfaceOrientations方法下方几行,更改条件to内的行:

    return UIInterfaceOrientationMaskPortrait;
    

刚才发生了什么?

我们刚刚告诉RootViewController,我们希望我们的应用程序在任何两种支持的纵向模式中运行。

支持视网膜显示屏

现在让我们将图像添加到我们的项目中。

行动时间 – 添加图像文件

首先,我们下载这个项目的资源,然后我们在 Xcode 中添加它们。

  1. 前往本书的 支持 页面 (www.packtpub.com/support) 并下载 4198_03_RESOURCES.zip 文件。在里面,您应该找到三个名为 hdsdfonts 的文件夹。

  2. 前往您的 Project 文件夹,即您系统中的实际文件夹。将三个文件夹拖到项目中的 Resources 文件夹内。

  3. 返回 Xcode。在项目导航面板中选择 Resources 文件夹。然后转到 文件 | 将文件添加到 AirHockey

  4. 文件 窗口中,导航到 Resources 文件夹并选择 sdhdfonts 文件夹。

  5. 这非常重要:确保 为任何添加的文件夹创建文件夹引用 已选中。同时确保您已选择 AirHockey 作为目标。确保 复制项目到目标... 也已选中。

  6. 点击 添加

发生了什么?

您已添加了您 Air Hockey 游戏所需的必要图像文件。它们有两种版本:一种是针对视网膜显示屏(高清)的,另一种是针对非视网膜显示屏(标准定义)的。确保只添加到实际文件夹的引用非常重要;这样,Xcode 才能将两个具有相同名称的文件分别放在项目的每个文件夹中;每个文件夹一个。我们还添加了游戏中将使用的字体。

现在,让我们告诉 Cocos2d-x 去哪里查找正确的文件。

是时候添加视网膜支持了

这次我们将处理 AppDelegate.cpp 类:

  1. 前往 AppDelegate.cpp (您可以在 Classes 文件夹中找到它)。在 applicationDidFinishLaunching 方法中,在 director->setAnimationInterval(1.0 / 60) 行下面,添加以下行:

    auto screenSize = glview->getFrameSize();
    auto designSize = Size(768, 1024);
    glview->setDesignResolutionSize(designSize.width, designSize.height, ResolutionPolicy::EXACT_FIT);
    
    std::vector<std::string> searchPaths;
    if (screenSize.width > 768) {
        searchPaths.push_back("hd");
        director->setContentScaleFactor(2);
    } else  {
        searchPaths.push_back("sd");
        director->setContentScaleFactor(1);
    }
    auto fileUtils = FileUtils::getInstance();
    fileUtils->setSearchPaths(searchPaths);
    
  2. 保存文件。

发生了什么?

可以写一本关于这个主题的整本书,尽管在这个第一个例子中,我们有一个非常简单的实现,说明如何支持多个屏幕尺寸,因为我们只针对 iPad。这里我们说的是:“嘿 AppDelegate,我为 768 x 1024 屏幕设计了这款游戏。”

所有用于定位和字体大小的值都是针对该屏幕尺寸选择的。如果屏幕更大,请确保您从 hd 文件夹中获取文件,并更改您将乘以所有定位和字体大小的比例。如果屏幕大小与我为游戏设计的相同,请使用 sd 文件夹中的文件,并将比例设置为 1。 (Android 为此增加了更多的复杂性,但我们在本书的后面会处理这个问题。)

FileUtils 将首先在 Resources | sd (或 hd) 中查找您为游戏加载的每个文件。如果在那里找不到,它将尝试在 Resources 中查找它们。这是一件好事,因为两个版本共享的文件可能只添加到项目中的 Resources 里面一次。我们现在就会这样做,添加声音文件。

添加声音效果

这个游戏有两个声音效果文件。您可以在之前下载的相同 .zip 文件中找到它们。

行动时间 - 添加声音文件

假设你已经有从下载的资源中获取的声音文件,让我们将它们添加到项目中。

  1. 将两个.wav文件拖到你的Project文件夹内的Resources文件夹中。

  2. 然后转到 Xcode,在文件导航面板中选择Resources文件夹,并选择文件 | 将文件添加到 AirHockey

  3. 确保选择了AirHockey目标。

  4. 再次转到AppDelegate.cpp。在顶部,添加以下include语句:

    #include "SimpleAudioEngine.h"
    
  5. 然后在USING_NS_CC宏(用于using namespace cocos2d)下方添加:

    using namespace CocosDenshion;
    
  6. 然后在上一节中添加的行下面,在applicationDidFinishLaunching中添加以下行:

    auto audioEngine = SimpleAudioEngine::getInstance();
    audioEngine->preloadEffect( fileUtils->fullPathForFilename("hit.wav").c_str() );
    audioEngine->preloadEffect( fileUtils->fullPathForFilename("score.wav").c_str() );
    audioEngine->setBackgroundMusicVolume(0.5f);
    audioEngine->setEffectsVolume(0.5f);
    

发生了什么?

通过CocosDenshionpreloadEffect方法,你成功预加载了文件以及实例化和初始化SimpleAudioEngine。这一步总是会消耗你的应用程序的处理能力,因此最好尽早完成。

到现在为止,你的游戏文件夹结构应该看起来像这样:

发生了什么?

扩展 Sprite

不,Sprite没有问题。我只是选择了一个需要从其一些精灵中获取更多信息的游戏。在这种情况下,我们想要存储精灵的位置以及游戏当前迭代完成后它将去的位置。我们还需要一个辅助方法来获取精灵的半径。

那么,让我们创建我们的GameSprite类。

行动时间 - 添加 GameSprite.cpp

从这里开始,我们将在 Xcode 中创建任何新的类,但如果你记得更新Make文件,你同样可以在 Eclipse 中轻松完成。我将在本章后面展示如何做到这一点。

  1. 在 Xcode 中,选择Classes文件夹,然后转到文件 | 新建 | 文件,导航到iOS | 选择C++ 文件

  2. 命名为GameSprite并确保选择了也创建一个头文件选项。

  3. 选择新的GameSprite.h接口文件,并用以下代码替换那里的代码:

    #ifndef __GAMESPRITE_H__
    #define __GAMESPRITE_H__
    #include "cocos2d.h"
    using namespace cocos2d;
    class GameSprite : public Sprite {
    public:
       CC_SYNTHESIZE(Vec2, _nextPosition, NextPosition);
       CC_SYNTHESIZE(Vec2, _vector, Vector);
       CC_SYNTHESIZE(Touch*, _touch, Touch);
       GameSprite();
       virtual ~GameSprite();
       static GameSprite* gameSpriteWithFile(const char*  pszFileName);
       virtual void setPosition(const Vec2& pos) override;
       float radius();
    };
    #endif // __GAMESPRITE_H__
    

发生了什么?

在接口中,我们声明该类是公共Sprite类的子类。

然后我们添加了三个合成属性。在 Cocos2d-x 中,这些是用于创建获取器和设置器的宏。你声明类型、受保护的变量名以及将附加到getset方法的单词。因此,在第一个CC_SYNTHESIZE方法中,将创建getNextPositionsetNextPosition方法来处理_nextPosition受保护变量中的Point值。

我们还为我们的类添加了构造函数和析构函数,以及实例化的通用静态方法。这个方法接收一个参数,即精灵使用的图像文件名。我们通过覆盖Sprite中的setPosition并添加我们的辅助方法半径的声明来完成。

下一步是实现我们的新类。

行动时间 - 实现 GameSprite

头文件处理完毕后,我们只需要实现我们的方法。

  1. 选择GameSprite.cpp文件,让我们开始类的实例化逻辑:

    #include "GameSprite.h"
    
    GameSprite::GameSprite(void){
        _vector = Vec2(0,0);
    }
    
    GameSprite::~GameSprite(void){
    }
    
    GameSprite* GameSprite::gameSpriteWithFile(const char * pszFileName) {
       auto sprite = new GameSprite();
       if (sprite && sprite->initWithFile(pszFileName)) {
              sprite->autorelease();
              return sprite;
       }
       CC_SAFE_DELETE(sprite);
       return sprite = nullptr;
    }
    
  2. 接下来,我们需要重写Node方法的setPosition。我们需要确保每次更改精灵的位置时,新的值也会被_nextPosition使用:

    void GameSprite::setPosition(const Point& pos) {
        Sprite::setPosition(pos);
        if (!_nextPosition.equals(pos)) {
            _nextPosition = pos;
        }
    }
    
  3. 最后,我们实现了我们的新方法来检索精灵的半径,我们将其确定为纹理宽度的一半:

    float GameSprite::radius() {
        return getTexture()->getContentSize().width * 0.5f;
    }
    

发生了什么?

事情只在静态方法中开始。我们创建一个新的GameSprite类,然后调用其上的initWithFile。这是从其超类继承的GameSprite方法;它返回一个布尔值,表示该操作是否成功。静态方法通过返回autorelease版本的GameSprite对象结束。

setPosition的重写确保当精灵被放置在某个位置时,_nextPosition也会接收到位置信息。并且辅助的radius方法返回精灵纹理宽度的二分之一。

尝试一下,英雄

将半径方法更改为接口中的内联方法,并从实现文件中删除它。

实际的游戏场景

最后,我们将看到我们的所有工作,并从中获得一些乐趣。但首先,让我们删除HelloWorldScene类(包括头文件和实现文件)。你会在项目中遇到一些错误,所以让我们修复这些错误。

AppDelegate.cpp中的两行必须更改对类的引用。继续将引用更改为GameLayer类。

我们将创建那个类。

行动时间 – 编码 GameLayer 接口

GameLayer是我们游戏中的主要容器。

  1. 按照步骤将新文件添加到你的Classes文件夹中。这是一个名为GameLayer的 C++文件。

  2. 选择你的GameLayer.h。在第一个define预处理器命令下方,添加:

    #define GOAL_WIDTH 400
    
  3. 我们定义了球门的宽度(以像素为单位)。

  4. 接下来,添加我们的精灵和得分文本标签的声明:

    #include "cocos2d.h"
    #include "GameSprite.h"
    
    using namespace cocos2d;
    
    class GameLayer : public Layer
    {
        GameSprite* _player1;
        GameSprite* _player2;
        GameSprite* _ball;
    
        Vector<GameSprite*> _players;
        Label* _player1ScoreLabel;
        Label* _player2ScoreLabel;
    

    我们有两个玩家的GameSprite对象(看起来奇怪的称为 mallets),以及球(称为 puck)。我们将两个玩家存储在 Cocos2d-x 的Vector中。我们还有两个文本标签来显示每个玩家的得分。

  5. 声明一个变量来存储屏幕大小。我们将大量使用它进行定位:

    Size _screenSize;
    
  6. 添加变量以存储得分信息,并添加一个方法来更新屏幕上的这些得分:

    int _player1Score;
    int _player2Score;
    
    void playerScore (int player);
    
  7. 最后,让我们添加我们的方法:

    public:
    
       GameLayer();
       virtual ~GameLayer();
       virtual bool init();
    
        static Scene* scene();
    
        CREATE_FUNC(GameLayer);
    
        void onTouchesBegan(const std::vector<Touch*> &touches,  Event* event);
       void onTouchesMoved(const std::vector<Touch*> &touches,  Event* event);
       void onTouchesEnded(const std::vector<Touch*> &touches,  Event* event);
    
      void update (float dt);
    };
    #endif // __GAMELAYER_H__
    

有构造函数和析构函数方法,然后是Layer init方法,最后是触摸事件处理程序和我们的循环方法update。这些触摸事件处理程序将被添加到我们的类中,以处理用户触摸开始、在屏幕上移动以及结束时的情况。

发生了什么?

GameLayer是我们的游戏。它包含我们需要控制和更新的所有精灵的引用,以及所有游戏数据。

在类实现中,所有逻辑都开始于init方法内部。

行动时间 – 实现 init()

init()内部,我们将构建游戏屏幕,引入游戏所需的全部精灵和标签:

  1. 因此,在调用超类Layer::init方法的if语句之后,我们添加:

    _players = Vector<GameSprite*>(2);
    _player1Score = 0;
    _player2Score = 0;
    _screenSize = Director::getInstance()->getWinSize();
    
  2. 我们创建一个向量来存储两个玩家,初始化得分值,并从单例、无所不知的Director中获取屏幕大小。我们将使用屏幕大小来相对定位所有精灵。接下来,我们将创建第一个精灵。它使用图像文件名创建,FileUtils将负责从正确的文件夹中加载:

    auto court = Sprite::create("court.png");
    court->setPosition(Vec2(_screenSize.width * 0.5, _screenSize.height * 0.5));
    this->addChild(court);
    
  3. 习惯使用相对值定位精灵,而不是绝对值,这样我们可以支持更多的屏幕尺寸。并且,欢迎Vec2类型定义,它用于创建点;你将在 Cocos2d-x 中经常看到它。

  4. 我们通过将精灵作为子节点添加到我们的GameLayer(球场精灵不需要是GameSprite)来完成。

  5. 接下来,我们将使用我们全新的GameSprite类,仔细地在屏幕上定位对象:

    _player1 =  GameSprite::gameSpriteWithFile("mallet.png");
    _player1->setPosition(Vec2(_screenSize.width * 0.5,  _player1->radius() * 2));
    _players.pushBack(_player1);
    this->addChild(_player1);
    
    _player2 =  GameSprite::gameSpriteWithFile("mallet.png");
    _player2->setPosition(Vec2(_screenSize.width * 0.5, _screenSize.height - _player1->radius() * 2));
    _players.pushBack(_player2);
    this->addChild(_player2);
    _ball = GameSprite::gameSpriteWithFile("puck.png");
    _ball->setPosition(Vec2(_screenSize.width * 0.5, _screenSize.height * 0.5 - 2 * _ball->radius()));
    this->addChild(_ball);
    
  6. 我们将使用Label类的createWithTTF静态方法创建 TTF 标签,传递初始字符串值(0)和字体文件路径。然后我们将定位和旋转标签:

    _player1ScoreLabel = Label::createWithTTF("0",  "fonts/Arial.ttf", 60);
    _player1ScoreLabel->setPosition(Vec2(_screenSize.width - 60,  _screenSize.height * 0.5 - 80));
    _player1ScoreLabel->setRotation(90);
    this->addChild(_player1ScoreLabel);
    _player2ScoreLabel = Label::createWithTTF("0",  "fonts/Arial.ttf", 60);
    _player2ScoreLabel->setPosition(Vec2(_screenSize.width - 60,  _screenSize.height * 0.5 + 80));
    _player2ScoreLabel->setRotation(90);
    this->addChild(_player2ScoreLabel);
    
  7. 然后,我们将GameLayer变成一个多点触摸事件监听器,并告诉Director事件分发器GameLayer希望监听这些事件。最后,我们按照以下方式安排游戏的主循环:

    auto listener = EventListenerTouchAllAtOnce::create();
    listener->onTouchesBegan =  CC_CALLBACK_2(GameLayer::onTouchesBegan, this);
    listener->onTouchesMoved =  CC_CALLBACK_2(GameLayer::onTouchesMoved, this);
    listener->onTouchesEnded =  CC_CALLBACK_2(GameLayer::onTouchesEnded, this);
    _eventDispatcher->addEventListenerWithSceneGraphPriority(listener, this);
    //create main loop
    this->scheduleUpdate();
    return true;
    

发生了什么?

你为冰球游戏创建了游戏屏幕,使用你自己的精灵和标签。一旦所有元素都添加完毕,游戏屏幕应该看起来像这样:

发生了什么?

现在我们已经准备好处理玩家的屏幕触摸了。

行动时间 – 处理多点触摸

在这个游戏中,我们需要实现三种方法来处理触摸事件。每个方法都接收一个参数,即一个Touch对象的向量:

  1. 因此,添加我们的onTouchesBegan方法:

    void GameLayer::onTouchesBegan(const std::vector<Touch*> &touches, Event* event)
    {
       for( auto touch : touches) {
         if(touch != nullptr) {
            auto tap = touch->getLocation();
            for (auto player : _players) {
             if (player->boundingBox().containsPoint(tap)) {
                player->setTouch(touch);
             }
           }
         }
       }
    }
    

    如果你还记得,每个GameSprite都有一个_touch属性。

    因此,我们遍历触摸事件,获取它们在屏幕上的位置,遍历向量中的玩家,并确定触摸是否落在某个玩家身上。如果是,我们将触摸存储在玩家的_touch属性中(来自GameSprite类)。

    对于onTouchesMovedonTouchesEnded,也会重复类似的过程,所以你可以复制粘贴代码,只需替换_players数组循环内的内容即可。

  2. TouchesMoved中,当我们遍历玩家时,我们这样做:

    for (auto player : _players) {
      if (player->getTouch() != nullptr && player->getTouch() ==  touch) {
        Point nextPosition = tap;
       if (nextPosition.x < player->radius())
          nextPosition.x = player->radius();
       if (nextPosition.x > _screenSize.width - player->radius())
          nextPosition.x = _screenSize.width - player->radius();
       if (nextPosition.y < player->radius())
          nextPosition.y  = player->radius();
       if (nextPosition.y > _screenSize.height - player->radius())
          nextPosition.y = _screenSize.height - player->radius();
    
       //keep player inside its court
       if (player->getPositionY() < _screenSize.height* 0.5f) {
          if (nextPosition.y > _screenSize.height* 0.5 -  player->radius()) {
             nextPosition.y = _screenSize.height* 0.5 -  player->radius();
            }
       } else {
          if (nextPosition.y < _screenSize.height* 0.5 +  player->radius()) {
             nextPosition.y = _screenSize.height* 0.5 +  player->radius();
          }
       }              
       player->setNextPosition(nextPosition);
       player->setVector(Vec2(tap.x - player->getPositionX(),  tap.y - player->getPositionY()));
     }   
    }
    

    我们检查存储在玩家中的_touch属性是否是当前正在移动的。如果是,我们使用触摸的当前位置更新玩家的位置,但我们会检查新位置是否有效:玩家不能移动到屏幕外,也不能进入对手的球场。我们还更新玩家的移动向量;当我们将玩家与冰球碰撞时,我们需要这个向量。该向量基于玩家的位移。

  3. onTouchesEnded中,我们添加以下内容:

    for (auto player : _players) {
       if (player->getTouch() != nullptr && player->getTouch() == touch) {
         //if touch ending belongs to this player, clear it
         player->setTouch(nullptr);
         player->setVector(Vec2(0,0));
       }
    }
    

如果这个触摸是刚刚结束的,我们清除玩家内部存储的 _touch 属性。玩家也会停止移动,因此其向量被设置为 0。注意,我们不再需要触摸的位置;所以在 TouchesEnded 中可以跳过这部分逻辑。

发生了什么?

当你实现多触控逻辑时,这基本上是你必须做的:将单个触摸存储在数组或单个精灵中,这样你可以继续跟踪这些触摸。

现在,对于游戏的核心和灵魂——主循环。

是时候添加我们的主循环了

这是我们的游戏的核心——update 方法:

  1. 我们将使用一点摩擦力更新冰球的速率(0.98f)。如果没有发生碰撞,我们将存储迭代结束时冰球的下一个位置:

    void GameLayer::update (float dt) {
    
        auto ballNextPosition = _ball->getNextPosition();
        auto ballVector = _ball->getVector();
        ballVector *=  0.98f;
    
        ballNextPosition.x += ballVector.x;
        ballNextPosition.y += ballVector.y;
    
  2. 接下来是碰撞。我们将检查每个球员精灵和球之间的碰撞:

    float squared_radii = pow(_player1->radius() +  _ball->radius(), 2);
    for (auto player : _players) {
      auto playerNextPosition = player->getNextPosition();
      auto playerVector = player->getVector();  
      float diffx = ballNextPosition.x - player->getPositionX();
      float diffy = ballNextPosition.y - player->getPositionY();
      float distance1 = pow(diffx, 2) + pow(diffy, 2);
      float distance2 = pow(_ball->getPositionX() -  playerNextPosition.x, 2) + pow(_ball->getPositionY() -  playerNextPosition.y, 2);
    

    通过球和球员之间的距离来检查碰撞。以下图示说明了两个条件将触发碰撞:

    是时候添加我们的主循环了

  3. 如果球和球员之间的距离等于两个精灵半径之和,或者小于两个精灵半径之和,则存在碰撞:

    if (distance1 <= squared_radii || 
        distance2 <= squared_radii)  {
    
  4. 我们使用平方半径值,这样我们就不需要使用昂贵的平方根计算来获取距离值。所以前一个条件语句中的所有值都是平方的,包括距离。

  5. 这些条件既检查球员的当前位置也检查其下一个位置,这样球在迭代之间“穿过”球员精灵的风险就小了。

  6. 如果发生碰撞,我们获取球和球员的向量的幅度,并使用这个力将球推开。在这种情况下,我们更新球的下一个位置,并通过 SimpleAudioEngine 单例播放一个好听的声音效果(别忘了包含 SimpleAudioEngine.h 头文件并声明我们使用 CocosDenshion 命名空间):

        float mag_ball = pow(ballVector.x, 2) + pow(ballVector.y, 2);
        float mag_player = pow(playerVector.x, 2) + pow (playerVector.y, 2);
        float force = sqrt(mag_ball + mag_player);
        float angle = atan2(diffy, diffx);
    
            ballVector.x = force * cos(angle);
            ballVector.y = (force * sin(angle));
    
            ballNextPosition.x = playerNextPosition.x + (player->radius() + _ball->radius() + force) * cos(angle);
            ballNextPosition.y = playerNextPosition.y + (player->radius() + _ball->radius() + force) * sin(angle);
    
           SimpleAudioEngine::getInstance()->playEffect("hit.wav");
        }
    }
    
  7. 接下来,我们将检查球和屏幕边缘之间的碰撞。如果是这样,我们将球移回球场,并在这里播放我们的音效:

    if (ballNextPosition.x < _ball->radius()) {
        ballNextPosition.x = _ball->radius();
        ballVector.x *= -0.8f;
        SimpleAudioEngine::getInstance()->playEffect("hit.wav");
    }
    
    if (ballNextPosition.x > _screenSize.width - _ball->radius()) {
        ballNextPosition.x = _screenSize.width - _ball->radius();
        ballVector.x *= -0.8f;
        SimpleAudioEngine::getInstance()->playEffect("hit.wav");
    }
    
  8. 在球场的顶部和底部两侧,我们检查球是否通过我们之前定义的 GOAL_WIDTH 属性没有穿过任何一个球门,如下所示:

    if (ballNextPosition.y > _screenSize.height - _ball->radius()) {
        if (_ball->getPosition().x < _screenSize.width * 0.5f - GOAL_WIDTH * 0.5f || _ball->getPosition().x > _screenSize.width * 0.5f + GOAL_WIDTH * 0.5f) {
            ballNextPosition.y = _screenSize.height - _ball->radius();
            ballVector.y *= -0.8f;
            SimpleAudioEngine::getInstance()->playEffect("hit.wav");
        }
    }
    
    if (ballNextPosition.y < _ball->radius() ) {
        if (_ball->getPosition().x < _screenSize.width * 0.5f - GOAL_WIDTH * 0.5f || _ball->getPosition().x > _screenSize.width * 0.5f + GOAL_WIDTH * 0.5f) {
            ballNextPosition.y = _ball->radius();
            ballVector.y *= -0.8f;
            SimpleAudioEngine::getInstance()->playEffect("hit.wav");
        }
    }
    
  9. 我们最终更新球的信息,如果球穿过了球门(鼓声响起):

    _ball->setVector(ballVector);
    _ball->setNextPosition(ballNextPosition);
    
    //check for goals!
    if (ballNextPosition.y  < -_ball->radius() * 2) {
       this->playerScore(2);
    }
    
    if (ballNextPosition.y > _screenSize.height + _ball->radius() * 2) {
       this->playerScore(1);
    }
    
  10. 我们调用我们的辅助方法来得分,并且现在我们知道游戏中每个元素 nextPosition 的值,我们完成更新,放置所有元素:

    _player1->setPosition(_player1->getNextPosition());
    _player2->setPosition(_player2->getNextPosition());
    _ball->setPosition(_ball->getNextPosition()); 
    

发生了什么?

我们刚刚构建了游戏的主循环。每当你的游戏玩法依赖于精确的碰撞检测时,你无疑会应用类似的逻辑:现在的位置,下一个位置,碰撞检查,如果发生碰撞,则调整下一个位置。然后我们使用我们的辅助方法完成游戏。

现在剩下的只是更新分数。

是时候采取行动——更新分数

是时候在游戏中输入最后一个方法了。

  1. 我们首先播放一个漂亮的进球效果,并停止我们的球:

    void GameLayer::playerScore (int player) {
    
        SimpleAudioEngine::getInstance()->playEffect("score.wav");
    
        _ball->setVector(Vec2(0,0));
    
  2. 然后我们更新得分的玩家的分数,在这个过程中更新分数标签。并且球移动到刚刚得分的玩家的球场:

    char score_buffer[10];
    if (player == 1) {
        _player1Score++;
        _player1ScoreLabel->setString(std::to_string(_player1Score));
        _ball->setNextPosition(Vec2(_screenSize.width * 0.5, _screenSize.height * 0.5 + 2 * _ball->radius()));
    
        } else {
        _player2Score++;
        _player2ScoreLabel->setString(std::to_string(_player2Score));
        _ball->setNextPosition(Vec2(_screenSize.width * 0.5, _screenSize.height * 0.5 - 2 * _ball->radius()));
        }
    

    玩家被移动到他们的原始位置,并且他们的 _touch 属性被清除:

        _player1->setPosition(Vec2(_screenSize.width * 0.5, _player1->radius() * 2));
        _player2->setPosition(Vec2(_screenSize.width * 0.5, _screenSize.height - _player1->radius() * 2));
        _player1->setTouch(nullptr);
        _player2->setTouch(nullptr);
    }
    

发生了什么?

好吧,猜猜看!你刚刚完成了你的第一个 Cocos2d-x 游戏。我们以快速的速度完成了第一个游戏,但我们在过程中几乎触及了 Cocos2d-x 游戏开发的每一个领域。

如果你现在点击 运行,你应该能够玩游戏。如果你在运行本章的源代码时遇到任何问题,你也应该找到游戏的完整版本。

是时候将这个游戏带到 Android 平台上了!

是时候采取行动——在 Android 上运行游戏

是时候将游戏部署到 Android 平台上了。

  1. 按照从 HelloWorld 示例中的说明来将游戏导入到 Eclipse 中。

  2. 导航到 proj.android 文件夹,在文本编辑器中打开 AndroidManifest.xml 文件。然后,转到 jni 文件夹,在文本编辑器中打开 Android.mk 文件。

  3. AndroidManifest.xml 文件中,编辑 activity 标签中的以下行:

    android:screenOrientation="portrait"   
    
  4. 通过在 supports-screens 标签中添加这些行,你可以仅针对平板电脑进行目标定位:

    <supports-screens android:anyDensity="true"
              android:smallScreens="false"
              android:normalScreens="false"
              android:largeScreens="true"
              android:xlargeScreens="true"/>
    
  5. 虽然如果你只想针对平板电脑,你可能还希望针对 SDK 的后续版本,如下所示:

    <uses-sdk android:minSdkVersion="11"/>
    
  6. 接下来,让我们编辑 make 文件,所以打开 Android.mk 文件,并编辑 LOCAL_SRC_FILES 中的行,使其读取:

    LOCAL_SRC_FILES := hellocpp/main.cpp \
                       ../../Classes/AppDelegate.cpp \
                       ../../Classes/GameSprite.cpp \
                       ../../Classes/GameLayer.cpp 
    
  7. 保存它并运行你的应用程序(别忘了连接一个 Android 设备,在这种情况下,如果你使用了这里解释的设置,那么是一个平板电脑)。

发生了什么?

就这样!你还可以在 Eclipse 中编辑这些文件。

当你在命令行中构建 Cocos2d-x 项目时,你会看到一个消息说 hellocpp 目标正在重命名。但我认为这仍然是构建脚本中的错误,通常在 make 文件和文件夹结构中纠正它会带来更大的麻烦。所以现在,坚持使用 Android.mk 中的奇怪命名的 hellocpp

英雄,试试看

对代码进行任何修改。例如,添加一个额外的标签,然后从 Eclipse 重新发布。你可能发现在这个 IDE 中与项目一起工作比 Xcode 快。

很遗憾,迟早,Eclipse 会抛出它臭名昭著的脾气。如果你在导航器中打开了多个项目,常见的问题之一是其中一个或多个项目报告错误,例如找不到 java.lang.Object 类文件无法解析 java.lang.Object 类型。养成在打开 Eclipse 后立即清理项目并构建它的习惯,只保留打开的活动项目,但即使这样也可能失败。解决方案?重启 Eclipse,或者更好的方法是,从导航器(但不是从磁盘!)中删除项目,然后重新导入它。是的,我知道。欢迎来到 Eclipse!

摘要

你现在知道了如何添加精灵和标签,以及如何添加对两种屏幕分辨率的支持以及多点触控的支持。除了传递一个图像文件名之外,还有许多创建精灵的方法,我将在接下来的游戏中展示这些示例。

在这本书中,LabelTTF 将不会使用得太多。通常,它们适用于大量文本和不太频繁更新的文本;从现在起,我们将使用位图字体。

那么,让我们继续进行下一款游戏和动画。我保证不会让你打太多字。你应该让你的朋友帮你打字!

第四章.与精灵的乐趣 - 天空防御

是时候构建我们的第二个游戏了!这次,你将了解 Cocos2d-x 中动作的力量。我会向你展示如何仅通过运行 Cocos2d-x 中包含的各种动作命令来构建整个游戏,使你的精灵移动、旋转、缩放、淡入淡出、闪烁等。你还可以使用动作通过多个图像来动画化你的精灵,就像在电影中一样。所以,让我们开始吧。

在本章中,你将学习:

  • 如何使用精灵表优化你的游戏开发

  • 如何在你的游戏中使用位图字体

  • 实现和运行动作是多么简单

  • 如何缩放、旋转、摆动、移动和淡出精灵

  • 如何加载多个 .png 文件并使用它们来动画化精灵

  • 如何使用 Cocos2d-x 创建通用游戏

游戏 - 天空防御

来认识我们的压力山大的城市……这里填入你选择的名称。这是一个美好的日子,突然天空开始下落。流星正冲向城市,你的任务是保护它安全。

游戏中的玩家可以轻触屏幕开始生长一个炸弹。当炸弹足够大可以激活时,玩家再次轻触屏幕来引爆它。任何附近的流星都会爆炸成百万碎片。炸弹越大,爆炸越剧烈,可以击毁的流星越多。但炸弹越大,生长它所需的时间就越长。

但坏消息不止这些。天空中还会掉落医疗包,如果你允许它们落到地面,你将恢复一些能量。

游戏设置

这是一个通用游戏。它为 iPad retina 屏幕设计,并将缩放到适合所有其他屏幕。游戏将以横屏模式进行,且不需要支持多点触控。

开始项目

请从本书的支持页面(www.packtpub.com/support)下载文件 4198_04_START_PROJECT.zip。当你解压文件时,你会发现基本项目已经设置好,准备好供你工作。

创建此项目涉及的步骤与我们之前游戏中的类似。我使用的命令行是:

cocos new SkyDefense -p com.rengelbert.SkyDefense -l cpp -d /Users/rengelbert/Desktop/SkyDefense

在 Xcode 中,你必须将 Deployment Info 中的 Devices 字段设置为 Universal,并将 Device Family 字段设置为 Universal。在 RootViewController.mm 中,支持的界面方向设置为 Landscape

我们将要构建的游戏只需要一个类,GameLayer.cpp,你会发现这个类的接口已经包含了它所需的所有信息。

此外,一些更简单或过时的逻辑已经在实现文件中就位了。但我会随着我们游戏的发展来讲解这些。

为通用应用添加屏幕支持

在上一个游戏中,我们只针对 iPad 尺寸的屏幕。现在事情变得有点复杂,因为我们在通用游戏中增加了对较小屏幕的支持,以及一些最常见的 Android 屏幕尺寸。

因此,打开AppDelegate.cpp文件。在applicationDidFinishLaunching方法内部,我们现在有以下代码:

auto screenSize = glview->getFrameSize();
auto designSize = Size(2048, 1536);
glview->setDesignResolutionSize(designSize.width, designSize.height,  ResolutionPolicy::EXACT_FIT);
std::vector<std::string> searchPaths;
if (screenSize.height > 768) {
   searchPaths.push_back("ipadhd");
   director->setContentScaleFactor(1536/designSize.height);
} else if (screenSize.height > 320) {
   searchPaths.push_back("ipad");
   director->setContentScaleFactor(768/designSize.height);
} else {
   searchPaths.push_back("iphone");
   director->setContentScaleFactor(380/designSize.height);
}
auto fileUtils = FileUtils::getInstance();
fileUtils->setSearchPaths(searchPaths);

再次提醒,我们告诉我们的GLView对象(我们的 OpenGL 视图)我们为某个屏幕尺寸(iPad 视网膜屏)设计了游戏,并且我们再次希望我们的游戏屏幕调整大小以匹配设备上的屏幕(ResolutionPolicy::EXACT_FIT)。

然后我们根据设备的屏幕尺寸确定从哪里加载我们的图像。我们有 iPad 视网膜屏的美术资源,然后是普通 iPad,它由 iPhone 视网膜屏共享,以及普通 iPhone 的美术资源。

我们最后根据设计的目标设置缩放因子。

添加背景音乐

仍然在AppDelegate.cpp中,我们加载游戏中将使用的声音文件,包括一个background.mp3(由 Kevin MacLeod 从incompetech.com提供),我们通过以下命令加载它:

auto audioEngine = SimpleAudioEngine::getInstance();
audioEngine->preloadBackgroundMusic(fileUtils->fullPathForFilename("background.mp3").c_str());

我们最后将音效的音量稍微调低:

//lower playback volume for effects
audioEngine->setEffectsVolume(0.4f);

对于背景音乐的音量,您必须使用setBackgroundMusicVolume。如果您在游戏中创建某种音量控制,这些就是您根据用户的偏好调整音量的调用。

初始化游戏

现在回到GameLayer.cpp。如果您查看我们的init方法,您会看到游戏通过调用三个方法进行初始化:createGameScreencreatePoolscreateActions

我们将在第一个方法内部创建所有屏幕元素,然后创建对象池,这样我们就不需要在主循环中实例化任何精灵;我们将在createActions方法内部创建游戏中使用的所有主要动作。

游戏初始化完成后,我们开始播放背景音乐,并将其should loop参数设置为true

SimpleAudioEngine::getInstance()-  >playBackgroundMusic("background.mp3", true);

我们再次存储屏幕尺寸以供将来参考,并使用一个_running布尔值来表示游戏状态。

如果现在运行游戏,您应该只能看到背景图像:

初始化游戏

在 Cocos2d-x 中使用精灵图集

精灵图集是将多个图像组合到一个图像文件中的方法。为了用这些图像之一纹理化精灵,您必须知道该图像在精灵图集中的位置信息(其矩形)。

精灵图集通常分为两个文件:一个是图像文件,另一个是数据文件,它描述了在图像中可以找到单个纹理的位置。

我使用TexturePacker为游戏创建了这些文件。您可以在Resources文件夹内的ipadipadhdiphone文件夹中找到它们。有一个sprite_sheet.png文件用于图像,还有一个sprite_sheet.plist文件,它描述了图像中的单个帧。

这就是sprite_sheet.png文件的外观:

在 Cocos2d-x 中使用精灵图集

批量绘制精灵

在 Cocos2d-x 中,精灵图可以与一个专门的节点一起使用,称为 SpriteBatchNode。这个节点可以在你希望使用多个共享相同源图像的精灵时使用。所以你可以有多个 Sprite 类的实例,例如使用 bullet.png 纹理。如果源图像是精灵图,你可以有多个精灵实例,显示尽可能多的不同纹理,就像你可以在精灵图中打包的那样。

使用 SpriteBatchNode,你可以显著减少游戏渲染阶段的调用次数,这有助于针对性能较弱的系统,尽管在更现代的设备上并不明显。

让我展示如何创建一个 SpriteBatchNode

行动时间 - 创建 SpriteBatchNode

让我们开始实现 GameLayer.cpp 中的 createGameScreen 方法。在添加 bg 精灵的行下面,我们实例化我们的批处理节点:

void GameLayer::createGameScreen() {

  //add bg
  auto bg = Sprite::create("bg.png");
  ...

  SpriteFrameCache::getInstance()->
  addSpriteFramesWithFile("sprite_sheet.plist");

  _gameBatchNode = SpriteBatchNode::create("sprite_sheet.png");
  this->addChild(_gameBatchNode);

为了从精灵图中创建批处理节点,我们首先将 sprite_sheet.plist 文件中描述的所有帧信息加载到 SpriteFrameCache 中。然后我们使用 sprite_sheet.png 文件创建批处理节点,这是所有添加到该批处理节点的精灵共享的源纹理。(背景图像不是精灵图的一部分,所以我们将其单独添加,在我们将 _gameBatchNode 添加到 GameLayer 之前。)

现在我们可以开始将东西放入 _gameBatchNode 中。

  1. 首先,是城市:

    for (int i = 0; i < 2; i++) {
      auto sprite = Sprite::createWithSpriteFrameName ("city_dark.png");
        sprite->setAnchorPoint(Vec2(0.5,0));
      sprite->setPosition(_screenSize.width * (0.25f + i *  0.5f),0));
      _gameBatchNode->addChild(sprite, kMiddleground);
    
      sprite = Sprite::createWithSpriteFrameName("city_light.png");
      sprite->setAnchorPoint(Vec2(0.5,0));
      sprite->setPosition(Vec2(_screenSize.width * (0.25f + i *  0.5f),
      _screenSize.height * 0.1f));
      _gameBatchNode->addChild(sprite, kBackground);
    }
    
  2. 然后是树木:

    //add trees
    for (int i = 0; i < 3; i++) {
      auto sprite = Sprite::createWithSpriteFrameName("trees.png");
      sprite->setAnchorPoint(Vec2(0.5f, 0.0f));
      sprite->setPosition(Vec2(_screenSize.width * (0.2f + i * 0.3f),0));
      _gameBatchNode->addChild(sprite, kForeground);
    
    }
    

    注意,在这里我们通过传递精灵帧名称来创建精灵。这些帧名称的 ID 是通过我们的 sprite_sheet.plist 文件加载到 SpriteFrameCache 中的。

  3. 到目前为止的屏幕由两个实例的 city_dark.png 在屏幕底部拼接而成,以及两个实例的 city_light.png 也进行拼接。一个需要出现在另一个之上,为此我们使用在 GameLayer.h 中声明的枚举值:

    enum {
      kBackground,
      kMiddleground,
      kForeground
    };
    
  4. 我们使用 addChild(Node, zOrder) 方法通过不同的 z 值将精灵层叠在一起。

    例如,当我们后来添加三个显示 trees.png 精灵帧的精灵时,我们使用枚举列表中找到的最高 z 值将它们添加到所有之前的精灵之上,这个值是 kForeground

注意

为什么费尽心机去拼接图像而不是使用一张大图,或者将其中一些与背景图像结合?因为我想在单个精灵图中包含尽可能多的图像,并使该精灵图尽可能小,以展示所有你可以使用和优化的精灵图方法。在这个特定的游戏中,这并不是必需的。

发生了什么?

我们开始创建游戏的初始屏幕。我们使用 SpriteBatchNode 来包含所有使用精灵表图片的精灵。所以 SpriteBatchNode 的行为就像任何节点一样——作为一个容器。我们可以通过操作它们的 z 排序在批处理节点内层叠单个精灵。

Cocos2d-x 中的位图字体

Cocos2d-x 的 Label 类有一个静态的 create 方法,它使用位图图像来显示字符。

我们在这里使用的位图图像是用 GlyphDesigner 程序创建的,本质上,它就像精灵表一样工作。事实上,Label 扩展了 SpriteBatchNode,所以它的行为就像一个批处理节点。

你需要的所有单个字符的图像都打包在一个 PNG 文件(font.png)中,然后是一个描述每个字符位置的描述文件(font.fnt)。以下截图显示了我们的游戏中的字体精灵表:

Cocos2d-x 中的位图字体

Label 和常规 SpriteBatchNode 类之间的区别在于数据文件还向 Label 对象提供了如何使用此字体书写的信息。换句话说,如何正确地分配字符和行。

我们在游戏中使用的 Label 对象是用数据文件名和它们的初始字符串值实例化的:

_scoreDisplay = Label::createWithBMFont("font.fnt", "0");

标签的值通过 setString 方法更改:

_scoreDisplay->setString("1000");

注意事项

就像游戏中每张图片一样,我们在 Resources 文件夹中也有不同版本的 font.fntfont.png,每个屏幕定义一个。FileUtils 将再次承担寻找正确文件的重任,以适应正确的屏幕。

现在让我们为我们的游戏创建标签。

行动时间 - 创建位图字体标签

创建位图字体与创建批处理节点有些相似。

  1. 继续使用 createGameScreen 方法,向 score 标签添加以下行:

    _scoreDisplay = Label::createWithBMFont("font.fnt", "0");
    _scoreDisplay->setAnchorPoint(Vec2(1,0.5));
    _scoreDisplay->setPosition(Vec2 (_screenSize.width * 0.8f, _screenSize.height * 0.94f));
    this->addChild(_scoreDisplay);
    

    然后添加一个标签来显示能量等级,并将其水平对齐设置为 Right

    _energyDisplay = Label::createWithBMFont("font.fnt", "100%", TextHAlignment::RIGHT);
    _energyDisplay->setPosition(Vec2 (_screenSize.width * 0.3f, _screenSize.height * 0.94f));
    this->addChild(_energyDisplay);
    
  2. _energyDisplay 标签旁边出现的图标添加以下行:

    auto icon = Sprite::createWithSpriteFrameName("health_icon.png");
    icon->setPosition( Vec2(_screenSize. width * 0.15f,  _screenSize.height * 0.94f) );
    _gameBatchNode->addChild(icon, kBackground);
    

刚才发生了什么?

我们刚刚在 Cocos2d-x 中创建了第一个位图字体对象。现在让我们完成创建游戏精灵。

行动时间 - 添加最终屏幕精灵

我们最后需要创建的精灵是云、炸弹和冲击波,以及我们的游戏状态消息。

  1. 回到 createGameScreen 方法,将云添加到屏幕上:

    for (int i = 0; i < 4; i++) {
      float cloud_y = i % 2 == 0 ? _screenSize.height * 0.4f : _screenSize.height * 0.5f;
      auto cloud = Sprite::createWithSpriteFrameName("cloud.png");
      cloud->setPosition(Vec2 (_screenSize.width * 0.1f + i * _screenSize.width * 0.3f,  cloud_y));
      _gameBatchNode->addChild(cloud, kBackground);
      _clouds.pushBack(cloud);
    }
    
  2. 创建 _bomb 精灵;玩家在触摸屏幕时会增长

    _bomb = Sprite::createWithSpriteFrameName("bomb.png");
    _bomb->getTexture()->generateMipmap();
    _bomb->setVisible(false);
    
    auto size = _bomb->getContentSize();
    
    //add sparkle inside bomb sprite
    auto sparkle = Sprite::createWithSpriteFrameName("sparkle.png");
    sparkle->setPosition(Vec2(size.width * 0.72f, size.height *  0.72f));
    _bomb->addChild(sparkle, kMiddleground, kSpriteSparkle);
    
    //add halo inside bomb sprite
    auto halo = Sprite::createWithSpriteFrameName ("halo.png");
    halo->setPosition(Vec2(size.width * 0.4f, size.height *  0.4f));
    _bomb->addChild(halo, kMiddleground, kSpriteHalo);
    _gameBatchNode->addChild(_bomb, kForeground);
    
  3. 然后创建 _shockwave 精灵,它在 _bomb 爆炸后出现:

    _shockWave = Sprite::createWithSpriteFrameName("shockwave.png");
    _shockWave->getTexture()->generateMipmap();
    _shockWave->setVisible(false);
    _gameBatchNode->addChild(_shockWave);
    
  4. 最后,添加屏幕上出现的两条消息,一条用于我们的 intro 状态,另一条用于 gameover 状态:

    _introMessage = Sprite::createWithSpriteFrameName("logo.png");
    _introMessage->setPosition(Vec2 (_screenSize.width * 0.5f, _screenSize.height * 0.6f));
    _introMessage->setVisible(true);
    this->addChild(_introMessage, kForeground);
    
    _gameOverMessage = Sprite::createWithSpriteFrameName ("gameover.png");
    _gameOverMessage->setPosition(Vec2 (_screenSize.width * 0.5f, _screenSize.height * 0.65f));
    _gameOverMessage->setVisible(false);
    this->addChild(_gameOverMessage, kForeground);
    

刚才发生了什么?

在之前的代码中关于精灵有很多新的信息。所以让我们仔细地过一遍:

  • 我们首先添加了云。我们将精灵放入一个向量中,以便以后可以移动云。注意,它们也是我们批处理节点的一部分。

  • 接下来是炸弹精灵和我们的第一个新调用:

    _bomb->getTexture()->generateMipmap();
    
  • 使用这种方法,我们告诉框架创建该纹理的逐级细节(mipmaps)的逐级减小尺寸的抗锯齿副本,因为我们稍后将要将其缩小。当然,这是可选的;精灵可以在不首先生成 mipmaps 的情况下调整大小,但如果您注意到缩放精灵时质量下降,您可以通过为它们的纹理创建 mipmaps 来修复这个问题。

    注意

    纹理必须具有所谓的 POT(2 的幂:2、4、8、16、32、64、128、256、512、1024、2048 等等)的大小值。OpenGL 中的纹理必须始终以这种方式进行尺寸设置;如果不是这样,Cocos2d-x 将执行以下两种操作之一:它将在内存中调整纹理的大小,添加透明像素,直到图像达到 POT 大小,或者停止执行并抛出断言。对于用于 mipmaps 的纹理,框架将停止执行非 POT 纹理。

  • 我将sparklehalo精灵作为子节点添加到_bomb精灵中。这将利用节点的容器特性为我们带来优势。当我放大炸弹时,所有子节点也会随之放大。

  • 注意,我还为halosparkleaddChild方法使用了第三个参数:

    bomb->addChild(halo, kMiddleground, kSpriteHalo);
    
  • 这个第三个参数是从GameLayer.h中声明的另一个枚举列表中的整数标签。我可以使用这个标签来检索从精灵中特定的子节点,如下所示:

    auto halo = (Sprite *)  bomb->getChildByTag(kSpriteHalo);
    

我们现在已经设置了游戏屏幕:

发生了什么?

接下来是对象池。

是时候行动了——创建我们的对象池

池只是对象的向量。以下是创建它们的步骤:

  1. createPools方法内部,我们首先为流星创建一个池:

    void GameLayer::createPools() {
      int i;
      _meteorPoolIndex = 0;
      for (i = 0; i < 50; i++) {
      auto sprite = Sprite::createWithSpriteFrameName("meteor.png");
      sprite->setVisible(false);
      _gameBatchNode->addChild(sprite, kMiddleground, kSpriteMeteor);
      _meteorPool.pushBack(sprite);
    }
    
  2. 然后我们为健康包创建一个对象池:

    _healthPoolIndex = 0;
    for (i = 0; i < 20; i++) {
      auto sprite = Sprite::createWithSpriteFrameName("health.png");
      sprite->setVisible(false);
      sprite->setAnchorPoint(Vec2(0.5f, 0.8f));
      _gameBatchNode->addChild(sprite, kMiddleground, kSpriteHealth);
      _healthPool.pushBack(sprite);
    }
    
  3. 随着游戏的进行,我们将使用相应的池索引从向量中检索对象。

发生了什么?

我们现在有一个不可见的流星精灵向量和一个不可见的健康精灵向量。我们将使用它们各自的池索引在需要时从向量中检索这些精灵,正如您一会儿将看到的。但首先我们需要处理动作和动画。

注意

使用对象池,我们在主循环中减少了实例化的数量,并且它允许我们永远不会销毁可以重用的任何东西。但如果您需要从节点中移除一个子节点,请使用->removeChild->removeChildByTag(如果存在标签)。

动作概述

如果您还记得,节点将存储有关位置、缩放、旋转、可见性和不透明度的信息。在 Cocos2d-x 中,有一个Action类可以随时间改变这些值中的每一个,从而实现这些转换的动画。

动作通常使用静态方法create创建。这些动作中的大多数都是基于时间的,所以通常您需要传递给动作的第一个参数是动作的时间长度。例如:

auto fadeout = FadeOut::create(1.0f);

这将创建一个渐隐动作,它将在一秒钟内完成。您可以在精灵或节点上运行它,如下所示:

mySprite->runAction(fadeout);

Cocos2d-x 有一个非常灵活的系统,允许我们创建任何组合的动作和变换,以实现我们想要的任何效果。

例如,你可以选择创建一个包含多个动作的动作序列(Sequence);或者你可以对你的动作应用缓动效果(EaseInEaseOut 等)。你可以选择重复一个动作一定次数(Repeat)或无限重复(RepeatForever);你还可以添加回调函数,以便在动作完成后调用(通常在 Sequence 动作内部)。

是时候进行动作了——使用 Cocos2d-x 创建动作

使用 Cocos2d-x 创建动作是一个非常简单的过程:

  1. 在我们的 createActions 方法中,我们将实例化我们可以在游戏中重复使用的动作。让我们创建我们的第一个动作:

    void GameLayer::createActions() {
     //swing action for health drops
     auto easeSwing = Sequence::create(
     EaseInOut::create(RotateTo::create(1.2f, -10), 2),
     EaseInOut::create(RotateTo::create(1.2f, 10), 2),
     nullptr);//mark the end of a sequence with a nullptr
     _swingHealth = RepeatForever::create( (ActionInterval *) easeSwing );
     _swingHealth->retain();
    
  2. 动作可以以许多不同的形式组合。在这里,保留的 _swingHealth 动作是一个 SequenceRepeatForever 动作,它将首先以一个方向旋转健康精灵,然后以另一个方向旋转,EaseInOut 包裹着 RotateTo 动作。RotateTo 需要 1.2 秒来首先将精灵旋转到 -10 度,然后旋转到 10 度。缓动函数的值为 2,我建议你尝试一下,以了解它在视觉上的意义。接下来我们再添加三个动作:

    //action sequence for shockwave: fade out, callback when  //done
    _shockwaveSequence = Sequence::create(
      FadeOut::create(1.0f),
      CallFunc::create(std::bind(&GameLayer::shockwaveDone, this)), nullptr);
    _shockwaveSequence->retain();
    
    //action to grow bomb
    _growBomb = ScaleTo::create(6.0f, 1.0);
    _growBomb->retain();
    
    //action to rotate sprites
    auto rotate = RotateBy::create(0.5f ,  -90);
    _rotateSprite = RepeatForever::create( rotate );
    _rotateSprite->retain();
    
  3. 首先,另一个 Sequence。这将使精灵淡出并调用 shockwaveDone 函数,该函数已经在类中实现,并在调用时将 _shockwave 精灵变为不可见。

  4. 最后一个是 RotateBy 动作的 RepeatForever 动作。在半秒内,执行此动作的精灵将旋转 -90 度,并且会不断重复这样做。

刚才发生了什么?

你刚刚看到了如何在 Cocos2d-x 中创建动作,以及框架如何允许进行各种组合以实现任何效果。

起初阅读 Sequence 动作并理解其工作原理可能有些困难,但一旦将其分解为各个部分,逻辑就很容易理解了。

但我们还没有完成 createActions 方法。接下来是精灵动画。

在 Cocos2d-x 中动画精灵

需要记住的关键点是,动画只是另一种类型的动作,它会在一段时间内改变精灵使用的纹理。

为了创建一个动画动作,你首先需要创建一个 Animation 对象。该对象将存储有关你希望在动画中使用的不同精灵帧的所有信息,动画的长度(以秒为单位),以及它是否循环。

使用这个 Animation 对象,然后创建一个 Animate 动作。让我们看看。

是时候进行动作了——创建动画

动画是一种特殊类型的动作,需要一些额外的步骤:

  1. 在同一个 createActions 方法中,添加游戏中的两个动画的代码行。首先,我们从当流星到达城市时显示爆炸动画的动画开始。我们首先将帧加载到 Animation 对象中:

    auto animation = Animation::create();
    int i;
    for(i = 1; i <= 10; i++) {
      auto name = String::createWithFormat("boom%i.png", i);
      auto frame = SpriteFrameCache::getInstance()->getSpriteFrameByName(name->getCString());
      animation->addSpriteFrame(frame);
    }
    
  2. 然后,我们在 Animate 动作中使用 Animation 对象:

    animation->setDelayPerUnit(1 / 10.0f);
    animation->setRestoreOriginalFrame(true);
    _groundHit = 
      Sequence::create(
        MoveBy::create(0, Vec2(0,_screenSize.height * 0.12f)),
        Animate::create(animation),
       CallFuncN::create(CC_CALLBACK_1(GameLayer::animationDone, this)), nullptr);
    _groundHit->retain();
    
  3. 相同的步骤被重复使用以创建其他爆炸动画,当玩家击中流星或健康包时使用。

    animation = Animation::create();
    for(int i = 1; i <= 7; i++) {
     auto name = String::createWithFormat("explosion_small%i.png", i);
     auto frame = SpriteFrameCache::getInstance()->getSpriteFrameByName(name->getCString());
     animation->addSpriteFrame(frame);
    }
    
    animation->setDelayPerUnit(0.5 / 7.0f);
    animation->setRestoreOriginalFrame(true);
    _explosion = Sequence::create(
         Animate::create(animation),
       CallFuncN::create(CC_CALLBACK_1(GameLayer::animationDone, this)), nullptr);
    _explosion->retain();
    

发生了什么?

我们在 Cocos2d-x 中创建了两种非常特殊的行为实例:Animate。以下是我们的操作步骤:

  • 首先,我们创建了一个 Animation 对象。该对象持有动画中使用的所有纹理的引用。帧被命名为,以便它们可以在循环中轻松连接(boom1boom2boom3 等等)。第一个动画有 10 帧,第二个有 7 帧。

  • 纹理(或帧)是我们从 SpriteFrameCache 中获取的 SpriteFrame 对象,如您所记得,它包含来自 sprite_sheet.plist 数据文件的所有信息。因此,帧在我们的精灵图中。

  • 然后当所有帧都就绪后,我们通过将动画所需的总秒数除以总帧数来确定每帧的延迟。

  • setRestoreOriginalFrame 方法在这里很重要。如果我们把 setRestoreOriginalFrame 设置为 true,那么动画结束后精灵将恢复到其原始外观。例如,如果我有一个将在流星精灵上运行的爆炸动画,那么在爆炸动画结束时,精灵将恢复显示流星纹理。

  • 是时候进行实际操作了。Animate 接收 Animation 对象作为其参数。(在第一个动画中,我们在爆炸出现之前移动精灵的位置,因此有一个额外的 MoveBy 方法。)

  • 在这两种情况下,我调用了类中已经实现的一个 animationDone 回调。这使得调用精灵变得不可见:

    void GameLayer::animationDone (Node* pSender) {
      pSender->setVisible(false);
    }
    

    注意

    我们本可以使用相同的方法为两个回调(animationDoneshockwaveDone)进行操作,因为它们完成的是相同的事情。但我想要展示一个接收节点作为参数的回调,这个节点进行了调用,另一个没有。分别是 CallFuncNCallFunc,它们被用于我们刚刚创建的动作序列中。

是时候让我们的游戏开始跳动!

好的,我们已经将主要元素放置到位,并准备好添加运行游戏的最后一点逻辑。但这一切将如何运作呢?

我们将使用倒计时系统来添加新的流星和新的健康包,以及一个逐渐使游戏更难玩的倒计时。

在触摸时,如果游戏未运行,玩家将开始游戏,并在游戏过程中添加炸弹并爆炸它们。爆炸会产生冲击波。

在更新时,我们将检查我们的 _shockwave 精灵(如果可见)与所有下落物体之间的碰撞。就这样。Cocos2d-x 将通过我们创建的动作和回调来处理所有其余的事情!

让我们先实现我们的触摸事件。

行动时间 - 处理触摸事件

是时候把玩家带到我们的派对上了:

  1. 是时候实现我们的onTouchBegan方法了。我们首先处理两个游戏状态,introgame over

    bool GameLayer::onTouchBegan (Touch * touch, Event * event){
    
      //if game not running, we are seeing either intro or  //gameover
      if (!_running) {
        //if intro, hide intro message
        if (_introMessage->isVisible()) {
          _introMessage->setVisible(false);
    
          //if game over, hide game over message 
        } else if (_gameOverMessage->isVisible()) {
          SimpleAudioEngine::getInstance()->stopAllEffects();
          _gameOverMessage->setVisible(false);
    
        }
    
        this->resetGame();
        return true;
      }
    
  2. 在这里,我们检查游戏是否没有运行。如果没有运行,我们检查我们的消息是否可见。如果_introMessage是可见的,我们就隐藏它。如果_gameOverMessage是可见的,我们就停止所有当前音效并隐藏该消息。然后我们调用一个名为resetGame的方法,它将所有游戏数据(能量、得分和倒计时)重置为其初始值,并将_running设置为true

  3. 接下来我们处理触摸。但我们每次只需要处理一个,所以我们使用Set上的->anyObject()

    auto touch = (Touch *)pTouches->anyObject();
    
    if (touch) {
    
      //if bomb already growing...
      if (_bomb->isVisible()) {
        //stop all actions on bomb, halo and sparkle
        _bomb->stopAllActions();
        auto child = (Sprite *) _bomb->getChildByTag(kSpriteHalo);
        child->stopAllActions();
        child = (Sprite *) _bomb->getChildByTag(kSpriteSparkle);
        child->stopAllActions();
    
        //if bomb is the right size, then create shockwave
        if (_bomb->getScale() > 0.3f) {
          _shockWave->setScale(0.1f);
          _shockWave->setPosition(_bomb->getPosition());
          _shockWave->setVisible(true);
          _shockWave->runAction(ScaleTo::create(0.5f, _bomb->getScale() * 2.0f));
          _shockWave->runAction(_shockwaveSequence->clone());
          SimpleAudioEngine::getInstance()->playEffect("bombRelease.wav");
    
        } else {
          SimpleAudioEngine::getInstance()->playEffect("bombFail.wav");
        }
        _bomb->setVisible(false);
        //reset hits with shockwave, so we can count combo hits
        _shockwaveHits = 0;
    
     //if no bomb currently on screen, create one
     } else {
        Point tap = touch->getLocation();
        _bomb->stopAllActions();
        _bomb->setScale(0.1f);
        _bomb->setPosition(tap);
        _bomb->setVisible(true);
        _bomb->setOpacity(50);
        _bomb->runAction(_growBomb->clone());
    
         auto child = (Sprite *) _bomb->getChildByTag(kSpriteHalo);
         child->runAction(_rotateSprite->clone());
         child = (Sprite *) _bomb->getChildByTag(kSpriteSparkle);
         child->runAction(_rotateSprite->clone());
      }
    }
    
  4. 如果_bomb是可见的,这意味着它已经在屏幕上生长了。所以当触摸时,我们在炸弹上使用stopAllActions()方法,并且使用stopAllActions()方法在其子项上,这些子项是通过我们的标签检索到的:

    child = (Sprite *) _bomb->getChildByTag(kSpriteHalo);
    child->stopAllActions();
    child = (Sprite *) _bomb->getChildByTag(kSpriteSparkle);
    child->stopAllActions();
    
  5. 如果_bomb的大小正确,我们就开始我们的_shockwave。如果它不正确,我们就播放一个炸弹故障音效;没有爆炸,并且_shockwave不会被显示出来。

  6. 如果我们有爆炸,那么_shockwave精灵的缩放设置为10%。它放置在炸弹相同的地点,并且我们对它执行几个动作:我们将_shockwave精灵的缩放增加到爆炸时炸弹的两倍,并运行我们之前创建的_shockwaveSequence的副本。

  7. 最后,如果屏幕上没有可见的_bomb,我们就创建一个。然后我们在_bomb精灵及其子项上运行之前创建的动作的克隆。当_bomb生长时,其子项也会生长。但是当子项旋转时,炸弹不会:父项改变其子项,但子项不会改变它们的父项。

刚才发生了什么?

我们刚刚添加了游戏核心逻辑的一部分。玩家通过触摸创建和爆炸炸弹来阻止陨石到达城市。现在我们需要创建我们的下落物体。但首先,让我们设置我们的倒计时和游戏数据。

是时候开始和重新开始游戏了

让我们添加开始和重新开始游戏的逻辑。

  1. 让我们来编写resetGame函数的实现:

    void GameLayer::resetGame(void) {
        _score = 0;
        _energy = 100;
    
        //reset timers and "speeds"
        _meteorInterval = 2.5;
        _meteorTimer = _meteorInterval * 0.99f;
        _meteorSpeed = 10;//in seconds to reach ground
        _healthInterval = 20;
        _healthTimer = 0;
        _healthSpeed = 15;//in seconds to reach ground
    
        _difficultyInterval = 60;
        _difficultyTimer = 0;
    
        _running = true;
    
        //reset labels
        _energyDisplay->setString(std::to_string((int) _energy) + "%");
        _scoreDisplay->setString(std::to_string((int) _score));
    }
    
  2. 接下来,添加stopGame的实现:

    void GameLayer::stopGame() {
    
        _running = false;
    
        //stop all actions currently running
        int i;
        int count = (int) _fallingObjects.size();
    
        for (i = count-1; i >= 0; i--) {
            auto sprite = _fallingObjects.at(i);
            sprite->stopAllActions();
            sprite->setVisible(false);
            _fallingObjects.erase(i);
        }
        if (_bomb->isVisible()) {
            _bomb->stopAllActions();
            _bomb->setVisible(false);
            auto child = _bomb->getChildByTag(kSpriteHalo);
            child->stopAllActions();
            child = _bomb->getChildByTag(kSpriteSparkle);
            child->stopAllActions();
        }
        if (_shockWave->isVisible()) {
            _shockWave->stopAllActions();
            _shockWave->setVisible(false);
        }
        if (_ufo->isVisible()) {
            _ufo->stopAllActions();
            _ufo->setVisible(false);
            auto ray = _ufo->getChildByTag(kSpriteRay);
            ray->stopAllActions();
            ray->setVisible(false);
        }
    }
    

刚才发生了什么?

通过这些方法我们控制游戏玩法。我们通过resetGame()使用默认值开始游戏,并通过stopGame()停止所有动作。

类中已经实现了随着时间推移使游戏更难的方法。如果你看看这个方法(increaseDifficulty),你会看到它减少了陨石之间的间隔,并减少了陨石到达地面的时间。

现在我们只需要update方法来运行倒计时和检查碰撞。

是时候更新游戏了

我们已经在update函数内部有了更新倒计时的代码。如果时间到了,我们需要添加陨石或医疗包,我们就这么做。如果时间到了,我们需要让游戏更难玩,我们也这么做。

注意

可以使用一个动作来处理这些计时器:一个 Sequence 动作和一个 Delay 动作对象以及一个回调。但使用这些倒计时有一些优点。重置它们和更改它们更容易,并且我们可以直接将它们带入我们的主循环。

现在是添加我们的主循环的时候了:

  1. 我们需要做的是检查碰撞。所以添加以下代码:

    if (_shockWave->isVisible()) {
     count = (int) _fallingObjects.size();
     for (i = count-1; i >= 0; i--) {
       auto sprite =  _fallingObjects.at(i);
       diffx = _shockWave->getPositionX() - sprite->getPositionX();
       diffy = _shockWave->getPositionY() - sprite->getPositionY();
       if (pow(diffx, 2) + pow(diffy, 2) <= pow(_shockWave->getBoundingBox().size.width * 0.5f, 2)) {
        sprite->stopAllActions();
        sprite->runAction( _explosion->clone());
        SimpleAudioEngine::getInstance()->playEffect("boom.wav");
        if (sprite->getTag() == kSpriteMeteor) {
          _shockwaveHits++;
          _score += _shockwaveHits * 13 + _shockwaveHits * 2;
        }
        //play sound
        _fallingObjects.erase(i);
      }
     }
     _scoreDisplay->setString(std::to_string(_score));
    }
    
  2. 如果 _shockwave 是可见的,我们检查它和 _fallingObjects 向量中每个精灵之间的距离。如果我们击中任何流星,我们就增加 _shockwaveHits 属性的值,这样我们就可以奖励玩家多次击中。接下来我们移动云朵:

    //move clouds
    for (auto sprite : _clouds) {
      sprite->setPositionX(sprite->getPositionX() + dt * 20);
      if (sprite->getPositionX() > _screenSize.width + sprite->getBoundingBox().size.width * 0.5f)
        sprite->setPositionX(-sprite->getBoundingBox().size.width * 0.5f);
    }
    
  3. 我选择不使用 MoveTo 动作来展示云朵,以显示可以由简单动作替换的代码量。如果不是因为 Cocos2d-x 动作,我们就必须实现移动、旋转、摆动、缩放和爆炸所有精灵的逻辑!

  4. 最后:

    if (_bomb->isVisible()) {
       if (_bomb->getScale() > 0.3f) {
          if (_bomb->getOpacity() != 255)
             _bomb->setOpacity(255);
       }
    }
    
  5. 我们通过改变炸弹准备爆炸时的不透明度,给玩家一个额外的视觉提示。

刚才发生了什么?

当你不需要担心更新单个精灵时,主循环相当简单,因为我们的动作会为我们处理这些。我们基本上只需要运行精灵之间的碰撞检测,并确定何时向玩家投掷新的东西。

因此,现在我们唯一要做的就是当计时器结束时从池中抓取流星和健康包。让我们直接开始吧。

行动时间 - 从对象池中检索对象

我们只需要使用正确的索引从各自的向量中检索对象:

  1. 要检索流星精灵,我们将使用 resetMeteor 方法:

    void GameLayer::resetMeteor(void) {
       //if too many objects on screen, return
        if (_fallingObjects.size() > 30) return;
    
        auto meteor = _meteorPool.at(_meteorPoolIndex);
          _meteorPoolIndex++;
        if (_meteorPoolIndex == _meteorPool.size()) 
          _meteorPoolIndex = 0;
          int meteor_x = rand() % (int) (_screenSize.width * 0.8f) + _screenSize.width * 0.1f;
       int meteor_target_x = rand() % (int) (_screenSize.width * 0.8f) + _screenSize.width * 0.1f;
    
        meteor->stopAllActions();
        meteor->setPosition(Vec2(meteor_x, _screenSize.height + meteor->getBoundingBox().size.height * 0.5));
    
        //create action
        auto  rotate = RotateBy::create(0.5f ,  -90);
        auto  repeatRotate = RepeatForever::create( rotate );
        auto  sequence = Sequence::create (
                   MoveTo::create(_meteorSpeed, Vec2(meteor_target_x, _screenSize.height * 0.15f)),
                   CallFunc::create(std::bind(&GameLayer::fallingObjectDone, this, meteor) ), nullptr);    
      meteor->setVisible ( true );
      meteor->runAction(repeatRotate);
      meteor->runAction(sequence);
     _fallingObjects.pushBack(meteor);
    }
    
  2. 我们从对象池中获取下一个可用的流星,然后为它的 MoveTo 动作随机选择一个起始和结束的 x 值。流星从屏幕顶部开始,将移动到底部向城市方向,但每次随机选择 x 值。

  3. 我们在一个 RepeatForever 动作中旋转流星,并使用 Sequence 将精灵移动到目标位置,然后在流星达到目标时调用 fallingObjectDone 回调。我们通过将我们从池中检索到的新流星添加到 _fallingObjects 向量中来完成,这样我们就可以检查与它的碰撞。

  4. 检索健康 (resetHealth) 精灵的方法基本上是相同的,只是使用 swingHealth 动作代替旋转。你会在 GameLayer.cpp 中找到该方法已经实现。

刚才发生了什么?

因此,在 resetGame 中我们设置计时器,并在 update 方法中更新它们。我们使用这些计时器通过从各自的池中获取下一个可用的对象来将流星和健康包添加到屏幕上,然后我们继续运行爆炸炸弹和这些下落对象之间的碰撞。

注意,在 resetMeteorresetHealth 中,如果屏幕上已经有很多精灵,我们不会添加新的精灵:

if (_fallingObjects->size() > 30) return;

这样游戏就不会变得荒谬地困难,我们也不会在我们的对象池中耗尽未使用的对象。

我们游戏中最后一点逻辑是我们的fallingObjectDone回调,当流星或健康包到达地面时调用,此时它根据玩家是否让精灵通过而奖励或惩罚玩家。

当你查看GameLayer.cpp中的那个方法时,你会注意到我们如何使用->getTag()来快速确定我们正在处理哪种类型的精灵(调用该方法的那个):

if (pSender->getTag() == kSpriteMeteor) {

如果它是一颗流星,我们就减少玩家的能量,播放音效,并运行爆炸动画;我们保留的_groundHit动作的一个 autorelease 副本,这样我们就不需要在每次需要运行这个动作时重复所有逻辑。

如果项目是健康包,我们就增加能量或给玩家一些分数,播放一个好听的声音效果,并隐藏精灵。

玩这个游戏!

我们疯狂地编码,现在终于到了运行游戏的时候。但首先,别忘了释放我们保留的所有项目。在GameLayer.cpp中,添加我们的析构函数方法:

GameLayer::~GameLayer () {

    //release all retained actions
    CC_SAFE_RELEASE(_growBomb);
    CC_SAFE_RELEASE(_rotateSprite);
    CC_SAFE_RELEASE(_shockwaveSequence);
    CC_SAFE_RELEASE(_swingHealth);
    CC_SAFE_RELEASE(_groundHit);
    CC_SAFE_RELEASE(_explosion);
    CC_SAFE_RELEASE(_ufoAnimation);
    CC_SAFE_RELEASE(_blinkRay);

    _clouds.clear();
    _meteorPool.clear();
    _healthPool.clear();
    _fallingObjects.clear();
}

实际的游戏屏幕现在看起来可能像这样:

玩这个游戏!

再次提醒,如果你在运行代码时遇到任何问题,可以参考4198_04_FINAL_PROJECT.zip

现在,让我们将其带到 Android 上。

是时候在 Android 上运行游戏了。

按照以下步骤将游戏部署到 Android:

  1. 这次,没有必要修改清单文件,因为默认设置就是我们想要的。所以,导航到proj.android,然后到jni文件夹,在文本编辑器中打开Android.mk文件。

  2. 编辑LOCAL_SRC_FILES中的行,使其如下所示:

    LOCAL_SRC_FILES := hellocpp/main.cpp \
                       ../../Classes/AppDelegate.cpp \
                       ../../Classes/GameLayer.cpp 
    
  3. 按照从HelloWorldAirHockey示例中的说明,将游戏导入 Eclipse。

  4. 保存并运行你的应用程序。这次,如果你有设备,你可以尝试不同的屏幕尺寸。

发生了什么?

你刚刚在 Android 上运行了一个通用应用。这再简单不过了。

作为奖励,我添加了游戏的另一个版本,增加了额外的敌人类型来处理:一个一心想要电击城市的 UFO!你可以在4198_04_BONUS_PROJECT.zip中找到它。

突击测验 - 精灵和动作

Q1. SpriteBatchNode可以包含哪些类型的元素?

  1. 使用来自两个或更多精灵图的纹理的精灵。

  2. 使用相同源纹理的精灵。

  3. 空精灵。

  4. 使用来自一个精灵图和另一个图像的纹理的精灵。

Q2. 为了持续运行一个动作,我需要使用什么?

  1. RepeatForever

  2. Repeat

  3. 动作默认的行为是持续运行。

  4. 动作不能永远重复。

Q3. 为了使精灵移动到屏幕上的某个点然后淡出,我需要哪些动作?

  1. 一个列出EaseInEaseOut动作的Sequence

  2. 一个列出FadeOutMoveTo动作的Sequence

  3. 一个列出MoveToMoveByFadeOut动作的Sequence

  4. 一个列出RotateByFadeOut动作的Sequence

Q4. 要创建一个精灵帧动画,哪些类组是绝对必要的?

  1. Sprite, SpriteBatchNode, 和 EaseIn.

  2. SpriteFrameCache, RotateBy, 和 ActionManager.

  3. Sprite, Layer, 和 FadeOut.

  4. SpriteFrame, Animation, 和 Animate.

摘要

在我看来,在节点及其所有派生对象之后,动作是 Cocos2d-x 的第二大优点。它们是节省时间的工具,并且可以快速为任何项目增添专业外观的动画。我希望通过本章中的示例,以及 Cocos2d-x 样本测试项目中的示例,您将能够使用 Cocos2d-x 创建您需要的任何动作。

在下一章中,我将向您介绍另一种简单的方法,您可以用它来为您的游戏增添活力:使用粒子效果!

第五章. 在线上 – 火箭穿越

在我们的第三个游戏,火箭穿越中,我们将使用粒子效果来增加一些趣味性,并且我们将使用 DrawNode 在屏幕上绘制自己的 OpenGL 图形。并且请注意,这个游戏使用了相当多的向量数学,但幸运的是,Cocos2d-x 附带了一套甜美的辅助方法来处理这些问题。

你将学习:

  • 如何加载和设置粒子系统

  • 如何使用DrawNode绘制原语(线条、圆圈等)

  • 如何使用 Cocos2d-x 中包含的向量数学辅助方法

游戏 – 火箭穿越

在这个经典蛇形游戏引擎的科幻版本中,你控制一艘火箭船,必须在七个行星之间移动,收集微小的超新星。但这里有个问题:你只能通过通过touch事件放置的支点旋转来控制火箭。所以,我们为火箭船设定的运动矢量有时是线性的,有时是圆形的。

游戏设置

这是一个通用游戏,专为普通 iPad 设计,然后放大和缩小以匹配其他设备的屏幕分辨率。它设置为在纵向模式下播放,并且不支持多点触控。

先玩,后工作

从本书的支持页面下载4198_05_START_PROJECT.zip4198_05_FINAL_PROJECT.zip文件。

你将再次使用开始项目选项来工作;这样,你就不需要输入已经在之前章节中覆盖的逻辑或语法。开始项目选项包含所有资源文件和所有类声明,以及类实现文件中的所有方法的占位符。我们稍后会介绍这些内容。

你应该运行最终项目版本,以便熟悉游戏。通过按住并拖动你的手指在火箭船上,你可以画一条线。释放触摸,你创建一个支点。船将围绕这个支点旋转,直到你再次按下船来释放它。你的目标是收集明亮的超新星并避开行星。

先玩,后工作

开始项目

如果你运行开始项目选项,你应该能看到基本的游戏屏幕已经就位。没有必要重复我们在之前的教程中创建批节点和放置所有屏幕精灵的步骤。我们再次有一个_gameBatchNode对象和一个createGameScreen方法。

但是无论如何,请阅读createGameScreen方法内部的代码。这里的关键重要性在于,我们创建的每个行星都存储在_planets向量中。我们还在这里创建了我们的_rocket对象(Rocket类)和我们的_lineContainer对象(LineContainer类)。关于这些内容,我们稍后会详细介绍。

开始项目选项中,我们还有我们的老朋友GameSprite,在这里它扩展了Sprite,并增加了一个获取精灵的radius()方法。Rocket对象和所有行星都是GameSprite对象。

屏幕设置

所以如果你在 Xcode 中打开了 Start Project 选项,让我们回顾一下 AppDelegate.cpp 中这个游戏的屏幕设置。在 applicationDidFinishLaunching 方法内部,你应该看到以下内容:

auto designSize = Size(1536, 2048);

glview->setDesignResolutionSize(designSize.width, designSize.height, ResolutionPolicy::EXACT_FIT);

std::vector<std::string> searchPaths;
if (screenSize.width > 768) {
  searchPaths.push_back("ipadhd");
  director->setContentScaleFactor(1536/designSize.width);
} else if (screenSize.width > 320) {
  searchPaths.push_back("ipad");
  director->setContentScaleFactor(768/designSize.width);
} else {
  searchPaths.push_back("iphone");
  director->setContentScaleFactor(380/designSize.width);
}
auto fileUtils = FileUtils::getInstance();
fileUtils->setSearchPaths(searchPaths);

因此,我们基本上是以与上一款游戏相同的方式开始的。这款游戏中的大多数精灵都是圆形的,你可能会在不同的屏幕上注意到一些扭曲;你应该测试相同的配置,但使用不同的 ResolutionPolicies,例如 SHOW_ALL

那么,粒子是什么?

粒子或粒子系统是向你的应用程序添加特殊效果的一种方式。一般来说,这是通过使用大量的小纹理精灵(粒子)来实现的,这些粒子被动画化并通过一系列变换运行。你可以使用这些系统来创建烟雾、爆炸、火花、闪电、雨、雪以及其他类似效果。

正如我在 第一章 中提到的,安装 Cocos2d-x,你应该认真考虑为自己获取一个程序来帮助你设计粒子系统。在这款游戏中,粒子是在 ParticleDesigner 中创建的。

是时候将它们添加到我们的游戏中了!

行动时间 - 创建粒子系统

对于粒子,我们只需要描述粒子系统属性的 XML 文件。

  1. 所以让我们去 GameLayer.cpp

  2. 游戏通过调用 createGameScreen 初始化,这已经就位,然后是 createParticlescreateStarGrid,这也是已实现的。所以现在让我们来看看 createParticles 方法。

  3. 前往 GameLayer.cpp 中的那个方法,并添加以下代码:

    _jet = ParticleSystemQuad::create("jet.plist");
    _jet->setSourcePosition(Vec2(-_rocket->getRadius() * 0.8f,0));
    _jet->setAngle(180);
    _jet->stopSystem();
    this->addChild(_jet, kBackground);
    
    _boom = ParticleSystemQuad::create("boom.plist");
    _boom->stopSystem();
    this->addChild(_boom, kForeground);
    
    _comet = ParticleSystemQuad::create("comet.plist");
    _comet->stopSystem();
    _comet->setPosition(Vec2(0, _screenSize.height * 0.6f));
    _comet->setVisible(false);
    this->addChild(_comet, kForeground);
    
    _pickup = ParticleSystemQuad::create("plink.plist");
    _pickup->stopSystem();
    this->addChild(_pickup, kMiddleground);
    
    _warp = ParticleSystemQuad::create("warp.plist");
    _warp->setPosition(_rocket->getPosition());
    this->addChild(_warp, kBackground);
    
    _star = ParticleSystemQuad::create("star.plist");
    _star->stopSystem();
    _star->setVisible(false);
    this->addChild(_star, kBackground, kSpriteStar);
    

刚才发生了什么?

我们创建了第一个粒子。ParticleDesigner 将粒子系统数据导出为 .plist 文件,我们使用它来创建我们的 ParticleSystemQuad 对象。你应该在 Xcode 中打开其中一个文件来查看粒子系统中使用的设置数量。从 Cocos2d-x 中,你可以通过 ParticleSystem 中的设置器修改这些设置中的任何一个。

我们将在游戏中使用的粒子如下:

  • _jet: 这与 _rocket 对象相关联,并且它将在 _rocket 对象的后面拖尾。我们将系统的角度和源位置参数设置为与 _rocket 精灵匹配。

  • _boom: 这是在 _rocket 爆炸时使用的粒子系统。

  • _comet: 这是一个在设定间隔内移动穿过屏幕的粒子系统,并且可以与 _rocket 发生碰撞。

  • _pickup: 这用于收集星星时。

  • _warp: 这标记了火箭的初始位置。

  • _star: 这是火箭必须收集的星星所使用的粒子系统。

以下截图显示了这些各种粒子:

刚才发生了什么?

所有粒子系统都作为 GameLayer 的子对象添加;它们不能添加到我们的 SpriteBatchNode 类中。并且,在创建每个系统时,你必须调用 stopSystem(),否则它们一旦被添加到节点中就会立即开始播放。

为了运行系统,你需要调用resetSystem()

注意

Cocos2d-x 附带了一些常见的粒子系统,你可以根据需要修改。如果你去test文件夹中的tests/cpp-tests/Classes/ParticleTest,你会看到这些系统被使用的示例。实际的粒子数据文件位于:tests/cpp-tests/Resources/Particles

创建网格

现在我们花些时间来回顾一下游戏中的网格逻辑。这个网格是在GameLayer.cpp中的createStarGrid方法中创建的。这个方法所做的就是确定屏幕上所有可以放置_star粒子系统的可能位置。

我们使用一个名为_grid的 C++向量列表来存储可用的位置:

std::vector<Point> _grid;

createStarGrid方法将屏幕划分为多个 32 x 32 像素的单元格,忽略离屏幕边缘太近的区域(gridFrame)。然后我们检查每个单元格与存储在向量_planets中的星球精灵之间的距离。如果单元格离星球足够远,我们就将其作为Point存储在_grid向量中。

在以下图中,你可以了解我们想要达到的结果。我们想要所有不与任何星球重叠的白色单元格。

创建网格

我们使用Log向控制台输出一条消息,说明我们最终有多少个单元格:

CCLOG("POSSIBLE STARS: %i", _grid.size());

这个vector列表将在每次新游戏中进行洗牌,所以我们最终得到一个可能的星星位置随机序列:

std::random_shuffle(_grid.begin(), _grid.end());

这样我们就不会在星球上方或离它如此近的地方放置星星,以至于火箭无法到达它而不与星球相撞。

在 Cocos2d-x 中绘制原语

游戏中的主要元素之一是LineContainer.cpp类。它是一个从DrawNode派生出来的类,允许我们在屏幕上绘制线条和圆圈。

DrawNode附带了一系列你可以用来绘制线条、点、圆圈、多边形等的绘制方法。

我们将使用的方法是drawLinedrawDot

是时候动手画一些东西了!

是时候在LineContainer.cpp中实现绘制功能了。你会注意到这个类已经实现了大部分方法,所以你可以节省一些输入。一旦我们添加了游戏的主要更新方法,我会解释这些方法代表什么。但基本上LineContainer将用于显示玩家在屏幕上绘制的线条,以操纵_rocket精灵,以及显示一个充当游戏计时器的能量条:

  1. 我们需要在这里更改的是update方法。所以这就是你需要在那个方法中输入的内容:

    _energy -= dt * _energyDecrement;
    if (_energy < 0) _energy = 0;
    clear();
    
    switch (_lineType) {
      case LINE_NONE:
       break;
      case LINE_TEMP:
       drawLine(_tip, _pivot, Color4F(1.0, 1.0, 1.0, 1.0));
       drawDot(_pivot, 5, Color4F(Color3B::WHITE));
       break;
    
      case LINE_DASHED:
       drawDot(_pivot, 5, Color4F(Color3B::WHITE));
       int segments = _lineLength / (_dash + _dashSpace);
       float t = 0.0f;
       float x_;
       float y_;
    
       for (int i = 0; i < segments + 1; i++) {
          x_ = _pivot.x + t * (_tip.x - _pivot.x);
          y_ = _pivot.y + t * (_tip.y - _pivot.y);
          drawDot(Vec2(x_, y_), 5, Color4F(Color3B::WHITE));
          t += (float) 1 / segments;
       }
       break;
    }
    
  2. 我们通过在同一个LineContainer节点上绘制能量条来结束我们的绘制调用:

    drawLine(Vec2(_energyLineX, _screenSize.height * 0.1f),  Vec2(_energyLineX, _screenSize.height * 0.9f), Color4F(0.0, 0.0, 0.0, 1.0)); 
    drawLine(Vec2(_energyLineX, _screenSize.height * 0.1f),  Vec2(_energyLineX, _screenSize.height * 0.1f + _energy *  _energyHeight ), Color4F(1.0, 0.5, 0.0, 1.0));
    

刚才发生了什么?

你刚刚学习了如何在DrawNode中绘制。代码中的一条重要行是clear()调用。在我们用新状态更新它们之前,它会清除该节点中的所有绘制。

LineContainer中,我们使用switch语句来确定如何绘制玩家的线。如果_lineType属性设置为LINE_NONE,则不绘制任何内容(这实际上会清除玩家所做的任何绘图)。

如果_lineTypeLINE_TEMP,这意味着玩家正在将手指从_rocket对象上拖开,我们想显示从_rocket当前位置到玩家当前触摸位置的白线。这些点分别称为tippivot

我们还在pivot点上画了一个点。

drawLine(_tip, _pivot, Color4F(1.0, 1.0, 1.0, 1.0));
drawDot(_pivot, 5, Color4F(Color3B::WHITE));

如果_lineTypeLINE_DASHED,这意味着玩家已经从屏幕上移除了手指,并为_rocket设置了一个新的旋转支点。我们用所谓的贝塞尔线性公式画一条白点线,从_rocket当前位置和pivot点画一系列小圆:

for (int i = 0; i < segments + 1; i++) {

    x_ = _pivot.x + t * (_tip.x - _pivot.x);
    y_ = _pivot.y + t * (_tip.y - _pivot.y);

    drawDot(Vec2(x_, y_), 5, Color4F(Color3B::WHITE));
    t += (float) 1 / segments;
}

最后,对于能量条,我们在橙色条下方画一条黑色线。当LineContainer中的_energy值减少时,橙色条会调整大小。黑色线保持不变,它在这里是为了显示对比。你通过draw调用的顺序来叠加你的绘图;所以先画的东西会出现在后画的东西下面。

火箭精灵

现在是处理游戏中的第二个对象:火箭。

再次强调,我已经放好了对你来说已经是老生常谈的逻辑部分。但请审查已经放在Rocket.cpp中的代码。我们有一个方法,每次新游戏开始时重置火箭(reset),还有一个方法通过改变其显示纹理来显示火箭的选中状态(select(bool flag)):

if (flag) {
    this->setDisplayFrame(SpriteFrameCache::getInstance()->getSpriteFrameByName("rocket_on.png"));
} else {
    this->setDisplayFrame(SpriteFrameCache::getInstance()->getSpriteFrameByName("rocket.png"));
}

这将显示火箭周围有光晕,或者不显示。

最后,我们有一个检查与屏幕边缘碰撞的方法(collidedWithSides)。如果有碰撞,我们将调整火箭使其远离碰撞的屏幕边缘,并从任何支点位置释放它。

我们真正需要担心的是火箭的update方法。这就是我们接下来要添加的。

更新我们的火箭精灵的行动时间

游戏的主循环会在每次迭代中调用火箭的update方法。

  1. Rocket.cpp中的空update方法内,添加以下行:

    Point position = this->getPosition();
    if (_rotationOrientation == ROTATE_NONE) {
      position.x += _vector.x * dt;
      position.y += _vector.y * dt;
    } else {
      float angle = _angularSpeed * dt;
      Point rotatedPoint = position.rotateByAngle(_pivot, angle);
      position.x = rotatedPoint.x;
      position.y = rotatedPoint.y;
      float rotatedAngle;
    
      Point diff = position;
      diff.subtract(_pivot);
      Point clockwise = diff.getRPerp();
    
      if (_rotationOrientation == ROTATE_COUNTER) {
        rotatedAngle = atan2 (-1 * clockwise.y, -1 * clockwise.x);
      } else {
        rotatedAngle = atan2 (clockwise.y, clockwise.x);
      }
    
      _vector.x = _speed * cos (rotatedAngle);
      _vector.y = _speed * sin (rotatedAngle);
      this->setRotationFromVector();
    
      if (this->getRotation() > 0) {
        this->setRotation( fmodf(this->getRotation(), 360.0f) );
      } else {
        this->setRotation( fmodf(this->getRotation(), -360.0f) );
      }
    }
    
  2. 这里我们说的是,如果火箭没有旋转(_rotationOrientation == ROTATE_NONE),就根据其当前_vector移动它。如果它在旋转,则使用 Cocos2d-x 辅助函数rotateByAngle方法找到其绕支点旋转的下一个位置:更新我们的火箭精灵的行动时间 – 更新我们的火箭精灵

  3. 该方法将围绕一个支点旋转任意点一定角度。因此,我们使用Rocket类的一个属性_angularSpeed来旋转火箭的更新位置(由玩家确定),我们稍后会看到它是如何计算的。

  4. 根据火箭是顺时针旋转还是逆时针旋转,我们调整其旋转,使火箭与火箭和其支点之间绘制的线条成 90 度角。然后我们根据这个旋转角度改变火箭的运动矢量,并将该角度的值包裹在 0 到 360 度之间。

  5. 使用以下行完成 update 方法的编写:

    if (_targetRotation > this->getRotation() + 180) {
      _targetRotation -= 360;
    }
    if (_targetRotation < this->getRotation() - 180) {
      _targetRotation += 360;
    }
    
    this->setPosition(position);
    _dr = _targetRotation - this->getRotation() ;
    _ar = _dr * _rotationSpring;
    _vr += _ar ;
    _vr *= _rotationDamping;
    float rotationNow = this->getRotation();
    rotationNow += _vr;
    this->setRotation(rotationNow);
    
  6. 通过这些行我们确定精灵的新目标旋转,并运行一个动画将火箭旋转到目标旋转(带有一点弹性)。

刚才发生了什么?

我们刚刚编写了将火箭在屏幕上移动的逻辑,无论火箭是否旋转。

因此,当玩家为 _rocket 精灵选择一个支点时,这个支点被传递给 RocketLineContainer。前者将使用它在其周围旋转矢量,后者将使用它来在 _rocketpivot 点之间绘制虚线。

注意

我们不能使用 Action 来旋转精灵,因为我们的逻辑中目标旋转更新得太频繁,而 Action 需要时间来初始化和运行。

因此,现在是时候编写触摸事件代码,让所有这些逻辑都落到实处。

行动时间 - 处理触摸

我们需要实现 onTouchBeganonTouchMovedonTouchEnded

  1. 现在,在 GameLayer.cpp 中,在 onTouchBegan 中添加以下行:

    if (!_running) return true;
    Point tap = touch->getLocation();
    float dx = _rocket->getPositionX() - tap.x;
    float dy = _rocket->getPositionY() - tap.y;
    if (dx * dx + dy * dy <= pow(_rocket->getRadius(), 2) ) {
     _lineContainer->setLineType ( LINE_NONE );
     _rocket->setRotationOrientation ( ROTATE_NONE );
     _drawing = true;
    }
    
    return true;
    

    当触摸开始时,我们只需要确定它是否触摸了飞船。如果是,我们将我们的 _drawing 属性设置为 true。这将表明我们有一个有效的点(一个从触摸 _rocket 精灵开始的点)。

  2. 我们通过调用 setLineType( LINE_NONE ) 清除 _lineContainer 中可能正在绘制的任何线条,并确保通过释放 _rocket (setRotationOrientation ( ROTATE_NONE )),我们不会旋转 _rocket,直到我们有一个支点,这样它将继续沿着当前的线性轨迹 (_vector) 移动。

  3. 从这里开始,我们使用下一个 onTouchMoved 方法绘制新的线条。在该方法内部,我们添加以下行:

    if (!_running) return;
      if (_drawing) {
         Point tap = touch->getLocation();
         float dx = _rocket->getPositionX() - tap.x;
         float dy = _rocket->getPositionY() - tap.y;
         if (dx * dx + dy * dy > pow (_minLineLength, 2)) {
           _rocket->select(true);
           _lineContainer->setPivot ( tap );
           _lineContainer->setLineType ( LINE_TEMP );
         } else {
           _rocket->select(false);
           _lineContainer->setLineType ( LINE_NONE );
        }
     }
    
  4. 我们只处理触摸移动,如果我们正在使用 _drawing,这意味着玩家已经按下了飞船,现在正在将手指拖过屏幕。

    一旦手指与 _rocket 之间的距离大于我们在游戏 init 中规定的 _ minLineLength 距离,我们就通过在 _rocket 周围添加发光效果(_rocket->select(true))向玩家提供一个视觉提示,并在 _lineContainer 中通过传递触摸的当前位置并设置线型为 LINE_TEMP 来绘制新的线条。如果未达到最小长度,则不显示线条,也不显示玩家已选择。

  5. 接下来是 onTouchEnded。在我们的 onTouchEnded 方法中已经存在处理游戏状态的逻辑。你应该取消注释对 resetGame 的调用,并在方法内添加一个新的 else if 语句:

    } else if (_state == kGamePaused) {
      _pauseBtn->setDisplayFrame(SpriteFrameCache::getInstance()->getSpriteFrameByName ("btn_pause_off.png"));
      _paused->setVisible(false);
      _state = kGamePlay;
      _running = true;
      return;
    } 
    
  6. 如果游戏处于暂停状态,我们通过 Sprite->setDisplayFrame_pauseBtn 精灵中更改纹理,并重新开始运行游戏。

  7. 现在我们开始处理触摸。首先,我们确定它是否落在 Pause 按钮上:

    if (!_running) return;
    if(touch != nullptr) {
      Point tap = touch->getLocation();
      if (_pauseBtn->getBoundingBox().containsPoint(tap)) {
        _paused->setVisible(true);
        _state = kGamePaused;
        _pauseBtn->setDisplayFrame(SpriteFrameCache::getInstance()->getSpriteFrameByName ("btn_pause_on.png"));
        _running = false;
        return;
      }
    }
    
  8. 如果是这样,我们将游戏状态更改为 kGamePaused,在 _pauseBtn 精灵上更改纹理(通过从 SpriteFrameCache 中检索另一个精灵帧),停止运行游戏(暂停游戏),并从函数中返回。

  9. 我们终于可以对火箭飞船做些事情了。所以,继续在之前看到的 if(touch != nullptr) { 条件语句中,添加以下行:

        _drawing = false;
       _rocket->select(false);
       if (_lineContainer->getLineType() == LINE_TEMP) {
          _lineContainer->setPivot (tap);
          _lineContainer->setLineLength ( _rocket->getPosition().distance( tap ) );
          _rocket->setPivot (tap);
    
  10. 我们首先取消选择 _rocket 精灵,然后检查我们是否正在 _lineContainer 中显示临时线条。如果我们正在显示,这意味着我们可以继续使用玩家的释放触摸来创建新的支点。我们通过 setPivot 方法将此信息传递给 _lineContainer,同时传递线条长度。_rocket 精灵也会接收到支点信息。

    然后,事情变得复杂!_rocket 精灵以像素为基础的速度移动。一旦 _rocket 开始旋转,它将通过 Point.rotateByAngle 以基于角的速度移动。因此,以下行被添加以将 _rocket 当前像素速度转换为角速度:

    float circle_length = _lineContainer->getLineLength() * 2 * M_PI;
    int iterations = floor(circle_length / _rocket->getSpeed());
    _rocket->setAngularSpeed ( 2 * M_PI / iterations);
    
  11. 它获取将要被 _rocket 描述的圆周长度(_rocket (line length * 2 * PI)),然后除以火箭的速度,得到火箭完成该长度所需的迭代次数。然后,将圆的 360 度除以相同的迭代次数(但我们用弧度来计算)以得到火箭在每次迭代中必须旋转的圆周分数:它的角速度。

  12. 接下来的是更多的数学计算,使用 Cocos2d-x 中与向量数学相关的非常有帮助的方法(例如 Point.getRPerpPoint.dotPoint.subtract 等),其中一些我们在 Rocket 类中已经见过:

    Vec2 diff = _rocket->getPosition();
    diff.subtract(_rocket->getPivot());
    Point clockwise = diff.getRPerp();
    float dot =clockwise.dot(_rocket->getVector());
    if (dot > 0) {
       _rocket->setAngularSpeed ( _rocket->getAngularSpeed() * -1 );
       _rocket->setRotationOrientation ( ROTATE_CLOCKWISE );
       _rocket->setTargetRotation  ( CC_RADIANS_TO_DEGREES( atan2(clockwise.y, clockwise.x) ) );
    } else {
       _rocket->setRotationOrientation ( ROTATE_COUNTER );
       _rocket->setTargetRotation ( CC_RADIANS_TO_DEGREES  (atan2(-1 * clockwise.y, -1 * clockwise.x) ) );
    }
    _lineContainer->setLineType ( LINE_DASHED );
    
  13. 他们在这里做的是确定火箭应该旋转的方向:顺时针还是逆时针,基于其当前的运动向量。

  14. 玩家刚刚在 _rocket 和支点之间绘制的线条,通过减去这两个点(Point.subtract)得到,有两个垂直向量:一个向右(顺时针)的向量,通过 Point.getRPerp 获取;一个向左(逆时针)的向量,通过 Point.getPerp 获取。我们使用其中一个向量的角度作为 _rocket 目标旋转,使火箭旋转到与 LineContainer 中绘制的线条成 90 度,并通过 _rocket 当前向量与其中一个垂直向量的点积(Point.dot)找到正确的垂直向量。

发生了什么?

我知道。有很多数学计算,而且都是一次性完成的!幸运的是,Cocos2d-x 使这一切处理起来容易得多。

我们刚刚添加了允许玩家绘制线条并设置 _rocket 精灵的新支点的逻辑。

玩家将通过给火箭一个旋转的支点来控制_rocket精灵穿越行星。通过释放_rocket从支点,玩家将使其再次沿直线移动。所有这些逻辑都由游戏中的触摸事件管理。

并且不用担心数学问题。虽然理解如何处理向量是任何游戏开发者工具箱中非常有用的工具,你应该绝对研究这个话题,但你可以用很少或没有数学知识来构建无数的游戏;所以加油!

游戏循环

是时候创建我们熟悉的老式计时器了!主循环将负责碰撞检测,更新_lineContainer内的分数,调整_jet粒子系统以匹配_rocket精灵,以及一些其他事情。

行动时间 – 添加主循环

让我们实现我们的主要update方法。

  1. GameLayer.cpp中,在update方法内部,添加以下行:

    if (!_running || _state != kGamePlay) return;
    if (_lineContainer->getLineType() != LINE_NONE) {
      _lineContainer->setTip (_rocket->getPosition() );
    }
    
    if (_rocket->collidedWithSides()) {
      _lineContainer->setLineType ( LINE_NONE );
    }
    _rocket->update(dt);
    
    //update jet particle so it follows rocket
    if (!_jet->isActive()) _jet->resetSystem();
    _jet->setRotation(_rocket->getRotation());
    _jet->setPosition(_rocket->getPosition());
    

    我们检查我们是否不在暂停状态。然后,如果有我们需要在_lineContainer中显示的船的线,我们使用_rocket的当前位置更新线的tip点。

    我们在_rocket和屏幕边缘之间运行碰撞检测,更新_rocket精灵,并定位和旋转我们的_jet粒子系统以与_rocket精灵对齐。

  2. 接下来我们更新_comet(它的倒计时、初始位置、移动和如果_comet可见则与_rocket的碰撞):

    _cometTimer += dt;
    float newY;
    
    if (_cometTimer > _cometInterval) {
        _cometTimer = 0;
        if (_comet->isVisible() == false) {
            _comet->setPositionX(0);
            newY = (float)rand()/((float)RAND_MAX/_screenSize.height * 0.6f) + _screenSize.height * 0.2f;
            if (newY > _screenSize.height * 0.9f) 
               newY = _screenSize.height * 0.9f;
               _comet->setPositionY(newY);
               _comet->setVisible(true);
               _comet->resetSystem();
        }
    }
    
    if (_comet->isVisible()) {
        //collision with comet
        if (pow(_comet->getPositionX() - _rocket->getPositionX(), 2) + pow(_comet->getPositionY() - _rocket->getPositionY(), 2) <= pow (_rocket->getRadius() , 2)) {
            if (_rocket->isVisible()) killPlayer();
        }
        _comet->setPositionX(_comet->getPositionX() + 50 * dt);
    
        if (_comet->getPositionX() > _screenSize.width * 1.5f) {
            _comet->stopSystem();
            _comet->setVisible(false);
        }
    }
    
  3. 接下来我们更新_lineContainer,并逐渐降低_rocket精灵的透明度,基于_lineContainer中的_energy等级:

    _lineContainer->update(dt);
    _rocket->setOpacity(_lineContainer->getEnergy() * 255);
    

    这将为玩家添加一个视觉提示,表明时间正在流逝,因为_rocket精灵将逐渐变得不可见。

  4. 运行星球的碰撞:

    for (auto planet : _planets) {
        if (pow(planet->getPositionX() - _rocket->getPositionX(),  2)
        + pow(planet->getPositionY() - _rocket->getPositionY(), 2)  <=   pow (_rocket->getRadius() * 0.8f + planet->getRadius()  * 0.65f, 2)) {
    
            if (_rocket->isVisible()) killPlayer();
            break;
        }
    }
    
  5. 并且与星星的碰撞:

    if (pow(_star->getPositionX() - _rocket->getPositionX(), 2)
        + pow(_star->getPositionY() - _rocket->getPositionY(), 2)  <=
        pow (_rocket->getRadius() * 1.2f, 2)) {
    
        _pickup->setPosition(_star->getPosition());
        _pickup->resetSystem();
        if (_lineContainer->getEnergy() + 0.25f < 1) {
            _lineContainer->setEnergy(_lineContainer->getEnergy() +  0.25f);
        } else {
            _lineContainer->setEnergy(1.0);
        }
        _rocket->setSpeed(_rocket->getSpeed() + 2);
        if (_rocket->getSpeed() > 70) _rocket->setSpeed(70);
            _lineContainer->setEnergyDecrement(0.0002f);
            SimpleAudioEngine::getInstance()->playEffect("pickup.wav");
            resetStar();
    
            int points = 100 - _timeBetweenPickups;
            if (points < 0) points = 0;
    
            _score += points;
            _scoreDisplay->setString(String::createWithFormat("%i", _score)->getCString());
            _timeBetweenPickups = 0;
    }
    

    当我们收集到_star时,我们在_star所在的位置激活_pickup粒子系统,填充玩家的能量等级,使游戏稍微困难一些,并立即将_star重置到下一个位置以便再次收集。

    分数基于玩家收集_star所需的时间。

  6. 我们在update函数的最后几行记录这个时间,同时检查能量等级:

    _timeBetweenPickups += dt;
    if (_lineContainer->getEnergy() == 0) {
        if (_rocket->isVisible()) killPlayer();
    }
    

刚刚发生了什么?

我们已经将主循环添加到游戏中,并且所有的部件都开始相互通信。但你可能已经注意到,我们调用了一些尚未实现的方法,比如killPlayerresetStar。我们将使用这些方法完成游戏逻辑。

销毁和重置

又是时候了!是时候杀死我们的玩家并重置游戏了!我们还需要在玩家拾取_star时将其精灵移动到新的位置。

行动时间 – 添加重置和销毁

我们需要添加逻辑来重新开始游戏,并将我们的拾取星移动到新的位置。但首先,让我们销毁玩家!

  1. killPlayer方法内部,添加以下行:

    void GameLayer::killPlayer() {
    
        SimpleAudioEngine::getInstance()->stopBackgroundMusic();
        SimpleAudioEngine::getInstance()->stopAllEffects();
        SimpleAudioEngine::getInstance()->playEffect("shipBoom.wav");
    
        _boom->setPosition(_rocket->getPosition());
        _boom->resetSystem();
        _rocket->setVisible(false);
        _jet->stopSystem();
        _lineContainer->setLineType ( LINE_NONE );
    
        _running = false;
        _state = kGameOver;
        _gameOver->setVisible(true);
        _pauseBtn->setVisible(false);
    }
    
  2. resetStar内部,添加以下行:

    void GameLayer::resetStar() {
        Point position = _grid[_gridIndex];
        _gridIndex++;
        if (_gridIndex == _grid.size()) _gridIndex = 0;
        //reset star particles
        _star->setPosition(position);
        _star->setVisible(true);
        _star->resetSystem();
    }
    
  3. 最后,我们的resetGame方法:

    void GameLayer::resetGame () {
    
        _rocket->setPosition(Vec2(_screenSize.width * 0.5f,  _screenSize.height * 0.1f));
        _rocket->setOpacity(255);
        _rocket->setVisible(true);
        _rocket->reset();
    
        _cometInterval = 4;
        _cometTimer = 0;
        _timeBetweenPickups = 0.0;
    
        _score = 0;
        _scoreDisplay->setString(String::createWithFormat("%i", _score)->getCString());
    
        _lineContainer->reset();
    
        //shuffle grid cells
    
        std::random_shuffle(_grid.begin(), _grid.end());
        _gridIndex = 0;
    
        resetStar();
    
        _warp->stopSystem();
    
        _running = true;
    
        SimpleAudioEngine::getInstance()->playBackgroundMusic("background.mp3", true);
        SimpleAudioEngine::getInstance()->stopAllEffects();
        SimpleAudioEngine::getInstance()->playEffect("rocket.wav", true);
    
    }
    

刚刚发生了什么?

就这样。我们完成了。这比大多数人舒服的数学要多。但你能告诉我什么呢,我就是喜欢玩弄向量!

现在,让我们继续学习 Android!

是时候行动了——在 Android 上运行游戏

按照以下步骤将游戏部署到 Android:

  1. 打开清单文件,并将app方向设置为portrait

  2. 接下来,在文本编辑器中打开Android.mk文件。

  3. 编辑LOCAL_SRC_FILES中的行,使其读取:

    LOCAL_SRC_FILES := hellocpp/main.cpp \
                       ../../Classes/AppDelegate.cpp \
                       ../../Classes/GameSprite.cpp \
                       ../../Classes/LineContainer.cpp \
                       ../../Classes/Rocket.cpp \
                       ../../Classes/GameLayer.cpp  
    
  4. 将游戏导入 Eclipse 并构建它。

  5. 保存并运行你的应用程序。这次,如果你有设备,你可以尝试不同的屏幕尺寸。

刚才发生了什么?

现在,你的 Rocket Through 已经在 Android 上运行了。

大胆尝试

resetStar方法添加逻辑,以确保新选择的位置不要离_rocket精灵太近。所以,让这个函数成为一个循环的函数,直到选择了一个合适的位置。

并且使用warp粒子系统,目前它并没有做什么,将其用作随机传送场,这样火箭可能会被随机放置的传送门吸入,并远离目标恒星。

摘要

恭喜!你现在对 Cocos2d-x 有了足够的信息来制作出色的 2D 游戏。首先是精灵,然后是动作,现在是粒子。

粒子让一切看起来都很闪亮!它们很容易实现,并且是给游戏添加额外动画的好方法。但是很容易过度使用,所以请小心。你不想让你的玩家出现癫痫发作。此外,一次性运行太多粒子可能会让你的游戏停止运行。

在下一章中,我们将看到如何使用 Cocos2d-x 快速测试和开发游戏想法。

第六章. 快速简单的精灵 - 维多利亚时代高峰期

在我们的第四个使用 Cocos2d-x 构建的游戏示例中,我将向你展示一个快速原型设计的简单技巧。在游戏开发中,你通常希望尽快测试你游戏的核心想法,因为一个游戏在你脑海中可能听起来很有趣,但现实中可能根本行不通。快速原型设计技术允许你在开发过程的早期就测试你的游戏,并在此基础上构建好的想法。

下面是你将学习的内容:

  • 如何快速创建占位符精灵

  • 如何为平台游戏编写碰撞代码

  • 如何为横版滚动游戏创建多样化的地形

游戏 - 维多利亚时代高峰期

在这个游戏(维多利亚时代高峰期)中,你控制一位在维多利亚时代的伦敦骑自行车的骑车人,试图避免他在回家的路上遇到高峰期交通。由于没有人能解释的原因,他骑自行车在建筑物的顶部。作为玩家,你的任务是确保他安全到达。

控制方式非常简单:你轻触屏幕使骑车人跳跃,当他处于空中时,如果你再次轻触屏幕,骑车人将打开他可靠的雨伞,这要么会减缓他的下降,要么会为他的跳跃增加动力。

这款游戏是一种常见的类型,通常被称为冲刺游戏或无尽跑酷游戏,这种类型在网上和各种应用商店中越来越受欢迎。通常在这些类型的游戏中,作为开发者的你有两个选择:要么让地形成为游戏中的主要障碍和挑战,要么让添加到地形中的元素成为主要挑战(敌人、拾取物、障碍物等)。对于这款游戏,我选择了第一个选项。

所以我们的挑战是创建一个游戏,其中地形是敌人,但不是不可战胜的。

游戏设置

这款游戏是一个通用应用程序,专为 iPad Retina 显示屏设计,但支持其他显示尺寸。它以横屏模式进行游戏,不支持多点触控。

使用 Cocos2d-x 进行快速原型设计

这种方法的理念是尽可能快速地创建精灵作为游戏元素的占位符,这样你就可以测试你的游戏想法并对其进行改进。本书中的每个游戏最初都是按照我即将展示的方式开发的,用简单的矩形代替纹理精灵。

这里展示的技术允许你创建任何大小和颜色的矩形,用于你的游戏逻辑:

使用 Cocos2d-x 进行快速原型设计

行动时间 - 创建占位符精灵

所以让我来展示如何做到这一点:

  1. 如果你还没有下载,请继续下载 4198_06_START_PROJECT.zip 文件。

  2. 当你在 Xcode 中打开项目时,你会看到我们为游戏所需的所有类,我们将在下一秒中讲解它们。但现在,请先转到 GameLayer.cpp

  3. 滚动到最后一个 createGameScreen 方法,并添加以下行:

    auto quickSprite = Sprite::create("blank.png");
    quickSprite->setTextureRect(Rect(0, 0, 100, 100));
    quickSprite->setColor(Color3B(255,255,255));
    
    quickSprite->setPosition(Vec2(_screenSize.width * 0.5, _screenSize.height * 0.5));
    this->addChild(quickSprite);
    

    就这样。精灵是用一个名为 blank.png 的纹理创建的。这是一个位于 Resources 文件夹中的 1 x 1 像素的白色方块。然后我们将精灵纹理矩形的大小设置为 100 x 100 像素(setTextureRect),并用白色填充它(setColor)。通过调整纹理矩形的大小,我们实际上调整了精灵的大小。如果你现在运行游戏,你应该会在屏幕中央看到一个白色方块。

  4. 现在删除之前的行,并用这些替换:

    _gameBatchNode = SpriteBatchNode::create("blank.png", 200);
    this->addChild(_gameBatchNode, kMiddleground);
    

    这创建了 _gameBatchNode,它使用相同的 blank.png 文件作为其源纹理。现在我们准备好在 _gameBatchNode 内放置尽可能多的矩形,并且如果需要,为每个矩形设置不同的颜色。换句话说,我们可以用一张微小的图片构建整个测试游戏。这正是我们现在要做的。

  5. 因此,为了完成这里的任务,添加这些最后一行:

    _terrain = Terrain::create();
    _gameBatchNode->addChild(_terrain, kMiddleground);
    
    _player = Player::create();
    _gameBatchNode->addChild(_player, kBackground);
    

刚才发生了什么?

我们刚刚创建了一个占位符精灵,我们可以用它快速且轻松地测试游戏玩法想法。我们还创建了游戏的两个主要对象:PlayerTerrain 对象。目前它们是空壳,但我们将从它们开始工作。但首先,让我们回顾一下不同的游戏元素。

玩家对象

这代表我们的自行车手。它会跳跃、漂浮,并与 _terrain 对象发生碰撞。它的 x 速度传递给 _terrain 对象,导致 Terrain 对象移动,向屏幕左侧滚动。

Player 对象再次从 GameSprite 类派生。这个类有获取器和设置器来获取下一个位置、移动向量以及精灵的宽度和高度。

Player 接口有内联辅助方法来检索与其当前位置相关的矩形边界信息(左、右、上、下),以及其下一个位置(next_leftnext_rightnext_topnext_bottom)。这些将在与 _terrain 对象的碰撞检测中使用。

块对象

这些对象构成了 _terrain 对象的各个独立部分。它们可以呈现建筑物的形状,或者建筑物之间的空隙。我们将有四种不同的建筑物类型,这些类型最终将代表我们引入精灵图集时的四种不同类型的纹理。这些块可以有不同的大小和高度。

Block 也从 GameSprite 派生,它也有内联辅助方法来检索其边界信息,但仅与其当前位置相关,因为 Block 并非技术上会移动。

地形对象

这个对象包含构成景观的各个 Block 对象。它包含足够的 Block 对象来填满屏幕,并且当 _terrain 对象向左滚动时,离开屏幕的 Block 对象会被移动到 _terrain 的右侧边缘,并作为新的块重新使用,确保连续滚动。

_terrain对象也负责与_player对象的碰撞检测,因为它可以快速访问我们进行碰撞检测所需的所有信息;即当前屏幕上所有块的信息,它们的大小、类型和位置。然后我们的主循环将调用Terrain对象来测试与player对象的碰撞。

让我们着手处理这些主要对象,从Player对象开始。

是时候动手编码玩家了

打开Player.cpp类。

  1. _player对象是通过一个静态方法创建的,该方法使用我们的blank.png文件来纹理精灵。该方法还调用initPlayer,这就是你应该为该方法输入的内容:

    void Player::initPlayer () {
        this->setAnchorPoint(Vec2(0.5f, 1.0f));
        this->setPosition(Vec2(_screenSize.width * 0.2f, _nextPosition.y));
    
        _height = 228;
        _width = 180;
        this->setTextureRect(Rect(0, 0, _width, _height));
        this->setColor(Color3B(255,255,255));
    }
    

    _player对象的注册点将在精灵的顶部。这个顶部中心锚点的原因更多是与_player对象在漂浮时将被如何动画处理有关,而不是与任何碰撞逻辑要求有关。

  2. 接下来是setFloating

    void Player::setFloating (bool value) {
    
        if (_floating == value) return;
    
        if (value && _hasFloated) return;
    
        _floating = value;
    
        if (value) {
            _hasFloated = true;
            _vector.y += PLAYER_JUMP * 0.5f;
        }
    }
    

    _hasFloated属性将确保玩家在空中只能打开一次雨伞。当我们把_floating设置为true时,我们给_player.y向量一个加速。

  3. 我们从_player的更新方法开始:

    void Player::update (float dt) {
        if (_speed + P_ACCELERATION <= _maxSpeed) {
            _speed += P_ACCELERATION;
        } else {
            _speed = _maxSpeed;
        }
    
        _vector.x = _speed;
    

    随着时间的推移,游戏将增加_player对象的_maxSpeed,使游戏难度增加。这些第一行代码使得从_players当前的_speed_maxSpeed的转换更加平滑,而不是立即改变。

    注意

    维多利亚时代高峰时段没有关卡,因此找出一种方法使其游戏难度逐渐增加,但又不是不可能的,这一点非常重要。在逻辑中找到这个最佳点可能需要一些时间,这也是尽快测试游戏想法的另一个原因。在这里,我们通过增加玩家的速度和建筑物之间间隙的大小来使游戏更难。这些更新都在主循环的倒计时中完成。

  4. 接下来,我们根据_player对象的_state移动状态更新_player对象:

    switch (_state) {
    
        case kPlayerMoving:
            _vector.y -= FORCE_GRAVITY;
            if (_hasFloated) _hasFloated = false;
            break;
    
       case kPlayerFalling:
          if (_floating ) {
             _vector.y -= FLOATNG_GRAVITY;
             _vector.x *= FLOATING_FRICTION;
    
          } else {
             _vector.y -= FORCE_GRAVITY;
             _vector.x *= AIR_FRICTION;
             _floatingTimer = 0;
          }
          break;
    
       case kPlayerDying:
          _vector.y -= FORCE_GRAVITY;
          _vector.x = -_speed;
          this->setPositionX(this->getPositionX() + _vector.x);
          break;
    
    }
    

    我们根据移动状态的不同,对重力和摩擦力有不同的取值。

    我们还设定了_player对象可以漂浮的时间限制,并且当_player对象不再漂浮时重置那个计时器。如果_player对象正在死亡(与墙壁碰撞),我们将_player对象向后和向下移动,直到它离开屏幕。

  5. 我们以以下内容结束:

        if (_jumping) {
            _state = kPlayerFalling;
            _vector.y += PLAYER_JUMP * 0.25f;
            if (_vector.y > PLAYER_JUMP ) _jumping = false;
        }
    
        if (_vector.y < -TERMINAL_VELOCITY) 
            _vector.y = -TERMINAL_VELOCITY;
    
        _nextPosition.y = this->getPositionY() + _vector.y;
    
        if (_floating) {
            _floatingTimer += dt;
            if (_floatingTimer > _floatingTimerMax) {
                _floatingTimer = 0;
                this->setFloating(false);
            }
        }
    }
    

    当玩家按下屏幕进行跳跃时,我们不应该让精灵立即跳跃。状态的变化应该总是平滑发生的。因此,我们在_player中有一个名为_jumping的布尔属性。当玩家按下屏幕时,它被设置为true,我们缓慢地给_vector.y添加跳跃力。所以玩家按屏幕的时间越长,跳跃就越高,快速轻触会导致跳跃较短。这是任何平台游戏都值得添加的一个好功能。

    我们接下来用终端速度限制y速度,更新_player对象的下一个位置,如果_player正在漂浮,则更新漂浮计时器。

刚才发生了什么?

_player 对象通过一系列状态进行更新。触摸屏幕将改变这个 _state 属性,以及与 _terrain 进行碰撞检查的结果。

现在让我们来编写 Block 类。

行动时间 - 编写 Block 对象的代码

再次使用一个静态方法 create,将使用 blank.png 创建我们的 Block 精灵。但这一次,我们实际上没有在 create 中更改 Block 的纹理矩形:

  1. setupBlock 方法中,Block 对象被正确地纹理化:

    void Block::setupBlock (int width, int height, int type) {
    
        _type = type;
    
        _width = width * _tileWidth;
        _height = height * _tileHeight;
    
        this->setAnchorPoint(Vec2(0,0));
        this->setTextureRect(Rect(0, 0, _width, _height));
    

    Block 对象的外观将基于其类型、宽度和高度。

    Block 精灵的注册点设置为左上角。我们最终在这里更改 Block 对象的纹理矩形大小。

  2. 然后我们根据类型设置 Block 对象的颜色:

        switch (type) {
    
            case kBlockGap:
                this->setVisible(false);
                return;
    
            case kBlock1:
    
                this->setColor(Color3B(200,200,200));
                break;
            case kBlock2:
    
                this->setColor(Color3B(150,150,150));
                break;
            case kBlock3:
    
                this->setColor(Color3B(100,100,100));
                break;
            case kBlock4:
    
                this->setColor(Color3B(50,50,50));
            break;
        }
    
        this->setVisible(true);
    
    }
    

    kBlockGap 表示没有建筑,只有 _player 对象必须跳过的空隙。在这种情况下,我们使方块不可见并从函数中返回。所以,再次强调,空隙在我们的逻辑中实际上是块的一种类型。

在这个测试版本中,不同的建筑类型用不同的颜色表示。稍后我们将使用不同的纹理。

发生了什么?

Block 对象非常简单。我们只需要它的 _width_height 值,无论它是空隙还是不是,这样我们就可以正确地运行与这些对象的碰撞检测。

规划 Terrain 类

在我们跳转到编写 Terrain 类之前,我们需要讨论一些关于随机性的问题。

在游戏开发者中,混淆随机性和可变性是一个非常常见的错误,而且知道何时需要什么非常重要。

随机数可以是任何东西。1234 是一组随机数字。下次你想得到一组随机数字,并且再次得到 1234,这将与之前一样随机。但不是变化的。

如果你决定构建随机地形,你可能会对结果感到失望,因为它不一定会有变化。此外,请记住,我们需要使地形成为游戏的关键挑战;但这意味着它既不能太简单也不能太难。真正的随机性不会给我们足够的控制,或者更糟糕的是,我们最终会得到一个长的条件列表,以确保我们有正确的块组合,这会在主循环中至少导致一个循环函数,这不是一个好主意。

我们需要通过应用自己的模式来控制结果及其可变性。

因此,我们将把这个模式的逻辑应用到我们的 _terrain 对象上,形成一个合适的随机选择池。我们将使用四个数组来存储决策中的可能结果,并在游戏中对其中三个数组进行洗牌,以增加地形中的“随机性”感觉。

这些数组是:

int patterns[] = {1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,3,3,3};

这包含了我们在空隙之间一行中拥有的建筑(Blocks)的数量信息。

你可以通过添加新值或增加或减少一个值出现的次数来轻松更改patterns值。所以在这里,我们正在创建一个在间隙之间有更多两座建筑组合的地形,而不是三座或一座的组合。

接下来,考虑以下行:

int widths[] = {2,2,2,2,2,3,3,3,3,3,3,4,4,4,4,4,4};
int heights[] =  {0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,2,2,2,3,3,3,3,3,3,4};

前面的行指定了每个新建筑的宽度和高度。这些将乘以我们为游戏确定的瓷砖大小,以获得你在Block:setupBlock中看到的最终宽度和高度值。

我们将使用0值表示高度,表示与上一个建筑之间没有高度变化。类似的逻辑可以很容易地应用于宽度。

最后:

int types[] =  {1,2,3,4,1,3,2,4,3,2,1,4,2,3,1,4,2,3,1,2,3,2,3,4,1,2,4,3,1,3,1,4,2,4,2,1,2,3};

这些是建筑类型,这个数组将不会像前三个那样洗牌,所以这是我们将用于整个游戏的typespatterns数组,它将连续循环。你可以让它尽可能长。

构建地形对象

所以每次我们需要创建一个新的方块时,我们都会根据这些数组中包含的信息来设置它。

这给了我们更多的控制权,这样我们就不可能为玩家创建不可能的障碍物组合:这是随机构建的地形在冲刺游戏中常见的错误。

但同时,我们可以轻松地扩展这个逻辑以适应每一个可能的需求。例如,我们可以通过创建这些数组的多个版本来将等级逻辑应用于我们的游戏,这样随着游戏的难度增加,我们就开始从包含特别困难值组合的数组中采样数据。

我们仍然可以使用条件循环来进一步细化结果,我会至少给你一个这样的例子。

你在patterns数组中看到的值将被存储在名为_blockPattern_blockWidths_blockHeights_blockTypes的列表中。

然后Terrain类负责在三个阶段构建游戏的地形。首先我们初始化_terrain对象,创建一个Block对象的池子。然后我们向_terrain对象添加第一个方块,直到达到最小宽度,以确保整个屏幕都填充了Blocks。最后我们分配各种方块对象。

是时候行动了——初始化我们的 Terrain 类

我们将在下一步中介绍这些步骤:

  1. 首先需要实现的重要方法是initTerrain

    void Terrain::initTerrain () {
    
        _increaseGapInterval = 5000;
        _increaseGapTimer = 0;
        _gapSize = 2;
    
        //init object pools
        for (int i = 0; i < 20; i++) {
              auto block = Block::create();
              this->addChild(block);
              _blockPool.pushBack(block);
        }
    
       _minTerrainWidth = _screenSize.width * 1.5f;
    
        random_shuffle(_blockPattern.begin(), _blockPattern.end());
        random_shuffle(_blockWidths.begin(), _blockWidths.end());
        random_shuffle(_blockHeights.begin(), _blockHeights.end());
    
       this->addBlocks(0);
    }
    

    我们有一个计时器来增加间隙的宽度(我们开始时使用两个瓷砖长度的间隙)。

    我们创建一个方块池,这样在游戏中就不会实例化任何方块。20个方块对于我们需要的来说已经足够多了。

    我们目前在地形中使用的方块将被存储在_blocks向量中。

    我们确定_terrain对象必须具有的最小宽度是屏幕宽度的1.5倍。我们将继续添加方块,直到_terrain对象达到这个最小宽度。最后,我们洗牌patterns数组并添加方块。

  2. addBlocks方法应该看起来像这样:

    void Terrain::addBlocks(int currentWidth) {
    
        while (currentWidth < _minTerrainWidth)
       {   
          auto block = _blockPool.at(_blockPoolIndex);
          _blockPoolIndex++;
          if (_blockPoolIndex == _blockPool.size()) {
            _blockPoolIndex = 0;
          }
          this->initBlock(block);
          currentWidth +=  block->getWidth();
          _blocks.pushBack(block);
       }
       this->distributeBlocks();
    }
    

    while 循环内部的逻辑将继续添加方块,直到 _terrain 对象的 currentWidth 达到 _minTerrainWidth。为了达到 _minTerrainWidth,我们从池中检索的每个新方块都会被添加到 _blocks 向量中。

  3. 方块根据它们的宽度进行分布:

    void Terrain::distributeBlocks() {
        int count = (int) _blocks.size();
        int i;
    
       for (i = 0; i < count; i++) {
          auto block =  _blocks.at(i);
          if (i != 0) {
            auto prev_block = _blocks.at(i - 1);
            block->setPositionX( prev_block->getPositionX() + prev_block->getWidth());
          }
          else
          {
            block->setPositionX ( 0 ); 
          }
       }
    }
    

发生了什么?

TerrainBlocks 的容器,我们刚刚添加了将新 block 对象添加到这个容器的逻辑。在 addBlocks 中,我们调用 initBlock 方法,该方法将使用我们 patterns 数组中的信息来初始化在地形中使用的每个方块。这就是我们将要实现的方法。

行动时间 – 初始化我们的 Blocks 对象

最后,我们将讨论基于我们的 patterns 数组初始化方块的方法:

  1. 因此,在 Terrain 类中,我们以如下方式开始 initBlock 方法:

    void Terrain::initBlock(Block * block) {
    
        int blockWidth;
        int blockHeight;
    
        int type = _blockTypes[_currentTypeIndex];
        _currentTypeIndex++;
    
        if (_currentTypeIndex == _blockTypes.size()) {
            _currentTypeIndex = 0;
        }
    

    首先确定我们正在初始化的建筑类型。看看我们是如何使用存储在 _currentTypeIndex 中的索引遍历 _blockTypes 数组的。我们会对其他 patterns 数组使用类似的逻辑。

  2. 然后,让我们开始构建我们的方块:

    if (_startTerrain) {
       //...
    } else {
        _lastBlockHeight = 2;
        _lastBlockWidth = rand() % 2 + 2;
        block->setupBlock (_lastBlockWidth, _lastBlockHeight, type);
    }
    

    玩家必须点击屏幕开始游戏(_startTerrain)。在此之前,我们显示具有相同高度(两个地砖)和随机宽度的建筑:

    行动时间 – 初始化我们的 Blocks 对象

    我们将存储 _lastBlockHeight_lastBlockWidth,因为关于地形的信息越多,我们就能更好地应用自己的条件,正如你一会儿会看到的。

  3. 考虑到我们设置为 _startTerrain

    if (_startTerrain) {
        if (_showGap) {
            int gap = rand() % _gapSize;
            if (gap < 2) gap = 2;
    
            block->setupBlock (gap, 0, kBlockGap);
            _showGap = false;
        } else {
            //...
    

    在下面的屏幕截图中,你可以看到我们方块使用的不同宽度:

    行动时间 – 初始化我们的 Blocks 对象

    _blockPattern 中的信息决定了我们一行显示多少座建筑,一旦一个系列完成,我们通过将 _showGap 的布尔值设置为 true 来显示一个间隙。间隙的宽度基于 _gapSize 的当前值,随着游戏难度增加,它可能会增加,但不能小于两倍的地砖宽度。

  4. 如果这次我们不创建间隙,我们将根据 _blockWidths_blockHeights 的当前索引值确定新方块的宽度和高度:

    } else {
    
        blockWidth = _blockWidths[_currentWidthIndex];
    
        _currentWidthIndex++;
        if (_currentWidthIndex == _blockWidths.size()) {
            random_shuffle(_blockWidths.begin(),  _blockWidths.end());
            _currentWidthIndex = 0;
        }
    
        if (_blockHeights[_currentHeightIndex] != 0) {
    
            //change height of next block
            blockHeight = _blockHeights[_currentHeightIndex];
            //if difference too high, decrease it
            if (blockHeight - _lastBlockHeight > 2 && _gapSize ==  2)  
            {
                blockHeight = 1;
            }
    
        } else {
            blockHeight = _lastBlockHeight;
        }
        _currentHeightIndex++;
        if (_currentHeightIndex == _blockHeights.size()) {
            _currentHeightIndex = 0;
            random_shuffle(_blockHeights.begin(),  _blockHeights.end());
        }
    
        block->setupBlock (blockWidth, blockHeight, type);
        _lastBlockWidth = blockWidth;
        _lastBlockHeight = blockHeight;
    

    注意,我们在遍历完数组后重新洗牌了数组(random_shuffle)。

    我们使用 _lastBlockHeight 来对我们的地形应用一个额外的条件。我们不希望下一个方块相对于前一个建筑过高,至少在游戏初期不是这样,我们可以通过检查 _gapSize 的值来确定这一点,该值只有在游戏难度增加时才会增加。

    如果 _blockHeights 的值是 0,我们不会改变新建筑的层数,而是使用 _lastBlockHeight 的相同值。

  5. 我们通过更新当前建筑系列的计数来确定是否应该显示下一个间隙,或者不显示:

    //select next block series pattern
    _currentPatternCnt++;
    
    if (_currentPatternCnt > _blockPattern[_currentPatternIndex]) {
        _showGap = true;
        //start new pattern
        _currentPatternIndex++;
        if (_currentPatternIndex == _blockPattern.size()) {
            random_shuffle(_blockPattern.begin(),  _blockPattern.end());
            _currentPatternIndex = 0;
        }
        _currentPatternCnt = 1;
        }
    }
    

发生了什么?

我们最终可以使用我们的 patterns 数组并在地形中构建方块。在这里,我们可以无限地控制构建方块的方式。但关键思想是确保游戏不会变得荒谬地困难,我建议你多尝试一些值以获得更好的结果(不要理所当然地接受我的选择)。

在我们处理碰撞之前,让我们添加移动和重置地形的逻辑。

行动时间 - 移动和重置

我们在 move 方法中移动地形。

  1. move 方法接收一个参数,即 x 轴上的移动量:

    void Terrain::move (float xMove) {
        if (xMove < 0) return;
    
        if (_startTerrain) {
    
            if (xMove > 0 && _gapSize < 5) 
            _increaseGapTimer += xMove;
    
            if (_increaseGapTimer > _increaseGapInterval) {
                _increaseGapTimer = 0;
                _gapSize += 1;
            }
        }
    
        this->setPositionX(this->getPositionX() - xMove);
    
       auto  block = _blocks.at(0);  
       if (_position.x + block->getWidth() < 0) {
          auto firstBlock = _blocks.at(0);
          _blocks.erase(0);
          _blocks.pushBack(firstBlock);
          _position.x +=  block->getWidth();
    
          float width_cnt = this->getWidth() - block->getWidth() - ( _blocks.at(0))->getWidth();
          this->initBlock(block);
          this->addBlocks(width_cnt);
        }
    }
    

    xMove 的值来自 _player 的速度。

    我们首先更新将使缝隙变宽的计时器。然后我们将地形向左移动。如果移动地形后,一个方块离开了屏幕,我们将方块移回到 _blocks 向量的末尾,并通过 initBlock 重新初始化它作为一个新的方块。

    我们调用 addBlocks,以防重新初始化的方块使得地形总宽度小于所需的最小宽度。

  2. 接下来,我们的 reset 方法:

    void Terrain::reset() {
    
        this->setPosition(Vec2(0,0));
        _startTerrain = false;
    
        int currentWidth = 0;
        for (auto block : _blocks) {
           this->initBlock(block);
           currentWidth +=  block->getWidth();
        }
    
       while (currentWidth < _minTerrainWidth) {
            auto block = _blockPool.at(_blockPoolIndex);
            _blockPoolIndex++;
            if (_blockPoolIndex == _blockPool.size()) {
                _blockPoolIndex = 0;
            }
            _blocks.pushBack(block);
            this->initBlock(block);
            currentWidth +=  block->getWidth();
       }
    
       this->distributeBlocks();
        _increaseGapTimer = 0;
        _gapSize = 2;
    }
    

    每次我们重新启动游戏时都会调用 reset 方法。我们将 _terrain 移回到其起始点,并重新初始化 _terrain 对象中当前的所有 Block 对象。这是因为在 _startTerrain = false,这意味着所有方块应该具有相同的高度和随机的宽度。

    如果在重置的最后需要更多的方块以达到 _minTerrainWidth,我们将相应地添加它们。

刚才发生了什么?

我们现在可以移动 _terrain 对象及其包含的所有方块,并且如果我们需要,我们可以重新开始整个过程。

再次强调,使用节点的容器行为极大地简化了我们的工作。当你滚动地形时,你也会滚动它包含的所有 Block 对象。

因此,我们最终准备好运行碰撞逻辑。

平台碰撞逻辑

我们已经拥有了检查碰撞所需的所有信息,这些信息可以通过 PlayerBlock 中找到的内置方法进行检查。

在这个游戏中,我们需要检查 _player 对象的底部与 block 对象的顶部之间的碰撞,以及 _player 对象的右侧与 Block 类的左侧之间的碰撞。我们将通过检查 _player 对象的当前位置和下一个位置来完成这一点。我们正在寻找以下条件:

平台碰撞逻辑

图表表示底部碰撞的条件,但同样的想法也适用于右侧碰撞。

在当前位置,_player 对象必须位于方块顶部之上或接触它。在下一个位置,_player 对象必须接触方块顶部或已经重叠它(或者完全移动过它)。这意味着发生了碰撞。

行动时间 - 添加碰撞检测

让我们看看这如何转化为代码:

  1. 仍然在 Terrain.cpp 中:

    void Terrain::checkCollision (Player * player) {
    
       if (player->getState() == kPlayerDying) return;
       bool inAir = true;
       for (auto block : _blocks) {
          if (block->getType() == kBlockGap) continue;
    
          //if within x, check y (bottom collision)
          if (player->right() >= this->getPositionX() + block->left() && player->left() <= this->getPositionX() + block->right()) {
    
            if (player->bottom() >= block->top() && player->next_bottom() <= block->top() && player->top() > block->top()) {
               player->setNextPosition(Vec2(player->getNextPosition().x, block->top() + player->getHeight()));
               player->setVector ( Vec2(player->getVector().x, 0) );
               player->setRotation(0.0);
               inAir = false;
               break;
             }       
          }
       }
    

    首先,我们声明_player对象当前正在下落,inAir = true;我们将让碰撞检查来确定这是否会保持为真。

    如果_player正在死亡,我们不检查碰撞,并且跳过与任何缝隙块的碰撞检查。

    我们在y轴上检查碰撞,在这里这意味着_player的底部和块的顶部。我们首先需要确定_player对象是否在我们想要检查碰撞的块的范围之内。这意味着_player对象的重心必须在块的左右两侧之间;否则,块离_player对象太远,可能会被忽略。

    然后,我们运行一个基本的检查,看看_player对象当前的位置和下一个位置之间是否有碰撞,使用我之前解释的条件。如果有,我们固定_player对象的位置,将其y向量速度更改为0,并且最终确定inAir = false,因为_player对象已经着陆。

  2. 接下来,我们检查x轴上的碰撞,这意味着_player对象的右侧与块的左侧:

    for (auto block : _blocks) {
      if (block->getType() == kBlockGap) continue;
      //now if within y, check x (side collision)
      if ((player->bottom() < block->top() && player->top() >  block->bottom()) || (player->next_bottom() < block->top() &&  player->next_top() > block->bottom())) {
       if (player->right() >= this->getPositionX() + block->getPositionX()  && player->left() < this->getPositionX() + block->getPositionX()) {
          player->setPositionX( this->getPositionX() +  block->getPositionX() - player->getWidth() * 0.5f );
          player->setNextPosition(Vec2(this->getPositionX() +  block->getPositionX() - player->getWidth() * 0.5f,  player->getNextPosition().y));
          player->setVector ( Vec2(player->getVector().x * -0.5f,  player->getVector().y) );
          if (player->bottom() + player->getHeight() * 0.2f <  block->top()) {
             player->setState(kPlayerDying);
             return;
          }
          break;
         }
      }
    }
    

    相似的步骤用于确定我们是否有可行的块。

    如果我们确实有侧面碰撞,将_player状态更改为kPlayerDying,我们反转其x速度,这样_player状态就会向左移动并离开屏幕,然后我们从该方法返回。

  3. 我们通过更新_player对象的状态来结束,基于我们的碰撞结果:

        if (inAir) {
            player->setState(kPlayerFalling);
        } else {
            player->setState(kPlayerMoving);
            player->setFloating (false);
        }
    }
    

发生了什么事?

我们刚刚将碰撞逻辑添加到我们的平台游戏中。就像我们在我们的第一个游戏,冰球一样,我们测试玩家的当前位置和下一个位置以确定当前迭代和下一个迭代之间是否发生了碰撞。测试只是寻找玩家和块边界之间的重叠。

添加控制

在像这样的冲刺游戏中,非常常见的是有非常简单的控制。通常,玩家只需按下屏幕进行跳跃。但我们增加了一些趣味,添加了一个浮动状态。

并且记住我们想要在状态之间有平滑的过渡,所以注意跳跃是如何实现的:不是通过立即对玩家的向量施加力,而是简单地改变一个boolean属性,并让_player对象的更新方法平滑地处理变化。

我们将在下一个步骤中处理触摸事件。

行动时间——处理触摸

让我们回到GameLayer.cpp并添加我们游戏的最终细节(有意为之)。

  1. 首先,我们处理我们的onTouchBegan方法:

    bool GameLayer::onTouchBegan(Touch* touch, Event* event) {
    
        if (!_running) {
    
            if (_player->getState() == kPlayerDying) {
                _terrain->reset();
                _player->reset();
                resetGame();
            }
            return true;
        }
    

    如果我们没有运行游戏,并且_player对象死亡,我们在触摸时重置游戏。

  2. 接下来,如果地形尚未开始,插入以下内容:

        if (!_terrain->getStartTerrain()) {
            _terrain->setStartTerrain ( true );
            return true;
        }
    

    记住,一开始建筑都是相同的高度,没有缝隙。一旦玩家按下屏幕,我们就通过setStartTerrain开始改变这一点。

  3. 我们最后完成:

        if (touch) {
          if (_player->getState() == kPlayerFalling) {
                _player->setFloating ( _player->getFloating() ? false : true );
    
           } else {
    
              if (_player->getState() !=  kPlayerDying) _player->setJumping(true);
           }
           return true;
        }
        return false;
    }
    

    现在,我们进入了游戏状态,如果 _player 对象正在下落,我们就通过调用 setFloating 来打开或关闭雨伞,具体情况而定。

    如果 _player 对象既没有下落也没有死亡,我们就通过 setJumping(true) 让它跳跃。

  4. 在触摸结束之后,我们只需要停止任何跳跃:

    void GameLayer::onTouchEnded(Touch* touch, Event* event) {
        _player->setJumping(false);
    }
    

刚才发生了什么?

我们添加了游戏控制的逻辑。如果 _player 对象当前正在下落,它将变为漂浮状态;如果当前位于建筑顶部,它将变为跳跃状态。

是时候添加我们的主游戏循环了。

行动时间 - 编写主循环

最后,是我们逻辑中的最后一部分。

  1. GameLayer.cpp 中:

    void GameLayer::update(float dt) {
    
        if (!_running) return;
    
        if (_player->getPositionY() < -_player->getHeight() ||
            _player->getPositionX() < -_player->getWidth() * 0.5f)  {
    
                _running = false;
    
        }
    

    如果 _player 对象离屏幕,我们停止游戏。

  2. 现在更新所有元素、位置并检查碰撞:

    _player->update(dt);
    
        _terrain->move(_player->getVector().x);
    
        if (_player->getState() != kPlayerDying) 
            _terrain->checkCollision(_player);
    
        _player->place();
    
  3. _gameBatchNode_player 对象相关移动:

    if (_player->getNextPosition().y > _screenSize.height * 0.6f) {
            _gameBatchNode->setPositionY( (_screenSize.height *  0.6f - _player->getNextPosition().y) * 0.8f);
    
        } else {
            _gameBatchNode->setPositionY  ( 0 );
        }
    
  4. 随着时间的推移,通过增加 _player 对象的最大速度来使游戏难度逐渐提高:

    if (_terrain->getStartTerrain() && _player->getVector().x > 0) {
    
            _speedIncreaseTimer += dt;
            if (_speedIncreaseTimer > _speedIncreaseInterval) {
                _speedIncreaseTimer = 0;
                _player->setMaxSpeed (_player->getMaxSpeed() + 4);
            }
        }
    
    }
    

刚才发生了什么?

我们已经设置了测试游戏。从这里,我们可以测试我们的地形模式、速度和一般玩法,以找到可以改进的地方。

我们应该特别检查游戏是否变得过于困难,或者我们是否有组合的建筑根本无法通过。

例如,我发现从更大的建筑群开始,比如四到五个,然后慢慢减少到两个和一,在间隙之间,可以使游戏更具趣味性,因此模式可以改变以反映这一想法。

摘要

每个游戏在其核心玩法中都包含一个简单的想法。但通常,这个想法需要大量的测试和改进,我们才能确定它是否有趣,这就是为什么快速原型设计至关重要的原因。

我们可以使用 Cocos2d-x 快速测试核心玩法想法,并在几分钟内在模拟器或设备上运行它们。

此外,这里展示的技术可以用来构建界面元素(例如我们之前游戏中的能量条)以及整个游戏!如果你不相信我,可以去你附近的 App Store 查看游戏 Square Ball

现在,随着游戏玩法逻辑的适当位置,我们可以继续制作这个游戏看起来更好!我们将在下一章中这样做。

第七章。添加外观 – 维多利亚时代高峰时段

现在我们有了测试游戏,是时候让它变得漂亮了!我们将介绍添加到游戏中以使其看起来更美观的新精灵元素,并涵盖一个或两个新主题。然而,到目前为止,你应该能够理解这个项目最终代码中的所有内容。

因此,你可以坐下来放松一下。这次,我不会让你打这么多字。我保证!

在本章中,你将学习:

  • 如何使用多个精灵为瓦片地形贴图

  • 如何在SpriteBatchNode内部使用多个容器

  • 如何创建视差效果

  • 如何在你的游戏中添加菜单

  • 如何构建游戏教程

维多利亚时代高峰时段 – 游戏画面

从本书的支持页面(www.packtpub.com/support)下载4198_07_START_PROJECT.zip文件,并在 Xcode 中运行项目。你应该能够识别出我们在测试版本中所做的所有工作,并定位到一些额外的元素。你还会看到实际的游戏玩法中没有任何新增内容。

在《维多利亚时代高峰时段》中,我想让地形成为游戏中的主要挑战,但我也想向你展示如何轻松地添加新元素到建筑中并与它们交互。

你可以稍后使用相同的逻辑来添加敌人、障碍物或为自行车精灵添加拾取物。你真正需要做的只是扩展碰撞检测逻辑以检查新项目。例如,你可以添加雨伞作为拾取物,每次_player对象漂浮时,它就会少一把雨伞。

接下来,我将列出添加到游戏中的新元素。

维多利亚时代高峰时段 – 游戏画面

新精灵

我们的游戏中添加了许多精灵:

  • 游戏开始时有一群代表交通的自行车手。

  • 我们添加了一个背景层(cityscape)和一个前景层(lamp posts),以帮助我们实现视差效果。背景中的云彩也是效果的一部分。

  • 我们为建筑添加了烟囱。当玩家点击屏幕时,烟囱会冒烟。

  • 当然,还有常规内容——得分标签、游戏标志和游戏结束信息。

    在下面的屏幕截图中,你可以看到player精灵和一群自行车手的图像:

    新精灵

动画

一些精灵现在运行动画动作:

  • _player精灵运行一个动画,展示他骑自行车的样子(_rideAnimation)。

  • 我还添加了我们老朋友摆动动画,当_player精灵漂浮时显示(_floatAnimation)。这也是为什么自行车精灵上的注册点看起来很奇怪的原因,因为摆动动画如果精灵的锚点不在中心位置看起来会更好。

  • 在游戏的介绍部分,我们的自行车手团队也被动画化,并在游戏开始时移出屏幕(_jamAnimate, _jamMove)。

  • 当玩家跳跃时,我们显示从烟囱中冒出的烟。这个动画存储在新的 Block.cpp 类中,并通过一系列动作创建,包括帧动画 (_puffAnimation, _puffSpawn, _puffMove, _puffFade_puffScale).

  • GameLayer.cpp 中,当 _player 对象死亡时,我们在 _hat 精灵上运行几个动作,使其在空中上升并再次落下,以增加一些幽默感。

现在,让我们回顾一下添加的逻辑。

使用精灵纹理我们的建筑

因此,在我们刚刚编写的测试版本中,我们的游戏屏幕被分成了 iPad 视网膜屏幕上 128 像素的瓦片。Block 对象的宽度和高度属性基于这个测量。所以一个宽度为两个瓦片、高度为三个瓦片的建筑实际上宽度为 256 像素,高度为 384 像素。间隙也会这样测量,尽管其高度设置为 0

我们用于纹理建筑的逻辑将考虑这些瓦片。

使用精灵纹理我们的建筑

因此,让我们看看添加纹理到我们建筑物的代码。

是时候进行纹理处理了——给建筑物添加纹理

initBlock 方法的运行方式现在有一些变化:

  1. 每个方块将存储四种不同类型的纹理的引用,代表游戏中使用的四种类型的建筑 (_tile1, _tile2, _tile3_tile4)。因此,我们现在在 initBlock 方法中存储这些信息:

    void Block::initBlock() {
    
      _tile1 = SpriteFrameCache::getInstance()- >getSpriteFrameByName ("building_1.png");
      _tile2 = SpriteFrameCache::getInstance()- >getSpriteFrameByName ("building_2.png");
      _tile3 = SpriteFrameCache::getInstance()- >getSpriteFrameByName ("building_3.png");
      _tile4 = SpriteFrameCache::getInstance()- >getSpriteFrameByName ("building_4.png");
    
  2. 每个方块还存储了两种纹理的引用,用于建筑屋顶瓦片 (_roof1_roof2):

    _roof1 = SpriteFrameCache::getInstance()-> getSpriteFrameByName ("roof_1.png");
      _roof2 = SpriteFrameCache::getInstance()- >getSpriteFrameByName ("roof_2.png");
    
  3. 接下来,我们创建并分配形成我们建筑的各个精灵瓦片:

    //create tiles
    for (int i = 0; i < 5; i++) {
       auto tile = Sprite::createWithSpriteFrameName("roof_1.png");
       tile->setAnchorPoint(Vec2(0, 1));
       tile->setPosition(Vec2(i * _tileWidth, 0));
       tile->setVisible(false);
       this->addChild(tile, kMiddleground, kRoofTile);
       _roofTiles.pushBack(tile);
       for (int j = 0; j < 4; j++) {
          tile =  Sprite::createWithSpriteFrameName("building_1.png");
          tile->setAnchorPoint(Vec2(0, 1));
          tile->setPosition(Vec2(i * _tileWidth, -1 *  (_tileHeight * 0.47f + j * _tileHeight)));
          tile->setVisible(false);
          this->addChild(tile, kBackground, kWallTile);
          _wallTiles.pushBack(tile);
       }
    }
    

    一个方块由 _wallTiles 向量中存储的 20 个精灵和 _roofTiles 向量中存储的 5 个精灵组成。因此,当我们初始化一个 Block 对象时,实际上创建了一个宽度为五个瓦片、高度为四个瓦片的建筑。我决定游戏中的任何建筑都不会超过这个大小。如果你决定更改这个,那么你需要在的地方进行更改。

  4. initBlock 方法还创建了五个烟囱精灵,并将它们放置在建筑的顶部。这些精灵稍后根据建筑类型分布,很容易变成我们的 _player 精灵的障碍。我们还在 initBlock 中创建了烟雾动画动作。

  5. 接下来是我们的新 setupBlock 方法,这是将不必要的瓦片和烟囱变为不可见并展开可见烟囱的地方。我们开始这个方法如下:

    void Block::setupBlock (int width, int height, int type) {
    
      this->setPuffing(false);
    
      _type = type;
    
      _width = width * _tileWidth;
      //add the roof height to the final height of the block
      _height = height * _tileHeight + _tileHeight * 0.49f;
      this->setPositionY(_height);
    
      SpriteFrame * wallFrame;
      SpriteFrame * roofFrame = rand() % 10 > 6 ? _roof1 :  _roof2;
    
      int num_chimneys;
      float chimneyX[] = {0,0,0,0,0};
    
  6. 然后,根据建筑类型,我们为烟囱精灵提供不同的 x 位置,并确定我们将用于墙面瓦片的纹理:

    switch (type) {
    
      case kBlockGap:
        this->setVisible(false);
        return;
    
      case kBlock1:
        wallFrame = _tile1;
        chimneyX[0] = 0.2f;
        chimneyX[1] = 0.8f;
        num_chimneys = 2;
        break;
      case kBlock2:
        wallFrame = _tile2;
        chimneyX[0] = 0.2f;
         chimneyX[1] = 0.8f;
        chimneyX[2] = 0.5f;
        num_chimneys = 3;
        break;
      case kBlock3:
        wallFrame = _tile3;
        chimneyX[0] = 0.2f;
        chimneyX[1] = 0.8f;
        chimneyX[2] = 0.5f;
        num_chimneys = 3;
    
        break;
      case kBlock4:
        wallFrame = _tile4;
        chimneyX[0] = 0.2f;
        chimneyX[1] = 0.5f;
        num_chimneys = 2;
        break;
    }
    
  7. 然后该方法继续定位可见的烟囱。我们最终转向建筑的纹理化。纹理屋顶和墙砖的逻辑是相同的;例如,以下是墙壁如何通过通过setDisplayFrame方法更改每个墙精灵的纹理来铺贴,然后使未使用的砖块不可见:

    count = _wallTiles->count();
      for (i  = 0; i < count; i++) {
        tile = (Sprite *) _wallTiles->objectAtIndex(i);
        if (tile->getPositionX() < _width && tile ->getPositionY() > -_height) {
          tile->setVisible(true);
          tile->setDisplayFrame(wallFrame);
        } else {
          tile->setVisible(false);
        }
      }
    }
    

发生了什么事?

当我们在initBlock中实例化一个块时,我们创建了一个由墙砖和屋顶砖块组成的 5 x 4 建筑,每个都是一个精灵。当我们需要将这个建筑变成 3 x 2 建筑,或 4 x 4 建筑,或任何其他建筑时,我们只需在setupBlock的末尾将多余的砖块设置为不可见。

屋顶使用的纹理是随机选择的,但墙壁使用的纹理是基于建筑类型(来自我们的patterns数组)。它也位于这个for循环中,所有定位在新建筑宽度高度点以上的砖块都被设置为不可见。

容器中的容器

在我们转向视差效果逻辑之前,我想谈谈与我们_gameBatchNode对象分层相关的一些事情,你可能还记得它是一个SpriteBatchNode对象。

如果你进入Terrain.cpp中的静态create方法,你会注意到对象仍然使用对blank.png纹理的引用创建:

terrain->initWithSpriteFrameName("blank.png")

事实上,测试版本中使用的相同的 1 x 1 像素图像现在在我们的精灵图集中,只是这次图像是透明的。

这是一种有点黑客式的方法,但却是必要的,因为只有当精灵的纹理源与创建批处理节点使用的纹理相同时,才能将其放置在批处理节点内。但是Terrain只是一个容器,它没有纹理。然而,通过将其blank纹理设置为包含在我们的精灵图集中的某个东西,我们可以将_terrain放置在_gameBatchNode中。

同样的操作也应用于Block类,现在,在游戏的最终版本中,它表现得像一个没有纹理的容器。它将包含墙壁和屋顶砖块以及烟囱和冒烟动画作为其子项。

我们_gameBatchNode对象内部层的组织可能看起来很复杂,有时甚至荒谬。毕竟,在同一个节点中,我们有一个前景“层”的街灯,一个中景“层”的建筑,以及一个背景“层”包含城市景观。玩家也被放置在背景中,但位于城市景观之上。不仅如此,这三个层以不同的速度移动以创建我们的视差效果,所有这些都在同一个SpriteBatchNode中!

但这种安排为我们节省的代码量足以证明我们在尝试保持批处理节点组织时可能遇到的任何困惑。现在我们可以动画化烟的冒烟,例如,而不用担心当地形向左滚动时将它们“附着”到相应的chimney精灵上。容器将负责保持一切在一起。

创建视差效果

Cocos2d-x 有一个名为ParallaxNode的特殊节点,关于它的一个令人惊讶的事情是,你实际上很少能用到它!ParallaxNode有助于使用有限层或有限滚动创建透视效果,这意味着如果你的游戏屏幕有滚动限制,你可以使用它。将ParallaxNode应用于可以无限滚动的游戏屏幕,例如Victorian Rush Hour中的屏幕,通常需要比构建自己的效果更多的努力。

通过在不同深度以不同速度移动对象来创建透视效果。一个层看起来离屏幕越远,其速度应该越慢。在游戏中,这通常意味着玩家精灵的速度被分成所有在其后面的层,并乘以出现在玩家精灵前面的层:

创建透视效果

让我们将其添加到我们的游戏中。

现在是时候创建透视效果了

我们的透视效果发生在主循环中:

  1. 因此,在我们的update方法中,你会找到以下代码行:

    if (_player->getVector().x > 0) {
      _background->setPositionX(_background->getPosition().x -  _player->getVector().x * 0.25f);
    

    首先,我们移动包含城市景观纹理沿x轴重复三次的_background精灵,并以_player精灵的四分之一速度移动它。

  2. _background精灵向左滚动,一旦第一张城市景观纹理离开屏幕,我们就将整个_background容器向右移动到第二张城市景观纹理应该出现的位置。我们通过从精灵的总宽度中减去精灵的位置来获取这个值:

    float diffx;
    
    if (_background->getPositionX() < -_background ->getContentSize().width) {
      diffx = fabs(_background->getPositionX()) - _background ->getContentSize().width;
      _background->setPositionX(-diffx);
    }
    

    因此,实际上,我们只滚动容器内的第一个纹理精灵。

  3. 使用_foreground精灵及其包含的三个路灯精灵重复类似的过程。只有_foreground精灵以玩家精灵的四倍速度移动。这些代码如下所示:

    _foreground->setPositionX(_foreground->getPosition().x - _player->getVector().x * 4);
    
    if (_foreground->getPositionX() < -_foreground ->getContentSize().width * 4) {
      diffx = fabs(_foreground->getPositionX()) - _foreground ->getContentSize().width * 4;
      _foreground->setPositionX(-diffx);
    }
    
  4. 我们还在透视效果中使用了我们的cloud精灵。由于它们出现在城市景观之后,因此距离_player更远,云层的移动速度更低(0.15):

    for (auto cloud : _clouds) {
       cloud->setPositionX(cloud->getPositionX() - _player->getVector().x * 0.15f);
       if (cloud->getPositionX() + cloud->getBoundingBox().size.width * 0.5f < 0 ) {
          cloud->setPositionX(_screenSize.width + cloud->getBoundingBox().size.width * 0.5f);
       }
    }
    

发生了什么?

我们只是通过在不同深度使用不同比例的玩家速度,简单地在我们的游戏中添加了透视效果。逻辑中稍微复杂的一部分是如何确保精灵连续滚动。但数学上非常简单。你只需要确保精灵正确对齐。

向我们的游戏中添加菜单

目前,我们只能在介绍屏幕上看到游戏标志。我们需要添加按钮来开始游戏,以及选择玩教程的选项。

为了做到这一点,我们将使用一种特殊的Layer类,称为Menu

Menu是一组MenuItems。层负责分配其项目以及跟踪所有项目上的触摸事件。项目可以是精灵、标签、图像等等。

向我们的游戏中添加菜单

现在是时候创建菜单和 MenuItem 了

GameLayer.cpp中,向下滚动到createGameScreen方法。我们将在这个方法的末尾添加新的逻辑。

  1. 首先,创建开始游戏按钮的菜单项:

    auto menuItemOn =  Sprite::createWithSpriteFrameName("btn_new_on.png");
    auto menuItemOff =  Sprite::createWithSpriteFrameName("btn_new_off.png");
    
    auto starGametItem = MenuItemSprite::create( menuItemOff,
    menuItemOn, CC_CALLBACK_1(GameLayer::startGame, this));
    

    我们通过为按钮的每个状态传递一个精灵来创建一个MenuItemSprite对象。当用户触摸一个MenuItemSprite对象时,关闭状态的精灵变为不可见,而开启状态的精灵变为可见,所有这些操作都在触摸开始事件中完成。如果触摸结束或取消,关闭状态将再次显示。

    我们还传递了此项目的回调函数;在这种情况下,GameLayer::StartGame

  2. 接下来,我们添加教程按钮:

    menuItemOn =  Sprite::createWithSpriteFrameName("btn_howto_on.png");
    menuItemOff =  Sprite::createWithSpriteFrameName("btn_howto_off.png");
    
    auto howToItem = MenuItemSprite::create( menuItemOff,  menuItemOn, CC_CALLBACK_1(GameLayer::showTutorial, this));
    
  3. 然后是创建菜单的时间:

    _mainMenu = Menu::create(howToItem, starGametItem, nullptr);
    _mainMenu->alignItemsHorizontallyWithPadding(120);
    _mainMenu->setPosition(Vec2(_screenSize.width *  0.5f, _screenSize.height * 0.54));
    
    this->addChild(_mainMenu, kForeground);
    

    Menu构造函数可以接收你希望显示的任意数量的MenuItemSprite对象。这些项目随后通过以下调用进行分布:alignItemsHorizontallyalignItemsHorizontallyWithPaddingalignItemsVerticallyWithPaddingalignItemsInColumnsalignItemsInRows。项目将按照传递给Menu构造函数的顺序显示。

  4. 然后我们需要添加我们的回调函数:

    void GameLayer::startGame (Ref* pSender) {
      _tutorialLabel->setVisible(false);
      _intro->setVisible(false);
      _mainMenu->setVisible(false);
    
      _jam->runAction(_jamMove);
      SimpleAudioEngine::getInstance() ->playEffect("start.wav");
      _terrain->setStartTerrain ( true );
      _state = kGamePlay;
    }
    
    void GameLayer::showTutorial (Ref* pSender) {
      _tutorialLabel->setString ("Tap the screen to make the player jump.");
      _state = kGameTutorialJump;
      _jam->runAction(_jamMove);
      _intro->setVisible(false);
      _mainMenu->setVisible(false);
      SimpleAudioEngine::getInstance() ->playEffect("start.wav");
      _tutorialLabel->setVisible(true);
    
    }
    

    这些是在我们的菜单按钮被点击时调用的,一个用于开始游戏,一个用于显示教程。

发生了什么?

我们刚刚创建了游戏的主菜单。Menu可以为我们节省很多时间处理按钮的所有交互逻辑。尽管它可能不如 Cocos2d-x 中的其他项目灵活,但如果我们需要它,了解它的存在仍然是有用的。

我们将在下一节处理教程部分。

将教程添加到我们的游戏中

让我们面对现实。除了空气曲棍球可能之外,这本书中到目前为止的每个游戏都可以从教程或“如何玩”部分中受益。在维多利亚时代交通高峰期中,我将向你展示一种快速实现教程的方法。

游戏教程的未言明规则是——使其可玩。这正是我们将在这里尝试的。

我们将为我们的教程创建一个游戏状态,并将一个Label对象添加到我们的舞台中,除非教程状态开启,否则使其不可见。我们将使用Label对象来显示我们的教程文本,如图中所示:

将教程添加到我们的游戏中

让我们回顾一下创建游戏教程所需的步骤。

是时候添加教程了

让我们回到我们的createGameScreen方法。

  1. 在那个方法中,添加以下行以创建我们的Label对象:

    _tutorialLabel = Label::createWithTTF("", "fonts/Times.ttf", 60);
    _tutorialLabel->setPosition(Vec2 (_screenSize.width *  0.5f, _screenSize.height * 0.6f) );
    this->addChild(_tutorialLabel, kForeground);
    _tutorialLabel->setVisible(false);
    
  2. 我们在我们的游戏状态枚举列表中添加了四个状态。这些将代表教程中的不同步骤:

    typedef enum {
      kGameIntro,
      kGamePlay,
      kGameOver,
      kGameTutorial,
      kGameTutorialJump,
      kGameTutorialFloat,
      kGameTutorialDrop
    
    } GameState;
    

    第一个教程状态kGameTutorial作为与其他游戏状态的分隔符。因此,如果_state的值大于kGameTutorial,我们就处于教程模式。

    现在,根据模式的不同,我们显示不同的消息,并等待不同的条件来切换到新的教程状态。

  3. 如果你还记得,我们的showTutorial方法从一条消息开始,告诉玩家触摸屏幕使精灵跳跃:

    _tutorialLabel->setString ("Tap the screen to make the player jump.");
    _state = kGameTutorialJump;
    
  4. 然后,在 update 方法的末尾,我们开始添加显示我们教程剩余信息的行。首先,如果玩家精灵正在跳跃并且刚刚开始下落,我们使用以下代码:

    if (_state > kGameTutorial) {
      if (_state == kGameTutorialJump) {
        if (_player->getState() == kPlayerFalling && _player ->getVector().y < 0) {
          _player->stopAllActions();
          _jam->setVisible(false);
          _jam->stopAllActions();
          _running = false;
          _tutorialLabel->setString ("While in the air, tap the screen to float.");
          _state = kGameTutorialFloat;
        }
    

    如你所见,我们让玩家知道再次点击将打开雨伞并导致精灵漂浮。

  5. 接下来,当精灵在空中漂浮时,当它到达建筑物的一定距离时,我们通知玩家,再次点击将关闭雨伞并导致精灵下落。以下是这些指令的代码:

      } else if (_state == kGameTutorialFloat) {
        if (_player->getPositionY() < _screenSize.height *  0.95f) {
          _player->stopAllActions();
          _running = false;
          _tutorialLabel->setString ("While floating, tap the screen again to drop.");
          _state = kGameTutorialDrop;
        }
    
  6. 之后,教程将完成,并显示玩家可以开始游戏的消息:

      } else {
        _tutorialLabel->setString ("That's it. Tap the screen to play.");
        _state = kGameTutorial;
      }
    }
    

    每当我们更改教程状态时,我们会暂时暂停游戏并等待点击。我们将在 onTouchBegan 中处理其余的逻辑,所以我们将稍后添加它。

  7. onTouchBegan 函数内部,在 switch 语句中,添加以下情况:

    case kGameTutorial:
      _tutorialLabel->setString("");
      _tutorialLabel->setVisible(false);
      _terrain->setStartTerrain ( true );
      _state = kGamePlay;
      break;
    
    case kGameTutorialJump:
      if (_player->getState() == kPlayerMoving) {
        SimpleAudioEngine::getInstance() ->playEffect("jump.wav");
        _player->setJumping(true);
      }
      break;
    
    case kGameTutorialFloat:
      if (!_player->getFloating()) {
        _player->setFloating (true);
        _running = true;
      }
      break;
    
    case kGameTutorialDrop:
      _player->setFloating (false);
      _running = true;
      break;
    

发生了什么?

我们在我们的游戏中添加了一个教程!正如你所见,我们使用了相当多的新状态。但现在我们可以将教程直接整合到我们的游戏中,让一个状态流畅地过渡到另一个状态。所有这些更改都可以在项目的最终版本 4198_07_FINAL_PROJECT.zip 中看到,你可以在本书的 支持 页面上找到。

现在,你已经猜到了,让我们在 Android 中运行它。

行动时间 - 在 Android 中运行游戏

按照以下步骤将游戏部署到 Android:

  1. 在文本编辑器中打开你项目中的 Android.mk 文件。

  2. LOCAL_SRC_FILES 中的行编辑为以下内容:

    LOCAL_SRC_FILES := hellocpp/main.cpp \
                       ../../Classes/AppDelegate.cpp \
                       ../../Classes/Block.cpp \
                       ../../Classes/GameSprite.cpp \
                       ../../Classes/Player.cpp \
                       ../../Classes/Terrain.cpp \
                       ../../Classes/GameLayer.cpp
    
  3. 将游戏导入 Eclipse 并等待所有类编译完成。

  4. 就这样。保存并运行你的应用程序。

发生了什么?

现在,你已经可以在 Android 上运行 维多利亚时代高峰时段 了。

摘要

在我们的测试游戏中,我们详细调整了游戏玩法后,引入精灵图集和游戏状态看起来非常简单和容易。

但在这个阶段,我们也可以考虑新的方法来改进游戏玩法。例如,意识到从烟囱冒出的烟雾云可以为玩家提供很好的视觉提示,以识别建筑物的位置,如果骑自行车的人跳得太高。或者,空中飞舞的帽子可能很有趣!

现在是时候将物理效果引入我们的游戏了,所以继续阅读下一章。

第八章. 获得物理感 – Box2D

是时候处理物理了!Cocos2d-x 随带 Box2D 和 Chipmunk。这些都是所谓的 2D 物理引擎 – 第一个是用 C++ 编写的,第二个是用 C 编写的。Chipmunk 有一个更近期的 Objective-C 版本,但 Cocos2d-x 必须使用用 C 编写的原始版本以保持兼容性。

我们将在这本书的示例中使用 Box2D。接下来两个我将展示的游戏将使用该引擎开发,从简单的台球游戏开始,以展示在项目中使用 Box2D 的所有主要点。

在本章中,你将学习:

  • 如何设置和运行 Box2D 模拟

  • 如何创建刚体

  • 如何使用调试绘制功能快速测试你的概念

  • 如何使用碰撞过滤器监听器

使用 Cocos2d-x 构建 Box2D 项目

使用框架的 3.x 版本,我们不再需要指定我们想要使用物理引擎。项目默认添加了这些 API。因此,为了创建一个 Box2D 项目,你只需要创建一个常规的 Cocos2d-x 项目,就像我们到目前为止在示例中所做的那样。

然而,如果你想在项目中使用一个名为调试绘制的功能,你需要执行一个额外的步骤。所以,让我们现在设置它。

行动时间 – 在你的 Box2D 项目中使用调试绘制

让我们先创建项目。在我的机器上,我在桌面上创建了一个名为 MiniPool 的游戏。以下是步骤:

  1. 打开终端并输入以下命令:

    cocos new MiniPool -p com.rengelbert.MiniPool -l cpp -d /Users/rengelbert/Desktop/MiniPool
    
  2. 在 Xcode 中打开新项目。

  3. 现在,导航到 Cocos2d-x 框架文件夹内的 Tests 文件夹。这可以在 tests/cpp-tests/Classes 中找到。然后打开 Box2DTestBed 文件夹。

  4. 将文件 GLES-Render.hGLES-Render.cpp 拖到你的 Xcode 项目中。

  5. 你也可以打开测试文件夹 Box2DTest 中的 Box2dTest.cpp 类,因为我们将从那里复制并粘贴一些方法。

  6. HelloWorldScene.h 头文件中,保留现有的包含,但将类声明更改为以下内容:

    class HelloWorld : public cocos2d::Layer {
    public:
        virtual ~HelloWorld();
        HelloWorld();
    
       static cocos2d::Scene* scene();
    
        void initPhysics();
        void update(float dt);
        virtual void draw(Renderer *renderer, const Mat4 &transform, uint32_t flags) override;
    
    private:
       GLESDebugDraw * _debugDraw;
        b2World* world;
        Mat4 _modelViewMV;
        void onDraw();
        CustomCommand _customCommand;
    };
    
  7. 然后在顶部添加以下 include 语句:

    #include "GLES-Render.h"
    
  8. 然后,在 HelloWorldScene.cpp 实现文件中,将 using namespace CocosDenshionHelloWorld::scene 方法之间的行替换为以下内容:

    #define PTM_RATIO 32
    
    HelloWorld::HelloWorld()
    {
        this->initPhysics();
        scheduleUpdate();
    }
    
    HelloWorld::~HelloWorld()
    {
        delete world;
        world = nullptr;
    
        delete _debugDraw;
       _debugDraw = nullptr;
    }
    
    void HelloWorld::initPhysics() {
    
        b2Vec2 gravity;
        gravity.Set(0.0f, -10.0f);
        world = new b2World(gravity);
    
        // Do we want to let bodies sleep?
        world->SetAllowSleeping(true);
        world->SetContinuousPhysics(true);
    
        _debugDraw = new (std::nothrow) GLESDebugDraw( PTM_RATIO );
        world->SetDebugDraw(_debugDraw);
    
        uint32 flags = 0;
        flags += b2Draw::e_shapeBit;
        //        flags += b2Draw::e_jointBit;
        //        flags += b2Draw::e_aabbBit;
        //        flags += b2Draw::e_pairBit;
        //        flags += b2Draw::e_centerOfMassBit;
        _debugDraw->SetFlags(flags);
    
    }
    void HelloWorld::update(float dt)
    {
        world->Step(dt, 8, 1);
    
    }
    
  9. 现在是 draw 方法的实现时间。你可以从 Box2DTest 文件夹复制并粘贴大部分代码:

    void GameLayer::draw(Renderer *renderer, const Mat4 &transform, uint32_t flags)
    {
       //
        // IMPORTANT:
        // This is only for debug purposes
        // It is recommended to disable it
        //
       Layer::draw(renderer, transform, flags);
      GL::enableVertexAttribs( cocos2d::GL::VERTEX_ATTRIB_FLAG_POSITION );
        auto director = Director::getInstance();
        CCASSERT(nullptr != director, "Director is null when setting matrix stack");
        director->pushMatrix(MATRIX_STACK_TYPE::MATRIX_STACK_MODELVIEW);
    
        _modelViewMV = director->getMatrix(MATRIX_STACK_TYPE::MATRIX_STACK_MODELVIEW);
    
        _customCommand.init(_globalZOrder);
        _customCommand.func = CC_CALLBACK_0(GameLayer::onDraw, this);
        renderer->addCommand(&_customCommand);
    
        director->popMatrix(MATRIX_STACK_TYPE::MATRIX_STACK_MODELVIEW);
    
    }
    
    void GameLayer::onDraw()
    {
       auto director = Director::getInstance();
       Mat4 oldMV;
        oldMV = director->getMatrix(MATRIX_STACK_TYPE::MATRIX_STACK_MODELVIEW);
        director->loadMatrix(MATRIX_STACK_TYPE::MATRIX_STACK_MODELVIEW, _modelViewMV);
        _world->DrawDebugData();
        director->loadMatrix(MATRIX_STACK_TYPE::MATRIX_STACK_MODELVIEW, oldMV);
    }
    

发生了什么?

GLES-Render 类是使用 Box2D 中的调试绘制功能所必需的。这将在屏幕上绘制模拟的所有元素。调试绘制对象在 initPhysics 方法中与 Box2D 模拟(b2World)一起创建。我们稍后会介绍这个逻辑。

draw 方法中的注释所述,一旦你完成游戏开发,应该关闭调试绘制功能。因此,当你准备发布版本时,应该注释掉与该对象以及 draw 方法相关的所有行。

那么物理引擎是什么?

著名的艾萨克·牛顿曾说,每一个作用力都有一个反作用力。就在他说完这句话后,他问道,谁他妈扔的那个苹果?

到目前为止,在我们的游戏中,我们涵盖了非常简单的碰撞系统,基本上只是检查简单的形状(圆形和矩形)是否重叠。到目前为止,我们游戏中这些碰撞的反应也非常简单:通过向量反转或者简单地让物体在接触后消失。使用 Box2D,你将得到更多!

Box2D 是一个非常健壮的碰撞检测引擎,当然可以仅为此目的使用。但模拟还会处理并返回从碰撞和物体之间的相互作用中派生的一组信息,这意味着根据它们的形状、质量和模拟中所有作用力,物体应该如何表现。

认识 Box2D

在引擎的核心,你有一个 b2World 对象。这就是模拟。你用 b2Body 对象填充世界,然后通过 b2World->Step() 来遍历模拟。然后你从模拟中获取结果,并通过你的精灵将它们显示给用户,通过获取一个 b2Body 对象的位置和旋转并将其应用到精灵上。

调试绘图对象允许你不用任何精灵就能看到模拟。有点像我们第六章中的测试项目版本,快速简单的精灵 – 维多利亚时代的交通高峰

认识世界

大多数时候,物理模拟意味着创建一个 b2World 对象。然而,请注意,你可以在同一个游戏中管理多个 world 对象,例如为了多个视角。但这将是另一本书的内容。

在我们的简化基本项目中,世界的创建方式如下:

b2Vec2 gravity;
gravity.Set(0.0f, -10.0f);
world = new b2World(gravity);

// Do we want to let bodies sleep?
world->SetAllowSleeping(true);
world->SetContinuousPhysics(true);

_debugDraw = new (std::nothrow) GLESDebugDraw( PTM_RATIO );
world->SetDebugDraw(_debugDraw);

uint32 flags = 0;
flags += b2Draw::e_shapeBit;
//        flags += b2Draw::e_jointBit;
//        flags += b2Draw::e_aabbBit;
//        flags += b2Draw::e_pairBit;
//        flags += b2Draw::e_centerOfMassBit;
_debugDraw->SetFlags(flags);

Box2D 有自己的向量结构,b2Vec2,我们在这里用它来创建世界的重力。b2World 对象接收这个作为它的参数。当然,模拟并不总是需要重力;在这种情况下,参数将是一个 (0, 0) 向量。

SetAllowSleeping 表示如果对象没有移动并且因此没有生成派生数据,则跳过检查这些对象的派生数据。

SetContinuousPhysics 表示我们手中有一些快速的对象,我们稍后会将其指向模拟,以便它对碰撞给予额外的关注。

然后我们创建调试绘图对象。正如我之前所说的,这是可选的。标志指示你希望在绘图中看到什么。在之前的代码中,我们只想看到物体的形状。

然后是PTM_RATIO,这是我们传递给 debug draw 的参数定义的常量。Box2D 出于各种原因使用米而不是像素,这些原因对任何人来说都完全没有必要知道。除了一个原因,像素到米PTM),所以游戏中使用的每个像素位置值都将除以这个比例常数。如果这个除法的结果超过 10 或低于 0.1,相应地增加或减少PTM_RATIO的值。

当然,你有一些灵活性。一旦你的游戏完成,不妨玩玩这个值,并特别注意速度上的细微差别(这个比例的另一个常见值是 100)。

运行模拟

正如我之前所说,你使用Step方法来运行模拟,通常在主循环内部进行,尽管不一定是这样:

world->Step(dt, 8, 1);

你需要传递给它时间步长,这里由主循环中的 delta 时间表示。然后传递步中的速度迭代次数和位置迭代次数。这基本上意味着速度和位置将在一个步长内被处理多少次。

在前面的例子中,我使用了 Cocos2d-x 中 Box2D 模板的默认值。通常,固定时间步长比 delta 更好,如果你的游戏中的物体移动得很快,可能还需要更高的位置迭代值。但始终记得玩这些值,目标是找到尽可能低的值。

Box2D 中没有 Ref 对象

Box2D 不使用Ref对象。所以,没有内存管理!记得通过delete而不是release来删除所有 Box2D 对象。如果你已经知道了……嗯,你记得:

HelloWorld::~HelloWorld(){
    delete world;
    world = nullptr;

    delete _debugDraw;
   _debugDraw = nullptr;
}

注意

正如我之前提到的,C++11 引入了智能指针,它们是内存管理的,这意味着你不需要自己删除这些对象。然而,共享指针的话题超出了本书的范围,在本章中使用唯一指针会添加很多与 Box2D 无关的行。尽管智能指针很棒,但它们的语法和用法,嗯,让我们说非常“C++风格”。

遇到物体

b2Body对象是你在 Box2D 模拟中花费大部分时间处理的东西。你有三种主要的b2Bodies类型:动态、静态和运动学。前两种更为重要,也是我们在游戏中会使用的类型。

物体是通过将身体定义与身体固定件组合来创建的。身体定义是一个包含类型、位置、速度和角度等信息的数据结构。固定件包含有关形状的信息,包括其密度、弹性和摩擦。

因此,要创建一个宽度为 40 像素的圆形,你会使用以下方法:

b2BodyDef bodyDef;
bodyDef.type = b2_dynamicBody;
//or make it static bodyDef.type = b2_staticBody;
b2Body * body = world->CreateBody(&bodyDef);

//create circle shape
b2CircleShape  circle;
circle.m_radius = 20.0/PTM_RATIO;

//define fixture
b2FixtureDef fixtureDef;
fixtureDef.shape = &circle;
fixtureDef.density = 1;
fixtureDef.restitution = 0.7;
fixtureDef.friction = 0.4;

body->CreateFixture(&fixtureDef);

要创建一个宽度为 40 像素的盒子,你会使用以下方法:

//create body
b2BodyDef bodyDef;
bodyDef.type = b2_dynamicBody;
b2Body * body = world->CreateBody(&bodyDef);

//define shape
b2PolygonShape box;
box.SetAsBox(20 /PTM_RATIO, 20 / PTM_RATIO);

//Define fixture
b2FixtureDef fixtureDef;
fixtureDef.shape = &box;
fixtureDef.density = 2;
fixtureDef.restitution = 0;
body->CreateFixture(&fixtureDef);

注意,你使用world对象来创建物体。还要注意,盒子是用其期望宽度和高度的一半创建的。

密度、摩擦和恢复系数都有默认值,所以你不必总是设置这些。

我们的游戏 – MiniPool

我们的游戏由十六个球(圆形)、一个球杆(矩形)以及由六条线(边缘)和六个球洞(圆形)组成的台球桌组成。从 Box2D 模拟的角度来看,这就是全部内容。

如果你希望跟随最终代码,请从本书的 支持 页面下载最终项目。Box2D 是一个复杂的 API,最好是通过审查和暴露逻辑而不是通过大量输入来工作。所以这次将没有起始项目可供工作。你可以选择任何方式将完成的项目中的文件添加到我在展示如何设置调试绘制对象时开始的项目中。最终游戏将看起来像这样:

我们的游戏 – MiniPool

游戏设置

这是一个仅支持竖屏的游戏,不允许屏幕旋转,并且是通用应用。游戏是为标准 iPhone(320 x 480)设计的,其分辨率大小设置为 kResolutionShowAll。这将显示不匹配 iPhone 1.5 屏幕比例的设备上的主屏幕周围的边框。

//in AppDelegate.cpp
auto screenSize = glview->getFrameSize();
auto designSize = Size(320, 480);

glview->setDesignResolutionSize(designSize.width, designSize.height, ResolutionPolicy::SHOW_ALL);
std::vector<std::string> searchPaths;
   if (screenSize.width > 640) {
      searchPaths.push_back("ipadhd");
      director->setContentScaleFactor(1280/designSize.width);
   } else if (screenSize.width > 320) {
      searchPaths.push_back("ipad");
      director->setContentScaleFactor(640/designSize.width);
   } else {
      searchPaths.push_back("iphone");
      director->setContentScaleFactor(320/designSize.width);
   }
   auto fileUtils = FileUtils::getInstance();
   fileUtils->setSearchPaths(searchPaths);

注意,我使用 iPhone 的尺寸来识别更大的屏幕。所以 iPad 和 iPhone retina 被认为是 320 x 480 的两倍,而视网膜 iPad 被认为是 320 x 480 的四倍。

精灵加 b2Body 等于 b2Sprite

在 Cocos2d-x 中处理 b2Body 对象最常见的方式是将它们与精灵结合使用。在我将要展示的游戏中,我创建了一个名为 b2Sprite 的类,它扩展了精灵并添加了一个 _body 成员属性,该属性指向其自己的 b2Body。我还添加了一些辅助方法来处理我们讨厌的 PTM_RATIO。请随意添加您认为必要的这些方法。

b2Body 对象有一个极其有用的属性,称为 userData。你可以在其中存储任何你希望的内容,并且这些对象将携带它穿过整个模拟。所以,大多数开发者会这样做,他们在对象的 userData 属性中存储一个指向包裹它的精灵实例的引用。所以 b2Sprite 知道它的 body,而 body 知道它的 b2Sprite

小贴士

实际上,在处理 Box2D 时,组合是关键。所以,在设计你的游戏时,确保每个对象都知道其他对象或可以快速访问它们。这将非常有帮助。

创建台球桌

在调试绘制视图中,桌子看起来是这样的:

创建台球桌

这里看到的所有元素都是在 GameLayer.cpp 中的 initPhysics 方法内部创建的。桌子除了我们游戏中使用的背景图像外没有视觉表示。所以没有精灵附加到单个球洞上,例如。

球洞对象是在 for 循环内部创建的,我使用了我能想到的最佳算法来正确地在屏幕上分布它们。这个逻辑可以在 initPhysics 方法中找到,所以让我们看看它,看看我们的第一个 b2Body 对象是如何创建的:

b2Body * pocket;
b2CircleShape circle;
float startX = _screenSize.width * 0.07;
float startY = _screenSize.height * 0.92f;
for (int i = 0; i < 6; i++) {
   bodyDef.type = b2_staticBody;
   if (i < 3) {
      bodyDef.position.Set(startX/PTM_RATIO,
(startY - i * (_screenSize.height * 0.84f * 0.5f))/PTM_RATIO);

    } else {
        bodyDef.position.Set(
        (startX + _screenSize.width * 0.85f)/PTM_RATIO,
        (startY - (i-3) * (_screenSize.height * 0.84f * 0.5f))/PTM_RATIO);
    }
    pocket = _world->CreateBody(&bodyDef);
    fixtureDef.isSensor = true;
    circle.m_radius = (float) (1.5 * BALL_RADIUS) / PTM_RATIO;
    fixtureDef.shape = &circle;

    pocket->CreateFixture(&fixtureDef);
    auto pocketData = new b2Sprite(this, kSpritePocket);
    pocket->SetUserData(pocketData);
}

pocket物体是静态物体,我们在它们的固定定义中确定它们应该像传感器一样行为:

fixtureDef.isSensor = true;

这将关闭一个对象的全部物理属性,将其变成碰撞热点。传感器仅用于确定是否有物体接触它。

提示

几乎总是最好忽略 Box2D 传感器,并使用自己的精灵或点在你的碰撞逻辑中。传感器的一个很酷的功能是,它们使得确定某物刚刚停止接触它们变得非常容易,正如我们一旦覆盖了接触监听器就会看到的那样。

创建边缘

如果一个形状只能从一侧被击中,那么边缘可能就是你需要的东西。以下是我们如何在游戏中创建边缘的方法:

b2BodyDef tableBodyDef;
tableBodyDef.position.Set(0, 0);
b2Body* tableBody = _world->CreateBody(&tableBodyDef);

// Define the table edges
b2EdgeShape tableBox;

// bottom edge
tableBox.Set(b2Vec2(_screenSize.width * 0.14f/PTM_RATIO, _screenSize.height * 0.09f/PTM_RATIO),
b2Vec2(_screenSize.width * 0.86f/PTM_RATIO, _screenSize.height * 0.09f/PTM_RATIO));
tableBody->CreateFixture(&tableBox,0);

// top edge
tableBox.Set(b2Vec2(_screenSize.width * 0.14f/PTM_RATIO, _screenSize.height * 0.91f/PTM_RATIO),
    b2Vec2(_screenSize.width * 0.86f/PTM_RATIO, _screenSize.height * 0.91f/PTM_RATIO));
tableBody->CreateFixture(&tableBox,0);

因此,同一个b2Body对象可以有你需要的大量边缘。你设置一个边缘,指定其起点和终点(在这种情况下,是b2Vec2结构),并将其作为固定件添加到身体中,密度为0

创建球对象

在游戏中,有一个名为Ball的类,它扩展了b2Sprite,用于目标球和球杆球。这些对象也是在initPhysics方法内部创建的。以下是该对象的基本配置:

//create Box2D body
b2BodyDef bodyDef;
bodyDef.type = b2_dynamicBody;

_body = _game->getWorld()->CreateBody(&bodyDef);
_body->SetLinearDamping(1.2f);
_body->SetAngularDamping(0.2f);

//create circle shape
b2CircleShape  circle;
circle.m_radius = BALL_RADIUS/PTM_RATIO;

//define fixture
b2FixtureDef fixtureDef;
fixtureDef.shape = &circle;
fixtureDef.density = 5;
fixtureDef.restitution = 0.7f;

//add collision filters so only white ball can be hit by cue
if (_type == kSpriteBall) {
    fixtureDef.filter.categoryBits = 0x0010;
} else if (_type == kSpritePlayer) {
//white ball is tracked as bullet by simulation
    _body->SetBullet(true);
    fixtureDef.filter.categoryBits = 0x0100;
}

//set sprite texture
switch (_color) {
    case kColorBlack:
        this->initWithSpriteFrameName("ball_black.png");
        break;
    case kColorRed:
        this->initWithSpriteFrameName("ball_red.png");
        break;
    case kColorYellow:
        this->initWithSpriteFrameName("ball_yellow.png");
        break;
    case kColorWhite:
        this->initWithSpriteFrameName("ball_white.png");
        break;
}

_body->CreateFixture(&fixtureDef);
//store the b2Sprite as the body's userData
_body->SetUserData(this);

friction固定属性涉及两个接触表面(两个物体)的反应。在这种情况下,我们想要与桌面表面创建“摩擦”,而桌面根本不是一个物体。因此,我们需要使用的是阻尼。这将产生与摩擦类似的效果,但不需要额外的表面。阻尼可以应用于物体的线性速度向量,如下所示:

_body->SetLinearDamping(1.2);

并且将其角速度设置为如下:

_body->SetAngularDamping(0.2);

此外,白球被设置为子弹形状:

_body->SetBullet(true);

这将使模拟在碰撞方面对这个对象给予额外的关注。我们本可以将游戏中的所有球都设置为子弹形状,但这不仅是不必要的(通过测试揭示的事实),而且也不太适合处理。

创建碰撞过滤器

ball对象中,固定定义内部有一个filter属性,我们使用它来屏蔽碰撞。这意味着我们确定哪些物体可以相互碰撞。球杆球接收与其它球不同的categoryBits值。

fixtureDef.filter.categoryBits = 0x0100;

当我们创建球杆体时,我们在其固定定义中设置了一个maskBits属性,如下所示:

fixtureDef.filter.maskBits = 0x0100;

我们将其设置为与白球的categoryBits相同的值。

所有这些的结果?现在球杆只能击中具有相同categoryBits的物体,在这里这意味着球杆只能与白球碰撞。

可以使用位运算符|将多个类别添加到屏蔽中,如下所示:

fixtureDef.filter.maskBits = 0x0100 | 0x0010;

或者,例如,不与球杆球碰撞,如下所示:

fixtureDef.filter.maskBits = 0xFFFF & ~0x0100;

创建球杆

球杆球也扩展了b2Sprite,其身体被设置为盒子形状。

//create body
b2BodyDef bodyDef;
bodyDef.type = b2_dynamicBody;

_body = _game->getWorld()->CreateBody(&bodyDef);
_body->SetLinearDamping(8);
_body->SetAngularDamping(5);

//Define shape
b2PolygonShape box;
box.SetAsBox(BALL_RADIUS * 21 /PTM_RATIO, BALL_RADIUS * 0.2 / PTM_RATIO);

//Define fixture
b2FixtureDef fixtureDef;
fixtureDef.shape = &box;
fixtureDef.filter.maskBits = 0x0100;
fixtureDef.density = 10;
fixtureDef.restitution = 1;
_body->CreateFixture(&fixtureDef);
_body->SetUserData(this);

它具有非常高的阻尼值,因为在玩家偶尔错过母球的情况下,球杆不会飞出屏幕,而是在白球几像素之外停止。

如果我们想要将球杆创建为梯形或三角形,我们需要给b2PolygonShape选项提供我们想要的顶点。以下是一个例子:

b2Vec2 vertices[3];
vertices[0].Set(0.0f, 0.0f);
vertices[1].Set(1.0f, 0.0f);
vertices[2].Set(0.0f, 1.0f);
int32 count = 3;

b2PolygonShape triangle;
triangle.Set(vertices, count);

并且顶点必须逆时针添加到数组中。这意味着,如果我们首先添加三角形的顶点,下一个顶点必须是左边的那个。

一旦所有元素都到位,调试绘制看起来是这样的:

创建球杆

创建接触监听器

除了碰撞过滤器之外,Box2D 中还有另一个有助于碰撞管理的功能,那就是创建一个接触监听器。

initPhysics方法内部,我们创建world对象如下:

b2Vec2 gravity;
gravity.Set(0.0f, 0.0f);
_world = new b2World(gravity);

_world->SetAllowSleeping(true);
_world->SetContinuousPhysics(true);
_collisionListener = new CollisionListener();
_world->SetContactListener(_collisionListener);

我们的CollisionListener类扩展了 Box2D 的b2ContactListener类,并且必须实现以下方法中的至少一个:

  • void BeginContact(b2Contact* contact);

  • void EndContact(b2Contact* contact);

  • void PreSolve(b2Contact* contact, const b2Manifold* oldManifold);

  • void PostSolve(b2Contact* contact, const b2ContactImpulse* impulse);

这些事件都与一个接触(碰撞)相关,并在接触的不同阶段触发。

注意

传感器对象只能触发BeginContactEndContact事件。

在我们的游戏中,我们实现了这两种方法中的两种。第一种是:

void CollisionListener::BeginContact(b2Contact* contact) {
    b2Body * bodyA = contact->GetFixtureA()->GetBody();
    b2Body * bodyB = contact->GetFixtureB()->GetBody();

    b2Sprite * spriteA = (b2Sprite *) bodyA->GetUserData();
    b2Sprite * spriteB = (b2Sprite *) bodyB->GetUserData();

    if (spriteA && spriteB) {
        //track collision between balls and pockets
        if (spriteA->getType() == kSpritePocket) {
            spriteB->setVisible(false);
        } else if (spriteB->getType() == kSpritePocket) {
            spriteA->setVisible(false);
        } else if (spriteA->getType() == kSpriteBall &&
            spriteB->getType() == kSpriteBall) {
            if (spriteA->mag() > 10 || spriteB->mag() > 10) {
            SimpleAudioEngine::getInstance()->playEffect("ball.wav");
            }
        } else if ((spriteA->getType() == kSpriteBall &&
                    spriteB->getType() == kSpritePlayer) ||
                    (spriteB->getType() == kSpriteBall &&
                    spriteA->getType() == kSpritePlayer)) {
            if (spriteA->mag() > 10 || spriteB->mag() > 10) {
               SimpleAudioEngine::getInstance()->playEffect("ball.wav");
            }
        }
    }
}

现在,你可以看到userData属性有多么重要。我们可以通过userData属性快速访问b2Contact对象中列出的身体所附加的精灵。

除了那之外,我们所有的精灵都有一个_type属性,它在我们的逻辑中就像标识标签。注意,你当然可以使用 Cocos2d-x 标签来做到这一点,但我发现有时,如果你可以将Sprite标签与它们的_type值结合起来,你可能会产生有趣的排序逻辑。

因此,在BeginContact中,我们跟踪球和袋之间的碰撞。但我们还跟踪球之间的碰撞。在第一种情况下,当球接触到袋时,球会变得不可见。在第二种情况下,每当两个球以一定速度(我们通过一个b2Sprite辅助方法检索精灵速度向量的平方模量来确定)相撞时,我们会播放音效。

监听器中的另一种方法是:

void CollisionListener::PreSolve(b2Contact* contact, const b2Manifold* oldManifold)  {

    b2Body * bodyA = contact->GetFixtureA()->GetBody();
    b2Body * bodyB = contact->GetFixtureB()->GetBody();

    b2Sprite * spriteA = (b2Sprite *) bodyA->GetUserData();
    b2Sprite * spriteB = (b2Sprite *) bodyB->GetUserData();

    if (spriteA && spriteB) {

    //track collision between player and cue ball
        if (spriteA->getType() == kSpriteCue && spriteA->mag() > 2) {
            if (spriteB->getType() == kSpritePlayer && spriteA->isVisible()) {
                SimpleAudioEngine::getInstance()->playEffect("hit.wav");
                spriteA->setVisible(false);
                spriteB->getGame()->setCanShoot(false);
            }
        } else if (spriteB->getType() == kSpriteCue && spriteA->mag()> 2) {
            if (spriteA->getType() == kSpritePlayer && spriteB->isVisible()) {
                SimpleAudioEngine::getInstance()->playEffect("hit.wav");
                spriteB->setVisible(false);
                spriteA->getGame()->setCanShoot(false);
            }
        } 

    }
}

在这里,我们在计算反应之前监听碰撞。如果球杆和白球之间发生碰撞,我们会播放音效,并隐藏球杆。

注意

如果你想要强制自己的逻辑到碰撞反应并覆盖 Box2D,你应该在PreSolve方法中这样做。然而,在这个游戏中,我们本可以将所有这些碰撞逻辑添加到BeginContact方法中,效果也会一样好。

游戏控制

在游戏中,玩家必须点击白球,然后拖动手指来激活球杆。手指离白球越远,射击力就越强。

因此,让我们添加事件来处理用户输入。

是时候添加触摸事件了

我们首先处理onTouchBegan

  1. onTouchBegan 方法中,我们首先更新游戏状态:

    bool GameLayer::onTouchBegan(Touch * touch, Event * event) {
    
        if (!_running) return true;
    
        if (_gameState == kGameOver) {
            if (_gameOver->isVisible()) _gameOver->setVisible(false);
            resetGame();
            return true;
        }
    
  2. 接下来,我们检查 _canShoot 的值。如果白球没有移动,它将返回 true

    if (!_canShoot) return true;
    
  3. 接下来,我们确定触摸是否落在白球上。如果是,如果游戏尚未运行,我们就开始游戏并使计时器可见。以下是执行此操作的代码:

    if (touch) {
    
        auto tap = touch->getLocation();
        auto playerPos = _player->getPosition();
        float diffx = tap.x - playerPos.x;
        float diffy = tap.y - playerPos.y;
        float diff = pow(diffx, 2) + pow(diffy, 2);
        if (diff < pow(BALL_RADIUS * 4, 2)) {
            if (_gameState != kGamePlay) {
                _gameState = kGamePlay;
                if (_intro->isVisible()) _intro->setVisible(false);
                _timer->setVisible(true);
            }
        }
    }
    

    注意,我们在我们的逻辑中使用了一个较大的白球半径(四倍大)。这是因为我们不希望目标区域太小,因为这个游戏将在 iPhone 和 iPad 上运行。我们希望玩家能够舒适地用手指击打白球。

  4. 我们存储点在球中的位置。这样,玩家就可以在不同的位置击打球,使其以不同的角度移动:

    //make point lie within ball
    if (diff > pow(BALL_RADIUS * 2, 2)) {
        float angle = atan2(diffy, diffx);
        _cueStartPoint = Vec2(
                playerPos.x + BALL_RADIUS * 0.8f * cos(angle),
                playerPos.y + BALL_RADIUS * 0.8f * sin(angle));
    } else {
        _cueStartPoint = playerPos;
    }
    

    由于我们使白球成为 touch 事件的一个更大的目标,现在我们必须确保玩家实际选择的点位于球内。因此,我们可能需要在这里做一些调整。

  5. 我们将点传递给我们的 LineContainer 对象,并准备要使用的球杆身体,如下所示:

    _lineContainer->setBallPoint(_cueStartPoint);
    _cue->getBody()->SetLinearVelocity(b2Vec2(0,0));
    _cue->getBody()->SetAngularVelocity(0.0);
    _touch = touch;
    

    我们再次有一个 LineContainer 节点,这样我们就可以在提示和球上将要击中的点之间画一条虚线。这为玩家准备他们的击球提供了一个视觉辅助。视觉辅助效果如下所示:

    执行动作时间 - 添加触摸事件

  6. onTouchMoved 中,我们只需要根据玩家手指的位置移动球杆身体。因此,我们计算移动触摸和白球之间的距离。如果球杆身体仍然太靠近球,我们将将其 body 对象设置为 sleep 并将其 texture 对象设置为 invisible

    void GameLayer::onTouchMoved(Touch * touch, Event * event) {
       if (touch && touch == _touch) {
             Point tap = touch->getLocation();
             float diffx = tap.x - _player->getPositionX();
             float diffy = tap.y - _player->getPositionY();
             if (pow(diffx,2) + pow(diffy,2) < pow(BALL_RADIUS * 2,2)) {
                _usingCue = false;
                _lineContainer->setDrawing(false);
                _cue->setVisible(false);
               _cue->getBody()->SetAwake(false);
             } else {
               _usingCue = true;
               _cue->setVisible(true);
               _lineContainer->setDrawing(true);
              placeCue(tap);
              _cue->getBody()->SetAwake(true);
             }
       }
    }
    
  7. 否则,我们唤醒身体并按照以下方式调用 placeCue 方法:

    void GameLayer::placeCue(Point position) {
        float diffx = _cueStartPoint.x - position.x;
        float diffy = _cueStartPoint.y - position.y;
    
        float angle = atan2(diffy, diffx);
       float distance = sqrt(pow (diffx, 2) + pow(diffy, 2));
    
        _pullBack = distance * 0.5f;
        Point cuePosition = Vec2(
            _cueStartPoint.x - (BALL_RADIUS * 21 + _pullBack) * cos(angle),
            _cueStartPoint.y - (BALL_RADIUS * 21 + _pullBack) * sin(angle)
        );
    
        _cue->getBody()->SetTransform(
            b2Vec2(cuePosition.x/PTM_RATIO, cuePosition.y/PTM_RATIO), 
            angle);
    
        _lineContainer->setCuePoint(Vec2(
            _cueStartPoint.x - ( _pullBack) * cos(angle),
         _cueStartPoint.y - ( _pullBack) * sin(angle)));
    }
    

    此方法随后计算球杆身体的角位置和相应地转换球杆的 b2Body 方法。b2Body 方法的 SetTransform 选项负责其位置和角度。

  8. 最后,在 onTouchEnded 中,我们按照以下方式释放球杆身体:

    void GameLayer::onTouchEnded(Touch* touch, Event* event) {
    
        if (_usingCue && _touch) {
            auto cueBody = _cue->getBody();
            float angle = cueBody->GetAngle();
    
            //release cue!
            cueBody->ApplyLinearImpulse(
                b2Vec2 (_pullBack * cos(angle) * SHOT_POWER,
                _pullBack * sin(angle) * SHOT_POWER),
                cueBody->GetWorldCenter());
        }
    
        _usingCue = false;
        _touch = nullptr;
        _lineContainer->setDrawing(false);
    }
    

    我们使用 ApplyLinearImpulse。这个方法接收一个表示要施加的冲量的向量以及应该在身体上应用此冲量的位置。

    _pullback 变量存储了玩家释放球杆身体时球杆身体与球之间的距离信息。它越远,射击力就越强。

发生了什么事?

我们添加了允许玩家用球杆身体击打白球的 touch 事件。这个过程非常简单。我们首先需要确保玩家正在触摸白球;然后,当玩家拖动手指时,我们移动球杆身体。最后,当触摸释放时,我们使用 ApplyLinearImpulse 使球杆弹簧向白球弹射。

我们还可以通过使用 SetLinearVelocityApplyForce 在 Box2D 中移动一个身体,每个都有细微和不太细微的差异。我建议你尝试一下这些。

主循环

如我之前所展示的,模拟只需要你在主循环中调用它的Step()方法。Box2D 会处理其所有方面。

通常剩下的就是游戏逻辑的其余部分:得分、游戏状态,以及更新你的精灵以匹配b2Bodies方法。

调用每个球和球杆的update方法是很重要的。这是我们的b2Sprite update方法的样子:

void b2Sprite::update(float dt) {
    if (_body && this->isVisible()) {
        this->setPositionX(_body->GetPosition().x * PTM_RATIO);
        this->setPositionY(_body->GetPosition().y * PTM_RATIO);
        this->setRotation(_RADIANS_TO_DEGREES(-1 * _body->GetAngle()));
    }
}

你需要做的只是确保Sprite方法与b2Body对象中的信息相匹配。并且确保在这样做的时候将米转换回像素。

因此,让我们添加我们的主循环。

行动时间 – 添加主循环

这是在我们的主循环中更新我们的b2World对象的地方。

  1. 首先,按照以下方式更新模拟:

    void GameLayer::update(float dt) {
    
       if (!_running) return;
       if (_gameState == kGameOver) return;
        _world->Step(dt, 10, 10);
    
  2. 接下来,我们需要通过检查当前游戏中球的数量来确定游戏是否结束。我们使用以下方法:

    //track invisible objects
    for (auto ball : _balls) {
       if (!ball->isVisible() && ball->getInPlay()) {
            ball->setInPlay(false);
            ball->hide();
            //count down balls
            _ballsInPlay--;
            SimpleAudioEngine::getInstance()->playEffect("drop.wav");
            if (_ballsInPlay == 0) {
                _gameState = kGameOver;
                _gameOver->setVisible(true);
            }
        } else {
            ball->update(dt);
        }
    }
    
  3. 接下来,我们继续按照以下方式更新精灵:

    if (!_cue->isVisible())  {
        _cue->hide();
    } else {
        _cue->update(dt);
    }
    if (!_player->isVisible()) {
        _player->reset();
        _player->setVisible(true);
        SimpleAudioEngine::getInstance()->playEffect("whitedrop.wav");
    }
    _player->update(dt);
    
  4. 我们还确定何时允许玩家进行新的射击。我决定只有在白球停止时才允许这样做。而确定这一点最快的方法是检查它的向量。下面是如何做的:

    //check to see if player ball is slow enough for a new shot
    if (_player->mag() < 0.5f && !_canShoot) {
        _player->getBody()->SetLinearVelocity(b2Vec2_zero);
        _player->getBody()->SetAngularVelocity(0);
        _canShoot = true;
    }
    

发生了什么?

我们添加了主循环。这将更新 Box2D 模拟,然后我们负责根据结果信息定位我们的精灵。

注意

Box2D 的一个非常重要的方面是理解在b2World::Step调用内部可以更改什么,不能更改什么。

例如,一个物体不能在步骤内部被设置为不活跃(b2Body::SetActive)或被销毁(b2World::DestroyBody)。你需要检查步骤之外的条件来做出这些更改。例如,在我们的游戏中,我们检查球精灵是否可见,如果不可见,则将它们的身体设置为不活跃。所有这些都是在调用b2World::Step之后完成的。

为我们的游戏添加计时器

在 MiniPool 中,我们计算玩家清理桌子的秒数。让我给你展示如何做到这一点。

行动时间 – 创建计时器

我们创建计时器的方式基本上与我们创建主循环的方式相同。

  1. 首先,我们通过在GameLayer构造函数中添加以下行来添加第二个计划的事件:

    this->schedule(CC_SCHEDULE_SELECTOR(GameLayer::ticktock), 1.5f);
    
  2. 通过这样,我们创建了一个单独的计时器,每1.5秒运行一次ticktock方法(我最终决定1.5秒看起来更好)。

  3. 该方法持续更新_time属性的价值,并在_timer标签中显示它。

    void GameLayer::ticktock(float dt) {
        if (_gameState == kGamePlay) {
            _time++;
            _timer->setString(std::to_string(_time));
        }
    }
    

发生了什么?

我们通过安排第二个更新(指定我们想要的时间间隔)使用schedule方法来为我们的游戏添加计时器。

如果你希望移除计时器,你只需要在任何地方调用你类中的unschedule(SEL_SCHEDULE selector)方法。

现在,让我们将我们的 Box2D 游戏带到 Android 上。

行动时间 – 在 Android 上运行游戏

按照以下步骤部署 Box2D 游戏到 Android:

  1. 在文本编辑器中打开Android.mk文件(你可以在proj.android/jni文件夹中找到它)。

  2. 编辑LOCAL_SRC_FILES中的行,使其读取:

    LOCAL_SRC_FILES := hellocpp/main.cpp \
                       ../../Classes/AppDelegate.cpp \
                      ../../Classes/GLES-Render.cpp \
                       ../../Classes/b2Sprite.cpp \
                       ../../Classes/Ball.cpp \
                       ../../Classes/CollisionListener.cpp \
                       ../../Classes/Cue.cpp \
                       ../../Classes/LineContainer.cpp \
                       ../../Classes/GameLayer.cpp 
    
  3. 打开清单文件并将应用方向设置为portrait

  4. 将游戏导入 Eclipse,等待所有类编译完成。

  5. 构建并运行你的应用程序。

发生了什么?

就这些了。使用 Box2D 构建的游戏和不使用 Box2D 构建的游戏之间没有区别。Box2D API 已经包含在make文件中,在外部文件夹中的类导入行。

当然,你不需要在你的最终项目中添加GLES-Render类。

尝试一下英雄

为了使游戏更有趣,可以做一些改动:限制白色球击中球袋的次数;另一个选项是让计时器作为一个倒计时,这样玩家在时间耗尽前有有限的时间来清理桌面。

此外,这款游戏还需要一些动画。当球击中球袋时,使用Action方法缩小并淡出球会看起来非常漂亮。

突击测验

Q1. Box2D 模拟中的主要对象是什么?

  1. b2Universe

  2. b2d

  3. b2World

  4. b2Simulation

Q2. 一个b2Body对象可以是哪种类型?

  1. b2_dynamicBodyb2_sensorBodyb2_liquidBody

  2. b2_dynamicBodyb2_staticBodyb2_kinematicBody

  3. b2_staticBodyb2_kinematicBodyb2_debugBody

  4. b2_kinematicBodyb2_transparentBodyb2_floatingBody

Q3. 以下哪些属性可以在固定定义中设置?

  1. 密度,摩擦,恢复力,形状。

  2. 位置,密度,子弹状态。

  3. 角阻尼,活动状态,摩擦。

  4. 线性阻尼,恢复力,固定旋转。

Q4. 如果两个物体在它们的固定定义中具有相同的唯一值maskBits属性,这意味着:

  1. 这两个物体永远不会发生碰撞。

  2. 这两个物体只会触发开始接触事件。

  3. 这两个物体只能相互碰撞。

  4. 这两个物体只会触发结束接触事件。

摘要

现在,似乎世界上每个人在某个时刻都玩过或将要玩一款基于物理的游戏。Box2D 无疑是休闲游戏领域最受欢迎的引擎。你在这里学到的命令几乎可以在引擎的每个端口中找到,包括一个正在变得越来越受欢迎的 JavaScript 端口。

设置引擎并使其运行起来非常简单——也许过于简单。开发 Box2D 游戏需要大量的测试和值调整,很快你就会发现,在开发基于物理的游戏时,保持引擎按你的意愿运行是最重要的技能。选择合适的摩擦、密度、恢复力、阻尼、时间步长、PTM 比率等值可以使你的游戏成功或失败。

在下一章中,我们将继续使用 Box2D,但我们将关注 Cocos2d-x 还能做些什么来帮助我们组织游戏。

第九章。在水平上 – 雪人

在我们的下一款游戏中,我们将介绍大多数游戏都需要的一些重要功能,但这些功能与游戏玩法没有直接关系。因此,我们将跳过架构方面,讨论读取和写入数据、使用场景转换以及创建整个应用程序都可以监听的自定义事件。

但当然,我还会添加一些游戏玩法想法!

这次,你将学习如何:

  • 创建场景转换

  • 加载外部数据

  • 使用UserDefault保存数据

  • 使用分发器创建自己的游戏事件

  • 使用加速度计

  • 重复使用 Box2D 实体

游戏 – 雪人

小雪人男孩晚点了晚餐。如果你选择接受这个任务,你的任务是引导这个小家伙回到他的冰屋。

这是一个 Box2D 游戏,控制非常简单。倾斜设备,雪人就会移动。如果你轻触屏幕,雪人会在雪球和冰块之间切换形状,每种形状都有其自身的物理特性和操纵度。例如,球体具有更高的摩擦力,而冰块则没有。

雪人到达目的地的唯一方式是撞击屏幕上分布的引力开关。

雪人结合了街机游戏和益智游戏元素,因为每个关卡都是计划好的,有一个完美的解决方案,即如何将小雪人带回家。然而,请注意,存在多种解决方案。

游戏 – 雪人

下载4198_09_FINAL_PROJECT.zip文件,并在有机会时运行游戏。再次强调,不需要进行多余的输入,因为游戏中使用的逻辑对你来说几乎都是老生常谈的,我们将深入探讨新内容。

游戏设置

这是一个仅包含肖像的游戏,基于加速度计,因此它不应该自动旋转。它是为普通 iPhone 设计的,其屏幕分辨率大小设置为kResolutionShowAll,因此屏幕设置与我们的前一款游戏相似。

为 iPhone 屏幕设计游戏并使用kResolutionShowAll参数,当在不符合 iPhone 1.5 比例的屏幕上玩游戏时,将产生所谓的信箱视图。这意味着你会在游戏屏幕周围看到边框。或者,你也可以使用kResolutionNoBorders参数,这将产生缩放效果,使游戏在全屏播放,但边框周围的区域将被裁剪。

以下截图说明了这两种情况:

游戏设置

左边的是 iPad 上的游戏屏幕,使用kResolutionShowAll。右边使用的是kResolutionNoBorders。注意第二个屏幕是如何缩放和裁剪的。当使用kResolutionNoBorders时,重要的是要设计你的游戏,确保没有关键的游戏元素出现在边框太近的地方,因为它可能不会被显示。

组织游戏

再次强调,有一个b2Sprite类,EskimoPlatform类扩展了b2Sprite。然后是常规的Sprite类,GSwitch(代表重力开关)和Igloo。逻辑在这最后两个和Eskimo之间运行碰撞检测,但我选择不将它们作为传感器体,因为我想要展示 Cocos2d-x 元素和 Box2D 元素的 2D 碰撞逻辑可以很好地共存。

但最重要的是,这个游戏现在有三个场景。到目前为止,在这本书中,我们每个游戏只使用了一个场景。这个游戏的场景对象将包装MenuLayerLevelSelectLayerGameLayer。以下是关于这三个的简要说明:

  • MenuLayer中,你可以选择玩游戏,这将带你到LevelSelectLayer,或者玩游戏教程,这将带你到GameLayer

  • LevelSelectLayer中,你可以选择你想玩哪个可用关卡,这将带你到GameLayer。或者你也可以回到MenuLayer

  • GameLayer中,你玩游戏,游戏结束后可能会回到MenuLayer

以下图像展示了游戏中的所有三个场景:

组织游戏

在 Cocos2d-x 中使用场景

场景本身就是小程序。如果你有 Android 开发经验,你可能会把场景想象成活动。在所有基于节点的类中,Scene 应用程序在架构上最为相关,因为Director 类运行场景,实际上就是在运行你的应用程序。

与场景一起工作的部分好处也是部分缺点:它们完全独立且互不干扰。在规划你的游戏类结构时,场景之间共享信息的需求将是一个重要因素。

此外,内存管理可能成为一个问题。当前正在运行的场景不会放弃其资源,直到一个新的场景启动并运行。因此,当你使用过渡动画时,请记住,在几秒钟内,两个场景都将存在于内存中。

在 Eskimo 中,我以两种不同的方式初始化场景。使用MenuLayerLevelSelectLayer,每次用户导航到这两个场景中的任何一个时,都会创建一个新的层对象(要么是新的MenuLayer,要么是新的LevelSelectLayer)。

然而,GameLayer是不同的。它是一个单例Layer类,在第一次实例化后永远不会从内存中消失,因此加快了从关卡选择到实际游戏的时间。但这可能并不适用于每个游戏。正如我之前提到的,在场景之间切换时,两个场景会在内存中持续几秒钟。但在这里,我们通过在整个过程中保持一个层在内存中,增加了这个问题。然而,Eskimo 在内存方面并不大。请注意,我们仍然可以选择为GameLayer应该被销毁的条件创建特殊条件,以及它不应该销毁的条件。

所以,让我来展示如何创建场景过渡。首先,使用一个每次创建时都会创建其 Layer 新副本的 Scene 类。

时间行动 - 创建场景过渡

你当然一直在使用场景。

  1. AppDelegate.cpp 中隐藏着类似的行:

    auto scene = GameLayer::scene();
    // run
    director->runWithScene(scene);
    
  2. 因此,为了更改场景,你只需要告诉 Director 类你希望它运行哪个场景。Cocos2d-x 将会移除当前场景中的所有内容(如果有的话,所有它们的析构函数都会被调用),并实例化一个新的层并将其包裹在新的 Scene 中。

  3. 进一步分解步骤,这是通常为 Director 创建新场景的方式:

    Scene* MenuLayer::scene()
    {
        // 'scene' is an autorelease object
        auto scene = Scene::create();
        // add layer as a child to scene
        auto layer = new MenuLayer();
        scene->addChild(layer);
        layer->release();
        return scene;
    }
    
  4. 静态方法 MenuLayer::scene 将创建一个空白场景,然后创建 MenuLayer 的新实例并将其作为子节点添加到新场景中。

  5. 现在你可以告诉 Director 运行它,如下所示:

    Director::getInstance()->replaceScene(MenuLayer::scene());
    
  6. 如果你希望使用过渡效果,逻辑会有一些变化。所以,在我们的 MenuLayer.cpp 类中,这是如何过渡到 LevelSelectLayer 的:

    auto newScene = TransitionMoveInR::create(0.2f,  LevelSelectLayer::scene());
    Director::getInstance()->replaceScene(newScene);
    

    上述代码创建了一个新的过渡对象,该对象将从屏幕的右侧滑入新场景,并覆盖当前场景。过渡将花费 0.2 秒。

刚才发生了什么?

你已经使用 Cocos2d-x 创建了一个场景过渡动画。

如我之前提到的,这种场景更改形式将导致每次都创建一个新的新层实例,并在每次被新场景替换时销毁。所以,在我们的游戏中,MenuLayerLevelSelectLayer 会根据用户在它们之间切换的次数被实例化和销毁。

还可以选择使用 pushScene 而不是 replaceScene。这会创建一个 scene 对象的堆栈,并将它们全部保留在内存中。这个堆栈可以通过 popScenepopToRootScene 进行导航。

现在让我来展示如何使用单例层做同样的事情。

到现在为止,你可能会发现 Tests 项目中有很多这些过渡类的例子,在 tests/cpp-tests/Classes/TransitionsTest

时间行动 - 使用单例层类创建过渡

我们首先需要确保相关的层只能实例化一次。

  1. GameLayer 中的 scene 静态方法看起来是这样的:

    Scene* GameLayer::scene(int level, int levelsCompleted)
    {
        // 'scene' is an autorelease object
        auto scene = Scene::create();
       // add layer as a child to scene
        scene->addChild(GameLayer::create(level, levelsCompleted));
       return scene;
    }
    

    该层在创建时接收两个参数:它应该加载的游戏关卡以及玩家完成的游戏关卡数量。我们创建一个新的 Scene 对象并将 GameLayer 作为其子节点添加。

  2. 但看看 GameLayer 中的静态 create 方法:

    GameLayer * GameLayer::create (int level, int levelsCompleted) {
        if (!_instance) {
            _instance = new GameLayer();
        } else {
            _instance->clearLayer();
        }
        _instance->setLevelsCompleted(levelsCompleted);
        _instance->loadLevel(level);
        _instance->scheduleUpdate();
        return _instance;
    }
    
  3. GameLayer.cpp 的顶部声明了一个 _instance 静态属性,如下所示:

    static GameLayer* _instance = nullptr;
    

    我们可以检查,如果 GameLayer 的一个实例当前在内存中,并在必要时实例化它。

  4. 场景过渡到 GameLayer 表面上看起来与常规过渡完全一样。所以,在 LevelSelectLayer 中,我们有以下内容:

    auto newScene = TransitionMoveInR::create(0.2f,  GameLayer::scene(_firstIndex + i, _levelsCompleted));
    Director::sharedDirector()->replaceScene(newScene);
    

刚才发生了什么?

我们已经创建了一个带有Layer类的Scene过渡,该类永远不会被销毁,因此我们不需要为每个新关卡实例化新的平台和重力开关精灵。

当然,这个过程当然存在问题和限制。例如,我们不能在这两个GameLayer对象之间进行转换,因为我们始终只有一个这样的对象。

在离开GameLayer和返回到它时,也有一些特殊考虑。例如,当我们返回到GameLayer时,我们必须确保我们的主循环正在运行。

唯一的方法是在离开GameLayer时取消调度它,并在返回时再次调度,如下所示:

//when leaving
unscheduleUpdate();
auto newScene = TransitionMoveInL::create(0.2f, MenuLayer::scene());
Director::sharedDirector()->replaceScene(newScene);

//when returning
_instance->scheduleUpdate();

小贴士

再次从架构的角度来看,还有更好的选择。可能最好的选择是创建自己的游戏元素缓存或游戏管理器,其中包含所有需要实例化的对象,并使其成为一个单例,每个场景都可以访问。这也是在场景之间共享与游戏相关的数据的最佳方式。

从 .plist 文件加载外部数据

Eskimo 只有五个游戏关卡,还有一个教程关卡(您可以自由添加更多)。这些关卡的数据存储在Resources文件夹中的levels.plist文件中。.plist文件是一个 XML 格式的数据文件,因此可以在任何文本编辑器中创建。然而,Xcode 提供了一个很好的 GUI 来编辑这些文件。

让我来向您展示如何在 Xcode 中创建它们。

动手实践 – 创建 .plist 文件

当然,您可以在任何文本编辑器中创建此文件,但 Xcode 使创建和编辑.plist文件变得格外简单。

  1. 在 Xcode 中,转到新建 | 文件...,然后选择资源属性列表。当被问及保存文件的位置时,选择您想要的任何位置。动手实践 – 创建 .plist 文件

  2. 您需要决定您的.plist文件中的元素将是什么——可以是数组字典类型(默认)。对于 Eskimo,元素是一个包含一系列字典的数组,每个字典都包含游戏中的一个关卡的数据。

  3. 通过选择元素,您会在类型声明旁边看到一个加号指示器。点击此加号将向添加一个元素。然后您可以为此新项目选择数据类型。选项包括布尔值数据日期数字字符串,以及再次是数组字典。最后两个可以包含树中的子项,就像元素一样。

  4. 继续向树中添加元素,尝试匹配以下截图中的项目:动手实践 – 创建 .plist 文件

发生了什么?

您已经在 Xcode 中创建了一个属性列表文件。这是 Cocos2d-x 可以加载和解析的 XML 结构化数据。您已经在加载粒子效果和精灵表信息时使用过它们。

加载关卡数据

在 Eskimo 中,由于我只有五个级别,我选择使用一个包含所有级别的 .plist 文件。这可能不是大型游戏中的最佳选择。

虽然苹果设备可以快速加载和解析 .plist 文件,但其他目标可能不一定如此。因此,通过将数据组织到多个文件中来限制 .plist 文件的大小。你可能见过将他们的级别分成多个组或包的游戏。这是一种简单的方法,可以为你的游戏创建一个额外的预加载屏幕,用于解析级别数据。这也可以用作将文件大小保持在最小值的方法。

在 Eskimo 中,我们可以有包含 10 个级别的 .plist 文件,例如,然后是 10 组这样的文件,总共 100 个级别。

因此,现在是时候加载我们的 .plist 文件并解析我们级别的数据了。

行动时间 - 从 .plist 文件中检索数据

级别数据在 GameLayer 中加载。

  1. GameLayer 构造函数内部,我们这样加载数据:

    _levels = FileUtils::getInstance()- >getValueVectorFromFile("levels.plist");
    

    Cocos2d-x 将负责将 FileUtils 映射到正确的目标。框架支持的每个平台都有一个 FileUtils,并且它们都可以与 .plist 格式一起工作。太棒了!如果 .plist 文件中的数据是 数组,你必须将其转换为 ValueVector;如果是 字典,你必须将其转换为 ValueMap。我们将在加载特定级别的数据时这样做。

    注意

    如果我们将级别分成多个 .plist 文件,那么每次加载一个新的 .plist 文件时,我们都需要有逻辑来刷新 _levels 数组。

  2. loadLevel 方法内部,我们这样加载级别的数据:

    ValueMap levelData = _levels.at(_currentLevel).asValueMap();
    

    在这里,.plist 文件中的数据是 字典,因此我们必须将数据转换为 ValueMap

    加载和解析到此结束。现在我们可以继续检索我们级别的数据。

    每个级别字典以关于级别重力的数据(一个级别可能以不同的重力值开始)开始,玩家应该放置的起点,以及冰屋应该放置的终点。

  3. 这些值在我们的代码中是这样检索的:

    _gravity = levelData.at("gravity").asInt();
    switch (_gravity) {
        case kDirectionUp:
            _world->SetGravity(b2Vec2(0,FORCE_GRAVITY));
            break;
        case kDirectionDown:
            _world->SetGravity(b2Vec2(0,-FORCE_GRAVITY));
            break;
        case kDirectionLeft:
            _world->SetGravity(b2Vec2(-FORCE_GRAVITY, 0));
            break;
        case kDirectionRight:
            _world->SetGravity(b2Vec2(FORCE_GRAVITY, 0));
            break;
    }
    
    _player->setSpritePosition(Vec2(
        levelData.at("startx").asFloat() * TILE,
        levelData.at("starty").asFloat() * TILE
    ));
    
    _igloo->initIgloo(_gravity, Vec2(
        levelData.at("endx").asFloat() * TILE,
        levelData.at("endy").asFloat() * TILE
    ));
    
  4. 在这个相同的字典中,我们有一个平台数组和一个重力开关数组。这些是这样检索的:

    ValueVector platforms =  levelData.at("platforms").asValueVector();
    ValueVector switches =  levelData.at("switches").asValueVector();
    
  5. 这些数组包含更多字典,包含每个级别中平台和重力开关的创建和放置数据。这些数据传递给相应的 PlatformGSwitch 类,然后 - 你就得到了一个级别。

    for ( auto platformData : platforms){
       ValueMap data = platformData.asValueMap();
       platform->initPlatform ( data.at("width").asInt() * TILE,
                             data.at("angle").asFloat(),
                             Vec2(data.at("x").asFloat() * TILE,
                             data.at("y").asFloat() * TILE));
    }
    
     for (int i = 0; i < switches.size(); i++) {
            auto gswitch = _gSwitchPool.at(i);
            ValueMap data = switches.at(i).asValueMap();
            gswitch->initGSwitch(data.at("gravity").asInt(),
                        Vec2(data.at("x").asFloat() * TILE,
                        data.at("y").asFloat() * TILE));
    }
    

发生了什么事?

使用 Cocos2d-x 解析和检索属性列表文件非常简单。你将始终与值数组或值字典一起工作,并将它们分别映射到 ValueVectorValueMap

保存游戏数据

当你规划你的游戏时,你可能会很快决定你希望存储与你的应用程序相关的数据,例如最高分或用户偏好。在 Cocos2d-x 中,你可以通过简单地访问 UserDefault 单例来实现这一点。

使用UserDefault,你可以通过每个数据类型的一个简单调用来存储整数、浮点数、双精度浮点数、字符串和布尔值,如下所示:

UserDefault::getInstance()->setIntegerForKey("levelsCompleted", _levelsCompleted);
UserDefault::getInstance()->flush();

其他方法有setFloatForKeysetDoubleForKeysetStringForKeysetBoolForKey。要检索数据,请使用它们各自的 getter。

我将向你展示如何在我们的游戏中使用它。

行动时间 – 存储完成关卡

打开LevelSelectLayer类。

  1. 这就是从层构造函数内部检索完成关卡数量的方式:

    _levelsCompleted = UserDefault::getInstance()- >getIntegerForKey("levelsCompleted");
    
  2. 初始时,如果没有数据存在,_levelsCompleted将等于0。因此,我们将关卡 1 存储为“解锁”。这是如何做到的:

    if (_levelsCompleted == 0) {
        _levelsCompleted = 1;
        UserDefault::getInstance()->setIntegerForKey("levelsCompleted", 1);
        UserDefault::getInstance()->flush();
    }
    
  3. 然后,每次我们开始一个新的关卡时,如果新关卡编号大于存储的值,我们就更新完成关卡的数量。

    if (_currentLevel > _levelsCompleted) {
        _levelsCompleted = _currentLevel;
        UserDefault::getInstance()->setIntegerForKey("levelsCompleted", _levelsCompleted);
        UserDefault::getInstance()->flush();
    }
    

    注意

    你不必每次更新数据中的每一个位时都刷新数据(使用flush)。你可以将多个更新分组在一个刷新中,或者找到你逻辑中的一个安全位置,在退出应用之前安全地刷新更新。节点为此提供了极有帮助的方法:onEnteronExitonEnterTransitionDidFinishonExitTransitionDidStart

发生了什么?

对于与你的游戏、设置和首选项相关的少量数据,UserDefault是存储信息的绝佳方式。Cocos2d-x 将再次将其映射到每个目标系统可用的本地存储。

在你的游戏中使用事件

框架的早期版本使用了一个受 Objective-C 启发的功能,即通知。但这个特定的 API 已经走向了被弃用的道路。相反,你应该使用无所不知的Director及其Dispatcher(这是我们之前在监听触摸事件时一直在与之交谈的对象)。

如果你曾经使用过 MVC 框架或开发过游戏 AI 系统,你可能熟悉一个称为观察者模式的设计模式。这包括一个中心消息分发对象,其他对象可以订阅(观察)以监听特殊消息,或者指示它将它们自己的消息分发给其他订阅者。换句话说,它是一个事件模型。

使用 Cocos2d-x,这做得非常快且简单。让我给你一个在 Eskimo 中使用的例子。

行动时间 – 使用事件分发器

如果我们想让Platform精灵监听特殊通知NOTIFICATION_GRAVITY_SWITCH,我们只需要将Platform添加为观察者。

  1. Platform类的构造函数中,你会找到这些行:

    auto onGravityChanged = [=] (EventCustom * event) {
           if (this->isVisible()) {
                switchTexture();
            }
    };
    Director::getInstance()->getEventDispatcher()- addEventListenerWithSceneGraphPriority(EventListenerCustom::create  (GameLayer::NOTIFICATION_GRAVITY_SWITCH, onGravityChanged), this);
    

    当然,这只是一行代码!最好为分发器和添加监听器代码创建一个宏;所以,可能像这样:

    #define EVENT_DISPATCHER Director::getInstance()- >getEventDispatcher()
    #define ADD_NOTIFICATION( __target__, __notification__,  __handler__) EVENT_DISPATCHER- addEventListenerWithSceneGraphPriority(EventListenerCustom::create  (__notification__, __handler__), __target__);
    

    这样,我们之前使用的相同行代码将看起来像这样:

    ADD_NOTIFICATION(this, GameLayer::NOTIFICATION_GRAVITY_SWITCH, onGravityChanged);
    
  2. 消息(或通知)NOTIFICATION_GRAVITY_SWITCHGameLayer中作为一个静态字符串创建:

    const char* GameLayer::NOTIFICATION_GRAVITY_SWITCH =  "NOTIFICATION_GRAVITY_SWITCH";
    

    Director类调度程序的这一行调用告诉它“平台”对象将监听这个定义的消息,并且当这样的消息被调度时,每个“平台”对象都会调用onGravityChanged方法。这个方法不需要像我这里展示的那样是一个块,但将处理程序尽可能靠近“添加监听器”调用是更易读的。因此,简单的块是组织监听器和它们的处理程序的好方法。

  3. 在游戏中,每个重力切换都有颜色编码,当 Eskimo 撞击一个切换时,平台的纹理会改变以反映新的重力,通过切换到激活的重力切换的颜色。这一切都是通过我们在主循环中检测到与GSwitch对象的碰撞时在GameLayer内部发出的简单通知来完成的。这就是我们这样做的方式:

    Director::getInstance()->getEventDispatcher()- >dispatchCustomEvent( GameLayer::NOTIFICATION_GRAVITY_SWITCH);
    

    或者,如果你使用宏,可以使用以下代码:

    EVENT_DISPATCHER->dispatchCustomEvent(  GameLayer::NOTIFICATION_GRAVITY_SWITCH);
    
  4. 你还可以在自定义事件中添加一个UserData对象作为调度的第二个参数。这可以从事件处理程序中的EventCustom *事件中检索,如下所示:

    event->getUserData();
    
  5. 当“平台”对象被销毁时,节点析构函数将负责将其作为监听器移除。

刚才发生了什么?

你刚刚学会了如何让你的开发者生活变得容易得多。将一个应用程序级的事件模型添加到你的游戏中是一种提高对象之间流动性和交互性的强大方式,而且使用起来非常简单,我相信你很快就会在所有游戏中实现这个功能。

使用加速度计

现在,让我们转向与游戏玩法相关的一些新主题,首先是使用加速度计数据。同样,这很简单。

行动时间 - 读取加速度计数据

就像你对“触摸”事件所做的那样,你需要告诉框架你想要读取加速度计数据。

  1. 你可以通过在任意“层”类中的这个调用告诉框架你希望使用加速度计:

    Device::setAccelerometerEnabled(true);
    
  2. 然后,就像你对“触摸”事件所做的那样,你按照以下方式订阅事件调度程序中的“加速度”事件:

    auto listenerAccelerometer =  
    EventListenerAcceleration::create(CC_CALLBACK_2 (GameLayer::onAcceleration, this));
    _eventDispatcher->addEventListenerWithSceneGraphPriority(listenerAccelerometer,  this);
    
  3. 在 Eskimo 中,加速度计数据改变了名为_acceleration的“点”向量的值。

    void GameLayer::onAcceleration(Acceleration *acc, Event *event) {
        _acceleration = Vec2(acc->x * ACCELEROMETER_MULTIPLIER,
                            acc->y * ACCELEROMETER_MULTIPLIER);
    }
    

    这个值随后在主循环中被读取,并用于移动 Eskimo。在游戏中,每次只更新一个轴,这取决于当前的引力。所以你只能使用加速度计数据在X轴或Y轴上移动 Eskimo,但不能同时移动两个轴。

    注意

    请记住,在“加速度”数据中还有一个Z轴值。将来某天这可能会派上用场!

刚才发生了什么?

是的。用几行代码,你就为你的游戏添加了加速度控制。

在这些加速度值上添加额外的过滤器是一种常见的做法,因为不同设备的结果可能会有所不同。这些过滤器是应用于加速度的比率,以保持值在某个范围内。你还可以在网上找到这些比率的多种公式。但这些都取决于你需要控制有多敏感,或者有多响应。

在游戏中,我们只有在精灵触摸平台时才更新爱斯基摩人的加速度计数据。我们可以通过检查 _player 身体是否有接触列表来快速确定这一点,如下所示:

if (_player->getBody()->GetContactList()) 

重复使用 b2Bodies

在爱斯基摩人中,我们有一个 b2Bodies 的池,这些 b2Bodies 被用于 Platform 对象中,并且每当玩家点击屏幕时,我们也改变小爱斯基摩人的形状。这是可能的,因为 Box2D 使得在不销毁实际身体的情况下更改 b2Body 配件的数据变得非常容易。

让我来给你展示一下。

行动时间 – 更改 b2Body 配件

你所要做的只是调用 body->DestroyFixture。不出所料,这应该在模拟步骤之外完成。

  1. Eskimo 类的 makeCircleShapemakeBoxShape 方法中,你会找到以下这些行:

    if (_body->GetFixtureList() ) {
        _body->DestroyFixture(_body->GetFixtureList());
    }
    

    在这里,我们只是声明如果这个身体有配件,就销毁它。当玩家点击屏幕时,我们可以从方形配件切换到圆形配件,但使用相同的身体。

  2. 我们也使用这个特性来处理平台。池中的平台如果没有在当前关卡中使用,则设置为不活动状态,如下所示:

    _body->SetActive(false);
    

    这将它们从模拟中移除。

  3. 当它们被重新初始化以用于关卡时,我们销毁它们现有的配件,更新它以匹配 .plist 文件中的数据,并将身体再次设置为活动状态。这就是我们这样做的方式:

    //Define shape
    b2PolygonShape box;
    box.SetAsBox(width * 0.5f /PTM_RATIO, PLATFORM_HEIGHT *  0.5f / PTM_RATIO);
    
    //Define fixture
    b2FixtureDef fixtureDef;
    fixtureDef.shape = &box;
    fixtureDef.density = 1;
    fixtureDef.restitution = 0;
    
    //reutilize body from the pool: so destroy any existent fixture
    if (_body->GetFixtureList()) {
        _body->DestroyFixture(_body->GetFixtureList());
    }
    _body->CreateFixture(&fixtureDef);
    _body->SetTransform(b2Vec2(position.x / PTM_RATIO, position.y /  PTM_RATIO), _DEGREES_TO_RADIANS(-angle));
    _body->SetActive(true);
    

刚才发生了什么?

因此,就像我们一直在对精灵池应用相同的逻辑一样,我们也可以将相同的逻辑应用到 b2Bodies 上,而无需在主循环中实例化任何东西。

现在,让我们看看 Android 如何处理所有这些关卡加载业务。

行动时间 – 在 Android 上运行游戏

是时候将游戏部署到 Android 上。

  1. 导航到 proj.android 文件夹,在文本编辑器中打开文件 AndroidManifest.xml。然后转到 jni 文件夹,在文本编辑器中打开文件 Android.mk

  2. AndroidManifest.xml 文件中,编辑 activity 标签中的以下行,如下所示:

    android:screenOrientation="portrait"   
    
  3. 接下来,让我们编辑 make 文件,所以打开 Android.mk 文件,并编辑 LOCAL_SRC_FILES 中的行,如下所示:

    LOCAL_SRC_FILES := hellocpp/main.cpp \
                       ../../Classes/AppDelegate.cpp \
                       ../../Classes/b2Sprite.cpp \
                       ../../Classes/Eskimo.cpp \
                       ../../Classes/GSwitch.cpp \
                       ../../Classes/Igloo.cpp \
                       ../../Classes/Platform.cpp \
                       ../../Classes/LevelSelectLayer.cpp \
                       ../../Classes/MenuLayer.cpp \
                       ../../Classes/GameLayer.cpp
    
  4. 现在将项目导入 Eclipse 并构建它。

  5. 你现在可以保存它并在你的 Android 设备上运行游戏。

刚才发生了什么?

到现在为止,你应该已经精通在 Android 上运行你的代码,并且希望你在 Eclipse 上的体验是好的。

好了,这就是全部了!

玩这个游戏。查看源代码(里面满是注释)。添加一些新关卡,让这个小爱斯基摩人的生活变得地狱般艰难!

来试试吧,英雄

通过一些新的想法,Eskimo 的游戏玩法可以进一步改进,这些想法会迫使玩家犯更多的错误。

在这类游戏中,评估玩家在关卡中所达到的“完整性”程度是一个常见特征。每个关卡可能都有一个时间限制,以及为爱斯基摩人准备的拾取物品,玩家在每关结束时会被评估,并根据其表现获得铜星、银星或金星。而且,只有获得一定数量的金星,才能解锁新的关卡组。

摘要

是的,你有一个很酷的游戏想法,太好了!但是,在结构和优化它上将会投入大量的努力。Cocos2d-x 可以帮助你完成这项工作的两个方面。

是的,场景可能会根据你的需求变得有些繁琐,但它们无疑是无可争议的内存管理器。当Director销毁一个场景时,它会彻底销毁它。

加载外部数据不仅可以帮助减小内存大小,还可以将更多开发者引入你的项目,他们专注于关卡设计和创建它们的外部数据文件。

并且,事件可以迅速成为你游戏结构中不可或缺的一部分。很快,你就会发现自己开始用事件来处理游戏状态和菜单交互性,以及其他事情。

现在,让我们转向一种全新的语言!

第十章。介绍 Lua!

在我们的上一场游戏中,我们将转向新的 Cocos IDE,并使用 Lua 脚本语言开发整个游戏。你将了解并使用 Cocos2d-x API 的 Lua 绑定,这与我们之前在 C++中使用的大致相同;如果有什么不同的话,那就是它更容易!

这次,你将学习如何:

  • 在 Cocos IDE 中创建和发布一个项目

  • 使用 Lua 编写整个游戏

  • 使用精灵、粒子、标签、菜单和动作,但这次使用 Lua 绑定

  • 构建一个三消游戏

那 Lua 是什么样的呢?

在 Lua 的核心(在葡萄牙语中意为月亮),你有表。你可以把它想象成类似于 JavaScript 对象,但它远不止如此。它扮演着数组、字典、枚举、结构和类等角色。这使得 Lua 成为管理大量数据的完美语言。你编写一个处理数据的脚本,然后不断地给它提供不同的“东西”。存货或商店系统、互动儿童书——这些类型的项目都可以从 Lua 以表为中心的强大功能中受益,因为它们可以围绕一个固定模板和数据表的核心来构建。

对于不习惯脚本语言的人来说,其语法可能有点奇怪,因为它有 dos、thens 和 ends。但一旦你克服了这个初步的障碍,你会发现 Lua 非常用户友好。以下是它语法中的一些“奇怪之处”:

-- a comment
--[[ 
a 
multiline 
comment 
]]
-- a table declared as a local variable
local myTable = {}
-- the length of a table
local len = #myTable
-- looping the table (starting with index 1!)
for i = 1, #myTable do
   local element = myTable[i]
   -- an if elseif else statement
   if (element ~= true ) then
      -- do something
   elseif (element == true) then
      -- do something else
   else
      -- we'll never get here!   
   end
end

注意

分号是可选的。

表可以被转换成模板以生成其实例,换句话说,就是一个类。必须使用 : 符号来访问表的实例方法:

myTableClassObject:myMethod()

在方法内部,你将类的实例称为 self

self.myProperty = 1
self:myOtherMethod()

或者,你可以使用点符号调用模板的方法,将模板的实例作为第一个参数传递给它:

myTableClassObject.myMethod(myTableClassObject)

我承认,这听起来很奇怪,但有时它很有用,因为你在 Lua 中编写的几乎所有方法都可以供代码的其他部分使用——有点像传统面向对象语言中静态方法的使用方式。

Lua 中的调试 – 说 nil 的骑士

调试 Lua 代码有时可能会让人感到沮丧。但很快你就会学会区分 Lua 运行时错误中的细微差别。编译器会在大约 99.9%的情况下告诉你某个东西是 nil(Lua 的 null)。这取决于你找出原因。以下是一些主要的原因:

  • 你在引用一个对象的属性时没有在前面加上 self.self:

  • 你正在使用点符号调用实例方法,而没有将实例作为第一个参数传递;例如 myObject.myMethod() 而不是 myObject.myMethod(myObject)。请使用 myObject:myMethod()

  • 你正在引用一个在其作用域之外的地方的变量。例如,一个在 if 语句内部声明的局部变量正在条件外部被引用。

  • 你在类或模块/表的声明结束时忘记了返回类对象。

  • 你尝试访问数组的零索引。

  • 你忘记添加一些 dos 和 thens 或 ends。

  • 最后,也许你只是碰到了那种日子。一个 nil 类似的日子。

Cocos IDE 会用粗体显示错误;它与全局变量使用的相同粗体,有时会让人困惑。但无论如何,它还是有帮助的。只需养成检查代码中粗体文本的习惯即可!

小贴士

你可能需要增加 IDE 中的堆内存。完成此操作的最快方法是找到 Cocos IDE 应用程序文件夹中的名为 eclipse.ini 的文件。在 Mac 上,这意味着在 Cocos IDE 应用程序包中:右键单击应用程序图标,选择显示包内容,然后导航到Contents/MacOS/eclipse.ini

然后找到你读取 -Xmx256m-Xmx512m 的行,并将其更改为 -Xmx1024m

这可能有助于较慢的计算机。我的笔记本电脑在运行 IDE 时经常崩溃。

《游戏 - 石器时代》

这是一个三消游戏。你知道,那种让一些公司赚得盆满钵满,让成千上万家公司克隆这些游戏以赚取一点钱的游戏。是的,就是那个游戏!

你必须匹配三个或更多的宝石。如果你匹配的宝石超过三个,一个随机宝石会爆炸并变成钻石,你可以收集这些钻石以获得更多分数。

游戏有一个计时器,当时间耗尽时,游戏结束。

我基本上使用了这本书中之前游戏相同的结构。但我将其分解成独立的模块,这样你更容易将代码作为参考使用。

我们有一个 MenuScene 和一个 GameScene 项目。我将几乎所有的 Cocos2d-x 动作放在一个名为 GridAnimations 的模块中,大部分交互性放在另一个名为 GridController 的模块中。所有对象池都保存在一个名为 ObjectPools 的类中。

这是一个网格游戏,非常适合用来展示在 Lua 中使用表格数组,以及它相对于 C++ 的主要优势:在 Lua 中创建和内存管理动态列表(数组)要容易得多。这种灵活性,与 Cocos2d-x 的强大功能相结合,使得原型设计和开发非常快速。实际的游戏将看起来像这样:

《游戏 - 石器时代》

但在你导入起始项目之前,让我先向你展示如何在 Cocos IDE 中创建新项目。

行动时间 - 创建或导入项目

没有什么比这更简单了;由于 IDE 基于 Eclipse,你知道它的许多主要功能:

  1. 首先,让我们设置 IDE 以使用 Lua 绑定。转到首选项 | Cocos | Lua,然后在Lua 框架下拉菜单中找到您下载的 Cocos2d-x 框架文件夹:行动时间 - 创建或导入项目

  2. 如果该选项已经可用,请选择文件 | 新建 | Cocos Lua 项目,或者选择文件 | 新建 | 其他 | Cocos Lua | Cocos Lua 项目

  3. 新建 Cocos 项目向导中,给你的项目命名并点击下一步

  4. 在下一个对话框中,您可以选择您项目的方向和设计大小。就这样。点击完成

  5. 为了导入项目,点击文件 | 导入然后Cocos | 导入 Cocos 项目,并导航到本章节的项目起始文件夹。游戏名为StoneAge。(如果您还没有下载,请从本书的网站下载本章节的源文件。这里有一个可以运行和测试的起始项目和最终项目。)

发生了什么?

您学会了如何在 Cocos IDE 中创建和导入项目。由于 IDE 是基于 Eclipse 的程序,这些步骤现在应该对您来说很熟悉。

您可能还希望更改模拟器的设置。为此,只需在您的项目上右键单击并选择运行为...调试为...,然后选择运行调试配置

对于Mac OSX运行时(如果您在 Mac 上,当然),最好保持默认设置,因为这是最快的选择。但如果您想更改模拟器,这里就是您操作的地方:

发生了什么?

注意

在我的机器上,框架的 3.4 版本抛出了编译错误。我不得不添加两个修复才能运行《石器时代》。在cocos-cocos2d-Cocos2dConstants.lua中,在最后一个表声明之前,我添加了这一行:

cc.AsyncTaskPool = {}

同样,在cocos-ui-GuiConstants.lua中,我在添加新表到LayoutComponent之前添加了ccui.LayoutComponent = {},也接近文件末尾。

如果遇到问题,切换到 3.3 版本,这个版本对 Lua 开发来说更加稳定。

是时候动手设置我们的屏幕分辨率了

旧的AppDelegate类逻辑现在存在于名为main.lua的文件中:

  1. 在 IDE 中,打开src文件夹内的main.lua文件。

  2. 在设置动画间隔的行之后,输入以下内容:

    cc.Director:getInstance():getOpenGLView(): setDesignResolutionSize(640, 960,  cc.ResolutionPolicy.SHOW_ALL)
       local screenSize =  cc.Director:getInstance():getVisibleSize()
       local designSize = cc.size(640, 960)
       if (screenSize.width > 320) then
         cc.Director:getInstance():setContentScaleFactor(640/   designSize.width)       
         cc.FileUtils:getInstance():addSearchPath("res/hd/") 
       else
         cc.Director:getInstance():setContentScaleFactor(320/designSize.width)
         cc.FileUtils:getInstance():addSearchPath("res/sd/")         
       end
    
  3. 我为 iPhone 视网膜屏设计了这款游戏,并且我们为视网膜和非视网膜手机设置了适当的缩放和资源文件夹。接下来,让我们预加载声音文件:

           local bgMusicPath =  cc.FileUtils:getInstance():fullPathForFilename("background.mp3") 
           cc.SimpleAudioEngine:getInstance():preloadMusic(bgMusicPath)    
           local effectPath =  cc.FileUtils:getInstance():fullPathForFilename("match.wav")
           cc.SimpleAudioEngine:getInstance():preloadEffect(effectPath)
       effectPath =  cc.FileUtils:getInstance():fullPathForFilename("diamond.wav")
           cc.SimpleAudioEngine:getInstance():preloadEffect(effectPath)
           effectPath =  cc.FileUtils:getInstance():fullPathForFilename("diamond2.wav")
           cc.SimpleAudioEngine:getInstance():preloadEffect(effectPath)
           effectPath =  cc.FileUtils:getInstance():fullPathForFilename("wrong.wav")
       cc.SimpleAudioEngine:getInstance():preloadEffect(effectPath)
    
  4. 最后,让我们通过创建和运行我们的第一个场景来启动项目:

    --create scene 
    local scene = require("MenuScene")
    local menuScene = scene.create()
    if cc.Director:getInstance():getRunningScene() then
            cc.Director:getInstance():replaceScene(menuScene)
        else
            cc.Director:getInstance():runWithScene(menuScene)
        end
    

发生了什么?

就像我们在几乎每一款游戏中做的那样,我们设置了应用程序的分辨率策略和缩放因子,并预加载了我们将使用的声音。

这次游戏只针对手机设计,并且是以 iPhone 4 屏幕为设计目标,可以调整到旧手机。

但现在不要运行游戏。让我们创建我们的菜单场景。它包含了一些基本元素,这将是一个完美的 Lua Cocos2d-x API 入门介绍。

是时候动手创建菜单场景了

让我们创建一个新文件,并将菜单场景添加到我们的游戏中:

  1. 右键单击src文件夹并选择新建 | Lua 文件;将新文件命名为MenuScene.lua

  2. 让我们创建一个扩展场景的类。我们首先加载我们自己的所有游戏常量模块(这个文件在起始项目中已经存在):

    local constants = require ("constants")
    
  3. 然后我们构建我们的类:

    local MenuScene = class("MenuScene", function()
        return cc.Scene:create()
    end)
    
    function MenuScene.create()
        local scene = MenuScene.new()
        return scene
    end
    
    function MenuScene:ctor()
        self.visibleSize =  cc.Director:getInstance():getVisibleSize()
        self.middle = {x = self.visibleSize.width * 0.5,  y = self.visibleSize.height * 0.5}
        self.origin = cc.Director:getInstance():getVisibleOrigin()
        self:init()
    end
    return MenuScene
    

    我们将添加方法,包括在类构造函数中调用的init方法(总是称为ctor),但我想要强调在声明末尾返回类的的重要性。

  4. 因此,在构造函数下方,让我们继续构建我们的场景:

    function MenuScene:init ()
        local bg = cc.Sprite:create("introbg.jpg")
        bg:setPosition(self.middle.x, self.middle.y)
        self:addChild(bg)
        --create pterodactyl animation
       local pterodactyl = cc.Sprite:create("ptero_frame1.png")
       pterodactyl:setPosition(cc.p(self.visibleSize.width + 100,  self.visibleSize.height * 0.8))
       self:addChild(pterodactyl)
       local animation = cc.Animation:create()
       local number, name
       for i = 1, 3 do
         number = i
         name = "ptero_frame"..number..".png"
         animation:addSpriteFrameWithFile(name)
       end
       animation:setDelayPerUnit(0.5 / 3.0)
       animation:setRestoreOriginalFrame(true)
       animation:setLoops(-1)
       local animate = cc.Animate:create(animation)
       pterodactyl:runAction( animate )
       local moveOut = cc.MoveTo:create(0, cc.p(self.visibleSize.width + 100, self.visibleSize.height *  0.8))
       local moveIn = cc.MoveTo:create(4.0, cc.p(-100,  self.visibleSize.height * 0.8))
       local delay = cc.DelayTime:create(2.5)
    pterodactyl:runAction(cc.RepeatForever:create (cc.Sequence:create(moveOut, moveIn, delay) ) )
        local character = cc.Sprite:create("introCharacter.png")
        character:setPosition(self.middle.x, self.middle.y + 110)
        self:addChild(character)
        local frame = cc.Sprite:create("frame.png")
        frame:setPosition(self.middle.x, self.middle.y)
        self:addChild(frame)    
    end
    

    通过这种方式,我们添加了一个背景和两个其他精灵,以及一个翼龙在背景中飞行的动画。再一次,调用与 C++中的调用非常相似。

  5. 现在,让我们在init方法中添加一个带有播放按钮的菜单(所有这些仍然在init方法中):

    --create play button
    local function playGame()
       local bgMusicPath =    cc.FileUtils:getInstance():fullPathForFilename("background.mp3") 
       cc.SimpleAudioEngine:getInstance():playMusic(bgMusicPath, true)
       local scene = require("GameScene")
       local gameScene = scene.create()
       cc.Director:getInstance():replaceScene(gameScene)
    end
    
    local btnPlay = cc.MenuItemImage:create("playBtn.png",  "playBtnOver.png")
    btnPlay:setPosition(0,0)
    btnPlay:registerScriptTapHandler(playGame)
    local menu  = cc.Menu:create(btnPlay)
    menu:setPosition(self.middle.x, 80)
    self:addChild(menu)
    

在引用回调的同一方法中键入按钮的回调,类似于在 C++中编写一个块或甚至是 lambda 函数。

刚才发生了什么?

你使用 Cocos2d-x 和 Lua 创建了一个场景,其中包括一个菜单、几个精灵和一个动画。很容易看出 Lua 绑定与原始 C++绑定的调用是多么相似。而且,在 IDE 中的代码补全功能使得查找正确的方法变得轻而易举。

现在让我们处理GameScene类。

注意

Lua 最吸引人的特性之一是所谓的实时编码,在 Cocos IDE 中默认开启。为了了解我所说的实时编码是什么意思,这样做:当游戏在模拟器中运行时,更改你的代码中角色精灵的位置并保存它。你应该会在模拟器中看到变化生效。这是一种构建 UI 和游戏场景的绝佳方式。

行动时间——创建我们的游戏场景

GameScene类已经添加到起始项目中,并且一些代码已经就位。我们首先将专注于构建游戏界面和监听触摸:

  1. 让我们专注于addTouchEvents方法:

    function GameScene:addTouchEvents()
        local bg = cc.Sprite:create("background.jpg")
        bg:setPosition(self.middle.x, self.middle.y)
        self:addChild(bg)
    
        local function onTouchBegan(touch, event)
            self.gridController:onTouchDown(touch:getLocation())
            return true
        end
    
        local function onTouchMoved(touch, event)
            self.gridController:onTouchMove(touch:getLocation())
        end
    
        local function onTouchEnded(touch, event)
            self.gridController:onTouchUp(touch:getLocation())
        end
    
        local listener = cc.EventListenerTouchOneByOne:create()
           listener:registerScriptHandler (onTouchBegan,cc.Handler.EVENT_TOUCH_BEGAN )
       listener:registerScriptHandler (onTouchMoved,cc.Handler.EVENT_TOUCH_MOVED )
        listener:registerScriptHandler (onTouchEnded,cc.Handler.EVENT_TOUCH_ENDED )
        local eventDispatcher = bg:getEventDispatcher()
           eventDispatcher:addEventListenerWithSceneGraphPriority (listener, bg)
    end
    
  2. 再次,我们使用节点的事件分发器的实例注册事件。实际的触摸由我们的GridController对象处理。我们稍后会介绍这些;首先,让我们构建 UI。现在是时候在init方法上工作了:

    function GameScene:init ()
        self.gridController = GridController:create()
        self.gridAnimations = GridAnimations:create()
        self.objectPools = ObjectPools.create()
    
        self.gridAnimations:setGameLayer(self)
        self.gridController:setGameLayer(self)
        self.objectPools:createPools(self)
    

    创建我们的特殊对象,一个用于处理用户交互,另一个用于动画,以及我们熟悉的对象池。

  3. 接下来,我们添加几个节点和我们的得分标签:

    self:addChild( self.gemsContainer )
    self.gemsContainer:setPosition( 25, 80)
    --build interface
    local frame = cc.Sprite:create("frame.png")
    frame:setPosition(self.middle.x, self.middle.y)
    self:addChild(frame)
    local diamondScoreBg = cc.Sprite:create("diamondScore.png")
    diamondScoreBg:setPosition(100, constants.SCREEN_HEIGHT - 30)
    self:addChild(diamondScoreBg)
    local scoreBg = cc.Sprite:create("gemsScore.png")
    scoreBg:setPosition(280, constants.SCREEN_HEIGHT - 30)
    self:addChild(scoreBg)
    local ttfConfig = {}
    ttfConfig.fontFilePath="fonts/myriad-pro.ttf"
    ttfConfig.fontSize=20
    self.diamondScoreLabel = cc.Label:createWithTTF(ttfConfig,  "0", cc.TEXT_ALIGNMENT_RIGHT , 150)    
    self.diamondScoreLabel:setPosition  (140, constants.SCREEN_HEIGHT - 30)
    self:addChild(self.diamondScoreLabel)
    self.scoreLabel = cc.Label:createWithTTF(ttfConfig,  "0", cc.TEXT_ALIGNMENT_RIGHT , 150)    
    self.scoreLabel:setPosition (330, constants.SCREEN_HEIGHT - 30)
    self:addChild(self.scoreLabel) 
    end
    

Label:createWithTTF的 C++实现相比,主要的不同之处在于 Lua 中有一个字体配置表。

刚才发生了什么?

这次,我们学习了如何注册触摸事件以及如何创建真类型字体标签。接下来,我们将介绍如何创建一个典型的三消游戏的网格。

行动时间——构建宝石

三消游戏基本上有两种类型,一种是在游戏中自动进行匹配选择,另一种是由玩家进行选择。糖果传奇是前者的一个好例子,而钻石冲刺则是后者。在构建第一种类型的游戏时,你必须添加额外的逻辑来确保你开始游戏时网格中不包含任何匹配项。我们现在就要这样做:

  1. 我们从buildGrid方法开始:

    function GameScene:buildGrid ()
       math.randomseed(os.clock())
       self.enabled = false
        local g
        for c = 1, constants.GRID_SIZE_X do
            self.grid[c] = {}
            self.gridGemsColumnMap[c] = {}
            for r = 1, constants.GRID_SIZE_Y do
                if (c < 3) then
                    self.grid[c][r] =  constants.TYPES[ self:getVerticalUnique(c,r) ]
                else
                    self.grid[c][r] =  constants.TYPES[ self:getVerticalHorizontalUnique(c,r) ]
                end
               g = Gem:create()
                g:setType(  self.grid[c][r] )
               g:setPosition ( c * (constants.TILE_SIZE +  constants.GRID_SPACE), 
                    r * (constants.TILE_SIZE +  constants.GRID_SPACE))
               self.gemsContainer:addChild(g)           
                self.gridGemsColumnMap[c][r] = g
                table.insert(self.allGems, g)
           end
        end
        self.gridAnimations:animateIntro()    
    end
    

    通过更改 randomseed 值,确保每次运行游戏时都生成不同的随机宝石序列。

    当网格正在更改或动画时,enabled 属性将阻止用户交互。

    网格是由宝石列组成的二维数组。魔法发生在 getVerticalUniquegetVerticalHorizontalUnique 方法中。

  2. 为了确保没有任何宝石会在前两列形成三个宝石的匹配,我们垂直检查它们:

    function GameScene:getVerticalUnique (col, row)
       local type = math.floor (math.random () *  #constants.TYPES + 1 )
       if (self.grid[col][row-1] == constants.TYPES[type] and  self.grid[col][row-2] ~= nil and self.grid[col][row-2] ==  constants.TYPES[type]) then
            type = type + 1; 
            if (type == #constants.TYPES + 1) then type = 1 end
        end
        return type
    end
    

    所有这些代码所做的只是检查一列,看看是否有任何宝石正在形成相同类型的三个相连宝石的字符串。

  3. 然后,我们垂直和水平检查,从第三列开始:

    function GameScene:getVerticalHorizontalUnique (col, row)
       local type = self:getVerticalUnique (col, row)
       if (self.grid[col - 1][row] == constants.TYPES[type] and  self.grid[col - 2][row] ~= nil and self.grid[col - 2][row] ==  constants.TYPES[type]) then
            local unique = false
            while unique == false do
              type = self:getVerticalUnique (col, row)
              if (self.grid[col-1][row] == constants.TYPES[type] and
              self.grid[col - 2 ][row] ~= nil and  self.grid[col -  2 ][row] == constants.TYPES[type]) then
                --do nothing
              else
                 unique = true
              end           
            end
        end
        return type
    end
    

此算法正在执行我们之前对列所做的相同操作,但它还在单独的行上进行检查。

发生了什么?

我们创建了一个没有三个宝石匹配的宝石网格。再次强调,如果我们构建了用户必须选择匹配宝石簇以从网格中移除的匹配三游戏(如 Diamond Dash),我们根本不需要担心这个逻辑。

接下来,让我们通过宝石交换、识别匹配和网格折叠来操作网格。

是时候采取行动了——使用 GridController 改变网格。

GridController 对象启动所有网格更改,因为这是我们处理触摸的地方。在游戏中,用户可以拖动宝石与另一个宝石交换位置,或者首先选择他们想要移动的宝石,然后在两指触摸过程中选择他们想要交换位置的宝石。让我们添加处理这种触摸的代码:

  1. GridController 中,让我们添加 onTouchDown 的逻辑:

    function GridController:onTouchDown (touch)
        if (self.gameLayer.running == false) then
            local scene = require("GameScene")
            local gameScene = scene.create()
            cc.Director:getInstance():replaceScene(gameScene)
            local bgMusicPath =  cc.FileUtils:getInstance():fullPathForFilename("background.mp3") 
            cc.SimpleAudioEngine:getInstance():playMusic(bgMusicPath, true)
            return 
        end
    

    如果我们正在显示游戏结束屏幕,则重新启动场景。

  2. 接下来,我们找到用户试图选择的宝石:

      self.touchDown = true
        if (self.enabled == false) then return end
        local touchedGem = self:findGemAtPosition (touch)
        if (touchedGem.gem ~= nil ) then 
            if (self.gameLayer.selectedGem == nil) then
                self:selectStartGem(touchedGem)
            else
                if (self:isValidTarget(touchedGem.x,  touchedGem.y, touch) == true) then 
                    self:selectTargetGem(touchedGem)
                else
                    if (self.gameLayer.selectedGem ~= nil)  then self.gameLayer.selectedGem:deselect() end
                    self.gameLayer.selectedGem = nil
                    self:selectStartGem (touchedGem)
                end
            end
        end
    end
    

    我们找到离触摸位置最近的宝石。如果用户尚未选择宝石(selectedGem = nil),我们将刚刚触摸到的宝石作为第一个选中的宝石。否则,我们确定第二个选中的宝石是否可以用于交换。只有位于第一个选中宝石上方和下方的宝石,或者位于其左右两侧的宝石可以交换。如果这是有效的,我们就使用第二个宝石作为目标宝石。

  3. 在继续到 onTouchMoveonTouchUp 之前,让我们看看我们是如何确定哪个宝石被选中以及哪个宝石是有效目标宝石的。所以让我们处理 findGemAtPosition 值。首先确定触摸落在网格容器中的位置:

    function GridController:findGemAtPosition (position)
        local mx = position.x
        local my = position.y
        local gridWidth = constants.GRID_SIZE_X *  (constants.TILE_SIZE + constants.GRID_SPACE)
        local gridHeight = constants.GRID_SIZE_Y *  (constants.TILE_SIZE + constants.GRID_SPACE)
        mx = mx - self.gameLayer.gemsContainer:getPositionX()
        my = my - self.gameLayer.gemsContainer:getPositionY()
        if (mx < 0) then mx = 0 end
        if (my < 0) then my = 0 end
        if (mx > gridWidth) then mx = gridWidth end
        if (my > gridHeight) then my = gridHeight end
    
  4. 这里是魔法发生的地方。我们使用网格内触摸的 xy 位置来确定数组中宝石的索引:

    local x = math.ceil ((mx - constants.TILE_SIZE * 0.5) /  (constants.TILE_SIZE + constants.GRID_SPACE))
        local y = math.ceil ((my - constants.TILE_SIZE * 0.5) /  (constants.TILE_SIZE + constants.GRID_SPACE))
        if (x < 1) then x = 1 end
        if (y < 1) then y = 1 end
        if (x > constants.GRID_SIZE_X) then x =  constants.GRID_SIZE_X end
        if (y > constants.GRID_SIZE_Y) then y =  constants.GRID_SIZE_Y end
        return {x = x, y = y, gem =  self.gameLayer.gridGemsColumnMap[x][y]}
    end
    

    我们最后检查触摸是否超出数组界限。

  5. 现在让我们看看确定目标宝石是否为有效目标的逻辑:

    function GridController:isValidTarget (px, py, touch)
        local offbounds = false
        if (px > self.gameLayer.selectedIndex.x + 1) then 
    offbounds = true end
        if (px < self.gameLayer.selectedIndex.x - 1) then 
    offbounds = true end
        if (py > self.gameLayer.selectedIndex.y + 1) then 
    offbounds = true end
        if (py < self.gameLayer.selectedIndex.y - 1) then 
    offbounds = true end
    

    我们首先检查目标宝石是否位于所选宝石的顶部、底部、左侧或右侧:

    local cell = math.sin (math.atan2  (math.pow( self.gameLayer.selectedIndex.x - px, 2),  math.pow( self.gameLayer.selectedIndex.y- py, 2) ) )
        if (cell ~= 0 and cell ~= 1) then
            offbounds = true
        end
        if (offbounds == true) then
            return false
        end
    

    我们接下来使用一点三角学的魔法来确定所选的目标宝石是否与所选宝石对角线:

       local touchedGem = self.gameLayer.gridGemsColumnMap[px][py]
        if (touchedGem.gem == self.gameLayer.selectedGem or  (px == self.gameLayer.selectedIndex.x and  py == self.gameLayer.selectedIndex.y)) then
            self.gameLayer.targetGem = nil
            return false
        end
        return true
    end
    

    我们最后检查目标宝石是否与之前选中的宝石不同。

  6. 现在,让我们继续处理onTouchUp事件:

    function GridController:onTouchUp (touch)
        if (self.gameLayer.running == false) then return end
        self.touchDown = false
        if (self.enabled == false) then return end
        if (self.gameLayer.selectedGem ~= nil) then  self.gameLayer:dropSelectedGem() end
    end
    

    很简单!我们只是改变了选择宝石的z层级,因为我们想确保在交换发生时宝石显示在其他宝石之上。所以当我们释放宝石时,我们将其推回到原始的z层级(这就是dropSelectedGem方法所做的事情,我们很快就会看到它是如何做到这一点的)。

  7. onTouchMove事件处理选择宝石拖动直到它与另一个宝石交换位置:

    function GridController:onTouchMove (touch)
        if (self.gameLayer.running == false) then return end
        if (self.enabled == false) then return end
        --track to see if we have a valid target
        if (self.gameLayer.selectedGem ~= nil and  self.touchDown == true) then
            self.gameLayer.selectedGem:setPosition(
            touch.x - self.gameLayer.gemsContainer:getPositionX(), 
            touch.y - self.gameLayer.gemsContainer:getPositionY())
            local touchedGem = self:findGemAtPosition (touch)
            if (touchedGem.gem ~= nil and self:isValidTarget(touchedGem.x, touchedGem.y, touch) == true ) then
                self:selectTargetGem(touchedGem)
            end
        end
    end
    

    我们运行了与onTouchDown相同的逻辑。我们将selectedGem对象移动,直到找到一个合适的目标宝石,然后选择第二个作为目标。这就是交换发生的时候。现在让我们来做这件事。

  8. 首先,设置我们选择宝石的逻辑:

    function GridController:selectStartGem (touchedGem)
           if (self.gameLayer.selectedGem == nil) then
            self.gameLayer.selectedGem = touchedGem.gem
            self.gameLayer.targetGem = nil
            self.gameLayer.targetIndex = nil
            touchedGem.gem:setLocalZOrder(constants.Z_SWAP_2)
            self.gameLayer.selectedIndex = {x = touchedGem.x,  y = touchedGem.y}
            self.gameLayer.selectedGemPosition =  {x = touchedGem.gem:getPositionX(),
                                                  y =  touchedGem.gem:getPositionY()}
            self.gameLayer.gridAnimations:animateSelected  (touchedGem.gem)                                              
        end
    end
    

    我们开始交换过程;我们有一个选择的宝石但没有目标宝石。我们通过setLocalZOrder改变选择宝石的层级。同时,我们也让选择宝石旋转 360 度。

  9. 然后,我们准备好选择目标宝石:

    function GridController:selectTargetGem (touchedGem)
        if (self.gameLayer.targetGem ~= nil) then return end
        self.enabled = false
        self.gameLayer.targetIndex = {x = touchedGem.x,  y = touchedGem.y}
        self.gameLayer.targetGem = touchedGem.gem
        self.gameLayer.targetGem:setLocalZOrder(constants.Z_SWAP_1)
        self.gameLayer:swapGemsToNewPosition()
    end
    

现在是我们最终调用GameScene类并要求它交换宝石的时候了。

刚才发生了什么?

我们刚刚添加了处理所有用户交互的逻辑。现在,我们剩下要做的就是处理交换,检查匹配项并折叠网格。让我们来做吧!

行动时间 - 交换宝石并寻找匹配项

交换逻辑位于GameScene中的swapGemsToNewPosition方法:

  1. swapGemsToNewPosition方法调用一次GridAnimations来动画化选择宝石和目标宝石之间的交换。一旦这个动画完成,我们触发一个onNewSwapComplete方法。大部分逻辑都发生在这里:

    function GameScene:swapGemsToNewPosition ()
        local function onMatchedAnimatedOut (sender)
            self:collapseGrid()
        end
    
        local function onReturnSwapComplete (sender)
            self.gridController.enabled = true
        end
    
        local function onNewSwapComplete (sender)
           self.gridGemsColumnMap[self.targetIndex.x][self.targetIndex.y]  = self.selectedGem
            self.gridGemsColumnMap[self.selectedIndex.x][self.selectedIndex.y] =  self.targetGem
            self.grid[self.targetIndex.x][self.targetIndex.y] =  self.selectedGem.type
            self.grid[self.selectedIndex.x][self.selectedIndex.y] =  self.targetGem.type
    The call back switches the gems around inside the grid array.
            self.combos = 0
            self.addingCombos = true
    Combos are used to track if we have more than 3 gems matched  after the player's move.        
            --check for new matches
            if (self.gridController:checkGridMatches() == true) then
    
  2. 如果我们找到了匹配项,我们对匹配的宝石运行动画,如果没有匹配,我们播放一个交换回动画并播放一个音效来表示玩家走错了一步:

                    --animate matched gems
                if (#self.gridController.matchArray > 3) then  self.combos = self.combos + (#self.gridController.matchArray -  3) end 
                self.gridAnimations:animateMatches  (self.gridController.matchArray, onMatchedAnimatedOut)
                self:showMatchParticle  (self.gridController.matchArray)
                self:setGemsScore(#self.gridController.matchArray *  constants.POINTS)
                self:playFX("match2.wav")
            else
                --no matches, swap gems back
                self.gridAnimations:swapGems (self.targetGem,  self.selectedGem, onReturnSwapComplete)
                self.gridGemsColumnMap[self.targetIndex.x][self.targetIndex.y]  = self.targetGem
                self.gridGemsColumnMap[self.selectedIndex.x][self.selectedIndex.y]  = self.selectedGem
                self.grid[self.targetIndex.x][self.targetIndex.y] =  self.targetGem.type
                self.grid[self.selectedIndex.x][self.selectedIndex.y] =  self.selectedGem.type
                self:playFX("wrong.wav")
            end
    

    在每个新动画的末尾,无论是匹配动画还是交换回动画,我们再次运行方法顶部列出的回调。这些回调最重要的作用是在onMatchedAnimatedOut回调中匹配宝石动画完成后调用collapseGrid

            self.selectedGem = nil
            self.targetGem = nil
       end
    

    我们通过清除选择的宝石并从一张干净的局面开始来结束回调。

  3. 在这里,函数的末尾,我们调用带有onNewSwapComplete作为回调的宝石交换动画:

       self.gridAnimations:swapGems (self.selectedGem, self.targetGem, onNewSwapComplete)
    end
    
  4. 让我们回到GridController并添加checkGridMatches方法。这分为三个部分:

    function GridController:checkGridMatches ()
        self.matchArray = {}
        for c = 1, constants.GRID_SIZE_X do
            for r = 1, constants.GRID_SIZE_Y do
                self:checkTypeMatch(c,r)
            end
        end
        if (#self.matchArray >= 2) then
            self.gameLayer:addToScore()
            return true
        end
        print("no matches")
        return false
    end
    

    这个方法通过在每个单元格上运行checkTypeMatch来开始检查。

  5. checkTypeMatch方法在当前索引周围搜索,寻找索引的上方、下方、左侧和右侧的匹配项:

    function GridController:checkTypeMatch (c, r)
        local type = self.gameLayer.grid[c][r]
        local stepC = c
        local stepR = r
        local temp_matches = {}
        --check top
        while stepR -1 >= 1 and self.gameLayer.grid[c][stepR-1] ==  type do
            stepR = stepR - 1
            table.insert (temp_matches, {x = c, y = stepR})
        end 
        if (#temp_matches >= 2) then self:addMatches (temp_matches) end
        temp_matches = {}
        --check bottom
        stepR = r
        while stepR + 1 <= constants.GRID_SIZE_Y 
       and self.gameLayer.grid[c][stepR + 1] == type do
            stepR = stepR + 1
            table.insert (temp_matches, {x = c, y= stepR})
        end
        if (#temp_matches >= 2) then self:addMatches (temp_matches) end
        temp_matches = {}
        --check left
        while stepC - 1 >= 1 and self.gameLayer.grid[stepC - 1][r]  == type do
            stepC = stepC - 1
            table.insert (temp_matches, {x = stepC, y= r})
        end
        if (#temp_matches >= 2) then self:addMatches (temp_matches) end
        temp_matches = {}
        --check right
        stepC = c;
        while stepC + 1 <= constants.GRID_SIZE_X and  self.gameLayer.grid[stepC + 1][r] == type do
            stepC = stepC + 1
            table.insert (temp_matches, {x = stepC, y = r})
        end
        if (#temp_matches >= 2) then self:addMatches (temp_matches) end
    end
    

    如果找到了任何匹配项,它们将被添加到matches数组中。

  6. 但首先我们需要确保没有重复项列在那里,所以当我们向matches数组添加宝石时,我们检查它是否已经被添加:

    function GridController:addMatches (matches)
        for key, value in pairs(matches) do
            if (self:find(value, self.matchArray) == false) then
                table.insert(self.matchArray, value)
            end
        end
    end
    
  7. 以及查找重复项的简单方法:

    function GridController:find (np, array)
        for key, value in pairs(array) do
            if (value.x == np.x and value.y == np.y) then return true end
        end
        return false
    end
    

刚才发生了什么?

寻找匹配项是任何三合一游戏所需逻辑的一半以上。你所需要做的就是尽可能有效地遍历网格,寻找重复的模式。

其余的逻辑涉及网格坍塌。我们将在下一步进行,然后我们就可以发布游戏了。

是时候行动了——坍塌网格并重复

因此,游戏的流程是移动部件,寻找匹配项,移除它们,坍塌网格,添加新的宝石,再次寻找匹配项,如果需要,整个流程循环进行:

  1. 这是游戏中最长的方法,而且,同样,大部分逻辑都发生在回调中。首先,我们通过将它们的类型数据设置为 -1 来标记要移除的宝石。matchArray 中的所有宝石都将被移除:

    function GameScene:collapseGrid ()
        for i = 1, #self.gridController.matchArray do
            self.grid[self.gridController.matchArray[i].x]
            [self.gridController.matchArray[i].y] = -1
        end
    
        local column = nil
        local newColumn = nil
        local i
    
  2. 接下来,我们遍历网格的列,重新排列列数组中类型不等于 -1 的宝石。本质上,我们在这里更新数据,以便移除的宝石上面的宝石“落下”。实际的变化将在 animateCollapse 方法中发生:

        for c = 1, constants.GRID_SIZE_X do
            column = self.grid[c]
            newColumn = {}
            i = 1
            while #newColumn < #column do
                if (#column > i) then
                    if (column[i] ~= -1) then
                        --move gem
                        table.insert(newColumn, column[i])
                    end
                else
                    --create new gem
                    table.insert(newColumn, 1, column[i])
                end
                i = i+1            
            end
            self.grid[c] = newColumn
        end
        self.gridAnimations:animateCollapse  (onGridCollapseComplete)
    end
    
  3. 但现在,让我们编写动画回调 onGridCollapseComplete 的代码。所以我们在 collapseGrid 中已经输入的代码上方添加 local 函数:

    local function onGridCollapseComplete (sender)
       local function onMatchedAnimatedOut (sender)
          self:collapseGrid()
       end
       for i = 1, #self.allGems do
          local gem = self.allGems[i]
          local xIndex = math.ceil ((gem:getPositionX() -  constants.TILE_SIZE * 0.5) / (constants.TILE_SIZE +  constants.GRID_SPACE))
          local yIndex = math.ceil ((gem:getPositionY() -  constants.TILE_SIZE * 0.5) / (constants.TILE_SIZE +  constants.GRID_SPACE))
          self.gridGemsColumnMap[xIndex][yIndex] = gem
          self.grid[xIndex][yIndex] = gem.type
       end
    

    首先,我们更新精灵数组,按网格的新 xy 索引排序。

  4. 然后,我们再次检查匹配项。记住,这个回调在网格坍塌动画完成后运行,这意味着已经添加了新的宝石,这些宝石可能创建了新的匹配项(我们很快将查看逻辑):

    if (self.gridController:checkGridMatches () == true) then
          --animate matched games
          if (self.addingCombos == true) then
             if (#self.gridController.matchArray > 3) then  self.combos = self.combos + (#self.gridController.matchArray -  3) end
          end
          self.gridAnimations:animateMatches  (self.gridController.matchArray, onMatchedAnimatedOut)
          self:showMatchParticle (self.gridController.matchArray)
          self:setGemsScore(#self.gridController.matchArray *  constants.POINTS)
          self:playFX("match.wav")
    
  5. 然后,如果我们没有找到更多的匹配项,当组合的价值大于 0(意味着在上一个玩家的移动中我们有多于 3 个宝石匹配)时,我们将一些随机的宝石替换为钻石:

    else 
       --no more matches, check for combos
       if (self.combos > 0) then
       --now turn random gems into diamonds
           local diamonds = {}
           local removeGems = {}
           local i = 0
    
           math.randomseed(os.clock())
           while i < self.combos do
             i = i + 1
             local randomGem = nil
             local randomX,randomY = 0
             while randomGem == nil do
               randomX = math.random(1, constants.GRID_SIZE_X)
               randomY = math.random(1, constants.GRID_SIZE_Y)
               randomGem = self.gridGemsColumnMap[randomX][randomY]
               if (randomGem.type == constants.TYPE_GEM_WHITE)  then randomGem = nil end
           end
    
  6. 我们随机选择宝石作为钻石:

            local diamond = self.objectPools:getDiamond()
          diamond:setPosition(randomGem:getPositionX(),  randomGem:getPositionY())
          local diamondParticle =  self.objectPools:getDiamondParticle()
          diamondParticle:setPosition(randomGem:getPositionX(),  randomGem:getPositionY())
             table.insert(diamonds, diamond)   
             table.insert(removeGems, {x=randomX, y=randomY}) 
            end
            self:setDiamondScore(#diamonds *  constants.DIAMOND_POINTS)
    

    动画收集钻石,并在该动画结束时调用 onMatchedAnimatedOut 回调,此时由于宝石“爆裂”成钻石,网格将再次坍塌:

            self.gridAnimations:animateMatches(removeGems,  onMatchedAnimatedOut)                
         self.gridAnimations:collectDiamonds(diamonds)
         self.combos = 0 
         self:playFX("diamond2.wav")  
        else
         self.gridController.enabled = true
        end
         self.addingCombos = false
       end
    end
    
  7. 这是整个 collapseGrid 方法:

    function GameScene:collapseGrid ()
        local function onGridCollapseComplete (sender)
           local function onMatchedAnimatedOut (sender)
                self:collapseGrid()
            end
           for i = 1, #self.allGems do
                local gem = self.allGems[i]
                local xIndex = math.ceil ((gem:getPositionX() -  constants.TILE_SIZE * 0.5) / (constants.TILE_SIZE +  constants.GRID_SPACE))
                local yIndex = math.ceil ((gem:getPositionY() -  constants.TILE_SIZE * 0.5) / (constants.TILE_SIZE +  constants.GRID_SPACE))
                self.gridGemsColumnMap[xIndex][yIndex] = gem
                self.grid[xIndex][yIndex] = gem.type
            end
            if (self.gridController:checkGridMatches () == true) then
               --animate matched games
               if (self.addingCombos == true) then
                   if (#self.gridController.matchArray > 3) then  self.combos = self.combos + (#self.gridController.matchArray -  3) end
               end
               self.gridAnimations:animateMatches  (self.gridController.matchArray, onMatchedAnimatedOut)
               self:showMatchParticle  (self.gridController.matchArray)
               self:setGemsScore(#self.gridController.matchArray *  constants.POINTS)
               self:playFX("match.wav")
            else 
                --no more matches, check for combos
                if (self.combos > 0) then
                    --now turn random gems into diamonds
                    local diamonds = {}
                    local removeGems = {}
                    local i = 0
                    math.randomseed(os.clock())
                    while i < self.combos do
                       i = i + 1
                       local randomGem = nil
                        local randomX,randomY = 0
                       while randomGem == nil do
                            randomX =  math.random(1, constants.GRID_SIZE_X)
                            randomY =  math.random(1, constants.GRID_SIZE_Y)
                            randomGem =  self.gridGemsColumnMap[randomX][randomY]
                            if (randomGem.type ==  constants.TYPE_GEM_WHITE) then randomGem = nil end
                        end
                        local diamond =  self.objectPools:getDiamond()
                        diamond:setPosition(randomGem:getPositionX(),  randomGem:getPositionY())
                        local diamondParticle =  self.objectPools:getDiamondParticle()
                        diamondParticle:setPosition(randomGem:getPositionX(),  randomGem:getPositionY())
                        table.insert(diamonds, diamond)
                        table.insert(removeGems, {x=randomX,  y=randomY}) 
                    end
                    self:setDiamondScore(#diamonds *  constants.DIAMOND_POINTS)
                    self.gridAnimations:animateMatches(removeGems,  onMatchedAnimatedOut)                
                    self.gridAnimations:collectDiamonds(diamonds)
                    self.combos = 0 
                    self:playFX("diamond2.wav")                 
                else
                    self.gridController.enabled = true
                end
                self.addingCombos = false
            end
        end
        for i = 1, #self.gridController.matchArray do
            self.grid[self.gridController.matchArray[i].x] [self.gridController.matchArray[i].y] = -1
        end
    
        local column = nil
        local newColumn = nil
        local i
        for c = 1, constants.GRID_SIZE_X do
            column = self.grid[c]
            newColumn = {}
            i = 1
            while #newColumn < #column do
                if (#column > i) then
                    if (column[i] ~= -1) then
                        --move gem
                        table.insert(newColumn, column[i])
                    end
                else
                    --create new gem
                    table.insert(newColumn, 1, column[i])
                end
                i = i+1            
            end
            self.grid[c] = newColumn
        end
        self.gridAnimations:animateCollapse  (onGridCollapseComplete)
    end
    

刚才发生了什么?

collapseGrid 方法收集所有受匹配或爆炸成钻石的宝石影响的宝石。结果数组被发送到 GridAnimations 以执行适当的动画。

我们将在这些基础上工作,完成我们的游戏。

是时候行动了——动画匹配和坍塌

现在是最后一点逻辑:最后的动画:

  1. 我们将从简单的开始:

    function GridAnimations:animateSelected (gem)
        gem:select()
        gem:stopAllActions()
        local rotate = cc.EaseBounceOut:create ( cc.RotateBy:create(0.5, 360) )
        gem:runAction(rotate)
    end
    

    这会使宝石旋转;我们使用这个动画来表示宝石首次被选中。

  2. 接下来是交换动画:

    function GridAnimations:swapGems  (gemOrigin, gemTarget, onComplete)
        gemOrigin:deselect()
       local origin = self.gameLayer.selectedGemPosition
        local target = cc.p(gemTarget:getPositionX(),  gemTarget:getPositionY()) 
       local moveSelected =  cc.EaseBackOut:create(cc.MoveTo:create(0.8, target) )   
        local moveTarget =  cc.EaseBackOut:create(cc.MoveTo:create(0.8, origin) )
        local callback = cc.CallFunc:create(onComplete)
       gemOrigin:runAction(moveSelected)
        gemTarget:runAction (cc.Sequence:create(moveTarget, callback))
    end
    

    这只是交换第一个选择的宝石和目标宝石的位置。

  3. 然后,我们添加运行匹配宝石的动画:

    function GridAnimations:animateMatches (matches, onComplete)
        local function onCompleteMe (sender)
           self.animatedMatchedGems = self.animatedMatchedGems - 1;
            if (self.animatedMatchedGems == 0) then
                if (onComplete ~= nil) then onComplete() end
            end
    end
        self.animatedMatchedGems = #matches
       local gem = nil
        for i, point in ipairs(matches) do
            gem = self.gameLayer.gridGemsColumnMap[point.x] [point.y]
            gem:stopAllActions()
            local scale = cc.EaseBackOut:create  ( cc.ScaleTo:create(0.3, 0))
            local callback = cc.CallFunc:create(onCompleteMe)
            local action = cc.Sequence:create (scale, callback)
            gem.gemContainer:runAction(action)
        end
    end
    

    这将使宝石缩放到无,并且只有当所有宝石完成缩放时才触发最终的回调。

  4. 接下来是收集钻石的动画:

    function GridAnimations:collectDiamonds(diamonds)
        local function removeDiamond (sender)
            sender:setVisible(false)
        end
        for i = 1, #diamonds do
            local delay = cc.DelayTime:create(i * 0.05)
            local moveTo = cc.EaseBackIn:create( cc.MoveTo:create ( 0.8, cc.p(50, constants.SCREEN_HEIGHT - 50) ) )
            local action = cc.Sequence:create  (delay, moveTo, cc.CallFunc:create(removeDiamond))
            diamonds[i]:runAction(action)
        end
    end
    

    这将钻石移动到钻石得分标签的位置。

  5. 现在,最后,添加网格坍塌:

    function GridAnimations:animateCollapse ( onComplete )
        self.animatedCollapsedGems = 0
        local gem = nil
        local drop  = 1
       for c = 1, constants.GRID_SIZE_X do 
            drop = 1
            for r = 1, constants.GRID_SIZE_Y do
                gem = self.gameLayer.gridGemsColumnMap[c][r]
                --if this gem has been resized, move it to the top 
                if (gem.gemContainer:getScaleX() ~= 1) then
                    gem:setPositionY((constants.GRID_SIZE_Y +  (drop)) * (constants.TILE_SIZE + constants.GRID_SPACE))
                    self.animatedCollapsedGems =  self.animatedCollapsedGems + 1
                    gem:setType ( self.gameLayer:getNewGem() )
                    gem:setVisible(true)
                    local newY = (constants.GRID_SIZE_Y -  (drop - 1)) * (constants.TILE_SIZE + constants.GRID_SPACE)
                    self:dropGemTo (gem, newY,  0.2, onComplete)
                    drop = drop + 1
                else
                   if (drop > 1) then
                        self.animatedCollapsedGems =  self.animatedCollapsedGems + 1
                        local newY = gem:getPositionY() -  (drop - 1) * (constants.TILE_SIZE + constants.GRID_SPACE)
                        self:dropGemTo (gem, newY, 0, onComplete)
                    end
               end
            end 
        end
    end 
    

    我们遍历所有宝石,并识别出那些被缩小的宝石,这意味着它们已经被移除。我们将这些宝石移动到列的上方,这样它们就会作为新的宝石落下,并为它们选择一个新的类型:

    gem:setType ( self.gameLayer:getNewGem() )
    

    那些没有被移除的宝石将落到它们的新位置。我们这样做的方式很简单。我们计算有多少宝石被移除,直到我们到达一个没有被移除的宝石。这个计数存储在局部变量 drop 中,每次列重置时都会将其重置为0

    这样,我们就知道了有多少宝石被其他宝石下面的宝石移除。我们使用这个信息来找到新的y位置。

  6. dropGemTo新位置看起来是这样的:

    function GridAnimations:dropGemTo (gem, y, delay, onComplete)
          gem:stopAllActions()
        gem:reset()
        local function onCompleteMe  (sender)
            self.animatedCollapsedGems =  self.animatedCollapsedGems - 1;
            if ( self.animatedCollapsedGems == 0 ) then
                if (onComplete ~= nil) then onComplete() end
            end
        end
        local move = cc.EaseBounceOut:create  (cc.MoveTo:create (0.6, cc.p(gem:getPositionX(), y) ) )
        local action = cc.Sequence:create  (cc.DelayTime:create(delay), move,  cc.CallFunc:create(onCompleteMe))
        gem:runAction(action)
    end
    

再次强调,我们只在所有宝石都坍塌后才会触发最终的回调。这个最终的回调将运行另一个检查匹配,就像我们之前看到的,然后再次启动整个过程。

刚才发生了什么?

就这样;我们已经拥有了三合一游戏的三个主要部分:交换、匹配和坍塌。

我们还没有介绍的一个动画,它已经包含在本章的代码中,那就是当网格首次创建时,用于介绍动画的列下降动画。但那个并没有什么新意。尽管如此,你可以随意查看它。

现在,是时候发布游戏了。

是时候使用 Cocos IDE 发布游戏了。

为了构建和发布游戏,我们需要告诉 IDE 一些信息。我会展示如何为 Android 发布游戏,但步骤对于其他目标也非常相似:

  1. 首先,让我们告诉 IDE 在哪里可以找到 Android SDK、NDK 和 ANT,就像我们安装 Cocos2d-x 控制台时做的那样。在 IDE 中,打开首选项面板。然后,在Cocos下输入三个路径,就像我们之前做的那样(记住,对于 ANT,你需要导航到它的bin文件夹)。使用 Cocos IDE 发布游戏的操作时间

  2. 现在,为了构建项目,你需要选择 IDE 顶部的第四个按钮(从左侧开始),或者右键点击你的项目并选择Cocos Tools。根据你在部署过程中的阶段,你将会有不同的选项可用。使用 Cocos IDE 发布游戏的操作时间

    首先,IDE 需要添加原生代码支持,然后它会在名为 frameworks 的文件夹内构建项目(它将包含 iOS、Mac OS、Windows、Android 和 Linux 版本的你的项目,就像你通过 Cocos 控制台创建它一样)。

  3. 然后,你可以选择将应用程序打包成 APK 或 IPA,你可以将其传输到你的手机上。或者,你可以使用 Eclipse 或 Xcode 中的生成项目。

刚才发生了什么?

你刚刚将你的 Lua 游戏构建到了 Android、iOS、Windows、Linux、Mac OS,或者所有这些平台!做得好。

概述

就这些。你现在可以选择 C++ 或 Lua 来构建你的游戏。整个 API 都可以通过这两种方式访问。所以,这本书中创建的每个游戏都可以用这两种语言(是的,包括 Box2D API)来完成。

这本书就到这里了。希望你不是太累,可以开始着手自己的想法。并且我希望不久能在附近的 App Store 中看到你的游戏!

附录 A. 使用 Cocos2d-x 进行向量计算

本附录将更详细地介绍 第五章 中使用的数学概念,“在线上 – 火箭穿越”。

什么是向量?

首先,让我们快速回顾一下向量以及你如何使用 Cocos2d-x 来处理它们。

那么,向量与点的区别是什么?起初,它们看起来很相似。考虑以下点和向量:

  • 点 (2, 3.5)

  • Vec2 (2, 3.5)

以下图展示了点和向量:

什么是向量?

在这个图中,它们每个的 xy 值都相同。那么区别在哪里?

使用向量,你总是有额外的信息。就好像,除了 xy 这两个值之外,我们还有向量的原点 xy 的值,在之前的图中我们可以假设它是点 (0, 0)。所以向量是 移动 在从点 (0, 0) 到点 (2, 3.5) 描述的方向上。我们可以从向量中推导出的额外信息是方向和长度(通常称为大小)。

就好像向量是一个人的步幅。我们知道每一步有多长,也知道这个人朝哪个方向走。

在游戏开发中,向量可以用来描述运动(速度、方向、加速度、摩擦等)或作用于物体的合力。

向量方法

你可以用向量做很多事情,有很多种方法来创建和操作它们。Cocos2d-x 还附带了一些辅助方法,可以帮助你完成大部分计算。以下是一些示例:

  • 你有一个向量,并且想要得到它的角度——使用 getAngle()

  • 你想要一个向量的长度——使用 getLength()

  • 你想要减去两个向量;例如,为了通过另一个向量减少精灵的移动量——使用 vector1 - vector2

  • 你想要添加两个向量;例如,为了通过另一个向量增加精灵的移动量——使用 vector1 + vector2

  • 你想要乘以一个向量;例如,将摩擦值应用到精灵的移动量上——使用 vector1 * vector2

  • 你想要一个垂直于另一个向量(也称为向量的法线)的向量——使用 getPerp()getRPerp()

  • 最重要的是,对于我们的游戏示例,你想要两个向量的点积——使用 dot(vector1, vector2)

现在让我给你展示如何在我们的游戏示例中使用这些方法。

使用 ccp 辅助方法

Rocket Through的例子中,我们在第五章中开发的On the Line – Rocket Through游戏中使用了向量来描述运动,现在我想向你展示我们用来处理向量操作的一些方法的逻辑以及它们的含义。

围绕一个点旋转火箭

让我们以火箭精灵以向量(5, 0)移动为例开始:

围绕一个点旋转火箭

然后,我们从火箭画一条线,比如说从点A到点B

围绕一个点旋转火箭

现在我们想让火箭围绕点B旋转。那么我们如何改变火箭的向量来实现这一点?使用 Cocos2d-x,我们可以使用辅助点方法rotateByAngle来围绕任何其他点旋转一个点。在这种情况下,我们通过一定的角度将火箭的位置点围绕点B旋转。

但这里有一个问题——火箭应该朝哪个方向旋转?

围绕一个点旋转火箭

通过观察这个图,你知道火箭应该顺时针旋转,因为它正在向右移动。但程序上,我们如何确定这一点,并且以最简单的方式确定?我们可以通过使用向量和从它们导出的另一个属性:点积来确定这一点。

使用向量的点积

两个向量的点积描述了它们的角关系。如果它们的点积大于零,则两个向量形成的角度小于 90 度。如果它小于零,则角度大于 90 度。如果它等于零,则向量是垂直的。看看这个描述性的图:

使用向量的点积

但另一种思考方式是,如果点积是一个正值,那么向量将“指向”同一方向。如果它是负值,它们指向相反的方向。我们如何利用这一点来帮助我们?

向量始终有两个垂线,如图所示:

使用向量的点积

这些垂线通常被称为左右或顺时针和逆时针垂线,并且它们自身也是向量,被称为法线。

现在,如果我们计算火箭的向量与线AB上的每个垂线之间的点积,你可以看到我们可以确定火箭应该旋转的方向。如果火箭和向量的右垂线的点积是一个正值,这意味着火箭正在向右移动(顺时针)。如果不是,这意味着火箭正在向左移动(逆时针)。

使用向量的点积

点积非常容易计算。我们甚至不需要担心公式(尽管它很简单),因为我们可以使用d ot(vector1, vector2)方法。

因此,我们已经有火箭的向量了。我们如何得到法线的向量?首先,我们得到AB线的向量。我们为此使用另一个方法——point1 - point2。这将减去点AB,并返回表示该线的向量。

接下来,我们可以使用getPerp()getRPerp()方法分别得到那个线向量的左右垂直线。然而,我们只需要检查其中一个。然后我们用dot(rocketVector, lineNormal)得到点积。

如果这是正确的法线,意味着点积的值是正的,我们可以将火箭旋转到指向这个法线方向;因此,当火箭旋转时,它将始终与线保持 90 度角。这很容易,因为我们可以用getAngle()方法将法线向量转换为角度。我们只需要将这个角度应用到火箭上。

但火箭应该旋转多快?我们将在下一部分看到如何计算这一点。

从基于像素的速度转换为基于角度的速度

当旋转火箭时,我们仍然希望显示它以与直线移动时相同的速度移动,或者尽可能接近。我们如何做到这一点?

从基于像素的速度转换为基于角度的速度

记住,向量正在被用来在每次迭代中更新火箭的位置。在我给出的例子中,(5, 0)向量目前在每次迭代中向火箭的 x 位置添加 5 像素。

现在让我们考虑角速度。如果角速度是 15 度,并且我们保持以那个角度旋转火箭的位置,这意味着火箭将在 24 次迭代内完成一个完整的圆。因为一个完整圆的 360 度除以 15 度等于 24。

但我们还没有正确的角度;我们只有火箭在每次迭代中移动的像素量。但数学可以在这里告诉我们很多。

数学告诉我们,圆的长度是圆的半径乘以π的两倍,通常写作2πr

我们知道我们想要火箭描述的圆的半径。它是我们画的线的长度。

从基于像素的速度转换为基于角度的速度

使用那个公式,我们可以得到那个圆的像素长度,也称为其周长。假设线的长度为 100 像素;这意味着火箭即将描述的圆的长度(或周长)为 628.3 像素(2 * π * 100)。

使用向量中描述的速度(5, 0),我们可以确定火箭完成那个像素长度需要多长时间。我们不需要这绝对精确;最后一次迭代最有可能超过那个总长度,但对于我们的目的来说已经足够好了。

从基于像素的速度转换为基于角度的速度

当我们有了完成长度所需的总迭代次数,我们可以将其转换为角度。所以,如果迭代值是 125,角度将是 360 度除以 125;即,2.88 度。这将是在 125 次迭代中描述圆所需的角。

从基于像素的速度转换为基于角度的速度

现在,火箭可以从基于像素的运动转换为基于角度的运动,而视觉变化不大。

附录 B. 突击测验答案

第四章,与精灵的乐趣 – 天空防御

突击测验 – 精灵和动作

Q1 2
Q2 1
Q3 3
Q4 4

第八章,物理化 – Box2D

突击测验

Q1 3
Q2 2
Q3 1
Q4 3
posted @ 2025-10-02 09:35  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报