安卓游戏编程-全-
安卓游戏编程(全)
原文:
zh.annas-archive.org/md5/6d341a966772c1fc8e2055ef177ee0c6译者:飞龙
前言
Android 是增长最快的操作系统,Android 设备能够赋能、娱乐和教育整个地球。Android 使用最流行的编程语言之一——Java,这是一种高性能、安全且面向对象的编程语言。
它是最广泛使用的操作系统,而游戏则是人们在手机上花费最多时间使用的应用。制作和分发视频游戏从未如此简单。谁不想为 Android 开发游戏呢?而且,制作游戏既有趣、上瘾,又非常有成就感;一旦开始,就很难停下来。
当我们遇到障碍,不知道如何实现一个功能,或者将其集成到我们的游戏中时,问题就出现了。这门课程将帮助你从头开始使用 Android SDK 构建简单和复杂的游戏,你在此过程中遇到的所有疑问和障碍都将得到解决。
让我们开始吧!
本学习路径涵盖的内容
模块 1,通过构建 Android 游戏学习 Java,从 Java 基础知识及其在 Android 环境中的应用开始,制作一个简单的数学测验游戏。你将使用 Android Studio 中的易于使用的工具构建自己的 Android UI。你将学习如何使用 Java 线程添加实时交互,并在游戏中实现锁定/处理屏幕旋转、像素图形、点击、动画、音效以及许多其他功能。它还涵盖了构建和部署图形乒乓球风格游戏的高级面向对象概念。本模块以探索不同的 API 和使用 Google 游戏服务实现高级功能,如在线排行榜和成就结束。
模块 2,通过实例学习 Android 游戏编程,是对 Android 2D 游戏功能的一次快速浏览,尽可能地将这些功能压缩到 11 个章节中。构建三个难度递增的游戏所使用的每一行代码都在本模块的文本中展示,并以直接的方式解释。它逐步构建,以实现一个灵活且高级的游戏引擎,该引擎使用 OpenGL ES 2 以实现快速流畅的帧率。这是通过从一个简单的游戏开始,逐步增加三个完整游戏的复杂性来实现的。你将实现酷炫的功能,如精灵图集角色动画和滚动视差背景,并设计和实现真正具有挑战性和可玩性的平台游戏关卡。然后,你将学习编写基本的和高级的碰撞检测代码,并使二维旋转、速度和碰撞背后的数学简单化。稍后,你将学习如何以每秒 60 帧或更高的帧率运行你的游戏设计,并处理多点触控屏幕输入。在本模块结束时,你将实现许多其他游戏功能,如拾取物品、发射武器、HUD、生成和播放音效、场景、关卡过渡、高分等。
模块 3, 精通 Android 游戏开发,将帮助你从头开始使用 Android SDK 构建实时游戏。本模块将使你了解游戏引擎的内部结构和每个组件。你将学习在 Android 上决定何时使用不同的绘图方式。继续前进,你将学习如何处理用户输入,从虚拟摇杆到游戏手柄,使用不同的技术进行碰撞检测,并了解如何为复杂游戏进行优化。你还将了解动画和粒子系统,以及如何在你的游戏中实现它们,在你最终开始解决将其发布到 Google Play for Android TV 面临的挑战之前。
你需要的学习路径
任何最近和免费的 Eclipse 或 Android Studio 版本,在所有主要操作系统上运行,都可以使用本课程中的代码。
Android Studio 是推荐的开发工具,在出版时,最低系统要求如下:
对于 Windows 系统:
-
Microsoft Windows 8/7/Vista/2003 (32 或 64 位)
-
最小 GB RAM,推荐 4 GB RAM
-
至少 400 MB 硬盘空间
-
至少 1 GB 用于 Android SDK、模拟器系统镜像和缓存
-
最小屏幕分辨率 1280 x 800
-
Java 开发工具包 (JDK) 7
-
可选的加速模拟器:支持 Intel VTx、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 VTx、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 位应用程序) 上进行了测试。
这条学习路径适合谁
如果你完全对 Java、Android 或游戏编程中的任何一个都不熟悉,这本书适合你。如果你想为了娱乐或商业目的发布 Android 游戏,但不确定从何开始,那么这本书将逐步向你展示该做什么。
读者反馈
我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
要发送给我们一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书籍的来源。
-
点击代码下载。
您也可以通过点击 Packt Publishing 网站书籍网页上的代码文件按钮下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录您的 Packt 账户。
一旦文件下载完成,请确保您使用最新版本解压缩或提取文件夹:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Android-Game-Programming。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。请查看它们!
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过链接mailto:copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您对本书的任何方面有问题,您可以通过链接mailto:questions@packtpub.com与我们联系,我们将尽力解决问题。
第一部分 第 1 模块
通过构建 Android 游戏学习 Java
准备好通过为 Android 平台开发游戏来享受学习 Java 的乐趣
第一章:为什么选择 Java、Android 和游戏?
欢迎来到《通过构建 Android 游戏学习 Java》,我希望这只是你进入设计和编写游戏激动人心的旅程的开始。到这本书的结尾,我们将制作四个完整的游戏:一个难度动态增加的数学测验,一个类似经典 Simon 玩具的记忆游戏,一个乒乓球风格的壁球游戏,以及经典蛇游戏的克隆版。
除了这些游戏,我们还将构建十几个工作应用来练习和展示单个概念,以帮助我们学习 Java、Android 和游戏。我们的游戏和应用将包含音效、图形和动画。我们将从使用标准的 Android 用户界面(UI)设计器到通过绘制单个像素来创建平滑动画的一切内容。
虽然我会鼓励你和我一起工作,并实现书中详细分步说明的具体项目,但我完全期待一旦你掌握了不同的概念,你将希望立即在自己的独特创作中使用它们。这正是我希望你能受到的启发。
游戏项目本身不是本书的目标,而是达到更高目标的手段。到本书结束时,你将能够设计和实现自己的 2D Android 游戏,无论是为了出售还是免费赠送,都可以在 Google Play 上发布。
小贴士
首先需要做一些基础工作,但我保证这不会花费很长时间,也不会很复杂。任何人都可以学会编程。
然而,专家之间有如此多的不同意见,这会在初学者中产生关于学习编程最佳方法的困惑。因此,了解为什么学习 Java、Android 和游戏是初学者的理想途径是一个好主意。这是我们将在本书中首先讨论的内容。
在本章中,我们将学习以下内容:
-
这本书适合我吗?
-
我为什么要用游戏来学习编程?
-
为什么我要学习 Java 和 Android?
-
设置我们的开发环境
这本书适合我吗?
如果你已经决定要学习 Java、Android 或游戏,那么下一个问题可能就是,“这本书适合我吗?”。
对于初学者来说,有很多 Java 书籍,以及比我更有成就的作者和程序员的书籍。我读过很多,并且很钦佩这些作者。然而,当这些书籍偏离主题——它们都会这样做——涉及到诸如 Java 本地接口、网络浏览器小程序或服务器端远程通信等话题时,我有时会质疑它们对我直接的相关性。
在这个时候,至少在潜意识里,我的承诺会减弱,学习过程会放缓或停止。
如果你只想学习纯 Java
如果你只是想单独学习 Java,这本书将是一个坚实的起点。尽管 Android 的内容可能被认为是纯 Java 学习的额外开销,但这比任何其他 Java 书籍中可能引入的众多不必要的话题要少得多。这本书的唯一缺点是所有必要的开销都在开始时。但一旦这个最小开销被清除,我们就可以非常专注地学习 Java。
关于开销的量:
-
在本章中设置我们的编程环境需要大约六页
-
要熟悉 Android 工具、创建你的第一个工作项目并瞥见你的第一段真正的 Java 代码,需要第二章, 开始使用 Android
-
从那时起,它将几乎完全是 Java 和构建游戏
你很快就会看到,微量的开销并不算多,而且是非常值得的。
如果 Android 是你的重点
如果是 Android 本身让你看了这本书,那么我很自豪地说,这是第一本不假设你有任何先前的 Java 或编程知识的 Android 书籍。
这本书将带你去往何方
到这本书结束时,你将能够轻松地选择许多路径之一,包括以下这些:
-
在任何平台上以更高水平学习 Java
-
中级水平的 Android 学习,包括纯游戏框架(将在第九章[Chapter 9. Making Your Game the Next Big Thing]中更详细地介绍),制作你的游戏成为下一个大热门
-
更高级别的游戏开发
-
更容易地处理任何现代面向对象的语言,如 iOS、Windows 或 Web 开发
所以如果你知道你想要学习 Android 或 Java,希望我已经走了一些路来让你承诺这本书将如何帮助你。但为什么是游戏、Android 或 Java 呢?
为什么要通过制作游戏来学习编程?
趣味,当然!但还有其他原因。成功运行我们编写的任何程序都令人无比满足,尤其是当它涉及到使用一些我们之前不理解的一些代码时。
但正如你很快就会意识到的那样,自己制作游戏会带来一种难以描述的愉悦感——必须亲身体验。然后还有将我们的作品与朋友在手机或平板电脑上分享,甚至公开在 Google Play Store 上分享的额外好处,你可能会意识到一旦你开始制作游戏,你就无法停止。
随着我们持续创建更复杂的游戏,你会发现所有技术和代码片段都可以重新组合来创建其他游戏,然后你可以开始规划你自己的独特杰作。这至少是非常令人兴奋的。
就像许多学科一样,我们练习得越多,我们就越好。所以,游戏是开始学习编程 Java 的完美方式。然而,大多数 Android 游戏入门书籍都需要相当高的 Java 知识水平。但正如我们将看到的,完全有可能将实际示例作为有趣的游戏项目,并从 Java 的非常基础开始。
这种方法有一些微小的权衡。我们不会总是以“按部就班”的方式处理工作游戏示例。这是为了避免在掌握前进翻滚之前就翻跟头的风险。
学习成果的优先级始终是 Java 编程概念,其次是理解 Android 环境和游戏设计原则。话虽如此,我们将仔细检查和实践大量的 Android 和游戏编程基础。
当然,根据我们刚才讨论的内容,如果你我们没有制作游戏,那么在相同数量的页码中,我们可能已经能够教授更多的 Java 知识。
这一点是正确的,但这样一来,我们就失去了使用游戏作为主题所带来的所有好处。制作游戏确实可以是一种乐趣,当我们的头脑开放且渴望信息时,我们会学得更快。这种学习方式的最低成本被抵消了百倍。如果你对游戏完全不感兴趣,那么市面上有很多采用传统方法的 Java 入门指南。只是不要期待当你发布带有在线排行榜和成就的第一款游戏时会有同样的兴奋感。
为什么选择 Android 和 Java?
成功学习的一部分是学生的承诺,不仅是要完成工作,而且要相信他们正在以正确的方式做正确的事情。所以,许多基于技术的课程和书籍并没有从读者那里获得这种承诺,至少不是无意识的承诺。
问题在于学生们的信念,他们可能至少部分地认为,他们在浪费时间,因为某些东西可能很快就会过时,或者可能并不完全适合他们。这在编程方面在很大程度上是正确的。那么,为什么你应该花有限的时间学习 Java,特别是 Android 的 Java 呢?
Android 是发展最快、增长最快的操作系统
曾经,Android 的更新几乎每两个月就会推出一次。即便现在,它们大约每六个月更新一次。相比之下,Windows 版本之间的间隔需要数年,甚至 iOS 的更新也通常是每年一次,而且版本之间的变化相对较小。Android 显然正在以前所未有的速度发展和改进。
小贴士
查看自 Android 1.0 版本以来的 Android 版本历史,请访问www.cnet.com/news/history-of-android/。
Android 的第一个版本于 2008 年发布,当时消费者对当时更加炫目的 iPhone 已经相当兴奋。新闻故事也报道说,开发者通过在 iTunes 应用商店销售应用而变得富有。
但在撰写本书的最后一整年,三星独自销售的安卓设备数量超过了苹果销售的 iOS 设备总和。我并不参与关于哪个设备更好的战争。我享受安卓和苹果的各个方面,但纯粹从选择学习平台的角度来看,你可能是在正确的时间和地点选择了安卓。
安卓开发者有广阔的前景
现在你可能只是因为学习编程游戏带来的乐趣和满足感而选择了这本书。但如果你决定进一步发展你的学习,你会发现安卓程序员的需求数量巨大,因此也非常有利可图。
小贴士
一些数据显示,薪资超过 10 万美元。更多信息,请访问www.indeed.com/salary?q1=Android+Developer&l1=United+States。
安卓是开源的
开源的意思是,尽管谷歌开发了用于最新设备上的所有安卓版本,但一旦代码发布,任何人都可以随意使用它。谷歌只对代码进行有限时间的控制。
在实践中,大多数安卓用户使用的是纯谷歌操作系统,或者是由三星和 HTC 等大型制造商推出的修改版。但没有任何东西可以阻止任何人获取操作系统并对其进行更改、适应或将其转换为任何他们喜欢的东西。简而言之,安卓永远不会从编程社区中消失。
Java 将长期存在
好的,所以我们看到安卓不太可能消失,但 Java 是否会变得过时?而且你投入的大量时间是否会白费?在安卓平台上,就像大多数平台一样,你可以使用许多语言和工具。然而,安卓是从底层设计的,旨在促进 Java 开发。所有其他语言和工具都不是无效的,但往往只服务于相当具体的目的,而不是 Java 的真正替代品。实际上,就游戏而言,许多纯 Java 开发环境的替代品也是基于 Java 的,并且需要具备相当水平的 Java 技能才能使用。例如,流行的 LibGDX 游戏开发库,它允许你同时为安卓、iOS、Windows、Linux、Mac 甚至网络开发游戏,仍然使用 Java!我们将在第九章中更多讨论这一点,让你的游戏成为下一个大热门。重点是 Java 和安卓紧密相连,并且可能会共同繁荣。
Java 不仅仅是为安卓
Java 的历史比安卓悠久得多,实际上是从 20 世纪 90 年代初开始的。尽管 Java 的应用范围在超过二十年的时间里已经演变和多样化,但语言本身最初实现的优势今天仍然保持不变。
Java 被设计成平台或计算机无关。这是通过使用虚拟机(VM)来实现的。这是一个用另一种语言编写的程序,它解码我们编写的 Java 程序,并与它运行的计算机平台交互。所以只要有一个 VM 可以在你想要运行 Java 程序的计算机上运行,有一些限制,你的 Java 程序就会工作。所以如果你学习 Java,你就是在学习一种从智能冰箱到网络以及大多数中间位置都使用的语言。
然而,确实,每个平台上的虚拟机都可以并且通常确实实现了特定于其可能被用于的功能。一个明显的例子就是针对移动设备的功能,比如传感器、GPS 或者许多 Android 设备内置的摄像头。使用 Java 和 Android,你可以拍照、检测空气压力,并精确地计算出你在世界上的确切位置。大多数冰箱虚拟机可能不会这样做。所以你并不能总是只在设备 x 上运行为设备 y 设计的 Java 程序,但语言和语法是相同的。在 Android 上学习 Java 在很大程度上为你准备在任何情况下使用 Java。所以请放心,Java 不会很快消失。
Java 快速且易于使用
关于哪种语言是整体上最好的或者哪种语言是学习编程最好的,已经有数十年的争论。Java 的批评者可能会说关于 Java 速度的一些事情。确实,Java 的内存管理和虚拟机解释过程确实有一些速度成本。然而,这些事情也有好处;它们显著提高了我们的生产力,并且 Android 虚拟机与设备交互的方式在很大程度上抵消了轻微的速度惩罚。并且自从 Android 4.4 以来,它完全通过Android 运行时(ART)来实现,将用 Java 编写的应用程序安装为完全本机应用程序。现在 Java 程序员可以用一种友好的、解释型语言构建游戏,并且它们可以像用更具挑战性的本机编译语言编写的那样运行。
Java 和 Android 的总结
在一个快速变化的世界里,如果你担心在哪里投资你宝贵的学习时间,很难更有信心。这里我们有一个语言(Java),其基础几乎保持了近四分之一世纪不变,有一个平台(Android),它得到了硬件、软件和零售界最大公司的支持,尽管它确实受到了巨大的影响,但实际上并不属于任何人。
我并不是任何一种技术的传教士,尽管确实我喜欢在 Android 上做事情。但你可以确信,如果你正在考虑学习编程的最佳路径,有一个非常有力的论据认为 Java 和 Android 是最好的选择。
如果你想要学习 Java 以用于其众多用途中的任何一个,那么这是一个非常好的开始地方。如果你想要为 Android 开发或者进入任何形式的 Android 开发,那么 Java 是绝对的基础入门方式,制作游戏也有我们已讨论过的巨大好处。
到了本书的结尾,你将能够为几乎任何 Java 支持的平台编写 Java 代码。你将能够使用本书中学到的几乎所有内容,即使是在 Android 环境之外。
如果你计划通过制作 Android 游戏或任何 Android 应用来追求职业或商业,那么这本书可能是初学者开始的地方。
如果你完全对 Java 一无所知,并且想要掌握它的最简单路径——地球上增长最快的平台——那么《通过构建 Android 游戏学习 Java》可能正是你所需要的。
因此,希望你能确信这本书将带你学习 Java 的路径将和 Java 学习本身一样简单、有趣且全面。让我们开始设置,以便我们可以开始构建游戏。
设置我们的开发环境
我们需要做的第一件事是为使用 Java 开发 Android 准备我们的 PC。幸运的是,这对我们来说变得相当简单。
小贴士
如果你正在 Mac 或 Linux 上学习,这本书中的所有内容都将适用。接下来的两个教程将包含针对 Windows 的特定说明和屏幕截图。然而,调整步骤以适应 Mac 或 Linux 应该不会太难。
我们需要做的只是:
-
安装一个名为Java 开发工具包(JDK)的软件包,它允许我们在 Java 中进行开发。
-
安装 Android Studio,这是一个旨在使 Android 开发快速且简单的程序。Android Studio 使用 JDK 以及一些其他在安装 Android Studio 时自动安装的特定于 Android 的工具。
安装 JDK
我们需要做的第一件事是获取 JDK 的最新版本。为了完成本指南,请执行以下步骤:
-
你需要访问 Java 网站,因此请访问
www.oracle.com/technetwork/java/javase/downloads/index.html。 -
在以下屏幕截图中所显示的三个按钮中找到并点击标有JDK(高亮显示)的按钮。它们位于网页的右侧。在JDK选项下点击下载按钮:
![安装 JDK]()
-
你将被带到具有多个下载 JDK 选项的页面。在产品/文件描述列中,你需要点击与你的操作系统匹配的选项。Windows、Mac、Linux 以及一些其他不太常见的选项都列出来了。
-
这里一个常见的问题是,“我是否有 32 位或 64 位的 Windows?”要找出答案,请右键单击你的我的电脑(Windows 8 上的此电脑)图标,点击属性选项,然后在系统类型条目下的系统**标题下查看,如下面的屏幕截图所示:
![安装 JDK]()
-
点击稍显隐藏的接受许可协议复选框:
![安装 JDK]()
-
现在点击您之前确定的操作系统和系统类型的下载选项。等待下载完成。
-
在您的
下载文件夹中,双击您刚刚下载的文件。截至撰写本文时,64 位 Windows PC 的最新版本是jdk-8u5-windows-x64。如果您使用 Mac/Linux 或 32 位操作系统,您的文件名将相应变化。 -
在几个安装对话框中的第一个,点击下一步按钮,您将看到下一个对话框:
![安装 JDK]()
-
通过点击下一步接受之前屏幕截图显示的默认设置。在下一个对话框中,您可以通过点击下一步接受默认的安装位置。
-
接下来是 Java 安装程序的最后一个对话框。点击关闭。
JDK 现在已安装在您的 PC 上。接下来我们将确保 Android Studio 能够使用 JDK。
-
右键单击您的我的电脑(Windows 8 上的“此电脑”)图标,导航到属性 | 高级系统设置 | 环境变量 | 新建(在系统变量下,不在用户变量下)。现在您可以看到如下所示的新建系统变量对话框:
![安装 JDK]()
-
在变量名处输入
JAVA_HOME,并在变量值字段中输入C:\Program Files\Java\jdk1.8.0_05。如果您在其他位置安装了 JDK,那么在变量值字段中输入的文件路径需要指向您放置它的位置。您的确切文件路径可能以不同的结尾结束,以匹配您下载时的 Java 最新版本。 -
点击确定保存您的新设置。现在再次点击确定以清除高级系统设置对话框。
现在我们已经在我们的 PC 上安装了 JDK。我们距离开始学习 Java 编程已经走了一半的路,但我们需要一个友好的方式与 JDK 交互,并帮助我们用 Java 制作 Android 游戏。
Android Studio
我们了解到 Android Studio 是一个简化 Android 开发的工具,并使用 JDK 允许我们编写和构建 Java 程序。您可以使用其他工具代替 Android Studio。它们各有优缺点。例如,另一个极其流行的选择是 Eclipse。就像编程中的许多事情一样,可以强烈论证为什么您应该使用 Eclipse 而不是 Android Studio。我两者都使用,但我希望您会喜欢 Android Studio 的以下元素:
-
它是一个非常整洁的界面,尽管仍在开发中,但非常精致和干净。
-
与 Eclipse 相比,开始使用它要容易得多,因为几个原本需要单独安装的 Android 工具已经包含在包中。
-
Android Studio 由 Google 开发,基于另一个名为 IntelliJ IDEA 的产品。它有可能在未来不久成为开发 Android 的标准方式。
小贴士
如果你想要使用 Eclipse,那也行;这本书中的所有代码都将正常工作。然而,一些快捷键和用户界面按钮显然会有所不同。如果你还没有安装 Eclipse,并且没有 Eclipse 的先验经验,那么我更强烈地建议你继续使用 Android Studio。
安装 Android Studio
因此,我们毫不拖延地开始安装 Android Studio,然后我们可以开始我们的第一个游戏项目。为此,让我们访问developer.android.com/sdk/installing/studio.html。
-
点击标有下载 Android Studio的按钮开始下载 Android Studio。这将带你去另一个看起来非常相似的网页,上面有一个你刚刚点击过的按钮。
-
通过勾选复选框接受许可,通过点击标有下载 Android Studio for Windows的按钮开始下载,并等待下载完成。按钮上的确切文本可能会根据当前最新版本而有所不同。
-
在你刚刚下载 Android Studio 的文件夹中,右键单击
android-studio-bundle-135.12465-windows.exe文件,然后点击以管理员身份运行。文件名末尾将根据 Android Studio 的版本和你的操作系统而有所不同。 -
当询问你是否想要允许来自未知发布者的以下程序更改你的计算机时,请点击是。在下一个屏幕上,点击下一步。
-
在以下屏幕截图显示的屏幕上,你可以选择你的 PC 上哪些用户可以使用 Android Studio。选择适合你的选项,因为所有选项都会正常工作,然后点击下一步:
![安装 Android Studio]()
-
在下一个对话框中,保留默认设置,然后点击下一步。
-
然后在选择开始菜单文件夹对话框中,保留默认设置并点击安装。
-
在安装完成对话框中,点击完成以首次运行 Android Studio。
-
下一个对话框是为已经使用过 Android Studio 的用户准备的,所以假设你是第一次使用,请选择我没有之前的 Android Studio 版本或我不想导入我的设置复选框,然后点击确定:
![安装 Android Studio]()
那是我们需要的最后一个软件组件。我们刚刚经历的简单九步流程实际上已经设置了一系列我们将从下一章开始使用的 Android 工具。
摘要
我们讨论了为什么游戏、Java 和 Android 不仅非常令人兴奋,而且可以说是学习编程的最佳方式。这是因为游戏可以是一个非常激励人心的主题,Java 和 Android 在流行度和持久性方面具有巨大优势,并且对我们所有人都是免费开放的。
我们还设置了 Java 开发工具包并安装了 Android Studio,为下一章做准备,在下一章中,我们将实际创建游戏的一部分,并首次查看一些 Java 代码。
第二章. Android 入门
在本章中,我们将通过所有你需要学习的 Android 主题进行一次过山车之旅,以便开始使用 Java。但这不仅仅只是理论。我们将设计一个游戏菜单的用户界面(UI),我们还将查看和编辑我们的第一段 Java 代码。
此外,我们还将看到我们如何在 PC/Mac 上的 Android 模拟器或如果我们有的话,在真实的 Android 设备上运行我们的应用。
本章中我们将涵盖的一些内容只是冰山一角。也就是说,对于我们在讨论的一些主题,表面之下还有更多内容,这比适合 Java 学习书籍的第二章更为合适。有时,我们可能需要基于信仰接受一些信息。
这将使我们能够在本章结束时真正设计和运行我们自己的 Android 应用。然后我们可以在下一章的开始真正开始学习 Java。
如果这一章看起来有点难,那么不要担心;继续前进,因为随后的每一章都会从一些不太清楚的主题中揭开更多内容。
对于本章以及接下来的两章,我们将构建一个数学游戏。我们将从简单开始,到第四章的“发现循环和方法”结束时,我们将扩展到使用显著 Java 技能的游戏功能。
在本章中,我们将:
-
开始我们的第一个游戏项目
-
探索 Android Studio
-
使用 Android Studio 的视觉设计器来制作我们的游戏 UI
-
学习如何为 Android 构建我们的代码结构
-
首次查看一些 Java 代码
-
在模拟器和真实设备上构建和安装我们的游戏
我们的第一个游戏项目
现在,我们将直接使用 Android Studio 进行实际操作。通过双击桌面上的 Android Studio 图标或安装它的文件夹中的图标来运行 Android Studio。
注意
如果你在一个提到权限提升的对话框中遇到任何错误,那么尝试以管理员权限运行 Android Studio。为此,通过点击 Windows 开始按钮并搜索Android Studio来找到 Android Studio 图标。现在右键单击图标并点击以管理员身份运行。每次运行 Android Studio 时都这样做。
准备 Android Studio
因此,在安装了 Android Studio 和 Java 之后,我们只需要添加我们将要使用的最新版本的 Android API,以便制作我们的第一个游戏。以下是安装 API 的步骤:
-
从 Android Studio UI 顶部的菜单栏中,导航到 工具 | Android | SDK 管理器。在 Android SDK 管理器 窗口中向下滚动并选择 Android 4.4.2 (API 19) 的复选框。
注意
注意,由于 Android 发展非常迅速,当你阅读这一章节时,可能会有比 19 更新的 API,比如 20、21 等等。如果你遇到这种情况,请选择更新的(编号更高的)API。
![准备 Android Studio]()
-
点击 安装包。
-
在下一屏幕上,点击 接受许可 复选框,然后点击 安装 按钮。Android Studio 将下载并安装适当的包。
我们刚才所做的就是设置 Android Studio,使其可用最新的、预先编写的代码,称为 API,我们将在整本书中与之交互。
构建项目
-
点击如下截图所示:
![创建新项目...]()
-
将会弹出 创建新项目 配置窗口。在 应用程序名称 字段中填写
Math Game Chapter 2,在 公司域名 中填写packtpub.com(或者你也可以在这里使用你自己的公司网站名称),如下面的截图所示:![构建项目]()
-
现在点击 下一步 按钮。在下一屏幕上,确认 手机和平板 复选框已被勾选。现在我们必须选择我们想要为构建我们的应用程序的最早版本的 Android。继续在下拉选择器中尝试几个选项。你会看到,我们选择的版本越早,我们的应用程序可以支持的设备百分比就越大。然而,这里的权衡是,我们选择的版本越早,我们应用程序中可用的最新 Android 功能就越少。一个好的平衡点是选择 API 8: Android 2.2 (Froyo)。现在就按照下面的截图所示进行操作:
![构建项目]()
-
点击 下一步。现在选择 空白活动,如下一截图所示,然后再次点击 下一步:
![构建项目]()
-
在下一屏幕上,只需将 活动名称 改为
MainActivity,然后点击 完成。提示
默认情况下,每次 Android Studio 启动时都会显示一个 每日提示 对话框。有些提示在你学习 Java 的过程中可能没有意义,但其中许多都非常实用,揭示了出色的快捷键和其他节省时间的技巧。当它们出现时,花几秒钟阅读它们是非常值得的。如前所述,Android Studio 是基于 IntelliJ IDEA 构建的,你可以在
www.jetbrains.com/idea/webhelp/keyboard-shortcuts-you-cannot-miss.html找到完整的键盘快捷键列表。 -
点击关闭按钮清除每日提示。
如果你完全对编程一无所知,那么代码、选项和文件可能看起来有些令人畏惧。别担心;坚持学习,因为我们不需要关注它们中的大多数来学习 Java。当需要与更详细的细节交互时,我们会一步一步地进行。
可能很难相信,但我们已经创建了我们第一个工作的应用程序。我们可以在 Android 设备上构建和运行它,很快我们就会做到。
在我们继续进行游戏开发之前,让我们更深入地了解一下 Android Studio。
探索 Android Studio
Android Studio 是一个非常强大的工具,但为了开始学习,我们只需要一次学习一个部分。对我们来说,命名 UI 的几个部分可能很有用,这样我们就可以在阅读本书的过程中轻松地引用它们。
看一下这个编号图和一些关于 Android Studio 关键部分的快速解释。如果你能的话,尝试记住这些部分,以便在未来的讨论中更容易理解。

这里有一个方便的表格,你可以快速参考并记住我们正在引用的 Android Studio 的哪个部分。以下是对每个区域的更详细解释。
| 编号 | 名称 |
|---|---|
| 1 | 项目资源管理器 |
| 2 | 编辑器 |
| 3 | 菜单栏 |
| 4 | 工具栏 |
| 5 | 导航栏 |
| 6 | 重要工具窗口 |
-
项目资源管理器(1):在屏幕截图中显示为1,它有点像 Windows 资源管理器。它显示了为我们的项目生成的所有文件和文件夹。随着本书的继续,我们将从这里做很多事情。实际上,如果你深入研究 Android Studio 创建的文件和文件夹,项目资源管理器并不是一个精确的映射。它被稍微简化并突出显示,以便更容易管理和探索我们的项目。
-
编辑器(2):正如其名所示,我们将在这里的编辑器中编辑我们的 Java 代码文件。然而,正如我们很快就会看到的,编辑器窗口会根据我们正在编辑的文件类型而改变。我们还将在这里查看和编辑 UI 设计。
-
菜单栏(3):像大多数程序一样,菜单栏为我们提供了访问 Android Studio 完整功能的方法。
-
工具栏(4):它包含许多非常有用的单点选项,例如部署和调试我们的游戏。将鼠标光标悬停在图标上,以获取弹出提示并深入了解每个工具栏图标。
-
导航栏(5):就像文件路径一样,它显示了当前在编辑器中的文件在项目中的确切位置。
-
重要工具窗口(6):这些是一些可以通过点击弹出和关闭的标签页。如果你愿意,现在尝试一些,看看它们是如何工作的。
让我们再详细谈谈 Android Studio UI 的各个部分以及编辑窗口如何将自己转换成视觉 UI 设计器。之后,当我们足够熟悉时,我们将看看如何为我们的数学游戏构建一个简单的菜单屏幕。
使用 Android Studio 视觉设计师
Android Studio 编辑窗口是一个非常动态的区域。它以最有用的方式呈现不同的文件类型。在我们创建项目的一小段时间之前,它还为我们创建了一个基本的 UI。Android 中的 UI 可以通过 Java 代码构建,或者,正如我们将看到的,在视觉设计师中构建,无需一行 Java 代码。然而,在我们构建游戏菜单的 UI 之后,为了使 UI 做出有用的操作,我们需要与之交互。这种交互始终是通过 Java 代码完成的。视觉设计师也会为我们生成 UI 代码。我们也会很快地看看这一点。
随着本书的进展,我们将主要避免 Android UI 开发,因为那通常是更多非游戏应用的基础。相反,我们将花更多的时间直接绘制像素和图像来制作我们的游戏。尽管如此,常规的 Android UI 仍然有其用途,Android Studio 视觉设计师是快速入门的最佳方式。
现在让我们看看这个:
-
在 Android Studio 项目资源管理器中,双击
layout文件夹以显示其中的activity_main.xml文件。除非您已折叠了目录,否则这应该很容易看到。如果您看不到layout文件夹,请使用项目资源管理器导航到它。您可以通过 Android Studio 项目资源管理器找到它,如以下截图所示:![使用 Android Studio 视觉设计师]()
-
现在双击 activity_main.xml 以在编辑窗口中打开它。经过一段简短的加载时间后,您将看到与下一张截图非常相似的内容。下面的截图显示了之前仅包含我们代码的全部内容。如您所见,之前只是一个文本窗口,现在有多个部分。让我们仔细看看这个截图:
![使用 Android Studio 视觉设计师]()
在之前标记为(1)的截图,称为 调色板,您可以从可用的 Android UI 元素中选择,并简单地点击并拖动它们到您的 UI 设计中。区域(2)是您正在构建的 UI 的视觉视图,您将在这里点击并拖动来自调色板中的元素。在视觉 UI 视图的右侧,您将看到 组件树 区域(3)。组件树允许您检查复杂 UI 的结构,并更轻松地选择特定元素。在此树下方是 属性 面板(4)。在这里,您可以调整当前所选 UI 元素的属性。这些可以是简单的事情,如颜色和大小,或者更高级的属性。
注意
注意标签上标记的(5)。这些标签允许你在 Android Studio 为这种布局文件提供的两个主要视图之间切换。正如你所看到的,这些视图是设计和文本。设计视图是默认视图,如前一个屏幕截图所示。文本视图也显示了你的正在构建的 UI,但它显示的是为我们自动生成的代码,而不是调色板元素和组件树。
我们不需要担心这段代码,因为它都是为我们处理的。偶尔查看这个标签卡是有好处的,这样我们可以开始理解设计工具为我们生成的内容。但是,学习 Java 并不需要这样做。这段代码被称为可扩展标记语言(XML)。
-
快速查看一下文本标签,完成后点击设计标签,然后我们将继续前进。
现在我们已经看到了视觉设计器的概述以及它为我们自动生成的代码的简要一瞥。我们可以更仔细地查看我们将在项目中使用的实际 UI 元素。
Android UI 类型
现在,我们将快速浏览一些非常有用的 Android UI 元素、一些关键属性以及如何将它们组合起来以创建一个 UI。这将向我们介绍一些可能性以及如何使用它们。然后,我们将快速运用我们所知来制作我们的菜单。
TextView
在视觉 UI 区域,点击文字Hello world!我们刚才选择的是一个名为 TextView 的小部件。TextView 可以是像这样的小文本,也可以是大型标题类型文本,这在我们的游戏菜单中可能很有用。
让我们尝试将另一个 TextView 拖放到我们的视觉 UI 上:
-
在我们的调色板中的小部件标题下,你可以看到有多种类型的 TextView。它们在调色板中以普通 TextView、大文本、中文字体和小字体的形式呈现。将一个大文本小部件拖放到我们的视觉设计中。不要立即放手。当你将它拖动到手机图像周围时,注意 Android Studio 如何图形化地显示不同的定位选项。在下面的屏幕截图中,你可以看到当被拖动的小部件定位在中心时的设计师外观:
![TextView]()
-
在你想要放置小部件的位置释放鼠标左键。如果你在如图所示的定位时释放,文本将如预期地出现在中心位置。
-
现在我们可以玩弄属性。在属性窗口中,点击textSize的右边。你可能需要滚动以找到它。将值设置为
100sp并按Enter键。注意文本变得很大。我们可以通过增加和减少在此处输入的值来细化文本的大小。单位sp代表缩放像素,它是一个试图在不同屏幕密度上缩放文本到适当等效实际大小的测量系统。![TextView]()
-
如果你喜欢,可以再玩一些属性,完成后,点击在视觉设计器中刚刚创建的 TextView 来突出显示它。然后点击删除键来移除它。现在删除我们开始时存在的 TextView——那个写着Hello world的 TextView。
布局元素
现在你可能看起来有一个空白的屏幕。然而,如果你在设计预览的任何地方点击,你会看到我们仍然在属性窗口中有一些选项。这个元素被称为 RelativeLayout。它是提供作为基础以控制和对齐布局小部件(如按钮、文本等)的几种布局元素类型之一。如果你查看调色板窗口的顶部,你会看到主要的布局选项。当我们实际构建游戏菜单时,我们将使用这个布局元素。
ImageView 小部件
ImageViews 不出所料是用来显示图片的。在标准的 Android UI 中,这是向我们的游戏中添加设计师的艺术作品的一个非常快速的方法:
-
以与刚才定位 TextView 相同的方式,将ImageView元素拖放到设计中。ImageView元素可以在小部件标题下找到。现在像之前一样将其居中,或者通过拖动它在设计中玩弄选项。我们将在一分钟内删除它;我们只是在真正做之前进行一点探索。
-
在属性窗口中,以与之前选择textSize属性相同的方式选择src属性。
-
注意,选择它之后,你可以点击...来获得更多选项。点击...并滚动到选项列表的底部。这些都是我们可以在 ImageView 中显示的图片文件。为了好玩,滚动到列表底部,选择ic_launcher,然后点击确定。我们可以使任何图片都可用,这是一种简单而强大的方法来构建一个吸引人的游戏菜单屏幕。
-
将layout:width属性更改为
150dp,将layout:height属性更改为150dp。单位dp是一种在具有非常不同像素数的屏幕上保持相对恒定的元素和小部件尺寸的方法。 -
以与之前删除其他视图相同的方式删除 ImageView。
ButtonView
ButtonView 的用途可能从其名称中可以看出。尝试将几个按钮拖放到我们的布局中。注意,有几种类型的 ButtonView,例如小按钮、按钮,如果您向下查看小部件列表,还有ImageButton。我们将使用常规的 ButtonView,标记为按钮。
现在,我们将使用这些 Android UI 元素中的每一个来制作我们的游戏菜单。
注意
您可以从本书配套网站的代码下载部分下载整个示例。
使用示例代码
本书中的所有代码都组织在项目中。如果一个项目跨越多个章节,每个章节都会提供一个完成状态的项目。这有助于您看到进展,而不仅仅是最终结果。要打开 Android Studio 中的项目,只需按照以下说明操作:
-
下载本书的代码。
-
在 Android Studio 的菜单栏中,导航到 文件 | 关闭项目。
-
现在创建一个新的空白项目,就像我们之前做的那样。浏览到您下载本书代码的位置。
-
导航到
Chapter2文件夹。在这里,您可以找到我们在这个章节中创建的所有文件的代码。 -
使用纯文本编辑器,如免费的 Notepad++ 打开代码文件。
-
在您的 Android Studio 项目中复制并粘贴,或者就像您看到的那样比较代码。
小贴士
虽然本书中需要的所有代码都已提供以便您的方便,但您仍然需要通过 Android Studio 自己创建每个项目。然后,您可以简单地复制并粘贴整个文件中的代码,或者只复制您可能遇到困难的代码部分。请记住,如果您创建了一个具有不同包名的项目,那么您必须从提供的代码文件中删除包含包名的代码行。当我们在本章后面更多关于包的讨论中了解更多时,原因将更加清晰。
让我们实际看看如何自己完成所有这些操作。
制作我们的游戏菜单
目前,我们只是使我们的游戏菜单具有功能性。在第五章中,我们将看到如何通过添加一些酷炫的动画来使菜单看起来更好,从而使菜单更具视觉吸引力和趣味性。
在本教程中,我们的目标是:

在开始编码之前,您应该在纸上首先设计您的布局。然而,Android Studio 的设计师非常友好,特别是对于简单的布局,可以在布局设计师中实际细化设计。执行以下步骤以创建游戏菜单:
-
通过逐个点击并按顺序在每个上按删除键,从您的设计师中删除所有小部件。请注意不要删除RelativeLayout布局元素,因为我们打算将其用作所有其他元素的基础。
-
点击并拖动一个大文本元素到设计区域的顶部中央,并给它以下属性。记住,你可以通过点击要更改的属性右侧来在属性面板中更改属性。将文本属性更改为
我的数学游戏,将大小更改为30sp。 -
从调色板中点击并拖动一个ImageView元素到设计的中心,略低于上一个 TextView。将layout:width属性更改为
150dp,将layout:height属性更改为150dp。 -
现在点击并拖动三个按钮用于玩、高分和退出。将它们垂直居中,位于上一个 ImageView 下方,一个接一个,如之前的设计所示。
-
点击顶部的按钮,配置文本属性,并输入值
玩。 -
点击中间的按钮,配置文本属性,并输入值
高分。 -
点击最底部的按钮,配置文本属性,并输入值
退出。 -
由于按钮之间包含的文本量相对不同,它们的大小将略有差异。你可以通过点击并拖动较小按钮的边缘来匹配较大按钮,使它们大小一致,以匹配预期的布局。这通常与你在 Windows 中调整应用程序窗口大小的方式相同。
-
使用Ctrl + S或通过导航到文件 | 全部保存来保存项目。
小贴士
如果你打算在比设计师中显示的 Nexus 4 大得多或小得多的屏幕上测试你的游戏,那么你可能希望调整本教程中使用的
sp和dp单位的值。对多设备上的 Android UI 的全面讨论超出了本书的范围,并且对于制作本书中的任何游戏都不是必要的。如果你想立即开始为不同屏幕设计,请查看
developer.android.com/training/multiscreen/index.html。
你可以通过从以下截图所示的下拉菜单中选择设备来查看你的菜单在其他设备上的外观:

在我们让我们的菜单在实际设备上生动起来之前,让我们看看 Android 应用程序的结构以及我们如何在编写 Java 代码时使用该结构。
为 Android 结构化我们的代码
如果你曾经使用过 Android 设备,你可能已经注意到它与其他许多操作系统的工作方式相当不同。例如,你正在使用一个应用程序——比如说你在检查人们在 Facebook 上做什么。然后你收到一封电子邮件通知,你点击电子邮件图标来阅读它。在阅读电子邮件的过程中,你可能会收到一条 Twitter 通知,因为你正在等待你关注的某人的重要新闻,你中断了你的电子邮件阅读,并通过触摸将应用程序切换到 Twitter。
在阅读完推文后,你突然想玩一下愤怒的小鸟,但在第一次大胆尝试中途,你突然想起了 Facebook 上的帖子。所以你退出愤怒的小鸟,点击 Facebook 图标。
然后你重新打开 Facebook,可能是在你离开的那个地方。你可能会继续阅读电子邮件,决定回复推文,或者启动一个全新的应用程序。所有这些来回操作都需要操作系统进行大量的管理,看起来与单个应用程序本身是独立的。
在我们刚刚讨论的上下文中,Windows PC 和 Android 之间的区别在于,虽然用户决定使用哪个应用程序,但 Android 操作系统决定何时以及是否关闭(销毁)应用程序。我们在编写游戏代码时只需考虑这一点即可。
生命周期阶段——我们需要了解的内容
Android 系统有不同的阶段,任何应用程序都可以处于这些阶段之一。根据阶段,Android 系统确定应用程序如何被用户查看,或者是否被查看。Android 有这些阶段,以便它可以决定哪个应用程序正在使用,然后分配正确的资源,如内存和处理能力。但同时也允许我们作为游戏开发者与这些阶段交互。如果有人退出我们的游戏去接电话,他们会丢失进度吗?
Android 有一个相当复杂的系统,如果为了解释的目的稍微简化一下,可以确保 Android 设备上的每个应用程序都处于以下阶段之一:
-
正在创建
-
启动
-
恢复
-
运行
-
暂停
-
停止
-
被销毁
希望这个阶段的列表看起来相当合理。例如,用户按下 Facebook 应用程序图标,应用程序就被创建了。然后它被启动。到目前为止,这些都是相当直接的,但列表中的下一个是恢复!如果我们暂时接受应用程序在启动后恢复,那么随着我们继续前进,一切都将变得清晰。
在恢复之后,应用程序是运行的。这是 Facebook 应用程序控制屏幕并可能占用系统内存和处理器能力更大的时候。那么我们之前从 Facebook 应用程序切换到电子邮件应用程序的例子呢?
当我们轻触以阅读电子邮件时,Facebook 应用程序可能会进入暂停阶段,电子邮件应用程序将进入正在创建阶段,然后是恢复,接着是运行。如果我们决定重新访问 Facebook,就像之前的场景一样,Facebook 应用程序可能会直接进入恢复阶段,然后再次运行,很可能是我们离开的地方。
注意,在任何时候,Android 都可以决定停止或销毁一个应用,在这种情况下,当我们再次运行该应用时,它需要从头开始创建。所以,如果 Facebook 应用长时间处于不活跃状态,或者愤怒的小鸟需要消耗如此多的系统资源以至于 Android 会销毁Facebook 应用,那么我们找到之前阅读的确切帖子的体验可能会有所不同。
现在,如果所有这些阶段的东西开始变得令人困惑,那么您会很高兴地知道,提及的唯一原因如下:
-
您知道它的存在
-
我们偶尔需要与之交互
-
我们将逐步进行
生命周期阶段 – 我们需要做什么
当我们制作游戏时,我们如何与这种复杂性交互呢?好消息是,当我们创建第一个项目时自动生成的 Android 代码为我们做了大部分交互。
作为游戏开发者,我们唯一需要做的就是确保 Android 在发生时知道如何处理我们的应用在每个阶段。更有好消息是,所有这些阶段都默认处理,除非我们覆盖默认处理。
这意味着我们可以继续学习 Java 和制作游戏,直到我们遇到需要在我们游戏中做某事的少数几个实例,具体来说是在某个阶段。
将我们的游戏划分为活动
我们编写的 Java 代码将被划分为称为活动的部分或部分。我们可以将活动视为我们游戏的不同屏幕。例如,在书本中,我们通常会创建一个主屏幕活动、一个游戏屏幕活动和一个高分屏幕活动。
每个活动都将有自己的生命周期,并将进一步划分为与(进入)我们刚才讨论的 Android 阶段相对应的部分。Java 中的这些部分被称为方法。方法是 Java 编程中的一个重要概念。
然而,在这个阶段,我们只需要知道方法是用来隔离我们编写的 Java 代码的,并且 Android 系统提供了一些方法,以便我们能够轻松地处理其他情况下复杂的 Android 生命周期。
以下列表是 Android 为我们提供的方便方法的一个快速说明,用于管理生命周期的阶段。为了澄清我们对生命周期阶段的讨论,方法被列在其对应的阶段旁边。然而,正如您将看到的,方法名称本身就很清楚地说明了它们的位置。
在列表中,还有关于何时使用给定方法以及因此在一个特定阶段交互的简要说明或建议。我们将随着本书的进展遇到这些方法中的大多数。我们将在本章后面看到 onCreate 方法。以下是列表:
-
onCreate:当活动被创建时执行此方法。在这里,我们为游戏准备一切,包括图形、声音,也许还有高分。 -
onStart:当应用处于启动阶段时,将执行此方法。 -
onResume:此方法在onStart之后运行,但也可以在活动在之前暂停后恢复后,以最合理的方式进入。当应用被中断时,例如接电话或用户运行另一个应用,我们可能会重新加载之前保存的游戏情况。 -
onPause:当我们的应用暂停时,这会发生。在这里,我们可能想要保存当前游戏。你可能已经对这些方法有了感觉。 -
onStop:这关系到停止阶段。这是我们可能撤销在onCreate中执行的所有操作的地方。如果我们到达这里,我们的活动可能很快就会被销毁。 -
onDestroy:这是我们的活动最终被销毁的时候——我们拆解游戏的最后机会。如果我们到达这里,我们肯定会再次从头开始经历生命周期阶段。
所有方法描述及其相关阶段应该都很直观。也许,唯一真正的问题是关于运行阶段。正如我们将看到的,当我们在其他方法/阶段编写代码时,onCreate、onStart和onResume方法将准备游戏,使其持续,形成运行阶段。onPause、onStop和onDestroy方法将在之后发生。现在我们可以真正地查看这些方法之一以及一些其他方法。
我们对 Java 的第一印象
那么,关于我们在创建新项目时 Android Studio 生成的所有代码,又是什么呢?这是将我们的游戏菜单激活的代码。让我们仔细看看。编辑器窗口中的第一行代码是这样的:
package com.packtpub.mathgamechapter2;
这行代码定义了我们首次创建项目时命名的包。随着本书的进展,我们将编写跨越多个文件的更复杂代码。我们创建的所有代码文件都需要它们所属的包,像上一行代码那样清晰地定义在顶部。这些代码实际上在我们的游戏中并没有做任何事情。注意,这一行以分号(;)结尾。这是 Java 语法的一部分,表示代码行的结束。如果你移除分号,将会得到一个错误,因为 Android Studio 会尝试将两行代码合并在一起。如果你喜欢,可以试一试。
小贴士
记住,如果你打算从下载包中复制和粘贴代码,这可能是一行可能会根据你的项目设置方式而变化的代码。如果代码文件中的包名与你在创建项目时创建的包名不同,始终使用创建项目时的包名。
要查看下一行代码,你可能需要点击小的+图标来展开它们。Android Studio 试图通过简化我们的代码视图来提供帮助。注意,编辑器窗口的侧面也有一些小的-图标。你可以根据需要展开和折叠它们,而不会影响程序的功能。这在上面的屏幕截图中显示:

一旦你展开了代码,你会看到这四行:
import android.support.v7.app.ActionBarActivity;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
注意,所有前面的行都是以单词import开头的。这是一条指令,指示在我们的游戏中包含其他包,而不仅仅是我们的包。这非常重要,因为它使我们能够利用其他程序员的辛勤工作,在这个案例中是 Android 开发团队的工作。正是这些导入使我们能够使用我们之前讨论过的方法,并允许我们与 Android 生命周期阶段交互。再次注意,所有行都以分号(;)结尾。
下一行引入了 Java 的一个基本构建块,称为类。类是我们将在整本书中不断扩展知识和理解的东西。现在,看看这一行代码,然后我们将详细讨论它:
public class MainActivity extends ActionBarActivity {
逐字逐句,这里正在发生的事情。前一行是在说:创建一个新的public class,名为MainActivity,并基于(extends)ActionBarActivity。
你可能会记得,MainActivity是我们创建此项目时选择的名称。ActionBarActivity是由 Android 开发团队编写的代码(称为类),使我们能够将 Java 代码放入 Android 中。
如果你有一双敏锐的眼睛,你可能会注意到这一行的末尾没有分号。然而,有一个开括号({)。这是因为MainActivity包含了其余的代码。实际上,所有内容都是我们MainActivity类的一部分,这个类是基于ActionBarActivity类/代码构建的。如果你滚动到编辑器窗口的底部,你会看到一个闭括号(})。这表示我们名为MainActivity的类的结束。
-
我们现在还不需要了解类是如何工作的。
-
我们将使用类来访问其代码中包含的一些方法,而且不做任何更多的事情,我们就已经默认地利用了我们之前讨论过的 Android 生命周期方法。
-
我们现在可以挑选和选择我们希望在这些类中重写或保留默认设置的方法。
因此,是ActionBarActivity类包含了使我们能够与 Android 生命周期交互的方法。实际上,还有许多不同的类使我们能够做到这一点,稍后我们将从使用ActionBarActivity转换到一个更合适的类,这个类也执行了上述所有操作。
小贴士
在这个阶段,正确理解 Java 类并不重要;只需知道你可以导入一个包,一个包可以包含一个或多个类,你可以使用这些类的功能,或者基于这些类编写自己的 Java 程序。
在接下来的几章中,我们将经常遇到类。把它们想象成做事情的编程黑盒子。在 第六章,面向对象编程 – 使用他人的辛勤工作 中,我们将打开这个黑盒子,真正掌握它们,我们甚至开始创建自己的类。
继续编写代码,让我们看看我们类中包含的代码实际上做了什么。
这是紧接在我们刚才讨论的关键行之后的代码块:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
希望现在一些代码开始变得有意义,并与我们之前讨论的内容联系起来。尽管精确的语法仍然感觉有点陌生,但只要我们清楚发生了什么,我们就可以继续学习 Java。
在前面的代码中,我们首先注意到的是 @override 这个单词。记得我们说过,所有与 Android 生命周期交互的方法都是默认实现的,我们可以选择何时以及是否重写它们吗?这就是我们在 onCreate 方法中所做的。
@override 这个单词表示接下来的方法正在被重写。protected void onCreate(Bundle savedInstanceState) { 这一行包含了我们要重写的方法。你可能能够猜到,动作从问题行末尾的 { 开始,并在三行后的 } 结束。
在方法名 onCreate 前面的 protected void 和方法名后面的 (Bundle savedInstanceState) 看起来有些奇怪,但在这个时候并不重要,因为它们是由我们处理的。这与数据在程序各个部分之间传输有关。我们只需要知道这里发生的事情将在 Android 生命周期的创建阶段发生。其余的将在 第四章,发现循环和方法 中变得清晰。让我们继续到下一行:
super.onCreate(savedInstanceState);
在这里,super 关键字引用的是原始 onCreate 方法中的代码,尽管我们看不到它,但它仍然存在。代码的意思是:尽管我正在重写你,但我希望你能像平时一样先设置好一切。然后,在 onCreate 完成了我们看不到也不需要看到的许多工作之后,方法继续进行,我们实际上可以用这一行代码来做一些自己的事情:
setContentView(R.layout.activity_main);
在这里,我们告诉 Android 设置主内容视图(我们的用户屏幕),这是我们之前创建的酷炫游戏菜单。具体来说,我们声明它是一个 R 或资源,位于 layout 文件夹中,文件名为 activity_main。
清理我们的代码
下两个代码块是由 Android Studio 根据我们可能想要重写另外两个方法的假设创建的。我们不需要,因为它们是更常用于非游戏应用的类的方法:
-
删除以下代码中显示的整个内容。小心不要删除我们的
MainActivity类的结束花括号:@Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.main, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. int id = item.getItemId(); if (id == R.id.action_settings) { return true; } return super.onOptionsItemSelected(item); } -
现在我们可以删除几个
@import语句。原因是我们刚刚删除了不再需要的(之前导入的)类的重写方法。注意,编辑器窗口中的以下行是灰色的。注意,如果你保留它们,程序仍然可以工作。现在删除它们两个,使你的代码尽可能清晰:import android.view.Menu; import android.view.MenuItem; -
在我们的代码完成之前的一些最终修改:在这个阶段,你可能认为我们已经删除和更改了太多的代码,以至于我们可能从一张空页开始并输入它。这几乎是正确的。但是,让 Android Studio 为我们创建一个新的项目并做出这些修改的过程更加彻底,同时也避免了相当多的步骤。以下是最后的代码更改。将
import android.support.v7.app.ActionBarActivity;行更改为import android.support.app.Activity;。 -
现在你将在我们的代码下看到几条红色的下划线,表示错误。这是因为我们正在尝试使用我们尚未导入的类。只需将
public class MainActivity extends ActionBarActivity {行更改为public class MainActivity extends Activity {。
我们对最后两个更改所做的操作是使用 Activity 类的一个稍微更合适版本。为此,我们也必须更改我们导入的内容。
当你完成时,你的编辑器窗口应该看起来完全像这样:
package com.packtpub.mathgamechapter2.mathgamechapter2;
import android.app.Activity;
import android.os.Bundle;
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
}
现在我们已经了解了情况,我们的代码干净且简洁,我们可以真正查看我们的游戏动作的开始部分!
小贴士
如果我们刚才讨论的任何内容看起来很复杂,你无需担心。Android 强制我们工作在 Activity 生命周期内,因此之前的步骤是不可避免的。即使你没有完全理解关于类和方法等的解释,你仍然可以从这里开始学习 Java。随着书籍的进展,所有的类和方法看起来都会更加直接。
构建和安装我们的游戏
很快,我们将真正看到我们的菜单在行动。但在我们这样做之前,我们需要了解如何使用 Android 模拟器和如何构建我们的游戏。然后我们将把它们放在一起,并将我们的游戏放入模拟器或真实设备中,以便像我们的玩家一样看到它。
模拟器和设备
现在我们已经准备好运行我们的游戏的第一部分。我们需要测试它以检查任何错误、崩溃或其他意外情况。同样重要的是要确保它在目标设备类型/尺寸上看起来很好并且运行正确。
注意
我们不会深入讨论处理不同设备类型的方法。我们所有的游戏都是全屏的,我们将在以后锁定方向并动态计算屏幕分辨率等参数。因此,我们可以为单一设备类型编写代码,并专注于学习 Java。
现在知道你可以为任何屏幕尺寸分类或像素密度创建不同的布局文件可能会有用。你只需要将布局文件放置在适当的文件夹中,使用完全相同的文件名。然后,Android 设备将知道最适合它的布局。有关详细讨论,请参阅 Google 开发者网站developer.android.com/guide/practices/screens_support.html。
注意,你不需要理解前面链接中的任何信息来学习 Java 并发布你的第一个游戏。
有几种方法可以做到这一点,我们将探讨两种。首先,我们将使用 Android Studio 和 Android 开发工具来创建设备模拟器,这样我们就可以在我们开发的同一台 PC/Mac 上使用、测试和调试我们的游戏。因此,我们不需要拥有设备。这将使我们能够从我们的游戏中获取崩溃报告。
然后,我们将直接将游戏安装到真实设备上,这样我们就可以看到当设备所有者下载我们的应用时将看到的确切内容。
还有更多选项。例如,你可以通过 USB 连接真实设备,并在 Android Studio 中直接在设备上调试,包括错误和语法反馈。这个过程可能因设备而异,并且由于我们不会专注于除基本调试之外的内容,因此我们不会在本书中介绍该内容。
创建模拟器
让我们启动我们的模拟器并开始模拟:
-
在 Android Studio 快速启动栏的右侧,找到 AVD 管理器图标:
![创建模拟器]()
-
点击图标以启动 Android 虚拟设备管理器。然后点击左下角的创建虚拟设备...按钮,以打开虚拟设备配置窗口。
-
现在点击Nexus 4选项,然后点击下一步。
-
现在我们需要选择我们将用于构建和测试游戏的 Android 版本。撰写本文时的最新版本是Lollipop - 21 - x86。这是唯一一个我们不需要完成下载即可继续的选项。因此,选择它(或你阅读此内容时的默认版本),然后点击下一步继续。
-
在下一屏幕上,我们可以保留所有默认设置。因此,点击完成。
我们现在有一个可以运行的 Android 模拟器。
运行模拟器
现在我们将启动(开启)我们的虚拟设备,然后按照以下步骤实际运行我们之前制作的游戏:
-
在名称列下点击Nexus 4 API 21。现在点击描述我们模拟器的右侧的三角形播放图标。
注意
Android 模拟器启动需要很长时间。即使在高性能的 PC 上也是如此。预计至少需要等待几分钟,甚至 10 分钟。
-
一旦启动,通过在模拟设备屏幕上的任何位置点击和拖动来解锁设备。这相当于滑动解锁真实的 Nexus 4。以下是我们的 Nexus 4 虚拟设备在运行和解锁时的样子:
![运行模拟器]()
您可以几乎以与真实 Android 设备相同的方式与这个模拟器玩耍。然而,您无法从 Google Play 下载应用。您可能会注意到,与真实设备相比,甚至与旧设备相比,模拟器有点慢。不久,我们将看看如何在真实设备上运行我们的应用。
在模拟器上运行我们的游戏
一旦模拟器运行,通常最好让它保持运行状态,这样每次我们想要使用它时,就无需等待它启动。让我们使用模拟器:
-
如果模拟器尚未运行,请启动模拟器,并确保设备已按之前描述的方式解锁。
-
点击工具栏中的运行图标(如下所示)来运行您的应用。您也可以通过从菜单栏导航到Run | Math Game Chapter 2来实现相同的功能:
![在模拟器上运行我们的游戏]()
-
在 Android Studio 构建我们的应用后暂停片刻,一个弹出对话框会询问您要在哪个设备上运行应用。在描述中选择带有Nexus 4 API 21的设备。这是我们之前创建的已运行设备。现在按OK。
-
注意此时,有用的 Android 窗口出现在 Android Studio 的底部区域。在不太可能遇到任何问题的前提下,只需检查代码中的拼写错误。如果事情真的出了问题,只需回到使用示例代码部分,与提供的代码进行比较或复制粘贴。
在短暂的暂停之后,我们的游戏菜单屏幕将在模拟器上出现。当然,它目前还没有做任何事情,但它正在运行,按钮可以被按下。
当您完成时,您可以按后退或主页图标退出应用,就像在真实的 Android 设备上一样。
现在我们已经看到一种方法,我们可以通过在 Android 模拟器中运行应用来测试我们的应用。让我们找出如何将我们的代码转换成可以在真实设备上分发和使用的应用。
构建我们的游戏
要在真实的 Android 设备上运行我们的游戏,我们需要创建一个.apk文件,即以.apk扩展名结尾的文件。.apk文件是 Android 系统用来运行和安装我们应用的文件和文件夹的压缩归档。以下是使用 Android Studio 制作游戏.apk的步骤:
-
从菜单栏导航到Build | Generate Signed APK。
-
一个稍微冗长的窗口会弹出,并显示:对于基于 Gradle 的项目,应在 Gradle 构建脚本中指定签名配置。您可以通过点击OK安全地关闭此窗口。
-
接下来是生成签名 APK 向导对话框。在这里,我们正在创建一个密钥,以标识密钥持有者有权分发 APK。在此过程结束时,你将有一个
.keys文件,每次构建.apk文件时都可以使用。所以这个步骤在将来可以省略。点击创建新按钮。 -
在密钥库路径字段中,输入或前往你希望在硬盘上存储密钥的位置。然后你会被提示选择密钥库的文件名。这是任意的。输入
MyKeystore并点击确定。 -
在密码字段中输入密码,然后在确认字段中重新输入。这是保护你的密钥的密码。
-
接下来,在别名字段中输入一个容易记住的别名。你可以将其视为你密钥的一种用户名。再次在密码字段中输入密码,然后在确认字段中重新输入。这是你密钥的密码。
-
将有效期下拉菜单保留在默认的25年。
-
然后,你可以填写你的姓名和组织详情(如果有),然后点击确定。
-
现在我们的密钥和密钥库已经完成,我们可以在生成签名 APK 向导对话框中点击确定。
-
我们随后会被提示选择运行 Proguard。在这个时候加密和优化我们的
.apk文件是不必要的。所以只需点击完成来生成我们应用的.apk文件。 -
生成的
.apk文件将放在你选择放置项目文件的同一目录中。例如,MathGameChapter2/app。
我们现在已经构建了一个可以在我们首次创建项目时指定的任何安卓设备上运行的.apk文件。
将设置安装到设备
因此,我们已经有了.apk文件,并且知道在哪里找到它。以下是我们在安卓设备上运行它的方法。
我们可以使用多种方法之一将.apk文件放入设备中。我发现最简单的方法是使用像 Dropbox 这样的云存储服务。然后你可以简单地点击并拖动.apk文件到你的 Dropbox 文件夹,就完成了。或者,你的安卓设备可能附带 PC 同步软件,允许你将文件拖放到你的设备上。在你将.apk文件放置到你的安卓设备上后,继续教程。
大多数安卓手机被设置为只能从 Google Play Store 以外的任何地方安装应用。所以我们需要更改这一点。你将导航到的确切菜单可能因设备而略有不同,但以下选项在大多数设备上,新旧设备,都几乎相同:
-
找到并点击设置应用。大多数安卓手机也都有一个设置菜单选项。任选其一即可。现在选择安全,然后向下滚动到未知来源选项。点击未知来源复选框以允许从未知来源安装应用。
-
使用 Dropbox 应用或您的设备文件浏览器根据您选择的将 APK 放在设备上的方法,在您的 Android 设备上找到文件。点击
MathGameChapter2.apk文件。 -
您现在可以像安装任何其他应用一样安装该应用。当提示时,按安装然后打开。游戏现在将在您的设备上运行。
将您的设备保持纵向模式,因为这是 UI 设计的方向。恭喜您在自己的设备上运行自己的 Android 应用。在数学游戏的下一个版本中,我们将锁定方向以使其更友好。
未来项目
在整本书中,我们将测试和运行我们的游戏项目。完全取决于您喜欢我们讨论的哪种方法。如果您遇到崩溃或未解释的 bug,那么您将需要使用模拟器。如果一切正常,那么最快且可能最令人满意的方式是在您自己的设备上运行它。
自我测试问题
Q1) 如果关于生命周期、类和方法的讨论有点令人困惑,你应该怎么做?
Q2) 什么是 Java 类?
Q3) 方法与类之间有什么区别?
Q4) 查看 Android 开发者网站及其对生命周期阶段的更技术性解释developer.android.com/reference/android/app/Activity.html。您能看到我们未讨论的阶段及其相关方法吗?它会在应用程序中的什么时候被触发?活动从创建到销毁的精确路径是什么?
摘要
我们讨论过,到目前为止,完全理解代码的工作原理并不是很重要。这是因为它将仅仅作为本书中我们编写的代码的容器。然而,正如我们在第四章中详细讨论的,发现循环和方法,以及在第六章中讨论的类第六章。面向对象编程 – 利用他人的辛勤工作,我们将开始理解我们游戏中所有的代码。
我们详细讨论了 Android 生命周期的复杂性。我们了解到,在这个阶段,我们只需要理解我们必须在正确的方法中编写代码,这些方法与生命周期的不同阶段相关。然后我们将没有困难地学习 Java。就像类和方法一样,所有内容都会在过程中解释,并通过实践变得更加清晰。
我们还学习了 Android Studio UI 的关键区域。我们使用 Android Studio 设计器为我们的数学游戏构建了启动菜单。此外,我们创建了使游戏出现在玩家设备上的必要 Java 代码。这主要是通过修改为我们自动生成的代码来实现的。
这可能是这本书中最困难的一章,因为有必要介绍一些像 Java 类、Java 方法和 Android 生命周期这样的东西。我们这样做是因为我们需要在我们学习 Java 时了解周围发生的事情。
然而,从现在开始,我们可以非常逻辑地一步一步地推进。如果你已经达到这个阶段,你将没有问题完成这本书中最具挑战性的项目。
如果这一章让你的大脑有点疼痛,请放心,你能够走到这一步是一个非常好的迹象,表明你很快就会成为一个 Java 高手。从基础开始,我们现在就来学习一些 Java。
第三章。说 Java – 你的第一个游戏
在这一章中,我们将开始编写我们自己的 Java 代码,同时我们开始理解 Java 语法。我们将学习如何存储、检索和操作存储在内存中的不同类型的值。我们还将探讨根据这些数据值做出决策和分支我们的代码流程。
按照这个顺序,我们将:
-
学习一些 Java 语法,看看编译器如何将其转换成可运行的程序
-
存储数据并使用变量
-
学习如何用表达式在 Java 中表达自己
-
通过提问继续进行数学游戏
-
了解 Java 中的决策
-
通过获取和检查答案继续进行数学游戏
掌握前面的 Java 技能将使我们能够构建数学游戏的下一个两个阶段。这个游戏将能够向玩家提问关于乘法的问题,检查答案并根据给出的答案提供反馈,如下面的截图所示:

Java 语法
在这本书的整个过程中,我们将使用简单的英语来讨论一些相当技术性的内容。你永远不会被要求阅读一个在非技术方式之前没有解释过的 Java 或 Android 概念的技术解释。
有时,我可能会要求或暗示你接受一个简化的解释,以便在更合适的时候提供更全面的解释,比如 Java 类作为一个黑盒;然而,你永远不会需要匆匆忙忙地去谷歌搜索以理解一个难词或充满术语的句子。
话虽如此,Java 和 Android 社区充满了使用技术术语的人,要加入并从这些社区中学习,你需要理解他们使用的术语。所以这本书采取的方法是使用完全平实的语言来学习一个概念或欣赏一个想法,同时,它将术语作为学习的一部分引入。
然后,许多术语将开始显示出其有用性,通常作为澄清或避免解释/讨论变得比必要的更长的一种方式。
“Java 语法”这个术语可能被认为是技术性的或术语性的。那么它是什么?Java 语法是我们将 Java 的语言元素组合在一起以产生在 Java/Dalvik 虚拟机上运行的代码的方式。语法也应该尽可能地对人类读者清晰,尤其是当我们未来再次访问我们的程序时。Java 语法是我们使用的单词及其形成句子结构的方式的组合。
这些 Java 元素或单词数量众多,但当我们将其分成小块时,几乎肯定比任何人类语言都容易学习。这是因为 Java 语言及其语法被特别设计成尽可能简单明了。我们还有 Android Studio 在我们这边,它经常会告诉我们我们是否犯了错误,有时甚至会提前思考并给出提示。
我相信,如果你能阅读,你就能学会 Java;因为学习 Java 非常容易。那么,完成基础 Java 课程的人和专家程序员之间有什么区别?这与语言学习者与大师诗人之间的区别相同。对语言的掌握是通过实践和进一步学习实现的。
在最后一章,如果你想要自己继续深入学习 Java,我会向你展示正确的方向。
编译器
编译器是将我们人类可读的 Java 代码转换成可以在虚拟机上运行的另一段代码的工具。这被称为编译。当我们的用户点击我们的应用图标时,Dalvik 虚拟机将运行这段编译后的代码。除了编译 Java 代码外,编译器还会检查错误。尽管我们可能仍然在我们的发布应用中存在错误,但许多错误是在代码编译时被发现的。
使用注释使代码清晰
随着你编写 Java 程序技能的提升,你用来创建程序的方法将会变得更长更复杂。此外,正如我们将在后面的章节中看到的,Java 是通过让我们将代码分成单独的部分,通常跨越多个文件,来设计用来管理复杂性的。
注释是 Java 程序的一部分,在程序本身中没有任何功能。编译器会忽略它们。它们的作用是帮助程序员记录、解释和阐明他们的代码,以便在将来或对其他可能需要使用或修改代码的程序员来说更容易理解。
因此,一段好的代码将会大量使用这样的行:
//this is a comment explaining what is going on
前面的注释以两个正斜杠字符开始,//。注释在行尾结束。这被称为单行注释。所以那一行上的内容仅供人类阅读,而下一行上的内容(除非它是另一个注释)需要是语法正确的 Java 代码:
//I can write anything I like here
but this line will cause an error
我们可以使用多个单行注释:
//Below is an important note
//I am an important note
//We can have as many single line comments like this as we like
如果我们想要暂时禁用一行代码,单行注释也非常有用。我们可以在代码前加上 //,这样它就不会被包含在程序中。回想一下以下代码,它告诉 Android 加载我们的菜单 UI:
//setContentView(R.layout.activity_main);
在前面的情况下,菜单将不会被加载,当运行应用程序时,屏幕将是空的,因为整行代码被编译器忽略。Java 中还有一种注释类型——多行注释。这对于较长的注释以及在代码文件顶部添加版权信息非常有用。同样,像单行注释一样,它也可以用来暂时禁用代码,在这种情况下通常是多行代码。
在起始 /* 标签和结束 */ 标签之间的所有内容都会被编译器忽略。以下是一些示例:
/*
This program was written by a Java expert
You can tell I am good at this because my
code has so many helpful comments in it.
*/
多行注释的行数没有限制。哪种类型的注释最适合使用将取决于具体情况。在这本书中,我会始终明确解释每一行代码,但你经常会发现代码中散布着大量的注释,这些注释提供了进一步的解释、见解或澄清。因此,阅读所有代码总是一个好主意:
/*
The winning lottery numbers for next Saturday are
9,7,12,34,29,22
But you still want to learn Java? Right?
*/
小贴士
所有最好的 Java 程序员都会在他们的代码中大量添加注释。
使用变量存储数据和使用数据
我们可以将变量想象成一个带有标签的存储盒。它们也像是程序员通往 Android 设备(或我们正在编程的任何设备)内存的窗口。变量可以在内存(存储盒)中存储数据,以便在需要时通过适当的标签进行检索或修改。
计算机内存有一个非常复杂的寻址系统,幸运的是,我们不需要在 Java 中与之交互。Java 变量允许我们为程序想要处理的所有数据创建方便的名称;JVM 将处理所有与操作系统交互的技术细节,而操作系统反过来,可能通过几层委托,将与硬件交互。
因此,我们可以将我们的 Android 设备内存想象成一个巨大的仓库。当我们为变量命名时,它们被存储在仓库中,以便在我们需要时使用。当我们使用变量的名称时,设备会确切地知道我们指的是什么。然后我们可以告诉它做一些事情,比如“获取盒子 A 并将其添加到盒子 C,删除盒子 B”,等等。
在游戏中,我们可能会有一个名为 score 的变量。这个 score 变量将用于管理与用户分数相关的任何操作,例如增加、减少或者只是向玩家显示它。
可能会出现的一些以下情况:
-
玩家回答正确问题,因此将 10 分加到他们现有的
score上。 -
玩家查看他们的统计数据界面,因此需要在屏幕上打印
score。 -
玩家获得了最佳分数,因此将
hiScore设置为与当前score相同。
这些是变量命名的一些相当随意的例子,只要你不使用 Java 限制的关键字字符,你实际上可以随意命名你的变量。然而,在实践中,最好采用一种命名约定,以便你的变量名保持一致。在这本书中,我们将使用一个松散的变量命名约定,即变量名以小写字母开头。当变量名中有多个单词时,第二个单词将以大写字母开头。这被称为“驼峰式命名法”。
这里有一些驼峰式命名的例子:
-
score -
hiScore -
playersPersonalBest
在我们查看一些带有变量的真实 Java 代码之前,我们首先需要了解我们可以创建和使用哪些类型的变量。
变量类型
想象一个简单的游戏可能需要很多变量并不困难。在前一节中,我们以hiScore变量为例进行了介绍。如果游戏有一个记住前 10 名玩家名字的高分榜,那会怎样?那么我们可能需要为每个玩家设置变量。
那么当一款游戏需要知道一个可玩角色是死是活,或者可能还有剩余的生命/重试次数时,情况会怎样呢?我们可能需要编写测试生命状态的代码,如果可玩角色死亡,则通过一个漂亮的血溅动画结束游戏。
在计算机程序中,包括游戏在内的另一个常见需求是正确或错误的计算:真或假。
为了涵盖这些以及其他你可能想要跟踪的信息类型,Java 有类型。有许多种类的变量,正如我们将在第六章中看到的,面向对象编程 – 利用他人的辛勤工作,我们也可以发明自己的类型或使用他人的类型。但就目前而言,我们将查看内置的 Java 类型。为了公平起见,它们几乎涵盖了我们在一段时间内可能遇到的所有情况。一些例子是最好的解释方式。
我们已经讨论了假设但高度可能的score变量。score变量很可能是数字,因此我们必须通过给分数一个适当的数据类型来向 Java 编译器传达这一点(即分数是数字)。假设但同样可能的playerName将当然包含构成玩家名字的字符。跳过几段之后,存储常规数字的类型称为int,而存储类似名字的数据的类型称为String。如果我们尝试在用于数字的score中存储玩家名字,比如“Ada Lovelace”,我们肯定会遇到麻烦。
编译器说不行!实际上,错误信息会这样显示:

如我们所见,Java 被设计成不可能让这样的错误进入运行程序。你也在之前的屏幕截图中发现我忘记在行尾加上分号了吗?有了这个编译器识别我们的错误,还能有什么可能出错呢?
下面是 Java 中的主要数据类型。稍后我们将看到如何开始使用它们:
-
int:这种类型用于存储整数。它使用 32 位(位)内存,因此可以存储超过 20 亿的值,包括负值。 -
long:正如其名所示,这种数据类型可以在需要更大数字时使用。long数据类型使用 64 位内存,2 的 63 次方是我们可以在这种类型中存储的。如果你想看看它是什么样子,试试这个:9,223,372,036,854,775,807。也许令人惊讶的是,long变量有用途,但如果较小的变量可以满足需求,我们应该使用它,这样我们的程序就会使用更少的内存。注意
你可能会想知道何时会使用这种规模的数字。明显的例子是数学或科学应用,它们需要进行复杂的计算,但另一种用途可能是计时。当你计时某件事情花费了多长时间时,Java 的
Date类使用自 1970 年 1 月 1 日以来的毫秒数。long数据类型可以用来从结束时间减去开始时间,以确定经过的时间。我们将在第五章,游戏和 Java 基础中使用long。 -
float:这是用于浮点数,即小数点后有精度的数。由于一个数的分数部分和整数部分一样占用内存空间,因此与非浮点数相比,float 类型可能存储的数值范围较小。所以,除非我们的变量确实需要额外的精度,否则 float 不会是我们的首选数据类型。 -
double:当float的精度不足时,我们有double。 -
short:当int数据类型也过于冗余时,超级瘦小的 short 类型可以放入最小的存储空间中,但我们只能存储大约 64,000 个值,从-32,768 到 32,767。 -
byte:这是一个比 short 类型更小的存储空间。在内存中有足够的空间,但一个 byte 只能存储从-128 到 127 的值。 -
boolean:在这本书中我们将大量使用布尔值。布尔变量可以是 true 或 false——没有其他情况。也许布尔值可以回答如下问题:-
玩家是否存活?
-
是否已经达到了新的高分?
-
两个例子是否足够说明布尔变量的用法?
-
-
char:这存储单个字母数字字符。它本身不会改变任何事情,但如果我们将很多这样的字符放在一起,可能会很有用。小贴士
我已经将数据类型的讨论保持在实用的水平,这在本书的上下文中是有用的。如果你对数据类型值的存储方式以及为什么限制是这样的感兴趣,请访问 Oracle Java 教程网站
docs.oracle.com/javase/tutorial/java/nutsandbolts/datatypes.html。请注意,你不需要比我们已讨论的更多信息来继续阅读本书。
正如我们刚刚学到的,我们可能想要存储的每种数据类型都需要特定的内存量。因此,在我们开始使用变量之前,我们必须让 Java 编译器知道变量的类型。
前面的变量被称为原始类型。它们使用预定义的内存量,因此,使用我们的存储类比,可以适应预定义的存储盒大小。
如“原始”标签所暗示的,它们不如引用类型复杂。
引用类型
你可能已经注意到,我们没有涵盖我们之前用来介绍变量概念的String变量类型。
字符串是一种特殊的变量类型,称为引用类型。它们非常简单,指的是变量存储开始的内存位置,但引用类型本身并不定义特定的内存量。原因相当直接:我们并不总是知道程序实际运行时需要存储多少数据。
我们可以将字符串和其他引用类型想象成不断扩展和收缩的存储盒。那么,这些String引用类型最终不会撞到另一个变量吗?如果你把设备内存想象成一个装满了标签存储盒的巨大仓库,那么你可以把 Dalvik 虚拟机想象成一个超级高效的叉车司机,将不同类型的存储盒放在最合适的位置。
如果有必要,虚拟机将在几秒钟内快速移动东西以避免冲突。它甚至会在适当的时候销毁不需要的存储盒。这同时发生,不断卸载所有类型的新的存储盒并将它们放置在最佳位置,为该类型的变量。Dalvik 倾向于将引用变量保存在与原始变量不同的仓库部分,我们将在第六章 OOP – 使用他人的辛勤工作中了解更多细节,面向对象编程 – 使用他人的辛勤工作。
因此,字符串可以用来存储任何键盘字符,就像char数据类型一样,但长度几乎可以是任何长度。从玩家的名字到整本书都可以存储在一个单独的字符串中。我们将经常使用字符串,包括在本章中。
我们还将探索更多种类的引用类型。数组是一种存储大量相同类型变量的方式,便于快速高效地访问。我们将在第五章游戏和 Java 基础中查看数组。
将数组想象成我们仓库中一个通道,所有特定类型的变量都按精确的顺序排列。数组是引用类型,因此 Dalvik 将这些变量存储在仓库的同一部分,就像字符串一样。
另一种引用类型是我们将在第六章面向对象编程 – 利用他人的辛勤工作中探讨的神秘对象或类。
因此,我们知道我们可能想要存储的每种数据类型都需要一定量的内存。因此,我们必须在开始使用它之前让 Java 编译器知道变量的类型。
声明
理论就到这里。让我们看看我们实际上会如何使用我们的变量和类型。记住,每种原始类型都需要一定量的实际设备内存。这就是为什么编译器需要知道变量的类型。因此,在我们尝试对变量进行任何操作之前,我们必须首先声明变量及其类型。
要声明一个名为score的int类型变量,我们会输入:
int score;
就这样!只需声明类型,在这种情况下是int,然后留一个空格,然后输入你想为这个变量使用的名称。还要注意,通常在行尾的分号,以向编译器表明我们已经完成了这一行,并且如果有的话,接下来的内容不是声明的一部分。
对于几乎所有的其他变量类型,声明方式都是相同的。以下是一些示例。变量名是任意的。这就像在仓库中预留一个带标签的存储箱:
long millisecondsElapsed;
float gravity;
double accurateGravity;
boolean isAlive;
char playerInitial;
String playerName;
初始化
在这里,对于每种类型,我们给变量初始化一个值。想想在存储箱中放置一个值,如下面的代码所示:
score = 0;
millisecondsElapsed = 1406879842000;//1st Aug 2014 08:57:22
gravity = 1.256;
accurateGravity =1.256098;
isAlive = true;
playerInitial = 'C';
playerName = "Charles Babbage";
注意,char变量使用单引号(')包围初始化值,而String使用双引号(")。
我们还可以将声明和初始化步骤结合起来。在下面的代码片段中,我们声明并初始化了之前相同的变量,但每个步骤都是一步:
int score = 0;
long millisecondsElapsed = 1406879842000;//1st Aug 2014 08:57:22
float gravity = 1.256;
double accurateGravity =1.256098;
boolean isAlive = true;
char playerInitial = 'C';
String playerName = "Charles Babbage";
注意
我们是单独声明和初始化还是一起进行,这很可能取决于具体情况。重要的是我们必须做两件事:
int a;
//The line below attempts to output a to the console
Log.i("info", "int a = " + a);
上述代码会导致以下结果:
Compiler Error: Variable a might not have been initialized
这里有一个重要的例外。在特定情况下,变量可以有默认值。我们将在第六章面向对象编程 – 利用他人的辛勤工作中看到这一点。但良好的做法是同时声明和初始化变量。
使用运算符更改变量
当然,在几乎任何程序中,我们都需要对这些值进行一些操作。以下是一个可能最常见的 Java 运算符列表,它允许我们操作变量。您不需要记住它们,因为当我们第一次使用它们时,我们会查看每一行代码:
-
赋值运算符 (=): 这使得运算符左侧的变量与右侧的值相同。例如,
hiScore = score;或score = 100;。 -
加法运算符 (+): 这将运算符两侧的值相加。它通常与赋值运算符一起使用,例如
score = aliensShot + wavesCleared;或score = score + 100;。注意,在运算符两侧同时使用相同的变量是完全可接受的。 -
减法运算符 (-): 这从运算符右侧的值减去运算符左侧的值。它通常与赋值运算符一起使用,例如
lives = lives - 1;或balance = income - outgoings;。 -
除法运算符 (/): 这将左侧的数字除以右侧的数字。同样,它通常与赋值运算符一起使用,如
fairShare = numSweets / numChildren;或recycledValueOfBlock = originalValue / .9;。 -
乘法运算符 (*): 这将变量和数字相乘,例如
answer = 10 * 10;或biggerAnswer = 10 * 10 * 10;。 -
递增运算符 (++): 这是一种非常巧妙的方法,可以将
1添加到变量的值。myVariable = myVariable + 1;语句与myVariable++;相同。 -
递减运算符 (--): 你猜对了:一种从某物中减去
1的非常巧妙的方法。myVariable = myVariable -1;语句与myVariable--;相同。
注意
这些运算符的正式名称与这里用于解释的名称略有不同。例如,除法运算符实际上是乘法运算符之一。但前面的名称对于学习 Java 来说更有用,如果您在与 Java 社区的人交谈时使用了“除法运算符”,他们会确切地知道您的意思。
实际上,Java 中还有许多其他运算符。当我们学习 Java 中的决策时,我们将在本章后面看到一大堆运算符。
提示
如果您对运算符感兴趣,可以在 Java 网站上找到它们的完整列表,网址为 docs.oracle.com/javase/tutorial/java/nutsandbolts/operators.html。本书中完成项目所需的所有运算符都将在此书中进行全面解释。此链接为好奇者提供。
在 Java 中表达自己
让我们尝试使用一些声明、赋值和运算符。当我们将这些元素组合成有意义的语法时,我们称之为表达式。所以让我们快速编写一个应用程序来尝试一下。
在这里,我们将进行一个小型的辅助项目,以便我们可以玩转到目前为止所学的所有内容。我们需要创建一个新的项目,就像我们在上一章中所做的那样,但这次我们不需要 UI。
相反,我们将简单地编写一些 Java 代码,并通过将变量的值输出到 Android 控制台(称为logcat)来检查其效果。我们将通过构建简单的项目并检查代码和控制台输出来确切了解这是如何工作的:
提示
以下是如何创建新项目的快速提醒。
-
关闭当前打开的项目,请导航到文件 | 关闭项目。
-
点击开始一个新的 Android Studio 项目。
-
将会出现创建新项目配置窗口。填写应用程序名称字段和公司域名为
packtpub.com,或者你也可以在这里使用你自己的公司网站名称。 -
现在点击下一步按钮。在下一屏幕上,确保手机和平板复选框被勾选。现在我们必须选择我们想要为我们的应用构建的最早版本的 Android。尝试在下拉选择器中玩转几个选项。你会发现,我们选择的版本越早,我们的应用可以支持的设备百分比就越大。然而,这里的权衡是,我们选择的版本越早,我们应用中可用的最新 Android 功能就越少。一个好的平衡点是选择API 8: Android 2.2 (Froyo)。
-
点击下一步。现在选择空白活动,然后再次点击下一步。
-
在下一屏幕上,只需将活动名称更改为
MainActivity,然后点击完成。 -
正如我们在第二章中所述,为了保持代码清晰简单,你可以删除两个不需要的方法(
onCreateOptionsMenu和onOptionsItemSelected)以及它们相关的@override和@import语句。然而,这对于示例的正常运行并不是必需的。
提示
要详细了解创建新项目的说明和图片,请参阅第二章,Android 入门。
与本书中的所有示例和项目一样,你可以从下载包中复制或查看代码。你可以在Chapter3/ExpressionsInJava/MainActivity.java文件中找到本教程的代码。只需按照之前描述的方式创建项目,并将下载包中的MainActivity.java文件中的代码粘贴到 Android Studio 创建项目时生成的MainActivity.java文件中。只需确保包名与创建项目时选择的相同。然而,我强烈建议跟随教程,这样我们可以学会自己完成所有事情。
注意
由于此应用使用 logcat 控制台来显示其输出,你应该只在模拟器上运行此应用,而不是在真实的 Android 设备上。此应用不会损害真实设备,但你将无法看到任何发生的事情。
-
创建一个名为
Expressions In Java的新空白项目。 -
现在,在
setContentView方法之后的onCreate方法中,添加以下代码以声明和初始化一些变量://first we declare and initialize a few variables int a = 10; String b = "Alan Turing"; boolean c = true; -
现在添加以下代码。这段代码简单地以我们可以稍后仔细检查的形式输出变量的值:
//Let's look at how Android 'sees' these variables //by outputting them, one at a time to the console Log.i("info", "a = " + a); Log.i("info", "b = " + b); Log.i("info", "c = " + c); -
现在,让我们使用加法运算符和另一个新运算符来更改我们的变量。在查看输出和代码说明之前,看看你是否能计算出变量
a、b和c的输出值://Now let's make some changes a++; a = a + 10; b = b + " was smarter than the average bear Booboo"; b = b + a; c = (1 + 1 == 3);//1 + 1 is definitely 2! So false. -
让我们再次以第 3 步中的相同方式输出值,但这次输出应该不同:
//Now to output them all again Log.i("info", "a = " + a); Log.i("info", "b = " + b); Log.i("info", "c = " + c); -
以通常的方式在模拟器上运行程序。你可以通过点击位于项目资源管理器下方的“有用的标签”区域中的Android标签来查看输出。
这是输出,其中一些不必要的格式已被删除:
info﹕ a = 10
info﹕ b = Alan Turing
info﹕ c = true
info﹕ a = 21
info﹕ b = Alan Turing was smarter than the average bear Booboo21
info﹕ c = false
现在我们来讨论发生了什么。在第 2 步中,我们声明并初始化了三个变量:
-
a:这是一个存储值为 10 的整型。 -
b:这是一个存储杰出计算机科学家名字的字符串。 -
c:这是一个存储值为false的布尔值
因此,当我们输出第 3 步中的值时,我们得到以下结果应该不会让人感到惊讶:
info﹕ a = 10
info﹕ b = Alan Turing
info﹕ c = true
在第 4 步中,所有有趣的事情发生了。我们使用增量运算符a++;将我们的整型a的值增加 1。记住,a++与a = a + 1相同。
然后,我们将 10 加到a上。注意,我们在已经加 1 之后将 10 加到a上。因此,我们得到 10 + 1 + 10 操作的结果:
info﹕ a = 21
现在,让我们检查我们的字符串b。我们似乎正在使用加法运算符在我们的杰出科学家上。发生的事情你可能已经猜到了。我们正在将两个字符串"Alan Turing"和"was smarter than the average bear Booboo."相加。当你将两个字符串相加时,这被称为连接,而+符号同时作为连接运算符。
最后,对于我们的字符串,我们似乎正在将其与int a相加。这是允许的,a的值被连接到b的末尾。
info﹕ b = Alan Turing was smarter than the average bear Booboo21
注意
这不适用于相反的情况;你不能将一个字符串添加到一个int上。这合乎逻辑,因为没有合理的答案。
a = a + b

最后,让我们看看将我们的布尔值c从true变为false的代码:c = (1+1=3);。在这里,我们将括号内表达式的值赋给c。这本来是直截了当的,但为什么是双等号(==)?我们有点超前了。双等号是另一个称为比较运算符的运算符。
因此,我们实际上是在问,1+1 是否等于 3?显然答案是错误的。你可能会问,“为什么使用 == 而不是 =?” 简单来说,是为了让编译器清楚地知道我们是在赋值还是比较。
小贴士
不小心使用 = 而不是 == 是一个非常常见的错误。
赋值运算符 (=) 将右侧的值赋给左侧的值,而比较运算符 (==) 比较两侧的值。
当我们这样做时,编译器会通过错误警告我们,但乍一看,你可能会发誓编译器是错的。我们将在本章的后面和整本书中学习更多关于比较运算符和其他运算符的内容。
现在,让我们利用我们所知道的一切以及更多知识来制作我们的数学游戏项目。
数学游戏 – 提出问题
现在我们已经掌握了所有这些知识,我们可以用它来改进我们的数学游戏。首先,我们将创建一个新的 Android 活动,作为实际的游戏屏幕,而不是开始菜单屏幕。然后我们将使用 UI 设计器来布局一个简单的游戏屏幕,这样我们就可以使用我们的 Java 技能,包括变量、类型、声明、初始化、运算符和表达式,让我们的数学游戏为玩家生成一个问题。然后我们可以通过一个按钮将开始菜单和游戏屏幕连接起来。
如果你想要节省打字时间并仅查看完成的项目,你可以使用从 Packt Publishing 网站下载的代码。如果你在使任何代码工作时有任何问题,你可以查看、比较或复制粘贴下载包中已完成的代码。
完成的代码在以下文件中,这些文件对应于我们在本教程中将要使用的文件名:
-
Chapter3/MathGameChapter3a/java/MainActivity.java -
Chapter3/MathGameChapter3a/java/GameActivity.java -
Chapter3/MathGameChapter3a/layout/activity_main.xml -
Chapter3/MathGameChapter3a/layout/activity_game.xml
如往常一样,我建议遵循这个教程,看看我们如何自己创建所有代码。
创建新的游戏活动
我们首先需要创建一个新的 Java 文件来存放游戏活动代码,以及一个相关的布局文件来存放游戏活动 UI。
-
运行 Android Studio 并选择我们已在 第二章 中构建的
Math Game Chapter 2项目,Android 入门。它可能已经默认打开了。现在我们将创建一个新的 Android 活动,该活动将包含实际的游戏屏幕,当玩家在我们的主菜单屏幕上点击 Play 按钮时,该屏幕将运行。 -
要创建一个新的活动,我们现在需要另一个布局文件和另一个 Java 文件。幸运的是,Android Studio 会帮助我们完成这项工作。要开始创建新活动所需的所有文件,在项目资源管理器中右键单击
src文件夹,然后转到 New | Activity。现在点击 Blank Activity,然后点击 Next。 -
现在我们需要在上述对话框中输入一些信息来告诉 Android Studio 关于我们新的活动的一些信息。将Activity Name字段更改为
GameActivity。注意Layout Name字段会自动更改为activity_game,而Title字段也会自动更改为GameActivity。 -
点击Finish。Android Studio 已经为我们创建了两个文件,并且也在一个清单文件中注册了我们的新活动,所以我们不需要担心它。
-
如果你查看编辑器窗口顶部的标签页,你会看到
GameActivity.java已经打开,准备好供我们编辑,如以下截图所示:![创建新的游戏活动]()
-
通过点击之前显示的GameActivity.java标签页,确保
GameActivity.java在编辑器窗口中处于活动状态。 -
回到第二章,我们在“Android 入门”中讨论了 Android 默认为我们覆盖了一些方法,并且大多数方法都是不必要的。在这里,我们再次可以看到不必要的代码。如果我们删除它,那么它将使我们的工作环境更简单、更干净。你可能还记得从第二章,“Android 入门”,删除和修改代码部分的过程,虽然不复杂,但相对较长。为了避免这种情况,我们将简单地使用
MainActivity.java中的代码作为GameActivity.java的模板。然后我们可以做一些小的修改。 -
在编辑器窗口中点击MainActivity.java标签页。使用键盘上的Ctrl + A组合键高亮显示编辑器窗口中的所有代码。
-
现在在键盘上使用Ctrl + C组合键复制编辑器窗口中的所有代码。
-
现在点击GameActivity.java标签页。
-
使用键盘上的Ctrl + A组合键在编辑器窗口中高亮显示所有代码。
-
现在将复制的代码粘贴到编辑器窗口中,并使用键盘上的Ctrl + V组合键覆盖当前高亮的代码。
-
注意到我们的代码中有一个错误,如以下截图所示,用红色下划线标记。这是因为我们在名为
GameActivity的文件中粘贴了引用MainActivity的代码。![创建新的游戏活动]()
简单地将文本MainActivity更改为GameActivity,错误就会消失。在我告诉你之前,花点时间看看你是否能找出其他必要的微小更改。
-
记住
setContentView加载我们的 UI 设计。我们需要做的是将setContentView更改为加载新的设计(我们将在下一节构建),而不是主屏幕设计。将setContentView(R.layout.activity_main);更改为setContentView(R.layout.activity_game);。 -
保存你的工作,我们就可以继续了。
注意项目资源管理器,Android Studio 会在这里放置为我们创建的两个新文件。在下一张截图中,我已经突出显示了两个文件夹。在未来的讨论中,我将简单地称它们为我们的java代码文件夹或layout文件文件夹。

注意
你可能会想知道为什么我们一开始没有简单地复制粘贴MainActivity.java文件,而是跳过创建新活动的过程?原因在于 Android Studio 在幕后做了很多事情。首先,它为我们创建布局模板。它还通过一个我们稍后会看到的文件,即AndroidManifest.xml,注册了新的活动。这对于新活动能够正常工作至关重要。综合考虑,我们这样做可能是最快的。
这阶段的代码与主菜单屏幕的代码完全相同。我们声明了包名并导入了 Android 提供的一些有用类:
package com.packtpub.mathgamechapter3a.mathgamechapter3a;
import android.app.Activity;
import android.os.Bundle;
我们创建了一个新的活动,这次称为GameActivity:
public class GameActivity extends Activity {
然后我们重写onCreate方法并使用setContentView方法将我们的 UI 设计设置为玩家屏幕的内容。然而,目前这个 UI 是空的:
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_game);
我们现在可以思考实际游戏屏幕的布局了。
布局游戏屏幕 UI
如我们所知,我们的数学游戏将提出问题并给玩家提供一些多项选择题作为答案。我们可以添加很多额外功能,例如难度级别、高分等等。但就目前而言,让我们只专注于提出一个简单、预定义的问题并提供三个预定义的可能答案的选择。
将 UI 设计保持到最简,这表明了一个布局。我们的目标 UI 将看起来大致如下:

布局应该是自解释的,但让我们确保我们真正清楚;当我们开始在 Android Studio 中构建这个布局时,模拟图中显示2 x 2的部分是问题,将由三个文本视图(数字和=符号也是一个单独的视图)组成。最后,三个选项由按钮布局元素组成。我们在上一章中使用了所有这些 UI 元素,但这次,因为我们将通过 Java 代码来控制它们,所以我们需要对它们做一些额外的事情。所以让我们一步一步来:
-
在编辑器窗口中打开将包含我们的游戏 UI 的文件。通过双击
activity_game.xml来完成此操作。这个文件位于我们的 UIlayout文件夹中,可以在项目资源管理器中找到。 -
删除Hello World TextView,因为它不是必需的。
-
在调色板中找到大文本元素。它可以在小部件部分找到。将三个元素拖动到 UI 设计区域,并将它们排列在如图所示的顶部附近。它不需要非常精确;只需确保它们成一行且不重叠,如图所示:
![布置游戏屏幕 UI]()
-
注意在组件树窗口中,Android Studio 已经自动为三个 TextView 分配了名称。它们是textView、textView2和textView3:
![布置游戏屏幕 UI]()
-
Android Studio 将这些元素名称称为id。这是一个重要的概念,我们将利用它。为了确认这一点,通过点击其名称(id)选择任何一个 textView,无论是在先前的截图所示的组件树中,还是在之前的 UI 设计器中直接点击它。现在查看属性窗口,找到id属性。你可能需要稍微滚动一下才能找到它:
![布置游戏屏幕 UI]()
注意,id属性的值为textView。正是这个
id,我们将用它从 Java 代码中与 UI 进行交互。因此,我们希望将所有 TextView 的 ID 更改为有用且易于记忆的名称。 -
如果你回顾我们的设计,你会看到具有textView ID 的 UI 元素将用于存储数学问题的第一部分数字。因此,将 ID 更改为
textPartA。注意text中的小写t,Part中的大写P,以及A的大写。你可以使用任何组合的大小写,实际上你可以将 ID 命名为任何你喜欢的名称。但就像 Java 变量命名约定一样,坚持这里的约定会使程序变得更加复杂时,减少错误的可能性。 -
现在选中textView2,将id改为
textOperator。 -
选中当前 ID 为textView3的元素,并将其更改为
textPartB。这个 TextView 将用于存储问题的后半部分。 -
现在从调色板中添加另一个大文本。将其放置在我们刚刚编辑的三行 TextView 之后。
这段大文本仅仅用于存储等号,并且没有计划对其进行任何更改。因此,我们不需要在 Java 代码中与之交互。我们甚至不需要关心更改 ID 或了解它的具体内容。如果这种情况发生了变化,我们可以在稍后时间回来编辑它的 ID。
-
然而,这个新的 TextView 当前显示的是大号文本,我们希望它显示等号。因此,在属性窗口中,找到文本属性,并输入值=。我们之前在第二章中已经更改过文本属性,Android 入门,你可能还希望更改
textPartA、textPartB和textOperator的文本属性。这并不是绝对必要的,因为我们将很快看到如何通过 Java 代码来更改它;然而,如果我们将文本属性更改为更合适的内容,那么我们的 UI 设计将更接近游戏在真实设备上运行时的样子。 -
因此,将textPartA的文本属性更改为
2,textPartB更改为2,textOperator更改为x。你的 UI 设计和组件树现在应该看起来像这样:![布置游戏屏幕 UI]()
-
为了让按钮包含我们的多项选择题答案,将三个按钮排成一行,放在=符号下方。整齐地排列,就像我们的目标设计一样。
-
现在,就像我们对 TextView 所做的那样,找到每个按钮的id属性,并从左到右,将id属性更改为
buttonChoice1、buttonChoice2和buttonChoice3。 -
为什么不为每个按钮的文本属性输入一些任意的数字,以便设计师更准确地反映我们的游戏外观,就像我们对其他 TextView 所做的那样?再次强调,这并不是绝对必要的,因为我们的 Java 代码将控制按钮的外观。
-
我们现在实际上已经准备好继续前进。但你可能也同意,UI 元素看起来有点迷失。如果按钮和文本更大一些,看起来会更好。我们只需要调整每个 TextView 和每个按钮的 textSize 属性。然后,我们只需要找到每个元素的 textSize 属性,并输入一个带有 sp 语法的数字。如果你想你的设计看起来就像我们之前的目标设计,为每个 TextView 的 textSize 属性输入
70sp,为每个按钮的 textSize 属性输入40sp。当你在你真实设备上运行游戏时,你可能想要回来调整大小上下一点。但在我们真正尝试我们的游戏之前,我们还有更多的事情要做。 -
保存项目后,我们就可以继续下一步了。
如前所述,我们已经构建了我们的用户界面。然而,这次,我们给 UI 的所有重要部分都分配了一个独特、有用且易于识别的 ID。正如我们将看到的,我们现在能够通过 Java 代码与我们的 UI 进行通信。
在 Java 中编写一个问题
根据我们目前对 Java 的了解,我们还没有能力完成我们的数学游戏,但我们可以迈出重要的一步。我们将探讨如何向玩家提问并提供一些多项选择题答案(一个正确答案和两个错误答案)。
在这个阶段,我们已经有足够的 Java 知识来声明和初始化一些变量,这些变量将存储问题的各个部分。例如,如果我们想提出乘法表问题2 x 2,我们可以有以下的变量初始化来存储问题的每个部分的值:
int partA = 2;
int partB = 2;
之前的代码声明并初始化了两个整型变量,每个变量的值都为 2。我们使用int类型,因为我们不会处理任何小数。记住,变量名是任意的,只是因为它们看起来合适。显然,任何值得下载的数学游戏都需要提出更多样化和高级的问题,而不仅仅是2 x 2,但这是一个开始。
现在我们知道我们的数学游戏将提供多个答案选项。因此,我们需要一个变量来存储正确答案,以及两个变量来存储两个错误答案。看看以下合并的声明和初始化:
int correctAnswer = partA * partB;
int wrongAnswer1 = correctAnswer - 1;
int wrongAnswer2 = correctAnswer + 1;
注意,错误答案变量的初始化依赖于正确答案的值,并且错误答案变量是在correctAnswer变量初始化之后初始化的。
现在我们需要将这些存储在变量中的值放入我们的 UI 上适当的位置。问题变量(partA和partB)需要在 UI 元素textPartA和textPartB中显示,而答案变量(correctAnswer、wrongAnswer1和wrongAnswer2)需要在具有以下 ID 的 UI 元素中显示:buttonChoice1、buttonChoice2和buttonChoice3。我们将在下一个逐步教程中看到如何做到这一点。我们还将实现之前讨论过的变量声明和初始化代码:
-
首先,在编辑器窗口中打开
GameActivity.java。记住,您可以通过在java文件夹中双击GameActivity或点击编辑器窗口上方的标签来实现这一点,如果GameActivity.java已经打开。 -
所有的代码都将放入
onCreate方法中。它将放在setContentView(R.layout.activity_game);行之后,但在onCreate方法的闭合花括号}之前。也许,留一个空白行以增加清晰度,并添加一个像以下代码中那样的良好解释性注释是个好主意。我们可以看到经过最新修改后的整个onCreate方法。粗体部分是需要添加的内容。如果您愿意,可以添加像我一样的有帮助的注释:@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //The next line loads our UI design to the screen setContentView(R.layout.activity_game); //Here we initialize all our variables int partA = 9; int partB = 9; int correctAnswer = partA * partB; int wrongAnswer1 = correctAnswer - 1; int wrongAnswer2 = correctAnswer + 1; }//onCreate ends here -
现在我们需要将变量中包含的值添加到我们的 UI 的
TextView和Button中。但在做之前,我们需要获取我们创建的 UI 元素。我们通过创建适当类的一个变量,并通过适当 UI 元素的 ID 属性来链接它来实现这一点。我们已经知道我们的 UI 元素的类:TextView和Button。以下是创建我们每个必要 UI 元素的特殊类变量的代码。仔细查看代码,但如果你现在不完全理解也没有关系。一旦一切正常工作,我们将详细分析代码。在输入上一步代码之后立即输入代码。如果你愿意,可以留一个空白行以增加清晰度。在你继续之前,请注意,在编写此代码时,你将在两个地方被提示导入另一个类。请在这两个场合都进行导入:/*Here we get a working object based on either the button or TextView class and base as well as link our new objects directly to the appropriate UI elements that we created previously*/ TextView textObjectPartA = (TextView)findViewById(R.id.textPartA); TextView textObjectPartB = (TextView)findViewById(R.id.textPartB); Button buttonObjectChoice1 = (Button)findViewById(R.id.buttonChoice1); Button buttonObjectChoice2 = (Button)findViewById(R.id.buttonChoice2); Button buttonObjectChoice3 = (Button)findViewById(R.id.buttonChoice3);注意
在前面的代码中,如果你阅读了多行注释,你会看到我使用了术语对象。当我们基于一个类创建一个变量类型时,我们称之为对象。一旦我们有一个类的对象,我们就可以做该类设计能做的任何事情。这是非常强大的,这在第六章 面向对象编程 – 利用他人的辛勤工作中得到了充分的探讨。
-
现在我们有五个新的对象与我们需要操作的 UI 元素相关联。我们究竟打算如何使用它们呢?我们需要在 UI 元素的文本中显示变量的值。我们可以使用我们刚刚创建的对象以及由类提供的方法,并将我们的变量作为文本的值。像往常一样,我们将在本教程的末尾进一步分析这段代码。以下是紧接上一步代码之后的代码。在我们一起查看之前,试着弄清楚正在发生什么:
//Now we use the setText method of the class on our objects //to show our variable values on the UI elements. //Just like when we output to the console in the exercise - //Expressions in Java, only now we use setText method //to put the values in our variables onto the actual UI. textObjectPartA.setText("" + partA); textObjectPartB.setText("" + partB); //which button receives which answer, at this stage is arbitrary. buttonObjectChoice1.setText("" + correctAnswer); buttonObjectChoice2.setText("" + wrongAnswer1); buttonObjectChoice3.setText("" + wrongAnswer2); -
保存你的工作。
如果你调整partA和partB的赋值,你可以将它们设置为任何你喜欢的值,游戏会相应地调整答案。显然,我们不应该每次想要新的问题都需要重新编程我们的游戏,我们很快就会解决这个问题。我们现在需要做的是将我们刚刚创建的游戏部分链接到起始屏幕菜单。我们将在下一教程中这样做。
现在我们更详细地探索我们代码中更复杂和较新的部分。
在第 2 步中,我们声明并初始化了迄今为止所需的变量:
//Here we initialize all our variables
int partA = 9;
int partB = 9;
int correctAnswer = partA * partB;
int wrongAnswer1 = correctAnswer - 1;
int wrongAnswer2 = correctAnswer + 1;
然后在第 3 步中,我们通过 Java 代码获取了我们的 UI 设计参考。对于 TextViews,操作如下:
TextView textObjectPartA = (TextView)findViewById(R.id.textPartA);
对于每个按钮,我们通过以下方式获取了我们的 UI 设计参考:
Button buttonObjectChoice1 =
(Button)findViewById(R.id.buttonChoice1);
在第 4 步中,我们做了些新的事情。我们使用了setText方法来在我们的 UI 元素(TextView和Button)上向玩家显示变量的值。让我们逐行分析,看看它是如何工作的。以下是显示correctAnswer变量在buttonObjectChoice1上显示的代码。
buttonObjectChoice1.setText("" + correctAnswer);
通过在以下代码行中输入buttonObjectChoice1并添加一个点,我们可以访问 Android 提供的该对象类类型的所有预编程方法:
buttonObjectChoice1.
提示
按钮和 Android API 的力量
实际上,有很多方法可以用于按钮类型的对象。如果你感到勇敢,尝试以下操作,以了解 Android 中功能性的丰富程度。
输入以下代码:
buttonObjectChoice1.
一定要在末尾输入句号。Android Studio 会弹出一个列表,显示可以在此对象上使用的可能方法。滚动列表,感受一下选项的数量和多样性:

如果一个普通的按钮可以做到这一切,想想当我们掌握了 Android 中包含的所有类之后,我们的游戏会有多少可能性。设计供他人使用的类集合统称为应用程序编程接口(API)。欢迎来到 Android API!
在这个例子中,我们只想设置按钮的文本。因此,我们使用setText并将存储在我们correctAnswer变量中的值连接到空字符串的末尾,如下所示:
setText("" + correctAnswer);
我们为需要显示的每个 UI 元素都这样做。
提示
使用自动完成功能
如果你尝试了之前的提示按钮和 Android API 的力量,并探索了按钮类型对象的可用方法,你将已经对自动完成有一些了解。请注意,当你输入时,Android Studio 会不断为你可能想要输入的下一个内容提供建议。如果你注意这一点,可以节省很多时间。只需选择建议的正确代码完成语句并按Enter键。你甚至可以通过从菜单栏选择帮助 | 生产力指南来查看你节省了多少时间。在这里,你可以看到代码完成每个方面的统计数据以及更多内容。以下是我的一些条目:

如您所见,如果您早期就习惯了使用快捷键,从长远来看可以节省很多时间。
将我们的游戏链接到主菜单
目前,如果我们运行应用程序,我们没有让玩家实际到达我们的新游戏活动的方法。我们希望当玩家点击主MainActivity UI 上的Play按钮时,游戏活动能够运行。为了实现这一点,我们需要做以下事情:
-
打开文件
activity_main.xml,可以通过在项目资源管理器中双击它,或者在编辑器窗口中点击其标签来实现。 -
现在,就像我们在构建游戏 UI 时做的那样,为Play按钮分配一个 ID。作为提醒,在 UI 设计或组件树中点击Play按钮。在属性窗口中找到id属性。将其分配给
buttonPlay。现在我们可以通过在我们的 Java 代码中引用它来让这个按钮执行操作。 -
通过在项目资源管理器中双击或点击编辑器窗口中的标签来打开
MainActivity.java文件。 -
在我们的
onCreate方法中,就在我们setContentView的行之后,添加以下突出显示的代码行:setContentView(R.layout.activity_main); Button buttonPlay = (Button)findViewById(R.id.buttonPlay); -
一旦我们使这个代码工作,我们就会详细剖析它。基本上,我们通过创建一个指向
Button对象的引用变量来与播放按钮建立连接。注意,这两个词都被用红色突出显示,表示有错误。就像之前一样,我们需要导入Button类来使代码工作。使用Alt + Enter键盘组合。现在点击弹出的选项列表中的导入类。这将在我们的MainActivity.java文件顶部自动添加所需的导入指令。 -
现在来点新的。我们将给按钮添加一个能力,使其能够监听用户对其的点击。在我们输入的最后一行代码之后立即输入以下内容:
buttonPlay.setOnClickListener(this); -
注意到
this关键字被用红色突出显示,表示有错误。这引入了 Java 的另一个特性,我们将在第六章,面向对象编程 – 使用他人的辛勤工作中更详细地探讨。暂且不谈这个,我们现在需要修改我们的代码,以便使用一个特殊的代码元素,即接口,它允许我们添加功能,例如监听按钮点击。按照以下方式编辑这一行。当提示导入另一个类时,点击确定:public class MainActivity extends Activity {to
public class MainActivity extends Activity implements View.OnClickListener{现在我们整行都被红色下划线标出。这表示有错误,但这是我们在这个阶段应该所在的位置。我们提到,通过添加
implements View.OnClickListener,我们已经实现了一个接口。我们可以将其视为一个我们可以使用的类,但带有额外的规则。OnClickListener接口的规则指出,我们必须实现/使用其方法之一。注意,到目前为止,我们已经根据需要选择性地覆盖/使用方法。如果我们希望使用这个接口提供的功能,即监听按钮按下,那么我们必须添加/实现onClick方法。 -
这就是我们这样做的方式。注意开括号
{和闭括号}。这些表示方法的开始和结束。注意,这个方法是空的,它不做任何事情,但一个空的方法就足以符合OnClickListener接口的规则,并且表示我们的代码有错误的红色线条已经消失了。我们一直在使用的这些方法的语法,正如承诺的那样,将在我们开始编写自己的方法时在第六章中解释。确保你在onCreate方法的闭括号}之外、MainActivity类的闭括号}之内输入以下代码:@Override public void onClick(View view) { } -
注意,我们在
onClick方法的{和}之间有一个空行。我们现在可以在这里添加代码,使按钮真正做些事情。在onClick的{和}之间输入以下突出显示的代码:@Override public void onClick(View view) { Intent i; i = new Intent(this, GameActivity.class); startActivity(i); } -
好吧,所以这段代码一次性理解起来有点困难。看看你是否能猜出发生了什么。线索在名为
startActivity的方法和希望熟悉的术语GameActivity中。注意,我们在将某个东西分配给i。我们将快速使应用程序工作,然后全面诊断代码。当我们探索第六章类的工作方式时,理解将完整。 -
注意,我们有一个错误:单词
Intent的所有实例都是红色的。我们可以通过导入使Intent工作所需的类来解决此问题。像以前一样,按Alt + Enter。 -
在模拟器或您的设备上运行游戏。
我们的应用程序现在将正常工作。这是在菜单屏幕上按下播放后新游戏屏幕的外观:

几乎我们代码的每一部分都有所改变,我们也添加了很多内容。让我们逐行查看MainActivity.java的内容。为了提供上下文,以下是它的完整内容:
package com.packtpub.mathgamechapter3a.mathgamechapter3a;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
public class MainActivity extends Activity implements View.OnClickListener{
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final Button buttonPlay = (Button)findViewById(R.id.buttonPlay);
buttonPlay.setOnClickListener(this);
}
@Override
public void onClick(View view) {
Intent i;
i = new Intent(this, GameActivity.class);
startActivity(i);
}
}
我们之前已经看到了很多这样的代码,但在继续之前,让我们逐块地浏览它,以确保它绝对清晰。代码的工作方式如下:
package com.packtpub.mathgamechapter3a.mathgamechapter3a;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
你可能还记得,这个代码块定义了我们的包名,并提供了我们为按钮、文本视图和活动所需的全部 Android API 功能。
从我们的MainActivity.java文件中,我们有以下内容:
public class MainActivity extends Activity implements View.OnClickListener{
我们使用新代码声明的MainActivity实现了View.OnClickListener接口,这使我们能够检测按钮点击。
在我们的代码中接下来是:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
这段之前的代码自第二章以来没有变化,即“Android 入门”。它位于我们的onCreate方法的开头,我们首先使用super.onCreate(savedInstanceState);调用onCreate的隐藏代码来执行其功能。然后我们使用setContentView(R.layout.activity_main);将我们的 UI 设置到屏幕上。
接下来,我们通过 ID 为buttonPlay获取我们的按钮引用:
Button buttonPlay = (Button)findViewById(R.id.buttonPlay);
buttonPlay.setOnClickListener(this);
最后,我们的onClick方法使用Intent类在用户点击播放按钮时将玩家发送到我们的GameActivity类和相关 UI:
@Override
public void onClick(View view) {
Intent i;
i = new Intent(this, GameActivity.class);
startActivity(i);
}
如果运行应用程序,您会注意到我们现在可以点击播放按钮,我们的数学游戏会向我们提问。当然,我们目前还不能回答它。尽管我们非常简要地了解了如何处理按钮点击,但我们需要学习更多的 Java 知识,以便智能地响应它们。我们还将揭示如何编写处理多个按钮点击的代码。这将是接收来自以多选题为中心的game_activity UI 输入所必需的。
Java 中的决策
我们现在可以召唤足够的 Java 能力来提问,但一个真正的数学游戏显然必须做更多的事情。我们需要捕捉玩家的答案,我们几乎做到了——我们可以检测按钮的按下。从那里,我们需要能够决定他们的答案是对是错。然后,基于这个决定,我们必须选择适当的行动方案。
让我们暂时把数学游戏放在一边,看看 Java 如何通过学习更多 Java 语言的基础知识和语法来帮助我们。
更多运算符
让我们看看一些更多的运算符:我们已经有加法(+)、减法(-)、乘法(*)、除法(/)、赋值(=)、递增(++)、比较(==)和递减(--)等运算符了。现在让我们介绍一些更超级有用的运算符,然后我们将直接了解如何在 Java 中实际使用它们。
小贴士
不要担心记住这里给出的每一个运算符。快速浏览它们及其解释,然后迅速进入下一节。在那里,我们将使用一些运算符,随着我们看到一些它们能做什么的例子,它们将变得更加清晰。这里将它们列出来,只是为了从一开始就清楚地展示运算符的多样性和范围。列表也将更方便在随后的实现讨论中参考。
-
==: 这是我们在之前非常简短地提到过的比较运算符。它用来测试相等性,结果要么为真要么为假。例如,表达式
(10 == 9);是错误的。 -
!: 逻辑非运算符。表达式
! (2+2==5).)是正确的,因为 2+2 不等于 5。 -
!=: 这是另一个比较运算符,用来测试某个值是否不等于另一个值。例如,表达式
(10 != 9);是正确的,也就是说 10 不等于 9。 -
>: 这是另一个比较运算符,用来测试某个值是否大于另一个值。表达式
(10 > 9);是正确的。还有一些其他的比较运算符。 -
<: 你猜对了。这个运算符用来测试左边的值是否小于右边的值。表达式
(10 < 9);是错误的。 -
>=: 这个运算符用来测试一个值是否大于或等于另一个值,如果任一条件为真,结果为真。例如,表达式
(10 >= 9);是正确的。表达式(10 >= 10);也是正确的。 -
<=: 和前面的运算符类似,这个运算符测试两个条件,但这次是小于或等于。表达式
(10 <= 9);是错误的。表达式(10 <= 10);是正确的。 -
&&: 这个操作符被称为逻辑与。它测试表达式中的两个或多个独立部分,并且所有部分都必须为真,结果才为真。逻辑与通常与其他操作符结合使用,以构建更复杂的测试。表达式
((10 > 9) && (10 < 11));为真,因为两部分都是真的。表达式((10 > 9) && (10 < 9));为假,因为表达式中只有一部分为真,另一部分为假。 -
||: 这个操作符被称为逻辑或。它与逻辑与类似,只是表达式中的两个或多个部分中只需要一个为真,表达式就为真。让我们看看我们使用的最后一个例子,但将 && 符号替换为 ||。表达式
((10 > 9) || (10 < 9));现在为真,因为表达式的一部分为真。
所有这些操作符在没有适当使用它们来做出影响真实变量和代码的实际决策的情况下几乎毫无用处。让我们看看如何在 Java 中做出决策。
决策 1 – 如果他们过桥,就射击他们
正如我们所见,操作符本身几乎没有用途,但看到我们可用的广泛而多样的范围的一部分可能是有用的。现在,当我们考虑使用最常用的操作符 == 时,我们可以开始看到操作符提供的强大而精细的控制。
让我们通过一个有趣的故事和一些代码,使用 Java 的 if 关键字和一些条件操作符来使前面的例子更具体。
上尉奄奄一息,知道他剩下的下属经验不足,他决定在他死后编写一个 Java 程序来传达他的最后命令。部队必须守住桥的一侧,等待增援。
上尉想要确保他的部队理解的第一条命令是:如果他们过桥,就射击他们。
那么,我们如何在 Java 中模拟这种情况?我们需要一个布尔变量 isComingOverBridge。接下来的代码段假设 isComingOverBridge 变量已经被声明并初始化。
我们可以像这样使用它:
if(isComingOverBridge){
//Shoot them
}
如果 isComingOverBridge 布尔值为真,则在大括号内的代码将执行。如果不是,程序将在 if 块之后继续执行,而不运行它。
决策 2 – 否则,这样做
上尉还想要告诉他的部队(原地待命),如果敌人没有过桥。
现在我们引入另一个 Java 关键字 else。当我们想要明确执行某些操作,而 if 块不评估为真时,我们可以使用 else。
例如,如果敌人没有过桥,告诉部队原地待命,我们使用 else:
if(isComingOverBridge){
//Shoot them
}else{
//Hold position
}
上尉随后意识到问题并不像他最初想的那么简单。如果敌人过桥并且有更多的部队怎么办?他的小队将被包围。因此,他想出了以下代码(这次我们也会使用一些变量):
boolean isComingOverTheBridge;
int enemyTroops;
int friendlyTroops;
//Code that initializes the above variables one way or another
//Now the if
if(isComingOverTheBridge && friendlyTroops > enemyTroops){
//shoot them
}else if(isComingOverTheBridge && friendlyTroops < enemyTroops) {
//blow the bridge
}else{
//Hold position
}
最后,船长的最后一个担忧是,如果敌人挥舞着白旗投降并立即被屠杀,那么他的手下最终会成为战争罪犯。所需的 Java 代码很明显。使用他写的wavingWhiteFlag布尔变量,他编写了这个测试:
if (wavingWhiteFlag){
//Take prisoners
}
但将这段代码放在哪里并不那么清楚。最后,船长选择了以下嵌套解决方案,并将wavingWhiteFlag的测试改为逻辑非,如下所示:
if (!wavingWhiteFlag){//not surrendering so check everything else
if(isComingOverTheBridge && friendlyTroops > enemyTroops){
//shoot them
}else if(isComingOverTheBridge && friendlyTroops < enemyTroops) {
//blow the bridge
}
}else{//this is the else for our first if
//Take prisoners
{
//Holding position
这表明我们可以将if和else语句嵌套在一起,以创建更深层次的决策。
我们可以继续做出越来越复杂的决策,但我们所看到的已经足够作为入门。如果有任何不清楚的地方,请花时间重新阅读。谁知道呢,也许在章节末尾的自我测试中甚至有一个棘手的逻辑问题。重要的是要指出,通常情况下,到达解决方案的方法有两条或更多。正确的方法通常是以最清晰、最简单的方式解决问题的方法。
使用switch进行决策
我们已经看到了将 Java 运算符与if和else语句结合使用的广泛且几乎无限的组合可能性。但有时 Java 中的决策可以通过其他方式更好地做出。
当我们必须根据一个清晰的、不涉及复杂组合的可能性列表做出决策时,通常使用switch是最佳选择。
我们这样开始一个switch决策:
switch(argument){
}
在上一个示例中,参数可以是一个表达式或一个变量。然后在大括号内,我们可以根据参数使用 case 和 break 元素来做出决策:
case x:
//code to for x
break;
case y:
//code for y
break;
你可以看到在上一个示例中,每个 case 都声明了一个可能的结果,每个 break 都表示该 case 的结束,以及不应再评估后续 case 语句的点。第一个遇到的 break 将我们带出 switch 块,继续执行下一行代码。
我们还可以使用不带值的default来运行一些代码,如果没有任何 case 语句评估为 true,如下所示:
default://Look no value
//Do something here if no other case statements are true
break;
假设我们正在编写一个老式的文字冒险游戏——这类游戏中玩家输入命令,如"Go East"、"Go West"、"Take Sword"等。在这种情况下,switch 可以像以下示例代码那样处理这种情况,并且我们可以使用default来处理玩家输入的未特别处理的命令的情况:
//get input from user in a String variable called command
switch(command){
case "Go East":
//code to go east
break;
case "Go West":
//code to go west
break;
case "Take sword":
//code to take the sword
break;
//more possible cases
default:
//Sorry I don't understand your command
break;
}
在下一节中,我们将使用switch来确保我们的onClick方法能够处理数学游戏中的不同多选题按钮。
小贴士
Java 有比我们这里涵盖的还要多的运算符。我们已经看到了这本书中将要需要的所有运算符,以及最常用的运算符。如果你想了解运算符的完整信息,请查看官方 Java 文档docs.oracle.com/javase/tutorial/java/nutsandbolts/operators.html。
数学游戏 – 获取和检查答案
在这里,我们将检测正确或错误的答案,并向玩家提供弹出消息。我们的 Java 代码现在相当不错了,所以让我们深入其中并添加这些功能。我会边走边解释,然后在结束时,像往常一样,彻底剖析代码。
已经完成的代码在下载包中,在以下文件中,这些文件对应于我们将在 Android Studio 中创建/自动生成的文件名:
-
Chapter3/MathGameChapter3b/java/MainActivity.java -
Chapter3/MathGameChapter3b/java/GameActivity.java -
Chapter3/MathGameChapter3b/layout/activity_main.xml -
Chapter3/MathGameChapter3b/layout/activity_game.xml
像往常一样,我建议您一步一步地跟随这个教程,看看我们如何自己创建所有的代码。
-
打开编辑器窗口中可见的
GameActivity.java文件。 -
现在我们需要给
GameActivity添加点击检测功能,就像我们为MainActivity做的那样。然而,这次我们会做得更深入一些。所以让我们一步一步地做,就像它是全新的。再一次,我们将给按钮添加用户点击它们的能力。在输入onCreate方法中的最后一行代码之后,但在关闭的}之前立即输入以下代码。这次当然,我们需要添加一些代码来监听三个按钮:buttonObjectChoice1.setOnClickListener(this); buttonObjectChoice2.setOnClickListener(this); buttonObjectChoice3.setOnClickListener(this); -
注意到
this关键字被红色突出显示,表示一个错误。再次,我们需要对我们的代码进行修改,以便使用接口,这是一个特殊的代码元素,它允许我们添加诸如监听按钮点击等功能。将这一行编辑如下。当提示导入另一个类时,点击 确定。考虑以下代码行:public class GameActivity extends Activity {改成以下行:
public class GameActivity extends Activity implements View.OnClickListener{ -
现在我们已经用红色划下了整行。这表示有一个错误,但这也是我们目前应该所在的位置。我们提到,通过添加
implements View.OnClickListener,我们已经实现了一个接口。我们可以把它想象成一个我们可以使用的类,但带有额外的规则。OnClickListener接口的一个规则是我们必须实现其方法之一,正如你可能记得的那样。现在我们将添加onClick方法。 -
输入以下代码。注意开括号
{和闭括号},它们表示方法的开始和结束。注意,这个方法是空的;它不做任何事情,但一个空的方法就足以符合OnClickListener接口的规则,以及表示错误的红色线条已经消失。确保你在onCreate方法的闭括号}之外,但GameActivity类的闭括号之内输入以下代码:@Override public void onClick(View view) { } -
注意,我们在
onClick方法的{和}大括号之间有一个空行。现在我们可以在其中放入一些代码,使按钮真正做些事情。在onClick的{和}之间输入以下内容。这里与我们的MainActivity中的代码有所不同。我们需要区分可能被按下的三个按钮。我们将使用之前讨论过的switch语句来完成这个任务。查看case条件;它们应该看起来很熟悉。以下是使用switch语句的代码:switch (view.getId()) { case R.id.buttonChoice1: //button 1 stuff goes here break; case R.id.buttonChoice2: //button 2 stuff goes here break; case R.id.buttonChoice3: //button 3 stuff goes here break; } -
每个
case元素处理不同的按钮。对于每个按钮情况,我们需要获取刚刚按下的按钮中存储的值,并查看它是否与我们的correctAnswer变量匹配。如果是,我们必须告诉玩家他们答对了,如果不是,我们必须告诉他们答错了。然而,我们仍然有一个问题需要解决。onClick方法与onCreate方法以及按钮对象是分开的。实际上,所有变量都是在onCreate方法中声明的。如果你现在尝试输入步骤 9 中的代码,你会得到很多错误。我们需要在onClick中使所有需要的变量都可用。为此,我们将它们的声明从onCreate方法上方移动到GameActivity的开头{下方。这意味着这些变量成为GameActivity类的变量,可以在GameActivity的任何地方访问。以下是这样声明这些变量的方式:int correctAnswer; Button buttonObjectChoice1; Button buttonObjectChoice2; Button buttonObjectChoice3; -
现在按照以下方式更改
onCreate方法中这些变量的初始化。需要更改的实际代码部分已突出显示。其余部分是为了说明上下文://Here we initialize all our variables int partA = 9; int partB = 9; correctAnswer = partA * partB; int wrongAnswer1 = correctAnswer - 1; int wrongAnswer2 = correctAnswer + 1;和
TextView textObjectPartA = (TextView)findViewById(R.id.textPartA); TextView textObjectPartB = (TextView)findViewById(R.id.textPartB); buttonObjectChoice1 = (Button)findViewById(R.id.buttonChoice1); buttonObjectChoice2 = (Button)findViewById(R.id.buttonChoice2); buttonObjectChoice3 = (Button)findViewById(R.id.buttonChoice3); -
这里是我们的
onClick方法顶部以及onClick方法的第一个case语句:@Override public void onClick(View view) { //declare a new int to be used in all the cases int answerGiven=0; switch (view.getId()) { case R.id.buttonChoice1: //initialize a new int with the value contained in buttonObjectChoice1 //Remember we put it there ourselves previously answerGiven = Integer.parseInt("" + buttonObjectChoice1.getText()); //is it the right answer? if(answerGiven==correctAnswer) {//yay it's the right answer Toast.makeText(getApplicationContext(), "Well done!", Toast.LENGTH_LONG).show(); }else{//uh oh! Toast.makeText(getApplicationContext(),"Sorry that's wrong", Toast.LENGTH_LONG).show(); } break; -
这里是其余的
case语句,它们执行与上一步骤中代码相同的步骤,除了处理最后两个按钮。在上一步骤输入的代码之后输入以下代码:case R.id.buttonChoice2: //same as previous case but using the next button answerGiven = Integer.parseInt("" + buttonObjectChoice2.getText()); if(answerGiven==correctAnswer) { Toast.makeText(getApplicationContext(), "Well done!", Toast.LENGTH_LONG).show(); }else{ Toast.makeText(getApplicationContext(),"Sorry that's wrong", Toast.LENGTH_LONG).show(); } break; case R.id.buttonChoice3: //same as previous case but using the next button answerGiven = Integer.parseInt("" + buttonObjectChoice3.getText()); if(answerGiven==correctAnswer) { Toast.makeText(getApplicationContext(), "Well done!", Toast.LENGTH_LONG).show(); }else{ Toast.makeText(getApplicationContext(),"Sorry that's wrong", Toast.LENGTH_LONG).show(); } break; } -
运行程序后,我们将仔细查看代码,特别是那个看起来很奇怪的
Toast东西。点击最左边的按钮时会发生以下情况:![数学游戏 – 获取并检查答案]()
这就是我们做到的:在步骤 1 到 6 中,我们设置了多选按钮的处理,包括使用 onClick 方法添加监听点击的能力,以及使用 switch 块来处理根据按下的按钮所做的决策。
在步骤 7 和 8 中,我们必须修改我们的代码,以便在 onClick 方法中使我们的变量可用。我们通过使它们成为 GameActivity 类的成员变量来实现这一点。
小贴士
当我们将一个变量作为类的一个成员时,我们称之为 字段。我们将在 第六章 面向对象编程 – 利用他人的辛勤工作 中讨论变量何时应该是一个字段,何时不应该。
在步骤 9 和 10 中,我们在onClick中的switch语句中实现了实际执行工作的代码。让我们逐行查看当按下button1时运行的代码。
case R.id.buttonChoice1:
首先,当按下具有buttonChoice1 ID 的按钮时,case语句为真。然后,接下来要执行的代码行是:
answerGiven = Integer.parseInt(""+ buttonObjectChoice1.getText());
前一行使用两种方法获取按钮上的值。首先,getText方法将数字作为字符串获取,然后Integer.parseInt将其转换为整数。该值存储在我们的answerGiven变量中。接下来的代码执行如下:
if(answerGiven==correctAnswer) {//yay it's the right answer
Toast.makeText(getApplicationContext(), "Well done!", Toast.LENGTH_LONG).show();
}else{//uh oh!
Toast.makeText(getApplicationContext(),"Sorry that's wrong", Toast.LENGTH_LONG).show();
}
if语句使用==运算符检查answerGiven变量是否与correctAnswer相同。如果是这样,就使用Toast对象的makeText方法来显示一条祝贺信息。如果两个变量的值不相同,显示的消息会稍微负面一些。
注意
Toast代码行可能是我们迄今为止见过的最邪恶的东西。它看起来异常复杂,并且确实需要比我们目前所掌握的 Java 知识更深入的了解才能理解。现在我们只需要知道,我们可以直接使用这段代码,只需更改消息,它就是一个宣布给玩家消息的伟大工具。到第六章 面向对象编程 – 使用他人的辛勤工作 的结尾,Toast的代码将会清晰。如果你现在真的想要一个解释,你可以这样想:当我们创建按钮对象时,我们得到了使用所有按钮方法的机会。但是,对于 Toast,我们直接使用类来访问其makeText方法,而不需要首先创建一个对象。我们可以在类及其方法设计为允许这样做的时候进行这个过程。
最后,我们按照以下方式跳出整个switch语句:
break;
现在我们已经用本章学到的知识尽可能改进了项目,为什么不测试一下到目前为止你所学的所有知识?
自我测试问题
Q1) 这段代码做了什么?
// setContentView(R.layout.activity_main);
Q2) 哪一行代码会导致错误?
String a = "Hello";
String b = " Vinton Cerf";
int c = 55;
a = a + b
c = c + c + 10;
a = a + c;
c = c + a;
Q3) 我们讨论了很多关于运算符的内容,以及如何将不同的运算符组合起来构建复杂的表达式。乍一看,表达式可能会让代码看起来很复杂。然而,仔细观察后,它们并没有看起来那么困难。通常,只需将表达式拆分成更小的部分,就可以弄清楚发生了什么。这里有一个比本书中任何其他表达式都更复杂的表达式。作为一个挑战,你能计算出:x将会是多少?
int x = 10;
int y = 9;
boolean isTrueOrFalse = false;
isTrueOrFalse = (((x <=y)||(x == 10))&&((!isTrueOrFalse) || (isTrueOrFalse)));
摘要
本章我们涵盖了很多内容。我们从对 Java 语法一无所知,到学习注释、变量、运算符和决策。
就像任何语言一样,通过简单的练习、学习和增加我们的词汇量,我们可以掌握 Java。在这个时候,我们可能会倾向于等到当前 Java 语法的掌握之后再继续前进,但最好的方法是同时在回顾我们已经开始学习的内容的同时,继续学习新的语法。
在下一章中,我们将通过添加多种难度级别的随机问题以及为多选题按钮使用更合适和随机的错误答案,最终完成我们的数学游戏。
为了使我们能够做到这一点,我们首先将学习一些更多的 Java。
第四章:发现循环和方法
在本章中,我们将通过查看 Java 中不同类型的循环(包括while循环、do-while循环和for循环)来学习如何以受控和精确的方式重复执行我们的代码的一部分。我们将了解在不同情况下使用不同类型循环的最佳时机。
然后,我们将简要介绍随机数的话题。我们还将看到如何使用 Java 的Random类。这显然对我们的数学游戏有很大的帮助。
接下来,我们将研究方法。它们允许我们将代码分成更易于管理的块。然后我们将看到如何在不同方法之间共享数据,以及如何将编程任务分解以简化问题。
然后,我们将把关于循环、随机数和方法的所有知识应用到我们的数学游戏项目中。例如,我们将使游戏在每次尝试回答后更改问题。
我们还将添加问题难度级别和适合给定难度级别的随机问题。我们将展示并更新我们的分数。根据回答问题的难度级别(正确回答),分数上升得更快。最终,即使是我们中间最好的数学家也应该被游戏打败。然而,我们大多数人希望比下一张截图显示的更前进一点。
如果玩家回答错误,难度将回到最简单级别,分数归零。这就是我们完成游戏后的样子:

在本章中,我们将:
-
学习多种类型循环中的循环
-
学习如何在 Java 中生成随机数
-
了解 Java 方法的一切,包括如何编写和调用它们
-
显著提高我们的数学游戏
循环中的循环
提问循环与编程有什么关系是完全合理的,但它们正是名字所暗示的。它们是一种执行代码相同部分多次或循环相同代码部分的方式,但每次可能得到不同的结果。
这可以简单地意味着重复做同样的事情,直到被循环的代码提示循环结束。它可能在循环代码本身指定的预定次数后提示循环结束。它也可能在满足预定的某种情况或条件时提示循环结束。或者,可能有多种方式组合来提示循环结束。与 if、else 和 switch 一样,循环是 Java 控制流语句的一部分。
我们将查看 Java 提供给我们控制代码的所有主要循环类型,在我们查看方法之后,我们将使用其中一些来实现我们数学游戏的增强。让我们继续到我们第一种循环类型。
当循环
while 循环具有最简单的语法。回忆一下 第三章 中的 if 语句,说 Java – 你的第一个游戏。我们可以在 if 语句的条件表达式中放置几乎任何组合的运算符和变量。如果表达式评估为 true,则执行 if 块体内的代码。同样,在 while 循环中,我们放置一个可以评估为 true 或 false 的表达式,如以下代码所示:
int x = 10;
while(x > 0){
x--;
//x decreases by one each pass through the loop
}
这里发生的事情是在 while 循环外部,声明了一个整数 x 并将其初始化为 10。然后开始 while 循环。其条件是 x > 0,因此它将一直循环执行其体内的代码,直到条件评估为 false。因此,代码将执行 10 次。
在第一次遍历中,x 等于 10,然后是 9,然后是 8,以此类推。但是一旦 x 等于 0,显然就不再大于 0。因此,程序将退出 while 循环并继续执行循环之后的代码的第一行。
就像 if 语句一样,while 循环可能一次也不会执行。看看这个永远不会执行的 while 循环的例子:
int x = 10;
while(x > 10){
//more code here.
//but it will never run unless x is greater than 10.
}
此外,条件表达式的复杂性和循环体内可以编写的代码量都没有限制:
int playerLives = 3;
int alienShips = 10;
while(playerLives >0 && alienShips >0){
//Entire game code here.
//...
//...
//etc.
}
//continue here when either playerLives or alienShips = 0
前面的 while 循环将继续执行,直到 playerLives 或 alienShips 等于或小于零。一旦这些条件中的任何一个发生,表达式评估为 false,程序将继续从 while 循环后的第一行代码执行。
值得注意的是,一旦进入循环体,它总是会完成,即使表达式在中间某处评估为 false,因为条件不会再次检查,直到代码尝试开始另一轮循环:
int x = 1;
while(x > 0){
x--;
//x is now 0 so the condition is false
//But this line still runs
//and this one
//and me!
}
前面的循环体将只执行一次。我们也可以设置一个会无限运行的 while 循环(这毫不奇怪地被称为无限循环),如下所示:
int x = 0;
while(true){
x++; //I am going to get mighty big!
}
跳出循环
我们可能会使用一个无限循环,就像前面例子中的循环一样,这样我们就可以在循环体内决定何时退出循环。当我们准备好离开循环体时,我们会使用 break 关键字,如下面的代码所示:
int x = 0;
while(true){
x++; //I am going to get mighty big!
break; //No you're not haha.
//code doesn't reach here
}
你可能已经猜到,我们可以在 while 循环以及我们稍后将看到的任何其他循环中结合任何决策工具,如 if、else 和 switch:
int x = 0;
int tooBig = 10;
while(true){
x++; //I am going to get mighty big!
if(x == tooBig){
break;
} //No you're not haha.
//code reaches here only until x = 10
}
要继续展示 while 循环的多样性,可能会占用更多页面,但我们想回到做一些真正的编程。所以这里有一个最后的概念,与 while 循环结合使用。
continue 关键字
continue 关键字的行为与 break 类似——但仅到此为止。continue 关键字会跳出循环体,但之后也会检查条件表达式,因此循环可能会再次运行。以下示例将展示 continue 的用法:
int x = 0;
int tooBig = 10;
int tooBigToPrint = 5;
while(true){
x++; //I am going to get mighty big!
if(x == tooBig){
break;
} //No your not haha.
//code reaches here only until x = 10
if(x >= tooBigToPrint){
//No more printing but keep looping
continue;
}
//code reaches here only until x = 5
//Print out x
}
do-while 循环
do-while 循环与 while 循环非常相似,区别在于它是在体之后评估其表达式的。这意味着 do-while 循环至少会执行一次,如下面的代码所示:
int x= 0;
do{
x++;
}while(x < 10);
//x now = 10
注意
break 和 continue 关键字也可以用在 do-while 循环中。
for 循环
for 循环的语法比 while 和 do-while 循环稍微复杂一些,因为它需要三个部分来初始化。首先看看下面的 for 循环。然后我们将将其分解:
for(int i = 0; i < 10; i++){
//Something that needs to happen 10 times goes here
}
当这样表达时,for 循环的看似晦涩的形式就更加清晰了:
for(declaration and initialization; condition; change after each pass through loop)
为了进一步阐明,我们可以在 for 循环中看到以下内容:
-
声明和初始化:我们创建一个新的
int变量i并将其初始化为 0。 -
条件:就像其他循环一样,这指的是必须评估为真以使循环继续的条件。
-
循环每次通过后的变化:在前面的例子中,
i++表示在每次通过时将 1 添加到i中。我们也可以使用i--在每次通过时减少/递减i,如下面的代码所示:for(int i = 10; i > 0; i--){ //countdown } //blast off i = 0
注意
注意,break 和 continue 也可以用在 for 循环中。
for 循环本质上将初始化、条件评估和控制变量的控制权掌握在自己手中。在我们查看随机数和方法之后,我们将立即使用 for 循环来增强我们的数学游戏。
Java 中的随机数
在我们深入研究方法之前,我们首先看看我们如何可以创建随机数,因为这是我们生成随机问题的方法。
所有的艰苦工作都由 Random 类为我们完成。首先,我们需要创建一个 Random 类型的对象:
Random randInt = new Random();
然后我们使用新对象的 nextInt 方法生成一定范围内的随机数:
int ourRandomNumber = randInt.nextInt(10);
我们输入的数字的范围从零开始。因此,前面的代码将生成一个介于 0 和 9 之间的随机数。如果我们想要一个介于 1 和 10 之间的随机数,我们只需这样做:
ourRandomNumber++;
小贴士
在这些早期章节中,我们通常需要接受在像 Random 这样的对象中发生了一些魔法。在第六章“面向对象编程 – 使用他人的辛勤工作”中,我们将打开黑盒子,甚至制作自己的。我们将能够编写自己的类,并在这些类中编写自己的方法。
一个好的开始是查看常规的香草方法,我们将在下一节中这样做。
方法
那么,Java 方法究竟是什么呢?方法是一系列变量、表达式和控制流语句的集合。我们已经使用了很多方法;我们只是还没有深入查看过它们。
在我们开始实际操作并使用所学知识来增强我们的数学游戏之前,了解 Java 方法将是本章的最后一个主题。
方法结构
我们所写的每个方法的第一部分被称为签名。以下是一个虚构的签名示例:
public boolean shootLazers(int number, string type)
添加一对大括号,并在其中放置一些方法执行的操作代码,我们就有了完整的方法,或者称为定义。以下是一个虚构但语法正确的示例方法:
private void setCoordinates(int x, int y){
//code to set coordinates goes here
}
我们然后可以使用我们的新方法从代码的另一个部分,如下所示:
//I like it here
setCoordinates(4,6);//now I am going off to setCoordinates method
//Phew, I'm back again - code continues here
在调用setCoordinates的点,程序的执行将分支到该方法内的代码,该代码将运行,直到它到达末尾或被指示返回。然后代码将从方法调用后的第一行继续运行。
这里是另一个方法的示例,包括使方法返回到调用它的代码的代码:
int addAToB(int a, int b){
int answer = a + b;
return answer;
}
使用前面方法的调用可能看起来像这样:
int myAnswer = addAToB(2,4);
显然,我们不需要编写方法来将两个int变量相加,但前面的例子帮助我们更深入地了解了方法的工作原理。首先,我们传递了值2和4。在方法的签名中,值2被分配给int a,而值4被分配给int b。
在方法体内部,变量a和b被添加并用于初始化一个新的变量,这个新变量是int类型的answer。return answer这一行正是这样做的。它将存储在answer中的值返回给调用代码,导致myAnswer被初始化为6的值。
注意,前面示例中的每个方法签名都有所不同。这是因为 Java 方法签名非常灵活,允许我们构建我们需要的精确方法。
方法签名如何定义方法必须如何调用,以及方法必须返回值(如果必须的话),这值得进一步讨论。让我们给签名的每个部分起一个名字,这样我们就可以将其分解成块,并分别了解各个部分。
小贴士
这里有一个带有标签和准备讨论的方法签名。您还可以查看以下表格,以进一步确定签名的哪个部分是哪个。这将使我们对方法的讨论更加直接。
修饰符 | 返回类型 | 方法名称(参数)
这里有一些我们迄今为止使用的例子,以便您可以清楚地识别正在讨论的签名部分:
| 签名部分 | 示例 |
|---|---|
| 修饰符 | public、private等等 |
| 返回类型 | int、boolean、float等等,或任何 Java 类型、表达式或对象 |
| 方法名称 | shootLazers、setCoordinates、addAToB等等 |
| 参数 | (int number,string type),(int x,int y),(int a,int b)等等 |
修饰符
在我们之前的例子中,我们只使用了修饰符两次,部分原因是该方法不必使用修饰符。修饰符是一种指定哪些代码可以使用您的方法的方式。一些修饰符的类型是public和private。实际上,常规变量也可以有修饰符,例如这些:
//Most code can see me
public int a;
//Code in other classes can't see me
private string secret = "Shhh, I am private";
(方法与变量的)修饰符是 Java 的一个基本主题,但最好在我们讨论其他重要的 Java 主题时处理,这些主题我们迄今为止已经绕过几次——对象和类。
注意
如前所述,这些神秘的对象将在第六章 面向对象编程 - 使用他人的辛勤工作 中揭晓。然而,正如我们可以从我们的示例方法和从我们迄今为止编写的所有示例都运行得很好这一事实中看到的那样,修饰符到目前为止并不是促进我们学习所必需的。
返回类型
接下来是return类型。像修饰符一样,return类型也是可选的,尽管它对我们来说更有直接用处。所以让我们更仔细地看看。我们已经看到我们的方法可以完成任何事情。但如果我们需要他们所做事情的结果呢?我们迄今为止看到的简单return类型示例是这样的:
int addAToB(int a, int b){
int answer = a + b;
return answer;
}
在此代码中,签名中的return类型被突出显示。因此,return类型是int。addAToB方法将(返回)一个值给调用它的代码,这个值将适合一个int变量。
return类型可以是迄今为止我们看到的任何 Java 类型。然而,方法不必返回任何值。在这种情况下,签名必须使用void关键字作为return类型。当使用void关键字时,方法体不得尝试返回一个值,因为这会导致编译器错误。然而,它可以使用不带值的return关键字。以下是一些有效的返回类型和return关键字使用的组合:
void doSomething(){
//our code
//I'm done going back to calling code here
//no return is necessary
}
return和void的另一种组合如下:
void doSomethingElse(){
//our code
//I can do this as long as I don't try and add a value
return;
}
以下代码是return和void的另一种组合:
void doYetAnotherThing(){
//some code
if(someCondition){
//if someCondition is true returning to calling code
//before the end of the method body
return;
}
//More code that might or might not get executed
return;
//As I'm at the bottom of the method body
//and the return type is void, I'm
//really not necessary but I suppose I make it
//clear that the method is over.
}
String joinTogether(String firstName, String lastName){
return firstName + lastName;
}
我们可以逐个调用前面的每个方法,如下所示:
//OK time to call some methods
doSomething();
doSomethingElse();
doYetAnotherThing();
String fullName = joinTogether("Jeff ","Minter")
//fullName now = Jeff Minter
//continue with code from here
注意
上述代码将逐个执行每个方法中的所有代码语句。如果方法签名有参数,调用方法的代码将略有不同。
方法名称
当我们设计自己的方法时,方法名是任意的,但有一个约定是使用动词,清楚地说明方法将要做什么。另一个约定是名字中第一个单词的首字母小写,后续每个单词的首字母大写。这被称为 驼峰式命名法,因为名字可以形成的形状有一个驼峰:
XGHHY78802c(){
//code here
}
这个名字是完全合法的,并且可以工作。然而,让我们看看一个使用约定更为清晰的例子:
doSomeVerySpecificTask(){
//code here
}
getMySpaceShipHealth(){
//code here
}
startNewGame(){
//code here
}
这些是更清晰的方法名称。
现在让我们来看看参数。
参数
我们知道一个方法可以向调用代码返回一个结果。如果我们需要从调用代码中共享一些数据值给方法怎么办?参数允许我们与方法共享值。当我们查看返回类型时,我们已经看到了一个带有参数的例子。我们将更仔细地查看相同的例子:
int addAToB(int a, int b){
int answer = a + b;
return answer;
}
代码中的参数被突出显示。注意,在方法体的第一行,我们使用 a + b 仿佛它们已经被声明和初始化。嗯,那是因为它们已经被声明了。方法签名中的参数是它们的声明,调用方法的代码初始化它们:
int returnedAnswer = addAToB(10,5);
此外,正如我们在之前的例子中部分看到的,我们不需要在我们的参数中使用 int。我们可以使用任何 Java 类型,包括我们自己设计的类型。我们还可以混合和匹配类型。我们可以使用必要的任何数量的参数来解决我们的问题。一个混合 Java 类型的例子可能有助于理解:
void addToAddressBook(char firstInitial, String lastName, String city, int age){
//all the parameters are now living breathing,
//declared and initialized variables
//code to add details to address book goes here
}
现在是时候认真对待我们的方法体了。
在方法体中完成事情
主体是我们迄今为止一直通过这样的注释来避免的部分:
//code here
//some code
但实际上,我们已经知道在这里要做什么了。我们迄今为止学到的任何 Java 语法都可以在方法体中使用。事实上,如果我们回顾一下,我们迄今为止编写的所有代码都已经在方法中,尽管是别人的方法。例如,我们在 onCreate 和 onClick 方法中编写了代码。
我们接下来能做的最好的事情就是编写一些在主体中实际做些事情的代码方法。
使用方法
我们不需要在我们的数学游戏项目中乱搞。我们将快速为接下来的两个方法探索创建一个新的空白项目。
我们也不需要花时间制作用户界面。我们将使用 Android 控制台来查看结果并讨论我们方法示例的影响。由于我们正在使用 Android 控制台来查看我们使用方法的工作结果,因此我们需要在 Android 模拟器上而不是在真实设备上运行所有这些示例。
注意
有可能设置一个真实设备以输出到控制台,但在这本书中我们没有涉及这一点。如果你想了解更多关于使用你的实际设备进行调试的信息,请查看developer.android.com/tools/device.html上的文章。
如同往常一样,你可以以通常的方式打开已经输入的代码文件。接下来的两个关于方法的示例可以在Chapter4文件夹和AWorkingMethod和ExploringMethodOverloading子文件夹中的 Packt Publishing 代码下载中找到。
小贴士
以下是如何创建一个新空白项目的快速提醒。
-
通过导航到文件 | 关闭项目来关闭任何当前打开的项目。
-
点击新建项目...。
-
创建新项目配置窗口将出现。填写应用程序名称字段和公司域名为
packtpub.com,或者你也可以在这里使用你自己的公司网站名称。 -
现在点击下一步按钮。在下一屏,确保手机和平板复选框中有勾选。现在我们必须选择我们想要为构建我们的应用程序的最早版本的 Android。继续在下拉选择器中尝试一些选项。你会看到,我们选择的版本越早,我们的应用程序可以支持的设备百分比就越大。然而,这里的权衡是,我们选择的版本越早,我们可以在应用程序中拥有的最新 Android 功能就越少。一个好的平衡点是选择API 8: Android 2.2 (Froyo)。现在就按照下一张截图所示进行操作。
-
点击下一步。现在选择空白活动并再次点击下一步。
-
在下一屏,只需将活动名称改为
MainActivity并点击完成。 -
正如我们在第二章中做的那样,为了使我们的代码清晰简单,你可以删除两个不需要的方法(
onCreateOptionsMenu和onOptionsItemSelected)以及它们相关的@override和@import语句,但这对于示例工作不是必要的。
有关创建新项目的详细说明和图像,请参阅第二章,Android 入门。
一个工作方法
首先,让我们创建一个简单的工作方法,包括返回类型和完全功能性的主体。
此方法将接受三个数字作为参数,并根据是否在方法中随机生成这三个数字之一,向调用代码返回true或false值:
-
创建一个名为
A Working Method的新空白项目。 -
在这个方法中,我们将使用我们之前看到的
Random类及其randInt方法作为演示的一部分。在onCreate的闭括号之后但在MainActivity的闭括号之前复制此方法的代码。当你被提示导入任何类时,只需点击确定:boolean guessANumber(int try1, int try2, int try3){ //all the Log.i lines print to the Android console Log.i("info", "Hi there, I am in the method body"); //prove our parameters have arrived in the method //By printing them in the console Log.i("info", "try1 = " + try1); Log.i("info", "try2 = " + try2); Log.i("info", "try3 = " + try3); -
现在我们声明一个名为
found的布尔变量并将其初始化为false。如果我们正确猜出随机数,我们将found改为true。接下来,我们声明我们的随机数并向控制台打印一些有用的值://we use the found variable to store our true or false //setting it to false to begin with boolean found = false; //Create an object of the Random class so we can use it Random randInt = new Random(); //Generate a random number between 0 and 5 int randNum = randInt.nextInt(6); //show our random number in the console Log.i("info", "Our random number = " + randNum); -
我们方法中的最后一部分代码检查是否有任何传入的参数与我们的随机数匹配,打印一些输出,然后使用
found变量将true或false返回给onCreate方法中的调用代码://Check if any of our guesses are the same as randNum if(try1 == randNum || try2 == randNum || try3 == randNum){ found = true; Log.i("info", "aha!"); }else{ Log.i("info", "hmmm"); } return found; } -
现在将此代码写在
onCreate方法的括号关闭之前,以调用代码并打印一些值到 Android 控制台://all the Log.i lines print to the Android console Log.i("info", "I am in the onCreate method"); //Call guessANumber with three values //and if true is returned output - Found it! if(guessANumber( 1,2,3 )) { Log.i("info", "Found It!"); }else{//guessANumber returned false -didn't find it Log.i ("info", "Can't find it"); } //continuing with the rest of the program now Log.i("info", "Back in onCreate"); -
启动一个模拟器。
-
在模拟器上运行应用程序。
-
我们所有的控制台消息都有一个名为info的标签。控制台窗口已经在编辑器窗口下方出现。我们可以在搜索框中键入
info,以仅显示我们的消息,如下面的屏幕截图所示:![一个工作方法]()
在前面的屏幕截图中,你可以看到搜索过滤器和控制台输出。我们现在将运行代码并解释输出。
为了清晰起见,以下是精确的控制台输出,没有在每个行首添加额外的日期、时间和包名。记住,我们正在处理一个随机数,所以你的输出可能会有所不同:
info: I am in the onCreate method
info﹕Hi there, I am in the method body
info﹕try1 = 1
info﹕try2 = 2
info﹕try3 = 3
info﹕Our random number = 0
info﹕hmmm
info﹕Can't find it
info﹕Back in onCreate
这里是正在发生的事情。在第二步中,我们开始编写我们的第一个方法。我们将其命名为guessANumber。它有三个int参数,并将返回一个布尔值。记住,这三个int参数成为完全初始化的变量。然而,首先,在我们的方法中,我们只是输出作为参数传递的新变量的值以及一条确认信息,表明我们方法中的代码目前正在执行:
boolean guessANumber(int try1, int try2, int try3){
//all the Log.i lines print to the Android console
Log.i("info", "Hi there, I am in the method body");
//prove our parameters have arrived in the method
//By printing them in the console
Log.i("info", "try1 = " + try1);
Log.i("info", "try2 = " + try2);
Log.i("info", "try3 = " + try3);
在第三步中,我们在方法中添加了更多的代码。我们声明并初始化了一个名为found的布尔变量,我们将使用它将值返回给调用代码,并让调用代码知道传入的参数中是否有任何一个与随机数相同:
//we use the found variable to store our true or false
//setting it to false to begin with
boolean found = false;
接下来(仍然在第三步中),我们以与本章前面相同的方式生成一个随机数。我们还使用Log输出随机数,以便我们可以检查发生了什么:
//Create an object of the Random class so we can use it
Random randInt = new Random();
//Generate a random number between 0 and 5
int randNum = randInt.nextInt(6);
//show our random number in the console
Log.i("info", "Our random number = " + randNum);
在第四步中,我们使用逻辑或运算符的if语句检测传入的参数中是否有任何一个与刚刚生成的随机数匹配,如下面的代码所示:
//Check if any of our guesses are the same as randNum
if(try1 == randNum || try2 == randNum || try3 == randNum){
如果条件为真,即try1、try2或try3中的任何一个等于randNum,则执行以下代码。我们的found布尔值被设置为true,并打印一条消息:
found = true;
Log.i("info", "aha!");
如果条件不为真,则执行else语句,打印不同的消息,并将found变量保持与之前相同的状态——false:
}else{
Log.i("info", "hmmm");
}
最后,在我们的方法中,我们将found变量返回给调用代码,该变量将是true或false:
return found;
}
现在我们来看第五步,这是onCreate方法中的代码,它首先调用我们的guessANumber方法。我们首先简单地打印一条消息,说明我们现在在onCreate中:
//all the Log.i lines print to the Android console
Log.i("info", "I am in the onCreate method");
然后我们用三个参数调用guessANumber。在这种情况下,我们使用了 1、2 和 3,但任何int值都可以工作。然而,我们将调用包裹在一个if语句中。这意味着方法返回的return值将被用来评估if语句。简单来说,如果返回true,则执行if语句,并打印出"找到了!":
//Call guessANumber with three values
//and if true is returned output - Found it!
if(guessANumber(1,2,3)){
Log.i("info", "Found It!");
}
相反,如果返回false,则执行else语句,并打印出"找不到":
else{//guessANumber returned false -didn't find it
Log.i ("info", "Can't find it");
}
//continuing with the rest of the program now
Log.i("info", "Back in onCreate");
记住我们正在处理随机数,所以你可能需要运行几次才能看到这个输出:

当然,你应该注意,发送给函数作为参数的猜测是任意的。只要所有数字都在 0 到 5 之间且不重复,它们共同将有 50%的机会找到随机数。
在结束语中,如果你只在这本书中读一条提示,那应该是这条。
提示
将变量值打印到控制台是检查你的游戏内部发生什么以及找到错误的好方法。
让我们看看方法的另一个例子。
探索方法重载
随着我们学习,方法这个主题真的是多种多样且深奥,但希望一步一步来,我们会发现它们并不令人畏惧。当我们增强我们的数学游戏时,我们会使用我们学到的关于方法的知识。我们将在第六章面向对象编程 – 使用他人的辛勤工作中更深入地探索方法。然而,现在,查看关于方法的一个更多主题将对我们大有裨益。让我们创建一个新的项目来探索方法重载。
正如我们现在将看到的,如果我们提供的参数不同,我们可以创建多个具有相同名称的方法。这个项目中的代码比上一个项目简单得多。直到我们稍后分析,这个代码是如何工作的可能会显得有些奇怪:
-
创建一个名为
Exploring Method Overloading的新空白项目。 -
在第一个方法中,我们将简单地称其为
printStuff,并通过参数传递一个要打印的int变量。在onCreate的括号关闭后但在MainActivity的括号关闭前复制这个方法的代码。当你被提示导入任何类时,只需点击确定:void printStuff(int myInt){ Log.i("info", "This is the int only version"); Log.i("info", "myInt = "+ myInt); } -
我们还将调用第二个方法
printStuff,但传递一个要打印的string变量。在onCreate的括号关闭后但在MainActivity的括号关闭前复制这个方法的代码。再次,当你被提示导入任何类时,只需点击确定:void printStuff(String myString){ Log.i("info", "This is the String only version"); Log.i("info", "myString = "+ myString); } -
再次,我们将调用这个第三个方法
printStuff,但传递一个要打印的string变量和一个int变量。和之前一样,在onCreate的括号关闭后但在MainActivity的括号关闭前复制这个方法的代码:void printStuff(int myInt, String myString){ Log.i("info", "This is the combined int and String version"); Log.i("info", "myInt = "+ myInt); Log.i("info", "myString = "+ myString); } -
现在将此代码写在
onCreate方法的闭合括号之前,以调用方法并将一些值打印到 Android 控制台://declare and initialize a String and an int int anInt = 10; String aString = "I am a string"; //Now call the different versions of printStuff //The name stays the same, only the parameters vary printStuff(anInt); printStuff(aString); printStuff(anInt, aString); -
启动模拟器。
-
在模拟器上运行应用程序。
这里是控制台输出:
info﹕ This is the int only version
info﹕ myInt = 10
info﹕ This is the String only version
info﹕ myString = I am a string
info﹕ This is the combined int and String version
info﹕ myInt = 10
info﹕ myString = I am a string
如您所见,Java 将具有相同名称但参数不同的三个方法视为完全不同的方法。正如我们刚才所展示的,这可以非常实用。这被称为方法重载。
小贴士
方法重载和重写的混淆
重载和重写如下定义:
-
当我们有一个以上同名但参数不同的方法时,会发生重载。
-
当我们用具有相同名称和参数列表的方法替换一个方法时,会发生重写。
我们对重载和重写有了足够的了解,可以完成这本书,但如果您勇敢且好奇心强,您可以重写一个重载的方法。但这将是另一篇文章的内容。
这就是前面代码的工作原理。在每个三个步骤(2、3 和 4)中,我们创建了一个名为printStuff的方法,但每个printStuff方法都有不同的参数,因此每个都是可以单独调用的不同方法:
void printStuff(int myInt){
...
}
void printStuff(String myString){
...
}
void printStuff(int myInt, String myString){
...
}
每个方法体的内容都很简单。它只是打印传入的参数并确认当前正在调用哪个版本的方法。
我们代码的下一个重要部分是我们使用适当的参数明确指出要调用哪个方法。在第 5 步中,我们依次使用适当的参数调用它们,以便 Java 知道确切需要的方法:
printStuff(anInt);
printStuff(aString);
printStuff(anInt, aString);
现在我们对方法、循环和随机数有了足够的了解,可以对我们数学游戏进行一些改进。
增强我们的数学游戏
我们将利用我们刚刚学到的关于方法和循环的知识,为我们的数学游戏添加一些功能。
如常,代码可在代码下载的Chapter4文件夹中复制。项目位于MathGameChapter4子文件夹中,包括本章中涵盖的所有剩余改进阶段,包括增强 UI、修改我们的游戏活动、setQuestion、updateScoreAndLevel、isCorrect和调用我们的新方法。
我们将在每次尝试回答问题后更改游戏的问题。
我们还将为问题添加难度级别和随机问题,但都在适合该难度级别的范围内。
我们将显示和更新分数。正确回答问题的难度级别越高,分数上升得越快。
如果玩家回答问题错误,难度将回到最简单级别,分数归零。
增强 UI
让我们继续修改我们的数学游戏 UI,以融入我们新的游戏功能。我们将添加一个 TextView 来显示分数,并添加另一个 TextView 来显示等级。
-
在编辑器窗口中打开
activity_game.xml文件。我们将在 UI 的底部添加一个新的 TextView 来显示分数。 -
从调色板中拖动一个大文本元素,并将其放置在我们的三个答案按钮的左侧下方。
-
现在我们需要更改id属性,以便我们可以从 Java 代码中访问我们的新 TextView。确保通过单击它来选择新的 TextView。现在,在属性窗口中,将id属性更改为
textScore。 -
为了清晰起见(尽管这一步在编程中没有任何作用),将text属性更改为
Score:999。 -
现在将另一个大文本元素放置在我们刚刚配置的元素的右侧,并将id属性更改为
textLevel。我们的 UI 的下半部分现在应该看起来像这样:![增强 UI]()
-
再次为了清晰起见(尽管这一步在编程中没有任何作用),将text属性更改为
Level:4。 -
保存项目。
我们刚刚添加了两个新的 TextView 元素,并给它们都分配了一个 ID,我们可以在 Java 代码中引用它。
小贴士
你现在可能已经意识到,就使游戏运行而言,我们 UI 元素的精确布局和大小并不重要。这为我们设计不同屏幕尺寸的布局提供了很大的灵活性。只要每个屏幕尺寸的布局都包含相同类型的元素和相同的 ID,相同的 Java 代码就可以用于不同的布局。如果你想了解更多关于为多个屏幕尺寸设计的信息,请查看developer.android.com/training/multiscreen/screensizes.html。
现在我们有了增强的 UI,并且理解了 Java Random类的工作方式,我们可以添加 Java 代码来实现我们的新功能。
新的 Java 代码
如前所述,项目代码位于可下载代码的Chapter4文件夹中。项目名为MathGameChapter4,涵盖了本章中涵盖的所有改进。
在这个阶段,我们将添加大量的新代码,移动一些现有代码,并修改一些现有代码。由于变化如此之多,我们将从头开始处理代码。新代码将完全解释,移动的代码将给出原因,而保持不变且位置相同的代码将解释得最少。
我们将首先对我们的现有代码进行一些修改和删除。然后我们将查看设计和实现我们每个新方法,以改进我们的代码并添加我们的新功能。
修改 GameActivity
首先,让我们对我们的当前代码进行必要的修改和删除:
-
在编辑器窗口中打开
GameActivity.java文件。 -
我们现在需要考虑代表我们 UI 元素的对象的作用域。
textObjectPartA和textObjectPartB都需要从我们将要创建的方法中可访问。所以,让我们像上一章中处理多选按钮一样,将它们的声明从onCreate方法中移出,以便它们在GameActivity类的任何地方都可以访问。以下代码显示了迄今为止的所有声明。它们位于GameActivity类开始之后。最近添加(或移动)的声明被突出显示。请注意,我们还添加了两个新 TextView 的声明以及得分和等级显示的声明。此外,还有两个新的int变量,我们可以操作它们来记录我们的得分和等级。它们是currentScore和currentLevel:public class GameActivity extends Activity implements View.OnClickListener{ int correctAnswer; Button buttonObjectChoice1; Button buttonObjectChoice2; Button buttonObjectChoice3; TextView textObjectPartA; TextView textObjectPartB; TextView textObjectScore; TextView textObjectLevel; int currentScore = 0; int currentLevel = 1; -
所有分配文本给我们的按钮或 TextView 对象的代码,以及初始化问题部分并分配错误答案值的代码,现在都将改变并移动,因此我们需要删除所有这些。以下代码中显示的所有内容都将被删除:
//Here we initialize all our variables int partA = 9; int partB = 9; correctAnswer = partA * partB; int wrongAnswer1 = correctAnswer - 1; int wrongAnswer2 = correctAnswer + 1; -
以下代码片段也需要删除:
//Now we use the setText method of the class on our objects //to show our variable values on the UI elements. textObjectPartA.setText("" + partA); textObjectPartB.setText("" + partA); //which button receives which answer, at this stage is arbitrary. buttonObjectChoice1.setText("" + correctAnswer); buttonObjectChoice2.setText("" + wrongAnswer1); buttonObjectChoice3.setText("" + wrongAnswer2); -
为了清晰和上下文,这里展示了当前整个
onCreate方法的内容。这里没有新的内容,但你可以看到你的代码,它连接了我们步骤 2 中声明的按钮和 TextView 对象。再次强调,这段代码包括我们新添加的两个 TextView,它们被突出显示,但步骤 3 和 4 中描述的其他所有内容都被删除了。和之前一样,有一段代码使我们的游戏能够监听按钮点击:protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //The next line loads our UI design to the screen setContentView(R.layout.activity_game); /*Here we get a working object based on either the button or TextView class and base as well as link our new objects directly to the appropriate UI elements that we created previously*/ textObjectPartA = (TextView)findViewById(R.id.textPartA); textObjectPartB = (TextView)findViewById(R.id.textPartB); textObjectScore = (TextView)findViewById(R.id.textScore); textObjectLevel = (TextView)findViewById(R.id.textLevel); buttonObjectChoice1 = (Button)findViewById(R.id.buttonChoice1); buttonObjectChoice2 = (Button)findViewById(R.id.buttonChoice2); buttonObjectChoice3 = (Button)findViewById(R.id.buttonChoice3); buttonObjectChoice1.setOnClickListener(this); buttonObjectChoice2.setOnClickListener(this); buttonObjectChoice3.setOnClickListener(this); }//onCreate ends here -
现在,我们将删除一些我们不需要的代码,因为我们将通过将其分块到我们新的方法中并添加我们的新功能来使其更高效。所以,在我们的
onClick方法中,在switch语句的每个情况下,我们想要删除if和else语句。我们将完全重写这些,但我们将保留初始化我们的answerGiven变量的代码。我们的onClick方法现在将看起来像这样:@Override public void onClick(View view) { //declare a new int to be used in all the cases int answerGiven=0; switch (view.getId()) { case R.id.buttonChoice1: //initialize a new int with the value contained in buttonObjectChoice1 //Remember we put it there ourselves previously answerGiven = Integer.parseInt("" + buttonObjectChoice1.getText()); break; case R.id.buttonChoice2: //same as previous case but using the next button answerGiven = Integer.parseInt("" + buttonObjectChoice2.getText()); break; case R.id.buttonChoice3: //same as previous case but using the next button answerGiven = Integer.parseInt("" + buttonObjectChoice3.getText()); break; } } -
保存你的项目。
哇!这有很多代码,但正如我们在过程中所看到的,没有新的概念。在步骤 2 中,我们只是将按钮和 TextView 对象的初始化移动到了一个它们现在可以从我们类中的任何地方可见的地方。
在步骤 3 和 4 中,我们进行了大量的删除,因为我们不再在 onCreate 中创建问题或填充多选按钮,因为这不够灵活。我们很快就会看到如何改进这一点。
在步骤 6 中,我们删除了测试答案是否正确的代码。然而,正如我们所看到的,我们仍然以相同的方式初始化了 answerGiven 变量——在 onClick 方法中的 switch 语句的适当情况下。
太好了!现在我们准备考虑和设计一些新的方法来划分我们的代码,避免其中的重复,并添加我们的额外功能。考虑以下我们将很快实现的方法。
方法
现在,我们将逐步编写一些方法。正如我们将看到的,这些方法将划分我们的代码,并防止我们的新功能的实现使代码变得过长和杂乱:
-
我们将编写一个
setQuestion方法来准备一个难度适当的题目。 -
我们将编写一个
updateScoreAndLevel方法来完成这项工作。我们还将编写一个isCorrect方法,我们的其他方法将使用它来评估答案的正确性。 -
然后,我们将有策略地放置调用我们新方法的代码。
我们将逐个完成这些任务,并在过程中解释代码,因为将解释留到最后会使引用单个步骤变得繁琐。
我们将使用我们在本章和上一章中学到的许多 Java 特性。这些包括以下内容:
-
方法
-
一个
for循环 -
switch控制结构
因此,让我们从我们的第一个方法开始。
setQuestion方法
我们确定我们需要一个为我们准备问题的方法;setQuestion似乎是一个不错的名字。每次我们的玩家通过点击三个多选按钮之一给出答案时,都需要准备一个新的问题。
此方法需要为我们的partA和partB变量生成值,并在由textObjectPartA和textObjectPartB对象引用的 TextViews 中显示它们。此外,该方法还需要将新的正确答案分配给我们的correctAnswer变量,然后该变量将被用来计算一些合适的错误答案。最后,该方法将在我们的多选按钮上显示正确和错误答案。
此外,我们的setQuestion方法需要考虑currentLevel中持有的级别,以确定它将提出的问题的范围或难度。让我们看一下代码。如果您想在我们进行时输入此代码,请确保将其放置在onClick的闭括号之后,但在我们的GameActivity类的闭括号之前:
-
首先,我们有方法签名和位于方法体之前的方法开大括号:
void setQuestion(){ -
这告诉我们返回类型是
void,所以setQuestion不会向调用它的代码返回任何值。此外,这里没有参数,所以它不需要任何值传递给它才能工作。让我们看看它做了什么。现在我们进入代码以生成问题的两部分://generate the parts of the question int numberRange = currentLevel * 3; Random randInt = new Random(); int partA = randInt.nextInt(numberRange); partA++;//don't want a zero value int partB = randInt.nextInt(numberRange); partB++;//don't want a zero value -
在上一步中,我们声明了一个新的
int变量numberRange,并通过将玩家的currentLevel值乘以3来初始化它。然后我们得到了一个新的Random对象,称为randInt,并使用它根据numberRange生成新的值。我们对partA和partB变量都做了这样的事情。随着currentLevel值的增加,问题的难度也可能增加。现在,就像我们过去所写的那样,我们写下这个:correctAnswer = partA * partB; int wrongAnswer1 = correctAnswer-2; int wrongAnswer2 = correctAnswer+2; textObjectPartA.setText(""+partA); textObjectPartB.setText(""+partB); -
我们将新乘法问题的答案分配给
correctAnswer。然后我们声明并分配了两个错误的答案给新的int变量,wrongAnswer1和wrongAnswer2。我们还使用了 TextView 对象的setText方法来向玩家显示问题。请注意,我们还没有显示正确和错误的答案。下面是它们:尝试分析这里发生了什么://set the multi choice buttons //A number between 0 and 2 int buttonLayout = randInt.nextInt(3); switch (buttonLayout){ case 0: buttonObjectChoice1.setText(""+correctAnswer); buttonObjectChoice2.setText(""+wrongAnswer1); buttonObjectChoice3.setText(""+wrongAnswer2); break; case 1: buttonObjectChoice2.setText(""+correctAnswer); buttonObjectChoice3.setText(""+wrongAnswer1); buttonObjectChoice1.setText(""+wrongAnswer2); break; case 2: buttonObjectChoice3.setText(""+correctAnswer); buttonObjectChoice1.setText(""+wrongAnswer1); buttonObjectChoice2.setText(""+wrongAnswer2); break; } } -
在前面的代码中,我们使用了我们的
Random 对象randInt来生成一个介于 0 和 2 之间的数字,并将其分配给一个新的int变量,称为buttonLayout。然后我们使用buttonLayout 在所有可能的值之间切换:0、1 或 2。每个case语句将正确和错误的答案以稍微不同的顺序设置到多选按钮中,这样玩家就不能只是不断地连续点击同一个按钮以获得高分。注意在关闭括号后面的额外关闭括号。这是我们的setQuestion方法的结束。
我们在解释代码时相当详细,但再次仔细看看一些部分可能是有益的。
在步骤 1 中,我们看到了我们的方法签名,它有一个void返回类型且没有参数。在步骤 2 中,我们生成了一些随机数,这些数将位于某个范围内。这个范围可能不像一开始看起来那么明显。首先,我们像这样分配、声明和初始化numberRange:
int numberRange = currentLevel * 3;
因此,如果玩家处于第一个问题,那么currentLevel将持有值1,而numberRange将被初始化为3。然后我们像之前讨论的那样创建了一个新的Random对象,并输入了这一行代码:
int partA = randInt.nextInt(numberRange);
这里发生的情况是,Random对象的nextInt方法,即randInt,将返回 0、1 或 2 的值,因为我们给它提供了一个 3 的种子。我们不希望我们的游戏中出现任何零,因为它们会导致非常简单的乘法,所以我们进入这个:
partA++;//don't want a zero value
这个操作符,你可能还记得从第三章,Speaking Java – 你的第一个游戏,当我们讨论操作符时,会将partA增加 1。然后我们对partB变量做完全相同的事情,这意味着如果玩家仍然在第一级,他们将有一个以下的问题:
1 x 1,1 x 2,1 x 3,2 x 1,2 x 2,2 x 3,3 x 1,3 x 2,或 3 x 3
随着难度的提升,问题的潜在范围显著增加。因此,在 2 级时,问题选项可以是 1 到 6 的部分;对于 3 级,则是 1 到 9;以此类推。在更高难度级别上仍然有可能得到一个简单的问题,但随着难度的增加,这种情况变得越来越不可能。最后在这个步骤中,我们使用setText方法将问题显示给玩家。
在第 3 步中,我们之前已经看到过,但这次我们稍作修改。我们计算并分配一个值给correctAnswer,并声明和分配wrongAnswer1和wrongAnswer2的值,它们将持有按钮的错误答案选项。
第三部分与上一章中onCreate所做的内容略有不同,因为我们分别从wrongAnswer1和wrongAnswer2中减去和加上 2。这使得猜测乘法问题的答案变得更难,因为你不能根据答案的奇偶性来排除答案。
第 4 步简单地随机化正确和错误答案将被放置的按钮。我们不需要跟踪这一点,因为当需要比较按下的按钮上的值与正确答案时,我们可以简单地使用我们的 Java 代码来发现它,就像我们在第三章中做的那样,Speaking Java – 你的第一个游戏。
updateScoreAndLevel方法
这个方法的名字本身就说明了它的作用。因为保持分数并不简单,而且我们希望更高难度级别产生更高的分数,所以我们将代码分块以保持程序的可读性。如果我们想要修改评分系统,所有这些修改都可以在这里进行。
让我们编写代码。
-
这段代码可以放在
GameActivity {}的任何开闭花括号内,但按照它们将被使用的顺序放置是良好的实践。所以为什么不从setQuestion的闭花括号之后开始添加你的代码,但显然在GameActivity的闭花括号之前呢?以下是带有开花括号的方法签名:void updateScoreAndLevel(int answerGiven){ -
这告诉我们,我们的方法不返回任何值,但它确实接收一个
int类型的参数,它将需要这个参数来完成其功能。参数的名称为我们提供了将要传递内容的很大线索。我们将在下一分钟看到它的实际应用,但如果将玩家的答案传递给这个方法而不是isCorrect方法有些令人困惑,我们将在下一部分代码中看到事情变得更加清晰。以下是需要添加的代码的下一部分:if(isCorrect(answerGiven)){ for(int i = 1; i <= currentLevel; i++){ currentScore = currentScore + i; } currentLevel++; } -
这里发生了很多事情,所以我们将在方法完成后再对其进行更详细的剖析。基本上,它调用
isCorrect方法(我们很快就会编写),如果响应是true,则通过for循环给玩家的分数加 1。之后,该方法将currentLevel增加 1。以下是代码中的else部分,以防isCorrect的响应是false:else{ currentScore = 0; currentLevel = 1; } -
如果响应是
false,即如果玩家答错了,currentScore变量将被设置为0,并将等级回退到1。最后,对于这个方法,我们输入以下内容://Actually update the two TextViews textObjectScore.setText("Score: " + currentScore); textObjectLevel.setText("Level: " + currentLevel); } -
在上一步中,我们更新了玩家看到的实际 TextViews,以显示新确定的分数和等级。然后方法结束,程序的控制在最初调用
updateScoreAndLevel的代码处返回。保存你的项目。
我们在讲解代码的过程中解释了大部分内容,但快速回顾一下并深入探讨某些部分可能是个好主意,特别是那个看起来奇怪的if语句中的isCorrect调用。
在第一步中,我们从方法签名开始。然后在第二步中,我们从上述那个奇怪的if语句开始:
if(isCorrect(answerGiven)){
我们在本书的“方法”部分的“A working method”示例中已经见过这种类型的语句。这里发生的情况是,isCorrect的调用正在替换要评估的语句,或者说它就是要评估的语句。所以isCorrect被调用时,带有answerGiven变量。你可能记得,answerGiven变量被传递给了updateScoreAndLevel。这次,它被传递给了isCorrect方法,该方法将对其进行一些操作,也许还有其他一些事情。然后它将返回一个true或false的值给if语句。如果问题被正确回答,该值将为true;如果没有,则为false。
假设if语句评估为真,程序将运行以下这段代码(也是第二步的内容):
for(int i = 1; i <= currentLevel; i++){
currentScore = currentScore + i;
}
currentLevel++;
代码进入一个for循环,其中起始变量i被初始化为 1,如下所示:int i = 1;。此外,循环被指令在i小于或等于我们的currentLevel变量时继续。然后在for循环内,我们将i加到当前分数上。例如,假设玩家刚刚答对了一个问题,并且我们带着currentLevel为 1 进入for循环。玩家的分数仍然是 0,因为这是他们的第一个正确答案。
在第一次遍历中,我们得到以下结果:
-
i = 1,因此它等于currentLevel,也就是 1。所以我们进入了for循环 -
i = 1,所以currentScore等于 0 -
我们将
i,即1,加到currentScore -
我们的
currentScore变量现在等于1
在第二次遍历中,发生以下步骤:
-
i增加到 2,所以它现在大于currentLevel,即 1 -
for循环的条件评估为false,我们继续执行for循环之后的代码 -
currentLevel增加 1 到 2
现在,让我们再次看看那个for循环,假设玩家也答对了下一个问题,并且我们回到了updateScoreAndLevel。这次,isCorrect评估为真,我们进入了for循环,但情况与上次略有不同。
在第一次遍历中,发生以下步骤:
-
i = 1,所以i小于currentLevel,即 2,我们进入了for循环 -
i = 1,currentScore= 1 -
我们将
i(等于 1)添加到currentScore -
我们的
currentScore变量现在等于 2
在第 2 次遍历时,发生以下步骤:
-
i增加到 2,现在等于currentLevel,也就是 2 -
i = 2,currentScore = 2 -
我们将
i(现在等于 2)添加到currentScore -
我们的
currentScore变量现在等于 4
在第 3 次遍历时,以下步骤发生:
-
i增加到 3,现在大于currentLevel,即 2。 -
for循环的条件评估为false,我们继续执行for循环之后的代码。 -
currentLevel的值增加 1 到 3。所以下次,我们将有额外的for循环遍历。
发生的事情是,随着每个等级的提升,玩家将获得另一个遍历 for 循环的机会,每次遍历 for 循环都会给他们的分数增加更大的值。为了总结 for 循环中发生的事情,以下是一个简短的值表,显示了玩家的分数是如何根据 currentLevel 变量增加的:
| currentLevel | 添加到当前分数 | 循环后的当前分数 |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 3 (1 + 2) | 4 |
| 3 | 6 (1 + 2 + 3) | 10 |
| 4 | 10 (1 + 2 + 3 + 4) | 20 |
| 5 | 15 (1 + 2 + 3 + 4 + 5) | 35 |
注意
当然,我们本来可以保持它非常简单,不使用 for 循环。我们可能只是使用 currentScore = currentScore + level,但这样并不能像我们当前解决方案那样提供不断增长的奖励,而且我们也无法练习我们的 for 循环。
如果 if(isCorrect(answerGiven)) 评估为 false,它将简单地在第 3 步将分数重置为 0,并将等级重置为 1。然后第 4 步使用我们刚刚讨论的变量更新我们的 TextViews 分数和等级。
现在我们只需要编写一个方法。当然,这是 isCorrect 方法,我们刚刚调用过。
isCorrect 方法
这个方法很好也很简单,因为我们之前已经看到了所有相关的代码。我们只需要仔细查看方法签名和返回值:
-
在
updateScoreAndLevel方法的闭合括号之后和GameActivity类的闭合括号之前输入代码。输入方法签名如下:boolean isCorrect(int answerGiven){ -
在这里我们可以看到,该方法必须返回一个布尔值,
true或false。如果不这样做,程序将无法编译。这保证了当我们使用此方法作为updateScoreAndLevel方法的评估表达式时,我们肯定会得到一个结果。它可以是true或false。签名还显示了我们传入的answerGiven变量,准备好供我们使用。输入以下代码,这将确定该结果:boolean correctTrueOrFalse; if(answerGiven == correctAnswer){//YAY! Toast.makeText(getApplicationContext(), "Well done!", Toast.LENGTH_LONG).show(); correctTrueOrFalse=true; }else{//Uh-oh! Toast.makeText(getApplicationContext(), "Sorry", Toast.LENGTH_LONG).show(); correctTrueOrFalse=false; } -
我们之前几乎看到了所有的代码。例外的是,我们声明了一个布尔变量
correctTrueOrFalse,如果玩家回答正确,我们将其赋值为true,如果不正确,则赋值为false。我们知道玩家是否正确,因为我们比较了answerGiven和correctAnswer在if语句中。注意,我们像之前一样触发了适当的 Android 弹出提示消息。最后,我们这样做:return correctTrueOrFalse; }
我们只是返回了correctTrueOrFalse中包含的任何值。所以,在updateScoreAndLevel中,我们详细讨论的关键的if语句将知道接下来要做什么。
为了确保我们理解isCorrect中发生的事情,让我们回顾一下我们代码中的事件序列。在步骤 1 中,我们有方法签名。我们看到我们将返回一个true或false值,并接收int。
在步骤 2 中,我们声明了一个名为correctTrueOrFalse的布尔变量来保存我们将要返回的值。然后我们用if(answerGiven == correctAnswer)测试正确或错误的答案。如果比较的两个值匹配,就会弹出恭喜的消息,并将true赋值给我们的布尔变量。当然,如果if语句是false,我们向玩家表示同情,并将false赋值给我们的重要布尔变量。
最后,在步骤 3 中,我们发送回true或false,以便updateScoreAndLevel方法可以继续其工作。
我们已经实现了所有的方法。现在是时候将它们投入使用。
调用我们的新方法
当然,我们这些闪亮的新方法在未被调用之前不会做任何事情。所以,这里是调用这些方法的计划:
-
当游戏开始时,我们希望为玩家设置一个新的问题。因此,作为
onCreate方法中的最后一行代码,我们可以这样调用我们的setQuestion方法:setQuestion(); }//onCreate ends here -
然后,我们将注意力转向
onClick方法,它已经检测到哪个按钮被按下,并将玩家的答案加载到我们的answerGiven变量中。所以,在onClick方法的末尾,在switch语句的闭合括号之后,我们只需调用这个函数:updateScoreAndLevel(answerGiven); -
这将玩家的尝试答案发送到
updateScoreAndLevel,它使用isCorrect评估答案,如果答案正确则加分并增加分数,如果不正确则重置分数和等级。我们现在需要的只是另一个问题。添加这一行。它将询问另一个问题:setQuestion();
因此,现在发生的情况是,玩家通过点击他们 Android 设备上的图标来开始我们的数学游戏。我们的GameActivity类声明了一些我们需要在整个过程中访问的变量:
int correctAnswer;
Button buttonObjectChoice1;
Button buttonObjectChoice2;
Button buttonObjectChoice3;
TextView textObjectPartA;
TextView textObjectPartB;
TextView textObjectScore;
TextView textObjectLevel;
int currentScore = 0;
int currentLevel = 1;
然后 onCreate 方法初始化一些变量,并使我们的按钮准备好接收玩家的点击,在调用 setQuestion 方法提出第一个问题之前。游戏随后等待玩家尝试回答。当玩家尝试回答时,它会被 onClick、updateScoreAndLevel 和 isCorrect 处理。然后程序控制回到 onClick,再次调用 setQuestion,我们再次等待玩家的回答。
完成细节
我们的游戏数学游戏进展顺利。不幸的是,我们很快就要继续前进。该项目已经完成了它的目的,展示了 Java 编程的一些基本原理以及一些关键的 Android 功能。现在我们需要开始介绍一些更多与游戏相关的话题。
在我们继续之前,有两件非常简单的事情可以使我们的游戏更加酷和完整。如果你对高分按钮感到好奇,我们将在查看下一个游戏项目第五章时了解如何实现,即 游戏和 Java 基础。那时,你将拥有足够的信息轻松地回来并自己实现高分。
另一个真正使我们的游戏更加完整并使其更具可玩性的功能是整体或每题的时间限制。也许甚至根据给出正确答案的速度来增加分数也会有所帮助。在我们能够做到这一点之前,我们需要一些新的 Java 技巧,但我们将看到如何在第五章,游戏和 Java 基础中,当我们谈到线程时,如何测量和响应时间。
现在我们将快速学习两个改进:
-
锁定屏幕方向
-
更改主屏幕图像
全屏显示和锁定方向
你可能已经注意到,如果你在应用运行时旋转设备,不仅你的游戏用户界面会扭曲,游戏进度也会丢失。出问题的是,当设备旋转时,会调用 onPause 和 onStop 方法。然后应用被重新启动。我们可以通过重写 onPause 方法并保存我们的数据来处理这个问题。我们将在稍后做这个。现在我们无论如何都不想屏幕旋转,所以如果我们停止它,我们就能一劳永逸地解决两个问题。
在向此文件添加代码时,Android Studio 可能会试图“帮助”通过添加额外的格式。如果你得到红色的错误指示器,你可以将你的 AndroidManifest.xml 文件与 Chapter4/MathGameChapter4 文件夹中的代码下载中的文件进行比较。或者,你可以简单地用下载的文件内容替换你文件的内容。逐步更改在此指南中详细说明,只是为了突出显示正在更改的内容:
-
这是锁定应用为纵向的第一步。打开
AndroidManifest.xml文件。它在项目资源管理器中的res文件夹下方直接位置。找到代码中的第一个<activity>开标签。 -
按如下方式输入新行:
android:screenOrientation="portrait" -
在第二个
<activity>实例之后重复步骤 2。现在我们已经锁定菜单和游戏屏幕为横幅模式。 -
要使游戏全屏,在同一文件中,找到以下文本,并在其后面但关闭
>符号之前添加一行粗体:<activity android:name="com.packtpub.mathgamechapter4.app.MainActivity" android:label="@string/app_name" android:theme="@android:style/Theme.NoTitleBar.Fullscreen"> </activity> -
将相同的更改应用到
GameActivity活动上,如下所示。再次,这里是在上下文中提供的代码,以避免这些>符号的错误:<activity android:name="com.packtpub.mathgamechapter4.app.GameActivity" android:label="@string/title_activity_game" android:theme="@android:style/Theme.NoTitleBar.Fullscreen"> </activity> -
保存项目。
现在,当你在游戏过程中旋转设备时,横幅方向将被固定。
添加自定义图片(而不是 Android 图标)
我们可能不想在我们的完成游戏主屏幕上显示 Android 图片,所以这里是更改它的步骤。这个快速指南依赖于你有一个想要使用的图片:
-
首先,我们需要将所需的图片添加到布局文件夹中。通过在Windows 资源管理器中点击图片并使用Ctrl + C来复制你的图片文件。
-
现在在 Android Studio 项目资源管理器中找到
drawable-mdpi文件夹。点击该文件夹。 -
使用Ctrl + V将图片粘贴到文件夹中。
-
现在图片已成为我们项目的一部分。我们只需像之前选择 Android 机器人的图片一样选择它。在编辑窗口中打开
activity_main.xml,并点击ImageView(目前是 Android 机器人)。 -
在属性窗口中,找到src属性。点击它,然后点击...。
-
搜索你的图片并选择它。
-
保存你的项目。
-
你现在已经在主屏幕上选择了你喜欢的图片。
自我测试问题
Q1) 猜猜这个方法有什么问题:
void doSomething(){
return 4;
}
Q2) 在这段代码的末尾x将等于多少?
int x=19;
do{
x=11;
x++;
}while(x<20)
摘要
在本章中,我们走了很长的路。你掌握了 Java 循环,并首次深入了解了 Java 方法和如何使用它们。你学习了如何生成随机数,并利用你获得的所有知识显著增强了你的数学游戏。
随着章节的推进,游戏将变得越来越像真实游戏。在下一章中,我们将制作一个测试玩家记忆力的游戏。它将包含声音、动画,并且实际上还会保存玩家的高分。
恭喜你到目前为止取得的进步,但让我们继续前进。
第五章。游戏和 Java 基础
在本章中,我们将涵盖一系列多样且有趣的主题。我们将学习 Java 数组,它允许我们以有组织和高效的方式操纵大量数据。
然后,我们将探讨线程在游戏中的作用,以便同时执行多项操作。
如果你认为我们的数学游戏有点安静,那么我们还将考虑为我们的游戏添加音效,以及介绍一个酷的开源应用程序来生成逼真的音效。
我们将要学习的最后一项新内容将是持久性。这是当玩家退出我们的游戏,甚至关闭他们的 Android 设备时发生的事情。那么分数会怎样?我们如何加载他们下次玩时的正确级别?
一旦我们完成了所有这些,我们将使用所有新的技术和知识,以及我们已有的知识来创建一个有趣的游戏。
在本章中,我们将涵盖以下主题:
-
Java 数组——变量数组
-
线程同步
-
创建和使用蜂鸣声和嗡嗡声——Android 声音
-
破坏后的生活展望——持久性
-
构建记忆游戏
Java 数组 - 变量数组
你可能会想知道当我们有一个需要跟踪大量变量的游戏时会发生什么。比如有一个包含前 100 名分数的高分表?我们可以像这样声明和初始化 100 个单独的变量:
int topScore1;
int topScore2;
int topScore3;
//96 more lines like the above
int topScore100;
立刻,这可能会显得难以控制,那么当有人获得新的最高分而我们不得不将每个变量的分数向下移动一个位置时怎么办?噩梦开始了:
topScore100 = topScore99;
topScore99 = topScore98;
topScore98 = topScore97;
//96 more lines like the above
topScore1 = score;
更新分数肯定有更好的方法。当我们有一大批变量时,我们需要的是一个 Java 数组。数组是一个可以存储最多固定数量元素的引用变量。每个元素都是一个具有一致类型的变量。
以下代码行声明了一个可以存储int类型变量的数组,甚至可能是高分表:
int [] intArray;
我们也可以声明其他类型的数组,如下所示:
String [] classNames;
boolean [] bankOfSwitches;
float [] closingBalancesInMarch;
每个这些数组在使用之前都需要分配一个固定的最大存储空间,如下所示:
intArray = new int [100];
上述代码行分配了最多 100 个整数大小的存储空间。想象一下在我们的变量仓库中有一排 100 个连续的存储空间。这些空间可能会被标记为intArray[0]、intArray[1]、intArray[2]等等,每个空间都存储一个int值。这里令人略感惊讶的事情是,存储空间从 0 开始,而不是从 1 开始。因此,在大小为 100 的数组中,存储空间将从 0 到 99。
我们实际上可以像这样初始化一些存储空间:
intArray[0] = 5;
intArray[1] = 6;
intArray[2] = 7;
注意,我们只能将声明的类型放入数组中,并且数组所持有的类型永远不会改变:
intArray[3]= "John Carmack";//Won't compile
因此,当我们有一个int类型的数组时,每个int变量叫什么呢?数组表示法语法替换了名称。我们可以对数组中的变量做任何我们可以在具有名称的常规变量上做的事情:
intArray[3] = 123;
这里是另一个数组变量像常规变量一样使用的例子:
intArray[10] = intArray[9] - intArray[4];
我们还可以将数组中的值赋给相同类型的常规变量,如下所示:
int myNamedInt = intArray [3];
注意,然而,myNamedInt是一个独立且不同的原始变量,所以对其所做的任何更改都不会影响存储在intArray引用中的值。它在仓库中有自己的空间,并且与数组不相连。
数组是对象
我们说数组是引用变量。将数组变量想象成指向一组给定类型变量的地址。也许,使用仓库类比,someArray是一个通道号。所以someArray[0]、someArray[1]等等是通道号后面跟着通道中的位置号。
数组也是对象。这意味着它们有我们可以使用的方法和属性:
int lengthOfSomeArray = someArray.length;
在上一行代码中,我们将someArray的长度赋值给名为lengthOfSomeArray的int变量。
我们甚至可以声明一个数组的数组。这是一个数组,在其每个元素中存储另一个数组,如下所示:
String[][] countriesAndCities;
在前面的数组中,我们可以为每个国家存储一个城市列表。现在我们不要变得过于热衷于数组。只需记住,一个数组可以存储预定的数量和类型的变量,它们的值可以通过以下语法访问:
someArray[someLocation];
让我们实际使用一些数组来尝试理解如何在实际代码中使用它们以及我们可能用它们做什么。
数组的一个简单示例
让我们通过以下步骤编写一个真正简单的数组工作示例。你可以在这个示例的完整代码在可下载的代码包中找到。它在Chapter5/SimpleArrayExample/MainActivity.java:
-
创建一个带有空白活动的项目,就像我们在第二章中做的那样,Android 入门。同时,通过删除不必要的部分来清理代码,但这不是必需的。
-
首先,我们声明我们的数组,分配五个空间,并将一些值初始化到每个元素中:
//Declaring an array int[] ourArray; //Allocate memory for a maximum size of 5 elements ourArray = new int[5]; //Initialize ourArray with values //The values are arbitrary as long as they are int //The indexes are not arbitrary 0 through 4 or crash! ourArray[0] = 25; ourArray[1] = 50; ourArray[2] = 125; ourArray[3] = 68; ourArray[4] = 47; -
我们将每个值输出到logcat控制台。注意,当我们添加数组元素时,我们是在多行上进行的。这是可以的,因为我们直到最后一个操作之前省略了分号,所以 Java 编译器将这些行视为一个语句:
//Output all the stored values Log.i("info", "Here is ourArray:"); Log.i("info", "[0] = "+ourArray[0]); Log.i("info", "[1] = "+ourArray[1]); Log.i("info", "[2] = "+ourArray[2]); Log.i("info", "[3] = "+ourArray[3]); Log.i("info", "[4] = "+ourArray[4]); //We can do any calculation with an array element //As long as it is appropriate to the contained type //Like this: int answer = ourArray[0] + ourArray[1] + ourArray[2] + ourArray[3] + ourArray[4]; Log.i("info", "Answer = "+ answer); -
在模拟器上运行示例。
记住,在模拟器显示上不会发生任何事情,因为整个输出都将发送到 Android Studio 中的我们的logcat控制台窗口。以下是前面代码的输出:
info﹕ Here is ourArray:
info﹕ [0] = 25
info﹕
[1] = 50
info﹕ [2] = 125
info﹕ [3] = 68
info﹕ [4] = 47
info﹕ Answer = 315
在第 2 步中,我们声明了一个名为ourArray的数组来存储int变量,并为该类型分配了最多五个变量的空间。
接下来,我们为我们的数组中的五个空间分别赋值。记住,第一个空间是ourArray[0],最后一个空间是ourArray[4]。
在第 3 步中,我们简单地打印出每个数组位置的值到控制台。从输出中,我们可以看到它们持有我们在上一步中初始化的值。然后我们将ourArray中的每个元素相加,并将它们的值初始化到answer变量中。然后我们将answer打印到控制台,并看到所有的值都被加在一起,就像它们是以稍微不同的方式存储的普通旧int类型一样,这正是它们的本质。
数组的动态使用
就像我们在所有这些数组内容开始讨论时讨论的那样,如果我们需要单独声明和初始化数组的每个元素,那么数组与常规变量相比并没有太大的好处。让我们看看动态声明和初始化数组的示例。
动态数组示例
按照以下步骤创建一个非常简单的动态数组。你可以在这个示例的工作项目中找到它,在下载包中,位于Chapter5/DynamicArrayExample/MainActivity.java:
-
创建一个带有空白活动的项目,就像我们在第二章中做的那样,开始使用 Android。同时,通过删除不必要的部分来清理代码,但这不是必需的。
-
在
onCreate的括号内输入以下内容。在我们讨论和分析代码之前,看看你能否预测输出结果://Declaring and allocating in one step int[] ourArray = new int[1000]; //Let's initialize ourArray using a for loop //Because more than a few variables is allot of typing! for(int i = 0; i < 1000; i++){ //Put the value of ourValue into our array //At the position determined by i. ourArray[i] = i*5; //Output what is going on Log.i("info", "i = " + i); Log.i("info", "ourArray[i] = " + ourArray[i]); } -
在模拟器上运行示例。记住,在模拟器显示上不会发生任何操作,因为所有的输出都将发送到 Android Studio 中的我们的logcat控制台窗口。以下是前面代码的输出:
info﹕ i = 0 info﹕ ourArray[i] = 0 info﹕ i = 1 info﹕ ourArray[i] = 5 info﹕ i = 2 info﹕ ourArray[i] = 10为了简洁起见,我删除了循环的 994 次迭代:
info﹕ ourArray[i] = 4985 info﹕ i = 998 info﹕ ourArray[i] = 4990 info﹕ i = 999 info﹕ ourArray[i] = 4995
所有的操作都在第 2 步完成。我们声明并分配了一个名为ourArray的数组来存储最多 1,000 个int类型的值。然而,这次我们在一行代码中完成了这两个步骤:
int[] ourArray = new int[1000];
然后我们使用了一个设置为循环 1,000 次的for循环:
(int i = 0; i < 1000; i++){
我们初始化数组中的空间从 0 到 999,其值为i乘以5,如下所示:
ourArray[i] = i*5;
为了展示i的值和数组中每个位置所持有的值,我们按照以下方式输出i的值和数组中相应位置的值:
Log.i("info", "i = " + i);
Log.i("info", "ourArray[i] = " + ourArray[i]);
所有这些操作重复了 1,000 次,产生了我们看到的输出。
使用数组进入第 n 维
我们非常简短地提到了数组甚至可以在每个位置存储其他数组。现在,如果一个数组包含很多存储很多其他类型的数组,我们如何访问包含数组中的值?我们为什么需要这样做呢?看看多维数组可以有用的下一个示例。
多维数组的示例
按照以下步骤创建一个非常简单的多维数组。你可以在这个示例的工作项目中找到它,在下载包中,位于Chapter5/MultidimensionalArrayExample/MainActivity.java:
-
创建一个带有空白活动的项目,就像我们在第二章中做的那样,开始使用 Android。同时,通过删除不必要的代码来清理代码,但这不是必需的。
-
在调用
setContentView之后,声明并初始化一个二维数组,如下所示://A Random object for generating question numbers later Random randInt = new Random(); //And a variable to hold the random value generated int questionNumber; //We declare and allocate in separate stages for clarity //but we don't have to String[][] countriesAndCities; //Here we have a 2 dimensional array //Specifically 5 arrays with 2 elements each //Perfect for 5 "What's the capital city" questions countriesAndCities = new String[5][2]; //Now we load the questions and answers into our arrays //You could do this with less questions to save typing //But don't do more or you will get an exception countriesAndCities [0][0] = "United Kingdom"; countriesAndCities [0][1] = "London"; countriesAndCities [1][0] = "USA"; countriesAndCities [1][1] = "Washington"; countriesAndCities [2][0] = "India"; countriesAndCities [2][1] = "New Delhi"; countriesAndCities [3][0] = "Brazil"; countriesAndCities [3][1] = "Brasilia"; countriesAndCities [4][0] = "Kenya"; countriesAndCities [4][1] = "Nairobi"; -
现在我们使用
for循环和一个Random类对象输出数组的内容。注意我们如何确保虽然问题是随机的,但我们总能选择正确的答案://Now we know that the country is stored at element 0 //The matching capital at element 1 //Here are two variables that reflect this int country = 0; int capital = 1; //A quick for loop to ask 3 questions for(int i = 0; i < 3; i++){ //get a random question number between 0 and 4 questionNumber = randInt.nextInt(5); //and ask the question and in this case just //give the answer for the sake of brevity Log.i("info", "The capital of " +countriesAndCities[questionNumber][country]); Log.i("info", "is " +countriesAndCities[questionNumber][capital]); }//end of for loop
在模拟器上运行示例。再次强调,在模拟器显示上不会发生任何事情,因为输出将被发送到 Android Studio 中的logcat控制台窗口。以下是上一段代码的输出:
info﹕ The capital of USA
info﹕ is Washington
info﹕ The capital of India
info﹕ is New Delhi
info﹕ The capital of United Kingdom
info﹕ is London
刚才发生了什么?让我们逐块分析,以便我们确切地知道发生了什么。
我们创建一个新的Random类型对象,命名为randInt,以便在程序后面生成随机数:
Random randInt = new Random();
我们声明一个简单的int变量来存储问题编号:
int questionNumber;
然后我们声明countriesAndCities,我们的数组数组。外层数组包含数组:
String[][] countriesAndCities;
现在我们为我们的数组分配空间。第一个外层数组将能够容纳五个数组,每个内层数组将能够容纳两个字符串:
countriesAndCities = new String[5][2];
接下来,我们初始化我们的数组来存储国家和它们相应的首都城市。注意,随着每次初始化配对,外层数组的数字保持不变,这表明每个国家/首都对都在一个内层数组(字符串数组)中。当然,这些内层数组中的每一个都存储在外层数组的一个元素中(它包含数组):
countriesAndCities [0][0] = "United Kingdom";
countriesAndCities [0][1] = "London";
countriesAndCities [1][0] = "USA";
countriesAndCities [1][1] = "Washington";
countriesAndCities [2][0] = "India";
countriesAndCities [2][1] = "New Delhi";
countriesAndCities [3][0] = "Brazil";
countriesAndCities [3][1] = "Brasilia";
countriesAndCities [4][0] = "Kenya";
countriesAndCities [4][1] = "Nairobi";
为了使即将到来的for循环更清晰,我们声明并初始化int变量来表示来自我们数组的国家和首都。如果你回顾一下数组初始化,所有的国家都存储在内层数组的0位置,所有相应的首都城市都存储在1位置:
int country = 0;
int capital = 1;
现在我们创建一个将运行三次的for循环。请注意,这个数字并不意味着我们访问数组的第一个三个元素。它实际上是我们通过循环的次数。我们可以让它循环一次或一千次,但示例仍然会工作:
for(int i = 0; i < 3; i++){
接下来,我们实际上确定要问哪个问题,或者更具体地说,确定我们外层数组中的哪个元素。记住randInt.nextInt(5)返回一个介于 0 到 4 之间的数字。这正是我们所需要的,因为我们有一个包含五个元素的外层数组,从 0 到 4:
questionNumber = randInt.nextInt(5);
现在,我们可以通过输出内层数组中持有的字符串来提出一个问题,而内层数组又是由上一行通过随机生成的数字选择的外层数组持有的:
Log.i("info", "The capital of " +countriesAndCities[questionNumber][country]);
Log.i("info", "is " +countriesAndCities[questionNumber][capital]);
}//end of for loop
为了记录,我们将在本书的其余部分不使用任何多维数组。所以如果关于这些数组内部的数组还有一点模糊不清,那就没关系了。你知道它们存在以及它们能做什么,所以如果需要可以回过头来复习它们。
数组越界异常
当我们尝试访问一个不存在的数组元素时,会发生数组越界异常。每次我们尝试这样做时,我们都会得到一个错误。有时,编译器会捕获它,以防止错误进入一个正在运行的游戏,就像这样:
int[] ourArray = new int[1000];
int someValue = 1;//Arbitrary value
ourArray[1000] = someValue;//Won't compile as compiler knows this won't work.
//Only locations 0 through 999 are valid
猜猜如果我们写点这样的东西会发生什么:
int[] ourArray = new int[1000];
int someValue = 1;//Arbitrary value
int x = 999;
if(userDoesSomething){x++;//x now equals 1000
}
ourArray[x] = someValue;
//Array out of bounds exception if userDoesSomething evaluates to true! This is because we end up referencing position 1000 when the array only has positions 0 through 999
//Compiler can't spot it and game will crash on player - yuck!
我们避免这种问题的唯一方法就是了解规则。规则是数组从零开始,到分配数减一为止。我们还可以使用清晰、易读的代码,这样我们就可以轻松评估我们所做的工作,并找出问题。
线程的定时
那么什么是线程呢?你可以把 Java 编程中的线程想象成故事中的线程。在一个故事的一个线程中,我们有主要角色在前线与敌人战斗,而在另一个线程中,士兵的家庭在日常生活中过着日子。当然,一个故事不一定只有两个线程。我们可以引入第三个线程。也许故事还讲述了政治家和军事指挥官在做决策。这些决策微妙地或不太微妙地影响着其他线程发生的事情。
编程中的线程就像这样。我们在程序中创建部分/线程,它们为我们控制不同的方面。我们引入线程来表示这些不同的方面,原因如下:
-
从组织角度来看是有意义的
-
它们是构建程序的一种经过验证的方法
-
我们正在工作的系统的性质迫使我们使用它们
在 Android 中,我们出于所有这些原因同时使用线程。这是有意义的,它有效,我们必须使用它,因为系统的设计。
在游戏中,想想看有一个线程接收玩家的“左”,“右”和“射击”按钮点击,一个线程代表外星人思考下一步该移动到哪里,还有一个线程在屏幕上绘制所有图形。
具有多线程的程序可能会出现问题。就像故事中的线程一样,如果未能进行适当的同步,那么事情就会出错。如果我们的士兵在战斗或甚至战争存在之前就进入了战场呢?太奇怪了!
假设我们有一个变量,int x,它代表程序中三个线程使用的关键数据。如果一个线程稍微领先于自己,使得数据对其他两个线程“错误”怎么办?这个问题是正确性问题,由多个线程竞速完成,彼此之间毫无察觉——因为毕竟它们只是愚蠢的代码。
正确性问题可以通过对线程的密切监督和锁定来解决。锁定意味着暂时阻止一个线程的执行,以确保事情以同步的方式进行。这就像冻结士兵上船参战,直到船真正靠岸,跳板降下,避免尴尬的溅水。
具有多线程的程序的其他问题是死锁问题,其中一个或多个线程被锁定,等待合适的时机访问x,但那个时刻永远不会到来,整个程序最终会停止运行。
你可能已经注意到,第一个问题(正确性)的解决方案是导致第二个问题(死锁)的原因。现在考虑我们刚刚讨论的所有内容,并将其与 Android Activity 生命周期结合起来。你可能会因为复杂性而感到有些恶心。
幸运的是,问题已经为我们解决了。正如我们使用Activity类并重写其方法来与 Android 生命周期交互一样,我们也可以使用其他类来创建和管理我们的线程。正如Activity一样,我们只需要知道如何使用它们,而不需要知道它们是如何工作的。
所以,当你问我所有这些关于线程的事情,而你实际上并不需要知道时,你可能会正确地问道。这仅仅是因为我们将要编写的代码看起来不同,并且以不熟悉的方式结构化。如果我们能够做到以下这些,我们就不需要流汗来编写我们的 Java 代码以创建和在我们的线程中工作:
-
接受我们将要引入的新概念是我们为了创建与线程相关的问题的 Android 特定解决方案所必需的
-
理解线程的一般概念,这通常与几乎同时发生的故事线程相同
-
学习使用一些 Android 线程类的几个规则
注意,我在第三点中说的是类,复数。不同的线程类在不同的情境下工作得最好。你甚至可以写一本书专门讨论 Android 中的线程。在这本书中,我们将使用两个线程类。在本章中,我们将使用Handler。在第七章,Retro Squash Game和第八章,蛇游戏中,我们将使用Runnable类。我们只需要记住的是,我们将编写几乎同时运行的程序的部分。
小贴士
我所说的“几乎”是什么意思?实际上发生的情况是 CPU 会依次在各个线程之间切换。然而,这个过程发生得如此之快,以至于我们无法感知到任何东西,只能感觉到同时性。
使用 Handler 类的一个简单的线程计时器示例
在这个示例之后,当我们意识到线程并不像最初担心的那样复杂时,我们可以松一口气。在真实游戏中使用线程时,我们将在简单示例的代码旁边添加一些额外的代码,但这并不多,我们将在适当的时候讨论它。
如同往常,你可以简单地使用下载包中的完整代码。该项目位于Chapter5/SimpleThreadTimer/MainActivity.java。
正如其名所示,我们将创建一个计时器——这在许多游戏中都是一个非常有用的功能:
-
创建一个带有空白活动的项目,就像我们在第二章中做的那样,开始使用 Android。同时,通过删除不必要的部分来清理代码,但这不是必需的。
-
在类声明之后立即输入三条突出显示的行:
public class MainActivity extends Activity { private Handler myHandler; boolean gameOn; long startTime; -
在
onCreate方法中输入此代码。它将在if(gameOn)块中创建一个线程,并有一些其他操作://How many milliseconds is it since the UNIX epoch startTime = System.currentTimeMillis(); myHandler = new Handler() { public void handleMessage(Message msg) { super.handleMessage(msg); if (gameOn) { long seconds = ((System.currentTimeMillis() - startTime)) / 1000; Log.i("info", "seconds = " + seconds); } myHandler.sendEmptyMessageDelayed(0, 1000); } }; gameOn = true; myHandler.sendEmptyMessage(0); } -
运行应用程序。在模拟器上使用主页或返回按钮退出。请注意,它仍在控制台打印。当我们实现我们的记忆游戏时,我们将处理这个异常。
当你在模拟器上运行示例时,请记住模拟器显示上不会发生任何事情,因为所有的输出都将发送到 Android Studio 中的logcat控制台窗口。以下是之前代码的输出:
info﹕ seconds = 1
info﹕ seconds = 2
info﹕ seconds = 3
info﹕ seconds = 4
info﹕ seconds = 5
info﹕ seconds = 6
那么,刚才发生了什么?在 1 秒间隔后,已过去的时间数被打印到控制台。让我们学习这是如何发生的。
首先,我们声明了一个新的对象,名为myHandler,其类型为Handler。然后我们声明了一个名为gameOn的布尔变量。我们将使用它来跟踪游戏何时运行。最后,这段代码块的最后一行声明了一个long类型的变量。你可能还记得从第三章 讲 Java - 你的第一个游戏 中学到的long类型。我们可以使用long变量来存储非常大的整数,这就是我们在startTime中做的事情:
private Handler myHandler;
boolean gameOn;
long startTime;
接下来,我们使用currentTimeMillis方法初始化startTime,这是System类的一个方法。该方法包含自 1970 年 1 月 1 日以来的毫秒数。我们将在下一行代码中看到我们如何使用这个值。
startTime = System.currentTimeMillis();
接下来是重要的代码。直到if(gameOn)的所有代码标记了定义我们线程的代码。当然,代码有点长,但并不像第一眼看上去那么糟糕。此外,记住我们只需要使用线程;我们不需要理解它们如何工作的每一个方面。
让我们剖析前面的代码,以消除一些神秘感。myHandler = new Handler()这一行只是初始化我们的myHandler对象。与之前看到的不同之处在于,我们紧接着立即定制该对象。我们重写了handleMessage方法(这是我们放置在线程中运行的代码的地方),然后调用super.handleMessage,在运行我们的自定义代码之前调用默认版本的handleMessage。这与每次调用super.onCreate时我们为onCreate方法所做的是一样的。
然后我们有if(gameOn)块。该if块中的所有代码都是我们想要在线程中运行的代码。if(gameOn)块只是给我们提供了一个控制是否运行我们代码的方法。例如,我们可能想让线程运行,但只在某些时候运行我们的代码。这个if语句给了我们轻松选择的能力。现在看看代码。我们将在稍后分析if块中发生的事情:
myHandler = new Handler() {
public void handleMessage(Message msg) {
super.handleMessage(msg);
if (gameOn) {
long seconds = ((System.currentTimeMillis() - startTime)) / 1000;
Log.i("info", "seconds = " + seconds);
}
myHandler.sendEmptyMessageDelayed(0, 1000);
}
};
在if块内部,我们声明并初始化另一个名为seconds的long变量,并对其进行一些数学运算:
long seconds = ((System.currentTimeMillis() - startTime)) / 1000;
首先,我们获取自 1970 年 1 月 1 日以来的当前毫秒数,然后从中减去startTime。这给出了自我们首次初始化startTime以来的毫秒数。然后我们将答案除以 1000,得到秒的值。我们使用以下行将此值打印到控制台:
Log.i("info", "seconds = " + seconds);
接下来,就在我们的if块之后,我们有这一行:
myHandler.sendEmptyMessageDelayed(0, 1000);
上行代码告诉 Android 系统,我们希望在每 1000 毫秒(每秒)运行一次handleMessage方法中的代码。
在onCreate中,在handleMessage方法和Handler类的闭合花括号之后,我们最终将gameOn设置为true,这样就可以在if块中运行代码:
gameOn = true;
然后,代码的最后一行开始在我们线程和 Android 系统之间传递消息的流程:
myHandler.sendEmptyMessage(0);
值得指出的是,if块内的代码可以是我们需要的最少或最多。当我们实现我们的记忆游戏时,我们将在if块中看到更多的代码。
我们真正需要了解的是,我们刚才看到的相对复杂的设置允许我们在新线程中运行if块的内容。就是这样!也许除了快速浏览一下System类之外。
注意
System类有很多用途。在这种情况下,我们使用它来获取自 1970 年 1 月 1 日以来的毫秒数。这是一种在计算机中测量时间的通用系统。它被称为 Unix 时间,1970 年 1 月 1 日的第一毫秒被称为 Unix 纪元。我们将在整本书中多次遇到这个概念。
关于线程的讨论就到这里,让我们制造一些噪音吧!
喇叭声和蜂鸣声 - Android 声音
本节将分为两部分——创建和使用声音效果。所以,让我们开始吧。
创建声音效果
几年前,每当我制作一个游戏,我就会花很多小时在提供免费声音效果的网站上搜索。虽然那里有很多好的,但真正出色的总是很昂贵,无论你付多少钱,它们永远不是你想要的。然后一个朋友指出一个简单的开源应用程序 Bfxr,从那以后我再也没有浪费时间寻找声音效果了。我们可以自己制作。
这里是使用 Bfxr 制作您自己的声音效果的快速指南。从www.bfxr.net获取 Bfxr 的免费副本。
按照网站上的简单说明进行设置。尝试一些这些示例来制作酷炫的声音效果:
提示
这是一个非常简化的教程。您可以使用 Bfxr 做更多的事情。要了解更多,请阅读之前 URL 网站上提供的提示。
-
运行
bfxr.exe:![创建声音效果]()
-
尝试所有预设类型,它们会生成该类型的随机声音。当您得到一个接近您想要的声音时,进入下一步:
![创建声音效果]()
-
使用滑块来微调您新声音的音高、时长和其他方面:
![创建声音效果]()
-
通过点击导出 Wav按钮保存你的声音。尽管这个按钮的名字是 Wav,但正如我们将看到的,我们可以保存成除了
.wav以外的其他格式。![创建声音效果]()
-
Android 喜欢使用 OGG 格式的声音,所以当被要求命名文件时,在文件名后面使用
.ogg扩展名。 -
如有需要,重复步骤 2 到 5。
小贴士
本书中每个需要声音样本的项目都附带提供的声音样本,但正如我们所看到的,制作自己的样本更有趣。你只需要将它们保存为与提供的样本相同的文件名。
在 Android 中播放声音
为了完成这个简短的示例,你需要三个保存为.ogg格式的声音效果。所以如果你没有,请回到创建声音效果部分制作一些。或者,你可以使用代码包中Chapter5/PlayingSounds/assets文件夹提供的声音。像往常一样,你可以在Chapter5/PlayingSounds/java/MainActivity.java和Chapter5/PlayingSounds/layout/activity_main.xml中查看或使用已经完成好的代码。现在执行以下步骤:
-
创建一个带有空白活动的项目,就像我们在第二章中做的那样,Android 入门。同时,通过删除不必要的部分来清理代码,尽管这不是必需的。
-
创建三个声音文件,并将它们保存为
sample1.ogg、sample2.ogg和sample3.ogg。 -
在项目资源管理器窗口的
main文件夹中,我们需要添加一个名为assets的文件夹。所以在项目资源管理器窗口中,右键点击main文件夹,导航到新建 | 目录。在新建目录对话框中输入assets。 -
现在将三个声音文件复制并粘贴到新创建的
assets文件夹中。或者,选择这三个文件,右键点击它们,然后点击复制。然后点击 Android Studio 项目资源管理器中的assets文件夹。现在右键点击assets文件夹并点击粘贴。 -
在编辑窗口中打开
activity_main.xml,并将三个按钮小部件拖拽到你的 UI 上。它们的位置和排列方式无关紧要。当你查看任何我们三个新按钮的id属性在属性窗口中时,你会注意到它们已经被自动分配了id属性。它们是button、button2和button3。正如我们将看到的,这正是我们所需要的。 -
让我们的活动能够监听按钮的点击,就像我们在所有其他按钮示例中做的那样实现
onClickListener。在编辑窗口中打开MainActivity.java。将public class MainActivity extends Activity {这一行替换为以下代码:public class MainActivity extends Activity implements View. OnClickListener { -
如前所述,我们在新的一行代码上得到了一个难看的红色下划线。上次发生这种情况时,我们输入了必须实现的
onClick方法的空体,一切顺利。这次,因为我们已经知道这里的情况,我们将学习一个快捷方式。将鼠标光标悬停在错误上,然后右键单击。现在点击生成...然后选择实现方法...。在选择 要实现的方法对话框中,onClick(View):void已经选中:![在 Android 中播放声音]()
-
通过点击确定选择此选项。现在滚动到代码的底部,你会看到 Android Studio 已经非常友好地为你实现了
onClick方法,错误也已经消失了。 -
在
MainActivity声明之后输入此代码以声明一些用于声音效果的变量:private SoundPool soundPool; int sample1 = -1; int sample2 = -1; int sample3 = -1; -
在
onCreate方法中输入此代码以将我们的声音加载到内存中:soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC,0); try{ //Create objects of the 2 required classes AssetManager assetManager = getAssets(); AssetFileDescriptor descriptor; //create our three fx in memory ready for use descriptor = assetManager.openFd("sample1.ogg"); sample1 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample2.ogg"); sample2 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample3.ogg"); sample3 = soundPool.load(descriptor, 0); }catch(IOException e){ //catch exceptions here } -
现在添加代码以获取我们 UI 中按钮的引用并监听它们的点击:
//Make a button from each of the buttons in our layout Button button1 =(Button) findViewById(R.id.button); Button button2 =(Button) findViewById(R.id.button2); Button button3 =(Button) findViewById(R.id.button3); //Make each of them listen for clicks button1.setOnClickListener(this); button2.setOnClickListener(this); button3.setOnClickListener(this); -
最后,在自动生成的
onClick方法中输入此代码:switch (view.getId()) { case R.id.button://when the first button is pressed //Play sample 1 soundPool.play(sample1, 1, 1, 0, 0, 1); break; //Now the other buttons case R.id.button2: soundPool.play(sample2, 1, 1, 0, 0, 1); break; case R.id.button3: soundPool.play(sample3, 1, 1, 0, 0, 1); break; }
在模拟器或真实 Android 设备上运行示例。注意,通过点击按钮,你可以随意播放你的三个声音样本。当然,声音可以在几乎任何时间播放,而不仅仅是按钮按下时。也许它们可以从线程中播放。当我们在本章后面实现记忆游戏时,我们将看到更多的声音样本。
这就是代码的工作原理。我们首先以通常的方式设置了一个新项目。然而,在第 2 步到第 5 步中,我们使用 Bfxr 创建了一些声音,创建了一个assets文件夹,并将文件放入其中。这是 Android 期望找到声音文件的文件夹。因此,当我们编写下一步骤中引用声音文件的代码时,Android 系统将能够找到它们。
在第 6 步到第 8 步中,我们使我们的活动能够监听按钮点击,就像我们之前多次做的那样。但这次,我们让 Android Studio 自动生成onClick方法。
然后我们看到了这段代码:
private SoundPool soundPool;
首先,我们创建了一个名为soundPool的SoundPool类型的对象。这个对象将是我们在 Android 设备上制造噪音的关键。接下来,我们有以下代码:
int sample1 = -1;
int sample2 = -1;
int sample3 = -1;
之前的代码非常简单;我们声明了三个int变量。然而,它们比普通的int变量有更深层次的目的。正如我们将在下一块代码分析中看到的那样,它们将被用来保存一个引用到已加载到内存中的声音文件。换句话说,Android 系统将为每个变量分配一个数字,该数字将引用我们的声音文件在内存中的位置。
我们可以将其视为我们变量仓库中的一个位置。因此,我们知道int变量的名称,并且其中包含 Android 需要找到我们的声音的内容。以下是我们将声音加载到内存中并使用我们刚刚讨论的引用的方法。
让我们把第 10 步中的代码拆分成几个部分。仔细看看,然后我们将检查正在发生的事情:
soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC,0);
在这里,我们初始化我们的 soundPool 对象,并请求最多 10 个同时播放的声音流。我们应该能够在每次按下应用按钮时都能得到声音。AudioManager.STREAM_MUSIC 描述了流的类型。这对于此类应用来说是典型的。最后,0 参数表示我们希望默认音质的声音。
现在我们看到了一些新的东西。注意,接下来的代码块被包裹在两个块中,try 和 catch。这意味着如果 try 块中的代码失败,我们希望 catch 块中的代码运行。正如你所看到的,catch 块中除了注释之外没有其他内容。
我们必须这样做,因为 SoundPool 类的设计方式。如果你尝试在不使用 try 和 catch 块的情况下编写代码,它将不会工作。这是典型的涉及从文件读取的 Java 类。这是一个检查文件是否可读甚至是否存在的安全过程。你可以在控制台输出一行代码来显示错误已发生。
小贴士
如果你想要尝试使用 try/catch,那么在 catch 块中添加一行代码以输出一条消息,并从资源文件夹中删除一个声音文件。当你运行应用时,加载将失败,并且 catch 块中的代码将被触发。
我们将不顾一切,因为我们相当确信文件会存在并且可以工作。让我们检查 try 块中的内容。仔细看看以下代码,然后我们将对其进行剖析:
try{
//Create objects of the 2 required classes
AssetManager assetManager = getAssets();
AssetFileDescriptor descriptor;
//create our three fx in memory ready for use
descriptor = assetManager.openFd("sample1.ogg");
sample1 = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("sample2.ogg");
sample2 = soundPool.load(descriptor, 0);
descriptor = assetManager.openFd("sample3.ogg");
sample3 = soundPool.load(descriptor, 0);
}catch(IOException e){
//catch exceptions here
}
首先,我们创建了一个名为 assetManager 的 AssetManager 类型的对象和一个名为 descriptor 的 AssetFileDescriptor 对象。然后我们使用这两个对象组合来加载我们的第一个声音样本,如下所示:
descriptor = assetManager.openFd("sample1.ogg");
sample1 = soundPool.load(descriptor, 0);
现在,我们已经将一个声音样本加载到内存中,并将其位置保存在我们名为 sample1 的 int 变量中。第一个声音文件 sample1.ogg 现在可以使用了。我们对 sample2 和 sample3 执行相同的程序,我们就准备好制造一些噪音了!
在第 11 步中,我们设置了我们的按钮,这是我们之前见过几次的。在第 12 步中,我们有了准备执行不同操作的开关块。你可能已经注意到,每个按钮执行的单个操作是播放声音。例如,Button1 执行如下:
soundPool.play(sample1, 1, 1, 0, 0, 1);
这行代码播放内存中由 int sample1 指示位置加载的声音。
注意
方法参数从左到右定义如下:要播放的样本,左声道音量,右声道音量,相对于其他播放声音的优先级,循环与否,播放速率。如果你喜欢,可以玩玩这些参数。尝试将循环参数设置为 3,将速率参数设置为 1.5。
我们以相同的方式处理每个按钮。现在让我们学习一些严肃的内容。
破坏后的生活——持久性
好吧,这听起来可能很复杂,但在制作游戏时,这是一个重要的主题。你可能已经注意到,最轻微的事情都可能重置我们的数学游戏,比如 incoming phone call,电量耗尽,或者甚至将设备倾斜到不同的方向。
当这些事件发生时,我们可能希望我们的游戏记住它当时的确切状态,这样当玩家回来时,它就在他们离开的地方。如果你使用的是文字处理应用程序,你肯定会期望这种行为。
我们的游戏不会达到那种程度,但作为最低限度,我们至少应该记住最高分。这给玩家一个目标,最重要的是,一个回到我们游戏的原因。
持久性的一个例子
Android 和 Java 有许多不同的方法来实现数据的持久性,从读取和写入文件到通过我们的代码设置和使用整个数据库。然而,对于本书中的示例来说,最整洁、最简单、最合适的方法是使用SharedPreferences类。
在这个例子中,我们将使用SharedPreferences类来保存数据。实际上,我们将读取和写入文件,但这个类隐藏了所有的复杂性,使我们能够专注于游戏。
我们将看到一个相对抽象的持久性例子,这样在我们使用类似的方法在记忆游戏中保存最高分之前,我们就能熟悉代码。这个例子的完整代码可以在代码包Chapter5/Persistence/java/MainActivity.java和Chapter5/Persistence/layout/activity_main.xml中找到:
-
创建一个带有空白活动的项目,就像我们在第二章中做的那样,Android 入门。同时,通过删除不必要的部分来清理代码,但这不是必需的。
-
在编辑器窗口中打开
activity_main.xml,从调色板中拖动一个按钮到设计区域。按钮分配的默认 ID 非常适合我们的用途,因此不需要在 UI 上进行进一步的工作。 -
在编辑器窗口中打开
MainActivity.java。实现View.OnClickListener并自动生成所需的onClick方法,就像我们在之前的在 Android 中播放声音示例的第 6 步和第 7 步中所做的那样。 -
在
MainActivity声明之后输入以下代码。这声明了我们将在幕后执行所有复杂操作的两个对象:一些有用的字符串和一个按钮:SharedPreferences prefs; SharedPreferences.Editor editor; String dataName = "MyData"; String stringName = "MyString"; String defaultString = ":-("; String currentString = "";//empty Button button1; -
在调用
setContentView之后,将下一块代码添加到onCreate方法中。我们初始化我们的对象并设置我们的按钮。示例完成后,我们将仔细查看这段代码://initialize our two SharedPreferences objects prefs = getSharedPreferences(dataName,MODE_PRIVATE); editor = prefs.edit(); //Either load our string or //if not available our default string currentString = prefs.getString(stringName, defaultString); //Make a button from the button in our layout button1 =(Button) findViewById(R.id.button); //Make each it listen for clicks button1.setOnClickListener(this); //load currentString to the button button1.setText(currentString); -
现在动作发生在我们的
onClick方法中。添加以下代码,它生成一个随机数并将其添加到currentString的末尾。然后它保存字符串并将字符串的值设置为按钮://we don't need to switch here! //There is only one button //so only the code that actually does stuff //Get a random number between 0 and 9 Random randInt = new Random(); int ourRandom = randInt.nextInt(10); //Add the random number to the end of currentString currentString = currentString + ourRandom; //Save currentString to a file in case the user //suddenly quits or gets a phone call editor.putString(stringName, currentString); editor.commit(); //update the button text button1.setText(currentString);
在模拟器或设备上运行示例。注意,每次你按按钮时,都会将一个随机数附加到按钮的文本上。现在退出应用程序,或者如果你喜欢,甚至关闭设备。当你重新启动应用程序时,我们酷炫的SharedPreferences类简单地加载最后保存的字符串。
这就是代码的工作方式。直到第 4 步,我们之前没有看到过任何我们没有看到过的东西:
SharedPreferences prefs;
SharedPreferences.Editor editor;
在这里,我们声明了两种名为prefs和editor的SharedPreferences对象。我们将在一分钟内看到我们如何使用它们。
接下来,我们声明了dataName和stringName字符串。我们这样做是因为要使用SharedPreferences的设施,我们需要使用一致的名字来引用我们的数据集合,以及其中任何单个数据项。通过初始化dataName和stringName,我们可以分别将它们用作数据存储的名称以及数据存储中的特定项。defaultString中的悲伤面孔在SharedPreferences对象需要默认值时被使用,因为要么之前没有保存任何内容,要么由于某种原因加载过程失败。currentString变量将保存我们将要保存和加载的字符串,以及显示给我们的应用程序用户。我们的按钮是button1:
String dataName = "MyData";
String stringName = "MyString";
String defaultString = ":-(";
String currentString = "";//empty
Button button1;
在第 5 步中,真正的动作从以下代码开始:
prefs = getSharedPreferences(dataName,MODE_PRIVATE);
editor = prefs.edit();
currentString = prefs.getString(stringName, defaultString);
之前的代码做了如果没有有用的SharedPreferences类将需要更多代码的事情。前两行初始化对象,第三行从我们的数据存储项(其名称包含在stringName中)加载值到currentString变量。第一次这样做时,它使用defaultString值,因为那里还没有存储任何内容,但一旦有值被存储,这一行代码将加载我们保存的字符串。
在第 5 步结束时,我们设置了我们的按钮,就像我们之前多次做的那样。接下来进行第 6 步,在onClick方法中,因为没有多个按钮,所以没有switch块。所以如果检测到点击,那一定是我们的按钮。以下是onClick的前三条代码:
Random randInt = new Random();
int ourRandom = randInt.nextInt(10);
currentString = currentString + ourRandom;
我们生成一个随机数并将其附加到currentString变量。接下来,仍然在onClick中,我们这样做:
editor.putString(stringName, currentString);
editor.commit();
这就像在onCreate中加载我们的字符串的代码的反面。前两行中的第一行标识了数据存储中写入值的位置(stringName)和要写入那里的值(currentString)。下一行editor.commit();只是说,“去做吧。”
以下行将currentString作为文本显示在我们的按钮上,这样我们就可以看到发生了什么:
button1.setText(currentString);
提示
想了解更多关于坚持的信息,请查看本章末尾的自测问题部分的第二个问题。
记忆游戏
记忆游戏中的代码不应该给我们带来太多挑战,因为我们已经对线程、数组、声音和持久性进行了背景研究。将会有一些看起来新的代码,当它出现时,我们将详细检查它。
这是我们的完成游戏的截图:

这是主页。它显示了高分,该分数在游戏会话之间以及当设备关闭时保持不变。它还显示了一个播放按钮,该按钮将玩家带到主游戏屏幕。看看下面的截图:

游戏屏幕本身将播放一系列声音和数字。相应的按钮将与相应的声音同步摇摆。然后玩家将能够与按钮交互并尝试复制序列。对于玩家正确复制序列的每一部分,他们都会获得积分。
如果整个序列被复制,则将播放一个新的更长的序列,并且玩家将再次尝试重复该序列。这将继续,直到玩家在序列的一部分出错。
随着分数的增加,它将在相关的 TextView 中显示,并且当序列正确复制时,级别将提高并在分数下方显示。
玩家可以通过按下重播按钮开始新游戏。如果达到高分,它将被保存到文件中并在主页上显示。
游戏的实现分为五个阶段。阶段结束时是一个很好的休息时机。以下是游戏的各个阶段:
-
第一阶段:这实现了 UI 和一些基础知识。
-
第二阶段:这为我们准备了变量,并向玩家展示要复制的图案。
-
第三阶段:在这个阶段,我们将处理玩家尝试复制图案时的响应。
-
第四阶段:在这里,我们将使用我们刚刚学到的持久性知识来保持玩家在退出游戏或关闭设备时的高分。
-
第五阶段:在第四阶段结束时,我们将拥有一个完全工作的记忆游戏。然而,为了增加我们的 Android 技能库,在讨论完本章末尾的 Android UI 动画后,我们将完成这个阶段,这将增强我们的记忆游戏。
所有包含完整代码和声音文件的文件,在所有五个阶段之后,都可以在Chapter5/MemoryGame文件夹中的下载捆绑包中找到。然而,在这个项目中,通过每个阶段的学习有很多东西可以学到。
第一阶段 – UI 和基础知识
在这里,我们将布局一个主页菜单屏幕 UI 和游戏本身的 UI。我们还将为一些 UI 元素配置一些 ID,以便我们可以在 Java 代码中稍后控制它们:
-
创建一个名为
Memory Game的新应用程序,如果你愿意,可以清理代码。 -
现在我们创建一个新的活动,并将其命名为
GameActivity。因此,在项目资源管理器中的java文件夹上右键单击,导航到新建 | 活动,然后单击下一步,将活动命名为GameActivity,然后单击完成。为了清晰起见,以我们清理所有其他活动相同的方式清理此活动。 -
将游戏设置为全屏并锁定方向,就像我们在第四章末尾的全屏和锁定方向教程中所做的那样,发现循环和方法。
-
从
res/layout文件夹中打开activity_main.xml文件。
让我们快速创建我们的主屏幕 UI,按照以下步骤操作:
-
在编辑器中打开
activity_main.xml并删除Hello WorldTextView。 -
单击并拖动以下内容:大文本到顶部中央(以创建我们的标题文本),图像在下面,另一个大文本在下面(用于我们的高分),以及一个按钮(玩家点击以开始游戏)。您的 UI 应该看起来有点像以下截图所示:
![阶段 1 – UI 和基础知识]()
-
调整两个
TextView和按钮元素的文本属性,使其明确表示每个将用于什么。像往常一样,您可以用您选择的任何图像替换ImageView中的 Android 图标(就像我们在第四章的添加自定义图像教程中所做的那样)。 -
以通常的方式调整元素的大小,以适应您将在其上运行游戏的模拟器或设备。
-
让我们使我们的高分
TextView的 ID 与其用途更相关。左键单击选择高分TextView,在属性窗口中找到其id属性,并将其更改为textHiScore。图像和标题的 ID 不是必需的,播放按钮的现有 ID 为button,这似乎已经足够合适。所以这里没有其他需要更改的地方。
让我们连接播放按钮,创建主屏幕和游戏屏幕之间的链接,如下所示:
-
在编辑器中打开
MainActivity.java。 -
在
MainActivity声明末尾添加implements View.onClickListener,使其现在看起来像这样:public class MainActivity extends Activity implements View.OnClickListener { -
现在,将鼠标悬停在您刚刚输入的行上,右键单击它。现在单击生成,然后单击实现方法...,然后单击确定,让 Android Studio 自动生成我们必须实现的
onClick方法。 -
在
onCreate方法末尾,在闭合花括号之前,输入以下代码以获取对播放按钮的引用并监听点击://Make a button from the button in our layout Button button =(Button) findViewById(R.id.button); //Make each it listen for clicks button.setOnClickListener(this); -
滚动到我们的
onClick方法,并在其主体中输入以下代码,以便播放按钮将玩家带到我们即将设计的GameActivity:Intent i; i = new Intent(this, GameActivity.class); startActivity(i);
到目前为止,应用程序将运行,玩家可以点击播放按钮,将其带到我们的游戏屏幕。所以让我们快速创建我们的游戏屏幕 UI:
-
在编辑器中打开
activity_game.xml并删除Hello World TextView。 -
将三个大文本元素一个接一个地拖动并水平居中。在它们下面,添加四个按钮,一个叠在另一个上面,最后,在下面添加另一个按钮,但将其向右偏移,使其看起来像下一张截图所示。我还调整了 UI 元素的文本属性,以便清楚地知道每个元素将用于什么,但这不是必需的,因为我们的 Java 代码将为我们完成所有工作。您也可以像往常一样调整元素的大小,以适应您将在其上运行游戏的模拟器或设备。
![阶段 1 – UI 和基础]()
-
现在让我们为我们的 UI 元素分配一些有用的 ID,这样我们就可以在下一教程中使用它们进行一些 Java 魔法。以下是一个表格,将上一张截图所示的 UI 元素与您需要分配的id属性值相匹配。将以下id属性值分配给相应的 UI 元素:
目的 默认 id 属性 新分配的 id 分数指示器 textView textScore 难度指示器 textView2 textDifficulty 观察/出发指示器 textView3 textWatchGo 按钮 1 button 保持默认 按钮 2 button2 保持默认 按钮 3 button3 保持默认 按钮 4 button4 保持默认 重放按钮 button5 buttonReplay
现在我们已经准备好了游戏菜单和实际的游戏 UI,我们可以开始让它工作。
阶段 2 – 准备变量和展示模式
在这里,我们将设置大量变量和对象供我们使用,包括这个阶段和后续阶段。我们还将实现向玩家展示模式的代码部分。我们将在后续阶段添加允许玩家做出反应的代码:
-
在编辑器窗口中打开
GameActivity.java。 -
我通过找到一个令人愉悦的声音,然后逐渐增加每个后续样本的频率滑块来制作声音。您可以使用
MemoryGame项目中的assets文件夹中的我的声音,或者使用 Bfxr 创建自己的声音。 -
在项目资源管理器窗口的
main文件夹中,我们需要添加一个名为assets的文件夹。因此,在项目资源管理器窗口中,右键单击main文件夹,导航到新建 | 目录。在新建目录对话框中键入assets。 -
现在将四个声音文件复制并粘贴到新创建的
assets文件夹中。您可以这样做:选择文件,右键单击它们,然后单击复制。然后单击 Android Studio 项目资源管理器中的assets文件夹。现在右键单击assets文件夹并单击粘贴。
让我们准备GameActivity以监听按钮点击,就像我们对MainActivity所做的那样,如下所示:
-
在
GameActivity声明末尾添加implementsView.onClickListener,使其看起来如下所示:public class GameActivity extends Activity implements View.OnClickListener { -
现在将鼠标悬停在您刚刚输入的行上,然后右键单击它。现在点击 生成,然后点击 实现方法...,最后点击 确定 以让 Android Studio 自动生成我们很快将使用的
onClick方法。 -
让我们声明一些我们需要引用我们的 UI 和即将加载的音效的
int引用的对象。在GameActivity的声明之后写入代码。通过将它们放在这里,它们将在GameActivity.java的所有代码部分中可用。以下是代码的上下文:public class GameActivity extends Activity implements View.OnClickListener { //Prepare objects and sound references //initialize sound variables private SoundPool soundPool; int sample1 = -1; int sample2 = -1; int sample3 = -1; int sample4 = -1; //for our UI TextView textScore; TextView textDifficulty; TextView textWatchGo; Button button1; Button button2; Button button3; Button button4; Button buttonReplay; -
现在,在上一步的最后一条代码之后,输入以下代码片段,它将声明并初始化一些用于我们线程的变量。注意,在最后,我们还声明了
myHandler,它将成为我们的线程,以及gameOn以控制线程内的代码是否执行://Some variables for our thread int difficultyLevel = 3; //An array to hold the randomly generated sequence int[] sequenceToCopy = new int[100]; private Handler myHandler; //Are we playing a sequence at the moment? boolean playSequence = false; //And which element of the sequence are we on int elementToPlay = 0; //For checking the players answer int playerResponses; int playerScore; boolean isResponding; -
在
onCreate方法中调用setContentView之后,我们使我们的音效准备好播放:soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC,0); try{ //Create objects of the 2 required classes AssetManager assetManager = getAssets(); AssetFileDescriptor descriptor; //create our three fx in memory ready for use descriptor = assetManager.openFd("sample1.ogg"); sample1 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample2.ogg"); sample2 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample3.ogg"); sample3 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample4.ogg"); sample4 = soundPool.load(descriptor, 0); }catch(IOException e){ //catch exceptions here } -
在上一步的代码之后,并且仍然在
onCreate方法中,我们初始化我们的对象并为按钮设置点击监听器://Reference all the elements of our UI //First the TextViews textScore = (TextView)findViewById(R.id.textScore); textScore.setText("Score: " + playerScore); textDifficulty = (TextView)findViewById(R.id.textDifficulty); textDifficulty.setText("Level: " + difficultyLevel); textWatchGo = (TextView)findViewById(R.id.textWatchGo); //Now the buttons button1 = (Button)findViewById(R.id.button); button2 = (Button)findViewById(R.id.button2); button3 = (Button)findViewById(R.id.button3); button4 = (Button)findViewById(R.id.button4); buttonReplay = (Button)findViewById(R.id.buttonReplay); //Now set all the buttons to listen for clicks button1.setOnClickListener(this); button2.setOnClickListener(this); button3.setOnClickListener(this); button4.setOnClickListener(this); buttonReplay.setOnClickListener(this); -
现在,在上一步的最后一条代码之后,输入创建我们线程的代码。我们将在下一步的
if(playSequence)块中添加详细信息。注意,线程每九十分之一秒(900 毫秒)运行一次。注意,我们启动了线程,但没有将playSequence设置为true。所以它现在不会做任何事情://This is the code which will define our thread myHandler = new Handler() { public void handleMessage(Message msg) { super.handleMessage(msg); if (playSequence) { //All the thread action will go here } myHandler.sendEmptyMessageDelayed(0, 900); } };//end of thread myHandler.sendEmptyMessage(0); -
在我们查看将在我们的线程中运行的代码之前,我们需要一种方法来生成适合难度级别的随机序列。这种情况看起来是一个方法的候选者。在
GameActivity类的闭合花括号之前输入此方法:public void createSequence(){ //For choosing a random button Random randInt = new Random(); int ourRandom; for(int i = 0; i < difficultyLevel; i++){ //get a random number between 1 and 4 ourRandom = randInt.nextInt(4); ourRandom ++;//make sure it is not zero //Save that number to our array sequenceToCopy[i] = ourRandom; } } -
我们还需要一个方法来准备和启动我们的线程。在
createSequence的闭合花括号之后输入以下方法:小贴士
实际上,方法的实现顺序并不重要。然而,按照顺序进行将意味着我们的代码看起来是一样的。即使您正在参考下载的代码,顺序也将是相同的。
public void playASequence(){ createSequence(); isResponding = false; elementToPlay = 0; playerResponses = 0; textWatchGo.setText("WATCH!"); playSequence = true; } -
在我们查看线程代码的细节之前,我们需要一个方法在序列播放后整理我们的变量。在
playASequence的闭合花括号之后输入此方法:public void sequenceFinished(){ playSequence = false; //make sure all the buttons are made visible button1.setVisibility(View.VISIBLE); button2.setVisibility(View.VISIBLE); button3.setVisibility(View.VISIBLE); button4.setVisibility(View.VISIBLE); textWatchGo.setText("GO!"); isResponding = true; } -
最后,我们将实现我们的线程。这部分有一些新的代码,我们将在完成项目这一阶段后详细说明。在
if(playSequence){ }块的开闭花括号之间输入此代码:if (playSequence) { //All the thread action will go here //make sure all the buttons are made visible button1.setVisibility(View.VISIBLE); button2.setVisibility(View.VISIBLE); button3.setVisibility(View.VISIBLE); button4.setVisibility(View.VISIBLE); switch (sequenceToCopy[elementToPlay]){ case 1: //hide a button button1.setVisibility(View.INVISIBLE); //play a sound soundPool.play(sample1, 1, 1, 0, 0, 1); break; case 2: //hide a button button2.setVisibility(View.INVISIBLE) //play a sound soundPool.play(sample2, 1, 1, 0, 0, 1); break; case 3: //hide a button button3.setVisibility(View.INVISIBLE); //play a sound soundPool.play(sample3, 1, 1, 0, 0, 1); break; case 4: //hide a button button4.setVisibility(View.INVISIBLE); //play a sound soundPool.play(sample4, 1, 1, 0, 0, 1); break; } elementToPlay++; if(elementToPlay == difficultyLevel){ sequenceFinished(); } } myHandler.sendEmptyMessageDelayed(0, 900); } };
小贴士
在 onCreate 的闭合花括号之前,我们可以通过调用我们的 playASequence 方法来启动一个序列,如下所示:
playASequence();
然后,我们可以运行我们的应用,点击主屏幕上的 播放,并观察一系列四个随机按钮及其匹配的音效开始播放。在下一阶段,我们将连接 重放 按钮以便玩家在他们准备好时启动序列。
呼!这真是一段长话。实际上,其中并没有太多新内容,但我们几乎把关于 Java 和 Android 的所有知识都塞进了一个地方,并且我们还以新的方式使用了它们。因此,我们将一步一步地查看它,并额外关注可能看起来有些棘手的部分。
让我们逐一查看每一块新的代码。
从步骤 1 到 7,我们初始化了我们的变量,设置了我们的按钮,并加载了我们的声音,就像我们之前做的那样。我们还为我们的线程放入了代码的大纲。
在第 8 步中,我们实现了createSequence方法。我们使用一个Random对象生成介于 1 和 4 之间的随机数序列。我们使用一个for循环来做这件事,直到创建了一个长度为difficultyLevel的序列。这个序列存储在一个名为sequenceToCopy的数组中,我们可以稍后使用它来比较玩家的回答:
public void createSequence(){
//For choosing a random button
Random randInt = new Random();
int ourRandom;
for(int i = 0; i < difficultyLevel; i++){
//get a random number between 1 and 4
ourRandom = randInt.nextInt(4);
ourRandom ++;//make sure it is not zero
//Save that number to our array
sequenceToCopy[i] = ourRandom;
}
}
在第 9 步中,我们实现了playASequence方法。首先,我们调用createSequence来加载我们的sequenceToCopy数组。然后我们将isResponding设置为false,因为我们不希望玩家在序列仍在播放时乱按按钮。我们将elementToPlay设置为0,因为这是我们数组的第一个元素。我们还把playerResponses设置为0,以便准备好计数玩家的回答。接下来,我们在 UI 上设置一些文本为"WATCH!",以便让玩家清楚地知道序列正在播放。最后,我们将playSequence设置为true,这允许我们的线程代码每 900 毫秒运行一次。以下是刚刚分析的代码:
public void playASequence(){
createSequence();
isResponding = false;
elementToPlay = 0;
playerResponses = 0;
textWatchGo.setText("WATCH!");
playSequence = true;
}
在第 10 步中,我们处理sequenceFinished。我们将playSequence设置为false,这阻止了我们的线程中的代码运行。我们将所有按钮都设置回可见状态,因为,正如我们将在线程代码中看到的,我们将它们设置为不可见,以强调序列中下一个按钮。我们将我们的 UI 文本设置为GO!,以使其清晰。现在是玩家尝试复制序列的时候了。为了使checkElement方法中的代码运行,我们将isResponding设置为true。我们将在下一阶段查看checkElement方法中的代码:
public void sequenceFinished(){
playSequence = false;
//make sure all the buttons are made visible
button1.setVisibility(View.VISIBLE);
button2.setVisibility(View.VISIBLE);
button3.setVisibility(View.VISIBLE);
button4.setVisibility(View.VISIBLE);
textWatchGo.setText("GO!");
isResponding = true;
}
在第 11 步中,我们实现了我们的线程。它相当长,但并不复杂。首先,我们将所有按钮都设置为可见,因为这比检查哪个按钮当前不可见并只设置那个按钮要快:
if (playSequence) {
//All the thread action will go here
//make sure all the buttons are made visible
button1.setVisibility(View.VISIBLE);
button2.setVisibility(View.VISIBLE);
button3.setVisibility(View.VISIBLE);
button4.setVisibility(View.VISIBLE);
然后,我们根据序列中下一个数字是什么来切换,隐藏相应的按钮,并播放相应的声音。以下是switch块中的第一个情况,供参考。其他情况元素执行相同的功能,但针对不同的按钮和不同的声音:
switch (sequenceToCopy[elementToPlay]){
case 1:
//hide a buttonbutton1.setVisibility(View.INVISIBLE);
//play a sound
soundPool.play(sample1, 1, 1, 0, 0, 1);
break;
//case 2, 3 and 4 go here
现在,我们增加elementToPlay,以便在线程大约 900 毫秒后再次运行时播放序列的下一部分:
elementToPlay++;
接下来,我们检查是否已经播放了序列的最后一部分。如果我们已经播放了,我们就调用sequenceFinished方法来为玩家尝试回答设置好一切:
if(elementToPlay == difficultyLevel){
sequenceFinished();
}
}
最后,我们告诉线程我们希望再次运行我们的代码:
myHandler.sendEmptyMessageDelayed(0, 900);
}
};
当你运行一个序列(参见之前的提示)时,你是否注意到我们的游戏操作有一个不完美/错误?这与序列的最后一个元素的动画方式有关。这是因为我们的 sequenceFinished 方法在按钮刚刚变得不可见之后立即使所有按钮可见,所以看起来按钮根本就没有变得不可见。当我们学习到第 5 阶段的 UI 动画时,我们将解决按钮不够长时间保持不可见的问题。
现在让我们来处理玩家的反应。
第三阶段 – 玩家的反应
我们现在有一个应用程序,它会播放一系列随机的按钮闪烁和匹配的声音。它还会将这个序列存储在数组中。所以我们现在要做的就是让播放器尝试复制这个序列,如果成功则得分。
我们可以分两个阶段完成所有这些。首先,我们需要处理按钮点击,这可以把所有的工作都传递给一个方法,它会做所有其他的事情。
让我们在编写代码的同时查看它。之后,我们将仔细检查不那么明显的地方:
-
这是处理按钮点击的方式。我们有一个空的
switch语句体,其中有一个额外的if语句检查是否正在播放序列。如果有序列,则不接受任何输入。我们将在下一步开始填充空体中的代码:if(!playSequence) {//only accept input if sequence not playing switch (view.getId()) { //case statements here... } } -
现在,这是处理
button1的代码。注意,它只是播放与button1相关的声音,然后调用checkElement方法,传递一个值为 1。对于按钮 1 到 4,我们只需要做这些:播放声音,然后告诉我们的新方法(checkElement)哪个编号的按钮被按下,checkElement将完成其余的工作:case R.id.button: //play a sound soundPool.play(sample1, 1, 1, 0, 0, 1); checkElement(1); break; -
这里是按钮 2 到 4 的几乎相同的代码。注意,传递给
checkElement的值和播放的声音样本是唯一与上一步不同的地方。在上一步骤的代码之后直接输入以下代码:case R.id.button2: //play a sound soundPool.play(sample2, 1, 1, 0, 0, 1); checkElement(2); break; case R.id.button3: //play a sound soundPool.play(sample3, 1, 1, 0, 0, 1); checkElement(3); break; case R.id.button4: //play a sound soundPool.play(sample4, 1, 1, 0, 0, 1); checkElement(4); break; -
这里是我们在
onClick方法中的代码的最后部分。这部分处理的是重置按钮。代码只是重置得分和难度级别,然后调用我们的playASequence方法,它负责重新开始游戏的其余工作。在上一步骤的代码之后直接输入以下代码:case R.id.buttonReplay: difficultyLevel = 3; playerScore = 0; textScore.setText("Score: " + playerScore); playASequence(); break; -
最后,这是我们的全能方法。与之前的方法相比,这个方法相当长,但有助于看到它的整个结构。我们将在稍后逐行分解它。在输入以下代码后,你实际上将能够玩游戏并获得分数:
public void checkElement(int thisElement){ if(isResponding) { playerResponses++; if (sequenceToCopy[playerResponses-1] == thisElement) { //Correct playerScore = playerScore + ((thisElement + 1) * 2); textScore.setText("Score: " + playerScore); if (playerResponses == difficultyLevel) {//got the whole sequence //don't checkElement anymore isResponding = false; //now raise the difficulty difficultyLevel++; //and play another sequence playASequence(); } } else {//wrong answer textWatchGo.setText("FAILED!"); //don't checkElement anymore isResponding = false; } }
我们在教程过程中相当全面地介绍了这些方法。然而,房间里的大象,即 checkElement 方法中代码的明显扩展。所以让我们一步一步地过一遍第 6 步中的所有代码。
首先,我们有方法签名。注意,它不返回任何值,但它接收一个int类型的值。记住,是onClick方法调用这个方法,并传递一个1、2、3或4,这取决于哪个按钮被点击:
public void checkElement(int thisElement){
接下来,我们将剩余的代码封装到一个if语句中。这里是if语句。当isResponding布尔值为true时,我们进入这个代码块,而isResponding在sequenceFinnished方法完成后被设置为true,这正是我们所需要的,以便玩家不能在时间到来之前连续按按钮,并且我们的游戏已经准备好监听:
if(isResponding) {
这里是if代码块内部发生的事情。我们在playerResponses变量中增加玩家收到的响应次数:
playerResponses++;
现在我们检查传递给checkElement方法并存储在thisElement中的数字是否与玩家试图复制的序列的适当部分匹配。如果匹配,我们将playerScore增加一个与到目前为止正确匹配的序列部分数量相关的金额。然后我们在屏幕上设置分数。注意,如果响应不匹配,有一个else代码块与这个if代码块相对应,我们将在下面解释:
if (sequenceToCopy[playerResponses-1] == thisElement) { //Correct
playerScore = playerScore + ((thisElement + 1) * 2);
textScore.setText("Score: " + playerScore);
接下来,我们还有一个if代码块。注意,这个if代码块嵌套在我们刚刚描述的if代码块内部。因此,它只有在玩家的响应正确时才会被测试和可能执行。这个if语句检查是否是序列的最后一部分,如下所示:
if (playerResponses == difficultyLevel) {
如果是序列的最后一部分,它将执行以下行:
//got the whole sequence
//don't checkElement anymore
isResponding = false;
//now raise the difficulty
difficultyLevel++;
//and play another sequence
playASequence();
}
在嵌套的if语句内部发生的事情,该语句检查整个序列是否被正确复制,如下所示:它将isResponding设置为false,因此玩家从按钮那里得不到任何响应。然后它将难度级别提高 1,以便下次序列更难。最后,它调用playSequence方法来播放另一个序列,整个过程再次开始。
这里是else代码块,如果玩家在序列的一部分出错时运行:
} else {
//wrong answer
textWatchGo.setText("FAILED!");
//don't checkElement anymore
isResponding = false;
}
}
在这里,我们在屏幕上设置一些文本,并将isResponding设置为false。
现在让我们使用关于SharedPreferences类的知识来保存高分。
第四阶段 – 保存高分
这个阶段既简洁又愉快。我们将使用本章早期学到的知识,如果玩家得分是新的高分,就保存玩家的分数,然后在MainActivity中的hi-score TextView 上显示最佳分数:
-
在编辑器窗口中打开
MainActivity.java。 -
然后,我们在类声明之后立即声明用于从文件中读取的对象,如下所示:
public class MainActivity extends Activity implements View.OnClickListener{ //for our hiscore (phase 4) SharedPreferences prefs; String dataName = "MyData"; String intName = "MyInt"; int defaultInt = 0; //both activities can see this public static int hiScore; -
现在,就在
onCreate方法中调用setContentView之后,我们初始化我们的对象,从我们的文件中读取,并将结果设置到hiScore变量中。然后我们向玩家显示它://for our high score (phase 4) //initialize our two SharedPreferences objects prefs = getSharedPreferences(dataName,MODE_PRIVATE); //Either load our High score or //if not available our default of 0 hiScore = prefs.getInt(intName, defaultInt); //Make a reference to the Hiscore textview in our layout TextView textHiScore =(TextView) findViewById(R.id.textHiScore); //Display the hi score textHiScore.setText("Hi: "+ hiScore); -
接下来,我们需要回到
GameActivity.java文件。 -
我们声明我们的对象来编辑文件,这次是这样的:
//for our hiscore (phase 4) SharedPreferences prefs; SharedPreferences.Editor editor; String dataName = "MyData"; String intName = "MyInt"; int defaultInt = 0; int hiScore; -
在
onCreate方法中调用setContentView之后,我们实例化我们的对象并将一个值赋给hiScore://phase 4 //initialize our two SharedPreferences objects prefs = getSharedPreferences(dataName,MODE_PRIVATE); editor = prefs.edit(); hiScore = prefs.getInt(intName, defaultInt); -
与我们之前学到的唯一不同之处在于,我们需要考虑将代码放在哪里来测试高分,以及如果合适的话,在哪里写入我们的文件。考虑以下情况:最终,每个玩家都必须失败。此外,他们失败的时刻是他们分数最高的时刻,但在他们再次尝试时分数被重置之前。将以下代码放在处理玩家错误答案的
else块中,高亮显示的代码是新的代码;其余的代码是为了帮助你理解上下文:} else {//wrong answer textWatchGo.setText("FAILED!"); //don't checkElement anymore isResponding = false; //for our high score (phase 4) if(playerScore > hiScore) { hiScore = playerScore; editor.putInt(intName, hiScore); editor.commit(); Toast.makeText(getApplicationContext(), "New Hi-score", Toast.LENGTH_LONG).show(); } }
玩游戏并得到高分。现在退出应用或甚至重新启动手机。当你回到应用时,你的高分仍然在那里。
在这个阶段我们添加的代码几乎与我们在之前的持久化示例中写的代码相同,唯一的区别在于我们是在达到新的高分时写入数据存储,而不是在按钮被按下时。此外,我们使用了editor.putInt方法,因为我们保存的是整数而不是使用editor.putString保存字符串。
动画化我们的游戏
在我们继续之前,让我们先思考一下动画。它究竟是什么?这个词可能让人联想到移动的卡通角色和视频游戏中的游戏角色。
我们需要动画化我们的按钮(使它们移动),以便清楚地表明它们是序列的一部分。我们看到了仅仅让一个按钮消失然后再次出现是不够的。
想象控制 UI 元素的运动可能会让我们联想到复杂的for循环和像素级计算。
幸运的是,Android 为我们提供了Animation类,它允许我们无需任何这样的像素级尴尬来动画化 UI 对象。以下是它是如何工作的。
注意
当然,要完全控制游戏中的形状和大小,我们最终必须学会操纵单个像素和线条。我们将从第七章,复古 squash 游戏开始这样做,当我们制作复古乒乓球风格的 squash 游戏时。
Android 中的 UI 动画
Android UI 中的动画可以分为三个阶段:
-
使用我们很快就会看到的特殊语法在文件中描述动画
-
在我们的 Java 代码中通过创建其对象来引用该动画
-
当需要运行动画时,将动画应用于 UI 元素
让我们看看一些描述动画的代码。我们很快就会在记忆游戏中重用这段代码。展示它的目的并不仅仅是让我们理解它的每一行。毕竟,学习 Java 本身就足够是一项成就,无需掌握这一点。此外,目的在于展示无论你描述了什么样的动画,都可以使用相同的 Java 在我们的游戏中使用。
我们可以快速在网上搜索以找到执行以下操作的代码:
-
淡入淡出
-
滑动
-
旋转
-
扩展或缩小
-
形状变色
这里有一些代码可以产生晃动效果。我们将将其用于按钮,但你也可以将其用于任何 UI 元素,甚至整个屏幕:
<?xml version="1.0" encoding="utf-8"?>
<rotate
android:duration="100"
android:fromDegrees="-5"
android:pivotX="50%"
android:pivotY="50%"
android:repeatCount="8"
android:repeatMode="reverse"
android:toDegrees="5" />
第一行简单地声明这是一个以 XML 格式编写的文件。下一行声明我们将执行一个旋转。然后我们声明持续时间将为 100 毫秒,旋转将从 -5 度开始,旋转中心在 x 和 y 轴上各占 50%,重复八次,然后反向到正 5 度。
这听起来可能有些复杂,但关键是它很容易抓取一个有效的模板,然后根据我们的情况对其进行定制。我们可以将前面的代码保存为名为 wobble.xml 的文件。
然后,我们可以简单地按照以下方式引用它:
Animation wobble = AnimationUtils.loadAnimation(this, R.anim.wobble);
现在,我们可以在我们选择的 UI 对象上播放动画,在这种情况下是我们的 button1 对象:
button1.startAnimation(wobble);
第五阶段 – 动画 UI
让我们添加一个动画,当播放按钮声音时,按钮会晃动。同时,我们可以移除使按钮不可见和使其重新出现的代码。这并不是最好的方法,但在开发游戏时它起到了作用:
-
我们需要在我们项目中添加一个新的文件夹,命名为
anim。因此,在项目资源管理器窗口中,在res文件夹上右键单击。导航到 新建 | Android 资源目录,然后单击 确定 创建新的anim文件夹。 -
现在,在
anim文件夹上右键单击,并导航到 新建 | 动画资源文件。在 文件名 字段中输入wobble,然后单击 确定。现在我们在编辑器窗口中打开了一个名为 wobble.xml 的新文件。 -
将
wobble.xml的第一行之外的所有行替换为以下代码:<?xml version="1.0" encoding="utf-8"?> <rotate android:duration="100" android:fromDegrees="-5" android:pivotX="50%" android:pivotY="50%" android:repeatCount="8" android:repeatMode="reverse" android:toDegrees="5" /> -
现在,切换到
GameActivity.java。 -
在我们的
GameActivity类声明之后添加以下代码://phase 5 - our animation object Animation wobble; -
在我们的
onCreate方法中调用setContentView之后,添加以下代码://phase5 - animation wobble = AnimationUtils.loadAnimation(this, R.anim.wobble); -
现在,在我们的线程代码开头附近,找到使按钮重新出现的调用。像这样将其注释掉:
//code not needed as using animations //make sure all the buttons are made visible //button1.setVisibility(View.VISIBLE); //button2.setVisibility(View.VISIBLE); //button3.setVisibility(View.VISIBLE); //button4.setVisibility(View.VISIBLE); -
接下来,直接在我们上一步的代码之后,在每个
case语句中,我们需要注释掉调用setVisibility的行,并用我们的晃动动画替换它们。以下代码略有缩写,但显示了注释和添加新行的确切位置:switch (sequenceToCopy[elementToPlay]){ case 1: //hide a button - not any more //button1.setVisibility(View.INVISIBLE); button1.startAnimation(wobble); ... ... case 2: //hide a button - not any more //button2.setVisibility(View.INVISIBLE); button2.startAnimation(wobble); ... ... case 3: //hide a button - not any more //button3.setVisibility(View.INVISIBLE); button3.startAnimation(wobble); ... ... case 4: //hide a button - not any more //button4.setVisibility(View.INVISIBLE); button4.startAnimation(wobble); -
最后,在我们的
sequenceFinished方法中,我们可以像在线程中那样注释掉所有的setVisibility调用,如下所示://button1.setVisibility(View.VISIBLE); //button2.setVisibility(View.VISIBLE); //button3.setVisibility(View.VISIBLE); //button4.setVisibility(View.VISIBLE);
这并不太难。我们在 anim 文件夹中添加了晃动动画,声明了一个动画对象,并初始化了它。然后,在需要时在适当的按钮上使用它。
显然,我们可以对这个游戏进行大量的改进,特别是外观方面。我相信你能想到更多。当然,如果这是你的应用,你正在尝试在 Play Store 上取得成功。这正是你应该做的。
不断改进所有方面,努力成为你所在领域的最佳。如果你有这样的冲动,为什么不尝试改进呢?
这里有一些自我测试问题,考察了我们如何利用本章的一些示例做更多的事情。
自我测试问题
Q1) 假设我们想要有一个测验,问题可能是命名总统以及首都。我们如何使用多维数组来完成这个任务?
Q2) 在我们的 持久化示例 部分,我们将一个持续更新的字符串保存到文件中,以便在应用程序关闭并重新启动后持久化。这就像要求用户点击一个“保存”按钮。回顾一下第二章,开始使用 Android,你能想到一种方法来保存字符串,而无需在按钮点击时保存,而是在用户退出应用程序时保存吗?
Q3) 除了提高难度级别,我们如何增加我们记忆游戏的挑战性,以吸引我们的玩家?
Q4) 使用平淡无奇的 Android UI 和单调的灰色按钮并不令人兴奋。看看视觉设计器中的 UI 元素,并尝试找出我们如何快速改善 UI 的视觉外观。
摘要
那是一个相当厚重的章节,但我们学到了很多新技术,例如使用数组进行存储和操作、创建和使用音效,以及在我们的游戏中保存重要数据,如高分。我们还简要地了解了一下功能强大且易于使用的Animation类。
在下一章中,我们将采取更理论的方法,但也会有大量的实际示例。我们最终将打开 Java 类的黑盒子,以便我们能够理解当我们声明和使用类的对象时发生了什么。
第六章. OOP – 利用他人的辛勤工作
OOP 代表面向对象编程。在本章中,你甚至不需要尝试记住所有内容。我为什么这么说?当然,那才是学习的本质。更重要的是,要掌握概念,并开始理解 OOP 的为什么,而不是记住规则、语法和术语。
更重要的是,实际上开始使用一些概念,即使你可能需要不断回顾,你的代码可能也无法完全遵循我们讨论的每个面向对象编程(OOP)原则。本书中的代码也是如此。本章中的代码旨在帮助你探索和掌握 OOP 的概念。
如果你试图记住这一章,你需要在你的大脑中腾出很多空间,你可能会忘记一些非常重要的事情,比如去上班或感谢作者告诉你不要试图记住这些内容。
一个好的目标将是尽量接近成功。然后我们将开始识别 OOP 的实际应用示例,以便我们的理解更加全面。然后你可以经常回顾这一章以进行复习。
那么,我们将学习哪些关于面向对象编程(OOP)的东西呢?实际上,我们已经在面向对象编程(OOP)方面学到了很多。到目前为止,我们已经使用了Button、Random和Activity等类,覆盖了类的方法(主要是onCreate)以及使用了一个接口;记得在前五章中多次实现onClickListener吗?
本章只是帮助我们理解面向对象编程(OOP),并扩展我们的理解,最后,我们将自己创建类。
然后,在接下来的两个章节中,我们将能够利用许多人的辛勤工作来制作两个酷炫的复古街机游戏。本章将主要介绍理论,但会包含一些使用 LogCat 的实用控制台示例,以便我们可以看到面向对象编程(OOP)的实际应用。
在本章中,我们将做以下事情:
-
看看面向对象编程(OOP)是什么。
-
编写我们的第一个类。
-
看看封装是什么,以及我们如何实现它,以及更深入地了解变量和不同类型。我们还将短暂休息一下,以清除垃圾。
-
在使用之前,了解继承以及我们如何扩展甚至改进一个类。
-
看看多态性,这是一种一次成为多件事物的方式,在编程中非常有用。
什么是面向对象编程(OOP)?
面向对象编程(OOP)是一种编程方式,它涉及将我们的需求分解成比整体更易于管理的块。
每个块都是自包含的,同时可能被其他程序重用,同时与其他块一起作为一个整体工作。
这些块就是我们所说的对象。当我们规划一个对象时,我们是用一个类来规划的。一个类可以被看作是对象的蓝图。
我们实现了一个类的对象。这被称为类的实例。想想房子的蓝图。你不能住在里面,但你可以从它那里建造一栋房子,这意味着你建造了它的一个实例。然而,面向对象编程(OOP)不仅仅是这个。它也是一种定义最佳实践的方法论,例如以下内容:
-
封装:这意味着保护你的代码的内部工作不被使用它的程序干扰,并且只允许你选择的变量和方法被访问。这意味着只要暴露的部分仍然以相同的方式访问,你的代码就可以始终更新、扩展或改进,而不会影响使用它的程序。
-
继承:正如其名,继承意味着我们可以利用其他人的所有努力,包括封装和多态性,同时针对我们的特定情况进行代码优化。实际上,我们每次使用
extends关键字时都已经这样做过了。 -
多态性:这允许我们编写不那么依赖于我们试图操作的类型的代码,使我们的代码更清晰、更高效。本章后面的几个例子将使这一点变得清晰。
小贴士
当我们谈论利用他人的辛勤工作时,我们并不是在谈论一种滥用版权并逍遥法外的神奇方法。有些代码简单明了,属于他人的财产。我们所说的是大量可供免费使用的代码,尤其是在本书的 Java 和 Android API 的背景下。如果你需要一些完成特定任务的代码,它可能已经被编写过了。我们只需要找到它,然后使用或修改它。
Java 从一开始就是为了考虑所有这些而设计的,所以我们相当程度上被限制在面向对象编程的使用上。然而,这却是好事,因为它让我们学会了如何使用最佳实践。
为什么这样做?
当编写得当,所有这些面向对象编程都允许你在添加新功能时不必过多担心它们与现有功能的交互。当你确实需要更改一个类时,其自包含的特性意味着对程序其他部分的影响更小,或者可能是零。这就是封装的部分。
你可以使用他人的代码,而无需了解甚至可能不关心它是如何工作的。想想 Android 的生命周期、按钮、线程等等。Button类相当复杂,有近 50 个方法——我们真的想只为一个按钮编写所有这些代码吗?
面向对象编程(OOP)让你在处理高度复杂的情况时也能轻松应对。你可以通过继承来创建多个类似但不同的类版本,而无需从头开始编写类,同时由于多态性,你仍然可以使用为原始对象类型设计的那些方法来使用你的新对象。
真的很有道理!让我们编写一些类,然后从它们中创建一些对象。
我们的第一个类和第一个对象
那么到底什么是类呢?类是一组可以包含方法、变量、循环以及所有其他类型 Java 语法的代码。类是包的一部分,大多数包通常会有多个类。通常但并非总是,每个新的类都会定义在其自己的.java代码文件中,文件名与类名相同。
一旦我们编写了一个类,我们就可以用它来创建所需数量的对象。记住,类是蓝图,我们根据蓝图来创建对象。房子不是蓝图,对象也不是类;它是从类中创建的对象。
这里是一个类的代码。我们称之为类实现:
public class Soldier {
int health;
String soldierType;
void shootEnemy(){
//bang bang
}
}
以下代码片段是一个名为Soldier的类的实现。有两个变量,一个名为health的int类型变量和一个名为soldierType的string类型变量。
还有一个名为shootEnemy的方法。该方法没有参数,返回类型为void,但类方法可以是我们在第五章“游戏和 Java 基础”中讨论的任何形状或大小。
当我们在类中声明变量时,它们被称为字段。当类实例化为一个真实对象时,字段变成了对象本身的变量,因此我们称它们为实例变量。无论它们被称为什么花哨的名字,它们只是类的变量。然而,随着我们的深入,字段和方法中声明的变量(称为局部变量)之间的区别变得越来越重要。我们将在 变量回顾 部分再次查看所有类型的变量。
记住,这只是一个类,不是一个对象。这是一个士兵的蓝图,而不是实际的 soldier 对象。这就是我们从 Soldier 类中创建 Soldier 类型对象的方式:
Soldier mySoldier = new Soldier();
在代码的第一部分,Soldier mySoldier 声明了一个新的引用类型变量 mySoldier,类型为 Soldier,在代码的最后部分,new Soldier() 创建了一个实际的 Soldier 对象。当然,两个部分中间的赋值操作符 = 将第二部分的结果赋给第一部分。就像常规变量一样,我们也可以像这样执行前面的步骤:
Soldier mySoldier;
mySoldier = new Soldier();
这是我们如何分配和使用变量的方式:
mySoldier.health = 100;
mySoldier.soldierType = "sniper";
//Notice that we use the object name mySoldier.
//Not the class name Soldier.
//We didn't do this:
// Soldier.health = 100; ERROR!
在前面的代码片段中,点操作符 . 用于访问类的变量,这就是我们调用方法的方式。再次强调,我们使用对象名称而不是类名称,然后跟随着点操作符:
mySoldier.shootEnemy();
小贴士
作为粗略的指南,一个类的方法是它能做什么,它的实例变量是它对自己了解的内容。
我们也可以通过创建另一个 Soldier 对象并访问其方法和变量来继续:
Soldier mySoldier2 = new Soldier();
mySoldier2.health = 150;
mySoldier2.soldierType = "special forces";
mySoldier2.shootEnemy();
重要的是要意识到 mySoldier2 是一个完全独立的对象,具有完全独立的实例变量。
注意,所有操作都是在对象本身上进行的。我们必须创建类的对象,以便使它们变得有用。
注意
总是会有例外,但它们是少数,我们将在本章后面讨论这些例外。事实上,我们早在 第三章,讲解 Java – 你的第一个游戏 中就已经看到了一个例外。想想 Toast。
让我们更深入地探索基本类。
基本类
当我们想要一支 Soldier 对象的军队时,我们将实例化多个对象。我们还将演示在变量和方法上使用点操作符,并展示不同的对象有不同的实例变量。
你可以在代码下载中找到这个示例的工作项目。它在 chapter6 文件夹中,简单地命名为 BasicClasses。或者继续阅读以创建你自己的工作示例:
-
创建一个带有空白活动的项目,就像我们在 第二章,开始使用 Android 中所做的那样。通过删除不必要的部分来清理代码,但这不是必需的。将应用程序命名为
BasicClasses。 -
现在我们创建一个新的类,名为
Soldier。在项目资源管理器窗口中,右键单击com.packtpub.basicclasses文件夹。点击新建,然后点击Java 类。在名称字段中输入Soldier,然后点击确定。新类会为我们创建,并带有代码模板,我们可以将其实现放入其中,就像以下截图所示:![基本类]()
-
注意,Android Studio 已经将类放在了与我们的应用其余部分相同的包中。现在我们可以编写它的实现了。在
Soldier类的开头和结尾大括号内编写以下类实现代码:public class Soldier { int health; String soldierType; void shootEnemy(){ //lets print which type of soldier is shooting Log.i(soldierType, " is shooting"); } } -
现在我们有了类,这是我们的未来
Soldier类型对象的蓝图,我们可以开始构建我们的军队。在编辑器窗口中,点击MainActivity.java标签页。我们将像往常一样,在调用setContentView方法之后,在onCreate方法中编写此代码://first we make an object of type soldier Soldier rambo = new Soldier(); rambo.soldierType = "Green Beret"; rambo.health = 150;// It takes a lot to kill Rambo //Now we make another Soldier object Soldier vassily = new Soldier(); vassily.soldierType = "Sniper"; vassily.health = 50;//Snipers have less armor //And one more Soldier object Soldier wellington = new Soldier(); wellington.soldierType = "Sailor"; wellington.health = 100;//He's tough but no green beret提示
这正是开始利用 Android Studio 中的自动完成功能的好时机。注意,在你声明并创建了一个新对象之后,你只需开始输入对象的名称,所有自动完成选项就会显示出来。
-
现在我们有了极其多样且有些不太可能的军队,我们可以使用它并验证每个对象的身份。在上一步骤的代码下方输入以下代码:
Log.i("Rambo's health = ", "" + rambo.health); Log.i("Vassily's health = ", "" + vassily.health); Log.i("Wellington's health = ", "" + wellington.health); rambo.shootEnemy(); vassily.shootEnemy(); wellington.shootEnemy(); -
现在,我们可以在模拟器上运行我们的应用。记住,所有输出都将显示在LogCat控制台窗口中。
这就是前面代码片段的工作原理。在第 2 步中,Android Studio 为我们新的Soldier类创建了一个模板。在第 3 步中,我们以与之前相同的方式实现了我们的类——两个变量,一个int和一个string,分别称为health和soldierType。
我们在类中还有一个名为shootEnemy的方法。让我们再次查看它并检查正在发生的事情:
void shootEnemy(){
//lets print which type of soldier is shooting
Log.i(soldierType, " is shooting");
}
在方法体中,我们首先将soldierType字符串打印到控制台,然后是任意的" is shooting"字符串。这里很酷的是,soldierType字符串将根据我们调用shootEnemy方法的哪个对象而不同。
在第 4 步中,我们声明、创建并分配了三个新的Soldier类型对象。它们是rambo、vassily和wellington。在第 5 步中,我们为每个对象的health以及soldierType分配了不同的值。
这里是输出结果:
Rambo's health =﹕ 150
Vassily's health =﹕ 50
Wellington's health =﹕ 100
Green Beret﹕ is shooting
Sniper﹕ is shooting
Sailor﹕ is shooting
注意,每次我们访问每个Soldier对象的health变量时,它都会打印出我们分配的值,这证明了尽管三个对象属于同一类型,但它们是完全独立的个体对象。
可能更有趣的是对 shootEnemy 的三次调用。我们的每个 Soldier 对象的 shootEnemy 方法都进行了一次调用,并且我们将 soldierType 变量打印到控制台。该方法对每个单独的对象都有适当的值,进一步证明了我们有三个不同的对象,尽管它们都是从同一个 Soldier 类创建的。
我们可以用我们的第一个类做更多的事情
我们可以将类视为其他变量一样。假设我们已经实现了我们的 Soldier 类,我们可以创建一个 Soldier 对象的数组,如下所示:
//Declare an array called myArmy to hold 10 Soldier objects
Soldier [] myArmy = new Soldier[10];
//Then we can add the Soldier objects
//We use the familiar array notation on the left
//And the newly learnt new Soldier() syntax on the right
myArmy[0] = new Soldier();
myArmy[1] = new Soldier();
myArmy[2] = new Soldier();
myArmy[3] = new Soldier();
//Initialize more here
//..
然后,我们可以使用与常规变量相同的数组表示法来使用数组中的对象,如下所示:
myArmy[0].health = 125;
myArmy[0].soldierType = "Pilot";
myArmy[0].shootEnemy();
// Pilot﹕ is shooting
我们还可以将一个类用作方法调用中的参数。以下是对 healSoldier 方法的假设调用:
healSoldier(rambo);
//Perhaps healSoldier could add to the health instance variable
提示
当然,前面的例子可能会引发一些问题,比如 healSoldier 方法是否应该是类的一个方法?
someHospitalObjectPerhaps.healSoldier(rambo);
可能是或不是(如前例所示)。这取决于对情况的最好解决方案。我们将查看更多的面向对象编程,然后对于许多类似的难题,最好的解决方案应该更容易出现。
如你所期望的那样,我们可以将对象用作方法调用的返回值。以下是对假设的 healSoldier 方法的可能实现:
Soldier healSoldier(Soldier soldierToBeHealed){
soldierToBeHealed.health++;
return soldierToBeHealed;
}
所有这些信息可能会引发一些疑问。面向对象编程(OOP)就是这样,所以为了尝试将所有这些类的东西与我们已经知道的东西结合起来,让我们再看看变量和封装。
封装
到目前为止,我们真正看到的是一种代码组织惯例,尽管我们讨论了所有这些面向对象编程的更广泛目标。现在我们将更进一步,看看我们实际上是如何通过面向对象编程实现封装的。
提示
封装的定义
正如我们所学的,封装意味着保护你的代码的内部工作免受使用它的程序的干扰,只允许你选择的变量和方法被访问。这意味着只要暴露的部分仍然以相同的方式提供,你的代码就可以始终更新、扩展或改进,而不会影响使用它的程序。这也使得使用你的封装代码的程序更加简单和易于维护,因为任务的大部分复杂性都封装在你的代码中。
但我没有说过我们不需要了解内部发生的事情吗?所以你可能会对我们迄今为止所看到的内容提出质疑。如果我们不断地设置实例变量,就像这样 rambo.health = 100;,那么最终事情可能会开始出错,也许就像以下这一行代码?
rambo.soldierType = "ballerina";
封装保护你的类不被以它不应该被使用的方式使用。通过严格控制代码的使用方式,它只能做你想要它做的事情,使用你可以控制的价值。它不能被强制进入错误或崩溃。此外,你还可以自由地更改代码内部的工作方式,而不会破坏使用较旧版本代码或程序其他部分的任何程序:
weighlifter.legstrength = 100;
weighlifter.armstrength = -100;
weightlifter.liftHeavyWeight();
//one typo and weightlifter rips own arms off
我们可以通过封装我们的类来避免这种情况,下面是如何做的。
使用访问修饰符控制类的使用
类的设计者控制着任何使用他们类的程序可以看到和操作的内容。我们可以在 class 关键字之前添加一个 访问修饰符,如下所示:
public class Soldier{
//Implementation goes here
}
有两种类访问修饰符。让我们依次简要地看看每个:
-
public:这是直截了当的。声明为public的类可以被所有其他类看到。 -
default:当没有指定访问修饰符时,类具有默认访问。这将使其公开,但仅限于同一包中的类,对所有其他类不可访问。
现在,我们可以开始处理这个封装问题。然而,即使一眼看去,所描述的访问修饰符也不是非常细粒度。我们似乎被限制在完全锁定包外的一切或完全自由使用。
实际上,这里的好处很容易被利用。想法是设计一个包,它完成一系列任务。然后,包的复杂内部工作,那些不应该被除我们包之外的人乱动的东西,应该有默认访问(仅限于包内的类)。然后我们可以提供一组精心挑选的公开类,供其他人(或我们程序的另一个独立部分)使用。
小贴士
对于这本书中游戏的规模和复杂性,多个包几乎肯定是不必要的。
类访问概述
一个设计良好的应用程序可能由一个或多个包组成,每个包只包含默认或默认和公开的类。
除了类级别的隐私控制之外,Java 给我们提供了非常细粒度的控制,但为了使用这些控制,我们必须更详细地查看变量。
使用访问修饰符控制变量的使用
在构建基于类可见性控制的基础上,我们有了可变访问修饰符。下面是一个声明为私有访问修饰符的变量示例:
private int myInt;
注意,我们关于变量访问修饰符的所有讨论也适用于对象变量。例如,这里是我们 Soldier 类的一个实例被声明、创建和分配的情况。正如你所看到的,这种情况下的访问指定是公开的:
public Soldier mySoldier = new Soldier();
在将修饰符应用于变量之前,你必须首先考虑类的可见性。如果类 a 对类 b 不可见,比如说因为类 a 有默认访问,而类 b 在另一个包中,那么在类 a 的变量上使用什么访问修饰符都没有关系;类 b 仍然看不到它。
因此,在必要时向另一个类展示一个类是有意义的,但你应该只暴露所需的变量——而不是所有东西。
我们还有关于访问修饰符的更多内容要介绍,然后我们将通过一些示例来帮助澄清这些内容。目前,这里是对不同变量访问修饰符的解释。它们比类访问修饰符更多且更细致。大部分解释都很直接,那些可能引起疑问的将在我们查看示例时变得清晰。
访问修饰的深度和复杂性并不在于修饰符的范围,而在于我们以智能的方式使用它们,我们可以将它们组合起来以实现封装的值得追求的目标。以下是变量访问修饰符:
-
public:正如你所猜到的!任何包中的任何类或方法都可以看到这个变量。只有当你确定这是你想要的时才使用public。 -
protected:这是public之后最不限制的修饰符。设置为protected的变量可以由同一包中的任何类和任何方法看到。 -
default:这听起来不像protected那样限制性,但实际上更限制。当一个变量没有指定访问权限时,它具有默认访问权限。default是限制性的事实可能意味着我们应该考虑隐藏变量而不是暴露它们。在此阶段,我们需要引入一个新概念。你还记得我们简要讨论了继承,以及我们如何可以通过使用extends关键字快速获得类的属性并对其进行改进吗?仅记录在案,具有默认访问权限的变量对子类不可见。这意味着当我们像使用Activity那样扩展一个类时,我们无法看到它的默认变量。我们将在本章的后面更详细地探讨继承。 -
private:这些变量只能在声明它们的类内部看到。像默认访问一样,它们不能被子类(继承类)看到。
变量访问概述
一个设计良好的应用程序可能包含一个或多个包,每个包只包含默认或默认和公共类。在这些类内部,变量将会有精心选择的、很可能是不同的访问修饰符。
在我们实际应用访问修饰符之前,还有一些关于访问修饰的细节需要了解。
方法也有访问修饰符
方法是我们类可以执行的事情,我们希望控制类用户可以做什么和不可以做什么。这里的一般想法是,一些方法只会在内部执行,因此不需要类用户,而一些方法对于用户如何使用你的类是基本的。
方法的访问修饰符与类变量的访问修饰符相同。这使得事情容易记住,但再次表明,成功的封装是一个设计问题,而不是任何特定的规则集。
例如,以下代码片段中的方法,在一个公共类中提供,可以被任何其他类使用:
public useMeEverybody(){
//do something everyone needs to do here
}
然而,以下方法只能由创建它的类内部使用:
private secretInternalTask(){
//do something that helps the class function internally
//Perhaps, if it is part of the same class,
//useMeEverybody could use this method...
//On behalf of the classes outside of this class.
//Neat!
}
下一个方法具有默认可见性,没有指定访问权限。它只能由同一包中的其他类使用。如果我们扩展包含此默认访问方法类的类,该类将无法访问此方法:
fairlySecretTask(){
//allow just the classes in the package
//Not for external use
}
在我们继续之前,这里有一个最后的例子。它包含一个protected方法,仅对包可见,但可以被扩展它的我们的类使用:
protected familyTask(){
//allow just the classes in the package
//And you can use me if you extend me too
}
方法访问概述
应选择方法访问以最好地执行我们已讨论的原则。它应向类的用户提供他们需要的访问权限,而且最好是不要更多。因此,我们实现了封装目标,如保护代码的内部工作免受使用它的程序干扰,这是我们已讨论的所有原因。
使用获取器和设置器方法访问私有变量
因此,如果我们认为将变量隐藏为私有是最好的实践,那么我们如何在不破坏封装的情况下允许访问它们呢?如果Hospital类的对象想要从Soldier类型的对象中访问health成员变量以增加它怎么办?health变量应该是私有的,对吧?
为了能够尽可能多地使成员变量私有,同时允许对其中一些变量进行某种有限的访问,我们使用获取器和设置器。获取器和设置器是仅获取和设置变量值的方法。
这不是我们必须学习的特殊或新 Java 特性。这只是对我们已知内容使用的一种约定。让我们通过我们的Soldier和Hospital类的例子来看看获取器和设置器。
在这个例子中,我们的两个类都是在其自己的文件中创建的,但属于同一个包。首先,这是我们的假设Hospital类:
class Hospital{
private void healSoldier(Soldier soldierToHeal){
int health = soldierToHeal.getHealth();
health = health + 10;
soldierToHeal.setHealth(health);
}
}
我们实现的Hospital类只有一个方法,healSoldier。它接收一个Soldier对象的引用作为参数,因此此方法将适用于传递的任何Soldier对象:vassily、wellington、rambo或任何人。
它还有一个health变量。它使用这个变量来暂时保存并增加士兵的健康值。在同一行中,它将health变量初始化为Soldier对象的当前健康值。Soldier对象的health是私有的,所以使用公共获取器方法来代替。
然后health增加 10,setHealth设置器方法将新的health值加载回Soldier对象。
关键在于,尽管Hospital对象可以改变Soldier对象的健康值,但它是在获取器和设置器方法的范围内进行的。获取器和设置器方法可以编写来控制和检查可能错误或有害的值。
接下来是我们的假设Soldier类,它具有最简单的 getter 和 setter 方法的实现:
public class Soldier{
private int health;
public int getHealth(){
return health;
}
public void setHealth(int newHealth){
health = newHealth;
}
}
我们有一个名为health的实例变量,它是私有的。私有意味着它只能通过Soldier类的方法来更改。然后我们有一个公共的getHealth方法,不出所料,它返回私有的health变量的值,该变量是int类型。由于这个方法是公共的,任何有权访问Soldier类的人都可以使用它。
接下来,实现setHealth方法。它仍然是公共的,但这次它接受int类型的参数,并将传入的任何值分配给私有的health变量。在一个更逼真的例子中,我们会在这里写更多的代码来确保传入的值在我们预期的范围内。
现在我们将声明、创建和分配,为我们的两个新类中的每一个创建一个对象,并看看我们的获取器和设置器是如何工作的:
Soldier mySoldier = new Soldier();
//mySoldier.health = 100;//Doesn't work private
//we can use the public setter setHealth()
mySoldier.setHealth(100);//That's better
Hospital militaryHospital = new Hospital();
//Oh no mySoldier has been wounded
mySoldier.setHealth(10);
//Take him to the hospital
//But my health variable is private
//And Hospital won't be able to access it
//I'm doomed - tell Laura I love her
//No wait- what about my public getters and setters?
//We can use the public getters and setters from another class
militaryHospital.healSoldier(mySoldier);
//mySoldiers private variable health has been increased by 10
//I'm feeling much better thanks!
我们看到,我们可以在我们的Soldier类型对象上直接调用公共的setHealth和getHealth方法。不仅如此,我们还可以调用Hospital对象的healSoldier方法,传入Soldier对象的引用,该引用可以使用公共的获取器和设置器来操作私有的health变量。
我们看到,私有的health变量简单易访问,但完全在Soldier类的设计者控制之下。
如果你想对这个例子进行实验,代码包中的Chapter6文件夹里有一个可工作的应用程序,名为Getters And Setters。我在这里添加了几行代码来打印到控制台。我们故意这样处理,以使代码的关键部分尽可能清晰。我们很快将构建一些真正的示例,探索类、变量和方法访问。
注意
获取器和设置器有时被称为它们更正确的名字,访问器和修改器。我们将坚持使用获取器和设置器。只是想让你知道。
再次,我们的例子和解释可能又提出了更多问题。这是好事!之前,我说过:
-
一个类有两个访问修饰符,默认和公共
-
类的对象是一种引用变量
-
变量(包括对象)有更多的访问可能性
我们需要更仔细地观察引用和原始变量,以及局部和实例变量。我们将在“变量回顾”部分这样做。在那个部分,我们将进一步整合我们的信息,以更紧密地掌握面向对象的内容。首先,让我们提醒自己关于封装的一些内容。
使用封装特性(如访问控制)就像签订一份非常重要的协议,关于如何使用和访问一个类、其方法和其变量。这个协议不仅是对现在的协议,也是对未来的一种隐含保证。随着我们继续本章的学习,我们将看到更多的方式来完善和加强这个协议。
注意
完全有可能在不考虑或关心封装的情况下重写本书中的每个示例。事实上,本书中除本章外的项目对封装相当宽松。
在需要的地方使用封装,或者当然,如果你是由雇主支付来使用它的话。通常,在小型学习项目中,如本书中的游戏,封装可能过度,除非你正在学习封装本身。
我们在学习 Java 面向对象编程(OOP)时,假设你有一天会想要编写更复杂的应用程序,无论是在 Android 上还是在其他使用 OOP 的平台。
使用构造函数设置我们的对象
与所有这些私有变量及其获取器和设置器相比,这意味着我们需要为每个私有变量都提供一个获取器和设置器吗?对于需要在一开始就初始化大量变量的类,又会怎样?考虑以下情况:
mySoldier.name
mysoldier.type
mySoldier.weapon
mySoldier.regiment
...
我们可以继续这样下去。这些变量中的一些可能需要获取器和设置器,但如果我们只想在对象首次创建时设置一些东西,以便对象能够正确地运行,那会怎样?我们是否需要对每个变量都需要两个方法(一个获取器和设置器)?
为了这个目的,我们有一个特殊的方法,称为构造函数。在这里,我们创建了一个Soldier类型的对象,并将其分配给一个名为mySoldier的对象:
Soldier mySoldier = new Soldier();
这里没有什么新的内容,但看看那行代码的最后部分:
...Soldier();
这看起来非常像是一个方法。
我们调用了一个特殊的方法,称为构造函数,它是由编译器自动为我们提供的。
然而(现在我们正在接近重点),就像方法一样,我们可以重写它,这意味着我们可以在对象被使用之前以及任何方法被放置在栈上之前做一些非常有用的事情来设置我们的新对象:
public Soldier(){
health = 200;
//more setup here
}
这是一个构造函数。它有很多与方法的语法相似之处。它只能通过使用new关键字来运行。除非我们像在之前的代码中那样创建自己的,否则它是由编译器自动为我们创建的。
构造函数有以下属性:
-
它们没有返回类型
-
它们的名称与类名相同
-
它们可以有参数
-
它们可以被重载
在下一个演示中,我们将玩转构造函数。
变量回顾
你可能还记得,在数学游戏项目中,我们一直在改变声明变量的位置。一开始,我们在onCreate中声明了一些变量,然后又把它们移到了类声明下方,然后我们正在把它们变成成员或实例变量。
因为我们没有指定访问权限,它们是默认访问权限,对整个类可见,并且由于所有操作都在一个类中完成,我们可以从任何地方访问它们。例如,我们可以从onClick更新我们的 TextView 类型对象,但为什么在它们在onCreate中声明时不能这样做?关于何时以及如何访问不同变量的进一步解释可能是有用的。
栈和堆
每个 Android 设备内部的虚拟机负责为我们游戏分配内存。此外,它将不同类型的变量存储在不同的地方。
我们在方法中声明和初始化的变量存储在称为“栈”的内存区域。当我们谈论栈时,可以坚持我们的仓库类比——几乎如此。我们已经知道如何操作栈。
让我们谈谈堆以及那里存储了什么。所有引用类型对象,包括对象(类的)和数组,都存储在堆上。把堆想象成同一个仓库的另一个独立区域。堆有大量的地板空间用于形状奇特的物体,货架用于较小的物体,有很多长行带有较小尺寸的立方体孔用于数组,等等。这就是我们对象存储的地方。问题是我们没有直接访问堆的能力。
让我们再次看看引用变量究竟是什么。它是一个我们通过引用来引用和使用的变量。引用可以松散地(但很有用)定义为地址或位置。对象的引用(地址或位置)在栈上。当我们使用点操作符时,我们是在要求 Dalvik 在存储在引用中的特定位置执行任务。
注意
引用变量正是如此——一个引用。它们是访问和操作对象(变量或方法)的方式,但它们并不是实际的变量。一个类比可能是原始数据类型就在那里(在栈上),但引用是一个地址,我们告诉在地址上做什么。在这个类比中,所有地址都在堆上。
我们为什么要有一个这样的系统呢?直接给我栈上的对象不就行了!
快速休息一下,扔掉垃圾
记得在第一章开头我说 Java 比一些语言更容易学习,因为它帮助我们管理内存吗?嗯,这个整个栈和堆的事情就是这样为我们做的。
如我们所知,虚拟机为我们跟踪所有对象并将它们存储在堆上——我们仓库的一个特殊区域。定期,虚拟机将扫描栈,或者我们仓库的常规货架,并将引用与对象匹配。如果它发现任何没有匹配引用的对象,它就会销毁它们。在 Java 术语中,它执行垃圾回收。想象一下一个非常挑剔的垃圾车在我们的堆中间行驶,扫描对象以匹配引用。没有引用?你现在就是垃圾了!毕竟,如果一个对象没有引用变量,我们无论如何也不可能对它做任何事情。这种垃圾回收系统通过释放未使用的内存帮助我们的游戏更有效地运行。
所以在方法中声明的变量是局部的,位于栈上,并且只能在声明它们的那个方法中可见。成员变量位于堆上,并且可以从任何有引用到它的地方引用,前提是访问权限允许引用。
现在我们可以更仔细地看看变量作用域——从哪里可以看到什么。
关于变量,还有更多需要学习的内容。在下一个演示中,我们将探索本章所学到的所有内容,以及一些新的想法。
我们将探讨以下主题:
-
在类的每个实例中保持一致的静态变量
-
类的静态方法,其中你可以使用该类的方法而不需要该类类型的对象
-
我们将演示类和局部变量的作用域,以及它们可以在程序的不同部分中看到或看不到的地方
-
我们将探讨
this关键字,它允许我们编写引用特定类实例变量的代码,而无需跟踪当前使用的是哪个实例
以下是一个演示。
访问、作用域、this、静态和构造函数演示
我们已经探讨了控制变量访问和作用域的复杂方式,看看它们的实际应用示例可能对我们大有裨益。这些示例可能不是非常实用的现实世界中的变量使用案例,但更多的是为了帮助理解类、方法和变量的访问修饰符,以及不同类型的变量,如引用(或原始)和局部(或实例)。然后我们将介绍静态和最终变量的新概念以及this关键字。完成的项目位于代码下载的Chapter6文件夹中。它被称为AccessScopeThisAndStatic。现在我们将执行以下步骤来实现它:
-
创建一个新的空白活动项目,并将其命名为
AccessScopeThisAndStatic。 -
通过在项目资源管理器中右键单击现有的
MainActivity类并导航到新建 | 类来创建一个新的类。将新类命名为AlienShip。 -
现在我们声明我们的新类和一些成员变量。请注意,
numShips是私有的和静态的。我们很快就会看到这个变量在类的所有实例中都是相同的。shieldStrength变量是私有的,而shipName是公共的:public class AlienShip { private static int numShips; private int shieldStrength; public String shipName; -
接下来是构造函数。我们可以看到构造函数是公共的,没有返回类型,并且名称与类名相同,符合规则。在其中,我们增加私有静态
numShips变量。记住,每次我们创建一个新的AlienShip类型的对象时,这都会发生。构造函数还使用私有的setShieldStrength方法为shieldStrength私有变量设置一个值:public AlienShip(){ numShips++; //Can call private methods from here because I am part //of the class //If didn't have "this" then this call might be less clear //But this "this" isn't strictly necessary this.setShieldStrength(100); //Because of "this" I am sure I am setting //the correct shieldStrength } -
这里是公开静态获取器方法,外部
AlienShip类可以使用它来找出有多少AlienShip对象。我们还将看到我们使用静态方法的非同寻常的方式:public static int getNumShips(){ return numShips; } -
以下代码展示了我们的私有
setShieldStrength方法。我们本可以直接在类内部设置shieldStrength,但这段代码展示了如何使用this关键字区分shieldStrength局部变量/参数和shieldStrength成员变量:private void setShieldStrength(int shieldStrength){ //"this" distinguishes between the //member variable shieldStrength //And the local variable/parameter of the same name this.shieldStrength = shieldStrength; } -
下一个方法是获取器,因此其他类可以读取但不能更改每个
AlienShip对象的护盾强度:public int getShieldStrength(){ return this.shieldStrength; } -
现在我们有一个公共方法,每次
AlienShip对象被击中时都可以调用。它只是打印到控制台,然后检查特定对象的shieldStrength是否为零。如果是零,它将调用我们接下来要看的destroyShip方法:public void hitDetected(){ shieldStrength -=25; Log.i("Incoming: ","Bam!!"); if (shieldStrength == 0){ destroyShip(); } } -
最后,我们将查看
AlienShip类的destroyShip方法。我们打印一条消息,指示根据其shipName已销毁的船只,以及增加numShips静态变量,以便我们可以跟踪我们拥有的AlienShip类型对象的数量:private void destroyShip(){ numShips--; Log.i("Explosion: ", ""+this.shipName + " destroyed"); } } -
现在我们切换到我们的
MainActivity类,并编写一些使用我们新的AlienShip类的代码。所有的代码都在调用setContentView之后放入onCreate方法中。首先,我们创建了两个新的AlienShip对象,分别命名为girlShip和boyShip://every time we do this the constructor runs AlienShip girlShip = new AlienShip(); AlienShip boyShip = new AlienShip(); -
看看我们是如何获取
numShips的值的。我们使用getNumShips方法,正如我们预期的那样。然而,仔细看看语法。我们使用的是类名,而不是对象。我们还可以使用非静态方法访问静态变量。我们这样做是为了看到静态方法的作用://Look no objects but using the static method Log.i("numShips: ", "" + AlienShip.getNumShips()); -
现在,我们为我们的公共
shipName字符串变量命名://This works because shipName is public girlShip.shipName = "Corrine Yu"; boyShip.shipName = "Andre LaMothe"; -
如果我们尝试直接给私有变量赋值,这是不会工作的。因此,我们使用公共的
getShieldStrength获取器方法来打印shieldStrength的值,该值被分配给构造函数://This won't work because shieldStrength is private //girlship.shieldStrength = 999; //But we have a public getter Log.i("girlShip shieldStrngth: ", "" + girlShip.getShieldStrength()); Log.i("boyShip shieldStrngth: ", "" + boyShip.getShieldStrength()); //And we can't do this because it's private //boyship.setShieldStrength(1000000);最后,我们通过玩弄
hitDetected方法并偶尔检查两个对象的护盾强度来炸毁一些东西://let's shoot some ships girlShip.hitDetected(); Log.i("girlShip shieldStrngth: ", "" + girlShip.getShieldStrength()); Log.i("boyShip shieldStrngth: ", "" + boyShip.getShieldStrength()); boyShip.hitDetected(); boyShip.hitDetected(); boyShip.hitDetected(); Log.i("girlShip shieldStrngth: ", "" + girlShip.getShieldStrength()); Log.i("boyShip shieldStrngth: ", "" + boyShip.getShieldStrength()); boyShip.hitDetected();//ahhh Log.i("girlShip shieldStrngth: ", "" + girlShip.getShieldStrength()); Log.i("boyShip shieldStrngth: ", "" + boyShip.getShieldStrength()); -
当我们认为我们已经销毁了一艘船时,我们再次使用我们的静态
getNumShips方法来检查我们的静态变量numShips是否被destroyShip方法改变:Log.i("numShips: ", "" + AlienShip.getNumShips()); -
运行演示并查看控制台输出。
这是前面代码块输出的结果:
numShips:﹕ 2
girlShip shieldStrngth:﹕ 100
boyShip shieldStrngth:﹕ 100
Incomiming:﹕ Bam!!
girlShip shieldStrngth:﹕ 75
boyShip shieldStrngth:﹕ 100
Incomiming:﹕ Bam!!
Incomiming:﹕ Bam!!
Incomiming:﹕ Bam!!
girlShip shieldStrngth:﹕ 75
boyShip shieldStrngth:﹕ 25
Incomiming:﹕ Bam!!
Explosion:﹕ Andre LaMothe destroyed
girlShip shieldStrngth:﹕ 75
boyShip shieldStrngth:﹕ 0
numShips:﹕ 1
boyShip shieldStrngth:﹕ 0
numShips:﹕ 1
在前面的例子中,我们看到了我们可以使用this关键字区分同名局部变量和成员变量。我们还可以使用this关键字来编写引用当前正在操作的对象的代码。
我们看到,静态变量(在这个例子中是numShips)在所有实例中是一致的。此外,通过在构造函数中增加并在我们的destroyShip方法中减少它,我们可以跟踪我们创建的AlienShip对象的数量。
我们还看到,我们可以通过使用点操作符来写类名而不是对象来使用静态方法。
最后,我们展示了如何使用访问修饰符来隐藏和暴露某些方法和变量。
在我们继续学习新内容之前,让我们快速回顾一下栈和堆。
关于栈和堆的简要总结
让我们回顾一下关于栈和堆的知识:
-
你不需要删除对象,但虚拟机在认为合适时会发送垃圾回收器。这通常发生在没有活动引用指向对象时。
-
局部变量和方法位于栈上,而局部变量仅限于它们声明的特定方法内。
-
实例或类变量位于堆上(及其对象),但指向对象的引用(地址)是栈上的局部变量。
-
我们控制栈内的内容。我们可以使用堆上的对象,但只能通过引用它们。
-
堆由垃圾回收器维护。
-
当不再有有效的引用指向一个对象时,该对象就会被垃圾回收。因此,当一个引用变量(无论是局部还是实例)从栈中移除时,与其相关的对象就变得适合进行垃圾回收,而当虚拟机决定时机合适(通常非常迅速)时,它将释放 RAM 内存以避免耗尽。
-
如果我们尝试引用一个不存在的对象,我们将得到一个空指针异常,游戏将崩溃。
继承
我们已经看到,我们可以通过从 Android 等 API 的类中实例化/创建对象来利用他人的辛勤工作,但这个面向对象(OOP)的概念甚至比这还要深入。
如果有一个类中包含大量有用的功能,但并不完全符合我们的需求呢?我们可以从这个类继承,然后进一步改进或添加其工作方式和功能。
你可能会惊讶地听到,我们实际上已经这样做了。事实上,我们已经在我们查看的每一个游戏和演示中都这样做了。当我们使用extends关键字时,我们正在继承,例如,在这行代码中:
public class MainActivity extends Activity ...
在这里,我们正在继承Activity类及其所有功能,或者更具体地说,是类设计者希望我们能够访问的所有功能。以下是我们可以对扩展的类执行的一些操作。
我们可以重写一个方法,同时仍然部分依赖于我们从其继承的类中的重写方法。例如,每次我们扩展Activity类时,我们都重写了onCreate方法,但当我们这样做时,我们也调用了类设计者提供的默认实现:
super.onCreate(...
在下一章中,我们还将重写Activity类的更多方法。具体来说,我们将重写处理生命周期的那些方法。
如果我们或类的设计者想要在我们使用他们的类之前强制我们继承,他们可以将类声明为抽象的。然后我们不能从它创建对象。因此,我们必须首先扩展它,并从子类创建对象。我们将在我们的继承示例中这样做,并在我们研究多态时进一步讨论。
我们也可以声明一个方法为抽象的,并且这个方法必须在扩展具有抽象方法的类的任何类中重写。我们将在我们的继承示例中也这样做。
在我们的游戏项目中,我们不会设计任何将要扩展的类。在了解构建简单游戏的环境中,我们没有这个需求。然而,在未来的每个游戏中,我们都会扩展其他人为设计的类。
我们主要讨论继承,是为了理解我们周围正在发生的事情,并且作为最终能够设计出有用类,我们或他人可以扩展的第一步。考虑到这一点,让我们创建一些简单的类,看看我们如何扩展它们,仅作为一个初步的语法练习,并且能够说我们已经做到了。当我们看到本章的最后一个主要主题多态时,我们也会对继承进行更深入的探讨。
继承的一个例子
我们已经看到了如何创建类层次结构来模拟适合我们的游戏或软件项目的系统,现在让我们尝试一些使用继承的简单代码。完成的项目位于代码下载的Chapter6文件夹中。它被称为InheritanceExample。我们现在将执行以下步骤:
-
按照常规方式创建三个新类。将其中一个命名为
AlienShip,另一个命名为Fighter,最后一个命名为Bomber。 -
下面是
AlienShip类的代码。它与我们的上一个AlienShip类演示非常相似。不同之处在于,构造函数现在接受一个int参数,它使用这个参数来设置护盾强度。构造函数还会向控制台输出一条消息,以便我们能够看到它何时被使用。AlienShip类还有一个新的方法fireWeapon,它被声明为abstract。这保证了任何子类AlienShip都必须实现自己的fireWeapon版本。注意,类声明中包含abstract关键字。我们必须这样做,因为它的一个方法也使用了abstract关键字。我们将在讨论这个演示和抽象类时解释abstract方法,当我们谈到多态时,我们也会更深入地讨论抽象类:public abstract class AlienShip { private static int numShips; private int shieldStrength; public String shipName; public AlienShip(int shieldStrength){ Log.i("Location: ", "AlienShip constructor"); numShips++; setShieldStrength(shieldStrength); } public abstract void fireWeapon();//Ahh my body public static int getNumShips(){ return numShips; } private void setShieldStrength(int shieldStrength){ this.shieldStrength = shieldStrength; } public int getShieldStrength(){ return this.shieldStrength; } public void hitDetected(){ shieldStrength -=25; Log.i("Incomiming: ", "Bam!!"); if (shieldStrength == 0){ destroyShip(); } } private void destroyShip(){ numShips--; Log.i("Explosion: ", "" + this.shipName + " destroyed"); } } -
现在我们将实现
Bomber类。注意调用super(100)。这会调用超类构造函数,并传入shieldStrength的值。我们可以在构造函数中做进一步的特定Bomber初始化,但现阶段,我们只是打印位置信息,以便我们能够看到Bomber构造函数何时被调用。我们还实现了一个Bomber类特定的抽象fireWeapon方法版本,因为我们必须这样做:public class Bomber extends AlienShip { public Bomber(){ super(100); //Weak shields for a bomber Log.i("Location: ", "Bomber constructor"); } public void fireWeapon(){ Log.i("Firing weapon: ", "bombs away"); } } -
现在我们将实现
Fighter类。注意调用super(400)。这会调用超类构造函数,并传入shieldStrength的值。我们可以在构造函数中做进一步的Fighter类特定初始化,但现阶段,我们只是打印位置信息,以便我们能够看到Fighter构造函数何时被调用。我们还实现了一个Fighter特定的抽象fireWeapon方法版本,因为我们必须这样做:public class Fighter extends AlienShip{ public Fighter(){ super(400); //Strong shields for a fighter Log.i("Location: ", "Fighter constructor"); } public void fireWeapon(){ Log.i("Firing weapon: ", "lasers firing"); } } -
下面是我们代码在
MainActivity的onCreate方法中的样子。像往常一样,我们在调用setContentView之后输入这段代码。这段代码使用了我们的三个新类。它看起来相当普通,但没有什么新东西;有趣的是输出:Fighter aFighter = new Fighter(); Bomber aBomber = new Bomber(); //Can't do this AlienShip is abstract - //Literally speaking as well as in code //AlienShip alienShip = new AlienShip(500); //But our objects of the subclasses can still do //everything the AlienShip is meant to do aBomber.shipName = "Newell Bomber"; aFighter.shipName = "Meier Fighter"; //And because of the overridden constructor //That still calls the super constructor //They have unique properties Log.i("aFighter Shield:", ""+ aFighter.getShieldStrength()); Log.i("aBomber Shield:", ""+ aBomber.getShieldStrength()); //As well as certain things in certain ways //That are unique to the subclass aBomber.fireWeapon(); aFighter.fireWeapon(); //Take down those alien ships //Focus on the bomber it has a weaker shield aBomber.hitDetected(); aBomber.hitDetected(); aBomber.hitDetected(); aBomber.hitDetected();
下面是前面代码片段的输出:
Location:﹕ AlienShip constructor
Location:﹕ Fighter constructor
Location:﹕ AlienShip constructor
Location:﹕ Bomber constructor
aFighter Shield:﹕ 400
aBomber Shield:﹕ 100
Firing weapon:﹕ bombs away
Firing weapon:﹕ lasers firing
Incomiming:﹕ Bam!!
Incomiming:﹕ Bam!!
Incomiming:﹕ Bam!!
Incomiming:﹕ Bam!!
Explosion:﹕ Newell Bomber destroyed
我们可以看到子类的构造函数是如何调用超类的构造函数的。我们也可以清楚地看到fireWeapon方法的单独实现工作得完全符合预期。
就好像面向对象编程本身还不够有用一样!我们现在可以模拟现实世界的对象,并设计它们相互交互。我们还看到了如何通过子类化/扩展/从其他类继承来使面向对象编程更加有用。我们可能想学习在这里的术语是,从其扩展的类是超类,从超类继承的类是子类。我们也可以称它们为父类和子类。
小贴士
如同往常,我们可能会对继承提出这样的问题:为什么?我们可以在父类中一次性编写通用的代码,并且可以更新这些通用代码。所有从它继承的类也会得到更新。此外,子类只继承公共实例变量和方法。当设计得当,这进一步增强了封装的目标。
多态
多态大致意味着不同的形式。但它对我们意味着什么呢?
用最简单的话来说,任何子类都可以作为使用超类的代码的一部分。
例如,如果我们有一个动物数组,我们可以将任何类型为Animal子类的对象放入Animal数组中,比如猫和狗。
这意味着我们可以编写更简单、更容易理解和修改的代码:
//This code assumes we have an Animal class
//And we have a Cat and Dog class that extends Animal
Animal myAnimal = new Animal();
Dog myDog = new Dog();
Cat myCat = new Cat();
Animal [] myAnimals = new Animal[10];
myAnimals[0] = myAnimal;//As expected
myAnimals[1] = myDog;//This is OK too
myAnimals[2] = myCat;//And this is fine as well
我们还可以为超类编写代码,并依赖于这样一个事实:无论它被子类化多少次,在一定的参数范围内,代码仍然可以工作。让我们继续之前的例子:
//6 months later we need elephants
//with its own unique aspects
//As long as it extends Animal we can still do this
Elephant myElephant = new Elephant();
myAnimals[3] = myElephant;//And this is fine as well
你还可以编写具有多态返回类型和参数的方法:
Animal feedAnimal(Animal animalToFeed){
//Feed any animal here
return animalToFeed;
}
因此,你甚至可以编写今天的代码,并在一周、一个月或一年后创建另一个子类,而完全相同的方法和数据结构仍然可以工作。
此外,我们可以对子类施加一套规则,规定它们可以做什么,不可以做什么,以及应该如何做。因此,一个阶段的好设计可以影响其他阶段的子类。
如果你突然发现自己遇到了一个像《愤怒的小鸟》那么大的现象,并且你的代码中有很多面向对象编程,那么从一开始,引入外部帮助来推进项目并仍然保持对项目的控制将会容易得多。
如果你有一个有很多功能的游戏的想法,但你希望尽快推出一个简单的游戏版本,那么智能的面向对象设计当然会是解决方案。它可以使你编写游戏的工作框架,然后逐渐扩展它。
接下来,让我们看看另一个面向对象编程(OOP)的概念:抽象类。我们现在可以深入探究那个 AlienShip 代码中发生的事情:
public abstract class AlienShip{...
抽象类
抽象类是一个不能被实例化,或者不能被制作成对象的类。我们在前面的例子中提到 AlienShip 是一个抽象类。那么它是不是一个永远不会被使用的蓝图呢?但那就像请建筑师设计你的房子然后永远不建造它一样!我对抽象方法的概念有点理解,但这真的很荒谬!
起初这看起来可能如此。我们通过使用 abstract 关键字声明一个抽象类,如下所示:
abstract class someClass{
//All methods and variables here as usual
//Just don't try and make an object out of me!
}
但为什么呢?
有时候,我们希望有一个可以作为多态类型的类,但同时确保它永远不能被用作对象。例如,Animal 类本身并没有什么意义。
我们不谈论动物;我们谈论动物的类型。我们不说,“哇,看那只可爱、蓬松、白色的动物”,或者“昨天我们去宠物店买了一只动物和一张动物床。”这太抽象了。
所以抽象类就像是一个模板,可以被任何扩展它的类(继承它的类)使用。
我们可能需要一个 Worker 类,并扩展它来创建如 Miner、Steelworker、OfficeWorker 和当然还有 Programmer 这样的类。但一个普通的 Worker 类到底做什么呢?我们为什么想要实例化一个?
答案是,我们可能不想实例化它,但我们可能希望将其用作多态类型,这样我们就可以在方法之间传递多个工作子类,并拥有可以存储所有类型工人的数据结构。
我们称这种类型的类为抽象类,当一个类有一个甚至一个抽象方法,如 AlienShip 所做的那样,它本身也必须被声明为抽象。正如我们所见,所有抽象方法都必须由扩展抽象类的任何类重写。这意味着抽象类可以提供一些在所有子类中都可能存在的公共功能。例如,Worker 类可能有 height、weight 和 age 成员变量。
它可能有一个 getPayCheck 方法,这个方法在所有子类中都是相同的,还有一个抽象的 doWork 方法,这个方法必须被重写,因为所有不同类型的工人做的工作非常不同。
这很自然地引出了多态性的另一个领域,它值得特别提一下,因为我们到目前为止在每一款游戏中都使用了它。
接口
接口就像一个类。呼!这里没有什么复杂的。然而,它就像一个总是抽象的类,并且只包含抽象方法。
我们可以将接口想象为一个完全抽象的类,其中所有的方法也都是抽象的。好吧,所以你几乎可以理解抽象类,因为它至少可以在其非抽象方法中传递一些功能,并作为多态类型。
但说真的,这个接口似乎有点没有意义。请耐心听我说。
要定义一个接口,我们输入以下代码:
public interface myInterface{
void someAbstractMethod();//omg I've got no body
int anotherAbstractMethod();//Ahh! Me too
//Interface methods are always abstract and public implicitly
//but we could make it explicit if we prefer
public abstract explicitlyAbstractAndPublicMethod();//still no body though
}
接口的方法没有主体,因为它们是抽象的,但它们仍然可以有返回类型和参数,或者没有。
要使用接口,我们在类声明之后使用implements关键字。是的,我们已经为onClickListener做过几次了:
public class someClass implements someInterface{
//class stuff here
//better implement the methods of the interface or the red error lines will not go away
public void someAbstractMethod(){
//code here if you like but just an empty implementation will do
}
public int anotherAbstractMethod(){
//code here if you like but just an empty implementation will do
//Must have a return type though as that is part of the contract
return 1;}
}
这使我们能够使用多态性,处理来自完全不同继承层次结构中的多个不同对象。只要它实现了接口,整个对象就可以像那个对象一样传递,它就是那个对象。我们甚至可以让一个类同时实现多个不同的接口。只需在每个接口之间添加逗号,并在implements关键字之后列出它们。但请确保实现所有必要的函数。
让我们回到onClickListener接口。任何东西都可能想知道它何时被点击;一个按钮、一个文本视图等等。我们不想为每种类型都有不同的onClick方法。
小贴士
当使用 Android 时,无论是用于游戏还是用于更常见的基于 GUI 的应用(有点像我们到目前为止的应用),十有八九,你将实现接口而不是编写自己的。然而,了解正在发生的事情非常重要,这不仅是因为技术意识的角度,正如我们刚才看到的,接口指定了一个合同,编译器强制执行它,而且更多的是因为当你使用implements关键字并编写一个(或多个)你未选择名称的方法时,了解实际发生的事情。
更多关于面向对象编程(OOP)和类的内容
可以写一本关于面向对象编程(OOP)的整本书,许多作者已经这样做了,但学习面向对象编程的最佳方式可能是实践;在我们学习所有理论之前先实践。无论如何,在我们继续一些更实际的例子之前,这里有一个稍微理论性的面向对象编程例子,如果不提出来,我们稍后可能会感到困惑。
内部类
当我们查看我们的基本类演示应用时,我们在与MainActivity类分开的文件中声明并实现了该类。那个文件与类的名称相同。
我们也可以在类内部声明和实现一个类。当然,唯一的问题是,我们为什么要这样做?当我们实现内部类时,内部类可以访问封装类的成员变量,而封装类可以访问内部类的成员。我们将在接下来的两章中看到这一点。
如果你不是在模拟深度或现实世界的系统,那么内部类通常是最佳选择。实际上,我们将在本书的其余部分编写的所有类都将扩展内部类。这意味着我们将扩展一个类型,在我们的Activity类内部创建自己的类。这使得我们的代码既简洁又简单。
自测问题
Q1) 找出这个类声明有什么问题:
private class someClass{
//class implementation goes here
}
Q2) 封装是什么?
Q3) 我没有完全理解,实际上,我现在比本章开始时的问题更多。我该怎么办?
概述
在本章中,我们比其他任何章节都涵盖了更多的理论。如果你没有记住所有内容,那么你已经完全成功了。如果你只是理解了面向对象编程(OOP)是通过封装、继承和多态来编写可重用、可扩展和高效代码的,那么你有潜力成为 Java 大师。简单来说,OOP 使我们能够在那些人当时做这项工作时并不完全清楚我们会做什么的情况下,利用他们的辛勤工作。你所要做的就是不断练习,那么让我们在下一章中制作一个复古游戏。
第七章.复古挤压游戏
本章是乐趣开始的地方。虽然复古挤压游戏显然比最新的大型预算游戏低一个或两个级别,但这是我们开始关注一些基础的时刻——绘图、检测我们绘制的物体何时相互碰撞,以及拥有由我们控制的动画。
一旦你能画出像素并移动它,只需一点想象力和工作,你就有潜力画出任何东西。然后,当我们结合一些非常简单的数学来模拟碰撞和重力的物理时,我们就能接近实现我们的挤压游戏。
小贴士
很遗憾,这本书没有时间深入探讨将屏幕上的点变成在三维世界中移动的逼真三维角色的数学。当然,大型预算游戏背后的技术和数学非常先进和复杂。然而,将像素变成线条,将线条变成三角形,对三角形进行纹理处理,用三角形构建物体,并在三维世界中定位它们,对于任何学过高中数学的人来说都在掌握之中。我们经常听到,优秀的图形并不一定能制作出优秀的游戏,这是真的,但优秀的图形(至少对我来说)是电子游戏中最令人兴奋的方面之一,即使它们显示在本身可能更有趣的游戏上。如果你想看看如何将像素变成神奇的世界,并开始欣赏顶级游戏引擎和图形库背后的工作,你可以从《计算机图形学:数学第一步》,P.A. Egerton 和 W.S Hall,Prentice Hall开始。
在本章中,我们将涵盖以下主题:
-
探索 Android
Canvas类,它使绘图变得简单和有趣 -
编写一个简单的 Canvas 演示应用
-
学习如何在屏幕上检测触摸
-
创建复古挤压游戏
-
实现复古挤压游戏
使用 Android Canvas 绘图
到目前为止,我们一直在使用 Android UI 设计器来实现所有我们的图形。当我们只需要按钮和文本等对象时,这是完全可以的。
诚然,Android UI 元素的内容远不止我们迄今为止所探索的。例如,我们知道我们可以使用Animation类做更多的事情,而且我们非常简短地看到我们可以将任何我们喜欢的图像分配给代表 UI 元素。
例如,我们可以将游戏角色,如宇宙飞船,分配给 UI 元素并对其动画化。
然而,如果我们想要平滑移动的宇宙飞船、精确的碰撞检测、可爱的人物和具有多帧卡通动画的恐怖敌人,那么我们就需要远离预定义的 UI 元素。
我们将需要开始关注并设计单个像素、线条、位图和精灵图。幸运的是,正如你可能猜到的,Android 有一些类可以让我们轻松地完成这些操作。我们将学习如何使用Canvas和Paint类入门。
位图和精灵图将在下一章中介绍。在这一章中,我们将学习如何绘制像素和线条,以制作一个简单的、平滑移动的乒乓球风格的 squash 游戏。
为了实现这一点,我们将了解我们用来绘制像素和线条的坐标系统。然后我们将查看Paint和Canvas类本身。
Android 坐标系统
像素是我们可以使用Paint和Canvas类操作的图形元素中最小的。它本质上是一个点。如果你的设备分辨率为 1920 x 1080,就像一些新的谷歌品牌平板电脑或高端三星手机,那么我们沿着设备最长边有 1920 个像素,沿着宽度有 1080 个像素。
因此,我们可以将我们将要在其上绘制的屏幕视为一个网格。我们使用Canvas和Paint类在这个虚拟画布上绘制。我们将通过在这个网格上绘制点(像素)、线条、形状和文本来实现这一点。
坐标系统从屏幕的左上角开始。
例如,看看下面这行代码:
drawPoint(0, 0); //Not actual syntax (but very close)
在这里,我们将在屏幕左上角绘制一个单独的像素。现在看看以下代码:
drawPoint(1920, 1080); //Not actual syntax (but very close)
如果我们这样使用它,我们可以在这些高端设备(在横屏位置)的右下角绘制一个点。
我们也可以通过指定起始和结束坐标位置来绘制线条,就像这样:
drawLine(0,0,1920, 1080); //Not actual syntax (but very close)
这将绘制从屏幕左上角到底右角的线条。
你可能已经注意到了一些潜在的问题。首先,并不是所有的 Android 设备都有如此高的分辨率;事实上,大多数分辨率都显著较低。即使具有高分辨率的设备,在横屏或竖屏位置时坐标也会完全不同。我们将如何编写代码以适应这些设备,而不管屏幕分辨率如何?我们很快就会看到解决方案。
动画化我们的像素
绘制形状、线条和像素都很好,但我们如何让它们看起来在移动呢?我们将使用在卡通、电影和其他视频游戏中使用的相同的动画技巧:
-
绘制一个对象。
-
擦除它。
-
在新位置绘制对象。
-
快速重复,以欺骗玩家的头脑,使游戏对象看起来在移动。
理论上,这一切听起来比实际要复杂。让我们快速看一下Paint和Canvas类以及一个简单的入门级演示应用。然后我们可以真正实现我们的复古网球游戏。
开始使用 Canvas 和 Paint
正如其名,Canvas类提供了我们预期的一切——一个虚拟画布,用于绘制我们的图形。
我们可以使用Canvas类从任何 Android UI 元素创建一个虚拟画布。在我们的演示应用中,我们将在 ImageView 上绘制,当我们制作游戏时,我们将直接在一种特殊类型的视图中绘制,这将带来一些额外的优势,正如我们将看到的。
要开始,我们需要一个视图来绘制。我们已经知道如何使用 Java 代码从我们的 UI 布局中获取视图:
ImageView ourView = (ImageView) findViewById(R.id.imageView);
这行代码获取了一个放置在 UI 设计中的 ImageView 的引用,并将其分配到我们的 Java 代码中的对象。正如我们所见,UI 设计中的 ImageView 有一个分配的 ID 为imageView,我们 Java 代码中的可控制 ImageView 对象被称为ourView。
现在我们需要一个位图。位图本身有一个像屏幕一样的坐标系。我们正在创建一个位图,以便将其转换为画布:
Bitmap ourBitmap = Bitmap.createBitmap(300,600, Bitmap.Config.ARGB_8888);
上一行代码声明并创建了一个Bitmap类型的对象。它将有 300x600 像素的大小。我们在稍后绘制时会记住这一点。
提示
createBitmap方法中的最后一个参数Bitmap.Config.ARGB_8888只是一个格式,我们可以在不涉及位图格式不同选项的情况下创建一些很棒的游戏。
现在我们可以通过从它创建一个Canvas对象来准备我们的位图以供绘制:
Canvas ourCanvas = new Canvas(ourBitmap);
接下来,我们创建一个Paint类型的对象。我们可以把这个对象想象成虚拟画布的画笔和颜料:
Paint paint = new Paint();
到目前为止,我们已经准备好使用我们的Paint和Canvas对象进行一些绘制。在屏幕左上角绘制像素的实际代码将看起来像这样:
ourCanvas.drawPoint(0, 0, paint);//How simple is that?
让我们现在看看一个工作示例。
Android Canvas 演示应用
让我们创建一个使用Canvas和Paint类并做一些绘制操作的应用。这个例子将完全是静态的(没有动画),这样我们就可以清楚地看到如何使用Canvas和Paint,而不会在代码中添加我们以后会学到的内容。
在这个演示应用中,我们使用一些概念上有帮助的变量名来帮助我们理解每个对象所扮演的角色,但我们在最后会过一遍整个过程,以确保我们确切地知道每个阶段的实际情况。当然,你不必输入所有这些。你可以从下载包中Chapter7文件夹的CanvasDemo文件夹中打开完成的代码文件:
-
开始一个新项目,并将其命名为
CanvasDemo。如果你想的话,整理一下不必要的导入和覆盖。 -
在编辑器中打开
activity_main.xml。从调色板中拖动一个 ImageView 到布局中。ImageView 默认有一个 ID,即imageView。现在我们将在这个 ID 中使用我们的代码。 -
切换到编辑器中的
MainActivity.java。首先,我们将创建我们的Bitmap、Canvas和Paint对象,正如我们之前讨论的那样。以下是代码的第一部分。在调用setContentView方法后直接输入://Get a reference to our ImageView in the layout ImageView ourFrame = (ImageView) findViewById(R.id.imageView); //Create a bitmap object to use as our canvas Bitmap ourBitmap = Bitmap.createBitmap(300,600, Bitmap.Config.ARGB_8888); Canvas ourCanvas = new Canvas(ourBitmap); //A paint object that does our drawing, on our canvas Paint paint = new Paint(); -
在这里,我们尝试绘制一些酷炫的东西。在上一步骤的代码后直接输入以下代码:
//Set the background color ourCanvas.drawColor(Color.BLACK); //Change the color of the virtual paint brush paint.setColor(Color.argb(255, 255, 255, 255)); //Now draw a load of stuff on our canvas ourCanvas.drawText("Score: 42 Lives: 3 Hi: 97", 10, 10, paint); ourCanvas.drawLine(10, 50, 200, 50, paint); ourCanvas.drawCircle(110, 160, 100, paint); ourCanvas.drawPoint(10, 260, paint); //Now put the canvas in the frame ourFrame.setImageBitmap(ourBitmap); -
在模拟器或设备上运行演示。
您的输出将类似于以下截图所示:

让我们再次审视一下代码。在步骤 1 和 2 中,我们创建了一个新项目,并在我们的 UI 布局中放置了一个 ID 为 imageView 的 ImageView 对象。
在步骤 3 中,我们首先获取了我们布局中 ImageView 对象的引用。然而,我们经常这样做,通常是用 TextViews 和 Buttons。我们称我们的 ImageView 为 ourFrame,因为它将包含我们的画布:
ImageView ourFrame = (ImageView) findViewById(R.id.imageView);
然后我们创建了一个用于制作画布的位图:
Bitmap ourBitmap = Bitmap.createBitmap(300,600, Bitmap.Config.ARGB_8888);
Canvas ourCanvas = new Canvas(ourBitmap);
之后,我们创建了我们的新 Paint 对象:
Paint paint = new Paint();
在步骤 4 中,我们准备开始绘制,并以几种不同的方式进行了绘制。首先,我们将整个画布涂成黑色:
ourCanvas.drawColor(Color.BLACK);
然后我们选择了我们将要使用的颜色。(255, 255, 255, 255) 是白色(不透明)的数值表示:
paint.setColor(Color.argb(255, 255, 255, 255));
现在我们看到了一些新的内容,但它很容易理解。我们也可以将文本字符串绘制到屏幕上,并将文本放置在精确的屏幕坐标上,就像我们可以对像素做的那样。
您会注意到,在 drawText 方法以及 Canvas 类的所有其他绘图方法中,我们总是将我们的 Paint 对象作为参数传递。为了使下一行代码中发生的事情绝对清晰,我声明 "Score: 42 Lives:3 Hi: 97" 是将在屏幕上绘制的字符串,10, 10 是屏幕坐标,paint 是我们的 Paint 对象:
ourCanvas.drawText("Score: 42 Lives: 3 Hi: 97", 10, 10, paint);
接下来,我们绘制了一条线。这里的参数列表可以这样描述:(起始 x 坐标,起始 y 坐标,结束 x 坐标,结束 y 坐标,我们的 Paint 对象):
ourCanvas.drawLine(10, 50, 200, 50, paint);
现在我们看到我们可以绘制圆形。我们也可以绘制其他形状。这里的参数列表可以这样描述:(起始 x 坐标,起始 y 坐标,圆的半径,我们的 Paint 对象):
ourCanvas.drawCircle(110, 160, 100, paint);
然后我们绘制了一个谦逊的、孤独的像素(点)。我们使用的参数格式如下:(x 坐标,y 坐标,Paint 对象):
ourCanvas.drawPoint(10, 260, paint);
最后,我们将我们的位图画布放置在 ImageView 框架中:
ourFrame.setImageBitmap(ourBitmap);
我们仍然需要更智能地管理屏幕分辨率和方向,我们将在我们的复古壁球游戏中这样做。此外,我们需要寻找一个系统,使我们能够以固定的时间间隔擦除并重新绘制我们的图像,以创造运动的错觉。实际上,我们已经知道这样一个系统。想想我们如何使用线程来实现这种错觉。首先,让我们看看玩家将如何控制游戏。毕竟,我们不会为这个游戏提供任何方便的 UI 按钮来按下。
检测屏幕上的触摸
在我们的复古壁球游戏中,我们将没有 UI 按钮,因此我们不能使用OnClickListener接口并重写onClick方法。然而,这不是问题。我们将使用另一个接口来适应我们的情况。我们将使用OnTouchListener并重写onTouchEvent方法。它的工作方式略有不同,所以在我们深入研究游戏代码之前,让我们看看如何实现它。
我们必须为想要监听触摸的活动实现OnTouchListener接口,如下所示:
public class MainActivity extends Activity implements View.OnTouchListener{
然后我们可以重写onTouchEvent方法,可能就像这样。
@Override
public boolean onTouchEvent(MotionEvent motionEvent) {
float x = motionEvent.getX();
float y = motionEvent.getY();
//do something with the x and y values
return false;
}
x变量将保存屏幕上被触摸的位置的水平值,而y将保存垂直位置。值得注意的是,motionEvent对象参数包含大量信息,以及x和y的位置,例如,屏幕是否被触摸或释放。我们可以使用这些信息制作一些非常有用的 switch 语句,就像我们稍后将要看到的那样。
精确地知道我们如何使用这种方法在壁球游戏中实现我们的目标,需要我们首先考虑游戏的设计。
准备制作复古壁球游戏
现在,我们已经准备好讨论我们下一款游戏的制作了。我们实际上知道我们需要的一切。我们只需要考虑如何使用我们学到的不同技术。
让我们首先看看我们确切想要实现什么,这样我们就有目标可以追求。
游戏设计
让我们看看游戏的一个截图作为良好的起点。当你设计自己的游戏时,绘制游戏内对象和游戏机制草图将是设计过程中的无价之宝。在这里,我们可以通过查看最终结果来作弊一点。

UI
从顶部开始,我们有得分。每当玩家成功击中球时,就会加一分。接下来是生命值。玩家开始时有三个生命值,每次他们让球从他们的球拍旁过去,就会失去一个生命值。当玩家的生命值为零时,他们的得分设置为零,生命值恢复到三个,游戏重新开始。旁边是帧率。FPS 代表每秒帧数。如果我们在屏幕上监控每秒屏幕重绘的次数,那将很棒,因为这是我们第一次动画化我们自己的图形。
在上一张截图的大约中间位置是球。它是一个正方形球,符合传统的乒乓风格。当需要执行看起来逼真的碰撞检测时,正方形也更容易处理。
物理
我们将检测球击中屏幕的任意四边以及击中球拍的情况。根据球击中的物体及其在碰撞时的当前方向,我们将确定球会发生什么。以下是每种类型碰撞的大致概述:
-
点击屏幕顶部:球将保持相同的水平 (x) 方向移动,但反转垂直 (y) 方向的移动。
-
点击屏幕的任意一侧:球将保持其 y 方向的移动,但会反转其 x 方向。
-
点击屏幕底部:球将消失,并在屏幕顶部以向下的 y 方向移动和随机的 x 方向移动重新开始。
-
点击玩家的球拍:我们将检查球是否击中了球拍的左侧或右侧,并改变 x 方向的移动以匹配。我们还将反转 y 方向的移动,使球再次回到顶部。
通过强制实施这些粗略的物理虚拟规则,我们可以简单地创建一个球,其行为几乎就像我们预期一个真实球会做的那样。我们将在球击中球拍后略微增加球的速度。这些规则在纵向或横向方向上都会同样有效。
玩家的球拍将是一个简单的矩形,玩家可以通过在屏幕左侧的任何位置握住来向左滑动,通过在屏幕右侧的任何位置握住来向右滑动。
为了简洁起见,我们不会创建一个主菜单屏幕来实现高分。在我们的最终游戏中,我们将在下一章开始时创建一个动画菜单屏幕、在线高分和成就。然而,这个弹跳游戏将在玩家生命归零时简单地重新开始。
代码结构
在这里,我们将快速从理论上审视一些可能引起疑问的实现方面。当我们最终着手实现时,我们应该会发现大部分代码相当直接,只有少数部分可能需要额外的解释。
我们已经讨论了我们需要知道的一切,我们还将随着实现过程的进行讨论具体细节。我们将在每个实现阶段的末尾回顾代码中更复杂的部分。
如往常一样,所有完成的代码文件都可以在下载包中找到。涵盖本项目所有阶段的文件位于 Chapter7/RetroSquash 文件夹中。
我们已经了解到,在使用类及其方法的程序中,代码的不同部分将相互依赖。因此,我们不会在代码中来回跳跃,而是从第一行到最后一行依次展开。当然,我们也会在过程中参考相关的代码部分。我强烈建议您全面研究代码,以充分理解正在发生的事情以及哪些代码部分调用了哪些其他部分。
为了防止这个实现变成一个庞大的待办事项列表,它已经被分为四个阶段。这应该提供了方便的停止和休息的地方。
没有布局文件,只有一个.java文件。这个文件叫做MainActivity.java。MainActivity.java文件的结构如以下代码概览所示。我已经缩进了一些部分以显示哪些部分被包含在其他部分中。这是一个高级视图,省略了很多细节:
Package name and various import statements
MainActivity class starts{
Declare some member variables
OnCreate method{
Initialization and setup
}
SquashCourtView class{
Constructor
Multiple methods of SquashCourtView
}
Some Android lifecycle method overrides
}
如前所述,我们可以看到一切都在MainActivity.java文件中。像往常一样,在文件的顶部,我们将有一个包名和大量导入我们将使用的不同类。
接下来,正如我们所有的其他项目一样,我们有MainActivity类。它包含了一切,甚至包括SquashCourtView类。这使得SquashCourtView类成为一个内部类,因此它将能够访问MainActivity类的成员变量,这在实现中将是至关重要的。
然而,在SquashCourtView类之前,我们将声明MainActivity类中的所有成员变量,然后是一个相当深入的onCreate方法。
我们可以接下来实现其他 Android 生命周期方法,您也可以这样做。然而,在其他 Android 生命周期方法中的代码,在我们看到SquashCourtView类的方法之后将更有意义。
在onCreate方法之后,我们将实现SquashCourtView类。这个类中包含了一些相当长的函数,因此我们将将其分为第 2 和第 3 阶段。
最后,我们将实现剩余的 Android 生命周期方法。它们很短,但很重要。
四个实现阶段的详细说明
在我们真正着手之前,让我们更仔细地看看实现过程。以下是我们将如何将实现过程分为四个阶段,这次将更详细地说明每个阶段可以期待什么:
-
第一阶段 – MainActivity 和 onCreate:在这个阶段,我们将创建项目本身,并实现以下步骤:
-
我们将添加我们的导入并创建
MainActivity类的主体 -
在此过程中,我们将声明游戏所需的成员变量
-
我们将实现我们的
onCreate方法,它做了大量的设置工作,但没有什么难以理解的
-
-
第二阶段 – SquashCourtView 部分 1:在这个阶段,我们将开始工作于我们的关键类
SquashCourtView。具体来说,我们将:-
实现声明
SquashCourtView类及其成员变量的声明。 -
编写一个简单的构造函数。
-
实现控制游戏流程的
run方法。 -
实现一个既长又相对容易理解的
updateCourt方法。这是处理碰撞检测并跟踪我们的球和球拍的函数。
-
-
阶段 3 – SquashCourtView 第二部分:在这个阶段,我们将通过实现以下内容来完成
SquashCourtView类:-
drawCourt方法,它意外地做了所有的绘图工作 -
controlFPS方法,它使游戏在不同 CPU 的设备上以相似的速度运行 -
接下来,我们将快速编写几个帮助 Android 具有类似名称的生命周期方法的方法——
pause和resume方法 -
最后,对于这个阶段,我们将通过重写之前查看的
onTouchEvent方法轻松处理游戏的触摸控制。
-
-
阶段 4 – 剩余的生命周期方法:在这个简短的阶段,我们将添加一些收尾工作:
-
快速实现
onPause、onResume和onStop方法,通过重写它们 -
我们还将处理当玩家在手机或平板电脑上按下返回按钮时会发生什么
-
阶段 1 – MainActivity 和 onCreate
既然我们已经看到了每个阶段我们将要做什么,那么让我们通过以下步骤开始构建我们的游戏:
-
创建一个新的项目,就像我们之前做的那样,但有一点不同。这次,在新建项目对话框中,将最低要求的 SDK更改为API 13: Android 3.2 (Honeycomb)。将项目命名为
RetroSquash。如果你喜欢,可以删除不必要的重写方法。 -
编辑
AndroidManifest.xml文件,就像我们在第四章的结尾所做的那样,使应用使用全屏。如有需要,请查看完整细节。注意,我们不会锁定方向,因为这款游戏在竖屏和横屏模式下都很有趣。以下是添加的代码行:android:theme="@android:style/Theme.NoTitleBar.Fullscreen"> -
使用 Bfxr 制作一些声音效果,就像我们在第五章中做的那样,四个就足够了,但你可以添加更多声音。为了获得正宗的 70 年代风格的声音,尝试以下截图中的Blip/Select按钮。将样本命名为
sample1.ogg、sample2.ogg、sample3.ogg和sample4.ogg。或者你也可以直接使用我的样本。它们位于代码包中名为RetroSquash的文件夹的assets文件夹中。![阶段 1 – MainActivity 和 onCreate]()
-
在项目资源管理器中,在
main目录下创建一个名为assets的目录。将上一步骤中创建的四个声音文件复制到新创建的assets文件夹中。 -
在
MainActivity.java文件的顶部输入以下导入语句,但仅在你包名之后,如下所示:package com.packtpub.retrosquash.app; import android.app.Activity; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.content.res.AssetManager; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Point; import android.media.AudioManager; import android.media.SoundPool; import android.os.Bundle; import android.view.Display; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.SurfaceHolder; import android.view.SurfaceView; import java.io.IOException; import java.util.Random; -
现在输入你的类声明并声明以下成员变量。我们将在本阶段结束时详细讨论成员变量:
public class MainActivity extends Activity { Canvas canvas; SquashCourtView squashCourtView; //Sound //initialize sound variables private SoundPool soundPool; int sample1 = -1; int sample2 = -1; int sample3 = -1; int sample4 = -1; //For getting display details like the number of pixels Display display; Point size; int screenWidth; int screenHeight; //Game objects int racketWidth; int racketHeight; Point racketPosition; Point ballPosition; int ballWidth; //for ball movement boolean ballIsMovingLeft; boolean ballIsMovingRight; boolean ballIsMovingUp; boolean ballIsMovingDown; //for racket movement boolean racketIsMovingLeft; boolean racketIsMovingRight; //stats long lastFrameTime; int fps; int score; int lives; -
接下来,我们将完整地进入
onCreate方法。我们将初始化我们在上一步中声明的许多成员变量,以及从我们的SquashCourtView类创建一个对象,我们将在下一阶段开始实现这个类。在这个代码块中,最引人注目的行可能是对setContentView的调用略有不同。看看setContentView的参数。我们将在本阶段结束时了解更多关于这个参数的信息。这一阶段还设置了SoundPool并加载了声音样本。输入onCreate代码的第一部分:protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); squashCourtView = new SquashCourtView(this); setContentView(squashCourtView); //Sound code soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC, 0); try { //Create objects of the 2 required classes AssetManager assetManager = getAssets(); AssetFileDescriptor descriptor; //create our three fx in memory ready for use descriptor = assetManager.openFd("sample1.ogg"); sample1 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample2.ogg"); sample2 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample3.ogg"); sample3 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample4.ogg"); sample4 = soundPool.load(descriptor, 0); } catch (IOException e) { //catch exceptions here } -
现在我们初始化我们之前创建的变量。请注意,有一些很好的潜在候选者可以进行一些封装。然而,为了保持代码的可读性,我们将在这一阶段不这样做。输入以下代码:
//Could this be an object with getters and setters //Don't want just anyone changing screen size. //Get the screen size in pixels display = getWindowManager().getDefaultDisplay(); size = new Point(); display.getSize(size); screenWidth = size.x; screenHeight = size.y; //The game objects racketPosition = new Point(); racketPosition.x = screenWidth / 2; racketPosition.y = screenHeight - 20; racketWidth = screenWidth / 8; racketHeight = 10; ballWidth = screenWidth / 35; ballPosition = new Point(); ballPosition.x = screenWidth / 2; ballPosition.y = 1 + ballWidth; lives = 3; }
第一阶段代码解释
让我们看看我们做了什么。从步骤 1 到 4,我们只是创建了一个项目和一些声音文件。然后,我们像在其他项目中做的那样,将这些声音文件添加到assets文件夹中。在步骤 5 中,我们添加了我们将要使用的所有类的必要导入。
在步骤 6 中,我们创建了一大堆成员变量。让我们更仔细地看看它们。我们声明了一个名为canvas的Canvas类型的对象。我们将使用此对象来设置我们的绘图系统。我们还声明了一个名为squashCourtView的SquashCourtView实例。这将因为尚未实现该类而显示为错误。
在这里,我们声明并初始化了变量,以便它们成为我们的声音文件的引用,就像我们在其他项目中做的那样。之后,我们做了一些新的事情:
//For getting display details like the number of pixels
Display display;
Point size;
int screenWidth;
int screenHeight;
我们声明了一个Display对象和一个Point对象。我们将在下一分钟内的onCreate方法中看到它们的作用,同时还有两个int变量,screenWidth和screenHeight。我们使用它们来获取屏幕的像素大小,以便我们的游戏可以在任何分辨率的屏幕上运行。
在这里,我们声明了一些变量,它们的目的从它们的名称中就可以看出。它们的实际使用在我们第 8 步初始化它们并在我们的SquashCourtView类中使用它们时变得更加清晰:
//Game objects
int racketWidth;
int racketHeight;
Point racketPosition;
Point ballPosition;
int ballWidth;
在这里,我们有一系列布尔变量来控制球拍和球的运动逻辑。请注意,对于球拍和球的可能方向,都有一个变量。请注意,球拍可以朝两个方向移动——左和右——而球可以朝四个方向移动。当然,球可以同时朝两个方向移动。所有这些都会在第二阶段的updateCourt方法编写时变得清晰。这是那段代码的再次展示:
//for ball movement
boolean ballIsMovingLeft;
boolean ballIsMovingRight;
boolean ballIsMovingUp;
boolean ballIsMovingDown;
//for racket movement
boolean racketIsMovingLeft;
boolean racketIsMovingRight;
在第 6 步的最后部分,我们声明了两个相当明显的变量,lives和score。那么lastFrameTime和fps呢?这些将在第 3 阶段我们将编写的controlFPS方法中使用。它们将和某些局部变量一起用来测量游戏循环的运行速度。然后我们可以将其锁定在一致的速率下运行,这样不同 CPU 速度的设备上的玩家都能获得相似的游戏体验。
在第 7 步中,我们进入了onCreate方法,但这次情况有所不同。我们将squashCourtView初始化为一个新的SquashCourtView对象。到目前为止一切正常,但随后我们似乎在告诉setContentView将其设置为玩家将看到的整个视图,而不是我们习惯在 Android Studio 设计器中创建的常规视图。在这个游戏中,我们不使用任何 Android UI 组件,所以视觉设计器和所有生成的 XML 对我们来说都没有用。正如你将在第 2 阶段开始时看到的那样,我们的SquashCourtView类扩展(继承自)SurfaceView。
我们创建了一个具有所有SurfaceView功能的对象。我们只需自定义它来播放我们的壁球游戏。真不错!因此,将我们的squashCourtView对象设置为玩家将看到的整个视图是完全可接受且逻辑的:
squashCourtView = new SquashCourtView(this);
setContentView(squashCourtView);
我们随后设置了我们的音效,就像之前做的那样。
在第 8 步中,我们初始化了在第 6 步中声明的许多变量。让我们看看它们的值和初始化的顺序。你可能已经注意到,我们在这里并没有初始化每个变量;一些将在稍后初始化。记住,我们不需要初始化成员变量,它们也有默认值。
在下面的代码中,我们获取设备的像素数(宽和高)。display对象在执行第一行后持有显示的详细信息。然后我们创建了一个名为size的新对象,它是Point类型。我们将size作为参数传递给display.getSize方法。Point类型有一个x和y成员变量,size对象也是如此,现在它持有显示的宽度和高度(以像素为单位)。然后这些值分别赋给screenWidth和screenHeight。我们将在SquashCourtView类中广泛使用screenWidth和screenHeight:
display = getWindowManager().getDefaultDisplay();
size = new Point();
display.getSize(size);
screenWidth = size.x;
screenHeight = size.y;
接下来,我们初始化确定球拍和球的大小和位置的变量。在这里,我们初始化我们的racketPosition对象,它是Point类型。记住,它有一个x和y成员变量:
racketPosition = new Point();
我们将racketPosition.x初始化为当前屏幕宽度(以像素为单位),但除以二,这样球拍将从一个水平和中央位置开始,无论屏幕的分辨率如何:
racketPosition.x = screenWidth / 2;
在下一行代码中,racketPosition.y被放置在屏幕底部,留有 20 像素的小间隙:
racketPosition.y = screenHeight - 20;
我们将球拍的宽度设置为屏幕宽度的八分之一。当我们运行游戏时,我们会看到这是一个相当有效的尺寸,但我们可以通过除以更小的数字来使其更大,或者通过除以更大的数字来使其更小。关键是,无论设备的分辨率如何,它都将与 screenWidth 保持相同的比例:
racketWidth = screenWidth / 8;
在下一行代码中,我们为球拍选择一个任意的高度:
racketHeight = 10;
然后我们将球的大小设置为屏幕的 1/35。同样,我们可以将其做得更大或更小:
ballWidth = screenWidth / 35;
在下一行代码中,我们将创建一个新的点对象来保存球的位置:
ballPosition = new Point();
正如我们对待球拍一样,我们从屏幕中心开始球,如下所示:
ballPosition.x = screenWidth / 2;
然而,我们将其设置为从屏幕顶部开始,足够远以至于可以看到球的顶部:
ballPosition.y = 1 + ballWidth;
玩家开始游戏时有三个生命:
lives = 3;
呼呼!这部分内容相当多。如果您喜欢,可以休息一下,然后我们将继续进行第 2 阶段。
第 2 阶段 – SquashCourtView 第一部分
最后,我们来到了我们游戏的秘密武器——SquashCourtView 类。这里展示了前三个方法,一旦我们实现了它们,将会有更详细的解释:
-
这里是一个扩展
SurfaceView的类声明,使我们的类拥有SurfaceView的所有方法和属性。它还实现了Runnable接口,这使得它可以在一个单独的线程中运行。正如您将看到的,我们将大部分功能放在run方法中。在声明之后,我们有一个构造函数。请记住,构造函数是一个与类名相同的函数,在我们初始化其类型的新对象时被调用。构造函数中的代码初始化了一些对象,然后使球以随机方向飞出。在实现这个阶段之后,我们将详细查看这部分。在MainActivity类的闭合花括号之前输入以下代码:class SquashCourtView extends SurfaceView implements Runnable { Thread ourThread = null; SurfaceHolder ourHolder; volatile boolean playingSquash; Paint paint; public SquashCourtView(Context context) { super(context); ourHolder = getHolder(); paint = new Paint(); ballIsMovingDown = true; //Send the ball in random direction Random randomNumber = new Random(); int ballDirection = randomNumber.nextInt(3); switch (ballDirection) { case 0: ballIsMovingLeft = true; ballIsMovingRight = false; break; case 1: ballIsMovingRight = true; ballIsMovingLeft = false; break; case 2: ballIsMovingLeft = false; ballIsMovingRight = false; break; } } -
现在我们有了对
run方法的简短而清晰的覆盖。请记住,run方法包含线程的功能。在这种情况下,它有三个调用,分别对应于updateCourt、drawCourt和controlFPS,这是我们类的三个关键方法。输入以下代码:@Override public void run() { while (playingSquash) { updateCourt(); drawCourt(); controlFPS(); } } -
在这个阶段,我们将实现一个方法(
updateCourt),但它相当长。在输入代码之前,我们将将其分成几个部分,并简要说明每个部分正在发生的事情。在阶段实现后,我们将更详细地检查其工作原理。在接下来的代码块中,我们将处理球拍的左右移动,以及检测和反应球击中屏幕的左侧或右侧。在上一步骤的代码之后输入以下代码:public void updateCourt() { if (racketIsMovingRight) { racketPosition.x = racketPosition.x + 10; } if (racketIsMovingLeft) { racketPosition.x = racketPosition.x - 10; } //detect collisions //hit right of screen if (ballPosition.x + ballWidth > screenWidth) { ballIsMovingLeft = true; ballIsMovingRight = false; soundPool.play(sample1, 1, 1, 0, 0, 1); } //hit left of screen if (ballPosition.x < 0) { ballIsMovingLeft = false; ballIsMovingRight = true; soundPool.play(sample1, 1, 1, 0, 0, 1); } -
在接下来的代码块中,我们检查球是否击中了屏幕底部,即玩家未能将球返回。在上一步骤的代码之后直接输入以下代码:
//Edge of ball has hit bottom of screen if (ballPosition.y > screenHeight - ballWidth) { lives = lives - 1; if (lives == 0) { lives = 3; score = 0; soundPool.play(sample4, 1, 1, 0, 0, 1); } ballPosition.y = 1 + ballWidth;//back to top of screen //what horizontal direction should we use //for the next falling ball Random randomNumber = new Random(); int startX = randomNumber.nextInt(screenWidth - ballWidth) + 1; ballPosition.x = startX + ballWidth; int ballDirection = randomNumber.nextInt(3); switch (ballDirection) { case 0: ballIsMovingLeft = true; ballIsMovingRight = false; break; case 1: ballIsMovingRight = true; ballIsMovingLeft = false; break; case 2: ballIsMovingLeft = false; ballIsMovingRight = false; break; } } -
在这段代码中,我们处理球是否击中屏幕顶部的情况。我们还计算了这一帧球的所有可能移动。现在输入以下代码:
//we hit the top of the screen if (ballPosition.y <= 0) { ballIsMovingDown = true; ballIsMovingUp = false; ballPosition.y = 1; soundPool.play(sample2, 1, 1, 0, 0, 1); } //depending upon the two directions we should //be moving in adjust our x any positions if (ballIsMovingDown) { ballPosition.y += 6; } if (ballIsMovingUp) { ballPosition.y -= 10; } if (ballIsMovingLeft) { ballPosition.x -= 12; } if (ballIsMovingRight) { ballPosition.x += 12; } -
最后,我们处理碰撞检测以及球拍和球的反应。我们还关闭了
updateCourt方法,这是本阶段最后一段代码。在上一阶段的代码之后,输入以下内容://Has ball hit racket if (ballPosition.y + ballWidth >= (racketPosition.y - racketHeight / 2)) { int halfRacket = racketWidth / 2; if (ballPosition.x + ballWidth > (racketPosition.x - halfRacket) && ballPosition.x - ballWidth < (racketPosition.x + halfRacket)) { //rebound the ball vertically and play a sound soundPool.play(sample3, 1, 1, 0, 0, 1); score++; ballIsMovingUp = true; ballIsMovingDown = false; //now decide how to rebound the ball horizontally if (ballPosition.x > racketPosition.x) { ballIsMovingRight = true; ballIsMovingLeft = false; } else { ballIsMovingRight = false; ballIsMovingLeft = true; } } } } }
第二阶段代码解释
这一阶段的代码很长,但当我们分解它时,并没有什么太具挑战性的。可能唯一的挑战在于解开一些嵌套的if语句。我们现在将这样做。
在第一步中,我们声明了我们的SquashCourView类。这实现了Runnable接口。你可能还记得从第五章,游戏和 Java 基础,Runnable为我们提供了一个线程。我们只需要重写run方法,其中的任何内容都会在新线程中运行。
然后我们创建了一个名为ourThread的新Thread对象,以及一个SurfaceHolder对象来持有我们的表面并允许我们在线程内部控制或锁定我们的表面。接下来,我们有playingSquash,它是布尔类型的。这封装了重写的run方法内部,以控制游戏何时运行。看起来奇怪的volatile修饰符意味着我们可以从线程外部和内部改变它的值。
最后,对于当前讨论的代码块,我们声明了一个名为paint的Paint类型对象,用于我们的绘图:
class SquashCourtView extends SurfaceView implements Runnable {
Thread ourThread = null;
SurfaceHolder ourHolder;
volatile boolean playingSquash;
Paint paint;
接下来,我们实现了我们类的构造函数,这样当我们回到onCreate中初始化一个新的SquashCourtView对象时,就会运行以下代码。首先,我们看到我们运行了超类的构造函数。然后,我们使用getHolder方法初始化ourHolder。接下来,我们初始化我们的paint对象:
public SquashCourtView(Context context) {
super(context);
ourHolder = getHolder();
paint = new Paint();
现在,仍然在构造函数中,我们让事物开始移动。我们将ballIsMovingDown变量设置为true。在每场游戏的开始时,我们总是希望球向下移动。我们将很快看到updateCourt方法将执行球的移动。接下来,我们将球向一个随机的水平方向发送。这是通过获取 0 到 2 之间的随机数来实现的。然后,我们为每个可能的案例进行切换:0、1 或 2。在每个 case 语句中,我们设置不同的布尔变量来控制水平移动。在case 0中,球向左移动,而在case 1和case 3中,球将分别向右和垂直向下移动。然后我们关闭我们的构造函数:
ballIsMovingDown = true;
//Send the ball in random direction
Random randomNumber = new Random();
int ballDirection = randomNumber.nextInt(3);
switch (ballDirection) {
case 0:
ballIsMovingLeft = true;
ballIsMovingRight = false;
break;
case 1:
ballIsMovingRight = true;
ballIsMovingLeft = false;
break;
case 2:
ballIsMovingLeft = false;
ballIsMovingRight = false;
break;
}
}
在第 2 步,我们有一些非常简单的代码,但这却是运行其他所有代码的代码。被重写的run方法是ourThread在定义的间隔内调用的。如您所见,代码被包裹在一个由我们的boolean类型的playingSquash变量控制的while块中。然后代码简单地调用updateCourt,它控制移动和碰撞检测;drawCourt,它将绘制一切;以及controlFPS,它将游戏锁定在一致的帧率。这就是run的全部内容:
@Override
public void run() {
while (playingSquash) {
updateCourt();
drawCourt();
controlFPS();
}
}
然后在第 3 步,我们开始updateCourt方法。它相当长,所以我们将其分解成几个可管理的部分。前两个if块检查racketIsMovingRight或racketIsMovingLeft布尔变量是否为真。如果其中一个为真,则这些块将10加到或从racketPosition.x中减去。玩家将在drawCourt方法中绘制球拍时看到这种效果。在onTouchEvent方法中如何操作布尔变量将在稍后讨论:
public void updateCourt() {
if (racketIsMovingRight) {
racketPosition.x = racketPosition.x + 10;
}
if (racketIsMovingLeft) {
racketPosition.x = racketPosition.x - 10;
}
现在,仍然在updateCourt方法中,我们检测和处理与屏幕左右两侧的碰撞。检查ballPosition.x是否大于screenWidth就足以看到球是否会反弹回来。然而,通过更加精确地测试ballPosition.x + ballWidth > screenWidth,我们实际上是在测试球的右边缘是否击中了屏幕的右侧。这创造了一个更加令人愉悦的效果,因为它看起来更加真实。当球与右侧发生碰撞时,我们只需反转球的方向并播放声音。左侧检测的if代码之所以更简单,是因为我们使用drawRect绘制了球,所以ballPosition.x是球的精确左侧。当球与左侧碰撞时,我们只需反转其方向并播放蜂鸣声:
//detect collisions
//hit right of screen
if (ballPosition.x + ballWidth > screenWidth) {
ballIsMovingLeft = true;
ballIsMovingRight = false;
soundPool.play(sample1, 1, 1, 0, 0, 1);
}
//hit left of screen
if (ballPosition.x < 0) {
ballIsMovingLeft = false;
ballIsMovingRight = true;
soundPool.play(sample1, 1, 1, 0, 0, 1);
}
在第 4 步,我们实现了球击中屏幕底部时发生的情况。这发生在玩家未能将球击回时,因此这里需要发生很多事情。然而,这一部分并没有什么过于复杂的内容。首先进行碰撞测试。我们检查球的底部是否击中了屏幕底部:
//Edge of ball has hit bottom of screen
if (ballPosition.y > screenHeight - ballWidth) {
如果球击中,我们将扣除一个生命值。然后我们检查玩家是否已经失去了所有生命值:
lives = lives - 1;
if (lives == 0) {
如果所有生命值都已丢失,我们将通过将生命值重置为 3 和得分设置为 0 来重新开始游戏。我们还会播放一个低沉的蜂鸣声:
lives = 3;
score = 0;
soundPool.play(sample4, 1, 1, 0, 0, 1);
}
目前为止,我们仍然处于if块中,因为球击中了屏幕底部,但对于那个生命值为零的玩家来说,则不在if块内。无论玩家是否有零生命值还是还有剩余生命值,我们都需要将球放回屏幕顶部,并使其沿向下轨迹和随机的水平方向移动。此代码与我们在构造函数中看到的开始游戏时使球移动的代码相似,但并不相同:
ballPosition.y = 1 + ballWidth;//back to top of screen
//what horizontal direction should we use
//for the next falling ball
Random randomNumber = new Random();
int startX = randomNumber.nextInt(screenWidth - ballWidth) + 1;
ballPosition.x = startX + ballWidth;
int ballDirection = randomNumber.nextInt(3);
switch (ballDirection) {
case 0:
ballIsMovingLeft = true;
ballIsMovingRight = false;
break;
case 1:
ballIsMovingRight = true;
ballIsMovingLeft = false;
break;
case 2:
ballIsMovingLeft = false;
ballIsMovingRight = false;
break;
}
}
在第 5 步中,我们处理球击中屏幕顶部的事件。通过反转ballIsMovingDown和ballIsMovingUp的值来反转球的方向。使用ballPosition.y = 1调整球的位置。这阻止了球卡住并播放了一个悦耳的哔哔声:
//we hit the top of the screen
if (ballPosition.y <= 0) {
ballIsMovingDown = true;
ballIsMovingUp = false;
ballPosition.y = 1;
soundPool.play(sample2, 1, 1, 0, 0, 1);
}
现在,在所有这些碰撞检测和布尔变量的切换之后,我们实际上移动了球。对于每个为真的方向,我们相应地添加到或从ballPosition.x和ballPosition.y中减去。注意,球向上移动的速度比向下移动快。这是为了缩短玩家等待重新进入比赛的时间,并且也粗略地模拟了球被球拍击中后的加速动作:
//depending upon the two directions we should be
//moving in adjust our x any positions
if (ballIsMovingDown) {
ballPosition.y += 6;
}
if (ballIsMovingUp) {
ballPosition.y -= 10;
}
if (ballIsMovingLeft) {
ballPosition.x -= 12;
}
if (ballIsMovingRight) {
ballPosition.x += 12;
}
小贴士
你可能已经注意到,通过硬编码球移动的像素数,我们在高分辨率和低分辨率屏幕之间为球创建了一个不一致的速度。查看本章末尾的自我测试问题,看看我们如何解决这个问题。
我们还需要进行最后一点碰撞检测。球是否击中了球拍?这个检测分为几个阶段。首先,我们检查球的底部是否到达或超过了球拍的顶部:
if (ballPosition.y + ballWidth >= (racketPosition.y - racketHeight / 2)) {
如果这个条件为真,我们执行一些额外的测试。首先,我们声明并初始化一个名为halfRacket的int变量,用于存储球拍宽度的一半。我们将在接下来的测试中使用它:
int halfRacket = racketWidth / 2;
下一个if块检查球的右侧是否大于球拍的远左角,并且是否接触它。使用 AND 运算符(&&),该块验证球的左侧边缘是否没有超过球拍的远右端。如果这个条件为真,我们肯定发生了碰撞,并可以思考如何处理反弹:
if (ballPosition.x + ballWidth > (racketPosition.x - halfRacket)
&& ballPosition.x - ballWidth < (racketPosition.x + halfRacket)) {
if块中的第一段代码很简单,它确定了一个明确的碰撞。播放声音,增加分数,并将球设置为向上轨迹,如下所示:
//rebound the ball vertically and play a sound
soundPool.play(sample3, 1, 1, 0, 0, 1);
score++;
ballIsMovingUp = true;
ballIsMovingDown = false;
现在我们有一个if-else条件,它简单地检查球的左侧边缘是否超过了球拍的中心。如果是,我们将球发送到右侧。否则,我们将球发送到左侧:
//now decide how to rebound the ball horizontally
if (ballPosition.x > racketPosition.x) {
ballIsMovingRight = true;
ballIsMovingLeft = false;
} else {
ballIsMovingRight = false;
ballIsMovingLeft = true;
}
}
}
}
第三阶段 – SquashCourtView 第二部分
在这个阶段,我们将完成SquashCourtView类。还有两个方法剩余,它们从run方法中调用,即drawCourt和controlFPS。然后还有一些简短的方法来与 Android 生命周期方法交互,这些方法将在第四和最终阶段实现:
-
下面是按照以下顺序绘制屏幕顶部文本、球和球拍的代码。所有这些都在
drawCourt方法中完成,该方法在调用updateCourt之后从run方法中调用。以下是drawCourt的代码。在SquashCourtView类的闭合花括号之前输入以下代码:public void drawCourt() { if (ourHolder.getSurface().isValid()) { canvas = ourHolder.lockCanvas(); //Paint paint = new Paint(); canvas.drawColor(Color.BLACK);//the background paint.setColor(Color.argb(255, 255, 255, 255)); paint.setTextSize(45); canvas.drawText("Score:" + score + " Lives:" + lives + " fps:" + fps, 20, 40, paint); //Draw the squash racket canvas.drawRect(racketPosition.x - (racketWidth / 2), racketPosition.y - (racketHeight / 2), racketPosition.x + (racketWidth / 2), racketPosition.y + racketHeight, paint); //Draw the ball canvas.drawRect(ballPosition.x, ballPosition.y, ballPosition.x + ballWidth, ballPosition.y + ballWidth, paint); ourHolder.unlockCanvasAndPost(canvas); } } -
现在的
controlFPS方法将我们的帧率锁定为平滑且一致的状态。我们很快就会详细了解其工作原理。在上一步骤的代码之后输入以下代码:public void controlFPS() { long timeThisFrame = (System.currentTimeMillis() - lastFrameTime); long timeToSleep = 15 - timeThisFrame; if (timeThisFrame > 0) { fps = (int) (1000 / timeThisFrame); } if (timeToSleep > 0) { try { ourThread.sleep(timeToSleep); } catch (InterruptedException e) { } } lastFrameTime = System.currentTimeMillis(); } -
接下来,我们编写
pause和resume的代码。这些是通过它们相关的 Android 生命周期方法(onPause和onResume)调用的。我们确保当玩家完成或恢复我们的游戏时,我们的线程能够安全地结束或启动。现在在上一步骤的代码之后输入以下代码:public void pause() { playingSquash = false; try { ourThread.join(); } catch (InterruptedException e) { } } public void resume() { playingSquash = true; ourThread = new Thread(this); ourThread.start(); } -
最后,我们有控制当玩家触摸我们的自定义
SurfaceView时发生什么的方法。记住,当我们讨论游戏设计时,我们说在屏幕左侧的任何地方按下都会将球拍向左移动,而在屏幕右侧的任何地方按下都会将球拍向右移动。在上一步骤的代码之后输入以下代码:@Override public boolean onTouchEvent(MotionEvent motionEvent) { switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: if (motionEvent.getX() >= screenWidth / 2) { racketIsMovingRight = true; racketIsMovingLeft = false; } else { racketIsMovingLeft = true; racketIsMovingRight = false; } break; case MotionEvent.ACTION_UP: racketIsMovingRight = false; racketIsMovingLeft = false; break; } return true; } }
第三阶段代码解释
在第一步中,我们完成所有的绘制。我们已经看到了Canvas类的所有不同绘制方法能做什么,并且它们的名称也是不言自明的。然而,我们到达坐标的方式需要一些解释。首先,在drawCourt内部,我们使用ourHolder来获取绘图表面,并检查其有效性(可用性)。然后我们初始化我们的canvas和paint对象:
public void drawCourt() {
if (ourHolder.getSurface().isValid()) {
canvas = ourHolder.lockCanvas();
//Paint paint = new Paint();
接下来,我们清除上一帧绘制的屏幕:
canvas.drawColor(Color.BLACK);//the background
现在我们将画笔颜色设置为白色:
paint.setColor(Color.argb(255, 255, 255, 255));
这项新功能虽然简单,但需要解释——我们为我们的文本设置一个大小:
paint.setTextSize(45);
现在我们可以绘制屏幕顶部的文本行。它显示了score和lives变量。我们已经看到了如何控制它们的值。它还显示了fps变量的值。当我们查看下一个方法controlFPS时,我们将看到如何为它分配一个值:
canvas.drawText("Score:" + score + " Lives:" + lives + " fps:" +fps, 20, 40, paint);
然后,我们绘制球拍。注意,我们通过从racketPosition.x中减去球拍宽度的一半来计算x起始位置,并通过将宽度加到x上来计算x结束位置。这使得我们的碰撞检测代码变得简单,因为racketPosition.x指的是球拍的中心:
//Draw the squash racket
canvas.drawRect(racketPosition.x - (racketWidth / 2),
racketPosition.y - (racketHeight / 2),
racketPosition.x + (racketWidth / 2),
racketPosition.y + racketHeight, paint);
接下来,我们绘制球。注意,起始的x和y坐标与ballPosition.x和ballPosition.y中持有的值相同。因此,这些坐标对应于球的左上角。这正是我们简单碰撞检测代码所需要的:
//Draw the ball
canvas.drawRect(ballPosition.x, ballPosition.y,
ballPosition.x + ballWidth, ballPosition.y + ballWidth, paint);
这最后一行将我们刚刚完成的内容绘制到屏幕上:
ourHolder.unlockCanvasAndPost(canvas);
}
}
在第二步中,我们本质上暂停了游戏。我们想要决定我们重新计算对象位置并重新绘制它们的次数。以下是它是如何工作的。
首先,当它从run方法中被调用时,我们进入controlFPS方法。我们声明并初始化一个带有毫秒时间的long变量,然后减去上一帧所花费的毫秒时间。时间是在上一次通过此方法运行时计算的,我们将在下面看到:
public void controlFPS() {
long timeThisFrame = (System.currentTimeMillis() - lastFrameTime);
然后,我们计算我们希望在帧之间暂停多长时间,并将该值初始化为timeToSleep,一个新的长整型变量。这里的计算方法是:15 毫秒的暂停可以给我们大约 60 帧每秒,这对我们的游戏来说效果很好,并且提供了非常平滑的动画。因此,15 - timeThisFrame等于我们应该暂停多少毫秒以使帧持续 15 毫秒:
long timeToSleep = 15 - timeThisFrame;
当然,一些设备可能无法处理这种速度。我们既不希望暂停一个负数,也不希望在timeThisFrame等于零时计算每秒帧数。接下来,我们将每秒帧数的计算包裹在一个if语句中,以防止我们除以零或负数:
if (timeThisFrame > 0) {
fps = (int) (1000 / timeThisFrame);
}
同样,我们将暂停线程的指令包裹在一个类似的谨慎的if语句中:
if (timeToSleep > 0) {
try {
ourThread.sleep(timeToSleep);
} catch (InterruptedException e) {
}
}
最后,我们看到我们是如何初始化lastFrameTime,为下一次调用controlFPS做准备:
lastFrameTime = System.currentTimeMillis();
}
在第 3 步中,我们快速实现了两个方法。它们是pause和resume。这些方法不应该与 Android Activity 生命周期中的onPause和onResume方法混淆。然而,pause和resume方法是从它们的近义词那里调用的。它们分别处理停止和启动ourThread。我们应该始终清理我们的线程。否则,它们可以在活动完成后继续运行:
public void pause() {
playingSquash = false;
try {
ourThread.join();
} catch (InterruptedException e) {
}
}
public void resume() {
playingSquash = true;
ourThread = new Thread(this);
ourThread.start();
}
在第 4 步中,我们处理屏幕上的触摸事件。这就是我们初始化racketIsMovingLeft和racketIsMovingRight布尔变量的方式,这些变量被updateCourt方法用来决定是否滑动玩家的球拍向左或向右,或者保持静止。我们之前已经讨论过onTouchEvent方法,但现在让我们看看我们是如何设置这些变量的值。
首先,我们重写该方法并切换以获取事件类型和事件的x、y坐标:
@Override
public boolean onTouchEvent(MotionEvent motionEvent) {
switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
如果事件类型是ACTION_DOWN,即屏幕已被触摸,我们进入这个情况:
case MotionEvent.ACTION_DOWN:
然后我们处理坐标。如果玩家触摸屏幕上的一个 x 坐标大于screenWidth / 2的位置,那么这意味着他们触摸了屏幕的右侧,因此我们将isMovingRight设置为true,将isMovingLeft设置为false。updateCourt方法将处理必要坐标的变化,而drawCourt方法将在适当的位置绘制球拍:
if (motionEvent.getX() >= screenWidth / 2) {
racketIsMovingRight = true;
racketIsMovingLeft = false;
else语句以相反的方式设置我们的两个布尔变量,因为触摸必须发生在屏幕的左侧:
} else {
racketIsMovingLeft = true;
racketIsMovingRight = false;
}
break;
现在我们处理 ACTION_UP 事件的情况。但我们为什么要关心两个事件呢?对于按钮,我们只关心点击事件,就这么多,但通过处理 ACTION_UP 事件,我们可以启用允许我们的播放器通过左右滑动屏幕的功能,正如我们在本章的 游戏设计 部分所讨论的那样。因此,ACTION_DOWN 事件设置球拍向一个方向或另一个方向移动,而 ACTION_UP 事件则简单地完全停止滑动:
case MotionEvent.ACTION_UP:
racketIsMovingRight = false;
racketIsMovingLeft = false;
break;
}
return true;
}
}
注意,我们并不关心 y 坐标。无论我们在左边还是右边移动,都会向左或向右移动。
注意
注意,无论设备是竖屏还是横屏,所有的代码都会工作,并且无论设备的分辨率如何,功能都会相同。然而(这是一个相当重要的“然而”),在低分辨率屏幕上游戏会稍微难一些。解决这个问题相当复杂,并且将在最后一章讨论,但它可能有助于我们做出关于未来学习 Android、游戏和 Java 的路径的决定。
第四阶段 – 剩余的生命周期方法
我们几乎完成了;只需再走几步,我们就会有一个可以工作的复古壁球游戏。我几乎能闻到怀旧的味道!由于这些剩余的方法相当直接,我们将随着编写过程进行解释:
-
如我们之前所学,当应用被停止时,Android 系统会调用
onStop方法。这个方法已经为我们实现了。我们在这里重写它的唯一原因是为了确保我们的线程被停止。我们通过高亮的那一行代码来实现。在MainActivity类的闭合花括号之前输入以下代码:@Override protected void onStop() { super.onStop(); while (true) { squashCourtView.pause(); break; } finish(); } -
当应用被暂停时,Android 系统会调用
onPause方法。这也已经为我们实现了,我们在这里重写它的唯一原因是为了确保我们的线程被停止。我们通过高亮的那一行代码来实现。在前面代码之后输入以下代码:@Override protected void onPause() { super.onPause(); squashCourtView.pause(); } -
当应用被恢复时,Android 系统会调用
onResume方法。同样,这个方法也已经为我们实现了。我们在这里重写它的唯一原因是为了确保我们的线程被恢复,我们通过高亮的那一行代码来实现。在上一步骤的代码之后输入以下代码:@Override protected void onResume() { super.onResume(); squashCourtView.resume(); } -
最后,我们做一些完全新的事情。我们处理玩家在设备上按下返回按钮时会发生的情况。正如你可能猜到的,有一个我们可以重写的方法来实现这一点——
onKeyDown。我们暂停我们的线程,就像我们在重写的生命周期方法中所做的那样,然后调用finish(),这会结束活动以及我们的应用。在前面代码之后输入以下代码:public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { squashCourtView.pause(); finish(); return true; } return false; }
我们在通过它时已经覆盖了这一阶段的代码,这是迄今为止最短的阶段。那么为什么我们没有封装一切呢?
良好的面向对象设计
也许简单的游戏并不是展示良好面向对象设计行动的最佳方式,但具有较少私有变量的简单代码设计实际上增强了项目。这确实使编码游戏的教授方面更容易解释。
然而,当游戏变得更加复杂,并且有更多的人在编写代码时,面向对象编程的原则变得更加必要。
自我测试问题
Q1) 你能解释如何使球的速度在不同屏幕分辨率之间相对吗?
摘要
我希望您喜欢制作您的第一个游戏。您达到了这个阶段,学到了很多。您不仅学习了所有的 Java 主题,还学会了如何使用 Android 的不同类来制作相对简单的游戏。
在下一章中,我们将继续到一个新的、更复杂的游戏。我希望你已经准备好了。
第八章. 蛇游戏
在本章中,我们将直接开始设计和实现一个极具吸引力的 Snake 游戏的克隆版本。我们将研究游戏的设计,并学习如何动画一些位图。然后,我们将查看代码的一些新方面,例如我们的坐标系。之后,我们将快速浏览游戏实现的步骤。最后,我们将探讨如何增强我们的游戏。
在本章中,我们将涵盖以下主题:
-
检查我们游戏的设计
-
查看我们 Snake 游戏的坐标系
-
检查代码结构,以便当我们开始实现游戏时,它会更加直接
-
在实现游戏的主屏幕的同时学习使用精灵表进行动画
-
将 Snake 游戏的代码分解成可管理的块,并运行其完整实现
-
稍微增强游戏
游戏设计
如果您之前没有玩过优秀的 Snake 游戏,这里有一个关于它是如何工作的解释。您控制一条非常小的蛇。在我们的版本中,只有头部、一个身体节段和尾部。以下是我们的蛇的截图,由三个节段组成:

以下截图显示了三个节段单独的样子:

现在,这里是事情的关键;我们的蛇非常饿,而且生长得非常快。每次它吃一个苹果,它就会长出一个身体节段。以下是苹果的截图:

生活很美好!我们的蛇只是吃和长!我们游戏玩家需要解决的问题是这个蛇有点过于活跃。它从不停止移动!加剧这个问题的是,如果蛇碰到屏幕的边缘,它就会死亡。
起初,这似乎不是一个太大的问题,但随着蛇变得越来越长,它不能只是不断地在圈子里转,因为它不可避免地会撞到自己。这又会导致它的死亡:

对于每个吃到的苹果,我们将分数增加一个越来越大的数值。以下是基本实现和增强之前游戏的外观预览:

玩家通过点击屏幕的左侧或右侧来控制蛇。蛇会相应地左转或右转。转向方向相对于蛇的移动方向,这增加了挑战性,因为玩家需要像蛇一样思考——有点像!
在本章结束时,我们还将简要地看看如何增强游戏,使用这个增强版本在下一章中发布到 Google Play 商店,并添加排行榜和成就。
坐标系统
在上一章中,我们直接将所有游戏对象绘制到屏幕上的点,并使用真实屏幕坐标来检测碰撞、反弹等。这次,我们将稍微有所不同。这既是出于必要性,但正如我们将看到的,碰撞检测和跟踪我们的游戏对象也会变得简单。当我们想到蛇可能由许多块组成时,这可能会让人感到惊讶。
跟踪蛇段
为了跟踪所有蛇段,我们首先定义一个块大小来定义整个游戏区域的网格部分。每个游戏对象都将驻留在(x,y)坐标,不是基于屏幕的像素分辨率,而是在我们的虚拟网格中的位置。在游戏中,我们定义了一个宽度为 40 块的网格,如下所示:
//Determine the size of each block/place on the game board
blockSize = screenWidth/40;
因此我们知道:
numBlocksWide = 40;
游戏屏幕的高度(以块为单位)将简单地通过将屏幕的高度(以像素为单位)除以之前确定的blockSize值,并减去顶部的一点点空间来计算分数:
numBlocksHigh = ((screenHeight - topGap ))/blockSize;
这然后允许我们使用两个数组来跟踪我们的蛇,这两个数组用于x和y坐标,其中元素零是头部,最后一个使用的元素是尾部,有点像这样:
//An array for our snake
snakeX = new int[200];
snakeY = new int[200];
只要我们有一个移动头部的系统,可能类似于弹跳球,但基于我们新的游戏网格,我们就可以做以下事情来使身体跟随头部:
//move the body starting at the back
for(int i = snakeLength; i >0 ; i--){
snakeX[i] = snakeX[i-1];
snakeY[i] = snakeY[i-1];
}
之前的代码只是从蛇的尾部开始,并在网格中创建其位置,而不考虑其前面的部分。它沿着身体向上移动,直到所有东西都移动到原来在它前面的部分的位置。
这也使得碰撞检测(即使是对于非常长的蛇)变得非常简单。
检测碰撞
使用基于blockSize的网格,我们可以检测到屏幕右侧的碰撞,例如:
if(snakeX[0] >= numBlocksWide)dead=true;
之前的代码只是简单地检查我们数组中的第一个元素,即蛇的x坐标,是否等于或大于游戏网格的宽度。在我们看到实现中的效果之前,试着推导出与左侧、顶部和底部碰撞的代码。
检测蛇撞到自己的事件也很简单。我们只需检查我们数组中的第一个元素(头部)是否与任何其他部分完全相同的位置,如下所示:
//Have we eaten ourselves?
for (int i = snakeLength-1; i > 0; i--) {
if ((i > 4) && (snakeX[0] == snakeX[i]) && (snakeY[0] == snakeY[i])) {
dead = true;
}
}
绘制蛇
我们简单地绘制蛇的每一部分,相对于其网格位置乘以一个方块的大小。blockSize变量处理了整个让游戏在不同屏幕尺寸上运行的任务,如下所示:
//loop through every section of the snake and draw it
//a block at a time.
canvas.drawBitmap(bodyBitmap, snakeX[i]*blockSize, (snakeY[i]*blockSize)+topGap, paint);
虽然关于我们的实现方式可能会有更多问题,但它们可能最好通过实际构建游戏来回答。
因此,我们可以通过编写代码或只是阅读完成的项目来轻松地跟随。让我们看看我们代码的整体结构。
代码结构
我们将有两个活动,一个用于菜单屏幕,一个用于游戏屏幕。菜单屏幕活动将被称为MainActivity,游戏屏幕活动将被称为GameActivity。你可以在下载包中的Chapter8/Snake文件夹中找到所有完成的代码文件以及所有资产,如图片、精灵表和声音文件。
MainActivity
与我们的其他项目相比,菜单屏幕将不会在 Android Studio UI 设计器中设计 UI。它将包括一个动画蛇头、一个标题和最高分。玩家将通过在屏幕上的任何地方点击来进入GameActivity。由于我们需要完成动画和用户交互,主屏幕也将有一个线程、一个视图对象以及通常与我们的游戏屏幕相关的方法,如下所示:
MainActivity.java file
Imports
MainActivity class
Declare some variables and objects
onCreate
SnakeAnimView class
Constructor
Run method
Update method
Draw method
controlFPS method
pause method
resume method
onTouchEvent method
onStop method
onResume method
onPause method
onKeyDown method
我们现在不会深入探讨菜单屏幕,因为在本节结束时,我们将逐行实现它。
GameActivity
游戏屏幕的结构与我们的沙包游戏以及菜单屏幕的结构有很多相似之处,尽管这个结构的内部差异很大(正如我们讨论的,以及我们将看到的)。在结构的末尾有一些差异,最值得注意的是loadSound方法和configureDisplay方法。以下是结构(我们将在之后看到为什么有两个额外的方法):
MainActivity.java file
Imports
GameActivity class
Declare some variables and objects
onCreate
SnakeView class
Constructor
getSnake method
getApple method
Run method
updateGame method
drawGame method
controlFPS method
pause method
resume method
onTouchEvent method
onStop method
onResume method
onPause method
onKeyDown method
loadSOund method
configureDisplay method
整理 onCreate 方法
当你检查我们即将实现的GameActivity类的代码时,你可能会注意到的第一件事就是onCreate方法有多短:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
loadSound();
configureDisplay();
snakeView = new SnakeView(this);
setContentView(snakeView);
}
我们已经编写了两个方法,loadSound和configureDisplay。它们处理了我们沙包游戏中的大部分初始化和设置。这使得我们的代码更加简洁。onCreate中剩下的只是初始化我们的SnakeView对象和调用setContentView。
当我们实现它们时,我们将详细查看我们的loadSound和configureDisplay方法。
由于我们已经对结构有深入的了解以及有这方面的先前经验,我们将一次性完成我们游戏活动的所有实现。
让我们快速实现菜单屏幕。
动画、精灵表和蛇的主屏幕
在上一章中,我们使用位图在 Java 代码创建的空白位图上绘制文本、圆形、线条和单个像素。然后我们使用Canvas类显示带有所有涂鸦的位图。现在我们将探讨一种绘制二维图像的技术,有时也称为精灵。这些是由预先绘制的图像组成的。这些图像可以是简单的乒乓球,也可以是具有肌肉定义、复杂服装、武器和头发的辉煌二维角色。
到目前为止,我们使用的是不变的物体进行动画,也就是说,我们在屏幕上移动了一个静态不变的图像。在本节中,我们将看到如何不仅将预先绘制的位图图像显示在屏幕上,而且不断改变它以创造即时动画的错觉。
当然,最终的组合将是同时通过改变图像和移动它来动画化位图。当我们查看本章的蛇游戏的增强版本时,我们将简要地看到这一点,但不会分析代码。
要进行这种即时的位图动画,正如你所预期的那样,我们需要一些位图。例如,为了绘制蛇尾巴来回摆动的动画,我们至少需要两个动画帧,展示尾巴在不同位置。在下面的屏幕截图中,花朵的头部朝向左边:

在这个屏幕截图中,花朵已经被翻转:

如果连续显示这两个位图,它们会创造出花朵在风中摇曳的基本效果。当然,两个动画帧不可能赢得任何动画奖项,而且我们很快就会了解到这些图像还存在另一个问题,因此我们应该添加更多的帧,使动画尽可能逼真。
在我们为游戏的主屏幕制作动画蛇头之前,我们还有一件事要讨论。我们如何让 Android 在这些位图之间切换?
使用精灵表进行动画
首先,我们需要以易于在代码中操作的方式呈现帧。这就是精灵表发挥作用的地方。以下图像显示了我们将用于游戏主屏幕的基本蛇头动画的一些帧。这一次,它们以帧条的形式呈现。它们都是同一图像的一部分,有点像电影中的一系列图像。此外,注意在以下图像中,帧相对于彼此居中,并且大小完全相等:

如果我们连续显示之前的花朵图像,它们不仅会摇摆,还会在它们的茎上从一边跳到另一边,这可能不是我们想要的效果。
因此,关于蛇的精灵表,只要我们一帧接一帧地显示,我们就会创建一个基本的动画。
那么,我们如何让我们的代码从一个精灵表的一部分跳到另一部分?每个帧的大小都是一样的,在这个例子中是 64 x 64 像素,所以我们只需要一种方式来显示从 0 到 63,然后 64 到 127,然后 128 到 192,以此类推的像素。由于精灵表图像的每一帧都有细微的差别,这使得我们可以使用一个包含多个帧的单个图像文件来创建我们的动画。幸运的是,我们有一个类来处理这个问题,它并不像特定的精灵表类那样奢华,但几乎一样。
小贴士
关于精灵表类,这样的东西确实存在,尽管它不在常规的 Android 类中。专门为二维游戏设计的 API 通常会包含精灵表类。我们将在下一章中查看这些示例。
Rect类保存一个矩形的坐标。在这里,我们创建一个新的Rect类型对象,并将其初始化为从 0, 0 开始,到 63, 63 结束:
Rect rectToBeDrawn = new Rect(0, 0, 63, 63);
Canvas类实际上可以使用我们的Rect对象来定义一个之前加载的位图的某个部分:
canvas.drawBitmap(headAnimBitmap, rectToBeDrawn, destRect, paint);
上述代码看起来比实际要简单得多。首先,我们看到canvas.drawBitmap。我们正在使用Canvas类的drawBitmap方法,就像我们之前做的那样。然后我们传递headAnimBitmap,这是我们包含所有想要动画化的帧的精灵表,作为参数。rectToBeDrawn代表headAnimationBitmap中当前相关帧的坐标。destRect简单地代表我们想要绘制当前帧的屏幕坐标,当然,paint是我们Paint类的对象。
我们现在要做的就是改变rectToBeDrawn的坐标,并通过一个线程来控制帧率,这样我们就完成了!让我们这样做,并为我们的蛇游戏创建一个动画的主屏幕。
实现蛇的主屏幕
在我们刚刚介绍过的背景信息和我们对即将编写的代码结构的详细分析之后,这段代码中不应该有任何惊喜。我们将把代码分解成块,只是为了确保我们确切地了解正在发生的事情:
-
创建一个 API 级别为 13 的新项目。命名为
Snake。 -
让活动全屏,就像我们之前做的那样,并将你的图形放入
drawable/mdpi文件夹。当然,你可以像往常一样使用我的图形。它们包含在Snake项目的graphics文件夹中的代码下载中。 -
在这里,你可以找到我们的
MainActivity类声明和成员变量。注意我们的Canvas和Bitmap类的变量,我们正在声明变量来保存帧大小(宽度和高度)以及帧数。我们还有一个Rect对象来保存精灵表当前帧的坐标。我们很快就会看到这些变量在行动中的表现。输入以下代码:public class MainActivity extends Activity { Canvas canvas; SnakeAnimView snakeAnimView; //The snake head sprite sheet Bitmap headAnimBitmap; //The portion of the bitmap to be drawn in the current frame Rect rectToBeDrawn; //The dimensions of a single frame int frameHeight = 64; int frameWidth = 64; int numFrames = 6; int frameNumber; int screenWidth; int screenHeight; //stats long lastFrameTime; int fps; int hi; //To start the game from onTouchEvent Intent i; -
下面是重写的
onCreate方法的实现。我们以通常的方式获取屏幕尺寸。我们将精灵图加载到headAnimBitmap位图中。最后,我们创建一个新的SnakeAnimView并将其设置为内容视图。在上一步骤的代码之后,输入以下代码:@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //find out the width and height of the screen Display display = getWindowManager().getDefaultDisplay(); Point size = new Point(); display.getSize(size); screenWidth = size.x; screenHeight = size.y; headAnimBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.head_sprite_sheet); snakeAnimView = new SnakeAnimView(this); setContentView(snakeAnimView); i = new Intent(this, GameActivity.class); } -
下面是
SnakeAnimView类的声明,它被称为SnakeAnimView,以及它的成员变量。注意,它扩展了SurfaceView并实现了Runnable接口。所有的方法将在接下来的步骤中介绍。在上一步骤的代码之后,输入此代码:class SnakeAnimView extends SurfaceView implements Runnable { Thread ourThread = null; SurfaceHolder ourHolder; volatile boolean playingSnake; Paint paint; -
下面是构造函数,它通过将位图宽度除以帧数来获取
frameWidth值,使用getHeight方法获取frameHeight值。在上一步骤的代码之后,输入此代码:public SnakeAnimView(Context context) { super(context); ourHolder = getHolder(); paint = new Paint(); frameWidth = headAnimBitmap.getWidth()/numFrames; frameHeight = headAnimBitmap.getHeight(); } -
现在我们实现这个简短但至关重要的
run方法。它依次调用这个类中的每个关键方法。这三个方法在以下三个步骤中实现。在上一步骤的代码之后,输入以下代码:@Override public void run() { while (playingSnake) { update(); draw(); controlFPS(); } } -
下面是
update方法。它跟踪并选择需要显示的帧号。每次通过update方法时,我们使用frameWidth、frameHeight和frameNumber计算要绘制的精灵图的坐标。如果你想知道为什么我们从每个水平坐标中减去1,那是因为像屏幕坐标一样,位图从 0,0 开始它们的坐标:public void update() { //which frame should we draw rectToBeDrawn = new Rect((frameNumber * frameWidth)-1, 0,(frameNumber * frameWidth +frameWidth)-1, frameHeight); //now the next frame frameNumber++; //don't try and draw frames that don't exist if(frameNumber == numFrames){ frameNumber = 0;//back to the first frame } } -
接下来是
draw方法,直到最后它都没有做任何新的事情,当它通过将screenHeight和screenWidth变量除以 2 来计算屏幕上绘制位图的位置。然后这些坐标被保存在destRect中。然后destRect和rectToDraw都传递给drawBitmap方法,该方法在所需位置绘制所需的帧。在上一步骤的代码之后,输入此代码:public void draw() { if (ourHolder.getSurface().isValid()) { canvas = ourHolder.lockCanvas(); //Paint paint = new Paint(); canvas.drawColor(Color.BLACK);//the background paint.setColor(Color.argb(255, 255, 255, 255)); paint.setTextSize(150); canvas.drawText("Snake", 10, 150, paint); paint.setTextSize(25); canvas.drawText(" Hi Score:" + hi, 10, screenHeight-50, paint); //Draw the snake head //make this Rect whatever size and location you like //(startX, startY, endX, endY) Rect destRect = new Rect(screenWidth/2-100, screenHeight/2-100, screenWidth/2+100, screenHeight/2+100); canvas.drawBitmap(headAnimBitmap, rectToBeDrawn, destRect, paint); ourHolder.unlockCanvasAndPost(canvas); } } -
我们可靠的旧
controlFPS方法确保我们的动画以合理的速率出现。此代码的唯一变化是将timeTosleep的初始化更改为在每帧之间创建 500 毫秒的暂停。在上一步骤的代码之后,输入以下代码:public void controlFPS() { long timeThisFrame = (System.currentTimeMillis() - lastFrameTime); long timeToSleep = 500 - timeThisFrame; if (timeThisFrame > 0) { fps = (int) (1000 / timeThisFrame); } if (timeToSleep > 0) { try { ourThread.sleep(timeToSleep); } catch (InterruptedException e) { } } lastFrameTime = System.currentTimeMillis(); } -
接下来是
pause和resume方法,它们与 Android 生命周期方法一起工作,以启动和停止我们的线程。在上一步骤的代码之后,输入此代码:public void pause() { playingSnake = false; try { ourThread.join(); } catch (InterruptedException e) { } } public void resume() { playingSnake = true; ourThread = new Thread(this); ourThread.start(); } -
对于我们的
SnakeAnimView类和onTouchEvent方法,该方法在屏幕任何地方被触摸时简单地启动游戏,我们输入以下代码。显然,我们还没有GameActivity:@Override public boolean onTouchEvent(MotionEvent motionEvent) { startActivity(i); return true; } } -
最后,回到
MainActivity类,我们处理一些 Android 生命周期方法。我们还处理玩家按下返回按钮时发生的情况:@Override protected void onStop() { super.onStop(); while (true) { snakeAnimView.pause(); break; } finish(); } @Override protected void onResume() { super.onResume(); snakeAnimView.resume(); } @Override protected void onPause() { super.onPause(); snakeAnimView.pause(); } public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { snakeAnimView.pause(); finish(); return true; } return false; } -
现在您必须暂时注释掉步骤 4 中的这一行来测试动画。原因是它会导致错误,直到我们实现
GameActivity类://i = new Intent(this, GameActivity.class); -
测试应用程序。
-
当我们实现了
GameActivity类时,取消注释第 14 步中的行。这是我们的完成后的主屏幕:![实现蛇的主屏幕]()
在这个练习中,我们创建了一个扩展SurfaceView的类,就像我们为我们的弹跳游戏所做的那样。我们有一个run方法,它控制线程,以及一个update方法,它计算当前动画在精灵图集中的坐标。draw方法简单地使用update方法计算出的坐标在屏幕上绘制。
就像在弹跳游戏中一样,我们有一个onTouchUpdate方法,但这次的代码非常简单。因为我们只需要检测任何类型的触摸在任何位置,所以我们只在该方法中添加了一行代码。
实现蛇游戏活动
并非所有这些代码都是新的。事实上,我们之前已经使用过其中大部分,或者在本章的早期讨论过。然而,我想按顺序并带有至少简要解释地展示每一行代码,即使我们之前已经见过。话虽如此,我没有包括长长的导入列表,因为我们要么会被提示自动添加它们,或者我们可以在需要时按Alt + Enter。
这样做,我们可以提醒自己整个事情是如何结合在一起的,而不会在我们的理解中留下任何空白。像往常一样,我会在实施过程中进行总结,并在最后深入探讨一些细节:
-
添加一个名为
GameActivity的活动。当被询问时,选择一个空白活动。 -
将活动设置为全屏,就像我们之前做的那样。
-
像往常一样,创建一些音效或者使用我的。按照常规方式在
main目录中创建一个assets目录。将音文件(sample1.ogg、sample2.ogg、sample3.ogg和sample4.ogg)复制粘贴到其中。 -
创建图形的单独非精灵图集版本或者使用我的。将它们复制粘贴到
res/drawable-mdpi文件夹中。 -
这里是带有成员变量的
GameActivity类声明。在这里,直到我们声明用于控制游戏网格的数组(snakeX和snakeY)之前,没有什么新的内容。注意我们用于控制游戏网格的变量(blockSize、numBlocksHigh和numBlocksWide)。现在输入此代码:public class GameActivity extends Activity { Canvas canvas; SnakeView snakeView; Bitmap headBitmap; Bitmap bodyBitmap; Bitmap tailBitmap; Bitmap appleBitmap; //Sound //initialize sound variables private SoundPool soundPool; int sample1 = -1; int sample2 = -1; int sample3 = -1; int sample4 = -1; //for snake movement int directionOfTravel=0; //0 = up, 1 = right, 2 = down, 3= left int screenWidth; int screenHeight; int topGap; //stats long lastFrameTime; int fps; int score; int hi; //Game objects int [] snakeX; int [] snakeY; int snakeLength; int appleX; int appleY; //The size in pixels of a place on the game board int blockSize; int numBlocksWide; int numBlocksHigh; -
如前所述,我们新的、小的
onCreate方法几乎没有什么要做,因为大部分工作都是在loadSound和configureDisplay方法中完成的。在上一步骤的代码之后输入此代码:@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); loadSound(); configureDisplay(); snakeView = new SnakeView(this); setContentView(snakeView); } -
这里是
SnakeView类的类声明、成员变量和构造函数。我们为snakeX和snakeY数组分配了 200 个int变量,并调用getSnake和getApple方法,这些方法将在屏幕上放置苹果和我们的蛇。当类被创建时,这正是我们想要的:class SnakeView extends SurfaceView implements Runnable { Thread ourThread = null; SurfaceHolder ourHolder; volatile boolean playingSnake; Paint paint; public SnakeView(Context context) { super(context); ourHolder = getHolder(); paint = new Paint(); //Even my 9 year old play tester couldn't //get a snake this long snakeX = new int[200]; snakeY = new int[200]; //our starting snake getSnake(); //get an apple to munch getApple(); } -
在我们的坐标系中,这是如何生成蛇和苹果的。在
getSnake方法中,我们将蛇头放置在屏幕的大约中心,通过初始化snakeX[0]和snakeY[0]为块的高度和宽度的二分之一。然后我们在蛇头后面立即放置一个身体段和尾部段。注意,我们不需要为不同类型的段做任何特殊安排。只要绘图代码 知道 第一个段是头部,最后一个段是尾部,中间的一切都是身体,那就足够了。在getApple方法中,整数变量appleX和appleY被初始化为游戏网格内的随机位置。正如我们在上一步骤中看到的那样,这个方法从构造函数中调用。每次我们的蛇成功吃到一个苹果时,也会调用这个方法来放置一个新的苹果。在上一步骤的代码之后输入以下代码:public void getSnake(){ snakeLength = 3; //start snake head in the middle of screen snakeX[0] = numBlocksWide / 2; snakeY[0] = numBlocksHigh / 2; //Then the body snakeX[1] = snakeX[0]-1; snakeY[1] = snakeY[0]; //And the tail snakeX[1] = snakeX[1]-1; snakeY[1] = snakeY[0]; } public void getApple(){ Random random = new Random(); appleX = random.nextInt(numBlocksWide-1)+1; appleY = random.nextInt(numBlocksHigh-1)+1; } -
接下来是
run方法,它控制着游戏流程。在上一步骤的代码之后输入以下代码:@Override public void run() { while (playingSnake) { updateGame(); drawGame(); controlFPS(); } } -
现在我们将查看
updateGame方法,这是整个应用中最复杂的方法。虽然这么说,它可能比我们的 squash 游戏中的相同方法稍微简单一些。这是因为我们的坐标系,这导致了更简单的碰撞检测。以下是updateGame方法的代码。仔细研究它,我们将在最后逐行分析它:public void updateGame() { //Did the player get the apple if(snakeX[0] == appleX && snakeY[0] == appleY){ //grow the snake snakeLength++; //replace the apple getApple(); //add to the score score = score + snakeLength; soundPool.play(sample1, 1, 1, 0, 0, 1); } //move the body - starting at the back for(int i=snakeLength; i >0 ; i--){ snakeX[i] = snakeX[i-1]; snakeY[i] = snakeY[i-1]; } //Move the head in the appropriate direction switch (directionOfTravel){ case 0://up snakeY[0] --; break; case 1://right snakeX[0] ++; break; case 2://down snakeY[0] ++; break; case 3://left snakeX[0] --; break; } //Have we had an accident boolean dead = false; //with a wall if(snakeX[0] == -1)dead=true; if(snakeX[0] >= numBlocksWide) dead = true; if(snakeY[0] == -1)dead=true; if(snakeY[0] == numBlocksHigh) dead = true; //or eaten ourselves? for (int i = snakeLength-1; i > 0; i--) { if ((i > 4) && (snakeX[0] == snakeX[i]) && (snakeY[0] == snakeY[i])) { dead = true; } } if(dead){ //start again soundPool.play(sample4, 1, 1, 0, 0, 1); score = 0; getSnake(); } } -
我们已经确定了游戏对象在屏幕上的位置,所以现在我们可以绘制它们。这段代码很容易理解,因为我们之前已经看到了大部分内容:
public void drawGame() { if (ourHolder.getSurface().isValid()) { canvas = ourHolder.lockCanvas(); //Paint paint = new Paint(); canvas.drawColor(Color.BLACK);//the background paint.setColor(Color.argb(255, 255, 255, 255)); paint.setTextSize(topGap/2); canvas.drawText("Score:" + score + " Hi:" + hi, 10, topGap-6, paint); //draw a border - 4 lines, top right, bottom , left paint.setStrokeWidth(3);//3 pixel border canvas.drawLine(1,topGap,screenWidth-1,topGap,paint); canvas.drawLine(screenWidth-1,topGap,screenWidth-1,topGap+(numBlocksHigh*blockSize),paint); canvas.drawLine(screenWidth-1,topGap+(numBlocksHigh*blockSize),1,topGap+(numBlocksHigh*blockSize),paint); canvas.drawLine(1,topGap, 1,topGap+(numBlocksHigh*blockSize), paint); //Draw the snake canvas.drawBitmap(headBitmap, snakeX[0]*blockSize, (snakeY[0]*blockSize)+topGap, paint); //Draw the body for(int i = 1; i < snakeLength-1;i++){ canvas.drawBitmap(bodyBitmap, snakeX[i]*blockSize, (snakeY[i]*blockSize)+topGap, paint); } //draw the tail canvas.drawBitmap(tailBitmap, snakeX[snakeLength-1]*blockSize, (snakeY[snakeLength-1]*blockSize)+topGap, paint); //draw the apple canvas.drawBitmap(appleBitmap, appleX*blockSize, (appleY*blockSize)+topGap, paint); ourHolder.unlockCanvasAndPost(canvas); } } -
这里是
controlFPS方法,与我们的 squash 游戏中的controlFPS方法没有变化,只是我们有一个不同的目标帧率。在上一步骤的代码之后输入以下代码:public void controlFPS() { long timeThisFrame = (System.currentTimeMillis() - lastFrameTime); long timeToSleep = 100 - timeThisFrame; if (timeThisFrame > 0) { fps = (int) (1000 / timeThisFrame); } if (timeToSleep > 0) { try { ourThread.sleep(timeToSleep); } catch (InterruptedException e) { } } lastFrameTime = System.currentTimeMillis(); } -
这里是我们的未更改的
pause和resume方法。在上一步骤的代码之后输入以下代码:public void pause() { playingSnake = false; try { ourThread.join(); } catch (InterruptedException e) { } } public void resume() { playingSnake = true; ourThread = new Thread(this); ourThread.start(); } -
然后是我们
onTouchEvent方法,与我们的 squash 游戏中的方法类似。这里没有新的概念,但在这个游戏中它的工作方式如下。我们切换到ACTION_UP事件。这基本上等同于检测点击。然后我们检查按下是在左侧还是右侧。如果是右侧,我们增加directionOfTravel。如果是左侧,我们减少directionOfTravel。如果你仔细看了updateGame方法,你会看到directionOfTravel表示蛇移动的方向。记住,蛇永远不会停止。这就是为什么我们与 squash 游戏的做法不同。在上一步骤的代码之后输入以下代码:@Override public boolean onTouchEvent(MotionEvent motionEvent) { switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_UP: if (motionEvent.getX() >= screenWidth / 2) { //turn right directionOfTravel ++; //no such direction if(directionOfTravel == 4) //loop back to 0(up) directionOfTravel = 0; } else { //turn left directionOfTravel--; if(directionOfTravel == -1) {//no such direction //loop back to 0(up) directionOfTravel = 3; } } } return true; } -
在
GameActivity类中,我们现在处理 Android 生命周期方法和“返回”按钮功能。在上一步骤的代码之后输入以下代码:@Override protected void onStop() { super.onStop(); while (true) { snakeView.pause(); break; } finish(); } @Override protected void onResume() { super.onResume(); snakeView.resume(); } @Override protected void onPause() { super.onPause(); snakeView.pause(); } public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { snakeView.pause(); Intent i = new Intent(this, MainActivity.class); startActivity(i); finish(); return true; } return false; } -
这里是我们的
loadSound方法,它通过将所有声音初始化移动到这里来简单地整理了onCreate方法。在上一步骤的代码之后输入以下代码:public void loadSound(){ soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC, 0); try { //Create objects of the 2 required classes AssetManager assetManager = getAssets(); AssetFileDescriptor descriptor; //create our three fx in memory ready for use descriptor = assetManager.openFd("sample1.ogg"); sample1 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample2.ogg"); sample2 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample3.ogg"); sample3 = soundPool.load(descriptor, 0); descriptor = assetManager.openFd("sample4.ogg"); sample4 = soundPool.load(descriptor, 0); } catch (IOException e) { //Print an error message to the console Log.e("error", "failed to load sound files); } } -
然后我们有
configureDisplay方法,它从onCreate调用,并完成位图和屏幕尺寸计算的整个设置。我们将在稍后更详细地查看这个方法。在上一步骤的代码之后输入以下代码:public void configureDisplay(){ //find out the width and height of the screen Display display = getWindowManager().getDefaultDisplay(); Point size = new Point(); display.getSize(size); screenWidth = size.x; screenHeight = size.y; topGap = screenHeight/14; //Determine the size of each block/place on the game board blockSize = screenWidth/40; //Determine how many game blocks will fit into the //height and width //Leave one block for the score at the top numBlocksWide = 40; numBlocksHigh = ((screenHeight - topGap ))/blockSize; //Load and scale bitmaps headBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.head); bodyBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.body); tailBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.tail); appleBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.apple); //scale the bitmaps to match the block size headBitmap = Bitmap.createScaledBitmap(headBitmap, blockSize, blockSize, false); bodyBitmap = Bitmap.createScaledBitmap(bodyBitmap, blockSize, blockSize, false); tailBitmap = Bitmap.createScaledBitmap(tailBitmap, blockSize, blockSize, false); appleBitmap = Bitmap.createScaledBitmap(appleBitmap, blockSize, blockSize, false); } -
现在运行应用程序。在实际设备上玩游戏比在模拟器上玩要有趣得多。
我们在进展过程中覆盖了代码,但像往常一样,这里是对几个更复杂的方法进行逐个剖析,从updateGame方法开始。
首先,我们检查玩家是否吃到了苹果。更具体地说,蛇的头部是否与苹果处于相同的网格位置?if语句检查这种情况是否发生,然后执行以下操作:
-
增加蛇的长度
-
通过调用
getApple在屏幕上放置另一个苹果 -
根据蛇的长度给玩家增加分数,使得每个苹果比前一个更有价值
-
播放蜂鸣声
这是描述我们刚刚描述的动作的代码:
public void updateGame() {
//Did the player get the apple
if(snakeX[0] == appleX && snakeY[0] == appleY){
//grow the snake
snakeLength++;
//replace the apple
getApple();
//add to the score
score = score + snakeLength;
soundPool.play(sample1, 1, 1, 0, 0, 1);
}
现在,我们简单地移动蛇的每一部分,从尾部开始,移动到它前面部分的位置。我们使用一个for循环来完成这个操作:
//move the body - starting at the back
for(int i = snakeLength; i >0 ; i--){
snakeX[i] = snakeX[i-1];
snakeY[i] = snakeY[i-1];
}
当然,我们最好也移动头部!我们最后移动头部,因为如果我们在更早的时候移动头部,身体的前端就会移动到错误的位置。只要整个移动在绘制之前完成,一切都会顺利。我们的run方法确保这一点始终如此。以下是移动头部到由directionOfTravel确定的方向的代码。正如我们所见,directionOfTravel在onTouchEvent方法中被玩家操作:
//Move the head in the appropriate direction
switch (directionOfTravel){
case 0://up
snakeY[0] --;
break;
case 1://right
snakeX[0] ++;
break;
case 2://down
snakeY[0] ++;
break;
case 3://left
snakeX[0] --;
break;
}
接下来,我们检查与墙壁的碰撞。我们在查看碰撞检测时看到了这段代码。以下是完整的解决方案,从左墙开始,然后是右墙,然后是上墙,最后是下墙:
//Have we had an accident
boolean dead = false;
//with a wall
if(snakeX[0] == -1)dead=true;
if(snakeX[0] >= numBlocksWide)dead=true;
if(snakeY[0] == -1)dead=true;
if(snakeY[0] == numBlocksHigh)dead=true;
然后我们检查蛇是否撞到了自己。最初,这看起来有些尴尬,但正如我们之前看到的,我们只是遍历我们的蛇数组,检查是否有任何部分与头部在x和y坐标上处于相同的位置:
//or eaten ourselves?
for (int i = snakeLength-1; i > 0; i--) {
if ((i > 4) && (snakeX[0] == snakeX[i]) && (snakeY[0] == snakeY[i])) {
dead = true;
}
}
如果我们的碰撞检测代码中的任何部分将dead设置为true,我们只需播放一个声音,将score设置为0,并得到一条新的小蛇:
if(dead){
//start again
soundPool.play(sample4, 1, 1, 0, 0, 1);
score = 0;
getSnake();
}
}
现在,我们更仔细地看看drawGame方法。首先,我们准备好绘制,清除屏幕:
public void drawGame() {
if (ourHolder.getSurface().isValid()) {
canvas = ourHolder.lockCanvas();
//Paint paint = new Paint();
canvas.drawColor(Color.BLACK);//the background
paint.setColor(Color.argb(255, 255, 255, 255));
paint.setTextSize(topGap/2);
现在,我们在configureDisplay中定义的topGap上方绘制玩家的分数文本:
canvas.drawText("Score:" + score + " Hi:" + hi, 10, topGap-6, paint);
现在,使用drawLine,我们在游戏网格周围绘制一个可见的边界:
//draw a border - 4 lines, top right, bottom, left
paint.setStrokeWidth(3);//4 pixel border
canvas.drawLine(1,topGap,screenWidth-1,topGap,paint);
canvas.drawLine(screenWidth-1,topGap,screenWidth-1,topGap+(numBlocksHigh*blockSize),paint);
canvas.drawLine(screenWidth-1,topGap+(numBlocksHigh*blockSize),1,topGap+(numBlocksHigh*blockSize),paint);
canvas.drawLine(1,topGap, 1,topGap+(numBlocksHigh*blockSize), paint);
接下来,我们绘制蛇的头部:
//Draw the snake
canvas.drawBitmap(headBitmap, snakeX[0]*blockSize, (snakeY[0]*blockSize)+topGap, paint);
蛇的头部将被所有身体部分跟随。看看for循环的条件。它从1开始,这意味着它不会重新绘制头部位置,并且结束于snakeLength - 1,这意味着它不会绘制尾部部分。以下是绘制身体部分的代码:
//Draw the body
for(int i = 1; i < snakeLength-1; i++){
canvas.drawBitmap(bodyBitmap, snakeX[i]*blockSize, (snakeY[i]*blockSize)+topGap, paint);
}
在这里,我们绘制蛇的尾巴:
//draw the tail
canvas.drawBitmap(tailBitmap, snakeX[snakeLength-
1]*blockSize, (snakeY[snakeLength-1]*blockSize)+topGap, paint);
最后,我们按照以下方式绘制苹果:
//draw the apple
canvas.drawBitmap(appleBitmap, appleX*blockSize,
(appleY*blockSize)+topGap, paint);
ourHolder.unlockCanvasAndPost(canvas);
}
}
接下来,我们将通过configureDisplay方法。
首先,我们获取屏幕分辨率并将结果存储在screenWidth和screenHeight中,就像平常一样:
public void configureDisplay(){
//find out the width and height of the screen
Display display = getWindowManager().getDefaultDisplay();
Point size = new Point();
display.getSize(size);
screenWidth = size.x;
screenHeight = size.y;
在这里,我们定义了一个名为topGap的间隙。它将是屏幕顶部的空间,并且不会是游戏区域的一部分。这个间隙用于显示分数。我们在drawGame方法中看到了topGap被广泛使用。之后,我们计算剩余区域的宽度和高度,以块为单位:
topGap = screenHeight/14;
//Determine the size of each block/place on the game board
blockSize = screenWidth/40;
//Determine how many game blocks will fit into the height and width
//Leave one block for the score at the top
numBlocksWide = 40;
numBlocksHigh = (screenHeight - topGap )/blockSize;
在代码的以下部分,我们将所有图像文件加载到Bitmap对象中:
//Load and scale bitmaps
headBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.head);
bodyBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.body);
tailBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.tail);
appleBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.apple);
最后,我们将每个位图缩放到与blockSize相同的宽度和高度:
//scale the bitmaps to match the block size
headBitmap = Bitmap.createScaledBitmap(headBitmap, blockSize,
blockSize, false);
bodyBitmap = Bitmap.createScaledBitmap(bodyBitmap, blockSize,
blockSize, false);
tailBitmap = Bitmap.createScaledBitmap(tailBitmap, blockSize,
blockSize, false);
appleBitmap = Bitmap.createScaledBitmap(appleBitmap,
blockSize, blockSize, false);
}
现在我们可以快速看看几种不同的方法来改进游戏。
增强游戏
这里有一系列问题和答案,引导我们改进我们的蛇游戏。如果你不能回答一些(甚至全部)问题没关系。只需看看问题和答案,然后你可以看看新的游戏和代码。
自我测试问题
Q1) 我们可以用什么来为我们的游戏屏幕提供视觉上的改进?我们能使用一个漂亮的浅绿色,草地的背景而不是仅仅黑色吗?
Q2) 一些漂亮的花朵怎么样?
Q3) 如果你感到勇敢,可以让花朵摇摆。想想我们学到的关于精灵图集的知识。理论完全和动画蛇头的理论一样。我们只需要几行代码来分别控制帧率,而不是游戏帧率。
Q4) 我们可以设置另一个计数器,并在GameActivity中使用我们的蛇头动画,但这不会很有用,因为微妙的舌头动作在较小的尺寸下几乎看不见。但我们能摆动尾巴段吗?
Q5) 这是一个稍微有点棘手的增强。你无法不注意到,当蛇精灵朝向四个可能方向中的三个时,它们看起来并不正确。你能修复这个问题吗?
概述
这是我们又一个成功的游戏项目的结束。你现在知道如何创建和动画精灵图集,以增加我们游戏的现实感。现在我们有一个增强版的蛇游戏。
在下一章中,我们将看到添加排行榜和成就有多么简单。这将使我们的游戏更具社交性和吸引力,让玩家可以看到他们朋友的高分和成就,并与自己的进行比较。
第九章。让你的游戏成为下一个大热门
终于到了我们可以发布我们的第一个游戏的日子。尽管这一章比其他章节短,但它可能是完成时间最长的章节。在真正深入之前,浏览不同的练习看看涉及的内容是个好主意。大多数这些教程不适合在你最喜欢的电视节目广告期间或下班后非常累的时候做。
阅读本章,并制定每个阶段执行的计划。阶段安排得如此,你应该能够在每个阶段之间暂停项目。如果你真的很坚定,直到现在已经理解了所有代码,对文件和文件夹有信心,并且没有中断,你可能在一天内就能完成本章的工作。
和往常一样,完成的代码位于下载包中相关文件夹内,在本例中是 Chapter9 文件夹。
注意
注意,由于我无法分享我的开发者账户的登录凭证,因此不得不在代码中使用一系列黑色线条来掩盖一些 ID 号码。你将在本章讨论 ids.xml 文件时在代码中看到这些,该文件由于其机密性质而不包含在代码包中。然而,正如你将在 设置蛇项目以供实施 部分中看到的那样,获取你自己的 ID 代码很容易。此外,请注意,本章中的大量工作涉及在你的开发者控制台中进行的设置。排行榜和成就将不会工作,直到你完成了必要的步骤。然而,你可以审查 Chapter9 文件夹中的整个代码,并从 第八章,蛇游戏,下载包含工作排行榜和成就的增强版游戏,从 第九章,让你的游戏成为下一个大热门,来自 play.google.com/store/apps/details?id=com.packtpub.enhancedsnakegame.enhancedsnakegame。
如果你想要自己实现所有内容,并且想要从游戏的增强版开始,包括来自上一章自我测试问题的所有改进,那么从 Chapter8 文件夹中获取 EnhancedSnakeGame 代码,并更新你的工作项目 Chapter8。
在本章中,你将学习以下主题:
-
如何发布你的应用
-
推广你的应用,包括使其具有排行榜和公开成就的社交功能
-
使用 Google Play Game Services API 实现排行榜和成就
-
根据你想要实现的目标来考虑下一步要做什么
如何发布你的应用
本指南中的一些步骤涉及编写描述和提供截图,因此你可能希望在实施任何步骤之前先阅读整个指南:
-
创建一个图标。确切地说,如何设计图标超出了本书的范围,但简单来说,您需要为 Android 屏幕密度类别中的每个类别创建一个漂亮的图像。这比听起来容易。使用一个简单的图像,例如蛇头位图,您可以从
romannurik.github.io/AndroidAssetStudio/icons-launcher.html定制并下载一套。有许多网站提供类似免费服务。当然,您也可以直接使用增强蛇项目中的图像,并跳过这一步和下一步。 -
一旦您从上一个链接下载了您的
.zip文件,您只需将下载中的res文件夹复制到项目资源管理器中的main文件夹内。现在所有密度的图标都将更新。 -
在我们继续之前,您可能需要准备一些游戏的截图。您将被提示上传几种屏幕类型的截图,但由于游戏在所有屏幕类型上几乎相同,一张图片就足够了。您还需要一张 512 x 512 像素的图标图像和一张 1024 x 500 像素的特色图形图像。它们不需要很棒,但您确实需要它们才能继续。您可以创建自己的图像,或者从
Chapter9文件夹中获取我非常简单的图形的副本。 -
现在,不幸的是,您可能需要花费 $ 25 来开通 Google Play 账户。您可以在
play.google.com/apps/publish/上注册。 -
一旦注册,您就可以登录到之前步骤中提到的相同 URL 的您的开发者控制台。
-
一旦进入您的控制台,点击 + 添加新应用 按钮:
![如何发布您的应用]()
-
在 添加新应用 对话框中,为您的应用程序输入一个名称,例如
Snake Game。现在点击 上传 APK。 -
现在,我们需要将我们的应用转换为发布版本。打开
AndroidManifest.xml文件,并在显示的位置添加高亮显示的代码行:<application android:debuggable="false" android:allowBackup="true" -
重建您已签名的 APK,以适用于最新版本的 Snake 游戏,如第二章中所述,Android 入门。
-
现在点击 上传您的第一个生产 APK。
-
现在转到您的 Snake 游戏 APK。
-
等待 APK 上传完成。您现在可以看到您的游戏摘要屏幕。注意下一张图片右上角的高亮进度指示器。我们有一个绿色的勾号,表示 APK 已成功上传:
![如何发布您的应用]()
-
接下来,我们需要做的是配置我们的商店列表,因此点击位于 APK 链接下方仅有的 Store Listing 链接。
-
编写简短和长描述。同时上传您的截图、特色图形和高分辨率图标。
-
在应用类型下拉菜单中,选择游戏。在分类下拉菜单中,街机可能最合适。对于内容评级,选择所有人,对于隐私政策,点击不在此时刻提交隐私政策的复选框。
-
将你的网站和电子邮件地址添加到相关框中。
-
返回网页顶部,点击保存按钮。
-
现在我们已经到达本指南的最终阶段。点击定价和分发链接。它位于第 13 步的商店列表链接下方。
-
在页面顶部点击免费按钮。
-
点击所有你希望你的游戏列出的国家的复选框。
-
滚动到页面底部并点击内容指南和美国出口法的复选框。
-
在页面顶部点击保存。
-
最后,从页面右上角的准备发布下拉菜单中,点击发布此应用,操作完成。
![如何发布你的应用]()
恭喜!你的游戏将在 5 分钟到 24 小时之间在 Google Play 上上线。
推广你的应用
在这个阶段,诱惑是坐下来等待我们的游戏成为畅销应用的前列。这种情况永远不会发生。
为了确保我们的应用发挥其全部潜力,我们需要持续进行以下操作:
改进它
我们已经对蛇游戏进行了相当多的改进,但还有很多,比如难度设置、音乐、调试(你看到过偶尔不规则的身体部分吗?)、设置菜单等等。你可以支付专业人士来设计背景和精灵,或者添加更多音效。当你进一步提高了你的 Android 和 Java 技能后,你可以使用更平滑的引擎重写整个游戏,并将其称为版本 2。
推广它
这可能需要另一本书来详细阐述,但我们可以用许多方式来宣传我们的应用。我们可以在所有社交媒体网站上创建页面/个人资料——Facebook、Twitter、Flickr 等等。添加定期更新、公告、挑战(参见强制行为)。我们可以创建一个网站来推广我们的应用,并以我们推广任何其他网站的方式推广它。我们可以在应用中添加一条信息,请求玩家对其进行评分,也许在他们获得高分或成就后弹出一条信息。我们可以请求我们认识的所有人和访问我们社交媒体/网站的人给出评分并留下评论。还有许多其他推广应用的方法。所有这些方法的秘诀是:持续进行。例如,不要创建一个 Facebook 页面,然后期望它自己变得流行。持续向所有推广渠道添加内容。
保持玩家的强制行为水平
除了以我们简要提到的方式改进游戏外,我们还需要给玩家一个有说服力的理由让他们继续回到我们的游戏中。一种方法可能是添加新关卡。例如,在我们的蛇游戏中实现关卡并不困难。每个关卡都可以有不同的墙壁位置,布局可以逐渐变得更加具有挑战性。我们只需要制作一个障碍物数组,在屏幕上绘制它们,并检测碰撞。然后为每个关卡设定蛇的长度目标,并在达到目标后进入下一关卡。
我们可以提供不同的蛇形设计,以解锁某些挑战。玩家将他们收集的所有苹果作为货币,然后有策略地花费这些货币,在死后有机会继续游戏,怎么样?
我们可以提供限时挑战怎么样?例如,在本月底前完成第 10 级,以获得一千个额外苹果。也许,我们可以想出更多苹果可以消费的东西。酷炫的蛇形配件或只能用苹果解锁的关卡。重点是,所有这些强制性都可以在我们上传改进的同时添加和更新。在我们迄今为止学到的技能中,讨论中提到的任何强制性都是可以实现的。
可能,我们可以添加到我们的游戏中最吸引人的方面是在线排行榜和成就,这样玩家就可以将自己与朋友和全世界的人进行比较。谷歌意识到了这一点,并做了大量工作来简化将排行榜和成就添加到游戏中的过程。我们将看看我们如何再次利用他人的辛勤工作。
此外,你在游戏中获得的全部成就都会被输入到他们的整体 Google Play 个人资料中。以下是我在 Google Play 成就个人资料中相当糟糕的截图:

你可能已经注意到其中有一些蛇成就。这个功能使你的游戏更具吸引力。
小贴士
让我们做一个快速的现实检查——我实际上并不是建议你花大量时间试图将我们这个简陋的蛇形游戏变成一个真正的商业项目。这只是一个有用的讨论例子。此外,如果我们能为这样一个古老而简单的游戏想出这么多点子,那么我们当然可以为那些我们热衷的游戏想出一些真正惊人的东西。当你有一个你热衷的想法时,那就是你采取行动并扩展我们讨论过的简要营销计划的时候了。
添加排行榜和成就
因此,我们知道排行榜和成就是好事。在这里我们需要做的第一件事是规划我们的成就。排行榜是一个高分表,仅此而已!我们无法做太多事情来使它们有所不同。然而,成就却值得讨论。
规划蛇形游戏的成就
起初,可能觉得像我们的蛇游戏这样非常简单的游戏实现并不够深入,以至于没有很多,甚至没有任何成就。所以以下是一个快速头脑风暴成就想法的会话:
-
得分 10、25、50、100 等:只需在不同的高分级别解锁成就。
-
蛇的长度:只需在不同的蛇长度解锁成就。
-
食人:当玩家第一次与自己的尾巴部分相撞时解锁一个成就。
-
收集 x 个苹果总数:记录所有收集到的苹果数量,并在重要的里程碑处解锁成就。
-
玩 10、25、50、100 场比赛:奖励玩家持续进行。无论他们赢或输,都会因努力解锁成就。
-
寻宝:如果每场比赛都有一个隐藏的地点会怎样?这可以给玩家一个探索每个级别的理由。他们可以因得分和苹果而获得奖励。然后他们可以解锁真正的成就,也许是为找到的每个 5、10 或 20 个隐藏地点解锁。
一些成就表明我们需要记录玩家的进度。令人惊讶的是,Google Play Game Services 实际上可以为我们做这件事。这些被称为增量成就。收集到的苹果总数是一个很好的增量成就例子。其他,如蛇的长度,仅取决于玩家在任意一场游戏中的表现。
我们将实现苹果总数和蛇长度成就,这样我们就可以看到如何实现这两种类型。
我们可以为达到以下蛇长度设置五个成就:5、10、20、35 和 50。还可以为收集到的苹果总数设置五个增量成就。具体来说,玩家将在收集到 10、25、50、100、150 和 250 个苹果时获得一个成就。很快我们就会看到如何做到这一点。
最后,我们需要决定每个成就将值多少分,每个游戏的 1000 分上限。由于我可能回来添加一些更多的成就,所以我将把 250 分分配给苹果成就,如下所示:
| 吃掉的苹果数量 | 成就点数 |
|---|---|
| 10 | 10 |
| 20 | 30 |
| 50 | 40 |
| 100 | 70 |
| 250 | 100 |
我还将把 250 分分配给蛇长度成就,如下表所示:
| 蛇的长度 | 成就点数 |
|---|---|
| 5 | 10 |
| 10 | 30 |
| 25 | 40 |
| 35 | 70 |
| 50 | 100 |
一旦你看到如何在代码和开发者控制台中实现这些成就,设计和实现你自己的不同成就将会相对简单。
步骤式排行榜和成就
这可能是这本书中最长的部分来完成。然而,一旦你通过了这个过程,下次就会容易得多。
在您的 PC 上安装 Google Play Services API
首先,我们需要添加使用游戏服务类所需的工具和库。在 Android Studio 中,这既方便又简单:
-
点击 Android Studio 工具栏中的 SDK 管理器图标:
![在您的 PC 上安装 Google Play 服务 API]()
-
SDK 管理器将启动。它看起来有点像这样:
![在您的 PC 上安装 Google Play 服务 API]()
-
滚动到页面底部,在 附加组件 下方,您将看到 Google Play 服务。通过点击以下截图所示的高亮复选框来勾选它:
![在您的 PC 上安装 Google Play 服务 API]()
-
现在,点击位于 Google Play 服务 下方的新 Google 仓库 复选框。
-
点击 安装包 并等待包下载和安装。
-
保存您的项目并重新启动 Android Studio。
我们现在已安装了开发 Google Play 游戏服务应用的工具。接下来,我们需要设置我们的开发者控制台以与我们的应用通信,为我们将要编写的功能做好准备。
配置 Google Play 开发者控制台
在这里,我们将通过创建一个新的游戏服务应用来准备您的开发者控制台。这听起来可能有点反直觉;Snake 不是我们的应用吗?是的,但 Google Play 的结构是这样的,您需要创建一个游戏服务应用,并且您的实际游戏(在这个例子中是 Snake)将通过这个应用进行通信。是游戏服务应用将拥有我们将从我们的 Snake 游戏中奖励和显示的成就和排行榜:
-
登录您的 Google Play 开发者控制台,请访问
play.google.com/apps/publish/. -
在网页左侧点击 游戏服务 选项卡。
![配置 Google Play 开发者控制台]()
-
现在,点击屏幕顶部的 添加新游戏 按钮。
-
将您的游戏命名为
Snake并从 类别 下拉菜单中选择 街机。现在点击 继续。所有这些都在下一张截图中显示:![配置 Google Play 开发者控制台]()
-
现在,我们可以配置我们的游戏。在 描述 字段中输入游戏描述,并添加与上传游戏时相同的相同高分辨率图标和功能图形。
-
点击屏幕顶部的 保存 按钮。
-
现在,我们将把我们的 Snake 游戏服务应用与实际的 Snake 游戏链接起来。在网页左侧,点击 已链接应用 选项卡。
![配置 Google Play 开发者控制台]()
-
Google Play 游戏服务可以与几乎任何平台一起使用,甚至包括苹果。我们在这里使用它来开发 Android 应用,因此点击 Android 按钮。
![配置 Google Play 开发者控制台]()
-
在这个屏幕上,我们只需要点击包名搜索框,然后点击我们的Snake 游戏选项。配置 Google Play 开发者控制台
-
在屏幕顶部点击保存并继续。
-
我们正接近这个阶段的尾声。点击立即授权您的应用并查看信息。配置 Google Play 开发者控制台
-
最后,点击继续。配置 Google Play 开发者控制台
我们现在已设置好一个 Google 游戏服务应用,并将其链接到我们的Snake游戏。
在 Google Play 开发者控制台中实现排行榜
现在,我们需要在我们的开发者控制台中创建我们的排行榜,以便我们可以在 Java 代码中稍后与之交互:
-
登录您的开发者控制台。
-
点击游戏服务,然后点击Snake,接着点击排行榜。
-
现在点击添加排行榜。这是新排行榜屏幕:在 Google Play 开发者控制台中实现排行榜
-
这可能看起来像是一场马拉松,但我们只需要在名称字段中输入一个名称(
Snake即可),我们就完成了。为我们的排行榜输入名称可能看起来有些奇怪,但这是因为一个游戏可以有多个排行榜。 -
仔细阅读所有选项。您会发现它们非常适合我们,无需进一步操作。点击保存。
我们的成绩排行榜现在可以与我们的Snake应用进行通信。
在 Google Play 开发者控制台中实现成就
在这里,我们将在我们的开发者控制台中设置之前讨论过的成就。
您可能想准备一些图形来代表这些成就。每个图形都需要是 512 x 512 像素。或者,您可以使用放大的苹果位图,也许可以用蛇的身体段来表示苹果和蛇长度成就:
-
登录您的开发者控制台。点击游戏服务,然后点击Snake,接着点击成就。
-
点击添加成就,您将看到新成就屏幕:在 Google Play 开发者控制台中实现成就
-
由于我们正在实现增量苹果成就,首先需要在新成就表单中输入一些内容。在名称字段中输入
Apple Muncher 1。 -
在描述字段中输入
吃掉 10 个苹果。 -
点击添加图标按钮,并选择您偏好的 512 x 512 像素的图形。
-
点击增量成就复选框,并在需要多少步字段中输入
5。这是因为第一个成就是吃掉 5 个苹果。这一步骤在下一张截图中有展示:在 Google Play 开发者控制台中实现成就 -
在积分字段中输入
10作为成就积分的数量。 -
点击保存,然后按照步骤 2 到 7 重复四次,为所有苹果成就进行操作,根据我们的计划和成就值表调整名称、描述、需要多少步?和积分字段。
-
现在我们可以继续处理我们的蛇长度成就。点击新建成就。在名称字段中输入
超级蛇 1。 -
在描述字段中输入
增长你的蛇到 5 段。 -
点击添加图标按钮,浏览到您喜欢的图片。
-
最后,在积分字段中输入
10作为成就积分的数量。 -
点击保存,然后按照步骤 9 到 13 重复四次,为每个蛇长度成就进行操作,根据我们的计划和成就值表调整名称、描述和积分字段。
现在我们已经设置了我们的成就,准备在代码中实施。
设置好蛇项目,准备实施。
在本节中,我们将准备我们的应用程序与 Google Play 服务器通信:
-
将以下高亮代码添加到
AndroidManifest.xml文件中,就在关闭</application>标签之前:<meta-data android:name="com.google.android.gms.games.APP_ID" android:value="@string/app_id" /> <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/> </application> -
在项目资源管理器中的
values文件夹中创建ids.xml文件。现在您需要获取您游戏的唯一代码并将其放入此文件。登录到您的开发者控制台,点击游戏服务,然后点击蛇。现在点击成就。 -
在成就列表下方有一个小的获取资源链接:设置蛇项目准备实施
-
点击获取资源链接。设置蛇项目准备实施
-
将代码复制并粘贴到
ids.xml文件中。然后点击开发者控制台中的完成按钮。 -
现在,我们需要从 Google Play 游戏服务 GitHub 仓库获取四个代码文件。我们将直接将这些文件复制粘贴到我们的项目中。
-
在
java文件夹中创建三个新的空文件。在项目资源管理器中右键点击GameActivity,然后导航到新建 | Java 类文件。将新文件命名为BaseGameActivity。重复此步骤并命名为GameHelper。再重复一次并命名为GameHelperUtils。 -
现在,我们将获取要复制到我们刚刚创建的三个文件中的 Java 代码。要获取
BaseGameActivity.java的代码,请访问github.com/playgameservices/android-basic-samples/tree/master/BasicSamples/libraries/BaseGameUtils/src/main/java/com/google/example/games/basegameutils,在那里您可以看到进一步链接到我们在第 7 步中创建的三个文件的代码:设置蛇项目准备实施 -
点击如图所示的BaseGameActivity.java。选择所有代码并将其复制粘贴到 Android Studio 中创建的相同名称的文件中。注意,当我们创建文件时,Android Studio 创建了一些基本的模板代码。我们需要删除所有这些代码,除了顶部的包名。当我们粘贴复制的代码时,我们需要删除 Google 包名。
-
点击如图所示的GameHelper.java,并重复步骤 9。
-
点击如图所示的GameHelperUtils.java,并重复步骤 9。
-
还需要创建一个文件。在项目资源管理器中右键单击values文件夹。导航到新建 | 文件。将文件命名为
gamehelper_strings.xml。 -
以与之前三个 Java 文件相同的方式获取此文件所需的代码,但来自此链接:
github.com/playgameservices/android-basic-samples/blob/master/BasicSamples/libraries/BaseGameUtils/src/main/res/values/gamehelper_strings.xml。 -
将步骤 12 中创建的
gamehelper_strings.xml中的代码粘贴进去。 -
现在更改
MainActivity.java文件中的MainActivity声明。考虑以下代码:
public class MainActivity extends Activity {将其更改为以下代码,以便我们现在可以扩展处理游戏服务 API 所有繁重工作的 Activity 版本:
public class MainActivity extends BaseGameActivity { -
现在检查
GameActivity.java文件中的代码:public class GameActivity extends Activity {将前面的代码更改为以下代码,以便我们现在可以扩展处理游戏服务 API 所有繁重工作的 Activity 版本:
public class GameActivity extends BaseGameActivity { -
注意,对于这两个 Activity,我们在刚刚输入的类声明中有一个错误。如果你将鼠标光标悬停在之前步骤中输入的代码上,你可以看到原因。我们需要实现我们使用的一个类的某些抽象方法。回想一下第六章,面向对象编程 – 使用他人的辛勤工作,如果一个类中的方法被声明为抽象的,那么扩展它的类必须实现它。那就是我们!现在让我们为空实现。右键单击有错误的代码行,导航到生成 | 实现方法。现在点击确定。对
MainActivity.java文件和GameActivity.java文件执行此步骤。我们的空方法现在准备好编写代码了。我们将在下一个教程中编写代码。 -
接下来,使用项目资源管理器,找到
build.gradle文件。请注意;有两个文件具有相同的名称。我们需要找到的文件位于AndroidManifest.xml文件下方几行。在下一张截图中被突出显示。通过双击build.gradle文件来打开它:![设置 Snake 项目以准备实现]()
-
找到此处显示的代码部分,并添加高亮显示的行。这使得我们在上一指南中下载的所有类都可以在我们的Snake游戏中使用:
dependencies { compile 'com.google.android.gms:play-services:+' compile 'com.android.support:appcompat-v7:+' compile fileTree(dir: 'libs', include: ['*.jar']) }
好的,我同意这是一个相当困难的教程,但我们现在已经准备好在三个最终步骤中实现我们的代码:
-
玩家登录和按钮。
-
排行榜。
-
成就。
然后,我们将能够上传我们的更新后的应用程序,并使用我们新的排行榜和成就。
实现玩家的登录、成就和排行榜按钮
到本节结束时,玩家将能够通过游戏登录到我们的空排行榜和成就。本节之后的指南将实际上使排行榜和成就工作。
-
首先,让我们启用我们的游戏服务。我们在开发者控制台中迄今为止所做的所有工作都需要在我们可以使用之前发布。登录到你的开发者控制台。导航到游戏服务 | Snake | 准备发布 | 发布游戏。然后你会看到一个发布你的游戏按钮。点击它。最后,阅读简短的免责声明并点击现在发布。
-
现在我们需要构建一个包含登录、登出、排行榜和成就按钮的用户界面。打开
layout_main.xml文件,在编辑窗口的文本标签页中编辑它,添加以下代码。显然,有很多内容需要输入。你可能喜欢从Chapter9\EnhancedSnakeGame\layout下载包中复制和粘贴代码。以下是代码。输入它或复制粘贴它:<RelativeLayout android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" tools:context="com.packtpub.enhancedsnakegame.enhancedsnakegame.MainActivity"> <Button android:id="@+id/llPlay" android:layout_width="140dp" android:layout_height="wrap_content" android:text="Leaderboards" android:layout_alignParentBottom="true" android:layout_alignParentRight="true" android:layout_alignParentEnd="true" android:visibility="gone"/> <Button android:id="@+id/awardsLink" android:layout_width="140dp" android:layout_height="wrap_content" android:text="Achievements" android:layout_gravity="center_vertical" android:layout_alignTop="@+id/llPlay" android:layout_toLeftOf="@+id/llPlay" android:visibility="gone"/> <!-- sign-in button --> <com.google.android.gms.common.SignInButton android:id="@+id/sign_in_button" android:layout_width="140dp" android:layout_gravity="center_horizontal" android:layout_height="wrap_content" android:layout_alignParentTop="true" android:layout_alignParentRight="true" android:layout_alignParentEnd="true" /> <!-- sign-out button --> <Button android:id="@+id/sign_out_button" android:layout_width="140dp" android:layout_height="wrap_content" android:text="Sign Out" android:layout_alignParentTop="true" android:layout_alignParentRight="true" android:layout_alignParentEnd="true" android:layout_gravity="center_horizontal" android:visibility="gone" /> </RelativeLayout> -
逐行解释代码超出了本书的范围,但这种方法与我们自第二章“开始使用 Android”以来使用 UI 设计器自动生成的代码并没有太大的区别。在最后一步中,代码的每个块定义了一个按钮及其在屏幕上的位置。你可以切换到设计标签页,移动按钮以适应你的需求。请注意,有些按钮在设计师中不可见,是因为它们在玩家登录之前是隐藏的。我们这样做的原因是为了确保我们以正确的方式实现了登录按钮。注意每个按钮的
id属性。我们将在接下来的 Java 代码中操作它们。当一些按钮设置为visibility = gone时,我们会看到如下所示的内容:![实现玩家的登录、成就和排行榜按钮]()
-
当一些按钮设置为
visibility = visible时,我们会看到如下截图所示的内容:![实现玩家的登录、成就和排行榜按钮]()
-
你可能想知道为什么我们在
SnakeAnimView是用户看到的内容时还要设计一个 UI。我们可以用位图实现我们所有的按钮,并使用它们的屏幕坐标来检测点击,但我们现在要做的是在SnakeAnimView之上加载我们的 UI,这将大大简化事情。切换到编辑器窗口中的MainActivity选项卡。 -
首先,我们想要实现
onClickListener接口来处理我们的按钮点击。为了实现这一点,将类声明更改为以下内容:public class MainActivity extends BaseGameActivity implements View.OnClickListener{ -
现在我们可以通过在类声明上右键单击,导航到添加 | 实现方法,然后单击确定,让 Android Studio 快速实现所需的
onClick方法。 -
在上一行代码之后,我们声明了四个新的按钮。在上一条代码之后添加以下代码:
//Our google play buttons Button llPlay; Button awardsLink; com.google.android.gms.common.SignInButton sign_in_button; Button sign_out_button; -
在
onCreate方法中,在调用setContent视图之后,我们使用LayoutInflater类的对象在SnakeAnimView之上加载我们的 UI。在调用setContentView之后添加高亮显示的代码:setContentView(snakeAnimView); //Load our UI on top of our SnakeAnimView LayoutInflater mInflater = LayoutInflater.from(this); View overView = mInflater.inflate(R.layout.activity_main, null); this.addContentView(overView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); -
在上一步的代码之后,我们可以获取所有按钮的引用,并以通常的方式监听点击事件:
//game services buttons sign_in_button = (com.google.android.gms.common.SignInButton)findViewById(R.id.sign_in_button); sign_in_button.setOnClickListener(this); sign_out_button = (Button)findViewById(R.id.sign_out_button); sign_out_button.setOnClickListener(this); awardsLink = (Button) findViewById(R.id.awardsLink); awardsLink.setOnClickListener(this); llPlay = (Button)findViewById(R.id.llPlay); llPlay.setOnClickListener(this); -
记住,在前面的指南中,我们覆盖了当我们扩展
BaseGameActivity类时继承的两个抽象方法。现在我们将向它们的实现中添加一些代码。代码非常直接。当登录失败时,我们隐藏注销按钮并显示登录按钮,当登录成功时,我们隐藏登录按钮并显示其他三个按钮。以下是这两个方法的全部内容。在方法中输入高亮显示的代码:@Override public void onSignInFailed() { // Sign in failed. So show the sign-in button. sign_in_button.setVisibility(View.VISIBLE); sign_out_button.setVisibility(View.GONE); } @Override public void onSignInSucceeded() { // show sign-out button, hide the sign-in button sign_in_button.setVisibility(View.GONE); sign_out_button.setVisibility(View.VISIBLE); llPlay.setVisibility(View.VISIBLE); awardsLink.setVisibility(View.VISIBLE); } -
现在我们处理
onClick方法以及玩家点击我们四个按钮中的任何一个时会发生什么。首先,我们编写我们的switch块代码。我们将在下一步中填写case语句:switch (v.getId()) { } -
在这里,我们处理登录按钮。我们简单地调用
beginUserInitiatedSignIn方法。这个方法在BaseGameActivity类中为我们实现。在上一步的switch块中输入以下代码:case R.id.sign_in_button: // start the sign beginUserInitiatedSignIn(); break; -
现在我们处理玩家注销时发生的情况。我们只需调用
signOut,这个方法在BaseGameActivity类中为我们实现。然后我们隐藏所有按钮并再次显示登录按钮。在上一条代码之后输入以下代码:case R.id.sign_out_button: // sign out. signOut(); // show sign-in button, hide the sign-out button sign_in_button.setVisibility(View.VISIBLE); sign_out_button.setVisibility(View.GONE); llPlay.setVisibility(View.GONE); awardsLink.setVisibility(View.GONE); break; -
接下来,我们处理玩家点击成就按钮时发生的情况。一行代码就为我们提供了所有成就功能。这正是面向对象编程(OOP)的精髓——别人的辛勤工作为我们做所有事情。在前面代码之后输入以下代码:
case R.id.awardsLink: startActivityForResult(Games.Achievements.getAchievementsIntent(getApiClient()), 0); break; -
最后,我们处理玩家点击排行榜按钮时发生的情况。同样,一行代码就为我们提供了排行榜的所有功能:
case R.id.llPlay: startActivityForResult(Games.Leaderboards.getLeaderboardIntent(getApiClient(), getResources().getString(R.string.leaderboard_snake)),0); break;
我们在编写代码的过程中解释了代码,但让我们总结一下:
-
我们设计了一个简单的用户界面。
-
我们在
SnakeAnimView之上加载了 UI。 -
我们获取了四个按钮的引用,并监听点击事件。
-
我们处理了当人们点击我们的按钮时会发生什么,这仅仅意味着根据需要隐藏和显示按钮,从
BaseGameActivity调用方法,并使用Intent类来实现我们所有的排行榜和成就功能。
你实际上可以运行蛇游戏并看到排行榜和成就屏幕。当然,在这个阶段,还没有人会有任何成就或高分。我们现在将解决这个问题。
在代码中实现排行榜
再次,我们将见证使用其他人精心设计的代码的简单性。诚然,到达这个阶段有一些复杂性,但一旦你设置好一切,那么你的下一款游戏将只需设置时间的一小部分:
-
我们希望在游戏结束时提交一个分数到
排行榜。Google Play 将处理检查是否是高分的过程。Google Play 甚至还会确定这是否是本周或月份的新高分。在代码编辑器窗口中打开GameActivity.java文件。 -
找到
updateGame方法,并在游戏结束时(当dead等于true)我们做的所有其他事情中添加高亮代码。我们只将一行代码包裹在一个检查中,以确保当前玩家已登录:if(dead){ if (isSignedIn()) { Games.Leaderboards.submitScore(getApiClient(), getResources().getString(R.string.leaderboard_snake), score); } -
就这样!构建游戏并在真实的 Android 设备上玩。你现在可以访问 Google Play 上的排行榜并查看你的高分。
非常简单。在这里,我们可以看到登录屏幕:

然后是欢迎信息和我们的成就和排行榜按钮,如下面的截图所示:

最后,我们可以看到只有一名玩家——我自己的新排行榜。

就算你好奇,我也能做得比 39 分更好。
在代码中实现成就
这个简短的教程将首先设置我们的游戏,以便发布苹果成就进度和蛇段长度一次性成就的增量更新:
-
在
GameActivity.java文件中,在类声明之后添加一个applesMunchedThisTurn变量,如下所示:public class GameActivity extends BaseGameActivity { int applesMunchedThisTurn; -
找到
updateGame方法。 -
添加一行代码来增加
applesMunchedThisTurn,每次苹果被吃掉时添加高亮代码,如下所示://Did the player get the apple if(snakeX[0] == appleX && snakeY[0] == appleY){ applesMunchedThisTurn++; //grow the snake snakeLength++; //replace the apple getApple(); //add to the score score = score + snakeLength; soundPool.play(sample1, 1, 1, 0, 0, 1); } -
注意,我们将这条高亮显示的行放在玩家死亡时执行的代码中(
if(dead)块)。我们本可以在玩家吃苹果时做这件事,但如果每次玩家吃苹果时都向 Google Play 服务器发送五条消息,我们可能会冒 Google 将其视为垃圾邮件的风险。我们只需将每个成就增加已吃苹果的数量,然后将applesMunchedThisTurn变量重置为零。我们用检查玩家是否已登录以及applesMunchedThisTurn是否大于零的检查来包装我们的成就方法调用。现在添加高亮显示的代码:if(dead){ //start again if (isSignedIn()) if(applesMunchedTisTurn > 0){//can't increment zero Games.Achievements.increment(getApiClient(), getResources().getString(R.string.achievement_apple_muncher_1), applesMunchedThisTurn); Games.Achievements.increment(getApiClient(), getResources().getString(R.string.achievement_apple_muncher_2), applesMunchedThisTurn); Games.Achievements.increment(getApiClient(), getResources().getString(R.string.achievement_apple_muncher_3), applesMunchedThisTurn); Games.Achievements.increment(getApiClient(), getResources().getString(R.string.achievement_apple_muncher_4), applesMunchedThisTurn); Games.Achievements.increment(getApiClient(), getResources().getString(R.string.achievement_apple_muncher_5), applesMunchedThisTurn); applesMunchedThisTurn = 0; }//end if(applesMunchedThisTurn > 0) Games.Leaderboards.submitScore(getApiClient(), getResources().getString(R.string.leaderboard_snake),score); }//end if(isSignedIn) soundPool.play(sample4, 1, 1, 0, 0, 1); score = 0; getSnake(); } } -
现在我们将处理段长度成就。在
updateGame方法中,在玩家吃苹果时执行的代码部分,紧接在增加snakeLength的代码行之后,我们检查是否有任何长度值得获得超级蛇成就。当达到期望的长度(5、10、25、35 或 50 个段)时,我们请求 Google Play 授予它(如果尚未授予)。我们用检查玩家是否已登录以及至少吃了一个苹果的检查来包装我们的成就方法调用。下面是添加的新代码://grow the snake snakeLength++; if (isSignedIn()){ if(applesMunchedThisTurn > 0) {//can't increment by zero //Are we long enough for a new SuperSnake achievement? if(snakeLength == 5){ Games.Achievements.unlock(getApiClient(), getResources().getString(R.string.achievement_super_snake_1)); } if(snakeLength == 10){ Games.Achievements.unlock(getApiClient(), getResources().getString(R.string.achievement_super_snake_2)); } if(snakeLength == 25){ Games.Achievements.unlock(getApiClient(), getResources().getString(R.string.achievement_super_snake_3)); } if(snakeLength == 35){ Games.Achievements.unlock(getApiClient(), getResources().getString(R.string.achievement_super_snake_4)); } if(snakeLength == 50){ Games.Achievements.unlock(getApiClient(), getResources().getString(R.string.achievement_super_snake_5)); } } -
就这样!你现在可以玩游戏并赚取成就:
![在代码中实现成就]()
再次,这很简单。你可能已经看到了如何实现本章中讨论的所有其他成就想法。让我们继续前进,并在 Google Play 上更新我们的游戏。
将更新的 Snake 游戏上传到 Google Play
这很简单,操作如下:
-
首先,我们需要让 Google Play 知道这是一个新版本。我们通过更改版本号来做这件事。打开
Build.gradle文件,找到以下代码行:versionCode 1 versionName "1.0" Change them to the following: versionCode 2 versionName "1.1" -
按照常规方式构建你的 APK。
-
登录到你的开发者控制台。
-
点击Snake Game 1.0,然后点击APK,然后点击上传新 APK 到生产。
-
前往你刚刚更新的 APK。
-
在本版本新增内容字段中,输入
Added leaderboards and achievements。 -
点击立即发布到生产。
从现在开始,所有下载你的游戏的人都将获得更新版本。随着我们的第一款游戏,包括精灵表动画、排行榜和成就,现在是时候休息一下,做一些理论研究了。
接下来是什么?
你应该为到目前为止的创造感到自豪,尤其是如果你这是第一次尝试编程。如果某些概念、语法或项目仍然不清楚,那么在休息后考虑重新审视它们。
我们还没有讨论的是,为了进一步进步,我们需要掌握的更多新技能。这完全取决于你最初阅读这本书的动机。
获得程序员的工作
如果你想要成为一名 Java 员工,也就是说,在一家中型或大型公司全职以专业能力工作,那么你可能需要一个大学学位,这本书希望让你对编程和 Java 本身的世界有了一个初步的了解。如果你是这样的情况,那么为了进一步学习,你可以考虑一本更正式的 Java 书籍,然后是一本关于面向对象分析和设计的纯 OOP 书籍。然后你可以继续学习设计模式。
一些最适合这些类别的最佳书籍包括《Head First Object-Oriented Analysis and Design: A Brain Friendly Guide to OOA&D》,作者 Brett McLaughlin 和 Gary Pollice;《Head First Design Patterns》;《Eric Freeman 和 Elisabeth Robson》,《O'Reilly》;以及《Design Patterns CD: Elements of Reusable Object-Oriented Software》,作者 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides,*《Addison Wesley》。前两本书非常适合初学者。后一本书备受推崇,但对于初学者来说阅读起来更具挑战性。
我的猜测是,你之所以没有选择关于游戏和 Java 的初学者书籍,很可能是因为你正朝着那个方向发展,所以让我们考虑一下到目前为止的压轴之作——我们的贪吃蛇游戏。
打造更大更好的游戏
如果你把我们的贪吃蛇游戏与现代的专业游戏相比,即使是二维游戏,更不用说现代的大预算第一人称射击游戏(FPS),那么我们还有很多东西要学习。让我们考虑一下我们的贪吃蛇游戏与专业游戏相比的一些不足之处。
想想我们的花朵和尾巴动画。它们之所以有效,是因为我们在controlFPS方法中设置了一个粗略的时间系统。但如果我们有十几个或更多的游戏对象需要动画怎么办?
如果它们都有不同的帧数和帧率怎么办?如果一些动画需要循环播放,而另一些动画需要在重新开始之前逐帧倒放,我们还可以进一步复杂化这些事情。
现在想象一个需要跳跃的角色。我们如何同步玩家跳跃时恰好显示的任何帧?
实际上,所有这些问题以及更多都可以通过快速的网络搜索和一些学习来解决。关键是事情开始变得相当复杂,而我们只讨论了动画。
关于物理呢?当物体弹跳时,我们未来的游戏中的物体会如何表现?我们之所以能在我们的 Squash 游戏中作弊,是因为环境和对象很少且简单。如果球是圆的,而且有很多不同大小和形状的物体,有些移动得很快,有些是静止的,我们该如何模拟这个物理模型?
再次强调,所有这些答案都在那里,但它们增加了复杂性。那么其他环境因素,如光和阴影呢?当我们的屏幕需要左右滚动时会发生什么?上下滚动呢?
现在考虑所有这些问题,并想象在一个虚拟的三维世界中实施解决方案。再次强调,解决方案就在那里,但一个有决心的初学者可能需要数月时间才能使用三维计算中涉及的原始数学来实现自己的解决方案。
接下来,想象一下,你希望你的新三维、基于物理、动画出色的游戏能够在 Android、Apple 和 PC 平台上运行。
如果我已经让你对这些解决方案感到气馁,但你又渴望找到答案,那么我的建议是无论如何都要去寻找。这肯定是一次令人着迷的旅程,并使你成为一个更好的游戏开发者。然而,在出于好奇心、自我提升或娱乐之外的原因实施任何这些内容之前,请三思。
原因在于我们并不是第一个遇到这些问题的人——这些解决方案已经得到了实施。而且你知道吗?我们可以免费使用这些解决方案。
例如,有一个名为 OpenGL 的库,它的唯一目的是在三维坐标系中绘图。它有你需要的一切类和方法。甚至还有一个适用于移动设备的 OpenGL 版本,称为 OpenGL ES,你可以用 Java 来编程。OpenGL 确实有一些自己的复杂性,但它们可以通过从易到难的逻辑和直接的方式学习。
如果你已经通过这本书走过了这一段路程,那么请快速回顾一下第六章, 面向对象编程 – 利用他人的辛勤工作,然后获取一份K. Brothaler的OpenGL ES2 for Android,Pragmatic Bookshelf的副本。这本书探讨了代码库及其背后的某些数学知识,因此它应该能满足好奇的读者和纯粹实用型读者的需求。或者,你可以在www.learnopengles.com/查看大量的免费教程。
如果你只是想制作更多的游戏,并且并不特别关心三维特性,那么下一个合乎逻辑的步骤就是使用 Java 的游戏库。有很多选择,但特别有一个使用纯 Java 在 Android、iPhone、PC 和网络上构建游戏。
事实上,你可以构建一个 Java 游戏,它几乎可以在世界上任何设备上运行,甚至是一个网页。它还包含简化上述 OpenGL ES 使用的类。这个库叫做 LibGDX,我在跟随Learning Libgdx Game development (www.packtpub.com/game-development/learning-libgdx-game-development)制作平台游戏时玩得很开心。LibGDX 还解决了我们的所有动画、滚动和物理难题,而无需任何数学知识,尽管它并没有真正解决三维特性问题。
小贴士
注意,这两本书都有一些相当深入的面向对象编程(OOP),但如果你理解了 第六章,OOP – 使用他人的辛勤工作,并且决心要学,这并不是遥不可及的。
如果你想要立即进入 3D 领域,那么 Unreal Engine 是一个真正有趣的选择。Unreal Engine 被用于许多大预算游戏,并且可能涉及另一种编程语言的巨大复杂性。然而,对于在 GUI 开发环境中制作二维和三维游戏来说,它可能是无与伦比的。Unreal Engine 4 使用一个名为 Blueprint 的系统,你可以拖放类似流程图的元素,而不是编写代码。它仍然使用所有面向对象编程的概念以及循环和分支,但你可以在不写一行“真实”代码的情况下做很多事情。看看没有一行代码创建的 Unreal Engine 版本的 Flappy Bird,在 play.google.com/store/apps/details?id=com.epicgames.TappyChicken。
Unreal Engine 也可以为多个平台构建游戏,但遗憾的是,需要支付小额的月费,而且最限制性的条件是,你制作的任何商业项目都将受到协议的约束。在这里,你需要支付给 Epic Games 30% 的费用,但对于学习和娱乐来说,可能没有比这更好的选择了。
或者,看看我的博客 (www.gamecodeschool.com),我在那里定期添加文章和针对初学者到中级游戏程序员的有趣游戏构建指南。我的博客讨论了许多不同的编程语言、目标平台、之前提到的所有工具等等。
自测问题
Q1) 尝试在设备上实现本地高分记录。
Q2) 在这本书的代码中,有多少杰出的计算机科学家有客串出现?
Q3) 作为最后的挑战,尝试在我的 Snake 领跑榜上击败我的高分。
摘要
在本章中,我们涵盖了大量的内容。我们在 Google Play 上发布了我们的 Snake 游戏。然后我们添加了一些在线排行榜和成就。我们还更新了我们的发布。这个过程展示了如何使用 API 将非常复杂的任务,如互联网通信,变得非常简单。
在完成这本书的最后润色工作时,我观看了一段 John Carmack 的 YouTube 讲座视频,他是一位软件传奇人物。他是 1995 年 6 月发布的 Doom 游戏的关键工程师。当他解释说在学校时,他觉得自己错过了技术革命,等到他足够大可以工作时,一切都已经结束,我和他的观众都笑了。
当然,许多技术革命已经到来,许多已经消失。至少,许多早期采用者的机会已经消失。John Carmack 解释说,总会有另一场革命即将到来。
所以你可能将要发展你的技能,并关注下一个大事件。或者,你可能只是想用任何语言为任何平台编写一些有趣的游戏程序。
我希望你喜欢我们通过 Android 和 Java 的旅程,并且你也会继续这段旅程。我真诚地祝愿你无论选择哪条道路,未来都能一切顺利。随时来分享你的经验和知识,网址是www.gamecodeschool.com。这本书的完美续集将在 2015 年中出版,名为《Android 游戏编程实例》。
附录 A. 自测题和答案
在这里,我们提供了一些你可以问自己的问题,以查看你是否理解了每一章。别担心!答案也包括在内。
第二章
Q1) 如果关于生命周期、类和方法的讨论让你感到有些困惑,你应该怎么做?
A) 别担心它们。理解是一点一点来的,如果它们在这个阶段并不完全清楚,这不会阻碍你彻底学习 Java,随着我们的进展,一切都会变得清晰。
Q2) Java 类究竟是什么?
A) 类是 Java 程序的基本构建块。它们就像我们 Java 代码的容器,我们甚至可以使用其他人的类来简化我们编写的程序,即使我们没有看到或理解这些类中包含的代码。
Q3) 方法和类之间的区别是什么?
A) 方法包含在类中,并代表类的特定功能,就像容器中的另一个容器。以游戏为例,我们可能有一个Tank类,它有shoot、drive和selfDestruct方法。我们可以通过创建自己的类来使用类及其方法,就像我们在第六章中将要做的,或者通过使用@import语句,就像我们在本章前面做的那样。
Q4) 看看 Android 开发者网站及其对生命周期阶段的更技术性解释,在developer.android.com/reference/android/app/Activity.html。你能看到我们还没有讨论的阶段及其相关方法吗?它会在应用中何时被触发?活动从创建到销毁的精确路径是什么?
A) 它是重启阶段。它对应的方法是onRestart。当一个应用被停止然后重新启动时,它会触发。在这本书中,我们可能不需要onRestart方法,但这个练习可能有助于阐明生命周期的概念。精确的路径可能会有所不同;我们只需要处理与我们的游戏相关的阶段。到目前为止,我们只是对onCreate进行了修改。
第三章
Q1) 这段代码做了什么?
// setContentView(R.layout.activity_main);
A) 没有区别,因为它被注释掉了,用//。
Q2) 哪一行代码会导致错误?
String a = "Hello";
String b = " Vinton Cerf";
int c = 55;
a = a + b
c = c + c + 10;
a = a + c;
c = c + a;
A) 第四行,a = a + b,没有分号,所以会导致错误。最后一行,c = c + a;,也会导致错误,因为你不能将字符串赋值给 int 类型的值。
Q3) 我们讨论了很多关于运算符的内容,以及不同的运算符如何组合在一起构建复杂的表达式。从表面上看,表达式有时会使代码看起来很复杂。然而,仔细观察后,它们并没有看起来那么困难。通常,只需要将表达式拆分成更小的部分,以便弄清楚发生了什么。这里有一个比本书中任何其他表达式都复杂的表达式。作为一个挑战,你能计算出 x 将会是多少吗?
int x = 10;
int y = 9;
boolean isTrueOrFalse = false;
isTrueOrFalse = (((x <=y)||(x == 10))&&((!isTrueOrFalse) || (isTrueOrFalse)));
A) 你可以在代码包的 Chapter3 文件夹中运行 SelfTestC3Q3 项目,在控制台中检查答案,但 isTrueOrFalse 评估为 true;原因如下。
首先,让我们将这个讨厌的行分解成由括号定义的可管理的部分:
((x <=y)||(x == 10))
之前,我们是在问,“x 是否小于或等于 y,或者 x 是否正好等于 10?”显然,x 不等于也不小于 y,但 x 正好等于 10,所以我们在中间使用逻辑或运算符 || 导致整个表达式的部分评估为 true。
&&
一个 && 运算符的两边都必须评估为 true,整个表达式才为 true。那么让我们看看另一边:
((!isTrueOrFalse) || (isTrueOrFalse)))
好吧,isTrueOrFalse 是一个布尔值。它只能是 true 或 false,所以这个表达式的这部分必须是 true,因为我们实际上是在问:“isTrueOrFalse 是 false 还是 true?”它必须是其中一个。所以,无论我们如何初始化 isTrueOrFalse,表达式的最后一部分都将为 true。
因此,整个表达式评估为 true,并将 true 赋值给 isTrueOrFalse。
第四章
Q1) 这个方法有什么问题?
void doSomething(){
return 4;
}
A) 它返回一个值,但具有 void 返回类型。
Q2) 在这段代码片段结束时,x 将等于多少?
int x=19;
do{
x=11;
x++;
}while(x<20)
A) 好吧,这是一个有点棘手的问题。无论 x 的值如何,do 块总是至少执行一次。然后 x 被设置为 11,之后它被增加到 12。所以当 while 表达式被评估时,它是 true,do 块再次执行。再次,x 被设置为 11,然后增加到 12。程序陷入了一个永无止境的(无限)循环。这段代码很可能是错误。
第五章
Q1) 假设我们想要进行一个测验,问题可以是关于总统的名字、首都等等。我们如何使用多维数组来完成这个任务?
A) 我们只需让内部数组包含三个字符串,可能像这样:
String[][] countriesCitiesAndPresidents;
//now allocate like this
countriesAndCities = new String[5][3];
//and initialize like this
countriesCitiesAndPresidents [0][0] = "United Kingdom";
countriesCitiesAndPresidents [0][1] = "London";
countriesCitiesAndPresidents [0][3] = "Cameron";//at time of writing
Q2) 在我们的持久化示例中,我们将一个持续更新的字符串保存到文件中,以便在应用程序关闭并重新启动后持久化。这就像要求用户点击保存按钮一样。回顾一下你对第二章,Android 入门的所有知识,你能想出一种方法来保存字符串,而无需通过按钮点击,只需当用户退出应用程序时?
A) 覆盖onPause生命周期方法,并将保存字符串的代码放在那里,如下所示:
@Override
protected void onPause() {
editor.putString(stringName, currentString);
editor.commit();
}
Q3) 除了提高难度级别,我们还能如何使记忆游戏更难?
A) 我们可以简单地改变线程执行中的暂停时间,提到一个更小的数字,给玩家更少思考的时间,如下所示:
myHandler.sendEmptyMessageDelayed(0, 450);
//This halves the players thinking time
Q4) 使用朴素的 Android UI 和单调的灰色按钮并不令人兴奋。看看视觉设计器中的 UI 元素。你能想出如何使用图片作为我们的按钮背景吗?
A) 只需将一些.png图形添加到drawable-mdpi文件夹中,然后在按钮被选中时找到属性窗口中的背景属性。以通常的方式单击编辑属性并选择添加到drawable-mdpi文件夹中的图形。
第六章
Q1) 封装是什么?
A) 封装是我们将变量、代码和方法打包的方式,只向我们的应用程序(或使用我们类的任何应用程序)的特定部分和功能暴露我们想要的那些部分。
Q2) 我不明白这些,实际上,我现在比章节开始时的问题更多。我该怎么办?
A) 你对 OOP 的了解足以在游戏和其他类型的 Java 编程中取得重大进展。如果你现在迫切想了解更多 OOP,有很多高度评价的书籍专门讨论 OOP。然而,实践和对语法的熟悉将有助于达到同样的效果,并且可能会更有趣。你是否现在匆忙学习 OOP 的复杂细节,真正取决于你的个人目标和你在未来想用你的编程技能做什么。阅读第九章的最后几页,让你的游戏成为下一个大热门,了解更多讨论。
第七章
Q1) 球的速度是以像素计算的。不同设备有不同的像素数。你能解释如何使球的速度在不同屏幕分辨率上大致相同吗?
A) 适应不同屏幕分辨率的一个简单方法就是设计一个考虑屏幕像素数的系统。我们已经为球拍和球的大小做了这件事。我们可以声明一个成员变量,如下所示:
int pixelsPerFrameX;
int pixelsPerFrameY;
我们可以在获得屏幕尺寸后,在onCreate方法中初始化这些变量:
pixelsPerFrameX = screenWidth/50;
pixelsPerFrameY = screenHeight/50;
然后我们可以稍微移动球,如下所示:
//moving in adjust our x any positions
if (ballIsMovingDown) {
ballPosition.y += pixelsPerFrameX;
}
//etc...
第八章
Q1) 关于我们游戏屏幕的视觉改进,或许是一个漂亮的浅绿色草地背景,而不是仅仅黑色?
A) 你可以使用大多数图形程序,如 Gimp 或 Photoshop,来获取漂亮的浅绿色草地颜色的 RGB 值。或者,你可以使用在线颜色选择器,如www.colorpicker.com/。然后看看我们drawGame方法中的这一行:
canvas.drawColor(Color.BLACK);//the background
改成以下行:
canvas.drawColor(Color.argb(255,186,230,177));//the background
Q2) 在背景中添加一些漂亮的花朵怎么样?
A) 这是这样做的方法。创建一个花朵位图(或使用我的),加载它,并在configureDisplay方法中以通常的方式缩放它。决定要绘制多少朵花。在SnakeView构造函数中选择并存储板上的位置(或编写并调用一个特殊的方法,可能是plantFlowers)。
在drawGame方法中在蛇和苹果之前绘制它们。这将确保它们永远不会隐藏苹果或蛇的一部分。你可以在提到的方法中看到我的具体实现,以及EnhancedSnakeGame项目中的Chapter8文件夹中的花朵位图副本。
Q3) 如果你感到勇敢,让花朵摇摆。想想精灵图集。理论与动画蛇头的理论完全相同。我们只需要几行代码来控制帧率,而不是游戏帧率。
A) 看看controlFPS方法中的新代码。我们只是为花朵动画设置了一个新的计数器,以便每六帧游戏帧切换一次花朵帧。你也可以从Chapter8文件夹中的EnhancedSnakeGame项目复制精灵图集。
Q4) 我们可以设置另一个计数器并使用我们的蛇头动画,但这不会很有用,因为由于尺寸较小,微妙的舌头动作几乎看不见。尽管如此,我们相当容易地摆动尾巴段。
A) 在EnhancedSnakeGame项目的Chapter8文件夹中有一个两帧的尾巴位图。由于这也是两帧,我们可以使用与花朵相同的帧计时器。看看EnhancedSnakeGame项目中的Chapter8文件夹中的实现。唯一需要更改的是configureDisplay和drawGame。
Q5) 这里是一个稍微有点棘手的增强。你无法不注意到,当蛇精灵朝向四个可能方向中的三个时,它们看起来并不正确。你能修复这个问题吗?
A) 我们需要根据它们的朝向来旋转它们。Android 有一个Matrix类,它允许我们轻松地旋转位图,Bitmap类有一个重载的createBitmap方法,它接受一个Matrix对象作为参数。
因此,我们可以为每个需要处理的角创建一个矩阵,如下所示:
Matrix matrix90 = new Matrix();
matrix90.postRotate(90);
然后,我们可以使用以下代码旋转位图:
rotatedBitmap = Bitmap.createBitmap(regularBitmap , 0, 0, regularBitmap .getWidth(), regularBitmap .getHeight(), matrix90, true);
另一个问题是我们如何跟踪蛇的每个部分的独立方向?我们已经有了一个寻找方向的方案:0 是向上,1 是向右,以此类推。因此,我们只需为每个部分创建另一个数组,以对应snakeX和snakeY数组中的身体部分。然后我们只需要确保头部有正确的方向,并在每一帧从后向前更新,就像我们更新蛇的坐标一样。你可以在Chapter8文件夹中的EnhancedSnakeGame项目中看到这个实现。
经过一些增强后的完成项目位于Chapter8文件夹中的EnhancedSnakeGame项目中。这是我们将在下一章和最后一章中作为起点使用的版本。你还可以从 Google Play 下载游戏,链接为play.google.com/store/apps/details?id=com.packtpub.enhancedsnakegame.enhancedsnakegame。


第九章
Q1) 尝试在设备上实现本地高分。
A) 你已经知道如何做这件事了。如果你不确定,只需回到第五章,游戏与 Java 基础。这个章节的项目代码中也有实现。
Q2) 在这本书的代码中,有多少著名的计算机科学家在代码中客串出现?
A) 9
艾达·洛夫莱斯
查尔斯·巴贝奇
阿兰·图灵
文顿·瑟夫
杰夫·米纳
科琳·尤
安德烈·拉莫斯
加布·纽维尔
西德·梅尔
为什么不在网上搜索这些名字呢?关于他们每个人都有一些有趣的故事。
第二部分。模块 2
Android 游戏编程实例
通过构建三个沉浸式和引人入胜的游戏来利用 Android SDK 的力量
第一章。玩家 1 UP
旧式街机和弹球机使用的术语“1 UP”是对玩家的一种通知,告诉他们现在正在玩游戏(上升)。它也被用来表示获得额外生命。你准备好构建三个伟大的游戏了吗?
我们将一起构建三个酷炫的游戏。这本书展示了这三个游戏的每一行代码;你永远不需要参考代码文件来了解发生了什么。此外,构建所有三个游戏所需的整个文件集包含在可以从 Packt 网站书籍页面获取的下载捆绑包中。
所有代码、Android 清单文件以及图形和音频资源都包含在下载中。这三个酷炫的游戏在实现上越来越具有挑战性。
第一个项目使用一个简单但功能齐全的游戏引擎,清楚地展示了主游戏循环的基本要素。游戏将具备主屏幕、高分、音效和动画等功能。但随着项目的进行,当我们添加功能和尝试平衡游戏玩法时,我们很快就会看到我们需要更多的灵活性来添加功能。
在第二个项目中,一个硬核复古平台游戏,我们将看到如何使用简单灵活的设计来构建一个相对快速且非常灵活的游戏引擎,该引擎可扩展且可重用。这种灵活性将使我们能够制作一个相当复杂且功能齐全的游戏。这个游戏将有多级、不同环境和更多内容。这反过来又突显了能够更快地绘制图形的需求。这让我们进入了第三个项目。
在第三个项目中,我们将构建一个类似 Asteroids 的游戏,称为 Asteroids 模拟器。尽管这个游戏不会像前一个项目那样有那么多功能,但它将具有超过 60 帧每秒的数百个动画游戏对象的超级平滑绘制。我们将通过了解和使用 嵌入式系统开放图形库(OpenGL ES 2)来实现这一点。
在这本书的结尾,你将拥有一整套设计理念、技术和代码模板,你可以在未来的游戏中使用。通过了解在 Android 上制作游戏的多种方式的优缺点,你将能够以最合适的方式成功设计和构建游戏。
深入了解游戏
快速浏览三个项目。
Tappy Defender
用一根手指飞得像 Flappy Bird 一样,到达你的家园星球,同时避开多个敌人。特色功能包括:
-
基本动画
-
主屏幕
![Tappy Defender]()
-
碰撞检测
-
高分
-
简单的 HUD
-
一指触摸屏控制
![Tappy Defender]()
艰难的复古平台游戏
这是一款真正难以击败的复古风格平台游戏。我们必须引导鲍勃从地下火洞穿过城市、森林,最终到达山区。它有四个挑战级别。特色功能包括:
-
更高级、更灵活的游戏引擎
-
更高级的“精灵表”角色动画
-
一个关卡构建器引擎,用于以文本格式设计您的关卡
-
多重滚动视差背景
-
关卡之间的过渡
-
更高级的 HUD
![艰难的复古平台游戏]()
-
添加大量额外的多样化关卡
-
声音管理器,轻松管理音效
-
拾取物品
-
可升级的枪械
-
搜索并摧毁敌方无人机
-
用于巡逻敌方守卫的简单 AI 脚本
-
如火山坑等危险
-
用于营造氛围的风景对象
![艰难的复古平台游戏]()
小行星模拟器
这是一款经典的射击游戏,具有复古矢量图形风格的视觉效果。它涉及使用快速射击枪清除平滑动画的旋转小行星的波浪。特色功能包括:
-
每秒 60 帧或更好,即使在旧硬件上
-
OpenGL ES 2 简介
-
难度递增的波浪射击游戏
-
高级多阶段碰撞检测
![小行星模拟器]()
设置您的开发环境
本书和下载包中的所有代码都将在您喜欢的 Android IDE 中运行。然而,我发现最新的 Android Studio 特别易于使用,代码也是在此环境中编写和测试的。
如果您目前不使用 Android Studio,我鼓励您尝试一下。以下是如何快速启动和运行的简要概述。本指南包括安装 Java JDK 的步骤,以防您是 Android 开发的初学者。
小贴士
如果您已经准备好了您偏好的开发环境,那么请直接跳转到第二章, Tappy Defender – 第一步。
我们需要做的第一件事是为使用 Java 开发 Android 准备您的 PC。幸运的是,这对我们来说变得相当简单。
小贴士
如果您在 Mac 或 Linux 上学习,本书中的所有内容仍然适用。接下来的两个教程包含 Windows 特定的说明和截图。然而,根据 Mac 或 Linux 进行轻微的步骤调整应该不会太难。
我们需要做的只是:
-
安装Java 开发工具包(JDK),它允许我们使用 Java 进行开发。
-
然后安装 Android Studio,使 Android 开发变得快速且简单。Android Studio 使用 JDK 和一些其他特定于 Android 的工具,这些工具在我们安装 Android Studio 时会自动安装。
安装 JDK
我们需要做的第一件事是获取 JDK 的最新版本。为了完成本指南,请执行以下步骤:
-
我们需要访问 Java 网站,因此请访问:
www.oracle.com/technetwork/java/javase/downloads/index.html。 -
找到此处显示的三个按钮,并点击以下图像中突出显示的 JDK 按钮。它们位于网页的右侧。然后,点击 JDK 选项下的 下载 按钮:
![安装 JDK]()
-
您将被带到具有多个选项下载 JDK 的页面。在 产品/文件描述 列表中,您需要点击与您的操作系统匹配的选项。Windows、Mac、Linux 以及一些其他不太常见的选项都列出了。
-
这里常问的一个问题是,我是否有 32 位或 64 位 Windows?要找出答案,请右键单击您的 我的电脑 图标(Windows 8 上的 此电脑),点击 属性 选项,然后在 系统 标题下查看 系统类型 条目:
![安装 JDK]()
-
点击稍显隐藏的 接受许可协议 复选框:
![安装 JDK]()
-
现在,点击 为您的操作系统下载 并输入之前确定的版本。等待下载完成。
-
在您的
下载文件夹中,双击您刚刚下载的文件。截至撰写本文时,64 位 Windows PC 的最新版本是jdk-8u5-windows-x64。如果您使用 Mac/Linux 或 32 位操作系统,您的文件名将相应变化。 -
在几个安装对话框中的第一个对话框中,点击 下一步 按钮,您将看到以下对话框:
![安装 JDK]()
-
通过点击 下一步 接受前一个图像中显示的默认设置。在下一个对话框中,您可以通过点击 下一步 接受默认的安装位置。
-
接下来是 Java 安装程序的最后一个对话框;为此,点击 关闭。
注意
JDK 已安装。接下来,我们将确保 Android Studio 能够使用 JDK。
-
右键单击您的 我的电脑 图标(Windows 8 上的 此电脑),然后点击 属性 | 高级系统设置 | 环境变量... | 新...(在 系统变量 下,不在 用户变量 下)。现在,您可以看到 新系统变量 对话框:
![安装 JDK]()
-
在 变量名 为 JAVA_HOME 的字段中输入,并在 变量值 字段中输入
C:\Program Files\Java\jdk1.8.0_05。如果您在其他位置安装了 JDK,那么您在 变量值 字段中输入的文件路径需要指向您放置它的位置。您的确切文件路径可能以不同的结尾结束,以匹配您下载时的最新 Java 版本。 -
点击 确定 保存您的新的设置。
-
现在在 系统变量 下,点击 路径,然后点击 编辑... 按钮。在 变量值 字段中的文本末尾,输入以下文本以将我们的新变量添加到 Windows 将使用的文件路径中,
;JAVA_HOME。务必不要遗漏开头的分号。 -
点击 确定 保存更新的 路径 变量。
-
现在,再次点击确定以清除高级系统设置对话框。
JDK 现在已安装在我们的 PC 上。
安装 Android Studio
不拖延,让我们立即安装 Android Studio,然后我们可以开始我们的第一个游戏项目。访问:
developer.android.com/sdk/index.html
-
点击标有下载 Android Studio for Windows的按钮开始下载 Android Studio。这将带您进入另一个网页,其中有一个与您刚才点击的按钮非常相似的按钮。
-
通过勾选复选框接受许可协议,并通过点击标有下载 Android Studio for Windows的按钮开始下载,等待下载完成。
-
在您刚刚下载 Android Studio 的文件夹中,右键单击
android-studio-bundle-135.12465-windows.exe文件,并点击以管理员身份运行。您的文件名末尾将根据 Android Studio 的版本和您的操作系统而有所不同。 -
当被问及是否允许来自未知发布者的以下程序更改您的计算机时,点击是。在下一屏幕上,点击下一步。
-
在此处显示的屏幕上,您可以选择哪些 PC 用户可以使用 Android Studio。选择适合您的选项,因为所有选项都将正常工作,然后点击下一步:
![安装 Android Studio]()
-
在下一个对话框中,保留默认设置,然后点击下一步。
-
在选择开始菜单文件夹对话框中,保留默认设置并点击安装。
-
在安装完成对话框中,点击完成以首次运行 Android Studio。
-
下一个对话框是为已经使用过 Android Studio 的用户准备的,所以假设您是第一次用户,请选择我没有先前的 Android Studio 版本或我不想导入我的设置复选框。然后点击确定:
![安装 Android Studio]()
那是我们需要的最后一款软件。我们将在下一章直接开始使用 Android Studio。
摘要
本章故意保持尽可能短,这样我们就可以开始构建一些游戏。我们现在就这样做。
第二章 Tappy Defender – 第一步
欢迎来到第一个游戏,我们将在接下来的三章中学习它。在本章中,我们将仔细检查最终产品的目标。如果我们确切地知道我们想要实现什么,这对构建游戏非常有帮助。
然后,我们可以查看我们代码的结构,包括我们将遵循的大致设计模式。然后,我们将组装我们第一个游戏引擎的代码框架。最后,为了完成本章,我们将从游戏中绘制第一个真实对象并在屏幕上对其进行动画处理。
我们将准备好进入第三章,Tappy Defender – Taking Flight,在那里我们可以在完成第四章,Tappy Defender – Going Home中的第一个游戏之前取得真正的进展。
规划第一个游戏
在本节中,我们将详细说明我们的游戏将是什么样子。背景故事;我们的英雄是谁,他们试图实现什么?游戏机制;玩家实际上会做什么?他会按哪些按钮,以及这种方式是如何成为一种挑战或有趣的事情的?然后,我们将研究规则。胜利、死亡和进步由什么构成?最后,我们将变得技术性,开始研究我们实际上将如何构建游戏。
背景
瓦莱丽自 20 世纪 80 年代初以来一直在保卫人类的前哨阵地。她的勇敢事迹最初在 1981 年的街机经典游戏《Defender》中被永久记录。然而,在 30 多年的前线战斗后,她即将退休,是时候开始回家的旅程了。不幸的是,在最近的一次小冲突中,她的飞船引擎和导航系统严重受损。因此,现在她必须仅靠她的助推器飞回家。
这意味着她必须通过同时向上和向前推力来驾驶她的飞船,有点像弹跳,同时避开试图撞向她的敌人。在一次与地球的最近通信中,瓦莱丽声称这就像“试图驾驶一只跛脚的鸟”。这是瓦莱丽在她的受损飞船中的概念艺术,因为它有助于尽早可视化我们的游戏。

现在我们已经对我们的英雄和她所处的困境有了一些了解,我们更仔细地看看游戏的机制。
游戏机制
机制是玩家必须执行并变得擅长以能够打败游戏的关键动作。在设计游戏时,你可以依赖经过验证和测试的机制想法,或者你可以发明自己的。在 Tappy Defender 中,我们将使用一种机制,即玩家轻触并保持屏幕以助推飞船。
这种助推将使飞船上升屏幕,但也会使飞船加速,因此更容易受到攻击。当玩家移开手指时,助推器将关闭,飞船将向下坠落并减速,从而使飞船稍微不那么容易受到攻击。因此,需要非常精细和精湛的助推与不助推之间的平衡,才能生存下来。
Tappy Defender 当然深受 Flappy Bird 及其众多类似游戏成功的影响。
与 Flappy Bird 那样的得分系统不同,Tappy Defender 的目标是到达“家”。然后,玩家可以多次重玩游戏,以尝试打破他们最快的记录。当然,为了更快,玩家必须更频繁地助推,并将瓦莱丽置于更大的危险之中。
注意
在不太可能的情况下,如果你从未玩过或见过《Flappy Bird》,现在花 5 分钟玩这种类型的游戏是非常值得的。你可以从 Google Play 商店下载许多受《Flappy Bird》启发的应用程序:
play.google.com/store/search?q=flappy%20bird&c=apps
游戏规则
在这里,我们将定义一些平衡游戏并使其对玩家公平和一致的事物:
-
玩家的飞船比敌人的飞船更坚固。这是因为玩家的飞船有护盾。每次玩家与敌人碰撞时,敌人会立即被摧毁,但玩家会失去一个护盾。玩家有三个护盾。
-
玩家需要飞行一定数量的公里才能到达家中。
-
每当玩家到达家中,他们就会赢得游戏。如果他们的时间是最快的,他们也会获得一个新的最快时间,就像高分一样。
-
敌人将在屏幕最右侧的随机高度出现,并以随机速度向玩家飞来。
玩家始终位于屏幕的远左侧,但加速会使敌人更快接近。
设计
我们将使用宽松的设计模式,我们将根据控制部分、模型部分和视图来分离我们的代码。这就是我们将代码分成三个区域的方法。
控制
这是控制我们代码其他部分的代码部分。它将决定何时显示视图,它将从模型初始化所有我们的游戏对象,并且它将根据模型中的数据状态提示决策。
模型
模型是我们的游戏数据和逻辑。船看起来是什么样子?我们的船在屏幕上的位置在哪里?它们移动得多快,等等。此外,我们代码中的模型部分是每个游戏对象的智能系统。尽管我们游戏中的敌人没有复杂的 AI,但它们会知道并自行决定移动的速度,何时重生等等。
视图
视图正如其名。这是我们代码的一部分,将根据模型的状态进行实际绘制。当控制代码的一部分告诉它时,它将绘制。它不会对游戏对象有任何影响。例如,视图不会决定对象的位置或其外观。它只是绘制,然后把控制权交回控制代码。
设计模式现实检查
事实上,这种分离并不像讨论中提到的那么清晰。实际上,绘制和控制代码位于同一个类中。然而,你将看到,绘制和控制的逻辑在那个类中是分开的。
通过将我们的游戏分成这三个部分,我们将看到我们如何简化开发并避免陷入混乱的代码,这些代码随着我们向游戏中添加新功能而不断扩展。
让我们更仔细地看看这个模式在我们代码中的位置。
游戏代码结构
首先,我们必须考虑我们正在工作的系统。在这种情况下,是 Android 系统。如果您已经制作了一段时间的 Android 应用,您可能会想知道这种模式与 Android Activity 生命周期有什么关系。如果您是 Android 新手,您可能会问 Activity 生命周期是什么。
Android Activity 生命周期
Android Activity 生命周期是我们必须在其中工作的框架,以制作任何类型的 Android 应用。有一个名为 Activity 的类,我们必须从中派生出来,并且它是我们应用的入口点。此外,我们需要意识到这个类,即我们的游戏是一个对象,也有一些我们可以重写的方法。这些方法控制着我们的应用生命周期。
当用户启动应用时,我们的 Activity 对象被创建,并且会按顺序调用我们可以重写的一系列方法。这就是发生的事情。
当 Activity 对象被创建时,会按顺序调用三个方法;onCreate()、onStart() 和 onResume()。此时,应用现在正在运行。此外,当用户退出应用或应用被中断,例如被电话呼叫,会调用 onPause 方法。用户可能会决定,也许在完成电话通话后,返回到应用。如果发生这种情况,会调用 onResume 方法,随后应用再次运行。
如果用户没有返回到应用或 Android 系统决定它需要为其他事情使用系统资源,将调用两个进一步的方法来清理。首先 onStop(),然后 onDestroy()。现在应用已被销毁,任何尝试再次返回到游戏的行为都将导致 Activity 生命周期从头开始。
作为游戏程序员,我们只需要意识到这个生命周期并遵守一些良好的管理规则。随着我们的进行,我们将实现并解释这些良好的管理规则。
注意
Android Activity 生命周期比我刚才解释的要复杂得多,也更为微妙。然而,我们已经知道了一切,足以开始编写我们的第一个游戏。如果您想了解更多信息,请查看 Android 开发者网站上的这篇文章:
developer.android.com/reference/android/app/Activity.html
一旦我们处理了 Android Activity 生命周期,我们代表模式控制部分的类的核心方法将像这样简单:
-
更新我们游戏对象的状态。
-
根据它们的状态绘制游戏对象。
-
暂停以锁定帧率。
-
获取玩家输入。实际上,由于第 1、2 和 3 部分发生在线程中,这部分可以在任何时间发生。
-
重复。
在我们开始真正构建游戏之前,还有一些最后的准备工作。
Android Studio 文件结构
Android 系统对我们放置类文件的位置非常讲究,包括 Activity,以及我们在文件层次结构中放置我们的资产(如声音文件和图形)的位置。
这里是一个关于我们将要放置所有内容的快速概述。你不需要记住这些,因为当我们添加资源时会提醒你正确的文件夹。我们将在第一次需要创建活动/类的时候逐步进行。
提前提醒,以下是 Tappy Defender 项目结束时你的 Android Studio 项目资源管理器将看起来像的标注图:

现在,我们可以真正开始构建 Tappy Defender。
构建主页
由于我们已经完成了所有的规划和准备,我们可以开始编写代码。
注意
要使用代码文件,你仍然需要创建一个 Android Studio 项目。此外,你还需要在每个 JAVA 文件的非常第一行代码中更改包名。将包名更改为与创建的项目包名匹配。最后,你需要确保任何如图片或声音文件等资源都放置在项目中的适当文件夹里。每个项目的所有必需资源都包含在下载包中。
创建项目
打开 Android Studio,按照以下步骤创建一个新项目。所有将在这个章节结束时用到的文件都包含在Chapter2文件夹中的下载包里。
-
在欢迎使用 Android Studio对话框中,点击创建新的 Android Studio 项目。
-
在接下来的创建新项目窗口中,我们需要输入一些关于我们应用的基本信息。这些信息将被 Android Studio 用于确定包名。
注意
在以下图片中,你可以看到编辑链接,你可以在此处自定义包名(如果需要的话)。
-
如果你将复制粘贴提供的代码到你的项目中,那么在应用程序名称字段中输入
C1 Tappy Defender,在公司域名字段中输入gamecodeschool.com,如以下截图所示:![创建项目]()
-
准备好时,点击下一步按钮。当被问及你的应用将运行在哪些设备形式上时,我们可以接受默认设置(手机和平板)。所以再次点击下一步。
-
在添加活动到移动设备对话框中,只需点击空白活动,然后点击下一步按钮。
-
在自定义活动对话框中,我们同样可以接受默认设置,因为
MainActivity似乎是我们主要活动的合适名称。所以点击完成按钮。
我们所做
Android Studio 已经构建了项目并创建了许多文件,其中大部分我们将在构建这个游戏的过程中看到并编辑。如前所述,即使你只是在复制粘贴代码,你也必须完成这一步,因为 Android Studio 在幕后做一些事情来使我们的项目工作。
构建主页 UI
我们 Tappy Defender 游戏的第一部分和最简单部分是主屏幕。我们需要的只是一个关于游戏的场景整洁图片、一个最高分和一个开始游戏的按钮。完成后的主屏幕将看起来有点像这样:

当我们构建项目时,Android Studio 会为我们打开两个文件以便编辑。您可以在下面的 Android Studio UI 设计器中看到它们作为标签页。这些文件(和标签页)是MainActivity.java和activity_main.xml:

MainActivity.java文件是我们游戏的入口点,我们很快就会看到更多细节。activity_main.xml文件是我们主屏幕将使用的 UI 布局。现在,我们可以继续编辑activity_main.xml文件,使其看起来像我们的主屏幕应该的样子。
-
首先,您的游戏将以 Android 设备的横屏模式运行。如果我们将我们的 UI 预览窗口更改为横屏,我们将更准确地看到您的进度。寻找下一张图片中显示的按钮。它就在 UI 预览之前:
![构建主屏幕 UI]()
-
点击前一张截图中的按钮,您的 UI 预览将切换到如下所示的横屏模式:
![构建主屏幕 UI]()
-
确保通过单击其标签打开
activity_main.xml。 -
现在,我们将设置一个背景图片。您可以使用自己的图片,或者从下载包中的
Chapter2/drawable/background.jpg使用我的图片。将您选择的照片添加到 Android Studio 项目中drawable文件夹。 -
在 UI 设计器的属性窗口中,找到并单击如图所示的背景属性:
![构建主屏幕 UI]()
-
此外,在上一张图片中,标记为...的按钮被轮廓包围。它位于背景属性的正右方。点击那个...按钮,浏览并选择您将使用的背景图片文件。
-
接下来,我们需要一个用于显示最高分的TextView小部件。请注意,布局中已经有一个TextView小部件。它写着Hello World。您将修改它并用于我们的最高分。左键单击它并将其拖动到您想要的位置。如果您打算使用提供的背景,可以复制它,或者将其放置在背景看起来最好的位置。
-
接下来,在属性窗口中,找到并单击id属性。输入
textHighScore。请准确无误地输入,因为当我们稍后在教程中编写一些 Java 代码时,我们将引用此 ID 来操作它,以显示玩家的最快时间。 -
您还可以编辑文本属性,将其设置为
High Score: 99999或类似内容,以便TextView看起来更合适。然而,这并不是必需的,因为您的 Java 代码将在稍后处理这一点。 -
现在,我们将从组件面板中拖动一个按钮,如下面的截图所示:
![构建主页 UI]()
-
将它拖动到背景上看起来不错的地方。如果您使用提供的背景,可以复制我,或者将其放置在您背景上看起来最好的位置。
-
接下来,在属性窗口中,找到并点击按钮的 id 属性。输入 buttonPlay。请准确无误地输入,因为我们将在稍后的教程中编写一些 Java 代码时,将引用此 ID 来操作它。同时编辑文本属性,使其显示为“播放”。
我们所做的是
现在我们有一个酷炫的背景,上面整齐地排列着小部件(一个TextView和一个Button),用于您的首页。我们可以通过 Java 代码向Button小部件添加功能。回顾第四章中的TextView,Tappy Defender – Going Home。重要的是,这两个小部件都分配了一个唯一的 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 类内部的位置右键单击,然后导航到 生成 | 实现方法 | 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,这将是我们游戏实际执行的地方。
-
从主菜单导航到 文件 | 新建 | 活动 | 空白活动。
-
在 自定义活动 对话框中,将 活动名称 字段更改为
GameActivity。 -
我们可以接受此对话框中的所有其他默认设置,因此点击 完成。
-
正如我们在
MainActivity类中所做的那样,我们将从头开始编写这个类。因此,从GameActivity.java中删除整个代码内容。
我们所做的工作
Android Studio 已经为我们生成了两个新文件,并在幕后做了一些工作,我们很快将调查这些工作。新文件是 GameActivity.java 和 activity_game.xml。它们都自动在我们打开的两个新标签页中打开,位于 UI 设计器上方的其他标签页相同的位置。
我们将永远不会需要 activity_game.xml,因为我们将会构建一个动态生成的游戏视图,而不是静态的用户界面。现在可以将其关闭或忽略。我们将在本章的 编写游戏循环 部分开始编写游戏时回到 GameActivity.java 文件。
配置 AndroidManifest.xml 文件
我们简要地提到,当我们创建一个新的项目或一个新的活动时,Android Studio 为我们做的不仅仅是创建两个文件。这就是我们以这种方式创建新项目/活动的原因。
在幕后发生的一件事是创建和修改manifests目录中的AndroidManifest.xml文件。
此文件对于我们的应用程序正常工作是必需的。此外,它需要被编辑以使我们的应用程序以我们想要的方式工作。Android Studio 已经为我们自动配置了基础知识,但现在我们将对此文件进行两项操作。
通过编辑AndroidManifest.xml文件,我们将强制我们的两个活动以全屏运行,并且我们将锁定它们为横幅布局。让我们在这里进行这些更改:
-
现在打开
manifests文件夹,双击AndroidManifest.xml文件以在代码编辑器中打开它。 -
在
AndroidManifest.xml文件中,找到以下代码行:android:name=".MainActivity" -
紧接着,输入或复制粘贴以下两行代码,使
MainActivity全屏运行并锁定在横幅方向:android:theme="@android:style/Theme.NoTitleBar.Fullscreen" android:screenOrientation="landscape" -
在
AndroidManifest.xml文件中,找到以下代码行:android:name=".GameActivity" -
紧接着,输入或复制粘贴以下两行代码,使
GameActivity全屏运行并锁定在横幅方向:android:theme="@android:style/Theme.NoTitleBar.Fullscreen" android:screenOrientation="landscape"
我们所做
我们现在已将我们的游戏中的两个活动配置为全屏。这对玩家来说呈现了一个更加令人愉悦的外观。此外,我们还禁用了玩家通过旋转他们的 Android 设备来影响我们的游戏的能力。
编写游戏循环
我们提到我们不会为我们的游戏屏幕使用 UI 布局,而是一个动态绘制的视图。这就是我们的图案视图发挥作用的地方。让我们创建一个新的类来表示我们的视图,然后我们将放入 Tappy Defender 游戏的根本构建块。
构建视图
我们将暂时不修改我们的两个活动类,以便我们可以查看代表我们游戏视图的类。正如我们在本章开头讨论的那样,视图和控制方面将是同一个类的部分。
Android API 为我们提供了满足我们需求的理想类。android.view.SurfaceView类不仅为我们提供了一个用于绘制像素、文本、线条和精灵的视图,而且还使我们能够快速处理玩家输入。
似乎这还不够有用,我们还可以通过实现可运行接口来创建一个线程,使我们的主游戏循环能够同时获取玩家输入和其他系统基本要素。现在,我们将处理您新SurfaceView实现的一般结构,以便我们可以在项目进展过程中填写细节。
为视图创建一个新类
不再拖延,我们可以创建一个新的类,该类扩展了SurfaceView。
-
右键单击包含我们的
.java文件的文件夹,选择新建 | Java 类,然后点击确定。 -
在创建新类对话框中,将新类命名为
TDView,(Tappy Defender 视图)。现在,点击确定让 Android Studio 自动生成该类。 -
新类将在代码编辑器中打开。修改代码,使其扩展
SurfaceView并实现Runnable,如前一小节所述。编辑以下代码的高亮部分:package com.gamecodeschool.c1tappydefender; import android.view.SurfaceView; public class TDView extends SurfaceView implements Runnable{ } -
使用Alt | Enter组合来导入缺失的类。
-
注意,我们代码中仍然存在错误。这是因为我们必须为我们的
SurfaceView实现提供一个构造函数。在TDView类声明下方右键单击,然后导航到生成 | 构造函数 | SurfaceView(Context:context)。或者,您也可以像下一个代码块中所示那样直接输入。现在点击确定。
我们所做
现在我们有一个名为TDView的新类,它扩展了SurfaceView以满足我们的绘图需求,并实现了Runnable以满足我们的线程需求。我们还生成了一个构造函数,我们将很快使用它来初始化我们的新类。
传递给我们的构造函数的Context参数是对 Android 系统中我们的GameActivity类持有的当前应用程序状态的引用。这个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);
}
}
结构化类代码
现在我们已经将TDView类扩展为SurfaceView类,我们可以开始编写代码了。为了控制游戏,我们需要能够更新所有游戏数据/对象。这意味着需要一个update方法。此外,我们显然希望在更新后每帧都绘制所有游戏数据,因此我们将所有的绘图代码放在一个名为draw的方法中。此外,我们需要控制这个操作的频率。因此,一个control方法似乎也应该成为类的一部分。
我们还知道,所有事情都需要在您的线程中发生;因此,为了实现这一点,我们应该将代码包裹在run方法中。最后,我们需要一种方式来控制线程何时以及何时不应执行其工作,因此我们需要一个由布尔值控制的无限循环,例如playing。
将以下代码复制到我们的TDView类体中,以实现我们刚刚讨论的内容:
@Override
public void run() {
while (playing) {
update();
draw();
control();
}
}
这是我们游戏的基础框架。run方法将在一个线程中执行,但只有在布尔实例playing为真时才会执行游戏循环。然后,它将更新所有游戏数据,根据这些游戏数据绘制屏幕,并控制run方法再次被调用的时间。
现在,我们可以快速构建这段代码。首先,我们可以实现从run方法中调用的三个方法。在run方法的括号闭合后,在TDView类体中输入以下代码:
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();
}
现在,我们需要一个名为gameThread的Thread类实例。我们可以在类声明之后,紧随我们的布尔playing参数之后将其声明为TDView的成员变量。如下所示:
volatile boolean playing;
Thread gameThread = null;
注意,onPause和onResume方法是公开的。我们现在可以向我们的GameActivity类添加代码,在适当的时间调用这些方法。记住,GameActivity扩展了Activity。因此,使用重写的Activity生命周期方法。
通过重写onPause方法,每当活动暂停时,我们可以关闭线程。这可以避免玩家可能感到尴尬,并不得不向他的呼叫者解释为什么他们能在背景中听到音效。
通过重写onResume(),我们可以在 Android 生命周期中实际运行之前启动线程的最后阶段。
注意
注意TDView类的pause方法和resume方法与GameActivity类的重写onPause和onResume方法之间的区别。
游戏活动
在实现/覆盖此方法之前,请注意,它们所做的一切只是调用各自方法的父版本,然后调用它们对应的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。以下是完成此操作的几个快速步骤。现在,右键单击包含我们的.java文件的文件夹,导航到新建 | Java 类,然后输入PlayerShip作为名称,点击确定。
我们需要我们的PlayerShip类能够了解关于自己的什么?作为最低要求,它至少需要能够:
-
知道它在屏幕上的位置
-
它看起来像什么
-
它飞行的速度有多快
这些要求表明我们可以声明几个成员变量。在我们生成的类声明之后输入代码:
private Bitmap bitmap;
private int x, y;
private int speed = 0;
如同往常,使用Alt | Enter键盘组合导入任何缺少的类。在之前的代码块中,我们看到我们声明了一个类型为Bitmap的对象,我们将使用它来存储代表我们的船的图形。
我们还声明了三个int类型的变量;x和y用于存储飞船的屏幕坐标,另一个int类型的变量speed用于存储飞船移动的速度值。
现在,让我们考虑我们的PlayerShip类需要做什么。再次作为最低要求,它至少需要做到:
-
准备自己
-
更新自己
-
与我们的视图共享其状态
构造函数似乎是准备自己的理想位置。我们可以初始化其x和y坐标变量,并使用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)资源文件夹请求一个名为 ship 的图形。要解决这个错误,我们只需要将我们的图形 ship.png 复制到项目的 drawable 文件夹中。
简单地将 Chapter2/drawable 文件夹中包含的 ship.png 图形从下载包拖放到 Android Studio 项目资源管理器窗口中的 res/drawable 文件夹。以下是一个 ship.png 图像:

我们列表中 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 作为示例来阐明屏幕坐标。

目前,当我们只在任意位置绘制一艘船时,这些信息意义不大。在下一章,当我们开始将我们的图形限制在可见屏幕上,并在它们消失时重新生成它们时,它将变得更加重要。
所以,让我们记住这一点,继续将我们的船绘制到屏幕上。
绘制玩家船
现在我们已经知道了所有这些,我们可以在TDView类中添加一些代码,这样我们就可以看到PlayerShip类的实际效果。首先,我们需要一个具有类作用域的新PlayerShip对象。以下代码是TDView类的声明:
//Game objects
private PlayerShip player;
我们还需要一些我们尚未见过的对象来帮助我们实际进行绘图。我们需要一个画布和一些画笔。
画布和画笔对象
命名恰当的Canvas类提供了你所期望的——一个虚拟画布,我们可以在此画布上绘制我们的图形。
我们可以使用Canvas类创建一个虚拟画布,并将其投影到我们的SurfaceView对象上,这是你的GameActivity类的视图。我们实际上可以在我们的Canvas对象上添加Bitmap对象,甚至使用我们的Paint对象的方法来操纵单个像素。此外,我们还需要一个SurfaceHolder类的对象。这允许我们在操作Canvas对象时锁定它,并在我们准备好绘制帧时解锁它。
我们将在接下来的过程中更详细地了解这些类是如何工作的。在输入上一行代码后立即输入以下代码:
// For drawing
private Paint paint;
private Canvas canvas;
private SurfaceHolder ourHolder;
如同往常,我们需要使用Alt | Enter键盘组合来导入接下来的两行代码所需的一些新类。从这一点开始,我们将保存数字链接,并假设你知道每次添加新类时都要这样做。
接下来,我们需要设置以准备绘图。最好的地方是在TDView()构造函数中这样做。输入以下代码以准备我们的Paint和SurfaceHolder对象以进行操作:
// 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对象:
-
确认我们的
SurfaceHolder类是有效的。 -
锁定
Canvas对象。 -
通过调用
drawColor()清除屏幕。 -
通过调用
drawBitmap()并传入PlayerShip位图以及x,y坐标,在其上泼溅一些虚拟油漆。 -
最后,解锁
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);
}
到这一点,我们实际上可以运行游戏。如果我们的视力足够快或者我们的 Android 设备足够慢,我们几乎可以看到我们的玩家宇宙飞船以极快的速度飞越屏幕。
在我们将游戏部署之前,还有一件事要做。
控制帧率
我们几乎看不到任何东西的原因是,尽管我们只在每一帧沿着x轴(在PlayerShip类的update方法中)移动我们的船一个像素,但我们的线程以无限制的方式调用run方法。这可能在每秒发生数百次。我们需要做的是控制这个速率。
每秒 60 帧(FPS)是一个合理的目标。这个目标意味着需要计时。Android 系统以毫秒(千分之一秒)为单位测量时间。因此,我们可以将以下代码添加到control方法中:
try {
gameThread.sleep(17);
} catch (InterruptedException e) {
}
在前面的代码中,我们通过调用gameThread.sleep并将17作为方法参数,使线程暂停了 17 毫秒(1000(毫秒)/60(FPS))。我们将代码包裹在一个try/catch块中。
部署游戏
现在,我们可以运行我们的游戏,看到我们的宇宙飞船在太空中漂浮(从x轴上的 50 像素和y轴上的 50 像素开始)。
Android Studio 使我们能够相当快速地创建模拟器,在开发 PC 上测试我们的游戏。然而,即使是最简单的游戏在模拟器上运行也不会很好。当我们开始测试像玩家输入这样的东西时,体验如此糟糕,最好完全避免使用模拟器。
解决方案是在真实的 Android 设备上进行调试。为此做准备非常容易。
在 Android 设备上调试
首件事是访问您的设备制造商的网站,获取并安装您设备和操作系统所需的任何驱动程序。
接下来的几个步骤将为调试设置 Android 设备。请注意,不同制造商的菜单选项结构可能略有不同。以下序列可能非常接近,如果不是完全相同,以在大多数设备上启用调试。
-
点击设置菜单选项或设置应用。
-
点击开发者选项。
-
点击USB 调试的复选框。
-
将您的 Android 设备连接到开发系统的 USB 端口。下一张图片显示了 Android 选项卡。在 Android Studio UI 的底部,您可以看到已检测到三星 GT-I9100 Android 4.1.2(API 16):
![在 Android 设备上调试]()
-
点击 Android Studio 工具栏中的播放图标:
![在 Android 设备上调试]()
-
当提示时,点击OK以在所选设备上运行游戏。
游戏现在将在设备上运行。任何输出或错误都可以在logcat窗口中看到,也在Android标签页上:

惊叹地看着我们的玩家飞船从左到右缓慢移动。
摘要
在本章中,我们花费了大量时间设置结构、游戏循环和线程。我们还花费时间处理 Android Activity 生命周期。
现在,我们已经有了所有这些,我们可以轻松地开始添加更多游戏对象,使 Tappy Defender 在下一章中快速感觉更像一个真正的游戏。
第三章。Tappy Defender –起飞
我们现在可以快速添加许多新对象和一些功能。到本章结束时,我们将非常接近一个可玩的游戏。我们将检测玩家触摸屏幕,以便他可以控制飞船。我们将在SpaceShip类中添加虚拟加速器,以使飞船上下移动并增加速度。
然后,我们将检测 Android 设备的分辨率,并使用它来做诸如防止玩家从屏幕上加速,以及检测敌人何时需要重生的事情。
我们将创建一个新的EnemyShip类,它将代表自杀式敌人。我们还将看到我们如何可以轻松地生成并控制它们,而无需更改代码控制部分中的任何逻辑。
我们将通过添加SpaceDust类并生成数十个来添加滚动效果,使其看起来像玩家正在飞快地穿过太空。
最后,我们将了解并实现碰撞检测,这样我们就知道玩家是否被敌人击中,以及查看一个图形技巧,帮助我们调试碰撞检测代码。
控制飞船
我们玩家的飞船现在在屏幕上无目的地漂浮,从左边 50 像素和顶部 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 之间切换为 true 和 false,即加速和不加速。将这些方法添加到 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 次。因此,要让飞船以荒谬的速度飞行,不需要太大的提升。我们需要限制飞船的速度。 -
另一个问题是我们的小飞船在加速时会上升屏幕,而且没有任何东西可以阻止它直接飞出屏幕顶部,从此消失。我们需要将飞船的 x 和 y 坐标限制在屏幕内。
-
当飞船不加速且速度稳步回到零时,什么会再次将飞船降下来?我们需要一个简单的重力物理模拟。
为了解决这三个问题,我们可以在PlayerShip类中添加代码。然而,在我们这样做之前,我们先简单谈谈游戏平衡。我们很快就会看到的代码使用了不同的整数值,例如,我们将GRAVITY初始化为-12,将MAX_SPEED初始化为20。这些数字在现实中没有任何意义!
这些只是使游戏平衡的任意数字。您可以随意调整所有这些任意数字,使游戏更难、更容易,甚至不可能。在第四章“Tappy Defender – Going Home”的结尾,我们将更仔细地研究游戏迭代,并再次看看难度和平衡。
在心中牢记我们之前提到的三个问题,在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这一行代码,我们根据速度和重力将屏幕上的图形向上或向下移动。GRAVITY和MAX_SPEED的看似随意的值非常适合让玩家笨拙且危险地在太空中弹跳。
最后,我们通过确保飞船图形永远不会超出maxY和minY来阻止飞船从屏幕上消失。您可能已经注意到,到目前为止,我们还没有初始化maxY和minY。此外,我们到底要将它们初始化为多少,因为许多 Android 设备的屏幕分辨率差异很大?
我们需要做的是在运行时发现 Android 设备的分辨率,并使用这些信息来初始化MaxY和minY。
检测屏幕分辨率
我们知道我们需要玩家屏幕的最大y坐标。在项目后期,当我们开始添加背景和敌舰时,我们会意识到我们还需要最大x坐标。考虑到这一点,让我们看看我们如何获取这些信息,并将其提供给PlayerShip类。
检测屏幕分辨率最方便的时间是在应用启动时,在我们视图和模型实例化之前。这意味着我们的 GameActivity 类是做这件事的好地方。我们现在将向 GameActivity 类的 onCreate 方法中添加代码。将以下新代码添加到 onCreate 类中,在调用 new... 创建我们的 TDView 对象之前:
// 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 中。现在将这个值传递到需要它的代码部分。首先,我们将更改传递给 new 调用的参数,该调用初始化我们的 TDView 对象。按照下面的说明更改 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 构造函数中初始化 maxY 和 minY 变量。在我们看到代码之前,我们需要考虑这究竟是如何工作的。
包含我们的宇宙飞船图形的位图的坐标是在 TDView 类的 draw 方法中通过传递给 drawBitmap() 的 x = 0 和 y = 0 坐标绘制的。这意味着在开始绘制飞船的坐标之后,有一些像素偏右。看看下面的图片来可视化这一点:

因此,我们必须考虑到这一点来设置我们的 minY 和 maxY 值。如图所示,位图的顶部像素确实正好绘制在飞船的 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对象和screenX、screenY的Context类,它们持有屏幕的分辨率。
正如我们在PlayerShip类中所做的那样,我们将一个图像加载到Bitmap中。当然,我们还需要将一个名为enemy.png的图像文件添加到我们项目的drawable文件夹中。下载包的Chapter3/drawable文件夹中有一个整洁的敌人图形,或者您可以自己设计。对于这个游戏来说,任何大约 32 x 32 到 256 x 256 大小的图形都足够了。同样,像提供的那些图形一样,您的图形不需要是正方形的。我们将看到,当它显示在不同屏幕尺寸上时,我们的游戏引擎在视觉上是不完美的,我们将在下一个项目中解决这个问题:

接下来,我们初始化maxX、maxY、minX和minY。虽然敌人只水平移动,但我们需要maxY和minY坐标来确保我们在一个合理的身高处生成它们。maxX坐标将使我们能够在水平方向上刚好在屏幕外生成它们。
我们创建了一个新的Random对象,并生成一个介于 10 和 15 之间的随机数。这些是我们敌人可以旅行的最大和最小速度。这些值相当任意,我们可能在第四章 Tappy Defender – 回家进行一些游戏测试时调整它们。
注意
如果您想知道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构造函数内部,我们使用随机值初始化speed、x和y变量,但在这个我们刚刚初始化的最大和最小变量设定的范围内。
然后我们实现SpaceDust类的update方法,该方法根据对象的速度和玩家的速度将对象向左移动,然后检查对象是否已经飞出屏幕的左侧边缘,如果已经飞出,则使用随机但适当的值重生它。
在底部,我们提供了两个获取方法,以便我们的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 坐标上以随机速度攻击:

发生碰撞的事物 - 碰撞检测
碰撞检测是一个相当广泛的主题。在这本书的三个项目中,我们将使用各种不同的方法来检测物体何时发生碰撞。
因此,这里快速看一下我们的碰撞检测选项,以及在什么情况下不同的方法可能是合适的。
实际上,我们只需要知道我们游戏中的某些物体何时触摸其他物体。然后我们可以通过爆炸、减少护盾、播放声音或采取适当的行动来响应该事件。我们需要对不同的选项有一个广泛的理解,这样我们才能在任何特定的游戏中做出正确的决定。
碰撞检测选项
首先,这里有一些我们可以利用的不同数学计算,以及它们可能何时有用。
矩形交集
这种碰撞检测类型非常直接。我们画一个想象中的矩形;我们可以称之为击中框或边界矩形,围绕我们想要测试碰撞的物体。然后测试它们是否相交。如果相交,我们就有了碰撞:

当击中框相交时,我们就有碰撞。从之前的图像中我们可以看到,这远非完美。然而,在某些情况下,这已经足够了。要实现这种方法,我们只需要测试两个物体的 x 和 y 坐标是否相交。
不要使用以下代码。它仅用于演示目的。
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 轴是否重叠。如果没有重叠,那么就没有必要继续下去。如果重叠,然后检查 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方法,它可以返回半径以及中心 x 和 y 坐标。此外,因为静态Math.sqrt方法接受并返回一个类型为double的变量,我们将在我们的SpaceShip和EnemyShip类中开始使用不同的类型。
注意
如果我们初始化距离的方式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 方法,基本上与矩形交集碰撞检测做的是同样的事情。让我们来思考如何将碰撞检测添加到我们的游戏中。
首先,我们所有的敌人和我们的玩家飞船都需要一个碰撞框。将以下代码添加到声明新的 Rect 成员变量 hitbox 中。在 PlayerShip 和 EnemyShip 类中执行此操作:
// 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方法完成调整后更新击中区域。再次提醒,将其添加到PlayerShip和EnemyShip中:
// Refresh hit box location
hitBox.left = x;
hitBox.top = y;
hitBox.right = x + bitmap.getWidth();
hitBox.bottom = y + bitmap.getHeight();
我们的击中区域坐标代表了我们位图的轮廓。这种情况几乎是完美的,除了边缘周围的透明部分。
现在,我们可以使用TDView类的update方法中的击中区域来检测碰撞。但在做之前,我们需要决定当发生碰撞时我们将做什么。
我们需要参考我们游戏规则。我们之前在第二章,Tappy Defender – 第一步中讨论过这些规则。我们知道玩家有三个护盾,但敌人被击中一次后就会爆炸。将像护盾这样的东西留到章节的后面部分是有道理的,但我们需要某种方式来查看我们的碰撞检测是否在起作用并确保它正常工作。
在这个阶段,承认碰撞的最简单方法可能是让敌舰消失并重新生成,就像它是一个全新的敌人一样。我们已经有了一个实现这个功能的机制。我们知道当敌人移动到屏幕的左侧时,它会像在右侧的新敌人一样重新生成。我们只需要将敌人瞬间传送到屏幕左侧之外的位置,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,并看到游戏在调试模式下的击中框完全启用的情况:

当我们完成调试代码后,我们可以将其注释掉,然后在需要时再次取消注释。
摘要
现在我们拥有了完成整个游戏所需的所有游戏对象。它们都在我们的设计模式的模型部分内部思考和代表自己。此外,我们的玩家现在终于可以控制他的宇宙飞船了,我们也可以检测到他何时撞毁。
在下一章中,我们将为我们的游戏添加最后的修饰,包括添加 HUD(抬头显示)、实现游戏规则、添加一些额外功能,并对游戏进行测试以平衡一切。
第四章 Tappy Defender – 回家
我们已经进入了第一款游戏的冲刺阶段。在本章中,我们将绘制一个 HUD(头部显示单元)来显示玩家在游戏中的信息,并实现游戏规则,以便玩家可以赢、输和获得最快时间。
之后,我们将制作一个暂停屏幕,让玩家在赢或输后可以欣赏他们的成就(或者不是)。
在本章中,我们还将生成我们自己的音效,并将其添加到游戏中。之后,我们将允许玩家保存他们的最快时间,最后我们将添加一些小的改进,包括根据玩家安卓设备的屏幕分辨率进行的一点点难度平衡。
显示 HUD
我们需要开始让我们的游戏更加完善。游戏有一个得分或者,在我们的情况下,是一个时间,以及其他规则。为了使玩家能够监控他们的进度,我们需要显示游戏的统计数据。
在这里,我们将快速设置一个 HUD,它将在玩家躲避敌人时在屏幕上显示他们所需了解的一切。我们还将声明并初始化向 HUD 提供数据的变量。在下一节“实现规则”中,我们可以开始操作如护盾、时间、最快时间等变量。
我们可以从向TDView类添加一些成员变量开始。我们使用浮点值来表示distanceRemaining变量,因为我们将会使用伪千米和千米分数来表示英雄到达她家园星球之前剩余的距离。对于timeTaken、timeStarted和fastestTime变量,我们将使用长类型,因为时间以毫秒表示,值会变得非常大。在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);
输入此代码后,我们有一些错误和一些疑问。
首先,我们将处理疑问。在下一节“实现规则”中,我们将更仔细地查看我们对fastestTime、timeTaken、distanceRemaining以及getSpeed返回的值的操作。简而言之,它们是距离和时间的表示,旨在让玩家了解他们的表现。它们不是距离的真实模拟,尽管时间是准确的。
我们将首先处理由调用不存在的方法player.getShieldStrength引起的第一个错误。向PlayerShip类添加一个成员变量shieldStrength:
private int shieldStrength;
在PlayerShip构造函数中将它初始化为2:
shieldStrength = 2;
在PlayerShip类中实现你缺失的 getter 方法:
public int getShieldStrength() {
return shieldStrength;
}
最后的错误是由未声明的变量screenX和screenY引起的。现在很明显,我们需要在这个代码部分的屏幕分辨率。处理这个问题最快的方法是创建一些新的类变量screenX和screenY。现在就在TDView类声明之后声明这些变量:
private int screenX;
private int screenY;
正如我们将看到的,知道屏幕坐标在许多地方都很有用,所以这样做是有意义的。
现在,在TDView构造函数中,使用GameActivity类传入的分辨率初始化screenX和screenY。在构造函数的开始处执行此操作:
screenX = x;
screenY = y;
我们现在可以运行游戏并查看我们的 HUD。我们 HUD 中带有有意义数据的部分只有护盾和速度标签。速度是每秒米数(MPS)的伪测量值。当然,它与现实无关,但它与旋转的星星、接近的敌人以及玩家与家的距离减少有关:

实现规则
现在,我们应该暂停并思考在项目后期需要做什么,因为它将影响我们在实现规则时的操作。当玩家的飞船被摧毁或玩家达到他们的目标时,游戏将结束。这意味着游戏需要重新启动。我们不希望每次都退出到主屏幕,因此我们需要一种从TDView类内部重新启动游戏的方法。
为了方便起见,我们将在TDView类中实现一个startGame方法。构造函数将能够调用它,并且我们的游戏循环在必要时也可以调用它。
还需要将构造函数当前执行的一些任务传递给新的 startGame 方法,以便它能够正确地完成其工作。此外,我们将使用 startGame 来初始化我们游戏规则和 HUD 所需的某些变量。
为了完成我们讨论的内容,startGame() 将需要一个应用 Context 对象的副本。所以,就像我们对 startX 和 startY 所做的那样,我们现在将 context 作为 TDView 的成员。在 TDView 类声明之后声明它:
private Context context;
在调用 super() 之后立即在构造函数中初始化它,如下所示:
super(context);
this.context = context;
我们现在可以实施新的 startGame 方法。大部分代码只是从构造函数中移动过来。请注意这些微妙但重要的差异,例如使用类的屏幕坐标版本 screenX 和 screenY 而不是构造函数参数 x 和 y。此外,我们还初始化了 distanceRemaining、timeTaken 和 timeStarted。
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。这看起来可能并不重要,但稍微提前思考一下,我们可以预见游戏结束的时间,无论是由于玩家失败还是获胜。让我们来谈谈游戏的结束。
游戏结束
如果游戏没有结束,游戏正在进行,如果玩家刚刚死亡或获胜,则游戏结束。我们需要知道游戏何时结束以及何时在进行。让我们创建一个新的成员变量gameEnded,并在TDView类声明之后声明它:
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。将 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;
尝试一下。你现在可以从暂停菜单通过点击屏幕来重新启动游戏。是不是只有我觉得这里有点安静?
添加声音效果
在安卓中添加声音效果非常简单。首先,让我们看看我们可以在哪里获取我们的声音效果。如果你只想继续编码,你可以使用我在Chapter4/assets文件夹中的声音效果。
生成声音效果
我们需要为我们的 Tappy Defender 游戏准备四个声音效果:
-
当我们的玩家撞到外星人的声音,我们将称之为
bump.ogg。 -
当玩家被摧毁时的声音,我们将称之为
destroyed.ogg。 -
当游戏刚开始时的一种有趣的声音,我们将称之为
start.ogg。 -
最后,一种胜利时的欢呼声类型的声音,我们将称之为
win.ogg。
这里是使用 BFXR 制作这些声音效果的快速指南。从www.bfxr.net获取 BFXR 的免费副本。
按照网站上的简单说明来设置它。尝试一些这些操作来制作我们酷炫的声音效果。
注意
这是一个非常简化的教程。你可以用 BFXR 做很多事情。要了解更多,请阅读之前 URL 上的网站上的提示。
-
运行
bfxr.exe。![生成声音效果]()
-
尝试所有预设类型,这些类型会生成你正在工作的随机声音。当你得到一个接近你想要的声音时,移动到下一步:
![生成声音效果]()
-
使用滑块来微调你新声音的音高、时长和其他方面:
![生成声音效果]()
-
通过点击导出 Wav按钮保存你的声音。尽管这个按钮的名称是
.wav,但我们会看到我们还可以保存其他格式的文件。![生成声音效果]()
-
安卓喜欢使用 OGG 格式的声音,所以当被要求命名文件时,在文件名末尾使用
.ogg扩展名。记住我们需要创建bump.ogg、destroyed.ogg、start.ogg和win.ogg。 -
重复步骤 2 到 5,创建我们讨论过的四个声音效果。
-
右键点击 Android Studio 中的
app文件夹。从弹出菜单中,导航到新建 | Android 资源目录。 -
在目录名称字段中,输入
assets。点击确定来创建assets文件夹。 -
使用你的操作系统文件管理器将一个名为
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 – 第一步 中分配的。然后我们使用 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.png 和 enemy3.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类,我们可以从中派生出PlayerShip和EnemyShip。
将这添加到我们为更高分辨率屏幕添加额外敌人的繁琐方式中,并且可能一个更具有多态性的解决方案是值得的。我们将看到我们如何可以真正改进这个和游戏引擎的几乎每个其他方面,在下一个项目中。
格式化时间
看看玩家 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 设备上按下返回按钮时会发生什么。将这个新方法添加到GameActivity和MainActivity类中。我们只需检查是否按下了返回按钮,如果是,就调用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,有几百个额外的星星和盾牌:

摘要
我们已经实现了基本游戏引擎的各个组成部分。我们可以做更多的事情。当然,一个现代移动游戏将比我们的游戏有更多的事情发生。当有更多游戏对象时,我们如何处理碰撞?我们能不能把我们的类层次结构稍微整理一下,因为我们的PlayerShip和EnemyShip类之间有很多相似之处?我们如何添加复杂的内部角色动画,而不会使我们的代码结构混乱,如果我们想有智能敌人,敌人实际上能思考怎么办?
我们需要逼真的背景、辅助目标、升级道具和拾取物品。我们希望游戏世界具有现实世界的坐标,无论屏幕分辨率如何,都能准确映射。
我们需要一个更智能的游戏循环,无论在哪个 CPU 上运行,都能以相同的速度运行游戏。最重要的是,我们真正需要的,比这些都要多的是一台非常大的机关枪。让我们打造一个经典平台游戏。
第五章。平台游戏 – 升级游戏引擎
欢迎来到这本书的第二个项目。在这里,我们将打造一个真正的艰难复古平台游戏。虽然制作起来不难,但当你玩的时候却很难打败。在项目结束时,我们还将讨论如果你希望的话,如何让游戏玩起来不那么严厉。
本章将完全专注于我们的游戏引擎,并本质上将导致 Tappy Defender 代码的升级版本。
首先,我们将讨论我们希望通过这个游戏实现什么:背景故事、游戏机制和规则。
接着,我们将快速创建一个活动,实例化一个视图,该视图将完成所有工作。
之后,我们将完善我们的 PlatformView 类的基本结构,它与我们的 TDView 类有一些微妙但重要的区别。最值得注意的是,PlatformView 将有一种简单但有效的方法来管理我们游戏中所有事件的时机。
然后,我们将开始迭代构建我们的 GameObject 类,几乎游戏世界中的每个实体都将从这个类派生出来。
接下来,我们将讨论视口的概念,通过视口玩家可以看到游戏世界。我们将不再设计我们的游戏对象以屏幕分辨率级别运行,但它们现在存在于一个有自己 x 和 y 坐标的世界上,我们可以将其视为虚拟米。在 z 轴上还有一个简单的深度系统。这将由我们新的 Viewport 类处理。
之后,我们将探讨如何设计和布局我们的游戏内容。这是通过一个用作关卡设计师的类来完成的,并且可以用非编程方式来绘制跳跃、敌人、奖励和目标,这些构成了关卡布局。
为了管理关卡设计和将它们加载到我们的游戏引擎中,我们需要另一个类。我们将称之为 LevelManager。
最后在本章中,我们将查看 PlatformView 类中增强的 update 和 draw 方法,这样我们就可以真正运行我们的新游戏,并在屏幕上看到第一个输出。
有这么多事情要做,我们最好开始行动了。
游戏
我们将要构建的游戏基于 80 年代一些残酷难度的平台游戏的游戏玩法,例如《鲍勃的复仇》和《不可能的任务》。这些游戏具有困难的跳跃和需要极端精确的时间控制,同时给玩家一个无法原谅的生命/机会数量。这种游戏风格对我们来说很适用,因为我们实际上可以在四个章节中仅用四个章节构建一个多级可玩游戏。
类的设计将使您轻松添加自己的额外功能,或者如果您想使游戏稍微容易一些,也可以做到。
背景故事
我们的英雄鲍勃刚刚从秘密任务中返回,任务是摧毁位于地球中心的邪恶科学家,他发现自己深陷地下。更糟糕的是,尽管他击败了邪恶的科学家,但似乎已经来不及拯救地球,因为强大的守卫和致命的飞行机器人无人机已经被他释放出来。
鲍勃必须从深埋地下的炽热洞穴中穿过,经过严密守卫的城市和森林,高耸入云的山脉,他希望在那里生活,摆脱恐怖的新秩序,这个新秩序已经占领了整个星球。
在他的四个关卡之旅中,他必须避开守卫,摧毁无人机,收集大量金钱,并升级他最初微不足道的机关枪。
游戏机制
游戏将关于执行精确跳跃,规划通过关卡的最佳路线以收集战利品和逃脱。鲍勃将能够站在边缘上,他的整个脚趾悬空,进行看似不可能的跳跃。鲍勃将能够控制跳跃的距离,这意味着有时他需要确保自己不要跳得太远。
鲍勃在尝试通过重兵把守的区域逃脱之前,需要收集机枪升级。
鲍勃只有三条命,但在他的旅途中可能会找到更多。
游戏规则
当鲍勃被无人机/守卫抓住、触碰火焰或从游戏世界中掉落时,他将在当前关卡的开始处重生。无人机可以飞行,并且一旦鲍勃进入视野,就会立即锁定他。鲍勃需要确保他有足够的火力来对付无人机。守卫将在关卡预定的部分巡逻,但它们很坚固,只能被鲍勃的机枪击退。通常,鲍勃需要执行精确时间跳来通过守卫。
环境也将非常艰难。鲍勃需要完全掌握每个级别,因为一次错误的跳跃会让他直线下坠,回到起点,直接落入敌人的魔爪,甚至可能葬身火海。
升级游戏引擎
所有关于守卫、无人机、火焰、可收集物品、枪支以及暗示的更大游戏世界的讨论,都表明需要一个更复杂的系统来管理。我们游戏引擎的一个目标将是使这种复杂性易于管理。另一个目标将是将关卡设计从编码中分离出来。当我们的游戏完成时,你将能够坐下来设计最邪恶、最有回报的关卡,在多个不同的环境中进行设计,而不必触碰代码。
平台活动
我们首先从我们的Activity类开始,这是进入我们游戏的大门。这里没有太多新内容,所以我们快速构建它。创建一个新的项目,在应用程序名称字段中输入C5 平台游戏。选择手机和平板电脑,然后当提示时选择空白活动。在活动名称字段中,键入PlatformActivity。
小贴士
显然,你不必完全遵循我的命名选择,但请记住在代码中进行一些小的修改,以反映你自己的命名选择。
你可以从layout文件夹中删除activity_platform.xml。你还可以删除PlatformActivity.java文件中的所有代码。只需留下包声明。现在,我们有一个完全空白的画布,可以开始编码。以下是到目前为止我们的整个项目:
package com.gamecodeschool.c5platformgame;
让我们开始构建我们的引擎。就像在我们的 Tappy Defender 项目中一样,我们将构建一个类来处理游戏视图方面。不出所料,我们将把这个类命名为PlatformView。因此,我们的PlatformActivity类需要实例化一个PlatformView对象,并将其设置为应用程序的主视图,就像在先前的项目中一样。
我们将对我们的引擎进行一些重大的升级,但这主要发生在视图方面。在接下来我们将查看的PlatformActivity类的代码中,我们与先前的项目做得很相似。首先,在重写的onCreate方法中声明PlatformView对象并将其设置为主要的视图;然而,在我们这样做之前,我们还会捕获并传递设备的屏幕分辨率。
我们使用Display类,并通过链式调用getWindowManager()和getDefaultDisplay()方法来获取游戏将运行的物理显示硬件的属性。然后,我们创建一个名为resolution的Point类型对象,并通过调用display.getSize(size)将显示的分辨率存储到我们的Point对象中。
这将屏幕的水平和垂直像素数分别存储到size.x和size.y中。然后,我们可以通过调用其构造函数并传递存储在size.x和size.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类以全屏方式运行,并且我们还将将其锁定为横屏布局。让我们进行以下更改:
-
现在,打开
manifests文件夹,双击AndroidManifest.xml文件,在代码编辑器中打开它。 -
在
AndroidManifest.xml文件中,找到以下代码行:android:name=".PlatformActivity" -
紧接着,输入或复制粘贴以下两行代码,使
PlatformActivity运行全屏并锁定为横屏模式。android:theme="@android:style/Theme.NoTitleBar.Fullscreen" android:screenOrientation="landscape"
现在,我们可以继续深入到我们游戏的核心部分,看看我们如何实现我们之前讨论的所有改进。
PlatformView 类
到完成时,这个类将依赖于许多其他类。我不想逐个展示每个类,因为这会相当难以跟踪,而且不清楚哪些代码实现了哪些功能会变得混乱。相反,我们将按需查看和编写每个功能,然后多次回顾许多类以添加更多功能。这将保持对代码每个部分的特定目的的关注。
话虽如此,我们已经非常小心地处理了这个问题,尽管我们将多次回顾这些类,但我们不会不断删除代码,而是会添加代码。当我们添加代码时,代码将在其适当的环境中呈现,新部分将在现有代码中突出显示。
关于类的结构,它们被设计得尽可能简单,同时,又不会限制你轻松添加功能和扩展代码的潜力。
这不是关于游戏引擎设计的课程,而更多的是关于展示我们可以学习到多少不同的功能,并将它们压缩到四个章节中,而不会使代码变得难以管理。
如果你计划构建大规模的游戏,尤其是在团队合作的情况下,那么需要一个更健壮的设计。这种更健壮的设计也意味着将需要大量的额外类、接口、包等等。
小贴士
如果你对这类讨论感兴趣,我强烈推荐 Mario Zechner 编写的书籍,书名为 《Android 游戏入门》,由 APRESS 出版。Mario 是 LibGDX 跨平台游戏库的创始人/创建者,他的书详细介绍了构建高度可扩展和可重用代码库所需的设计模式。这本书的缺点是,要构建一个简单的复古蛇游戏,可能需要大约 600 页。
首先,让我们创建一个类。在 Android Studio 项目资源管理器中,右键单击包名,然后导航到 新建 | Java 类。将新类命名为 PlatformView。删除类的自动生成内容,因为我们很快就会添加自己的代码。
我们将在整个项目过程中继续向这个类添加代码。本章添加到类中的全部代码可以在 Chapter5/PlatformView.java 的下载包中找到。
我们需要一个可以管理我们等级的类。让我们称它为 LevelManager。
我们还需要一个可以存储我们关卡数据的类,因为每次我们创建新的/不同的关卡设计时,我们都可以扩展它。让我们称这个父类为LevelData,以及鲍勃需要逃离的第一个真实关卡,LevelCave。
此外,由于这个游戏将有许多敌人、道具和地形类型,我们需要一个更干净的系统来管理它们。我们需要一个相当通用的GameObject类,所有不同的游戏对象都可以扩展它。然后我们可以在update和draw方法中非常容易地管理它们。
由于必要性,我们还将构建一个稍微复杂的方法来检测玩家的输入。我们将创建一个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);
}
}
组装视图的第一阶段最后的部分是pause和resume方法,当操作系统调用相应的 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类的世界位置。这个类将在x和y轴上持有详细的位置。请注意,这些与我们的游戏将在其上运行的设备的像素坐标完全独立。我们可以将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类型。然后我们有两个浮点成员,它们将持有GameObject类的宽度和高度。接下来,我们有布尔变量active和visible,它们可能被用来标记一个对象当它处于活动状态、可见状态或其他状态时。我们将在本章的后面部分开始看到这如何有益。
我们还需要知道任何给定对象有多少帧的内部动画。默认值将是1,因此animFrameCount将相应地初始化。
我们有一个名为type的char类。这个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;
}
设置和获取每个GameObject的type。
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。我们不会在这个类中放入任何功能,只提供一个x和y世界位置。这样,我们将实现的Viewport类就知道在哪里聚焦。
这里是Player类,它将代表我们的英雄鲍勃。在这个阶段,这个类非常简单直接,几乎与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文件夹。这个图形是一个多帧精灵表,所以在我们将其在第六章中动画化之前,它不会很好地显示,平台游戏 – 鲍勃、哔哔声和碰撞,但作为现在的占位符它将发挥作用。
正如我们接下来将要看到的,玩家看到的游戏世界视图将聚焦于鲍勃,正如你可能预期的那样。
视口视图
视口可以被视为跟随我们游戏动作的电影摄像机。它定义了要向玩家展示的游戏世界区域。通常,它将集中在鲍勃身上。
它还通过确定哪些对象在玩家的视野内和视野外,使我们的绘制方法更有效。如果敌人在任何给定时刻都不相关,就没有必要绘制或处理它们。
这将显著加快诸如碰撞检测等任务,通过实施第一阶段检测,从要检查碰撞的对象列表中移除屏幕外的对象,这做起来出奇地简单。
此外,我们的Viewport类将负责将游戏世界坐标转换为屏幕上绘制适当的像素坐标。我们还将看到这个类是如何计算我们的GameObject类在prepareBitmap方法中使用的pixelsPerMetre值的。
Viewport类确实是一个全能的类。所以让我们开始编码。
首先,我们将声明一大堆有用的变量。我们还有一个Vector2Point5D,它将仅用于表示当前在视口中是世界中央焦点的任何点。然后,我们有pixelsPerMetreX和pixelsPerMetreY的单独整数值。
注意
实际上,在这个实现中,pixelsPerMetrX和pixelsPerMetreY之间没有区别。然而,Viewport类可以被升级以考虑设备宽高比的不同,基于屏幕大小,而不仅仅是分辨率。在这个实现中我们没有这样做。
接下来,我们简单地有屏幕在两个轴上的分辨率:screenXResolution和screenYResolution。然后我们有screenCentreX和screenCentreY,这两个变量基本上是前两个变量除以二得到的中间值。
在我们声明的变量列表中,我们有metresToShowX和metresToShowY,这将是我们将挤压到视口中的米数。更改这些值将在屏幕上显示更多或更少的游戏世界。
最后一个成员,我们将在这一点上声明,是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;
现在,让我们看看构造函数。构造函数只需要知道屏幕的分辨率。这通过参数x和y获得,当然,我们将它们分别分配给screenXResolution和screenYResolution。
然后,如之前建议的那样,我们将这两个先前变量除以二,并将结果分别赋值给screenCentreX和screenCentreY。
pixelsPerMetreX和pixelsPerMetreY是通过除以 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()。它接收一个x和y参数,这些参数立即被分配为currentWorldCentre。我们需要这个方法,因为当然玩家会在世界中移动,我们需要让Viewport类知道鲍勃的位置。此外,正如我们将在第八章中看到的,整合所有元素,我们还将遇到一种我们不想让鲍勃成为关注中心的情况。
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()函数的工作原理。它接收一个对象的x和y世界位置,以及该对象的宽度和高度。有了这些值,每个值依次从对象的坐标乘以当前屏幕每米的像素数中减去,然后从适当的世界视口中心(x或y)中减去。然后,对于对象的左上坐标,从像素屏幕中心值中减去,对于底部和右部坐标,则加上。
这些值随后被打包到convertedRect的左、上、右和底部值中,并返回给PlatformView的draw方法。将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()。
再次,我们接收对象的x、y、width和height作为参数。测试开始时,我们假设我们想要裁剪当前对象,并将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
现在,我们可以在我们的游戏世界中描述和定位对象,并专注于我们感兴趣的世界精确部分。让我们看看我们实际上如何将对象放入这个世界,这样我们就可以像之前那样更新和绘制它们。我们还将探讨层级概念。
创建层级
在这里,我们将看到如何构建我们的LevelManager、LevelData以及我们的第一个真实层级LevelCave。
LevelManager类最终需要我们InputController类的副本。因此,为了尽量保持我们的意图,即不删除任何代码,我们在LevelManager构造函数中包含一个InputController参数。
让我们快速创建一个InputController类的空白模板。以通常的方式创建一个新的类,命名为InputController。添加以下代码:
public class InputController {
InputController(int screenWidth, int screenHeight) {
}
}
现在,让我们看看我们的,最初非常简单的LevelData类。创建一个新的类,命名为LevelData,并添加以下代码。在这个阶段,它只包含一个用于字符串的ArrayList对象。
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来保存关卡名称,mapWidth和mapHeight来存储当前关卡在游戏世界米中的宽度和高度,一个Player对象,因为我们知道我们始终会有一个,以及一个名为playerIndex的整型。
很快,我们将有一个包含许多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类构建时确定的,screenWidth直接来自Viewport类,我们InputController类的一个副本,以及要加载的关卡名称。int参数px和py是玩家的起始坐标。
我们将关卡参数分配给我们的成员变量level,然后我们切换以确定我们的当前关卡将使用哪个类。当然,目前我们只有LevelCave。
然后,我们初始化我们的gameObject ArrayList和bitmapsArray。接着,我们调用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类进行实际的工作,并从我们的设计中加载我们的关卡。该方法需要pixelsPerMetre和Player对象的坐标来完成其工作。由于这是一个较大的方法,解释和代码已被分成几个部分。
在这个第一部分,我们简单地声明一个名为index的int类型并将其设置为-1。当我们遍历我们的关卡设计时,它将帮助我们跟踪我们的进度。
然后,我们使用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();
我们进入一个嵌套的for循环,从我们的ArrayList对象中的第一个字符串的第一个元素开始。我们在处理第一个字符串之前,从左到右工作,然后转到第二个字符串。
我们检查当前位置是否存在除空格(.)之外的对象,如果存在,则进入一个switch块以在指定位置创建适当的对象。
如果我们遇到1,则向ArrayList添加一个新的Grass对象,如果遇到p,则初始化在LevelManager类构造函数中传递的位置的Player对象。当创建一个新的Player对象时,我们也初始化我们的playerIndex和player对象,以便将来使用。
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传递一些数据。当我们构建InputController类时,我们将在第六章 Bob,哔哔声和颠簸 中看到我们如何使用这些数据。最后,我们调用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
}
}
}
}
增强绘图方法
现在,我们可以更精确地确定需要绘制哪些对象。首先,我们声明并初始化一个新的Rect对象,称为toScreen2d。
然后,我们遍历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.resetNumClipped将numClipped变量重置为零,以便为下一帧做好准备。将此代码直接添加到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设计的草地精确布局。您还可以看到我们的压缩比伯精灵图集,以及有 28 个对象,但其中 10 个已经被裁剪。随着我们的关卡变得更大,裁剪与未裁剪的比例将显著增加,大多数对象将被裁剪。
摘要
在本章中,我们覆盖了很多内容,现在我们有一个非常完善的游戏引擎。
由于我们已经完成了大部分的设置工作,从现在开始,我们添加的大部分代码也将产生可见(或可听)的结果,并且会更有满足感,因为我们能够定期运行我们的游戏来查看改进。
在下一章中,我们将添加音效和输入检测,从而使比伯栩栩如生。然后,我们将看到他的世界有多么危险,并将立即添加碰撞检测,以便他能够站在平台上。
第六章:平台游戏 – 比伯、哔哔声和颠簸
现在我们已经设置了基本游戏引擎,我们可以开始取得一些快速进展。在本章中,我们将快速添加一个SoundManager类,我们将使用它来在任意位置和任意时间制造噪音。之后,我们将为比伯添加一些实质性的内容并实现Player类中所需的核心功能。然后,我们可以处理多阶段碰撞检测的第二阶段(裁剪之后),并让比伯获得站在平台上的有用技能。
在我们完成这个显著的成就之后,我们将通过实现 InputController 类将鲍勃的控制权交给玩家。鲍勃最终将能够四处奔跑和跳跃。在本章结束时,我们将动画化鲍勃的精灵图集,让他看起来真的在跑,而不是到处滑动。
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 文件夹中。如果你的项目中还没有 assets 文件夹,请在 src/main 文件夹中创建一个 assets 文件夹以实现这一点。
现在,我们可以在任何我们想要的地方播放音效。是时候让我们的英雄鲍勃复活了。
介绍鲍勃
在这里,我们可以为 Player 类添加实质性的内容。然而,这不会是我们最后一次访问 Player 类。现在,我们将添加必要的功能,让鲍勃能够移动。在我们完成这个之后,我们将添加代码,让玩家可以使用即将到来的碰撞检测代码和 Animation 类。
首先,我们需要向 Player 类添加一些成员。Player 类需要知道它能够移动多快,当玩家按下左右控制键时,以及它是否在坠落或跳跃。此外,Player 类还需要知道它已经跳跃了多久以及它应该跳跃多久。
下一个代码块为我们提供了监控所有这些内容的变量。我们很快就会看到,我们如何使用它们让鲍勃做我们想要的事情。
现在,我们知道这些变量的用途了。我们可以在类声明之后立即添加此代码:
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类中。我们将跟踪当前的水平速度和垂直速度,对象面对的方向,以及对象是否可以移动,以下是一些变量。将这些添加到GameObject类中:
private float xVelocity;
private float yVelocity;
final int LEFT = -1;
final int RIGHT = 1;
private int facing;
private boolean moves = false;
现在,在GameObject类中,我们将添加一个move方法。这个方法简单地检查任一轴上的速度是否为零,如果是,则通过改变其worldLocation对象来移动对象。此方法使用速度(xVelocity或yVelocity)除以当前每秒帧数来计算每帧要移动的距离。这确保了移动将完全正确,无论当前每秒帧数是多少。我们的游戏是否执行流畅,或者略有波动,或者 Android 设备中的 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。需要注意的是,两个速度变量的 setter(setxVelocity和setyVelocity)在实际上传值之前会检查if(moves)。将这些新的 getter 和 setter 添加到GameObject类中。
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方法中实际使用所有这些新代码。
首先,我们处理当isPressingRight或isPressingLeft为真时会发生什么。当然,我们仍然需要能够通过屏幕触摸来设置这些变量。非常简单,接下来的代码块将水平速度设置为MAX_X_VELOCITY,如果isPressingRight为真,或者设置为-MAX_X_VELOCITY,如果isPressingLeft为真。如果两者都不为真,则将水平速度设置为零,即站立不动。
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);
}
接下来,我们检查玩家移动的方向,并使用RIGHT或LEFT作为参数调用setFacing()。
//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被设置为false,直到玩家下一次点击跳跃按钮。以下代码中的最后一个else子句在isJumping为false时执行,并将玩家的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()来更新x和y坐标,如果它们已经改变。
// 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构造函数中设置playing为true的那行代码。不久,这将由屏幕触摸和刚刚编写的该方法来处理:
// 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”中所述,在“碰撞检测”部分Things that go bump。其次,通过在Player类中处理检查,我们可以给玩家多个不同的碰撞盒,并根据被击中的碰撞盒做出不同的反应。
让我们创建自己的碰撞盒类,这样我们就可以按照自己的意愿来设计它。它需要使用浮点坐标,需要一个intersects方法以及一些 getter 和 setter。创建一个新的类,命名为RectHitbox。
在这里,我们看到RectHitbox只是一些简单的 getter 和 setter 方法。它还有一个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;
}
}
现在,我们可以在GameObject中将RectHitbox类作为成员添加。在类声明之后添加它。
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 方法,以便在检测到碰撞时可以更改x和y世界坐标。将以下两个方法添加到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方法,这使得我们能够在跳跃尝试成功时播放一个整洁的复古跳跃声音。将这三个新方法添加到Player类中:
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 对象的点。
我们定义了一些方便的变量,buttonWidth、buttonHeight 和 buttonPadding,基于设备的屏幕分辨率来帮助我们整齐地安排我们的 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_DOWN 和 ACTION_UP 事件。现在,我们需要深入挖掘以获取更多的事件数据。
为了记录和传递多个手指触摸、离开和移动到屏幕上的详细信息,MotionEvent 类将它们全部存储在一个数组中。当玩家的第一个手指触摸屏幕时,详细信息、坐标等存储在位置零。然后后续动作随后存储在数组中的较后位置。
与任何此类手指活动相关的数组中的位置并不一致。在某些情况下,例如检测特定手势,这可能会成为一个问题,程序员需要捕获、记住并响应 MotionEvent 类中持有的手指 ID。
幸运的是,在这种情况下,我们有明确定义的屏幕区域,代表我们的按钮,我们最需要知道的是手指是否在其中一个预定义区域内按下或释放屏幕。
我们只需要找出有多少手指导致了事件,并且因此存储在数组中,通过调用 motionEvent.getPointerCount()。然后我们遍历这些事件中的每一个,同时提供一个 switch 块来处理它们,无论屏幕的哪个区域发生了 ACTION_DOWN 或 ACTION_UP。我们的事件存储在数组中的哪个位置并不重要,只要我们检测到它并对其做出响应。
在我们可以编写解决方案之前,我们还需要知道的是,数组中的后续动作存储为 ACTION_POINTER_DOWN 和 ACTION_POINTER_UP;因此,在接下来的代码中,每次循环遍历时,我们需要检查和处理 ACTION_DOWN 和 ACTION_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 方法绘制整洁的圆角矩形,以表示屏幕上将对玩家的触摸做出响应的区域。在调用 unlockCanvasAndPost() 之前,在 draw 方法中输入此代码:
//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 初始化一个新的动画对象。此外,当它们在 PlatformView 的 draw 方法中被绘制时,如果对象是动画的,我们将对其进行稍微不同的处理。
在本节中,我们还将看到如何使用跟踪玩家朝向的面向变量。它将使我们能够根据玩家的朝向反转精灵图集。
让我们从创建动画类开始。创建一个新的 Java 类,命名为Animation。接下来的代码将声明变量,这些变量将保存要操作的位图、位图的名称,以及一个rect参数来定义精灵图集中的区域,这是当前相关动画帧的坐标。
此外,我们还有frameCount、currentFrame、frameTicker和framePeriod,它们保存和控制可用帧的数量、当前帧号和帧变化的时机。正如你所期望的,我们还需要知道动画帧的宽度和高度,这些由frameWidth和frameHeight保存。
此外,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类调用。因此,看起来奇怪的检查是在确定是否需要新的帧。
如果一个对象移动(例如鲍勃),但处于静止状态,那么我们不需要改变动画帧。然而,如果一个动画对象从未有过速度,比如咆哮的火焰,那么我们需要一直给它动画。它将永远不会有任何速度,所以moves变量将是false,但方法将继续进行。
该方法使用time、frameTicker和framePeriod来确定是否是显示下一个动画帧的时候,并且是否增加帧数以显示。然后,如果动画处于最后一帧,它将回到第一帧。
最后,计算并返回代表包含所需帧的精灵图集部分的精确左右位置,这些位置被返回给调用代码。
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 所有的动画辉煌。截图无法显示他的动作,但你可以看到他现在完美成型:

摘要
我们的游戏正在稳步推进。在这个阶段,我们可以在LevelCave中构建一个巨大的关卡设计,到处奔跑和跳跃。然而,我们将保存以推迟尝试使游戏可玩,直到我们添加更多整洁的功能。
这些整洁的功能将包括一挺机枪,它可以通过可收集的拾取物和一些 Bob 可以射击的敌人进行升级。我们将在下一章开始介绍这些内容。
第七章:平台游戏 - 枪支、生命、金钱和敌人
在本章中,我们将做很多事情。首先,我们将构建一个具有可变射速的机枪,并让它射击子弹。然后,我们将介绍拾取物或可收集物品。这些物品在玩家试图逃入下一级时提供了一些搜寻的东西。
然后,就在 Bob 开始认为他的生活是幸福的一生的草地和可收集物品时,我们将为他构建两个对手,让他智胜或杀死。一个归巢无人机和一个巡逻守卫。我们将很容易将这些事物添加到我们的关卡设计中。
准备瞄准射击
现在,我们可以给我们的英雄配备一把枪,稍后,我们可以给他一些敌人来射击。我们将创建一个MachineGun类来完成所有工作,并创建一个Bullet类来表示它发射的弹丸。Player类将控制MachineGun类,而MachineGun类将控制并跟踪它发射的所有Bullet对象。
创建一个新的 Java 类,命名为Bullet。子弹并不复杂。我们的子弹将需要一个x和y位置、一个水平速度和一个方向来帮助计算速度。
这意味着以下简单的类、构造函数以及一大堆 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 个对于一把非常快速的射击枪来说是足够的,就像我们将会看到的那样。numBullets和nextBullet成员帮助类跟踪其 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,如果你想了解更多,请访问:
我们有一个只初始化子弹、lastShotTime和nextBullet的构造函数:
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方法中重新分配。shoot方法中。
public void hideBullet(int index){
bullets.get(index).hideBullet();
}
一个 getter 方法,返回移动的方向:
public int getDirection(int index){
return bullets.get(index).getDirection();
}
现在,我们添加一个更全面的方法,实际上发射子弹。该方法比较上次发射的射击时间与当前的rateOfFire。然后继续增加nextBullet并创建一个新的Bullet对象(如果允许的话)。子弹以与鲍勃面对的方向相同的速度飞出。注意,如果成功发射了子弹,该方法返回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类记住一个x和y位置,当玩家失去生命时可以重生。输入这些成员变量和简单的构造函数:
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
接下来,声明一个PlayerState类型的对象,作为PlatformView类的成员:
// 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 类:Coin、ExtraLife和MachineGunUpgrade。注意,拾取物比平台小一点,也许正如我们预期的那样。依次输入它们的代码。
以下是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类以期望我们在关卡设计中使用这三个新对象,并将它们添加到GameObjects的ArrayList中。为此,我们需要在三个地方更新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设计中。请将clip.png、coin.png和life.png从下载包中的Chapter7/drawables文件夹复制到 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。我们需要成员变量来记住我们上次设置航点的时刻。这将限制无人机获取鲍勃坐标导航更新的频率。这将阻止无人机过于精确。它需要一个航点/目标坐标,并且还需要通过MAX_X_VELOCITY和MAX_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类的最后一个方法中,通过传递鲍勃的坐标作为参数来更新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
将Chapter7/drawable中的无人机图形drone.png添加到你的项目中的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派生对象将意味着鲍勃会立即死亡。它们不会移动,但会进行动画。我们将看到我们只需设置GameObject已经存在的属性就能实现这一点。
将此功能添加到我们的游戏中很简单,因为我们已经实现了我们需要的所有功能。我们已经有了一种定位和添加新砖块的方法,一种检测和响应碰撞的方法,精灵表动画,等等。让我们一步一步来做,然后我们可以将这些危险和生命威胁元素添加到我们的世界中。
我们可以将类的整个功能放入其构造函数中。我们所做的只是配置对象,就像我们配置我们的Grass对象一样,但除此之外,我们还要配置所有动画设置,就像我们对Player和Guard对象所做的那样。fire.png精灵图集有 3 帧动画,我们希望在一秒钟内播放。

创建一个新的类,命名为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文件夹中。
然后,我们将向我们的LevelManager类添加内容,就像我们为所有新的GameObject派生类所做的那样,通常有三种方式。
在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;
}
}
现在,我们将我们的新类型 ArrayList 添加到 LevelData 类中:
ArrayList<String> tiles;
ArrayList<BackgroundData> backgroundDataList;
// This class will evolve along with the project
接下来,让我们创建 Background 类本身。创建一个新的类,命名为 Background。首先,我们设置一些变量来保存背景图像的一个副本以及一个反转的副本。我们将通过交替放置正图像和反转图像来使背景看起来是无限的。我们将在代码的后续部分看到如何实现这一点。
我们还有用于图像宽度和高度的像素变量。reversedFirst 布尔值将确定当前绘制在屏幕左侧(第一)的图像副本是哪个。当玩家移动和图像滚动时,它将改变。xClip 变量将保存 x 轴(图像的)上的精确像素,我们将从屏幕的左侧边缘开始切割图像并绘制它。
y、endY、z 和 speed 成员变量用于存储作为参数传入的相关值:
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)来缩放我们刚刚创建的位图,使位图正好适合当前设备的屏幕。
提示
注意,我们必须为我们的背景设计选择适当的高度,否则它们将看起来被拉伸。
在构造函数中我们做的最后一件事是创建一个Matrix对象,并将其与位图一起发送到createScaledBitmap方法,因此我们现在在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对象,我们将使用它们来保存bitmap和reversedBitmap的起始和结束点。
按照以下方式实现drawBackground方法的第一部分:
private void drawBackground(int start, int stop) {
Rect fromRect1 = new Rect();
Rect toRect1 = new Rect();
Rect fromRect2 = new Rect();
Rect toRect2 = new Rect();
现在,我们只需使用start和stop参数遍历所有背景,以决定哪些背景具有我们目前感兴趣绘制的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(如果它没有被裁剪)横跨屏幕的宽度。很快,我们将看到我们根据鲍勃的速度修改 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);
}
我们可以根据鲍勃的速度和方向进行滚动,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;
}
}
}
}
然后,我们在游戏对象之前添加对 drawBackground() 的调用,对于具有小于零的 z 层的背景。
// 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 类时,平台游戏 – 鲍勃、哔哔声和碰撞,我们将所有控制逻辑包裹在 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;
当加载新关卡时,Player、MachineGun和Bullet对象都是从零开始创建的。因此,我们需要在我们的loadLevel方法中添加一行,将PlayerState类中的当前机枪射速重新加载到MachineGun类中。添加以下高亮代码:
ps.saveLocation(location);
// Reload the players current fire rate from the player state
lm.player.bfg.setFireRate(ps.getFireRate());
现在,我们可以真正开始设计关卡了。
关卡设计
你可以直接从Chapter8/java文件夹复制粘贴四个类到你的项目中开始游戏,或者你可以从头开始并设计自己的关卡。这些关卡相当大,复杂,难以击败。在书中或电子书中以任何有意义的方式打印关卡设计都是不可能的,所以你需要打开LevelCave、LevelCity、LevelForest和LevelMountain设计文件,才能看到四个关卡的具体细节。
然而,以下是对关卡、图片和一些截图的简要讨论,但不是来自四个设计的实际代码。
注意
注意,以下截图展示了新的 HUD,这是我们将在本章最后讨论的最后一个内容。
洞穴
洞穴关卡是整个游戏开始的地方。它不仅包含适度令人沮丧的跳跃,还有大量的火焰,使得坠落可能致命。

作为玩家开始时只有一把微弱的机枪,这个关卡中只有几个无人机。但有两个笨拙的守卫需要翻越。

城市
城市拥有巨大的奖励,特别是在左下角有硬币,在左上角有机枪升级。

然而,如果玩家想要收集所有散落的硬币而不是选择留下它们,那么底层有一个非常难以跳跃的守卫。必须穿越几乎垂直的上升,沿着左手边向上爬,这可能会令人沮丧。如果玩家选择不升级机枪,他可能会在通往下一关的门外的双重守卫处遇到困难。

森林
森林可能是所有关卡中最具挑战性的关卡,因为有一段非常长的跳跃,很容易跳过或跳得太低。

并且有超过一打无人机等着扑向鲍勃,他的像素正悬挂在平台边缘,摇摇欲坠。

山脉
新鲜的山间空气意味着鲍勃几乎已经成功了。没有守卫或无人机在视线范围内。

然而,看看那些跳跃的蜿蜒路径,大多数情况下,如果鲍勃有一个像素放错位置,他就会被直接扔回底部。

小贴士
如果你想尝试每个关卡而不完成前面的艰难关卡,当然,你只需从你选择的关卡和位置开始即可。为此,只需将PlatformView构造函数中对loadLevel()的调用更改为以下之一:
loadLevel("LevelMountain", 118, 17);
loadLevel("LevelForest", 1, 17);
loadLevel("LevelCity", 118, 18);
loadLevel("LevelCave", 1, 16);
HUD
最后的修饰是添加一个 HUD。这个代码位于PlatformView的draw方法中,使用了现有游戏对象中的一些图形。
在调用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类中的代码,使鲍勃逐渐加速和减速,而不是始终以全速奔跑。只需在玩家按住左右键的每一帧增加速度,在他们不按的每一帧减少速度。
一旦你做到了这一点,就将前面的代码添加到update方法中的碰撞检测switch块中,使玩家在雪地上打滑,在混凝土上加速,并为每种地砖类型提供不同的行走/着陆音效。
在鲍勃身上画一把枪,并调整Bullet对象生成的位置高度,使其看起来是从他的机枪枪管中发射出来的。
使一些物体可推动。给GameObject添加一个isPushable成员,并使碰撞检测简单地使物体稍微后退。也许,鲍勃可以推动矿车进入火中,以跳过更宽的火坑。请注意,推动掉落到另一个级别的物体将比推动保持在同一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 或更高的帧率将我们的简单但暂时静态的宇宙飞船绘制到屏幕上。
小贴士
如果你从未见过或玩过 1980 年代的街机游戏《小行星》(1979 年 11 月发布),为什么不现在去看看它的克隆版或视频呢?
免费网页游戏在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 1 的最初版本在当时确实给人留下了深刻印象。我记得当我第一次在手机上玩 3D 射击游戏时,差点从椅子上摔下来!现在这当然是家常便饭。然而,与桌面版本的 OpenGL 相比,OpenGL ES 1 有一个主要的缺点。
OpenGL ES 1 有一个被称为固定功能管道的东西。要绘制的几何形状进入 GPU 并绘制,但任何进一步操纵单个像素的操作都需要在 OpenGL ES 接管游戏帧的绘制之前进行。
现在,有了 OpenGL ES 2,我们可以访问所谓的可编程管道。也就是说,我们可以将我们的图形发送出去绘制,但我们还可以编写在 GPU 上运行的代码,这些代码能够独立操纵每个像素。这是一个非常强大的功能,尽管我们不会深入探索它。
在 GPU 上运行的额外代码被称为着色器程序。我们可以在称为顶点着色器的地方编写代码来操纵我们图形的几何形状(位置)。我们还可以编写代码来单独操纵每个像素的外观,这被称为片段着色器。
注意
实际上,我们甚至可以做得比像素操作更好。片段不一定是像素。这取决于硬件和正在处理的图形的具体性质。它可能是一个以上的像素或亚像素:屏幕硬件中构成像素的几个光之一。
对于像这样的简单游戏,OpenGL ES 2 的缺点是,即使你不会对它们做很多事情,也必须至少提供一个顶点着色器和一个片段着色器。然而,正如我们将看到的,这并不困难。尽管我们不会深入探讨着色器,但我们将编写一些着色器代码,使用GL 着色器语言(GLSL),并一瞥它们提供的可能性。
小贴士
如果可编程图形管道和着色器的力量太过激动人心,以至于无法留到另一天,那么我强烈推荐 Jacobo Rodríguez 的《GLSL Essentials》。
www.packtpub.com/hardware-and-creative/glsl-essentials
本书探讨了桌面上的 OpenGL 着色器,对任何具有基本编程知识并愿意学习不同语言(GLSL)的读者来说都易于理解,尽管它与 Java 有一些语法相似之处。
我们将如何使用 OpenGL ES 2?
我们将如何使用 OpenGL ES 2?
在 OpenGL 中,一切都是点、线或三角形。此外,我们还可以将颜色和纹理附加到这种基本几何形状,并将这些元素组合起来,以制作我们今天在现代移动游戏中看到的复杂图形。
我们将使用每种类型的一些元素(点、线和三角形),这些元素统称为原语。
在这个项目中,我们不会使用纹理。幸运的是,无纹理原语的外观非常适合构建类似 Asteroids 的游戏。
除了原语之外,OpenGL 还使用矩阵。矩阵是一种执行算术的方法和结构。这种算术可以从非常简单的中学水平计算(移动(平移)坐标)到执行更复杂的数学,将我们的游戏世界坐标转换为 GPU 可以使用的 OpenGL 屏幕坐标。
关键在于 OpenGL API 完全提供了矩阵及其使用方法。这意味着我们只需要了解哪些方法执行哪些图形操作,而无需关心幕后(在 GPU 上)可能出现的复杂数学。
在 OpenGL 中了解着色器、原语和矩阵的最好方法就是直接开始使用它们。
准备 OpenGL ES 2
首先,我们从Activity类开始,正如之前一样,它是我们游戏的入口点。创建一个新的项目,在应用程序名称字段中输入C9 Asteroids。选择手机和平板电脑,然后当提示时选择空白活动。在活动名称字段中输入AsteroidsActivity。
小贴士
显然,你不必完全遵循我的命名选择,但请记住对代码进行一些小的修改,以反映你自己的命名选择。
你可以从layout文件夹中删除activity_asteroids.xml。你还可以从AsteroidsActivity.java文件中删除所有代码。只需留下包声明。
锁定布局为横屏
就像我们为前两个项目所做的那样,我们将确保游戏只在横屏模式下运行。我们将修改我们的AndroidManifest.xml文件,强制我们的AsteroidsActivity类全屏运行,并将其锁定为横屏方向。让我们进行以下更改:
-
现在打开
manifests文件夹,双击AndroidManifest.xml文件,在代码编辑器中打开它。 -
在
AndroidManifest.xml文件中,找到以下代码行:android:name=".AsteroidsActivity" -
立即输入或复制粘贴以下两行代码,使
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类涉及的内容。
管理我们游戏的一个类
这个类将控制玩家所在的关卡、生命值以及游戏世界的整体大小等。随着项目的进展,它会有所发展,但与之前项目中LevelManager和PlayerState类的综合深度相比,它将保持相当简单,尽管它实际上取代了这两个类。
在接下来的代码中,我们声明int成员来保存游戏世界的宽度和高度;我们可以根据需要将其做得更大或更小。我们使用布尔值playing来跟踪游戏的状态。
GameManager类还需要知道屏幕的宽度和高度(以像素为单位),当对象在AsteroidsView类中初始化时,这个信息被传递给构造函数。
还要注意metresToShowX和metresToShowY成员变量。这些可能听起来与上一个项目中我们的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;
}
}
现在,我们可以首次查看这些功能强大的着色器以及我们将如何管理它们。
管理简单的着色器
一个应用程序可以有多个着色器。然后我们可以将不同的着色器附加到不同的游戏对象上,以创建所需的效果。
在这个游戏中,我们只会使用一个顶点着色器和一个片段着色器。然而,当你看到如何将着色器附加到原语时,你会明白拥有更多着色器是多么简单。
-
首先,我们需要在 GPU 上执行的着色器代码。
-
然后我们需要编译那段代码。
-
最后,我们需要将两个编译好的着色器链接成一个 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 是将用于表示我们游戏对象中单个顶点(点)的值的数量。正如你所见,我们将其初始化为三个坐标:x、y 和 z。
我们还有 FLOAT_SIZE,其初始化为 4。这是 Java 中 float 类型的字节数。正如我们很快就会看到的,OpenGL 喜欢以 ByteBuffer 的形式接收所有传入它的原型的数据。我们需要确保我们精确地知道 ByteBuffer 中每条信息的位置。
接下来,我们声明 STRIDE 并将其初始化为 COMPONENTS_PER_VERTEX * FLOAT_SIZE。由于 OpenGL 使用 float 类型来存储它处理的所有数据,因此 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 的变量,其类型为 uniform mat4,以及一个名为 a_Position 的变量,其类型为 attribute vec4。我们将在后面的 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;"+
"}";
接下来,我们将实现片段着色器。这里发生了一些事情。首先,行精度 mediump float 告诉 OpenGL 以中等精度绘制,因此速度中等。然后我们可以看到我们的变量 u_Color 被声明为类型 uniform vec4。我们将在后面的 GameObject 类中看到如何将 color 值传递给这个变量。
当执行从void main()开始时,我们只需将u_Color赋值给gl_FragColor。所以,无论分配给u_Colour的颜色是什么,所有片段都将具有该颜色。在片段着色器之后,我们声明一个名为program的int,它将作为我们的 GL 程序的句柄。
在上一段代码块之后立即输入片段着色器的代码:
// A very simple fragment Shader
// that we can define with a String
private static String fragmentShader =
"precision mediump float;" +
"uniform vec4 u_Color;" +
"void main()" +
"{" +
"gl_FragColor = u_Color;" +
"}";
// A handle to the GL glProgram public static int program;
这是一个获取方法,它返回 GL 程序的句柄:
public static int program;
public static int getGLProgram(){
return program;
}
下一个方法可能看起来很复杂,但它所做的只是返回一个编译和链接好的程序给调用者。它是通过调用 OpenGL 的linkProgram方法,并将compileVertexShader()和compileFragmentShader()作为参数来实现的。接下来,我们看到这两个新方法,它们只需要调用我们的compileShader()方法,并使用 OpenGL 常量表示着色器类型和适当的字符串,该字符串包含匹配的 GLSL 着色器代码。
将我们刚才讨论的三个方法放入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 渲染器系统,该系统决定了这个类的流程。
要开始,我们有一些成员变量值得仔细查看。
布尔调试将用于在控制台开启和关闭输出。frameCounter、averageFPS 和 fps 变量不仅用于检查我们达到的帧率,还将传递给我们的游戏对象,这些对象将根据每一帧的经过时间自行更新。
我们第一个真正有趣的变量是浮点数组 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 必须代表 x 和 y 轴。幸运的是,我们的 glViewport() 已经为我们处理了这个问题。
在这个方法中我们看到最后一件事情是调用 orthoM 方法,并将 viewportMatrix 作为第一个参数。OpenGL 现在将为 OpenGL 本身准备 viewportMatrix 以供使用。orthoM 方法创建一个矩阵,将坐标转换为正交视图。如果我们的坐标是三维的,它将产生所有对象看起来距离相同的效果。由于我们正在制作一个二维游戏,这也适合我们。
输入 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(),这将告诉所有我们的对象绘制自己。
然后,我们更新timeThisFrame和fps,可选地输出每 100 帧的平均帧数,如果我们在调试的话。
现在我们知道 OpenGL 会每秒调用onDrawFrame()数百次。我们将有条件地每次调用我们的update方法以及调用我们的draw方法。我们实际上已经实现了游戏循环,除了实际的draw和update方法本身。
将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()方法中我们接下来要做的事情非常有趣。我们根据游戏世界中的当前位置和分配给metresToShowX和metresToShowY的值修改我们的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 numElements 和 numVertices,它们保存构成任何给定 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();
接下来,我们将添加另一批成员变量。首先,也是最重要的,我们有一个名为 vertices 的 FloatBuffer。正如我们所知,OpenGL 在本地代码中执行,FloatBuffers 是它喜欢消费数据的方式。我们将看到如何将所有顶点打包到这个 FloatBuffer 中。
我们还将使用 GLManager 类中的所有公共静态成员来帮助我们正确实现。
在 OpenGL 方面,第二有趣的新成员可能是我们还有另外三个名为 modelMatrix、viewportModelMatrix 和 rotateViewportModelMatrix 的浮点数组。这些将在帮助 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(glProgram == -1) 块内的内容。
我们调用 setGLProgram(),然后使用 glUseProgram() 并将 glProgram 作为参数。这就是我们必须要做的,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;
}
现在我们有几个获取器和设置器,包括 getWorldLocation(),我们在 AsteroidsRenderer 的 draw 方法中调用它,以及 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 中存储与数组长度相等的元素数量。请注意,元素的数量与表示的顶点数量不同。需要一个 (x,y,和 z) 元素来构成一个顶点。因此,我们可以通过将 numElements 除以 ELEMENTS_PER_VERTEX 来将正确的值存储到 numVertices 中。
现在,我们可以通过调用 allocateDirect() 并传入我们新初始化的变量以及 FLOAT_SIZE 来实际初始化我们的 ByteBuffer。ByteOrder.nativeOrder 方法简单地检测特定系统的端序,而 asFloatBuffer() 告诉 ByteBuffer 将存储的数据类型。现在,我们可以通过调用 vertices.put(modelVertices) 将我们的顶点数组存储到顶点 ByteBuffer 中。这些数据现在已准备好传递给 OpenGL。
小贴士
如果你想了解更多关于端序的信息,请查看这篇维基百科文章:
将 setVertices 方法输入到 GameObject 类中:
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,但为了清楚起见,这与它无关。这被称为模型空间。下一张图是我们即将创建的宇宙飞船在模型空间中的表示:

这就是我们ByteBuffer中包含的数据。这些数据不考虑方向(飞船或小行星是否旋转),不考虑其在游戏世界中的位置,并且作为提醒,它与 OpenGL 坐标系统完全无关。
因此,在我们绘制ByteBuffer之前,我们需要转换这些数据,或者更准确地说,我们需要准备一个适当的矩阵,我们将这个矩阵与数据一起传递给 OpenGL,这样 OpenGL 就会知道如何使用或转换这些数据。
我已经将draw方法分成六个部分来讨论我们是如何做到这一点的。请注意,我们的viewPort矩阵是在AsteroidsRenderer类的draw方法中准备的,该方法以飞船的位置为中心,基于我们想要显示的游戏世界的比例,并作为参数传递。
首先,我们调用glUseProgram()并传入我们程序的句柄。然后我们通过vertices.position(0)将我们的ByteBuffer的内部指针设置到开始位置。
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 方法中。平移是数学术语,意为移动。仔细观察传递给 translateM() 的参数。我们传递了对象的 x 和 y 世界位置。这是 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 = 0 和 y = 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 的速度绘制
在三个简单的步骤中,我们将能够一瞥我们的宇宙飞船:
-
将
SpaceShip对象添加到GameManager成员变量中:private boolean playing = false; // Our first game object SpaceShip ship; int screenWidth; -
将对新的
SpaceShip()的调用添加到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); } -
在
AsteroidsRenderer的draw方法中调用绘制太空船的调用,在每个帧中:// Start drawing! // Draw the ship gm.ship.draw(viewportMatrix);
运行游戏并查看输出:

并不是特别令人印象深刻的视觉效果,但在调试模式下,当输出到一台老式的三星 Galaxy S2 手机的控制台时,它每秒可以运行 67 到 212 帧。

在整个项目中,我们的目标是将数百个对象添加到游戏中,并保持每秒 60 帧以上的帧率。
小贴士
该书的一位评论者报告说,在 Nexus 5 上帧率超过了每秒 1000 帧!因此,如果你计划将此发布到 Google Play 商店,考虑一个最大帧率锁定策略以节省电池寿命将是值得考虑的。
摘要
设置绘图系统有点冗长。然而,现在它已经完成,我们可以更容易地生成新对象。我们只需要定义类型和顶点,然后我们可以轻松地绘制它们。
正是因为这个基础,下一章将更加视觉上令人满意。接下来,我们将创建闪烁的星星、游戏世界边界、旋转和移动的小行星、快速移动的子弹,以及 HUD,并添加完整的控制和太空船的运动。
第十章. 使用 OpenGL ES 2 移动和绘制
在本章中,我们将实现所有的图形、游戏玩法和移动。在超过 30 页的内容中,我们将完成除了碰撞检测之外的所有内容。我们能实现这么多,是因为我们在上一章中打下的基础。
首先,我们将绘制一个静态的边界围绕我们的游戏世界,然后是一些闪烁的星星,接着添加太空船的运动以及一些子弹。之后,我们将快速添加玩家的控制,我们将在屏幕上快速移动。
我们还将通过实现带有一些新音效的SoundManager类来制造一些噪音。
一旦完成这些,我们将在世界中添加随机形状的小行星,它们在旋转的同时移动。
然后,我们可以添加一个 HUD 来突出屏幕的可触摸区域,并提供剩余玩家生命和需要摧毁以进入下一级的小行星的计数。
绘制静态游戏边界
在这个简单的类中,我们定义了四组点,这些点将代表四条线。不出所料,GameObject类将使用这些点作为线的端点来绘制边界。
在构造函数中,即整个类的内容,我们通过调用setType()设置类型,将世界位置设置为地图的中心,以及将height和width设置为整个地图的高度和宽度。
然后,我们在一个浮点数组中定义四条线,并调用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);
}
}
然后,我们可以将一个Border对象声明为GameManager的一个成员,如下所示:
// Our game objects
SpaceShip ship;
Border border;
在AsteroidsRenderer的createObjects方法中初始化它,如下所示:
// 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);
}
}
}
然后,我们声明一个Star数组,作为GameManager的一个成员,以及一个额外的int变量来控制我们想要绘制的星星数量,如下所示:
// Our game objects
SpaceShip ship;
Border border;
Star[] stars;
int numStars = 200;
在AsteroidsRenderer的createObjects方法中初始化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中这样做,因为子弹和陨石与飞船有惊人的相似之处。
我们需要一些 getter 和 setter 来获取和设置旋转速率、移动角度和面向角度。将以下方法添加到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方法,该方法根据当前每秒帧数调整对象的x和y坐标以及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类的补充,添加以下用于速度、速度和最大速度的 getter 和 setter:
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类添加一些功能。添加以下三个成员以控制玩家的飞船是否转向或向前移动:
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是true还是false增加和减少速度。
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);
}
}
然后,我们根据角度设置x和y速度,即船面对的方向和速度。
注意
我们使用速度乘以船面对的角度的余弦值来设置x轴上的速度。这是因为余弦函数是一个完美的变体,当船正好面向左或右时,它会返回-1 或 1 的值;当船正好向上或向下时,变体会返回精确的 0 值。它也会在中间返回精细的值。角度的正弦在y轴上以完全相同的方式工作。代码看起来稍微复杂一些,是因为我们需要将我们的角度转换为弧度,并且必须将 90 度添加到我们的facingAngle中,因为 0 度是指向三点的。这个事实不利于我们在x,y平面上以我们有的方式使用它,所以我们将其修改为 90 度,船就会按预期移动。有关此工作方式的更多信息,请查看这个教程:
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);
显然,在我们添加玩家控制之前,我们无法实际移动。让我们快速为游戏添加一些子弹。然后,我们将添加声音和控件,以便我们可以看到和听到我们添加的酷炫新功能。
连续射击子弹
自从 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为true,并查看这在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;
在AsteroidsRenderer中的createObjects()方法中初始化它们,紧接上一节中的星星之后。注意我们如何初始化它们在游戏世界中的位置,即船的中心。
// 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);
}
子弹现在可以发射了!
我们将添加一个SoundManager和InputController类,然后我们就可以看到我们的船和它的快速射击枪在行动了。
重用现有类
让我们快速将SoundManager和InputController类添加到这个项目中,因为它们只需要稍作调整就能满足我们的需求。
在AsteroidsView和AsteroidsRenderer类中为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构造函数的调用中添加两个额外的参数,以传递SoundManager和InputController对象的引用。
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文件夹中的所有声音文件添加到你的项目资源文件夹中。和前两个项目一样,你可能需要在项目的.../app/src/main文件夹中创建资源文件夹。
小贴士
和往常一样,你可以使用提供的声音效果或创建自己的。
现在,向项目中添加一个名为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更简单、更短。
将onTouchEvent方法添加到AsteroidsView类中,以便将处理触摸的责任传递给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(Head-Up Display)使控制飞船变得容易。
分数和 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类的基于屏幕坐标的视口变换——一个int 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方法,这是从GameObject中的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);
// 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();
}
这现在看起来可能很熟悉的绘制方法。
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 对象
我们将声明、初始化和绘制我们的 HUD 对象,就像所有的GameObject类一样。然而,请注意,正如预期的那样,我们不向draw方法传递视口矩阵,因为 HUD 类提供了自己的。
将这些成员添加到GameManager:
TallyIcon[] tallyIcons;
int numLives = 3;
LifeIcon[] lifeIcons;
正如我们对asteroids数组所做的那样,在GameManager构造函数中初始化tallyIcons和lifeIcons:
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++;
}
现在我们可以根据剩余的生命数和下一关之前剩余的陨石数来绘制我们的 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。

显然,如果我们想要利用我们的生命和陨石计数指示器,那么我们首先需要能够射击小行星,以及在飞船被击中时检测它们。
概述
我们在本章中取得了许多成果,实际上简单地添加更多游戏对象也很容易。也许,偶尔会出现像原始街机经典游戏中的 UFO。
在下一章中,我们将利用之前项目中学到的知识来设置碰撞检测并完成游戏。然而,一个具有精确、干净、平滑移动线条的游戏需要比我们迄今为止使用的更精确的碰撞检测。
因此,我们将专注于仅实现精确、高效的碰撞检测,这将使我们的《小行星》模拟器完整。
第十一章。碰撞事件——第二部分
这个游戏的碰撞检测比前两个游戏复杂得多。因此,代码将会有很多注释。有时注释会稍微详细或以略不同的方式解释一些内容。
然而,这并不意味着它需要是件苦差事。我们需要做的是花点时间考虑一个对我们有效的策略。
希望到本章结束时,我们的碰撞检测解决方案将看起来很简单。
碰撞检测规划
我们试图实现的目标可以分为以下两类:
-
我们对边界的期望:
-
小行星、子弹和飞船需要知道它们何时与边界发生碰撞
-
小行星在接触边界时应该反向并返回游戏区域
-
子弹应该在边界处重置
-
飞船应该减去一条生命并在中心重生
-
-
我们对小行星的期望。我们需要知道并响应以下情况:
-
飞船接触小行星
-
当子弹接触小行星时
-
就像原始的《小行星》游戏一样,我们不会对小行星之间的碰撞做出反应
-
虽然我们不会在小行星碰撞中检测小行星,但你会看到,当我们的碰撞检测接近完成时,实现小行星之间的碰撞检测不会带来太大的额外挑战。然而,它会给设备的 CPU 带来额外的压力。
我们知道我们需要检测边界碰撞上的对象和小行星碰撞上的对象。
与边界的碰撞
虽然听起来很明显,但边界只是四条静态的直线。这使得边界碰撞与小行星碰撞成为不同的问题。
我们感兴趣的所有对象都有顶点(或者子弹的情况是一个顶点)。这最初可能表明我们可以简单地从模型空间和存储在worldLocation中的对象中心计算每个顶点的世界位置。我们可以这样做,但忽略了小行星和飞船会旋转的事实,这会不断改变所有顶点的实际世界位置。
我们需要翻译和旋转模型空间顶点,然后测试它们是否触碰了边界。我们可以在对象的update方法中为每一帧做这件事,但只有在对象非常接近边界时,我们才偶尔需要旋转的坐标。
边界碰撞检测的第一阶段
这表明,一个初步检查,碰撞检测的第一阶段,更有效率。它意味着顶点的平移和旋转需要在对象本身之外进行。
我们将使用基于对象中心和其宽度和高度的简单矩形相交检查。如果这个便宜的方法返回一个命中,然后我们将旋转和转换每个顶点,并单独检查它们的实际世界坐标与边界的位置。
一旦计算出了顶点的旋转游戏世界位置,碰撞检测就变得简单了。
if (any point falls outside the border){collision has occurred}
正如我们将看到的,对于小行星检测,一个两阶段解决方案是合适的。此外,旋转和转换是涉及的,但它远不那么重要。
与小行星碰撞
测试与小行星的碰撞在某些方面是相似的。我们需要找出船或子弹的任何单个顶点是否穿入了由小行星顶点包含的空间。
第一个问题是小行星不仅是一个移动的目标,而且是一个旋转的目标。我们不仅需要旋转和转换所有对象的顶点,还需要小行星。
我们还需要计算小行星上每对顶点之间的线。幸运的是,在这个时候,我们可以依赖一个比我更伟大的数学家设计的巧妙算法。我们将使用交叉数算法。这是它的工作原理。
交叉数
我们计算由一对顶点形成的线,并使用交叉数算法来查看测试对象中的特定顶点是否穿过了那条线。如果它确实穿过了,我们将变量从 0 增加到 1。
我们将测试点与来自小行星的每个顶点对形成的每条线进行测试,每次测试时增加我们的变量。如果测试顶点与交叉数算法中的每条线测试后,我们的变量是奇数,我们就有了一个命中。如果是偶数,则没有发生碰撞。
当然,如果没有发生碰撞,我们必须继续测试测试对象中的每一个顶点与由小行星顶点对形成的每一条线的碰撞。
这里是交叉数算法在作用中的视觉表示。

当然,在所有这些复杂的计算进行中,我们肯定会想先进行一个简单的初步测试,看看是否有可能发生了碰撞,然后再进行复杂的测试。
边界碰撞检测的第一阶段和概述
当测试单个顶点时,例如子弹、旋转的三角形(如飞船)或旋转的小行星,半径重叠测试非常合适。
这是我们将用于测试与小行星碰撞的整个过程的概述:
-
被测试物体的半径是否与小行星的半径重叠?
-
如果是,物体的第一个顶点是否穿过了小行星的第一条线?
-
如果是,
crossingNumber ++。 -
对物体的每条线重复步骤 2。
-
如果
crossingNumber是奇数,则向调用代码返回 true,因为发生了碰撞。 -
如果
crossingNumber是偶数,则尚未发生碰撞(重复步骤 2、3 和 4,使用待测试物体的下一个顶点)。 -
如果所有顶点都已测试并且我们到达这里,那么没有发生碰撞。
我们将设置一个名为 CD 的碰撞检测类,其中包含两个静态方法。detect 方法将检测与小行星的碰撞,并且会在每一帧中对每一颗小行星与每一颗子弹和飞船进行检测。
contain 方法将检查与每个小行星、子弹和飞船的边界的碰撞。
在对象本身之外进行计算意味着我们需要为将要测试的对象以及提供给新 CD 类方法的数据准备大量数据。
碰撞包类
我们知道我们需要一组数据来正确执行检测。下一个类将保存我们的碰撞检测类方法执行其工作所需的所有数据,并且每个需要检测碰撞的对象都将有一个。
当需要将所有点旋转到其实际位置时,我们的碰撞包需要知道物体面向的方向。我们有一个名为 facingAngle 的浮点数。
显然,我们需要一个模型空间顶点的副本。就像旋转的位置一样,我们不会每帧都更新,而只是在第一阶段的碰撞检测显示可能发生碰撞之后才这样做。
我们还将保留包含这些顶点的数组长度的预计算值。这可能在碰撞检测过程中节省时间。
因此,我们还需要物体的世界坐标。这将每帧更新一次。
每个对象都将有一个预计算的 radius 变量,这是从物体的中心到其最远顶点的尺寸。这将在我们的 detect 方法中用于半径重叠,第一阶段检测。
我们还将有几个 PointF 对象,currentPoint 和 currentPoint2,这些只是方便的对象,将避免我们在两个碰撞检测方法的密集部分可能调用垃圾收集器。
创建一个新的类,命名为 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构造函数中调用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());
}
与SpaceShip和Bullet类一样,我们将在update方法的末尾调用move之后更新碰撞包:
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 类概要
现在,我们将实现碰撞检测的第一阶段。正如讨论的那样,我们将使用的算法计算量很大,我们只想在存在实际碰撞可能性的情况下使用它们。
因此,我们将使用第三章中讨论的半径重叠方法,即 Tappy Defender – Taking Flight,检查每一颗子弹和飞船与每一颗陨石之间的碰撞。我们将使用简化的矩形交集方法检查陨石、飞船和子弹与边界之间的碰撞。
在接下来的两个部分之后,你实际上将能够玩游戏,但你将看到我们迄今为止使用的碰撞检测的基本方法对于这种类型的游戏来说还不够令人满意。
这些初步检查将决定我们是否继续进行更精确且计算量更大的检查。
我们将在“精确碰撞检测与边界”和“精确碰撞检测与陨石”这两个部分实现这些第二阶段的检查,这些部分将使用更高级的算法,并充分利用我们的碰撞包中的数据。
要开始,创建一个新的类,并将其命名为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 重置为一,将 numLives 设置为三,调用 createObjects() 重新绘制一个关卡,暂停游戏,然后播放一个适合让玩家知道他正在重新开始的声音。
现在,将 lifeLost 方法添加到 AsteroidsRenderer 类中:
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 等于零,玩家已经清空了一个整个关卡。在这种情况下,我们增加 levelNumber 和 numLives,播放胜利的声音,并通过调用 createObjects() 开始一个更难的关卡。
现在,将 destroyAsteroid() 方法添加到 AsteroidsRenderer 类中:
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() 中测试碰撞
首先,我们将检查飞船是否需要包含。我们只需使用mapWidth、mapHeight和飞船的碰撞包调用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类使用弧度,因为它们在计算中比容易可视化的度数测量更有数学意义。
变量cosAngle和sinAngle正如其名,用于跟随此代码块的代码块中。
小贴士
值得注意的是,Math.cos()和Math.sin()方法相对耗时。我们可以通过预先计算sin和cos的 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方法。
我们还可以添加 Google Play 成就和排行榜,然后发布游戏。如果你发布一个使用 OpenGL 的游戏,你需要在AndroidManifest.xml文件中添加以下声明:
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
尝试添加我们讨论的一些改进,也许还可以添加一些你自己的想法。无论你是否发布你的游戏,我都非常乐意听到你的想法或看到你项目链接到gamecodeschool.com。
我认为我们完成了!
摘要
我希望你喜欢我们这次快速浏览,制作 Android 游戏,并希望你能继续制作许多新游戏!
第三部分. 第 3 模块
精通 Android 游戏开发
使用 Android SDK 精通游戏开发,开发高度互动和令人惊叹的游戏
第一章. 设置项目
在本章中,我们将描述使用 Android SDK 进行游戏开发是有意义的场景,以及最好使用外部引擎的场景,解释每种情况的优势和劣势。
我们将创建一个简单的项目,我们将在整本书中对其进行改进,直到它成为一个完整游戏。我们将要构建的游戏是一个太空射击游戏。
将做出一些高级决策并解释,例如使用哪种方向以及我们将如何使用活动和片段。
我们将描述游戏引擎的高级架构,研究它与典型应用程序的不同之处,解释为什么存在UpdateThread以及它是如何与用户输入交互的,以及为什么它被从DrawThread中分离出来;我们将在我们的项目中包含这些元素。
一旦游戏引擎完成,我们将扩展项目以显示暂停对话框,正确处理 Android 返回键,保持与Activity生命周期的连贯性,并使其全屏。
最后,我们将总结一些编写游戏代码的最佳实践。
本章将涵盖以下主题:
-
适合游戏的正确工具
-
使用 Android Studio 设置项目
-
游戏架构
-
警告对话框
-
处理返回键
-
处理全屏模式
-
游戏开发者最佳实践
适合游戏的正确工具
在我们开始详细讨论使用 Android SDK 制作游戏之前,让我们先退一步,考虑我们为什么要这样做,以及制作在 Android 上运行的游戏的其他替代方案是什么。
人们往往经常重新发明轮子,开发者更是如此,尤其是在视频游戏领域。虽然从头开始创建一个完整的引擎是一个很好的学习经历,但它也花费了很多时间。所以,如果你只是想制作一个游戏,使用现有的引擎可能对你来说更经济高效。
我们正处于视频游戏制作工具的黄金时代。不仅有大量的工具,而且其中大多数都是免费的。这使得选择正确的工具变得更加复杂。
让我们看看几个问题,以帮助我们决定使用哪种工具来满足特定游戏的需求。由于你已经在阅读这本书,我认为多平台不是你优先考虑的事项,而且重用你现有的 Java 和 Android 知识是一个加分项。

你想使用 3D 吗?
如果答案是肯定的;我肯定会推荐你使用一个已经存在的引擎。你需要实现一些已知任务来构建甚至最简单的 3D 引擎,例如加载模型、加载和应用纹理、处理变换以及处理相机。除此之外,你还需要编写 OpenGL。所有这些都需要大量的工作。
编写 OpenGL 引擎是重新发明轮子的非常定义。如果你想要学习 3D 引擎的内部结构,这是很好的学习经历,但如果你选择这条路,你将花费几个月的时间才能开始游戏。如果你想直接制作游戏,你最好从一个现有的 3D 引擎开始。
在这条路上,第二个问题是:你更喜欢与代码一起工作,还是更习惯于使用完整的编辑器?对于代码,你可以使用 jPCT-AE 和 libGDX,而在编辑器方面,最常见的选择是 Unity。
你想使用物理吗?
对这个问题的肯定回答应该直接指向一个现有的引擎。
物理模拟是一个众所周知且有很多文档的领域,你应该能够实现自己的物理引擎。再次强调,这是一个很好的学习经历,但如果你想直接制作游戏,使用支持物理的现有引擎会方便得多。最常用的物理引擎是 Box2D,它用 C++编写,并已通过 NDK 移植到 Android。
虽然我们将在本书的后面部分讨论碰撞检测,但物理超出了本书的范围。任何比两个球体碰撞更复杂的情况都可能变得难以处理。
一次又一次,这取决于你是否更喜欢与代码一起工作,或者你是否需要一个完整的编辑器。如果你想与代码一起工作,AndEngine 应该是你的首选武器。在编辑器的方面,Corona 和 Unity 是最受欢迎的选择之一。
你想使用 Java 吗?
我们所提到的功能丰富的环境都有自己的环境,包括一个特定的 IDE。学习它们需要付出努力,其中一些使用不同的语言(例如 Unity 有自己的环境,并使用 JavaScript 或 C#)。
另一方面,框架更简单。你只需要包含它们,你仍然可以编写 Android 游戏。这是一个有趣的中间地带,你仍然可以重用你的 Android 和 Java 知识,并利用诸如物理或 3D 模型等功能。在本节中,我们可以提到 AndEngine 作为 2D 和物理以及 jPCT-AE 作为 3D 的好选择。
使用 Android SDK 构建游戏的优点
使用 Android SDK 构建游戏有几个优点:
-
构建原型更快
-
你对引擎有完全的控制权
-
它的学习曲线较小(你已经知道 Android、Java 和 Android Studio)
-
你的大部分知识可以应用于应用
-
你可以使用 Google Play 服务和其他库进行原生操作
使用 Android SDK 构建游戏的缺点
当然,并非一切都很完美。有一些严重的缺点,其中大部分已经提到,例如:
-
代码不可移植到其他平台(特别是 iOS)。
-
性能可能是一个问题。如果游戏达到一定的复杂度,你可能需要使用 OpenGL。
-
它缺少物理引擎;你需要自己编写。
-
OpenGL 的支持仅仅是基本原语;你需要构建一切(或使用一个库)。
我想要 Android SDK!
你还在这里吗?恭喜你,你选择了正确的书籍!
如果你想探索其他选项,有关于 Unity、AndEngine 和 libGDX 的书籍可供选择,由 Packt 出版。
现在我们都在同一页面上,让我们开始吧。
项目 – YASS(另一个太空射击游戏)
在本书中,我们将构建一个游戏作为每个章节我们将要学习概念的演示。这个游戏将是一个经典的太空射击街机游戏。我们将称之为 YASS – 另一个太空射击游戏。
这意味着对于这种特定类型的游戏,将做出一些决定,但也会讨论其他选项,因为这本书旨在介绍通用的视频游戏开发。
活动和片段
我们将创建一个只有一个 Activity 的项目,并在必要时添加片段。
在 Android 5.0 Lollipop 之前的版本中,活动之间的转换可以被修改,但只能以非常有限的方式。用户甚至可以在设置中禁用它们。总的来说,这将在从一个 Activity 切换到另一个 Activity 时使你的游戏看起来很笨拙。如果你需要保存 Activity 的状态,以防它被销毁。由于每个 Activity 都是一个独立的实例,如果你需要,你需要注意它们之间的通信。
另一方面,当你与片段一起工作时,你永远不会退出 Activity,并且你对过渡动画有完全的控制权。除此之外,你仍然有每个部分的代码和布局分离,因此模块化和封装没有受到影响。
最后,当涉及到处理第三方库,如 In-App Billing 或 Google Play 服务时,你必须确保初始化和配置只进行一次,因为这些是在 Activity 级别链接的。
注意
对于游戏,使用一个 Activity 和多个片段会更有效率。
一个好的做法是有一个基础 Fragment 用于我们的游戏(YassBaseFragment),所有其他片段都将从这个片段继承。这个片段的一个很好的用途是有一个替换 getActivity 的方法,它返回我们的特定 Activity,但还有其他情况下,有一个公共基础片段会很有用。
项目设置
我们将使用 Android Studio 作为 IDE。我们将创建一个 minSDK 为 15(冰淇淋三明治 – ICS)的项目。作为一个好的做法,我们不想移动最低 SDK,除非我们正在使用一些之前不可用的功能。通过保持 minSDK 低,你使你的游戏尽可能多的设备可用。
我们将从 ICS 使用两个主要功能:Fragments、ValueAnimators和ViewPropertyAnimators。所有这些在 Honeycomb 中都已经可用,但 3.x 被认为只是对 ICS 的一个测试;它并不成熟,并且几乎在所有设备上都被 ICS 所取代。
在不太可能的情况下,如果你想支持旧版本,如 Gingerbread,你可以使用兼容库和 NineOldAndroids 来为使用的功能添加向后兼容性。
创建存根项目
让我们继续并导航到文件 > 新建项目。我们将使用YASS作为应用程序名称,example.com作为公司域名。

我们包括对 Android TV 的支持,因为我们希望能够在大屏幕上运行我们的游戏。这将创建一个额外的模块,我们可以为其编译,但我们不会在最后一章之前接触它。
如前所述,我们将为手机使用最小 SDK 版本15,为 Android TV 使用 21,因为这是它们可用的时候。
对于应用程序的包名,我们将使用com.example.yass。

我们不会使用任何默认向导,因为它们都包括对应用程序来说很棒但对我们游戏无用的动作栏/工具栏。因此,我们将选择空项目选项:

同样,我们不会为电视创建任何 Activity:

一旦项目创建完成,我们将创建一个包含一个Fragment的单个Activity。这是通过菜单选项新建 > Activity > 带有 Fragment 的空白 Activity完成的。

我们将自定义Activity,如下填写对话框:
-
Activity 名称:
YassActivity -
布局名称:
activity_yass(一旦我们更改 Activity 名称,它将成为默认值) -
Fragment 布局名称:
fragment_yass(一旦我们更改 Activity 名称,它将成为默认值) -
标题:
YassActivity
这将创建以下文件:
-
YassActivity.java包含YassActivity和PlaceholderFragment的代码 -
activity_main.xml:一个带有@+id/container的FrameLayout,它将被用来加载片段 -
fragment_main.xml:一个带有文本Hello World!的占位符布局
由于我们没有告诉 Android Studio 这个活动将成为我们的启动活动,我们需要编辑AndroidManifest.xml来配置它,通过添加适当的 intent 过滤器:
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
清理工作
我们将完全不使用菜单,因此有一些方法和文件我们将不需要,我们可以删除它们。如果你想保留所有这些方法,也可以,但保持一个干净的环境,没有未使用的代码会更好。
因此,我们可以删除resources下的menu文件夹及其中的文件,这些文件原本是YassActivity的菜单。
处理菜单创建和菜单项选择的方法也是无用的,因此我们可以从 YassActivity 中移除以下方法:
-
onCreateOptionsMenu:当菜单创建时被调用 -
OnOptionsItemSelected:当从菜单中选择一个选项时被调用
选择方向
决定游戏的方向是一个非常关键的问题。鉴于 Android 手机的多样性,分辨率和宽高比是我们必须处理的一些事情。
游戏传统上是在横屏方向进行的:电脑显示器是横屏模式,当你用游戏机玩游戏时,电视屏幕也是横屏模式。几乎所有便携式游戏机也都是设计成横屏方向的。更重要的是,大多数平板电脑也将横屏视为默认方向。
注意
横屏是游戏的传统方向。
YASS 将是一款横屏游戏。我们这样做的主要原因是将来能够将游戏移植到 Android 控制台,包括 Android TV 和 OUYA。这并不意味着竖屏模式不是游戏的有效方向,但对于玩家来说,它是一个不太熟悉的方向。
我们将使用 sensorLandscape 而不是仅仅 landscape,这样设备就可以旋转 180 度以适应任何朝下的侧面。我们必须更新 AndroidManifest.xml 以使其看起来像这样:
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme" >
<activity
android:screenOrientation="sensorLandscape"
android:name=".YassActivity"
android:label="@string/title_activity_yass" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
如你所知,当 Android 中的 Activity 改变方向时,它会被销毁并重新创建,以及其中所有的片段。这意味着,除非你明确保存和恢复信息,否则片段将不会记住之前的状态。
注意
sensorLandscape 和 sensorPortrait 模式在旋转时不会销毁活动。
一些好消息:在使用 sensorLandscape 时,旋转不会杀死 Activity,因此不需要额外的工作。这是因为布局完全相同,不需要重新创建任何内容。
如果你计划制作一个可以旋转的游戏,你必须特别注意在方向改变时保存和恢复游戏状态。这本身就是将游戏锁定在特定方向(无论是横屏还是竖屏)的另一个很好的理由。
处理宽高比
Android 设备拥有许多不同的宽高比,至少从 4:3 到 16:9。这还不包括像素的数量。
在为多个宽高比设计游戏时,基本上有两种方法。对于每一种方法,我们都为最极端的宽高比进行设计。我们将使用额外的空间来制作“智能信箱”,这意味着我们可以有更多的游戏视图。

设计不同宽高比的不同方法
最常见的选项是将相机置于中心并固定最小尺寸(横屏方向的高度)。这允许在两侧有更多的视图空间,同时确保最小的屏幕将有足够的显示空间。这在 16:9 屏幕上查看 4:3 图像是等效的。
如果游戏设计合理,你也可以固定更大的尺寸。如果屏幕是正方形,这将增加顶部和底部的额外空间。这相当于在 4:3 屏幕上查看 16:9 的图像。
另一种方法是简单地拥有“更多的相机空间”。我们还可以使游戏视图达到一定大小,并使用额外的空间来放置其他控件,如得分、等级等。
如果你将这种方法推向极致,你可以设计一个完全平方的游戏区域,并将额外信息放在“智能信封”中,适用于横屏和竖屏。一个很好的例子是 Candy Crush Saga。这是最灵活的方法,但也是需要最多工作量的方法。
对于我们的游戏,我们将采用“更接近相机空间”的方法,使用固定大小的信封来显示得分和生命值。
对于分辨率和像素密度的差异,我们将为低密度屏幕进行设计。我们将以编程方式读取设备的分辨率并应用一个转换因子。关于这种方法的一些深入细节可以在专门介绍低级绘图、菜单和对话框的章节中找到。
游戏架构
游戏的架构和控制流程与应用程序不同。两者似乎都能即时响应用户输入,但应用程序是通过设置监听器并使用方法调用(最常见的是onClick方法调用OnClickListener)来做到这一点的,而对于实时游戏来说,这种方法是不适用的(尽管对于非实时游戏是适用的)。
一旦游戏开始运行,它必须尽可能快地评估和更新一切。这也是为什么它不能被用户事件中断的原因。这些事件或状态应该被记录下来,然后在游戏对象的更新过程中读取。
游戏引擎应该创建在运行游戏的片段中,因为我们只需要在玩游戏时运行游戏引擎。这有一个优点,那就是我们可以利用我们现有的 Android 知识来创建和处理游戏的其他屏幕。

游戏引擎的简化架构
基本的游戏引擎架构由一个更新线程、一个绘图线程和一系列属于游戏引擎的游戏对象组成。
游戏引擎是程序其他部分与游戏交互的组件。它的任务也是封装更新和绘图线程的存在,以及处理游戏对象。
一个游戏由既更新又绘制的游戏对象组成。这些对象被保存在游戏引擎内部。
更新线程负责尽可能快地更新游戏对象的状态。它将遍历所有游戏对象,调用更新方法。
UI 也必须不断更新,并且独立于更新线程。它将通过在它们上调用绘图方法来绘制所有游戏对象。
让我们详细分析每个组件。
GameEngine 和 GameObjects
GameEngine 包含了前面提到的三个元素。
GameObject 是一个抽象类,我们游戏中的所有游戏对象都必须从这个类扩展。这个接口将它们与 Update 和 Draw 线程连接起来。
public abstract class GameObject {
public abstract void startGame();
public abstract void onUpdate(long elapsedMillis, GameEngine gameEngine);
public abstract void onDraw();
public final Runnable mOnAddedRunnable = new Runnable() {
@Override
public void run() {
onAddedToGameUiThread();
}
};
public final Runnable mOnRemovedRunnable = new Runnable() {
@Override
public void run() {
onRemovedFromGameUiThread();
}
};
public void onRemovedFromGameUiThread(){
}
public void onAddedToGameUiThread(){
}
}
-
startGame用于在游戏开始之前初始化对象。 -
onUpdate被游戏引擎尽可能快地调用,提供自上次调用以来经过的毫秒数以及GameEngine本身的引用,供将来使用,例如访问用户输入。 -
onDraw使组件能够渲染自身。我们目前还没有使用任何参数,但稍后我们将传递一个Canvas来绘制。 -
onRemovedFromGameUiThread包含了当对象从游戏中移除时必须在UIThread上运行的代码。 -
onAddedToGameUiThread包含了当对象被添加到游戏时必须在UIThread上运行的代码。 -
两个
Runnable对象用于在UIThread内部调用onRemovedFromGameUiThread和onAddedToGameUiThread。
GameEngine 将为我们提供启动、停止、暂停和恢复游戏的方法,这样我们就不必担心外部的线程或游戏对象。
游戏引擎由三个项目组成:游戏对象列表、UpdateThread 和 DrawThread。
private List<GameObject> mGameObjects = new ArrayList<GameObject>();
private UpdateThread mUpdateThread;
private DrawThread mDrawThread;
让我们看看引擎处理游戏的不同方法。
开始游戏
从 GameEngine 开始游戏的代码如下:
public void startGame() {
// Stop a game if it is running
stopGame();
// Setup the game objects
int numGameObjects = mGameObjects.size();
for (int i=0; i<numGameObjects; i++) {
mGameObjects.get(i).startGame();
}
// Start the update thread
mUpdateThread = new UpdateThread(this);
mUpdateThread.start();
// Start the drawing thread
mDrawThread = new DrawThread(this);
mDrawThread.start();
}
首先,我们必须确保没有游戏正在运行,因此我们在开始时调用 stopGame 来停止正在进行的游戏。
其次,我们重置与引擎链接的所有游戏对象。在启动线程之前做这件事很重要,这样一切都可以从初始位置开始。
最后,我们创建并启动 UpdateThread 和 DrawThread。
停止游戏
停止游戏甚至更简单。我们只需停止存在的 Update 和 Draw 线程:
public void stopGame() {
if (mUpdateThread != null) {
mUpdateThread.stopGame();
}
if (mDrawThread != null) {
mDrawThread.stopGame();
}
}
我们还有 pauseGame 和 resumeGame 方法,它们的功能与此类似。我们在这里不包括这些方法的代码,因为它们是冗余的。
管理游戏对象
引擎必须管理游戏对象的添加和删除。我们不能直接处理列表,因为它将在 onUpdate 和 onDraw 期间被大量使用。
public void addGameObject(final GameObject gameObject) {
if (isRunning()){
mObjectsToAdd.add(gameObject);
}
else {
mGameObjects.add(gameObject);
}
mActivity.runOnUiThread(gameObject.mOnAddedRunnable);
}
public void removeGameObject(final GameObject gameObject) {
mObjectsToRemove.add(gameObject);
mActivity.runOnUiThread(gameObject.mOnRemovedRunnable);
}
我们使用列表 mObjectsToAdd 和 mObjectsToRemove 来跟踪必须添加或删除的对象。我们将这两者都作为 onUpdate 方法的最后一步执行,除非游戏引擎正在运行,在这种情况下,可以直接添加和删除它们。
我们还在 UIThread 上从 GameObject 运行相应的 Runnable 对象。
要从引擎更新游戏对象,我们只需在它们所有上调用onUpdate。一旦更新循环完成,我们就处理必须从mGameObjects中移除或添加的对象。这部分是通过使用synchronized代码块来完成的,这对于onDraw方法也很重要。
public void onUpdate(long elapsedMillis) {
int numGameObjects = mGameObjects.size();
for (int i=0; i<numGameObjects; i++) {
mGameObjects.get(i).onUpdate(elapsedMillis, this);
}
synchronized (mGameObjects) {
while (!mObjectsToRemove.isEmpty()) {
mGameObjects.remove(mObjectsToRemove.remove(0));
}
while (!mObjectsToAdd.isEmpty()) {
mGameObjects.add(mObjectsToAdd.remove(0));
}
}
}
对于绘图,我们做的是同样的事情,只不过绘图必须在UIThread上完成。因此,我们创建一个Runnable对象,并将其传递给活动的runOnUIThread方法。
private Runnable mDrawRunnable = new Runnable() {
@Override
public void run() {
synchronized (mGameObjects) {
int numGameObjects = mGameObjects.size();
for (int i = 0; i < numGameObjects; i++) {
mGameObjects.get(i).onDraw();
}
}
}
};
public void onDraw(Canvas canvas) {
mActivity.runOnUiThread(mDrawRunnable);
}
注意,我们使用mGameObjects来同步运行方法。我们这样做是为了确保在迭代列表时列表不会被修改。
同样重要的是,只有onUpdate的最后一部分需要同步。如果没有对象被添加或移除,线程是独立的。如果我们同步整个onUpdate方法,我们将失去将Update和Draw线程分开的所有优势。
UpdateThread
UpdateThread是一个持续在游戏引擎上运行更新的线程。对于每次对onUpdate的调用,它提供自上次执行以来的毫秒数。
更新线程的基本run方法如下:
@Override
public void run() {
long previousTimeMillis;
long currentTimeMillis;
long elapsedMillis;
previousTimeMillis = System.currentTimeMillis();
while (mGameIsRunning) {
currentTimeMillis = System.currentTimeMillis();
elapsedMillis = currentTimeMillis - previousTimeMillis;
mGameEngine.onUpdate(elapsedMillis);
previousTimeMillis = currentTimeMillis;
}
}
线程会持续循环,直到游戏运行结束。在每次迭代中,它将获取当前时间,计算自上次运行以来的已过毫秒数,并在GameEngine对象上调用onUpdate。
虽然这个第一个版本可行且非常简单易懂,但它只能启动和停止游戏。我们希望能够暂停和恢复它。
要暂停和恢复游戏,我们需要一个变量,我们在循环内部读取它以检查何时暂停执行。我们需要记录已过毫秒数并扣除暂停所花费的时间。一个简单的方法如下:
while (mGameIsRunning) {
currentTimeMillis = System.currentTimeMillis();
elapsedMillis = currentTimeMillis - previousTimeMillis;
if (mPauseGame) {
while (mPauseGame) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
// We stay on the loop
}
}
currentTimeMillis = System.currentTimeMillis();
}
mGameEngine.onUpdate(elapsedMillis);
previousTimeMillis = currentTimeMillis;
}
pauseGame和resumeGame方法的代码只是将变量mPauseGame设置为 true 或 false。
如果游戏处于暂停状态,我们将进入一个 while 循环,直到游戏恢复才会退出。为了避免有一个持续运行的空循环,我们可以让线程短暂休眠(20 毫秒)。注意,Thread.sleep可能会触发InterruptedException。如果发生这种情况,我们只需继续,因为它将在 20 毫秒后再次运行。此外,我们马上就要改进它了。
这种方法可行,但仍然有大量的空闲处理在进行。对于线程来说,有更高效的暂停和恢复机制。我们将使用wait/notify来改进这一点。
代码可以更新为如下所示:
while (mGameIsRunning) {
currentTimeMillis = System.currentTimeMillis();
elapsedMillis = currentTimeMillis - previousTimeMillis;
if (mPauseGame) {
while (mPauseGame) {
try {
synchronized (mLock) {
mLock.wait();
}
} catch (InterruptedException e) {
// We stay on the loop
}
}
currentTimeMillis = System.currentTimeMillis();
}
mGameEngine.onUpdate(elapsedMillis);
previousTimeMillis = currentTimeMillis;
}
pauseGame方法与之前相同,但我们需要更新resumeGame,使其位于通知和释放锁的位置:
public void resumeGame() {
if (mPauseGame == true) {
mPauseGame = false;
synchronized (mLock) {
mLock.notify();
}
}
}
使用wait/notify,我们确保线程在空闲时不会做任何工作,并且我们知道一旦我们通知它,它就会被唤醒。重要的是首先将mPauseGame设置为false,然后唤醒线程,否则主循环可能会再次停止。
最后,要开始和停止游戏,我们只需要改变变量的值:
public void start() {
mGameIsRunning = true;
mPauseGame = false;
super.start();
}
public void stopGame() {
mGameIsRunning = false;
resumeGame();
}
游戏永远不会以暂停状态开始。要停止游戏,我们只需将mGameIsRunning值设置为false,run方法中的循环就会结束。
在stopGame方法中调用resumeGame是很重要的。如果我们暂停游戏时调用停止,线程将等待,除非我们恢复游戏,否则不会发生任何事情。如果游戏没有暂停,resumeGame内部没有执行任何操作,所以调用它没有关系。
DrawThread
实现 DrawThread 的方法有几种。它可以以类似更新线程的方式完成,但我们将使用一种不使用Thread的更简单的方法。
我们将使用Timer和TimerTask类以足够高的频率将onDraw回调发送到游戏引擎,以实现每秒 30 帧的渲染:
private static int EXPECTED_FPS = 30;
private static final long TIME_BETWEEN_DRAWS = 1000 / EXPECTED_FPS;
public void start() {
stopGame();
mTimer = new Timer();
mTimer.schedule(new TimerTask() {
@Override
public void run() {
mGameEngine.onDraw();
}
}, 0, TIME_BETWEEN_DRAWS);
}
我们每 33 毫秒调用这个方法。在简单实现中,这个方法将只调用GameView中的invalidate,这将导致调用View的onDraw方法。
此实现依赖于 Android UI 的一个特性。为了重新显示视图,Android 内置了一个应急系统,以避免重复的无效化。如果在视图正在绘制时请求无效化,它将被排队。如果有多个无效化请求排队,它们将被丢弃,因为它们不会产生任何效果。
这样,如果视图绘制时间超过TIME_BETWEEN_DRAWS,系统将自动回退到每秒更少的帧数。
在本书的后面部分,我们将重新访问这个线程以进行更复杂的实现,但现在让我们保持简单。
停止、暂停和恢复DrawThread也很简单:
public void stopGame() {
if (mTimer != null) {
mTimer.cancel();
mTimer.purge();
}
}
public void pauseGame() {
stopGame();
}
public void resumeGame() {
start();
}
要停止游戏,我们只需要cancel和purge计时器。cancel方法将取消计时器和所有计划中的任务,而purge将从队列中删除所有已取消的任务。
由于我们不需要跟踪任何状态,我们只需将pauseGame和resumeGame与stopGame和启动等效。
注意,如果我们想在 30fps 下有一个平滑的游戏,屏幕上所有项目的绘制必须在不到 33 毫秒内完成。这意味着这些方法的代码通常需要优化。
用户输入
正如我们提到的,用户输入将由某个输入控制器处理,然后由需要它们的对象在需要时读取。我们将在下一章详细介绍此类输入控制器。现在,我们只想检查游戏引擎是否按预期工作,并且正确处理启动、停止、暂停和恢复调用。
暂停、恢复和启动与其他用户输入不同,因为它们影响引擎和线程本身的状态,而不是修改游戏对象的状态。因此,我们将使用标准的事件驱动编程来触发这些函数。
将一切整合起来
让我们拿起我们的原型项目,添加所有必要的类以拥有一个可工作的游戏引擎,然后修改代码以便我们可以启动、停止、暂停和继续游戏引擎,并显示自游戏开始以来的毫秒数。
我们将把当前的GameEngine、UpdateThread、DrawThread和GameObject实现放入com.example.yass.engine包中。
接下来,我们将创建另一个名为com.example.yass.counter的包,我们将使用该包中的代码来编写此示例。
在YassActivity内部,我们有一个名为PlaceholderFragment的内部类。我们将将其重命名为GameFragment,将其重构为单独的文件,并将其放入com.example.yass.counter包下。
我们将添加一个TextView来显示毫秒数,以及两个按钮:一个用于启动和停止游戏引擎,另一个用于暂停和继续它。
我们将把它们添加到fragment_yass_main.xml布局中,它将看起来像这样:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/activity_horizontal_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
tools:context="com.example.yass.counter.PlaceholderFragment">
<TextView
android:id="@+id/txt_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello_world" />
<Button
android:id="@+id/btn_start_stop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/start" />
<Button
android:id="@+id/btn_play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pause" />
</LinearLayout>
对于游戏片段,我们需要在onViewCreated内部添加以下代码:
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mGameEngine = new GameEngine(getActivity());
mGameEngine.addGameObject(
new ScoreGameObject(view, R.id.txt_score));
view.findViewById(R.id.btn_start_stop)
.setOnClickListener(this);
view.findViewById(R.id.btn_play_pause)
.setOnClickListener(this);
}
一旦创建了视图,我们就创建游戏引擎并添加一个新的ScoreGameObject到其中。然后我们将当前片段设置为两个已添加按钮的监听器。
onClick的代码非常简单;只需为每个按钮决定调用哪个方法:
@Override
public void onClick(View v) {
if (v.getId() == R.id.btn_play_pause) {
playOrPause();
}
if (v.getId() == R.id.btn_start_stop) {
startOrStop();
}
}
决定游戏是否应该暂停或继续就像这样简单:
private void playOrPause() {
Button button = (Button)
getView().findViewById(R.id.btn_play_pause);
if (mGameEngine.isPaused()) {
mGameEngine.resumeGame();
button.setText(R.string.pause);
}
else {
mGameEngine.pauseGame();
button.setText(R.string.resume);
}
}
我们还处理了按钮的名称更改,以确保 UI 的一致性。在代码中,我们使用了GameEngine中的isPaused方法。只要该对象不为空,该方法就只返回UpdateThread对象的状态:
public boolean isPaused() {
return mUpdateThread != null && mUpdateThread.isGamePaused();
}
同样,为了播放/暂停游戏并保持按钮的状态,我们将添加此方法:
private void startOrStop() {
Button button = (Button)
getView().findViewById(R.id.btn_start_stop);
Button playPauseButton = (Button)
getView().findViewById(R.id.btn_play_pause);
if (mGameEngine.isRunning()) {
mGameEngine.stopGame();
button.setText(R.string.start);
playPauseButton.setEnabled(false);
}
else {
mGameEngine.startGame();
button.setText(R.string.stop);
playPauseButton.setEnabled(true);
playPauseButton.setText(R.string.pause);
}
}
再次强调,我们需要在GameEngine中有一个方法来知道它是否正在运行。就像我们之前做的那样,我们只是镜像UpdateThread的状态:
public boolean isRunning() {
return mUpdateThread != null && mUpdateThread.isGameRunning();
}
一旦完成基本连接,我们就可以进入真正有趣的部分:我们正在创建的游戏对象。这个对象展示了我们一直在讨论的GameObject类中每个方法的用法:
public class ScoreGameObject extends GameObject {
private final TextView mText;
private long mTotalMilis;
public ScoreGameObject(View view, int viewResId) {
mText = (TextView) view.findViewById(viewResId);
}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine)
{
mTotalMilis += elapsedMillis;
}
@Override
public void startGame() {
mTotalMilis = 0;
}
@Override
public void onDraw() {
mText.setText(String.valueOf(mTotalMilis));
}
}
onUpdate方法只是不断地将毫秒数添加到总数中。当新游戏开始时,总数会被重置,而onDraw则设置文本视图中总毫秒数的值。
如预期的那样,onUpdate被调用的次数比onDraw多得多。另一方面,onDraw是在UIThread上执行的,这是我们无法承担onUpdate所做的事情。
现在我们可以编译并运行示例,检查计时器是否在启动和停止游戏引擎时开始和停止。我们还可以检查暂停和继续是否按预期工作。
继续示例
现在我们将稍微改变一下示例。我们将创建一个暂停对话框,我们可以从中继续或停止游戏。如果用户点击暂停按钮,或者按返回键,将显示此对话框。
最后,我们将添加一个片段,玩家可以从该片段开始游戏,并将游戏片段与菜单分开。
因此,我们将创建 MainMenuFragment.java 和 fragment_main_menu.xml。布局的内容将非常简单:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_gravity="center_horizontal|top"
style="@android:style/TextAppearance.DeviceDefault.Large"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:text="@string/game_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:id="@+id/btn_start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/start" />
</FrameLayout>
这包括屏幕上的应用标题和开始播放的按钮:

在这个片段内部,我们添加了一个监听器到启动按钮,并使其调用 startGame 方法。startGame 方法的代码同样很简单:
public void startGame() {
getFragmentManager()
.beginTransaction()
.replace(R.id.container, new GameFragment(), TAG_FRAGMENT)
.addToBackStack(null)
.commit();
}
我们正在使用片段管理器从当前片段过渡到 GameFragment。
beginTransition 方法创建过渡本身,并且我们可以通过链式方法来配置它。
我们正在用 R.id.container id 替换视图内的片段。这将移除旧片段。如果我们使用 add,则两个片段都会显示。
然后,我们添加一个没有标签的片段到返回栈,因为我们不需要任何标签。这非常重要,因为它允许系统正确处理返回键。当按下返回键时,片段管理器的返回栈上的所有内容都将弹出。
如果我们不将片段添加到返回栈,当我们在返回键上点击时,默认行为将是关闭应用。当片段在返回栈上时,我们可以依赖系统正确处理片段导航。
最后,我们提交过渡,这样片段就会被替换。
在我们已有的游戏片段内部,我们将移除启动/停止对话框,并修改暂停按钮以显示一个对话框,从该对话框我们可以恢复或退出当前游戏。
我们希望游戏立即开始,因此 GameFragment 的 onViewCreated 方法现在看起来像这样:
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mGameEngine = new GameEngine(getActivity());
mGameEngine.addGameObject(
new ScoreGameObject(view, R.id.txt_score));
view.findViewById(R.id.btn_play_pause)
.setOnClickListener(this);
mGameEngine.startGame();
}
我们还将修改 onClick 方法,移除启动或停止的旧代码,使其看起来像这样:
@Override
public void onClick(View v) {
if (v.getId() == R.id.btn_play_pause) {
pauseGameAndShowPauseDialog();
}
}
这个更简单的版本只关心暂停游戏,并在点击暂停按钮时显示对话框。
目前,我们将使用 AlertDialog 框架创建一个默认对话框:
private void pauseGameAndShowPauseDialog() {
mGameEngine.pauseGame();
new AlertDialog.Builder(getActivity())
.setTitle(R.string.pause_dialog_title)
.setMessage(R.string.pause_dialog_message)
.setPositiveButton(R.string.resume,
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
mGameEngine.resumeGame();
}
})
.setNegativeButton(R.string.stop,
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
mGameEngine.stopGame();
((MainActivity)getActivity()).navigateBack();
}
})
.create()
.show();
}
正面按钮将恢复游戏,因此它调用游戏引擎中的 resumeGame。
负面按钮将退出游戏,因此它调用 GameEngine 中的 stopGame,然后调用父 Activity 中的 navigateBack。
navigateBack 方法不过是处理活动中的返回键按下:
public void navigateBack() {
super.onBackPressed();
}
由于我们将片段放入导航栈,MainMenuFragment 将再次加载,而 GameFragment 将被销毁。以下是如何显示 Pause 对话框:

处理返回键
我们想要做的事情之一是正确处理返回键。这是当它不按预期在游戏中工作时让 Android 用户感到沮丧的事情,因此我们将特别关注这一点。目前有两个地方它没有按预期工作。
注意
在 Android 上正确处理返回键非常重要。
-
如果我们使用返回键取消暂停对话框,游戏将不会恢复。
-
在游戏片段中,返回键应该暂停游戏。目前,返回键会返回到
GameFragment。
对于第一个问题,我们需要在对话框中添加一个OnCancelListener。这与每次对话框被关闭时都会被调用的OnDismissListener不同。cancel方法仅在对话框被取消时调用。
此外,OnDismissListener是在 API 级别 17 中引入的。由于我们不需要它,所以我们不会担心提高游戏的minSDK。
我们使用以下代码更新暂停对话框的创建:
new AlertDialog.Builder(getActivity())
[...]
.setOnCancelListener(new DialogInterface.OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
mGameEngine.resumeGame();
}
})
.create()
show();
剩下的工作是当游戏过程中按下返回键时暂停游戏。这是需要在片段中处理的事情。实际上,onBackPressed是仅对活动可用的方法。我们需要编写代码来扩展它到当前的片段。
我们将利用我们的YassBaseFragment,这是我们游戏中所有片段的基类,以添加对onBackPressed的支持。我们在这里创建一个onBackPressed方法:
public class YassBaseFragment extends Fragment {
public boolean onBackPressed() {
return false;
}
}
在Activity中,我们更新onBackClicked以允许片段在需要时覆盖它:
@Override
public void onBackPressed() {
final YassFragment fragment = (YassFragment)
getFragmentManager().findFragmentByTag(TAG_FRAGMENT);
if (!fragment.onBackPressed()) {
super.onBackPressed();
}
}
如果片段没有处理返回键的按下,它将返回false。然后,我们只需调用超类方法以允许默认行为。
TAG_FRAGMENT非常重要;它允许我们获取我们添加的片段,并且在我们将片段添加到FragmentTransition时设置。让我们回顾一下由向导创建的MainActivity的onCreate方法,并将TAG_FRAGMENT添加到初始的FragmentTransition:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_yass);
if (savedInstanceState == null) {
getFragmentManager().beginTransaction()
.add(R.id.container, new MainMenuFragment(), TAG_FRAGMENT)
.commit();
}
}
同样重要的是,应用程序的所有片段都必须扩展自YassBaseFragment,否则此方法将抛出ClassCastException。
在所有部件就绪后,我们现在在GameFragment内部覆盖onBackPressed方法以显示暂停对话框:
@Override
public boolean onBackPressed() {
if (mGameEngine.isRunning()) {
pauseGameAndShowPauseDialog();
return true;
}
return false;
}
使用这种方式,当我们在GameFragment中点击返回时,会显示暂停对话框。请注意,我们只有在GameEngine正在运行时才会显示暂停对话框。当它没有运行时,我们将返回false。Android 的默认行为将被触发,并且必须显示的暂停对话框将被取消。
尊重生命周期
我们的游戏也应该与活动生命周期保持一致;特别是,当活动暂停时,它应该暂停。这主要有两个原因:
-
如果游戏被置于后台,当它返回时,用户希望它被暂停
-
只要游戏在运行,更新线程将尽可能快地更新,因此会使手机感觉更慢
根据当前实现,这些都不会发生。你可以尝试按一下主页按钮,你会看到设备没有响应。此外,如果你再次使用最近活动按钮将游戏置于前台,你会看到计时器仍在计数。
注意
不尊重片段生命周期会导致性能问题和玩家不满。
解决这个问题非常简单,我们只需要保持片段生命周期的连贯性,通过在GameFragment中添加以下代码:
@Override
public void onPause() {
super.onPause();
if (mGameEngine.isRunning()){
pauseGameAndShowPauseDialog();
}
}
@Override
public void onDestroy() {
super.onDestroy();
mGameEngine.stopGame();
}
通过这种方式,每当片段暂停时,我们暂停游戏并显示对话框,以便玩家可以再次继续。另外,每当片段被销毁时,我们停止游戏引擎。
在暂停之前检查游戏引擎是否正在运行非常重要,因为onPause在退出游戏时也会被调用。所以,如果我们忘记这样做,通过暂停对话框退出将导致应用崩溃。
尽可能使用屏幕空间
我们正在开发一个游戏。我们希望拥有设备的所有屏幕空间,没有干扰。有两个项目从我们这里拿走了这些:
-
状态栏:屏幕顶部的栏,显示时间、电池、WiFi、移动信号和通知。
-
导航栏:这是放置“返回”、“主页”和“最近”按钮的栏。它可能根据设备的方向位于不同的位置。

状态栏和导航栏在屏幕上占据相当大的空间
导航栏是在冰淇淋三明治上引入的,作为物理按钮的替代品。但即使今天,一些制造商仍然决定使用物理按钮,所以它可能存在也可能不存在。
我们首先可以告诉系统我们想要全屏。有一个名为SYSTEM_UI_FLAG_FULLSCREEN的标志,这似乎是我们正在寻找的。
问题在于,这个标志是在 Android 早期版本中引入的,当时没有导航栏。当时,它确实意味着全屏,但从冰淇淋三明治开始,它仅仅意味着“移除状态栏”。
注意
SYSTEM_UI_FLAG_FULLSCREEN模式并不是真正的全屏。

全屏仅使状态栏消失。
除了导航栏外,还添加了一些处理全屏的方法。这种方法在 KitKat 中得到了回顾。所以,让我们看看我们的选项。
在 Android 4.4 之前 – 几乎全屏
在 Android 4.0 中,除了导航栏外,还添加了两个新标志来处理导航栏,这些标志是现有全屏标志的补充:
-
SYSTEM_UI_FLAG_HIDE_NAVIGATION:这告诉系统隐藏导航栏 -
SYSTEM_UI_FLAG_LOW_PROFILE:这会将设备置于“低配置”模式,将导航栏上的图标变暗,并用点替换它们
虽然确实“隐藏导航”标志可以完全隐藏导航栏,但由于这种模式旨在用于非交互式活动,如视频播放,因此一旦你触摸屏幕上的任何地方,导航栏就会重新出现。因此,SYSTEM_UI_FLAG_HIDE_NAVIGATION对我们来说并没有太大用处。
使用低轮廓来降低导航栏亮度是一个更合理的解决方案。尽管我们没有获得额外的屏幕空间,但将导航栏上的图标缩小为小点,使得玩家可以更多地专注于内容。这些图标在必要时(基本上,当用户点击导航栏时)会显示出来,并在不需要时立即变暗。
注意
隐藏导航栏仅适用于非交互式应用。一旦你触摸屏幕,导航栏就会再次出现。
总的来说,我们只能对仅降低导航栏亮度并去除状态栏感到满意。

低轮廓模式会降低导航栏的亮度,使其不那么显眼。
这是我们需要添加到MainActivity中以去除状态栏并将设备置于低轮廓模式的代码:
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
View decorView = getWindow().getDecorView();
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LOW_PROFILE);
}
}
我们在主Activity中重写了onWindowFocusChanged方法。这是处理标志的推荐位置,因为每当窗口焦点改变时都会调用它。当应用恢复焦点时,我们不知道状态栏处于何种状态。因此,确保一切如我们所愿是一个好的做法。
还有两个我们尚未提到的标志。它们是在 API 级别 16 中引入的,旨在处理布局对元素出现和消失的反应。
SYSTEM_UI_FLAG_LAYOUT_STABLE 标志意味着布局将是连贯的,独立于显示或隐藏的元素。
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 标志告诉系统我们的稳定布局将是全屏模式下的布局——没有导航栏。
这意味着如果/当状态栏显示时,布局不会改变,这是好的,否则它看起来就像是一个错误。这也意味着我们需要注意边距,确保没有重要内容被状态栏覆盖。
注意
稳定布局仅从 Jelly Bean 版本开始存在(API 级别 16+)。
对于 Ice Cream Sandwich,SYSTEM_UI_FLAG_LAYOUT_STABLE 不起作用。但拥有这个版本的设备非常少,状态栏也很少显示,所以这是可以接受的。
真正的全屏模式是在 KitKat 中引入的。
Android 4.4 及更高版本 – 沉浸模式
在 KiKat 中,引入了一种新的模式:沉浸模式。
沉浸模式会完全隐藏状态栏和导航栏。正如其名所示,它旨在提供完全沉浸式的体验,这意味着主要是游戏。即使导航栏再次出现,它也是半透明的,而不是黑色,并叠加在游戏之上。
注意
粘性沉浸模式几乎是为游戏专门设计的。
沉浸模式可以使用两种方式:正常和粘性。它们都是全屏模式,并且当应用第一次进入此模式时,用户会看到一个提示,解释如何退出此模式:

沉浸式非粘性模式会在状态栏和导航栏显示后保持可见,而沉浸式粘性模式会在几秒钟后隐藏它们,返回真实的全屏模式。对于游戏来说,推荐使用粘性沉浸模式。
将应用置于全屏粘性沉浸模式的代码如下:
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
View decorView = getWindow().getDecorView();
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
在这种情况下,就像之前的情况一样,我们要求使用稳定的布局,并使其看起来像全屏。这次,我们包括一个标志,使稳定的布局成为没有导航栏的布局(SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)。
我们还添加了隐藏状态栏(全屏)和导航栏(隐藏导航)的标志。最后,我们请求沉浸式粘性模式。结果是真正的全屏游戏:

沉浸模式为我们提供了设备上的所有屏幕空间
使用此配置,即使用户做出手势显示状态栏和导航栏,它们也会以半透明的方式叠加在我们的 UI 之上:

当在粘性沉浸模式下显示栏时,它们会叠加并半透明
不幸的是,粘性模式需要我们添加SYSTEM_UI_FLAG_HIDE_NAVIGATION标志来将导航栏置于粘性模式。在 Android 的早期版本中,这有一个非常糟糕的副作用,即触摸屏幕后导航栏会连续出现和消失,因为此标志在没有沉浸模式的情况下意味着不同的东西。
此外,SYSTEM_UI_FLAG_LOW_PROFILE标志在沉浸模式可用的版本中没有任何效果。这是有意义的,因为它被认为是沉浸模式的替代品和改进。
将全屏结合在一起
由于我们有两种不同的全屏请求模式,KitKat 之前(低配置)和 KitKat 之后(沉浸模式),以及隐藏导航栏的标志不兼容,我们需要根据设备运行的 Android 版本进行不同的配置:
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
View decorView = getWindow().getDecorView();
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LOW_PROFILE);
}
else {
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
}
使用此代码,我们为每个 Android 版本提供了预期的游戏体验;在 KitKat 之前的版本上,导航栏变暗以降低配置,而在较新的设备上则使用全沉浸模式。
游戏开发者的良好实践
通常情况下,你应该避免过早优化。这意味着,除非你遇到性能问题,否则不要对你的代码进行优化。
然而,在游戏中,我们有两种方法(onUpdate和onDraw)的执行时间至关重要。因此,我们将提供一些应该足以使性能保持在合理阈值之下的技巧。
对于其他情况,你的代码可能没问题。如果你发现性能问题,你应该仔细测量以找到瓶颈所在,然后才进行优化。大多数情况下,问题并不在我们认为的地方。过早的优化可能导致代码可读性降低,而改进并不显著。
对象池
对象的创建和销毁是一项昂贵的操作,应该限制其使用。在这方面,实时游戏比应用程序更为敏感。
每次创建对象时,垃圾收集器都有机会运行。在 Android 的旧版本中,这意味着一切都会停止 200 毫秒。虽然现在情况不再这么糟糕,但可能仍然会注意到。
注意
我们应该尽可能避免对象创建。
我们希望避免在必须尽可能快运行的onUpdate方法中执行任何昂贵的操作,因此我们将对象创建和销毁的操作从其中移除。
解决这个问题的一个方法是众所周知的软件模式,称为对象池。
在我们开始游戏之前,我们将预先创建我们将需要的对象并将它们放入池中。池可以是像栈或列表这样简单的东西。
而不是创建一个对象,我们将从池中挑选一个并初始化它。如果池为空,这意味着我们低估了对象的数量。所以作为一个较小的恶,必须创建对象的新实例。
而不是销毁一个对象,我们将将其放回池中。
我们必须将对象返回到池中的事实迫使我们思考何时一个对象不再需要,而不是仅仅依赖垃圾收集器为我们完成这项工作。虽然这需要一点努力,但这种思维练习将提高游戏性能和结构。如果你曾经使用过 C++,这对你来说应该很容易。
我们将在代码中使用对象池来处理所有游戏对象;这意味着敌人和子弹基本上。
避免在列表中使用增强型循环语法
与对象创建相关,我们应该避免在列表中使用增强型循环语法。虽然 for-each 语法更容易阅读,但它会在运行时创建一个迭代器,这会使执行变慢,并给垃圾收集器运行的机会。
在GameEngine的onUpdate方法的情况下,我们可以使用类似这样的 for-each 语法来编写它:
public void onUpdate(long elapsedMillis) {
for (GameObject gameObject : mGameObjects) {
gameObject.onUpdate(elapsedMillis, this);
}
}
但这比使用标准的 for 循环语法要慢得多。这就是为什么它看起来是这样的:
public void onUpdate(long elapsedMillis) {
int numGameObjects = mGameObjects.size();
for (int i=0; i<numGameObjects; i++) {
mGameObjects.get(i).onUpdate(elapsedMillis, this);
}
}
在数组的特定情况下,增强的语法在具有JIT(即时)编译器的设备上与传统语法一样快——现在所有设备都应该如此——因此,始终使用默认循环语法而不是增强语法没有缺点。
也很重要的是,使用变量来存储大小,而不是在每次迭代时请求它,这引出了下一个技巧。
预先创建对象
与在onUpdate循环中创建对象的低效性相关,我们应该始终预先创建我们将要使用的对象。
这种做法的一个好例子是创建在GameObject内部的Runnable对象,以运行onRemovedFromGameUiThread和onAddedToGameUiThread。
我们可以在游戏引擎中按需创建它们,作为addGameObject和removeGameObject的一部分,但这将效率低得多。
直接访问变量
在尽可能多的场合,我们将使用直接变量访问而不是使用 getter 和 setter。这通常是一个好习惯,因为访问器很昂贵,编译器不会内联它们。
在游戏的情况下,将这种做法扩展到其他类的变量是有意义的。正如我们之前多次提到的,onUpdate和onDraw的执行时间至关重要;仅仅毫秒级的差异就很重要。这就是为什么当游戏对象中的变量被其他游戏对象访问时,我们会将它们设置为公共的,并直接与它们一起工作。
这对于 Java 开发者来说可能有点反直觉,因为我们习惯于通过 getter 和 setter 来封装一切。在这种情况下,效率比封装更重要。
小心处理浮点数
在进行计算的情况下,整数操作的速度大约是浮点操作的两倍。
当整数不够用时,浮点数和双精度数之间的速度没有真正的差异。唯一的区别在于空间,双精度数是两倍大。
此外,即使对于整数,一些处理器有硬件乘法,但没有硬件除法。在这种情况下,整数除法和取模操作是在软件中执行的。总的来说,这是一个过早优化可能对你有害的例子。
性能神话——避免使用接口
在引入 JIT 编译器之前的 Android 旧版本中,通过接口访问方法而不是精确类型稍微高效一些。在这些版本中,声明ArrayList变量而不是泛型 List 接口来直接访问类是有意义的。
然而,在现代版本的 Android 中,通过接口访问变量和直接访问之间没有区别。因此,为了通用性,我们将使用通用接口而不是类,就像在GameEngine中看到的那样:
private List<GameObject> mGameObjects = new ArrayList<GameObject>();
摘要
在简要介绍了哪种工具最适合制作哪种类型的游戏之后,我们描述了使用裸 Android SDK 制作游戏的优缺点。
我们已经建立了一个项目,并定义了主要活动和其方向。我们创建了一个基本的游戏引擎,将其包含在项目中,并检查它是否按预期工作。
后来,我们通过添加第二个片段和暂停对话框扩展了项目,正确管理了游戏的生命周期,并为不同的 Android 版本定义了全屏的方式。
最后,我们介绍了一些关于优化游戏关键部分代码的技巧。
我们准备好开始处理用户输入了。
第二章 管理用户输入
在本章中,我们将学习如何以通用方式处理用户输入,并稍后将其扩展为虚拟摇杆、传感器或外部控制器。
为了获得输入的视觉反馈,我们将在屏幕上放置一艘宇宙飞船并移动它。我们还将让它发射一些子弹。这也有助于您理解游戏对象与游戏引擎之间的交互。
我们将扩展通用的InputController类,以使最简单的键盘控制器尽可能易于理解,了解该类如何适应现有架构以及如何在游戏对象内部处理和读取输入。
一旦我们使基本的键盘工作,我们将实现一个虚拟摇杆,这是一种处理用户输入的更好方式。
管理物理控制器对我们正在编写的游戏也很重要,因此我们将了解如何检测它们并处理不同的选项。
最后,我们将简要讨论使用传感器作为控制方式。它们不适合这种类型的游戏,但我们将介绍基础知识,并提供一些链接,如果您想进一步探索。
InputController基类
无论我们想要如何控制游戏,它都可以抽象为一个二维摇杆和一个射击按钮。对于其他游戏,这可能是不同的,但您应该始终能够从用户那里提取基本动作并创建一个处理它们的InputController。我们正在构建的InputController对任何使用方向控制的游戏都很有用。
注意
输入控制器对每种类型的游戏都很具体。
我们将考虑一个归一化的水平和垂直轴作为输入(从-1 到 1)。对于没有范围的控制器,我们将将其设置为最大值。这将允许我们在允许输入类型的情况下,以精确的方式处理用户输入,正如虚拟和真实摇杆以及传感器的情况一样。

手机屏幕的坐标系
就像提醒一样,计算机屏幕上的坐标在左上角为[0,0],向右和向下为正值。右下角的坐标为[width, height]。这与我们习惯的标准坐标系不同,但您记住这一点非常重要。
这就是为什么向左移动是-1,向上移动也是-1 的原因。
我们游戏中所有输入控制器的基类将如下所示:
public class InputController {
public double mHorizontalFactor;
public double mVerticalFactor;
public boolean mIsFiring;
public void onStart() {
}
public void onStop() {
}
public void onPause() {
}
public void onResume() {
}
}
注意,这是一个具有公共变量的类。这样做是为了避免通过方法读取值。我们已经在上一章中提到过,这是一种性能提升的方法。
这个类的每个实现都将负责使用更新后的值填充这些变量。游戏对象可以在 onUpdate 期间读取它们。通过这种方式,我们将游戏对象使用值的动作与读取用户输入的动作分开。
注意
InputController 将输入的读取与其通过游戏对象的使用隔离开。
InputController 是 GameEngine 的一部分。我们只是向引擎添加了这个类型的变量并创建了一个设置它的方法:
public InputController mInputController;
public void setInputController(InputController controller) {
mInputController = controller;
}
onStart、onStop、onPause 和 onResume 方法是在游戏启动、停止、暂停或恢复时由 GameEngine 调用的。在这种情况下,一些输入控制器可能需要执行特殊操作。
最后,我们在 GameFragment 中初始化引擎时将输入控制器添加到 GameEngine 中:
mGameEngine = new GameEngine(getActivity());
mGameEngine.addGameObject(new ScoreGameObject(view, R.id.txt_score));
view.findViewById(R.id.btn_play_pause).setOnClickListener(this);
mGameEngine.setInputController(new InputController());
mGameEngine.addGameObject(new Player(getView()));
mGameEngine.startGame();
目前,我们添加了一个不执行任何操作的输入控制器。我们还添加了一个 Player 游戏对象,在详细研究不同的输入控制器之前,我们将先处理这个游戏对象。
注意,我们不再使用之前示例中的 ScoreGameObject,并且不应将其添加到游戏引擎中。
玩家对象
我们将要构建的 Player 游戏对象的第一版将只是初始化其在屏幕中间的坐标。然后,它将根据输入控制器中的信息更新它们,最后,它将在布局上的 TextView 中以 [x, y] 的形式显示值。
在此之后,我们将使其显示位于坐标处的宇宙飞船。但到目前为止,我们将专注于 onUpdate 的实现。
Player 类第一版 onUpdate 和 onDraw 的代码如下:
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
InputController inputController = gameEngine.inputController;
mPositionX += mSpeedFactor*inputController.mHorizontalFactor*elapsedMillis;
if (mPositionX < 0) {
mPositionX = 0;
}
if (mPositionX > mMaxX) {
mPositionX = mMaxX;
}
mPositionY += mSpeedFactor*inputController.mVerticalFactor*elapsedMillis ;
if (mPositionY < 0) {
mPositionY = 0;
}
if (mPositionY > mMaxY) {
mPositionY = mMaxY;
}
}
@Override
public void onDraw() {
mTextView.setText("["+(int) (mPositionX)+","+(int) (mPositionY)+"]");
}
因此,在每次 onUpdate 运行中,我们将使用相应的因子(我们从输入控制器中读取)、速度因子和已过毫秒数来增加 x 和 y 位置。这不过是经典的公式 距离 = 速度 * 时间。
代码的其余部分确保 x 和 y 位置保持在屏幕边界内。
onDraw 方法与 ScoreGameObject 的方法等效,但它只是在 TextView 中设置文本。
现在这段代码中有几个值我们没有初始化。它们如下所示:
-
mSpeedFactor:将速度转换为每毫秒像素。 -
mMaxX:x的最大值。它将是视图的宽度减去填充。 -
mMaxY:y的最大值。它是视图的高度减去填充。 -
mTextView:我们设置当前坐标的视图。
所有这些元素都在接收父视图作为参数的 Player 对象的构造函数中初始化:
public Player(final View view) {
// We read the size of the view
double pixelFactor = view.getHeight() / 400d;
mSpeedFactor = pixelFactor * 100d / 1000d;
mMaxX = view.getWidth() - view.getPaddingRight() - view.getPaddingRight();
mMaxY = view.getHeight() - view.getPaddingTop() - view.getPaddingBottom();
mTextView = (TextView) view.findViewById(R.id.txt_score);
}
我们计算屏幕的像素因子,以 400 个单位的高度为参考。这是一个任意数字,你可以使用对你有意义的任何数字。如果你想象在一个 400 像素高的屏幕上工作,然后让代码将其转换为实际的像素数,这将有所帮助。
这是一个与 dips 类似但不同的概念。虽然 dips 旨在在所有设备上具有相同的物理尺寸,但单位使我们的游戏可缩放。因此,无论分辨率或设备大小如何,游戏中的所有项目都将占据相同数量的屏幕空间。
备注
我们将以“单位”定义游戏空间,这样所有设备都有相同的屏幕高度。
我们希望我们的船以每秒 100 个单位的速度移动,所以从底部到顶部穿过屏幕需要 4 秒。由于我们需要以像素每毫秒的速度,我们需要将期望的速度乘以像素因子(像素/单位)并除以 1,000(毫秒/秒)。
下一步是读取父视图的宽度和高度,并在减去填充后使用它们作为最大宽度和高度。
最后,我们获取到我们将用于显示坐标的TextView的钩子。
一旦完成初始化,我们仍然有startGame方法。在这个方法中,我们将玩家定位在屏幕中间。
@Override
public void startGame() {
mPositionX = mMaxX / 2;
mPositionY = mMaxY / 2;
}
如果你现在尝试运行示例,你会看到位置保持在[0,0],这表明出了问题。
问题在于我们在视图创建后立即(在GameFragment的onViewCreated方法中)读取其宽度和高度。在这个时候,视图尚未被测量。
备注
你不能在构造函数期间获取视图的宽度和/或高度,因为此时它尚未被测量。
解决这个问题的方法是在视图被测量后延迟初始化GameEngine。最好的方法是使用ViewTreeObserver。让我们转到GameFragment的onViewCreated并更新它:
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
view.findViewById(R.id.btn_play_pause).setOnClickListener(this);
final ViewTreeObserver obs = view.getViewTreeObserver();
obs.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
obs.removeGlobalOnLayoutListener(this);
}
else {
obs.removeOnGlobalLayoutListener(this);
}
mGameEngine = new GameEngine(getActivity());
mGameEngine.setInputController(new BasicInputController(getView()));
mGameEngine.addGameObject(new Player(getView()));
mGameEngine.startGame();
}
});
}
我们获取用于布局的刚刚创建的视图的ViewTreeObserver,并向其添加一个新的OnGlobalLayoutListener。我们将监听器创建为一个匿名内部类。
每当执行全局布局时,此监听器都会被调用。为了避免多次调用并因此初始化多个引擎,我们需要在监听器被调用后立即移除它。
不幸的是,在 Jelly Bean 之前的 Android 版本中,用于移除监听器的函数名中有一个拼写错误,因此我们必须为 Jelly Bean 之前的版本使用一个方法名,而为后续版本使用另一个方法名。
方法内部剩余的代码是引擎初始化,这之前是在onViewCreated中直接完成的。我们只是将它移动到了onGlobalLayout中。
注意,虽然这些观点尚未被测量,但它们已经被创建并且存在。因此,没有必要将设置暂停按钮OnClickListener的代码移动到布局观察者中。
如果我们继续运行这个版本,我们会看到坐标显示的是屏幕中心的像素值。

显示宇宙飞船
如果我们至少不展示一艘宇宙飞船,所有这些都不好玩,这样我们才能看到确实有事情发生。
我们将从 OpenGameArt 网站(opengameart.org)获取游戏图形,该网站包含多个免费(自由)的游戏图形,其中大部分受 Creative Commons 许可协议保护,这意味着你必须给作者致谢。
注意
OpenGameArt.org 网站是游戏图形的一个极好资源。
我们将要展示的宇宙飞船是由 Eikesteer 制作的,我们将在整个游戏中使用它们。

我们从 Open Game Art 中挑选的 Eikesteer 制作的宇宙飞船套装
从套装中,我们将使用从右数第三个。我们可以使用像 GIMP 这样的简单编辑器将其提取为新的图像,并将其放置在drawable-nodpi目录下。
注意,我们将把所有内容缩放到与我们的 400 个屏幕高度单位一致,因此将图像放入具有密度限定符的 drawable 目录中是没有意义的。这就是为什么我们将使用drawable-nodpi。
drawable-nodpi目录旨在与任何密度无关,而drawable则用于没有限定符的图像。这意味着当我们尝试读取可绘制图像的内建尺寸时,行为会有所不同。当放置在nodpi下时,内建尺寸将返回实际尺寸,而从drawable读取时将取决于设备。
注意
我们将把游戏对象图像放在drawable-nodpi文件夹中。
下一步是创建一个ImageView来显示我们的宇宙飞船。我们将在Player对象的构造函数中完成这项工作:
public Player(final View view) {
[...]
// We create an image view and add it to the view
mShip = new ImageView(view.getContext());
Drawable shipDrawable = view.getContext().getResources()
.getDrawable(R.drawable.ship);
mShip.setLayoutParams(new ViewGroup.LayoutParams(
(int) (shipDrawable.getIntrinsicWidth() * mPixelFactor),
(int) (shipDrawable.getIntrinsicHeight() * mPixelFactor)));
mShip.setImageDrawable(shipDrawable);
mMaxX -= (shipDrawable.getIntrinsicWidth()*mPixelFactor);
mMaxY -= (shipDrawable.getIntrinsicHeight()*mPixelFactor);
((FrameLayout) view).addView(mShip);
}
构造函数的前一部分保持不变。然后我们添加创建ImageView并将Drawable加载到其中的代码。
首先,我们使用父视图的Context创建一个ImageView,并将其存储为类变量。
然后,我们从资源中加载飞船的Drawable并将其分配给局部变量shipDrawable。
我们继续为ImageView创建一个LayoutParams对象并设置它。由于我们已经有了一个 drawable,我们可以指定它的确切尺寸。为此,我们读取shipDrawable的内建宽度和高度,并将其乘以像素因子。
这意味着宇宙飞船的ImageView将被缩放到相当于 400 像素高的屏幕。另一种说法是,宇宙飞船的大小与在 400 像素高的屏幕上显示时的大小完全相同。然后,将 drawable 设置为ImageView。
我们还必须通过减去飞船的大小来更新x和y的最大值。这样,它就被放置在中心,不会超出边界。
最后,将ImageView添加到父视图中,这预期是一个FrameLayout。这个新要求来自于能够将图像放置在任何位置的需求。
这是我们需要更新或我们将得到ClassCastException的情况。我们正在更新fragment_game.xml布局,使其顶部布局为FrameLayout类型。
现在我们正在接触布局,我们还将暂停按钮对齐到右上角,这是大多数游戏暂停按钮的位置:
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
tools:context="com.plattysoft.yass.counter.GameFragment">
<TextView
android:layout_gravity="top|left"
android:id="@+id/txt_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello_world" />
<Button
android:layout_gravity="top|right"
android:id="@+id/btn_play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pause" />
</FrameLayout>
最后,我们需要更新onDraw方法,使其能够显示太空船在正确的位置。为此,我们只需使用translateX和translateY将ImageView平移到屏幕上的预期位置。
这远非最佳,但我们在下一章中会处理绘图。现在,它足以在正确的位置显示图像:
@Override
public void onDraw() {
mTextView.setText("["+(int) (mPositionX)+","+(int) (mPositionY)+"]");
mShip.setTranslationX((int) mPositionX);
mShip.setTranslationY((int) mPositionY);
}
如果我们启动游戏,我们可以在屏幕中间看到太空船:

现在我们有了太空船,是时候添加一些子弹了。
发射子弹
太空船将发射子弹,这些子弹将向上移动,直到它们超出屏幕。
正如我们在上一章的“游戏开发者良好实践”部分中提到的,我们将使用对象池来管理我们在Player类内部创建的子弹:
List<Bullet> mBullets = new ArrayList<Bullet>();
private void initBulletPool() {
for (int i=0; i<INITIAL_BULLET_POOL_AMOUNT; i++) {
mBullets.add(new Bullet(mPixelFactor));
}
}
private Bullet getBullet() {
if (mBullets.isEmpty()) {
return null;
}
return mBullets.remove(0);
}
private void releaseBullet(Bullet b) {
mBullets.add(b);
}
它初始化我们在屏幕上想要显示的子弹数量。如果你在池中有项目时请求子弹,它将移除一个并返回,但如果列表为空,它将返回 null。你可以将这个数字作为一个限制来影响游戏玩法,或者你可以进行数学计算,使池足够大。
在我们的情况下,由于子弹的速度和射击之间的时间,我们无法发射超过 6 发子弹。
回到池,要释放子弹,我们只需将其放回列表中。
现在,在玩家的onUpdate中,我们检查是否应该发射子弹:
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
updatePosition(elapsedMillis, gameEngine.mInputController);
checkFiring(elapsedMillis, gameEngine);
}
private void checkFiring(long elapsedMillis, GameEngine gameEngine) {
if (gameEngine.mInputController.mIsFiring
&& mTimeSinceLastFire > TIME_BETWEEN_BULLETS) {
Bullet b = getBullet();
if (b == null) {
return;
}
b.init(mPositionX + mShip.getWidth()/2, mPositionY);
gameEngine.addGameObject(b);
mTimeSinceLastFire = 0;
}
else {
mTimeSinceLastFire += elapsedMillis;
}
}
我们检查输入控制器是否按下了射击按钮,以及冷却时间是否已过。如果我们想并且可以射击,我们就从池中取一个子弹。
如果没有可用的子弹(对象 b 为 null),我们不做其他任何事情并返回。
一旦我们从池中获取一个Bullet,我们使用当前位置对其进行初始化,并将其放置在太空船的中间。然后,我们将其添加到引擎中。最后,我们重置上次射击的时间。
如果我们不能或不想射击,我们只需将经过的毫秒数添加到上次射击的时间。

在前面的图像中,我们可以看到子弹与太空船的相对位置以及为什么将x坐标作为太空船的中心可以给子弹提供正确的信息。但我们仍然需要添加一些偏移量。
从此刻起,所有关于子弹移动的逻辑都在Bullet对象内部完成。
子弹游戏对象
Bullet对象也扩展了GameObject。同样,就像太空船一样,它也在构造函数中创建了一个ImageView并将可绘制的内容加载到其中:
public Bullet(View view, double pixelFactor) {
Context c = view.getContext();
mSpeedFactor = pixelFactor * -300d / 1000d;
mImageView = new ImageView(c);
Drawable bulletDrawable = c.getResources().getDrawable(R.drawable.bullet);
mImageHeight = bulletDrawable.getIntrinsicHeight() * pixelFactor;
mImageWidth = bulletDrawable.getIntrinsicWidth() * pixelFactor;
mImageView.setLayoutParams(new ViewGroup.LayoutParams(
(int) (mImageWidth),
(int) (mImageHeight)));
mImageView.setImageDrawable(bulletDrawable);
mImageView.setVisibility(View.GONE);
((FrameLayout) view).addView(mImageView);
}
与Player对象的构造函数相比,唯一的区别是我们将ImageView的可见性设置为GONE,因为除非正在发射,否则子弹不应该显示。子弹也有一个用于绘制的mPositionX和mPositionY。
这些相似之处源于这两个游戏对象都是我们所说的精灵。精灵是与它关联的图像的GameObject,并在屏幕上渲染。
注意
精灵是游戏中显示并作为一个单一实体操作的(通常是一个 2D 图像)游戏对象。
在下一章中,我们将提取精灵的公共概念并将它们放入一个基类中。
在构造函数中,我们还设置了子弹的速度为每秒 300 个单位。这是太空船速度的 3 倍。你可以调整速度和子弹之间的时间间隔,但请记住测试在太空船向上移动时连续发射时它们不会重叠。
如果你修改了子弹速度,你可能还需要检查池的大小。最坏的情况是,当太空船位于屏幕底部时,持续发射。
下一个有趣点是初始化。这是通过接收太空船位置的init方法来完成的:
public void init(Player parent, double positionX, double positionY) {
mPositionX = positionX - mImageWidth/2;
mPositionY = positionY - mImageHeight/2;
mParent = parent;
}
值得注意的是,我们希望将子弹放置在太空船前方一点,并且正确居中。由于成员变量mPositionX和mPositionY指向图像的左上角,我们必须根据子弹的大小对初始参数应用偏移。
我们只在垂直轴上(mImageHeight/2)将子弹定位在太空船外部的一半,以增强它从太空船发射的感觉。我们还在水平轴上居中显示它,这就是为什么我们还要减去mImageWidth/2。
上一个章节中的图像也将帮助你可视化这个偏移量。
因为子弹是在GameEngine中添加和移除的,所以当它们被添加和移除时,我们需要改变视图的可见性。这需要在UIThread上完成。为此,我们使用上一章中创建的回调函数:
@Override
public void onRemovedFromGameUiThread() {
mImageView.setVisibility(View.GONE);
}
@Override
public void onAddedToGameUiThread() {
mImageView.setVisibility(View.VISIBLE);
}
注意
所有对视图的更改都必须在UIThread上完成,否则将抛出异常。
由于这些子弹也是精灵,所以onDraw方法几乎与玩家的方法相同。我们再次通过动画视图和转换来实现这一点:
@Override
public void onDraw() {
mImageView.setTranslationX((int) mPositionX);
mImageView.setTranslationY((int) mPositionY);
}
另一方面,onUpdate方法略有不同,详细研究它很有趣:
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mPositionY += mSpeedFactor * elapsedMillis;
if (mPositionY < -mImageHeight) {
gameEngine.removeGameObject(this);
// And return it to the pool
mParent.releaseBullet(this);
}
}
与我们对玩家所做的一样,我们使用距离 = 速度 * 时间公式。但在这个情况下,InputController没有任何影响。子弹有一个固定的垂直速度。
我们还检查子弹是否飞出了屏幕。由于我们在屏幕的左上角绘制项目,我们需要确保它完全消失。这就是为什么我们与mImageHeight进行比较。
如果子弹飞出,我们就从GameEngine中移除它,并通过调用releaseBullet将其返回到池中。
这个游戏对象移除是在GameEngine的onUpdate循环中完成的。如果我们此时修改列表,在执行GameEngine中的onUpdate时将会得到ArrayIndexOutOfBoundsException异常。这就是为什么removeGameObject方法将对象放入一个单独的列表中,在调用onUpdate之后进行移除。
现在,除非我们可以移动宇宙飞船并射击子弹,否则所有这一切都是无用的。让我们构建最基本的InputController。
最基本的虚拟键盘
最简单的方法是在屏幕左侧构建一个十字形状的简单键盘,并在其右侧放置一个射击按钮。对于这个布局,我们将在layout文件夹下创建一个新文件,命名为view_keypad.xml:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_gravity="bottom"
android:padding="@dimen/keypad_size"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/keypad_up"
android:layout_alignParentTop="true"
android:layout_toRightOf="@+id/keypad_left"
android:layout_width="@dimen/keypad_size"
android:layout_height="@dimen/keypad_size" />
<Button
android:id="@+id/keypad_down"
android:layout_below="@+id/keypad_left"
android:layout_toRightOf="@+id/keypad_left"
android:layout_width="@dimen/keypad_size"
android:layout_height="@dimen/keypad_size" />
<Button
android:id="@+id/keypad_left"
android:layout_alignParentLeft="true"
android:layout_below="@+id/keypad_up"
android:layout_width="@dimen/keypad_size"
android:layout_height="@dimen/keypad_size" />
<Button
android:id="@+id/keypad_right"
android:layout_toRightOf="@+id/keypad_up"
android:layout_below="@+id/keypad_up"
android:layout_width="@dimen/keypad_size"
android:layout_height="@dimen/keypad_size" />
<Button
android:id="@+id/keypad_fire"
android:layout_alignParentRight="true"
android:layout_alignTop="@+id/keypad_left"
android:layout_width="@dimen/keypad_size"
android:layout_height="@dimen/keypad_size" />
</RelativeLayout>
我们有一个相对布局,覆盖了屏幕的全宽。它有一个layout_gravity设置为bottom,所以我们确信它会被正确对齐。
我们将四个按钮的垫子排列在一个RelativeLayout中。左按钮与布局的左侧对齐,上按钮与布局的顶部对齐。然后,将上下按钮设置为左按钮的右侧。右按钮设置为上按钮的下方和右侧。最后,左按钮设置为上按钮的下方,下按钮正好位于左按钮下方。听起来有点复杂,但图片会更清晰。

在屏幕的另一侧,与父元素对齐,位于左按钮的上方,我们有一个射击按钮。
你可能已经注意到,所有按钮都使用一个名为keypad_size的特殊维度。这是一个非常重要的点,不仅是为了使它们看起来都一样,而且对于一般的使用性也很重要。我们将它设置为 42 dp,这是触摸目标的推荐最小尺寸。
注意
可触摸项目的最小尺寸应为 42 dp。
随意调整按钮的大小,并亲自观察,你会发现尺寸较小的按钮很难触摸。实际上,对于游戏,我们应该始终使用大尺寸的触摸目标,有时甚至比提供视觉反馈的区域还要大。控制器的触摸区域越大,越好。在这个例子中,射击按钮的触摸区域可以大到屏幕的右半部分。
我们将在这个游戏片段中包含这个布局,这样我们就可以看到它是如何叠加的。由于我们已经将顶部布局更新为FrameLayout,我们只需要使用一个include标签。
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
tools:context="com.plattysoft.yass.counter.GameFragment">
<TextView
android:layout_gravity="top|left"
android:id="@+id/txt_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello_world" />
<Button
android:layout_gravity="top|right"
android:id="@+id/btn_play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pause" />
<include layout="@layout/view_keypad" />
</FrameLayout>
如果我们继续运行它,我们可以看到整体的效果。

现在我们来编写BasicInputController的代码来处理按钮。从构造函数开始,代码如下:
public BasicInputController(View view) {
view.findViewById(R.id.keypad_up).setOnTouchListener(this);
view.findViewById(R.id.keypad_down).setOnTouchListener(this);
view.findViewById(R.id.keypad_left).setOnTouchListener(this);
view.findViewById(R.id.keypad_right).setOnTouchListener(this);
view.findViewById(R.id.keypad_fire).setOnTouchListener(this);
}
我们将游戏控制器设置为所有按钮(上、下、左、右和射击)的触摸监听器:这是很重要的,我们必须使用OnTouchListener而不是OnClickListener。
onClick回调仅在按钮被按下然后释放时触发。在我们的情况下,我们需要知道按钮何时被按下以及何时被释放。在按钮被按下的同时,我们需要移动太空船。这就是为什么我们需要OnTouchListener提供的更详细的回调。
BasicInputController中OnTouchListener方法的具体实现如下:
@Override
public boolean onTouch(View v, MotionEvent event) {
int action = event.getActionMasked();
int id = v.getId();
if (action == MotionEvent.ACTION_DOWN) {
// User started pressing a key
if (id == R.id.keypad_up) {
mVerticalFactor -= 1;
}
else if (id == R.id.keypad_down) {
mVerticalFactor += 1;
}
else if (id == R.id.keypad_left) {
mHorizontalFactor -= 1;
}
else if (id == R.id.keypad_right) {
mHorizontalFactor += 1;
}
else if (id == R.id.keypad_fire) {
mIsFiring = false;
}
}
else if (action == MotionEvent.ACTION_UP) {
if (id == R.id.keypad_up) {
mVerticalFactor += 1;
}
else if (id == R.id.keypad_down) {
mVerticalFactor -= 1;
}
else if (id == R.id.keypad_left) {
mHorizontalFactor += 1;
}
else if (id == R.id.keypad_right) {
mHorizontalFactor -= 1;
}
else if (id == R.id.keypad_fire) {
mIsFiring = false;
}
}
return false;
}
重要的是要注意,我们调用的是getActionMasked而不是getAction。在多个触摸指针的情况下,getAction包括指针信息,而当请求作为掩码动作时,该信息被移除。这就是为什么推荐处理多点触控的方式是使用getActionMasked和getActionPointer。否则,你需要使用OR操作来检查动作,而不是使用等于,否则在读取上面的指针时不会工作。
注意
使用getActionMasked和getPointerIndex是处理多点触控的推荐方式。
我们有两种情况。当动作是MotionEvent.ACTION_DOWN时,这意味着用户已经按下了按钮,因此我们检查被触摸的视图的 ID 并相应地操作。
如果视图是向上或向下,我们向垂直因子加或减 1。同样,如果触摸的按钮是左或右,我们向水平因子加或减 1。
第二部分,处理MotionEvent.ACTION_UP动作,将加法或减法反转到相应的因子。
我们是在进行加法和减法,而不是将值设置为 1 或-1,以处理多点触控。例如,如果你首先点击右键,然后点击左键,太空船应该停止,因为你同时按下了两个按钮。一旦你释放其中一个,运动就会恢复。
对于射击按钮,当它按下时,我们将mIsFiring设置为true,当它抬起时设置为false。很简单。
最后,我们返回false。这很重要,因为它告诉系统事件没有被我们的监听器消耗,因此监听器的链可以继续。这个监听器链包括按钮自己的点击监听器,它负责将背景图像更改为与按钮状态一致的一个。如果我们返回true,则不会更新背景。
注意
OnTouch实现返回事件是否被此监听器消耗。
就这么简单——我们现在可以运行游戏了。我们会看到太空船在屏幕上移动,并且还发射了一些子弹。最后,YASS 开始看起来像一款游戏。
局限性和问题
这样简单的键盘存在一些局限性和问题。除了按钮相当小且难以操作之外,其余问题都源于用户移动触摸指针时的情况。
如果用户将指针移出按钮,Android 版本低于 API 级别 17 将触发MotionEvent.ACTION_DOWN类型的事件,但从这个 API 级别开始则不会。如果您想正确处理这种情况,您需要在每次移动或操作时进行检查,并验证它是否移出了原始视图的矩形,以便进行手动取消。但这并不是移动的唯一问题。如果您在一个按钮上点击并移动到相反的按钮,新的点击将不会被检测到,因为它是一个ACTION_MOVE而不是ACTION_DOWN。
解决这个问题的方法是检查每个事件中每个指针的位置,看它是否在按钮的矩形内,并相应地采取行动。
还有一个问题是无法处理对角线移动。
我们可以尝试解决这个键盘的问题。但由于它本身并不是一个非常优雅的输入控制器,我们将继续前进,创建一个合适的虚拟摇杆InputController。
创建虚拟摇杆
我们将改进用户输入,我们将通过创建虚拟摇杆来实现这一点。
虚拟摇杆测量触摸位置到其中心的距离,并使用这些信息在两个轴上设置值。它表现得像传统的模拟摇杆。
由于它是虚拟的,我们不受限于将其放置在屏幕上的特定位置,因此我们可以将其放置在玩家触摸的任何地方。
然而,我们无法将整个屏幕都用于虚拟摇杆。还需要一个射击按钮。
我们已经体验过小触摸目标带来的挫败感,所以我们将尽可能使射击按钮变大。这意味着我们将使用屏幕的一半空间用于虚拟摇杆,另一半空间用于射击按钮。
我们将要使用的布局将有两个视图填满屏幕,每个视图覆盖一半的宽度。我们将把这个布局命名为view_vjoystick.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View android:id="@+id/vjoystick_main"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:layout_weight="1"
/>
<View android:id="@+id/vjoystick_touch"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:layout_weight="1"
/>
</LinearLayout>
这个布局有趣的地方在于使用了 Android 的layout_weight来将屏幕等分为两部分。如果您想为虚拟摇杆或射击按钮留出更大的空间,可以修改权重值。
我们将创建一个类来处理这个用户的InputController。我们将称之为VirtualJoystickInputController,并且它显然将扩展InputController。
为了处理这个InputController的事件,我们将使用两个内部类。每个我们想要监听事件的视图一个:
public VirtualJoystickInputController(View view) {
view.findViewById(R.id.vjoystick_main)
.setOnTouchListener(new VJoystickTouchListener());
view.findViewById(R.id.vjoystick_touch)
.setOnTouchListener(new VFireButtonTouchListener());
double pixelFactor = view.getHeight() / 400d;
mMaxDistance = 50*pixelFactor;
}
mMaxDistance变量定义了我们认为用户已经达到最大距离的触摸距离。这个值再次是以屏幕单位来衡量的。你可以想象最大距离是虚拟游戏手柄的半径。这个距离越小,摇杆就越敏感。
较小的最大距离将允许快速反应,而较大的距离将允许更好的精度。请随意尝试调整其大小,使其按你的意愿工作。

火箭按钮比虚拟摇杆更容易操作。我们使用与上一个示例相同的逻辑。当事件是按下动作时,将mIsFiring设置为true,当事件是抬起动作时,将其设置为false:
private class VFireButtonTouchListener implements View.OnTouchListener {
@Override
public boolean onTouch(View v, MotionEvent event) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mIsFiring = true;
}
else if (action == MotionEvent.ACTION_UP) {
mIsFiring = false;
}
return true;
}
}
虚拟摇杆的监听器更有趣。我们在按下动作执行时记录触摸的位置,当触摸抬起时,我们也重置这些值。但是,只要它移动,我们就根据原始触摸的距离更新mHorizontalFactor和mVerticalFactor的值:
private class VJoystickTouchListener implements View.OnTouchListener {
@Override
public boolean onTouch(View v, MotionEvent event) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mStartingPositionX = event.getX(0);
mStartingPositionY = event.getY(0);
}
else if (action == MotionEvent.ACTION_UP) {
mHorizontalFactor = 0;
mVerticalFactor = 0;
}
else if (action == MotionEvent.ACTION_MOVE) {
// Get the proportion to the max
mHorizontalFactor = (event.getX(0) - mStartingPositionX) / mMaxDistance;
if (mHorizontalFactor > 1) {
mHorizontalFactor = 1;
}
else if (mHorizontalFactor < -1) {
mHorizontalFactor = -1;
}
mVerticalFactor = (event.getY(0) - mStartingPositionY) / mMaxDistance;
if (mVerticalFactor > 1) {
mVerticalFactor = 1;
}
else if (mVerticalFactor < -1) {
mVerticalFactor = -1;
}
}
return true;
}
}
请注意,我们希望将mHorizontalFactor和mVerticalFactor保持在-1 和 1 之间;因此,当距离大于mMaxDistance时,我们不予以考虑。
最后,是时候将这个新的控制器连接到GameEngine了。这相当简单。我们只需更新fragment_game.xml的布局,包括将view_vjoystick.xml替换为view_keypad.xml,然后更新GameEngine的初始化:
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
tools:context="com.plattysoft.yass.counter.GameFragment">
<TextView
android:layout_gravity="top|left"
android:id="@+id/txt_score"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello_world" />
<Button
android:layout_gravity="top|right"
android:id="@+id/btn_play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pause" />
<include layout="@layout/view_vjoystick" />
</FrameLayout>
作为提醒,GameEngine的初始化是在GameFragment的onViewCreated中完成的。我们只需要创建适当的InputController实例:
mGameEngine = new GameEngine(getActivity());
mGameEngine.setInputController(new
VirtualJoystickInputController(get View()));
mGameEngine.addGameObject(new Player(getView()));
mGameEngine.startGame();
是时候运行游戏并尝试这个控制器了。
一般考虑和改进
与我们之前做的基本键盘相比,这种输入方法是一个巨大的改进。触摸区域与屏幕大小相当,玩家不需要看这个屏幕区域来点击小按钮。它可以在任何地方工作。
该系统可以处理斜向以及水平和垂直移动,以及介于两者之间的任何移动。
玩家不需要从屏幕上移除手指来改变方向。
缺乏视觉反馈,可以通过在玩家使用时将虚拟游戏手柄绘制为两个圆圈来解决。一个大的圆圈将显示虚拟摇杆的范围,而一个较小的圆圈将显示当前的触摸指针。另一方面,你可能不想这样做,因为视觉杂乱的缺乏使得屏幕更干净。
物理控制器
是时候尝试一种硬核玩家喜欢的控制器类型了:物理控制器。
有一些设备将控制器作为其硬件的一部分包含在内。一些值得注意的例子是 XPeria Play——这是一款具有滑动游戏手柄的先驱手机——以及 Nvidia Shield,这是这一类中的最新产品。
XPeria Play 是第一个集成了游戏手柄的设备:

Nvidia Shield 是最强大的 Android 游戏设备之一,带有游戏手柄:

另一方面,有许多品牌为智能手机制作游戏控制器,它们在传统玩家中相当受欢迎。所有这些控制器都是蓝牙控制器,可以连接到您的手机或平板电脑。其中一些设计得可以让您的手机适应它,例如 Gametel(另一家先驱)和大多数 MOGA 型号。

配有可调节带子以固定手机的 MOGA 控制器
还有一些使用控制器作为主要输入源的 Android 设备。这里,我们谈论的是像 OUYA 或其他类似电视的设备,如 Amazon FireTV 或 Android TV。

OUYA 是第一个 Android 微型游戏机
这些设备的工作方式与 HID 设备非常相似,无论是以键盘的形式还是基于轴的方向控制(模拟摇杆)。我们处理它们所需要做的只是设置正确的监听器。
一些控制器确实有自己的专有库。我们不会涉及这一点,因为它们非常具体,并且提供了如何集成它们的详细文档。MOGA Pocket(更高级的 MOGA 控制器支持两种模式:专有和 HID)就是这种情况。
注意
大多数控制器都以 HID 的形式工作,这是标准的。
我们可以在Activity级别或View级别为控制器设置监听器。在任何情况下,我们都需要扩展这个类。无法为这些方法添加监听器,它们必须被重写。由于我们已经在扩展Activity类,我们将这样做。
注意
我们在Activity内部监听KeyEvent和MotionEvent。
我们需要监听两种类型的事件。如下所示:
-
KeyEvent:对于所有按钮按下,在一些游戏手柄中,还包括方向十字键 -
MotionEvent:与轴上的运动相关的事件:摇杆
我们希望将输入控制器与Activity分离,因此我们将创建一个特殊的监听器,它结合了我们需要的两个事件,然后在该监听器上创建Activity的代理。
我们需要的接口非常简单,我们将称之为GamepadControllerListener:
public interface GamepadControllerListener {
boolean dispatchGenericMotionEvent(MotionEvent event);
boolean dispatchKeyEvent(KeyEvent event);
}
在Activity内部,我们创建了一个设置GamepadControllerListener类型监听器的方法。由于我们一次只需要一个监听器,所以方法被设置为而不是添加。要移除监听器,我们只需将其设置为 null:
public void setGamepadControllerListener(GamepadControllerListener listener) {
mGamepadControllerListener = listener;
}
最后,我们必须在我们的Activity内部重写dispatchGenericMotionEvent和dispatchKeyEvent:
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (mGamepadControllerListener != null) {
if (mGamepadControllerListener.dispatchGenericMotionEvent(event)) {
return true;
}
}
return super.dispatchGenericMotionEvent(event);
}
@Override
public boolean dispatchKeyEvent (KeyEvent event) {
if (mGamepadControllerListener != null) {
if (mGamepadControllerListener.dispatchKeyEvent(event)) {
return true;
}
}
return super.dispatchKeyEvent(event);
}
注意,此方法使用的是返回true表示事件已被消耗,返回false表示事件未被消耗的约定。在我们的情况下,我们只有在事件被监听器消耗时才返回true。在其他情况下,我们将返回结果以委托事件到基类。
在超类中调用相应的方法非常重要,因为在Activity类内部进行了大量的处理,我们不希望意外地丢弃这些处理。
在这些组件就绪后,我们可以继续创建我们的GamepadInputController,它将扩展InputController并实现GamepadControllerListener:
public class GamepadInputController
extends InputController
implements GamepadControllerListener {
public GamepadInputController(YassActivity activity) {
mActivity = activity;
}
@Override
public void onStart() {
mActivity.setGamepadControllerListener(this);
}
@Override
public void onStop() {
mActivity.setGamepadControllerListener(null);
}
[...]
}
尽可能限制对Activity的关键事件和运动事件的钩子。这就是为什么我们重写InputController的onStop和onStart方法,只在游戏运行时设置监听器。
有几种可能的控制器布局。一般来说,它们通常有一个十字控制键和/或模拟控制杆。一些设备有几个模拟控制杆,当然还有按钮。总的来说,关于事件和不同游戏手柄如何配置有一些重要的细节:

-
十字控制键可以由按钮组成,也可以是另一个模拟控制杆。如果它有按钮,它将作为具有与 D-Pad 相同常量的
KeyEvent处理。如果它是一个模拟控制杆,它将使用AXIS_HAT_X和AXIS_HAT_Y。 -
模拟控制杆通过
MotionEvent处理,我们可以使用MotionEvent的getAxisValue方法来读取它们。默认控制杆将使用AXIS_X和AXIS_Y。 -
我们不会为这个游戏映射第二个模拟控制杆,但它被映射在
AXIS_Z和AXIS_RZ上。 -
按钮被映射为具有每个按钮名称的
KeyEvent。
处理MotionEvent
当我们接收到MotionEvent时,我们首先需要验证事件是否来自我们应该读取的源。
源是事件的一部分,它是一系列标志的组合。我们感兴趣的是:
-
SOURCE_GAMEPAD:表示设备具有如A、B、X或Y之类的游戏手柄按钮。 -
SOURCE_DPAD:表示设备有一个 D-Pad。 -
SOURCE_JOYSTICK:表示设备具有模拟控制杆。
我们应该处理的唯一运动事件是那些设置了控制杆标志的事件。游戏手柄和 D-Pad 源都将作为KeyEvent发送。
接收MotionEvent的处理方式如下:
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
int source = event.getSource();
if ((source & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) {
return false
}
mHorizontalFactor = event.getAxisValue(MotionEvent.AXIS_X);
mVerticalFactor = event.getAxisValue(MotionEvent.AXIS_Y);
InputDevice device = event.getDevice();
MotionRange rangeX = device.getMotionRange(MotionEvent.AXIS_X, source);
if (Math.abs(mHorizontalFactor) <= rangeX.getFlat()) {
mHorizontalFactor = event.getAxisValue(MotionEvent.AXIS_HAT_X);
MotionRange rangeHatX = device.getMotionRange(MotionEvent.AXIS_HAT_X, source);
if (Math.abs(mHorizontalFactor) <= rangeHatX.getFlat()) {
mHorizontalFactor = 0;
}
}
MotionRange rangeY = device.getMotionRange(MotionEvent.AXIS_Y, source);
if (Math.abs(mVerticalFactor) <= rangeY.getFlat()) {
mVerticalFactor = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
MotionRange rangeHatY = device.getMotionRange(MotionEvent.AXIS_HAT_Y, source);
if (Math.abs(mVerticalFactor) <= rangeHatY.getFlat()) {
mVerticalFactor = 0;
}
}
return true;
}
首先,我们检查源。如果不是来自控制杆,我们就直接返回false,因为我们不会消耗这个事件。
然后我们读取MotionEvent.AXIS_X和MotionEvent.AXIS_Y的轴值,并将它们分配给我们的变量。这是为了读取默认控制杆。但我们还没有完成。控制器可能有一个用作模拟控制杆的十字控制键。
为了决定我们是否读取辅助摇杆,我们检查默认摇杆是否有输入。如果没有,我们将辅助摇杆的值分配给我们的变量。
重要的是要注意,大多数模拟摇杆在 0 点并不完全对齐,因此比较 mHorizontalFactor 和 mVerticalFactor 的值与 0 并不是检测摇杆是否移动的有效方法。
注意
模拟摇杆在 0 点并不完全居中。
我们需要做的是读取设备的运动范围平坦值。这比听起来简单得多,因为所有这些信息都是 MotionEvent 的一部分。
然后,如果没有来自默认轴的输入,我们将 AXIS_HAT_X 和 AXIS_HAT_Y 的值分配给我们的变量。我们还检查轴的输入是否高于其平坦值,如果不是,则将其设置为 0。我们必须这样做,否则太空船在没有任何输入的情况下会移动得非常慢。
最后,我们返回 true 以指示我们已经消费了该事件。
处理键事件
dispatchKeyEvent 的实现与我们在屏幕按钮基本控制器上所做的非常相似:
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
int action = event.getAction();
int keyCode = event.getKeyCode();
if (action == MotionEvent.ACTION_DOWN) {
if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
mVerticalFactor -= 1;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
mVerticalFactor += 1;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
mHorizontalFactor -= 1;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
mHorizontalFactor += 1;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_BUTTON_A) {
mIsFiring = true;
return true;
}
}
else if (action == MotionEvent.ACTION_UP) {
if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
mVerticalFactor += 1;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
mVerticalFactor -= 1;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
mHorizontalFactor += 1;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
mHorizontalFactor -= 1;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_BUTTON_A) {
mIsFiring = false;
return true;
}
else if (keyCode == KeyEvent.KEYCODE_BUTTON_B) {
mActivity.onBackPressed();
return true;
}
}
return false;
}
唯一的重大区别是我们将键码与 D-Pad 的常量进行比较,而不是与视图 ID 进行比较。但除此之外,逻辑完全相同。
我们还必须注意将按钮 B 映射为返回键。虽然这已经在 Android 的最新版本中完成,但并非总是如此,因此我们需要处理它。为此,我们使用在 YassActivity 中已创建的 onBackPressed 回调。
此外,在 Android 4.2(API 级别 17)及其之前,系统默认将 BUTTON_A 视为 Android 返回键。这就是为什么我们应该始终使用 BUTTON_A 作为主要游戏动作的原因。
检测游戏手柄
当我们启动游戏时检查控制器是否连接是一种良好的做法。这允许我们在用户开始玩游戏之前显示如何使用控制器的帮助屏幕。我们还应该在游戏运行时检查控制器是否断开连接,以便暂停游戏。
虽然可以通过 InputDevice 类检查控制器,但检查控制器变化的功能仅在 API 级别 16 中引入(我们使用 minSDK=15)。
注意
检测控制器连接或断开连接的功能仅在 Jelly Bean 中引入。
我们不会提供向后兼容的解决方案来检测控制器的连接和断开。如果您需要这样做,官方文档中有详细的步骤,请参阅developer.android.com/training/game-controllers/compatibility.html;这些步骤基本上是在输入设备上使用轮询机制并检查列表中的变化。
我们将在MainMenuFragment的onResume期间检查游戏手柄。当检测到控制器第一次时,我们将显示一个AlertDialog,显示如何使用游戏手柄:
@Override
public void onResume() {
super.onResume();
if (isGameControllerConnected() && shouldDisplayGamepadHelp()) {
displayGamepadHelp();
// Do not show the dialog again
PreferenceManager.getDefaultSharedPreferences(getActivity())
.edit()
.putBoolean(PREF_SHOULD_DISPLAY_GAMEPAD_HELP, false)
.commit();
}
}
private boolean shouldDisplayGamepadHelp() {
return PreferenceManager.getDefaultSharedPreferences(getActivity())
.getBoolean(PREF_SHOULD_DISPLAY_GAMEPAD_HELP, true);
}
我们使用默认的共享首选项来存储是否已经显示了对话框。一旦显示,我们将值设置为 false,因此不再显示。
检查是否有控制器连接的方法如下:
public boolean isGameControllerConnected() {
int[] deviceIds = InputDevice.getDeviceIds();
for (int deviceId : deviceIds) {
InputDevice dev = InputDevice.getDevice(deviceId);
int sources = dev.getSources();
if (((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) ||
((sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK)) {
return true;
}
}
return false;
}
我们遍历输入设备,如果其中任何一个的来源是游戏手柄或操纵杆,我们就返回 true。
如果没有找到具有这些来源的设备,我们返回 false。
注意,每个InputDevice都有一个名称。这有助于在需要显示不同帮助屏幕的情况下识别特定的游戏手柄,例如 Nvidia Shield。
要检查控制器在游戏过程中是否断开连接,我们需要在InputManager上注册一个InputDeviceListener并处理事件。我们将使GameFragment实现InputDeviceListener。
我们在创建GameEngine后立即进行注册,并在onDestroy中停止游戏后进行注销。你需要添加一些注释以防止 int 在方法不可用的情况下给出错误,或者将其包裹在一个检查版本的if块中,就像我们之前做的那样。
然后,当设备断开连接时暂停游戏就很简单了:
@Override
public void onInputDeviceRemoved(int deviceId) {
if (!mGameEngine.isRunning()) {
pauseGameAndShowPauseDialog();
}
}
注意,当任何设备断开连接时,这将暂停游戏。一个非控制器的设备断开连接的可能性不大,但我们可以通过检查来源来确保它是一个控制器,就像我们在isGameControllerConnected中做的那样。
传感器和输入控制器
传感器是智能手机上控制游戏的常见方式。当游戏中的唯一控制是左右(如赛车游戏)时,它们工作得很好。如果你计划同时上下移动,你需要在游戏开始时让玩家进行校准以使其可用。请注意,当你只使用一个轴时,这种校准是不必要的。
此外,上下移动往往会干扰sensorLandscape方向。因此,对于 YASS,使用传感器不是一个很好的主意。
注意
传感器在某些情况下是很好的控制方式。
你还必须考虑,虽然传感器可以替代方向,但你仍然需要在屏幕上放置动作按钮——在我们的例子中,是射击按钮。
我们不会在 YASS 中使用传感器,但如果你想制作一个使用它们的游戏,我们将介绍基础知识。
你需要为加速度计注册一个监听器,并为磁场注册另一个监听器。你应该只在游戏运行时监听传感器,因此我们将重写生命周期方法以相应地注册和注销传感器:
private void registerListeners() {
SensorManager sm = (SensorManager)
mActivity.getSystemService(Activity.SENSOR_SERVICE);
sm.registerListener(mAccelerometerChangesListener,
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER),
SensorManager.SENSOR_DELAY_FASTEST);
sm.registerListener(mMagneticChangesListener,
sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD),
SensorManager.SENSOR_DELAY_FASTEST);
}
private void unregisterListeners() {
SensorManager sm = (SensorManager)
mActivity.getSystemService(Activity.SENSOR_SERVICE);
sm.unregisterListener(mAccelerometerChangesListener);
sm.unregisterListener(mMagneticChangesListener);
}
@Override
public void onStart() {
registerListeners();
}
@Override
public void onStop() {
unregisterListeners();
}
@Override
public void onResume() {
registerListeners();
}
@Override
public void onPause() {
unregisterListeners();
}
注意,我们使用的是SensorManager.SENSOR_DELAY_FASTEST,这意味着传感器会尽可能快和尽可能频繁地提供反馈。这对于实时游戏非常重要。
我们将对象设置为监听器。每个监听器将只复制传感器的值到一个我们将稍后处理的本地数组中。例如,在加速度计的情况下,我们将这样做:
@Override
public void onSensorChanged(SensorEvent event) {
System.arraycopy(event.values, 0, mLastAccels, 0, 3);
}
为了获得最终值,我们必须进行一些计算。因此,我们将添加一个 onPreUpdate 方法,该方法将在 GameEngine 调用 onUpdate 之前被调用。
需要注意的是,有一些特殊情况。它们如下:
-
有些设备缺少磁场传感器。在这种情况下,我们可以使用一个简化版本,使用加速度计的值。Nvidia Shield 和某些版本的 Nook 就是这些设备之一。
-
在所有情况下,传感器都与设备的默认方向相关,可以是横屏或竖屏。在处理值时,我们必须考虑这一点。
总体来说,水平轴的转换可以这样做:
private double getHorizontalAxis() {
if (SensorManager.getRotationMatrix(mRotationMatrix, null, mLastAccels, mLastMagFields)) {
if (mRotation == Surface.ROTATION_0) {
SensorManager.remapCoordinateSystem(mRotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, mRotationMatrix);
SensorManager.getOrientation(mRotationMatrix, mOrientation);
return mOrientation[1] * DEGREES_PER_RADIAN;
}
else {
SensorManager.getOrientation(mRotationMatrix, mOrientation);
return -mOrientation[1] * DEGREES_PER_RADIAN;
}
}
else {
// Case for devices which do NOT have magnetic sensors
if (mRotation == Surface.ROTATION_0) {
return -mLastAccels[0]* 5;
}
else {
return -mLastAccels[1] * -5;
}
}
}
getHorizontalAxis 代码执行以下步骤:
-
使用加速度计和磁传感器的最后数据计算旋转矩阵。
-
如果它返回 true,一切正常。根据设备的旋转,我们决定是否需要重新映射坐标系,然后返回转换为度的方向。
-
如果无法计算(缺少磁场传感器),该方法返回 false。我们必须依赖于使用加速度计值的一个近似。根据设备的旋转,我们应该使用一个或另一个轴。
设备的旋转可以在 InputController 的构造函数中通过一行代码读取。
mRotation = yassActivity.getWindowManager().getDefaultDisplay().getRotation();
最后,onPreUpdate 方法:
@Override
public void onPreUpdate() {
mHorizontalFactor = getHorizontalAxis()/ MAX_ANGLE;
if (mHorizontalFactor > 1) {
mHorizontalFactor = 1;
}
else if (mHorizontalFactor < -1) {
mHorizontalFactor = -1;
}
mVerticalFactor = 0;
}
is 方法只是将读取值(以度为单位)转换为 [-1,1] 范围内的值,使用我们认为是完全倾斜的最大角度。我建议你玩一下这个常数,从 30 度开始。
关于处理传感器的更多信息,你可以查看官方文档 developer.android.com/guide/topics/sensors/sensors_overview.html。
选择控制模式
游戏通常会让用户选择他们偏好的控制模式,但避免询问不必要的选项,尽可能减少摩擦也是一个好的实践。
YASS 只使用虚拟摇杆和游戏手柄控制。没有必要询问用户他们想要哪一个。两种输入模式都是兼容的,尤其是虚拟摇杆在不使用时不会在屏幕上显示任何内容。我们唯一需要做的是修改 GameEngine 以支持一个以上的 InputController。
注意
我们将同时支持两种输入模式。
同时支持两种输入模式的方法是创建一个 CompositeInputController,它使用组合模式来同时拥有 VirtualJoystickInputController 和 GamepadInputController,并组合两者的输入。
为了同步两个输入控制器读取的数据,我们打算在InputController上使用一个名为onPreUpdate的方法,这个方法将在onUpdate之前被调用。我们将使用它来填充mHorizontalFactor、mVerticalFactor和mIsFiring的值,这些值是从其他控制器读取的。
public void onPreUpdate() {
mIsFiring = mGamepadInputController.mIsFiring || mVJoystickInputController.mIsFiring;
mHorizontalFactor = mGamepadInputController.mHorizontalFactor + mVJoystickInputController.mHorizontalFactor;
mVerticalFactor = mGamepadInputController.mVerticalFactor + mVJoystickInputController.mVerticalFactor;
}
现在我们有一个可以用虚拟摇杆和游戏手柄控制的游戏。
摘要
我们已经学会了如何以多种方式处理用户的输入,以及如何使它对GameEngine透明。
为了从控制器获得适当的视觉反馈,我们创建了一个Player游戏对象,它根据InputController的值更新其位置。我们还学会了如何在游戏过程中添加和移除游戏对象到GameEngine。
我们创建了一个非常基本的键盘,后来演变成了虚拟摇杆。我们还学会了如何处理外部控制器。
到目前为止,我们的游戏有一个在屏幕上移动并发射子弹的宇宙飞船。它可以独立地使用虚拟摇杆或游戏手柄进行控制。
当前实现偶尔会有延迟,而我们刚刚开始在屏幕上绘制对象。是时候修复这个问题了。下一个目标:通过直接在视图中绘制来改进渲染,而不是依赖于在屏幕上定位视图。
第三章. 进入绘制线程
在本章中,我们将改进游戏中精灵的渲染。为此,我们将使用一个自定义的GameView,它将执行低级绘制。我们将实现两种不同的实现:一种扩展自View,另一种扩展自SurfaceView。我们将让DrawThread成为一个真正的线程,以更好地与这个GameView协同工作。
我们将重构项目,创建一个Sprite类,该类将用于游戏中所有绘制的项目。我们将在Canvas上绘制位图,并了解用于此目的的变换矩阵。
为了继续改进游戏,我们将添加敌人。它们将是一波向我们的宇宙飞船移动的小行星。为此,我们将学习GameController的概念以及实现它的不同方法,从静态到程序化关卡生成。
作为渲染技术的一部分,我们将了解遮挡剔除和视差背景,我们将使用这些技术来使游戏看起来更美观。
最后,我们将为引擎添加对层的支持。
使用 GameView
到目前为止,我们一直在使用标准视图并将它们转换为渲染游戏的不同元素。虽然这是一种在屏幕上绘制元素的方法,但它远非高效。我们依赖于系统布局来进行绘制。
虽然这种技术在回合制游戏或任何非实时游戏中都很好,但它无法以足够的帧率渲染实时游戏。
注意
对于非实时游戏,使用标准视图是可行的。
我们将创建一个自定义的View,我们将称之为GameView。这个视图将负责绘制精灵。
我们已经在上一章中提到了代码重复的概念,并介绍了精灵(sprite)的概念。现在我们将继续前进,创建一个Sprite类,该类将负责在GameView内部特定坐标处绘制图像。
在 Android 上,有几种低级绘图方式。它们是:
-
扩展
View并重写onDraw -
扩展
SurfaceView并使用SurfaceHolder
在这两种情况下,我们都会得到一个Canvas,并在其上绘制我们的GameObjects。主要区别在于View的onDraw方法是在UIThread上执行的,而SurfaceView和SurfaceHolder被设计为在单独的线程上执行绘制。
注意
在 Android 上,低级绘图始终使用Canvas完成。
根据官方文档,使用SurfaceView更高效。但是,从 Android 4.0 开始,视图渲染是硬件加速的(而SurfaceView不是)。在具有高分辨率屏幕和快速处理器的现代手机上,这并不总是如此。
注意
SurfaceView不是硬件加速的,可能比普通View表现更差。
无论如何,你应该了解它们,并且能够轻松地互换,即使只是为了测试目的。我们将创建一个名为GameView的接口,这两个类将实现它,以便它们可以轻松更改。我们将制作的类是:
-
StandardGameView:这将扩展自View -
SurfaceGameView:这将扩展自SurfaceView
GameView接口
GameView接口将包含GameEngine处理View所需的所有方法:
public interface GameView {
void draw();
void setGameObjects(List<GameObject> gameObjects);
// Generic methods from View
int getWidth();
int getHeight();
int getPaddingLeft();
int getPaddingRight();
int getPaddingTop();
int getPaddingBottom();
Context getContext();
}
我们基本上需要两种方法,一种用于触发绘制,另一种将游戏对象列表传递给GameView,以便它们可以在那里绘制:
-
draw:这将触发GameView的绘制 -
setGameObjects:这将设置View的GameObjects列表
其余的方法都在View中实现。我们需要声明它们,因为我们正在GameEngine中使用它们。
让我们详细探索每种实现方式。
StandardGameView
StandardGameView类扩展自View。我们只为View提供了基本的构造函数,重写了onDraw方法,然后实现了GameView中的方法:
public class StandardGameView extends View implements GameView {
private List<GameObject> mGameObjects;
public GameView(Context context) {
super(context);
}
public GameView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public GameView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
synchronized (mGameObjects) {
int numObjects = mGameObjects.size();
for (int i = 0; i < numObjects; i++) {
mGameObjects.get(i).onDraw(canvas);
}
}
}
@Override
public void draw() {
postInvalidate();
}
@Override
public void setGameObjects(List<GameObject> gameObjects) {
mGameObjects = gameObjects;
}
}
基本上,setGameObjects存储游戏对象的引用。游戏对象的添加和删除在GameEngine中完成。当我们绘制视图时,我们遍历游戏对象列表,对它们中的每一个调用onDraw,并传递我们正在绘制的Canvas对象。
注意,该方法使用mGameObjects变量进行同步。这很重要,因为我们提到在第一章中,设置项目,列表的内容可以在onUpdate期间改变,我们不希望在我们遍历列表时发生这种情况。
另一个重要的点是,GameObjects 的列表是 GameEngine 中列表的引用,而不是副本,所以每当列表被修改时,最新的值都可以从两个地方访问。这也是为什么需要同步的原因。
注意
GameObjects 的列表在 GameEngine 和 GameView 之间共享。
在性能方面,在每次 onDraw 执行中都将列表中的所有元素复制到新列表中是没有意义的。
要触发绘制,我们只需调用 postInvalidate。记住,使视图无效必须在 UIThread 上完成。这就是为什么我们需要调用 postInvalidate 的原因。此方法将发布一个将在 UIThread 上运行的 Runnable,然后使 View 无效。
正如我们在前面的章节中提到的,一旦视图被使无效,Android 确保调用 View 的 onDraw 方法,然后更新 UI。这是使视图无效和 onDraw 方法之间的联系,我们在其中绘制游戏对象。
onDraw 方法显然是时间敏感的。我们应该避免所有不必要的操作。特别是,如果你在 onDraw 中创建对象,lint 会显示警告。这再次重申,是游戏开发者的最佳实践:始终提前创建对象。
注意
永远不要在 onDraw 中创建对象。
此外,值得记住的是,Android 有一个回退机制来避免绘图过载。如果一个视图已被使无效但尚未重绘,对无效的调用将被忽略(视图已经将要重绘)。
SurfaceGameView
要实现一个扩展 SurfaceView 的 GameView,我们需要为 SurfaceHolder 定义一个 Callback——用于访问 SurfaceView 的类——然后,每当我们要绘制时,我们锁定画布,在其上绘制,然后再解锁它,以便它可以由 SurfaceView 渲染。
让我们看看 SurfaceGameView 的代码:
public class SurfaceGameView extends SurfaceView implements SurfaceHolder.Callback, GameView {
private List<GameObject> mGameObjects;
private boolean mReady;
public SurfaceGameView(Context context) {
super(context);
getHolder().addCallback(this);
}
public SurfaceGameView(Context context, AttributeSet attrs) {
super(context, attrs);
getHolder().addCallback(this);
}
public SurfaceGameView(Context c, AttributeSet attrs, int defStyleAttr) {
super(c, attrs, defStyleAttr);
getHolder().addCallback(this);
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
mReady = true;
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
mReady = false;
}
@Override
public void setGameObjects(List<GameObject> gameObjects) {
mGameObjects = gameObjects;
}
@Override
public void draw() {
if (!mReady) {
return;
}
Canvas canvas = getHolder().lockCanvas();
if (canvas == null) {
return;
}
canvas.drawRGB(0,0,0);
synchronized (mGameObjects) {
int numObjects = mGameObjects.size();
for (int i = 0; i < numObjects; i++) {
mGameObjects.get(i).onDraw(canvas);
}
}
getHolder().unlockCanvasAndPost(canvas);
}
}
首先,我们有三个具有不同参数的构造函数,这些参数是 SurfaceView 内在的。注意,它们都包括调用将 Callback 设置到 SurfaceHolder,这也由 SurfaceGameView 实现。此回调将通知我们 SurfaceView 是否已准备好或何时发生变化。
下一个方法是 Callback 接口的实现。这些是在 SurfaceView 创建、修改或销毁时被调用的方法。我们存储视图的状态,以便知道它是否已准备好,这样就可以用于绘制。一个 View 在创建后直到销毁的任何时间都是可用的。
然后,我们进入实现 GameView 的方法。
要设置 GameObjects,我们与 StandardGameView 完全一样,同样在处理引用时也有相同的含义。
draw 方法是事情有点不同。我们必须检查视图是否已准备好。如果是这样,我们锁定 Canvas 以便我们可以在其上绘制。
一旦我们有了画布,我们在绘制每一帧之前需要清理它。画布上将有之前的图像。(如果我们不清理它,我们将在下面的屏幕截图中看到渲染伪影。)这种清理是通过使用drawRGB用纯色填充画布来完成的。

一旦我们清理了画布,我们就使用与StandardGameView相同的绘图,然后遍历游戏对象。
最后,我们解锁画布并发布它。这是我们将Canvas返回给SurfaceView并发布到UIThread的时刻。请注意,所有绘图都是在UIThread外完成的。只有当Canvas完全渲染后,它才会被返回以进行绘图。
注意
SurfaceView在UIThread外部的Canvas上执行绘图。
如前所述,SurfaceView应该提供更好的性能。但是,由于它只是软件加速,在现代手机上,在某些情况下,带有硬件加速的标准View可能更有效率。SurfaceView性能受到影响的一个特定情况是,如果我们将其上的其他视图(如暂停按钮)放在它上面,因为每次表面变化时都会执行完整的 alpha 混合合成。
更新 GameEngine
从GameEngine的角度来看,使用GameView有一些影响。这意味着它必须初始化GameView,然后使用通用接口触发绘图。
GameView将成为GameEngine构造函数的参数。它将被初始化,传递一个游戏对象列表的引用。更新后的GameEngine构造函数如下:
public GameEngine (Activity a, GameView gameView) {
mActivity = a;
mGameView = gameView;
mGameView.setGameObjects(mGameObjects);
mWidth = gameView.getWidth()
- gameView.getPaddingRight() - gameView.getPaddingRight();
mHeight = gameView.getHeight()
- gameView.getPaddingTop() - gameView.getPaddingBottom();
mPixelFactor = mHeight / 400d;
}
从现在起,我们也将计算pixelFactor,在GameEngine内部。我们将将其存储在一个公共变量中,以便游戏对象可以读取。这有几个优点,例如:
-
如果我们决定更改屏幕的单位数,这将在一个地方完成
-
移除代码重复总是有利于维护
另一方面,GameEngine的onDraw方法变得极其简单:
public void onDraw() {
mGameView.draw();
}
更新游戏布局
当然,我们必须修改fragment_game.xml布局以包含GameView。我们将借此机会对其进行一些其他修改,例如删除TextView并更改布局的填充为暂停按钮的边距。这确保了GameView全屏,同时保持按钮边距不变。
重要的是要记住,在FrameLayout中,XML 中的顺序指定了项目绘制的顺序(z-index)。我们将把GameView放在布局的开头,以确保暂停按钮绘制在其上方。
fragment_game.xml的新版本如下:
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.example.yass.counter.GameFragment">
<com.example.yass.engine.SurfaceGameView
android:id="@+id/gameView"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:layout_gravity="top|right"
android:id="@+id/btn_play_pause"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginRight="@dimen/activity_vertical_margin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pause" />
<include layout="@layout/view_vjoystick" />
</FrameLayout>
注意,这是决定我们将要使用 GameView 变体的地方。其余的代码将通过 GameView 接口访问方法,因此不需要做任何其他更改。从现在起,我们将使用 SurfaceGameView,但也可以自由地尝试 StandardGameView。
注意
布局是设置我们将要使用的 GameView 变体的地方。
最后,在 GameFragment 中,我们通过添加 GameView 参数来更新 GameEngine 的创建:
GameView gameView = (GameView) getView().findViewById(R.id.gameView);
mGameEngine = new GameEngine(getActivity(), gameView);
现在我们有一个依赖于 GameView 进行渲染的 GameEngine。我们仍然需要更新 GameObject 类以利用它。
在我们到达 GameObject 类之前,让我们花点时间改进 DrawThread。
改进 DrawThread
为了触发绘制,我们使用了一个 Timer 和 TimerTask 来安排,以便每秒获得 30 帧。虽然这可行,但像 UpdateThread 一样运行尽可能多的 onDraw 调用会提供更好的性能。
这种方法对于 SurfaceView 来说效果很好,因为绘制是在同一线程上完成的。但是,当使用 StandardGameView 时,可能会出现一些消息溢出问题,因为它只是调用 postInvalidate。为了防止溢出,我们将确保 onDraw 调用之间的时间永远不会短于 20 毫秒,这对于 50 帧每秒来说已经足够了。
新 DrawThread 的代码与 UpdateThread 的代码完全相同,只是在运行方法中处理溢出的部分不同。它看起来像这样:
@Override
public void run() {
long elapsedMillis;
long currentTimeMillis;
long previousTimeMillis = System.currentTimeMillis();
while (mGameIsRunning) {
currentTimeMillis = System.currentTimeMillis();
elapsedMillis = currentTimeMillis - previousTimeMillis;
if (mPauseGame) {
while (mPauseGame) {
try {
synchronized (mLock) {
mLock.wait();
}
} catch (InterruptedException e) {
// We stay on the loop
}
}
currentTimeMillis = System.currentTimeMillis();
}
if (elapsedMillis < 20) { // This is 50 fps
try {
Thread.sleep(20-elapsedMillis);
} catch (InterruptedException e) {
// We just continue.
}
}
mGameEngine.onDraw();
previousTimeMillis = currentTimeMillis;
}
}
如果运行两个调用过于接近,我们将线程置于剩余时间的睡眠状态,直到至少 20 毫秒。
如果发生 InterruptedException,我们实际上没有太多要处理的。因此,我们可以继续前进并调用 GameEngine 中的 onDraw。
Sprites
我们已经提到了精灵是一个在屏幕特定位置绘制和处理的项目。本质上,我们在游戏中看到的一切都是精灵。有一些例外,例如游戏控制器(不绘制任何内容)和背景(以不同的方式绘制),但我们将稍后在章节中讨论它们。
因此,这是扩展 GameObject 类的 Sprite 类的代码:
public abstract class Sprite extends GameObject {
protected double mPositionX;
protected double mPositionY;
protected final double mPixelFactor;
private final Bitmap mBitmap;
protected final int mImageHeight;
protected final int mImageWidth;
private final Matrix mMatrix = new Matrix();
protected Sprite (GameEngine gameEngine, int drawableRes) {
Resources r = gameEngine.getContext().getResources();
Drawable spriteDrawable = r.getDrawable(drawableRes);
mPixelFactor = gameEngine.mPixelFactor;
mImageHeight = (int) (spriteDrawable.getIntrinsicHeight()*mPixelFactor);
mImageWidth = (int) (spriteDrawable.getIntrinsicWidth()*mPixelFactor);
mBitmap = ((BitmapDrawable) spriteDrawable).getBitmap();
}
@Override
public void onDraw(Canvas canvas) {
mMatrix.reset();
mMatrix.postScale((float) mPixelFactor, (float) mPixelFactor);
mMatrix.postTranslate((float) mPositionX, (float) mPositionY);
canvas.drawBitmap(mBitmap, mMatrix, null);
}
}
Sprite 是一个抽象类,并且完全没有实现 onUpdate。Sprite 关注的是在屏幕上显示一个项目,而不是这个项目如何移动。
该类有一系列成员变量。让我们逐一介绍:
-
mPositionX,mPositionY: 屏幕上Sprite的位置。与Player和Bullet对象使用的相同概念。此位置位于图像的左上角。 -
mPixelFactor: 与之前相同的概念。将屏幕单位转换为像素的系数。 -
mBitmap: 我们将要绘制的位图。 -
mImageWidth,mImageHeight:屏幕上绘制的位图的大小。这里设置是为了方便。我们总是可以使用位图和像素因子来计算它,但直接存储以供子类将来使用更快。 -
mMatrix:此对象是一个变换矩阵。它用于在画布上渲染位图之前进行缩放、移动和旋转。为了优化目的,它被重用于onDraw执行之间,而不是为每次运行创建。
下一段代码是构造函数。我们传递GameEngine的引用和一个可绘制资源。在前一章中,我们传递了父View,这样我们就可以将其添加到新创建的ImageView中。这不再是必要的。我们只需要Context和像素因子,这些都可以从GameEngine中获取。
构造函数中的代码与我们之前看到的非常相似。它做了几件事情:
-
通过
Context从资源中加载Drawable。 -
获取可绘制对象的固有尺寸,并将其乘以像素因子以存储精灵将用于此特定设备的像素宽度和高度。
-
从
Drawable获取Bitmap并将其存储在类变量中,以便在onDraw中使用。
最后,我们有onDraw方法,它接收一个Canvas。现在这个方法是从GameView中调用的。Canvas的获取方式与StandardGameView和SurfaceGameView不同,但在两种情况下绘制逻辑是相同的。
Canvas 作为实际绘图表面的绘图接口。它为我们提供了一套绘图原语,包括位图、文本、线条、矩形、椭圆等。当使用 Canvas 时,绘图实际上是在一个底层的位图上进行的,然后该位图被放置在窗口中。
注意
Canvas 充当一个绘图接口,为我们提供基本绘图元素。
要绘制精灵,我们使用Canvas类的drawBitmap方法。此方法接收一个变换矩阵作为参数。让我们看看我们可以用Matrix做什么:
-
reset:我们将变换矩阵重置为上次运行的值。这是为了重用上次运行的Matrix对象。 -
postScale:我们在变换列表的末尾添加一个缩放变换。缩放与mPixelFactor相同。 -
postTranslate: 这将在变换列表的末尾添加一个翻译变换。这意味着这个变换将在缩放之后执行。我们将项目翻译到位置(mPositionX,mPositionY)。
目前,这就是我们将对变换矩阵所做的一切。在章节的后面,我们将添加旋转。
注意
变换矩阵中动作的顺序非常重要。结果会受到变换顺序的影响。
变换矩阵是转换位图的一个非常强大的工具。在创建矩阵时,关键是要记住顺序非常重要。当我们只使用平移和缩放时,顺序并不重要。但是,当使用旋转时,结果会受到变换顺序的影响。
更新飞船和子弹
现在我们有了 Sprite 基类,我们必须更新现有的 Player 和 Bullet 类,以便从它扩展。
Player 对象的大部分成员变量现在都是 Sprite 的一部分。我们也可以移除旧的 onDraw 实现,并依赖于 Sprite 中的实现。
最后,我们有新的更简单的构造函数,它们只接收 GameEngine。Player 类的构造函数如下:
public Player(GameEngine gameEngine) {
super(gameEngine, R.drawable.ship);
mSpeedFactor = mPixelFactor * 100d / 1000d;
mMaxX = gameEngine.mWidth - mImageWidth;
mMaxY = gameEngine.mHeight - mImageHeight;
initBulletPool(gameEngine);
}
Bullet 对象的构造函数如下:
public Bullet(GameEngine gameEngine) {
super(gameEngine, R.drawable.bullet);
mSpeedFactor = gameEngine.mPixelFactor * -300d / 1000d;
}
总的来说,我们已经将大量代码推送到 Sprite 类,这将使新游戏元素的添加更加容易。
在此之前,我们还要在 GameView 中添加每秒帧数计数器,以便能够比较 StandardGameView 和 SurfaceGameView 的性能。
添加每秒帧数(fps)计数器
我们已经更新了 DrawThread 以在任意帧数每秒运行,适应渲染所需的时间,而不是固定的 30 fps,并且我们正在使用精灵。现在是添加每秒帧数计数器的完美时机。这是一个非常简单的工具,并且也便于检查性能。
我们本可以使用 TextView,但有一些很好的理由直接在 Canvas 上绘制它:
-
当我们在
SurfaceView上叠加其他视图时,其性能会受到影响 -
这是在
Canvas上绘制其他方法的有趣示例 -
我们可以添加和移除它,而不需要触及布局
我们将创建一个名为 FPSCounter 的类,它从 GameObject 扩展,如下所示:
public class FPSCounter extends GameObject {
private final double mPixelFactor;
private final float mTextWidth;
private final float mTextHeight;
private Paint mPaint;
private long mTotalMillis;
private int mDraws;
private float mFps;
private String mFpsText = "";
public FPSCounter(GameEngine gameEngine) {
mPaint = new Paint();
mPaint.setTextAlign(Paint.Align.CENTER);
mTextHeight = (float) (25*gameEngine.mPixelFactor);
mTextWidth = (float) (50*gameEngine.mPixelFactor);
mPaint.setTextSize(mTextHeight/2);
}
@Override
public void startGame() {
mTotalMillis = 0;
}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mTotalMillis += elapsedMillis;
if (mTotalMillis > 1000) {
mFps = mDraws*1000 / mTotalMillis;
mFpsText = mFps+" fps";
mTotalMillis = 0;
mDraws = 0;
}
}
@Override
public void onDraw(Canvas canvas) {
mPaint.setColor(Color.BLACK);
canvas.drawRect(0,(int)(canvas.getHeight()-mTextHeight), mTextWidth, canvas.getHeight(), mPaint);
mPaint.setColor(Color.WHITE);
canvas.drawText(mFpsText, mTextWidth/2, (int) (canvas.getHeight()-mTextHeight/2), mPaint);
mDraws++;
}
}
逻辑相当简单;我们计算 onDraw 和 onUpdate 的调用次数。每当运行时间超过 1000 毫秒时,我们进行计算,存储结果,并重置这两个变量。
onDraw 方法绘制一个黑色正方形,然后在其中居中渲染文本。我们将正方形的大小设置为 50x25 单位,文本大小为正方形高度的一半,因此留有边距。为此,我们还需要使用一个 Paint 对象。
Paint 类有设置颜色、对齐方式、笔触宽度和类型等方法。由于我们主要绘制位图,我们不会对此进行更详细的说明。
注意,Paint 对象是创建一次并重复使用的,就像我们在 Sprite 上的 Matrix 一样,前提是我们不应该在 onDraw 中进行任何对象分配。
生成敌人 – GameController
现在,我们准备生成一些敌人。为此,我们将引入一个新概念:GameController。
GameController是一种没有视觉表示(它不是一个精灵)的特殊类型的GameObject,其任务是使用onUpdate调用控制游戏的演变。
GameController最典型的任务之一是管理环境。这包括在必要时使用正确的参数生成敌人。
游戏控制器分为两大类:
-
程序/随机
-
确定性/静态
程序/随机
这是一种基于一组参数(或函数)生成级别或敌人的GameController,这些参数(或函数)包含某种随机输入。
程序生成的最大优点是,你不必详细创建所有级别,你只需提供参数和算法即可。正确调整它可能很复杂,但一旦调整正确,它可能会在每次玩游戏时提供不同的设置。这提高了可玩性。
一些使用程序游戏控制器的是 Chalk Ball(生存模式)、水果忍者、Eufloria。程序级别生成的最经典例子是类似 Rogue 的游戏,其中地牢的每一层在你进入时都会生成。

Nethack,是第一个使用程序级别生成的游戏之一。
确定性/静态
确定性级别的生成用于当级别固定且始终相同的情况下。
这意味着需要关注很多细节,这允许在级别中微调难度。显然,这也需要花费很多时间。
大多数游戏谜题和塔防游戏都是这种类型的例子:愤怒的小鸟、剪绳子、异形、太空猫,等等。
作为一项经验法则,当使用确定性GameController时,你希望将级别的定义存储在可以修改而不需要触及代码的文件中。这允许你独立于代码调整级别或添加新的级别集合。你甚至可以创建一个外部编辑器来管理级别。
这些文件格式完全取决于你。我个人推荐使用结构化语言,如 XML 或 JSON。

SpaceCat 级别编辑器中看到的级别
混合方法
游戏级别设计不是非黑即白。有许多方法可以定义一个位于程序和确定性控制器之间的GameController。
例如,糖果传奇在级别布局确定时采用混合级别生成,但每次玩游戏时掉落的糖果都不同。
这兼顾了两者的优点。它允许你在每次玩游戏时都能调整级别的设计,同时保持其每次的不同。这也需要花费很多时间,因为你必须调整静态设计和算法。
我们的方案
对于 YASS 来说,拥有一个确定性的GameController没有意义,因此我们将采用程序生成。
我们将使小行星从屏幕顶部落下,角度在 [-30,30] 度的范围内变化。我们将限制它们在 x 轴上的位置在屏幕的 50 百分之中央区域。
速度将是恒定的,并且它们将以给定的间隔生成,这也是一个常数。
处理生成 Asteroids 的 GameController 代码如下:
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mCurrentMillis += elapsedMillis;
long waveTimestamp = mEnemiesSpawned*TIME_BETWEEN_ENEMIES;
if (mCurrentMillis > waveTimestamp) {
// Spawn a new enemy
Asteroid a = mAsteroidPool.remove(0);
a.init(gameEngine);
gameEngine.addGameObject(a);
mEnemiesSpawned++;
}
}
我们计算游戏运行的时间,然后计算下一个敌人生成的计时。这是已经创建的敌人数量乘以敌人之间的时间。
如果游戏当前时间大于下一个敌人必须出现的时间,那么我们就生成一个并添加到 GameEngine 中。
为了做到这一点,我们从对象池中获取一个 Asteroid,就像我们处理子弹一样。我们初始化它并将其添加到 GameEngine 中。然后,我们将生成的敌人数量加一。
代码通过 TIME_BETWEEN_ENEMIES 指定的毫秒数来分隔生成新的敌人。我们将其设置为 500 毫秒。
我们用于小行星的对象池与用于子弹的几乎相同。我不会再次重复相同的细节。
其余的生成过程都在 Asteroid 的 init 方法中,如下所示:
public void init(GameEngine gameEngine) {
// They initialize in a [-30, 30] degrees angle
double angle = gameEngine.mRandom.nextDouble()*Math.PI/3d-Math.PI/6d;
mSpeedX = mSpeed * Math.sin(angle);
mSpeedY = mSpeed * Math.cos(angle);
// Asteroids initialize in the central 50% of the screen
mPositionX = gameEngine.mRandom.nextInt(gameEngine.mWidth/2)+gameEngine.mWidth/4;
// They initialize outside of the screen vertically
mPositionY = -mImageHeight;
}
cos 和 sin 方法需要将参数转换为弧度,所以我们使用 Random.getDouble 获取一个在 [0,1] 范围内的双精度值,将其乘以 PI/3,然后减去 PI/6。这样,我们得到一个在 [-PI/6,PI/6] 范围内的随机值。
我们使用随机角度来获取 X 和 Y 上的速度分量,使用 sin 和 cos。
对于起始位置,我们使用相同的技术。我们获取一个在 [0,width/2] 范围内的随机整数,然后加上 width/4,所以最终值在 [width/4,width*3/4] 范围内。

关于小行星
最后,我们需要我们的 Asteroid 类,它将是一个 Sprite。就像我们处理其他图形一样,我们从 OpenGameArt 网站下载了我们的艺术作品。这次我们得到了几个形状和颜色不同的小行星。
小行星的实现与子弹的实现相似,但这次对象在从底部离开屏幕时从 GameEngine 中移除,并且它在两个轴上都有速度分量。
Asteroid 的代码如下:
public class Asteroid extends Sprite {
private final GameController mController;
private final double mSpeed;
private double mSpeedX;
private double mSpeedY;
public Asteroid(GameController gameController, GameEngine gameEngine) {
super(gameEngine.getContext(), R.drawable.a10000, gameEngine.mPixelFactor);
mSpeed = 200d*mPixelFactor/1000d;
mController = gameController;
}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mPositionX += mSpeedX * elapsedMillis;
mPositionY += mSpeedY * elapsedMillis;
// Check of the sprite goes out of the screen
if (mPositionY > gameEngine.mHeight) {
// Return to the pool
gameEngine.removeGameObject(this);
mController.returnToPool(this);
}
}
public void init(GameEngine gameEngine) {
// We already saw that
}
}
多亏了 Sprite 基类,这种实现非常简单。我们只需要在构造函数中设置速度,然后使用速度和 elapsedMillis 更新两个轴上的位置。
最后,我们检查对象是否超出边界,如果是,则将其从引擎中移除并返回到池中。
现在,我们可以编译并运行,可以看到小行星正朝我们的宇宙飞船飞来。

更多关于变换矩阵的内容
你可能已经注意到小行星看起来相当静止。这是因为图像始终如一。我们可以通过一个简单巧妙的方法来改进这一点:给它们添加旋转。
我们需要做的第一件事是更新Sprite类,使其能够处理旋转作为变换矩阵配置的一部分:
@Override
public void onDraw(Canvas canvas) {
mMatrix.reset();
mMatrix.postScale((float) mPixelFactor, (float) mPixelFactor);
mMatrix.postTranslate((float) mPositionX, (float) mPositionY);
mMatrix.postRotate((float) mRotation,
(float) (mPositionX + mImageWidth/2),
(float) (mPositionY + mImageHeight/2));
canvas.drawBitmap(mBitmap, mMatrix, null);
}
变换的顺序非常重要。变换是按顺序应用的,旋转确实会变换参考轴。因此,如果我们先旋转 45 度,然后向右平移 40 个单位,由于初始旋转改变了坐标的参考,最终位置将会向下和向左。另一方面,如果我们先平移再旋转,坐标系只有在最后才会受到影响。

这一开始可能不太直观,但它是一个非常强大的工具来描述非线性运动。我们将在第八章动画框架中看到更多关于变换的内容。
对于精灵,我们首先进行平移,然后旋转,以精灵在最终位置的中心作为旋转的支点。
注意
旋转也会影响对象的参考坐标系。
一旦Sprite知道如何处理旋转,我们只需要将小行星的旋转初始化为一个有意义的值,并且对于每个小行星都是不同的。
首先,我们将旋转初始化为一个随机角度,因此每个小行星都有一个不同的值。我们将此行添加到Asteroid的init方法中:
mRotation = gameEngine.mRandom.nextInt(360);
然后,我们将旋转速度与线性速度的角度成比例,因此每个小行星都有一个基于轨迹倾角的旋转速度。这也包含在init方法中:
mRotationSpeed = angle*(180d / Math.PI)/250d;
我们让小行星每秒进行一次与倾角相等的旋转。
一旦初始化了值,我们还需要在onUpdate调用期间更新它们。我们将向onUpdate添加以下代码:
mRotation += mRotationSpeed * elapsedMillis;
if (mRotation > 360) {
mRotation = 0;
}
else if (mRotation < 0) {
mRotation = 360;
}
我们更新旋转并确保它处于有效值范围内[0-360]。
注意
变换矩阵的旋转是以度数提供的。
注意,变换矩阵使用的是角度的度数而不是弧度,而我们使用的数学运算符期望的是弧度。始终要检查角度期望的单位。
如果你现在运行游戏,你会注意到这样一个简单的调整如何让游戏看起来更加美观。
次要遮挡剔除
次要遮挡剔除是一种在游戏中广泛使用的技术,尤其是在 3D 游戏中。由于绘制非常昂贵并且需要多次进行,因此每个优化都很重要。明显的优化是不绘制那些不会看到的部分(例如被其他东西遮挡)。如果我们只能将屏幕上的每个像素绘制一次,我们就能节省大量的处理时间。
绘制每个像素多次的事实被称为过度绘制。过度绘制高是影响性能的最主要因素之一。
在 3D 的情况下,绘制尤其昂贵,暗影剔除是大多数引擎在某种程度上自动执行的功能。
注意
暗影剔除通过不绘制未显示的内容来优化绘制时间。
在我们的情况下,我们可能会绘制一些像素两次,但由于我们只做 2D,绘制的成本并不高,而且这不是我们必须做的。
然而,有一个特殊情况:绘制位于GameView之外的精灵。
到目前为止,我们一直是根据需要生成精灵,一旦它们离开GameView就删除它们。我们可以在关卡初始化时将它们放置到位,并仅依靠对onUpdate的调用使它们出现在屏幕上。这对于场景或静态敌人等元素来说是很常见的。在类似 Rogue 的游戏中,我们会在开始时生成每个地牢关卡,然后生成所有精灵并将它们放置到位。
显然,绘制玩家视野之外的物体是浪费。因此,我们将确保Sprite类执行这种类似于暗影剔除的简单优化:
@Override
public void onDraw(Canvas canvas) {
if (mPositionX > canvas.getWidth()
|| mPositionY > canvas.getHeight()
|| mPositionX < -mImageWidth
|| mPositionY < -mImageHeight) {
return;
}
mMatrix.reset();
mMatrix.postScale((float) mPixelFactor, (float) mPixelFactor);
mMatrix.postRotate((float) mRotation,
(float) mImageWidth/2, (float) mImageHeight/2);
mMatrix.postTranslate((float) mPositionX, (float) mPositionY);
canvas.drawBitmap(mBitmap, mMatrix, null);
}
我们只需要检查x和y轴上的位置,看看精灵是否在绘制区域内。记住,我们使用图像的左上角点。这就是为什么我们必须检查负宽度和高度。
这在我们的游戏中不会有任何区别,但它可能对其他类型的游戏至关重要,尤其是那些具有静态内容的游戏。
垂直背景
2D 游戏中的另一个典型特征,与DrawThread密切相关的是垂直背景。
垂直背景的想法是有一个比前景元素移动速度慢的图像,从而产生深度感。
注意
垂直背景用于在 2D 游戏中创建深度错觉。
为了使这种效果更好,我们可以使用不同速度的多个背景图像。这在 2D 滚动游戏中很常见。例如,近处的树木和远处的山脉和云彩。
对于 YASS,我们将使用一个缓慢向下移动的星场作为背景。
我们将使用与精灵相同的单位约定。我们用于背景的图像应该设计成可以平铺的,在我们的情况下是垂直平铺。这意味着图像的末端与起始部分相匹配,因此它们一个接一个地放置并保持连续性。
它还应该设计得比最大的屏幕大。我们可以考虑最高屏幕是那些具有 16:9 宽高比的屏幕。因此,对于 400 像素的高度,这张图片至少应该是 720 像素宽(400 * 16 / 9 = 711.11)。
背景与精灵相当不同,主要是因为图像的大小以及在某些情况下我们可能需要绘制两个图像来覆盖屏幕。
我们将为此创建一个新的ParallaxBackground类:
public ParallaxBackground(GameEngine gameEngine, int speed, int drawableResId) {
Drawable spriteDrawable = gameEngine.getContext().getResources()
.getDrawable(drawableResId);
mBitmap = ((BitmapDrawable) spriteDrawable).getBitmap();
mPixelFactor = gameEngine.mPixelFactor;
mSpeedY = speed*mPixelFactor/1000d;
mImageHeight = spriteDrawable.getIntrinsicHeight()*mPixelFactor;
mImageWidth = spriteDrawable.getIntrinsicWidth()*mPixelFactor;
mScreenHeight = gameEngine.mHeight;
mScreenWidth = gameEngine.mWidth;
mTargetWidth = Math.min(mImageWidth, mScreenWidth);
}
构造函数与Sprite类似。我们加载位图,计算速度,并存储屏幕和图像在显示大小下的宽度和高度。
我们有一个新的概念:目标宽度。当图像大于屏幕时,我们使用它进行优化,这样我们就不绘制那些将不会被看到的内容。
首先,我们将使用与精灵上使用的相同变换矩阵概念进行简单的实现:
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mPositionY += mSpeedY * elapsedMillis;
}
@Override
public void onDraw(Canvas canvas) {
if (mPositionY > 0) {
mMatrix.reset();
mMatrix.postScale((float) (mPixelFactor), (float) (mPixelFactor));
mMatrix.postTranslate(0, (float) (mPositionY - mImageHeight));
canvas.drawBitmap(mBitmap, mMatrix, null);
}
mMatrix.reset();
mMatrix.postScale((float) (mPixelFactor), (float) (mPixelFactor));
mMatrix.postTranslate(0, (float) mPositionY);
canvas.drawBitmap(mBitmap, mMatrix, null);
if (mPositionY > mScreenHeight) {
mPositionY -= mImageHeight;
}
}
我们在y坐标处绘制图像。当这个坐标大于 0 时,视图顶部将会有空间需要用另一个图像填充。这就是代码的第一部分所做的工作;它再次绘制图像,但将整个图像的高度进行平移,以便它们可以拼接。
在任何情况下,我们都必须在mPositionY处绘制背景,这正是第二个块所做的。注意,当位置小于 0 时,我们只需要绘制一个图像。类的逻辑确保Y的值永远不会小于mImageHeight减去mScreenHeight。
一旦Y位置超出屏幕,绘制过程的第二部分就不再需要,所以我们减去图像的高度。这样,图像保持相同的位置并平滑滚动,因为绘制过程的第二部分现在等同于第一部分,后者没有进入。

背景可绘制状态的不同。
虽然这段代码可以工作,但它相当低效,因为它做了很多不可见的绘制。
我们可以使用drawBitmap的另一个变体在ParallaxBackground上实现绘制,该变体接收我们要绘制的图像的矩形以及屏幕上绘制时应显示的矩形。
这个案例的数学计算看起来有些复杂。让我们先看看代码:
private void efficientDraw(Canvas canvas) {
if (mPositionY < 0) {
mSrcRect.set(0,
(int) (-mPositionY/mPixelFactor),
(int) (mTargetWidth/mPixelFactor),
(int) ((mScreenHeight - mPositionY)/mPixelFactor));
mDstRect.set(0,
0,
(int) mTargetWidth,
(int) mScreenHeight);
canvas.drawBitmap(mBitmap, mSrcRect, mDstRect, null);
}
else {
mSrcRect.set(0,
0,
(int) (mTargetWidth/mPixelFactor),
(int) ((mScreenHeight - mPositionY) / mPixelFactor));
mDstRect.set(0,
(int) mPositionY,
(int) mTargetWidth,
(int) mScreenHeight);
canvas.drawBitmap(mBitmap, mSrcRect, mDstRect, null);
// We need to draw the previous block
mSrcRect.set(0,
(int) ((mImageHeight - mPositionY) / mPixelFactor),
(int) (mTargetWidth/mPixelFactor),
(int) (mImageHeight/mPixelFactor));
mDstRect.set(0,
0,
(int) mTargetWidth,
(int) mPositionY);
canvas.drawBitmap(mBitmap, mSrcRect, mDstRect, null);
}
if (mPositionY > mScreenHeight) {
mPositionY -= mImageHeight;
}
}
处理方式在概念上与之前的算法相同,但现在你必须记住,源矩形具有图像的原始比例,而目标矩形具有GameView的比例。除此之外,情况和它们的工作方式都是相同的。
虽然这种绘制在“只绘制所需内容”的标准下更高效,但在某些情况下并不高效。
当我们使用两个矩形drawBitmap时,会创建一个新的位图并使用它。然后丢弃这个位图,并为下一次调用onDraw创建另一个位图。在另一种实现中,位图不会被修改,因此它们可以保留在内存中,从而节省一些处理时间。
总体而言,性能取决于位图的大小,如果你使用SurfaceView或普通View,以及设备的硬件性能如何。
注意
虽然这种方法效率高,但绘制方式可能并不更快。位图操作和内存分配也是耗时的工作。
要向GameEngine添加背景,我们必须记住GameView中的绘制顺序是GameObjects被添加到GameEngine中的顺序,因此我们必须确保我们在其他游戏对象之前将背景添加到开始处。
GameFragment内部的GameEngine初始化现在应该如下所示:
mGameEngine = new GameEngine(getActivity(), (GameView) getView().findViewById(R.id.gameView));
mGameEngine.setInputController(
new CompositeInputController(getView(), getYassActivity()));
mGameEngine.addGameObject(
new ParallaxBackground(mGameEngine, 20, R.drawable.seamless_space_0));
mGameEngine.addGameObject(new GameController(mGameEngine));
mGameEngine.addGameObject(new Player(mGameEngine));
mGameEngine.startGame();
我们的游戏开始看起来真的很棒。

多重背景
在游戏中添加另一个具有不同速度的ParallaxBackground以及一组额外的星星相当简单,这样两者结合就能更好地体现深度感。这就像添加一个具有不同速度和具有透明度的可绘制ParallaxBackground对象一样简单。
如果我们这样做,我们将看到性能的显著下降。
这种性能问题是由于像素过度绘制数量造成的。使用第二个背景时,屏幕上的每个点至少被绘制两次(每个背景一次),当涉及精灵时,有些点甚至被绘制三次。透明像素也计入过度绘制。
注意
使用两个重叠的视差背景迫使每个像素在每一帧至少绘制两次。
总体来说,性能涉及许多参数。在大多数情况下,SurfaceGameView以及高效的背景绘制将给出最佳结果。但是,一旦我们在GameView上叠加另一个View,性能就会急剧下降。在这种情况下,StandardGameView的性能下降较慢。
当我们只使用一个背景时,GameView的两个实现或背景渲染方法在性能上没有太大差异。但同样,这取决于用于背景的位图的大小。我建议你亲自实验并查看差异。
虽然两个视差背景的效果很好,并且我们的GameView可以以合理的刷新率处理它,但我们将在 YASS 中只使用一个背景。在这种情况下,我们会有这么多的过度绘制,我们将达到使用标准 Android SDK(不使用 OpenGL)所能做到的性能极限。
如果你想要更复杂的背景,你可能需要转向使用 OpenGL 引擎,如 AndEngine,它使用与我们这里相同的概念。
层
到目前为止,GameObject的绘制顺序是它们被添加到GameEngine中的顺序。这至少是不方便的。我们应该改进它。
如同大多数其他绘图系统一样,我们的引擎应该使用层。
每当我们向GameEngine添加一个GameObject时,我们将传递一个整数来指示我们希望将其添加到的层。我们将 0 视为添加背景的层。将层想象成游戏对象的 z-index。
我们将使用四个层。从前景到背景,我们将显示:
-
Player对象:飞船 -
小行星
-
子弹
-
背景

为了添加层支持,我们需要修改GameEngine、StandardGameView和SurfaceGameView类。为了方便,我们还将更新GameObject以了解其层。
在顶层,我们将处理层作为游戏对象列表的列表。这是我们将在GameEngine中存储并传递给GameView实现的数据结构。我们将保留旧的mGameObject列表作为遍历所有GameObjects的简单方式。
我们在GameEngine的构造函数中初始化层。我们将向构造函数添加一个参数,显示我们期望拥有的层数,以便它们可以预先创建:
private List<List<GameObject>> mLayers = new ArrayList<List<GameObject>>();
public GameEngine (Activity a, GameView gameView, int numLayers) {
[...]
for (int i=0; i<numLayers; i++) {
mLayers.add(new ArrayList<GameObject>());
}
}
主要更改发生在onUpdate内部:
public void onUpdate(long elapsedMillis) {
mInputController.onPreUpdate();
int numObjects = mGameObjects.size();
for (int i=0; i<numObjects; i++) {
mGameObjects.get(i).onUpdate(elapsedMillis, this);
}
synchronized (mLayers) {
while (!mObjectsToRemove.isEmpty()) {
GameObject objectToRemove = mObjectsToRemove.remove(0);
mGameObjects.remove(objectToRemove);
mLayers.get(objectToRemove.mLayer).remove(objectToRemove);
}
while (!mObjectsToAdd.isEmpty()) {
GameObject gameObject = mObjectsToAdd.remove(0);
addToLayerNow(gameObject);
}
}
}
第一部分与之前相同。它遍历所有的GameObjects,调用onUpdate。
对于对象的移除,重要的是GameObject知道它的层。正因为如此,从层的列表中移除对象只是多一行代码。
在添加GameObjects时,了解层也很重要,尤其是当这种添加被延迟到onUpdate的末尾时。
有一个新方法叫做addToLayerNow。当对象在游戏开始前被添加时也会使用它。我们可以在GameEngine的addGameObject方法中看到它是如何使用的:
public void addGameObject(final GameObject gameObject, int layer) {
gameObject.mLayer = layer;
if (isRunning()){
mObjectsToAdd.add(gameObject);
}
else {
addToLayerNow(gameObject);
}
mActivity.runOnUiThread(gameObject.mOnAddedRunnable);
}
注意,当我们添加一个新的GameObject时,我们首先做的事情是设置它要添加到的层。
addToLayerNow方法处理当我们想要将一个GameObject添加到尚未定义的层的情况。如果我们正确定义了预创建的层数,这种情况不应该发生。但这是一个很好的安全措施,尤其是在你不确定将使用多少层时:
private void addToLayerNow (GameObject object) {
int layer = object.mLayer;
while (mLayers.size() <= layer) {
mLayers.add(new ArrayList<GameObject>());
}
mLayers.get(layer).add(object);
mGameObjects.add(object);
}
由于GameObject知道它需要添加到的层,这使得我们可以在一行代码中将它添加到正确的层。请注意,我们还将GameObject添加到所有游戏对象的列表中。
最后,在GameView的两个实现中,将游戏对象绘制到画布上的代码必须更改。这是使用层的顺序来提供绘制顺序的地方:
int numLayers = mLayers.size();
for (int i = 0; i < numLayers; i++) {
List<GameObject> currentLayer = mLayers.get(i);
int numObjects = currentLayer.size();
for (int j=0; j<numObjects; j++) {
currentLayer.get(j).onDraw(canvas);
}
}
注意,在每个层内部,绘制的顺序仍然是添加的顺序。由于我们现在可以将相同类型的项隔离到同一层,这不应该再是令人担忧的事情了。
此外,请注意,由于同步现在使用mLayers,GameViews和GameEngine中的同步对象是相同的。
摘要
我们已经学会了如何使用标准的View和SurfaceView进行低级绘制,我们还创建了一个Sprite类来重用屏幕上显示的项的代码。
DrawThread已经被更新为一个更高效的版本,我们还添加了一个每秒帧数计数器来检查每个配置的效率。
在旅途中,我们学习了游戏控制器和创建不同级别的不同方法。我们还决定使用程序生成来生成 YASS 彗星,并将其付诸实践。
我们还向引擎添加了对透视背景和层的支持。
总的来说,YASS 开始看起来不错,但我们显然缺少一些东西:子弹击中彗星时没有任何动作,我们的太空船也是如此。
是时候实现碰撞检测了。
第四章 碰撞检测
在大多数游戏中,我们需要检测对象何时相互交叉以触发动作;这被称为碰撞检测。我们将使用它来检测子弹击中彗星(以摧毁它)以及玩家被彗星击中(以结束游戏)。这种检测可以是离散的或连续的,并且可以涉及不同类型的形状。我们将使用矩形和圆形形状的离散检测。
如第一章中所述,设置项目,我们不会进行任何物理模拟。这是一个完全独立的话题,它足够长,足以成为一本书的主题。
我们还将讨论优化技术,并实现一种称为空间划分的方法,该方法根据对象密度将区域划分为更小的部分。
作为旁注,本章中的所有概念都可以很容易地推广到 3D 碰撞。
检测碰撞
检查碰撞有两种主要方法:
-
离散的,或后验
-
连续的,或先验
对于离散方法,我们推进游戏项目的状态,然后检查其中是否有任何项目相交。因为它只在每个步骤的末尾进行评估,所以它是一个离散模拟。因为它是在对象移动之后进行的,所以它被称为后验。它是反应性的。大多数时候我们缺乏确切的接触点;我们只知道在模拟步骤结束时对象发生了碰撞。
另一方面,连续方法在应用运动之前预测碰撞,基于每个对象的参数。它在运动执行之前进行计算。这就是为什么它被称为先验。这种方法提供了确切的接触点,当我们需要精确度时——例如物理模拟——它非常有用。
离散方法通常比连续方法简单一个维度。这使得它更容易理解和实现。它以速度换取精度。
注意
离散碰撞检测更快但精度较低。
由于我们不会使用物理引擎,并且我们并不真正关心确切的接触点,我们将使用离散方法。
哪些对象可以发生碰撞?
为了能够计算GameObject的碰撞,我们需要将一个形状(或身体)与一个GameObject关联起来。这种关联需要一些关于屏幕上对象的信息,例如在x轴和y轴上的位置,以及宽度和高度。
我们将创建一个名为 ScreenGameObject 的类,它继承自 GameObject 并将包含相关信息。在我们的游戏中,Sprite 是唯一一个将继承自 ScreenGameObject 的类,但如果你想在屏幕上放置非视觉项目,当玩家穿过它们时触发某些操作,这将是游戏中的常见技术。

GameObject 类层次结构
要与其他对象发生碰撞,你需要屏幕上的位置和大小。ScreenGameObject 将提供这些信息。
所有计算碰撞的逻辑都将放置在 ScreenGameObject 类内部。类的存根如下:
public abstract class ScreenGameObject extends GameObject {
protected double mX;
protected double mY;
protected int mHeight;
protected int mWidth;
public Rect mBoundingRect = new Rect(-1, -1, -1, -1);
public boolean checkCollision(ScreenGameObject otherObject) {
return false;
}
public void onCollision(GameEngine gameEngine, ScreenGameObject sgo) {
}
}
我们利用这个机会将变量 width、height、X 和 Y 重构为更短、更通用的名称。我们将继续在本章中工作与此类。
checkCollision 方法是动作发生的地方。此方法将根据我们用于计算碰撞的形状具有不同的实现。
最后,onCollision 用于在发生碰撞时触发一个动作。
更新 GameEngine
为了运行离散碰撞检测,我们将在 GameEngine 的 onUpdate 执行期间使用名为 checkCollisions 的方法,因此它将在游戏对象更新时运行。我们将在游戏对象更新之后和移除对象的代码之前放置这个方法调用。碰撞是对象被移除的典型原因。
由于只有 ScreenGameObjects 可以有碰撞,我们将创建一个包含它们的特殊列表。
private List<ScreenGameObject> mCollisionableObjects;
并且我们将在对象被添加到或从 GameEngine 中移除时保持其更新,就像我们在上一章中为单独的层所做的那样。
检查碰撞并通知涉及的游戏对象的代码如下。
private void checkCollisions() {
int numObjects = mCollisionableObjects.size();
for (int i = 0; i < numObjects; i++) {
ScreenGameObject objectA = mCollisionableObjects.get(i);
for (int j = i + 1; j < numObjects; j++) {
ScreenGameObject objectB = mCollisionableObjects.get(j);
if (objectA.checkCollision(objectB)) {
objectA.onCollision(gameEngine, objectB);
objectB.onCollision(gameEngine, objectA);
}
}
}
}
代码就像一个嵌套的 for 循环,检查每个 ScreenGameObject 与其他所有对象的碰撞。如果检测到碰撞,我们将对涉及的两个屏幕游戏对象执行 onCollision 方法,并将它们碰撞的对象作为参数传递。
请记住,此方法具有二次复杂度,而 onUpdate 中涉及的所有其他方法都具有线性复杂度。运行碰撞检测是昂贵的。
注意
检查碰撞具有二次复杂度 O(n²)。
处理碰撞
无论我们使用什么方法来计算碰撞,对于玩家和子弹,我们都要采取相同的行动。让我们为它们覆盖 onCollision 方法。
在子弹的情况下,我们必须检查与之碰撞的对象是否是小行星,如果是,则从 GameEngine 中移除这两个对象。
public void onCollision(GameEngine gameEngine, ScreenGameObject otherObject) {
if (otherObject instanceof Asteroid) {
// Remove both from the game (and return them to their pools)
removeObject(gameEngine);
Asteroid a = (Asteroid) otherObject;
a.removeObject(gameEngine);
}
}
注意,我们是在对象本身上调用一个名为 removeObject 的方法。此方法负责将 GameObject 从 GameEngine 中移除,并将其返回到对象池。
玩家的代码几乎与子弹的代码相同:在遇到小行星的情况下,我们只是移除两个碰撞对象。
值得注意的是,由于我们生成子弹的方式,当它们被添加到场景中时,它们会与Player对象发生碰撞,因为我们想让它们看起来是从宇宙飞船中出现的。我们必须忽略这种碰撞。始终检查对象正在与什么发生碰撞是良好的实践。
如果我们想要制作一个有多个生命的游戏,我们应该在那个时刻向GameEngine发出信号以停止生成波次,移除一个生命,生成一个新的Player对象,然后继续游戏。
让我们看看碰撞实际上是如何计算的。
矩形体
我们将要实现检测的第一种方式是通过矩形的交集,这也是最简单的方法。
我们将使用ScreenGameObject的边界矩形,并检查它是否与另一个ScreenGameObject的边界矩形相交。
每次我们更新精灵的位置时,边界矩形都会改变,由于我们可能需要与其他许多对象进行检查,所以在onUpdate之后重新计算它最好。我们将创建一个新的方法onPostUpdate并在其中执行此操作。
我们必须向ScreenGameObject添加一个新的方法。
public void onPostUpdate(GameEngine gameEngine) {
mBoundingRect.set(
(int) mX,
(int) mY,
(int) mX + mWidth,
(int) mY + mHeight);
}
如果你需要在其他对象上重写onPostUpdate,请记住始终调用超类方法,否则碰撞将表现异常。
然后,在检查碰撞时,我们进行矩形碰撞的检查:
@Override
public boolean checkCollision(ScreenGameObject otherObject) {
return checkRectangularCollision(otherObject);
}
最后,尽管计算矩形的交集是一个非常简单的操作,因为Rect类已经为我们提供了一个执行此操作的工具方法,所以我们将使用它。
private boolean checkRectangularCollision(ScreenGameObject other) {
return Rect.intersects(mBoundingRect, other.mBoundingRect);
}
添加视觉反馈
当处理碰撞时,真正有帮助的是获得一些关于正在发生什么的视觉反馈。为此,我们将以黄色为背景绘制边界矩形,作为精灵的背景:
@Override
public void onDraw(Canvas canvas) {
if (mX > canvas.getWidth() || mY > canvas.getHeight()
|| mX < -mWidth || mY < -mHeight) {
return;
}
mPaint.setColor(Color.YELLOW);
canvas.drawRect(mBoundingRect, mPaint);
mMatrix.reset();
[...]
canvas.drawBitmap(mBitmap, mMatrix, null);
}

现在是时候尝试我们的闪亮的碰撞检测方法,看看它的表现如何。剧透:不太好。
优缺点
这种碰撞检测有一些优点:
-
实现简单
-
评估速度快
但它也有一些重要的缺陷:
-
如果精灵有填充,碰撞区域太大,不真实。
-
当精灵旋转时,我们没有旋转矩形。
-
碰撞检测非常严格。系统检测到只有透明像素的地方发生碰撞。当角落接触时,这尤其糟糕。
我们可以通过裁剪精灵或为ScreenGameObject添加设置边距的可能性来解决第一个问题。
第二个问题可以通过一些简单的数学方法解决,但这会使代码复杂化,而且结果并不比圆形碰撞体更好。这超出了本书的范围,留给读者作为练习。

矩形体在图像上不需要填充,但仍可能产生误报。
不幸的是,最后一个问题是这种方法的固有缺陷,除非我们使用边界多边形或矩形的组合,否则无法解决。
圆形身体
我们可以用来检测碰撞的下一类身体是一个圆形。为此,我们将考虑圆的直径是精灵尺寸的最大值。我们必须在ScreenGameObject中添加一个名为mRadius的成员变量,并将此代码添加到精灵的构造函数中:
mRadius = Math.max(mHeight, mWidth)/2;
注意,从ScreenGameObject继承的其他元素可能希望以不同的方式初始化半径。
圆形碰撞的计算相当简单:我们只需要测量两个圆心之间的距离,并检查它是否小于半径之和。

对于圆形身体,碰撞可以发生在精灵的矩形之外
由于计算平方根比乘法要耗费更多的时间,我们将使用由勾股定理定义的距离的平方:距离**² = Δx**² + Δy**²。
检查圆形碰撞的代码如下:
private boolean checkCircularCollision(ScreenGameObject other) {
double distanceX = (mX + mWidth /2) - (other.mX + other.mWidth /2);
double distanceY = (mY + mHeight /2) - (other.mY + other.mHeight /2);
double squareDistance = distanceX*distanceX + distanceY*distanceY;
double collisionDistance = (mRadius + other.mRadius);
return squareDistance <= collisionDistance*collisionDistance;
}
我们在每个轴上计算距离,然后得到squareDistance作为平方的总和。collisionDistance是半径的总和,然后我们将其与collisionDistance的平方进行比较。
添加视觉反馈
就像我们对矩形碰撞检测所做的那样,我们将在游戏过程中为精灵添加一些视觉反馈来显示碰撞区域。在这种情况下,我们只需要画一个圆:
canvas.drawCircle(
(int) (mX + mWidth / 2),
(int) (mY + mHeight / 2),
(int) mRadius,
mPaint);
就这样简单,我们就可以看到游戏中的精灵的碰撞区域。

圆形身体非常适合小行星,但对子弹来说却很糟糕
优缺点
这种方法也不是完美的,但它有一些优点:
-
它比矩形更适合大多数精灵
-
它很容易实现
-
它不涉及复杂的计算
但它也有一些问题——最明显的一个问题是,当图像主要是矩形时,碰撞区域太大。我们可以在子弹的例子中清楚地看到这一点。
因此,一些精灵与矩形配合得更好,而另一些则与圆形配合得更好。我们可以为每个ScreenGameObject设置不同的身体类型,并相应地计算碰撞。
混合碰撞检测
我们已经看到单一形状并不适用于所有情况,因此我们将更新我们的游戏,以便我们可以定义每个ScreenGameObject用于碰撞的身体形状。为此,我们将创建一个身体类型的枚举,并在ScreenGameObject中有一个变量来存储该信息。
BodyType枚举如下:
public enum BodyType {
None,
Circular,
Rectangular
}
在精灵的情况下,我们将在构造函数中添加一个参数,指定身体类型。请注意,我们有一个特殊类型称为None。这用于不与其他精灵碰撞的精灵。虽然在我们的游戏中还没有这样的精灵,但其他类型的游戏可以有它们——例如,地牢爬行者中的地板砖。
注意
我们可能希望有一些不会触发碰撞的精灵。这是通过使用BodyType.None来实现的。
我们将为小行星和玩家使用圆形身体,为子弹使用矩形身体。
由于我们有一个可以碰撞的身体列表,如果ScreenGameObject具有BodyType.None,则我们不会将其添加到列表中;因此,我们不需要检查其碰撞。这段代码位于GameEngine的addToLayerNow方法中。
if (object instanceof ScreenGameObject) {
ScreenGameObject sgo = (ScreenGameObject) object;
if (sgo.mBodyType != BodyType.None) {
mCollisionableObjects.add(sgo);
}
}
然后我们必须更新ScreenGameObject的checkCollision方法,以检查两个对象的身体类型,以及我们必须应用哪种方法:
@Override
public boolean checkCollision(ScreenGameObject otherObject) {
if (mBodyType == BodyType.Circular
&& otherObject.mBodyType == BodyType.Circular) {
return checkCircularCollision(otherObject);
}
else if (mBodyType == BodyType.Rectangular
&& otherObject.mBodyType == BodyType.Rectangular) {
return checkRectangularCollision(otherObject);
}
else {
return checkMixedCollision(otherObject);
}
}
注意,在执行到这一点时,我们知道该对象是一个ScreenGameObject,并且它有一个不是None的BodyType。
如果涉及的两个对象都具有矩形身体,我们使用矩形碰撞检测方法。如果两者都具有圆形身体,我们使用圆形碰撞检测方法。在任何其他情况下,一个是圆形,另一个是矩形,因此我们为此情况有一个新方法:checkMixedCollision。
要计算矩形和圆是否碰撞,我们必须检查矩形最接近圆的点是否在圆内(到圆心的距离小于半径)。
如果我们将问题隔离到每个坐标,则矩形最接近圆的点可以很容易地计算出来。

圆和矩形及其最近点的可能相对位置
我们将讨论垂直轴(这更容易区分,因为子弹很高)。水平轴遵循类似的逻辑:
-
我们画一条穿过圆心的水平线。
-
如果那条线与矩形相交,交点就是最近的点。因为我们只考虑垂直轴,所以这个值是圆心的y坐标。
-
如果矩形在直线以下,则圆最近的点的y坐标是矩形的顶部位置(y坐标)。
-
如果矩形在直线以上,则圆最近的点的y坐标是矩形的底部位置(y +
height)。
记住,在计算机图形中,[0,0]点位于屏幕的左上角,y轴向下为正。
现在算法已经清晰,让我们看看代码:
private boolean checkMixedCollision(ScreenGameObject other) {
ScreenGameObject circularSprite;
ScreenGameObject rectangularSprite;
if (mBodyType == BodyType.Rectangular) {
circularSprite = this;
rectangularSprite = other;
}
else {
circularSprite = other;
rectangularSprite = this;
}
double circleCenterX = circularSprite.mX + circularSprite.mWidth /2;
double positionXToCheck = circleCenterX;
if (circleCenterX < rectangularSprite.mX) {
positionXToCheck = rectangularSprite.mX;
}
else if (circleCenterX > rectangularSprite.mX + rectangularSprite.mWidth) {
positionXToCheck = rectangularSprite.mX + rectangularSprite.mWidth;
}
double distanceX = circleCenterX - positionXToCheck;
double circleCenterY = circularSprite.mY + circularSprite.mHeight /2;
double positionYToCheck = circleCenterY;
if (circleCenterY < rectangularSprite.mY) {
positionYToCheck = rectangularSprite.mY;
}
else if (circleCenterY > rectangularSprite.mY + rectangularSprite.mHeight) {
positionYToCheck = rectangularSprite.mY + rectangularSprite.mHeight;
}
double distanceY = circleCenterY - positionYToCheck;
double squareDistance = distanceX*distanceX + distanceY*distanceY;
if (squareDistance <= circularSprite.mRadius*circularSprite.mRadius) {
// They are overlapping
return true;
}
return false;
}
我们首先确定哪个对象具有圆形身体,哪个具有矩形身体,并将它们设置为局部变量。
然后,我们根据之前描述的逻辑计算最近点的 x 和 y 坐标。这种组合给我们提供了九种可能的相对位置,您可以在图中看到。
最后,我们计算该点到圆心的平方距离,并检查它是否小于半径的平方。
添加视觉反馈
添加视觉反馈再次非常简单。我们只需要运行代码来绘制矩形或圆形,具体取决于精灵体的类型。
mPaint.setColor(Color.YELLOW);
if (mBodyType == BodyType.Circular) {
canvas.drawCircle(
(int) (mX + mWidth / 2),
(int) (mY + mHeight / 2),
(int) mRadius,
mPaint);
}
else if (mBodyType == BodyType.Rectangular) {
canvas.drawRect(mBoundingRect, mPaint);
}

其他形状选项
通过这种方式,我们已经涵盖了使用简单形状进行碰撞检测的基本选项。为了对游戏有一个良好的感觉,您需要特别注意每个精灵的形状,并看看哪种形状最适合它,也许需要一些修改,比如更小的半径。为此,我建议您保持视觉反馈开启以检查近似值。
还有一种可能性是使用多边形作为形状或多个矩形。这些是提高碰撞检测的合理简单方法,并且实现起来也不太难。我们将探索这一主题的深入细节留给读者,因为它需要微调。
在宇宙飞船的情况下,最好的形状可能是两个矩形的组合,但由于我们对圆形身体感到满意,我们将保持原样。

宇宙飞船的不同形状选项
优化
检查碰撞是一个二次复杂度的算法。如果需要检查的对象数量增加,它可能很快就会成为瓶颈。
一种选择是记录对象之间比较的先前状态,然后如果自上次检查以来两个对象都没有改变,就返回旧值,或者使用旧状态作为计算新状态的参数。这对于 3D 游戏特别有用,因为碰撞检测需要更复杂的算法。
另一种优化称为空间划分,它利用了对象的邻近性。这种技术基于这样的想法:对象只能与屏幕上靠近它们的其他元素发生碰撞。很明显,对吧?
为了实现空间划分,我们将使用一种称为 QuadTree 的数据结构。
空间划分和 QuadTree
QuadTree 与二叉树类似,除了每个不是叶子的节点有四个子节点而不是两个。
这表示将空间划分为四个部分,每个部分由一个子节点表示。每个子节点可以有另外四个节点,或者只是一个叶子节点。这种设计使得在需要时可以递归地应用划分。

高密度情况下的递归划分示例。
我们只需要分区空间,如果其中的对象数量太大。这意味着一些具有高密度对象的部分可能被分区多次,而其他部分则不是。
当将此概念应用于 3D 游戏时,数据结构称为OctTree,因为当我们有三个维度时,空间被分成八个部分。
填充QuadTree的算法相当直接:
-
我们检查空间中有多少对象,如果数量不是太大(我们使用常量
MAX_OBJECTS_TO_CHECK来表示这一点),我们将像以前一样运行碰撞检测,检查每个对象与另一个对象的碰撞 -
否则,将空间分成四个象限或区域
-
对于每个象限,检查哪些对象在内部(一个对象可能位于多个象限中)
-
对于每个象限,递归应用此相同的算法
每个QuadTree都有一个Rect成员,指定其区域和要检查的对象列表。
在GameEngine内部,我们将用QuadTree的根节点替换检查碰撞的对象列表。当对象被添加到或从GameEngine中删除时,我们将向或从该节点添加或删除对象,并将QuadTree委托以检查碰撞。
在GameEngine的构造函数中,我们将设置根节点的区域:
mQuadTreeRoot.setArea(new Rect(0,0,mWidth, mHeight));
检查GameEngine上的碰撞的代码简化为单行:
private void checkCollisions() {
mQuadTreeRoot.checkCollisions(this);
}
然后,在QuadTree内部,检查碰撞的代码如下:
public void checkCollisions(GameEngine gameEngine) {
int numObjects = mGameObjects.size();
if (numObjects > MAX_OBJECTS_TO_CHECK && sQuadTreePool.size() >= 4) {
splitAndCheck(gameEngine);
}
else {
for (int i = 0; i < numObjects; i++) {
ScreenGameObject objectA = mGameObjects.get(i);
for (int j = i + 1; j < numObjects; j++) {
ScreenGameObject objectB = mGameObjects.get(j);
if (objectA.checkCollision(objectB)) {
objectA.onCollision(gameEngine, objectB);
objectB.onCollision(gameEngine, objectA);
}
}
}
}
}
有两种可能性。如果我们有太多的对象,并且池中有超过四个QuadTree对象,我们将分割空间并在子节点上检查碰撞。如果不是这样,我们就像以前一样运行碰撞检测方法,遍历列表上的所有对象。
splitAndCheck方法看起来是这样的:
private void splitAndCheck(GameEngine gameEngine) {
for (int i=0 ; i<4; i++) {
mChildren[i] = sQuadTreePool.remove(0);
}
for (int i=0 ; i<4; i++) {
mChildren[i].setArea(getArea(i));
mChildren[i].checkObjects(mGameObjects);
mChildren[i].checkCollisions(gameEngine);
// Clear and return to the pool
mChildren[i].mGameObjects.clear();
sQuadTreePool.add(mChildren[i]);
}
}
我们从池中取出四个QuadTree对象,并将它们分配给子数组中的元素。考虑到算法的递归性质,这一点非常重要。
对于每个子节点,我们定义区域,这意味着将当前区域分成四个相等的矩形,检查哪些对象在区域内,然后检查碰撞,这又是递归函数。一旦完成,我们清除子节点并将其返回到池中。
最后一点——getArea只是重用Rect对象来将其设置为四个象限的值。
private Rect getArea(int area) {
int startX = mArea.left;
int startY = mArea.top;
int width = mArea.width();
int height = mArea.height();
switch (area) {
case 0:
mTmpRect.set(startX, startY,
startX + width / 2, startY + height / 2);
break;
case 1:
mTmpRect.set(startX + width / 2, startY,
startX + width, startY + height / 2);
break;
case 2:
mTmpRect.set(startX, startY + height / 2,
startX + width / 2, startY + height);
break;
case 3:
mTmpRect.set(startX + width / 2, startY + height / 2,
startX + width, startY + height);
break;
}
return mTmpRect;
}
为了了解 QuadTrees 的优化水平,让我们想象屏幕上有 100 个对象,并且它们只分布在两个象限中。这就是这种算法提供最佳优化的一种情况。

如果我们不使用QuadTree技术,需要评估的碰撞次数是 50,000 次。遍历列表两次给出 n²/2 次碰撞检查。
当我们将空间划分为四个部分时,第一和第四象限的碰撞检查数量为 1.250(50²/2),其他象限没有。我们需要添加 400 个操作,这些操作是查看每个对象位于哪个部分所必需的(四个象限中有 100 个对象)。
对于我们来说,检查一个对象位于哪个象限的复杂度与检查碰撞相似,但对于 3D 中的碰撞,碰撞检查的成本要高得多。
重复碰撞
在使用空间划分时,我们应该注意一个特殊情况:一些碰撞可能会被检测两次。
当两个对象位于两个象限的交点时,这两个对象都位于两个象限中;当算法检查碰撞时,在每个象限都会被检测到。在最坏的情况下,当对象位于四个象限的交点时,它会被检测四次。
为了解决这个问题,我们将保留一个已检测到的碰撞列表,并且只有当碰撞之前没有被检测到时才处理碰撞。
Collision类非常简单。它只包含两个ScreenGameObject实例、一个equals方法和一些用于处理其池的静态方法:
public class Collision {
private static List<Collision> sCollisionPool = new ArrayList<Collision>();
public static Collision init(ScreenGameObject objectA, ScreenGameObject objectB) {
if (sCollisionPool.isEmpty()) {
return new Collision(objectA, objectB);
}
Collision c = sCollisionPool.remove(0);
c.mObjectA = objectA;
c.mObjectB = objectB;
return c;
}
public static void release(Collision c) {
c.mObjectA = null;
c.mObjectB = null;
sCollisionPool.add(c);
}
public ScreenGameObject mObjectA;
public ScreenGameObject mObjectB;
public Collision(ScreenGameObject objectA, ScreenGameObject objectB) {
mObjectA = objectA;
mObjectB = objectB;
}
public boolean equals (Collision c) {
return (mObjectA == c.mObjectA && mObjectB == c.mObjectB)
|| (mObjectA == c.mObjectB && mObjectB == c.mObjectA);
}
}
我们将检测到的碰撞列表作为参数添加到checkCollisions方法中,并确保在再次检查之前在GameEngine中清理它。GameEngine上的更新版checkCollisions如下所示:
private List<Collision> mDetectedCollisions = new ArrayList<Collision>();
private void checkCollisions() {
// Release the collisions from the previous step
while (!mDetectedCollisions.isEmpty()) {
Collision.release(mDetectedCollisions.remove(0));
}
mQuadTreeRoot.checkCollisions(this, mDetectedCollisions);
}
注意,因为我们使用了一个池,我们不能简单地清除列表;我们需要逐个释放每个元素。
最后,我们验证碰撞之前没有被检测到,在QuadTree上检查它。
public void checkCollisions(GameEngine gameEngine, List<Collision> detectedCollisions) {
int numObjects = mGameObjects.size();
if (numObjects > MAX_OBJECTS_TO_CHECK && sQuadTreePool.size() >= 4) {
// Split this area in 4
splitAndCheck(gameEngine, detectedCollisions);
}
else {
for (int i = 0; i < numObjects; i++) {
ScreenGameObject objectA = mGameObjects.get(i);
for (int j = i + 1; j < numObjects; j++) {
ScreenGameObject objectB = mGameObjects.get(j);
if (objectA.checkCollision(objectB)) {
Collision c = Collision.init(objectA, objectB);
if (!hasBeenDetected(detectedCollisions, c)) {
detectedCollisions.add(c);
objectA.onCollision(gameEngine, objectB);
objectB.onCollision(gameEngine, objectA);
}
}
}
}
}
}
注意,在整个过程中都需要碰撞列表,并且它必须作为计算状态的一部分传递给递归方法。
摘要
现在游戏真的可以玩儿了,我们可以检测子弹是否接触到小行星并摧毁它们;此外,我们还得小心,因为我们的飞船也可能被它们摧毁。
我们学习了如何使用矩形和圆形体进行简单的碰撞检测,并调整每个特定精灵的体。
我们还学习了碰撞检测的一些优化技术,并实现了一个利用空间划分的技术。
我们路线图中的下一步是给游戏增加一些趣味性。你没有注意到小行星缺少一些爆炸效果吗?在下一章中,我们将讨论如何创建用于效果的粒子系统。
第五章:粒子系统
粒子系统是一种在视频游戏中广泛使用的技巧,用于模拟其他方法难以渲染的现象。它们典型的应用包括爆炸、烟花、烟雾、火焰、水等等。它们通常是高度混沌的系统。
它们的基座是使用大量称为粒子的微小精灵。它们的行为是参数化的,因此每个粒子都有不同的一组伪随机值。这使得每次使用粒子系统都不同,同时看起来仍然相似。
大多数游戏引擎都包含实现粒子系统的方法。我们的引擎也将拥有这些功能。
我们将基于开源项目 Leonids 编写我们的代码,Leonids 是一个用于在标准 Android UI 中显示粒子系统的库。我们将对其进行适配以供我们的引擎使用。
在解释了粒子系统的基本原理之后,我们将实现一些一次性粒子系统的示例,以模拟爆炸,以及一些连续发射器来模拟小行星的轨迹和宇宙飞船引擎的烟雾。
一般概念
我们将按照plattysoft.github.io/Leonids/上的 Leonids 粒子系统库的方式制作我们的粒子系统。这是一个我制作的免费软件库,用于在标准 Android UI 中使用粒子系统。

Leonids 库的演示可在 Google Play 上找到
因此,Leonids 实现了自己的更新和绘制线程,以及简化的GameView。我们已经有所有这些,所以我们将适配代码以满足我们的需求。
我们将在本章中使用的概念是:
-
粒子:每个被渲染的精灵
-
粒子系统:负责生成、更新和跟踪粒子的实体
-
初始化器:一个基于某些参数(在激活之前)为粒子设置值的类
-
修饰符:一个基于经过的时间(在激活期间)更新粒子值的类
在我们的游戏中,我们将仅使用少数初始化器和修饰符。如果您需要扩展粒子系统的功能,您可以访问 Leonids 的 GitHub 页面以获取灵感。
让我们深入了解这些概念。
粒子
粒子是Sprites的子类。每个粒子都是一个小图像,具有几个特定的特性:
-
粒子没有碰撞体
-
它们的运动基于线性和角速度
-
它们有有限的生命周期
虽然从理论上讲,您可以在粒子系统中实现粒子可以碰撞的情况——这对于模拟瀑布等效果很有用——但事实是,除非您还有物理引擎,否则这不会正常工作。为了简单起见,我们将定义我们的粒子具有BodyType为None。
粒子确实具有线性和角速度,其onUpdate方法基于这些变量。每个粒子都有这些值,由粒子系统初始化。
粒子通常修改精灵的一些属性。它们是透明度和缩放。我们目前还没有在精灵上使用它们,所以我们将修改Sprite的onDraw方法来实现这一点:
float scaleFactor = (float) (mPixelFactor*mScale);
mMatrix.reset();
mMatrix.postScale(scaleFactor, scaleFactor);
mMatrix.postTranslate((float) mX, (float) mY);
mMatrix.postRotate((float) mRotation,
(float) (mX + mWidth*mScale / 2),
(float) (mY + mHeight*mScale / 2));
mPaint.setAlpha(mAlpha);
canvas.drawBitmap(mBitmap, mMatrix, mPaint);
事实上,我们正在使用缩放作为变换矩阵的一部分来根据像素因子缩放精灵。现在,我们还乘以感知的缩放,这样就完成了。
注意,我们还需要将宽度和高度乘以缩放比例,以便将旋转中心放在缩放精灵的中心。
此外,请注意我们在开始时进行缩放。如果我们要在平移之后进行缩放,那么移动也会被缩放,而这不是我们想要的。再次强调,变换的顺序会影响结果。
虽然scale是变换矩阵的一部分,但alpha是Paint的参数。它的值从 0(透明)到 255(不透明)。重要的是你要记得将其初始化为 255,否则我们所有的精灵都将透明。
注意
透明度值从 0(透明)到 255(不透明)。
关于存活时间,我们只需要检查粒子在onUpdate期间活跃的总时间,以从GameEngine中移除它,并在它过期时将其返回到粒子系统。
Particle是一个扩展了Sprite的类,它有一个激活的方法。它根据线性和角速度的值运行其更新:
public class Particle extends Sprite {
private long mTimeToLive;
private long mTotalMillis;
public double mSpeedX;
public double mSpeedY;
public double mRotationSpeed;
protected Particle(
ParticleSystem particleSystem,
GameEngine gameEngine,
int drawableRes) {
super(gameEngine, drawableRes, BodyType.None);
mParent = particleSystem;
}
@Override
public void removeFromGameEngine(GameEngine gameEngine) {
super.removeFromGameEngine(gameEngine);
mParent.returnToPool(this);
}
public void activate(
GameEngine gameEngine,
long timeToLive,
double x,
double y,
ArrayList<ParticleModifier> modifiers
int layer) {
mTimeToLive = timeToLive;
mX = x-mWidth/2;
mY = y-mHeight/2;
addToGameEngine(gameEngine, layer);
mModifiers = modifiers;
mTotalMillis = 0;
}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mTotalMillis += elapsedMillis;
if (mTotalMillis > mTimeToLive) {
removeFromGameEngine(gameEngine);
}
else {
mX += mSpeedX*elapsedMillis;
mY += mSpeedY*elapsedMillis;
mRotation += mRotationSpeed*elapsedMillis/1000d;
for (int i=0; i<mModifiers.size(); i++) {
mModifiers.get(i).apply(this, mTotalMillis);
}
}
}
}
在构造函数中,我们向父ParticleSystem传递一个引用,当我们从游戏引擎中移除粒子时,我们将使用它将粒子返回。我们还向基类构造函数传递BodyType.None,这样粒子就不会参与碰撞检测。
当我们激活粒子时,我们执行几个操作:
-
设置存活时间
-
在x和y轴上初始化位置,并调整图像的宽度和高度
-
将其添加到
GameEngine的特定层 -
存储在
onUpdate期间要使用的修饰符列表的引用 -
将总毫秒数设置为 0,以表示该粒子刚刚被激活
值得注意的是,我们保留了对修饰符列表的引用。这个列表在系统中的所有粒子之间共享,并由ParticleSystem类管理。
在onUpdate期间,我们将经过的毫秒数添加到总时间,并检查Particle是否已经过了其存活时间。如果是这样,我们就从GameEngine中移除它。否则,我们根据速度(线性和角速度)更新位置和旋转,然后应用所有修饰符。
你可能已经意识到我们正在使用线性和角速度值,但到目前为止,我们还没有设置它们。这是通过ParticleSystem的初始化器完成的。这就是为什么这些变量是公开的。
让我们看看处理粒子的系统。
ParticleSystem
ParticleSystem本身是控制粒子发射的部分。它在某些方面类似于GameController类。ParticleSystem负责初始化、激活和生成粒子。
ParticleSystem的一个职责是管理Particle对象的池。鉴于粒子系统有大量过期并被重用的粒子,这是使用对象池最重要的案例之一。
注意
ParticleSystem有一个Particles的池。
池在ParticleSystem的构造函数中被填充:
public ParticleSystem(
GameEngine gameEngine,
int maxParticles,
int drawableRedId,
long timeToLive) {
mRandom = new Random();
mModifiers = new ArrayList<ParticleModifier>();
mInitializers = new ArrayList<ParticleInitializer>();
mTimeToLive = timeToLive;
mPixelFactor = gameEngine.mPixelFactor;
for (int i=0; i<maxParticles; i++) {
mParticlePool.add(new Particle(this, gameEngine, drawableRedId));
}
}
构造函数接收将同时使用的最大粒子数。这是我们用于池大小的值。
关于我们的粒子系统,另一个重要点是所有粒子具有相同的图像。这个图像在构造时传递给ParticleSystem。如果你想要不同的图像,你应该为每个粒子使用一个ParticleSystem,就像我们在一个示例中所做的那样。
注意
ParticleSystem中的所有粒子具有相同的图像。
在构造函数中,我们还创建了ParticleInitializer和ParticleModifiers的列表。初始化器将在激活每个粒子时使用。修饰器列表将被传递给每个粒子,它将在Particle的onUpdate方法中使用,就像我们已经看到的那样。
这就是在粒子激活期间初始化器是如何被使用的:
private void activateParticle(GameEngine gameEngine) {
Particle p = mParticlePool.remove(0);
for (int i=0; i<mInitializers.size(); i++) {
mInitializers.get(i).initParticle(p, mRandom);
}
p.activate(gameEngine, mTimeToLive, mX, mY, mModifiers, mLayer);
mActivatedParticles++;
}
ParticleSystem中的一个重要设计概念是它通过实用方法抽象了初始化器和修饰器,并且每个实用方法都返回ParticleSystem对象,这样初始化就可以链式调用,使代码更短,更容易阅读。
让我们详细看看ParticleInitializers和ParticleModifiers。
初始化器
初始化器被ParticleSystem用来根据参数为粒子设置值。为此,我们将ParticleInitializer定义为一个非常简单的接口:
public interface ParticleInitializer {
void initParticle(Particle p, Random r);
}
例如,我们将查看ParticleSytem的实用方法来设置粒子的初始旋转:
public ParticleSystem setInitialRotationRange (int minAngle, int maxAngle) {
mInitializers.add(new RotationInitiazer(minAngle, maxAngle));
return this;
}
当我们为Particle设置初始旋转范围时,我们将RotationInitializer类型的初始化器添加到ParticleSystem中。然后我们返回这个对象,这样方法就可以链式调用了。
我们可以使用的其他配置粒子初始化的方法包括:
-
setRotationSpeedRange(double minRotationSpeed, double maxRotationSpeed) -
setSpeedRange(double speedMin, double speedMax) -
setSpeedModuleAndAngleRange(double speedMin, double speedMax, int minAngle, int maxAngle) -
setRotationSpeedRange(double minRotationSpeed, double maxRotationSpeed)
我们不会包含我们将要使用的所有初始化器的代码,因为它们都遵循相同的模式:
-
接收一组参数并定义范围
-
从范围中生成随机值
-
将值设置到
Particle的适当变量中(这可能涉及多个字段)
注意
初始化器从范围内获取随机值并将其设置到Particle的变量中。
作为ParticleInitializer实现的一个示例,让我们看看RotationInitializer的代码:
public class RotationInitiazer implements ParticleInitializer {
private int mMinAngle;
private int mMaxAngle;
public RotationInitiazer(int minAngle, int maxAngle) {
mMinAngle = minAngle;
mMaxAngle = maxAngle;
}
@Override
public void initParticle(Particle p, Random r) {
int value = r.nextInt(mMaxAngle-mMinAngle)+mMinAngle;
p.mRotation = value;
}
}
足够直接;初始化器将角度的最小值和最大值存储在成员变量中(mMinAngle,mMaxAngle)。当它初始化一个粒子时,它会在范围内生成一个随机值并将其设置为粒子的旋转变量。
所有初始化器都以相同的方式工作;有些比其他的一些更复杂。例如,SpeedModuleAndRangeInitializer 使用三角学将速度从角度和模块转换为坐标:
public class SpeedModuleAndRangeInitializer implements ParticleInitializer {
private double mSpeedMin;
private double mSpeedMax;
private int mMinAngle;
private int mMaxAngle;
public SpeedModuleAndRangeInitializer(
double speedMin, double speedMax,
int minAngle, int maxAngle) {
mSpeedMin = speedMin;
mSpeedMax = speedMax;
mMinAngle = minAngle;
mMaxAngle = maxAngle;
}
@Override
public void initParticle(Particle p, Random r) {
double speed = r.nextDouble()*(mSpeedMax-mSpeedMin) + mSpeedMin;
int angle;
if (mMaxAngle == mMinAngle) {
angle = mMinAngle;
}
else {
angle = r.nextInt(mMaxAngle - mMinAngle) + mMinAngle;
}
double angleInRads = angle*Math.PI/180d;
p.mSpeedX = speed * Math.cos(angleInRads)/1000d;
p.mSpeedY = speed * Math.sin(angleInRads)/1000d;
}
}
在这个例子中,我们有两个范围,一个用于速度模块,另一个用于角度。当我们初始化一个粒子时,我们从它们各自的范围内获取每个值,但随后我们需要使用 sin 和 cos 将它们转换为可以用于 Particle 的值,即 mSpeedX 和 mSpeedY。
在这种情况下,我们正在初始化 Particle 的两个变量。再次注意,Particle 类的这些字段是公开的,因此我们可以轻松地从此类中修改它们。
修饰符
修饰符 是一个类似于初始化器的概念,但它们是在粒子激活时应用的。
至于初始化器,我们定义了一个接口,允许它们与 Particle 类交互:
public interface ParticleModifier {
void apply(Particle particle, long milliseconds);
}
对于我们来说,修饰符将为所有粒子具有相同的参数,而初始化器将为每个粒子生成一个值。这就是为什么线性和角速度不是由修饰符处理的,而是每个粒子的变量,它们可以——并且很可能会——具有不同的值。
ParticleModifier 实例也通过 ParticleSystem 的实用方法进行管理,例如设置淡出:
public ParticleSystem setFadeOut(long millisecondsBeforeEnd) {
mModifiers.add(
new AlphaModifier(255, 0, mTimeToLive-millisecondsBeforeEnd, mTimeToLive));
return this;
}
此方法创建一个 AlphaModifier 来设置淡出(从 alpha 值 255 到 0),基于粒子在系统中的存活时间。
通常,我们可以创建自己的 ParticleModifier 实例,并通过调用 addModifier 将它们添加到 ParticleSystem 中:
public ParticleSystem addModifier(ParticleModifier modifier) {
mModifiers.add(modifier);
return this;
}
我们将只使用两个修饰符:AlphaModifier 用于淡出和 ScaleModifier。
让我们来看看 AlphaModifier 的代码:
public class AlphaModifier implements ParticleModifier {
private int mInitialValue;
private int mFinalValue;
private long mStartTime;
private long mEndTime;
private float mDuration;
private float mValueIncrement;
public AlphaModifier(int initialValue, int finalValue, long startMilis, long endMilis) {
mInitialValue = initialValue;
mFinalValue = finalValue;
mStartTime = startMilis;
mEndTime = endMilis;
mDuration = mEndTime - mStartTime;
mValueIncrement = mFinalValue-mInitialValue;
}
@Override
public void apply(Particle particle, long milliseconds) {
if (milliseconds < mStartTime) {
particle.mAlpha = mInitialValue;
}
else if (milliseconds > mEndTime) {
particle.mAlpha = mFinalValue;
}
else {
double percentageValue = (miliseconds- mStartTime)*1d/mDuration;
int newAlphaValue = (int) (mInitialValue + mValueIncrement*percentageValue);
particle.mAlpha = newAlphaValue;
}
}
}
注意,我们有一个初始值和一个最终值;如果时间小于起始时间,我们将 alpha 设置为初始值;如果时间大于结束时间,我们将 alpha 设置为最终值。
这允许我们使用相同的类同时进行淡入和淡出。
对于修饰符,重要的是强调 apply 方法接收已花费的总毫秒数。这是必需的,因为修饰符对 Particle 一无所知。它没有任何状态,因此所有信息都必须作为参数传递给 apply 方法。
注意
修饰符对所有粒子都是相同的,并且不保存任何状态。
修饰符有一个开始时间和结束时间。当时间在区间之外时,我们将它设置为初始或最终值。当时间在这两个边界之间时,它返回初始值和最终值之间增量的一次线性插值。
要修改此代码以使用其他类型的插值器非常简单。如果你对此好奇,可以查看 GitHub 上 Leonids 的代码,它支持插值器。
ScaleModifier 几乎与这个相同,只是值被设置为 mScale 而不是 mAlpha。
组合 GameObjects 和 GameEngine
到目前为止,我们一直在使用的 GameObjects 是一个单一实体。从现在开始,我们还将拥有包含其他 GameObjects 的 GameObjects。特别是,每个对象使用的 ParticleSystems 将由它们拥有。
这意味着必须更新将 GameObjects 添加到 GameEngine 的添加和删除操作。对象将有两个方法来自身添加和删除到 GameEngine,并且当我们使用组合对象时可以覆盖此方法来处理它们。
我们的变化将是不再使用 GameEngine 的 addGameObject 和 removeGameObject 方法,而是开始使用新的等效方法,如 addToGameEngine 和 removeFromGameEngine 在 GameObject 上。
这意味着我们应该检查项目的代码,并替换所有出现的地方。这在 GameController 的情况下尤为重要,其中小行星被生成,以及 GameFragment 内 GameEngine 的初始化,当我们添加 Player 对象时:
mGameEngine = new GameEngine(getActivity(), gameView, 4);
mGameEngine.setInputController(
new CompositeInputController(getView(), getYassActivity()));
new ParallaxBackground(mGameEngine, 20, R.drawable.seamless_space_0)
.addToGameEngine(mGameEngine, 0);
new GameController(mGameEngine).addToGameEngine(mGameEngine, 2);
new Player(mGameEngine).addToGameEngine(mGameEngine, 3);
new FPSCounter(mGameEngine).addToGameEngine(mGameEngine, 2);
即使使用旧方法在非组合对象上会产生相同的结果,但最好以相同的方式将所有 GameObjects 添加到 GameEngine 中,以保持代码的一致性。
addToGameEngine 和 removeFromGameEngine 的默认实现对于一个非组合 GameObject 来说实际上非常简单:
public void addToGameEngine (GameEngine gameEngine, int layer) {
gameEngine.addGameObject(this, layer);
}
public void removeFromGameEngine (GameEngine gameEngine) {
gameEngine.removeGameObject(this);
}
当我们在组合项上覆盖此方法时,我们必须记得调用超类方法来添加和删除对象。
制作好的粒子系统
作为旁注,粒子系统非常强大,但调整和设计它们并不简单。
粒子系统的实现细节非常简单,正如我们所看到的。但制作出看起来逼真且好的粒子系统则是另一回事。
调整它们的关键是玩弄参数,再次再次再次查看它们的外观,直到我们对结果满意为止。
为了尝试阐明调整粒子系统的神秘艺术,我们将做一些例子。你可以自己玩弄粒子和参数,看看小变化能有多大影响。
使用粒子系统的两种方式:一次性发射器和连续发射器。让我们举一些两种方法的例子。
一击
在使用一次性发射时,我们将 ParticleSystem 中的所有粒子一次性发射出去。
在这种情况下,我们不需要将 ParticleSystem 添加到或从 GameEngine 中删除,因为 ParticleSystem 的 onUpdate 方法不需要被调用(它仅用于发射新粒子)。
在使用单次发射时,用我们计划用于射击的相同数量的粒子初始化粒子池是合乎逻辑的。
ParticleSystem类的oneShot方法如下:
public void oneShot(GameEngine gameEngine, double x, double y,
int numParticles) {
mX = x;
mY = y;
mIsEmiting = false;
for (int i=0; !mParticlePool.isEmpty() && i<numParticles; i++) {
activateParticle(gameEngine);
}
}
我们设置了发射的x和y坐标,然后设置isEmitting为false。在更新isEmitting时,只有在将ParticleSystem添加到GameEngine时才是必要的,但我们只是为了安全起见这样做。在这种情况下,将isEmitting设置为false将使onUpdate简单地什么也不做。
一旦设置好参数,我们就从池中获取粒子并激活它们。记住,我们在解释ParticleSystem类时已经看到了activate方法。作为激活的一部分,粒子被添加到GameEngine中。
我们将使用这种类型的粒子系统来处理Asteroids和Player的爆炸。
小行星爆炸
对于小行星的爆炸,我们将使用一个有三个小岩石碎片的粒子,这样它看起来就像小行星已经分裂成多个部分。为了强化这种效果,我们将使粒子以任何方向旋转和移动,但不要离得太远。
我们在创建和配置ParticleSystem时将其作为Asteroid创建的一部分。这将确保每个小行星都有自己的独立粒子池:
public Asteroid(GameController gameController, GameEngine gameEngine) {
super(gameEngine, R.drawable.a10000, BodyType.Circular);
mSpeed = 200d*mPixelFactor/1000d;
mController = gameController;
mExplisionParticleSystem = new ParticleSystem(gameEngine, EXPLOSION_PARTICLES, R.drawable.particle_asteroid_1, 700)
.setSpeedRange(15, 40)
.setFadeOut(300)
.setInitialRotationRange(0, 360)
.setRotationSpeedRange(-180, 180);
}
public void explode(GameEngine gameEngine) {
mExplisionParticleSystem.oneShot(gameEngine, mX + mWidth / 2, mY + mHeight / 2, EXPLOSION_PARTICLES);
}
粒子系统的配置执行以下操作:
-
粒子将存活 700 毫秒。
-
它们将以每秒 15 到 40 个单位的速度向所有方向发射。
-
在最后 300 毫秒内,它们将有一个 alpha 修改器,以平滑地淡出。
-
粒子将具有任何初始旋转。请注意,粒子不是对称的;这一点非常重要,可以防止粒子看起来静止。
-
最后,每个粒子都将有一个每秒-180 到 180 度的角速度。
-
EXPLOSION_PARTICLES是Asteroid的一个常量。我们将其设置为 15。
explode方法确实会从小行星的中心触发oneShot,这将使所有粒子开始运动。
最后的连接点是触发explode方法。这发生在子弹与小行星碰撞或玩家与小行星碰撞时。这是在Bullet类内部如何实现的:
@Override
public void onCollision(GameEngine gameEngine, ScreenGameObject otherObject) {
if (otherObject instanceof Asteroid) {
removeFromGameEngine(gameEngine);
Asteroid a = (Asteroid) otherObject;
a.explode(gameEngine);
a.removeFromGameEngine(gameEngine);
}
}
就这样。很简单,对吧?由于ParticleSystem使用的是oneShot,因此不需要添加或从GameEngine中移除它。

爆炸粒子和粒子系统的细节。
宇宙飞船爆炸
对于Player的爆炸,我们将采用不同的方法。我们希望它看起来更加戏剧化。我们将使用比小行星更大的速度值,以产生更加强烈且范围更广的爆炸印象。我们还将使用两种不同颜色而不是一个带有多个碎片的一个粒子。
由于每个ParticleSystem都设计用来只持有一种类型的Particle(一个图像),因此Player类中将有两个ParticleSystem实例,它们将在构造函数中初始化:
mExplisionParticleSystem1 = new ParticleSystem(gameEngine,
EXPLOSION_PARTICLES, R.drawable.particle_ship_explosion_1, 600)
.setSpeedRange(30, 150)
.setInitialRotationRange(0,360)
.setFadeOut(200);
mExplisionParticleSystem2 = new ParticleSystem(gameEngine,
EXPLOSION_PARTICLES, R.drawable.particle_ship_explosion_2, 600)
.setSpeedRange(30, 150)
.setInitialRotationRange(0,360)
.setFadeOut(200);
在这种情况下,每个粒子都非常小且对称,所以我们不需要旋转速度。由于它们比以前快得多,所以存活时间也短一些,淡出也更尖锐。
在这种情况下,EXPLOSION_PARTICLES设置为 20,所以总共有 40 个粒子。
当Player对象爆炸(作为onCollision的一部分)时,我们在两个ParticleSystem实例上触发oneShot:
mExplisionParticleSystem1.oneShot(gameEngine, mX + mWidth / 2, mY+mWidth/2, EXPLOSION_PARTICLES);
mExplisionParticleSystem2.oneShot(gameEngine, mX+mWidth/2, mY+mWidth/2, EXPLOSION_PARTICLES);
如您所见,如果您现在运行它,爆炸的感觉与用于小行星的完全不同:

发射器
使用粒子系统的另一种方法是将其配置为发射器。这意味着在系统活跃时,它每秒发射的粒子数有一个比率。
计算正确的池大小相当简单。它只是一个基于粒子存活时间和你想要的每秒粒子数量的公式。例如,使用每秒 20 个粒子,500 毫秒的存活时间,你只需要一个包含 10 个粒子的池,因为它们在死亡时会被返回到池中。
重要的是要记住,作为发射器的ParticleSystem需要被添加到GameEngine中,并从其中移除,因为需要onUpdate方法来检查更多粒子的生成。ParticleSystem的onUpdate方法如下所示:
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
if (!mIsEmiting){
return;
}
mTotalMillis += elapsedMillis;
// We have to make sure that we have to keep emiting
while ( !mParticlePool.isEmpty() && // We have particles in the pool
mActivatedParticles < mParticlesPerMillisecond*mTotalMillis) {
// and we are under the number of particles that should be launched
// Activate a new particle
activateParticle(gameEngine);
}
}
代码相当简单。首先我们检查系统是否在发射。如果系统没有在发射,我们不需要更新任何东西,所以我们可以简单地返回。
如果它在发射,我们检查是否需要激活一个粒子。为此,我们比较我们已激活的粒子数量与应该激活的粒子数量(particlesPerMillisecondtotalMilliseconds*)。我们还需要确保可以从池中获取一个粒子(它不是空的)。
一旦我们知道我们需要生成一个粒子并且有一些粒子可用,我们就进行激活。activateParticle方法的最后一行增加了激活粒子的计数器。
注意,激活的粒子数量是发射器激活的总粒子数量,而不是当前活跃粒子的数量。
ParticleSystem的发射方法如下所示:
public void emit (int particlesPerSecond) {
mActivatedParticles = 0;
mTotalMillis = 0;
mParticlesPerMillisecond = particlesPerSecond/1000d;
mIsEmiting = true;
}
足够简单;它重置了激活粒子的数量和时间计数器。它还计算每毫秒的粒子率(参数以每秒粒子数传递)并将mIsEmitting设置为true,这样onUpdate方法应该可以工作。
最后,要停止发射,我们只需将mIsEmitting设置为false:
public void stopEmiting() {
mIsEmiting = false;
}
让我们看看一些发射器的例子。
小行星尾迹
如果陨石留下尾迹,它们看起来会酷得多。在Asteroid类的构造函数中,我们将在与小行星爆炸相同的地点创建尾迹的ParticleSystem:
mTrailParticleSystem = new ParticleSystem(gameEngine, 50, R.drawable.particle_dust, 600)
.addModifier(new ScaleModifier(1, 2, 200, 600))
.setFadeOut(200);
对于这个尾迹,我们使用一个粒子,即两个小块。为了更好地体现尾迹的感觉,我们添加了一个ScaleModifier以及渐隐效果。
我们希望初始化依赖于陨石的价值。为此,我们将在调用陨石的init方法期间设置所有初始化器:
public void init(GameEngine gameEngine) {
[…] // Standard initialization
mTrailParticleSystem.clearInitializers()
.setInitialRotationRange(0,360)
.setRotationSpeed(mRotationSpeed * 1000);
.setSpeedByComponentsRange(
-mSpeedY * 100, mSpeedY * 100,
mSpeedX * 100, mSpeedX * 100);
}
首先,我们清理所有初始化器,然后将其设置为任何旋转。旋转速度将与小行星的旋转速度相同。然后,线性速度将与小行星组件成比例并交换。这将使尾迹垂直于小行星移动。
ParticleSystem的发射点需要在每次onUpdate运行时更新,以确保从正确的位置发射:
mTrailParticleSystem.setPosition(mX + mWidth / 2, mY + mHeight / 2);
由于它是一个发射器,它需要与小行星一起添加到和从GameEngine中移除。为此,我们已定义了addToGameEngine和removeFromGameEngine方法。我们现在将覆盖它们:
@Override
public void addToGameEngine (GameEngine gameEngine, int layer) {
super.addToGameEngine(gameEngine, layer);
mTrailParticleSystem.addToGameEngine(gameEngine, mLayer-1);
mTrailParticleSystem.emit(15);
}
@Override
public void removeFromGameEngine(GameEngine gameEngine) {
super.removeFromGameEngine(gameEngine);
mTrailParticleSystem.stopEmiting();
mTrailParticleSystem.removeFromGameEngine(gameEngine);
}
注意,我们将它添加到GameEngine中,位于小行星之下的一层,因此粒子系统总是位于它们之后,而不是相反。
通过这种方式,我们已经将所有部件就位,现在我们可以看到我们的陨石看起来有多漂亮:

宇宙飞船的引擎
对于最后一个效果,我们将在宇宙飞船上添加一些烟雾。因为它是一个发射器,它需要通过添加和移除它与GameEngine中的玩家对象一起遵守GameObject生命周期,就像我们为小行星尾迹所做的那样:
@Override
public void removeFromGameEngine(GameEngine gameEngine) {
super.removeFromGameEngine(gameEngine);
mEngineFireParticle.removeFromGameEngine(gameEngine);
}
@Override
public void addToGameEngine(GameEngine gameEngine, int layer) {
super.addToGameEngine(gameEngine, layer);
mEngineFireParticle.addToGameEngine(gameEngine, mLayer - 1);
mEngineFireParticle.emit(12);
}
在onUpdate方法内部,我们必须同步玩家的位置与ParticleSystem的发射器,就像我们处理小行星那样:
mEngineFireParticle.setPosition(mX+mWidth/2, mY+mHeight);
注意,我们将发射器设置在宇宙飞船的底部而不是中心。这使得烟雾出现在正确的位置。
最后,我们将在Player对象的构造函数中创建ParticleSystem,与我们已经为爆炸创建的其他两个对象在同一位置:
mEngineFireParticle = new ParticleSystem(gameEngine, 50, R.drawable.particle_smoke, 600)
.setInitialRotationRange(0, 360)
.setRotationSpeedRange(-30, 30)
.setSpeedModuleAndAngleRange(50, 80, 60, 120)
.setFadeOut(400);
这些配置方法我们现在都很熟悉。
我们有整个初始旋转范围[0-360],旋转速度从每秒-30 度到 30 度。我们还为其他系统设置了更长的渐隐效果,以便使其平滑溶解。
我们还使用setSpeedModuleAndAngleRange,它根据角度和模块设置速度。我们希望烟雾在[60-120]度(即 60 度弧线延伸到宇宙飞船底部)的范围内排出,并且速度变化不大。
您现在可以运行它,看看效果如何:

当查看静态图像时,你可以看到每个粒子,但当他们运动时,感觉真的很好。
可选地,你可以在onUpdate期间有用户输入时启动这个粒子系统,如果没有则停止它,这将使游戏看起来更加动态,因为烟雾只有在飞船移动时才会出现。
概述
我们已经学习了粒子系统的工作原理以及如何将其集成到我们的游戏中。我们将ParticleSystem和粒子本身集成到GameEngine中。我们还学习了初始化器和修改器以及如何创建新的来扩展系统,如果我们想的话。
然后,我们看到了如何组合一个包含其他GameObject实例的GameObject。特别是,粒子系统通常属于另一个GameObject。为此,ParticleSystem连接到GameObject的生命周期。我们更新了GameEngine,使其更容易添加组合的GameObjects。
最后,我们检查了两个一次性声音效果示例和两个发射器示例,涵盖了各种不同的参数。
总的来说,这四个ParticleSystem实例使游戏感觉更加生动。让我们继续到最后一步,让游戏感觉生动:声音效果。
第六章. 声音效果和音乐
没有声音的游戏感觉不完整。在本章中,我们将探讨在 Android 中播放声音效果和音乐的多种选项,我们将构建一个SoundManager类来处理它们,并看看它是如何与GameEngine交互的。
对于声音效果,我们将使用SoundPool,它专门设计用于通过预先加载到内存中来播放小声音。为了触发声音效果,我们将介绍GameEvent的概念,并学习它们是如何通过GameEngine传播的。
在背景音乐的情况下,我们将直接使用MediaPlayer,因为长曲目与SoundPool配合不佳,而MediaPlayer是播放所有类型媒体文件的通用解决方案。
最后,我们将添加控件以在主页上启用和禁用声音效果和音乐,并让SoundManager负责它们。
SoundManager
为了管理声音和音乐,我们将有一个名为SoundManager的类。这个类将仅在Application代码中实例化一次,并且将在YassActivity的onCreate方法中完成。这样做有几个原因:
-
声音效果确实需要一点时间来加载,因此最好提前加载它们
-
我们可能想在菜单中使用声音和音乐
-
加载声音和音乐需要内存;重复加载是没有意义的
让我们看看我们需要对YassActivity进行的修改:
private SoundManager mSoundManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
[...]
setVolumeControlStream(AudioManager.STREAM_MUSIC);
mSoundManager = new SoundManager(getApplicationContext());
}
public SoundManager getSoundManager() {
return mSoundManager;
}
我们有一个包含SoundManager的字段,我们将在onCreate方法中初始化它。我们还提供了它的 getter 方法。
另有一行需要特别提及:
setVolumeControlStream(AudioManager.STREAM_MUSIC);
这段代码将应用程序的音量控制流设置为音乐。它告诉操作系统,当我们的应用程序正在运行时按下物理音量键,我们希望它修改音乐音量而不是默认音量。在 Android 上有七种流类型,每种声音类型一种,从系统音量和铃声音量到闹钟和通知。
注意
如果在游戏运行时按下音量键,我们应该在 Activity 中调用 setVolumeControlStream(AudioManager.STREAM_MUSIC) 来使音量键控制音乐音量。
由于我们将播放声音,这个小的调整非常重要,以便用户可以在游戏过程中控制声音的音量。
SoundManager 将使用 Android SDK 中的不同类来处理声音和音乐。它还将提供不同的方法来访问它们。音效和音乐是 SoundManager 类中的独立功能,因此我们将分别查看每个功能。
音效 FX
音效将用于游戏中的事件,如爆炸、开火等,也可以用于其他情况,如菜单点击和对话框出现。在我们的游戏中,我们将为小行星和宇宙飞船的爆炸以及激光发射添加音效。
我们将开始讨论一些获取游戏音效的方法。如果我们没有音文件,我们无法工作,对吧?
一旦我们放置了声音,我们将更新 GameEngine 以提供一种在游戏中发出信号的方式,并且我们将让 SoundManager 知道当这些事件中的任何一个发生时。
最后,我们将解释 SoundPool 的工作原理以及如何将其包含在我们的 SoundManager 中,将游戏事件与音效关联起来。
亲身体验——让我们获取一些音文件。
如何创建音效
对于独立开发者来说,有几个地方可以获取音效。之前提到的 OpenGameArt 不仅提供图形,还有声音。寻找游戏音效的最佳地方之一是网站 www.freesound.org(以前称为“免费声音项目”)。

用它自己的话来说:Freesound 致力于创建一个巨大的协作数据库,包含在 Creative Commons 许可下发布的音频片段、样本、录音、哔哔声等,允许其重用。Freesound 提供了新的和有趣的方式来访问这些样本,允许用户使用关键词、一种“听起来像”的浏览方式以及更多方式来浏览声音。
注意
Freesound.org 是一个在 Creative Commons 许可下的优秀声音数据库。
浏览到所需的声音可能需要一些时间,但这确实是一个非常有用的资源。我们在制作 The Pill Tree 的 Chalk Ball 和 SpaceCat 时使用了它。
对于需要复古风格简单声音的游戏,还有一个有趣的项目:Bfxr (www.bfxr.net/)
注意
Bfxr 是一个简单便捷的工具,用于创建复古风格的音效。
Bfxr 允许你对声音波进行很多操作,但你不需要是专家,因为它还有一些按钮,可以根据某些参数生成新的随机声音。这些按钮用于射击、拾取、加成、击中等等。Bfxr 受到了 as3sfxr 的启发,它也更简单。

bfxr 网页版的 UI
你对使用 bfxr 制作的所有声音拥有完全的权利,因此你可以自由地用于任何目的,无论是商业的还是其他目的。
对于 YASS,我们使用了 bfxr 生成了一些爆炸声和激光声,然后挑选了我们更喜欢的一些。你可以自己制作,这只需要几分钟。
现在我们有了声音,我们需要一个地方将它们放入我们的项目中。我们可以将声音作为原始资源(在res/raw下)存储,但将它们放在assets文件夹中会更方便,在那里我们可以有一个分层结构,并且对文件命名没有限制。
注意
我们将声音文件存储在assets文件夹下。
我们必须在项目的src/main目录下创建assets文件夹。在assets文件夹内,我们将创建一个名为sfx的文件夹来存放所有的声音文件。
游戏事件
正如我们提到的,我们将把声音效果链接到GameEvents,所以首先我们需要我们的GameEngine支持这样的GameEvents。为此,我们创建了一个枚举,其中包含了我们感兴趣的GameEvents:
public enum GameEvent {
AsteroidHit,
SpaceshipHit,
LaserFired
}
GameEvents将以类似于BroadcastReceiver或EventBus的方式在GameEngine中传播。我们在GameEngine中创建了一个新的方法onGameEvent。当发生GameEvent时,将调用此方法。
public void onGameEvent (GameEvent gameEvent) {
mSoundManager.playSoundForGameEvent(gameEvent);
}
当GameEvent到达时,我们将它传达给SoundManager。只需在这个方法中添加一个遍历GameObjects的循环,就可以轻松地让所有GameObjects订阅GameEvents。
剩下的唯一事情就是在事件发生时触发它们。让我们看看从Player对象触发的那些事件:
private void checkFiring(long elapsedMillis, GameEngine gameEngine) {
if (gameEngine.mInputController.mIsFiring
&& mTimeSinceLastFire > TIME_BETWEEN_BULLETS) {
[...]
gameEngine.onGameEvent(GameEvent.LaserFired);
}
else {
mTimeSinceLastFire += elapsedMillis;
}
}
@Override
public void onCollision(GameEngine gameEngine, ScreenGameObject otherObject) {
if (otherObject instanceof Asteroid) {
[...]
gameEngine.onGameEvent(GameEvent.SpaceshipHit);
}
}
因为GameEvents发生在onUpdate中,所以我们总是手头有一个GameEngine的引用,发送事件就像在正确的位置添加一个方法调用一样简单。现在Player对象将LaserFired和SpaceshipHit事件的通信传递给GameEngine。
子弹也会触发一个GameEvent。
@Override
public void onCollision(GameEngine gameEngine, ScreenGameObject otherObject) {
if (otherObject instanceof Asteroid) {
[...]
gameEngine.onGameEvent(GameEvent.AsteroidHit);
}
}
现在事件被触发并且达到SoundManager时,我们必须在事件到达时实际播放特定的声音。为此,我们需要构建和配置SoundPool。
使用 SoundPool
SoundPool是一个用于以低延迟播放多个短声音文件的实用工具。它预先加载文件,并允许我们同时播放多个文件。SoundManager的初始构造函数将看起来像这样:
public SoundManager(Context context) {
mContext = context;
loadSounds();
}
我们将有一个加载声音的方法。目前这仅使用一次,但稍后,当我们添加一个启用或禁用它们的设置时,声音可以被多次加载和卸载。
由于 Lollipop 上默认构造函数已被弃用,而构建它的新方法相当冗长且不向后兼容;我们需要根据操作系统版本分支代码。
private void createSoundPool() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
mSoundPool = new SoundPool(MAX_STREAMS, AudioManager.STREAM_MUSIC, 0);
}
else {
AudioAttributes audioAttributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build();
mSoundPool = new SoundPool.Builder()
.setAudioAttributes(audioAttributes)
.setMaxStreams(MAX_STREAMS)
.build();
}
}
构建这两种方式的SoundPool接收相同类型的参数,但旧的那个只有一个构造函数,而新的一个使用构建器和AudioAttributes。
现在已弃用的构造函数的参数是:
-
最大流数:同时播放的最大流数。
-
流类型:在
AudioManager中描述的音频流类型。游戏应使用STREAM_MUSIC。 -
Src 质量:采样率转换器的质量。它没有效果,所以我们只使用 0。
构建新方法的SoundPool用AudioAttributes的使用和内容类型替换了流类型,但您也可以使用旧系统类型代替这些参数,如果您愿意,可以将它设置为AudioManager.STREAM_MUSIC。
最大流数的参数与之前完全相同。SoundPool跟踪活动流的数量。如果超过最大流数,SoundPool将自动停止之前播放的一个,首先基于优先级,然后在同一优先级内按年龄排序。
注意
SoundPool旨在播放小声音文件,具有低延迟播放。
使用SoundPool相当简单。当我们把一个文件加载到SoundPool中时,它会返回一个声音 ID,我们需要使用这个 ID 来稍后播放声音。
为了存储这些声音 ID,我们将在SoundManager内部有一个映射,它以GameEvent作为键,以Integer作为值的类型。
private HashMap<GameEvent, Integer> mSoundsMap;
我们现在创建一个方法,将文件加载到SoundPool中并将其与GameEvent关联:
private void loadEventSound(Context context, GameEvent event, String filename) {
try {
AssetFileDescriptor descriptor = context.getAssets().openFd("sfx/" + filename);
int soundId = mSoundPool.load(descriptor, 1);
mSoundsMap.put(event, soundId);
} catch (IOException e) {
e.printStackTrace();
}
}
SoundPool有几种加载文件的方法,无论它们位于何处。由于我们的声音文件在assets中,我们需要使用接收AssetFileDescriptor的那个方法。
加载方法也接收优先级作为参数。官方文档表示,这目前没有效果,我们应该为了未来的兼容性使用 1,所以我们这样做。
最后,我们将声音 ID 存储到映射中。
如果我们尝试打开的文件不存在,我们可能会得到一个IOException。如果我们播放时遗漏了一些声音,打印堆栈跟踪是有趣的,以检查可能的错误。
是时候看看loadSounds了,它有效地结合了我们迄今为止所看到的所有代码:
private void loadSounds() {
createSoundPool();
mSoundsMap = new HashMap<GameEvent, Integer>();
loadEventSound(mContext, GameEvent.AsteroidHit, "Asteroid_explosion_1.wav");
loadEventSound(mContext, GameEvent.SpaceshipHit, "Spaceship_explosion.wav");
loadEventSound(mContext, GameEvent.LaserFired, "Laser_shoot.wav");
}
足够简单——我们创建SoundPool和声音映射。然后我们加载三个声音文件并将它们与GameEvents关联。
我们还有一个卸载声音的方法:
private void unloadSounds() {
mSoundPool.release();
mSoundPool = null;
mSoundsMap.clear();
}
要卸载声音,我们释放声音池并清除声音映射。
官方文档建议我们释放 SoundPool 并将其设置为 null;在加载一组新的声音时,创建一个新的 SoundPool 而不是卸载每个声音效果。这就是为什么在 loadSounds 的开始处创建一个新的 SoundPool 而不是在 SoundManager 的构造函数中。
最后,当 GameEvent 到达时我们准备播放声音:
public void playSoundForGameEvent(GameEvent event) {
Integer soundId = mSoundsMap.get(event);
if (soundId != null) {
mSoundPool.play(soundId, 1.0f, 1.0f, 0, 0, 1.0f);
}
}
我们从映射中获取声音 ID,如果它不是 null,我们就播放它。这个检查对于使游戏面向未来很重要,当我们将其扩展以支持更多的 GameEvents 并且其中一些没有关联声音时。
SoundPool 的播放方法参数如下:
-
左声道音量:不言而喻。一个介于 0.0 和 1.0 之间的浮点值。
-
右声道音量:同样不言而喻。一个介于 0.0 和 1.0 之间的浮点值。
-
优先级:0 表示最低优先级;我们所有的声音都将具有相同的优先级。它用于在达到最大流数时选择停止播放的声音。
-
循环:-1 的循环值表示无限循环,0 的值表示不循环,其他值表示重复次数。我们不想循环我们的声音,所以我们传递 0。
-
速率:1.0 的值表示以原始频率播放。2.0 的值表示以两倍速度播放,0.5 的值表示以半速播放。范围是 0.5 到 2.0。
您可以通过根据事件在屏幕上的位置调整左右声道音量来改善声音的感觉,但我们不会深入探讨这一点,所以我们只使用 1.0 作为左右声道音量。
播放方法将返回一个流 ID,我们可以使用它来暂停或继续播放此声音。由于我们使用的是非常短的声音,它们不循环且不会被中断,所以我们根本不需要存储流 ID。
注意,如果超过最大活动流数,调用 play 可能会导致另一个声音停止播放。
使用这种 SoundManager 架构,只需将它们关联到 GameEvent 并从产生它的任何 GameObject 触发事件,就可以很容易地添加新的声音效果。
播放音乐
游戏中包含的另一种声音类型是背景音乐。这些曲目通常比声音效果长,并且循环播放。在某些情况下可能有多个曲目,例如,如果我们想要菜单和关卡的音乐,或者如果我们想要几个具有不同背景音乐曲目的关卡。我们将只处理一个,但很容易将其扩展到多个曲目。
我们将使用 MediaPlayer 在游戏中播放背景音乐,并且我们也将通过 SoundManager 抽象化它,提供在活动暂停和恢复时暂停和继续播放音乐的方法。
获取音乐
就像音效一样,我们需要一些音轨来工作。当涉及到在预算有限的条件下制作游戏时,寻找可以免费使用的音乐的最佳地方之一是 Jamendo.com。那里有许多在 Creative Commons 许可下的专辑,其中一些在用于制作商业产品时价格非常合理,而且大多数只需要署名(也称为向艺术家致谢)。

Jamendo 是 Creative Commons 音乐的绝佳资源
我们将使用 Riccardo Colombo 的一首名为"Something mental"的音轨,它有一个很好的环境音效。我们还将将该音效放置在我们创建在assets内部的sfx文件夹中。
在 Android 上,MP3 文件通常工作得很好,但 OGG 在不同的设备和硬件能力之间通常会产生更一致和可靠的行为。您可以使用 Audacity(audacity.sourceforge.net/)等音频编辑器将任何 MP3 转换为 OGG。
MediaPlayer
MediaPlayer的使用非常直观,但它也有一个复杂的生命周期,并且对其使用非常严格。如果使用不当,可能会出现问题。
生命周期由几个状态和方法组成。在某个状态下,只有少数方法可以被调用。因此,必须按照精确的顺序调用方法调用,以从一个状态移动到下一个状态。如果您调用MediaPlayer当前状态不允许的方法,它将崩溃并显示一个神秘的错误。此外,没有方法可以知道 MediaPlayer 的当前状态。
注意
如果调用MediaPlayer当前状态不允许的方法,它将崩溃并显示一个神秘的错误。

但不用担心,我们将以最简单的方式使用它。
我们将要执行以下操作来加载音轨:
-
创建
MediaPlayer对象,使其处于空闲状态。 -
设置数据源,使其变为初始化状态。
-
调用
prepare。这将MediaPlayer移动到准备状态。 -
从准备状态,我们将调用
start,使其移动到开始状态。 -
在播放过程中,我们可以
暂停流,移动到暂停状态。然后从暂停状态我们可以再次调用start以回到开始状态。
我们还将有一个方法来卸载音轨,以便音乐可以在任何时候启用或禁用。要卸载音轨,我们只需:
-
调用停止。这可以从开始或暂停状态执行。这个调用将使音乐停止。
-
调用释放。这将从任何其他状态移动到结束状态。请注意,没有方法可以退出结束状态,因此当我们想要再次加载音乐时,我们需要创建一个新的
MediaPlayer并从头开始。
我们需要最小心处理的情况是在准备或暂停状态下调用pause。该方法在那里是不允许的,并且会将MediaPlayer置于非工作状态。然而,如果你遵循这里提供的步骤,你就不必担心这个问题。
让我们看看SoundManager中处理背景音乐的方法:
private void loadMusic() {
try {
mBgPlayer = new MediaPlayer();
AssetFileDescriptor afd = mContext.getAssets()
.openFd("sfx/Riccardo_Colombo_-_11_-_Something_mental.mp3");
mBgPlayer.setDataSource(
afd.getFileDescriptor(),
afd.getStartOffset(),
afd.getLength());
mBgPlayer.setLooping(true);
mBgPlayer.setVolume(DEFAULT_MUSIC_VOLUME, DEFAULT_MUSIC_VOLUME);
mBgPlayer.prepare();
}
catch (IOException e) {
e.printStackTrace();
}
}
private void unloadMusic() {
mBgPlayer.stop();
mBgPlayer.release();
}
public void pauseBgMusic() {
mBgPlayer.pause();
}
public void resumeBgMusic() {
mBgPlayer.start();
}
我们还没有涉及的是对setLooping和setVolume的调用。它们本身相当清晰:setLooping根据参数设置媒体播放器为循环或非循环模式;setVolume设置MediaPlayer实例的左右音量。对于音量,我们使用一个常量DEFAULT_MUSIC_VOLUME,将其设置为 0.6,因此音乐的播放音量比音效要小。
在加载音乐时,我们可能会遇到IOException,原因与音效相同:如果文件不存在。正如我们之前所做的那样,如果发生这种情况,我们只需记录下来并继续。
当然,我们还需要在初始化SoundManager时加载音乐,在调用loadSounds之后立即调用loadMusic。
public SoundManager(Context context) {
mContext = context;
loadSounds();
loadMusic();
}
音乐和 Activity 生命周期
在本节中,我们将处理暂停和恢复音乐。为此,我们只需要将SoundManager链接到 Activity 生命周期。在下一节中,我们将处理用户更改偏好时按需加载和卸载音乐:
@Override
protected void onPause() {
super.onPause();
mSoundManager.pauseBgMusic();
}
@Override
protected void onResume() {
super.onResume();
mSoundManager.resumeBgMusic();
}
记住,我们在onCreate期间创建了SoundManager。现在我们添加方法调用,当活动调用onPause时暂停音乐,当活动调用onResume时恢复音乐。
这允许我们在其他应用进入前台(或游戏被置于后台)时暂停音乐,并在它返回前台时恢复播放。
不要允许用户手动暂停或恢复背景音乐;这可能会使MediaPlayer处于不一致的状态。我们不会暂停音乐,而是允许用户禁用音乐,这是一个不同的过程,我们将在下一节中介绍。
启用和禁用音乐和音效
现在我们已经让音乐和音效正常工作,但我们遗漏了一个非常重要的点:允许用户禁用它们。如果你在游戏中添加了音效,你需要提供一个禁用它们的方法。很多人喜欢在静音状态下玩游戏。
注意
总是为用户提供一种方法来单独禁用音乐和音效。
为了做到这一点,我们将在主屏幕上添加两个按钮(一个用于音乐,一个用于音效)来独立启用和禁用每个选项。这些选项也应该出现在暂停对话框中,但我们将留到下一章重新设计对话框时再讨论。
一方面,我们将更新MainMenuFragment的布局和代码;另一方面,我们将让SoundManager负责这项配置。
为了存储音乐和声音状态,我们将使用 SharedPreferences,因为它是存储 Android 上键值持久数据的简单且方便的方式。
更新 MainMenuFragment
到目前为止,FrameLayout 对我们来说已经足够好了。现在我们希望声音和音乐按钮在一边并且放在一起,所以我们需要将其替换为 RelativeLayout。
fragment_main.xml 的更新版本如下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<TextView
style="@android:style/TextAppearance.DeviceDefault.Large"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:text="@string/game_title"
android:layout_centerHorizontal="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:id="@+id/btn_start"
android:layout_centerInParent="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/start" />
<Button
android:id="@+id/btn_sound"
android:layout_margin="@dimen/activity_vertical_margin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:text="@string/sound_on" />
<Button
android:id="@+id/btn_music"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@+id/btn_sound"
android:layout_toLeftOf="@+id/btn_sound"
android:text="@string/music_on" />
</RelativeLayout>
不需要特定的注释。我们只是在右下角添加了 btn_sound 和 btn_music,并设置了正确的边距,将音乐按钮对齐到声音按钮的底部,并将其放置在其左侧。

代码有点更有趣。我们必须修改 MainMenuFragment 来处理新按钮的点击,以及放置正确的文本。为此,我们将修改 onViewCreated 和 onClick 并添加一个新的方法来放置每个按钮的正确文本。
首先,我们必须在 onViewCreated 期间将这些按钮添加为 MainMenuFragment 的点击监听器:
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
view.findViewById(R.id.btn_start).setOnClickListener(this);
view.findViewById(R.id.btn_sound).setOnClickListener(this);
view.findViewById(R.id.btn_music).setOnClickListener(this);
updateSoundAndMusicButtons();
}
然后,我们必须处理点击。为此,我们依赖于在 SoundManager 内部创建的方法来切换值。一旦完成,我们更新按钮上的文本。
@Override
public void onClick(View v) {
if (v.getId() == R.id.btn_start){
((YassActivity) getActivity()).startGame();
}
else if (v.getId() == R.id.btn_music) {
SoundManager soundManager = getYassActivity().getSoundManager();
soundManager.toggleMusicStatus();
updateSoundAndMusicButtons();
}
else if (v.getId() == R.id.btn_sound) {
SoundManager soundManager = getYassActivity().getSoundManager();
soundManager.toggleSoundStatus();
updateSoundAndMusicButtons();
}
}
最后,updateSoundAndMusicButtons 方法简单地从 SoundManager 读取声音和音乐的状态,并在每个按钮上设置正确的字符串资源。
private void updateSoundAndMusicButtons() {
SoundManager soundManager = getYassActivity().getSoundManager();
TextView btnMusic = (TextView) getView().findViewById(R.id.btn_music);
if (soundManager.getMusicStatus()) {
btnMusic.setText(R.string.music_on);
}
else {
btnMusic.setText(R.string.music_off);
}
TextView btnSounds= (TextView) getView().findViewById(R.id.btn_sound);
if (soundManager.getSoundStatus()) {
btnSounds.setText(R.string.sound_on);
}
else {
btnSounds.setText(R.string.sound_off);
}
}
正如我们提到的,所有繁重的工作都是在 SoundManager 内部完成的,尤其是在我们尚未实现的方法中:toggleMusicStatus 和 toggleSoundStatus。
让我们来看看 SoundManager。
更新 SoundManager
我们的 SoundManager 负责读取、更改和恢复声音和音乐首选项的值。为此,我们在构造时从 SharedPreferences 读取它们,并提供一个方法来更改设置,该方法还负责根据需要加载和卸载声音文件。
private boolean mSoundEnabled;
private boolean mMusicEnabled;
public SoundManager(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
mSoundEnabled = prefs.getBoolean(SOUNDS_PREF_KEY, true);
mMusicEnabled = prefs.getBoolean(MUSIC_PREF_KEY, true);
mContext = context;
loadIfNeeded();
}
private void loadIfNeeded () {
if (mSoundEnabled) {
loadSounds();
}
if (mMusicEnabled) {
loadMusic();
}
}
public boolean getSoundStatus() {
return mSoundEnabled;
}
public boolean getMusicStatus() {
return mMusicEnabled;
}
通过这个初始化,我们从 SharedPreferences 读取值并将它们存储在成员变量 mSoundEnabled 和 mMusicEnabled 中,这些变量可以在任何时候访问,无需从磁盘读取。一旦读取了值,我们只在没有启用的情况下加载声音和音乐。
我们还提供了访问器方法 getSoundStatus 和 getMusicStatus,这些方法我们已经在 MainMenuFragment 中使用,用于更新按钮上的文本。
要更改每个变量的值,我们必须使用一个方法来处理保存状态以及根据需要加载或卸载所需的文件。让我们看看声音的方法:
public void toggleSoundStatus() {
mSoundEnabled = !mSoundEnabled;
if (mSoundEnabled) {
loadSounds();
}
else {
unloadSounds();
}
// Save it to preferences
PreferenceManager.getDefaultSharedPreferences(mContext).edit()
.putBoolean(SOUNDS_PREF_KEY, mSoundEnabled)
.commit();
}
该方法将设置的值切换为其逻辑否定。然后根据新值加载或卸载声音。最后,它将更新的值存储在 SharedPreferences 上,以确保成员变量和存储的值保持同步。
在音乐的情况下,方法具有完全相同的逻辑,但根据mMusicEnabled的值加载和卸载音乐,而偏好键是常量MUSIC_PREF_KEY。
public void toggleMusicStatus() {
mMusicEnabled = !mMusicEnabled;
if (mMusicEnabled) {
loadMusic();
resumeBgMusic();
}
else {
unloadMusic();
}
// Save it to preferences
PreferenceManager.getDefaultSharedPreferences(mContext).edit()
.putBoolean(MUSIC_PREF_KEY, mMusicEnabled)
.commit();
}
为了完成功能,我们还需要修改playSoundForGameEvent以及pauseBgMusic和resumeBgMusic,以便仅在声音或音乐启用时执行某些操作。这就像在每个相关方法的开头添加一个检查一样简单。
public void playSoundForGameEvent(GameEvent event) {
if (!mSoundEnabled) {
return;
}
Integer soundId = mSoundsMap.get(event);
if (soundId != null) {
mSoundPool.play(soundId, 1.0f, 1.0f, 0, 0, 1.0f);
}
}
public void pauseBgMusic() {
if (mMusicEnabled) {
mBgPlayer.pause();
}
}
public void resumeBgMusic() {
if (mMusicEnabled) {
mBgPlayer.start();
}
}
注意,这个检查非常重要,因为我们尝试访问未初始化的组件时很容易得到NullPointerExceptions。
禁用系统声音
Android 默认为按钮点击提供声音。如果你想为点击动作使用自己的声音,你还需要禁用系统声音。这几乎是我们每次都会做的事情。幸运的是,这非常容易做到。我们只需修改已经在res/styles.xml中定义的应用程序样式,并告诉它我们想要禁用音效:
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:soundEffectsEnabled">false</item>
</style>
摘要
我们创建了一个SoundManager,它从资源文件夹中加载和卸载音效和音乐轨道,使用 Android 提供的类——SoundPool和MediaPlayer——并且我们学习了它们各自的最佳使用方法。
为了播放声音,我们在GameEngine中添加了一个GameEvent消息系统,它可以用于其他目的;这是同步GameObjects的常见机制。
对于音乐,我们将暂停和恢复音乐与 Activity 生命周期相关联。
我们还增加了在任何时候独立启用和禁用声音和音乐的可能性,并将状态存储在持久存储中。
到目前为止,GameEngine基本上已经完成。在接下来的章节中,我们将关注 Android 提供的工具,以使 UI 更美观、更有吸引力,从而改善游戏的整体外观和感觉。
第七章:菜单和对话框
现在游戏已经可以运行了,是时候调整剩余的 UI 界面了。当你开发一个应用时,最佳实践是使用所有标准的 UI 组件。游戏则不同,它们应该有自己的个性。为此,我们将了解如何使用自定义字体、按钮和对话框。
我们首先将使游戏的所有片段都使用自定义字体。然后我们将调整主菜单,通过添加背景和自定义音效和音乐的按钮来使其看起来更美观;我们还将使用在 XML 中可以定义的特殊可绘制类型来自定义启动游戏的按钮。
对于GameFragment,我们将留出空间显示分数和生命值,并更新暂停按钮。我们将让游戏负责计分和玩家死亡。为此,我们还将了解如何使用GameEvents,不仅用于播放声音,还用于扩展功能,并添加一些事件。
我们还将通过使用状态机让GameController承担更多游戏情况的责任。这项技术可以应用于其他情况,如新关卡等。
最后,由于 Android 框架的对话框功能相当有限,我们将看看如何创建用于退出游戏、暂停和游戏结束的自定义对话框。
自定义字体
对于应用开发者,Google 一直强制使用系统默认字体,特别是自从 Robotto 引入以来。这就是为什么系统中没有其他字体。然而,TextView 可以使用 True Type (.ttf) 或 Open Type (.otf) 格式的自定义字体。
选择字体时,你有许多选择。有一些网站列出了许多免费使用的字体。因为 YASS 意在成为复古风格的射击游戏,我选择了一个像素艺术风格的字体,名为 Adore64。
我们将字体文件存储在 assets 下的一个文件夹中,命名为 ttf,就像我们为声音所做的那样。
为了加载 TextView 可以使用的格式,我们有 Typeface 类。加载字体的过程是昂贵的,所以我们只做一次(在 onCreate 中),并将 Typeface 变量作为 Activity 的成员保留。
注意
加载 Typeface 是昂贵的;我们应该将其保存在内存中(在 Activity 级别)。
我们需要添加到 YassActivity 中以加载字体的代码非常简单:
private Typeface mCustomTypeface;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
[...]
mCustomTypeface = Typeface.createFromAsset(getAssets(), "ttf/Adore64.ttf");
}
一旦字体加载,我们只需将其应用到层次结构中的所有文本视图。这可以通过递归轻松完成:
public void applyTypeface (View view) {
if (view instanceof ViewGroup) {
// Apply recursively to all the children
ViewGroup viewGroup = (ViewGroup) view;
for (int i=0; i<viewGroup.getChildCount(); i++) {
applyTypeface(viewGroup.getChildAt(i));
}
}
else if (view instanceof TextView) {
TextView tv = (TextView) view;
tv.setTypeface(mCustomTypeface);
}
}
该方法接收一个 View。如果它是 ViewGroup 的实例,这意味着它有(或可以有)更多视图作为子视图,所以我们使用相同的方法递归地遍历所有子视图。
如果视图是 TextView 的实例,我们只需调用 setTypeface 方法,这个方法只存在于 TextView 中,然后我们可以继续。
对于任何其他类型的视图,我们不做任何事情。
注意
使用递归算法将字体应用到所有视图是很容易做到的。
注意,instanceof 检查对象是否遵守给定的类。这意味着从它扩展的类的对象将返回 true。这对于此算法正常工作至关重要,因为所有 Layout 类都是 ViewGroup 的子类,所有包含文本的视图(即 Button)都扩展自 TextView。
缺少的链接是,我们在哪里调用这个方法以及使用哪些参数?我们将使用 YassBaseFragment 的 onViewCreated 方法来完成。
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
getYassActivity().applyTypeface(view);
}
由于所有片段都将扩展自这个,它们都将自动设置 Typeface。
如果我们想在游戏中使用多个字体,代码开始变得复杂,使用库来处理它是个更好的主意。为此,我推荐使用 Calligraphy (github.com/chrisjenx/Calligraphy),它通过 ContextWrapper 钩子来加载字体,而不是在之后遍历层次结构,并且还支持 XML 属性来设置特定视图的特定字体。
注意
如果你想在 Android 项目中使用多种字体,书法艺术是一个很好的库。
与背景一起工作
让游戏看起来不好的是当前我们使用的纯白色背景。虽然我们可以为活动设置背景,但这不是最好的解决方案,因为背景图片被设置为缩放以适应,并且无法更改。当使用背景图片时,它将扩展以覆盖所有视图。
注意
属性 android:background 将拉伸图片以使其适合视图。
我们不希望背景图片在一个维度上被拉伸。我们希望它在两个轴向上均匀缩放。
使用 9-patch 作为背景图片可以改进这一点,但在这个例子中,我们将只使用一个覆盖整个布局的 ImageView,并将 scaleType 设置为 centerCrop。此参数将图像均匀缩放(保持图像的宽高比),使得图像的两个维度(宽度和高度)都将等于或大于 View 的相应维度。
我们将在 fragment_main_menu.xml 的开头添加一个 ImageView,并设置所需的 scaleType:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent" >
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="img/seamless_space_0"/>
[…]
</RelativeLayout>
仅通过字体和背景,主菜单就已经看起来好多了!

我们可以使用 GameView 而不是 ImageView 来实现视差背景,我将这个作为练习留给对调整背景更感兴趣的读者。
XML 可绘制元素的力量
到目前为止,我们使用了图像类型的可绘制元素,但 Android 提供了一个非常完整的框架,允许我们使用其他类型的资源。可绘制资源是一个通用概念,用于可以在屏幕上绘制的图形。你可以在 XML 中定义几种类型的可绘制元素。
我们将描述状态列表和形状可绘制元素,但还有更多。特别是如果你不熟悉 9-patch,你也应该查看它们。
注意
你可以在官方文档中了解所有可绘制资源:developer.android.com/guide/topics/resources/drawable-resource.html。
状态列表的可绘制元素
StateListDrawable 是在 XML 中定义的一个可绘制对象,它使用几个其他可绘制元素来表示同一图形,具体取决于对象的状态。框架定义了许多状态,但我们将只使用 pressed、focused 和 default(无状态)。还有一个名为 selected 的状态,不应与 focused 混淆。实际上,selected 很少使用。
状态列表由一个根标签为 <selector> 的 XML 文件描述。每个图形由一个 <item> 元素表示,该元素可以使用各种属性来描述它应该作为可绘制图形使用的状态。
当状态改变时,列表将按顺序检查,并使用第一个匹配当前状态的项。重要的是要记住,这种状态选择不是基于最佳匹配,而是基于第一个匹配。定义状态列表的最佳实践是将最限制性的状态放在前面,并将默认状态作为最后一个项。
注意
状态的顺序非常重要。列表中的第一个匹配项将被使用。
我们将使用状态列表可绘制来为音乐和声音按钮。然后我们将有四个状态列表可绘制——音乐开启、音乐关闭、声音开启 & 声音关闭——并且对于每一个,我们将有三种状态:按下、聚焦和正常。
这些视图将不再是按钮,而是 ImageView。为了实现这个变化,需要对代码进行一些调整,基本上是在 updateSoundAndMusicButtons 中,它现在应该使用可绘制而不是文本。
关于外观和感觉,我们将使用代表状态的颜色的圆形,然后为所有状态使用相同的图标,只需改变颜色。我们还将通过给图像添加边距,使按下状态稍微小一点,以产生被按下的感觉。
我们使用白色表示正常状态,黄色表示按下状态,蓝色表示聚焦状态。

所有用于声音和音乐状态的可绘制组合。
状态可绘制文件应放置在 res/drawable 目录中,因为它们只是引用列表,因此它们与密度无关。另一方面,我们应该提供作为状态引用的可绘制文件的密度特定版本。
现在,由于所有这些图标都使用相同的背景,我们可以使用 4 个图标、3 个背景状态和 1 个状态可绘制来配置它,这将作为所有这些的背景。这还将允许我们为其他按钮重用背景。
这是我们将用于背景的状态列表可绘制代码:
<?xml version="1.0" encoding="utf-8"?>
<selector >
<item
android:drawable="@drawable/icon_button_bg_pressed"
android:state_pressed="true"/>
<item
android:drawable="@drawable/icon_button_bg_selected"
android:state_focused="true"/>
<item
android:drawable="@drawable/icon_button_bg_normal"/>
</selector>
正如我们之前所说的,顺序非常重要。我们希望在按下时显示按下状态;我们不在乎它是否聚焦,所以这是第一个情况。然后我们想显示它是否聚焦。最后,我们有一个默认情况,没有参数,覆盖“其他所有情况”。
背景是通过 GIMP 创建的,基础图标是我们使用图标搜索网站(如 IconFinder www.iconfinder.com)找到的免费图标。有许多图标搜索网站。尝试几个,看看哪个最适合你。
注意,命名约定是使用状态列表的名称,然后是每个状态的相同名称后跟 pressed、selected 或 normal。
状态列表颜色
我们可以使用选择器来定义颜色以及图像。这可以用于按钮中的文本或视图的背景。它与可绘制元素的工作方式相同,但使用 color 关键字而不是 drawable。定义颜色的文件必须放置在 res/colors 目录下。
我们稍后会参考的一个颜色状态列表示例是 btn_background.xml。
<?xml version="1.0" encoding="utf-8"?>
<selector >
<item
android:color="@color/btn_pressed"
android:state_pressed="true"/>
<item
android:color="@color/btn_focused"
android:state_selected="true"/>
<item
android:color="@color/btn_normal"/>
</selector>
当然,我们还需要定义这些颜色。请注意,这些颜色不是状态列表,因此应该在 res/values 目录下的文件中定义。我们通常按照惯例将此文件命名为 colors.xml,但任何名称都可以。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="btn_selected">#39a29c</color>
<color name="btn_normal">#ffffff</color>
<color name="btn_pressed">#fdd33f</color>
</resources>
注意
状态列表颜色 XML 文件放置在 res/color 目录下,但正常颜色定义为 res/values 目录下的文件中的值。
作为良好的实践,你应该始终将布局的所有变量外部化。这对于颜色、尺寸和字符串都是有效的。
形状可绘制元素
形状可绘制元素正如其名所示:在 XML 中定义通用形状的一种方式。我们将使用它们来制作所有的按钮背景。
形状可绘制元素的语法定义如下:
<?xml version="1.0" encoding="utf-8"?>
<shape
android:shape=["rectangle" | "oval" | "line" | "ring"] >
<corners
android:radius="integer"
android:topLeftRadius="integer"
android:topRightRadius="integer"
android:bottomLeftRadius="integer"
android:bottomRightRadius="integer" />
<gradient
android:angle="integer"
android:centerX="integer"
android:centerY="integer"
android:centerColor="integer"
android:endColor="color"
android:gradientRadius="integer"
android:startColor="color"
android:type=["linear" | "radial" | "sweep"]
android:useLevel=["true" | "false"] />
<padding
android:left="integer"
android:top="integer"
android:right="integer"
android:bottom="integer" />
<size
android:width="integer"
android:height="integer" />
<solid
android:color="color" />
<stroke
android:width="integer"
android:color="color"
android:dashWidth="integer"
android:dashGap="integer" />
</shape>
形状可绘制元素的最高级标签是 <shape>,它有一个也命名为 shape 的属性,用于定义其类型。可能的值包括:
-
rectangle:填充包含视图的矩形。如果没有指定,这是默认形状。 -
oval:适合包含视图尺寸的椭圆形。 -
line:横跨包含视图宽度的水平线。此形状需要<stroke>元素来定义线的宽度。 -
ring:环形形状。此形状允许正确定义一些其他属性,例如innerRadius/innerRadiusRatio、thickness/thicknessRatio和useLevel。
形状的内容可以是纯色或渐变。为此,我们使用 <solid> 或 <gradient> 标签。
<solid> 标签的参数是 color,可以是十六进制值或颜色资源。此颜色资源也可以是状态列表。
可以使用 <gradient> 标签代替 <solid> 标签,其参数是自解释的。我们不会在我们的游戏中使用它们。
我们可以使用 <padding> 标签来定义包含视图元素的填充。它有四个不同的属性——left、top、right 和 bottom——可以作为尺寸值或尺寸资源提供。
形状的大小是可选的,可以使用 <size> 标签来定义,该标签具有 height 和 width 属性,可以是尺寸值或资源。
注意
形状默认按比例缩放到容器 View 的大小,与这里定义的尺寸相对应。当你在 ImageView 中使用形状时,你可以通过设置 android:scaleType 为 "center" 来限制缩放。
形状的边框或轮廓使用 <stroke> 标签定义。它接受以下属性:
-
宽度:线的厚度,可以是尺寸值或尺寸资源。
-
颜色:线的颜色,作为十六进制值或颜色资源。
-
DashGap/DashWith:线段之间的距离和每个线段的尺寸,两者都作为尺寸值和尺寸资源。它们需要一起设置。
当使用矩形形状时,我们可以通过使用<corners>标签来指定要圆滑的角落;为此,我们可以只使用radius,或者我们可以为每个角落指定一个维度:topLeftRadius、topRightRadius、bottomLeftRadius和bottomRightRadius。
注意
系统要求在 XML 文件中将每个圆角半径初始化为大于 1 的值。否则,没有圆角会被圆滑。为了解决这个问题,你可以通过程序覆盖圆角半径值。
既然我们已经详细了解了语法,让我们创建我们将在游戏中使用的形状。
我们将为圆形按钮创建一个椭圆形形状,并使用一个也是颜色状态列表的颜色来替换当前具有多个形状的可绘制状态列表,因为这会使代码更加紧凑且易于更新。
<?xml version="1.0" encoding="utf-8"?>
<shape
android:shape="oval">
<solid
android:color="@color/btn_backgound" />
<padding android:bottom="@dimen/round_button_padding"
android:left="@dimen/round_button_padding"
android:right="@dimen/round_button_padding"
android:top="@dimen/round_button_padding"/>
</shape>
我们将形状定义为椭圆形,然后将其设置为具有实心颜色,即我们在上一节中定义的状态列表颜色资源。最后,我们定义了一些来自尺寸资源的内边距。
接下来,我们将为方形按钮定义一个形状(目前它仅用于启动游戏的按钮):
<?xml version="1.0" encoding="utf-8"?>
<shape
android:shape="rectangle">
<solid
android:color="@color/btn_backgound" />
<padding android:bottom="@dimen/square_button_padding"
android:left="@dimen/square_button_padding"
android:right="@dimen/square_button_padding"
android:top="@dimen/square_button_padding"/>
<stroke android:color="@color/btn_border"
android:width="@dimen/square_button_border" />
</shape>
这个形状是一个带有边框的矩形。边框由stroke标签定义。
我们为这个形状使用的所有尺寸都必须定义。我们将它们放入res/values下的dimens.xml文件中。
<dimen name="square_button_padding">18dp</dimen>
<dimen name="square_button_border">6dp</dimen>
<dimen name="round_button_padding">6dp</dimen>
<dimen name="btn_sound_size">60dp</dimen>
我们还在colors.xml中为按钮边框和文字颜色添加了几个更多颜色。
<color name="text_color">#FFFFFF</color>
<color name="btn_border">#AAAAAA</color>
最后,我们将利用样式来使布局上的代码更整洁。Android 上的样式允许你定义多个 XML 属性并将它们与一个名称关联。然后你可以在任何布局中通过名称引用样式,它将被应用。
样式的目的是在单个位置定义外观和感觉,以便可以轻松更改和/或更新。这个概念与网页的 CSS 类似。
注意
样式对于保持布局整洁并将外观和感觉的定义放在一个地方非常有用。
我们将为圆形按钮定义一个样式。这将在res/values下的styles.xml文件中(而且,文件名可以是任何名称,但最好遵循约定)。
<resources>
<style name="iconButton" >
<item name="android:background">@drawable/icon_button_bg</item>
<item name="android:layout_width">@dimen/btn_round_size</item>
<item name="android:layout_height">@dimen/btn_round_size</item>
</style>
</resources>
为了完成这一部分,让我们看看包含所有这些更改的fragment_main_menu.xml的更新版本:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="img/seamless_space_0"/>
<TextView
android:textColor="@color/text_color"
android:id="@+id/main_title"
style="@android:style/TextAppearance.DeviceDefault.Large"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:text="@string/game_title"
android:layout_centerHorizontal="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:textColor="@color/text_color"
android:layout_below="@+id/main_title"
style="@android:style/TextAppearance.DeviceDefault.Medium"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:text="@string/game_subtitle"
android:layout_centerHorizontal="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:id="@+id/btn_start"
android:layout_centerInParent="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/button_square_bg"
android:text="@string/start" />
<ImageView
android:background="@drawable/button_round_bg"
android:id="@+id/btn_sound"
android:layout_margin="@dimen/activity_vertical_margin"
android:layout_width="@dimen/btn_sound_size"
android:layout_height="@dimen/btn_sound_size"
android:src="img/sounds_on_no_bg"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"/>
<ImageView
android:background="@drawable/button_round_bg"
android:id="@+id/btn_music"
android:layout_width="@dimen/btn_sound_size"
android:layout_height="@dimen/btn_sound_size"
android:layout_alignBottom="@+id/btn_sound"
android:src="img/music_on_no_bg"
android:layout_toLeftOf="@+id/btn_sound"/>
</RelativeLayout>

游戏片段
既然我们已经调整了主菜单,现在是时候开始处理GameFragment了。
我们还没有构建的两个基本游戏功能:得分和生命值。我们现在将解决这个问题。首先,我们需要在布局中为它们腾出空间,然后我们必须编写一些代码来实际处理这两个功能。
对于得分和生命值的 UI,我们将使用标准的 Android 组件。我们已经在我们的GameEngine中构建了许多功能,但我们不想重新发明轮子。由于 Android 提供了一个很好的方式来定义布局并创建我们熟悉的 UI,不利用它将是浪费的。
注意
使用标准的 Android 视图可以为您节省大量时间。
我们将通过在每边添加一列来缩小游戏区域。我们将把新的 UI 元素放在那里,并将它们链接到GameObjects以在游戏过程中更新它们。
虽然我们可以在GameView上叠加控件而不是使用信封,但请注意,我们正在使用SurfaceView,当其他视图叠加在其上时,其性能会急剧下降。我们也认为将游戏区域与控件分开看起来更好。如果您想使用叠加,您应该将GameView更改为StandardGameView。
现在我们正在使用fragment_game.xml,这是一个更新暂停按钮的好时机,使其与为声音和音乐创建的相同样式保持一致。
fragment_game.xml的新版本如下:
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context="com.plattysoft.yass.counter.GameFragment">
<RelativeLayout
android:layout_width="@dimen/game_menu_width"
android:layout_height="match_parent">
<!-- Lives and score go here -->
</RelativeLayout>
<FrameLayout
android:background="@color/game_view_frame"
android:layout_weight="1"
android:padding="4dp"
android:layout_marginLeft="@dimen/game_menu_width"
android:layout_marginRight="@dimen/game_menu_width"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.plattysoft.yass.engine.SurfaceGameView
android:id="@+id/gameView"
android:layout_weight="1"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
<include layout="@layout/view_vjoystick" />
<ImageView
style="@style/iconButton"
android:layout_gravity="top|right"
android:id="@+id/btn_play_pause"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginRight="@dimen/activity_vertical_margin"
android:src="img/pause" />
</FrameLayout>
GameView现在位于一个居中且具有每边边距的FrameLayout中,边距值对应于列(R.dimen.game_menu_width)。FrameLayout用于在GameView周围显示一个红色边框,以清楚地将游戏区域与其他 UI 部分分开。
边框背景是一个类似于我们已定义的矩形形状的可绘制对象:
<?xml version="1.0" encoding="utf-8"?>
<shape
android:shape="rectangle">
<solid
android:color="@android:color/transparent" />
<padding android:bottom="@dimen/game_frame_width"
android:left="@dimen/game_frame_width"
android:right="@dimen/game_frame_width"
android:top="@dimen/game_frame_width"/>
<stroke android:color="@color/game_view_frame"
android:width="@dimen/game_frame_width" />
</shape>
在左侧,我们有一个RelativeLayout,我们将使用它来放置得分和生命值的控件。在这个控件前面,我们有我们之前已经使用的虚拟摇杆,它覆盖了整个屏幕。最后,我们有暂停按钮,它必须保持在前景,否则虚拟摇杆将捕获触摸事件。正如我们之前提到的,暂停按钮现在使用与主菜单中的音乐和声音按钮相同的样式和感觉。

现在我们有了空间,让我们开始添加项目!我们只是会显示得分和生命值,但您可以使用这个空间来显示与游戏相关的任何内容,从最高分到应用内购买按钮。
添加得分
让我们实现得分计数器。为此,我们需要定义一种给玩家记分的方法。
我们将使用TextView在屏幕上显示得分。为了控制这个TextView,我们将创建一个ScoreGameObject,它在许多方面与我们在第一章中使用的类似,用于显示Player的坐标。
得分将在ScoreGameObject类内部计算,并根据游戏事件进行更新。这也意味着GameEngine必须将事件传播到游戏对象(到目前为止,它只将事件传播到SoundEngine)。
每当玩家击中一个小行星时,我们将给予 50 分,每次小行星逃脱时,我们将扣除 1 分。小行星逃脱是一个新的 GameEvent,我们将必须创建并触发它。这两个分数修改值都将设置为常量,作为可读性和易于更改的良好实践。
首先:让我们让 GameEngine 将 GameEvents 传播给所有当前活动的 GameObjects。为此,我们修改 GameEngine 的 onGameEvent 方法。
public void onGameEvent (GameEvent gameEvent) {
// We notify all the GameObjects
int numObjects = mGameObjects.size();
for (int i=0; i<numObjects; i++) {
mGameObjects.get(i).onGameEvent(gameEvent);
}
// Also the sound manager
mSoundManager.playSoundForGameEvent(gameEvent);
}
注意,这暗示在 GameObject 中创建一个名为 onGameEvent 的方法,默认情况下该方法为空。
我们将使用一个新的 GameEvent,当小行星超出屏幕时触发(AsteroidMissed)。我们必须将此值添加到 GameEvents 枚举中,并从 Asteroid 的 onUpdate 方法中触发事件。
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
[...]
if (mY > gameEngine.mHeight) {
gameEngine.onGameEvent(GameEvent.AsteroidMissed);
removeFromGameEngine(gameEngine);
}
}
现在让我们将 TextView 添加到我们的 fragment_game.xml 的左侧列。我们将有两个新的文本视图:一个带有文本 "Score:" (R.id.score_title),另一个带有实际的分数 (R.id.score_value)。
<RelativeLayout
android:layout_width="@dimen/game_menu_width"
android:layout_height="match_parent">
<TextView
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginLeft="@dimen/menu_margin"
android:id="@+id/score_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:textColor="@color/text_color"
android:text="@string/score"/>
<TextView
android:id="@+id/score_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/menu_margin"
android:layout_below="@+id/score_title"
android:layout_alignLeft="@+id/score_title"
android:textColor="@color/text_color"
android:text="000000"/>
</RelativeLayout>
我们已经准备好了所有东西,现在是时候用 ScoreGameObject 类将它们连接起来。这是一个相当简单的类:
public class ScoreGameObject extends GameObject {
private final TextView mText;
private int mPoints;
private boolean mPointsHaveChanged;
private static final int POINTS_LOSS_PER_ASTEROID_MISSED = 1;
private static final int POINTS_GAINED_PER_ASTEROID_HIT = 50;
public ScoreGameObject(View view, int viewResId) {
mText = (TextView) view.findViewById(viewResId);
}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {}
@Override
public void startGame() {
mPoints = 0;
mText.post(mUpdateTextRunnable);
}
@Override
public void onGameEvent(GameEvent gameEvent) {
if (gameEvent == GameEvent.AsteroidHit) {
mPoints += POINTS_GAINED_PER_ASTEROID_HIT;
mPointsHaveChanged = true;
}
else if (gameEvent == GameEvent.AsteroidMissed) {
if (mPoints > 0) {
mPoints -= POINTS_LOSS_PER_ASTEROID_MISSED;
}
mPointsHaveChanged = true;
}
}
private Runnable mUpdateTextRunnable = new Runnable() {
@Override
public void run() {
String text = String.format("%06d", mPoints);
mText.setText(text);
}
};
@Override
public void onDraw(Canvas canvas) {
if (mPointsHaveChanged) {
mText.post(mUpdateTextRunnable);
mPointsHaveChanged = false;
}
}
}
这个类在游戏开始时将分数设置为 0,然后通过修改玩家的总分数来响应 GameEvents。一旦分数值被修改,我们将通过一个布尔变量发出信号,这样我们就可以知道在下次调用 onDraw 时需要更新它。这样做是为了防止在 TextView 上进行不必要的重绘。
记住,当使用 StandardGameView 时,onDraw 是在 UIThread 上调用的,但在 SurfaceGameView 的情况下,它是在 UpdateThread 上调用的。由于视图只能在 UIThread 上更新,我们使用一个 Runnable,该 Runnable 被发布到 UIThread,然后更新 TextView 的值,使其对 StandardGameView 和 SurfaceGameView 都有效。
注意
视图修改必须在 UIThread 上进行。
我们使用 String.format 来获取一个由 6 位数字组成的数字,如果整数位数不足,则在左侧用 0 填充。这只是为了使分数看起来更美观。
剩下的唯一链接是将这个 GameObject 添加到 GameFragment 中的 GameEngine 初始化中。
new ScoreGameObject(getView(),R.id.score_value).addToGameEngine(mGameEngine, 0);
我们现在可以玩游戏了,并最终得分。
添加生命值
我们还将在左侧列中添加一个生命值指示器。为此,我们必须对游戏进行大量更新,因为我们目前只有一条生命,游戏在玩家死亡时不会做任何事情。
与分数类似,我们将有一个 LivesCounter 对象来处理显示,但在这个情况下,生命值将依赖于 GameController。GameController 和 LivesCounter 之间的同步将通过 GameEvents 完成。
另一个需要考虑的是,一旦玩家死亡,波次必须停止。只有当屏幕为空时,我们才能生成一个新的 Player 对象,然后在几秒钟后重新开始波次。
为了管理这一点,我们将 GameController 设计为一个状态机,并根据 GameEvents 在状态之间进行转换。这是游戏控制器中常用的技术。
注意
将 GameController 设计为状态机是游戏中的常用技术。
我们将首先对 GameController 进行修改,然后是 LivesCounter。
对于 GameController 的状态,我们将创建一个枚举,我们将其称为 GameControllerState。
public enum GameControllerState {
StoppingWave,
SpawningEnemies,
PlacingSpaceship,
Waiting,
GameOver;
}
让我们描述每个状态:
-
StoppingWave:这个状态是基于时间的。当GameController处于这个状态时,不会生成任何小行星。与超时结合使用,它有效地停止了当前波次。从这个状态开始,控制器将根据是否有剩余生命而转换到GameOver或PlacingSpaceship。 -
SpawningEnemies:正常状态。这是之前版本没有状态的行为的等价物。当飞船被摧毁时,它转换到StoppingWave。 -
PlacingSpaceship:控制器将一个Player对象放入游戏中,并发送关于它的GameEvent。这会自动转换到Waiting状态。 -
Waiting:类似于StoppingWave,这个状态也是基于时间的,但它总是转换到SpawningEnemies。这个状态的存在是为了让玩家在新飞船放置到屏幕上后有足够的时间放松。

GameController 从 PlacingSpaceship 状态开始,获得一条生命并在屏幕上放置一艘飞船。GameController 将移动到 Waiting 状态,然后移动到 SpawningEnemies 状态。
当一个 SpaceshipHit 事件到达时,我们进入 StoppingWave 状态。一旦屏幕上没有更多的 Asteroids,我们检查剩余的生命数量。如果它是 0,那么就是 GameOver,否则,我们转到 PlacingSpaceship(这会触发一个 LifeLost 游戏事件)然后再次移动到 Waiting 状态,直到我们可以移动到 SpawningEnemies。
让我们看看启动游戏的代码:
@Override
public void startGame(GameEngine gameEngine) {
mCurrentMillis = 0;
mEnemiesSpawned = 0;
mWaitingTime = 0;
for (int i=0; i<INITIAL_LIFES; i++) {
gameEngine.onGameEvent(GameEvent.LifeAdded);
}
mState = GameControllerState.PlacingSpaceship;
}
首先,我们重置所有计数器,然后我们对初始生命数量进行循环,发送游戏事件 LifeAdded。这个事件在这个类以及 LivesCounter 中被处理。
以这种方式添加生命的主要优点是,我们可以处理除了启动游戏之外的其他方式给出的生命,也就是说,任何额外的生命。我们还有一个初始生命的值在单一位置。
最后,我们移动到之前描述的 PlacingSpaceship 状态。
作为一个重要的注意事项:由于我们是从 PlacingSpaceship 开始的,我们现在不再需要将玩家对象添加到 GameEngine 中,因为我们是从这个类中做的。我们必须从引擎的初始化中删除那段代码。
注意
GameController 现在负责将 Player 添加到游戏中。我们不再需要在 GameEngine 创建时手动添加它。
我们也可以在 StoppingWave 状态下启动 GameController。这样,在生命值被移除并添加之前,我们会有一段时间。尝试一下,看看你更喜欢哪一个。
接下来,让我们看看 GameController 的 onGameEvent 方法:
@Override
public void onGameEvent(GameEvent gameEvent) {
if (gameEvent == GameEvent.SpaceshipHit) {
mState = GameControllerState.StoppingWave;
mWaitingTime = 0;
}
else if (gameEvent == GameEvent.GameOver) {
mState = GameControllerState.GameOver;
}
else if (gameEvent == GameEvent.LifeAdded) {
mNumLives++;
}
}
这确实会响应 SpaceshipHit 事件,通过移动到 StoppingWave 状态,并且它也会重置等待时间。该状态将检查是否有剩余的生命值可以使用,如果需要,将触发 GameOver 事件。我们将在查看 onUpdate 时看到这一点。
该方法通过将自己置于 GameOver 状态来响应 GameOver 事件。目前,这个事件只由 GameController 发送,但为了未来的使用,这样很好。也许我们想要一个非常强大的敌人,它可以一次性结束游戏。
最后,当 LifeAdded 到来时,它会增加生命值计数器。同样,这个事件仅在构建此类时发送,但我们可以实现一个额外的生命值机制,然后从其他地方触发它。
真正的核心在于 onUpdate 方法中。
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
if (mState == GameControllerState.SpawningEnemies) {
mCurrentMillis += elapsedMillis;
long waveTimestamp = mEnemiesSpawned * TIME_BETWEEN_ENEMIES;
if (mCurrentMillis > waveTimestamp) {
// Spawn a new enemy
Asteroid a = mAsteroidPool.remove(0);
a.init(gameEngine);
a.addToGameEngine(gameEngine, mLayer);
mEnemiesSpawned++;
return;
}
}
else if (mState == GameControllerState.StoppingWave) {
mWaitingTime += elapsedMillis;
if (mWaitingTime > STOPPING_WAVE_WAITING_TIME) {
mState = GameControllerState.PlacingSpaceship;
}
}
else if (mState == GameControllerState.PlacingSpaceship) {
if (mNumLifes == 0) {
gameEngine.onGameEvent(GameEvent.GameOver);
}
else {
mNumLives--;
gameEngine.onGameEvent(GameEvent.LifeLost);
Player newLife = new Player(gameEngine);
newLife.addToGameEngine(gameEngine, 2);
newLife.startGame(gameEngine);
// We wait to start spawning more enemies
mState = GameControllerState.Waiting;
mWaitingTime = 0;
}
}
else if (mState == GameControllerState.Waiting) {
mWaitingTime += elapsedMillis;
if (mWaitingTime > WAITING_TIME) {
mState = GameControllerState.SpawningEnemies;
}
}
}
你可以看到,我们之前有的代码现在是 SpawningEnemies 状态的代码。关于生成小行星没有新的内容。
要停止一个波,我们只需要等待几毫秒。由于没有新的 小行星 正在被生成,这个超时时间只需要比 小行星 穿过屏幕的时间长即可。我们也可以在它们返回池中时计数,但这种方法总是等待相同的时间,并且对玩家来说感觉更好。
PlacingSpaceship 状态只持续一个迭代周期。它要么发送一个 GameOver 事件(这将将其移动到 GameOver 状态),要么使用一个生命值,这包括:
-
发送
LifeLost事件 -
创建一个
Player对象,初始化它并将其添加到GameEngine -
转入
Waiting状态并重置等待时间
在这个时候,当 GameOver 事件发生时,我们不做任何事情,但我们在本章的第二部分会处理这个问题,通过显示一个游戏结束对话框。
最后,在 Waiting 状态下,我们计算毫秒数,就像我们在 StoppingWave 中做的那样,然后我们移动到 SpawningEnemies 状态。
这就是我们需要的,让 GameController 正确处理状态机。现在是时候转向 LivesCounter 了。
要在屏幕上显示生命值,我们需要在布局中添加一些视图,并实现处理它们的类。我们希望每个生命值都显示为飞船的图标,位于分数计数器下方左侧。
我们需要将以下代码添加到 fragment_game.xml 布局的左侧列:
<RelativeLayout>
[...]
<TextView
android:layout_marginTop="@dimen/activity_vertical_margin"
android:id="@+id/lives_title"
android:layout_below="@+id/score_value"
android:layout_alignLeft="@+id/score_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_color"
android:text="@string/lives" />
<LinearLayout
android:orientation="horizontal"
android:id="@+id/lives_value"
android:layout_marginTop="@dimen/menu_margin"
android:layout_below="@+id/lives_title"
android:layout_alignLeft="@+id/lives_title"
android:layout_width="@dimen/game_menu_width"
android:layout_height="wrap_content" />
</RelativeLayout>
我们有一个显示文本 "Lives"的TextView和一个水平方向的LinearLayout,当我们收到相应的 GameEvents时,我们将在其中添加和移除ImageViews`。
LivesCounter 的代码就像这样简单:
public class LivesCounter extends GameObject {
private final LinearLayout mLayout;
public LivesCounter(View view, int viewResId) {
mLayout = (LinearLayout) view.findViewById(viewResId);
}
@Override
public void startGame(GameEngine gameEngine) {}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {}
@Override
public void onDraw(Canvas canvas) {}
@Override
public void onGameEvent(GameEvent gameEvent) {
if (gameEvent == GameEvent.LifeLost) {
mLayout.post(mRemoveLifeRunnable);
}
else if (gameEvent == GameEvent.LifeAdded) {
mLayout.post(mAddLifeRunnable);
}
}
private Runnable mRemoveLifeRunnable = new Runnable() {
@Override
public void run() {
// Remove one life from the layout
mLayout.removeViewAt(mLayout.getChildCount()-1);
}
};
private Runnable mAddLifeRunnable = new Runnable() {
@Override
public void run() {
// Remove one life from the layout
View spaceship = View.inflate(mLayout.getContext(), R.layout.view_spaceship, mLayout);
}
};
}
如你所见,这个类并没有考虑到玩家拥有的生命值数量。它只通过向LinearLayout添加或移除项目来响应LifeAdded和LifeLost事件。
我们通过发布一个可运行的 runnable 对象来添加和移除视图,这与ScoreGameObject的原因相同:修改视图必须在UIThread中完成,而当我们使用SurfaceGameView时,onDraw确实在UpdateThread中运行。
最后一个部分是我们正在填充的布局view_spaceship.xml,它只是一个ImageView:
<?xml version="1.0" encoding="utf-8"?>
<ImageView
android:src="img/ship"
android:scaleType="fitCenter"
android:layout_width="@dimen/life_size"
android:layout_height="@dimen/life_size" />
在dimens.xml文件中,life_size维度被设置为 30dp,因此有足够的空间放置三个飞船。

自定义对话框
使我们的 UI 更美观的下一步是停止使用标准的 Android 对话框,而是创建我们自己的自定义对话框。再次强调,这完全是不被鼓励在应用程序中做的事情,但对于游戏来说却非常有意义。
有很多原因说明为什么自定义对话框系统更适合游戏:
-
默认对话框在不同版本的 Android 上看起来会有所不同。事实上,当你在应用程序中使用
AlertDialog时,这实际上是非常好的,因为它模仿了其他所有应用程序的对话框,但不是游戏的。我们想要一致性。 -
默认对话框使用系统字体。
-
如果你尝试设置自定义背景或自定义内容视图,
AlertDialogs看起来会很糟糕。同样,你绝对不应该在应用程序中这样做,但在游戏中你肯定想要。 -
标准对话框通过在屏幕上显示通知和系统栏来破坏沉浸式体验。
-
用自定义动画替换
AlertDialog的动画很困难。
我们将构建一个系统,在屏幕上显示我们的对话框,并在它们后面显示一个灰色半透明的覆盖层。这些对话框将使用由形状可绘制物制成的自定义背景,并使用我们为游戏选定的自定义字体。
最后,对话框本身只是我们放在内容前面的一个布局。
值得注意的是,当我们显示一个对话框而另一个新的对话框试图显示在顶部时,我们需要定义一个策略。在我们的游戏中,旧对话框将保持,新对话框将不会显示,但无论如何,我们将提供一个方法来关闭前一个对话框并显示新的对话框,以防你在游戏中想要这种行为。
我们首先将描述架构(包括对YassBaseFragment的修改),然后我们将从制作退出游戏的对话框开始,因为它是最简单的。之后,我们将替换当前的暂停对话框,最后我们将制作一个新的游戏结束对话框。
BaseCustomDialog
我们将要创建的自定义对话框框架由三个项目组成:
-
BaseCustomDialog:所有对话框的基础类 -
my_overlay_dialog:所有对话框的父布局 -
基础片段类中的实用函数
大部分工作都是在BaseCustomDialog类中完成的,它提供了setContentView、show和dismiss等方法。
BaseCustomDialog 类的完整代码如下:
public class BaseCustomDialog implements OnTouchListener {
private boolean mIsShowing;
protected final YassActivity mParent;
private ViewGroup mRootLayout;
private View mRootView;
public BaseCustomDialog(YassActivity activity) {
mParent = activity;
}
protected void onViewClicked() {
// Ignore clicks on this view
}
protected void setContentView(int dialogResId) {
ViewGroup activityRoot = (ViewGroup) mParent.findViewById(android.R.id.content);
mRootView = LayoutInflater.from(mParent).inflate(dialogResId, activityRoot, false);
mParent.applyTypeface(mRootView);
}
public void show() {
if (mIsShowing) {
return;
}
mIsShowing = true;
ViewGroup activityRoot = (ViewGroup) mParent.findViewById(android.R.id.content);
mRootLayout = (ViewGroup) LayoutInflater.from(mParent).inflate(R.layout.my_overlay_dialog, activityRoot, false);
activityRoot.addView(mRootLayout);
mRootLayout.setOnTouchListener(this);
mRootLayout.addView(mRootView);
}
public void dismiss() {
if (!mIsShowing) {
return;
}
mIsShowing = false;
hideViews();
}
private void hideViews() {
mRootLayout.removeView(mRootView);
ViewGroup activityRoot = (ViewGroup) mParent.findViewById(android.R.id.content);
activityRoot.removeView(mRootLayout);
}
protected View findViewById(int id) {
return mRootView.findViewById(id);
}
@Override
public boolean onTouch(View v, MotionEvent event) {
// Ignoring touch events on the gray outside
return true;
}
public boolean isShowing() {
return mIsShowing;
}
}
在构建过程中,我们将主活动的引用存储到成员变量 mParent 中,以便稍后使用。
类中还有一些其他的成员变量:
-
mRootLayout:这是我们为所有对话框充气的布局,它用作背景。 -
mRootView:内容视图的根。这用于显示和隐藏对话框本身。 -
mIsShowing:一个变量,用来确定对话框是否正在显示。
在 setContentView 期间,我们充气对话框的视图并将其引用存储在 mRootView 中,但我们还没有将其添加到任何 ViewGroup 中。对于充气,我们使用应用程序的内容视图的参数(android.R.id.content)。充气方法的最后一个参数用于确定系统是否应该将视图添加为父视图的子视图。我们传递 false,因此视图不会被添加到层次结构中。最后,我们将其应用到自定义字型上。
注意,setContentView 通常在构建期间调用,而不是在显示期间;这就是为什么在这个点上视图没有被添加到层次结构中的原因。
为了显示对话框,我们首先检查它是否已经被显示,如果是的话,我们就返回并什么都不做。否则,我们将 mIsShowing 设置为 true 并继续进行。
为了正确显示对话框,我们充气根布局并将其添加到主要内容中,然后我们将其根视图添加到其中。这使得对话框成为视图层次结构前景中的最前面项。
我们还将该类作为触摸监听器添加到 mRootLayout,但它什么都不做。这的目的是过滤掉对话框后面的所有触摸和点击事件。如果我们想让对话框在用户点击其边界之外时消失,我们只需在这个监听器内部添加对 dismiss 的调用即可。
最后,dismiss 首先检查对话框是否正在显示,如果是的话,它将移除视图层次结构中的视图。
我们还有如 findViewById 和 isShowing 这样的实用方法。
mRootLayout 的布局只是一个灰色的半透明覆盖层,其代码如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
android:background="#aa000000"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
注意,这已经设计得很容易添加动画到显示和取消对话框的过程(将在下一章中讨论)。这尤其适用于拥有完全灰色的背景,且与内容视图无关的情况。
我们对话框框架的最后一个阶段是在基础片段中提供一些实用方法,以方便使用我们的自定义对话框。
BaseCustomDialog mCurrentDialog;
public void showDialog (BaseCustomDialog newDialog) {
showDialog(newDialog, false);
}
public void showDialog (BaseCustomDialog newDialog, boolean dismissOtherDialog) {
if (mCurrentDialog != null && mCurrentDialog.isShowing()) {
if (dismissOtherDialog) {
mCurrentDialog.dismiss();
}
else {
return;
}
}
mCurrentDialog = newDialog;
mCurrentDialog.show();
}
public boolean onBackPressed() {
if (mCurrentDialog != null && mCurrentDialog.isShowing()) {
mCurrentDialog.dismiss();
return true;
}
return false;
}
我们将通过在片段上调用 showDialog 方法来显示对话框。这将存储当前显示的对话框的引用,可以用来确定是否还有其他内容正在显示,并在需要时将其取消。
默认情况下,如果屏幕上已经有一个对话框,我们不会显示对话框,但如果你想在游戏中这样做,showDialog有一个版本接受一个布尔参数,如果存在,则关闭其他对话框。
我们还负责处理返回键的按下,并使用它们来关闭显示的对话框,并返回true,以指示事件已被片段消耗。
现在我们已经有了所有的基础,让我们为我们的游戏创建一个退出对话框。
退出对话框
当在MainMenuFragment中按下返回键时,必须显示退出对话框。它将显示一些文本和两个按钮,用于退出或继续游戏。
这是一个简单的对话框,它将帮助我们理解在开始创建更复杂的对话框之前,所有这些部分是如何融入架构中的。
我们将在layouts文件夹下创建名为dialog_game_over.xml的文件来定义它。我们使用了一种命名约定,即所有对话框布局定义都以“对话框”开头(类似于片段和活动的约定)。
布局如下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_gravity="center"
android:background="@drawable/diablog_bg"
android:layout_width="@dimen/dialog_width"
android:layout_height="@dimen/dialog_height">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_color"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_centerHorizontal="true"
style="@android:style/TextAppearance.Large"
android:text="@string/exit_confirm"/>
<LinearLayout
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="@dimen/activity_vertical_margin"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
style="@style/iconButton"
android:layout_marginRight="@dimen/btn_sound_size"
android:id="@+id/btn_resume"
android:src="img/resume"/>
<ImageView
style="@style/iconButton"
android:id="@+id/btn_exit"
android:src="img/exit"/>
</LinearLayout>
</RelativeLayout>
这相当简单,但我们还有一些注意事项:
-
按钮将具有
btn_exit和btn_resume的 ID,并使用我们从免费图标集中获得的透明图像。 -
两个按钮都使用了我们在主页面上创建的
iconButton样式,它设置了圆形状态列表背景图像。 -
为了使按钮居中,我们将它们放置在一个自身在
RelativeLayout中居中的LinearLayout中。这是居中多个按钮的最简单方法。 -
我们使用
dialog_bg可绘制资源作为背景。它是一个类似于正方形按钮的形状可绘制资源,但颜色不同。 -
对话框的宽度和高度被提取为尺寸。它们将适用于所有对话框。我们将其设置为 400x250dp。
从BaseCustomDialog扩展的类同样很简单:
public class QuitDialog extends BaseCustomDialog implements View.OnClickListener {
private QuitDialogListener mListener;
public QuitDialog(YassActivity activity) {
super(activity);
setContentView(R.layout.dialog_quit);
findViewById(R.id.btn_exit).setOnClickListener(this);
findViewById(R.id.btn_resume).setOnClickListener(this);
}
public void setListener(QuitDialogListener listener) {
mListener = listener;
}
@Override
public void onClick(View v) {
if (v.getId() == R.id.btn_exit) {
dismiss();
mListener.exit();
}
else if (v.getId() == R.id.btn_resume) {
dismiss();
}
}
public interface QuitDialogListener {
void exit();
}
}
在构造函数中,我们设置按钮的监听器,然后在onClick方法中,如果点击了继续,则仅关闭对话框;如果点击了退出,则关闭对话框并退出。
这突出了我们将要使用的典型架构。对话框有一个自定义的监听器相关联,并且监听器中有一个方法对应于可以从对话框触发的每个特定操作。
注意
每个对话框都将有一个监听器,其中包含针对对话框提供的每个特定选项的方法。
使用接口而不是调用对话框的片段类,我们可以解耦功能,并从其他地方调用对话框。这本身也是一种良好的实践。
现在,MainFragment负责显示这个对话框,同时也是实现QuitDialogListener的类。让我们看看在片段中我们需要更改的代码来处理对话框:
@Override
public boolean onBackPressed() {
boolean consumed = super.onBackPressed();
if (!consumed){
QuitDialog quitDialog = new QuitDialog(getYassActivity());
quitDialog.setListener(this);
showDialog(quitDialog);
}
return true;
}
@Override
public void exit() {
getYassActivity().finish();
}
这相当简单。在按返回键的情况下,我们首先检查父片段是否处理了事件(也就是说,如果正在显示对话框)。如果返回 false,我们创建一个 QuitDialog,设置此片段为监听器,并使用基类的 showDialog 方法来显示它。
无论哪种方式,事件都会被这个片段消耗,所以 onBackPressed 总是返回 true。
由于 QuitDialogListener 接口,我们必须实现的方法是 exit,这就像获取父 Activity 并在其中调用 finish 一样简单。

暂停对话框
让我们做一些更复杂的事情。我们将用自定义对话框替换现有的暂停对话框,并且我们还将添加按钮来控制声音和音乐。
再次,我们有一些必须组合在一起的部件:
-
对话框的布局
-
一个扩展
BaseCustomDialog的类来处理它 -
对话框中执行的操作的监听器接口
-
在
GameFragment中的代码来处理它
让我们从布局开始。这个布局将有两个按钮组,一个在左下角用于退出和继续,另一个在右下角用于声音和音乐设置。
布局是一个简单的 RelativeLayout:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_gravity="center"
android:background="@drawable/diablog_bg"
android:layout_width="@dimen/dialog_width"
android:layout_height="@dimen/dialog_height">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_color"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_centerHorizontal="true"
style="@android:style/TextAppearance.Large"
android:text="@string/pause"/>
<ImageView
style="@style/iconButton"
android:id="@+id/btn_resume"
android:layout_margin="@dimen/activity_vertical_margin"
android:src="img/resume"
android:layout_alignParentBottom="true"
android:layout_alignParentLeft="true"/>
<ImageView
style="@style/iconButton"
android:id="@+id/btn_exit"
android:layout_alignBottom="@+id/btn_resume"
android:src="img/exit"
android:layout_toRightOf="@+id/btn_resume"/>
<ImageView
style="@style/iconButton"
android:id="@+id/btn_sound"
android:layout_margin="@dimen/activity_vertical_margin"
android:src="img/sounds_on_no_bg"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"/>
<ImageView
style="@style/iconButton"
android:id="@+id/btn_music"
android:layout_alignBottom="@+id/btn_sound"
android:src="img/music_on_no_bg"
android:layout_toLeftOf="@+id/btn_sound"/>
</RelativeLayout>
每个按钮都对齐到另一个按钮或对话框本身的左侧或右侧。鉴于我们已经选择了对话框框架和按钮的尺寸,我们可以确保四个按钮将很好地适应屏幕。我们也确信这个对话框在所有设备上看起来都一样。

暂停对话框本身的代码比 QuitDialog 的代码要复杂一些,主要是因为它有四个不同的操作;它还需要检查声音和音乐状态来决定使用哪个图像。
让我们深入代码:
public class PauseDialog extends BaseCustomDialog implements View.OnClickListener {
private PauseDialogListener mListener;
public PauseDialog(YassActivity activity) {
super(activity);
setContentView(R.layout.dialog_pause);
findViewById(R.id.btn_music).setOnClickListener(this);
findViewById(R.id.btn_sound).setOnClickListener(this);
findViewById(R.id.btn_exit).setOnClickListener(this);
findViewById(R.id.btn_resume).setOnClickListener(this);
updateSoundAndMusicButtons();
}
public void setListener(PauseDialogListener listener) {
mListener = listener;
}
@Override
public void onClick(View v) {
if (v.getId() == R.id.btn_sound) {
mParent.getSoundManager().toggleSoundStatus();
updateSoundAndMusicButtons();
}
else if (v.getId() == R.id.btn_music) {
mParent.getSoundManager().toggleMusicStatus();
updateSoundAndMusicButtons();
}
else if (v.getId() == R.id.btn_exit) {
super.dismiss();
mListener.exitGame();
}
else if (v.getId() == R.id.btn_resume) {
dismiss();
}
}
@Override
public void dismiss() {
super.dismiss();
mListener.resumeGame();
}
public void updateSoundAndMusicButtons() {
[...]
}
public interface PauseDialogListener {
void exitGame();
void resumeGame();
}
}
结构与之前相同。我们设置每个按钮的类作为点击监听器,并在 onClick 中相应地做出反应。我们已经隐藏了 updateSoundAndMusicButtons 的代码,因为它与 MainFragment 中的代码非常相似。
注意,PauseDialogListener 只关心退出和继续游戏。对声音状态的修改是通过 SoundManager 直接完成的,它通过父活动访问。
最后的部分是 GameFragment 中的修改;这基本上是将旧的 AlertDialog.Builder 替换为新的类,因为其他功能已经存在:
@Override
public boolean onBackPressed() {
if (mGameEngine.isRunning() && !mGameEngine.isPaused()){
pauseGameAndShowPauseDialog();
return true;
}
return super.onBackPressed();
}
private void pauseGameAndShowPauseDialog() {
if (mGameEngine.isPaused()) {
return;
}
mGameEngine.pauseGame();
PauseDialog dialog = new PauseDialog(getYassActivity());
dialog.setListener(this);
showDialog(dialog);
}
public void resumeGame() {
mGameEngine.resumeGame();
}
public void exitGame() {
mGameEngine.stopGame();
getYassActivity().navigateBack();
}
游戏结束对话框
让我们再来一个对话框,即 游戏结束 对话框,它将询问我们是在重新开始游戏还是退出到主菜单。为了处理 GameOverDialog,我们需要在 GameFragment 中添加一些代码来开始新游戏。我们将提取我们已有的代码到一个可以从片段中调用的方法中。
我们还需要从 GameEngine 的“某个地方”访问片段,以便在事件发生时显示对话框。我们将通过将片段传递给 GameController 来做到这一点。
该对话框本身与 PauseDialog 非常相似,所以我们不会在这里包含布局或代码,只包含我们为其定义的接口,我们称之为 GameOverDialogListener。
public interface GameOverDialogListener {
void exitGame();
void startNewGame();
}

让我们从对 GameFragment 的修改开始。这个类将实现 GameOverDialogListener。
事实上,GameFragment 已经有一个名为 exitGame 的方法,我们是从 PauseDialog 中调用的,所以唯一需要实现的是 startNewGame。
为了做到这一点,我们将创建 GameEngine 的所有逻辑提取到一个方法中,然后从游戏可以开始的两个地方调用它:
-
当片段创建和测量时调用的
onGlobalLayout方法 -
当在
GameOver对话框中点击 "再玩一次?" 按钮时,startNewGame。
代码如下:
@Override
public void startNewGame() {
// Exit the current game
mGameEngine.stopGame();
// Start a new one
prepareAndStartGame();
}
private void prepareAndStartGame() {
GameView gameView = (GameView) getView().findViewById(R.id.gameView);
mGameEngine = new GameEngine(getActivity(), gameView, 4);
mGameEngine.setInputController(new CompositeInputController(getView(), getYassActivity()));
mGameEngine.setSoundManager(getYassActivity().getSoundManager());
new ParallaxBackground(mGameEngine, 20, R.drawable.seamless_space_0).addToGameEngine(mGameEngine, 0);
new GameController(mGameEngine, GameFragment.this).addToGameEngine(mGameEngine, 2);
new FPSCounter(mGameEngine).addToGameEngine(mGameEngine, 2);
new ScoreGameObject(getView(), R.id.score_value).addToGameEngine(mGameEngine, 0);
new LivesCounter(getView(), R.id.lives_value).addToGameEngine(mGameEngine, 0);
mGameEngine.startGame();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
InputManager inputManager = (InputManager) getActivity().getSystemService(Context.INPUT_SERVICE);
inputManager.registerInputDeviceListener(GameFragment.this, null);
}
}
当我们开始游戏时,我们首先必须停止上一个游戏。即使 GameOverDialog 在前面,GameEngine 也不会暂停或停止。实际上,我们可以看到透视背景在对话框后面移动。这是一个很好的效果,我们希望保持这种效果,但这意味着在开始新游戏之前我们需要停止当前游戏,否则我们将有两个引擎同时运行,这对性能和电池寿命都是灾难性的。
注意,初始化过程中没有创建 Player。这是我们在使 GameController 管理生命值时移除的,但再次强调这一点是值得的。
另一方面,我们需要能够在 GameController 内部显示对话框,因为显示对话框的实用方法是在基础片段中实现的。这就是为什么我们需要向构造函数传递一个参数并将其存储为成员变量的原因。
public GameController(GameEngine gameEngine, GameFragment parent) {
mParent = parent;
[…]
}
@Override
public void onGameEvent(GameEvent gameEvent) {
[...]
else if (gameEvent == GameEvent.GameOver) {
mState = GameControllerState.GameOver;
showGameOverDialog();
}
}
private void showGameOverDialog() {
mParent.getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
GameOverDialog quitDialog = new GameOverDialog(mParent);
quitDialog.setListener(mParent);
mParent.showDialog(quitDialog);
}
});
}
作为处理 GameOver 事件的一部分,我们创建并显示 GameOverDialog。请注意,由于游戏事件在 UpdateThread 中到达,而对话框的创建和显示必须在 UIThread 上运行,因此我们需要使用 Runnable 对象。
其他对话框
游戏中还有两个我们尚未处理的对话框。这些对话框是:
-
控制器连接的通知对话框
-
触摸控制信息对话框
这两个对话框只是一个图像,点击它们应该关闭它们,因此既没有复杂的布局,也不需要自定义监听器。
然而,我们将在第十章 "迈向大屏幕" 中处理与控制器连接的对话框,因为我们希望在不同的环境下显示它:在电视上而不是在手机上。
为多个屏幕尺寸设计
虽然我们已经通过使用适当的屏幕单位使我们的 GameView 在所有屏幕尺寸和纵横比中均匀缩放,但 Android 视图并不这样做;然而,我们可以遵循标准 Android 程序来处理它们。
对于应用程序,建议根据屏幕尺寸调整布局。对于游戏,保持所有屏幕尺寸相同的布局并仅调整某些项目的尺寸和边距是有意义的。
注意
游戏应保持所有屏幕尺寸相同的布局。
按照惯例,你应该始终使用RelativeLayout来设计你的布局,其中屏幕上的每个项目都是相对于其他项目定位的。这允许布局平滑地适应所有屏幕尺寸。这个概念是响应式设计的核心。
为了在所有屏幕尺寸上正确显示,我们将指定尺寸;这是 Android SDK 的一部分,也是推荐的做法。
注意
指定尺寸是应用程序和游戏的最佳实践。
根据屏幕尺寸选择不同的尺寸规范并动态应用资源,这是通过指定资源实现的。注意,自 Android 3.2 以来,使用normal、large和xlarge关键字进行资源指定已被视为过时(尽管这是查看文档时首先看到的内容)。
根据屏幕尺寸,有三种方法来指定资源:
-
最小宽度:无论设备是横屏还是竖屏,都取设备的最小宽度。这在需要保证任何尺寸的最小大小时很有用。它不会在屏幕旋转时改变。
-
可用屏幕宽度:当前屏幕宽度。注意,当方向改变时,这会发生变化,其他资源,如布局,也可能发生变化。
-
可用屏幕高度:与上一个选项互补。
使用这些资源的方法是创建一个带有后缀的resources目录,例如values-sw720dp或values-w820dp。注意,这些后缀是在 dips 中定义的。
-
普通手机的大小在 320x480dp 到 480x800dp 之间,因为它们可以从 3.2 英寸到超过 5 英寸不等。
-
7 英寸平板电脑的起始分辨率大约为 600x960dp。
-
10 英寸平板电脑的典型分辨率至少为 720x1280dp。
我们将根据可用屏幕宽度指定一些尺寸,并将手机、小型平板电脑和大型平板电脑分开。我们将使用values-w820dp(由向导已创建)和values-w1024dp目录。
让我们看看我们为游戏定义的尺寸:
<resources>
<dimen name="square_button_padding">18dp</dimen>
<dimen name="square_button_border">6dp</dimen>
<dimen name="round_button_padding">6dp</dimen>
<dimen name="btn_round_size">60dp</dimen>
<dimen name="menu_margin">8dp</dimen>
<dimen name="game_frame_width">6dp</dimen>
<dimen name="game_menu_width">100dp</dimen>
<dimen name="life_size">30dp</dimen>
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="dialog_width">400dp</dimen>
<dimen name="dialog_height">250dp</dimen>
</resources>
注意,由于我们的TextViews使用的是标准的 Android 样式,我们无法指定文本大小。如果你使用自己的样式来设置文本,建议也指定文本大小。
我们不会修改触摸目标的大小。因此,我们将保持所有尺寸的方形和圆形按钮尺寸。
注意
触摸目标的大小不应随着屏幕尺寸的变化而变化。
当我们创建默认项目时,Android Studio 为w820dp定义了边距,所以我们也不需要修改这些。
游戏的边距和对话框的大小是我们需要更新的维度。仅此一项,就能让 7 英寸和 10 英寸的平板电脑看起来好得多。
对于手机来说,最糟糕的情况是宽度为 480 dp 的窄屏幕,因为它只给我们留下了 280 dp 的游戏区域。尽管如此,这仍然超过屏幕的 50%,看起来还不错。
另一方面,当游戏的宽高比过于宽时,布局开始看起来很糟糕。因此,对于w820dp,我们将使用 150 dp 作为边距,而对于w1280dp,我们将使用 200 dp。你可以轻松地在 Android Studio 的预览工具中调整这些值,直到你满意为止。在那些屏幕上,全尺寸也应该更大,因为三个它们的间距也更大。
我们也会在更大的屏幕上使对话框稍微大一些。
总的来说,values-w820dp将具有以下dimens.xml:
<dimen name="activity_horizontal_margin">64dp</dimen>
<dimen name="menu_margin">12dp</dimen>
<dimen name="game_menu_width">150dp</dimen>
<dimen name="life_size">45dp</dimen>
<dimen name="dialog_width">500dp</dimen>
<dimen name="dialog_height">300dp</dimen>
而对于values-w1080dp,我们将有这些:
<dimen name="menu_margin">16dp</dimen>
<dimen name="game_menu_width">200dp</dimen>
<dimen name="life_size">60dp</dimen>
<dimen name="dialog_width">600dp</dimen>
<dimen name="dialog_height">400dp</dimen>
这种技术允许我们修改 UI 以适应更大的屏幕,而无需触摸布局。

在为 w820dp 合格前后
摘要
我们已经学会了如何使用 Android 提供的标准工具、可绘制对象和视图来定制游戏的用户界面。这包括自定义字体、状态列表可绘制对象、形状可绘制对象,尤其是自定义对话框,它们以与我们的游戏外观和感觉相匹配的方式替换了默认对话框。
我们还修改了游戏以包括得分和多个生命值。我们修改了GameFragment的 UI 以显示这两个功能。为此,我们扩展了游戏事件系统,使其作为所有GameObjects的事件总线工作。
当游戏开始看起来完整时,它有时仍然感觉有点笨拙,这是因为我们没有使用任何动画。在下一章中,我们将学习 Android 提供的不同动画技术,并将它们应用到游戏中。
第八章。动画框架
Android 提供了一系列强大的 API 来将动画应用于 UI 元素。本章旨在提供概述,帮助您决定哪种方法最适合您的需求。
在我们开始添加动画之前,我们将对我们的代码进行一些重构,通过在布局完成后在我们的基础片段上创建一个回调,使动画的使用更加容易。
然后,我们将了解如何定义传统的帧动画,这些动画可以在ImageView中使用。我们还将了解如何将它们以AnimatedSprite的形式纳入我们的GameEngine中。
本章的核心是关于动画视图的不同方式。我们将从讨论插值器和它们在 Android 动画框架中的作用开始。然后,我们将了解旧的方法,即视图动画,并使用它来动画化游戏的一些区域,包括如何显示和隐藏我们的自定义对话框。
然后,我们将讨论ValueAnimator、PropertyAnimator,最后是ViewPropertyAnimator,解释它们如何比视图动画更不同、更灵活、更复杂,以及在哪些情况下每个都是首选的。我们还将做一些它们的例子。
最后,我们将使用不同的方法来动画化主屏幕的TextView,以便你可以检查它们的差异和相似之处。
更新BaseFragment
通常情况下,动画(尤其是ViewPropertyAnimator)需要在应用之前完成视图的布局。我们已经在GameFragment中有一个处理这个问题的方法,所以我们将对其进行泛化,使其成为BaseFragment的一部分。
该方法使用ViewTreeObserver来检查视图布局何时完成。我们将添加到BaseFragment的代码如下:
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
getYassActivity().applyTypeface(view);
final ViewTreeObserver obs = view.getViewTreeObserver();
obs.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public synchronized void onGlobalLayout() {
ViewTreeObserver viewTreeObserver = getView().getViewTreeObserver();
if (viewTreeObserver.isAlive()) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
viewTreeObserver.removeGlobalOnLayoutListener(this);
} else {
viewTreeObserver.removeOnGlobalLayoutListener(this);
}
onLayoutCompleted();
}
}
});
}
由于我们从GameFragment中移除了很多代码,新版本要简单得多:
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
view.findViewById(R.id.btn_play_pause).setOnClickListener(this);
}
@Override
protected void onLayoutCompleted() {
prepareAndStartGame();
}
通过这些修改,我们可以在本章后面添加动画时在MainMenuFragment中使用onLayoutCompleted。
AnimationDrawable
AnimationDrawable是你在 Android 中定义逐帧动画的方式。它将可绘制资源描述为一系列其他可绘制资源,这些资源按顺序播放以创建动画。这是最传统的动画:一系列独立图像,依次播放。
我们可以使用AnimationDrawable类在代码中定义动画的帧,但使用 XML 会更简单。此文件列出了组成动画的帧及其持续时间。XML 由一个<animation-list>类型的根节点和一系列定义帧的<item>类型子节点组成,这些节点使用可绘制资源和帧持续时间:
<?xml version="1.0" encoding="utf-8"?>
<animation-list
android:oneshot=["true" | "false"] >
<item
android:drawable="@[package:]drawable/drawable_resource_name"
android:duration="integer" />
</animation-list>
此 XML 文件属于你的 Android 项目的res/drawable/目录,因为它被视为可绘制资源。
注意
AnimationDrawable资源放置在drawable目录中。
让我们通过一个例子来看看。我们将制作一个简单的动画,使我们的宇宙飞船的灯光闪烁。为此,我们将使用四个帧:
-
关闭灯光(正常宇宙飞船)
-
打开左灯
-
关闭灯光(再次)
-
打开右灯
注意,我们可以为不同的帧重用相同的可绘制资源,从而节省一些空间。

我们宇宙飞船动画的四个帧
闪烁灯光的宇宙飞船定义如下:
<?xml version="1.0" encoding="utf-8"?>
<animation-list
android:oneshot="false">
<item android:drawable="@drawable/ship_2" android:duration="600" />
<item android:drawable="@drawable/ship_1" android:duration="400" />
<item android:drawable="@drawable/ship_2" android:duration="600" />
<item android:drawable="@drawable/ship_3" android:duration="400" />
</animation-list>
我们让灯光只亮 400 毫秒,然后 600 毫秒没有灯光。然后,我们切换到另一盏灯。
我们已经将oneShot设置为false。这意味着一旦最后一帧完成,动画将从开始处重复。如果你想要只播放一次的动画,你应该将oneShot设置为true。
为了测试这个,我们可以在主菜单布局中添加一个ImageView,并为其设置AnimationDrawable:
<ImageView
android:id="@+id/ship_animated"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_centerHorizontal="true"
android:src="img/ship_animated"
android:layout_below="@+id/btn_start"
/>
如果我们尝试这样做,我们会发现动画无法正常工作。AnimationDrawable不会自动播放。此外,需要注意的是,AnimationDrawable的start方法不能在活动的onCreate方法内部调用,因为AnimationDrawable尚未完全附加到窗口。我们必须等待窗口完全创建,这将在活动的onWindowFocusChanged方法中通知我们。
注意
动画可绘制资源不会自动播放,我们必须在代码中启动它们。
然而,动画可以从片段的onViewCreated方法启动。由于我们已经有稍后调用的onLayoutCompleted方法,我们将使用这个方法以保持一致性:
@Override
protected void onLayoutCompleted() {
ImageView iv = (ImageView) getView().findViewById(R.id.ship_animated);
((AnimationDrawable)iv.getDrawable()).start();
}
但这对我们来说还不够:AnimationDrawable定义了一个可以用于ImageView的逐帧动画。真正有趣的是能够使用相同的 XML 定义来描述动画精灵。为此,我们将创建一个新的类,它从Sprite扩展并处理动画。
动画精灵
要创建动画精灵,我们需要注意AnimationDrawable的细节。由于我们已经有在屏幕上绘制Sprite的所有代码,新的AnimatedSprite类只需负责计算时间以选择应该绘制的位图。
注意,这仅适用于所有帧都定义为位图的AnimationDrawable,我们的Sprite基类不支持其他 XML 资源,如形状。
让我们看看AnimatedSprite的代码:
public abstract class AnimatedSprite extends Sprite {
private final AnimationDrawable mAnimationDrawable;
private int mTotalTime;
private long mCurrentTime;
public AnimatedSprite(GameEngine gameEngine, int drawableRes, BodyType bodyType) {
super(gameEngine, drawableRes, bodyType);
// Now, the drawable must be an animation drawable
mAnimationDrawable = (AnimationDrawable) mSpriteDrawable;
// Calculate the total time of the animation
mTotalTime = 0;
for (int i=0; i<mAnimationDrawable.getNumberOfFrames(); i++) {
mTotalTime += mAnimationDrawable.getDuration(i);
}
}
@Override
protected Bitmap obtainDefaultBitmap() {
AnimationDrawable ad = (AnimationDrawable) mSpriteDrawable;
return ((BitmapDrawable) ad.getFrame(0)).getBitmap();
}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mCurrentTime += elapsedMillis;
if (mCurrentTime > mTotalTime) {
if (mAnimationDrawable.isOneShot()) {
return;
}
else {
mCurrentTime = mCurrentTime % mTotalTime;
}
}
long animationElapsedTime = 0;
for (int i=0; i<mAnimationDrawable.getNumberOfFrames(); i++) {
animationElapsedTime += mAnimationDrawable.getDuration(i);
if (animationElapsedTime > mCurrentTime) {
mBitmap = ((BitmapDrawable) mAnimationDrawable.getFrame(i)).getBitmap();
break;
}
}
}
}
我们创建了一个名为obtainDefaultBitmap的新方法,该方法从构造函数中调用。对于普通精灵,此方法仅返回位图。在AnimatedDrawable的情况下,我们将其初始化为第一帧。
构造函数具有与普通精灵相同的参数,但如果可绘制资源不是AnimationDrawable,则会抛出ClassCastException。为了使代码更易于理解,没有包含错误处理。
构造函数中做的另一件事是计算AnimationDrawable的总时间,即所有帧的持续时间之和。每次我们运行onUpdate时都需要这个值,因此我们应该提前获取它。
在onUpdate期间,我们将已过时的毫秒数添加到总时间,然后检查AnimatedSprite运行的总时间是否长于动画的总时间。如果是这种情况,我们检查AnimationDrawable是否设置为oneShot。如果是oneShot,我们不做任何事情,因为最后一帧已经设置。如果动画需要重复,我们只需通过应用模运算符使mCurrentTime回到间隔内。
一旦我们知道当前时间将在动画时间范围内,我们就遍历帧,检查哪一个是当前帧,并将这个帧中的位图设置到mImage成员变量中,这是基类用来在画布上绘制的。
在画布上绘制位图已经由父Sprite类完成了。
注意,所有从AnimatedSprite扩展的类都必须在重写onUpdate时调用 super 方法。否则,更新图像的代码将不会执行。
注意
当扩展AnimatedSprite时,不要忘记在重写onUpdate时调用 super。
现在,让我们为游戏中的飞船添加动画。
我们只需要将Player更新为从AnimatedSprite扩展,更改传递给构造函数的图像资源,并记得在onUpdate中调用 super 方法:
public class Player extends AnimatedSprite {
public Player(GameEngine gameEngine) {
super(gameEngine, R.drawable.ship_animated, BodyType.Circular);
[…]
}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
super.onUpdate(elapsedMillis, gameEngine);
[…]
}
}
我们有一个带有闪烁灯光的飞船!
动画化视图
Android 框架提供了两个动画系统:
-
查看动画
-
属性动画
视图动画自 Android 的第一个版本以来就存在了,而属性动画是在 Android 3.0 中引入的。后者是推荐的,因为它更一致,并且提供了更多功能。
视图动画系统只能用来动画化视图。它还受到限制,因为它只暴露了View对象的一些方面以进行动画化,例如视图的缩放和旋转,但不是例如背景颜色等。
视图动画系统的另一个缺点是它只修改视图的绘制位置,而不是实际的视图本身。例如,如果你将一个按钮动画化使其在屏幕上移动,按钮的绘制将是正确的,但按钮实际考虑的点击位置并没有改变,这可能会出现问题。
注意
View 动画修改的是视图绘制的地方,而不是视图本身。
相反,属性动画系统允许我们动画化任何对象的任何属性(视图和非视图),并且对象本身实际上是被修改的。
然而,视图动画系统更容易使用,并且需要的代码更少。如果视图动画能够完成你需要做的所有事情,就没有必要使用属性动画系统。
注意
视图动画更简单。属性动画更高级。
当使用ViewPropertyAnimation时,动画只接收一个最终值的参数,因为它是从当前值动画化的。这可能需要一些初始化。
总的来说,了解两个动画系统并应用最适合每个情况的系统是很好的。
不论是哪个系统,在 Android 中实现动画通常比较容易,但调整参数以使动画感觉正确则需要大量的工作。一个感觉不好的动画甚至比没有动画更糟,但一个正确的动画会使游戏感觉更加舒适和流畅。在处理它们时,请准备好投入大量的注意力到细节上。
注意
调整动画需要花费很多时间。
按照惯例,动画应该足够长以引起注意(否则,添加它们将是徒劳的),但又不至于让游戏感觉缓慢。这意味着过渡动画的持续时间应该在 300 到 400 毫秒之间。
XML 与代码
视图动画和属性动画(几乎任何资源)都可以在代码或 XML 中定义。除非你需要只能在运行时获得的一些值,否则最好使用 XML,因为所有文件都是外部于代码的,并且可以修改动画而无需触摸 Java 源代码。
将动画定义为资源也允许我们在代码的不同地方使用它们,并且可以确信任何动画的变化都会影响它被使用的地方。如果我们用代码定义动画,我们就必须检查动画构建的每个地方,或者依赖于实用类,这并不容易处理。
在一个中间地带,你可以使用 XML 定义动画,然后通过代码读取并修改一些参数。这种方法相当强大;它让我们在保持大多数定义在代码之外的同时控制动画。
插值器
动画系统在开始时间和结束时间之间播放动画。动画的每一帧都在开始和结束之间的特定时间显示。默认情况下,它遵循线性函数,但这可以改变。在游戏中,这种技术通常被称为缓动,但在 Android 中它被称为插值。让我们看看它是如何工作的。
注意
在通用游戏术语中,插值器相当于缓动动画。
动画使用时间索引来计算值。这个时间索引基本上是一个归一化时间,一个介于 0.0 和 1.0 之间的值。
在最简单的情况下,时间索引的值用于计算对象的转换。在转换的情况下,0.0 对应于起始位置,1.0 对应于结束位置,0.5 对应于起始和结束之间的一半。这正是线性插值器所做的事情。
通常,我们可以通过使用数学函数将时间索引转换成另一个值。这正是插值器所做的事情。
时间插值器本质上是一个函数,它接受一个介于 0.0 和 1.0 之间的值,并将其转换成另一个用于计算动画作为时间索引的值。
Android 提供了一套默认的插值器,涵盖了基本配置,应该足够用于大多数情况。如果你需要非常特殊的东西,你可以创建自己的插值器,只需要实现一个单方法接口。
我们不会进入数学函数的细节,而只是概述它们的外观。Android 中定义的插值器有:
-
线性:一个简单的线性函数。
-
循环:动画使用时间索引 1 作为完整圆周的正弦曲线。
-
弹跳: 当动画到达末端时,会弹跳几次。
-
减速: 动画在结束时减速。
-
加速: 动画在末端会变得更快。
-
加速减速: 动画在开始时加速,在结束时减速。
-
超调: 动画超过终点后返回。
-
预期: 在开始之前,动画会返回以获得一个脉冲。这与超调相反。
-
预期超调: 这结合了超调和预期。

Android 的不同插值器
为了遵循 Material Design 指南的动画,API 级别 21 添加了一些额外的插值器。对于游戏,我们并不真的需要它们。我们想要有趣、好看的动画;我们并不关心它们是否看起来真实,这是 Material Design 的核心特征。
插值器是一个常见概念,可以应用于我们将要处理的动画的所有方式。
视图动画
在 Android 中,动画视图的原始和简单方式是使用视图动画。这可以通过从 XML 加载或程序化创建一个Animation对象,然后将其应用于视图。它们相对容易设置,并且提供了足够的功能来满足大多数需求。
关于视图动画有一些重要的细节。它们如下:
-
当我们动画化一个视图时,视图的所有子视图也会受到影响。
-
无论你的动画如何移动或调整大小,动画视图的边界将不会自动调整以适应它。即便如此,动画仍然会超出其视图边界并不会被裁剪。然而,如果动画超出父视图的边界,将会发生裁剪。这可以通过在父视图中设置
clipChildren为false来修复。 -
动画完成后,视图将返回到其原始状态。如果你计划使用此类动画来显示或隐藏视图,你必须确保在动画开始之前和动画结束后将其可见性设置为所需状态。这可以通过使用监听器轻松实现。
-
视图的边界在动画过程中不会改变。这意味着无论视图绘制在何处,触摸区域都是相同的。这是我们想要使用属性动画而不是视图动画的最相关原因之一。
定义动画的文件必须放置在res/animation文件夹下,其定义如下:
<?xml version="1.0" encoding="utf-8"?>
<set
android:interpolator="@[package:]anim/interpolator_resource"
android:shareInterpolator=["true" | "false"] >
<alpha
android:fromAlpha="float"
android:toAlpha="float" />
<scale
android:fromXScale="float"
android:toXScale="float"
android:fromYScale="float"
android:toYScale="float"
android:pivotX="float"
android:pivotY="float" />
<translate
android:fromXDelta="float"
android:toXDelta="float"
android:fromYDelta="float"
android:toYDelta="float" />
<rotate
android:fromDegrees="float"
android:toDegrees="float"
android:pivotX="float"
android:pivotY="float" />
<set>
...
</set>
</set>
格式中定义的一些属性是位置。它们可以以三种不同的方式定义:
-
相对于默认位置的像素(例如 50)
-
相对于视图本身的百分比(例如 50%)
-
相对于父视图的百分比(例如 50%p)
不建议使用像素,相对于视图或父视图的百分比通常是更好的选择。
集合不过是一种将其他属性分组的方式。大多数时候,你只会使用一个,但它们可以嵌套以定义更复杂的动画。
该集合可以有一个插值器,如果将shareInterpolator属性设置为 true,则将应用于所有子元素。这允许所有动画流畅地一起流动。这通常是使用它的方式,但每个组件也可以有自己的插值器。
这些概念与我们之前在DrawThread上使用变换矩阵时使用的概念几乎相同。我们可以缩放、平移、旋转和修改透明度。
透明度是最简单的一个;它只有初始和最终值。
缩放接收两个轴上的初始和最终缩放值以及旋转中心点。这个旋转中心点是应用缩放的位置。它通常以百分比的形式提供。最常见的配置是将 50%放在两个轴上,这样它就从视图的中心开始生长。但其他配置也可以很好地工作,比如两个轴上都是 0%,这将使它从左上角开始生长。
平移接收两个轴上的原点和目标点的增量。它们也是位置,可以用相对于父视图的百分比来定义。
旋转接收从和到度数以及应用旋转的旋转中心点。请注意,这允许您从相对于父视图的位置或甚至视图本身之外的位置旋转视图,这可能很有用。
有一些属性是所有标签共有的。它们如下:
-
startOffset:允许我们定义一个偏移量,这样动画就不会立即开始。 -
duration:定义动画将持续多长时间。 -
repeatCount:允许我们使动画重复,无论是无限重复还是特定次数。 -
repeatMode:仅在重复时使用。它允许我们反转动画而不是从开始重复。 -
interpolator:要使用的插值器(如果shareInterpolator设置为 false)。
已知问题是在 XML 中为集合定义时repeatCount不起作用,尽管它对单个动画有效。然而,您可以在加载动画后通过代码设置repeatCount,这也有效。
注意
当在 XML 中为集合定义时,repeatCount将不起作用。
重复动画与startOffset的交互方式可能不太直观。偏移量被视为动画的一部分,因此它会被重复。我们将在本章后面看到这个例子。
同样,正如变换矩阵发生的情况一样,动画中定义的顺序非常重要。首先平移然后旋转的结果与首先旋转然后平移的结果不同。现在应该对每个人都很清楚。
动画对话框
我们将使用视图动画来动画化游戏中的对话框的显示和隐藏。
尽管我们未使用平台默认的动画效果来处理对话框,但建议在我们的游戏中保持一致性,并确保所有对话框都使用相同的动画。这就是为什么更改需要在单一位置进行,以确保所有对话框都使用相同的动画。
让我们看看我们需要对BaseCustomDialog进行哪些修改以添加动画:
public void show() {
if (mIsShowing) {
return;
}
mIsHiding = true;
[...]
startShowAnimation();
}
private void startShowAnimation() {
Animation dialogIn = AnimationUtils.loadAnimation(mParent, R.animator.dialog_in);
mRootView.startAnimation(dialogIn);
}
public void dismiss() {
if (!mIsShowing) {
return;
}
if (mIsHiding) {
return;
}
mIsHiding = true;
startHideAnimation();
}
private void startHideAnimation() {
Animation dialogOut = AnimationUtils.loadAnimation(mParent, R.animator.dialog_out);
dialogOut.setAnimationListener(this);
mRootView.startAnimation(dialogOut);
}
@Override
public void onAnimationEnd(Animation paramAnimation) {
hideViews();
mIsShowing = false;
onDismissed();
}
protected void onDismissed() {
}
我们有一个startShowAnimation方法,它在show方法结束时被调用,还有一个startHideAnimation方法,它在dismiss方法结束时被调用。
这两个方法都很简单;它们使用AnimationUtils加载一个Animation,然后使用startAnimation方法将其应用到mRootView上。
然而,有一些细节需要注释说明:
-
我们在动画开始前将视图添加到内容中,并在动画完成后移除它们,因此不需要更改它们的可见性。在其他情况下(当视图在动画完成后仍然保留在层次结构中时),你可能需要在
AnimationListener中更新视图的可见性。 -
BaseCustomDialog实现了AnimationListener接口,我们使用它来检测隐藏动画何时完成,以便在那个时刻移除视图。 -
我们有一个名为
onDismissed的新方法。这个方法在动画结束后被调用。到目前为止,对话框的关闭是一个瞬间操作。现在情况不再是这样了。在关闭对话框时执行的操作应该移动到onDismiss。 -
我们使用两个变量来确定对话框的状态:
mIsShowing和mIsHiding。从显示动画开始到关闭动画完成,对话框被认为是显示状态。然而,我们不应该关闭一个已经正在关闭的对话框,因此需要mIsHiding来防止这种情况发生。
动画本身是在 XML 中定义的,因此它们与对话框是否动画化无关。我们将查看几个对话框动画,以更深入地了解框架及其可能性。我们将使用成对的互补动画:
-
从中心放大 / 向中心缩小
-
从顶部进入 / 从顶部退出
要使对话框从中心放大和缩小,我们只需使用缩放即可。
从中心放大的代码如下:
<?xml version="1.0" encoding="UTF-8"?>
<set
android:interpolator="@android:anim/decelerate_interpolator"
>
<scale
android:fromXScale="0.5"
android:toXScale="1.0"
android:fromYScale="0.5"
android:toYScale="1.0"
android:pivotX="50%"
android:pivotY="50%"
android:duration="400"
/>
</set>
向中心缩小的定义如下:
<?xml version="1.0" encoding="UTF-8"?>
<set
android:interpolator="@android:anim/accelerate_interpolator">
<scale
android:fromXScale="1.0"
android:toXScale="0.5"
android:fromYScale="1.0"
android:toYScale="0.5"
android:pivotX="50%"
android:pivotY="50%"
android:duration="400"
/>
</set>
正如你所见,两种动画都很相似,但from和to的参数是相反的。
注意,我们从一个缩放值为 0.5 开始,而不是 0。较小的尺寸动画实际上并不明显,这主要是因为减速插值器,但如果你想的话,可以将其设置为 0。
我们也将旋转中心点设置为两个轴上的 50%。这就是它从中心缩放的原因。
一个有趣的变体是只在单一轴上应用缩放。这感觉就像视图是从屏幕中间展开的。这留作读者的练习。
另一对动画使用平移而不是缩放。我们将使对话框从顶部进入和退出,但更改代码使其使用屏幕的任何一边非常容易。
这是动画从顶部进入的定义:
<?xml version="1.0" encoding="utf-8"?>
<set
android:interpolator="@android:anim/overshoot_interpolator">
<translate
android:fromYDelta="-100%p"
android:toYDelta="0%p"
android:duration="500" />
</set>
这是通过顶部退出的代码:
<?xml version="1.0" encoding="utf-8"?>
<set
android:interpolator="@android:anim/anticipate_interpolator">
<translate
android:fromYDelta="0%p"
android:toYDelta="-100%p"
android:duration="500" />
</set>
注意,y增量是相对于父视图的百分比。进入动画从顶部开始,位于父视图大小的-100%(整个屏幕向上)。对于退出动画,我们只需反转增量。
最后,关于这些动画,还有一个重要的决定要做,那就是选择使用哪种插值器。最常用的配置有:
-
两个都是线性:简单,但有点无聊
-
减速显示/加速隐藏:这比线性更平滑,给人一种更专业的效果
-
超出显示/隐藏以预测:由于视图会超出结束位置然后返回,这使得动画感觉更加有趣
您可以使用动画和插值器的任何组合,或创建自己的外观和感觉。修改代码并尝试,直到您对结果满意。只需更改插值器,动画感觉就不同了。
在对话框中延迟动作到onDismissed
由于动画需要一些时间,当用户点击按钮时在对话框上执行的动作应延迟到动画完成。
要做到这一点,我们将存储被点击视图的 ID,然后在onDismissed方法中检查它以触发相应的操作。这是我们必须对每个对话框进行的更改。
让我们先看看我们需要对GameOverDialog进行的更改:
@Override
public void onClick(View v) {
mSelectedId = v.getId();
dismiss();
}
@Override
protected void onDismissed() {
if (mSelectedId == R.id.btn_exit) {
mListener.exitGame();
}
else if (mSelectedId == R.id.btn_resume) {
mListener.startNewGame();
}
}
简单吗?代码几乎和以前一样,但已从onClick移动到onDismiss,因此它会在稍后执行。
接下来,PauseDialog类似:
@Override
public void onClick(View v) {
[...]
else if (v.getId() == R.id.btn_exit) {
mSelectedId = v.getId();
super.dismiss();
}
else if (v.getId() == R.id.btn_resume) {
mSelectedId = v.getId();
super.dismiss();
}
}
@Override
protected void onDismissed () {
if (mSelectedId == R.id.btn_exit) {
mListener.exitGame();
}
else if (mSelectedId == R.id.btn_resume) {
mListener.resumeGame();
}
}
@Override
public void dismiss() {
super.dismiss();
mSelectedId = R.id.btn_resume;
}
这个案例稍微复杂一些,因为有一些按钮仍然会启动某些动作(音乐和声音),但不会关闭对话框。我们还添加了一个默认选中动作(在这种情况下是恢复),以便在用户关闭对话框时使用。
注意,onClick中的两个动作都明确调用super.dismiss()方法,以避免被默认动作覆盖。
最后,对于QuitDialog,我们又有同样的想法:
@Override
public void onClick(View v) {
mSelectedId = v.getId();
dismiss();
}
@Override
protected void onDismissed() {
if (mSelectedId == R.id.btn_exit) {
mListener.exit();
}
}
就这样。对话框已经动画化,动作在对话框关闭后执行。
跳动的按钮
让我们再添加一个使用动画视图的动画。我们将动画化按钮以启动游戏,使其在两个轴向上循环增长和缩小,模拟按钮的跳动。想法是它是一个“想要被点击”的按钮。
为了做到这一点,我们将使用组合动画。动画将在 X 和 Y 轴上缩放按钮,但动画将不同。X 将在动画的整个持续时间内增长,而 Y 只在第二部分增长。然后我们使动画以反向模式无限重复。
在 XML 中动画的代码如下:
<?xml version="1.0" encoding="utf-8"?>
<set
android:interpolator="@android:anim/accelerate_decelerate_interpolator">
<scale
android:fromXScale="1.0"
android:toXScale="1.2"
android:fromYScale="1.0"
android:toYScale="1.0"
android:pivotX="50%"
android:pivotY="50%"
android:duration="800"
android:repeatMode="reverse"
android:repeatCount="infinite"
/>
<scale
android:fromXScale="1.0"
android:toXScale="1.0"
android:fromYScale="1.0"
android:toYScale="1.1"
android:pivotX="50%"
android:pivotY="50%"
android:startOffset="300"
android:duration="500"
android:repeatMode="reverse"
android:repeatCount="infinite"
/>
</set>
如我们之前提到的,repeatCount 属性对 <set> 标签不起作用。我们可以在代码中实现它,但只需将其添加到每个动画中会更简单,因为我们只有两个。这就是为什么 repeatCount 和 repeatMode 都设置在 <scale> 标签上。
注意,infinite 关键字被接受为 repeatCount。我们不需要为此使用尴尬的常量。
重复与 startOffset 的交互有时是反直觉的。startOffset 的值将应用于每个迭代。在这个特定的情况下,这种行为很有用,因为我们希望动画在 y 轴上的开始时间晚于在 x 轴上的开始时间。但如果我们想要创建一个具有延迟开始的重复动画,它可能不会按预期工作。
注意
startOffset 是动画的一部分,它将被包含在每次重复中。
对于具有延迟开始的重复动画,最佳解决方案是使用不同的方法来添加初始延迟。Android 提供了 Timer/TimerTask 以及延迟发布 Runnable 的可能性,用于此目的。
将动画设置到视图中非常简单,只需在 MainMenuFragment 中写几行代码;一行用于加载 Animation,另一行用于启动它:
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
[…]
Animation pulseAnimation = AnimationUtils.loadAnimation(getActivity(), R.animator.button_pulse);
view.findViewById(R.id.btn_start).startAnimation(pulseAnimation);
}
随意调整参数,甚至让两个组件有不同的时间,这样它们可以相互抵消。示例中我们选择的值是为了让它非常明显;你可以通过使用更小的最终比例和/或更长的周期来让它更微妙,我推荐这样做。
属性动画
管理安卓中动画的第二种方式是在 Android 3.0(API 级别 11)中引入的。它设计得非常通用,因此可以处理任何对象的任何属性上的动画。该系统是可扩展的,并允许你动画化自定义类型的属性。
使用属性动画有很多种方法。最简单的一种是使用 ValueAnimator。这就像定义一个从某个值到另一个值的动画,有一个持续时间,可选的插值器。然后你添加一个监听器,每次有新值时都会被调用,最后你开始动画。
此代码将创建一个 ValueAnimator,在 1,000 毫秒内将浮点数从 0 动画到 42:
ValueAnimator animation = ValueAnimator.ofFloat(0f, 42f);
animation.setDuration(1000);
animation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
Float currentValue = (Float) animation.getAnimatedValue();
// Do something with the value
}
});
animation.start();
值动画本身并不修改值,但你可以在监听器的 onAnimationUpdate 方法中对动画值进行控制。
我们在 YASS 中不会使用任何 ValueAnimator,但它们对于其他类型的游戏非常有用。我们可以在需要变量从一值平滑过渡到下一值时使用它们。一些情况下,值动画对游戏很有趣,例如:
-
在完成一个等级后添加加分
-
在完成一个任务/击败对手后添加经验值
-
在受到打击后减少 HP 点数/在恢复药水后增加
通常,值动画器可以在我们想要平滑动画化任何值的时候使用。你甚至可以使用自定义进度条来显示值,并在ValueAnimator的回调中更新它。
如果你想让 Android 直接修改对象中属性的值,可以使用PropertyAnimator而不是ValueAnimator。对于视图的特定情况,我们有一个名为ViewPropertyAnimator的特殊类,它比PropertyAnimator更容易使用和阅读,并且专门设计用于动画化视图。
ViewPropertyAnimator
这种动画技术提供了一种简单的方法,通过单个底层的Animator对象并行动画化视图的多个属性。它还会修改视图属性的真正值。
使用ViewPropertyAnimator的一个缺点是它更有限。我们只能动画化视图的基本属性(位置、缩放、透明度和旋转),而使用PropertyAnimation我们可以动画化几乎任何东西。
预先提一下,这种动画技术只需要动画的最终值。它的目的是从视图的当前值开始动画。这意味着有时你可能需要将视图初始化到初始位置。
因为这种动画是通过修改视图的值来工作的,一旦动画完成,动画视图将保持在它们最终状态。这使得它们对于类似拼图或棋盘游戏非常有用。
注意
使用ViewPropertyAnimator动画化的视图在动画结束后将保持在最终位置。
ViewPropertyAnimator使用两个概念来获取视图绘制的坐标:位置和转换。你可以动画化位置或转换。如果你只使用其中一个,那么差别不大。只需记住,translateX将以视图的当前位置为原点(也称为[0,0]),视图将在其位置和转换的矢量和中绘制。
移动飞船
为了看到ViewPropertyAnimator的力量,我们将在主菜单中添加另一个动画。我们将使用之前用来显示逐帧动画的宇宙飞船,然后让它随机地在屏幕上移动。
我认为这种动画太多了,使得主菜单显得过于拥挤,所以我建议在最终游戏中移除它,但无论如何,它都是一个很好的框架工作示例。
因为每个动画都是从视图的先前位置开始的,所以最终效果是老框架无法实现的。
让我们看看代码:
@Override
protected void onLayoutCompleted() {
[...]
animateShip();
}
private void animateShip() {
View iv = getView().findViewById(R.id.ship_animated);
// Get a random position on the screen
Random r = new Random();
int targetX = r.nextInt(getView().getWidth());
int targetY = r.nextInt(getView().getHeight());
// Animate
iv.animate()
.x(targetX)
.y(targetY)
.setDuration(500)
.setInterpolator(new AccelerateDecelerateInterpolator())
.setListener(new Animator.AnimatorListener() {
@Override
public void onAnimationEnd(Animator animation) {
animateShip();
}
@Override
public void onAnimationStart(Animator animation) {}
@Override
public void onAnimationCancel(Animator animation) {}
@Override
public void onAnimationRepeat(Animator animation) {}
});
}
当布局完成时,我们调用一次animateShip,然后每次动画完成后都会再次调用它。
为了动画化飞船,我们获取要动画化的视图,然后使用Random和片段根视图的尺寸在屏幕上选择一个随机位置。
我们在视图中调用animate。这返回一个ViewPropertyAnimator类型的对象。我们可以使用这个对象上的不同方法来配置动画,并且每个方法都会返回这个对象,因此它们可以非常容易地链式调用,易于阅读的代码。
我们通过设置目标x和y位置(我们根本不触碰平移),选择持续时间,并设置AccelerateDecelerateInterpolator类型的插值器来配置动画。我们还设置了一个监听器,这样我们就可以在动画结束时得到通知,并可以调用animateShip来创建另一个动画。注意,AnimationListener是一个接口,即使我们不使用它,我们也必须实现它的所有方法。
最后,我们可以调用start来立即开始动画,但这不是必需的。
我们总是使用相同的动画持续时间,所以有时船只的移动速度会比其他船只快得多。我们可以通过使用起点和终点之间的距离来计算动画的持续时间,使其保持恒定速度。
重要的是要检查ImageView在布局中的位置,因为 z-index 是由顺序提供的。我建议你将船只直接放置在背景图像之后,这样它就会在标题和按钮后面。
动画主菜单
为了完成这一章,我们将对游戏标题和副标题进行动画处理。为了比较 Android 提供的不同动画可能性,我们将以三种不同的方式创建每个动画:XML 中的视图动画、代码中的ViewPropertyAnimation以及 XML 中的对象动画器。
首先,我们将对主标题进行动画处理,使其从屏幕左侧进入并到达其正常位置的中心。我们将使用弹跳插值器使其看起来更有趣。
作为良好的实践,我们将动画的开始偏移和持续时间作为整数外部化,使用名为integers.xml的文件,位于res/values文件夹下:
<integer name="tittle_start_offset">400</integer>
<integer name="tittle_duration">1600</integer>
动画不会立即开始,以给玩家一些时间真正注意到屏幕。我们有一个很长的持续时间,因为否则弹跳插值器看起来不好。
使用 XML 中的视图动画的第一版定义如下:
<?xml version="1.0" encoding="utf-8"?>
<set
android:interpolator="@android:anim/bounce_interpolator">
<translate
android:startOffset="@integer/tittle_start_offset"
android:fromXDelta="-100%p"
android:toXDelta="0%p"
android:repeatCount="0"
android:duration="@integer/tittle_duration" />
</set>
我们使用与父视图相关的百分比作为原始 delta 值,将视图完全移出屏幕。-100%p 意味着父视图宽度的 100%在左侧。
加载动画并开始它的代码应该放在onLayoutCompleted内部,并且非常简单:
Animation titleAnimation = AnimationUtils.loadAnimation(getActivity(), R.animator.title_enter);
title.startAnimation(titleAnimation);
注意,一旦我们调用startAnimation,动画就被认为是开始了。这意味着在开始偏移期间,平移也是动画化的,并设置为动画的初始值。
注意
对于视图动画器来说,开始偏移被认为是动画的一部分,其值在动画等待开始时设置为初始值。
让我们比较这个定义与代码中ViewPropertyAnimator的定义:
View title = getView().findViewById(R.id.main_title);
title.setTranslationX(-getView().getWidth());
int duration = getResources().getInteger(R.integer.tittle_duration);
int startOffset = getResources().getInteger(R.integer.subtitle_start_offset);
title.animate()
.translationX(0)
.setStartDelay(startOffset)
.setDuration(duration)
.setInterpolator(new BounceInterpolator())
.start();
由于持续时间和偏移量定义为整数,我们需要在运行动画之前获取它们。
由于视图最初放置的位置是我们希望它结束的位置,我们将对平移进行动画处理,并保持位置不变。
注意,由于ViewPropertyAnimator只接收最终值作为参数,我们需要将其设置为屏幕外的默认初始位置。为此,我们使用视图的setTranslationX方法。通过这样做,平移的最终值为 0。
最后,我们设置插值器并调用start。结果是与之前的方法相同,但如您所见,在程序中有一些显著的不同。
第三种方法是在 XML 中将相同的动画定义为对象动画器,然后加载并在代码中使用它。XML 定义如下:
<set
android:interpolator="@android:anim/bounce_interpolator">
<objectAnimator
android:interpolator="@android:anim/bounce_interpolator"
android:propertyName="translationX"
android:valueTo="0"
android:startOffset="@integer/tittle_start_offset"
android:duration="@integer/tittle_duration" />
</set>
<objectAnimator>标签本身是通用的,并使用propertyName作为通过反射修改的属性的名称。
一旦动画被定义,我们必须加载并启动它:
title.setTranslationX(-title.getX()-title.getWidth());
AnimatorSet set = (AnimatorSet) AnimatorInflater.loadAnimator(getActivity(), R.animator.title_enter_property);
set.setTarget(title);
set.start();
如前一个示例所示,我们需要对平移进行初始化。虽然我们可以在 XML 中设置valueFrom,但在定义 XML 时我们不知道屏幕的大小,我们也不能使用引用父视图的值,因此我们必须在代码中初始化它。
总的来说,这三种版本在概念上执行相同的动画,但它们的定义方式略有不同。
让我们看看另一个例子。对于字幕,我们将对 alpha 进行动画处理,使其在标题动画完成后出现。为了使这个动画在之前的动画之后运行,我们可以使用启动延迟,或者我们可以设置一个监听器到之前的动画,并在这个动画完成后启动它。
使用监听器更精确,但添加延迟要简单得多,所以我们将选择这种方法。就像我们为标题动画所做的那样,我们将定义一些用于持续时间和启动偏移的整数,在这种情况下,它是标题动画的持续时间和启动偏移量的总和:
<integer name="subtitle_start_offset">2000</integer>
<integer name="subtitle_duration">600</integer>
使用视图动画执行此操作的 XML 如下所示:
<set >
<alpha android:fromAlpha="0.0"
android:toAlpha="1.0"
android:startOffset="@integer/subtitle_start_offset"
android:duration="@integer/subtitle_duration"/>
</set>
运行它的代码也与之前看到的标题动画的代码非常相似:
Animation subtitleAnimation = AnimationUtils.loadAnimation(context, R.animator.subtitle_enter);
subtitle.startAnimation(subtitleAnimation);
再次强调,由于startOffset被视为动画的一部分,这非常方便,因为它允许我们在不接触视图的情况下将 alpha 设置为 0,持续前 2,000 毫秒。
让我们将其与ViewPropertyAnimation进行比较:
View subtitle = getView().findViewById(R.id.main_subtitle);
subtitle.setAlpha(0);
int subtitleDuration = getResources().getInteger(R.integer.subtitle_duration);
int subtitleStartOffset = getResources().getInteger(R.integer.subtitle_start_offset);
subtitle.animate()
.alpha(1)
.setDuration(subtitleDuration)
.setStartDelay(subtitleStartOffset)
.setInterpolator(new DecelerateInterpolator())
.start();
同样,与上一个例子非常相似,我们需要在视图中初始化 alpha 的值。我们还需要加载整数值以在配置中使用它们。
最后,相同的动画在 XML 中定义为PropertyAnimation:
<set >
<objectAnimator
android:interpolator="@android:anim/decelerate_interpolator"
android:propertyName="alpha"
android:valueFrom="0"
android:valueTo="1"
android:startOffset="@integer/subtitle_start_offset"
android:duration="@integer/subtitle_duration" />
</set>
然后,它被加载并分配给字幕视图:
subtitle.setAlpha(0);
AnimatorSet set = (AnimatorSet) AnimatorInflater.loadAnimator(context, R.animator.fade_in_property);
set.setTarget(subtitle);
set.start();
在这种情况下,我们还需要预先设置 alpha 值为 0,因为对于PropertyAnimator来说,起始偏移量不被视为动画的一部分。如果我们不设置它,它将保持为 1,直到动画开始运行。
通常来说,没有一劳永逸的解决方案,每种类型的动画更适合不同的场景。当使用简单的视觉效果使游戏看起来更美观时,视图动画器通常就足够了,而且也更容易设置。如果视图将要交互,并且你使用动画来转换游戏元素,那么PropertyAnimation(或其任何变体)是唯一的选择。
摘要
我们学习了如何进行逐帧动画,以及如何使用 Android 提供的两种不同的框架来动画化视图:视图动画和PropertyAnimation。
我们研究了它们之间的差异和局限性,并学会了何时使用哪一个。
我们已经对对话框和主菜单进行了动画处理。对于标题,我们看到了如何通过不同的方法达到相同的效果。
总的来说,游戏看起来更加流畅,因为现在很多内容都进行了动画处理。
我们可以说游戏已经完成,但我们还会添加一些新功能来使其更有趣。谷歌为我们提供了一个 API 来管理成就和排行榜。这两个功能都是 Google Play 服务的一部分,这就是我们接下来要做的!
第九章:集成 Google Play Services
谷歌提供 Google Play Services 作为在应用中使用特殊功能的方式。作为游戏服务子集,这是最吸引我们的一部分。请注意,Google Play Services 作为独立于操作系统的应用程序进行更新。这让我们可以假设大多数玩家都将安装最新的 Google Play Services 版本。
由于这个原因,越来越多的功能正从 Android SDK 迁移到 Play Services。
Play Services 不仅提供游戏服务,还有一个专门针对游戏的独立部分,即Google Play Game Services(GPGS)。这些功能包括成就、排行榜、任务、保存游戏、礼物,甚至多人游戏支持。
GPGS 还附带一个名为“Play Games”的独立应用程序,它向用户展示他们正在玩的游戏、最新的成就以及他们的朋友正在玩的游戏。这是一种非常有趣的方式来推广你的游戏。
即使作为一个独立的功能,成就和排行榜是现在大多数游戏都会使用的两个概念,所以为什么还要自己制作定制的呢,当你可以依赖谷歌提供的那些呢?
GPGS 可以在许多平台上使用:Android、iOS 和网页等。它在 Android 上使用得更多,因为它被包含在谷歌应用的一部分中。
在线有大量的分步指南文档,但这些细节散布在不同的地方。我们将它们汇总在这里,并为您提供官方文档的链接以获取更详细的信息。
对于本章,你应拥有一个开发者账户并能够访问 Google Play 开发者控制台。了解签名和发布应用的流程也是明智的。如果你不熟悉,官方文档在developer.android.com/distribute/googleplay/start.html有非常详细的说明。
GPGS 有两个方面:开发者控制台和代码。在讨论不同功能时,我们将交替使用这两个方面。
设置开发者控制台
现在我们正接近发布状态,我们必须开始使用开发者控制台。
我们需要做的第一件事是进入控制台的游戏服务部分来创建和配置一个新的游戏。在左侧菜单中,有一个标记为游戏服务的选项。这就是你需要点击的地方。一旦进入游戏服务部分,点击添加新游戏:

这将带我们到设置对话框。如果你在游戏中使用其他 Google 服务,如Google Maps或Google Cloud Messaging(GCM),你应该选择第二个选项并继续。否则,你只需填写我在游戏中还没有使用任何 Google API的字段并继续。如果你不知道你是否已经使用它们,你很可能没有。

现在,是时候将一个游戏链接到它了。我建议你先发布你的游戏作为 alpha 版本。这将让你在开始输入包名时从列表中选择它。
注意
在将其添加到游戏服务之前发布游戏到 alpha 通道会使配置变得容易得多。
如果你对你应用的签名和发布不熟悉,请查看官方文档developer.android.com/tools/publishing/app-signing.html。
最后,当我们链接第一个应用时,我们只需要进行两个步骤。我们需要授权它并提供品牌信息。授权将生成一个 OAuth 密钥——我们不需要使用它,因为它需要用于其他平台——以及一个游戏 ID。这个 ID 对所有链接的应用都是唯一的,我们将需要它来登录。但现在没有必要写下它,它可以在控制台中随时轻松找到。
注意
授权应用将生成游戏 ID,这个 ID 对所有链接的应用都是唯一的。
注意,我们添加的应用已配置为使用发布密钥。如果你继续并尝试登录集成,你会得到一个错误,告诉你应用是用错误的证书签名的:

你有两种方法来处理这个限制:
-
总是创建发布构建以测试 GPGS 集成
-
将你的调试签名游戏作为链接应用添加
我建议你将调试签名应用作为链接应用添加。为此,我们只需要链接另一个应用,并使用调试密钥的 SHA1 指纹进行配置。要获取它,我们必须打开终端并运行 keytool 实用程序:
keytool -exportcert -alias androiddebugkey -keystore <path-to-debug-keystore> -list -v
注意,在 Windows 中,调试keystore位于C:\Users\<USERNAME>\.android\debug.keystore。在 Mac 和 Linux 上,调试keystore通常位于~/.android/debug.keystore。

在游戏服务控制台中链接调试应用的对话框
现在,我们已经配置了游戏。我们可以在控制台中继续创建成就和排行榜,但我们将把它放在一边,确保我们能够登录并连接到 GPGS。
在游戏未发布时,只有测试者可以登录到 GPGS。你可以使链接应用的 alpha 和/或 beta 测试者成为游戏服务的测试者,你也可以手动添加电子邮件地址。你可以在测试选项卡中修改这些。
注意
只有测试账户可以访问未发布的游戏。
开发者控制台所有者的电子邮件已预先填充为测试者。以防你登录时出现问题,请再次检查测试者列表。
未发布的游戏服务不会出现在 Play 服务应用的源中,但可以进行测试和修改。这就是为什么在游戏本身准备好并同时发布游戏和游戏服务之前,将其保持在草案模式是一个好主意。
设置代码
我们需要做的第一件事是将 Google Play 服务库添加到我们的项目中。在创建项目时,向导应该已经完成了这项工作,但我建议你现在再次检查。
需要将库添加到主模块的build.gradle文件中。请注意,Android Studio 项目包含一个顶级build.gradle和每个模块的模块级build.gradle。我们将修改位于mobile模块下的那个。
确保在依赖项下列出了 play 服务库:
apply plugin: 'com.android.application'
dependencies {
compile 'com.android.support:appcompat-v7:22.1.1'
compile 'com.google.android.gms:play-services:7.3.0'
}
在撰写本文时,最新版本是 7.3.0。基本功能没有太大变化,并且不太可能改变。你可以强制 Gradle 使用库的特定版本,但通常我建议你使用最新版本。
一旦添加,保存更改并点击同步项目与 Gradle 文件。
为了能够连接到 GPGS,我们需要让游戏知道游戏 ID。这是通过AndroidManifest.xml中的<meta-data>标签完成的。你可以在这里硬编码值,但强烈建议你将其设置为 Android 项目中的资源。
我们将在 res/values 目录下创建一个新文件,命名为 play_services.xml。在这个文件中,我们将放置游戏 ID,但稍后我们也将把成就和排行榜 ID 放进去。使用单独的文件来存储这些值是推荐的,因为它们是常数,不需要翻译:
<application>
<meta-data android:name="com.google.android.gms.games.APP_ID" android:value="@string/app_id" />
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
[...]
</application>
添加此元数据非常重要。如果您忘记更新 AndroidManifest.xml,当您尝试登录 Google Play 服务时,应用将会崩溃。请注意,gms 版本的整数在库中定义,我们不需要将其添加到我们的文件中。
注意
如果您忘记将游戏 ID 添加到字符串中,应用将会崩溃。
现在,是时候进行登录了。这个过程相当繁琐,需要许多检查,因此 Google 发布了一个名为 BaseGameUtils 的开源项目,这使得它更容易实现。不幸的是,这个项目不是 play services 库的一部分,甚至不能作为一个库提供。因此,我们必须从 GitHub(无论是检出还是下载 ZIP 文件)获取它。
注意
BaseGameUtils 将我们从与 Play Services 的连接复杂性中抽象出来。
更为麻烦的是,BaseGameUtils 不能作为独立下载,必须与另一个项目一起下载。这个重要的代码片段不是官方库的一部分,这使得设置起来相当繁琐。为什么它会这样做,我自己都不理解。
包含 BaseGameUtils 的项目被称为 android-basic-samples,可以从 github.com/playgameservices/android-basic-samples 下载。
注意
添加 BaseGameUtils 并不像我们希望的那样简单。
一旦下载了 android-basic-samples,请在 Android Studio 中打开您的游戏项目。点击 文件 > 导入模块,并导航到您下载 android-basic-samples 的目录。在 BasicSamples/libraries 目录中选择 BaseGameUtils 模块,然后点击 确定。
最后,更新 build.gradle 文件中 mobile 模块的依赖项,并再次同步 gradle:
dependencies {
compile project(':BaseGameUtils')
[...]
}
在完成所有这些设置项目的步骤后,我们终于准备好开始登录了。
我们将使我们的主要 Activity 继承自 BaseGamesActivity,该类负责处理所有连接的交互,并使用 Google Play 服务进行登录。
另一个细节:到目前为止,我们使用 Activity 而不是 FragmentActivity 作为 YassActivity(BaseGameActivity 继承自 FragmentActivity)的基类,这个更改将会影响我们在调用 navigateBack 时的对话框行为。我们可以更改 BaseGameActivity 的基类或修改 navigateBack 以在片段导航层次结构上执行弹出。我推荐第二种方法:
public void navigateBack() {
// Do a pop on the navigation history
getFragmentManager().popBackStack();
}
这个实用类被设计用于与单活动游戏一起工作。它可以在多个活动中使用,但这并不简单。这也是将游戏保持在单个活动中的另一个好理由。
备注
BaseGameUtils 是设计用于单活动游戏的。
BaseGameActivity 的默认行为是在每次 Activity 启动时尝试登录。如果用户同意登录,登录将自动发生。但如果用户拒绝这样做,他们将被多次询问。
我个人觉得这很侵扰且令人烦恼,我建议你只提示登录一次 Google Play 服务(并且再次,如果用户注销)。我们始终可以在应用中提供一个登录入口。
这很容易更改。默认尝试次数设置为 3,这是 GameHelper 代码的一部分:
// Should we start the flow to sign the user in automatically on startup? If
// so, up to
// how many times in the life of the application?
static final int DEFAULT_MAX_SIGN_IN_ATTEMPTS = 3;
int mMaxAutoSignInAttempts = DEFAULT_MAX_SIGN_IN_ATTEMPTS;
因此,我们只需在我们的活动中进行配置,在 onCreate 期间添加一行代码来更改默认行为,以实现我们想要的行为:只需尝试一次:
getGameHelper().setMaxAutoSignInAttempts(1);
最后,有两种方法我们可以覆盖以在用户成功登录和出现问题时执行:onSignInSucceeded 和 onSignInFailed。我们将在本章末尾更新主菜单时使用它们。
进一步使用 GPGS 应通过 GameHelper 和/或 GoogleApiClient 进行,后者是 GameHelper 的一部分。我们可以使用 BaseGameActivity 的 getGameHelper 方法来获取 GameHelper 的引用。
现在用户可以登录 Google Play 服务,我们可以继续处理成就和排行榜。让我们回到开发者控制台。
成就
我们首先将在开发者控制台中定义几个成就,然后看看如何在游戏中解锁它们。请注意,要发布任何带有 GPGS 的游戏,您至少需要定义五个成就。没有其他功能是强制性的,但成就是。
备注
我们至少需要定义五个成就才能发布带有 Google Play Game 服务的游戏。
如果你想在没有任何成就的游戏中使用 GPGS,我建议你添加五个虚拟的秘密成就并让它们保持原样。
要添加一个成就,我们只需导航到左侧的 成就 选项卡并点击 添加成就:

添加新成就的菜单有几个字段,大部分都是自我解释的。它们如下:
-
名称:将要显示的名称(可以本地化为不同的语言)。
-
描述:将要显示的成就描述(也可以本地化为不同的语言)。
-
图标:成就的图标,作为 512x512 像素的 PNG 图像。这将被用于在列表中显示成就,并在解锁时生成锁定图像和在游戏中的弹出窗口。
-
逐步成就:如果成就需要完成一系列步骤,则称为逐步成就,可以用进度条显示。我们将有一个逐步成就来展示这一点。
-
初始状态:显示/隐藏取决于我们是否希望显示成就。当成就显示时,名称和描述是可见的,玩家知道他们需要做什么来解锁它。另一方面,隐藏成就是一个秘密,当解锁时可能会带来有趣的惊喜。我们将有两个秘密成就。
-
积分:GPGS 允许每个游戏有 1000 积分用于解锁成就。这些积分将在 Google Play 游戏玩家的个人资料中转换为经验值。这可以用来突出某些成就比其他成就更难,因此可以获得更大的奖励。一旦发布,就不能更改这些积分,所以如果你计划未来有更多的成就,请提前规划积分。
-
列表顺序:成就的顺序是显示的。并不总是遵循这个顺序,因为在 Play Games 应用中,解锁的成就会在锁定成就之前显示。重新排列它们仍然很有用。
![成就]()
在开发者控制台中添加成就的对话框
如我们之前决定的,我们的游戏将有五个成就,它们如下:
-
高分:在一局游戏中获得超过 100,000 分。这是在游戏中获得的。
-
小行星杀手:摧毁 100 颗小行星。这将跨不同游戏计算,是一个逐步成就。
-
幸存者:存活 60 秒。
-
目标达成:一个隐藏成就。连续击中 20 颗小行星,不得有失误。这是为了奖励那些只在应该射击时射击的玩家。
-
目标丢失:这是一个有趣的成就,当你连续 10 次射击失误时获得。它也是隐藏的,否则解锁会太容易。
因此,我们为它们创建了一些图片,并将它们添加到控制台中。

配置了所有成就的开发者控制台
每个成就都有一个字符串 ID。我们需要这些 ID 来在我们的游戏中解锁成就,但谷歌已经为我们简化了这一过程。我们在底部有一个名为获取资源的链接,它会弹出一个对话框,显示我们需要的字符串资源。我们可以直接从那里复制它们,并将它们粘贴到我们已创建的play_services.xml文件中。

架构
对于我们的游戏,鉴于我们只有五个成就,我们将直接将成就的代码添加到ScoreObject中。这将减少你的阅读代码量,我们可以专注于如何实现。然而,对于真正的生产代码,我建议你为成就定义一个专门的架构。
推荐的架构是有一个AchievementsManager类,在游戏开始时加载所有成就并将它们存储在三个列表中:
-
所有成就
-
已锁定成就
-
已解锁成就
然后,我们有一个Achievement基类,它有一个抽象的check方法,我们为每个实例实现它:
public boolean check (GameEngine gameEngine, GameEvent gameEvent)
{
}
这个基类负责从本地存储(我推荐使用SharedPreferences)加载成就状态,并根据check的结果进行修改。
成就检查是在AchievementManager级别通过checkLockedAchievements方法完成的,该方法遍历可以解锁的成就列表。这个方法应该作为GameEngine的onEventReceived方法的一部分被调用。
这种架构允许你在特定的地方检查尚未解锁的成就,以及游戏中包含的所有成就。
在我们这个例子中,因为我们把分数保存在ScoreGameObject中,所以我们将把所有成就的代码添加到那里。
注意,让GameEngine负责分数,并使其成为其他对象可以读取的变量,也是推荐的设计模式之一,但将其作为ScoreGameObject的一部分来实现更为简单。
解锁成就
为了处理成就,我们需要访问GoogleApiClient类的对象。我们可以在ScoreGameObject的构造函数中获取对其的引用:
private final GoogleApiClient mApiClient;
public ScoreGameObject(YassBaseFragment parent, View view, int viewResId) {
[…]
mApiClient = parent.getYassActivity().getGameHelper().getApiClient();
}
父级Fragment引用了Activity,Activity又引用了GameHelper,而GameHelper则引用了GoogleApiClient。
解锁一个成就只需要一行代码,但在尝试解锁成就之前,我们还需要检查用户是否连接到 Google Play 服务。这是必要的,因为如果用户未签名,则会抛出异常,导致游戏崩溃。
注意
解锁一个成就只需要一行代码。
但这个检查还不够。在边缘情况下,当用户手动从 Google Play 服务中注销(这可以在成就屏幕中完成)时,连接不会关闭,也没有办法知道他或她是否已经注销。
我们将创建一个实用方法来解锁成就,这个方法会执行所有检查,并将解锁方法包装在一个try/catch块中,如果抛出异常,则使 API 客户端断开连接:
private void unlockSafe(int resId) {
if (mApiClient.isConnecting() || mApiClient.isConnected()) {
try {
Games.Achievements.unlock(mApiClient, getString(resId));
} catch (Exception e) {
mApiClient.disconnect();
}
}
}
即使进行了所有检查,代码仍然非常简单。
让我们专注于为游戏定义的特定成就。尽管它们非常具体,但跟踪游戏事件和变量以及检查解锁成就的方法本身是通用的,并且作为一个真实世界的例子,展示了如何处理成就。
我们设计的成就要求我们统计一些游戏事件以及运行时间。对于最后两个成就,我们需要为子弹未击中的情况创建一个新的GameEvent,这是我们之前尚未创建的。触发这个新GameEvent的Bullet对象中的代码如下:
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mY += mSpeedFactor * elapsedMillis;
if (mY < -mHeight) {
removeFromGameEngine(gameEngine);
gameEngine.onGameEvent(GameEvent.BulletMissed);
}
}
现在,让我们在ScoreGameObject内部工作。我们将有一个方法,每次击中小行星时检查成就。当发生该事件时,可以解锁三个成就:
-
高分,因为击中小行星会给我们加分
-
目标已锁定,因为它需要连续击中小行星
-
小行星杀手,因为它统计了被摧毁的小行星总数
代码如下:
private void checkAsteroidHitRelatedAchievements() {
if (mPoints > 100000) {
// Unlock achievement
unlockSafe(R.string.achievement_big_score);
}
if (mConsecutiveHits >= 20) {
unlockSafe(R.string.achievement_target_acquired);
}
// Increment achievement of asteroids hit
if (mApiClient.isConnecting() || mApiClient.isConnected()) {
try {
Games.Achievements.increment(mApiClient, getString(R.string.achievement_asteroid_killer), 1);
} catch (Exception e) {
mApiClient.disconnect();
}
}
}
我们检查总分数和连续击中次数来解锁相应的成就。
“小行星杀手”成就是一个有点不同的情况,因为它是一个增量成就。这类成就没有unlock方法,而是有一个increment方法。每次我们增加值,成就的进度就会更新。一旦进度达到 100%,它就会自动解锁。
备注
增量成就自动解锁,我们只需增加它们的值。
这使得增量成就比跟踪本地进度更容易使用。但我们需要像为unlockSafe所做的那样进行所有检查。
我们正在使用一个名为mConsecutiveHits的变量,我们尚未初始化它。这是在onGameEvent中完成的,这是检查其他隐藏成就目标丢失的地方。这里也进行了一些“幸存者”成就的初始化:
public void onGameEvent(GameEvent gameEvent) {
if (gameEvent == GameEvent.AsteroidHit) {
mPoints += POINTS_GAINED_PER_ASTEROID_HIT;
mPointsHaveChanged = true;
mConsecutiveMisses = 0;
mConsecutiveHits++;
checkAsteroidHitRelatedAchievements();
}
else if (gameEvent == GameEvent.BulletMissed) {
mConsecutiveMisses++;
mConsecutiveHits = 0;
if (mConsecutiveMisses >= 20) {
unlockSafe(R.string.achievement_target_lost);
}
}
else if (gameEvent == GameEvent.SpaceshipHit) {
mTimeWithoutDie = 0;
}
[…]
}
每次我们击中一颗小行星,我们都会增加连续击中小行星的次数并重置连续未击中的次数。同样,每次我们错过一枪,我们都会增加连续未击中的次数并重置连续击中的次数。
作为一条旁注,每次太空船被摧毁时,我们都会重置时间而不死亡,这被用于“幸存者”模式,但这并不是唯一需要更新无死亡时间的情况。我们在游戏开始时也要重置它,并在onUpdate内部通过仅添加已过去的时间毫秒来修改它:
@Override
public void startGame(GameEngine gameEngine) {
mTimeWithoutDie = 0;
[…]
}
@Override
public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
mTimeWithoutDie += elapsedMillis;
if (mTimeWithoutDie > 60000) {
unlockSafe(R.string.achievement_survivor);
}
}
因此,一旦游戏自开始以来运行了 60,000 毫秒,或者自太空船被摧毁以来,我们就解锁“幸存者”成就。
通过这样,我们就有了解锁游戏为我们创建的成就所需的全部代码。让我们以对系统和开发者控制台的一些评论来结束这一部分:
-
作为一条经验法则,你可以在发布到生产之前编辑成就的大部分细节。
-
一旦你的成就被发布,就不能删除。你只能在预发布状态下删除成就。成就屏幕底部有一个标有删除的按钮用于此操作。
-
您还可以在成就处于草稿状态时重置成就进度。这个重置会一次性对所有玩家生效。在成就屏幕底部有一个标记为重置成就进度的按钮用于此操作。
还要注意,GameBaseActivity做了很多日志记录。所以,如果您的设备连接到您的计算机,并且您运行的是调试版本,您可能会看到它有时会卡顿。这种情况不会在发布版本中发生,因为日志已经被移除。
排行榜
由于 YASS 只有一个游戏模式和游戏中的一个分数,因此只在一个 Google Play Game Services 上有一个排行榜是有意义的。排行榜在开发者控制台的游戏服务区域内的自己的标签中管理。
与成就不同,您不需要有任何排行榜就可以发布您的游戏。
如果您的游戏有不同的难度级别,您可以为每个难度级别创建一个排行榜。如果游戏有几个衡量玩家进度的值,您也可以为每个值创建一个排行榜。

在 Play Games 控制台管理排行榜
排行榜可以在排行榜标签中创建和管理。当我们点击添加排行榜时,会看到一个表单,其中包含几个需要填写字段。它们如下:
-
名称: 排行榜的显示名称,可以是本地化的。我们将简单地称其为
High Scores。 -
分数格式化: 这可以是数值、货币或时间。我们将为 YASS 使用数值。
-
图标: 一个 512x512 像素的图标,用于识别排行榜。
-
排序: 数值越大越好 / 数值越小越好。我们将使用数值越大越好,但其他分数类型可能像赛车游戏一样是数值越小越好。
-
启用篡改保护: 这会自动过滤掉可疑的分数。您应该保持这个选项开启。
-
限制: 如果您想限制在排行榜上显示的分数范围,您可以在这里进行设置。我们不会使用这个功能。
-
列表顺序: 排行榜的顺序。由于我们只有一个,对我们来说并不重要。
![排行榜]()
在 Play Games 控制台设置排行榜
现在我们已经定义了排行榜,是时候在游戏中使用它了。就像成就一样,我们有一个链接,可以获取游戏中所有资源的 XML。因此,我们继续获取排行榜的 ID,并将其添加到play_services.xml文件中定义的字符串中。
我们必须在游戏结束时提交分数(即GameOver事件),也当用户通过暂停按钮退出游戏时。为了统一,我们将创建一个新的GameEvent,称为GameFinished,它在GameOver事件之后和用户退出游戏后触发。
我们将更新GameEngine的stopGame方法,该方法在两种情况下都会被调用以触发事件:
public void stopGame() {
if (mUpdateThread != null) {
synchronized (mLayers) {
onGameEvent(GameEvent.GameFinished);
}
mUpdateThread.stopGame();
mUpdateThread = null;
}
[…]
}
在发送事件后,我们必须将updateThread设置为 null,以防止此代码被运行两次。否则,我们可能会发送每个分数多次。
同样,对于成就,提交分数非常简单,只需一行代码。但我们也需要检查GoogleApiClient是否已连接,并且当抛出异常时,我们仍然会遇到相同的边缘情况。因此,我们需要将其包裹在try/catch块中。
为了保持一切都在同一个地方,我们将这段代码放在ScoreGameObject内部:
@Override
public void onGameEvent(GameEvent gameEvent) {
[…]
else if (gameEvent == GameEvent.GameFinished) {
// Submit the score
if (mApiClient.isConnecting() || mApiClient.isConnected()) {
try {
Games.Leaderboards.submitScore(mApiClient, getLeaderboardId(), mPoints);
}
catch (Exception e){
mApiClient.disconnect();
}
}
}
}
private String getLeaderboardId() {
return mParent.getString(R.string.leaderboard_high_scores);
}
这非常直接。现在 GPGS 正在接收我们的分数,并负责创建每日、每周和所有时间排行榜的分数时间戳。它还使用你的 Google+圈子来显示你朋友的社会分数。所有这些都会自动为你完成。
最后缺少的部分是让玩家可以从主菜单打开排行榜和成就 UI,如果他们未登录,还可以触发登录。
打开 Play Games UI
为了完成成就和排行榜的集成,我们将在主菜单中添加按钮,以打开 GPGS 提供的原生 UI。
为了实现这一点,我们将在屏幕左下角放置两个按钮,与音乐和声音按钮相对。我们还将检查我们是否已连接;如果没有,我们将显示一个单独的登录按钮。
对于这些按钮,我们将使用 GPGS 的官方图像,这些图像可供开发者使用。请注意,在使用图标时,必须遵循品牌指南,并且它们必须以原始形式显示,不得修改。这也为支持 Play Games 的所有游戏提供了一致的外观和感觉。
由于我们已经看到了很多布局,我们不会包括另一个几乎与现有布局相同的布局。

带有查看成就和排行榜按钮的主菜单。
为了处理这些新按钮,我们将像往常一样,将MainMenuFragment设置为视图的OnClickListener。我们在与其它按钮相同的地方这样做,即在onViewCreated内部:
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
[...]
view.findViewById(R.id.btn_achievements).setOnClickListener(this);
view.findViewById(R.id.btn_leaderboards).setOnClickListener(this);
view.findViewById(R.id.btn_sign_in).setOnClickListener(this);
}
正如成就和排行榜所发生的那样,工作是通过接收GoogleApiClient对象的静态方法完成的。我们可以从BaseGameActivity中的GameHelper获取这个对象,如下所示:
GoogleApiClient apiClient = getYassActivity().getGameHelper().getApiClient();
要打开原生 UI,我们必须获取一个Intent,然后使用它启动一个Activity。使用startActivityForResult非常重要,因为一些数据需要在它们之间来回传递。
要打开成就 UI,代码如下:
Intent achievementsIntent = Games.Achievements.getAchievementsIntent(apiClient);
startActivityForResult(achievementsIntent, REQUEST_ACHIEVEMENTS);

这将直接生效。它会自动将解锁成就的图标变灰,为正在进行的成就添加计数器和进度条,并为隐藏的成就添加一个锁。
同样,要打开排行榜 UI,我们从Games.Leaderboards类获取一个意图:
Intent leaderboardsIntent = Games.Leaderboards.getLeaderboardIntent(
apiClient,
getString(R.string.leaderboard_high_scores));
startActivityForResult(leaderboardsIntent, REQUEST_LEADERBOARDS);
在这种情况下,我们请求一个特定的排行榜,因为我们只有一个。我们可以使用getLeaderboardsIntent代替,这将打开所有排行榜的 Play Games UI。
注意
我们可以有一个意图来打开排行榜列表或特定的一个。
需要完成的事情是在用户未连接时替换登录按钮。为此,我们将创建一个读取状态并相应地显示和隐藏视图的方法:
private void updatePlayButtons() {
GameHelper gameHelper = getYassActivity().getGameHelper();
if (gameHelper.isConnecting() || gameHelper.isSignedIn()) {
getView().findViewById(R.id.btn_achievements).setVisibility(View.VISIBLE);
getView().findViewById(R.id.btn_leaderboards).setVisibility(View.VISIBLE);
getView().findViewById(R.id.btn_sign_in).setVisibility(View.GONE);
}
else {
getView().findViewById(R.id.btn_achievements).setVisibility(View.GONE);
getView().findViewById(R.id.btn_leaderboards).setVisibility(View.GONE);
getView().findViewById(R.id.btn_sign_in).setVisibility(View.VISIBLE);
}
}
此方法根据状态决定是否删除或显示视图。我们将在重要的状态更改方法中调用它:
-
onLayoutCompleted:我们第一次打开游戏以初始化 UI 时。 -
onSignInSucceeded:当用户成功登录到 GPGS 时。 -
onSignInFailed:当我们自动登录且没有连接时可以触发。处理它很重要。 -
onActivityResult:当我们从游戏 UI 返回时,如果用户已经注销。
但没有事情像看起来那么简单。事实上,当用户注销且未退出游戏时,GoogleApiClient保持连接打开。因此,GameHelper中的isSignedIn的值仍然返回 true。这是我们一直在本章中讨论的边缘情况。
由于这个边缘情况,UI 显示成就和排行榜按钮时应该显示登录按钮。
注意
当用户从 Play Games 注销时,GoogleApiClient保持连接打开。这可能导致困惑。
不幸的是,这已被 Google 标记为预期的工作。原因是连接仍然活跃,并且我们有责任在onActivityResult方法中解析结果以确定新状态。但这并不方便。
由于这是一个罕见的情况,我们将采取最简单的解决方案,即在try/catch块中包裹它,并在用户未登录时点击排行榜或成就时让用户登录。这是我们处理成就按钮点击的代码,但排行榜的代码是等效的:
else if (v.getId() == R.id.btn_achievements) {
try {
GoogleApiClient apiClient = getYassActivity().getGameHelper().getApiClient();
Intent achievementsIntent = Games.Achievements.getAchievementsIntent(apiClient);
startActivityForResult(achievementsIntent, REQUEST_ACHIEVEMENTS);
}
catch (Exception e) {
GameHelper gameHelper = getYassActivity().getGameHelper();
gameHelper.disconnect();
gameHelper.beginUserInitiatedSignIn();
}
}
基本上,我们有打开成就活动的旧代码,但我们将其包裹在try/catch块中。如果抛出异常,我们将断开游戏助手并使用beginUserInitiatedSignIn方法开始新的登录。
在我们再次尝试登录之前,非常重要地断开gameHelper。否则,登录将不会工作。
注意
在我们使用GameHelper中的方法登录之前,我们必须从 GPGS 断开连接。
最后,当用户点击登录按钮时,这仅仅触发使用GameHelper中的beginUserInitiatedSignIn方法进行登录:
if (v.getId() == R.id.btn_sign_in) {
getYassActivity().getGameHelper().beginUserInitiatedSignIn();
}
一旦您发布了游戏和游戏服务,成就和排行榜不会立即出现在 Google Play 的游戏描述中。需要“相当数量的用户”使用它们。您没有做错什么,您只需要等待。
Google Play 服务的其他功能
Google Play 游戏服务为游戏开发者提供了比成就和排行榜更多的功能。它们中的任何一个都不太适合我们正在构建的游戏,但了解它们的存在是有用的,以防万一您的游戏需要它们。通过使用它们而不是重新发明轮子,您可以节省大量的时间和精力。
Google Play 游戏服务的其他功能包括:
-
事件和任务:这些功能允许您监控游戏使用情况和进度。此外,它们还增加了创建有时间限制的事件并为玩家提供奖励的可能性。
-
礼物:正如其名,您可以给其他玩家发送礼物或请求别人给您发送礼物。是的,这是在几年前流行起来的非常机械的 Facebook 游戏中看到的。
-
保存的游戏:保存游戏的标准概念。如果您的游戏具有基于用户行为的进度或可以解锁内容,您可能希望使用此功能。由于它保存在云端,因此保存的游戏可以在多个设备上访问。
-
回合制和实时多人游戏:Google Play 游戏服务提供了一个 API,允许您实现回合制和实时多人游戏功能,而无需编写任何服务器代码。
如果您的游戏是多人游戏并且具有在线经济,可能值得自己建立服务器,并且只在服务器上授予虚拟货币以防止作弊。否则,破解礼物/奖励系统相对容易,一个人就可以破坏整个游戏经济。
然而,如果没有在线游戏经济,礼物和任务的好处可能比有人可以破解它们的事实更重要。
让我们逐一查看这些功能。
事件
事件的 API 为我们提供了一种定义和收集游戏度量指标并将其上传到 Google Play 游戏服务的方法。
这与我们已经在游戏中使用的GameEvents非常相似。事件应该是我们游戏游戏事件的一个子集。我们拥有的许多游戏事件都是作为对象之间的信号或同步机制在内部使用的。这些事件在引擎外部并不真正相关,但其他事件可能相关。那些是我们应该发送给 GPGS 的事件。
要从游戏中发送事件到 GPGS,我们必须首先在开发者控制台中创建它。
要创建一个事件,我们必须进入开发者控制台中的事件选项卡,点击添加新事件,并填写以下字段:
-
名称:事件的简短名称。名称最多可以有 100 个字符。此值可以进行本地化。
-
描述:事件的更详细描述。描述最多可以有 500 个字符。此值也可以进行本地化。
-
图标:事件的图标,标准大小为 512x512 px。
-
可见性:与成就一样,这可以是显示或隐藏。
-
格式:与排行榜一样,这可以是数值、货币或时间。
-
事件类型:这用于标记创建或消耗高级货币的事件。这可以是高级货币消耗、高级货币来源或无。
在游戏中,事件的工作方式几乎与增量成就相同。您可以使用以下代码行增加事件计数器:
Games.Events.increment(mGoogleApiClient, myEventId, 1);
只要事件没有被任务使用,您就可以删除处于草稿状态或已发布的事件。您还可以像处理成就一样重置您的事件测试者的玩家进度数据。
虽然事件可以用作分析系统,但它们真正的实用性在于与任务结合使用时。
任务
任务是一个挑战,要求玩家在特定时间段内完成事件多次以获得奖励。
因为任务与事件相关联,要使用任务,您至少需要创建一个事件。
您可以从开发者控制台中的任务选项卡创建任务。任务有以下字段需要填写:
-
名称:任务的简称。这可以最多包含 100 个字符,并且可以进行本地化。
-
描述:任务的较长描述。您的任务描述应让玩家知道他们需要做什么来完成任务。描述可以最多包含 500 个字符。前 150 个字符将在 Google Play Games 应用程序中显示的卡片等上对玩家可见。
-
图标:一个将与任务关联的方形图标。
-
横幅:一个用于推广任务的矩形图像。
-
完成标准:这是任务本身的配置。它由一个事件和事件必须发生的次数组成。
-
时间表:任务的开始和结束日期和时间。GPGS 使用您的本地时区,但以 UTC 存储值。玩家将看到这些值以他们的本地时区显示。您可以选择复选框以在任务即将结束时通知用户。
-
奖励数据:这针对每个游戏都是特定的。它可以是指定奖励的 JSON 对象。当任务完成时,这将被发送到客户端。
一旦在开发者控制台中配置完毕,您可以对任务执行以下两项操作:
-
显示任务列表
-
处理任务完成
要获取任务列表,我们通常通过静态方法启动一个提供给我们意图的活动:
Intent questsIntent = Games.Quests.getQuestsIntent(mGoogleApiClient,
Quests.SELECT_ALL_QUESTS);
startActivityForResult(questsIntent, QUESTS_INTENT);
当任务完成时,我们只需注册一个监听器即可收到通知:
Games.Quests.registerQuestUpdateListener(mGoogleApiClient, this);
一旦我们设置了监听器,一旦任务完成,onQuestCompleted 方法将被调用一次。在完成奖励的处理后,游戏应调用 claim 以通知 Play Game 服务玩家已领取奖励。
以下代码片段显示了您可能如何覆盖 onQuestCompleted 回调:
@Override
public void onQuestCompleted(Quest quest) {
// Claim the quest reward.
Games.Quests.claim(mGoogleApiClient, quest.getQuestId(),
quest.getCurrentMilestone().getMilestoneId());
// Process the RewardData to provision a specific reward.
String reward = new
String(quest.getCurrentMilestone().getCompletionRewardData(),
Charset.forName("UTF-8"));
}
奖励本身由客户端定义。正如我们之前提到的,这将使游戏变得非常容易破解并获得奖励。但通常,避免编写自己的服务器所带来的麻烦是值得的。
礼物
GPGS 的礼物功能允许我们向其他玩家发送礼物,并请求他们向我们发送礼物。这是为了使游戏玩法更具协作性,并提高游戏的社会性。
关于其他 GPGS 功能,我们提供了由库提供的内置 UI,可以用于。在这种情况下,用于向和从他们的 Google+圈子中的朋友发送和请求游戏内物品和资源的礼物。请求系统可以利用通知。
玩家可以使用 Google Play 游戏服务中的游戏礼物功能发送两种类型的请求:
-
一个愿望请求,用于向他们的朋友请求游戏内物品或某种形式的帮助
-
一个礼物请求,用于向他们的朋友发送游戏内物品或某种形式的帮助
玩家可以从默认的请求发送 UI 中指定一个或多个目标请求接收者。礼物或愿望可以被接收者接受(消费)或取消。
要详细了解礼物 API,您可以访问developers.google.com/games/services/android/giftRequests。
再次强调,关于任务奖励,这是完全由客户端完成的,这使得游戏容易受到盗版的影响。
保存游戏
保存游戏服务提供云游戏保存槽位。您的游戏可以检索保存的游戏数据,以便让回归玩家可以从任何设备继续他们在上次保存点停止的游戏。
此服务使玩家能够在多个设备之间同步游戏数据成为可能。例如,如果您有一个在 Android 上运行的游戏,您可以使用保存游戏服务允许玩家在他们的 Android 手机上开始游戏,然后在没有丢失任何进度的情况下在平板电脑上继续玩游戏。此服务还可以用于确保即使玩家的设备丢失、损坏、更换为新款或游戏重新安装,玩家的游戏玩法也能从上次停止的地方继续。
保存游戏服务不了解游戏内部结构,因此它提供了一个字段,该字段是一个非结构化的二进制 blob,您可以在其中读取和写入游戏数据。一个游戏可以为单个玩家写入任意数量的保存游戏,受用户配额限制,因此没有硬性要求限制玩家只能有一个保存文件。
注意
保存游戏是以非结构化的二进制 blob 形式进行的。
保存游戏的 API 还接收一些元数据,这些数据由 Google Play 游戏用于填充 UI 并在 Google Play 游戏应用中显示有用信息(例如,最后更新时间戳)。
保存的游戏有几个入口点和操作,包括如何处理保存游戏中的冲突。要了解更多信息,请查看官方文档developers.google.com/games/services/android/savedgames。
多人游戏
如果您要实现多人游戏,GPGS 可以为您节省大量工作。您可能或可能不会将其用于最终产品,但它将消除在游戏概念得到验证之前考虑服务器端的需求。
您可以使用 GPGS 来开发回合制和实时多人游戏。尽管每个都是完全不同的,并使用不同的 API,但总有一个初始步骤,即设置游戏并选择或邀请对手。
在回合制多人游戏中,一个共享状态在玩家之间传递,只有拥有回合的玩家才有权修改它。玩家根据游戏确定的播放顺序异步轮流进行。
玩家通过 API 调用明确完成一个回合。然后,将游戏状态连同回合一起传递给其他玩家。
有许多情况:选择对手、创建比赛、离开比赛、取消等等。官方文档developers.google.com/games/services/android/turnbasedMultiplayer非常详尽,如果您计划使用此功能,应该阅读它。
在实时多人游戏中,没有回合的概念。相反,服务器使用房间的概念:这是一个虚拟结构,它使得多个玩家在同一游戏会话中能够进行网络通信,并允许玩家直接向彼此发送数据,这是游戏服务器的常见概念。
注意
实时多人服务基于房间的概念。
实时多人游戏的 API 使我们能够轻松地:
-
管理网络连接以创建和维护实时多人房间
-
提供一个玩家选择用户界面,邀请玩家加入房间,寻找随机玩家进行自动匹配,或者两者的组合
-
在游戏运行时,将参与者和房间状态信息存储在 Play Game 服务的服务器上
-
向玩家发送房间邀请和更新
要查看实时游戏的完整文档,请访问官方网站developers.google.com/games/services/android/realtimeMultiplayer。
摘要
我们已经将 Google Play 服务添加到 YASS 中,包括在开发者控制台中设置游戏,并将所需的库添加到项目中。
然后,我们定义了一套成就,并添加了解锁它们的代码。我们使用了普通、递增和隐藏的成就类型来展示可用的不同选项。
我们还配置了排行榜,并提交了分数,无论是游戏完成时还是通过暂停对话框退出时。
最后,我们在主菜单中添加了链接到本地 UI 的排行榜和成就。
我们还介绍了事件、任务和礼物等概念,以及 Google Play 游戏服务提供的保存游戏和多人游戏功能。
游戏现在可以发布了。在下一章中,我们将看到如何让它运行在 Android TV 上。
第十章。迈向大屏幕
我们有一个在手机和平板电脑上运行良好的游戏。那么 Android TV 呢?
我相信 Android TV 在不久的将来有可能成为一个大型游戏平台。尽管其他系统尝试将 Android 游戏带到大屏幕上——即 OUYA、GameStick 和 Amazon FireTV——但有一个主要区别:Android TV 不是设计成可以插入电视的盒子,而是 SmartTV 中的操作系统。
这会带来很大的不同,因为人们会默认拥有它。我之前对 OUYA 持怀疑态度,直到我尝试了它。那是一次美妙的体验,但如果我不是 Android 游戏开发者,我可能就不会尝试它。Android TV 可以打破这个障碍。
此外,为 Android TV 添加支持相当简单,这可能让你能够触及更广泛的用户群体,所以,为什么不试试呢?
电视与手机不同,它们更大,没有触摸屏。它们通常从更远的距离观看。正因为如此,Android TV 有一个不同的用户界面,并且对应用程序在电视上运行有一些额外的要求。
将游戏移植到 Android TV 时的主要要求是支持横屏方向,并且可以使用控制器。我们已经做到了这一点。如果你的游戏不支持横屏或未设计为与游戏手柄一起使用,那么移植到 Android TV 将需要更多的工作,可能不是你时间的最佳投资;然而,如果你有横屏和控制器支持,那就去做吧!
项目配置
向项目中添加 Android TV 有两种不同的方法。它们在概念上是不同的,但你需要为这两种方法编写的代码几乎相同,尽管在不同的地方。根据你项目的特点,你可能更喜欢其中的一种。这些选项是:
-
为 Android TV 构建单独的 APK
-
使用相同的 APK 为手机、平板电脑和电视使用
Android Studio 的默认选项是创建一个适用于 Android TV 的构建变体。我们在使用向导设置项目并添加 TV 支持时,它就是这样做的。这就是为什么我们有mobile和tv目录。主要项目是mobile,而tv被配置为构建变体或“口味”。
Flavors 是构建不同 APK 的强大方式,允许我们修改代码或资源的一部分。当你必须在不同市场发布同一款游戏时,这尤其有用。另一方面,如果你有一个单个 APK,它必须能够自行适应不同的配置。
每种方法都有其优缺点。
你可能想要为 Android TV 创建一个单独的 APK 的原因包括:
-
为移动版本使用较低的目标 API(Android TV 需要目标 SDK 21 – Lollipop)
-
为电视使用更高分辨率的资源,而不将其包含在移动包中
-
为移动/电视提供分离的特殊功能
另一方面,拥有单个 APK 的主要优势包括:
-
更容易发布(只需上传一个文件)
-
更容易维护
由于我们不会为 Android TV 内置任何特殊功能,我们将采用单 APK 方法。请注意,如果你想要创建构建变体,修改几乎相同,但需要在tv目录中进行。
注意
在 Android TV 上发布需要目标 SDK 21。
官方文档建议我们包含Leanback库,该库需要minSDK版本 17。由于我们不是在构建一个应用,而是一个游戏,并且该库是关于 UI 的,我们可以忽略它并将minSDK保持为 15。
Android TV 的测试
有几种方法可以测试 Android TV 的构建。最明显且最简单的方法是使用模拟器。
Android TV 的模拟器——就像手机一样——是运行与真实设备完全相同操作系统版本的虚拟机。因此,它们非常可靠,但同时也相当慢。
当然,理想情况下,你应该能够在真实设备上测试你的游戏。在撰写本文时,有几款宣布搭载 Android TV 的智能电视,但最好的测试设备是 ADT-1(开发者套件)和 Nexus Player。
然而,ADT-1 供应短缺,难以获得。另一方面,Nexus Player 则相对便宜。
然而,除非你需要测试游戏玩法,否则导航可以通过模拟器轻松测试。
注意
除非你需要测试游戏玩法,否则模拟器应该足以测试游戏。
注意,模拟器不包括 Google Play 服务,但真实设备将包含它们。
我还注意到 ADT-1 的主题与模拟器略有不同,并且启动按钮上的文字颜色也不同。这不是什么大问题,也容易修复。
声明 TV Activity
Android TV 使用 Leanback 界面,该界面是为由遥控器控制的超大屏幕设计的。这个 UI 以与手机不同的方式显示应用和游戏。启动器也有所不同。正因为如此,应用和游戏需要声明一个具有特定 intent filter 的 Activity,以便 Leanback 启动器能够找到它们并使它们对用户可用。
此 intent filter 将声明应用可在 Leanback 界面上启动。
进行操作的最简单方法是创建一个新的 Activity,它扩展自我们的正常活动,并在必要时覆盖一些方法以适应电视界面。目前,我们将创建 YassTvActivity,它只是扩展 YassActivity:
public class YassTvActivity extends YassActivity {
}
然后,我们将使用适当的 intent 过滤器在 AndroidManifest.xml 中声明它:
<activity
android:name=".YassTvActivity"
android:label="@string/app_name"
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
如您所见,intent 过滤器几乎与正常启动活动的相同,只是类别有不同的名称(LEANBACK_LAUNCHER 而不是 LAUNCHER)。
注意,在 Android 上,您不能在清单中声明同一活动两次,但可以为同一活动声明多个 intent 过滤器。您可以使用相同的活动为移动和电视使用,如下所示:
<activity
android:screenOrientation="sensorLandscape"
android:name=".YassActivity"
android:hardwareAccelerated="false"
android:label="@string/title_activity_main" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
我们将使用一个独立的活动来允许对代码进行细微调整,但考虑到可能需要具有两个 intent 过滤器的相同活动,尽管我建议只有在你确定你不需要覆盖任何内容的情况下才使用它。
无法为电视定义限定符,这是 Android 团队有意为之,以强制响应式布局设计。唯一知道我们正在电视上运行的方法是程序性地检查 Leanback 功能或使用特定的电视活动并覆盖其上的某些方法。
提供主屏幕横幅
Android TV 应用必须在清单中声明横幅。这是移动应用不存在的要求之一。
横幅由 Leanback UI 用于在应用程序和游戏之间导航。它具有与图标不同的宽高比。特别是,横幅是一个 320x180 像素的图片,我们必须将其放在 drawable-xhdpi 目录下。

横幅是一个 320x180 像素的图片
这张图片对电视的重要性就像图标对移动设备的重要性一样,它是电视上游戏的入口点。虽然我们——作为开发者——可以在不提供横幅的情况下安装和运行电视上的应用程序,但没有横幅,从 Leanback UI 启动应用程序就没有简单的入口点。
要定义要使用的横幅图片,我们需要将其添加到 AndroidManifest.xml 中 <application> 标签的属性之一。
<application
[…]
android:banner="@drawable/banner_small" >
这个属性是在 Lollipop 中引入的,这也是我们需要使用目标 SDK 21 编译以便能够发布到 Android TV 的原因之一。

我们在模拟器 Leanback UI 中的游戏
声明为游戏
如您在之前的屏幕截图中所见,Leanback UI 将应用程序与游戏分开,我们的游戏列在应用程序中。我们正在开发一个游戏,并希望被归类为游戏。
这是 <application> 标签的另一个属性,它也是在 Lollipop 中为 Android TV 引入的;我们可以声明应用程序为游戏。
<application
[…]
android:isGame="true">
这将在 游戏 类别中显示横幅。

YASS 在 ADT-1 上的游戏类别中列出
声明 Leanback 支持
为了让 Google Play 在从 Android TV 搜索时列出我们的游戏,我们需要明确声明我们支持 Leanback UI。为此,我们必须声明我们使用 Leanback 功能:
<uses-feature android:name="android.software.leanback"
android:required="false" />
注意,我们声明我们使用它,但我们将其标记为非必需。这很重要,因为否则游戏将不会出现在没有 Leanback 功能的设备上。如果我们有两个单独的 APK,我们应该仅在 TV 构建变体上标记此功能为必需。
声明uses-feature意味着你的应用使用了它。将其标记为非必需意味着它可以不使用它,但如果存在,它将使用它。
注意
声明uses-feature意味着你的应用使用了它。将其标记为非必需意味着它可以不使用它。
如果你为移动和电视使用单独的 APK,你应该仅在 TV 构建变体上声明uses-feature,在移动版本上不做任何操作。通过这样做,TV APK 将仅出现在具有 Leanback UI 的设备上,而移动 APK 将仅出现在没有 Leanback 支持的设备上。
Leanback 功能的默认状态是不必需的。
声明触摸屏能力为非必需
由于uses-feature系统的工作方式,我们需要修复请求的功能,以便我们的游戏可以在 Android TV 上可用。本质上这意味着我们不能请求电视没有的功能,否则游戏将不会显示为对该设备可用。
这看起来并不简单,因为有些功能会自动包含,而无需做任何事情。
由于 Android 的特性,触摸屏能力默认是必需的,但如果我们不将其标记为非必需,游戏将不会在 Google Play 上列出,因为该设备没有触摸屏。
注意
在 Android 应用中,触摸屏功能默认是必需的。
这实际上很容易解决,我们只需要在AndroidManifest.xml中将触摸屏能力声明为非必需。
<uses-feature android:name="android.hardware.touchscreen"
android:required="false" />
注意,这也意味着游戏应该在没有触摸屏的情况下完全可用。为此,我们在本章后面有一个专门关于使用控制器处理菜单的特殊部分。
Android TV 上还有其他不可用的功能,如果你使用它们,必须确保将它们标记为非必需(并在它们不可用时处理这些情况)。这些功能包括:
-
触摸屏:
android.hardware.touchscreen -
触摸屏模拟器:
android.hardware.faketouch -
电信:
android.hardware.telephony -
相机:
android.hardware.camera -
蓝牙:
android.hardware.bluetooth -
近场通信(NFC):
android.hardware.nfc -
GPS:
android.hardware.location.gps -
麦克风:
android.hardware.microphone -
传感器:
android.hardware.sensor
对于游戏来说,触摸屏和传感器可能令人担忧,但有些游戏将位置和/或摄像头作为游戏不可或缺的一部分。当将游戏带到电视上时,这可能会成为决定性的因素。
注意
如果你的游戏依赖于 GPS 或摄像头,可能不适合在电视上运行。
此外,一些uses-permission声明隐含了对硬件功能的要求。隐含需要功能的权限是:
-
RECORD_AUDIO需要使用android.hardware.microphone。 -
CAMERA需要:-
android.hardware.camera -
android.hardware.camera.autofocus
-
-
ACCESS_COARSE_LOCATION需要:-
android.hardware.location -
android.hardware.location.network
-
-
ACCESS_FINE_LOCATION需要:-
android.hardware.location -
android.hardware.location.gps
-
如果你使用这些权限中的任何一项,你应该确保将适当的功能标记为非必需,并在不存在时相应地操作。
要检测一个功能是否存在,以便在运行时启用或禁用游戏的一些部分,你可以使用PackageManager的hasSystemFeature方法。如下所示:
getPackageManager().hasSystemFeature("android.hardware.touchscreen")
注意,getPackageManager在Context级别可用,因此你可以从Activity中访问它。
检查清单文件
一旦我们将 TV 的活动添加到 Leanback 启动器中,确保请求了 Leanback 和触摸屏功能但不是必需的,配置了横幅,并将其标记为游戏,我们就完成了对AndroidManifest.xml的修改。
更新后的清单文件如下所示:
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.plattysoft.yass" >
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:isGame="true"
android:banner="@drawable/banner_small"
android:theme="@style/AppTheme" >
<uses-feature android:name="android.software.leanback"
android:required="false" />
<uses-feature android:name="android.hardware.touchscreen"
android:required="false" />
<meta-data android:name="com.google.android.gms.games.APP_ID"
android:value="@string/app_id" />
<meta-data android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version"/>
<activity
android:screenOrientation="sensorLandscape"
android:name=".YassActivity"
android:hardwareAccelerated="false"
android:label="@string/title_activity_main" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".YassTvActivity"
android:label="@string/app_name" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
显示控制器说明
一个应用要获得 Android TV 的批准的特殊要求之一是提供一个对话框,指示控制器是如何映射的。为此,谷歌为我们提供了一个模板,我们可以使用,它可在网上找到:(developer.android.com/training/tv/games/index.html)。

控制器映射模板
我们已经有一个在控制器连接时显示的对话框,但我们从未将其更新为只是一个AlertDialog。现在是时候将其改为自定义对话框了。
对于手机,逻辑是在主菜单第一次加载并连接控制器时显示对话框。对于 Android TV,我们希望有不同的行为:我们希望在每次游戏打开时都显示对话框。
要做到这一点,我们将覆盖MainMenuFragment上的showControllerHelp方法,使用一个带有修改后的模板图像以包含我们游戏控制的自定义对话框。
至于我们已创建的其他自定义对话框,我们必须创建一个布局和一个类。布局非常简单,只是一个图像:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_gravity="center"
android:background="@drawable/dialog_bg"
android:layout_width="@dimen/dialog_width"
android:layout_height="@dimen/dialog_height">
<ImageView
android:id="@+id/controller_help_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:src="img/controller_help"/>
</RelativeLayout>
由于BaseCustomDialog的存在,这个类也非常简单:
public class ControllerHelpDialog extends BaseCustomDialog implements View.OnClickListener {
public ControllerHelpDialog(YassActivity a) {
super(a);
setContentView(R.layout.dialog_controller_help);
findViewById(R.id.controller_help_image).setOnClickListener(this);
}
@Override
public void onClick(View v) {
dismiss();
}
}
我们正在设置布局,然后处理图像的点击作为关闭。当使用控制器时,对话框只有在使用键B或返回键时才会关闭;我们将在本章后面处理对话框的正确键处理,并使其在点击A按钮时也关闭。
最后,我们需要对MainMenuFragment进行一些修改,以便显示这个新创建的对话框,并修改何时显示它的逻辑:
private void displayGamepadHelp() {
showDialog(new ControllerHelpDialog(getYassActivity()));
}
private boolean shouldDisplayGamepadHelp() {
PackageManager pm = getYassActivity().getPackageManager();
boolean isLeanback = pm.hasSystemFeature("android.software.leanback");
if (isLeanback) {
boolean shownAlready = YassActivity.sGamepadHelpShown;
YassActivity.sGamepadHelpShown = true;
return !shownAlready;
}
if (isGameControllerConnected()) {
return PreferenceManager.getDefaultSharedPreferences(getActivity())
.getBoolean(PREF_SHOULD_DISPLAY_GAMEPAD_HELP, true);
}
return false;
}
在shouldDisplayGamePadHelp方法中,我们将检查是否使用 Leanback 功能来了解我们是否在电视上。
如果 Leanback 功能不存在,我们将使用之前的相同代码:如果控制器已连接且之前未显示过对话框,则显示对话框。
如前所述,我们将保留为手机设计的逻辑,但对于电视,我们始终会显示对话框。
在 Android TV 的情况下,我们希望在应用程序打开时显示它。由于shouldDisplayGamepadHelp在片段的onResume方法中执行,我们将把已经显示的事实存储在一个静态变量中,这样在游戏结束后返回片段时,对话框就不会再次显示。然后,如果对话框尚未显示,我们返回 true。

如果你计划支持其他控制器或游戏机,如 MOGA、OUYA 或 Nvidia Shield,你应该为每个控制器提供不同的图像,并通过使用设备描述符检测哪个控制器已连接。在 OUYA 的特定情况下,由于它是一个不同的市场,你可以有一个带有特殊图形的另一个构建变体。
处理过扫描
由于电视标准的发展和始终向观众展示全屏画面的需求,电视布局有一些特殊要求。因此,电视设备可能会裁剪应用程序布局的外边缘,以确保整个显示区域被填满。这种行为通常被称为过扫描。
为了避免由于过扫描而裁剪屏幕元素,建议在布局的所有边上添加 10%的边距。当谈到 dips 时,这相当于左侧和右侧边缘的 48dp 边距,以及顶部和底部边缘的 27dp 边距。
虽然我们可以在所有设备上直接在布局中添加该填充,但我们将为 Android TV 创建特殊的布局,作为一个实际示例,展示如何操作。如前所述,没有为电视设置资源限定符;我们必须依赖于 Leanback 功能是否存在。
我们已经有一个在 Leanback 界面启动的特殊活动。我们还将为电视创建特殊片段,唯一的区别将是布局。为了将这两部分结合起来,我们将覆盖创建片段的方法,并在电视活动中替换它们。
这意味着我们需要首先将片段的创建提取到一个我们可以覆盖的方法中。对于GameFragment来说,这是在开始游戏时创建的,但对于MainMenuFragment来说则不是。该片段仅在活动的onCreate期间创建,因此我们将通过提取MainMenuFragment的创建到一个新方法中来重构代码。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_yass);
if (savedInstanceState == null) {
getFragmentManager().beginTransaction()
.add(R.id.container, createMenuFragment(), TAG_FRAGMENT)
.commit();
}
[...]
}
protected Fragment createMenuFragment() {
return new MainMenuFragment();
}
public void startGame() {
navigateToFragment(new GameFragment());
}
要将边距添加到游戏本身,我们将创建一个名为fragment_game_tv.xml的文件,作为GameTvFragment的布局,以下是其声明:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
android:layout_width="match_parent"
android:layout_gravity="center"
android:paddingTop="27dp"
android:paddingLeft="48dp"
android:paddingRight="48dp"
android:paddingBottom="27dp"
android:background="@color/background"
android:layout_height="match_parent">
<include layout="@layout/fragment_game" />
</FrameLayout>
我们只是在顶级放置一个FrameLayout,并添加必要的填充来处理过扫描,然后包含通常用于游戏的布局。
注意,我们使用填充而不是边距,因为我们希望背景成为FrameLayout的子项,以便填充布局的其余部分。
在代码方面,GameTvFragment的类也很简单:
public class GameTvFragment extends GameFragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_game_tv, container, false);
}
}
我们扩展GameFragment并重写onCreateView方法以使用我们刚刚创建的替代布局。由于正常布局已被导入,所有视图都保留了它们的 ID,对于GameTvFragment来说,不需要做其他任何事情。

带有额外填充以处理电视上过扫描的 GameFragment
由于布局中有一个ImageView作为背景图像,主菜单的情况稍微复杂一些。我们有两个可能的解决方案:
-
如前所述导入布局,并将背景图像的可见性设置为
GONE -
将所有通用布局提取到新文件中,并在移动设备和电视布局中导入它。
最后一个选项更容易阅读和维护,所以我们将以这种方式进行。
我们将所有布局信息移动到一个名为fragment_main_menu_common.xml的新文件中,这基本上是除了背景图像之外的所有布局,背景图像将放在其他每个布局中。
然后,我们为不同的布局创建了两个文件;用于移动设备的布局看起来像这样:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent" >
<ImageView
android:id="@+id/main_menu_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="img/seamless_space_0"/>
<include layout="@layout/fragment_main_menu_common" />
</FrameLayout>
简单到极致——我们用FrameLayout替换了顶级视图,添加了背景,并包含了通用元素。
Android TV 的布局基本上是相同的,但我们给导入的布局添加了边距:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
android:layout_width="match_parent"
android:layout_gravity="center"
android:layout_height="match_parent">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="img/seamless_space_0"/>
<include layout="@layout/fragment_main_menu_common"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="27dp"
android:layout_marginLeft="48dp"
android:layout_marginRight="48dp"
android:layout_marginBottom="27dp"/>
</FrameLayout>
注意,为了使边距与include标签一起工作,你还需要设置width和height,但幸运的是,Android Studio 会给我们一个警告。
进入代码,我们将在 TV 片段上实现不同的布局替换方式。我们将提取布局资源 ID 到一个可以被扩展类覆盖的方法中。我们将像这样对MainMenuFragment做这件事。这种技术在你想要你的片段在onCreateView期间做些工作时特别有用。
MainMenuFragment上的代码将稍作修改,以将布局资源提取到方法中:
@Override
public final View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(getLayoutResId(), container, false);
}
protected int getLayoutResId() {
return R.layout.fragment_main_menu;
}
由于我们提取布局是因为我们不希望覆盖onCreateView,所以我们也将它声明为 final。
现在,MainMenuTvFragment类就像这样:
public class MainMenuTvFragment extends MainMenuFragment {
@Override
protected int getLayoutResId() {
return R.layout.fragment_main_menu_tv;
}
}

应用过扫描边距的主菜单前后对比
最后,我们必须覆盖YassTvActivity中创建片段的特定方法。最后,我们在这个类中添加了一些代码:
public class YassTvActivity extends YassActivity {
public void startGame() {
// Navigate the the game fragment, which makes the start automatically
navigateToFragment( new GameTvFragment());
}
protected Fragment createMenuFragment() {
return new MainMenuTvFragment();
}
}
现在我们已经展示了如何以几种不同的方式解决过度扫描的问题,让我们继续到最后一个方面:使用控制器导航游戏。
基于控制器的导航
对于 Android TV 来说,最复杂的要求是游戏的所有选项都必须使用控制器可访问。如果您计划在控制台发布,您应该提前考虑,不要制作过于复杂的菜单或对话框。
注意
对于 Android TV,游戏中的所有选项都必须使用游戏手柄可访问。
这就是使用原生 Android UI 的好处。Android 框架自动处理布局元素之间的方向导航,所以原则上你不需要做任何额外的工作。然而,你仍然应该使用控制器测试导航,看看是否有任何导航问题。
用户还可以使用键盘上的箭头键(行为与使用 D-Pad 或轨迹球导航时相同)来导航您的应用程序。Android 根据屏幕上视图的布局提供了一个最佳猜测,关于在给定方向上哪个视图应该被给予焦点。然而,有时 Android 可能会猜错。
如果系统在导航时没有将焦点传递到我们想要的视图,我们可以通过指定以下属性来覆盖它,以确定哪个视图应该接收焦点:
-
android:nextFocusUp -
android:nextFocusDown -
android:nextFocusLeft -
android:nextFocusRight
一般而言,你应该始终让A按钮提供正面操作,让B按钮提供返回操作。一些控制器(如 ADT-1 上的控制器)提供专门的返回和主页按钮,它们也应该这样工作。所有这些都已经由 Android 为你处理。
注意
A按钮始终应该是正面操作,而B按钮应作为取消/返回操作。
在我们的案例中,YASS 存在一些问题,我们必须解决这些问题以提供完整的导航。这些问题很有趣,因为它们也是常见的陷阱。
如果我们现在运行游戏,我们可以看到主菜单上唯一可聚焦的控制是开始按钮。无法导航到任何其他视图。这是 Android 框架的一个不明显特性:ImageView默认不可聚焦。
注意
ImageView默认不可聚焦。
幸运的是,我们已经为圆形按钮定义了一种样式。我们可以在样式中将focusable设置为 true,它将应用于应用程序中的所有圆形按钮:
<style name="iconButton" >
<item name="android:background">@drawable/icon_button_bg</item>
<item name="android:layout_width">@dimen/btn_round_size</item>
<item name="android:layout_height">@dimen/btn_round_size</item>
<item name="android:padding">@dimen/round_button_padding</item>
<item name="android:focusable">true</item>
</style>
我们也可以使用ImageButton代替ImageView,因为ImageView默认是可聚焦的,但我认为使ImageView可聚焦更清晰。
现在我们可以使用控制器在菜单中导航,并且可以看到 Android 提供的默认导航是好的。下一步是检查对话框。
当我们与对话框交互时,会有一些副作用。由于它们不是标准的 Android 对话框,焦点可以移出对话框。此外,GameFragment使用一个游戏输入控制器,该控制器负责处理所有事件。我们需要做一些修复。
对话框和控制器
我们将逐个解决这些问题。第一个问题是焦点可以移动到对话框外的视图。
这可以通过打开游戏,在控制器帮助对话框存在时,将焦点移动到背景按钮上来轻松复制。同样的问题也存在于退出对话框中。在这两种情况下,我们可以以对话框在顶部的方式开始游戏,这是不正确的。
对于触摸,我们通过充当点击屏幕的背景视图来解决,该视图获取所有点击事件,并不让事件传递到其后的其他视图。然而,对于控制器导航,我们需要其他东西。
有几种方法可以解决这个问题。一种是在布局上重写导航;另一种是在显示对话框时忽略片段的onClick事件。我们选择另一种方法:将按键事件传递给对话框并在那里过滤它们。
这种方法也解决了第二个问题:在游戏中,输入控制器收集并消耗控制器按键。
由于所有的KeyEvent和MotionEvent处理都是在活动级别完成的,我们将进行一些重构,并将显示对话框的逻辑从片段移动到活动。
我们将把showDialog方法和变量mCurrentDialog从YassBaseFragment移动到YassActivity。
在基本片段中,我们只需用以下代码替换showDialog方法的代码:
public void showDialog (BaseCustomDialog newDialog, boolean dismissOtherDialog) {
getYassActivity().showDialog(newDialog, dismissOtherDialog);
}
下一步是对YassActivity的dispatchKeyEvent和dispatchGenericMotionEvent方法进行重构,使对话框优先于输入控制器。
dispatchKeyEvent的更新版本如下:
@Override
public boolean dispatchKeyEvent (KeyEvent event) {
if (mCurrentDialog != null && mCurrentDialog.isShowing()) {
if (mCurrentDialog.dispatchKeyEvent(event)) {
return true;
}
}
else if (mGamepadControllerListener != null) {
if (mGamepadControllerListener.dispatchKeyEvent(event)) {
return true;
}
}
return super.dispatchKeyEvent(event);
}
如果当前对话框不为空且正在显示,我们将在其上调用一个也称为dispatchKeyEvent的方法。如果按键事件被消耗(方法返回 true),则返回 true。
类似地,如果没有显示对话框,我们也会对游戏手柄控制器监听器进行同样的处理。如果事件被游戏手柄控制器消耗,我们也会返回 true。
最后,如果事件尚未被消耗,我们调用超类方法,这将正常处理事件。这包括在视图之间移动焦点。
同样,我们也会修改dispatchGenericMotionEvent。尽管我们不会在我们的对话框上重写任何这些事件,但如果您计划使用方向键自定义导航,您也必须注意模拟摇杆,并且您将需要这个方法来做到这一点:
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (mCurrentDialog != null && mCurrentDialog.isShowing()) {
if (mCurrentDialog.dispatchGenericMotionEvent(event)) {
return true;
}
}
else if (mGamepadControllerListener != null) {
if (mGamepadControllerListener.dispatchGenericMotionEvent(event)) {
return true;
}
}
return super.dispatchGenericMotionEvent(event);
}
最后,我们必须更新返回键的处理:
@Override
public void onBackPressed() {
if (mCurrentDialog != null && mCurrentDialog.isShowing()) {
mCurrentDialog.dismiss();
return;
}
final YassBaseFragment fragment = (YassBaseFragment) getFragmentManager().findFragmentByTag(TAG_FRAGMENT);
if (fragment == null || !fragment.onBackPressed()) {
super.onBackPressed();
}
}
如果我们在显示对话框,我们只需将其关闭。否则,我们保留之前的代码:我们要求当前片段处理返回键的按下,如果事件没有被消耗,我们将其传递给父类。
注意,将返回键事件传递给当前片段是很重要的。在我们的游戏中,当在GameFragment中按下返回键时,意味着我们想要暂停游戏。
为了完成这个,我们必须让对话框处理按键。BaseCustomDialog的默认实现是只返回 false,意味着事件没有被消耗:
public boolean dispatchKeyEvent(KeyEvent event) {
return false;
}
public boolean dispatchGenericMotionEvent(MotionEvent event) {
return false;
}
我们将进行一个非常简单的处理,其中我们只处理当对话框上的某个按钮被选中时的 OK 点击,但你也可以通过更复杂的方式来防止焦点移动到对话框外的视图。
对于ControllerHelpDialog,我们将在按下OK按钮时将其关闭,无论哪个视图处于焦点:
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_A ||
event.getKeyCode() == KeyEvent.KEYCODE_ENTER ||
event.getKeyCode() == KeyEvent.KEYCODE_DPAD_CENTER) {
dismiss();
return true;
}
return false;
}
注意,从控制器的角度来看,A按钮是OK按钮,但某些控制器也有 D-Pad 中心按钮,有时还有Enter键,所以我们接受任何这些按键事件作为OK。
在退出对话框的情况下,代码略有不同:
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_A ||
event.getKeyCode() == KeyEvent.KEYCODE_ENTER ||
event.getKeyCode() == KeyEvent.KEYCODE_DPAD_CENTER) {
if (findViewById(R.id.btn_resume).isFocused() ||
findViewById(R.id.btn_exit).isFocused()) {
// Return false, so a proper click is sent
return false;
}
return true;
}
return false;
}
在这个对话框中,每当收到 OK 事件时,我们检查是否有我们的按钮被聚焦。如果是这样,我们返回 false,意味着我们让上层处理它。这将把事件传递给活动,然后进行处理并返回点击。
如果我们的任何操作都没有被聚焦,我们返回 true,意味着我们正在消耗事件,不应该对它做任何其他处理。
在任何其他情况下,我们返回 false(再次,不消耗事件)以让活动正常处理按键事件。
最后,在GameFragment中显示的暂停和游戏结束对话框的情况下,我们不需要做任何事情,因为那个 UI 上唯一可聚焦的项目就是暂停按钮。
我们将要做的就是将暂停按钮设置为不可聚焦,然后让系统处理按键:
<ImageView
style="@style/iconButton"
android:focusable="false"
android:layout_gravity="top|right"
android:id="@+id/btn_play_pause"
android:layout_marginTop="@dimen/menu_margin"
android:layout_marginRight="@dimen/menu_margin"
android:src="img/pause" />
注意,虽然ImageView默认情况下不可聚焦,但我们已经在样式级别设置了该属性,并且我们可以在布局定义中覆盖样式的值。
经过所有这些更改,我们游戏的 UI 都可以用游戏手柄使用,我们终于准备好在 Android TV 上发布了。
超出这本书的范围
我们做到了!我们从头开始使用 Android SDK 构建了一个游戏。我们构建了一个具有单独的UpdateThread和DrawThread的游戏引擎,我们有可以移动的精灵,我们处理了触摸和游戏手柄控制器,并添加了碰撞检测、声音,甚至粒子。
我们还使用了 Android 框架的组件来构建我们的菜单和对话框,并利用动画工具使其更加动态。
YASS 还使用了 Google Play 游戏服务的成就和排行榜,现在它也准备好在 Android TV 上玩了。
到目前为止,有一些事情可以做以改进游戏,这也可以作为一个好的练习,帮助你前进,如果你愿意的话。一些想法包括:
-
生成多个难度递增的波
-
包含更大的小行星,当被击中时会破碎成更小的部分
-
添加船只选择:提供具有不同特性(速度、火率等)的几艘船
-
实现像护盾或改进的激光这样的增强功能
虽然这本书主要关注游戏开发,但制作游戏的其他方面也被忽视,但同样非常重要。其中最相关的是游戏设计和盈利模式。
注意
游戏设计和盈利模式是制作游戏的关键方面。
游戏设计是一个独立于平台和代码的学科,它需要对游戏本身进行高级考虑。游戏设计是关于制作一个有趣并能吸引用户的游戏。有许多书籍专门讨论这个概念,但这本书的范围不包括这个内容。
盈利模式现在也是一个很大的话题。如果你只是想为了乐趣发布一款游戏,你不需要担心这个问题,但如果你打算从中赚钱,那么这是一个需要评估的问题。
游戏盈利的主要选项有:
-
高级版:将其制作成付费游戏
-
广告:通过向用户展示广告来盈利游戏,无论是横幅还是插页式广告
-
应用内购买:为用户提供在游戏内购买物品的机会
制作付费游戏的优点是它构建起来要简单得多,而且会给你带来更少的麻烦。
一些在移动设备上表现良好的高级游戏示例包括纪念碑谷、三和房间(1 和 2)。
免费游戏的优点,无论是带广告还是应用内购买,是安装门槛是最低的。如果有人对游戏感兴趣,安装它是免费的。为游戏付费需要做出有意识的决策,很多人会选择放弃,无论价格如何。
如果你打算走免费游玩的道路,要做好担心玩家留存和转换的准备。你将熟悉 DAU、MAU、ARPU 和 ARPPU 等术语,仅举几个例子。
一些成功的免费游戏,具有不同的盈利模式,例如糖果传奇、部落冲突和愤怒的小鸟。
大多数研究都会告诉你,人们通过应用内购买比付费应用赚更多的钱。虽然这是真的,但要创建一个盈利良好的免费游玩游戏需要时间和精力来调整它;设计吸引人的应用内购买也是如此。
根据我的经验,为免费游玩游戏设计良好的盈利模式需要的时间和制作游戏本身的时间差不多。如果你没有仔细设计,你可能会得到非常低的转换率,这会导致收入很少或没有收入。
根据经验法则,不要在感觉不合适的情况下推动游戏中的盈利模式,并投入精力使游戏变得有趣。
摘要
我们已经为 Android TV 准备好了游戏。我们更新了清单文件,以提供一个从 Leanback 界面启动的活动以及一个在 UI 上显示的横幅,并将其标记为游戏。我们还审查了权限,以确保游戏可以在 Android TV 的 Google Play 上可用,主要标记了触摸屏能力和 Leanback,如请求但非必需。
从代码的角度来看,我们在电视上运行时为所有屏幕添加了一些额外的填充,以确保重要内容不会被过扫描裁剪掉,并且我们调整了对话框上的代码,使其与控制器配合得很好。
最后,我们提到了一些关于如何进一步改进游戏的提示,并简要讨论了游戏设计和盈利模式。
是时候开始制作你自己的游戏了。祝你好运,玩得开心!
附录 A. Android 版本 API 级别
下表显示了不同 Android 平台版本的 API 级别,以及版本代码:
| 平台版本 | API 级别 | 版本代码 |
|---|---|---|
| Android 5.1 | 22 | LOLLIPOP_MR1 |
| Android 5.0 | 21 | LOLLIPOP |
| Android 4.4W | 20 | KITKAT_WATCH |
| Android 4.4 | 19 | KITKAT |
| Android 4.3 | 18 | JELLY_BEAN_MR2 |
| Android 4.2, 4.2.2 | 17 | JELLY_BEAN_MR1 |
| Android 4.1, 4.1.1 | 16 | JELLY_BEAN |
| Android 4.0.3, 4.0.4 | 15 | ICE_CREAM_SANDWICH_MR1 |
| Android 4.0, 4.0.1, 4.0.2 | 14 | ICE_CREAM_SANDWICH |
| Android 3.2 | 13 | HONEYCOMB_MR2 |
| Android 3.1.x | 12 | HONEYCOMB_MR1 |
| Android 3.0.x | 11 | HONEYCOMB |
| Android 2.3.4Android 2.3.3 | 10 | GINGERBREAD_MR1 |
| Android 2.3.2Android 2.3.1Android 2.3 | 9 | GINGERBREAD |
| Android 2.2.x | 8 | FROYO |
| Android 2.1.x | 7 | ECLAIR_MR1 |
| Android 2.0.1 | 6 | ECLAIR_0_1 |
| Android 2.0 | 5 | ECLAIR |
| Android 1.6 | 4 | DONUT |
| Android 1.5 | 3 | CUPCAKE |
| Android 1.1 | 2 | BASE_1_1 |
| Android 1.0 | 1 | BASE |
附录 A. 参考文献列表
这条学习路径是将内容融合在一起,考虑到您的学习旅程而精心打包的。它包括以下 Packt 产品的内容:
-
通过构建 Android 游戏学习 Java,约翰·霍顿
-
Android 游戏编程实例教程,约翰·霍顿
-
精通 Android 游戏开发,劳尔·波塔莱斯















































































浙公网安备 33010602011771号