安卓游戏编程示例-全-

安卓游戏编程示例(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

制作游戏是令人上瘾且非常有成就感的,一旦开始就很难停下来。问题出现在我们遇到障碍时,因为我们不知道如何实现一个特性,或者将其整合到游戏中。这本书是关于尽可能多地将 Android 2D 游戏特性压缩进 11 章的旋风之旅。

书中展示了构建三个难度递增的游戏的每一行代码,并以简单明了的方式进行了解释。

逐步构建一个灵活且先进的游戏引擎,使用 OpenGL ES 2 实现快速流畅的帧率。这是通过从一个简单的游戏开始,逐步增加三个完整游戏的复杂性来实现的。

实现酷炫的特性,如图像表角色动画和滚动视差背景。设计和实现真正具有挑战性和可玩性的平台游戏关卡。

学习编码基础和高级碰撞检测。简化 2D 旋转、速度和碰撞背后的数学。让你的游戏设计以每秒 60 帧或更好的速度运行。

处理多点触控屏幕输入。实现许多其他游戏特性,如拾取物品、发射武器、HUD、生成和播放音效、风景、关卡过渡、高分榜等。

这本书涵盖的内容

第一章,玩家 1 启动,是关于我们将构建的三个酷炫游戏的介绍。我们还将设置开发环境。

第二章,Tappy Defender – 第一步,是关于规划游戏项目,并让我们的第一个游戏引擎的代码运行起来。我们将实现一个主游戏循环,控制帧率,并在屏幕上绘制。

第三章,Tappy Defender – 飞向太空,教我们添加许多新对象和一些特性,如玩家控制、敌人以及背景中的滚动星星。在碰撞检测 - 碰撞的东西部分,我们将讨论碰撞检测选项,并为这个游戏实现一个高效的解决方案。

第四章,Tappy Defender – 回家,完成了游戏,包括增加高分榜、胜利条件、音效等。

第五章,Platformer – 升级游戏引擎,提供了理解简单游戏引擎所需内容的好方法。我们可以快速了解并构建更高级、更灵活的引擎,适用于真正困难、复古的 2D 平台游戏。

第六章, 平台游戏——鲍勃、哔哔声和碰撞,使用我们的新游戏引擎添加一个类来管理声音特效,以及一个类来实现这类游戏所需的更复杂的玩家控制。然后我们可以让鲍勃,我们的可玩角色,成为一个奔跑、跳跃的英雄动画。

第七章, 平台游戏——枪、生命、金钱和敌人,继续前两章的主题;在这一章中,我们将添加大量功能。我们将添加可收集的拾取物和升级包,一个致命的追踪敌人,以及一个巡逻的守卫。当然,所有这些功能,鲍勃将需要一把机枪来保护自己,他得到了一把!

第八章, 平台游戏——组合在一起,我们的平台游戏在这里变得生动。我们将添加许多新的平台瓦片类型和场景对象,多个滚动视差背景,碰撞检测,以及一个传送系统,以便鲍勃可以在游戏的各个级别之间旅行。使用我们的瓦片类型、场景对象和背景范围,我们将实现四个通过传送系统连接的可玩关卡。

第九章, 使用 OpenGL ES 2 达到 60 FPS 的小行星,包含本书的最终项目,这是对超快的 OpenGL 图形库进行 2D 游戏介绍。在本章中,我们将快速学习如何使用 OpenGL ES 2 进行绘制,并将绘制系统整合到我们的游戏引擎中。到本章结束时,我们将拥有一个可以绘制类似小行星风格太空船到屏幕上的工作引擎。

第十章, 使用 OpenGL ES 2 移动和绘制,我们将快速整合之前项目中的声音和控制系统。然后,我们可以为玩家的太空船添加游戏边框、闪烁的星系、旋转的小行星、整洁的 HUD、逐渐增加难度的关卡以及快速开火的枪。

第十一章, 碰撞物——第二部分,通过添加碰撞检测来完成小行星游戏。检测与不规则形状旋转的小行星碰撞所需的数学变得简单,并将其实现到游戏引擎中。在本章结束时,你将拥有第三个也是最后一个完全可玩的游戏。

本书所需准备

任何主流操作系统上运行的近期免费版 Eclipse 或 Android Studio 都可以使用本书中的代码。

推荐使用 Android Studio 作为开发工具,在本书出版时,最低系统要求如下:

对于 Windows:

  • 微软 Windows 8/7/Vista/2003(32 或 64 位)

  • 2 GB RAM 最低要求,4 GB RAM 推荐

  • 400 MB 硬盘空间

  • 至少 1 GB 空间用于 Android SDK、模拟器系统镜像和缓存

  • 最低 1280 x 800 屏幕分辨率

  • Java 开发工具包(JDK)7

  • 加速模拟器可选:支持 Intel VT-x、Intel EM64T(Intel 64)和执行禁用(XD)位功能的 Intel 处理器

对于 Mac OS X:

  • 需要安装 Mac OS X 10.8.5 或更高版本,直至 10.9(Mavericks)

  • 最低 2 GB RAM,建议 4 GB RAM

  • 400 MB 硬盘空间

  • 至少 1 GB 用于 Android SDK、模拟器系统映像和缓存

  • 最低 1280 x 800 屏幕分辨率

  • Java 运行环境(JRE)6

  • Java 开发工具包(JDK)7

  • 加速模拟器可选:支持 Intel VT-x、Intel EM64T(Intel 64)和执行禁用(XD)位功能的 Intel 处理器

在 Mac OS 上,使用 Java 运行环境(JRE)6 运行 Android Studio 以优化字体渲染。然后,您可以配置项目以使用 JDK 6 或 JDK 7。

对于 Linux:

  • GNOME 或 KDE 桌面

  • GNU C 库(glibc)2.15 或更高版本

  • 最低 2 GB RAM,建议 4 GB RAM

  • 400 MB 硬盘空间

  • 至少 1 GB 用于 Android SDK、模拟器系统映像和缓存

  • 最低 1280 x 800 屏幕分辨率

  • Oracle Java 开发工具包(JDK)7

在 Ubuntu 14.04,Trusty Tahr(64 位分发版,能够运行 32 位应用程序)上测试。

本书适合的读者

这本书最适合那些希望将自己的技能适应于开发激动人心的 Android 游戏的现有 Android 或 Java 程序员。

这本书也适合那些可能没有 Android、游戏编程甚至 Java 经验,但假定有良好面向对象编程理解的读者。

此外,具有至少一些面向对象编程(OOP)经验的坚定编程初学者也可以跟随并构建所有项目,因为这本书采用了逐步指导的方法。对于那些已经完成《通过构建 Android 游戏学习 Java》的读者来说,这本书也非常适合。

约定

在这本书中,您会发现多种文本样式,用于区分不同类型的信息。以下是一些样式示例及其含义的解释。

文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理程序会像这样显示:"我们首先会添加所有类,然后在通常的三个地方更新LevelManager。"

代码块设置如下:

if (lm.isPlaying()) {
  // Reset the players location as 
  // the world centre of the viewport
  //if game is playing
  vp.setWorldCentre(lm.gameObjects.get(lm.playerIndex)
    .getWorldLocation().x,
    lm.gameObjects.get(lm.playerIndex)
    .getWorldLocation().y);

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

 //Has player fallen out of the map?
 if (lm.player.getWorldLocation().x < 0 ||
 lm.player.getWorldLocation().x > lm.mapWidth ||
 lm.player.getWorldLocation().y > lm.mapHeight) {

新术语重要词汇以粗体显示。您在屏幕上看到的内容,例如菜单或对话框中的,会像这样出现在文本中:"在接下来显示的创建新项目窗口中,我们需要输入有关我们应用的基本信息。"

注意

警告或重要提示会像这样出现在一个框中。

提示

技巧和窍门会像这样出现。

读者反馈

我们非常欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们很重要,因为它能帮助我们开发出您真正能从中受益的图书。

如果要给我们发送一般反馈,只需通过电子邮件 <feedback@packtpub.com> 联系我们,并在邮件的主题中提及书名。

如果您在某个主题上有专业知识,并且您有兴趣撰写或参与书籍编写,请查看我们的作者指南 www.packtpub.com/authors

客户支持

既然您已经拥有了 Packt 的一本书,我们有很多方法可以帮助您充分利用您的购买。

下载示例代码

您可以从您的账户中下载所有您购买的 Packt Publishing 书籍的示例代码文件,网址是 www.packtpub.com。如果您在别处购买了这本书,可以访问 www.packtpub.com/support 注册,我们会将文件直接通过电子邮件发送给您。

下载本书的色彩图像

我们还为您提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的色彩图像。色彩图像可以帮助您更好地理解输出的变化。您可以从以下网址下载此文件:www.packtpub.com/sites/default/files/downloads/0122OS_ColoredImages.pdf

错误更正

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

要查看之前提交的错误更正,请访问 www.packtpub.com/books/content/support,在搜索字段中输入书名。所需信息将显示在 错误更正 部分下。

盗版

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

如发现疑似盗版材料,请通过 <copyright@packtpub.com> 联系我们,并提供相关链接。

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

问题

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

第一章:玩家 1 UP

老式街机和弹球机使用的术语“1 UP”是一种通知玩家他们正在(继续)游戏的提示。它还用来表示获得额外生命。你准备好构建三个伟大的游戏了吗?

我们将一起构建三个很酷的游戏。这本书中展示了这三个游戏每一行代码;你无需参考代码文件就能了解正在发生什么。此外,构建这三个游戏所需的所有文件都可以在 Packt 网站上的书籍页面下载捆绑包中获得。

下载内容还包括所有代码、Android 清单文件以及图形和音频资源。这三个酷游戏实现难度逐渐增加。

第一个项目使用了一个简单但功能性的游戏引擎,清晰地展示了主游戏循环的基本要素。游戏将包括主屏幕、高分记录、声音和动画,并且完全可玩。但到项目结束时,随着我们添加功能和尝试平衡游戏玩法,我们会很快发现我们需要更多的灵活性来添加功能。

在第二个项目中,一个硬派复古平台游戏,我们将看到如何使用简单灵活的设计构建一个相对快速且非常灵活的游戏引擎,它是可扩展和可重用的。这种灵活性将允许我们制作相当复杂且功能丰富的游戏。这个游戏将包含多个关卡、不同的环境等等。这进而突出了快速绘制图形的需要。这引导我们进入第三个项目。

在第三个项目中,我们将构建一个类似《小行星》的游戏,称为小行星模拟器。尽管这个游戏没有前一个项目那么多功能,但它能以每秒 60 帧以上的速度绘制数百个动画游戏对象,实现超平滑的视觉效果。我们将通过学习和使用嵌入式系统开放图形库OpenGL ES 2)来实现这一点。

到本书结束时,你将拥有一整套可以在未来游戏中使用的设计理念、技术和代码模板。通过了解在 Android 上制作游戏的多种方式的优缺点,你将能够以最适合你下一个大型游戏的方式来成功设计和构建游戏。

更近距离地观察游戏

这里快速预览一下三个项目。

点击防御游戏(Tappy Defender)

用一根手指像玩《飞扬的小鸟》一样飞向你的家园星球,同时避开多个敌人。特点包括:

  • 基本动画

  • 主屏幕点击防御游戏

  • 碰撞检测

  • 高分记录

  • 简单的 HUD 界面

  • 单指触摸屏控制点击防御游戏

硬派复古平台游戏

这是一个真正难以击败的复古风格平台游戏。我们必须引导鲍勃从地下火洞穿过城市、森林,最终到达山脉。它有四个具有挑战性的关卡。特点包括:

  • 一个更先进、更灵活的游戏引擎

  • 更先进的“精灵表”角色动画

  • 一个关卡构建引擎,可以用文本格式设计你的关卡

  • 多个滚动视差背景

  • 关卡之间的过渡

  • 一个更先进的 HUD艰难的复古平台游戏

  • 添加大量多样化的额外关卡

  • 声音管理器,轻松管理音效

  • 拾取物品

  • 可升级的枪械

  • 寻找并摧毁敌方无人机

  • 为巡逻的敌人守卫编写简单的 AI 脚本

  • 像火坑这样的危险物品

  • 添加场景对象以营造氛围艰难的复古平台游戏

小行星模拟器

这是一个经典的射击游戏,具有复古的矢量图形风格视觉效果。它包括清除一系列平滑旋转的小行星,使用快速射击枪。功能包括:

  • 即使在旧硬件上也能达到每秒 60 帧或更好的效果

  • 初识 OpenGL ES 2

  • 难度逐渐增加的射击波次

  • 先进的多阶段碰撞检测小行星模拟器

设置你的开发环境

本书中的所有代码和下载包都可以在你喜欢的 Android IDE 中运行。然而,我发现最新版本的 Android Studio 特别易于使用,而且代码也是在其中编写和测试的。

如果你目前还没有使用 Android Studio,我建议你尝试一下。以下是如何快速上手的一个简要概述。本指南包括安装 Java JDK 的步骤,以防你完全不了解 Android 开发。

提示

如果你已经准备好你喜欢的开发环境,那么可以直接跳到第二章,Tappy Defender – 第一步

我们需要做的第一件事是准备你的电脑,以便使用 Java 进行 Android 开发。幸运的是,这一步对我们来说很简单。

提示

如果你是在 Mac 或 Linux 上学习,本书中的内容仍然适用。接下来的两个教程包含 Windows 特定的指令和截图。然而,稍作调整应该也不难适应 Mac 或 Linux。

我们需要做的是:

  1. 安装Java 开发工具包JDK),它允许我们用 Java 进行开发。

  2. 然后安装 Android Studio,以快速轻松地进行 Android 开发。Android Studio 使用 JDK 和一些其他特定于 Android 的工具,安装 Android Studio 时会自动安装这些工具。

安装 JDK

我们需要做的第一件事是获取 JDK 的最新版本。要完成本指南,请执行以下操作:

  1. 我们需要访问 Java 网站,所以请访问:www.oracle.com/technetwork/java/javase/downloads/index.html

  2. 找到这里显示的三个按钮,并点击标记为JDK的那个,如下图所示,它位于网页的右侧。然后,在JDK选项下点击下载按钮:安装 JDK

  3. 你将被带到有多个选项下载 JDK 的页面。在产品/文件描述列中,你需要点击与你的操作系统相匹配的选项。Windows、Mac、Linux 以及一些不太常见的选项都被列出来了。

  4. 在这里经常被问到的一个问题是,我的系统是 32 位还是 64 位的?要找出答案,请右键点击我的电脑图标(Windows 8 中为此电脑),点击属性选项,在系统标题下查看系统类型条目:安装 JDK

  5. 点击稍微隐藏的接受许可协议复选框:安装 JDK

  6. 现在,点击为你的操作系统下载并按照之前确定的类型输入。等待下载完成。

  7. 在你的下载文件夹中,双击你刚刚下载的文件。在撰写本文时,64 位 Windows 电脑的最新版本是jdk-8u5-windows-x64。如果你使用的是 Mac/Linux 或拥有 32 位操作系统,你的文件名会有相应的变化。

  8. 在一系列安装对话框中的第一个里,点击下一步按钮,你会看到以下对话框:安装 JDK

  9. 通过点击下一步接受上张图片中显示的默认设置。在下一个对话框中,你可以通过点击下一步接受默认的安装位置。

  10. 接下来是 Java 安装程序的最后一个对话框;对于这个,点击关闭

    注意

    现在 JDK 已经安装完毕。接下来,我们将确保 Android Studio 能够使用 JDK。

  11. 右键点击你的我的电脑图标(Windows 8 中为此电脑),然后点击属性 | 高级系统设置 | 环境变量... | 新建...(位于系统变量下,而不是用户变量下)。现在,你可以看到新建系统变量对话框:安装 JDK

  12. 变量名:处输入JAVA_HOME,并在变量值:字段中输入C:\Program Files\Java\jdk1.8.0_05。如果你在其他位置安装了 JDK,那么在变量值:字段中输入的文件路径需要指向你安装 JDK 的位置。你输入的确切文件路径可能有所不同,以匹配你下载时最新的 Java 版本。

  13. 点击确定以保存你的新设置。

  14. 现在,在系统变量下,点击Path,然后点击编辑...按钮。在变量值:字段文本的最后,输入以下文本以将我们的新变量添加到 Windows 将要使用的文件路径中,;JAVA_HOME。确保不要漏掉前面的分号。

  15. 点击确定以保存更新后的Path变量。

  16. 现在,再次点击确定以清除高级系统设置对话框。

现在 JDK 已经安装在我们的电脑上。

安装 Android Studio

不必拖延,让我们立即安装 Android Studio,然后我们可以开始第一个游戏项目。访问:

developer.android.com/sdk/index.html

  1. 点击标记为DOWNLOAD ANDROID STUDIO FOR WINDOWS的按钮开始下载 Android Studio。这将带您进入另一个看起来与刚才点击的按钮非常相似的网页。

  2. 通过选中复选框接受许可协议,然后点击标记为DOWNLOAD ANDROID STUDIO FOR WINDOWS的按钮开始下载,并等待下载完成。

  3. 在您刚刚下载 Android Studio 的文件夹中,右键点击android-studio-bundle-135.12465-windows.exe文件,并选择以管理员身份运行。文件名的末尾会根据您所安装的 Android Studio 版本和操作系统而有所不同。

  4. 当系统询问您是否允许来自未知发布者的以下程序对您的计算机进行更改时,请点击。在下一个屏幕上,点击下一步

  5. 在这里显示的屏幕上,您可以选择您的电脑上的哪些用户可以使用 Android Studio。选择适合您的选项,因为所有选项都可以正常工作,然后点击下一步安装 Android Studio

  6. 在下一个对话框中,保留默认设置,然后点击下一步

  7. 选择开始菜单文件夹对话框中保留默认设置,然后点击安装

  8. 在安装完成的对话框上,点击完成以首次运行 Android Studio。

  9. 下一个对话框是为已经使用过 Android Studio 的用户准备的,因此假设您是第一次使用,请选择我没有之前的 Android Studio 版本,或者我不想导入我的设置复选框。然后点击确定安装 Android Studio

这是我们需要安装的最后一个软件。在下一章中,我们将立即开始使用 Android Studio。

总结

本章故意保持尽可能简短,以便我们可以开始构建一些游戏。我们现在就开始。

第二章:Tappy Defender – 起步

欢迎来到我们将在三章内了解的第一个游戏。在本章中,我们将详细审视最终产品的目标。如果我们确切知道我们试图实现什么,那么在构建游戏时会非常有帮助。

然后,我们可以看看我们代码的结构,包括我们将遵循的近似设计模式。接着,我们将组装我们第一个游戏引擎的代码框架。最后,为了完成本章,我们将绘制游戏中的第一个真实对象,并在屏幕上为其添加动画。

然后,我们将准备好进入第三章,Tappy Defender – 翱翔,在那里我们在完成第一个游戏之前可以取得非常快的进展,并在第四章,Tappy Defender – 回家中完成它。

规划第一个游戏

在本节中,我们将详细阐述我们的游戏将会是什么样子。背景故事;谁是我们的英雄,他们试图实现什么?游戏机制;玩家实际上会做什么?他会按哪些按钮,这种方式有何挑战性或乐趣?然后,我们将看看规则。什么构成了胜利、死亡和进步?最后,我们将从技术角度出发,开始探讨我们实际上将如何构建这个游戏。

背景故事

瓦莱丽自 20 世纪 80 年代初以来一直在保卫人类的遥远前哨。她勇敢的壮举最初在 1981 年的街机经典游戏《Defender》中被永远铭记。然而,在 30 多年的前线生涯后,她即将退休,是时候开始回家的旅程了。不幸的是,在最近的一次小规模战斗中,她的飞船引擎和导航系统受到了严重损坏。因此,现在她必须仅使用她的推进器飞回家。

这意味着她必须通过同时向上和向前推进,有点像是在弹跳,同时避开试图撞击她的敌人来驾驶她的飞船。在最近与地球的通讯中,瓦莱丽表示这就像是在“尝试驾驶一只跛脚的鸟”。这是瓦莱丽在她的受损飞船中的概念艺术,因为这样有助于我们尽早可视化我们的游戏。

背景故事

现在我们已经对我们的英雄和她的困境有了一些了解,我们将更仔细地看看游戏机制。

游戏机制

机制是玩家必须掌握并熟练的关键动作,以能够通关游戏。在设计游戏时,你可以依赖经过尝试和测试的机制想法,或者你可以发明自己的。在 Tappy Defender 中,我们将使用一种机制,玩家通过轻敲并按住屏幕来推进飞船。

这个加速功能会将飞船向上提升,但同时也会让飞船加速,因此更容易受到攻击。当玩家移开手指,加速引擎会关闭,飞船会向下坠落并减速,从而使得飞船稍微不那么脆弱。因此,为了生存,需要非常精细和熟练地平衡加速和不加速。

Tappy Defender 当然深受 Flappy Bird 以及其成功后涌现的大量类似游戏的启发。

与 Flappy Bird 的“我能走多远”计分系统不同,Tappy Defender 的目标是到达“家”。然后,玩家可以多次重玩游戏,试图打破他们的最快时间。当然,为了更快,玩家必须更频繁地加速,并让 Valerie 面临更大的危险。

注意

如果你从未玩过或见过 Flappy Bird,花 5 分钟玩玩这类游戏是非常值得的。你可以从 Google Play 商店下载一个受 Flappy Bird 启发的应用程序:

在 Google Play 商店搜索 Flappy Bird

游戏规则

在这里,我们将定义一些平衡游戏并使其对玩家公平和一致的事物:

  • 玩家的飞船比敌人的飞船要坚固得多。这是因为玩家的飞船有护盾。每次玩家与敌人相撞,敌人会被立即摧毁,但玩家会失去一个护盾。玩家有三个护盾。

  • 玩家需要飞行一定的公里数才能到达家中。

  • 每次玩家到达家中,他们就赢得了游戏。如果他们用时最短,他们还会获得一个新的最快时间,就像是一个高分。

  • 敌人将在屏幕最右侧的随机高度生成,并以随机速度向玩家飞行。

玩家始终位于屏幕最左侧,但加速意味着敌人会更快地接近。

设计理念

我们将使用一个宽松的设计模式,根据控制部分、模型部分和视图来分离我们的代码。这是我们如何将代码分为三个区域的方法。

控制

这是我们的代码部分,它将控制所有其他部分。它将决定何时显示视图,它将初始化模型中的所有游戏对象,并根据数据的状况提示模型中发生的数据决策。

模型

模型是我们的游戏数据和逻辑。飞船长什么样?它们在屏幕的哪个位置?它们移动得多快等等。此外,我们代码中的模型部分是每个游戏对象的智能系统。尽管这个游戏中的敌人没有复杂的 AI,但它们会自行判断它们的速度、何时重生等。

视图

视图(View)正如其名所示,它是根据模型的状态进行实际绘制的代码部分。当控制代码告诉它时,它将进行绘制。它不会对游戏对象有任何影响。例如,视图不会决定一个对象在哪里,甚至它看起来是什么样子。它只是绘制,然后将控制权交还给控制代码。

设计模式现实检查

实际上,这种分离并不像讨论中那么清晰。实际上,绘制和控制代码在同一个类中。但是,你会发现,即使在这个类中,绘制和控制的逻辑是分开的。

通过将游戏分为这三个部分,我们可以看到如何简化开发过程,并避免在添加新功能时代码不断膨胀,变得混乱。

让我们更仔细地看看这种模式如何与我们的代码契合。

游戏代码结构

首先,我们必须考虑到我们所工作的系统。在这个案例中,它是安卓系统。如果你已经开发了一段时间的安卓应用,你可能会想知道这种模式与安卓 Activity 生命周期如何契合。如果你是安卓开发新手,你可能会问 Activity 生命周期是什么。

安卓 Activity 生命周期

安卓 Activity 生命周期是我们必须遵循的框架,以制作任何类型的安卓应用。有一个名为Activity的类,我们必须从中派生,它是我们应用的入口点。此外,我们需要知道这个类,我们的游戏是其对象,还有一些我们可以覆盖的方法。这些方法控制着应用的生命周期。

当用户启动一个应用时,我们的Activity对象将被创建,并且可以覆盖的一系列方法将按顺序被调用。以下是发生的情况。

当创建Activity对象时,将按顺序调用三个方法:onCreate()onStart()onResume()。此时,应用正在运行。此外,当用户退出应用或应用被中断,比如一个电话,将调用onPause方法。用户可能会决定,在完成电话后返回应用。如果发生这种情况,将调用onResume方法,之后应用再次运行。

如果用户没有返回应用,或者安卓系统决定需要这些系统资源做其他事情,将调用两个进一步的方法来进行清理。首先是onStop(),然后是onDestroy()。现在应用已经被销毁,任何尝试返回游戏的行为都将以 Activity 生命周期的开始为结果。

作为游戏程序员,我们必须注意这个生命周期,并遵循一些良好的家务管理规则。在接下来的过程中,我们将实施并解释这些良好的家务管理规则。

注意

安卓 Activity 生命周期比我刚才所解释的要复杂得多,也更为细致。然而,我们知道开始编程我们第一款游戏所需的一切。如果你想了解更多,请查看安卓开发者网站上的这篇文章:

developer.android.com/reference/android/app/Activity.html

一旦我们考虑了 Android Activity 生命周期,代表控制部分模式的类核心方法将会像这样非常简单:

  1. 更新我们的游戏对象的状态。

  2. 根据它们的状态绘制游戏对象。

  3. 暂停以锁定帧率。

  4. 获取玩家输入。实际上,因为第 1、2 和 3 部分在线程中发生,这部分可以在任何时候进行。

  5. 重复。

在我们真正开始构建游戏之前,还有最后一点准备工作。

Android Studio 文件结构

安卓系统非常讲究我们放置类文件的位置,包括Activity,以及我们在文件层次结构中放置如声音文件和图像等资源的位置。

这里是一个非常快速的总览,介绍我们将要放置所有内容的地方。你不需要记住这个,因为我们在添加资源时会提醒自己正确的文件夹。在最初几次需要时,我们将逐步完成活动/类创建过程。

提前告知,以下是你的 Android Studio 项目浏览器在完成 Tappy Defender 项目后看起来会是什么样子的一份注释图解。

Android Studio 文件结构

现在,我们可以真正开始构建 Tappy Defender。

构建主屏幕

既然我们已经完成了所有规划和准备工作,我们可以开始编写代码了。

注意事项

下载示例代码

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

要使用代码文件,你仍然需要创建一个 Android Studio 项目。此外,你还需要更改每个 JAVA 文件代码第一行的包名。将包名更改为与你创建的项目相匹配的包名。最后,你需要确保将任何资源(如图片或声音文件)放置到项目中的适当文件夹。每个项目所需的资源在下载包中都有提供。

创建项目

打开 Android Studio 并按照以下步骤创建一个新项目。到本章结束时,我们将要使用的所有文件都可以在下载包中的 Chapter2 文件夹找到。

  1. 欢迎来到 Android Studio对话框中,点击开始一个新的 Android Studio 项目

  2. 在接下来显示的创建新项目窗口中,我们需要输入一些关于我们应用的基本信息。这些信息将被 Android Studio 用来确定软件包名称。

    注意

    在下图中,你可以看到编辑链接,如果需要,你可以在这里自定义软件包名称。

  3. 如果你将提供的代码复制粘贴到你的项目中,那么在应用名称字段中使用C1 Tappy Defender,在公司域名字段中使用gamecodeschool.com,如下截图所示:创建项目

  4. 准备好之后,点击下一步按钮。当被问及选择应用将运行的表单因素时,我们可以接受默认设置(手机和平板电脑)。因此再次点击下一步

  5. 向移动设备添加活动对话框中,只需点击空白活动,然后点击下一步按钮。

  6. 自定义活动对话框中,我们再次可以接受默认设置,因为MainActivity看起来是我们主活动的不错名称。所以点击完成按钮。

我们的操作步骤

Android Studio 已经构建了项目并创建了许多文件,我们将在构建这个游戏的过程中看到并编辑其中大部分文件。正如前面提到的,即使你只是复制粘贴代码,也需要完成这一步,因为 Android Studio 在幕后做了很多工作,以确保我们的项目能够运行。

构建主屏幕用户界面

我们 Tappy Defender 游戏的第一部分也是最为简单的部分就是主屏幕。我们需要的只是一个整洁的画面,包含有关游戏场景、最高分和开始游戏的按钮。完成后的主屏幕大致会是这样:

构建主屏幕用户界面

当我们构建项目时,Android Studio 会打开两个文件供我们编辑。你可以在以下 Android Studio UI 设计师的标签中看到它们。这些文件(以及标签)是MainActivity.javaactivity_main.xml

构建主屏幕用户界面

MainActivity.java文件是我们游戏的入口点,我们很快会详细看到这一点。activity_main.xml文件是我们主屏幕将使用的 UI 布局。现在,我们可以继续编辑activity_main.xml文件,使其看起来像我们的主屏幕应该有的样子。

  1. 首先,你的游戏将在横屏模式下通过 Android 设备进行游戏。如果我们把 UI 预览窗口改为横屏,我们将会更准确地看到你的进度。寻找下一个图像中显示的按钮。它就在 UI 预览之前:构建主屏幕用户界面

  2. 点击前一个截图中显示的按钮,你的 UI 预览将切换到横屏,如下所示:构建主屏幕用户界面

  3. 确保通过点击其标签打开activity_main.xml

  4. 现在,我们将设置一个背景图片。你可以使用自己的图片,或者使用下载包中Chapter2/drawable/background.jpg的我的图片。将你选择的图片添加到 Android Studio 中项目的drawable文件夹中。

  5. 在 UI 设计师的属性窗口中,找到并点击background属性,如下一个图像所示:构建主屏幕 UI

  6. 此外,在上一张图片中,标记为...的按钮被圈出。它位于background属性的右侧。点击那个...按钮,浏览并选择你将使用的背景图片文件。

  7. 然后,我们需要一个TextView小部件,用来显示高分。注意,布局中已经有一个TextView小部件,显示的是Hello World。你会修改这个,将其用于我们的高分显示。左键点击它,并将TextView拖动到你想要的位置。如果你打算使用提供的背景,可以参考我的操作,或者将其放置在你背景上最佳的位置。

  8. 接下来,在属性窗口中找到并点击id属性。输入textHighScore。务必按照显示的格式输入,因为在后面的教程中编写一些 Java 代码时,我们将引用这个 ID 以便操作它,显示玩家的最快时间。

  9. 你还可以编辑text属性,使其显示为High Score: 99999或类似的文字,以便TextView看起来更合适。但这不是必须的,因为你的 Java 代码稍后会处理这个问题。

  10. 现在,我们将按照以下截图所示从窗口小部件调色板中拖动一个按钮:构建主屏幕 UI

  11. 将其拖动到背景上看起来合适的位置。如果你使用提供的背景,可以参考我的操作,或者将其放置在你背景上最佳的位置。

我们的操作

现在,我们有一个酷炫的背景,以及为你主屏幕整齐排列的小部件(一个TextView和一个Button)。接下来,我们可以通过 Java 代码为Button小部件添加功能。在第四章 Tappy Defender – Going Home中重新访问玩家的最高分数TextView。重要的是,这两个小部件都被分配了一个唯一的 ID,我们可以在你的 Java 代码中引用并操作它。

编写功能代码

现在,我们为游戏主屏幕创建了一个简单的布局。接下来,我们需要添加功能,允许玩家点击播放按钮来开始游戏。

点击MainActivity.java文件的标签页。自动为我们生成的代码并不是完全符合我们需要的。因此,我们将重新开始,因为这比调整现有的东西更简单、更快。

删除MainActivity.java文件中的全部内容,除了包名,并在其中输入以下代码。当然,你的包名可能有所不同。

package com.gamecodeschool.c1tappydefender;

import android.app.Activity;
import android.os.Bundle;

public class MainActivity extends Activity{

    // This is the entry point to our game
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        //Here we set our UI layout as the view
        setContentView(R.layout.activity_main);

    }
}

所提及的代码是我们主要的MainActivity类的当前内容,也是我们游戏的入口点,即onCreate方法。以setContentView...开头的代码行是将我们的 UI 布局从activity_main.xml加载到玩家屏幕的代码。现在我们可以运行游戏并查看主屏幕,但让我们继续取得更多进展,本章末尾我们将了解如何在实际设备上运行游戏。

现在,让我们处理主屏幕上的播放按钮。将下面高亮的两行代码添加到onCreate方法中,紧跟在setContentView()调用之后。第一行新代码创建了一个新的Button对象,并获取了 UI 布局中Button的引用。第二行是监听按钮点击的代码。

//Here we set our UI layout as the view
setContentView(R.layout.activity_main);

// Get a reference to the button in our layout
final Button buttonPlay =
 (Button)findViewById(R.id.buttonPlay);
// Listen for clicks
buttonPlay.setOnClickListener(this);

注意我们的代码中有几个错误。我们可以通过按住Alt键然后按Enter来解决这些错误。这将添加对Button类的导入指令。

我们还有一个错误。我们需要实现一个接口,以便我们的代码监听按钮点击。按照高亮显示的方式修改MainActivity类的声明:

public class MainActivity extends Activity 
 implements View.OnClickListener{

当我们实现onClickListener接口时,我们还必须实现onClick方法。这里就是处理按钮点击后发生情况的地方。我们可以在onCreate方法之后,但在MainActivity类内右键点击,导航到Generate | Implement methods | onClick(v:View):void来自动生成onClick方法,或者直接添加给定的代码。

我们还需要让 Android Studio 为Android.view.View添加另一个导入指令。再次使用Alt | Enter键盘组合。

现在,我们可以滚动到MainActivity类的底部附近,可以看到 Android Studio 已经为我们实现了一个空的onClick方法。此时你的代码中应该没有错误。以下是onClick方法:

@Override
public void onClick(View v) {
  //Our code goes here
}

由于我们只有一个Button对象和一个监听器,我们可以安全地假设主屏幕上的任何点击都是玩家点击我们的播放按钮。

Android 使用Intent类在活动之间切换。由于我们需要在点击播放按钮时进入一个新的活动,我们将创建一个新的Intent对象,并将其构造函数中传入我们未来的Activity类名,GameActivity。然后我们可以使用Intent对象来切换活动。将以下代码添加到onClick方法的主体中:

// must be the Play button.
// Create a new Intent object
Intent i = new Intent(this, GameActivity.class);
// Start our GameActivity class via the Intent
startActivity(i);
// Now shut this activity down
finish();    

我们代码中再次出现了错误,因为我们需要生成一个新的导入指令,这次是为Intent类,所以再次使用Alt | Enter键盘组合。我们代码中还有一个错误。这是因为我们的GameActivity类尚未存在。我们现在将解决这个问题。

创建 GameActivity

我们已经看到,当玩家点击播放按钮时,主活动将关闭,游戏活动将开始。因此,我们需要创建一个名为GameActivity的新活动,你的游戏实际上将在这里执行。

  1. 从主菜单导航到文件 | 新建 | 活动 | 空白活动

  2. 自定义活动对话框中,将活动名称字段更改为GameActivity

  3. 我们可以接受此对话框中的其他默认设置,所以点击完成

  4. 就像我们对MainActivity类所做的那样,我们将从这个类开始编写代码。因此,删除GameActivity.java中的所有代码内容。

我们的操作

Android Studio 为我们生成了两个新文件,并在幕后完成了一些工作,我们很快就会研究这些内容。新文件是GameActivity.javaactivity_game.xml。它们都会在 UI 设计师上方的两个新标签页中自动打开。

我们将不需要activity_game.xml,因为我们将构建一个动态生成的游戏视图,而不是静态 UI。现在可以关闭它,或者直接忽略。在编写游戏循环代码部分,我们将在本章后面真正开始编写游戏代码时回到GameActivity.java文件。

配置AndroidManifest.xml文件

我们之前提到,当我们创建新项目或新活动时,Android Studio 不仅仅是为我们创建两个文件。这就是为什么我们要以这种方式创建新项目/活动。

在幕后发生的一件事是在manifests目录中创建和修改AndroidManifest.xml文件。

我们的程序要运行需要这个文件。同时,它还需要被编辑,以便按照我们的意愿工作。Android Studio 已经为我们自动配置了基本内容,但现在我们将对这个文件进行两项额外的操作。

通过编辑AndroidManifest.xml文件,我们将强制我们的两个活动全屏运行,并且我们将它们锁定为横屏布局。让我们在这里进行这些更改:

  1. 现在打开manifests文件夹,双击AndroidManifest.xml文件,在代码编辑器中打开它。

  2. AndroidManifest.xml文件中,找到以下代码行:

    android:name=".MainActivity"
    
  3. 紧接着,输入或复制粘贴以下两行代码,使MainActivity全屏运行并锁定为横屏方向:

    android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
    android:screenOrientation="landscape"
    
  4. AndroidManifest.xml文件中,找到以下代码行:

    android:name=".GameActivity"
    
  5. 紧接着,输入或复制粘贴以下两行代码,使GameActivity全屏运行并锁定为横屏方向:

    android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
    android:screenOrientation="landscape"
    

我们的操作

现在我们已将游戏的两个活动配置为全屏。这为我们的玩家提供了更加愉悦的外观。此外,我们还取消了玩家通过旋转他们的 Android 设备影响我们游戏的能力。

编写游戏循环代码

我们说过,我们的游戏屏幕不使用 UI 布局,而是动态绘制的视图。这就是我们模式中的视图部分。让我们创建一个新类来表示我们的视图,然后我们将放入“Tappy Defender”游戏的基本构建块。

构建视图

我们将暂时不处理两个活动类,这样我们就可以看看将代表游戏视图的类。正如本章开始时所讨论的,视图和控制器方面将包含在同一个类中。

Android API 为我们提供了一个理想的类来满足我们的需求。android.view.SurfaceView类不仅为我们提供了一个专门用于绘制像素、文本、线条和精灵的视图,还使我们能够快速处理玩家输入。

就像这还不够有用一样,我们还可以通过实现可运行接口来生成一个线程,这样我们的主游戏循环可以同时获取玩家输入和其他系统要点。现在,我们将处理您新的SurfaceView实现的基本结构,随着项目的进行,我们可以填充细节。

为视图创建一个新类

没有更多延迟,我们可以创建一个扩展了SurfaceView的新类。

  1. 右键点击包含我们的.java文件的文件夹,选择新建 | Java 类,然后点击确定

  2. 创建新类对话框中,将新类命名为TDView(Tappy Defender 视图)。现在,点击确定让 Android Studio 自动生成该类。

  3. 新类将在代码编辑器中打开。修改代码,让它扩展SurfaceView并实现Runnable,如前一部分所述。编辑下面高亮显示的代码部分:

    package com.gamecodeschool.c1tappydefender;
    
    import android.view.SurfaceView;
    
    public class TDView extends SurfaceView implements Runnable{
    
    }
    
  4. 使用Alt | Enter组合键导入缺失的类。

  5. 请注意,我们的代码中仍然有一个错误。这是因为我们必须为我们的SurfaceView实现提供一个构造函数。在TDView类声明下方右键点击,导航到生成 | 构造函数 | SurfaceView(Context:context)。或者你可以像在下一块代码中显示的那样直接输入。现在点击确定

我们所做的工作

现在我们有一个名为TDView的新类,它扩展了SurfaceView以满足我们的绘图需求,并实现了Runnable以支持我们的线程需求。我们还生成了一个构造函数,我们很快会使用它来初始化我们的新类。

传递给我们的构造函数的Context参数是对当前应用状态的引用,在我们的GameActivity类中由 Android 系统保存。这个Context参数在实现我们整个项目中的许多功能时非常有用/至关重要。

到目前为止,我们的TDView类将如下所示:

package com.gamecodeschool.c1tappydefender;

import android.content.Context;
import android.view.SurfaceView;

public class TDView extends SurfaceView implements Runnable{

    public TDView(Context context) {
        super(context);
    }
}

组织类代码

既然我们已经从SurfaceView类扩展了TDView类,我们可以开始编写代码了。为了控制游戏,我们需要能够更新所有的游戏数据/对象。这意味着需要一个update方法。此外,我们显然会在每次更新后,每一帧都绘制所有的游戏数据。让我们将所有的绘图代码放在一个名为draw的方法中。而且,我们还需要控制发生的频率。因此,一个control方法似乎也应该成为类的一部分。

我们也知道所有的事情都需要在您的线程中发生;因此,为了实现这一点,我们应该将代码包裹在run方法中。最后,我们需要一种方法来控制线程应该和不应该执行工作的时间,因此我们需要一个由布尔值控制的无限循环,或许可以使用playing

将以下代码复制到我们的TDView类中,以实现我们刚才讨论的内容:

@Override
    public void run() {
        while (playing) {
            update();
            draw();
            control();
        }
    }

这是我们的游戏的基本框架。run方法将在一个线程中执行,但它只会在布尔实例playing为真时执行游戏循环。然后,它将更新所有的游戏数据,基于这些游戏数据绘制屏幕,并控制再次调用run方法的时间间隔。

现在,我们可以快速地在此基础上构建代码。首先,我们可以实现从run方法中调用的三个方法。在TDView类的run方法结束大括号之前,键入以下代码:

private void update(){

}

private void draw(){

}

private void control(){

}

我们现在需要声明我们的playing成员变量。我们可以使用volatile关键字这样做,因为它将从线程外部和内部访问。在TDView类声明后键入以下代码:

volatile boolean playing;

现在,我们知道我们可以使用无限循环和playing变量来控制run方法内的代码执行。我们也需要开始和停止实际的线程本身。不仅在我们决定时,而且当玩家意外退出游戏时。如果他接到电话或者只是在他的设备上点击了主页按钮怎么办?

为了处理这些事件,我们需要TDView类和GameActivity协同工作。现在,在TDView类中,我们可以实现一个pause方法和一个resume方法。在其中,我们放置停止和启动线程的代码。在TDView类的主体中实现这两个方法:

// Clean up our thread if the game is interrupted or the player quits
public void pause() {
        playing = false;
        try {
            gameThread.join();
        } catch (InterruptedException e) {

        }
    }

    // Make a new thread and start it
    // Execution moves to our R
    public void resume() {
           playing = true;
           gameThread = new Thread(this);
           gameThread.start();
    }

现在,我们需要一个名为gameThreadThread类实例。我们可以在类声明之后,紧接着布尔参数playing之后,将其声明为TDView的成员变量。如下所示:

volatile boolean playing;
Thread gameThread = null;

请注意,onPauseonResume方法是公开的。我们现在可以在GameActivity类中添加代码,在适当的时候调用这些方法。记住,GameActivity继承自Activity。因此,使用重写的Activity生命周期方法。

通过重写onPause方法,无论何时活动暂停,我们都可以关闭线程。这避免了可能让玩家尴尬的情况,以及向来电者解释为什么他们能听到背景中的音效。

通过重写onResume(),我们可以在应用程序实际运行之前,在 Android 生命周期的最后阶段启动我们的线程。

注意

注意区分TDView类的pauseresume方法与GameActivity类中重写的onPauseonResume方法。

游戏活动

在你实现/重写这个方法之前,请注意,它们将执行的操作只是调用它们各自方法对应的父版本,然后调用TDView类中对应的方法。

你可能还记得我们创建新的GameActivity类的那一节,我们删除了整个代码内容?考虑到这一点,以下是我们在GameActivity.java中需要的代码大纲,包括我们之前讨论的GameActivity类体内重写方法的实现。在GameActivity.java中输入以下代码:

package com.gamecodeschool.c1tappydefender;

import android.app.Activity;
import android.os.Bundle;

public class GameActivity extends Activity {

    // This is where the "Play" button from HomeActivity sends us
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

    }

    // If the Activity is paused make sure to pause our thread
    @Override
    protected void onPause() {
        super.onPause();
        gameView.pause();
    }

    // If the Activity is resumed make sure to resume our thread
    @Override
    protected void onResume() {
        super.onResume();
        gameView.resume();
    }

}

最后,让我们继续声明TDView类的一个对象。在GameActivity类声明之后立即这样做:

// Our object to handle the View
private TDView gameView;

现在,在onCreate方法中,我们需要实例化你的对象,记住在TDView.java中的构造函数需要一个Context对象作为参数。然后,我们使用新实例化的对象在调用setContentView()时使用。记得我们构建主屏幕时,我们调用了setContentView()并传入了我们的 UI 设计。这次,我们将玩家的视图设置为我们的TDView类的对象。将以下代码复制到GameActivity类的onCreate方法中:

// Create an instance of our Tappy Defender View (TDView)
// Also passing in "this" which is the Context of our app
gameView = new TDView(this);

// Make our gameView the view for the Activity
setContentView(gameView);

在这一点上,我们可以实际运行我们的游戏,并点击播放按钮进入GameView活动,它将使用TDView作为其视图并启动我们的线程。显然,现在还看不到任何东西,所以让我们着手构建我们设计模式的模型,并构建我们第一个游戏对象的基本大纲。在本章的最后,我们将看到如何在 Android 设备上运行游戏。

PlayerShip对象

我们需要尽可能将代码的模型部分与其它部分分开。我们可以通过为玩家的太空飞船创建一个类来实现这一点。让我们将我们的新类命名为PlayerShip

继续向项目中添加一个新类,并将其命名为PlayerShip。以下是几个快速步骤说明如何做到这一点。现在,右键点击包含我们的.java文件的文件夹,导航到新建 | Java 类,然后输入PlayerShip作为名称并点击确定

我们需要PlayerShip类能够了解自己的哪些信息呢?至少它需要知道:

  • 知道它在屏幕上的位置

  • 它的外观

  • 它的飞行速度

这些要求提示我们可以声明一些成员变量。在我们生成的类声明之后输入以下代码:

private Bitmap bitmap;
private int x, y;
private int speed = 0;

像往常一样,使用 Alt | Enter 键盘组合导入任何缺失的类。在之前的代码块中,我们看到我们声明了一个类型为 Bitmap 的对象,我们将用它来保存表示我们飞船的图像。

我们还声明了三个 int 类型的变量;xy 用来保存飞船的屏幕坐标,另一个 int 类型变量 speed 用来保存飞船的移动速度值。

现在,让我们考虑一下我们的 PlayerShip 类需要做什么。同样,最低限度它需要:

  • 准备自身

  • 更新自身

  • 与视图共享其状态

构造函数似乎是准备自身的好地方。我们可以初始化其 xy 坐标变量,并用 speed 变量设置一个起始速度。

构造函数还需要做另一件事,即加载表示其外观的位图图像。要加载位图,我们需要一个 Android Context 对象。这意味着我们编写的构造函数需要从视图接收一个 Context 对象。

考虑到所有这些,以下是我们的 PlayerShip 构造函数,以实现待办事项列表中的第一点:

// Constructor
public PlayerShip(Context context) {
        x = 50;
        y = 50;
        speed = 1;
        bitmap = BitmapFactory.decodeResource 
        (context.getResources(), R.drawable.ship);

    }

像往常一样,我们需要使用 Alt | Enter 组合导入一些新类。导入初始化我们的位图对象所需的全部新类后,我们可以看到我们仍然有一个错误;Cannot resolve symbol ship

让我们剖析加载飞船位图的行,因为我们在书中会经常看到这个。

BitmapFactory 类正在使用其静态方法 decodeResource() 尝试加载玩家飞船的图像。它需要两个参数。第一个是由视图传递的 Context 对象提供的 getResources 方法。

第二个参数 R.drawable.ship 是从名为 drawable 的 (R)esource 文件夹中请求一个名为 ship 的图像。要解决这个错误,我们只需将名为 ship.png 的图像文件复制到我们项目的 drawable 文件夹中。

只需将下载包中 Chapter2/drawable 文件夹内的 ship.png 图像拖放/复制粘贴到 Android Studio 项目资源管理器窗口中的 res/drawable 文件夹。以下是 ship.png 图像:

PlayerShip 对象

我们 PlayerShip 需要做的第二件事是更新自身。让我们实现一个公共 update 方法,该方法可以被 TDView 类调用。该方法将每次调用时简单地将飞船的 x 值增加 1。显然,我们需要比这更先进。现在在 PlayerShip 类中像这样实现该方法:

public void update() {
  x++;
}

待办事项列表的第三项是与视图共享其状态。我们可以通过提供一系列如下的获取器方法来实现这一点:

//Getters
public Bitmap getBitmap() {
  return bitmap;
}

public int getSpeed() {
  return speed;
}

public int getX() {
  return x;
}

public int getY() {
  return y;
}

现在,TDView类可以被实例化,了解它关于任何PlayerShip对象的喜好。然而,只有PlayerShip类本身才能决定它应该的外观,具有哪些属性以及如何表现。

我们可以看到我们如何将玩家的船只绘制到屏幕上并对其进行动画处理。

绘制场景

正如我们将要看到的,绘制位图实际上非常简单。但是,我们需要简要解释我们用来绘制图形的坐标系统。

绘图和绘制

当我们将Bitmap对象绘制到屏幕上时,我们传递我们想要绘制对象的坐标。给定 Android 设备的可用坐标取决于其屏幕的分辨率。

例如,三星 Galaxy S4 手机在横屏模式下,屏幕分辨率为 1920 像素(水平)乘 1080 像素(垂直)。

这些坐标的编号系统从左上角的 0,0 开始,向下和向右直到右下角是像素 1919, 1079。1920/1919 和 1080/1079 之间的 1 像素差异是因为编号从 0 开始。

因此,当我们绘制位图或任何其他可绘制对象到屏幕上时,我们必须指定x, y坐标。

此外,位图当然是由许多像素组成的。那么,给定位图的哪个像素会绘制在我们将要指定的x, y屏幕坐标上?

答案是Bitmap对象的左上角像素。查看下一张图片,它应该能使用三星 Galaxy S4 作为例子来阐明屏幕坐标。

绘图和绘制

目前,在任意位置绘制单一船只时,这些信息并不重要。在下一章中,当我们开始限制图形在可见屏幕上并当它们消失时重新生成时,这将变得更加重要。

所以让我们牢记这一点,继续将我们的船只绘制到屏幕上。

绘制PlayerShip

既然我们知道这些,我们可以在TDView类中添加一些代码,以便我们可以看到PlayerShip类的运行情况。首先,我们需要一个具有类作用域的新PlayerShip对象。以下是TDView类的声明代码:

//Game objects
private PlayerShip player;

我们还需要一些我们尚未见过的对象来帮助我们实际进行绘制。我们需要一个画布和一些画笔。

CanvasPaint对象

名副其实的Canvas类提供了你所期望的东西——一个虚拟画布来绘制我们的图形。

我们可以使用Canvas类创建一个虚拟画布,并将其投影到我们的SurfaceView对象上,这是GameActivity类的视图。我们实际上可以在Canvas对象上添加Bitmap对象,甚至可以使用Paint对象的方法操作单个像素。此外,我们还需要一个SurfaceHolder类的对象。这允许我们在操作Canvas对象时锁定它,并在准备好绘制帧时解锁。

我们将在接下来的内容中更详细地了解这些类是如何工作的。在输入我们刚才输入的代码行之后,立即输入以下代码:

// For drawing
private Paint paint;
private Canvas canvas;
private SurfaceHolder ourHolder;

和往常一样,我们需要使用 Alt | Enter 键盘组合导入一些新的类,用于接下来的两行代码。从这一点开始,我们将省略数字链接,并假设你知道每次添加新类时都要这样做。

接下来,我们需要设置以准备绘制。做这件事最好的地方是在 TDView() 构造函数中。输入以下代码,为我们的 PaintSurfaceHolder 对象准备行动:

// Initialize our drawing objects
ourHolder = getHolder();
paint = new Paint();

在上一行代码之后,我们可以最后调用 new() 来初始化我们的 PlayerShip 对象:

// Initialize our player ship
player = new PlayerShip(context);

现在,我们可以跳到 TDView 类的 update 方法,并进行以下操作:

// Update the player
player.update();

就这样。PlayerShip 类(模型的一部分)知道该做什么,我们可以在 PlayerShip 类中添加各种人工智能。TDView 类(控制器)只是说何时该更新。你可以很容易地想象,我们只需要创建具有不同属性和行为的各种游戏对象,并每帧调用一次它们的 update 方法。

现在,跳到 TDView 类的 draw 方法。通过执行以下操作来绘制我们的 player 对象:

  1. 检查我们的 SurfaceHolder 类是否有效。

  2. 锁定 Canvas 对象。

  3. 通过调用 drawColor() 清屏。

  4. 通过调用 drawBitmap() 并传入 PlayerShip 位图以及一个 x, y 坐标,在它上面喷上一些虚拟的油漆。

  5. 最后,解锁 Canvas 对象并绘制场景。

为了实现这些事情,在 draw 方法中输入以下代码:

if (ourHolder.getSurface().isValid()) {

  //First we lock the area of memory we will be drawing to
  canvas = ourHolder.lockCanvas();

  // Rub out the last frame
  canvas.drawColor(Color.argb(255, 0, 0, 0));

  // Draw the player
  canvas.drawBitmap(
    player.getBitmap(), 
    player.getX(), 
    player.getY(), 
    paint);

  // Unlock and draw the scene
  ourHolder.unlockCanvasAndPost(canvas);
}

在这一点上,我们实际上可以运行游戏了。如果我们的视力足够快或者我们的安卓设备足够慢,我们就能看到玩家宇宙飞船以极快的速度飞过屏幕。

在我们部署目前完成的游戏之前,还有一件事要做。

控制帧率

我们几乎看不到任何东西的原因是,尽管我们每帧只让飞船在 x 轴上移动一个像素(在 PlayerShip 类的 update 方法中),但我们的线程正在不受限制地调用 run 方法。这可能每秒发生数百次。我们需要做的是控制这个速率。

每秒六十帧(FPS)是一个合理的目标。这个目标意味着需要计时。安卓系统以毫秒(千分之一秒)为单位测量时间。因此,我们可以向 control 方法中添加以下代码:

try {
    gameThread.sleep(17);
    } catch (InterruptedException e) {

    }

在前面的代码中,我们通过调用 gameThread.sleep 方法并传入 17 作为参数,让线程暂停了 17 毫秒(1000(毫秒)/60(帧率))。我们将代码包裹在 try/catch 块中。

部署游戏

现在,我们可以运行游戏,看到我们的宇宙飞船在太空中漂浮(从 x 轴上的 50 像素和 y 轴上的 50 像素开始)。

Android Studio 使我们能够相对快速地创建模拟器,在开发 PC 上测试我们的游戏。然而,即使是最简单的游戏在模拟器上运行也不好。当我们开始测试像玩家输入这样的东西时,体验是如此糟糕,最好完全避免使用模拟器。

解决方案是在真实的 Android 设备上进行调试。为此做准备非常简单。

在 Android 设备上进行调试

首先要做的是访问您的设备制造商的网站,获取并安装所需的驱动程序,以便在您的设备和操作系统上使用。

接下来的几个步骤将设置 Android 设备以进行调试。请注意,不同的制造商在菜单选项的结构上可能会有细微的差别。以下步骤可能非常接近,如果不是完全相同的话,可以在大多数设备上启用调试。

  1. 点击设置菜单选项或设置应用。

  2. 点击开发者选项。

  3. 点击USB 调试的复选框。

  4. 将您的 Android 设备连接到开发系统的 USB 端口。下一张图片显示在 Android 标签页上。在 Android Studio 界面的底部,您可以看到已经检测到三星 GT-I9100 Android 4.1.2 (API 16)在 Android 设备上调试

  5. 点击 Android Studio 工具栏中的播放图标:在 Android 设备上调试

  6. 当提示时,点击确定以在您选择的设备上运行游戏。

游戏现在将在设备上运行。任何输出或错误都可以在logcat窗口中查看,同样在Android标签页上:

在 Android 设备上调试

目睹我们玩家的太空船缓缓从左向右移动,令人惊叹。

总结

在本章中,我们花了大量时间设置结构、游戏循环和线程。我们还花时间处理 Android Activity 的生命周期。

现在,我们已经准备好了一切,可以在下一章中轻松添加更多游戏对象,让 Tappy Defender 迅速变得像一个真正的游戏。

第三章:Tappy Defender – 飞翔之旅

我们现在准备快速添加许多新对象和一些功能。在本章结束时,我们将非常接近一个可玩的游戏。我们将检测玩家触摸屏幕,这样他就可以控制飞船。我们将在SpaceShip类中添加虚拟推进器,以使飞船上下移动并增加速度。

我们将检测安卓设备的分辨率,并利用它来执行诸如防止玩家从屏幕边缘飞出,以及检测我们的敌人何时需要重生等操作。

我们将创建一个新的EnemyShip类,它将代表自杀式的敌人。我们还将看到如何轻松生成并控制它们,而无需更改我们代码中控制部分的任何逻辑。

我们将通过添加一个SpaceDust类并生成数十个它们来添加滚动效果,使玩家看起来像是在太空中飞速穿梭。

最后,我们将了解并实现碰撞检测,以便我们知道玩家何时被敌人击中,同时也会看看一个图形技巧,以帮助我们在调试碰撞检测代码时。

控制飞船

我们让玩家的飞船在屏幕上毫无目的地漂浮,从左边缘和顶部边缘各 50 像素开始,缓缓向右漂移。现在,我们可以让玩家控制飞船。

记住,控制设计是一个单指点击并长按加速,释放后停止加速并减速。

检测触摸

我们扩展的用于视图的SurfaceView类非常适合处理屏幕触摸。

我们需要做的就是在我们TDView类中重写onTouchEvent方法。让我们先看看完整的代码,然后我们可以更仔细地检查以确保我们理解正在发生的事情。在TDView类中输入此方法,并以通常的方式导入必要的类。我已经突出了我们稍后将自定义的代码部分:

// SurfaceView allows us to handle the onTouchEvent
@Override
public boolean onTouchEvent(MotionEvent motionEvent) {

    // There are many different events in MotionEvent
    // We care about just 2 - for now.
    switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {

        // Has the player lifted their finger up?
        case MotionEvent.ACTION_UP:
 // Do something here
            break;

        // Has the player touched the screen?
        case MotionEvent.ACTION_DOWN:
 // Do something here
           break;
    }
   return true;
}

这是到目前为止onTouchEvent方法的工作方式。玩家触摸屏幕;这可以是任何一种接触。它可能是滑动,捏合,多个手指等。一条详细的信息被发送到onTouchEvent方法。

事件详细信息包含在MotionEvent类参数中,正如我们在代码中所看到的。MotionEvent类包含大量数据。它知道有多少个手指放在屏幕上,每个手指的坐标,以及是否还进行了任何手势。

由于我们实现了一个简单的点击并长按加速,释放停止加速的控制方案;我们可以通过使用motionEvent.getAction() & MotionEvent.ACTION_MASK条件简单地切换,只需处理许多可能不同情况中的两种。

MotionEvent.ACTION_UP:的情况,顾名思义,会告诉我们在玩家将手指从屏幕上移开时。然后,不出所料,MotionEvent.ACTION_DOWN:的情况会告诉我们在玩家将手指放在屏幕上时。

注意

通过MotionEvent类我们可以了解到的内容非常丰富。何不在这里看看它的全部潜力:developer.android.com/reference/android/view/MotionEvent.html。在接下来的项目中,我们也会在第五章《平台游戏——升级游戏引擎》中进一步探索这个类。

为飞船添加助推器

现在,我们需要考虑如何使用这些事件来控制飞船。首先,飞船需要知道它是否正在加速。这需要一个布尔成员变量。在PlayerShip类的类声明后立即添加以下代码:

private boolean boosting;

然后,我们需要在创建PlayerShip对象时初始化它。在PlayerShip构造函数中添加以下内容:

boosting = false;

现在,我们需要让onTouchEvent方法在boosting的真和假之间切换,以控制飞船的加速和停止加速。在PlayerShip类中添加以下方法:

public void setBoosting() {
  boosting = true;
}

public void stopBoosting() {
  boosting = false;
}

现在,我们可以从onTouchEvent方法中调用这些公共方法,以控制飞船是否正在加速的状态。在onTouchEvent方法中添加以下新代码:

// Has the player lifted there finger up?
case MotionEvent.ACTION_UP:
 player.stopBoosting();
  break;

// Has the player touched the screen?
case MotionEvent.ACTION_DOWN:
 player.setBoosting();
  break;

现在,我们的视图与模型进行了交流;我们需要做的是根据加速变量的状态让它执行不同的操作。逻辑上,这部分代码应该放在PlayerShip类的update方法中。

我们将根据飞船当前是否正在加速来改变飞船的speed变量。这看起来很简单,但仅仅基于飞船是否加速来增加速度会有一些小问题:

  • 一个问题是update方法每秒被调用 60 次。因此,不需要太多加速,飞船就会以荒谬的速度飞行。我们需要限制飞船的速度。

  • 另一个问题在于,当飞船加速时,它将向屏幕上方移动,而没有任何东西能阻止它直接飞出屏幕顶部,永远消失不见。我们需要将飞船的xy坐标限制在屏幕内。

  • 当飞船不加速且速度逐渐降为零时,是什么让飞船再次降下来?我们需要一个简单的重力物理模拟。

要解决这三个问题,我们可以在PlayerShip类中添加代码。但在我们这样做之前,先简单谈谈游戏平衡。我们很快就会看到的代码使用了不同的整数值,例如,我们将GRAVITY初始化为-12,将MAX_SPEED初始化为20。这些数字在现实中没有任何依据!

这些数值只是为了使游戏玩法保持平衡。随意调整这些任意数值,让游戏变得更容易或更难,甚至不可能完成。在第四章《Tappy Defender——回家》的最后,我们将更详细地探讨游戏迭代,并再次审视难度和平衡。

考虑到我们之前提出的三个问题,请在PlayerShip类声明后的类声明后立即添加以下成员变量:

private final int GRAVITY = -12;

// Stop ship leaving the screen
private int maxY;
private int minY;

//Limit the bounds of the ship's speed
private final int MIN_SPEED = 1;
private final int MAX_SPEED = 20;

现在,我们已经开始了解决我们三个问题的过程,我们可以在PlayerShip类的update方法中添加代码。我们将删除之前章节中放入的那行代码。那只是为了快速查看我们的飞船的行动。输入我们PlayerShip类的update方法的新代码。之后我们将更详细地查看:

public void update() {

  // Are we boosting?
  if (boosting) {
    // Speed up
    speed += 2;
  } else {
    // Slow down
    speed -= 5;
  }

  // Constrain top speed
  if (speed > MAX_SPEED) {
    speed = MAX_SPEED;
}

  // Never stop completely
  if (speed < MIN_SPEED) {
    speed = MIN_SPEED;
}

  // move the ship up or down
  y -= speed + GRAVITY;

  // But don't let ship stray off screen
  if (y < minY) {
    y = minY;
  }

  if (y > maxY) {
    y = maxY;
  }

}

从之前代码块的顶部开始,我们根据飞船是否在加速,每一帧游戏都在增加或减少速度变量,这些数值看似是任意的。

然后,我们将飞船的速度限制在最大 20 和最小 1 之间,这是我们之前添加的变量所指定的。通过y -= speed + GRAVITY这行代码,我们根据速度和重力将屏幕上的图形向上或向下移动。GRAVITYMAX_SPEED的看似任意的值很好地让玩家能够笨拙且不稳定地在太空中弹跳。

最后,我们确保飞船图形永远不会超出屏幕,也就是确保飞船图形不会超过maxYminY。你可能已经注意到,到目前为止,我们还没有初始化maxYminY。此外,由于许多 Android 设备的屏幕分辨率截然不同,我们到底要将它们初始化为多少?

我们需要做的是在运行时发现 Android 设备的分辨率,并使用这些信息来初始化MaxYminY

检测屏幕分辨率

我们知道我们需要玩家屏幕的最大y坐标。稍后,在项目中添加背景和敌方飞船时,我们会意识到我们也需要最大的x坐标。考虑到这一点,让我们看看如何获取这些信息,并将其提供给PlayerShip类。

在应用启动时检测屏幕分辨率最为方便,这发生在我们的视图和模型被实例化之前。这意味着我们的GameActivity类是进行这一操作的好地方。现在我们将在GameActivity类的onCreate方法中添加代码。在创建我们的TDView对象的new...调用之前,将以下新代码添加到onCreate类中:

// Get a Display object to access screen details
Display display = getWindowManager().getDefaultDisplay();
// Load the resolution into a Point object
Point size = new Point();
display.getSize(size);

之前的代码使用getWindowManager().getDefaultDisplay();声明并初始化了Display类型的对象。然后我们创建了一个Point类型的新对象。Point对象可以保存两个坐标,然后我们将其作为参数传递给新Display对象的getSize方法。

现在,我们已经将我们游戏运行的 Android 设备的分辨率整洁地存储在size中。现在,将这个信息传递给需要它的代码部分。首先,我们将改变我们传递给初始化我们的TDView对象的new调用的参数。按照如下所示更改new的调用,将屏幕分辨率传递给TDView构造函数:

// Create an instance of our Tappy Defender View
// Also passing in this.
// Also passing in the screen resolution to the constructor
gameView = new TDView(this, size.x, size.y);

然后,当然,我们需要更新TDView构造函数本身。在TDView.java文件中,修改TDView构造函数的签名,使得声明现在看起来像这样:

TDView(Context context, int x, int y) {

现在,在构造函数中,改变我们初始化PlayerShip对象的玩家方式:

player = new PlayerShip(context, x, y);

当然,我们现在必须修改PlayerShip类本身中的构造函数声明,如下所示:

public PlayerShip(Context context, int screenX, int screenY) {

此外,我们现在可以在PlayerShip构造函数内初始化我们的maxYminY变量。在我们看到代码之前,我们需要确切地考虑这将如何工作。

保存我们太空飞船图形的位图的坐标是在TDView类的draw方法中传递给drawBitmap()x = 0y = 0坐标处绘制的。这意味着在开始绘制飞船的坐标右侧和下方有一些像素。查看下一张图片以可视化这一点:

检测屏幕分辨率

因此,我们必须考虑到这一点,设置我们的minYmaxY值。如图所示,位图的顶部像素确实是在船只的y位置精确绘制的。这样我们可以确定minY应该是零。

然而,船的底部是在y + 位图的高度处绘制的。

我们现在可以在构造函数中添加两行代码来初始化这些变量:

maxY = screenY - bitmap.getHeight();
minY = 0;

您现在可以运行游戏并测试您的助推器了!

构建敌人

既然我们已经实现了点击控制,现在是时候添加一些玩家可以通过助推来躲避的敌人了。

这将比我们添加玩家太空飞船时要简单得多,因为我们所需的大部分内容已经就位。我们只需编写一个类来表示我们的敌人,实例化我们需要的多个敌人对象,调用它们的update方法,然后绘制它们。

我们将看到,我们敌人的update方法与PlayerShip的将大不相同。它需要处理像简单的 AI 飞向玩家等事情。它还需要处理当它离开屏幕时的重生。

设计敌人

首先,创建一个新的 Java 类,将其命名为EnemyShip。在类内部添加这些成员变量,这样你的新类将如下所示:

public class EnemyShip{
    private Bitmap bitmap;
    private int x, y;
    private int speed = 1;

    // Detect enemies leaving the screen
    private int maxX;
    private int minX;

    // Spawn enemies within screen bounds
    private int maxY;
    private int minY;
}

现在,添加一些 getter 和 setter 方法,以便draw方法可以访问它需要绘制的内容以及需要绘制的地方。这里没有新的或异常的内容:

//Getters and Setters
public Bitmap getBitmap(){
  return bitmap;
}

public int getX() {
  return x;
}

public int getY() {
  return y;
}

生成敌人

让我们完整地实现EnemyShip构造函数。现在输入代码,然后我们将更仔细地查看:

// Constructor
public EnemyShip(Context context, int screenX, int screenY){
    bitmap = BitmapFactory.decodeResource 
    (context.getResources(), R.drawable.enemy);

  maxX = screenX;
  maxY = screenY;
  minX = 0;
  minY = 0;

  Random generator = new Random();
  speed = generator.nextInt(6)+10;

  x = screenX;
  y = generator.nextInt(maxY) - bitmap.getHeight();
}

构造函数的签名与PlayerShip类完全相同。一个用于操作Bitmap对象的Context类以及保存屏幕分辨率的screenXscreenY

就像我们对PlayerShip类所做的那样,我们将图像加载到Bitmap中。当然,我们再次需要将名为enemy.png的图像文件添加到项目的drawable文件夹中。下载包的Chapter3/drawable文件夹中有一个整洁的敌人图形,或者你可以设计自己的图形。对于这个游戏来说,大约 32 x 32 到 256 x 256 之间的任何尺寸都足够了。同样,你的图形也不需要是正方形。我们会看到,我们的游戏引擎在处理不同屏幕尺寸的外观时并不完美,我们将在下一个项目中解决这个问题:

生成敌人

接下来,我们初始化maxXmaxYminXminY。尽管敌人只进行水平移动,我们需要maxYminY坐标以确保我们以一个合理的高度生成它们。maxX坐标将使我们能够将它们水平地生成在屏幕之外。

我们创建了一个类型为Random的新对象,并生成了一个在 10 到 15 之间的随机数。这是我们的敌人能够移动的最大和最小速度。这些值相对随意,我们在第四章进行游戏测试时可能会调整它们,Tappy Defender – Going Home

注意

如果你好奇generator.nextInt(6)+10;是如何生成 10 到 15 之间的数字的,这是因为6参数导致nextInt()返回一个 0 到 5 之间的数字。

然后,我们将敌人飞船的x坐标设置为屏幕,这样它就会在屏幕最左侧生成。实际上,这是在屏幕外生成的。但这没问题,因为它会逐渐进入玩家的视野,而不是一次性出现。

我们现在基于maxY生成另一个随机数——敌人飞船位图的高度(bitmap.getHeight())——为我们的敌人飞船生成一个随机但合理的y坐标。

现在我们需要做的是通过编写它们的更新方法给敌人赋予生命。

让敌人“思考”

现在,我们可以处理EnemyShip类的update方法。目前,我们只需要处理两件事。首先,让敌人向玩家端的屏幕飞行。我们需要考虑敌人的速度和玩家的速度以准确模拟这一点。我们需要这样做的原因是,当玩家加速时,他期望自己的速度会增加,物体更快地向他冲来。然而,太空船的图形是水平静止的。

我们可以同时根据敌人的静态速度和随机生成的速度以及玩家动态设定的速度(通过加速)增加敌人移动的速度,这将给玩家一种加速的感觉,尽管飞船图形从未向前移动。

另一个问题就是敌人的飞船最终会从屏幕左侧飞出。我们需要检测这种情况,并在右侧以新的随机y坐标和速度重生它。这与我们在构造函数中所做的一样。

在我们真正开始编写代码之前,先考虑一个问题。如果敌人要留意并利用玩家的速度,它需要能够获取这个速度。注意在下一个代码块中,EnemyShip类的update方法声明有一个参数用来接收玩家的速度。

当我们向TDView类的update方法中添加代码时,我们将会看到它是如何传递的。现在,为EnemyShip类的update方法输入以下代码,以实现我们刚才讨论的内容:

public void update(int playerSpeed){

  // Move to the left
  x -= playerSpeed;
  x -= speed;

  //respawn when off screen
  if(x < minX-bitmap.getWidth()){
    Random generator = new Random();
    speed = generator.nextInt(10)+10;
    x = maxX;
    y = generator.nextInt(maxY) - bitmap.getHeight();
  }
}

如你所见,我们首先将敌人的x坐标减去玩家的速度,然后减去敌人的速度。当玩家加速时,敌人会以更快的速度向玩家飞行。然而,如果玩家没有加速,那么敌人将以之前随机生成的速度攻击。

// Move to the left
x -= playerSpeed;
x -= speed;

之后,我们简单地检测敌人的位图右侧是否已经从屏幕左侧消失。这是通过检测EnemyShip类的x坐标是否在屏幕外位图的宽度处完成的。

if(x < minX-bitmap.getWidth()){

然后,我们重生同一个对象,让它再次向玩家发起攻击。这对玩家来说就像是完全新的敌人。

我们还必须做的最后三件事是声明并初始化一个来自EnemyShip的新对象。实际上,让我们创建三个。

在这里,在我们TDView.java文件中声明玩家飞船的地方,像这样声明三个敌舰:

// Game objects
private PlayerShip player;
public EnemyShip enemy1;

public EnemyShip enemy2;
public EnemyShip enemy3;

现在,在我们TDView类的构造函数中,初始化我们的三个新敌人:

// Initialize our player ship
player = new PlayerShip(context, x, y);
enemy1 = new EnemyShip(context, x, y);
enemy2 = new EnemyShip(context, x, y);
enemy3 = new EnemyShip(context, x, y);

在我们TDView类的update方法中,我们依次调用了每个新对象的update方法。在这里,我们也可以看到如何将玩家的速度传递给每个敌人,以便它们在自己的update方法中使用它来相应地调整速度。

// Update the player
player.update();
// Update the enemies
enemy1.update(player.getSpeed());
enemy2.update(player.getSpeed());
enemy3.update(player.getSpeed());

最后,在TDView类的draw方法中,我们在屏幕上绘制我们新的敌人。

// Draw the player
canvas.drawBitmap
    (player.getBitmap(), player.getX(), player.getY(), paint);

canvas.drawBitmap
 (enemy1.getBitmap(), 
 enemy1.getX(), 
 enemy1.getY(), paint);

canvas.drawBitmap
 (enemy2.getBitmap(), 
 enemy2.getX(), 
 enemy2.getY(), paint);

canvas.drawBitmap
 (enemy3.getBitmap(), 
 enemy3.getX(), 
 enemy3.getY(), paint);

你现在可以运行游戏并尝试一下这个功能。

第一个也是最明显的问题是玩家和敌人会直接穿过对方。我们将在本章的碰撞检测——相互碰撞的部分解决这个问题。但现在,我们可以通过绘制星形/星际尘埃场作为背景来增强玩家的沉浸感。

飞行的刺激——滚动背景

实现我们的星际尘埃将会非常快和简单。我们要做的是创建一个具有与其他游戏对象非常相似属性的SpaceDust类。在随机位置生成它们,以随机速度向玩家移动,并在屏幕最右侧重生它们,再次赋予它们随机的速度和y坐标。

然后在我们的TDView类中,我们可以声明一个这些对象的整个数组,每一帧更新并绘制它们。

创建一个新类,并将其命名为SpaceDust。现在输入此代码:

public class SpaceDust {

    private int x, y;
    private int speed;

    // Detect dust leaving the screen
    private int maxX;
    private int maxY;
    private int minX;
    private int minY;

    // Constructor
    public SpaceDust(int screenX, int screenY){

        maxX = screenX;
        maxY = screenY;
        minX = 0;
        minY = 0;

        // Set a speed between  0 and 9
        Random generator = new Random();
        speed = generator.nextInt(10);

        //  Set the starting coordinates
        x = generator.nextInt(maxX);
        y = generator.nextInt(maxY);
    }

    public void update(int playerSpeed){
        // Speed up when the player does
        x -= playerSpeed;
        x -= speed;

        //respawn space dust
        if(x < 0){
            x = maxX;
            Random generator = new Random();
            y = generator.nextInt(maxY);
            speed = generator.nextInt(15);
        }
    }

    // Getters and Setters
    public int getX() {

        return x;
    }

    public int getY() {

        return y;
    }
}

这是SpaceDust类中发生的事情。在上一代码块的顶部,我们声明了通常的速度和最大/最小变量。它们将使我们能够检测到SpaceDust对象离开屏幕左侧并需要在右侧重新生成时,并为重新生成对象的高度提供合理的边界。

然后在SpaceDust构造函数中,我们用随机值初始化speedxy变量,但要在我们刚刚初始化的最大和最小变量设定的范围内。

然后我们实现了SpaceDust类的update方法,它根据对象和玩家的速度将对象向左移动,然后检查对象是否已经飞出屏幕左侧边缘并在必要时使用随机但适当的值重新生成它。

在底部,我们提供了两个 getter 方法,以便我们的draw方法知道在哪里绘制每一粒尘埃。

现在,我们可以创建一个ArrayList对象来保存所有的SpaceDust对象。在TDView类顶部声明其他游戏对象的地方下面声明它:

// Make some random space dust
public ArrayList<SpaceDust> dustList = new
  ArrayList<SpaceDust>();

TDView构造函数中,我们可以使用for循环初始化一堆SpaceDust对象,然后将它们放入ArrayList对象中:

int numSpecs = 40;

for (int i = 0; i < numSpecs; i++) {
  // Where will the dust spawn?
  SpaceDust spec = new SpaceDust(x, y);
  dustList.add(spec);
}

我们总共创建了四十粒尘埃。每次通过循环,我们创建一粒新的尘埃,SpaceDust构造函数为其分配一个随机位置和一个随机速度。然后,我们使用dustList.add(spec);SpaceDust对象放入我们的ArrayList对象中。

接下来,我们跳转到TDView类的update方法,并使用增强的for循环来调用每个SpaceDust对象的update()

for (SpaceDust sd : dustList) {
  sd.update(player.getSpeed());
}

请记住,我们传入了玩家速度,以便尘埃相对于玩家的速度增加和减少其速度。

现在要绘制所有的空间尘埃,我们遍历ArrayList对象一次绘制一粒尘埃。当然,我们将代码添加到我们的TDView类的draw方法中,但我们必须确保首先绘制空间尘埃,使其出现在其他游戏对象后面。此外,我们在使用我们的Canvas对象的drawPoint方法为每个SpaceDust对象绘制单个像素之前,添加了一行额外的代码以切换像素颜色为白色。

TDView类的draw方法中,添加此代码来绘制我们的尘埃:

// White specs of dust
paint.setColor(Color.argb(255, 255, 255, 255));

//Draw the dust from our arrayList
for (SpaceDust sd : dustList) {
      canvas.drawPoint(sd.getX(), sd.getY(), paint);

    // Draw the player
    // ...
}

这里的唯一新事物是canvas.drawpoint...这行代码。除了向屏幕绘制位图,Canvas类还允许我们绘制诸如点、线这样的基本图形,以及文本和形状等。在第四章,Tappy Defender – Going Home中绘制游戏 HUD 时,我们将使用这些功能。

何不运行这个应用程序,看看我们已经实现了多少整洁的功能?在这张截图中,我临时将SpaceDust对象的数量增加到200,仅供娱乐。你还可以看到我们已经绘制了敌人,它们在随机的y坐标以随机速度攻击:

飞行的刺激——滚动背景

碰撞检测那些事

碰撞检测是一个相当广泛的主题。在本书的三个项目中,我们将使用各种不同的方法来检测物体何时发生碰撞。

所以,这里快速了解一下我们进行碰撞检测的选择,以及不同方法在哪些情况下可能适用。

本质上,我们只需要知道游戏中某些物体何时接触到其他物体。然后,我们可以通过爆炸、减少护盾、播放声音等方式对此事件做出反应,或者采取任何适当的措施。我们需要广泛了解不同的选择,这样我们才能在任何特定游戏中做出正确的决定。

碰撞检测选项

首先,这里有一些不同的数学计算方法我们可以利用,以及它们可能在什么情况下有用。

矩形相交

这种碰撞检测方法非常直观。我们围绕想要检测碰撞的物体画一个假想的矩形,我们可以称之为命中框或边界矩形。然后,检测它们是否相交。如果相交,那么就发生了碰撞:

矩形相交

命中框相交的地方,我们称之为碰撞。从前面的图片可以看出,这种方法远非完美。然而,在某些情况下,它已经足够了。要实现这个方法,我们只需要使用两个物体的xy坐标来检测它们是否相交。

不要使用下面的代码。它仅用于演示目的。

if(ship.getHitbox().right > enemy.getHitbox().left  
    && ship.getHitbox().left < enemy.getHitbox().right ){
    // Ship is intersecting enemy on x axis
    //But they could be at different heights

    if(ship.getHitbox().top < enemy.getHitbox().bottom  
        && ship.getHitbox().bottom > enemy.getHitbox().top ){
        // Ship is intersecting enemy on y axis as well
        // Crash
    }
}

上述代码假设我们有一个getHitbox方法,它返回给定物体的左右x坐标以及上下y坐标。在上述代码中,我们首先检查x轴是否重叠。如果没有,那么就没有继续的必要了。如果它们在x轴上重叠,那么检查y轴。如果它们在y轴上也没有重叠,那么可能是敌人从上方或下方飞过。如果它们在y轴上也重叠,那么我们就有了碰撞。

请注意,我们可以先检查x轴或y轴,只要两个轴都检查了即可。

半径重叠

这个方法同样用于检测两个命中框是否相互相交,但正如标题所示,它使用圆形而非矩形。这有明显的优缺点。主要是这种方法对于更接近圆形的形状效果很好,对于细长形状则效果不佳。

半径重叠

从前面的图片中,我们可以很容易地看出半径重叠方法对于这些特定物体是如何不精确的,也不难想象对于一个圆形物体比如球来说,它将是完美的。

这里是我们如何实施这种方法。

注意

下面的代码仅用于演示目的。

// Get the distance of the two objects from 
// the edges of the circles on the x axis
distanceX = (ship.getHitBox.centerX + ship.getHitBox.radius) - 
  (enemy.getHitBox.centerX + enemy.getHitBox.radius;

// Get the distance of the two objects from 
// the edges of the circles on the y axis
distanceY = (ship.getHitBox.centerY + ship.getHitBox.radius) -  
  (enemy.getHitBox.centerY + enemy.getHitBox.radius;

// Calculate the distance between the center of each circle
double distance = Math.sqrt
    (distanceX * distanceX + distanceY * distanceY);

// Finally see if the two circles overlap
if (distance < ship.getHitBox.radius + enemy.getHitBox.radius) {
    // bump
}

代码再次做出了一些假设。比如我们有一个 getHitBox 方法,它可以返回半径以及中心的 xy 坐标。此外,因为静态的 Math.sqrt 方法接收并返回一个 double 类型的变量,我们将需要在 SpaceShipEnemyShip 类中开始使用不同的类型。

注意

如果我们初始化距离的方式:Math.sqrt(distanceX * distanceX + distanceY * distanceY); 让人有些迷惑,它实际上只是使用了勾股定理来获取一个直角三角形的斜边长度,这个长度等于两个圆心之间直线距离的长度。在我们解决方案的最后一步,我们测试 distance < ship.getHitBox.radius + enemy.getHitBox.radius,这样我们可以确定一定发生了碰撞。这是因为如果两个圆的中心点比它们的半径之和还要近,那么它们一定发生了重叠。

交叉数算法

这种方法在数学上更为复杂。然而,正如我们将在第三个也是最后一个项目中看到的,它非常适合检测一个点是否与凸多边形相交:

交叉数算法

这非常适合制作一个《小行星》克隆游戏,我们将在最终项目中进一步探索这种方法,并看到它的实际应用。

优化

正如我们所见,不同的碰撞检测方法至少可以根据你在哪种情况下使用哪种方法而有至少两个问题。这些问题是缺乏精确度和对 CPU 周期的消耗。

多个碰撞箱

第一个问题,精确度不足,可以通过每个对象具有多个碰撞箱来解决。

我们只需向游戏对象添加所需数量的碰撞箱,以最有效的方式包装它,然后依次对每个执行相同的矩形相交代码。

邻居检查

这种方法允许我们只检查那些彼此在近似相同区域内的对象。这可以通过检查我们的游戏中的给定两个对象在哪个邻域内,并且只有在有可能发生碰撞的情况下,才执行更耗 CPU 的碰撞检测来实现。

假设我们有 10 个对象,每个对象都需要与其他对象进行检查,那么我们需要执行 10 的平方(100)次碰撞检查。如果我们首先进行邻居检查,我们可以显著减少这个数字。在图表中非常假设的情况下,如果我们首先检查对象是否共享同一个区域,那么对于 10 个对象,我们最多只需要执行 11 次碰撞检查,而不是 100 次。

邻居检查

在代码中实现这一点可以很简单,即为每个游戏对象提供一个区域成员变量,然后遍历对象列表,仅检查它们是否在同一个区域。

注意

在我们的三个游戏项目中,我们将使用所有这些选项和优化。

适用于 Tappy Defender 的最佳选项

既然我们已经了解了碰撞检测的选项,我们可以决定在我们当前游戏中采取的最佳行动。我们所有的飞船都近似于矩形(或正方形),它们上面很少有或没有突出部分,而且我们只有一个真正关心与其他物体发生碰撞的对象。

这往往建议我们可以为玩家和敌人使用单一的矩形碰撞箱,并执行纯角对齐的全局碰撞检测。如果你对我们选择简单的方法感到失望,那么你将会很高兴听到在接下来的两个项目中,我们将要研究所有更高级的技术。

为了让生活更加便捷,Android API 有一个方便的Rect类,它不仅可以表示我们的碰撞箱,而且还有一个整洁的intersects方法,基本上与矩形相交碰撞检测相同。让我们考虑如何为我们的游戏添加碰撞检测。

首先,我们的所有敌人和玩家飞船都需要一个碰撞箱。添加这段代码来声明一个名为hitbox的新Rect成员。在PlayerShipEnemyShip类中都这样做:

// A hit box for collision detection
private Rect hitBox;

提示

重要!

确保为EnemyShip类和PlayerShip类都完成上一步和接下来的三个代码块。我每次都会提醒你,但觉得还是提前提一下比较好。

现在,我们需要向PlayerShip类和EnemyShip类添加一个获取器方法。将此代码添加到两个类中:

public Rect getHitbox(){
  return hitBox;
}

接下来,我们需要确保在两个构造函数中都初始化我们的碰撞箱。确保在构造函数的最后输入代码:

// Initialize the hit box
hitBox = new Rect(x, y, bitmap.getWidth(), bitmap.getHeight());

现在,我们需要确保碰撞箱与我们的敌人和玩家的坐标保持最新。做这件事最好的地方是敌舰/玩家飞船的update方法。下一代码块将使用飞船的当前坐标更新碰撞箱。确保将此代码块添加到update()方法的最后,以便在update方法进行调整后,用坐标更新碰撞箱。同样,也要将其添加到PlayerShipEnemyShip中:

// Refresh hit box location
hitBox.left = x;
hitBox.top = y;
hitBox.right = x + bitmap.getWidth();
hitBox.bottom = y + bitmap.getHeight();

我们的碰撞箱具有代表位图外框的坐标。这种情况几乎完美,除了边缘周围的透明部分。

现在,我们可以从TDView类的update方法中使用我们的碰撞箱来检测碰撞。但首先,我们需要决定碰撞发生时我们打算做什么。

我们需要参考我们游戏的规则。我们在第二章,Tappy Defender – First Step的开头讨论过它们。我们知道玩家有三个护盾,但一个敌方飞船在一次撞击后就会爆炸。将护盾等内容留到章节的后面部分是有道理的,但我们需要某种方式来查看我们的碰撞检测的实际效果并确保它正常工作。

在这个阶段,最简单的确认碰撞的方法可能是让敌方飞船消失并像正常情况一样重生,就像它是一艘全新的敌方飞船一样。我们已经为此建立了一个机制。我们知道,当敌方飞船从屏幕左侧移出时,它会在右侧重生,就像是一艘新的敌方飞船。我们需要做的就是立即将敌方飞船传送到屏幕左侧外的位置,EnemyShip类会完成其余的工作。

我们需要能够改变EnemyShip对象的x坐标。让我们为EnemyShip类添加一个 setter 方法,这样我们就可以操纵所有敌方太空飞船的x坐标。如下所示:

// This is used by the TDView update() method to
// Make an enemy out of bounds and force a re-spawn
public void setX(int x) {
  this.x = x;
}

现在,我们可以进行碰撞检测并在检测到碰撞时做出响应。下面这段代码使用了静态方法Rect.intersects(),通过比较玩家飞船的碰撞箱与每个敌方碰撞箱,来检测是否发生碰撞。如果检测到碰撞,适当的敌方飞船将被移出屏幕,准备在下一帧由其自己的update方法重生。将这段代码放在TDView类的update方法的最顶部:

// Collision detection on new positions
// Before move because we are testing last frames
// position which has just been drawn

// If you are using images in excess of 100 pixels
// wide then increase the -100 value accordingly
if(Rect.intersects
  (player.getHitbox(), enemy1.getHitbox())){
    enemy1.setX(-100);
}

if(Rect.intersects
  (player.getHitbox(), enemy2.getHitbox())){
    enemy2.setX(-100);
}

if(Rect.intersects
  (player.getHitbox(), enemy3.getHitbox())){
    enemy3.setX(-100);
}

这样就完成了,我们的碰撞现在可以工作了。能够真正看到发生的情况可能会更好。为了调试的目的,让我们在所有太空飞船周围画一个矩形,这样我们就可以看到碰撞箱了。我们将使用Paint类的drawRect方法,并将我们的碰撞箱的属性作为参数传递,以定义要绘制的区域。如您所料,这段代码应该放在draw方法中。请注意,它应该在绘制我们飞船的代码之前,这样矩形就在它们后面绘制了,但在我们清除屏幕的代码之后,如高亮代码所示:

// Rub out the last frame
canvas.drawColor(Color.argb(255, 0, 0, 0));

// For debugging
// Switch to white pixels
paint.setColor(Color.argb(255, 255, 255, 255));

// Draw Hit boxes
canvas.drawRect(player.getHitbox().left, 
 player.getHitbox().top, 
 player.getHitbox().right, 
 player.getHitbox().bottom, 
 paint);

canvas.drawRect(enemy1.getHitbox().left, 
 enemy1.getHitbox().top, 
 enemy1.getHitbox().right, 
 enemy1.getHitbox().bottom, 
 paint);

canvas.drawRect(enemy2.getHitbox().left, 
 enemy2.getHitbox().top, 
 enemy2.getHitbox().right, 
 enemy2.getHitbox().bottom, 
 paint);

canvas.drawRect(enemy3.getHitbox().left, 
 enemy3.getHitbox().top, 
 enemy3.getHitbox().right, 
 enemy3.getHitbox().bottom, 
 paint);

我们现在可以运行 Tappy Defender,开启调试模式的碰撞箱,查看游戏运行的实际效果:

Tappy Defender 的最佳选项

当我们用完调试代码后,可以注释掉这段代码,如果以后需要,再取消注释。

总结

我们现在已经拥有了完成游戏所需的所有游戏对象。它们都在我们设计模式的模型部分内部进行思考和自我表示。此外,我们的玩家终于可以控制他的太空飞船了,我们也能检测到他是否发生碰撞。

在下一章中,我们将为我们的游戏添加最后的润色,包括添加一个 HUD(抬头显示),实现游戏规则,增加一些额外的功能,并通过测试游戏来使一切保持平衡。

第四章:Tappy Defender – 回家之路

我们即将完成我们的第一款游戏。在本章中,我们将绘制一个 HUD 来显示玩家游戏内的信息,并实现游戏规则,以便玩家可以赢得胜利、失败,并获得最快时间。

之后,我们将制作一个暂停屏幕,以便玩家在赢得或输掉比赛后可以欣赏他们的成就(或不是)。

在本章中,我们还将生成自己的声音效果,并将它们添加到游戏中。接下来,我们将允许玩家保存他们的最快时间,最后,我们将添加一系列小改进,包括根据玩家的 Android 设备屏幕分辨率进行一些难度平衡调整。

显示 HUD

我们需要开始使我们的游戏更加完善。游戏有得分,或者在我们的情况下是时间,还有其他规则。为了让玩家跟踪他们的进度,我们需要显示游戏的统计数据。

在这里,我们将快速设置一个 HUD,它将显示玩家在躲避敌人时需要知道的所有信息。我们还将声明并初始化为 HUD 提供数据的所需变量。在下一节实现规则中,我们可以开始操纵诸如护盾、时间、最快时间等变量。

我们可以从为TDView类添加一些成员变量开始。我们使用浮点值作为distanceRemaining变量,因为我们将使用伪公里和公里分数来表示英雄到达她的家园星球前剩余的距离。对于timeTakentimeStartedfastestTime变量,我们将使用长整型,因为时间以毫秒表示,数值会变得非常大。在TDView类声明后添加以下代码:

private float distanceRemaining;
private long timeTaken;
private long timeStarted;
private long fastestTime;

目前,我们将这些变量保留为其默认值,并专注于在 HUD 中显示它们。在下一节实现规则中,我们将使它们变得有用和有意义。

现在,我们可以继续绘制我们的 HUD,以显示玩家在游戏过程中可能想要知道的所有数据。像往常一样,我们将使用多功能Paint类对象paint来完成大部分工作。这次,我们使用drawText方法向屏幕添加文本,setTextAlign方法来对齐文本,以及setTextSize来缩放文本的大小。

我们现在可以将这段代码添加到TDView类的draw方法中。将其作为最后要绘制的内容,就在调用unlockCanvasAndPost()之前,如高亮代码所示:

// Draw the hud
paint.setTextAlign(Paint.Align.LEFT);
paint.setColor(Color.argb(255, 255, 255, 255));
paint.setTextSize(25);
canvas.drawText("Fastest:"+ fastestTime + "s", 10, 20, paint);
canvas.drawText("Time:" + timeTaken + "s", screenX / 2, 20, paint);
canvas.drawText("Distance:" + 
 distanceRemaining / 1000 + 
 " KM", screenX / 3, screenY - 20, paint);

canvas.drawText("Shield:" + 
 player.getShieldStrength(), 10, screenY - 20, paint);

canvas.drawText("Speed:" + 
 player.getSpeed() * 60 + 
 " MPS", (screenX /3 ) * 2, screenY - 20, paint);

// Unlock and draw the scene
ourHolder.unlockCanvasAndPost(canvas);

输入这段代码后,我们遇到了一些错误,可能还有一些疑问。

首先,我们将处理这些问题。在下一节实现规则中,我们将更详细地了解我们对fastestTimetimeTakendistanceRemaining以及getSpeed返回值的操作。简单来说,它们是表示距离和时间的量,旨在让玩家了解自己的表现如何。它们并不是真实的距离模拟,尽管时间是一致的。

我们将处理的第一 个错误是由于调用一个不存在的方法player.getShieldStrength引起的。在PlayerShip类中添加一个成员变量shieldStrength

private int shieldStrength;

PlayerShip构造函数中将其初始化为2

 shieldStrength = 2;

PlayerShip类中实现你缺失的 getter 方法:

public int getShieldStrength() {
  return shieldStrength;
}

最后的错误是由于未声明的变量screenXscreenY引起的。现在显然我们需要在这部分代码中获取屏幕分辨率。处理这个问题的最快方式是声明两个名为screenXscreenY的新类变量。现在就在TDView类声明之后声明它们:

private int screenX;
private int screenY;

如我们所见,知道屏幕坐标在许多地方都很有用,所以这样做是有意义的。

现在,在TDView构造函数中,使用GameActivity类传递进来的分辨率初始化screenXscreenY。在构造函数开始时进行如下操作:

screenX = x;
screenY = y;

我们现在可以运行游戏并查看我们的 HUD。我们 HUD 中唯一具有有意义数据的部分是ShieldSpeed标签。速度是 MPS(每秒米数)的伪测量值。当然,这并不反映现实,但相对于呼啸而过的星星、逼近的敌人,以及玩家目标距离的减少,它是有相对性的:

显示 HUD

实现规则

现在,我们应该暂停并思考后期项目中我们需要做什么,因为这会影响我们实现规则时的操作。当玩家的飞船被摧毁或玩家达到目标时,游戏将结束。这意味着游戏需要重新开始。我们不想每次都退回到主屏幕,所以我们需要一种方法从TDView类内部重新开始游戏。

为了实现这一点,我们将在TDView类中实现一个startGame方法。构造函数将能够调用它,我们的游戏循环在必要时也能调用它。

还需要将构造函数当前执行的一些任务传递给新的startGame方法,以便它能正确地完成其工作。此外,我们还将使用startGame初始化游戏规则和 HUD 所需的一些变量。

为了完成我们讨论的内容,startGame()需要应用程序Context对象的副本。所以,就像我们对startXstartY所做的那样,我们现在将context作为TDView的成员。在TDView类声明之后进行声明:

private Context context;

在构造函数中,在调用super()之后立即进行初始化,如下所示:

super(context);
this.context  = context;

我们现在可以实现新的startGame方法。大部分代码只是从构造函数中移动过来的。注意一些微妙但重要的区别,比如使用类的版本screenXscreenY来代替构造函数参数xy。同时,我们初始化distanceRemainingtimeTakentimeStarted

private void startGame(){
    //Initialize game objects
        player = new PlayerShip(context, screenX, screenY);
        enemy1 = new EnemyShip(context, screenX, screenY);
        enemy2 = new EnemyShip(context, screenX, screenY);
        enemy3 = new EnemyShip(context, screenX, screenY);

        int numSpecs = 40;
        for (int i = 0; i < numSpecs; i++) {
            // Where will the dust spawn?
            SpaceDust spec = new SpaceDust(screenX, screenY);
            dustList.add(spec);
        }

        // Reset time and distance
        distanceRemaining = 10000;// 10 km
        timeTaken = 0;

        // Get start time
        timeStarted = System.currentTimeMillis();
}

注意

你是否在疑惑timeStarted初始化的部分是怎么回事?我们使用了System类的方法currentTimeMillis来初始化startTime,现在startTime保存的是自 1970 年 1 月 1 日以来的毫秒数。我们将在接下来的结束游戏部分看到如何使用这个值。System类有很多用途,这里我们用它来获取自 1970 年 1 月 1 日以来的毫秒数。这是计算机中测量时间的常见系统,称为 Unix 时间。1970 年 1 月 1 日第一个毫秒之前的那一刻被称为 Unix 纪元。

现在,注释掉或删除TDView构造函数中不再需要的代码,但要在相应位置添加对startGame()的调用:

// Initialize our player ship
//player = new PlayerShip(context, x, y);
//enemy1 = new EnemyShip(context, x, y);
//enemy2 = new EnemyShip(context, x, y);
//enemy3 = new EnemyShip(context, x, y);

//int numSpecs = 40;

//for (int i = 0; i < numSpecs; i++) {
      // Where will the dust spawn?
      //SpaceDust spec = new SpaceDust(x, y);
      //dustList.add(spec);
//}

startGame();

接下来,我们想要创建一个方法来减少PlayerShip的护盾强度。这样,当我们检测到碰撞时,可以每次减少一点。在PlayerShip类中添加这个简单的方法:

public void reduceShieldStrength(){
  shieldStrength --;
}

现在,我们可以跳到TDView类的update方法,并添加代码进一步实现我们的游戏规则。我们将在进行所有碰撞检测之前添加一个布尔变量hitDetected。在每个检测到击中的if块内部,我们可以将hitDetected设置为true

然后,在所有碰撞检测代码之后,我们可以检查是否检测到击中,并相应地减少玩家的护盾强度。以下是TDView类的update方法顶部部分,新的代码行已高亮显示:

// Collision detection on new positions
// Before move because we are testing last frames
// position which has just been drawn
boolean hitDetected = false;
if(Rect.intersects(player.getHitbox(), enemy1.getHitbox())){
 hitDetected = true;
    enemy1.setX(-100);
}

if(Rect.intersects(player.getHitbox(), enemy2.getHitbox())){
 hitDetected = true;
    enemy2.setX(-100);
}

if(Rect.intersects(player.getHitbox(), enemy3.getHitbox())){
 hitDetected = true;
    enemy3.setX(-100);
}

if(hitDetected) {
 player.reduceShieldStrength();
 if (player.getShieldStrength() < 0) {
 //game over so do something
 }
}

注意在调用player.reduceShieldStrength之后的嵌套 if 语句。这会检测玩家是否已经失去了所有护盾并失败。我们很快就会处理这里会发生的情况。

我们非常接近完成游戏规则了。我们只需要根据玩家的速度减少distanceRemaining。这样我们才知道玩家何时成功。我们还需要更新timeTaken变量,以便每次调用我们的绘图方法时更新 HUD。这可能看起来不重要,但如果我们稍微考虑一下未来,我们可以预见到游戏结束的时候,无论是玩家失败还是玩家获胜。让我们谈谈游戏的结束。

结束游戏

如果游戏没有结束,那么游戏正在进行中,如果玩家刚刚死亡或获胜,那么游戏已经结束。我们需要知道游戏何时结束,何时在进行中。让我们在TDView类声明之后添加一个新的成员变量gameEnded并声明它。

private boolean gameEnded;

现在,我们可以在startGame方法中初始化gameEnded。将这行代码作为该方法中的最后一行输入。

gameEnded = false;

现在,我们可以完成游戏规则逻辑的最后几行,但需要用测试来包裹它们,以查看游戏是否已经结束。在 TDView 类的 update 方法最后添加以下代码,有条件地更新我们的游戏规则逻辑:

if(!gameEnded) {
            //subtract distance to home planet based on current speed
            distanceRemaining -= player.getSpeed();

            //How long has the player been flying
            timeTaken = System.currentTimeMillis() - timeStarted;
}

我们的 HUD 现在将具有准确的数据,让玩家了解他们到底做得如何。我们还可以检测玩家是否回到家并获胜,因为 distanceRemaining 将通过零。此外,当剩余距离小于零时,我们可以测试 timeTaken 是否小于 fastestTime,如果是,则更新 fastestTime。我们还可以将 gameEnded 设置为 true。在 TDView 类的 update 方法的最后一块代码后直接添加以下代码:

//Completed the game!
if(distanceRemaining < 0){
  //check for new fastest time
  if(timeTaken < fastestTime) {
    fastestTime = timeTaken;
  }

  // avoid ugly negative numbers
  // in the HUD
  distanceRemaining = 0;

  // Now end the game
  gameEnded = true;
}

当玩家获胜时我们结束了游戏;现在,添加这行代码,当玩家失去所有护盾时结束游戏。在 TDView 类的 update 方法中更新此代码。新的一行代码已高亮:

if(hitDetected) {
  player.reduceShieldStrength();
  if (player.getShieldStrength() < 0) {
 gameEnded = true;
 }
}

现在,我们只需要在 gameEnded 设置为 true 时实际执行一些操作。

一种方法是,根据 gameEnded 布尔值是真是假来交替绘制 HUD。在 draw 方法中找到 HUD 绘制代码,再次展示在这里以便于参考:

// Draw the HUD
paint.setTextAlign(Paint.Align.LEFT);
paint.setColor(Color.argb(255, 255, 255, 255));
paint.setTextSize(25);
canvas.drawText("Fastest:"+ fastestTime + "s", 10, 20, paint);
canvas.drawText("Time:" + timeTaken + "s", screenX / 2, 20, paint);

canvas.drawText("Distance:" + 
  distanceRemaining / 1000 + 
  " KM", screenX / 3, screenY - 20, paint);

canvas.drawText("Shield:" + 
  player.getShieldStrength(), 10, screenY - 20, paint);

canvas.drawText("Speed:" + 
  player.getSpeed() * 60 +
  " MPS", (screenX /3 ) * 2, screenY - 20, paint);

我们希望将那段代码包裹在一个 if-else 块中。如果游戏没有结束,就绘制正常的 HUD,否则绘制一个替代的。像这样包裹 HUD 绘制代码:

if(!gameEnded){
  // Draw the hud
  paint.setTextAlign(Paint.Align.LEFT);
  paint.setColor(Color.argb(255, 255, 255, 255));
  paint.setTextSize(25);
  canvas.drawText("Fastest:"+ fastestTime + "s", 10, 20, paint);

  canvas.drawText("Time:" + 
    timeTaken + 
    "s", screenX / 2, 20,   paint);

  canvas.drawText("Distance:" + 
    distanceRemaining / 1000 + 
    " KM", screenX / 3, screenY - 20, paint);

  canvas.drawText("Shield:" + 
    player.getShieldStrength(), 10, screenY - 20, paint);

  canvas.drawText("Speed:" + 
    player.getSpeed() * 60 +
    " MPS", (screenX /3 ) * 2, screenY - 20, paint);

}else{
 //this happens when the game is ended
}

现在,让我们处理 else 块,当游戏结束时将执行这部分。我们将绘制一个大的游戏结束,并显示 HUD 中的结束游戏统计信息。线程继续运行,但 HUD 停止更新。在 else 块中输入以下代码:

// Show pause screen
paint.setTextSize(80);
paint.setTextAlign(Paint.Align.CENTER);
canvas.drawText("Game Over", screenX/2, 100, paint);
paint.setTextSize(25);
canvas.drawText("Fastest:"+ 
  fastestTime + "s", screenX/2, 160, paint);

canvas.drawText("Time:" + timeTaken + 
  "s", screenX / 2, 200, paint);

canvas.drawText("Distance remaining:" + 
  distanceRemaining/1000 + " KM",screenX/2, 240, paint);

paint.setTextSize(80);
canvas.drawText("Tap to replay!", screenX/2, 350, paint);

注意我们使用 setTextSize() 切换文本大小,并使用 setTextAlign() 将所有文本对准屏幕中心。这就是运行游戏时的样子。我们只需要在游戏结束后找到一种重新开始游戏的方法:

结束游戏

重新开始游戏

为了让玩家在游戏结束后可以重新开始,我们只需要监听触摸事件并调用 startGame()。让我们编辑我们的 onTouchListener() 代码以实现这一点。我们感兴趣的是修改 MotionEvent.ACTION_DOWN: 的情况。我们只需在这里简单地添加条件,如果游戏结束时屏幕被触摸,就重新开始。要添加到 MotionEvent.ACTION_DOWN: 情况中的新代码已高亮:

// Has the player touched the screen?
case MotionEvent.ACTION_DOWN:
    player.setBoosting();
 // If we are currently on the pause screen, start a new game
 if(gameEnded){
 startGame();
 }
   break;

尝试一下。现在你可以在暂停菜单中通过点击屏幕重新开始游戏。是我太敏感还是这里有点安静?

添加声音效果

在 Android 中添加声音效果真的很简单。首先,让我们看看我们可以在哪里获取声音效果。如果你只想继续编程,可以使用我在 Chapter4/assets 文件夹中的声音效果。

生成效果音

我们需要四个声音效果用于我们的 Tappy Defender 游戏:

  • 当我们的玩家撞到外星人时的声音,我们将其称为 bump.ogg

  • 当玩家被摧毁时的声音,我们将其称为 destroyed.ogg

  • 游戏开始时一个有趣的声音,我们称之为start.ogg

  • 最后,一个胜利的欢呼声效,我们称之为win.ogg

这是一个非常简短的指南,介绍如何使用 BFXR 制作这些音效。从www.bfxr.net获取 BFXR 的免费副本。

按照网站上的简单说明进行设置。尝试其中一些功能,制作我们酷炫的音效。

注意

这是一个非常精简的教程。你可以用 BFXR 做很多事情。想要了解更多,请访问前一个 URL 的网站上的小贴士。

  1. 运行bfxr.exe生成音效

  2. 尝试所有预设类型,这些预设会生成你正在处理的类型的随机声音。当你找到一个接近你想要的声音时,进行下一步操作:生成音效

  3. 使用滑块微调你新声音的音调、时长和其他方面:生成音效

  4. 通过点击导出 Wav按钮保存你的声音。尽管这个按钮的名字是这样,但如我们所见,我们也可以保存除.wav以外的格式。生成音效

  5. Android 喜欢使用 OGG 格式的声音,因此当要求你命名文件时,在文件名末尾使用.ogg扩展名。记住我们需要创建bump.oggdestroyed.oggstart.oggwin.ogg

  6. 重复步骤 2 至 5,创建我们讨论过的四种音效。

  7. 在 Android Studio 中右键点击app文件夹。在弹出菜单中,导航到新建 | Android 资源目录

  8. 目录名称字段中,输入assets。点击确定创建assets文件夹。

  9. 使用你的操作系统的文件管理器,在项目的主文件夹中添加一个名为assets的文件夹,然后将四个声音文件添加到项目中的新assets文件夹中。

SoundPool

为了播放我们的声音,我们将使用SoundPool类。我们使用SoundPool构造函数的弃用版本,因为新版本需要 API 21 或更高版本,而且很可能有很多读者在使用更早版本的 Android。我们可以动态获取 Android 版本,并为 API 级别 21 之前和之后提供不同版本的代码,但旧的构造函数满足了我们的需求。

编码音效

声明一个SoundPool对象和一些整数来代表各个声音。在TDView类声明后立即添加此代码:

private SoundPool soundPool;
    int start = -1;
    int bump = -1;
    int destroyed = -1;
    int win = -1;

接下来,我们可以初始化我们的SoundPool对象和我们的整型声音 ID。我们将代码包裹在必需的try-catch块中。

注意,调用load()开始了一个将我们的.ogg文件转换为原始声音数据的过程。如果在进行playSound()调用时此过程尚未完成,声音将不会播放。load()的调用顺序是按照它们被使用的方式来最小化这种可能性。在TDView类的构造函数中输入如下代码。新代码已高亮显示:

TDView(Context context, int x, int y) {
  super(context);
  this.context  = context;

 // This SoundPool is deprecated but don't worry
 soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC,0);
 try{
 //Create objects of the 2 required classes
 AssetManager assetManager = context.getAssets();
 AssetFileDescriptor descriptor;

 //create our three fx in memory ready for use
 descriptor = assetManager.openFd("start.ogg");
 start = soundPool.load(descriptor, 0);

 descriptor = assetManager.openFd("win.ogg");
 win = soundPool.load(descriptor, 0);

 descriptor = assetManager.openFd("bump.ogg");
 bump = soundPool.load(descriptor, 0);

 descriptor = assetManager.openFd("destroyed.ogg");
 destroyed = soundPool.load(descriptor, 0);

 }catch(IOException e){
 //Print an error message to the console
 Log.e("error", "failed to load sound files");
 }

在我们代码中表示游戏内适当事件的点处,使用适当的引用添加对playSound()的调用。我们有四种声音,所以将会有四次对playSound()的调用。

第一个在startGame()方法的最后面:

soundPool.play(start, 1, 1, 0, 0, 1);

接下来的两行在if(hitDetected)块中被高亮显示:

if(hitDetected) {
 soundPool.play(bump, 1, 1, 0, 0, 1);
  player.reduceShieldStrength();
  if (player.getShieldStrength() < 0) {
 soundPool.play(destroyed, 1, 1, 0, 0, 1);
      paused = true;
  }
}

最后一个在if(distanceRemaining < 0)块中被高亮显示:

//Completed the game!
if(distanceRemaining < 0){
 soundPool.play(win, 1, 1, 0, 0, 1);
     //check for new fastest time
     if(timeTaken < fastestTime) {
         fastestTime = timeTaken;
     }

     // avoid ugly negative numbers
     // in the HUD
     distanceRemaining = 0;

     // Now end the game
     gameEnded = true;
}

现在是运行 Tappy Defender 并听听动作中的声音的时候了。

我们将看到当玩家在游戏中达到高分时如何将其保存到文件中,并在 Tappy Defender 启动时重新加载它。

添加持久性

您可能已经注意到当前的最快时间是零,因此永远无法被打破。另一个问题是,每次玩家退出游戏时,最高分都会丢失。现在,我们将从文件中加载一个默认的高分。当达到新的高分时,将其保存到文件中。无论玩家退出游戏还是关闭手机,他们的高分都会保留。

首先,我们需要两个新的对象。在TDView类声明之后,将它们声明为TDView类的成员。第一个是SharedPreferences对象,第二个是Editor对象,它实际上为我们写入文件:

private SharedPreferences prefs;
private SharedPreferences.Editor editor;

我们首先使用prefs,因为我们只是想尝试加载一个存在的高分。我们还会初始化editor,以便在我们保存高分时可以使用。我们在TDView构造函数中这样做:

// Get a reference to a file called HiScores. 
// If id doesn't exist one is created
prefs = context.getSharedPreferences("HiScores", 
  context.MODE_PRIVATE);

// Initialize the editor ready
editor = prefs.edit();

// Load fastest time from a entry in the file
//  labeled "fastestTime"
// if not available highscore = 1000000
fastestTime = prefs.getLong("fastestTime", 1000000);

让我们在适当的时候使用我们的Editor对象将任何新的最快时间写入到HiScores文件中。首先将显示的额外高亮行添加到我们的文件缓冲区中,然后提交更改以添加我们提议的修改:

//Completed the game!
if(distanceRemaining < 0){
 soundPool.play(win, 1, 1, 0, 0, 1);
     //check for new fastest time
     if(timeTaken < fastestTime) {
         // Save high score
         editor.putLong("fastestTime", timeTaken);
         editor.commit();
         fastestTime = timeTaken;
     }

     // avoid ugly negative numbers
     // in the HUD
     distanceRemaining = 0;

     // Now end the game
     gameEnded = true;
}

我们需要做的最后一件事是让主屏幕加载最快的游戏时间并将其展示给玩家。我们将以与在TDView构造函数中完全相同的方式加载最快的时间。我们还会通过其 ID textHighScore获取对TextView的引用,这是我们在第二章Tappy Defender – First Step开始时分配的。然后我们使用setText方法将其展示给玩家。

打开MainActivity.java,在onCreate方法中添加我们刚才讨论过的那些高亮代码:

// This is the entry point to our game
@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);

  //Here we set our UI layout as the view
  setContentView(R.layout.activity_main);

 // Prepare to load fastest time
 SharedPreferences prefs;
 SharedPreferences.Editor editor;
 prefs = getSharedPreferences("HiScores", MODE_PRIVATE);

  // Get a reference to the button in our layout
  final Button buttonPlay =
    (Button)findViewById(R.id.buttonPlay);

 // Get a reference to the TextView in our layout
 final TextView textFastestTime = 
 (TextView)findViewById(R.id.textHighScore);

  // Listen for clicks
  buttonPlay.setOnClickListener(this);

 // Load fastest time
 // if not available our high score = 1000000
 long fastestTime = prefs.getLong("fastestTime", 1000000);

 // Put the high score in our TextView
 textFastestTime.setText("Fastest Time:" + fastestTime);

}

现在,我们已经有了一个可以运行的游戏。然而,它还没有真正完成。为了制作一个真正可玩且有趣的游戏,我们必须改进、优化、测试并迭代。

迭代

我们如何使游戏变得更好玩?让我们看看一些可能性,然后去实施它们。

多个不同的敌人图形

让我们通过为游戏添加更多图形使敌人更有趣。首先,我们需要将额外的图形添加到项目中。将下载包中Chapter4/drawables文件夹中的enemy2.pngenemy3.png复制并粘贴到 Android Studio 中的drawables文件夹中。

多种不同的敌人图像

enemy2 和 enemy3

现在,我们只需要修改EnemyShip构造函数。这段代码生成一个 0 到 2 之间的随机数,然后根据需要切换加载不同的敌人位图。我们完成的构造函数现在看起来像这样:

// Constructor
public EnemyShip(Context context, int screenX, int screenY){
 Random generator = new Random();
 int whichBitmap = generator.nextInt(3);
 switch (whichBitmap){
 case 0:
 bitmap = BitmapFactory.decodeResource
 (context.getResources(), R.drawable.enemy3);
 break;

 case 1:
 bitmap = BitmapFactory.decodeResource
 (context.getResources(), R.drawable.enemy2);
 break;

 case 2:
 bitmap = BitmapFactory.decodeResource
 (context.getResources(), R.drawable.enemy);
 break;
 }

    maxX = screenX;
    maxY = screenY;
    minX = 0;
    minY = 0;

    speed = generator.nextInt(6)+10;
    x = screenX;
    y = generator.nextInt(maxY) - bitmap.getHeight();

    // Initialize the hit box
    hitBox = new Rect(x, y, bitmap.getWidth(),  bitmap.getHeight());

}

请注意,我们只需要将Random generator = new Random();这行代码移到构造函数的顶部,这样我们就可以用它来选择位图以及在构造函数的后面像往常一样生成一个随机的高度。

这是一个平衡的练习

游戏中最大的可玩性问题可能是,在中/高分辨率屏幕上玩游戏与在低分辨率屏幕上相比,难度差异的问题。例如,我的一个测试设备是三星 Galaxy S2,现在它已经有些年头了,当横屏握持时,屏幕分辨率为 800 x 480 像素。相比之下,我在横屏模式下使用 1920 x 1080 像素的三星 Galaxy S4 测试了游戏。这比 S2 的分辨率高出一倍多。

在 S4 上,玩家似乎可以轻松地在几乎微不足道的敌人之间滑行,而在 S2 上,玩家面临的是几乎无法穿透的外星钢铁之墙。

这个问题的真正解决方案是以伪现实世界坐标绘制游戏对象,然后将这些坐标以相同的比例映射回设备,无论分辨率如何。这样,无论在 S2 还是 S4 上,游戏看起来和玩起来的效果都是一样的。在下一个项目中,我们将构建一个更高级的游戏引擎来实现这一点。

当然,我们仍然会考虑实际物理屏幕尺寸,使玩家的体验多样化,但这种情形更容易被游戏玩家接受。

作为一种快速而简便的解决方案,我们将改变战舰的大小和敌人的数量。因此,在低分辨率下,我们将有三个敌人,但会缩小它们的大小。在高分辨率下,我们将逐渐增加敌人的数量。

EnemyShip类中,在将敌人图像加载到我们的Bitmap对象的switch块之后,添加高亮显示的行,以调用我们将要编写的新方法scaleBitmap()

switch (whichBitmap){
    case 0:
          bitmap = BitmapFactory.decodeResource(context.getResources(),           
          R.drawable.enemy3);
          break;

    case 1:
          bitmap = BitmapFactory.decodeResource(context.getResources(),           
          R.drawable.enemy2);
          break;

   case 2:
          bitmap = BitmapFactory.decodeResource(context.getResources(),           
          R.drawable.enemy);
          break;
}

scaleBitmap(screenX);

现在,我们将编写新的scaleBitmap方法。这个简单的辅助方法接受一个参数,正如我们所见,是屏幕的水平分辨率。然后我们使用分辨率和静态的createScaledBitmap方法,根据屏幕分辨率按 2 或 3 的比例缩小我们的Bitmap对象。将新的scaleBitmap方法添加到EnemyShip类中:

public void scaleBitmap(int x){

  if(x < 1000) {
       bitmap = Bitmap.createScaledBitmap(bitmap,
       bitmap.getWidth() / 3,
       bitmap.getHeight() / 3,
       false);
  }else if(x < 1200){
       bitmap = Bitmap.createScaledBitmap(bitmap,
       bitmap.getWidth() / 2,
       bitmap.getHeight() / 2,
       false);
   }
}

在低分辨率屏幕上,敌人的大小会被缩小。现在,让我们为高分辨率增加敌人的数量。

为此,我们将在TDView类中添加代码,为高分辨率屏幕添加额外的敌人。

注意

警告!这段代码很糟糕,但它有效,它告诉我们可以在下一个项目中在哪里进行改进。在规划游戏时,总是在良好设计与简单性之间进行权衡。从一开始就保持事物有序,我们可以在最后稍微进行一些黑客行为。是的,我们可以重新设计我们生成和存储游戏对象的方式,如果 Tappy Defender 是一个持续的项目,那么这将是有价值的。

在前三个之后,按照所示添加两个更多的敌人飞船对象:

// Game objects
private PlayerShip player;
public EnemyShip enemy1;
public EnemyShip enemy2;
public EnemyShip enemy3;
public EnemyShip enemy4;
public EnemyShip enemy5;

现在,在startGame方法中添加代码,有条件地初始化这两个新对象:

enemy1 = new EnemyShip(context, screenX, screenY);
enemy2 = new EnemyShip(context, screenX, screenY);
enemy3 = new EnemyShip(context, screenX, screenY);

if(screenX > 1000){
 enemy4 = new EnemyShip(context, screenX, screenY);
}

if(screenX > 1200){
 enemy5 = new EnemyShip(context, screenX, screenY);
}

update方法中添加代码,更新我们的第四和第五个敌人并检查碰撞:

// Collision detection on new positions
// Before move because we are testing last frames
// position which has just been drawn
boolean hitDetected = false;
if(Rect.intersects(player.getHitbox(), enemy1.getHitbox())){
  hitDetected = true;
  enemy1.setX(-100);
}

if(Rect.intersects(player.getHitbox(), enemy2.getHitbox())){
  hitDetected = true;
  enemy2.setX(-100);        
}

if(Rect.intersects(player.getHitbox(), enemy3.getHitbox())){
  hitDetected = true;
  enemy3.setX(-100);       
}

if(screenX > 1000){
 if(Rect.intersects(player.getHitbox(), enemy4.getHitbox())){
 hitDetected = true;
 enemy4.setX(-100); 
 }
}

if(screenX > 1200){
 if(Rect.intersects(player.getHitbox(), enemy5.getHitbox())){
 hitDetected = true;
 enemy5.setX(-100);
 }
}

if(hitDetected) {
soundPool.play(bump, 1, 1, 0, 0, 1);
            player.reduceShieldStrength();
            if (player.getShieldStrength() < 0) {
                soundPool.play(destroyed, 1, 1, 0, 0, 1);
                gameEnded = true;
            }
}

// Update the player
player.update();
// Update the enemies
enemy1.update(player.getSpeed());
enemy2.update(player.getSpeed());
enemy3.update(player.getSpeed());

if(screenX > 1000) {
 enemy4.update(player.getSpeed());
}
if(screenX > 1200) {
 enemy5.update(player.getSpeed());
}

最后,在draw方法中,在适当的时候绘制我们的额外敌人:

// Draw the player
canvas.drawBitmap(player.getBitmap(), player.getX(), player.getY(), paint);
canvas.drawBitmap(enemy1.getBitmap(),
  enemy1.getX(), enemy1.getY(), paint);
canvas.drawBitmap(enemy2.getBitmap(),
  enemy2.getX(), enemy2.getY(), paint);
canvas.drawBitmap(enemy3.getBitmap(),
  enemy3.getX(), enemy3.getY(), paint);

if(screenX > 1000) {
 canvas.drawBitmap(enemy4.getBitmap(),
 enemy4.getX(), enemy4.getY(), paint);
}
if(screenX > 1200) {
 canvas.drawBitmap(enemy5.getBitmap(),
 enemy5.getX(), enemy5.getY(), paint);
}

当然,我们现在意识到我们可能还想缩放玩家。这使得或许我们需要一个Ship类,从中我们可以派生出PlayerShipEnemyShip

加入我们为更高分辨率屏幕添加额外敌人的笨拙方式,一个更加多态的解决方案可能更有价值。我们将在下一个项目中看到如何彻底改进这一点以及我们游戏引擎的几乎所有其他方面。

格式化时间

查看玩家 HUD 中时间是如何格式化的:

格式化时间

呕!让我们编写一个简单的辅助方法,让这个看起来更美观。我们将在TDView类中添加一个名为formatTime()的新方法。该方法使用游戏中经过的毫秒数(timeTaken)并将它们重新组织成秒和秒的小数部分。它适当地用零填充小数部分,并将结果作为String返回,以便在TDView类的draw方法中绘制。该方法之所以采用参数而不是直接使用成员变量timeTaken,是为了我们可以在一分钟内重用这段代码。

private String formatTime(long time){
    long seconds = (time) / 1000;
    long thousandths = (time) - (seconds * 1000);
    String strThousandths = "" + thousandths;
    if (thousandths < 100){strThousandths = "0" + thousandths;}
    if (thousandths < 10){strThousandths = "0" + strThousandths;}
    String stringTime = "" + seconds + "." + strThousandths;
    return stringTime;
}

我们修改了绘制玩家 HUD 中时间的行。为了提供上下文,在下一段代码中,我注释掉了原始行的全部内容,并提供了新的行,其中包含我们对formatTime()的调用,并已高亮显示:

//canvas.drawText("Time:" + timeTaken + "s", screenX / 2, 20, paint);
canvas.drawText("Time:" + 
 formatTime(timeTaken) + 
 "s", screenX / 2, 20, paint);

此外,通过一个小的改动,我们也可以在 HUD 中的最快时间:标签上使用这种格式。同样,旧行已被注释掉,新行已高亮显示。在TDView类的draw方法中查找并修改代码:

//canvas.drawText("Fastest:" + fastestTime + "s", 10, 20, paint);
canvas.drawText("Fastest:" + 
 formatTime(fastestTime) + 
 "s", 10, 20, paint);

我们还应该更新暂停屏幕上的时间格式。要更改的行已被注释掉,需要添加的新行已高亮显示:

// Show pause screen
paint.setTextSize(80);
paint.setTextAlign(Paint.Align.CENTER);
canvas.drawText("Game Over", screenX/2, 100, paint);
paint.setTextSize(25);

// canvas.drawText("Fastest:"
  + fastestTime + "s", screenX/2, 160, paint);
canvas.drawText("Fastest:"+ 
 formatTime(fastestTime) + "s", screenX/2, 160, paint);

// canvas.drawText("Time:" + 
  timeTaken + "s", screenX / 2, 200, paint);
canvas.drawText("Time:" 
 + formatTime(timeTaken) + "s", screenX / 2, 200, paint);

canvas.drawText("Distance remaining:" +
  distanceRemaining/1000 + " KM",screenX/2, 240, paint);
paint.setTextSize(80);
canvas.drawText("Tap to replay!", screenX/2, 350, paint);

最快时间:现在在游戏内 HUD 和暂停屏幕 HUD 上都与时间:的格式相同。看看我们现在整洁的时间格式:

格式化时间

处理返回按钮

我们将快速添加一小段代码,以处理玩家在 Android 设备上按下返回键时会发生什么。将这个新方法添加到GameActivityMainActivity类中。我们只需检查是否按下了返回键,如果是,就调用finish()让操作系统知道我们已经完成了这个活动。

// If the player hits the back button, quit the app
public boolean onKeyDown(int keyCode, KeyEvent event) {
  if (keyCode == KeyEvent.KEYCODE_BACK) {
       finish();
       return true;
  }
  return false;
}

完成的游戏

最后,如果你是为了理论学习而不是实践而跟进的话,这里有一个在高分辨率屏幕上完成的GameActivity,其中包含了几百个额外的星星和盾牌:

完成的游戏

总结

我们已经实现了一个基本游戏引擎的各个组成部分。我们还可以做得更多。当然,一个现代移动游戏会比我们的游戏有更多内容。当有更多的游戏对象时,我们将如何处理碰撞?我们是否可以稍微收紧一下我们的类层次结构,因为我们的PlayerShipEnemyShip类之间有很多相似之处?我们如何在不对代码结构造成混乱的情况下添加复杂的内部角色动画,如果我们想要智能敌人,能够实际思考的敌人,该怎么办?

我们需要逼真的背景、侧目标、能量升级和拾取物品。我们希望游戏世界具有真实世界的坐标,无论屏幕分辨率如何,都能准确映射回来。

我们需要一个更智能的游戏循环,无论在哪种 CPU 上处理,都能以相同的速度运行游戏。最重要的是,我们真正需要的,比这些更重要的,是一把大大的机枪。让我们构建一个经典平台游戏。

第五章:平台游戏 - 升级游戏引擎

欢迎来到这本书的第二个项目。在这里,我们将构建一个真正困难的复古平台游戏。它不是难以构建,而是当你玩它时难以击败。在项目结束时,我们还将讨论如何使游戏玩法稍微不那么严苛,如果你希望的话。

本章将完全聚焦于我们的游戏引擎,本质上将导致 Tappy Defender 代码的升级版本。

首先,我们将讨论我们希望通过这个游戏实现的目标:背景故事、游戏机制和规则。

然后,我们将快速创建一个活动,实例化一个将完成所有工作的视图。

之后,我们将充实PlatformView类的基本结构,它将有一些微妙的但重要的区别于我们的TDView类。最值得注意的是,PlatformView将有一个简单但有效的方式来管理我们游戏所有事件的时间。

然后,我们将开始迭代构建我们的GameObject类,游戏世界中的几乎每一个实体都将由此派生。

接下来,我们将讨论视口的概念,玩家通过这个视口来观看游戏世界。我们不再将游戏对象设计为在屏幕分辨率层面操作,而是存在于一个拥有自身xy坐标的世界中,我们可以将这些坐标视为虚拟米。在z轴上也有一个简单的深度系统。这将由我们新的Viewport类来处理。

在此之后,我们将研究如何设计和布局游戏内容。这是通过一个用作关卡设计师的类完成的,可以非编程地使用它来规划跳跃、敌人、奖励和目标,这些构成了一个关卡的布局。

为了管理关卡设计并将它们加载到我们的游戏引擎中,我们将需要另一个类。我们将它称为LevelManager

在本章的最后,我们将查看PlatformView类中的增强型updatedraw方法,这样我们就可以实际运行我们的新游戏,并在屏幕上看到首次输出。

有这么多事情要做,我们最好开始吧。

游戏

我们将要构建的游戏基于一些 80 年代残酷难度的平台游戏,如 Bounty Bob Strikes Back 和 Impossible Mission 的游戏玩法。这些游戏以难以跳跃和同时需要极其精确的时机控制著称,同时给玩家一个不宽恕的生命/机会数量。这种游戏风格很适合我们,因为我们可以实际上在四个章节内构建一个多级别的可玩游戏。

类的设计将使你能够轻松添加自己的额外功能、游戏对象,或者如果你愿意,也可以稍微降低游戏的难度。

背景故事

我们的英雄鲍勃刚从地球中心摧毁一个邪恶科学家的秘密任务中回来,发现他正处于地下深处。更糟的是,尽管他已经击败了邪恶科学家,但似乎来不及拯救这个星球免受他释放的强大守卫和致命的飞行机器人无人机的侵袭。

鲍勃必须从深地下的火焰洞穴出发,穿过重兵把守的城市和山区森林,他希望在那里过上自由的生活,摆脱接管这个星球的可怕新秩序。

在这四个关卡中,他必须避开守卫,摧毁无人机,收集大量金钱,并升级他最初弱小的机枪。

游戏机制

游戏将围绕执行精确的跳跃,规划通过关卡的最佳路径以收集战利品并逃脱。鲍勃将能够小心翼翼地站在边缘,脚只有几个像素悬空,以完成看似不可能的跳跃。鲍勃将能够控制跳跃时的距离,这意味着有时他需要确保自己不会跳过头。

鲍勃在尝试通过重兵把守的区域逃脱前,需要收集机枪升级。

鲍勃只有三条生命,但在他的旅程中可能会找到更多。

游戏规则

当鲍勃被无人机/守卫捕获、触碰到火焰,或者跌出游戏世界而失去生命时,他将在当前关卡的起点重新出现。无人机可以飞行,并且一旦鲍勃进入视线就会锁定他。鲍勃需要确保他有足够的火力来对付无人机。守卫将在关卡预定区域巡逻,但他们很强大,只能被鲍勃的机枪击退。通常,鲍勃需要执行一个精确计时跳跃以绕过守卫。

环境同样会非常艰难。鲍勃需要完全掌握每个关卡,因为一次错误的跳跃就会让他直接回到起点,落入敌人手中,甚至直接遭遇火葬。

升级游戏引擎

所有的守卫、无人机、火焰、收藏品、枪支的讨论,以及暗示的更大游戏世界,表明我们需要管理一个更为复杂的系统。我们的游戏引擎的目标之一就是让这种复杂性易于管理。另一个目标是将关卡设计从编码中分离出来。当我们的游戏完成时,你将能够轻松设计出最邪恶但也最有成就感的关卡,在不同的环境中无需触碰代码就能完成设计。

平台活动

首先,我们从Activity类开始,这是进入我们游戏的入口点。这里没有太多新内容,让我们快速构建它。创建一个新项目,在应用名称字段中输入C5 平台游戏。选择手机和平板,然后在提示时选择空白活动。在活动名称字段中,输入PlatformActivity

提示

显然,您不必遵循我的确切命名选择,但请记得在代码中进行一些小修改,以反映您自己的命名选择。

您可以从layout文件夹中删除activity_platform.xml。您还可以删除PlatformActivity.java文件中的所有代码。只保留包声明。现在,我们有一个完全空白的画布,准备开始编码。以下是到目前为止我们的项目的全部内容:

package com.gamecodeschool.c5platformgame;

让我们开始构建我们的引擎。就像在我们的 Tappy Defender 项目中一样,我们将构建一个类来处理游戏视图方面。或许不足为奇,我们将这个类称为PlatformView。因此,我们的PlatformActivity类需要实例化一个PlatformView对象,并将其设置为应用程序的主要视图,就像在之前的项目中一样。

我们将对引擎进行一些重大升级,但主要是在视图层面进行。在接下来要看的PlatformActivity类的代码中,我们与上一个项目所做的类似。首先,在重写的onCreate方法中声明PlatformView对象,并将其设置为主要的视图;但在这样做之前,我们还需要捕获并传入设备屏幕的分辨率。

我们通过使用Display类,链式调用getWindowManager()getDefaultDisplay()方法来获取我们游戏将要运行的物理显示硬件的属性。然后,我们创建一个名为resolutionPoint类型的对象,并通过调用display.getSize(size)将显示的分辨率存储到我们的Point对象中。

这会将屏幕的水平像素数和垂直像素数分别存储在size.xsize.y中。然后我们可以继续通过调用其构造函数并传入size.xsize.y中存储的值来实例化一个新的PlatformView对象。与之前一样,我们还需要传入应用程序的Context对象(this),正如在之前的项目中,我们会发现它有很多用途。

我们可以通过调用setContentView()方法,将platformView设置为视图。如前所述,我们重写Activity类的生命周期方法onPause()onResume(),让它们调用我们即将编写的PlatformView类中的相应方法。这两个方法可以启动和停止我们的Thread类。

下面是我们刚刚讨论的PlatformActivity类的完整代码,没有新的重要方面。将代码输入或复制粘贴到您的项目中。本章的代码可以在 Packt Publishing 网站的书籍页面下载捆绑包中找到。本章的所有代码和资源都可以在Chapter5文件夹中找到。这个文件叫做PlatformActivity.java

提示

当提示导入所有新类时,请记得导入,或者当因缺少类而出现错误时,将光标悬停在错误上,按Alt | Enter键盘组合进行导入。

import android.app.Activity;
import android.graphics.Point;
import android.os.Bundle;
import android.view.Display;

public class PlatformActivity extends Activity {

    // Our object to handle the View
    private PlatformView platformView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Get a Display object to access screen details
        Display display = getWindowManager().getDefaultDisplay();

        // Load the resolution into a Point object
        Point resolution = new Point();
        display.getSize(resolution);

        // And finally set the view for our game
        // Also passing in the screen resolution
        platformView = new PlatformView
        (this, resolution.x, resolution.y);

        // Make our platformView the view for the Activity
        setContentView(platformView);

    }

    // If the Activity is paused make sure to pause our thread
    @Override
    protected void onPause() {
        super.onPause();
        platformView.pause();
    }

    // If the Activity is resumed make sure to resume our thread
    @Override
    protected void onResume() {
        super.onResume();
        platformView.resume();
    }
}

注意

显然,在我们创建PlatformView类之前,我们的PlatformActivity类代码中将会出现错误。

将布局锁定为横屏

正如我们在上一个项目中做的那样,我们将确保游戏只在横屏模式下运行。我们将使我们的AndroidManifest.xml文件强制我们的PlatformActivity类以全屏运行,并且我们还将将其锁定为横屏布局。让我们进行以下更改:

  1. 现在打开manifests文件夹,双击AndroidManifest.xml文件,在代码编辑器中打开它。

  2. AndroidManifest.xml文件中,找到以下代码行:

    android:name=".PlatformActivity"
    
  3. 在它下面,输入或复制粘贴以下两行代码,使PlatformActivity全屏运行,并将其锁定为横屏方向。

    android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
    android:screenOrientation="landscape"
    

现在,我们可以进入游戏的核心部分,看看我们如何实现我们讨论的所有这些改进。

PlatformView 类

到完成时,这个类将依赖于许多其他类。我不想逐一介绍每个类,因为这样会很难跟上,而且哪些代码实现了哪个功能也会变得混乱。相反,我们将根据需要逐个查看和编写每个功能,并多次回顾许多类以添加更多功能。这将使代码每一部分的特定目的保持焦点。

说到这里,我们已经非常注意,尽管我们会多次回顾这些类,但我们不会不断地删除代码,而只是在原有代码中增加内容。当我们增加代码时,将在适当的上下文中展示代码,并将新部分在现有代码中突出显示。

至于类的结构,它们被设计为尽可能最小,同时也不会限制你轻松添加功能和扩展代码的潜力。

这不是关于游戏引擎设计的课程,而是更多地学习如何实现和压缩四个章节中的不同功能,而不会使代码变得难以管理。

如果你计划构建非常大的游戏,尤其是在团队中工作时,那么更健壮的设计将是必要的。这种更健壮的设计也将意味着大量的额外类、接口、包等等。

提示

如果这类讨论吸引了你,我强烈推荐你阅读 Mario Zechner 所著的《Beginning Android Games》,由 APRESS 出版。Mario 是跨平台游戏库 LibGDX 的创始人/创造者,他的书详细介绍了构建高度可扩展和可重用游戏代码库所需的设计模式。这本书详细的设计细节的唯一缺点是,它需要大约 600 页来构建一个简单的复古贪吃蛇游戏。

首先,让我们创建一个类。在 Android Studio 项目浏览器中右键点击包名,选择New | Java Class。将新类命名为PlatformView。删除类中自动生成的代码,因为我们将很快添加自己的代码。

在整个项目过程中,我们将会继续向这个类添加代码。本章中添加到类中的完整代码可以在下载包中的Chapter5/PlatformView.java找到。

我们需要一个能够管理我们关卡的类。让我们称它为LevelManager

我们还需要一个类来保存我们关卡的数据,这样每次我们创建一个新/不同的关卡设计时,都可以扩展它。让我们将父类称为LevelData,而 Bob 逃脱的第一个真实关卡称为LevelCave

此外,由于这个游戏将有许多敌人、道具和地形类型,我们需要一个更清洁的系统来管理它们。我们需要一个相当通用的GameObject类,所有不同的游戏对象都可以继承它。这样,我们在updatedraw方法中可以更容易地管理它们。

同样,由于必要性,我们将构建一个稍微复杂一些的方法来检测玩家的输入。我们将创建一个InputController类,将所有代码从PlatformView委托给它。但是,我们将在下一章中完全展开我们的Player对象来表示玩家之后,才会了解这个类的细节。

我们可以快速编写基本的PlatformView类,其代码与第一个项目非常相似,但有几个值得注意的区别,我们将在后面讨论。

PlatformView的基本结构

这里是必要的导入和我们开始需要的成员变量。随着项目的进行,我们将会增加这些内容。

请注意,我们还声明了三种新的对象类型,lm是我们的LevelManager类,vp是我们的Viewport类,以及ic,它是我们的InputController类。我们将在本章中开始处理其中一些内容。当然,在我们实现它们各自的类之前,这些声明将显示错误。

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class PlatformView extends SurfaceView 
  implements Runnable {

  private boolean debugging = true;
  private volatile boolean running;
  private Thread gameThread = null;

  // For drawing
  private Paint paint;
  // Canvas could initially be local.
  // But later we will use it outside of draw()
  private Canvas canvas;
  private SurfaceHolder ourHolder;

  Context context;
  long startFrameTime;
  long timeThisFrame;
  long fps;

   // Our new engine classes
   private LevelManager lm;
   private Viewport vp;
   InputController ic;

在这里,我们有我们的PlatformView构造函数。在这个阶段,它没有做任何新的操作,实际上,它的代码比我们的TDView构造函数还要少,但它很快就会得到增强。现在,请输入如下代码:

PlatformView(Context context, int screenWidth, 
    int screenHeight) {

    super(context);
    this.context = context;

    // Initialize our drawing objects
    ourHolder = getHolder();
    paint = new Paint();
}

这是我们的线程的run方法。注意,在调用update()之前,我们获取当前时间(毫秒)并将其放入startFrameTime长整型变量中。然后在draw()完成之后,我们再次调用以获取系统时间,并测量自帧开始以来已经过去了多少毫秒。然后我们执行计算fps = 1000 / thisFrameTime,这给了我们上一个帧中游戏运行的帧数。这个值存储在fps变量中。随着游戏的进行,我们将到处使用这个值。编写我们刚刚讨论的run方法,如下所示:

@Override
public void run() {

  while (running) {
       startFrameTime = System.currentTimeMillis();

       update();
       draw();

      // Calculate the fps this frame
      // We can then use the result to
      // time animations and movement.
      timeThisFrame = System.currentTimeMillis() - startFrameTime;
            if (timeThisFrame >= 1) {
                fps = 1000 / timeThisFrame;
            }
     }
}

在本章后面,我们将看到如何管理多种对象类型的额外复杂性,并在必要时更新它们。现在,只需向PlatformView类添加一个空的update方法,如下所示:

private void update() {
  // Our new update() code will go here
}

在这里,我们看到我们熟悉的draw方法的部分。在本章后面,我们将看到一些新代码。现在,添加draw方法的基本部分,如下所示,这部分将保持不变:

private void draw() {

     if (ourHolder.getSurface().isValid()){
      //First we lock the area of memory we will be drawing to
      canvas = ourHolder.lockCanvas();

      // Rub out the last frame with arbitrary color
      paint.setColor(Color.argb(255, 0, 0, 255));
      canvas.drawColor(Color.argb(255, 0, 0, 255));

      // New drawing code will go here

      // Unlock and draw the scene
      ourHolder.unlockCanvasAndPost(canvas);
  }
}

视图第一阶段组合的最后部分是pauseresume方法,这些方法是由操作系统调用相应的 Activity 生命周期方法时由PlatformActivity调用的。它们与上一个项目中的方法没有变化,但为了完整性和便于跟踪,这里再次列出。将这些方法添加到PlatformView类中:

// Clean up our thread if the game is interrupted    
public void pause() {
  running = false;
   try {
       gameThread.join();
   } catch (InterruptedException e) {
       Log.e("error", "failed to pause thread");
   }
}

// Make a new thread and start it
// Execution moves to our run method
public void resume() {
   running = true;
   gameThread = new Thread(this);
   gameThread.start();

}

}// End of PlatformView

现在,我们已经完成了视图的基本大纲编码并准备就绪。让我们首先看看GameObject类。

GameObject

我们知道我们需要一个父类来保存我们游戏对象的大部分内容,因为我们想要改进上一个项目中代码的灵活性和重复性。从上一个项目我们知道,它需要许多属性和方法。

首先,我们需要一个简单的类来表示所有未来GameObject类的世界位置。这个类将在xy轴上保存一个详细的位置。请注意,这些与我们的游戏将运行的设备上的像素坐标完全独立。我们可以将z坐标视为图层编号。数字越小,越先绘制。因此,创建一个新的 Java 类,将其命名为Vector2Point5D,并输入以下代码:

public class Vector2Point5D {

    float x;
    float y;
    int z;
}

现在,让我们看看并编码GameObject类的基本工作大纲,然后在项目过程中,我们可以回过头来添加额外的功能。创建一个新的 Java 类,将其命名为GameObject。让我们看看我们需要开始编写使这个类有用的代码。首先,我们导入所需的类。

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;

当我们编写GameObject本身时,请注意该类没有提供构造函数,因为这将根据我们实现的特定GameObject而有所不同。

你在代码中注意到的第一个变量是worldLocation,正如你所预期的,它是Vector2Point5D类型的。然后我们有两个 float 成员,将保存GameObject类的宽度和高度。接下来是布尔变量activevisible,它们可能用于标记对象在活动、可见或其它状态时的标签。我们将在本章后面看到这样做的好处。

我们还需要知道任何给定的对象有多少内部动画帧。默认值将是1,因此animFrameCount相应地初始化。

然后,我们有一个名为typechar类。这个type变量将确切地确定任何特定的GameObject可能是什么。它将被广泛使用。目前最后一个成员变量是bitmapName。我们将看到,知道代表我们每个单独对象外观的图形的名称将非常有用。添加我们刚刚讨论的成员变量:

public abstract class GameObject {

    private Vector2Point5D worldLocation;
    private float width;
    private float height;

    private boolean active = true;
    private boolean visible = true;
    private int animFrameCount = 1;
    private char type;

    private String bitmapName;

现在,我们可以看看GameObject功能的第一部分。我们有一个抽象方法update()。我们的计划是所有对象都需要更新自身。在四章内容中,这显得有些过于雄心勃勃,我们的一些对象(主要是平台和场景)将只提供一个空的update()实现。但是,这并不妨碍你让场景比我们现在有时间处理的更具互动性,或者在我们了解事物如何运作后,让平台更具动态性和冒险性。添加以下抽象update方法:

public abstract void update(long fps, float gravity);

我们处理管理我们图形的方法。我们有一个获取器来检索bitmapName。然后,我们有一个prepareBitmap()方法,它使用字符串bitmapName.png图像文件制作一个 Android 资源 ID。这个文件必须存在于项目的drawable文件夹中。就像我们之前看到的那样创建位图。

现在,我们的prepareBitmap方法做了些新的事情。它使用createScaledBitmap方法来改变我们刚刚创建的位图的大小。它不仅使用我们之前讨论的animFrameCount,还使用方法的参数pixelsPerMetre变量。

想法是,每个设备都有一个适合该设备的pixelsPerMetre值,这将帮助我们跨不同分辨率的设备创建一个相同的游戏视图。当我们讨论Viewport类时,我们将确切地了解我们从哪里获取这个pixelsPerMetre值。在GameObject类中输入以下方法:

public String getBitmapName() {
        return bitmapName;
}

public Bitmap prepareBitmap(Context context, 
    String bitmapName, 
    int pixelsPerMetre) {

   // Make a resource id from the bitmapName
   int resID = context.getResources().
        getIdentifier(bitmapName,
        "drawable", context.getPackageName());

    // Create the bitmap
    Bitmap bitmap = BitmapFactory.
        decodeResource(context.getResources(),
        resID);

    // Scale the bitmap based on the number of pixels per metre
    // Multiply by the number of frames in the image
    // Default 1 frame
    bitmap = Bitmap.createScaledBitmap(bitmap,
                (int) (width * animFrameCount * pixelsPerMetre),
                (int) (height * pixelsPerMetre),
                false);

    return bitmap;
}

我们还希望能够知道每个GameObject在世界的哪个位置,当然,也要设置它在世界的哪个位置。以下是一个获取器和设置器,它们正好实现了这个功能。

    public Vector2Point5D getWorldLocation() {
        return worldLocation;
    }

    public void setWorldLocation(float x, float y, int z) {
        this.worldLocation = new Vector2Point5D();
        this.worldLocation.x = x;
        this.worldLocation.y = y;
        this.worldLocation.z = z;
    }

我们希望能够获取和设置我们之前已经讨论过的许多成员变量。这些获取器和设置器将实现这一功能。

    public void setBitmapName(String bitmapName){
        this.bitmapName = bitmapName;
    }

    public float getWidth() {
        return width;
    }

    public void setWidth(float width) {
        this.width = width;
    }

    public float getHeight() {
        return height;
    }

    public void setHeight(float height) {
        this.height = height;
    }

此外,我们还将希望能够检查和更改我们活动变量和可见变量的状态。

    public boolean isActive() {
        return active;
    }

    public boolean isVisible() {
        return visible;
    }

    public void setVisible(boolean visible) {
        this.visible = visible;
    }

设置和获取每个GameObjecttype

    public char getType() {
        return type;
    }

    public void setType(char type) {
        this.type = type;
    }

}// End of GameObject

现在,我们将从GameObject创建我们的第一个子类。在 Android Studio 资源管理器中右键点击包名,并创建一个名为Grass的类。这将是我们第一个基本的地砖类型,玩家可以在上面走动。

这段简单的代码使用构造函数来初始化高度、宽度、类型以及游戏世界中的位置。请注意,所有这些信息都是作为参数传递给构造函数的。Grass类唯一“知道”的,以及与其他简单的GameObject子类区别开来的少数几件事之一,就是bitmapName的值,在这个情况下是turf

如先前讨论的,我们还提供了一个空的update方法的实现:

public class Grass extends GameObject {

    Grass(float worldStartX, float worldStartY, char type) {
        final float HEIGHT = 1;
        final float WIDTH = 1;

        setHeight(HEIGHT); // 1 metre tall
        setWidth(WIDTH); // 1 metre wide

        setType(type);

        // Choose a Bitmap
        setBitmapName("turf");

        // Where does the tile start
        // X and y locations from constructor parameters
        setWorldLocation(worldStartX, worldStartY, 0);
    }

    public void update(long fps, float gravity) {}
}

现在,将下载包中Chapter5/drawable文件夹里的turf.png图形添加到 Android Studio 的drawable文件夹中。

最后,我们将对我们的Player类进行一个最基础的实现,该类也将扩展GameObject。我们不会在这个类中放置任何功能,只需一个xy的世界位置。这样,我们接下来要实现的Viewport类就知道要聚焦在哪里了。

这是代表我们的英雄 Bob 的Player类。在这个阶段,这个类和Grass类一样简单直接,几乎与Grass类相同。随着我们进展,这将会有实质性的变化和发展。注意,我们将类型设置为p

import android.content.Context;

public class Player extends GameObject {

    Player(Context context, float worldStartX, 
        float worldStartY, int pixelsPerMetre) {

        final float HEIGHT = 2;
        final float WIDTH = 1;

        setHeight(HEIGHT); // 2 metre tall
        setWidth(WIDTH); // 1 metre wide

        setType('p');

        // Choose a Bitmap
        // This is a sprite sheet with multiple frames
        // of animation. So it will look silly until we animate it
        // In chapter 6.

        setBitmapName("player");

        // X and y locations from constructor parameters

        setWorldLocation(worldStartX, worldStartY, 0);

    }

    public void update(long fps, float gravity) {

    }
}

将下载包中drawable文件夹里的player.png图形添加到 Android Studio 的drawable文件夹中。这个图形是一个多帧的精灵表,所以在第六章平台游戏 – Bob, Beeps, 和 Bumps中进行动画处理之前,它不会很好地显示,但现在它可以作为一个占位符。

正如我们接下来将看到的,玩家看到的游戏世界的视图,将聚焦于 Bob,这应该是在你意料之中的。

通过视口看到的视图。

可以将视口视为跟随我们游戏动作的电影摄像机。它定义了要向玩家展示的游戏世界区域。通常,它会以 Bob 为中心。

它还通过确定哪些物体在玩家的视野内外,使我们的绘图方法更加高效。如果在一特定时刻它们并不相关,那么绘制或处理一堆敌人是毫无意义的。

通过实现第一阶段检测,即从需要检查碰撞的对象列表中移除屏幕外的对象,这将显著加快碰撞检测等任务的速度,而且这样做出奇地简单。

此外,我们的Viewport类将负责将游戏世界的坐标转换为屏幕上绘制的适当像素坐标。我们还将了解这个类是如何计算GameObject类在prepareBitmap方法中使用的pixelsPerMetre值的。

Viewport类确实是一个功能全面的东西。那么,让我们开始编程吧。

首先,我们将声明一大堆有用的变量。我们还有一个 Vector2Point5D,它将用于表示当前视口中焦点的世界上的任意点。然后,我们分别为 pixelsPerMetreXpixelsPerMetreY 分配了整数值。

注意

实际上,在这个实现中,pixelsPerMetrXpixelsPerMetreY 之间没有区别。但是,Viewport 类可以升级,以考虑基于屏幕尺寸而不是仅分辨率的不同设备宽高比。在这个实现中我们没有这样做。

接下来,我们简单地在两个轴上都有屏幕的分辨率:screenXResolutionscreenYResolution。然后我们有 screenCentreXscreenCentreY,它们基本上是前两个变量除以二以找到中间位置。

在我们声明的变量列表中,我们有 metresToShowXmetresToShowY,它们将是我们将压缩到视口中的米数。改变这些值将显示屏幕上更多或更少的游戏世界。

在这一点上,我们将声明的最后一个成员是 int numClipped。我们将使用它输出调试文本,以查看 Viewport 类在提高绘图、更新和多阶段碰撞检测的效率方面有何影响。

创建一个名为 Viewport 的新类,并声明我们刚刚讨论过的变量:

import android.graphics.Rect;

public class Viewport {
    private Vector2Point5D currentViewportWorldCentre;
    private Rect convertedRect;
    private int pixelsPerMetreX;
    private int pixelsPerMetreY;
    private int screenXResolution;
    private int screenYResolution;
    private int screenCentreX;
    private int screenCentreY;
    private int metresToShowX;
    private int metresToShowY;
    private int numClipped;

现在,让我们看看构造函数。构造函数只需要知道屏幕的分辨率。这是通过参数 xy 获取的,当然,我们分别将其分配给 screenXResolutionscreenYResolution

然后,如前所述,我们将这两个变量除以二,并将结果分别分配给 screenCentreXscreenCentreY

pixelsPerMetreXpixelsPerMetreY 是通过分别除以 32 和 18 来计算的,因此一个分辨率为 840 x 400 像素的设备将会有每米x/y的像素数为 32/22。现在,我们有变量表示当前设备上表示游戏世界一米的屏幕像素数量。在代码中我们会多次看到,这将非常有用。

我们实际上会绘制一个比这稍宽的区域,以确保屏幕边缘不会有难看的缝隙/线条,并将 34 分配给 metresToShowX,20 分配给 metresToShowY。现在,我们有变量表示我们每一帧将绘制多少游戏世界。

提示

一旦有了屏幕输出,你可以通过调整这些值来为玩家创建放大或缩小的体验。

在构造函数即将结束时,我们创建了一个名为 convertedRect 的新 Rect 对象,我们很快就会看到它的实际应用。我们在 currentViewportWorldCentre 上调用 new() 方法,所以它很快就能投入使用。

 Viewport(int x, int y){

        screenXResolution = x;
        screenYResolution = y;

        screenCentreX = screenXResolution / 2;
        screenCentreY = screenYResolution / 2;

        pixelsPerMetreX = screenXResolution / 32;
        pixelsPerMetreY = screenYResolution / 18;

        metresToShowX = 34;
        metresToShowY = 20;

        convertedRect = new Rect();
        currentViewportWorldCentre = new Vector2Point5D();

}

注意

如果这个项目中的某些截图看起来与您得到的结果略有不同,那是因为一些图片是使用不同的视口设置来突出游戏世界的不同方面。

我们为Viewport类编写的第一个方法是setWorldCentre()。它接收一个xy参数,并立即分配给currentWorldCentre。我们需要这个方法,因为玩家当然会在世界中移动,我们需要让Viewport类知道 Bob 的位置。同样,正如我们将在第八章,组合在一起中看到的,我们也会有不想让 Bob 成为关注焦点的情况。

void setWorldCentre(float x, float y){
  currentViewportWorldCentre.x  = x;
  currentViewportWorldCentre.y  = y;
}

现在,一些简单的获取器和设置器将在我们进行时非常有用。

public int getScreenWidth(){
  return  screenXResolution;
}

public int getScreenHeight(){
  return  screenYResolution;
}

public int getPixelsPerMetreX(){
  return  pixelsPerMetreX;
}

我们通过worldToScreen()方法实现了Viewport类的主要功能之一。顾名思义,这个方法是用来将当前可见视口中的所有对象的位置从世界坐标转换为可以实际绘制在屏幕上的像素坐标。它返回我们之前准备好的rectToDraw对象作为结果。

worldToScreen()方法就是这样工作的。它接收一个对象的xy世界位置以及该对象的宽度和高度。利用这些值,分别从当前屏幕的世界视口中心(xy)减去对象的世界坐标乘以每米的像素数。然后,对于对象的左和上坐标,从像素屏幕中心值中减去结果,对于下和右坐标,则加上。

这些值随后被包装进convertedRect的左、上、右和下值中,并返回给PlatformViewdraw方法。将worldToScreen方法添加到Viewport类中:


public Rect worldToScreen(
  float objectX, 
  float objectY, 
  float objectWidth, 
  float objectHeight){

   int left = (int) (screenCentreX -               
    ((currentViewportWorldCentre.x - objectX) 
    * pixelsPerMetreX));

    int top =  (int) (screenCentreY -         
    ((currentViewportWorldCentre.y - objectY) 
    * pixelsPerMetreY));

   int right = (int) (left + 
    (objectWidth * 
    pixelsPerMetreX));

  int bottom = (int) (top + 
    (objectHeight * 
    pixelsPerMetreY));

  convertedRect.set(left, top, right, bottom);

  return convertedRect;
}

现在,我们实现了Viewport类的第二个主要功能,即移除当前对我们没有兴趣的对象。我们称这个过程为剪辑,我们将要调用的方法是clipObjects()

再次,我们接收作为参数的物体的xywidthheight。测试首先假设我们想要剪辑当前对象,并将true分配给clipped

然后,四个嵌套的if语句测试对象的每一个点是否都在视口相关侧边的范围内。如果是,我们将clipped设置为false。我们设计的某些级别将包含超过一千个对象,但我们将会看到,在任何给定帧中,我们很少需要处理(更新、碰撞检测和绘制)超过四分之一的对象。输入clipObjects方法的代码:


public boolean clipObjects(float objectX, 
  float objectY, 
  float objectWidth, 
  float objectHeight) {

  boolean clipped = true;

   if (objectX - objectWidth < 
    currentViewportWorldCentre.x + (metresToShowX / 2)) {

    if (objectX + objectWidth > 
      currentViewportWorldCentre.x - (metresToShowX / 2)) {

      if (objectY - objectHeight <           
        currentViewportWorldCentre.y + 
        (metresToShowY / 2)) {

        if (objectY + objectHeight >       
          currentViewportWorldCentre.y - 
          (metresToShowY / 2)){

                 clipped = false;
        }     
      }

    }

  }

   // For debugging
   if(clipped){
       numClipped++;
   }

   return clipped;
}

现在,我们提供了对numClipped变量的访问权限,以便它可以每帧被读取并重置为零。

public int getNumClipped(){
  return numClipped;    
}

public void resetNumClipped(){
  numClipped = 0;
}

}// End of Viewport

让我们声明并初始化我们的Viewport对象。在PlatformView构造函数中初始化我们的Paint对象之后,添加以下代码。新代码在这里高亮显示:

  // Initialize our drawing objects
  ourHolder = getHolder();
  paint = new Paint();

 // Initialize the viewport
 vp = new Viewport(screenWidth, screenHeight);

}// End of constructor

现在,我们可以描述并定位游戏世界中的对象,并专注于我们感兴趣的世界精确部分。让我们看看我们实际上是如何将对象放入那个世界的,这样我们就可以像以前一样更新和绘制它们。我们还将探讨关卡的概念。

创建关卡

在这里,我们将了解如何构建我们的LevelManagerLevelData和我们第一个真正的关卡LevelCave

LevelManager类最终将需要我们InputController类的一个副本。因此,为了尽量遵循我们不需要删除任何代码的意图,我们将在LevelManager构造函数中包含一个InputController的参数。

让我们快速为我们的InputController类创建一个空白模板。按照通常的方式创建一个新类,并将其命名为InputController。添加以下代码:

public class InputController {
    InputController(int screenWidth, int screenHeight) {
    }
}

现在,让我们看看我们最初非常简单的LevelData类。创建一个新类,将其命名为LevelData,并添加此代码。在这个阶段,它仅包含一个用于StringsArrayList对象。

import java.util.ArrayList;

public class LevelData {
    ArrayList<String> tiles;

    // This class will evolve along with the project

    // Tile types
    // . = no tile
    // 1 = Grass

}

接下来,我们可以开始创建最终将成为我们第一个可玩关卡的代码。创建一个新类,将其命名为LevelCave,并添加此代码:

import java.util.ArrayList;

public class LevelCave extends LevelData{
    LevelCave() {
    tiles = new ArrayList<String>();
    this.tiles.add("p.............................................");
    this.tiles.add("..............................................");
    this.tiles.add(".....................111111...................");
    this.tiles.add("..............................................");
    this.tiles.add("............111111............................");
    this.tiles.add("..............................................");
    this.tiles.add(".........1111111..............................");
    this.tiles.add("..............................................");
    this.tiles.add("..............................................");
    this.tiles.add("..............................................");
    this.tiles.add("..............................11111111........");
    this.tiles.add("..............................................");
    }
}

提示

LevelCave文件中,p代表玩家的位置是任意的。只要它在里面,Player对象就会被初始化。玩家角色的实际生成位置由对loadLevel方法的调用决定,我们很快就会看到。我通常将代表玩家的p作为地图第一行第一个元素,这样就不太可能被遗忘。

现在,让我们谈谈这个关卡设计将如何工作。我们将在LevelCave类中的代码的tiles.add("..."部分输入字母数字字符。我们将根据要放入关卡的GameObject输入不同的字母数字字符。目前,我们只有一个p代表Player对象,一个1代表Grass对象,以及一个句点(.)代表一个游戏世界一平方米的空地。

提示

这意味着上一代码块中使用1字符定位Grass对象的方式可以完全按照你的喜好来安排。确实如此,每当我们查看LevelCave类的代码时,请随意即兴发挥和实验。

随着项目的进行,我们将添加超过二十个不同的GameObject子类。有些将像Grass一样是静止的,其他的将是具有思考能力的侵略性敌人。所有这些都可放置在我们的关卡设计中。

现在,我们可以实现一个类来管理我们的关卡。创建一个新的 Java 类,将其命名为LevelManager。随着我们逐步进行,输入LevelManager类的代码,一次讨论一个代码块。

首先,是一些导入指令。

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;
import java.util.ArrayList;

现在,构造函数是我们有一个String类型的level来保存关卡名称,mapWidthmapHeight以游戏世界米为单位存储当前关卡的宽度和高度,一个Player对象,因为我们知道我们总会有一个,以及一个名为playerIndexint类型。

不久,我们将拥有许多GameObject类的ArrayList对象,始终拥有Player对象的索引将非常方便。

接下来,我们有布尔值playing,因为我们需要知道游戏是在进行中还是暂停,以及一个名为gravity的浮点数。

提示

在这个项目的背景下,重力不会发挥其全部潜力,但可以轻松地操纵它,使不同级别的重力不同。这就是为什么它在LevelManager类中的原因。

最后,我们声明一个LevelData类型的对象,一个用于保存所有GameObject对象的ArrayList对象,一个用于保存玩家控制按钮表示的ArrayList对象,以及一个常规数组用于保存我们大部分需要的Bitmap对象。

public class LevelManager {

    private String level;
    int mapWidth;
    int mapHeight;

    Player player;
    int playerIndex;

    private boolean playing;
    float gravity;

    LevelData levelData;
    ArrayList<GameObject> gameObjects;

    ArrayList<Rect> currentButtons;
    Bitmap[] bitmapsArray;

然后,在构造函数中,我们检查签名并看到它接收一个Context对象,pixelsPerMetre(在Viewport类构造时确定),再次直接来自Viewport类的screenWidth,我们InputController类的一个副本,以及要加载的关卡名称。int参数pxpy是玩家的起始坐标。

我们将关卡参数赋值给我们的成员级别,然后切换以确定哪个类将是我们的当前关卡。当然,目前我们只有一个LevelCave

然后,我们初始化我们的gameObject ArrayListbitmapsArray。然后我们调用loadMapData(),这是我们很快会编写的一个方法。在此之后,我们将playing设置为true,最后我们有一个获取器方法来找出playing的状态。在LevelManager类中输入我们刚刚讨论的代码:

public LevelManager(Context context, 
    int pixelsPerMetre, int screenWidth, 
    InputController ic, 
    String level, 
    float px, float py) {

    this.level = level;

    switch (level) {
        case "LevelCave":
        levelData = new LevelCave();
        break;

        // We can add extra levels here

    }

    // To hold all our GameObjects
    gameObjects = new ArrayList<>();

    // To hold 1 of every Bitmap
    bitmapsArray = new Bitmap[25];

    // Load all the GameObjects and Bitmaps
    loadMapData(context, pixelsPerMetre, px, py);

    // Ready to play
    playing = true;
}

public boolean isPlaying() {
    return playing;
}

现在,我们有一个非常简单的方法,可以基于我们当前处理的GameObject类型获取任何Bitmap对象。这样,每个GameObject不必持有自己的Bitmap对象。例如,我们可以设计一个包含数百个Grass对象的关卡。这很容易就会用尽即使是现代平板电脑的内存。

我们的getBitmap方法接收一个int类型的索引值,并返回一个Bitmap对象。我们将在下一个方法中看到如何访问index的适当值:

    // Each index Corresponds to a bitmap
    public Bitmap getBitmap(char blockType) {

        int index;
        switch (blockType) {
            case '.':
                index = 0;
                break;

            case '1':
                index = 1;
                break;

            case 'p':
                index = 2;
                break;

            default:
                index = 0;
                break;
        }// End switch

        return bitmapsArray[index];

 }// End getBitmap

下一个方法将使我们能够获得调用getBitmap方法的index。只要char案例与我们创建的各种GameObject子类持有的type值相对应,并且此方法返回的索引与bitmapsArray中适当Bitmap的索引相匹配,我们就只需要每个Bitmap对象的一个副本。

// This method allows each GameObject which 'knows'
// its type to get the correct index to its Bitmap
// in the Bitmap array.
public int getBitmapIndex(char blockType) {

    int index;
        switch (blockType) {
            case '.':
                index = 0;
                break;

            case '1':
                index = 1;
                break;

            case 'p':
                index = 2;
                break;

            default:
                index = 0;
                break;

        }// End switch

        return index;
    }// End getBitmapIndex()

现在,我们使用LevelManager类进行实际的工作,并从我们的设计中加载关卡。该方法需要pixelsPerMetrePlayer对象的坐标才能执行其工作。由于这是一个大方法,解释和代码已经被分成几个部分。

在这一部分,我们简单声明一个名为indexint类型,并将其设置为-1。当我们遍历我们的关卡设计时,它将帮助我们跟踪当前的位置。

然后,我们使用ArrayList的大小和ArrayList的第一个元素的长度分别计算地图的高度和宽度。

// For now we just load all the grass tiles
// and the player. Soon we will have many GameObjects
private void loadMapData(Context context, 
  int pixelsPerMetre, 
  float px, float py) {

   char c;

   //Keep track of where we load our game objects
   int currentIndex = -1;

   // how wide and high is the map? Viewport needs to know
   mapHeight = levelData.tiles.size();
   mapWidth = levelData.tiles.get(0).length();

我们从ArrayList对象的第一个字符串的第一个元素开始进入嵌套的for循环。我们在移动到第二个字符串之前,从左到右遍历第一个字符串。

我们检查当前位置是否除了空格(.)之外还有其他对象,如果有,我们就进入一个开关块,在指定位置创建适当的对象。

如果我们遇到一个1,那么我们向ArrayList中添加一个新的Grass对象;如果遇到一个p,我们就在传递到LevelManager类构造函数的位置初始化Player对象。当一个新Player对象被创建时,我们还会初始化我们的playerIndexplayer对象,以备将来使用。

for (int i = 0; i < levelData.tiles.size(); i++) {
            for (int j = 0; j < 
                    levelData.tiles.get(i).length(); j++) {

                c = levelData.tiles.get(i).charAt(j);

                    // Don't want to load the empty spaces
                    if (c != '.'){ 
                      currentIndex++;
                      switch (c) {

                        case '1':
                            // Add grass to the gameObjects
                            gameObjects.add(new Grass(j, i, c));
                            break;

                        case 'p':
                            // Add a player to the gameObjects
                            gameObjects.add(new Player
                                (context, px, py, 
                                 pixelsPerMetre));

                            // We want the index of the player
                            playerIndex = currentIndex;
                            // We want a reference to the player
                            player = (Player)           
                            gameObjects.get(playerIndex);

                            break;

            }// End switch

如果一个新的对象被添加到gameObjects ArrayList中,我们需要检查相应的位图是否已经被添加到bitmapsArray中。如果没有,我们使用当前考虑的GameObject类的prepareBitmap方法添加一个。以下是执行此检查并在必要时准备位图的代码:

// If the bitmap isn't prepared yet
if (bitmapsArray[getBitmapIndex(c)] == null) {

    // Prepare it now and put it in the bitmapsArrayList
    bitmapsArray[getBitmapIndex(c)] =
        gameObjects.get(currentIndex).
        prepareBitmap(context,                                                
        gameObjects.get(currentIndex).                                                        
        getBitmapName(),                                     
        pixelsPerMetre);

}// End if

}// End if (c != '.'){ 

}// End for j

}// End for i

}// End loadMapData()

}// End LevelManager

回到PlatformView类中,为了使用我们的所有关卡对象,我们在PlatformView构造函数中初始化Viewport类之后立即调用loadLevel()。新代码已经突出显示,并提供现有代码作为上下文:

  // Initialize the viewport
  vp = new Viewport(screenWidth, screenHeight);

 // Load the first level
 loadLevel("LevelCave", 15, 2);

}

当然,现在我们需要在PlatformView类中实现loadLevel方法。

loadLevel方法需要知道要加载哪个关卡,这样LevelManager构造函数中的switch语句才能执行其工作,它还需要坐标来生成我们的英雄 Bob。

我们通过从vp获取的视口数据以及我们刚刚讨论的关卡/玩家数据调用其构造函数来初始化我们的LevelManager对象。

我们接着创建一个新的InputController类,同样从vp中传递一些数据。在第六章,Bob, Beeps, 和 Bumps中构建我们的InputController类时,我们会确切地看到如何使用这些数据。最后,我们调用vp.setWorldCentre(),并将玩家的位置坐标传递给它,这样屏幕就居中了 Bob。

public void loadLevel(String level, float px, float py) {

    lm = null;

    // Create a new LevelManager
    // Pass in a Context, screen details, level name 
    // and player location
    lm = new LevelManager(context, 
        vp.getPixelsPerMetreX(), 
        vp.getScreenWidth(), 
        ic, level, px, py);

    ic = new InputController(vp.getScreenWidth(),       
        vp.getScreenHeight());

    // Set the players location as the world centre     
    vp.setWorldCentre(lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().x,
        lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().y);
    }

我们可以在我们的update方法中添加一些代码,这将首先利用我们新的Viewport类的主要功能。

增强的更新方法

最后,我们可以使用我们的ArrayList游戏对象和Viewport功能来完善我们的增强型update方法。在下面的代码中,我们仅使用增强的for循环遍历每个GameObject。我们检查它是否isActive(),然后通过if语句将对象的位置和尺寸传递给clipObjects()。如果clipObjects()返回false,则对象没有被剪辑,并通过调用go.setVisible(true)将对象标记为可见。否则,通过调用go.setVisible(false)将其标记为不可见。这是此刻更新任何对象的唯一方面。我们将在本章末尾运行游戏时看到,它已经很有用了。在update方法中输入新代码:

for (GameObject go : lm.gameObjects) {
        if (go.isActive()) {
            // Clip anything off-screen
            if (!vp.clipObjects(go.getWorldLocation().x,                                
                go.getWorldLocation().y, 
                go.getWidth(), 
                go.getHeight())) {

                // Set visible flag to true
                go.setVisible(true);

            } else {
                // Set visible flag to false
                go.setVisible(false);
                // Now draw() can ignore them

            }
        }

    }
}

增强的绘制方法

现在,我们可以更精确地确定我们需要绘制哪些对象。首先,我们声明并初始化一个新的名为toScreen2dRect对象。

然后,我们从最低层开始,针对每一层遍历一次gameObjects ArrayList。在这个阶段,这并不是严格必要的,因为我们的所有对象默认都当前在零层。在项目结束前,我们将添加位于-1 层和 1 层的对象,如果我们能够避免,则不想重写代码。

接下来,我们检查对象是否可见并且是否在当前层。如果是,我们将当前对象的位置和尺寸传递给worldToScreen方法,该方法将结果返回给我们之前准备的toScreen2d Rect对象。然后,我们使用bitmapArray调用drawBitmap()以提供适当的位图,并传入toScreen2d的坐标。更新突出显示的draw方法:

private void draw() {

    if (ourHolder.getSurface().isValid()) {
        //First we lock the area of memory we will be drawing to
        canvas = ourHolder.lockCanvas();

        // Rub out the last frame with arbitrary color
        paint.setColor(Color.argb(255, 0, 0, 255));
        canvas.drawColor(Color.argb(255, 0, 0, 255));
 // Draw all the GameObjects
 Rect toScreen2d = new Rect();

 // Draw a layer at a time
 for (int layer = -1; layer <= 1; layer++){
 for (GameObject go : lm.gameObjects) {
 //Only draw if visible and this layer
 if (go.isVisible() && go.getWorldLocation().z 
 == layer) { 

 toScreen2d.set(vp.worldToScreen
 (go.getWorldLocation().x,
 go.getWorldLocation().y,
 go.getWidth(),
 go.getHeight()));

 // Draw the appropriate bitmap
 canvas.drawBitmap(
 lm.bitmapsArray
 [lm.getBitmapIndex(go.getType())],
 toScreen2d.left,
 toScreen2d.top, paint);
 }
 }
}

现在,仍然在draw方法中,我们将调试信息打印到屏幕上,包括我们的gameObjects ArrayList的大小与这一帧中被剪辑的对象数量比较。

然后,我们通过常规调用unlockCanvasAndPost()来完成draw方法。注意,在if(debugging)块的末尾,我们调用vp.resetNumClippednumClipped变量重置为零,为下一帧做准备。在draw方法中的上一代码块之后直接添加此代码:

// Text for debugging
if (debugging) {
 paint.setTextSize(16);
 paint.setTextAlign(Paint.Align.LEFT);
 paint.setColor(Color.argb(255, 255, 255, 255));
 canvas.drawText("fps:" + fps, 10, 60, paint);

 canvas.drawText("num objects:" + 
 lm.gameObjects.size(), 10, 80, paint);

 canvas.drawText("num clipped:" + 
 vp.getNumClipped(), 10, 100, paint);

 canvas.drawText("playerX:" + 
 lm.gameObjects.get(lm.playerIndex).
 getWorldLocation().x,
 10, 120, paint);

 canvas.drawText("playerY:" + 
 lm.gameObjects.get(lm.playerIndex).
 getWorldLocation().y, 
 10, 140, paint);

 //for reset the number of clipped objects each frame
 vp.resetNumClipped();

}// End if(debugging)

// Unlock and draw the scene
ourHolder.unlockCanvasAndPost(canvas);

}// End (ourHolder.getSurface().isValid())
}// End draw()

在这个项目中,我们第一次实际运行游戏并看到了一些结果:

增强的绘制方法

注意图像中我们LevelCave设计中草地的精确布局。您还可以看到我们压缩的 Bob 精灵表和有 28 个对象,但其中 10 个已被剪辑。随着我们的关卡变得越来越大,剪辑与未剪辑的比例将大幅增加,绝大多数对象将被剪辑。

总结

在本章中,我们已经介绍了许多内容,现在拥有了一个完善的游戏引擎。

由于我们已经完成了大部分设置工作,从现在开始,我们添加的大部分代码也将有可见(或可听)的结果,并将更加令人满意,因为我们将能够定期运行我们的游戏以查看改进。

在下一章中,我们将添加声音效果和输入检测,从而让 Bob 栩栩如生。然后,我们将会看到他的世界可能多么危险,并将迅速添加碰撞检测,使他能够站在平台上。

第六章:平台游戏 - Bob、哔哔声和碰撞

我们的 basic 游戏引擎设置好后,我们就可以开始快速进展了。在本章中,我们将快速添加一个SoundManager类,我们可以在任何需要的时候用它来发出声音。之后,我们将为 Bob 添加一些实质性的内容,并在Player类中实现我们所需的核心功能。然后,我们可以处理多阶段碰撞检测的第二阶段(剪辑后),让 Bob 具备站在平台上的有用技能。

在我们完成这项重大任务后,我们将通过实现InputController类将 Bob 的控制权交给玩家。Bob 终于能够到处跑和跳了。在本章结束时,我们将为 Bob 的精灵表制作动画,让他看起来真的在跑,而不是到处滑动。

SoundManager 类

在接下来的几章中,我们将为各种事件添加声音效果。有时这些声音将直接在主PlatformView类中触发,但其他时候,它们需要在代码更远的角落中触发,比如InputController类,甚至是在GameObject类内部。我们将快速制作一个简单的SoundManager类,当需要哔哔声时,可以传递并按需使用。

创建一个新的 Java 类,将其命名为SoundManager。这个类有三个主要部分。在第一部分,我们简单地声明一个SoundPool对象和一些int变量,以保存每个声音效果的引用。以下是第一部分代码,声明和成员:

import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.media.AudioManager;
import android.media.SoundPool;
import android.util.Log;

import java.io.IOException;

public class SoundManager {
    private SoundPool soundPool;
    int shoot = -1;
    int jump = -1;
    int teleport = -1;
    int coin_pickup = -1;
    int gun_upgrade = -1;
    int player_burn = -1;
    int ricochet = -1;
    int hit_guard = -1;
    int explode = -1;
    int extra_life = -1;

类的第二部分是loadSound方法,它毫不意外地将所有声音加载到内存中,准备播放。我们在PlatformView构造函数中初始化一个SoundManager对象后,将调用这个方法。接下来输入这段代码:

public void loadSound(Context context){
    soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC,0);
    try{
        //Create objects of the 2 required classes
        AssetManager assetManager = context.getAssets();
        AssetFileDescriptor descriptor;

        //create our fx
        descriptor = assetManager.openFd("shoot.ogg");
        shoot = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("jump.ogg");
        jump = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("teleport.ogg");
        teleport = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("coin_pickup.ogg");
        coin_pickup = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("gun_upgrade.ogg");
        gun_upgrade = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("player_burn.ogg");
        player_burn = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("ricochet.ogg");
        ricochet = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("hit_guard.ogg");
        hit_guard = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("explode.ogg");
        explode = soundPool.load(descriptor, 0);

        descriptor = assetManager.openFd("extra_life.ogg");
        extra_life = soundPool.load(descriptor, 0);

    }catch(IOException e){
        //Print an error message to the console
        Log.e("error", "failed to load sound files");

    }

}

最后,对于我们的SoundManager类,我们需要能够播放任何我们喜欢的声音。这个playSound方法只是简单地通过一个作为参数传递的字符串来切换。当我们有一个SoundManager对象时,我们可以通过一个合适的字符串参数简单地调用playSound()

public void playSound(String sound){
        switch (sound){
            case "shoot":
                soundPool.play(shoot, 1, 1, 0, 0, 1);
                break;

            case "jump":
                soundPool.play(jump, 1, 1, 0, 0, 1);
                break;

            case "teleport":
                soundPool.play(teleport, 1, 1, 0, 0, 1);
                break;

            case "coin_pickup":
                soundPool.play(coin_pickup, 1, 1, 0, 0, 1);
                break;

            case "gun_upgrade":
                soundPool.play(gun_upgrade, 1, 1, 0, 0, 1);
                break;

            case "player_burn":
                soundPool.play(player_burn, 1, 1, 0, 0, 1);
                break;

            case "ricochet":
                soundPool.play(ricochet, 1, 1, 0, 0, 1);
                break;

            case "hit_guard":
                soundPool.play(hit_guard, 1, 1, 0, 0, 1);
                break;

            case "explode":
                soundPool.play(explode, 1, 1, 0, 0, 1);
                break;

            case "extra_life":
                soundPool.play(extra_life, 1, 1, 0, 0, 1);
                break;

        }

    }
}// End SoundManager

在上一章你的新游戏引擎类之后,PlatformView类声明后声明一个类型为SoundManager的新对象。

// Our new engine classes
private LevelManager lm;
private Viewport vp;
InputController ic;
SoundManager sm;

接下来,在PlatformView构造函数中初始化SoundManager对象,并调用loadSound(),如下所示:

// Initialize the viewport
vp = new Viewport(screenWidth, screenHeight);

sm = new SoundManager();
sm.loadSound(context);

loadLevel("LevelCave", 15, 2);

你可以使用 BFXR 创建所有自己的声音,或者直接从Chapter6/assets文件夹复制我的。将所有声音复制到你的 Android Studio 项目的assets文件夹中。如果还不存在,请在项目的src/main文件夹中创建一个assets文件夹以实现这一点。

现在,我们可以在任何地方播放声音效果。是时候让我们的英雄 Bob 活灵活现了。

介绍 Bob

在这里,我们可以为你的Player类增加一些实质性的内容。不过,这不会是我们最后一次回顾Player类。现在,我们将添加必要的功能,让 Bob 能够移动。完成这一步后,我们将会添加代码,允许玩家使用即将到来的碰撞检测代码和Animation类。

首先,我们需要向Player类中添加一些成员。Player类需要知道它能移动多快,玩家何时按下左右控制键,以及它是否在掉落或跳跃。此外,Player类还需要知道它已经跳跃了多长时间,以及它应该跳跃多久。

下一个代码块为我们提供了监控所有这些事物的变量。我们很快就会看到,如何使用它们让 Bob 做出我们想要的行为。

现在,我们知道这些变量是干什么用的了。我们可以在类声明后直接添加这段代码,如下所示:

public class Player extends GameObject {

 final float MAX_X_VELOCITY = 10;
 boolean isPressingRight = false;
 boolean isPressingLeft = false;

 public boolean isFalling;
 private boolean isJumping;
 private long jumpTime;
 private long maxJumpTime = 700;// jump 7 10ths of second

此外,还有一些其他与移动相关的条件我们需要跟踪,但它们在其他类中也会很有用。因此,我们将它们作为成员添加到GameObject类中。我们将跟踪当前的水平速度和垂直速度,对象面向的方向,以及以下变量来确定对象是否可以移动。

private float xVelocity;
private float yVelocity;
final int LEFT = -1;
final int RIGHT = 1;
private int facing;
private boolean moves = false;

现在,在GameObject类中,我们将添加一个move方法。这个方法简单检查下 x 轴或 y 轴上的速度是否为零,如果不是,它就会通过改变对象的worldLocation来移动对象。这个方法使用速度(xVelocityyVelocity)除以当前的每秒帧数来计算每帧移动的距离。这样可以确保无论当前的每秒帧数是多少,移动都是完全正确的。无论我们的游戏运行是否流畅,或者有所波动,或者安卓设备中的 CPU 性能强大与否,都没有关系。我们很快就会在Player类的update方法中调用这个move方法。在项目的后期,我们也会从其他类中调用它。

void move(long fps){
        if(xVelocity != 0) {
            this.worldLocation.x += xVelocity / fps;
        }

        if(yVelocity != 0) {
            this.worldLocation.y += yVelocity / fps;
        }
    }

接下来,在GameObject类中,我们为之前添加的新变量准备了一堆 getter 和 setter 方法。唯一需要注意的是,两个速度变量(setxVelocitysetyVelocity)的 setter 在真正赋值之前会检查if(moves)

public int getFacing() {
  return facing;
}

public void setFacing(int facing) {
  this.facing = facing;
}

public float getxVelocity() {
  return xVelocity;
}

public void setxVelocity(float xVelocity) {
  // Only allow for objects that can move
  if(moves) {
    this.xVelocity = xVelocity;
  }
}

public float getyVelocity() {
  return yVelocity;
}

public void setyVelocity(float yVelocity) {
  // Only allow for objects that can move
  if(moves) {
    this.yVelocity = yVelocity;
  }
}

public boolean isMoves() {
  return moves;
}

public void setMoves(boolean moves) {
  this.moves = moves;
}

public void setActive(boolean active) {
  this.active = active;
}

现在,回到Player类的构造函数中,我们可以使用其中一些新方法在对象创建时进行设置。在Player构造函数中添加高亮显示的代码。

setHeight(HEIGHT); // 2 metre tall
setWidth(WIDTH); // 1 metre wide

// Standing still to start with
setxVelocity(0);
setyVelocity(0);
setFacing(LEFT);
isFalling = false;

// Now for the player's other attributes
// Our game engine will use these
setMoves(true);
setActive(true);
setVisible(true);
//...

最后,我们可以在Player类的update方法中实际使用所有这些新代码。

首先,我们处理当isPressingRightisPressingLeft为真时会发生什么。当然,我们还需要能够通过屏幕触摸来设置这些变量。很简单,下一个代码块如果isPressingRight为真,将水平速度设置为MAX_X_VELOCITY;如果isPressingLeft为真,则设置为-MAX_X_VELOCITY。如果都不为真,则将水平速度设置为零,即静止不动。

public void update(long fps, float gravity) {
 if (isPressingRight) {
 this.setxVelocity(MAX_X_VELOCITY);
 } else if (isPressingLeft) {
 this.setxVelocity(-MAX_X_VELOCITY);
 } else {
 this.setxVelocity(0);
 }

接下来,我们检查玩家移动的方向,并调用setFacing(),参数为RIGHTLEFT

//which way is player facing?
if (this.getxVelocity() > 0) {
  //facing right
  setFacing(RIGHT);
} else if (this.getxVelocity() < 0) {
  //facing left
  setFacing(LEFT);
}//if 0 then unchanged

现在,我们可以处理跳跃。当玩家按下跳跃按钮时,如果成功,isJumping将被设置为真,jumpTime将被设置为当前系统时间。这样,我们就可以在每一帧进入if(isJumping)块,测试鲍勃已经跳跃了多长时间,并且如果他没有超过maxJumpTime,就会采取两个可能动作之一。

动作一是:如果我们还没有跳到一半,y速度设置为-gravity(向上)。动作二是:如果鲍勃跳过一半了,他的y速度设置为gravity(向下)。

当超过maxJumpTime时,isJumping会被重新设置为假,直到下一次玩家点击跳跃按钮。以下代码中的最后一个else子句在isJumping为假时执行,并将玩家的y速度设置为gravity。注意,还有一行代码将isFalling设置为true。正如我们将要看到的,这个变量用于控制玩家初次尝试跳跃时以及我们碰撞检测代码部分会发生什么。它基本上阻止了玩家在空中跳跃。

// Jumping and gravity
if (isJumping) {
  long timeJumping = System.currentTimeMillis() - jumpTime;
  if (timeJumping < maxJumpTime) {
    if (timeJumping < maxJumpTime / 2) {
      this.setyVelocity(-gravity);//on the way up
       } else if (timeJumping > maxJumpTime / 2) {
          this.setyVelocity(gravity);//going down
       }
  } else {
    isJumping = false;
  }
} else {
      this.setyVelocity(gravity);
      // Read Me!
      // Remove this next line to make the game easier
      // it means the long jumps are less punishing
      // because the player can take off just after the platform
      // They will also be able to cheat by jumping in thin air
      isFalling = true;
}

在处理完跳跃之后,我们立即调用move()来更新xy坐标,如果它们有变化的话。

 // Let's go!
 this.move(fps);
}// end update()

这有点复杂,但除了实际控制之外,它几乎包含了我们让玩家移动所需的一切。我们只需要从我们PlatformView类的update方法中每一帧调用一次update()方法,我们的玩家角色就会活跃起来。

PlatformView类的update方法中,像这样添加以下高亮代码:

// Set visible flag to true
go.setVisible(true);

if (lm.isPlaying()) {
 // Run any un-clipped updates
 go.update(fps, lm.gravity);
}

} else {
  // Set visible flag to false
  //...

接下来,我们可以看到正在发生什么。在PlatformView类的draw方法的if(debugging)块中添加一些更多的文本输出。像这里显示的那样添加新的高亮代码:

canvas.drawText("playerY:" +   lm.gameObjects.get(lm.playerIndex).getWorldLocation().y,
  10, 140, paint);

canvas.drawText("Gravity:" + 
 lm.gravity, 10, 160, paint);

canvas.drawText("X velocity:" +   lm.gameObjects.get(lm.playerIndex).getxVelocity(), 
 10, 180, paint);

canvas.drawText("Y velocity:" +   lm.gameObjects.get(lm.playerIndex).getyVelocity(), 
 10, 200, paint);

//for reset the number of clipped objects each frame

现在为何不运行游戏呢?你可能已经注意到下一个问题是玩家不见了。

介绍鲍勃

这是因为我们现在有了重力,而且调用update()的线程在应用程序启动时立即运行,甚至在我们完成关卡和玩家角色的设置之前。

我们需要做两件事。首先,我们只想在LevelManager类完成工作后运行update()。其次,我们需要在每一帧更新Viewport类的焦点,这样即使玩家正在掉入死亡(他经常这样做),屏幕也会以他为中心,这样我们就可以看到他的终结。

让我们从暂停模式开始游戏,这样玩家就不会错过。首先,我们将在LevelManager类中添加一个方法,该方法将切换游戏状态在玩与不玩之间。一个合适的名字可能是switchPlayingStatus()。按照如下所示,将新方法添加到LevelManager中:

public void switchPlayingStatus() {
        playing = !playing;
        if (playing) {
            gravity = 6;
        } else {
            gravity = 0;
        }
    }

现在,删除或注释掉LevelManager构造函数中设置playingtrue的那行代码。很快,这将会通过屏幕触摸和我们刚刚编写的方法来处理:

// Load all the GameObjects and Bitmaps
loadMapData(context, pixelsPerMetre, px, py);

//playing = true;
//..

我们将编写一点临时代码,只是一点点。我们已经知道,我们最终会将监控玩家输入的责任委托给我们的新InputController类。在重写的onTouchEvent方法中,这点代码是值得的,因为我们可以立即使用暂停功能。

这段代码将在每次触摸屏幕时使用我们刚刚编写的方法切换游戏状态。将重写的方法添加到PlatformView类中。我们将在本章稍后替换其中一些代码。

@Override
public boolean onTouchEvent(MotionEvent motionEvent) {
  switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
    case MotionEvent.ACTION_DOWN:
         lm.switchPlayingStatus();
         break;
   }
return true;
}

你可以在Player类中将isPressingRight设置为true,然后运行游戏并点击屏幕。然后我们会看到玩家像幽灵一样从底部掉落,同时向屏幕右侧移动:

介绍鲍勃

现在,让我们每帧更新视口,使其保持在玩家中心。将这段高亮代码添加到PlatformView类中的update方法的最后:

if (lm.isPlaying()) {
    //Reset the players location as the centre of the viewport
    vp.setWorldCentre(lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().x,
        lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().y);}
}// End of update()

如果你现在运行游戏,尽管玩家仍然向右掉入厄运,但至少屏幕会聚焦在他身上,让我们看到这一过程。

我们将处理持续下落的问题。

多阶段碰撞检测

我们已经看到,我们的玩家角色会简单地穿过世界,落入虚无。当然,我们需要玩家能够站在平台上。以下是我们要采取的措施。

我们将为每个重要的对象提供一个碰撞箱,这样我们就可以在Player类中提供测试碰撞箱是否与玩家接触的方法。每帧一次,我们将发送所有未被视口剪辑的碰撞箱到这个新方法,在这里可以测试是否发生碰撞。

我们这样做有两个主要原因。首先,通过仅发送未剪辑的碰撞箱进行碰撞测试,我们大大减少了检查的数量,如第三章,Tappy Defender – Taking Flight中的“碰撞检测”部分所述。其次,通过在Player类中处理检查,我们可以给玩家多个不同的碰撞箱,并根据哪个被击中稍微有不同的反应。

让我们创建一个自己的碰撞箱类,这样我们可以按照自己的需求来定义它。它需要使用浮点坐标,还需要一个intersects方法和一些获取器和设置器。创建一个新类,将其命名为RectHitbox

在这里,我们看到RectHitbox仅有一系列的自我解释的获取器和设置器。它还具有intersects方法,如果传递给它的RectHitbox与自身相交,则返回true。关于intersects()代码如何工作的解释,请参见第三章,Tappy Defender – Taking Flight。在新的类中输入以下代码:

public class RectHitbox {
    float top;
    float left;
    float bottom;
    float right;
    float height;

    boolean intersects(RectHitbox rectHitbox){
        boolean hit = false;

        if(this.right > rectHitbox.left
                && this.left < rectHitbox.right ){
            // Intersecting on x axis

            if(this.top < rectHitbox.bottom
                    && this.bottom > rectHitbox.top ){
                // Intersecting on y as well
                // Collision
                hit = true;
            }
        }

        return hit;
    }

    public void setTop(float top) {
        this.top = top;
    }

    public float getLeft() {
        return left;
    }

    public void setLeft(float left) {
        this.left = left;
    }

    public void setBottom(float bottom) {
        this.bottom = bottom;
    }

    public float getRight() {
        return right;
    }

    public void setRight(float right) {
        this.right = right;
    }

    public float getHeight() {
        return height;
    }

    public void setHeight(float height) {
        this.height = height;
    }
}

现在,我们可以将RectHitbox类作为GameObject的一个成员添加。在类声明后直接添加它。

private RectHitbox rectHitbox = new RectHitbox();

然后,我们添加一个方法来初始化碰撞箱,以及一个方法,以便在我们需要时获取它的副本。将这些两个方法添加到GameObject中:

public void setRectHitbox() {
   rectHitbox.setTop(worldLocation.y);
   rectHitbox.setLeft(worldLocation.x);
   rectHitbox.setBottom(worldLocation.y + height);
   rectHitbox.setRight(worldLocation.x + width);
}

RectHitbox getHitbox(){
  return rectHitbox;
}

现在,对于我们的Grass对象,我们添加一个对setRectHitbox()的调用,然后我们就可以开始与之碰撞了。在Grass类的构造函数的最后,添加这一行高亮代码。调用setRectHitbox()需要在setWorldLocation()之后进行,否则碰撞箱将不会围绕草地块。

// Where does the tile start
// X and y locations from constructor parameters
setWorldLocation(worldStartX, worldStartY, 0);
setRectHitbox();
}// End of Grass constructor

在我们开始理解进行碰撞检测的代码之前,需要让Player类拥有自己的碰撞箱集合。我们需要了解以下关于玩家角色的信息:

  • 当头部撞到它上方的物体时

  • 当脚部落在下方的平台上时

  • 当玩家从两侧走进某物时

为此,我们将创建四个碰撞箱;一个用于头部,一个用于脚部,还有两个用于左右两侧。由于它们是玩家独有的,我们将在Player类中创建碰撞箱。

Player类声明后立即声明四个碰撞箱作为成员:

RectHitbox rectHitboxFeet;
RectHitbox rectHitboxHead;
RectHitbox rectHitboxLeft;
RectHitbox rectHitboxRight;

在构造函数中,我们调用新的RectHitbox()来准备它们。注意我们还没有给碰撞箱赋值。我们很快就会看到如何操作。在Player构造函数的最后,像这样添加四个对new()的调用:

rectHitboxFeet = new RectHitbox();
rectHitboxHead = new RectHitbox();
rectHitboxLeft = new RectHitbox();
rectHitboxRight = new RectHitbox();

我们将看到如何正确初始化它们。下面代码中的碰撞箱值是基于实际角色形状在表示每个角色帧的矩形中所占空间手动估算的。如果你使用不同的角色图形,你可能需要调整你使用的精确值。

图表显示了每个碰撞箱将定位的大致图形表示位置。左侧和右侧碰撞箱看起来距离较远,是因为动画的不同帧比这一帧稍微宽一些。这是一个折中方案。

多阶段碰撞检测

代码必须在Player类中的update方法内调用move()之后的位置。这样,每次玩家位置改变时都会更新碰撞箱。在显示的确切位置添加高亮代码,这样我们就更接近能够开始碰撞到各种东西了。

// Let's go!
this.move(fps);

// Update all the hitboxes to the new location
// Get the current world location of the player
// and save them as local variables we will use next
Vector2Point5D location = getWorldLocation();
float lx = location.x;
float ly = location.y;

//update the player feet hitbox
rectHitboxFeet.top = ly + getHeight() * .95f;
rectHitboxFeet.left = lx + getWidth() * .2f;
rectHitboxFeet.bottom = ly + getHeight() * .98f;
rectHitboxFeet.right = lx + getWidth() * .8f;

// Update player head hitbox
rectHitboxHead.top = ly;
rectHitboxHead.left = lx + getWidth() * .4f;
rectHitboxHead.bottom = ly + getHeight() * .2f;
rectHitboxHead.right = lx + getWidth() * .6f;

// Update player left hitbox
rectHitboxLeft.top = ly + getHeight() * .2f;
rectHitboxLeft.left = lx + getWidth() * .2f;
rectHitboxLeft.bottom = ly + getHeight() * .8f;
rectHitboxLeft.right = lx + getWidth() * .3f;

// Update player right hitbox
rectHitboxRight.top = ly + getHeight() * .2f;
rectHitboxRight.left = lx + getWidth() * .8f;
rectHitboxRight.bottom = ly + getHeight() * .8f;
rectHitboxRight.right = lx + getWidth() * .7f;

}// End update()

在下一阶段,我们可以检测到一些碰撞并对它们做出反应。仅涉及玩家的碰撞,比如跌落、撞头或者试图穿墙,都将在下一个方法中直接处理,该方法位于Player类中。请注意,该方法还返回一个int值来表示是否发生碰撞以及碰撞发生在玩家的哪个部位,以便处理与其他物体(如拾取物或火坑)的碰撞。

新的checkCollisions方法接收一个RectHitbox作为参数。这将是我们当前正在检查碰撞的任何对象的RectHitbox。将checkCollisions方法添加到Player类中。

public int checkCollisions(RectHitbox rectHitbox) {
    int collided = 0;// No collision

    // The left
    if (this.rectHitboxLeft.intersects(rectHitbox)) {
        // Left has collided
        // Move player just to right of current hitbox
        this.setWorldLocationX(rectHitbox.right - getWidth() * .2f);
        collided = 1;
    }

    // The right
    if (this.rectHitboxRight.intersects(rectHitbox)) {
        // Right has collided
        // Move player just to left of current hitbox
        this.setWorldLocationX(rectHitbox.left - getWidth() * .8f);
        collided = 1;
    }

    // The feet
    if (this.rectHitboxFeet.intersects(rectHitbox)) {
        // Feet have collided
        // Move feet to just above current hitbox
        this.setWorldLocationY(rectHitbox.top - getHeight());
        collided = 2;
    }

    // Now the head
    if (this.rectHitboxHead.intersects(rectHitbox)) {
        // Head has collided. Ouch!
        // Move head to just below current hitbox bottom
        this.setWorldLocationY(rectHitbox.bottom);
        collided = 3;
    }

    return collided;
}

如前述代码所示,我们需要向GameObject类中添加一些 setter 方法,以便在检测到碰撞时可以更改xy世界坐标。向GameObject类添加以下两个方法:

public void setWorldLocationY(float y) {
  this.worldLocation.y = y;
}

public void setWorldLocationX(float x) {
  this.worldLocation.x = x;
}

最后一步是选择所有相关对象并进行碰撞测试。我们在PlatformView类的update方法中进行这项操作,然后根据哪个身体部位与哪种对象类型发生碰撞来进一步采取行动。由于我们只有一个可能与草地平台发生碰撞的对象类型,因此我们的 switch 块最初只会有一个默认情况。请注意,当检测到脚部发生碰撞时,我们将isFalling变量设置为false,使玩家能够跳跃。在显示的位置输入高亮代码:

// Set visible flag to true
go.setVisible(true);

// check collisions with player
int hit = lm.player.checkCollisions(go.getHitbox());
if (hit > 0) {
 //collision! Now deal with different types
 switch (go.getType()) {

 default:// Probably a regular tile
 if (hit == 1) {// Left or right
 lm.player.setxVelocity(0);
 lm.player.setPressingRight(false);
 }

 if (hit == 2) {// Feet
 lm.player.isFalling = false;
 }

 break;
 }
}

注意事项

随着这个项目的进行,我们将更多地利用在hit中存储的值进行基于碰撞的决策。

让我们真正地控制玩家。

玩家输入

首先,在Player类中添加一些方法,我们的输入控制器将能够调用这些方法,然后操作Player类的update方法用来移动的变量。

我们已经玩过了isPressingRight变量,也有一个isPressingLeft变量。此外,我们希望能够跳跃。如果你查看Player类的update方法,我们已经有处理这些情况的代码了。我们只需要玩家能够通过触摸屏幕来启动这些动作。

我们之前的按钮布局设计和到目前为止编写的代码,暗示了一种向左走的方法,一种向右走的方法,以及一种跳跃的方法。

你还会注意到,我们将SoundManager的副本传递给startJump方法,这使得如果跳跃尝试成功,我们可以播放一个整洁的复古跳跃声音。

public void setPressingRight(boolean isPressingRight) {
        this.isPressingRight = isPressingRight;
    }

    public void setPressingLeft(boolean isPressingLeft) {
        this.isPressingLeft = isPressingLeft;
    }

    public void startJump(SoundManager sm) {
        if (!isFalling) {//can't jump if falling
            if (!isJumping) {//not already jumping
                isJumping = true;
                jumpTime = System.currentTimeMillis();
                sm.playSound("jump");
            }
        }
    }

现在,我们可以专注于InputController类。让我们从onTouchEvent方法中将控制权传递给我们的InputController类。在PlatformView类中更改onTouchEvent方法的代码如下:

@Override
    public boolean onTouchEvent(MotionEvent motionEvent) {
        if (lm != null) {
            ic.handleInput(motionEvent, lm, sm, vp);
        }
        //invalidate();
        return true;
    }

我们的新方法中有一个错误。这仅仅是因为我们调用了handleInput方法,但还没有实现它。我们现在就来做这件事。

注意

如果你好奇为什么需要检查lm != null,这是因为onTouchEvent方法是从 Android UI 线程触发的,不在我们的控制范围内。如果我们传入lm并尝试用它做事情,而它尚未初始化,游戏将会崩溃。

我们现在可以在InputController类中完成我们需要做的一切。现在打开这个类,我们将计划我们要做什么。

我们需要一个向左的按钮,一个向右的按钮,一个跳跃按钮,一个切换暂停的按钮,稍后我们还需要一个发射机枪的按钮。因此,我们确实需要突出屏幕的不同区域来代表这些任务。

为了实现这一点,我们将声明四个Rect对象,每个任务一个。然后在构造函数中,我们将通过基于玩家屏幕分辨率进行一些简单的计算来定义这四个Rect对象的点。

我们根据设备屏幕分辨率定义了一些方便的变量,buttonWidthbuttonHeightbuttonPadding,以帮助我们整齐地排列Rect坐标。输入以下成员和InputController构造函数,如下所示:

import android.graphics.Rect;
import android.view.MotionEvent;
import java.util.ArrayList;

public class InputController {

    Rect left;
    Rect right;
    Rect jump;
    Rect shoot;
    Rect pause;

    InputController(int screenWidth, int screenHeight) {

        //Configure the player buttons
        int buttonWidth = screenWidth / 8;
        int buttonHeight = screenHeight / 7;
        int buttonPadding = screenWidth / 80;

        left = new Rect(buttonPadding,
            screenHeight - buttonHeight - buttonPadding,
            buttonWidth,
            screenHeight - buttonPadding);

        right = new Rect(buttonWidth + buttonPadding,
            screenHeight - buttonHeight - buttonPadding,
            buttonWidth + buttonPadding + buttonWidth,
            screenHeight - buttonPadding);

        jump = new Rect(screenWidth - buttonWidth - buttonPadding,
            screenHeight - buttonHeight - buttonPadding -                           
            buttonHeight - buttonPadding,
            screenWidth - buttonPadding,
            screenHeight - buttonPadding - buttonHeight -                           
            buttonPadding);

        shoot = new Rect(screenWidth - buttonWidth - buttonPadding,
            screenHeight - buttonHeight - buttonPadding,
            screenWidth - buttonPadding,
            screenHeight - buttonPadding);

        pause = new Rect(screenWidth - buttonPadding -                          
            buttonWidth,
            buttonPadding,
            screenWidth - buttonPadding,
            buttonPadding + buttonHeight);

    }

我们将使用这四个Rect对象在屏幕上绘制按钮。draw方法将需要它们的副本。输入getButtons方法的代码以实现这一点:

public ArrayList getButtons(){
   //create an array of buttons for the draw method
   ArrayList<Rect> currentButtonList = new ArrayList<>();
   currentButtonList.add(left);
   currentButtonList.add(right);
   currentButtonList.add(jump);
   currentButtonList.add(shoot);
   currentButtonList.add(pause);
   return  currentButtonList;
}

我们现在可以处理实际的玩家输入。这个项目与上一个项目不同,因为有大量不同的玩家动作需要监控和响应,有时是同时进行的。正如你所期望的,Android API 具有使这尽可能简单的功能。

MotionEvent类中隐藏的数据比我们目前看到的要多。之前,我们只是检查了ACTION_DOWNACTION_UP事件。现在,我们需要更深入地挖掘以获取更多的事件数据。

为了记录和传递多个手指在屏幕上触摸、离开和移动的详细信息,MotionEvent 类将它们都存储在一个数组中。当玩家的第一个手指触摸屏幕时,详细信息、坐标等存储在位置零。后续动作随后存储在数组的后面。

与任何手指活动相关的数组中的位置并不一致。在某些情况下,例如检测特定的手势时,这可能是个问题,程序员需要捕获、记住并响应对应于 MotionEvent 类中保存的手指 ID。

幸运的是,在这种情况下,我们有明确定义的屏幕区域来表示我们的按钮,我们最多需要知道的是,玩家的手指是否在这些预定义的区域内按下或释放了屏幕。

我们只需通过调用 motionEvent.getPointerCount() 来找出导致事件的手指数量,进而得知它们存储在数组中的情况。然后,我们遍历这些事件,并提供一个 switch 代码块来处理它们,无论在屏幕的哪个区域发生了 ACTION_DOWNACTION_UP。只要我们能够检测到事件并对其作出响应,事件存储在数组的哪个位置都无关紧要。

在我们编写解决方案的代码之前,还需要了解的另外一点是,数组中后续的动作被存储为 ACTION_POINTER_DOWNACTION_POINTER_UP;因此,在即将编写的循环中,每次通过时,我们都需要检查并处理 ACTION_DOWNACTION_POINTER_DOWN

在所有这些讨论之后,以下是每次屏幕被触摸或释放时调用的 handleInput 方法:

public void handleInput(MotionEvent motionEvent,LevelManager l,     
  SoundManager sound, Viewport vp){

    int pointerCount = motionEvent.getPointerCount();

    for (int i = 0; i < pointerCount; i++) {

        int x = (int) motionEvent.getX(i);
        int y = (int) motionEvent.getY(i);

        if(l.isPlaying()) {
            switch  (motionEvent.getAction() &
            MotionEvent.ACTION_MASK) {

            case MotionEvent.ACTION_DOWN:
                    if (right.contains(x, y)) {
                    l.player.setPressingRight(true);
                    l.player.setPressingLeft(false);

                    } else if (left.contains(x, y)) {
                    l.player.setPressingLeft(true);
                    l.player.setPressingRight(false);

                    } else if (jump.contains(x, y)) {
                    l.player.startJump(sound);

                    } else if (shoot.contains(x, y)) {

                    } else if (pause.contains(x, y)) {
                    l.switchPlayingStatus();
                    }

                break;

                case MotionEvent.ACTION_UP:
                    if (right.contains(x, y)) {
                    l.player.setPressingRight(false);

                    } else if (left.contains(x, y)) {
                    l.player.setPressingLeft(false);
                }

                break;

                case MotionEvent.ACTION_POINTER_DOWN:
                if (right.contains(x, y)) {
                    l.player.setPressingRight(true);
                    l.player.setPressingLeft(false);

                    } else if (left.contains(x, y)) {
                    l.player.setPressingLeft(true);
                        l.player.setPressingRight(false);

                    } else if (jump.contains(x, y)) {
                    l.player.startJump(sound);

                    } else if (shoot.contains(x, y)) {
                    //Handle shooting here

                    } else if (pause.contains(x, y)) {
                    l.switchPlayingStatus();
                }

                    break;

                case MotionEvent.ACTION_POINTER_UP:
                    if (right.contains(x, y)) {
                    l.player.setPressingRight(false);
                   //Log.w("rightP:", "up" );

                    } else if (left.contains(x, y)) {
                    l.player.setPressingLeft(false);
                   //Log.w("leftP:", "up" );

                    } else if (shoot.contains(x, y)) {
                    //Handle shooting here
                    } else if (jump.contains(x, y)) {
                   //Handle more jumping stuff here later
                }

                break;
}// End if(l.playing)

}else {// Not playing
    //Move the viewport around to explore the map
    switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {

    case MotionEvent.ACTION_DOWN:

        if (pause.contains(x, y)) {
            l.switchPlayingStatus();
            //Log.w("pause:", "DOWN" );
        }

      break;
            }
        }
    }
}
}

注意

如果你好奇为什么我们要设置两组控制代码,一组用于播放,一组用于不播放,那是因为在第八章《组合在一起》中,我们将为游戏暂停时添加一个很酷的新功能。当然,togglePlayingStatus 方法不必这样做,即使没有播放状态的检测也能正常工作。这只是为我们稍后对代码进行微小的精细修改节省时间。

现在,我们需要做的就是打开 PlatformView 类,获取包含所有控制按钮的数组副本,并将它们绘制到屏幕上。我们使用 drawRoundRect 方法绘制整洁的圆角矩形,以表示屏幕上将对玩家的触摸作出响应的区域。在 draw 方法的 unlockCanvasAndPost() 调用之前输入以下代码:

//draw buttons
paint.setColor(Color.argb(80, 255, 255, 255));
ArrayList<Rect> buttonsToDraw;
buttonsToDraw = ic.getButtons();

for (Rect rect : buttonsToDraw) {
  RectF rf = new RectF(rect.left, rect.top, 
    rect.right, rect.bottom);

    canvas.drawRoundRect(rf, 15f, 15f, paint);
}

同样,在我们调用 unlockCanvasAndPost() 之前,让我们绘制一个简单的暂停屏幕,这样我们就可以知道游戏是暂停还是正在播放。

//draw paused text
if (!this.lm.isPlaying()) {
    paint.setTextAlign(Paint.Align.CENTER);
    paint.setColor(Color.argb(255, 255, 255, 255));

    paint.setTextSize(120);
    canvas.drawText("Paused", vp.getScreenWidth() / 2,                       
    vp.getScreenHeight() / 2, paint);
}

现在你可以到处跳跃和行走,同时还会播放一段不错的复古跳跃音效。为何不通过编辑LevelCave并向场景中添加更多草地,用一些1字符替换几个句点(.)字符呢?下一张截图显示了玩家已经跳跃了一段时间,以及用于控制的按钮:

玩家输入

注意

我们将设计一些真正可玩的游戏关卡,并在第八章,将其全部组合在一起中链接它们。现在,只需用LevelCave做任何看起来有趣的事情。

现在,我们可以摆脱那个难看的压缩玩家图像,并使其成为一个整洁的小动画。

动画鲍勃

精灵表动画通过快速更改屏幕上绘制的图像来工作。这就像一个孩子在书本的角落里画出火柴人的动作阶段,然后快速翻动书本,使其看起来像是在移动。

鲍勃的动画帧已经包含在我们一直用来表示他的player.png文件中。

动画的鲍勃

我们需要做的就是在玩家移动时逐个遍历这些帧。

实现这一点非常直接。我们将制作一个简单的动画类,处理保持时间和在请求时返回精灵表适当部分的功能。然后,我们可以为任何需要动画的GameObject初始化一个新的动画对象。此外,当它们在PlatformViewdraw方法中被绘制时,如果对象是动画的,我们将稍微不同地处理它。

在本节中,我们还将了解如何使用面对变量来跟踪玩家面向的方向。它将使我们能够根据玩家(或任何未来的动画对象)前进的方向来反转精灵表。

让我们先创建一个动画类。创建一个新的 Java 类,将其命名为Animation。接下来的代码将声明用于操作位图的变量、位图名称以及一个rect参数,以定义精灵表当前相关动画帧的区域坐标。

此外,我们还有frameCountcurrentFrameframeTickerframePeriod,它们分别保存和控制可用的帧数、当前帧编号以及帧变化的时间。如您所料,我们还需要知道动画帧的宽度和高度,这些由frameWidthframeHeight保存。

此外,Animation类将经常引用每米的像素数;因此,将这个值保存在成员变量中是有意义的。

我们来输入在Animation类中讨论过的成员变量:

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;

public class Animation {
    Bitmap bitmapSheet;
    String bitmapName;
    private Rect sourceRect;
    private int frameCount;
    private int currentFrame;
    private long frameTicker;
    private int framePeriod;
    private int frameWidth;
    private int frameHeight;
    int pixelsPerMetre;

接下来,我们有构造函数,它为我们的动画对象做好准备。我们很快就会看到如何为实际动画做准备。注意,签名中有相当多的参数,表明动画是相当可配置的。只需注意,这里的 FPS 不是指游戏的帧率,而是指动画的帧率。

Animation(Context context, 
  String bitmapName, float frameHeight, 
  float frameWidth, int animFps, 
  int frameCount, int pixelsPerMetre){

   this.currentFrame = 0;
   this.frameCount = frameCount;
   this.frameWidth = (int)frameWidth * pixelsPerMetre;
   this.frameHeight = (int)frameHeight * pixelsPerMetre;
   sourceRect = new Rect(0, 0, this.frameWidth, this.frameHeight);

   framePeriod = 1000 / animFps;
   frameTicker = 0l;
   this.bitmapName = "" + bitmapName;
   this.pixelsPerMetre = pixelsPerMetre;
}

我们可以处理类的实际功能。getCurrentFrame方法首先检查对象是否在移动或是否能够移动。在这个阶段,这可能看起来有点奇怪,因为该方法只会被一个已动画化的GameObject类调用。因此,这个奇怪的检查是确定此刻是否需要一个新帧。

如果一个对象移动(比如 Bob),但处于静止状态,那么我们不需要改变动画的帧。然而,如果一个动画对象从不具有速度,比如熊熊燃烧的火焰,那么我们需要一直动画它。它永远不会有任何速度,所以moves变量将是false,但方法将继续执行。

该方法然后使用timeframeTickerframePeriod来确定是否到了显示动画下一帧的时间,并递增要显示的帧号。然后,如果动画在最后一帧,它会回到第一帧。

最后,计算代表精灵表中包含所需帧的精确左右位置,并将这些位置返回给调用代码。

public Rect getCurrentFrame(long time, 
    float xVelocity, boolean moves){

    if(xVelocity!=0 || moves == false) {
    // Only animate if the object is moving 
    // or it is an object which doesn't move
    // but is still animated (like fire)

        if (time > frameTicker + framePeriod) {
            frameTicker = time;
            currentFrame++;
            if (currentFrame >= frameCount) {
                currentFrame = 0;
            }
        }
    }

    //update the left and right values of the source of
    //the next frame on the spritesheet
    this.sourceRect.left = currentFrame * frameWidth;
    this.sourceRect.right = this.sourceRect.left + frameWidth;

    return sourceRect;

}

}// End of Animation class

接下来,我们可以向GameObject类添加一些成员。

// Most objects only have 1 frame
// And don't need to bother with these
private Animation anim = null;
private boolean animated;
private int animFps = 1;

一些与我们的Animation类交互的方法,设置和获取变量,使动画工作,并通知draw方法对象是否已动画化。

public void setAnimFps(int animFps) {
  this.animFps = animFps;
}

public void setAnimFrameCount(int animFrameCount) {
  this.animFrameCount = animFrameCount;
}

public boolean isAnimated() {
  return animated;
}

最后,在GameObject中,有一个方法,需要动画的对象可以使用它来设置它们的整个动画对象。注意,是setAnimated方法在一个新的动画对象上调用new()

public void setAnimated(Context context, int pixelsPerMetre,  
  boolean animated){

 this.animated = animated;
 this.anim = new Animation(context, bitmapName,
     height,
     width,
     animFps,
     animFrameCount,
     pixelsPerMetre );
}

下一个方法作为PlatformView类的draw方法和Animation类的getRectToDraw方法之间的中介。

public Rect getRectToDraw(long deltaTime){
  return anim.getCurrentFrame(
    deltaTime, 
    xVelocity, 
    isMoves());
}

然后,我们需要更新Player类,以便根据其特定的帧数和每秒帧数初始化其动画对象。Player类中的新代码如下所示:

setBitmapName("player");

final int ANIMATION_FPS = 16;
final int ANIMATION_FRAME_COUNT = 5;

// Set this object up to be animated
setAnimFps(ANIMATION_FPS);
setAnimFrameCount(ANIMATION_FRAME_COUNT);
setAnimated(context, pixelsPerMetre, true);

// X and y locations from constructor parameters
setWorldLocation(worldStartX, worldStartY, 0);

我们可以使用draw方法中的所有新代码来实现我们的动画。下一块代码检查当前正在绘制的GameObject是否isAnimated()。如果是,它通过GameObject类的getRectToDraw方法使用getNextRect()方法从精灵表中获取适当的矩形。

注意,从原始的draw方法中调用drawBitmap()的下一行代码,现在被包裹在新代码末尾的一个else子句中。基本上,逻辑是这样的:如果需要动画,执行新代码,否则按常规方式处理。

除了我们已知的动画代码外,我们还检查 if(go.getFacing() == 1) 并使用 Matrix 类在必要时通过 x 轴缩放 -1 来翻转位图。

这里是所有新代码,包括原始的 drawBitmap() 调用,在最后的 else 子句中进行了包装:

toScreen2d.set(vp.worldToScreen
  go.getWorldLocation().x,
  go.getWorldLocation().y,
  go.getWidth(),
  go.getHeight()));

if (go.isAnimated()) {
 // Get the next frame of the bitmap
 // Rotate if necessary
 if (go.getFacing() == 1) {
 // Rotate
 Matrix flipper = new Matrix();
 flipper.preScale(-1, 1);
 Rect r = go.getRectToDraw(System.currentTimeMillis());
 Bitmap b = Bitmap.createBitmap(
 lm.bitmapsArray[lm.getBitmapIndex(go.getType())],
 r.left,
 r.top,
 r.width(),
 r.height(),
 flipper,
 true);
 canvas.drawBitmap(b, toScreen2d.left, toScreen2d.top, paint);
} else {
 // draw it the regular way round
 canvas.drawBitmap(
 lm.bitmapsArray[lm.getBitmapIndex(go.getType())],
 go.getRectToDraw(System.currentTimeMillis()),
 toScreen2d, paint);
}
} else { // Just draw the whole bitmap
 canvas.drawBitmap(
 lm.bitmapsArray[lm.getBitmapIndex(go.getType())],
 toScreen2d.left,
 toScreen2d.top, paint);
}

现在,您可以运行游戏,并看到 Bob 的所有动画效果。截图无法展示他的动作,但您可以看到他现在形态完美:

动画 Bob

总结

我们的游戏正在稳步成型。在这个阶段,我们可以在 LevelCave 中构建一个巨大的关卡设计,并在各处奔跑跳跃。然而,我们会推迟尝试使游戏可玩,直到我们添加了更多整洁的特性为止。

这些整洁的特性将包括一挺机关枪,这挺枪可以通过收集升级物品和 Bob 可以射击的一些敌人来进行升级。我们将在下一章开始介绍这些内容。

第七章:平台游戏 - 枪支、生命、金钱和敌人

在本章中,我们将做很多事情。首先,我们将构建一个可变射速的机枪,让它射击子弹。然后,我们将引入拾取物或收藏品。这些给玩家在尝试逃到下一个关卡时提供了搜寻的目标。

然后,就在 Bob 开始认为他的生活是充满草丛和收藏品的幸福生活时,我们将为他构建两个对手,让他智取或消灭。一个追踪无人机和一个巡逻的守卫。我们可以轻松地将所有这些事物添加到我们的关卡设计中。

准备,瞄准,开火。

现在,我们可以给我们的英雄一把枪,稍后,我们可以给他敌人射击。我们将创建一个MachineGun类来完成所有工作,以及一个Bullet类来表示它发射的炮弹。Player类将控制MachineGun类,而MachineGun类将控制和跟踪它发射的所有Bullet对象。

创建一个新的 Java 类,将其命名为Bullet。子弹并不复杂。我们的子弹需要有一个xy的位置,一个水平速度和一个方向,以帮助计算速度。

这意味着以下简单的类、构造函数以及一堆的 getter 和 setter:

public class Bullet  {

    private float x;
    private float y;
    private float xVelocity;
    private int direction;

    Bullet(float x, float y, int speed, int direction){
        this.direction = direction;
        this.x = x;
        this.y = y;
        this.xVelocity = speed * direction;
    }

    public int getDirection(){
        return direction;
    }

    public void update(long fps, float gravity){
        x += xVelocity / fps;
    }

    public void hideBullet(){
        this.x = -100;
        this.xVelocity = 0;
    }

    public float getX(){
        return x;
    }

    public float getY(){
        return y;
    }

}

现在,让我们实现MachineGun类。

创建一个新的 Java 类,将其命名为MachineGun。首先,我们添加一些成员。maxBullets变量不是玩家拥有的射击次数,那是无限的,它是MachineGun类可以拥有的子弹对象数量。对于非常快速射击的枪来说,10 个就足够了,正如我们将看到的。成员numBulletsnextBullet帮助类跟踪其 10 个子弹。rateOfFire变量控制玩家能够多快地按下射击按钮,lastShotTime通过跟踪上次发射子弹的系统时间来帮助执行rateOfFire。射速将是武器可升级的方面。

输入我们讨论过的代码如下。

import java.util.concurrent.CopyOnWriteArrayList;

public class MachineGun extends GameObject{
    private int maxBullets = 10;
    private int numBullets;
    private int nextBullet;
    private int rateOfFire = 1;//bullets per second
    private long lastShotTime;

    private CopyOnWriteArrayList<Bullet> bullets;

    int speed = 25;

注意

对于功能性目的,我们可以将存储我们子弹的CopyOnWriteArrayList bullets视为一个普通的ArrayList对象。我们使用这个更复杂且稍慢的类,因为它线程安全,当玩家点击射击按钮时,子弹可能会同时从 UI 线程以及我们自己的线程中被访问。这篇文章解释了CopyOnWriteArrayList,如果你想知道更多,请访问:

如何处理并发修改异常

我们有一个构造函数,它只是初始化子弹,lastShotTimenextBullet

MachineGun(){
   bullets = new CopyOnWriteArrayList<Bullet>();
   lastShotTime = -1;
   nextBullet = -1;
}

在这里,我们通过调用每个子弹的bullet.update方法,更新枪支控制的所有Bullet对象。

public void update(long fps, float gravity){
        //update all the bullets
        for(Bullet bullet: bullets){
            bullet.update(fps, gravity);
        }
    }

接下来,我们有一些 getter,它们将让我们了解有关我们的枪及其子弹的信息,以便进行像碰撞检测和绘制子弹等操作。

public int getRateOfFire(){
  return rateOfFire;
}

public void setFireRate(int rate){
  rateOfFire = rate;
}

public int getNumBullets(){
  //tell the view how many bullets there are
  return numBullets;
}

public float getBulletX(int bulletIndex){
  if(bullets != null && bulletIndex < numBullets) {
       return bullets.get(bulletIndex).getX();
    }

  return -1f;
}

public float getBulletY(int bulletIndex){
  if(bullets != null) {
       return bullets.get(bulletIndex).getY();
     }
     return -1f;
}

我们还有一个快速帮助方法,当我们想要停止绘制子弹时使用。我们在shoot方法中将其隐藏,直到准备好重新分配。

public void hideBullet(int index){
  bullets.get(index).hideBullet();
}

一个返回旅行方向的 getter:

public int getDirection(int index){
  return bullets.get(index).getDirection();
}

现在,我们添加一个更全面的方法,该方法实际射出一颗子弹。该方法将上一次射击的时间与当前的rateOfFire进行比较。然后继续增加nextBullet并在允许的情况下创建一个新的Bullet对象。子弹以 Bob 面向的同一方向飞速射出。请注意,如果成功发射了子弹,该方法将返回true。这样,InputController类可以播放与玩家按钮按下相对应的声音效果。

public boolean shoot(float ownerX, float ownerY, 
    int ownerFacing, float ownerHeight){

    boolean shotFired = false;
    if(System.currentTimeMillis() - lastShotTime  >                          
      1000/rateOfFire){

        //spawn another bullet;
        nextBullet ++;

        if(numBullets >= maxBullets){
            numBullets = maxBullets;
        }

        if(nextBullet == maxBullets){
            nextBullet = 0;
        }

        lastShotTime = System.currentTimeMillis();
        bullets.add(nextBullet, 
                new Bullet(ownerX, 
                (ownerY+ ownerHeight/3), speed, ownerFacing));

        shotFired = true;
        numBullets++;
    }
    return shotFired;
}

最后,我们有一个方法,当玩家找到机枪升级包时调用。我们将在本章后面看到更多相关内容。在这里,我们只是增加了rateOfFire,这使得玩家可以更猛烈地敲击开火按钮,并且仍然能够得到效果。

public void upgradeRateOfFire(){
  rateOfFire += 2;
}
}// End of MachineGun class

现在,我们将修改Player类以携带一把MachineGun。给Player一个类型为MachineGun的成员变量。

public MachineGun bfg;

接下来,在Player构造函数中,添加一行代码来初始化我们的新MachineGun对象:

bfg = new MachineGun();

Player类的update方法中,在我们为玩家调用move()之前,添加对MachineGun类的update方法的调用。如下所示突出:

bfg.update(fps, gravity);

// Let's go!
this.move(fps);

Player类添加一个方法,这样我们的InputController就可以访问虚拟触发器。正如我们所见,如果成功射击,该方法将返回true,这样InputController类就知道是否播放射击声音。

public boolean pullTrigger() {
        //Try and fire a shot
        return bfg.shoot(this.getWorldLocation().x,  
           this.getWorldLocation().y, 
           getFacing(), getHeight());
}

现在,我们可以在InputController类中做一些小的添加,让玩家能够开火。要添加的代码在现有代码中突出显示:

} else if (jump.contains(x, y)) {
  l.player.startJump(sound);

} else if (shoot.contains(x, y)) {
 if (l.player.pullTrigger()) {
 sound.playSound("shoot");
 }

} else if (pause.contains(x, y)) {
  l.switchPlayingStatus();

}

不要忘记我们新的控制系统的工作方式,我们还需要在InputController类的MotionEvent.ACTION_POINTER_DOWN情况下的更下方添加同样的额外代码。像往常一样,这里是有很多上下文背景的突出代码:

} else if (jump.contains(x, y)) {
  l.player.startJump(sound);

} else if (shoot.contains(x, y)) {
 if (l.player.pullTrigger()) {
 sound.playSound("shoot");
}

} else if (pause.contains(x, y)) {
  l.switchPlayingStatus();
}

现在我们有了一把枪,它已装填好,我们知道如何扣动扳机。我们只需要绘制子弹。

draw方法中添加新代码,在我们绘制调试文本之前,如下所示:

//draw the bullets
paint.setColor(Color.argb(255, 255, 255, 255));
for (int i = 0; i < lm.player.bfg.getNumBullets(); i++) {
   // Pass in the x and y coords as usual
   // then .25 and .05 for the bullet width and height
   toScreen2d.set(vp.worldToScreen
            (lm.player.bfg.getBulletX(i),
            lm.player.bfg.getBulletY(i),
            .25f,
            .05f));

        canvas.drawRect(toScreen2d, paint);
}

// Text for debugging
if (debugging) {
// etc

我们现在将发射一些子弹。请注意,开火速率令人不满意且缓慢。我们将添加一些收集品,玩家可以获得这些收集品以增加他的枪的开火速率。

收集品

收集品是玩家可以收集的游戏对象。它们包括像升级包、额外生命、金钱等。我们现在将实现其中每一个收集品。由于我们的游戏引擎是这样设置的,这将出奇地简单。

我们首先要创建一个类来保存当前玩家的状态。我们想要监控收集到的金钱、机枪的火力以及剩余的生命。我们将其称为PlayerState。创建一个新的 Java 类,并将其命名为PlayerState

除了我们刚才讨论的那些变量之外,我们还希望PlayerState类记住一个xy位置,以便在玩家失去生命时进行重生。输入这些成员变量和简单的构造函数:

import android.graphics.PointF;

public class PlayerState {

    private int numCredits;
    private int mgFireRate;
    private int lives;
    private float restartX;
    private float restartY;

    PlayerState() {
        lives = 3;
        mgFireRate = 1;
        numCredits = 0;
    }

现在,我们需要一个方法,我们可以调用它来初始化重生位置。我们稍后会用到这个方法。此外,我们还需要一个方法来重新加载位置。这是PlayerState类的接下来两个方法:

public void saveLocation(PointF location) {
   // The location saves each time the player uses a teleport
     restartX = location.x;
     restartY = location.y;
}

public PointF loadLocation() {
   // Used every time the player loses a life
   return new PointF(restartX, restartY);
}

我们只需要一堆 getter 和 setter,以便访问这个类的成员:

public int getLives(){
  return lives;
}

public int getFireRate(){
  return mgFireRate;
}

public void increaseFireRate(){
  mgFireRate += 2;
}

public void gotCredit(){
  numCredits ++;
}

public int getCredits(){
  return numCredits;
}

public void loseLife(){
  lives--;
}

public void addLife(){
  lives++;
}

public void resetLives(){
  lives = 3;
}
public void resetCredits(){
  lives = 0;
}

}// End PlayerState class

接下来,在PlatformView类中声明一个PlayerState类型的成员对象:

// Our new engine classes
private LevelManager lm;
private Viewport vp;
InputController ic;
SoundManager sm;
private PlayerState ps;

PlatformView构造函数中初始化它:

vp = new Viewport(screenWidth, screenHeight);
sm = new SoundManager();
sm.loadSound(context);
ps = new PlayerState();

loadLevel("LevelCave", 10, 2);

现在,在loadLevel方法中,创建一个RectF对象,保存玩家的起始位置,并将其传递给PlayerState对象ps以便妥善保存。每次玩家死亡时,都可以使用这个位置进行重生。

ic = new InputController(vp.getScreenWidth(), vp.getScreenHeight());

PointF location = new PointF(px, py);
ps.saveLocation(location);

//set the players location as the world centre of the viewport

现在,我们将创建三个类,分别对应我们的三种收集物。这些类非常简单。它们扩展了GameObject,设置了位图,具有碰撞箱和在世界中的位置。还要注意,它们在构造函数中都接收一个类型,并使用setType()存储这个值。我们很快就会看到如何使用它们的类型来处理玩家“收集它们”时会发生的事情。创建三个新的 Java 类:CoinExtraLifeMachineGunUpgrade。注意,收集物比平台稍小一些,这可能正如我们所预期的。依次输入它们的代码。

以下是Coin的代码:

public class Coin extends GameObject{

    Coin(float worldStartX, float worldStartY, char type) {

        final float HEIGHT = .5f;
        final float WIDTH = .5f;

        setHeight(HEIGHT); 
        setWidth(WIDTH); 

        setType(type);

        // Choose a Bitmap
        setBitmapName("coin");

        // Where does the tile start
        // X and y locations from constructor parameters
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity){}
}

现在,对于ExtraLife

public class ExtraLife extends GameObject{

    ExtraLife(float worldStartX, float worldStartY, char type) {

        final float HEIGHT = .8f;
        final float WIDTH = .65f;

        setHeight(HEIGHT); 
        setWidth(WIDTH); 

        setType(type);

        // Choose a Bitmap

        setBitmapName("life");

        // Where does the tile start
        // X and y locations from constructor parameters
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity){}
}

最后,MachineGunUpgrade类:

public class MachineGunUpgrade extends GameObject{
    MachineGunUpgrade(float worldStartX, 
        float worldStartY, 
        char type) {

        final float HEIGHT = .5f;
        final float WIDTH = .5f;

        setHeight(HEIGHT); 
        setWidth(WIDTH); 

        setType(type);

        // Choose a Bitmap

        setBitmapName("clip");

        // Where does the tile start
        // X and y locations from constructor parameters
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity){}
}

现在,更新LevelManager类,使其能够处理我们的关卡设计中这三个新对象,并将它们添加到GameObjectsArrayList中。为此,我们需要在三个地方更新LevelManager类:getBitmap()getBitmapIndex()loadMapData()。以下是这些小更新的内容,新代码在现有代码中突出显示。

getBitmap()进行以下添加:

case 'p':
  index = 2;
  break;

case 'c':
 index = 3;
 break;

case 'u':
 index = 4;
 break;

case 'e':
 index = 5;
 break;

default:
  index = 0;
  break;

进行相同的添加,但这次是在getBitmapIndex()中:

case 'p':
  index = 2;
  break;

case 'c':
 index = 3;
 break;

case 'u':
 index = 4;
 break;

case 'e':
 index = 5;
 break;

default:
  index = 0;
  break;

LevelManager中进行最后的修改,对loadMapData()进行以下添加:

case 'p':// a player
    // Add a player to the gameObjects
    gameObjects.add(new Player(context, px, py, pixelsPerMetre));
    // We want the index of the player
    playerIndex = currentIndex;
    // We want a reference to the player object
    player = (Player) gameObjects.get(playerIndex);
    break;

case 'c':
 // Add a coin to the gameObjects
 gameObjects.add(new Coin(j, i, c));
 break;

case 'u':
 // Add a machine gun upgrade to the gameObjects
 gameObjects.add(new MachineGunUpgrade(j, i, c));
 break;

case 'e':
 // Add an extra life to the gameObjects
 gameObjects.add(new ExtraLife(j, i, c));
 break;
}

现在,我们可以将三个适当命名的图形添加到 drawable 文件夹中,并开始将它们添加到我们的LevelCave设计中。继续从下载捆绑包中的Chapter7/drawables文件夹复制clip.pngcoin.pnglife.png到你的 Android Studio 项目的drawable文件夹中。

添加一系列注释,标识所有游戏对象类型。我们将在项目过程中添加这些注释,以及它们在关卡设计中的字母数字代码。将以下注释添加到LevelData类中:

// Tile types
// . = no tile
// 1 = Grass
// 2 = Snow
// 3 = Brick
// 4 = Coal
// 5 = Concrete
// 6 = Scorched
// 7 = Stone

//Active objects
// g = guard
// d = drone
// t = teleport
// c = coin
// u = upgrade
// f = fire
// e  = extra life

//Inactive objects
// w = tree
// x = tree2 (snowy)
// l = lampost
// r = stalactite
// s = stalacmite
// m = mine cart
// z = boulders

在我们增强LevelCave类以使用我们的新对象之前,我们想要检测玩家收集它们或与它们碰撞的时刻,并采取适当的行动。我们首先会在Player类中添加一个快速辅助方法。这样做的原因是,当玩家与另一个对象碰撞时,Player类中checkCollisions方法的默认动作是停止角色移动。我们不希望拾取物发生这种情况,因为这会让玩家感到烦恼。因此,我们将在Player类中快速添加一个restorePreviousVelocity方法,在我们不希望发生默认动作时调用它。将此方法添加到Player类中:

public void restorePreviousVelocity() {
  if (!isJumping && !isFalling) {
       if (getFacing() == LEFT) {
           isPressingLeft = true;
           setxVelocity(-MAX_X_VELOCITY);
         } else {
           isPressingRight = true;
                     setxVelocity(MAX_X_VELOCITY);
       }
    }
}

现在,我们可以依次处理每个拾取物的碰撞。在PlatformView类的update方法中处理碰撞的 switch 块内,添加以下情况来处理我们的三个拾取物:

switch (go.getType()) {
 case 'c':
 sm.playSound("coin_pickup");
 go.setActive(false);
 go.setVisible(false);
 ps.gotCredit();

 // Now restore state that was 
 // removed by collision detection
 if (hit != 2) {// Any hit except feet
 lm.player.restorePreviousVelocity();
 }
 break;

case 'u':
 sm.playSound("gun_upgrade");
 go.setActive(false);
 go.setVisible(false);
 lm.player.bfg.upgradeRateOfFire();
 ps.increaseFireRate();
 if (hit != 2) {// Any hit except feet
 lm.player.restorePreviousVelocity();
 }
 break;

case 'e':
 //extralife
 go.setActive(false);
 go.setVisible(false);
 sm.playSound("extra_life");
 ps.addLife();

 if (hit != 2) {
 lm.player.restorePreviousVelocity();
 }
 break;

default:// Probably a regular tile
    if (hit == 1) {// Left or right
        lm.player.setxVelocity(0);
        lm.player.setPressingRight(false);
    }

    if (hit == 2) {// Feet
        lm.player.isFalling = false;
    }
    break;
}

最后,将新对象添加到我们的LevelCave类中。

提示

下面的代码片段,我建议是用于演示我们新对象的简单新布局,但你的布局可以尽可能大或者复杂。我们将在下一章设计并链接一些关卡时做一些更复杂的事情。

将以下代码输入到LevelCave中,或者用你自己的设计进行扩展:

public class LevelCave extends LevelData{
  LevelCave() {
    tiles = new ArrayList<String>();
 this.tiles.add("p.............................................");
 this.tiles.add("..............................................");
 this.tiles.add("..............................................");
 this.tiles.add("..............................................");
 this.tiles.add("....................c.........................");
 this.tiles.add("....................1........u................");
 this.tiles.add(".................c..........u1................");
 this.tiles.add(".................1.........u1.................");
 this.tiles.add("..............c...........u1..................");
 this.tiles.add("..............1..........u1...................");
 this.tiles.add("......................e..1....e.....e.........");
 this.tiles.add("....11111111111111111111111111111111111111....");
}

这就是简单布局的样子:

拾取物

尝试收集拾取物,你会听到愉悦的声音效果。此外,每次我们收集一个拾取物,PlayerState类就会存储一个更新。这将在我们下一章构建一个 HUD 时非常有用。最有趣的是;如果你收集了机枪升级,然后尝试射击,你会发现使用起来更加令人满意。

我们最好让这些子弹发挥作用。不过,在我们这样做之前,让我们给玩家提供一些炮灰,形式是几个敌人。

无人机

无人机是一个简单但邪恶的敌人。它将在视口中检测到玩家并直接向玩家飞去。如果无人机接触到玩家,那么玩家将立即死亡。

让我们构建一个Drone类。创建一个新的 Java 类,将其命名为Drone。我们需要成员变量来记录我们设置最后一个航点的时刻。这将限制无人机获取 Bob 坐标导航更新的频率。这将阻止无人机过于精确地打击目标。它需要一个航点/目标坐标,还需要知道通过MAX_X_VELOCITYMAX_Y_VELOCITY的速度限制。

import android.graphics.PointF;

public class Drone extends GameObject {

    long lastWaypointSetTime;
    PointF currentWaypoint;

    final float MAX_X_VELOCITY = 3;
    final float MAX_Y_VELOCITY = 3;

现在,在Drone构造函数中,初始化常规的GameObject成员,特别是Drone类的成员,如currentWaypoint。不要忘记,如果我们打算射击无人机,它将需要一个碰撞箱,我们在调用setWorldLocation()之后调用setRectHitBox()

Drone(float worldStartX, float worldStartY, char type) {
    final float HEIGHT = 1;
    final float WIDTH = 1;
    setHeight(HEIGHT); // 1 metre tall
    setWidth(WIDTH); // 1 metres wide

    setType(type);

    setBitmapName("drone");
    setMoves(true);
    setActive(true);
    setVisible(true);

    currentWaypoint = new PointF();

    // Where does the drone start
    // X and y locations from constructor parameters
    setWorldLocation(worldStartX, worldStartY, 0);
    setRectHitbox();
    setFacing(RIGHT);
}

这是update方法的实现,它将比较无人机的坐标与其currentWaypoint变量,并据此改变其速度。然后,我们通过调用move()然后是setRectHitbox()来结束update()

public void update(long fps, float gravity) {
  if (currentWaypoint.x > getWorldLocation().x) {
       setxVelocity(MAX_X_VELOCITY);
   } else if (currentWaypoint.x < getWorldLocation().x) {
       setxVelocity(-MAX_X_VELOCITY);
   } else {
       setxVelocity(0);
   }

    if (currentWaypoint.y >= getWorldLocation().y) {
       setyVelocity(MAX_Y_VELOCITY);
     } else if (currentWaypoint.y < getWorldLocation().y) {
       setyVelocity(-MAX_Y_VELOCITY);
     } else {
       setyVelocity(0);
  }

  move(fps);

  // update the drone hitbox
   setRectHitbox();

}

Drone类的最后一个方法中,通过传入 Bob 的坐标作为参数来更新currentWaypoint变量。注意,我们会检查是否已经过了足够的时间来进行更新,以确保我们的无人机不会过于精确。

public void setWaypoint(Vector2Point5D playerLocation) {
  if (System.currentTimeMillis() > lastWaypointSetTime + 2000) {//Has 2 seconds passed
        lastWaypointSetTime = System.currentTimeMillis();
        currentWaypoint.x = playerLocation.x;
        currentWaypoint.y = playerLocation.y;
     }
}
}// End Drone class

drone.png图形文件从Chapter7/drawable文件夹添加到项目的drawable文件夹中。

接下来,我们需要在LevelManager类中添加无人机,就像我们对每个拾取物品所做的那样,在三个常规位置添加。现在,在getBitmap()getBitmapIndex()loadMapData()方法中添加代码。这是按顺序需要添加的三个小部分代码。

getBitmap方法中添加高亮显示的代码:

case 'e':
  index = 5;
  break;

case 'd':
 index = 6;
 break;

default:
  index = 0;
  break;

getBitmapIndex方法中添加高亮显示的代码:

case 'e':
  index = 5;
  break;

case 'd':
 index = 6;
 break;

default:
  index = 0;
  break;

loadMapData方法中添加高亮显示的代码:

case 'e':
   // Add an extra life to the gameObjects
   gameObjects.add(new ExtraLife(j, i, c));
   break;

case 'd':
 // Add a drone to the gameObjects
 gameObjects.add(new Drone(j, i, c));
 break;

一个迫切的问题是:无人机如何知道要去哪里?在每一帧中,如果视口内有无人机,我们可以发送玩家的坐标。在PlatformView类的update方法中执行以下代码块所示的操作。

与往常一样,新代码以高亮形式展示,并嵌入到现有代码的上下文中。如果你记得Drone类中的setWaypoint()代码,它只接受每 2 秒更新一次。这防止了无人机过于精确。

if (lm.isPlaying()) {
   // Run any un-clipped updates
   go.update(fps, lm.gravity);

 if (go.getType() == 'd') {
 // Let any near by drones know where the player is
 Drone d = (Drone) go;
 d.setWaypoint(lm.player.getWorldLocation());
 }
}

现在,这些邪恶的无人机可以策略性地放置在关卡周围,它们会锁定玩家。要使无人机完全运作,我们需要做的最后一件事是检测它们实际上是否与玩家发生了碰撞。这非常简单。只需在PlatformView类的update方法中的碰撞检测switch块中为无人机添加一个案例:

case 'e':
  //extralife
   go.setActive(false);
   go.setVisible(false);
   sm.playSound("extra_life");
   ps.addLife();
   if (hit != 2) {// Any hit except feet
       lm.player.restorePreviousVelocity();
   }
   break;

case 'd':
 PointF location;
 //hit by drone
 sm.playSound("player_burn");
 ps.loseLife();
 location = new PointF(ps.loadLocation().x, 
 ps.loadLocation().y);
 lm.player.setWorldLocationX(location.x);
 lm.player.setWorldLocationY(location.y);
 lm.player.setxVelocity(0);
 break;

default:// Probably a regular tile
  if (hit == 1) {// Left or right
       lm.player.setxVelocity(0);
       lm.player.setPressingRight(false);
  }

   if (hit == 2) {// Feet
       lm.player.isFalling = false;
   }

继续在LevelCave中添加大量无人机,并观察它们向玩家飞去。注意,如果无人机捕捉到玩家,玩家会死亡并重新生成。

无人机

现在,尽管世界上已经有足够多的敌方无人机使它变得危险,但让我们再添加一种类型的敌人。

守卫

守卫敌人将是一个脚本练习。我们将让LevelManager类自动生成一个简单的脚本,为我们的守卫生成一个巡逻路线。

路线将尽可能简单;它只包括两个守卫会不断巡逻的点。预编程两个预定的航点会更快捷、更简单。然而,如果自动生成,我们可以根据需要(在一定的参数范围内)在任何设计的关卡上放置守卫,行为将由系统处理。

我们的守卫将会有动画效果,因此我们将在构造函数中使用一个精灵表单并配置动画细节,就像我们对Player类所做的那样。

创建一个新类,并将其命名为Guard。首先,处理成员变量。我们的Guard类不仅需要两个航点,还需要一个变量来指示当前的航点是哪一个。像其他移动对象一样,它需要速度。以下是开始编写你的类的类声明和成员变量:

import android.content.Context;

public class Guard extends GameObject {

    // Guards just move on x axis between 2 waypoints

    private float waypointX1;// always on left
    private float waypointX2;// always on right
    private int currentWaypoint;
    final float MAX_X_VELOCITY = 3;

我们需要通过构造函数设置我们的守卫。首先,设置我们的动画变量、位图和大小。然后像往常一样,设置守卫在关卡中的位置、它的碰撞箱以及它面向的方向。然而,在构造函数的最后一行,我们将currentWaypoint设置为1;这是新的。我们将在该类的update方法中看到这是如何影响守卫的行为的。

Guard(Context context, float worldStartX, 
  float worldStartY, char type, 
  int pixelsPerMetre) {

        final int ANIMATION_FPS = 8;
        final int ANIMATION_FRAME_COUNT = 5;
        final String BITMAP_NAME = "guard";
        final float HEIGHT = 2f;
        final float WIDTH = 1;

        setHeight(HEIGHT); // 2 metre tall
        setWidth(WIDTH); // 1 metres wide

        setType(type);

        setBitmapName("guard");
        // Now for the player's other attributes
        // Our game engine will use these
        setMoves(true);
        setActive(true);
        setVisible(true);

        // Set this object up to be animated
        setAnimFps(ANIMATION_FPS);
        setAnimFrameCount(ANIMATION_FRAME_COUNT);
        setBitmapName(BITMAP_NAME);
        setAnimated(context, pixelsPerMetre, true);

        // Where does the tile start
        // X and y locations from constructor parameters
        setWorldLocation(worldStartX, worldStartY, 0);
        setxVelocity(-MAX_X_VELOCITY);
        currentWaypoint = 1;
}

接着,添加一个方法,供我们的LevelManager类使用,以告知Guard类其两个航点是什么:

public void setWaypoints(float x1, float x2){
  waypointX1 = x1;
  waypointX2 = x2;
}

现在,我们将编写Guard类的“大脑”部分,也就是它的update方法。你基本上可以将这个方法分为两个主要部分。首先,if(currentWaypoint == 1),其次,if(currentWaypoint == 2)。在这两个if块内部,只需检查守卫是否已经到达或通过了适当的航点。如果是,则切换航点,反转速度,并让守卫面向另一个方向。

最后,调用move()然后setRectHitbox(),以更新碰撞箱到守卫的新位置。添加update方法的代码,然后我们将看到如何让它工作。

public void update(long fps, float gravity) {
  if(currentWaypoint == 1) {// Heading left
       if (getWorldLocation().x <= waypointX1) {
          // Arrived at waypoint 1
           currentWaypoint = 2;
           setxVelocity(MAX_X_VELOCITY);
           setFacing(RIGHT);
      }
  }

  if(currentWaypoint == 2){
    if (getWorldLocation().x >= waypointX2) {
         // Arrived at waypoint 2
          currentWaypoint = 1;
          setxVelocity(-MAX_X_VELOCITY);
          setFacing(LEFT);
      }
  }

  move(fps);
   // update the guards hitbox
   setRectHitbox();
}
}// End Guard class

记得从下载包的Chapter7/drawables文件夹中添加guard.png到项目的drawable文件夹中。

现在,我们可以在LevelManager类中进行通常的三处添加,以加载可能在我们的关卡设计中找到的任何守卫。

getBitmap()中,添加高亮显示的代码:

case 'd':
  index = 6;
  break;

case 'g':
 index = 7;
 break;

default:
  index = 0;
  break;

getBitmapIndex()中,添加高亮显示的代码:

case 'd':
  index = 6;
  break;

case 'g':
 index = 7;
 break;

default:
  index = 0;
  break;

loadMapData()中,添加高亮显示的代码:

case 'd':
     // Add a drone to the gameObjects
     gameObjects.add(new Drone(j, i, c));
     break;
case 'g':
 // Add a guard to the gameObjects
 gameObjects.add(new Guard(context, j, i, c, pixelsPerMetre));
 break;

我们很快将为LevelManager添加一个全新的功能。那就是一个将创建脚本(设置两个巡逻航点)的方法。为了让这个新方法工作,它需要知道瓦片是否适合行走。我们将为GameObject添加一个新属性、一个获取器和设置器,以便轻松发现这一点。

GameObject类的类声明后直接添加这个新成员:

private boolean traversable = false;

GameObject类添加这两个方法,以获取和设置这个变量:

public void setTraversable(){
  traversable = true;
}

public boolean isTraversable(){
  return traversable;
}

现在,在Grass类的构造函数中,添加对setTraversable()的调用。如果我们希望守卫能够在上面巡逻,我们必须记得为所有未来设计的GameObject派生类做这一点。在Grass中,在构造函数顶部添加这一行:

setTraversable();

接下来,我们将查看为LevelManager类新增加的setWaypoints方法。它需要检查关卡设计,并为关卡中存在的任何Guard对象计算两个航点。

我们将把这个方法分成几个部分,以便我们可以看到每个阶段的操作。

首先,我们需要遍历所有的gameObjects类,寻找Guard对象。

public void setWaypoints() {
  // Loop through all game objects looking for Guards
    for (GameObject guard : this.gameObjects) {
       if (guard.getType() == 'g') {

如果我们到达代码的这一部分,这意味着我们已经找到了一个需要设置两个航点的守卫。首先,我们需要找到守卫“站立”的瓷砖。然后,我们计算每侧最后一个可通行的瓷砖的坐标,但最大范围是每个方向五个瓷砖。这两个点将作为两个航点。以下是添加到setWaypoints方法中的代码。它包含大量注释,以清晰说明情况而不中断流程。

// Set waypoints for this guard
// find the tile beneath the guard
// this relies on the designer putting 
// the guard in sensible location

int startTileIndex = -1;
int startGuardIndex = 0;
float waypointX1 = -1;
float waypointX2 = -1;

for (GameObject tile : this.gameObjects) {
    startTileIndex++;
    if (tile.getWorldLocation().y == 
            guard.getWorldLocation().y + 2) {

        // Tile is two spaces below current guard
        // Now see if has same x coordinate
        if (tile.getWorldLocation().x == 
            guard.getWorldLocation().x) {

            // Found the tile the guard is "standing" on
            // Now go left as far as possible 
            // before non travers-able tile is found
            // Either on guards row or tile row
            // upto a maximum of 5 tiles. 
            //  5 is an arbitrary value you can
            // change it to suit

            for (int i = 0; i < 5; i++) {// left for loop
                if (!gameObjects.get(startTileIndex -
                    i).isTraversable()) {

                    //set the left waypoint
                    waypointX1 = gameObjects.get(startTileIndex - 
                        (i + 1)).getWorldLocation().x;

                     break;// Leave left for loop
                     } else {
                    // Set to max 5 tiles as 
                    // no non traversible tile found
                    waypointX1 = gameObjects.get(startTileIndex -
                        5).getWorldLocation().x;
               }
                }// end get left waypoint

                for (int i = 0; i < 5; i++) {// right for loop
                    if (!gameObjects.get(startTileIndex +
                        i).isTraversable()) {

                        //set the right waypoint
                        waypointX2 = gameObjects.get(startTileIndex +
                            (i - 1)).getWorldLocation().x;

                    break;// Leave right for loop
                    } else {
                    //set to max 5 tiles away
                    waypointX2 = gameObjects.get(startTileIndex +
                       5).getWorldLocation().x;
                }

                }// end get right waypoint

        Guard g = (Guard) guard;
        g.setWaypoints(waypointX1, waypointX2);
    }
}
}
}
}
}// End setWaypoints()

现在,我们可以在LevelManager构造函数的最后调用我们新的setWaypoints方法。我们需要在GameObject类的ArrayList填充完毕后调用此方法,否则其中将没有守卫。像这样突出显示添加对setWaypoints()的调用:

// Load all the GameObjects and Bitmaps
loadMapData(context, pixelsPerMetre, px, py);
// Set waypoints for our guards
setWaypoints();

接下来,将这段代码添加到PlatformView类的update方法中的碰撞检测开关块中,以便我们可以与守卫相撞。

case 'd':
    PointF location;
    //hit by drone
    sm.playSound("player_burn");
    ps.loseLife();
    location = new PointF(ps.loadLocation().x, 
        ps.loadLocation().y);

    lm.player.setWorldLocationX(location.x);
    lm.player.setWorldLocationY(location.y);
    lm.player.setxVelocity(0);
    break;

case 'g':
 // Hit by guard
 sm.playSound("player_burn");
 ps.loseLife();
 location = new PointF(ps.loadLocation().x,
 ps.loadLocation().y);

 lm.player.setWorldLocationX(location.x);
 lm.player.setWorldLocationY(location.y);
 lm.player.setxVelocity(0);
 break;

default:// Probably a regular tile
    if (hit == 1) {// Left or right
        lm.player.setxVelocity(0);
        lm.player.setPressingRight(false);
    }
    if (hit == 2) {// Feet
        lm.player.isFalling = false;
    }

最后,向LevelCave类中添加一些g字母。确保将它们放置在平台上方一个空格的位置,因为它们的高度是 2 米,如下面的伪代码所示:

................g............................
...........................d.................
111111111111111111111111111111111111111111111

守卫

总结

我们实现了枪支、拾取物、无人机和守卫。这意味着我们现在有很多危险,但拥有一把无法造成伤害的机枪。我们将在下一章首先解决这个问题,为我们的子弹实现碰撞检测。然而,我们的目标不仅仅是让子弹击中敌人。

第八章:平台游戏——整合所有功能

最后,我们将让子弹造成一些伤害。当子弹的能量被一团草地吸收时,这种反弹声非常令人满意。我们将添加大量的新平台类型和非动画场景对象,使我们的关卡更有趣。通过实现多个滚动视差背景,我们将提供一种真正的运动感和沉浸感。

我们还将添加一个动画火焰瓦片,让玩家避开,此外,还会添加一个特殊的Teleport类,将各个关卡连接成一个可玩的游戏。然后,我们将使用所有的游戏对象和背景创建四个连接、完全可玩的游戏关卡。

然后,我们将添加一个 HUD 来跟踪拾取物和生命值。最后,我们将讨论一些无法在这四章中容纳的精彩内容。

子弹碰撞检测

检测子弹碰撞相当直接。我们遍历由我们的MachineGun对象持有的所有现有Bullet对象。接下来,我们将每个子弹的点转换成RectHitBox对象,并使用intersects()方法测试我们的视口中的每个对象。

如果我们受到攻击,我们会检查它击中的对象类型。然后,我们会切换到处理我们关心的每种类型的对象。如果是Guard对象,我们将其稍微击退一点;如果是Drone对象,我们将其销毁;如果是其他任何对象,我们只需让子弹消失,并播放一种沉闷的/反弹声。

我们只需在我们处理玩家碰撞的switch块之后,但在我们调用所有未剪辑对象的update()之前,放置我们讨论过的这个逻辑,如下所示:

default:// Probably a regular tile
    if (hit == 1) {// Left or right
        lm.player.setxVelocity(0);
        lm.player.setPressingRight(false);
    }

   if (hit == 2) {// Feet
        lm.player.isFalling = false;
    }
    break;
}
}

//Check bullet collisions
for (int i = 0; i < lm.player.bfg.getNumBullets(); i++) {
 //Make a hitbox out of the the current bullet
 RectHitbox r = new RectHitbox();
 r.setLeft(lm.player.bfg.getBulletX(i));
 r.setTop(lm.player.bfg.getBulletY(i));
 r.setRight(lm.player.bfg.getBulletX(i) + .1f);
 r.setBottom(lm.player.bfg.getBulletY(i) + .1f);

 if (go.getHitbox().intersects(r)) {
 // Collision detected
 // make bullet disappear until it 
 // is respawned as a new bullet
 lm.player.bfg.hideBullet(i);

 //Now respond depending upon the type of object hit
 if (go.getType() != 'g' && go.getType() != 'd') {
 sm.playSound("ricochet");

 } else if (go.getType() == 'g') {
 // Knock the guard back
 go.setWorldLocationX(go.getWorldLocation().x +
 2 * (lm.player.bfg.getDirection(i)));

 sm.playSound("hit_guard");

 } else if (go.getType() == 'd') {
 //destroy the droid
 sm.playSound("explode");
 //permanently clip this drone
 go.setWorldLocation(-100, -100, 0);
 }
 }
}

if (lm.isPlaying()) {
    // Run any un-clipped updates
    go.update(fps, lm.gravity);
        //...

尝试一下,尤其是高射速时,这真的很令人满意。

添加一些火焰瓦片

这些新的基于GameObject的对象将对 Bob 造成即死的效果。它们不会移动,但它们将被动画化。我们将看到,只需设置GameObject已有的属性,我们就可以实现这一点。

将这个功能添加到我们的游戏中非常简单,因为我们已经实现了所需的所有功能。我们已经有了定位和添加新瓦片的方法,检测并响应碰撞的方法,精灵图动画等等。让我们一步步进行,然后我们就可以将这些危险且致命的元素添加到我们的世界中。

我们可以将类的所有功能都放入其构造函数中。我们所要做的就是像配置Grass对象那样配置这个对象,此外,我们还要为其配置所有动画设置,就像我们对PlayerGuard对象所做的那样。fire.png精灵图有三种动画帧,我们希望在一秒钟内播放它们。

添加一些火焰瓦片

创建一个新类,将其命名为Fire,并向其中添加以下代码:

import android.content.Context;

public class Fire extends GameObject{

    Fire(Context context, float worldStartX, 
    float worldStartY, char type, int pixelsPerMetre) {

        final int ANIMATION_FPS = 3;
        final int ANIMATION_FRAME_COUNT = 3;
        final String BITMAP_NAME = "fire";

        final float HEIGHT = 1;
        final float WIDTH = 1;

        setHeight(HEIGHT); // 1 metre tall
        setWidth(WIDTH); // 1 metre wide

        setType(type);
        // Now for the player's other attributes
        // Our game engine will use these
        setMoves(false);
        setActive(true);
        setVisible(true);

        // Choose a Bitmap
        setBitmapName(BITMAP_NAME);
        // Set this object up to be animated
        setAnimFps(ANIMATION_FPS);
        setAnimFrameCount(ANIMATION_FRAME_COUNT);
        setBitmapName(BITMAP_NAME);
        setAnimated(context, pixelsPerMetre, true);

        // Where does the tile start
        // X and y locations from constructor parameters
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

 public void update(long fps, float gravity) {
 }
}

现在,当然,我们需要将下载包中Chapter8/drawable目录下的fire.png精灵图添加到项目的drawable文件夹中。

然后,我们按照为所有新的GameObject派生类所做的方式,以通常的三种方法将它们添加到我们的LevelManager类中。

getBitmap方法中,添加突出显示的代码:

case 'g':
    index = 7;
    break;

case 'f':
 index = 8;
 break;

default:
    index = 0;
    break;

getBitmapIndex方法中:

case 'g':
    index = 7;
    break;

case 'f':
 index = 8;
 break;

default:
    index = 0;
    break;

loadMapData()方法中:

case 'g':
     // Add a guard to the gameObjects
     gameObjects.add(new Guard(context, j, i, c, pixelsPerMetre));
     break;

 case 'f':
 // Add a fire tile the gameObjects
 gameObjects.add(new Fire
 (context, j, i, c, pixelsPerMetre));

 break;

最后,我们在碰撞检测的switch块中添加处理触碰这个可怕瓦片的后果。

case 'g':
    //hit by guard
    sm.playSound("player_burn");
    ps.loseLife();
    location = new PointF(ps.loadLocation().x,
        ps.loadLocation().y);
    lm.player.setWorldLocationX(location.x);
    lm.player.setWorldLocationY(location.y);
    lm.player.setxVelocity(0);
    break;

case 'f':
 sm.playSound("player_burn");
 ps.loseLife();
 location = new PointF(ps.loadLocation().x,
 ps.loadLocation().y);
 lm.player.setWorldLocationX(location.x);
 lm.player.setWorldLocationY(location.y);
 lm.player.setxVelocity(0);
 break;

default:// Probably a regular tile
    if (hit == 1) {// Left or right
        lm.player.setxVelocity(0);
        lm.player.setPressingRight(false);
    }

    if (hit == 2) {// Feet
        lm.player.isFalling = false;
    }
    break;

不如在LevelCave中添加一些f瓦片,并实验玩家能够跳跃过哪些。这将帮助我们在本章后面设计一些具有挑战性的关卡。

添加一些火焰瓦片

我们不希望玩家一直走在草地上,所以让我们添加一些多样性。

眼前一亮

本章接下来的三个部分将纯粹关注外观。我们将添加一整套不同的瓦片图像和匹配的类,这样我们可以使用更多的艺术许可来使我们的关卡更有趣。这些瓦片之间的区别将纯粹是视觉上的,但使它们具有比这更多的功能性将相当简单。

例如,我们可以轻松检测与雪瓦片的碰撞,并让玩家在短暂停止后继续移动以模拟滑行,或者;混凝土瓦片可以让玩家移动得更快,因此改变我们设计大跳跃的方式等等。重点是,你不必仅仅复制粘贴这里呈现的类。

我们还将添加一些完全为了美观的道具:矿车、巨石、石钟乳石等。这些对象不会有碰撞检测。它们将允许关卡设计师使关卡在视觉上更有趣。

提示

要使这些美观元素更具功能性很简单。只需添加一个碰撞箱并在碰撞检测switch块中添加一个案例来处理后果。

可能,我们添加的视觉上最重要的改进将是滚动背景。我们将添加一些类,允许关卡设计师向关卡设计中添加多个不同的滚动背景。

提示

不妨将下载包中Chapter8/drawable文件夹的所有图像添加到项目的drawable文件夹中。这样,你将拥有所有图形,包括本节和接下来两节的图形都准备好了。

新的平台瓦片

现在,按照显示的文件名添加所有这些类。我移除了代码中的所有注释,因为它们在功能上都与Grass类相同。按照显示的名称创建以下每个类,并输入代码:

这是Brick类的代码:

public class Brick extends GameObject {

    Brick(float worldStartX, float worldStartY, char type) {
        setTraversable();
        final float HEIGHT = 1;
        final float WIDTH = 1;
        setHeight(HEIGHT); 
        setWidth(WIDTH); 
        setType(type);
        setBitmapName("brick");
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity) {
    }
}

这是Coal类的代码:

public class Coal extends GameObject {

    Coal(float worldStartX, float worldStartY, char type) {
        setTraversable();
        final float HEIGHT = 1;
        final float WIDTH = 1;
        setHeight(HEIGHT); 
        setWidth(WIDTH);
        setType(type);
        setBitmapName("coal");
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity) {
    }
}

这是Concrete类的代码:

public class Concrete extends GameObject {

    Concrete(float worldStartX, float worldStartY, char type) {
        setTraversable();
        final float HEIGHT = 1;
        final float WIDTH = 1;
        setHeight(HEIGHT);
        setWidth(WIDTH);
        setType(type);
        setBitmapName("concrete");
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity) {
    }
}

以下是Scorched类的代码:

public class Scorched extends GameObject {

    Scorched(float worldStartX, float worldStartY, char type) {
        setTraversable();
        final float HEIGHT = 1;
        final float WIDTH = 1;
        setHeight(HEIGHT);
        setWidth(WIDTH);
        setType(type);
        setBitmapName("scorched");
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity) {
    }
}

这是Snow类的代码:

public class Snow extends GameObject {

    Snow(float worldStartX, float worldStartY, char type) {
        setTraversable();
        final float HEIGHT = 1;
        final float WIDTH = 1;
        setHeight(HEIGHT);
        setWidth(WIDTH);
        setType(type);
        setBitmapName("snow");
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity) {
    }
}

这是Stone类的代码:

public class Stone extends GameObject {

    Stone(float worldStartX, float worldStartY, char type) {
        setTraversable();
        final float HEIGHT = 1;
        final float WIDTH = 1;
        setHeight(HEIGHT);
        setWidth(WIDTH); 
        setType(type);
        setBitmapName("stone");
        setWorldLocation(worldStartX, worldStartY, 0);
        setRectHitbox();
    }

    public void update(long fps, float gravity) {
    }
}

现在,像我们习惯的那样,我们需要将它们全部添加到我们的LevelManager中,在通常的三个地方。

getBitmap()方法中,我们像平常一样将它们添加进去。请注意,尽管这些值是任意的,但我们将为类型 2、3、4 等使用数字。这样在设计关卡时容易记住,我们所有的实际平台都是数字。实际的索引编号对我们来说不重要,只要它们与getBitmapIndex方法中的相同即可。此外,记住我们在LevelData类的注释中有一个类型列表,以便在设计关卡时方便参考。

case 'f':
    index = 8;
    break;

case '2':
 index = 9;
 break;

case '3':
 index = 10;
 break;

case '4':
 index = 11;
 break;

case '5':
 index = 12;
 break;

case '6':
 index = 13;
 break;

case '7':
 index = 14;
 break;

default:
    index = 0;
    break;

getBitmapIndex()中,我们做同样的事情:

case 'f':
    index = 8;
    break;

case '2':
 index = 9;
 break;

case '3':
 index = 10;
 break;

case '4':
 index = 11;
 break;

case '5':
 index = 12;
 break;

case '6':
 index = 13;
 break;

case '7':
 index = 14;
 break;

default:
    index = 0;
    break;

loadMapData()中,我们只需在我们的新GameObjects上调用new(),将它们添加到gameObjects列表中。

case 'f':
    // Add a fire tile the gameObjects
    gameObjects.add(new Fire(context, j, i, c, pixelsPerMetre));
    break;

case '2':
 // Add a tile to the gameObjects
 gameObjects.add(new Snow(j, i, c));
 break;

case '3':
 // Add a tile to the gameObjects
 gameObjects.add(new Brick(j, i, c));
 break;

case '4':
 // Add a tile to the gameObjects
 gameObjects.add(new Coal(j, i, c));
 break;

case '5':
 // Add a tile to the gameObjects
 gameObjects.add(new Concrete(j, i, c));
 break;

case '6':
 // Add a tile to the gameObjects
 gameObjects.add(new Scorched(j, i, c));
 break;

case '7':
 // Add a tile to the gameObjects
 gameObjects.add(new Stone(j, i, c));
 break;

现在,大胆地为LevelCave类添加不同的地形:

新的平台瓦片

现在,我们来添加一些景观对象。

新的景观对象

在这里,我们会添加一些除了看起来漂亮之外什么都不做的对象。我们只需不添加碰撞箱,并将它们随机设置为 z 层-1 或 1,让游戏引擎知道这一点。然后玩家可以出现在它们前面或后面。

我们首先会添加所有类,然后像往常一样更新LevelManager的三个地方。按照以下方式创建每个新类:

这是Boulders类:

public class Boulders extends GameObject {

    Boulders(float worldStartX, float worldStartY, char type) {

        final float HEIGHT = 1;
        final float WIDTH = 3;

        setHeight(HEIGHT); // 1 metre tall
        setWidth(WIDTH); // 1 metre wide

        setType(type);

        // Choose a Bitmap
        setBitmapName("boulder");
        setActive(false);//don't check for collisions etc

        // Randomly set the tree either just in front or just 
        //behind the player -1 or 1
        Random rand = new Random();
        if(rand.nextInt(2)==0) {
            setWorldLocation(worldStartX, worldStartY, -1);
        }else{
            setWorldLocation(worldStartX, worldStartY, 1);//
        }
        //No hitbox!!

    }

    public void update(long fps, float gravity) {
    }
}

从现在开始,我删除了所有注释以节省墨水。该类的功能与Boulders中的相同,只是属性有些许不同。

这是Cart类:

public class Cart extends GameObject {

  Cart(float worldStartX, float worldStartY, char type) {

        final float HEIGHT = 2;
        final float WIDTH = 3;
        setWidth(WIDTH);
        setHeight(HEIGHT);
        setType(type);
        setBitmapName("cart");
        setActive(false);
        Random rand = new Random();
        if(rand.nextInt(2)==0) {
          setWorldLocation(worldStartX, worldStartY, -1);
        }else{
          setWorldLocation(worldStartX, worldStartY, 1);
        }
     }

  public void update(long fps, float gravity) {
     }
}

这是Lampost类的代码:

public class Lampost extends GameObject {

  Lampost(float worldStartX, float worldStartY, char type) {

        final float HEIGHT = 3;
        final float WIDTH = 1;
        setHeight(HEIGHT);
        setWidth(WIDTH); 
        setType(type);
        setBitmapName("lampost");
        setActive(false);
        Random rand = new Random();
        if(rand.nextInt(2)==0) {
          setWorldLocation(worldStartX, worldStartY, -1);
        }else{
          setWorldLocation(worldStartX, worldStartY, 1);
        }
  }

    public void update(long fps, float gravity) {
   }
}

这是Stalagmite类:

import java.util.Random;

public class Stalagmite extends GameObject {

  Stalagmite(float worldStartX, float worldStartY, char type) {

        final float HEIGHT = 3;
        final float WIDTH = 2;
        setHeight(HEIGHT);
        setWidth(WIDTH);
        setType(type);
        setBitmapName("stalacmite");
        setActive(false);
        Random rand = new Random();
        if(rand.nextInt(2)==0) {
         setWorldLocation(worldStartX, worldStartY, -1);
        }else{
         setWorldLocation(worldStartX, worldStartY, 1);
        }
    }

    public void update(long fps, float gravity) {
    }
}

这是Stalactite类:

import java.util.Random;

public class Stalactite extends GameObject {

  Stalactite(float worldStartX, float worldStartY, char type) {

        final float HEIGHT = 3;
        final float WIDTH = 2;
        setHeight(HEIGHT);
        setWidth(WIDTH);
        setType(type);
        setBitmapName("stalactite");
        setActive(false);
        Random rand = new Random();
        if(rand.nextInt(2)==0) {
          setWorldLocation(worldStartX, worldStartY, -1);
        }else{
          setWorldLocation(worldStartX, worldStartY, 1);
        }
  }

     public void update(long fps, float gravity) {
     }
}

这是Tree类:

import java.util.Random;

public class Tree extends GameObject {

  Tree(float worldStartX, float worldStartY, char type) {

       final float HEIGHT = 4;
       final float WIDTH = 2;
       setWidth(WIDTH);
        setHeight(HEIGHT);
        setType(type);
        setBitmapName("tree1");
        setActive(false);
        Random rand = new Random();
        if(rand.nextInt(2)==0) {
          setWorldLocation(worldStartX, worldStartY, -1);
        }else{
          setWorldLocation(worldStartX, worldStartY, 1);
        }
     }

     public void update(long fps, float gravity) {
     }
}

这是Tree2类:

import java.util.Random;

public class Tree2 extends GameObject {

  Tree2(float worldStartX, float worldStartY, char type) {

        final float HEIGHT = 4;
        final float WIDTH = 2;
        setWidth(WIDTH);
        setHeight(HEIGHT);
        setType(type);
        setBitmapName("tree2");
        setActive(false);
        Random rand = new Random();
        if(rand.nextInt(2)==0) {
          setWorldLocation(worldStartX, worldStartY, -1);
        }else{
          setWorldLocation(worldStartX, worldStartY, 1);
        }
  }

     public void update(long fps, float gravity) {
     }
}

这就是所有新的景观对象类。现在,我们可以在LevelManager类中用七种新类型更新getBitmap方法。

case '7':
    index = 14;
    break;

case 'w':
 index = 15;
 break;

case 'x':
 index = 16;
 break;

case 'l':
 index = 17;
 break;

case 'r':
 index = 18;
 break;

case 's':
 index = 19;
 break;

case 'm':
 index = 20;
 break;

case 'z':
 index = 21;
 break;

default:
    index = 0;
    break;

以同样的方式更新getBitmapIndex方法:

case '7':
    index = 14;
    break;

case 'w':
 index = 15;
 break;

case 'x':
 index = 16;
 break;

case 'l':
 index = 17;
 break;

case 'r':
 index = 18;
 break;

case 's':
 index = 19;
 break;

case 'm':
 index = 20;
 break;

case 'z':
 index = 21;
 break;

default:
    index = 0;
    break;

最后,确保我们的新景观物品被添加到gameObjects数组列表中:

case '7':
    // Add a tile to the gameObjects
    gameObjects.add(new Stone(j, i, c));
    break;

case 'w':
 // Add a tree to the gameObjects
 gameObjects.add(new Tree(j, i, c));
 break;

case 'x':
 // Add a tree2 to the gameObjects
 gameObjects.add(new Tree2(j, i, c));
 break;

case 'l':
 // Add a tree to the gameObjects
 gameObjects.add(new Lampost(j, i, c));
 break;

case 'r':
 // Add a stalactite to the gameObjects
 gameObjects.add(new Stalactite(j, i, c));
 break;

case 's':
 // Add a stalagmite to the gameObjects
 gameObjects.add(new Stalagmite(j, i, c));
 break;

case 'm':
 // Add a cart to the gameObjects
 gameObjects.add(new Cart(j, i, c));
 break;

case 'z':
 // Add a boulders to the gameObjects
 gameObjects.add(new Boulders(j, i, c));
 break;

现在,我们可以设计带有景观的关卡。注意当对象在层零与层一上绘制时外观上的细微差别,以及玩家角色如何穿过它们前面或后面:

新的景观对象

提示

当然,如果你想要碰撞到路灯、被石笋刺穿,或者跳到矿车顶上,只需给它们一个碰撞箱即可。

我们还有一种美化游戏世界的方法。

滚动的视差背景

视差背景是滚动的背景,我们根据它们距离的远近来减慢滚动速度。因此,如果玩家脚边有草地,我们会快速滚动它。然而,如果远处有山脉,我们会慢慢滚动它。这种效果可以给玩家带来运动的感觉。

为了实现这一功能,我们首先将添加一个数据结构来表示背景的参数。我们将这个类称为BackgroundData,然后实现一个Background类,它具有控制滚动所需的功能,然后我们将会看到如何在我们的关卡设计中定位和定义背景。最后,我们将编写一个drawBackground方法,我们将会从常规的draw方法中调用它。

确保你已经将从下载包的Chapter8/drawable文件夹中的所有图像添加到你的项目的drawable文件夹中。

首先,让我们构建一个简单的类来保存定义我们背景的数据结构。正如在下一个代码块中我们可以看到的,我们有很多参数和成员变量。我们需要知道哪个位图将代表背景,在z轴上哪个层面绘制它(前面为 1,后面为-1),在y轴上它在全球的哪个位置开始和结束,背景滚动的速度有多快,以及背景的高度是多少。

isParallax布尔值旨在提供一种让背景静止的选项,但我们不会实现这个功能。当你看到背景类的代码时,你会发现如果你想要,添加这个功能是非常简单的。

创建一个新类,将其命名为BackgroundData,然后用以下代码实现它:

public class BackgroundData {
  String bitmapName;
     boolean isParallax;
     //layer 0 is the map
     int layer;
     float startY;
     float endY;
     float speed;
     int height;
     int width;

     BackgroundData(String bitmap, boolean isParallax, 
     int layer, float startY, float endY, 
     float speed, int height){

      this.bitmapName = bitmap;
      this.isParallax = isParallax;
      this.layer = layer;
      this.startY = startY;
      this.endY = endY;
      this.speed = speed;
      this.height = height;
  }
}

现在,我们在LevelData类中添加了一个我们新类型的ArrayList

ArrayList<String> tiles;
ArrayList<BackgroundData> backgroundDataList;

// This class will evolve along with the project

接下来,让我们创建Background类本身。创建一个新类,并将其命名为Background。首先,我们设置一组变量来保存背景图像以及它的反转副本。我们将通过将图像背靠背交替使用常规图像和反转图像,使背景看起来无限。我们将在代码中进一步了解如何实现这一点。

我们还有用于存储图像的宽度和高度的像素变量。reversedFirst布尔值将决定当前在屏幕左侧(首先)绘制哪个图像副本,并将在玩家移动和图像滚动时改变。xClip变量将保存我们将在屏幕左侧边缘开始绘制的图像的x轴的确切像素。

yendYzspeed成员变量用于保存作为参数传递的相关值:

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;

public class Background {

     Bitmap bitmap;
     Bitmap bitmapReversed;

     int width;
     int height;

     boolean reversedFirst;
     int xClip;// controls where we clip the bitmaps each frame
     float y;
     float endY;
     int z;

     float speed;
     boolean isParallax;//Not currently used

在构造函数中,我们从作为参数传递的图形文件名创建一个 Android 资源 ID。然后,通过调用BitmapFactory.decodeResource()创建实际的位图。我们将reversedFirst设置为false,因此将从屏幕左侧开始使用图像的正常(非反转)副本。我们初始化成员变量,并通过调用Bitmap.createScaledBitmap()并传入位图、屏幕宽度和背景在游戏世界中的高度乘以pixelsPerMetre来缩放我们刚刚创建的位图,使位图恰好适合当前设备屏幕的尺寸。

提示

请注意,我们必须为背景设计选择适当的高度,否则它们会出现拉伸。

构造函数中最后要做的就是在调用createScaledBitmap方法时创建一个Matrix对象,并连同位图一起传递,这样我们现在在bitmapReversed Bitmap对象中存储了一个背景图像的反转副本。

  Background(Context context, int yPixelsPerMetre, 
    int screenWidth, BackgroundData data){

      int resID =   context.getResources().getIdentifier
      (data.bitmapName, "drawable", 
      context.getPackageName());

          bitmap = BitmapFactory.decodeResource
          (context.getResources(), resID);

          // Which version of background (reversed or regular) is // currently drawn first (on left)
          reversedFirst = false;

          //Initialize animation variables.
          xClip = 0;  //always start at zero
          y = data.startY;
          endY = data.endY;
          z = data.layer;
          isParallax = data.isParallax;
          speed = data.speed; //Scrolling background speed

          //Scale background to fit the screen.
          bitmap = Bitmap.createScaledBitmap(bitmap, screenWidth,
                data.height * yPixelsPerMetre
                , true); 

          width = bitmap.getWidth();
          height = bitmap.getHeight();

          // Create a mirror image of the background
          Matrix matrix = new Matrix();  
          matrix.setScale(-1, 1); //Horizontal mirror effect.
          bitmapReversed = Bitmap.createBitmap(
          bitmap, 0, 0, width, height, matrix, true);

    }
}

现在,我们在关卡设计中添加两个背景。我们填写已经讨论过的所需参数。请注意,第 1 层的“草地”背景滚动速度比-1 层的“天际线”背景快得多。这将产生所需的视差效果。在LevelCave构造函数的末尾添加以下代码:

backgroundDataList = new ArrayList<BackgroundData>();
// note that speeds less than 2 cause problems
this.backgroundDataList.add(
  new BackgroundData("skyline", true, -1, 3, 18, 10, 15 ));

this.backgroundDataList.add(
  new BackgroundData("grass", true, 1, 20, 24, 24, 4 ));

注意

大多数洞穴确实没有草地和天际线,这只是一个演示,让代码工作起来。我们将在本章稍后重新设计LevelCave,并设计一些更合适的关卡。

现在,我们通过声明一个Arraylist对象作为LevelManager类的成员,用我们的LevelManager类加载它们。

LevelData levelData;
ArrayList<GameObject> gameObjects;
ArrayList<Background> backgrounds;

然后,在LevelManager中添加一个新方法来加载背景数据:

private void loadBackgrounds(Context context, 
  int pixelsPerMetre, int screenWidth) {

  backgrounds = new ArrayList<Background>();
     //load the background data into the Background objects and
     // place them in our GameObject arraylist
     for (BackgroundData bgData : levelData.backgroundDataList) {
            backgrounds.add(new Background(context,       
            pixelsPerMetre, screenWidth, bgData));
     }
}

我们在LevelManager构造函数中调用这个新方法:

// Load all the GameObjects and Bitmaps
loadMapData(context, pixelsPerMetre, px, py);
loadBackgrounds(context, pixelsPerMetre, screenWidth);

并且,不是最后一次,我们将升级我们的Viewport类,让PlatformView方法能够获取它们需要的信息,以绘制视差背景。

public int getPixelsPerMetreY(){
  return  pixelsPerMetreY;
}

public int getyCentre(){
  return screenCentreY;
}

public float getViewportWorldCentreY(){
  return currentViewportWorldCentre.y;
}

然后,我们将在PlatformView类中添加一个实际执行绘图的方法。接下来,我们会在onDraw()中的恰当位置调用这个方法。请注意,我们正在使用刚刚添加到Viewport类中的新方法。

首先,我们定义四个Rect对象,用来保存bitmapreversedBitmap的起始和结束点。

按照所示实现drawBackground方法的第一部分:

private void drawBackground(int start, int stop) {

     Rect fromRect1 = new Rect();
     Rect toRect1 = new Rect();
     Rect fromRect2 = new Rect();
     Rect toRect2 = new Rect();

现在,我们只需遍历所有背景,使用startstop参数来确定哪些背景具有我们当前感兴趣的z层。

     for (Background bg : lm.backgrounds) {
     if (bg.z < start && bg.z > stop) {

接下来,我们将背景的世界坐标发送到Viewport类进行裁剪。如果没有裁剪(并且应该绘制),我们将使用之前添加到我们的Viewport类中的新方法,获取y轴上的起始像素坐标和结束像素坐标。请注意,我们将结果转换为int变量,以便绘制到屏幕上。

          // Is this layer in the viewport?
            // Clip anything off-screen
            if (!vp.clipObjects(-1, bg.y, 1000, bg.height)) {
                float floatstartY = ((vp.getyCentre() -                     
                    ((vp.getViewportWorldCentreY() - bg.y) * 
                    vp.getPixelsPerMetreY())));

                int startY = (int) floatstartY;

                float floatendY = ((vp.getyCentre() -           
                    ((vp.getViewportWorldCentreY() - bg.endY) *                                 
                    vp.getPixelsPerMetreY())));

                int endY = (int) floatendY;

下面的代码块是真正行动发生的地方。我们用两个Bitmap对象的起始和结束坐标初始化四个Rect对象。请注意,计算出的点(或像素)由xClip确定,最初为零。因此,首先我们会看到background(如果它没有被剪辑)拉伸到屏幕的宽度。很快,我们会看到根据 Bob 的速度修改xClip,并展示每个位图的不同区域:

        // Define what portion of bitmaps to capture 
        // and what coordinates to draw them at
        fromRect1 = new Rect(0, 0, bg.width - bg.xClip,     
          bg.height);

        toRect1 = new Rect(bg.xClip, startY, bg.width, endY);
             fromRect2 = new Rect(bg.width - bg.xClip, 0, bg.width, bg.height);

        toRect2 = new Rect(0, startY, bg.xClip, endY);
        }// End if (!vp.clipObjects...

现在,我们确定当前首先绘制的是哪种背景(正常或反向),然后先绘制该背景,接着绘制另一种。

          //draw backgrounds
            if (!bg.reversedFirst) {

                canvas.drawBitmap(bg.bitmap,
                    fromRect1, toRect1, paint);
                canvas.drawBitmap(bg.bitmapReversed, 
                    fromRect2, toRect2, paint);

            } else {
                canvas.drawBitmap(bg.bitmap, 
                    fromRect2, toRect2, paint);

                canvas.drawBitmap(bg.bitmapReversed, 
                    fromRect1, toRect1, paint);
            }

我们可以根据 Bob 的速度和方向滚动,lv.player.getxVelocity(),如果xClip已达到当前第一个背景的末端,if (bg.xClip >= bg.width),只需将xClip设为零,并改变我们首先展示的位图。

          // Calculate the next value for the background's
            // clipping position by modifying xClip
            // and switching which background is drawn first,
            // if necessary.
            bg.xClip -= lm.player.getxVelocity() / (20 / bg.speed);
            if (bg.xClip >= bg.width) {
                bg.xClip = 0;
                bg.reversedFirst = !bg.reversedFirst;
            } 
            else if (bg.xClip <= 0) {
                bg.xClip = bg.width;
                bg.reversedFirst = !bg.reversedFirst;

            }
        }
    }
}

然后,在z层小于零的背景的游戏对象之前,我们添加对drawBackground()的调用。

// Rub out the last frame with arbitrary color
paint.setColor(Color.argb(255, 0, 0, 255));
canvas.drawColor(Color.argb(255, 0, 0, 255));

// Draw parallax backgrounds from -1 to -3
drawBackground(0, -3);

// Draw all the GameObjects
Rect toScreen2d = new Rect();

在绘制子弹之后,但在那些z顺序大于零的背景的调试文本之前。

// Draw parallax backgrounds from layer 1 to 3
drawBackground(4, 0);

// Text for debugging

现在,我们可以真正开始发挥创意设计关卡。

滚动视差背景

很快,我们将制作一些真正可玩的关卡,使用我们在过去四章中实现的所有功能。在我们这样做之前,让我们在Viewport类中找点乐趣。

对于玩家来说,在关卡中扫描并规划路线将非常有用。同样,在设计关卡时,放大并围绕关卡查看某个特定部分的外观,而无需让玩家角色到达该部分以便在屏幕上看到它,也会很有帮助。所以,让我们将暂停屏幕变成一个可移动视口。

带有可移动视口的暂停菜单

这样做既好又快。我们只需向Viewport类添加一堆新方法来改变焦点中心。然后,我们将在InputController中调用它们。

如果你记得我们在第六章实现InputController类时,平台游戏 – Bob, Beeps 和 Bumps,我们将所有控制逻辑封装在一个if(playing)测试中。我们还在else子句中实现了暂停按钮。我们将要做的就是将左、右、跳跃和射击按钮分别用作左、右、上和下来移动视口。

首先,向Viewport类添加以下方法:

public void moveViewportRight(int maxWidth){
  if(currentViewportWorldCentre.x < maxWidth -       
    (metresToShowX/2)+3) {

     currentViewportWorldCentre.x += 1;
  }
}

public void moveViewportLeft(){
  if(currentViewportWorldCentre.x > (metresToShowX/2)-3){
    currentViewportWorldCentre.x -= 1;
     }
}

public void moveViewportUp(){
  if(currentViewportWorldCentre.y > (metresToShowY /2)-3) {
        currentViewportWorldCentre.y -= 1;
   }
}

public void moveViewportDown(int maxHeight){
  if(currentViewportWorldCentre.y < 
    maxHeight - (metresToShowY / 2)+3) {

    currentViewportWorldCentre.y += 1;
  }
}

现在,将以下调用添加到我们在InputController类中刚刚讨论的if条件的else子句中。

//Move the viewport around to explore the map
switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
  case MotionEvent.ACTION_DOWN:
 if (right.contains(x, y)) {
 vp.moveViewportRight(l.mapWidth);
 } else if (left.contains(x, y)) {
 vp.moveViewportLeft();
 } else if (jump.contains(x, y)) {
 vp.moveViewportUp();
 } else if (shoot.contains(x, y)) {
 vp.moveViewportDown(l.mapHeight);
 } else if (pause.contains(x, y)) {
 l.switchPlayingStatus();
 }
      break;
}

在暂停屏幕上,玩家可以四处查看并规划他们在更复杂关卡中的路线。他们可能需要这么做。

关卡和游戏规则

我们已经实现了许多功能,但我们仍然没有一个方法将这些功能整合成一个可玩的游戏。我们需要能够在关卡之间移动,并且在移动时保持玩家状态。

在关卡之间移动

因为我们将要设计四个关卡,我们希望玩家能够在它们之间移动。首先,让我们在LevelManager构造函数的开始部分的switch语句中添加代码,包括我们即将构建的所有四个关卡:

switch (level) {
  case "LevelCave":
     levelData = new LevelCave();
     break;

// We can add extra levels here
case "LevelCity": 
 levelData = new LevelCity(); 
 break; 

case "LevelForest": 
 levelData = new LevelForest(); 
 break;

case "LevelMountain": 
 levelData = new LevelMountain(); 
 break;
}

如我们所知,我们通过从PlatformView构造函数中调用loadLevel()来开始游戏。参数包括关卡名称和玩家生成的坐标。如果你正在设计自己的关卡,那么你需要决定从哪个关卡和坐标开始。如果你将跟随我提供的关卡,请在PlatformView的构造函数中将loadLevel()的调用设置如下:

loadLevel("LevelCave", 1, 16);

if(lm.isPlaying())块中,在update方法中,我们每一帧设置视口以玩家为中心;添加以下代码以检测(并残忍地消灭)玩家如果他掉出地图,以及当他的生命值耗尽时,使游戏重新开始,拥有三条生命,零金钱,没有升级:

if (lm.isPlaying()) {
    // Reset the players location as 
    // the world centre of the viewport
    //if game is playing
    vp.setWorldCentre(lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().x,
        lm.gameObjects.get(lm.playerIndex)
        .getWorldLocation().y);

 //Has player fallen out of the map?
 if (lm.player.getWorldLocation().x < 0 ||
 lm.player.getWorldLocation().x > lm.mapWidth ||
 lm.player.getWorldLocation().y > lm.mapHeight) {

 sm.playSound("player_burn");
 ps.loseLife();
 PointF location = new PointF(ps.loadLocation().x,
 ps.loadLocation().y);

 lm.player.setWorldLocationX(location.x);
 lm.player.setWorldLocationY(location.y);
 lm.player.setxVelocity(0);
 }

 // Check if game is over
 if (ps.getLives() == 0) {
 ps = new PlayerState();
 loadLevel("LevelCave", 1, 16);
 }
}

现在,我们可以创建一个特殊的GameObject类,当玩家接触这个类时,会将玩家传送到一个预定的关卡和位置。然后我们可以策略性地将这些对象添加到我们的关卡设计中,它们将作为我们关卡之间的链接。创建一个名为Teleport的新类。如果你还没有这样做,请将Chapter8/drawable文件夹中的door.png文件添加到项目的drawable文件夹中。

这就是我们的Teleport对象在游戏中的样子:

在关卡之间移动

让我们创建一个简单的类来保存每个Teleport对象所需的数据。创建一个名为Location的新类,如下所示:

public class Location {
     String level;
     float x;
     float y;

     Location(String level, float x, float y){
        this.level = level;
        this.x = x;
        this.y = y;
     }
}

实际的Teleport类看起来像任何其他的GameObject类,但请注意它还包含一个Location成员变量。我们将看到关卡设计将如何保存Teleport的目的地,LevelManager类将初始化它,然后当玩家与它碰撞时,我们可以加载新的位置,将玩家送往他的目的地。

public class Teleport extends GameObject {

    Location target;

    Teleport(float worldStartX, float worldStartY, 
        char type, Location target) {

        final float HEIGHT = 2;
        final float WIDTH = 2;
        setHeight(HEIGHT); // 2 metres tall
        setWidth(WIDTH); // 1 metre wide
        setType(type);
        setBitmapName("door");

        this.target = new Location(target.level, 
            target.x, target.y);

        // Where does the tile start
        // X and y locations from constructor parameters
        setWorldLocation(worldStartX, worldStartY, 0);

        setRectHitbox();
    }

    public Location getTarget(){
        return target;
    }

    public void update(long fps, float gravity){
    }
}

为了让我们的Teleport类以让关卡设计师决定它将确切执行什么的方式工作,我们需要像这样向我们的LevelData类中添加内容:

ArrayList<String> tiles;
ArrayList<BackgroundData> backgroundDataList;
ArrayList<Location> locations;

// This class will evolve along with the project

然后,我们需要在想要设置传送门/门的关卡设计中的相应位置添加一个t,并在关卡类的构造函数中添加如下代码行。

请注意,你可以在地图中设置任意数量的Teleport对象,只要它们在代码中定义的顺序与设计中出现的顺序相匹配。当我们稍后查看实际的关卡设计时,我们会确切地看到这是如何工作的,但代码将如下所示:

// Declare the values for the teleports in order of appearance
locations = new ArrayList<Location>();
this.locations.add(new Location("LevelCity", 118f, 18f));

与往常一样,我们需要更新LevelManager类以加载和定位我们的传送点。以下是getBitmap()的新代码:

case 'z':
  index = 21;
  break;

case 't':
 index = 22;
 break;

default:
  index = 0;
  break;

getBitmapIndex()的新代码:

case 'z':
  index = 21;
     break;

case 't':
 index = 22;
 break;

default:
  index = 0;
  break;

在加载阶段,我们还需要跟踪我们的Teleport对象,以防有多个。所以,在loadMapData方法中添加一个新的局部变量,如下所示:

//Keep track of where we load our game objects
int currentIndex = -1;
int teleportIndex = -1;
// how wide and high is the map? Viewport needs to know

对于LevelManager类,我们最终需要初始化所有从关卡设计中获取的传送数据,将其存储在对象中,并添加到我们的gameObject ArrayList中。

case 'z':
    // Add a boulders to the gameObjects
    gameObjects.add(new Boulders(j, i, c));
    break;

 case 't':
 // Add a teleport to the gameObjects
 teleportIndex++;
 gameObjects.add(new Teleport(j, i, c,
 levelData.locations.get(teleportIndex)));

 break;

我们已经非常接近能够到处传送了。我们需要检测与传送点的碰撞,然后在玩家所需的位置加载新关卡。这段代码将放在PlatformView类中的碰撞检测开关块里,如下所示:

case 'f':
    sm.playSound("player_burn");
    ps.loseLife();
    location = new PointF(ps.loadLocation().x,
      ps.loadLocation().y); 
    lm.player.setWorldLocationX(location.x);
    lm.player.setWorldLocationY(location.y);
    lm.player.setxVelocity(0);
    break;

case 't':
 Teleport teleport = (Teleport) go;
 Location t = teleport.getTarget();
 loadLevel(t.level, t.x, t.y);
 sm.playSound("teleport");
 break;

default:// Probably a regular tile
    if (hit == 1) {// Left or right
        lm.player.setxVelocity(0);
        lm.player.setPressingRight(false);
    }
    if (hit == 2) {// Feet
        lm.player.isFalling = false;
    }
    break;

当加载新关卡时,PlayerMachineGunBullet对象都将从头开始创建。因此,我们需要在loadLevel方法中添加一行代码,将当前的机枪射速从PlayerState类重新加载到MachineGun类中。添加高亮显示的代码:

ps.saveLocation(location);

// Reload the players current fire rate from the player state
lm.player.bfg.setFireRate(ps.getFireRate());

现在,我们可以真正开始设计关卡了。

关卡设计

你可以从Chapter8/java文件夹中复制并粘贴四个类到你的项目中开始游戏,或者你可以从头开始设计自己的关卡。这些关卡相当大,复杂且难以通关。由于篇幅限制,无法在书籍或电子书中以有意义的方式呈现关卡设计,因此你需要打开LevelCaveLevelCityLevelForestLevelMountain设计文件,以查看四个关卡的详细信息。

然而,以下内容将简要讨论四个设计中的关卡、图片和一些截图,但不会包含实际的代码。

注意

请注意,以下截图展示了本章最后将要介绍的新 HUD。

洞穴

洞穴关卡是整个游戏的开始。它不仅包含一些令人稍微感到沮丧的跳跃,还有大量的火焰,一旦跌落可能致命。

洞穴

由于玩家开始时只有一把微弱的机枪,因此关卡中只有少数无人机。但有两个别扭的守卫需要翻越。

洞穴

城市

城市中拥有巨大的奖励,尤其是在左下角收集硬币和左上角升级机枪。

城市

然而,如果玩家想要收集所有散落的硬币而不选择放弃它们,底层有一个跳跃非常别扭的守卫。必须从左侧几乎垂直上升,这很可能会让玩家感到沮丧。如果玩家选择不去升级机枪,他可能会在与下一层门口外的双守卫战斗中遇到困难。

城市

森林

森林可能是所有关卡中最困难的一个,有一段残酷的长距离跳跃,非常容易跳过或未跳够。

森林

当 Bob 的像素悬挂在平台边缘时,超过一打无人机正等着猛扑向他。

森林

山脉

清新的山间空气意味着 Bob 几乎要成功了。四周没有守卫或无人机的踪影。

山脉

然而,看看那条蜿蜒的跳跃路径,如果 Bob 放错了一个像素的位置,大部分路径都会让他直接掉回底部。

山脉

提示

如果你想要在不完成前面的艰难关卡的情况下尝试每个关卡,当然,你可以直接从你选择的关卡和位置开始。为此,只需将PlatformView构造函数中的loadLevel()调用更改为以下之一:

loadLevel("LevelMountain", 118, 17);
loadLevel("LevelForest", 1, 17);
loadLevel("LevelCity", 118, 18);
loadLevel("LevelCave", 1, 16);

HUD

画龙点睛之笔是添加一个 HUD。PlatformViewdraw方法中的这段代码使用了现有游戏对象中的一些图像。

在最后一次调用drawBackground()之后,并在绘制调试文本之前添加代码:

// Draw the HUD
// This code needs bitmaps: extra life, upgrade and coin
// Therefore there must be at least one of each in the level

int topSpace = vp.getPixelsPerMetreY() / 4;
int iconSize = vp.getPixelsPerMetreX();
int padding = vp.getPixelsPerMetreX() / 5;
int centring = vp.getPixelsPerMetreY() / 6;
paint.setTextSize(vp.getPixelsPerMetreY()/2);
paint.setTextAlign(Paint.Align.CENTER);

paint.setColor(Color.argb(100, 0, 0, 0));
canvas.drawRect(0,0,iconSize * 7.0f, topSpace*2 + iconSize,paint);
paint.setColor(Color.argb(255, 255, 255, 0));

canvas.drawBitmap(lm.getBitmap('e'), 0, topSpace, paint);
canvas.drawText("" + ps.getLives(), (iconSize * 1) + padding, 
  (iconSize) - centring, paint);

canvas.drawBitmap(lm.getBitmap('c'), (iconSize * 2.5f) + padding, 
  topSpace, paint);

canvas.drawText("" + ps.getCredits(), (iconSize * 3.5f) + padding * 2, (iconSize) - centring, paint);

canvas.drawBitmap(lm.getBitmap('u'), (iconSize * 5.0f) + padding, 
  topSpace, paint);

canvas.drawText("" + ps.getFireRate(), (iconSize * 6.0f) + padding * 2, (iconSize) - centring, paint);

我想我们完成了!

总结

我们完成了这个平台游戏,因为篇幅有限。为什么不尝试实施以下一些或全部改进和功能呢?

修改Player类中的代码,使 Bob 逐渐加速和减速,而不是一直以全速运行。只需在玩家按住左右方向的每个帧增加速度,在他们不按的每个帧减少速度。

完成这些后,将前面的代码添加到update方法中的碰撞检测switch块中,以使玩家在雪地上打滑,在混凝土上加速,并为每种瓦片类型提供不同的行走/着陆声效。

在 Bob 身上画一把枪,并调整Bullet对象生成的高度,使其看起来是从他的机枪枪管中射出的。

让一些对象可以被推动。在GameObject中添加一个isPushable成员,并让碰撞检测简单地使对象后退一点。也许,Bob 可以把矿车推入火中,以跳过特别宽的火坑。请注意,推动那些掉到另一个层次的对象将比推动保持在相同y坐标的对象复杂得多。

可破坏的瓦片听起来很有趣。给它们一个力量变量,当被子弹击中时递减,当达到零时从gameObjects中移除。

移动平台是优秀平台游戏的重要组成部分。只需向瓦片对象添加航点,并在update方法中添加移动代码。挑战将是如何分配航点。你可以让它们都向左或向右,或者向上或向下移动固定的空间数量,或者像我们编写Guard对象那样,使用某种setTileWaypoint方法。

通过保存玩家收集到的硬币总数,记住哪些关卡已被解锁,并在菜单屏幕上提供重新玩任何已解锁关卡的选项,使游戏更具持久性。

使用传送点作为路标,让游戏变得更容易。调整视口缩放以适应不同屏幕尺寸。当前的缩放对于一些小手机来说可能有点太低了。

加入计时跑以获得高分、排行榜和成就,并增加更多关卡。

在下一章中,我们将看到一个更小的项目,但仍然很有趣,因为我们将使用 OpenGL ES 进行超快速、流畅的绘制。

第九章:使用 OpenGL ES 2 以 60 FPS 实现小行星游戏

欢迎来到最终项目。在接下来的三章中,我们将使用 OpenGL ES 2 图形 API 构建一个类似小行星的游戏。如果你好奇 OpenGL ES 2 到底是什么,我们将在本章后面讨论其细节。

我们将构建一个非常简单但有趣且具有挑战性的游戏,可以在一次绘制和动画化数百个对象,即使在相当老旧的 Android 设备上也能实现。

使用 OpenGL,我们将把绘图效率提升到一个更高的层次,通过一些不太复杂的数学运算,我们的移动和碰撞检测将比之前的项目大大增强。

在本章结束时,我们将拥有一个基本的可工作的 OpenGL ES 2 引擎,以 60 FPS 或更高的帧率绘制我们简单但暂时静态的飞船到屏幕上。

提示

如果你从未见过或玩过 1979 年 11 月发布的 80 年代街机游戏(Asteroids),为什么不去看看它的克隆版或视频呢?

www.freeasteroids.org/免费玩网络游戏。

在 YouTube 上观看www.youtube.com/watch?v=WYSupJ5r2zo

让我们确切地讨论一下我们打算构建的内容。

小行星模拟器

我们的游戏设定在一个四个方向滚动的世界中,玩家可以在寻找小行星的同时穿越这个世界。世界将被一个矩形边界所包围,以防止小行星漂移得太远,这个边界也将成为玩家需要避开的另一个障碍。

游戏控制

我们将重新使用我们的InputController类,并进行一些简单的修改,甚至可以保持相同的按钮布局。然而,正如我们将看到的,我们将以与复古平台游戏非常不同的方式在屏幕上绘制按钮。此外,玩家将旋转飞船向左和向右 360 度,而不是左右走动。跳跃按钮将变成一个推进开关,以开启或关闭前进动力,而射击按钮将保持原样。我们还将保持暂停按钮在同一位置。

游戏规则

当小行星撞击边界时,它将反弹回游戏世界。如果玩家撞击边界,将损失一条生命,并且飞船将在屏幕中心重新生成。如果小行星撞击飞船,这将是致命的。

玩家将从三条生命开始,必须清除所有小行星模拟器中的小行星。抬头显示(HUD)将显示剩余小行星和生命的计数。如果玩家清除了所有小行星,下一波将会比上一波更多,并且它们的移动速度会稍快一些。每清除一波,玩家将获得一条额外的生命。

我们将在项目进行中实施这些规则。

介绍 OpenGL ES 2

OpenGL ES 2 是针对嵌入式系统的开放图形库OpenGL)的第二个主要版本。它是桌面系统 OpenGL 在移动设备上的化身。

为什么使用它以及它是如何工作的?

OpenGL 作为一个本地进程运行,而不是像我们其他 Java 代码那样在 Dalvik 虚拟机上运行。这是它超级快速的一个原因。OpenGL ES API 消除了与本地代码交互的所有复杂性,而 OpenGL 本身在其本地代码库中也提供了非常高效和快速的算法。

第一版的 OpenGL 在 1992 年完成。重点是即使在那时,OpenGL 也使用了可以说最高效的代码和算法来绘制图形。现在,20 多年后,它一直在不断改进和完善,同时适配最新的图形硬件,包括移动端和桌面端。所有移动 GPU 制造商都专门设计硬件以兼容最新版本的 OpenGL ES。

因此,试图改进 OpenGL ES 可能是一项愚蠢的尝试。

提示

当专门为 Windows 设备开发时,还有另一个可行的图形 API 选项,即 DirectX。

第二版的亮点是什么?

第一版的 OpenGL ES 在当时的确令人印象深刻。我记得当我第一次在手机上玩 3D 射击游戏时,几乎从椅子上掉下来!现在这当然很常见。然而,与桌面版的 OpenGL 相比,OpenGL ES 1 有一个重大缺点。

OpenGL ES 1 有一个所谓的固定功能管线。要绘制的几何图形输入到 GPU 中,它就会被绘制,但任何对单个像素的进一步操作都需要在 OpenGL ES 接管游戏帧的绘制之前进行。

现在,通过 OpenGL ES 2,我们可以使用所谓的可编程管线。也就是说,我们可以将图形发送出去进行绘制,同时我们还可以编写在 GPU 上运行的代码,这种代码能够独立操作每一个像素。这是一个非常强大的特性,尽管我们不会深入探讨它。

在 GPU 上运行的这段额外代码称为着色器程序。我们可以编写代码在所谓的顶点着色器中操作图形的几何(位置)。我们还可以编写代码,单独操作每一个像素的外观,这称为片断着色器

注意

实际上,我们甚至可以比像素操作做得更好。片断不一定是像素。这取决于硬件和正在处理的图形的具体性质。它可以是一个以上的像素或子像素:屏幕硬件中组成一个像素的多个光线之一。

对于这种简单游戏而言,OpenGL ES 2 的缺点在于,即使你不会大量使用它们,也必须至少提供一个顶点和片断着色器。然而,正如我们将看到的,这并不困难。虽然我们不会深入探讨着色器,但我们将使用GL Shader Language (GLSL)编写一些着色器代码,并一窥它们所提供的可能性。

提示

如果可编程图形管线和着色器的力量让你兴奋到无法等待,那么我强烈推荐 Jacobo Rodríguez 编写的GLSL Essentials

GLSL 基础

这本书探讨了桌面上的 OpenGL 着色器,对于任何具有基本编程知识并愿意学习不同语言(GLSL)的读者来说,都是高度可访问的,尽管它有一些与 Java 相似的语法。

我们将如何使用 OpenGL ES 2?

我们将如何使用 OpenGL ES 2?

在 OpenGL 中,一切都是点、线或三角形。此外,我们可以将颜色和纹理附加到这种基本几何图形上,并将这些元素组合起来制作出现在现代移动游戏中的复杂图形。

我们将使用各种类型的元素(点、线和三角形),这些元素统称为图元。

在这个项目中,我们将不使用纹理。幸运的是,未纹理化的图元的外观适合构建类似小行星的游戏。

除了图元,OpenGL 还使用矩阵。矩阵是一种进行算术的方法和结构。这种算术可以从非常简单的高中水平的计算来移动(转换)一个坐标,也可以进行更复杂的计算,将我们的游戏世界坐标转换为 GPU 可以使用的 OpenGL 屏幕坐标。

重点在于,无论是矩阵还是使用它们的方法,完全由 OpenGL API 提供。这意味着我们只需了解哪些方法可以进行哪些图形操作,无需关心背后可能复杂的数学运算(在 GPU 上进行的)。

学习 OpenGL 中的着色器、图元和矩阵的最佳方式是继续使用它们。

准备 OpenGL ES 2

首先,我们从Activity类开始,这和之前一样,是进入我们游戏的入口点。创建一个新项目,在应用名称字段中输入C9 Asteroids。选择手机和平板,然后在提示时选择空白活动。在活动名称字段中输入AsteroidsActivity

提示

显然,你不必遵循我确切的命名选择,但只需记得在代码中进行小改动,以反映你自己的命名选择。

你可以从layout文件夹中删除activity_asteroids.xml。你也可以删除AsteroidsActivity.java文件中的所有代码。只需保留包声明。

将布局锁定为横屏

正如我们在前两个项目中做的那样,我们将确保游戏只在横屏模式下运行。我们将使我们的AndroidManifest.xml文件,强制我们的AsteroidsActivity类以全屏运行,并将其锁定为横屏方向。让我们进行这些更改:

  1. 现在打开manifests文件夹,双击AndroidManifest.xml文件,在代码编辑器中打开它。

  2. AndroidManifest.xml文件中,找到以下代码行:

    android:name=".AsteroidsActivity"
    
  3. 立即输入或复制粘贴以下两行代码,使PlatformActivity全屏运行,并将其锁定为横屏方向:

    android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
    android:screenOrientation="landscape"
    

现在,我们可以继续使用 OpenGL 实现我们的 Asteroids 模拟器游戏。

活动

首先,我们有一个熟悉的Activity类。这里唯一的新事物是我们视图类的类型。我们声明了一个名为asteroidsView的成员,其类型为GLSurfaceView。这个类将为我们提供轻松访问 OpenGL 的途径。我们很快就会看到具体如何实现。注意,我们所做的一切就是通过传递Activity上下文和以通常方式获取的屏幕分辨率来初始化GLSurfaceView。按照所示实现AsteroidsActivity类:

package com.gamecodeschool.c9asteroids;

import android.app.Activity;
import android.graphics.Point;
import android.opengl.GLSurfaceView;
import android.os.Bundle;
import android.view.Display;

public class AsteroidsActivity extends Activity {

    private GLSurfaceView asteroidsView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Get a Display object to access screen details
        Display display = getWindowManager().getDefaultDisplay();

        // Load the resolution into a Point object
        Point resolution = new Point();
        display.getSize(resolution);

        asteroidsView = new AsteroidsView 
          (this, resolution.x, resolution.y); 

        setContentView(asteroidsView);
    }

    @Override
    protected void onPause() {
        super.onPause();

        asteroidsView.onPause();

    }

    @Override
    protected void onResume() {
        super.onResume();

        asteroidsView.onResume();

    }
}

接下来,我们将看到一些 OpenGL 代码。

视图

在这里,我们将实现GLSurfaceView类。实际上,这里并不是真正的动作发生的地方,但它确实允许我们附加一个 OpenGL 渲染器。这是一个实现了Renderer接口的类。除了这个关键的Renderer之外,GLSurfaceView类还允许我们覆盖onTouchListener方法,这将允许我们以与前一个项目中SurfaceView相同的方式来检测玩家输入。

注意

Android Studio 不会自动导入甚至建议所有必需的 OpenGL 导入。因此,我在代码清单中包含了一些类的所有导入。此外,你会注意到我们有时使用静态导入。这将使代码更具可读性。

在下面的代码中,我们声明并初始化了一个即将实现的GameManager类型的新对象。我们通过调用setEGLContextClientVersion(2)将 OpenGL 版本设置为 2,并通过调用setRenderer()并传入我们的GameManager对象来设置我们关键的渲染器对象。创建一个名为AsteroidsView的新类,并按以下方式实现它:

import android.content.Context;
import android.opengl.GLSurfaceView;

public class AsteroidsView extends GLSurfaceView{

    GameManager gm;

    public AsteroidsView(Context context, int screenX, int screenY) {
        super(context);

        gm = new GameManager(screenX, screenY);

        // Which version of OpenGl we are using
        setEGLContextClientVersion(2);

        // Attach our renderer to the GLSurfaceView
        setRenderer(new AsteroidsRenderer(gm));

    }

}

现在,我们可以看看我们的GameManager类涉及到哪些内容。

一个管理我们游戏的类

这个类将控制诸如玩家所在的关卡、生命数量以及游戏世界的整体大小之类的事情。随着项目的进行,它会有一些变化,但与之前项目中的LevelManagerPlayerState类的综合深度相比,它将保持相当简单,尽管它实际上取代了这两个类。

在下面的代码中,我们声明了int类型的成员来保存游戏世界的宽度和高度;我们可以根据需要将其设置得更大或更小。我们使用布尔值playing来跟踪游戏的状态。

GameManager类还需要知道屏幕的宽度和高度(以像素为单位),这个信息是在AsteroidsView类中初始化对象时传递给构造函数的。

也请注意metresToShowXmetresToShowY成员变量。这些可能听起来很熟悉,因为它们来自上一个项目的Viewport类。这些变量将被用于完全相同的事情:定义游戏世界的当前可查看区域。然而这次,OpenGL 将负责在绘制之前裁剪哪些对象(使用矩阵)。我们很快就会看到这是在哪里发生的。

注意

请注意,尽管 OpenGL 负责裁剪和缩放我们想要显示的游戏世界区域,但它对每帧更新哪些对象没有任何影响。然而,正如我们将要看到的,这正是我们想要的游戏效果,因为即使对象在屏幕外,我们也希望所有对象每帧都能更新自己。因此,这个游戏不需要Viewport类。

最后,我们需要一个便捷的方式来暂停和继续游戏,我们通过switchPlayingStatus方法提供这个功能。创建一个名为GameManager的新类,并按照所示实现它:

public class GameManager {

    int mapWidth = 600;
    int mapHeight = 600;
    private boolean playing = false;

    // Our first game object
    SpaceShip ship;

    int screenWidth;
    int screenHeight;

    // How many metres of our virtual world
    // we will show on screen at any time.
    int metresToShowX = 390;
    int metresToShowY = 220;

    public GameManager(int x, int y){

        screenWidth = x;
        screenHeight = y;

    }

    public void switchPlayingStatus() {
        playing = !playing;

    }

    public boolean isPlaying(){
        return playing;
    }
}

现在我们可以第一次了解这些强大的着色器以及我们将如何管理它们。

管理简单的着色器

应用程序可以有许多着色器。然后我们可以将不同的着色器附加到不同的游戏对象上,以创建所需的效果。

在这个游戏中,我们只会有一个顶点着色器和一个片段着色器。然而,当你了解到如何将着色器附加到图元上时,你会发现拥有更多着色器是非常简单的。

  1. 首先,我们需要在 GPU 中执行的着色器的代码。

  2. 然后,我们需要编译那段代码。

  3. 最后,我们需要将两个编译后的着色器链接成一个 GL 程序。

当我们实现这个下一个简单的类时,我们将看到如何将这个功能打包到一个方法调用中,这个调用可以由游戏中的对象发出,并将准备运行的 GL 程序返回给游戏对象。在本章后面构建我们的GameObject类时,我们将看到如何使用这个 GL 程序。

让我们在一个新类中实现必要的三个步骤。创建一个新类,并将其命名为GLManager。添加如下所示的静态导入:

import static android.opengl.GLES20.GL_FRAGMENT_SHADER;
import static android.opengl.GLES20.GL_VERTEX_SHADER;
import static android.opengl.GLES20.glAttachShader;
import static android.opengl.GLES20.glCompileShader;
import static android.opengl.GLES20.glCreateProgram;
import static android.opengl.GLES20.glCreateShader;
import static android.opengl.GLES20.glLinkProgram;
import static android.opengl.GLES20.glShaderSource;

接下来,我们将添加一些公共静态最终成员变量,我们可以在本章后面的GameObject类中使用它们。虽然我们将在使用它们时确切地看到它们是如何工作的,但这里有一个快速预览解释。

COPONENTS_PER_VERTEX是用于表示构成游戏对象的图元中的单个顶点(点)的值数量。如您所见,我们将这个值初始化为三个坐标:xyz

我们还有FLOAT_SIZE,它被初始化为4。这是 Java 浮点数的字节数。正如我们很快将看到的,OpenGL 喜欢所有传入它的图元以ByteBuffer的形式。我们需要确保精确地知道在ByteBuffer中的每个信息片段的位置。

接下来,我们声明STRIDE并将其初始化为COMPONENTS_PER_VERTEX * FLOAT_SIZE。由于 OpenGL 使用浮点类型来处理几乎所有其工作的数据,STRIDE现在等于表示单个物体顶点的数据大小(以字节为单位)。请将这些成员添加到类的顶部:

public class GLManager {

     // Some constants to help count the number of bytes between
     // elements of our vertex data arrays
     public static final int COMPONENTS_PER_VERTEX = 3;
     public  static final int FLOAT_SIZE = 4;
     public static final int STRIDE =
       (COMPONENTS_PER_VERTEX)
        * FLOAT_SIZE;

     public static final int ELEMENTS_PER_VERTEX = 3;// x,y,z

GLSL 是一种自身的语言,并且它也有自己的类型,这些类型的变量可以被利用。在这里,我们声明并初始化一些字符串,我们可以使用它们在代码中更清晰地引用这些变量。

这些类型的讨论超出了本书的范围,但简单来说,它们将代表一个矩阵(u_matrix)、一个位置(a_position)和一个颜色(u_Color)。我们很快将在我们的着色器代码中看到这些变量实际的 GLSL 类型。

在字符串之后,我们声明了三个int类型。这三个公共静态(但不是最终的)成员将用于存储我们着色器中同名类型的位置。这使得我们可以在给 OpenGL 最终的绘图指令之前,在着色器程序中操作这些值。

// Some constants to represent GLSL types in our shaders
public static final String U_MATRIX = "u_Matrix";
public static final String A_POSITION = "a_Position";
public static final String U_COLOR = "u_Color";

// Each of the above constants also has a matching int
// which will represent its location in the open GL glProgram
public static int uMatrixLocation;
public static int aPositionLocation;
public static int uColorLocation;

最后,我们来到了打包在字符串中的顶点着色器 GLSL 代码。注意,我们声明了一个名为u_Matrix的统一变量,类型为mat4,以及一个类型为属性vec4a_Position。稍后在我们GameObject类中,我们将看到如何获取这些变量的位置,以便我们可以从 Java 代码中为它们传递值。

void main()开始的代码行是实际着色器代码执行的地方。注意gl_position被分配了我们刚才声明的两个变量的乘积值。同时gl_PointSize被分配了3.0的值。这将是我们绘制所有点图元的大小。在之前的代码块之后,输入顶点着色器的代码:

// A very simple vertexShader glProgram
// that we can define with a String

private static String vertexShader =
     "uniform mat4 u_Matrix;" +
     "attribute vec4 a_Position;" +

     "void main()" +
     "{" +
       "gl_Position = u_Matrix * a_Position;" +
       "gl_PointSize = 3.0;"+
  "}";

接下来,我们将实现片元着色器。这里发生了一些事情。首先,以precision mediump float开头的行告诉 OpenGL 以中等精度(因此也是中等速度)进行绘制。然后我们可以看到我们的变量u_Color被声明为统一类型vec4。我们很快会在GameObject类中看到如何将color值传递给这个变量。

当在void main()开始执行时,我们只需将u_Color分配给gl_FragColor。因此,无论分配给u_Color什么颜色,所有片段都将具有那个颜色。在片元着色器之后,我们声明了一个名为programint,它将作为我们 GL 程序的句柄。

在前面的代码块之后,输入片元着色器的代码:

// A very simple vertexShader glProgram
// that we can define with a String

private static String vertexShader =
    "uniform mat4 u_Matrix;" +
    "attribute vec4 a_Position;" +

    "void main()" +
    "{" +
        "gl_Position = u_Matrix * a_Position;" +
        "gl_PointSize = 3.0;"+
    "}";

这是一个获取器方法,它返回 GL 程序的句柄:

public static int getGLProgram(){
  return program;
}

下一个方法可能看起来复杂,但它所做的只是将一个编译并链接的程序返回给调用者。它通过使用compileVertexShader()compileFragmentShader()作为参数调用 OpenGL 的linkProgram方法来实现。接下来,我们看到这两个新方法,它们只需要使用代表着色器类型的 OpenGL 常量以及包含匹配着色器 GLSL 代码的适当字符串来调用我们的方法compileShader()

将我们刚刚讨论的三个方法输入到GLManager类中:

public static int buildProgram(){
    // Compile and link our shaders into a GL glProgram object
    return linkProgram(compileVertexShader(),compileFragmentShader());

}

private static int compileVertexShader() {
    return compileShader(GL_VERTEX_SHADER, vertexShader);
}

private static int compileFragmentShader() {
    return compileShader(GL_FRAGMENT_SHADER, fragmentShader);
}

现在我们来了解当调用方法compileShader()时会发生什么。首先,我们会根据type参数创建一个着色器的句柄。然后,我们将该句柄和代码传递给glShaderSource()。最后,我们使用glCompileShader()编译着色器,并将句柄返回给调用方法:

private static int compileShader(int type, String shaderCode) {

    // Create a shader object and store its ID
    final int shader = glCreateShader(type);

    // Pass in the code then compile the shader
    glShaderSource(shader, shaderCode);
    glCompileShader(shader);

    return shader;
}

现在我们可以看到这个过程最后一步。我们使用glCreateProgram()创建一个空程序。然后依次使用glAttachShader()附加每个编译后的着色器,并最终使用glLinkProgram()将它们链接成一个我们可以实际使用的程序:

private static int linkProgram(int vertexShader, int fragmentShader) {

  // A handle to the GL glProgram -
  // the compiled and linked shaders
     program = glCreateProgram();

     // Attach the vertex shader to the glProgram.
     glAttachShader(program, vertexShader);

     // Attach the fragment shader to the glProgram.
     glAttachShader(program, fragmentShader);

     // Link the two shaders together into a glProgram.
     glLinkProgram(program);

     return program;
}
}// End GLManager

注意我们创建了一个程序,并且可以通过其句柄和getProgram方法访问它。我们还可以访问我们创建的所有那些公共静态成员,因此我们将能够从 Java 代码中调整着色器程序中的变量。

游戏的主循环——渲染器

现在我们将看到代码的核心部分将如何进行。创建一个名为AsteroidsRenderer的新类。这是我们附加到GLSurfaceView的渲染器类。按照如下方式添加导入语句,注意其中有一些是静态的:

import android.graphics.PointF;
import android.opengl.GLSurfaceView.Renderer;
import android.util.Log;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
import static android.opengl.GLES20.GL_COLOR_BUFFER_BIT;
import static android.opengl.GLES20.glClear;
import static android.opengl.GLES20.glClearColor;
import static android.opengl.GLES20.glViewport;
import static android.opengl.Matrix.orthoM;

现在我们将构建这个类。首先要注意的是,我们之前提到过这个类实现了Renderer,因此我们需要重写三个方法。它们是onSurfaceCreated()onSurfaceChanged()onDrawFrame()。此外,我们将在该类中最初添加一个构造函数来设置一切,一个createObjects方法,我们最终将在其中初始化所有游戏对象,一个update方法,我们将在其中每帧更新所有对象,以及一个draw方法,我们将在其中每帧绘制所有对象。

我们将在实现每个方法时探讨和解释它,我们还将看到我们的方法如何融入到 OpenGL 渲染器系统中,该系统决定了这个类的流程。

首先,有一些成员变量值得我们仔细查看。

布尔值debugging将被用来切换控制台输出的开启和关闭。frameCounteraverageFPSfps变量不仅将被用来检查我们达到的帧率,而且还将被用来传递给我们的游戏对象,这些对象将根据每帧的流逝时间更新自己。

我们第一个真正有趣的变量是浮点数数组viewportMatrix。顾名思义,它将保存一个 OpenGL 可以用来计算到我们游戏世界的视口的矩阵。

我们有一个GameManager来保存对GameManager对象的引用,这是AsteroidsView在传递到这个类的构造函数中。最后,我们有两个PointF对象。

我们将在构造函数中初始化PointF对象,并将它们用于一些不同的事情,以避免在主游戏循环中取消引用任何对象。当垃圾收集器开始清理丢弃的对象时,即使是 OpenGL 也会减慢速度。避免召唤垃圾收集器将是整个游戏的目标。

AsteroidsRenderer类的顶部输入成员变量:

public class AsteroidsRenderer implements Renderer {

// Are we debugging at the moment

boolean debugging = true;

// For monitoring and controlling the frames per second

long frameCounter = 0;
long averageFPS = 0;
private long fps;

// For converting each game world coordinate
// into a GL space coordinate (-1,-1 to 1,1)
// for drawing on the screen

private final float[] viewportMatrix = new float[16];

// A class to help manage our game objects
// current state.

private GameManager gm;

// For capturing various PointF details without
// creating new objects in the speed critical areas

PointF handyPointF;
PointF handyPointF2;

这是我们的构造函数,我们从参数初始化GameManager引用,并创建两个方便的PointF对象以备使用:

public AsteroidsRenderer(GameManager gameManager) {

     gm = gameManager;

     handyPointF = new PointF();
     handyPointF2 = new PointF();

}

这是第一个重写的方法。每次创建带有附加渲染器的GLSurfaceView类时都会调用它。我们调用glClearColor()来设置每次 OpenGL 清除屏幕时使用的颜色。然后我们使用GLManager.buildProgram()方法构建我们的着色器程序,并调用我们将很快编写的createObjects方法。

@Override
public void onSurfaceCreated(GL10 glUnused, EGLConfig config) {

   // The color that will be used to clear the
   // screen each frame in onDrawFrame()
   glClearColor(0.0f, 0.0f, 0.0f, 0.0f);

   // Get GLManager to compile and link the shaders into an object
   GLManager.buildProgram();

   createObjects();

}

下一个重写的方法在onSurfaceCreated()之后调用一次,并且在屏幕方向改变时调用。在这里,我们调用glViewport()方法,告诉 OpenGL 将 OpenGL 坐标系统映射到哪个像素坐标上。

OpenGL 坐标系统与我们之前在两个项目中习惯处理的像素坐标有很大不同。屏幕的中心是 0,0,左下角是-1,右上角是 1。

游戏主循环 - 渲染器

前面的情况由于大多数屏幕不是正方形而进一步复杂化,但-1 到 1 的范围必须同时表示xy轴。幸运的是,我们的glViewport()已经为我们处理了这个问题。

在这个方法中最后我们看到的是调用orthoM方法,将我们的viewportMatrix作为第一个参数。OpenGL 现在将准备使用viewportMatrixorthoM()方法创建一个矩阵,将坐标转换为正交视图。如果我们的坐标是三维的,它将使所有物体看起来距离相同。由于我们正在制作一个二维游戏,这也适合我们。

输入onSurfaceChanged方法的代码:

@Override
    public void onSurfaceChanged(GL10 glUnused, int width, int height) {

        // Make full screen
        glViewport(0, 0, width, height);

        /*
            Initialize our viewport matrix by passing in the starting
            range of the game world that will be mapped, by OpenGL to
            the screen. We will dynamically amend this as the player
            moves around.

            The arguments to setup the viewport matrix:
            our array,
            starting index in array,
            min x, max x,
            min y, max y,
            min z, max z)
        */

            orthoM(viewportMatrix, 0, 0, 
        gm.metresToShowX, 0, 
        gm.metresToShowY, 0f, 1f);
}

这是我们的createObjects方法,如您所见,我们创建了一个SpaceShip类型的对象,并将地图的高度和宽度传递给构造函数。我们将在本章后面构建SpaceShip类及其父类GameObject。输入createObjects方法:

    private void createObjects() {
        // Create our game objects

        // First the ship in the center of the map
        gm.ship = new SpaceShip(gm.mapWidth / 2, gm.mapHeight / 2);
    }

这是重写的onDrawFrame方法。系统会连续调用它。我们可以通过设置渲染模式来控制何时调用它,当我们把AsteroidsRenderer附加到视图上,但默认的 OpenGL 控制连续调用正是我们所需要的。

我们将startFrameTime设置为当前的系统时间。然后,如果isPlaying()返回true,我们调用即将实现的update方法。然后,我们调用draw(),这将告诉我们的所有对象绘制自己。

然后,我们更新timeThisFramefps,可以选择在每 100 帧输出一次平均每秒帧数,如果我们正在调试的话。

现在我们知道 OpenGL 每秒最多可以调用onDrawFrame()数百次。我们将条件性地每次调用我们的update方法,以及调用我们的draw方法。除了实际的drawupdate方法本身,我们已经有效地实现了游戏循环。

向类中添加onDrawFrame方法:

@Override
public void onDrawFrame(GL10 glUnused) {

        long startFrameTime = System.currentTimeMillis();

        if (gm.isPlaying()) {
            update(fps);
        }

        draw();

        // Calculate the fps this frame
        // We can then use the result to
        // time animations and more.
        long timeThisFrame = System.currentTimeMillis() - startFrameTime;
        if (timeThisFrame >= 1) {
            fps = 1000 / timeThisFrame;
        }

        // Output the average frames per second to the console
        if (debugging) {
            frameCounter++;
            averageFPS = averageFPS + fps;
            if (frameCounter > 100) {
                averageFPS = averageFPS / frameCounter;
                frameCounter = 0;
                Log.e("averageFPS:", "" + averageFPS);
            }
        }
    }

这是我们的update方法,现在先留一个空体:

    private void update(long fps) {

    }

现在,我们来看看draw方法,它从onDrawFrame方法中每帧调用一次。在这里,我们将飞船的当前位置加载到我们的便捷PointF对象之一中。显然,由于我们还没有实现SpaceShip类,这个方法调用将产生错误。

draw()中我们接下来要做的事情相当有趣。我们根据游戏世界中当前的位置以及分配给metresToShowXmetresToShowY的值修改我们的viewportMatrix。简单来说,我们将视口中心定位在飞船所在的位置,并向四个方向扩展我们希望显示的一半距离。记住,这会在每一帧发生,所以我们的视口将始终跟随玩家飞船。

接下来,我们调用glClear(),用onSurfaceCreated()中设置的颜色清除屏幕。在draw()中我们做的最后一件事是在我们的SpaceShip对象上调用draw方法。这意味着这与我们之前的两款游戏有一个非常基本的设计变化。

我们已经提到过这一点,但在这里我们可以看到它的实际应用:每个对象将绘制自己。同时,注意我们传递了新配置的viewportMatrix

输入draw方法的代码:

private void draw() {

    // Where is the ship?
    handyPointF = gm.ship.getWorldLocation();

    // Modify the viewport matrix orthographic projection
    // based on the ship location
    orthoM(viewportMatrix, 0,
        handyPointF.x - gm.metresToShowX / 2,
        handyPointF.x + gm.metresToShowX / 2,
        handyPointF.y - gm.metresToShowY / 2,
        handyPointF.y + gm.metresToShowY / 2,
        0f, 1f);

    // Clear the screen
    glClear(GL_COLOR_BUFFER_BIT);

    // Start drawing!

    // Draw the ship
    gm.ship.draw(viewportMatrix);
}
}

现在,我们可以构建我们的GameObject超类,紧接着是它的第一个子类SpaceShip。我们将看到这些对象如何设法使用 OpenGL 来绘制自己。

构建一个对 OpenGL 友好的GameObject超类

让我们直接进入代码。正如我们将看到的,这个GameObject将与之前项目中的GameObject类有很多共同之处。最显著的区别将在于,这个最新的GameObject当然会使用 GL 程序的句柄、子类中的原始(顶点)数据以及viewportMatrix中的视口矩阵来绘制自己。

创建一个新类,将其命名为GameObject,并输入这些导入语句,再次注意其中一些是静态的:

import android.graphics.PointF;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import static android.opengl.GLES20.GL_FLOAT;
import static android.opengl.GLES20.GL_LINES;
import static android.opengl.GLES20.GL_POINTS;
import static android.opengl.GLES20.GL_TRIANGLES;
import static android.opengl.GLES20.glDrawArrays;
import static android.opengl.GLES20.glEnableVertexAttribArray;
import static android.opengl.GLES20.glGetAttribLocation;
import static android.opengl.GLES20.glGetUniformLocation;
import static android.opengl.GLES20.glUniform4f;
import static android.opengl.GLES20.glUniformMatrix4fv;
import static android.opengl.GLES20.glUseProgram;
import static android.opengl.Matrix.multiplyMM;
import static android.opengl.Matrix.setIdentityM;
import static android.opengl.Matrix.setRotateM;
import static android.opengl.Matrix.translateM;
import static android.opengl.GLES20.glVertexAttribPointer;
import static com.gamecodeschool.c9asteroids.GLManager.*;

有很多成员变量,其中许多是自解释的并已加上注释以刷新我们的记忆,但也有一些全新的变量。

例如,我们有一个enum来表示我们将要创建的每种类型的GameObject。这样做的原因是,我们将某些对象绘制为点,一些绘制为线,一个绘制为三角形。我们使用 OpenGL 的方式在不同类型的图元之间是一致的;因此,我们将代码捆绑到这个父类中。然而,最终绘制图元的调用取决于图元的类型。我们可以使用type变量在switch语句中执行正确的draw方法。

我们还有一个名为int numElementsnumVertices的变量,用于记录构成任何给定GameObject的点数。我们将在后面的子类中设置这些值。

我们还有一个浮点数数组modelVertices,它将存储构成一个模型的全部顶点。

GameObject类中输入第一组成员变量,并查看注释以刷新您的记忆或明确各种成员最终将用于什么:

public class GameObject {

    boolean isActive;

    public enum Type {SHIP, ASTEROID, BORDER, BULLET, STAR}

    private Type type;

    private static int glProgram =-1;

    // How many vertices does it take to make
    // this particular game object?
    private int numElements;
    private int numVertices;

    // To hold the coordinates of the vertices that
    // define our GameObject model
    private float[] modelVertices;

    // Which way is the object moving and how fast?
    private float xVelocity = 0f;
    private float yVelocity = 0f;
    private float speed = 0;
    private float maxSpeed = 200;

    // Where is the object centre in the game world?
    private PointF worldLocation = new PointF();

接下来,我们将添加另一组成员变量。首先,最值得注意的是,我们有一个名为verticesFloatBuffer。我们知道,OpenGL 在本地代码中执行,而FloatBuffers是它喜欢消费数据的方式。我们将看到如何将所有顶点打包到这个FloatBuffer中。

我们还将使用GLManager类中的所有公共静态成员来帮助我们正确处理。

在 OpenGL 方面,可能第二个最有趣的新成员是我们还有另外三个浮点数数组,名为modelMatrixviewportModelMatrixrotateViewportModelMatrix。这些将帮助 OpenGL 精确地按照要求绘制GameObject类。我们将在到达本类的draw方法时详细检查它们是如何初始化和使用的。

我们还有一些成员变量,用于保存不同的角度和旋转速率。我们如何使用和更新这些变量,以便通知 OpenGL 我们对象的方向,我们很快就会看到:

    // This will hold our vertex data that is
    // passed into the openGL glProgram
    // OPenGL likes FloatBuffer
    private FloatBuffer vertices;

    // For translating each point from the model (ship, asteroid etc)
    // to its game world coordinates
    private final float[] modelMatrix = new float[16];

    // Some more matrices for Open GL transformations
    float[] viewportModelMatrix = new float[16];
    float[] rotateViewportModelMatrix = new float[16];

    // Where is the GameObject facing?
    private float facingAngle = 90f;

    // How fast is it rotating?
    private float rotationRate = 0f;

    // Which direction is it heading?
    private float travellingAngle = 0f;

    // How long and wide is the GameObject?
    private float length;
    private float width;

现在我们实现构造函数。首先,我们检查是否之前已经编译过着色器,因为我们只需要做一次。如果我们没有,这就是if(glProgarm == -1)块内发生的事情。

我们调用setGLProgram(),然后传入glProgram参数调用glUseProgram()。我们需要做的就这么多,GLManager会处理其余的工作,我们的 OpenGL 程序就可以使用了。

然而,在我们继续之前,通过调用相应的方法(glGetUniformLocation()glGetAttrtibuteLocation)来保存关键着色器变量的位置,这些位置在我们的 GL 程序中的位置。我们将在本类的draw方法中看到如何使用这些位置来操作着色器内的值。

最后,我们将isActive设置为true。将此方法输入到GameObject类中:

public GameObject(){
    // Only compile shaders once
    if (glProgram == -1){
        setGLProgram();

        // tell OpenGl to use the glProgram
        glUseProgram(glProgram);

        // Now we have a glProgram we need the locations
        // of our three GLSL variables.
        // We will use these when we call draw on the object.
        uMatrixLocation = glGetUniformLocation(glProgram, U_MATRIX);
        aPositionLocation = glGetAttribLocation(glProgram, A_POSITION);
        uColorLocation = glGetUniformLocation(glProgram, U_COLOR);
    }

    // Set the object as active
    isActive = true;

}

现在我们有一些 getters 和 setters,包括从AsteroidsRenderer中的draw方法调用的getWorldLocation(),以及setGLProgram()。这使用GLManager类的静态方法getGLProgram()来获取我们 GL 程序的句柄。

GameObject类中输入所有这些方法:

public boolean isActive() {
  return isActive;
}

public void setActive(boolean isActive) {
  this.isActive = isActive;
}

public void setGLProgram(){
  glProgram = GLManager.getGLProgram();
}

public Type getType() {
  return type;
}

public void setType(Type t) {
  this.type = t;
}

public void setSize(float w, float l){
  width = w;
  length = l;

}

public PointF getWorldLocation() {
  return worldLocation;
}

public void setWorldLocation(float x, float y) {
  this.worldLocation.x = x;
  this.worldLocation.y = y;
}

下一个方法setVertices()是在准备由 OpenGL 绘制对象时的关键步骤。在我们的每个子类中,我们将构建一个浮点类型的数组来表示构成游戏对象形状的顶点。每个游戏对象在形状上显然都是不同的,但setVertices方法无需关注这些差异,它只需要数据。

正如我们在下一个代码块中看到的,该方法接收一个浮点数组作为参数。然后它将等于数组长度的元素数量存储在numElements中。请注意,元素的数量与它们表示的顶点数量不同。需要三个元素(xyz)来表示一个顶点。因此,我们可以通过将numElements除以ELEMENTS_PER_VERTEX来将正确的值存储在numVertices中。

现在,我们可以通过调用allocateDirect()并传入我们新初始化的变量以及FLOAT_SIZE来实际初始化我们的ByteBufferByteOrder.nativeOrder方法只是检测特定系统的字节序,而asFloatBuffer()告诉ByteBuffer将要存储的数据类型。现在,我们可以通过调用vertices.put(modelVertices)将顶点数组存储到我们的顶点ByteBuffer中。这些数据现在可以传递给 OpenGL 了。

提示

如果你想了解更多关于字节序的信息,请查看这篇维基百科文章:

关于字节序的维基百科文章

GameObject类中输入setVertices方法:

public void setVertices(float[] objectVertices){

    modelVertices = new float[objectVertices.length];
    modelVertices = objectVertices;

    // Store how many vertices and elements there is for future use
    numElements = modelVertices.length;

    numVertices = numElements/ELEMENTS_PER_VERTEX;

    // Initialize the vertices ByteBuffer object based on the
    // number of vertices in the ship design and the number of
    // bytes there are in the float type
    vertices = ByteBuffer.allocateDirect(
            numElements
            * FLOAT_SIZE)
            .order(ByteOrder.nativeOrder()).asFloatBuffer();

    // Add the ship into the ByteBuffer object
    vertices.put(modelVertices);

}

现在我们来看看我们是如何实际绘制我们的ByteBuffer的内容的。乍一看,以下代码可能看起来很复杂,但当我们讨论ByteBuffer中的数据性质以及 OpenGL 绘制这些数据的步骤时,我们会发现这实际上相当直接。

由于我们还没有编写第一个GameObject子类的代码,有一个关键点需要指出。表示游戏对象形状的顶点是基于其自身的中心为零的。

OpenGL 坐标系统的中心是0,0,但为了明确起见,这与我们无关,这被称为模型空间。下一张图片展示了我们即将创建的飞船,在模型空间中的表示:

构建一个对 OpenGL 友好的 GameObject 超类

这些数据包含在我们的ByteBuffer中。这些数据不考虑方向(飞船或小行星是否旋转),不考虑在游戏世界中的位置,并且再次提醒,它与 OpenGL 坐标系统完全无关。

因此,在我们绘制ByteBuffer之前,我们需要转换这些数据,或者更准确地说,我们需要准备一个合适的矩阵,并将其与数据一起传递给 OpenGL,以便 OpenGL 知道如何使用或转换数据。

我将draw方法分成了六个部分来讲解我们是如何做到这一点的。请注意,我们的viewPort矩阵在我们的AsteroidsRenderer类的draw方法中准备,该方法以飞船的位置为中心,基于我们想要显示的游戏世界比例,并作为参数传入。

首先,我们调用glUseProgram()并传入我们程序的句柄。然后我们将ByteBuffer的内部指针设置为起始位置,使用vertices.position(0)

glVertexAttributePointer方法使用我们的aPositionLocation变量以及我们的GLManager静态常量,当然还有vertices ByteBuffer,将我们的顶点与顶点着色器中的aPosition变量相关联。最后,对于这段代码,我们告诉 OpenGL 启用属性数组:

    public void draw(float[] viewportMatrix){

        // tell OpenGl to use the glProgram
        glUseProgram(glProgram);

        // Set vertices to the first byte
        vertices.position(0);

        glVertexAttribPointer(
              aPositionLocation,
              COMPONENTS_PER_VERTEX,
              GL_FLOAT,
              false,
              STRIDE,
              vertices);

        glEnableVertexAttribArray(aPositionLocation);

现在,我们将矩阵投入使用。通过调用setIndentityM(),我们从modelMatrix数组中创建一个单位矩阵。

注意

正如我们将要看到的,我们将使用和组合相当多的矩阵。单位矩阵作为一个起点或容器,我们可以在其上构建一个矩阵,该矩阵结合了我们需要发生的所有变换。一种非常简单但并不完全准确的方式来考虑单位矩阵是,它就像数字 1。当你乘以一个单位矩阵时,它不会对和的其它部分造成任何改变。然而,这个答案对于继续方程的下一部分是正确的。如果这让你感到烦恼,并且你想了解更多,请查看关于矩阵和单位矩阵的以下快速教程。

矩阵:

矩阵介绍

单位矩阵:

单位矩阵教程

然后,我们将新的modelMatrix传递给translateM方法。在数学术语中,translate 意为移动。仔细观察传递给translateM()的参数。我们正在传递物体的xy世界坐标。这就是 OpenGL 知道物体位置的方式:

    // Translate model coordinates into world coordinates
    // Make an identity matrix to base our future calculations on
    // Or we will get very strange results
    setIdentityM(modelMatrix, 0);
    // Make a translation matrix

    /*
        Parameters:
        m   matrix
        mOffset index into m where the matrix starts
        x   translation factor x
        y   translation factor y
        z   translation factor z
    */
    translateM(modelMatrix, 0, worldLocation.x, worldLocation.y, 0);

我们知道 OpenGL 有一个矩阵可以将我们的对象转换到它的世界位置。它还有一个ByteBuffer类,其中包含模型空间坐标,但它如何将转换后的模型空间坐标转换为使用 OpenGL 坐标系统绘制的视口呢?

它使用了视口矩阵,该矩阵每帧都会被修改并传入这个方法。我们需要做的就是使用multiplyMM()viewportMatrix和最近转换的modelMatrix相乘。这个方法创建了组合或乘积矩阵,并将结果存储在viewportModelMatrix中:

   // Combine the model with the viewport
   // into a new matrix
   multiplyMM(viewportModelMatrix, 0, 
      viewportMatrix, 0, modelMatrix, 0);

我们几乎完成了矩阵的创建。OpenGL 需要对ByteBuffer中的顶点进行的唯一其他可能的扭曲就是根据facingAngle参数旋转它们。

接下来,我们创建一个适合当前物体面向角度的旋转矩阵,并将结果重新存储在modelMatrix中。

然后,我们将新旋转的modelMatrix与我们的viewportModelMatrix组合或相乘,并将结果存储在rotateViewportModelMatrix中。这是我们最终要传入 OpenGL 系统的矩阵:

   /*
        Now rotate the model - just the ship model

        Parameters
        rm  returns the result
        rmOffset    index into rm where the result matrix starts
        a   angle to rotate in degrees
        x   X axis component
        y   Y axis component
        z   Z axis component
    */
    setRotateM(modelMatrix, 0, facingAngle, 0, 0, 1.0f);

    // And multiply the rotation matrix into the model-viewport 
    // matrix
    multiplyMM(rotateViewportModelMatrix, 0, 
      viewportModelMatrix, 0, modelMatrix, 0);

现在,我们使用glUniformMatrix4fv()方法传入矩阵,并在参数中使用uMatrixLocation变量(这是顶点着色器中与矩阵相关的变量的位置)和我们最终的矩阵。

我们还通过调用glUniform4f()并使用uColorLocation和一个 RGBT(红、绿、蓝、透明度)值来选择颜色。所有值都设置为 1.0,因此片段着色器将绘制白色。

   // Give the matrix to OpenGL

    glUniformMatrix4fv(uMatrixLocation, 1, false,                                        
    rotateViewportModelMatrix, 0);

    // Assign a color to the fragment shader
    glUniform4f(uColorLocation, 1.0f, 1.0f, 1.0f, 1.0f);

最后,我们根据对象类型进行切换,并绘制点、线或三角形图元:

   // Draw the point, lines or triangle
    switch (type){
        case SHIP:
        glDrawArrays(GL_TRIANGLES, 0, numVertices);
        break;

        case ASTEROID:
        glDrawArrays(GL_LINES, 0, numVertices);
        break;

        case BORDER:
        glDrawArrays(GL_LINES, 0, numVertices);
        break;

       case STAR:
        glDrawArrays(GL_POINTS, 0, numVertices);
        break;

        case BULLET:
        glDrawArrays(GL_POINTS, 0, numVertices);
        break;
    }

} // End draw()

}// End class

现在我们已经掌握了GameObject类的基础知识,我们可以创建一个类来表示我们的飞船并在屏幕上绘制它。

飞船

这个类非常简单,尽管它会随着项目的发展而演变。构造函数接收游戏世界中起点的位置。我们使用GameObject类的方法设置飞船的类型和世界位置,并设置宽度和高度。

我们声明并初始化一些变量,以简化模型空间坐标的初始化,然后继续初始化一个浮点数组,其中包含三个顶点,这些顶点表示我们的飞船的三角形。请注意,这些值是基于x = 0y = 0中心的。

接下来,我们只需调用setVertices()GameObject就会准备好ByteBuffer供 OpenGL 使用:

public class SpaceShip extends GameObject{

  public SpaceShip(float worldLocationX, float worldLocationY){
       super();

        // Make sure we know this object is a ship
        // So the draw() method knows what type
        // of primitive to construct from the vertices

        setType(Type.SHIP);

        setWorldLocation(worldLocationX,worldLocationY);

        float width = 15;
        float length = 20;

        setSize(width, length);

        // It will be useful to have a copy of the
        // length and width/2 so we don't have to keep dividing by 2
        float halfW = width / 2;
        float halfL = length / 2;

        // Define the space ship shape
        // as a triangle from point to point
        // in anti clockwise order
        float [] shipVertices = new float[]{

               - halfW, - halfL, 0,
               halfW, - halfL, 0,
               0, 0 + halfL, 0

      };

       setVertices(shipVertices);

     }

}

最后,我们可以看到我们努力的成果。

以 60 + FPS 的速度绘制

通过三个简单的步骤,我们将能够看到我们的飞船:

  • GameManager成员变量中添加一个SpaceShip对象:

    private boolean playing = false;
    
     // Our first game object
     SpaceShip ship;
    
         int screenWidth;
    
  • createObjects方法中添加对新的SpaceShip()的调用:

    private void createObjects() {
    
      // Create our game objects
     // First the ship in the center of the map
     gm.ship = new SpaceShip(gm.mapWidth / 2, gm.mapHeight / 2);
    }
    
  • AsteroidsRendererdraw方法中,添加每一帧绘制飞船的调用:

    // Start drawing!
    // Draw the ship
    gm.ship.draw(viewportMatrix);
    
    

运行游戏并查看输出:

以 60 + FPS 的速度绘制

视觉效果并不令人印象深刻,但在调试模式下,在老旧的三星 Galaxy S2 手机上输出到控制台时,它的运行帧数在 67 到 212 之间。

以 60 + FPS 的速度绘制

在整个项目中,我们的目标是在保持每秒 60 帧以上的情况下添加数百个对象。

提示

一位书籍评论者在 Nexus 5 上报告了每秒超过 1000 帧的速率!因此,如果你打算将此应用发布到 Google Play 商店,考虑采用最大帧率锁定策略以节省电池寿命将是非常值得的。

总结

设置绘图系统可能有些繁琐。然而,现在它已经完成,我们可以更轻松地快速生成新对象。我们只需定义类型和顶点,然后就可以轻松地绘制它们。

正是因为有了这些基础工作,下一章内容将更加视觉上令人满意。接下来,我们将创建闪烁的星星、游戏世界的边界、旋转移动的小行星、呼啸而过的子弹、以及一个 HUD(平视显示器),同时为太空飞船添加完整的控制和动作。

第十章:使用 OpenGL ES 2 进行移动和绘制

在本章中,我们将实现所有的图形、游戏玩法和移动。在 30 多页的内容中,我们将完成除了碰撞检测之外的所有内容。我们能完成这么多,是因为我们在上一章打下了基础。

首先,我们将在游戏世界周围绘制一个静态边界,然后是一些闪烁的星星,接着为我们的太空船添加移动以及一些子弹。在那之后,我们将快速添加玩家的控制,我们将在屏幕上飞快地移动。

我们还将通过实现带有一些新声音效果的SoundManager类来制造一些噪音。

完成这些后,我们将添加随机形状的小行星,这些小行星在旋转的同时穿过整个世界。

然后,我们可以添加一个 HUD 来突出屏幕的可触摸区域,并提供剩余玩家生命值和需要摧毁的小行星数量的统计。

绘制静态游戏边界

在这个简单的类中,我们定义了四组点,它们将代表四条线。毫不奇怪,GameObject类将使用这些点作为线的端点来绘制边界。

在构造函数中,也就是类的全部内容,我们通过调用setType()设置类型,将世界位置设置为地图中心,以及将heightwidth设置为整个地图的高度和宽度。

然后,我们在一个 float 数组中定义四条线,并调用setVertices()来准备一个FloatBuffer

创建一个名为Border的新类,并添加以下代码:

public class Border extends GameObject{

  public Border(float mapWidth, float mapHeight){

        setType(Type.BORDER);
        //border center is the exact center of map
        setWorldLocation(mapWidth/2,mapHeight/2);

        float w = mapWidth;
        float h = mapHeight;
        setSize(w, h);

       // The vertices of the border represent four lines
       // that create a border of a size passed into the constructor
       float[] borderVertices = new float[]{
           // A line from point 1 to point 2
            - w/2, -h/2, 0,
            w/2, -h/2, 0,
            // Point 2 to point 3
            w/2, -h/2, 0,
            w/2, h/2, 0,
            // Point 3 to point 4
            w/2, h/2, 0,
            -w/2, h/2, 0,
            // Point 4 to point 1
            -w/2, h/2, 0,
            - w/2, -h/2, 0,
    };

        setVertices(borderVertices);

  }

}

然后,我们可以像这样在GameManager中声明一个Border对象:

// Our game objects
SpaceShip ship;
Border border;

AsteroidsRenderercreateObjects方法中这样初始化它:

// Create our game objects

// First the ship in the center of the map
gm.ship = new SpaceShip(gm.mapWidth / 2, gm.mapHeight / 2);

// The deadly border
gm.border = new Border(gm.mapWidth, gm.mapHeight);

现在,我们可以在AsteroidsRendrer类的draw方法中添加一行代码来绘制我们的边界:

gm.ship.draw(viewportMatrix);
gm.border.draw(viewportMatrix);

你现在可以运行游戏了。如果你想实际看到边界,可以将我们初始化飞船的位置改到靠近边界的地方。记住,在draw方法中,我们将视口围绕飞船居中。要看到边界,将SpaceShip类中的这一行改为这样:

setWorldLocation(10,10);

运行游戏看看效果。

绘制静态游戏边界

改回这一行:

setWorldLocation(worldLocationX,worldLocationY);

现在,我们将在边框内填充星星。

闪烁的星星

我们将使边界更加动态,而不仅仅是静态的。在这里,我们将向一个简单的Star类中添加一个update方法,该方法可以用来随机地打开或关闭星星。

我们将类型设置为normal,并在边界的范围内为星星创建一个随机位置,并像往常一样调用setWorldLocation()

星星将被绘制成点,因此我们的顶点数组将只包含模型空间 0,0,0 的一个顶点。然后,我们像往常一样调用setVertices()

创建一个新类,命名为Star,并输入我们讨论过的代码:

public class Star extends GameObject{

    // Declare a random object here because
    // we will use it in the update() method
    // and we don't want GC to have to keep clearing it up
    Random r;

    public Star(int mapWidth, int mapHeight){
    setType(Type.STAR);
    r = new Random();
    setWorldLocation(r.nextInt(mapWidth),r.nextInt(mapHeight));

    // Define the star
    // as a single point
    // in exactly the coordinates as its world location
    float[] starVertices = new float[]{

                0,
                0,
                0

    };

    setVertices(starVertices);

    }

这是我们的Star类的update方法。正如我们所见,每一帧都有千分之一的机会让星星改变其状态。为了更多闪烁,请使用较低的种子值,为了减少闪烁,请使用较高的种子值。

public void update(){

  // Randomly twinkle the stars
     int n = r.nextInt(1000);
     if(n == 0){
       // Switch on or off
       if(isActive()){
         setActive(false);
        }else{
          setActive(true);
        }
   }

}

然后,我们在GameManager中声明一个Star数组成员,以及一个额外的int变量来控制我们想要绘制的星星数量,如下所示:

// Our game objects
SpaceShip ship;
Border border;
Star[] stars;
int numStars = 200;

AsteroidsRenderercreateObjects方法中初始化Star对象的数组,如下所示:

// The deadly border
gm.border = new Border(gm.mapWidth, gm.mapHeight);

// Some stars
gm.stars = new Star[gm.numStars];
for (int i = 0; i < gm.numStars; i++) {

 // Pass in the map size so the stars no where to spawn
 gm.stars[i] = new Star(gm.mapWidth, gm.mapHeight);
}

现在,我们可以在AsteroidsRenderer类的draw方法中添加以下代码行来绘制我们的星星。注意,我们首先绘制星星,因为它们在背景中。

// Start drawing!

// Some stars
for (int i = 0; i < gm.numStars; i++) {

 // Draw the star if it is active
 if(gm.stars[i].isActive()) {
 gm.stars[i].draw(viewportMatrix);
 }
}

gm.ship.draw(viewportMatrix);
gm.border.draw(viewportMatrix);

当然,为了使它们闪烁,我们从AsteroidsRenderer类的update方法中调用它们的update方法,如下所示:

private void update(long fps) {

 // Update (twinkle) the stars
 for (int i = 0; i < gm.numStars; i++) {
 gm.stars[i].update();
 }

}

你现在可以运行游戏了:

闪烁的星星

让飞船生动起来

首先,我们需要为我们的GameObject类添加更多功能。我们在GameObject中这样做,因为子弹和行星与飞船共享许多惊人的相似之处。

我们需要一堆获取器和设置器来获取和设置旋转速率、行驶角度和面向角度。向GameObject类添加以下方法:

public void setRotationRate(float rotationRate) {
  this.rotationRate = rotationRate;
}

public float getTravellingAngle() {
  return travellingAngle;
}

public void setTravellingAngle(float travellingAngle) {
  this.travellingAngle = travellingAngle;
}

public float getFacingAngle() {
  return facingAngle;
}

public void setFacingAngle(float facingAngle) {
  this.facingAngle = facingAngle;
}

现在,我们添加一个move方法,该方法根据当前的每秒帧数调整对象的xy坐标以及facingAngle。添加move方法:

void move(float fps){
  if(xVelocity != 0) {
       worldLocation.x += xVelocity / fps;
    }

     if(yVelocity != 0) {
       worldLocation.y += yVelocity / fps;
    }

     // Rotate
     if(rotationRate != 0) {
       facingAngle = facingAngle + rotationRate / fps;
    }

}

为了完善我们对GameObject类的添加,为速度、速度和最大速度添加以下获取器和设置器:

public float getxVelocity() {
  return xVelocity;
}

public void setxVelocity(float xVelocity) {
  this.xVelocity = xVelocity;
}

public float getyVelocity() {
  return yVelocity;
}

public void setyVelocity(float yVelocity) {
  this.yVelocity = yVelocity;
}

public float getSpeed() {
  return speed;
}

public void setSpeed(float speed) {
  this.speed = speed;
}

public float getMaxSpeed() {
  return maxSpeed;
}

public void setMaxSpeed(float maxSpeed) {
  this.maxSpeed = maxSpeed;
}

我们可以为SpaceShip类添加一些内容。向SpaceShip类添加以下三个成员,以控制玩家的飞船是否在转向或向前移动:

boolean isThrusting;
private boolean isPressingRight = false;
private boolean isPressingLeft = false;

现在,在SpaceShip构造函数内部,让我们设置飞船的最大速度。我在现有代码中突出了新的一行代码:

setSize(width, length);

setMaxSpeed(150);

// It will be useful to have a copy of the

接下来,在SpaceShip类中,我们添加一个update方法,首先根据isThrusting是真是假来增加或减少速度。

public void update(long fps){

float speed = getSpeed();
if(isThrusting) {
  if (speed < getMaxSpeed()){
       setSpeed(speed + 5);
     }

     }else{
       if(speed > 0) {
         setSpeed(speed - 3);
        }else {
         setSpeed(0);
        }
}

然后,我们根据角度、船舶面向的方向以及速度来设置xy速度。

注意

我们使用速度乘以船舶面向角度的余弦值来设置在x轴上的速度。这样做有效是因为余弦函数是一个完美的变量,当船舶分别面向左或右时,它会返回-1 或 1 的值;当船舶正好指向上或下时,该变量返回精确的 0 值。它也在两者之间的角度返回精细的值。正弦函数在y轴上以完全相同的方式工作。代码看起来有些复杂,这是因为我们需要将角度转换为弧度,并且必须给我们的facingAngle加上 90 度,因为 0 度是指向三点钟方向。这个事实不利于我们按照现在的方式在x, y平面上使用它,所以我们将其修改为 90 度,船舶就能如预期般移动了。有关这一工作原理的更多信息,请查看以下教程:

gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/

setxVelocity((float) 
  (speed* Math.cos(Math.toRadians(getFacingAngle() + 90))));

setyVelocity((float) 
  (speed* Math.sin(Math.toRadians(getFacingAngle() + 90))));

现在,我们根据玩家是向左还是向右转动来设置旋转速度。最后,我们调用move()以使所有更新生效。

if(isPressingLeft){
  setRotationRate(360);
}

else if(isPressingRight){
  setRotationRate(-360);
     }else{
       setRotationRate(0);
    }

     move(fps);
}

现在,我们需要添加一个pullTrigger方法,目前我们只需返回true。我们还提供了三种方法供未来的InputController调用,触发update方法以进行各种更改。

public boolean pullTrigger() {
  //Try and fire a shot
  // We could control rate of fire from here
  // But lets just return true for unrestricted rapid fire
  // You could remove this method and any code which calls it

   return true;
}

public void setPressingRight(boolean pressingRight) {
  isPressingRight = pressingRight;
}

public void setPressingLeft(boolean pressingLeft) {
  isPressingLeft = pressingLeft;
}

public void toggleThrust() {
  isThrusting = ! isThrusting;
}

我们已经在每一帧中绘制了飞船,但我们需要在AsteroidsRenderer类的update方法中添加一行代码。添加这行代码以调用SpaceShip类的update方法:

// Update (twinkle) the stars
for (int i = 0; i < gm.numStars; i++) {
  gm.stars[i].update();
}

// Run the ship,s update() method
gm.ship.update(fps);

显然,在我们添加玩家控制之前,我们实际上无法移动。让我们快速向游戏中添加一些子弹。然后,我们将添加声音和控制,这样我们就可以看到和听到我们添加的酷炫新功能。

快速连发子弹

自 20 世纪 70 年代的 Pong 游戏以来,我就沉迷于游戏,记得当一位朋友在家中拥有一台太空侵略者游戏机大约一周时,我是多么兴奋。尽管真正让小行星比太空侵略者好的地方在于,你可以多快地进行射击。秉承这一传统,我们将制作一个令人满意的快速子弹流。

创建一个名为Bullet的新类,它有一个顶点,并将被绘制成一个点。注意,我们还声明并初始化了一个inFlight布尔值。

public class Bullet extends GameObject {

  private boolean inFlight = false;

  public Bullet(float shipX, float shipY) {
       super();

       setType(Type.BULLET);

       setWorldLocation(shipX, shipY);

       // Define the bullet
       // as a single point
       // in exactly the coordinates as its world location
       float[] bulletVertices = new float[]{

                0,
                0,
                0

       };

    setVertices(bulletVertices);

}

接下来,我们有一个shoot方法,它将子弹的facingAngle设置为飞船的facingAngle。这将导致子弹在按下开火按钮时沿飞船面向的方向移动。我们还设置inFlight为真,并查看在update方法中是如何使用它的。最后,我们将速度设置为300

我们还添加了一个resetBullet方法,它将子弹设置在飞船内部并取消其速度和速度。这让我们对如何实现我们的子弹有了线索。子弹将在飞船内部不可见,直到它们被发射。

public void shoot(float shipFacingAngle){

     setFacingAngle(shipFacingAngle);
     inFlight = true;
     setSpeed (300);
}

public void resetBullet(PointF shipLocation){

     // Stop moving if bullet out of bounds
     inFlight = false;
     setxVelocity(0);
     setyVelocity(0);
     setSpeed(0);
     setWorldLocation(shipLocation.x, shipLocation.y);

}

public boolean isInFlight(){
  return  inFlight;
}

现在,我们根据子弹的facingAngle和速度移动子弹,但只有当inFlight为真时。否则,我们将子弹保留在飞船内部。然后,我们调用move()

public void update(long fps, PointF shipLocation){
        // Set the velocity if bullet in flight
        if(inFlight){
            setxVelocity((float)(getSpeed()* 
               Math.cos(Math.toRadians(getFacingAngle() + 90))));
            setyVelocity((float)(getSpeed()* 
               Math.sin(Math.toRadians(getFacingAngle() + 90))));
        }else{
            // Have it sit inside the ship
            setWorldLocation(shipLocation.x, shipLocation.y);
        }

        move(fps);
    }
}

现在,我们有一个Bullet类,可以在GameManager类中声明一个数组,用来保存这一类型的多个对象。

int numStars = 200;
Bullet [] bullets;
int numBullets = 20;

createObjects()中初始化它们,就在AsteroidsRenderer中上一节星星之后。注意我们是如何将它们在游戏世界中的位置初始化为飞船的中心。

// Some bullets
gm.bullets = new Bullet[gm.numBullets];
for (int i = 0; i < gm.numBullets; i++) {
  gm.bullets[i] = new Bullet(
     gm.ship.getWorldLocation().x,
     gm.ship.getWorldLocation().y);
}

update方法中更新它们,就在我们的闪烁星星之后。

// Update all the bullets
for (int i = 0; i < gm.numBullets; i++) {

    // If not in flight they will need the ships location
    gm.bullets[i].update(fps, gm.ship.getWorldLocation());

}

draw方法中绘制它们,再次在星星之后。

for (int i = 0; i < gm.numBullets; i++) {
  gm.bullets[i].draw(viewportMatrix);
}

子弹现在已准备好发射!

我们将添加一个SoundManagerInputController类,然后我们可以看到我们的飞船及其快速开火枪支的行动。

重用现有类

让我们快速将SoundManagerInputController类添加到这个项目中,因为它们只需要稍作调整就能满足我们这里的需求。

AsteroidsViewAsteroidsRenderer类中都添加一个SoundManager和一个InputController对象的成员。

private InputController ic;
private SoundManager sm;

AsteroidsView类的onCreate方法中初始化新对象,并像这样调用loadSound方法:

public AsteroidsView(Context context, int screenX, int screenY) {
  super(context);

 sm = new SoundManager();
 sm.loadSound(context);
 ic = new InputController(screenX, screenY);
     gm = new GameManager(screenX, screenY);

同样在AsteroidsView中,向AsteroidsRenderer构造函数的调用中添加两个额外的参数,以传递对SoundManagerInputController对象的引用。

setEGLContextClientVersion(2);
setRenderer(new AsteroidsRenderer(gm,sm,ic));

现在,在AsteroidsRenderer构造函数中添加两个额外的参数,并像这样初始化两个新成员:

public AsteroidsRenderer(GameManager gameManager,
 SoundManager soundManager, InputController inputController) {

        gm = gameManager;
 sm = soundManager;
 ic = inputController;

       handyPointF = new PointF();
       handyPointF2 = new PointF();

}

在我们添加这两个类之前,你的 IDE 中会有错误。我们现在就来做这件事。

添加SoundManager

SoundManager类的工作方式与上一个项目完全一样,所以这里没有什么新内容需要解释。

将下载包Chapter10/assets文件夹中的所有声音文件添加到项目的 assets 文件夹中。与最后两个项目一样,你可能需要在项目的.../app/src/main文件夹中创建 assets 文件夹。

提示

与往常一样,你可以使用提供的声音效果,或者创建自己的效果。

现在,向项目中添加一个名为SoundManager的新类。请注意,该类的功能与上一个项目完全相同,但代码不同仅仅是因为声音文件和相关变量的名称不同。将以下代码添加到SoundManager类中:

public class SoundManager {
    private SoundPool soundPool;
    private int shoot = -1;
    private int thrust = -1;
    private int explode = -1;
    private int shipexplode = -1;
    private int ricochet = -1;
    private int blip = -1;
    private int nextlevel = -1;
    private int gameover = -1;

    public void loadSound(Context context){
        soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC,0);
        try{
            //Create objects of the 2 required classes
            AssetManager assetManager = context.getAssets();
            AssetFileDescriptor descriptor;

            //create our fx
            descriptor = assetManager.openFd("shoot.ogg");
            shoot = soundPool.load(descriptor, 0);

            descriptor = assetManager.openFd("thrust.ogg");
            thrust = soundPool.load(descriptor, 0);

            descriptor = assetManager.openFd("explode.ogg");
            explode = soundPool.load(descriptor, 0);

            descriptor = assetManager.openFd("shipexplode.ogg");
            shipexplode = soundPool.load(descriptor, 0);

            descriptor = assetManager.openFd("ricochet.ogg");
            ricochet = soundPool.load(descriptor, 0);

            descriptor = assetManager.openFd("blip.ogg");
            blip = soundPool.load(descriptor, 0);

            descriptor = assetManager.openFd("nextlevel.ogg");
            nextlevel = soundPool.load(descriptor, 0);

            descriptor = assetManager.openFd("gameover.ogg");
            gameover = soundPool.load(descriptor, 0);

        }catch(IOException e){
            //Print an error message to the console
            Log.e("error", "failed to load sound files");
        }
    }

    public void playSound(String sound){
        switch (sound){
            case "shoot":
                soundPool.play(shoot, 1, 1, 0, 0, 1);
                break;

            case "thrust":
                soundPool.play(thrust, 1, 1, 0, 0, 1);
                break;

            case "explode":
                soundPool.play(explode, 1, 1, 0, 0, 1);
                break;

            case "shipexplode":
                soundPool.play(shipexplode, 1, 1, 0, 0, 1);
                break;

            case "ricochet":
                soundPool.play(ricochet, 1, 1, 0, 0, 1);
                break;

            case "blip":
                soundPool.play(blip, 1, 1, 0, 0, 1);
                break;

            case "nextlevel":
                soundPool.play(nextlevel, 1, 1, 0, 0, 1);
                break;

            case "gameover":
                soundPool.play(gameover, 1, 1, 0, 0, 1);
                break;

        }

    }
}

我们现在可以从任何有对新类引用的地方调用playSound()

添加InputController

这与上一个项目中的处理方式相同,只是我们调用适当的PlayerShip方法,而不是 Bob 的。此外,当游戏暂停时,我们不会移动视口,因此无需在游戏暂停时以不同的方式处理屏幕触摸;这使得这个InputController更简单,更短。

AsteroidsView类中添加onTouchEvent方法,以将处理触摸的责任传递给InputController

@Override
    public boolean onTouchEvent(MotionEvent motionEvent) {
        ic.handleInput(motionEvent, gm, sm);
        return true;
    }

添加一个名为InputController的新类,并添加以下代码,这些代码很直观,除了我们处理玩家发射子弹的方式。

我们声明一个成员int currentBullet,用于跟踪我们将要发射的下一个子弹,来自我们即将声明的数组。然后,当按下开火按钮时,我们可以计算子弹数量,并在数组中的最后一个子弹发射后回到第一个子弹。

创建一个名为InputController的新类,并输入以下代码:

public class InputController {

    private int currentBullet;

    Rect left;
    Rect right;
    Rect thrust;
    Rect shoot;
    Rect pause;

    InputController(int screenWidth, int screenHeight) {

        //Configure the player buttons
        int buttonWidth = screenWidth / 8;
        int buttonHeight = screenHeight / 7;
        int buttonPadding = screenWidth / 80;

        left = new Rect(buttonPadding,
            screenHeight - buttonHeight - buttonPadding,
            buttonWidth,
            screenHeight - buttonPadding);

        right = new Rect(buttonWidth + buttonPadding,
            screenHeight - buttonHeight - buttonPadding,
            buttonWidth + buttonPadding + buttonWidth,
            screenHeight - buttonPadding);

        thrust = new Rect(screenWidth - buttonWidth - 
            buttonPadding,
            screenHeight - buttonHeight - buttonPadding - 
            buttonHeight - buttonPadding,
            screenWidth - buttonPadding,
            screenHeight - buttonPadding - buttonHeight - 
            buttonPadding);

        shoot = new Rect(screenWidth - buttonWidth - 
            buttonPadding,
            screenHeight - buttonHeight - buttonPadding,
            screenWidth - buttonPadding,
            screenHeight - buttonPadding);

        pause = new Rect(screenWidth - buttonPadding - 
            buttonWidth,
            buttonPadding,
            screenWidth - buttonPadding,
            buttonPadding + buttonHeight);

让我们将所有按钮捆绑在一个列表中,并通过一个公共方法使它们可用。

    }    
    public ArrayList getButtons(){

        //create an array of buttons for the draw method
        ArrayList<Rect> currentButtonList = new ArrayList<>();
        currentButtonList.add(left);
        currentButtonList.add(right);
        currentButtonList.add(thrust);
        currentButtonList.add(shoot);
        currentButtonList.add(pause);
        return  currentButtonList;
    }

接下来,我们像以前一样处理输入,只是调用我们的Ship类的方法。

public void handleInput(MotionEvent motionEvent,GameManager l,                                      
  SoundManager sound){

        int pointerCount = motionEvent.getPointerCount();

        for (int i = 0; i < pointerCount; i++) {
        int x = (int) motionEvent.getX(i);
        int y = (int) motionEvent.getY(i);

          switch (motionEvent.getAction() & 
             MotionEvent.ACTION_MASK) {

            case MotionEvent.ACTION_DOWN:
                    if (right.contains(x, y)) {
                    l.ship.setPressingRight(true);
                    l.ship.setPressingLeft(false);
                 } else if (left.contains(x, y)) {
                    l.ship.setPressingLeft(true);
                    l.ship.setPressingRight(false);
                    } else if (thrust.contains(x, y)) {
                    l.ship.toggleThrust();
                    } else if (shoot.contains(x, y)) {
                        if (l.ship.pullTrigger()) {
                        l.bullets[currentBullet].shoot
                                (l.ship.getFacingAngle());

                            currentBullet++;
                       // If we are on the last bullet restart
                       // from the first one again
                       if(currentBullet == l.numBullets){
                            currentBullet = 0;
                        }

                           sound.playSound("shoot");
                    }

                    } else if (pause.contains(x, y)) {
                    l.switchPlayingStatus();
                    }
                    break;

            case MotionEvent.ACTION_UP:
            if (right.contains(x, y)) {
                    l.ship.setPressingRight(false);
                } else if (left.contains(x, y)) {
                    l.ship.setPressingLeft(false);
                }

                break;

            case MotionEvent.ACTION_POINTER_DOWN:
            if (right.contains(x, y)) {
                    l.ship.setPressingRight(true);
                    l.ship.setPressingLeft(false);
                } else if (left.contains(x, y)) {
                    l.ship.setPressingLeft(true);
                 l.ship.setPressingRight(false);
                } else if (thrust.contains(x, y)) {
                    l.ship.toggleThrust();
                } else if (shoot.contains(x, y)) {
                    if (l.ship.pullTrigger()) {
                    l.bullets[currentBullet].shoot
                            (l.ship.getFacingAngle());

                        currentBullet++;
                    // If we are on the last bullet restart
                    // from the first one again
                    if(currentBullet == l.numBullets){
                        currentBullet = 0;
                    }
                    sound.playSound("shoot");
                    }
                } else if (pause.contains(x, y)) {
                    l.switchPlayingStatus();
                }
                break;

            case MotionEvent.ACTION_POINTER_UP:
            if (right.contains(x, y)) {
                    l.ship.setPressingRight(false);
                } else if (left.contains(x, y)) {
                    l.ship.setPressingLeft(false);
                }

                break;
            }
         }

    }
}

现在,我们可以四处飞行并发射几轮太空子弹!当然,在绘制本章后面的 HUD 之前,您将不得不估计屏幕位置。别忘了玩家需要首先点击暂停按钮(右上角)。

注意

请注意,目前我们不使用resetBullet方法,一旦您发射了二十颗子弹,您将无法再射击。我们可以快速检查子弹是否位于边界外,然后调用resetBullet,但我们将与所有的碰撞检测一起,在下一章中完全处理这个问题。

当然,没有行星的话,我们不能有一个行星游戏。

绘制和移动行星

最后,我们将添加酷炫的旋转行星。首先,我们将看看与其他游戏对象构造函数相当相似的构造函数,不同之处在于我们随机设置世界位置。但是,需要特别小心,不要在游戏开始的太空船中心位置生成它们。

创建一个名为Asteroid的新类,并添加这个构造函数。注意我们没有定义任何顶点。我们将这个任务委托给即将看到的generatePoints方法。

public class Asteroid extends GameObject{

    PointF[] points;

    public Asteroid(int levelNumber, int mapWidth, int mapHeight){
        super();

        // set a random rotation rate in degrees per second
        Random r = new Random();
        setRotationRate(r.nextInt(50 * levelNumber) + 10);

        // travel at any random angle
        setTravellingAngle(r.nextInt(360));

        // Spawn asteroids between 50 and 550 on x and y
        // And avoid the extreme edges of map
        int x = r.nextInt(mapWidth - 100)+50;
        int y = r.nextInt(mapHeight - 100)+50;

        // Avoid the center where the player spawns
        if(x > 250 && x < 350){ x = x + 100;}
        if(y > 250 && y < 350){ y = y + 100;}

        // Set the location
        setWorldLocation(x,y);

        // Make them a random speed with the maximum
        // being appropriate to the level number
        setSpeed(r.nextInt(25 * levelNumber)+1);

        setMaxSpeed(140);

        // Cap the speed
        if (getSpeed() > getMaxSpeed()){
            setSpeed(getMaxSpeed());
        }

        // Make sure we know this object is a ship
        setType(Type.ASTEROID);

        // Define a random asteroid shape
        // Then call the parent setVertices()
        generatePoints();

    }

我们的更新方法仅根据速度和移动角度计算速度,就像我们对SpaceShip类所做的那样。然后以常规方式调用move()

public void update(float fps){

  setxVelocity ((float) (getSpeed() * Math.cos(Math.toRadians  (getTravellingAngle() + 90))));

  setyVelocity ((float) (getSpeed() * Math.sin(Math.toRadians(getTravellingAngle() + 90))));

     move(fps);

}

在这里我们看到generatePoints方法,它将创建一个随机形状的行星。简单来说,每个行星都有六个顶点。每个顶点都有一个随机生成的位置,但限制相当严格,这样我们就不会得到任何重叠的线条。

// Create a random asteroid shape
public void generatePoints(){
  points = new PointF[7];

   Random r = new Random();
   int i;

     // First a point roughly centre below 0
     points[0] = new PointF();
     i = (r.nextInt(10))+1;
     if(i % 2 == 0){i = -i;}
     points[0].x = i;
     i = -(r.nextInt(20)+5);
     points[0].y = i;

     // Now a point still below centre but to the right and up a bit
     points[1] = new PointF();
     i = r.nextInt(14)+11;
     points[1].x = i;
     i = -(r.nextInt(12)+1);
     points[1].y =  i;

     // Above 0 to the right
     points[2] = new PointF();
     i = r.nextInt(14)+11;
     points[1].x = i;
     i = r.nextInt(12)+1;
     points[2].y = i;

     // A point roughly centre above 0
     points[3] = new PointF();
     i = (r.nextInt(10))+1;
     if(i % 2 == 0){i = -i;}
     points[3].x = i;
     i = r.nextInt(20)+5;
     points[3].y =  i;

     // left above 0
     points[4] = new PointF();
     i = -(r.nextInt(14)+11);
     points[4].x = i;
     i = r.nextInt(12)+1;
     points[4].y = i ;

     // left below 0
     points[5] = new PointF();
     i = -(r.nextInt(14)+11);
     points[5].x =  i;
     i = -(r.nextInt(12)+1);

     points[5].y = i;

现在,我们有六个点用来构建表示顶点的浮点数数组。最后,我们调用setVertices()来创建我们的ByteBuffer。请注意,行星将被绘制成一系列的线条,这就是数组中的最后一个顶点与第一个顶点相同的原因。

  // Now use these points to draw our asteroid
  float[] asteroidVertices = new float[]{
     // First point to second point
     points[0].x, points[0].y, 0,
     points[1].x, points[1].y, 0,

     // 2nd to 3rd
     points[1].x, points[1].y, 0,
     points[2].x, points[2].y, 0,

     // 3 to 4
     points[2].x, points[2].y, 0,
     points[3].x, points[3].y, 0,

     // 4 to 5
     points[3].x, points[3].y, 0,
     points[4].x, points[4].y, 0,

     // 5 to 6
     points[4].x, points[4].y, 0,
     points[5].x, points[5].y, 0,

     // 6 back to 1
     points[5].x, points[5].y, 0,
     points[0].x, points[0].y, 0,
};

setVertices(asteroidVertices);

}// End method

}// End class

如您所料,我们在GameManager中添加了一个数组来保存所有的行星。同时,我们还将声明一些变量,用来记录玩家当前的关卡以及初始(基础)的行星数量。随后,当我们初始化所有行星时,我们将看到如何确定需要摧毁的行星数量以完成一个关卡。

Asteroid [] asteroids;
int numAsteroids;
int numAsteroidsRemaining;
int baseNumAsteroids = 10;
int levelNumber = 1;

GameManager构造函数中初始化数组:

// For all our asteroids
asteroids = new Asteroid[500];

createObjects方法中使用我们之前声明的变量来初始化对象本身,根据当前关卡确定行星的数量。

// Determine the number of asteroids
gm.numAsteroids = gm.baseNumAsteroids * gm.levelNumber;
// Set how many asteroids need to be destroyed by player
gm.numAsteroidsRemaining = gm.numAsteroids;
// Spawn the asteroids

for (int i = 0; i < gm.numAsteroids * gm.levelNumber; i++) {
     // Create a new asteroid
     // Pass in level number so they can be made
     // appropriately dangerous.
     gm.asteroids[i] = new Asteroid
      (gm.levelNumber, gm.mapWidth, gm.mapHeight);

}

update方法中更新它们。

// Update all the asteroids
for (int i = 0; i < gm.numAsteroids; i++) {
  if (gm.asteroids[i].isActive()) {
    gm.asteroids[i].update(fps);
  }
}

最后,我们在draw方法中绘制所有的行星。

// The bullets
for (int i = 0; i < gm.numBullets; i++) {
  gm.bullets[i].draw(viewportMatrix);
}

for (int i = 0; i < gm.numAsteroids; i++) {
 if (gm.asteroids[i].isActive()) {
 gm.asteroids[i].draw(viewportMatrix);
 }

}

现在,运行游戏并查看那些流畅的 60+ FPS 旋转行星。

绘制和移动行星

现在,我们需要通过添加按钮图像以及一些其他覆盖信息,包括 HUD,来使控制飞船变得容易。

分数和 HUD(头上显示装置)

HUD 对象永远不会被旋转。另外,它们是在InputController类中根据屏幕坐标定义的,而不是游戏世界或甚至是 OpenGL 坐标。因此,我们的GameObject类不是一个合适的父类。

为了简单起见,这三个 HUD 类将各自拥有自己的draw方法。我们将看到如何使用新的视口矩阵以一致的大小和屏幕位置绘制它们。

创建了我们所有的 HUD 类之后,我们将添加所有的对象声明、初始化和绘制代码。

添加控制按钮

我们将为第一个 HUD 对象创建一个类,这是一个简单的按钮。

注意

我明确地展示了所有的导入语句,因为它们不会自动导入。请注意,接下来的两个类也需要这些。代码像往常一样包含在下载包中,如果你希望直接复制粘贴。

创建一个新类,将其命名为GameButton,然后添加以下导入语句。请确保根据你使用的章节代码或你给项目命的名声明正确的包名。

import android.graphics.PointF;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import static android.opengl.GLES20.GL_FLOAT;
import static android.opengl.GLES20.GL_LINES;
import static android.opengl.GLES20.glDrawArrays;
import static android.opengl.GLES20.glEnableVertexAttribArray;
import static android.opengl.GLES20.glGetAttribLocation;
import static android.opengl.GLES20.glGetUniformLocation;
import static android.opengl.GLES20.glUniform4f;
import static android.opengl.GLES20.glUniformMatrix4fv;
import static android.opengl.GLES20.glUseProgram;
import static android.opengl.Matrix.orthoM;
import static android.opengl.GLES20.glVertexAttribPointer;
import static com.gamecodeschool.c10asteroids.GLManager.A_POSITION;
import static com.gamecodeschool.c10asteroids.GLManager.COMPONENTS_PER_VERTEX;
import static com.gamecodeschool.c10asteroids.GLManager.FLOAT_SIZE;
import static com.gamecodeschool.c10asteroids.GLManager.STRIDE;
import static com.gamecodeschool.c10asteroids.GLManager.U_COLOR;
import static com.gamecodeschool.c10asteroids.GLManager.U_MATRIX;

首先,我们声明一些成员;viewportMatrix,我们将把来自InputController类的基于屏幕坐标的视口变换的新矩阵放入其中——一个整型glprogram值,一个int numVertices值,以及一个FloatBuffer类。

public class GameButton {

    // For button coordinate
    // into a GL space coordinate (-1,-1 to 1,1)
    // for drawing on the screen
    private final float[] viewportMatrix = new float[16];

    // A handle to the GL glProgram -
    // the compiled and linked shaders
    private static int glProgram;

    // How many vertices does it take to make
    // our button
    private int numVertices;

    // This will hold our vertex data that is
    // passed into openGL glProgram
    private FloatBuffer vertices;

在构造函数中我们首先通过调用orthoM()并传入屏幕的高度和宽度作为0,0来创建我们的视口矩阵。这使得 OpenGL 将一个与设备分辨率相同的坐标范围映射到 OpenGL 坐标范围之上。

然后,我们获取传入按钮的坐标并将其缩小以使其变小。然后,我们初始化一个顶点数组作为四条线来表示一个按钮。显然,我们将需要创建一个新的按钮对象来代表InputController类中的每个按钮。

public GameButton(int top, int left, 
    int bottom, int right, GameManager gm){

    //The HUD needs its own viewport
    // notice we set the screen height in pixels as the
    // starting y coordinates because
    // OpenGL is upside down world :-)
    orthoM(viewportMatrix, 0, 0, 
        gm.screenWidth, gm.screenHeight, 0, 0, 1f);

        // Shrink the button visuals to make
        // them less obtrusive while leaving
        // the screen area they represent the same.
        int width = (right - left) / 2;
        int height = (top - bottom) / 2;
        left = left + width / 2;
        right = right - width / 2;
        top = top - height / 2;
        bottom = bottom + height / 2;

        PointF p1 = new PointF();
        p1.x = left;
        p1.y = top;

        PointF p2 = new PointF();
        p2.x = right;
        p2.y = top;

        PointF p3 = new PointF();
        p3.x = right;
        p3.y = bottom;

        PointF p4 = new PointF();
        p4.x = left;
        p4.y = bottom;

        // Add the four points to an array of vertices
        // This time, because we don't need to animate the border
        // we can just declare the world space coordinates, the
        // same as above.
        float[] modelVertices = new float[]{
                // A line from point 1 to point 2
                p1.x, p1.y, 0,
                p2.x, p2.y, 0,
                // Point 2 to point 3
                p2.x, p2.y, 0,
                p3.x, p3.y, 0,
                // Point 3 to point 4
                p3.x, p3.y, 0,
                p4.x, p4.y, 0,
                // Point 4 to point 1
                p4.x, p4.y, 0,
                p1.x, p1.y, 0
        };

现在,我们从GameObject复制了一些代码来准备ByteBuffer,但我们仍然使用我们的静态GLManager.getGLProgram()来获取 GL 程序的句柄。

       // Store how many vertices and 
       // elements there is for future use
       final int ELEMENTS_PER_VERTEX = 3;// x,y,z
       int numElements = modelVertices.length;
       numVertices = numElements/ELEMENTS_PER_VERTEX;

       // Initialize the vertices ByteBuffer object based on the
       // number of vertices in the button and the number of
       // bytes there are in the float type
       vertices = ByteBuffer.allocateDirect(
                numElements
                * FLOAT_SIZE)
                .order(ByteOrder.nativeOrder()).asFloatBuffer();

       // Add the button into the ByteBuffer object
       vertices.put(modelVertices);

       glProgram = GLManager.getGLProgram();

}

最后,我们实现了draw方法,这是来自GameObjectdraw方法的简化版本。注意我们不需要处理模型、转换和旋转矩阵,并且我们传递了一个不同的颜色给片段着色器。

public void draw(){

    // And tell OpenGl to use the glProgram
    glUseProgram(glProgram);

    // Now we have a glProgram we need the locations
    // of our three GLSL variables
    int uMatrixLocation = glGetUniformLocation(glProgram, U_MATRIX);

    int aPositionLocation = 
        glGetAttribLocation(glProgram, A_POSITION);

    int uColorLocation = glGetUniformLocation(glProgram, U_COLOR);

    vertices.position(0);

    glVertexAttribPointer(
        aPositionLocation,
        COMPONENTS_PER_VERTEX,
        GL_FLOAT,
        false,
        STRIDE,
        vertices);

    glEnableVertexAttribArray(aPositionLocation);

    // give the new matrix to OpenGL
    glUniformMatrix4fv(uMatrixLocation, 1, false, viewportMatrix, 0);

    // Assign a different color to the fragment shader
    glUniform4f(uColorLocation, 0.0f, 0.0f, 1.0f, 1.0f);

    // Draw the lines
    // start at the first element of the
    // vertices array and read in all vertices
    glDrawArrays(GL_LINES, 0, numVertices);

}
}// End class

计数字符

这个类与GameButton相同,不同之处在于计数字符将是一个单一的垂直直线;因此,我们只需要两个顶点。

但是请注意,我们在构造函数中有一个名为nthIcon的参数。调用代码需要负责让TallyIcon知道已经创建的TallyIcon对象的总数量加一。然后,当前的TallyIcon对象可以使用内边距变量来适当定位自己。

创建一个名为 TallyIcon 的新类,并输入以下代码。像之前一样,根据需要包含静态导入。以下是所有声明和构造函数的代码:

public class TallyIcon {

    // For button coordinate
    // into a GL space coordinate (-1,-1 to 1,1)
    // for drawing on the screen
    private final float[] viewportMatrix = new float[16];

    // A handle to the GL glProgram -
    // the compiled and linked shaders
    private static int glProgram;

    // How many vertices does it take to make
    // our button
    private int numVertices;

    // This will hold our vertex data that is
    // passed into openGL glProgram
    //private final FloatBuffer vertices;
    private FloatBuffer vertices;

    public TallyIcon(GameManager gm, int nthIcon){

        // The HUD needs its own viewport
        // notice we set the screen height in pixels as the
        // starting y coordinates because
        // OpenGL is upside down world :-)
        orthoM(viewportMatrix, 0, 0,
          gm.screenWidth, gm.screenHeight, 0, 0f, 1f);

        float padding = gm.screenWidth / 160;
        float iconHeight = gm.screenHeight / 15;
        float iconWidth = 1; // square icons
        float startX = 10 + (padding + iconWidth)* nthIcon;
        float startY = iconHeight * 2 + padding;

        PointF p1 = new PointF();
        p1.x = startX;
        p1.y = startY;

        PointF p2 = new PointF();
        p2.x = startX;
        p2.y = startY - iconHeight;

        // Add the four points to an array of vertices
        // This time, because we don't need to animate the border
        // we can just declare the world space coordinates, the
        // same as above.
        float[] modelVertices = new float[]{
                // A line from point 1 to point 2
                p1.x, p1.y, 0,
                p2.x, p2.y, 0,

        };

        // Store how many vertices and 
        //elements there is for future use
        final int ELEMENTS_PER_VERTEX = 3;// x,y,z
        int numElements = modelVertices.length;
        numVertices = numElements/ELEMENTS_PER_VERTEX;

        // Initialize the vertices ByteBuffer object based on the
        // number of vertices in the button and the number of
        // bytes there are in the float type
        vertices = ByteBuffer.allocateDirect(
                numElements
                * FLOAT_SIZE)
                .order(ByteOrder.nativeOrder()).asFloatBuffer();

        // Add the button into the ByteBuffer object
        vertices.put(modelVertices);

        glProgram = GLManager.getGLProgram();
    }

这就是 draw 方法,现在看起来可能相当熟悉了。

    public void draw(){

        // And tell OpenGl to use the glProgram
        glUseProgram(glProgram);

        // Now we have a glProgram we need the locations
        // of our three GLSL variables
        int uMatrixLocation = 
        glGetUniformLocation(glProgram, U_MATRIX);

        int aPositionLocation = 
        glGetAttribLocation(glProgram, A_POSITION);

        int uColorLocation = 
        glGetUniformLocation(glProgram, U_COLOR);

        vertices.position(0);

        glVertexAttribPointer(
                aPositionLocation,
                COMPONENTS_PER_VERTEX,
                GL_FLOAT,
                false,
                STRIDE,
                vertices);

        glEnableVertexAttribArray(aPositionLocation);

        // Just give the passed in matrix to OpenGL
        glUniformMatrix4fv(uMatrixLocation, 1, 
          false, viewportMatrix, 0);

        // Assign a color to the fragment shader
        glUniform4f(uColorLocation, 1.0f, 1.0f, 0.0f, 1.0f);

        // Draw the lines
        // start at the first element of the vertices array and read in all vertices
        glDrawArrays(GL_LINES, 0, numVertices);
    }

现在是最后的 HUD 元素。

生命图标

我们最后的图标将是一种迷你飞船,用来指示玩家还剩下多少生命。

我们将使用线条构建一个三角形形状,以创建一个漂亮的空心效果。请注意,LifeIcon 构造函数还使用 nthIcon 元素来控制填充和屏幕上的位置。

创建一个名为 LifeIcon 的新类,并输入以下代码,记住所有不会自动导入的导入语句。以下是声明和构造函数:

public class LifeIcon {

     // Remember the static import for GLManager

     // For button coordinate
     // into a GL space coordinate (-1,-1 to 1,1)
     // for drawing on the screen
     private final float[] viewportMatrix = new float[16];

     // A handle to the GL glProgram -
     // the compiled and linked shaders
     private static int glProgram;

     // Each of the above constants also has a matching int
     // which will represent its location in the open GL glProgram
     // In GameButton they are declared as local variables

     // How many vertices does it take to make
     // our button
     private int numVertices;

     // This will hold our vertex data that is
     // passed into openGL glProgram
     //private final FloatBuffer vertices;
     private FloatBuffer vertices;

     public LifeIcon(GameManager gm, int nthIcon){

     // The HUD needs its own viewport
     // notice we set the screen height in pixels as the
     // starting y coordinates because
     // OpenGL is upside down world :-)
     orthoM(viewportMatrix, 0, 0,
       gm.screenWidth, gm.screenHeight, 0, 0f, 1f);

     float padding = gm.screenWidth / 160;
     float iconHeight = gm.screenHeight / 15;
     float iconWidth = gm.screenWidth / 30;
     float startX = 10 + (padding + iconWidth)* nthIcon;
     float startY = iconHeight;

     PointF p1 = new PointF();
     p1.x = startX;
     p1.y = startY;

     PointF p2 = new PointF();
     p2.x = startX + iconWidth;
     p2.y = startY;

     PointF p3 = new PointF();
     p3.x = startX + iconWidth/2;
     p3.y = startY - iconHeight;

     // Add the four points to an array of vertices
     // This time, because we don't need to animate the border
     // we can just declare the world space coordinates, the
     // same as above.
     float[] modelVertices = new float[]{
               // A line from point 1 to point 2
               p1.x, p1.y, 0,
               p2.x, p2.y, 0,
               // Point 2 to point 3
               p2.x, p2.y, 0,
               p3.x, p3.y, 0,
               // Point 3 to point 1
               p3.x, p3.y, 0,
               p1.x, p1.y, 0,

  };

     // Store how many vertices and elements there is for future 
     // use
     final int ELEMENTS_PER_VERTEX = 3;// x,y,z
     int numElements = modelVertices.length;
     numVertices = numElements/ELEMENTS_PER_VERTEX;

     // Initialize the vertices ByteBuffer object based on the
     // number of vertices in the button and the number of
     // bytes there are in the float type
     vertices = ByteBuffer.allocateDirect(
              numElements
              * FLOAT_SIZE)
              .order(ByteOrder.nativeOrder()).asFloatBuffer();

     // Add the button into the ByteBuffer object
     vertices.put(modelVertices);

       glProgram = GLManager.getGLProgram();
     }

这是 LifeIcon 类的 draw 方法:

    public void draw(){

            // And tell OpenGl to use the glProgram
            glUseProgram(glProgram);

            // Now we have a glProgram we need the locations
            // of our three GLSL variables
            int uMatrixLocation = glGetUniformLocation 
              (glProgram, U_MATRIX);
            int aPositionLocation = glGetAttribLocation 
              (glProgram, A_POSITION);
            int uColorLocation = glGetUniformLocation 
               (glProgram, U_COLOR);

            vertices.position(0);

            glVertexAttribPointer(
                    aPositionLocation,
                    COMPONENTS_PER_VERTEX,
                    GL_FLOAT,
                    false,
                    STRIDE,
                    vertices);

            glEnableVertexAttribArray(aPositionLocation);

            // Just give the passed in matrix to OpenGL
            glUniformMatrix4fv(uMatrixLocation, 1, 
              false, viewportMatrix, 0);
            // Assign a color to the fragment shader
            glUniform4f(uColorLocation, 1.0f, 
              1.0f, 0.0f, 1.0f);
            // Draw the lines
            // start at the first element of 
            // the vertices array and read in all vertices
            glDrawArrays(GL_LINES, 0, numVertices);
        }

}

我们已经有了三个 HUD 类,并且可以将它们绘制到屏幕上。

声明、初始化并绘制 HUD 对象

我们将像所有 GameObject 类一样声明、初始化并绘制我们的 HUD 对象。但是请注意,如预期的那样,我们不向 draw 方法传递视口矩阵,因为 HUD 类提供了自己的视口矩阵。

GameManager 添加这些成员:

TallyIcon[] tallyIcons;
int numLives = 3;
LifeIcon[] lifeIcons;

与我们对 asteroids 数组的操作一样,在 GameManager 构造函数中初始化 tallyIconslifeIcons

lifeIcons = new LifeIcon[50];
tallyIcons = new TallyIcon[500];

AsteroidsRenderer 类添加一个新的成员数组:

// This will hold our game buttons
private final GameButton[] gameButtons = new GameButton[5];

添加这段代码以创建我们所有新 HUD 类的对象。将其添加到 createObjects 方法中的闭合大括号之前:

// Now for the HUD objects
// First the life icons
for(int i = 0; i < gm.numLives; i++) {
    // Notice we send in which icon this represents
    // from left to right so padding and positioning is correct.
    gm.lifeIcons[i] = new LifeIcon(gm, i);
}

// Now the tally icons (1 at the start)
for(int i = 0; i < gm.numAsteroidsRemaining; i++) {
    // Notice we send in which icon this represents
    // from left to right so padding and positioning is correct.
    gm.tallyIcons[i] = new TallyIcon(gm, i);
}

// Now the buttons
ArrayList<Rect> buttonsToDraw = ic.getButtons();
int i = 0;
for (Rect rect : buttonsToDraw) {
    gameButtons[i] = new GameButton(rect.top, rect.left, 
        rect.bottom, rect.right, gm);

    i++;

}

现在,我们可以根据剩余的生命次数和升级前剩余的 asteroids 数量来绘制我们的 HUD。将此代码添加到 draw 方法的末尾:

// the buttons
for (int i = 0; i < gameButtons.length; i++) {
  gameButtons[i].draw();
}

// Draw the life icons
for(int i = 0; i < gm.numLives; i++) {
     // Notice we send in which icon this represents
     // from left to right so padding and positioning is correct.
     gm.lifeIcons[i].draw();
}

// Draw the level icons
for(int i = 0; i < gm.numAsteroidsRemaining; i++) {
  // Notice we send in which icon this represents
  // from left to right so padding and positioning is correct.
  gm.tallyIcons[i].draw();
}

现在你可以飞来飞去,欣赏你的新 HUD 了。

声明、初始化并绘制 HUD 对象

显然,如果我们想要充分利用生命和 asteroid 计数指示器,那么我们首先需要能够射击 asteroid,并在飞船被击中时检测到它们。

总结

在本章中我们取得了很大的成就,实际上可以很容易地快速添加更多的游戏对象。也许,可以像原始街机经典游戏中那样偶尔添加一个 UFO。

在下一章中,我们将利用在前一个项目中学习到的内容来设置碰撞检测,并完成游戏。然而,一个拥有精确、清晰、平滑移动线条的游戏,理应比我们至今所使用的更精确的碰撞检测。

因此,我们将专注于实现精确高效的碰撞检测,以使我们的 Asteroids 仿真模拟器得以完善。

第十一章:碰撞事件——第二部分

这款游戏中的碰撞检测比前两款要复杂得多。因此,代码将会有很多注释。有时注释会详细解释一些内容,或者用稍微不同的方式解释。

然而,这并不意味着它需要艰苦的工作。我们需要做的是花点时间考虑一个适合我们的策略。

希望这种方法意味着在本章结束时,我们的碰撞检测解决方案将显得直接明了。

碰撞检测的规划

我们试图实现的目标可以分为以下两类:

  • 我们希望边界能做到:

    • 小行星、子弹和船只需要在它们与边界碰撞时知道这一点

    • 小行星在接触到边界时应反转并返回游戏区域

    • 子弹在接触到边界时应重置自己

    • 船只需要减去一条生命,然后在中心重新生成

  • 我们希望小行星能做到什么。我们需要知道并在以下情况下做出响应:

    • 船只接触到小行星

    • 当一颗子弹接触到小行星时

    • 与原始的《小行星》游戏一样,我们将不对小行星之间的相互碰撞做出响应

尽管我们不会检测小行星之间的碰撞,但当我们的碰撞检测接近完成时,你会发现实现小行星之间的碰撞检测并不会带来太大的额外挑战。然而,这会对设备的 CPU 造成额外的压力。

我们知道我们需要检测的对象有边界碰撞和小行星碰撞。

与边界的碰撞

这可能听起来很明显,但边界仅仅是由四条静态直线组成。这使得边界碰撞与小行星碰撞是不同的问题。

我们感兴趣的所有对象都有顶点(子弹的情况就是一个顶点)。这最初可能意味着我们可以简单地从模型空间和存储在worldLocation中的对象中心计算每个顶点的世界位置。我们可以这样做,但这忽略了小行星和船只的旋转,这导致所有顶点的实际世界位置不断变化。

我们需要将模型空间的顶点进行平移和旋转,然后测试它们是否触碰到边界。我们可以在每个帧的对象的update方法中这样做,但我们只需要在对象非常接近边界时偶尔获取旋转后的坐标。

边界碰撞检测的第一阶段

这表明初步检查,即碰撞检测的第一阶段,效率更高。这意味着顶点的平移和旋转需要发生在对象本身之外。

我们将使用一个基于对象中心和宽高的简单矩形相交检查。如果这个低成本的方法返回一个命中,我们然后将每个顶点进行旋转和平移,并单独检查它们的世界坐标是否与边界位置相撞。

计算出顶点的旋转后游戏世界位置后,碰撞检测就变得简单了。

if (any point falls outside the border){collision has occurred}

正如我们将看到的,两阶段解决方案也适用于小行星检测。尽管涉及到旋转和平移,但这要次要得多。

与小行星碰撞

与小行星的碰撞测试在某些方面是相似的。我们需要找出船或子弹的任何一个顶点是否进入了由小行星顶点所围成的空间。

第一个问题在于小行星不仅是一个移动目标,而且还在旋转。我们不仅要旋转和平移物体的所有顶点,还要对小行星进行同样的操作。

我们还需要计算小行星上每对顶点之间的线段。幸运的是,在这个阶段,我们可以依赖一个比我更伟大的数学家设计并完善的巧妙算法。我们将使用交叉数算法。这是它的工作原理。

交叉数

我们计算一对顶点形成的线段,并使用交叉数算法查看被测试物体的某个特定顶点是否穿过了该线段。如果穿过了,我们将一个变量从 0 增加到 1。

我们用交叉数算法测试同一个点与由小行星的每对顶点形成的每一条线,每次它穿过就增加我们的变量。如果在对顶点与每条线进行测试后,我们的变量是奇数,那么就表示有碰撞发生。如果是偶数,则没有发生碰撞。

当然,如果没有发生碰撞,我们必须继续测试被测试物体的每个顶点与由小行星上的顶点对形成的每条线。

这是一张交叉数算法工作过程的视觉表示图。

交叉数

当然,在进行所有这些复杂的计算时,我们肯定想要先做一个简单的第一阶段测试,以查看是否可能发生了碰撞,然后再进行复杂的测试。

小行星碰撞检测的第一阶段和概述

当测试单个顶点,如子弹、像船一样的旋转三角形或旋转小行星时,半径重叠测试非常合适。

这是我们将用于测试与 小行星碰撞的整个过程的概述:

  1. 被测试物体的半径是否与小行星的半径重叠?

  2. 如果是,物体的第一个顶点是否穿过了小行星的第一条线?

  3. 如果是,crossingNumber ++

  4. 对每个物体的每行重复步骤 2。

  5. 如果crossingNumber是奇数,返回给调用代码 true,因为已经发生了碰撞。

  6. 如果crossingNumber是偶数,则尚未发生碰撞,用被测试物体的下一个顶点重复步骤 2、3 和 4。

  7. 如果所有顶点都已测试并且我们到达这里,则没有发生碰撞。

我们将设置一个名为CD的碰撞检测类,其中包含两个静态方法。detect方法将测试与小行星的碰撞,并且每一帧对每个子弹和飞船调用,针对每一个小行星。

contain方法将检查每个小行星、子弹和飞船与边界的碰撞情况。

在对象外部进行计算意味着我们将需要大量我们正在测试的对象的数据,以及那些可供新的CD类方法访问的数据。

CollisionPackage

我们知道,为了正确执行检测,我们需要一组特定的数据。接下来的这个类将保存碰撞检测类方法执行任务所需的所有数据,而每个需要检测碰撞的对象都将拥有这样一个类。

当需要将所有点旋转到它们在现实世界中的位置时,我们的碰撞包需要知道物体面向哪个方向。我们有一个名为facingAngle的浮点数。

显然,我们需要模型空间顶点的副本。与旋转位置一样,我们不会在每一帧都麻烦地更新,而是在碰撞检测的第一阶段显示可能发生碰撞后这样做。

我们还将保存一个预计算的值,即保存这些顶点的数组的长度。它可以在碰撞检测过程中潜在地节省时间。

因此,我们还需要物体的世界坐标。这个坐标我们将每一帧更新。

每个对象将有一个预计算的radius变量,这是从对象中心到最远顶点的距离,即对象的大小。这将在我们的detect方法中用于第一阶段检测的半径重叠。

我们还将有两个PointF对象,currentPointcurrentPoint2,它们只是方便的对象,可以避免在我们两个碰撞检测方法中的密集部分可能调用垃圾收集器。

创建一个名为CollisionPackage的新类,并实现我们刚刚讨论过的成员:

// All objects which can collide have a collision package.
// Asteroids, ship, bullets. The structure seems like slight
// overkill for bullets but it keeps the code generic,
// and the use of vertexListLength means there isn't any
// actual speed overhead. Also if we wanted line, triangle or
// even spinning bullets the code wouldn't need to change.

public class CollisionPackage {

    // All the members are public to avoid multiple calls
    // to getters and setters.

    // The facing angle allows us to calculate the
    // current world coordinates of each vertex using
    // the model-space coordinates in vertexList.
    public float facingAngle;

    // The model-space coordinates
    public PointF[] vertexList;

    /* 
    The number of vertices in vertexList
    is kept in this next int because it is pre-calculated
    and we can use it in our loops instead of
    continually calling vertexList.length.
   */
    public int vertexListLength;

    // Where is the centre of the object?
    public PointF worldLocation;

    /* 
    This next float will be used to detect if the circle shaped
    hitboxes collide. It represents the furthest point
    from the centre of any given object.
    Each object will set this slightly differently.
    The ship will use height/2 an asteroid will use 25
    To allow for a max length rotated coordinate.
   */
    public float radius;

    // A couple of points to store results and avoid creating new
    // objects during intensive collision detection
    public PointF currentPoint = new PointF();
    public PointF currentPoint2 = new PointF();

接下来,我们有一个简单的构造函数,它将在每个对象的构造函数末尾接收来自每个对象的所有必要数据。按照如下所示实现CollisionPackage构造函数:

public CollisionPackage(PointF[] vertexList, PointF worldLocation, 
  float radius, float facingAngle){ 

        vertexListLength = vertexList.length;
        this.vertexList = new PointF[vertexListLength];
        // Make a copy of the array

        for (int i = 0; i < vertexListLength; i++) {
            this.vertexList[i] = new PointF();
            this.vertexList[i].x = vertexList[i].x;
            this.vertexList[i].y = vertexList[i].y;
        }

        this.worldLocation = new PointF();
        this.worldLocation = worldLocation;

        this.radius = radius;

        this.facingAngle = facingAngle;

    }

}

这就是我们进行高级碰撞检测所需的所有数据。

向对象添加碰撞包并使它们可访问

现在,我们有了CollisionPackage类。我们将看到如何向每个需要监控的对象添加一个。

Bullet类添加一个碰撞包

打开Bullet类,我们将看到如何在我们最简单的情况(只是一个点)上使用CollisionPackage构造函数。为碰撞包添加一个新成员。

Bullet类中添加一个类型为CollisionPackage的新成员:

CollisionPackage cp;

现在,我们创建一个结构,将其传递给我们的CollisionPackage构造函数,并初始化碰撞包。注意,我们传递一个只包含模型空间坐标 0,0,0 的单元素数组。然后,我们传递子弹面向的世界位置、半径 1 和角度。在Bullet类的构造函数的最后输入以下代码:

// Initialize the collision package
// (the object space vertex list, x any world location
// the largest possible radius, facingAngle)

// First, build a one element array
PointF point = new PointF(0,0);
PointF[] points = new PointF[1];
points[0] = point;

// 1.0f is an approximate representation 
//of the size of a bullet
cp = new CollisionPackage(points, getWorldLocation(),
1.0f, getFacingAngle());

最后,对于Bullet类,我们通过在Bullet类的update方法的最后添加以下代码,在每一帧更新碰撞包:

        move(fps);

 // Update the collision package
 cp.facingAngle = getFacingAngle();
 cp.worldLocation = getWorldLocation();

现在,我们的子弹都已准备好进行检测。

向 SpaceShip 类添加碰撞包

打开SpaceShip类并添加这些成员。然后我们将在SpaceShip构造函数中看到如何使用它们:

CollisionPackage cp;

// Next, a 2d representation using PointF of
// the vertices. Used to build shipVertices
// and to pass to the CollisionPackage constructor
PointF[] points;

在这里,与Bullet类相比,我们做了一些额外的工作。我们增加了三个额外的模型空间坐标。OpenGL 不需要知道这些,也不需要它们。它们位于构成飞船的每条线的中间。我们这样做是为了使小行星的顶点更难在没有飞船顶点位于小行星内部的情况下漂入飞船内部。这是我们正在解决的问题的视觉表示。飞船的顶点被重点强调,以突出这个问题。参考以下图表:

向 SpaceShip 类添加碰撞包

我们可以通过测试所有小行星的顶点与所有飞船的线,以及我们计划要做的事情;测试所有飞船的顶点与所有小行星的线,完全解决这个问题。然而,仅向飞船添加几个额外的点确实可以产生近乎完美的检测,如下所示:

向 SpaceShip 类添加碰撞包

现在,在SpaceShip构造函数中setVertices()调用之后,立即实现我们刚才讨论的代码:

setVertices(shipVertices);

// Initialize the collision package
// (the object space vertex list, x any world location
// the largest possible radius, facingAngle)

points = new PointF[6];
points[0] = new PointF(- halfW, - halfL);

points[2] = new PointF(halfW, - halfL);
points[4] = new PointF(0, 0 + halfL);

// To make collision detection more accurate we will define some
// more points on the midpoints of all our sides.
// It is possible that the point of an asteroid will pass through
// the side of the ship and we do not test for this!
// We only test for the point of a ship 
// passing through the side of an asteroid!!
// This is computationally cheaper than running both tests.
// Although not as accurate we will see it is very close.
// We can think of this visually as 
// adding extra sensors on the sides of our ship
// Here we use an equation to find the midpoint 
// of a line which you can find an explanation of
// on most good high school math web sites.

points[1] = new PointF(points[0].x + 
 points[2].x/2,(points[0].y + points[2].y)/2);

points[3] = new PointF((points[2].x + points[4].x)/2,
 (points[2].y + points[4].y)/2);

points[5] = new PointF((points[4].x + points[0].x)/2,
 (points[4].y + points[0].y)/2);

cp = new CollisionPackage(points, getWorldLocation(), 
 length/2, getFacingAngle());

}// End SpaceShip constructor

接下来,像对Bullet类所做的那样,我们在SpaceShip类的update方法中每帧同步碰撞包。在move()调用更新飞船坐标后,我们会在方法的最后这样做。

move(fps);

 // Update the collision package
 cp.facingAngle = getFacingAngle();
 cp.worldLocation = getWorldLocation();

}// End SpaceShip update()

最后,我们将在小行星上添加一个碰撞包。

向 Asteroid 类添加碰撞包

打开Asteroid类并添加一个CollisionPackage成员:

CollisionPackage cp;

Asteroid构造函数的最后,紧接在generatePoints()调用之后,我们初始化了CollisionPackage对象:

// Define a random asteroid shape
// Then call the parent setVertices()
generatePoints();

// Initialize the collision package
// (the object space vertex list, x any world location
// the largest possible radius, facingAngle)
cp = new CollisionPackage
 (points, getWorldLocation(), 25, getFacingAngle());

接下来,我们添加一个辅助方法,当检测到碰撞时,这个方法会反转旅行方向,并将小行星通过几个像素弹回。当检测到与边界的碰撞时,我们将调用这个方法。将bounce方法添加到Asteroid类中:

public void bounce(){

  // Reverse the travelling angle
    if(getTravellingAngle() >= 180){
      setTravellingAngle(getTravellingAngle()-180);
     }else{
      setTravellingAngle(getTravellingAngle() + 180);
    }

    // Reverse velocity because occasionally they get stuck
    setWorldLocation((getWorldLocation().x + -getxVelocity()/3), (getWorldLocation().y + -getyVelocity()/3));

    // Speed up by 10%
    setSpeed(getSpeed() * 1.1f);

    // Not too fast though
    if(getSpeed() > getMaxSpeed()){
      setSpeed(getMaxSpeed());

}

SpaceShipBullet类一样,我们将在update方法中,紧接在move调用之后,在update方法的最后更新碰撞包:

move(fps);

// Update the collision package
cp.facingAngle = getFacingAngle();
cp.worldLocation = getWorldLocation();

}

现在,我们需要做一件在其他类中不需要做的事情。我们的交叉数算法使用线而不是顶点,所以我们需要通过将最后一个顶点与第一个顶点连接起来来形成一条线。由于我们的碰撞数据代码的工作方式,我们不需要对SpaceShip类这样做。碰撞数据代码将测试子弹和飞船的顶点与小行星的线。而不是反过来的方式。

这是需要添加到generatePoints方法的第七点处的额外代码。在以下代码中,我在新突出显示的代码两侧包含了现有的代码:

// left below 0
points[5] = new PointF();
i = -(r.nextInt(14)+11);
points[5].x =  i;
i = -(r.nextInt(12)+1);

points[5].y = i;

// We add on an extra point that we won't use in asteroidVertices[].
// The point is the same as the first. 
// This is because the last vertex
// links back to the first to create a line. 
// This line will need to be
// used in calculations when we do our collision detection.

// Here is the extra vertex- same as the first.
points[6] = new PointF();
points[6].x = points[0].x;
points[6].x = points[0].x;

// Now use these points to draw our asteroid
float[] asteroidVertices = new float[]{
// First point to second point
points[0].x, points[0].y, 0,
points[1].x, points[1].y, 0,

现在,我们可以谈谈构建碰撞检测类本身。

CD 类大纲

我们现在将实现碰撞检测的第一阶段。如所讨论的,我们将使用的算法计算成本很高,只有当有实际碰撞的可能性时,我们才希望使用它们。

因此,我们将使用在第三章中讨论的半径重叠方法,检查每个子弹和飞船与每个小行星之间的碰撞。我们将使用简化的矩形相交方法检查小行星、飞船和子弹与边界之间的碰撞。

在接下来的两个部分之后,你实际上可以玩游戏,但你会发现我们到目前为止使用的这种基本碰撞检测对于这类游戏来说还不够令人满意。

这些初步检查将决定我们是否继续进行更准确且计算成本更高的检查。

我们将在精确边界碰撞检测精确小行星碰撞检测部分实现这些第二阶段检查,它们将使用更高级的算法,并充分利用我们的碰撞数据包。

首先,创建一个名为CD的新类。添加一个PointF成员对象并初始化它。我们将在代码的关键部分使用它,以避免创建新对象。

private static PointF rotatedPoint = new PointF();

现在,让我们讨论一下这些方法。

为小行星和飞船实现半径重叠

让我们在CD类中添加我们的第一个方法,用于检测子弹与行星以及飞船与行星之间的碰撞。正如我们讨论的,现在我们只实现这个方法的第一部分。以下是半径重叠代码的实现。

代码通过构建一个缺少一边的假设三角形,然后使用勾股定理计算两个对象中心点之间的缺失边,也就是两个物体之间的距离。如果两个物体的半径之和大于两个物体中心之间的距离,那么就存在重叠。

添加带有半径重叠代码的detect方法。注意,如果半径重叠,我们返回true。这行代码将在本章后面被更准确的检测所替换。

public static boolean detect(CollisionPackage cp1, 
    CollisionPackage cp2) {

    boolean collided = false;

   // Check circle collision between the two objects

   // Get the distance of the two objects from
   // the centre of the circles on the x axis
   float distanceX = (cp1.worldLocation.x)
        - (cp2.worldLocation.x);

   // Get the distance of the two objects from
   // the centre of the circles on the y axis
   float distanceY = (cp1.worldLocation.y)
        - (cp2.worldLocation.y);

        // Calculate the distance between the center of each circle
        double distance = Math.sqrt
            (distanceX * distanceX + distanceY * distanceY);

        // Finally see if the two circles overlap
        // If they do it is worth doing the more intensive
        // and accurate check.
        if (distance < cp1.radius + cp2.radius) {

         // Log.e("Circle collision:","true");
         // todo  Eventually we will add the 
         // more accurate code here
         // todo and delete the line below.

            collided = true;
        }

        return collided;
    }

现在,让我们讨论一下边界。

实现边界矩形相交

我们将检查是否有任何小行星、子弹或飞船需要被限制在边界内。如讨论所述,我们将执行一个简单的矩形相交测试,如果检测到则返回true。稍后,我们将删除返回true并添加更复杂的代码。

按照如下所示实现contain方法:

// Check if anything hits the border
public static boolean contain(float mapWidth, float mapHeight,                                              
  CollisionPackage cp) {

   boolean possibleCollision = false;

    // Check if any corner of a virtual rectangle
    // around the centre of the object is out of bounds.
    // Rectangle is best because we are testing 
    // against straight sides (the border)
    // If it is we have a possible collision.

    if (cp.worldLocation.x - cp.radius < 0) {
            possibleCollision = true;
        } else if (cp.worldLocation.x + cp.radius > mapWidth) {
            possibleCollision = true;
        } else if (cp.worldLocation.y - cp.radius < 0) {
            possibleCollision = true;
        } else if (cp.worldLocation.y + cp.radius > mapHeight) {
            possibleCollision = true;
        }

        if (possibleCollision) {
            // todo For now we return true
            return true;
        }

        return false; // No collision
}

现在,我们有两个方法,只需对所有合适对象组合调用它们即可。

执行检查

我们已经非常接近能够玩我们的游戏了,尽管碰撞检测被简化了。首先添加一些处理特定碰撞被检测到时会发生什么的方法,然后看看我们是如何实际使用我们的CD类的。

辅助方法

首先,我们需要一些辅助方法,以便在检测到各种类型的碰撞时做出响应。

我们需要一个在飞船被摧毁时调用的方法,以及一个在摧毁小行星时调用的方法。接下来的两个小节将介绍这些内容。

摧毁飞船

飞船的“死亡”可以在两个地方检测到,因此添加一个处理随后事件的方法是合理的。在下一个方法中,我们将飞船的位置重置为地图中心,播放声音,并减少numLives的值。

如果numLives等于零,将levelNumber重置为 1,numLives重置为 3,调用createObjects()重新绘制一个级别,暂停游戏,然后播放一个声音,让玩家知道他要重新开始。

现在,向AsteroidsRenderer类中添加lifeLost方法:

public void lifeLost(){
        // Reset the ship to the center
        gm.ship.setWorldLocation(gm.mapWidth/2, gm.mapHeight/2);
        // Play a sound
        sm.playSound("shipexplode");

        // Deduct a life
        gm.numLives = gm.numLives -1;

        if(gm.numLives == 0){
            gm.levelNumber = 1;
            gm.numLives = 3;
            createObjects();
            gm.switchPlayingStatus();
            sm.playSound("gameover");
        }
    }

我们将处理小行星“死亡”时会发生什么。

摧毁小行星

当飞船或子弹击中一个小行星时,将调用此方法。首先,我们将触发碰撞的小行星设置为setActive(false),它将不再被绘制或更新。

接下来,我们播放声音并减少numAsteroidsRemaining的值。如果numAsteroidsRemaining等于零,意味着玩家已经清除了整个关卡。在这种情况下,我们增加levelNumbernumLives,播放胜利的声音,并通过调用createObjects()开始一个更难的级别。

现在,向AsteroidsRenderer类中添加destroyAsteroid()方法:

public void destroyAsteroid(int asteroidIndex){

  gm.asteroids[asteroidIndex].setActive(false);
     // Play a sound
     sm.playSound("explode");
     // Reduce the number of active asteroids
     gm.numAsteroidsRemaining --;

     // Has the player cleared them all?
     if(gm.numAsteroidsRemaining == 0){
     // Play a victory sound

     // Increment the level number
     gm.levelNumber ++;

     // Extra life
     gm.numLives ++;

     sm.playSound("nextlevel");
     // Respawn everything
     // With more asteroids
     createObjects();

}
}
}// End class

现在,我们可以调用我们新的CD类的静态方法,并在检测到碰撞时做出响应。

update()中测试碰撞

首先,我们将检查是否需要限制飞船。我们只需使用mapWidthmapHeight和飞船的碰撞包调用CD.contain()。如果发生碰撞,代码将调用lifeLost()

update方法中更新所有对象后,添加碰撞检测代码:

// End of all updates!!

// All objects are in their new locations
// Start collision detection

// Check if the ship needs containing
if (CD.contain(gm.mapWidth, gm.mapHeight, gm.ship.cp)) {

  lifeLost();

}

这段代码用于检测是否有小行星试图离开小行星模拟器。除了我们遍历每个小行星,检查它是否处于活动状态,并在检测到碰撞时对小行星调用bounce方法外,它的工作原理与之前的代码块完全相同。

// Check if an asteroid needs containing
for (int i = 0; i < gm.numAsteroids; i++) {
  if (gm.asteroids[i].isActive()) {
       if (CD.contain(gm.mapWidth, gm.mapHeight, 
       gm.asteroids[i].cp)) {

          // Bounce the asteroid back into the game
          gm.asteroids[i].bounce();

          // Play a sound
          sm.playSound("blip");

       }
    }

}

子弹的代码看起来有点复杂,但其实不是。对CD.contain()的调用是相同的,我们对每颗子弹都这样做。但是,为了使子弹在离开视口(如果这发生在边界之前)时重置,需要进行一些最后的游戏平衡,否则飞船可以简单地旋转并从很远的距离摧毁小行星。

输入代码以检测子弹与边界和视口边缘的碰撞:

// Check if bullet needs containing
// But first see if the bullet is out of sight
// If it is reset it to make game harder
for (int i = 0; i < gm.numBullets; i++) {

    // Is the bullet in flight?
    if (gm.bullets[i].isInFlight()) {

   // Comment the next block to make the game easier!!!
   // It will allow the bullets to go all the way from
   // ship to border without being reset. 
   // These lines reset the bullet when
   // shortly after they leave the players view.
   // This forces the player to go 'hunting' for the
   // asteroids instead of spinning round spamming the
   // fire button...
   // This code would be better with a viewport.clip() method
   // like in project 2 but seems a bit excessive just for these
   // few 15ish lines of code.

   // Start comment out to make easier
   handyPointF = gm.bullets[i].getWorldLocation();
   handyPointF2 = gm.ship.getWorldLocation();

   if(handyPointF.x > handyPointF2.x + gm.metresToShowX / 2){
        // Reset the bullet
        gm.bullets[i].resetBullet(gm.ship.getWorldLocation());

    }else
        if(handyPointF.x < handyPointF2.x - gm.metresToShowX / 2){
            // Reset the bullet
            gm.bullets[i].resetBullet(gm.ship.getWorldLocation());

        }else
        if(handyPointF.y > handyPointF2.y + gm.metresToShowY/ 2){
            // Reset the bullet
            gm.bullets[i].resetBullet(gm.ship.getWorldLocation());
       }else
        if(handyPointF.y < handyPointF2.y - gm.metresToShowY / 2){
            // Reset the bullet
            gm.bullets[i].resetBullet(gm.ship.getWorldLocation());
                }
            // End comment out to make easier

            // Does bullet need containing?
            if (CD.contain(gm.mapWidth, gm.mapHeight,      
                gm.bullets[i].cp)) {

                 // Reset the bullet
                 gm.bullets[i].resetBullet
                    (gm.ship.getWorldLocation());
                 // Play a sound
                 sm.playSound("ricochet");
          }

     }

}

现在你可以运行游戏,看看CD.contain()方法是如何很好地保持所有物体在模拟小行星内的。

我们将调用detect方法,看看是否有任何东西撞上了小行星。

首先,检查子弹。注意我们进行初步检查,以确保子弹在飞行中,小行星处于活动状态,然后才会麻烦我们的CD.detect方法。然后,我们只需传入两个碰撞包,CD.detect完成其余工作。如果子弹与边界碰撞,我们会在相应的子弹上调用resetBullet()

// Now we see if anything has hit an asteroid

// Check collisions between asteroids and bullets
// Loop through each bullet and asteroid in turn

for (int bulletNum = 0; bulletNum < gm.numBullets; bulletNum++) {
    for (int asteroidNum = 0; asteroidNum < gm.numAsteroids;                            
        asteroidNum++) {

        // Check that the current bullet is in flight
        // and the current asteroid is 
        // active before proceeding
        if (gm.bullets[bulletNum].isInFlight() &&                                           
            gm.asteroids[asteroidNum].isActive())

            // Perform the collision checks by 
            // passing in the collision packages

            // A Bullet only has one vertex. 
            // Our collision detection works on vertex pairs

          if (CD.detect(gm.bullets[bulletNum].cp,                                           
              gm.asteroids[asteroidNum].cp)) {

                // If we get a hit...
                destroyAsteroid(asteroidNum);

                // Reset the bullet
                gm.bullets[bulletNum].resetBullet
                    (gm.ship.getWorldLocation());
           }

    }
}

现在,我们测试飞船。如果检测到碰撞,我们依次调用destroyAsteroid()lifeLost()

// Check collisions between asteroids and ship
// Loop through each asteroid in turn

for (int asteroidNum = 0; asteroidNum < gm.numAsteroids;                            
     asteroidNum++) {

    // Is the current asteroid active before proceeding
    if (gm.asteroids[asteroidNum].isActive()) {

        // Perform the collision checks by
        // passing in the collision packages
        if (CD.detect(gm.ship.cp, gm.asteroids[asteroidNum].cp)) {

        // hit!
        destroyAsteroid(asteroidNum);
        lifeLost();
       }
    }
}

在这一点上,你可以玩游戏,我们的基本碰撞检测将会起作用。但是,如果你飞得太接近小行星,你会在没有接触的情况下失去一条生命,或者只是在小行星附近发射一颗子弹,小行星就会消失。我们需要能够掠过边界或小行星的表面,并且只有在当一个点实际进入另一个物体的确切空间时才会发生碰撞。

精确的边界碰撞检测

为了升级我们的detect方法,我们需要用更精确的检测代码替换if(possibleCollision)块中的返回语句。

首先,初始化radianAngle为我们的物体面向的任意方向(以度为单位)的弧度等价。Math类使用弧度,因为它们在计算中比更容易视觉化的度数更有数学上的用途。

变量cosAnglesinAngle正如其名所示,并在接下来的代码块中使用。

提示

值得一提的是,Math.cos()Math.sin()方法相对耗时。我们可以通过预先计算sincos的 360 个值来加速碰撞检测类,然后使用简单的查找方法代替这个计算。

然而,我们要保持每秒 60 帧以上的目标,所以这里不要这样做。

删除返回语句,并在if(possibleCollision)块中添加以下代码:

if (possibleCollision) {

 double radianAngle = ((cp.facingAngle/180)*Math.PI);
 double cosAngle = Math.cos(radianAngle);
 double sinAngle = Math.sin(radianAngle);

在下一块代码中,输入一个for循环,遍历每个对象的顶点,将它们从模型空间转换到世界空间坐标,然后使用之前计算好的facingAngle对象的余弦和正弦值来旋转它们到游戏世界中的精确位置。

    //Rotate each and every vertex then check for a collision
    // If just one is then we have a collision.
    // Once we have a collision no need to check further
    for (int i = 0 ; i < cp.vertexListLength; i++){
        // First update the regular un-rotated model space coordinates
        // relative to the current world location (centre of object)
        float worldUnrotatedX = 
                cp.worldLocation.x + cp.vertexList[i].x;

        float worldUnrotatedY =  
                cp.worldLocation.y + cp.vertexList[i].y;

        // Now rotate the newly updated point, stored in currentPoint
        // around the centre point of the object (worldLocation)
        cp.currentPoint.x = cp.worldLocation.x + (int)                                   
            ((worldUnrotatedX - cp.worldLocation.x)
            * cosAngle - (worldUnrotatedY - cp.worldLocation.y)
            * sinAngle);

        cp.currentPoint.y = cp.worldLocation.y + (int)                                   
            ((worldUnrotatedX - cp.worldLocation.x)
            * sinAngle+(worldUnrotatedY - cp.worldLocation.y)
            * cosAngle);

现在我们要做的就是检查旋转和平移后的顶点是否在边界/地图的左侧、右侧、顶部或底部之外。如果是,我们返回true;如果不是,循环将继续以相同的方式检查每个顶点(平移、旋转、检查等)。

     // Check the rotated vertex for a collision
     if (cp.currentPoint.x < 0) {

       return true;
     } else if (cp.currentPoint.x > mapWidth) {

       return true;
     } else if (cp.currentPoint.y < 0) {

       return true;
     } else if (cp.currentPoint.y > mapHeight) {

       return true;
   }

}

你现在可以运行游戏,观看子弹带着令人满意的撞击声消失在边界内,或者驾驶你的飞船危险地接近边界。

让我们优化小行星碰撞的处理。

精确检测小行星的碰撞

我们之所以最后做这个,是因为有一个更复杂的最后步骤。与边界检测类似,我们需要转换和旋转物体的顶点。但这次,我们需要对两个物体都这样做。

此外,一旦我们旋转和平移了小行星的顶点,我们需要成对处理形成线的顶点。这些线是我们将要测试与其他物体每个顶点相交的线。这个测试当然是我们之前讨论过的交叉数方法。

我们需要在if (distance < cp1.radius + cp2.radius) { ...}的代码块内完成所有这些操作,之前我们只是将collided布尔值设置为true

代码量相当大,因此我们会将其分成几部分,并逐步了解每个阶段发生的情况。此外,为了尽可能使格式易于阅读,代码缩进在各个代码块之间可能不会始终保持一致。

接下来的几段代码就是前述if代码块的全部内容,需要替换。

提示

如前所述,这里我们也可以使用正弦和余弦的查找表。

我们可以创建一个方法来旋转角度,因为我们经常这样做。但这并不像看起来那么简单。如果我们把旋转代码放在一个方法中,我们要么不得不把下面的正弦和余弦计算也放进去,这将使它变慢;要么在方法调用和for循环之前预先计算,这本身也是一种不太整洁的做法。

另外,考虑到我们需要一个角度的正弦和余弦的多个值,该方法需要知道使用哪个值,这并不是火箭科学,但它的复杂度可能比我们最初想象的还要高。因此,我选择完全避免方法调用,即使代码看起来有些冗长。实际上,如果你把所有代码放在一个方法调用中,在旧款 Galaxy S2 手机上仍然可以得到接近 60 FPS 的帧率。所以如果你想要整理代码,请随意;我只是认为这种方式值得一谈。

在我们像边界检测一样进入for循环之前,我们会计算一些在此方法执行期间不会改变的东西。即两个碰撞包的面向角度的正弦和余弦。

     if (distance < cp1.radius + cp2.radius) {

            double radianAngle1 = ((cp1.facingAngle / 180) * Math.PI);
            double cosAngle1 = Math.cos(radianAngle1);
            double sinAngle1 = Math.sin(radianAngle1);

            double radianAngle2 = ((cp2.facingAngle / 180) * Math.PI);
            double cosAngle2 = Math.cos(radianAngle2);
            double sinAngle2 = Math.sin(radianAngle2);

            int numCrosses = 0;    // The number of times we cross a side

            float worldUnrotatedX;
            float worldUnrotatedY;

现在,我们从cp2遍历所有顶点,然后依次与cp1中的所有边(顶点对)进行测试。记住,小行星有一个额外的顶点填充,与第一个顶点相同。因此,我们可以测试小行星的最后一边。调用CD.detect()时,我们一定要将小行星碰撞包作为第二个参数传入。

在下一代码块中,将测试对象翻译并相对于小行星进行旋转。

for (int i = 0; i < cp1.vertexListLength; i++) {

    worldUnrotatedX = cp1.worldLocation.x + cp1.vertexList[i].x;
    worldUnrotatedY = cp1.worldLocation.y + cp1.vertexList[i].y;

    // Now rotate the newly updated point, stored in currentPoint
    // around the centre point of the object (worldLocation)
    cp1.currentPoint.x = cp1.worldLocation.x +
        (int) ((worldUnrotatedX - cp1.worldLocation.x)
        * cosAngle1 - (worldUnrotatedY - cp1.worldLocation.y) *
        sinAngle1);

    cp1.currentPoint.y = cp1.worldLocation.y + 
        (int) ((worldUnrotatedX - cp1.worldLocation.x)
        * sinAngle1 + (worldUnrotatedY - cp1.worldLocation.y) *                   
         cosAngle1);

    // cp1.currentPoint now hold the x/y 
    // world coordinates of the first point to test

现在,每次使用小行星的一对顶点,将它们翻译并旋转到它们最终的世界坐标空间,为下一代码块做准备,在那里我们将使用上一块和这一块计算出的顶点位置。

// Use two vertices at a time to represent the line we are testing
// We don't test the last vertex because we are testing pairs
// and the last vertex of cp2 is the padded extra vertex.
// It will form part of the last side when we test vertexList[5]

for (int j = 0; j < cp2.vertexListLength - 1; j++) {

    // Now we get the rotated coordinates of 
    // BOTH the current 2 points being
    // used to form a side from cp2 (the asteroid)
    // First we need to rotate the model-space 
    // coordinate we are testing
    // to its current world position
    // First update the regular un-rotated model space coordinates
    // relative to the current world location (centre of object)

    worldUnrotatedX = cp2.worldLocation.x + cp2.vertexList[j].x;
    worldUnrotatedY = cp2.worldLocation.y + cp2.vertexList[j].y;

    // Now rotate the newly updated point, stored in worldUnrotatedX/y
    // around the centre point of the object (worldLocation)

    cp2.currentPoint.x = cp2.worldLocation.x + 
          (int) ((worldUnrotatedX - cp2.worldLocation.x)
          * cosAngle2 - (worldUnrotatedY - cp2.worldLocation.y) *                   
          sinAngle2);

    cp2.currentPoint.y = cp2.worldLocation.y + 
          (int) ((worldUnrotatedX - cp2.worldLocation.x)
          * sinAngle2 + (worldUnrotatedY - cp2.worldLocation.y) *                   
          cosAngle2);

    // cp2.currentPoint now hold the x/y world coordinates
    // of the first point that
    // will represent a line from the asteroid

    // Now we can do exactly the same for the 
    // second vertex and store it in
    // currentPoint2\. We will then have a point and a line (two 
    // vertices)we can use the
    // crossing number algorithm on.

    worldUnrotatedX = cp2.worldLocation.x + cp2.vertexList[i + 1].x;
    worldUnrotatedY = cp2.worldLocation.y + cp2.vertexList[i + 1].y;

    // Now rotate the newly updated point, stored in worldUnrotatedX/Y
    // around the centre point of the object (worldLocation)
    cp2.currentPoint2.x = cp2.worldLocation.x + 
          (int) ((worldUnrotatedX - cp2.worldLocation.x)
          * cosAngle2 - (worldUnrotatedY - cp2.worldLocation.y) *                   
          sinAngle2);

    cp2.currentPoint2.y = cp2.worldLocation.y + 
          (int) ((worldUnrotatedX - cp2.worldLocation.x)
          * sinAngle2 + (worldUnrotatedY - cp2.worldLocation.y) *                   
           cosAngle2);

在这里,我们检测当前的顶点(无论是飞船还是子弹)是否穿过由小行星当前顶点对形成的线。如果穿过了,我们就增加numCrosses的计数。

// And now we can test the rotated point from cp1 against the
// rotated points which form a side from cp2

if (((cp2.currentPoint.y > cp1.currentPoint.y) !=                               
       (cp2.currentPoint2.y > cp1.currentPoint.y)) &&
       (cp1.currentPoint.x < (cp2.currentPoint2.x -                                
     cp2.currentPoint2.x)    *(cp1.currentPoint.y - 
        cp2.currentPoint.y) / (cp2.currentPoint2.y  -                               
  cp2.currentPoint.y) + cp2.currentPoint.x)){

        numCrosses++;

}

最后,我们使用模运算符来确定numCrosses是奇数还是偶数。如讨论所述,对于奇数我们返回true(碰撞),对于偶数返回false(无碰撞)。

            }
            }
            // So do we have a collision?
            if (numCrosses % 2 == 0) {
                // even number of crosses(outside asteroid)
                collided = false;
            } else {
                // odd number of crosses(inside asteroid)
                collided = true;
            }

        }// end if

现在你可以驾驶你的飞船直接飞向小行星,只有在真正看起来应该撞上的时候才会被击中。参考以下截图:

与行星精确碰撞检测

现在,我们的碰撞检测和小行星模拟器游戏都完成了!

完成收尾工作

我们可以继续改进我们的游戏。例如,当当前的小行星被摧毁时,生成两个或三个更小的行星并不困难。我们只需要一个数组来保存小行星。当我们停用常规小行星时,该数组会在与常规小行星相同的位置激活一些先前实例化的小型小行星。然后我们可以对计算小行星数量的方式进行一些小修改,这样我们就会有一个整洁的新功能。

街机经典游戏 Asteroids 中,会偶尔出现一个神秘的 UFO。设计一个由线条构成的 UFO 形状很简单,让它随机从左到右或从右到左移动,同时上下也有所移动。

最后,我们可以添加一个超空间按钮。这是玩家在确定即将死亡时的最后手段。轻触超空间按钮,飞船将在随机位置重新生成。我们只需要在InputController类中的数组里添加一个按钮,并在Ship类中调用一个新的简单方法randomHyperspaceJump

我们还可以添加谷歌游戏成就和排行榜,然后发布游戏。如果你发布一个使用 OpenGL 的游戏,你需要在AndroidManifest.xml文件中添加这个声明:

<uses-feature android:glEsVersion="0x00020000" android:required="true" />

尝试添加我们讨论过的一些改进,也许还有你自己的改进。无论你是否发布你的游戏,我都想听听你的想法,或者看到你在gamecodeschool.com上的项目链接。

我想我们已经完成了!

总结

我希望您享受了我们快速浏览的为 Android 制作游戏的旅程,并希望您继续制作更多的新游戏!

posted @ 2024-05-22 15:12  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报