安卓游戏编程示例-一-

87 阅读1小时+

安卓游戏编程示例(一)

原文: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 上处理,都能以相同的速度运行游戏。最重要的是,我们真正需要的,比这些更重要的,是一把大大的机枪。让我们构建一个经典平台游戏。