安卓游戏入门指南-全-

龙哥盟 / 2024-10-06 / 原文

安卓游戏入门指南(全)

原文:Beginning Android Games

协议:CC BY-NC-SA 4.0

零、简介

大家好,欢迎来到 Android 游戏开发的世界。你来这里是为了学习 Android 上的游戏开发,我们希望成为让你实现自己想法的人。

我们将一起涵盖相当广泛的材质和主题:Android 基础知识,音频和图形编程,一点数学和物理,OpenGL ES,Android 原生开发工具包(NDK)介绍,最后,出版,营销,从你的游戏中赚钱。基于所有这些知识,我们将开发三个不同的游戏,其中一个甚至是 3D 的。

如果你知道自己在做什么,游戏编程会很容易。因此,我们试图以这样一种方式呈现这些材质,不仅给你有用的代码片段供你重用,而且实际上向你展示游戏开发的全貌。理解潜在的原则是解决越来越复杂的游戏想法的关键。你不仅能够编写与本书中开发的游戏相似的游戏,而且你还将具备足够的知识去上网或逛书店,自己开发新的游戏领域。

这本书是给谁的

这本书首先面向游戏编程的完全初学者。你不需要任何关于主题的先验知识;我们会教你所有的基本知识。然而,我们需要假设你对 Java 有一点了解。如果你对这个问题感到生疏,我们建议你读一读布鲁斯·埃凯尔(Prentice Hall,2006 年)的《用 Java 思考》(Thinking in Java )来刷新你的记忆,这是一本优秀的编程语言入门书籍。除此之外,没有其他要求。没有必要事先接触 Android 或 Eclipse!

这本书也是针对那些想接触 Android 的中级游戏程序员的。虽然有些材质对你来说可能已经是旧闻了,但仍然有许多技巧和提示值得一读。Android 有时是一只奇怪的野兽,这本书应该被视为你的战斗指南。

这本书的结构

这本书采用了一种迭代的方法,我们将缓慢但肯定地从绝对的基础工作到硬件加速游戏编程的深奥高度。在本章的过程中,我们将建立一个可重用的代码库,你可以用它作为大多数类型游戏的基础。

如果你阅读这本书纯粹是作为一个学习练习,我们建议从第一章开始按顺序阅读这几章。每一章都建立在前一章的基础上,这是一次很好的学习经历。

如果你读这本书的目的是想在最后发布一款新游戏,我们强烈建议你跳到第十四章,学习如何设计你的游戏,使其适销对路并赚钱,然后回到起点开始开发。

当然,更有经验的读者可以跳过他们认为有把握的部分。请务必通读您浏览过的部分的代码清单,这样您就会理解在后续更高级的部分中如何使用这些类和接口。

下载代码

这本书是完全独立的;包含了运行示例和游戏所需的所有代码。然而,将书中的清单复制到 Eclipse 很容易出错,而且游戏不仅仅由代码组成,还包含一些您不能轻易从书中复制出来的素材。我们非常小心地确保本书中的所有列表都没有错误,但是小精灵们总是在努力工作。

为了使这一过程顺利进行,我们创建了一个谷歌代码项目,为您提供以下内容:

  • 从项目的 Subversion 存储库中可以获得完整的源代码和素材。该代码根据 Apache License 2.0 获得许可,因此可以在商业和非商业项目中免费使用。这些素材由-SA 3.0 根据知识共享协议授予许可。您可以为您的商业项目使用和修改它们,但是您必须将您的素材置于相同的许可之下!
  • 一个快速入门指南,向您展示如何以文本形式将项目导入到 Eclipse 中,以及同样的视频演示。
  • 一个问题跟踪器,允许您报告您发现的任何错误,无论是在书本身还是在书附带的代码中。一旦您在问题跟踪器中提交了一个问题,我们就可以在 Subversion 存储库中合并任何修复。这样,您将始终拥有本书代码的最新(希望)无错误版本,其他读者也可以从中受益。
  • 一个讨论组,每个人都可以自由加入并讨论书的内容。当然,我们也会在那里。

对于包含代码的每一章,Subversion 存储库中都有一个等价的 Eclipse 项目。这些项目并不相互依赖,因为我们将在本书的过程中反复改进一些框架类。因此,每个项目都是独立的。第五章和第六章的代码都包含在ch06-mrnom项目中。

谷歌代码项目可以在http://code.google.com/p/beginnginandroidgames2.找到

联系作者

如果您有任何问题或意见——或者甚至发现您认为我们应该知道的错误——您可以通过注册帐户并在http://badlogicgames.com/forum/viewforum.php?f=21发帖联系 Mario Zechner,或者通过访问www.rbgrn.net/contact.联系 Robert Green

我们更喜欢通过论坛联系。这样其他读者也会受益,因为他们可以查找已经回答的问题或参与讨论!

一、每个家庭都有一个安卓

作为 80 年代和 90 年代的孩子,我们很自然地伴随着值得信赖的任天堂游戏机和世嘉游戏机长大。我们花了无数时间帮助马里奥营救公主,在俄罗斯方块中获得最高分,并通过链接电缆在超级 RC Pro-Am 中与我们的朋友比赛。我们带着这些很棒的硬件去任何我们能去的地方。我们对游戏的热情让我们想要创造自己的世界,并与我们的朋友分享。我们开始在 PC 上编程,但很快意识到我们无法将我们的小杰作转移到可用的便携式游戏机上。随着我们继续成为热情的程序员,随着时间的推移,我们对实际玩视频游戏的兴趣消退了。此外,我们的游戏男孩最终打破了。。。

快进到今天。智能手机和*板电脑已经成为这个时代的新移动游戏*台,与任天堂 3DS 和 PlayStation Vita 等经典的专用手持系统竞争。这一发展重新激起了我们的兴趣,我们开始研究哪些移动*台适合我们的开发需求。苹果的 iOS 似乎是我们游戏编码技能的一个很好的候选。然而,我们很快意识到这个系统不是开放的,只有在苹果公司允许的情况下,我们才能与他人分享我们的工作,我们需要一台 Mac 来开发 iOS。然后我们发现了 Android 。

我们俩立刻就爱上了 Android。它的开发环境可以在所有主要*台上运行——没有任何附加条件。它有一个充满活力的开发人员社区,乐意帮助您解决遇到的任何问题,并提供全面的文档。你可以与任何人分享你的游戏,而不必为此付费,如果你想将你的作品货币化,你可以在几分钟内轻松地向拥有数百万用户的全球市场发布你最新最伟大的创新。

剩下的唯一事情就是弄清楚如何为 Android 编写游戏,以及如何将我们的 PC 游戏开发知识转移到这个新系统中。在接下来的章节中,我们希望与您分享我们的经验,并帮助您开始 Android 游戏开发。当然,这在一定程度上是一个自私的计划:我们想在旅途中玩更多的游戏!

让我们从了解我们的新朋友 Android 开始。

Android 简史

Android 首次公开露面是在 2005 年,当时谷歌收购了一家名为 Android Inc .的小型初创公司,这引发了人们对谷歌有意进入移动设备领域的猜测。2008 年,Android 1.0 版本的发布结束了所有的猜测,Android 继续成为移动市场上新的挑战者。自那以后,Android 一直在与已经建立的*台竞争,如 iOS(当时称为 iPhone OS)、黑莓 OS 和 Windows Phone 7。Android 的增长是惊人的,因为它每年都获得越来越多的市场份额。虽然移动技术的未来总是在变化,但有一点是肯定的:Android 将会继续存在。

由于 Android 是开源的,使用新*台的手机制造商进入门槛很低。他们可以生产所有价格段的设备,修改 Android 本身以适应特定设备的处理能力。因此,Android 不仅限于高端设备,还可以部署在低成本设备中,从而覆盖更广泛的受众。

Android 成功的一个关键因素是 2007 年末开放手机联盟(OHA)的成立。OHA 包括 HTC、高通、摩托罗拉和英伟达等公司,它们都合作开发移动设备的开放标准。尽管 Android 的代码主要是由谷歌开发的,但所有 OHA 成员都以这样或那样的形式为其源代码做出了贡献。

Android 本身是一个基于 Linux 内核版本 2.6 和 3.x 的移动操作系统和*台,它可以免费用于商业和非商业用途。OHA 的许多成员为他们的设备开发了用户界面经过修改的定制版 Android,比如 HTC 的 Sense 和摩托罗拉的 MOTOBLUR。Android 的开源特性也使得爱好者能够创建和分发他们自己的版本。这些通常被称为 mods固件rom。在撰写本文时,最著名的 rom 是由 Steve Kondik(也称为 Cyanogen)和许多贡献者开发的。它旨在为各种 Android 设备带来最新最好的改进,并为那些被抛弃或陈旧的设备带来新鲜空气。

自 2008 年发布以来,Android 已经收到了许多重大的版本更新,都是以甜点命名的(Android 1.1 除外,如今已经无关紧要了)。Android *台的大多数版本都添加了新功能,通常以应用编程接口(API)或新开发工具的形式出现,这些功能在某种程度上与游戏开发者相关:

  • 1.5 版本(Cupcake) :增加了在 Android 应用中包含原生库的支持,之前仅限于纯 Java 编写。在最关心性能的情况下,本机代码非常有用。
  • 1.6 版本(甜甜圈) :引入了对不同屏幕分辨率的支持。我们将在本书中多次重温这一发展,因为它对我们如何为 Android 编写游戏有一些影响。
  • 2.0 版本(克莱尔) :增加了对多点触控屏幕的支持。
  • 2.2 版(Froyo) :在 Dalvik 虚拟机(VM)上增加了即时(JIT)编译,这是一款为 Android 上所有 Java 应用提供动力的软件。JIT 大大加快了 Android 应用的执行速度——根据不同的场景,速度提高了 5 倍。
  • 2.3 版本(姜饼) :在 Dalvik VM 中增加了一个新的并发垃圾收集器。
  • 3.0 版本(蜂巢) :创造了一个*板电脑版本的 Android。Honeycomb 于 2011 年初推出,包含了比迄今为止发布的任何其他单一 Android 版本更多的重大 API 变化。到了 3.1 版本,Honeycomb 增加了对分割和管理大型高分辨率*板电脑屏幕的广泛支持。它增加了更多类似 PC 的功能,如 USB 主机支持和 USB 外设支持,包括键盘、鼠标和操纵杆。这个版本唯一的问题是它只针对*板电脑。小屏幕/智能手机版本的 Android 还停留在 2.3 版本。
  • Android 4.0(冰激凌三明治【ICS】):将 Honeycomb (3.1)和 Gingerbread (2.3)合并成一套通用的功能,在*板电脑和手机上都运行良好。
  • Android 4.1(果冻豆) :改进了 UI 的合成方式,以及一般的渲染。这一努力被称为“黄油项目”;首款搭载果冻豆的设备是谷歌自己的 Nexus 7 *板电脑。

ICS 对最终用户来说是一个巨大的推动,它对 Android UI 和内置应用(如浏览器、电子邮件客户端和照片服务)进行了大量改进。对于开发人员来说,除了其他事情之外,IC 还融入了蜂窝 UI APIs,为手机带来了大屏幕功能。ICS 还合并了 Honeycomb 的 USB 外围支持,这使制造商可以选择支持键盘和操纵杆。至于新的 API,ICS 增加了一些,比如 Social API,它为联系人、个人资料、状态更新和照片提供了一个统一的存储。对 Android 游戏开发者来说幸运的是,ICS 在其核心保持了良好的向后兼容性,确保了一个正确构建的游戏将与旧版本如 Cupcake 和艾克蕾尔保持良好的兼容性。

注意我们都经常被问到新版本的 Android 会给游戏带来哪些新功能。答案经常让人们感到惊讶:自 2.1 版本以来,除了原生开发工具包(NDK)之外,实际上没有新的游戏特定功能被添加到 Android 中。从那个版本开始,Android 已经包含了你所需要的一切,你可以构建任何你想要的游戏。大多数新特性都添加到了 UI API 中,所以只需关注 2.1 版,你就可以开始了。

碎片化

Android 的巨大灵活性是有代价的:选择开发自己用户界面的公司必须赶上新版本 Android 发布的快速步伐。这可能导致推出不到几个月的手机变得过时,因为运营商和手机制造商拒绝创建包含新 Android 版本改进的更新。这个过程的结果是一个叫做碎片化的大怪物。

碎片化有很多面。对于最终用户来说,这意味着由于被旧版本的 Android 卡住而无法安装和使用某些应用和功能。对于开发人员来说,这意味着在创建能够在所有版本的 Android 上运行的应用时必须小心谨慎。虽然为早期版本的 Android 编写的应用通常在新版本上运行良好,但反之则不然。当然,新版本的 Android 增加的一些功能在旧版本上是不可用的,比如多点触摸支持。开发者因此被迫为不同版本的 Android 创建不同的代码路径。

2011 年,许多著名的 Android 设备制造商同意支持最新的 Android 操作系统,设备寿命为 18 个月。这似乎不是很长的时间,但这是帮助减少碎片化的一大步。这也意味着 Android 的新功能,比如冰激凌三明治中的新 API,可以更快地在更多手机上使用。一年后,这个承诺似乎没有兑现。很大一部分市场仍在运行旧的 Android 版本,主要是姜饼。如果一款游戏的开发者想要得到大众市场的认可,这款游戏将需要在至少六个不同版本的 Android 上运行,分布在 600 多种设备上(还在增加!).

但是不要害怕。虽然这听起来很可怕,但事实证明,为适应多个版本的 Android 而必须采取的措施是很少的。大多数情况下,你甚至可以忘记这个问题,假装只有一个版本的 Android。作为游戏开发者,我们不太关心 API 的差异,而更关心硬件能力。这是一种不同形式的碎片化,这也是 iOS 等*台的问题,尽管没有那么明显。在这本书里,我们将讨论相关的碎片问题,当你为 Android 开发下一个游戏时,这些问题可能会妨碍你。

谷歌的作用

尽管 Android 官方上是开放手机联盟的产物,但在实现 Android 本身以及为其发展提供必要的生态系统方面,谷歌显然是领导者。

Android 开源项目

谷歌的努力总结在 Android 开源项目中。大多数代码都是在 Apache License 2 下授权的,与其他开源许可证(如 GNU 通用公共许可证(GPL ))相比,Apache License 2 是非常开放和无限制的。每个人都可以自由地使用这个源代码来构建自己的系统。然而,宣称兼容 Android 的系统首先必须通过 Android 兼容性计划,这一过程确保与开发者编写的第三方应用的基线兼容性。兼容系统被允许参与 Android 生态系统,其中还包括 Google Play

Google Play

Google Play(原名 Android Market )于 2008 年 10 月由谷歌向公众开放。这是一个在线商店,用户可以购买音乐、视频、书籍和第三方应用,或在他们的设备上消费的应用。Google Play 主要在 Android 设备上提供,但也有一个 web 前端,用户可以在那里搜索、评级、下载和安装应用。这不是必需的,但大多数 Android 设备都默认安装了 Google Play 应用。

Google Play 允许第三方开发者免费或付费发布他们的程序。在许多国家都可以购买付费应用,集成的购买系统使用 Google Checkout 处理汇率。Google Play 还提供了为每个国家的应用手动定价的选项。

用户在建立谷歌账户后就可以进入商店。应用可以通过信用卡购买,通过谷歌结帐或使用运营商计费。买家可以在购买后 15 分钟内决定退回申请,获得全额退款。以前,退款窗口是 24 小时,但为了减少对系统的利用,退款窗口被缩短了。

开发者需要向谷歌注册一个 Android 开发者账户,一次性支付 25 美元,才能在商店上发布应用。注册成功后,开发人员可以在几分钟内开始发布新的应用。

Google Play 没有审批流程,而是依靠许可系统。在安装应用之前,会向用户提供一组必需的权限,这些权限处理对电话服务、网络、安全数字(SD)卡等的访问。用户可能因为权限而选择不安装应用,但是用户当前没有能力简单地不允许应用具有特定的权限。整体上是“要么接受,要么放弃”。这种方法旨在让应用诚实地知道他们将使用设备做什么,同时为用户提供他们需要的信息,以决定信任哪些应用。

为了销售应用,开发人员还必须注册一个免费的 Google Checkout 商家帐户。所有的金融交易都通过这个账户处理。谷歌也有一个应用内购买系统,它与 Android Market 和谷歌 Checkout 集成在一起。开发人员可以使用单独的 API 来处理应用内购买交易。

谷歌输入输出

一年一度的谷歌 I/O 大会是每个安卓开发者每年都期待的一件大事。在 Google I/O 上,展示了最新最伟大的 Google 技术和项目,其中 Android *年来获得了特殊的地位。谷歌 I/O 通常会有多个关于 Android 相关主题的会议,这些会议也可以在 YouTube 的谷歌开发者频道上以视频形式获得。在谷歌 I/O 2011 上,三星和谷歌向所有常规与会者分发了 Galaxy Tab 10.1 设备。这标志着谷歌开始大举进军*板电脑市场。

Android 的功能和架构

Android 不仅仅是另一个面向移动设备的 Linux 发行版。在为 Android 开发时,你不太可能遇到 Linux 内核本身。Android 面向开发者的一面是一个*台,它抽象出底层的 Linux 内核,并通过 Java 编程。从高层次来看,Android 拥有几个不错的特性:

  • 一个应用框架,它为创建各种类型的应用提供了丰富的 API。它还允许重用和替换*台和第三方应用提供的组件。
  • Dalvik 虚拟机,负责在 Android 上运行应用。
  • 一套用于 2D 和 3D 编程的图形库
  • 媒体支持常见的音频、视频和图像格式,如 Ogg Vorbis、MP3、MPEG-4、H.264 和 PNG。甚至有一个专门的 API 来播放声音效果,这将在你的游戏开发冒险中派上用场。
  • 用于访问外设的 API,如摄像头、全球定位系统(GPS)、指南针、加速度计、触摸屏、轨迹球、键盘、控制器和操纵杆。注意,并不是所有的 Android 设备都有这些外设——硬件碎片化在起作用。

当然,Android 的功能远不止刚刚提到的几个。但是,对于您的游戏开发需求,这些功能是最相关的。

Android 的架构由堆叠的组件组组成,每一层都建立在其下一层的组件之上。图 1-1 给出了 Android 主要组件的概述。

9781430246770_Fig01-01.jpg

图 1-1。 Android 架构概述

内核

从堆栈的底部开始,您可以看到 Linux 内核为硬件组件提供了基本的驱动程序。此外,内核负责诸如内存和进程管理、网络等日常事务。

运行时和 Dalvik

Android 运行时构建在内核之上,它负责生成和运行 Android 应用。每个 Android 应用都在自己的进程中运行,有自己的 Dalvik VM。

Dalvik 以 Dalvik 可执行(DEX)字节码格式运行程序。通常,你转换普通的 Java。使用软件开发工具包(SDK)提供的名为 dx 的特殊工具将类文件转换成 DEX 格式。与经典 Java 相比,DEX 格式的内存占用更小。类文件。这是通过大量压缩、表和多个。类文件。

Dalvik VM 与核心库接口,核心库提供向 Java 程序公开的基本功能。核心库通过使用 Apache Harmony Java 实现的子集,提供了 Java Standard Edition (SE)中可用的一些类,但不是全部。这也意味着没有可用的 Swing 或抽象窗口工具包(AWT ),也没有可以在 Java Micro Edition (ME)中找到的任何类。然而,只要小心,您仍然可以在 Dalvik 上使用许多可用于 Java SE 的第三方库。

在 Android 2.2 (Froyo)之前,所有的字节码都是解释的。Froyo 引入了一个跟踪 JIT 编译器,它可以动态地将部分字节码编译成机器码。这大大提高了计算密集型应用的性能。JIT 编译器可以使用专门为特殊计算定制的 CPU 特性,例如专用浮点单元(FPU)。几乎每一个新版本的 Android 都改进了 JIT 编译器并提高了性能,通常是以消耗内存为代价的。不过,这是一个可扩展的解决方案,因为新设备包含越来越多的标准 RAM。

Dalvik 还有一个集成的垃圾收集器(GC) ,在早期版本中,它有时会让开发人员有点抓狂。不过,只要注意一些细节,你就可以在日常游戏开发中与 GC 和*共处。从 Android 2.3 开始,Dalvik 采用了改进的并发 GC,这减轻了一些痛苦。在本书的后面,您将更详细地研究 GC 问题。

Dalvik VM 实例中运行的每个应用总共至少有 16 MB 的堆内存可用。较新的设备,特别是*板电脑,有更高的堆限制,以促进更高分辨率的图形。不过,玩游戏很容易耗尽所有的内存,所以当你处理图像和音频资源时,你必须记住这一点。

系统库

除了提供一些 Java SE 功能的核心库之外,还有一组本地 C/C++ 库(图 1-1 中的第二层),它们为应用框架构建了基础(图 1-1 中的第三层)。这些系统库主要负责计算量大的任务,这些任务不太适合 Dalvik VM,比如图形渲染、音频回放和数据库访问。API 由应用框架中的 Java 类包装,当你开始编写游戏时,你将会利用这些 API。您将以某种形式使用以下库:

  • Skia 图形库(Skia) :这款 2D 图形软件用于渲染 Android 应用的 UI。您将使用它来绘制您的第一个 2D 游戏。
  • 嵌入式系统 OpenGL(OpenGL ES):这是硬件加速图形渲染的行业标准。OpenGL ES 1.0 和 1.1 在所有版本的 Android 上都暴露给 Java。OpenGL ES 2.0 将着色器带到了桌面上,仅从 Android 2.2 (Froyo)开始受支持。应该提到的是,Froyo 中 OpenGL ES 2.0 的 Java 绑定是不完整的,并且缺少一些重要的方法。幸运的是,这些方法是在 2.3 版本中添加的。此外,许多仍然占市场一小部分份额的旧仿真器图像和设备不支持 OpenGL ES 2.0。出于您的目的,请坚持使用 OpenGL ES 1.0 和 1.1,以最大化兼容性并允许您轻松进入 Android 3D 编程的世界。
  • OpenCore :这是一个音频和视频的媒体回放和录制库。它支持 Ogg Vorbis、MP3、H.264、MPEG-4 等格式的良好混合。您将主要处理音频部分,它不直接暴露给 Java 端,而是包装在几个类和服务中。
  • 这是一个用来加载和渲染位图和矢量字体的库,最著名的是 TrueType 格式。FreeType 支持 Unicode 标准,包括阿拉伯语和类似特殊文本的从右到左字形呈现。与 OpenCore 一样,FreeType 并不直接暴露于 Java 端,而是包装在几个方便的类中。

这些系统库覆盖了游戏开发者的很多领域,并完成了大部分繁重的工作。这就是为什么你可以用普通的 Java 编写游戏的原因。

注意尽管 Dalvik 的功能通常足以满足您的需求,但有时您可能需要更高的性能。这可能是非常复杂的物理模拟或繁重的 3D 计算的情况,为此您通常会求助于编写本机代码。我们将在本书的后一章对此进行探讨。已经有几个 Android 的开源库可以帮助你保持在 Java 方面。参见http://code.google.com/p/libgdx/中的示例。

应用框架

应用框架将系统库和运行时联系在一起,创建了 Android 的用户端。该框架管理应用,并提供应用在其中运行的精细结构。开发人员通过一组 Java APIs 为这个框架创建应用,这些 API 涵盖了 UI 编程、后台服务、通知、资源管理、外设访问等领域。Android 提供的所有开箱即用的核心应用,比如邮件客户端,都是用这些 API 编写的。

应用,无论是 ui 还是后台服务,都可以将它们的能力传达给其他应用。这种通信使应用能够重用其他应用的组件。一个简单的例子是,一个应用需要拍摄一张照片,然后在照片上执行一些操作。应用向系统查询提供该服务的另一个应用的组件。然后,第一个应用可以重用该组件(例如,内置的照相机应用或照片库)。这大大减轻了程序员的负担,也使你能够定制 Android 行为的方方面面。

作为游戏开发人员,您将在这个框架内创建 UI 应用。因此,您会对应用的架构和生命周期以及它与用户的交互感兴趣。后台服务通常在游戏开发中起的作用很小,这也是不详细讨论的原因。

软件开发工具包

要为 Android 开发应用,您将使用 Android 软件开发工具包(SDK)。SDK 由一套全面的工具、文档、教程和示例组成,可以帮助您快速入门。还包括为 Android 创建应用所需的 Java 库。这些包含应用框架的 API。所有主要的桌面操作系统都支持作为开发环境。

SDK 的突出特点如下:

  • 调试器,能够调试在设备或仿真器上运行的应用。
  • 一个内存和性能概要文件来帮助你发现内存泄漏和识别缓慢的代码。
  • 设备模拟器虽然有时有点慢,但很准确,它基于 QEMU(一个用于模拟不同硬件*台的开源虚拟机)。有一些选项可用于加速仿真器,如英特尔硬件加速执行管理器(HAXM),我们将在第二章中讨论。
  • 与设备通信的命令行工具
  • 构建脚本和工具来打包和部署应用。

SDK 可以与 Eclipse 集成,Eclipse 是一种流行的、功能丰富的开源 Java 集成开发环境(IDE)。集成是通过 Android 开发工具(ADT)插件 实现的,该插件为 Eclipse 添加了一组新功能,目的如下:创建 Android 项目;在仿真器或设备上执行、分析和调试应用;并打包 Android 应用以部署到 Google Play。注意,SDK 也可以集成到其他 ide 中,比如 NetBeans。然而,对此没有官方支持。

第二章讲述了如何用 SDK 和 Eclipse 来设置 IDE。

Eclipse 的 SDK 和 ADT 插件不断更新,添加新的特性和功能。因此,保持更新是一个好主意。

任何好的 SDK 都有大量的文档。Android 的 SDK 在这方面并不逊色,它包含了很多示例应用。您还可以在http://developer.android.com/guide/index.html找到开发人员指南和应用框架所有模块的完整 API 参考。

除了 Android SDK,使用 OpenGL 的游戏开发人员可能希望安装和使用高通、PowerVR、英特尔和 NVIDIA 的各种分析器。与 Android SDK 中的任何东西相比,这些分析器提供了更多关于游戏在设备上的需求的数据。我们将在第二章中更详细地讨论这些分析器。

开发者社区

Android 成功的部分原因是它的开发者社区,他们聚集在网络的各个地方。开发者交流最频繁的网站是位于http://groups.google.com/group/android-developers的 Android 开发者小组。当你偶然发现一个看似无法解决的问题时,这里是你提问或寻求帮助的首选之地。各种各样的 Android 开发人员都会访问这个小组,从系统程序员到应用开发人员,再到游戏程序员。偶尔,负责 Android 部分的谷歌工程师也会提供有价值的见解。注册是免费的,我们强烈建议你现在就加入这个小组!除了为你提供一个提问的地方,它也是一个搜索以前回答过的问题和问题解决方案的好地方。所以,在提问之前,先检查一下是否已经有人回答了。

另一个信息和帮助来源是http://www.stackoverflow.com的堆栈溢出。可以通过关键词搜索,也可以通过标签浏览最新的安卓问题。

每个称职的开发者社区都有一个吉祥物。Linux 有企鹅 Tux,GNU 有它的。。。好吧,gnu,Mozilla Firefox 也有它时髦的 Web 2.0 fox。安卓也没什么不同,选了一个绿色小机器人做吉祥物。图 1-2 给你看那个小恶魔。

9781430246770_Fig01-02.jpg

图 1-2。 Android 机器人

这个机器人已经出演了一些流行的安卓游戏。它最引人注目的出现在 Replica Island,这是一个免费的开源*台,由前谷歌开发者倡导者 Chris Pruett 创建,是一个 20%的项目。(术语百分之二十项目代表谷歌员工每周有一天可以花在他们自己选择的项目上。)

装置,装置,装置!

Android 没有被锁定在一个单一的硬件生态系统中。许多著名的手机制造商,如 HTC、摩托罗拉、三星和 LG,已经加入了 Android 的行列,他们提供了大量运行 Android 的设备。除了手机,还有一系列基于 Android 的*板设备。不过,一些关键概念是所有设备都共享的,这将使你作为游戏开发者的生活变得更容易一些。

硬件

Google 最初发布了以下最低硬件规格。几乎所有可用的 Android 设备都满足,并且经常大大超过这些建议:

  • 128 MB RAM :这个规格是最低的。目前的高端设备已经包括 1 GB RAM,如果摩尔定律得以实现,这种上升趋势不会很快结束。
  • 256 MB 闪存:这是存储系统映像和应用所需的最小内存量。长期以来,缺乏足够的内存是 Android 用户最大的抱怨,因为第三方应用只能安装到闪存中。随着 Froyo 的发布,这种情况发生了变化。
  • 迷你或微型 SD 卡存储:大多数设备都带有几千兆字节的 SD 卡存储,用户可以将其替换为更高容量的 SD 卡。一些设备,如三星 Galaxy Nexus,已经取消了可扩展的 SD 卡插槽,只集成了闪存。
  • 16 位彩色四分之一视频图形阵列(QVGA)薄膜晶体管液晶显示器(TFT-LCD) :在 Android 版本之前,操作系统只支持半尺寸 VGA (HVGA)屏幕(480 × 320 像素)。从版本 1.6 开始,支持更低和更高分辨率的屏幕。目前的高端手机都有宽 VGA (WVGA)屏幕(800 × 480、848 × 480 或 852 × 480 像素),一些低端设备支持 QVGA 屏幕(320 × 280 像素)。*板电脑屏幕有各种尺寸,通常约为 1280 × 800 像素,谷歌电视支持高清电视的 1920 × 1080 分辨率!虽然许多开发人员喜欢认为每个设备都有触摸屏,但事实并非如此。Android 正在向机顶盒和带有传统显示器的类似 PC 的设备进军。这些设备类型都没有与手机或*板电脑相同的触摸屏输入。
  • 专用硬件按键:这些按键用于导航。设备总是会提供按钮,或者作为软键,或者作为硬件按钮,专门映射到标准导航命令,例如 home 和 back,通常与屏幕触摸命令分开。Android 的硬件范围很大,所以不要做任何假设!

当然,大多数 Android 设备配备的硬件比最低规格要求的要多得多。几乎所有的手机都有 GPS ,一个加速计,和一个指南针。许多还具有接*和光传感器。这些外设为游戏开发者提供了新的方式让用户与游戏互动;我们将在本书的后面使用其中的一些。一些设备甚至有完整的 QWERTY 键盘和轨迹球。后者最常见于 HTC 设备。摄像头也几乎在目前所有的便携设备上都有。一些手机和*板电脑有两个摄像头:一个在背面,一个在正面,用于视频聊天。

专用的图形处理单元(GPU)对于游戏开发尤为关键。最早运行 Android 的手机已经有一个符合 OpenGL ES 1.0 的 GPU。较新的便携式设备的 GPU 性能与较旧的 Xbox 或 PlayStation 2 相当,支持 OpenGL ES 2.0。如果没有可用的图形处理器,该*台以称为 PixelFlinger 的软件渲染器的形式提供后备。许多低预算手机依赖于软件渲染器,这对于大多数低分辨率屏幕来说已经足够快了。

除了图形处理器,任何当前可用的 Android 设备也有专用的音频硬件。许多硬件*台包括解码不同媒体格式(如 H.264)的特殊电路。通过硬件组件为移动电话、Wi-Fi 和蓝牙提供连接。Android 设备中的所有硬件模块通常都集成在单个片上系统(SoC) 中,这种系统设计也出现在嵌入式硬件中。

设备的范围

一开始,有 G1。开发人员急切地等待更多的设备,几款略有不同的手机很快问世,这些手机被认为是“第一代”。多年来,硬件变得越来越强大,现在已经有了手机、*板电脑和机顶盒,从具有 2.5 英寸 QVGA 屏幕、仅在 500 MHz ARM CPU 上运行软件渲染器的设备,一直到具有双 1 GHz CPUs、支持 HDTV 的非常强大的 GPU 的机器。

我们已经讨论了碎片问题,但是开发人员还需要处理如此大范围的屏幕尺寸、功能和性能。做到这一点的最佳方法是了解最小硬件,并使其成为游戏设计和性能测试的最小公分母。

最低实际目标

截至 2012 年年中,不到 3%的 Android 设备运行的是 2.1 之前的 Android 版本。这很重要,因为这意味着你现在开始的游戏将只需要支持最低 7 (2.1)的 API 级别,并且当它完成时,它仍将达到所有 Android 设备的 97%(按版本)。这并不是说你不能使用最新的新功能!你当然可以,我们会告诉你怎么做。你只需要设计一些后备机制来兼容 2.1 版的游戏。当前数据可在http://developer.android.com/resources/dashboard/platform-versions.html通过谷歌获得,2012 年 8 月收集的图表显示在图 1-3 中。

9781430246770_Fig01-03.jpg

图 1-3。2012 年 8 月 1 日 Android 版本发布

那么,作为最低目标,什么是好的基线设备呢?回到发布的第一款 Android 2.1 设备:原版摩托罗拉 Droid ,如图图 1-4 。虽然 droid 已经更新到 Android 2.2,但它仍然是一款广泛使用的设备,在 CPU 和 GPU 性能方面都相当出色。

9781430246770_Fig01-04.jpg

图 1-4。摩托罗拉 Droid

最初的 Droid 被称为第一个“第二代”设备,它是在第一套基于高通 MSM7201A 的模型(包括 G1、Hero、MyTouch、厄里斯和许多其他模型)大约一年后发布的。Droid 是第一款拥有分辨率高于 480 × 320 的屏幕和独立 PowerVR GPU 的手机,也是第一款原生多点触摸 Android 设备(尽管它有一些多点触摸问题,但稍后会有更多)。

支持 Droid 意味着您支持具有以下规格的设备:

  • CPU 速度在 550 MHz 和 1 GHz 之间,支持硬件浮点运算
  • 支持 OpenGL ES 1.x 和 2.0 的可编程 GPU
  • WVGA 屏风
  • 多点触摸支持
  • Android 版本 2.1 或 2.2 以上

Droid 是一个优秀的最小目标,因为它运行 Android 2.2 并支持 OpenGL ES 2.0。它的屏幕分辨率为 854 × 480,与大多数基于手机的手机相似。如果一款游戏在 Droid 上运行良好,那么它很可能在 90 %的 Android 手机上运行良好。仍然会有一些旧的,甚至一些新的设备的屏幕尺寸为 480 × 320,所以最好为它做好计划,至少在它们上面进行测试,但从性能角度来看,你不太可能需要比 Droid 支持的少得多,以抓住绝大多数 Android 观众。

Droid 也是一款出色的测试设备,可以模拟许多涌入亚洲市场的廉价中国手机的功能,由于价格低廉,这些手机也进入了一些西方市场。

尖端设备

Honeycomb 推出了非常可靠的*板电脑支持,*板电脑显然是一个不错的游戏*台。随着 NVIDIA Tegra 2 芯片在 2011 年初引入设备,手机和*板电脑都开始接收快速的双核 CPU,甚至更强大的 GPU 也成为了标准。在写一本书的时候,很难讨论什么是现代,因为它变化如此之快,但在撰写本文的时候,设备到处都有超高速处理器、大量存储、大量内存、高分辨率屏幕、十点多点触摸支持,甚至在一些型号中有 3D 立体显示,这变得非常普遍。

Android 设备中最常见的 GPU 是 Imagination Technologies 的 PowerVR 系列,高通的集成 Adreno GPUs 的骁龙,NVIDIA 的 Tegra 系列,以及许多三星芯片中内置的 Mali 系列。PowerVR 目前有几个版本:530、535、540 和 543。不要被型号之间的小增量所迷惑;与其前辈相比,540 绝对是速度极快的 GPU,它在三星 Galaxy S 系列和谷歌 Galaxy Nexus 中都有搭载。543 目前配备在最新的 iPad 和 PlayStation Vita 中,比 540 快几倍!虽然它目前没有安装在任何主要的 Android 设备上,但我们不得不假设 543 将很快出现在新的*板电脑上。较旧的 530 在 Droid 中,535 分散在几个型号中。也许最常用的 GPU 是高通的,几乎在每一个 HTC 设备中都能找到。Tegra GPU 的目标是*板电脑,但也在几款手机中使用。三星的许多新手机都在使用 Mali GPU,取代了以前使用的 PowerVR 芯片。所有这四种竞争芯片架构都具有很强的可比性和强大的功能。

三星的 Galaxy Tab 2 10.1(见图 1-5 )很好地代表了最新的 Android *板电脑产品。它具有以下特点:

9781430246770_Fig01-05.jpg

图 1-5。三星银河 Tab 2 10.1

  • 双核 1 GHz CPU/GPU
  • 支持 OpenGL ES 1.x 和 2.0 的可编程 GPU
  • 1280 × 800 像素的屏幕
  • 十点多点触控支持
  • 安卓冰淇淋三明治 4.0

支持 Galaxy Tab 2 10.1 级*板电脑对于维持越来越多的用户接受这项技术非常重要。从技术上来说,支持它和支持任何其他设备没有区别。*板电脑大小的屏幕是在设计阶段可能需要额外考虑的另一个方面,但你会在本书的后面找到更多相关信息。

未来:下一代

设备制造商试图尽可能长时间地对他们的最新手机保密,但一些规格总是被泄露。

所有未来设备的总体趋势是更多的内核、更多的内存、更好的 GPU、更高的屏幕分辨率和每英寸像素。竞争对手的芯片不断出现,不断吹嘘更大的数量,而 Android 本身也在发展和成熟,这既通过提高性能,也通过在几乎每个后续版本中增加功能。硬件市场竞争异常激烈,而且没有任何放缓的迹象。

虽然 Android 始于一部手机,但它已经迅速发展到可以在不同类型的设备上运行,包括电子书阅读器、机顶盒、*板电脑、导航系统和插入坞站成为个人电脑的混合手机。为了创造一个可以在任何地方工作的 Android 游戏,开发者需要考虑 Android 的本质;也就是说,一个无处不在的操作系统可以嵌入到几乎任何东西上。人们不应该认为 Android 将简单地停留在当前类型的设备上。自 2008 年以来,它的增长如此之快,覆盖面如此之广,以至于对于 Android 来说,很明显天空是无限的。

无论未来会发生什么,Android 将永远存在!

兼容所有设备

在所有这些关于手机、*板电脑、芯片组、外设等等的讨论之后,很明显,支持 Android 设备市场与支持 PC 市场没有什么不同。屏幕尺寸从微小的 320 × 240 像素一直到 1920 × 1080(在 PC 显示器上可能更大!).在最低端的第一代设备上,你只有微不足道的 500 MHz ARM5 CPU 和非常有限的 GPU,没有太多的内存。另一方面,您有一个高带宽、多核 1–2 GHz CPU,带有大规模并行 GPU 和大量内存。第一代手机有一个不确定的多点触摸系统,无法检测离散的触摸点。新的*板电脑可以支持 10 个独立的触摸点。机顶盒根本不支持任何触摸!开发者该怎么做?

首先,所有这些都是明智的。Android 本身有一个兼容性程序,规定了 Android 兼容设备各部分的最低规格和值范围。如果设备不符合标准,则不允许捆绑 Google Play 应用。唷,那就放心了!兼容性程序在http://source.android.com/compatibility/overview.html可用。

Android 兼容性计划在兼容性定义文档(CDD)中进行了概述,该文档可在兼容性计划网站上获得。该文档针对 Android *台的每个版本进行更新,硬件制造商必须更新和重新测试他们的设备以保持合规。

CDD 规定的与游戏开发者相关的一些项目如下:

  • 最小音频延迟(各不相同)
  • 最小屏幕尺寸(目前为 2.5 英寸)
  • 最小屏幕密度(目前为 100 dpi)
  • 可接受的长宽比(目前为 4:3 到 16:9)
  • 3D 图形加速(需要 OpenGL ES 1.0)
  • 输入设备

即使你不能理解上面列出的一些项目,也不用担心。在本书的后面部分,您将会更详细地了解这些主题。从这个列表中可以看出,有一种方法可以设计一款游戏,使其能够在绝大多数 Android 设备上运行。通过规划游戏中的用户界面和一般视图等内容,以便它们可以在不同的屏幕大小和长宽比上工作,并通过了解您不仅需要触摸功能,还需要键盘或其他输入方法,您可以成功开发一个非常兼容的游戏。不同的游戏需要不同的技术来在不同的硬件上实现良好的用户体验,所以不幸的是没有解决这些问题的灵丹妙药。但是,请放心:随着时间的推移和一点适当的规划,你将能够获得良好的结果。

手机游戏不同

早在 iPhone 和 Android 出现之前,游戏就已经是一个巨大的市场了。然而,随着这些新形式的混合设备的出现,情况开始发生变化。游戏不再是书呆子们的专利。人们看到严肃的商务人士在公共场合用他们的手机玩最新的流行游戏,报纸报道成功的小游戏开发商在手机应用市场上发财的故事,而老牌游戏发行商很难跟上移动领域的发展。游戏开发者必须认识到这种变化,并做出相应的调整。让我们看看这个新的生态系统能提供什么。

每个口袋里都有一台游戏机

移动设备无处不在。这可能是从本节中得出的关键陈述。由此,你可以很容易地推导出手机游戏的所有其他事实。

随着硬件价格不断下降,新设备的计算能力不断增强,它们也成为游戏的理想选择。现在手机是必需品,所以市场渗透率很大。许多人正在用新一代智能手机替换他们的旧的、经典的手机,并发现他们可以使用的各种新的应用。

以前,如果你想玩视频游戏,你必须有意识地决定购买视频游戏系统或游戏 PC。现在,您可以在手机、*板电脑和其他设备上免费获得该功能。没有额外的费用(至少如果你不计算你可能需要的数据计划),你的新游戏设备随时可供你使用。只需从您的口袋或钱包中取出它,您就可以开始使用了-无需随身携带单独的专用系统,因为一切都集成在一个包中。

除了只需携带一台设备来满足电话、互联网和游戏需求的好处之外,另一个因素使更多的观众可以轻松地在手机上玩游戏:你可以在你的设备上启动一个专用的市场应用,选择一个看起来有趣的游戏,然后立即开始玩。没有必要去商店或者通过你的电脑下载一些东西,例如,你没有把游戏传输到你的手机上所需的 USB 线。

当代设备处理能力的提高也对你作为游戏开发者的潜力产生了影响。即使是中产阶级的设备也能够产生类似于旧 Xbox 和 PlayStation 2 系统的游戏体验。有了这些强大的硬件*台,你也可以开始探索具有物理模拟的复杂游戏,这是一个具有巨大创新潜力的领域。

新的设备带来了新的输入方法,这一点我们已经提到过了。一些游戏已经利用了大多数 Android 设备中的 GPS 和/或指南针。使用加速度计已经是许多游戏的必备功能,多点触摸屏为用户提供了与游戏世界互动的新方式。已经讨论了很多内容,但是仍然有新的方法以创新的方式使用所有这些功能。

始终连接

Android 设备通常与数据计划一起出售。这使得网络流量越来越大。智能手机用户很可能在任何给定时间连接到网络(不考虑硬件设计故障导致的接收不良)。

永久连接为手机游戏打开了一个全新的世界。用户可以挑战地球另一端的对手,进行一场快速的国际象棋比赛,探索有真人居住的虚拟世界,或者在一场绅士之死比赛中尝试击碎来自另一个城市的最好的朋友。此外,所有这一切都发生在旅途中——在公共汽车上,在火车上,或者在当地公园最受欢迎的角落里。

除了多人游戏功能,社交网络也开始影响手机游戏。游戏提供自动将您的最新高分直接发布到您的 Twitter 帐户的功能,或者通知朋友您在你们都喜欢的赛车游戏中获得的最新成就。虽然传统游戏世界中存在越来越多的社交网络(例如,Xbox Live 或 PlayStation Network),但脸书和 Twitter 等服务的市场渗透率要高得多,因此用户可以免去同时管理多个网络的负担。

休闲与硬核

绝大多数用户采用移动设备也意味着从未接触过 NES 控制器的人突然发现了游戏世界。他们对好游戏的想法往往与铁杆游戏玩家相差甚远。

根据手机的使用案例,典型用户倾向于更休闲的游戏,他们可以在公交车上或快餐店排队时玩几分钟。这些游戏相当于 PC 上那些令人上瘾的小 flash 游戏,每当他们感觉到身后有人时,就会迫使许多职场人疯狂地按 Alt + Tab。问问你自己:你愿意每天花多少时间在手机上玩游戏?你能想象在这样的设备上玩“快速”的文明游戏吗?

当然,如果他们可以在手机上玩他们心爱的高级龙与地下城游戏,可能会有认真的游戏玩家愿意献出他们的第一个孩子。但这个群体是少数,iPhone 应用商店和 Google Play 中最畅销的游戏就证明了这一点。最畅销的游戏通常本质上非常休闲,但他们有一个巧妙的锦囊妙计:玩一轮游戏的*均时间在几分钟内,但这些游戏通过使用各种邪恶的计划让你回来。一个游戏可能会提供一个复杂的在线成就系统,让你可以虚拟地吹嘘你的技能。另一个可能实际上是一个伪装的硬核游戏。为用户提供一个简单的方法来保存他们的进展,你是在卖一个可爱的益智游戏史诗 RPG!

大市场,小开发商

移动游戏市场的低进入门槛是吸引许多爱好者和独立开发者的主要因素。在 Android 的情况下,这个障碍特别低:只要让你自己的 SDK 和程序离开。你甚至不需要一个设备;只需使用模拟器(尽管建议至少有一个开发设备)。Android 的开放性也导致了网络上的大量活动。关于系统编程的所有方面的信息都可以在网上免费找到。没有必要签署一份保密协议,或者等待某个权威机构批准你进入他们神圣的生态系统。

最初,市场上许多最成功的游戏都是由一个人的公司和小团队开发的。各大出版社很长时间没有涉足这个市场,至少没有成功。智乐就是一个最好的例子。尽管 Gameloft 在 iPhone 上很大,但在很长一段时间内无法在 Android 上立足,因此决定在自己的网站上销售他们的游戏。智乐可能不喜欢缺少数字版权管理方案(现在安卓上有了)。最终,Gameloft 与 Zynga 或 Glu Mobile 等其他大公司一起,再次开始在 Google Play 上发布内容。

Android 环境也允许大量的实验和创新,因为无聊的人在 Google Play 上搜索小宝石,包括新的想法和游戏机制。在经典游戏*台(如 PC 或游戏机)上进行的实验经常会失败。然而,Google Play 能让你接触到大量愿意尝试实验性新想法的观众,而且不费吹灰之力就能接触到他们。

当然,这并不意味着你不必推销你的游戏。一种方法是在网上的各种博客和专门的网站上发布你的最新游戏。许多安卓用户都是狂热爱好者,经常光顾这样的网站,查看下一个大热门。

接触大量受众的另一种方式是在 Google Play 中出现。当用户启动 Google Play 应用时,您的应用将出现在用户列表中。许多开发人员报告下载量大幅增加,这与在 Google Play 中获得功能直接相关。不过,如何成为特色有点神秘。无论你是一个大出版商还是一个小的个人商店,拥有一个令人敬畏的想法并以最完美的方式执行它是你最好的选择。

最后,仅仅通过简单的口口相传,社交网络就可以大大提高你的应用的下载量和销量。病毒游戏通常通过直接整合脸书或推特让这个过程变得更加容易。让一款游戏像病毒一样传播是一种黑艺术,通常与在正确的时间出现在正确的地点比计划更有关系。

摘要

Android 是一个令人兴奋的野兽。您已经看到了它的构成,并对它的开发者生态系统有了一些了解。从开发的角度来看,它在软件和硬件方面为您提供了一个非常有趣的系统,鉴于免费提供的 SDK,进入的门槛非常低。这些设备本身对于手持设备来说非常强大,它们将使你能够向你的用户呈现视觉上丰富的游戏世界。使用传感器,如加速度计,让你创造新的用户互动的创新游戏的想法。完成游戏开发后,您可以在几分钟内将它们部署给数百万潜在的游戏玩家。听起来很刺激?是时候动手编写一些代码了!

二、Android SDK 的第一步

Android SDK 提供了一套工具,使您能够在短时间内创建应用。本章将指导你使用 SDK 工具构建一个简单的 Android 应用。这包括以下步骤:

  1. 设置开发环境。
  2. 在 Eclipse 中创建新项目并编写代码。
  3. 在模拟器或设备上运行应用。
  4. 调试和分析应用。

我们将通过研究有用的第三方工具来结束本章。让我们从设置开发环境开始。

设置开发环境

Android SDK 非常灵活,可以很好地与多种开发环境集成。纯粹主义者可能会选择使用命令行工具。不过,我们希望事情变得更舒适一点,所以我们将使用 IDE(集成开发环境)走更简单、更可视化的路线。

以下是您需要按照给定顺序下载并安装的软件列表:

  1. Java 开发工具包(JDK) ,版本 5 或 6。我们建议用 6。在撰写本文时,JDK 7 在 Android 开发方面存在问题。必须指示编译器为 Java 6 编译。
  2. Android 软件开发工具包(Android SDK)。
  3. Eclipse for Java Developers,3.4 版或更新版本。
  4. Eclipse 的 Android 开发工具(ADT)插件。

让我们来看一下正确设置所需的步骤。

注意由于网络是一个移动的目标,我们在这里不提供具体的下载网址。启动你最喜欢的搜索引擎,找到合适的地方找到上面列出的物品。

设置 JDK

下载适用于您的操作系统的指定版本之一的 JDK。在大多数系统中,JDK 都包含在一个安装程序或包中,所以不应该有任何障碍。一旦安装了 JDK,您应该添加一个名为 JDK_HOME 的新环境变量,指向 JDK 安装的根目录。此外,您应该将$JDK _ HOME/bin(% Windows 上的 JDK_HOME%\bin)目录添加到 PATH 环境变量中。

设置 Android SDK

Android SDK 也适用于三种主流桌面操作系统。为您的*台选择版本并下载。SDK 以 ZIP 或 tar gzip 文件的形式出现。解压到一个方便的文件夹就行了(比如 Windows 上的 c:\android-sdk 或者 Linux 上的/opt/android-sdk)。SDK 附带了几个命令行工具,位于 tools/文件夹中。创建一个名为 ANDROID_HOME 的环境变量,指向 SDK 安装的根目录,并将$ ANDROID _ HOME/tools(% ANDROID _ HOME % \ tools,在 Windows 上)添加到 PATH 环境变量中。这样,如果需要的话,您可以很容易地从 shell 中调用命令行工具。

注意对于 Windows,你也可以下载一个合适的安装程序,它会为你设置好一切。

在执行了前面的步骤之后,您将拥有一个由创建、编译和部署 Android 项目所需的基本命令行工具、SDK 管理器(一个用于安装 SDK 组件的工具)和 AVD 管理器(负责创建仿真器使用的虚拟设备)组成的基本安装。仅仅这些工具不足以开始开发,所以您需要安装额外的组件。这就是 SDK 管理器的用武之地。管理器是一个包管理器,很像 Linux 上的包管理工具。管理器允许您安装以下类型的组件:

  • Android *台:对于每一个正式的 Android 版本,都有一个 SDK *台组件,包括运行时库、仿真器使用的系统映像和任何特定于版本的工具。
  • SDK 附加组件:附加组件通常是不特定于*台的外部库和工具。一些例子是允许你在应用中集成谷歌地图的谷歌 API。
  • Windows 的 USB 驱动程序:这个驱动程序是在 Windows 的物理设备上运行和调试应用所必需的。在 Mac OS X 和 Linux 上,你不需要特殊的驱动程序。
  • 样本:对于每个*台,也有一组特定于*台的样本。这些是了解如何使用 Android 运行时库实现特定目标的很好的资源。
  • 文档:这是最新 Android 框架 API 文档的本地副本。

作为贪婪的开发人员,我们希望安装所有这些组件,以便拥有所有这些功能。因此,首先我们必须启动 SDK 管理器。在 Windows 上,SDK 的根目录下有一个名为 SDK manager.exe 的可执行文件。在 Linux 和 Mac OS X 上,您只需在 SDK 的工具目录中启动脚本 android。

在第一次启动时,SDK 管理器将连接到包服务器并获取可用包的列表。然后,管理器将向您显示如图 2-1 所示的对话框,允许您安装单独的软件包。只需单击选择旁边的新链接,然后单击安装按钮。您将看到一个对话框,要求您确认安装。选中全部接受复选框,然后再次单击安装按钮。接下来,给自己泡杯好茶或咖啡。管理器需要一段时间来安装所有的软件包。安装程序可能会要求您提供某些软件包的登录凭据。您可以安全地忽略这些,只需点击取消。

9781430246770_Fig02-01.jpg

图 2-1。第一次与 SDK 经理联系

您可以随时使用 SDK 管理器来更新组件或安装新组件。一旦安装过程完成,您就可以进入设置开发环境的下一步。

安装 Eclipse

Eclipse 有几种不同的风格。对于 Android 开发者,我们建议使用 Eclipse for Java Developers 版本 3.7.2,代码名为“Indigo”。与 Android SDK 类似,Eclipse 以 ZIP 或 tar gzip 包的形式出现。只需将其提取到您选择的文件夹中。一旦包被解压缩,您就可以在桌面上创建一个快捷方式,指向 eclipse 安装根目录下的 Eclipse 可执行文件。

第一次启动 Eclipse 时,会提示您指定一个工作区目录。图 2-2 显示了该对话框。

9781430246770_Fig02-02.jpg

图 2-2。选择工作空间

工作区是 Eclipse 对包含一组项目的文件夹的概念。您是为所有项目使用单个工作区,还是将几个项目组合在一起的多个工作区,完全由您决定。本书附带的示例项目都组织在一个单独的工作空间中,您可以在该对话框中指定该工作空间。现在,我们将简单地在某个地方创建一个空的工作区。

然后 Eclipse 会用一个欢迎屏幕来欢迎您,您可以安全地忽略并关闭它。这将为您留下默认的 Eclipse Java 透视图。在后面的小节中,您将对 Eclipse 有一点了解。目前,让它运行就足够了。

安装 ADT Eclipse 插件

我们的设置难题的最后一部分是安装 ADT Eclipse 插件。Eclipse 基于一个插件架构,用于通过第三方插件来扩展其功能。ADT 插件 将 Android SDK 中的工具与 Eclipse 的强大功能结合在一起。有了这个组合,我们可以完全忘记调用所有的命令行 Android SDK 工具;ADT 插件将它们透明地集成到我们的 Eclipse 工作流中。

为 Eclipse 安装插件可以手动完成,通过将插件 ZIP 文件的内容放入 Eclipse 的 plug-ins 文件夹,或者通过与 Eclipse 集成的 Eclipse 插件管理器。这里我们将选择第二条路线:

  1. Go to Help image Install New Software, which opens the installation dialog. In this dialog, you can choose the source from which to install a plug-in. First, you have to add the plug-in repository from the ADT plug-in that is fetched. Click the Add button. You will be presented with the dialog shown in Figure 2-3.

    9781430246770_Fig02-03.jpg

    图 2-3。添加存储库

  2. 在第一个文本字段中,输入存储库的名称;类似“ADT 存储库”的东西就可以了。第二个文本字段指定存储库的 URL。对于 ADT 插件,该字段应为https://dl-ssl.google.com/android/eclipse/。请注意,对于较新的版本,此 URL 可能会有所不同,因此请查看 ADT 插件网站以获取最新链接。

  3. 单击 OK,您将返回到安装对话框,现在应该会获取存储库中可用插件的列表。选中开发工具复选框,然后单击下一步按钮。

  4. Eclipse 会计算所有必要的依赖项,然后向您呈现一个新的对话框,其中列出了将要安装的所有插件和依赖项。单击“下一步”按钮进行确认。

  5. Another dialog pops up prompting you to accept the license for each plug-in to be installed. You should, of course, accept those licenses and then initiate the installation by clicking the Finish button.

    注意在安装过程中,会要求您确认未签名软件的安装。别担心,插件只是没有经过验证的签名。同意安装以继续该过程。

  6. Eclipse 会询问您是否应该重启以应用更改。您可以选择完全重启或不重启就应用更改。为了安全起见,选择 Restart Now,这将按预期重启 Eclipse。

Eclipse 重启后,您将看到和以前一样的 Eclipse 窗口。工具栏提供了几个 Android 特有的新按钮,允许您直接从 Eclipse 中启动 SDK 和 AVD 管理器,并创建新的 Android 项目。图 2-4 显示了新的工具栏按钮。

9781430246770_Fig02-04.jpg

图 2-4。 ADT 工具栏按钮

左侧的前两个按钮允许您分别打开 SDK 管理器和 AVD 管理器。看起来像复选框的按钮让你运行 Android lint,它检查你的项目是否有潜在的 bug。下一个按钮是新的 Android 应用项目按钮,这是创建新的 Android 项目的快捷方式。最右边的两个按钮分别使您能够创建一个新的单元测试项目或 Android 清单文件(我们不会在本书中使用该功能)。

作为完成 ADT 插件安装的最后一步,您必须告诉插件 Android SDK 的位置:

  1. 打开窗口image偏好设置,并在出现的对话框的树形视图中选择 Android。
  2. 在右侧,单击浏览按钮选择 Android SDK 安装的根目录。
  3. 单击“确定”按钮关闭对话框。现在,您将能够创建您的第一个 Android 应用。

快速游览 Eclipse

Eclipse 是一个开源的 IDE,可以用来开发用各种语言编写的应用。通常,Eclipse 与 Java 开发结合使用。鉴于 Eclipse 的插件架构,已经创建了许多扩展,因此也有可能开发纯 C/C++、Scala 或 Python 项目。可能性是无穷无尽的;例如,甚至存在编写 LaTeX 项目的插件——这与您通常的代码开发任务略有相似。

Eclipse 的一个实例使用一个包含一个或多个项目的工作区。以前,我们在启动时定义了一个工作区。您创建的所有新项目都将存储在工作区目录中,以及定义使用工作区时 Eclipse 外观的配置。

Eclipse 的用户界面(UI) 围绕着两个概念:

  • 一个视图,一个单一的 UI 组件,比如源代码编辑器、输出控制台或者项目浏览器。
  • 一个透视图,一组特定的视图,您很可能需要它们来完成特定的开发任务,比如编辑和浏览源代码、调试、分析、与版本控制库同步等等。

Eclipse for Java Developers 附带了几个预定义的透视图。我们最感兴趣的是 Java 和 Debug。Java 透视图如图 2-5 所示。它的特点是左边是 Package Explorer 视图,中间是 Source Code 视图(它是空的,因为我们还没有打开一个源文件),右边是 Task List 视图,一个 Outline 视图,以及一个选项卡式视图,其中包含称为 Problems 视图、Javadoc 视图、Declaration 视图和 Console 视图的子视图。

9781430246770_Fig02-05.jpg

图 2-5。 Eclipse 在行动 Java 的视角

通过拖放,您可以自由地重新安排透视图中任何视图的位置。您也可以调整视图的大小。此外,您可以在透视图中添加和删除视图。要添加视图,进入窗口image显示视图,从显示的列表中选择一个或选择其他以获得所有可用视图的列表。

要切换到另一个透视图,您可以转到窗口image打开透视图并选择您想要的那个。Eclipse 的左上角提供了一种在已经打开的透视图之间切换的更快方法。在那里,您将看到哪些透视图已经打开,哪些透视图是活动的。在图 2-5 中,注意 Java 透视图是打开的并且是活动的。这是目前唯一开放的视角。一旦您打开附加的透视图,它们也会显示在 UI 的那个部分。

图 2-5 中显示的 工具栏也只是视图。根据您当时所处的视角,工具栏也可能会发生变化。回想一下,安装 ADT 插件后,工具栏中出现了几个新按钮。这是插件的常见行为:一般来说,它们会添加新的视图和视角。对于 ADT 插件,除了标准的 Java Debug 透视图之外,我们现在还可以访问一个名为 DDMS 的透视图(Dalvik Debugging Monitor Server,它专用于调试和分析 Android 应用,这将在本章后面介绍)。ADT 插件还添加了几个新视图,包括 LogCat 视图,它显示来自任何连接的设备或仿真器的实时日志记录信息。

一旦您熟悉了透视图和视图概念,Eclipse 就不那么可怕了。在下面的小节中,我们将探索一些我们将用来编写 Android 游戏的视角和视图。我们不可能涵盖使用 Eclipse 开发的所有细节,因为它是如此庞大。因此,如果需要的话,我们建议您通过其广泛的帮助系统来学习更多关于 Eclipse 的知识。

有用的 Eclipse 快捷键

每个新的 IDE 都需要一些时间来学习和适应。在使用 Eclipse 多年后,我们发现以下快捷方式可以显著加快软件开发。这些快捷键使用 Windows 术语,因此 Mac OS X 用户应该在适当的地方替换命令和选项:

  • 将光标放在函数或字段上时,按 Ctr + Shift + G 组合键将在工作区中搜索对该函数或字段的所有引用。例如,如果你想知道某个函数在哪里被调用,只需点击鼠标将光标移动到该函数上,然后按 Ctrl + Shift + G。
  • 将光标放在调用 into 函数上的 F3 将跟随该调用,并将您带到声明和定义该函数的源代码。将此热键与 Ctrl + Shift + G 结合使用,可以轻松导航 Java 源代码。在类名或字段上做同样的事情将会打开它的声明。
  • Ctr +空格键自动完成您当前键入的函数或字段名称。输入几个字符后,开始键入并按快捷键。当有多种可能性时,会出现一个框。
  • Ctr + Z 无效。
  • Ctr + X 削减。
  • ctrl+c 副本。
  • ctrl+v 蛋糕。
  • Ctr + F11 运行应用。
  • F11 调试应用。
  • Ctr + Shift + O 组织当前源文件的 Java 导入。
  • Ctr + Shift + F 格式化当前源文件。
  • Ctr + Shift + T 跳转到任何 Java 类。
  • Ctr + Shift + R 跳转到任意资源文件;即图像、文本文件等等。
  • Alt + Shift + T 调出当前选择的重构菜单。
  • Ctrl + O 让您跳转到当前打开的 Java 类中的任何方法或字段。

Eclipse 中有许多更有用的特性,但是掌握这些基本的键盘快捷键可以显著加快游戏开发,让 Eclipse 中的生活稍微好一点。Eclipse 也是非常可配置的。这些键盘快捷键中的任何一个都可以在偏好设置中重新分配给不同的键。

在 Eclipse 中创建新项目并编写代码

有了我们的开发设置,我们现在可以在 Eclipse 中创建我们的第一个 Android 项目。ADT 插件安装了几个向导,使得创建新的 Android 项目变得非常容易。

创建项目

创建新的 Android 项目有两种方法。第一种是在包资源管理器视图中右键单击(见图 2-5 ,然后从弹出菜单中选择新建image项目。在新建对话框中,选择 Android 类别下的 Android 项目。如您所见,在该对话框中有许多其他项目创建选项。这是在 Eclipse 中创建任何类型的新项目的标准方法。在对话框中单击确定后,Android 项目向导将打开。

第二种方法要简单得多:只需点击新的 Android 应用项目工具栏按钮(如前面的图 2-4 所示),这也会打开向导。

一旦你进入 Android 项目向导对话框,你必须做出一些决定。请遵循以下步骤:

  1. 定义应用名称。这是 Android 上的启动器中显示的名称。我们会用“你好世界”

  2. 指定项目名称。这是您的项目在 Eclipse 中将被引用的名称。习惯上使用全部小写字母,所以我们将输入“helloworld”

  3. 指定包名。这是您所有 Java 代码所在的包的名称。向导试图根据您的项目名来猜测您的包名,但是您可以根据自己的需要随意修改它。在本例中,我们将使用“com.helloworld”。

  4. 指定生成 SDK。选择安卓 4.1。这允许我们使用最新的 API。

  5. Specify the minimum required SDK. This is the lowest Android version your application will support. We’ll choose Android 1.5 (Cupcake, API level 3).

    注意在第一章中,你看到 Android 的每个新版本都向 Android 框架 API 添加了新的类。构建 SDK 指定了您希望在应用中使用这个 API 的哪个版本。例如,如果您选择 Android 4.1 build SDK,您将获得最新、最棒的 API 特性。但是,这也有风险:如果您的应用运行在使用较低 API 版本的设备上(比如说,运行 Android 版本的设备),那么如果您访问仅在 4.1 版本中可用的 API 特性,您的应用就会崩溃。在这种情况下,您需要在运行时检测支持的 SDK 版本,并在您确定设备上的 Android 版本支持该版本时,仅访问 4.1 特性。这听起来可能很糟糕,但是正如你将在第五章中看到的,给定一个好的应用架构,你可以很容易地启用和禁用某些特定于版本的功能,而没有崩溃的风险。

  6. 单击下一步。您将看到一个对话框,让您定义您的应用的图标。我们将保持一切不变,因此只需单击“下一步”。

  7. 在下一个对话框中,询问您是否想要创建一个空白活动。接受此选择,然后单击“下一步”继续。

  8. 在最后一个对话框中,您可以修改向导将为您创建的空白活动的一些属性。我们将活动名称设置为“HelloWorldActivity”,标题设置为“Hello World”单击“完成”将创建您的第一个 Android 项目。

注意设置所需的最低 SDK 版本有一些含义。该应用只能在 Android 版本等于或高于您指定的最低 SDK 版本的设备上运行。当用户通过 Google Play 应用浏览 Google Play 时,只会显示具有适当最低 SDK 版本的应用。

探索项目

在包浏览器中,您现在应该看到一个名为“helloworld”的项目如果你展开它和它的所有子节点,你会看到类似于图 2-6 的东西。这是大多数 Android 项目的一般结构。让我们稍微探索一下。

  • src/包含了你所有的 Java 源文件。请注意,这个包与您在 Android 项目向导中指定的包同名。
  • gen/包含 Android 构建系统生成的 Java 源文件。您不应该修改它们,因为它们会自动重新生成。
  • Android 4.1 告诉我们,我们正在以 Android 4.1 版本为目标进行构建。这实际上是一个标准 JAR 文件形式的依赖项,它保存了 Android 4.1 API 的类。
  • Android Dependencies 向我们展示了我们的应用链接到的任何支持库,同样是以 JAR 文件的形式。作为游戏开发者,我们不关心这些。
  • assets/是存储应用所需文件的地方(例如配置文件、音频文件等)。这些文件与您的 Android 应用打包在一起。
  • bin/保存已编译的代码,准备部署到设备或仿真器。与 gen/文件夹一样,我们通常不关心这个文件夹中发生了什么。
  • libs/保存我们希望应用依赖的任何额外的 JAR 文件。如果我们的应用使用 C/C++ 代码,它还包含本机共享库。我们将在第十三章中探讨这个问题。
  • RES/hold 应用需要的资源,例如图标、国际化字符串和通过 XML 定义的 UI 布局。像素材一样,资源也与应用打包在一起。
  • AndroidManifest.xml 描述了您的应用。它定义了应用包含哪些活动和服务,应用运行的最低和目标 Android 版本(假设),以及它需要哪些权限(例如,访问 SD 卡或网络)。
  • project.properties 和 proguard-project.txt 保存构建系统的各种设置。我们不会触及这一点,因为 ADT 插件会在必要时负责修改这些文件。

9781430246770_Fig02-06.jpg

图 2-6。 Hello World 项目结构

我们可以很容易地在 Package Explorer 视图中添加新的源文件、文件夹和其他资源,方法是右键单击我们想要放置新资源的文件夹,并选择 new 和我们想要创建的相应资源类型。但是现在,我们会让一切保持原样。接下来,让我们看看如何修改我们的基本应用设置和配置,以便它能够兼容尽可能多的 Android 版本和设备。

使应用兼容所有 Android 版本

之前我们创建了一个项目,指定 Android 1.5 作为我们的最低 SDK。遗憾的是,ADT 插件有一个小错误,它忘记为 Android 1.5 上的应用创建包含图标图像的文件夹。下面是我们解决这个问题的方法:

  1. 在 res/目录下创建一个名为 drawable/的文件夹。您可以在 Package Explorer 视图中直接这样做,方法是右键单击 res/目录,并从上下文菜单中选择 New image Folder。
  2. 将 ic_launcher.png 文件从 res/drawable-mdpi/文件夹复制到新的 assets/drawable/文件夹。Android 1.5 需要这个文件夹,而更高版本会根据屏幕大小和分辨率在其他文件夹中查找图标和其他应用资源。我们将在第四章中讨论这个问题。

有了这些改变,你的应用可以在目前所有的 Android 版本上运行!

编写应用代码

我们还没有写一行代码,所以让我们改变一下。Android 项目向导为我们创建了一个名为 HelloWorldActivity 的模板活动类,当我们在模拟器或设备上运行应用时,它将会显示出来。在 Package Explorer 视图中双击文件,打开类的源代码。我们将用清单 2-1 中的代码替换模板代码。

清单 2-1。HelloWorldActivity.java

package com.helloworld;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;

public class HelloWorldActivity extends Activity
                                implements View.OnClickListener {
    Button button;
    int touchCount;
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        button = new Button( this );
        button.setText( "Touch me!" );
        button.setOnClickListener( this );
        setContentView(button);
    }

    public void onClick(View v) {
        touchCount++;
        button.setText("Touched me " + touchCount + " time(s)");
    }
}

让我们剖析一下清单 2-1 ,这样你就能理解它在做什么。我们将把本质细节留给后面的章节。我们只想知道发生了什么。

源代码文件从标准的 Java 包声明和几个导入开始。大多数 android 框架类都位于 Android 包中。

package com.helloworld;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;

接下来,我们定义我们的 HelloWorldActivity,并让它扩展基类 Activity,这是由 Android 框架 API 提供的。活动很像传统桌面用户界面中的一个窗口,限制是活动总是充满整个屏幕(除了 Android 用户界面顶部的通知栏)。此外,我们让活动实现 OnClickListener 接口。如果您有使用其他 UI 工具包的经验,您可能会看到接下来会发生什么。一会儿会有更多的内容。

public class HelloWorldActivity extends Activity
                                implements View.OnClickListener {

我们让我们的活动有两个成员:一个按钮和一个计算按钮被触摸频率的 int。

    Button button;
    int touchCount;

每个 Activity 子类都必须实现抽象方法 Activity.onCreate(),当 Activity 第一次启动时,Android 系统会调用它一次。这取代了通常用来创建类实例的构造函数。必须调用基类 onCreate()方法作为方法体中的第一条语句。

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

接下来,我们创建一个按钮并设置它的初始文本。按钮是 Android 框架 API 提供的众多小部件之一。UI 小部件在 Android 上被称为视图。注意,button 是我们的 HelloWorldActivity 类的成员。我们以后需要参考它。

        button = new Button( this );
        button.setText( "Touch me!" );

onCreate()中的下一行设置按钮的 OnClickListener。OnClickListener 是一个回调接口,只有一个方法 OnClickListener.onClick(),单击按钮时会调用该方法。我们希望在点击时得到通知,所以我们让 HelloWorldActivity 实现该接口,并将其注册为按钮的 OnClickListener。

        button.setOnClickListener( this );

onCreate()方法中的最后一行将按钮设置为活动的内容视图。视图可以嵌套,活动的内容视图是这个层次结构的根。在我们的例子中,我们简单地将按钮设置为由活动显示的视图。为了简单起见,我们不会详细讨论在给定内容视图的情况下如何安排活动。

        setContentView(button);
    }

下一步只是实现 OnClickListener.onClick()方法,接口要求我们的活动使用该方法。每次单击按钮时都会调用此方法。在这个方法中,我们增加了 touchCount 计数器,并将按钮的文本设置为一个新的字符串。

    public void onClick(View v) {
        touchCount++;
        button.setText("Touched me" + touchCount + "times");
    }

因此,为了总结我们的 Hello World 应用,我们构造了一个带有按钮的活动。每次点击按钮时,我们相应地设置它的文本。这可能不是这个星球上最令人兴奋的应用,但它将用于进一步的演示目的。

注意,我们从来不需要手动编译任何东西。每当我们添加、修改或删除一个源文件或资源时,ADT 插件和 Eclipse 都会重新编译项目。这个编译过程的结果是一个 APK 文件,可以部署到仿真器或 Android 设备上。APK 文件位于项目的 bin/文件夹中。

在接下来的小节中,您将使用这个应用来学习如何在模拟器实例和设备上运行和调试 Android 应用。

在设备或仿真器上运行应用

一旦我们编写了应用代码的第一个迭代,我们希望运行并测试它来识别潜在的问题,或者只是对它的辉煌感到惊讶。我们有两种方法可以实现这一点:

  • 我们可以在通过 USB 连接到开发 PC 的真实设备上运行我们的应用。
  • 我们可以启动 SDK 中包含的模拟器,并在那里测试我们的应用。

在这两种情况下,我们都必须做一些设置工作,然后才能最终看到我们的应用在运行。

连接设备

在连接设备进行测试之前,我们必须确保操作系统能够识别它。在 Windows 上,这涉及到安装适当的驱动程序,这是我们之前安装的 SDK 的一部分。只需连接您的设备并遵循 Windows 的标准驱动程序安装项目,将过程指向 SDK 安装根目录中的驱动程序/文件夹。对于某些设备,您可能需要从制造商的网站上获取驱动程序。许多设备可以使用 SDK 附带的 Android ADB 驱动程序;但是,通常需要一个过程来将特定的设备硬件 ID 添加到 INF 文件中。在谷歌上快速搜索设备名称和“Windows ADB ”,通常会获得与该特定设备连接所需的信息。

在 Linux 和 Mac OS X 上,你通常不需要安装任何驱动程序,因为它们是操作系统自带的。根据您的 Linux 风格,您可能需要稍微调整一下您的 USB 设备发现,通常是为 udev 创建一个新的规则文件。这因设备而异。快速的网络搜索应该会为你的设备带来一个解决方案。

创建 Android 虚拟设备

SDK 附带了一个模拟器,可以运行 Android 虚拟设备(avd)。一个 Android 虚拟设备由一个特定 Android 版本的系统映像、一个皮肤和一组属性组成,包括屏幕分辨率、SD 卡大小等等。

要创建一个 AVD,你必须启动 Android 虚拟设备管理器。您可以按照之前在 SDK 安装步骤中描述的方式来完成这项工作,也可以通过单击工具栏中的 AVD Manager 按钮直接在 Eclipse 中完成这项工作。你可以使用现有的 avd。相反,让我们来看一下创建自定义 AVD 的步骤:

  1. Click the New button on the right side of the AVD Manager screen, which opens the Edit Android Virtual Device (AVD) dialog, shown in Figure 2-7.

    9781430246770_Fig02-07.jpg

    图 2-7。编辑 Android 虚拟设备(AVD)对话框

  2. 每个 AVD 都有一个名称,您可以通过它来引用它。你可以自由选择任何你想要的名字。

  3. 目标指定 AVD 应该使用的 Android 版本。对于我们简单的“hello world”项目,您可以选择一个 Android 4.0.3 目标。

  4. CPU/ABI 指定 AVD 应该模拟哪种 CPU 类型。在此选择手臂。

  5. 您可以通过皮肤设置中的选项指定 AVDsd 卡的大小以及屏幕大小。让这些字段保持原样。对于实际测试,您通常会希望创建多个 avd,覆盖您希望应用处理的所有 Android 版本和屏幕尺寸。

  6. 启用快照选项将在您关闭时保存模拟器的状态。下次启动时,模拟器将加载保存状态的快照,而不是引导。这可以在启动新的模拟器实例时节省一些时间。

  7. 硬件选项更先进。我们将在下一节中探究其中的一些。它们允许您修改仿真器设备和仿真器本身的低级属性,例如仿真器的图形输出是否应该进行硬件加速。

注意除非你有几十个不同 Android 版本和屏幕尺寸的不同设备,否则你应该使用仿真器对 Android 版本/屏幕尺寸组合进行额外测试。

安装高级仿真器功能

现在有一些硬件虚拟化实现支持 Android 模拟器,英特尔是其中之一。如果您有英特尔 CPU,您应该能够安装英特尔硬件加速执行管理器(HAXM) ,它与 x86 仿真器映像结合使用,将虚拟化您的 CPU,并且运行速度明显快于普通的完全仿真映像。与此同时,启用 GPU 加速将(理论上)提供一个合理的性能测试环境。我们对这些工具当前状态的经验是,它们仍然有一点缺陷,但事情看起来很有希望,所以请确保关注 Google 的官方声明。同时,让我们做好准备:

  1. 从英特尔下载并安装 HAXM 软件,可在http://software.intel.com/en-us/articles/intel-hardware-accelerated-execution-manager/获得。

  2. Once installed, you will need to make sure you have installed the specific AVD called Intel x86 Atom System Image. Open the SDK Manager, navigate to the Android 4.0.3 section, and check if the image is installed (see Figure 2-8). If it is not installed, check the entry, then click “Install packages . . .”

    9781430246770_Fig02-08.jpg

    图 2-8。为 ICS 选择 x86 Atom 系统映像

  3. Create a specific AVD for the x86 image. Follow the steps to create a new AVD described in the last section, but this time make sure to select the Intel Atom (x86) CPU. In the Hardware section, add a new property called GPU emulation and set its value to yes, as shown in Figure 2-9.

    9781430246770_Fig02-09.jpg

    图 2-9。创建启用 GPU 仿真的 x86 AVD

现在您已经准备好了新的模拟器映像,我们需要让您了解一些注意事项。在测试时,我们得到了一些混合的结果。图 2-10 中的图像来自一款 2D 游戏,该游戏使用 OpenGL 1.1 多重纹理在角色上获得微妙的灯光效果。如果你仔细观察这幅图像,你会发现敌人的脸是横着的,上下颠倒。正确的渲染总是让它们正面朝上,所以这肯定是个错误。另一个更复杂的游戏崩溃了,无法运行。这并不是说硬件加速的 AVD 没有用,因为对于更基本的渲染来说,它可能工作得很好,如果你注意到右下角的数字 61,那基本上意味着它每秒运行 60 帧(FPS)——这是这台测试 PC 上 Android 模拟器上 GL 的新纪录!

9781430246770_Fig02-10.jpg

图 2-10。快速 OpenGL ES 1.1 仿真,但有一些渲染错误

图 2-11 中的图像显示了运行 OpenGL ES 2.0 的演示的主屏幕。虽然演示渲染正确,但帧速率开始一般,最后相当糟糕。这个菜单中并没有渲染太多东西,已经降低到 45FPS 了。主要的演示游戏运行速度为 15 到 30FPS,而且非常简单。很高兴看到 ES 2.0 运行,但显然还有一些改进的空间。

9781430246770_Fig02-11.jpg

图 2-11。 OpenGL ES 2.0 工作正常,但帧速率较低

尽管我们在本节中概述了这些问题,但新的模拟器加速是 Android SDK 的一个受欢迎的补充,如果您选择不在设备上专门测试,我们建议您在游戏中试用它。有很多情况下它会工作得很好,你可能会发现你有更快的周转时间测试,这就是它的全部。

运行应用

现在您已经设置了您的设备和 avd,您终于可以运行 Hello World 应用了。在 Eclipse 中,您可以通过在 Package Explorer 视图中右键单击“hello world”项目,然后选择 Run As image Android Application(或者您可以单击工具栏上的 Run 按钮)来轻松实现这一点。然后,Eclipse 将在后台执行以下步骤:

  1. 如果自上次编译以来有任何文件发生了更改,则将项目编译为 APK 文件。
  2. 为 Android 项目创建一个新的运行配置(如果尚不存在的话)。(我们稍后将了解运行配置。)
  3. 通过使用合适的 Android 版本启动或重用已经运行的仿真器实例,或者通过在连接的设备上部署和运行应用来安装和运行应用(该设备还必须至少运行您在创建项目时指定为最低必需 SDK 级别的最低 Android 版本)。

注意第一次在 Eclipse 中运行 Android 应用时,会询问您是否希望 ADT 对设备/仿真器输出中的消息做出反应。因为您总是需要所有的信息,所以只需单击 OK。

如果您没有连接设备,ADT 插件将启动您在 AVD 管理器窗口中看到的一个 AVD。输出应该看起来像图 2-12 。

9781430246770_Fig02-12.jpg

图 2-12。Hello World 应用正在运行

模拟器的工作方式几乎与真实设备完全一样,您可以通过鼠标与它进行交互,就像用手指在设备上操作一样。以下是真实设备和模拟器之间的一些差异:

  • 模拟器仅支持单点触摸输入。简单地使用你的鼠标光标,假装它是你的手指。
  • 模拟器缺少一些应用,例如 Google Play 应用。
  • 要更改设备在屏幕上的方向,请不要倾斜显示器。相反,使用数字键盘上的 7 键来更改方向。您必须先按下数字键盘上方的 Num Lock 键来禁用其数字功能。
  • 模拟器非常慢。不要通过在模拟器上运行来评估应用的性能。
  • 4.0.3 之前的模拟器版本仅支持 OpenGL ES 1.x. OpenGL ES 2.0 及更高版本的模拟器支持 OpenGL ES 2.0。我们将在第七章中讨论 OpenGL ES。模拟器将很好地为我们的基本测试工作。一旦我们深入到 OpenGL,你会想要得到一个真实的设备来测试,因为即使我们使用了最新的模拟器,OpenGL 实现(虚拟化和软件化)仍然有一点缺陷。现在,请记住不要在模拟器上测试任何 OpenGL ES 应用。

玩一会儿,感觉舒服点。

注意启动一个新的仿真器实例需要相当长的时间(根据您的硬件,最长可达 10 分钟)。您可以让模拟器在整个开发会话期间一直运行,这样您就不必重复重新启动它,或者您可以在创建或编辑 AVD 时检查 Snapshot 选项,这将允许您保存和恢复虚拟机(VM)的快照,从而实现快速启动。

有时,当我们运行 Android 应用时,ADT 插件执行的自动仿真器/设备选择是一个障碍。例如,我们可能连接了多个设备/仿真器,我们希望在一个特定的设备/仿真器上测试我们的应用。为了解决这个问题,我们可以在 Android 项目的运行配置中关闭自动设备/仿真器选择。那么,什么是运行配置呢?

当您告诉 Eclipse 运行应用时,运行配置提供了一种方式来告诉 Eclipse 应该如何启动您的应用。运行配置通常允许您指定传递给应用的命令行参数、VM 参数(在 Java SE 桌面应用的情况下)等等。Eclipse 和第三方插件为特定类型的项目提供了不同的运行配置。ADT 插件将 Android 应用运行配置添加到可用运行配置集中。当我们在本章前面第一次运行我们的应用时,Eclipse 和 ADT 在后台用默认参数为我们创建了一个新的 Android 应用运行配置。

要获得 Android 项目的运行配置,请执行以下操作:

  1. 在 Package Explorer 视图中右键单击项目,并选择 Run AsimageRun configuration。
  2. 从左侧的列表中,选择“hello world”项目。
  3. 在对话框的右侧,您现在可以修改运行配置的名称,并更改 Android、Target 和 Commons 选项卡上的其他设置。
  4. 要将自动部署更改为手动部署,请单击目标选项卡并选择手动。

当您再次运行应用时,系统会提示您选择一个兼容的仿真器或设备来运行应用。图 2-13 显示了该对话框。

9781430246770_Fig02-13.jpg

图 2-13。选择运行应用的仿真器/设备

该对话框显示所有正在运行的仿真器和当前连接的设备,以及所有其他当前未运行的 avd。您可以选择任何模拟器或设备来运行您的应用。注意连接设备旁边的红色 × 。这通常表明应用不能在这个设备上运行,因为它的版本低于我们指定的目标 SDK 版本(在本例中是 14 对 15)。然而,因为我们指定了最低 SDK 版本 3 (Android 1.5),所以我们的应用实际上也可以在这个设备上工作。

调试和分析应用

有时,您的应用会以意想不到的方式运行或崩溃。为了找出到底哪里出错了,您希望能够调试您的应用。

Eclipse 和 ADT 为我们提供了极其强大的 Android 应用调试工具。我们可以在源代码中设置断点,检查变量和当前堆栈跟踪,等等。

通常,在调试之前设置断点,以检查程序中某些点的程序状态。要设置断点,只需在 Eclipse 中打开源文件,双击要设置断点的行前面的灰色区域。出于演示目的,对 HelloWorldActivity 类中的第 23 行执行此操作。这将使调试器在您每次单击该按钮时停止。双击该行后,源代码视图会在该行前显示一个小圆圈,如图 2-14 所示。您可以通过在源代码视图中再次双击断点来移除断点。

9781430246770_Fig02-14.jpg

图 2-14。设置断点

如前一节所述,开始调试非常类似于运行应用。在 Package Explorer 视图中右键单击项目,并选择 Debug As image Android Application。这将为您的项目创建一个新的调试配置,就像简单地运行应用一样。您可以通过从上下文菜单中选择 Debug As image Debug Configurations 来更改该调试配置的默认设置。

注意您可以使用 Run 菜单来运行和调试应用并访问配置,而不是在 Package Explorer 视图中浏览项目的上下文菜单。

如果您开始第一个调试会话,并且命中了一个断点(例如,您在我们的应用中点击按钮),Eclipse 会询问您是否想要切换到调试透视图,您可以确认这一点。先来看看那个视角。图 2-15 显示了我们开始调试 Hello World 应用后的样子。

9781430246770_Fig02-15.jpg

图 2-15。调试视角

如果您还记得我们对 Eclipse 的快速浏览,那么您会知道有几个不同的透视图,它们由一组特定任务的视图组成。调试透视图与 Java 透视图看起来非常不同。

  • 左上角的 Debug 视图显示了所有当前正在运行的应用,以及它们所有线程的堆栈跟踪(如果应用在调试模式下运行并被挂起)。
  • 调试视图下面是源代码视图,它也出现在 Java 透视图中。
  • 控制台视图也出现在 Java 透视图中,它打印出来自 ADT 插件的消息,告诉我们它正在做什么。
  • 任务列表视图(控制台视图旁边带有标签“Tasks”的选项卡与 Java 透视图中的相同。我们通常不需要它,你可以关闭它。
  • LogCat view 将是您旅途中最好的朋友之一。这个视图显示了运行应用的仿真器/设备的日志输出。日志输出来自系统组件、其他应用和您自己的应用。LogCat 视图将在应用崩溃时向您显示堆栈跟踪,并允许您在运行时输出自己的日志消息。在下一节中,我们将进一步了解 LogCat。
  • Outline 视图也出现在 Java 透视图中,但在 Debug 透视图中不是很有用。您通常会关心断点和变量,以及在调试时程序被挂起的当前行。我们经常从 Debug 透视图中删除 Outline 视图,以便为其他视图留出更多空间。
  • Variables 视图对于调试特别有用。当调试器遇到断点时,您将能够在程序的当前范围内检查和修改变量。
  • 断点视图显示了到目前为止您已经设置的断点列表。

如果您很好奇,您可能已经在运行的应用中单击了按钮,以查看调试器的反应。它将在第 23 行停止,正如我们在那里设置断点所指示的那样。您还会注意到,Variables 视图现在显示了当前范围内的变量,包括活动本身(this)和方法的参数(v)。您可以通过展开这些变量来进一步深入研究它们。

Debug 视图向您显示当前堆栈的堆栈跟踪,一直到您当前所在的方法。请注意,您可能有多个线程正在运行,并且可以在 Debug 视图中随时暂停它们。

最后,请注意,我们设置断点的那一行被突出显示,表明程序当前在代码中暂停的位置。

您可以指示调试器执行当前语句(通过按 F6),单步执行当前方法中调用的任何方法(通过按 F5),或者继续正常执行程序(通过按 F8)。或者,您可以使用“运行”菜单上的项目来实现同样的目的。此外,请注意,除了我们刚刚提到的那些,还有更多步进选项。和所有事情一样,我们建议你尝试一下,看看什么对你有用,什么没用。

注意好奇心是成功开发 Android 游戏的基石。您必须熟悉您的开发环境,才能充分利用它。这种范围的书不可能解释 Eclipse 的所有本质细节,所以我们敦促您进行实验。

洛卡特和 DDMS

ADT Eclipse 插件安装了许多将在 Eclipse 中使用的新视图和透视图。最有用的视图之一是 LogCat 视图,我们在上一节中简要地提到了它。

LogCat 是 Android 事件记录系统,它允许系统组件和应用输出关于各种记录级别的记录信息。每个日志条目都由时间戳、日志记录级别、日志来自的进程 ID、由日志记录应用本身定义的标记以及实际的日志记录消息组成。

LogCat 视图从连接的仿真器或设备收集并显示这些信息。图 2-16 显示了 LogCat 视图的一些示例输出。

9781430246770_Fig02-16.jpg

图 2-16。log cat 视图

请注意,在 LogCat 视图的左上角和右上角有许多按钮:

  • 加号和减号按钮允许您添加和删除过滤器。已经有一个过滤器只显示来自我们应用的日志消息。
  • 减号按钮右侧的按钮允许您编辑现有的过滤器。
  • 下拉列表框允许您选择消息必须在下面的窗口中显示的日志级别。
  • 下拉列表框右侧的按钮允许您(按从左到右的顺序)保存当前日志输出、清除日志控制台、切换左侧过滤器窗口的可见性以及停止更新控制台窗口。

如果当前连接了几个设备和模拟器,那么 LogCat 视图将只输出其中一个的日志数据。为了获得更细粒度的控制和更多的检查选项,您可以切换到 DDMS 透视图。

DDMS (Dalvik 调试监控服务器)提供了大量关于所有连接设备上运行的进程和 Dalvik 虚拟机的深入信息。您可以随时通过窗口image打开视角image其他image DDMS 切换到 DDMS 视角。图 2-17 显示了 DDMS 视角通常的样子。

9781430246770_Fig02-17.jpg

图 2-17。 DDMS 在行动

和往常一样,几种特定的观点适合我们手头的任务。在这种情况下,我们希望收集关于所有进程、它们的虚拟机和线程、堆的当前状态、关于特定连接设备的 LogCat 信息等信息。

  • Devices 视图显示所有当前连接的仿真器和设备,以及在其上运行的所有进程。通过该视图的工具栏按钮,您可以执行各种操作,包括调试选定的进程、记录堆和线程信息以及截图。

  • LogCat 视图与 Debug 透视图中的相同,不同之处在于它将显示 Devices 视图中当前所选设备的输出。

  • 模拟器控件视图允许您改变正在运行的模拟器实例的行为。例如,您可以强制模拟器伪造 GPS 坐标进行测试。

  • 图 2-17 中的所示的线程视图显示了在设备视图中当前选择的进程上运行的线程的信息。仅当您还启用了线程跟踪时,线程视图才会显示此信息,这可以通过单击设备视图中左起第五个按钮来实现。

  • 堆视图提供了设备上堆的状态信息。与线程信息一样,您必须通过单击左边第二个按钮,在 Devices 视图中显式启用堆跟踪。

  • 分配跟踪器视图显示了哪些类在最*几分钟内被分配得最多。这个视图提供了一个寻找内存泄漏的好方法。

  • 网络状态视图允许您跟踪通过连接的 Android 设备或模拟器的网络连接发送的传入和传出字节数。

  • 文件浏览器视图允许您修改连接的 Android 设备或仿真器实例上的文件。您可以像使用标准操作系统文件资源管理器一样,将文件拖放到该视图中。

DDMS 实际上是一个通过 ADT 插件与 Eclipse 集成的独立工具。你也可以从$ANDROID _ HOME/tools 目录(Windows 上的 %ANDROID_HOME%/tools)作为独立应用启动 DDMS。DDMS 并不直接连接设备,而是使用 Android Debug Bridge (ADB),这是 SDK 中包含的另一个工具。让我们来看看 ADB,以完善您对 Android 开发环境的了解。

使用 ADB

ADB 允许您管理连接的设备和仿真器实例。它实际上由三部分组成:

  • 运行在开发机器上的客户机,您可以通过发出 adb 命令从命令行启动它(如果您按照前面的描述设置了环境变量,它应该可以工作)。当我们谈到 ADB 时,我们指的是这个命令行程序。
  • 也在您的开发机器上运行的服务器。服务器作为后台服务安装,它负责 ADB 程序实例和任何连接的设备或仿真器实例之间的通信。
  • ADB 守护进程,它也作为后台进程在每个仿真器和设备上运行。ADB 服务器连接到这个守护进程进行通信。

通常,我们通过 DDMS 透明地使用 ADB,而忽略它作为命令行工具的存在。有时,ADB 可以在小任务中派上用场,所以让我们快速浏览一下它的一些功能。

查看 Android 开发者网站上的 ADB 文档,获取可用命令的完整参考列表。

使用 ADB 执行的一个非常有用的任务是查询所有连接到 ADB 服务器的设备和仿真器(以及您的开发机器)。为此,请在命令行上执行以下命令(注意>不是该命令的一部分):

> adb devices

这将打印出所有连接的设备和仿真器的列表,以及它们各自的序列号,类似于以下输出:

List of devices attached
HT97JL901589    device
HT019P803783    device

设备或仿真器的序列号用于指定后续命令。以下命令将在序列号为 HT019P803783 的设备上安装位于开发机器上的名为 myapp.apk 的 APK 文件:

> adb –s HT019P803783 install myapp.apk

–s 参数可以与任何执行针对特定设备的操作的 ADB 命令一起使用。

还存在将文件复制到设备或仿真器以及从设备或仿真器复制文件的命令。以下命令将名为 myfile.txt 的本地文件复制到序列号为 HT019P803783 的设备的 SD 卡上:

> adb –s HT019P803783 push myfile.txt  /sdcard/myfile.txt

要从 SD 卡中提取名为 myfile.txt 的文件,您可以发出以下命令:

> abd pull /sdcard/myfile.txt myfile.txt

如果当前只有一个设备或仿真器连接到 ADB 服务器,您可以省略序列号。adb 工具将自动为您定位连接的设备或仿真器。

也可以通过网络(没有 USB)使用 ADB 调试设备。这被称为 ADB 远程调试,在某些设备上是可能的。要检查您的设备是否可以做到这一点,找到开发者选项,看看“ADB over network”是否在选项列表中。如果是这样,你很幸运。只需在您的设备上启用这个远程调试选项,然后运行以下命令:

> adb connect ipaddress

连接后,设备将显示为通过 USB 连接。如果不知道 IP 地址,通常可以通过触摸当前接入点名称在 Wi-Fi 设置中找到。

当然,ADB 工具提供了更多的可能性。大多数是通过 DDMS 暴露的,我们通常使用它而不是命令行。但是,对于快速任务,命令行工具是理想的。

有用的第三方工具

Android SDK 和 ADT 可能提供了大量的功能,但是还有许多非常有用的第三方工具,下面列出了其中的一些,它们可以在以后的开发中帮助您。这些工具可以监视 CPU 的使用情况,告诉您 OpenGL 渲染的情况,帮助您找到内存或文件访问中的瓶颈,等等。您需要将设备中的芯片与芯片制造商提供的工具相匹配。以下列表包括制造商和 URL,以帮助您进行匹配。排名不分先后:

  • Adreno Profiler :用于高通/骁龙设备(主要是 HTC,但也有很多其他);https://developer.qualcomm.com/mobile-development/mobile-technologies/gaming-graphics-optimization-adreno/tools-and-resources
  • PVRTune/PVRTrace :用在 PowerVR 芯片上(三星,LG,等);http://www.imgtec.com/powervr/insider/powervr-utilities.asp
  • NVidia PerfHUD ES :用在 Tegra 芯片上(LG、三星、摩托罗拉等);http://developer.nvidia.com/mobile/perfhud-es

我们不会详细讨论安装或使用这些工具的细节,但是当你准备认真对待你的游戏性能时,请务必回到这一部分并深入研究。

摘要

Android 开发环境有时可能有点吓人。幸运的是,您只需要可用选项的一个子集就可以开始了,本章末尾的“使用 ADB”一节应该已经为您提供了足够的信息来开始一些基本的编码。

从这一章中学到的最重要的一课是如何将这些部分组合在一起。JDK 和 Android SDK 为所有 Android 开发提供了基础。它们提供了在仿真器实例和设备上编译、部署和运行应用的工具。为了加快开发速度,我们将 Eclipse 与 ADT 插件结合使用,该插件完成了我们原本必须使用 JDK 和 SDK 工具在命令行上完成的所有繁重工作。Eclipse 本身建立在几个核心概念之上:工作区,它管理项目;视图,提供特定的功能,如源代码编辑或 LogCat 输出;透视图,它将特定任务(如调试)的视图联系在一起;以及运行和调试配置,这些配置允许您指定运行或调试应用时使用的启动设置。

掌握这一切的秘诀是实践,尽管这听起来很枯燥。在整本书中,我们将实现几个项目,这些项目会让你对 Android 开发环境更加熟悉。然而,在一天结束的时候,这取决于你是否能更进一步。

有了这些信息,你就可以继续你最初阅读这本书的原因:开发游戏。

三、游戏开发 101

游戏开发很难——不是因为它是火箭科学,而是因为在你真正开始编写你梦想的游戏之前,有大量的信息需要消化。在编程方面,您必须担心诸如文件输入/输出(I/O)、用户输入处理、音频和图形编程以及网络代码之类的日常事务。而这些只是基础!最重要的是,你会想要建立你真正的游戏机制。代码也需要结构,如何创建游戏的架构并不总是显而易见的。你实际上必须决定如何让你的游戏世界移动。你能不使用物理引擎,而使用你自己的简单模拟代码吗?你的游戏世界设定的单位和尺度是什么?如何翻译到屏幕上?

实际上还有另一个许多初学者忽略的问题,那就是,在你开始动手之前,你实际上必须首先设计你的游戏。数不清的项目从未公开,并陷入技术演示阶段,因为对游戏实际上应该如何运行从来没有任何清晰的想法。我们不是在谈论你的普通第一人称射击游戏的基本游戏机制。这是最简单的部分:WASD 移动键加鼠标,你就完成了。你应该问自己这样的问题:有闪屏吗?它过渡到什么?主菜单屏幕上有什么?实际游戏画面上有哪些*视显示元素?如果我按下暂停按钮会发生什么?设置屏幕上应该提供什么选项?我的 UI 设计在不同的屏幕尺寸和长宽比下会怎样?

有趣的是没有灵丹妙药;没有处理所有这些问题的标准方法。我们不会假装给你开发游戏的终极解决方案。相反,我们将尝试说明我们通常是如何设计游戏的。你可以决定完全适应它或者修改它以更好地满足你的需要。没有规则——对你有效的就行。然而,你应该总是努力寻找一个简单的解决方案,无论是在代码上还是在纸上。

流派:适合每个人的口味

在你的项目开始时,你通常决定你的游戏将属于哪种类型。除非你想出一些全新的、前所未见的东西,否则你的游戏创意很有可能会符合当前流行的广泛类型之一。大多数流派都建立了游戏机制标准(例如,控制方案、特定目标等等)。偏离这些标准可以让游戏大受欢迎,因为游戏玩家总是渴望新的东西。不过,这也是一个很大的风险,所以要仔细考虑你的新*台玩家/第一人称射击游戏/即时战略游戏是否真的有观众。

让我们来看看 Google Play 上更受欢迎的流派的一些例子。

休闲游戏

可能 Google Play 上最大的游戏部分是所谓的休闲游戏。那么到底什么是休闲游戏呢?这个问题没有具体的答案,但是休闲游戏有一些共同的特点。通常,它们具有很好的可访问性,因此即使非游戏玩家也可以很容易地使用它们,这极大地增加了潜在玩家的数量。一场游戏最多只需要几分钟。然而,休闲游戏的简单性容易让人上瘾,经常让玩家沉迷几个小时。实际的游戏机制从极其简单的益智游戏到一键*台游戏,再到像把纸团扔进篮子这样简单的事情。由于休闲风格的模糊定义,这种可能性是无穷无尽的。

神庙逃亡(见图 3-1 ),由伊玛吉工作室制作,是完美的休闲游戏范例。你引导一个人物通过充满障碍的多条轨迹。整个输入方案是基于滑动的。如果你向左或向右滑动,角色会向那个方向转弯(假设前面有一个十字路口)。如果你向上滑动,角色会跳跃,而向下滑动会使角色滑到障碍物下面。一路上你可以获得各种奖励和动力。易于理解的控制、明确的目标和漂亮的 3D 图形使这款游戏在苹果应用商店和谷歌 Play 上一炮而红。

9781430246770_Fig03-01.jpg

图 3-1。伊曼吉工作室的《神庙逃亡》

宝石矿工:挖得更深(见图 3-2 ),由一人军 Psym 机动,是完全不同的动物。这是同一家公司大获成功的宝石矿工 的续集。它只是稍微迭代了一下原文。你扮演一名矿工,试图在随机产生的矿中找到有价值的矿石、金属和宝石。这些宝藏可以用来交换更好的设备,以挖掘更深的地方,找到更有价值的宝藏。它利用了一个事实,即许多人喜欢研磨的概念:没有太多的努力,你就不断地得到新的噱头,让你玩下去。这个游戏的另一个有趣的方面是地雷是随机产生的。这极大地增加了游戏的重玩价值,而没有增加额外的游戏机制。为了增加趣味,游戏提供了具有具体目标的挑战关卡,完成后你可以获得奖牌。这是一个非常轻量级的成就系统。

9781430246770_Fig03-02.jpg

图 3-2。宝石矿工:深入挖掘,Psym Mobile

这个游戏更有趣的一面是它的赚钱方式。尽管目前的趋势是“免费增值”游戏(游戏本身是免费的,而额外的内容可以以经常是荒谬的价格购买),它使用的是“老派”付费模式。大约 2 美元一张,超过 100,000 次下载,这对于一个非常简单的游戏来说是相当大的一笔钱。这种销售数字在 Android 上很少见,尤其是 Psym Mobile 基本上没有为游戏做任何广告。前作的成功及其庞大的玩家基础很大程度上保证了续集的成功。

休闲游戏类别的所有可能的子类别的列表将会占据本书的大部分。在这个流派中可以找到许多更具创新性的游戏概念,值得在市场上查看各自的类别以获得一些灵感。

益智游戏

益智游戏无需介绍。我们都知道一些很棒的游戏,比如俄罗斯方块和 ?? 宝石迷阵。他们是安卓游戏市场的重要组成部分,在所有人群中都很受欢迎。与基于 PC 的益智游戏(通常只涉及将三个颜色或形状的物体放在一起)相比,Android 上的许多益智游戏偏离了经典的 match-3 公式,使用了更复杂的基于物理的谜题。

切绳子(见图 3-3 ),作者 ZeptoLab,是一个物理学难题的极好例子。游戏的目标是给每个屏幕上的小生物喂糖果。这块糖必须通过切断它所系的绳子,将它放入气泡中以便它可以向上漂浮,绕过障碍物等等来引导它。每个游戏对象在某种程度上都是物理模拟的。这款游戏由 2D 物理引擎 Box2D 驱动。割绳子在 iOS 应用商店和 Google Play 上都获得了瞬间的成功,甚至已经被移植到浏览器中运行!

9781430246770_Fig03-03.jpg

图 3-3。割断绳子,由 ZeptoLab

器械(见图 3-4 ),由 Bithack(另一个一人公司)制作,深受老牌 Amiga 和 PC 经典不可思议机器的影响。像切断绳子,这是一个物理难题,但它给了玩家更多的控制她解决每个难题的方式。各种积木,如可以钉在一起的简单木头、绳子、马达等等,可以以创造性的方式组合起来,将蓝色的球从关卡的一端带到目标区域。

9781430246770_Fig03-04.jpg

图 3-4。仪器,由 Bithack

除了有预制关卡的战役模式,还有一个沙盒环境,在那里你可以发挥你的创造力。更好的是,你的定制装置可以很容易地与他人分享。设备的这个方面保证了即使玩家已经完成了游戏,仍然有大量的额外内容需要探索。

当然,你也可以在市场上找到各种各样的俄罗斯方块克隆版,match-3 游戏,以及其他标准公式。

动作和街机游戏

动作和街机游戏通常会释放 Android *台的全部潜力。其中许多都具有令人惊叹的 3D 视觉效果,展示了在当前这一代硬件上的可能性。这种类型有许多子类别,包括赛车游戏、射击游戏、第一和第三人称射击游戏以及*台游戏。在过去的几年里,随着大型游戏工作室开始将其游戏移植到 Android 上,Android 市场的这一部分已经获得了很大的吸引力。

SHADOWGUN (见图 3-5 ),由 MADFINGER Games 出品,是一款视觉效果惊人的第三人称射击游戏,展示了最*的 Android 手机和*板电脑的计算能力。与许多 AAA 游戏一样,它在 Android 和 iOS 上都可以使用。 SHADOWGUN 利用跨*台游戏引擎 Unity,是 Unity 在移动设备上的力量的典型代表之一。游戏性方面,它是一个双模拟棍射击游戏,甚至允许躲在板条箱和其他通常在手机动作游戏中找不到的漂亮机械装置后面。

9781430246770_Fig03-05.jpg

图 3-5。 SHADOWGUN,由 MADFINGER 游戏

虽然很难获得确切的数字,但 Android 市场的统计数据似乎表明, SHADOWGUN 的下载量与之前讨论过的宝石矿大致相当。这表明,创造一款成功的 Android 游戏并不一定需要一个庞大的 AAA 团队。

坦克英雄:激光战争(见图 3-6 ) 是坦克英雄的续集,由一个名为 Clapfoot Inc .的非常小的独立团队创作。你指挥一辆坦克,你可以装备越来越多疯狂的附件,如射线枪、声波炮等等。关卡非常小,限制在*坦的战场上,周围散布着互动元素,你可以利用这些元素来消灭游戏场上所有其他的敌方坦克。通过简单地触摸敌人或游戏场地来控制坦克,作为回应,它将采取适当的行动(分别是射击或移动)。虽然它在视觉上还没有达到 SHADWOGUN 的水*,但它仍然拥有相当好看的动态照明系统。这里要吸取的教训是,即使是小团队,如果他们对内容加以约束,比如限制比赛场地的大小,也可以创造出视觉上令人愉悦的体验。

9781430246770_Fig03-06.jpg

图 3-6。《坦克英雄:激光战争》,克拉普富特公司出品。

龙,飞吧!(见图 3-7 ),by Four pixel,改编自 Andreas Illiger 的极其成功的游戏 Tiny Wings ,在撰写本文时该游戏仅在 iOS 上可用。你控制一条小龙在几乎无限多的斜坡上上下下,同时收集各种宝石。如果加速足够快,小龙可以起飞和飞行。这是通过在下坡时触摸屏幕来实现的。机制非常简单,但随机生成的世界和对更高分数的渴望使人们回来寻求更多。

9781430246770_Fig03-07.jpg

图 3-7。龙,飞!,四个像素

龙,飞吧!很好地说明了一个现象:通常,特定的手机游戏流派会出现在 iOS 上。即使有巨大的需求,原创者也不经常把他们的游戏移植到 Android 上。其他游戏开发商可以介入,为 Android 市场提供替代版本。这也可能完全适得其反,如果“灵感”游戏太过抄袭,就像 Zynga 对一款名为小塔的游戏所做的那样。推广一个创意通常会受到好评,而公然抄袭另一个游戏通常会遭到恶语相向。

【马克思·佩恩】(见图 3-8)由 Rockstar Games 出品,是一款 2001 年出版的老牌 PC 游戏的移植。我们把它放在这里是为了说明一个不断增长的趋势,即 AAA 出版商把他们的旧知识产权移植到移动环境中。《马克思·佩恩》讲述了一名警察的家庭被贩毒集团谋杀的故事。马克斯暴跳如雷,为妻子和孩子报仇。所有这一切都嵌入了黑色电影风格的叙事中,通过连环漫画和短片场景来展示。最初的游戏严重依赖于我们在 PC 上玩射击游戏时习惯使用的标准鼠标/键盘组合。Rockstar Games 成功创造了基于触摸屏的控件。虽然控制不如 PC 上的精确,但它们仍然足以让游戏在触摸屏上变得令人愉快。

*9781430246770_Fig03-08.jpg

图 3-8。马克思·佩恩,由摇滚明星游戏公司出品

动作和街机类型在市场上仍然有点代表性不足。玩家渴望好的动作游戏,所以那可能是你的专长!

塔防游戏

鉴于他们在 Android *台上的巨大成功,我们觉得有必要将塔防游戏作为他们自己的类型来讨论。塔防游戏作为由 modding 社区开发的 PC 即时战略游戏的变体变得流行起来。这个概念很快就被翻译成了单机游戏。塔防游戏目前是 Android 上最畅销的游戏类型。

在一个典型的塔防游戏中,一些主要是邪恶的力量在所谓的波浪中派出生物来攻击你的城堡/基地/水晶/你能想到的。你的任务是通过放置射击来袭敌人的防御炮塔来保卫游戏地图上的那个特殊地方。对于每一个你杀死的敌人,你通常会得到一些钱或点数,你可以投资在新的炮塔或升级上。这个概念非常简单,但是要在这种类型的游戏中找到*衡是非常困难的。

DroidHen 的是 Google Play 上最受欢迎的免费游戏之一,但它使用了 flash 游戏玩家所熟知的简单的塔防旋转。你有一个玩家控制的塔,而不是建造多个塔,它可以接受许多升级,从攻击力增加到分裂箭。除了主要武器,还有不同的科技法术树可以用来消灭入侵的敌军。这个游戏的好处在于它很简单,容易理解,而且很精致。图形都很干净,主题也很好,DroidHen 得到了恰到好处的*衡,这往往会让你玩得比你计划的时间长得多。这款游戏在赚钱方面很聪明,因为你可以获得许多免费升级,但对于没有耐心的人来说,你总是可以用真钱提前购买一些东西,并获得即时满足。

9781430246770_Fig03-09.jpg

图 3-9。防御者,由 DroidHen

防御者只有一个等级,但是它把坏人混在一起进行一波又一波的攻击。就好像你没有注意到它只有一个等级,因为它看起来很漂亮,会让你把更多的注意力放在坏人、你的武器和你的法术上。总的来说,这应该是一个小开发团队在合理的时间内开发出的游戏类型的好灵感,一个休闲玩家会喜欢的游戏。

社交游戏

你不会以为我们会跳过社交游戏吧?如果有什么不同的话,“社交”这个词是我们现代技术集体中最大的热门话题(也是最大的赚钱机器之一)。什么是社交游戏?在游戏中,你可以与朋友和熟人分享经验,通常以病毒式反馈循环的方式相互交流。这是惊人的强大,如果做得好,它可以滚雪球般变成雪崩式的成功。

Zynga 的 Words with Friends (见图 3-10 )将回合制游戏添加到已经建立的基于磁贴的单词创建类型中。《??》与朋友的对话的真正创新之处在于整合了聊天和多个同时进行的游戏。你可以同时玩很多游戏,这样就不用等待一个游戏了。一篇著名的评论(由约翰·梅耶撰写)称,“‘和朋友聊天’应用是新的 Twitter。”这很好地概括了 Zynga 如何很好地利用社交空间,并将其与一款非常容易上手的游戏相结合。

9781430246770_Fig03-10.jpg

图 3-10。Zynga 的《与朋友的话》

画东西(见图 3-11 ),由 OMGPOP 出品,是一款让玩家一笔一划猜测某人在画什么的游戏。这不仅很有趣,而且其他玩家也将自己的作品提交给朋友,这是众包内容的神奇之处。 Draw Something 乍一看像是一个基本的手指绘画应用,但仅仅几分钟后,游戏的精髓就真正显现出来了,因为你想立即提交你的猜测,猜测另一个,然后画出你自己的,并让你的朋友一起分享乐趣。

9781430246770_Fig03-11.jpg

图 3-11。画点东西,由 OMGPOP

超越流派

许多新游戏、创意、流派和应用一开始看起来并不是游戏,但它们确实是。因此,当进入 Google Play 时,很难真正明确指出现在有什么创新。我们见过这样的游戏,其中*板电脑被用作游戏主机,然后连接到电视,电视又通过蓝牙连接到多个 Android 手机,每个手机都被用作控制器。休闲、社交游戏已经做得很好了,许多在苹果*台上开始的热门游戏现在都移植到了 Android 上。一切可能的都已经做了吗?不可能!对于那些愿意冒险尝试一些新游戏创意的人来说,总会有尚未开发的市场和游戏创意。硬件变得越来越快,这开启了全新的可能性领域,以前由于缺乏 CPU 马力,这些可能性是不可行的。

所以,现在你已经知道 Android 上已经有什么可用的了,我们建议你启动 Google Play 应用,看看之前展示的一些游戏。注意它们的结构(例如,哪些屏幕通向其他哪些屏幕,哪些按钮做什么,游戏元素如何相互交互等等)。用分析的心态玩游戏,实际上可以获得对这些事情的感觉。暂且抛开娱乐因素,专心解构游戏。一旦你完成了,回来继续读下去。我们要在纸上设计一个非常简单的游戏。

游戏设计:笔比代码更强大

正如我们前面所说的,启动 IDE 并拼凑出一个不错的技术演示是很诱人的。如果你想建立实验游戏机制的原型,看看它们是否真的有效,这是可以的。然而,一旦你这样做了,就扔掉原型。拿起一支笔和一些纸,坐在一把舒适的椅子上,仔细思考你的游戏的所有高级方面。先不要专注于技术细节,你以后会做的。现在,你想专注于设计游戏的用户体验。做到这一点的最好方法是画出以下内容:

  • 核心游戏机制,包括关卡概念(如果适用的话)
  • 主要人物的粗略背景故事
  • 一系列的物品,能量或者其他可以改变角色,机械或者环境的东西
  • 基于背景故事和人物的图形风格草图
  • 所有相关屏幕的草图,屏幕之间的转换图,以及转换触发器(例如,游戏结束状态)

如果你看过目录,你就会知道我们将在 Android 上实现 Snake《蛇》是手机市场上最受欢迎的游戏之一。如果你还不知道,在继续阅读之前,在网上查一下。与此同时,我们将在这里等着。。。

欢迎回来。所以,现在你知道 Snake 是关于什么的了,让我们假装是我们自己想出了这个主意,并开始为它设计。让我们从游戏机制开始。

核心游戏机制

在我们开始之前,这里有一份我们需要的清单:

  • 一把剪刀
  • 用来写字的东西
  • 很多纸

在我们游戏设计的这个阶段,一切都是移动的目标。我们建议你用纸创建基本的构建模块,并在桌子上重新排列它们,直到它们合适为止,而不是用 Paint、Gimp 或 Photoshop 精心制作精美的图像。你可以很容易地从物理上改变事情,而不必应付一个愚蠢的鼠标。一旦你确定了你的纸张设计,你就可以拍照或扫描设计供将来参考。让我们从创建核心游戏屏幕的那些基本块开始。图 3-12 向你展示了我们的核心游戏机制需要什么。

9781430246770_Fig03-12.jpg

图 3-12。游戏设计积木

最左边的矩形是我们的屏幕,大约是 Nexus One 屏幕的大小。这是我们放置其他元素的地方。下一个构建模块是两个箭头按钮,我们将使用它们来控制蛇。最后,还有蛇头、几条尾巴和一块它可以吃的东西。我们还写了一些数字,并把它们剪了下来。这些将用于显示分数。图 3-13 展示了我们对初始竞争环境的愿景。

9781430246770_Fig03-13.jpg

图 3-13。最初的比赛场地

让我们来定义游戏机制:

  • 这条蛇沿着它的头指向的方向前进,拖着它的尾巴。头部和尾部由大小相等的部分组成,在视觉上没有太大的区别。
  • 如果蛇走出屏幕边界,它会从另一边重新进入屏幕。
  • 如果按下右箭头或左箭头按钮,蛇将顺时针(右)或逆时针(左)旋转 90 度。
  • 如果蛇撞到自己(比如尾巴的一部分),游戏就结束了。
  • 如果蛇用头撞上了一个棋子,这个棋子就消失了,分数增加 10 分,场上出现一个新的棋子,位置不是蛇自己占据的。蛇还长了一个尾巴。新的尾巴部分附在蛇的末端。

对于这样一个简单的游戏来说,这是一个相当复杂的描述。请注意,我们按照复杂性升序对项目进行了排序。当蛇在游戏场上吃掉一块时游戏的行为可能是最复杂的。当然,更复杂的游戏无法用如此简洁的方式描述。通常,您会将这些拆分成单独的部分,并单独设计每个部分,在流程结束时的最终合并步骤中将它们连接起来。

最后一个游戏力学项目有这样的暗示:游戏最终会结束,因为屏幕上的所有空间都将被蛇用尽。

既然我们完全原创的游戏力学想法看起来不错,让我们试着为它想出一个背景故事。

一个故事和一种艺术风格

虽然有僵尸、宇宙飞船、矮人和大量爆炸的史诗故事会很有趣,但我们必须意识到我们的资源是有限的。我们的绘图技巧,如图 3-12 所示,有些欠缺。如果我们的生命取决于僵尸,我们就不能画它。所以我们做了任何有自尊的独立游戏开发者都会做的事情:诉诸涂鸦风格,并相应地调整设置。

进入诺姆先生的世界。Nom 先生是一条纸蛇,总是渴望吃掉从不明来源掉落在他的纸地上的墨滴。Nom 先生非常自私,他只有一个不那么高尚的目标:成为世界上最大的墨水纸蛇!

这个小小的背景故事让我们可以定义更多的东西:

  • 艺术风格是 doodly。我们将在以后扫描我们的构建模块,并在我们的游戏中使用它们作为图形素材。
  • 由于 Nom 先生是一个个人主义者,我们将稍微修改他的块状性质,给他一个适当的蛇脸。和一顶帽子。
  • 可消化的部分将被转化成一组墨水污迹。
  • 我们将通过让诺姆先生每次吃到墨水渍时发出咕噜声来解决游戏的音频问题。
  • 与其选择“涂鸦蛇”这样无聊的标题,不如把这个游戏叫做“Nom 先生”,一个更有趣的标题。

图 3-14 显示了 Nom 先生的全盛时期,以及一些将取代原块的墨迹。我们还画了一个很棒的 Nom 先生标志,可以在整个游戏中重复使用。

9781430246770_Fig03-14.jpg

图 3-14。Nom 先生,他的帽子,墨水渍,还有商标

屏幕和过渡

随着游戏机制、背景故事、人物和艺术风格的固定,我们现在可以设计我们的屏幕和它们之间的过渡。然而,首先重要的是要准确理解屏幕是由什么组成的:

  • 屏幕是填充整个显示器的原子单位,它只负责游戏的一部分(例如,主菜单、设置菜单或动作发生的游戏屏幕)。
  • 一个屏幕可以由多个组件组成(例如,按钮、控件、*视显示器或游戏世界的渲染)。
  • 屏幕允许用户与屏幕的元素进行交互。这些交互可以触发屏幕转换(例如,按下主菜单上的新游戏按钮可以将当前活动的主菜单屏幕与游戏屏幕或级别选择屏幕交换)。

有了这些定义,我们就可以开动脑筋,设计 Nom 先生游戏的所有屏幕。

我们的游戏首先呈现给玩家的是主菜单屏幕。什么是好的主菜单屏幕?

  • 原则上,显示我们游戏的名字是一个好主意,所以我们会放上 Nom 先生的标志。

  • 为了让事情看起来更一致,我们还需要一个背景。为此,我们将重复使用运动场背景。

  • 玩家通常会想玩这个游戏,所以让我们加入一个游戏按钮。这将是我们的第一个交互组件。

  • Players want to keep track of their progress and awesomeness, so we’ll also add a high-score button as shown in Figure 3-15, another interactive component.

    9781430246770_Fig03-15.jpg

    图 3-15。主菜单屏幕

  • 可能有人不知道蛇。让我们以帮助按钮的形式给他们一些帮助,帮助按钮将转换到帮助屏幕。

  • 虽然我们的音效设计会很可爱,但有些玩家可能还是喜欢安静地玩。给他们一个象征性的切换按钮来启用和禁用声音就可以了。

我们实际上如何在屏幕上布置这些组件是一个品味问题。你可以开始研究计算机科学的一个分支,叫做人机界面(HCI ),以获得关于如何向用户展示你的应用的最新科学观点。不过,对 Nom 先生来说,这可能有点过头了。我们采用了图 3-15 所示的简单设计。

请注意,所有这些元素(徽标、菜单按钮等)都是独立的图像。

从主菜单屏幕开始,我们获得了一个直接的优势:我们可以直接从交互组件中获得更多的屏幕。在 Nom 先生的例子中,我们需要一个游戏屏幕、一个高分屏幕和一个帮助屏幕。我们不包括设置屏幕,因为唯一的设置(声音)已经出现在主菜单屏幕上。

让我们暂时忽略游戏屏幕,先把注意力集中在高分屏幕上。我们决定高分将存储在本地的 Nom 先生,所以我们将只跟踪单个玩家的成就。我们还决定只记录五个最高分。因此,高分屏幕将看起来像图 3-16 ,在顶部显示“高分”文本,随后是五个最高分和一个带箭头的按钮,指示您可以过渡回某个内容。我们将再次重复使用运动场的背景,因为我们喜欢它便宜。

9781430246770_Fig03-16.jpg

图 3-16。高分屏幕

接下来是帮助屏幕。它将告知玩家背景故事和游戏机制。所有这些信息在一个屏幕上显示有点太多了。因此,我们将帮助屏幕分成多个屏幕。这些屏幕中的每一个都将向用户呈现一条必不可少的信息:Nom 先生是谁,他想要什么,如何控制 Nom 先生让他吃墨迹,以及 Nom 先生不喜欢什么(即吃自己)。总共有三个帮助屏幕,如图图 3-17 所示。请注意,我们在每个屏幕上添加了一个按钮,以表明还有更多信息需要阅读。我们一会儿就把这些屏幕连接起来。

9781430246770_Fig03-17.jpg

图 3-17。帮助屏幕

最后,是我们的游戏屏幕,我们已经看到了。不过,我们忽略了一些细节。第一,游戏不应该马上开始;我们应该给运动员一些时间准备。因此,屏幕将开始请求触摸屏幕以开始咀嚼。这并不保证单独的屏幕;我们将直接在游戏屏幕中实现初始暂停。

说到暂停,我们还将添加一个按钮,允许用户暂停游戏。一旦暂停,我们还需要给用户一个恢复游戏的方法。在这种情况下,我们将只显示一个大的 Resume 按钮。在暂停状态下,我们还将显示另一个按钮,允许用户返回主菜单屏幕。一个额外的退出按钮让用户返回到主菜单。

万一 Nom 先生咬到自己的尾巴,我们需要通知玩家游戏结束了。我们可以实现一个单独的游戏结束屏幕,或者我们可以留在游戏屏幕内,只覆盖一个大的“游戏结束”信息。在这种情况下,我们将选择后者。为了使事情圆满,我们还将显示玩家获得的分数,以及一个返回主菜单的按钮。

把游戏屏幕的这些不同状态想象成子屏幕。我们有四个子屏幕:初始就绪状态、正常游戏状态、暂停状态和游戏结束状态。图 3-18 显示了这些子屏幕。

9781430246770_Fig03-18.jpg

图 3-18。游戏画面及其四种不同状态

现在是时候把屏幕连在一起了。每个屏幕都有一些交互组件,用于转换到另一个屏幕。

  • 从主菜单屏幕,我们可以通过相应的按钮进入游戏屏幕、高分屏幕和帮助屏幕。
  • 从游戏屏幕,我们可以通过暂停状态的按钮或游戏结束状态的按钮返回到主菜单屏幕。
  • 从高分屏幕,我们可以回到主菜单屏幕。
  • 从第一个帮助屏幕,我们可以转到第二个帮助屏幕;从第二个到第三个;从第三个到第四个;从第四个开始,我们将返回到主菜单屏幕。

这就是我们所有的转变!看起来没那么糟,是吧?图 3-19 直观地总结了所有的转换,箭头从每个交互组件指向目标屏幕。我们还放入了组成屏幕的所有元素。

9781430246770_Fig03-19.jpg

图 3-19。所有设计元素和过渡

我们现在已经完成了第一个完整的游戏设计。剩下的就是实现了。我们如何把这个设计变成一个可执行的游戏?

注意我们刚刚使用的游戏设计方法对于小游戏来说是很好的。这本书叫做开始安卓游戏,所以这是一个合适的方法论。对于较大的项目,你最有可能在一个团队中工作,每个团队成员专攻一个方面。虽然您仍然可以在该上下文中应用这里描述的方法,但是您可能需要对它进行一点调整,以适应不同的环境。您还将更加迭代地工作,不断完善您的设计。

代码:本质细节

这里还有一个先有鸡还是先有蛋的情况:我们只想了解与游戏编程相关的 Android APIs。然而,我们仍然不知道如何实际编程一个游戏。我们有一个如何设计的想法,但将其转化为可执行文件对我们来说仍然是巫术。在下面的小节中,我们想给你一个游戏元素的概述。我们将查看一些接口的伪代码,稍后我们将使用 Android 提供的功能来实现这些代码。接口令人敬畏有两个原因:它们允许我们专注于语义而不需要知道实现细节,并且它们允许我们稍后交换实现(例如,代替使用 2D CPU 渲染,我们可以利用 OpenGL ES 在屏幕上显示 Nom 先生)。

每一个游戏都需要一个基本的框架来抽象和减轻与底层操作系统通信的痛苦。通常这被分成如下模块:

  • 应用和窗口管理:这是负责创建一个窗口,并处理像关闭窗口或暂停/恢复 Android 中的应用这样的事情。
  • 输入:这与窗口管理模块有关,它跟踪用户输入(即触摸事件、击键、外围和加速度计读数)。
  • 文件输入/输出(File I/O):这允许我们从磁盘中获取我们的素材字节到我们的程序中。
  • 图形:这可能是除了实际游戏之外最复杂的模块了。它负责加载图形并将它们绘制在屏幕上。
  • 音频:这个模块负责加载和播放一切会撞击我们耳朵的东西。
  • 游戏框架(Game framework):这将上述所有内容联系在一起,为编写游戏提供了一个易于使用的基础。

每个模块都由一个或多个接口组成。每个接口至少有一个具体的实现,它基于底层*台(在我们的例子中是 Android)提供的东西应用接口的语义。

注意是的,我们故意在前面的列表中遗漏了网络。我们不会在本书中实现多人游戏。这是一个相当高级的话题,取决于游戏的类型。如果你对这个话题感兴趣,你可以在网上找到一系列的教程。是一个开始的好地方。)

在下面的讨论中,我们将尽可能地与*台无关。这些概念在所有*台上都是相同的。

应用和窗口管理

游戏就像任何其他有用户界面的计算机程序一样。它包含在某种窗口中(如果底层操作系统的 UI 范例是基于窗口的,这是所有主流操作系统的情况)。窗口作为一个容器,我们基本上认为它是一个画布,我们从中绘制游戏内容。

除了触摸客户区或按键之外,大多数操作系统允许用户以一种特殊的方式与窗口交互。在桌面系统上,你通常可以拖动窗口,调整它的大小,或者最小化到某种任务栏。在 Android 中,调整大小被适应方向变化所取代,最小化类似于通过按下 home 键或对来电的反应将应用放在后台。

应用和窗口管理模块还负责实际设置窗口,并确保它由单个 UI 组件填充,我们稍后可以渲染该组件,该组件以触摸或按键的形式接收来自用户的输入。UI 组件可以通过 CPU 呈现,也可以是硬件加速的,就像 OpenGL ES 一样。

应用和窗口管理模块没有一组具体的接口。稍后我们会将它与游戏框架合并。我们必须记住的是我们必须管理的应用状态和窗口事件:

  • Create :当窗口(以及应用)启动时调用一次
  • 暂停:当应用被某种机制暂停时调用
  • Resume :当应用恢复并且窗口再次在前台时调用

注意此时,一些安卓迷可能会翻白眼。为什么只使用单一窗口(Android speak 中的活动)?为什么不在游戏中使用一个以上的 UI 小部件呢——比如说,实现我们的游戏可能需要的复杂 UI?主要原因是我们想要完全控制我们游戏的外观和感觉。它还允许我们专注于 Android 游戏编程,而不是 Android UI 编程,关于这个主题有更好的书籍——例如,马克·墨菲的优秀开始 Android 3 (Apress,2011)。

投入

用户肯定会想以某种方式与我们的游戏互动。这就是输入模块的用武之地。在大多数操作系统上,诸如触摸屏幕或按键之类的输入事件被分派到当前聚焦的窗口。然后,窗口将进一步将事件分派给具有焦点的 UI 组件。调度过程通常对我们是透明的;我们唯一关心的是从聚焦的 UI 组件中获取事件。操作系统的 UI APIs 提供了一种挂钩到事件调度系统的机制,以便我们可以轻松地注册和记录事件。这种事件的挂钩和记录是输入模块的主要任务。

我们可以用记录的信息做什么?有两种操作方式:

  • 轮询:通过轮询,我们只检查输入设备的当前状态。当前检查和上一次检查之间的任何状态都将丢失。例如,这种输入处理方式适用于检查用户是否触摸了特定的按钮。它不适合跟踪文本输入,因为键事件的顺序丢失了。
  • 基于事件的处理(Event-based handling):这为我们提供了自上次检查以来发生的事件的完整历史记录。它是执行文本输入或任何其他依赖于事件顺序的任务的合适机制。检测手指第一次接触屏幕或抬起的时间也很有用。

我们想要处理什么输入设备?在 Android 上,我们有三种主要的输入方式:触摸屏、键盘/轨迹球和加速度计。前两种方法适用于轮询和基于事件的处理。加速度计通常只是被轮询。触摸屏可以产生三个事件:

  • 向下触摸:手指触摸屏幕时会发生这种情况。
  • 触摸拖动:手指在屏幕上拖动时会出现这种情况。在拖拽之前,总会有一个向下的事件。
  • Touch up :手指从屏幕上抬起时会出现这种情况。

每个触摸事件都有附加信息:相对于 UI 组件原点的位置,以及在多点触摸环境中用于识别和跟踪不同手指的指针索引。

键盘可以产生两种类型的事件:

  • 按键按下:这种情况发生在按键被按下的时候。
  • 向上键:当一个键被抬起时会发生这种情况。此事件之前总是有一个按键事件。

关键事件也携带附加信息。按键事件存储被按下的按键的代码。按键事件存储按键的代码和实际的 Unicode 字符。按键代码和按键事件生成的 Unicode 字符是有区别的。在后一种情况下,还会考虑其他键的状态,例如 Shift 键。例如,通过这种方式,我们可以在按键事件中获得大写和小写字母。对于按键事件,我们只知道某个键被按下了;我们不知道按键实际上会产生哪个字符。

寻求使用自定义 usb 硬件(包括操纵杆、模拟控制器、特殊键盘、触摸板或其他 android 支持的外围设备)的开发人员可以通过使用 android.hardware.usb 包 API 来实现这一点,这些 API 在 API level 12 (Android 3.1)中引入,并通过 com.android.future.usb 包向后移植到 Android 2 . 3 . 4。USB API 使 Android 设备能够在主机模式下运行,这允许外围设备连接到 Android 设备并由其使用,或者在附件模式下运行,这允许设备作为另一个 USB 主机的附件。这些 API 不是初学者的材质,因为设备访问级别非常低,为 USB 附件提供数据流 I/O,但重要的是要注意功能确实存在。如果你的游戏设计围绕一个特定的 USB 附件,你肯定会想为该附件开发一个通信模块,并使用它制作原型。

最后,还有加速度计。尽管几乎所有的手机和*板电脑都将加速度计作为标准硬件,但包括机顶盒在内的许多新设备可能没有加速度计,因此请始终计划使用多种输入模式,这一点很重要。

为了使用加速度计,我们将总是轮询加速度计的状态。加速度计报告地球重力在加速度计三个轴之一上施加的加速度。轴被称为 x、y 和 z。图 3-20 描述了每个轴的方向。每个轴上的加速度用米每秒*方(m/s 2 表示。从物理课上,我们知道一个物体在地球上自由落体时会以大约 9.8 米/秒 2 的速度加速。其他星球引力不同,所以加速度常数也不同。为了简单起见,我们在这里只讨论地球。当一个轴指向远离地心的方向时,最大的加速度作用在它上面。如果一个轴指向地球的中心,我们得到一个负的最大加速度。例如,如果你在纵向模式下将手机直立,那么 y 轴将报告 9.8 米/秒的加速度 2 。在图 3-20 中,z 轴将报告加速度为 9.8 米/秒 2 ,x 轴和 y 轴将报告加速度为零。

9781430246770_Fig03-20.jpg

图 3-20。安卓手机上的加速度计轴。z 轴指向手机之外

现在,让我们定义一个接口,它给我们提供对触摸屏、键盘和加速度计的轮询访问,也给我们提供对触摸屏和键盘的基于事件的访问(见清单 3-1 )。

清单 3-1。 输入界面以及 KeyEvent 和 TouchEvent 类

package com.badlogic.androidgames.framework;

import java.util.List;

public interface Input {
    public static class KeyEvent {
        public static final int *KEY_DOWN* = 0;
        public static final int *KEY_UP* = 1;

        public int type;
        public int keyCode;
        public char keyChar;
    }

    public static class TouchEvent {
        public static final int *TOUCH_DOWN* = 0;
        public static final int *TOUCH_UP* = 1;
        public static final int *TOUCH_DRAGGED* = 2;

        public int type;
        public int x, y;
        public int pointer;
    }

    public boolean isKeyPressed(int keyCode);

    public boolean isTouchDown(int pointer);

    public int getTouchX(int pointer);

    public int getTouchY(int pointer);

    public float getAccelX();

    public float getAccelY();

    public float getAccelZ();

    public List<KeyEvent> getKeyEvents();

    public List<TouchEvent> getTouchEvents();
}

我们的定义由两个类开始,KeyEvent 和 TouchEvent。KeyEvent 类定义了编码 KeyEvent 类型的常量;TouchEvent 类也是如此。如果事件的类型是 KEY_UP,则 KeyEvent 实例记录其类型、键的代码和 Unicode 字符。

TouchEvent 代码类似,它保存 TouchEvent 的类型、手指相对于 UI 组件原点的位置以及触摸屏驱动程序赋予手指的指针 ID。只要手指在屏幕上,该手指的指针 ID 就会保持不变。如果两个手指放下,手指 0 抬起,那么手指 1 只要接触屏幕就保持其 ID。新手指将获得第一个空闲 ID,在本例中为 0。指针 id 通常是按顺序分配的,但并不保证会这样。例如,索尼 Xperia Play 使用 15 个 id,并以循环方式将它们分配给 touches。不要在代码中对新指针的 ID 做任何假设——只能使用索引读取指针的 ID 并引用它,直到指针被抬起。

接下来是输入接口的轮询方法,这应该是不言自明的。Input.isKeyPressed()接受一个键码,并返回相应的键当前是否被按下。Input.isTouchDown()、Input.getTouchX()和 Input.getTouchY()返回给定指针是否按下,以及其当前的 x 和 y 坐标。请注意,如果相应的指针没有实际接触屏幕,坐标将是未定义的。

Input.getAccelX()、Input.getAccelY()和 Input.getAccelZ()返回每个加速度计轴各自的加速度值。

最后两种方法用于基于事件的处理。它们返回自我们上次调用这些方法以来记录的 KeyEvent 和 TouchEvent 实例。事件根据发生的时间进行排序,最新的事件位于列表的末尾。

有了这个简单的接口和这些助手类,我们可以满足所有的输入需求。让我们继续处理文件。

注意虽然带有公共成员的可变类令人厌恶,但我们可以在这种情况下摆脱它们,原因有两个:Dalvik 在调用方法(在这种情况下是 getters)时仍然很慢,事件类的可变性对输入实现的内部工作没有影响。请注意,这通常是不好的风格,但是出于性能原因,我们偶尔会采用这种快捷方式。

文件输入输出

读写文件对于我们的游戏开发工作来说是非常重要的。假设我们在 Java 领域,我们主要关心的是创建 InputStream 和 OutputStream 实例,这是从特定文件读取数据和向特定文件写入数据的标准 Java 机制。在我们的例子中,我们主要关心的是读取游戏中打包的文件,比如关卡文件、图像和音频文件。写文件是我们很少做的事情。通常,如果我们想保持高分或游戏设置,或者保存一个游戏状态,以便用户可以从他们离开的地方继续,我们就只写文件。

我们想要尽可能简单的文件访问机制。清单 3-2 显示了我们对简单接口的建议。

清单 3-2。 文件 I/O 接口

package com.badlogic.androidgames.framework;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public interface FileIO {
    public InputStream readAsset(String fileName)throws IOException;

    public InputStream readFile(String fileName)throws IOException;

    public OutputStream writeFile(String fileName)throws IOException;
}

那是相当精简和卑鄙的。我们只是指定一个文件名,然后得到一个流作为回报。正如我们在 Java 中通常做的那样,我们将抛出一个 IOException 以防出错。当然,我们在哪里读写文件取决于实现。素材将从我们的应用的 APK 文件中读取,文件将从 SD 卡(也称为外部存储)中读取和写入。

返回的 InputStreams 和 OutputStreams 是普通的 Java 流。当然,一旦我们用完它们,我们必须把它们关上。

声音的

虽然音频编程是一个相当复杂的话题,但我们可以用一个非常简单的抽象来摆脱它。我们不会做任何高级音频处理;我们只是回放从文件中加载的声音效果和音乐,就像我们在图形模块中加载位图一样。

不过,在我们深入模块接口之前,让我们停下来,了解一下声音实际上是什么,以及它是如何以数字形式表示的。

声音的物理学

声音通常被建模为在空气或水等介质中传播的一组波。波不是实际的物理对象,而是分子在介质中的运动。想象一个小池塘,你往里面扔一块石头。当石头撞击池塘表面时,它将推开池塘内的大量水分子,这些被推开的分子将把它们的能量转移给它们的邻居,邻居也将开始移动和推动。最终,你会看到圆形的波浪从石头击中池塘的地方出现。

声音产生的时候也会发生类似的情况。你得到的不是圆周运动,而是球形运动。从你童年可能进行过的高度科学的实验中你可能知道,水波是可以相互作用的;它们可以相互抵消或相互加强。声波也是如此。当你听音乐时,环境中的所有声波结合起来形成你听到的音调和旋律。声音的音量取决于移动和推动的分子对其邻居并最终对你的耳朵施加了多少能量。

录制和回放

录制和回放音频的原理在理论上非常简单。为了记录,我们记录下形成声波的分子对空间中的某个区域施加一定压力的时间点。回放这些数据仅仅是让扬声器周围的空气分子像我们记录时一样摆动和移动。

在实践中,当然要复杂一点。音频通常以两种方式录制:模拟或数字。在这两种情况下,声波都被某种麦克风记录下来,麦克风通常由一层薄膜组成,将分子的推动转化为某种信号。信号的处理和存储方式决定了模拟录音和数字录音的区别。我们正在数字化工作,所以让我们看看那个案例。

以数字方式记录音频意味着以离散的时间步长测量并存储麦克风膜片的状态。根据周围分子的推动,膜可以相对于中性状态向内或向外推动。这个过程被称为采样,因为我们在离散的时间点采集膜状态样本。我们每单位时间内采集的样本数称为采样率。通常时间单位以秒为单位,单位称为赫兹(Hz)。每秒采样越多,音频质量就越高。CD 以 44,100Hz 或 44.1KHz 的采样率回放。例如,当通过电话线传输语音时,采样率较低(在这种情况下通常为 8KHz)。

采样率只是决定录音质量的一个因素。我们存储每个膜状态样本的方式也起作用,它也受数字化的影响。让我们回忆一下膜的实际状态是什么:它是膜离中性状态的距离。因为膜是被向内推还是向外推是有区别的,所以我们记录了带符号的距离。因此,特定时间步的膜状态是单个负数或正数。我们可以用多种方式存储这个有符号数:作为有符号的 8 位、16 位或 32 位整数,作为 32 位浮点数,甚至作为 64 位浮点数。每种数据类型都有有限的精度。一个 8 位有符号整数可以存储 127 个正距离值和 128 个负距离值。32 位整数提供了更高的分辨率。当存储为浮点型时,膜状态通常归一化为 1 到 1 之间的范围。最大的正值和最小的负值代表了膜离开其中性状态的最远距离。膜状态也被称为振幅。它代表撞击它的声音的响度。

使用单个麦克风,我们只能录制单声道声音,这将丢失所有空间信息。通过两个麦克风,我们可以测量空间中不同位置的声音,从而获得所谓的立体声。例如,您可以将一个麦克风放在发声物体的左侧,另一个放在右侧,从而获得立体声。当声音通过两个扬声器同时播放时,我们可以合理地再现音频的空间分量。但这也意味着当存储立体声音频时,我们需要存储两倍数量的样本。

回放最终是一件简单的事情。一旦我们获得了数字形式的音频样本,并且具有特定的采样速率和数据类型,我们就可以将这些数据发送到音频处理单元,它会将信息转换为信号,供连接的扬声器使用。扬声器解释这个信号,并将其转化为薄膜的振动,这又会导致周围的空气分子移动并产生声波。这正是为记录所做的,只是颠倒了!

音频质量和压缩

哇,好多理论。我们为什么关心?如果您注意了,现在您可以根据采样率和用于存储每个样本的数据类型来判断音频文件是否是高质量的。采样率越高,数据类型精度越高,音频质量就越好。然而,这也意味着我们需要更多的存储空间来存放音频信号。

想象一下,我们以 60 秒的长度录制相同的声音,但我们录制了两次:一次是以 8KHz 的采样率、每样本 8 位,另一次是以 44KHz 的采样率、16 位精度。我们需要多少内存来存储每个声音?在第一种情况下,每个样本需要 1 个字节。将其乘以 8,000Hz 的采样率,我们需要每秒 8000 字节。对于我们完整的 60 秒录音,这是 480,000 字节,或大约半兆字节(MB)。我们更高质量的录音需要更多的内存:每个样本 2 字节,每秒 44,000 字节的 2 倍。也就是每秒 88000 字节。将此乘以 60 秒,我们得到 5,280,000 字节,或 5MB 多一点。你通常的 3 分钟流行歌曲会占用超过 15MB 的质量,这只是一个单声道录音。对于立体声录音,你需要两倍的内存。对于一首愚蠢的歌来说相当多的字节!

许多聪明人想出了减少录音所需字节数的方法。他们发明了相当复杂的心理声学压缩算法,分析未压缩的音频记录,并输出较小的压缩版本。压缩通常是有损,意味着原始音频的一些次要部分被省略。当你播放 MP3 或 OGGs 时,你实际上是在听压缩的有损音频。因此,使用 MP3 或 OGG 等格式将有助于我们减少存储音频所需的磁盘空间。

回放压缩文件的音频怎么样?虽然存在用于各种压缩音频格式的专用解码硬件,但是普通的音频硬件通常只能处理未压缩的样本。在实际向声卡输入样本之前,我们必须先读入样本并解压缩。我们可以这样做一次,将所有未压缩的音频样本存储在内存中,或者只在需要时从音频文件的分区中流过。

在实践中

您已经看到,即使是 3 分钟的歌曲也会占用大量内存。因此,当我们播放游戏音乐时,我们会实时输入音频样本,而不是将所有音频样本预加载到内存中。通常,我们只有一个音乐流在播放,所以我们只需访问磁盘一次。

对于短暂的声音效果,如爆炸或枪声,情况略有不同。我们经常想要同时播放多次声音效果。对于声音效果的每个实例,从磁盘流式传输音频样本不是一个好主意。不过,我们很幸运,因为短音不会占用太多内存。因此,我们将把音效的所有样本读入内存,这样我们就可以直接同时播放它们。

我们有以下要求:

  • 我们需要一种方法来加载音频文件,以便进行流式播放和从内存中播放。
  • 我们需要一种方法来控制流式音频的回放。
  • 我们需要一种方法来控制满载音频的回放。

这直接转化为音频、音乐和声音接口(分别显示在清单 3-3 到 3-5、中)。

清单 3-3。 音频接口

package com.badlogic.androidgames.framework;

public interface Audio {
    public Music newMusic(String filename);

    public Sound newSound(String filename);
}

音频接口是我们创建新的音乐和声音实例的方式。一个音乐实例代表一个流式音频文件。一个声音实例代表一个简短的声音效果,我们将它完全保存在内存中。Audio.newMusic()和 Audio.newSound()方法都将文件名作为参数,并在加载过程失败时抛出 IOException(例如,当指定的文件不存在或损坏时)。文件名指的是我们的应用的 APK 文件中的素材文件。

清单 3-4。 音乐界面

package com.badlogic.androidgames.framework;

public interface Music {
    public void play();

    public void stop();

    public void pause();

    public void setLooping(boolean looping);

    public void setVolume(float volume);

    public boolean isPlaying();

    public boolean isStopped();

    public boolean isLooping();

    public void dispose();
}

音乐界面稍微复杂一点。它具有开始播放音乐流、暂停和停止音乐流的方法,并将其设置为循环播放,这意味着当它到达音频文件的结尾时,它将自动从头开始播放。此外,我们可以将音量设置为 0(静音)到 1(最大音量)范围内的浮动值。还提供了一些 getter 方法,允许我们轮询 Music 实例的当前状态。一旦我们不再需要音乐实例,我们就必须处理它。这将关闭所有系统资源,例如音频流所来自的文件。

清单 3-5。 声音界面

package com.badlogic.androidgames.framework;

public interface Sound {
    public void play(float volume);
,,,
    public void dispose();
}

声音界面更简单。我们需要做的就是调用它的 play()方法,该方法再次接受一个 float 参数来指定音量。我们可以随时调用 play()方法(例如,当 Nom 先生吃了一个墨迹)。一旦我们不再需要 Sound 实例,我们就必须释放它来释放样本使用的内存,以及其他可能相关的系统资源。

注意虽然我们在本章中讲述了很多内容,但关于音频编程还有很多内容需要学习。我们简化了一些内容,以保持这一部分简洁明了。例如,通常你不会线性地指定音量。在我们的背景下,可以忽略这个小细节。只是要意识到还有更多!

制图法

我们游戏框架核心的最后一个模块是图形模块。你可能已经猜到了,它将负责把图像(也称为位图)绘制到我们的屏幕上。这听起来可能很容易,但如果你想要高性能的图形,你至少要知道图形编程的基本知识。让我们从 2D 图形的基础开始。

我们需要问的第一个问题是这样的:图像到底是如何输出到我的显示器上的?答案相当复杂,我们不一定需要知道所有的细节。我们将快速回顾一下我们的计算机和显示器内部发生了什么。

栅格、像素和帧缓冲区

今天的显示器是基于光栅的。光栅是一个所谓图片元素的二维网格。你可能知道它们是像素,我们将在随后的文本中这样称呼它们。光栅网格的宽度和高度是有限的,我们通常用每行和每列的像素数来表示。如果你觉得勇敢,你可以打开你的电脑,试着在你的显示器上辨认出单个的像素。请注意,我们对您的眼睛造成的任何损害概不负责。

一个像素有两个属性:在网格中的位置和颜色。像素的位置以离散坐标系中的二维坐标给出。 离散是指一个坐标总是在一个整数位置。坐标是在施加于网格上的欧几里得坐标系中定义的。坐标系的原点是网格的左上角。正 x 轴指向右侧,y 轴指向下方。最后一项是最让人困惑的。我们一会儿会回来。出现这种情况的原因很简单。

忽略愚蠢的 y 轴,我们可以看到,由于我们坐标的离散性,原点与网格中左上角的像素重合,位于(0,0)。原点像素右边的像素位于(1,0),原点像素下面的像素位于(0,1),依此类推(见图 3-21 左侧)。显示器的光栅网格是有限的,因此有意义的坐标数量有限。负坐标在屏幕外。大于或等于栅格宽度或高度的坐标也在屏幕之外。请注意,最大的 x 坐标是栅格的宽度减 1,最大的 y 坐标是栅格的高度减 1。这是因为原点与左上角的像素重合。一个接一个的错误是图形编程中常见的挫折来源。

9781430246770_Fig03-21.jpg

图 3-21。显示光栅网格和 VRAM,过于简化

显示器从图形处理器接收恒定的信息流。它按照控制屏幕绘制的程序或操作系统的指定,对显示器光栅中每个像素的颜色进行编码。显示器将每秒刷新其状态几十次。确切的速率称为刷新率。它用赫兹表示。液晶显示器的刷新率通常为每秒 60Hz 阴极射线管(CRT)显示器和等离子显示器通常具有更高的刷新率。

图形处理器可以访问一个称为视频随机存取存储器(VRAM)的特殊内存区域。在 VRAM 中,有一个保留区域用于存储屏幕上显示的每个像素。这个区域通常被称为帧缓冲区。一幅完整的屏幕图像因此被称为一帧。对于显示器光栅网格中的每个像素,在保存像素颜色的帧缓冲区中都有相应的内存地址。当我们想改变屏幕上显示的内容时,我们只需改变 VRAM 内存区域中像素的颜色值。

现在是时候解释为什么显示器坐标系中的 y 轴指向下方了。内存,无论是 VRAM 还是普通 RAM,都是线性一维的。把它想象成一个一维数组。那么我们如何将二维像素坐标映射到一维内存地址呢?图 3-21 显示了一个相当小的 3×2 像素的显示光栅网格,以及它在 VRAM 中的表示。(我们假设 VRAM 仅由帧缓冲存储器组成。)由此,我们可以很容易地推导出下面的公式来计算一个像素在(x,y)处的内存地址:

int address = x + y * rasterWidth;

我们也可以反过来,从地址到像素的 x 和 y 坐标:

int x = address % rasterWidth;
int y = address / rasterWidth;

因此,由于 VRAM 中像素颜色的内存布局,y 轴指向下方。这实际上是从早期计算机图形学继承下来的遗产。监视器将更新屏幕上每个像素的颜色,从左上角开始,移动到右边,在下一行回到左边,直到它们到达屏幕的底部。将 VRAM 内容以易于将颜色信息传输到监视器的方式进行布局是很方便的。

注意如果我们可以完全访问帧缓冲区,我们可以使用前面的等式编写一个完整的图形库来绘制像素、线条、矩形、加载到内存的图像等等。由于各种原因,现代操作系统不允许我们直接访问帧缓冲区。相反,我们通常绘制到一个内存区域,然后由操作系统复制到实际的帧缓冲区。不过,一般概念在这种情况下也适用!如果你对如何有效地做这些低级的事情感兴趣,在网上搜索一个叫 Bresenham 的家伙和他的画线和画圆算法。

垂直同步和双缓冲

现在,如果你还记得关于刷新率的那一段,你可能已经注意到刷新率似乎相当低,我们可以比显示器刷新更快地写入帧缓冲区。这是有可能的。更糟糕的是,我们不知道显示器何时从 VRAM 获取最新的帧副本,如果我们正在画东西,这可能是一个问题。在这种情况下,显示器将显示旧帧缓冲区内容的一部分和新状态的一部分,这是不希望的情况。你可以在许多 PC 游戏中看到这种效果,它表现为撕裂(屏幕同时显示上一帧的部分和新帧的部分)。

这个问题解决方案的第一部分叫做双缓冲。图形处理单元(GPU)实际上管理两个帧缓冲区,而不是单个帧缓冲区:前端缓冲区和后端缓冲区。将从中提取像素颜色的前缓冲区可供显示器使用,后缓冲区可用于绘制我们的下一帧,同时显示器很高兴地从前缓冲区获取数据。当我们完成绘制当前帧时,我们告诉 GPU 将两个缓冲区相互交换,这通常意味着只交换前后缓冲区的地址。在图形编程文献和 API 文档中,您可能会发现术语翻页缓冲区交换,它们指的就是这个过程。

但是,仅仅双缓冲并不能完全解决问题:当屏幕正在刷新内容时,交换仍然会发生。这就是垂直同步(也称为 vsync )发挥作用的地方。当我们调用 buffer swap 方法时,GPU 会一直阻塞,直到显示器发出信号,表示它已经完成了当前的刷新。如果发生这种情况,GPU 可以安全地交换缓冲区地址,一切都会好起来。

幸运的是,如今我们几乎不需要关心这些烦人的细节。VRAM 以及双缓冲和垂直同步的细节对我们是安全隐藏的,因此我们无法对它们进行破坏。相反,我们被提供了一组 API,这些 API 通常限制我们操作应用窗口的内容。其中一些 API,如 OpenGL ES,公开了硬件加速,它基本上只不过是用图形芯片上的专用电路操纵 VRAM。看,这不是魔法!至少在高层次上,您应该了解内部工作原理的原因是,它允许您了解应用的性能特征。当 vsync 启用时,你永远不能超过屏幕的刷新率,如果你所做的只是绘制一个像素,这可能会令人困惑。

当我们使用非硬件加速的 API 进行渲染时,我们不会直接处理显示器本身。相反,我们在窗口中绘制一个 UI 组件。在我们的例子中,我们处理一个扩展到整个窗口的 UI 组件。因此,我们的坐标系不会延伸到整个屏幕,而只会延伸到我们的 UI 组件。UI 组件实际上变成了我们的显示器,拥有自己的虚拟帧缓冲区。然后,操作系统将管理所有可见窗口内容的合成,并确保它们的内容被正确地传输到它们在实际帧缓冲区中覆盖的区域。

什么是颜色?

你会注意到,到目前为止,我们已经很方便地忽略了颜色。我们在图 3-21 中虚构了一种叫做颜色的类型,并假装一切正常。让我们看看什么是真正的颜色。

从物理上来说,颜色是你的视网膜和视觉皮层对电磁波的反应。这种波的特征是它的波长和强度。我们可以看到波长大约在 400 到 700 纳米之间的波。电磁波谱的这个子波段也被称为可见光光谱。彩虹显示了可见光光谱的所有颜色,从紫色到蓝色到绿色到黄色,然后是橙色,最后是红色。显示器所做的只是为每个像素发射特定的电磁波,我们感受到的是每个像素的颜色。不同类型的显示器使用不同的方法来实现这一目标。这个过程的一个简化版本是这样的:屏幕上的每个像素都是由三种不同的荧光粒子组成的,它们会发出红色、绿色或蓝色中的一种颜色的光。当显示器刷新时,每个像素的荧光粒子将通过某种方式发光(例如,在 CRT 显示器的情况下,像素的粒子被一束电子击中)。对于每个粒子,显示器可以控制它发出多少光。例如,如果一个像素完全是红色的,那么只有红色的粒子会被全强度的电子击中。如果我们想要三种基色之外的颜色,我们可以通过混合基色来实现。混合是通过改变每个粒子发出颜色的强度来完成的。电磁波在到达我们视网膜的途中会相互叠加。我们的大脑将这种混合解释为一种特定的颜色。因此,颜色可以由基色红、绿、蓝的混合强度来确定。

颜色模型

我们刚刚讨论的被称为颜色模型,特别是 RGB 颜色模型。当然,RGB 代表红色、绿色和蓝色。我们可以使用更多的颜色模型,例如 YUV 和 CMYK。然而,在大多数图形编程 API 中,RGB 颜色模型几乎是标准的,所以我们在这里只讨论它。

RGB 颜色模型被称为加色颜色模型,因为最终颜色是通过混合加色原色红、绿和蓝而获得的。你可能在学校尝试过混合原色。图 3-22 向你展示了一些 RGB 颜色混合的例子,让你回忆一下。

9781430246770_Fig03-22.jpg

图 3-22。享受混合红、绿、蓝三原色的乐趣

当然,通过改变红色、绿色和蓝色成分的强度,我们可以生成比图 3-22 所示更多的颜色。每个分量可以具有介于 0 和某个最大值(比如 1)之间的强度值。如果我们将每个颜色分量解释为一个三维欧几里得空间的三个轴中的一个值,我们可以绘制出一个所谓的色立方体,如图图 3-23 所示。如果我们改变每种成分的强度,就有更多的颜色可供选择。颜色以三元组(红、绿、蓝)给出,其中每个分量的范围在 0.0 和 1.0 之间(0.0 表示该颜色没有强度,1.0 表示完全强度)。黑色位于原点(0,0,0),白色位于原点(1,1,1)。

9781430246770_Fig03-23.jpg

图 3-23。强大的 RGB 颜色立方体

数字编码颜色

我们如何在计算机内存中对 RGB 颜色三元组进行编码?首先,我们必须定义颜色组件要使用的数据类型。我们可以使用浮点数,并将有效范围指定为 0.0 到 1.0 之间。这将为每个组件提供相当多的分辨率,并为我们提供许多不同的颜色。遗憾的是,这种方法占用了大量空间(每像素 3 乘以 4 或 8 字节,这取决于我们使用的是 32 位还是 64 位浮点)。

我们可以做得更好——以失去一些颜色为代价——这完全没问题,因为显示器通常只能发出有限的颜色。我们可以使用无符号整数,而不是对每个组件使用浮点数。现在,如果我们对每个分量使用 32 位整数,我们没有得到任何东西。相反,我们对每个分量使用一个无符号字节。每个分量的强度范围从 0 到 255。因此,对于 1 个像素,我们需要 3 个字节,即 24 位。这是 2 的 24 次方(16,777,216)种不同的颜色。这对我们的需要来说足够了。

我们能再降低一点吗?是的,我们可以。我们可以将每个组件打包成一个 16 位字,因此每个像素需要 2 个字节的存储空间。红色用 5 位,绿色用 6 位,蓝色用剩下的 5 位。绿色获得 6 位的原因是我们的眼睛可以看到更多的绿色阴影,而不是红色或蓝色。所有的位加在一起构成 2 的 16 次方(65,536)种我们可以编码的不同颜色。图 3-24 显示了如何用上述三种编码对颜色进行编码。

9781430246770_Fig03-24.jpg

图 3-24。粉红色的颜色编码(抱歉,在这本书的印刷本中将是灰色的)

在浮点数的情况下,我们可以使用三个 32 位的 Java 浮点数。在 24 位编码的情况下,我们有一个小问题:Java 中没有 24 位整数类型,所以我们可以将每个组件存储在一个字节中,或者使用 32 位整数,剩下的高 8 位不用。在 16 位编码的情况下,我们也可以使用两个单独的字节,或者将各个部分存储在一个短值中。注意 Java 没有无符号类型。由于二进制补码的强大功能,我们可以安全地使用有符号整数类型来存储无符号值。

对于 16 位和 24 位整数编码,我们还需要指定在短整型值中存储三个部分的顺序。通常使用两种方法:RGB 和 BGR。图 3-23 使用 RGB 编码。蓝色分量位于最低的 5 或 8 位,绿色分量使用接下来的 6 或 8 位,红色分量使用最高的 5 或 8 位。BGR 编码正好颠倒了这个顺序。绿色的位留在原处,红色和蓝色的位交换位置。我们将在整本书中使用 RGB 顺序,因为 Android 的图形 API 也使用这种顺序。让我们总结一下到目前为止讨论的颜色编码:

  • 32 位浮点 RGB 编码的每个像素有 12 个字节,亮度在 0.0 和 1.0 之间变化。
  • 24 位整数 RGB 编码的每个像素有 3 或 4 个字节,亮度在 0 到 255 之间变化。组件的顺序可以是 RGB 或 BGR。在某些圈子里,这也被称为 RGB888 或 BGR888,其中 8 表示每个元件的位数。
  • 16 位整数 RGB 编码对于每个像素有 2 个字节;红色和蓝色的强度介于 0 和 31 之间,绿色的强度介于 0 和 63 之间。组件的顺序可以是 RGB 或 BGR。在某些圈子中,这也被称为 RGB565 或 BGR565,其中 5 和 6 指定相应元件的位数。

我们使用的编码类型也被称为色深。我们创建并存储在磁盘或内存中的图像具有定义的颜色深度,实际图形硬件和显示器本身的帧缓冲区也是如此。现在的显示器通常有一个默认的 24 位色深,在某些情况下可以配置得更少。图形硬件的帧缓冲区也相当灵活,它可以使用许多不同的颜色深度。当然,我们自己的图像也可以有我们喜欢的任何颜色深度。

注意对每像素颜色信息进行编码的方式还有很多。除了 RGB 颜色,我们还可以有灰度像素,它只有一个单一的组成部分。由于这些不常用,我们在这一点上忽略它们。

图像格式和压缩

在我们游戏开发过程中的某个时刻,我们的美工会给我们提供用 Gimp、Paint.NET 或 Photoshop 等图形软件制作的图像。这些图像可以以各种格式存储在磁盘上。为什么首先需要这些格式?难道我们不能将栅格数据作为字节块存储在磁盘上吗?

嗯,我们可以,但是让我们检查一下那会占用多少内存。假设我们想要最好的质量,所以我们选择以每像素 24 位的 RGB888 编码我们的像素。该图像的大小为 1,024 × 1,024 像素。这是 3MB 的一个微不足道的形象!使用 RGB565,我们可以将其降至大约 2MB。

就像音频一样,有很多关于如何减少存储图像所需内存的研究。像往常一样,采用压缩算法,专门为存储图像和尽可能多地保留原始颜色信息的需要而定制。两种最流行的格式是 JPEG 和 PNG。JPEG 是一种有损格式。这意味着一些原始信息在压缩过程中被丢弃。PNG 是一种无损格式,它将再现百分之百真实的原始图像。有损格式通常表现出更好的压缩特性,并且占用更少的磁盘空间。因此,我们可以根据磁盘内存的限制来选择使用哪种格式。

与音效类似,当我们将图像加载到内存中时,我们必须对其进行完全解压缩。因此,即使你的图像在磁盘上压缩了 20KB,你仍然需要 RAM 中的全宽乘以高乘以色深的存储空间。

一旦加载并解压缩,图像将以像素颜色数组的形式可用,与 VRAM 中的帧缓冲区布局方式完全相同。唯一的区别是像素位于普通 RAM 中,颜色深度可能不同于帧缓冲区的颜色深度。载入的图像也有一个类似 framebuffer 的坐标系,原点在左上角,x 轴指向右边,y 轴指向下面。

一旦图像被加载,我们可以简单地通过将图像中的像素颜色传输到帧缓冲区中的适当位置,将它绘制到 RAM 中的帧缓冲区。我们不用手来做这件事;相反,我们使用提供该功能的 API。

Alpha 合成和混合

在我们开始设计我们的图形模块接口之前,我们必须处理另外一件事:图像合成。为了便于讨论,假设我们有一个可以渲染的帧缓冲区,以及一组加载到 RAM 中的图像,我们将在帧缓冲区中抛出这些图像。图 3-25 显示了一个简单的背景图像,还有鲍勃,一个杀僵尸的女人缘。

9781430246770_Fig03-25.jpg

图 3-25。一个简单的背景和鲍勃,宇宙的主人

要绘制 Bob 的世界,我们首先将背景图像绘制到 framebuffer,然后在 framebuffer 中的背景图像上绘制 Bob。这个过程被称为合成,因为我们将不同的图像合成为最终的图像。我们绘制图像的顺序是相关的,因为任何新的绘制操作都会覆盖帧缓冲区中的当前内容。那么,我们合成的最终结果会是什么呢?图 3-26 给你看。

9781430246770_Fig03-26.jpg

图 3-26。将背景和鲍勃合成到帧缓冲区中(这不是我们想要的)

哎哟,这不是我们想要的。在图 3-26 中,注意 Bob 被白色像素包围。当我们在背景上绘制 Bob 到 framebuffer 时,那些白色像素也被绘制,有效地覆盖了背景。如何绘制 Bob 的图像,使得只绘制 Bob 的像素,忽略白色背景像素?

进入阿尔法混合。在 Bob 的例子中,这在技术上被称为 alpha 蒙版,但这只是 alpha 混合的一个子集。图形软件通常让我们不仅指定像素的 RGB 值,还指示其半透明性。可以把它看作是像素颜色的另一个组成部分。我们可以对它进行编码,就像我们对红色、绿色和蓝色分量进行编码一样。

我们之前暗示过,我们可以在 32 位整数中存储 24 位 RGB 三元组。在这个 32 位整数中有 8 个未使用的位,我们可以抓取并在其中存储我们的 alpha 值。然后我们可以指定一个像素的半透明度从 0 到 255,其中 0 是完全透明的,255 是不透明的。根据组件的顺序,这种编码称为 ARGB8888 或 BGRA8888。当然还有 RGBA8888 和 ABGR8888 格式。

在 16 位编码的情况下,我们有一个小问题:我们的 16 位短整型的所有位都被颜色分量占用了。让我们模仿 ARGB8888 格式,类似地定义一个 ARGB4444 格式。我们的 RGB 值总共剩下 12 位,每个颜色分量 4 位。

我们可以很容易地想象完全半透明或不透明的像素渲染方法是如何工作的。在第一种情况下,我们只需忽略 alpha 分量为零的像素。在第二种情况下,我们只需覆盖目标像素。然而,当一个像素既没有完全半透明也没有完全不透明的 alpha 分量时,事情会变得稍微复杂一点。

当以正式的方式谈论混合时,我们必须定义一些事情:

  • 混合有两个输入和一个输出,每个都表示为 RGB 三元组(C)加上 alpha 值(α)。
  • 这两个输入被称为目的地。源是我们要在目标图像(即帧缓冲区)上绘制的图像像素。目标像素是我们将要用源像素(部分)过度绘制的像素。
  • 输出再次是表示为 RGB 三元组和 alpha 值的颜色。不过,通常我们会忽略 alpha 值。为了简单起见,我们将在本章中这样做。
  • 为了简化数学,我们将 RGB 和 alpha 值表示为 0.0 到 1.0 范围内的浮点数。

有了这些定义,我们可以创建所谓的混合方程。最简单的等式是这样的:

red = src.red * src.alpha + dst.red * (1 – src.alpha)
blue = src.green * src.alpha + dst.green * (1 – src.alpha)
green = src.blue * src.alpha + dst.blue * (1 – src.alpha)

src 和 dst 是我们想要彼此混合的源和目标的像素。我们将这两种颜色按分量混合。请注意,在这些混合等式中缺少目标 alpha 值。让我们尝试一个例子,看看它做了什么:

src = (1, 0.5, 0.5), src.alpha = 0.5, dst = (0, 1, 0)
red = 1 * 0.5 + 0 * (1 – 0.5) = 0.5
blue = 0.5 * 0.5 + 1 * (1 – 0.5) = 0.75
red = 0.5 * 0.5 + 0 * (1 – 0.5) = 0.25

图 3-27 说明了前面的等式。我们的源颜色是粉红色,目标颜色是绿色。这两种颜色对最终输出颜色的贡献相等,导致绿色或橄榄色有点脏。

9781430246770_Fig03-27.jpg

图 3-27。混合两个像素

两位名叫波特和达夫的绅士提出了一系列混合方程式。不过,我们将坚持前面的等式,因为它涵盖了我们的大多数用例。试着在纸上或你选择的图形软件中进行实验,感受一下混合会对你的作品产生什么样的影响。

勾兑是一个很广的领域。如果你想充分利用它的潜力,我们建议你在网上搜索波特和达夫在这个问题上的原创作品。然而,对于我们将要编写的游戏,前面的等式就足够了。

请注意,前面的等式中包含了大量乘法运算(准确地说是六次)。乘法是昂贵的,我们应该尽可能避免它们。在混合的情况下,我们可以通过将源像素颜色的 RGB 值与源 alpha 值相乘来消除其中的三个乘法。大多数图形软件支持图像的 RGB 值与相应的 alphas 值相乘。如果不支持,可以在加载时在内存中实现。然而,当我们使用图形 API 绘制混合图像时,我们必须确保使用正确的混合公式。我们的图像仍然包含 alpha 值,所以前面的等式会输出不正确的结果。源 alpha 不得与源颜色相乘。幸运的是,所有 Android 图形 API 都允许我们完全指定我们想要如何混合我们的图像。

在 Bob 的例子中,我们只是在首选的图形软件程序中将所有白色像素的 alpha 值设置为零,加载 ARGB8888 或 ARGB4444 格式的图像,可能会预乘 alpha,并使用一种绘图方法,使用正确的混合公式进行实际的 alpha 混合。结果看起来像图 3-28 。

9781430246770_Fig03-28.jpg

图 3-28。 Bob blended 在左边,Bob in Paint。NET .在右边。棋盘显示白色背景像素的 alpha 为零,因此背景棋盘会发光

注意JPEG 格式不支持存储每个像素的 alpha 值。在这种情况下,请使用 PNG 格式。

在实践中

有了这些信息,我们终于可以开始设计图形模块的接口了。让我们来定义这些接口的功能。注意,当我们提到 framebuffer 时,我们实际上是指我们所绘制的 UI 组件的虚拟 framebuffer。我们只是假装直接绘制到真正的帧缓冲区。我们需要能够执行以下操作:

  • 从磁盘加载图像,并将其存储在内存中,以便以后绘制。
  • 用一种颜色清除帧缓冲区,这样我们就可以清除最后一帧中仍然存在的内容。
  • 将帧缓冲区中特定位置的像素设置为特定颜色。
  • 向帧缓冲区绘制线条和矩形。
  • 将先前加载的图像绘制到帧缓冲区。我们希望能够画出完整的图像或图像的一部分。我们还需要能够绘制混合和不混合的图像。
  • 获取帧缓冲区的尺寸。

我们提出两个简单的接口:图形和位图。让我们从图形界面开始,如清单 3-6 所示。

清单 3-6。 图形界面

package com.badlogic.androidgames.framework;

public interface Graphics {
    public static enum PixmapFormat {
        *ARGB8888*,*ARGB4444*,*RGB565*
    }

    public Pixmap newPixmap(String fileName, PixmapFormat format);

    public void clear(int color);

    public void drawPixel(int x, int y, int color);

    public void drawLine(int x, int y, int x2, int y2, int color);

    public void drawRect(int x, int y, int width, int height, int color);

    public void drawPixmap(Pixmap pixmap, int x, int y, int srcX, int srcY,
            int srcWidth, int srcHeight);

    public void drawPixmap(Pixmap pixmap, int x, int y);

    public int getWidth();

    public int getHeight();
}

我们从一个名为 PixmapFormat 的公共静态枚举开始。它编码了我们将支持的不同像素格式。接下来,我们有我们的图形界面的不同方法:

  • Graphics.newPixmap()方法将加载 JPEG 或 PNG 格式的图像。我们为生成的位图指定一个期望的格式,这是对加载机制的一个提示。产生的位图可能有不同的格式。我们这样做是为了在某种程度上控制已加载图像的内存占用(例如,通过将 RGB888 或 ARGB8888 图像加载为 RGB565 或 ARGB4444 图像)。文件名指定了我们的应用的 APK 文件中的一个素材。
  • Graphics.clear()方法用给定的颜色清除整个帧缓冲区。我们的小框架中的所有颜色将被指定为 32 位 ARGB8888 值(当然,Pixmaps 可能有不同的格式)。
  • Graphics.drawPixel()方法会将 framebuffer 中(x,y)处的像素设置为给定的颜色。屏幕外的坐标将被忽略。这叫做削波
  • Graphics.drawLine()方法类似于 Graphics.drawPixel()方法。我们指定线条的起点和终点,以及颜色。位于帧缓冲区栅格之外的线的任何部分都将被忽略。
  • Graphics.drawRect()方法在 framebuffer 中绘制一个矩形。(x,y)指定帧缓冲区中矩形左上角的位置。参数 width 和 height 指定 x 和 y 的像素数,矩形将从(x,y)开始填充。我们在 y 方向向下填充。颜色参数是用来填充矩形的颜色。
  • Graphics.drawPixmap()方法将 Pixmap 的矩形部分绘制到 framebuffer 中。(x,y)坐标指定了帧缓冲区中位图目标位置的左上角位置。参数 srcX 和 srcY 指定了矩形区域的相应左上角,该矩形区域是从像素图中使用的,在像素图自己的坐标系中给出。最后,srcWidth 和 srcHeight 指定了我们从位图中获取的部分的大小。
  • 最后,Graphics.getWidth()和 Graphics.getHeight()方法以像素为单位返回 framebuffer 的宽度和高度。

除 Graphics.clear()之外的所有绘制方法都会自动对它们接触的每个像素执行混合,如前一节所述。我们可以根据具体情况禁用混合来加快绘制速度,但这会使我们的实现变得复杂。通常,对于像 Nom 先生这样的简单游戏,我们可以一直启用混合。

列表 3-7 中的给出了 Pixmap 接口。

清单 3-7。 点阵图界面

package com.badlogic.androidgames.framework;

import com.badlogic.androidgames.framework.Graphics.PixmapFormat;

public interface Pixmap {
    public int getWidth();

    public int getHeight();

    public PixmapFormat getFormat();

    public void dispose();
}

我们保持它非常简单和不可变,因为合成是在帧缓冲区中完成的:

  • Pixmap.getWidth()和 Pixmap.getHeight()方法以像素为单位返回 Pixmap 的宽度和高度。
  • 方法返回 Pixmap 存储在 RAM 中的 PixelFormat。
  • 最后,还有 Pixmap.dispose()方法。Pixmap 实例会耗尽内存和潜在的其他系统资源。如果我们不再需要它们,我们应该用这种方法处理它们。

有了这个简单的图形模块,我们以后可以很容易地实现 Nom 先生。让我们以对游戏框架本身的讨论来结束这一章。

游戏框架

在我们做了所有的基础工作之后,我们终于可以谈论如何实现游戏本身了。为此,让我们确定我们的游戏必须执行哪些任务:

  • 游戏被分成不同的屏幕。每个屏幕执行相同的任务:评估用户输入,将输入应用到屏幕状态,以及渲染场景。一些屏幕可能不需要任何用户输入,只是在一段时间后转换到另一个屏幕(例如,闪屏)。
  • 屏幕需要以某种方式进行管理(也就是说,我们需要跟踪当前屏幕,并有办法过渡到新屏幕,这可以归结为销毁旧屏幕并将新屏幕设置为当前屏幕)。
  • 游戏需要授予屏幕对不同模块(图形、音频、输入等)的访问权限,以便它们可以加载资源、获取用户输入、播放声音、渲染到帧缓冲区,等等。
  • 由于我们的游戏将是实时的(这意味着事物将不断移动和更新),我们必须使当前屏幕更新其状态,并尽可能经常地呈现它自己。我们通常会在一个叫做主循环的循环中这样做。当用户退出游戏时,循环将终止。这个循环的单次迭代被称为。我们可以计算的每秒帧数(FPS)被称为帧率
  • 说到时间,我们还需要记录自上一帧以来已经过去的时间跨度。这是用于独立于帧的运动,我们将在一分钟内讨论。
  • 游戏需要跟踪窗口状态(即它是暂停还是恢复),并将这些事件通知当前屏幕。
  • 游戏框架将处理设置窗口和创建 UI 组件,我们渲染和接收输入。

让我们将其归结为一些伪代码,暂时忽略暂停和恢复等窗口管理事件:

createWindowAndUIComponent();

Input input = new Input();
Graphics graphics = new Graphics();
Audio audio = new Audio();
Screen currentScreen = new MainMenu();
Float lastFrameTime = currentTime();

while ( !userQuit() ) {
   float deltaTime = currentTime() – lastFrameTime;
   lastFrameTime = currentTime();

   currentScreen.updateState(input, deltaTime);
   currentScreen.present(graphics, audio, deltaTime);
}

cleanupResources();

我们首先创建游戏的窗口和 UI 组件,我们向其渲染并从其接收输入。接下来,我们实例化完成底层工作所需的所有模块。我们实例化我们的开始屏幕并使它成为当前屏幕,我们记录当前时间。然后我们进入主循环,如果用户表示他或她想要退出游戏,主循环将终止。

在游戏循环内,我们计算所谓的 delta 时间。这是从最后一帧开始所经过的时间。然后我们记录下当前帧开始的时间。增量时间和当前时间通常以秒为单位。对于屏幕,delta time 表示自上次更新以来已经过了多长时间——如果我们想要进行独立于帧的移动(我们稍后将回到这一点),则需要该信息。

最后,我们简单地更新当前屏幕的状态并呈现给用户。更新取决于增量时间以及输入状态;因此,我们将它们提供给屏幕。该演示包括将屏幕状态呈现到帧缓冲区,以及回放屏幕状态所需的任何音频(例如,由于上次更新中发射的一个镜头)。表示方法可能还需要知道自上次调用以来已经过了多长时间。

当主循环终止时,我们可以清理并释放所有资源,关闭窗口。

这也是几乎所有游戏的高级工作方式:处理用户输入,更新状态,将状态呈现给用户,并无限重复(或者直到用户厌倦了我们的游戏)。

现代操作系统上的 UI 应用通常不能实时工作。它们使用基于事件的范例,其中操作系统通知应用输入事件,以及何时呈现自身。这是通过应用在启动时向操作系统注册的回调来实现的;然后,它们负责处理收到的事件通知。所有这些都发生在所谓的 UI 线程—UI 应用的主线程中。尽可能快地从回调中返回通常是一个好主意,所以我们不想在其中一个回调中实现我们的主循环。

相反,我们将游戏的主循环放在一个单独的线程中,当游戏启动时,我们将产生这个线程。这意味着当我们想要接收 UI 线程事件时,例如输入事件或窗口事件,我们必须采取一些预防措施。但是这些都是我们以后在为 Android 实现游戏框架时要处理的细节。请记住,我们需要在某些时候同步 UI 线程和游戏的主循环线程。

游戏和屏幕界面

综上所述,让我们试着设计一个游戏界面。下面是这个接口的实现必须做的事情:

  • 设置窗口和 UI 组件,并挂钩回调,以便我们可以接收窗口和输入事件。
  • 启动主循环线程。
  • 跟踪当前屏幕,并告诉它在每次主循环迭代中更新和呈现自己(也称为帧)。
  • 将任何窗口事件(例如,暂停和恢复事件)从 UI 线程转移到主循环线程,并将它们传递到当前屏幕,以便它可以相应地更改其状态。
  • 授权访问我们之前开发的所有模块:输入、文件、图形和音频。

作为游戏开发人员,我们希望不知道我们的主循环运行在什么线程上,以及我们是否需要与 UI 线程同步。我们只是想在低级模块和一些窗口事件通知的帮助下实现不同的游戏屏幕。因此,我们将创建一个非常简单的游戏界面,隐藏所有这些复杂性,以及一个抽象的屏幕类,我们将使用它来实现我们所有的屏幕。清单 3-8 显示游戏界面。

清单 3-8。 游戏界面

package com.badlogic.androidgames.framework;

public interface Game {
    public Input getInput();

    public FileIO getFileIO();

    public Graphics getGraphics();

    public Audio getAudio();

    public void setScreen(Screen screen);

    public Screen getCurrentScreen();

    public Screen getStartScreen();
}

正如预期的那样,有几个 getter 方法可以返回我们底层模块的实例,游戏实现将实例化和跟踪这些模块。

Game.setScreen()方法允许我们设置游戏的当前屏幕。这些方法将被实现一次,连同所有的内部线程创建、窗口管理和主循环逻辑,它们将不断要求当前屏幕呈现并更新自身。

Game.getCurrentScreen()方法返回当前活动的屏幕实例。

稍后我们将使用一个名为 AndroidGame 的抽象类来实现游戏接口,它将实现除 Game.getStartScreen()方法之外的所有方法。这个方法将是一个抽象方法。如果我们为实际游戏创建 AndroidGame 实例,我们将扩展它并覆盖 Game.getStartScreen()方法,将实例返回到游戏的第一个屏幕。

为了让你对设置我们的游戏有多简单有个印象,这里有个例子(假设我们已经实现了 AndroidGame 类):

public class MyAwesomeGameextends AndroidGame {
    public Screen getStartScreen () {
        return new MySuperAwesomeStartScreen(this );
    }
}

太棒了,不是吗?我们所要做的就是实现我们想要用来启动游戏的屏幕,AndroidGame 类会为我们完成剩下的工作。从这一点开始,我们的 MySuperAwesomeStartScreen 将被主循环线程中的 AndroidGame 实例请求更新和呈现。注意,我们将 MyAwesomeGame 实例本身传递给屏幕实现的构造函数。

注意如果你想知道实际上是什么实例化了我们的 MyAwesomeGame 类,我们给你一个提示:AndroidGame 将从 Activity 派生,当用户启动我们的游戏时,它将由 Android 操作系统自动实例化。

拼图的最后一块是抽象类屏幕。我们让它成为一个抽象类,而不是一个接口,这样我们就可以实现一些簿记。这样,在抽象 Screen 类的实际实现中,我们必须编写更少的样板代码。清单 3-9 显示了抽象的屏幕类。

清单 3-9。 屏幕类

package com.badlogic.androidgames.framework;

public abstract class Screen {
    protected final Game game;

    public Screen(Game game) {
        this .game = game;
    }

    public abstract void update(float deltaTime);

    public abstract void present(float deltaTime);

    public abstract void pause();

    public abstract void resume();

    public abstract void dispose();
}

事实证明,记账并没有那么糟糕。构造函数接收游戏实例,并将其存储在所有子类都可以访问的最终成员中。通过这种机制,我们可以实现两件事:

  • 我们可以访问游戏界面的底层模块来回放音频、在屏幕上绘图、获取用户输入以及读写文件。
  • 我们可以在适当的时候通过调用 Game.setScreen()来设置一个新的当前屏幕(例如,当按下一个按钮触发到新屏幕的转换时)。

第一点非常明显:我们的屏幕实现需要访问这些模块,这样它才能真正做一些有意义的事情,比如渲染大量患有狂犬病的独角兽。

第二点允许我们在屏幕实例本身中容易地实现我们的屏幕转换。每个屏幕可以根据其状态(例如,当按下菜单按钮时)决定何时转换到其他屏幕。

方法 Screen.update()和 Screen.present()现在应该是不言自明的了:它们将更新屏幕状态并相应地显示出来。游戏实例将在主循环的每次迭代中调用它们一次。

当游戏暂停或恢复时,将调用 Screen.pause()和 Screen.resume()方法。这同样由游戏实例完成,并应用于当前活动的屏幕。

如果调用 Game.setScreen(),游戏实例将调用 Screen.dispose()方法。游戏实例将通过这个方法释放当前屏幕,从而给屏幕一个机会来释放它的所有系统资源(例如,存储在 Pixmaps 中的图形资源),以便在内存中为新屏幕的资源腾出空间。对 Screen.dispose()方法的调用也是屏幕确保保存任何需要持久性的信息的最后机会。

简单的例子

继续我们的 MySuperAwesomeGame 示例,这里是 MySuperAwesomeStartScreen 类的一个非常简单的实现:

public class MySuperAwesomeStartScreen extends Screen {
    Pixmap awesomePic;
    int x;

    public MySuperAwesomeStartScreen(Game game) {
        super (game);
        awesomePic = game.getGraphics().newPixmap("data/pic.png",
                PixmapFormat.*RGB565*);
    }

    @Override
    public void update(float deltaTime) {
        x += 1;
        if (x > 100)
            x = 0;
    }

    @Override
    public void present(float deltaTime) {
        game.getGraphics().clear(0);
        game.getGraphics().drawPixmap(awesomePic, x, 0, 0, 0,
                awesomePic.getWidth(), awesomePic.getHeight());
    }

    @Override
    public void pause() {
        // nothing to do here
    }

    @Override
    public void resume() {
        // nothing to do here
    }

    @Override
    public void dispose() {
        awesomePic.dispose();
    }
}

让我们看看这个类,结合 MySuperAwesomeGame 类,将会做什么:

  1. 当 MySuperAwesomeGame 类被创建时,它将设置窗口、我们向其呈现和从其接收事件的 UI 组件、接收窗口和输入事件的回调以及主循环线程。最后,它将调用自己的 mysuperawesomegay . getstartscreen()方法,该方法将返回 MySuperAwesomeStartScreen()类的一个实例。
  2. 在 MySuperAwesomeStartScreen 构造函数中,我们从磁盘加载一个位图,并将其存储在一个成员变量中。这就完成了我们的屏幕设置,控制权交还给了 MySuperAwesomeGame 类。
  3. 主循环线程现在将不断调用我们刚刚创建的实例的 mysuperawesomestartscreen . update()和 mysuperawesomestartscreen . present()方法。
  4. 在 mysuperawesomestartscreen . update()方法中,我们每帧增加一个名为 x 的成员。这个成员持有我们想要渲染的图像的 x 坐标。当 x 坐标值大于 100 时,我们将其重置为 0。
  5. 在 mysuperawesomestartscreen . present()方法中,我们用黑色(0x00000000 = 0)清除帧缓冲区,并在位置(x,0)呈现我们的位图。
  6. 主循环线程将重复步骤 3 到 5,直到用户按下设备上的后退按钮退出游戏。游戏实例将调用 mysuperawesomestarscreen . dispose()方法,该方法将释放位图。

这是我们第一个(不那么)激动人心的游戏!用户只会看到图像在屏幕上从左向右移动。这并不是一个令人愉快的用户体验,但我们稍后会解决这个问题。请注意,在 Android 上,游戏可以暂停,并在任何时间点恢复。然后,我们的 MyAwesomeGame 实现将调用 mysuperawesomestartscreen . pause()和 mysuperawesomestartscreen . resume()方法。只要应用本身暂停,主循环线程就会暂停。

还有最后一个我们必须要说的问题:帧率——独立运动。

帧速率-独立运动

让我们假设用户的设备可以以 60FPS 的速度运行上一节中的游戏。我们的 Pixmap 将在 100 帧中前进 100 个像素,因为我们每帧将 MySuperAwesomeStartScreen.x 成员增加 1 个像素。在 60FPS 的帧速率下,到达位置(100,0)大约需要 1.66 秒。

现在让我们假设第二个用户在不同的设备上玩我们的游戏。那个设备能够以每秒 30 帧的速度运行我们的游戏。每秒,我们的位图前进 30 个像素,所以到达位置(100,0)需要 3.33 秒。

这很糟糕。它可能不会对我们的简单游戏所产生的用户体验产生影响,但是用超级马里奥代替像素地图,并考虑以依赖于帧的方式移动他将意味着什么。假设我们按住右边的 D-pad 按钮,马里奥就会跑到右边。在每一帧中,我们将他推进 1 个像素,就像我们在像素图中所做的那样。在能以 60 FPS 运行游戏的设备上,马里奥的运行速度将是以 30 FPS 运行游戏的设备的两倍!这将完全改变用户体验,取决于设备的性能。我们需要解决这个问题。

这个问题的解决方案叫做独立于帧速率的运动。我们不是每帧固定移动我们的点阵图(或马里奥),而是指定每秒单位的移动速度。假设我们希望我们的位图每秒前进 50 个像素。除了每秒 50 像素的值之外,我们还需要关于自从我们上次移动位图以来已经过了多长时间的信息。这就是这个奇怪的 delta 时间发挥作用的地方。它告诉我们自上次更新以来已经过去了多长时间。因此,我们的 mysuperawesomestartscreen . update()方法应该如下所示:

@Override
public void update(float deltaTime) {
    x += 50 * deltaTime;
    if(x > 100)
        x = 0;
}

如果我们的游戏以恒定的 60FPS 运行,传递给该方法的增量时间将始终是 1/60 0.016 秒。因此,在每一帧中,我们前进 50×0.016 \u 0.83 像素。在 60FPS 下,我们推进 60×0.83∾50 像素!我们用 30FPS 来测试一下这个:50×1/30∾1.66。乘以 30FPS,我们再次每秒移动 50 个像素。因此,无论运行我们游戏的设备执行游戏的速度有多快,我们的动画和动作将始终与实际的挂钟时间保持一致。

如果我们真的用前面的代码来尝试,我们的位图根本不会以 60FPS 的速度移动。这是因为我们代码中的一个错误。我们会给你一些时间来发现它。这很微妙,但却是游戏开发中常见的陷阱。我们用来增加每一帧的 x 成员实际上是一个整数。整数加 0.83 不会有任何影响。要解决这个问题,我们只需将 x 存储为浮点数而不是整数。这也意味着我们在调用 Graphics.drawPixmap()时,必须向 int 添加一个强制转换。

注意虽然 Android 上的浮点计算通常比整数运算慢,但影响几乎可以忽略不计,所以我们可以不用使用更昂贵的浮点运算。

这就是我们游戏框架的全部内容。我们可以直接把 Mr. Nom 设计的屏幕翻译成我们的类和框架的接口。当然,一些实现细节仍然需要注意,但是我们将把它留到后面的章节。现在,你可以为自己感到骄傲。你坚持读完这一章,现在你已经准备好成为 Android(和其他*台)的游戏开发者了!

摘要

大约 50 页高度浓缩和信息丰富的内容之后,你应该对创建一个游戏有一个很好的想法。我们在 Google Play 上查看了一些最受欢迎的流派,并得出了一些结论。我们从头开始设计了一个完整的游戏,只用了剪刀、一支笔和一些纸。最后,我们探索了游戏开发的理论基础,我们甚至创建了一组接口和抽象类,我们将在本书中使用它们来实现基于这些理论概念的游戏设计。如果你觉得你想超越这里所涵盖的基础知识,那么尽一切办法在网上寻找更多的信息。你手里握着所有的关键词。理解这些原则是开发稳定且性能良好的游戏的关键。也就是说,让我们为 Android 实现我们的游戏框架吧!*

四、面向游戏开发者的 Android

Android 的应用框架非常庞大,有时会令人困惑。对于你能想到的每一个可能的任务,都有一个你可以使用的 API。当然,你必须先学习 API。幸运的是,我们游戏开发者只需要非常有限的一组 API。我们想要的只是一个有单一 UI 组件的窗口,我们可以在其中绘图,从那里我们可以接收输入,以及播放音频的能力。这涵盖了我们实现游戏框架的所有需求,我们在第三章中设计了这个框架,并且是以一种*台无关的方式。

在这一章中,你将学到实现 Nom 先生所需的最少数量的 Android APIs。您会惊讶地发现,要实现这个目标,您实际上只需要了解这些 API。让我们回忆一下我们需要哪些原料:

  • 窗口管理
  • 投入
  • 文件输入输出
  • 声音的
  • 制图法

对于这些模块中的每一个,在应用框架 API 中都有一个对应的模块。我们将挑选处理这些模块所需的 API,讨论它们的内部结构,最后实现我们在第三章设计的游戏框架的各个接口。

如果你碰巧来自 iOS/Xcode 背景,我们在本章末尾有一小段将提供一些翻译和指导。然而,在我们深入 Android 上的窗口管理之前,我们必须回顾一下我们在第二章中简单讨论过的东西:通过清单文件定义我们的应用。

定义 Android 应用:清单文件

一个 Android 应用 可以由大量不同的组件组成:

  • Activities :这些是面向用户的组件,提供一个可以与之交互的 UI。
  • 服务:这些是在后台工作的进程,没有可见的 UI。例如,服务可能负责轮询邮件服务器以获取新的电子邮件。
  • 内容提供者(Content providers):这些组件使您的应用数据的一部分对其他应用可用。
  • 意图:这些是系统或应用自己创建的消息。然后,它们被传递给任何感兴趣的一方。意图可能会通知我们系统事件,如 SD 卡被移除或 USB 电缆被连接。意图也被系统用来启动我们的应用的组件,比如活动。我们还可以触发自己的意图,要求其他应用执行某个操作,比如打开照片库来显示图像,或者启动相机应用来拍照。
  • 广播接收器:这些接收器对特定的意图做出反应,它们可能会执行一个动作,比如开始一个特定的活动或者向系统发出另一个意图。

Android 应用没有单一的入口点,就像我们习惯在桌面操作系统上拥有的那样(例如,以 Java 的 main()方法的形式)。取而代之的是,Android 应用的组件被启动或被要求执行特定意图的特定动作。

应用的清单文件中定义了我们的应用由哪些组件组成,以及这些组件对哪些意图做出反应。Android 系统使用这个清单文件来了解我们的应用是由什么组成的,比如应用启动时显示的默认活动。

注意我们只关心本书中的活动,所以我们只讨论这种类型组件的清单文件的相关部分。如果你想让自己晕头转向,你可以在 Android 开发者网站上了解更多关于 manifest 文件的信息(【http://developer.android.com】??)。

清单文件不仅仅用于定义应用的组件。以下列表总结了游戏开发环境中清单文件的相关部分:

  • 在 Google Play 上显示和使用的应用版本
  • 我们的应用可以运行的 Android 版本
  • 我们的应用需要的硬件配置文件(即多点触摸、特定的屏幕分辨率或对 OpenGL ES 2.0 的支持)
  • 使用特定组件的权限,例如写入 SD 卡或访问网络堆栈

在接下来的小节中,我们将创建一个模板清单文件,我们可以以稍微修改的方式在本书的所有项目中重用它。为此,我们将浏览定义应用所需的所有相关 XML 标记。

元素

标签是 AndroidManifest.xml 文件的根元素。这里有一个基本的例子:

<manifest xmlns:android="*[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)*"
      package="*com*.*helloworld*"
      android:versionCode="*1*"
      android:versionName="*1*.*0*"
      android:installLocation="*preferExternal*">
...
</manifest>

我们假设您以前使用过 XML,所以您应该熟悉第一行。标签指定了一个名为 android 的名称空间,该名称空间在清单文件的其余部分中使用。package 属性定义了我们的应用的根包名。稍后,我们将引用与这个包名相关的应用的特定类。

versionCode 和 versionName 属性以两种形式指定应用的版本。versionCode 属性是一个整数,每次我们发布应用的新版本时,它都必须递增。Google Play 使用它来跟踪我们应用的版本。当 Google Play 的用户浏览我们的应用时,会向他们显示 versionName 属性。我们可以在这里使用任何我们喜欢的字符串。

只有当我们在 Eclipse 中将 Android 项目的构建目标设置为 Android 2.2 或更新版本时,installLocation 属性才可用。它指定了我们的应用应该安装在哪里。字符串 preferExternal 告诉系统我们希望我们的应用安装到 SD 卡上。这只适用于 Android 2.2 或更高版本,所有早期的 Android 应用都会忽略该字符串。在 Android 2.2 或更高版本中,应用总是会尽可能地安装到内部存储中。

清单文件中 XML 元素的所有属性通常都以 android 名称空间为前缀,如前所示。为了简洁起见,在下面的部分中,当谈到特定的属性时,我们将不指定名称空间。

元素中,我们定义了应用的组件、权限、硬件配置文件和支持的 Android 版本。

元素

元素的情况一样,让我们以示例的形式讨论元素:

<application android:icon="@*drawable*/*icon*" android:label="@*string*/*app*_*name*">
...
</application>

这看起来是不是有点奇怪?@drawable/icon 和@string/app_name 字符串是怎么回事?在开发一个标准的 Android 应用时,我们通常会编写大量的 XML 文件,每个文件都定义了应用的一个特定部分。这些部分的完整定义要求我们还能够引用 XML 文件中没有定义的资源,比如图像或国际化字符串。这些资源位于 res/文件夹的子文件夹中,正如我们在 Eclipse 中剖析 Hello World 项目时在第二章中讨论的那样。

为了引用资源,我们使用前面的符号。@指定我们想要引用在别处定义的资源。下面的字符串标识了我们想要引用的资源的类型,它直接映射到 RES/目录中的一个文件夹或文件。最后一部分指定了资源的名称。在前面的例子中,这是一个名为 icon 的图像和一个名为 app_name 的字符串。对于图像,它是我们指定的实际文件名,可以在 res/drawable-xxx/文件夹中找到。请注意,图像名称没有像这样的后缀。png 或. jpg. Android 会根据 res/drawable-xxx/文件夹里的内容自动推断后缀。app_name 字符串在 res/values/strings.xml 文件中定义,该文件将存储应用使用的所有字符串。字符串的名称是在 strings.xml 文件中定义的。

注意Android 上的资源处理非常灵活,但也很复杂。对于这本书,我们决定跳过大部分资源处理,原因有两个:这对游戏开发来说完全是大材小用,我们想完全控制我们的资源。Android 有修改放置在 res/文件夹中的资源的习惯,尤其是图片(称为 drawables)。这是我们作为游戏开发者不希望看到的。我们建议 Android 资源系统在游戏开发中的唯一用途是国际化字符串。我们不会在本书中深入探讨这一点;相反,我们将使用更有利于游戏开发的资源/文件夹,这不会影响我们的资源,并允许我们指定自己的文件夹层次结构。

现在,元素属性的含义应该变得更清楚了。icon 属性指定 res/drawable/文件夹中的图像用作应用的图标。该图标将显示在 Google Play 以及设备上的应用启动器中。它也是我们在元素中定义的所有活动的默认图标。

label 属性指定在应用启动器中为我们的应用显示的字符串。在前面的例子中,它引用了 res/values/string.xml 文件中的一个字符串,这是我们在 Eclipse 中创建 Android 项目时指定的。我们也可以将它设置为一个原始字符串,比如我的超级棒的游戏。该标签也是我们在元素中定义的所有活动的默认标签。标签将显示在我们的应用的标题栏中。

我们只讨论了可以为元素指定的很小一部分属性。但是,这些对于我们的游戏开发需求来说已经足够了。如果你想知道更多,你可以在 Android 开发者网站上找到完整的文档。

元素包含所有应用组件的定义,包括活动和服务,以及使用的任何附加库。

元素

现在越来越有趣了。下面是我们的提名先生游戏的一个假设的例子:

<activity android:name=".*MrNomActivity*"
          android:label="*Mr*.*Nom*"
          android:screenOrientation="*portrait*">
          android:configChanges="*keyboard*|*keyboardHidden*|*orientation*">
    <intent-filter>
        <action android:name="*android*.*intent*.*action*.*MAIN*" />
        <category android:name="*android*.*intent*.*category*.*LAUNCHER*" />
    </intent-filter>
</activity>

让我们先来看看标签的属性:

  • name:这指定了相对于我们在元素中指定的包属性的活动类的名称。您也可以在这里指定一个完全限定的类名。
  • 标签:我们已经在元素中指定了相同的属性。该标签显示在活动的标题栏中(如果有)。如果我们定义的活动是应用的入口点,标签也将用作应用启动器中显示的文本。如果我们不指定它,将使用来自元素的标签。请注意,我们在这里使用了原始字符串,而不是对 string.xml 文件中的字符串的引用。
  • screenOrientation:该属性指定活动将使用的方向。这里我们为我们的提名先生游戏指定了肖像,它只能在肖像模式下工作。或者,如果我们想在横向模式下运行,我们可以指定横向。这两种配置都将强制活动的方向在活动的生命周期中保持不变,不管设备实际上是如何定向的。如果我们忽略这个属性,那么活动将使用设备的当前方向,通常基于加速度计数据。这也意味着无论何时设备方向改变,活动都将被破坏并重新开始——这在游戏中是不可取的。我们通常将游戏活动的方向固定为横向模式或纵向模式。
  • 配置更改:重新定位设备或滑出键盘被视为配置更改。在这种变化的情况下,Android 将销毁并重新启动我们的应用来适应这种变化。这在游戏中是不可取的。元素的 configChanges 属性可以解决这个问题。它允许我们指定我们想要自己处理的配置更改,而不需要破坏和重新创建我们的活动。可以通过使用|字符连接多个配置更改来指定它们。在前面的例子中,我们自己处理键盘、隐藏键盘和方向的变化。

元素一样,当然,您可以为元素指定更多的属性。对于游戏开发来说,我们摆脱了刚才讨论的四个属性。

现在,您可能已经注意到,元素不是空的,但是它包含另一个元素,该元素本身又包含两个元素。这些是干什么用的?

正如我们之前指出的,Android 上的应用没有单一的主入口点。相反,我们可以有多个活动和服务形式的入口点,这些入口点是为了响应系统或第三方应用发出的特定意图而启动的。不知何故,我们需要与 Android 沟通,我们的应用的哪些活动和服务将对特定意图做出反应(以及以何种方式)。这就是元素发挥作用的地方。

在前面的例子中,我们指定了两种类型的意图过滤器:一个和一个元素告诉 Android 我们的活动是应用的主要入口。元素指定我们希望将该活动添加到应用启动器中。这两个元素一起允许 Android 推断,当应用启动器中的图标被按下时,它应该开始特定的活动。

对于元素,唯一指定的是 name 属性,它标识活动将对其做出反应的意图。intent android . intent . action . main 是一个特殊的 intent,Android 系统使用它来启动应用的主活动。intent android . intent . category . launcher 用于告诉 Android 应用的特定活动是否应该在应用启动器中有一个条目。

通常,我们只有一个活动指定这两个意图过滤器。然而,一个标准的 Android 应用几乎总是有多个活动,这些活动也需要在 manifest.xml 文件中定义。下面是这种子活动的定义示例:

<activity android:name=".*MySubActivity*"
          android:label="*Sub Activity Title*"
          android:screenOrientation="*portrait*">
          android:configChanges="*keyboard*|*keyboardHidden*|*orientation*"/>

这里没有指定意图过滤器——只有我们前面讨论的活动的四个属性。当我们像这样定义一个活动时,它只对我们自己的应用可用。我们带着一种特殊的意图以编程方式开始这种类型的活动;比方说,当在一个活动中按下一个按钮来打开一个新的活动时。我们将在后面的章节中看到如何以编程方式启动一个活动。

总而言之,我们为一个活动指定了两个意图过滤器,这样它就成为了我们应用的主要入口点。对于所有其他活动,我们省略了意图过滤器规范,这样它们就在我们的应用内部。我们将以编程方式启动这些。

如前所述,我们在游戏中只会有一个活动。该活动将具有与前面所示完全相同的意图过滤器规范。我们讨论如何指定多个活动的原因是,我们将在一分钟内创建一个具有多个活动的特殊示例应用。别担心,这很容易。

元素

我们现在离开元素,回到我们通常定义为元素的子元素的元素。其中一个元素是元素。

Android 有一个复杂的安全模型。每个应用都在自己的进程和虚拟机(VM)中运行,有自己的 Linux 用户和组,它不能影响其他应用。Android 还限制系统资源的使用,如网络设施、SD 卡和录音硬件。如果我们的应用想要使用这些系统资源,我们必须请求许可。这是通过元素完成的。

权限总是具有以下形式,其中字符串指定我们想要被授予的权限的名称:

<uses-permission android:name="*string*"/>

以下是一些可能会派上用场的权限名称:

  • Android . permission . record _ AUDIO:这允许我们访问录音硬件。
  • android.permission.INTERNET:这授予我们访问所有网络 API 的权限,因此我们可以从互联网上获取图像或上传高分。
  • Android . permission . write _ EXTERNAL _ STORAGE:这允许我们读写外部存储上的文件,通常是设备的 SD 卡。
  • android.permission.WAKE_LOCK:这允许我们获得一个唤醒锁。有了这个唤醒锁,如果屏幕有一段时间没有被触摸,我们可以防止设备进入睡眠状态。例如,这可能发生在仅由加速度计控制的游戏中。
  • Android . permission . access _ COARSE _ LOCATION:这是一个非常有用的权限,因为它允许您获得非 GPS 级别的访问权限,例如用户所在的国家,这对于语言默认和分析非常有用。
  • android.permission.NFC:这允许应用通过*场通信(NFC)执行 I/O 操作,这对于涉及少量信息快速交换的各种游戏功能非常有用。

为了访问网络 API,我们将下面的元素指定为元素的子元素:

<uses-permission android:name="*android*.*permission*.*INTERNET*"/>

对于任何额外的权限,我们只需添加更多的元素。您可以指定更多的权限;我们再次建议您参考 Android 官方文档。我们只需要刚才讨论过的那套。

忘记添加访问 SD 卡等权限是常见的错误来源。它在设备日志中显示为一条消息,因此由于日志中杂乱的信息,它可能不会被发现。在随后的部分中,我们将更详细地描述日志。考虑游戏需要的权限,并在最初创建项目时指定它们。

另一件要注意的事情是,当用户安装您的应用时,他或她将首先被要求检查您的应用需要的所有权限。许多用户会跳过这些,高兴地安装他们能找到的任何东西。一些用户对他们的决定更有意识,会详细检查权限。如果你请求可疑的权限,比如发送昂贵的短信或获取用户位置的能力,当你的应用在 Google Play 上时,你可能会在评论区收到用户的一些讨厌的反馈。如果你必须使用那些有问题的权限,你的应用描述也应该告诉用户你为什么使用它。最好的办法是首先避免这些权限,或者提供合法使用它们的功能。

元素

如果你自己是一个 Android 用户,并且拥有一个像 1.5 这样的旧 Android 版本的旧设备,你会注意到一些很棒的应用不会出现在你设备上的 Google Play 应用中。其中一个原因可能是在应用的清单文件中使用了元素。

Google Play 应用将根据您的硬件配置文件过滤所有可用的应用。使用元素,应用可以指定它需要哪些硬件特性;比如多点触控或者支持 OpenGL ES 2.0。任何不具备指定功能的设备都将触发该过滤器,因此最终用户首先不会看到该应用。

一个元素具有以下属性:

<uses-feature android:name="*string*" android:required=["*true*" | "*false*"]
android:glEsVersion="*integer*" />

name 属性指定了要素本身。required 属性告诉过滤器我们是否真的在所有情况下都需要这个特性,或者它只是一个很好的特性。最后一个属性是可选的,仅在需要特定的 OpenGL ES 版本时使用。

对于游戏开发者来说,以下功能最为重要:

  • Android . hardware . touchscreen . multi touch:这要求设备具有多点触摸屏幕,能够进行基本的多点触摸交互,如挤压缩放等。这些类型的屏幕在独立跟踪多个手指方面存在问题,所以你必须评估这些功能是否足以满足你的游戏。
  • Android . hardware . touch . multi touch . distinct:这是最后一个功能的老大哥。这需要完整的多点触摸功能,适合于实现像屏幕上的虚拟双操纵杆这样的控制。

我们将在本章的后半部分研究多点触摸。现在,只要记住,当我们的游戏需要多点触摸屏幕时,我们可以通过指定一个具有前面的功能名称的元素来剔除所有不支持该功能的设备,就像这样:

<uses-feature android:name="*android*.*hardware*.*touchscreen*.*multitouch*" android:required="*true*"/>

游戏开发者要做的另一件有用的事情是指定需要哪个 OpenGL ES 版本。在本书中,我们将关注 OpenGL ES 1.0 和 1.1。对于这些,我们通常不指定元素,因为它们彼此没有太大的不同。然而,任何实现 OpenGL ES 2.0 的设备都可以被认为是图形发电站。如果我们的游戏在视觉上很复杂,需要大量的处理能力,我们可以要求 OpenGL ES 2.0,以便游戏只在能够以可接受的帧速率呈现令人惊叹的视觉效果的设备上显示。注意,我们没有使用 OpenGL ES 2.0,我们只是通过硬件类型进行过滤,以便我们的 OpenGL ES 1.x 代码获得足够的处理能力。我们可以这样做:

<uses-feature android:glEsVersion="*0x00020000*"android:required="*true*"/>

这将使我们的游戏只能在支持 OpenGL ES 2.0 的设备上显示,因此被认为具有相当强大的图形处理器。

注意一些设备错误地报告了这个特性,这将使你的应用对其他完美的设备不可见。慎用。

假设您希望为您的游戏提供可选的 USB 外设支持,以便设备可以成为 USB 主机,并连接控制器或其他外设。正确的处理方式是添加以下内容:

<uses-feature android:name="*android*.*hardware*.*usb*.*host*" android:required="*false*"/>

将“android:required”设置为 false 会对 Google Play 说,“我们可能会使用这个功能,但没有必要下载并运行游戏。”设置可选硬件功能的使用是一种很好的方法,可以让你的游戏在各种你还没有遇到过的硬件上经得起时间考验。它允许制造商将应用限制在那些声明支持其特定硬件的应用中,如果你声明支持它,你将被包括在可以为该设备下载的应用中。

现在,你在硬件方面的每一个具体要求都有可能减少可以安装游戏的设备数量,这将直接影响你的销售。在指定以上任何一项之前,请三思。例如,如果你的游戏的标准模式需要多点触摸,但你也可以想办法让它在单点触摸设备上工作,你应该努力有两个代码路径——每个硬件配置文件一个——以便你的游戏可以部署到更大的市场。

元素

我们将放入清单文件的最后一个元素是元素。它是元素的子元素。当我们在第二章中创建 Hello World 项目时,我们定义了这个元素,并确保我们的 Hello World 应用从 Android 1.5 开始通过一些手动修改就可以工作。那么这个元素是做什么的呢?这里有一个例子:

<uses-sdk android:minSdkVersion="*3*" android:targetSdkVersion="*16*"/>

正如我们在第二章中讨论的,每个 Android 版本都有一个整数,也称为 SDK 版本。< uses-sdk >元素指定了我们的应用支持的最低版本和我们的应用的目标版本。在这个例子中,我们定义我们的最低版本为 Android 1.5,目标版本为 Android 4.1。该元素允许我们将使用仅在较新版本中可用的 API 的应用部署到安装了较低版本的设备上。一个突出的例子是多点触摸 API,它从 SDK 版本 5 (Android 2.0)开始就受到支持。当我们在 Eclipse 中建立我们的 Android 项目时,我们使用一个支持该 API 的构建目标;比如 SDK 第 5 版或更高版本(我们通常设置为最新的 SDK 版本,编写时为 16)。如果我们希望我们的游戏也能在安装了 SDK version 3 (Android 1.5)的设备上运行,我们像以前一样在 manifest 文件中指定 minSdkVersion。当然,我们必须注意不要使用任何在较低版本中不可用的 API,至少在 1.5 设备上是这样。在更高版本的设备上,我们也可以使用更新的 API。

对于大多数游戏来说,前面的配置通常是合适的(除非您不能为更高版本的 API 提供单独的回退代码路径,在这种情况下,您会希望将 minSdkVersion 属性设置为您实际支持的最低 SDK 版本)。

八个简单步骤中的 Android 游戏项目设置

现在让我们结合前面的所有信息,开发一个简单的逐步方法,在 Eclipse 中创建新的 Android 游戏项目。以下是我们希望从我们的项目中得到的:

  • 它应该能够使用最新 SDK 版本的功能,同时保持与一些设备仍在运行的最低 SDK 版本的兼容性。那意味着我们要支持 Android 1.5 及以上版本。
  • 如果可能的话,应该将它安装到 SD 卡上,这样我们就不会填满设备的内部存储空间。
  • 它应该有一个单独的主活动,自己处理所有的配置更改,这样当硬件键盘暴露或者设备的方向改变时,它就不会被破坏。
  • 活动应固定为纵向或横向模式。
  • 它应该允许我们访问 SD 卡。
  • 它应该能让我们得到一个唤醒锁。

利用你刚刚获得的信息,这些是一些容易实现的目标。以下是步骤:

  1. 通过打开 new Android project 向导,在 Eclipse 中创建新的 Android 项目,如第二章中所述。
  2. 创建项目后,打开 AndroidManifest.xml 文件。
  3. 要让 Android 在 SD 卡上安装游戏(如果有的话),需要将 installLocation 属性添加到元素中,并将其设置为 preferExternal。
  4. 要固定活动的方向,将 screenOrientation 属性添加到元素,并指定您想要的方向(纵向或横向)。
  5. 要告诉 Android 我们想要处理键盘、keyboardHidden 和 orientation 配置更改,请将元素的 configChanges 属性设置为 keyboard | keyboard hidden | orientation。
  6. 元素中添加两个元素,并指定名称属性 Android . permission . write _ external stage 和 android.permission.WAKE_LOCK。
  7. 设置元素的 minSdkVersion 和 targetSdkVersion 属性(例如,minSdkVersion 设置为 3,targetSdkVersion 设置为 16)。
  8. 在 res/文件夹中创建一个名为 drawable/的文件夹,将 RES/drawable-mdpi/IC _ launcher . png 文件复制到这个新文件夹中。这是 Android 1.5 将搜索启动器图标的位置。如果不想支持 Android 1.5,可以跳过这一步。

这就是了。八个简单的步骤将生成一个完全定义的应用,该应用将安装到 SD 卡上(在 Android 2.2 及更高版本上),具有固定的方向,不会在配置更改时爆炸,允许您访问 SD 卡和唤醒锁,并将在从 1.5 到最新版本的所有 Android 版本上工作。以下是执行上述步骤后的最终 AndroidManifest.xml 内容:

<?xml version="*1*.*0*" encoding="*utf*-*8*"?>
<manifest xmlns:android="*[`schemas.android.com/apk/res/android`](http://schemas.android.com/apk/res/android)*"
      package="*com*.*badlogic*.*awesomegame*"
      android:versionCode="*1*"
      android:versionName="*1*.*0*"
      android:installLocation="*preferExternal*">
    <application android:icon="@*drawable*/*icon*"
                 android:label="*Awesomnium*"
                 android:debuggable="*true*">
        <activity android:name=".*GameActivity*"
                  android:label="*Awesomnium*"
                  android:screenOrientation="*landscape*"
                  android:configChanges="*keyboard*|*keyboardHidden*|*orientation*">
            <intent-filter>
                <action android:name="*android*.*intent*.*action*.*MAIN*" />
                <category android:name="*android*.*intent*.*category*.*LAUNCHER*" />
            </intent-filter>
        </activity>
    </application>
    <uses-permission android:name="*android*.*permission*.*WRITE*_*EXTERNAL*_*STORAGE*"/>
    <uses-permission android:name="*android*.*permission*.*WAKE*_*LOCK*"/>
    <uses-sdk android:minSdkVersion="*3*" android:targetSdkVersion="*16*"/>
</manifest>

如您所见,我们去掉了元素的标签属性中的@string/app_name。这不是真正必要的,但是最好将应用定义放在一个地方。从现在开始,一切都是为了代码!或者是?

Google Play 过滤器

有这么多不同的 Android 设备,有这么多不同的功能,硬件制造商有必要只允许兼容的应用下载并在他们的设备上运行;否则,用户会有尝试运行与设备不兼容的应用的糟糕体验。为了解决这个问题,Google Play 从特定设备的可用应用列表中过滤掉不兼容的应用。例如,如果你有一个没有摄像头的设备,而你搜索一个需要摄像头的游戏,它就不会出现。不管是好是坏,对你这个用户来说,就好像这个应用不存在一样。

我们之前讨论的许多清单元素都被用作过滤器,包括。以下是您应该记住的另外三个特定于过滤的元素:

  • 这允许你声明游戏可以运行的屏幕尺寸和密度。理想情况下,你的游戏可以在所有屏幕上运行,我们将向你展示如何确保这一点。但是,在清单文件中,您可能希望明确声明支持每种屏幕尺寸。
  • :这允许你在设备上声明对输入配置类型的显式支持,比如硬键盘、QWERTY 专用键盘、触摸屏或者轨迹球导航输入。理想情况下,您将支持以上所有内容,但如果您的游戏需要非常具体的输入,您将需要研究并在 Google Play 上使用此标签进行过滤。
  • 这允许声明你的游戏所依赖的第三方库必须存在于设备上。例如,你可能需要一个非常大的文本到语音转换库,但是对于你的游戏来说非常普通。用这个标签声明这个库可以确保只有安装了这个库的设备才能看到和下载你的游戏。这样做的一个常见用途是允许基于 GPS/地图的游戏只能在安装了谷歌地图库的设备上运行。

随着 Android 的发展,可能会有更多的过滤器标签可用,所以请确保在部署之前查看 http://developer.android.com/guide/google/play/filters.html 的官方 Google Play 过滤器页面,以获得最新信息。

定义你游戏的图标

当你把你的游戏部署到一个设备上,打开应用启动器,你会看到它的入口有一个漂亮的,但不是真正唯一的,Android 图标。你的游戏在 Google Play 上会显示同样的图标。如何将它更改为自定义图标?

仔细看看元素。在那里,我们定义了一个名为 icon 的属性。它引用了 res/drawable-xxx 目录中一个名为 icon 的图像。所以,应该很明显要做什么:用你自己的图标图像替换 drawable 文件夹中的图标图像。

按照创建 Android 项目的八个简单步骤,你会在 res/文件夹中看到类似于图 4-1 的东西。

9781430246770_Fig04-01.jpg

图 4-1。我的 res/ folder 怎么了?

我们在第一章中看到设备有不同的尺寸,但我们没有谈到 Android 如何处理这些不同的尺寸。事实证明,Android 有一个复杂的机制,允许你为一组屏幕密度定义图形素材。屏幕密度是物理屏幕尺寸和屏幕像素数量的组合。我们将在第五章中更详细地探讨这个话题。现在,知道 Android 定义了四种密度就足够了:低密度屏幕的 ldpi、标准密度屏幕的 mdpi、高密度屏幕的 hdpi 和超高密度屏幕的 xhdpi。对于低密度的屏幕,我们通常使用较小的图像;对于更高密度的屏幕,我们使用高分辨率的素材。

因此,对于我们的图标,我们需要提供四个版本:每个密度一个。但是每个版本应该有多大呢?幸运的是,我们在 res/drawable 文件夹中已经有了默认图标,可以用来重新设计我们自己图标的大小。res/drawable-ldpi 中的图标分辨率为 36×36 像素,res/drawable-mdpi 中的图标分辨率为 48×48 像素,res/drawable-hdpi 中的图标分辨率为 72×72 像素,res/drawable-xhdpi 中的图标分辨率为 96×96 像素。我们所要做的就是用相同的分辨率创建自定义图标的版本,并用我们自己的 icon.png 文件替换每个文件夹中的 icon.png 文件。我们可以保持清单文件不变,只要我们把我们的图标图像文件称为 icon.png。请注意,清单文件中的文件引用区分大小写。为了安全起见,在资源文件中总是使用小写字母。

为了真正兼容 Android 1.5,我们需要添加一个名为 res/drawable/的文件夹,并将 res/drawable-mdpi/文件夹中的图标图像放在那里。Android 1.5 不知道其他可绘制的文件夹,所以它可能找不到我们的图标。

最后,我们准备完成一些 Android 编码。

对于来自 iOS/Xcode 的用户

Android 的环境与苹果的环境有很大不同。在苹果控制非常严格的地方,Android 依赖于来自不同来源的许多不同模块,这些模块定义许多 API,控制格式,并规定哪些工具最适合特定任务,例如构建应用。

Eclipse/ADT 与。x mode(x mode)-x mode(x mode)-x mode(x mode)(x mode)(x mode)(x mode)(x mode)(x mode)(x mode)(x mode)

Eclipse 是一个多项目、多文档的界面。您可以在一个工作区中拥有许多 Android 应用,它们都列在您的 Package Explorer 视图下。您还可以在源代码视图中从这些项目中打开多个文件。就像 Xcode 中的前进/后退一样,Eclipse 有一些工具栏按钮来帮助导航,甚至还有一个名为 Last Edit Location 的导航选项,可以将您带回上次所做的更改。

Eclipse 为 Java 提供了许多 Xcode 为 Objective-C 所没有的语言特性,而在 Xcode 中你必须点击“跳转到定义”,在 Eclipse 中你只需按 F3 或点击 Open Declaration。另一个最喜欢的是参考搜索功能。想知道什么调用特定的方法吗?只需点击选择它,然后按 Ctrl+Shift+G 或选择搜索image引用image工作空间。所有的重命名或移动操作都被归类为“重构”操作,所以在您因为看不到任何重命名类或文件的方法而沮丧之前,请看一下重构选项。因为 Java 没有单独的头文件和实现文件,所以没有“跳转到头文件/实现”的快捷方式。如果您启用了项目image自动构建,Java 文件的编译是自动的。启用该设置后,每次进行更改时,您的项目都会被增量编译。要自动完成,只需按 Ctrl+Space。

作为一名新的 Android 开发人员,您首先会注意到的一件事是,要在设备上部署,除了启用设置之外,您不必做太多其他事情。Android 上的任何可执行代码仍然需要用私钥签名,就像在 iOS 中一样,但密钥不需要由像苹果这样的可信机构颁发,所以 IDE 实际上是在您在设备上运行测试代码时为您创建了一个“调试”密钥。这个密钥将不同于您的生产密钥,但是不必为了进行应用测试而弄乱任何东西是非常有用的。密钥位于名为的子目录下的用户主目录中。android/debug.keystore。

像 Xcode 一样,Eclipse 支持 Subversion (SVN),尽管您需要安装一个插件。最常见的插件叫做 Subclipse,可以在subclipse.tigris.org获得。所有的 SVN 功能都可以在团队上下文菜单选项下获得,或者通过选择窗口image显示视图image其他image SVN 来打开视图。首先检查那里,以访问您的存储库,并开始签出或共享项目。

Eclipse 中的大多数东西都是上下文相关的,所以您需要右键单击(或者双击/Ctrl-click)项目、文件、类、方法以及其他任何东西的名称,看看有哪些选项。例如,第一次运行一个项目最好的方法就是右键单击项目名称,然后选择 Run As image Android Application。

定位和配置目标

Xcode 可以有一个包含多个目标的项目,如 My Game Free 和 My Game Full,它们有不同的编译时选项,可以基于这些选项生成不同的应用。Android 在 Eclipse 中没有这种东西,因为 Eclipse 是以非常扁*化的方式面向项目的。要在 Android 中做同样的事情,你需要有两个不同的项目,它们共享所有的代码,除了那个项目的一段特殊的配置代码。共享代码非常容易,使用 Eclipse 简单的“链接源代码”特性就可以做到。

如果你习惯了 Xcode 列表和页面配置,你会很高兴听到你在 Android 中可能需要的几乎所有东西都位于以下两个位置之一:AndroidManifest.xml(本章介绍)和项目的属性窗口。Android manifest 文件涵盖了非常特定于应用的内容,就像 Xcode 目标的摘要和信息一样,项目的属性窗口涵盖了 Java 语言的特性(例如链接了哪些库,类位于何处,等等。).右键单击该项目并选择 Properties,会显示许多类别供您配置。Android 和 Java 构建路径类别处理库和源代码依赖性,很像 Xcode 中的许多构建设置、构建阶段和构建规则标签选项。事情肯定会有所不同,但是了解到哪里可以节省大量的时间。

其他有用的花絮

当然 XCode 和 Eclipse 之间有更多的区别。下面的列表告诉你那些我们认为最有用的。

  • Eclipse 显示了实际的文件系统结构,但是缓存了关于它的许多东西,所以请充分利用 F5/refresh 特性来获得项目文件的最新情况。
  • 文件位置确实很重要,而且没有相当于组的位置虚拟化。这就好像所有文件夹都是文件夹引用,不包括文件的唯一方法是设置排除过滤器。
  • 设置是基于每个工作空间的,因此您可以有多个工作空间,每个工作空间都有不同的设置。当你既有个人项目又有专业项目,并且想把它们分开时,这是非常有用的。
  • Eclipse 有多个透视图,当前透视图由 Eclipse 窗口右上角的活动图标标识,默认情况下是 Java。正如在第二章中所讨论的,透视图是一组预配置的视图和一些相关的上下文设置。如果事情在任何一点上看起来变得奇怪,检查以确保你处于正确的角度。
  • 本书涵盖了部署,但它不像在 Xcode 中那样改变方案或目标。这是一个完全独立的操作,您可以通过项目的右键上下文菜单来完成(Android Tools image导出签名的应用包)。
  • 如果代码编辑似乎没有生效,很可能是您的自动构建设置被关闭了。您通常希望为期望的行为启用它(项目image自动构建)。
  • XIB 没有直接的对等物。最接*的是 Android 布局,但 Android 不像 XIB 那样做插座,所以只要假设你会一直使用 id 惯例。大部分游戏不需要在意多种布局,但是记住就好。
  • Eclipse 在项目目录中主要使用基于 XML 的配置文件来存储项目设置。检查“点”文件,如。如果需要手动进行更改或构建自动化系统,请使用。这个加上 AndroidManifest.xml 非常类似于 Xcode 中的 project.pbxproj 文件。

Android API 基础

在这一章的剩余部分,我们将集中精力使用那些与我们游戏开发需求相关的 Android API。为此,我们将做一些相当方便的事情:我们将建立一个测试项目,该项目将包含我们将要使用的不同 API 的所有小测试示例。我们开始吧。

创建测试项目

从上一节中,我们已经知道了如何设置我们所有的项目。因此,我们要做的第一件事是执行前面列出的八个步骤。创建一个名为 ch04–Android-basics 的项目,使用名为 com.badlogic.androidgames 的包以及一个名为 AndroidBasicsStarter 的主活动。我们将使用一些旧的和一些新的 API,因此我们将最低 SDK 版本设置为 3 (Android 1.5),将构建 SDK 版本设置为 16 (Android 4.1)。您可以为其他设置填入您喜欢的任何值,例如应用的标题。从现在开始,我们要做的就是创建新的活动实现,每个实现展示 Android API 的一部分。

但是,请记住,我们只有一个主要活动。那么,我们的主要活动是什么样的呢?我们希望有一种方便的方式来添加新的活动,我们希望能够轻松地开始一个特定的活动。对于一个主要的活动,应该清楚的是,这个活动将会以某种方式为我们提供一个方法来开始一个特定的测试活动。如前所述,main 活动将被指定为清单文件中的主入口点。我们添加的每一个额外的活动都将在没有子元素的情况下被指定。我们将从主活动中以编程方式启动它们。

AndroidBasicsStarter 活动

Android API 为我们提供了一个名为 ListActivity 的特殊类,它来自我们在 Hello World 项目中使用的 Activity 类。ListActivity 类是一种特殊类型的活动,它的唯一目的是显示一个事物列表(例如,字符串)。我们使用它来显示我们的测试活动的名称。当我们触摸其中一个列表项时,我们将以编程方式启动相应的活动。清单 4-1 显示了我们的 AndroidBasicsStarter 主活动的代码。

清单 4-1。AndroidBasicsStarter.java,我们的主要活动负责列出并开始我们所有的测试

package com.badlogic.androidgames;

import android.app.ListActivity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;

public class AndroidBasicsStarter extends ListActivity {
    String tests[] = { "LifeCycleTest", "SingleTouchTest", "MultiTouchTest",
            "KeyTest", "AccelerometerTest", "AssetsTest",
            "ExternalStorageTest", "SoundPoolTest", "MediaPlayerTest",
            "FullScreenTest", "RenderViewTest", "ShapeTest", "BitmapTest",
            "FontTest", "SurfaceViewTest" };

    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        setListAdapter(new ArrayAdapter<String>(this ,
                android.R.layout.*simple*_*list*_*item*_*1*, tests));
    }

    @Override
    protected void onListItemClick(ListView list, View view, int position,
            long id) {
        super .onListItemClick(list, view, position, id);
        String testName = tests[position];
        try {
            Class clazz = Class
                    .*forName*("com.badlogic.androidgames." + testName);
            Intent intent = new Intent(this , clazz);
            startActivity(intent);
        }catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

我们选择的包名是 com.badlogic.androidgames。这些就是我们将在代码中使用的所有类。我们的 AndroidBasicsStarter 类派生自 ListActivity 类——仍然没有什么特别的。field tests 是一个字符串数组,它保存了我们的 starter 应用应该显示的所有测试活动的名称。请注意,数组中的名称正是我们稍后要实现的活动类的 Java 类名。

下一段代码应该是熟悉的;我们必须为我们的每个活动实现 onCreate()方法,,该方法将在创建活动时被调用。记住,我们必须调用活动基类的 onCreate()方法。这是我们在自己的活动实现的 onCreate()方法中必须做的第一件事。如果我们不这样做,将会抛出一个异常,并且不会显示该活动。

这样一来,接下来我们要做的是调用一个名为 setListAdapter()的方法。这个方法是由派生它的 ListActivity 类提供给我们的。它让我们指定希望 ListActivity 类为我们显示的列表项。这些需要以实现 ListAdapter 接口的类实例的形式传递给该方法。我们使用方便的 ArrayAdapter 类来做到这一点。这个类的构造函数有三个参数:第一个是我们的活动,第二个我们将在下一段解释,第三个是 ListActivity 应该显示的项目数组。我们很乐意为第三个参数指定我们之前定义的测试数组,这就是我们需要做的全部工作。

那么, ArrayAdapter 构造函数的第二个参数是什么?为了解释这一点,我们不得不经历所有的 Android UI API 的东西,我们不打算在本书中使用。因此,我们不会在我们不需要的东西上浪费页面,而是给你一个简单明了的解释:列表中的每一项都通过视图显示。该参数定义了每个视图的布局以及每个视图的类型。安卓的价值。R.layout.simple_list_item_1 是 UI API 提供的预定义常量,用于快速启动和运行。它代表将显示文本的标准列表项视图。作为快速复习,视图是 Android 上的 UI 小部件,比如按钮、文本字段或滑块。在第二章中,我们在剖析 HelloWorldActivity 时引入了按钮实例形式的视图。

如果我们用 onCreate()方法开始我们的活动,我们会看到类似于图 4-2 所示的屏幕。

9781430246770_Fig04-02.jpg

图 4-2。我们的测试启动活动,看起来很花哨,但还没做多少

现在让我们在触摸列表项时发生一些事情。我们希望开始我们接触的列表项所代表的相应活动。

以编程方式启动活动

ListActivity 类有一个名为 onListItemClick()的受保护方法,当点击一个项目时将调用该方法。我们所需要做的就是在我们的 AndroidBasicsStarter 类中覆盖该方法。这正是我们在清单 4-1 中所做的。

这个方法的参数是 ListActivity 用来显示项目的 ListView、被触摸的视图(包含在这个 ListView 中)、被触摸的项目在列表中的位置,以及一个 ID,我们并不太感兴趣。我们真正关心的是立场论点。

onListItemClicked()方法从成为好公民开始,首先调用基类方法。如果我们覆盖一个活动的方法,这总是一件好事。接下来,我们根据 position 参数从 tests 数组中获取类名。这是拼图的第一部分。

前面,我们讨论了我们可以通过一个意图以编程方式启动我们在清单文件中定义的活动。Intent 类有一个很好的简单的构造函数来做这件事,它有两个参数:一个上下文实例和一个类实例。后者表示我们想要启动的活动的 Java 类。

上下文是为我们提供应用全局信息的接口。它是由 Activity 类实现的,所以我们只需将这个引用传递给 Intent 构造函数。

为了获得表示我们想要启动的活动的类实例,我们使用了一点反射,如果您使用过 Java,这可能对您来说很熟悉。反射允许我们在运行时以编程方式检查、实例化和调用类。静态方法 Class.forName()接受一个字符串,该字符串包含我们要为其创建类实例的类的完全限定名。我们稍后将实现的所有测试活动都将包含在 com.badlogic.androidgames 包中。将包名与我们从 tests 数组中获取的类名连接起来,将得到我们想要启动的 activity 类的完全限定名。我们将该名称传递给 Class.forName(),并获得一个可以传递给 Intent 构造函数的不错的类实例。

一旦构建了 Intent 实例,我们就可以通过调用 startActivity()方法来启动它。这个方法也在上下文接口中定义。因为我们的活动实现了那个接口,所以我们只调用它的那个方法的实现。就是这样!

那么我们的应用将如何表现呢?首先,将显示启动器活动。每次我们触摸列表上的一个项目,相应的活动就会启动。启动活动将暂停,并进入后台。新活动将由我们发出的意向创建,并将替换屏幕上的起始活动。当我们按下 Android 设备上的 back 按钮时,活动被破坏,starter 活动恢复,收回屏幕。

创建测试活动

当我们创建一个新的测试活动时,我们必须执行以下步骤:

  1. 在 com.badlogic.androidgames 包中创建相应的 Java 类,并实现其逻辑。
  2. 在清单文件中为 activity 添加一个条目,使用它需要的任何属性(即 android:configChanges 或 android:screenOrientation)。注意,我们不会指定一个元素,因为我们将以编程方式启动活动。
  3. 将活动的类名添加到 AndroidBasicsStarter 类的 tests 数组中。

只要我们坚持这个过程,其他一切都将由我们在 AndroidBasicsStarter 类中实现的逻辑来处理。新的活动会自动出现在列表中,只需轻轻一触就能启动。

您可能想知道的一件事是,在 touch 上开始的测试活动是否在它自己的进程和 VM 中运行。不是的。由活动组成的应用有一个叫做活动栈的东西。每次我们开始一个新的活动,它就会被推到堆栈上。当我们关闭新的活动时,最后一个推入堆栈的活动将被弹出并恢复,成为屏幕上新的活动活动。

这也有一些其他的含义。首先,应用的所有活动(堆栈上暂停的活动和活动的活动)共享同一个 VM。它们还共享同一个内存堆。这可能是福也可能是祸。如果您的活动中有静态字段,它们一启动就会在堆上获得内存。作为静态字段,它们将在活动的销毁和活动实例的后续垃圾收集中幸存。如果您不小心使用静态字段,这可能会导致一些严重的内存泄漏。在使用静态字段之前要三思。

正如已经说过几次的,我们在实际的游戏中只会有一个活动。前面的活动启动器是这个规则的一个例外,让我们的生活变得更轻松。但是不用担心;即使是一项活动,我们也有很多机会陷入困境。

注意这是我们对 Android UI 编程的最深理解。从现在开始,我们将总是在活动中使用单个视图来输出内容和接收输入。如果你想了解布局、视图组和 Android UI 库提供的所有功能,我们建议你看看格兰特·艾伦的书,《??》开始 Android 4(2011 年出版),或者 Android 开发者网站上的优秀开发者指南。

活动生命周期

在为 Android 编程时,我们首先要弄清楚的是一个活动是如何表现的。在 Android 上,这被称为活动生命周期。它描述了活动所处的状态以及这些状态之间的转换。我们先来讨论一下这背后的理论。

理论上

活动可以处于以下三种状态之一:

  • 运行:在这种状态下,占据屏幕并直接与用户交互的是顶层活动。
  • 暂停:当活动在屏幕上仍然可见,但被透明活动或对话框部分遮挡,或者设备屏幕被锁定时,会出现这种情况。Android 系统可以在任何时间点终止暂停的活动(例如,由于内存不足)。请注意,活动实例本身仍然活跃在 VM 堆中,并等待返回到运行状态。
  • Stopped :当一个活动被另一个活动完全遮挡,从而在屏幕上不再可见时,就会出现这种情况。例如,如果我们开始一个测试活动,我们的 AndroidBasicsStarter 活动将处于这种状态。当用户按下主屏幕按钮暂时转到主屏幕时,也会发生这种情况。如果内存不足,系统可以再次决定完全终止该活动并将其从内存中删除。

在暂停和停止状态下,Android 系统可以决定在任何时间点终止活动。它可以礼貌地这样做,首先通过调用它的 finished()方法通知活动,也可以不礼貌地这样做,悄悄终止活动的进程。

活动可以从暂停或停止状态返回到运行状态。再次注意,当活动从暂停或停止状态恢复时,它仍然是内存中的同一个 Java 实例,因此所有状态和成员变量都与活动暂停或停止前相同。

一个活动有一些受保护的方法,我们可以覆盖这些方法来获得关于状态变化的信息:

  • Activity.onCreate():当我们的活动第一次启动时调用这个函数。在这里,我们设置了所有的 UI 组件并连接到输入系统。这个方法在我们活动的生命周期中只被调用一次。
  • Activity.onRestart():当活动从停止状态恢复时调用这个函数。它前面是对 onStop()的调用。
  • Activity.onStart():在 onCreate()之后或者当活动从停止状态恢复时调用这个函数。在后一种情况下,它前面是对 onRestart()的调用。
  • Activity.onResume():在 onStart()之后或者当活动从暂停状态恢复时(例如,当屏幕解锁时)调用这个函数。
  • Activity.onPause():当活动进入暂停状态时调用该函数。这可能是我们收到的最后一个通知,因为 Android 系统可能会决定悄悄地杀死我们的应用。我们要用这种方法保存所有我们想坚持的状态!
  • Activity.onStop():当活动进入停止状态时调用该函数。它前面有一个对 onPause()的调用。这意味着活动在暂停之前就已停止。和 onPause()一样,这可能是我们在 Android 系统静默终止活动之前收到的最后一个通知。我们也可以在这里保存持久状态。然而,系统可能决定不调用这个方法,而只是终止活动。由于 onPause()总是在 onStop()之前和活动被静默终止之前被调用,我们宁愿将所有内容保存在 onPause()方法中。
  • Activity.onDestroy():当活动被不可恢复地销毁时,在活动生命周期结束时调用这个函数。这是我们最后一次保存任何信息,以便在下次重新创建活动时恢复。请注意,如果活动在系统调用 onPause()或 onStop()后被静默销毁,则实际上可能永远不会调用此方法。

图 4-3 说明了活动生命周期和方法调用顺序。

9781430246770_Fig04-03.jpg

图 4-3。浩浩荡荡、令人困惑的活动生命周期

以下是我们应该从中吸取的三大教训:

  1. 在我们的活动进入运行状态之前,无论我们是从停止状态还是暂停状态恢复,onResume()方法总是被调用。因此,我们可以放心地忽略 onRestart()和 onStart()方法。我们不关心是从停止状态还是暂停状态恢复。对于我们的游戏,我们只需要知道我们现在实际上正在运行,onResume()方法向我们发出信号。
  2. 在 onPause()之后,可以静默地销毁该活动。我们永远不应该假设 onStop()或 onDestroy()被调用。我们还知道 onPause()总是在 onStop()之前被调用。因此,我们可以安全地忽略 onStop()和 onDestroy()方法,只重写 onPause()。在这种方法中,我们必须确保我们想要保持的所有状态,如高分和等级进步,都被写入外部存储,如 SD 卡。在 onPause()之后,所有的赌注都取消了,我们不知道我们的活动是否还有机会再次运行。
  3. 我们知道,如果系统在 onPause()或 onStop()之后决定终止活动,则可能永远不会调用 onDestroy()。然而,有时我们想知道活动是否真的会被扼杀。那么,如果 onDestroy()不会被调用,我们该怎么做呢?Activity 类有一个名为 Activity.isFinishing()的方法,我们可以随时调用它来检查我们的活动是否会被终止。我们至少可以保证 onPause()方法在 activity 被终止之前被调用。我们所需要做的就是在 onPause()方法中调用这个 isFinishing()方法,以决定在 onPause()调用之后活动是否会终止。

这让生活变得简单多了。我们只覆盖 onCreate()、onResume()和 onPause()方法。

  • 在 onCreate()中,我们设置我们的窗口和 UI 组件,向其呈现内容,并从其接收输入。
  • 在 onResume()中,我们(重新)开始我们的主循环线程(在第三章的中讨论)。
  • 在 onPause()中,我们简单地暂停我们的主循环线程,如果 Activity.isFinishing()返回 true,我们还会将我们希望保持的任何状态保存到磁盘中。

许多人纠结于活动的生命周期,但是如果我们遵循这些简单的规则,我们的游戏将能够处理暂停、恢复和清理。

在实践中

让我们编写演示活动生命周期的第一个测试示例。我们希望有某种输出来显示到目前为止发生了哪些状态变化。我们将通过两种方式做到这一点:

  1. 活动将显示的唯一 UI 组件是一个 TextView。顾名思义,它显示文本,我们已经在 starter 活动中隐式地使用它来显示每个条目。每当我们进入一个新的状态时,我们将向 TextView 追加一个字符串,它将显示到目前为止发生的所有状态变化。
  2. 我们将无法在 TextView 中显示活动的销毁事件,因为它会很快从屏幕上消失,所以我们还会将所有状态更改输出到 LogCat。我们用 Log 类来实现这一点,它提供了两个静态方法来将消息添加到 LogCat 中。

记住我们需要做什么来添加一个测试活动到我们的测试应用中。首先,我们在清单文件中以元素的形式定义它,它是元素的子元素:

<activity android:label="*Life Cycle Test*"
          android:name=".*LifeCycleTest*"
          android:configChanges="*keyboard*|*keyboardHidden*|*orientation*" />

接下来,我们将名为 LifeCycleTest 的新 Java 类添加到我们的 com.badlogic.androidgames 包中。最后,我们将类名添加到前面定义的 androidbasicstarter 类的 tests 成员中。(当然,当我们出于演示的目的编写这个类时,我们就已经有了。)

对于我们在接下来的部分中创建的任何测试活动,我们将不得不重复所有这些步骤。为简洁起见,我们不再提及这些步骤。还要注意,我们没有为 LifeCycleTest 活动指定方向。在本例中,我们可以处于横向模式或纵向模式,具体取决于设备方向。我们这样做是为了让您可以看到方向更改对生命周期的影响(由于我们如何设置 configChanges 属性,所以没有影响)。清单 4-2 显示了整个活动的代码。

清单 4-2。【LifeCycleTest.java】,展示活动生命周期

package com.badlogic.androidgames;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.widget.TextView;

public class LifeCycleTest extends Activity {
   StringBuilder builder = new StringBuilder();
   TextView textView;

   private void log(String text) {
       Log.*d*("LifeCycleTest", text);
       builder.append(text);
       builder.append('\n');
       textView.setText(builder.toString());
   }

   @Override
   public void onCreate(Bundle savedInstanceState) {
      super .onCreate(savedInstanceState);
      textView = new TextView(this );
      textView.setText(builder.toString());
        setContentView(textView);
        log("created");
   }

   @Override
   protected void onResume() {
      super .onResume();
      log("resumed");
   }

   @Override
   protected void onPause() {
      super .onPause();
      log("paused");

      if (isFinishing()) {
            log("finishing");
      }
   }
}

让我们快速浏览一下这段代码。这个类来源于 Activity——这并不奇怪。我们定义了两个成员:一个是 StringBuilder,它将保存我们到目前为止生成的所有消息,另一个是 TextView,我们用它直接在活动中显示这些消息。

接下来,我们定义一个小的私有 helper 方法,它将把文本记录到 LogCat,把它附加到我们的 StringBuilder,并更新 TextView 文本。对于 LogCat 输出,我们使用静态 Log.d()方法,该方法将一个标记作为第一个参数,将实际消息作为第二个参数。

在 onCreate()方法中,我们像往常一样首先调用超类方法。我们创建 TextView 并将其设置为活动的内容视图。它将填满活动的整个空间。最后,我们将创建的消息记录到 LogCat 中,并使用之前定义的 helper 方法 log()更新 TextView 文本。

接下来,我们覆盖活动的 onResume()方法。与我们覆盖的任何活动方法一样,我们首先调用超类方法。我们所做的就是再次调用 log()并将 resumed 作为参数。

被覆盖的 onPause()方法看起来很像 onResume()方法。我们首先将消息记录为“暂停”。我们还想知道在 onPause()方法调用之后活动是否会被销毁,所以我们检查 Activity.isFinishing()方法。如果它返回 true,我们也记录完成事件。当然,我们将看不到更新的 TextView 文本,因为在更改显示在屏幕上之前,活动将被销毁。因此,如前所述,我们也将所有内容输出到 LogCat。

运行应用,并稍微试验一下这个测试活动。下面是您可以执行的一系列操作:

  1. 从启动活动启动测试活动。
  2. 锁屏。
  3. 解锁屏幕。
  4. 按下主屏幕按钮(这将带你回到主屏幕)。
  5. 在主屏幕上,在旧的 Android 版本(版本 3 之前)上,按住 home 键,直到出现当前正在运行的应用。在 Android 版本 3+上,触摸运行应用按钮。选择 Android 基础入门应用以继续(这将使测试活动回到屏幕上)。
  6. 按“后退”按钮(这将带您返回到开始活动)。

如果你的系统在暂停的任何时候都没有决定静默终止活动,你会在图 4-4 中看到输出(当然,前提是你还没有按下返回按钮)。

9781430246770_Fig04-04.jpg

图 4-4。运行生命周期测试活动

启动时,调用 onCreate(),然后调用 onResume()。当我们锁定屏幕时,调用 onPause()。当我们解锁屏幕时,调用 onResume()。当我们按下 home 键时,onPause()被调用。回到活动将再次调用 onResume()。当然,相同的消息显示在 LogCat 中,您可以在 Eclipse 的 LogCat 视图中观察到。图 4-5 显示了我们在执行前面的动作序列(加上按下后退按钮)时写入 LogCat 的内容。

9781430246770_Fig04-05.jpg

图 4-5。生命周期测试的 LogCat 输出

再次按 back 按钮调用 onPause()方法。由于它也破坏了活动,onPause()中的 if 语句也被触发,通知我们这是最后一次看到该活动。

这就是活动生命周期,为了我们的游戏编程需要而被去神秘化和简化。我们现在可以轻松地处理任何暂停和恢复事件,并保证在活动被销毁时得到通知。

输入设备处理

正如前面章节所讨论的,我们可以从 Android 上的许多不同的输入设备中获取信息。在这一部分,我们将讨论 Android 上三个最相关的输入设备以及如何使用它们:触摸屏、键盘、加速度计和指南针。

获取(多点)触摸事件

触摸屏可能是获取用户输入的最重要的方式。在 Android 版本之前,API 只支持处理单指触摸事件。多点触控是在 Android 2.0 (SDK 版本 5)中引入的。多点触摸事件报告被标记在单触式 API 上,在可用性方面有一些混合的结果。我们将首先研究处理单点触摸事件,这在所有 Android 版本上都可用。

处理单点触摸事件

当我们在第二章中处理点击按钮时,我们看到监听器接口是 Android 向我们报告事件的方式。触摸事件也不例外。触摸事件被传递给一个 OnTouchListener 接口实现,我们用一个视图注册它。OnTouchListener 接口只有一个方法:

public abstract boolean onTouch (View v, MotionEvent event)

第一个参数是触摸事件被调度到的视图。第二个参数是我们将分析以获得触摸事件的内容。

OnTouchListener 可以通过 View.setOnTouchListener()方法注册到任何视图实现中。在将 MotionEvent 分派给视图本身之前,将调用 OnTouchListener。在 onTouch()方法的实现中,我们可以通过从该方法返回 true 来通知视图我们已经处理了该事件。如果我们返回 false,视图本身将处理该事件。

MotionEvent 实例有三个与我们相关的方法:

  • MotionEvent.getX()和 MotionEvent.getY():这些方法报告触摸事件相对于视图的 x 和 y 坐标。坐标系定义为原点在视图的左上方,x 轴指向右侧,y 轴指向下方。坐标以像素为单位。请注意,这些方法返回浮点数,因此坐标具有子像素精度。
  • MotionEvent.getAction():该方法返回触摸事件的类型。它是一个整数,取值为MotionEvent.ACTION_DOWNMotionEvent.ACTION_MOVEMotionEvent.ACTION_CANCELMotionEvent.ACTION_UP中的一个。

听起来很简单,事实也确实如此。运动事件。手指触摸屏幕时发生 ACTION_DOWN 事件。当手指移动时,类型为 MotionEvent 的事件。ACTION_MOVE 被触发。请注意,您将始终获得 MotionEvent。动作 _ 移动事件,因为你不能保持手指不动来避免它们。触摸传感器将识别最轻微的变化。当手指再次抬起时,MotionEvent。报告了 ACTION_UP 事件。运动事件。ACTION_CANCEL 事件有点神秘。文档显示,当当前手势被取消时,它们将被触发。我们还从未在现实生活中见过这一事件。然而,我们仍然会处理它,并假设它是一个运动事件。当我们开始实现我们的第一个游戏时的 ACTION_UP 事件。

让我们编写一个简单的测试活动,看看这在代码中是如何工作的。该活动应该显示手指在屏幕上的当前位置以及事件类型。清单 4-3 显示了我们的成果。

清单 4-3。【SingleTouchTest.java】;测试单点触摸操作

package com.badlogic.androidgames;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.widget.TextView;

public class SingleTouchTest extends Activity implements OnTouchListener {
   StringBuilder builder = new StringBuilder();
   TextView textView;

   public void onCreate(Bundle savedInstanceState) {
       super .onCreate(savedInstanceState);
       textView = new TextView(this );
       textView.setText("Touch and drag (one finger only)!");
       textView.setOnTouchListener(this );
       setContentView(textView);
   }

   public boolean onTouch(View v, MotionEvent event) {
      builder.setLength(0);
      switch (event.getAction()) {
      case MotionEvent.*ACTION*_*DOWN*:
          builder.append("down, ");
          break ;
      case MotionEvent.*ACTION*_*MOVE*:
          builder.append("move, ");
          break ;
      case MotionEvent.*ACTION*_*CANCEL*:
          builder.append("cancel", ");
          break ;
      case MotionEvent.*ACTION*_*UP*:
          builder.append("up, ");
          break ;
      }
      builder.append(event.getX());
      builder.append(", ");
      builder.append(event.getY());
      String text = builder.toString();
      Log.*d*("TouchTest", text);
      textView.setText(text);
      return true ;
   }
}

我们让我们的活动实现 OnTouchListener 接口。我们还有两个成员:一个用于 TextView,另一个用于构造事件字符串的 StringBuilder。

onCreate()方法是不言自明的。惟一的新颖之处是对 TextView.setOnTouchListener()的调用,在这里我们向 TextView 注册了我们的活动,以便它接收 MotionEvents。

剩下的就是 onTouch()方法实现本身。我们忽略视图参数,因为我们知道它必须是 TextView。我们感兴趣的是获取触摸事件类型,将标识它的字符串追加到我们的 StringBuilder,追加触摸坐标,并更新 TextView 文本。就这样。我们还将事件记录到 LogCat 中,这样我们就可以看到事件发生的顺序,因为 TextView 只会显示我们处理的最后一个事件(每次调用 onTouch()时,我们都会清除 StringBuilder)。

onTouch()方法中一个微妙的细节是 return 语句,在这里我们返回 true。通常,我们会坚持侦听器的概念并返回 false,以便不干扰事件调度过程。如果我们在示例中这样做,我们将不会得到除了 MotionEvent 之外的任何事件。ACTION_DOWN 事件因此,我们告诉 TextView 我们刚刚消费了事件。在不同的视图实现之间,这种行为可能会有所不同。幸运的是,在本书的其余部分,我们只需要其他三个视图,这些视图将让我们愉快地消费我们想要的任何事件。

如果我们在模拟器或连接的设备上启动该应用,我们可以看到 TextView 总是显示向 onTouch()方法报告的最后一个事件类型和位置。此外,您可以在 LogCat 中看到相同的消息。

我们没有修复清单文件中活动的方向。当然,如果您旋转设备,使活动处于横向模式,坐标系也会改变。图 4-6 显示了纵向模式(左)和横向模式(右)下的活动。在这两种情况下,我们都试图触及视图的中间。注意 x 和 y 坐标是如何交换的。该图还显示了两种情况下的 x 轴和 y 轴(黄线),以及屏幕上我们粗略触摸过的点(绿圈)。在这两种情况下,原点都在 TextView 的左上角,x 轴指向右侧,y 轴指向下方。

9781430246770_Fig04-06.jpg

图 4-6。在纵向和横向模式下触摸屏幕

当然,根据方向的不同,我们的最大 x 和 y 值也会变化。前面的图片是在运行 Android 2.2 (Froyo)的 Nexus One 上拍摄的,它在人像模式下的屏幕分辨率为 480×800 像素(在风景模式下为 800×480)。由于触摸坐标是相对于视图给出的,并且视图没有填满整个屏幕,因此我们的最大 y 值将小于分辨率高度。稍后我们将看到如何启用全屏模式,以便标题栏和通知栏不会妨碍我们。

遗憾的是,旧版本 Android 和第一代设备上的触摸事件存在一些问题:

  • 触摸事件泛滥:当手指在触摸屏上按下时,司机会报告尽可能多的触摸事件——在一些设备上,每秒数百次。我们可以通过将 Thread.sleep(16)调用放入我们的 onTouch()方法中来解决这个问题,这将使分派这些事件的 UI 线程休眠 16 毫秒。这样的话,我们每秒最多可以处理 60 个事件,这对于一个反应灵敏的游戏来说已经足够了。这只是安卓 1.5 版本设备上的问题。如果你的目标不是那个 Android 版本,忽略这个建议。
  • 触屏吃**CPU:即使我们在我们的 onTouch()方法中休眠,系统也要处理驱动程序报告的内核中的事件。在老设备上,比如 Hero 或 G1,这可以使用高达 50%的 CPU,这使得我们的主循环线程的处理能力大大降低。因此,我们完美的帧速率将会大大下降,有时会到游戏无法播放的程度。在第二代设备上,这个问题要小得多,通常可以忽略。遗憾的是,在旧设备上没有解决方案。

处理多点触摸事件

警告:前方剧痛!multitouch API 已经被标记到 MotionEvent 类中,该类最初只处理单点触摸。当试图解码多点触摸事件时,这造成了一些主要的混乱。让我们试着理解它。

注意多点触控 API 显然也让开发它的 Android 工程师感到困惑。它在 SDK 版本 8 (Android 2.2)中得到了重大改进,增加了新方法、新常量,甚至重命名了常量。这些变化应该会让多点触控的使用变得更加容易。但是,它们仅从 SDK 版本 8 开始提供。为了支持所有支持多点触摸的 Android 版本(2.0 以上),我们必须使用 SDK 版本 5 的 API。

处理多点触摸事件与处理单点触摸事件非常相似。我们仍然实现了与单触事件相同的 OnTouchListener 接口。我们还获得了一个从中读取数据的 MotionEvent 实例。我们还处理之前处理过的事件类型,比如 MotionEvent。ACTION_UP,加上几个没什么大不了的新功能。

指针 id 和索引

当我们想要访问触摸事件的坐标时,处理多触摸事件和处理单触摸事件之间的区别就开始了。MotionEvent.getX()和 MotionEvent.getY()返回单个手指在屏幕上的坐标。当我们处理多点触摸事件时,我们使用这些方法的重载变体,它们接受一个指针索引。这可能看起来像这样:

event.getX(pointerIndex);
event.getY(pointerIndex);

现在,人们会期望指针索引直接对应于触摸屏幕的手指之一(例如,触摸的第一个手指的指针索引为 0,触摸的下一个手指的指针索引为 1,依此类推)。不幸的是,事实并非如此。

pointerIndex 是 MotionEvent 内部数组的索引,它保存触摸屏幕的特定手指的事件坐标。手指在屏幕上的真实标识符被称为指针标识符。指针标识符是唯一标识触摸屏幕的指针的一个实例的任意数字。有一个单独的方法叫做 motion event . getpointeridentifier(int pointer index),它基于指针索引返回指针标识符。只要单个手指接触屏幕,指针标识符将保持不变。指针索引不一定如此。重要的是要理解两者之间的区别,并理解你不能依赖于第一次触摸是索引 0,ID 0,因为在一些设备上,特别是 Xperia Play 的第一个版本,指针 ID 总是会增加到 15,然后从 0 开始,而不是重复使用 ID 的最低可用数字。

让我们从研究如何到达一个事件的指针索引开始。我们现在将忽略事件类型。

int pointerIndex = (event.getAction() & MotionEvent.ACTION_POINTER_ID_MASK) >> MotionEvent.ACTION_POINTER_ID_SHIFT;

当我们第一次实现它时,您可能会有同样的想法。在我们对人性失去信心之前,让我们试着破译这里发生了什么。我们通过 MotionEvent.getAction()从 MotionEvent 获取事件类型。很好,我们以前做过。接下来,我们使用从 MotionEvent.getAction()方法获得的整数和一个名为 MotionEvent 的常量执行按位 AND 运算。动作 _ 指针 _ 标识 _ 掩码。现在好戏开始了。

该常量的值为 0xff00,因此我们基本上将所有位设为 0,但第 8 位至第 15 位除外,它们保存事件的指针索引。event.getAction()返回的整数的低 8 位保存事件类型的值,如 MotionEvent。ACTION_DOWN 及其同级。通过这种位运算,我们实际上抛弃了事件类型。这种转变现在应该更有意义了。我们通过运动事件转移。ACTION_POINTER_ID_SHIFT,其值为 8,因此我们基本上将第 8 位到第 15 位移动到第 0 位到第 7 位,从而得到事件的实际指针索引。这样,我们就可以获得事件的坐标,以及指针标识符。

请注意,我们的神奇常数被称为 XXX_POINTER_ID_XXX,而不是 XXX_POINTER_INDEX_XXX(这更有意义,因为我们实际上想要提取指针索引,而不是指针标识符)。好吧,安卓工程师一定也很困惑。在 SDK 版本 8 中,他们弃用了这些常数,并引入了名为 XXX_POINTER_INDEX_XXX 的新常数,这些常数与弃用的常数具有完全相同的值。为了让针对 SDK 第 5 版编写的遗留应用继续在较新的 Android 版本上工作,旧的常量仍然可用。

所以我们现在知道如何获得神秘的指针索引,我们可以用它来查询事件的坐标和指针标识符。

动作掩码和更多事件类型

接下来,我们必须获得纯事件类型减去附加指针索引,该指针索引编码在由 MotionEvent.getAction()返回的整数中。我们只需要屏蔽掉指针索引:

int action = event.getAction() & MotionEvent.ACTION_MASK;

好吧,那很简单。遗憾的是,只有当你知道指针索引是什么,并且它实际上编码在动作中时,你才能理解它。

剩下的就是像我们之前做的那样解码事件类型。我们已经说过有一些新的事件类型,现在让我们来看一下:

  • 运动事件。ACTION_POINTER_DOWN:在第一个手指触摸屏幕后,任何其他手指触摸屏幕都会发生此事件。第一个手指仍然产生运动事件。ACTION_DOWN 事件
  • 运动事件。ACTION_POINTER_UP:这类似于前面的操作。当一个手指从屏幕上抬起,并且不止一个手指触摸屏幕时,就会触发这个事件。屏幕上最后一个被抬起的手指将产生一个运动事件。ACTION_UP 事件这个手指不一定是触摸屏幕的第一个手指。

幸运的是,我们可以假设这两个新的事件类型与旧的 MotionEvent 相同。ACTION_UP 和 MotionEvent。动作 _ 停止事件。

最后一个区别是,单个 MotionEvent 可以包含多个事件的数据。是的,你没看错。为此,合并的事件必须具有相同的类型。实际上,这只会发生在运动事件中。ACTION_MOVE 事件,因此我们只需在处理所述事件类型时处理这一事实。为了检查单个 MotionEvent 中包含多少个事件,我们使用 MotionEvent.getPointerCount()方法,该方法告诉我们在 MotionEvent 中具有坐标的手指的数量。然后,我们可以通过 MotionEvent.getX()、MotionEvent.getY()和 MotionEvent.getPointerId()方法获取指针索引 0 到 motion event . getpointercount()–1 的指针标识符和坐标。

在实践中

让我们为这个优秀的 API 写一个例子。我们希望最多跟踪十个手指(还没有设备可以跟踪更多,所以我们在这里是安全的)。当我们在屏幕上添加更多手指时,Android 设备通常会分配连续的指针索引,但这并不总是有保证的,所以我们依赖于数组的指针索引,并将简单地显示哪个 id 分配给了触摸点。我们跟踪每个指针的坐标和触摸状态(触摸与否),并通过文本视图将这些信息输出到屏幕上。让我们称我们的测试活动为 MultiTouchTest。清单 4-4 显示了完整的代码。

清单 4-4。【MultiTouchTest.java】;测试多点触摸 API

package com.badlogic.androidgames;

import android.app.Activity;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.widget.TextView;

@TargetApi (5)
public class MultiTouchTest extends Activity implements OnTouchListener {
   StringBuilder builder = new StringBuilder();
   TextView textView;
   float [] x = new float [10];
   float [] y = new float [10];
   boolean [] touched = new boolean [10];
   int [] id = new int [10];

   private void updateTextView() {
      builder.setLength(0);
      for (int i = 0; i < 10; i++) {
          builder.append(touched[i]);
          builder.append(", ");
          builder.append(id[i]);
          builder.append(", ");
          builder.append(x[i]);
          builder.append(", ");
          builder.append(y[i]);
          builder.append("\n");
        }
        textView.setText(builder.toString());
    }

    public void onCreate(Bundle savedInstanceState) {
       super .onCreate(savedInstanceState);
       textView = new TextView(this );
       textView.setText("Touch and drag (multiple fingers supported)!");
       textView.setOnTouchListener(this );
       setContentView(textView);
       for (int i = 0; i < 10; i++) {
           id[i] = -1;
       }
       updateTextView();
    }

    public boolean onTouch(View v, MotionEvent event) {
       int action = event.getAction() & MotionEvent.*ACTION*_*MASK*;
       int pointerIndex = (event.getAction() & MotionEvent.*ACTION*_*POINTER*_*ID*_*MASK*) >> MotionEvent.*ACTION*_*POINTER*_*ID*_*SHIFT*;
       int pointerCount = event.getPointerCount();
       for (int i = 0; i < 10; i++) {
             if (i >= pointerCount) {
                 touched[i] = false ;
                 id[i] = -1;
                 continue ;
             }
             if (event.getAction() != MotionEvent.*ACTION*_*MOVE*&& i != pointerIndex) {
                 // if it's an up/down/cancel/out event, mask the id to see if we should process it for this touch point
                 continue ;
             }
             int pointerId = event.getPointerId(i);
             switch (action) {

             case MotionEvent.*ACTION*_*DOWN*:
             case MotionEvent.*ACTION*_*POINTER*_*DOWN*:
                 touched[i] = true ;
                 id[i] = pointerId;
                 x[i] = (int ) event.getX(i);
                 y[i] = (int ) event.getY(i);
                 break ;
             case MotionEvent.*ACTION*_*UP*:
             case MotionEvent.*ACTION*_*POINTER*_*UP*:
     case MotionEvent.*ACTION*_*OUTSIDE*:
             case MotionEvent.*ACTION*_*CANCEL*:
                 touched[i] = false ;
                 id[i] = -1;
                 x[i] = (int ) event.getX(i);
                 y[i] = (int ) event.getY(i);
                 break ;

             case MotionEvent.*ACTION*_*MOVE*:
                 touched[i] = true ;
                 id[i] = pointerId;
                 x[i] = (int ) event.getX(i);
                 y[i] = (int ) event.getY(i);
                 break ;
             }
         }
         updateTextView();
         return true ;
     }
}

注意类定义顶部的 TargetApi 注释。这是必要的,因为我们访问的 API 不是我们在创建项目时指定的最低 SDK 的一部分(Android 1.5)。每次我们使用不属于最小 SDK 的 API 时,我们都需要将注释放在使用这些 API 的类的顶部!

我们像以前一样实现 OnTouchListener 接口。为了跟踪十个手指的坐标和触摸状态,我们添加了三个新的成员数组来保存这些信息。数组 x 和 y 保存每个指针 ID 的坐标,被触摸的数组存储具有该指针 ID 的手指是否按下。

接下来,我们自由地创建了一个小助手方法,将手指的当前状态输出到 TextView。该方法只需迭代所有十个手指状态,并通过 StringBuilder 将它们连接起来。最终文本将设置为 TextView。

onCreate()方法设置我们的活动,并将其注册为 TextView 中的 OnTouchListener。这部分我们已经背熟了。

现在是可怕的部分:onTouch()方法。

我们首先通过屏蔽 event.getAction()返回的整数来获取事件类型。接下来,我们提取指针索引,并从 MotionEvent 获取相应的指针标识符,如前所述。

onTouch()方法的核心是那个讨厌的大 switch 语句,我们已经用它的简化形式来处理单触事件。我们将所有事件分为三大类:

  • 一触 - 倒地事件发生 (MotionEvent。ACTION_DOWN 或 MotionEvent。ACTION_PONTER_DOWN):我们将指针标识符的触摸状态设置为 true,并保存指针的当前坐标。
  • 一触 - up 事件发生 (MotionEvent。ACTION_UP,MotionEvent。ACTION_POINTER_UP 或 MotionEvent。取消):我们将该指针标识符的触摸状态设置为假,并保存其最后已知的坐标。
  • 一个或多个手指 被拖过 屏幕(运动事件。ACTION_MOVE):我们检查 MotionEvent 中包含多少个事件,然后将指针索引 0 的坐标更新为 motion event . getpointercount()-1。对于每个事件,我们获取相应的指针 ID 并更新坐标。

处理完事件后,我们通过调用前面定义的 updateView()方法来更新 TextView。最后,我们返回 true,表明我们处理了触摸事件。

图 4-7 显示了触摸三星 Galaxy Nexus 手机的五个手指并稍微拖动它们所产生的活动输出。

9781430246770_Fig04-07.jpg

图 4-7。多点触控带来的乐趣

运行这个示例时,我们可以观察到一些情况:

  • 如果我们在 Android 版本低于 2.0 的设备或模拟器上启动它,我们会得到一个令人讨厌的异常,因为我们使用了一个在那些早期版本上不可用的 API。我们可以通过确定应用运行的 Android 版本来解决这个问题,在运行 Android 1.5 和 1.6 的设备上使用单触代码,在运行 Android 2.0 或更高版本的设备上使用多触代码。我们将在下一章回到这个话题。
  • 模拟器上没有多点触摸。如果我们创建一个运行 Android 或更高版本的仿真器,API 就在那里,但我们只有一个鼠标。即使我们有两只老鼠,也不会有什么不同。
  • 向下触摸两个手指,抬起第一个手指,然后再次向下触摸。第二个手指将在第一个手指抬起后保持其指针 ID。当第一个手指第二次按下时,它会获得一个新的指针 ID,通常为 0,但可以是任何整数。任何触摸屏幕的新手指都将获得一个新的指针 ID,它可以是当前没有被另一个活动触摸使用的任何东西。这是一条需要记住的规则。
  • 如果你在 Nexus One、Droid 或更新的低预算智能手机上尝试这一功能,当你在一个轴上交叉两个手指时,你会注意到一些奇怪的行为。这是因为这些设备的屏幕不完全支持对单个手指的跟踪。这是一个大问题,但是我们可以通过精心设计我们的 ui 来解决它。我们将在下一章中再来看这个问题。要记住的短语是:不要不要过河

这就是多点触摸处理在 Android 上的工作方式。这是一种痛苦,但是一旦你解开了所有的术语,并*静地接受了这一点,你就会对实现感到更加舒服,并像专家一样处理所有的接触点。

注意如果这让你的头爆炸了,我们很抱歉。这部分任务相当繁重。遗憾的是,该 API 的官方文档极其缺乏,大多数人只是通过简单地钻研来“学习”该 API。我们建议您尝试一下前面的代码示例,直到您完全理解其中的内容。

处理关键事件

经过最后一部分的疯狂,你应该得到一些非常简单的东西。欢迎处理关键事件。

为了捕捉关键事件,我们实现了另一个监听器接口,称为 OnKeyListener。它有一个名为 onKey()的方法,签名如下:

public boolean onKey(View view, int keyCode, KeyEvent event)

视图指定接收键事件的视图,keyCode 参数是在 key event 类中定义的常量之一,最后一个参数是键事件本身,它有一些附加信息。

什么是关键代码?(屏幕)键盘上的每个键和每个系统键都有一个唯一的编号。这些键码在 KeyEvent 类中被定义为静态公共最终整数。一种这样的密钥代码是 key code。KEYCODE_A,是 A 键的代码。这与按下某个键时文本字段中生成的字符没有任何关系。它实际上只是标识了密钥本身。

KeyEvent 类类似于 MotionEvent 类。它有两种与我们相关的方法:

  • KeyEvent.getAction():这将返回 KeyEvent。ACTION_DOWN,KeyEvent。ACTION_UP 和 KeyEvent。动作 _ 多个。出于我们的目的,我们可以忽略最后一个关键事件类型。另外两个将在按键被按下或释放时发送。
  • KeyEvent.getUnicodeChar():返回文本字段中的 Unicode 字符。假设我们按住 Shift 键并按下 A 键。这将被报告为一个键码为 KeyEvent 的事件。KEYCODE_A,但是带有一个 Unicode 字符 A,如果我们自己想做文本输入的话可以用这个方法。

要接收键盘事件,视图必须有焦点。这可以通过以下方法调用来强制实现:

View.setFocusableInTouchMode(true);
View.requestFocus();

第一种方法将保证视图可以聚焦。第二种方法要求特定视图获得焦点。

让我们实现一个简单的测试活动,看看这两者是如何结合起来的。我们希望获得关键事件,并在文本视图中显示我们收到的最后一个事件。我们将显示的信息是键事件类型,以及键代码和 Unicode 字符(如果会产生的话)。请注意,有些键本身并不产生 Unicode 字符,而是与其他字符组合产生。清单 4-5 展示了我们如何用少量的代码行实现所有这些。

清单 4-5。【KeyTest.Java】;测试关键事件 API

package com.badlogic.androidgames;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.view.View.OnKeyListener;
import android.widget.TextView;

public class KeyTest extends Activity implements OnKeyListener {
   StringBuilder builder = new StringBuilder();
   TextView textView;

   public void onCreate(Bundle savedInstanceState) {
      super .onCreate(savedInstanceState);
      textView = new TextView(this );
      textView.setText("Press keys (if you have some)!");
      textView.setOnKeyListener(this );
      textView.setFocusableInTouchMode(true );
      textView.requestFocus();
      setContentView(textView);
   }

    public boolean onKey(View view, int keyCode, KeyEvent event) {
       builder.setLength(0);
       switch (event.getAction()) {
       case KeyEvent.*ACTION*_*DOWN*:
           builder.append("down, ");
           break ;
       case KeyEvent.*ACTION*_*UP*:
           builder.append("up, ");
           break ;
       }
       builder.append(event.getKeyCode());
       builder.append(", ");
       builder.append((char ) event.getUnicodeChar());
       String text = builder.toString();
       Log.*d*("KeyTest", text);
       textView.setText(text);

       return event.getKeyCode() != KeyEvent.*KEYCODE*_*BACK*;
   }
}

我们首先声明该活动实现了 OnKeyListener 接口。接下来,我们定义两个我们已经熟悉的成员:构造要显示的文本的 StringBuilder 和显示文本的 TextView。

在 onCreate()方法中,我们确保 TextView 获得焦点,这样它就可以接收按键事件。我们还通过 TextView.setOnKeyListener()方法将活动注册为 OnKeyListener。

onKey()方法也非常简单。我们处理 switch 语句中的两种事件类型,向 StringBuilder 追加一个适当的字符串。接下来,我们追加 KeyEvent 本身的键代码和 Unicode 字符,并将 StringBuffer 实例的内容输出到 LogCat 和 TextView。

最后一个 if 语句很有趣:如果按下 Back 键,我们从 onKey()方法返回 false,使 TextView 处理事件。否则,我们返回 true。为什么在这里进行区分?

如果我们在 Back 键的情况下返回 true,我们会稍微打乱活动的生命周期。该活动不会关闭,因为我们决定自己使用 Back key。当然,在有些情况下,我们实际上想要捕捉 Back 键,这样我们的活动就不会被关闭。但是,除非绝对必要,否则强烈建议不要这样做。

图 4-8 展示了按住机器人键盘上的 Shift 和 A 键时活动的输出。

9781430246770_Fig04-08.jpg

图 4-8。同时按下 Shift 和 A 键

这里有几点需要注意:

  • 当您查看 LogCat 输出时,请注意我们可以轻松地处理并发的键事件。按住多个键不是问题。
  • 按下 D-pad 和滚动轨迹球都被报告为按键事件。
  • 与触摸事件一样,按键事件会耗尽旧版本 Android 和第一代设备上的大量 CPU 资源。然而,它们不会产生大量事件。

与上一节相比,这相当轻松,不是吗?

注意键处理 API 比我们在这里展示的要复杂一些。然而,对于我们的游戏编程项目来说,这里包含的信息已经足够了。如果你需要更复杂的东西,可以参考 Android 开发者网站上的官方文档。

读取加速度计状态

一个非常有趣的游戏输入选项是加速度计。所有 Android 设备都需要包含一个三轴加速度计。我们在第三章中略微谈到了加速度计。一般来说,我们只会轮询加速度计的状态。

那么,我们如何获得加速度计信息呢?你猜对了——通过注册一个监听器。我们需要实现的接口叫做 SensorEventListener,它有两个方法:

public void onSensorChanged(SensorEvent event);
public void onAccuracyChanged(Sensor sensor, int accuracy);

当新的加速度计事件到达时,调用第一个方法。当加速度计的精度改变时,调用第二种方法。出于我们的目的,我们可以安全地忽略第二种方法。

那么我们在哪里注册 SensorEventListener 呢?为此,我们必须做一点工作。首先,我们需要检查设备中是否安装了加速度计。现在,我们刚刚告诉你,所有的 Android 设备都必须包含一个加速度计。这仍然是事实,但将来可能会改变。因此,我们希望百分之百地确保我们可以使用该输入法。

我们需要做的第一件事是获取 SensorManager 的一个实例。那个人会告诉我们是否安装了加速度计,这也是我们注册监听器的地方。为了获得 SensorManager,我们使用了上下文接口的一个方法:

SensorManager manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);

SensorManager 是由 Android 系统提供的的系统服务。Android 由多个系统服务组成,每一个服务都为任何人提供不同的系统信息。

一旦有了 SensorManager,我们就可以检查加速度计是否可用:

boolean hasAccel = manager.getSensorList(Sensor.TYPE_ACCELEROMETER).size() > 0;

使用这段代码,我们向 SensorManager 轮询所有安装的加速度计类型的传感器。虽然这意味着一个设备可以有多个加速度计,但实际上这只会返回一个加速度计传感器。

如果安装了加速度计,我们可以从 SensorManager 获取它,并向它注册 SensorEventListener,如下所示:

Sensor sensor = manager.getSensorList(Sensor.TYPE_ACCELEROMETER).get(0);
boolean success = manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME);

参数 SensorManager。SENSOR_DELAY_GAME 指定监听器应该多久更新一次加速度计的最新状态。这是一个专门为游戏设计的特殊常量,所以我们很乐意使用它。请注意,SensorManager.registerListener()方法返回一个布尔值,表明注册过程是否成功。这意味着我们必须在事后检查布尔值,以确保我们确实能从传感器中获得任何事件。

一旦我们注册了侦听器,我们将在 sensoreventlistener . onsensorchanged()方法中接收 SensorEvents。该方法的名称意味着它只在传感器状态改变时被调用。这有点令人困惑,因为加速度计的状态不断变化。当我们注册侦听器时,我们实际上指定了希望接收传感器状态更新的频率。

那么我们如何处理 SensorEvent 呢?那相当容易。 SensorEvent 有一个名为 SensorEvent.values 的公共浮点数组成员,它保存加速度计三个轴中每个轴的当前加速度值。SensorEvent.values[0]保存 x 轴的值,SensorEvent.values[1]保存 y 轴的值,SensorEvent.values[2]保存 z 轴的值。我们在第三章中讨论了这些值的含义,所以如果你忘记了,请再次查看“输入”部分。

有了这些信息,我们可以编写一个简单的测试活动。我们要做的就是在 TextView 中输出每个加速度计轴的加速度计值。清单 4-6 展示了如何做到这一点。

清单 4-6。【AccelerometerTest.java】;测试加速度计 API

package com.badlogic.androidgames;

import android.app.Activity;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.widget.TextView;

public class AccelerometerTest extends Activity implements SensorEventListener {
   TextView textView;
   StringBuilder builder = new StringBuilder();

   @Override
   public void onCreate(Bundle savedInstanceState) {
      super .onCreate(savedInstanceState);
      textView = new TextView(this );
      setContentView(textView);

      SensorManager manager = (SensorManager) getSystemService(Context.*SENSOR*_*SERVICE*);
      if (manager.getSensorList(Sensor.*TYPE*_*ACCELEROMETER*).size() == 0) {
          textView.setText("No accelerometer installed");
      }else {
          Sensor accelerometer = manager.getSensorList(
                  Sensor.*TYPE*_*ACCELEROMETER*).get(0);
          if (!manager.registerListener(this , accelerometer,
                  SensorManager.*SENSOR*_*DELAY*_*GAME*)) {
              textView.setText("Couldn't register sensor listener");
          }
      }
   }

   public void onSensorChanged(SensorEvent event) {
      builder.setLength(0);
      builder.append("x: ");
      builder.append(event.values[0]);
      builder.append(", y: ");
      builder.append(event.values[1]);
      builder.append(", z: ");
      builder.append(event.values[2]);
      textView.setText(builder.toString());
   }

   public void onAccuracyChanged(Sensor sensor, int accuracy) {
      // nothing to do here
   }
}

我们首先检查加速度计传感器是否可用。如果是,我们从 SensorManager 获取它,并尝试注册我们的活动,该活动实现 SensorEventListener 接口。如果这些都失败了,我们设置文本视图来显示一个正确的错误信息。

onSensorChanged()方法只是从传递给它的 SensorEvent 中读取轴值,并相应地更新 TextView 文本。

有了 onAccuracyChanged()方法,我们可以完全实现 SensorEventListener 接口。它没有真正的其他用途。

图 4-9 显示了当设备垂直于地面时,轴在纵向和横向模式下的数值。

9781430246770_Fig04-09.jpg

图 4-9。当设备垂直于地面时,纵向模式(左)和横向模式(右)下的加速度计轴值

Android 加速度计处理的一个问题是加速度计值是相对于设备的默认方向的。这意味着,如果你的游戏只在横向模式下运行,默认方向为纵向模式的设备与默认方向为横向模式的设备的数值相差 90 度!例如,*板电脑就是这种情况。那么,如何应对这种情况呢?使用这个方便的代码片段,您应该已经准备好了:

int screenRotation;
public void onResume() {
        WindowManager windowMgr = (WindowManager)activity.getSystemService(Activity.WINDOW_SERVICE);
                // getOrientation() is deprecated in Android 8 but is the same as getRotation(), which is the rotation from the natural orientation of the device
        screenRotation = windowMgr.getDefaultDisplay().getOrientation();
}
static final int *ACCELEROMETER*_*AXIS*_*SWAP*[][] = {
    {1, -1, 0, 1}, // ROTATION_0
    {-1, -1, 1, 0}, // ROTATION_90
    {-1, 1, 0, 1}, // ROTATION_180
    {1, 1, 1, 0}}; // ROTATION_270
public void onSensorChanged(SensorEvent event) {
    final int [] as =*ACCELEROMETER*_*AXIS*_*SWAP*[screenRotation];
    float screenX = (float )as[0] * event.values[as[2]];
    float screenY = (float )as[1] * event.values[as[3]];
    float screenZ = event.values[2];
    // use screenX, screenY, and screenZ as your accelerometer values now!
}

下面是一些关于加速度计的结束语:

  • 正如您在图 4-9 右侧的截图中看到的,加速度计值有时可能会超出其指定范围。这是由于传感器中的小误差造成的,因此如果您需要这些值尽可能精确,就必须进行调整。
  • 无论您的活动方向如何,加速度计轴总是以相同的顺序报告。
  • 应用开发人员负责根据设备的自然方向旋转加速度计值。

读取指南针状态

除了加速度计之外的读数传感器,例如指南针,也非常相似。事实上,它是如此的相似,以至于你可以简单地替换 Sensor 的所有实例。在清单 4-6 中输入 _ 加速度计和传感器。输入 _ 方向并重新运行测试,将我们的加速计测试代码用作指南针测试!

现在,您将看到您的 x、y 和 z 值正在做一些非常不同的事情。如果您将设备*放,屏幕朝上并与地面*行,x 将读取指南针指向的度数,y 和 z 应该接* 0。现在将设备倾斜,看看这些数字是如何变化的。x 应该仍然是主航向(方位角),但是 y 和 z 应该分别显示设备的俯仰和滚动。因为 TYPE_ORIENTATION 的常数已被否决,所以您也可以通过调用 sensor manager . get ORIENTATION(float[]R,float[] values)来接收相同的指南针数据,其中 R 是旋转矩阵(请参见 sensor manager . getrotationmatrix()),values 保存三个返回值,这次以弧度为单位。

至此,我们已经讨论了游戏开发所需的 Android API 的所有与输入处理相关的类。

注意顾名思义,SensorManager 类也允许您访问其他传感器。这包括指南针和光传感器。如果你想有创意,你可以想出一个使用这些传感器的游戏创意。处理事件的方式与我们处理加速度计数据的方式类似。Android 开发者网站上的文档会给你更多的信息。

文件处理

Android 为我们提供了几种读写文件的方法。在这一节中,我们将了解素材、如何访问外部存储(大部分实现为 SD 卡)和共享首选项,它们的作用就像一个持久的哈希表。先说素材。

阅读素材

在第二章中,我们简单看了一下一个 Android 项目的所有文件夹。我们将 assets/和 res/ folders 标识为我们可以放置文件的地方,这些文件应该与我们的应用一起分发。当我们讨论 manifest 文件时,我们声明我们不打算使用 res/ folder,因为它意味着对我们如何构造文件集的限制。素材/目录是放置我们所有文件的地方,无论我们想要什么文件夹层次结构。

assets/ folder 中的文件通过一个名为 AssetManager 的类公开。对于我们的应用,我们可以获得对该管理器的引用,如下所示:

AssetManager assetManager = context.getAssets();

我们已经看到了上下文接口;它由 Activity 类实现。在现实生活中,我们会从活动中获取素材管理器。

一旦我们有了素材管理器,我们就可以开始疯狂地打开文件:

InputStream inputStream = assetManager.open("dir/dir2/filename.txt");

这个方法将返回一个普通的 Java InputStream,我们可以用它来读取任何类型的文件。AssetManager.open()方法的唯一参数是相对于素材目录的文件名。在前面的示例中,我们在 assets/文件夹中有两个目录,其中第二个目录(dir2/)是第一个目录(dir/)的子目录。在我们的 Eclipse 项目中,该文件将位于 assets/dir/dir2/中。

让我们编写一个简单的测试活动来检查这个功能。我们希望从名为 texts 的素材/目录的子目录中加载一个名为 myawesometext.txt 的文本文件。文本文件的内容将显示在文本视图中。清单 4-7 显示了这个令人敬畏的活动的来源。

清单 4-7。AssetsTest.java,演示如何读取素材文件

package com.badlogic.androidgames;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

import android.app.Activity;
import android.content.res.AssetManager;
import android.os.Bundle;
import android.widget.TextView;

public class AssetsTest extends Activity {
   @Override
   public void onCreate(Bundle savedInstanceState) {
      super .onCreate(savedInstanceState);
      TextView textView = new TextView(this );
      setContentView(textView);

      AssetManager assetManager = getAssets();
      InputStream inputStream = null ;
      try {
          inputStream = assetManager.open("texts/myawesometext.txt");
          String text = loadTextFile(inputStream);
          textView.setText(text);
      }catch (IOException e) {
          textView.setText("Couldn't load file");
      }finally {
          if (inputStream != null )
              try {
                  inputStream.close();
              }catch (IOException e) {
                  textView.setText("Couldn't close file");
              }
      }
   }

    public String loadTextFile(InputStream inputStream)throws IOException {
       ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
       byte [] bytes = new byte [4096];
       int len = 0;
       while ((len = inputStream.read(bytes)) > 0)
          byteStream.write(bytes, 0, len);
       return new String(byteStream.toByteArray(), "UTF8");
   }
}

除了发现在 Java 中从 InputStream 加载简单文本相当冗长之外,我们在这里没有看到什么大的意外。我们编写了一个名为 loadTextFile()的小方法,它将从 InputStream 中挤出所有的字节,并以字符串的形式返回这些字节。我们假设文本文件编码为 UTF-8。剩下的只是捕捉和处理各种异常。图 4-10 显示了这个小活动的输出。

9781430246770_Fig04-10.jpg

图 4-10。素材测试的文本输出

您应该从本节中删除以下内容:

  • 用 Java 从 InputStream 加载文本文件简直是一团糟!通常,我们会用 Apache IOUtils 这样的东西来做。我们会把它留给你自己去完成。
  • 我们只能读素材,不能写素材。
  • 我们可以很容易地修改 loadTextFile()方法来加载二进制数据。我们只需要返回字节数组而不是字符串。

访问外部存储器

虽然素材对于将我们所有的图像和声音与我们的应用一起传送来说是极好的,但是有时我们需要能够持久存储一些信息并在以后重新加载它。一个常见的例子就是高分。

Android 提供了许多不同的方法,比如使用应用的本地共享首选项,使用小型 SQLite 数据库等等。所有这些选项都有一个共同点:它们不能很好地处理大型二进制文件。我们为什么需要那个?虽然我们可以告诉 Android 将我们的应用安装在外部存储设备上,从而不浪费内部存储的内存,但这只能在 Android 2.2 及更高版本上运行。对于早期版本,我们所有的应用数据都将安装在内部存储中。理论上,我们只能将应用的代码包含在 APK 文件中,并在应用第一次启动时将所有素材文件从服务器下载到 SD 卡中。Android 上很多高配置的游戏都是这么做的。

还有其他一些场景,我们想要访问 SD 卡(在所有当前可用的设备上,sd 卡与术语外部存储几乎是同义词)。我们可以允许我们的用户用游戏内编辑器创建他们自己的关卡。我们需要将这些级别存储在某个地方,而 SD 卡正好适合这个目的。

所以,现在我们已经说服你不要使用 Android 提供的花哨机制来存储应用偏好,让我们看看如何在 SD 卡上读写文件。

我们要做的第一件事是请求访问外部存储器的许可。这是在 manifest 文件中用本章前面讨论的元素完成的。

接下来,我们必须检查用户的 Android 设备上是否真的有可用的外部存储设备。例如,如果你创建了一个 Android 虚拟设备(AVD ),你可以选择不让它模拟 SD 卡,这样你就不能在你的应用中写入它。无法访问 SD 卡的另一个原因可能是外部存储设备当前正被其他设备使用(例如,用户可能正在通过台式 PC 上的 USB 来浏览它)。下面是我们获取外部存储状态的方法:

String state = Environment.getExternalStorageState();

嗯,我们得到了一个字符串。环境类定义了几个常量。其中之一叫做环境。媒体安装。也是字符串。如果前面的方法返回的字符串等于这个常数,我们就拥有对外部存储的完全读/写访问权限。请注意,您必须使用 equals()方法来比较这两个字符串;引用相等并不是在所有情况下都有效。

一旦我们确定我们实际上可以访问外部存储,我们需要获得它的根目录名。如果我们想要访问一个特定的文件,我们需要指定它相对于这个目录的位置。为了获得根目录,我们使用另一个环境静态方法:

File externalDir = Environment.getExternalStorageDirectory();

从这里开始,我们可以使用标准的 Java I/O 类来读写文件。

让我们编写一个简单的例子,将文件写入 SD 卡,读取文件,在 TextView 中显示其内容,然后再次从 SD 卡中删除文件。清单 4-8 显示了它的源代码。

清单 4-8。externalstragetest 活动

package com.badlogic.androidgames;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;

import android.app.Activity;
import android.os.Bundle;
import android.os.Environment;
import android.widget.TextView;

public class ExternalStorageTest extends Activity {
   @Override
   public void onCreate(Bundle savedInstanceState) {
      super .onCreate(savedInstanceState);
      TextView textView = new TextView(this );
      setContentView(textView);

      String state = Environment.*getExternalStorageState*();
      if (!state.equals(Environment.*MEDIA*_*MOUNTED*)) {
          textView.setText("No external storage mounted");
      }else {
          File externalDir = Environment.*getExternalStorageDirectory*();
          File textFile = new File(externalDir.getAbsolutePath()
                  + File.*separator*+ "text.txt");
          try {
              writeTextFile(textFile, "This is a test. Roger");
              String text = readTextFile(textFile);
              textView.setText(text);
              if (!textFile.delete()) {
                  textView.setText("Couldn't remove temporary file");
              }
          }catch (IOException e) {
              textView.setText("Something went wrong! " + e.getMessage());
           }
       }
   }

   private void writeTextFile(File file, String text)throws IOException {
      BufferedWriter writer = new BufferedWriter(new FileWriter(file));
      writer.write(text);
      writer.close();
   }

   private String readTextFile(File file)throws IOException {
      BufferedReader reader = new BufferedReader(new FileReader(file));
      StringBuilder text = new StringBuilder();
      String line;
      while ((line = reader.readLine()) != null ) {
          text.append(line);
          text.append("\n");
      }
      reader.close();
      return text.toString();
   }
}

首先,我们检查 SD 卡是否已经安装。如果不行,我们就提前退出。接下来,我们获取外部存储目录,并构造一个新的文件实例,它指向我们将在下一条语句中创建的文件。writeTextFile()方法使用标准的 Java I/O 类来施展它的魔法。如果文件还不存在,这个方法将创建它;否则,它将覆盖一个已经存在的文件。在我们成功地将测试文本转储到外部存储设备上的文件之后,我们再次读取它并将其设置为 TextView 的文本。最后一步,我们再次从外部存储器中删除该文件。所有这些都是通过适当的标准安全措施来完成的,这些措施将通过向 TextView 输出错误消息来报告是否出现了问题。图 4-11 显示了活动的输出。

9781430246770_Fig04-11.jpg

图 4-11。罗杰!

以下是可以从这一部分吸取的经验教训:

  • 不要乱动任何不属于你的文件。如果你删除他们上一次度假的照片,你的用户会很生气。
  • 务必检查外部存储设备是否已安装。
  • 不要弄乱外部存储设备上的任何文件!

因为删除外部存储设备上的所有文件非常容易,所以在从 Google Play 安装下一个请求 SD 卡权限的应用之前,您可能会三思而行。该应用一旦安装,就可以完全控制你的文件。

共享偏好

Android 提供了一个简单的 API 来存储应用的键值对,称为 SharedPreferences。SharedPreferences API 与标准的 Java 属性 API 没有什么不同。一个活动可以有一个默认的 SharedPreferences 实例,也可以根据需要使用多个不同的 SharedPreferences 实例。下面是从活动中获取 SharedPreferences 实例的典型方法:

SharedPreferences prefs = PreferenceManager.*getDefaultSharedPreferences*(this );

或者:

SharedPreferences prefs = getPreferences(Context.MODE_PRIVATE);

第一个方法给出了一个公共的 SharedPreferences,它将被那个上下文(在我们的例子中是 Activity)共享。第二种方法做同样的事情,但是它让你选择共享偏好的隐私。选项是上下文。MODE_PRIVATE,这是默认的上下文。模式 _ 世界 _ 可读,和上下文。模式 _ 世界 _ 可写。使用上下文之外的任何东西。MODE_PRIVATE 更高级,它对于保存游戏设置之类的事情来说是不必要的。

要使用共享首选项,您首先需要获得编辑器。这是通过

Editor editor = prefs.edit()

现在我们可以插入一些值:

editor.putString("key1", "banana");
editor.putInt("key2", 5);

最后,当我们想要保存时,我们只需添加

editor.commit();

准备好回读了吗?正如人们所料:

String value1 = prefs.getString("key1", null);
int value2 = prefs.getInt("key2", 0);

在我们的例子中,值 1 是“香蕉”,值 2 是 5。SharedPreferences 的“get”调用的第二个参数是默认值。如果在偏好设置中找不到密钥,将使用这些选项。例如,如果从未设置“key1”,那么在 getString 调用后,value1 将为 null。SharedPreferences 非常简单,我们实际上不需要任何测试代码来演示。只要记住总是提交这些编辑!

音频编程

Android 提供了几个易于使用的 API 来播放音效和音乐文件——正好满足我们的游戏编程需求。我们来看看那些 API。

设置音量控制

如果你有一个 Android 设备,你会注意到当你按下音量调高和调低按钮时,你会根据你当前使用的应用来控制不同的音量设置。在通话中,您可以控制传入语音流的音量。在 YouTube 应用中,您可以控制视频音频的音量。在主屏幕上,您可以控制系统声音的音量,如铃声或收到的即时消息。

Android 有不同用途的不同音频流。当我们在游戏中回放音频时,我们使用将音效和音乐输出到一个特定流的类,这个特定流称为音乐流。在我们考虑播放音效或音乐之前,我们首先必须确保音量按钮将控制正确的音频流。为此,我们使用上下文接口的另一种方法:

context.setVolumeControlStream(AudioManager.STREAM_MUSIC);

一如既往,我们选择的上下文实现将是我们的活动。这次通话后,音量按钮将控制音乐流,我们稍后将向其中输出音效和音乐。我们只需要在活动生命周期中调用这个方法一次。Activity.onCreate()方法是实现这一点的最佳方法。

编写一个只包含一行代码的示例有点矫枉过正。因此,我们将在这一点上避免这样做。只要记住在所有输出声音的活动中使用这种方法。

播放声音效果

在第三章中,我们讨论了流媒体音乐和回放音效的区别。后者存储在内存中,通常不会超过几秒钟。Android 为我们提供了一个名为 SoundPool 的类,使得播放音效变得非常容易。

我们可以简单地实例化新的 SoundPool 实例,如下所示:

SoundPool soundPool = new SoundPool(20, AudioManager.STREAM_MUSIC, 0);

第一个参数定义了我们可以同时播放的声音效果的最大数量。这并不意味着我们不能加载更多的音效;它只是限制了可以同时播放的音效数量。第二个参数定义 SoundPool 输出音频的音频流。我们选择的音乐流也设置了音量控制。最后一个参数目前没有使用,应该默认为 0。

要将声音效果从音频文件加载到堆内存中,我们可以使用 SoundPool.load()方法。我们将所有文件存储在 assets/ directory 中,因此需要使用重载的 SoundPool.load()方法,该方法采用 AssetFileDescriptor。我们如何获得 AssetFileDescriptor?很简单——通过我们之前合作过的素材管理器。下面是我们如何通过 SoundPool 从 assets/ directory 加载名为 explosion.ogg 的 OGG 文件:

AssetFileDescriptor descriptor = assetManager.openFd("explosion.ogg");
int explosionId = soundPool.load(descriptor, 1);

通过 AssetManager.openFd()方法可以直接获得 AssetFileDescriptor。通过 SoundPool 加载音效也同样简单。SoundPool.load()方法的第一个参数是我们的 AssetFileDescriptor,第二个参数指定声音效果的优先级。目前不使用,为了将来的兼容性,应该设置为 1。

SoundPool.load()方法返回一个整数,作为加载的声音效果的句柄。当我们想要播放声音效果时,我们指定这个句柄,以便 SoundPool 知道要播放什么效果。

播放声音效果也很容易:

soundPool.play(explosionId, 1.0f, 1.0f, 0, 0, 1);

第一个参数是我们从 SoundPool.load()方法收到的句柄。接下来的两个参数指定用于左右声道的音量。这些值应该在 0(无声)和 1(耳朵爆炸)之间的范围内。

接下来是两个我们很少用到的论点。第一个是优先级,目前没有使用,应该设置为 0。另一个参数指定声音效果循环的频率。不推荐循环音效,所以这里一般应该用 0。最后一个参数是回放速率。将其设置为高于 1 的值将允许声音效果以比录制时更快的速度回放,而将其设置为低于 1 的值将导致回放速度变慢。

当我们不再需要声音效果并希望释放一些内存时,我们可以使用 SoundPool.unload()方法:

soundPool.unload(explosionId);

我们只需为音效传递从 SoundPool.load()方法接收的句柄,它将从内存中卸载。

一般来说,我们在游戏中会有一个 SoundPool 实例,我们将根据需要使用它来加载、播放和卸载音效。当我们完成所有的音频输出并且不再需要 SoundPool 时,我们应该总是调用 SoundPool.release()方法,这将释放 SoundPool 通常使用的所有资源。发布之后,你当然不能再使用 SoundPool 了。此外,该 SoundPool 加载的所有声音效果都将消失。

让我们编写一个简单的测试活动,它将在我们每次点击屏幕时播放爆炸声音效果。我们已经知道了实现这个需要知道的一切,所以清单 4-9 应该不会有什么大的惊喜。

清单 4-9。【SoundPoolTest.java】;播放音效

package com.badlogic.androidgames;

import java.io.IOException;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.media.AudioManager;
import android.media.SoundPool;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.widget.TextView;

public class SoundPoolTest extends Activity implements OnTouchListener {
    SoundPool soundPool;
    int explosionId = -1;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        TextView textView = new TextView(this );
        textView.setOnTouchListener(this );
        setContentView(textView);

        setVolumeControlStream(AudioManager.*STREAM*_*MUSIC*);
        soundPool = new SoundPool(20, AudioManager.*STREAM*_*MUSIC*, 0);

        try {
            AssetManager assetManager = getAssets();
            AssetFileDescriptor descriptor = assetManager
                    .openFd("explosion.ogg");
            explosionId = soundPool.load(descriptor, 1);
        }catch (IOException e) {
            textView.setText("Couldn't load sound effect from asset, "
                    + e.getMessage());
        }
    }

    public boolean onTouch(View v, MotionEvent event) {
        if (event.getAction() == MotionEvent.*ACTION*_*UP*) {
            if (explosionId != -1) {
                soundPool.play(explosionId, 1, 1, 0, 0, 1);
            }
        }
        return true ;
    }
}

我们首先从 Activity 派生出我们的类,并让它实现 OnTouchListener 接口,这样我们以后就可以处理屏幕上的点击。我们的类有两个成员:SoundPool 和我们将要加载和播放的音效句柄。我们最初将其设置为–1,表示音效尚未加载。

在 onCreate()方法中,我们做了以前做过几次的事情:创建一个 TextView,将活动注册为 OnTouchListener,并将 TextView 设置为内容视图。

下一行设置音量控制来控制音乐流,如前所述。然后我们创建 SoundPool,并对其进行配置,使其可以同时播放 20 种效果。这对大多数游戏来说应该足够了。

最后,我们从 AssetManager 获得一个放在 assets/目录中的 explosion.ogg 文件的 AssetFileDescriptor。要加载声音,我们只需将描述符传递给 SoundPool.load()方法并存储返回的句柄。SoundPool.load()方法会在加载过程中出现问题时抛出异常,在这种情况下,我们会捕捉到异常并显示一条错误消息。

在 onTouch()方法中,我们简单地检查手指是否抬起,这表示屏幕被点击。如果是这种情况,并且爆炸声音效果被成功加载(由句柄不为–1 指示),我们简单地回放该声音效果。

当你执行这个小活动时,只需轻触屏幕就能让世界爆炸。如果您快速连续触摸屏幕,您会注意到声音效果会以重叠的方式播放多次。很难超过我们在 SoundPool 中配置的最大播放次数 20 次。但是,如果发生这种情况,当前播放的声音之一将被停止,以便为新请求的播放腾出空间。

请注意,在前面的示例中,我们没有卸载声音或释放 SoundPool。这是为了简洁。通常,当活动将要被销毁时,您会在 onPause()方法中释放 SoundPool。只要记住总是释放或卸载任何你不再需要的东西。

虽然 SoundPool 类非常容易使用,但是有几个注意事项您应该记住:

  • SoundPool.load()方法异步执行实际加载。这意味着在使用该声音效果调用 SoundPool.play()方法之前,您必须等待片刻,因为加载可能尚未完成。遗憾的是,没有办法检查音效何时加载完毕。这只有 SoundPool 的 SDK 版本 8 才有可能,我们希望支持所有 Android 版本。通常这没什么大不了的,因为在第一次播放声音效果之前,您很可能会加载其他资源。
  • 众所周知,SoundPool 在 MP3 文件和长声音文件方面存在问题,其中 long 被定义为“长于 5 到 6 秒”这两个问题都是没有记载的,所以没有严格的规则来决定你的音效会不会麻烦。一般来说,我们建议坚持使用 OGG 的音频文件,而不是 MP3,并在音频质量变差之前,尝试尽可能低的采样率和持续时间。

注意和我们讨论的任何 API 一样,SoundPool 中有更多的功能。我们简单地告诉过你,你可以循环音效。为此,您可以从 SoundPool.play()方法获得一个 ID,用于暂停或停止循环音效。如果您需要 SoundPool 的功能,请查看 Android 开发者网站上的 sound pool 文档。

流媒体音乐

小的音效适合 Android 应用从操作系统获得的有限堆内存。包含较长音乐片段的较大音频文件不适合。出于这个原因,我们需要将音乐流式传输到音频硬件,这意味着我们一次只能读取一小部分,足以将其解码为原始 PCM 数据并将其发送到音频芯片。

听起来很吓人。幸运的是,有 MediaPlayer 类,它为我们处理所有的事务。我们需要做的就是把它指向音频文件,告诉它回放。

实例化 MediaPlayer 类很简单:

MediaPlayer mediaPlayer = new MediaPlayer();

接下来,我们需要告诉 MediaPlayer 播放什么文件。这也是通过 AssetFileDescriptor 完成的:

AssetFileDescriptor descriptor = assetManager.openFd("music.ogg");
mediaPlayer.setDataSource(descriptor.getFileDescriptor(), descriptor.getStartOffset(), descriptor.getLength());

这比 SoundPool 的情况要复杂一些。MediaPlayer.setDataSource()方法不直接采用 AssetFileDescriptor。相反,它需要一个 FileDescriptor,我们通过 asset file descriptor . getfile descriptor()方法获得它。此外,我们必须指定音频文件的偏移量和长度。为什么要抵消?实际上,所有素材都存储在一个文件中。为了让 MediaPlayer 到达文件的开头,我们必须向它提供文件在包含的素材文件中的偏移量。

在开始播放音乐文件之前,我们必须再调用一个方法来准备 MediaPlayer 进行播放:

mediaPlayer.prepare();

这将实际打开该文件,并检查它是否可以被 MediaPlayer 实例读取和回放。从这里开始,我们可以自由播放音频文件,暂停,停止,设置为循环播放,并改变音量。

要开始回放,我们只需调用以下方法:

mediaPlayer.start();

请注意,只有在成功调用 MediaPlayer.prepare()方法之后,才能调用该方法(如果它抛出运行时异常,您会注意到)。

我们可以通过调用 pause()方法来暂停回放:

mediaPlayer.pause();

同样,只有当我们已经成功准备好 MediaPlayer 并开始播放时,调用此方法才有效。要恢复暂停的 MediaPlayer,我们可以再次调用 MediaPlayer.start()方法,无需任何准备。

要停止回放,我们调用下面的方法:

mediaPlayer.stop();

注意,当我们想要启动一个停止的 MediaPlayer 时,我们首先必须再次调用 MediaPlayer.prepare()方法。

我们可以使用以下方法设置 MediaPlayer 循环播放:

mediaPlayer.setLooping(true);

要调节音乐播放的音量,我们可以用这个方法:

mediaPlayer.setVolume(1, 1);

这将设置左右声道的音量。文档没有指定这两个参数必须在什么范围内。根据实验,有效范围似乎在 0 和 1 之间。

最后,我们需要一种方法来检查回放是否已经完成。我们可以用两种方法做到这一点。首先,我们可以向 MediaPlayer 注册一个 OnCompletionListener,它将在回放完成时被调用:

mediaPlayer.setOnCompletionListener(listener);

如果我们想要轮询 MediaPlayer 的状态,我们可以使用以下方法:

boolean isPlaying = mediaPlayer.isPlaying();

请注意,如果 MediaPlayer 设置为循环,前面的方法都不会指示 MediaPlayer 已经停止。

最后,如果我们完成了 MediaPlayer 实例,我们通过调用以下方法来确保它占用的所有资源都被释放:

mediaPlayer.release();

在丢弃实例之前总是这样做被认为是一种好的做法。

如果我们没有将 MediaPlayer 设置为循环播放,并且播放已经完成,我们可以通过再次调用 MediaPlayer.prepare()和 MediaPlayer.start()方法来重新启动 MediaPlayer。

这些方法中的大部分都是异步工作的,所以即使您调用了 MediaPlayer.stop(),MediaPlayer.isPlaying()方法也可能在此后的一小段时间内返回。我们通常不担心这个。在大多数游戏中,我们将 MediaPlayer 设置为循环播放,然后在需要时停止播放(例如,当我们切换到不同的屏幕播放其他音乐时)。

让我们编写一个小的测试活动,其中我们以循环模式从素材/目录中回放一个声音文件。这种声音效果将根据活动的生命周期暂停和恢复,当我们的活动暂停时,音乐也应该暂停,当活动恢复时,音乐播放应该从它停止的地方继续。清单 4-10 展示了这是如何做到的。

清单 4-10。【MediaPlayerTest.java】;播放音频流

package com.badlogic.androidgames;

import java.io.IOException;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.os.Bundle;
import android.widget.TextView;

public class MediaPlayerTest extends Activity {
    MediaPlayer mediaPlayer;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        TextView textView = new TextView(this );
        setContentView(textView);

        setVolumeControlStream(AudioManager.*STREAM*_*MUSIC*);
        mediaPlayer = new MediaPlayer();
        try {
            AssetManager assetManager = getAssets();
            AssetFileDescriptor descriptor = assetManager.openFd("music.ogg");
            mediaPlayer.setDataSource(descriptor.getFileDescriptor(),
                    descriptor.getStartOffset(), descriptor.getLength());
            mediaPlayer.prepare();
            mediaPlayer.setLooping(true );
        }catch (IOException e) {
            textView.setText("Couldn't load music file, " + e.getMessage());
            mediaPlayer = null ;
        }
    }

    @Override
    protected void onResume() {
        super .onResume();
        if (mediaPlayer != null ) {
            mediaPlayer.start();
        }
    }

    protected void onPause() {
        super .onPause();
        if (mediaPlayer != null ) {
            mediaPlayer.pause();
            if (isFinishing()) {
                mediaPlayer.stop();
                mediaPlayer.release();
            }
        }
    }
}

我们以活动成员的形式保留对 MediaPlayer 的引用。在 onCreate()方法中,我们只是像往常一样创建一个 TextView 来输出任何错误消息。

在我们开始使用 MediaPlayer 之前,我们要确保音量控制确实能控制音乐流。设置好之后,我们实例化 MediaPlayer。我们从 AssetManager 中为位于 assets/ directory 中的一个名为 music.ogg 的文件获取 AssetFileDescriptor,并将其设置为 MediaPlayer 的数据源。剩下要做的就是准备 MediaPlayer 实例,并将其设置为循环流。为了防止出错,我们将 MediaPlayer 成员设置为 null,这样我们可以在以后确定加载是否成功。此外,我们向 TextView 输出一些错误文本。

在 onResume()方法中,我们只需启动 MediaPlayer(如果创建成功的话)。onResume()方法是实现这一点的最佳位置,因为它是在 onCreate()和 onPause()之后调用的。第一种情况,它会第一次开始播放;在第二种情况下,它将简单地恢复暂停的 MediaPlayer。

onResume()方法暂停 MediaPlayer。如果活动将被终止,我们停止媒体播放器,然后释放它的所有资源。

如果你在玩这个,确保你也测试了它对暂停和恢复活动的反应,通过锁定屏幕或者暂时切换到主屏幕。恢复播放时,MediaPlayer 将从暂停时停止的地方继续播放。

以下是一些需要记住的事情:

  • 方法 MediaPlayer.start()、MediaPlayer.pause()和 MediaPlayer.resume()只能在特定的状态下调用,就像刚才讨论的那样。当你还没有准备好媒体播放器的时候,千万不要打电话给他们。仅在准备好 MediaPlayer 之后,或者在通过调用 MediaPlayer.pause()显式暂停 MediaPlayer 之后想要恢复 MediaPlayer 时,才调用 MediaPlayer.start()。
  • MediaPlayer 实例相当重量级。将它们实例化会占用大量的资源。我们应该总是尝试只有一个音乐播放。SoundPool 类可以更好地处理声音效果。
  • 记得设置音量控制来处理音乐流,否则你的玩家将无法调整游戏的音量。

我们几乎完成了这一章,但是一个大的主题仍然摆在我们面前:2D 图形。

基本图形编程

Android 为我们提供了两个大的在屏幕上绘图的 API。一个主要用于简单的 2D 图形编程,另一个用于硬件加速的 3D 图形编程。这一章和下一章将集中讨论用 Canvas API 进行 2D 图形编程,Canvas API 是 Skia 库的一个很好的包装器,适用于中等复杂的 2D 图形。从第七章开始,我们将研究用 OpenGL 渲染 2D 和 3D 图形。在此之前,我们首先需要讨论两件事:唤醒锁和全屏。

使用唤醒锁

如果你把我们写的测试放在一边几秒钟,你的手机屏幕就会变暗。只有当你触摸屏幕或按下按钮时,屏幕才会恢复到最大亮度。为了让我们的屏幕一直保持清醒,我们可以使用唤醒锁

我们需要做的第一件事是在 manifest 文件中添加一个名为 android.permission.WAKE_LOCK 的适当的标记。这将允许我们使用 WakeLock 类。

我们可以从 PowerManager 中获得一个 WakeLock 实例,如下所示:

PowerManager powerManager = (PowerManager)context.getSystemService(Context.POWER_SERVICE);
WakeLock wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "My Lock");

像所有其他系统服务一样,我们从上下文实例中获取 PowerManager。PowerManager.newWakeLock()方法有两个参数:锁的类型和一个我们可以自由定义的标记。有几种不同的唤醒锁类型;出于我们的目的,电源管理器。完整 _ 唤醒 _ 锁定类型是正确的类型。它将确保屏幕将保持打开,CPU 将全速工作,键盘将保持启用。

要启用唤醒锁,我们必须调用它的 acquire()方法:

wakeLock.acquire();

从这一点开始,无论多长时间没有用户交互,手机都将保持唤醒状态。当我们的应用暂停或被破坏时,我们必须再次禁用或释放唤醒锁:

wakeLock.release();

通常我们在 Activity.onCreate()方法上实例化 WakeLock 实例,在 Activity.onResume()方法中调用 WakeLock.acquire(),在 Activity.onPause()方法中调用 WakeLock.release()方法。这样,我们保证我们的应用在被暂停或恢复的情况下仍然表现良好。因为只有四行代码要添加,所以我们不打算写一个完整的例子。相反,我们建议您只需将代码添加到下一节的全屏示例中,并观察效果。

全屏显示

在我们开始用 Android APIs 绘制我们的第一批图形之前,让我们先解决一些别的问题。到目前为止,我们所有的活动都显示了标题栏。通知栏也是可见的。我们想通过去掉这些来让我们的玩家更加沉浸其中。我们可以通过两个简单的调用来实现:

requestWindowFeature(Window.FEATURE_NO_TITLE);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);

第一个调用去掉了活动的标题栏。为了让活动全屏显示,从而消除通知栏,我们调用第二个方法。注意,我们必须在设置活动的内容视图之前调用这些方法。

清单 4-11 显示了一个非常简单的测试活动,演示了如何全屏显示。

清单 4-11。【FullScreenTest.java】;让我们的活动全屏进行

package com.badlogic.androidgames;

import android.os.Bundle;
import android.view.Window;
import android.view.WindowManager;

public class FullScreenTest extends SingleTouchTest {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        requestWindowFeature(Window.*FEATURE*_*NO*_*TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*,
                WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*);
        super .onCreate(savedInstanceState);
    }
}

这里发生了什么事?我们简单地从前面创建的 TouchTest 类派生并覆盖 onCreate()方法。在 onCreate()方法中,我们启用全屏模式,然后调用超类的 onCreate()方法(在本例中,是 TouchTest 活动),这将设置所有其余的活动。再次注意,我们必须在设置内容视图之前调用这两个方法。因此,在我们执行这两个方法之后,超类 onCreate()方法被调用。

我们还在清单文件中将活动的方向固定为纵向模式。您没有忘记在我们编写的每个测试的清单文件中添加元素,对吗?从现在开始,我们将总是把它固定为纵向模式或横向模式,因为我们不希望坐标系一直在变化。

通过从 TouchTest 派生,我们有了一个完全可用的示例,现在我们可以用它来探索我们将要绘制的坐标系。该活动将显示您触摸屏幕的坐标,就像在旧的 TouchTest 示例中一样。这次不同的是,我们是全屏的,这意味着我们触摸事件的最大坐标等于屏幕分辨率(每个维度减一,因为我们从[0,0]开始)。对于 Nexus One,在纵向模式下,坐标系将跨越坐标(0,0)到(479,799)(总共 480×800 像素)。

虽然看起来屏幕是连续重绘的,但实际上不是。请记住,在我们的 TouchTest 类中,每次处理触摸事件时,我们都会更新 TextView。这反过来会使 TextView 重绘自身。如果我们不触摸屏幕,文本视图不会自己重绘。对于一个游戏,我们需要尽可能频繁地重绘屏幕,最好是在我们的主循环线程中。我们将从简单开始,从 UI 线程中的连续呈现开始。

UI 线程中的连续呈现

到目前为止,我们所做的只是在需要时设置 TextView 的文本。实际的渲染是由 TextView 本身执行的。让我们创建自己的自定义视图,它的唯一目的是让我们在屏幕上绘制内容。我们还希望它尽可能经常地重画自己,并且我们希望在那个神秘的重画方法中有一个简单的方法来执行我们自己的绘制。

虽然这听起来可能很复杂,但实际上 Android 让我们很容易就能创建这样的东西。我们所要做的就是创建一个从 View 类派生的类,并覆盖一个名为 View.onDraw()的方法。每当 Android 系统需要我们的视图重绘自己时,它都会调用这个方法。这可能是这样的:

class RenderView extends View {
    public RenderView(Context context) {
        super (context);
    }

    protected void onDraw(Canvas canvas) {
        // to be implemented
    }
}

不完全是火箭科学,是吗?我们将一个名为 Canvas 的类的实例传递给 onDraw()方法。这将是我们在下面几节中的主要工作。它允许我们将形状和位图绘制到另一个位图或视图(或表面,我们稍后会谈到)。

我们可以像使用 TextView 一样使用这个 RenderView。我们只是将它设置为活动的内容视图,并连接我们需要的任何输入侦听器。然而,它还不是那么有用,有两个原因:它实际上并不绘制任何东西,即使它能够绘制某些东西,它也只会在需要重绘活动时才会这样做(也就是说,当它被创建或恢复时,或者当一个与它重叠的对话框变得不可见时)。怎么才能让它自己重画?

简单,像这样:

protected void onDraw(Canvas canvas) {
    // all drawing goes here
    invalidate();
}

onDraw()末尾对 View.invalidate()方法的调用将告诉 Android 系统一旦找到时间就重新绘制 RenderView。所有这些仍然发生在 UI 线程上,这有点像一匹懒马。然而,我们实际上用 onDraw()方法进行了连续渲染,尽管连续渲染相对较慢。我们稍后会解决这个问题;目前,它足以满足我们的需求。

那么,让我们回到神秘的画布类。这是一个非常强大的类,它封装了一个名为 Skia 的自定义低级图形库,专门用于在 CPU 上执行 2D 渲染。Canvas 类为我们提供了许多绘制各种形状、位图甚至文本的方法。

绘制方法绘制到哪里?那得看情况。画布可以呈现为位图实例;位图是由 Android 的 2D API 提供的另一个类,我们将在本章后面研究它。在这种情况下,它绘制到视图在屏幕上占据的区域。当然,这是一种疯狂的过度简化。在底层,它不会直接绘制到屏幕上,而是绘制到某种位图上,系统稍后会将该位图与活动的所有其他视图的位图结合使用,以合成最终的输出图像。然后,该图像将被移交给 GPU,GPU 将通过另一组神秘的路径将其显示在屏幕上。

我们真的不需要关心细节。从我们的角度来看,我们的视图似乎延伸到整个屏幕,所以它也可能是绘制到系统的帧缓冲区。在接下来的讨论中,我们将假设我们直接绘制到 framebuffer,系统为我们做所有漂亮的事情,如垂直回扫和双缓冲。

只要系统允许,就会调用 onDraw()方法。对我们来说,它非常类似于我们理论游戏主循环的主体。如果我们要用这个方法实现一个游戏,我们要把所有的游戏逻辑都放在这个方法中。出于各种原因,我们不会这样做,性能是其中之一。

所以让我们做一些有趣的事情。每次访问新的绘图 API 时,编写一个小测试来检查屏幕是否真的频繁重绘。有点像穷人的灯光秀。在每次调用 redraw 方法时,您需要做的就是用一种新的随机颜色填充屏幕。这样,您只需要找到允许您填充屏幕的那个 API 的方法,而不需要了解很多细节。让我们用我们自己的自定义 RenderView 实现来编写这样一个测试。

画布用特定颜色填充其呈现目标的方法称为 Canvas.drawRGB():

Canvas.drawRGB(int r, int g, int b);

r、g 和 b 参数分别代表我们将用来填充“屏幕”的颜色的一个分量。它们中的每一个都必须在 0 到 255 的范围内,所以我们实际上在这里指定了 RGB888 格式的颜色。如果你不记得关于颜色的细节,再看一下第三章的“数字编码颜色”一节,因为我们将在本章的其余部分使用这些信息。

清单 4-12 显示了我们的小灯光秀的代码。

Caution Running this code will rapidly fill the screen with a random color. If you have epilepsy or are otherwise light-sensitive in any way, don’t run it.

清单 4-12。RenderViewTest 活动

package com.badlogic.androidgames;

import java.util.Random;

import android.app.Activity;
import android.content.Context;
import android.graphics.Canvas;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;

public class RenderViewTest extends Activity {
    class RenderView extends View {
        Random rand = new Random();

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

        protected void onDraw(Canvas canvas) {
            canvas.drawRGB(rand.nextInt(256), rand.nextInt(256),
                    rand.nextInt(256));
            invalidate();
        }
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        requestWindowFeature(Window.*FEATURE*_*NO*_*TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*,
                WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*);
        setContentView(new RenderView(this ));
    }
}

对于我们的第一个图形演示,这是非常简洁的。我们将 RenderView 类定义为 RenderViewTest 活动的内部类。如前所述,RenderView 类派生自 View 类,具有一个强制构造函数和一个被覆盖的 onDraw()方法。它还有一个 Random 类的实例作为成员;我们将用它来生成我们的随机颜色。

onDraw()方法非常简单。我们首先告诉画布用随机颜色填充整个视图。对于每个颜色分量,我们简单地指定一个 0 到 255 之间的随机数(Random.nextInt()是唯一的)。之后,我们告诉系统我们希望尽快再次调用 onDraw()方法。

活动的 onCreate()方法启用全屏模式,并将 RenderView 类的一个实例设置为内容视图。为了使示例简短,我们现在不考虑唤醒锁。

截取这个例子的截图有点没有意义。它所做的只是在 UI 线程上以系统允许的最快速度用随机颜色填充屏幕。这没什么值得大书特书的。让我们做一些更有趣的事情:画一些形状。

注意前面的连续渲染方法可以工作,但是我们强烈建议不要使用它!我们应该在 UI 线程上做尽可能少的工作。一分钟后,我们将使用一个单独的线程来讨论如何正确地做到这一点,稍后我们还可以实现我们的游戏逻辑。

获取屏幕分辨率(和坐标系)

在第二章中,我们讨论了很多关于帧缓冲区及其属性的内容。请记住,帧缓冲区保存了屏幕上显示的像素的颜色。我们可用的像素数是由屏幕分辨率定义的,屏幕分辨率是由屏幕的宽度和高度(以像素为单位)给出的。

现在,使用我们的自定义视图实现,我们实际上并不直接渲染到帧缓冲区。然而,由于我们的视角跨越了整个屏幕,我们可以假装它是这样的。为了知道我们可以在哪里渲染我们的游戏元素,我们需要知道 x 轴和 y 轴上有多少像素,或者屏幕的宽度和高度。

Canvas 类有两个方法为我们提供这些信息:

int width = canvas.getWidth();
int height = canvas.getHeight();

这将返回画布呈现的目标的宽度和高度(以像素为单位)。请注意,根据我们活动的方向,宽度可能小于或大于高度。例如,HTC Thunderbolt 在纵向模式下的分辨率为 480×800 像素,因此 Canvas.getWidth()方法将返回 480,Canvas.getHeight()方法将返回 800。在横向模式下,这两个值只是简单地交换:Canvas.getWidth()将返回 800,Canvas.getHeight()将返回 480。

我们需要知道的第二条信息是我们渲染的坐标系统的组织。首先,只有整数像素坐标才有意义(有个概念叫子像素,但我们会忽略)。我们还知道,在纵向模式和横向模式下,坐标系的原点(0,0)总是在显示屏的左上角。正 x 轴总是指向右侧,y 轴总是指向下方。图 4-12 显示了一个分辨率为 48×32 像素的假想屏幕,处于横向模式。

9781430246770_Fig04-12.jpg

图 4-12。48×32 像素宽屏幕的坐标系

注意图 4-12 中坐标系的原点是如何与屏幕左上角的像素重合的。因此,屏幕左下角的像素不是我们预期的(48,32),而是(47,31)。通常,(width–1,height–1)总是屏幕右下角像素的位置。

图 4-12 显示了一个横向模式下的假想屏幕坐标系。到现在为止,你应该能够想象在纵向模式下坐标系是什么样子了。

Canvas 的所有绘制方法都是在这种坐标系下操作的。通常,我们可以寻址比 48×32 像素(例如 800×480)更多的像素。也就是说,让我们最后画一些像素、线条、圆形和矩形。

注意您可能已经注意到不同的设备可能有不同的屏幕分辨率。我们将在下一章研究这个问题。现在,让我们把注意力集中在最终让我们自己在屏幕上有所作为。

画简单的形状

深入到第四章,我们终于开始绘制我们的第一个像素。我们将快速浏览 Canvas 类提供给我们的一些绘图方法。

绘图像素

我们首先要解决的是如何绘制单个像素。那是用下面的方法完成的:

Canvas.drawPoint(float x, float y, Paint paint);

需要立即注意的两件事是,像素的坐标是用 floats 指定的,Canvas 不允许我们直接指定颜色,而是希望我们提供 Paint 类的一个实例。

不要被我们将坐标指定为浮点数的事实所迷惑。Canvas 有一些非常高级的功能,允许我们渲染到非整数坐标,这就是它的来源。不过,我们现在还不需要这个功能;我们将在下一章回到它。

Paint 类保存用于绘制形状、文本和位图的样式和颜色信息。对于绘制形状,我们只对两件事感兴趣:颜料的颜色和风格。既然一个像素并没有真正的风格,那我们就先集中在颜色上。下面是我们如何实例化 Paint 类并设置颜色:

Paint paint = new Paint();
paint.setARGB(alpha, red, green, blue);

实例化 Paint 类相当容易。Paint.setARGB()方法也应该很容易破译。每个参数代表颜色的一种颜色成分,范围从 0 到 255。因此,我们在这里指定了 ARGB8888 颜色。

或者,我们可以使用以下方法来设置 Paint 实例的颜色:

Paint.setColor(0xff00ff00);

我们向该方法传递一个 32 位整数。它再次编码 ARGB8888 颜色;在这种情况下,它是 alpha 设置为完全不透明的绿色。Color 类定义了一些静态常量,这些常量对一些标准颜色进行编码,比如 Color。红色,彩色。黄色,等等。如果您不想自己指定十六进制值,可以使用这些。

画线

要画一条线,我们可以使用下面的画布方法:

Canvas.drawLine(float startX, float startY, float stopX, float stopY, Paint paint);

前两个参数指定线条起点的坐标,接下来的两个参数指定线条终点的坐标,最后一个参数指定 Paint 实例。画出的线将有一个像素厚。如果我们希望线条更粗,我们可以通过设置 Paint 实例的笔画宽度来以像素为单位指定线条的粗细:

Paint.setStrokeWidth(float widthInPixels);

绘制矩形

我们也可以用下面的画布方法画矩形:

Canvas.drawRect(float topleftX, float topleftY, float bottomRightX, float bottomRightY, Paint paint);

前两个参数指定矩形左上角的坐标,后两个参数指定矩形左下角的坐标,而 Paint 指定矩形的颜色和样式。那么我们可以有什么风格,如何设置呢?

要设置 Paint 实例的样式,我们调用以下方法:

Paint.setStyle(Style style);

Style 是具有值 Style 的枚举。填充,样式。笔画和风格。填充和描边。如果我们指定风格。填充,矩形将被填充油漆的颜色。如果我们指定风格。STROKE,将只绘制矩形的轮廓,同样使用绘画的颜色和笔画宽度。如果风格。设置 FILL_AND_STROKE,矩形将被填充,轮廓将用给定的颜色和笔画宽度绘制。

画圆

画圆可以带来更多的乐趣,可以是实心的,也可以是描边的(或者两者都画):

Canvas.drawCircle(float centerX, float centerY, float radius, Paint paint);

前两个参数指定圆心的坐标,下一个参数以像素为单位指定半径,最后一个参数也是一个 Paint 实例。与 Canvas.drawRectangle()方法一样,将使用颜料的颜色和样式来绘制圆。

混合

最后一件重要的事情是,所有这些绘制方法都将执行阿尔法混合。只需将颜色的 alpha 指定为 255 (0xff)以外的值,您的像素、线条、矩形和圆形将是半透明的。

把这一切放在一起

让我们编写一个快速测试活动来演示前面的方法。这一次,我们希望你首先分析清单 4-13 中的代码。在纵向模式下,找出 480×800 屏幕上不同形状将被绘制的位置。当进行图形编程时,最重要的是想象你发出的绘图命令将如何表现。这需要一些练习,但真的会有回报。

清单 4-13。【ShapeTest.java】;疯狂画形状

package com.badlogic.androidgames;

import android.app.Activity;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;

public class ShapeTest extends Activity {
    class RenderView extends View {
        Paint paint;
        public RenderView(Context context) {
            super (context);
            paint = new Paint();
        }
        protected void onDraw(Canvas canvas) {
            canvas.drawRGB(255, 255, 255);
            paint.setColor(Color.*RED*);
            canvas.drawLine(0, 0, canvas.getWidth()-1, canvas.getHeight()-1, paint);
            paint.setStyle(Style.*STROKE*);
            paint.setColor(0xff00ff00);
            canvas.drawCircle(canvas.getWidth() / 2, canvas.getHeight() / 2, 40, paint);
            paint.setStyle(Style.*FILL*);
            paint.setColor(0x770000ff);
            canvas.drawRect(100, 100, 200, 200, paint);
            invalidate();
        }
    }
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        requestWindowFeature(Window.*FEATURE*_*NO*_*TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*,
                             WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*);
        setContentView(new RenderView(this ));
    }
}

你已经创造出那个心理图像了吗?那么我们来快速分析一下 RenderView.onDraw()方法。剩下的和上一个例子一样。

我们从用白色填充屏幕开始。接下来,我们从原点到屏幕的右下角画一条线。我们使用一种颜色设置为红色的颜料,所以这条线将是红色的。

接下来,我们稍微修改一下画图,将其样式设置为 style。笔画,其颜色为绿色,其阿尔法值为 255。使用我们刚刚修改的颜料,在屏幕的中心以 40 像素的半径绘制圆。由于绘画的风格,将只绘制圆的轮廓。

最后,我们再次修改油漆。我们把它的风格设定为风格。填充,颜色为全蓝色。请注意,我们这次将 alpha 设置为 0x77,这在十进制中等于 119。这意味着我们在下一次调用时绘制的形状大约有 50%是半透明的。

图 4-13 显示了纵向模式下 480×800 和 320×480 屏幕上测试活动的输出(黑色边框是后来添加的)。

9781430246770_Fig04-13.jpg

图 4-13。480×800 屏幕(左)和 320×480 屏幕(右)上的 ShapeTest 输出

天啊,这里发生了什么事?这就是我们在不同屏幕分辨率下用绝对坐标和大小渲染得到的结果。两幅图中唯一不变的是红线,它只是从左上角画到右下角。这是以独立于屏幕分辨率的方式完成的。

该矩形位于(100,100)处。根据屏幕分辨率,到屏幕中心的距离会有所不同。矩形的大小为 100×100 像素。在大屏幕上,它比在小屏幕上占用的相对空间要少得多。

圆的位置也是独立于屏幕分辨率的,但它的半径不是。因此,它在较小的屏幕上比在较大的屏幕上占据更多的相对空间。

我们已经看到,处理不同的屏幕分辨率可能会有点问题。当我们考虑不同的物理屏幕尺寸时,情况会变得更糟。然而,我们将在下一章尝试解决这个问题。请记住,屏幕分辨率和物理尺寸很重要。

注意画布和绘画课程提供的远不止我们刚刚谈到的内容。事实上,所有标准的 Android 视图都是用这个 API 绘制的,所以你可以想象它背后有更多的东西。像往常一样,查看 Android 开发者网站获取更多信息。

使用位图

虽然用线条或圆形等基本形状制作游戏是可能的,但这并不十分性感。我们希望一个令人敬畏的艺术家为我们创建精灵和背景以及所有的爵士乐,然后我们可以从 PNG 或 JPEG 文件加载。在 Android 上做到这一点极其容易。

加载和检查位图

位图类将成为我们最好的朋友。我们通过使用 BitmapFactory singleton 从文件中加载位图。当我们以素材的形式存储图像时,让我们看看如何从素材/目录中加载图像:

InputStream inputStream = assetManager.open("bob.png");
Bitmap bitmap = BitmapFactory.decodeStream(inputStream);

Bitmap 类本身有一些我们感兴趣的方法。首先,我们想知道位图实例的宽度和高度,以像素为单位:

int width = bitmap.getWidth();
int height = bitmap.getHeight();

接下来我们可能想知道位图实例的颜色格式:

Bitmap.Config config = bitmap.getConfig();

位图。Config 是具有以下值的枚举:

  • 配置文件。阿尔法 8 号
  • 配置。ARGB_4444
  • 配置。ARGB_8888
  • Config.RGB_565

从第三章开始,你应该知道这些值是什么意思。如果没有,我们强烈建议你再读一遍第三章的“数字编码颜色”一节。

有趣的是,没有 RGB888 颜色格式。PNG 仅支持 ARGB8888、RGB888 和托盘化颜色。什么颜色格式将用于加载 RGB888 PNG?BitmapConfig。RGB_565 就是答案。对于我们通过 BitmapFactory 加载的任何 RGB888 PNG,这都会自动发生。原因是大多数 Android 设备的实际帧缓冲区都支持这种颜色格式。加载每像素位深度更高的图像会浪费内存,因为像素无论如何都需要转换为 RGB565 以进行最终渲染。

那么为什么会有配置?ARGB_8888 的配置呢?因为图像合成可以在将最终图像绘制到帧缓冲区之前在 CPU 上完成。在 alpha 组件的情况下,我们也有比 Config 多得多的位深度。ARGB_4444,这可能是一些高质量的图像处理所必需的。

ARGB8888 PNG 图像将加载到带有配置文件的位图中。ARGB_8888 配置。其他两种颜色格式很少使用。然而,我们可以告诉 BitmapFactory 尝试加载一个特定颜色格式的图像,即使它的原始格式是不同的。

InputStream inputStream = assetManager.open("bob.png");
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.ARGB_4444;
Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null , options);

我们使用重载的 BitmapFactory.decodeStream()方法以 BitmapFactory 实例的形式传递提示。图像解码器的选项类。我们可以通过 BitmapFactory 来指定位图实例所需的颜色格式。Options.inPreferredConfig 成员,如前所示。在这个假设的例子中,bob.png 文件将是一个 ARGB8888 PNG,我们希望 BitmapFactory 加载它并将其转换为 ARGB4444 位图。但是,BitmapFactory 可以忽略这个提示。

这将释放该位图实例使用的所有内存。当然,调用此方法后,您不能再使用位图进行渲染。

您也可以使用以下静态方法创建空位图:

Bitmap bitmap = Bitmap.createBitmap(int width, int height, Bitmap.Config config);

如果你想自己进行自定义图像合成,这可能会派上用场。Canvas 类也适用于位图:

Canvas canvas = new Canvas(bitmap);

然后,您可以像修改视图内容一样修改位图。

处置位图

BitmapFactory 可以帮助我们在加载图像时减少内存占用。位图会占用很多内存,这在第三章中已经讨论过了。通过使用较小的颜色格式来减少每像素的位数是有帮助的,但是如果我们继续一个接一个地加载位图,最终我们会耗尽内存。因此,我们应该通过下面的方法来处理我们不再需要的位图实例:

Bitmap.recycle();

绘制位图

一旦我们加载了位图,我们就可以通过画布来绘制它们。最简单的方法如下:

Canvas.drawBitmap(Bitmap bitmap, float topLeftX, float topLeftY, Paint paint);

第一个论点应该是显而易见的。参数 topLeftX 和 topLeftY 指定位图左上角在屏幕上的坐标。最后一个参数可以为空。我们可以用 Paint 指定一些非常高级的绘图参数,但是我们并不真的需要这些。

还有另一种方法也会派上用场:

Canvas.drawBitmap(Bitmap bitmap, Rect src, Rect dst, Paint paint);

这个方法超级牛逼。它允许我们通过第二个参数指定位图的一部分。Rect 类保存矩形的左上角和右下角坐标。当我们通过 src 指定位图的一部分时,我们是在位图的坐标系中进行的。如果我们指定 null,将使用完整的位图。

第三个参数定义在哪里绘制位图部分,同样以 Rect 实例的形式。这一次,角坐标是在画布目标的坐标系中给出的(无论是视图还是另一个位图)。令人惊讶的是,这两个矩形不一定要一样大。如果我们指定目标矩形比源矩形小,那么画布会自动缩放。当然,如果我们指定一个更大的目标矩形,情况也是如此。我们通常会将最后一个参数再次设置为 null。但是,请注意,这种缩放操作非常昂贵。我们应该只在绝对必要的时候使用它。

因此,您可能会想:如果我们有不同颜色格式的位图实例,在我们可以通过画布绘制它们之前,我们需要将它们转换成某种标准格式吗?答案是否定的。画布会自动为我们做这件事。当然,如果我们使用与本地帧缓冲区格式相同的颜色格式,速度会快一点。通常我们只是忽略这一点。

默认情况下,混合也是启用的,所以如果我们的图像每个像素包含一个 alpha 组件,它实际上是被解释的。

把这一切放在一起

有了所有这些信息,我们最终可以加载和渲染一些 bob。清单 4-14 显示了我们出于演示目的编写的 BitmapTest 活动的源代码。

清单 4-14。BitmapTest 活动

package com.badlogic.androidgames;

import java.io.IOException;
import java.io.InputStream;

import android.app.Activity;
import android.content.Context;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;

public class BitmapTest extends Activity {
    class RenderView extends View {
        Bitmap bob565;
        Bitmap bob4444;
        Rect dst = new Rect();
        public RenderView(Context context) {
            super (context);

            try {
                AssetManager assetManager = context.getAssets();
                InputStream inputStream = assetManager.open("bobrgb888.png");
                bob565 = BitmapFactory.*decodeStream*(inputStream);
                inputStream.close();
                Log.*d*("BitmapText",
                        "bobrgb888.png format: " + bob565.getConfig());

                inputStream = assetManager.open("bobargb8888.png");
                BitmapFactory.Options options = new BitmapFactory.Options();
                options.inPreferredConfig = Bitmap.Config.*ARGB*_*4444*;
                bob4444 = BitmapFactory
                        .*decodeStream*(inputStream, null , options);
                inputStream.close();
                Log.*d*("BitmapText",
                        "bobargb8888.png format: " + bob4444.getConfig());

            }catch (IOException e) {
                // silently ignored, bad coder monkey, baaad!
            }finally {
                // we should really close our input streams here.
            }
        }

        protected void onDraw(Canvas canvas) {
            canvas.drawRGB(0, 0, 0);
            dst.set(50, 50, 350, 350);
            canvas.drawBitmap(bob565, null , dst, null );
            canvas.drawBitmap(bob4444, 100, 100, null );
            invalidate();
        }
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        requestWindowFeature(Window.*FEATURE*_*NO*_*TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*,
                WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*);
        setContentView(new RenderView(this ));
    }
}

我们的 activity 的 onCreate()方法是旧的,所以让我们继续我们的自定义视图。它有两个位图成员,一个以 RGB565 格式存储 Bob 的图像(在第三章中介绍过),另一个以 ARGB4444 格式存储 Bob 的图像。我们还有一个 Rect 成员,在那里我们存储了用于呈现的目标矩形。

在 RenderView 类的构造函数中,我们首先将 Bob 加载到视图的 bob565 成员中。请注意,图像是从 RGB888 PNG 文件加载的,BitmapFactory 会自动将其转换为 RGB565 图像。为了证明这一点,我们还输出了位图。将位图配置到 LogCat。Bob 的 RGB888 版本具有不透明的白色背景,因此不需要执行任何混合。

接下来,我们从存储在 assets/ directory 中的 ARGB8888 PNG 文件加载 Bob。为了节省一些内存,我们还告诉 BitmapFactory 将 Bob 的图像转换为 ARGB4444 位图。工厂可能不会遵守这一要求(原因不明)。为了看它对我们是否友好,我们输出了位图。这个位图的配置文件。

onDraw()方法微不足道。我们所做的就是用黑色填充屏幕,绘制缩放到 250×250 像素的 bob565(从他的原始大小 160×183 像素),并在 bob565 的顶部绘制 bob4444,未缩放但已混合(这是由画布自动完成的)。图 4-14 展示了这两个 bob 的辉煌。

9781430246770_Fig04-14.jpg

图 4-14。上下重叠的两个 bob(分辨率为 480×800 像素)

LogCat 报告说 bob565 确实有颜色格式配置。RGB_565,bob4444 被转换为 Config。ARGB_4444。位图工厂没有让我们失望!

这里有一些你应该从这一部分学到的东西:

  • 使用尽可能少的颜色格式来节省内存。但是,这可能会降低视觉质量和渲染速度。
  • 除非绝对必要,否则不要绘制缩放的位图。如果您知道它们的缩放大小,请离线或在加载时预缩放它们。
  • 如果不再需要位图,请务必调用 Bitmap.recycle()方法。否则,你会得到一些内存泄漏或运行内存不足。

一直使用 LogCat 进行文本输出有点乏味。让我们看看如何通过画布呈现文本。

注意和其他类一样,位图有比我们在这个简短的部分所能描述的更多的东西。我们涵盖了给诺姆先生写信所需的最低限度。如果您想了解更多信息,请查看 Android 开发者网站上的文档。

渲染文本

虽然我们将在 Mr. Nom 游戏中输出的文本将由手工绘制,但了解如何通过 TrueType 字体绘制文本并没有坏处。让我们从从 assets/ directory 加载一个定制的 TrueType 字体文件开始。

加载字体

Android API 为我们提供了一个名为 Typeface 的类,它封装了一种 TrueType 字体。它提供了一个简单的静态方法来从 assets/ directory: 加载这样一个字体文件

Typeface font = Typeface.*createFromAsset*(context.getAssets(), "font.ttf");

有趣的是,如果字体文件无法加载,这个方法不会抛出任何异常。相反,会引发 RuntimeException。为什么这个方法没有显式抛出异常是一个谜。

用字体绘制文本

一旦我们有了自己的字体,我们就将它设置为 Paint 实例的字样:

paint.setTypeFace(font);

通过 Paint 实例,我们还指定了想要呈现字体的大小:

paint.setTextSize(30);

这种方法的文档也很少。它没有告诉我们文本大小是以磅还是像素给出的。我们只是假设后者。

最后,我们可以通过下面的 Canvas 方法用这种字体绘制文本:

canvas.drawText("This is a test!", 100, 100, paint);

第一个参数是要绘制的文本。接下来的两个参数是应该绘制文本的坐标。最后一个参数是熟悉的:它是 Paint 实例,指定要绘制的文本的颜色、字体和大小。通过设置绘画的颜色,您还可以设置要绘制的文本的颜色。

文本对齐和边界

现在,您可能想知道前面方法的坐标如何与文本字符串填充的矩形相关联。它们是否指定了包含文本的矩形的左上角?答案有点复杂。Paint 实例有一个名为对齐设置的属性。它可以通过画图类的这个方法来设置:

Paint.setTextAlign(Paint.Align align);

油漆。Align 枚举有三个值:Paint。对齐。向左,绘画。根据设置的对齐方式,传递给 Canvas.drawText()方法的坐标被解释为矩形的左上角、矩形的中上像素或矩形的右上角。标准对齐方式是 Paint.Align.LEFT。

有时知道特定字符串的边界(以像素为单位)也很有用。为此,Paint 类提供了以下方法:

Paint.getTextBounds(String text, int start, int end, Rect bounds);

第一个参数是我们想要得到界限的字符串。第二个和第三个参数指定应该测量的字符串中的开始字符和结束字符。end 参数是排他的。最后一个参数 bounds 是一个 Rect 实例,我们自己分配并传递给方法。该方法会将边框的宽度和高度写入 Rect.right 和 Rect.bottom 字段。为了方便起见,我们可以调用 Rect.width()和 Rect.height()来获得相同的值。

请注意,所有这些方法都只适用于单行文本。如果要渲染多行,就得自己做布局。

把这一切放在一起

说够了:让我们做更多的编码。清单 4-15 展示了文本呈现的实际效果。

清单 4-15。font test 活动

package com.badlogic.androidgames;

import android.app.Activity;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;

public class FontTest extends Activity {
    class RenderView extends View {
        Paint paint;
        Typeface font;
        Rect bounds = new Rect();

        public RenderView(Context context) {
            super (context);
            paint = new Paint();
            font = Typeface.*createFromAsset*(context.getAssets(), "font.ttf");
        }

        protected void onDraw(Canvas canvas) {
            canvas.drawRGB(0, 0, 0);
            paint.setColor(Color.*YELLOW*);
            paint.setTypeface(font);
            paint.setTextSize(28);
            paint.setTextAlign(Paint.Align.*CENTER*);
            canvas.drawText("This is a test!", canvas.getWidth() / 2, 100,
                    paint);

            String text = "This is another test o_O";
            paint.setColor(Color.*WHITE*);
            paint.setTextSize(18);
            paint.setTextAlign(Paint.Align.*LEFT*);
            paint.getTextBounds(text, 0, text.length(), bounds);
            canvas.drawText(text, canvas.getWidth() - bounds.width(), 140,
                    paint);
            invalidate();
        }
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        requestWindowFeature(Window.*FEATURE*_*NO*_*TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*,
                WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*);
        setContentView(new RenderView(this ));
    }
}

我们不会讨论活动的 onCreate()方法,因为我们以前见过它。

我们的 RenderView 实现有三个成员:Paint、Typeface 和 Rect,稍后我们将在其中存储文本字符串的边界。

在构造函数中,我们创建一个新的 Paint 实例,并从 assets/目录中的 font.ttf 文件加载一个字体。

在 onDraw()方法中,我们用黑色清除屏幕,将颜料设置为黄色,设置字体及其大小,并指定在 Canvas.drawText()调用中解释坐标时要使用的文本对齐方式。实际的绘图调用渲染字符串这是一个测试!,在 y 轴上的坐标 100 处水*居中。

对于第二个文本呈现调用,我们做了其他事情:我们希望文本与屏幕的右边缘右对齐。我们可以通过使用油漆来实现。Align.RIGHT 和 canvas . getwidth()–1 的 x 坐标。相反,我们通过使用字符串的边界来练习非常基本的文本布局。我们还改变了颜色和字体的大小。图 4-15 显示了此活动的输出。

9781430246770_Fig04-15.jpg

图 4-15。文字趣味(480×800 像素分辨率)

Typeface 类的另一个神秘之处是,它没有明确允许我们释放它的所有资源。我们不得不依靠垃圾收集者来为我们做脏活。

注意我们在这里仅仅触及了文本渲染的表面。如果你想知道更多。。。现在你知道去哪里找了。

使用表面视图进行连续渲染

这是我们成为真正的男人和女人的部分。它涉及到线程,以及与之相关的所有痛苦。我们会活着度过的。我们保证!

动机

当我们第一次尝试连续渲染时,我们用了错误的方法。霸占 UI 线程是不可接受的;我们需要一个在单独的线程中完成所有脏活的解决方案。进入 SurfaceView。

顾名思义,SurfaceView 类是一个处理 Surface 的视图,这是 Android API 的另一个类。什么是曲面?它是一个原始缓冲区的抽象,由屏幕合成器用来渲染特定的视图。屏幕合成器是 Android 上所有渲染背后的主谋,它最终负责将所有像素推送到 GPU。在某些情况下,可以对表面进行硬件加速。不过,我们并不太关心这个事实。我们只需要知道,这是一种更直接的将事物渲染到屏幕上的方式。

我们的目标是在一个单独的线程中执行渲染,这样我们就不会占用忙于其他事情的 UI 线程。SurfaceView 类为我们提供了一种从一个线程而不是 UI 线程来渲染它的方法。

表面夹具和锁定

为了从一个不同于 UI 线程的线程渲染到 SurfaceView,我们需要获取 SurfaceHolder 类的一个实例,如下:

SurfaceHolder holder = surfaceView.getHolder();

SurfaceHolder 是表面的包装器,为我们做一些簿记工作。它为我们提供了两种方法:

Canvas SurfaceHolder.lockCanvas();
SurfaceHolder.unlockAndPost(Canvas canvas);

第一种方法锁定表面进行渲染,并返回一个我们可以使用的不错的 Canvas 实例。第二种方法再次解锁表面,并确保我们通过画布绘制的内容显示在屏幕上。我们将在渲染线程中使用这两种方法来获取画布,用它进行渲染,并最终使我们刚刚渲染的图像在屏幕上可见。我们必须传递给 SurfaceHolder.unlockAndPost()方法的画布必须是我们从 SurfaceHolder.lockCanvas()方法收到的画布。

实例化 SurfaceView 时,不会立即创建曲面。相反,它是异步创建的。每次暂停活动时都会破坏该表面,当活动恢复时会重新创建该表面。

表面创建和有效性

只要表面还没有生效,我们就不能从表面持有者那里获得画布。但是,我们可以通过下面的语句检查表面是否已经创建:

boolean isCreated = surfaceHolder.getSurface().isValid();

如果这个方法返回 true,我们就可以安全地锁定这个表面,并通过我们收到的画布绘制它。我们必须绝对确保在调用 SurfaceHolder.lockCanvas()后再次解锁 Surface,否则我们的活动可能会锁定手机!

把这一切放在一起

那么,我们如何将所有这些与单独的渲染线程以及活动生命周期集成在一起呢?解决这个问题的最好方法是查看一些实际的代码。清单 4-16 显示了一个完整的例子,它在一个单独的线程中对表面视图进行渲染。

清单 4-16。SurfaceViewTest 活动

package com.badlogic.androidgames;

import android.app.Activity;
import android.content.Context;
import android.graphics.Canvas;
import android.os.Bundle;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.Window;
import android.view.WindowManager;

public class SurfaceViewTest extends Activity {
    FastRenderView renderView;
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        requestWindowFeature(Window.*FEATURE*_*NO*_*TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*,
                             WindowManager.LayoutParams.*FLAG*_*FULLSCREEN*);
        renderView = new FastRenderView(this );
        setContentView(renderView);
    }
    protected void onResume() {
        super .onResume();
        renderView.resume();
    }
    protected void onPause() {
        super .onPause();
        renderView.pause();
    }
    class FastRenderView extends SurfaceViewimplements Runnable {
        Thread renderThread = null ;
        SurfaceHolder holder;
        volatile boolean running = false ;
        public FastRenderView(Context context) {
            super (context);
            holder = getHolder();
        }

        public void resume() {
            running = true ;
            renderThread = new Thread(this );
            renderThread.start();
        }
        public void run() {
            while (running) {
                if (!holder.getSurface().isValid())
                    continue ;
                Canvas canvas = holder.lockCanvas();
                canvas.drawRGB(255, 0, 0);
                holder.unlockCanvasAndPost(canvas);
            }
        }

        public void pause() {
            running = false ;
            while (true ) {
                try {
                    renderThread.join();
                    return ;
                }catch (InterruptedException e) {
                    // retry
                }
            }
        }
    }
}

这看起来没那么吓人,对吧?我们的活动将 FastRenderView 实例作为成员。这是一个自定义的 SurfaceView 子类,将为我们处理所有的线程业务和表面锁定。对于活动来说,它看起来像一个普通的视图。

在 onCreate()方法中,我们启用全屏模式,创建 FastRenderView 实例,并将其设置为活动的内容视图。

这次我们还覆盖了 onResume()方法。在这个方法中,我们将通过调用 FastRenderView.resume()方法间接启动我们的渲染线程,该方法在内部完成所有的工作。这意味着线程将在最初创建活动时启动(因为 onCreate()后面总是跟有对 onResume()的调用)。当活动从暂停状态恢复时,它也会重新启动。

这当然意味着我们必须在某个地方停止线程;否则,我们会在每次调用 onResume()时创建一个新线程。这就是 onPause()的用武之地。它调用 FastRenderView.pause()方法,这将完全停止线程。在线程完全停止之前,该方法不会返回。

所以我们来看看这个例子的核心类:FastRenderView。它类似于我们在前几个例子中实现的 RenderView 类,因为它是从另一个视图类派生的。在这种情况下,我们直接从 SurfaceView 类派生它。它还实现了 Runnable 接口,因此我们可以将它传递给渲染线程,以便它运行渲染线程逻辑。

FastRenderView 类有三个成员。renderThread 成员只是对负责执行渲染线程逻辑的线程实例的引用。holder 成员是对 SurfaceHolder 实例的引用,该实例是从派生它的 SurfaceView 超类中获得的。最后,running 成员是一个简单的布尔标志,我们将使用它来通知渲染线程应该停止执行。volatile 修饰符有一个特殊的含义,我们一会儿会讲到。

我们在构造函数中所做的就是调用超类构造函数,并将对 SurfaceHolder 的引用存储在 Holder 成员中。

接下来是 FastRenderView.resume()方法。它负责启动渲染线程。注意,每次调用这个方法时,我们都会创建一个新线程。这与我们在讨论活动的 onResume()和 onPause()方法时所讨论的一致。我们还将运行标志设置为 true。一会儿你会看到它是如何在渲染线程中使用的。最后要做的是,我们将 FastRenderView 实例本身设置为线程的 Runnable。这将在新线程中执行 FastRenderView 的下一个方法。

FastRenderView.run()方法是我们自定义视图类的核心。它的主体在渲染线程中执行。如您所见,它仅仅由一个循环组成,一旦 running 标志被设置为 false,该循环将停止执行。当这种情况发生时,线程也将停止并死亡。在 while 循环中,我们首先检查表面是否有效。如果是,我们锁定它,渲染它,并再次解锁它,如前所述。在这个例子中,我们简单地用红色填充表面。

FastRenderView.pause()方法看起来有点奇怪。首先,我们将运行标志设置为 false。如果稍微向上看一下,就会看到 FastRenderView.run()方法中的 while 循环最终会因此而终止,从而停止渲染线程。在接下来的几行中,我们只是通过调用 Thread.join()来等待线程完全死亡。此方法将等待线程死亡,但可能会在线程实际死亡之前引发 InterruptedException。因为在从那个方法返回之前,我们必须绝对确定线程是死的,所以我们在一个无限循环中执行 join,直到它成功。

让我们回到运行标志的 volatile 修饰符。我们为什么需要它?原因很微妙:如果编译器发现 FastRenderView.pause()方法中的第一行与 while 块之间没有依赖关系,它可能会决定对该方法中的语句进行重新排序。如果它认为这样做会使代码执行得更快,它是被允许这样做的。然而,我们依赖于在该方法中指定的执行顺序。想象一下,如果在我们尝试加入线程后设置了运行标志。我们会进入一个无限循环,因为线程永远不会终止。

volatile 修饰符防止这种情况发生。引用该成员的任何语句都将按顺序执行。这让我们远离了讨厌的海森堡——一个来来去去却无法持续复制的 bug。

还有一件事你可能认为会导致这段代码爆炸。如果在调用 SurfaceHolder.getSurface()的过程中销毁了曲面,会怎么样呢?isValid()和 SurfaceHolder.lock()?嗯,我们很幸运——这种事永远不会发生。为了理解为什么,我们必须后退一步,看看 Surface 的生命周期是如何工作的。

我们知道表面是异步创建的。很可能我们的渲染线程会在表面有效之前执行。我们通过不锁定表面来防止这种情况,除非它是有效的。这涵盖了曲面创建的情况。

在有效性检查和锁定之间,渲染线程代码不会从被破坏的表面开始爆炸的原因与表面被破坏的时间点有关。从活动的 onPause()方法返回后,表面总是被销毁。因为我们通过调用 FastRenderView.pause()来等待线程在该方法中死亡,所以当表面实际上被破坏时,渲染线程将不再是活动的。很性感,不是吗?但也很混乱。

我们现在以正确的方式进行连续渲染。我们不再独占 UI 线程,而是使用单独的渲染线程。我们还让它遵守活动生命周期,这样它就不会在后台运行,在活动暂停时消耗电池。整个世界又是一个快乐的地方。当然,我们需要将 UI 线程中输入事件的处理与渲染线程同步。但是这将会变得非常容易,你会在下一章看到,当我们基于你在这一章中理解的所有信息实现我们的游戏框架时。

使用 Canvas 进行硬件加速渲染

Android 3.0 (Honeycomb)增加了一个显著的功能,即支持标准 2D 画布绘制调用的 GPU 硬件加速。此功能的价值因应用和设备而异,因为一些设备实际上在 2D 利用 CPU 时性能会更好,而其他设备将受益于 GPU。在引擎盖下,硬件加速分析绘制调用,并将其转换为 OpenGL。例如,如果我们指定应该从 0,0 到 100,100 绘制一条线,那么硬件加速将使用 OpenGL 组织一个特殊的画线调用,并将其绘制到一个硬件缓冲区,稍后将合成到屏幕上。

启用这种硬件加速非常简单,只需将以下内容添加到 AndroidManifest.xml 的标签下:

android:hardwareAccelerated="true"

请确保在各种设备上打开和关闭加速来测试您的游戏,以确定它是否适合您。将来,让它一直开着可能没什么问题,但是和任何事情一样,我们建议你自己采取测试和决定的方法。当然,有更多的配置选项可以让你为特定的应用、活动、窗口或视图设置硬件加速,但因为我们是在做游戏,所以我们只计划每种都有一个,所以通过应用全局设置它将是最有意义的。

Android 这一功能的开发者 Romain Guy 有一篇非常详细的博客文章,介绍了硬件加速的注意事项和注意事项,以及使用它获得良好性能的一些通用指南。博客条目的网址是Android-developers . blogspot . com/2011/03/Android-30-hardware-acceleration . html

最佳实践

Android(或者更确切地说是 Dalvik)有时会有一些奇怪的性能特征。在本节中,我们将向您介绍一些最重要的最佳实践,您应该遵循这些实践来使您的游戏像丝绸一样流畅。

  • 垃圾收集者是你最大的敌人。一旦它获得 CPU 时间来做它的脏工作,它将停止世界长达 600 毫秒。这是半秒钟,你的游戏将不会更新或渲染。用户会抱怨。尽可能避免创建对象,尤其是在内部循环中。
  • 对象可能被创建在一些不太明显的地方,而这些地方是你想要避免的。不要使用迭代器,因为它们会创建新的对象。不要使用任何标准的集合或地图集合类,因为它们会在每次插入时创建新的对象;而是使用 Android API 提供的 SparseArray 类。使用 StringBuffers,而不是用+运算符连接字符串。这将每次创建一个新的 StringBuffer。为了这个世界上所有美好的事物,不要使用装箱的原语!
  • 与其他虚拟机相比,Dalvik 中的方法调用具有更大的关联成本。如果可以的话,使用静态方法,因为静态方法的性能最好。静态方法通常被认为是邪恶的,就像静态变量一样,因为它们促进了糟糕的设计,所以尽量保持你的设计整洁。也许你也应该避免 getters 和 setters。直接字段访问比不使用 JIT 编译器的方法调用快三倍,使用 JIT 编译器快七倍。然而,在移除所有的 getters 和 setters 之前,请考虑您的设计。
  • 浮点运算是在没有 JIT 编译器的旧设备和 Dalvik 版本(Android 版之前的任何版本)上用软件实现的。守旧派游戏开发者会立即退回到定点数学。也不要这样做,因为整数除法也很慢。大多数时候,您可以使用浮点,新的设备支持浮点单元(fpu ),一旦 JIT 编译器开始运行,速度会加快很多。
  • 尝试将频繁访问的值塞进方法内部的局部变量中。访问局部变量比访问成员或调用 getters 更快。

当然,你需要小心许多其他的事情。当上下文需要时,我们将在本书的其余部分添加一些性能提示。如果你遵循前面的建议,你应该是安全的。别让收垃圾的赢了就行!

摘要

这一章涵盖了为 Android 写一个像样的小 2D 游戏所需要知道的一切。我们看到了用一些默认设置建立一个新的游戏项目是多么容易。我们讨论了神秘的活动生命周期以及如何与之共存。我们与触摸(更重要的是多点触摸)事件进行了斗争,处理了按键事件,并通过加速度计检查了设备的方向。我们探索了如何读写文件。在 Android 上输出音频被证明是轻而易举的事情,除了 SurfaceView 的线程问题之外,在屏幕上绘制东西也不是很难。诺姆先生现在可以成为现实了——一个可怕的、饥饿的现实!

五、Android 游戏开发框架

你可能已经注意到了,我们已经读了四章,却没有写一行游戏代码。我们让你经历所有这些无聊的理论并让你实现测试程序的原因很简单:如果你想写游戏,你必须知道到底发生了什么。你不能只是从整个网络上复制和粘贴代码,并希望它将形成下一个第一人称射击游戏。到目前为止,您应该已经牢牢掌握了如何从头开始设计一个简单的游戏,如何为 2D 游戏开发构建一个好的 API,以及哪些 Android APIs 将提供实现您的想法所需的功能。

为了让 Nom 先生成为现实,我们必须做两件事:实现我们在第三章设计的游戏框架接口和类,并在此基础上,编写 Nom 先生的游戏机制。让我们从游戏框架开始,把我们在第三章中设计的和我们在第四章中讨论的结合起来。90%的代码你应该已经很熟悉了,因为我们在前一章的测试程序中已经介绍了大部分。

行动(或活动、袭击)计划

在第三章第一节中,我们为游戏框架设计了一个最小的设计,它抽象出了所有的*台细节,这样我们就可以专注于我们的目标:游戏开发。现在,我们将以自下而上的方式实现所有这些接口和抽象类,从最容易到最难。第三章的接口位于 com . badlogic . Android games . framework 包中,我们将这一章的实现放在 com . badlogic . Android games . framework . impl 包中,并指出它保存了 Android 框架的实际实现。我们将用 Android 作为所有接口实现的前缀,这样我们就可以将它们与接口区分开来。让我们从最简单的部分开始,文件 I/o。

本章和下一章的代码将被合并到一个 Eclipse 项目中。现在,你可以按照第四章中的步骤在 Eclipse 中创建一个新的 Android 项目。此时,您将默认活动命名为什么并不重要。

AndroidFileIO 类

最初的 FileIO 接口是精简的,也是低劣的。它包含四个方法:一个获取素材的输入流,另一个获取外部存储中文件的输入流,第三个返回外部存储设备上文件的输出流,最后一个获取游戏的共享首选项。在第四章中,您学习了如何使用 Android APIs 打开外部存储上的素材和文件。清单 5-1 基于来自第四章的知识,展示了 FileIO 接口的实现。

清单 5-1 。【AndroidFileIO.java】;实现 FileIO 接口

package com.badlogic.androidgames.framework.impl;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.AssetManager;
import android.os.Environment;
import android.preference.PreferenceManager;
import com.badlogic.androidgames.framework.FileIO;

public class AndroidFileIO implements FileIO {
    Context context;
    AssetManager assets;
    String externalStoragePath;

    public AndroidFileIO(Context context) {
        this.context = context;
        this.assets = context.getAssets();
        this.externalStoragePath = Environment.getExternalStorageDirectory()
                .getAbsolutePath() + File.separator;
    }
    public InputStream readAsset(String fileName) throws IOException {
        return assets.open(fileName);
    }
    public InputStream readFile(String fileName) throws IOException {
        return new FileInputStream(externalStoragePath + fileName);
    }
    public OutputStream writeFile(String fileName) throws IOException {
        return new FileOutputStream(externalStoragePath + fileName);
    }
    public SharedPreferences getPreferences() {
        return PreferenceManager.getDefaultSharedPreferences(context);
    }
}

一切都很简单。我们实现了 FileIO 接口,存储了 Context 实例,它是 Android 中几乎所有东西的网关,存储了一个 AssetManager,它是我们从上下文中提取的,存储了外部存储的路径,并基于该路径实现了四个方法。最后,我们传递任何抛出的 IOExceptions,这样我们就知道调用方是否有任何异常。

我们的游戏接口实现将保存这个类的一个实例,并通过 Game.getFileIO()返回它。这也意味着我们的游戏实现需要通过上下文才能让 AndroidFileIO 实例工作。

请注意,我们不检查外部存储是否可用。如果它不可用,或者如果我们忘记向清单文件添加适当的权限,我们将得到一个异常,因此检查错误是隐式的。现在,我们可以进入框架的下一部分,即音频。

机器人音频、机器人声音和机器人音乐:碰撞、撞击、撞击!

在第三章中,我们为我们所有的音频需求设计了三个界面:音频、声音和音乐。Audio 负责从资源文件创建声音和音乐实例。声音可以让我们播放存储在内存中的音效,音乐可以将更大的音乐文件从磁盘传输到声卡。在第四章中,你学习了实现这个需要哪些 Android APIs。我们将从 AndroidAudio 的实现开始,如清单 5-2 所示,并在适当的地方穿插解释文本。

清单 5-2 。【AndroidAudio.java】;实现音频接口

package com.badlogic.androidgames.framework.impl;

import java.io.IOException;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.media.AudioManager;
import android.media.SoundPool;

import com.badlogic.androidgames.framework.Audio;
import com.badlogic.androidgames.framework.Music;
import com.badlogic.androidgames.framework.Sound;
public class AndroidAudio implements Audio {
    AssetManager assets;
    SoundPool soundPool;

AndroidAudio 实现有一个 AssetManager 和一个 SoundPool 实例。在调用 AndroidAudio.newSound()时,AssetManager 是将声音效果从素材文件加载到 SoundPool 中所必需的。AndroidAudio 实例还管理 SoundPool。

    public AndroidAudio(Activity activity) {
        activity.setVolumeControlStream(AudioManager.*STREAM_MUSIC*);
        this.assets = activity.getAssets();
        this.soundPool = new SoundPool(20, AudioManager.*STREAM_MUSIC*, 0);
    }

我们在构造函数中传递游戏的 Activity 有两个原因:它允许我们设置媒体流的音量控制(我们总是希望这样做),它给了我们一个 AssetManager 实例,我们很乐意将它存储在相应的类成员中。SoundPool 配置为并行播放 20 种音效,这足以满足我们的需求。

    public Music newMusic(String filename) {
        try {
            AssetFileDescriptor assetDescriptor = assets.openFd(filename);
            return new AndroidMusic(assetDescriptor);
        }catch (IOException e) {
            throw new RuntimeException("Couldn't load music '" + filename + "'");
        }
    }

newMusic()方法创建一个新的 AndroidMusic 实例。该类的构造函数接受一个 AssetFileDescriptor,用它来创建一个内部 MediaPlayer(稍后将详细介绍)。如果出现问题,AssetManager.openFd()方法会抛出 IOException。我们捕获它并将其作为 RuntimeException 重新抛出。为什么不把 IOException 交给调用者呢?首先,它会使调用代码相当混乱,所以我们宁愿抛出一个不必显式捕获的 RuntimeException。其次,我们从一个素材文件中加载音乐。只有当我们忘记将音乐文件添加到 assets/directory 中,或者音乐文件包含错误的字节时,它才会失败。错误字节构成了不可恢复的错误,因为我们需要音乐实例来使我们的游戏正常运行。为了避免这种情况发生,我们在游戏框架中的更多地方抛出 RuntimeExceptions 而不是 checked exceptions。

    public Sound newSound(String filename) {
        try {
            AssetFileDescriptor assetDescriptor = assets.openFd(filename);
            int soundId = soundPool.load(assetDescriptor, 0);
            return new AndroidSound(soundPool, soundId);
        }catch (IOException e) {
            throw new RuntimeException("Couldn't load sound '" + filename + "'");
        }
    }
}

最后,newSound()方法将资源中的声音效果加载到 SoundPool 中,并返回一个 AndroidSound 实例。该实例的构造函数获取一个 SoundPool 和 SoundPool 分配给它的音效 ID。同样,我们捕捉任何 IOException 并将其作为未检查的 RuntimeException 重新抛出。

注意我们不会以任何方式释放 SoundPool。原因是总会有一个游戏实例拥有一个音频实例,而音频实例拥有一个 SoundPool 实例。因此,只要活动(以及我们的游戏)存在,SoundPool 实例就将存在。活动一结束就会自动销毁。

接下来,我们将讨论 AndroidSound 类,它实现了声音接口。清单 5-3 展示了它的实现。

清单 5-3 。使用 AndroidSound.java 实现声音接口

package com.badlogic.androidgames.framework.impl;

import android.media.SoundPool;

import com.badlogic.androidgames.framework.Sound;

public class AndroidSoundimplements Sound {
    int soundId;
    SoundPool soundPool;

    public AndroidSound(SoundPool soundPool, int soundId) {
        this.soundId = soundId;
        this.soundPool = soundPool;
    }

    public void play(float volume) {
        soundPool.play(soundId, volume, volume, 0, 0, 1);
    }

    public void dispose() {
        soundPool.unload(soundId);
    }
}

这里没有惊喜。通过 play()和 dispose()方法,我们简单地存储 SoundPool 和加载的声音效果的 ID,以便以后播放和处理。感谢 Android API,没有比这更简单的了。

最后,我们要实现 AndroidAudio.newMusic()返回的 AndroidMusic 类。清单 5-4 显示了这个类的代码,看起来比以前要复杂一些。这是由于 MediaPlayer 使用的状态机,如果我们在某些状态下调用方法,它会不断抛出异常。请注意,清单再次被分解,并在适当的地方插入了注释。

清单 5-4 。【AndroidMusic.java】;实现音乐界面

package com.badlogic.androidgames.framework.impl;

import java.io.IOException;

import android.content.res.AssetFileDescriptor;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;

import com.badlogic.androidgames.framework.Music;

public class AndroidMusic implements Music, OnCompletionListener {
    MediaPlayer mediaPlayer;
    boolean isPrepared = false ;

AndroidMusic 类存储一个 MediaPlayer 实例和一个名为 isPrepared 的布尔值。记住,我们只能在 MediaPlayer 准备好的情况下调用 media player . start()/stop()/pause()。该成员帮助我们跟踪 MediaPlayer 的状态。

AndroidMusic 类实现了 Music 接口和 OnCompletionListener 接口。在第四章的中,我们简单地将这个界面定义为一种通知我们自己媒体播放器何时停止播放音乐文件的方式。如果发生这种情况,MediaPlayer 需要在我们调用任何其他方法之前再次准备好。on completion listener . on completion()方法可能在单独的线程中调用,由于我们在此方法中设置了 isPrepared 成员,因此我们必须确保它不会被并发修改。

    public AndroidMusic(AssetFileDescriptor assetDescriptor) {
        mediaPlayer = new MediaPlayer();
        try {
            mediaPlayer.setDataSource(assetDescriptor.getFileDescriptor(),
                    assetDescriptor.getStartOffset(),
                    assetDescriptor.getLength());
            mediaPlayer.prepare();
            isPrepared = true ;
            mediaPlayer.setOnCompletionListener(this );
        }catch (Exception e) {
            throw new RuntimeException("Couldn't load music");
        }
    }

在构造函数中,我们从传入的 AssetFileDescriptor 创建并准备 MediaPlayer,我们设置 isPrepared 标志,并将 AndroidMusic 实例注册为 MediaPlayer 的 OnCompletionListener。如果出现任何问题,我们再次抛出一个未检查的 RuntimeException。

    public void dispose() {
        if (mediaPlayer.isPlaying())
            mediaPlayer.stop();
        mediaPlayer.release();
    }

dispose()方法检查 MediaPlayer 是否还在播放,如果是,就停止播放。否则,对 MediaPlayer.release()的调用将引发 RuntimeException。

    public boolean isLooping() {
        return mediaPlayer.isLooping();
    }
    public boolean isPlaying() {
        return mediaPlayer.isPlaying();
    }
    public boolean isStopped() {
        return !isPrepared;
    }

方法 isLooping()、isPlaying()和 isStopped()非常简单。MediaPlayer 提供的前两种使用方法;最后一个使用 isPrepared 标志,它指示 MediaPlayer 是否停止。这是 MediaPlayer . is play()不一定要告诉我们的,因为如果 media player 暂停但没有停止,它会返回 false。

    public void pause() {
        if (mediaPlayer.isPlaying())
            mediaPlayer.pause();
    }

pause()方法只是检查 MediaPlayer 实例是否正在播放,如果正在播放,就调用它的 pause()方法。

    public void play() {
        if (mediaPlayer.isPlaying())
            return ;
        try {
            synchronized (this ) {
                if (!isPrepared)
                    mediaPlayer.prepare();
                mediaPlayer.start();
            }
        }catch (IllegalStateException e) {
            e.printStackTrace();
        }catch (IOException e) {
            e.printStackTrace();
        }
    }

play()方法稍微复杂一些。如果我们已经在玩了,我们就从函数返回。接下来,我们有一个强大的尝试。。。catch 块,我们在其中检查 MediaPlayer 是否已经根据我们的标志准备好;如果需要,我们会准备的。如果一切顺利,我们调用 MediaPlayer.start()方法,这将开始播放。这是在 synchronized 块中进行的,因为我们使用的是 isPrepared 标志,该标志可能会在单独的线程上设置,因为我们实现的是 OnCompletionListener 接口。万一出错,我们抛出一个未检查的 RuntimeException。

    public void setLooping(boolean isLooping) {
        mediaPlayer.setLooping(isLooping);
    }
    public void setVolume(float volume) {
        mediaPlayer.setVolume(volume, volume);
    }

setLooping()和 setVolume()方法可以在 MediaPlayer 的任何状态下调用,并委托给相应的 MediaPlayer 方法。

    public void stop() {
        mediaPlayer.stop();
        synchronized (this ) {
            isPrepared = false ;
        }
    }

stop()方法停止 MediaPlayer 并在同步块中设置 isPrepared 标志。

    public void onCompletion(MediaPlayer player) {
        synchronized (this ) {
            isPrepared = false ;
        }
    }
}

最后,还有由 AndroidMusic 类实现的 on completion listener . on completion()方法。它所做的只是在 synchronized 块中设置 isPrepared 标志,这样其他方法就不会突然抛出异常。接下来,我们将继续学习与输入相关的类。

机器人输入和加速度处理器

使用一些方便的方法,我们在第三章中设计的输入界面允许我们在轮询和事件模式下访问加速度计、触摸屏和键盘。将该接口实现的所有代码放在一个文件中的想法有点讨厌,所以我们将所有输入事件处理外包给处理程序类。输入实现将使用这些处理程序来假装它实际上正在执行所有的工作。

加速器手柄:哪边朝上?

让我们从所有处理器中最简单的开始,加速度计处理器。清单 5-5 显示了它的代码。

清单 5-5 。【AccelerometerHandler.java】;执行所有加速度计处理

package com.badlogic.androidgames.framework.impl;

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;

public class AccelerometerHandler implements SensorEventListener {
    float accelX;
    float accelY;
    float accelZ;
    public AccelerometerHandler(Context context) {
        SensorManager manager = (SensorManager) context
                .getSystemService(Context.*SENSOR_SERVICE*);
        if (manager.getSensorList(Sensor.*TYPE_ACCELEROMETER*).size() ! = 0) {
            Sensor accelerometer = manager.getSensorList(
                    Sensor.*TYPE_ACCELEROMETER*).get(0);
            manager.registerListener(this , accelerometer,
                    SensorManager.*SENSOR_DELAY_GAME*);
        }
    }
    public void onAccuracyChanged(Sensor sensor, int accuracy) {
        // nothing to do here
    }
    public void onSensorChanged(SensorEvent event) {
        accelX = event.values[0];
        accelY = event.values[1];
        accelZ = event.values[2];
    }
    public float getAccelX() {
        return accelX;
    }
    public float getAccelY() {
        return accelY;
    }
    public float getAccelZ() {
        return accelZ;
    }
}

不出所料,该类实现了我们在第四章中使用的 SensorEventListener 接口。该类通过保存三个加速度计轴上的加速度来存储三个成员。

构造函数获取一个上下文,从中获取一个 SensorManager 实例来设置事件侦听。剩下的代码相当于我们在第四章中所做的。请注意,如果没有安装加速度计,处理器将很乐意在其整个生命周期内在所有轴上返回零加速度。因此,我们不需要任何额外的错误检查或异常抛出代码。

接下来的两个方法,onAccuracyChanged()和 onSensorChanged(),应该很熟悉。在第一种方法中,我们什么都不做,所以没有什么可报告的。在第二个示例中,我们从提供的 SensorEvent 中获取加速度计值,并将它们存储在处理程序的成员中。最后三种方法只是返回每个轴的当前加速度。

注意,我们不需要在这里执行任何同步,即使可能在不同的线程中调用 onSensorChanged()方法。Java 内存模型保证对 Boolean、int 或 byte 等基本类型的读写是原子的。在这种情况下,依靠这个事实是可以的,因为我们没有做任何比赋值更复杂的事情。如果不是这种情况,我们就需要适当的同步(例如,如果我们对 onSensorChanged()方法中的成员变量做了一些事情)。

CompassHandler

只是为了好玩,我们将提供一个例子,类似于加速度处理器,但是这一次我们将给出罗盘值以及手机的俯仰和滚动,如清单 5-6 所示。我们称罗盘值为偏航,因为这是一个标准的方位术语,很好地定义了我们看到的值。

Android 通过相同的接口处理所有传感器,因此这个例子向您展示了如何应对这种情况。列表 5-6 与之前的加速度计示例之间的唯一区别是传感器类型变为 TYPE_ORIENTATION,并且字段从 accel 重命名为 yaw、pitch 和 roll。否则,它以同样的方式工作,您可以很容易地将这些代码作为控制处理程序交换到游戏中!

清单 5-6 。【CompassHandler.java】;执行所有罗盘操作

package com.badlogic.androidgames.framework.impl;

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;

public class CompassHandler implements SensorEventListener {
    float yaw;
    float pitch;
    float roll;
    public CompassHandler(Context context) {
        SensorManager manager = (SensorManager) context
                .getSystemService(Context.SENSOR_SERVICE);
        if (manager.getSensorList(Sensor.TYPE_ORIENTATION).size() ! = 0) {
            Sensor compass = manager.getDefaultSensor(Sensor.TYPE_ORIENTATION);
            manager.registerListener(this , compass,
                    SensorManager.SENSOR_DELAY_GAME);
        }
    }
    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) {
        // nothing to do here
    }
    @Override
    public void onSensorChanged(SensorEvent event) {
        yaw = event.values[0];
        pitch = event.values[1];
        roll = event.values[2];
    }
    public float getYaw() {
        return yaw;
    }
    public float getPitch() {
        return pitch;
    }
    public float getRoll() {
        return roll;
    }
}

我们不会在本书的任何游戏中使用指南针,但是如果你打算重用我们开发的框架,这个类可能会派上用场。

池类:因为复用对你有好处!

作为 Android 开发者,我们可能遇到的最糟糕的事情是什么?世界停止垃圾收集!如果你查看第三章中的输入接口定义,你会发现 getTouchEvents()和 getKeyEvents()方法。这些方法返回 TouchEvent 和 KeyEvent 列表。在我们的键盘和触摸事件处理程序中,我们不断地创建这两个类的实例,并将它们存储在处理程序内部的列表中。当按下一个键或手指触摸屏幕时,Android 输入系统会触发许多这样的事件,因此我们不断创建新的实例,由垃圾收集器在很短的时间间隔内收集。为了避免这种情况,我们实现了一个叫做实例池的概念。我们简单地重用以前创建的实例,而不是重复创建一个类的新实例。Pool 类是实现该行为的一种便捷方式。让我们看看它在清单 5-7 中的代码,它被再次分解,包含适当的注释。

清单 5-7 。【Pool.java】;玩好垃圾收集器

package com.badlogic.androidgames.framework;

import java.util.ArrayList;
import java.util.List;

public class Pool < T > {

这里是泛型:首先要认识到这是一个泛型类,很像 ArrayList 或 HashMap 之类的集合类。泛型允许我们在池中存储任何类型的对象,而不必不断地进行类型转换。那么 Pool 类是做什么的呢?

    public interface PoolObjectFactory < T > {
        public T createObject();
    }

首先定义的是一个名为 PoolObjectFactory 的接口,它也是通用的。它有一个方法 createObject(),该方法将返回一个具有 Pool/PoolObjectFactory 实例的通用类型的新对象。

    private final List < T > freeObjects;
    private final PoolObjectFactory < T > factory;
    private final int maxSize;

Pool 类有三个成员。其中包括一个用于存储池化对象的 ArrayList、一个用于生成由类保存的类型的新实例的 PoolObjectFactory,以及一个存储池可以保存的最大对象数的成员。最后一点是必需的,这样我们的池就不会无限增长;否则,我们可能会遇到内存不足的异常。

    public Pool(PoolObjectFactory < T > factory, int maxSize) {
        this.factory = factory;
        this.maxSize = maxSize;
        this.freeObjects = new ArrayList < T > (maxSize);
    }

Pool 类的构造函数采用 PoolObjectFactory 和它应该存储的最大对象数。我们将这两个参数存储在各自的成员中,并用设置为最大对象数的容量实例化一个新的 ArrayList。

    public T newObject() {
       T object = null ;
       if (freeObjects.isEmpty())
           object = factory.createObject();
       else 
           object = freeObjects.remove(freeObjects.size() - 1);
       return object;
    }

newObject()方法负责通过 PoolObjectFactory.newObject()方法向我们传递一个池持有的类型的全新实例,或者在 freeObjectsArrayList 中有池实例的情况下返回一个池实例。如果我们使用这个方法,只要池中有一些存储在 freeObjects 列表中的对象,我们就可以得到回收的对象。否则,该方法通过工厂创建一个新的。

    public void free(T object) {
        if (freeObjects.size() < maxSize)
            freeObjects.add(object);
    }
}

free()方法允许我们重新插入不再使用的对象。如果对象还没有填满,它只是将对象插入到 freeObjects 列表中。如果列表已满,则不会添加该对象,它很可能会在垃圾收集器下次执行时被消耗掉。

那么,我们如何使用这个类呢?我们将结合触摸事件来看看 Pool 类的一些伪代码用法。

PoolObjectFactory <TouchEvent> factory = new PoolObjectFactory <TouchEvent> () {
    @Override
    public TouchEvent createObject() {
        return new TouchEvent();
    }
};
Pool <TouchEvent> touchEventPool = new Pool <TouchEvent> (factory, 50);
TouchEvent touchEvent = touchEventPool.newObject();
. . . do something here . . .
touchEventPool.free(touchEvent);

首先,我们定义一个创建 TouchEvent 实例的 PoolObjectFactory。接下来,我们实例化这个池,告诉它使用我们的工厂,它应该最多存储 50 个 TouchEvents。当我们需要池中的新 TouchEvent 时,我们调用池的 newObject()方法。最初,池是空的,因此它将要求工厂创建一个全新的 TouchEvent 实例。当我们不再需要 TouchEvent 时,我们通过调用池的 free()方法将其重新插入池中。下一次我们调用 newObject()方法时,我们将获得相同的 TouchEvent 实例并回收它,以避免垃圾收集器出现问题。这个类在几个地方很有用。请注意,当从池中取出重用的对象时,必须小心地完全重新初始化它们。

键盘处理程序:上,上,下,下,左,右。。。

键盘处理程序必须完成几项任务。首先,它必须与接收键盘事件的视图相连接。接下来,它必须为轮询存储每个键的当前状态。它还必须保留一个我们在第三章中为基于事件的输入处理设计的 KeyEvent 实例列表。最后,它必须正确地同步一切,因为它将在 UI 线程上接收事件,同时从我们的主游戏循环中轮询,这是在不同的线程上执行的。这是很大的工作量!作为复习,我们将向您展示我们在第三章的中定义的作为输入接口一部分的 KeyEvent 类。

public static class KeyEvent {
    public static final int *KEY_DOWN* = 0;
    public static final int *KEY_UP* = 1;

    public int type;
    public int keyCode;
    public char keyChar;
}

这个类简单地定义了两个常量,这两个常量对键事件类型以及三个成员进行编码,同时保存事件的类型、键代码和 Unicode 字符。这样,我们就可以实现我们的处理程序了。

清单 5-8 显示了使用之前讨论的 Android APIs 和我们新的 Pool 类实现处理程序。这个列表被注释打断了。

清单 5-8 。keyboard handler . Java:从 2010 年开始处理按键

package com.badlogic.androidgames.framework.impl;

import java.util.ArrayList;
import java.util.List;

import android.view.View;
import android.view.View.OnKeyListener;

import com.badlogic.androidgames.framework.Input.KeyEvent;
import com.badlogic.androidgames.framework.Pool;
import com.badlogic.androidgames.framework.Pool.PoolObjectFactory;

public class KeyboardHandler implements OnKeyListener {
    boolean [] pressedKeys = new boolean [128];
    Pool <KeyEvent> keyEventPool;
    List <KeyEvent> keyEventsBuffer = new ArrayList <KeyEvent> ();
    List <KeyEvent> keyEvents = new ArrayList <KeyEvent> ();

KeyboardHandler 类实现 OnKeyListener 接口,以便它可以从视图接收按键事件。成员是下一个。

第一个成员是一个包含 128 个布尔值的数组。我们将每个键的当前状态(按下与否)存储在这个数组中。它由钥匙的钥匙代码索引。幸运的是,Android . view . keyevent . key code _ XXX 常量(编码键码)都在 0 到 127 之间,因此我们可以将它们存储在垃圾收集器友好的形式中。注意,不幸的是,我们的 KeyEvent 类与 Android KeyEvent 类同名,后者的实例被传递给我们的 OnKeyEventListener.onKeyEvent()方法。这种轻微的混淆仅限于这个处理程序代码。因为对于一个关键事件来说,没有比“关键事件”更好的名字了,所以我们选择忍受这种短暂的混乱。

下一个成员是保存我们的 KeyEvent 类的实例的池。我们不想让垃圾收集器生气,所以我们回收我们创建的所有 KeyEvent 对象。

第三个成员存储我们的游戏尚未使用的 KeyEvent 实例。每当我们在 UI 线程上获得一个新的按键事件,我们就把它添加到这个列表中。

最后一个成员存储我们通过调用 KeyboardHandler.getKeyEvents()返回的 KeyEvents。在接下来的章节中,我们将会看到为什么我们必须对关键事件进行双缓冲。

    public KeyboardHandler(View view) {
        PoolObjectFactory <KeyEvent> factory = new PoolObjectFactory <KeyEvent> () {
            public KeyEvent createObject() {
                return new KeyEvent();
            }
        };
        keyEventPool = new Pool < KeyEvent > (factory, 100);
        view.setOnKeyListener(this );
        view.setFocusableInTouchMode(true );
        view.requestFocus();
    }

该构造函数有一个参数,由我们希望从其接收按键事件的视图组成。我们使用适当的 PoolObjectFactory 创建池实例,将处理程序注册为视图的 OnKeyListener,最后,通过使视图成为焦点视图来确保视图将接收关键事件。

    public boolean onKey(View v, int keyCode, android.view.KeyEvent event) {
        if (event.getAction() == android.view.KeyEvent.*ACTION_MULTIPLE*)
            return false ;

        synchronized (this ) {
            KeyEvent keyEvent = keyEventPool.newObject();
            keyEvent.keyCode = keyCode;
            keyEvent.keyChar = (char ) event.getUnicodeChar();
            if (event.getAction() == android.view.KeyEvent.*ACTION_DOWN*) {
                keyEvent.type = KeyEvent.*KEY_DOWN*;
                if (keyCode > 0 && keyCode < 127)
                    pressedKeys[keyCode] = true ;
            }
            if (event.getAction() == android.view.KeyEvent.*ACTION_UP*) {
                keyEvent.type = KeyEvent.*KEY_UP*;
                if (keyCode > 0 && keyCode < 127)
                    pressedKeys[keyCode] = false ;
            }
            keyEventsBuffer.add(keyEvent);
        }
        return false ;
    }

接下来,我们将讨论 OnKeyListener.onKey()接口方法的实现,每次视图接收到新的按键事件时都会调用该方法。我们从忽略任何编码按键事件的(Android)按键事件开始。动作 _ 多个事件。这些与我们的上下文无关。这后面是一个同步块。请记住,事件在 UI 线程上接收,在主循环线程上读取,因此我们必须确保没有成员被并行访问。

在 synchronized 块中,我们首先从池中获取一个 KeyEvent 实例(我们的 KeyEvent 实现的实例)。这将使我们获得一个回收的实例或一个全新的实例,这取决于池的状态。接下来,我们根据传递给该方法的 Android KeyEvent 的内容设置 KeyEvent 的 keyCode 和 keyChar 成员。然后,我们解码 Android KeyEvent 类型,并相应地设置我们的 KeyEvent 的类型以及 pressedKey 数组中的元素。最后,我们将 KeyEvent 添加到前面定义的 keyEventBuffer 列表中。

    public boolean isKeyPressed(int keyCode) {
        if (keyCode < 0 || keyCode > 127)
            return false ;
        return pressedKeys[keyCode];
    }

我们处理程序的下一个方法是 isKeyPressed()方法,它实现了 Input.isKeyPressed()的语义。首先,我们传入一个指定键码的整数(Android KeyEvent 之一。KEYCODE_XXX 常量)并返回该键是否被按下。我们通过在一些范围检查之后在 pressedKey 数组中查找键的状态来做到这一点。记住,我们在前面的方法中设置了这个数组的元素,这个方法在 UI 线程中被调用。因为我们又在处理基本类型,所以不需要同步。

    public List <KeyEvent> getKeyEvents() {
        synchronized (this ) {
            int len = keyEvents.size();
            for (int i = 0; i < len; i++) {
                keyEventPool.free(keyEvents.get(i));
            }
            keyEvents.clear();
            keyEvents.addAll(keyEventsBuffer);
            keyEventsBuffer.clear();
            return keyEvents;
        }
    }
}

我们的处理程序的最后一个方法称为 getKeyEvents(),它实现了 Input.getKeyEvents()方法的语义。同样,我们从一个同步块开始,记住这个方法将从不同的线程调用。

接下来,我们遍历 keyEvents 数组,并将其所有的 KeyEvents 插入到我们的池中。记住,我们在 UI 线程的 onKey()方法中从池中获取实例。在这里,我们将它们重新插入池中。但是 keyEvents 列表不是空的吗?是的,但只是在我们第一次调用那个方法的时候。要理解为什么,你必须掌握剩下的方法。

在我们神秘的池插入循环之后,我们清除 keyEvents 列表并用 keyEventsBuffer 列表中的事件填充它。最后,我们清除 keyEventsBuffer 列表,并将新填充的 keyEvents 列表返回给调用者。这里发生了什么事?

我们将用一个简单的例子来说明这一点。首先,我们将检查每次新事件到达 UI 线程或游戏在主线程中获取事件时,keyEvents 和 keyEventsBuffer 列表以及我们的池会发生什么:

UI thread: onKey() ->
           keyEvents = { }, keyEventsBuffer = {KeyEvent1}, pool = { }
Main thread: getKeyEvents() ->
           keyEvents = {KeyEvent1}, keyEventsBuffer = { }, pool { }
UI thread: onKey() ->
           keyEvents = {KeyEvent1}, keyEventsBuffer = {KeyEvent2}, pool { }
Main thread: getKeyEvents() ->
           keyEvents = {KeyEvent2}, keyEventsBuffer = { }, pool = {KeyEvent1}
UI thread: onKey() ->

keyEvents = {KeyEvent2}、keyeventsbuffer = { keyevent 1 }、pool = { }

  1. 我们在 UI 线程中得到一个新事件。池中还没有任何东西,所以创建了一个新的 KeyEvent 实例(KeyEvent1)并将其插入到 keyEventsBuffer 列表中。
  2. 我们在主线程上调用 getKeyEvents()。getKeyEvents()从 keyEventsBuffer 列表中获取 KeyEvent1,并将其放入返回给调用者的 KeyEvents 列表中。
  3. 我们在 UI 线程上得到另一个事件。我们在池中仍然什么都没有,所以创建了一个新的 KeyEvent 实例(KeyEvent2)并将其插入到 keyEventsBuffer 列表中。
  4. 主线程再次调用 getKeyEvents()。现在,有趣的事情发生了。进入该方法后,keyEvents 列表仍然保存 KeyEvent1。插入循环会将事件放入我们的池中。然后,它清除 keyEvents 列表并将任何 KeyEvent 插入到 keyEventsBuffer 中,在本例中为 KeyEvent2。我们刚刚回收了一个关键事件。
  5. 另一个关键事件到达 UI 线程。这一次,我们的池中有一个免费的 KeyEvent,我们很乐意重用它。令人难以置信的是,没有垃圾收集!

这种机制有一个警告,即我们必须频繁调用 KeyboardHandler.getKeyEvents(),否则 KeyEvents 列表会很快填满,并且没有对象返回到池中。只要我们记住这一点,问题是可以避免的。

触摸处理器

现在是时候考虑碎片化了。在第四章中,我们透露了多点触控仅在高于 1.6 的 Android 版本上受支持。我们在多点触摸代码中使用的所有好的常量(例如,MotionEvent。ACTION_POINTER_ID_MASK)在 Android 1.5 或 1.6 上对我们不可用。如果我们将项目的构建目标设置为具有该 API 的 Android 版本,我们可以在代码中使用它们;然而,该应用将在任何运行 Android 1.5 或 1.6 的设备上崩溃。我们希望我们的游戏可以在目前所有可用的 Android 版本上运行,那么我们如何解决这个问题呢?

我们使用了一个简单的技巧。我们编写两个处理程序,一个使用 Android 1.5 中的单触 API,另一个使用 Android 2.0 及以上版本中的多触 API。只要我们不在低于 2.0 版本的 Android 设备上执行多点触摸处理程序代码,这是安全的。VM 不会加载代码,也不会连续抛出异常。我们需要做的就是找出设备运行的 Android 版本,并实例化适当的处理程序。当我们讨论 AndroidInput 类时,您将看到这是如何工作的。现在,让我们把注意力集中在这两个处理程序上。

触摸处理器接口

为了互换使用我们的两个处理程序类,我们需要定义一个公共接口。清单 5-9 展示了 TouchHandler 接口。

清单 5-9 。TouchHandler.java,将在 Android 1.5 和 1.6 上实现

package com.badlogic.androidgames.framework.impl;

import java.util.List;

import android.view.View.OnTouchListener;

import com.badlogic.androidgames.framework.Input.TouchEvent;

public interface TouchHandlerextends OnTouchListener {
    public boolean isTouchDown(int pointer);

    public int getTouchX(int pointer);

    public int getTouchY(int pointer);

    public List <TouchEvent> getTouchEvents();

}

所有 TouchHandlers 都必须实现 OnTouchListener 接口,该接口用于向视图注册处理程序。接口的方法对应于第三章中定义的输入接口的相应方法。前三个用于轮询特定指针 ID 的状态,最后一个用于获取用来执行基于事件的输入处理的触摸事件。注意,轮询方法采用指针 id,它可以是任何数字,由触摸事件给出。

SingleTouchHandler 类

在我们的单触处理程序中,我们忽略除零以外的任何 id。概括地说,我们将回忆一下在第三章中定义的 TouchEvent 类,它是输入接口的一部分。

public static class TouchEvent {
    public static final int *TOUCH_DOWN* = 0;
    public static final int *TOUCH_UP* = 1;
    public static final int *TOUCH_DRAGGED* = 2;

    public int type;
    public int x, y;
    public int pointer;
}

像 KeyEvent 类一样,TouchEvent 类定义了两个常数,它们反映了触摸事件的类型,以及视图坐标系中的 x 和 y 坐标和指针 ID。清单 5-10 展示了 Android 1.5 和 1.6 的 TouchHandler 接口的实现,通过注释进行了分解。

清单 5-10 。【SingleTouchHandler.java】;单点触控效果不错,多点触控效果不太好

package com.badlogic.androidgames.framework.impl;

import java.util.ArrayList;
import java.util.List;

import android.view.MotionEvent;
import android.view.View;
import com.badlogic.androidgames.framework.Pool;

import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Pool.PoolObjectFactory;

public class SingleTouchHandler implements TouchHandler {
    boolean isTouched;
    int touchX;
    int touchY;
    Pool <TouchEvent> touchEventPool;
    List <TouchEvent> touchEvents = new ArrayList <TouchEvent> ();
    List <TouchEvent> touchEventsBuffer = new ArrayList <TouchEvent> ();
    float scaleX;
    float scaleY;

我们首先让类实现 TouchHandler 接口,这也意味着我们必须实现 OnTouchListener 接口。接下来,我们有三个成员存储一个手指的触摸屏的当前状态,后面是一个池和两个保存触摸事件的列表。这与 KeyboardHandler 中的相同。我们还有两个成员,scaleX 和 scaleY。我们将在下面的章节中解决这些问题,并使用它们来处理不同的屏幕分辨率。

注意当然,我们可以通过从一个基类派生 KeyboardHandler 和 SingleTouchHandler 来处理关于池和同步的所有问题,从而使这变得更加优雅。然而,这会使解释更加复杂,因此,我们将编写多几行代码。

    public SingleTouchHandler(View view, float scaleX, float scaleY) {
       PoolObjectFactory <TouchEvent> factory = new PoolObjectFactory <TouchEvent> () {
            @Override
            public TouchEvent createObject() {
                return new TouchEvent();
            }
        };
        touchEventPool = new Pool <TouchEvent> (factory, 100);
        view.setOnTouchListener(this );
        this.scaleX = scaleX;
        this.scaleY = scaleY;
    }

在构造函数中,我们将处理程序注册为 OnTouchListener,并设置用于回收 TouchEvents 的池。我们还存储传递给构造函数的 scaleX 和 scaleY 参数(暂时忽略它们)。

    public boolean onTouch(View v, MotionEvent event) {
        synchronized (this ) {
            TouchEvent touchEvent = touchEventPool.newObject();
            switch (event.getAction()) {
            case MotionEvent.*ACTION_DOWN*:
                touchEvent.type = TouchEvent.*TOUCH_DOWN*;
                isTouched = true ;
                break ;
            case MotionEvent.*ACTION_MOVE*:
                touchEvent.type = TouchEvent.*TOUCH_DRAGGED*;
                isTouched = true ;
                break ;
            case MotionEvent.*ACTION_CANCEL*:
            case MotionEvent.*ACTION_UP*:
                touchEvent.type = TouchEvent.*TOUCH_UP*;
                isTouched = false ;
                break ;
            }
            touchEvent.x = touchX = (int )(event.getX() * scaleX);
            touchEvent.y = touchY = (int )(event.getY() * scaleY);
            touchEventsBuffer.add(touchEvent);
            return true ;
        }
    }

onTouch()方法实现了与我们的 KeyboardHandler 的 onKey()方法相同的结果;唯一的区别是现在我们处理触摸事件而不是按键事件。我们已经知道了所有的同步、池和运动事件处理。唯一有趣的是,我们将报告的触摸事件的 x 和 y 坐标乘以 scaleX 和 scaleY。记住这一点很重要,因为我们将在接下来的部分中回到这一点。

    public boolean isTouchDown(int pointer) {
        synchronized (this ) {
            if (pointer == 0)
                return isTouched;
            else 
                return false ;
        }
    }
    public int getTouchX(int pointer) {
        synchronized (this ) {
            return touchX;
        }
    }
    public int getTouchY(int pointer) {
        synchronized (this ) {
            return touchY;
        }
    }

isTouchDown()、getTouchX()和 getTouchY()方法允许我们根据在 onTouch()方法中设置的成员来轮询触摸屏的状态。唯一值得注意的是,它们只返回指针 ID 值为零的有用数据,因为这个类只支持单点触摸屏。

    public List <TouchEvent> getTouchEvents() {
        synchronized (this ) {
            int len = touchEvents.size();
            for (int i = 0; i < len; i++ )
                touchEventPool.free(touchEvents.get(i));
            touchEvents.clear();
            touchEvents.addAll(touchEventsBuffer);
            touchEventsBuffer.clear();
            return touchEvents;
        }
    }
}

最后一个方法 singletouchhandler . gettouchevents()应该为您所熟悉,它类似于 KeyboardHandler.getKeyEvents()方法。记住我们经常调用这个方法,这样 touchEvents 列表就不会填满。

多触点手柄

对于多点触摸处理,我们使用一个名为 MultiTouchHandler 的类,如清单 5-11 所示。

清单 5-11 。【MultiTouchHandler.java】(更多相同)

package com.badlogic.androidgames.framework.impl;

import java.util.ArrayList;
import java.util.List;

import android.view.MotionEvent;
import android.view.View;

import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Pool;
import com.badlogic.androidgames.framework.Pool.PoolObjectFactory;

@TargetApi(5) 
public class MultiTouchHandler implements TouchHandler {
    private static final int *MAX_TOUCHPOINTS* = 10;
    boolean [] isTouched = new boolean [*MAX_TOUCHPOINTS*];
    int [] touchX = new int [*MAX_TOUCHPOINTS*];
    int [] touchY = new int [*MAX_TOUCHPOINTS*];
    int [] id = new int [*MAX_TOUCHPOINTS*];
    Pool <TouchEvent> touchEventPool;
    List <TouchEvent> touchEvents = new ArrayList <TouchEvent> ();
    List <TouchEvent> touchEventsBuffer = new ArrayList <TouchEvent> ();
    float scaleX;
    float scaleY;

    public MultiTouchHandler(View view, float scaleX, float scaleY) {
        PoolObjectFactory <TouchEvent> factory = new PoolObjectFactory <TouchEvent> () {
            public TouchEvent createObject() {
                return new TouchEvent();
            }
        };
        touchEventPool = new Pool <TouchEvent> (factory, 100);
        view.setOnTouchListener(this );
        this.scaleX = scaleX;
        this.scaleY = scaleY;
    }
    public boolean onTouch(View v, MotionEvent event) {
        synchronized (this ) {
            int action = event.getAction() & MotionEvent.*ACTION_MASK*;
            int pointerIndex = (event.getAction() & MotionEvent.*ACTION_POINTER_ID_MASK*) > > MotionEvent.*ACTION_POINTER_ID_SHIFT*;
            int pointerCount = event.getPointerCount();
            TouchEvent touchEvent;
            for (int i = 0; i < *MAX_TOUCHPOINTS*; i++) {
                if (i >= pointerCount) {
                    isTouched[i] = false ;
                    id[i] = -1;
                    continue ;
                }
                int pointerId = event.getPointerId(i);
                if (event.getAction() != MotionEvent.*ACTION_MOVE*&& i != pointerIndex) {
                    // if it's an up/down/cancel/out event, mask the id to see if we should process it for this touch
                    // point
                    continue ;
                }
                switch (action) {
                case MotionEvent.*ACTION_DOWN*:
                case MotionEvent.*ACTION_POINTER_DOWN*:
                    touchEvent = touchEventPool.newObject();
                    touchEvent.type = TouchEvent.*TOUCH_DOWN*;
                    touchEvent.pointer = pointerId;
                    touchEvent.x = touchX[i] = (int ) (event.getX(i) * scaleX);
                    touchEvent.y = touchY[i] = (int ) (event.getY(i) * scaleY);
                    isTouched[i] = true ;
                    id[i] = pointerId;
                    touchEventsBuffer.add(touchEvent);
                    break ;

                case MotionEvent.*ACTION_UP*:
                case MotionEvent.*ACTION_POINTER_UP*:
                case MotionEvent.*ACTION_CANCEL*:
                    touchEvent = touchEventPool.newObject();
                    touchEvent.type = TouchEvent.*TOUCH_UP*;
                    touchEvent.pointer = pointerId;
                    touchEvent.x = touchX[i] = (int ) (event.getX(i) * scaleX);
                    touchEvent.y = touchY[i] = (int ) (event.getY(i) * scaleY);
                    isTouched[i] = false ;
                    id[i] = -1;
                    touchEventsBuffer.add(touchEvent);
                    break ;

                case MotionEvent.*ACTION_MOVE*:
                    touchEvent = touchEventPool.newObject();
                    touchEvent.type = TouchEvent.*TOUCH_DRAGGED*;
                    touchEvent.pointer = pointerId;
                    touchEvent.x = touchX[i] = (int ) (event.getX(i) * scaleX);
                    touchEvent.y = touchY[i] = (int ) (event.getY(i) * scaleY);
                    isTouched[i] = true ;
                    id[i] = pointerId;
                    touchEventsBuffer.add(touchEvent);
                    break ;
                }
            }
            return true ;
        }
    }
    public boolean isTouchDown(int pointer) {
        synchronized (this ) {
            int index = getIndex(pointer);
            if (index < 0 || index >=*MAX_TOUCHPOINTS*)
                return false ;
            else 
                return isTouched[index];
        }
    }
    public int getTouchX(int pointer) {
        synchronized (this ) {
            int index = getIndex(pointer);
            if (index < 0 || index >=*MAX_TOUCHPOINTS*)
                return 0;
            else 
                return touchX[index];
        }
    }
    public int getTouchY(int pointer) {
        synchronized (this ) {
            int index = getIndex(pointer);
            if (index < 0 || index >=*MAX_TOUCHPOINTS*)
                return 0;
            else 
                return touchY[index];
        }
    }
    public List <TouchEvent> getTouchEvents() {
        synchronized (this ) {
            int len = touchEvents.size();
            for (int i = 0; i < len; i++)
                touchEventPool.free(touchEvents.get(i));
            touchEvents.clear();
            touchEvents.addAll(touchEventsBuffer);
            touchEventsBuffer.clear();
            return touchEvents;
        }
    }
    // returns the index for a given pointerId or −1 if no index.
    private int getIndex(int pointerId) {
        for (int i = 0; i < *MAX_TOUCHPOINTS*; i++) {
            if (id[i] == pointerId) {
                return i;
            }
        }
        return -1;
    }
}

我们从另一个 TargetApi 注释开始,告诉编译器我们知道自己在做什么。在这种情况下,我们将最低 API 级别设置为 3,但是多点触摸处理程序中的代码需要 API 级别 5。如果没有这个注释,编译器会报错。

onTouch()方法看起来和我们在第四章中的测试例子一样吓人。然而,我们需要做的就是将测试代码与我们的事件池和同步结合起来,这一点我们已经详细讨论过了。与 SingleTouchHandler.onTouch()方法唯一真正的区别是,我们处理多个指针并相应地设置 TouchEvent.pointer 成员(而不是使用零值)。

轮询方法 isTouchDown()、getTouchX()和 getTouchY()看起来也应该很熟悉。我们执行一些错误检查,然后从填充到 onTouch()方法中的一个成员数组中获取相应指针索引的相应指针状态。

最后一个公共方法 getTouchEvents()与 singletouchhandler . getTouchEvents()中对应的方法完全相同。现在我们已经配备了所有这些处理程序,我们可以实现输入接口了。

类中的最后一个方法是帮助器方法,我们用它来查找指针 ID 的索引。

伟大的协调者

我们游戏框架的输入实现将我们开发的所有处理程序联系在一起。任何方法调用都被委托给相应的处理程序。这个实现唯一有趣的部分是根据设备运行的 Android 版本选择使用哪个 TouchHandler 实现。清单 5-12 显示了一个叫做 AndroidInput 的实现,并附有注释。

清单 5-12 。【AndroidInput.java】;使用样式处理处理程序

package com.badlogic.androidgames.framework.impl;

import java.util.List;

import android.content.Context;
import android.os.Build.VERSION;
import android.view.View;

import com.badlogic.androidgames.framework.Input;

public class AndroidInput implements Input {
    AccelerometerHandler accelHandler;
    KeyboardHandler keyHandler;
    TouchHandler touchHandler;

我们首先让这个类实现在第三章中定义的输入接口。这就引出了三个成员:AccelerometerHandler、KeyboardHandler 和 TouchHandler。

    public AndroidInput(Context context, View view, float scaleX, float scaleY) {
        accelHandler = new AccelerometerHandler(context);
        keyHandler = new KeyboardHandler(view);
        if (Integer.*parseInt*(VERSION.*SDK*) < 5)
            touchHandler = new SingleTouchHandler(view, scaleX, scaleY);
        else 
            touchHandler = new MultiTouchHandler(view, scaleX, scaleY);
    }

这些成员在构造函数中初始化,构造函数接受一个上下文、一个视图以及 scaleX 和 scaleY 参数,我们可以再次忽略这些参数。AccelerometerHandler 通过 Context 参数实例化,因为 KeyboardHandler 需要传入的视图。

为了决定使用哪个 TouchHandler,我们只需检查应用运行所使用的 Android 版本。这可以使用版本来完成。SDK 字符串,是 Android API 提供的常量。不清楚为什么这是一个字符串,因为它直接编码了我们在清单文件中使用的 SDK 版本号。所以我们需要把它做成整数,以便做一些比较。第一个支持多点触摸 API 的 Android 版本是版本 2.0,对应于 SDK 版本 5。如果当前设备运行较低的 Android 版本,我们实例化 SingleTouchHandler 否则,我们使用 MultiTouchHandler。在 API 级别,这就是我们需要关心的所有碎片。当我们开始渲染 OpenGL 时,我们会遇到更多的碎片问题,但没有必要担心——这些问题很容易解决,就像 touch API 问题一样。

    public boolean isKeyPressed(int keyCode) {
        return keyHandler.isKeyPressed(keyCode);
    }
    public boolean isTouchDown(int pointer) {
        return touchHandler.isTouchDown(pointer);
    }
    public int getTouchX(int pointer) {
        return touchHandler.getTouchX(pointer);
    }
    public int getTouchY(int pointer) {
        return touchHandler.getTouchY(pointer);
    }
    public float getAccelX() {
        return accelHandler.getAccelX();
    }
    public float getAccelY() {
        return accelHandler.getAccelY();
    }
    public float getAccelZ() {
        return accelHandler.getAccelZ();
    }
    public List <TouchEvent> getTouchEvents() {
        return touchHandler.getTouchEvents();
    }
    public List <KeyEvent> getKeyEvents() {
        return keyHandler.getKeyEvents();
    }
}

这个类的其余部分是不言自明的。每个方法调用都被委托给适当的处理程序,由它来完成实际的工作。这样,我们就完成了游戏框架的输入 API。接下来,我们将讨论图形。

AndroidGraphics 和 AndroidPixmap:双彩虹

是时候回到我们最喜爱的话题,图形编程了。在第三章中,我们定义了两个接口,分别叫做 Graphics 和 Pixmap。现在,我们将根据你在第四章中学到的东西来实现它们。然而,有一件事我们还没有考虑:如何处理不同的屏幕尺寸和分辨率。

处理不同的屏幕尺寸和分辨率

Android 从 1.6 版本开始就支持不同的屏幕分辨率。它可以处理从 240×320 像素到 1920×1080 的全高清电视分辨率。在第四章中,我们讨论了不同屏幕分辨率和物理屏幕尺寸的影响。例如,用绝对坐标和以像素为单位的尺寸绘图会产生意想不到的结果。图 5-1 显示了当我们在 480×800 和 320×480 屏幕上渲染一个左上角为(219,379)的 100×100 像素的矩形时会发生什么。

9781430246770_Fig05-01.jpg

图 5-1。在 480×800 屏幕(左)和 320×480 屏幕(右)上以(219,379)绘制的 100×100 像素矩形

这种差异是有问题的,原因有二。首先,我们不能画出我们的游戏,并假设一个固定的分辨率。第二个原因更微妙:在图 5-1 中,我们假设两个屏幕具有相同的密度(即每个像素在两个设备上都具有相同的物理尺寸),但现实中很少是这样的。

密度

密度通常用每英寸像素或每厘米像素来表示(有时你会听到每英寸点数,这在技术上是不正确的)。Nexus One 拥有 480×800 像素的屏幕,物理尺寸为 8×4.8 厘米。老款 HTC Hero 的屏幕为 320×480 像素,物理尺寸为 6.5×4.5 厘米。Nexus One 的两个轴上每厘米 100 像素,Hero 的两个轴上每厘米大约 71 像素。我们可以使用下面的等式很容易地计算出每厘米的像素:

每厘米像素(x 轴上)=像素宽度/厘米宽度

或者:

每厘米像素(y 轴上)=像素高度/厘米高度

通常,我们只需要在单个轴上计算这个,因为物理像素是正方形的(它们实际上是三个像素,但我们在这里忽略它)。

以厘米为单位,一个 100×100 像素的矩形有多大?在 Nexus One 上,我们有一个 1×1 厘米的矩形,而 Hero 有一个 1.4×1.4 厘米的矩形。这是我们需要考虑的事情,例如,如果我们试图在所有屏幕尺寸上提供对普通拇指来说足够大的按钮。这个例子意味着这是一个可能带来巨大问题的主要问题;然而,通常不会。我们需要确保我们的按钮在高密度屏幕上(例如,Nexus One)有足够大的尺寸,因为它们在低密度屏幕上会自动足够大。

长宽比

纵横比是另一个需要考虑的问题。屏幕的纵横比是宽度和高度之间的比率,以像素或厘米为单位。我们可以使用下面的等式来计算纵横比:

像素纵横比=像素宽度/像素高度

或者:

物理纵横比=厘米宽度/厘米高度

这里的宽度高度通常是指风景模式下的宽度和高度。Nexus One 的像素和物理纵横比为 1.66。英雄的像素和物理长宽比为 1.5。这是什么意思?在 Nexus One 上,相对于高度,我们在横向模式下 x 轴上的可用像素比我们在 Hero 上的可用像素多。图 5-2 用两台设备上副本岛的截图说明了这一点。

本书采用公制。我们知道,如果您熟悉英寸和磅,这可能会带来不便。然而,由于我们将在接下来的章节中考虑一些物理问题,最好现在就习惯它,因为物理问题通常是用公制来定义的。记住 1 英寸大约是 2.54 厘米。

9781430246770_Fig05-02.jpg

图 5-2。Nexus One(上)和 HTC Hero(下)上的复制岛

Nexus One 在 x 轴上显示的更多一些。然而,y 轴上的一切都是相同的。在这种情况下副本岛的创作者做了什么?

应对不同的长宽比

复制岛将作为纵横比问题的一个非常有用的例子。该游戏最初被设计为适合 480×320 像素的屏幕,包括所有的“精灵”,如机器人和医生,“世界”的瓷砖,以及 UI 元素(左下角的按钮和屏幕顶部的状态信息)。当游戏在一个英雄上渲染时,sprite 位图中的每个像素正好映射到屏幕上的一个像素。在 Nexus One 上,一切都是在渲染时按比例放大的,因此一个 sprite 的一个像素实际上占用了屏幕上的 1.5 个像素。换句话说,一个 32×32 像素的精灵在屏幕上将是 48×48 像素。使用以下公式可以很容易地计算出该比例因子:

缩放因子(x 轴上)=以像素为单位的屏幕宽度/以像素为单位的目标宽度

缩放因子(y 轴上)=以像素为单位的屏幕高度/以像素为单位的目标高度

目标宽度和高度等于图形素材设计的屏幕分辨率;在副本岛中,尺寸为 480×320 像素。对于 Nexus One,x 轴上的缩放因子为 1.66,y 轴上的缩放因子为 1.5。为什么两个轴上的比例因子不同?

这是因为两种屏幕分辨率具有不同的纵横比。如果我们简单地将 480×320 像素的图像拉伸为 800×480 像素的图像,则原始图像在 x 轴上被拉伸。对于大多数游戏来说,这无关紧要,所以我们可以简单地为特定的目标分辨率绘制图形资源,并在渲染时将它们拉伸到实际的屏幕分辨率(记住 Bitmap.drawBitmap()方法)。

然而,对于一些游戏,你可能想要使用一个更复杂的方法。图 5-3 显示了复制岛 从 480×320 放大到 800×480 像素,并覆盖了一张看起来真实的模糊图像。

9781430246770_Fig05-03.jpg

图 5-3。复制岛从 480×320 像素延伸到 800×480 像素,覆盖了一个在 800×480 像素显示器上呈现的模糊图像

复制岛使用我们刚刚计算的缩放因子(1.5)在 y 轴上执行正常拉伸,但不是使用会挤压图像的 x 轴缩放因子(1.66),而是使用 y 轴缩放因子。这个技巧允许屏幕上的所有对象保持它们的纵横比。32×32 像素的精灵变成 48×48 像素,而不是 53×48 像素。但是,这也意味着我们的坐标系不再有界在(0,0)和(479,319)之间;而是从(0,0)到(533,319)。这就是为什么我们在 Nexus One 上比在 HTC Hero 上看到更多的副本岛

但是,请注意,使用这种奇特的方法可能不适合某些游戏。例如,如果世界的大小取决于屏幕的长宽比,拥有更宽屏幕的玩家可能会有不公*的优势。像《星际争霸 2》这样的游戏就属于这种情况。最后,如果你想让整个游戏适合一个屏幕,就像《诺姆先生》一样,最好使用更简单的拉伸方法;如果我们使用第二个版本,在更宽的屏幕上会留下空白。

更简单的解决方案

副本岛的一个优势是它通过硬件加速的 OpenGL ES 来完成所有这些拉伸和缩放。到目前为止,我们只讨论了如何通过 Canvas 类绘制位图和视图,在旧版本的 Android 上,Canvas 类涉及 CPU 上缓慢的数字处理,而不涉及 GPU 上的硬件加速。

考虑到这一点,我们用我们的目标分辨率以位图实例的形式创建一个帧缓冲区,来执行一个简单的技巧。这样,当我们设计图形素材或通过代码渲染它们时,我们就不必担心实际的屏幕分辨率。相反,我们假设屏幕分辨率在所有设备上都是相同的,并且我们所有的绘制调用都通过 Canvas 实例将这个“虚拟”帧缓冲位图作为目标。当我们渲染完一个帧后,我们只需通过调用 Canvas.drawBitmap()方法将这个帧缓冲区位图绘制到我们的 SurfaceView,这允许我们绘制一个拉伸的位图。

如果我们想要使用与副本岛相同的技术,我们需要在更大的轴上调整我们的帧缓冲区的大小(即,在横向模式下在 x 轴上,在纵向模式下在 y 轴上)。我们还必须确保填充额外的像素,以避免空白。

实施

让我们总结一个工作计划中的一切:

  • 我们为固定的目标分辨率设计了所有的图形资源(Nom 先生的分辨率为 320×480)。
  • 我们创建一个与目标分辨率大小相同的位图,并将所有的绘图调用指向它,有效地在一个固定的坐标系中工作。
  • 当我们画完一个帧后,我们画一个被拉伸到 SurfaceView 的帧缓冲位图。在屏幕分辨率较低的设备上,图像会缩小;在分辨率较高的设备上,它会放大。
  • 当我们使用缩放技巧时,我们确保所有用户交互的 UI 元素对于所有的屏幕密度都足够大。我们可以在图形素材设计阶段使用实际设备的尺寸并结合前面提到的公式来实现这一点。

现在我们知道了如何处理不同的屏幕分辨率和密度,我们可以解释在前面几节中实现 SingleTouchHandler 和 MultiTouchHandler 时遇到的 scaleX 和 scaleY 变量。

我们所有的游戏代码都将使用我们固定的目标分辨率(320×480 像素)。如果我们在分辨率更高或更低的设备上接收触摸事件,这些事件的 x 和 y 坐标将在视图的坐标系中定义,而不是在我们的目标分辨率坐标系中定义。因此,有必要将坐标从其原始系统转换到我们的系统,这是基于比例因子的。为此,我们使用以下等式:

transformed touch x = real touch x * (target pixels on x axis / real pixels on x axis)

transformed touch y = real touch y * (target pixels on y axis / real pixels on y axis)

让我们计算一个简单的例子,目标分辨率为 320×480 像素,设备分辨率为 480×800 像素。如果我们触摸屏幕的中间,我们会收到一个坐标为(240,400)的事件。使用前面的两个公式,我们得到下面的方程,这些方程正好在我们的目标坐标系的中间:

transformed touch x = 240 * (320 / 480) = 160

transformed touch y = 400 * (480 / 800) = 240

让我们再做一个,假设实际分辨率为 240×320,再次触摸屏幕的中间,在(120,160):

transformed touch x = 120 * (320 / 240) = 160

transformed touch y = 160 * (480 / 320) = 240

这是双向的。如果我们将真实触摸事件坐标乘以目标因子除以真实因子,我们就不必担心转换我们实际的游戏代码。所有触摸坐标将在我们的固定目标坐标系中表示。

有了这个问题,我们就可以实现游戏框架的最后几个类了。

AndroidPixmap:为人民服务的像素

根据我们从第三章开始的 Pixmap 接口设计,实现的东西不多。清单 5-13 给出了代码。

清单 5-13 。【AndroidPixmap.java】一个 Pixmap 实现包装位图

package com.badlogic.androidgames.framework.impl;

import android.graphics.Bitmap;

import com.badlogic.androidgames.framework.Graphics.PixmapFormat;
import com.badlogic.androidgames.framework.Pixmap;

public class AndroidPixmapimplements Pixmap {
    Bitmap bitmap;
    PixmapFormat format;

    public AndroidPixmap(Bitmap bitmap, PixmapFormat format) {
        this.bitmap = bitmap;
        this.format = format;
    }
    public int getWidth() {
        return bitmap.getWidth();
    }
    public int getHeight() {
        return bitmap.getHeight();
    }
    public PixmapFormat getFormat() {
        return format;
    }
    public void dispose() {
        bitmap.recycle();
    }
}

我们所需要做的就是存储我们包装的位图实例,以及它的格式,它被存储为一个 PixmapFormat 枚举值,如第三章中定义的那样。此外,我们实现了 Pixmap 接口所需的方法,以便我们可以查询 Pixmap 的宽度和高度,以及它的格式,并确保像素可以从 RAM 中转储。注意位图成员是包私有的,所以我们可以在 AndroidGraphics 中访问它,我们现在将实现它。

AndroidGraphics:满足我们的绘图需求

我们在第三章中设计的图形界面也是精益吝啬的。它将画像素,线条,矩形和像素映射到帧缓冲区。如前所述,我们将使用位图作为帧缓冲区,并通过画布将所有绘图调用指向它。它还负责从资源文件创建位图实例。因此,我们还需要另一个素材管理者。清单 5-14 显示了我们实现接口 AndroidGraphics 的代码,并附有注释。

清单 5-14 。【AndroidGraphics.java】;实现图形接口

package com.badlogic.androidgames.framework.impl;

import java.io.IOException;
import java.io.InputStream;

import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.Bitmap.Config;
import android.graphics.BitmapFactory;
import android.graphics.BitmapFactory.Options;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.Rect;

import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Pixmap;

public class AndroidGraphics implements Graphics {
    AssetManager assets;
    Bitmap frameBuffer;
    Canvas canvas;
    Paint paint;
    Rect srcRect = new Rect();
    Rect dstRect = new Rect();

该类实现图形接口。它包含一个我们用来加载位图实例的 AssetManager 成员、一个表示我们的人工帧缓冲区的 Bitmap 成员、一个我们用来绘制到人工帧缓冲区的 Canvas 成员、一个我们绘制所需的 Paint 成员以及两个我们实现 AndroidGraphics.drawPixmap()方法所需的 Rect 成员。这最后三个成员就在那里,所以我们不必在每次 draw 调用时都创建这些类的新实例。这将给垃圾收集器带来许多问题。

    public AndroidGraphics(AssetManager assets, Bitmap frameBuffer) {
        this.assets = assets;
        this.frameBuffer = frameBuffer;
        this.canvas = new Canvas(frameBuffer);
        this.paint = new Paint();
    }

在构造函数中,我们得到了一个 AssetManager 和位图,它们从外部代表了我们的人工帧缓冲区。我们将它们存储在各自的成员中,并创建 Canvas 实例,该实例将绘制人工 framebuffer 以及 Paint,我们将它用于一些绘制方法。

    public Pixmap newPixmap(String fileName, PixmapFormat format) {
        Config config = null ;
        if (format == PixmapFormat.*RGB565*)
            config = Config.*RGB_565*;
        else if (format == PixmapFormat.*ARGB4444*)
            config = Config.*ARGB_4444*;
        else 
            config = Config.*ARGB_8888*;

        Options options = new Options();
        options.inPreferredConfig = config;

        InputStream in = null ;
        Bitmap bitmap = null ;
        try {
            in = assets.open(fileName);
            bitmap = BitmapFactory.*decodeStream*(in);
            if (bitmap ==null )
                throw new RuntimeException("Couldn't load bitmap from asset '"
                        + fileName + "'");
        }catch (IOException e) {
            throw new RuntimeException("Couldn't load bitmap from asset '"
                    + fileName + "'");
        }finally {
            if (in != null ) {
                try {
                    in.close();
                }catch (IOException e) {
                }
            }
        }
        if (bitmap.getConfig() == Config.*RGB_565*)
            format = PixmapFormat.*RGB565*;
        else if (bitmap.getConfig() == Config.*ARGB_4444*)
            format = PixmapFormat.*ARGB4444*;
        else 
            format = PixmapFormat.*ARGB8888*;

        return new AndroidPixmap(bitmap, format);
    }

newPixmap()方法尝试使用指定的 PixmapFormat 从资源文件中加载位图。我们首先将 PixmapFormat 翻译成在第四章中使用的 Android Config 类的常量之一。接下来,我们创建一个新的 Options 实例,并设置我们的首选颜色格式。然后,我们尝试通过 BitmapFactory 从素材中加载位图,如果出错,就会抛出 RuntimeException。否则,我们检查 BitmapFactory 使用什么格式来加载位图,并将其转换为 PixmapFormat 枚举值。请记住,BitmapFactory 可能会决定忽略我们想要的颜色格式,所以我们必须检查以确定它使用什么来解码图像。最后,我们基于加载的位图及其 PixmapFormat 构造一个新的 AndroidBitmap 实例,并将其返回给调用者。

    public void clear(int color) {
        canvas.drawRGB((color & 0xff0000) >> 16, (color & 0xff00) >> 8,
                (color & 0xff));
    }

clear()方法提取指定的 32 位 ARGB 颜色参数的红色、绿色和蓝色分量,并调用 Canvas.drawRGB()方法,该方法用该颜色清除我们的人工帧缓冲区。这个方法忽略了指定颜色的任何 alpha 值,所以我们不必提取它。

    public void drawPixel(int x, int y, int color) {
        paint.setColor(color);
        canvas.drawPoint(x, y, paint);
    }

drawPixel()方法通过 Canvas.drawPoint()方法绘制我们的人工帧缓冲区的像素。首先,我们设置 Paint 成员变量的颜色,并将其传递给 drawing 方法以及像素的 x 和 y 坐标。

    public void drawLine(int x, int y, int x2, int y2, int color) {
        paint.setColor(color);
        canvas.drawLine(x, y, x2, y2, paint);
    }

drawLine()方法绘制人工 framebuffer 的给定线条,调用 Canvas.drawLine()方法时使用 Paint 成员指定颜色。

    public void drawRect(int x, int y, int width, int height, int color) {
        paint.setColor(color);
        paint.setStyle(Style.*FILL*);
        canvas.drawRect(x, y, x + width - 1, y + width - 1, paint);
    }

drawRect()方法设置 Paint 成员的颜色和样式属性,以便我们可以绘制一个填充的彩色矩形。在实际的 Canvas.drawRect()调用中,我们必须转换矩形左上角和右下角坐标的 x、y、宽度和高度参数。对于左上角,我们简单地使用 x 和 y 参数。对于右下角,我们将 x 和 y 的宽度和高度相加,然后减去 1。例如,如果我们渲染一个矩形,其 x 和 y 为(10,10),宽度和高度分别为 2 和 2,并且不减去 1,则屏幕上的矩形大小将为 3×3 像素。

    public void drawPixmap(Pixmap pixmap, int x, int y, int srcX, int srcY,
            int srcWidth, int srcHeight) {
        srcRect.left = srcX;
        srcRect.top = srcY;
        srcRect.right = srcX + srcWidth - 1;
        srcRect.bottom = srcY + srcHeight - 1;

        dstRect.left = x;
        dstRect.top = y;
        dstRect.right = x + srcWidth - 1;
        dstRect.bottom = y + srcHeight - 1;

        canvas.drawBitmap(((AndroidPixmap) pixmap).bitmap, srcRect, dstRect, null );
    }

drawPixmap()方法允许我们绘制 Pixmap 的一部分,它设置了实际绘图调用中使用的 Rect 成员的源和目的地。与绘制矩形一样,我们必须将 x 和 y 坐标连同宽度和高度一起转换到左上角和右下角。同样,我们必须减去 1,否则我们将超调 1 个像素。接下来,我们通过 Canvas.drawBitmap()方法执行实际的绘制,如果我们绘制的 Pixmap 具有 PixmapFormat,该方法将自动进行混合。ARGB4444 或 PixmapFormat。ARGB8888 颜色深度。请注意,我们必须将 Pixmap 参数转换为 AndroidPixmap,以便获取位图成员来用画布进行绘制。这有点复杂,但是我们可以确定传入的 Pixmap 实例将是一个 AndroidPixmap。

    public void drawPixmap(Pixmap pixmap, int x, int y) {
        canvas.drawBitmap(((AndroidPixmap)pixmap).bitmap, x, y, null );
    }

第二个 drawPixmap()方法在给定坐标处将完整的 Pixmap 绘制到人工帧缓冲区。同样,我们必须做一些转换来获得 AndroidPixmap 的位图成员。

    public int getWidth() {
        return frameBuffer.getWidth();
    }
    public int getHeight() {
        return frameBuffer.getHeight();
    }
}

最后,我们有 getWidth()和 getHeight()方法,它们简单地返回由 AndroidGraphics 类存储的人工帧缓冲区的大小,并在内部呈现给该类。

AndroidFastRenderView 是我们需要实现的最后一个类。

AndroidFastRenderView:循环,拉伸,循环,拉伸

这个类的名字应该给出未来的事情。在第四章中,我们讨论了使用 SurfaceView 在一个单独的线程中执行连续渲染,这个线程也可以容纳我们游戏的主循环。我们开发了一个非常简单的类,名为 FastRenderView,它是从 SurfaceView 类派生而来的,我们确保我们很好地处理了活动生命周期,并且我们设置了一个线程,以便通过画布持续呈现 SurfaceView。这里,我们将重用这个 FastRenderView 类,并扩充它来做更多的事情:

  • 它保存了一个对游戏实例的引用,可以从中获取活动屏幕。我们不断地从 FastRenderView 线程中调用 Screen.update()和 Screen.present()方法。
  • 它跟踪传递到活动屏幕的帧之间的时间增量。

它接受 AndroidGraphics 实例绘制的人工帧缓冲区,并将其绘制到 SurfaceView,如果需要,将对其进行缩放。

清单 5-15 显示了 AndroidFastRenderView 类的实现,并在适当的地方添加了注释。

清单 5-15 。【AndroidFastRenderView.java】线程化的 SurfaceView 执行我们的游戏代码

package com.badlogic.androidgames.framework.impl;

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class AndroidFastRenderViewextends SurfaceView implements Runnable {
    AndroidGame game;
    Bitmap framebuffer;
    Thread renderThread = null ;
    SurfaceHolder holder;
    volatile boolean running = false ;

这个应该看着眼熟。我们只需要再添加两个成员——一个 AndroidGame 实例和一个代表我们的人工帧缓冲区的位图实例。其他成员与第三章中的 FastRenderView 相同。

    public AndroidFastRenderView(AndroidGame game, Bitmap framebuffer) {
        super (game);
        this.game = game;
        this.framebuffer = framebuffer;
        this.holder = getHolder();
    }

在构造函数中,我们简单地用 AndroidGame 参数调用基类的构造函数(这是一个活动;这将在下面的部分中讨论)并将参数存储在各自的成员中。和前面几节一样,我们又一次得到了一个 SurfaceHolder。

    public void resume() {
        running = true ;
        renderThread = new Thread(this );
        renderThread.start();
    }

resume()方法是 FastRenderView.resume()方法的精确副本,因此我们不再讨论它。简而言之,该方法确保我们的线程与活动生命周期很好地交互。

    public void run() {
        Rect dstRect = new Rect();
        long startTime = System.*nanoTime*();
        while (running) {
            if (!holder.getSurface().isValid())
                continue ;

            float deltaTime = (System.*nanoTime*()-startTime) / 1000000000.0f;
            startTime = System.*nanoTime*();

            game.getCurrentScreen().update(deltaTime);
            game.getCurrentScreen().present(deltaTime);

            Canvas canvas = holder.lockCanvas();
            canvas.getClipBounds(dstRect);
            canvas.drawBitmap(framebuffer, null , dstRect, null );
            holder.unlockCanvasAndPost(canvas);
        }
    }

run()方法还有一些特性。第一个新增功能是它能够跟踪每帧之间的增量时间。为此,我们使用 System.nanoTime(),它以长整型返回以纳秒为单位的当前时间。

注意:一纳秒是一秒的十亿分之一。

在每次循环迭代中,我们从上一次循环迭代的开始时间和当前时间之间的差值开始。为了更容易处理这个增量,我们把它转换成秒。接下来,我们保存当前时间戳,我们将在下一次循环迭代中使用它来计算下一个增量时间。有了增量时间,我们调用当前屏幕实例的 update()和 present()方法,这将更新游戏逻辑并将内容渲染到人工帧缓冲区。最后,我们得到了表面视图的画布,并绘制了人工帧缓冲区。如果我们传递给 Canvas.drawBitmap()方法的目标矩形小于或大于 framebuffer,则会自动执行缩放。

注意,我们在这里使用了一个快捷方式,通过 Canvas.getClipBounds()方法获得一个延伸到整个 SurfaceView 的目标矩形。它会将 dstRect 的顶部和左侧成员分别设置为 0 和 0,将底部和右侧成员设置为实际屏幕尺寸(在 Nexus One 上,纵向模式下为 480×800)。该方法的其余部分与我们在上一章的 FastRenderView 测试中使用的完全相同。该方法只是确保线程在活动暂停或销毁时停止。

    public void pause() {
        running = false ;
        while (true ) {
            try {
                renderThread.join();
                return ;
            }catch (InterruptedException e) {
                // retry
            }
        }
    }
}

该类的最后一个方法 pause()也与 FastRenderView.pause()方法相同,它只是终止渲染/主循环线程,并等待它完全死亡后再返回。

我们差不多完成了我们的框架。拼图的最后一块是游戏界面的实现。

安卓游戏:把所有东西绑在一起

我们的游戏开发框架即将完成。我们所需要做的就是通过实现我们在第三章设计的游戏界面来把松散的部分连接起来。为此,我们将使用我们在本章前面几节中创建的类。以下是责任清单:

  • 执行窗口管理。在我们的上下文中,这意味着设置一个活动和一个 AndroidFastRenderView,并以干净的方式处理活动生命周期。
  • 使用和管理唤醒锁,使屏幕不会变暗。
  • 实例化并向感兴趣的各方分发图形、音频、文件和输入的引用。
  • 管理屏幕并将其与活动生命周期集成。
  • 我们的总体目标是有一个叫 AndroidGame 的类,我们可以从中派生。我们希望稍后实现 Game.getStartScreen()方法,以下面的方式开始我们的游戏。
public class MrNomextends AndroidGame {
    public Screen getStartScreen() {
        return new MainMenu(this );
    }
}

我们希望你能明白为什么在一头扎进实际的游戏编程之前设计一个可行的框架是有益的。我们可以在未来所有不需要太多图形的游戏中重用这个框架。现在,让我们讨论清单 5-16 ,它显示了 AndroidGame 类,被注释分开。

清单 5-16 。【AndroidGame.java】;将一切联系在一起

package com.badlogic.androidgames.framework.impl;

import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Bitmap.Config;
import android.os.Bundle;
import android.os.PowerManager;
import android.os.PowerManager.WakeLock;
import android.view.Window;
import android.view.WindowManager;

import com.badlogic.androidgames.framework.Audio;
import com.badlogic.androidgames.framework.FileIO;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input;
import com.badlogic.androidgames.framework.Screen;

public abstract class AndroidGameextends Activity implements Game {
    AndroidFastRenderView renderView;
    Graphics graphics;
    Audio audio;
    Input input;
    FileIO fileIO;
    Screen screen;
    WakeLock wakeLock;

类定义从让 AndroidGame 扩展 Activity 类,实现游戏接口开始。接下来,我们定义几个应该已经熟悉的成员。第一个成员是 AndroidFastRenderView,我们将绘制到它,它将为我们管理主循环线程。当然,我们将 Graphics、Audio、Input 和 FileIO 成员设置为 AndroidGraphics、AndroidAudio、AndroidInput 和 AndroidFileIO 的实例。下一个成员持有当前活动的屏幕。最后,有一个成员持有一个唤醒锁,我们用它来防止屏幕变暗。

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

        requestWindowFeature(Window.*FEATURE_NO_TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG_FULLSCREEN*,
                WindowManager.LayoutParams.*FLAG_FULLSCREEN*);

        boolean isLandscape = getResources().getConfiguration().orientation == Configuration.*ORIENTATION_LANDSCAPE*;
        int frameBufferWidth = isLandscape ? 480 : 320;
        int frameBufferHeight = isLandscape ? 320 : 480;
        Bitmap frameBuffer = Bitmap.*createBitmap*(frameBufferWidth,
                frameBufferHeight, Config.*RGB_565*);
        float scaleX = (float ) frameBufferWidth
                / getWindowManager().getDefaultDisplay().getWidth();
        float scaleY = (float ) frameBufferHeight
                / getWindowManager().getDefaultDisplay().getHeight();

        renderView = new AndroidFastRenderView(this , frameBuffer);
        graphics = new AndroidGraphics(getAssets(), frameBuffer);
        fileIO = new AndroidFileIO(this );
        audio = new AndroidAudio(this );
        input = new AndroidInput(this , renderView, scaleX, scaleY);
        screen = getStartScreen();
        setContentView(renderView);

        PowerManager powerManager = (PowerManager) getSystemService(Context.*POWER_SERVICE*);
        wakeLock = powerManager.newWakeLock(PowerManager.*FULL_WAKE_LOCK*, "GLGame");
    }

onCreate()方法是我们熟悉的 Activity 类的启动方法,它通过根据需要调用基类的 onCreate()方法来启动。接下来,我们让活动全屏显示,就像我们在第四章的其他几个测试中所做的那样。在接下来的几行中,我们设置了我们的人工帧缓冲区。根据活动的方向,我们希望使用 320×480 帧缓冲区(纵向模式)或 480×320 帧缓冲区(横向模式)。为了确定活动的屏幕方向,我们从一个名为 Configuration 的类中获取方向成员,这个类是通过调用 getResources()获得的。getConfiguration()。基于该成员的值,我们然后设置帧缓冲区大小并实例化一个位图,我们将在接下来的章节中把它交给 AndroidFastRenderView 和 AndroidGraphics 实例。

注意位图实例具有 RGB565 颜色格式。这样就不浪费内存,我们的画图完成的也快一点。

注意对于我们的第一个游戏,Nom 先生,我们将使用 320×480 像素的目标分辨率。AndroidGame 类硬编码了这些值。如果你想使用不同的目标分辨率,相应地修改 AndroidGame!

我们还计算 scaleX 和 scaleY 值,SingleTouchHandler 和 MultiTouchHandler 类将使用它们来转换固定坐标系中的触摸事件坐标。

接下来,我们用必要的构造函数参数实例化 AndroidFastRenderView、AndroidGraphics、AndroidAudio、AndroidInput 和 AndroidFileIO。最后,我们调用 getStartScreen()方法,我们的游戏将实现该方法,并将 AndroidFastRenderView 设置为活动的内容视图。当然,所有先前实例化的助手类将在后台做更多的工作。例如,AndroidInput 类告诉选定的触摸处理程序与 AndroidFastRenderView 通信。

    @Override
    public void onResume() {
        super.onResume();
        wakeLock.acquire();
        screen.resume();
        renderView.resume();
    }

接下来是 Activity 类的 onResume()方法,我们覆盖了它。像往常一样,我们做的第一件事是调用超类方法。接下来,我们获取唤醒锁,并确保当前屏幕被告知游戏以及活动已经恢复。最后,我们告诉 AndroidFastRenderView 恢复渲染线程,这也将开始我们游戏的主循环,在这里我们告诉当前屏幕在每次迭代中更新和呈现它自己。

    @Override
    public void onPause() {
        super.onPause();
        wakeLock.release();
        renderView.pause();
        screen.pause();
        if (isFinishing())
            screen.dispose();
    }

首先,onPause()方法再次调用超类方法。接下来,它释放唤醒锁,并确保渲染线程终止。如果我们在调用当前屏幕的 onPause()方法之前不终止线程,我们可能会遇到并发问题,因为 UI 线程和主循环线程将同时访问屏幕。一旦我们确定主循环线程不再存在,我们告诉当前屏幕它应该暂停自己。如果活动将被销毁,我们还会通知屏幕,以便它可以做任何必要的清理工作。

    public Input getInput() {
        return input;
    }
    public FileIO getFileIO() {
        return fileIO;
    }
    public Graphics getGraphics() {
        return graphics;
    }
    public Audio getAudio() {
        return audio;
    }

getInput()、getFileIO()、getGraphics()和 getAudio()方法无需解释。我们只是将各自的实例返回给调用者。后来,调用方将始终是我们游戏的屏幕实现之一。

    public void setScreen(Screen screen) {
        if (screen ==null )
            throw new IllegalArgumentException("Screen must not be null");
        this.screen.pause();
        this.screen.dispose();
        screen.resume();
        screen.update(0);
        this.screen = screen;
    }

起初,我们从游戏接口继承的 setScreen()方法看起来很简单。我们从一些传统的空检查开始,因为我们不允许空屏幕。接下来,我们告诉当前屏幕暂停并释放自己,以便为新屏幕腾出空间。新屏幕被要求以零的增量时间自我恢复和自我更新一次。最后,我们将屏幕成员设置为新屏幕。

让我们想想谁会在什么时候调用这个方法。当我们设计 Mr. Nom 时,我们确定了各种屏幕实例之间的所有转换。我们通常会在这些屏幕实例之一的 update()方法中调用 AndroidGame.setScreen()方法。

例如,假设我们有一个主菜单屏幕,在这里我们检查是否在 update()方法中按下了 Play 按钮。如果是这种情况,我们将通过从 MainMenu.update()方法中调用 AndroidGame.setScreen()方法,使用下一个屏幕的全新实例来转换到下一个屏幕。在调用 AndroidGame.setScreen()之后,主菜单屏幕将重新获得控制权,并且应该立即返回到调用方,因为它不再是活动屏幕。在这种情况下,调用者是主循环线程中的 AndroidFastRenderView。如果您检查主循环中负责更新和呈现活动屏幕的部分,您将看到 update()方法将在 MainMenu 类上调用,但是 present()方法将在新的当前屏幕上调用。这可能会有问题,因为我们定义屏幕接口的方式保证了在屏幕被要求显示之前,resume()和 update()方法至少会被调用一次。这就是为什么我们在新屏幕上的 AndroidGame.setScreen()方法中调用这两个方法。AndroidGame 类负责一切。

    public Screen getCurrentScreen() {
        return screen;
    }
}

最后一个方法是 getCurrentScreen()方法,它只返回当前活动的屏幕。

最后,记住 AndroidGame 是从 Game 派生出来的,Game 有另外一个方法叫做 getStartScreen()。这是我们必须实现的方法,让我们的游戏进行下去!

现在,我们已经创建了一个易于使用的 Android 游戏开发框架。我们需要做的就是实现我们游戏的屏幕。我们也可以在未来的游戏中重用这个框架,只要它们不需要强大的图形能力。如果有必要,我们必须使用 OpenGL ES。然而,要做到这一点,我们只需要替换我们框架的图形部分。音频、输入和文件 I/O 的所有其他类都可以重用。

摘要

在这一章中,我们从零开始实现了一个成熟的 2D Android 游戏开发框架,它可以在所有未来的游戏中重用(只要它们在图形上是适度的)。为了实现一个良好的、可扩展的设计,我们非常小心。我们可以把代码和渲染部分替换成 OpenGL ES,这样就可以制作出 3D 的 Nom 先生。

有了所有这些样板代码,让我们专注于我们在这里的目的:编写游戏!*

六、Nom 先生入侵 Android

在第三章中,我们为 Nom 先生做了一个完整的设计,包括游戏机制,一个简单的背景故事,手工制作的图形资源,以及基于一些剪纸的所有屏幕的定义。在《??》第五章中,我们开发了一个成熟的游戏开发框架,让我们可以轻松地将设计画面转换成代码。但是说够了;让我们开始写我们的第一个游戏吧!

创建素材

我们在 Nom 先生有两种素材:音频素材和图形素材。我们通过一个叫做 Audacity 的开源应用和一个糟糕的上网本麦克风录制了音频素材。我们创造了一种声音效果,当按下按钮或选择菜单项时播放,一种是当 Nom 先生吃了污渍时播放,另一种是当他吃了自己时播放。我们将它们作为 OGGs 保存到 assets/文件夹中,分别命名为 click.ogg、eat.ogg 和 bitten.ogg。您可以发挥创造力,使用 Audacity 和麦克风自己创建这些文件,或者您可以在http://code.google.com/p/beginnginandroidgames2/从 SVN 存储库中获取这些文件。如果你不熟悉 SVN,请看前面我们描述如何获得源代码的内容。

早些时候,我们提到过,我们希望将那些设计阶段的剪纸重新用作我们真正的游戏图形。为此,我们首先必须使它们符合我们的目标分辨率。

我们选择了 320 × 480(纵向模式)的固定目标分辨率,我们将为其设计所有的图形素材。这可能看起来很小,但它使我们开发游戏和图形变得非常快速和容易,毕竟,这里的重点是你可以看到整个 Android 游戏开发过程。

对于您的制作游戏,考虑所有的分辨率并使用更高分辨率的图形,以便您的游戏在*板电脑大小的屏幕上看起来很好,也许目标是 800 × 1280 作为基线。我们扫描了所有的剪纸,并稍微调整了一下尺寸。我们将大部分素材保存在单独的文件中,并将其中一些合并到一个文件中。所有图像都以 PNG 格式保存。背景是 RGB888 的唯一图像;其他都是 ARGB8888。图 6-1 向你展示了我们最终的结果。

9781430246770_Fig06-01.jpg

图 6-1。Nom 先生的所有图形素材及其各自的文件名和像素大小

让我们稍微分解一下这些图像:

  • 这是我们的背景图像,这将是我们绘制到 framebuffer 的第一个东西。由于显而易见的原因,它与我们的目标分辨率大小相同。
  • 这包含了我们在游戏中需要的所有按钮。我们将它们放在一个文件中,因为我们可以通过 Graphics.drawPixmap()方法轻松地绘制它们,该方法允许绘制图像的一部分。当我们开始用 OpenGL ES 绘图时,我们会更频繁地使用这种技术,所以我们现在最好习惯它。将几幅图像合并成一幅图像通常被称为图册,图像本身被称为图像图册(或纹理图册,或 sprite sheet)。每个按钮的大小为 64 × 64 像素,当我们必须判断触摸事件是否按下了屏幕上的按钮时,这将派上用场。
  • help3.png、help3.png 和 help3.png:这些是我们将在 Nom 先生的三个帮助屏幕上显示的图像。它们的大小都一样,这使得将它们放在屏幕上更容易。
  • logo.png:这是我们将在主菜单屏幕上显示的徽标。
  • 这包含了我们将在主菜单上呈现给玩家的三个选项。选择其中一个将触发到相应屏幕的转换。每个选项的高度大约为 42 像素,我们可以用它来轻松检测哪个选项被触摸了。
  • ready.png、pause.png 和 gameover.png:我们会在游戏即将开始、暂停和结束时画出这些。
  • numbers.png:它保存了我们稍后获得高分所需的所有数字。关于这个图像需要记住的是,每个数字都有相同的宽度和高度,20 × 32 像素,除了末尾的点,它是 10 × 32 像素。我们可以用它来呈现任何抛给我们的数字。
  • tail.png:这是 Nom 先生的尾巴,或者说是他尾巴的一部分。尺寸是 32 × 32 像素。
  • headup.png,headup.png,headright.png,还有 headup.png:这些图片是给诺姆先生的头像;他能移动的每个方向都有一个。因为他的帽子,我们不得不把这些图像做得比尾巴图像大一点。每张头像尺寸为 42 × 42 像素。
  • stain3.png、stain3.png 和染色剂 3.png:这是我们可以渲染的三种染色剂。拥有三种类型会让游戏画面更多样化一点。它们的尺寸是 32 × 32 像素,就像尾图一样。

很好,现在让我们开始实现屏幕!

设置项目

正如在第五章中提到的,我们将把 Nom 先生的代码与我们的框架代码合并。所有与 Nom 先生相关的类都将被放入包 com . badlogic . androidgames . Mr Nom .另外,我们必须修改清单文件,如第四章所述。我们的默认活动将被称为 MrNomGame。只需按照第四章中“Android 游戏项目设置八个简单步骤”一节中概述的八个步骤,正确设置<活动>属性(也就是说,游戏以纵向模式固定,配置更改由应用处理),并给予我们的应用适当的权限(写入外部存储,使用唤醒锁,等等)。

前面章节中的所有素材都位于项目的素材/文件夹中。此外,我们必须将 ic_launcher.png 文件放入 res/drawable、res/drawable-ldpi、res/drawable-mdpi、res/drawable-hdpi 和 res/drawable-xhdpi 文件夹中。我们只是拿了 Nom 先生的 headright.png,将其重命名为 ic_launcher.png,并在每个文件夹中放了一个大小适当的版本。

剩下的就是把我们的游戏代码放到 Eclipse 项目的 com . bad logic . androidgames . Mr nom 包里了!

MrNomGame:主要活动

我们的应用需要一个主入口点,也就是 Android 上的默认活动。我们将把这个默认活动称为 MrNomGame,并让它从 AndroidGame 派生,这个类是我们在第五章中实现的,用来运行我们的游戏。稍后,它将负责创建和运行我们的第一个屏幕。清单 6-1 展示了我们的 MrNomGame 类。

清单 6-1 。【MrNomGame.java】;我们的主要活动/游戏混合

package com.badlogic.androidgames.mrnom;

import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.impl.AndroidGame;

public class MrNomGame extends AndroidGame {
    public Screen getStartScreen() {
        returnnew LoadingScreen(this );
    }
}

我们需要做的就是从 AndroidGame 派生并实现 getStartScreen()方法,这将返回 LoadingScreen 类的一个实例(我们将在一分钟内实现)。请记住,这将使我们从游戏所需的所有东西开始,从设置音频、图形、输入和文件 I/O 的不同模块到启动主循环线程。很简单,是吧?

素材:一个方便的素材商店

加载屏幕将加载我们游戏的所有素材。但是我们把它们存放在哪里呢?为了存储它们,我们将做一些在 Java 领域不常见的事情:我们将创建一个类,它有大量的公共静态成员,这些成员保存我们从素材中加载的所有位图和声音。清单 6-2 显示了那个类。

清单 6-2 。【Assets.java】;保存我们所有的像素图和声音以便于访问

package com.badlogic.androidgames.mrnom;

import com.badlogic.androidgames.framework.Pixmap;
import com.badlogic.androidgames.framework.Sound;

public class Assets {
    public static Pixmap*background*;
    public static Pixmap*logo*;
    public static Pixmap*mainMenu*;
    public static Pixmap*buttons*;
    public static Pixmap*help1*;
    public static Pixmap*help2*;
    public static Pixmap*help3*;
    public static Pixmap*numbers*;
    public static Pixmap*ready*;
    public static Pixmap*pause*;
    public static Pixmap*gameOver*;
    public static Pixmap*headUp*;
    public static Pixmap*headLeft*;
    public static Pixmap*headDown*;
    public static Pixmap*headRight*;
    public static Pixmap*tail*;
    public static Pixmap*stain1*;
    public static Pixmap*stain2*;
    public static Pixmap*stain3*;

    public static Sound*click*;
    public static Sound*eat*;
    public static Sound*bitten*;
}

我们从素材中加载的每个图像和声音都有一个静态成员。如果我们想使用这些素材中的一个,我们可以这样做:

game.getGraphics().drawPixmap(Assets.background, 0, 0)

或者类似这样的东西:

Assets.click.play(1);

这下方便了。但是,请注意,没有什么可以阻止我们覆盖这些静态成员,因为它们不是最终的。但是只要我们不覆盖它们,我们就是安全的。这些公共的、非最终的成员实际上使这个“设计模式”成为一个反模式。不过,对于我们的游戏来说,稍微懒一点是可以的。一个更干净的解决方案是将素材隐藏在所谓的单例类的 setters 和 getters 之后。我们会坚持我们穷人的素材经理。

设置:跟踪用户选择和高分

在加载屏幕中我们还需要加载另外两个东西:用户设置和高分。如果你回头看看第三章中的主菜单和高分屏幕,你会看到我们允许用户切换声音,并且我们存储了前五个高分。我们会将这些设置保存到外部存储器,以便下次游戏开始时可以重新加载它们。为此,我们将实现另一个简单的类,名为 Settings,如清单 6-3 所示。列表被拆分,评论相互交错。

清单 6-3 。【Settings.java】;存储我们的设置并加载/保存它们

package com.badlogic.androidgames.mrnom;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import com.badlogic.androidgames.framework.FileIO;

public class Settings {
    public static boolean *soundEnabled* = true ;
    public static int[] *highscores* = new int[]  { 100, 80, 50, 30, 10 };

是否回放声音效果由一个名为 soundEnabled 的公共静态布尔值决定。高分存储在一个五元素整数数组中,从最高到最低排序。我们为这两种设置定义了合理的默认值。我们可以像访问 Assets 类的成员一样访问这两个成员。

    public static void load(FileIO files) {
        BufferedReader in = null ;
        try {
            in = new BufferedReader( new InputStreamReader(
                    files.readFile(".mrnom")));
            *soundEnabled* = Boolean.*parseBoolean*(in.readLine());
            for (int i = 0; i < 5; i++) {
                *highscores*[i] = Integer.*parseInt*(in.readLine());
            }
        } catch (IOException e) {
            // :( It's ok we have defaults
        } catch (NumberFormatException e) {
            // :/ It's ok, defaults save our day
        } finally {
            try {
                if (in !=null )
                    in.close();
            }catch (IOException e) {
            }
        }
    }

静态 load()方法尝试从名为。来自外部存储器的 mrnom。为此它需要一个 FileIO 实例,我们将它传递给方法。它假设声音设置和每个高分条目存储在单独的行上,并简单地读入它们。如果出现任何问题(例如,如果外部存储不可用或者还没有设置文件),我们只需返回到默认值并忽略故障。

    public static void save(FileIO files) {
        BufferedWriter out = null ;
        try {
            out = new BufferedWriter(new OutputStreamWriter(
                    files.writeFile(".mrnom")));
            out.write(Boolean.*toString*(*soundEnabled*));
            for (int i = 0; i < 5; i++) {
                out.write(Integer.*toString*(*highscores*[i]));
            }
        }catch (IOException e) {
        }finally {
            try {
                if (out !=null )
                    out.close();
            }catch (IOException e) {
            }
        }
    }

接下来是一个叫做 save()的方法。它获取当前设置并将它们序列化到。外部存储器上的 mrnom 文件(即/sdcard/。mrnom)。正如 load()方法所期望的那样,声音设置和每个高分条目都作为单独的一行存储在该文件中。如果出现问题,我们只需忽略失败并使用前面定义的默认值。在 AAA 标题中,您可能希望通知用户这个加载错误。

值得注意的是,在 Android API 8 中,添加了更多特定的方法来处理托管的外部存储。添加了 Context.getExternalFilesDir()方法,它在外部存储中提供了一个特定的点,不会污染 SD 卡或内部闪存的根目录,并且在卸载应用时也会被清理。当然,增加对它的支持意味着要么为 API 8 动态加载一个类,要么将 SDK 的最小值设置为 8,这样就失去了向后兼容性。为了简单起见,Nom 先生将使用旧的 API 1 外部存储点,但是如果您需要一个如何动态加载类的例子,只需看看我们在第五章中的 TouchHandler 代码。

    public static void addScore(int score) {
        for (int i = 0; i < 5; i++) {
            if (*highscores*[i] < score) {
                for (int j = 4; j > i; j--)
                    *highscores*[j] = *highscores*[j - 1];
                *highscores*[i] = score;
                break ;
            }
        }
    }
}

最后一个方法 addScore()是一个方便的方法。我们将使用它向高分添加一个新的分数,根据我们想要插入的值自动重新排序。

LoadingScreen:从磁盘获取素材

有了这些类,我们现在可以轻松地实现加载屏幕。清单 6-4 显示了代码。

清单 6-4 。【LoadingScreen.java】;加载所有素材和设置

package com.badlogic.androidgames.mrnom;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.Graphics.PixmapFormat;

public class LoadingScreen extends Screen {
    public LoadingScreen(Game game) {
        super (game);
    }

我们让 LoadingScreen 类从我们在第三章中定义的 Screen 类派生出来。这要求我们实现一个接受游戏实例的构造函数,我们把它交给超类构造函数。注意,这个构造函数将在我们前面定义的 MrNomGame.getStartScreen()方法中被调用。

    public void update(float deltaTime) {
        Graphics g = game.getGraphics();
        Assets.*background* = g.newPixmap("background.png", PixmapFormat.*RGB565*);
        Assets.*logo* = g.newPixmap("logo.png", PixmapFormat.*ARGB4444*);
        Assets.*mainMenu* = g.newPixmap("mainmenu.png", PixmapFormat.*ARGB4444*);
        Assets.*buttons* = g.newPixmap("buttons.png", PixmapFormat.*ARGB4444*);
        Assets.*help1* = g.newPixmap("help1.png", PixmapFormat.*ARGB4444*);
        Assets.*help2* = g.newPixmap("help2.png", PixmapFormat.*ARGB4444*);
        Assets.*help3* = g.newPixmap("help3.png", PixmapFormat.*ARGB4444*);
        Assets.*numbers* = g.newPixmap("numbers.png", PixmapFormat.*ARGB4444*);
        Assets.*ready* = g.newPixmap("ready.png", PixmapFormat.*ARGB4444*);
        Assets.*pause* = g.newPixmap("pausemenu.png", PixmapFormat.*ARGB4444*);
        Assets.*gameOver* = g.newPixmap("gameover.png", PixmapFormat.*ARGB4444*);
        Assets.*headUp* = g.newPixmap("headup.png", PixmapFormat.*ARGB4444*);
        Assets.*headLeft* = g.newPixmap("headleft.png", PixmapFormat.*ARGB4444*);
        Assets.*headDown* = g.newPixmap("headdown.png", PixmapFormat.*ARGB4444*);
        Assets.*headRight* = g.newPixmap("headright.png", PixmapFormat.*ARGB4444*);
        Assets.*tail* = g.newPixmap("tail.png", PixmapFormat.*ARGB4444*);
        Assets.*stain1* = g.newPixmap("stain1.png", PixmapFormat.*ARGB4444*);
        Assets.*stain2* = g.newPixmap("stain2.png", PixmapFormat.*ARGB4444*);
        Assets.*stain3* = g.newPixmap("stain3.png", PixmapFormat.*ARGB4444*);
        Assets.*click* = game.getAudio().newSound("click.ogg");
        Assets.*eat* = game.getAudio().newSound("eat.ogg");
        Assets.*bitten* = game.getAudio().newSound("bitten.ogg");
        Settings.*load*(game.getFileIO());
        game.setScreen(new MainMenuScreen(game));
    }

接下来是 update()方法的实现,在这里我们加载素材和设置。对于图像素材,我们只需通过 Graphics.newPixmap()方法创建新的像素图。注意,我们指定了位图应该具有的颜色格式。背景为 RGB565 格式,所有其他图像为 ARGB4444 格式(如果 BitmapFactory 尊重我们的提示)。我们这样做是为了节省内存,并在稍后提高渲染速度。我们的原始图像以 RGB888 和 ARGB8888 格式存储为 png。我们还加载了三个声音效果,并将它们存储在 Assets 类的各个成员中。接下来,我们通过 Settings.load()方法从外部存储器加载设置。最后,我们启动一个屏幕转换到一个名为 MainMenuScreen 的屏幕,它将从那时起接管执行。

    public void present(float deltaTime) {
    }
    public void pause() {
    }
    public void resume() {
    }
    public void dispose() {
    }
}

其他方法只是存根,不执行任何操作。由于 update()方法将在加载所有素材后立即触发屏幕转换,所以在这个屏幕上没有什么可做的了。

主菜单屏幕

主菜单屏幕是相当愚蠢的。它只是以切换按钮的形式呈现徽标、主菜单选项和声音设置。它所做的只是对主菜单选项或声音设置切换按钮上的触摸做出反应。为了实现这一行为,我们需要知道两件事:我们在屏幕上的什么地方呈现图像,以及触发屏幕转换或切换声音设置的触摸区域是什么。图 6-2 显示了我们在屏幕上渲染不同图像的位置。由此我们可以直接得出触摸面积。

9781430246770_Fig06-02.jpg

图 6-2。主菜单屏幕。坐标指定了我们渲染不同图像的位置,轮廓显示了触摸区域。

计算徽标和主菜单选项图像的 x 坐标,使它们以 x 轴为中心。

接下来,我们来实现屏幕。清单 6-5 显示了代码。

清单 6-5 。【MainMenuScreen.java】;主菜单屏幕

package com.badlogic.androidgames.mrnom;

import java.util.List;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Screen;

public class MainMenuScreen extends Screen {
    public MainMenuScreen(Game game) {
        super (game);
    }

我们让这个类再次从 Screen 派生,并为它实现一个合适的构造函数。

    public void update(float deltaTime) {
        Graphics g = game.getGraphics();
        List < TouchEvent > touchEvents = game.getInput().getTouchEvents();
        game.getInput().getKeyEvents();
        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            if (event.type == TouchEvent.*TOUCH_UP*) {
                if (inBounds(event, 0, g.getHeight() - 64, 64, 64)) {
                    Settings.*soundEnabled* = !Settings.*soundEnabled*;
                    if (Settings.*soundEnabled*)
                        Assets.*click*.play(1);
                }
                if (inBounds(event, 64, 220, 192, 42) ) {
                    game.setScreen(new GameScreen(game));
                    if (Settings.*soundEnabled*)
                        Assets.*click*.play(1);
                    return ;
                }
                if (inBounds(event, 64, 220 + 42, 192, 42) ) {
                    game.setScreen(new HighscoreScreen(game));
                    if (Settings.*soundEnabled*)
                        Assets.*click*.play(1);
                    return ;
                }
                if (inBounds(event, 64, 220 + 84, 192, 42) ) {
                    game.setScreen(new HelpScreen(game));
                    if (Settings.*soundEnabled*)
                        Assets.*click*.play(1);
                    return ;
                }
            }
        }
    }

接下来,我们有 update()方法,,我们将在其中进行所有的触摸事件检查。我们首先从游戏提供给我们的输入实例中获取 TouchEvent 和 KeyEvent 实例。注意,我们不使用 KeyEvent 实例,但是为了清除内部缓冲区,我们还是获取了它们(是的,这有点讨厌,但是让我们养成习惯)。然后,我们遍历所有的 TouchEvent 实例,直到找到一个类型为 TouchEvent 的实例。润色。(我们也可以寻找触摸事件。TOUCH_DOWN 事件,但是在大多数 UI 中,up 事件用于指示 UI 组件被按下。)

一旦我们有了一个 fitting 事件,我们就检查它是按下了声音切换按钮还是某个菜单项。为了使代码更加简洁,我们编写了一个名为 inBounds()的方法,它接受一个触摸事件、x 和 y 坐标以及宽度和高度。该方法检查触摸事件是否在由这些参数定义的矩形内,并返回 true 或 false。

如果声音切换按钮被按下,我们只需反转设置。如果任何主菜单项被按下,我们通过实例化它并通过 Game.setScreen()设置它来转换到适当的屏幕。在这种情况下,我们可以立即返回,因为 MainMenuScreen 屏幕已经没有任何事情可做了。如果按下切换按钮或主菜单条目并启用声音,我们还会播放卡嗒声。

请记住,所有触摸事件都将相对于我们的目标分辨率 320 × 480 像素进行报告,这要归功于我们在第五章中讨论的触摸事件处理程序中执行的缩放魔法。

    private boolean inBounds(TouchEvent event, int x, int y, int width, int height) {
        if (event.x > x && event.x < x + width - 1 &&
           event.y > y && event.y < y + height - 1)
            return true ;
        else
            return false ;
    }

inBounds()方法的工作方式与前面讨论的一样:放入一个触摸事件和一个矩形,它会告诉您触摸事件的坐标是否在该矩形内。

    public void present(float deltaTime) {
        Graphics g = game.getGraphics();

        g.drawPixmap(Assets.*background*, 0, 0);
        g.drawPixmap(Assets.*logo*, 32, 20);
        g.drawPixmap(Assets.*mainMenu*, 64, 220);
        if (Settings.*soundEnabled*)
            g.drawPixmap(Assets.*buttons*, 0, 416, 0, 0, 64, 64);
        else
            g.drawPixmap(Assets.*buttons*, 0, 416, 64, 0, 64, 64);
    }

present()方法可能是您最期待的方法,但它并不那么令人兴奋。我们的小游戏框架使得渲染我们的主菜单屏幕变得非常简单。我们所做的就是在(0,0)处渲染背景,这将基本上擦除我们的帧缓冲区,所以不需要调用 Graphics.clear()。接下来,我们在图 6-2 所示的坐标处绘制标志和主菜单条目。我们通过绘制基于当前设置的声音切换按钮来结束该方法。正如你所看到的,我们使用了相同的位图,但是只画了它的适当部分(声音切换按钮;参见图 6-1 。这很容易。

    public void pause() {
        Settings.*save*(game.getFileIO());
    }

我们需要讨论的最后一部分是 pause()方法。由于我们可以更改该屏幕上的一个设置,我们必须确保它保存在外部存储器中。有了我们的设置类,这也很容易!

    public void resume() {
    }
    public void dispose() {
    }
}

resume()和 dispose()方法在这个屏幕中没有任何作用。

帮助屏幕类

接下来,让我们实现之前在 update()方法中使用的 HelpScreen、HighscoreScreen 和 GameScreen 类。

我们在第三章中定义了三个帮助界面,每个或多或少解释了游戏的一个方面。我们现在直接将它们转化为屏幕实现,称为 HelpScreen、HelpScreen2 和 HelpScreen3。它们都有一个启动屏幕转换的按钮。帮助屏幕 3 屏幕将转换回主菜单屏幕。图 6-3 显示了带有绘图坐标和触摸区域的三个帮助屏幕。

9781430246770_Fig06-03.jpg

图 6-3。三个帮助屏幕、绘图坐标和触摸区域

这看起来很容易实现。让我们从 HelpScreen 类开始,如清单 6-6 所示。

清单 6-6 。【HelpScreen.java】;第一个帮助屏幕

package com.badlogic.androidgames.mrnom;

import java.util.List;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Screen;

public class HelpScreenextends Screen {
    public HelpScreen(Game game) {
        super (game);
    }
    @Override
    public void update(float deltaTime) {
        List < TouchEvent > touchEvents = game.getInput().getTouchEvents();
        game.getInput().getKeyEvents();
        int len = touchEvents.size();

        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            if (event.type == TouchEvent.*TOUCH_UP*) {
                if (event.x > 256 && event.y > 416 ) {
                    game.setScreen(new HelpScreen2(game));
                    if (Settings.*soundEnabled*)
                        Assets.*click*.play(1);
                    return ;
                }
            }
        }
    }
    @Override
    public void present(float deltaTime) {
        Graphics g = game.getGraphics();
        g.drawPixmap(Assets.*background*, 0, 0);
        g.drawPixmap(Assets.*help1*, 64, 100);
        g.drawPixmap(Assets.*buttons*, 256, 416, 0, 64, 64, 64);
    }
    @Override
    public void pause() {
    }
    @Override
    public void resume() {
    }
    @Override
    public void dispose() {
    }
}

同样,非常简单。我们从 Screen 派生,并实现了一个合适的构造函数。接下来,我们有熟悉的 update()方法,它简单地检查底部的按钮是否被按下。如果是这种情况,我们播放卡嗒声并转换到帮助屏幕 2。

present()方法只是再次呈现背景,然后是帮助图像和按钮。

HelpScreen2 和 HelpScreen3 类看起来是一样的;唯一的区别是他们绘制的帮助图像和他们转换到的屏幕。我们可以同意我们不必看他们的代码。在高分屏幕上!

高分屏幕

高分屏幕简单地画出我们存储在设置类中的前五个高分,加上一个花哨的标题告诉玩家他或她在高分屏幕上,左下角还有一个按钮,当按下时会转换回主菜单。有趣的部分是我们如何渲染高分。让我们先看看我们在哪里渲染图像,如图 6-4 所示。

9781430246770_Fig06-04.jpg

图 6-4。高分屏幕,无高分

这看起来和我们实现的其他屏幕一样简单。但是怎么才能画出动态的分数呢?

渲染数字:一次远足

我们有一个名为 numbers.png 的素材图像,包含从 0 到 9 的所有数字和一个点。每个数字是 20 × 32 像素,点是 10 × 32 像素。数字从左到右按升序排列。高分屏幕应该显示五行,每行显示五个高分中的一个。这样的一行将从高分的位置开始(例如,“1”或者“5”),后跟一个空格,再按实际分数。我们如何做到这一点?

我们有两件事情要处理:numbers.png 图像和 Graphics.drawPixmap()方法,它允许我们将图像的一部分绘制到屏幕上。比方说我们想要的第一行的默认高分(用字符串" 1。100”)呈现在(20,100),以便数字 1 的左上角与这些坐标重合。我们这样调用 Graphics.drawPixmap():

game.getGraphics().drawPixmap(Assets.*numbers*, 20, 100, 20, 0, 20, 32);

我们知道数字 1 的宽度是 20 个像素。我们字符串的下一个字符必须在(20 + 20,100)处呈现。在字符串“1”的情况下。100”,该字符是点,在 numbers.png 图像中宽度为 10 个像素:

game.getGraphics().drawPixmap(Assets.*numbers*, 40, 100, 200, 0, 10, 32);

字符串中的下一个字符需要在(20 + 20 + 10,100)处呈现。那个字符是一个空格,我们不需要画出来。我们需要做的就是在 x 轴上再前进 20 个像素,因为我们假设这是空格字符的宽度。因此,下一个字符 1 将在(20 + 20 + 10 + 20,100)处呈现。看到这里的模式了吗?

给定字符串中第一个字符左上角的坐标,我们可以遍历字符串中的每个字符,绘制它,并根据我们刚刚绘制的字符,将下一个要绘制的字符的 x 坐标增加 20 或 10 个像素。

我们还需要考虑在给定当前角色的情况下,我们应该绘制 numbers.png 图像的哪一部分。为此,我们需要该部分左上角的 x 和 y 坐标,以及它的宽度和高度。y 坐标永远是 0,看图 6-1 应该很明显。高度也是一个常数—在我们的例子中是 32。宽度为 20 像素(如果字符串的字符是数字)或 10 像素(如果是点)。我们唯一需要计算的是 numbers.png 图像中该部分的 x 坐标。我们可以通过下面这个巧妙的小技巧做到这一点。

字符串中的字符可以解释为 Unicode 字符或 16 位整数。这意味着我们实际上可以用这些字符代码进行计算。幸运的是,字符 0 到 9 都有升序的整数表示。我们可以用它来计算一个数字的 number.png 图像部分的 x 坐标,如下所示:

char character = string.charAt(index);
int x = (character – '0') * 20;

这将为字符 0 提供 0,为字符 3 提供 3 × 20 = 60,依此类推。这就是每个数字部分的 x 坐标。当然,这不适用于点字符,所以我们需要特别对待。让我们用一种方法来总结这一点,该方法可以呈现我们的一条高分线,给定该线的字符串以及应该开始呈现的 x 和 y 坐标:

public void drawText(Graphics g, String line, int x, int y) {
    int len = line.length();
    for (int i = 0; i < len; i++) {
        char character = line.charAt(i);

        if (character == ' ') {
            x += 20;
            continue ;
        }

        int srcX = 0;
        int srcWidth = 0;
        if (character == '.') {
            srcX = 200;
            srcWidth = 10;
        }else {
            srcX = (character - '0') * 20;
            srcWidth = 20;
        }
        g.drawPixmap(Assets.*numbers*, x, y, srcX, 0, srcWidth, 32);
        x += srcWidth;
    }
}

我们迭代字符串中的每个字符。如果当前字符是一个空格,我们只需将 x 坐标前移 20 个像素。否则,我们计算 numbers.png 图像中当前字符区域的 x 坐标和宽度。该字符是一个数字或一个点。然后,我们渲染当前字符,并将渲染 x 坐标提升我们刚刚绘制的字符的宽度。如果我们的字符串包含除了空格、数字和点以外的任何内容,这个方法当然会失败。你能想出一种方法让它适用于任何字符串吗?

实现屏幕

有了这些新知识,我们现在可以很容易地实现 HighscoreScreen 类,如清单 6-7 所示。

清单 6-7 。【HighscoreScreen.java】;向我们展示我们迄今为止取得的最好成绩

package com.badlogic.androidgames.mrnom;

import java.util.List;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.Input.TouchEvent;

public class HighscoreScreenextends Screen {
    String lines[] = new String[5];

    public HighscoreScreen(Game game) {
        super (game);

        for (int i = 0; i < 5; i++) {
            lines[i] = "" + (i + 1) + ". " + Settings.*highscores*[i];
        }
    }

因为我们希望与垃圾收集器保持友好关系,所以我们将五个高分行的字符串存储在一个字符串数组成员中。我们基于构造函数中的 Settings.highscores 数组来构造字符串。

    @Override
    public void update(float deltaTime) {
        List < TouchEvent > touchEvents = game.getInput().getTouchEvents();

        game.getInput().getKeyEvents();
        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            if (event.type == TouchEvent.*TOUCH_UP*) {
                if (event.x < 64 && event.y > 416) {
                    if (Settings.*soundEnabled*)
                        Assets.*click*.play(1);
                    game.setScreen(new MainMenuScreen(game));
                    return ;
                }
            }
        }
    }

接下来,我们定义 update()方法,毫无疑问这很无聊。我们所做的就是检查一个触发事件是否按下了左下角的按钮。如果是这种情况,我们播放卡嗒声并转换回主菜单屏幕。

    @Override
    public void present(float deltaTime) {
        Graphics g = game.getGraphics();

        g.drawPixmap(Assets.*background*, 0, 0);
        g.drawPixmap(Assets.*mainMenu*, 64, 20, 0, 42, 196, 42);

        int y = 100;
        for (int i = 0; i < 5; i++) {
            drawText(g, lines[i], 20, y);
            y += 50;
        }
        g.drawPixmap(Assets.*buttons*, 0, 416, 64, 64, 64, 64);
    }

借助我们之前定义的强大的 drawText()方法,present()方法非常简单。像往常一样,我们首先渲染背景图像,然后是 Assets.mainmenu 图像的“HIGHSCORES”部分。我们可以将它存储在一个单独的文件中,但是我们重用它来释放更多的内存。

接下来,我们遍历在构造函数中创建的每一个高分行的五个字符串。我们用 drawText()方法绘制每一行。第一行从(20,100)开始,下一行在(20,150)渲染,依此类推。我们只是将每行文本渲染的 y 坐标增加 50 个像素,这样我们就可以在两行之间有一个很好的垂直间距。我们通过画按钮来结束这个方法。

public void drawText(Graphics g, String line, int x, int y) {
        int len = line.length();
        for (int i = 0; i < len; i++) {
            char character = line.charAt(i);
            if (character == ' ') {
                x += 20;
                continue ;
            }
            int srcX = 0;
            int srcWidth = 0;
            if (character == '.') {
                srcX = 200;
                srcWidth = 10;
            }else {
                srcX = (character - '0') * 20;
                srcWidth = 20;
            }
            g.drawPixmap(Assets.*numbers*, x, y, srcX, 0, srcWidth, 32);
            x += srcWidth;
        }

    }
    @Override
    public void pause() {

    }
    @Override
    public void resume() {

    }
    @Override
    public void dispose() {
    }
}

剩下的方法应该是不言自明的。让我们来看看 Nom 先生游戏中缺少的最后一块:游戏屏幕。

抽象 Nom 先生的世界:模型、视图、控制器

到目前为止,我们只为我们的素材和设置实现了无聊的 UI 和一些内务代码。我们现在将抽象出 Nom 先生的世界和其中的所有物体。我们也会把 Nom 先生从屏幕分辨率中解放出来,让他活在自己的小世界里,有自己的小坐标系。

如果你是一个长期的程序员,你可能听说过设计模式。给定一个场景,它们或多或少是设计代码的策略。有些是学术上的,有些在现实世界中有用途。对于游戏开发,可以借鉴模型-视图-控制器(MVC) 设计模式的一些思路。它经常被数据库和 web 社区用来将数据模型从表示层和数据操作层中分离出来。我们不会严格遵循这种设计模式,而是采用更简单的形式。

那么这对诺姆先生来说意味着什么呢?首先,我们需要一个独立于任何位图、声音、帧缓冲区或输入事件的世界的抽象表示。相反,我们将以面向对象的方式用几个简单的类来模拟 Nom 先生的世界。我们将为世界上的污点上一堂课,为诺姆先生自己上一堂课。Nom 先生由头部和尾部组成,我们也用单独的类来表示。为了将一切联系在一起,我们将有一个无所不知的类来代表 Nom 先生的整个世界,包括污点和 Nom 先生本人。所有这些都代表了 MVC 的模型部分。

MVC 中的视图将是负责渲染 Nom 先生世界的代码。我们将有一个类或方法来获取这个类,读取它的当前状态,并将其呈现在屏幕上。如何渲染与模型类无关,这是从 MVC 学到的最重要的一课。模型类独立于一切,但是视图类和方法依赖于模型类。

最后,我们有 MVC 中的控制器。它告诉模型类根据用户输入或时间流逝等改变它们的状态。模型类向控制器提供方法(例如,使用类似“将 Mr. Nom 转向左边”的指令),控制器可以使用这些方法来修改模型的状态。我们在模型类中没有任何直接访问触摸屏或加速度计的代码。这样,我们可以保持模型类没有任何外部依赖。

这听起来可能很复杂,你可能想知道为什么我们要这样做。然而,这种方法有很多好处。我们可以实现我们所有的游戏逻辑,而不必了解图形、音频或输入设备。我们可以修改游戏世界的渲染,而不必改变模型类本身。我们甚至可以将 2D 世界渲染器与 3D 世界渲染器进行交换。通过使用控制器,我们可以很容易地增加对新输入设备的支持。它所做的只是将输入事件转换成模型类的方法调用。想通过加速度计转动诺姆先生吗?没问题——读取控制器中的加速度计值,并在 Nom 先生的模型上将它们转换为“左转 Nom 先生”或“右转 Nom 先生”方法调用。想要增加对 Zeemote 的支持吗?没问题,就像加速度计的情况一样!使用控制器最好的一点是,我们不需要接触 Nom 先生的任何一行代码就可以实现所有这些。

让我们从定义 Nom 先生的世界开始。为此,我们将稍微脱离严格的 MVC 模式,使用我们的图形素材来说明基本思想。这也将有助于我们稍后实现视图组件(以像素为单位呈现 Nom 先生的抽象世界)。

图 6-5 显示了游戏屏幕,上面以网格的形式叠加了 Nom 先生的世界。

9781430246770_Fig06-05.jpg

图 6-5。Nom 先生的世界叠加在我们的游戏屏幕上

请注意,Nom 先生的世界被限制在一个 10 × 13 单元的网格中。我们在一个坐标系统中处理细胞,其原点在左上角(0,0),跨越到右下角(9,12)。Nom 先生的任何部分都必须在这些单元中的一个中,因此,在这个世界中具有整数 x 和 y 坐标。这个世界的污点也是如此。Nom 先生的每一部分恰好适合一个 1 × 1 单位的单元。请注意,单位的类型并不重要——这是我们自己的幻想世界,摆脱了 SI 系统或像素的束缚!

诺姆先生不能离开这个小小的世界。如果他通过一个边缘,他会从另一端出来,他所有的部分都会跟着出来。(顺便说一下,我们在地球上也有同样的问题——朝任何方向走足够长的时间,你都会回到你的起点。)诺姆先生也只能一个细胞一个细胞地前进。他所有的部分都会一直在整数坐标上。比如说,他永远不会占据两个半牢房。

注意如前所述,我们这里使用的不是严格的 MVC 模式。如果你对 MVC 模式的真正定义感兴趣,我们建议你读一读由 Erich Gamm,Richard Helm,Ralph Johnson 和 John M. Vlissides(又名四人组)(Addison-Wesley,1994)撰写的 设计模式:可重用面向对象软件的元素】。在他们的书中,MVC 模式被称为观察者模式。

污渍课

在诺姆先生的世界里,最简单的物体就是污渍。它只是坐在世界的一个细胞里,等着被吃掉。当我们设计 Nom 先生时,我们创造了三种不同的污渍视觉表现。在 Nom 先生的世界里,污渍的类型并不重要,但我们还是会把它包含在我们的污渍类中。清单 6-8 显示了污点等级。

清单 6-8 。Stain.java

package com.badlogic.androidgames.mrnom;

public class Stain {
    public static final int *TYPE_1* = 0;
    public static final int *TYPE_2* = 1;
    public static final int *TYPE_3* = 2;
    public int x, y;
    public int type;

    public Stain(int x, int y, int type) {
        this .x = x;
        this .y = y;
        this .type = type;
    }
}

Stain 类定义了三个公共静态常量,它们对污点的类型进行编码。每个 Stain 实例都有三个成员,Nom 先生世界中的 x 和 y 坐标,以及一个类型,它是之前定义的常量之一。为了使我们的代码简单,我们不包括 getters 和 setters,这是常见的做法。我们用一个很好的构造函数结束了这个类,它允许我们很容易地实例化一个 Stain 实例。

需要注意的一点是,它缺少与图形、声音或其他类的任何联系。污渍类独立存在,自豪地编码了 Nom 先生世界中污渍的属性。

蛇和蛇的一部分类

Nom 先生就像一条移动的链条,由相互关联的部分组成,当我们选取一部分并将其拖到某个地方时,这些部分就会一起移动。在 Nom 先生的世界里,每个部分占据一个细胞,就像一个污点。在我们的模型中,我们不区分头部和尾部,所以我们可以有一个单独的类来表示 Nom 先生的两种类型的部分。清单 6-9 显示了 SnakePart 类,它用于定义 Nom 先生的两个部分。

清单 6-9 。SnakePart.java

package com.badlogic.androidgames.mrnom;

public class SnakePart {
    public int x, y;

    public SnakePart(int x, int y) {
        this .x = x;
        this .y = y;
    }
}

这与 Stain 类本质上是一样的——我们只是移除了类型成员。我们的 Nom 先生的世界模型的第一个真正有趣的类是 Snake 类。让我们想想它必须能够做什么:

  • 它必须存储头部和尾部。
  • 它必须知道 Nom 先生目前的走向。
  • 当诺姆先生吃了一个污渍时,它一定能长出一个新的尾巴。
  • 它必须能够在当前方向上移动一个单元。

第一和第二项很容易。我们只需要 SnakePart 实例的列表——列表中的第一部分是头部,其他部分组成尾部。Nom 先生可以上下左右移动。我们可以用一些常量对其进行编码,并将他当前的方向存储在 Snake 类的一个成员中。

第三项也没那么复杂。我们只是在已有的零件列表中添加了另一个 SnakePart。问题是,那部分应该加在什么位置?这听起来可能令人惊讶,但我们给它的位置与列表中的最后一部分相同。当我们看到如何实现前面列表中的最后一项:移动 Nom 先生时,这样做的原因就变得更清楚了。

图 6-6 显示了 Nom 先生的初始配置。他由三部分组成:头部(5,6)和两个尾部(5,7)和(5,8)。

9781430246770_Fig06-06.jpg

图 6-6。Nom 先生的初始配置

列表中的部分是有序的,从头部开始,到最后一个尾部结束。当诺姆先生前进一个细胞时,他脑袋后面的所有部分都必须跟着前进。然而,Nom 先生的各个部分可能不会像图 6-6 中那样呈直线排列,因此简单地将所有部分向 Nom 先生前进的方向移动是不够的。我们必须做一些更复杂的事情。

我们需要从列表中的最后一部分开始,这听起来可能有些违反直觉。我们将它移动到它之前的部分的位置,并对列表中的所有其他部分重复此操作,除了头部,因为它之前没有任何部分。在头部的情况下,我们检查 Nom 先生当前的方向,并相应地修改头部的位置。图 6-7 用 Nom 先生的更复杂的配置说明了这一点。

9781430246770_Fig06-07.jpg

图 6-7。诺姆先生带着他的尾巴前进

这种运动策略和我们的饮食策略配合得很好。当我们向 Nom 先生添加一个新部件时,在 Nom 先生下一次移动时,它将停留在与之前部件相同的位置。另外,请注意,如果 Nom 先生通过了其中一条边,这将允许我们轻松地将他包装到世界的另一边。我们只要相应地设置头部的位置,剩下的就是自动完成的了。

有了这些信息,我们现在可以实现代表 Nom 先生的 Snake 类。清单 6-10 显示了代码。

清单 6-10 。【Snake.java】;代号的诺姆先生

package com.badlogic.androidgames.mrnom;

import java.util.ArrayList;
import java.util.List;

public class Snake {
    public static final int *UP* = 0;
    public static final int *LEFT* = 1;
    public static final int *DOWN* = 2;
    public static final int *RIGHT* = 3;

    public List < SnakePart > parts = new ArrayList < SnakePart > ();
    public int direction;

首先,我们定义几个常量来编码 Nom 先生的方向。请记住,Nom 先生只能向左转和向右转,因此我们定义常量值的方式至关重要。稍后,它将允许我们轻松地将方向旋转正负 90 度,只需将常量的当前方向递增和递减 1 即可。

接下来,我们定义一个名为 parts 的列表来保存 Nom 先生的所有部分。列表中第一项是头部,其他项是尾部。蛇类的第二个成员掌握着 Nom 先生目前前进的方向。

    public Snake() {
        direction = *UP*;
        parts.add(new SnakePart(5, 6));
        parts.add(new SnakePart(5, 7));
        parts.add(new SnakePart(5, 8));
    }

在构造器中,我们设置 Nom 先生由他的头部和两个额外的尾部组成,差不多位于世界的中间,如前面的图 6-6 所示。我们还设置了蛇的方向。向上,这样 Nom 先生在下一次被要求提升时将向上提升一个单元格。

    public void turnLeft() {
        direction += 1;
        if (direction > *RIGHT*)
            direction = *UP*;
    }

    public void turnRight() {
        direction - = 1;
        if (direction < *UP*)
            direction = *RIGHT*;
    }

方法 turnLeft()和 turnRight()只是修改 Snake 类的方向成员。对于左转,我们增加 1,对于右转,我们减少 1。我们还必须确保,如果方向值超出了我们之前定义的常量范围,我们就把 Nom 先生包围起来。

    public void eat() {
        SnakePart end = parts.get(parts.size()-1);
        parts.add(new SnakePart(end.x, end.y));
    }

接下来是 eat()方法。它所做的只是在列表末尾添加一个新的 SnakePart。这个新零件将与当前的结束零件具有相同的位置。如前所述,下一次 Nom 先生前进时,这两个重叠的部分将会分开。

    public void advance() {
        SnakePart head = parts.get(0);

        int len = parts.size() - 1;
        for (int i = len; i > 0; i--) {
            SnakePart before = parts.get(i-1);
            SnakePart part = parts.get(i);
            part.x = before.x;
            part.y = before.y;
        }
        if (direction ==*UP*)
            head.y - = 1;
        if (direction ==*LEFT*)
            head.x - = 1;
        if (direction ==*DOWN*)
            head.y += 1;
        if (direction ==*RIGHT*)
            head.x += 1;
        if (head.x < 0)
            head.x = 9;
        if (head.x > 9)
            head.x = 0;
        if (head.y < 0)
            head.y = 12;
        if (head.y > 12)
            head.y = 0;
    }

下一个方法 advance()实现了图 6-7 中的逻辑。首先,我们从最后一个部分开始,将每个部分移动到它前面的部分的位置。我们把头部排除在这个机制之外。然后,我们根据 Nom 先生当前的方向移动头部。最后,我们执行一些检查,以确保 Nom 先生不会走出他的世界。如果是这样,我们就把他包起来,让他从世界的另一端出来。

    public boolean checkBitten() {
        int len = parts.size();
        SnakePart head = parts.get(0);
        for (int i = 1; i < len; i++) {
            SnakePart part = parts.get(i);
            if (part.x == head.x && part.y == head.y)
                return true ;
        }
        return false ;
    }
}

最后一个方法 checkBitten()是一个小助手方法,它检查 Nom 先生是否咬到了自己的尾巴。它所做的只是检查没有一个尾部和头部在同一个位置。如果是这样的话,Nom 先生就死了,游戏也就结束了。

世界一流

我们的最后一个模型类叫做 World。世界级有几项任务要完成:

  • 跟踪 Nom 先生(以 Snake 实例的形式),以及世界上出现的 Stain 实例。我们的世界将永远只有一个污点。
  • 提供以基于时间的方式更新 Nom 先生的方法(例如,他应该每 0.5 秒前进一个单元格)。这种方法还可以检查 Nom 先生是否吃了污渍或咬了自己。
  • 记录分数;这基本上就是目前吃的污渍数乘以 10。
  • 诺姆先生每吃十块污渍就增加一次速度。这将使游戏更具挑战性。
  • 记录诺姆先生是否还活着。稍后我们会用这个来决定游戏是否结束。
  • 在 Nom 先生吃掉当前的污渍后,创建一个新的污渍(一个微妙但重要且复杂得惊人的任务)。

这个任务列表上只有两项我们还没有讨论:以基于时间的方式更新世界和放置新的污点。

诺姆先生的时间运动

在第三章中,我们谈到了基于时间的运动。这基本上意味着我们定义所有游戏对象的速度,测量自上次更新以来经过的时间(也称为增量时间),并通过将对象的速度乘以增量时间来推进对象。在第三章的中给出的例子中,我们使用浮点值来实现这一点。然而,Nom 先生的部件具有整数位置,所以我们需要弄清楚如何在这个场景中推进对象。

我们先定义一下诺姆先生的速度。诺姆先生的世界是有时间的,我们是以秒来衡量的。最初,Nom 先生应该每 0.5 秒前进一个单元格。我们需要做的就是记录从我们上次提升 Nom 先生以来已经过去了多长时间。如果累积的时间超过了我们的 0.5 秒阈值,我们调用 Snake.advance()方法并重置我们的时间累加器。我们从哪里得到这些德尔塔时间?记住 Screen.update()方法。它获得帧增量时间。我们只需将它传递给我们的 World 类的 update()方法,它将进行累加。为了让游戏更具挑战性,我们会在诺姆先生每多吃十个污渍的时候,将阈值减少 0.05 秒。当然,我们必须确保我们不会达到 0 的阈值,否则诺姆先生会以无限的速度旅行——这是爱因斯坦不会喜欢的。

放置污渍

我们要解决的第二个问题是,当 Nom 先生吃掉当前的污渍时,如何放置新的污渍。它应该出现在世界的任意一个单元格中。所以我们可以用一个随机的位置来实例化一个新的污点,对吗?可悲的是,这并不容易。

想象一下诺姆先生占据了相当数量的细胞。有一个合理的可能性,污渍将被放置在一个已经被诺姆先生占据的细胞中,并且它将随着诺姆先生变得越来越大而增加。因此,我们必须找到一个目前没有被 Nom 先生占用的牢房。又轻松了,对吧?只需迭代所有单元格,并使用第一个未被 Nom 先生占用的单元格。

同样,这有点不太理想。如果我们从同一个位置开始寻找,污点就不会随机出现。相反,我们将从世界上的一个随机位置开始,扫描所有单元格,直到到达世界的尽头,然后扫描开始位置以上的所有单元格,如果我们还没有找到一个空闲单元格的话。

我们如何检查一个单元是否空闲?简单的解决方法是检查所有的单元格,获取每个单元格的 x 和 y 坐标,并根据这些坐标检查 Nom 先生的所有部分。我们有 10 × 13 = 130 个单元格,诺姆先生可以占 55 个单元格。那就是 130×55 = 7150 张支票!诚然,大多数设备可以处理,但我们可以做得更好。

我们将创建一个布尔二维数组,其中每个数组元素代表世界上的一个单元格。当我们必须放置一个新的污点时,我们首先遍历 Mr. Nom 的所有部分,并将数组中某个部分所占用的那些元素设置为 true。然后我们简单地选择一个随机的位置,从这里开始扫描,直到我们找到一个空闲的细胞,我们可以在其中放置新的染色剂。Nom 先生由 55 部分组成,需要 130 + 55 = 185 张支票。那好多了!

决定游戏何时结束

我们还要考虑最后一件事:如果所有的细胞都被 Nom 先生占据了呢?在这种情况下,游戏就结束了,因为诺姆先生将正式成为全世界。假设我们在诺姆先生每吃一个污渍时就给分数加 10,那么最高可得分数是((10×13)-3)×10 = 1270 分(记住,诺姆先生已经开始吃三个部分了)。

实现世界级

唷,我们有很多东西要实现,所以让我们开始吧。清单 6-11 显示了世界类的代码。

清单 6-11 。World.java

package com.badlogic.androidgames.mrnom;

import java.util.Random;

public class World {
    static final int *WORLD_WIDTH* = 10;
    static final int *WORLD_HEIGHT* = 13;
    static final int *SCORE_INCREMENT* = 10;
    static final float *TICK_INITIAL* = 0.5f;
    static final float *TICK_DECREMENT* = 0.05f;

    public Snake snake;
    public Stain stain;
    public boolean gameOver = false ;;
    public int score = 0;

    boolean fields[][] = new boolean [*WORLD_WIDTH*][*WORLD_HEIGHT*];
    Random random = new Random();
    float tickTime = 0;
    float *tick* = *TICK_INITIAL*;

像往常一样,我们首先定义几个常数——在这种情况下,世界的宽度和高度以单元格为单位,我们用来在 Nom 先生每次吃一个染色剂时增加分数的值,用来推进 Nom 先生的初始时间间隔(称为 tick ),以及 Nom 先生每次吃十个染色剂时减少 tick 的值,以便稍微加快速度。

接下来,我们有一些公共成员,它们保存一个 Snake 实例、一个 Stain 实例、一个存储游戏是否结束的布尔值和当前分数。

我们定义了另外四个包私有成员:我们将用来放置新染色的 2D 数组;Random 类的一个实例,通过它我们将产生随机数来放置污点并生成它的类型;时间累加器变量 tickTime,我们将添加帧增量时间;以及一个分笔成交点的当前持续时间,它定义了我们提升 Nom 先生的频率。

    public World() {
        snake = new Snake();
        placeStain();
    }

在构造函数中,我们创建了一个 Snake 类的实例,它将具有如图 6-6 所示的初始配置。我们还通过 placeStain()方法放置第一个随机染色。

    private void placeStain() {
        for (int x = 0; x < *WORLD_WIDTH*; x++) {
            for (int y = 0; y < *WORLD_HEIGHT*; y++) {
                fields[x][y] = false ;
            }
        }

        int len = snake.parts.size();
        for (int i = 0; i < len; i++) {
            SnakePart part = snake.parts.get(i);
            fields[part.x][part.y] = true ;
        }
        int stainX = random.nextInt(*WORLD_WIDTH*);
        int stainY = random.nextInt(*WORLD_HEIGHT*);
        while (true ) {
            if (fields[stainX][stainY] ==false )
                break ;
            stainX += 1;
            if (stainX >=*WORLD_WIDTH*) {
                stainX = 0;
                stainY += 1;
                if (stainY >=*WORLD_HEIGHT*) {
                    stainY = 0;
                }
            }
        }
        stain = new Stain(stainX, stainY, random.nextInt(3));
    }

placeStain()方法实现了前面讨论的放置策略。我们从清空单元阵列开始。接下来,我们将蛇的各个部分所占据的所有单元格设置为 true。最后,我们从随机位置开始扫描数组,寻找空闲单元。一旦我们找到了一个自由细胞,我们就用随机类型创建一个染色。请注意,如果所有单元格都被 Nom 先生占用,那么循环将永远不会终止。我们将确保在下一个方法中不会发生这种情况。

    public void update(float deltaTime) {
        if (gameOver)
            return ;

        tickTime += deltaTime;

        while (tickTime > *tick*) {
            tickTime - = *tick*;
            snake.advance();
            if (snake.checkBitten()) {
                gameOver = true ;
                return ;
            }

            SnakePart head = snake.parts.get(0);
            if (head.x == stain.x && head.y == stain.y) {
                score +=*SCORE_INCREMENT*;
                snake.eat();
                if (snake.parts.size() ==*WORLD_WIDTH***WORLD_HEIGHT*) {
                    gameOver = true ;
                    return ;
                }else {
                    placeStain();
                }
                if (score % 100 == 0 &&*tick*-*TICK_DECREMENT* > 0) {
                    *tick*- = *TICK_DECREMENT*;
                }
            }
        }
    }
}

update()方法负责根据我们传递给它的时间增量更新世界和其中的所有对象。这个方法会调用游戏画面中的每一帧,让世界不断更新。我们从检查游戏是否结束开始。如果是这样的话,那么我们就不需要更新什么了。接下来,我们将增量时间添加到累加器中。while 循环将使用尽可能多的累计滴答(例如,当 tickTime 为 1.2,而一个滴答需要 0.5 秒时,我们可以更新世界两次,在累加器中留下 0.2 秒)。这被称为固定时间步长模拟

在每次迭代中,我们首先从累加器中减去节拍间隔。接下来,我们告诉诺姆先生前进。我们检查他是否咬了自己,如果是,就设置游戏结束标志。最后,我们检查诺姆先生的头是否和污点在同一个牢房。如果是这样的话,我们增加分数,并告诉 Nom 先生增长。接下来,我们检查 Nom 先生的组成部分是否和世界上的细胞一样多。如果是这样,游戏就结束了,我们从函数中返回。否则,我们用 placeStain()方法放置一个新的染色。我们做的最后一件事是检查 Nom 先生是否又吃了十个污渍。如果是这种情况,我们的阈值大于零,我们将它减少 0.05 秒。分笔成交点会更短,从而让诺姆动作更快。

这就完成了我们的模型类集。我们最不需要实现的就是游戏画面!

GameScreen 类

只需要再实现一个屏幕。让我们看看这个屏幕做了什么:

  • 正如 Nom 先生在第三章中的设计所定义的,游戏屏幕可以处于四种状态之一:等待用户确认他或她准备好了,运行游戏,在暂停状态下等待,或者在游戏结束状态下等待用户点击按钮。

  • 在就绪状态下,我们简单地要求用户触摸屏幕来开始游戏。

  • 在运行状态下,我们更新世界,渲染世界,还告诉 Nom 先生当玩家按下屏幕底部的一个按钮时向左转和向右转。

  • 在暂停状态下,我们只显示两个选项:一个是恢复游戏,一个是退出游戏。

  • 在游戏结束的状态下,我们告诉用户游戏结束了,并提供一个触摸按钮,这样他或她就可以回到主菜单。

  • 对于每个状态,我们有不同的 update()和 present()方法要实现,因为每个状态做不同的事情并显示不同的 UI。

  • 一旦游戏结束,我们必须确保存储分数,如果分数很高的话。

这是相当多的责任,这意味着比*常更多的代码。因此,我们将分解这个类的源代码清单。在深入研究代码之前,让我们展示一下如何在每个状态下安排不同的 UI 元素。图 6-8 显示了四种不同的状态。

9781430246770_Fig06-08.jpg

图 6-8。游戏屏幕有四种状态:就绪、运行、暂停和游戏结束

请注意,我们还在屏幕底部呈现了分数,以及一条将 Nom 先生的世界与底部按钮分开的线。分数是用我们在 HighscoreScreen 中使用的相同例程呈现的。此外,我们根据乐谱字符串宽度将其水*居中。

最后缺失的一点信息是如何根据模型来呈现 Nom 先生的世界。这其实很简单。再看一下图 6-1 和图 6-5 。每个单元格的大小正好是 32 × 32 像素。污点图像也是 32 × 32 像素大小,Nom 先生的尾部也是。Nom 先生各个方向的头像都是 42 × 42 像素,所以不能完全装进一个单元格。不过,这不是问题。要渲染 Nom 先生的世界,我们需要做的就是将每个污点和蛇的部分乘以 32 的世界坐标,以像素为单位得出屏幕上对象的中心——例如,世界坐标为(3,2)的污点在屏幕上的中心为 96 × 64。基于这些中心,剩下要做的就是获取适当的资源,并以这些坐标为中心进行渲染。让我们开始编码吧。清单 6-12 显示了 GameScreen 类。

清单 6-12 。GameScreen.java

package com.badlogic.androidgames.mrnom;

import java.util.List;

import android.graphics.Color;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.Pixmap;
import com.badlogic.androidgames.framework.Screen;

public class GameScreenextends Screen {
    enum GameState {
        *Ready*,
        *Running*,
        *Paused*,
        *GameOver*
    }

    GameState state = GameState.*Ready*;
    World world;
    int oldScore = 0;
    String score = "0";

我们首先定义一个名为 GameState 的枚举,它对我们的四种状态(就绪、运行、暂停和游戏结束)进行编码。接下来,我们定义一个成员保存屏幕的当前状态,另一个成员保存世界实例,还有两个成员以整数和字符串的形式保存当前显示的分数。我们使用最后两个成员的原因是,我们不想在每次抽签时不断地从 World.score 成员中创建新的字符串。相反,我们将缓存字符串,只在分数改变时创建一个新的。那样的话,我们就能和垃圾收集者友好相处了。

    public GameScreen(Game game) {
        super (game);
        world = new World();
    }

构造函数调用超类构造函数并创建一个新的 World 实例。在构造函数返回给调用者后,游戏屏幕将处于就绪状态。

    @Override
    public void update(float deltaTime) {
        List < TouchEvent > touchEvents = game.getInput().getTouchEvents();
        game.getInput().getKeyEvents();

        if (state == GameState.*Ready*)
            updateReady(touchEvents);
        if (state == GameState.*Running*)
            updateRunning(touchEvents, deltaTime);
        if (state == GameState.*Paused*)
            updatePaused(touchEvents);
        if (state == GameState.*GameOver*)
            updateGameOver(touchEvents);
    }

接下来是屏幕的 update()方法。它所做的只是从输入模块中获取 TouchEvents 和 KeyEvents,然后将更新委托给我们根据当前状态为每个状态实现的四个更新方法之一。

    private void updateReady(List < TouchEvent > touchEvents) {
        if (touchEvents.size() > 0)
            state = GameState.*Running*;
    }

下一个方法称为 updateReady()。当屏幕处于就绪状态时,它将被调用。它所做的只是检查屏幕是否被触摸过。如果是这种情况,它会将状态更改为正在运行。

    private void updateRunning(List < TouchEvent > touchEvents, float deltaTime) {
        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            if (event.type == TouchEvent.*TOUCH_UP*) {
                if (event.x < 64 && event.y < 64) {
                    if (Settings.*soundEnabled*)
                        Assets.*click*.play(1);
                    state = GameState.*Paused*;
                    return ;
                }
            }
            if (event.type == TouchEvent.*TOUCH_DOWN*) {
                if (event.x < 64 && event.y > 416) {
                    world.snake.turnLeft();
                }
                if (event.x > 256 && event.y > 416) {
                    world.snake.turnRight();
                }
            }
        }
        world.update(deltaTime);
        if (world.gameOver) {
            if (Settings.*soundEnabled*)
                Assets.*bitten*.play(1);
            state = GameState.*GameOver*;
        }
        if (oldScore !=world.score) {
            oldScore = world.score;
            score = "" + oldScore;
            if (Settings.*soundEnabled*)
                Assets.*eat*.play(1);
        }
    }

updateRunning()方法首先检查屏幕左上角的暂停按钮是否被按下。如果是这种情况,它会将状态设置为暂停。然后检查屏幕底部的控制器按钮是否被按下。注意,这里我们不检查触发事件,而是检查触下事件。如果其中一个按钮被按下,我们告诉世界的蛇实例向左转或向右转。没错,updateRunning()方法包含了我们 MVC 模式的控制器代码!检查完所有触摸事件后,我们告诉世界用给定的增量时间更新自己。如果世界发出游戏结束的信号,我们相应地改变状态,也播放 bitten.ogg 声音。接下来,我们检查我们缓存的旧分数是否与世界存储的分数不同。如果是,那么我们就知道两件事:诺姆先生吃了一颗污渍,乐谱串肯定是改了。在这种情况下,我们播放 eat.ogg 声音。这就是运行状态更新的全部内容。

    private void updatePaused(List < TouchEvent > touchEvents) {
        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            if (event.type == TouchEvent.*TOUCH_UP*) {
                if (event.x > 80 && event.x <= 240) {
                    if (event.y > 100 && event.y <= 148) {
                        if (Settings.*soundEnabled*)
                            Assets.*click*.play(1);
                        state = GameState.*Running*;
                        return ;
                    }
                    if (event.y > 148 && event.y < 196) {
                        if (Settings.*soundEnabled*)
                            Assets.*click*.play(1);
                        game.setScreen(new MainMenuScreen(game));
                        return ;
                    }
                }
            }
        }
    }

updatePaused()方法只是检查菜单选项之一是否被触摸,并相应地改变状态。

    private void updateGameOver(List < TouchEvent > touchEvents) {
        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            if (event.type == TouchEvent.*TOUCH_UP*) {
                if (event.x >= 128 && event.x <= 192 &&
                   event.y >= 200 && event.y <= 264) {
                    if (Settings.*soundEnabled*)
                        Assets.*click*.play(1);
                    game.setScreen(new MainMenuScreen(game));
                    return ;
                }
            }
        }
    }

updateGameOver()方法也检查屏幕中间的按钮是否被按下。如果已经按下,那么我们启动屏幕转换回到主菜单屏幕。

    @Override
    public void present(float deltaTime) {
        Graphics g = game.getGraphics();
        g.drawPixmap(Assets.*background*, 0, 0);
        drawWorld(world);

        if (state == GameState.*Ready*)
            drawReadyUI();
        if (state == GameState.*Running*)
            drawRunningUI();
        if (state == GameState.*Paused*)
            drawPausedUI();
        if (state == GameState.*GameOver*)
            drawGameOverUI();

        drawText(g, score, g.getWidth() / 2 - score.length()*20 / 2, g.getHeight() - 42);
    }

接下来是渲染方法。present()方法首先绘制背景图像,因为这在所有状态中都需要。接下来,它为我们所处的状态调用相应的绘制方法。最后,它呈现了 Nom 先生的世界,并在屏幕的底部中央画出了分数。

    private void drawWorld(World world) {
        Graphics g = game.getGraphics();
        Snake snake = world.snake;
        SnakePart head = snake.parts.get(0);
        Stain stain = world.stain;

        Pixmap stainPixmap = null ;
        if (stain.type == Stain.*TYPE_1*)
            stainPixmap = Assets.*stain1*;
        if (stain.type == Stain.*TYPE_2*)
            stainPixmap = Assets.*stain2*;
        if (stain.type == Stain.*TYPE_3*)
            stainPixmap = Assets.*stain3*;
        int x = stain.x * 32;
        int y = stain.y * 32;
        g.drawPixmap(stainPixmap, x, y);

        int len = snake.parts.size();
        for (int i = 1; i < len; i++) {
            SnakePart part = snake.parts.get(i);
            x = part.x * 32;
            y = part.y * 32;
            g.drawPixmap(Assets.*tail*, x, y);
        }

        Pixmap headPixmap = null ;
        if (snake.direction == Snake.*UP*)
            headPixmap = Assets.*headUp*;
        if (snake.direction == Snake.*LEFT*)
            headPixmap = Assets.*headLeft*;
        if (snake.direction == Snake.*DOWN*)
            headPixmap = Assets.*headDown*;
        if (snake.direction == Snake.*RIGHT*)
            headPixmap = Assets.*headRight*;
        x = head.x * 32 + 16;
        y = head.y * 32 + 16;
        g.drawPixmap(headPixmap, x - headPixmap.getWidth() / 2, y - headPixmap.getHeight() / 2);
    }

正如我们刚刚讨论的,drawWorld()方法绘制世界。它首先选择用于渲染污点的像素图,然后绘制它并将其水*居中在屏幕位置。接下来,我们渲染 Nom 先生的所有尾巴部分,这相当简单。最后,我们根据 Nom 先生的指示,选择使用头部的哪一个像素图,并在屏幕坐标中的头部位置绘制该像素图。与其他对象一样,我们也将图像围绕该位置居中。这就是 MVC 中视图的代码。

    private void drawReadyUI() {
        Graphics g = game.getGraphics();

        g.drawPixmap(Assets.*ready*, 47, 100);
        g.drawLine(0, 416, 480, 416, Color.*BLACK*);

    }

    private void drawRunningUI() {
        Graphics g = game.getGraphics();

        g.drawPixmap(Assets.*buttons*, 0, 0, 64, 128, 64, 64);
        g.drawLine(0, 416, 480, 416, Color.*BLACK*);
        g.drawPixmap(Assets.*buttons*, 0, 416, 64, 64, 64, 64);
        g.drawPixmap(Assets.*buttons*, 256, 416, 0, 64, 64, 64);
    }

    private void drawPausedUI() {
        Graphics g = game.getGraphics();
        g.drawPixmap(Assets.*pause*, 80, 100);
        g.drawLine(0, 416, 480, 416, Color.*BLACK*);
    }
    private void drawGameOverUI() {
        Graphics g = game.getGraphics();
        g.drawPixmap(Assets.*gameOver*, 62, 100);
        g.drawPixmap(Assets.*buttons*, 128, 200, 0, 128, 64, 64);
        g.drawLine(0, 416, 480, 416, Color.*BLACK*);
    }

    public void drawText(Graphics g, String line, int x, int y) {
        int len = line.length();
        for (int i = 0; i < len; i++) {
            char character = line.charAt(i);
            if (character == ' ') {
                x += 20;
                continue ;
            }
            int srcX = 0;
            int srcWidth = 0;
            if (character == '.') {
                srcX = 200;
                srcWidth = 10;
            }else {
                srcX = (character - '0') * 20;
                srcWidth = 20;
            }

            g.drawPixmap(Assets.*numbers*, x, y, srcX, 0, srcWidth, 32);
            x += srcWidth;
        }

    }

drawReadUI()、drawRunningUI()、drawPausedUI()和 drawGameOverUI()方法并不新鲜。基于图 6-8 所示的坐标,它们一如既往地执行相同的旧 UI 渲染。drawText()方法与 HighscoreScreen 中的方法相同,所以我们也不讨论那个方法。

    @Override
    public void pause() {
        if (state == GameState.*Running*)
            state = GameState.*Paused*;
        if (world.gameOver) {
            Settings.*addScore*(world.score);
            Settings.*save*(game.getFileIO());
        }
    }
    @Override
    public void resume() {

    }

    @Override
    public void dispose() {

    }
}

最后,还有一个最后重要的方法,pause(),当活动暂停或者游戏屏幕被另一个屏幕替换时,就会调用这个方法。那是保存我们设置的完美地方。首先,我们将游戏的状态设置为暂停。如果 paused()方法由于活动被暂停而被调用,这将保证当用户返回游戏时会被要求继续游戏。这是很好的行为,因为从离开游戏的地方立即重新开始会有压力。接下来,我们检查游戏屏幕是否处于游戏结束状态。如果是这种情况,我们将玩家获得的分数加到高分中(或者不加,取决于它的值),并将所有设置保存到外部存储器中。

仅此而已。我们已经为 Android 从头开始编写了一个完整的游戏!你可以为自己感到骄傲,因为你已经征服了所有必要的话题,创造了几乎任何你喜欢的游戏。从这里开始,大部分只是化妆品。

摘要

在这一章中,我们在我们的框架上实现了一个完整的游戏,带有所有的铃铛和口哨(没有音乐)。你知道了为什么将模型从视图和控制器中分离出来是有意义的,你也知道了你不需要用像素来定义你的游戏世界。我们可以用这段代码替换 OpenGL ES 的渲染部分,让 Nom 先生变成 3D。我们还可以通过向 Nom 先生添加动画、添加一些颜色、添加新的游戏机制等等来增加当前渲染器的趣味。然而,我们只是触及了可能性的表面。

在继续阅读这本书之前,我们建议拿着游戏代码到处玩玩。添加一些新的游戏模式、能量和敌人——任何你能想到的。

一旦你回来,在下一章,你将加强你的图形编程知识,使你的游戏看起来有点花哨,你也将迈出第一步进入第三维!****

七、OpenGL ES:温和介绍

诺姆先生获得了巨大的成功。由于其坚实的初始设计和游戏框架,实现 Nom 先生对我们来说轻而易举。最棒的是,游戏即使在低端设备上也能流畅运行。当然,Nom 先生不是一个非常复杂或图形密集型的游戏,所以使用 Canvas API 进行渲染被证明是一个好主意。

然而,当你想做一些更复杂的事情时,你会碰壁:Canvas 只是跟不上这样一个游戏的视觉复杂性。如果你想玩 3D 游戏,画布也帮不了你。所以。。。你能做什么?

这就是 OpenGL ES 的用武之地。在这一章中,首先我们将简要地看一下 OpenGL ES 实际上是什么和做什么。然后,我们将重点关注使用 OpenGL ES 进行 2D 图形,而不必深入到使用 API 进行 3D 图形的更复杂的数学领域(我们将在后面的章节中讨论)。我们将一步一步来,因为 OpenGL ES 会变得非常复杂。你准备好了解 OpenGL ES 了吗?

什么是 OpenGL ES,我为什么要关心?

OpenGL ES 是(3D)图形编程的行业标准。它特别针对移动和嵌入式设备。它由 Khronos Group 维护,Khronos Group 是一个行业联盟,其成员包括 ATI、NVIDIA 和 Intel 等;这些公司共同定义并扩展了标准。

说到标准,目前 OpenGL ES 有四个增量版本:1.0、1.1、2.0,以及最*发布的 3.0。我们关心的是本书中的前两个。所有 Android 设备都支持 OpenGL ES 1.0,并且大多数还支持 1.1 版本,该版本在 1.0 规范中添加了一些新功能。然而,OpenGL ES 2.0 打破了与 1.x 版本的兼容性。您可以使用 1.x 或 2.0,但不能同时使用两者。原因是 1.x 版本使用一种称为固定功能管道、的编程模型,而 2.0 版本允许您通过所谓的着色器以编程方式定义渲染管道的一部分。很多二代设备已经支持 OpenGL ES 2.0 然而,学习 3D 编程和使用着色器进行编程的认知摩擦非常大。OpenGL ES 1.x 对于大多数游戏来说已经足够好了,所以我们在这里坚持使用它。OpenGL ES 3.0 是如此的新鲜,以至于在我写这篇文章的时候,还没有设备实现这个标准。设备制造商需要一段时间才能跟上速度。

注意仿真器支持 OpenGL ES 1.0 和 2.0。然而,仿真并不完美。此外,虽然 OpenGL ES 是一种标准,但不同的制造商对它有不同的解释,并且设备之间的性能差异很大,因此请确保在各种设备上进行测试,以确保兼容性。我们将设计几个有用的类,可以在任何设备上工作。

OpenGL ES 是一个 API,它以 Khronos Group 提供的一组 C 头文件的形式出现,同时还有一个非常详细的规范,说明了在这些头文件中定义的 API 应该如何工作。这包括诸如像素和线条必须如何渲染之类的事情。然后,硬件制造商采用该规范,并在 GPU 驱动程序之上为他们的 GPU 实现该规范。你可以在 http://www.khronos.org/opengles 找到所有的规格和更多。

这些实现的质量略有不同:一些公司严格遵守标准(PowerVR),而其他公司似乎很难坚持下去。这有时会导致实现中依赖于 GPU 的错误,这些错误与 Android 本身无关,而是与制造商提供的硬件驱动程序有关。在您进入 OpenGL ES 领域的过程中,我们将为您指出任何特定于设备的问题。

注意 OpenGL ES 或多或少是功能更丰富的桌面 OpenGL 标准的兄弟。它不同于后者,因为某些功能被减少或完全删除。尽管如此,还是有可能编写一个可以运行这两种规范的应用,如果你想把你的游戏移植到你的桌面上,这是一个很好的选择。

那么 OpenGL ES 实际上是做什么的呢?简短的回答是,这是一个精简的三角形渲染机器。长的答案有点复杂。

编程模型:一个类比

总的来说,OpenGL ES 是一个 3D 图形编程 API。因此,它有一个非常好且易于理解的编程模型,我们可以用一个简单的类比来说明。

把 OpenGL ES 想象成像照相机一样工作。要拍照,你得去你想拍的场景。您的场景由对象组成,例如,一张上面有更多对象的桌子。它们都有相对于你的相机的位置和方向,以及不同的材质和纹理。玻璃是半透明的,具有反射性;桌子可能是木头做的;一本杂志上有一位政治家的最新照片;等等。有些物体甚至会四处移动(例如,一只果蝇)。您的相机还具有一些属性,如焦距、视野、图像分辨率、将要拍摄的照片的大小,以及在世界范围内的唯一位置和方向(相对于某个原点)。即使物体和相机都在移动,当你按下快门时,你会捕捉到场景的静止图像(现在,我们将忽略快门速度,这可能会导致图像模糊)。在那个无限小的瞬间,一切都静止不动,轮廓分明,照片准确地反映了位置、方向、纹理、材质和照明的所有配置。图 7-1 展示了一个抽象的场景,有一台相机,灯光,和三个不同材质的物体。

9781430246770_Fig07-01.jpg

图 7-1。抽象场景

每个对象都有相对于场景原点的位置和方向。由眼睛指示的摄像机也具有相对于场景原点的位置。图 7-1 中的金字塔是所谓的视体视见体、,它显示了摄像机捕捉了多少场景以及摄像机是如何定向的。带有光线的小白球是场景中的光源,它也有一个相对于原点的位置。

我们可以直接将这个场景映射到 OpenGL ES,但是要这样做,我们需要定义以下内容:

  • 物体(又称模型) : 这些通常由四组属性组成:几何形状、颜色、纹理和材质。几何图形被指定为一组三角形。每个三角形由三维空间中的三个点组成,所以我们有相对于坐标系原点定义的 x,y,z 坐标,如图图 7-1 所示。注意 z 轴指向我们。颜色通常指定为 RGB 三色,我们已经习惯了。纹理和材质稍微复杂一点。我们稍后会谈到这些。
  • 灯光 : OpenGL ES 提供了几个不同的灯光类型和不同的属性。它们只是在 3D 空间中具有位置和/或方向的数学对象,加上诸如颜色之类的属性。
  • 相机 : 这也是一个在 3D 空间中有位置和方向的数学对象。此外,它还有控制我们看到多少图像的参数,类似于真正的相机。所有这些共同定义了一个视见体或视见*截头体(在图 7-1 中用顶部被切掉的金字塔表示)。这个金字塔里面的任何东西都可以被摄像机看到;外面的任何东西都不会进入最终的画面。
  • 视窗 : 这定义了最终图像的尺寸和分辨率。可以把它想象成你放入模拟相机的胶片类型,或者你用数码相机拍摄的照片的图像分辨率。

考虑到所有这些,OpenGL ES 可以从摄像机的角度构建我们场景的 2D 位图。请注意,我们在 3D 空间中定义一切。那么,OpenGL ES 如何将它映射到二维空间呢?

预测

这个 2D 映射是通过一个叫做投影的东西来完成的。我们已经提到 OpenGL ES 主要关注三角形。单个三角形在 3D 空间中定义了三个点。为了将这样的三角形渲染到帧缓冲区,OpenGL ES 需要知道这些 3D 点在帧缓冲区的基于像素的坐标系中的坐标。一旦知道了这三个角点坐标,它就可以简单地在帧缓冲区中画出三角形内部的像素。我们甚至可以通过将 3D 点投影到 2D 来编写我们自己的小 OpenGL ES 实现,并通过画布简单地在它们之间画线。

3D 图形中常用的投影有两种。

  • *行(或正交)投影 : 如果你曾经玩过 CAD 应用,你可能已经知道这个了。*行投影不在乎物体离相机有多远;在最终图像中,对象将始终具有相同的大小。这种类型的投影通常用于在 OpenGL ES 中渲染 2D 图形。
  • 透视投影 : 你的眼睛每天都在使用这种投影。离你越远的物体在你的视网膜上显得越小。当我们用 OpenGL ES 做 3D 图形时,通常使用透视投影。

在这两种情况下,你都需要一个被称为投影*面、的东西,它几乎与你的视网膜完全相同——它是光线实际记录形成最终图像的地方。就面积而言,数学*面是无限的,而我们的视网膜是有限的。我们的 OpenGL ES“视网膜”等于图 7-1 中所见的视锥顶部的矩形。视图截锥的这一部分是 OpenGL ES 投射点的地方。这个区域被称为裁剪*面、附*的,它有自己的小 2D 坐标系。图 7-2 再次显示了附*的裁剪*面,从摄像机的角度看,与坐标系叠加。

9781430246770_Fig07-02.jpg

图 7-2。*裁剪*面(也称为投影*面)及其坐标系

注意坐标系绝不是固定的。我们可以操纵它,这样我们就可以在任何我们喜欢的投影坐标系中工作;例如,我们可以指示 OpenGL ES 让原点在左下角,让“视网膜”的可视区域在 x 轴上是 480 个单位,在 y 轴上是 320 个单位。听起来很熟悉?是的,OpenGL ES 允许你为投影点指定任何你想要的坐标系。

一旦我们指定了视图*截头体,OpenGL ES 就会获取三角形的每个点,并通过投影*面从其中射出一条光线。*行投影和透视投影的区别在于这些光线的方向是如何构成的。图 7-3 显示了从上方观察时两者的区别。

9781430246770_Fig07-03.jpg

图 7-3。透视投影(左)和*行投影(右)

透视投影通过相机(或眼睛,在这种情况下)从三角形点射出光线。因此,更远的物体在投影*面上会显得更小。当我们使用*行投影时,光线是垂直于投影*面射出的。在这种情况下,无论距离多远,物体都将在投影*面上保持其大小。

正如前面指出的,我们的投影*面在 OpenGL ES 行话中被称为裁剪*面附*的。视见体的所有边都有相似的名称。离相机最远的那个被称为远裁剪*面。其余称为裁剪*面。那些*面之外或之后的任何东西都不会被渲染。部分位于视见*截头体内的对象将从这些*面中被剪切掉,这意味着视见*截头体外的部分将被切掉。这就是裁剪*面名称的由来。

你可能想知道为什么图 7-3 中*行投影情况下的视截头体是矩形的。事实证明,投影实际上是由我们如何定义裁剪*面决定的。在透视投影的情况下,左侧、右侧、顶部和底部裁剪*面不垂直于**面和远*面(见图 7-3 ,仅显示了左侧和右侧裁剪*面)。在*行投影的情况下,这些*面是垂直的,这告诉 OpenGL ES 以相同的大小渲染一切,不管它离相机有多远。

标准化设备空间和视口

一旦 OpenGL ES 计算出三角形在*裁剪*面上的投影点,它就可以最终将它们转换为帧缓冲区中的像素坐标。为此,它必须首先将这些点转换到所谓的标准化设备空间。这等于图 7-2 中所示的坐标系。基于这些标准化的设备空间坐标,OpenGL ES 通过以下简单公式计算最终的帧缓冲区像素坐标:

pixelX = (norX + 1) / (viewportWidth + 1) + norX
pixelY = (norY + 1) / (viewportHeight + 1) + norY

其中,norX 和 norY 是 3D 点的标准化设备坐标,viewportWidth 和 viewportHeight 是 x 轴和 y 轴上以像素为单位的视口大小。我们不必担心标准化的设备坐标,因为 OpenGL 会自动为我们完成转换。然而,我们真正关心的是视口和视见体。稍后,您将看到如何指定视图截锥,从而指定投影。

矩阵

OpenGL ES 以矩阵的形式表达投影。我们不需要知道矩阵的内部。我们只需要知道它们对我们在场景中定义的点做了什么。这里是矩阵的执行摘要:

  • 矩阵对要应用于点的变换进行编码。变换可以是投影、*移(其中点四处移动)、围绕另一个点和轴的旋转或缩放等。
  • 通过将这样的矩阵乘以一个点,我们将变换应用于该点。例如,将一个点与编码 x 轴上 10 个单位的*移的矩阵相乘,将使该点在 x 轴上移动 10 个单位,从而修改其坐标。
  • 我们可以通过矩阵相乘将存储在不同矩阵中的变换连接成一个矩阵。当我们用一个点乘以这个单个连接矩阵时,存储在该矩阵中的所有变换都将应用于该点。应用变换的顺序取决于矩阵相乘的顺序。
  • 有一种特殊的矩阵叫做单位矩阵。如果我们把一个矩阵或者一个点乘以它,什么都不会发生。把一个点或矩阵乘以一个单位矩阵想象成一个数乘以 1。根本没有效果。一旦您了解 OpenGL ES 如何处理矩阵(参见“矩阵模式和活动矩阵”一节),单位矩阵的相关性将变得清晰——这是一个经典的先有鸡还是先有蛋的问题。

注意当我们在这个上下文中谈论点时,我们实际上指的是 3D 矢量。

OpenGL ES 有三种不同的矩阵,适用于我们的模型点:

  • 模型-视图矩阵 : 我们可以使用这个矩阵来移动、旋转或缩放三角形的点(这是模型-视图矩阵的模型部分)。这个矩阵也用于指定我们摄像机的位置和方向(这是视图部分)。
  • 投影矩阵 : 这个名字说明了一切——这个矩阵编码了一个投影,也就是我们相机的视锥。
  • 纹理矩阵 : 这个矩阵允许我们操作纹理坐标(我们将在后面讨论)。然而,我们将避免在本书中使用这个矩阵,因为 OpenGL ES 的这一部分由于错误的驱动程序而在一些设备上被破坏了。

渲染管道

OpenGL ES 跟踪这三个矩阵。每次我们设置一个矩阵,OpenGL ES 会记住它,直到我们再次改变矩阵。在 OpenGL ES 中,这被称为一个状态。 OpenGL 不仅仅跟踪矩阵状态;它还跟踪我们是否想要阿尔法混合三角形,我们是否想要将光照考虑在内,哪个纹理应该应用于我们的几何图形,等等。事实上,OpenGL ES 是一个巨大的状态机;我们设置它的当前状态,向它提供我们的对象的几何图形,并告诉它为我们渲染一幅图像。让我们看看一个三角形是如何通过这个强大的三角形渲染机器的。图 7-4 显示了 OpenGL ES 管道的高度简化视图。

9781430246770_Fig07-04.jpg

图 7-4。三角形之道

三角形通过此管道的方式如下所示:

  1. 我们的勇敢三角形首先被模型-视图矩阵转换。这意味着它的所有点都与这个矩阵相乘。这种乘法将有效地在世界上移动三角形的点。
  2. 然后将得到的输出乘以投影矩阵,有效地将 3D 点转换到 2D 投影*面上。
  3. 在这两个步骤之间(或*行于它们),当前设置的灯光和材质也被应用到我们的三角形,赋予它颜色。
  4. 所有这些完成后,投影的三角形被剪切到我们的“视网膜”上,并通过应用视口变换转换到帧缓冲区坐标。
  5. 最后一步,OpenGL 根据灯光阶段的颜色、要应用于三角形的纹理以及三角形的每个像素可能会或可能不会与帧缓冲区中的像素相结合的混合状态来填充三角形的像素。

你需要学习的只是如何在 OpenGL ES 中抛出几何图形和纹理,并设置前面每个步骤所使用的状态。在你这样做之前,你需要了解 Android 是如何授权你访问 OpenGL ES 的。

注意虽然对 OpenGL ES 管道的高级描述大多是正确的,但它被极大地简化了,并省略了一些在后面章节中会变得重要的细节。另一个需要注意的是,当 OpenGL ES 执行投影时,它实际上并不投影到 2D 坐标系上;相反,它投射到一个叫做的齐次坐标系中,实际上是四维的。这是一个非常复杂的数学主题,所以为了简单起见,我们将只坚持 OpenGL ES 投影到 2D 坐标的简化前提。

在开始之前

在本章的其余部分,我们将提供许多简短的例子,就像我们在第四章讨论 Android API 基础时所做的一样。我们将使用我们在第四章中使用的相同的 starter 类,它向您显示了您可以开始的 starter 活动列表。唯一会改变的是通过反射实例化的活动的名称,以及它们所在的包。本章其余部分的所有例子都在 com . badlogic . androidgames . GL basics 包中,其余代码保持不变。您的新启动活动将被命名为 GLBasicsStarter。你还将复制所有来自第五章的源代码,即 com . badlogic . androidgames . framework 包及其所有子包。在这一章中,你将编写一些新的框架和助手类,它们将放在 com . bad logic . androidgames . framework 包和子包中。

我们还有一个清单文件。由于下面的每个例子都是一个活动,我们还必须确保每个例子在清单中都有一个条目。所有的例子都将使用一个固定的方向(纵向或横向,取决于例子),并将告诉 Android 它们可以处理键盘、keyboardHidden 和 orientationChange 事件。这与我们在第四章中使用的设置几乎完全相同。

别挡着我们的路,让乐趣开始吧!

GLSurfaceView :自 2008 年以来让事情变得简单

我们需要的第一件事是某种类型的视图,允许我们通过 OpenGL ES 进行绘制。幸运的是,在 Android API 中有这样一个视图——它被称为 GLSurfaceView,它是 SurfaceView 类的派生,我们已经用它来绘制 Nom 先生的世界。

我们还需要一个单独的主循环线程,这样我们就不会陷入 UI 线程的泥潭。惊喜:GLSurfaceView 已经为我们设置了这样的线程!我们需要做的就是实现一个名为 GLSurfaceView 的侦听器接口。渲染器并将其注册到 GLSurfaceView。该接口有三种方法:

interface Renderer {
    public void onSurfaceCreated(GL10 gl, EGLConfig config);

    public void onSurfaceChanged(GL10 gl, int width, int height);

    public void onDrawFrame(GL10 gl);
}

每次创建 onSurfaceCreated()表面时都会调用 onSurfaceCreated()方法。这发生在我们第一次启动活动时,以及每次我们从暂停状态回到活动时。该方法有两个参数:一个 GL10 实例和一个 EGLConfig。GL10 实例允许我们向 OpenGL ES 发出命令。EGLConfig 只是告诉我们表面的属性,比如颜色、深度等等。我们通常会忽略它。我们将在 onSurfaceCreated()方法中设置我们的几何图形和纹理。

每次调整图面大小时,都会调用 onSurfaceChanged()方法。我们以像素为单位获取新的表面宽度和高度作为参数,如果我们想发出 OpenGL ES 命令,还需要一个 GL10 实例。

onDrawFrame()方法是有趣的地方。它在精神上类似于我们的 Screen.render()方法,该方法被 GLSurfaceView 为我们设置的渲染线程尽可能频繁地调用。在这个方法中,我们执行所有的渲染。

除了注册渲染器监听器,我们还必须在活动的 onPause()/onResume()方法中调用 glsurface view . on pause()/on resume()。原因很简单。GLSurfaceView 将在其 onResume()方法中启动渲染线程,并在其 onPause()方法中将其拆除。这意味着当我们的活动暂停时,我们的侦听器不会被调用,因为调用我们的侦听器的呈现线程也将被暂停。

唯一令人沮丧的是:每次我们的活动暂停时,GLSurfaceView 的表面就会被破坏。当活动恢复时,调用 GLSurfaceView.onResume()时,GLSurfaceView 实例化一个新的 OpenGL ES 呈现表面,并通过调用侦听器的 onSurfaceCreated()方法通知我们这一点。如果没有一个问题,这一切都很好:我们到目前为止设置的所有 OpenGL ES 状态都将丢失。这也包括像纹理这样的东西,我们必须重新加载。这个问题被称为上下文丢失。单词 context 源于这样一个事实:OpenGL ES 将一个上下文与我们创建的每个表面相关联,它保存着当前的状态。当我们破坏了表面,背景也就消失了。不过,考虑到我们恰当地设计了游戏来处理这种上下文丢失,这也不是那么糟糕。

注意实际上,EGL 负责上下文和表面的创建和破坏。EGL 是另一个 Khronos 组标准;它定义了操作系统的 UI 如何与 OpenGL ES 协同工作,以及操作系统如何授予 OpenGL ES 对底层图形硬件的访问权限。这包括表面创建以及上下文管理。因为 GLSurfaceView 为我们处理了所有 EGL 的东西,所以我们几乎在所有情况下都可以安全地忽略它。

遵循传统,让我们写一个小例子,用随机颜色清空屏幕。清单 7-1 显示了代码。列表被分开,评论混杂在一起。

清单 7-1 。【GLSurfaceViewTest.java】;清屏狂

package com.badlogic.androidgames.glbasics;

import java.util.Random;

import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;

import android.app.Activity;
import android.opengl.GLSurfaceView;
import android.opengl.GLSurfaceView.Renderer;
import android.os.Bundle;
import android.util.Log;
import android.view.Window;
import android.view.WindowManager;

public class GLSurfaceViewTest extends Activity {
    GLSurfaceView glView;

    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        requestWindowFeature(Window.*FEATURE_NO_TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG_FULLSCREEN*,
                WindowManager.LayoutParams.*FLAG_FULLSCREEN*);
        glView = new GLSurfaceView(this );
        glView.setRenderer(new SimpleRenderer());
        setContentView(glView);
    }

我们将对 GLSurfaceView 实例的引用作为该类的成员。在 onCreate()方法中,我们让应用全屏显示,创建 GLSurfaceView,设置渲染器实现,并让 GLSurfaceView 成为我们活动的内容视图。

    @Override
    public void onResume() {
        super .onPause();
        glView.onResume();
    }

    @Override
    public void onPause() {
        super .onPause();
        glView.onPause();
    }

在 onResume()和 onPause()方法中,我们调用 supermethods 以及各自的 GLSurfaceView 方法。这将启动和关闭 GLSurfaceView 的渲染线程,进而在适当的时候触发我们的渲染器实现的回调方法。

    static class SimpleRendererimplements Renderer {
        Random rand = new Random();

        public void onSurfaceCreated(GL10 gl, EGLConfig config) {
            Log.*d*("GLSurfaceViewTest", "surface created");
        }

        public void onSurfaceChanged(GL10 gl, int width, int height) {
            Log.*d*("GLSurfaceViewTest", "surface changed: " + width + "x"
                    + height);
        }

        public void onDrawFrame(GL10 gl) {
            gl.glClearColor(rand.nextFloat(), rand.nextFloat(),
                    rand.nextFloat(), 1);
            gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        }
    }
}

代码的最后一部分是我们的渲染器实现。它只是在 onSurfaceCreated()和 onSurfaceChanged()方法中记录一些信息。真正有趣的部分是 onDrawFrame()方法。

如前所述,GL10 实例让我们可以访问 OpenGL ES API。GL10 中的 10 表示它为我们提供了 OpenGL ES 1.0 标准中定义的所有功能。目前,我们对此感到满意。按照标准中的定义,该类的所有方法都映射到相应的 C 函数。每个方法都以前缀 gl 开始,这是 OpenGL ES 的一个老传统。

我们调用的第一个 OpenGL ES 方法是 glClearColor()。你可能已经知道那会有什么作用。它设置当我们发出清除屏幕的命令时使用的颜色。OpenGL ES 中的颜色几乎总是 RGBA,其中每个分量的范围在 0 到 1 之间。在 RGB565 中有很多定义颜色的方法,但是现在,让我们坚持使用浮点表示法。我们可以只设置一次用于清除的颜色,OpenGL ES 会记住它。我们用 glClearColor()设置的颜色是 OpenGL ES 的状态之一。

下一个调用实际上用我们刚刚指定的透明颜色来清除屏幕。方法 glClear()接受一个指定要清除哪个缓冲区的参数。除了帧缓冲区之外,OpenGL 还有其他一些缓冲区。你将在第十章中了解它们,但是现在,我们所关心的是保存我们像素的帧缓冲区,OpenGL ES 称之为颜色缓冲区。为了告诉 OpenGL ES 我们想要清除那个确切的缓冲区,我们指定了常量 GL10。GL_COLOR_BUFFER_BIT。

OpenGL ES 有很多常量,都被定义为 GL10 接口的静态公共成员。像这些方法一样,每个常量都有前缀 GL_。

这是我们的第一个 OpenGL ES 应用。我们不会给你留下令人印象深刻的截图,因为你可能知道它看起来像什么。

注意你永远不能从另一个线程调用 OpenGL ES!第一条也是最后一条戒律!原因是 OpenGL ES 被设计成只在单线程环境中使用,它不是线程安全的。它可以在多线程上工作,但许多驱动程序对此有问题,这样做没有真正的好处。

galgame:实现游戏界面

在前一章中,我们实现了 AndroidGame 类,它将音频、文件 I/O、图形和用户输入处理的所有子模块联系在一起。我们希望在即将推出的 2D OpenGL ES 游戏中重用其中的大部分,所以让我们实现一个名为 GLGame 的新类,它实现了我们之前定义的游戏接口。

你会注意到的第一件事是,以你目前对 OpenGL ES 的了解,你不可能实现图形界面。这里有一个惊喜:你不会实现它。OpenGL 不太适合图形界面的编程模型;相反,我们将实现一个新的类 GLGraphics,它将跟踪我们从 GLSurfaceView 获得的 GL10 实例。清单 7-2 显示了代码。

清单 7-2 。【GLGraphics.java】;跟踪 GLSurfaceView 和 GL10 实例

package com.badlogic.androidgames.framework.impl;

import javax.microedition.khronos.opengles.GL10;

import android.opengl.GLSurfaceView;

public class GLGraphics {
    GLSurfaceView glView;
    GL10 gl;
    GLGraphics(GLSurfaceView glView) {
        this.glView = glView;
    }

    public GL10 getGL() {
        return gl;
    }

    void setGL(GL10 gl) {
        this.gl = gl;
    }

    public int getWidth() {
        return glView.getWidth();
    }

    public int getHeight() {
        return glView.getHeight();
    }
}

这个类只有几个 getters 和 setters。注意,我们将在 GLSurfaceView 设置的渲染线程中使用这个类。因此,调用视图的方法可能会有问题,因为视图主要位于 UI 线程上。在这种情况下,没问题,因为我们只查询 GLSurfaceView 的宽度和高度,所以我们没有问题。

GLGame 类更复杂一些。它借用了 AndroidGame 类的大部分代码。渲染线程和 UI 线程之间的同步稍微复杂一些。让我们在清单 7-3 中来看看。

清单 7-3 。GLGame.java,浩浩荡荡的 OpenGL ES 游戏实现

package com.badlogic.androidgames.framework.impl;

import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;

import android.app.Activity;
import android.content.Context;
import android.opengl.GLSurfaceView;
import android.opengl.GLSurfaceView.Renderer;
import android.os.Bundle;
import android.os.PowerManager;
import android.os.PowerManager.WakeLock;
import android.view.Window;
import android.view.WindowManager;

import com.badlogic.androidgames.framework.Audio;
import com.badlogic.androidgames.framework.FileIO;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Graphics;
import com.badlogic.androidgames.framework.Input;
import com.badlogic.androidgames.framework.Screen;

public abstract class GLGame extends Activityimplements Game, Renderer {
    enum GLGameState {
        *Initialized*,
        *Running*,
        *Paused*,
        *Finished*,
        *Idle*
    }

    GLSurfaceView glView;
    GLGraphics glGraphics;
    Audio audio;
    Input input;
    FileIO fileIO;
    Screen screen;
    GLGameState state = GLGameState.*Initialized*;
    Object stateChanged = new Object();
    long startTime = System.*nanoTime*();
    WakeLock wakeLock;

该类扩展了 Activity 类,实现了 Game 和 GLSurfaceView。渲染器接口。它有一个名为 GLGameState 的枚举,用于跟踪 GLGame 实例所处的状态。一会儿你会看到它们是如何被使用的。

该类的成员由一个 GLSurfaceView 实例和一个 GLGraphics 实例组成。这个类也有音频、输入、文件和屏幕实例,这些是我们编写游戏所需要的,就像我们编写 AndroidGame 类一样。状态成员通过一个 GLGameState 枚举来跟踪状态。stateChanged 成员是一个我们将用来同步 UI 和呈现线程的对象。最后,我们有一个成员来跟踪增量时间和一个唤醒锁,我们将使用它来防止屏幕变暗。

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super .onCreate(savedInstanceState);
        requestWindowFeature(Window.*FEATURE_NO_TITLE*);
        getWindow().setFlags(WindowManager.LayoutParams.*FLAG_FULLSCREEN*,
                             WindowManager.LayoutParams.*FLAG_FULLSCREEN*);
        glView = new GLSurfaceView(this );
        glView.setRenderer(this );
        setContentView(glView);

        glGraphics = new GLGraphics(glView);
        fileIO = new AndroidFileIO(this );
        audio = new AndroidAudio(this );
        input = new AndroidInput(this , glView, 1, 1);
        PowerManager powerManager = (PowerManager) getSystemService(Context.*POWER_SERVICE*);
        wakeLock = powerManager.newWakeLock(PowerManager.*FULL_WAKE_LOCK*, "GLGame");
    }

在 onCreate()方法中,我们执行通常的设置例程。我们让活动全屏显示,并实例化 GLSurfaceView,将其设置为内容视图。我们还实例化了所有其他实现框架接口的类,比如 AndroidFileIO 和 AndroidInput 类。注意,我们重用了在 AndroidGame 类中使用的类,除了 AndroidGraphics。另一个重点是,我们不再让 AndroidInput 类将触摸坐标缩放到目标分辨率,就像在 AndroidGame 中一样。标度值都是 1,所以我们将得到真实的触摸坐标。我们为什么这样做,以后会变得很清楚。我们要做的最后一件事是创建唤醒锁实例。

    @Override
    public void onResume() {
        super .onResume();
        glView.onResume();
        wakeLock.acquire();
    }

在 onResume()方法中,我们让 GLSurfaceView 通过调用其 onResume()方法来启动渲染线程。我们也获得了唤醒锁。

    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        glGraphics.setGL(gl);

        synchronized (stateChanged) {
            if (state == GLGameState.*Initialized*)
                screen = getStartScreen();
            state = GLGameState.*Running*;
            screen.resume();
            startTime = System.*nanoTime*();
        }
    }

接下来将调用 onSurfaceCreate()方法,这当然是在渲染线程上调用的。在这里,您可以看到状态枚举是如何使用的。如果是第一次启动应用,state 会是 GLGameState.Initialized,这种情况下我们调用 getStartScreen()方法返回游戏的开始画面。如果游戏没有处于初始化状态,而是已经运行,我们知道我们刚刚从暂停状态恢复。在任何情况下,我们都将 state 设置为 GLGameState。运行并调用当前屏幕的 resume()方法。我们还记录了当前时间,这样我们就可以在以后计算 delta 时间。

同步是必要的,因为我们在 synchronized 块中操作的成员可以在 UI 线程上的 onPause()方法中操作。这是我们必须防止的,所以我们使用一个对象作为锁。我们也可以使用 GLGame 实例本身,或者一个适当的锁。

    public void onSurfaceChanged(GL10 gl, int width, int height) {
    }

onSurfaceChanged()方法基本上只是一个存根。我们在这里无事可做。

    public void onDrawFrame(GL10 gl) {
        GLGameState state = null ;

        synchronized (stateChanged) {
            state = this.state;
        }
        if (state == GLGameState.*Running*) {
            float deltaTime = (System.*nanoTime*()-startTime) / 1000000000.0f;
            startTime = System.*nanoTime*();
            screen.update(deltaTime);
            screen.present(deltaTime);
        }
        if (state == GLGameState.*Paused*) {
            screen.pause();
            synchronized (stateChanged) {
                this.state = GLGameState.*Idle*;
                stateChanged.notifyAll();
            }
        }
        if (state == GLGameState.*Finished*) {
            screen.pause();
            screen.dispose();
            synchronized (stateChanged) {
                this.state = GLGameState.*Idle*;
                stateChanged.notifyAll();
            }
        }
    }

onDrawFrame()方法是执行大部分工作的地方。渲染线程尽可能频繁地调用它。在这里,我们检查我们的游戏处于什么状态,并作出相应的反应。由于可以在 UI 线程的 onPause()方法上设置状态,所以我们必须同步对它的访问。

如果游戏正在运行,我们计算 delta 时间,并告诉当前屏幕更新并自我呈现。

如果游戏暂停,我们告诉当前屏幕也暂停。然后,我们将状态更改为 GLGameState。Idle,表示我们收到了 UI 线程的暂停请求。因为我们在 UI 线程的 onPause()方法中等待这种情况发生,所以我们通知 UI 线程现在可以真正暂停应用了。这个通知是必要的,因为我们必须确保渲染线程被正确地暂停/关闭,以防我们的活动在 UI 线程上被暂停或关闭。

如果活动正在关闭(而不是暂停),我们对 GLGameState.Finished 作出反应。在这种情况下,我们告诉当前屏幕暂停并释放自己,然后向 UI 线程发送另一个通知,该线程等待呈现线程正确关闭。

    @Override
    public void onPause() {
        synchronized (stateChanged) {
            if (isFinishing())
                state = GLGameState.*Finished*;
            else
                state = GLGameState.*Paused*;
            while (true ) {
                try {
                    stateChanged.wait();
                    break ;
                } catch (InterruptedException e) {
                }
            }
        }
        wakeLock.release();
        glView.onPause();
        super .onPause();
    }

onPause()方法是我们常用的活动通知方法,当活动暂停时,在 UI 线程上调用该方法。根据应用是关闭还是暂停,我们相应地设置状态,并等待呈现线程处理新状态。这是通过标准的 Java 等待/通知机制实现的。

最后,我们释放唤醒锁,并告诉 GLSurfaceView 和 Activity 暂停自己,有效地关闭了渲染线程并破坏了 OpenGL ES 表面,这触发了前面提到的可怕的 OpenGL ES 上下文丢失。

    public GLGraphics getGLGraphics() {
        return glGraphics;
    }

getGLGraphics()方法是一个只能通过 GLGame 类访问的新方法。它返回我们存储的 GLGraphics 实例,以便我们可以在以后的屏幕实现中访问 GL10 接口。

    public Input getInput() {
        return input;
    }

    public FileIO getFileIO() {
        return fileIO;
    }

    public Graphics getGraphics() {
        throw new IllegalStateException("We are using OpenGL!");
    }

    public Audio getAudio() {
        return audio;
    }
    public void setScreen(Screen newScreen) {
        if (screen ==null )
            throw new IllegalArgumentException("Screen must not be null");

        this.screen.pause();
        this.screen.dispose();
        newScreen.resume();
        newScreen.update(0);
        this.screen = newScreen;
    }

    public Screen getCurrentScreen() {
        return screen;
    }
}

班上的其他人像以前一样工作。如果我们不小心试图访问标准图形实例,我们会抛出一个异常,因为 GLGame 不支持它。相反,我们将使用通过 GLGame.getGLGraphics()方法获得的 GLGraphics 方法。

为什么我们要经历与渲染线程同步的所有痛苦?这将使我们的屏幕实现完全依赖于渲染线程。Screen 的所有方法都将在那里执行,如果我们想访问 OpenGL ES 功能,这是必要的。记住,我们只能在渲染线程上访问 OpenGL ES。

让我们用一个例子来完成它。清单 7-4 展示了我们在本章中的第一个例子在使用 GLGame 和 Screen 时的样子。

清单 7-4 。【GLGameTest.java】;更多的屏幕清晰,现在是 100%的 GLGame

package com.badlogic.androidgames.glbasics;

import java.util.Random;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.impl.GLGame;
import com.badlogic.androidgames.framework.impl.GLGraphics;

public class GLGameTest extends GLGame {
    public Screen getStartScreen() {
        return new TestScreen(this );
    }

    class TestScreen extends Screen {
        GLGraphics glGraphics;
        Random rand = new Random();
        public TestScreen(Game game) {
            super (game);
            glGraphics = ((GLGame) game).getGLGraphics();
        }

        @Override
        public void present(float deltaTime) {
            GL10 gl = glGraphics.getGL();
            gl.glClearColor(rand.nextFloat(), rand.nextFloat(),
                    rand.nextFloat(), 1);
            gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        }

        @Override
        public void update(float deltaTime) {
        }

        @Override
        public void pause() {
        }

        @Override
        public void resume() {
        }

        @Override
        public void dispose() {
        }
    }
}

这是与我们上一个例子相同的程序,除了我们现在从 GLGame 而不是 Activity 派生,并且我们提供了一个屏幕实现而不是 GLSurfaceView。渲染器实现。

在下面的例子中,我们将只看每个例子的屏幕实现的相关部分。我们示例的整体结构将保持不变。当然,我们必须将示例 GLGame 实现添加到我们的 starter 活动以及 manifest 文件中。

这样一来,让我们渲染我们的第一个三角形。

看,妈妈,我有一个红色的三角形!

你已经知道 OpenGL ES 在我们告诉它画一些几何图形之前需要设置一些东西。我们最关心的两件事是投影矩阵(以及与之相关的视图截锥)和视口,视口控制着输出图像的大小和渲染输出在帧缓冲区中的位置。

定义视口

OpenGL ES 使用视口作为一种方式,将投影到*剪裁*面的点的坐标转换为帧缓冲区像素坐标。我们可以用下面的方法告诉 OpenGL ES 只使用我们的帧缓冲区的一部分——或者全部:

GL10.glViewport(int x, int y, int width, int height)

x 和 y 坐标指定帧缓冲区中视口的左上角,宽度和高度以像素为单位指定视口的大小。请注意,OpenGL ES 假设帧缓冲坐标系的原点在屏幕的左下角。通常我们将 x 和 y 设置为 0,将宽度和高度设置为屏幕分辨率,因为我们使用的是全屏模式。通过这种方法,我们可以指示 OpenGL ES 只使用一部分帧缓冲区。然后,它将获取渲染输出,并自动将其拉伸到该部分。

注意虽然这个方法看起来像是为我们设置了一个 2D 坐标系来进行渲染,但实际上并不是这样。它只定义了 OpenGL ES 用来输出最终图像的帧缓冲区部分。我们的坐标系是通过投影和模型视图矩阵定义的。

定义投影矩阵

接下来我们需要定义的是投影矩阵。由于本章我们只关心 2D 图形,我们想用*行投影。我们如何做到这一点?

矩阵模式和有源矩阵

我们已经讨论过 OpenGL ES 跟踪三个矩阵:投影矩阵、模型视图矩阵和纹理矩阵(我们将继续忽略)。OpenGL ES 提供了几种特定的方法来修改这些矩阵。然而,在我们使用这些方法之前,我们必须告诉 OpenGL ES 我们想要操作哪个矩阵。我们通过以下方法做到这一点:

GL10.glMatrixMode(int mode)

模式参数可以是 GL10。GL_PROJECTION,GL10。GL_MODELVIEW,或 GL10。GL _ 纹理。应该清楚这些常数中的哪一个将使哪一个矩阵活跃。对矩阵操作方法的任何后续调用都将针对我们用此方法设置的矩阵,直到我们通过对此方法的另一个调用再次更改活动矩阵。这种矩阵模式是 OpenGL ES 的状态之一(如果我们的应用暂停并恢复,当我们丢失上下文时,它将丢失)。要通过任何后续调用来操作投影矩阵,我们可以像这样调用方法:

gl.glMatrixMode(GL10.GL_PROJECTION);

带 glOrthof 的正投影

OpenGL ES 提供以下方法将活动矩阵设置为正交(*行)投影矩阵:

GL10.glOrthof(int left, int right, int bottom, int top, int near, int far)

嘿,这看起来好像和我们的视图截锥的剪裁*面有关。。。的确如此!那么我们在这里指定什么值呢?

OpenGL ES 有一个标准坐标系,如图图 7-5 所示。正 x 轴指向右侧,正 y 轴指向上方,正 z 轴指向我们。使用 glOrthof(),我们在这个坐标系中定义我们的*行投影的视图截锥。如果你回头看图 7-3 ,你可以看到*行投影的视锥是一个盒子。我们可以将 glOrthof()的参数解释为指定视图截锥框的两个角。图 7-5 说明了这一点。

9781430246770_Fig07-05.jpg

图 7-5。一个正交视图截锥

我们的视见*截头体的正面将直接映射到我们的视口。在从(0,0)到(480,320)的全屏视口的情况下(例如,Hero 上的横向模式),正面的左下角将映射到屏幕的左下角,正面的右上角将映射到屏幕的左上角。OpenGL 将为我们自动执行拉伸。

因为我们想做 2D 图形,我们将指定角点——左、下、*、右、上、远(见图 7-5)——以一种允许我们在某种像素坐标系中工作的方式,就像我们对画布和 Nom 先生所做的那样。下面是我们如何建立这样一个坐标系:

gl.glOrthof(0, 480, 0, 320, 1, -1);

图 7-6 显示了视图截锥。

9781430246770_Fig07-06.jpg

图 7-6。我们用 OpenGL ES 实现 2D 渲染的*行投影视锥

我们的视图截锥很薄,但这没关系,因为我们只在 2D 工作。我们坐标系的可见部分从(0,0,1)到(480,320,–1)。我们在此框中指定的任何点也将在屏幕上可见。这些点将被投影到这个盒子的正面,这是我们心爱的*裁剪*面。然后投影会被拉伸到视口,不管它有多大。假设我们有一台 Nexus One,在风景模式下分辨率为 800×480 像素。当我们指定我们的视见*截头体时,我们可以在 480×320 的坐标系中工作,OpenGL 会将其拉伸到 800×480 的帧缓冲区(如果我们指定视口覆盖整个帧缓冲区)。最重要的是,没有什么能阻止我们使用更疯狂的视锥。我们也可以使用一个带拐角的(1,–1,100)和(2,2,–100)。我们指定的落在这个框内的所有东西都将是可见的,并且会被自动拉伸——非常漂亮!

注意,我们还设置了*剪裁*面和远剪裁*面。因为在这一章中我们将完全忽略 z 坐标,你可能想用 0 来表示*和远;然而,由于各种原因,这是一个坏主意。为了安全起见,我们在 z 轴上给视图截锥一点缓冲。我们所有几何图形的点都将在 x-y *面中定义,z 设置为 0-2D。

注意你可能已经注意到,y 轴现在指向上方,原点在屏幕的左下角。虽然画布、UI 框架和许多其他 2D 渲染 API 使用 y 向下、原点左上角的约定,但实际上使用这种“新”坐标系进行游戏编程更方便。例如,如果超级马里奥在跳跃,你不希望他在上升的过程中 y 坐标增加而不是减少吗?想在另一个坐标系中工作?好吧,就把 glOrthof()的底部和顶部参数交换一下。此外,虽然视图截锥的图示从几何角度来看大多是正确的,但 glOrthof()实际上对*裁剪*面和远裁剪*面的解释略有不同。因为这有点复杂,我们就假设前面的例子是正确的。

一个有用的片段

这里有一小段将在本章的所有例子中使用。它用黑色清除屏幕,将视口设置为跨越整个帧缓冲区,并设置投影矩阵(从而设置视见*截头体),以便我们可以在一个舒适的坐标系中工作,原点在屏幕的左下角,y 轴指向上方。

gl.glClearColor(0,0,0,1);
gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
gl.glMatrixMode(GL10.GL_PROJECTION);
gl.glLoadIdentity();
gl.glOrthof(0, 320, 0, 480, 1, -1);

等等,glLoadIdentity()在里面做什么?嗯,OpenGL ES 提供给我们的大多数操作活动矩阵的方法实际上并没有设置矩阵;取而代之的是,它们从所取的任何参数中构造一个临时矩阵,并将其与当前矩阵相乘。glOrthof()方法也不例外。例如,如果我们在每一帧调用 glOrthof(),我们会将投影矩阵与其自身相乘。我们没有这样做,而是在乘以投影矩阵之前,确保我们有一个干净的单位矩阵。记住,矩阵乘以单位矩阵会再次输出矩阵本身,这就是 glLoadIdentity()的作用。可以把它想象成首先加载值 1,然后用它乘以我们拥有的任何东西——在我们的例子中,是 glOrthof()产生的投影矩阵。

请注意,我们的坐标系现在从(0,0,1)变为(320,480,–1)——这是针对肖像模式渲染的。

指定三角形

接下来,我们必须弄清楚如何告诉 OpenGL ES 我们想要它渲染的三角形。首先,让我们定义一下三角形的组成:

  • 三角形由三个点组成。
  • 每个点称为一个顶点。
  • 顶点在 3D 空间中有一个位置。
  • 3D 空间中的位置由三个浮点数给出,分别指定 x、y 和 z 坐标。
  • 一个顶点可以有额外的属性,比如颜色或者纹理坐标(我们将在后面讨论)。这些也可以用浮点来表示。

OpenGL ES 期望以数组的形式发送我们的三角形定义;但是,鉴于 OpenGL ES 实际上是一个 C API,我们不能只使用标准的 Java 数组。相反,我们必须使用 Java NIO 缓冲区,它只是连续字节的内存块。

一个小的 NIO 缓冲区题外话

准确地说,我们需要使用直接 NIO 缓冲区。这意味着内存不是在虚拟机的堆内存中分配的,而是在本机堆内存中分配的。要构建这样一个直接的 NIO 缓冲区,我们可以使用下面的代码片段:

ByteBuffer buffer = ByteBuffer.allocateDirect(NUMBER_OF_BYTES);
buffer.order(ByteOrder.nativeOrder());

这将分配一个 ByteBuffer,它总共可以容纳 NUMBER_OF_BYTES 个字节,并且它将确保字节顺序等于底层 CPU 使用的字节顺序。NIO 缓冲区有三个属性。

  • 容量:缓冲区总共可以容纳的元素数量。
  • Position:下一个元素将被写入或读取的当前位置。
  • Limit:已定义的最后一个元素的索引,加 1。

缓冲区的容量就是它的实际大小。对于 ByteBuffer,它以字节为单位。position 和 limit 属性可以被认为是定义了缓冲区中从 position 开始到 limit (exclusive)结束的段。

因为我们想将顶点指定为浮点,所以最好不要处理字节。幸运的是,我们可以将 ByteBuffer 实例转换为 FloatBuffer 实例,这就允许我们这样做:使用 floats。

FloatBuffer floatBuffer = buffer.asFloatBuffer();

在 FloatBuffer 的情况下,容量、位置和限制是以浮点数的形式给出的。我们对这些缓冲区的使用模式将非常有限——它是这样的:

float[] vertices = { ... definitions of vertex positions etc.  ... };
floatBuffer.clear();
floatBuffer.put(vertices);
floatBuffer.flip();

我们首先在一个标准的 Java 浮点数组中定义数据。在将浮点数组放入缓冲区之前,我们告诉缓冲区通过 clear()方法清除自身。这实际上不会擦除任何数据,但它会将位置设置为 0,并将容量限制设置为。接下来,我们使用 FloatBuffer.put(float[] array)方法将整个数组的内容复制到缓冲区,从缓冲区的当前位置开始。复制后,缓冲区的位置将增加数组的长度。接下来,调用 put()方法将附加数据追加到我们复制到缓冲区的最后一个数组的数据中。对 FloatBuffer.flip()的最后一次调用只是交换位置和界限。

对于这个例子,让我们假设顶点数组的大小是五个浮点数,并且我们的 FloatBuffer 有足够的容量来存储这五个浮点数。在调用 FloatBuffer.put()之后,缓冲区的位置将是 5(索引 0 到 4 由数组中的五个浮点数占据)。该限制仍将等于缓冲器的容量。调用 FloatBuffer.flip()后,位置将被设置为 0,限制将被设置为 5。对从缓冲区读取数据感兴趣的任何一方将知道它应该从索引 0 到 4 读取浮点数(记住限制是排他的);这也正是 OpenGL ES 需要知道的。但是,请注意,它会愉快地忽略这个限制。通常,除了向它传递缓冲区之外,我们还必须告诉它要读取的元素的数量。没有错误检查,所以要小心。

有时,在我们填满缓冲区后,手动设置它的位置是很有用的。这可以通过调用以下方法来完成:

FloatBuffer.position(int position)

稍后,当我们临时将填充的缓冲区的位置设置为 0 以外的值,以便 OpenGL ES 从特定位置开始读取时,这将会很方便。

将顶点发送到 OpenGL ES

那么我们如何定义第一个三角形的三个顶点的位置呢?简单——假设我们的坐标系是(0,0,1)到(320,480,–1 ),正如我们在前面的代码片段中定义的那样——我们可以做以下事情:

ByteBuffer byteBuffer = ByteBuffer.*allocateDirect*(3 * 2 * 4);
byteBuffer.order(ByteOrder.*nativeOrder*());
FloatBuffer vertices = byteBuffer.asFloatBuffer();
vertices.put(new float [] {   0.0f,   0.0f,
                           319.0f,   0.0f,
                           160.0f, 479.0f  });
vertices.flip();

前三行应该已经很熟悉了。唯一有趣的部分是我们分配了多少字节。我们有三个顶点,每个顶点由 x 和 y 坐标组成。每个坐标是一个浮点数,因此占用 4 个字节。那是 3 个顶点 2 个坐标乘以 4 个字节,对于我们的三角形总共是 24 个字节。

注意我们只能用 x 和 y 坐标指定顶点,OpenGL ES 会自动为我们将 z 坐标设置为 0。

接下来,我们将保存顶点位置的浮点数组放入缓冲区。我们的三角形从左下角(0,0)开始,到视见体/屏幕的右边缘(319,0),然后到视见体/屏幕的上边缘的中间。作为优秀的 NIO 缓冲区用户,我们还在缓冲区上调用 flip()方法。因此,位置将是 0,限制将是 6(记住,FloatBuffer 限制和位置是以浮点数而不是字节给出的)。

一旦我们准备好了 NIO 缓冲区,我们就可以告诉 OpenGL ES 用它的当前状态(即视口和投影矩阵)来绘制它。这可以通过下面的代码片段来完成:

gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
gl.glVertexPointer( 2, GL10.*GL_FLOAT*, 0, vertices);
gl.glDrawArrays(GL10.*GL_TRIANGLES*, 0, 3);

对 glEnableClientState()的调用有点过时了。它告诉 OpenGL ES 我们要画的顶点有位置。这有点傻,原因有二:

  • 这个常数叫做 GL10。GL_VERTEX_ARRAY,有点混乱。如果叫 GL10 就更有意义了。GL_POSITION_ARRAY
  • 没有办法画出没有位置的东西,所以调用这个方法有点多余。无论如何,我们这样做是为了让 OpenGL ES 开心。

在对 glVertexPointer()的调用中,我们告诉 OpenGL ES 在哪里可以找到顶点位置,并给它一些附加信息。第一个参数告诉 OpenGL ES,每个顶点位置由两个坐标 x 和 y 组成。如果我们指定了 x、y 和 z,我们就会向该方法传递三个。第二个参数告诉 OpenGL ES 我们用来存储每个坐标的数据类型。在这种情况下,是 GL10。GL_FLOAT,表示我们使用了编码为 4 字节的浮点数。第三个参数 stride 告诉 OpenGL 我们的顶点位置之间的距离,以字节为单位。在前面的例子中,stride 是 0,因为位置被紧密压缩[顶点 1 (x,y),顶点 2 (x,y),等等]。最后一个参数是我们的 FloatBuffer,需要记住两件事:

  • FloatBuffer 表示本机堆中的一个内存块,因此有一个起始地址。
  • FloatBuffer 的位置是从起始地址的偏移量。

OpenGL ES 将获取缓冲区的起始地址,并添加缓冲区的位置以到达缓冲区中的浮点值,当我们告诉它绘制缓冲区的内容时,它将从该浮点值开始读取顶点。顶点指针(也应该称为位置指针)是 OpenGL ES 的一种状态。只要我们不改变它(并且上下文没有丢失),OpenGL ES 就会记住它,并在所有需要顶点位置的后续调用中使用它。

最后,调用 glDrawArrays()。它会画出我们的三角形。第一个参数指定了我们将要绘制的图元的类型。在这种情况下,我们说我们想要呈现一个三角形列表,这是通过 GL10 指定的。GL _ 三角形。下一个参数是相对于顶点指针指向的第一个顶点的偏移量。偏移量是以顶点来度量的,而不是以字节或浮点来度量。如果我们指定了不止一个三角形,我们可以使用这个偏移量来只呈现三角形列表的一个子集。最后一个参数告诉 OpenGL ES 应该使用多少个顶点进行渲染。在我们的例子中,有三个顶点。请注意,如果我们绘制 GL10,我们必须始终指定 3 的倍数。GL _ 三角形。每个三角形由三个顶点组成,所以这是有意义的。对于其他基本类型,规则略有不同。

一旦我们发出 glVertexPointer()命令,OpenGL ES 就会将顶点位置传输到 GPU,并存储在那里,以供所有后续的渲染命令使用。每次我们告诉 OpenGL ES 渲染顶点时,它都会从我们上次通过 glVertexPointer()指定的数据中获取它们的位置。

我们的每个顶点可能有更多的属性,而不仅仅是一个位置。另一个属性可能是顶点的颜色。我们通常将那些属性称为顶点属性

你可能想知道 OpenGL ES 如何知道我们的三角形应该有什么颜色,因为我们只有指定的位置。事实证明,对于我们没有指定的任何顶点属性,OpenGL ES 都有合理的默认值。这些默认值中的大多数都可以直接设置。例如,如果我们想为我们绘制的所有顶点设置默认颜色,我们可以使用以下方法:

GL10.glColor4f(float r, float g, float b, float a)

这个方法将为所有没有指定颜色的顶点设置默认颜色。颜色以 0.0 到 1.0 范围内的 RGBA 值给出,与之前的透明颜色一样。OpenGL ES 的默认颜色以 is (1,1,1,1)开始,即完全不透明的白色。

这就是我们使用自定义*行投影渲染三角形所需的全部代码——仅 16 行代码,用于清理屏幕、设置视口和投影矩阵、创建 NIO 缓冲区(在其中存储顶点位置)以及绘制三角形!现在比较一下,我们花了六页来向你们解释。当然,我们可以省略细节,使用更粗俗的语言。问题是 OpenGL ES 有时非常复杂,为了避免出现空屏幕,最好了解它的全部内容,而不只是复制和粘贴代码。

把它放在一起

为了完成这一部分,让我们通过一个漂亮的 GLGame 和屏幕实现将所有这些放在一起。清单 7-5 显示了完整的例子。

清单 7-5 。FirstTriangleTest.java

package com.badlogic.androidgames.glbasics;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.impl.GLGame;
import com.badlogic.androidgames.framework.impl.GLGraphics;

public class FirstTriangleTest extends GLGame {
    public Screen getStartScreen() {
        return new FirstTriangleScreen(this );
    }

FirstTriangleTest 类派生自 GLGame,因此必须实现 Game.getStartScreen()方法。在该方法中,我们创建了一个新的 FirstTriangleScreen,GLGame 将频繁调用它来更新和显示它自己。请注意,当调用此方法时,我们已经在主循环中——或者更确切地说,在 GLSurfaceView 呈现线程中——因此我们可以在 FirstTriangleScreen 类的构造函数中使用 OpenGL ES 方法。让我们仔细看看这个屏幕实现。

    class FirstTriangleScreen extends Screen {
        GLGraphics glGraphics;
        FloatBuffer vertices;
        public FirstTriangleScreen(Game game) {
            super (game);
            glGraphics = ((GLGame)game).getGLGraphics();

            ByteBuffer byteBuffer = ByteBuffer.*allocateDirect*(3 * 2 * 4);
            byteBuffer.order(ByteOrder.*nativeOrder*());
            vertices = byteBuffer.asFloatBuffer();
            vertices.put(new float [] {    0.0f,   0.0f,
                                         319.0f,   0.0f,
                                         160.0f, 479.0f});
            vertices.flip();
        }

FirstTriangleScreen 类包含两个成员:一个 GLGraphics 实例和我们可靠的 FloatBuffer,它存储了三角形三个顶点的 2D 位置。在构造函数中,我们从 GLGame 中获取 GLGraphics 实例,并根据前面的代码片段创建和填充 FloatBuffer。由于屏幕构造函数获得了一个游戏实例,我们必须将它转换为一个 GLGame 实例,这样我们就可以使用 GLGame.getGLGraphics()方法。

        @Override
        public void present(float deltaTime) {
            GL10 gl = glGraphics.getGL();
            gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
            gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
            gl.glMatrixMode(GL10.*GL_PROJECTION*);
            gl.glLoadIdentity();
            gl.glOrthof(0, 320, 0, 480, 1, -1);

            gl.glColor4f(1, 0, 0, 1);
            gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
            gl.glVertexPointer( 2, GL10.*GL_FLOAT*, 0, vertices);
            gl.glDrawArrays(GL10.*GL_TRIANGLES*, 0, 3);
        }

present()方法反映了我们刚刚讨论的内容:我们设置视口,清除屏幕,设置投影矩阵,以便我们可以在自定义坐标系中工作,设置默认的顶点颜色(在本例中为红色),指定我们的顶点将有位置,告诉 OpenGL ES 它可以在哪里找到这些顶点位置,最后,呈现我们令人惊叹的红色小三角形。

        @Override
        public void update(float deltaTime) {
            game.getInput().getTouchEvents();
            game.getInput().getKeyEvents();
        }

        @Override
        public void pause() {
        }

        @Override
        public void resume() {
        }

        @Override
        public void dispose() {
        }
    }
}

其余的类只是样板代码。在 update()方法中,我们确保事件缓冲区不会被填满。代码的其余部分什么也不做。

注意从现在开始,我们将只关注屏幕类本身,因为封闭的 GLGame 派生类,如 FirstTriangleTest,将总是相同的。我们还将通过删除 Screen 类的任何空方法或样板方法来减少一点代码量。下面的例子只是在成员、构造函数和当前方法方面有所不同。

图 7-7 显示了清单 7-5 的输出。

9781430246770_Fig07-07.jpg

图 7-7。我们第一个吸引人的三角形

根据 OpenGL ES 最佳实践,以下是我们在本例中做错的地方:

  • 我们一遍又一遍地将相同的状态设置为相同的值,没有任何必要。OpenGL ES 中的状态改变是昂贵的——有些多一点,有些少一点。我们应该总是试图减少我们在单个帧中进行的状态改变的数量。
  • 一旦我们设置了视窗和投影矩阵,它们将永远不会改变。我们可以将该代码移动到 resume()方法中,该方法在每次 OpenGL ES 表面被(重新)创建时只被调用一次;这也处理 OpenGL ES 上下文丢失。
  • 我们还可以将设置用于清除的颜色和设置默认顶点颜色转移到 resume()方法中。这两种颜色也不会变。
  • 我们可以将 glEnableClientState()和 glVertexPointer()方法移到 resume()方法中。
  • 我们只需要调用每个框架的 glClear()和 glDrawArrays()。两者都使用当前的 OpenGL ES 状态,只要我们不改变它们,只要我们不因为活动被暂停和恢复而丢失上下文,它们就会保持不变。

如果我们将这些优化付诸实践,我们的主循环中将只有两个 OpenGL ES 调用。为了清楚起见,我们现在将避免使用这种最小状态改变优化。然而,当我们开始编写我们的第一个 OpenGL ES 游戏时,我们将不得不尽我们所能遵循那些实践来保证良好的性能。

让我们给三角形的顶点添加更多的属性,从颜色开始。

注意非常非常警觉的读者可能已经注意到图 7-7 中的三角形实际上在右下角缺少了一个像素。这可能看起来像一个典型的一个错误,但它实际上是由于 OpenGL ES 光栅化(绘制像素)三角形的方式。有一个特定的三角形栅格化规则是造成这种假象的原因。不用担心——我们最关心的是渲染 2D 矩形(由两个三角形组成),这种效果会消失。

指定逐顶点颜色

在前面的例子中,我们通过 glColor4f()为所有绘制的顶点设置了一个全局默认颜色。有时我们希望有更细粒度的控制(例如,我们希望设置每个顶点的颜色)。OpenGL ES 为我们提供了这一功能,而且它真的很容易使用。我们所要做的就是为每个顶点添加 RGBA 浮动组件,并告诉 OpenGL ES 在哪里可以找到每个顶点的颜色,就像我们告诉它在哪里可以找到每个顶点的位置一样。让我们从给每个顶点添加颜色开始。

int VERTEX_SIZE = (2 + 4) * 4;
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(3 * VERTEX_SIZE);
byteBuffer.order(ByteOrder.nativeOrder());
FloatBuffer vertices = byteBuffer.asFloatBuffer();
vertices.put(new float[] {   0.0f,   0.0f, 1, 0, 0, 1,
                            319.0f,   0.0f, 0, 1, 0, 1,
                            160.0f, 479.0f, 0, 0, 1, 1});
vertices.flip();

我们首先要为我们的三个顶点分配一个 ByteBuffer。ByteBuffer 应该有多大?我们每个顶点有两个坐标和四个(RGBA)颜色分量,所以总共有六个浮点数。每个浮点值占用 4 个字节,因此单个顶点使用 24 个字节。我们将这些信息存储在 VERTEX_SIZE 中。当我们调用 ByteBuffer.allocateDirect()时,我们只是将 VERTEX_SIZE 乘以我们希望存储在 ByteBuffer 中的顶点数。剩下的就不言自明了。我们将一个 FloatBuffer 视图放到 ByteBuffer 中,并将顶点放入 ByteBuffer 中。浮动数组的每一行按顺序保存 x 和 y 坐标以及顶点的 R、G、B 和 A 分量。

如果我们要渲染这个,就要告诉 OpenGL ES,我们的顶点不仅有位置,还有颜色属性。和以前一样,我们首先调用 glEnableClientState():

gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);

既然 OpenGL ES 知道它可以预期每个顶点的位置和颜色信息,我们必须告诉它在哪里可以找到这些信息:

vertices.position(0);
gl.glVertexPointer(2, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
vertices.position(2);
gl.glColorPointer(4, GL10.GL_FLOAT, VERTEX_SIZE, vertices);

我们从设置 FloatBuffer 的位置开始,它将我们的顶点设置为 0。因此,位置指向缓冲区中第一个顶点的 x 坐标。接下来,我们调用 glVertexPointer()。与前一个例子唯一的不同是,我们现在还指定了顶点的大小(记住,它是以字节为单位的)。然后 OpenGL ES 将从缓冲区中我们告诉它开始的位置开始读取顶点位置。对于第二个顶点位置,它会将 VERTEX_SIZE 字节添加到第一个位置的地址,依此类推。

接下来,我们将缓冲区的位置设置为第一个顶点的 R 分量,并调用 glColorPointer(),它告诉 OpenGL ES 在哪里可以找到我们顶点的颜色。第一个参数是每种颜色的组件数量。这总是四个,因为 OpenGL ES 要求我们提供 R、G、B 和每个顶点一个组件。第二个参数指定每个组件的类型。与顶点坐标一样,我们使用 GL10。GL_FLOAT 再次指示每个颜色分量是介于 0 和 1 之间的浮点数。第三个参数是顶点颜色之间的跨度。当然和顶点位置之间的步幅是一样的。最后一个参数是我们的顶点缓冲。

由于我们在 glColorPointer()调用之前调用了 vertices . position(2 ), OpenGL ES 知道可以从缓冲区中的第三个浮点开始找到第一个顶点颜色。如果我们没有将缓冲区的位置设置为 2,OpenGL ES 就会从位置 0 开始读取颜色。那会很糟糕,因为那是我们第一个顶点的 x 坐标。图 7-8 显示了 OpenGL ES 将从哪里读取我们的顶点属性,以及它如何为每个属性从一个顶点跳到下一个。

9781430246770_Fig07-08.jpg

图 7-8。我们的 FloatBuffer 保存顶点,从其中读取位置/颜色的 OpenGL ES 的起始地址,以及用于跳到下一个位置/颜色的 stride

为了绘制三角形,我们再次调用 glDrawElements(),它告诉 OpenGL ES 使用 FloatBuffer 的前三个顶点来绘制三角形:

gl.glDrawElements(GL10.GL_TRIANGLES, 0, 3);

因为我们启用了 GL10。GL_VERTEX_ARRAY 和 GL10。GL_COLOR_ARRAY,OpenGL ES 知道应该使用 glVertexPointer()和 glColorPointer()指定的属性。它将忽略默认颜色,因为我们提供了自己的逐顶点颜色。

注意我们刚才指定顶点的位置和颜色的方式叫做交错。这意味着我们将一个顶点的属性打包在一个连续的内存块中。还有另一种方法可以实现这个目标:非交错顶点数组。我们可以使用两个浮动缓冲器,一个用于位置,一个用于颜色。然而,由于内存局部性,交错执行得更好,所以我们在这里不讨论非交错顶点数组。

将所有这些整合到一个新的 GLGame 和屏幕实现中应该是轻而易举的事情。清单 7-6 显示了 ColoredTriangleTest.java 文件的摘录。我们省略了样板代码。

清单 7-6 。摘自 ColoredTriangleTest.java;交错位置和颜色属性

class ColoredTriangleScreen extends Screen {
    final int VERTEX_SIZE = (2 + 4) * 4;
    GLGraphics glGraphics;
    FloatBuffer vertices;

    public ColoredTriangleScreen(Game game) {
        super (game);
        glGraphics = ((GLGame) game).getGLGraphics();
        ByteBuffer byteBuffer = ByteBuffer.*allocateDirect*(3 * VERTEX_SIZE);
        byteBuffer.order(ByteOrder.*nativeOrder*());
        vertices = byteBuffer.asFloatBuffer();
        vertices.put(new float [] {   0.0f,   0.0f, 1, 0, 0, 1,
                                    319.0f,   0.0f, 0, 1, 0, 1,
                                    160.0f, 479.0f, 0, 0, 1, 1});
        vertices.flip();
    }

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        gl.glOrthof(0, 320, 0, 480, 1, -1);

        gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
        gl.glEnableClientState(GL10.*GL_COLOR_ARRAY*);

        vertices.position(0);
        gl.glVertexPointer(2, GL10.*GL_FLOAT*, VERTEX_SIZE, vertices);
        vertices.position(2);
        gl.glColorPointer(4, GL10.*GL_FLOAT*, VERTEX_SIZE, vertices);

        gl.glDrawArrays(GL10.*GL_TRIANGLES*, 0, 3);
    }

酷——这看起来仍然很简单。与前面的例子相比,我们简单地在我们的 FloatBuffer 中的每个顶点添加了四个颜色分量,并启用了 GL10。GL_COLOR_ARRAY 最好的一点是,我们在下面的例子中添加的任何额外的顶点属性都将以同样的方式工作。我们只是告诉 OpenGL ES 不要为那个特定的属性使用默认值;相反,我们告诉它在我们的 FloatBuffer 中查找属性,从特定的位置开始,按 VERTEX_SIZE 字节从一个顶点移动到另一个顶点。

现在,我们也可以关闭 GL10。GL_COLOR_ARRAY,以便 OpenGL ES 再次使用默认的顶点颜色,我们可以像前面一样通过 glColor4f()来指定。为此我们可以称之为

gl.glDisableClientState(GL10.GL_COLOR_ARRAY);

OpenGL ES 将关闭从我们的浮动缓冲区读取颜色的功能。如果我们已经通过 glColorPointer()设置了一个颜色指针,OpenGL ES 会记住这个指针,即使我们只是告诉 OpenGL ES 不要使用它。

为了结束这个例子,让我们看一下前面程序的输出。图 7-9 为截图。

9781430246770_Fig07-09.jpg

图 7-9。逐顶点彩色三角形

哇,这太棒了!对于 OpenGL ES 将如何使用我们指定的三种颜色(红色用于左下顶点,绿色用于右下顶点,蓝色用于上顶点),我们没有做任何假设。原来它会为我们插值顶点之间的颜色。有了这个,我们可以很容易地创建漂亮的渐变。;然而,颜色本身不会让我们快乐很久。我们想用 OpenGL ES 画图像。这就是纹理贴图 发挥作用的地方。

纹理映射:壁纸变得简单

当我们编写 Nom 先生时,我们加载了一些位图并直接将它们绘制到帧缓冲区——不涉及旋转,只需要一点点缩放,这很容易实现。在 OpenGL ES 中,我们最关心的是三角形,它可以有我们想要的任何方向和比例。那么,如何用 OpenGL ES 渲染位图呢?

很简单,只需将位图加载到 OpenGL ES(就此而言,加载到 GPU,它有自己的专用 RAM),为我们三角形的每个顶点添加一个新属性,并告诉 OpenGL ES 渲染我们的三角形,并将位图(在 OpenGL ES 中也称为纹理)应用到三角形。让我们首先看看这些新的顶点属性实际上指定了什么。

纹理坐标

要将位图映射到三角形,我们需要将纹理坐标添加到三角形的每个顶点。什么是纹理坐标?它指定纹理(我们上传的位图)中的一个点映射到三角形的一个顶点。纹理坐标通常是 2D。

虽然我们称我们的位置坐标为 x、y 和 z,但是纹理坐标通常被称为 u 和 v 或者 s 和 t,这取决于你所在的图形程序员圈子。OpenGL ES 称它们为 s 和 t,所以这就是我们要坚持的。如果你在网上阅读使用 u/v 命名法的资源,不要混淆:它和 s,t 是一样的,坐标系是什么样子的?图 7-10 显示了我们将 Bob 上传到 OpenGL ES 后,他在纹理坐标系中的样子。

9781430246770_Fig07-10.jpg

图 7-10。 Bob,上传到 OpenGL ES,显示在纹理坐标系中

这里发生了一些有趣的事情。首先,s 等于标准坐标系中的 x 坐标,t 等于 y 坐标。s 轴指向右侧,t 轴指向下方。坐标系的原点与 Bob 图像的左上角重合。图像的右下角映射到(1,1)。

那么,像素坐标怎么了?原来 OpenGL ES 不是很喜欢他们。相反,我们上传的任何图像,无论其宽度和高度是多少像素,都将嵌入到这个坐标系中。图像的左上角将始终位于(0,0),右下角将始终位于(1,1)—即使宽度是高度的两倍。我们称这些为归一化坐标,它们实际上有时会让我们的生活变得更容易。现在,我们如何将 Bob 映射到我们的三角形?简单,我们只要给三角形的每个顶点一个 Bob 坐标系中的纹理坐标对。图 7-11 显示了一些配置。

9781430246770_Fig07-11.jpg

图 7-11。三个不同的三角形映射到 Bob 名称 v1、v2 和 v3 各自指定三角形的一个顶点

我们可以把我们的三角形的顶点映射到纹理坐标系。请注意,三角形在位置坐标系中的方向不必与它在纹理坐标系中的方向相同。坐标系完全解耦。因此,让我们看看如何将这些纹理坐标添加到我们的顶点:

int VERTEX_SIZE = (2 + 2) * 4;
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(3 * VERTEX_SIZE);
byteBuffer.order(ByteOrder.nativeOrder());
vertices = byteBuffer.asFloatBuffer();
vertices.put(new float [] {    0.0f,   0.0f, 0.0f, 1.0f,
                             319.0f,   0.0f, 1.0f, 1.0f,
                             160.0f, 479.0f, 0.5f, 0.0f});
vertices.flip();

那很容易。我们所要做的就是确保我们的缓冲区中有足够的空间,然后将纹理坐标附加到每个顶点。前面的代码对应于图 7-10 中最右边的映射。请注意,我们的顶点位置仍然在我们通过投影定义的常用坐标系中给出。如果我们愿意,我们也可以给每个顶点添加颜色属性,就像前面的例子一样。然后,OpenGL ES 会动态地将插值的顶点颜色与三角形映射到的纹理像素的颜色混合。当然,我们需要相应地调整缓冲区的大小以及 VERTEX_SIZE 常量;比如(2 + 4 + 2) × 4。为了告诉 OpenGL ES 我们的顶点有纹理坐标,我们再次使用 glEnableClientState()和 glTexCoordPointer()方法,该方法的行为与 glVertexPointer()和 glColorPointer()完全相同(这里可以看出一个模式吗?):

gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);

vertices.position(0);
gl.glVertexPointer(2, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
vertices.position(2);
gl.glTexCoordPointer(2, GL10.GL_FLOAT, VERTEX_SIZE, vertices);

不错——看起来很眼熟。那么,剩下的问题是,我们如何将纹理上传到 OpenGL ES 并告诉它将其映射到我们的三角形?自然,这就有点复杂了。但是不要害怕,这仍然很容易。

上传位图

首先,我们必须加载我们的位图。我们已经知道如何在 Android 上做到这一点:

Bitmap bitmap = BitmapFactory.decodeStream(game.getFileIO().readAsset("bobrgb888.png"));

这里我们以 RGB888 配置加载 Bob。接下来我们需要做的是告诉 OpenGL ES 我们想要创建一个新的纹理。OpenGL ES 对一些东西有对象的概念,比如纹理。要创建一个纹理对象,我们可以调用下面的方法:

GL10.glGenTextures(int numTextures, int [] ids, int offset)

第一个参数指定我们想要创建多少个纹理对象。通常,我们只想创建一个。下一个参数是一个 int 数组,OpenGL ES 将把生成的纹理对象的 id 写入这个数组。最后一个参数只是告诉 OpenGL ES 应该从哪里开始在数组中写入 id。

你已经知道 OpenGL ES 是一个 C API。自然,它不能为一个新的纹理返回一个 Java 对象;相反,它给我们一个 ID,或者说是那个纹理的句柄。每次我们想让 OpenGL ES 用那个特定的纹理做一些事情,我们指定它的 ID。下面是一个更完整的代码片段,展示了如何生成一个新的纹理对象并获取其 ID:

int textureIds[] = new int [1];
gl.glGenTextures(1, textureIds, 0);
int textureId = textureIds[0];

纹理对象仍然是空的,这意味着它还没有任何图像数据。让我们上传我们的位图。为此,我们首先要绑定纹理。绑定 OpenGL ES 中的某个东西意味着我们希望 OpenGL ES 在所有后续调用中使用该特定对象,直到我们再次改变绑定。在这里,我们想要绑定一个纹理对象,方法 glBindTexture()可用于该对象。一旦我们绑定了一个纹理,我们就可以操作它的属性,比如图像数据。下面是我们将 Bob 上传到新的纹理对象的方法:

gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
GLUtils.*texImage2D*(GL10.*GL_TEXTURE_2D*, 0, bitmap, 0);

首先,我们用 glBindTexture()绑定纹理对象。第一个参数指定了我们想要绑定的纹理类型。我们的鲍勃的形象是 2D,所以我们使用 GL10。2D 纹理。还有其他的纹理类型,但是在本书中我们不需要它们。我们将始终指定 GL10。GL_TEXTURE_2D 用于需要知道我们想要处理的纹理类型的方法。该方法的第二个参数是我们的纹理 ID。一旦方法返回,所有使用 2D 纹理的后续方法都将使用我们的纹理对象。

下一个方法调用调用由 Android 框架提供的 LUtils 类的方法。通常,上传纹理图像的任务是相当复杂的;这个小助手类大大减轻了我们的痛苦。我们需要做的就是指定纹理类型(GL10。GL_TEXTURE_2D),mipmapping 级别(我们会在第十一章里看那个;默认为 0)、我们要上传的位图,以及另一个参数,在所有情况下都必须设置为 0。在这个调用之后,我们的纹理对象已经附加了图像数据。

注意纹理对象及其图像数据实际上保存在视频 RAM 中,而不是我们通常的 RAM 中。当 OpenGL ES 上下文被破坏时(例如,当我们的活动被暂停和恢复时),纹理对象(和图像数据)将会丢失。这意味着我们必须重新创建纹理对象,并在每次重新创建 OpenGL ES 上下文时重新加载我们的图像数据。如果我们不这样做,我们只会看到一个白色的三角形。

纹理过滤

在使用纹理对象之前,我们还需要定义最后一件事。事实上,我们的三角形在屏幕上占用的像素可能比纹理映射区域中的像素多或少。例如 图 7-10 中鲍勃的图像尺寸为 128×128 像素。我们的三角形映射到图像的一半,所以它使用纹理的(128×128) / 2 像素(也称为纹理像素)。当我们使用前面代码片段中定义的坐标将三角形绘制到屏幕上时,它将占用(320×480) / 2 个像素。我们在屏幕上使用的像素比我们从纹理贴图中获取的像素要多得多。当然,也可以反过来:我们在屏幕上使用的像素少于纹理映射区域的像素。第一种情况称为放大、,第二种情况称为缩小。对于每种情况,我们需要告诉 OpenGL ES 它应该如何放大或缩小纹理。在 OpenGL ES 行话中,放大和缩小也被称为缩小和放大过滤器。这些过滤器是我们的纹理对象的属性,很像图像数据本身。要设置它们,我们首先必须确保通过调用 glBindTexture()来绑定纹理对象。如果是这种情况,我们可以这样设置它们:

gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST);
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_NEAREST);

两次我们都使用方法 GL10.glTexParameterf(),它设置纹理的属性。在第一次调用中,我们指定缩小过滤器;在第二个例子中,我们称之为放大过滤器。该方法的第一个参数是纹理类型,默认为 GL10。2D 纹理。第二个参数告诉方法我们想要设置哪些属性——在我们的例子中是 GL10。GL_TEXTURE_MIN_FILTER 和 GL10。GL_TEXTURE_MAG_FILTER 最后一个参数指定应该使用的过滤器类型。我们这里有两个选择:GL10。GL_NEAREST 和 GL10。GL_LINEAR。

第一种过滤器类型将总是选择纹理图中最*的纹理元素来映射到一个像素。第二种过滤器类型将对三角形的一个像素的四个最*的纹理元素进行采样,并对它们进行*均以得到最终的颜色。如果我们想要一个像素化的外观,我们使用第一种类型的过滤器,如果我们想要一个*滑的外观,我们使用第二种。图 7-12 显示了这两种过滤器的区别。

9781430246770_Fig07-12.jpg

图 7-12。 GL10。GL_NEAREST vs. GL10。GL_LINEAR。第一种过滤器类型产生像素化的外观;第二个稍微缓和了一些

我们的纹理对象现在已经完全定义好了:我们创建了一个 ID,设置了图像数据,并指定了在我们的渲染不是像素完美的情况下要使用的过滤器。一旦我们定义好纹理,通常的做法是解除绑定。我们还应该回收我们加载的位图,因为我们不再需要它。(为什么要浪费内存?)这可以通过下面的代码片段来实现:

gl.glBindTexture(GL10.GL_TEXTURE_2D, 0);
bitmap.recycle();

这里,0 是一个特殊的 ID,它告诉 OpenGL ES 应该解除当前绑定的对象。当然,如果我们想使用纹理来绘制三角形,我们需要再次绑定它。

处理纹理

如果我们不再需要纹理对象,知道如何从视频 RAM 中删除它也很有用(就像我们使用 Bitmap.recycle()来释放位图的内存)。这可以通过下面的代码片段来实现:

gl.glBindTexture(GL10.GL_TEXTURE_2D, 0);
int textureIds = { textureid };
gl.glDeleteTextures(1, textureIds, 0);

请注意,在删除纹理对象之前,我们首先必须确保它当前没有被绑定。其余部分类似于我们如何使用 glGenTextures()创建纹理对象。

一个有用的片段

作为参考,下面是在 Android 上创建纹理对象、加载图像数据和设置过滤器的完整代码片段:

Bitmap bitmap = BitmapFactory.*decodeStream*(game.getFileIO().readAsset("bobrgb888.png"));
int textureIds[] = new int [1];
gl.glGenTextures(1, textureIds, 0);
int textureId = textureIds[0];
gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
GLUtils.*texImage2D*(GL10.*GL_TEXTURE_2D*, 0, bitmap, 0);
gl.glTexParameterf(GL10.*GL_TEXTURE_2D*, GL10.*GL_TEXTURE_MIN_FILTER*, GL10.*GL_NEAREST*);
gl.glTexParameterf(GL10.*GL_TEXTURE_2D*, GL10.*GL_TEXTURE_MAG_FILTER*, GL10.*GL_NEAREST*);
gl.glBindTexture(GL10.*GL_TEXTURE_2D*, 0);
bitmap.recycle();

毕竟没那么糟。所有这些最重要的部分是在我们完成后回收位图;否则,我们会浪费内存。我们的图像数据安全地存储在纹理对象的视频 RAM 中(直到上下文丢失,我们需要重新加载它)。

启用纹理

在我们用纹理绘制三角形之前,还有一件事要完成。我们需要绑定纹理,我们需要告诉 OpenGL ES,它实际上应该将纹理应用于我们渲染的所有三角形。是否进行纹理映射是 OpenGL ES 的另一种状态,我们可以通过以下方法来启用和禁用:

GL10.glEnable(GL10.GL_TEXTURE_2D);
GL10.glDisable(GL10.GL_TEXTURE_2D);

这些看起来有点眼熟。在前面几节中启用/禁用顶点属性时,我们使用了 glEnableClientState()/glDisableClientState()。正如我们前面提到的,这些都是 OpenGL 早期的遗物。这些没有与 glEnable()/glDisable()合并是有原因的,但我们在这里不会深入讨论。只需要记住使用 glEnableClientState()/glDisableClientState()来启用和禁用顶点属性,使用 glEnable()/glDisable()用于 OpenGL 的任何其他状态,比如纹理。

把它放在一起

这样一来,我们现在可以写一个小例子把所有这些放在一起。清单 7-7 显示了 theTexturedTriangleTest.java 源文件的摘录,只列出了其中包含的 TexturedTriangleScreen 类的相关部分。

清单 7-7 。摘自 TexturedTriangleTest.java;纹理一个三角形

class TexturedTriangleScreen extends Screen {
    final int VERTEX_SIZE = (2 + 2) * 4;
    GLGraphics glGraphics;
    FloatBuffer vertices;
    int textureId;

    public TexturedTriangleScreen(Game game) {
        super (game);
        glGraphics = ((GLGame) game).getGLGraphics();

        ByteBuffer byteBuffer = ByteBuffer.*allocateDirect*(3 * VERTEX_SIZE);
        byteBuffer.order(ByteOrder.*nativeOrder*());
        vertices = byteBuffer.asFloatBuffer();
        vertices.put(new float [] {    0.0f,   0.0f, 0.0f, 1.0f,
                                     319.0f,   0.0f, 1.0f, 1.0f,
                                     160.0f, 479.0f, 0.5f, 0.0f});
        vertices.flip();
        textureId = loadTexture("bobrgb888.png");
    }

    public int loadTexture(String fileName) {
        try {
            Bitmap bitmap = BitmapFactory.*decodeStream*(game.getFileIO().readAsset(fileName));
            GL10 gl = glGraphics.getGL();
            int textureIds[] = new int [1];
            gl.glGenTextures(1, textureIds, 0);
            int textureId = textureIds[0];
            gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
            GLUtils.*texImage2D*(GL10.*GL_TEXTURE_2D*, 0, bitmap, 0);
            gl.glTexParameterf(GL10.*GL_TEXTURE_2D*, GL10.*GL_TEXTURE_MIN_FILTER*, GL10.*GL_NEAREST*);
            gl.glTexParameterf(GL10.*GL_TEXTURE_2D*, GL10.*GL_TEXTURE_MAG_FILTER*, GL10.*GL_NEAREST*);
            gl.glBindTexture(GL10.*GL_TEXTURE_2D*, 0);
            bitmap.recycle();
            return textureId;
        } catch (IOException e) {
            Log.*d*("TexturedTriangleTest", "couldn't load asset 'bobrgb888.png'!");
            throw new RuntimeException("couldn't load asset '" + fileName + "'");
        }
    }

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        gl.glOrthof(0, 320, 0, 480, 1, -1);

        gl.glEnable(GL10.*GL_TEXTURE_2D*);
        gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);

        gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
        gl.glEnableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);

        vertices.position(0);
        gl.glVertexPointer(2, GL10.*GL_FLOAT*, VERTEX_SIZE, vertices);
        vertices.position(2);
        gl.glTexCoordPointer(2, GL10.*GL_FLOAT*, VERTEX_SIZE, vertices);

        gl.glDrawArrays(GL10.*GL_TRIANGLES*, 0, 3);
    }

我们可以自由地将纹理加载到一个名为 loadTexture()的方法中,该方法只接受要加载的位图的文件名。该方法返回由 OpenGL ES 生成的纹理对象 ID,我们将在 present()方法中使用它来绑定纹理。

我们的三角形的定义不应该是一个大惊喜;我们只是给每个顶点添加了纹理坐标。

present()方法做它一直做的事情:它清除屏幕并设置投影矩阵。接下来,我们通过调用 glEnable()来启用纹理映射,并绑定我们的纹理对象。剩下的只是我们之前做的:启用我们想要使用的顶点属性;告诉 OpenGL ES 在哪里可以找到它们,用什么步长;最后,通过调用 glDrawArrays()来绘制三角形。图 7-13 显示了前面代码的输出。

9781430246770_Fig07-13.jpg

图 7-13。纹理映射鲍勃到我们的三角形

还有最后一件事我们还没有提到,它非常重要:我们加载的所有位图的宽度和高度必须是 2 的幂。坚持下去,否则东西会爆炸。

那么这实际上意味着什么呢?我们在示例中使用的 Bob 的图像大小为 128×128 像素。值 128 是 2 的 7 次方(2×2×2×2×2×2×2)。其他有效的图像大小包括 2×8、32×16、128×256 等。我们的图像可以有多大也是有限制的。遗憾的是,这取决于我们的应用运行的硬件。OpenGL ES 1.x 标准没有指定最低支持的纹理大小;然而,从经验来看,512×512 像素的纹理似乎可以在所有当前的 Android 设备上工作(并且很可能也可以在所有未来的设备上工作)。我们甚至可以说 1024×1024 也可以。

到目前为止,我们忽略的另一个问题是纹理的颜色深度。幸运的是,我们用来将图像数据上传到 GPU 的方法 LUtils.texImage2D()很好地解决了这个问题。OpenGL ES 可以处理 RGBA8888、RGB565 等颜色深度。我们应该始终努力使用尽可能低的色深来降低带宽。为此,我们可以使用位图工厂。选项类在内存中将 RGB888 位图加载到 RGB565 位图。一旦我们用我们想要的颜色深度加载了我们的位图实例,LUtils.texImage2D()就会接管并确保 OpenGL ES 以正确的格式获取图像数据。当然,你应该经常检查颜色深度的减少是否对你的游戏的视觉保真度有负面影响。

纹理类

为了减少后续示例所需的代码,我们编写了一个名为 Texture 的小助手类。它将从资源中加载一个位图,并从中创建一个纹理对象。它也有一些方便的方法来绑定和处理纹理。清单 7-8 显示了代码。

清单 7-8 。Texture.java,一点 OpenGL ES 纹理类

package com.badlogic.androidgames.framework.gl;

import java.io.IOException;
import java.io.InputStream;

import javax.microedition.khronos.opengles.GL10;

import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.opengl.GLUtils;

import com.badlogic.androidgames.framework.FileIO;
import com.badlogic.androidgames.framework.impl.GLGame;
import com.badlogic.androidgames.framework.impl.GLGraphics;

public class Texture {
    GLGraphics glGraphics;
    FileIO fileIO;
    String fileName;
    int textureId;
    int minFilter;
    int magFilter;
    int width;
    int height;

    public Texture(GLGame glGame, String fileName) {
        this.glGraphics = glGame.getGLGraphics();
        this.fileIO = glGame.getFileIO();
        this.fileName = fileName;
        load();
    }

    private void load() {
        GL10 gl = glGraphics.getGL();
        int [] textureIds = new int [1];
        gl.glGenTextures(1, textureIds, 0);
        textureId = textureIds[0];

        InputStream in = null ;
        try {
            in = fileIO.readAsset(fileName);
            Bitmap bitmap = BitmapFactory.*decodeStream*(in);
            width = bitmap.getWidth();
            height = bitmap.getHeight();
            gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
            GLUtils.*texImage2D*(GL10.*GL_TEXTURE_2D*, 0, bitmap, 0);
            setFilters(GL10.*GL_NEAREST*, GL10.*GL_NEAREST*);
            gl.glBindTexture(GL10.*GL_TEXTURE_2D*, 0);
        } catch (IOException e) {
            throw new RuntimeException("Couldn't load texture '" + fileName + "'", e);
        } finally {
            if (in != null )
                try { in.close(); } catch (IOException e) { }
        }
    }

    public void reload() {
        load();
        bind();
        setFilters(minFilter, magFilter);
        glGraphics.getGL().glBindTexture(GL10.*GL_TEXTURE_2D*, 0);
    }

    public void setFilters(int minFilter, int magFilter) {
        this.minFilter = minFilter;
        this.magFilter = magFilter;
        GL10 gl = glGraphics.getGL();
        gl.glTexParameterf(GL10.*GL_TEXTURE_2D*, GL10.*GL_TEXTURE_MIN_FILTER*, minFilter);
        gl.glTexParameterf(GL10.*GL_TEXTURE_2D*, GL10.*GL_TEXTURE_MAG_FILTER*, magFilter);
    }

    public void bind() {
        GL10 gl = glGraphics.getGL();
        gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
    }

    public void dispose() {
        GL10 gl = glGraphics.getGL();
        gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
        int [] textureIds = { textureId };
        gl.glDeleteTextures(1, textureIds, 0);
    }
}

这个类唯一有趣的地方是 reload()方法,当 OpenGL ES 上下文丢失时我们可以使用它。还要注意,setFilters()方法只有在纹理实际绑定的情况下才有效。否则,它将设置当前绑定纹理的过滤器。

我们也可以为顶点缓冲区写一个小的辅助方法。但在我们这样做之前,我们还得讨论一件事:索引顶点。

索引顶点:因为重复使用对你有好处

到目前为止,我们已经定义了三角形列表,其中每个三角形都有自己的顶点集。我们实际上只画了一个三角形,但是增加更多的三角形并不是什么大事。

然而,在某些情况下,两个或多个三角形可以共享一些顶点。让我们想想如何用现有的知识绘制一个矩形。我们简单地定义两个三角形,这两个三角形的两个顶点具有相同的位置、颜色和纹理坐标。我们可以做得更好。图 7-14 显示了渲染矩形的旧方法和新方法。

9781430246770_Fig07-14.jpg

图 7-14。将一个矩形渲染成两个有六个顶点的三角形(左),渲染成有四个顶点(右)

我们只定义这些顶点一次,而不是用顶点 v4 和 v6 复制顶点 v1 和 v2。在这种情况下,我们仍然渲染两个三角形,但我们明确告诉 OpenGL ES 每个三角形使用哪些顶点(即,第一个三角形使用 v1、v2 和 v3,第二个三角形使用 v3、v4 和 v1),每个三角形使用哪些顶点是通过顶点数组中的索引定义的。数组中的第一个顶点的索引为 0,第二个顶点的索引为 1,依此类推。对于前面的矩形,我们有一个索引列表,如下所示:

short[] indices = { 0, 1, 2,
                    2, 3, 0  };

顺便提一下,OpenGL ES 希望我们将索引指定为 shorts(这不完全正确;我们也可以使用字节)。然而,与顶点数据一样,我们不能只将一个短数组传递给 OpenGL ES。它需要一个直接的短缓冲区。我们已经知道如何处理:

ByteBuffer byteBuffer = ByteBuffer.allocate(indices.length * 2);
byteBuffer.order(ByteOrder.nativeOrder());
ShortBuffer shortBuffer = byteBuffer.asShortBuffer();
shortBuffer.put(indices);
shortBuffer.flip();

一个 short 需要 2 字节的内存,所以我们为我们的 ShortBuffer 分配 indexes . length×2 字节。我们再次将顺序设置为 native,并获得一个 ShortBuffer 视图,这样我们可以更容易地处理底层的 ByteBuffer。剩下的就是把我们的索引放入 ShortBuffer 并翻转它,这样限制和位置就设置正确了。

如果我们想把 Bob 画成一个有两个索引三角形的矩形,我们可以这样定义我们的顶点:

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4 * VERTEX_SIZE);
byteBuffer.order(ByteOrder.nativeOrder());
vertices = byteBuffer.asFloatBuffer();
vertices.put(new float [] {  100.0f, 100.0f, 0.0f, 1.0f,
                            228.0f, 100.0f, 1.0f, 1.0f,
                            228.0f, 229.0f, 1.0f, 0.0f,
                            100.0f, 228.0f, 0.0f, 0.0f });
vertices.flip();

顶点的顺序与图 7-14 右边部分的顺序完全相同。我们告诉 OpenGL ES 我们有顶点的位置和纹理坐标,并且它可以通过调用 glEnableClientState()和 glVertexPointer()/glTexCoordPointer()找到这些顶点属性。唯一的区别是我们调用的绘制两个三角形的方法:

gl.glDrawElements(GL10.*GL_TRIANGLES*, 6, GL10.*GL_UNSIGNED_SHORT*, indices);

这个方法其实和 glDrawArrays()很像。第一个参数指定了我们想要呈现的图元的类型——在本例中,是一个三角形列表。下一个参数指定我们想要使用多少个顶点,在我们的例子中是 6 个。第三个参数指定索引的类型——我们指定无符号短整型。注意,Java 没有无符号类型;然而,考虑到有符号数的单补码编码,使用实际保存有符号短整型数的 ShortBuffer 是可以的。最后一个参数是保存六个索引的 ShortBuffer。

那么,OpenGL ES 会做什么呢?它知道我们要渲染三角形,它知道我们要渲染两个三角形,因为我们指定了六个顶点;但是,OpenGL ES 不是从顶点数组中顺序提取六个顶点,而是顺序遍历索引缓冲区并使用它已经索引的顶点。

把它放在一起

当我们把它们放在一起时,我们得到了清单 7-9 中的代码。

清单 7-9 。摘自 IndexedTest.java;绘制两个索引三角形

class IndexedScreen extends Screen {
    final int VERTEX_SIZE = (2 + 2) * 4;
    GLGraphics glGraphics;
    FloatBuffer vertices;
    ShortBuffer indices;
    Texture texture;

    public IndexedScreen(Game game) {
        super (game);
        glGraphics = ((GLGame) game).getGLGraphics();

        ByteBuffer byteBuffer = ByteBuffer.*allocateDirect*(4 * VERTEX_SIZE);
        byteBuffer.order(ByteOrder.*nativeOrder*());
        vertices = byteBuffer.asFloatBuffer();
        vertices.put(new float [] {  100.0f, 100.0f, 0.0f, 1.0f,
                                    228.0f, 100.0f, 1.0f, 1.0f,
                                    228.0f, 228.0f, 1.0f, 0.0f,
                                    100.0f, 228.0f, 0.0f, 0.0f });
        vertices.flip();

        byteBuffer = ByteBuffer.*allocateDirect*(6 * 2);
        byteBuffer.order(ByteOrder.*nativeOrder*());
        indices = byteBuffer.asShortBuffer();
        indices.put(new short [] { 0, 1, 2,
                                  2, 3, 0 });
        indices.flip();
        texture = new Texture((GLGame)game, "bobrgb888.png");
    }

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        gl.glOrthof(0, 320, 0, 480, 1, -1);

        gl.glEnable(GL10.*GL_TEXTURE_2D*);
        texture.bind();

        gl.glEnableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);
        gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);

        vertices.position(0);
        gl.glVertexPointer(2, GL10.*GL_FLOAT*, VERTEX_SIZE, vertices);
        vertices.position(2);
        gl.glTexCoordPointer(2, GL10.*GL_FLOAT*, VERTEX_SIZE, vertices);

        gl.glDrawElements(GL10.*GL_TRIANGLES*, 6, GL10.*GL_UNSIGNED_SHORT*, indices);
    }

请注意我们的 awesome 纹理类的使用,它大大降低了代码的大小。图 7-15 显示了输出,以及鲍勃的所有荣耀。

9781430246770_Fig07-15.jpg

图 7-15。鲍勃,编入索引

现在,这非常接*我们如何使用画布。我们也有了更多的灵活性,因为我们不再局限于轴对齐的矩形。

这个例子已经涵盖了我们现在需要知道的关于顶点的所有内容。我们看到,每个顶点必须至少有一个位置,并可以有额外的属性,如颜色,给定为四个 RGBA 浮点值和纹理坐标。我们还看到,如果我们想避免重复,我们可以通过索引重用顶点。这给了我们一点点性能提升,因为 OpenGL ES 不需要用投影和模型视图矩阵乘以比绝对必要的更多的顶点(这也不完全正确,但让我们坚持这种解释)。

顶点类

让我们通过创建一个顶点类来使我们的代码更容易编写,该类可以保存最大数量的顶点,并且可以选择保存用于渲染的索引。它还应该负责启用渲染所需的所有状态,以及在渲染完成后清理状态,以便其他代码可以依赖于一组干净的 OpenGL ES 状态。清单 7-10 显示了我们易于使用的顶点类。

清单 7-10 。【Vertices.java】;封装(索引)顶点

package com.badlogic.androidgames.framework.gl;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import java.nio.ShortBuffer;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.impl.GLGraphics;

public class Vertices {
    final GLGraphics glGraphics;
    final boolean hasColor;
    final boolean hasTexCoords;
    final int vertexSize;
    final FloatBuffer vertices;
    final ShortBuffer indices;

Vertices 类有一个对 GLGraphics 实例的引用,所以我们可以在需要时获得 GL10 实例。我们还存储顶点是否有颜色和纹理坐标。这给了我们很大的灵活性,因为我们可以选择渲染所需的最小属性集。此外,我们存储一个保存顶点的 FloatBuffer 和一个保存可选索引的 ShortBuffer。

    public Vertices(GLGraphics glGraphics, int maxVertices, int maxIndices, boolean hasColor, boolean hasTexCoords) {
        this.glGraphics = glGraphics;
        this.hasColor = hasColor;
        this.hasTexCoords = hasTexCoords;
        this.vertexSize = (2 + (hasColor?4:0) + (hasTexCoords?2:0)) * 4;

        ByteBuffer buffer = ByteBuffer.*allocateDirect*(maxVertices * vertexSize);
        buffer.order(ByteOrder.*nativeOrder*());
        vertices = buffer.asFloatBuffer();

        if (maxIndices > 0) {
            buffer = ByteBuffer.*allocateDirect*(maxIndices * Short.*SIZE*/ 8);
            buffer.order(ByteOrder.*nativeOrder*());
            indices = buffer.asShortBuffer();
        } else {
            indices = null ;
        }
    }

在构造函数中,我们指定顶点实例最多可以容纳多少个顶点和索引,以及顶点是否有颜色或纹理坐标。在构造函数中,我们相应地设置成员并实例化缓冲区。请注意,如果 maxIndices 为 0,则 ShortBuffer 将被设置为 null。在这种情况下,我们的渲染将是非索引的。

    public void setVertices(float [] vertices, int offset, int length) {
        this.vertices.clear();
        this.vertices.put(vertices, offset, length);
        this.vertices.flip();
    }

    public void setIndices(short [] indices, int offset, int length) {
        this.indices.clear();
        this.indices.put(indices, offset, length);
        this.indices.flip();
    }

接下来是 setVertices()和 setIndices()方法。如果 Vertices 实例不存储索引,后者将抛出 NullPointerException。我们所做的就是清空缓冲区并复制数组的内容。

    public void draw(int primitiveType, int offset, int numVertices) {
        GL10 gl = glGraphics.getGL();

        gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
        vertices.position(0);
        gl.glVertexPointer(2, GL10.*GL_FLOAT*, vertexSize, vertices);

        if (hasColor) {
            gl.glEnableClientState(GL10.*GL_COLOR_ARRAY*);
            vertices.position(2);
            gl.glColorPointer(4, GL10.*GL_FLOAT*, vertexSize, vertices);
        }

        if (hasTexCoords) {
            gl.glEnableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);
            vertices.position(hasColor?6:2);
            gl.glTexCoordPointer(2, GL10.*GL_FLOAT*, vertexSize, vertices);
        }

        if (indices != null ) {
            indices.position(offset);
            gl.glDrawElements(primitiveType, numVertices, GL10.*GL_UNSIGNED_SHORT*, indices);
        } else {
            gl.glDrawArrays(primitiveType, offset, numVertices);
        }

        if (hasTexCoords)
            gl.glDisableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);

        if (hasColor)
            gl.glDisableClientState(GL10.*GL_COLOR_ARRAY*);
    }
}

Vertices 类的最后一个方法是 draw()。它采用原语的类型(例如,GL10。GL_TRIANGLES)、顶点缓冲区(或索引缓冲区,如果我们使用索引)中的偏移量以及用于渲染的顶点数。根据顶点是否有颜色和纹理坐标,我们启用相关的 OpenGL ES 状态,并告诉 OpenGL ES 在哪里可以找到数据。当然,我们对顶点位置也做了同样的处理,它们总是需要的。根据是否使用索引,我们调用 glDrawElements()或 glDrawArrays(),并将参数传递给方法。请注意,offset 参数也可以用于索引渲染:我们只需相应地设置索引缓冲区的位置,以便 OpenGL ES 从该偏移量开始读取索引,而不是从索引缓冲区的第一个索引开始。我们在 draw()方法中做的最后一件事是稍微清理一下 OpenGL ES 状态。我们用 GL10 调用 glDisableClientState()。GL_COLOR_ARRAY 或 GL10。GL_TEXTURE_COORD_ARRAY 如果我们的顶点有这些属性。我们需要这样做,因为顶点的另一个实例可能不使用这些属性。如果我们渲染其他顶点实例,OpenGL ES 仍然会寻找颜色和/或纹理坐标。

我们可以用下面的代码片段替换前面示例的构造函数中所有冗长的代码:

Vertices vertices = new Vertices(glGraphics, 4, 6, false , true );
*vertices.setVertices(*new float [] { 100.0f, 100.0f, 0.0f, 1.0f,
                                  228.0f, 100.0f, 1.0f, 1.0f,
                                  228.0f, 228.0f, 1.0f, 0.0f,
                                  100.0f, 228.0f, 0.0f, 0.0f }, 0, 16);
vertices.setIndices(new short[] { 0, 1, 2, 2, 3, 0 }, 0, 6);

同样,我们可以用对以下内容的单个调用来替换设置顶点属性数组和渲染的所有调用:

vertices.draw(GL10.GL_TRIANGLES, 0, 6);

加上我们的纹理类,我们现在有了一个非常好的基础来进行我们所有的 2D OpenGL ES 渲染。然而,为了完全再现我们所有的画布渲染能力,我们仍然缺少混合。让我们看看那个。

阿尔法混合:我能看穿你

OpenGL ES 中的 Alpha 混合很容易实现。我们只需要两个方法调用:

gl.glEnable(GL10.GL_BLEND);
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);

第一个方法调用应该是熟悉的:它只是告诉 OpenGL ES 应该将 alpha 混合应用于我们从这一点开始渲染的所有三角形。第二种方法稍微复杂一点。它指定源颜色和目标颜色应该如何组合。回想一下第三章中的内容,源颜色和目标颜色的组合方式是由一个简单的混合方程式决定的。glBlendFunc()方法只是告诉 OpenGL ES 使用哪种方程。前面的参数说明了我们希望源颜色与目标颜色完全按照第三章中的混合公式混合。这等于画布如何为我们混合位图。

在 OpenGL ES 中混合是非常强大和复杂的,还有更多。出于我们的目的,我们可以忽略所有这些细节,只要我们想将三角形与帧缓冲区混合,就使用前面的混合函数——就像我们将位图与画布混合一样。

第二个问题是源色和目的色从何而来。后者很容易解释:它是我们要用绘制的三角形覆盖的帧缓冲区中像素的颜色。源颜色实际上是两种颜色的组合。

  • 顶点颜色:这是我们通过 glColor4f()为所有顶点指定的颜色,或者通过为每个顶点添加一个颜色属性来为每个顶点指定颜色。
  • 纹理元素颜色:如前所述,纹理元素是纹理中的一个像素。当我们的三角形使用映射到它的纹理渲染时,OpenGL ES 会将三角形每个像素的纹理颜色与顶点颜色混合。

因此,如果我们的三角形没有纹理映射,混合的源颜色等于顶点颜色。如果三角形是纹理映射的,则三角形的每个像素的源颜色是顶点颜色和纹理元素颜色的混合。我们可以通过使用 glTexEnv()方法来指定如何组合顶点和纹理元素的颜色。默认情况下,通过纹理元素颜色来调制顶点颜色,这基本上意味着这两种颜色在分量上彼此相乘(顶点 r ×纹理元素 r,以此类推)。对于本书中的所有用例来说,这正是我们想要的,所以我们不会深入讨论 glTexEnv()。还有一些非常特殊的情况,你可能想改变顶点和纹理颜色的组合方式。与 glBlendFunc()一样,我们将忽略细节,只使用默认值。

当我们加载一个没有 alpha 通道的纹理图像时,OpenGL ES 会自动假定每个像素的 alpha 值为 1。如果我们加载 RGBA8888 格式的图像,OpenGL ES 会很乐意使用提供的 alpha 值进行混合。

对于顶点颜色,我们总是必须指定一个 alpha 分量,要么使用 glColor4f(),其中最后一个参数是 alpha 值,要么指定每个顶点的四个分量,其中最后一个分量也是 alpha 值。

让我们用一个简单的例子来实践这一点。我们希望绘制鲍勃两次:第一次使用图像 bobrgb888.png,它没有每像素一个 alpha 通道,第二次使用图像 bobargb8888.png,它有 alpha 信息。请注意,PNG 图像实际上是以 ARGB8888 格式存储像素,而不是 RGBA8888 格式。幸运的是,我们用来上传纹理图像数据的 LUtils.texImage2D()方法会自动为我们进行转换。清单 7-11 显示了我们使用纹理和顶点类的小实验的代码。

清单 7-11 。摘自 BlendingTest.java;混合动作

class BlendingScreen extends Screen {
    GLGraphics glGraphics;
    Vertices vertices;
    Texture textureRgb;
    Texture textureRgba;

    public BlendingScreen(Game game) {
        super (game);
        glGraphics = ((GLGame)game).getGLGraphics();

        textureRgb = new Texture((GLGame)game, "bobrgb888.png");
        textureRgba = new Texture((GLGame)game, "bobargb8888.png");

        vertices = new Vertices(glGraphics, 8, 12, true , true );
        float [] rects = new float [] {
                100, 100, 1, 1, 1, 0.5f, 0, 1,
                228, 100, 1, 1, 1, 0.5f, 1, 1,
                228, 228, 1, 1, 1, 0.5f, 1, 0,
                100, 228, 1, 1, 1, 0.5f, 0, 0,

                100, 300, 1, 1, 1, 1, 0, 1,
                228, 300, 1, 1, 1, 1, 1, 1,
                228, 428, 1, 1, 1, 1, 1, 0,
                100, 428, 1, 1, 1, 1, 0, 0
        };
        vertices.setVertices(rects, 0, rects.length);
        vertices.setIndices(new short [] {0, 1, 2, 2, 3, 0,
                                         4, 5, 6, 6, 7, 4 }, 0, 12);
    }

我们的小 BlendingScreen 实现包含一个顶点实例,我们将在其中存储两个矩形,以及两个纹理实例—一个包含 Bob 的 RGBA8888 图像,另一个存储 Bob 的 RGB888 版本。在构造函数中,我们从文件 bobrgb888.png 和 bobargb8888.png 加载纹理,并根据 OpenGL ES 的需要,依靠纹理类和 GLUtils.texImag2D()将 ARGB8888 PNG 转换为 RGBA8888。接下来,我们定义顶点和索引。第一个矩形由四个顶点组成,映射到 Bob 的 RGB888 纹理。第二个矩形映射到 RGBA8888 版本的 Bob,并在 RGB888 Bob 矩形之上渲染 200 个单位。请注意,第一个矩形的顶点都具有颜色(1,1,1,0.5f),而第二个矩形的顶点都具有颜色(1,1,1,1)。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
        gl.glClearColor(1,0,0,1);
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        gl.glOrthof(0, 320, 0, 480, 1, -1);

        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);

        gl.glEnable(GL10.*GL_TEXTURE_2D*);
        textureRgb.bind();
        vertices.draw(GL10.*GL_TRIANGLES*, 0, 6 );

        textureRgba.bind();
        vertices.draw(GL10.*GL_TRIANGLES*, 6, 6 );
    }

在我们的 present()方法中,我们用红色清除屏幕,并像我们习惯做的那样设置投影矩阵。接下来,我们启用阿尔法混合,并设置正确的混合方程。最后,我们启用纹理映射并渲染两个矩形。第一个矩形使用 RGB888 纹理边界进行渲染,第二个矩形使用 RGBA8888 纹理边界进行渲染。我们将两个矩形存储在同一个 Vertices 实例中,因此在 vertices.draw()方法中使用 offsets。图 7-16 显示了这个小宝石的输出。

9781430246770_Fig07-16.jpg

图 7-16。鲍勃,顶点颜色混合(底部)和纹理混合(顶部)

在 RGB888 Bob 的情况下,混合是通过每顶点颜色中的 alpha 值执行的。因为我们将它们设置为 0.5f,Bob 是 50%半透明的。

在 RGBA8888 Bob 的情况下,每顶点颜色的 alpha 值都为 1。但是,由于该纹理的背景像素的 alpha 值为 0,并且由于顶点和纹理元素的颜色已被调制,因此该版本 Bob 的背景会消失。如果我们将每顶点颜色的 alpha 值也设置为 0.5f,那么 Bob 自己也会像他在屏幕底部的克隆体一样半透明 50%。图 7-17 显示了它的样子。

9781430246770_Fig07-17.jpg

图 7-17。RGB a8888 Bob 的替代版本,使用 0.5f 的逐顶点 alpha(屏幕顶部)

这基本上是我们需要知道的关于在 2D 与 OpenGL ES 混合的全部内容。

但是,还有一点非常重要的我们要指出:勾兑贵!说真的,不要过度使用。目前的移动 GPU 并不擅长混合大量的像素。只有在绝对必要的情况下,你才应该使用混合。

更多图元:点、线、带和扇形

当我们告诉你 OpenGL ES 是一个巨大的、讨厌的三角形渲染机器时,我们并没有 100%诚实。其实 OpenGL ES 也可以渲染点和线。最重要的是,这些也是通过顶点定义的,因此上述所有内容也适用于它们(纹理,逐顶点颜色,等等)。我们需要做的就是使用 GL10 以外的东西来渲染这些图元。GL_TRIANGLES 当我们调用 glDrawArrays()/glDrawElements()时。我们还可以用这些图元执行索引渲染,尽管这有点多余(至少在点的情况下)。图 7-18 显示了 OpenGL ES 提供的所有图元类型的列表。

9781430246770_Fig07-18.jpg

图 7-18。OpenGL ES 可以渲染的所有图元

让我们快速浏览一下所有这些原语:

  • :用一个点,每个顶点都是自己的图元。
  • 线:一条线由两个顶点组成。和三角形一样,我们可以用 2 × n 个顶点来定义 n 条线。
  • 线条:所有顶点被解释为属于一条长线。
  • 线条循环:这类似于线条,不同的是 OpenGL ES 会自动从最后一个顶点到第一个顶点额外画一条线。
  • 三角形:这个我们已经知道了。每个三角形由三个顶点组成。
  • 三角形带:我们不指定三个顶点,只指定个三角形 + 1 个顶点。然后 OpenGL ES 将从顶点(v1,v2,v3)构建第一个三角形,从顶点(v2,v3,v4)构建下一个三角形,依此类推。
  • 三角形扇形:它有一个由所有三角形共享的基顶点(v1)。第一个三角形将是(v1,v2,v3),下一个三角形将是(v1,v3,v4),依此类推。

三角形条和扇形比纯三角形列表灵活一点。但是它们可以提供一点点性能提升,因为需要乘以投影和模型视图矩阵的顶点更少。不过,我们将在所有代码中坚持使用三角形列表,因为它们更容易使用,并且可以通过使用索引来实现类似的性能。

OpenGL ES 中的点和线有点奇怪。当我们使用像素完美的正交投影时——例如,我们的屏幕分辨率是 320×480 像素,我们的 glOrthof()调用使用这些精确值——我们仍然不能在所有情况下获得像素完美的渲染。由于所谓的菱形退出规则,点和线顶点的位置必须偏移 0.375f。如果你想渲染像素完美的点和线,请记住这一点。我们已经看到类似的东西也适用于三角形。然而,鉴于我们通常在 2D 画矩形,我们不会遇到这个问题。

鉴于你所要做的就是渲染 GL10 以外的图元。GL_TRIANGLES 是使用图 7-17 中的另一个常数,我们会给你一个示例程序。我们将在很大程度上坚持三角形列表,尤其是在做 2D 图形编程的时候。

现在让我们深入了解 OpenGL ES 提供给我们的另一个东西:全能的模型-视图矩阵!

2D 变换:模型-视图矩阵的乐趣

到目前为止,我们所做的只是以三角形列表的形式定义静态几何。没有任何东西在移动、旋转或缩放。此外,即使顶点数据本身保持不变(例如,由两个三角形以及纹理坐标和颜色组成的矩形的宽度和高度保持不变),如果我们想在不同的地方绘制相同的矩形,我们仍然必须复制顶点。回头看看清单 7-11 中的,现在忽略顶点的颜色属性。这两个矩形仅在 y 坐标上相差 200 个单位。如果我们有办法移动这些顶点而不实际改变它们的值,我们可以只定义 Bob 的矩形一次,并简单地在不同的位置画出他——这就是我们如何使用模型-视图矩阵。

世界和模型空间

为了理解世界和模型是如何工作的,我们不得不跳出我们小小的正交视图*截头体盒子来思考。我们的视见*截头体在一个特殊的坐标系中,这个坐标系叫做世界空间。这是我们所有的顶点最终要到达的地方。

到目前为止,我们已经指定了相对于这个世界空间原点的绝对坐标中的所有顶点位置(对比图 7-5 )。我们真正想要的是让我们的顶点位置的定义独立于这个世界空间坐标系。我们可以通过给每个模型(例如,Bob 的矩形、宇宙飞船等等)一个自己的坐标系来实现这一点。这就是我们通常所说的模型空间,我们在其中定义模型顶点位置的坐标系。图 7-19 展示了 2D 的这个概念,同样的规则也适用于 3D(只需添加一个 z 轴)。

9781430246770_Fig07-19.jpg

图 7-19。在模型空间中定义我们的模型,重用它,并在世界空间的不同位置渲染它

在图 7-19 中,我们有一个通过顶点实例定义的单一模型——例如,像这样:

Vertices vertices = new Vertices(glGraphics, 4, 12, false , false );
vertices.setVertices(new float [] { −50, -50,
                                    50, -50,
                                    50,  50,
                                   -50,  50 }, 0, 8);
vertices.setIndices(new short [] {0, 1, 2, 2, 3, 0}, 0, 6);

在我们的讨论中,我们只是忽略了顶点颜色和纹理坐标。现在,当我们渲染这个模型而没有任何进一步的修改时,它将被放置在我们最终图像中世界空间的原点周围。如果我们想在不同的位置渲染它,比如说,它的中心在世界空间中的(200,300),我们可以像这样重新定义顶点位置:

vertices.setVertices(new float [] { −50 + 200, -50 + 300,
                                    50 + 200, -50 + 300,
                                    50 + 200,  50 + 300,
                                   -50 + 200,  50 + 300 }, 0, 8);

在下一次调用 vertices.draw()时,模型将以(200,300)为中心进行渲染,但这有点乏味,不是吗?

又是矩阵

还记得我们简短地讨论过矩阵吗?我们讨论了矩阵如何对转换进行编码,比如*移(移动东西)、旋转和缩放。我们用来将顶点投影到投影*面上的投影矩阵编码了一种特殊类型的变换:投影。

矩阵是更优雅地解决我们之前问题的关键。我们不是通过重新定义来手动移动我们的顶点位置,而是简单地设置一个编码*移的矩阵。由于 OpenGL ES 的投影矩阵已经被我们通过 glOrthof()指定的正交图形投影矩阵占据,我们使用一个不同的 OpenGL ES 矩阵:模型-视图矩阵。下面是我们如何渲染我们的模型,将它的原点移动到眼睛/世界空间中的特定位置:

gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
gl.glTranslatef(200, 300, 0);
vertices.draw(GL10.GL_TRIANGLES, 0, 6);

我们首先要告诉 OpenGL ES 我们想要操作哪个矩阵。在我们的例子中,这是模型-视图矩阵,由常量 GL10 指定。GL_MODELVIEW。接下来,我们确保模型-视图矩阵被设置为单位矩阵。基本上,我们只是覆盖已经存在的任何东西——我们可以说是清除了矩阵。下一个电话是奇迹发生的地方。

glTranslatef()方法有三个参数:x、y 和 z 轴上的*移。因为我们希望模型的原点位于眼睛/世界空间中的(200,300)处,所以我们指定在 x 轴上*移 200 个单位,在 y 轴上*移 300 个单位。因为我们在 2D 工作,我们简单地忽略 z 轴,并将*移分量设置为 0。我们没有为顶点指定 z 坐标,所以这些将默认为 0。0 加 0 等于 0,所以我们的顶点会停留在 x-y *面。

从这一点开始,OpenGL ES 的模型-视图矩阵通过(200,300,0)对*移进行编码,这将应用于通过 OpenGL ES 管道的所有顶点。如果你回头参考图 7-4 ,你会看到 OpenGL ES 首先将每个顶点乘以模型-视图矩阵,然后应用投影矩阵。直到现在,模型-视图矩阵被设置为单位矩阵(OpenGL ES 的默认);因此,它对我们的顶点没有影响。我们的小 glTranslatef()调用改变了这一点,它将在投影之前首先移动所有顶点。

当然,这是即时完成的;我们的顶点实例中的值根本没有改变。我们会注意到顶点实例的任何永久变化,因为按照这种逻辑,投影矩阵已经改变了它。

使用翻译的初始示例

我们可以用翻译来做什么?假设我们想在世界的不同位置渲染 100 个 bob。此外,我们希望它们在屏幕上四处移动,并在每次碰到屏幕的边缘时改变方向(或者更确切地说,是我们的*行投影视图*截头体的*面,它与我们的屏幕范围一致)。我们可以用一个大的顶点实例来保存 100 个矩形的顶点——每个 Bob 一个——并重新计算每个帧的顶点位置。更简单的方法是让一个小顶点实例只包含一个矩形(Bob 的模型),并通过动态转换模型-视图矩阵来重用它。让我们来定义我们的 Bob 模型:

Vertices bobModel = new Vertices(glGraphics, 4, 12, false , true );
bobModel.setVertices(new float [] { −16, -16, 0, 1,
                                    16, -16, 1, 1,
                                    16,  16, 1, 0,
                                   -16,  16, 0, 0, }, 0, 8);
bobModel.setIndices(new short [] {0, 1, 2, 2, 3, 0}, 0, 6);

因此,每个 Bob 的大小为 32×32 个单位。我们也对他进行纹理映射——我们将使用 bobrgb888.png 来查看每个 Bob 的范围。

鲍勃成为一个阶层

让我们定义一个简单的 Bob 类。它将负责保持 Bob 实例的位置,并根据 delta 时间向其当前方向推进其位置,就像我们推进 Nom 先生一样(区别在于我们不再在网格中移动)。update()方法还将确保 Bob 不会超出我们的视图体积界限。清单 7-12 显示了 Bob 类。

清单 7-12 。Bob.java

package com.badlogic.androidgames.glbasics;

import java.util.Random;

class Bob {
    static final Random *rand* = new Random();
    public float x, y;
    float dirX, dirY;

    public Bob() {
        x = *rand*.nextFloat() * 320;
        y = *rand*.nextFloat() * 480;
        dirX = 50;
        dirY = 50;
    }

    public void update(float deltaTime) {
        x = x + dirX * deltaTime;
        y = y + dirY * deltaTime;

        if (x < 0) {
            dirX = −dirX;
            x = 0;
        }

        if (x > 320) {
            dirX = −dirX;
            x = 320;
        }

        if (y < 0) {
            dirY = −dirY;
            y = 0;
        }

        if (y > 480) {
            dirY = −dirY;
            y = 480;
        }
    }
}

当我们构造 Bob 时,每个 Bob 实例都将自己放置在世界上的一个随机位置。所有 Bob 实例最初都将向同一方向移动:每秒向右移动 50 个单位,向上移动 50 个单位(当我们乘以 deltaTime 时)。在 update()方法中,我们简单地以基于时间的方式在当前方向上推进 Bob 实例,然后检查它是否离开了视图截锥边界。如果是这种情况,我们反转它的方向,并确保它仍然在视图截锥中。

现在让我们假设我们正在实例化 100 个 Bob 实例,如下所示:

Bob[] bobs = new Bob[100];
for(int i = 0; i < 100; i++) {
    bobs[i] = new Bob();
}

为了渲染每个 Bob 实例,我们要做这样的事情(假设我们已经清空了屏幕,设置了投影矩阵,并绑定了纹理):

gl.glMatrixMode(GL10.GL_MODELVIEW);
for(int i = 0; i < 100; i++) {
    bob.update(deltaTime);
    gl.glLoadIdentity();
    gl.glTranslatef(bobs[i].x, bobs[i].y, 0);
    bobModel.render(GL10.GL_TRIANGLES, 0, 6);
}

很甜蜜,不是吗?对于每个 Bob 实例,我们调用它的 update()方法,这将提高它的位置,并确保它保持在我们这个小世界的范围内。接下来,我们将一个单位矩阵加载到 OpenGL ES 的模型-视图矩阵中,这样我们就有了一个新的开始。然后,我们在对 glTranslatef()的调用中使用当前 Bob 实例的 x 和 y 坐标。当我们在下一个调用中渲染 Bob 模型时,所有顶点都将偏移当前 Bob 实例的位置——这正是我们想要的。

把它放在一起

让我们把它作为一个完整的例子。清单 7-13 显示了带有注释的代码。

清单 7-13 。【BobTest.java】;100 个移动的 bob!

package com.badlogic.androidgames.glbasics;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.gl.FPSCounter;
import com.badlogic.androidgames.framework.gl.Texture;
import com.badlogic.androidgames.framework.gl.Vertices;
import com.badlogic.androidgames.framework.impl.GLGame;
import com.badlogic.androidgames.framework.impl.GLGraphics;

public class BobTest extends GLGame {
    public Screen getStartScreen() {
        return new BobScreen(this );
    }

    class BobScreen extends Screen {
        static final int *NUM_BOBS* = 100;
        GLGraphics glGraphics;
        Texture bobTexture;
        Vertices bobModel;
        Bob[] bobs;

我们的 BobScreen 类包含一个纹理(从 bobrbg888.png 加载)、一个包含 Bob 模型的 Vertices 实例(一个简单的纹理矩形)和一个 Bob 实例数组。我们还定义了一个名为 NUM_BOBS 的小常量,这样我们就可以修改我们想要在屏幕上显示的 bob 数量。

        public BobScreen(Game game) {
            super (game);
            glGraphics = ((GLGame)game).getGLGraphics();

            bobTexture = new Texture((GLGame)game, "bobrgb888.png");

            bobModel = new Vertices(glGraphics, 4, 12, false , true );
            bobModel.setVertices(new float [] { −16, -16, 0, 1,
                                                16, -16, 1, 1,
                                                16,  16, 1, 0,
                                               -16,  16, 0, 0, }, 0, 16);
            bobModel.setIndices(new short [] {0, 1, 2, 2, 3, 0}, 0, 6);

            bobs = new Bob[100];
            for (int i = 0; i < 100; i++) {
                bobs[i] = new Bob();
            }
        }

构造函数只是加载纹理,创建模型,并实例化 NUM_BOBS Bob 实例。

        @Override
        public void update(float deltaTime) {
            game.getInput().getTouchEvents();
            game.getInput().getKeyEvents();

            for (int i = 0; i < *NUM_BOBS*; i++) {
                bobs[i].update(deltaTime);
            }
        }

update()方法是我们让 Bob 实例自我更新的地方。我们还确保输入事件缓冲区是空的。

        @Override
        public void present(float deltaTime) {
            GL10 gl = glGraphics.getGL();
            gl.glClearColor(1,0,0,1);
            gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
            gl.glMatrixMode(GL10.*GL_PROJECTION*);
            gl.glLoadIdentity();
            gl.glOrthof(0, 320, 0, 480, 1, -1);

            gl.glEnable(GL10.*GL_TEXTURE_2D*);
            bobTexture.bind();

            gl.glMatrixMode(GL10.*GL_MODELVIEW*);
            for (int i = 0; i < *NUM_BOBS*; i++) {
                gl.glLoadIdentity();
                gl.glTranslatef(bobs[i].x, bobs[i].y, 0);
                bobModel.draw(GL10.*GL_TRIANGLES*, 0, 6);
            }
        }

在 present()方法中,我们清除屏幕,设置投影矩阵,启用纹理,并绑定 Bob 的纹理。最后几行负责实际呈现每个 Bob 实例。由于 OpenGL ES 记住了它的状态,我们只需设置一次活动矩阵;在这种情况下,我们将在剩余的代码中修改模型-视图矩阵。然后,我们遍历所有的 Bob 实例,根据当前 Bob 实例的位置将模型-视图矩阵设置为转换矩阵,并呈现模型,这将由模型-视图矩阵自动转换。

        @Override
        public void pause() {
        }

        @Override
        public void resume() {
        }

        @Override
        public void dispose() {
        }
    }
}

就这样。最棒的是,我们再次采用了在 Nom 先生中使用的 MVC 模式。它非常适合游戏编程。Bob 的逻辑方面与他的外观完全分离,这很好,因为我们可以很容易地用更复杂的东西替换他的外观。图 7-20 显示了我们的小程序运行几秒钟后的输出。

9781430246770_Fig07-20.jpg

图 7-20。波比真多!

这还不是我们所有转换乐趣的终结。如果你记得我们几页前说过的话,你就会知道接下来会发生什么:旋转和缩放。

更多转换

除了 glTranslatef()方法之外,OpenGL ES 还为我们提供了两种转换方法:glRotatef()和 glScalef()。

循环

下面是 glRotatef(): 的签名

GL10.glRotatef(float angle, float axisX, float axisY, float axisZ);

第一个参数是我们想要旋转顶点的角度,以度为单位。其余参数是什么意思?

当我们旋转某物时,我们绕轴旋转它。什么是轴?我们已经知道了三个轴:x 轴,y 轴和 z 轴。我们可以将这三个轴表示为向量。正 x 轴将被描述为(1,0,0),正 y 轴将被描述为(0,1,0),正 z 轴将被描述为(0,0,1)。如你所见,向量实际上编码了一个方向——在我们的例子中,是在 3D 空间中。鲍勃的方向也是一个矢量,但在 2D 空间。向量也可以对位置进行编码,比如鲍勃在 2D 空间中的位置。

要定义 Bob 模型旋转所围绕的轴,我们需要回到 3D 空间。图 7-21 显示了 Bob 的模型(应用了纹理进行定向),如先前代码在 3D 空间中所定义的。

9781430246770_Fig07-21.jpg

图 7-21。3D 中的鲍勃

由于我们还没有为 Bob 的顶点定义 z 坐标,所以他被嵌入到我们的 3D 空间(实际上是模型空间,记得吗?).如果我们想旋转 Bob,我们可以围绕我们能想到的任何轴来做:x、y 或 z 轴,甚至是一个完全疯狂的轴,比如(0.75,0.75,0.75)。然而,对于我们的 2D 图形编程需要,在 x-y *面旋转 Bob 是有意义的;因此,我们将使用正 z 轴作为旋转轴,可以定义为(0,0,1)。旋转将围绕 z 轴逆时针旋转。如下调用 glRotatef()将导致 Bob 模型的顶点旋转,如图 7-22 所示:

GL . GL rotate ref(45.0,0.1);

9781430246770_Fig07-22.jpg

图 7-22。 Bob,绕 z 轴旋转 45 度

缩放比例

我们也可以用 glScalef()缩放 Bob 的模型,像这样:

glScalef(2, 0.5f, 1);

给定 Bob 的原始模型姿势,这将导致图 7-23 中描绘的新方向。

9781430246770_Fig07-23.jpg

图 7-23。 Bob,在 x 轴上缩放 2 倍,在 y 轴上缩放 0.5 倍。。。哎哟!

组合转换

现在,我们还讨论了我们可以通过将多个矩阵相乘来组合它们的效果,从而形成一个新的矩阵。所有方法—glTranslatef()、glScalef()、glRotatef()和 glOrthof()—都是这样做的。它们根据我们传递给它们的参数,将当前的活动矩阵乘以内部创建的临时矩阵。所以,让我们结合鲍勃的旋转和缩放:

gl.glRotatef(45, 0, 0, 1);
gl.glScalef(2, 0.5f, 1);

这将使 Bob 的模型看起来像图 7-24 (记住,我们仍然在模型空间中)。

9781430246770_Fig07-24.jpg

图 7-24。 Bob,先缩放后旋转(看起来还是不开心)

如果我们反过来应用转换,会发生什么?

gl.glScalef(2, 0.5, 0);
gl.glRotatef(45, 0, 0, 1)

图 7-25 给你答案。

9781430246770_Fig07-25.jpg

图 7-25。 Bob,先旋转后缩放

哇,这不是我们以前认识的鲍勃。这里发生了什么?如果你查看代码片段,你实际上会期望图 7-24 看起来像图 7-25 ,而图 7-25 看起来像图 7-24 。在第一个片段中,我们首先应用旋转,然后缩放 Bob,对吗?

错了。OpenGL ES 将矩阵相乘的方式决定了矩阵编码的转换应用于模型的顺序。与当前活动矩阵相乘的最后一个矩阵将是第一个应用于顶点的矩阵。因此,如果我们想要以这种精确的顺序缩放、旋转和*移 Bob,我们必须像这样调用方法:

glTranslatef(bobs[i].x, bobs[i].y, 0);
glRotatef(45, 0, 0, 1);
glScalef(2, 0.5f, 1);

如果我们将 BobScreen.present()方法中的循环改为以下代码,输出将类似于图 7-26 :

gl.glMatrixMode(GL10.*GL_MODELVIEW*);
for (int i = 0; i <*NUM_BOBS*; i++) {
    gl.glLoadIdentity();
    gl.glTranslatef(bobs[i].x, bobs[i].y, 0);
    gl.glRotatef(45, 0, 0, 1);
    gl.glScalef(2, 0.5f, 0);
    bobModel.draw(GL10.*GL_TRIANGLES*, 0, 6);

}

9781430246770_Fig07-26.jpg

图 7-26。一百个 bob 被缩放、旋转和*移(按此顺序)到它们在世界空间中的位置

当你第一次在桌面上使用 OpenGL 时,很容易混淆这些矩阵运算的顺序。为了记住如何正确地做这件事,使用称为拉斯菲亚原则的记忆方法:最后指定,首先应用。(是啊,这种记忆术也没那么好,是吧?)

熟悉模型-视图转换的最简单的方法是大量使用它们。我们建议您使用 BobTest.java 源文件,修改内部循环一段时间,并观察效果。请注意,您可以指定任意数量的转换来渲染每个模型。添加更多旋转、*移和缩放。发疯吧。

有了最后这个例子,我们基本上知道了编写 2D 游戏所需要知道的一切。。。还是我们?

优化性能

当我们在 Droid 或 Nexus One 等强大的第二代设备上运行这个例子时,一切都会像丝绸一样流畅。如果我们在一个英雄身上运行它,一切将开始结巴,看起来相当不愉快。但是,嘿,我们不是说过 OpenGL ES 是快速图形渲染的银弹吗?是的,但前提是我们要按照 OpenGL ES 希望我们做的方式去做。

测量帧速率

BobTest 提供了一个完美的例子来开始一些优化。然而,在此之前,我们需要一种评估性能的方法。人工目测(“doh,看起来有点口吃”)不够精准。衡量程序执行速度的一个更好的方法是计算我们每秒渲染的帧数。在第三章中,我们谈到了垂直同步,简称 vsync。到目前为止,市场上所有的 Android 设备都支持这一功能,并且它将每秒最大帧数(FPS)限制在 60 帧。当我们以那个帧速率运行时,我们知道我们的代码足够好。

注意虽然 60 FPS 很好,但实际上在许多 Android 设备上很难达到这样的性能。高分辨率*板电脑有很多像素需要填充,即使我们只是在清空屏幕。如果我们的游戏以超过 30 帧/秒的速度渲染世界,我们会很高兴。不过,多放几帧也无妨。

让我们编写一个小的助手类,它计算 FPS 并定期输出那个值。清单 7-14 显示了一个名为 FPSCounter 的类的代码。

清单 7-14 。【FPSCounter.java】;每秒钟计算帧数并记录到 LogCat 中

package com.badlogic.androidgames.framework.gl;

import android.util.Log;

public class FPSCounter {
    long startTime = System.*nanoTime*();
    int frames = 0;

    public void logFrame() {
        frames++;
        if (System.*nanoTime*() - startTime > = 1000000000) {
            Log.*d*("FPSCounter", "fps: " + frames);
            frames = 0;
            startTime = System.*nanoTime*();
        }
    }
}

我们可以将这个类的一个实例放在我们的 BobScreen 类中,并在 BobScreen.present()方法中调用一次 logFrame()方法。我们刚刚做到了这一点,下面是一个 Hero(运行 Android 1.5)、一个 droid(运行 Android 2.2)和一个 Nexus One(运行 Android 2.2.1)的输出:

Hero:
12–10 03:27:05.230: DEBUG/FPSCounter(17883): fps: 22
12–10 03:27:06.250: DEBUG/FPSCounter(17883): fps: 22
12–10 03:27:06.820: DEBUG/dalvikvm(17883): GC freed 21818 objects / 524280 bytes in 132ms
12–10 03:27:07.270: DEBUG/FPSCounter(17883): fps: 20
12–10 03:27:08.290: DEBUG/FPSCounter(17883): fps: 23

Droid:
12–10 03:29:44.825: DEBUG/FPSCounter(8725): fps: 39
12–10 03:29:45.864: DEBUG/FPSCounter(8725): fps: 38
12–10 03:29:46.879: DEBUG/FPSCounter(8725): fps: 38
12–10 03:29:47.879: DEBUG/FPSCounter(8725): fps: 39
12–10 03:29:48.887: DEBUG/FPSCounter(8725): fps: 40

Nexus One:
12–10 03:28:05.923: DEBUG/FPSCounter(930): fps: 43
12–10 03:28:06.933: DEBUG/FPSCounter(930): fps: 43
12–10 03:28:07.943: DEBUG/FPSCounter(930): fps: 44
12–10 03:28:08.963: DEBUG/FPSCounter(930): fps: 44
12–10 03:28:09.973: DEBUG/FPSCounter(930): fps: 44
12–10 03:28:11.003: DEBUG/FPSCounter(930): fps: 43
12–10 03:28:12.013: DEBUG/FPSCounter(930): fps: 44

第一次检查时,我们可以看到以下内容:

  • 英雄比 Droid 和 Nexus One 慢一倍。
  • Nexus One 比 Droid 略快。
  • 在我们的进程中,我们在英雄上生成垃圾(17883)。

现在,单子上的最后一项有点令人困惑。我们在所有三台设备上运行相同的代码。经过进一步检查,我们没有在 present()方法或 update()方法中分配任何临时对象。那么英雄身上发生了什么?

安卓 1.5 上的英雄奇案

原来 Android 1.5 有一个 bug。嗯,这不是真正的错误,这只是一些极其草率的编程。还记得我们对顶点和索引使用直接 NIO 缓冲吗?这些实际上是本机堆内存中的内存块。每次我们调用 glVertexPointer()、glColorPointer()或任何其他 glXXXPointer()方法时,OpenGL ES 将尝试获取该缓冲区的本机堆内存地址,以查找顶点,从而将数据传输到视频 RAM。Android 1.5 上的问题是,每次我们从直接 NIO 缓冲区请求内存地址时,都会生成一个名为 PlatformAddress 的临时对象。由于我们有很多对 glXXXPointer()和 glDrawElements()方法的调用(记住,后者从直接 ShortBuffer 中获取地址),Android 分配了大量临时 PlatformAddress 实例,对此我们无能为力。(实际上,有一个解决方法,但现在我们不讨论它。)让我们接受在 Android 1.5 上使用 NIO 缓冲区是可怕的错误这一事实,然后继续前进。

*是什么让我的 OpenGL ES 渲染这么慢?

英雄比第二代设备慢并不奇怪。然而,Droid 中的 PowerVR 芯片比 Nexus One 中的 Adreno 芯片略快,所以前面的结果乍一看有点奇怪。通过进一步的检查,我们可能不会将这种差异归因于 GPU 的能力,而是因为我们在每一帧中调用了许多 OpenGL ES 方法,这些方法是昂贵的 Java 本地接口方法。这意味着它们实际上是调入 C 代码,这比在 Dalvik 上调用一个 Java 方法花费更多。Nexus One 有一个 JIT 编译器,可以稍微优化一下。所以我们就假设区别源于 JIT 编译器(这可能不完全正确)。

现在,让我们来看看什么对 OpenGL ES 不利:

  • 每帧改变很多状态(即混合、启用/禁用纹理映射等)
  • 每帧改变矩阵很多
  • 每帧绑定很多纹理
  • 每帧大量更改顶点、颜色和纹理坐标指针

这真的可以归结为状态的改变。为什么这成本很高?GPU 的工作方式就像工厂里的装配线。当生产线的前端处理新来的工件时,生产线的末端完成生产线前一阶段已经处理过的工件。让我们用一个小的汽车工厂类比来尝试一下。

生产线有几种状态,例如工厂工人可用的工具、用于组装汽车零件的螺栓类型、汽车喷漆的颜色等等。是的,真正的汽车工厂有多条装配线,但我们就假装只有一条。现在,只要我们不改变任何状态,生产线的每个阶段都会很忙。然而,只要我们改变一个状态,生产线就会停止,直到所有正在组装的汽车都完成为止。只有到那时,我们才能真正改变状态,用新的油漆、螺栓或其他东西组装汽车。

关键在于,对 glDrawElements()或 glDrawArrays()的调用不会立即执行;相反,命令被放入缓冲区,由 GPU 异步处理。这意味着对绘图方法的调用不会阻塞。因此,测量调用 glDrawElements()所花费的时间并不是一个好主意,因为实际的工作可能会在将来执行。这就是为什么我们改为测量 FPS。当交换帧缓冲区时(是的,我们在 OpenGL ES 中也使用双缓冲),OpenGL ES 将确保所有未完成的操作都被执行。

因此,将汽车工厂类比转换为 OpenGL ES 意味着以下内容:当新的三角形通过调用 glDrawElements()或 glDrawArrays()进入命令缓冲区时,GPU 管道可能会从对 render 方法的早期调用中完成对当前处理的三角形的渲染(例如,当前可以在管道的光栅化状态下处理三角形)。这有如下含义:

  • 改变当前绑定的纹理代价很高。必须首先渲染命令缓冲区中尚未处理且使用纹理的任何三角形。管道会停止工作。
  • 改变顶点、颜色和纹理坐标指针是昂贵的。必须首先渲染命令缓冲区中尚未渲染并使用旧指针的任何三角形。管道会停止工作。
  • 改变混合状态是昂贵的。命令缓冲区中任何需要/不需要混合且尚未渲染的三角形都必须首先渲染。管道会停止工作。
  • 改变模型视图或投影矩阵是昂贵的。命令缓冲区中任何尚未处理的三角形以及旧矩阵应该应用到的三角形必须首先渲染。管道会停止工作。

这一切的精髓是减少你的状态变化——所有的变化。

移除不必要的状态更改

让我们看看 BobTest 的 present()方法,看看我们能改变什么。下面是代码片段,其中我们添加了 FPSCounter,还使用了 glRotatef()和 glScalef()):

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
    gl.glClearColor(1,0,0,1);
    gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
    gl.glMatrixMode(GL10.*GL_PROJECTION*);
    gl.glLoadIdentity();
    gl.glOrthof(0, 320, 0, 480, 1, -1);

    gl.glEnable(GL10.*GL_TEXTURE_2D*);
    bobTexture.bind();

    gl.glMatrixMode(GL10.*GL_MODELVIEW*);
    for (int i = 0; i < *NUM_BOBS*; i++) {
        gl.glLoadIdentity();
        gl.glTranslatef(bobs[i].x, bobs[i].y, 0);
        gl.glRotatef(45, 0, 0, 1);
        gl.glScalef(2, 0.5f, 1);
        bobModel.draw(GL10.*GL_TRIANGLES*, 0, 6);
    }
    fpsCounter.logFrame();
}

我们可以做的第一件事是移动对 glViewport()和 glClearColor()的调用,以及将投影矩阵设置为 BobScreen.resume()方法的方法调用。清澈的颜色永远不会改变;视口和投影矩阵也不会改变。为什么不在 BobScreen 的构造函数中放入设置所有像 viewport 或 projection matrix 这样的持久 OpenGL 状态的代码?好吧,我们需要与语境缺失做斗争。我们执行的所有 OpenGL ES 状态修改都将丢失,并且当我们的屏幕的 resume()方法被调用时,我们知道上下文已经被重新创建,因此丢失了我们之前可能已经设置的所有状态。我们还可以将 glEnable()和纹理绑定调用放入 resume()方法中。毕竟,我们希望纹理一直处于启用状态,并且我们也只希望使用包含 Bob 图像的单一纹理。为了更好地测量,我们还在 resume()方法中调用 texture.reload(),以便在上下文丢失的情况下,我们的纹理图像数据也被重新加载。下面是我们修改后的 present()和 resume()方法:

@Override
public void resume() {
    GL10 gl = glGraphics.getGL();
    gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
    gl.glClearColor(1, 0, 0, 1);
    gl.glMatrixMode(GL10.*GL_PROJECTION*);
    gl.glLoadIdentity();
    gl.glOrthof(0, 320, 0, 480, 1, -1);
    bobTexture.reload();
    gl.glEnable(GL10.*GL_TEXTURE_2D*);
    bobTexture.bind();
}
@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);

    gl.glMatrixMode(GL10.*GL_MODELVIEW*);
    for (int i = 0; i < *NUM_BOBS*; i++) {
        gl.glLoadIdentity();
        gl.glTranslatef(bobs[i].x, bobs[i].y, 0);
        gl.glRotatef(45, 0, 0, 1);
        gl.glScalef(2, 0.5f, 0);
        bobModel.draw(GL10.*GL_TRIANGLES*, 0, 6);
    }

    fpsCounter.logFrame();
}

运行这一“改进”版本可在三台设备上获得以下性能:

Hero:
12–10 04:41:56.750: DEBUG/FPSCounter(467): fps: 23
12–10 04:41:57.770: DEBUG/FPSCounter(467): fps: 23
12–10 04:41:58.500: DEBUG/dalvikvm(467): GC freed 21821 objects / 524288 bytes in 133ms
12–10 04:41:58.790: DEBUG/FPSCounter(467): fps: 19
12–10 04:41:59.830: DEBUG/FPSCounter(467): fps: 23

Droid:
12–10 04:45:26.906: DEBUG/FPSCounter(9116): fps: 39
12–10 04:45:27.914: DEBUG/FPSCounter(9116): fps: 41
12–10 04:45:28.922: DEBUG/FPSCounter(9116): fps: 41
12–10 04:45:29.937: DEBUG/FPSCounter(9116): fps: 40

Nexus One:
12–10 04:37:46.097: DEBUG/FPSCounter(2168): fps: 43
12–10 04:37:47.127: DEBUG/FPSCounter(2168): fps: 45
12–10 04:37:48.147: DEBUG/FPSCounter(2168): fps: 44
12–10 04:37:49.157: DEBUG/FPSCounter(2168): fps: 44
12–10 04:37:50.167: DEBUG/FPSCounter(2168): fps: 44

正如你所看到的,所有的设备都已经从我们的优化中受益了一点点。当然,影响并不十分巨大。这可以归因于这样一个事实,当我们最初在帧的开始调用所有这些方法时,管道中没有三角形。

减小纹理大小意味着要提取的像素更少

那么还有什么可以改变的呢?一些不那么明显的事情。我们的 Bob 实例大小为 32×32 个单元。我们使用大小为 320×480 单位的投影*面。在一个英雄身上,这将给我们像素级的完美渲染。在 Nexus One 或 Droid 上,我们坐标系中的单个单位将占用不到一个像素。无论如何,我们的纹理实际上是 128×128 像素大小。我们不需要那么高的分辨率,所以让我们将纹理图像 bobrgb888.png 的大小调整为 32×32 像素。我们将把这个新形象称为 bobrgb888-32x32.png。使用这个更小的纹理,我们为每个设备得到如下的 FPS:

Hero:
12–10 04:48:03.940: DEBUG/FPSCounter(629): fps: 23
12–10 04:48:04.950: DEBUG/FPSCounter(629): fps: 23
12–10 04:48:05.860: DEBUG/dalvikvm(629): GC freed 21812 objects / 524256 bytes in 134ms
12–10 04:48:05.990: DEBUG/FPSCounter(629): fps: 21
12–10 04:48:07.030: DEBUG/FPSCounter(629): fps: 24

Droid:
12–10 04:51:11.601: DEBUG/FPSCounter(9191): fps: 56
12–10 04:51:12.609: DEBUG/FPSCounter(9191): fps: 56
12–10 04:51:13.625: DEBUG/FPSCounter(9191): fps: 55
12–10 04:51:14.641: DEBUG/FPSCounter(9191): fps: 55

Nexus One:
12–10 04:48:18.067: DEBUG/FPSCounter(2238): fps: 53
12–10 04:48:19.077: DEBUG/FPSCounter(2238): fps: 56
12–10 04:48:20.077: DEBUG/FPSCounter(2238): fps: 53
12–10 04:48:21.097: DEBUG/FPSCounter(2238): fps: 54

哇,这在第二代设备上产生了巨大的差异!事实证明,这些设备的 GPU 最讨厌的莫过于必须扫描大量像素。这适用于从纹理中获取纹理元素,以及实际将三角形渲染到屏幕上。这些 GPU 可以获取纹理元素并将像素渲染到帧缓冲区的速率称为填充速率。所有第二代 GPU 都有严重的填充率限制,所以我们应该尽量使用尽可能小的纹理(或者只将我们的三角形映射到其中的一小部分),而不是将极其巨大的三角形渲染到屏幕上。我们还应该注意重叠:重叠的三角形越少越好。

注意实际上,对于像 Droid 上的 PowerVR SGX 530 这样的 GPU 来说,重叠并不是一个非常大的问题。这些 GPU 有一种称为基于图块的延迟渲染的特殊机制,可以在特定条件下消除大量重叠。尽管如此,我们仍然应该关心那些在屏幕上永远看不到的像素。

英雄只是稍微受益于纹理图像尺寸的减小。那么罪魁祸首是什么呢?

减少对 OpenGL ES/JNI 方法的调用

第一个疑点是,当我们为每个 Bob 渲染模型时,每帧都会发出许多 OpenGL ES 调用。首先,我们每个 Bob 有四个矩阵运算。如果我们不需要旋转或缩放,我们可以减少到两个调用。当我们在内部循环中只使用 glLoadIdentity()和 glTranslatef()时,每个设备的 FPS 数如下:

Hero:
12–10 04:57:49.610: DEBUG/FPSCounter(766): fps: 27
12–10 04:57:49.610: DEBUG/FPSCounter(766): fps: 27
12–10 04:57:50.650: DEBUG/FPSCounter(766): fps: 28
12–10 04:57:50.650: DEBUG/FPSCounter(766): fps: 28
12–10 04:57:51.530: DEBUG/dalvikvm(766): GC freed 22910 objects / 568904 bytes in 128ms

Droid:
12–10 05:08:38.604: DEBUG/FPSCounter(1702): fps: 56
12–10 05:08:39.620: DEBUG/FPSCounter(1702): fps: 57
12–10 05:08:40.628: DEBUG/FPSCounter(1702): fps: 58
12–10 05:08:41.644: DEBUG/FPSCounter(1702): fps: 57

Nexus One:
12–10 04:58:01.277: DEBUG/FPSCounter(2509): fps: 54
12–10 04:58:02.287: DEBUG/FPSCounter(2509): fps: 54
12–10 04:58:03.307: DEBUG/FPSCounter(2509): fps: 55
12–10 04:58:04.317: DEBUG/FPSCounter(2509): fps: 55

嗯,它大大提高了英雄的性能,Droid 和 Nexus One 也受益于取消了两个矩阵运算。当然,这里有一点欺骗:如果我们需要旋转和缩放我们的 bob,没有办法发出这两个额外的调用。然而,当我们所做的只是 2D 渲染时,有一个巧妙的小技巧可以让我们摆脱所有的矩阵运算(我们将在下一章探讨这个技巧)。

OpenGL ES 是一个通过 JNI 包装器提供给 Java 的 C API。这意味着我们调用的任何 OpenGL ES 方法都必须穿过那个 JNI 包装器来调用实际的 C 原生函数。这在早期的 Android 版本上有点昂贵,但在最*的版本上已经变得更好了。如图所示,影响并不那么大,特别是如果实际操作比发出调用本身花费更多的时间。

绑定顶点的概念

那么,还有什么我们可以改进的吗?让我们再看一次当前的 present()方法[去掉了 glRotatef()和 glScalef()]:

public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);

    gl.glMatrixMode(GL10.*GL_MODELVIEW*);
    for (int i = 0; i < *NUM_BOBS*; i++) {
        gl.glLoadIdentity();
        gl.glTranslatef(bobs[i].x, bobs[i].y, 0);
        bobModel.draw(GL10.*GL_TRIANGLES*, 0, 6);
    }

    fpsCounter.logFrame();
}

这看起来非常理想,不是吗?嗯,事实上它不是最优的。首先,我们还可以将 gl.glMatrixMode()调用移动到 resume()方法,但正如我们已经看到的那样,这不会对性能产生巨大影响。第二件可以优化的事情稍微微妙一点。

我们使用 Vertices 类来存储和呈现 bob 的模型。还记得 Vertices.draw()方法吗?这又是一次:

public void draw(int primitiveType, int offset, int numVertices) {
    GL10 gl = glGraphics.getGL();

    gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
    vertices.position(0);
    gl.glVertexPointer(2, GL10.*GL_FLOAT*, vertexSize, vertices);

    if (hasColor) {
        gl.glEnableClientState(GL10.*GL_COLOR_ARRAY*);
        vertices.position(2);
        gl.glColorPointer(4, GL10.*GL_FLOAT*, vertexSize, vertices);
    }

    if (hasTexCoords) {
        gl.glEnableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);
        vertices.position(hasColor?6:2);
        gl.glTexCoordPointer(2, GL10.*GL_FLOAT*, vertexSize, vertices);
    }

    if (indices! =null ) {
        indices.position(offset);
        gl.glDrawElements(primitiveType, numVertices, GL10.*GL_UNSIGNED_SHORT*, indices);
    } else {
        gl.glDrawArrays(primitiveType, offset, numVertices);
    }

    if (hasTexCoords)
        gl.glDisableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);

    if (hasColor)
        gl.glDisableClientState(GL10.*GL_COLOR_ARRAY*);
}

现在再看一下循环的前面。注意到什么了吗?对于每个 Bob,我们通过 glEnableClientState()反复启用相同的顶点属性。我们实际上只需要设置一次,因为每个 Bob 使用相同的模型,总是使用相同的顶点属性。下一个大问题是对每个 Bob 的 glXXXPointer()调用。因为这些指针也是 OpenGL ES 状态,我们只需要设置它们一次,因为它们一旦被设置就永远不会改变。那么我们如何解决这个问题呢?让我们稍微重写一下 Vertices.draw()方法:

public void bind() {
    GL10 gl = glGraphics.getGL();

    gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
    vertices.position(0);
    gl.glVertexPointer(2, GL10.*GL_FLOAT*, vertexSize, vertices);

    if (hasColor) {
        gl.glEnableClientState(GL10.*GL_COLOR_ARRAY*);
        vertices.position(2);
        gl.glColorPointer(4, GL10.*GL_FLOAT*, vertexSize, vertices);
    }

    if (hasTexCoords) {
        gl.glEnableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);
        vertices.position(hasColor?6:2);
        gl.glTexCoordPointer(2, GL10.*GL_FLOAT*, vertexSize, vertices);
    }
}
public void draw(int primitiveType, int offset, int numVertices) {
    GL10 gl = glGraphics.getGL();

    if (indices !=null ) {
        indices.position(offset);
        gl.glDrawElements(primitiveType, numVertices, GL10.*GL_UNSIGNED_SHORT*, indices);
    } else {
        gl.glDrawArrays(primitiveType, offset, numVertices);
    }
}
public void unbind() {
    GL10 gl = glGraphics.getGL();
    if (hasTexCoords)
        gl.glDisableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);

    if (hasColor)
        gl.glDisableClientState(GL10.*GL_COLOR_ARRAY*);
}

你能看到我们做了什么吗?我们可以像对待纹理一样对待顶点和所有的指针。我们通过对 Vertices.bind()的一次调用来“绑定”顶点指针。从这一点开始,每个 Vertices.draw()调用都将使用那些“绑定”的顶点,就像 draw 调用也将使用当前绑定的纹理一样。一旦我们完成了顶点实例的渲染,我们调用 Vertices.unbind()来禁用另一个顶点实例可能不需要的任何顶点属性。保持我们的 OpenGL ES 状态干净是一件好事。下面是我们的 present()方法现在的样子[我们移动了 glMatrixMode(GL10。GL_MODELVIEW)调用 resume()也是]:

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);

    bobModel.bind();
    for (int i = 0; i < *NUM_BOBS*; i++) {
        gl.glLoadIdentity();
        gl.glTranslatef(bobs[i].x, bobs[i].y, 0);
        bobModel.draw(GL10.*GL_TRIANGLES*, 0, 6);
    }
    bobModel.unbind();

    fpsCounter.logFrame();
}

这实际上是每帧只调用一次 glXXXPointer()和 glEnableClientState()方法。因此,我们节省了* 100 × 6 次对 OpenGL ES 的调用。这应该会对性能产生巨大的影响,对吗?

Hero:
12–10 05:16:59.710: DEBUG/FPSCounter(865): fps: 51
12–10 05:17:00.720: DEBUG/FPSCounter(865): fps: 46
12–10 05:17:01.720: DEBUG/FPSCounter(865): fps: 47
12–10 05:17:02.610: DEBUG/dalvikvm(865): GC freed 21815 objects / 524272 bytes in 131ms
12–10 05:17:02.740: DEBUG/FPSCounter(865): fps: 44
12–10 05:17:03.750: DEBUG/FPSCounter(865): fps: 50

Droid:
12–10 05:22:27.519: DEBUG/FPSCounter(2040): fps: 57
12–10 05:22:28.519: DEBUG/FPSCounter(2040): fps: 57
12–10 05:22:29.526: DEBUG/FPSCounter(2040): fps: 57
12–10 05:22:30.526: DEBUG/FPSCounter(2040): fps: 55

Nexus One:
12–10 05:18:31.915: DEBUG/FPSCounter(2509): fps: 56
12–10 05:18:32.935: DEBUG/FPSCounter(2509): fps: 56
12–10 05:18:33.935: DEBUG/FPSCounter(2509): fps: 55
12–10 05:18:34.965: DEBUG/FPSCounter(2509): fps: 54

这三种设备现在几乎不相上下。Droid 表现最好,其次是 Nexus One。我们的小英雄也表现得很好。我们从非优化情况下的 22 FPS 提高到 50 FPS。这意味着性能提高了 100%以上。我们可以为自己感到骄傲。我们优化的 Bob 测试是非常优化的。

当然,我们新的可绑定顶点类现在有一些限制:

  • 当 Vertices 实例未绑定时,我们只能设置顶点和索引数据,因为该信息的上传是在 Vertices.bind()中执行的。
  • 我们不能同时绑定两个顶点实例。这意味着我们在任何时间点只能使用一个顶点实例进行渲染。虽然这通常不是一个大问题,但考虑到令人印象深刻的性能提升,我们将接受它。

最后

还有一个我们可以应用的优化,适用于*面几何的 2D 图形编程,比如矩形。我们将在下一章研究这个问题。要搜索的关键字是批处理,意思是减少 glDrawElements()/glDrawArrays()调用的次数。3D 图形也有一个对等物,叫做实例化,但这在 OpenGL ES 1.x 中是不可能的

在我们结束这一章之前,我们想再提两件事。首先,当您运行 BobTest 或 OptimizedBobTest(包含我们刚刚开发的超级优化的代码)时,请注意 bob 在屏幕周围有些晃动。这是因为它们的位置是作为浮点数传递给 glTranslatef()的。像素完美渲染的问题是 OpenGL ES 对坐标中有小数部分的顶点位置非常敏感。我们无法真正解决这个问题;这种效果在真实的游戏中不太明显,甚至不存在,我们将在下一个游戏中看到。我们可以通过使用更多样化的背景在某种程度上隐藏这种效果。

我们要指出的第二件事是我们如何解读 FPS 测量值。正如您在前面的输出中看到的,FPS 有一点波动。这可以归因于与我们的应用一起运行的后台进程。我们永远不会拥有游戏的所有系统资源,所以我们必须学会面对这个问题。当你在优化你的程序时,不要通过杀死所有后台进程来伪造环境。在处于正常状态的手机上运行该应用,就像你自己使用它一样。这将反映用户将拥有的相同体验。

我们的好成绩结束了这一章。作为一个警告,只有在你的渲染代码运行之后,并且只有在你真的遇到性能问题之后,才开始优化你的渲染代码。过早的优化通常是不得不重写整个渲染代码的原因,因为它可能变得不可维护。

摘要

OpenGL ES 是一头巨兽。我们设法把所有这些都浓缩到一个大小,使它容易用于我们的游戏编程需求。我们讨论了 OpenGL ES 是什么(一个精简的、*均的三角形渲染机器)以及它是如何工作的。然后,我们探讨了如何通过指定顶点、创建纹理和使用状态(如混合)来获得一些漂亮的效果,从而利用 OpenGL ES 的功能。我们还研究了一些投影以及它们与矩阵的关系。虽然我们没有在内部讨论矩阵的作用,但是我们探索了如何使用矩阵来旋转、缩放和转换可重用的模型,从模型空间到世界空间。当我们以后使用 OpenGL ES 进行 3D 编程时,你会注意到你已经学会了 90%你需要知道的东西。我们所要做的就是改变投影,并给我们的顶点添加一个 z 坐标(嗯,还有一些事情,但在高层次上,实际上就是这些)。然而,在此之前,我们将用 OpenGL ES 编写一个不错的 2D 游戏。在下一章,你将了解一些我们可能需要的 2D 编程技术。*******

八、2D 游戏编程技巧

第七章展示了 OpenGL ES 为 2D 图形编程提供了很多特性,比如简单的旋转和缩放,以及视见区的自动拉伸。它还提供了优于使用画布的性能优势。

现在是时候看看 2D 游戏编程的一些更高级的主题了。你在写《提名先生》的时候直观地使用了其中的一些概念,包括基于时间的状态更新和图像地图集。接下来的许多事情也确实是非常直观的,并且很有可能你迟早会想出同样的解决方案。但是明确地了解这些东西也无妨。

对于 2D 游戏编程来说,有一些至关重要的概念。其中一些将与图形相关,其他的将处理你如何表现和模拟你的游戏世界。所有这些都有一个共同点:它们依赖于一点线性代数和三角学。不用担心,编写像超级马里奥兄弟这样的游戏所需的数学水*并不令人惊讶。让我们从复习 2D 线性代数和三角学的一些概念开始。

在开始之前

和前面的“理论”章节一样,我们将创建几个例子来感受一下发生了什么。对于这一章,我们可以重用我们在第七章开发的东西,主要是 GLGame、GLGraphics、Texture 和 Vertices 类,以及其余的框架类。

按照与在第七章中建立项目完全相同的方式建立一个新项目。将 com . bad logic . androidgames . framework 包复制到您的新项目中,然后创建一个名为 com . bad logic . androidgames . gamedev2d .的新包

添加一个名为 GameDev2DStarter 的 starter 类。重用 GLBasicsStarter 的代码,简单地替换测试的类名。修改清单文件,以便启动这个新的 starter 类。对于我们将要开发的每个测试,您必须以元素的形式向清单中添加一个条目。

每个测试都是游戏界面的一个实例,实际的测试逻辑是以测试的游戏实现中包含的屏幕的形式实现的,如前一章所述。为了节省一些页面,将只显示屏幕的相关部分。每个测试的 GLGame 和 Screen 实现的命名约定也是 XXXTest 和 XXXScreen。

既然这样,是时候谈谈向量了。

开始的时候。。。有一个向量

在第七章中,你学到了向量不应该和位置混在一起。这并不完全正确,因为我们可以(也将会)通过一个向量来表示某些空间中的位置。一个向量其实可以有多种解释:

  • Position :我们已经在前面的章节中使用它来编码实体相对于坐标系原点的坐标。
  • 速度和加速度 : 这些是你将在下一节听到的物理量。虽然您可能习惯于认为速度和加速度是一个单一的值,但它们实际上应该表示为 2D 或 3D 向量。它们不仅编码实体的速度(例如,以 100 km/h 行驶的汽车),还编码实体行进的方向。注意,这种向量解释并没有说明向量是相对于原点给出的。这是有意义的,因为汽车的速度和方向与其位置无关。想象一辆车以 100 km/h 的速度在直线公路上向西北方向行驶,只要它的速度和方向不变,速度矢量也不会变,而它的位置会变。
  • 方向和距离 : 方向类似于速度但一般缺少物理量。你可以用这样的矢量解释来编码状态,比如这个实体指向东南。距离只是告诉我们一个位置离另一个位置有多远,在什么方向。

图 8-1 显示了这些解释的作用。

9781430246770_Fig08-01.jpg

图 8-1。 Bob,用向量表示位置、速度、方向和距离

当然,图 8-1 并不详尽。向量可以有更多的解释。然而,对于我们的游戏开发需求,这四个基本解释就足够了。

图 8-1 中忽略的一点是矢量分量的单位。我们必须确保这些是合理的(例如,鲍勃的速度可以是米每秒,所以他在 1 秒内向左移动 2 米,向上移动 3 米)。位置和距离也是如此,它们也可以用米来表示。然而,鲍勃的方向是一个特例——它是无单位的。如果我们想要指定一个物体的大致方向,同时保持该方向的物理特征独立,这将非常方便。对于 Bob 的速度,我们可以这样做,将他的速度方向存储为一个方向向量,将他的速度存储为一个值。单值也被称为标量。方向向量的长度必须是 1,这将在本章后面讨论。

使用向量

向量的力量源于这样一个事实,即我们可以很容易地操纵和组合它们。在我们这样做之前,我们需要定义如何表示向量。这里有一个特别的,半数学的向量表示:

v = (x,y)

现在,这不是一个大惊喜;我们已经做了无数次了。在我们的 2D 空间中,每个矢量都有一个 x 和一个 y 分量。(是的,本章我们将停留在二维空间。)我们还可以加上两个向量:

c = a + b = (a.x,a.y) + (b.x,b.y) = (a.x + b.x,a.y + b.y)

我们所要做的就是把这些分量加在一起,得到最终的矢量。用图 8-1 中给出的矢量进行试验。假设你取鲍勃的位置, p = (3,2),加上他的速度,v=(-2,3)。你到达一个新的位置,p' =(3+–2,2 + 3) = (1,5)。不要被这里的 p 后面的撇号搞糊涂了;它只是表示你有了一个新的向量。当然,这个小操作只有在位置和速度的单位吻合时才有意义。在这种情况下,我们假设位置以米(m)为单位给出,速度以米/秒(m/s)为单位给出,这非常合适。

当然,我们也可以减去向量:

c = a b =(a . x,a . y )( b . x,b . y)=(a . x b . x,a . y b . y)

同样,我们所做的就是把两个向量的分量结合起来。然而,请注意,我们从一个向量中减去另一个向量的顺序很重要。以图 8-1 中最右边的图像为例。我们在 pg = (1,4)处有一个绿色摆锤,在 pr = (6,1)处有一个红色摆锤,其中 pgpr 分别代表绿色位置和红色位置。当我们获取从绿色 Bob 到红色 Bob 的距离矢量时,我们计算如下:

d = pg-pr =(1.4)-6.1)=(-5.3)

这很奇怪。这个矢量实际上是从红 Bob 指向绿 Bob!为了得到从绿鲍勃到红鲍勃的方向向量,我们必须颠倒减法的顺序:

d = pr-pg =(6.1)-(1.4)=(5,-3)

如果我们想要找到从位置 a 到位置 b 的距离向量,我们使用以下通用公式:

d = b a

换句话说,总是从结束位置减去开始位置。这一开始有点令人困惑,但如果你仔细想想,这绝对有道理。在一些图表纸上尝试一下吧!

我们也可以将一个向量乘以一个标量(记住,标量只是一个单一的值):

a' = a *标量= (a.x *标量,a.y *标量)

我们将向量的每个分量乘以标量。这让我们可以缩放向量的长度。以图 8-1 中的方向向量为例。指定为 d = (0,–1)。如果我们将它乘以标量 s = 2,我们有效地将它的长度翻倍: d × s = (0,–1×2)=(0,–2)。当然,我们可以通过使用一个小于 1 的标量使它变得更小——例如, d 乘以 s = 0.5 创建一个新的向量 d ' = (0,–0.5)。

说到长度,我们也可以计算向量的长度(以给出的单位):

image

符号简单的解释了这代表了向量的长度。如果你在学校的线性代数课上没有睡觉,你可能知道向量长度的公式。这只是应用于我们想象中的 2D 向量的毕达哥拉斯定理。向量的 x 和 y 分量形成直角三角形的两条边,第三条边是向量的长度。图 8-2 说明了这一点。

9781430246770_Fig08-02.jpg

图 8-2。毕达哥拉斯也会喜欢向量

给定*方根的属性,向量长度总是正的或零。如果我们将此应用于红色 Bob 和绿色 Bob 之间的距离向量,我们可以计算出它们彼此相距多远(如果它们的位置以米为单位给出):

image

注意,如果我们计算|pgpr|,我们会得到相同的值,因为长度与向量的方向无关。这个新知识还有另一层含义:当我们把一个向量乘以一个标量时,它的长度会相应地改变。给定一个向量 d = (0,–1),原始长度为 1 个单位,你可以将其乘以 2.5,得到一个新的向量,长度为 2.5 个单位。

方向向量通常没有任何相关的单位。我们可以通过将它们乘以一个标量来给它们一个单位——比如我们可以将一个方向向量 d = (0,1)乘以一个速度常数 s = 100 m/s 得到一个速度向量 v = (0 × 100,1 × 100) = (0,100)。让方向向量的长度为 1 总是一个好主意。长度为 1 的向量称为 单位向量。我们可以通过将任何向量的每个分量除以其长度来使其成为单位向量:

image

记住|d|只是表示向量的长度 d 。试试看。假设你想要一个指向东北的方向向量: d = (1,1)。看起来这个向量已经是一个单位长度了,因为两个分量都是 1,对吗?错误:

image

你可以通过把向量变成单位向量来解决这个问题:

image

这也被称为归一化一个向量,,这仅仅意味着我们确保它的长度为 1。用这个小技巧,我们可以,例如,从一个距离向量创建一个单位长度的方向向量。当然,我们必须小心零长度向量,因为在这种情况下我们必须除以零!

一点三角学

现在我们来看一下三角学。三角学中有两个必不可少的函数: 余弦正弦。每一个都有一个参数:一个角度。您可能习惯于用度数来表示角度(例如,45 度或 360 度)。然而,在大多数数学库中,三角函数期望角度以弧度表示。我们可以使用以下等式轻松地进行角度和弧度之间的转换:

度数弧度(角度度数)=角度度数/180 *π

辐射度(角度)=角度辐射度/π* 180

这里, pi 是心爱的超常数,*似值为 3.14159265。 pi 弧度等于 180,所以前面的函数就是这么来的。

给定一个角度,余弦和正弦实际上计算什么?他们计算单位长度向量相对于原点的 x 和 y 分量。图 8-3 说明了这一点。

9781430246770_Fig08-03.jpg

图 8-3。余弦和正弦产生一个单位矢量,其端点位于单位圆上

给定一个角度,我们可以创建一个单位长度的方向向量,如下所示:

v = (cos(角度),sin(角度))

我们也可以反过来,计算矢量相对于 x 轴的角度:

角度= atan2 (v.y,v.x)

atan2 函数实际上是一个人工构造。它使用反正切函数(正切函数的反函数,三角学中的另一个基本函数)来构造一个在–180°到 180°范围内的角度(或者如果角度以弧度返回,则使用–pipi )。内部有点复杂,在这个讨论中不太重要。自变量是向量的 y 和 x 分量。注意,矢量不必是 atan2 函数起作用的单位矢量。另外,请注意,通常会先给出 y 分量,然后给出 x 分量——但这取决于所选的数学库。这是一个常见的错误来源。

试举几个例子。给定一个向量 v = (cos(97),sin(97)),atan2(sin(97),cos(97))的结果是 97。太好了,很简单。使用向量 v = (1,–1),你得到 atan2(–1,1)=–45。因此,如果矢量的 y 分量是负的,你将得到一个 0 到–180 的负角度。如果 atan2 的输出为负,您可以通过添加 360(或 2 个 pi )来解决这个问题。在前面的示例中,您将得到 315。

我们希望能够应用于矢量的最后一个操作是将它们旋转某个角度。下面的方程的推导又是相当复杂的。幸运的是,我们可以直接使用这些方程,不需要知道正交基向量。(提示:如果你想知道幕后发生了什么,这是在网上搜索的关键词。)下面是神奇的伪代码:

v.x' = cos(角度)* v . x sin(角度)* v.y

v.y' = sin(角度)* v.x + cos(角度)* v.y

哇,没想象中复杂。这将绕原点逆时针旋转任何矢量,不管你对矢量有什么解释。

加上向量加法、减法和标量乘法,我们实际上可以自己实现所有的 OpenGL 矩阵运算。这是进一步提高第七章中 BobTest 性能的解决方案的一部分。这将在下一节讨论。现在,让我们专注于所讨论的内容,并将其转移到代码中。

实现 Vector 类

现在我们可以为 2D 向量创建一个易于使用的向量类。我们称之为 Vector2。它应该有两个成员来保存向量的 x 和 y 分量。此外,它应该有几个不错的方法,允许你做以下事情:

  • 加减向量
  • 将向量分量与标量相乘
  • 测量矢量的长度
  • 标准化向量
  • 计算向量和 x 轴之间的角度
  • 旋转矢量

Java 缺乏操作符重载,所以我们必须想出一种机制来减少使用 Vector2 类的麻烦。理想情况下,我们应该有如下内容:

Vector2 v = new Vector2();
v.add(10,5).mul(10).rotate(54);

我们可以通过让每个 Vector2 方法返回对 Vector 本身的引用来轻松实现这一点。当然,我们还想重载像 Vector2.add()这样的方法,这样我们就可以传入两个浮点数或者另一个 Vector2 的一个实例。清单 8-1 展示了你的 Vector2 类的全部荣耀,并在适当的地方添加了注释。

清单 8-1。Vector2.java;实现一些不错的 2D 矢量功能

package com.badlogic.androidgames.framework.math;

import android.util.FloatMath;

public class Vector2 {
    public static float *TO_RADIANS* = (1 / 180.0f) * (float ) Math.*PI*;
    public static float *TO_DEGREES* = (1 / (float ) Math.*PI*) * 180;
    public float x, y;

    public Vector2() {
    }

    public Vector2(float x, float y) {
        this .x = x;
        this .y = y;
    }

    public Vector2(Vector2 other) {
        this .x = other.x;
        this .y = other.y;
    }

将该类放入包 com . badlogic . androidgames . framework . math 中,我们还将在其中存放任何其他与数学相关的类。

我们首先定义两个静态常数,TO _ 弧度和 TO _ 度。要转换以弧度表示的角度,我们只需将其乘以 TO _ DEGREES 要将以度为单位的角度转换为弧度,我们将它乘以 TO_RADIANS。我们可以通过查看之前定义的控制角度到弧度转换的两个等式来再次检查这一点。通过这个小技巧,我们可以减少一些分歧,加快速度。

接下来,我们定义成员 x 和 y,它们存储向量的分量,以及几个构造函数——没什么太复杂的:

    public Vector2 cpy() {
        return new Vector2(x, y);
    }

cpy()方法将创建一个当前 vector 的副本实例并返回它。如果我们想要操作一个向量的副本,保留原始向量的值,这可能会很方便。

    public Vector2 set(float x, float y) {
        this .x = x;
        this .y = y;
        return this ;
    }

    public Vector2 set(Vector2 other) {
        this .x = other.x;
        this .y = other.y;
        return this ;
    }

set()方法允许我们根据两个浮点参数或另一个向量来设置向量的 x 和 y 分量。这些方法返回对这个向量的引用,因此我们可以像前面讨论的那样链接操作。

    public Vector2 add(float x, float y) {
        this .x += x;
        this .y += y;
        return this ;
    }

    public Vector2 add(Vector2 other) {
        this .x += other.x;
        this .y += other.y;
        return this ;
    }

    public Vector2 sub(float x, float y) {
        this .x -= x;
        this .y -= y;
        return this ;
    }

    public Vector2 sub(Vector2 other) {
        this .x -= other.x;
        this .y -= other.y;
        return this ;
    }

add()和 sub()方法有两种风格:在一种情况下,它们使用两个 float 参数,而在另一种情况下,它们使用另一个 Vector2 实例。这四个方法都返回了对这个向量的引用,这样我们就可以链接操作了。

    public Vector2 mul(float scalar) {
        this .x *= scalar;
        this .y *= scalar;
        return this ;
    }

mul()方法只是将向量的 x 和 y 分量与给定的标量值相乘,并返回对向量本身的引用,用于链接。

    public float len() {
        return FloatMath.*sqrt*(x * x + y * y);
    }

len()方法精确计算向量的长度,就像前面定义的那样。注意,我们使用 FloatMath 类,而不是 Java SE 提供的普通数学类。这是一个特殊的 Android API 类,使用浮点数而不是双精度数工作,至少在旧的 Android 版本上,它比等价的数学类要快一点。

    public Vector2 nor() {
        float len = len();
        if (len != 0) {
            this .x /= len;
            this .y /= len;
        }
        return this ;
    }

nor()方法将向量标准化为单位长度。我们在内部使用 len()方法首先计算长度。如果是零,我们可以早点退出,避免被零除。否则,我们将向量的每个分量除以它的长度,得到一个单位长度的向量。为了链接,我们再次返回对这个向量的引用。

    public float angle() {
        float angle = (float ) Math.*atan2*(y, x) **TO_DEGREES*;
        if (angle < 0)
            angle += 360;
        return angle;
    }

angle()方法使用 atan2()方法计算向量和 x 轴之间的角度,如前所述。我们必须使用 Math.atan2()方法,因为 FloatMath 类没有这个方法。返回的角度以弧度给出,所以我们通过乘以 TO_DEGREES 将其转换为度数。如果角度小于零,我们给它加上 360,这样我们可以返回一个范围在 0 到 360 之间的值。

    public Vector2 rotate(float angle) {
        float rad = angle **TO_RADIANS*;
        float cos = FloatMath.*cos*(rad);
        float sin = FloatMath.*sin*(rad);

        float newX = this .x * cos - this .y * sin;
        float newY = this .x * sin + this .y * cos;

        this .x = newX;
        this .y = newY;

        return this ;
    }

rotate()方法只是将向量围绕原点旋转给定的角度。因为 FloatMath.cos()和 FloatMath.sin()方法希望角度以弧度给出,所以我们首先将它们从角度转换为弧度。接下来,我们使用之前定义的等式来计算向量的新 x 和 y 分量,然后返回向量本身,再次用于链接。

    public float dist(Vector2 other) {
        float distX = this .x - other.x;
        float distY = this .y - other.y;
        return FloatMath.*sqrt*(distX * distX + distY * distY);
    }

    public float dist(float x, float y) {
        float distX = this .x - x;
        float distY = this .y - y;
        return FloatMath.*sqrt*(distX * distX + distY * distY);
    }

}

最后,我们有两种方法来计算这个向量和另一个向量之间的距离。

这就是我们闪亮的 Vector2 类,我们可以用它在接下来的代码中表示位置,速度,距离和方向。为了对您的新类有所了解,我们将在一个简单的例子中使用它。

一个简单的用法示例

这里有一个简单测试的建议:

  • 我们创造了一种用三角形表示的大炮,它在我们的世界中有一个固定的位置。三角形的中心将位于(2.4,0.5)。
  • 每次触摸屏幕时,我们都希望旋转三角形以面对触摸点。
  • 我们的视图截锥将显示(0,0)和(4.8,3.2)之间的区域。我们不在像素坐标中操作,而是定义自己的坐标系,其中一个单位等于一米。此外,我们将在横向模式下工作。

有几件事我们需要考虑。我们已经知道如何在模型空间中定义一个三角形——我们可以为此使用一个顶点实例。我们的加农炮应该在默认方向上以 0 度角指向右边。图 8-4 显示了模型空间中的大炮三角形。

9781430246770_Fig08-04.jpg

图 8-4。模型空间中的大炮三角

当我们渲染三角形时,我们只需使用 glTranslatef()将它移动到它在世界上的位置(2.4,0.5)。

我们还想旋转加农炮,使其尖端指向屏幕上我们最后触摸的点的方向。为此,我们需要找出世界上最后一次触摸事件的位置。GLGame.getInput()。getTouchX()和 getTouchY()方法将返回屏幕坐标中的触摸点,原点在左上角。Input 实例不会像在 Mr. Nom 中那样将事件缩放到一个固定的坐标系中。Intead,我们需要将这些触摸坐标转换成世界坐标。我们已经在 Nom 先生的触摸处理程序和基于画布的游戏框架中做到了这一点;这次唯一的不同是坐标系范围小了一点,我们世界的 y 轴指向上方。下面的伪代码展示了我们如何在一般情况下实现转换,这与第五章中的触摸处理程序几乎相同:

worldX = (touchX / Graphics.getWidth()) * viewFrustmWidth
worldY = (1 - touchY / Graphics.getHeight()) * viewFrustumHeight

我们通过将触摸坐标除以屏幕分辨率,将触摸坐标归一化到范围(0,1)。在 y 坐标的情况下,我们从 1 中减去触摸事件的归一化 y 坐标来翻转 y 轴。剩下的就是通过视图截锥的宽度和高度缩放 x 和 y 坐标——在我们的例子中,是 4.8 和 3.2。从 worldX 和 worldY 中,我们可以构建一个 Vector2,它存储触摸点在您的世界坐标中的位置。

我们需要做的最后一件事是计算佳能旋转的角度。看一下图 8-5 ,它显示了我们的大炮和世界坐标中的一个接触点。

9781430246770_Fig08-05.jpg

图 8-5。我们的加农炮在默认状态下,指向右边(角度= 0),一个触摸点,以及我们需要旋转加农炮的角度。矩形是我们的视图截锥将在屏幕上显示的世界区域:(0,0)到(4.8,3.2)

我们需要做的只是创建一个从大炮中心(2.4,0.5)到接触点的距离向量(记住,我们必须从接触点减去大炮中心,而不是相反)。一旦我们有了距离向量,我们就可以用 Vector2.angle()方法计算角度。这个角度可以用来通过 glRotatef()旋转你的模型。

让我们编码。清单 8-2 显示了 CannonScreen 的相关部分,CannonTest 类的一部分,添加了适当的注释。

清单 8-2。摘自 CannonTest.java;触摸屏幕将旋转大炮

class CannonScreen extends Screen {
    float FRUSTUM_WIDTH = 4.8f;
    float FRUSTUM_HEIGHT = 3.2f;
    GLGraphics glGraphics;
    Vertices vertices;
    Vector2 cannonPos = new Vector2(2.4f, 0.5f);
    float cannonAngle = 0;
    Vector2 touchPos = new Vector2();

如前所述,我们从定义*截头体的宽度和高度的两个常数开始。接下来,我们包括一个 GLGraphics 实例和一个 Vertices 实例。我们将加农炮的位置存储在 Vector2 中,将角度存储在 float 中。最后,我们有另一个 Vector2,可以用来计算从原点到接触点的向量与 x 轴之间的角度。

为什么我们将 Vector2 实例存储为类成员?我们可以在需要的时候实例化它们,但是那会让垃圾收集器生气。一般来说,我们尝试实例化所有 Vector2 实例一次,然后尽可能频繁地重用它们。

    public CannonScreen(Game game) {
        super (game);
        glGraphics = ((GLGame) game).getGLGraphics();
        vertices = new Vertices(glGraphics, 3, 0, false , false );
        vertices.setVertices( new float [] { -0.5f, -0.5f,
                                            0.5f, 0.0f,
                                           -0.5f, 0.5f }, 0, 6);
    }

在构造函数中,我们获取 GLGraphics 实例并根据图 8-4 创建三角形。

    @Override
    public void update(float deltaTime) {
        List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
        game.getInput().getKeyEvents();

        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);

            touchPos.x = (event.x / (float ) glGraphics.getWidth())
                    * FRUSTUM_WIDTH;
            touchPos.y = (1 - event.y / (float ) glGraphics.getHeight())
                    * FRUSTUM_HEIGHT;
            cannonAngle = touchPos.sub(cannonPos).angle();
        }

    }

接下来是 update()方法。我们简单地循环所有触摸事件,并计算大炮的角度。这可以分两步完成。首先,如前所述,我们将触摸事件的屏幕坐标转换到世界坐标系。我们将触摸事件的世界坐标存储在 touchPoint 成员中。然后我们从接触点向量中减去大炮的位置,这将产生图 8-5 中描绘的向量。然后我们计算这个向量和 x 轴之间的角度。这就是全部了!

    @Override
    public void present(float deltaTime) {

        GL10 gl = glGraphics.getGL();
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        gl.glOrthof(0, FRUSTUM_WIDTH, 0, FRUSTUM_HEIGHT, 1, -1);
        gl.glMatrixMode(GL10.*GL_MODELVIEW*);
        gl.glLoadIdentity();
        gl.glTranslatef(cannonPos.x, cannonPos.y, 0);
        gl.glRotatef(cannonAngle, 0, 0, 1);
        vertices.bind();
        vertices.draw(GL10.*GL_TRIANGLES*, 0, 3);
        vertices.unbind();
    }

present()方法做着和以前一样无聊的事情。我们设置视口,清空屏幕,使用我们的*截头体的宽度和高度设置正交投影矩阵,并告诉 OpenGL ES 所有后续的矩阵操作都将在模型-视图矩阵上进行。我们将一个单位矩阵加载到模型-视图矩阵中来“清除”它。接下来,我们将(相同的)模型-视图矩阵乘以一个*移矩阵,这将把你的三角形的顶点从模型空间移动到世界空间。我们用在 update()方法中计算的角度调用 glRotatef(),这样我们的三角形在被转换之前就在模型空间中旋转了。请记住,变换是以相反的顺序应用的,即首先应用最后指定的变换。最后,我们绑定三角形的顶点,渲染它,然后解除绑定。

    @Override
    public void pause() {

    }

    @Override
    public void resume() {

    }

    @Override
    public void dispose() {

    }

}

现在我们有了一个可以跟随你每次触摸的三角形。图 8-6 显示触摸屏幕左上角后的输出。

9781430246770_Fig08-06.jpg

图 8-6。我们的三角炮对左上角的触摸事件做出反应

请注意,我们是在加农炮位置渲染三角形还是渲染映射到加农炮图像的矩形纹理并不重要——OpenGL ES 并不在乎。我们在 present()方法中也有所有的矩阵运算。事实是,这样更容易跟踪 OpenGL ES 状态,并且我们可以在一个 present()调用中使用多个视图截锥(例如,一个视图截锥以米为单位设置世界,用于呈现我们的世界,另一个视图截锥以像素为单位设置世界,用于呈现 UI 元素)。对性能的影响并没有那么大,如第七章中所述,所以大多数时候这样做是可以接受的。请记住,如果需要的话,您可以对此进行优化。

从现在开始,Vectors 将是你最好的朋友。您可以使用它们来指定您世界中的几乎所有东西。你也将能够用向量做一些非常基本的物理。如果大炮不能发射,那它有什么用,对吗?

2D 的一点物理知识

在这一节,我们将讨论一个非常简单和有限的物理学版本。游戏就是要做好假货。他们尽可能作弊,以避免潜在的繁重计算。游戏中物体的行为不需要 100%物理准确;它只需要足够好,看起来可信。有时你甚至不想要物理上准确的行为(也就是说,你可能想让一组物体向下坠落,而另一组更疯狂的物体向上坠落)。

即使是最初的超级马里奥兄弟也至少使用了一些牛顿物理学的基本原理。这些原则真的很简单,也很容易实现。将只讨论为我们的游戏对象实现简单物理模型所需的绝对最小值。

牛顿和欧拉,永远的好朋友

我们主要关心的是所谓的点质量的运动物理学。运动物理学描述了物体的位置、速度和加速度随时间的变化。点质量意味着所有物体都*似为一个具有相关质量的无穷小的点。我们不必处理像扭矩这样的东西——物体围绕其质心的旋转速度——因为这是一个复杂的问题领域,已经有不止一本完整的书写了。我们只看物体的这三个属性:

  • 位置:表示为某个空间中的向量——在我们的例子中,是 2D 空间。通常位置以米为单位。
  • 速度:物体每秒钟位置的变化。速度被给定为 2D 速度向量,它是对象前进的单位长度方向向量和对象将移动的速度的组合,以米每秒(m/s)给定。注意,速度只是决定了速度向量的长度;如果你用速度归一化速度向量,你会得到一个很好的单位长度方向向量。
  • 加速度:物体每秒钟的速度变化。我们可以用一个只影响速度的标量(速度向量的长度)来表示,或者用一个 2D 向量来表示,这样我们就可以在 x 轴和 y 轴上有不同的加速度。这里我们将选择后者,因为它允许我们更容易地使用诸如弹道学之类的东西。加速度通常以米/秒为单位(m/s 2 )。不,这不是打字错误,你改变了速度,以米每秒为单位。

当我们知道一个物体在给定时间点的属性时,我们可以整合它们来模拟物体随着时间的推移在世界中的路径。这听起来可能有点吓人,但是我们已经和 Nom 先生以及我们的 BobTest 类一起做过了。在那些情况下,我们没有使用加速度;我们简单地将速度设为一个固定的矢量。下面是我们如何综合物体的加速度、速度和位置:

Vector2 position = new Vector2();
Vector2 velocity = new Vector2();
Vector2 acceleration = new Vector2(0, -10);
while (simulationRuns) {
   float deltaTime = getDeltaTime();
   velocity.add(acceleration.x * deltaTime, acceleration.y * deltaTime);
   position.add(velocity.x * deltaTime, velocity.y * deltaTime);
}

这叫数值欧拉积分,是游戏中使用的最直观的积分方法。我们从(0,0)处的位置开始,速度给定为(0,0),加速度为(0,–10),这意味着速度在 y 轴上将增加 1 m/s。在 x 轴上没有运动。在我们进入积分循环之前,我们的对象是静止的。在循环中,我们首先根据加速度乘以增量时间来更新速度,然后根据速度乘以增量时间来更新位置。这就是“整合 ??”这个可怕的大词的全部含义。

和往常一样,这还不到故事的一半。欧拉积分是一种“不稳定”的积分方法,应尽可能避免。通常,人们会使用所谓的 verlet integration 的变体,只是稍微复杂一点。然而,为了我们的目的,更简单的欧拉积分就足够了。

力和质量

你可能想知道加速度从何而来。这个问题问得好,答案很多。汽车的加速度来自发动机。发动机给汽车施加一个力,使它加速。但这还不是全部。由于重力,汽车也会加速向地心运动。唯一阻止它坠入地心的是地面,它无法穿过地面。地面抵消了这种重力。大意是这样的:

力=质量×加速度

您可以将其重新整理为以下等式:

加速度=力/质量

力的单位是国际单位制牛顿。(猜猜这是谁想出来的。)如果你指定加速度为矢量,那么你也必须指定力为矢量。因此,力可以有方向。例如,重力沿(0,–1)方向向下拉动。加速度也取决于物体的质量。一个物体的质量越大,你需要施加越大的力才能使它加速到和一个质量较小的物体一样快。这是前面方程的直接结果。

然而,对于简单的游戏,我们可以忽略质量和力,直接处理速度和加速度。上一节中的伪代码将加速度设置为(0,–10)m/s2(同样不是错别字),这大致是一个物体向地球下落时的加速度,无论其质量如何(忽略空气阻力之类的东西)。是真的……问问伽利略!

理论上,到处玩

我们将用前面的例子来玩一个向地球坠落的物体。让我们假设我们让循环迭代十次,getDeltaTime()将总是返回 0.1 s。我们将获得每次迭代的以下位置和速度:

time=0.1, position=(0.0,-0.1), velocity=(0.0,−1.0)
time=0.2, position=(0.0,-0.3), velocity=(0.0,-2.0)
time=0.3, position=(0.0,-0.6), velocity=(0.0,-3.0)
time=0.4, position=(0.0,-1.0), velocity=(0.0,-4.0)
time=0.5, position=(0.0,-1.5), velocity=(0.0,-5.0)
time=0.6, position=(0.0,-2.1), velocity=(0.0,-6.0)
time=0.7, position=(0.0,-2.8), velocity=(0.0,-7.0)
time=0.8, position=(0.0,-3.6), velocity=(0.0,-8.0)
time=0.9, position=(0.0,-4.5), velocity=(0.0,-9.0)
time=1.0, position=(0.0,-5.5), velocity=(0.0,-10.0)

1 秒钟后,我们的物体将下落 5.5 米,速度为(0,-10)米/秒,直接向下移动到地球的核心(当然,直到它撞到地面)。

我们的物体会不断增加向下的速度,因为我们没有考虑空气阻力。(如前所述,你很容易骗过自己的系统。)我们可以简单地通过检查当前速度长度来强制一个最大速度,它等于物体的速度。

无所不知的维基百科指出,一个自由落体的人的最大速度或极限速度大约是每小时 125 英里。将其转换为米/秒(125 × 1.6 × 1000 / 3600),我们得到 55.5 米/秒。为了使我们的模拟更加真实,我们可以将环路修改如下:

while (simulationRuns) {
   float deltaTime = getDeltaTime();
   if (velocity.len() < 55.5)
      velocity.add(acceleration.x * deltaTime, acceleration.y * deltaTime);
   position.add(velocity.x * deltaTime, velocity.y * deltaTime);
}

只要物体的速度(速度向量的长度)小于 55.5 m/s,我们就可以通过加速度来增加速度。当我们达到终极速度时,我们简单地停止加速度的增加。这种简单的速度上限是许多游戏中经常使用的技巧。

我们可以通过在 x 方向增加另一个加速度,比如说(–1,0) m/s 2 来将风加入到方程中。为此,我们将重力加速度加到风加速度上,然后再加到速度上:

Vector2 gravity = new Vector2(0,-10);
Vector2 wind = new Vector2(-1,0);
while (simulationRuns) {
   float deltaTime = getDeltaTime();
   acceleration.set(gravity).add(wind);
   if (velocity.len() < 55.5)
      velocity.add(acceleration.x * deltaTime, acceleration.y * deltaTime);
   position.add(velocity.x * deltaTime, velocity.y * deltaTime);
}

我们也可以完全忽略加速度,让我们的物体有一个固定的速度。我们在 BobTest 中正是这样做的。只有当每个摆锤碰到边缘时,我们才改变它的速度,而且是立即改变。

实际上是在玩

即使是这个简单的模型,可能性也是无穷无尽的。在这一节中,我们将扩展本章前面的小 CannonTest,这样我们就可以发射炮弹了。下面是我们想做的:

  • 只要我们在屏幕上拖动手指,佳能就会跟着移动。这就是我们如何指定你击球的角度。
  • 一旦我们收到一个触发事件,我们就可以向大炮指向的方向发射炮弹。炮弹的初速度将是大炮的方向和炮弹从开始就有的速度的组合。速度等于大炮和接触点之间的距离。我们接触得越远,炮弹就会飞得越快。
  • 只要没有新的修饰事件,炮弹就会飞起来。
  • 我们可以将你的视见体的大小加倍到(0,0)到(9.6,6.4),这样我们可以看到我们世界的更多部分。此外,我们可以将大炮放置在(0,0)处。注意,现在世界上所有的单位都是米。
  • 我们可以将炮弹渲染成一个大小为 0.2×0.2 米或 20×20 厘米的红色矩形——足够接*真实的炮弹。当然,你们当中的海盗可能会选择更真实的尺寸。

最初,炮弹的位置将是(0,0)—与大炮的位置相同。速度也将是(0,0)。由于我们在每次更新中应用重力,炮弹将简单地垂直落下。

一旦接收到触发事件,我们将球的位置设置回(0,0)并将其初始速度设置为(Math.cos(cannonAngle),Math.sin(cannonAngle))。这将确保炮弹沿着大炮指向的方向飞行。此外,我们简单地通过将速度乘以接触点和大炮之间的距离来设置速度。接触点离大炮越*,炮弹飞得越慢。

听起来很简单,所以现在我们可以尝试实现它。将 CannonTest.java 文件中的代码复制到名为 CannonGravityTest.java 的新文件中。将该文件中包含的类重命名为 CannonGravityTest 和 CannonGravityScreen。清单 8-3 展示了 CannonGravityScreen 类,为了清楚起见添加了一些注释。

清单 8-3。 摘自 CannonGravityTest

class CannonGravityScreen extends Screen {
    float FRUSTUM_WIDTH = 9.6f;
    float FRUSTUM_HEIGHT = 6.4f;
    GLGraphics glGraphics;
    Vertices cannonVertices;
    Vertices ballVertices;
    Vector2 cannonPos = new Vector2();
    float cannonAngle = 0;
    Vector2 touchPos = new Vector2();
    Vector2 ballPos = new Vector2(0,0);
    Vector2 ballVelocity = new Vector2(0,0);
    Vector2 gravity = new Vector2(0,-10);

没什么变化。我们简单地将视见体的大小加倍,并通过将视见体宽度和视见体高度分别设置为 9.6 和 6.2 来反映这一点。这意味着我们可以看到一个 9.2×6.2 米的长方形世界。由于我们也想绘制炮弹,我们添加了另一个顶点实例,称为 ballVertices,它将保存炮弹矩形的四个顶点和六个索引。新成员 ballPos 和 ballVelocity 存储炮弹的位置和速度,成员 gravity 是重力加速度,它将在我们程序的生命周期内保持恒定(0,–10)m/s2

    public CannonGravityScreen(Game game) {
        super (game);
        glGraphics = ((GLGame) game).getGLGraphics();
        cannonVertices = new Vertices(glGraphics, 3, 0, false , false );
        cannonVertices.setVertices( new float [] { -0.5f, -0.5f,
                                            0.5f, 0.0f,
                                           -0.5f, 0.5f }, 0, 6);
        ballVertices = new Vertices(glGraphics, 4, 6, false , false );
        ballVertices.setVertices( new float [] { -0.1f, -0.1f,
                                                0.1f, -0.1f,
                                                0.1f,  0.1f,
                                               -0.1f,  0.1f }, 0, 8);
        ballVertices.setIndices( new short [] {0, 1, 2, 2, 3, 0}, 0, 6);
    }

在构造函数中,我们简单地为炮弹的矩形创建额外的顶点实例。我们在模型空间中用顶点(–0.1、–0.1)、(0.1、–0.1)、(0.1、0.1)和(–0.1、0.1)来定义它。我们使用索引绘图,因此在这种情况下指定六个顶点。

    @Override
    public void update(float deltaTime) {
        List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
        game.getInput().getKeyEvents();

        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);

            touchPos.x = (event.x / (float ) glGraphics.getWidth())
                    * FRUSTUM_WIDTH;
            touchPos.y = (1 - event.y / (float ) glGraphics.getHeight())
                    * FRUSTUM_HEIGHT;
            cannonAngle = touchPos.sub(cannonPos).angle();

            if (event.type == TouchEvent.*TOUCH_UP*) {
                float radians = cannonAngle * Vector2.*TO_RADIANS*;
                float ballSpeed = touchPos.len();
                ballPos.set(cannonPos);
                ballVelocity.x = FloatMath.*cos*(radians) * ballSpeed;
                ballVelocity.y = FloatMath.*sin*(radians) * ballSpeed;
            }

        }

        ballVelocity.add(gravity.x * deltaTime, gravity.y * deltaTime);
        ballPos.add(ballVelocity.x * deltaTime, ballVelocity.y * deltaTime);
    }

update()方法仅略有变化。世界坐标中接触点的计算和加农炮的角度仍然相同。第一个添加是事件处理循环中的 if 语句。万一我们得到一个润色事件,我们就准备好要发射的炮弹。我们将大炮的瞄准角度转换为弧度,因为我们稍后将使用 FastMath.cos()和 FastMath.sin()。接下来,我们计算大炮和接触点之间的距离。这将是炮弹的速度。我们将球的位置设置为大炮的位置。最后,我们计算炮弹的初速度。我们使用正弦和余弦,如前一节所讨论的,从加农炮的角度构建一个方向向量。我们将这个方向向量乘以炮弹的速度,得到最终的炮弹速度。这很有趣,因为炮弹从一开始就有这个速度。在现实世界中,在给定空气阻力、重力和大炮施加的力的情况下,炮弹当然会从 0 米/秒加速到它所能达到的任何速度。不过,我们可以在这里作弊,因为加速将发生在一个非常小的时间窗口内(几百毫秒)。我们在 update()方法中做的最后一件事是更新炮弹的速度,并基于此调整其位置。

    @Override
    public void present(float deltaTime) {

        GL10 gl = glGraphics.getGL();
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        gl.glMatrixMode(GL10.*GL_PROJECTION*);

        gl.glLoadIdentity();
        gl.glOrthof(0, FRUSTUM_WIDTH, 0, FRUSTUM_HEIGHT, 1, -1);
        gl.glMatrixMode(GL10.*GL_MODELVIEW*);

        gl.glLoadIdentity();
        gl.glTranslatef(cannonPos.x, cannonPos.y, 0);
        gl.glRotatef(cannonAngle, 0, 0, 1);
        gl.glColor4f(1,1,1,1);
        cannonVertices.bind();
        cannonVertices.draw(GL10.*GL_TRIANGLES*, 0, 3);
        cannonVertices.unbind();

        gl.glLoadIdentity();
        gl.glTranslatef(ballPos.x, ballPos.y, 0);
        gl.glColor4f(1,0,0,1);
        ballVertices.bind();
        ballVertices.draw(GL10.*GL_TRIANGLES*, 0, 6);
        ballVertices.unbind();
    }

在 present()方法中,我们简单地添加了炮弹矩形的渲染。我们在渲染 cannon 的三角形之后这样做,这意味着我们必须在渲染矩形之前“清理”模型-视图矩阵。我们使用 glLoadIdentity()来实现这一点,然后使用 glTranslatef()在球的当前位置将炮弹的矩形从模型空间转换到世界空间。

    @Override
    public void pause() {

    }

    @Override
    public void resume() {

    }

    @Override
    public void dispose() {

    }

}

如果你运行这个例子并触摸几次屏幕,你会对炮弹的飞行有很好的感觉。图 8-7 显示了输出(这并不令人印象深刻,因为它是一个静止的图像)。

9781430246770_Fig08-07.jpg

图 8-7。射出红色长方形的三角炮。印象深刻!

对你的目的来说,这些物理知识已经足够了。有了这个简单的模型,我们可以模拟的远不止炮弹。例如,超级马里奥也可以用同样的方式模拟。如果你玩过超级马里奥兄弟,你可能会注意到马里奥在奔跑时需要一点时间才能达到最大速度。这可以通过非常快的加速度和速度上限来实现,如上一节的伪代码所示。跳跃的实现方式与发射炮弹非常相似。马里奥的当前速度将由 y 轴上的初始跳跃速度来调整(请记住,您可以像添加任何其他向量一样添加速度)。你总是施加一个负的 y 加速度(重力),这使得他在跳跃后回到地面,或者掉进一个坑里。x 方向的速度不受 y 轴的影响。你仍然可以按左右键来改变 x 轴的速度。这个简单模型的美妙之处在于,它允许您用很少的代码实现非常复杂的行为。当你写下一个游戏时,你可以使用这种类型的物理学。

简单地发射炮弹并不好玩。你希望能够用炮弹击中物体。为此,您需要一种叫做碰撞检测的东西,我们将在下一节中研究它。

2D 中的碰撞检测和物体表示

一旦你的世界中有了移动的物体,你希望它们相互作用。一种这样的交互模式是简单的碰撞检测。当两个物体以某种方式重叠时,据说它们在碰撞。当你检查 Nom 先生是咬了自己还是吃了墨水渍时,我们已经在 Nom 先生身上做了一点碰撞检测。

碰撞检测伴随着碰撞响应:一旦我们确定两个物体发生了碰撞,我们需要通过以合理的方式调整我们物体的位置和/或移动来对碰撞做出响应。例如,当超级马里奥跳到古姆巴上时,古姆巴会去古姆巴天堂,马里奥会再跳一小段。一个更详细的例子是两个或多个台球的碰撞和响应。我们现在不需要进入这种碰撞响应,因为它对我们的目的来说是多余的。我们的碰撞响应通常包括改变对象的状态(例如,让对象爆炸或死亡,收集硬币,设置分数,等等)。这种类型的反应是依赖于游戏的,所以不会在这一节讨论。

那么我们如何判断两个物体是否发生了碰撞呢?首先,我们需要考虑何时检查碰撞。如果我们的对象遵循一个简单的物理模型,如前一节所述,我们可以在移动当前帧和时间步长的所有对象后检查碰撞。

边界形状

一旦我们有了对象的最终位置,我们就可以执行碰撞测试,这归结为重叠测试。但是重叠的是什么呢?我们的每一个物体都需要有某种数学定义的形式或形状来为它提供界限。在这种情况下,正确的术语是边界形状。图 8-8 显示了一些边界形状的选择。

9781430246770_Fig08-08.jpg

图 8-8。鲍勃周围的各种边界形状

图 8-8 中三种边界形状的属性如下:

  • 三角形网格 : 通过用几个三角形*似物体的轮廓,尽可能紧密地限制物体。它需要最大的存储空间,而且很难构建,测试起来也很昂贵。然而,它给出了最精确的结果。我们不一定使用相同的三角形进行渲染,而只是简单地存储它们进行碰撞检测。网格可以存储为顶点列表,每三个后续的顶点形成一个三角形。为了节省内存,我们也可以使用索引顶点列表。
  • 轴对齐边界框 : 这通过一个轴对齐的矩形来限制对象,这意味着底部和顶部边缘总是与 x 轴对齐,而左侧和右侧边缘总是与 y 轴对齐。测试起来也很快,但不如三角形网格精确。边界框通常以其左下角位置加上其宽度和高度的形式存储。(在 2D 的例子中,这也被称为边界矩形。)
  • 包围圆 : 用能包含物体的最小圆来包围物体。它的测试速度非常快,但它是最不精确的边界形状。圆通常以圆心位置和半径的形式存储。

我们游戏中的每个物体都有一个包围它的边界形状,还有它的位置、比例和方向。当然,当我们移动对象时,我们需要根据对象的位置、比例和方向来调整边界形状的位置、比例和方向,比如在物理集成步骤中。

调整位置变化很容易:我们只需相应地移动边界形状。在三角形网格的情况下,移动每个顶点;在边框的情况下,移动左下角;在边界圆的情况下,移动中心。

缩放绑定图形有点困难。我们需要定义我们缩放的点。这通常是物体的位置,常给定为物体的中心。如果我们使用这个约定,那么伸缩就很容易。对于三角形网格,我们缩放每个顶点的坐标;对于边框,我们缩放其宽度、高度和左下角位置;对于边界圆,我们缩放其半径(圆心等于对象的中心)。

旋转边界形状还取决于旋转点的定义。使用刚才提到的约定(其中物体中心为旋转点),旋转也变得容易。在三角形网格的情况下,我们简单地围绕物体的中心旋转所有的顶点。在边界圆的例子中,我们不需要做任何事情,因为无论我们如何旋转我们的对象,半径都会保持不变。边框更复杂一些。我们需要构建所有四个角点,旋转它们,然后找到包围这四个点的轴对齐的边界矩形。图 8-9 显示了旋转后的三个边界形状。

9781430246770_Fig08-09.jpg

图 8-9。旋转的边界形状,以对象的中心为旋转点

虽然旋转三角形网格或边界圆相当容易,但轴对齐边界框的结果并不令人满意。请注意,原始对象的边界框比其旋转版本更紧密。有一种边界框变体,称为定向边界形状,,它更适合旋转,但它的缺点是更难计算。到目前为止,所涉及的边界形状已经足够满足我们的需求(以及大多数游戏)。如果你想更多地了解定向边界形状,并真正深入到碰撞检测,我们推荐克里斯特·埃里克森的书实时碰撞检测

另一个问题是:我们如何首先为 Bob 创建边界形状?

构建边界形状

在图 8-8 所示的例子中,我们简单地根据 Bob 的图像手工构建了边界形状。但是如果鲍勃的图像是以像素为单位的,而你的世界是以米为单位的呢?这个问题的解决方案涉及到规范化和模型空间。想象一下当我们用 OpenGL ES 渲染 Bob 时,我们在模型空间中为他使用的两个三角形。该矩形在模型空间中以原点为中心,并具有与 Bob 的纹理图像相同的纵横比(宽度/高度)(即,纹理贴图中为 32×32 像素,而模型空间中为 2×2 m)。现在,我们可以应用 Bob 的纹理,并计算出模型空间中边界形状的点的位置。图 8-10 显示了我们如何在模型空间中构建鲍勃周围的边界形状。

9781430246770_Fig08-10.jpg

图 8-10。在模型空间中围绕 Bob 的边界形状

这个过程可能看起来有点麻烦,但涉及的步骤并不那么难。我们首先要记住的是纹理映射是如何工作的。我们在纹理空间中为 Bob 的矩形(由两个三角形组成)的每个顶点指定纹理坐标。纹理空间中纹理图像的左上角在(0,0),左下角在(1,1),不管图像的实际宽度和高度是多少像素。要从图像的像素空间转换到纹理空间,我们可以使用这个简单的转换:

u = x / imageWidth

v = y / imageHeight

其中 u 和 v 是图像空间中由 x 和 y 给出的像素的纹理坐标。imageWidth 和 imageHeight 设置为图像的像素尺寸(在 Bob 的例子中为 32×32)。图 8-11 显示了 Bob 的图像中心如何映射到纹理空间。

9781430246770_Fig08-11.jpg

图 8-11。将像素从图像空间映射到纹理空间

纹理应用于您在模型空间中定义的矩形。在图 8-10 的例子中,左上角在(–1,1),右下角在(1,–1)。我们可以在我们的世界中使用米作为单位,因此矩形的宽度和高度为 2 m。此外,我们知道左上角的纹理坐标为(0,0),右下角的纹理坐标为(1,1),因此我们可以将完整的纹理映射到 Bob。情况不会总是这样,你会在后面的纹理贴图部分看到。

现在我们需要一种通用的方法来从纹理空间映射到模型空间。我们可以通过在纹理空间和模型空间中将我们的映射限制为仅与轴对齐的矩形来使我们的生活变得简单一些。假设纹理空间中的轴对齐矩形区域被映射到模型空间中的轴对齐矩形。对于转换,我们需要知道模型空间中矩形的宽度和高度,以及纹理空间中矩形的宽度和高度。在我们的 Bob 示例中,我们在模型空间中有一个 2×2 的矩形,在纹理空间中有一个 1×1 的矩形(因为我们将完整的纹理映射到矩形)。我们还需要知道每个矩形左上角在各自空间中的坐标。对于模型空间矩形,这是(–1,1);对于纹理空间矩形,它是(0,0)(同样,因为我们映射的是完整的纹理,而不仅仅是一部分)。有了这些信息,以及我们要映射到模型空间的像素的 u 和 v 坐标,我们就可以用这两个等式进行转换:

MX =(u-minu)/(twidth)×mwidth+minx

my = (1 − ((v − minV) / (tHeight))× mHeight − minY

变量 u 和 v 是在先前从像素空间到纹理空间的变换中计算的坐标。变量 minU 和米女是从纹理空间映射的区域左上角的坐标。变量 tWidth 和 the height 是你的纹理空间区域的宽度和高度。变量 mWidth 和 mHeight 是模型空间矩形的宽度和高度。变量 minX 和 minY 是——您猜对了——模型空间中矩形左上角的坐标。最后,mx 和 my 是模型空间中的变换坐标。

这些方程采用 u 和 v 坐标,将它们映射到范围 0 到 1,然后在模型空间中缩放和定位它们。图 8-12 显示了纹理空间中的一个纹理元素,以及它如何映射到模型空间中的一个矩形。在侧面,你可以看到 tWidth 和 tHeight,以及 mWidth 和 mHeight。每个矩形的左上角对应于纹理空间中的(minU,米女)和模型空间中的(minX,minY)。

9781430246770_Fig08-12.jpg

图 8-12。纹理空间到模型空间的映射

代入前两个方程,我们可以直接从像素空间到模型空间:

MX =((x/image width)-minu)/(twidth)* mwidth+minx

my = (1 − ((y / imageHeight) − minV) / (tHeight))* mHeight − minY

我们可以使用这两个方程,根据通过纹理映射映射到矩形的图像来计算对象的边界形状。在三角形网格的情况下,这可能有点乏味;包围矩形和包围圆形的情况要容易得多。通常,你不需要走这条艰难的路,而是创建你的纹理,使边界矩形至少具有与你通过 OpenGL ES 渲染的矩形相同的纵横比。这样,您可以直接从对象的图像维度构建边框。边界圆也是如此。

现在你应该知道如何为你的 2D 物体构造一个合适的边界形状。创建图形资源时手动定义这些边界形状大小,并定义游戏世界中对象的单位和大小。然后,您可以在代码中使用这些大小来碰撞对象。

游戏对象属性

鲍勃变得更胖了。除了我们用于渲染的网格(映射到 Bob 的图像纹理的矩形),我们现在有了第二个数据结构,以某种形式保存他的边界。重要的是要认识到,当我们在模型空间中在 Bob 的映射版本之后建模边界时,实际的边界与 Bob 的矩形映射到的纹理区域无关。当然,当我们创建边界形状时,我们试图在纹理中与 Bob 图像的轮廓紧密匹配。然而,纹理图像是 32×32 像素还是 128×128 像素并不重要。因此,我们世界中的物体有三个属性组:

  • 它的位置、方向、比例、速度和加速度:有了这些,我们可以应用上一节的物理模型。当然,有些对象可能是静态的,因此只有位置、方向和比例。通常,我们甚至可以忽略方向和比例。物体的位置通常与模型空间中的原点重合,如前面的图 8-10 所示。这使得一些计算更容易。
  • 它的边界形状(通常在模型空间中围绕物体的中心构建):这与它的位置一致,并与其方向和比例对齐,如图图 8-10 。这给了我们的对象一个边界,定义了它在世界上的大小。我们可以把这个形状做得尽可能复杂。例如,我们可以把它做成几个边界形状的组合。
  • 它的图形表示:如图图 8-12 所示,我们仍然用两个三角形为 Bob 组成一个矩形,并将他的图像纹理映射到矩形上。矩形在模型空间中定义,但不一定等于边界形状,如图图 8-10 所示。我们发送给 OpenGL ES 的 Bob 的图形矩形略大于 Bob 的边界矩形。

这种属性分离允许我们应用模型-视图-控制器(MVC)模式,如下所示:

  • 在模型方面,我们有 Bob 的物理属性,包括他的位置、比例、旋转、速度、加速度和边界形状。Bob 的位置、比例和方向决定了他的边界形状在世界空间中的位置。
  • 该视图只是采用 Bob 的图形表示(即模型空间中定义的两个纹理映射的三角形),并根据 Bob 的位置、旋转和缩放,在其世界空间位置进行渲染。这里我们可以像以前一样使用 OpenGL ES 矩阵运算。
  • 控制器负责根据用户输入更新 Bob 的物理属性(例如,按下左按钮可以将他向左移动),并根据物理力,例如重力加速度(就像我们在上一节中应用于炮弹一样)。

当然,Bob 的边界形状和他在纹理中的图形表示之间有一些对应关系,因为我们将边界形状建立在图形表示的基础上。因此,我们的 MVC 模式并不完全清晰,但我们可以接受。

宽相位和窄相位碰撞检测

然而,我们仍然不知道如何检查我们的对象和它们的边界形状之间的碰撞。碰撞检测有两个阶段:

  • 广泛阶段 : 在这个阶段,我们试图找出哪些物体可能会发生碰撞。想象一下,有 100 个物体可能会相互碰撞。如果我们天真地选择对照其他对象来测试每个对象,我们需要执行 100 × 100 / 2 的重叠测试。这种简单的重叠测试方法具有 O(n2)的渐*复杂度,这意味着它需要 n2 个步骤才能完成(它实际上可以在一半的步骤中完成,但是渐*复杂度忽略了任何常数)。在一个良好的、非暴力的广泛阶段,我们可以试图找出哪对物体实际上有碰撞的危险。其他对(例如,两个对象相距太远而不会发生碰撞)将不会被检查。我们可以通过这种方式减少计算量,因为窄相位测试通常非常昂贵。
  • 窄阶段 : 一旦我们知道哪对物体可能会碰撞,我们通过对它们的边界形状进行重叠测试来测试它们是否真的碰撞。

我们将首先讨论窄阶段,将宽阶段留到以后,因为宽阶段取决于我们游戏的一些特征,而窄阶段可以独立实现。

窄相位

一旦我们完成了粗略阶段,我们必须检查潜在碰撞物体的边界形状是否重叠。如前所述,我们有几个选择来限制形状。三角形网格是创建时计算量最大、最麻烦的,但是在大多数 2D 游戏中,你不需要它们,只需要使用包围矩形和包围圆形就可以了,所以这就是我们在这里要关注的。

圆形碰撞

边界圆是检查两个对象是否碰撞的最便宜的方法,所以让我们定义一个简单的 Circle 类。清单 8-4 显示了代码。

清单 8-4。Circle.java,一个简单的圆类

package com.badlogic.androidgames.framework.math;

public class Circle {
    public final Vector2 center = new Vector2();
    public float radius;
    public Circle(float x, float y, float radius) {
        this .center.set(x,y);
        this .radius = radius;
    }

}

我们将中心存储为 Vector2,将半径存储为一个简单的浮点数。如何检查两个圆是否重叠?看看图 8 - 13 。

9781430246770_Fig08-13.jpg

图 8-13。两个圆重叠(左),两个圆不重叠(右)

它非常简单,计算效率高。我们需要做的就是计算出两个中心之间的距离。如果距离大于两个半径之和,那么我们知道这两个圆不重叠。在代码中,这将如下所示:

public boolean overlapCircles(Circle c1, Circle c2) {
    float distance = c1.center.dist(c2.center);
    return distance <= c1.radius + c2.radius;
}

首先,我们测量两个中心之间的距离,然后检查距离是否小于或等于半径之和。

我们必须在 Vector2.dist()方法中取*方根。这是不幸的,因为求*方根是一个代价很高的操作。我们能快点吗?是的,我们可以——我们需要做的就是重新定义你的条件:

sqrt(距离 x 距离 x 距离 x 距离+y 距离 x 距离 y) <=半径 1 +半径 2

我们可以通过对不等式的两边取幂来去掉*方根,如下所示:

x 距离 x 距离 x 距离+y 距离 x y 距离< =(半径 1 +半径 2) ×(半径 1 +半径 2)

我们用*方根换另一个右边的加法和乘法。这个好多了。现在我们可以创建一个 Vector2.distSquared()函数,它将返回两个向量之间的*方距离:

public float distSquared(Vector2 other) {
    float distX = this .x - other.x;
    float distY = this .y - other.y;
    return distX * distX + distY * distY;
}

我们还应该添加第二个 distSquared()方法,它采用两个浮点数(x 和 y)而不是一个向量。

overlapCircles()方法如下所示:

public boolean overlapCircles(Circle c1, Circle c2) {
    float distance = c1.center.distSquared(c2.center);
    float radiusSum = c1.radius + c2.radius;
    return distance <= radiusSum * radiusSum;
}

矩形碰撞

对于矩形碰撞,我们首先需要一个可以表示矩形的类。如前所述,我们希望矩形由它的左下角位置加上它的宽度和高度来定义。清单 8-5 就是这么做的。

清单 8-5。Rectangle.java 的,一个长方形类

package com.badlogic.androidgames.framework.math;

public class Rectangle {
    public final Vector2 lowerLeft;
    public float width, height;

    public Rectangle(float x, float y, float width, float height) {
        this .lowerLeft = new Vector2(x,y);
        this .width = width;
        this .height = height;
    }

}

我们将左下角的位置存储在 Vector2 中,宽度和高度存储在两个浮点数中。如何检查两个矩形是否重叠?图 8-14 应该给你一个提示。

9781430246770_Fig08-14.jpg

图 8-14。大量重叠和不重叠的矩形

部分重叠(左)和不重叠(中)的前两种情况很容易。右边的案例是一个惊喜。当然,一个矩形可以完全包含在另一个矩形中。这也可能发生在圆的情况下。但是,如果一个圆包含在另一个圆中,我们的圆重叠测试将返回正确的结果。

起初,在矩形情况下检查重叠看起来很复杂。然而,如果我们使用一点逻辑,我们可以创建一个非常简单的测试。下面是检查两个矩形之间重叠的最简单方法:

public boolean overlapRectangles(Rectangle r1, Rectangle r2) {
    if (r1.lowerLeft.x < r2.lowerLeft.x + r2.width &&
       r1.lowerLeft.x + r1.width > r2.lowerLeft.x &&
       r1.lowerLeft.y < r2.lowerLeft.y + r2.height &&
       r1.lowerLeft.y + r1.height > r2.lowerLeft.y)
        return true ;
    else
        return false ;
}

乍一看,这看起来有点混乱,所以让我们检查一下每个条件。第一个条件规定第一个矩形的左边缘必须在第二个矩形右边缘的左边。下一个条件声明第一个矩形的右边缘必须在第二个矩形左边缘的右边。其他两个条件同样适用于矩形的顶部和底部边缘。如果所有这些条件都满足,那么这两个矩形重叠。用图 8-14 再次检查。它还涵盖了遏制情况。

圆形/矩形碰撞

我们能检查圆和矩形之间的重叠部分吗?是的,我们可以。然而,这有点复杂。看一下图 8-15 。

9781430246770_Fig08-15.jpg

图 8-15。重叠测试一个圆和一个矩形,通过找到矩形上/内最靠*圆的点

测试圆形和矩形之间重叠的总体策略如下:

  • 找出距离圆心最*的矩形上或矩形中的 x 坐标。该坐标可以是矩形左边缘或右边缘上的点,除非圆心包含在矩形中,在这种情况下,最接*的 x 坐标是圆心的 x 坐标。
  • 在距离圆心最*的矩形上或矩形内找到 y 坐标。该坐标可以是矩形上边缘或下边缘上的点,除非圆心包含在矩形中,在这种情况下,最接*的 y 坐标是圆心的 y 坐标。
  • 如果由最*的 x 和 y 坐标组成的点在圆内,则圆和矩形重叠。

虽然没有在图 8-15 中描述,但该方法也适用于完全包含矩形的圆。代码如下:

public boolean overlapCircleRectangle(Circle c, Rectangle r) {
    float closestX = c.center.x;
    float closestY = c.center.y;

    if (c.center.x < r.lowerLeft.x) {
        closestX = r.lowerLeft.x;
    }

    else if (c.center.x > r.lowerLeft.x + r.width) {
        closestX = r.lowerLeft.x + r.width;
    }

    if (c.center.y < r.lowerLeft.y) {
        closestY = r.lowerLeft.y;
    }

    else if (c.center.y > r.lowerLeft.y + r.height) {
        closestY = r.lowerLeft.y + r.height;
    }

    return c.center.distSquared(closestX, closestY) < c.radius * c.radius;
}

描述看起来比实现可怕得多。我们确定矩形上距离圆最*的点,然后简单地检查该点是否位于圆内。如果是这样,圆和矩形之间就有重叠。

注意,我们向 Vector2 添加了一个重载的 distSquared()方法,该方法采用两个浮点参数,而不是另一个 Vector2。我们对 dist()函数做同样的事情。

把这一切放在一起

检查一个点是否位于圆或矩形内也很有用。我们可以再编写两个方法,并将它们与我们刚刚定义的其他三个方法放在一个名为 OverlapTester 的类中。清单 8-6 显示了代码。

清单 8-6。OverlapTester.java;测试圆、矩形和点之间的重叠

package com.badlogic.androidgames.framework.math;

public class OverlapTester {
    public static boolean overlapCircles(Circle c1, Circle c2) {
        float distance = c1.center.distSquared(c2.center);
        float radiusSum = c1.radius + c2.radius;
        return distance <= radiusSum * radiusSum;
    }

    public static boolean overlapRectangles(Rectangle r1, Rectangle r2) {
        if (r1.lowerLeft.x < r2.lowerLeft.x + r2.width &&
           r1.lowerLeft.x + r1.width > r2.lowerLeft.x &&
           r1.lowerLeft.y < r2.lowerLeft.y + r2.height &&
           r1.lowerLeft.y + r1.height > r2.lowerLeft.y)
            return true ;
        else
            return false ;
    }

    public static boolean overlapCircleRectangle(Circle c, Rectangle r) {
        float closestX = c.center.x;
        float closestY = c.center.y;

        if (c.center.x < r.lowerLeft.x) {
            closestX = r.lowerLeft.x;
        }

        else if (c.center.x > r.lowerLeft.x + r.width) {
            closestX = r.lowerLeft.x + r.width;
        }

        if (c.center.y < r.lowerLeft.y) {
            closestY = r.lowerLeft.y;
        }

        else if (c.center.y > r.lowerLeft.y + r.height) {
            closestY = r.lowerLeft.y + r.height;
        }

        return c.center.distSquared(closestX, closestY) < c.radius * c.radius;
    }

    public static boolean pointInCircle(Circle c, Vector2 p) {
        return c.center.distSquared(p) < c.radius * c.radius;
    }

    public static boolean pointInCircle(Circle c, float x, float y) {
        return c.center.distSquared(x, y) < c.radius * c.radius;
    }

    public static boolean pointInRectangle(Rectangle r, Vector2 p) {
        return r.lowerLeft.x <= p.x && r.lowerLeft.x + r.width >= p.x &&
               r.lowerLeft.y <= p.y && r.lowerLeft.y + r.height >= p.y;
    }

    public static boolean pointInRectangle(Rectangle r, float x, float y) {
        return r.lowerLeft.x <= x && r.lowerLeft.x + r.width >= x &&
               r.lowerLeft.y <= y && r.lowerLeft.y + r.height >= y;
    }

}

太好了。现在我们有了一个全功能的 2D 数学库,可以用于你所有的小物理模型和碰撞检测。现在,我们准备更详细地看一下广义阶段。

宽相位

那么,我们如何才能实现 broad 阶段所承诺的魔力呢?考虑图 8-16 ,它展示了一个典型的超级马里奥兄弟场景。

9781430246770_Fig08-16.jpg

图 8-16。超级马里奥和他的敌人。对象周围的方框是它们的包围矩形;这些大盒子构成了一个强加于世界的网格

你能猜出你能做什么来消除一些碰撞检查吗?图 8-16 中的网格代表我们用来划分世界的细胞。每个细胞大小完全相同,整个世界都被细胞覆盖。马里奥目前在其中的两个单元中,马里奥可能与之相撞的其他物体在不同的单元中。因此,您不需要检查任何碰撞,因为马里奥与场景中的任何其他对象都不在同一个单元中。我们需要做的是:

  • 基于我们的物理和控制器步骤更新世界上的所有对象。
  • 根据对象的位置更新每个对象的边界形状的位置。我们当然也可以包括方位和尺度。
  • 根据边界形状,找出每个对象包含在哪个或哪些单元格中,并将它们添加到这些单元格中包含的对象列表中。
  • 检查碰撞,但只在可能碰撞的对象对之间(例如,Goombas 不会与其他 Goombas 碰撞)并且在同一单元中。

这被称为空间散列网格宽阶段,并且非常容易实现。你首先要定义的是每个单元格的大小。这很大程度上取决于你在游戏世界中使用的比例和单位。

复杂的例子

我们将基于前面的炮弹示例(位于“实际操作”部分)开发一个空间散列网格宽阶段。到目前为止,我们将对它进行彻底的修改,以包含本节中涉及的所有内容。除了大炮和炮弹,我们还要有靶子。我们让生活变得简单,只使用 0.5×0.5 米的正方形作为目标。这些方块不会移动;它们是静态的。我们的大炮也是静止的。唯一会动的是炮弹本身。我们通常可以将游戏世界中的物体分为静态物体和动态物体。让我们设计一个代表这些对象的类。

游戏对象、动态游戏对象和大炮

让我们从清单 8-7 中的静态情况,或基本情况开始。

清单 8-7。GameObject.java,一个有位置和边界的静态游戏对象

package com.badlogic.androidgames.framework;

import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;

public class GameObject {
    public final Vector2 position;
    public final Rectangle bounds;

    public GameObject(float x, float y, float width, float height) {
        this .position = new Vector2(x,y);
        this .bounds = new Rectangle(x-width/2, y-height/2, width, height);
    }

}

我们游戏中的每个物体都有一个与其中心重合的位置。此外,我们让每个对象都有一个单一的边界形状,在本例中是一个矩形。在构造函数中,我们根据参数设置位置和边界矩形(以对象的中心为中心)。

对于动态物体(即移动的物体),我们还需要跟踪速度和加速度(如果物体实际上是自己加速的——例如,通过引擎或推进器)。清单 8-8 显示了 DynamicGameObject 类的代码。

清单 8-8。DynamicGameObject.java;用速度和加速度向量扩展游戏对象

package com.badlogic.androidgames.framework;

import com.badlogic.androidgames.framework.math.Vector2;

public class DynamicGameObject extends GameObject {
    public final Vector2 velocity;
    public final Vector2 accel;

    public DynamicGameObject(float x, float y, float width, float height) {
        super (x, y, width, height);
        velocity = new Vector2();
        accel = new Vector2();
    }

}

我们扩展了 GameObject 类来继承位置和边界成员。此外,我们为速度和加速度创建向量。一个新的动态游戏对象在初始化后将会有零速度和零加速度。

在我们的炮弹例子中,我们有大炮、炮弹和目标。炮弹是一个动态游戏对象,因为它根据我们简单的物理模型移动。目标是静态的,可以使用标准的游戏对象来实现。大炮也可以通过 GameObject 类实现。我们将从 GameObject 类派生一个 cannon 类,并添加一个存储 Cannon 当前角度的字段。清单 8-9 显示了代码。

清单 8-9。Cannon.java;用一个角度扩展游戏对象

package com.badlogic.androidgames.gamedev2d;

public class Cannon extends GameObject {
    public float angle;

    public Cannon(float x, float y, float width, float height) {
        super (x, y, width, height);
        angle = 0;
    }

}

这很好地封装了在我们的 cannon 世界中表示一个对象所需的所有数据。每当我们需要一种特殊的物体,比如大炮,你可以简单地从 GameObject,如果它是一个静态物体,或者从 DynamicGameObject,如果它有速度和加速度。

注意过度使用继承会导致严重的问题和非常丑陋的代码架构。不要为了使用它而使用它。刚刚使用的简单的类层次结构是可以的,但是你不应该让它变得更深(例如,通过扩展 Cannon)。游戏对象有不同的表现形式,通过组合来消除所有的继承。不过,对于您的目的来说,简单的继承已经足够了。如果你对其他表现感兴趣,在网上搜索“复合”或“混合”。

空间散列网格

我们的大炮将以 1×1 米的矩形为边界,炮弹的边界矩形为 0.2×0.2 米,每个目标的边界矩形为 0.5×0.5 米。边界矩形以每个对象的位置为中心,使我们的生活稍微轻松一些。

当我们的 cannon 示例启动时,我们可以简单地在随机位置放置一些目标。下面是我们如何在我们的世界中设置对象:

Cannon cannon = new Cannon(0, 0, 1, 1);
DynamicGameObject ball = new DynamicGameObject(0, 0, 0.2f, 0.2f);
GameObject[] targets = new GameObject[NUM_TARGETS];
for (int i = 0; i < NUM_TARGETS; i++) {
    targets[i] = new GameObject((float )Math.*random*() * WORLD_WIDTH,
                                (float )Math.*random*() * WORLD_HEIGHT,
                                0.5f, 0.5f);
}

常量 WORLD_WIDTH 和 WORLD_HEIGHT 定义了我们游戏世界的大小。一切都应该发生在由(0,0)和(WORLD_WIDTH,WORLD_HEIGHT)包围的矩形内。图 8-17 显示了目前为止游戏世界的一个小模型。

9781430246770_Fig08-17.jpg

图 8-17。你游戏世界的模型

我们的世界以后会是这个样子,但现在我们可以覆盖一个空间散列网格。散列网格的单元应该有多大?没有灵丹妙药,但一个好的启发是让它们比场景中最大的物体大五倍。在我们的例子中,最大的物体是加农炮,但我们没有用加农炮碰撞任何东西,所以我们可以根据场景中下一个最大的物体,即目标,来确定网格的大小。这些尺寸是 0.5×0.5 米。因此,网格单元的大小应为 2.5×2.5 米。图 8-18 显示了覆盖在我们世界上的网格。

9781430246770_Fig08-18.jpg

图 8-18。我们的大炮世界,覆盖着一个由 12 个单元组成的空间散列网格

我们有固定数量的单元——在大炮世界中,是 12 个。我们给每个单元格一个唯一的编号,从左下角的单元格开始,它的 ID 为 0。请注意,顶部的单元格实际上延伸到世界之外。这不是问题;我们只需要确保我们所有的对象都在世界的边界之内。

我们想要做的是找出一个对象属于哪个(哪些)单元。理想情况下,我们希望计算包含该对象的单元格的 id。这允许您使用以下简单的数据结构来存储单元格:

List<GameObject>[] cells;

没错;我们将每个单元格表示为一个游戏对象列表。空间散列网格本身只是由一组游戏对象列表组成。

现在我们可以算出包含一个对象的单元格的 id。图 8-18 显示了跨越两个单元格的几个目标。事实上,一个小对象最多可以跨越四个单元格,一个比网格单元格大的对象可以跨越四个以上的单元格。我们可以通过选择网格尺寸为游戏中最大物体尺寸的倍数来确保这种情况不会发生。这就给我们留下了一种可能性,一个物体最多包含在四个单元格中。

为了计算一个对象的单元 id,我们简单地取边界矩形的四个角点,并检查每个角点在哪个单元中。确定一个点所在的像元很容易,我们只需要用它的坐标除以像元的宽度。假设在(3,4)处有一个点,像元大小为 2.5×2.5 米:该点将位于 ID 为 5 的像元中,如图 8-18 中的所示。

我们可以将每个点的坐标除以像元大小,得到 2D 整数坐标,如下所示:

cellX = floor(point . x/cellSize)= floor(3/2.5)= 1

cellY = floor(point . y/cellSize)= floor(4/2.5)= 1

从这些细胞坐标,我们可以很容易地得到细胞 ID:

cellid = cellx+celly×celllock ow = 1+1×4 = 5

常数 cells row 就是我们需要用 x 轴上的细胞覆盖我们的世界的细胞数:

单元格行=单元格(世界宽度/单元格大小)=单元格(9.6 / 2.5) = 4

我们可以这样计算每列所需的单元格数量:

cell column = ceil(world height/cell size)= ceil(6.4/2.5)= 3

基于此,我们可以相当容易地实现空间散列网格。我们通过给它世界的大小和想要的细胞大小来设置它。我们假设所有的行为都发生在世界的正象限。这意味着世界上所有点的 x 和 y 坐标都将是正的。这是我们可以接受的约束。

根据参数,空间散列网格可以计算出它需要多少个像元(cellsPerRow × cellsPerColumn)。我们还可以添加一个简单的方法来将对象插入到网格中,该方法将使用对象的边界来确定包含它的单元格。然后,该对象将被添加到它包含的每个单元格的对象列表中。如果对象边界形状的一个角点在网格之外,我们可以忽略这个角点。

在每一帧中,我们在更新对象的位置后,将每个对象重新插入到空间散列网格中。然而,在我们的加农炮世界中有不移动的物体,所以为每一帧重新插入它们是非常浪费的。我们通过在每个单元格中存储两个列表来区分动态对象和静态对象。一个列表将每帧更新一次,并且仅保存移动的对象,而另一个列表将是静态的,并且仅在插入新的静态对象时才被修改。

最后,我们需要一个方法来返回我们希望与其他对象发生碰撞的对象的单元格中的对象列表。这个方法所做的就是检查有问题的对象在哪些单元格中,检索这些单元格中的动态和静态对象的列表,并将列表返回给调用者。当然,我们必须确保不返回任何副本,如果一个对象位于多个单元格中,就会出现这种情况。

清单 8-10 显示了代码(嗯,大部分)。稍后将讨论 SpatialHashGrid.getCellIds()方法,因为它有点复杂。

清单 8-10。摘自 SpatialHashGrid.java;一种空间哈希网格实现

package com.badlogic.androidgames.framework;

import java.util.ArrayList;

import java.util.List;

import com.badlogic.androidgames.framework.GameObject;

import android.util.FloatMath;

public class SpatialHashGrid {
    List<GameObject>[] dynamicCells;
    List<GameObject>[] staticCells;
    int cellsPerRow;
    int cellsPerCol;
    float cellSize;
    int [] cellIds = new int [4];
    List<GameObject> foundObjects;

如前所述,我们存储两个单元格列表,一个用于动态对象,一个用于静态对象。我们还存储每一行和每一列的单元,以便我们以后可以决定我们检查的点是在世界内部还是外部。单元大小也需要被存储。cell IDs 数组是一个工作数组,我们可以用它来临时存储一个游戏对象所包含的四个单元 id。如果它只包含在一个单元格中,那么只有数组的第一个元素将被设置为包含整个对象的单元格的单元格 ID。如果对象包含在两个单元格中,那么数组的前两个元素将保存单元格 ID,依此类推。为了指示单元格 id 的数量,我们将数组的所有“空”元素设置为–1。foundObjects 列表也是一个工作列表,我们可以在调用 getPotentialColliders()时返回它。为什么我们保留这两个成员,而不是在每次需要时实例化一个新的数组和列表?还记得垃圾收集怪物吗?

    @SuppressWarnings("unchecked")
    public SpatialHashGrid(float worldWidth, float worldHeight, float cellSize) {
        this .cellSize = cellSize;
        this .cellsPerRow = ( int )FloatMath.*ceil*(worldWidth / cellSize);
        this .cellsPerCol = ( int )FloatMath.*ceil*(worldHeight / cellSize);
        int numCells = cellsPerRow * cellsPerCol;
        dynamicCells = new List[numCells];
        staticCells = new List[numCells];
        for (int i = 0; i < numCells; i++) {
            dynamicCells[i] = new ArrayList<GameObject>(10);
            staticCells[i] = new ArrayList<GameObject>(10);
        }
        foundObjects = new ArrayList<GameObject>(10);
    }

该类的构造函数获取世界的大小和所需的单元格大小。根据这些参数,我们计算需要多少个单元格,并实例化单元格数组和保存每个单元格中包含的对象的列表。初始化 foundObjects 列表。我们创建的所有数组列表实例的初始容量都是 10 个 GameObject 实例。我们这样做是为了避免内存分配。假设一个单元不太可能包含十个以上的游戏对象实例。只要这是真的,数组列表就不需要调整大小。

    public void insertStaticObject(GameObject obj) {
        int [] cellIds = getCellIds(obj);
        int i = 0;
        int cellId = -1;
        while (i <= 3 && (cellId = cellIds[i++]) != -1) {
            staticCells[cellId].add(obj);
        }

    }

    public void insertDynamicObject(GameObject obj) {
        int [] cellIds = getCellIds(obj);
        int i = 0;
        int cellId = -1;
        while (i <= 3 && (cellId = cellIds[i++]) != -1) {
            dynamicCells[cellId].add(obj);
        }

    }

接下来是 insertStaticObject()和 insertDynamicObject()方法。它们通过调用 getCellIds()计算包含该对象的单元格的 id,,并相应地将该对象插入到适当的列表中。getCellIds()方法将实际填充 CellIds 成员数组。

    public void removeObject(GameObject obj) {
        int [] cellIds = getCellIds(obj);
        int i = 0;
        int cellId = -1;
        while (i <= 3 && (cellId = cellIds[i++]) != -1) {
            dynamicCells[cellId].remove(obj);
            staticCells[cellId].remove(obj);
        }

    }

我们还有一个 removeObject()方法,可以用它来判断对象在哪个单元格中,然后相应地从动态或静态列表中删除它。例如,当一个游戏对象死亡时,这将是需要的。

    public void clearDynamicCells(GameObject obj) {
        int len = dynamicCells.length;
        for (int i = 0; i < len; i++) {
            dynamicCells[i].clear();
        }

    }

clearDynamicCells()方法将用于清除所有动态单元格列表。如前所述,我们需要在重新插入动态对象之前调用这个框架。

    public List<GameObject> getPotentialColliders(GameObject obj) {
        foundObjects.clear();
        int [] cellIds = getCellIds(obj);
        int i = 0;
        int cellId = -1;
        while (i <= 3 && (cellId = cellIds[i++]) != -1) {
            int len = dynamicCells[cellId].size();
            for (int j = 0; j < len; j++) {
                GameObject collider = dynamicCells[cellId].get(j);
                if (!foundObjects.contains(collider))
                    foundObjects.add(collider);
            }

            len = staticCells[cellId].size();
            for (int j = 0; j < len; j++) {
                GameObject collider = staticCells[cellId].get(j);
                if (!foundObjects.contains(collider))
                    foundObjects.add(collider);
            }

        }

        return foundObjects;
    }

最后,getPotentialColliders()方法获取一个对象,并返回一个包含在与该对象相同的单元格中的相邻对象的列表。我们使用工作列表 foundObjects 来存储找到的对象的列表。同样,我们不希望每次调用这个方法时都实例化一个新的列表。我们需要做的就是找出传递给方法的对象在哪个单元格中。然后,我们只需将这些单元格中的所有动态和静态对象添加到 foundObjects 列表中,并确保没有重复的对象。使用 foundObjects.contains()来检查重复项当然不是最佳选择,但是考虑到找到的对象数量不会很大,在这种情况下使用它是可以接受的。如果我们遇到性能问题,那么这是我们优化的首选。可悲的是,这不是小事。当然,我们可以使用集合,但是每次我们添加一个对象到集合中时,它会在内部分配新的对象。现在,我们只是让它保持原样,知道如果性能方面出现任何问题,我们可以回头再来。

遗漏的方法是 SpatialHashGrid.getCellIds()。清单 8-11 显示了它的代码。别害怕,它只是看起来很危险。

清单 8-11。SpatialHashGrid.java 其余地区的;实现 getCellIds()

    public int [] getCellIds(GameObject obj) {
        int x1 = ( int )FloatMath.*floor*(obj.bounds.lowerLeft.x / cellSize);
        int y1 = ( int )FloatMath.*floor*(obj.bounds.lowerLeft.y / cellSize);
        int x2 = ( int )FloatMath.*floor*((obj.bounds.lowerLeft.x + obj.bounds.width) / cellSize);
        int y2 = ( int )FloatMath.*floor*((obj.bounds.lowerLeft.y + obj.bounds.height) / cellSize);

        if (x1 == x2 && y1 == y2) {
            if (x1 >= 0 && x1 < cellsPerRow && y1 >= 0 && y1 < cellsPerCol)
                cellIds[0] = x1 + y1 * cellsPerRow;
            else
                cellIds[0] = -1;
            cellIds[1] = -1;
            cellIds[2] = -1;
            cellIds[3] = -1;
        }

        else if (x1 == x2) {
            int i = 0;
            if (x1 >= 0 && x1 < cellsPerRow) {
                if (y1 >= 0 && y1 < cellsPerCol)
                    cellIds[i++] = x1 + y1 * cellsPerRow;
                if (y2 >= 0 && y2 < cellsPerCol)
                    cellIds[i++] = x1 + y2 * cellsPerRow;
            }
            while (i <= 3) cellIds[i++] = -1;
        }
        else if (y1 == y2) {
            int i = 0;
            if (y1 >= 0 && y1 < cellsPerCol) {
                if (x1 >= 0 && x1 < cellsPerRow)
                    cellIds[i++] = x1 + y1 * cellsPerRow;
                if (x2 >= 0 && x2 < cellsPerRow)
                    cellIds[i++] = x2 + y1 * cellsPerRow;
            }
            while (i <= 3) cellIds[i++] = -1;
        }
        else {
            int i = 0;
            int y1CellsPerRow = y1 * cellsPerRow;
            int y2CellsPerRow = y2 * cellsPerRow;
            if (x1 >= 0 && x1 < cellsPerRow && y1 >= 0 && y1 < cellsPerCol)
                cellIds[i++] = x1 + y1CellsPerRow;
            if (x2 >= 0 && x2 < cellsPerRow && y1 >= 0 && y1 < cellsPerCol)
                cellIds[i++] = x2 + y1CellsPerRow;
            if (x2 >= 0 && x2 < cellsPerRow && y2 >= 0 && y2 < cellsPerCol)
                cellIds[i++] = x2 + y2CellsPerRow;
            if (x1 >= 0 && x1 < cellsPerRow && y2 >= 0 && y2 < cellsPerCol)
                cellIds[i++] = x1 + y2CellsPerRow;
            while (i <= 3) cellIds[i++] = -1;
        }
        return cellIds;
    }

}

此方法的前四行计算对象边框左下角和右上角的单元格坐标。这个计算前面已经讨论过了。要理解这个方法的其余部分,请考虑一个对象如何重叠网格单元。有四种可能:

  • 该对象包含在单个单元格中。因此,边框的左下角和右上角具有相同的单元坐标。
  • 对象与两个单元格水*重叠。左下角在一个单元格中,右上角在右边的单元格中。
  • 该对象垂直重叠两个单元格。左下角在一个单元格中,右上角在上面的单元格中。
  • 该对象与四个单元格重叠。左下角在一个单元格中,右下角在右边的单元格中,右上角在它上面的单元格中,左上角在第一个单元格上面的单元格中。

这种方法所做的只是为这些可能性中的每一个做一个特例。第一个 if 语句检查单个单元格的情况,第二个 if 语句检查水*双单元格的情况,第三个 if 语句检查垂直双单元格的情况,else 块处理对象与四个网格单元格重叠的情况。在四个块的每一个中,我们确保仅当相应的单元坐标在世界范围内时才设置单元 ID。这就是这个方法的全部内容。

现在,这个方法看起来需要大量的计算能力。的确如此,但比它的规模所暗示的要小。最常见的情况是第一种,处理起来非常便宜。你能看到进一步优化这种方法的机会吗?

把这一切放在一起

让我们把在这一节中收集的所有知识放在一起,形成一个漂亮的小例子。我们可以扩展上一节的 cannon 示例,就像几页前讨论的那样。我们使用一个加农炮对象作为加农炮,一个动态游戏对象作为炮弹,还有一些游戏对象作为目标。每个目标的大小为 0.5×0.5 米,随机放置在世界各地。

我们希望能够射击这些目标。为此,我们需要碰撞检测。我们可以循环所有的目标,对照炮弹检查它们,但那会很无聊。我们使用新的 SpatialHashGrid 类来加速寻找当前球位置的潜在碰撞目标的过程。我们不把球或大炮放进格子里,因为那不会真正帮助你。

因为这个例子已经很大了,所以把它分成了多个清单。调用测试 CollisionTest 和相应的屏幕 CollisionScreen。一如既往,我们只看屏幕代码。让我们从清单 8-12 中的成员和构造函数开始。

清单 8-12。摘自 CollisionTest.java;成员和构造函数

class CollisionScreen extends Screen {
    final int NUM_TARGETS = 20;
    final float WORLD_WIDTH = 9.6f;
    final float WORLD_HEIGHT = 4.8f;
    GLGraphics glGraphics;
    Cannon cannon;
    DynamicGameObject ball;
    List<GameObject> targets;
    SpatialHashGrid grid;

    Vertices cannonVertices;
    Vertices ballVertices;
    Vertices targetVertices;

    Vector2 touchPos = new Vector2();
    Vector2 gravity = new Vector2(0,-10);

    public CollisionScreen(Game game) {
        super (game);
        glGraphics = ((GLGame)game).getGLGraphics();
        cannon = new Cannon(0, 0, 1, 1);
        ball = new DynamicGameObject(0, 0, 0.2f, 0.2f);
        targets = new ArrayList<GameObject>(NUM_TARGETS);
        grid = new SpatialHashGrid(WORLD_WIDTH, WORLD_HEIGHT, 2.5f);
        for (int i = 0; i < NUM_TARGETS; i++) {
            GameObject target = new GameObject((float )Math.*random*() * WORLD_WIDTH,
                                               (float )Math.*random*() * WORLD_HEIGHT,
                                               0.5f, 0.5f);
            grid.insertStaticObject(target);
            targets.add(target);
        }

        cannonVertices = new Vertices(glGraphics, 3, 0, false , false );
        cannonVertices.setVertices( new float [] { -0.5f, -0.5f,
                                                  0.5f, 0.0f,
                                                 -0.5f, 0.5f }, 0, 6);

        ballVertices = new Vertices(glGraphics, 4, 6, false , false );
        ballVertices.setVertices( new float [] { -0.1f, -0.1f,
                                                0.1f, -0.1f,
                                                0.1f,  0.1f,
                                               -0.1f,  0.1f }, 0, 8);
        ballVertices.setIndices( new short [] {0, 1, 2, 2, 3, 0}, 0, 6);
        targetVertices = new Vertices(glGraphics, 4, 6, false , false );
        targetVertices.setVertices( new float [] { -0.25f, -0.25f,
                                                  0.25f, -0.25f,
                                                  0.25f,  0.25f,
                                                 -0.25f,  0.25f }, 0, 8);
        targetVertices.setIndices( new short [] {0, 1, 2, 2, 3, 0}, 0, 6);
    }

我们可以从炮弹屏幕上获得很多信息。我们从几个恒定的定义开始,控制目标的数量和我们世界的大小。接下来,我们有 GLGraphics 实例,以及大炮、球和目标的对象,它们存储在一个列表中。当然,我们也有一个空间散列网格。为了渲染我们的世界,我们需要几个网格:一个用于大炮,一个用于球,一个用于渲染每个目标。请记住,我们在 BobTest 中只需要一个矩形就可以将 100 个 bob 呈现到屏幕上。我们在这里重用该原则,只使用一个顶点实例来保存目标的三角形(矩形)。最后两个成员与 CannonGravityTest 中的成员相同。当用户触摸屏幕时,我们用它们来击球和施加重力。

构造函数完成前面讨论的所有事情。实例化我们的世界对象和网格。唯一有趣的是,我们还将目标作为静态对象添加到空间散列网格中。

现在看看清单 8-13 中的 CollisionTest 类的下一个方法。

清单 8-13。摘自 CollisionTest.java;update()方法

@Override
public void update(float deltaTime) {
    List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
    game.getInput().getKeyEvents();
    int len = touchEvents.size();
    for (int i = 0; i < len; i++) {
        TouchEvent event = touchEvents.get(i);

        touchPos.x = (event.x / (float ) glGraphics.getWidth()) * WORLD_WIDTH;
        touchPos.y = (1 - event.y / (float ) glGraphics.getHeight()) * WORLD_HEIGHT;

        cannon.angle = touchPos.sub(cannon.position).angle();

        if (event.type == TouchEvent.*TOUCH_UP*) {
            float radians = cannon.angle * Vector2.*TO_RADIANS*;
            float ballSpeed = touchPos.len() * 2;
            ball.position.set(cannon.position);
            ball.velocity.x = FloatMath.*cos*(radians) * ballSpeed;
            ball.velocity.y = FloatMath.*sin*(radians) * ballSpeed;
            ball.bounds.lowerLeft.set(ball.position.x - 0.1f, ball.position.y - 0.1f);
        }

    }

    ball.velocity.add(gravity.x * deltaTime, gravity.y * deltaTime);
    ball.position.add(ball.velocity.x * deltaTime, ball.velocity.y * deltaTime);
    ball.bounds.lowerLeft.add(ball.velocity.x * deltaTime, ball.velocity.y * deltaTime);

    List<GameObject> colliders = grid.getPotentialColliders(ball);
    len = colliders.size();

    for (int i = 0; i < len; i++) {
        GameObject collider = colliders.get(i);
        if (OverlapTester.*overlapRectangles*(ball.bounds, collider.bounds)) {
            grid.removeObject(collider);
            targets.remove(collider);
        }

    }

}

和往常一样,首先我们获取触摸和按键事件,并且只迭代触摸事件。触摸事件的处理几乎与 CannonGravityTest 中的相同。唯一的区别是,我们使用 cannon 对象,而不是旧示例中的 vectors,并且当 Cannon 在 touch-up 事件后准备射击时,我们重置球的边界矩形。

下一个变化是我们如何更新球。我们使用为球实例化的 DynamicGameObject 的成员,而不是直接的向量。我们忽略了 DynamicGameObject.acceleration 成员,而是将重力添加到球的速度中。我们把球的速度乘以 2,让炮弹飞得快一点。有趣的是,我们不仅更新了球的位置,还更新了边框左下角的位置。这是至关重要的,否则我们的球会移动,但其边界矩形不会。为什么我们不简单地使用球的外接矩形来存储球的位置呢?我们可能希望将多个边界形状附加到一个对象上。那么哪个边界形状会保存对象的实际位置呢?因此,将这两件事分开是有益的,并且只会引入少量的计算开销。当然,我们可以通过将速度乘以时间增量来优化它。开销可以归结为另外两项增加——这是我们获得灵活性的小小代价。

这个方法的最后一部分是我们的碰撞检测代码。我们在空间散列网格中找到目标,这些目标与我们的炮弹在同一个单元中。为此,我们使用 spatialhashgrid . getpotential colliders()方法。因为球所在的单元格在该方法中被直接计算,所以我们不需要将球插入网格。接下来,我们遍历所有潜在的碰撞体,并检查球的边界矩形和潜在碰撞体的边界矩形之间是否真的有重叠。如果有,我们只需从目标列表中删除目标。记住,我们只将目标作为静态对象添加到网格中。

这些就是我们完整的游戏机制。拼图的最后一块是实际的渲染,这不应该真的让你吃惊。参见清单 8-14 中的代码。

清单 8-14。摘自 CollisionTest.java;present()方法

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
    gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
    gl.glMatrixMode(GL10.*GL_PROJECTION*);
    gl.glLoadIdentity();
    gl.glOrthof(0, WORLD_WIDTH, 0, WORLD_HEIGHT, 1, -1);
    gl.glMatrixMode(GL10.*GL_MODELVIEW*);

    gl.glColor4f(0, 1, 0, 1);
    targetVertices.bind();
    int len = targets.size();
    for (int i = 0; i < len; i++) {
        GameObject target = targets.get(i);
        gl.glLoadIdentity();
        gl.glTranslatef(target.position.x, target.position.y, 0);
        targetVertices.draw(GL10.*GL_TRIANGLES*, 0, 6);
    }
    targetVertices.unbind();

    gl.glLoadIdentity();
    gl.glTranslatef(ball.position.x, ball.position.y, 0);
    gl.glColor4f(1,0,0,1);
    ballVertices.bind();
    ballVertices.draw(GL10.*GL_TRIANGLES*, 0, 6);
    ballVertices.unbind();

    gl.glLoadIdentity();
    gl.glTranslatef(cannon.position.x, cannon.position.y, 0);
    gl.glRotatef(cannon.angle, 0, 0, 1);
    gl.glColor4f(1,1,1,1);
    cannonVertices.bind();
    cannonVertices.draw(GL10.*GL_TRIANGLES*, 0, 3);
    cannonVertices.unbind();
}

这里没什么新鲜的。像往常一样,我们设置投影矩阵和视窗,并首先清除屏幕。接下来,我们渲染所有目标,重用存储在 targetVertices 中的矩形模型。这与我们在 BobTest 中所做的基本相同,但这次我们渲染的是目标。接下来,我们渲染球和大炮,就像我们在碰撞重力测试中所做的那样。

这里唯一要注意的是,我们改变了绘制顺序,这样球将总是在目标的上方,大炮将总是在球的上方。我们还通过调用 glColor4f()将目标涂成绿色。

这个小测试的输出与图 8-17 中的完全相同,所以我们可以避免重复。当我们发射炮弹时,它会穿过目标区域。任何被球击中的目标都将从世界中移除。

这个例子实际上可能是一个不错的游戏,如果你稍微润色一下,加入一些激励性的游戏机制。你能想到补充吗?稍微摆弄一下这个例子,感受一下我们在过去几页中开发的新工具。

这一章还会讨论一些东西:相机,纹理贴图集和精灵。这些使用图形相关的技巧,独立于我们的游戏世界模型。该出发了!

2D 的一台相机

到目前为止,我们的代码中还没有摄像机的概念;我们只通过 glOrthof()定义了我们的视见体,就像这样:

gl.glMatrixMode(GL10.*GL_PROJECTION*);
gl.glLoadIdentity();
gl.glOrthof(0, FRUSTUM_WIDTH, 0, FRUSTUM_HEIGHT, 1, -1);

从第七章我们知道,前两个参数定义了我们的*截头体左右边在世界上的 x 坐标,后两个参数定义了*截头体上下边的 y 坐标,最后两个参数定义了*裁剪*面和远裁剪*面。图 8-19 再次显示*截头体。

9781430246770_Fig08-19.jpg

图 8-19。你的 2D 世界的视锥

所以我们只看到我们世界的区域(0,0,1)到(*截头体宽度,*截头体高度,–1)。如果我们可以移动截锥,比如说,到左边,不是很好吗?那当然很好,而且也非常简单:

gl.glOrthof(x, x + FRUSTUM_WIDTH, 0, FRUSTUM_HEIGHT, 1, -1);

在这种情况下,x 只是一个你定义的偏移量。当然,我们也可以在 x 轴和 y 轴上移动:

gl.glOrthof(x, x + FRUSTUM_WIDTH, y, y +FRUSTUM_HEIGHT, 1, -1);

图 8-20 显示了这意味着什么。

9781430246770_Fig08-20.jpg

图 8-20。移动*截头体

我们只需在世界空间中指定视图截锥的左下角。这已经足以实现可自由移动的 2D 相机。但是我们可以做得更好。不使用 x 和 y 指定视见体的左下角,而是指定视见体的中心,会怎么样?通过这种方式,我们可以轻松地将我们的视见体集中在一个特定位置的对象上,比如前面示例中的炮弹:

gl.glOrthof(x – FRUSTUM_WIDTH / 2, x + FRUSTUM_WIDTH / 2, y – FRUSTUM_HEIGHT / 2, y +FRUSTUM_HEIGHT / 2, 1, -1);

图 8-21 显示了它的样子。

9781430246770_Fig08-21.jpg

图 8-21。根据其中心指定视图截锥

这还不是我们对 glOrthof()所能做的全部。变焦呢?想想这个。我们知道,通过 glViewportf(),您可以告诉 OpenGL ES 我们希望在屏幕的哪个部分呈现我们的视图截锥的内容。OpenGL ES 将自动拉伸和缩放输出,以与视口对齐。现在,如果我们让你的视锥的宽度和高度变小,我们将简单地在屏幕上显示你的世界的一个更小的区域——这就是放大。如果我们把*截头体做得更大,我们就能展示更多你的世界——这就是缩小。因此,我们可以引入一个缩放因子,并将其乘以我们的*截头体的宽度和高度来放大和缩小。系数为 1 将向我们展示这个世界,如图 8-21 所示,使用正常的截锥宽度和高度。小于 1 的因子将放大我们视见体的中心,而大于 1 的因子将缩小,向我们显示我们世界的更多部分(例如,将缩放因子设置为 2 将显示我们世界的两倍)。下面是我们如何使用 glOrthof()做到这一点:

gl.glOrthof(x – FRUSTUM_WIDTH / 2 * zoom, x + FRUSTUM_WIDTH / 2 * zoom, y – FRUSTUM_HEIGHT / 2 * zoom, y +FRUSTUM_HEIGHT / 2 * zoom, 1, -1);

非常简单!我们现在可以创建一个 camera 类,它有一个正在观察的位置(视见*截头体的中心),一个标准的*截头体宽度和高度,以及一个使*截头体变小或变大的缩放因子,从而显示我们世界的更少部分(放大)或更多部分(缩小)。图 8-22 显示了一个缩放系数为 0.5 的视见*截头体(内部的灰色方框)和一个缩放系数为 1 的视见*截头体(外部的透明方框)。

9781430246770_Fig08-22.jpg

图 8-22。缩放,通过操纵*截头体大小

为了使我们的生活完整,我们应该再增加一件事。想象一下,我们触摸屏幕,并想知道我们触摸了 2D 世界中的哪一点。在我们迭代改进的 cannon 示例中,我们已经这样做了几次。如图 8-19 中的图所示,对于不考虑摄像机位置和缩放的视见*截头体配置,我们有以下等式(参见 cannon 示例的 update()方法):

worldX = (touchX / Graphics.getWidth()) × FRUSTUM_WIDTH;
worldY = (1 – touchY / Graphics.getHeight()) × FRUSTUM_HEIGHT;

首先,我们通过除以屏幕的宽度和高度,将触摸的 x 和 y 坐标归一化到 0 到 1 的范围内,然后我们通过将它们乘以*截头体的宽度和高度来缩放它们,以便用我们的世界空间来表示它们。我们需要做的就是考虑视图截锥的位置,以及缩放因子。我们是这样做的:

worldX = (touchX / Graphics.getWidth()) × FRUSTUM_WIDTH + x – FRUSTUM_WIDTH / 2;
worldY = (1 – touchY / Graphics.getHeight()) × FRUSTUM_HEIGHT + y – FRUSTUM_HEIGHT / 2;

这里,x 和 y 是我们的相机在世界空间中的位置。

Camera2D 类

让我们把所有这些放在一个类中。我们希望它存储摄像机的位置、标准的*截头体宽度和高度以及缩放因子。我们还需要一种方便的方法来正确设置视窗(总是使用整个屏幕)和投影矩阵。此外,我们希望有一种方法可以将触摸坐标转换为世界坐标。清单 8-15 展示了我们新的 Camera2D 类,以及一些注释。

清单 8-15。Camera2D.java,我们闪亮的新相机类为 2D 渲染

package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.impl.GLGraphics;
import com.badlogic.androidgames.framework.math.Vector2;

public class Camera2D {
    public final Vector2 position;
    public float zoom;
    public final float frustumWidth;
    public final float frustumHeight;
    final GLGraphics glGraphics;

如前所述,我们将摄像机的位置、*截头体的宽度和高度以及缩放因子存储为成员。位置和缩放因子是公开的,所以我们可以很容易地操纵它们。我们还需要一个对 GLGraphics 的引用,这样我们就可以获得屏幕的最新宽度和高度(以像素为单位),以便将触摸坐标转换为世界坐标。

    public Camera2D(GLGraphics glGraphics, float frustumWidth, float frustumHeight) {
        this .glGraphics = glGraphics;
        this .frustumWidth = frustumWidth;
        this .frustumHeight = frustumHeight;
        this .position = new Vector2(frustumWidth / 2, frustumHeight / 2);
        this .zoom = 1.0f;
    }

在构造函数中,我们将一个 GLGraphics 实例和缩放因子为 1 的截锥的宽度和高度作为参数。我们存储它们并初始化摄像机的位置,使其看着由(0,0,1)和(frustumWidth,frustumHeight,–1)界定的盒子的中心,如图图 8-19 所示。初始缩放因子设置为 1。

    public void setViewportAndMatrices() {
        GL10 gl = glGraphics.getGL();
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        gl.glOrthof(position.x - frustumWidth * zoom / 2,
                    position.x + frustumWidth * zoom / 2,
                    position.y - frustumHeight * zoom / 2,
                    position.y + frustumHeight * zoom / 2,
                    1, -1);
        gl.glMatrixMode(GL10.*GL_MODELVIEW*);
        gl.glLoadIdentity();
    }

setViewportAndMatrices()方法将视口设置为跨越整个屏幕,并根据摄像机的参数设置投影矩阵,如前所述。在方法的最后,我们告诉 OpenGL ES,所有进一步的矩阵操作都是针对模型-视图矩阵,并加载一个单位矩阵。我们在每一帧都调用这个方法,这样我们就可以从头开始。不再需要直接调用 OpenGL ES 来设置我们的视口和投影矩阵。

    public void touchToWorld(Vector2 touch) {
        touch.x = (touch.x / (float ) glGraphics.getWidth()) * frustumWidth * zoom;
        touch.y = (1 - touch.y / (float ) glGraphics.getHeight()) * frustumHeight * zoom;
        touch.add(position).sub(frustumWidth * zoom / 2, frustumHeight * zoom / 2);
    }

}

touchToWorld()方法采用包含触摸坐标的 Vector2 实例,并将该向量转换到世界空间。这和刚才讨论的是一样的;唯一的区别是,我们可以使用我们喜欢的 Vector2 类。

一个例子

我们现在将在您的 cannon 示例中使用 Camera2D 类。复制 CollisionTest 文件,并将其重命名为 Camera2DTest。在文件 Camera2DTest 中重命名 GLGame 类,并将 CollisionScreen 类重命名为 Camera2DScreen。为了使用新的 Camera2D 类,我们需要做一些小的改动。

我们做的第一件事是向 Camera2DScreen 类添加一个新成员:

Camera2D camera;

我们在构造函数中初始化这个成员,如下所示:

camera = new Camera2D(glGraphics, WORLD_WIDTH, WORLD_HEIGHT);

我们传入我们的 GLGraphics 实例和世界的宽度和高度,这是我们在调用 glOrthof()时使用的*截头体的宽度和高度。我们现在需要做的就是替换 present()方法中的直接 OpenGL ES 调用,如下所示:

gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
gl.glMatrixMode(GL10.GL_PROJECTION);
gl.glLoadIdentity();
gl.glOrthof(0, WORLD_WIDTH, 0, WORLD_HEIGHT, 1, -1);
gl.glMatrixMode(GL10.GL_MODELVIEW);

我们用这个替换它们:

gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
camera.setViewportAndMatrices();

当然,我们仍然需要清除 framebuffer,但是所有其他直接的 OpenGL ES 调用都很好地隐藏在 Camera2D.setViewportAndMatrices()方法中。如果您运行这段代码,您会发现什么都没有改变。一切都像以前一样工作——我们所做的只是让事情变得更好、更灵活。

我们还可以简化测试的 update()方法。既然我们在 Camera2D 类中添加了 Camera2D.touchToWorld()方法,我们不妨使用它。我们可以替换 update()方法中的这段代码:

touchPos.x = (event.x / (float) glGraphics.getWidth()) * WORLD_WIDTH;
touchPos.y = (1 - event.y / (float) glGraphics.getHeight()) * WORLD_HEIGHT;

有了这个:

camera.touchToWorld(touchPos.set(event.x, event.y));

整洁——现在一切都被很好地封装了。但是如果我们没有最大限度地利用你的 Camera2D 类的特性,那就非常无聊了。计划是这样的:只要炮弹不飞,我们就想让摄像机以“正常”的方式观察世界。那很容易;我们已经在这么做了。我们可以通过检查炮弹所在位置的 y 坐标是否小于等于零来确定炮弹是否飞行。因为我们总是对炮弹施加重力,所以即使我们不发射它,它也会落下,所以这是一种廉价的检查方法。

我们新加的会在炮弹飞起来的时候生效(y 坐标大于零的时候)。我们想让摄像机跟踪炮弹。我们可以通过简单地将相机的位置设置为炮弹的位置来实现这一点。这将始终保持炮弹在屏幕的中心。我们还想尝试一下我们的缩放功能。因此,我们可以根据炮弹的 y 坐标增加缩放因子:离零越远,缩放因子越高。如果炮弹有较高的 y 坐标,这将使相机缩小。下面是我们需要添加到测试屏幕中 update()方法末尾的内容:

if(ball.position.y > 0) {
    camera.position.set(ball.position);
    camera.zoom = 1 + ball.position.y / WORLD_HEIGHT;
} else {
    camera.position.set(WORLD_WIDTH / 2, WORLD_HEIGHT / 2);
    camera.zoom = 1;
}

只要我们球的 y 坐标大于零,相机就会跟着它缩小。只需在标准缩放因子 1 上增加一个值。这个值就是球的 y 位置和世界高度之间的关系。如果球的 y 坐标在 WORLD_HEIGHT,缩放系数将是 2,所以我们将看到我们世界的更多部分。这样做的方式可以是任意的;我们可以想出任何我们想要的公式——这没什么神奇的。如果球的位置小于或等于零,我们正常显示世界,就像我们在前面的例子中所做的那样。

纹理图谱:因为分享是关爱

到目前为止,我们在程序中只使用了一个纹理。如果我们不仅要渲染鲍勃,还要渲染其他超级英雄、敌人、爆炸或硬币,那该怎么办?我们可以有多个纹理,每个纹理保存一个对象类型的图像。但是 OpenGL ES 不会太喜欢这样,因为我们需要为我们渲染的每个对象类型切换纹理(也就是说,绑定 Bob 的纹理,渲染 Bob,绑定硬币纹理,渲染硬币,等等)。我们可以通过将多个图像放入一个纹理中来更有效地实现它。那是一个纹理图谱:一个包含多个图像的单一纹理。我们只需要绑定纹理一次,然后我们就可以渲染任何在图集中有图像的实体类型。这节省了一些状态更改开销并提高了性能。图 8-23 显示了这样一个纹理图谱。

9781430246770_Fig08-23.jpg

图 8-23。纹理图谱

图 8-23 中有三个物体:一门大炮,一个炮弹,还有鲍勃。网格不是纹理的一部分;它只是为了说明我们通常如何手动创建纹理地图集。

纹理图谱大小为 64×64 像素,每个网格为 32×32 像素。大炮占了两个牢房,炮弹占了不到四分之一个牢房,鲍勃占了一个牢房。现在,如果你回头看看你是如何定义加农炮、炮弹和目标的边界(和图形矩形)的,你会注意到它们之间的大小关系与你在网格中看到的非常相似。目标在你的世界里是 0.5×0.5 m,大炮是 0.2×0.2 m,在我们的纹理图谱里,Bob 占了 32×32 像素,炮弹略小于 16×16 像素。纹理地图和我们世界中的物体尺寸之间的关系应该是清楚的:地图中的 32 像素等于我们世界中的 0.5 m。在我们最初的例子中,加农炮是 1×1 米,但是我们当然可以改变它。根据我们的纹理图谱,其中的大炮占用 64×32 像素,我们应该让我们的大炮在我们的世界中有 1×0.5 m 的大小。哇,那非常容易,不是吗?

那么为什么要选择 32 像素来搭配你的世界里的 1 米呢?记住纹理的宽度和高度必须是 2 的幂。使用像 32 这样的 2 的幂像素单位来映射到你的世界中的 0.5 米是艺术家应对纹理大小限制的一种方便的方式。这也使得在像素艺术中更容易得到我们世界中不同物体的大小关系。

请注意,没有什么可以阻止我们在每个世界单位中使用更多的像素。我们可以选择 64 像素或 50 像素来匹配你的世界中的 0.5 m。那么什么是好的像素到米的尺寸呢?这同样取决于游戏运行的屏幕分辨率。让我们做一些计算。

我们的大炮世界以左下角的(0,0)和左上角的(9.6,4.8)为界。这映射到我们的屏幕上。让我们计算一下,在横向模式下,480×320 像素的低端设备的屏幕上,每世界单位有多少像素:

pixelsPerUnitX =屏幕宽度/世界宽度= 480 / 9.6 = 50 像素/米

pixelsPerUnitY =屏幕高度/世界高度= 320 / 6.4 = 50 像素/米

我们的加农炮现在将占用 1×0.5 m 的世界,因此将在屏幕上使用 50×25 像素。我们在纹理中使用了一个 64×32 像素的区域,所以在渲染加农炮时,我们实际上缩小了纹理图像。这完全没问题——OpenGL ES 会为我们自动完成这项工作。根据我们为纹理设置的缩小过滤器,结果将是清晰和像素化的(GL_NEAREST)或稍微*滑的(GL_LINEAR)。如果你想在一个 480x320 像素的设备上得到完美的像素渲染,我们需要稍微缩放一下我们的纹理图像。我们可以使用 25×25 像素的网格大小,而不是 32×32 像素。然而,如果我们只是调整 atlas 图像的大小(或者更确切地说,手动重新绘制所有内容),我们将得到一个 50×50 像素的图像——这对于 OpenGL ES 是不可行的。我们必须在左边和底部添加填充以获得 64×64 的图像(因为 OpenGL ES 需要 2 的幂的宽度和高度)。因此,OpenGL ES 很适合在低端设备上缩小我们的纹理图像。

HTC Desire HD(横向模式下 800×480)这种分辨率更高的设备情况如何?让我们通过以下等式来计算这种屏幕配置:

pixelsPerUnitX =屏幕宽度/世界宽度= 800 / 9.6 = 83 像素/米

pixelsPerUnitY =屏幕高度/世界高度= 480/6.4 = 75 像素/米

我们在 x 轴和 y 轴上有不同的单位像素,因为我们的视锥的纵横比(9.6 / 6.4 = 1.5)不同于屏幕的纵横比(800 / 480 = 1.66)。这在第四章中讨论过,其中概述了几个解决方案。当时,我们的目标是固定的像素大小和纵横比;现在,我们可以采用该方案,并以固定的*截头体宽度和高度为目标。在 HTC Desire HD 的情况下,由于更高的分辨率和不同的纵横比,大炮、炮弹和鲍勃将被放大和拉伸。我们接受这个事实,因为我们希望所有玩家看到我们世界的同一个区域。否则,拥有更高长宽比的玩家将有优势看到更多的世界。

那么,我们如何使用这样的纹理图谱呢?我们只是重新映射我们的矩形。我们不使用所有的纹理,而是使用它的一部分。为了计算出包含在纹理图谱中的图像的角的纹理坐标,我们可以重用前面一个例子中的等式。这里有一个快速复习:

u = x / imageWidth

v = y / imageHeight

这里,u 和 v 是纹理坐标,x 和 y 是像素坐标。Bob 在像素坐标中的左上角在(32,32)。如果我们把它代入前面的等式,我们得到(0.5,0.5)作为纹理坐标。我们可以对任何其他我们需要的角做同样的事情,并基于此为你的矩形顶点设置正确的纹理坐标。

一个例子

我们把这个纹理图谱添加到我们之前的例子中,让它看起来更漂亮。鲍勃将是你的目标。

复制 Camera2DTest,稍微修改一下。将该副本放在一个名为 TextureAtlasTest.java 的文件中,并相应地重命名其中包含的两个类(TextureAtlasTest 和 TextureAtlasScreen)。

我们做的第一件事是向 TextureAtlasScreen 添加一个新成员:

Texture texture;

我们不是在构造函数中创建纹理,而是在 resume()方法中创建。请记住,当我们的应用从暂停状态返回时,纹理会丢失,所以我们必须在 resume()方法中重新创建它们:

@Override
public void resume() {
    texture = new Texture(((GLGame)game), "atlas.png");
}

将图 8-23 中的图像放入项目的素材/文件夹中,并将其命名为 atlas.png。(当然不包含图中所示的网格线。)

接下来,我们需要改变顶点的定义。每个实体类型(加农炮、炮弹和鲍勃)都有一个顶点实例,包含一个由四个顶点和六个索引组成的矩形,构成三个三角形。我们需要做的就是根据纹理图谱给每个顶点添加纹理坐标。我们还将大炮从表示为三角形改为表示为 1×0.5 m 的矩形。下面是我们用来替换构造函数中旧的顶点创建代码的内容:

cannonVertices = new Vertices(glGraphics, 4, 6, false , true );
cannonVertices.setVertices( new float [] { -0.5f, -0.25f, 0.0f, 0.5f,
                                          0.5f, -0.25f, 1.0f, 0.5f,
                                          0.5f,  0.25f, 1.0f, 0.0f,
                                         -0.5f,  0.25f, 0.0f, 0.0f },
                                         0, 16);
cannonVertices.setIndices( new short [] {0, 1, 2, 2, 3, 0}, 0, 6);

ballVertices = new Vertices(glGraphics, 4, 6, false , true );
ballVertices.setVertices( new float [] { -0.1f, -0.1f, 0.0f, 0.75f,
                                        0.1f, -0.1f, 0.25f, 0.75f,
                                        0.1f,  0.1f, 0.25f, 0.5f,
                                       -0.1f,  0.1f, 0.0f, 0.5f },
                                        0, 16);
ballVertices.setIndices( new short [] {0, 1, 2, 2, 3, 0}, 0, 6);

targetVertices = new Vertices(glGraphics, 4, 6, false , true );
targetVertices.setVertices( new float [] { -0.25f, -0.25f, 0.5f, 1.0f,
                                          0.25f, -0.25f, 1.0f, 1.0f,
                                          0.25f,  0.25f, 1.0f, 0.5f,
                                         -0.25f,  0.25f, 0.5f, 0.5f },
                                         0, 16);
targetVertices.setIndices( new short [] {0, 1, 2, 2, 3, 0}, 0, 6);

我们的每个网格现在由四个顶点组成,每个顶点都有一个 2D 位置和纹理坐标。我们向网格添加六个索引,指定我们想要渲染的两个三角形。大炮在 y 轴上稍微小一点。它现在的大小为 1×0.5 米,而不是 1×1 米。这也反映在构造函数中早期的 Cannon 对象的构造中:

cannon = new Cannon(0, 0, 1, 0.5f);

由于我们不对加农炮本身进行任何碰撞检测,所以在构造函数中设置什么大小并不重要;我们这样做只是为了一致性。

我们需要改变的最后一件事是我们的渲染方法。这是它最辉煌的时刻:

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
    camera.setViewportAndMatrices();

    gl.glEnable(GL10.GL_BLEND);
    gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
    gl.glEnable(GL10.GL_TEXTURE_2D);
    texture.bind();

    targetVertices.bind();
    int len = targets.size();
    for (int i = 0; i < len; i++) {
        GameObject target = targets.get(i);
        gl.glLoadIdentity();
        gl.glTranslatef(target.position.x, target.position.y, 0);
        targetVertices.draw(GL10.GL_TRIANGLES, 0, 6);
    }

    targetVertices.unbind();

    gl.glLoadIdentity();
    gl.glTranslatef(ball.position.x, ball.position.y, 0);
    ballVertices.bind();
    ballVertices.draw(GL10.GL_TRIANGLES, 0, 6);
    ballVertices.unbind();

    gl.glLoadIdentity();
    gl.glTranslatef(cannon.position.x, cannon.position.y, 0);
    gl.glRotatef(cannon.angle, 0, 0, 1);
    cannonVertices.bind();
    cannonVertices.draw(GL10.GL_TRIANGLES, 0, 6);
    cannonVertices.unbind();
}

在这里,我们启用混合,设置适当的混合功能,启用纹理,并绑定我们的 atlas 纹理。我们还稍微修改了 cannonVertices.draw()调用,它现在呈现两个三角形,而不是一个。这就是全部了。图 8-24 显示了我们整容手术的结果。

9781430246770_Fig08-24.jpg

图 8-24。用纹理图谱美化大炮实例

关于纹理贴图集,您还需要了解一些事情:

  • 当您使用 GL_LINEAR 作为缩小和/或放大过滤器时,当图谱中的两幅图像相互接触时,可能会出现伪像。这是因为纹理映射器实际上从屏幕上的一个像素的纹理中提取了四个最*的纹理元素。当它为一幅图像的边界这样做时,它也从图集的相邻图像中获取纹理元素。您可以通过在图像之间引入两个像素的空白边框来消除这个问题。更好的是,您可以复制每个图像的边框像素。第一个解决方案更简单——只要确保你的纹理保持 2 的幂。
  • 没有必要把地图集里的所有图像都放在一个固定的网格里。你可以尽可能紧密地将任意大小的图像放入地图集。你所需要知道的就是一张图像在图集中的开始和结束位置,这样你就可以为它计算合适的纹理坐标。然而,打包任意大小的图像是一个不小的问题。网上有一些工具可以帮助你创建一个纹理图谱;只要搜索一下,你就会发现有太多的选择。
  • 通常你不能将游戏中的所有图像组合到一个纹理中。记住,不同的设备有不同的最大纹理尺寸。您可以放心地假设所有设备都支持 512×512 像素(甚至 1024×1024)的纹理大小。所以,你可以有多个纹理图谱。您应该尝试将在屏幕上一起看到的对象分组到一个 atlas 中——比方说,一个 atlas 中的所有 1 级对象,另一个中的所有 2 级对象,另一个中的所有 UI 元素,等等。在最终确定你的艺术素材之前,考虑一下逻辑分组。
  • 还记得我们如何在 Nom 先生中动态地抽取数字吗?我们为此使用了纹理图谱。事实上,e 可以通过一个纹理图谱完成所有的动态文本渲染。只需将游戏所需的所有角色放入地图中,并通过将矩形映射到地图中适当的角色来按需渲染它们。你可以在网上找到一些工具,可以为你生成一个位图字体。为了接下来的章节,我们坚持 Nom 先生使用的方法:静态文本将作为一个整体进行预渲染,只有动态文本(例如,高分中的数字)将通过地图集进行渲染。

你可能已经注意到 bob 在被炮弹击中之前就消失了。这是因为我们的边界形状有点太大了。我们在鲍勃和炮弹周围留有一些空白。解决办法是什么?只是让边界形状小一点。你应该有这种感觉,所以操纵源,直到碰撞感觉正确。你在开发一款游戏的时候会经常发现这样的微调“机会”。除了好的关卡设计,微调可能是游戏开发中最重要的部分之一。让事情感觉正确可能很难,但一旦你达到了超级马里奥兄弟中的完美水*,这将是非常令人满意的。可悲的是,这是没有什么可以教的,因为它取决于你的游戏的外观和感觉。把它看作是区分好游戏和坏游戏的魔法酱。

注意为了处理刚才提到的消失问题,使边界矩形比它们的图形表示稍小一些,以允许在碰撞触发之前有一些重叠。

纹理区域、精灵和批次:隐藏 OpenGL ES

到目前为止,对于 cannon 示例,我们的代码由许多样板文件组成,其中一些可以简化。其中一个领域是顶点实例的定义。用七行代码来定义一个纹理矩形是很乏味的。我们可以改进的另一个领域是纹理地图中图像的纹理坐标的手动计算。最后,当我们想要渲染我们的 2D 矩形时,有许多高度重复的代码。还有一种更好的方法来呈现许多对象,而不是每个对象一个 draw 调用。我们可以通过引入一些新概念来解决所有这些问题:

  • 纹理区域 : 我们在上一个例子中使用了纹理区域。纹理区域是单一纹理中的矩形区域(例如,在我们的图集中包含大炮的区域)。我们想要一个很好的类,它可以封装所有将像素坐标转换为纹理坐标的讨厌的计算。
  • 精灵 : 精灵很像一个游戏对象。它有一个位置(可能还有方向和比例),以及一个图形范围。你通过一个矩形渲染一个精灵,就像你渲染鲍勃或者大炮一样。事实上,Bob 和其他对象的图形表示可以也应该被视为精灵。精灵也映射到纹理中的一个区域。这就是纹理区域出现的原因。虽然在游戏中直接组合精灵和游戏对象很诱人,但是你应该按照模型-视图-控制器的模式将它们分开。图形和模型代码之间的清晰分离有助于更好的设计。
  • 精灵批处理 : 一个精灵批处理器负责一次渲染多个精灵。为此,精灵批处理程序需要知道每个精灵的位置、大小和纹理区域。sprite 批处理程序将是我们摆脱每个对象的多次绘制调用和矩阵操作的神奇成分。

这些概念是高度相互关联的,将在下面讨论。

TextureRegion 类

因为我们已经处理了纹理区域,所以弄清楚我们需要什么应该很简单。我们知道如何从像素坐标转换到纹理坐标。我们希望有一个类,在这个类中,我们可以指定纹理贴图集中图像的像素坐标,然后存储贴图集区域的相应纹理坐标以供进一步处理(例如,当我们希望渲染一个精灵时)。事不宜迟,清单 8-16 展示了我们的 TextureRegion 类。

清单 8-16。TextureRegion.java;将像素坐标转换为纹理坐标

package com.badlogic.androidgames.framework.gl;

public class TextureRegion {
    public final float u1, v1;
    public final float u2, v2;
    public final Texture texture;

    public TextureRegion(Texture texture, float x, float y, float width, float height) {
        this .u1 = x / texture.width;
        this .v1 = y / texture.height;
        this .u2 = this .u1 + width / texture.width;
        this .v2 = this .v1 + height / texture.height;
        this .texture = texture;
    }

}

TextureRegion 将区域的左上角(u1,v1)和右下角(u2,v2)的纹理坐标存储在纹理坐标中。构造函数获取纹理和左上角,以及像素坐标中区域的宽度和高度。要为加农炮构建纹理区域,我们可以这样做:

TextureRegion cannonRegion = new TextureRegion(texture, 0, 0, 64, 32);

类似地,我们可以为 Bob 构建一个区域:

TextureRegion bobRegion = new TextureRegion(texture, 32, 32, 32, 32);

诸如此类。我们可以在已经创建的示例代码中使用它,并使用 TextureRegion.u1、v1、u2 和 v2 成员来指定矩形顶点的纹理坐标。但是我们不需要这样做,因为我们想完全摆脱这些乏味的定义。这就是我们可以使用雪碧配料器的原因。

SpriteBatcher 类

如前所述,子画面可以通过其位置、大小和纹理区域(以及可选的旋转和缩放)轻松定义。它只是我们世界空间中的一个图形矩形。为了使事情变得简单,我们坚持约定,位置在精灵的中心,矩形围绕中心构造。现在我们可以有一个 Sprite 类,并像这样使用它:

Sprite bobSprite = new Sprite(20, 20, 0.5f, 0.5f, bobRegion);

这将构造一个新的精灵,其中心在(20,20)处,向每边延伸 0.25 米,并使用 bobRegion 纹理区域。但是我们可以这样做:

spriteBatcher.drawSprite(bob.x, bob.y, BOB_WIDTH, BOB_HEIGHT, bobRegion);

现在看起来好多了。我们不需要构建另一个对象来表示对象的图形化方面。相反,我们按需绘制 Bob 的实例。我们也可以有一个重载的方法:

spriteBatcher.drawSprite(cannon.x, cannon.y, CANNON_WIDTH, CANNON_HEIGHT, cannon.angle, cannonRegion);

这会画出旋转了角度的大炮。那么我们如何实现 sprite 批处理程序呢?顶点实例在哪里?让我们想想分批配料器是如何工作的。

究竟什么是批处理?在图形社区中,批处理被定义为将多个绘制调用合并成一个绘制调用。这让 GPU 很高兴,正如在第七章中讨论的。雪碧批处理提供了一种方法来实现这一点。以下是如何:

  • 批处理程序有一个最初为空的缓冲区(或者在我们发出清除信号后变空)。该缓冲区将保存顶点。在我们的例子中,它将是一个简单的浮点数组。
  • 每次调用 SpriteBatcher.drawSprite()方法时,我们都会根据作为参数指定的位置、大小、方向和纹理区域向缓冲区添加四个顶点。这也意味着我们必须手动旋转和*移顶点位置,而没有 OpenGL ES 的帮助。不过,不用担心,Vector2 类的代码在这里会派上用场。这是消除所有听牌要求的关键。
  • 一旦我们指定了我们想要渲染的所有精灵,我们告诉精灵批处理程序将精灵的所有矩形的顶点一次性提交给 GPU,然后调用实际的 OpenGL ES 绘制方法来渲染所有的矩形。为此,我们可以将 float 数组的内容传输到 Vertices 实例,并使用它来呈现矩形。

注意你只能批量处理使用相同纹理的精灵。然而,这不是一个大问题,因为你将使用纹理贴图集。

sprite 批处理程序的通常使用模式如下:

batcher.beginBatch(texture);
// call batcher.drawSprite() as often as needed, referencing regions in the texture
batcher.endBatch();

对 SpriteBatcher.beginBatch()的调用告诉批处理程序两件事:它应该清除它的缓冲区,它应该使用我们传入的纹理。为了方便起见,我们将在这个方法中绑定纹理。

接下来,我们根据需要渲染尽可能多的引用纹理区域的精灵。这将填充缓冲区,为每个精灵添加四个顶点。

对 SpriteBatcher.endBatch()的调用向 SpriteBatcher 发出信号,表明我们已经完成了批量 sprite 的渲染,现在它应该将顶点上传到 GPU 进行实际渲染。我们将对顶点实例使用索引渲染,因此除了浮点数组缓冲区中的顶点之外,我们还需要指定索引。然而,由于我们总是呈现矩形,我们可以在 SpriteBatcher 的构造函数中预先生成一次索引。为此,我们需要知道批处理程序每批可以绘制多少精灵。通过对每批可以渲染的精灵数量进行严格限制,我们不需要增加任何其他缓冲区的数组;我们可以在构造函数中一次性分配这些数组和缓冲区。

一般的机制相当简单。SpriteBatcher.drawSprite()方法可能看起来很神秘,但这不是一个大问题(如果我们暂时不考虑旋转和缩放的话)。我们需要做的就是计算顶点位置和纹理坐标,正如参数所定义的那样。在前面的例子中,我们已经手动完成了这一步——例如,当我们为加农炮、炮弹和鲍勃定义矩形时。我们可以在 SpriteBatcher.drawSprite()方法中做或多或少相同的事情,只是根据方法的参数自动进行。所以让我们来看看 SpriteBatcher。清单 8-17 显示了代码。

清单 8-17。摘自 SpriteBatcher.java 的,没有旋转和缩放

package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GL10;

import android.util.FloatMath;

import com.badlogic.androidgames.framework.impl.GLGraphics;
import com.badlogic.androidgames.framework.math.Vector2;

public class SpriteBatcher {
    final float [] verticesBuffer;
    int bufferIndex;
    final Vertices vertices;
    int numSprites;

我们先看成员。成员 verticesBuffer 是一个临时浮动数组,我们在其中存储当前批处理的精灵的顶点。成员 bufferIndex 表示我们应该从浮点数组的什么地方开始写下一个顶点。成员顶点是用于渲染批的顶点实例。它还存储了我们马上要定义的索引。成员 numSprites 保存当前批次中到目前为止抽取的数字。

    public SpriteBatcher(GLGraphics glGraphics, int maxSprites) {
        this .verticesBuffer = new float [maxSprites*4*4];
        this .vertices = new Vertices(glGraphics, maxSprites*4, maxSprites*6, false , true );
        this .bufferIndex = 0;
        this .numSprites = 0;

        short [] indices = new short [maxSprites*6];
        int len = indices.length;
        short j = 0;
        for (int i = 0; i < len; i += 6, j += 4) {
                indices[i + 0] = ( short )(j + 0);
                indices[i + 1] = ( short )(j + 1);
                indices[i + 2] = ( short )(j + 2);
                indices[i + 3] = ( short )(j + 2);
                indices[i + 4] = ( short )(j + 3);
                indices[i + 5] = ( short )(j + 0);
        }
        vertices.setIndices(indices, 0, indices.length);
    }

转到构造函数,我们有两个参数:创建顶点实例所需的 GLGraphics 实例,以及批处理程序在一个批处理中能够呈现的最大精灵数量。我们在构造函数中做的第一件事是创建浮点数组。每个 sprite 有四个顶点,每个顶点占用四个浮点(两个用于 x 和 y 坐标,另外两个用于纹理坐标)。我们最多可以有 maxSprites 个 Sprites,所以缓冲区需要 4 × 4 × maxSprites 个浮点。

接下来,我们创建顶点实例。我们需要它存储 maxSprites × 4 个顶点和 maxSprites × 6 个索引。我们告诉顶点实例,我们不仅有位置属性,还有每个顶点的纹理坐标。然后,我们将 bufferIndex 和 numSprites 成员初始化为零。我们为顶点实例创建索引。我们只需要这样做一次,因为索引永远不会改变。一批中的第一个子画面将总是具有索引 0,1,2,2,3,0;下一个精灵会有 4,5,6,6,7,4;等等。我们可以预先计算这些,并将它们存储在顶点实例中。这样,我们只需要设置它们一次,而不是为每个精灵设置一次。

    public void beginBatch(Texture texture) {
        texture.bind();
        numSprites = 0;
        bufferIndex = 0;
    }

接下来是 beginBatch()方法。它绑定纹理并重置 numSprites 和 bufferIndex 成员,以便第一个 sprite 的顶点将被插入到 verticesBuffer float 数组的前面。

    public void endBatch() {
        vertices.setVertices(verticesBuffer, 0, bufferIndex);
        vertices.bind();
        vertices.draw(GL10.*GL_TRIANGLES*, 0, numSprites * 6);
        vertices.unbind();
    }

下一个方法是 end batch();我们将调用它来完成并绘制当前批次。它首先将为该批次定义的顶点从浮点数组传输到顶点实例。剩下的工作就是绑定 Vertices 实例,绘制 numSprites × 2 个三角形,然后再次解除对 Vertices 实例的绑定。因为我们使用索引渲染,所以我们指定要使用的索引数量——每个 sprite 六个索引乘以 numSprites。这就是渲染的全部内容。

    public void drawSprite(float x, float y, float width, float height, TextureRegion region) {
        float halfWidth = width / 2;
        float halfHeight = height / 2;
        float x1 = x - halfWidth;
        float y1 = y - halfHeight;
        float x2 = x + halfWidth;
        float y2 = y + halfHeight;

        verticesBuffer[bufferIndex++] = x1;
        verticesBuffer[bufferIndex++] = y1;
        verticesBuffer[bufferIndex++] = region.u1;
        verticesBuffer[bufferIndex++] = region.v2;

        verticesBuffer[bufferIndex++] = x2;
        verticesBuffer[bufferIndex++] = y1;
        verticesBuffer[bufferIndex++] = region.u2;
        verticesBuffer[bufferIndex++] = region.v2;

        verticesBuffer[bufferIndex++] = x2;
        verticesBuffer[bufferIndex++] = y2;
        verticesBuffer[bufferIndex++] = region.u2;
        verticesBuffer[bufferIndex++] = region.v1;

        verticesBuffer[bufferIndex++] = x1;
        verticesBuffer[bufferIndex++] = y2;
        verticesBuffer[bufferIndex++] = region.u1;
        verticesBuffer[bufferIndex++] = region.v1;

        numSprites++;
    }

下一个方法 drawSprite(),是 SpriteBatcher 类的主力。它获取精灵中心的 x 和 y 坐标、宽度和高度以及它映射到的纹理区域。该方法的职责是从当前 bufferIndex 开始向浮点数组添加四个顶点。这四个顶点形成一个纹理映射的矩形。我们计算左下角(x1,y1)和右上角(x2,y2)的位置,并使用这四个变量以及来自 TextureRegion 的纹理坐标来构建顶点。从左下角的顶点开始,按逆时针顺序添加顶点。一旦它们被添加到 float 数组中,我们就增加 numSprites 计数器,并等待另一个 sprite 被添加或者批处理被完成。

这就是我们要做的一切。我们只是简单地通过在一个浮动数组中缓冲预转换的顶点并一次性渲染它们,从而消除了许多绘制方法。与我们之前使用的方法相比,这将大大提高我们的 2D 精灵渲染性能。更少的 OpenGL ES 状态改变和更少的绘图调用让 GPU 很开心。

我们还需要实现一个东西:一个可以绘制旋转精灵的 SpriteBatcher.drawSprite()方法。我们所要做的就是在不添加位置的情况下构建四个角顶点,围绕原点旋转它们,添加精灵的位置,以便将顶点放置在世界空间中,然后像前面的绘制方法一样继续进行。为此,我们可以使用 Vector2.rotate(),但是这将引入额外的函数调用开销。因此,我们复制了 Vector2.rotate()中的代码,并在可能的地方进行了优化。SpriteBatcher 类的最后一个方法类似于清单 8-18 中的。

清单 8-18。SpriteBatcher.java 其余地区的;一种绘制旋转精灵的方法

    public void drawSprite(float x, float y, float width, float height, float angle, TextureRegion region) {
        float halfWidth = width / 2;
        float halfHeight = height / 2;

        float rad = angle * Vector2.*TO_RADIANS*;
        float cos = FloatMath.*cos*(rad);
        float sin = FloatMath.*sin*(rad);

        float x1 = -halfWidth * cos - (-halfHeight) * sin;
        float y1 = -halfWidth * sin + (-halfHeight) * cos;
        float x2 = halfWidth * cos - (-halfHeight) * sin;
        float y2 = halfWidth * sin + (-halfHeight) * cos;
        float x3 = halfWidth * cos - halfHeight * sin;
        float y3 = halfWidth * sin + halfHeight * cos;
        float x4 = -halfWidth * cos - halfHeight * sin;
        float y4 = -halfWidth * sin + halfHeight * cos;

        x1 += x;
        y1 += y;
        x2 += x;
        y2 += y;
        x3 += x;
        y3 += y;
        x4 += x;
        y4 += y;

        verticesBuffer[bufferIndex++] = x1;
        verticesBuffer[bufferIndex++] = y1;
        verticesBuffer[bufferIndex++] = region.u1;
        verticesBuffer[bufferIndex++] = region.v2;

        verticesBuffer[bufferIndex++] = x2;
        verticesBuffer[bufferIndex++] = y2;
        verticesBuffer[bufferIndex++] = region.u2;
        verticesBuffer[bufferIndex++] = region.v2;

        verticesBuffer[bufferIndex++] = x3;
        verticesBuffer[bufferIndex++] = y3;
        verticesBuffer[bufferIndex++] = region.u2;
        verticesBuffer[bufferIndex++] = region.v1;

        verticesBuffer[bufferIndex++] = x4;
        verticesBuffer[bufferIndex++] = y4;
        verticesBuffer[bufferIndex++] = region.u1;
        verticesBuffer[bufferIndex++] = region.v1;

        numSprites++;
    }

}

除了我们构建所有的四个角点而不仅仅是两个相对的角点之外,我们和简单的绘图方法做的一样。这是旋转所需要的。剩下的和之前一样。

缩放呢?我们并不明确需要另一种方法,因为缩放精灵只需要缩放它的宽度和高度。我们可以在这两个绘制方法之外完成,所以没有必要为精灵的缩放绘制提供另一堆方法。

这就是使用 OpenGL ES 进行闪电般快速的精灵渲染背后的大秘密。

使用 SpriteBatcher 类

现在,我们可以在 cannon 示例中合并 TextureRegion 和 SpriteBatcher 类。复制 TextureAtlas 示例,并将其重命名为 SpriteBatcherTest。其中包含的类可以称为 SpriteBatcherTest 和 SpriteBatcherScreen。

我们去掉了屏幕类中的顶点成员。我们不再需要他们了,因为泼妇会替我们做所有的脏活。相反,我们添加以下成员:

TextureRegion cannonRegion;
TextureRegion ballRegion;
TextureRegion bobRegion;
SpriteBatcher batcher;

现在,我们的 atlas 中的三个对象都有了一个 TextureRegion,以及一个 SpriteBatcher。

接下来,修改屏幕的构造函数。去掉所有顶点实例化和初始化代码,用一行代码替换它:

batcher = new SpriteBatcher(glGraphics, 100);

这将把我们的 batcher 成员设置为一个新的 SpriteBatcher 实例,它可以一次渲染 100 个 sprites。

TextureRegions 在 resume()方法中初始化,因为它们依赖于纹理:

@Override
public void resume() {
    texture = new Texture(((GLGame)game), "atlas.png");
    cannonRegion = new TextureRegion(texture, 0, 0, 64, 32);
    ballRegion = new TextureRegion(texture, 0, 32, 16, 16);
    bobRegion = new TextureRegion(texture, 32, 32, 32, 32);
}

这里没有惊喜。我们需要改变的最后一件事是 present()方法。你会惊讶它现在看起来有多干净。这是:

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
    camera.setViewportAndMatrices();

    gl.glEnable(GL10.GL_BLEND);
    gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
    gl.glEnable(GL10.GL_TEXTURE_2D);

    batcher.beginBatch(texture);

    int len = targets.size();
    for (int i = 0; i < len; i++) {
        GameObject target = targets.get(i);
        batcher.drawSprite(target.position.x, target.position.y, 0.5f, 0.5f, bobRegion);
    }

    batcher.drawSprite(ball.position.x, ball.position.y, 0.2f, 0.2f, ballRegion);
    batcher.drawSprite(cannon.position.x, cannon.position.y, 1, 0.5f, cannon.angle, cannonRegion);
    batcher.endBatch();
}

那真是太好了。我们现在发布的唯一 OpenGL ES 调用是为了清除屏幕,启用混合和纹理,以及设置混合功能。剩下的就是纯粹的 SpriteBatcher 和 Camera2D 善良了。因为我们所有的对象共享同一个纹理贴图集,所以我们可以一次渲染它们。我们用 atlas 纹理调用 batcher.beginBatch(),用简单的画图方法渲染所有的 Bob 目标,渲染球(还是用简单的画图方法),最后用可以旋转一个 sprite 的画图方法渲染大炮。我们通过调用 batcher.endBatch()来结束该方法,这将实际上把你的精灵的几何图形传输到 GPU 并渲染一切。

衡量绩效

那么 SpriteBatcher 方法比您在 BobTest 中使用的方法快多少呢?让我们使用新的 OpenGL ES 类来重写 BobTest 代码。我们在代码中添加了一个 FPSCounter,将目标的数量增加到 100,并将 SpriteBatcher 可以渲染的精灵的最大数量设置为 102,因为我们渲染了 100 个目标、1 个球和 1 门大炮。以下是几款旧设备的测试结果,代表了最低要求:

Hero (1.5):
12-27 23:51:09.400: DEBUG/FPSCounter(2169): fps: 31
12-27 23:51:10.440: DEBUG/FPSCounter(2169): fps: 31
12-27 23:51:11.470: DEBUG/FPSCounter(2169): fps: 32
12-27 23:51:12.500: DEBUG/FPSCounter(2169): fps: 32

Droid (2.1.1):
12-27 23:50:23.416: DEBUG/FPSCounter(8145): fps: 56
12-27 23:50:24.448: DEBUG/FPSCounter(8145): fps: 56
12-27 23:50:25.456: DEBUG/FPSCounter(8145): fps: 56
12-27 23:50:26.456: DEBUG/FPSCounter(8145): fps: 55

Nexus One (2.2.1):
12-27 23:46:57.162: DEBUG/FPSCounter(754): fps: 61
12-27 23:46:58.171: DEBUG/FPSCounter(754): fps: 61
12-27 23:46:59.181: DEBUG/FPSCounter(754): fps: 61
12-27 23:47:00.181: DEBUG/FPSCounter(754): fps: 60

在得出任何结论之前,我们也通过添加 FPSCounter 来测试旧方法。以下是在相同的旧硬件上的结果:

Hero (1.5):
12-27 23:53:45.950: DEBUG/FPSCounter(2303): fps: 46
12-27 23:53:46.720: DEBUG/dalvikvm(2303): GC freed 21811 objects / 524280 bytes in 135ms
12-27 23:53:46.970: DEBUG/FPSCounter(2303): fps: 40
12-27 23:53:47.980: DEBUG/FPSCounter(2303): fps: 46
12-27 23:53:48.990: DEBUG/FPSCounter(2303): fps: 46

Droid (2.1.1):
12-28 00:03:13.004: DEBUG/FPSCounter(8277): fps: 52
12-28 00:03:14.004: DEBUG/FPSCounter(8277): fps: 52
12-28 00:03:15.027: DEBUG/FPSCounter(8277): fps: 53
12-28 00:03:16.027: DEBUG/FPSCounter(8277): fps: 53

Nexus One (2.2.1):
12-27 23:56:09.591: DEBUG/FPSCounter(873): fps: 61
12-27 23:56:10.591: DEBUG/FPSCounter(873): fps: 60
12-27 23:56:11.601: DEBUG/FPSCounter(873): fps: 61
12-27 23:56:12.601: DEBUG/FPSCounter(873): fps: 60

与使用 glTranslate()和类似方法的旧方法相比,使用新的 SpriteBatcher 方法时,Hero 的性能要差得多。Droid 实际上受益于新的 SpriteBatcher 方法,Nexus One 并不真正关心我们使用什么。如果我们再增加 100 个目标,我们会看到 SpriteBatcher 方法在 Nexus One 上也会更快。

那么男主角怎么样了?BobTest 中的问题是我们调用了太多的 OpenGL ES 方法,那么为什么现在我们使用的 OpenGL ES 方法调用越来越少,它的性能却越来越差呢?继续读。

在 FloatBuffer 中解决一个 Bug

主人公在新的 SpriteBatcher 方法下表现不佳的原因并不明显。当我们调用 Vertices.setVertices()时,我们的 SpriteBatcher 将一个浮点数组放入每一帧的直接 ByteBuffer 中。该方法归结为调用 FloatBuffer.put(float[]),这是影响性能的罪魁祸首。虽然 desktop Java 通过真正的大容量内存移动来实现 FloatBuffer 方法,但旧 Android 版本中使用的 Harmony 版本为数组中的每个元素调用 FloatBuffer.put(float)。这是非常不幸的,因为该方法是 JNI 方法,有很多开销(很像 OpenGL ES 方法,也是 JNI 方法)。

有几个解决方案。例如,IntBuffer.put(int[])就没有这个问题。我们可以用 IntBuffer 替换 Vertices 类中的 FloatBuffer,并修改 Vertices.setVertices(),以便它首先将 float 从 float 数组转移到一个临时 int 数组,然后将该 int 数组的内容复制到 IntBuffer。这个解决方案是由同为游戏开发者的 Ryan McNally 提出的,他也在 Android bug 追踪器上报告了这个 bug。它在 Hero 上产生了五倍的性能提升,在其他 Android 设备上稍低。

我们修改顶点类来包含这个修正。我们将顶点成员更改为 IntBuffer。我们添加了一个名为 tmpBuffer 的新成员,它是一个 int 数组。tmpBuffer 数组在顶点的构造函数中初始化,如下所示:

this.tmpBuffer = new int [maxVertices * vertexSize / 4];

我们还从构造函数中的 ByteBuffer 得到一个 IntBuffer 视图,而不是 FloatBuffer:

vertices = buffer.asIntBuffer();

Vertices.setVertices()方法现在如下所示:

public void setVertices(float [] vertices, int offset, int length) {
    this .vertices.clear();
    int len = offset + length;
    for (int i = offset, j = 0; i < len; i++, j++)
        tmpBuffer[j] = Float.floatToRawIntBits(vertices[i]);
    this .vertices.put(tmpBuffer, 0, length);
    this .vertices.flip();
}

首先,我们将 vertices 参数的内容传递给 tmpBuffer。静态方法 Float.floatToRawIntBits()将浮点的位模式重新解释为 int。然后,我们需要将 int 数组的内容复制到 IntBuffer,以前称为 FloatBuffer。它能提高性能吗?现在,在 Hero、Droid 和 Nexus One 上运行 SpriteBatcherTest 会产生以下输出:

Hero (1.5):
12-28 00:24:54.770: DEBUG/FPSCounter(2538): fps: 61
12-28 00:24:54.770: DEBUG/FPSCounter(2538): fps: 61
12-28 00:24:55.790: DEBUG/FPSCounter(2538): fps: 62
12-28 00:24:55.790: DEBUG/FPSCounter(2538): fps: 62

Droid (2.1.1):
12-28 00:35:48.242: DEBUG/FPSCounter(1681): fps: 61
12-28 00:35:49.258: DEBUG/FPSCounter(1681): fps: 62
12-28 00:35:50.258: DEBUG/FPSCounter(1681): fps: 60
12-28 00:35:51.266: DEBUG/FPSCounter(1681): fps: 59

Nexus One (2.2.1):
12-28 00:27:39.642: DEBUG/FPSCounter(1006): fps: 61
12-28 00:27:40.652: DEBUG/FPSCounter(1006): fps: 61
12-28 00:27:41.662: DEBUG/FPSCounter(1006): fps: 61
12-28 00:27:42.662: DEBUG/FPSCounter(1006): fps: 61

不,那不是打印错误。英雄现在真的达到 60 FPS 了。一个由五行代码组成的变通方法将我们的性能提高了 50%。这个机器人也从这次修复中受益匪浅。

自 Android 2.3 以来,该问题已得到修复。但是,仍然有许多设备运行 Android 2.2,因此您应该保留此变通方法以保持向后兼容性。

注意还有另一种更快的解决方法。它涉及一个自定义的 JNI 方法,该方法在本机代码中进行内存移动。我们将在第十三章中研究这个问题。

精灵动画

如果你玩过 2D 电子游戏,你就会知道我们仍然缺少一个重要的组成部分:精灵动画。动画由关键帧组成,产生运动的错觉。图 8-25 展示了一个由阿里·费尔德曼制作的漂亮的动画精灵(他的免版税精灵的一部分)。

9781430246770_Fig08-25.jpg

图 8-25。阿里·费尔德曼的《行走的穴居人》

图像大小为 256×64 像素,每个关键帧为 64×64 像素。要制作动画,我们只需使用第一个关键帧绘制一段时间(比如 0.25 秒)的精灵,然后切换到下一个关键帧,依此类推。当我们到达最后一帧时,我们有几个选择:我们可以停留在最后一个关键帧,再次从头开始(并执行所谓的循环动画)或反向播放动画。

我们可以通过 TextureRegion 和 SpriteBatcher 类轻松做到这一点。通常,我们不仅有一个单一的动画,就像图 8-25 中的那样,而且在一个单独的地图集中有更多的动画。除了行走动画,我们还可以有跳跃动画,攻击动画等等。对于每个动画,我们需要知道帧持续时间,它告诉我们在切换到下一帧之前,动画的单个关键帧要保持使用多长时间。

动画课

让我们定义一个动画类的需求,它存储单个动画的数据,例如图 8-25 : 中的行走动画

  • 一个动画包含许多 TextureRegions,它们存储每个关键帧在纹理图谱中的位置。TextureRegions 的顺序与用于播放动画的顺序相同。
  • 动画还存储了帧的持续时间,在我们切换到下一帧之前必须经过这段时间。
  • 动画应该为我们提供一个方法,我们将时间传递到动画所代表的状态(例如,向左走),这将返回适当的 TextureRegion。该方法应该考虑当到达终点时,我们是希望动画循环还是停留在最后一帧。

最后一点很重要,因为它允许我们存储一个动画实例,供我们世界中的多个对象使用。一个对象只是记录它当前的状态,即它是在行走、射击还是跳跃,以及它在这种状态下已经多久。当我们渲染这个对象时,我们使用状态来选择我们想要回放的动画,并使用状态时间从动画中获取正确的 TextureRegion。清单 8-19 显示了我们新动画类的代码。

清单 8-19。Animation.java,一个简单的动画类

package com.badlogic.androidgames.framework.gl;

public class Animation {
    public static final int *ANIMATION_LOOPING* = 0;
    public static final int *ANIMATION_NONLOOPING* = 1;

    final TextureRegion[] keyFrames;
    final float frameDuration;

    public Animation(float frameDuration, TextureRegion ... keyFrames) {
        this .frameDuration = frameDuration;
        this .keyFrames = keyFrames;
    }

    public TextureRegion getKeyFrame(float stateTime, int mode) {
        int frameNumber = ( int )(stateTime / frameDuration);
        if (mode ==*ANIMATION_NONLOOPING*) {
            frameNumber = Math.*min*(keyFrames.length-1, frameNumber);
        } else {
            frameNumber = frameNumber % keyFrames.length;
        }
        return keyFrames[frameNumber];
    }

}

首先,我们定义两个用于 getKeyFrame()方法的常量。第一个命令说动画应该循环播放,第二个命令说它应该在最后一帧停止。

接下来,我们定义两个成员:一个保存 TextureRegions 的数组和一个存储帧持续时间的 float。

我们将帧持续时间和保存关键帧的 TextureRegions 传递给构造函数,后者只是存储它们。我们可以制作一个关键帧数组的防御性副本,但是这样会分配一个新的对象,这会让垃圾收集器有点抓狂。

有趣的部分是 getKeyFrame()方法。我们传递对象处于动画表示的状态的时间,以及模式,或者动画。动画 _ 循环或动画。非循环。我们根据 stateTime 计算给定状态下已经播放了多少帧。如果动画不应该循环,我们只需将 frameNumber 固定到 TextureRegion 数组中的最后一个元素。否则,我们取模,这将自动创建我们想要的循环效果(例如,4 % 3 = 1)。剩下的就是返回正确的 TextureRegion。

一个例子

本节展示了如何创建一个名为 AnimationTest 的示例,以及一个名为 AnimationScreen 的相应屏幕。一如既往,只讨论屏幕本身。

我们想渲染一些穴居人,都走在左边。我们的世界将与我们的视见体大小相同,视见体的大小为 4.8×3.2 米(这是任意的;我们可以用任何尺寸。)穴居人是一个大小为 1×1 m 的 DynamicGameObject,我们将从 DynamicGameObject 派生并创建一个名为 caveman 的新类,该类将存储一个额外的成员,该成员跟踪穴居人行走了多长时间。每个穴居人将以 0.5 米/秒的速度移动,或者向左,或者向右。向 caveman 类添加一个 update()方法,根据 delta 时间和他的速度更新 Caveman 的位置。如果一个穴居人到达了世界的左边或右边,我们把他设定到世界的另一边。我们使用图 8-25 中的图像,并相应地创建纹理区域实例和动画实例。对于渲染,我们使用 Camera2D 实例和 SpriteBatcher,因为它们很漂亮。清单 8-20 显示了穴居人类的代码。

清单 8-20。摘自 AnimationTest.java;展示内部穴居人阶层

static final float *WORLD_WIDTH* = 4.8f;
static final float *WORLD_HEIGHT* = 3.2f;

static class Caveman extends DynamicGameObject {
    public float walkingTime = 0;
    public Caveman(float x, float y, float width, float height) {
        super (x, y, width, height);
        this .position.set((float )Math.*random*() **WORLD_WIDTH*,
                          (float )Math.*random*() **WORLD_HEIGHT*);
        this .velocity.set(Math.*random*() > 0.5f?-0.5f:0.5f, 0);
        this .walkingTime = (float )Math.*random*() * 10;
    }

    public void update(float deltaTime) {
        position.add(velocity.x * deltaTime, velocity.y * deltaTime);
        if (position.x < 0) position.x =*WORLD_WIDTH*;
        if (position.x >*WORLD_WIDTH*) position.x = 0;
        walkingTime += deltaTime;
    }

}

两个常量 WORLD_WIDTH 和 WORLD_HEIGHT 是封闭 AnimationTest 类的一部分,由内部类使用。我们的世界大小为 4.8×3.2 米。

接下来是内部穴居人类,它扩展了 DynamicGameObject,因为我们将基于速度移动穴居人。我们定义了一个额外的成员来记录穴居人行走了多长时间。在构造器中,我们把穴居人放在一个随机的位置,让他向左或向右走。我们将 walkingTime 成员初始化为 0 到 10 之间的一个数字;这样我们的穴居人就不会同步行走。

update()方法基于穴居人的速度和时间增量推进他。如果他离开了这个世界,我们把他重置到左边或者右边。我们将 delta 时间加到 walkingTime 上来记录他走了多长时间。

清单 8-21 显示了 AnimationScreen 类。

清单 8-21。摘自 AnimationTest.java;AnimationScreen 类

class AnimationScreen extends Screen {
    static final int NUM_CAVEMEN = 10;
    GLGraphics glGraphics;
    Caveman[] cavemen;
    SpriteBatcher batcher;
    Camera2D camera;
    Texture texture;
    Animation walkAnim;

我们的屏幕类有通常的嫌疑人作为成员。我们有一个 GLGraphics 实例、一个 Caveman 数组、一个 SpriteBatcher、一个 Camera2D、包含行走关键帧的纹理和一个动画实例。

    public AnimationScreen(Game game) {
        super (game);
        glGraphics = ((GLGame)game).getGLGraphics();
        cavemen = new Caveman[*NUM_CAVEMEN*];
        for (int i = 0; i <*NUM_CAVEMEN*; i++) {
            cavemen[i] = new Caveman((float )Math.*random*(), (float )Math.*random*(), 1, 1);
        }

        batcher = new SpriteBatcher(glGraphics,*NUM_CAVEMEN*);
        camera = new Camera2D(glGraphics,*WORLD_WIDTH*,*WORLD_HEIGHT*);
    }

在构造函数中,创建 Caveman 实例,以及 SpriteBatcher 和 Camera2D。

    @Override
    public void resume() {
        texture = new Texture(((GLGame)game), "walkanim.png");
        walkAnim = new Animation( 0.2f,
                                  new TextureRegion(texture, 0, 0, 64, 64),
                                  new TextureRegion(texture, 64, 0, 64, 64),
                                  new TextureRegion(texture, 128, 0, 64, 64),
                                  new TextureRegion(texture, 192, 0, 64, 64));
    }

在 resume()方法中,我们从素材文件 walkanim.png 加载包含动画关键帧的纹理贴图集,这与图 8-25 中的所示相同。之后,我们创建动画实例,将帧持续时间设置为 0.2 s,并为纹理贴图集中的每个关键帧传入一个 TextureRegion。

    @Override
    public void update(float deltaTime) {
        int len = cavemen.length;
        for (int i = 0; i < len; i++) {
            cavemen[i].update(deltaTime);
        }

    }

update()方法只是遍历所有 Caveman 实例,并使用当前的 delta 时间调用它们的 Caveman.update()方法。这将使穴居人移动,并将更新他们的行走时间。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        camera.setViewportAndMatrices();

        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);
        gl.glEnable(GL10.*GL_TEXTURE_2D*);

        batcher.beginBatch(texture);
        int len = cavemen.length;
        for (int i = 0; i < len; i++) {
            Caveman caveman = cavemen[i];
            TextureRegion keyFrame = walkAnim.getKeyFrame(caveman.walkingTime, Animation.*ANIMATION_LOOPING*);
            batcher.drawSprite(caveman.position.x, caveman.position.y, caveman.velocity.x < 0?1:-1, 1, keyFrame);
        }

        batcher.endBatch();
    }

    @Override
    public void pause() {
    }

    @Override
    public void dispose() {
    }

}

最后,我们有 present()方法。我们首先清理屏幕,并通过相机设置视窗和投影矩阵。接下来,我们启用混合和纹理映射,并设置混合功能。我们通过告诉 sprite 批处理程序我们想要使用动画纹理贴图集开始一个新的批处理来开始渲染。接下来,我们循环遍历所有的穴居人并渲染他们。对于每个穴居人,我们首先根据穴居人的行走时间从动画实例中获取正确的关键帧。我们指定动画应该循环播放。然后我们在穴居人的位置绘制正确的纹理区域。

但是我们在这里用宽度参数做什么呢?请记住,我们的动画纹理只包含“向左走”动画的关键帧。我们想要水*翻转纹理,以防穴居人向右走,这可以通过指定负宽度来实现。如果你不信任我们,回到 SpriteBatcher 代码,检查这是否有效。我们通过指定一个负的宽度来翻转精灵的矩形。我们也可以在垂直方向上做同样的事情,指定一个负的高度。

图 8-26 显示了我们行走的穴居人。

9781430246770_Fig08-26.jpg

图 8-26。穴居人行走

这就是用 OpenGL ES 制作一个好的 2D 游戏所需要知道的一切。请注意我们仍然是如何将游戏逻辑和表现彼此分开的。穴居人不需要知道他正在被渲染。因此,他不会保留任何与渲染相关的成员,比如动画实例或纹理。我们需要做的就是记录穴居人的状态,以及他在这种状态下呆了多久。连同他的位置和大小,我们可以很容易地使用我们的小助手类来渲染他。

摘要

你现在应该已经准备好创建任何你想要的 2D 游戏了。我们已经学习了向量以及如何使用它们,从而产生了一个很好的、可重用的 Vector2 类。我们还研究了创造弹道炮弹等东西的基础物理学。碰撞检测也是大多数游戏的一个重要部分,现在你应该知道如何通过 SpatialHashGrid 正确有效地进行碰撞检测。我们探索了一种方法,通过创建 GameObject 和 DynamicGameObject 类来跟踪对象的状态和形状,从而将游戏逻辑和对象从渲染中分离出来。我们介绍了通过 OpenGL ES 实现 2D 相机的概念是多么容易,所有这些都基于一个名为 glOrthof()的方法。我们学习了纹理地图,为什么我们需要它们,以及如何使用它们。这是通过引入纹理区域,精灵,以及我们如何通过精灵来有效地渲染它们来扩展的。最后,我们研究了精灵动画,它实现起来非常简单。

值得注意的是,本章涵盖的所有主题,包括宽相位和窄相位碰撞检测、物理模拟、运动积分和不同形状的边界,都在许多开源库中实现,如 Box2D、Chipmunk Physics、Bullet Physics 等。所有这些库最初都是用 C 或 C++ 开发的,但有一些是 Android 包装器或 Java 实现,这使得它们在你计划游戏时值得一试。

在下一章,我们将用我们的新工具创建一个新游戏。你会惊讶它是多么容易。

九、超级 Jumper:一款 2D OpenGL ES 游戏

是时候把你所学的关于 OpenGL ES 的知识整合到一个游戏中了。正如在第三章中所讨论的,在移动领域开发游戏时,有几个非常流行的类型可供选择。对于我们的下一个游戏,我们决定坚持休闲风格。我们将实现一个类似于绑架涂鸦跳跃的跳跃游戏。和 Nom 先生一样,我们将从定义我们的游戏机制开始。

核心游戏机制

如果你不熟悉诱拐,我们建议你把它安装在你的 Android 设备上试一试(在 Google Play 上免费下载),或者至少在网上看一段这个游戏的视频。以绑架为例,我们可以浓缩我们游戏的核心游戏机制,这款游戏将被称为超级 Jumper。以下是一些细节:

  • 主角不断向上跳跃,从一个*台移动到另一个*台。游戏世界垂直跨越多个屏幕。
  • 水*移动可以通过向左或向右倾斜手机来控制。
  • 当主角离开一个水*的屏幕边界时,他从对面重新进入屏幕。
  • *台可以是静止的,也可以是水*移动的。
  • 有些*台会在主角撞上的时候随机粉化。
  • 一路上,主角可以收集物品得分。
  • 除了硬币,还有一些*台上的弹簧会让主角跳得更高。
  • 邪恶势力充斥着游戏世界,水*移动。当我们的主角击中其中一个时,他就会死亡,游戏也就结束了。
  • 当我们的主角跌破屏幕底部边缘时,游戏就结束了。
  • 最高级别是某种目标。当主角达到那个目标,一个新的水*就开始了。

虽然这个列表比我们为 Nom 先生创建的要长,但它看起来并没有复杂多少。图 9-1 显示了核心原则的初始模型。这一次,我们直接去了 Paint.NET 制作模型。让我们想一个背景故事。

9781430246770_Fig09-01.jpg

图 9-1。我们最初的游戏力学模型,展示了主角、*台、弹簧、硬币、邪恶力量和关卡顶端的目标

发展背景故事和选择艺术风格

我们将完全发挥创造力,为我们的游戏开发以下独特的故事。

我们的主角鲍勃患有慢性跳楼症。他每次触地都注定要跳起来。更糟糕的是,他心爱的公主,将保持无名,被一个邪恶的飞行杀手松鼠军队绑架,并放置在空中城堡。鲍勃的情况证明毕竟是有益的,他开始寻找他所爱的人,与邪恶的松鼠力量作斗争。

这个经典的视频游戏故事非常适合 8 位图形风格,这种风格可以在游戏中找到,例如 NES 上的原版超级马里奥兄弟。图 9-1 中的实体模型展示了我们游戏中所有元素的最终图形。鲍勃,硬币,飞行松鼠,粉碎*台,当然,动画。我们还将使用符合我们视觉风格的音乐和音效。

定义屏幕和过渡

我们现在能够定义我们的屏幕和过渡。按照我们在 Nom 先生中使用的公式,我们将包括以下元素:

  • 带有标志的主屏幕;播放、高分和帮助菜单项。和启用声音的按钮。
  • 一个游戏屏幕,要求玩家做好准备,并优雅地处理运行、暂停、游戏结束和下一级状态。我们在《诺姆先生》中使用的唯一新增加的是屏幕的下一级状态,这将在鲍勃击中城堡时触发。在这种情况下,将产生一个新的水*,鲍勃将再次从世界底部开始,保持他的分数。
  • 一个高分屏幕,显示玩家目前为止获得的前五分。
  • 向玩家展示游戏机制和目标的帮助屏幕。我们将偷偷摸摸地省略如何控制玩家的描述。现在的孩子应该能够处理我们在 80 年代和 90 年代初面临的复杂性,当时游戏没有提供任何指导。

这与《诺姆先生》中的设置大致相同。图 9-2 显示了所有的屏幕和过渡。请注意,除了暂停按钮,我们在游戏屏幕或其子屏幕上没有任何按钮。当被问及是否准备好时,用户会直观地触摸屏幕。

9781430246770_Fig09-02.jpg

图 9-2。超级跳线的所有屏幕和过渡

定义好屏幕和过渡后,我们现在可以考虑世界的大小和单位,以及这些大小和单位与图形素材的关系。

定义游戏世界

经典的先有鸡还是先有蛋的问题再次困扰着我们。正如你在第八章中了解到的,我们有世界单位(例如,米)和像素之间的对应关系。我们的物体在世界空间中被物理地定义。边界形状和位置以米为单位给出;速度以米每秒为单位。然而,我们对象的图形表示是用像素定义的,所以我们必须有某种映射。我们通过首先为我们的图形素材定义一个目标分辨率来克服这个问题。与 Nom 先生一样,我们将使用 320×480 像素的目标分辨率(纵横比为 1.5)。我们使用这个目标是因为它是最低的实际分辨率;如果你专门针对*板电脑,你可能希望使用 800×1280 这样的分辨率,或者介于两者之间,比如 480×800(典型的 Android 手机)。不管你的目标是什么,原则都是一样的。

接下来我们要做的是在我们的世界中建立像素和米之间的对应关系。图 9-1 中的模型给我们一种感觉,不同的对象使用了多少屏幕空间,以及它们相对于彼此的比例。我们建议为 2D 游戏选择 32 像素到 1 米的映射。因此,让我们覆盖我们的实体模型,它的大小为 320×380 像素,每个单元格为 32×32 像素。在我们的世界空间中,这将映射到 1×1 米的单元。图 9-3 显示了我们的实体模型和网格。

9781430246770_Fig09-03.jpg

图 9-3。覆盖有网格的实体模型。每个单元为 32×32 像素,对应游戏世界中 1×1 米的区域

我们在图 9-3 中作弊了一点。我们以某种方式排列图形,使它们与网格单元很好地对齐。在真实的游戏中,我们会把物体放在非整数的位置。

那么,我们能从图 9-3 中得到什么?首先,我们可以用米直接估算出我们世界中每个物体的宽度和高度。下面是我们将用于物体的矩形边界的值:

  • bob 0.8×0.8 米;他没有完全跨越一个完整的细胞。
  • 一个*台为 2×0.5 米,水*占用两个单元,垂直占用半个单元。
  • 一枚硬币是 0.8×0.5 米。它几乎垂直跨越一个单元格,水*占据大约半个单元格。
  • 一个弹簧为 0.5×0.5 米,在每个方向上向上延伸半个单元。弹簧实际上比它的宽度要高一点。我们把它的边界形状做成方形,这样碰撞测试会更宽容一些。
  • 一只松鼠是 1×0.8 米。
  • 一座城堡是 1.8×1.8 米。

有了这些尺寸,我们也就有了用于碰撞检测的物体的边界矩形的尺寸。如果它们变得有点太大或太小,我们可以调整它们,这取决于游戏如何使用这些值。

从图 9-3 中我们可以得到的另一件事是我们的视见*截头体的大小。它将向我们展示 10×15 米的世界。

剩下唯一要定义的是游戏中的速度和加速度。这些在很大程度上取决于我们对游戏的期望。通常,你必须做一些实验来得到正确的值。经过几次反复调整后,我们得出了以下结论:

  • 重力加速度矢量是(0,–13)米/秒 2 ,比我们在地球上得到的和我们在第八章的大炮例子中使用的略多。
  • Bob 的初始跳跃速度向量为(0,11) m/s。请注意,跳跃速度仅影响 y 轴上的移动。水*移动将由当前加速度计读数来定义。
  • 当 Bob 击中弹簧时,他的跳跃速度矢量将是正常跳跃速度的 1.5 倍。这相当于(0,16.5)米/秒。同样,该值完全是通过实验得出的。
  • Bob 的水*移动速度是 20 m/s,注意那是无方向的速度,不是矢量。我们稍后将解释它如何与加速度计一起工作。
  • 松鼠会不断地从左到右来回巡逻。它们的恒定移动速度为 3 米/秒。用矢量表示,如果松鼠向左移动,速度为(–3,0)米/秒;如果松鼠向右移动,速度为(3,0)米/秒。

那么鲍勃的水*移动将如何工作呢?我们之前定义的移动速度,其实就是 Bob 的最大水*速度。根据玩家倾斜手机的程度,Bob 的水*移动速度将在 0(不倾斜)和 20 m/s(完全向一侧倾斜)之间。

我们将使用加速度计的 x 轴值,因为我们的游戏将以纵向模式运行。当手机没有倾斜时,axis 会报告 0 米/秒的加速度 2 。当完全向左倾斜以使手机处于横向时,axis 将报告大约-10 米/秒的加速度 2 。当完全向右倾斜时,轴将报告大约 10 米/秒的加速度 2 。我们需要做的就是将加速度计读数除以最大绝对值(10),然后乘以 Bob 的最大水*速度,从而实现标准化。因此,当电话完全倾斜到一侧时,Bob 将向左或向右移动 20 m/s,如果电话倾斜较小,则移动更少。当手机完全倾斜时,Bob 每秒可以在屏幕上移动两次。

我们将根据 x 轴上的当前加速度计值更新每一帧的水*移动速度,并将其与 Bob 的垂直速度相结合,Bob 的垂直速度是从重力加速度和他的当前垂直速度中得出的,就像我们在第八章的示例中对炮弹所做的那样。

世界的一个重要方面是我们看到的那一部分。由于 Bob 在底部边缘离开屏幕时会死亡,因此我们的摄像头也在游戏机制中发挥了作用。虽然我们将使用一个相机进行渲染,并在 Bob 跳跃时将其向上移动,但我们不会在我们的世界模拟类中使用它。相反,我们记录 Bob 目前最高的 y 坐标。如果他低于这个值减去视锥高度的一半,我们知道他已经离开了屏幕。因此,我们在模型(我们的世界模拟类)和视图之间没有完全清晰的分离,因为我们需要知道视图截锥的高度来确定 Bob 是否死了。我们可以忍受这个。

让我们看看我们需要的素材。

创建素材

我们的新游戏有两种类型的图形素材:UI 元素和实际的游戏或世界元素。让我们从 UI 元素开始。

用户界面元素

首先要注意的是 UI 元素(按钮、徽标等等)不依赖于我们的像素到世界单位的映射。正如 Nom 先生所说,我们将它们设计成适合目标分辨率——在我们的例子中,是 320×480 像素。看图 9-2 ,可以确定我们有哪些 UI 元素。

我们创建的第一个 UI 元素是不同屏幕所需的按钮。图 9-4 显示了我们游戏的所有按钮。

9781430246770_Fig09-04.jpg

图 9-4。各种按钮,每个大小为 64×64 像素

我们更喜欢在网格中创建所有图形素材,网格的单元格大小为 32×32 或 64×64 像素。图 9-4 中的按钮被布置在一个网格中,每个单元有 64×64 个像素。顶行中的按钮在主菜单屏幕上用于指示是否启用声音。左下角的箭头在几个屏幕中用于导航到下一个屏幕。游戏屏幕右下角的按钮用于游戏运行时,允许用户暂停游戏。

你可能想知道为什么没有指向右边的箭头。记住第八章中的内容,使用我们的高级精灵批处理器,我们可以通过指定负的宽度和/或高度值来轻松翻转我们绘制的东西。我们将对几个图形素材使用这个技巧来节省一些内存。

接下来是我们在主菜单屏幕上需要的元素:徽标、菜单项和背景图像。图 9-5 显示了所有这些元素。

9781430246770_Fig09-05.jpg

图 9-5。背景图像、主菜单项和徽标

背景图像不仅用于主菜单屏幕,还用于所有屏幕。它和我们的目标分辨率一样,320×480 像素。主菜单项由 300×110 像素组成。主菜单使用黑色背景是因为白底白字不好看。当然,在实际图像中,主菜单的背景是由透明像素组成的。该徽标为 274×142 像素,角上有一些透明像素。

接下来是帮助屏幕图像。我们没有用几个元素将它们合成,而是懒洋洋地将它们制作成 320×480 大小的全屏图像。这将减少我们的绘图代码的大小,而不会增加我们的程序的大小。您可以在图 9-2 中看到所有的帮助屏幕。我们将合成这些图像的唯一东西是箭头按钮。

对于高分屏幕,我们将重用主菜单图像中显示高分的部分。实际的分数是用一种特殊的技术来表现的,我们将在本章的后面部分研究这种技术。屏幕的其余部分再次由背景图像和一个按钮组成。

游戏屏幕还有一些文本 UI 元素,即 READY?标签、暂停状态的菜单项(恢复和退出)和游戏结束标签。图 9-6 展示了他们所有的荣耀。

9781430246770_Fig09-06.jpg

图 9-6。准备好了吗?、恢复、退出和游戏结束标签

用位图字体处理文本

那么,我们如何渲染游戏屏幕中的其他文本元素呢?我们使用在《诺姆先生》中使用的相同技术来渲染分数。我们现在不仅有数字,还有文字。我们使用一个图像图谱,其中每个子图像代表一个角色(例如, 0a )。这个图像集被称为位图字体。图 9-7 显示了我们将要使用的位图字体。

9781430246770_Fig09-07.jpg

图 9-7。位图字体

图 9-7 中的黑色背景和网格当然不是实际图像的一部分。在游戏中,使用位图字体在屏幕上呈现文本是一种非常古老的技术。位图字体通常包含一系列 ASCII 字符的图像。一个这样的字符图像被称为字形 。ASCII 是 Unicode 的前身之一。ASCII 字符集中有 128 个字符,如图图 9-8 所示。

9781430246770_Fig09-08.jpg

图 9-8。 ASCII 字符及其十进制、十六进制和八进制值

在这 128 个字符中,有 95 个是可打印的(字符 32 到 126)。我们的位图字体只包含可打印的字符。位图字体的第一行包含字符 32 到 47,下一行包含字符 48 到 63,依此类推。ASCII 仅在您希望存储和显示使用标准拉丁字母的文本时有用。有一种扩展的 ASCII 格式,使用值 128 到 255 来编码西方语言的其他常见字符,如“”和“”。更具表现力的字符集(例如,中文或阿拉伯文)通过 Unicode 表示,不能通过 ASCII 编码。对于我们的游戏,标准的 ASCII 字符集就足够了。

那么,我们如何用位图字体渲染文本呢?事实证明这真的很容易。首先,我们创建 96 个纹理区域,每个映射到位图字体中的一个字形。我们可以将这些纹理区域存储在一个数组中,如下所示:

TextureRegion[] glyphs = new TextureRegion[96];

Java 字符串以 16 位 Unicode 编码。幸运的是,位图字体中的 ASCII 字符在 ASCII 和 Unicode 中有相同的值。要获取 Java 字符串中字符的区域,我们只需要这样做:

int index = string.charAt(i) – 32;

这为我们提供了纹理区域数组的直接索引。我们只是从字符串中的当前字符中减去空格字符(32)的值。如果索引小于 0 或大于 95,则我们有一个不在位图字体中的 Unicode 字符。通常,我们只是忽略了这样一个人物。

为了在一行中呈现多个字符,我们需要知道字符之间应该有多少空间。图 9-7 中的位图字体是一种固定 - 宽度字体,意思是每个字形宽度相同。我们的位图字体字形每个都有 16×20 像素的大小。当我们在字符串中从一个字符到另一个字符提升渲染位置时,我们只需要增加 20 个像素。我们将绘制位置从一个字符移动到另一个字符的像素数称为前进。对于我们的位图字体,它是固定的;然而,它通常是可变的,取决于我们绘制的字符。一种更复杂的前进形式通过考虑我们将要绘制的当前字符和下一个字符来计算前进。这个技巧叫做字距调整 ,如果你想在网上查的话。我们将只使用固定宽度的位图字体,因为它们使我们的生活变得相当容易。

那么,我们是如何生成 ASCII 位图字体的呢?我们使用了网络上众多工具中的一种来生成位图字体。我们用的这个叫做 Codehead 的位图字体生成器(CBFG) ,在www.codehead.co.uk/cbfg/可以免费获得。您可以在硬盘上选择一个字体文件,并指定字体的高度,生成器将从该文件中为 ASCII 字符集生成一个图像。该工具有许多选项,超出了我们在这里讨论的范围。我们建议您下载 CBFG,并对它进行一些小的改动。

我们将使用这种技术来绘制游戏中所有剩余的字符串。稍后,您将看到位图字体类的具体实现。现在,让我们回到创建我们的素材。

有了位图字体,我们现在有了所有图形用户界面元素的素材。我们将通过一个 sprite 批处理程序使用一个相机来渲染它们,这个相机设置了一个直接映射到我们的目标分辨率的视锥。这样,我们可以在像素坐标中指定所有的坐标。

游戏元素

如前所述,实际的游戏对象取决于我们的像素到世界单位的映射。为了使游戏元素的创建尽可能容易,我们使用了一个小技巧:我们用每个单元 32×32 像素的网格开始每幅画。所有的物体都集中在一个或多个这样的单元中,因此它们很容易与我们世界中它们的物理尺寸相对应。让我们从鲍勃开始,如图 9-9 中的所示。

9781430246770_Fig09-09.jpg

图 9-9。鲍勃和他的五个动画帧

图 9-9 显示了跳跃的两帧,坠落的两帧,死亡的一帧。每个图像的大小为 160×32 像素,每个动画帧的大小为 32×32 像素。背景像素是透明的。

鲍勃可能处于三种状态之一:跳跃、坠落或死亡。我们有这些状态的动画帧。诚然,两个跳跃帧和两个下落帧之间的差异很小——只有他的额发在摆动。我们将为 Bob 的三个动画分别创建一个动画实例,并根据他的当前状态使用它们进行渲染。我们也没有鲍勃朝左的重复帧。和箭头按钮一样(如前所示,在图 9-4 中),我们将通过调用 SpriteBatcher.drawSprite()指定一个负宽度来水*翻转 Bob 的图像。

图 9-10 描绘了邪恶的飞鼠。我们又有了两个动画帧,所以松鼠看起来在拍打它邪恶的翅膀。

9781430246770_Fig09-10.jpg

图 9-10。一只邪恶的飞鼠和它的两个动画帧

图 9-10 中的图像为 64×32 像素,每帧为 32×32 像素。

图 9-11 所示的硬币动画比较特殊。我们的关键帧序列不会是 1,2,3,1,而是 1,2,3,2,1。否则,硬币将从第 3 帧中的折叠状态变为第 1 帧中的完全展开状态。我们可以通过重复使用第二个框架来节省一点空间。

9781430246770_Fig09-11.jpg

图 9-11。硬币及其动画帧

图 9-11 中的图像为 96×32 像素,每帧为 32×32 像素。

关于图 9-12 中的弹簧图像,不必多说。春天只是快乐地坐在图像的中心。该图像为 32×32 像素。

9781430246770_Fig09-12.jpg

图 9-12。春天

图 9-13 中的城堡也不是动画。它比其他对象大(64×64 像素)。

9781430246770_Fig09-13.jpg

图 9-13。城堡

图 9-14 (64x64 像素)中的*台有四个动画帧。根据我们的游戏机制,一些*台在鲍勃击中时会被粉碎。我们将播放一次这种情况下*台的完整动画。对于静态*台,我们只使用第一个框架。

9781430246770_Fig09-14.jpg

图 9-14。*台及其动画帧

纹理图谱拯救世界

既然我们已经确定了游戏中所有的图形素材,我们需要讨论它们的纹理。我们已经讨论过纹理需要有 2 的幂的宽度和高度。我们的背景图像和所有帮助屏幕的尺寸都是 320×480 像素。我们将把它们存储在 512×512 像素的图像中,这样我们就可以把它们作为纹理来加载。已经有六种纹理了。

我们是否也为所有其他图像创建单独的纹理?不。我们创建一个单一的纹理图谱。事实证明,其他所有东西都可以很好地放在一个 512×512 像素的地图集里,我们可以将它作为一个单一的纹理来加载——这将使 GPU 非常高兴,因为我们只需要为所有游戏元素绑定一个纹理,除了背景和帮助屏幕图像。图 9-15 所示为图集。

9781430246770_Fig09-15.jpg

图 9-15。威武纹理图册

图 9-15 中的图像尺寸为 512×512 像素。网格和红色轮廓不是图像的一部分,背景像素是透明的。UI 标签的黑色背景像素和位图字体也是如此。网格单元的大小为 32×32 像素。像这样使用纹理贴图集很酷的一点是,如果你想支持更高分辨率的屏幕,除了这个纹理贴图集的大小,你不需要改变任何东西。您可以使用更高保真的图形将其放大到 1024×1024 像素,即使您的目标是 320×480,OpenGL ES 也可以在不改变游戏的情况下为您提供更好的图形!

我们把地图集里所有的图像放在坐标为 32 的倍数的角上。这使得创建纹理区域更容易。

音乐和声音

我们还需要音效和音乐。由于我们的游戏是一个 8 位复古风格的游戏,所以使用芯片曲调、音效和合成器生成的音乐是合适的。最著名的芯片曲调是由任天堂的 nes,s NES 和游戏男孩。为了音效,我们使用了一个叫做 as3sfxr 的工具(汤姆·维安的 Flash 版 sfxr ,由托马斯·彼得森创作)。可以在www.superflashbros.net/as3sfxr找到。图 9-16 显示了其用户界面。

9781430246770_Fig09-16.jpg

图 9-16。 as3sfxr,sfxr 的一个 Flash 端口

我们为跳跃、撞击弹簧、撞击硬币和撞击松鼠创造了音效。我们还为点击 UI 元素创建了声音效果。我们所做的就是在 as3sfxr 中为每一个类别捣碎左边的按钮,直到我们找到一个合适的声音效果。

游戏音乐通常比较难得到。网上有几个网站以 8 位芯片调谐为特色,适合像超级 Jumper 这样的游戏。我们将用一首名为“新歌”的歌曲,由盖尔·捷尔塔创作。这首歌可以在免费音乐档案馆(www.freemusicarchive.org)找到。它是在知识共享署名-非商业性使用-类似共享 3.0 美国许可证下许可的。这意味着我们可以在非商业项目中使用它,例如我们的开源超级 Jumper 游戏,只要我们将归属权交给 Geir,并且不修改原始作品。当你在网上搜索游戏中使用的音乐时,一定要确保你遵守许可。人们在他们的歌曲中投入了很多心血。如果许可证不适合你的项目(也就是说,如果你的游戏是商业游戏),那么你就不能使用它。

实现超级跳线

实现超级 Jumper 将非常容易。我们可以重用第八章的完整框架,并在高层次上遵循 Nom 先生的架构。这意味着我们将为每个屏幕创建一个类,每个类都将实现该屏幕的逻辑和表示。除此之外,我们还将使用适当的清单文件、assets/folder 中的所有素材、应用的图标等来设置我们的标准项目。让我们从我们的主要素材类别开始。只要像以前一样设置项目,复制所有的框架类,就可以编写这个精彩的游戏了。

素材类别

在 Nom 先生中,我们已经有了一个素材类,它仅由静态成员变量中保存的一公吨的位图和声音引用组成。我们将在《超级 Jumper》中做同样的事情。不过,这一次,我们将添加一些加载逻辑。清单 9-1 显示了代码,其中夹杂了注释。

清单 9-1。Assets.java 的 ,它拥有除了帮助屏幕纹理之外的所有资源

package com.badlogic.androidgames.jumper;

import com.badlogic.androidgames.framework.Music;
import com.badlogic.androidgames.framework.Sound;
import com.badlogic.androidgames.framework.gl.Animation;
import com.badlogic.androidgames.framework.gl.Font;
import com.badlogic.androidgames.framework.gl.Texture;
import com.badlogic.androidgames.framework.gl.TextureRegion;
import com.badlogic.androidgames.framework.impl.GLGame;

public class Assets {
    public static Texture*background*;
    public static TextureRegion*backgroundRegion*;

    public static Texture*items*;
    public static TextureRegion*mainMenu*;
    public static TextureRegion*pauseMenu*;
    public static TextureRegion*ready*;
    public static TextureRegion*gameOver*;
    public static TextureRegion*highScoresRegion*;
    public static TextureRegion*logo*;
    public static TextureRegion*soundOn*;
    public static TextureRegion*soundOff*;
    public static TextureRegion*arrow*;
    public static TextureRegion*pause*;
    public static TextureRegion*spring*;
    public static TextureRegion*castle*;
    public static Animation*coinAnim*;
    public static Animation*bobJump*;
    public static Animation*bobFall*;
    public static TextureRegion*bobHit*;
    public static Animation*squirrelFly*;
    public static TextureRegion*platform*;
    public static Animation*brakingPlatform*;
    public static Font*font*;

    public static Music*music*;

    public static Sound*jumpSound*;
    public static Sound*highJumpSound*;
    public static Sound*hitSound*;
    public static Sound*coinSound*;
    public static Sound*clickSound*;

该类包含了我们在游戏中需要的所有纹理、纹理区域、动画、音乐和声音实例的引用。这里我们唯一没有加载的是帮助屏幕的图像。

    public static void load(GLGame game) {
        *background* = new Texture(game, "background.png");
        *backgroundRegion* = new TextureRegion(*background*, 0, 0, 320, 480);

        *items* = new Texture(game, "items.png");
        *mainMenu* = new TextureRegion(*items*, 0, 224, 300, 110);
        *pauseMenu* = new TextureRegion(*items*, 224, 128, 192, 96);
        *ready* = new TextureRegion(*items*, 320, 224, 192, 32);
        *gameOver* = new TextureRegion(*items*, 352, 256, 160, 96);
        *highScoresRegion* = new TextureRegion(Assets.*items*, 0, 257, 300, 110 / 3);
        *logo* = new TextureRegion(*items*, 0, 352, 274, 142);
        *soundOff* = new TextureRegion(*items*, 0, 0, 64, 64);
        *soundOn* = new TextureRegion(*items*, 64, 0, 64, 64);
        *arrow* = new TextureRegion(*items*, 0, 64, 64, 64);
        *pause* = new TextureRegion(*items*, 64, 64, 64, 64);

        *spring* = new TextureRegion(*items*, 128, 0, 32, 32);
        *castle* = new TextureRegion(*items*, 128, 64, 64, 64);
        *coinAnim* = new Animation(0.2f,
                                 new TextureRegion(*items*, 128, 32, 32, 32),
                                 new TextureRegion(*items*, 160, 32, 32, 32),
                                 new TextureRegion(*items*, 192, 32, 32, 32),
                                 new TextureRegion(*items*, 160, 32, 32, 32));
        *bobJump* = new Animation(0.2f,
                                new TextureRegion(*items*, 0, 128, 32, 32),
                                new TextureRegion(*items*, 32, 128, 32, 32));
        *bobFall* = new Animation(0.2f,
                                new TextureRegion(*items*, 64, 128, 32, 32),
                                new TextureRegion(*items*, 96, 128, 32, 32));
        *bobHit* = new TextureRegion(*items*, 128, 128, 32, 32);
        *squirrelFly* = new Animation(0.2f,
                                    new TextureRegion(*items*, 0, 160, 32, 32),
                                    new TextureRegion(*items*, 32, 160, 32, 32));
        *platform* = new TextureRegion(*items*, 64, 160, 64, 16);
        *brakingPlatform* = new Animation(0.2f,
                                     new TextureRegion(*items*, 64, 160, 64, 16),
                                     new TextureRegion(*items*, 64, 176, 64, 16),
                                     new TextureRegion(*items*, 64, 192, 64, 16),
                                     new TextureRegion(*items*, 64, 208, 64, 16));
        *font* = new Font(*items*, 224, 0, 16, 16, 20);
        *music* = game.getAudio().newMusic("music.mp3");
        *music*.setLooping( true );
        *music*.setVolume(0.5f);
        if (Settings.*soundEnabled*)
            *music*.play();
        *jumpSound* = game.getAudio().newSound("jump.ogg");
        *highJumpSound* = game.getAudio().newSound("highjump.ogg");
        *hitSound* = game.getAudio().newSound("hit.ogg");
        *coinSound* = game.getAudio().newSound("coin.ogg");
        *clickSound* = game.getAudio().newSound("click.ogg");
    }

load()方法将在游戏开始时调用一次,负责填充该类的所有静态成员。它加载背景图像并为其创建相应的 TextureRegion。接下来,它加载纹理地图并创建所有必要的纹理区域和动画。将代码与图 9-15 和上一节中的其他图进行比较。关于加载图形素材的代码,唯一值得注意的是硬币动画实例的创建。如前所述,我们在动画帧序列的末尾重用第二帧。所有动画都使用 0.2 秒的帧时间。

我们还创建了 Font 类的一个实例,我们还没有讨论过。它将实现用嵌入在 atlas 中的位图字体来呈现文本的逻辑。构造函数获取纹理,该纹理包含位图字体标志符号、包含标志符号的区域的左上角的像素坐标、每行的标志符号数以及每个标志符号的像素大小。

我们还在那个方法中加载所有的音乐和声音实例。正如你所看到的,我们又和老朋友一起工作了。我们可以从 Mr. Nom 项目中原样重用它,只需稍加修改,您马上就会看到。请注意,我们将 Music 实例设置为循环播放,音量设置为 0.5,因此它比声音效果稍微安静一些。只有当用户之前没有禁用声音时,音乐才会开始播放,声音存储在 Settings 类中,如 Mr. Nom。

    public static void reload() {
        *background*.reload();
        *items*.reload();
        if (Settings.*soundEnabled*)
            *music*.play();
    }

接下来,我们有一个神秘的方法叫做 reload()。请记住,当我们的应用暂停时,OpenGL ES 上下文将会丢失。当应用恢复时,我们必须重新加载纹理,这正是这个方法要做的。如果启用了声音,我们还会恢复音乐播放。

    public static void playSound(Sound sound) {
        if (Settings.*soundEnabled*)
            sound.play(1);
    }
}

这个类的最后一个方法 playSound()是一个助手方法,我们将在剩下的代码中使用它来回放音频。我们将检查封装在这个方法中,而不是检查是否在任何地方都启用了声音。

我们来看看修改后的设置类。

设置类

在设置类中没有太多变化。清单 9-2 显示了我们稍微修改过的设置类的代码。

清单 9-2。 设置。java,我们稍微修改的设置类,借用了 Nom 先生

package com.badlogic.androidgames.jumper;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;

import com.badlogic.androidgames.framework.FileIO;

public class Settings {
    public static boolean *soundEnabled* = true ;
    public final static int []*highscores* = new int [] { 100, 80, 50, 30, 10 };
    public final static String*file* = ".superjumper";

    public static void load(FileIO files) {
        BufferedReader in = null ;
        try {
            in = new BufferedReader( new InputStreamReader(files.readFile(*file*)));
            *soundEnabled* = Boolean.*parseBoolean*(in.readLine());
            for (int i = 0; i < 5; i++) {
                *highscores*[i] = Integer.*parseInt*(in.readLine());
            }
        } catch (IOException e) {
            // :( It's ok we have defaults
        } catch (NumberFormatException e) {
            // :/ It's ok , defaults save our day
        } finally {
            try {
                if (in != null )
                    in.close();
            } catch (IOException e) {
            }
        }
    }

    public static void save(FileIO files) {
        BufferedWriter out = null ;
        try {
            out = new BufferedWriter( new OutputStreamWriter(
                    files.writeFile(*file*)));
            out.write(Boolean.*toString*(*soundEnabled*));
            out.write("\n");
            for (int i = 0; i < 5; i++) {
                out.write(Integer.*toString*(*highscores*[i]));
                out.write("\n");
            }

        } catch (IOException e) {
        } finally {
            try {
                if (out != null )
                    out.close();
            } catch (IOException e) {
            }
        }
    }

    public static void addScore( int score) {
        for (int i=0; i < 5; i++) {
            if (*highscores*[i] < score) {
                for (int j= 4; j > i; j--)
                    *highscores*[j] =*highscores*[j-1];
                *highscores*[i] = score;
                break ;
            }
        }
    }
}

与这个类的 Mr. Nom 版本的唯一区别是我们读写设置的文件。而不是。mrnom,我们现在使用文件. superjumper。

主要活动

我们需要一个活动作为我们游戏的主要切入点。我们称之为超级 Jumper。清单 9-3 显示了它的代码。

清单 9-3。SuperJumper.java,主要入口点类

package com.badlogic.androidgames.jumper;

import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.impl.GLGame;

public class SuperJumper extends GLGame {
    boolean firstTimeCreate = true ;

    public Screen getStartScreen() {
        return new MainMenuScreen( this );
    }

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        super.onSurfaceCreated(gl, config);
        if (firstTimeCreate) {
            Settings.load(getFileIO());
            Assets.load( this );
            firstTimeCreate = false ;
        } else {
            Assets.reload();
        }
    }

    @Override
    public void onPause() {
        super.onPause();
        if (Settings.soundEnabled)
            Assets.music.pause();
    }
}

我们从 GLGame 派生并实现 getStartScreen()方法,该方法返回一个 MainMenuScreen 实例。另外两种方法不太明显。

我们覆盖 onSurfaceCreate(),每次重新创建 OpenGL ES 上下文时都会调用它(与第七章中 GL game 的代码比较)。如果第一次调用该方法,我们将使用 Assets.load()方法加载所有素材,并从 SD 卡上的设置文件中加载设置(如果可用)。否则,我们需要做的就是通过 Assets.reload()方法重新加载纹理并开始播放音乐。我们还覆盖 onPause()方法来暂停正在播放的音乐。我们做这两件事,这样我们就不必在屏幕的 resume()和 pause()方法中重复它们。

在我们深入到屏幕实现之前,让我们看一下我们的新字体类。

字体类

我们将使用位图字体来呈现任意(ASCII)文本。我们已经讨论了这在高层次上是如何工作的,所以让我们看看清单 9-4 中的代码。

清单 9-4。Font.java,一个位图字体渲染类

package com.badlogic.androidgames.framework.gl;

public class Font {
    public final Texture texture;
    public final int glyphWidth;
    public final int glyphHeight;
    public final TextureRegion[] glyphs = new TextureRegion[96];

该类存储包含字体标志符号的纹理、单个标志符号的宽度和高度以及一个 TextureRegions 数组(每个标志符号一个)。数组中的第一个元素保存空格标志符号的区域,下一个元素保存感叹号标志符号的区域,依此类推。换句话说,第一个元素对应于代码为 32 的 ASCII 字符,最后一个元素对应于代码为 126 的 ASCII 字符。

    public Font(Texture texture,
                int offsetX, int offsetY,
                int glyphsPerRow, int glyphWidth, int glyphHeight) {
        this .texture = texture;
        this .glyphWidth = glyphWidth;
        this .glyphHeight = glyphHeight;
        int x = offsetX;
        int y = offsetY;
        for (int i = 0; i < 96; i++) {
            glyphs[i] = new TextureRegion(texture, x, y, glyphWidth, glyphHeight);
            x += glyphWidth;
            if (x == offsetX + glyphsPerRow * glyphWidth) {
                x = offsetX;
                y += glyphHeight;
            }
        }
    }

在构造函数中,我们存储位图字体的配置并生成字形区域。offsetX 和 offsetY 参数指定纹理中位图字体区域的左上角。在我们的纹理图谱中,这是(224,0)处的像素。参数 glyphsPerRow 告诉我们每行有多少个字形,参数 glyphWidth 和 glyphHeight 指定单个字形的大小。因为我们使用固定宽度的位图字体,所以所有字形的大小是相同的。glyphWidth 也是我们在渲染多个字形时要前进的值。

    public void drawText(SpriteBatcher batcher, String text, float x, float y) {

        int len = text.length();
        for (int i = 0; i < len; i++) {
            int c = text.charAt(i) - ' ';
            if (c < 0 || c > glyphs.length - 1)
                continue ;

            TextureRegion glyph = glyphs[c];
            batcher.drawSprite(x, y, glyphWidth, glyphHeight, glyph);
            x += glyphWidth;
        }
    }
}

drawText()方法接受一个 SpriteBatcher 实例、一行文本以及开始绘制文本的 x 和 y 位置。x 和 y 坐标指定第一个字形的中心。我们所做的就是获取字符串中每个字符的索引,检查我们是否有它的字形,如果有,就通过 SpriteBatcher 呈现它。然后,我们将 x 坐标增加 glyphWidth,这样我们就可以开始呈现字符串中的下一个字符。

你可能想知道为什么我们不需要绑定包含字形的纹理。我们假设这是在调用 drawText()之前完成的。原因是文本渲染可能是批处理的一部分,在这种情况下,纹理必须已经绑定。为什么要在 drawText()方法中不必要地再绑定一次呢?请记住,OpenGL ES 只喜欢最小的状态变化。

当然,我们只能用这个类处理固定宽度的字体。如果我们想支持更通用的字体,我们还需要了解每个字符的前进方向。一种解决方案是使用字距调整,如前面的“用位图字体处理文本”一节所述不过,我们对自己的简单解决方案很满意。

goscreen 类

在前两章的例子中,我们总是通过造型来获取对 GLGraphics 的引用。让我们用一个叫做 GLScreen 的小助手类来解决这个问题,它将为我们做一些脏活,并将对 GLGraphics 的引用存储在一个成员中。清单 9-5 显示了代码。

清单 9-5。GLScreen.java,一个小帮手类

package com.badlogic.androidgames.framework.impl;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Screen;

public abstract class GLScreen extends Screen {
    protected final GLGraphics glGraphics;
    protected final GLGame glGame;

    public GLScreen(Game game) {
        super (game);
        glGame = (GLGame)game;
        glGraphics = glGame.getGLGraphics();
    }
}

我们将 GLGraphics 和 GLGame 实例存储在 GLScreen 类中。当然,如果作为参数传递给构造函数的游戏实例不是 GLGame,这将会崩溃。但我们会确保它是。Super Jumper 的所有屏幕都将从这个类派生。

主菜单屏幕

主菜单屏幕是由 SuperJumper.getStartScreen()返回的屏幕,因此它是玩家将看到的第一个屏幕。它呈现背景和 UI 元素,并简单地等待玩家触摸任何 UI 元素。基于被触摸的元素,游戏或者改变配置(声音启用/禁用)或者转换到新的屏幕。清单 9-6 显示了代码。

清单 9-6。MainMenuScreen.java,主菜单屏幕

package com.badlogic.androidgames.jumper;

import java.util.List;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.gl.Camera2D;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.impl.GLScreen;
import com.badlogic.androidgames.framework.math.OverlapTester;
import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;

public class MainMenuScreen extends GLScreen {
    Camera2D guiCam;
    SpriteBatcher batcher;
    Rectangle soundBounds;
    Rectangle playBounds;
    Rectangle highscoresBounds;
    Rectangle helpBounds;
    Vector2 touchPoint;

该类派生自 GLScreen,因此我们可以更容易地访问 GLGraphics 实例。

这个班有几个成员。第一个是名为 guiCam 的 Camera2D 实例。我们还需要一个 SpriteBatcher 来呈现我们的背景和 UI 元素。我们将使用矩形来确定用户是否触摸了 UI 元素。因为我们使用 Camera2D,所以我们还需要一个 Vector2 实例来将触摸坐标转换为世界坐标。

    public MainMenuScreen(Game game) {
        super(game);
        guiCam = new Camera2D(glGraphics, 320, 480);
        batcher = new SpriteBatcher(glGraphics, 100);
        soundBounds = new Rectangle(0, 0, 64, 64);
        playBounds = new Rectangle(160 - 150, 200 + 18, 300, 36);
        highscoresBounds = new Rectangle(160 - 150, 200 - 18, 300, 36);
        helpBounds = new Rectangle(160 - 150, 200 - 18 - 36, 300, 36);
        touchPoint = new Vector2();
    }

在构造函数中,我们简单地设置了所有的成员。还有一个惊喜。Camera2D 实例将允许我们在 320×480 像素的目标分辨率下工作。我们需要做的就是将视图截锥的宽度和高度设置为合适的值。其余的由 OpenGL ES 动态完成。但是,请注意,原点仍然在左下角,y 轴指向上方。我们将在所有具有 UI 元素的屏幕中使用这样的 GUI 摄像头,这样我们就可以用像素而不是世界坐标来布局它们。当然,我们在不是 320×480 像素宽的屏幕上作弊了一点,但我们已经在 Nom 先生中这样做了,所以我们不需要为此感到难过。因此,我们为每个 UI 元素设置的矩形是以像素坐标给出的。

    @Override
    public void update(float deltaTime) {
        List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
        game.getInput().getKeyEvents();

        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            if (event.type == TouchEvent.*TOUCH*_*UP*) {
                touchPoint.set(event.x, event.y);
                guiCam.touchToWorld(touchPoint);

                if (OverlapTester.*pointInRectangle*(playBounds, touchPoint)) {
                    Assets.*playSound*(Assets.*clickSound*);
                    game.setScreen( new GameScreen(game));
                    return ;
                }
                if (OverlapTester.*pointInRectangle*(highscoresBounds, touchPoint)) {
                    Assets.*playSound*(Assets.*clickSound*);
                    game.setScreen( new HighscoreScreen(game));
                    return ;
                }
                if (OverlapTester.*pointInRectangle*(helpBounds, touchPoint)) {
                    Assets.*playSound*(Assets.*clickSound*);
                    game.setScreen( new HelpScreen(game));
                    return ;
                }
                if (OverlapTester.*pointInRectangle*(soundBounds, touchPoint)) {
                    Assets.*playSound*(Assets.*clickSound*);
                    Settings.*soundEnabled* = !Settings.*soundEnabled*;
                    if (Settings.*soundEnabled*)
                        Assets.*music*.play();
                    else
                        Assets.*music*.pause();
                }
            }
        }
    }

接下来是 update()方法。我们遍历由输入实例返回的触摸事件,并检查触摸事件。如果我们有这样的事件,我们首先将触摸坐标转换为世界坐标。由于相机是以这样一种方式设置的,即我们在目标分辨率下工作,这种转换可以简单地归结为在 320×480 像素的屏幕上翻转 y 坐标。在更大或更小的屏幕上,我们只是将触摸坐标转换为目标分辨率。一旦我们有了世界接触点,我们就可以对照 UI 元素的矩形来检查它。如果触摸了 PLAY、HIGHSCORES 或 HELP,我们会转换到相应的屏幕。如果声音按钮被按下,我们改变设置,或者恢复或暂停音乐。还要注意,如果通过 Assets.playSound()方法按下了一个 UI 元素,我们将播放卡嗒声。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClear(GL10.*GL*_*COLOR*_*BUFFER*_*BIT*);
        guiCam.setViewportAndMatrices();

        gl.glEnable(GL10.*GL*_*TEXTURE*_*2D*);

        batcher.beginBatch(Assets.*background*);
        batcher.drawSprite(160, 240, 320, 480, Assets.*backgroundRegion*);
        batcher.endBatch();

        gl.glEnable(GL10.*GL*_*BLEND*);
        gl.glBlendFunc(GL10.*GL*_*SRC*_*ALPHA*, GL10.*GL*_*ONE*_*MINUS*_*SRC*_*ALPHA*);

        batcher.beginBatch(Assets.*items*);

        batcher.drawSprite(160, 480 - 10 - 71, 274, 142, Assets.*logo*);
        batcher.drawSprite(160, 200, 300, 110, Assets.*mainMenu*);
        batcher.drawSprite(32, 32, 64, 64, Settings.*soundEnabled*?Assets.*soundOn*:Assets.*soundOff*);

        batcher.endBatch();

        gl.glDisable(GL10.*GL*_*BLEND*);
    }

在这一点上,present()方法不需要任何解释,我们之前已经完成了所有这些。我们清空屏幕,通过摄像头设置投影矩阵,并渲染背景和 UI 元素。由于 UI 元素有透明的背景,我们暂时启用混合来渲染它们。背景不需要混合,所以我们不使用它,以节省一些 GPU 周期。再次注意,UI 元素是在一个坐标系统中呈现的,原点在屏幕的左下方,y 轴指向上方。

    @Override
    public void pause() {
        Settings.*save*(game.getFileIO());
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
    }
}

最后一个真正起作用的方法是 pause()方法。在这里,我们确保设置保存到 SD 卡,因为用户可以在此屏幕上更改声音设置。

帮助屏幕

我们总共有五个帮助屏幕,它们都以相同的方式工作:加载帮助屏幕图像,将其与箭头按钮一起呈现,并等待触摸箭头按钮以移动到下一个屏幕。这两个屏幕的区别仅在于各自加载的图像和切换到的屏幕。出于这个原因,我们将只查看第一个帮助屏幕的代码,如清单 9-7 所示,它会转换到第二个帮助屏幕。帮助屏幕的图像文件被命名为 help1.png、help2.png 等等,直到 help5.png。相应的屏幕类称为 HelpScreen、Help2Screen 等等。最后一个屏幕 Help5Screen 再次转换到 main menu 屏幕。

清单 9-7。HelpScreen.java,第一个帮助屏幕

package com.badlogic.androidgames.jumper;

import java.util.List;

import javax.microedition.khronos.opengles.GL10;
import com.badlogic.androidgames.framework.Game;

import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.gl.Camera2D;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.gl.Texture;
import com.badlogic.androidgames.framework.gl.TextureRegion;
import com.badlogic.androidgames.framework.impl.GLScreen;
import com.badlogic.androidgames.framework.math.OverlapTester;
import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;

public class HelpScreen extends GLScreen {
    Camera2D guiCam;
    SpriteBatcher batcher;
    Rectangle nextBounds;
    Vector2 touchPoint;
    Texture helpImage;
    TextureRegion helpRegion;

我们又有几个成员拿着相机,一个 SpriteBatcher,一个箭头按钮的矩形,一个触摸点的向量,一个帮助图像的纹理和纹理区域。

    public HelpScreen(Game game) {
        super (game);

        guiCam = new Camera2D(glGraphics, 320, 480);
        nextBounds = new Rectangle(320 - 64, 0, 64, 64);
        touchPoint = new Vector2();
        batcher = new SpriteBatcher(glGraphics, 1);
    }

在构造函数中,我们设置所有成员的方式与在 MainMenuScreen 中的方式非常相似。

    @Override
    public void resume() {
        helpImage = new Texture(glGame, "help1.png" );
        helpRegion = new TextureRegion(helpImage, 0, 0, 320, 480);
    }

    @Override
    public void pause() {
        helpImage.dispose();
    }

在 resume()方法中,我们加载实际的帮助屏幕纹理,并创建一个相应的 TextureRegion,用于使用 SpriteBatcher 进行呈现。我们用这种方法加载,因为 OpenGL ES 上下文可能会丢失。如前所述,背景和 UI 元素的纹理由 Assets 和 SuperJumper 类处理。我们不需要在任何屏幕上处理它们。此外,我们再次在 pause()方法中释放帮助图像纹理来清理内存。

    @Override
    public void update(float deltaTime) {
        List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
        game.getInput().getKeyEvents();
        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            touchPoint.set(event.x, event.y);
            guiCam.touchToWorld(touchPoint);

            if (event.type == TouchEvent.*TOUCH*_*UP*) {
                if (OverlapTester.*pointInRectangle*(nextBounds, touchPoint)) {
                    Assets.*playSound*(Assets.*clickSound*);
                    game.setScreen( new HelpScreen2(game));
                    return ;
                }
            }
        }
    }

接下来是 update()方法,它只是检查箭头按钮是否被按下。如果它被按下,我们过渡到下一个帮助屏幕。我们还播放卡嗒声。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClear(GL10.*GL*_*COLOR*_*BUFFER*_*BIT*);
        guiCam.setViewportAndMatrices();

        gl.glEnable(GL10.*GL*_*TEXTURE*_*2D*);

        batcher.beginBatch(helpImage);
        batcher.drawSprite(160, 240, 320, 480, helpRegion);
        batcher.endBatch();

        gl.glEnable(GL10.*GL*_*BLEND*);
        gl.glBlendFunc(GL10.*GL*_*SRC*_*ALPHA*, GL10.*GL*_*ONE*_*MINUS*_*SRC*_*ALPHA*);

        batcher.beginBatch(Assets.*items*);
        batcher.drawSprite(320 - 32, 32, -64, 64, Assets.*arrow*);
        batcher.endBatch();

        gl.glDisable(GL10.*GL*_*BLEND*);
    }

    @Override
    public void dispose() {
    }
}

在 present()方法中,我们清空屏幕,设置矩阵,成批呈现帮助图像,然后呈现箭头按钮。当然,我们不需要在这里呈现背景图像,因为帮助图像已经包含了背景图像。

如前所述,其他帮助屏幕是类似的。

高分屏幕

我们列表中的下一个是高分屏幕。这里,我们将使用主菜单 UI 标签的一部分(高分部分),并通过存储在 Assets 类中的字体实例呈现存储在 Settings 中的高分。当然,我们有一个箭头按钮,这样玩家可以回到主菜单。清单 9-8 显示了代码。

清单 9-8。HighscoresScreen.java,高分屏幕

package com.badlogic.androidgames.jumper;

import java.util.List;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.gl.Camera2D;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.impl.GLScreen;
import com.badlogic.androidgames.framework.math.OverlapTester;
import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;

public class HighscoreScreen extends GLScreen {
    Camera2D guiCam;
    SpriteBatcher batcher;
    Rectangle backBounds;
    Vector2 touchPoint;
    String[] highScores;
    float xOffset = 0;

像往常一样,我们有几个成员用于照相机、SpriteBatcher、箭头按钮的边界等等。在 highScores 数组中,我们存储呈现给玩家的每个高分的格式化字符串。xOffset 成员是我们计算的一个值,用来偏移每一行的呈现,以便这些行水*居中。

    public HighscoreScreen(Game game) {
        super (game);

        guiCam = new Camera2D(glGraphics, 320, 480);
        backBounds = new Rectangle(0, 0, 64, 64);
        touchPoint = new Vector2();
        batcher = new SpriteBatcher(glGraphics, 100);
        highScores = new String[5];
        for (int i = 0; i < 5; i++) {
            highScores[i] = (i + 1) + ". " + Settings.*highscores*[i];
            xOffset = Math.*max*(highScores[i].length() * Assets.*font*.glyphWidth, xOffset);
        }
        xOffset = 160 - xOffset / 2;
    }

在构造函数中,我们像往常一样设置所有成员,并计算 xOffset 值。我们通过评估我们为五个高分创建的五个字符串中最长的字符串的大小来做到这一点。因为我们的位图字体是固定宽度的,所以我们可以通过将字符数乘以字形宽度来轻松计算单行文本所需的像素数。当然,这不包括不可打印的字符或 ASCII 字符集之外的字符。因为我们知道我们不会用到它们,所以我们可以通过这个简单的计算得到答案。然后,构造函数中的最后一行从 160(320×480 像素的目标屏幕的水*中心)中减去最大行宽的一半,并通过减去字形宽度的一半来进一步调整它。这是必要的,因为 Font.drawText()方法使用字形中心而不是其中一个角点。

    @Override
    public void update(float deltaTime) {
        List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
        game.getInput().getKeyEvents();
        int len = touchEvents.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = touchEvents.get(i);
            touchPoint.set(event.x, event.y);
            guiCam.touchToWorld(touchPoint);

            if (event.type == TouchEvent.*TOUCH*_*UP*) {
                if (OverlapTester.*pointInRectangle*(backBounds, touchPoint)) {
                    game.setScreen( new MainMenuScreen(game));
                    return ;
                }
            }
        }
    }

update()方法只是检查箭头按钮是否被按下。如果是,它会播放卡嗒声并切换回主菜单屏幕。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClear(GL10.*GL*_*COLOR*_*BUFFER*_*BIT*);
        guiCam.setViewportAndMatrices();

        gl.glEnable(GL10.*GL*_*TEXTURE*_*2D*);

        batcher.beginBatch(Assets.*background*);
        batcher.drawSprite(160, 240, 320, 480, Assets.*backgroundRegion*);
        batcher.endBatch();

        gl.glEnable(GL10.*GL*_*BLEND*);
        gl.glBlendFunc(GL10.*GL*_*SRC*_*ALPHA*, GL10.*GL*_*ONE*_*MINUS*_*SRC*_*ALPHA*);

        batcher.beginBatch(Assets.*items*);
        batcher.drawSprite(160, 360, 300, 33, Assets.*highScoresRegion*);

        float y = 240;
        for (int i = 4; i >= 0; i--) {
            Assets.*font*.drawText(batcher, highScores[i], xOffset, y);
            y += Assets.*font*.glyphHeight;
        }

        batcher.drawSprite(32, 32, 64, 64, Assets.*arrow*);
        batcher.endBatch();

        gl.glDisable(GL10.*GL*_*BLEND*);
    }

    @Override
    public void resume() {
    }

    @Override
    public void pause() {
    }

    @Override
    public void dispose() {
    }
}

present()方法也非常简单。我们清空屏幕,设置矩阵,呈现背景,呈现主菜单标签的“高分”部分,然后使用我们在构造函数中计算的 xOffset 呈现五个高分行。现在我们可以看到为什么字体不做任何纹理绑定:我们可以批处理对 Font.drawText()的五个调用。当然,我们必须确保 SpriteBatcher 实例可以根据渲染文本的需要批处理尽可能多的 sprites(或者说字形)。当我们在构造函数中创建它的时候,我们确保它可以有 100 个精灵(字形)的最大批量。

现在是时候看看我们模拟的类了。

模拟类

在我们进入游戏屏幕之前,我们需要创建我们的模拟类。我们将遵循与 Nom 先生相同的模式,每个游戏对象都有一个类,还有一个名为 World 的无所不知的超类,它将松散的部分联系在一起,使我们的游戏世界运转起来。我们需要以下的类:

  • 上下移动
  • 松鼠
  • 弹簧
  • 硬币
  • *台

Bob、松鼠和*台可以移动,所以我们将基于我们在第八章中创建的 DynamicGameObject 来创建它们的类。弹簧和硬币是静态的,所以它们将从 GameObject 类派生。我们每个模拟课程的任务如下:

  • 存储对象的位置、速度和边界形状。
  • 如果需要,存储对象的状态和处于该状态的时间长度(状态时间)。
  • 提供一个 update()方法,如果需要,该方法将根据对象的行为推进对象。
  • 提供改变对象状态的方法(例如,告诉 Bob 他死了或者撞到了弹簧)。

然后,World 类将跟踪这些对象的多个实例,每帧更新它们,检查对象和 Bob 之间的碰撞,并执行碰撞响应(即,让 Bob 死去,收集一枚硬币,等等)。接下来,我们将从最简单到最复杂,逐一介绍每个类。

春季班

让我们从 Spring 类开始,它出现在清单 9-9 中。

清单 9-9。Spring.java,春班

package com.badlogic.androidgames.jumper;

import com.badlogic.androidgames.framework.GameObject;

public class Spring extends GameObject {
    public static float *SPRING*_*WIDTH* = 0.3f;
    public static float *SPRING*_*HEIGHT* = 0.3f;

    public Spring(float x, float y) {
        super (x, y, SPRING_WIDTH, SPRING_HEIGHT);
    }
}

Spring 类源自我们在第八章中创建的 GameObject 类。我们只需要一个位置和一个边界形状,因为弹簧不会移动。

接下来,我们定义两个可公开访问的常量:弹簧宽度和弹簧高度,单位为米。我们之前估算过这些值,我们只是在这里重复使用它们。

最后一部分是构造函数,它获取弹簧中心的 x 和 y 坐标。这样,我们调用超类 GameObject 的构造函数,它接受对象的位置以及宽度和高度,并从该对象构造一个边界形状(一个以给定位置为中心的矩形)。有了这些信息,我们的 Spring 类就完全定义好了,有了要碰撞的位置和边界形状。

硬币类

接下来是硬币类,如清单 9-10 所示。

清单 9-10。Coin.java,硬币类

package com.badlogic.androidgames.jumper;

import com.badlogic.androidgames.framework.GameObject;

public class Coin extends GameObject {
    public static final float *COIN*_*WIDTH* = 0.5f;
    public static final float *COIN*_*HEIGHT* = 0.8f;
    public static final int *COIN*_*SCORE* = 10;

    float stateTime;
    public Coin(float x, float y) {
        super (x, y,*COIN*_*WIDTH*,*COIN*_*HEIGHT*);
        stateTime = 0;
    }

    public void update(float deltaTime) {
        stateTime += deltaTime;
    }
}

Coin 类与 Spring 类非常相似,只有一点不同:我们跟踪硬币已经存在的时间。当我们想稍后使用动画来渲染硬币时,需要这些信息。在第八章最后一个例子中,我们为我们的穴居人做了同样的事情。这是我们在所有模拟课上都会用到的技术。给定一个状态和状态时间,我们可以选择一个动画,以及该动画的关键帧,用于渲染。硬币只有一种状态,所以我们只需要记录状态时间。为此,我们有 update()方法,它将状态时间增加传递给它的增量时间。

在类的顶部定义的常量指定了硬币的宽度和高度,如前所述,以及 Bob 击中硬币所获得的分数。

城堡课程

接下来,我们有一个关于世界之巅城堡的课程。清单 9-11 显示了代码。

清单 9-11。Castle.java,城堡类

package com.badlogic.androidgames.jumper;

import com.badlogic.androidgames.framework.GameObject;

public class Castle extends GameObject {
    public static float *CASTLE*_*WIDTH* = 1.7f;
    public static float *CASTLE*_*HEIGHT* = 1.7f;

    public Castle(float x, float y) {
        super (x, y, CASTLE_WIDTH, CASTLE_HEIGHT);
    }

}

不太复杂。我们需要存储的只是城堡的位置和边界。城堡的大小由常量 CASTLE_WIDTH 和 CASTLE_HEIGHT 定义,使用我们前面讨论过的值。

松鼠班

接下来是松鼠类,如清单 9-12 所示。

清单 9-12。【Squirrel.java】??,松鼠类

package com.badlogic.androidgames.jumper;

import com.badlogic.androidgames.framework.DynamicGameObject;

public class Squirrel extends DynamicGameObject {
    public static final float *SQUIRREL*_*WIDTH* = 1;
    public static final float *SQUIRREL*_*HEIGHT* = 0.6f;
    public static final float *SQUIRREL*_*VELOCITY* = 3f;

    float stateTime = 0;

    public Squirrel(float x, float y) {
        super (x, y, SQUIRREL_WIDTH, SQUIRREL_HEIGHT);
        velocity.set(*SQUIRREL*_*VELOCITY*, 0);
    }

    public void update(float deltaTime) {
        position.add(velocity.x * deltaTime, velocity.y * deltaTime);
        bounds.lowerLeft.set(position).sub(*SQUIRREL*_*WIDTH*/ 2,*SQUIRREL*_*HEIGHT*/ 2);

        if (position.x <*SQUIRREL*_*WIDTH*/ 2 ) {
            position.x =*SQUIRREL*_*WIDTH*/ 2;
            velocity.x =*SQUIRREL*_*VELOCITY*;
        }

        if (position.x > World.*WORLD*_*WIDTH*-*SQUIRREL*_*WIDTH*/ 2) {
            position.x = World.*WORLD*_*WIDTH*-*SQUIRREL*_*WIDTH*/ 2;
            velocity.x = -*SQUIRREL*_*VELOCITY*;
        }

        stateTime += deltaTime;
    }
}

松鼠是移动的物体,所以我们让这个类从 DynamicGameObject 派生,这给了我们一个速度向量和一个加速度向量。我们做的第一件事是定义一只松鼠的大小,以及它的速度。因为松鼠是动画,所以我们也记录它的状态时间。松鼠只有一种状态,就像硬币一样:水*移动。它是向左移动还是向右移动可以根据速度向量的 x 分量来决定,所以我们不需要为此存储单独的状态成员。

在构造函数中,我们用松鼠的初始位置和大小调用超类的构造函数。我们还将速度向量设置为(SQUIRREL_VELOCITY,0)。因此,所有的松鼠一开始都会向右移动。

update()方法根据速度和增量时间更新松鼠的位置和边界形状。这是我们标准的欧拉积分步骤,我们在第八章中讨论并使用了很多。我们还检查松鼠是撞到了世界的左边还是右边。如果是这样的话,我们只需简单地反转它的速度矢量,使它开始向相反的方向运动。如前所述,我们世界的宽度固定为 10 米。我们要做的最后一件事是根据 delta 时间更新状态时间,这样我们就可以决定稍后需要使用两个动画帧中的哪一个来渲染这只松鼠。

*台类

*台类如清单 9-13 中的所示。

清单 9-13。Platform.java,站台班

package com.badlogic.androidgames.jumper;

import com.badlogic.androidgames.framework.DynamicGameObject;

public class Platform extends DynamicGameObject {
    public static final float *PLATFORM*_*WIDTH* = 2;
    public static final float *PLATFORM*_*HEIGHT* = 0.5f;
    public static final int *PLATFORM*_*TYPE*_*STATIC* = 0;
    public static final int *PLATFORM*_*TYPE*_*MOVING* = 1;
    public static final int *PLATFORM*_*STATE*_*NORMAL* = 0;
    public static final int *PLATFORM*_*STATE*_*PULVERIZING* = 1;
    public static final float *PLATFORM*_*PULVERIZE*_*TIME* = 0.2f * 4;
    public static final float *PLATFORM*_*VELOCITY* = 2;

当然,*台稍微复杂一点。让我们复习一下在类中定义的常量。如前所述,前两个常数定义了*台的宽度和高度。一个*台有一个类型;它可以是静态*台,也可以是移动*台。我们通过常量*台类型静态和*台类型移动来表示这一点。*台也可以处于两种状态之一:它可以处于正常状态——也就是说,要么静止不动,要么移动——或者它可以被粉碎。状态通过常量*台状态正常或*台状态粉碎之一进行编码。当然,粉碎是一个有时间限制的过程。因此,我们将*台完全粉碎所需的时间定义为 0.8 秒。这个值简单地从*台动画的帧数和每帧的持续时间中得出——这是我们在试图遵循 MVC 模式时不得不接受的一个小怪癖。最后,如前所述,我们将移动*台的速度定义为 2 m/s。一个移动的*台的行为就像一只松鼠,它只是朝一个方向移动,直到碰到世界的水*边界,在这种情况下,它只是反转方向。

    int type;
    int state;
    float stateTime;

    public Platform( int type, float x, float y) {
        super (x, y, PLATFORM_WIDTH, PLATFORM_HEIGHT);
        this .type = type;
        this .state =*PLATFORM*_*STATE*_*NORMAL*;
        this .stateTime = 0;
        if (type ==*PLATFORM*_*TYPE*_*MOVING*) {
            velocity.x =*PLATFORM*_*VELOCITY*;
        }
    }

为了存储*台实例的类型、状态和状态时间,我们需要三个成员。这些在构造函数中基于*台的类型进行初始化,*台的类型是构造函数的一个参数,以及*台中心的位置。

    public void update(float deltaTime) {
        if (type ==*PLATFORM*_*TYPE*_*MOVING*) {
            position.add(velocity.x * deltaTime, 0);
            bounds.lowerLeft.set(position).sub(*PLATFORM*_*WIDTH*/ 2,*PLATFORM*_*HEIGHT*/ 2);

            if (position.x <*PLATFORM*_*WIDTH*/ 2) {
                velocity.x = -velocity.x;
                position.x =*PLATFORM*_*WIDTH*/ 2;
            }
            if (position.x > World.*WORLD*_*WIDTH*-*PLATFORM*_*WIDTH*/ 2) {
                velocity.x = -velocity.x;
                position.x = World.*WORLD*_*WIDTH*-*PLATFORM*_*WIDTH*/ 2;
            }
        }

        stateTime += deltaTime;
    }

update()方法将移动*台并检查外部条件,通过反转速度向量相应地采取行动。这与我们在 Squirrel.update()方法中所做的完全一样。我们还在方法结束时更新状态时间。

    public void pulverize() {
        state = PLATFORM_STATE_PULVERIZING;
        stateTime = 0;
        velocity.x = 0;
    }
}

这个类的最后一个方法被称为粉化()。它将状态从*台状态正常切换到*台状态粉碎,并重置状态时间和速度。这意味着移动*台将停止移动。如果世界类检测到 Bob 和*台之间的冲突,它将调用该方法,并根据一个随机数决定粉碎*台。我们稍后会谈到这一点。首先我们需要谈谈鲍勃。

鲍勃类

Bob 类如清单 9-14 中的所示。

清单 9-14。Bob.java

package com.badlogic.androidgames.jumper;

import com.badlogic.androidgames.framework.DynamicGameObject;

public class Bob extends DynamicGameObject{
    public static final int *BOB*_*STATE*_*JUMP* = 0;
    public static final int *BOB*_*STATE*_*FALL* = 1;
    public static final int *BOB*_*STATE*_*HIT* = 2;
    public static final float *BOB*_*JUMP*_*VELOCITY* = 11;
    public static final float *BOB*_*MOVE*_*VELOCITY* = 20;
    public static final float *BOB*_*WIDTH* = 0.8f;
    public static final float *BOB*_*HEIGHT* = 0.8f;

我们再次从几个常数开始。鲍勃可能处于三种状态之一:向上跳、向下摔或被击中。他还有一个垂直跳跃速度,只应用在 y 轴上,还有一个水*移动速度,只应用在 x 轴上。最后两个常量定义了 Bob 在世界上的宽度和高度。当然,我们还必须存储 Bob 的州和州时间。

    int state;
    float stateTime;

    public Bob(float x, float y) {
        super (x, y,*BOB*_*WIDTH*,*BOB*_*HEIGHT*);
        state =*BOB*_*STATE*_*FALL*;
        stateTime = 0;
    }

构造函数只是调用超类的构造函数,以便正确初始化 Bob 的中心位置和边界形状,然后初始化 state 和 stateTime 成员变量。

public void update(float deltaTime) {
        velocity.add(World.*gravity*.x * deltaTime, World.*gravity*.y * deltaTime);
        position.add(velocity.x * deltaTime, velocity.y * deltaTime);
        bounds.lowerLeft.set(position).sub(bounds.width / 2, bounds.height / 2);

        if (velocity.y > 0 && state !=*BOB*_*STATE*_*HIT*) {
            if (state !=*BOB*_*STATE*_*JUMP*) {
                state =*BOB*_*STATE*_*JUMP*;
                stateTime = 0;
            }
        }

        if (velocity.y < 0 && state !=*BOB*_*STATE*_*HIT*) {
            if (state !=*BOB*_*STATE*_*FALL*) {
                state =*BOB*_*STATE*_*FALL*;
                stateTime = 0;
            }
        }

        if (position.x < 0)
            position.x = World.*WORLD*_*WIDTH*;
        if (position.x > World.*WORLD*_*WIDTH*)
            position.x = 0;

        stateTime += deltaTime;
    }

update()方法首先基于重力和 Bob 的当前速度更新 Bob 的位置和边界形状。请注意,由于跳跃和水*移动,速度是重力和 Bob 自身移动的合成。接下来的两个大条件块将 Bob 的状态设置为 BOB_STATE_JUMPING 或 BOB_STATE_FALLING,并根据其速度的 y 分量重新初始化其状态时间。如果大于零,则 Bob 在跳;如果它小于零,那么鲍勃正在下落。只有当 Bob 没有被击中,并且他还没有处于正确的状态时,我们才这样做。否则,我们总是将状态时间重置为零,这将不会很好地与 Bob 的动画播放。如果 Bob 向左或向右离开世界,我们也从世界的一边绕到另一边。最后,我们再次更新 stateTime 成员。

除了重力,鲍勃从哪里得到他的速度?这就是其他方法的用武之地。

    public void hitSquirrel() {
        velocity.set(0,0);
        state =*BOB*_*STATE*_*HIT*;
        stateTime = 0;
    }

    public void hitPlatform() {
        velocity.y =*BOB*_*JUMP*_*VELOCITY*;
        state =*BOB*_*STATE*_*JUMP*;
        stateTime = 0;
    }

    public void hitSpring() {
        velocity.y =*BOB*_*JUMP*_*VELOCITY** 1.5f;
        state =*BOB*_*STATE*_*JUMP*;
        stateTime = 0;
    }
}

如果 Bob 击中了一只松鼠,World 类将调用 hitSquirrel()方法。如果是这样的话,Bob 自己停止移动,进入 BOB_STATE_HIT 状态。从这一点开始,只有重力会作用于鲍勃;玩家再也控制不了他,他也不再和*台互动。这类似于超级马里奥被敌人击中时的表现。他只是摔倒了。

hitPlatform()方法也由 World 类调用。当 Bob 下落时撞到*台时,将调用该函数。如果是这样的话,那么我们把他的 y 速度设置为 BOB_JUMP_VELOCITY,我们也相应地设置了他的状态和状态时间。从这一点开始,鲍勃将向上移动,直到重力再次获胜,使鲍勃摔倒。

最后一个方法 hitSpring()在 Bob 碰到弹簧时被 World 类调用。它与 hitPlatform()方法做同样的事情,只有一个例外;也就是说,初始向上速度设置为 BOB_JUMP_VELOCITY 的 1.5 倍。这意味着鲍勃在撞击弹簧时会比撞击*台时跳得高一点。

世界一流

我们要讨论的最后一门课是世界课。有点长,我们就分了吧。清单 9-15 显示了代码的第一部分。

清单 9-15。摘自 World.java;常数、成员和初始化

package com.badlogic.androidgames.jumper;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

import com.badlogic.androidgames.framework.math.OverlapTester;
import com.badlogic.androidgames.framework.math.Vector2;

public class World {
    public interface WorldListener {
        public void jump();
        public void highJump();
        public void hit();
        public void coin();
    }

我们首先定义的是一个名为 WorldListener 的接口。它是做什么的?我们需要它来解决一个小小的 MVC 问题:我们什么时候播放音效?我们可以只将 Assets.playSound()的调用添加到各自的模拟类中,但这并不是很干净。相反,我们将让一个世界级的用户注册一个 WorldListener,当 Bob 从一个*台上跳下、从一个弹簧上跳下、被一只松鼠击中或收集一枚硬币时,就会调用这个用户。稍后,我们将注册一个监听器,为每个事件播放正确的声音效果,使模拟类不直接依赖于渲染和音频播放。

    public static final float *WORLD*_*WIDTH* = 10;
    public static final float *WORLD*_*HEIGHT* = 15 * 20;
    public static final int *WORLD*_*STATE*_*RUNNING* = 0;
    public static final int *WORLD*_*STATE*_*NEXT*_*LEVEL* = 1;
    public static final int *WORLD*_*STATE*_*GAME*_*OVER* = 2;
    public static final Vector2*gravity* = new Vector2(0, -12);

接下来,我们定义几个常数。WORLD_WIDTH 和 WORLD_HEIGHT 指定了我们世界的水*和垂直范围。请记住,我们的视见体将显示我们世界的一个 10×15 米的区域。给定这里定义的常数,我们的世界将垂直跨越 20 个视锥或屏幕。同样,这是我们通过调整得出的值。当我们讨论如何生成一个关卡时,我们会回到这个话题。世界也可以处于三种状态之一:奔跑,等待下一关开始,或者游戏结束状态——当 Bob 落得太远时(在视图截锥之外)。这里我们也把重力加速度向量定义为常数。

    public final Bob bob;
    public final List<Platform> platforms;
    public final List<Spring> springs;
    public final List<Squirrel> squirrels;
    public final List<Coin> coins;
    public Castle castle;
    public final WorldListener listener;
    public final Random rand;

    public float heightSoFar;
    public int score;
    public int state;

接下来是世界级的所有成员。它跟踪鲍勃;所有的*台、弹簧、松鼠和硬币;还有城堡。此外,它还引用了一个 WorldListener 和一个 Random 实例,我们将使用它来为各种目的生成随机数。最后三个成员记录 Bob 目前达到的最高高度,以及世界状态和获得的分数。

    public World(WorldListener listener) {
        this .bob = new Bob(5, 1);
        this .platforms = new ArrayList<Platform>();
        this .springs = new ArrayList<Spring>();
        this .squirrels = new ArrayList<Squirrel>();
        this .coins = new ArrayList<Coin>();
        this .listener = listener;
        rand = new Random();
        generateLevel();

        this .heightSoFar = 0;
        this .score = 0;
        this .state =*WORLD*_*STATE*_*RUNNING*;
    }

构造函数初始化所有成员,并存储作为参数传递的 WorldListener。Bob 被水*放置在世界的中间,并且在(5,1)处稍微高于地面。除了 generateLevel()方法之外,其余的几乎不言自明。

创造世界

你可能已经想知道我们实际上是如何在我们的世界中创建和放置物体的。我们使用一种叫做过程生成的方法。我们想出了一个简单的算法,可以为我们生成一个随机的等级。清单 9-16 显示了代码。

清单 9-16。摘自 World.java;generateLevel()方法

private void generateLevel() {
    float y = Platform.*PLATFORM*_*HEIGHT*/ 2;
    float maxJumpHeight = Bob.*BOB*_*JUMP*_*VELOCITY** Bob.*BOB*_*JUMP*_*VELOCITY*
            / (2 * -*gravity*.y);
    while (y <*WORLD*_*HEIGHT*-*WORLD*_*WIDTH*/ 2) {
        int type = rand.nextFloat() > 0.8f ? Platform.*PLATFORM*_*TYPE*_*MOVING*
                : Platform.*PLATFORM*_*TYPE*_*STATIC*;
        float x = rand.nextFloat()
                * (*WORLD_WIDTH*- Platform.*PLATFORM_WIDTH*)
                + Platform.*PLATFORM_WIDTH*/ 2;

        Platform platform = new Platform(type, x, y);
        platforms.add(platform);

        if (rand.nextFloat() > 0.9f
                && type != Platform.*PLATFORM_TYPE_MOVING*) {
            Spring spring = new Spring(platform.position.x,
                    platform.position.y + Platform.*PLATFORM_HEIGHT*/ 2
                            + Spring.*SPRING_HEIGHT*/ 2);
            springs.add(spring);
        }

        if (y >*WORLD_HEIGHT*/ 3 && rand.nextFloat() > 0.8f) {
            Squirrel squirrel = new Squirrel(platform.position.x
                    + rand.nextFloat(), platform.position.y
                    + Squirrel.*SQUIRREL_HEIGHT*+ rand.nextFloat() * 2);
            squirrels.add(squirrel);
        }

        if (rand.nextFloat() > 0.6f) {
            Coin coin = new Coin(platform.position.x + rand.nextFloat(),
                    platform.position.y + Coin.*COIN_HEIGHT*
                            + rand.nextFloat() * 3);
            coins.add(coin);
        }

        y += (maxJumpHeight - 0.5f);
        y -= rand.nextFloat() * (maxJumpHeight / 3);
    }

    castle = new Castle(*WORLD_WIDTH*/ 2, y);
}

让我们用简单的话概括一下算法的大致思想:

  1. 从 y = 0 的世界底部开始。

  2. As long as we haven’t reached the top of the world yet, do the following:

    a.在当前 y 位置创建一个移动或静止的*台,并随机选择一个 x 位置。

    b.取一个 0 到 1 之间的随机数,如果大于 0.9,并且*台没有移动,则在*台顶部创建一个弹簧。

    c.如果我们高于第一个三分之一的水*,获取一个随机数,如果它高于 0.8,创建一个从*台位置随机偏移的松鼠。

    d.获取一个随机数,如果它大于 0.6,则创建一个从*台位置随机偏移的硬币。

    e.将 y 增加 Bob 的最大正常跳跃高度,随机减少一点点,但只减少到不低于最后一个 y 值的程度,然后转到第 2 步的开头。

  3. 将城堡放置在最后一个 y 位置,水*居中。

这个过程的大秘密是我们如何在步骤 2e 中增加下一个*台的 y 位置。我们必须确保 Bob 可以从当前*台跳转到每个后续*台。鲍勃只能跳重力允许的高度,因为他的初始垂直跳跃速度是 11 米/秒。我们如何计算鲍勃会跳多高?我们可以用下面的公式做到这一点:

image

这意味着我们应该在每个*台之间保持 4.6 米的垂直距离,以便 Bob 仍然可以够到它。为了确保所有*台都可以到达,我们使用了一个比最大跳跃高度稍小的值。这保证了 Bob 总是能够从一个*台跳到下一个*台。*台的水*放置也是随机的。假设 Bob 的水*移动速度为 20 m/s,我们可以非常确定他不仅能够垂直到达*台,还能够水*到达*台。

其他的物体都是随机产生的。方法 Random.nextFloat()在每次调用时返回一个介于 0 和 1 之间的随机数,其中每个数字出现的概率相同。只有当我们从 random 中取出的随机数大于 0.8 时,才会产生松鼠。这意味着我们将以 20%的概率(1–0.8)生成一只松鼠。对于所有其他随机创建的对象也是如此。通过调整这些值,我们可以在我们的世界中拥有更多或更少的对象。

更新世界

一旦我们生成了我们的世界,我们可以更新其中的所有对象并检查碰撞。清单 9-17 显示了世界类的更新方法,并附有注释。

清单 9-17。摘自 World.java;更新方法

public void update(float deltaTime, float accelX) {
    updateBob(deltaTime, accelX);
    updatePlatforms(deltaTime);
    updateSquirrels(deltaTime);
    updateCoins(deltaTime);
    if (bob.state != Bob.*BOB_STATE_HIT*)
        checkCollisions();
    checkGameOver();
}

方法 update()是我们的游戏屏幕稍后调用的方法。它接收加速度计 x 轴上的增量时间和加速度作为参数。它负责调用其他更新方法,以及执行冲突检查和游戏结束检查。对于我们世界中的每一种对象类型,我们都有一个更新方法。

private void updateBob(float deltaTime, float accelX) {
    if (bob.state != Bob.*BOB_STATE_HIT*&& bob.position.y <= 0.5f)
        bob.hitPlatform();
    if (bob.state != Bob.*BOB_STATE_HIT*)
        bob.velocity.x = -accelX / 10 * Bob.*BOB_MOVE_VELOCITY*;
    bob.update(deltaTime);
    heightSoFar = Math.*max*(bob.position.y, heightSoFar);
}

updateBob()方法负责更新鲍勃的状态。它做的第一件事是检查 Bob 是否到达世界的底部,在这种情况下,Bob 被指示跳跃。这意味着,在每一关开始时,鲍勃被允许跳离我们的世界。当然,一旦地面看不见了,这就行不通了。接下来,我们更新 Bob 的水*速度,这是基于我们作为参数得到的加速度计的 x 轴值。如前所述,我们将该值从–10 到 10 的范围归一化到–1 到 1 的范围(完全向左倾斜到完全向右倾斜),然后乘以 Bob 的标准移动速度。接下来,我们告诉 Bob 通过调用 Bob.update()方法来更新自己。我们要做的最后一件事是记录 Bob 目前到达的最高 y 位置。我们需要这个来确定 Bob 后来是否走得太远了。

private void updatePlatforms(float deltaTime) {
    int len = platforms.size();
    for (int i = 0; i < len; i++) {
        Platform platform = platforms.get(i);
        platform.update(deltaTime);
        if (platform.state == Platform.*PLATFORM_STATE_PULVERIZING*
                && platform.stateTime > Platform.*PLATFORM_PULVERIZE_TIME*) {
            platforms.remove(platform);
            len = platforms.size();
        }
    }
}

接下来,我们更新 updatePlatforms()中的所有*台。我们遍历*台列表,用当前的 delta 时间调用每个*台的 update()方法。如果*台处于粉碎过程中,我们检查这种情况已经持续了多长时间。如果*台处于*台 _ 状态 _ 粉碎状态的时间超过*台 _ 粉碎 _ 时间,我们只需从*台列表中删除该*台。

private void updateSquirrels(float deltaTime) {
    int len = squirrels.size();
    for (int i = 0; i < len; i++) {
        Squirrel squirrel = squirrels.get(i);
        squirrel.update(deltaTime);
    }
}

private void updateCoins(float deltaTime) {
    int len = coins.size();
    for (int i = 0; i < len; i++) {
        Coin coin = coins.get(i);
        coin.update(deltaTime);
    }
}

在 updateSquirrels()方法中,我们通过其 update()方法更新每个松鼠实例,并传入当前的 delta 时间。我们在 updateCoins()方法中对每个 Coin 实例做同样的事情。

冲突检测和响应

回顾我们最初的 World.update()方法,我们可以看到,我们接下来要做的事情是检查 Bob 与世界上所有其他可能与他发生碰撞的对象之间的碰撞。只有当 Bob 处于不等于 BOB_STATE_HIT 的状态时,我们才这样做,因为在那个状态下,他只是由于重力而继续下落。让我们看看清单 9-18 中的那些碰撞检查方法。

清单 9-18。摘自 World.java;冲突检查方法

private void checkCollisions() {
    checkPlatformCollisions();
    checkSquirrelCollisions();
    checkItemCollisions();
    checkCastleCollisions();
}

checkCollisions()方法或多或少是另一个主方法,它简单地调用所有其他冲突检查方法。鲍勃可以与世界上的一些东西发生碰撞:*台、松鼠、硬币、弹簧和城堡。对于这些对象类型中的每一种,我们都有单独的冲突检查方法。请记住,在我们更新了世界中所有对象的位置和边界形状之后,我们将调用这个方法和从属方法。把它想象成在给定时间点我们世界状态的快照。我们所做的就是观察这张静止图像,看看是否有任何重叠。然后,我们可以采取行动,并确保碰撞的对象通过操纵它们的状态、位置、速度等,对下一帧中的重叠或碰撞做出反应。

private void checkPlatformCollisions() {
    if (bob.velocity.y > 0)
        return ;

    int len = platforms.size();
    for (int i = 0; i < len; i++) {
        Platform platform = platforms.get(i);
        if (bob.position.y > platform.position.y) {
            if (OverlapTester
                    .*overlapRectangles*(bob.bounds, platform.bounds)) {
                bob.hitPlatform();
                listener.jump();
                if (rand.nextFloat() > 0.5f) {
                    platform.pulverize();
                }
                break ;
            }
        }
    }
}

在 checkPlatformCollisions()方法中,我们测试 Bob 和我们世界中的任何*台之间的重叠。如果 Bob 正在往上走,我们就提前脱离这个方法。这使得鲍勃能够从下面穿过*台。对于超级跳高运动员来说,那是好行为;在像超级马里奥兄弟这样的游戏中,如果鲍勃从下面撞上一个障碍物,我们可能会希望他摔倒。

接下来,我们遍历所有*台,检查 Bob 是否在当前*台之上。如果是,我们测试他的包围矩形是否与*台的包围矩形重叠。如果是,我们通过调用 Bob.hitPlatform()告诉 Bob 他碰到了一个*台。回头看看那个方法,我们看到它将触发一个跳转,并相应地设置 Bob 的状态。接下来,我们调用 WorldListener.jump()方法通知侦听器 Bob 刚刚再次开始跳转。我们稍后将使用它在听众中回放相应的声音效果。我们做的最后一件事是获取一个随机数,如果它大于 0.5,就告诉*台自行粉碎。它将在另一个 PLATFORM _ minuffy _ TIME 秒(0.8)内有效,然后将在前面显示的 updatePlatforms()方法中被删除。当我们渲染该*台时,我们将使用其状态时间来确定回放哪个*台动画关键帧。

private void checkSquirrelCollisions() {
    int len = squirrels.size();
    for (int i = 0; i < len; i++) {
        Squirrel squirrel = squirrels.get(i);
        if (OverlapTester.*overlapRectangles*(squirrel.bounds, bob.bounds)) {
            bob.hitSquirrel();
            listener.hit();
        }
    }
}

checkSquirrelCollisions()方法根据每只松鼠的包围矩形测试 Bob 的包围矩形。如果 Bob 击中了一只松鼠,我们告诉他进入 BOB_STATE_HIT 状态,这将使他摔倒,而玩家无法进一步控制他。例如,我们还把它告诉了 WorldListener,以便 Bob 可以回放一个声音效果。

private void checkItemCollisions() {
    int len = coins.size();
    for (int i = 0; i < len; i++) {
        Coin coin = coins.get(i);
        if (OverlapTester.*overlapRectangles*(bob.bounds, coin.bounds)) {
            coins.remove(coin);
            len = coins.size();
            listener.coin();
            score += Coin.*COIN_SCORE*;
        }

    }

    if (bob.velocity.y > 0)
        return ;

    len = springs.size();
    for (int i = 0; i < len; i++) {
        Spring spring = springs.get(i);
        if (bob.position.y > spring.position.y) {
            if (OverlapTester.*overlapRectangles*(bob.bounds, spring.bounds)) {
                bob.hitSpring();
                listener.highJump();
            }
        }
    }
}

checkItemCollisions()方法对照世界上所有的硬币和所有的弹簧来检查 Bob。如果 Bob 击中了一枚硬币,我们将该硬币从我们的世界中移除,告诉听者一枚硬币被收集,并将当前分数增加 COIN_SCORE。如果 Bob 向下坠落,我们还会对照世界上所有的弹簧来检查 Bob。如果他击中了一个,我们会告诉他,这样他就会比*时跳得更高。我们还会将此事件通知给侦听器。

private void checkCastleCollisions() {
    if (OverlapTester.*overlapRectangles*(castle.bounds, bob.bounds)) {
        state = WORLD_STATE_NEXT_LEVEL;
    }
}

最后一个方法是用城堡来检验 Bob。如果 Bob 点击了它,我们将世界的状态设置为 WORLD_STATE_NEXT_LEVEL,向任何外部实体(如我们的游戏屏幕)发出信号,表明我们应该过渡到下一个级别,这将再次是一个随机生成的世界实例。

游戏结束了,伙计!

World 类中的最后一个方法在 World.update()方法的最后一行被调用,如清单 9-19 中的所示。

清单 9-19。World.java 其余地区的;游戏结束检查方法

    private void checkGameOver() {
        if (heightSoFar - 7.5f > bob.position.y) {
            state =*WORLD*_*STATE*_*GAME*_*OVER*;
        }
    }
}

还记得我们是如何定义游戏结束状态的吗:Bob 必须离开视图截锥的底部。当然,视图截锥由 Camera2D 实例控制,它有一个位置。那个位置的 y 坐标总是等于 Bob 到目前为止拥有的最大的 y 坐标,所以相机会在 Bob 向上的路上跟随他。因为我们想把渲染和模拟代码分开,所以在我们的世界里我们没有一个对相机的引用。因此,我们在 updateBob()中跟踪 Bob 的最高 y 坐标,并将该值存储在 heightSoFar 中。我们知道我们的视见*截头体将有 15 米高。因此,我们还知道,如果 Bob 的 y 坐标低于 heightSoFar–7.5,那么他已经将视图截锥留在了底部边缘。这时鲍勃被宣布死亡。当然,这有一点点粗糙,因为它是基于这样的假设:视见体的高度将始终是 15 米,并且摄像机将始终位于 Bob 到目前为止能够到达的最高 y 坐标处。如果我们允许变焦或使用不同的相机跟踪方法,这将不再成立。我们不会让事情变得过于复杂,而是让它保持原样。在游戏开发中,你会经常面临这样的决定,因为从软件工程的角度来看,有时很难保持一切整洁(正如我们过度使用公共或包私有成员所证明的)。

你可能想知道为什么我们不使用我们在第八章开发的 SpatialHashGrid 类。一会儿我们会告诉你原因。让我们通过首先实现 GameScreen 类来完成我们的游戏。

游戏屏幕

我们即将完成超级 Jumper。我们需要实现的最后一件事是游戏屏幕,它将把实际的游戏世界呈现给玩家,并允许玩家与之进行交互。游戏屏幕由五个子屏幕组成,如本章前面的图 9-2 所示。我们有就绪屏幕、正常运行屏幕、下一级屏幕、游戏结束屏幕和暂停屏幕。《诺姆先生》中的游戏画面与此类似;它只缺少一个下一级屏幕,因为只有一个级别。我们将使用与 Nom 先生相同的方法:我们将为所有更新和呈现游戏世界的子屏幕以及子屏幕中的 UI 元素提供单独的更新和呈现方法。由于游戏屏幕代码有点长,我们将在这里把它分成多个清单。清单 9-20 显示了游戏屏幕的第一部分。

清单 9-20。摘自 GameScreen.java;成员和构造函数

package com.badlogic.androidgames.jumper;

import java.util.List;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.gl.Camera2D;
import com.badlogic.androidgames.framework.gl.FPSCounter;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.impl.GLScreen;
import com.badlogic.androidgames.framework.math.OverlapTester;
import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;
import com.badlogic.androidgames.jumper.World.WorldListener;

public class GameScreen extends GLScreen {
    static final int *GAME*_*READY* = 0;
    static final int *GAME*_*RUNNING* = 1;
    static final int *GAME*_*PAUSED* = 2;
    static final int *GAME*_*LEVEL*_*END* = 3;
    static final int *GAME*_*OVER* = 4;

    int state;
    Camera2D guiCam;
    Vector2 touchPoint;
    SpriteBatcher batcher;
    World world;
    WorldListener worldListener;
    WorldRenderer renderer;
    Rectangle pauseBounds;
    Rectangle resumeBounds;
    Rectangle quitBounds;
    int lastScore;
    String scoreString;

这个类从定义屏幕五种状态的常量开始。接下来,我们有成员。我们有一个用于呈现 UI 元素的摄像头,以及一个向量,以便我们可以将触摸坐标转换为世界坐标(就像在其他屏幕中一样,转换为 320×480 单位的视见*截头体,这是我们的目标分辨率)。接下来,我们有一个 SpriteBatcher、一个 World 实例和一个 WorldListener。WorldRenderer 类是我们马上要研究的东西。它基本上只是把一个世界呈现出来。注意,它还将对 SpriteBatcher 的引用作为其构造函数的参数。这意味着我们将使用相同的 SpriteBatcher 来呈现屏幕的 UI 元素和游戏世界。其余的成员是不同 UI 元素的矩形(比如暂停的子屏幕上的 RESUME 和 QUIT 菜单项)和两个用于跟踪当前分数的成员。我们希望在渲染乐谱时避免每一帧都创建一个新的字符串,以便让垃圾收集器满意。

    public GameScreen(Game game) {
        super (game);
        state =*GAME*_*READY*;
        guiCam = new Camera2D(glGraphics, 320, 480);
        touchPoint = new Vector2();
        batcher = new SpriteBatcher(glGraphics, 1000);
        worldListener = new WorldListener() {
            public void jump() {
                Assets.*playSound*(Assets.*jumpSound*);
            }

            public void highJump() {
                Assets.*playSound*(Assets.*highJumpSound*);
            }

            public void hit() {
                Assets.*playSound*(Assets.*hitSound*);
            }

            public void coin() {
                Assets.*playSound*(Assets.*coinSound*);
            }
        };
        world = new World(worldListener);
        renderer = new WorldRenderer(glGraphics, batcher, world);
        pauseBounds = new Rectangle(320- 64, 480- 64, 64, 64);
        resumeBounds = new Rectangle(160 - 96, 240, 192, 36);
        quitBounds = new Rectangle(160 - 96, 240 - 36, 192, 36);
        lastScore = 0;
        scoreString = "score: 0";
    }

在构造函数中,我们初始化所有的成员变量。这里唯一有趣的是我们作为匿名内部类实现的 WorldListener。它在 World 实例中注册,它将根据向它报告的事件播放声音效果。

更新游戏屏幕

接下来我们有更新方法,这将确保任何用户输入被正确地处理,并且如果必要的话还将更新世界实例。清单 9-21 显示了代码。

清单 9-21。摘自 GameScreen.java;更新方法

@Override
public void update(float deltaTime) {
    if (deltaTime > 0.1f)
        deltaTime = 0.1f;

    switch (state) {
    case *GAME*_*READY*:
        updateReady();
        break ;
    case GAME_RUNNING:
        updateRunning(deltaTime);
        break ;
    case GAME_PAUSED:
        updatePaused();
        break ;
    case GAME_LEVEL_END:
        updateLevelEnd();
        break ;
    case *GAME*_*OVER*:
        updateGameOver();
        break ;
    }
}

我们再次将 GLScreen.update()方法作为主方法,它根据屏幕的当前状态调用其他更新方法之一。请注意,我们将增量时间限制为 0.1 秒。我们为什么要这么做?在第七章中,我们谈到了 Android 版本中直接字节缓冲区的一个 bug,这个 bug 会产生垃圾。我们会在超级 Jumper 和 Android 1.5 设备上遇到这个问题。我们的游戏时不时会被垃圾收集器中断几百毫秒。这将反映在几百毫秒的时间增量中,这将使 Bob 从一个地方传送到另一个地方,而不是*稳地移动到那里。这对玩家来说很烦人,对我们的碰撞检测也有影响。Bob 可以穿过一个*台,而不会与它重叠,因为他在一个帧中移动了很大的距离。通过将增量时间限制为 0.1 秒的合理最大值,我们可以补偿这些影响。

private void updateReady() {
    if (game.getInput().getTouchEvents().size() > 0) {
        state =*GAME*_*RUNNING*;
    }
}

在暂停的子屏幕中调用 updateReady()方法。它所做的只是等待一个触摸事件,在这种情况下,它会将游戏屏幕的状态更改为 GAME_RUNNING 状态。

private void updateRunning(float deltaTime) {
    List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
    int len = touchEvents.size();
    for (int i = 0; i < len; i++) {
        TouchEvent event = touchEvents.get(i);
        if (event.type != TouchEvent.*TOUCH*_*UP*)
            continue ;

        touchPoint.set(event.x, event.y);
        guiCam.touchToWorld(touchPoint);

        if (OverlapTester.*pointInRectangle*(pauseBounds, touchPoint)) {
            Assets.*playSound*(Assets.*clickSound*);
            state =*GAME*_*PAUSED*;
            return ;
        }
    }

    world.update(deltaTime, game.getInput().getAccelX());
    if (world.score != lastScore) {
        lastScore = world.score;
        scoreString = "" + lastScore;
    }
    if (world.state == World.*WORLD*_*STATE*_*NEXT*_*LEVEL*) {
        state =*GAME*_*LEVEL*_*END*;
    }
    if (world.state == World.*WORLD*_*STATE*_*GAME*_*OVER*) {
        state =*GAME*_*OVER*;
        if (lastScore >= Settings.*highscores*[4])
            scoreString = "new highscore: " + lastScore;
        else
            scoreString = "score: " + lastScore;
        Settings.*addScore*(lastScore);
        Settings.*save*(game.getFileIO());
    }
}

在 updateRunning()方法中,我们首先检查用户是否触摸了右上角的暂停按钮。如果是这种情况,那么游戏就进入 GAME_PAUSED 状态。否则,我们用当前的增量时间和加速度计的 x 轴值来更新世界实例,它们负责水*移动 Bob。世界更新后,我们检查我们的分数字符串是否需要更新。我们还检查鲍勃是否已经到达城堡;如果有,我们进入 GAME_NEXT_LEVEL 状态,在图 9-2 的左上角屏幕显示消息,等待触摸事件生成下一关。如果游戏结束了,我们将分数字符串设置为 score: #score 或 new highscore: #score,这取决于所达到的分数是否是新的高分。然后,我们将分数添加到 Settings 类中,并告诉它将所有设置保存到 SD 卡中。此外,我们将游戏屏幕设置为 GAME_OVER 状态。

private void updatePaused() {
    List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
    int len = touchEvents.size();
    for (int i = 0; i < len; i++) {
        TouchEvent event = touchEvents.get(i);
        if (event.type != TouchEvent.*TOUCH*_*UP*)
            continue ;

        touchPoint.set(event.x, event.y);
        guiCam.touchToWorld(touchPoint);

        if (OverlapTester.*pointInRectangle*(resumeBounds, touchPoint)) {
            Assets.*playSound*(Assets.*clickSound*);
            state =*GAME*_*RUNNING*;
            return ;
        }

        if (OverlapTester.*pointInRectangle*(quitBounds, touchPoint)) {
            Assets.*playSound*(Assets.*clickSound*);
            game.setScreen( new MainMenuScreen(game));
            return ;
        }
    }
}

在 updatePaused()方法中,我们检查用户是否触摸了 RESUME 元素或 QUIT UI 元素,并做出相应的反应。

private void updateLevelEnd() {
    List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
    int len = touchEvents.size();
    for (int i = 0; i < len; i++) {
        TouchEvent event = touchEvents.get(i);
        if (event.type != TouchEvent.*TOUCH*_*UP*)
            continue ;
        world = new World(worldListener);
        renderer = new WorldRenderer(glGraphics, batcher, world);
        world.score = lastScore;
        state =*GAME*_*READY*;
    }
}

在 updateLevelEnd()方法中,我们检查触摸事件;如果有,我们创建一个新的 World 和 WorldRenderer 实例。我们还告诉世界使用到目前为止取得的分数,并将游戏屏幕设置为 GAME_READY 状态,这将再次等待触摸事件。

private void updateGameOver() {
    List<TouchEvent> touchEvents = game.getInput().getTouchEvents();
    int len = touchEvents.size();
    for (int i = 0; i < len; i++) {
        TouchEvent event = touchEvents.get(i);
        if (event.type != TouchEvent.*TOUCH*_*UP*)
            continue ;
        game.setScreen( new MainMenuScreen(game));
    }
}

在 updateGameOver()方法中,我们再次检查触摸事件,在这种情况下,我们简单地转换回主菜单,如图 9-2 所示。

渲染游戏屏幕

在所有这些更新之后,游戏屏幕将被要求通过调用 GameScreen.present()来呈现自己。让我们看看清单 9-22 中的方法。

清单 9-22。摘自 GameScreen.java;渲染方法

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.*GL*_*COLOR*_*BUFFER*_*BIT*);
    gl.glEnable(GL10.*GL*_*TEXTURE*_*2D*);

    renderer.render();

    guiCam.setViewportAndMatrices();
    gl.glEnable(GL10.*GL*_*BLEND*);
    gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
    batcher.beginBatch(Assets.*items*);
    switch (state) {
    case *GAME*_*READY*:
        presentReady();
        break ;
    case GAME_RUNNING:
        presentRunning();
        break ;
    case GAME_PAUSED:
        presentPaused();
        break ;
    case GAME_LEVEL_END:
        presentLevelEnd();
        break ;
    case *GAME*_*OVER*:
        presentGameOver();
        break ;
    }
    batcher.endBatch();
    gl.glDisable(GL10.*GL*_*BLEND*);
}

游戏屏幕的渲染分两步完成。我们首先通过 WorldRenderer 类渲染实际的游戏世界,然后根据游戏屏幕的当前状态在游戏世界上渲染所有的 UI 元素。render()方法就是这样做的。与我们的更新方法一样,我们也为所有子屏幕提供了单独的渲染方法。

private void presentReady() {
    batcher.drawSprite(160, 240, 192, 32, Assets.*ready*);
}

presentRunning()方法只在右上角显示暂停按钮,在左上角显示乐谱字符串。

private void presentRunning() {
    batcher.drawSprite(320 - 32, 480 - 32, 64, 64, Assets.*pause*);
    Assets.*font*.drawText(batcher, scoreString, 16, 480-20);
}

在 presentRunning()方法中,我们简单地呈现暂停按钮和当前的分数字符串。

private void presentPaused() {
    batcher.drawSprite(160, 240, 192, 96, Assets.*pauseMenu*);
    Assets.*font*.drawText(batcher, scoreString, 16, 480-20);
}

presentPaused()方法再次显示暂停菜单 UI 元素和乐谱。

private void presentLevelEnd() {
    String topText = "the princess is ...";
    String bottomText = "in another castle!";
    float topWidth = Assets.*font*.glyphWidth * topText.length();
    float bottomWidth = Assets.*font*.glyphWidth * bottomText.length();
    Assets.*font*.drawText(batcher, topText, 160 - topWidth / 2, 480 - 40);
    Assets.*font*.drawText(batcher, bottomText, 160 - bottomWidth / 2, 40);
}

presentLevelEnd()方法呈现公主所在的字符串。。。屏幕上方还有另一座城堡里的绳子!在屏幕下方,如图图 9-2 所示。我们执行一些计算来使这些字符串水*居中。

private void presentGameOver() {
    batcher.drawSprite(160, 240, 160, 96, Assets.*gameOver*);
    float scoreWidth = Assets.*font*.glyphWidth * scoreString.length();
    Assets.*font*.drawText(batcher, scoreString, 160 - scoreWidth / 2, 480-20);
}

presentGameOver()方法显示游戏结束 UI 元素以及分数字符串。请记住,分数屏幕在 updateRunning()方法中设置为 score: #score 或 new highscore: #value。

收尾

这基本上是我们的游戏屏幕类。它的其余代码在清单 9-23 中给出。

清单 9-23。GameScreen.java 其余地区的;pause()、resume()和 dispose()方法

    @Override
    public void pause() {
        if (state ==*GAME*_*RUNNING*)
            state =*GAME*_*PAUSED*;
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
    }
}

我们只是确保当用户决定暂停应用时,我们的游戏屏幕是暂停的。

我们必须实现的最后一件事是 WorldRenderer 类。

WorldRenderer 类

这堂课应该不奇怪。它只是在构造函数中使用我们传递给它的 SpriteBatcher,并相应地渲染世界。清单 9-24 显示了代码的开头。

清单 9-24。摘自 WorldRenderer.java;常数、成员和构造函数

package com.badlogic.androidgames.jumper;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.gl.Animation;
import com.badlogic.androidgames.framework.gl.Camera2D;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.gl.TextureRegion;
import com.badlogic.androidgames.framework.impl.GLGraphics;

public class WorldRenderer {
    static final float *FRUSTUM*_*WIDTH* = 10;
    static final float *FRUSTUM*_*HEIGHT* = 15;
    GLGraphics glGraphics;
    World world;
    Camera2D cam;
    SpriteBatcher batcher;

    public WorldRenderer(GLGraphics glGraphics, SpriteBatcher batcher, World world) {
        this .glGraphics = glGraphics;
        this .world = world;
        this .cam = new Camera2D(glGraphics,*FRUSTUM*_*WIDTH*,*FRUSTUM*_*HEIGHT*);
        this .batcher = batcher;
    }

像往常一样,我们从定义一些常数开始。在这种情况下,我们将视图截锥的宽度和高度分别定义为 10 米和 15 米。我们也有几个成员——即一个 GLGraphics 实例、一个摄像机和我们从游戏屏幕上获得的 SpriteBatcher 引用。

该构造函数将一个 GLGraphics 实例、一个 SpriteBatcher 和 WorldRenderer 应该绘制的世界作为参数。我们相应地设置了所有成员。清单 9-25 显示了实际的渲染代码。

清单 9-25。WorldRenderer.java 其余地区的;实际的渲染代码

   public void render() {
        if (world.bob.position.y > cam.position.y )
            cam.position.y = world.bob.position.y;
        cam.setViewportAndMatrices();
        renderBackground();
        renderObjects();
    }

render()方法将渲染分成两批:一批用于背景图像,另一批用于世界上的所有对象。它还根据 Bob 的当前 y 坐标更新摄像机位置。如果他在摄像机的 y 坐标上方,摄像机的位置会相应调整。请注意,我们使用的相机在这里的世界单位。我们只为背景和物体设置一次矩阵。

    public void renderBackground() {
        batcher.beginBatch(Assets.*background*);
        batcher.drawSprite(cam.position.x, cam.position.y,
                           *FRUSTUM*_*WIDTH*,*FRUSTUM*_*HEIGHT*,
                           Assets.*backgroundRegion*);
        batcher.endBatch();
    }

renderBackground()方法简单地渲染背景,使其跟随摄像机。它不会滚动,而是始终呈现,以便填充整个屏幕。我们也不使用任何混合来渲染背景,这样我们可以挤出更多的性能。

    public void renderObjects() {
        GL10 gl = glGraphics.getGL();
        gl.glEnable(GL10.*GL*_*BLEND*);
        gl.glBlendFunc(GL10.*GL*_*SRC*_*ALPHA*, GL10.*GL*_*ONE*_*MINUS*_*SRC*_*ALPHA*);

        batcher.beginBatch(Assets.*items*);
        renderBob();
        renderPlatforms();
        renderItems();
        renderSquirrels();
        renderCastle();
        batcher.endBatch();
        gl.glDisable(GL10.*GL*_*BLEND*);
    }

renderObjects()方法负责渲染第二批。这一次我们使用混合,因为我们所有的对象都有透明的背景像素。所有对象都在一个批处理中渲染。回头看看 GameScreen 的构造函数,我们看到我们使用的 SpriteBatcher 可以一次处理 1000 个 sprites 对于我们的世界来说绰绰有余。对于每种对象类型,我们都有单独的渲染方法。

    private void renderBob() {
        TextureRegion keyFrame;
        switch (world.bob.state) {
        case Bob.*BOB*_*STATE*_*FALL*:
            keyFrame = Assets.*bobFall*.getKeyFrame(world.bob.stateTime, Animation.*ANIMATION*_*LOOPING*);
            break ;
        case Bob.*BOB*_*STATE*_*JUMP*:
            keyFrame = Assets.*bobJump*.getKeyFrame(world.bob.stateTime, Animation.*ANIMATION*_*LOOPING*);
            break ;
        case Bob.*BOB*_*STATE*_*HIT*:
        default :
            keyFrame = Assets.*bobHit*;
        }
        float side = world.bob.velocity.x < 0? -1: 1;
        batcher.drawSprite(world.bob.position.x, world.bob.position.y, side * 1, 1, keyFrame);
    }

方法 renderBob() 负责渲染 Bob。基于鲍勃的状态和状态时间,我们从鲍勃的总共五个关键帧中选择一个关键帧(见本章前面的图 9-9 )。基于 Bob 的速度的 x 分量,我们还确定 Bob 面向哪一侧。基于此,我们乘以 1 或–1 来相应地翻转纹理区域。记住,我们只有向右看的 Bob 的关键帧。还要注意,我们不使用 BOB_WIDTH 或 BOB_HEIGHT 来指定我们为 BOB 绘制的矩形的大小。这些大小是边界形状的大小,不一定是我们渲染的矩形的大小。相反,我们使用 1×1 米到 32×32 像素的映射。这是我们将为所有的精灵渲染做的事情;我们将使用 1×1 矩形(鲍勃、硬币、松鼠、弹簧)、2×0.5 矩形(*台)或 2×2 矩形(城堡)。

    private void renderPlatforms() {
        int len = world.platforms.size();
        for (int i = 0; i < len; i++) {
            Platform platform = world.platforms.get(i);
            TextureRegion keyFrame = Assets.*platform*;
            if (platform.state == Platform.*PLATFORM*_*STATE*_*PULVERIZING*) {
                keyFrame = Assets.*brakingPlatform*.getKeyFrame(platform.stateTime,Animation.*ANIMATION*_*NONLOOPING*);
            }
            batcher.drawSprite(platform.position.x, platform.position.y,
                               2, 0.5f, keyFrame);
        }
    }

方法 renderPlatforms() 遍历世界上所有的*台,并根据*台的状态选择一个 TextureRegion。*台可以粉碎,也可以不粉碎。在后一种情况下,我们简单地使用第一个关键帧;在前一种情况下,我们根据*台的状态时间从雾化动画中获取一个关键帧。

    private void renderItems() {
        int len = world.springs.size();
        for (int i = 0; i < len; i++) {
            Spring spring = world.springs.get(i);
            batcher.drawSprite(spring.position.x, spring.position.y, 1, 1, Assets.*spring*);
        }

        len = world.coins.size();
        for (int i = 0; i < len; i++) {
            Coin coin = world.coins.get(i);
            TextureRegion keyFrame = Assets.*coinAnim*.getKeyFrame(coin.stateTime, Animation.*ANIMATION*_*LOOPING*);
            batcher.drawSprite(coin.position.x, coin.position.y, 1, 1, keyFrame);
        }
    }

方法 renderItems() 呈现弹簧和硬币。对于弹簧,我们只使用我们在素材中定义的一个 TextureRegion,对于硬币,我们再次根据硬币的状态时间从动画中选择一个关键帧。

    private void renderSquirrels() {
        int len = world.squirrels.size();
        for (int i = 0; i < len; i++) {
            Squirrel squirrel = world.squirrels.get(i);
            TextureRegion keyFrame = Assets.*squirrelFly*.getKeyFrame(squirrel.stateTime, Animation.*ANIMATION*_*LOOPING*);
            float side = squirrel.velocity.x < 0?-1:1;
            batcher.drawSprite(squirrel.position.x, squirrel.position.y, side * 1, 1, keyFrame);
        }
    }

方法 renderSquirrels() 呈现松鼠。我们再次基于松鼠的状态时间获取一个关键帧,确定它面向哪个方向,并在使用 SpriteBatcher 渲染它时相应地操纵宽度。这是必要的,因为我们在纹理贴图集中只有一个朝左的松鼠版本。

    private void renderCastle() {
        Castle castle = world.castle;
        batcher.drawSprite(castle.position.x, castle.position.y, 2, 2, Assets.*castle*);
    }
}

最后一个方法 renderCastle(),简单地用我们在 Assets 类中定义的 TextureRegion 绘制城堡。

这很简单,不是吗?我们只有两批渲染:一批用于背景,一批用于物体。后退一步,我们看到我们也为游戏屏幕的所有 UI 元素呈现了第三批。这是三次纹理更改和三次上传新顶点到 GPU。理论上我们可以合并 UI 和对象批处理,但是那会很麻烦,并且会在我们的代码中引入一些漏洞。

我们终于完成了。我们的第二个游戏,超级 Jumper,现在可以开始了。根据我们在第七章中的优化指导方针,我们应该拥有闪电般的渲染速度。让我们看看这是不是真的。

优化还是不优化

是时候测试我们的新游戏了。我们真正需要处理速度的地方是游戏屏幕。我们只是将 FPSCounter 实例放在 GameScreen 类中,并在 GameScreen.render()方法的末尾调用它的 FPSCounter.logFrame()方法。以下是一个英雄、一个机器人和一个 Nexus One 的结果:

Hero (1.5):
01-02 20:58:06.417: DEBUG/FPSCounter(8251): fps: 57
01-02 20:58:07.427: DEBUG/FPSCounter(8251): fps: 57
01-02 20:58:08.447: DEBUG/FPSCounter(8251): fps: 57
01-02 20:58:09.447: DEBUG/FPSCounter(8251): fps: 56

Droid (2.1.1):
01-02 21:03:59.643: DEBUG/FPSCounter(1676): fps: 61
01-02 21:04:00.659: DEBUG/FPSCounter(1676): fps: 59
01-02 21:04:01.659: DEBUG/FPSCounter(1676): fps: 60
01-02 21:04:02.666: DEBUG/FPSCounter(1676): fps: 60

Nexus One (2.2.1):
01-02 20:54:05.263: DEBUG/FPSCounter(1393): fps: 61
01-02 20:54:06.273: DEBUG/FPSCounter(1393): fps: 61
01-02 20:54:07.273: DEBUG/FPSCounter(1393): fps: 60
01-02 20:54:08.283: DEBUG/FPSCounter(1393): fps: 61

每秒 60 帧已经很不错了。当然,由于其不太出色的 CPU,英雄有点挣扎。我们可以使用 SpatialHashGrid 来稍微加快我们世界的模拟速度。亲爱的读者,我们将把它留给你做练习。不过,这样做并没有真正的必要性,因为英雄总是充满问题(就此而言,其他 1.5 设备也是如此)。更糟糕的是,由于垃圾收集,英雄偶尔会打嗝。我们知道原因(direct ByteBuffer 中的一个 bug),但是我们真的无能为力。令人欣慰的是,Android 版本已经不再那么普遍了。为了最大限度的兼容,还是要考虑的。

我们在主菜单中禁用声音的情况下进行了上述测量。让我们打开音频播放再试一次:

Hero (1.5):
01-02 21:01:22.437: DEBUG/FPSCounter(8251): fps: 43
01-02 21:01:23.457: DEBUG/FPSCounter(8251): fps: 48
01-02 21:01:24.467: DEBUG/FPSCounter(8251): fps: 49
01-02 21:01:25.487: DEBUG/FPSCounter(8251): fps: 49

Droid (2.1.1):
01-02 21:10:49.979: DEBUG/FPSCounter(1676): fps: 54
01-02 21:10:50.979: DEBUG/FPSCounter(1676): fps: 56
01-02 21:10:51.987: DEBUG/FPSCounter(1676): fps: 54
01-02 21:10:52.987: DEBUG/FPSCounter(1676): fps: 56

Nexus One (2.2.1):
01-02 21:06:06.144: DEBUG/FPSCounter(1470): fps: 61
01-02 21:06:07.153: DEBUG/FPSCounter(1470): fps: 61
01-02 21:06:08.173: DEBUG/FPSCounter(1470): fps: 62
01-02 21:06:09.183: DEBUG/FPSCounter(1470): fps: 61

哎哟。当我们播放背景音乐时,主人公的表现明显下降。音频也对机器人造成了损害。不过,Nexus One 并不担心。我们能为英雄和机器人做些什么呢?没什么,真的。罪魁祸首与其说是音效,不如说是背景音乐。流式传输和解码 MP3 或 OGG 文件会占用我们游戏的 CPU 周期;世界就是这样运转的。请记住将这一点纳入您的绩效评估中。

摘要

我们已经用 OpenGL ES 的力量创造了我们的第二个游戏。由于我们良好的框架,它实际上很容易实现。纹理贴图和 SpriteBatcher 的使用带来了非常好的性能。我们还讨论了如何呈现固定宽度的 ASCII 位图字体。我们游戏机制的良好初始设计以及世界单位和像素单位之间关系的清晰定义使得开发游戏变得更加容易。想象一下,如果我们试图以像素为单位做任何事情,那将是一场噩梦。我们所有的计算都会被除法弄得千疮百孔——功能较弱的 Android 设备的 CPU 不太喜欢这一点。我们还非常小心地将我们的逻辑与演示分开。总而言之,《超级跳线》是成功的。

现在是时候把旋钮转到 11 了。让我们尝试一些 3D 图形编程。

十、OpenGL ES:走向 3D

超级跳线工作得相当好,与 2D OpenGL ES 渲染引擎。现在是全 3D 的时候了。当我们定义视图截锥和精灵的顶点时,我们实际上已经在 3D 空间中工作了。在后一种情况下,每个顶点的 z 坐标默认设置为 0。与 2D 渲染的区别其实并不是很大:

  • 顶点不仅有 x 和 y 坐标,还有一个 z 坐标。
  • 使用透视投影代替正投影。离相机越远的物体看起来越小。
  • 旋转、*移和缩放等变换在 3D 中有更多的自由度。我们现在可以在所有三个轴上自由移动顶点,而不仅仅是在 x-y *面上移动顶点。
  • 我们可以在 3D 空间中定义一个任意位置和方向的摄像机。
  • 我们渲染物体的三角形的顺序现在很重要。离摄像机较远的对象必须与离摄像机较*的对象重叠。

最好的事情是,我们已经在我们的框架中为所有这些奠定了基础。为了实现 3D,我们只需要稍微调整几个职业。

在开始之前

和前几章一样,我们将在这一章写几个例子。我们将遵循与之前相同的路线,通过一个显示示例列表的启动活动。我们将重用在前三章中创建的整个框架,包括 GLGame、GLScreen、Texture 和 Vertices 类。

本章的 starter 活动称为 gl3 dbasic starter。我们可以重用第七章中的 GLBasicsStarter 活动的代码,只需将我们将要运行的示例类的包名改为 com.badlogic.androidgames.gl3d。我们还必须再次以<活动>元素的形式将每个测试添加到清单中。我们所有的测试都将在固定的横向方向上运行,我们将根据< activity >元素来指定。

每个测试都是 GLGame 抽象类的一个实例,实际的测试逻辑是以测试的 GLGame 实现中包含的 GLScreen 实例的形式实现的,如第九章所示。为了节省篇幅,我们将只展示 GLScreen 实例的相关部分。每个测试的 GLGame 和 GLScreen 实现的命名约定也是 XXXTest 和 XXXScreen。

三维顶点

在第七章中,你了解到一个顶点有几个属性:

  • 位置
  • 颜色(可选)
  • 纹理坐标(可选)

我们创建了一个名为 Vertices 的助手类,它为我们处理所有的脏细节。我们将顶点位置限制为只有 x 和 y 坐标。要实现 3D,我们需要做的就是修改 Vertices 类,使其支持 3D 顶点位置。

顶点 3:存储 3D 位置

让我们编写一个名为 Vertices3 的新类,基于我们原来的 Vertices 类来处理 3D 顶点。清单 10-1 显示了代码。

清单 10-1。Vertices3.java,现在多了坐标

package com.badlogic.androidgames.framework.gl;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.IntBuffer;
import java.nio.ShortBuffer;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.impl.GLGraphics;

public class Vertices3 {
    final GLGraphics glGraphics;
    final boolean hasColor;
    final boolean hasTexCoords;
    final int vertexSize;
    final IntBuffer vertices;
    final int [] tmpBuffer;
    final ShortBuffer indices;

    public Vertices3(GLGraphics glGraphics,int maxVertices,int maxIndices,
            boolean hasColor,boolean hasTexCoords) {
        this .glGraphics = glGraphics;
        this .hasColor = hasColor;
        this .hasTexCoords = hasTexCoords;
        this .vertexSize = (3 + (hasColor ? 4 : 0) + (hasTexCoords ? 2 : 0)) * 4;
        this .tmpBuffer =new int [maxVertices * vertexSize / 4];

        ByteBuffer buffer = ByteBuffer.*allocateDirect*(maxVertices * vertexSize);
        buffer.order(ByteOrder.*nativeOrder*());
        vertices = buffer.asIntBuffer();

        if (maxIndices > 0) {
            buffer = ByteBuffer.*allocateDirect*(maxIndices * Short.*SIZE*/ 8);
            buffer.order(ByteOrder.*nativeOrder*());
            indices = buffer.asShortBuffer();
        }else {
            indices =null ;
        }
    }

    public void setVertices(float [] vertices,int offset,int length) {
        this .vertices.clear();
        int len = offset + length;
        for (int i = offset, j = 0; i < len; i++, j++)
            tmpBuffer[j] = Float.*floatToRawIntBits*(vertices[i]);
        this .vertices.put(tmpBuffer, 0, length);
        this .vertices.flip();
    }

    public void setIndices(short [] indices,int offset,int length) {
        this .indices.clear();
        this .indices.put(indices, offset, length);
        this .indices.flip();
    }

    public void bind() {
        GL10 gl = glGraphics.getGL();

        gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
        vertices.position(0);
        gl.glVertexPointer(3, GL10.*GL_FLOAT*, vertexSize, vertices);

        if (hasColor) {
            gl.glEnableClientState(GL10.*GL_COLOR_ARRAY*);
            vertices.position(3);
            gl.glColorPointer(4, GL10.*GL_FLOAT*, vertexSize, vertices);
        }

        if (hasTexCoords) {
            gl.glEnableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);
            vertices.position(hasColor ? 7 : 3);
            gl.glTexCoordPointer(2, GL10.*GL_FLOAT*, vertexSize, vertices);
        }
    }

    public void draw(int primitiveType,int offset,int numVertices) {
        GL10 gl = glGraphics.getGL();

        if (indices !=null ) {
            indices.position(offset);
            gl.glDrawElements(primitiveType, numVertices,
                    GL10.*GL_UNSIGNED_SHORT*, indices);
        }else {
            gl.glDrawArrays(primitiveType, offset, numVertices);
        }
    }

    public void unbind() {
        GL10 gl = glGraphics.getGL();
        if (hasTexCoords)
            gl.glDisableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);

        if (hasColor)
            gl.glDisableClientState(GL10.*GL_COLOR_ARRAY*);
    }
}

与顶点相比,一切都保持不变,除了一些小事:

  • 在构造函数中,我们以不同的方式计算 vertexSize,因为顶点位置现在采用三个浮点而不是两个浮点。
  • 在 bind()方法中,我们在对 glVertexPointer()(第一个参数)的调用中告诉 OpenGL ES 我们的顶点有三个而不是两个坐标。
  • 我们必须调整在可选颜色和纹理坐标组件的 vertices.position()调用中设置的偏移量。

这就是我们需要做的。使用 Vertices3 类,我们现在必须在调用 Vertices3.setVertices()方法时指定每个顶点的 x、y 和 z 坐标。在使用方面,其他一切都保持不变。我们可以有逐顶点颜色、纹理坐标、索引等等。

一个例子

让我们写一个简单的例子,叫做 Vertices3Test 。我们想画两个三角形,一个是每个顶点的 z 为 3,另一个是每个顶点的 z 为 5。我们也将使用逐顶点颜色。因为我们还没有讨论如何使用透视投影,所以我们将只使用具有适当的*裁剪*面和远裁剪*面的正交投影,以便三角形位于视图截锥中(即,*为 10,远为 10)。图 10-1 所示场景。

9781430246770_Fig10-01.jpg

图 10-1。3D 空间中的红色三角形(正面)和绿色三角形(背面)

红色三角形在绿色三角形的前面。说“在前面”是可能的,因为在 OpenGL ES 中默认情况下相机位于原点向下看负 z 轴(实际上没有相机的概念)。绿色的三角形也向右移动了一点,这样当我们从前面看的时候可以看到它的一部分。它应该被红色三角形大部分重叠。清单 10-2 显示了渲染这个场景的代码。

清单 10-2。【Vertices3Test.java】;画两个三角形

package com.badlogic.androidgames.gl3d;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.gl.Vertices3;
import com.badlogic.androidgames.framework.impl.GLGame;
import com.badlogic.androidgames.framework.impl.GLScreen;

public class Vertices3Test extends GLGame {

    public Screen getStartScreen() {
        return new Vertices3Screen(this );
    }

    class Vertices3Screen extends GLScreen {
        Vertices3 vertices;

        public Vertices3Screen(Game game) {
            super (game);

            vertices = new Vertices3(glGraphics, 6, 0,true ,false );
            vertices.setVertices(new float [] { −0.5f, -0.5f, -3, 1, 0, 0, 1,
                                                0.5f, -0.5f, -3, 1, 0, 0, 1,
                                                0.0f,  0.5f, -3, 1, 0, 0, 1,
                                                0.0f,  -0.5f, -5, 0, 1, 0, 1,
                                                1.0f,  -0.5f, -5, 0, 1, 0, 1,
                                                0.5f,  0.5f, -5, 0, 1, 0, 1}, 0, 7 * 6);
        }

        @Override
        public void present(float deltaTime) {
            GL10 gl = glGraphics.getGL();
            gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
            gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
            gl.glMatrixMode(GL10.*GL_PROJECTION*);
            gl.glLoadIdentity();
            gl.glOrthof(−1, 1, -1, 1, 10, -10);
            gl.glMatrixMode(GL10.*GL_MODELVIEW*);
            gl.glLoadIdentity();
            vertices.bind();
            vertices.draw(GL10.*GL_TRIANGLES*, 0, 6);
            vertices.unbind();
        }

        @Override
        public void update(float deltaTime) {
        }

        @Override
        public void pause() {
        }

        @Override
        public void resume() {
        }

        @Override
        public void dispose() {
        }
    }
}

如您所见,这是完整的源文件。以下示例将只显示该文件的相关部分,因为除了类名之外,其余部分基本保持不变。

我们在 Vertices3Screen 中有一个 Vertices3 成员,我们在构造函数中初始化了它。我们总共有六个顶点,每个顶点有一种颜色,没有纹理坐标。因为两个三角形都不共享顶点,所以我们不使用索引几何。该信息被传递给 Vertices3 构造函数。接下来,我们通过调用 Vertices3.setVertices()来设置实际的顶点。前三行指定前面的红色三角形,其他三行指定后面的绿色三角形,稍微向右偏移 0.5 个单位。每一行的第三个浮点数是相应顶点的 z 坐标。

在 present()方法中,我们必须首先清除屏幕并设置视口,一如既往。接下来,我们加载一个正交投影矩阵,设置一个足够大的视见*截头体来显示整个场景。最后,我们只渲染包含在顶点 3 实例中的两个三角形。图 10-2 显示了该程序的输出。

9781430246770_Fig10-02.jpg

图 10-2。两个三角形——但有些不对劲

这很奇怪。根据我们的理论,红色三角形(中间)应该在绿色三角形的前面。相机位于原点向下看负 z 轴,从图 10-1 我们可以看到红色三角形比绿色三角形更靠*原点。这里发生了什么事?

OpenGL ES 将按照我们在 Vertices3 实例中指定的顺序来渲染三角形。因为我们首先指定了红色三角形,所以它将首先被绘制。我们可以改变三角形的顺序来解决这个问题。但是,如果我们的相机不是向下看负 z 轴,而是从后面看呢?在渲染之前,我们必须再次根据三角形与摄像机的距离对它们进行排序。那不可能是解决办法。事实并非如此。我们马上就能解决这个问题。让我们先去掉这个正投影,改用透视投影。

关于坐标系的一个注意事项 :你可能注意到,在我们的例子中,我们从向下看 z 轴开始,如果 z 向我们这边增加,x 向右增加,y 向上增加。这就是 OpenGL 使用的标准坐标系。记住这个系统的一个简单方法叫做右手定则。首先将右手的小拇指和无名指指尖压在右手掌心。你的拇指代表 x 轴,伸出的食指代表 y 轴,中指指向你代表 z 轴。参见图 10-3 中的示例。只要记住这条规则,它最终会自然而然地来到你身边。

9781430246770_Fig10-03.jpg

图 10-3。右手法则

透视投影:越*越大

直到现在,我们一直使用正交投影,这意味着无论一个对象距离最*的裁剪*面有多远,它在屏幕上的大小总是相同的。我们的眼睛向我们展示了一幅不同的世界图景。一个物体离我们越远,它在我们看来就越小。这叫做透视投影、,我们在第七章中简单讨论过。

正交投影和透视投影之间的差异可以通过视见*截头体的形状来解释。在正投影中,有一个盒子。在透视投影中,有一个金字塔,其截顶作为*剪裁*面,金字塔的底部作为远剪裁*面,其侧面作为左、右、顶和底剪裁*面。图 10-4 显示了一个透视图,通过它我们可以看到我们的场景。

9781430246770_Fig10-04.jpg

图 10-4。包含场景的透视图视锥(左);从上往下看的*截头体(右)

透视图截锥由四个参数定义:

  1. 从相机到最*裁剪*面的距离
  2. 从相机到远剪裁*面的距离
  3. 视口的纵横比,它嵌入在由视口宽度除以视口高度给出的*剪裁*面中
  4. 视野,指定视见*截头体有多宽,因此,它显示了多少场景

虽然我们已经讨论了“相机”,但这里还没有涉及到这样的概念。假设有一个摄像机固定在原点向下看负 z 轴,如图图 10-4 所示。

第七章中的我们已经熟悉了*裁剪*面距离和远裁剪*面距离。我们只需要将它们设置好,使完整的场景包含在视见体中。当观察图 10-4 中的右图时,视野也很容易理解。

视口的长宽比不太直观。为什么需要它?如果我们渲染的屏幕的长宽比不等于 1,它可以确保我们的世界不会被拉长。

之前,我们使用 glOrthof()以投影矩阵的形式指定正交视图截锥。对于透视图截锥,我们可以使用一个名为 glFrustumf()的方法。然而,有一种更简单的方法。

传统上,OpenGL ES 附带一个名为 GLU 的工具库。它包含一些辅助功能,比如设置投影矩阵和实现相机系统。这个库也可以在 Android 上以一个名为 GLU 的类的形式获得。它有几个静态方法,我们不需要 GLU 实例就可以调用。我们感兴趣的方法称为 gluPerspective():

GLU.gluPerspective(GL10 gl, float fieldOfView, float aspectRatio, float near, float far);

该方法将当前活动矩阵(即投影矩阵或模型-视图矩阵)乘以透视投影矩阵,类似于 glOrthof()。第一个参数是 GL10 的实例,通常是用于所有其他 OpenGL ES 相关业务的实例;第二个参数是视野,以角度给出;第三个参数是视口的纵横比;最后两个参数指定了*剪裁*面和远剪裁*面与相机位置的距离。由于我们还没有相机,那些值是相对于世界的原点给出的,迫使我们向下看负 z 轴,如图图 10-4 所示。这完全符合我们目前的目的;我们将确保我们渲染的所有对象都停留在这个固定不动的视图截锥内。只要我们只使用 gluPerspective(),我们就不能改变我们的虚拟相机的位置或方向。当向下看负 z 轴时,我们总是只能看到世界的一部分。

让我们修改清单 10-2 中的,使其使用透视投影。首先,将 Vertices3Test 中的所有代码复制到一个名为 PerspectiveTest 的新类中,并将 Vertices3Screen 重命名为 PerspectiveScreen。我们唯一需要改变的是 present()方法。清单 10-3 显示了代码。

清单 10-3。 摘自 PerspectiveTest.java;透视投影

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
    gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
    gl.glMatrixMode(GL10.*GL_PROJECTION*);
    gl.glLoadIdentity();
    GLU.*gluPerspective*(gl, 67,
                       glGraphics.getWidth() / (float )glGraphics.getHeight(),
                       0.1f, 10f);
    gl.glMatrixMode(GL10.*GL_MODELVIEW*);
    gl.glLoadIdentity();
    vertices.bind();
    vertices.draw(GL10.*GL_TRIANGLES*, 0, 6);
    vertices.unbind();
}

与上一个示例中的 present()方法的唯一区别是,我们现在使用 GLU.gluPerspective()而不是 glOrtho()。我们使用 67 度的视野,接*人类的*均视野。通过增加或减少该值,您可以或多或少地看到左侧和右侧。接下来我们要指定的是长宽比,也就是屏幕的宽度除以高度。请注意,这将是一个浮点数,所以我们必须在除法之前将其中一个值转换为浮点数。最后的参数是*剪裁*面和远剪裁*面的距离。假设虚拟相机位于原点,向下看负 z 轴,任何 z 值小于 0.1 且大于 10 的物体都将位于*剪裁*面和远剪裁*面之间,因此可能是可见的。图 10-5 显示了这个例子的输出。

9781430246770_Fig10-05.jpg

图 10-5。视角(基本正确)

现在我们实际上正在做适当的 3D 图形。正如你所看到的,我们的三角形的渲染顺序仍然有问题。这可以通过使用强大的 z 缓冲区来解决。

Z-buffer:将秩序带入混乱

什么是 z 缓冲器?在《??》第三章中,我们讨论了帧缓冲区。它存储屏幕上每个像素的颜色。当 OpenGL ES 将一个三角形渲染到帧缓冲区时,它只是改变组成该三角形的像素的颜色。

z 缓冲区与帧缓冲区非常相似,因为它也为屏幕上的每个像素提供了一个存储位置。它存储深度值,而不是存储颜色。一个像素的深度值 大致是 3D 中对应点到视见体最*裁剪*面的归一化距离。

默认情况下,OpenGL ES 会将三角形的每个像素的深度值写入 z 缓冲区(如果 z 缓冲区是与帧缓冲区一起创建的)。我们只需要告诉 OpenGL ES 使用这个信息来决定一个正在绘制的像素是否比当前的像素更靠**裁剪*面。为此,我们只需要用适当的参数调用 glEnable():

GL10.glEnable(GL10.GL_DEPTH_TEST);

然后,OpenGL ES 将传入的像素深度与 z 缓冲区中已经存在的像素深度进行比较。如果传入的像素深度较小,其对应的像素更靠**剪裁*面,因此位于已经在帧和 z 缓冲区中的像素之前。

图 10-5 说明了该过程。z 缓冲区开始时所有值都设置为无穷大(或一个非常大的数字)。渲染第一个三角形时,将其每个像素的深度值与 z 缓冲区中的像素值进行比较。如果像素的深度值小于 z 缓冲器中的值,则该像素通过所谓的深度测试,或 z 测试。像素的颜色将被写入帧缓冲区,其深度将覆盖 z 缓冲区中的相应值。如果测试失败,像素的颜色和深度值都不会被写入缓冲区。这显示在图 10-6 中,其中呈现了第二个三角形。一些像素具有较小的深度值,因此被渲染;其他像素没有通过测试。

9781430246770_Fig10-06.jpg

图 10-6。帧缓冲区中的图像(左);渲染两个三角形后的 z 缓冲区内容(右)

和 framebuffer 一样,我们也必须清除每个帧的 z-buffer 否则,上一帧的深度值仍会在那里。为此,我们可以调用 glClear(),如下所示:

gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);

这将一次性清除帧缓冲区(或颜色缓冲区)和 z 缓冲区(或深度缓冲区)。

修复前面的示例

让我们通过使用 z-buffer 来解决前面例子中的问题。只需将所有代码复制到一个名为 ZBufferTest 的新类中,并修改新 ZBufferScreen 类的 present()方法,如清单 10-4 所示。

清单 10-4。 摘自 ZBufferTest.java;使用 Z 缓冲器

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*| GL10.*GL_DEPTH_BUFFER_BIT*);
    gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
    gl.glMatrixMode(GL10.*GL_PROJECTION*);
    gl.glLoadIdentity();
    GLU.*gluPerspective*(gl, 67,
            glGraphics.getWidth() / (float )glGraphics.getHeight(),
            0.1f, 10f);
    gl.glMatrixMode(GL10.*GL_MODELVIEW*);
    gl.glLoadIdentity();

    gl.glEnable(GL10.*GL_DEPTH_TEST*);

    vertices.bind();
    vertices.draw(GL10.*GL_TRIANGLES*, 0, 6);
    vertices.unbind();

    gl.glDisable(GL10.*GL_DEPTH_TEST*);
}

我们首先更改了调用 glClear()的参数。现在两个缓冲区都被清除,而不仅仅是帧缓冲区。

在渲染两个三角形之前,我们还启用了深度测试。在我们渲染完所有的 3D 几何图形后,我们再次禁用深度测试。为什么呢?假设我们想要在 3D 场景上呈现 2D UI 元素,比如当前的乐谱或按钮。由于我们使用了 SpriteBatcher,它只在 2D 有效,所以对于 2D 元素的顶点,我们没有任何有意义的 z 坐标。我们也不需要深度测试,因为我们会明确指定顶点在屏幕上的绘制顺序。

这个例子的输出如图 10-7 所示,看起来和预期的一样。

9781430246770_Fig10-07.jpg

图 10-7。z-buffer 在起作用,使渲染顺序独立

最后,中间的绿色三角形在红色三角形后面被正确渲染,这要感谢我们新的好朋友,z-buffer。然而,和大多数朋友一样,有时你们的友谊会因为一些小问题而受损。让我们看看使用 z 缓冲器时的一些注意事项。

融合:你身后什么都没有

假设我们想要在场景中 z = 3 处启用红色三角形的混合。假设我们将每个顶点颜色的 alpha 分量设置为 0.5f,这样三角形后面的任何东西都可以透过。在这种情况下,z = 5 处的绿色三角形应该会发光。我们来想想 OpenGL ES 会做什么,还会发生什么:

  • OpenGL ES 会将第一个三角形渲染到 z 缓冲区和 colorbuffer。
  • 接下来 OpenGL ES 将渲染绿色三角形,因为它在我们的顶点 3 实例中位于红色三角形之后。
  • 红色三角形后面的绿色三角形部分不会显示在屏幕上,因为像素会被深度测试拒绝。
  • 没有东西会透过前面的红色三角形发光,因为渲染时那里没有东西可以透过。

将混合与 z 缓冲区结合使用时,您必须确保所有透明对象都按照离相机位置的距离递增的方式进行排序,并从后向前进行渲染。所有不透明对象必须在任何透明对象之前渲染。然而,不透明的物体不需要被分类。

让我们写一个简单的例子来演示这一点。我们保持当前场景由两个三角形组成,并将第一个三角形(z = 3)的顶点颜色的 alpha 分量设置为 0.5f。根据我们的规则,我们必须首先渲染不透明对象,在本例中为绿色三角形(z = 5),然后渲染所有透明对象,从最远到最*。在我们的场景中,只有一个透明物体:红色三角形。

我们将清单 10-4 中的所有代码复制到一个名为 ZBlendingTest 的新类中,并将包含的 ZBufferScreen 重命名为 ZBlendingScreen。我们现在只需要更改第一个三角形的顶点颜色,并在 present()方法中以适当的顺序混合和渲染这两个三角形。清单 10-5 显示了两种相关的方法。

清单 10-5。 摘自 ZBlendingTest.java;启用 Z 缓冲器的混合

public ZBlendingScreen(Game game) {
    super (game);

    vertices = new Vertices3(glGraphics, 6, 0,true ,false );
    vertices.setVertices(new float [] { −0.5f, -0.5f, -3, 1, 0, 0, 0.5f,
                                        0.5f, -0.5f, -3, 1, 0, 0, 0.5f,
                                        0.0f,  0.5f, -3, 1, 0, 0, 0.5f,
                                        0.0f,  -0.5f, -5, 0, 1, 0, 1,
                                        1.0f,  -0.5f, -5, 0, 1, 0, 1,
                                        0.5f,  0.5f, -5, 0, 1, 0, 1}, 0, 7 * 6);
}

@Override
public void present(float deltaTime) {
    GL10 gl = glGraphics.getGL();
    gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*| GL10.*GL_DEPTH_BUFFER_BIT*);
    gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
    gl.glMatrixMode(GL10.*GL_PROJECTION*);
    gl.glLoadIdentity();
    GLU.*gluPerspective*(gl, 67,
            glGraphics.getWidth() / (float )glGraphics.getHeight(),
            0.1f, 10f);
    gl.glMatrixMode(GL10.*GL_MODELVIEW*);
    gl.glLoadIdentity();

    gl.glEnable(GL10.*GL_DEPTH_TEST*);
    gl.glEnable(GL10.*GL_BLEND*);
    gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);

    vertices.bind();
    vertices.draw(GL10.*GL_TRIANGLES*, 3, 3);
    vertices.draw(GL10.*GL_TRIANGLES*, 0, 3);
    vertices.unbind();

    gl.glDisable(GL10.*GL_BLEND*);
    gl.glDisable(GL10.*GL_DEPTH_TEST*);
}

在 ZBlendingScreen 类的构造函数中,我们只将第一个三角形的顶点颜色的 alpha 分量更改为 0.5。这将使第一个三角形透明。在 present()方法中,我们做通常的事情,比如清除缓冲区和设置矩阵。我们还启用混合并设置适当的混合功能。有趣的是我们现在如何渲染这两个三角形。我们首先渲染绿色三角形,它是 Vertices3 实例中的第二个三角形,因为它是不透明的。在渲染任何透明对象之前,必须渲染所有不透明对象。接下来,我们渲染透明三角形,这是 Vertices3 实例中的第一个三角形。对于这两个绘图调用,我们简单地使用适当的偏移量和顶点数作为 vertices.draw()方法的第二个和第三个参数。图 10-8 显示了该程序的输出。

9781430246770_Fig10-08.jpg

图 10-8。启用 z 缓冲器混合

让我们颠倒绘制两个三角形的顺序,如下所示:

vertices.draw(GL10.GL_TRIANGLES, 0, 3);
vertices.draw(GL10.GL_TRIANGLES, 3, 3);

所以,我们先从顶点 0 开始画三角形,然后从顶点 3 开始画第二个三角形。这将首先渲染前面的红色三角形,然后渲染后面的绿色三角形。图 10-9 显示了结果。

9781430246770_Fig10-09.jpg

图 10-9。勾兑做错了;后面的三角形应该会发光

到目前为止,对象只由三角形组成,这当然有点简单。当我们渲染更复杂的形状时,我们将再次结合 z-buffer 讨论混合。现在,让我们总结一下如何在 3D 中处理混合:

  1. 渲染所有不透明对象。
  2. 按照离相机的距离递增(从最远到最*)对所有透明对象进行排序。
  3. 按照从远到*的排序顺序渲染所有透明对象。

在大多数情况下,排序可以基于对象中心到相机的距离。如果你的一个对象很大并且可以跨越多个对象,你就会遇到问题。如果没有高级技巧,就不可能解决这个问题。有几个防弹解决方案非常适合桌面版本的 OpenGL,但由于其有限的 GPU 功能,它们无法在大多数 Android 设备上实现。幸运的是,这是非常罕见的,您几乎可以始终坚持简单的基于中心的排序。

Z 缓冲精度和 Z 战斗

滥用远*剪裁*面来尽可能多地展示我们令人敬畏的场景总是很有诱惑力的。毕竟,我们已经付出了很多努力来给我们的世界添加大量的物体,而且这种努力应该是可见的。唯一的问题是 z 缓冲器的精度有限。在大多数 Android 设备上,z-buffer 中存储的每个深度值不超过 16 位;最多有 65,535 个不同的深度值。因此,我们应该坚持使用更合理的值,而不是将*剪裁*面距离设置为 0.00001,将远剪裁*面距离设置为 1000000。否则,我们很快就会发现一个配置不当的视图截锥结合 z 缓冲区会产生什么好的工件。

有什么问题?想象一下,我们像刚才提到的那样设置*剪裁*面和远剪裁*面。像素的深度值或多或少是其与*剪裁*面的距离,距离越*,深度值越小。对于 16 位深度缓冲区,我们将从*到远裁剪*面深度值内部量化为 65,535 个片段;在我们的世界中,每一段占用 1,000,000 / 65,535 = 15 个单位。如果我们选择我们的单位为米,并且我们有通常大小的对象,如 1×2×1 米,都在同一段中,z 缓冲区不会对我们有很大帮助,因为所有像素都将获得相同的深度值。

注意z 缓冲区中的深度值实际上不是线性的,但总体思路仍然是正确的。

使用 z 缓冲器时的另一个相关问题是所谓的 z 战斗 。图 10-10 说明了这个问题。

9781430246770_Fig10-10.jpg

图 10-10。Z-战斗在行动

图 10-10 中的两个矩形共面;也就是说,它们嵌入在同一*面中。因为它们重叠,所以它们也共享一些像素,这些像素应该具有相同的深度值。然而,由于有限的浮点精度,GPU 可能不会为重叠的像素获得相同的深度值。哪一个像素通过深度测试是一种抽签。这通常可以通过将两个共面对象中的一个从另一个对象推开一点点来解决。该偏移量的值取决于几个因素,因此通常最好进行实验。总结一下:

  • 对于*剪裁*面距离和远剪裁*面距离,不要使用太小或太大的值。
  • 通过稍微偏移来避免共面对象。

定义 3D 网格

到目前为止,我们只使用了几个三角形作为我们世界中物体的占位符。更复杂的对象呢?

我们已经讨论过 GPU 是一个巨大的三角形渲染机器。因此,我们所有的 3D 物体也必须由三角形组成。在前面的章节中,我们用两个三角形来代表一个*面矩形。我们在那里使用的原则,像顶点定位、颜色、纹理和顶点索引,在 3D 中完全相同。我们的三角形不再局限于 x-y *面。我们可以自由指定每个顶点在三维空间中的位置。

你如何着手创建这样一个由三角形组成的 3D 物体呢?我们可以通过编程来实现,就像我们对精灵的矩形所做的那样。我们也可以使用软件,让我们以所见即所得的方式雕刻 3D 物体。在这些应用中使用了各种范例,从操纵单独的三角形到只指定几个参数来输出一个三角形网格(三角形列表的一个有趣名称)。

著名的软件包如 Blender、3ds Max、ZBrush 和 Wings 3D 为用户提供了大量创建 3D 对象的功能。其中有些是免费的(比如 Blender 和 Wings 3D),有些是商用的(比如 3ds Max 和 ZBrush)。教你如何使用这些程序不在本书的范围之内。然而,所有这些程序都可以将 3D 模型保存为不同的文件格式。网络上也充满了免费使用的 3D 模型。在下一章,我们将为一种最简单也是最常用的文件格式编写一个加载器。

在这一章中,我们将以编程的方式做所有的事情。让我们创建一个最简单的 3D 对象:一个立方体。

一个立方体:你好,3D 世界

在前三章中,我们大量使用了模型空间的概念。这是定义模型的空间;它与世界空间完全无关。我们使用围绕模型空间的原点构建所有对象的惯例,以便对象的中心与该原点重合。这样的模型可以被重用来渲染世界空间中不同位置和不同方向的多个对象,就像第七章中的大规模 BobTest 示例一样。

对于立方体,我们首先要弄清楚的是它的角点。图 10-11 显示一个边长为 1 个单位(例如 1 米)的立方体。我们还将立方体分解了一点,这样我们就可以看到由两个三角形组成的各个侧面。当然,在现实中,所有的边都会在边缘和拐角处相遇。

9781430246770_Fig10-11.jpg

图 10-11。一个立方体及其角点

立方体有六条边,每条边由两个三角形组成。每条边的两个三角形共享两个顶点。对于立方体的正面,( 0.5,0.5,0.5)和(0.5,-0.5,0.5)处的顶点是共享的。我们每边只需要四个顶点;对于一个完整的立方体,总共有 6 × 4 = 24 个顶点。然而,我们确实需要指定 36 个索引,而不仅仅是 24 个。这是因为有 6 × 2 个三角形,每个三角形使用了我们 24 个顶点中的 3 个。我们可以使用顶点索引为此立方体创建网格,如下所示:

float[] vertices = { −0.5f, -0.5f,  0.5f,
                      0.5f, -0.5f,  0.5f,
                      0.5f,  0.5f,  0.5f,
                     -0.5f,  0.5f,  0.5f,

                      0.5f, -0.5f,  0.5f,
                      0.5f, -0.5f, -0.5f,
                      0.5f,  0.5f, -0.5f,
                      0.5f,  0.5f,  0.5f,

                      0.5f, -0.5f, -0.5f,
                     -0.5f, -0.5f, -0.5f,
                     -0.5f,  0.5f, -0.5f,
                      0.5f,  0.5f, -0.5f,

                     -0.5f, -0.5f, -0.5f,
                     -0.5f, -0.5f,  0.5f,
                     -0.5f,  0.5f,  0.5f,
                     -0.5f,  0.5f, -0.5f,

                     -0.5f,  0.5f,  0.5f,
                      0.5f,  0.5f,  0.5f,
                      0.5f,  0.5f, -0.5f,
                     -0.5f,  0.5f, -0.5f,

                     -0.5f, -0.5f,  0.5f,
                      0.5f, -0.5f,  0.5f,
                      0.5f, -0.5f, -0.5f,
                     -0.5f, -0.5f, -0.5f
};

short[] indices = { 0, 1, 3, 1, 2, 3,
                    4, 5, 7, 5, 6, 7,
                    8, 9, 11, 9, 10, 11,
                    12, 13, 15, 13, 14, 15,
                    16, 17, 19, 17, 18, 19,
                    20, 21, 23, 21, 22, 23,
};

Vertices3 cube = new Vertices3(glGraphics, 24, 36, false, false);
cube.setVertices(vertices, 0, vertices.length);
cube.setIndices(indices, 0, indices.length);

在这段代码中,我们只指定顶点的位置。我们从正面开始,其左下顶点位于(0.5,-0.5,0.5)。然后,我们逆时针方向指定该边的下三个顶点。下一条边是立方体的右侧,然后是背面、左侧、顶面和底面,所有这些都遵循相同的模式。将顶点定义与图 10-11 中的进行比较。

接下来,我们定义指数。总共有 36 个索引—前面代码中的每一行都定义了两个三角形,每个三角形由三个顶点组成。指数(0,1,3,1,2,3)定义立方体的正面,接下来的三个指数定义左侧,依此类推。将这些指数与前面代码中给出的顶点进行比较,并再次与图 10-11 中的进行比较。

一旦我们定义了所有的顶点和索引,我们就将它们存储在 Vertices3 实例中进行渲染,这是在前面代码片段的最后几行中完成的。

纹理坐标呢?很简单,我们只需将它们添加到顶点定义中。假设有一个 128×128 的纹理包含一个板条箱的一面的图像。我们希望立方体的每一面都有这个图像的纹理。图 10-12 显示了我们如何做到这一点。

9781430246770_Fig10-12.jpg

图 10-12。正面、左边和顶面的每个顶点的纹理坐标(其他边也是一样的)

将纹理坐标添加到立方体的正面,代码如下所示:

float[] vertices = { −0.5f, -0.5f,  0.5f, 0, 1,
                      0.5f, -0.5f,  0.5f, 1, 1,
                      0.5f,  0.5f,  0.5f, 1, 0,
                     -0.5f,  0.5f,  0.5f, 0, 0,
                     // rest is analogous

当然,我们还需要告诉 Vertices3 实例,它也包含纹理坐标:

Vertices3 cube = new Vertices3(glGraphics, 24, 36, false, true);

剩下的就是加载纹理本身,用 glEnable()启用纹理映射,用 Texture.bind()绑定纹理。我们来写个例子。

一个例子

我们希望创建一个立方体网格,如前面的片段所示,应用了板条箱纹理。因为我们在模型空间中围绕原点对立方体建模,所以我们必须使用 glTranslatef()将它移动到世界空间中,就像我们在 BobTest 示例中对 Bob 的模型所做的那样。我们还希望我们的立方体围绕 y 轴旋转,这可以通过使用 glRotatef()来实现,就像在 BobTest 示例中一样。清单 10-6 显示了 CubeTest 类中包含的 CubeScreen 类的完整代码。

清单 10-6。 摘自 CubeTest.java;渲染一个纹理立方体

class CubeScreen extends GLScreen {
    Vertices3 cube;
    Texture texture;
    float angle = 0;

    public CubeScreen(Game game) {
        super(game);
        cube = createCube();
        texture = new Texture(glGame, "crate.png");
    }

    private Vertices3 createCube() {
        float [] vertices = { −0.5f, -0.5f,  0.5f, 0, 1,
                              0.5f, -0.5f,  0.5f, 1, 1,
                              0.5f,  0.5f,  0.5f, 1, 0,
                             -0.5f,  0.5f,  0.5f, 0, 0,

                              0.5f, -0.5f,  0.5f, 0, 1,
                              0.5f, -0.5f, -0.5f, 1, 1,
                              0.5f,  0.5f, -0.5f, 1, 0,
                              0.5f,  0.5f,  0.5f, 0, 0,

                              0.5f, -0.5f, -0.5f, 0, 1,
                             -0.5f, -0.5f, -0.5f, 1, 1,
                             -0.5f,  0.5f, -0.5f, 1, 0,
                              0.5f,  0.5f, -0.5f, 0, 0,

                             -0.5f, -0.5f, -0.5f, 0, 1,
                             -0.5f, -0.5f,  0.5f, 1, 1,
                             -0.5f,  0.5f,  0.5f, 1, 0,
                             -0.5f,  0.5f, -0.5f, 0, 0,

                             -0.5f,  0.5f,  0.5f, 0, 1,
                              0.5f,  0.5f,  0.5f, 1, 1,
                              0.5f,  0.5f, -0.5f, 1, 0,
                             -0.5f,  0.5f, -0.5f, 0, 0,

                             -0.5f, -0.5f,  0.5f, 0, 1,
                              0.5f, -0.5f,  0.5f, 1, 1,
                              0.5f, -0.5f, -0.5f, 1, 0,
                             -0.5f, -0.5f, -0.5f, 0, 0
        };

        short [] indices = { 0, 1, 3, 1, 2, 3,
                            4, 5, 7, 5, 6, 7,
                            8, 9, 11, 9, 10, 11,
                            12, 13, 15, 13, 14, 15,
                            16, 17, 19, 17, 18, 19,
                            20, 21, 23, 21, 22, 23,
        };

        Vertices3 cube = new Vertices3(glGraphics, 24, 36,false ,true );
        cube.setVertices(vertices, 0, vertices.length);
        cube.setIndices(indices, 0, indices.length);
        return cube;
    }

    @Override
    public void resume() {
        texture.reload();
    }

    @Override
    public void update(float deltaTime) {
        angle += 45 * deltaTime;
    }

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
        gl.glClear(GL10.GL_COLOR_BUFFER_BIT | GL10.GL_DEPTH_BUFFER_BIT);
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        GLU.*gluPerspective*(gl, 67,
                           glGraphics.getWidth() / (float) glGraphics.getHeight(),
                           0.1f, 10.0f);
        gl.glMatrixMode(GL10.*GL_MODELVIEW*);
        gl.glLoadIdentity();

        gl.glEnable(GL10.*GL_DEPTH_TEST*);
        gl.glEnable(GL10.*GL_TEXTURE_2D*);
        texture.bind();
        cube.bind();
        gl.glTranslatef(0,0,-3);
        gl.glRotatef(angle, 0, 1, 0);
        cube.draw(GL10.*GL_TRIANGLES*, 0, 36);
        cube.unbind();
        gl.glDisable(GL10.*GL_TEXTURE_2D*);
        gl.glDisable(GL10.*GL_DEPTH_TEST*);
    }

    @Override
    public void pause() {
    }

    @Override
    public void dispose() {
    }
}

我们有一个存储立方体网格的字段、一个纹理实例和一个存储当前旋转角度的 float。在构造器中,我们创建立方体网格,并从名为 crate.png 的资源文件中加载纹理,这是一个板条箱一侧的 128×128 像素图像。

多维数据集创建代码位于 createCube()方法中。它只是设置顶点和索引,并从它们创建一个顶点 3 实例。每个顶点都有一个 3D 位置和纹理坐标。

resume()方法只是告诉纹理重新加载它。请记住,在 OpenGL ES 上下文丢失后,必须重新加载纹理。

update()方法只是增加了立方体绕 y 轴旋转的角度。

present()方法首先设置 viewport,然后清除 framebuffer 和 depthbuffer。接下来,我们建立一个透视投影,并将一个单位矩阵加载到 OpenGL ES 的模型-视图矩阵中。我们启用深度测试和纹理,绑定纹理和立方体网格。然后,我们使用 glTranslatef()将立方体移动到世界空间中的位置(0,0,–3)。使用 glRotatef(),我们在模型空间中围绕 y 轴旋转立方体。请记住,这些变换应用到网格的顺序是相反的。立方体将首先被旋转(在模型空间中),然后旋转后的版本将被定位在世界空间中。最后,我们绘制立方体,解除网格绑定,并禁用深度测试和纹理。我们不需要禁用这些状态;如果我们要在 3D 场景上渲染 2D 元素,我们只需禁用这些状态。图 10-13 显示了第一个真实 3D 程序的输出。

9781430246770_Fig10-13.jpg

图 10-13。3D 旋转纹理立方体

矩阵和变换

在第七章中,你学习了一些关于矩阵的知识。让我们总结一下他们的一些属性作为快速复习:

  • 矩阵将点(在我们的例子中是顶点)转换到一个新的位置。这是通过将矩阵乘以点的位置来实现的。
  • 矩阵可以将每个轴上的点*移一定的量。
  • 矩阵可以缩放点,这意味着它将点的每个坐标乘以某个常数。
  • 矩阵可以绕轴旋转一个点。
  • 用一个点乘以一个单位矩阵对那个点没有影响。
  • 将一个矩阵与另一个矩阵相乘得到一个新矩阵。将一个点与这个新矩阵相乘会将原始矩阵中编码的两种变换都应用于该点。
  • 将矩阵乘以单位矩阵对矩阵没有影响。

OpenGL ES 为我们提供了三种类型的矩阵:

  • 投影矩阵 : 这是用来设置视见*截头体的形状和大小,它决定了投影的类型和世界的大小。
  • 模型-视图矩阵 : 这用于转换模型空间中的模型,并将模型放置在世界空间中。
  • 纹理矩阵 : 这是用来动态操作纹理坐标的,就像我们用模型-视图矩阵操作顶点位置一样。此功能在某些设备上被破坏。我们不会在本书中使用它。

现在我们在 3D 环境下工作,我们有了更多的选择。例如,我们不仅可以像对 Bob 那样围绕 z 轴旋转模型,还可以围绕任意轴旋转。然而,唯一真正改变的是我们现在可以用来放置对象的额外的 z 轴。当我们在第七章第一节渲染鲍勃的时候,我们实际上已经在 3D 中工作了;我们只是忽略了 z 轴。但是我们还能做更多。

矩阵堆栈

到目前为止,我们已经在 OpenGL ES: 中使用了这样的矩阵

gl.glMatrixMode(GL10.GL_PROJECTION);
gl.glLoadIdentity();
gl.glOrthof(−1, 1, -1, 1, -10, 10);

第一条语句设置当前活动的矩阵。所有后续矩阵运算都将在该矩阵上执行。在这种情况下,我们将活动矩阵设置为单位矩阵,然后将其乘以正交投影矩阵。我们对模型-视图矩阵做了类似的事情:

gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
gl.glTranslatef(0, 0, -10);
gl.glRotate(45, 0, 1, 0);

这个代码片段操作模型-视图矩阵。在调用之前,它首先加载一个单位矩阵来清除模型-视图矩阵中的内容。接下来,它将矩阵与*移矩阵和旋转矩阵相乘。乘法的顺序很重要,因为它定义了这些变换应用于网格顶点的顺序。指定的最后一个变换将首先应用于顶点。在前面的例子中,我们首先将每个顶点绕 y 轴旋转 45 度。然后,我们将每个顶点沿 z 轴移动 10 个单位。

在这两种情况下,所有的转换都编码在一个矩阵中,或者在 OpenGL ES 投影中,或者在模型-视图矩阵中。但事实证明,对于每种矩阵类型,实际上都有一堆矩阵供我们使用。

目前,我们只使用这个堆栈中的一个插槽:堆栈顶部(TOS)。矩阵堆栈的 TOS 是 OpenGL ES 实际用来转换顶点的槽,无论是投影矩阵还是模型视图矩阵。堆栈中任何低于 TOS 的矩阵都只是闲置在那里,等待成为新的 TOS。那么我们如何操纵这个堆栈呢?

OpenGL ES 有两种方法可以用来推送和弹出当前的 TOS:

GL10.glPushMatrix();
GL10.glPopMatrix();

像 glTranslatef()和 consorts 一样,这些方法总是在我们通过 glMatrixMode()设置的当前活动矩阵堆栈上工作。

glPushMatrix()方法获取当前 TOS,制作一份副本,并将其推送到堆栈上。glPopMatrix()方法获取当前的 TOS 并将其从堆栈中弹出,以便其下的元素成为新的 TOS。

让我们看一个小例子:

gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
gl.glTranslate(0,0,-10);

到目前为止,在模型-视图矩阵栈中只有一个矩阵。让我们“保存”这个矩阵:

gl.glPushMatrix();

现在,我们已经复制了当前的 TOS,并删除了旧的 TOS。现在堆栈上有两个矩阵,每个矩阵对 z 轴上的*移进行 10 个单位的编码。

gl.glRotatef(45, 0, 1, 0);
gl.glScalef(1, 2, 1);

由于矩阵运算总是在 TOS 上工作,我们现在在顶部矩阵中编码了一个缩放运算、一个旋转和一个*移。我们推出的矩阵仍然只包含一个翻译。当我们现在渲染模型空间中给定的网格时,如我们的立方体,它将首先在 y 轴上缩放,然后围绕 y 轴旋转,然后在 z 轴上*移 10 个单位。现在让我们打开 TOS:

gl.glPopMatrix();

这将删除 TOS,并使其下方的矩阵成为新的 TOS。在我们的例子中,这是原始的转换矩阵。在这个调用之后,堆栈上又只有一个矩阵了——在示例开始时初始化的那个矩阵。如果我们现在渲染一个对象,它只会在 z 轴上*移 10 个单位。包含缩放、旋转和*移的矩阵不见了,因为我们从堆栈中弹出了它。图 10-14 显示了当我们执行前面的代码时,矩阵堆栈会发生什么。

9781430246770_Fig10-14.jpg

图 10-14。操纵矩阵堆栈

那么这有什么好处呢?我们可以使用它的第一件事是记住应该应用于我们世界中所有对象的变换。假设我们希望我们世界中的所有对象在每个轴上偏移 10 个单位;我们可以做到以下几点:

gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
gl.glTranslatef(10, 10, 10);
for( MyObject obj: myObjects) {
   gl.glPushMatrix();
   gl.glTranslatef(obj.x, obj.y, obj.z);
   gl.glRotatef(obj.angle, 0, 1, 0);
   // render model of object given in model space, e.g., the cube
   gl.glPopMatrix();
}

我们将在本章后面讨论如何创建 3D 摄像机系统时使用这种模式。摄像机的位置和方向通常被编码为矩阵。我们将加载这个相机矩阵,它将以这样一种方式转换所有对象,即我们从相机的角度来看它们。不过,我们可以用矩阵堆栈做更好的事情。

具有矩阵堆栈的分层系统

什么是等级制度?我们的太阳系就是一个例子。中间是太阳。围绕太阳的是以一定距离围绕它运行的行星。在一些行星周围有一个或多个卫星围绕着行星运行。太阳、行星和卫星都围绕自己的中心旋转(某种程度上)。我们可以用矩阵堆栈来构建这样一个系统。

太阳在我们的世界中有一个位置,它绕着自己旋转。所有的行星都随着太阳移动,所以如果太阳改变位置,行星也必须改变位置。我们可以使用 glTranslatef()定位太阳,使用 glRotatef()让太阳绕着自己旋转。

每颗行星都有相对于太阳的位置,既绕自身旋转,也绕太阳旋转。行星绕自身旋转可以通过 glRotatef()实现,绕太阳旋转可以通过使用 glTranslatef()和 glRotatef()实现。让行星随着太阳移动可以通过使用额外的 glTranslatef()来实现。

每个卫星都有一个相对于它所围绕的行星的位置,它既绕着自己旋转,也绕着它的行星旋转。通过 glRotatef()可以使卫星围绕自身旋转,通过使用 glTranslatef()和 glRotatef()可以使卫星围绕其行星旋转。让月球随其行星移动可以通过使用 glTranslatef()来实现。由于行星随太阳运动,所以月亮也必须随太阳运动,这也可以通过调用 glTranslatef()来实现。

我们这里有所谓的亲子关系。太阳是每颗行星的母体,而每颗行星又是其卫星的母体。每颗行星都是太阳的孩子,每颗卫星都是其行星的孩子。这意味着一个孩子的位置总是相对于他的父母,而不是相对于世界的起源。

太阳没有父母,所以它的位置确实是相对于世界的起源给出的。行星是太阳的孩子,所以它的位置是相对于太阳给出的。月亮是行星的孩子,所以它的位置是相对于它的行星给出的。我们可以把每个父母的中心想象成坐标系的原点,在这个坐标系中我们指定了那个父母的孩子。

系统中每个对象的自旋转独立于其父对象。如果我们想要缩放一个对象,情况也是如此。这些东西是相对于它们的中心给出的。这本质上与模型空间相同。

一个简单的箱式太阳系

让我们创造一个小例子,一个非常简单的箱式太阳系。在世界坐标系中,位于(0,0,5)处的系统中心有一个机箱。在这个“太阳”机箱周围,我们希望有一个“行星”机箱以 3 个单位的距离绕太阳运行。行星箱也应该比太阳箱小,所以我们将它缩小到 0.2 个单位。在行星箱周围,我们希望有一个“月球”箱。行星箱和月球箱之间的距离应该是 1 个单位,月球箱也会缩小,比如缩小到 0.1 个单位。行星箱和月球箱在 x-z *面中围绕各自的父对象旋转,所有对象都围绕自己的 y 轴旋转。图 10-15 显示了我们场景的基本设置。

9781430246770_Fig10-15.jpg

图 10-15。板条箱系统

HierarchicalObject 类

让我们定义一个简单的类,它可以用以下属性对一个通用的太阳系对象进行编码:

  • 相对于其父中心的位置
  • 围绕父对象的旋转角度
  • 绕其自身 y 轴的旋转角度
  • 天*
  • 儿童名单
  • 对要呈现的顶点 3 实例的引用

我们的 HierarchicalObject 实例应该更新其旋转角度及其子对象,并呈现其自身及其所有子对象。这是一个递归过程,因为每个子对象都将呈现自己的子对象。我们将使用 glPushMatrix()和 glPopMatrix()来保存父级的转换,这样子级将随父级一起移动。清单 10-7 显示了代码。

清单 10-7。HierarchicalObject.java,代表板条箱系统中的一个对象

package com.badlogic.androidgames.gl3d;

import java.util.ArrayList;
import java.util.List;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.gl.Vertices3;

public class HierarchicalObject {
    public float x, y, z;
    public float scale = 1;
    public float rotationY, rotationParent;
    public boolean hasParent;
    public final List<HierarchicalObject> children = new ArrayList<HierarchicalObject>();
    public final Vertices3 mesh;

前三个成员编码对象相对于其父对象的位置(如果对象没有父对象,则编码相对于世界原点的位置)。下一个成员存储对象的比例。rotationY 成员存储对象围绕自身的旋转,rotationParent 成员存储围绕父对象中心的旋转角度。hasParent 成员指示该对象是否有父对象。如果没有,那么我们就不必应用围绕父对象的旋转。我们系统中的“太阳”就是如此。最后,我们有一个孩子列表,后面是对 Vertices3 实例的引用,它保存了我们用来呈现每个对象的立方体的网格。

    public HierarchicalObject(Vertices3 mesh,boolean hasParent) {
        this .mesh = mesh;
        this .hasParent = hasParent;
    }

构造函数只接受一个 Vertices3 实例和一个布尔值,该值指示该对象是否有父对象。

    public void update(float deltaTime) {
        rotationY += 45 * deltaTime;
        rotationParent += 20 * deltaTime;
        int len = children.size();
        for (int i = 0; i < len; i++) {
            children.get(i).update(deltaTime);
        }
    }

在 update()方法中,我们首先更新 rotationY 和 rotationParent 成员。每个对象将围绕自身每秒旋转 45 度,围绕其父对象每秒旋转 20 度。我们还为对象的每个子对象递归调用 update()方法。

    public void render(GL10 gl) {
        gl.glPushMatrix();
        if (hasParent)
            gl.glRotatef(rotationParent, 0, 1, 0);
        gl.glTranslatef(x, y, z);
        gl.glPushMatrix();
        gl.glRotatef(rotationY, 0, 1, 0);
        gl.glScalef(scale, scale, scale);
        mesh.draw(GL10.*GL_TRIANGLES*, 0, 36);
        gl.glPopMatrix();

        int len = children.size();
        for (int i = 0; i < len; i++) {
            children.get(i).render(gl);
        }
        gl.glPopMatrix();
    }
}

render()方法是有趣的地方。我们做的第一件事是推送模型-视图矩阵的当前 TOS,它将在对象外部被设置为活动的。因为这个方法是递归的,我们将通过这个方法保存父方法的转换。

接下来,我们应用围绕父对象旋转对象的变换,并将其相对于父对象的中心放置。请记住,变换是以相反的顺序执行的,因此我们实际上首先相对于父对象放置对象,然后围绕父对象旋转它。仅当对象实际上有父对象时,才会执行旋转。日光箱没有父对象,所以我们不旋转它。这些是相对于对象父对象的变换,也适用于对象的子对象。围绕太阳机箱移动行星机箱也会移动“附加的”月亮机箱。

接下来我们要做的是再次推 TOS。到目前为止,它已经包含了父对象的变换和对象相对于父对象的变换。我们需要保存这个矩阵,因为它也将应用于对象的子对象。对象的自旋转及其缩放不适用于子对象,这就是为什么我们在 TOS 的副本(通过推 TOS 创建的)上执行此操作。在应用自旋转和缩放变换后,我们可以使用存储引用的板条箱网格来渲染该对象。让我们考虑一下,由于 TOS 矩阵,模型空间中给定的顶点会发生什么情况。记住应用转换的顺序:从最后到第一。

板条箱将首先被缩放到合适的尺寸。下一个应用的变换是自转。这两种变换应用于模型空间中的顶点。接下来,顶点将被转换到相对于对象父对象的位置。如果这个物体没有父物体,我们将有效地把顶点转换到世界空间。如果它有一个父节点,我们将把顶点转换到父节点的空间,父节点在原点。我们还将在父空间中围绕父对象旋转对象,如果它有父对象的话。如果你展开递归,你会看到我们也应用了这个对象的父对象的转换,等等。通过这种机制,月球机箱将首先被放置到父坐标系中,然后被放置到太阳机箱的坐标系中,这相当于世界空间。

渲染完当前对象后,我们弹出 TOS,这样新的 TOS 只包含对象相对于其父对象的变换和旋转。我们不希望子对象也应用对象的“局部”变换(即,围绕对象的 y 轴旋转和对象缩放)。剩下的就是反复出现在孩子们身上。

注意我们实际上应该以向量的形式对 HierarchicalObject 实例的位置进行编码,这样我们可以更容易地使用它。然而,我们还没有写一个 Vector3 类。我们将在下一章做那件事。

把这一切放在一起

让我们在适当的程序中使用这个 HierarchicalObject 类。为此,只需复制清单 10-6 中的 CubeTest 类的代码,其中也包含我们将重用的 createCube()方法。将类重命名为 HierarchyTest,并将 CubeScreen 重命名为 HierarchyScreen。我们需要做的就是创建我们的对象层次结构,并在适当的位置调用 HierarchicalObject.update()和 HierarchicalObject.render()方法。清单 10-8 显示了 HierarchyTest 的相关部分。

清单 10-8。 摘自 HierarchyTest.java;实现一个简单的层次系统

class HierarchyScreen extends GLScreen {
        Vertices3 cube;
        Texture texture;
        HierarchicalObject sun;

我们班只增加了一个新成员,叫孙。它表示对象层次结构的根。因为所有其他对象都作为子对象存储在这个 sun 对象中,所以我们不需要显式地存储它们。

        public HierarchyScreen(Game game) {
            super(game);
            cube = createCube();
            texture = new Texture(glGame, "crate.png");

            sun = new HierarchicalObject(cube,false );
            sun.z = −5;

            HierarchicalObject planet = new HierarchicalObject(cube,true );
            planet.x = 3;
            planet.scale = 0.2f;
            sun.children.add(planet);

            HierarchicalObject moon = new HierarchicalObject(cube,true );
            moon.x = 1;
            moon.scale = 0.1f;
            planet.children.add(moon);
        }

在构造函数中,我们建立了层次系统。首先,我们加载纹理并创建立方体网格供所有对象使用。接下来,我们创建太阳箱。它没有父对象,并且位于相对于世界原点(虚拟摄影机所在的位置)的(0,0,–5)处。接下来,我们创建围绕太阳运行的行星箱。它位于相对于太阳的(0,0,3)位置,刻度为 0.2。由于行星箱在模型空间中的边长为 1,该缩放因子将使其边长为 0.2 个单位。这里关键的一步是,我们小时候把行星箱加到太阳箱里。对于月球箱,我们做一些类似的事情。它位于相对于行星箱的(0,0,1)处,其比例为 0.1 个单位。我们也把它作为一个孩子添加到行星箱中。参考使用相同单位系统的图 10-15 ,了解设置情况。

        @Override
        public void update(float deltaTime) {
            sun.update(deltaTime);
        }

在 update()方法中,我们简单地告诉 sun crate 更新它自己。它将递归调用所有子节点的相同方法,这些子节点又调用所有子节点的相同方法,依此类推。这将更新层次中所有对象的旋转角度。

        @Override
        public void present(float deltaTime) {
            GL10 gl = glGraphics.getGL();
            gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());
            gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*| GL10.*GL_DEPTH_BUFFER_BIT*);
            gl.glMatrixMode(GL10.*GL_PROJECTION*);
            gl.glLoadIdentity();
            GLU.*gluPerspective*(gl, 67, glGraphics.getWidth()
                    / (float ) glGraphics.getHeight(), 0.1f, 10.0f);
            gl.glMatrixMode(GL10.*GL_MODELVIEW*);
            gl.glLoadIdentity();
            gl.glTranslatef(0, -2, 0);

            gl.glEnable(GL10.*GL_DEPTH_TEST*);
            gl.glEnable(GL10.*GL_TEXTURE_2D*);
            texture.bind();
            cube.bind();

            sun.render(gl);
            cube.unbind();
            gl.glDisable(GL10.*GL_TEXTURE_2D*);
            gl.glDisable(GL10.*GL_DEPTH_TEST*);
        }
// rest as in CubeScreen

最后,我们有了 render()方法。我们从通常的视口设置和清除 framebuffer 和 depthbuffer 开始。我们还建立了透视投影矩阵,并将单位矩阵加载到 OpenGL ES 的模型-视图矩阵中。之后对 glTranslatef()的调用很有趣:它会将我们的太阳系在 y 轴上向下推 2 个单位。这样,我们有点瞧不起这个系统。这可以被认为是实际上将相机在 y 轴上向上移动了 2 个单位。这种解释实际上是正确的相机系统的关键,我们将在下一节“简单的相机系统”中研究。

一旦我们设置好了所有的基础,我们就可以进行深度测试和纹理处理,绑定纹理和立方体网格,让太阳自己渲染。由于层次中的所有对象都使用相同的纹理和网格,我们只需要绑定它们一次。这个调用将递归地渲染太阳及其所有子太阳,如前一节所述。最后,我们禁用深度测试和纹理,只是为了好玩。图 10-16 显示了我们程序的输出。

9781430246770_Fig10-16.jpg

图 10-16。我们的板条箱太阳系在运转

太好了,一切都像预期的那样。太阳只绕着自己旋转。这颗行星以 3 个单位的距离绕太阳运行。它也绕着自己旋转,有太阳的 20%大。月球绕着行星运行,但由于使用了矩阵堆栈,它也绕着太阳运行。它还具有自旋转和缩放形式的局部变换。

HierarchicalObject 类非常通用,您可以随意使用它。增加更多的行星和卫星,甚至可能是卫星的卫星。疯狂使用矩阵堆栈,直到你掌握它的窍门。这也是你只能通过大量练习才能学到的东西。你需要能够在你的大脑中想象当组合所有的转换时实际上发生了什么。

注意不要对矩阵栈太着迷。它有一个最大深度,通常在 16 到 32 个条目之间,取决于 GPU/驱动程序。在一个应用中,我们最多只能使用四个层次。

简单的摄像系统

在前面的例子中,我们看到了如何实现 3D 摄像机系统的提示。我们使用 glTranslatef()在 y 轴上将整个世界向下推 2 个单位。由于相机固定在原点,向下看负 z 轴,这种方法给人的印象是相机本身向上移动了 2 个单位。所有对象仍然定义为 y 坐标设置为 0。

我们需要移动世界,而不是移动摄像机。假设我们希望摄像机位于位置(10,4,2)。我们需要做的就是如下使用 glTranslatef ():

gl.glTranslatef(−10,-4,-2);

如果我们想让相机绕 y 轴旋转 45 度,我们可以这样做:

gl.glRotatef(−45,0,1,0);

我们也可以结合这两个步骤,就像我们对“普通”对象所做的那样:

gl.glTranslatef(−10,-4,-2);
gl.glRotatef(−45,0,1,0);

秘诀是反转转换方法的参数。让我们用前面的例子来思考一下。我们知道,我们的“真正的”相机注定要坐在世界的原点,从 z 轴往下看。通过应用逆相机变换,我们将世界带入相机的固定视野。使用绕 y 轴旋转 45 度的虚拟相机等同于固定相机并将世界绕相机旋转 45 度。翻译也是如此。我们的虚拟摄像机可以放在(10,4,2)处。然而,由于我们的真实相机固定在世界的原点,我们只需要通过该位置向量的逆向量(10,–4,–2)来*移所有对象。

当我们修改前面示例的 present()方法的以下三行代码时,

gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
gl.glTranslatef(0, -2, 0);

有了这四行,

gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
gl.glTranslatef(0, -3, 0);
gl.glRotatef(45, 1, 0, 0);

我们得到的输出如图 10-17 所示。

9781430246770_Fig10-17.jpg

图 10-17。从(0,3,0)俯视我们的世界

从概念上讲,我们的相机现在位于(0,3,0),它以 45 度角俯视我们的场景(这与围绕 x 轴旋转相机 45 度相同)。图 10-18 显示了我们用摄像机设置的场景。

9781430246770_Fig10-18.jpg

图 10-18。摄像机如何在场景中定位和定向

我们实际上可以用四个属性指定一个非常简单的摄像机:

  • 它在世界空间中的位置。
  • 它绕 x 轴的旋转(俯仰)。这相当于上下倾斜你的头。
  • 它绕 y 轴的旋转(偏航)。这相当于左右转动你的头。
  • 它绕 z 轴的旋转(滚动)。这相当于把头向左右倾斜。

给定这些属性,我们可以使用 OpenGL ES 方法来创建一个相机矩阵。这叫做欧拉旋转相机。许多第一人称射击游戏使用这种相机来模拟头部的倾斜。通常你会忽略滚转,只应用偏航和俯仰。应用旋转的顺序很重要。对于第一人称射击游戏,首先应用俯仰旋转,然后应用偏航旋转:

gl.glTranslatef(−cam.x,- cam.y,-cam.z);
gl.glRotatef(cam.yaw, 0, 1, 0);
gl.glRotatef(cam.pitch, 1, 0, 0);

许多游戏仍然使用这种非常简单的相机模型。如果包含了滚动旋转,你可能会观察到一个叫做万向锁定的效果。在给定的配置下,这种效果会抵消其中一个旋转。

用文字甚至图像解释万向锁是非常困难的。由于我们将只使用偏航和俯仰,我们不会有这个问题。要了解万向节锁到底是什么,可以在你最喜欢的视频网站上查找。这个问题不能用欧拉旋转来解决。实际的解决方案在数学上很复杂,我们不会在本书中深入探讨。

一个非常简单的相机系统的第二种方法是使用 GLU.glLookAt()方法:

GLU.gluLookAt(GL10 gl,
              float eyeX, float eyeY, float eyeZ,
              float centerX, float centerY, float centerZ,
              float upX, float upY, float upZ);

与 GLU.gluPerspective()方法一样,GLU.glLookAt()会将当前活动的矩阵乘以一个变换矩阵。在这种情况下,相机矩阵将改变世界:

  • gl 只是整个渲染过程中使用的 GL10 实例。
  • eyeX、eyeY 和 eyeZ 指定相机在世界上的位置。
  • centerX、centerY 和 centerZ 指定了世界上相机所注视的点。
  • upX、upY 和 upZ 指定了上矢量。把它想象成一个从你头顶射出的箭头,指向上方。向左或向右倾斜你的头,箭头将指向与你头顶相同的方向。

上方向向量通常设置为(0,1,0),即使这不完全正确。在大多数情况下,gluLookAt()方法可以重新规范化这个上方向向量。图 10-19 显示了摄像机在(3,3,0),观察(0,0,–5)时的场景,以及它的“真实”上矢量。

9781430246770_Fig10-19.jpg

图 10-19。我们的摄像机在位置(3,3,0),看着(0,0,–3)

我们可以用下面的代码片段替换之前更改的 HierarchyScreen.present()方法中的代码:

gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
GLU.gluLookAt(gl, 3, 3, 0, 0, 0, -5, 0, 1, 0);

这一次,我们还注释掉了对 sun.update()的调用,因此层次结构将类似于图 10-19 所示。图 10-20 显示了使用相机的结果。

9781430246770_Fig10-20.jpg

图 10-20。行动中的相机

当你想要跟随一个角色或者想要通过指定相机的位置和观察点来更好的控制你如何观察场景的时候,这种相机是很棒的。现在,这就是你所需要知道的关于相机的一切。在下一章,我们将编写两个简单的类,一个用于第一人称射击类型的摄像机,另一个用于可以跟踪物体的注视摄像机。

摘要

现在,您应该知道使用 OpenGL ES 进行 3D 图形编程的基础知识。您学习了如何设置透视视图*截头体,如何指定 3D 顶点位置,以及什么是 z 缓冲区。您还看到了 z 缓冲器可以是朋友也可以是敌人,这取决于它的使用是否正确。您创建了您的第一个 3D 对象:一个纹理立方体,这被证明是非常容易的。最后,你学到了更多关于矩阵和变换的知识,以及如何创建一个层次分明且非常简单的相机系统。你会很高兴地知道,这甚至不是冰山一角。在下一章中,我们将在 3D 图形编程的背景下重温第八章中的几个主题。我们还将介绍一些新的技巧,当你编写我们的最终游戏时,这些技巧会派上用场。我们强烈推荐使用本章中的例子。创造新的形状,疯狂地使用变形和摄像系统。

十一、3D 编程技巧

3D 编程是一个极其广泛和复杂的领域。本章探讨了一些主题,这些主题是编写一个简单的 3D 游戏的绝对最低要求:

  • 我们将再次访问我们的朋友向量,并附加一个坐标。
  • 灯光是任何 3D 游戏的重要组成部分。我们将看看如何用 OpenGL ES 实现简单的光照。
  • 以编程方式定义对象很麻烦。我们将看一个简单的 3D 文件格式,以便我们可以加载和渲染用 3D 建模软件创建的 3D 模型。
  • 在第八章中,我们讨论了对象表示和碰撞检测。我们将看看如何在 3D 中做同样的事情。
  • 我们还将简要回顾一下我们在第八章中探索过的一些物理概念——这次是在 3D 环境中。

让我们从三维向量开始。

在我们开始之前

像往常一样,我们将在本章创建几个简单的示例程序。要做到这一点,我们只需创建一个新项目,并复制到目前为止我们开发的框架的所有源代码。

和前面的章节一样,我们将有一个单一的测试启动活动,以列表的形式向我们展示测试。我们将其命名为 GLAdvancedStarter,并将其作为我们的默认活动。简单地复制 GL3DBasicsStarter,并替换测试的类名。我们还需要用适当的元素将每个测试活动添加到清单中。

每个测试都会照常扩展 GLGame 实际的代码将被实现为一个 GLScreen 实例,我们将把它与 GLGame 实例连接起来。正如在第十章中一样,为了节省篇幅,我们将只介绍 GLScreen 实现的相关部分。所有的测试和启动活动都在 com . badlogic . androidgames . glad advanced 包中。一些类将是我们框架的一部分,并将进入各自的框架包中。

三维矢量

在第八章中,我们讨论了 2D 的向量及其解释。正如你可能已经猜到的,我们在那里讨论的所有事情在 3D 空间中仍然适用。我们需要做的就是给我们的向量再加一个坐标,也就是 z 坐标。

我们在 2D 用向量进行的操作可以很容易地转移到 3D 空间。我们用如下语句指定 3D 中的向量:

v = (x,y,z)

3D 中的添加按如下方式进行:

c = a + b = (a.x,a.y,b.z) + (b.x,b.y,b.z) = (a.x + b.x,a.y + b.y,a.z + b.z)

减法的工作方式完全相同:

c = a–b =(a . x,a.y,b . z)–(b . x,b.y,b . z)=(a . x–b . x,a . y–b . y,a . z–b . z)

向量乘以标量的工作原理如下:

a' = a *标量= (a.x *标量,a.y *标量,a.z *标量)

在 3D 中测量一个矢量的长度也很简单;我们只要把 z 坐标加到勾股方程:

| a | = sqrt(a . x * a . x+a . y * a . y+a . z * a . z)

基于此,我们也可以将我们的向量再次归一化为单位长度:

a' = (a.x / |a|,a.y / |a|,a.z / |a|)

我们在第八章中谈到的所有对向量的解释在 3D 中也成立:

  • 位置只是由法向量的 x,y 和 z 坐标来表示。
  • 速度和加速度也可以表示为 3D 矢量。然后,每个分量在一个轴上表示属性的某个量,例如速度情况下的米每秒(m/s)或加速度情况下的米每秒(m/s 2 )。
  • 我们可以将方向(或轴)表示为简单的 3D 单位向量。当我们使用 OpenGL ES 的旋转工具时,我们在第八章中已经这样做了。
  • 我们可以通过从结束向量中减去开始向量并测量结果向量的长度来测量距离。

另一个可能相当有用的操作是 3D 向量围绕 3D 轴的旋转。我们之前通过 OpenGL ES glRotatef()方法使用了这个原理。然而,我们不能使用它来旋转我们将用来存储我们的游戏对象的位置或方向的向量之一,因为它只对我们提交给 GPU 的顶点起作用。幸运的是,Android API 中有一个矩阵类,允许我们模拟 OpenGL ES 在 GPU 上做的事情。让我们编写一个实现所有这些特性的 Vector3 类。清单 11-1 显示了代码,我们会一路解释。

清单 11-1。Vector3.java 的 ??,3D 中的矢量

package com.badlogic.androidgames.framework.math;

import android.opengl.Matrix;
import android.util.FloatMath;

public class Vector3 {
    private static final float []*matrix* = new float [16];
    private static final float []*inVec* = new float [4];
    private static final float []*outVec* = new float [4];
    public float x, y, z;

该类从几个私有静态 final float 数组开始。我们稍后在实现 Vector3 类的新 rotate()方法时会用到它们。请记住,矩阵成员有 16 个元素,而 inVec 和 outVec 成员各有 4 个元素。我们创建这三个数组,这样我们就不必在以后一直创建它们。这将使垃圾收集器高兴。只要记住这样做,Vector3 类就不是线程安全的!

接下来定义的 x、y 和 z 成员应该是不言自明的。它们存储矢量的实际分量:

    public Vector3() {
    }

    public Vector3(float x, float y, float z) {
        this .x = x;
        this .y = y;
        this .z = z;
    }

    public Vector3(Vector3 other) {
        this .x = other.x;
        this .y = other.y;
        this .z = other.z;
    }

    public Vector3 cpy() {
    return new Vector3(x, y, z);
    }

    public Vector3 set(float x, float y, float z) {
        this .x = x;
        this .y = y;
        this .z = z;
        return this ;
    }

    public Vector3 set(Vector3 other) {
        this .x = other.x;
        this .y = other.y;
        this .z = other.z;
        return this ;
    }

像 Vector2 一样,我们的 Vector3 类也有几个构造函数和设置函数以及一个 cpy()方法,这样我们就可以很容易地克隆 Vector 或者从程序中计算的组件中设置它们。

    public Vector3 add(float x, float y, float z) {
        this .x += x;
        this .y += y;
        this .z += z;
        return this ;
    }

    public Vector3 add(Vector3 other) {
        this .x += other.x;
        this .y += other.y;
        this .z += other.z;
        return this ;
    }

    public Vector3 sub(float x, float y, float z) {
        this .x -= x;
        this .y -= y;
        this .z -= z;
        return this ;
    }

    public Vector3 sub(Vector3 other) {
        this .x -= other.x;
        this .y -= other.y;
        this .z -= other.z;
        return this ;
    }

    public Vector3 mul(float scalar) {
        this .x *= scalar;
        this .y *= scalar;
        this .z *= scalar;
        return this ;
    }

各种 add()、sub()和 mul()方法只是我们在 Vector2 类中的扩展,增加了一个 z 坐标。它们实现了我们几页前讨论过的内容。直截了当,对吧?

    public float len() {
        return FloatMath.*sqrt*(x * x + y * y + z * z);
    }

    public Vector3 nor() {
        float len = len();
        if (len != 0) {
            this .x /= len;
            this .y /= len;
            this .z /= len;
        }

        return this ;
    }

len()和 nor()方法本质上也与 Vector2 类中的方法相同。我们所做的就是将新的 z 坐标合并到计算中。

    public Vector3 rotate(float angle, float axisX, float axisY, float axisZ) {
        *inVec*[0] = x;
        *inVec*[1] = y;
        *inVec*[2] = z;
        *inVec*[3] = 1;
        Matrix.*setIdentityM*(*matrix*, 0);
        Matrix.*rotateM*(*matrix*, 0, angle, axisX, axisY, axisZ);
        Matrix.*multiplyMV*(*outVec*, 0,*matrix*, 0,*inVec*, 0);
        x = *outVec*[0];
        y = *outVec*[1];
        z = *outVec*[2];
        return this ;
    }

这是我们新的 rotate()方法。如前所述,它利用了 Android 的 Matrix 类。Matrix 类本质上由两个静态方法组成,比如 Matrix.setIdentityM()和 Matrix.rotateM()。这些在浮点数组上操作,类似于我们之前定义的那些。一个矩阵存储为 16 个浮点值,一个向量预计有四个元素。我们不会详细讨论这个类的内部工作原理;我们所需要的是一种在 Java 端模拟 OpenGL ES 矩阵功能的方法,而这正是这个类提供给我们的。所有这些方法都在矩阵上工作,其操作方式与 OpenGL ES 中的 glRotatef()、glTranslatef()和 glIdentityf()完全相同。

rotate()方法首先将向量的分量设置为我们前面定义的 inVec 数组。接下来,我们在类的矩阵成员上调用 Matrix.setIdentityM()。这将“清除”矩阵。在 OpenGL ES 中,我们使用 glIdentityf()对驻留在 GPU 上的矩阵做了同样的事情。接下来,我们调用 Matrix.rotateM()。它接受保存矩阵的浮点数组、该数组的偏移量、我们要旋转的角度(以度为单位)以及我们要绕其旋转的(单位长度)轴。此方法等效于 glRotatef()。它将给定的矩阵乘以一个旋转矩阵。最后,我们调用 Matrix.multiplyMV(),它将把我们存储在 inVec 中的向量乘以矩阵。这会将矩阵中存储的所有变换应用到向量。结果将在 outVec 中输出。该方法的其余部分只是从 outVec 数组中获取生成的新组件,并将它们存储在 Vector3 类的成员中。

注意除了旋转向量,你还可以使用 Matrix 类做更多的事情。在对传入矩阵的影响方面,它的操作方式与 OpenGL ES 完全相同。

    public float dist(Vector3 other) {
        float distX = this .x - other.x;
        float distY = this .y - other.y;
        float distZ = this .z - other.z;
        return FloatMath.*sqrt*(distX * distX + distY * distY + distZ * distZ);
    }

    public float dist(float x, float y, float z) {
        float distX = this .x - x;
        float distY = this .y - y;
        float distZ = this .z - z;
        return FloatMath.*sqrt*(distX * distX + distY * distY + distZ * distZ);
    }

    public float distSquared(Vector3 other) {
        float distX = this .x - other.x;
        float distY = this .y - other.y;
        float distZ = this .z - other.z;
        return distX * distX + distY * distY + distZ * distZ;
    }

    public float distSquared(float x, float y, float z) {
        float distX = this .x - x;
        float distY = this .y - y;
        float distZ = this .z - z;
        return distX * distX + distY * distY + distZ * distZ;
    }

}

最后,我们有常用的 dist()和 distSquared()方法来计算 3D 中两个向量之间的距离。

注意,我们在 Vector2 中省略了 angle()方法。虽然可以在 3D 中测量两个向量之间的角度,但这并不能给出 0 到 360 范围内的角度。通常,我们只需使用每个向量的两个分量并应用 Vector2.angle()方法,就可以计算 x/y、z/y 和 x/z *面中两个向量之间的角度。直到我们的最后一个游戏,我们才需要这个功能,所以我们将在那时回到主题。

使用这个类的一个明确的例子是不必要的。我们可以像在第八章中调用 Vector2 类一样调用它。进入下一个主题:OpenGL ES 中的光照。

OpenGL ES 中的光照

OpenGL ES 中的光照是一个有用的特性,可以给我们的 3D 游戏一个很好的触感。为了使用这个功能,我们必须对 OpenGL ES 光照模型有一个概念。

照明如何工作

让我们思考一下照明是如何工作的。我们首先需要的是一个发光的光源。我们还需要一个可以被点亮的物体。最后,我们需要一个传感器,就像我们的眼睛或相机一样,它将捕捉由光源发出并被物体反射回来的光子。照明根据以下因素改变物体的感知颜色:

  • 光源的类型
  • 光源的颜色和强度
  • 光源相对于被照亮对象的位置和方向
  • 对象的材质和纹理

物体反射光的强度取决于多种因素。我们最关心的是光线照射表面的角度。光线越垂直于它所照射的表面,表面反射的光的强度就越大。图 11-1 说明了这一点。

9781430246770_Fig11-01.jpg

图 11-1。光线越垂直于表面,反射光的强度越大

一旦光线到达一个表面,它会以两种不同的方式被反射。大多数光线被漫反射,这意味着反射光线被物体表面的不规则性随机散射。有些反射是镜面反射,这意味着光线会反弹回来,就好像它们碰到了一面完美的镜子。图 11-2 显示了漫反射和镜面反射的区别。

*9781430246770_Fig11-02.jpg

图 11-2。漫反射和镜面反射

镜面反射会在物体上表现为高光。一个物体是否会产生镜面反射取决于它的材质。具有粗糙或不*坦表面的对象,如皮肤或织物,不太可能具有镜面高光。表面光滑的物体,如玻璃或大理石,确实会出现这些照明伪像。当然,玻璃或大理石表面也不是绝对光滑的。然而,相对于木材或人类皮肤等材质,它们非常光滑。

当光线照射到一个表面时,它的反射也会根据照射到的物体的化学成分而改变颜色。例如,我们看到的红色物体是那些只反射光的红色部分的物体。该物体“吞噬”所有其他波长。黑色物体是一种几乎能吸收所有照射到它上面的光线的物体。

OpenGL ES 允许我们通过指定光源和对象的材质来模拟这种真实世界的行为。

光源

我们被各种光源包围着。太阳不断向我们发射光子。我们的监视器发出光,在夜晚用那种美丽的蓝色光芒包围着我们。灯泡和头灯让我们在黑暗中不会撞到或撞到东西。OpenGL ES 使我们能够创建四种类型的光源:

  • 环境光 : 环境光本身并不是一种光源,而是来自其他光源的光子在我们的世界中反弹的结果。所有这些杂散光子的组合构成了特定的默认照明级别,该级别是无方向的,并且同等地照明所有对象。
  • 点光源 : 这些点光源在空间中有位置,向各个方向发光。例如,灯泡是一种点光源。
  • 方向灯 : 这些在 OpenGL ES 中被表示为方向,并被假设为无限远。太阳可以被理想化为定向光源。我们可以假设,由于地球和太阳之间的距离,来自太阳的光线都以相同的角度照射到地球上。
  • 聚光灯 : 它们与点光源相似,在空间中有明确的位置。此外,他们有一个方向,他们发光,并创建一个光锥,是有限的一些半径。路灯是聚光灯。

我们将只研究环境光、点光和*行光。由于 OpenGL ES 计算光照的方式,使用有限的 GPU(如 Android 设备上的 GPU)很难获得正确的聚光灯。你马上就会明白为什么会这样。

除了光源的位置和方向,OpenGL ES 还允许我们指定光线的颜色或强度。这被表示为 RGBA 颜色。然而,OpenGL ES 要求我们为每个光源指定四种不同的颜色,而不是只有一种:

  • 环境:这是一个物体整体阴影的强度/颜色。无论物体相对于光源的位置或方向如何,物体都会被均匀地照亮。
  • 漫反射:这是计算漫反射时物体被照亮的强度/颜色。物体不面对光源的一侧不会被照亮,就像在现实生活中一样。
  • 镜面:这种强度/颜色类似于漫反射颜色。但是,它只会影响对象上朝向观察者和光源的特定方向的点。
  • 发射:这完全令人困惑,在现实应用中几乎没有用处,所以我们就不深入探讨了。

通常,我们只设置光源的漫反射和镜面反射强度,其他两个保持默认值。我们也将使用相同的 RGBA 颜色用于漫反射和高光强度。

材质

我们世界中的每一个物体都有一层材质。该材质不仅定义了照射到物体上的光线如何被反射,还修改了反射光线的颜色。OpenGL ES 允许我们为材质指定与光源相同的四种 RGBA 颜色:

  • 环境:这是与场景中任何光源的环境颜色相结合的颜色。
  • 漫射:这是与任何光源的漫射颜色相结合的颜色。
  • 镜面反射:这是一种颜色,与物体表面镜面反射高光点的任何光源的镜面反射颜色相结合。
  • 发射的:我们再次忽略它,因为它在我们的上下文中几乎没有用处。

图 11-3 展示了前三种材质/光源的属性:环境、漫射和镜面。

9781430246770_Fig11-03.jpg

图 11-3。不同的材质/灯光类型。左:仅环境温度。中心:仅漫射。右:环境光和漫反射,带镜面高光

在图 11-3 中,我们可以看到不同材质和灯光属性的贡献。环境光均匀地照亮物体。漫射光会根据光线击中物体的角度而被反射;直接面对光源的区域会比较亮,光线达不到的区域比较暗。最右边的图像显示了环境光、漫射光和镜面光的组合。镜面反射光在球体上显示为白色高光。

OpenGL ES 如何计算光照:顶点法线

你知道从物体反射回来的光的强度取决于光线照射到物体表面的角度。OpenGL ES 利用这个事实来计算光照。它通过使用顶点法线来做到这一点,我们必须在代码中定义它,就像我们定义纹理坐标和顶点颜色一样。图 11-4 显示了一个带有顶点法线的球体。

9781430246770_Fig11-04.jpg

图 11-4。一个球体及其顶点的法线

法线是简单的单位长度向量,指向一个表面所面对的方向。在我们的例子中,曲面是三角形。然而,我们必须指定顶点法线,而不是指定曲面法线。曲面法线和顶点法线之间的区别在于,顶点法线可能不必指向与曲面法线相同的方向。我们可以在图 11-4 中清楚地看到这一点,其中每个顶点法线实际上是该顶点所属三角形法线的*均值。这种*均有助于对象的*滑着色。

当我们渲染一个启用顶点法线和光照的物体时,OpenGL ES 将决定每个顶点和光源之间的角度。有了这个角度,它可以根据对象和光源的材质的环境、漫反射和镜面反射属性来计算顶点的颜色。最终结果是对象的每个顶点的颜色,然后结合其他顶点的计算颜色在每个三角形上进行插值。这个插值的颜色将会和我们应用到物体上的纹理贴图结合起来。

这听起来很可怕,但其实没那么糟糕。我们所需要做的就是启用照明,指定光源,我们想要渲染的对象的材质,顶点法线,以及我们通常指定的其他顶点属性,如位置和纹理坐标。让我们来看看如何用 OpenGL ES 实现这一切。

在实践中

我们现在将通过所有必要的步骤让照明与 OpenGL ES 一起工作。在这个过程中,我们将创建一些小的助手类,使使用光源变得更容易。我们将把它们放在 com . badlogic . Android games . framework . GL 包中。

启用和禁用照明

与所有 OpenGL ES 状态一样,我们首先必须启用相关的功能。我们是这样做的:

gl.glEnable(GL10.GL_LIGHTING);

一旦启用,照明将应用于我们渲染的所有对象。当然,我们必须指定光源、材质和顶点法线来获得有意义的结果。渲染完所有需要照明的对象后,我们可以再次禁用照明:

gl.glDisable(GL10.GL_LIGHTING);

指定光源

如前所述,OpenGL ES 为我们提供了四种类型的光源:环境光、点光、*行光和聚光灯。我们来看看如何定义前三个。为了让聚光灯更有效,看起来更好,我们需要为每个物体的模型设置一个非常高的三角形数。这在大多数当前的移动设备上是禁止的。

OpenGL ES 限制我们在一个场景中最多有八个光源,外加一个全局环境光。八个光源中的每一个都有一个标识符,来自 GL10。GL_LIGHT0 至 GL10。GL_LIGHT7。如果我们想要操作其中一个光源的属性,我们可以通过指定该光源各自的 ID 来实现。

必须使用以下语法启用光源:

gl.glEnable(GL10.GL_LIGHT0);

在这种情况下,OpenGL ES 将获取 ID 为 0 的光源的属性,并相应地将其应用于所有渲染对象。如果我们想禁用一个光源,我们可以这样做:

gl.glDisable(GL10.GL_LIGHT0);

环境光是一种特殊情况,因为它没有标识符。在 OpenGL ES 场景中只有一个环境光。让我们看看那个。

环境光

正如已经解释过的,环境光是一种特殊类型的光。它没有位置和方向,只有一种颜色,通过这种颜色,场景中的所有对象将被均匀地照亮。OpenGL ES 允许我们如下指定全局环境光:

float[] ambientColor = { 0.2f, 0.2f, 0.2f, 1.0f };
gl.glLightModelfv(GL10.GL_LIGHT_MODEL_AMBIENT, color, 0);

ambientColor 数组保存环境光颜色的 RGBA 值,编码为 0 到 1 范围内的浮点数。glLightModelfv()方法将一个常量作为参数,指定我们要设置环境光的颜色;保存颜色的浮点数组;以及该方法应该开始读取 RGBA 值的浮点数组中的偏移量。让我们把这个放到可爱的小课堂里。清单 11-2 显示了代码。

清单 11-2。AmbientLight.java??,OpenGL ES 全局环境光的简单抽象

package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GL10;

public class AmbientLight {
    float [] color = {0.2f, 0.2f, 0.2f, 1};

    public void setColor(float r, float g, float b, float a) {
        color[0] = r;
        color[1] = g;
        color[2] = b;
        color[3] = a;
    }

    public void enable(GL10 gl) {
        gl.glLightModelfv(GL10.*GL_LIGHT_MODEL_AMBIENT*, color, 0);
    }

}

我们所做的就是将环境光的颜色存储在一个浮点数组中,然后提供两种方法:一种是设置颜色,另一种是让 OpenGL ES 使用我们定义的环境光颜色。默认情况下,我们使用灰色的环境光颜色。

点光源

点光源有一个位置以及一个环境光,漫射光和镜面反射光的颜色/强度(我们忽略了发射光的颜色/强度)。要指定不同的颜色,我们可以执行以下操作:

gl.glLightfv(GL10.GL_LIGHT3, GL10.GL_AMBIENT, ambientColor, 0);
gl.glLightfv(GL10.GL_LIGHT3, GL10.GL_DIFFUSE, diffuseColor, 0);
gl.glLightfv(GL10.GL_LIGHT3, GL10.GL_SPECULAR, specularColor, 0);

第一个参数是光标识符;在这种情况下,我们使用第四盏灯。下一个参数指定了我们想要修改的灯光属性。第三个参数也是一个保存 RGBA 值的浮点数组,最后一个参数是该数组的偏移量。指定位置很容易:

float[] position = {x, y, z, 1};
gl.glLightfv(GL10.GL_LIGHT3, GL10.GL_POSITION, position, 0);

我们再次指定要修改的属性(在本例中是位置),以及一个四元素数组,该数组存储光线在我们的世界中的 x、y 和 z 坐标。请注意,对于位置光源,数组的第四个元素必须设置为 1!让我们把它放到一个助手类中。清单 11-3 显示了代码。

清单 11-3。PointLight.java??,OpenGL ES 点光源的简单抽象

package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GL10;

public class PointLight {
    float [] ambient = { 0.2f, 0.2f, 0.2f, 1.0f };
    float [] diffuse = { 1.0f, 1.0f, 1.0f, 1.0f };
    float [] specular = { 0.0f, 0.0f, 0.0f, 1.0f };
    float [] position = { 0, 0, 0, 1 };
    int lastLightId = 0;
    public void setAmbient(float r, float g, float b, float a) {
        ambient[0] = r;
        ambient[1] = g;
        ambient[2] = b;
        ambient[3] = a;
    }

    public void setDiffuse(float r, float g, float b, float a) {
        diffuse[0] = r;
        diffuse[1] = g;
        diffuse[2] = b;
        diffuse[3] = a;
    }

    public void setSpecular(float r, float g, float b, float a) {
        specular[0] = r;
        specular[1] = g;
        specular[2] = b;
        specular[3] = a;
    }

    public void setPosition(float x, float y, float z) {
        position[0] = x;
        position[1] = y;
        position[2] = z;
    }

    public void enable(GL10 gl, int lightId) {
        gl.glEnable(lightId);
        gl.glLightfv(lightId, GL10.*GL_AMBIENT*, ambient, 0);
        gl.glLightfv(lightId, GL10.*GL_DIFFUSE*, diffuse, 0);
        gl.glLightfv(lightId, GL10.*GL_SPECULAR*, specular, 0);
        gl.glLightfv(lightId, GL10.*GL_POSITION*, position, 0);
        lastLightId = lightId;
    }

    public void disable(GL10 gl) {
        gl.glDisable(lastLightId);
    }

}

我们的助手类存储光线的环境、漫射和镜面反射颜色分量以及位置(第四个元素设置为 1)。此外,我们存储了用于这个灯的最后一个灯标识符,这样我们就可以提供一个 disable()方法,在必要时关闭灯。对于每个灯光属性,我们都有一个很好的设置方法。我们还有一个 enable()方法,它接受一个 GL10 实例和一个 light 标识符(比如 GL10。GL_LIGHT6)。它启用灯光,设置其属性,并存储所使用的灯光标识符。disable()方法只是使用 enable()中设置的 lastLightId 成员来禁用灯光。

在成员数组的初始化器中,我们对环境、漫射和镜面反射颜色使用合理的默认值。灯光将是白色的,它不会产生任何高光,因为高光的颜色是黑色的。

方向灯

*行光与点光几乎相同。唯一不同的是它有方向而不是位置。方向的表达方式有些混乱。OpenGL ES 不使用方向向量,而是希望我们在世界上定义一个点。然后通过从该点到世界原点的方向向量来计算方向。以下片段将产生来自世界右侧的*行光:

float[] dirPos = {1, 0, 0, 0};
gl.glLightfv(GL10.GL_LIGHT0, GL10.GL_POSITION, dirPos, 0);

我们可以把它转换成一个方向向量:

dir = -dirPos = {-1, 0, 0, 0}

其余属性,如环境光或漫反射颜色,与点光源的属性相同。清单 11-4 显示了一个用于漫射灯光的小助手类的代码。

清单 11-4。DirectionalLight.java??,OpenGL ES 定向灯的简单抽象

package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GL10;

public class DirectionalLight {
    float [] ambient = { 0.2f, 0.2f, 0.2f, 1.0f };
    float [] diffuse = { 1.0f, 1.0f, 1.0f, 1.0f };
    float [] specular = { 0.0f, 0.0f, 0.0f, 1.0f };
    float [] direction = { 0, 0, -1, 0 };
    int lastLightId = 0;
    public void setAmbient(float r, float g, float b, float a) {
        ambient[0] = r;
        ambient[1] = g;
        ambient[2] = b;
        ambient[3] = a;
    }

    public void setDiffuse(float r, float g, float b, float a) {
        diffuse[0] = r;
        diffuse[1] = g;
        diffuse[2] = b;
        diffuse[3] = a;
    }

    public void setSpecular(float r, float g, float b, float a) {
        specular[0] = r;
        specular[1] = g;
        specular[2] = b;
        specular[3] = a;
    }

    public void setDirection(float x, float y, float z) {
        direction[0] = -x;
        direction[1] = -y;
        direction[2] = -z;
    }

    public void enable(GL10 gl, int lightId) {
        gl.glEnable(lightId);
        gl.glLightfv(lightId, GL10.*GL_AMBIENT*, ambient, 0);
        gl.glLightfv(lightId, GL10.*GL_DIFFUSE*, diffuse, 0);
        gl.glLightfv(lightId, GL10.*GL_SPECULAR*, specular, 0);
        gl.glLightfv(lightId, GL10.*GL_POSITION*, direction, 0);
        lastLightId = lightId;
    }

    public void disable(GL10 gl) {
        gl.glDisable(lastLightId);
    }

}

我们的助手类几乎与点光源类相同。唯一的区别是方向数组的第四个元素被设置为 1。我们还有一个 setDirection()方法,而不是 setPosition()方法。setDirection()方法允许我们指定一个方向,比如(–1,0,0),以便光线从右侧发出。在这个方法中,我们只是否定了所有的矢量分量,因此我们将方向转换为 OpenGL ES 期望的格式。

指定材质

材质由几个属性定义。与 OpenGL ES 中的任何东西一样,材质是一种状态,在我们再次改变它或 OpenGL ES 上下文丢失之前,它都是活动的。要设置当前活动的材质属性,我们可以执行以下操作:

gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_AMBIENT, ambientColor, 0);
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_DIFFUSE, diffuseColor, 0);
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL10.GL_SPECULAR, specularColor, 0);

像往常一样,我们有一个环境,一个漫射,和一个镜面 RGBA 颜色来指定。我们再次通过四元素浮动数组来实现,就像我们对光源属性所做的那样。将这些放到一个小助手类中也很容易。清单 11-5 显示了代码。

清单 11-5。Material.java,OpenGL ES 素材的简单抽象

package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GL10;

public class Material {

    float [] ambient = { 0.2f, 0.2f, 0.2f, 1.0f };
    float [] diffuse = { 1.0f, 1.0f, 1.0f, 1.0f };
    float [] specular = { 0.0f, 0.0f, 0.0f, 1.0f };
    public void setAmbient(float r, float g, float b, float a) {
        ambient[0] = r;
        ambient[1] = g;
        ambient[2] = b;
        ambient[3] = a;
    }

    public void setDiffuse(float r, float g, float b, float a) {
        diffuse[0] = r;
        diffuse[1] = g;
        diffuse[2] = b;
        diffuse[3] = a;
    }

    public void setSpecular(float r, float g, float b, float a) {
        specular[0] = r;
        specular[1] = g;
        specular[2] = b;
        specular[3] = a;
    }

    public void enable(GL10 gl) {
        gl.glMaterialfv(GL10.*GL_FRONT_AND_BACK*, GL10.*GL_AMBIENT*, ambient, 0);
        gl.glMaterialfv(GL10.*GL_FRONT_AND_BACK*, GL10.*GL_DIFFUSE*, diffuse, 0);
        gl.glMaterialfv(GL10.*GL_FRONT_AND_BACK*, GL10.*GL_SPECULAR*, specular, 0);
    }

}

这里也没有什么大的惊喜。我们只是存储材质的三个组件,并提供设置器和一个 enable()方法,用于设置材质。

谈到材质,OpenGL ES 还有一个锦囊妙计。通常我们不会使用 glMaterialfv(),而是选择一种叫做颜色材质的东西。这意味着 OpenGL ES 将采用模型的顶点颜色作为环境和漫射材质颜色,而不是通过 glMaterialfv()指定的环境和漫射颜色。要启用这个漂亮的特性,我们只需调用它:

gl.glEnable(GL10.GL_COLOR_MATERIAL);

我们通常使用这个而不是一个完整的材质类,如前所示,因为环境光和漫反射颜色通常是相同的。因为我们在大多数演示和游戏中也不使用高光,所以我们可以只启用颜色材质而不使用任何 glMaterialfv()调用。选择使用材质类还是颜色材质完全取决于你。

指定法线

为了让光照在 OpenGL ES 中工作,我们必须为模型的每个顶点指定顶点法线。顶点法线必须是一个单位长度的向量,它指向顶点所属曲面的(*均)面向方向。图 11-5 显示了立方体的顶点法线。

9781430246770_Fig11-05.jpg

图 11-5。立方体每个顶点的顶点法线

顶点法线只是另一个顶点属性,就像位置或颜色一样。为了上传顶点法线,我们必须再次修改我们的顶点 3 类。为了告诉 OpenGL ES 在哪里可以找到每个顶点的法线,我们使用 glNormalPointer()方法,就像我们之前使用 glVertexPointer()或 glColorPointer()方法一样。清单 11-6 显示了我们最终修订的 Vertices3 类。

清单 11-6。Vertices3.java??,最终版本支持法线

package com.badlogic.androidgames.framework.gl;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.IntBuffer;
import java.nio.ShortBuffer;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.impl.GLGraphics;

public class Vertices3 {
    final GLGraphics glGraphics;
    final boolean hasColor;
    final boolean hasTexCoords;
    final boolean hasNormals;
    final int vertexSize;
    final IntBuffer vertices;
    final int [] tmpBuffer;
    final ShortBuffer indices;

在这些成员中,唯一新增加的是 hasNormals 布尔值,它跟踪顶点是否有法线。

    public Vertices3(GLGraphics glGraphics, int maxVertices, int maxIndices,
            boolean hasColor, boolean hasTexCoords, boolean hasNormals) {
        this .glGraphics = glGraphics;
        this .hasColor = hasColor;
        this .hasTexCoords = hasTexCoords;
        this .hasNormals = hasNormals;
        this .vertexSize = (3 + (hasColor ? 4 : 0) + (hasTexCoords ? 2 : 0) + (hasNormals ? 3 : 0)) * 4;
        this .tmpBuffer = new int [maxVertices * vertexSize / 4];

        ByteBuffer buffer = ByteBuffer.*allocateDirect*(maxVertices * vertexSize);
        buffer.order(ByteOrder.*nativeOrder*());
        vertices = buffer.asIntBuffer();

        if (maxIndices > 0) {
            buffer = ByteBuffer.*allocateDirect*(maxIndices * Short.*SIZE*/ 8);
            buffer.order(ByteOrder.*nativeOrder*());
            indices = buffer.asShortBuffer();
        } else {
            indices = null ;
        }

    }

在构造函数中,我们现在还接受一个 hasNormals 参数。我们还必须修改 vertexSize 成员的计算,如果法线可用,则为每个顶点添加三个浮点。

    public void setVertices(float [] vertices, int offset, int length) {
        this .vertices.clear();
        int len = offset + length;
        for (int i = offset, j = 0; i < len; i++, j++)
            tmpBuffer[j] = Float.*floatToRawIntBits*(vertices[i]);
        this .vertices.put(tmpBuffer, 0, length);
        this .vertices.flip();
    }

    public void setIndices( short [] indices, int offset, int length) {
        this .indices.clear();
        this .indices.put(indices, offset, length);
        this .indices.flip();
    }

如您所见,方法 setVertices()和 setIndices()保持不变。

    public void bind() {
        GL10 gl = glGraphics.getGL();

        gl.glEnableClientState(GL10.*GL_VERTEX_ARRAY*);
        vertices.position(0);
        gl.glVertexPointer(3, GL10.*GL_FLOAT*, vertexSize, vertices);

        if (hasColor) {
            gl.glEnableClientState(GL10.*GL_COLOR_ARRAY*);
            vertices.position(3);
            gl.glColorPointer(4, GL10.*GL_FLOAT*, vertexSize, vertices);
        }

        if (hasTexCoords) {
            gl.glEnableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);
            vertices.position(hasColor ? 7 : 3);
            gl.glTexCoordPointer(2, GL10.*GL_FLOAT*, vertexSize, vertices);
        }

        if (hasNormals) {
            gl.glEnableClientState(GL10.*GL_NORMAL_ARRAY*);
            int offset = 3;
            if (hasColor)
                offset += 4;
            if (hasTexCoords)
                offset += 2;
            vertices.position(offset);
            gl.glNormalPointer(GL10.*GL_FLOAT*, vertexSize, vertices);
        }

    }

在 bind()方法中,我们使用了通常的 ByteBuffer 技巧,这次还通过 glNormalPointer()方法合并了法线。为了计算法线指针的偏移量,我们必须考虑颜色和纹理坐标是否给定。

    public void draw( int primitiveType, int offset, int numVertices) {
        GL10 gl = glGraphics.getGL();
        if (indices != null ) {
            indices.position(offset);
            gl.glDrawElements(primitiveType, numVertices,
                    GL10.*GL_UNSIGNED_SHORT*, indices);
        } else {
            gl.glDrawArrays(primitiveType, offset, numVertices);
        }

    }

您可以看到 draw()方法再次未被修改;所有神奇的事情都发生在 bind()方法中。

    public void unbind() {
        GL10 gl = glGraphics.getGL();
        if (hasTexCoords)
            gl.glDisableClientState(GL10.*GL_TEXTURE_COORD_ARRAY*);

        if (hasColor)
            gl.glDisableClientState(GL10.*GL_COLOR_ARRAY*);

        if (hasNormals)
            gl.glDisableClientState(GL10.*GL_NORMAL_ARRAY*);
    }

}

最后,我们还稍微修改了 unbind()方法。如果使用了法线,我们禁用法线指针,以正确清理 OpenGL ES 状态。

使用这个修改后的 Vertices3 版本和以前一样简单。这里有一个小例子:

float [] vertices = { -0.5f, -0.5f, 0, 0, 0, 1,
                      0.5f, -0.5f, 0, 0, 0, 1,
                      0.0f,  0.5f, 0, 0, 0, 1 };
Vertices3 vertices = new Vertices3(glGraphics, 3, 0, false , false , true );
vertices.setVertices(vertices);

我们创建一个浮点数组来保存三个顶点,每个顶点都有一个位置(每行的前三个浮点)和一个法线(每行的后三个浮点)。在这种情况下,我们在 x-y *面上有一个三角形,它的法线指向 z 轴的正方向。剩下要做的就是创建顶点 3 实例并设置顶点。很简单,对吧?绑定、绘图和解除绑定工作与旧版本完全相同。当然,我们也可以像以前一样添加顶点颜色和纹理坐标。

将所有这些放在一起

让我们把所有这些照明知识放在一个例子中。我们想用一个全局环境光,一个点光和一个*行光来绘制一个场景,这些光都照亮一个以原点为中心的立方体。另外,我们还将调用 gluLookAt()来定位我们的摄像机。图 11-6 展示了我们这个世界的设置。

9781430246770_Fig11-06.jpg

图 11-6。我们的第一个灯光场景

和我们所有的例子一样,我们创建了一个名为 LightTest 的类,它照常扩展了 GLGame。它从 getStartScreen()方法返回一个新的 LightScreen 实例。LightScreen 类扩展了 GLScreen,如清单 11-7 所示。

清单 11-7。摘自 LightTest.java;使用 OpenGL ES 进行照明

class LightScreen extends GLScreen {
    float angle;
    Vertices3 cube;
    Texture texture;
    AmbientLight ambientLight;
    PointLight pointLight;
    DirectionalLight directionalLight;
    Material material;

我们从几个成员开始。角度成员存储立方体围绕 y 轴的当前旋转。Vertices3 成员存储立方体模型的顶点,我们稍后将对其进行定义。此外,我们存储环境光,点光,方向光和材质实例。

    public LightScreen(Game game) {
        super (game);

        cube = createCube();
        texture = new Texture(glGame, "crate.png");
        ambientLight = new AmbientLight();
        ambientLight.setColor(0, 0.2f, 0, 1);
        pointLight = new PointLight();
        pointLight.setDiffuse(1, 0, 0, 1);
        pointLight.setPosition(3, 3, 0);
        directionalLight = new DirectionalLight();
        directionalLight.setDiffuse(0, 0, 1, 1);
        directionalLight.setDirection(1, 0, 0);
        material = new Material();
    }

接下来是构造函数。在这里我们创建立方体模型的顶点并加载箱子纹理,就像我们在第十章中所做的一样。我们还实例化所有的灯光和材质,并设置它们的属性。环境光颜色是浅绿色,点光源是红色,在我们的世界中位于(3,3,0)。*行光具有蓝色漫反射颜色,来自左侧。对于材质,我们使用默认值(一点环境光,白色用于漫反射,黑色用于镜面反射)。

    @Override
    public void resume() {
        texture.reload();
    }

在 resume()方法中,我们确保在上下文丢失的情况下(重新)加载纹理。

    private Vertices3 createCube() {
        float [] vertices = { -0.5f, -0.5f, 0.5f, 0, 1, 0, 0, 1,
                              0.5f, -0.5f, 0.5f, 1, 1, 0, 0, 1,
                              0.5f,  0.5f, 0.5f, 1, 0, 0, 0, 1,
                             -0.5f,  0.5f, 0.5f, 0, 0, 0, 0, 1,

                              0.5f, -0.5f,  0.5f, 0, 1, 1, 0, 0,
                              0.5f, -0.5f, -0.5f, 1, 1, 1, 0, 0,
                              0.5f,  0.5f, -0.5f, 1, 0, 1, 0, 0,
                              0.5f,  0.5f,  0.5f, 0, 0, 1, 0, 0,

                              0.5f, -0.5f, -0.5f, 0, 1, 0, 0, -1,
                             -0.5f, -0.5f, -0.5f, 1, 1, 0, 0, -1,
                             -0.5f,  0.5f, -0.5f, 1, 0, 0, 0, -1,
                              0.5f,  0.5f, -0.5f, 0, 0, 0, 0, -1,

                             -0.5f, -0.5f, -0.5f, 0, 1, -1, 0, 0,
                             -0.5f, -0.5f,  0.5f, 1, 1, -1, 0, 0,
                             -0.5f,  0.5f,  0.5f, 1, 0, -1, 0, 0,
                             -0.5f,  0.5f, -0.5f, 0, 0, -1, 0, 0,

                             -0.5f,  0.5f,  0.5f, 0, 1, 0, 1, 0,
                              0.5f,  0.5f,  0.5f, 1, 1, 0, 1, 0,
                              0.5f,  0.5f, -0.5f, 1, 0, 0, 1, 0,
                             -0.5f,  0.5f, -0.5f, 0, 0, 0, 1, 0,

                             -0.5f, -0.5f, -0.5f, 0, 1, 0, -1, 0,
                              0.5f, -0.5f, -0.5f, 1, 1, 0, -1, 0,
                              0.5f, -0.5f,  0.5f, 1, 0, 0, -1, 0,
                             -0.5f, -0.5f,  0.5f, 0, 0, 0, -1, 0 };
        short [] indices = { 0, 1, 2, 2, 3, 0,
                            4, 5, 6, 6, 7, 4,
                            8, 9, 10, 10, 11, 8,
                            12, 13, 14, 14, 15, 12,
                            16, 17, 18, 18, 19, 16,
                            20, 21, 22, 22, 23, 20,
                            24, 25, 26, 26, 27, 24 };
        Vertices3 cube = new Vertices3(glGraphics, vertices.length / 8, indices.length, false , true , true );
        cube.setVertices(vertices, 0, vertices.length);
        cube.setIndices(indices, 0, indices.length);
        return cube;
    }

createCube()方法与我们在前面的例子中使用的方法基本相同。然而这一次,我们给每个顶点添加了法线,如图图 11-4 所示。除此之外,没有什么真正改变。

    @Override
    public void update(float deltaTime) {
        angle += deltaTime * 20;
    }

在 update()方法中,我们只是增加立方体的旋转角度。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClearColor(0.2f, 0.2f, 0.2f, 1.0f);
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*| GL10.*GL_DEPTH_BUFFER_BIT*);
        gl.glEnable(GL10.*GL_DEPTH_TEST*);
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());

        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        GLU.*gluPerspective*(gl, 67, glGraphics.getWidth()
                / (float ) glGraphics.getHeight(), 0.1f, 10f);
        gl.glMatrixMode(GL10.*GL_MODELVIEW*);
        gl.glLoadIdentity();
        GLU.*gluLookAt*(gl, 0, 1, 3, 0, 0, 0, 0, 1, 0);
        gl.glEnable(GL10.*GL_LIGHTING*);

        ambientLight.enable(gl);
        pointLight.enable(gl, GL10.*GL_LIGHT0*);

        directionalLight.enable(gl, GL10.*GL_LIGHT1*);
        material.enable(gl);
        gl.glEnable(GL10.*GL_TEXTURE_2D*);
        texture.bind();
        gl.glRotatef(angle, 0, 1, 0);
        cube.bind();
        cube.draw(GL10.*GL_TRIANGLES*, 0, 6 * 2 * 3);
        cube.unbind();
        pointLight.disable(gl);
        directionalLight.disable(gl);

        gl.glDisable(GL10.*GL_TEXTURE_2D*);
        gl.glDisable(GL10.*GL_DEPTH_TEST*);
    }

这里变得有趣了。前几行是我们清除 colorbuffer 和 depthbuffer 的样板代码——启用深度测试和设置视口。

接下来,我们通过 gluPerspective()将投影矩阵设置为透视投影矩阵,并将 gluLookAt()用于模型-视图矩阵,这样我们就有了如图图 11-6 所示的摄像机设置。

接下来,我们启用照明本身。此时,我们还没有定义任何灯光,所以我们在接下来的几行中通过调用灯光和材质的 enable()方法来定义。

像往常一样,我们也启用纹理和绑定我们的板条箱纹理。最后,我们调用 glRotatef()来旋转我们的立方体,然后通过调用 Vertices3 实例来呈现它的顶点。

为了完善这个方法,我们禁用了点光源和*行光(记住,环境光是全局状态)以及纹理和深度测试。这就是 OpenGL ES 中光照的全部内容!

    @Override
    public void pause() {
    }

    @Override
    public void dispose() {
    }

}

其余的类只是空的;在暂停的情况下,我们不必做任何特殊的事情。

图 11-7 显示了我们例子的输出。

9781430246770_Fig11-07.jpg

图 11-7。我们的场景来自图 11-6 ,用 OpenGL ES 渲染

OpenGL ES 中光照的一些注意事项

虽然灯光可以给你的游戏增加一些好看的视觉效果,但是它也有它的局限性和缺陷。以下是一些你应该牢记在心的事情:

  • 照明很贵,尤其是在低端设备上。小心使用。启用的光源越多,渲染场景所需的计算能力就越强。
  • 当指定点光源/*行光的位置/方向时,您必须在加载相机矩阵之后和将模型视图矩阵与任何矩阵相乘以移动和旋转对象之前执行此操作!这一点至关重要。如果不按照这个方法,你会有一些莫名其妙的灯光神器。
  • 当您使用 glScalef()更改模型的大小时,它的法线也会被缩放。这很糟糕,因为 OpenGL ES 期望单位长度的法线。要解决这个问题,您可以使用命令 glEnable(GL10。GL_NORMALIZE)或者在某些情况下是 glEnable(GL10。GL_RESCALE_NORMAL)。我们建议坚持前者,因为后者有一些限制和警告。问题是规格化或重定法线的计算量很大。为了获得最佳性能,最好不要缩放照明对象。

Mipmapping

如果你玩了我们之前的例子,让立方体远离相机,你可能会注意到,立方体越小,纹理开始变得粗糙,充满了小瑕疵。这种效应被称为混叠 ,在所有类型的信号处理中都是一种突出的效应。图 11-8 显示了右侧的效果和左侧应用一种叫做 mipmapping 的技术的结果。

9781430246770_Fig11-08.jpg

图 11-8。右边混叠伪影;左边是小中见大贴图的结果

我们不会深入研究为什么会发生混叠的细节;你只需要知道如何让物体看起来更好。这就是 mipmapping 的用武之地。

解决锯齿问题的关键是对屏幕上较小或远离视点的对象部分使用较低分辨率的图像。这通常被称为 mipmap 金字塔??。给定一个默认分辨率的图像,比如说 256×256 像素,我们创建它的较小版本,为 mipmap 金字塔的每个级别将边分成两部分。图 11-9 显示了具有不同 mipmap 级别的板条箱纹理。

9781430246770_Fig11-09.jpg

图 11-9。一个 mipmap 链

要在 OpenGL ES 中制作纹理贴图,我们必须做两件事:

  • 将缩小过滤器设置为 GL_XXX_MIPMAP_XXX 常量之一,通常为 GL_LINEAR_MIPMAP_NEAREST。
  • 通过调整原始图像的大小为每个 mipmap 链级别创建图像,并将它们上传到 OpenGL ES。小中见大贴图链附加到单个纹理,而不是多个纹理。

要调整 mipmap 链的基本图像的大小,我们可以简单地使用 Android API 提供的位图和画布类。让我们稍微修改一下纹理类。 清单 11-8 显示了代码。

清单 11-8。Texture.java,我们最终版本的纹理类

package com.badlogic.androidgames.framework.gl;

import java.io.IOException;
import java.io.InputStream;

import javax.microedition.khronos.opengles.GL10;

import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.opengl.GLUtils;

import com.badlogic.androidgames.framework.FileIO;
import com.badlogic.androidgames.framework.impl.GLGame;
import com.badlogic.androidgames.framework.impl.GLGraphics;

public class Texture {
    GLGraphics glGraphics;
    FileIO fileIO;
    String fileName;
    int textureId;
    int minFilter;
    int magFilter;
    public int width;
    public int height;
    boolean mipmapped;

我们只添加了一个新成员,称为 mipmapped,它存储纹理是否有 mipmap 链。

    public Texture(GLGame glGame, String fileName) {
        this (glGame, fileName, false );
    }

    public Texture(GLGame glGame, String fileName, boolean mipmapped) {
        this .glGraphics = glGame.getGLGraphics();
        this .fileIO = glGame.getFileIO();
        this .fileName = fileName;
        this .mipmapped = mipmapped;
        load();
    }

为了兼容,我们保留旧的构造函数,它调用新的构造函数。新的构造函数接受第三个参数,让我们指定是否希望纹理被 mipmapped。

    private void load() {
        GL10 gl = glGraphics.getGL();
        int [] textureIds = new int [1];
        gl.glGenTextures(1, textureIds, 0);
        textureId = textureIds[0];

        InputStream in = null ;
        try {
            in = fileIO.readAsset(fileName);
            Bitmap bitmap = BitmapFactory.*decodeStream*(in);
            if (mipmapped) {
                createMipmaps(gl, bitmap);
            } else {
                gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
                GLUtils.*texImage2D*(GL10.*GL_TEXTURE_2D*, 0, bitmap, 0);
                setFilters(GL10.*GL_NEAREST*, GL10.*GL_NEAREST*);
                gl.glBindTexture(GL10.*GL_TEXTURE_2D*, 0);
                width = bitmap.getWidth();
                height = bitmap.getHeight();
                bitmap.recycle();
            }

        } catch (IOException e) {
            throw new RuntimeException("Couldn't load texture '" + fileName
                    + "'", e);
        } finally {
            if (in != null )
                try {
                    in.close();
                } catch (IOException e) {
                }

        }

    }

load()方法本质上也保持不变。唯一增加的是调用 createMipmaps(),以防纹理应该被 mipmapped。非小中见大贴图纹理实例如前所述创建。

    private void createMipmaps(GL10 gl, Bitmap bitmap) {
        gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
        width = bitmap.getWidth();
        height = bitmap.getHeight();
        setFilters(GL10.*GL_LINEAR_MIPMAP_NEAREST*, GL10.*GL_LINEAR*);

        int level = 0;
        int newWidth = width;
        int newHeight = height;
        while (true ) {
            GLUtils.*texImage2D*(GL10.*GL_TEXTURE_2D*, level, bitmap, 0);
            newWidth = newWidth / 2;
            newHeight = newHeight / 2;
            if (newWidth <= 0)
                break ;
            Bitmap newBitmap = Bitmap.*createBitmap*(newWidth, newHeight,
                    bitmap.getConfig());
            Canvas canvas = new Canvas(newBitmap);
            canvas.drawBitmap(bitmap,
                    new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()),
                    new Rect(0, 0, newWidth, newHeight), null );
            bitmap.recycle();
            bitmap = newBitmap;
            level++;
        }

        gl.glBindTexture(GL10.*GL_TEXTURE_2D*, 0);
        bitmap.recycle();
    }

createMipmaps()方法相当简单。我们从绑定纹理开始,这样我们就可以操作它的属性。我们做的第一件事是跟踪位图的宽度和高度,并设置过滤器。请注意,我们使用 GL_LINEAR_MIPMAP_NEAREST 作为缩小过滤器。如果我们不使用那个过滤器,小中见大贴图将不起作用,OpenGL ES 将退回到正常的过滤,只使用基本图像。

while 循环非常简单。我们上传当前位图作为当前级别的图像。我们从第 0 层开始,原始图像的基础层。一旦当前级别的图像被上传,我们创建一个较小的版本,将其宽度和高度除以 2。如果新的宽度小于或等于 0,我们可以打破无限循环,因为我们已经为每个 mipmap 级别上传了一个图像(最后一个图像的大小为 1×1 像素)。我们使用 Canvas 类来调整图像的大小并将结果存储在 newBitmap 中。然后我们回收旧的位图,这样我们就可以清理掉它使用的所有内存,并将新位图设置为当前位图。我们重复这个过程,直到图像小于 1×1 像素。

最后,我们解除纹理绑定,并回收循环中创建的最后一个位图。

    public void reload() {
        load();
        bind();
        setFilters(minFilter, magFilter);
        glGraphics.getGL().glBindTexture(GL10.*GL_TEXTURE_2D*, 0);
    }

    public void setFilters( int minFilter, int magFilter) {
        this .minFilter = minFilter;
        this .magFilter = magFilter;
        GL10 gl = glGraphics.getGL();
        gl.glTexParameterf(GL10.*GL_TEXTURE_2D*, GL10.*GL_TEXTURE_MIN_FILTER*,
                minFilter);
        gl.glTexParameterf(GL10.*GL_TEXTURE_2D*, GL10.*GL_TEXTURE_MAG_FILTER*,
                magFilter);
    }

    public void bind() {
        GL10 gl = glGraphics.getGL();
        gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
    }

    public void dispose() {
        GL10 gl = glGraphics.getGL();
        gl.glBindTexture(GL10.*GL_TEXTURE_2D*, textureId);
        int [] textureIds = { textureId };
        gl.glDeleteTextures(1, textureIds, 0);
    }

}

该类的其余部分与前一版本相同。用法上唯一的区别是我们如何调用构造函数。因为这非常简单,所以我们不会只为 mipmapping 编写一个例子。我们将在所有用于 3D 物体的纹理上使用纹理映射。在 2D,小中见大贴图没那么有用。关于小中见大映射的最后几点说明:

  • 如果使用纹理贴图绘制的对象很小,纹理贴图可以大大提高性能。其原因是 GPU 必须从 mipmap 金字塔中较小的图像获取较少的纹理元素。因此,明智的做法是在可能变小的物体上使用纹理贴图。
  • 与等效的非小中见大贴图版本相比,小中见大贴图纹理占用 33%以上的内存。这种权衡通常是好的。
  • 在 OpenGL ES 1.x 中,Mipmapping 只适用于正方形纹理。记住这一点至关重要。如果你的物体保持白色,即使它们有很好的图像纹理,你可以很确定你忘记了这个限制。

再次注意,因为这真的很重要,记住小中见大贴图只适用于正方形纹理!512×256 像素的图像无法工作。

简单相机

在前一章,我们讨论了两种创建相机的方法。第一个是欧拉相机,类似于第一人称射击游戏中使用的相机。第二个是观察相机,用于电影摄影或跟踪物体。让我们创建两个辅助类来创建可以在游戏中使用的摄像机。

第一人称或欧拉摄像机

第一人称或欧拉摄影机由以下属性定义:

  • 以度为单位的视野。
  • 视口纵横比。
  • *剪裁*面和远剪裁*面。
  • 3D 空间中的位置。
  • 围绕 y 轴的角度(偏航)。
  • 围绕 x 轴的角度(斜度)。这被限制在–90 度到+90 度的范围内。想想你能把自己的头倾斜多远,试着超越那些角度!我们不对任何伤害负责。

前三个属性用于定义透视投影矩阵。在我们所有的 3D 示例中,我们已经通过调用 gluPerspective()做到了这一点。

其他三个属性定义了相机在我们世界中的位置和方向。我们将据此构建一个矩阵,如第十章所述。

我们还希望能够将相机朝它前进的方向移动。为此,我们需要一个单位长度的方向向量,我们可以把它加到摄像机的位置向量上。我们可以在 Android API 提供的 Matrix 类的帮助下创建这种类型的向量。让我们思考一下这个问题。

在其默认配置中,我们的相机将向下看负 z 轴,给它一个方向向量(0,0,-1)。当我们指定一个偏航角或俯仰角时,这个方向向量将相应地旋转。为了计算出方向向量,我们只需要将它乘以一个将旋转默认方向向量的矩阵,就像 OpenGL ES 将旋转我们模型的顶点一样。

让我们看看所有这些在代码中是如何工作的。清单 11-9 显示了 EulerCamera 类。

清单 11-9。EulerCamera.java,基于绕 x 轴和 y 轴的欧拉角的简单第一人称相机

package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GL10;

import android.opengl.GLU;
import android.opengl.Matrix;

import com.badlogic.androidgames.framework.math.Vector3;

public class EulerCamera {
    final Vector3 position = new Vector3();
    float yaw;
    float pitch;
    float fieldOfView;
    float aspectRatio;
    float near;
    float far;

前三个成员控制摄像机的位置和旋转角度。其他四个成员保存用于计算透视投影矩阵的参数。默认情况下,我们的相机位于世界的原点,向下看负 z 轴。

    public EulerCamera(float fieldOfView, float aspectRatio, float near, float far){
        this .fieldOfView = fieldOfView;
        this .aspectRatio = aspectRatio;
        this .near = near;
        this .far = far;
    }

构造函数接受四个定义透视投影的参数。我们保留相机位置和旋转角度不变。

    public Vector3 getPosition() {
        return position;
    }

    public float getYaw() {
        return yaw;
    }

    public float getPitch() {
        return pitch;
    }

getter 方法只是返回相机的方向和位置。

    public void setAngles(float yaw, float pitch) {
        if (pitch < -90)
            pitch = -90;
        if (pitch > 90)
            pitch = 90;
        this .yaw = yaw;
        this .pitch = pitch;
    }

    public void rotate(float yawInc, float pitchInc) {
        this .yaw += yawInc;
        this .pitch += pitchInc;
        if (pitch < -90)
            pitch = -90;
        if (pitch > 90)
            pitch = 90;
    }

setAngles()方法允许我们直接指定摄像机的偏航和俯仰。请注意,我们将螺距限制在–90°到 90°的范围内。我们不能把自己的头转得比这更远,所以我们的相机也不能这样做。

rotate()方法与 setAngles()方法几乎相同。它不是设置角度,而是通过参数增加角度。在下一个例子中,当我们实现一个基于触摸屏的控制方案时,这将非常有用。

    public void setMatrices(GL10 gl) {
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        GLU.*gluPerspective*(gl, fieldOfView, aspectRatio, near, far);
        gl.glMatrixMode(GL10.*GL_MODELVIEW*);
        gl.glLoadIdentity();
        gl.glRotatef(-pitch, 1, 0, 0);
        gl.glRotatef(-yaw, 0, 1, 0);
        gl.glTranslatef(-position.x, -position.y, -position.z);
    }

setMatrices()方法只是像前面讨论的那样设置投影和模型视图矩阵。投影矩阵是通过 gluPerspective()根据构造函数中给摄像机的参数设置的。模型-视图矩阵执行我们在第十章中谈到的移动世界的技巧,通过应用绕 x 和 y 轴的旋转和*移。所有涉及的因素都被取消,以实现相机保持在世界的原点,向下看负 z 轴的效果。因此,我们围绕相机旋转和*移对象,而不是反过来。

    final float [] matrix = new float [16];
    final float [] inVec = { 0, 0, -1, 1 };
    final float [] outVec = new float [4];
    final Vector3 direction = new Vector3();
    public Vector3 getDirection() {
        Matrix.*setIdentityM*(matrix, 0);
        Matrix.*rotateM*(matrix, 0, yaw, 0, 1, 0);
        Matrix.*rotateM*(matrix, 0, pitch, 1, 0, 0);
        Matrix.*multiplyMV*(outVec, 0, matrix, 0, inVec, 0);
        direction.set(outVec[0], outVec[1], outVec[2]);
        return direction;
    }

}

最后,我们有神秘的 getDirection()方法。它伴随着几个我们在方法内部计算时使用的最终成员。我们这样做是为了在每次调用该方法时不分配新的浮点数组和 Vector3 实例。将这些成员视为临时工作变量。

在该方法中,我们首先设置一个包含绕 x 和 y 轴旋转的转换矩阵。我们不需要包括*移,因为我们只需要一个方向向量,而不是位置向量。相机的方向与其在世界上的位置无关。我们调用的矩阵方法应该是不言自明的。唯一奇怪的是,我们实际上以相反的顺序应用它们,而没有否定论点。我们在 setMatrices()方法中做了相反的事情。这是因为我们现在实际上是在以与我们变换虚拟相机相同的方式变换一个点,虚拟相机不必位于原点或定向为向下看负 z 轴。我们旋转的向量是(0,0,–1),存储在 inVec 中。如果没有旋转,这就是我们相机的默认方向。矩阵乘法所做的只是通过相机的俯仰和滚动来旋转这个方向向量,以便它指向相机前进的方向。我们做的最后一件事是根据矩阵向量乘法的结果设置一个 Vector3 实例,并将其返回给调用者。稍后,我们可以使用这个单位长度的方向向量来移动摄像机。

有了这个小助手类,我们可以编写一个小小的示例程序,让我们在一个箱子世界中移动。

一个欧拉相机的例子

我们现在想在一个小程序中使用 EulerCamera 类。我们希望能够基于用手指滑动触摸屏来上下左右旋转相机。我们还希望它在按钮被按下时向前移动。我们的世界应该有几十个箱子。图 11-10 显示了我们场景的初始设置。

9781430246770_Fig11-10.jpg

图 11-10。一个简单的场景,有 25 个板条箱,一个点光源,一个欧拉摄像机在它的初始位置和方向

相机将位于(0,1,3)。我们在(3,3,–3)处也有一个白色的点光源。板条箱位于 x 轴上从–4 到 4 和 z 轴上从 0 到–8 的网格中,中心之间的距离为 2 个单位。

我们如何通过滑动来旋转相机?当我们水*滑动时,我们希望相机围绕 y 轴旋转。那相当于左右转动你的头。当我们垂直滑动时,我们还希望相机围绕 x 轴旋转。这相当于上下倾斜你的头。我们还希望能够结合这两种滑动动作。实现这一点最直接的方法是检查手指是否在屏幕上,如果是,则测量每个轴上手指在屏幕上最后已知位置的差值。然后,我们可以利用 y 轴旋转的 x 轴差和 x 轴旋转的 y 轴差得出两个轴的旋转变化。

我们还希望能够通过按下屏幕上的按钮来向前移动摄像机。那很简单;我们只需要调用 EulerCamera.getDirection()并将其结果乘以我们希望相机移动的速度和 delta 时间,这样我们就再次执行基于时间的移动。我们需要做的唯一事情是绘制按钮(我们决定在屏幕的左下角绘制一个 64×64 的按钮)并检查它当前是否被手指触摸。

为了简化我们的实现,我们只允许用户滑动-旋转或移动。我们可以为此使用多点触摸工具,但这会使我们的实现变得相当复杂。

有了这个攻击计划,让我们看看 EulerCameraScreen,一个包含在名为 EulerCameraTest 的 GLGame 实现中的 GLScreen 实现(只是通常的测试结构)。清单 11-10 显示了代码。

清单 11-10。摘自 EulerCameraTest.java;电影《??》

class EulerCameraScreen extends GLScreen {
    Texture crateTexture;
    Vertices3 cube;
    PointLight light;
    EulerCamera camera;
    Texture buttonTexture;
    SpriteBatcher batcher;
    Camera2D guiCamera;
    TextureRegion buttonRegion;
    Vector2 touchPos;
    float lastX = -1;
    float lastY = -1;

我们从一些成员开始。前两个存储板条箱的纹理和纹理立方体的顶点。我们将使用前面示例中的 createCube()方法生成顶点。下一个成员是我们已经熟悉的 PointLight 实例,后面是我们新的 EulerCamera 类的实例。

接下来是我们需要呈现按钮的几个成员。我们为这个按钮使用一个单独的 64×64 的图像,名为 button.png。为了渲染它,我们还需要一个 SpriteBatcher 实例以及一个 Camera2D 实例和一个 TextureRegion 实例。这意味着我们将在这个例子中结合 3D 和 2D 渲染!最后三个成员用于跟踪 UI 坐标系(固定为 480×320)中的当前触摸位置,以及存储最后已知的触摸位置。我们将对 lastX 和 lastY 使用值-1 来表示还不知道有效的最后触摸位置。

    public EulerCameraScreen(Game game) {
        super (game);

        crateTexture = new Texture(glGame, "crate.png", true );
        cube = createCube();
        light = new PointLight();
        light.setPosition(3, 3, -3);
        camera = new EulerCamera(67, glGraphics.getWidth() / (float )glGraphics.getHeight(), 1, 100);
        camera.getPosition().set(0, 1, 3);

        buttonTexture = new Texture(glGame, "button.png");
        batcher = new SpriteBatcher(glGraphics, 1);
        guiCamera = new Camera2D(glGraphics, 480, 320);
        buttonRegion = new TextureRegion(buttonTexture, 0, 0, 64, 64);
        touchPos = new Vector2();
    }

在构造器中,我们加载箱子纹理并创建立方体顶点,就像我们在前面的例子中所做的一样。我们还创建了一个点光源,并将其位置设置为(3,3,–3)。EulerCamera 实例是使用标准参数、67 度视野、当前屏幕分辨率的纵横比、1 的*剪裁*面距离和 100 的远剪裁*面距离创建的。最后,我们将相机位置设置为(0,1,3),如图图 11-10 所示。

在构造函数的其余部分,我们只加载按钮纹理并创建呈现按钮所需的 SpriteBatcher 实例、Camera2D 实例和 TextureRegion 实例。最后,我们创建一个 Vector2 实例,这样我们就可以将真实的触摸坐标转换为用于 UI 渲染的 Camera2D 实例的坐标系,就像我们在第九章的《超级跳线》中所做的一样。

    private Vertices3 createCube() {
       // same as in previous example
    }

    @Override
    public void resume() {
        crateTexture.reload();
    }

createCube()和 resume()方法与前面的示例完全相同,因此这里不再重复所有代码。

    @Override
    public void update(float deltaTime) {
        game.getInput().getTouchEvents();
        float x = game.getInput().getTouchX(0);
        float y = game.getInput().getTouchY(0);
        guiCamera.touchToWorld(touchPos.set(x, y));

        if (game.getInput().isTouchDown(0)) {
            if (touchPos.x < 64 && touchPos.y < 64) {
                Vector3 direction = camera.getDirection();
                camera.getPosition().add(direction.mul(deltaTime));
            } else {
                if (lastX == -1) {
                    lastX = x;
                    lastY = y;
                } else {
                    camera.rotate((x - lastX) / 10, (y - lastY) / 10);
                    lastX = x;
                    lastY = y;
                }

            }

        } else {
            lastX = -1;
            lastY = -1;
        }

    }

update()方法是所有基于触摸事件的滑动旋转和移动发生的地方。我们做的第一件事是通过调用 Input.getTouchEvents()清空触摸事件缓冲区。接下来,我们获取第一个手指在屏幕上的当前触摸坐标。请注意,如果当前没有手指触摸屏幕,我们调用的方法将返回索引为 0 的手指的最后已知位置。我们还将真实的触摸坐标转换到我们的 2D UI 的坐标系中,以便我们可以轻松地检查左下角的按钮是否被按下。

有了所有这些值,我们就可以检查手指是否真的接触到了屏幕。如果是,我们首先检查它是否正在触摸按钮,该按钮在 2D UI 系统中跨越坐标(0,0)到(64,64)。如果是这种情况,我们获取摄像机的当前方向,并将其添加到其位置,乘以当前的增量时间。因为方向向量是一个单位长度的向量,这意味着相机将每秒移动一个单位。

如果按钮没有被触摸,我们将触摸解释为滑动手势。为此,我们需要一个有效的最后已知触摸坐标。用户第一次放下手指时,lastX 和 lastY 成员的值将为–1,这表明我们无法在上次和当前触摸坐标之间创建差异,因为我们只有一个数据点。因此,我们只是存储当前的触摸坐标,并从 update()方法返回。如果我们记录了最后一次调用 update()时的触摸坐标,我们只需获取当前和最后一次触摸坐标在 x 和 y 轴上的差值。我们直接将这些转化为旋转角度的增量。为了稍微减慢旋转速度,我们将差值除以 10。剩下的唯一一件事就是调用 EulerCamera.rotate()方法,该方法将相应地调整旋转角度。

最后,如果当前没有手指触摸屏幕,我们将 lastX 和 lastY 成员设置为–1,以指示我们必须等待第一次触摸事件,然后才能进行任何滑动手势处理。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*| GL10.*GL_DEPTH_BUFFER_BIT*);
        gl.glViewport(0, 0, glGraphics.getWidth(), glGraphics.getHeight());

        camera.setMatrices(gl);

        gl.glEnable(GL10.*GL_DEPTH_TEST*);
        gl.glEnable(GL10.*GL_TEXTURE_2D*);
        gl.glEnable(GL10.*GL_LIGHTING*);

        crateTexture.bind();
        cube.bind();
        light.enable(gl, GL10.*GL_LIGHT0*);

        for (int z = 0; z >= -8; z- = 2) {
            for (int x = -4; x < =4; x + =2 ) {
                gl.glPushMatrix();
                gl.glTranslatef(x, 0, z);
                cube.draw(GL10.*GL_TRIANGLES*, 0, 6 * 2 * 3);
                gl.glPopMatrix();
            }

        }

        cube.unbind();
        gl.glDisable(GL10.*GL_LIGHTING*);
        gl.glDisable(GL10.*GL_DEPTH_TEST*);
        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);

        guiCamera.setViewportAndMatrices();
        batcher.beginBatch(buttonTexture);
        batcher.drawSprite(32, 32, 64, 64, buttonRegion);
        batcher.endBatch();

        gl.glDisable(GL10.*GL_BLEND*);
        gl.glDisable(GL10.*GL_TEXTURE_2D*);
    }

present()方法非常简单,这要归功于我们在所有这些小助手类中所做的工作。我们从一些常见的事情开始,比如清空屏幕和设置视窗。接下来,我们告诉 EulerCamera 实例设置投影矩阵和模型-视图矩阵。从这一点开始,我们可以在屏幕上渲染任何应该是 3D 的东西。在此之前,我们启用深度测试、纹理和光照。接下来,我们绑定箱子纹理和立方体顶点,并启用点光源。请注意,我们只绑定纹理和立方体顶点一次,因为我们将在渲染所有板条箱时重用它们。这和我们在第八章的 BobTest 中使用的技巧是一样的,当时我们想通过减少状态变化来加速渲染。

下一段代码只是通过一个简单的嵌套 for 循环绘制了网格形式的 25 个立方体。因为我们必须将模型-视图矩阵乘以一个转换矩阵,以将立方体顶点放在特定的位置,所以我们还必须使用 glPushMatrix()和 glPopMatrix(),这样我们就不会破坏也存储在模型-视图矩阵中的相机矩阵。

一旦我们完成了立方体的渲染,我们就解除立方体顶点的绑定,并禁用光照和深度测试。这是至关重要的,因为我们现在要呈现带有按钮的 2D UI 覆盖图。因为按钮实际上是圆形的,我们也启用混合来使纹理的边缘透明。

呈现按钮的方式与我们在 Super Jumper 中呈现 UI 元素的方式相同。我们告诉 Camera2D 实例设置视口和矩阵(我们实际上不需要在这里再次设置视口;随意“优化”这个方法)并告诉 SpriteBatcher 我们要渲染一个 sprite。我们在通过 guiCamera 实例设置的 480×320 坐标系中,在(32,32)处渲染完整的按钮纹理。

最后,我们只需禁用之前启用的最后几个状态,混合和纹理。

    @Override
    public void pause() {
    }

    @Override
    public void dispose() {
    }

}

类的其余部分仍然只是 pause()和 dispose()的一些存根方法。图 11-11 显示了这个小程序的输出。

9781430246770_Fig11-11.jpg

图 11-11。第一人称射击游戏控件的简单例子,为了简单起见没有多点触摸

很不错,对吧?由于我们的助手类为我们做了出色的工作,它也不需要很多代码。现在,增加多点触摸支持当然会很棒。这里有一个提示:不要像刚才看到的例子那样使用轮询,而是使用实际的触摸事件。在“触地”事件中,检查按钮是否被按下。如果是,将与其相关联的指针 ID 标记为不能产生滑动手势,直到相应的“向上触摸”事件被发出信号。来自所有其他指针 id 的触摸事件可以被解释为滑动手势!

一台取景相机

游戏中常见的第二种相机是简单的观察相机。其定义如下:

  • 太空中的位置。
  • 向上的向量。把它想象成一个箭头,当你把相机*放在水*面上时,如果你在相机背面贴上“此面朝上”的标签,你就会看到这个箭头。
  • 空间中的观察位置,或者方向向量。我们将使用前者。
  • 以度为单位的视野。
  • 视口纵横比。
  • *剪裁*面距离和远剪裁*面距离。

注视相机和欧拉相机之间的唯一区别是我们对相机的方向进行编码的方式。在这种情况下,我们通过上方向向量和观察位置来指定方向。让我们为这种类型的相机编写一个助手类。清单 11-11 显示了代码。

清单 11-11。LookAtCamera.java 的 ??,一个没有花里胡哨的简单取景相机

package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GL10;

import android.opengl.GLU;

import com.badlogic.androidgames.framework.math.Vector3;

public class LookAtCamera {
    final Vector3 position;
    final Vector3 up;
    final Vector3 lookAt;
    float fieldOfView;
    float aspectRatio;
    float near;
    float far;
    public LookAtCamera(float fieldOfView, float aspectRatio, float near, float far) {
        this .fieldOfView = fieldOfView;
        this .aspectRatio = aspectRatio;
        this .near = near;
        this .far = far;

        position = new Vector3();
        up = new Vector3(0, 1, 0);
        lookAt = new Vector3(0,0,-1);
    }

    public Vector3 getPosition() {
        return position;
    }

    public Vector3 getUp() {
        return up;
    }

    public Vector3 getLookAt() {
        return lookAt;
    }

    public void setMatrices(GL10 gl) {
        gl.glMatrixMode(GL10.*GL_PROJECTION*);
        gl.glLoadIdentity();
        GLU.*gluPerspective*(gl, fieldOfView, aspectRatio, near, far);
        gl.glMatrixMode(GL10.*GL_MODELVIEW*);
        gl.glLoadIdentity();
        GLU.*gluLookAt*(gl, position.x, position.y, position.z, lookAt.x, lookAt.y, lookAt.z, up.x, up.y, up.z);
    }

}

这里没有真正的惊喜。我们只是将 position、up 和 lookAt 值存储为 Vector3 实例,以及 EulerCamera 类中的透视投影参数。此外,我们提供了几个 getters,以便我们可以修改相机的属性。唯一有趣的方法是 setMatrices(),但即使这样对我们来说也是过时的。我们首先基于视野、纵横比以及*裁剪*面和远裁剪*面的距离将投影矩阵设置为透视投影矩阵。然后,我们通过 gluLookAt()设置模型-视图矩阵,使其包含摄像机位置和方向矩阵,如第十章所述。这实际上会产生一个矩阵,非常类似于我们在 EulerCamera 示例中“手工制作”的矩阵。它还会围绕相机旋转对象,而不是反过来。然而,gluLookAt()方法的良好接口使我们免受那些愚蠢的事情,比如颠倒位置或角度。

事实上,我们可以像使用 EulerCamera 实例一样使用这个相机。我们所需要做的就是通过从观察点减去相机的位置来创建一个方向向量,并将其归一化。然后,我们把这个方向向量旋转偏航角和俯仰角。最后,我们将新的观察位置设置为摄像机的位置,并添加方向向量。两种方法都会产生完全相同的变换矩阵。这只是处理相机方向的两种不同方式。

我们将避免为 LookAtCamera 类编写显式示例,因为该接口非常简单。我们将在本书的最后一个游戏中使用它,让它跟随一艘整洁的小飞船!如果您想稍微试验一下,可以将它添加到我们之前编写的 LightTest 中,或者修改 EulerCameraTest,使 LookAtCamera 类可以像第一人称射击相机一样使用,如前一段所述。

装载模型

至少可以说,在代码中定义像我们的立方体这样的模型是非常麻烦的。创建这些类型的模型的更好的方法是使用特殊的软件,该软件允许所见即所得地创建复杂的表单和对象。有很多软件可以完成这项任务:

  • Blender ,一个在很多游戏和电影制作中使用的开源项目。它非常能干和灵活,但也有点吓人。
  • 我们的首选武器,也是开源的。我们用它对静态物体进行简单的低多边形建模。这非常简单,但却完成了任务。
  • 3D Studio Max ,业界事实上的标准之一。这是一个商业产品,但也有学生版。
  • 玛雅,另一个业界宠儿。这也是一个商业产品,但有一些价格选择,可能适合较小的钱包。

这只是在野外选择的更受欢迎的选项。教你如何使用其中的一个已经超出了本书的范围。然而,不管你用什么软件,在某个时候你会把你的作品保存成某种格式。其中一种格式是波前 OBJ,这是一种非常古老的纯文本格式,可以很容易地解析并转换为我们的 Vertices3 实例之一。

波前 OBJ 格式

我们将为这种格式的子集实现一个加载器。我们的加载器将支持仅由三角形组成的模型,并且可以选择包含纹理坐标和法线。OBJ 格式也支持任意凸多边形的存储,但我们不会深入探讨。无论你只是简单地找到一个 OBJ 模型还是创建自己的模型,只要确保它是三角形的就行了——这意味着它只由三角形组成。

OBJ 格式是基于行的。下面是我们将要处理的语法部分:

  • v x y z:v 表示线条对顶点位置进行编码,而 x、y 和 z 是编码为浮点数的坐标。
  • VN I j k:VN 表示直线编码顶点法线,I、j 和 k 是顶点法线的 x、y 和 z 分量。
  • vt u v:vt 表示线条编码一个纹理坐标对,u 和 v 是纹理坐标。
  • f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3:f 表示直线编码一个三角形。每个 v/vt/vn 块包含三角形的单个顶点的位置、纹理坐标和顶点法线的索引。这些索引是相对于顶点位置、纹理坐标和顶点法线的,它们是先前由其他三种线条格式定义的。vt 和 vn 索引可以省略,以指示三角形的特定顶点没有纹理坐标或法线。

我们将忽略任何不以 v、vn、vt 或 f 开头的行;如果任何允许的行没有遵循刚才描述的格式,我们也将输出一个错误。单行中的项目由空白分隔,空白可以包括空格、制表符等。

注意OBJ 格式可以存储比我们在这里解析的更多的信息。我们可以只解析这里显示的语法,忽略其他任何东西,只要模型是三角化的,并且具有法线和纹理坐标。

这是一个非常简单的 OBJ 格式的纹理三角形的例子:

v -0.5 -0.5 0
v 0.5 -0.5 0
v 0 0.5 0
vn 0 0 1
vn 0 0 1
vn 0 0 1
vt 0 1
vt 1 1
vt 0.5 0
f 1/1/1 2/2/2 3/3/3

请注意,顶点位置,纹理坐标和法线不需要以如此好的顺序定义。如果保存文件的软件选择这样做,它们可能会纠缠在一起。

f 语句中给出的索引是从 1 开始的,而不是从 0 开始的(如 Java 数组的情况)。一些软件有时甚至输出负指数。这是 OBJ 格式规范所允许的,但却是一个大问题。我们必须跟踪到目前为止已经加载了多少顶点位置、纹理坐标或顶点法线,然后根据索引指示的顶点属性,将该负索引添加到相应的位置、顶点坐标或法线的数量中。

实现 OBJ 加载器

我们的攻击计划是将文件完全加载到内存中,并每行创建一个字符串。我们还将为将要加载的所有顶点位置、纹理坐标和法线创建临时浮动数组。它们的大小将等于 OBJ 文件中的行数乘以每个属性的组件数;也就是说,两个用于纹理坐标,三个用于法线。通过这样做,我们超出了存储数据所需的必要内存量,但这仍然比每次填满数组时都分配新数组要好。

我们也为定义每个三角形的索引做同样的事情。虽然 OBJ 格式确实是一种索引格式,但我们不能将这些索引直接用于我们的 Vertices3 类。这是因为一个顶点属性可能会被多个顶点重用,所以在 OpenGL ES 中不允许一对多的关系。因此,我们将使用一个无索引的顶点 3 实例,并简单地复制顶点。为了我们的需要,这是可以的。

让我们看看如何实现所有这些。清单 11-12 显示了代码。

清单 11-12。ObjLoader.java 的??,一个用于加载 OBJ 格式子集的简单类

package com.badlogic.androidgames.framework.gl;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;

import com.badlogic.androidgames.framework.impl.GLGame;

public class ObjLoader {
    public static Vertices3 load(GLGame game, String file) {
        InputStream in = null ;
        try {
            in = game.getFileIO().readAsset(file);
            List <String> lines = *readLines*(in);

            float [] vertices = new float [lines.size()* 3];
            float [] normals = new float [lines.size()* 3];
            float [] uv = new float [lines.size()* 2];

            int numVertices = 0;
            int numNormals = 0;
            int numUV = 0;
            int numFaces = 0;

            int[] facesVerts = new int [lines.size() * 3];
            int[] facesNormals = new int [lines.size() * 3];
            int[] facesUV = new int [lines.size() * 3];
            int vertexIndex = 0;
            int normalIndex = 0;
            int uvIndex = 0;
            int faceIndex = 0;

我们做的第一件事是打开一个由 file 参数指定的素材文件的 InputStream。然后,我们使用名为 readLines()的方法(在下面的代码中定义)读入该文件的所有行。根据行数,我们分配浮点数组,这些数组将存储每个顶点位置的 x、y 和 z 坐标,每个顶点法线的 x、y 和 z 分量,以及每个顶点纹理坐标的 u 和 v 分量。由于我们不知道文件中有多少个顶点,我们只是为数组分配了比所需更多的空间。每个顶点属性存储在三个数组的后续元素中。第一个读取顶点的位置在顶点[0]、顶点[1]和顶点[2]中,依此类推。我们也为顶点的三个属性中的每一个跟踪三角形定义中的索引。此外,我们有几个计数器来记录我们已经装载了多少东西。

            for (int i = 0; i < lines.size(); i++) {
                String line = lines.get(i);

接下来我们有一个 for 循环,它遍历文件中的所有行。

                if (line.startsWith("v ")) {
                    String[] tokens = line.split("[ ] + ");
                    vertices[vertexIndex] = Float.*parseFloat*(tokens[1]);
                    vertices[vertexIndex + 1] = Float.*parseFloat*(tokens[2]);
                    vertices[vertexIndex + 2] = Float.*parseFloat*(tokens[3]);
                    vertexIndex += 3;
                    numVertices++;
                    continue ;
                }

如果当前行是顶点位置定义,我们通过空格分割该行,读取 x、y 和 z 坐标,并将其存储在顶点数组中。

                if (line.startsWith("vn ")) {
                    String[] tokens = line.split("[ ] + ");
                    normals[normalIndex] = Float.*parseFloat*(tokens[1]);
                    normals[normalIndex + 1] = Float.*parseFloat*(tokens[2]);
                    normals[normalIndex + 2] = Float.*parseFloat*(tokens[3]);
                    normalIndex += 3;
                    numNormals++;
                    continue ;
                }

                if (line.startsWith("vt")) {
                    String[] tokens = line.split("[ ] + ");
                    uv[uvIndex] = Float.*parseFloat*(tokens[1]);
                    uv[uvIndex + 1] = Float.*parseFloat*(tokens[2]);
                    uvIndex += 2;
                    numUV++;
                    continue ;
                }

我们对法线和纹理坐标做同样的事情。

                if (line.startsWith("f ")) {
                    String[] tokens = line.split("[ ] + ");

                    String[] parts = tokens[1].split("/");
                    facesVerts[faceIndex] = *getIndex*(parts[0], numVertices);
                    if (parts.length > 2)
                        facesNormals[faceIndex] = *getIndex*(parts[2], numNormals);
                    if (parts.length > 1)
                        facesUV[faceIndex] = *getIndex*(parts[1], numUV);
                    faceIndex++;

                    parts = tokens[2].split("/");
                    facesVerts[faceIndex] = *getIndex*(parts[0], numVertices);
                    if (parts.length > 2)
                        facesNormals[faceIndex] = *getIndex*(parts[2], numNormals);
                    if (parts.length > 1)
                        facesUV[faceIndex] = *getIndex*(parts[1], numUV);
                    faceIndex++;

                    parts = tokens[3].split("/");
                    facesVerts[faceIndex] = *getIndex*(parts[0], numVertices);
                    if (parts.length > 2)
                        facesNormals[faceIndex] = *getIndex*(parts[2], numNormals);
                    if (parts.length > 1)
                        facesUV[faceIndex] = *getIndex*(parts[1], numUV);
                    faceIndex++;
                    numFaces++;
                    continue ;
                }

            }

在这段代码中,三角形的每个顶点(这里称为,因为这是 OBJ 格式中使用的术语)由顶点位置、纹理坐标和法向数组的三个索引定义。纹理坐标和法线索引可以被省略,所以我们跟踪这一点。索引也可以是负的,在这种情况下,我们必须将它们添加到目前为止加载的位置/纹理坐标/法线的数量中。这就是 getIndex()方法为我们做的事情。

            float [] verts = new float [(numFaces * 3)
                    * (3 + (numNormals > 0 ? 3 : 0) + (numUV > 0 ? 2 : 0))];

一旦我们加载了所有的顶点位置、纹理坐标、法线和三角形,我们就可以开始组装一个浮动数组,以顶点 3 实例所期望的格式保存顶点。存储这些顶点所需的浮点数量可以很容易地从我们加载的三角形数量以及是否给定了法线和纹理坐标中导出。

            for (int i = 0, vi = 0; i < numFaces * 3; i++) {
                int vertexIdx = facesVerts[i] * 3;
                verts[vi++] = vertices[vertexIdx];
                verts[vi++] = vertices[vertexIdx + 1];
                verts[vi++] = vertices[vertexIdx + 2];

                if (numUV > 0) {
                    int uvIdx = facesUV[i] * 2;
                    verts[vi++] = uv[uvIdx];
                    verts[vi++] = 1 - uv[uvIdx + 1];
                }

                if (numNormals > 0) {
                    int normalIdx = facesNormals[i] * 3;
                    verts[vi++] = normals[normalIdx];
                    verts[vi++] = normals[normalIdx + 1];
                    verts[vi++] = normals[normalIdx + 2];
                }

            }

要填充 verts 数组,我们只需遍历所有三角形,获取三角形每个顶点的 vertex 属性,并将它们放入我们通常用于 Vertices3 实例的布局中的 verts 数组。

            Vertices3 model = new Vertices3(game.getGLGraphics(), numFaces * 3,
                    0, false , numUV > 0, numNormals > 0);
            model.setVertices(verts, 0, verts.length);
            return model;

我们做的最后一件事是实例化 vertics 3 实例并设置顶点。

        } catch (Exception ex) {
            throw new RuntimeException("couldn't load '" + file + "'", ex);
        } finally {
            if (in != null )
                try {
                    in.close();
                } catch (Exception ex) {
                }

        }

    }

方法的其余部分只是做一些异常处理和关闭 InputStream。

    static int getIndex(String index, int size) {
        int idx = Integer.*parseInt*(index);
        if (idx < 0)
            return size + idx;
        else
            return idx - 1;
    }

getIndex()方法获取一个给定的三角形定义中顶点属性的索引,以及到目前为止加载的属性的数量,并返回一个适合引用我们的一个工作数组中的属性的索引。

    static List <String> readLines(InputStream in) throws IOException {
        List <String> lines = new ArrayList <String>();

        BufferedReader reader = new BufferedReader( new InputStreamReader(in));
        String line = null ;
        while ((line = reader.readLine()) != null )
            lines.add(line);
        return lines;
    }

}

最后,还有 readLines()方法,它只读取文件的每一行,并将所有这些行作为字符串列表返回。

为了从素材中加载 OBJ 文件,我们可以如下使用 obj loader:

Vertices3 model = ObjLoader.load(game, "mymodel.obj");

在所有这些指数变戏法之后非常简单,对吗?为了渲染这个顶点 3 实例,我们需要知道它有多少个顶点。让我们再一次扩展 Vertices3 类,添加两个方法来返回实例中当前定义的顶点数和索引数。清单 11-13 显示了代码。

清单 11-13。摘自 Vertices3.java;获取顶点和指数的数量

public int getNumIndices() {
    return indices.limit();
}

public int getNumVertices() {
    return vertices.limit()/ (vertexSize / 4);
}

对于索引的数量,我们只返回存储索引的短缓冲区的限制。对于顶点的数量,我们做同样的事情。然而,由于限制是在 FloatBuffer 中定义的浮点数中报告的,所以我们必须用顶点大小来除它。因为我们在 vertexSize 中存储了字节数,所以我们将该成员除以 4。

使用 OBJ 装载机

为了演示 OBJ 加载器,让我们重写前面的示例,并创建一个名为 ObjTest 的新测试和一个 ObjScreen。复制上一个示例中的所有代码,仅更改负责创建多维数据集的 ObjScreen 类的构造函数中的代码行:

cube = ObjLoader.*load*(glGame, "cube.obj");

我们没有使用 createCube()方法(我们已经删除了该方法),而是直接从一个名为 cube.obj 的 OBJ 文件中加载一个模型。就像所有其他素材一样,您可以在 SVN 知识库中找到它。它与手工制作的版本具有相同的顶点位置、纹理坐标和法线。毫不奇怪,当你运行 ObjTest 时,它看起来和我们的 EulerCameraTest 一模一样。因此,我们将免去你的强制性截图。

加载模型的一些注意事项

对于我们将在下一章中编写的游戏,我们的加载器已经足够了,但是还不够健壮。有一些警告:

  • Android 中的字符串处理天生就很慢。OBJ 格式是一种纯文本格式,因此需要大量的解析。这将对加载时间产生负面影响。我们可以通过将我们的 OBJ 模型转换成定制的二进制格式来解决这个问题。例如,我们可以只序列化我们在 ObjLoader.load()方法中填充的 verts 数组。
  • OBJ 格式有很多我们没有利用的特性。如果您想扩展我们的简单加载器,请在 Web 上查找格式规范。添加更多功能应该很容易。
  • OBJ 文件通常伴随着所谓的素材文件。该文件定义了 OBJ 文件中顶点组使用的颜色和纹理。我们将不需要这个功能,因为我们知道哪个纹理用于特定的 OBJ 文件。对于一个更健壮的加载器,您还需要查看材质文件规范。

3D 中的一点物理

在第八章中,我们在 2D 开发了一个非常简单的基于质点的物理模型。好消息是:在 3D 中一切都一样!

  • 位置现在是 3D 矢量,而不是 2D 矢量。我们只需添加一个 z 坐标。
  • 每个轴上的速度仍然用米/秒来表示。我们只是为 z 轴增加了一个组件!
  • 每个轴上的加速度仍然以米/秒*方(m/s 2 表示。同样,我们只需添加另一个坐标。

在第八章中,我们描述物理模拟更新的伪代码是这样的:

Vector2 position = new Vector2();
Vector2 velocity = new Vector2();
Vector2 acceleration = new Vector2(0, -10);
while (simulationRuns) {
   float deltaTime = getDeltaTime();
   velocity.add(acceleration.x * deltaTime, acceleration.y * deltaTime);
   position.add(velocity.x * deltaTime, velocity.y * deltaTime);
}

我们可以通过简单地将 Vector2 实例与 Vector3 实例进行交换来将其转化为 3D 空间:

Vector3 position = new Vector3();
Vector3 velocity = new Vector3();
Vector3 acceleration = new Vector3(0, -10, 0);
while (simulationRuns) {
   float deltaTime = getDeltaTime();
   velocity.add(acceleration.x * deltaTime, acceleration.y * deltaTime, acceleration.z * deltaTime);
   position.add(velocity.x * deltaTime, velocity.y * deltaTime, velocity.z * deltaTime);
}

这就是它的全部!这个简单的物理模型对于许多简单的 3D 游戏来说已经足够了。在本书的最终游戏中,由于游戏中物体的性质,我们甚至不会使用任何加速。

当然,更复杂的 3D 物理(和 2D)更难实现。为此,您通常希望使用第三方库,而不是自己重新发明轮子。Android 的问题是基于 Java 的解决方案太慢,因为计算量太大。Android 版 2D 物理有一些解决方案,通过 Java 原生接口(JNI)包装了像 Box2D 这样的原生 C++ 库,为 Java 应用提供原生 API。对于 3D 物理,有一个叫做 Bullet 的库。然而,这个库还没有任何可用的 JNI 绑定。不过,这些话题已经超出了本书的范围,而且在很多情况下,我们不需要任何复杂的刚体物理学。

3D 中的碰撞检测和对象表示

在第八章中,我们讨论了物体表示和碰撞检测之间的关系。我们努力使我们的游戏世界对象尽可能独立于它们的图形表示。相反,我们想根据它们的边界形状、位置和方向来定义它们。位置和方向不是大问题:我们可以将前者表示为 Vector3 实例,将后者表示为绕 x、y 和 z 轴的旋转(注意在第十章中提到的潜在的万向锁定问题)。让我们来看看边界形状。

3D 中的边界形状

就边界形状而言,和 2D 编程一样,我们也有大量的选择。图 11-12 显示了 3D 编程中一些比较流行的边界形状。

9781430246770_Fig11-12.jpg

图 11-12。各种边界形状。从左到右:三角形网格、轴对齐的边界框和边界球体

  • 三角形网格:尽可能紧密地约束物体。然而,基于三角形网格碰撞两个对象的计算量很大。
  • 轴对齐的边界框:松散地限制对象。它的计算量比三角形网格要少得多。
  • 包围球:这使得物体的边界更加模糊。这是检查冲突的最快方法。

三角形网格和边界框的另一个问题是,每当我们旋转或缩放对象时,我们都必须重新定向它们,就像在 2D 一样。另一方面,如果我们旋转一个物体,包围球不需要任何修改。如果我们缩放一个物体,我们只需要缩放球体的半径,这是一个简单的乘法。

包围球重叠测试

三角形网格和包围盒碰撞检测的数学可能非常复杂。对于我们的下一个游戏,边界球会做得很好。还有一个我们可以应用的小技巧,我们已经在 Super Jumper 中使用过了:为了让边界球更好地拟合,我们让它比图形表示小一些。图 11-13 显示了宇宙飞船的情况。

9781430246770_Fig11-13.jpg

图 11-13。缩小包围球以更好地适应物体

当然,这是一个非常廉价的技巧,但事实证明,在许多情况下,它足以维持大多数正确碰撞检测的假象。

那么我们如何让两个球体互相碰撞呢?或者说,我们如何测试重叠?它的工作原理与圆的情况完全一样!我们所需要做的就是测量一个球的中心到另一个球的中心的距离。如果这个距离小于两个球的半径之和,那么我们就有碰撞。让我们创建一个简单的球体类。清单 11-14 显示了代码。

清单 11-14。Sphere.java??,一个简单的包围球

package com.badlogic.androidgames.framework.math;
public class Sphere {
    public final Vector3 center = new Vector3();
    public float radius;
    public Sphere(float x, float y, float z, float radius) {
        this .center.set(x,y,z);
        this .radius = radius;
    }

}

这与我们在第八章的中的 Circle 类中使用的代码相同。我们改变的只是保持中心的向量,现在是向量 3 而不是向量 2。

让我们用一些方法来扩展 OverlapTester 类,以检查两个球体的重叠,并测试一个点是否在球体内部。清单 11-15 显示了代码。

清单 11-15。摘自 OverlapTester.java;增加球体测试方法

public static boolean overlapSpheres(Sphere s1, Sphere s2) {
      float distance = s1.center.distSquared(s2.center);
      float radiusSum = s1.radius + s2.radius;
      return distance <= radiusSum * radiusSum;
}

public static boolean pointInSphere(Sphere c, Vector3 p) {
    return c.center.distSquared(p) < c.radius * c.radius;
}

public static boolean pointInSphere(Sphere c, float x, float y, float z) {
    return c.center.distSquared(x, y, z) < c.radius * c.radius;
}

这和圆圈重叠测试中的代码完全相同。我们只使用球体的中心,它是一个 Vector3 实例,而不是一个 Vector2 实例,就像圆的情况一样。

注意整本书都在讨论 3D 碰撞检测。如果你想深入那个相当有趣的世界,我们推荐克里斯特·埃里克森(Morgan Kaufmann,2005)的《?? 实时碰撞检测 》一书。应该是任何一个有自尊心的游戏开发者的架子上!

3D 游戏对象与 dynamic cgameobject 3d

现在我们有了一个很好的 3D 对象的边界形状,我们可以很容易地编写我们在 2D 使用的 GameObject 和 DynamicGameObject 类的等价物。我们只是用 Vector3 实例替换任何 Vector2 实例,并使用 Sphere 类代替 Rectangle 类。清单 11-16 显示了 GameObject3D 类。

清单 11-16。GameObject3D.java,代表一个有位置和边界的简单物体

package com.badlogic.androidgames.framework;

import com.badlogic.androidgames.framework.math.Sphere;
import com.badlogic.androidgames.framework.math.Vector3;

public class GameObject3D {
    public final Vector3 position;
    public final Sphere bounds;
    public GameObject3D(float x, float y, float z, float radius) {
        this .position = new Vector3(x,y,z);
        this .bounds = new Sphere(x, y, z, radius);
    }

}

这段代码非常简单,您可能不需要任何解释。唯一的问题是我们必须存储相同的位置两次:一次作为 GameObject3D 类中的位置成员,另一次在 GameObject3D 类中包含的 Sphere 实例的位置成员中。这有点难看,但是为了清楚起见,我们将坚持这样做。

从这个类派生一个 DynamicGameObject3D 类也很简单。清单 11-17 显示了代码。

清单 11-17。DynamicGameObject3D.java,其动态相当于 GameObject3D

package com.badlogic.androidgames.framework;

import com.badlogic.androidgames.framework.math.Vector3;

public class DynamicGameObject3D extends GameObject3D {
    public final Vector3 velocity;
    public final Vector3 accel;

    public DynamicGameObject3D(float x, float y, float z, float radius) {
        super (x, y, z, radius);
        velocity = new Vector3();
        accel = new Vector3();
    }

}

我们再次用 Vector3 实例替换任何 Vector2 实例,开心地笑了。

在 2D,我们不得不努力思考我们的对象的图形表示(以像素为单位)和我们的世界模型中使用的单位之间的关系。在 3D 中,我们可以摆脱这一点!我们从 OBJ 文件中加载的 3D 模型的顶点可以用我们想要的任何单位系统来定义。我们不再需要将像素转换为世界单位,反之亦然。这使得在 3D 中工作变得稍微容易一些。我们只需要训练我们的艺术家,让他或她为我们提供与我们世界的单位系统成比例的模型。

摘要

同样,我们已经揭开了游戏编程世界中的许多秘密。我们谈了一点 3D 矢量,它和 2D 的矢量一样简单易用。总的主题是:加个 z 坐标就行了!我们还看了一下 OpenGL ES 中的光照。有了我们编写的表示材质和光源的辅助类,在场景中设置照明就相当简单了。为了更好的性能和更少的图形伪影,我们还实现了简单的 mipmapping 作为纹理类的一部分。我们还探索了简单的欧拉和注视相机的实现,使用很少的代码和矩阵类的一点帮助。

由于用代码手工创建 3D 网格很繁琐,我们还研究了一种最简单也是最流行的 3D 文件格式:波前 OBJ。我们重新审视了我们简单的物理模型,并将其转移到 3D 领域,结果证明这就像创建 3D 向量一样简单。

我们议程的最后一点是弄清楚如何处理 3D 中的边界形状和对象表示。鉴于我们的适度需求,我们为这两个问题找到了非常简单的解决办法,这些办法与我们在 2D 使用的办法非常相似,甚至完全相同。

虽然 3D 编程还有很多我们无法在这里介绍的内容,但是你现在已经对编写一个 3D 游戏需要什么有了一个很好的想法。最大的体会是,2D 游戏和 3D 游戏之间确实没有太大的区别(当然,在一定程度上是复杂的)。我们再也不用害怕 3D 了!在第十二章中,我们将使用我们的新知识来编写本书的最后一个游戏:机器人入侵者!*

十二、安卓入侵者:大结局

我们终于准备好为这本书创作最后一个游戏了。这一次我们将开发一个简单的动作/街机游戏,采用前两章讨论的技术,改编一个旧的经典并赋予它一个漂亮的 3D 外观。

核心游戏机制

正如你可能已经从这一章的标题中猜到的,我们即将实现太空入侵者的一个变种,一个 2D 游戏的原始形式(如图图 12-1 )。

9781430246770_Fig12-01.jpg

图 12-1。原版太空入侵者街机游戏

这里有一个小惊喜:我们将继续留在 2D,大部分时间。我们所有的对象都将具有 3D 边界,其形式为 3D 空间中的边界球体和位置。然而,运动只会发生在 x-z *面,这使得某些事情变得简单了一些。图 12-2 展示了改编后的 3D 太空入侵者世界。实体模型是用 Wings3D 创建的。

9781430246770_Fig12-02.jpg

图 12-2。3D 游戏场地实物模型

现在来定义游戏机制:

  • 一艘船在操场的底部飞行,只能在 x 轴上导航。
  • 运动被限制在操场的边界。当船到达游戏区域的左边或右边界时,它简单地停止移动。
  • 我们想让玩家选择使用加速度计来导航船只,或者使用屏幕上的按钮来左右移动。
  • 这艘船每秒能发射一发子弹。玩家按下屏幕上的按钮射击。
  • 在游戏场的底部,有三个盾牌,每个盾牌由五个立方体组成。
  • 入侵者以图 12-2 所示的配置开始,然后向左移动一段距离,再向 z 正方向移动一段距离,再向右移动一段距离。总共将有 32 个入侵者,组成四排八个入侵者。
  • 入侵者会随机射击。
  • 当一颗子弹击中船时,船就会爆炸并失去一条生命。
  • 当子弹击中盾牌时,盾牌会永久消失。
  • 一枪命中入侵者,入侵者爆炸,分数增加 10 分。
  • 当所有入侵者被消灭后,新一波入侵者出现,移动速度略快于上一波。
  • 当入侵者直接撞上一艘船,游戏就结束了。
  • 当这艘船失去了所有的生命,游戏就结束了。

这不是一个压倒性的名单,是吗?所有操作基本上都可以在 2D 进行(在 x-z *面而不是 x-y *面)。然而,我们仍然会使用 3D 包围球。如果你愿意,你可以在我们完成第一次迭代后将游戏扩展到真正的 3D。现在,让我们来看看背景故事和艺术风格。

发展背景故事和选择艺术风格

我们将称这个游戏为安卓入侵者,以示对安卓和太空入侵者的尊重。那是便宜的,但是我们还不打算生产一个 AAA 头衔。在传统的经典射击游戏中,像末日,背景故事会很少。事情是这样的:

来自外太空的入侵者攻击地球。你是唯一有能力击退邪恶力量的人。

这对《毁灭战士》和《??》《地震》来说已经足够好了,所以对《安卓入侵者》来说也足够好了。

当涉及到 GUI 时,艺术风格将有点复古,使用我们在第九章中为超级 Jumper 使用的相同的老式字体。我们的游戏世界本身将以花哨的 3D 显示,带有纹理和光照的 3D 模型。图 12-3 显示了游戏屏幕的样子。

9781430246770_Fig12-03.jpg

图 12-3。安卓入侵者样机。想不到!

音乐将是摇滚/金属混合,音效将与场景相匹配。

定义屏幕和过渡

由于我们已经实现了两次帮助屏幕和高分屏幕,分别是在第六章第一章的《提名先生》和第九章第三章的《超级跳跃者》中,我们将不再为 Android 入侵者这样做;这总是相同的原则,无论如何,一旦游戏屏幕出现,玩家应该立即知道该做什么。相反,我们将添加一个设置屏幕,允许玩家选择输入类型(多点触摸或加速度计)以及禁用或启用声音。以下是安卓入侵者的屏幕列表:

  • 主屏幕上有一个标志和播放和设置选项。
  • 立即开始游戏的游戏画面(没有更多的准备?信号!)并且还处理暂停状态(带有恢复和退出选项),并且一旦船上没有更多的生命就显示游戏结束。
  • 显示代表配置选项(多点触控、加速度计和声音)的三个图标的设置屏幕。

这与我们前两场比赛的情况非常相似。图 12-4 显示了所有的屏幕和过渡。

9781430246770_Fig12-04.jpg

图 12-4。安卓入侵者的画面和过渡

定义游戏世界

在 3D 中工作的乐趣之一是我们摆脱了像素的束缚。我们可以用任何我们想要的单位来定义我们的世界。我们概述的游戏机制规定了一个有限的游戏领域,所以让我们从定义这个领域开始。图 12-5 显示了我们游戏世界中游戏场地的面积。

9781430246770_Fig12-05.jpg

图 12-5。比赛场地

我们世界的一切都将发生在 x-z *面的这个边界内。坐标将限制在 x 轴从 14 到 14,z 轴从 0 到 15。船只将能够沿着比赛场地的底边从(14,0,0)移动到(14,0,0)。

接下来,我们定义世界中物体的大小:

  • 飞船的半径将为 0.5 个单位。
  • 入侵者的半径会稍微大一点,0.75 个单位。这使得它们更容易被击中。
  • 每个屏蔽块的半径为 0.5 个单位。
  • 每个镜头的半径为 0.1 个单位。

我们是如何得出这些价值的?我们简单地将我们的游戏世界分成一个单元一个单元的单元,并确定每个游戏元素相对于游戏场地的大小应该有多大。通常,您可以通过一些实验或者使用米这样的真实单位来获得这些测量值。在安卓入侵者中,我们会使用无名单位。

当然,我们刚刚定义的半径可以直接转化为边界球。在屏蔽块和飞船的情况下,我们可以欺骗一下,因为它们显然不是球形的。多亏了我们世界的 2D 属性,我们才得以逃脱这个小把戏。在入侵者的例子中,球体实际上是一个很好的*似。

我们还必须定义运动物体的速度:

  • 船能以每秒 20 单位的最大速度移动。在超级 Jumper 中,我们通常会有一个较低的速度,因为它取决于设备的倾斜。
  • 入侵者最初每秒移动 1 个单位。每一波都会稍微提高这个速度。
  • 镜头以每秒 10 个单位的速度移动。

有了这些定义,我们就可以开始实现我们游戏世界的逻辑了。在我们开始实现逻辑之前,我们将首先创建我们的素材。

创建素材

正如在我们以前的游戏中,我们有两种图形素材:UI 元素,如徽标和按钮,以及游戏中不同类型对象的模型。

用户界面素材

我们将再次创建与某个目标分辨率相关的 UI 素材。我们的游戏将在横向模式下运行,因此我们只需选择 480×320 像素的目标分辨率。图 12-4 中的屏幕显示了我们用户界面中已经有的元素:一个标志、不同的菜单项、几个按钮和一些文本。对于文本,我们将重用超级跳线的字体。我们已经在之前的游戏中为所有这些元素做了合成,你已经知道将它们放入纹理贴图集对性能有好处。我们将为 Android Invaders 使用的纹理地图,包含所有的 UI 元素(以及游戏中所有屏幕的字体),如图图 12-6 所示。

9781430246770_Fig12-06.jpg

图 12-6。UI 元素图集,有按钮,logo,字体。它存储在 512×512 像素的 items.png 文件中

这和我们在《超级跳线》中使用的概念基本相同。我们也有一个将在所有屏幕上呈现的背景。图 12-7 显示了该图像。

9781430246770_Fig12-07.jpg

图 12-7。背景存储在 background,512×512 像素

如前面的图 12-4 所示,我们只需要使用该图像的左上角区域来渲染一个完整的帧(480×320 像素)。

这些都是我们需要的 UI 元素。现在我们可以看看我们的 3D 模型和它们的纹理。

游戏素材

正如在第十一章中提到的,详细介绍如何用 Wings3D 这样的软件创建 3D 模型超出了本书的范围。如果你想创建自己的模型,选择一个应用来工作,并通过一些教程,这些教程通常可以在互联网上免费获得。对于 Android Invaders 的模型,我们使用 Wings3D 并简单地将它们导出为 OBJ 格式,我们可以用我们的框架加载它。所有模型都只由三角形组成,并且有纹理坐标和法线。对于一些模型,我们不需要纹理坐标,但是拥有它们也无妨。

船舶模型及其纹理如图图 12-8 所示。

9781430246770_Fig12-08.jpg

图 12-8。wings 3d 中的船舶模型(ship.obj)及其纹理(ship.png,256×256 像素)

至关重要的是,图 12-8 中的船大致具有上一节中概述的“半径”。我们不需要缩放任何东西,也不需要将尺寸和位置从一个坐标系转换到另一个坐标系。船的模型是用和它的边界球相同的单位定义的!

图 12-9 显示了入侵者模型及其纹理。

9781430246770_Fig12-09.jpg

图 12-9。入侵者模型(invader.obj)及其纹理(invader.png,256×256 像素)

入侵者模型遵循与船只模型相同的原则。我们有一个 OBJ 文件存储顶点位置,纹理坐标,法线和面,以及一个纹理图像。

盾牌块和镜头被建模为立方体,并存储在文件 shield.obj 和 shot.obj 中。尽管它们被分配了纹理坐标,但我们在渲染它们时实际上并不使用纹理映射。我们只是用特定的颜色(蓝色的屏蔽块,黄色的镜头)将它们绘制成(半透明的)物体。

最后,我们有我们的爆炸(再次参见图 12-3 )。我们如何对它们建模?我们没有。我们做了我们在 2D 做的事情,简单地在我们的 3D 世界中绘制一个具有适当 z 位置的矩形,用包含爆炸动画的纹理图像的帧对其进行纹理映射。这和我们在《超级跳伞》中使用的动画物体的原理是一样的。唯一的区别是,我们在小于 0 的 z 位置(爆炸对象所在的位置)绘制矩形。我们甚至可以滥用 SpriteBatcher 类来做到这一点。OpenGL ES 万岁!图 12-10 显示了纹理。

9781430246770_Fig12-10.jpg

图 12-10。爆炸动画纹理(explode.png,256×256 像素)

动画的每一帧大小为 64×64 像素。我们需要做的就是为每一帧生成纹理区域,并将它们放入一个动画实例中,我们可以使用它来获取给定动画时间的正确帧,就像我们在《超级跳线》中对松鼠和鲍勃动画所做的那样。

声音和音乐

对于音效,我们再次使用了 as3sfxr。我们在网上找到了爆炸音效。这是一个公共域音效,所以我们可以在 Android Invaders 中使用它。对于我们自己版本的 Android Invaders,我们用真实的乐器自己录制了音乐。是的——那很老派。以下是安卓入侵者的音频文件列表:

  • click.ogg:用于菜单项/按钮的咔哒声
  • 射击声:射击声
  • 爆炸:一种爆炸的声音
  • music.mp3:我们为 Android Invaders 写的摇滚/金属歌曲

行动(或活动、袭击)计划

有了我们的游戏机制、设计和素材,我们可以开始编码了。像往常一样,我们创建一个新项目,复制我们所有的框架代码,确保我们有一个合适的清单和图标,等等。到目前为止,您应该已经很好地掌握了如何进行设置。Android 入侵者的所有代码都会放在包 com . badlogic . androidgames . androidinvaders 中,素材存放在 Android 项目的 assets/目录下。我们使用了与超级 Jumper 中相同的通用结构:一个从 GLGame 派生的默认活动;实现不同屏幕和过渡的几个 GLScreen 实例,如图 12-4 所示;用于加载素材和存储设置的类,以及用于游戏对象的类;还有一个渲染类,可以用 3D 绘制我们的游戏世界。让我们从素材类开始。

素材类别

好吧,我们之前在《提名先生》和《超级跳伞者》中已经这样做过了,所以不要期待任何惊喜。清单 12-1 显示了素材类的代码。

清单 12-1。【Assets.java】,一如既往地装载和储存素材

package com.badlogic.androidgames.androidinvaders;

import com.badlogic.androidgames.framework.Music;
import com.badlogic.androidgames.framework.Sound;
import com.badlogic.androidgames.framework.gl.Animation;
import com.badlogic.androidgames.framework.gl.Font;
import com.badlogic.androidgames.framework.gl.ObjLoader;
import com.badlogic.androidgames.framework.gl.Texture;
import com.badlogic.androidgames.framework.gl.TextureRegion;
import com.badlogic.androidgames.framework.gl.Vertices3;
import com.badlogic.androidgames.framework.impl.GLGame;

public class Assets {
    public static Texture *background*;
    public static TextureRegion *backgroundRegion*;
    public static Texture *items*;
    public static TextureRegion *logoRegion*;
    public static TextureRegion *menuRegion*;
    public static TextureRegion *gameOverRegion*;
    public static TextureRegion *pauseRegion*;
    public static TextureRegion *settingsRegion*;
    public static TextureRegion *touchRegion*;
    public static TextureRegion *accelRegion*;
    public static TextureRegion *touchEnabledRegion*;
    public static TextureRegion *accelEnabledRegion*;
    public static TextureRegion *soundRegion*;
    public static TextureRegion *soundEnabledRegion*;
    public static TextureRegion *leftRegion*;
    public static TextureRegion *rightRegion*;
    public static TextureRegion *fireRegion*;
    public static TextureRegion *pauseButtonRegion*;
    public static Font *font*;

我们有几个成员存储 UI 元素的纹理,以及背景图像。我们还存储了几个 TextureRegions,以及一个 Font 实例。这涵盖了我们所有的 UI 需求。

    public static Texture *explosionTexture*;
    public static Animation *explosionAnim*;
    public static Vertices3 *shipModel*;
    public static Texture *shipTexture*;
    public static Vertices3 *invaderModel*;
    public static Texture *invaderTexture*;
    public static Vertices3 *shotModel*;
    public static Vertices3 *shieldModel*;

我们使用纹理实例和顶点实例来存储游戏对象的模型和纹理。我们使用一个动画实例来保存爆炸动画的帧。

    public static Music*music*;
    public static Sound *clickSound*;
    public static Sound *explosionSound*;
    public static Sound *shotSound*;

我们使用一个音乐实例和几个声音实例来存储游戏的音频。

    public static void load(GLGame game) {
        *background* = new Texture(game, "background.jpg",true );
        *backgroundRegion* = new TextureRegion(*background*, 0, 0, 480, 320);
        *items* = new Texture(game, "items.png",true );
        *logoRegion* = new TextureRegion(*items*, 0, 256, 384, 128);
        *menuRegion* = new TextureRegion(*items*, 0, 128, 224, 64);
        *gameOverRegion* = new TextureRegion(*items*, 224, 128, 128, 64);
        *pauseRegion* = new TextureRegion(*items*, 0, 192, 160, 64);
        *settingsRegion* = new TextureRegion(*items*, 0, 160, 224, 32);
        *touchRegion* = new TextureRegion(*items*, 0, 384, 64, 64);
        *accelRegion* = new TextureRegion(*items*, 64, 384, 64, 64);
        *touchEnabledRegion* = new TextureRegion(*items*, 0, 448, 64, 64);
        *accelEnabledRegion* = new TextureRegion(*items*, 64, 448, 64, 64);
        *soundRegion* = new TextureRegion(*items*, 128, 384, 64, 64);
        *soundEnabledRegion* = new TextureRegion(*items*, 190, 384, 64, 64);
        *leftRegion* = new TextureRegion(*items*, 0, 0, 64, 64);
        *rightRegion* = new TextureRegion(*items*, 64, 0, 64, 64);
        *fireRegion* = new TextureRegion(*items*, 128, 0, 64, 64);
        *pauseButtonRegion* = new TextureRegion(*items*, 0, 64, 64, 64);
        *font* = new Font(*items*, 224, 0, 16, 16, 20);

load()方法从创建 UI 相关的东西开始。像往常一样,只是一些纹理加载和区域创建。

        *explosionTexture* = new Texture(game, "explode.png",true );
        TextureRegion[] keyFrames = new TextureRegion[16];
        int frame = 0;
        for (int y = 0; y < 256; y += 64) {
            for (int x = 0; x < 256; x += 64) {
                keyFrames[frame++] = new TextureRegion(*explosionTexture*, x, y, 64, 64);
            }
        }
        *explosionAnim* = new Animation(0.1f, keyFrames);

接下来,我们为爆炸动画创建纹理,以及每个帧和动画实例的纹理区域。我们简单地以 64 像素的增量从左上到右下循环,并且每帧创建一个 TextureRegion。然后,我们将所有区域提供给一个动画实例,其帧持续时间为 0.1 秒。

        *shipTexture* = new Texture(game, "ship.png",true );
        *shipModel*  = ObjLoader.*load*(game, "ship.obj");
        *invaderTexture* = new Texture(game, "invader.png",true );
        *invaderModel*  = ObjLoader.*load*(game, "invader.obj");
        *shieldModel*  = ObjLoader.*load*(game, "shield.obj");
        *shotModel*  = ObjLoader.*load*(game, "shot.obj");

接下来,我们加载船、入侵者、盾块和镜头的模型和纹理。这对于我们强大的 ObjLoader 来说非常简单,不是吗?请注意,我们使用纹理的小中见大贴图。

        *music*  = game.getAudio().newMusic("music.mp3");
        *music*.setLooping(true );
        *music*.setVolume(0.5f);
        if (Settings.*soundEnabled*)
            *music*.play();

        *clickSound*  = game.getAudio().newSound("click.ogg");
        *explosionSound*  = game.getAudio().newSound("explosion.ogg");
        *shotSound*  = game.getAudio().newSound("shot.ogg");
    }

在这里,我们加载游戏的音乐和音效。对 Settings 类的引用本质上和《超级 Jumper》和《Nom 先生》中的一样。当我们的游戏在我们马上要实现的 AndroidInvaders 类中启动时,这个方法将被调用一次。一旦所有的资源都被加载,我们可以忘记它们中的大部分,除了纹理,如果游戏暂停然后继续,我们需要重新加载。

    public static void reload() {
        *background*.reload();
        *items*.reload();
        *explosionTexture*.reload();
        *shipTexture*.reload();
        *invaderTexture*.reload();
        if (Settings.*soundEnabled*)
            *music*.play();
    }

这就是 reload()方法的用武之地。我们在 AndroidInvaders.onResume()方法中调用此方法,这样我们的纹理将被重新加载,音乐将被取消暂停。

    public static void playSound(Sound sound) {
        if (Settings.*soundEnabled*)
            sound.play(1);
    }
}

最后,我们有在《超级跳线》中使用的相同的便利方法来减轻回放声音效果的痛苦。当用户禁用声音时,我们在这个方法中不播放任何东西。

注意虽然这种加载和管理素材的方法很容易实现,但是如果您拥有的素材太多,就会变得一团糟。另一个问题是,有时并不是所有的素材都能一次放入内存。对于简单的游戏,比如我们在本书中开发的游戏,这种方法很好。我们也经常在游戏中使用它。对于更大的游戏,你必须考虑更精细的素材管理策略。

设置类

与素材类一样,对于设置类,我们可以在某种程度上重用我们为以前的游戏编写的内容。我们现在可以存储一个额外的布尔值,告诉我们用户是想使用触摸屏还是加速度计来移动船只。我们可以放弃高分支持,因为我们不需要跟踪这一点。(作为练习,您当然可以重新引入高分屏幕并将这些分数保存到 SD 卡中。)清单 12-2 显示了代码。

清单 12-2。Settings.java,老样子,老样子

package com.badlogic.androidgames.androidinvaders;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;

import com.badlogic.androidgames.framework.FileIO;

public class Settings {
    public static boolean *soundEnabled* =true ;
    public static boolean *touchEnabled* =true ;
    public final static String*file*  = ".androidinvaders";

首先,我们存储声音是否启用,以及用户是否希望使用触摸输入来导航船只。这些设置将存储在文件中。SD 卡上的 androidinvaders。

    public static void load(FileIO files) {
        BufferedReader in = null ;
        try {
            in = new BufferedReader(new InputStreamReader(files.readFile(*file*)));
            *soundEnabled*  = Boolean.*parseBoolean*(in.readLine());
            *touchEnabled*  = Boolean.*parseBoolean*(in.readLine());
        }catch (IOException e) {
            // :( It's ok we have defaults
        }catch (NumberFormatException e) {
            // :/ It's ok, defaults save our day
        }finally {
            try {
                if (in != null )
                    in.close();
            }catch (IOException e) {
            }
        }
    }

这一节没有什么我们需要仔细阅读的,真的;我们以前做过这个。我们尝试从 SD 卡上的文件中读取这两个布尔值。如果失败,我们就回到默认值。

    public static void save(FileIO files) {
        BufferedWriter out = null ;
        try {
            out = new BufferedWriter(new OutputStreamWriter(
                    files.writeFile(*file*)));
            out.write(Boolean.*toString*(*soundEnabled*));
            out.write("\n");
            out.write(Boolean.*toString*(*touchEnabled*));
        }catch (IOException e) {
        }finally {
            try {
                if (out != null )
                    out.close();
            }catch (IOException e) {
            }
        }
    }
}

存钱又很无聊。我们只是存储我们所拥有的,如果失败,我们忽略错误。这是另一个需要改进的地方,因为您可能想让用户知道有什么地方出错了。

主要活动

像往常一样,我们有一个从 GLGame 类派生的主活动。它负责在启动时通过调用 Assets.load()加载素材,以及在活动暂停或恢复时暂停和恢复音乐。作为开始屏幕,我们只是返回 MainMenuScreen,我们将很快实现它。我们需要记住的一件事是在清单文件的活动定义中将方向设置为横向。清单 12-3 显示了代码。

清单 12-3。AndroidInvaders.java,主要活动

package com.badlogic.androidgames.androidinvaders;

import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.impl.GLGame;

public class AndroidInvaders extends GLGame {
    boolean firstTimeCreate =true ;

    public Screen getStartScreen() {
        return new MainMenuScreen(this );
    }

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        super.onSurfaceCreated(gl, config);
        if (firstTimeCreate) {
            Settings.*load*(getFileIO());
            Assets.*load*(this );
            firstTimeCreate =false ;
        }else {
            Assets.*reload*();
        }
    }

    @Override
    public void onPause() {
        super.onPause();
        if (Settings.*soundEnabled*)
            Assets.*music*.pause();
    }
}

这和《超级 Jumper》里一模一样。在对 getStartScreen()的调用中,我们返回 MainMenuScreen 的一个新实例,接下来我们将编写这个实例。在 onSurfaceCreated()中,我们确保我们的资源被重新加载,而在 onPause()中,如果音乐正在播放,我们就暂停它。

正如你所看到的,一旦你对如何实现一个简单的游戏有了一个好的想法,有很多事情可以重复。想想如何通过将事情转移到框架中来进一步减少样板代码!

主菜单屏幕

我们已经为之前的游戏写了很多琐碎的画面。安卓入侵者也有一些这样的。原则总是一样的:提供一些 UI 元素来点击和触发转换或配置更改,并显示一些信息。主菜单屏幕仅显示徽标以及播放和设置选项,如前面的图 12-4 所示。触摸其中一个按钮会切换到游戏屏幕或设置屏幕。清单 12-4 显示了代码。

清单 12-4。【MainMenuScreen.java】,主菜单屏幕

package com.badlogic.androidgames.androidinvaders;

import java.util.List;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.gl.Camera2D;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.impl.GLScreen;
import com.badlogic.androidgames.framework.math.OverlapTester;
import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;

public class MainMenuScreen extends GLScreen {
    Camera2D guiCam;
    SpriteBatcher batcher;
    Vector2 touchPoint;
    Rectangle playBounds;
    Rectangle settingsBounds;

像往常一样,我们需要一个摄像机来设置我们的视口和 480×320 像素的虚拟目标分辨率。我们使用 SpriteBatcher 来呈现 UI 元素和背景图像。Vector2 和 Rectangle 实例将帮助我们判断触摸是否点击了按钮。

    public MainMenuScreen(Game game) {
        super(game);

        guiCam = new Camera2D(glGraphics, 480, 320);
        batcher = new SpriteBatcher(glGraphics, 10);
        touchPoint = new Vector2();
        playBounds = new Rectangle(240–112, 100, 224, 32);
        settingsBounds = new Rectangle(240–112, 100–32, 224, 32);
    }

在构造函数中,我们像往常一样设置了摄像机和 SpriteBatcher。我们使用屏幕上两个元素的位置、宽度和高度,以 480×320 的目标分辨率实例化 Vector2 和 Rectangle 实例。

    @Override
    public void update(float deltaTime) {
        List<TouchEvent> events = game.getInput().getTouchEvents();
        int len = events.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = events.get(i);
            if (event.type != TouchEvent.*TOUCH_UP*)
                continue ;

            guiCam.touchToWorld(touchPoint.set(event.x, event.y));
            if (OverlapTester.*pointInRectangle*(playBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                game.setScreen(new GameScreen(game));
            }
            if (OverlapTester.*pointInRectangle*(settingsBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                game.setScreen(new SettingsScreen(game));
            }
        }
    }

在 update()方法中,我们获取触摸事件并检查“touch-up”事件。如果有这样的事件,我们把它的真实坐标转换到摄像机建立的坐标系。剩下要做的就是对照包围菜单项的两个矩形来检查接触点。如果其中一个被击中,我们播放点击声音,并转换到各自的屏幕。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        guiCam.setViewportAndMatrices();

        gl.glEnable(GL10.*GL_TEXTURE_2D*);

        batcher.beginBatch(Assets.*background*);
        batcher.drawSprite(240, 160, 480, 320, Assets.*backgroundRegion*);
        batcher.endBatch();

        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);

        batcher.beginBatch(Assets.*items*);
        batcher.drawSprite(240, 240, 384, 128, Assets.*logoRegion*);
        batcher.drawSprite(240, 100, 224, 64, Assets.*menuRegion*);
        batcher.endBatch();

        gl.glDisable(GL10.*GL_BLEND*);
        gl.glDisable(GL10.*GL_TEXTURE_2D*);
    }

present()方法做的事情和它在 Super Jumper 的大多数屏幕中做的一样。我们清空屏幕,并通过相机建立投影矩阵。我们启用纹理,然后通过我们在 Assets 类中定义的 SpriteBatcher 和 TextureRegion 立即渲染背景。菜单项有半透明的区域,所以我们在渲染之前启用混合。

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
    }
}

该类的其余部分由样板方法组成,它们不做任何事情。纹理重新加载是在 AndroidInvaders 活动中完成的,所以在 MainMenuScreen 中没有任何事情需要处理。

设置屏幕

设置屏幕为播放器提供了更改输入法的选项以及启用或禁用音频的选项。我们用三种不同的图标来表示这些选项(见图 12-4 )。触摸手图标或倾斜设备图标可启用相应的输入法。当前活动输入法的图标将为金色。对于音频图标,我们做的和以前的游戏一样。

用户的选择通过在设置类中设置相应的布尔值来反映。我们还通过调用 Settings.save()确保这些设置在其中一项发生变化时立即保存到 SD 卡中。清单 12-5 显示了代码。

清单 12-5。【SettingsScreen.java】,设置屏幕

package com.badlogic.androidgames.androidinvaders;

import java.util.List;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.gl.Camera2D;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.impl.GLScreen;
import com.badlogic.androidgames.framework.math.OverlapTester;
import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;

public class SettingsScreen extends GLScreen {
    Camera2D guiCam;
    SpriteBatcher batcher;
    Vector2 touchPoint;
    Rectangle touchBounds;
    Rectangle accelBounds;
    Rectangle soundBounds;
    Rectangle backBounds;

像往常一样,我们有一个摄像头和一个 SpriteBatcher 来呈现我们的 UI 元素和背景。为了检查触摸事件是否击中了按钮,我们为屏幕上的四个按钮存储了一个 Vector2 实例和四个 Rectangle 实例。

    public SettingsScreen(Game game) {
        super(game);
        guiCam = new Camera2D(glGraphics, 480, 320);
        batcher = new SpriteBatcher(glGraphics, 10);
        touchPoint = new Vector2();

        touchBounds = new Rectangle(120–32, 160–32, 64, 64);
        accelBounds = new Rectangle(240–32, 160–32, 64, 64);
        soundBounds = new Rectangle(360–32, 160–32, 64, 64);
        backBounds = new Rectangle(32, 32, 64, 64);
    }

在构造函数中,我们设置了屏幕的所有成员。这里不涉及火箭科学。

    @Override
    public void update(float deltaTime) {
        List<TouchEvent> events = game.getInput().getTouchEvents();
        int len = events.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = events.get(i);
            if (event.type != TouchEvent.*TOUCH_UP*)
                continue ;

            guiCam.touchToWorld(touchPoint.set(event.x, event.y));
            if (OverlapTester.*pointInRectangle*(touchBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                Settings.*touchEnabled* =true ;
                Settings.*save*(game.getFileIO());
            }
            if (OverlapTester.*pointInRectangle*(accelBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                Settings.*touchEnabled* =false ;
                Settings.*save*(game.getFileIO());
            }
            if (OverlapTester.*pointInRectangle*(soundBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                Settings.*soundEnabled*  = !Settings.*soundEnabled*;
                if (Settings.*soundEnabled*) {
                    Assets.*music*.play();
                }else {
                    Assets.*music*.pause();
                }
                Settings.*save*(game.getFileIO());
            }
            if (OverlapTester.*pointInRectangle*(backBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                game.setScreen(new MainMenuScreen(game));
            }
        }
    }

update()方法获取触摸事件并检查是否注册了一个“触发”事件。如果是,它会将触摸坐标转换到相机的坐标系。有了这些坐标,它测试各种矩形来决定采取什么行动。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*);
        guiCam.setViewportAndMatrices();

        gl.glEnable(GL10.*GL_TEXTURE_2D*);

        batcher.beginBatch(Assets.*background*);
        batcher.drawSprite(240, 160, 480, 320, Assets.*backgroundRegion*);
        batcher.endBatch();

        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);

        batcher.beginBatch(Assets.*items*);
        batcher.drawSprite(240, 280, 224, 32, Assets.*settingsRegion*);
        batcher.drawSprite(120, 160, 64, 64,
                Settings.*touchEnabled*? Assets.*touchEnabledRegion*: Assets.*touchRegion*);
        batcher.drawSprite(240, 160, 64, 64,
                Settings.*touchEnabled*? Assets.*accelRegion*
                        : Assets.*accelEnabledRegion*);
        batcher.drawSprite(360, 160, 64, 64,
                Settings.*soundEnabled*? Assets.*soundEnabledRegion*: Assets.*soundRegion*);
        batcher.drawSprite(32, 32, 64, 64, Assets.*leftRegion*);
        batcher.endBatch();

        gl.glDisable(GL10.*GL_BLEND*);
        gl.glDisable(GL10.*GL_TEXTURE_2D*);
    }

present()方法的作用与 MainMenuScreen.render()方法相同。我们在需要的地方用纹理和混合来渲染背景和按钮。基于当前的设置,我们决定使用哪个 TextureRegion 来呈现三个设置按钮。

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
    }
}

类的其余部分还是由一些样板方法组成,没有任何功能。

在创建 GameScreen 类之前,我们首先要实现我们世界的逻辑和渲染。模型-视图-控制器来拯救我们!

模拟类

像往常一样,我们将为我们世界中的每个对象创建一个单独的类:

  • 屏蔽块
  • 发射
  • 一艘船
  • 入侵者

这种编排是由一个无所不知的世界级大师完成的。正如我们在第十一章中看到的,在物体表现方面,2D 和 3D 并没有太大的区别。我们现在可以使用 GameObject3D 和 DynamicObject3D 来代替 GameObject 和 DynamicObject。唯一的区别是,我们使用 Vector3 实例而不是 Vector2 实例来存储位置、速度和加速度,并且我们使用边界球体而不是边界矩形来表示对象的形状。剩下要做的就是实现我们世界中不同对象的行为。

盾牌格挡类

从游戏力学的定义中,我们知道我们的盾块的大小和行为。他们只是坐在我们世界的某个地方,等着被飞船或入侵者的子弹消灭。它们没有太多的逻辑,所以代码相当简洁。清单 12-6 显示了屏蔽块的内部结构。

清单 12-6。Shield.java,盾牌格挡类

package com.badlogic.androidgames.androidinvaders;

import com.badlogic.androidgames.framework.GameObject3D;

public class Shield extends GameObject3D {
    static float *SHIELD_RADIUS*  = 0.5f;

    public Shield(float x,float y,float z) {
        super(x, y, z,*SHIELD_RADIUS*);
    }
}

我们定义屏蔽的半径,并根据构造函数的参数初始化它的位置和包围球。这就是全部了!

铅球课

Shot 类同样简单。它来源于 DynamicGameObject3D,因为它实际上在移动。清单 12-7 显示了代码。

清单 12-7。Shot.java,射击类

package com.badlogic.androidgames.androidinvaders;

import com.badlogic.androidgames.framework.DynamicGameObject3D;

public class Shot extends DynamicGameObject3D {
    static float *SHOT_VELOCITY*  = 10f;
    static float *SHOT_RADIUS*  = 0.1f;

    public Shot(float x,float y,float z,float velocityZ) {
        super(x, y, z,*SHOT_RADIUS*);
        velocity.z = velocityZ;
    }

    public void update(float deltaTime) {
        position.z += velocity.z * deltaTime;
        bounds.center.set(position);
    }
}

这里,我们定义一些常数,即弹丸速度及其半径。构造函数获取一个镜头的初始位置,以及它在 z 轴上的速度。等等,我们不是刚把速度定义为常数了吗?是的,但是那会让我们的射击只在 z 轴的正方向上行进。这对于入侵者的射击来说没问题,但是来自船上的射击必须向相反的方向行进。当我们创建一个镜头时(在这个类之外),我们知道镜头应该向哪个方向移动。

update()方法只做通常的质点物理学。这里不涉及加速度,因此我们只需要将恒定速度乘以时间增量加到射击的位置上。关键的部分是,我们还根据镜头的位置更新包围球中心的位置。否则,边界球体不会随快照移动。

轮船等级

Ship 类负责更新船的位置,使其保持在游戏场的边界内,并跟踪其所处的状态。它可以是活的,也可以是爆炸的。在这两种情况下,我们都会记录船只处于这种状态的时间。然后,我们可以使用状态时间来制作动画,例如,就像我们在 Super Jumper 及其 WorldRenderer 类中所做的那样。船只将从外部获得当前速度,基于用户输入,或者是加速度计读数,就像我们对 Bob 所做的那样,或者是基于一个常数,取决于屏幕上的按钮被按下。此外,这艘船将记录它拥有的生命数量,并向我们提供一种方式来告诉它它已经被杀死。清单 12-8 显示了代码。

清单 12-8。Ship.java,船级

package com.badlogic.androidgames.androidinvaders;

import com.badlogic.androidgames.framework.DynamicGameObject3D;

public class Ship extends DynamicGameObject3D {
    static float *SHIP_VELOCITY*  = 20f;
    static int *SHIP_ALIVE*  = 0;
    static int *SHIP_EXPLODING*  = 1;
    static float *SHIP_EXPLOSION_TIME*  = 1.6f;
    static float *SHIP_RADIUS*  = 0.5f;

我们首先用一些常数来定义最大船速,两种状态(存活和爆炸),船身完全爆炸所需的时间,以及船身的包围球半径。此外,我们让该类从 DynamicGameObject3D 派生,因为它有一个位置和边界球,以及一个速度。存储在 DynamicGameObject3D 中的加速度矢量将再次被使用。

    int lives;
    int state;
    float stateTime = 0;

接下来我们有两个成员,由两个整数组成,用来记录船的生命数量和它的状态(SHIP_ALIVE 或 SHIP _ EXPLODING)。最后一个成员记录飞船处于当前状态的秒数。

    public Ship(float x,float y,float z) {
        super(x, y, z,*SHIP_RADIUS*);
        lives = 3;
        state =*SHIP_ALIVE*;
    }

构造函数执行通常的超类构造函数调用,并初始化一些成员。这艘船总共有三条命。

    public void update(float deltaTime,float accelY) {
        if (state ==*SHIP_ALIVE*) {
            velocity.set(accelY / 10 **SHIP_VELOCITY*, 0, 0);
            position.add(velocity.x * deltaTime, 0, 0);
            if (position.x < World.*WORLD_MIN_X*)
                position.x = World.*WORLD_MIN_X*;
            if (position.x > World.*WORLD_MAX_X*)
                position.x = World.*WORLD_MAX_X*;
            bounds.center.set(position);
        }else {
            if (stateTime >=*SHIP_EXPLOSION_TIME*) {
                lives--;
                stateTime = 0;
                state =*SHIP_ALIVE*;
            }
        }
        stateTime += deltaTime;
    }

update()方法非常简单。它需要设备 y 轴上的增量时间和当前加速度计读数(记住,我们处于横向模式,因此加速度计 y 轴是我们屏幕的 x 轴)。如果船是活的,我们就根据加速度计的值(在 10 到 10 的范围内)设置它的速度,就像我们在《超级 Jumper》中对 Bob 所做的那样。此外,我们根据当前速度更新它的位置。接下来,我们使用两个常数来检查船是否离开了比赛场地的边界,我们将在后面的世界级课程中定义这两个常数。当位置固定时,我们可以更新船的包围球的中心。

如果飞船正在爆炸,我们检查这种情况已经持续了多久。在爆炸状态 1.6 秒后,飞船完成爆炸,失去一条生命,并回到存活状态。

最后,我们根据给定的增量时间更新 stateTime 成员。

    public void kill() {
        state =*SHIP_EXPLODING*;
        stateTime = 0;
        velocity.x = 0;
    }
}

如果世界类确定船只与射击者或入侵者之间发生了碰撞,将调用 last kill()方法。它会将状态设置为爆炸,重置状态时间,并确保船的速度在所有轴上都为零(我们从未设置速度向量的 y 和 z 分量,因为我们只在 x 轴上移动)。

侵略者阶级

入侵者只是按照预定的模式漂浮在太空中。图 12-11 显示了这种模式。

9781430246770_Fig12-11.jpg

图 12-11。入侵者的动作:左、下、右、下、左、下、右、下。。。

入侵者遵循极其简单的运动模式。从初始位置开始,它先向右移动一段距离。接下来,它再次向下移动一段指定的距离(这意味着在运动场上 z 轴的正方向上)。一旦完成,它就开始向右移动,基本上回到向左移动之前的 x 坐标。

除了开始时,左右移动距离总是相同的。图 12-11 展示了左上入侵者的移动。它的第一次向左运动比随后所有向左或向右的运动都要短。水*移动距离是运动场宽度的一半,在这种情况下是 14 个单位。对于第一次水*运动,入侵者必须行进的距离是这个距离的一半,即 7 个单位。

我们要做的是跟踪入侵者移动的方向,以及它在这个方向上已经移动了多远。如果达到给定移动状态的移动距离(水*移动 14 个单位,垂直移动 1 个单位),则切换到下一个移动状态。所有入侵者最初的移动距离都被设置为球场宽度的一半。再次查看图 12-11 以了解其工作原理。这将使入侵者从比赛场地的边缘向左右弹开。

入侵者也有恒定的速度。如果当前一波入侵者都死了,那么每次我们产生新一波入侵者时,速度都会增加。我们可以简单地通过将默认速度乘以某个从外部设置的常数来实现这一点,即负责更新所有入侵者的世界级。

最后,我们必须跟踪入侵者的状态,可以是活着的,也可以是正在爆炸的。我们将使用与船的情况相同的机制,带有状态和状态时间。清单 12-9 显示了代码。

清单 12-9。Invader.java,入侵者阶层

package com.badlogic.androidgames.androidinvaders;

import com.badlogic.androidgames.framework.DynamicGameObject3D;

public class Invader extends DynamicGameObject3D {
    static final int *INVADER_ALIVE*  = 0;
    static final int *INVADER_DEAD*  = 1;
    static final float *INVADER_EXPLOSION_TIME*  = 1.6f;
    static final float *INVADER_RADIUS*  = 0.75f;
    static final float *INVADER_VELOCITY*  = 1;
    static final int *MOVE_LEFT*  = 0;
    static final int *MOVE_DOWN*  = 1;
    static final int *MOVE_RIGHT*  = 2;

我们从定义入侵者的状态、爆炸持续时间、半径和默认速度的一些常数开始,然后是三个常数,允许我们跟踪入侵者当前移动的方向。

    int state =*INVADER_ALIVE*;
    float stateTime = 0;
    int move =*MOVE_LEFT*;
    boolean wasLastStateLeft =true ;
    float movedDistance = World.*WORLD_MAX_X*/ 2;

我们跟踪入侵者的状态、状态时间、移动方向和移动距离,这些应该最初设置为运动场宽度的一半。我们还记录了最后一次水*移动是否向左。一旦入侵者完成了在 z 轴上的垂直运动,我们就可以决定它应该朝哪个方向前进。

    public Invader(float x,float y,float z) {
        super(x, y, z,*INVADER_RADIUS*);
    }

构造函数通过超类构造函数执行入侵者位置和边界船只的常规设置。

    public void update(float deltaTime,float speedMultiplier) {
        if (state ==*INVADER_ALIVE*) {
            movedDistance += deltaTime **INVADER_VELOCITY** speedMultiplier;
            if (move ==*MOVE_LEFT*) {
                position.x -= deltaTime **INVADER_VELOCITY** speedMultiplier;
                if (movedDistance > World.*WORLD_MAX_X*) {
                    move =*MOVE_DOWN*;
                    movedDistance = 0;
                    wasLastStateLeft =true ;
                }
            }
            if (move ==*MOVE_RIGHT*) {
                position.x += deltaTime **INVADER_VELOCITY** speedMultiplier;
                if (movedDistance > World.*WORLD_MAX_X*) {
                    move =*MOVE_DOWN*;
                    movedDistance = 0;
                    wasLastStateLeft =false ;
                }
            }
            if (move ==*MOVE*_*DOWN*) {
                position.z += deltaTime **INVADER*_*VELOCITY** speedMultiplier;
                if (movedDistance > 1) {
                    if (wasLastStateLeft)
                        move =*MOVE*_*RIGHT*;
                    else
                        move =*MOVE*_*LEFT*;
                    movedDistance = 0;
                }
            }

            bounds.center.set(position);
        }

        stateTime += deltaTime;
    }

update()方法采用当前的增量时间和速度乘数,使新一波入侵者移动得更快。当然,只有当入侵者活着的时候,我们才进行运动。

我们首先计算这次更新中入侵者将行进多少个单位,并相应地增加 movedDistance 成员。如果它向左移动,我们直接通过从位置的 x 坐标减去移动速度乘以时间和速度的增量乘数来更新位置。如果它移动得足够远,我们通过将 move 成员设置为 MOVE_DOWN 来告诉它开始垂直移动。此外,我们将 wasLastStateLeft 设置为 true,这样我们就知道,向下运动完成后,入侵者必须向右移动。

对于向右的运动,我们做了完全相同的处理。唯一的区别是,我们从位置的 x 坐标中减去移动速度,并在达到移动距离后将 wasLastStateLeft 设置为 false。

如果入侵者向下移动,我们操纵入侵者位置的 z 坐标,并再次检查它在那个方向上移动了多远。如果它到达了向下移动的移动距离,我们将移动状态切换到 MOVE_LEFT 或 MOVE_RIGHT,这取决于 wasLastStateLeft 成员中编码的最后一个水*移动方向。一旦我们完成了入侵者位置的更新,我们就设置包围球的位置,就像我们对飞船所做的那样。最后,我们更新当前状态时间,并认为更新已经完成。

    public void kill() {
        state =*INVADER*_*DEAD*;
        stateTime = 0;
    }
}

这里的 kill()方法与 Ship 类的 kill()方法的作用相同。它允许我们告诉入侵者它应该开始死亡。我们将其状态设置为 INVADER_DEAD,并重置其状态时间。然后入侵者将停止移动,并且只根据当前的时间增量更新它的状态时间。

世界一流

世界级是这一切的策划者。它存储船只、入侵者和镜头,并负责更新它们和检查碰撞。这和《超级 Jumper》中的差不多,只有一些小的不同。初始放置盾块,以及入侵者,也是世界级的责任。我们创建了一个 WorldListener 接口来通知外部各方我们世界中的事件,例如爆炸或枪击。这将允许我们播放声音效果,就像在超级跳线。一次检查一个方法会有所帮助。清单 12-10 显示了代码。

清单 12-10。World.java,世界级,将一切联系在一起

package com.badlogic.androidgames.androidinvaders;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

import com.badlogic.androidgames.framework.math.OverlapTester;

public class World {
    public interface WorldListener {
        public void explosion();

        public void shot();
    }

我们希望外界知道何时发生爆炸或何时开枪。为此,我们定义了一个侦听器接口,我们可以实现该接口并向一个 World 实例注册,当这些事件之一发生时将调用该实例。这很像超级跳伞,只是项目不同。

    final static float *WORLD*_*MIN*_*X*  = −14;
    final static float *WORLD*_*MAX*_*X*  = 14;
    final static float *WORLD*_*MIN*_*Z*  = −15;

我们也有几个常量来定义世界的范围,正如前面“定义游戏世界”一节所讨论的。

    WorldListener listener;
    int waves = 1;
    int score = 0;
    float speedMultiplier = 1;
    final List<Shot> shots = new ArrayList<Shot>();
    final List<Invader> invaders = new ArrayList<Invader>();
    final List<Shield> shields = new ArrayList<Shield>();
    final Ship ship;
    long lastShotTime;
    Random random;

我们的世界记录了一些事情。我们有一个监听器,当发生爆炸或枪击时,我们会调用它。此外,我们会记录玩家已经消灭了多少波入侵者。score 成员跟踪当前分数,speedMultiplier 成员允许我们加快入侵者的移动速度(记住 Invaders.update()方法)。此外,我们还储存了世界上现存的射击、入侵者和盾块的列表。最后,我们有一个船的实例,我们存储船最后一次开枪的时间。我们以纳秒为单位存储这个时间,由 System.nanoTime()返回—因此是长数据类型。当我们想决定一个入侵者是否应该开枪时,这个随机的例子将会派上用场。

    public World() {
        ship = new Ship(0, 0, 0);
        generateInvaders();
        generateShields();
        lastShotTime = System.*nanoTime*();
        random = new Random();
    }

在构造器中,我们在初始位置创建飞船,生成入侵者和护盾,初始化其余成员。

    private void generateInvaders() {
        for (int row = 0; row < 4; row++) {
            for (int column = 0; column < 8; column++) {
                Invader invader = new Invader(−*WORLD*_*MAX*_*X*/ 2 + column * 2f,
                        0,*WORLD*_*MIN*_*Z*+ row * 2f);
                invaders.add(invader);
            }
        }
    }

generateInvaders()方法简单地创建了一个入侵者网格,8 乘 4,排列如图图 12-11 所示。

    private void generateShields() {
        for (int shield = 0; shield < 3; shield++) {
            shields.add(new Shield(−10 + shield * 10–1, 0, -3));
            shields.add(new Shield(−10 + shield * 10 + 0, 0, -3));
            shields.add(new Shield(−10 + shield * 10 + 1, 0, -3));
            shields.add(new Shield(−10 + shield * 10–1, 0, -2));
            shields.add(new Shield(−10 + shield * 10 + 1, 0, -2));
        }
    }

generateShields()方法做得差不多:实例化三个盾牌,每个盾牌由五个盾牌块组成,如图 12-2 所示。

    public void setWorldListener(WorldListener worldListener) {
        this .listener = worldListener;
    }

我们还有一个 setter 方法来设置世界上的侦听器。我们将用它来了解世界上发生的事件并做出相应的反应,例如播放音效。

    public void update(float deltaTime,float accelX) {
        ship.update(deltaTime, accelX);
        updateInvaders(deltaTime);
        updateShots(deltaTime);

        checkShotCollisions();
        checkInvaderCollisions();

        if (invaders.size() == 0) {
            generateInvaders();
            waves++;
            speedMultiplier += 0.5f;
        }
    }

update()方法出奇的简单。它使用当前的 delta 时间,以及加速度计 y 轴上的读数,我们可以将它们传递给 Ship.update()。一旦船更新了,我们调用 updateInvaders()和 updateShots(),它们负责更新这两种类型的对象。在世界上所有的物体都被更新后,我们开始检查是否有碰撞。checkShotCollision()方法将检查任何镜头与船只和/或入侵者之间的碰撞。

最后,我们检查入侵者是否已经死亡。如果是的话,我们会产生新一波入侵者。出于对垃圾收集器(GC)的热爱,我们可以重用旧的 Invader 实例,例如,通过 Pool 类。然而,为了简单起见,我们简单地创建新的实例。对了,打针也是一样。鉴于我们在一个游戏会话中创建的对象数量很少,GC 不太可能触发。如果你想让 GC 真正开心,就使用一个 Pool 实例来重用死去的入侵者和镜头。另外,请注意,我们在这里增加了速度乘数!

    private void updateInvaders(float deltaTime) {
        int len = invaders.size();
        for (int i = 0; i < len; i++) {
            Invader invader = invaders.get(i);
            invader.update(deltaTime, speedMultiplier);

            if (invader.state == Invader.*INVADER*_*ALIVE*) {
                if (random.nextFloat() < 0.001f) {
                    Shot shot = new Shot(invader.position.x,
                                 invader.position.y,
                                                             invader.position.z,
                                 Shot.*SHOT*_*VELOCITY*);
                    shots.add(shot);
                    listener.shot();
                }
            }

            if (invader.state == Invader.*INVADER_DEAD*&&
                            invader.stateTime > Invader.*INVADER_EXPLOSION_TIME*) {
                invaders.remove(i);
                i--;
                len--;
            }
        }
    }

updateInvaders()方法有几个职责。它遍历所有入侵者并调用他们的 update()方法。一旦入侵者实例被更新,我们检查它是否还活着。如果是的话,我们通过产生一个随机数给它一个开火的机会。如果这个数字低于 0.001,它就开一枪。这意味着每个入侵者每帧有 0.1%的几率开枪。如果发生这种情况,我们实例化一个新镜头,设置它的速度,使它在 z 轴的正方向上移动,并通知侦听器该事件。如果入侵者已经死亡并完成爆炸,我们只需将其从当前入侵者列表中移除。

    private void updateShots(float deltaTime) {
        int len = shots.size();
        for (int i = 0; i < len; i++) {
            Shot shot = shots.get(i);
            shot.update(deltaTime);
            if (shot.position.z <*WORLD_MIN_Z*||
                shot.position.z > 0) {
                shots.remove(i);
                i--;
                len--;
            }
        }
    }

updateShots()方法也很简单。我们循环所有镜头,更新它们,并检查每一个镜头是否已经离开了比赛场地,在这种情况下,我们将其从镜头列表中删除。

    private void checkInvaderCollisions() {
        if (ship.state == Ship.*SHIP_EXPLODING*)
            return ;

        int len = invaders.size();
        for (int i = 0; i < len; i++) {
            Invader invader = invaders.get(i);
            if (OverlapTester.*overlapSpheres*(ship.bounds, invader.bounds)) {
                ship.lives = 1;
                ship.kill();
                return ;
            }
        }
    }

在 checkInvaderCollisions()方法中,我们检查是否有任何入侵者与船只发生碰撞。这是一件非常简单的事情,因为我们需要做的就是遍历所有入侵者,并检查每个入侵者的边界球和飞船的边界球之间的重叠。根据我们的游戏力学定义,如果飞船与入侵者相撞,游戏就结束了。这就是为什么我们在调用 Ship.kill()方法之前将船的寿命设置为 1。在那个调用之后,ship 的 lives 成员被设置为 0,我们将在另一个方法中使用它来检查游戏结束状态。

    private void checkShotCollisions() {
        int len = shots.size();
        for (int i = 0; i < len; i++) {
            Shot shot = shots.get(i);
            boolean shotRemoved =false ;

            int len2 = shields.size();
            for (int j = 0; j < len2; j++) {
                Shield shield = shields.get(j);
                if (OverlapTester.*overlapSpheres*(shield.bounds, shot.bounds)) {
                    shields.remove(j);
                    shots.remove(i);
                    i--;
                    len--;
                    shotRemoved =true ;
                    break ;
                }
            }
            if (shotRemoved)
                continue ;

            if (shot.velocity.z < 0) {
                len2 = invaders.size();
                for (int j = 0; j < len2; j++) {
                    Invader invader = invaders.get(j);
                    if (OverlapTester.*overlapSpheres*(invader.bounds,
                            shot.bounds)
                            && invader.state == Invader.*INVADER_ALIVE*) {
                        invader.kill();
                        listener.explosion();
                        score += 10;
                        shots.remove(i);
                        i--;
                        len--;
                        break ;
                    }
                }
            }else {
                if (OverlapTester.*overlapSpheres*(shot.bounds, ship.bounds)
                        && ship.state == Ship.*SHIP_ALIVE*) {
                    ship.kill();
                    listener.explosion();
                    shots.remove(i);
                    i--;
                    len--;
                }
            }
        }
    }

checkShotCollisions()方法稍微复杂一点。它循环遍历每个镜头实例,并检查它与盾块、入侵者或船只之间的重叠。盾牌格挡可以被飞船或入侵者的射击击中。入侵者只能被飞船发射的子弹击中。而且船只能被入侵者射出的子弹击中。要区分一发子弹是船只发射的还是入侵者发射的,我们需要做的就是看它的 z 向速度。如果它是正的,它向船移动,因此被入侵者发射。如果是否定的,那就是飞船发射的。

    public boolean isGameOver() {
        return ship.lives == 0;
    }

isGameOver()方法只是简单地告诉一个外部方这艘船是否已经失去了所有的生命。

    public void shoot() {
        if (ship.state == Ship.*SHIP_EXPLODING*)
            return ;

        int friendlyShots = 0;
        int len = shots.size();
        for (int i = 0; i < len; i++) {
            if (shots.get(i).velocity.z < 0)
                friendlyShots++;
        }

        if (System.*nanoTime*() - lastShotTime > 1000000000 || friendlyShots == 0) {
            shots.add(new Shot(ship.position.x, ship.position.y,
                    ship.position.z, -Shot.*SHOT_VELOCITY*));
            lastShotTime = System.*nanoTime*();
            listener.shot();
        }
    }
}

最后,每次用户按下 Fire 按钮时,都会从外部调用 shoot()方法。正如在“核心游戏机制”一节中所提到的,船只可以每秒发射一次,或者如果没有船只在战场上发射过,也可以发射一次。飞船爆炸当然不能开火,所以这是我们检查的第一件事。接下来,我们运行所有的快照实例,并检查其中是否有一个是船只快照。如果不是这样,我们可以立即拍摄。否则,我们检查最后一枪是什么时候开的。如果距离上次射击超过一秒钟,我们就发射新的。这一次,我们将速度设置为 Shot。SHOT_VELOCITY,使镜头沿 z 轴负方向向入侵者移动。像往常一样,我们调用侦听器来通知它事件。

而这就是组成我们游戏世界的所有职业!与我们在《超级 Jumper》中看到的相比。原理几乎相同,代码看起来也非常相似。当然,Android Invaders 是一个非常简单的游戏,所以我们可以用简单的解决方案,比如对所有东西都使用边界球。对于许多简单的 3D 游戏,这就是你所需要的。继续我们游戏的最后两个部分:GameScreen 类和 WorldRenderer 类!

GameScreen 类

一旦游戏转换到 GameScreen 类,玩家可以立即开始游戏,而不必声明他或她已经准备好了。我们仅有的状态是:

  • 运行状态,我们渲染背景、世界和 UI 元素,如图图 12-4 所示
  • 暂停状态,我们渲染背景、世界和暂停菜单,如图图 12-4 所示
  • 游戏结束状态,我们渲染的东西和暂停状态差不多。

我们将遵循在 Super Jumper 中使用的相同模式,并为三种状态中的每一种状态使用不同的 update()和 present()方法。

这个类最有趣的部分是我们如何处理用户输入来移动船只。我们希望我们的玩家能够通过屏幕上的按钮或加速度计来控制船只。我们可以读取 Settings.touchEnabled 字段来找出用户对此想要什么。根据哪个输入方法是活动的,我们决定是否呈现屏幕上的按钮,并将适当的加速度计值传递给 World.update()方法来移动船只。

如果玩家选择了屏幕上的按钮,我们当然不需要使用加速度计的值;相反,我们只是将一个恒定的人工加速度值传递给 World.update()方法。它必须在 10(左)到 10(右)的范围内。经过一点试验,我们通过屏幕上的按钮得出向左移动的值为 5,向右移动的值为 5。

这个课程的另一个有趣的部分是我们结合 3D 游戏世界的渲染和 2D UI 元素的方式。让我们看看清单 12-11 中的 GameScreen 类的代码。

清单 12-11。GameScreen.java,游戏画面

package com.badlogic.androidgames.androidinvaders;

import java.util.List;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.androidinvaders.World.WorldListener;
import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Input.TouchEvent;
import com.badlogic.androidgames.framework.gl.Camera2D;
import com.badlogic.androidgames.framework.gl.FPSCounter;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.impl.GLScreen;
import com.badlogic.androidgames.framework.math.OverlapTester;
import com.badlogic.androidgames.framework.math.Rectangle;
import com.badlogic.androidgames.framework.math.Vector2;

public class GameScreen extends GLScreen {
    static final int *GAME*_*RUNNING*  = 0;
    static final int *GAME_PAUSED*  = 1;
    static final int *GAME_OVER*  = 2;

像往常一样,我们有几个常量用于编码屏幕的当前状态。

    int state;
    Camera2D guiCam;
    Vector2 touchPoint;
    SpriteBatcher batcher;
    World world;
    WorldListener worldListener;
    WorldRenderer renderer;
    Rectangle pauseBounds;
    Rectangle resumeBounds;
    Rectangle quitBounds;
    Rectangle leftBounds;
    Rectangle rightBounds;
    Rectangle shotBounds;
    int lastScore;
    int lastLives;
    int lastWaves;
    String scoreString;
    FPSCounter fpsCounter;

游戏屏幕的成员照常工作。我们有一个跟踪状态的成员、一个摄像头、一个触摸点的向量、一个用于呈现 2D UI 元素的 SpriteBatcher、一个 World 实例、一个 WorldListener、一个 WorldRenderer(我们马上就要编写这个程序)以及几个用于检查 UI 元素是否被触摸的矩形。此外,三个整数跟踪最后的生命数、waves 和 score,这样我们就不必为了减少 GC 活动而每次都更新 scoreString。最后,我们有一个 FPSCounter,这样以后我们就可以计算出游戏的性能。

    public GameScreen(Game game) {
        super(game);

        state =*GAME_RUNNING*;
        guiCam = new Camera2D(glGraphics, 480, 320);
        touchPoint = new Vector2();
        batcher = new SpriteBatcher(glGraphics, 100);
        world = new World();
        worldListener = new WorldListener() {
            public void shot() {
                Assets.*playSound*(Assets.*shotSound*);
            }

            public void explosion() {
                Assets.*playSound*(Assets.*explosionSound*);
            }
        };
        world.setWorldListener(worldListener);
        renderer = new WorldRenderer(glGraphics);
        pauseBounds = new Rectangle(480–64, 320–64, 64, 64);
        resumeBounds = new Rectangle(240–80, 160, 160, 32);
        quitBounds = new Rectangle(240–80, 160–32, 160, 32);
        shotBounds = new Rectangle(480–64, 0, 64, 64);
        leftBounds = new Rectangle(0, 0, 64, 64);
        rightBounds = new Rectangle(64, 0, 64, 64);
        lastScore = 0;
        lastLives = world.ship.lives;
        lastWaves = world.waves;
        scoreString = "lives:" + lastLives + " waves:" + lastWaves + " score:"
                + lastScore;
        fpsCounter = new FPSCounter();
    }

在构造函数中,我们设置了所有的成员,就像我们现在习惯做的那样。WorldListener 负责在我们的世界发生事件时播放正确的声音。其余部分与 Super Jumper 相同,尽管针对稍微不同的 UI 元素做了一些修改。

    @Override
    public void update(float deltaTime) {
        switch (state) {
        case *GAME_PAUSED*:
            updatePaused();
            break ;
        case *GAME_RUNNING*:
            updateRunning(deltaTime);
            break ;
        case *GAME_OVER*:
            updateGameOver();
            break ;
        }
    }

update()方法根据屏幕的当前状态,将真正的更新委托给其他三种更新方法之一。

    private void updatePaused() {
        List<TouchEvent> events = game.getInput().getTouchEvents();
        int len = events.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = events.get(i);
            if (event.type != TouchEvent.*TOUCH_UP*)
                continue ;

            guiCam.touchToWorld(touchPoint.set(event.x, event.y));
            if (OverlapTester.*pointInRectangle*(resumeBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                state =*GAME_RUNNING*;
            }

            if (OverlapTester.*pointInRectangle*(quitBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                game.setScreen(new MainMenuScreen(game));
            }
        }
    }

updatePaused()方法遍历所有可用的触摸事件,并检查是否按下了两个菜单项中的一个(Resume 或 Quit)。在每种情况下,我们都播放卡嗒声。这里没什么新鲜的。

    private void updateRunning(float deltaTime) {
        List<TouchEvent> events = game.getInput().getTouchEvents();
        int len = events.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = events.get(i);
            if (event.type != TouchEvent.*TOUCH_DOWN*)
                continue ;

            guiCam.touchToWorld(touchPoint.set(event.x, event.y));

            if (OverlapTester.*pointInRectangle*(pauseBounds, touchPoint)) {
                Assets.*playSound*(Assets.*clickSound*);
                state =*GAME_PAUSED*;
            }
            if (OverlapTester.*pointInRectangle*(shotBounds, touchPoint)) {
                world.shoot();
            }
        }

        world.update(deltaTime, calculateInputAcceleration());
        if (world.ship.lives != lastLives || world.score != lastScore
                || world.waves != lastWaves) {
            lastLives = world.ship.lives;
            lastScore = world.score;
            lastWaves = world.waves;
            scoreString = "lives:" + lastLives + " waves:" + lastWaves
                    + " score:" + lastScore;
        }
        if (world.isGameOver()) {
            state =*GAME_OVER*;
        }
    }

updateRunning()方法负责两件事:检查暂停按钮是否被按下(并相应地做出反应)以及根据用户输入更新世界。第一块拼图很琐碎,我们来看看世界更新机制。我们将加速度值的计算委托给一个名为 calculateInputAcceleration()的方法。一旦世界被更新,我们检查三个状态(生命、波浪或分数)中的任何一个是否已经改变,并相应地更新 scoreString。最后,我们检查游戏是否结束,在这种情况下,我们进入 GameOver 状态。

    private float calculateInputAcceleration() {
        float accelX = 0;
        if (Settings.*touchEnabled*) {
            for (int i = 0; i < 2; i++) {
                if (game.getInput().isTouchDown(i)) {
                    guiCam.touchToWorld(touchPoint.set(game.getInput()
                            .getTouchX(i), game.getInput().getTouchY(i)));
                    if (OverlapTester.*pointInRectangle*(leftBounds, touchPoint)) {
                        accelX = −Ship.*SHIP_VELOCITY*/ 5;
                    }
                    if (OverlapTester.*pointInRectangle*(rightBounds, touchPoint)) {
                        accelX = Ship.*SHIP_VELOCITY*/ 5;
                    }
                }
            }
        }else {
            accelX = game.getInput().getAccelY();
        }
        return accelX;
    }

calculateInputAcceleration()方法是我们实际解释用户输入的地方。如果启用了触摸,我们会检查屏幕上的向左或向右移动按钮是否被按下,如果是,我们会相应地将加速度值设置为 5(左)或 5(右)。如果使用加速度计,我们只需返回它在 y 轴上的当前值(记住,我们处于横向模式)。

    private void updateGameOver() {
        List<TouchEvent> events = game.getInput().getTouchEvents();
        int len = events.size();
        for (int i = 0; i < len; i++) {
            TouchEvent event = events.get(i);
            if (event.type == TouchEvent.*TOUCH_UP*) {
                Assets.*playSound*(Assets.*clickSound*);
                game.setScreen(new MainMenuScreen(game));
            }
        }
    }

updateGameOver()方法也很简单,它只是检查触摸事件,在这种情况下,我们转换到 MainMenuScreen。

    @Override
    public void present(float deltaTime) {
        GL10 gl = glGraphics.getGL();
        gl.glClear(GL10.*GL_COLOR_BUFFER_BIT*| GL10.*GL_DEPTH_BUFFER_BIT*);
        guiCam.setViewportAndMatrices();

        gl.glEnable(GL10.*GL_TEXTURE_2D*);
        batcher.beginBatch(Assets.*background*);
        batcher.drawSprite(240, 160, 480, 320, Assets.*backgroundRegion*);
        batcher.endBatch();
        gl.glDisable(GL10.*GL_TEXTURE_2D*);

        renderer.render(world, deltaTime);

        switch (state) {
        case *GAME_RUNNING*:
            presentRunning();
            break ;
        case *GAME_PAUSED*:
            presentPaused();
            break ;
        case *GAME_OVER*:
            presentGameOver();
        }

        fpsCounter.logFrame();
    }

present()方法实际上非常简单,也是如此。和往常一样,我们从清除帧缓冲区开始。此外,我们清除 z 缓冲区,因为我们要渲染一些需要 z 测试的 3D 对象。接下来,我们设置投影矩阵,这样我们就可以渲染我们的 2D 背景图像,就像我们在 MainMenuScreen 或 SettingsScreen 类中所做的那样。一旦完成,我们告诉 WorldRenderer 成员渲染我们的游戏世界。最后,我们根据当前状态委托呈现 UI 元素。请注意,WorldRenderer.render()方法负责设置渲染 3D 世界所需的所有内容!

    private void presentPaused() {
        GL10 gl = glGraphics.getGL();
        guiCam.setViewportAndMatrices();
        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);
        gl.glEnable(GL10.*GL_TEXTURE_2D*);

        batcher.beginBatch(Assets.*items*);
        Assets.*font*.drawText(batcher, scoreString, 10, 320–20);
        batcher.drawSprite(240, 160, 160, 64, Assets.*pauseRegion*);
        batcher.endBatch();

        gl.glDisable(GL10.*GL_TEXTURE_2D*);
        gl.glDisable(GL10.*GL_BLEND*);
    }

presentPaused()方法只是通过存储在 Assets 类中的字体实例以及暂停菜单来呈现 scoreString。请注意,在这一点上,我们已经渲染了背景图像,以及 3D 世界。因此,所有的 UI 元素都将覆盖 3D 世界。

    private void presentRunning() {
        GL10 gl = glGraphics.getGL();
        guiCam.setViewportAndMatrices();
        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);
        gl.glEnable(GL10.*GL_TEXTURE_2D*);

        batcher.beginBatch(Assets.*items*);
        batcher.drawSprite(480–32, 320–32, 64, 64, Assets.*pauseButtonRegion*);
        Assets.*font*.drawText(batcher, scoreString, 10, 320–20);
        if (Settings.*touchEnabled*) {
            batcher.drawSprite(32, 32, 64, 64, Assets.*leftRegion*);
            batcher.drawSprite(96, 32, 64, 64, Assets.*rightRegion*);
        }
        batcher.drawSprite(480–40, 32, 64, 64, Assets.*fireRegion*);
        batcher.endBatch();

        gl.glDisable(GL10.*GL_TEXTURE_2D*);
        gl.glDisable(GL10.*GL_BLEND*);
    }

presentRunning()方法也非常简单。我们首先呈现 scoreString。如果启用了触摸输入,我们将呈现左右移动按钮。最后,我们渲染 Fire 按钮,并重置我们已经更改的任何 OpenGL ES 状态(纹理和混合)。

    private void presentGameOver() {
        GL10 gl = glGraphics.getGL();
        guiCam.setViewportAndMatrices();
        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);
        gl.glEnable(GL10.*GL_TEXTURE_2D*);

        batcher.beginBatch(Assets.*items*);
        batcher.drawSprite(240, 160, 128, 64, Assets.*gameOverRegion*);
        Assets.*font*.drawText(batcher, scoreString, 10, 320–20);
        batcher.endBatch();

        gl.glDisable(GL10.*GL_TEXTURE_2D*);
        gl.glDisable(GL10.*GL_BLEND*);
    }

presentGameOver()方法与此大同小异,只是一些字符串和 UI 呈现。

    @Override
    public void pause() {
        state =*GAME_PAUSED*;
    }

最后,pause()方法只是将 GameScreen 置于暂停状态。

    @Override
    public void resume() {

    }

    @Override
    public void dispose() {

    }
}

剩下的只是空的存根,这样我们就完成了 GLGame 接口定义。接下来是我们的最后一个类:WorldRenderer!

worldreader 类

让我们回忆一下我们需要在 3D 中渲染什么:

  • 船,使用船的模型和纹理,并应用灯光。
  • 入侵者,使用入侵者模型和纹理,再次使用灯光。
  • 操场上的任何镜头,基于镜头模型,这一次没有纹理,但有灯光。
  • 屏蔽块,基于屏蔽块模型,同样没有纹理,但是有灯光和透明度(见图 12-3 )。
  • 爆炸而不是船只或入侵者模型,以防船只或入侵者爆炸。当然,爆炸没有被点燃。

我们知道如何对这个列表中的前四项进行编码。但是爆炸呢?

事实证明,我们可以为此滥用 SpriteBatcher。根据爆炸船只或入侵者的状态时间,我们可以从保存爆炸动画的动画实例中获取一个 TextureRegion(请参见 Assets 类)。SpriteBatcher 只能在 x-y *面上渲染纹理化的矩形,所以我们要想办法把这样的矩形移动到空间中的任意位置(爆炸的船或者入侵者所在的位置)。在通过 SpriteBatcher 渲染矩形之前,我们可以通过在模型视图矩阵上使用 glTranslatef()轻松实现这一点!

其他对象的渲染设置非常简单。我们有一个来自右上角的*行光,我们有一个环境光来照亮所有的物体,不管它们的方向。相机位于船的上方和后方一点,它会看着船前方一点的地方。我们用我们的摄像机来做这个。为了让相机跟随船只,我们只需要保持其位置的 x 坐标和观察点与船只的 x 坐标同步。

为了获得更多的视觉效果,我们将绕 y 轴旋转入侵者,并根据当前速度绕 z 轴旋转船只,使其看起来向移动的方向倾斜。

让我们把它写成代码吧!清单 12-12 显示了 Android 入侵者的最后一类。

清单 12-12。WorldRenderer.java,世界渲染器

package com.badlogic.androidgames.androidinvaders;

import java.util.List;

import javax.microedition.khronos.opengles.GL10;

import com.badlogic.androidgames.framework.gl.AmbientLight;
import com.badlogic.androidgames.framework.gl.Animation;
import com.badlogic.androidgames.framework.gl.DirectionalLight;
import com.badlogic.androidgames.framework.gl.LookAtCamera;
import com.badlogic.androidgames.framework.gl.SpriteBatcher;
import com.badlogic.androidgames.framework.gl.TextureRegion;
import com.badlogic.androidgames.framework.impl.GLGraphics;
import com.badlogic.androidgames.framework.math.Vector3;

public class WorldRenderer {
    GLGraphics glGraphics;
    LookAtCamera camera;
    AmbientLight ambientLight;
    DirectionalLight directionalLight;
    SpriteBatcher batcher;
    float invaderAngle = 0;

WorldRenderer 跟踪 GLGraphics 实例,我们将从中获取 GL10 实例。我们还有一个观察摄像机,一个环境光,一个方向灯和一个聚光灯。最后,我们使用一个成员来跟踪所有入侵者的当前旋转角度。

    public WorldRenderer(GLGraphics glGraphics) {
        this .glGraphics = glGraphics;
        camera = new LookAtCamera(67, glGraphics.getWidth()
                / (float ) glGraphics.getHeight(), 0.1f, 100);
        camera.getPosition().set(0, 6, 2);
        camera.getLookAt().set(0, 0, -4);
        ambientLight = new AmbientLight();
        ambientLight.setColor(0.2f, 0.2f, 0.2f, 1.0f);
        directionalLight = new DirectionalLight();
        directionalLight.setDirection(−1, -0.5f, 0);
        batcher = new SpriteBatcher(glGraphics, 10);
    }

在构造函数中,我们像往常一样设置所有成员。该相机具有 67 度的视野、0.1 个单位的*剪裁*面距离和 100 个单位的远剪裁*面距离。因此,视见*截头体将很容易包含整个游戏世界。我们把它放在船的上方和后方,让它看着(0,0,–4)。环境光只是淡淡的灰色,方向光是白色的,来自右上方。最后,我们实例化 SpriteBatcher,以便我们可以呈现爆炸矩形。

    public void render(World world,float deltaTime) {
        GL10 gl = glGraphics.getGL();
        camera.getPosition().x = world.ship.position.x;
        camera.getLookAt().x = world.ship.position.x;
        camera.setMatrices(gl);

        gl.glEnable(GL10.*GL_DEPTH_TEST*);
        gl.glEnable(GL10.*GL_TEXTURE_2D*);
        gl.glEnable(GL10.*GL_LIGHTING*);
        gl.glEnable(GL10.*GL_COLOR_MATERIAL*);
        ambientLight.enable(gl);
        directionalLight.enable(gl, GL10.*GL_LIGHT0*);

        renderShip(gl, world.ship);
        renderInvaders(gl, world.invaders, deltaTime);

        gl.glDisable(GL10.*GL_TEXTURE_2D*);
        renderShields(gl, world.shields);
        renderShots(gl, world.shots);

        gl.glDisable(GL10.*GL_COLOR_MATERIAL*);
        gl.glDisable(GL10.*GL_LIGHTING*);
        gl.glDisable(GL10.*GL_DEPTH_TEST*);
    }

在 render()方法中,我们首先将相机的 x 坐标设置为船的 x 坐标。当然,我们也相应地设置了摄像机观察点的 x 坐标。这样,摄像机会跟着船走。一旦位置和观察点被更新,我们就可以通过调用 lookat camera . set matrix()来设置投影和模型-视图矩阵。

接下来,我们设置渲染所需的所有状态。我们将需要深度测试、纹理、光照和颜色材质功能,这样我们就不必通过 glMaterial()为对象指定材质。接下来的两条语句激活环境光和*行光。通过这些调用,我们已经设置好了一切,可以开始渲染对象了。

我们通过调用 renderShip()渲染的第一件事是船。接下来,我们通过调用 renderInvaders()来渲染入侵者。

由于屏蔽块和镜头不需要纹理,我们简单地禁用它来节省一些计算。一旦纹理被关闭,我们通过调用 renderShots()和 renderShields()来渲染镜头和盾牌。

最后,我们禁用我们设置的其他状态,以便向调用我们的任何人返回一个干净的 OpenGL ES 状态。

    private void renderShip(GL10 gl, Ship ship) {
        if (ship.state == Ship.*SHIP_EXPLODING*) {
            gl.glDisable(GL10.*GL_LIGHTING*);
            renderExplosion(gl, ship.position, ship.stateTime);
            gl.glEnable(GL10.*GL_LIGHTING*);
        }else {
            Assets.*shipTexture*.bind();
            Assets.*shipModel*.bind();
            gl.glPushMatrix();
            gl.glTranslatef(ship.position.x, ship.position.y, ship.position.z);
            gl.glRotatef(ship.velocity.x / Ship.*SHIP_VELOCITY** 90, 0, 0, -1);
            Assets.*shipModel*.draw(GL10.*GL_TRIANGLES*, 0,
                    Assets.*shipModel*.getNumVertices());
            gl.glPopMatrix();
            Assets.*shipModel*.unbind();
        }
    }

renderShip()方法从检查船的状态开始。如果它正在爆炸,我们禁用照明,调用 renderExplosion()在船的位置渲染爆炸,并再次启用照明。

如果船是活的,我们绑定它的纹理和模型,推模型-视图矩阵,移动到它的位置并根据它的速度绕 z 轴旋转,绘制它的模型。最后,我们再次弹出模型-视图矩阵(只留下相机的视图)并解除船只模型顶点的绑定。

    private void renderInvaders(GL10 gl, List<Invader> invaders,float deltaTime) {
        invaderAngle += 45 * deltaTime;

        Assets.*invaderTexture*.bind();
        Assets.*invaderModel*.bind();
        int len = invaders.size();
        for (int i = 0; i < len; i++) {
            Invader invader = invaders.get(i);
            if (invader.state == Invader.*INVADER_DEAD*) {
                gl.glDisable(GL10.*GL_LIGHTING*);
                Assets.*invaderModel*.unbind();
                renderExplosion(gl, invader.position, invader.stateTime);
                Assets.*invaderTexture*.bind();
                Assets.*invaderModel*.bind();
                gl.glEnable(GL10.*GL_LIGHTING*);
            }else {
                gl.glPushMatrix();
                gl.glTranslatef(invader.position.x, invader.position.y,
                        invader.position.z);
                gl.glRotatef(invaderAngle, 0, 1, 0);
                Assets.*invaderModel*.draw(GL10.*GL_TRIANGLES*, 0,
                        Assets.*invaderModel*.getNumVertices());
                gl.glPopMatrix();
            }
        }
        Assets.*invaderModel*.unbind();
    }

renderInvaders()方法与 renderShip()方法非常相似。唯一的区别是在渲染每个入侵者之前绑定纹理和网格。这大大减少了绑定的数量并加快了渲染速度。对于每一个入侵者,我们再次检查其状态,并渲染爆炸或正常入侵者模型。由于我们在 for 循环之外绑定了模型和纹理,所以在渲染爆炸而不是入侵者之前,我们必须解除绑定并重新绑定它们。

    private void renderShields(GL10 gl, List<Shield> shields) {
        gl.glEnable(GL10.*GL_BLEND*);
        gl.glBlendFunc(GL10.*GL_SRC_ALPHA*, GL10.*GL_ONE_MINUS_SRC_ALPHA*);
        gl.glColor4f(0, 0, 1, 0.4f);
        Assets.*shieldModel*.bind();
        int len = shields.size();
        for (int i = 0; i < len; i++) {
            Shield shield = shields.get(i);
            gl.glPushMatrix();
            gl.glTranslatef(shield.position.x, shield.position.y,
                    shield.position.z);
            Assets.*shieldModel*.draw(GL10.*GL_TRIANGLES*, 0,
                    Assets.*shieldModel*.getNumVertices());
            gl.glPopMatrix();
        }
        Assets.*shieldModel*.unbind();
        gl.glColor4f(1, 1, 1, 1f);
        gl.glDisable(GL10.*GL_BLEND*);
    }

renderShields()方法渲染屏蔽块。我们应用与渲染入侵者相同的原理。我们只绑定模型一次。既然我们没有纹理,就不需要绑定一个。然而,我们需要启用混合。我们将全局顶点颜色设置为蓝色,alpha 组件设置为 0.4。这将使屏蔽块有点透明。

    private void renderShots(GL10 gl, List<Shot> shots) {
        gl.glColor4f(1, 1, 0, 1);
        Assets.*shotModel*.bind();
        int len = shots.size();
        for (int i = 0; i < len; i++) {
            Shot shot = shots.get(i);
            gl.glPushMatrix();
            gl.glTranslatef(shot.position.x, shot.position.y, shot.position.z);
            Assets.*shotModel*.draw(GL10.*GL_TRIANGLES*, 0,
                    Assets.*shotModel*.getNumVertices());
            gl.glPopMatrix();
        }
        Assets.*shotModel*.unbind();
        gl.glColor4f(1, 1, 1, 1);
    }

在 renderShots()中渲染镜头与渲染盾牌是一样的,只是我们不使用混合,而是使用不同的顶点颜色(黄色)。

    private void renderExplosion(GL10 gl, Vector3 position,float stateTime) {
        TextureRegion frame = Assets.*explosionAnim*.getKeyFrame(stateTime,
                Animation.*ANIMATION_NONLOOPING*);

        gl.glEnable(GL10.*GL_BLEND*);
        gl.glPushMatrix();
        gl.glTranslatef(position.x, position.y, position.z);
        batcher.beginBatch(Assets.*explosionTexture*);
        batcher.drawSprite(0, 0, 2, 2, frame);
        batcher.endBatch();
        gl.glPopMatrix();
        gl.glDisable(GL10.*GL_BLEND*);
    }
}

最后,我们有神秘的 renderExplosion()方法。我们得到我们想要渲染爆炸的位置,以及正在爆炸的物体的状态时间。后者用于从爆炸动画中获取正确的 TextureRegion,就像我们在《超级 Jumper》中为 Bob 所做的一样。

我们做的第一件事是根据状态时间获取爆炸动画帧。接下来,我们启用混合,因为爆炸有我们不想渲染的透明像素。我们推送当前的模型-视图矩阵并调用 glTranslatef(),以便在该调用之后呈现的任何内容都将被定位在给定的位置。我们告诉 SpriteBatcher 我们将要使用爆炸纹理渲染一个矩形。

下一个电话是奇迹发生的地方。我们告诉 SpriteBatcher 在(0,0,0)处呈现一个矩形(z 坐标没有给出,但隐含为零,还记得吗?),宽度和高度为 2 个单位。因为我们使用了 glTranslatef(),所以这个矩形的中心不是原点,而是我们指定给 glTranslatef()的位置,也就是爆炸的船只或入侵者的位置。最后,我们弹出模型-视图矩阵,并再次禁用混合。

就这样。十二类,形成一个全 3D 游戏,鹦鹉学舌经典太空入侵者游戏。试试看。等你回来,我们再来看看性能特点。

最佳化

在我们考虑优化游戏之前,我们必须评估它的表现如何。我们在 GameScreen 类中放了一个 FPSCounter,所以让我们看看它在一个 Hero、一个 Droid 和一个 Nexus 上的输出。

Hero (Android 1.5):
02–17 00:59:04.180: DEBUG/FPSCounter(457): fps: 25
02–17 00:59:05.220: DEBUG/FPSCounter(457): fps: 26
02–17 00:59:06.260: DEBUG/FPSCounter(457): fps: 26
02–17 00:59:07.280: DEBUG/FPSCounter(457): fps: 26

Nexus One (Android 2.2.1):
02–17 01:05:40.679: DEBUG/FPSCounter(577): fps: 41
02–17 01:05:41.699: DEBUG/FPSCounter(577): fps: 41
02–17 01:05:42.729: DEBUG/FPSCounter(577): fps: 41
02–17 01:05:43.729: DEBUG/FPSCounter(577): fps: 40

Droid (Android 2.1.1):
02–17 01:47:44.096: DEBUG/FPSCounter(1758): fps: 47
02–17 01:47:45.112: DEBUG/FPSCounter(1758): fps: 47
02–17 01:47:46.127: DEBUG/FPSCounter(1758): fps: 47
02–17 01:47:47.135: DEBUG/FPSCounter(1758): fps: 46

英雄挣扎了很久,但游戏可以以 25 FPS 的速度运行。Nexus One 达到了 41 FPS 左右,Droid 也达到了 47 FPS,可玩性相当不错。还能好起来吗?

就状态变化而言,这还不算太糟。我们可以减少一些多余的更改,比如一些 glEnable()/glDisable()调用,但是我们从以前的优化尝试中知道这样做不会减少很多开销。

关于英雄,有一件事我们可以做:禁用照明。一旦我们删除了 WorldRenderer.render()中相应的 glEnable()/glDisable()调用,以及 WorldRenderer.renderShip()和 WorldRenderer.renderInvaders(),Hero 将获得以下帧速率:

Hero (Android 1.5):
02–17 01:14:44.580: DEBUG/FPSCounter(618): fps: 31
02–17 01:14:45.600: DEBUG/FPSCounter(618): fps: 31
02–17 01:14:46.610: DEBUG/FPSCounter(618): fps: 31
02–17 01:14:47.630: DEBUG/FPSCounter(618): fps: 31

这是相当大的进步,我们所要做的就是关掉照明。对特定设备的呈现代码进行特殊处理是可能的,但是最好避免这样做。我们还能做什么吗?

我们渲染爆炸的方式在入侵者爆炸的情况下是次优的。我们在渲染所有入侵者的过程中改变了模型和纹理绑定,这让图形管道有点不高兴。不过爆炸不会经常发生,也不会花很长时间(1.6 秒)。此外,刚刚显示的测量是在没有任何屏幕爆炸的情况下进行的,所以这不是罪魁祸首。

事实是,我们每帧渲染了太多的对象,导致了大量的调用开销,并使流水线停顿了一点。以我们目前对 OpenGL ES 的了解,我们对此无能为力。然而,考虑到游戏“感觉”在所有设备上都可以玩,并不是一定要达到 60 FPS。众所周知,Droid 和 Nexus One 很难以 60 FPS 的速度渲染哪怕是稍微复杂的 3D 场景。所以,最后一个教训是:如果你的游戏没有以 60 FPS 运行,不要发疯。如果视觉流畅,玩的好,甚至可以凑合 30 FPS。

注意其他常见的优化策略包括使用剔除、顶点缓冲对象,以及这里没有讨论的其他更高级的主题。我们尝试将这些添加到我们的 Android 入侵者中,效果是:零。这些设备都没有从这些优化中受益。这并不意味着这些技术是无用的。这取决于很多因素及其副作用,很难预测某些配置将如何表现。如果你感兴趣,只需在网上搜索这些术语,并亲自尝试这些技术!

摘要

在这一章中,我们完成了我们的第三个游戏,一个成熟的 3D 太空入侵者克隆体。我们运用了在这本书的过程中学到的所有技巧和诀窍,最终的结果相当令人满意。当然,这些都不是 AAA 级游戏。事实上,在很长一段时间内,这些都不是令人愉快的。这就是你进来的地方。发挥创意,扩展这些游戏,让它们变得有趣!你有自己的工具。

十三、与 NDK 一起走向原生

在过去三章的 3D 铺张浪费之后,是时候来看看为 Android 编程游戏的另一个方面了。虽然就执行速度而言,Java 和 Dalvik VM 对于许多游戏类型来说已经足够了,但是有时候你需要更强的能力。这对于物理模拟、复杂的 3D 动画、碰撞检测等尤其如此。这种类型的代码最好用更“金属”的语言编写,如 C/C++ 甚至汇编语言。Android 本地开发套件(NDK)让我们做到了这一点。

用 C/C++ 实现 3D 动画或物理引擎超出了本书的范围。然而,在第八章中,我们发现了一个可以用一点本地代码修复的瓶颈。在 Android 上,将浮点数组复制到 ByteBuffer 非常慢。我们的一些 OpenGL ES 类依赖于这种机制。在这一章中,我们将研究用一些 C/C++ 代码来解决这个问题!

注意下面几节将向您展示如何从您的 Java 应用中与 C/C++ 代码交互。如果你对进入这个话题没有信心,就跳过这一章,如果你想知道更多,就回到这一章。

什么是安卓 NDK?

NDK 是对 Android SDK 的补充,它允许您编写 C/C++ 和汇编代码,然后您可以将它们集成到您的 Android 应用中。NDK 包括一组特定于 Android 的 C 库、一个基于 GNU 编译器集合(GCC)的交叉编译器工具链,该工具链可以编译 Android 支持的所有不同的 CPU 架构(ARM、x86 和 MIPS),以及一个定制的构建系统,与编写自己的 makefiles 相比,该系统应该可以使编译 C/C++ 代码更加容易。

在 NDK 的早期版本中,用 Eclipse 的调试器调试本机代码不是官方支持的功能。现在有一些官方的新工具正在工作中,使本地调试变得更加容易。在撰写本文时,这个功能是全新的,还没有经过测试,但是当您读到这些文字时,它可能已经更加成熟了。所以如果你需要调试你的本地代码,我们建议你快速搜索一下 Eclipse NDK 插件 ,看看有什么可用的。

NDK 没有公开大多数 Androids APIs,比如 UI 工具包。它主要是通过用 C/C++ 重写缓慢的 Java 方法并从 Java 内部调用它们来加速它们。从 Android 2.3 开始,使用 NativeActivity 类代替 Java activities,几乎可以完全绕过 Java。NativeActivity 类是专门为全窗口控制的游戏设计的,但它根本不提供对 Java 的访问,所以它不能与其他基于 Java 的 Android 库一起使用。许多来自 iOS 的游戏开发人员选择这条路线,因为这让他们可以重用 Android 上的大部分 C/C++,而不必深入研究 Android Java APIs。然而,诸如脸书认证或 ads 之类的服务的集成仍然需要用 Java 来完成,所以将游戏设计成在 Java 中启动并通过 JNI 调用 C++ 通常是最兼容的方式。也就是说,如何使用 JNI 呢?

Java 本地接口

Java 本地接口(JNI)是让虚拟机(以及 Java 代码)与 C/C++ 代码通信的一种方式。这是双向的;可以从 Java 调用 C/C++ 代码,也可以从 C/C++ 调用 Java 方法。Android 的许多库使用这种机制来公开本机代码,如 OpenGL ES 或音频解码器。

一旦使用了 JNI,您的应用就由两部分组成:Java 代码和 C/C++ 代码。在 Java 端,您通过添加一个名为 native 的特殊限定符来声明要在本机代码中实现的类方法。这可能看起来像这样:

package com.badlogic.androidgames.ndk;

public class MyJniClass {
    public native int add(int a,int b);
}

如您所见,我们声明的方法没有方法体。当运行 Java 代码的 VM 在方法上看到这个限定符时,它知道相应的实现是在共享库中找到的,而不是在 JAR 文件或 APK 文件中。

共享库非常类似于 Java JAR 文件。它包含编译的 C/C++ 代码,任何加载这个共享库的程序都可以调用这些代码。在 Windows 上,这些共享库通常带有后缀。dll 在 Unix 系统上,它们以. so 结尾。

在 C/C++ 方面,我们有许多头文件和源文件,它们定义了 C 中本地方法的签名,并包含实际的实现。在前面的代码中,我们的类的头文件看起来像这样:

 /* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_badlogic_androidgames_ndk_MyJniClass */

#ifndef _Included_com_badlogic_androidgames_ndk_MyJniClass
#define _Included_com_badlogic_androidgames_ndk_MyJniClass
#ifdef __cplusplus 
extern "C" {
#endif 
/*
 * Class:     com_badlogic_androidgames_ndk_MyJniClass
 * Method:    add
 * Signature: (II)I
 */
JNIEXPORT jint JNICALL Java_com_badlogic_androidgames_ndk_MyJniClass_add
  (JNIEnv *, jobject, jint, jint);

#ifdef __cplusplus 
}
#endif 
#endif

这个头文件是用一个叫做 javah 的 JDK 工具生成的。该工具将一个 Java 类作为输入,并为它找到的任何本机方法生成一个 C 函数签名。这里发生了很多事情,因为 C 代码需要遵循特定的命名模式,并且需要能够将 Java 类型封送到它们对应的 C 类型(例如,Java 的 int 变成了 C 中的 jint)。我们还获得了 JNIEnv 和 jobject 类型的两个附加参数。第一个可以被认为是虚拟机的句柄。它包含与 VM 通信的方法,比如调用类实例的方法。第二个参数是调用该方法的类实例的句柄。我们可以将它与 JNIEnv 参数结合使用,从 C 代码中调用这个类实例的其他方法。

这个头文件还不包含函数的实现。我们需要一个实现该功能的相应 C/C++ 源文件:

#include "myjniclass.h"

JNIEXPORT jint JNICALL Java_com_badlogic_androidgames_ndk_MyJniClass_add
    (JNIEnv * env, jobject obj, jint a, jint b) {
    return a + b;
}

这些 C/C++ 源代码被编译到一个共享库中,然后我们通过一个特定的 Java API 加载这个库,这样 VM 就可以找到我们的 Java 类的本地方法的实现:

 System.loadLibrary("myjnitest");
int result = new MyJniClass().add(12, 32);

对 System.loadLibrary()的调用采用共享库的名称。它如何找到相应的文件在某种程度上取决于 VM 实现。这个方法只在启动时调用一次,这样 VM 就知道在哪里可以找到任何本地库的实现。如您所见,我们可以像调用任何其他 Java 类方法一样调用 MyJniClass.add()方法!

说够了。让我们用 Android 上的一点 C 代码来弄脏我们的手吧!我们将编写一些简单的 C 函数,希望从我们的 Java 应用中调用这些函数。我们将指导您完成编译共享库、加载它以及调用本机方法的过程。

设置 NDK

开始之前,我们必须安装 NDK。这其实是一个相当简单的过程。

  1. 前往developer.android.com/tools/sdk/ndk/index.html,为你的*台选择归档。

  2. 将存档文件解压到您喜欢的位置,并记下它的位置。

  3. Add the base NDK directory to your system path:

    a.在 Linux 或 Mac OS X 上,打开一个 shell,将 NDK 安装目录的路径添加到$PATH 环境变量中。一般是在。配置文件,该行如下所示:导出路径= $ PATH:/PATH/to/your/ndk/installation

    b.在 Windows 上,选择控制面板image系统和安全image系统image高级系统设置image环境变量image系统变量,在系统变量列表中选择 Path,点击编辑,将目录添加到变量值的末尾,以分号开头(如;c:\安卓 _NDK)。

  4. 要验证您的 NDK 是否已经成功安装,请在您的终端中发出命令 ndk-build。它应该吐出一些关于缺少一个 Android 项目的评论。

  5. 在第二章中,我们设置了 JDK,并将它的 bin/目录添加到我们的路径中。通过发出 javah 命令,确保该目录中的工具仍然可用。它应该打印出该工具的使用信息。我们以后会需要它。

建立 NDK Android 项目

与前面的编码章节一样,您必须创建一个新的 Android 项目。复制第十二章的所有框架代码。然后,创建一个名为 com.badlogic.androidgames.ndk 的新包,将前面章节中的一个 starter 活动的副本放入其中,将其重命名为 NdkStarter,并使其成为启动器活动。和往常一样,记住向清单文件和启动活动添加任何新的测试。

为了使事情变得简单,您应该现在打开您的终端并导航到那个新项目的根目录。确保您的路径仍然包含正确的条目,以便可以调用 ndk-build 和 javah 工具。

创建 Java 本地方法

正如我们之前看到的,指定 Java 类的哪些方法在本机代码中实现是相当简单的。然而,在定义传递给方法的类型和从方法中得到的返回类型时,我们需要小心。

虽然我们可以将任何 Java 类型传递给本机方法,但有些类型比其他类型更难处理。最容易处理的类型是基本类型,如 int、byte、boolean、C 等等,它们直接对应于等价的 C 类型。在 C/C++ 端,下一个最容易处理的类型是基元类型的一维数组,比如 int[]或 float[]。这些数组可以用我们前面看到的 JNIEnv 类型提供的方法转换成 C 数组或指针。接下来是直接 ByteBuffer 实例。和数组一样,它们可以很容易地转换成指针。根据不同的用例,字符串也可以很容易使用。对象和多维数组更难处理。在 C/C++ 端使用这些 API 类似于在 Java 端使用反射 API。

我们也可以从本地方法返回任何 Java 类型。原始类型也很容易处理。返回其他类型通常涉及在 C/C++ 端创建该类型的实例,这可能相当复杂。

我们将只研究传递基本类型、数组、ByteBuffer 实例和字符串。如果你想知道更多关于如何通过 JNI 处理类型的信息,我们建议你参考(在线)书籍 Java Native Interface 5.0 规范,在docs . Oracle . com/javase/1 . 5 . 0/docs/guide/JNI/spec/jnitoc . html

对于我们的 JNI 实验,我们将创建两个方法。一个将把 float[]复制到 C 代码中的 direct ByteBuffer,另一个将把一个字符串打印到 LogCat。清单 13-1 显示了我们的 JniUtils 类。

清单 13-1 。【JniUtils.java】;我们保持简单

package com.badlogic.androidgames.ndk;

import java.nio.ByteBuffer;

public class JniUtils {
    static {
        System.loadLibrary("jniutils");
    }
    public static native void log(String tag, String message);

    public static native void copy(ByteBuffer dst,float[] src,int offset,int len);

}

该类从一个静态块开始。该块中的代码将在 VM 第一次遇到对 JNIUtils 类的引用时被调用。这是调用 System.loadLibrary()的最佳位置,它将加载我们稍后将编译的共享库。我们传递给方法的参数是共享库的纯名称。正如我们将在后面看到的,实际的文件名为 libjniutils.so。该方法将自己解决这个问题。

log()方法模仿 Android Java Log.logd()方法。它需要一个标签和一条消息,这条消息将被打印到 LogCat。

copy()方法其实很有用。在第八章中,我们研究了 FloatBuffer.put()方法的性能问题。我们求助于使用一个纯 Java 实现,该实现使用了 IntBuffer 和一些令人讨厌的技巧,这样我们可以加速将一个浮点数组复制到 Vertices 类中的一个直接 ByteBuffer。我们现在将实现一个方法,该方法采用一个直接 ByteBuffer 和一个 float 数组,并将该数组复制到缓冲区。这比使用相应的 Java APIs 要快得多。我们可以稍后修改我们的顶点和顶点 3 类来使用这个新功能。

请注意,这两种方法都是静态方法,而不是实例方法。这意味着我们可以在没有 JniTest 类实例的情况下调用它们!这也对我们的 C 签名有很小的影响,我们稍后会看到。

创建 C/C++ 头文件并实现

当我们开始编写 C/C++ 代码时,我们做的第一件事是通过 Java JDK 命令行工具生成头文件。它需要几个对我们有用的参数:

  • 输出文件的名称,在我们的例子中是 jni/jniutils.h。如果 jni/文件夹还不存在,javah 工具将为我们创建它。
  • 包含的路径。应该为其生成 C 头文件的 Java 类的类文件。如果我们从项目的根目录调用 javah,这将是 bin/classes。这是 Eclipse 编译器编译我们的 Android 项目的任何源文件时的输出路径。
  • 该类的完全限定名,在我们的例子中是 com . bad logic . androidgames . ndk . jniutils。

打开终端或命令提示符,导航到 Android 项目的根文件夹。如前所述,确保 NDK 和 JDK 位于您的$PATH 中。现在执行以下命令:

javah -o jni/jniutils.h -classpath bin/classes com.badlogic.androidgames.ndk.JniUtils

这将在我们的 Android 项目的 jni/文件夹中创建一个名为 jniutils.h 的文件。清单 13-2 显示了它的内容。

清单 13-2 。 jniutils.h,包含实现我们本地方法的 C 函数

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_badlogic_androidgames_ndk_JniUtils */

#ifndef _Included_com_badlogic_androidgames_ndk_JniUtils
#define _Included_com_badlogic_androidgames_ndk_JniUtils
#ifdef __cplusplus 
extern "C" {
#endif 
/*
 * Class:     com_badlogic_androidgames_ndk_JniUtils
 * Method:    log
 * Signature: (Ljava/lang/String;Ljava/lang/String;)V
 */
JNIEXPORT void JNICALL Java_com_badlogic_androidgames_ndk_JniUtils_log
  (JNIEnv *, jclass, jstring, jstring);
/*
 * Class:     com_badlogic_androidgames_ndk_JniUtils
 * Method:    copy
 * Signature: (Ljava/nio/ByteBuffer;FII)V
 */
JNIEXPORT void JNICALL Java_com_badlogic_androidgames_ndk_JniUtils_copy
  (JNIEnv *, jclass, jobject, jfloatArray, jint, jint);

#ifdef __cplusplus 
}
#endif 
#endif

是时候实现这些功能了。首先,我们在 jni/文件夹中创建一个名为 jniutils.cpp 的新文件。清单 13-3 显示了它的内容。

[清单 13-3 。 jniutils.cpp,jniutils 原生方法的实现

#include <android/log.h>
#include <string.h>
#include "jniutils.h"

我们需要几个 C includes,即 log.h,它是由 NDK、string.h 和我们自己的 jniutils.h 提供的。第一个 include 让我们可以使用原生的 Android 日志功能。第二个 include 让我们使用 memcpy()。最后一个导入我们本地方法的签名以及 jni.h,它包含 JNI API。

JNIEXPORT void JNICALL Java_com_badlogic_androidgames_ndk_JniUtils_log
  (JNIEnv *env, jclass clazz, jstring tag, jstring message) {
   const char *cTag = env-> GetStringUTFChars(tag, 0);
   const char *cMessage = env-> GetStringUTFChars(message, 0);

   __android_log_print(ANDROID_LOG_VERBOSE, cTag, cMessage);
   env-> ReleaseStringUTFChars(tag, cTag);
   env-> ReleaseStringUTFChars(message, cMessage);
}

该函数实现 JniUtils.log()方法,该方法将一个 JNIEnv 和一个 jclass 作为前两个参数。env 参数允许我们直接使用 JVM。jclass 参数表示 JniUtils 类。记住我们的方法是静态方法。与前面的例子不同,我们得到了一个 jobject,而是一个类。标签和消息参数是我们从 Java 传入的两个字符串。

log.h 头定义了一个名为 __android_log_print 的函数,类似于标准的 C printf 函数。它采用一个日志级别和两个表示标记和消息的 char指针。我们的标记和消息参数具有 jstring 类型,不能转换为 char指针。相反,我们必须通过 env 参数公开的方法将它们临时转换成 char*指针。这是通过调用 env-> GetStringUTFChars()在函数的前两行中完成的。

接下来,我们简单地调用 logging 方法,传入参数。最后,我们需要清理转换后的字符串,这样我们就不会泄漏内存。这是通过 env-> ReleaseStringUTFChars()完成的。

JNIEXPORT void JNICALL Java_com_badlogic_androidgames_ndk_JniUtils_copy
  (JNIEnv *env, jclass clazz, jobject dst, jfloatArray src, jint offset, jint len) {
    unsigned char* pDst = (unsigned char* )env-> GetDirectBufferAddress(dst);
    float* pSrc = (float* )env-> GetPrimitiveArrayCritical(src, 0);
    memcpy(pDst, pSrc + offset, len * 4);
    env-> ReleasePrimitiveArrayCritical(src, pSrc, 0);
}

第二个函数接受一个直接的 ByteBuffer、一个 float 数组、一个 float 数组的偏移量以及我们想要复制的 float 的数量。注意,ByteBuffer 的类型是 jobject!无论何时传入除基本类型或数组之外的任何东西,都会得到一个 jobject。您的 C/C++ 代码需要知道预期的类型!在我们的例子中,我们知道我们得到了一个 ByteBuffer 实例。ByteBuffer 实例只是本机内存区域周围的薄薄的包装。它们在 C 语言中非常容易处理;我们可以简单地通过 env-> GetDirectBufferAddress()获取指向它们的内存地址的指针。

我们的浮点数组有点难以处理。env-> GetPrimitiveArrayCritical()方法将锁定数组并返回指向其第一个元素的指针。使用该功能是危险的;您不应该尝试在 Java 中同时修改数组。从这一点开始,也禁止调用任何其他 JNI 方法。否则,您将在 C/C++ 代码中得到难以调试的行为!

一旦有了指针,我们只需使用 memcpy()将 float 数组的内容复制到 ByteBuffer。注意,我们不执行任何类型的边界检查,这意味着调用该方法的 Java 代码必须是防弹的。试图将比我们分配的更多的浮点复制到 ByteBuffer 中可能会导致严重的分段错误。当我们在传入的浮点数组之外指定偏移量和长度时,情况也是如此。总的来说,当你使用 JNI 和 NDK 时,你必须知道你在做什么。如果不这样做,您的应用将会出现难以调试的错误!

在函数的最后,我们通过调用 env-> ReleasePrimitiveArrayCritical()再次解锁浮点数组。在任何情况下都必须调用此方法;否则你会遇到各种各样的问题。

有了 C/C++ 头文件和源文件,就该构建共享库了。

构建共享库

如前所述,NDK 有自己的构建系统。虽然它仍然使用标准的 makefiles,但是作为用户,您不必处理它们的复杂性。相反,您需要编写两个文件:一个 Application.mk 文件,指定您希望针对的 CPU 架构;另一个 Android.mk 文件,定义您希望链接到的其他库、要编译的源文件以及最终共享库的调用方式。

Application.mk 文件放在 jni/文件夹中。清单 13-4 显示了它的内容。

清单 13-4 。 Application.mk,定义我们要瞄准的 CPUs】

APP_ABI := armeabi armeabi-v7a x86 mips

这就是全部了!它定义了我们的本地代码应该运行的四种架构。ARM 架构是最常见的目标——几乎所有当前的 Android 设备都有 ARM CPU。据传闻,x86 架构可以在即将发布的英特尔设备中找到。也有一些仿真器镜像支持这种架构。MIPS 架构目前被少数低端 Android *板电脑使用。

定义了架构之后,我们现在可以转到 Android.mk 文件,该文件指定了我们的本机代码应该如何构建。这个文件也位于 jni/文件夹中。清单 13-5 显示了内容。

清单 13-5 。 Android.mk,指定我们的构建

LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)

LOCAL_MODULE    := jniutils
LOCAL_LDLIBS := − llog
LOCAL_ARM_MODE  := arm
LOCAL_SRC_FILES := jniutils.cpp
include $(BUILD_SHARED_LIBRARY)

The first two lines are prettyThe first two lines are pretty The first two lines are pretty

前两行几乎是样板文件。它们确保路径被正确处理,并且各种变量被重置。

下一行定义了我们共享库的名称,在本例中是 jniutils。

接下来,我们指定要链接的库。我们使用原生的 Android 日志记录工具,所以我们链接到 NDK 提供的 liblog 库。

下一行特定于 ARM 架构。它告诉构建系统我们想要生成非 thumb 代码。ARM 处理器有两种工作模式,thumb 和 32 位 ARM。前者会产生更小的代码,但通常会更慢。对于我们的 C/C++ 代码来说,这并不重要,但是启用这个选项是一个很好的实践。

接下来,我们指定应该编译的 C/C++ 源文件。我们只有一份文件。要指定附加文件,只需将它们添加到同一行,用空格隔开,或者添加到新的一行。如果您选择第二个选项,您必须在前一行的末尾追加一个反斜杠。

最后一行告诉构建系统生成一个共享库。我们也可以让它生成一个静态库,然后我们将它编译成一个与其他静态库共享的库。这是更复杂的 JNI 项目使用的机制。我们对一个简单的共享库很满意。

注意 NDK 的建造系统是一个非常复杂的系统。使用 Android.mk 和 Application.mk 文件,您几乎可以修改构建代码的任何方面。如果您想了解关于构建系统的更多信息,请查看您的 NDK 安装的 doc/文件夹。

现在是时候建立我们的共享库了。为此,打开终端,确保 PATH 环境变量正常,导航到项目的根目录,发出以下命令:

ndk-build

如果一切顺利,您应该会看到以下输出:

Compile++ arm    : jniutils <= jniutils.cpp
In file included from jni/jniutils.h:2:0,
                 from jni/jniutils.cpp:2:
D:/workspaces/book/android-ndk-r8b/platforms/android-14/arch-arm/usr/include/jni.h:592:13: note: the mangling of 'va_list' has changed in GCC 4.4
StaticLibrary  : libstdc++.a
SharedLibrary  : libjniutils.so
Install        : libjniutils.so => libs/armeabi/libjniutils.so
Compile++ arm    : jniutils <= jniutils.cpp
In file included from jni/jniutils.h:2:0,
                 from jni/jniutils.cpp:2:
D:/workspaces/book/android-ndk-r8b/platforms/android-14/arch-arm/usr/include/jni.h:592:13: note: the mangling of 'va_list' has changed in GCC 4.4
StaticLibrary  : libstdc++.a
SharedLibrary  : libjniutils.so
Install        : libjniutils.so => libs/armeabi-v7a/libjniutils.so
Compile++ x86    : jniutils <= jniutils.cpp
StaticLibrary  : libstdc++.a
SharedLibrary  : libjniutils.so
Install        : libjniutils.so => libs/x86/libjniutils.so
Compile++ mips   : jniutils <= jniutils.cpp
StaticLibrary  : libstdc++.a
SharedLibrary  : libjniutils.so
Install        : libjniutils.so => libs/mips/libjniutils.so

这个神秘的输出告诉我们一些事情。首先,我们的代码分别针对四种 CPU 架构进行了编译。生成的共享库都称为 libjniutils.so,它们放在 libs/文件夹中。每个架构都有一个子目录(例如 armeabi 或 x86)。当我们编译我们的 APK 时,这些共享库被打包到我们的应用中。当我们调用 System.loadLibrary()时,如清单 13-1 中的所示,Android 知道在哪里为我们的应用当前运行的架构找到正确的共享库。太好了,让我们来测试一下!

注意每次修改 C/C++ 代码时,您都必须调用 ndk-build 来重建共享库。如果您有多个源文件,并且只修改了文件的一个子集,构建工具将只重新编译发生更改的文件,从而减少编译时间。如果需要确保所有源文件都被重新编译,只需调用 ndk-build clean。

把这一切放在一起

我们现在已经准备好测试我们的本地方法了。让我们创建一个调用两个 JniUtils 方法的测试。我们调用 JniUtilsTest 类,它扩展了 GLGame 类,并像往常一样包含一个 GLScreen 实现。它只是将一个 float[]数组复制到一个直接的 ByteBuffer 中,然后通过另一个本地方法将 ByteBuffer 的内容输出到 LogCat。清单 13-6 显示了完整的代码。不要忘记将它添加到 NdkStarter 类和清单文件中。

清单 13-6 。JniUtilsTest.java,测试我们的原生方法

package com.badlogic.androidgames.ndk;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;

import com.badlogic.androidgames.framework.Game;
import com.badlogic.androidgames.framework.Screen;
import com.badlogic.androidgames.framework.impl.GLGame;
import com.badlogic.androidgames.framework.impl.GLScreen;

public class JniUtilsTestextends GLGame {

    public Screen getStartScreen() {
        return new JniUtilsScreen(this);
    }

    class JniUtilsScreenextends GLScreen {
        public JniUtilsScreen(Game game) {
            super (game);
            float[] values = { 1.231f, 554.3f, 348.6f, 499.3f };
            ByteBuffer buffer = ByteBuffer.allocateDirect(3 * 4);
            buffer.order(ByteOrder.nativeOrder());

            JniUtils.copy(buffer, values, 1, 3);
            FloatBuffer floatBuffer = buffer.asFloatBuffer();
            for (int i = 0; i < 3; i++) {
                JniUtils.log("JniUtilsTest", Float.toString(floatBuffer.get(i)));
            }
        }
        @Override
        public void update(float deltaTime) {
        }
        @Override
        public void present(float deltaTime) {
        }

        @Override
        public void pause() {
        }
        @Override

        public void resume() {
        }

        @Override
        public void dispose() {
        }
    }
}

所有重要的事情都发生在屏幕的构造函数中。我们首先创建一个小型 float[]数组,其中包含一些虚拟值和一个直接 ByteBuffer 实例,该实例可以容纳 12 个字节,即 4 个浮点数。我们还确保 ByteBuffer 实例使用本机字节顺序,这样当我们将它传递给 C/C++ 代码时就不会遇到一些令人讨厌的问题。

然后,我们从 float[]数组复制三个浮点数,从索引 1 开始,到 ByteBuffer 实例。最后几行通过我们的本地日志记录方法输出复制的浮点。

在设备上执行此代码将在 LogCat 中输出以下内容:

08–15 17:28:31.953: V/JniUtilsTest(1901): 554.3
08–15 17:28:31.953: V/JniUtilsTest(1901): 348.6
08–15 17:28:31.953: V/JniUtilsTest(1901): 499.3

正是我们所期待的。让我们快速修改 Vertices 和 Vertices3 类,以使用我们新的、更快的 copy()方法。

修改 vertices 类从一个问题开始:JniUtils 类不在 com . bad logic . androidgames . framework 包中;而是在 com.badlogic.androidgames.ndk 包中。让我们把它移到框架包中,这样我们就可以重用它了。

这就引出了一个新问题。我们的 C/C++ 头文件和源文件依赖于 JniUtils 类在 ndk 包中这一事实。既然我们已经将它移动到框架包中,我们必须相应地修改头文件和源文件。我们需要做的第一件事是再次调用 javah。这将更新 jniutils.h 文件。接下来,我们必须将新的函数名复制到 jniutils.cpp 文件中。最后,我们需要通过调用 ndk-build 来重新编译共享库。这么小的变化要做相当多的工作。

注意如果您在命令行上重建共享库,Eclipse 和 ADT 插件本身不会选择新的共享库。如果你在编译完共享库后立即运行你的应用,它仍然会使用旧的共享库!要解决这个问题,请在 Eclipse 的 Package Explorer 视图中选择您的 Android 项目,然后按 F5。

既然我们已经固定了 JniUtils 类的位置,我们可以修改 Vertices 和 Vertices3 类了。两者都有一个名为 IntBuffer 类型的顶点的字段。我们现在可以将这些字段改为 ByteBuffer 实例。我们还可以去掉两个类中的 tmpBuffer 字段,因为我们不再需要转换任何东西。我们需要做的就是修改构造函数,以便它们可以再次编译,并修改 setVertices()方法,以便它们可以使用我们的本地方法。清单 13-7 和清单 13-8 分别显示了顶点和顶点 3 发生变化的部分。

清单 13-7 。【Vertices.java】节选,用 JniUtils

public class Vertices {
    final GLGraphics glGraphics;
    final boolean hasColor;
    final boolean hasTexCoords;
    final int vertexSize;
    final ByteBuffer vertices;
    final ShortBuffer indices;

    public Vertices(GLGraphics glGraphics,int maxVertices,int maxIndices,boolean hasColor,boolean hasTexCoords) {
        this .glGraphics = glGraphics;
        this .hasColor = hasColor;
        this .hasTexCoords = hasTexCoords;
        this .vertexSize = (2 + (hasColor?4:0) + (hasTexCoords?2:0)) * 4;

        this .vertices = ByteBuffer.allocateDirect(maxVertices * vertexSize);
        this .vertices.order(ByteOrder.nativeOrder());

        if (maxIndices > 0) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(maxIndices * Short.SIZE / 8);
            buffer.order(ByteOrder.nativeOrder());
            this. indices = buffer.asShortBuffer();
        }else {
            this .indices = null;
        }
    }
    public void setVertices(float[] vertices,int offset,int length) {
        this .vertices.clear();
        JniUtils.copy(this .vertices, vertices, offset, length);
        this .vertices.position(length * 4);
    }
    public void bind() {
        GL10 gl = glGraphics.getGL();

        gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
        vertices.position(0);
        gl.glVertexPointer(2, GL10.GL_FLOAT, vertexSize, vertices);

        if (hasColor) {
            gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
            vertices.position(8);
            gl.glColorPointer(4, GL10.GL_FLOAT, vertexSize, vertices);
        }

        if (hasTexCoords) {
            gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
            vertices.position(hasColor?24:8);
            gl.glTexCoordPointer(2, GL10.GL_FLOAT, vertexSize, vertices);
        }
    }
// rest as before

清单 13-8 。【Vertices3.java】节选,用 JniUtils

public class Vertices3 {
    final GLGraphics glGraphics;
    final boolean hasColor;
    final boolean hasTexCoords;
    final boolean hasNormals;
    final int vertexSize;
    final ByteBuffer vertices;
    final ShortBuffer indices;

    public Vertices3(GLGraphics glGraphics,int maxVertices,int maxIndices,
            boolean hasColor,boolean hasTexCoords,boolean hasNormals) {
        this .glGraphics = glGraphics;
        this .hasColor = hasColor;
        this .hasTexCoords = hasTexCoords;
        this .hasNormals = hasNormals;
        this .vertexSize = (3 + (hasColor ? 4 : 0) + (hasTexCoords ? 2 : 0) + (hasNormals ? 3  : 0)) * 4;

        this .vertices = ByteBuffer.allocateDirect(maxVertices * vertexSize);
        this .vertices.order(ByteOrder.nativeOrder());

        if (maxIndices > 0) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(maxIndices * Short.SIZE / 8);
            buffer.order(ByteOrder.nativeOrder());
            this .indices = buffer.asShortBuffer();
        }else {
            this .indices = null;
        }
    }
    public void setVertices(float[] vertices,int offset,int length) {
        this .vertices.clear();
        JniUtils.copy(this .vertices, vertices, offset, length);
        this .vertices.position(length * 4);
    }
    public void bind() {
        GL10 gl = glGraphics.getGL();

        gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
        vertices.position(0);
        gl.glVertexPointer(3, GL10.GL_FLOAT, vertexSize, vertices);

        if (hasColor) {
            gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
            vertices.position(12);
            gl.glColorPointer(4, GL10.GL_FLOAT, vertexSize, vertices);
        }

        if (hasTexCoords) {
            gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
            vertices.position(hasColor ? 28 : 12);
            gl.glTexCoordPointer(2, GL10.GL_FLOAT, vertexSize, vertices);
        }

        if (hasNormals) {
            gl.glEnableClientState(GL10.GL_NORMAL_ARRAY);
            int offset = 12;
            if (hasColor)
                offset += 16;
            if (hasTexCoords)
                offset += 8;
            vertices.position(offset);
            gl.glNormalPointer(GL10.GL_FLOAT, vertexSize, vertices);
        }
    }
// rest as before

最大的变化在 setVertices()和 bind()中。在 setVertices()中,我们现在使用 JniUtils 类将 float[]数组复制到直接 ByteBuffer 实例。注意,我们手动设置缓冲器的极限和位置。这是必要的,因为我们的 JNI 方法不操纵缓冲区的位置和限制字段。但是,我们将缓冲区传递给的 OpenGL ES 方法可能会使用该信息。

在 bind()中,我们必须修改位置偏移量,因为我们现在是以字节而不是整数计数。

注意如果你注意的话,你会发现我们可以对 ByteBuffer 的索引做同样的事情。这需要向 JniUtils 添加一个新的本机方法,该方法采用一个短数组。试试看!

通过使用我们的 native copy()方法,我们可以获得相当大的速度,特别是当我们通过 SpriteBatcher 类绘制许多精灵时。每一帧都必须将顶点从 CPU 移动到 GPU 会降低性能,而通过先制作副本然后将所有内容转换为 int 来这样做甚至更糟。我们新的 JniUtils 大大减少了复制的数量,同时也比我们之前使用的 IntBuffer 技巧更快!

要测试我们的实现,只需复制 SpriteBatcherTest 和 ObjTest 以及前面章节中所有必要的素材。将它们与 NdkStarter 活动联系起来,并在您的 Android 设备上试用它们。

摘要

我们仅仅触及了 NDK 的皮毛。但是,正如您所看到的,即使是轻量级使用 NDK 也能获得可观的回报。我们的 copy()方法实现起来很简单,但是通过比旧的基于 Java 的实现快得多,我们可以每帧显示更多的精灵。这是 NDK 开发中一个反复出现的主题:识别瓶颈,在本机端只实现非常小的代码片段。不要走极端,因为调用本机方法有开销。仅仅让所有东西都成为本机方法不可能提高应用的性能。

现在是时候考虑我们游戏的营销因素了。*

十四、营销和货币化

因此,读完前面的 13 章,你现在已经掌握了构建一个优秀的 Android 游戏所需的技能和知识。是不是像“建了就发财了”这么简单?肯定不是!通过营销和货币化你的游戏来赚钱本身就是一项技能,你需要学习很多东西才能在这方面取得成功——不仅仅是在你的游戏完成之后,甚至是在你开始开发它之前。真正的赚钱机器往往从计划阶段就被设计成受欢迎和赚钱,除此之外还要做得好。

赚钱你的游戏

货币化是一个常用术语,大致意思是从产品或服务中赚钱的过程。当有人说,“我需要用我的游戏赚钱”时,他们所说的是,他们需要利用游戏当前的用户群,并找到一种方式开始从中赚取真金白银。

有很多方法可以让你的游戏赚钱:

  • 横幅广告 : 通常基于点击付费的图形或文字广告。我们都在网络、应用和游戏中看到过横幅广告。它们在网站上无处不在。
  • 全屏广告 : 很像横幅广告,除了它们需要用户更多的注意力,通常需要用户点击一个按钮或者等待一段时间来消除它们。这种格式的视频和注册表单很常见。
  • 应用内购买 : 供用户用真钱购买内容、物品、升级或虚拟货币的选项。一个例子是花 99 美分买一个关卡包,或者花 1.99 美元买一个“低重力”的作弊软件。
  • 虚拟货币奖励 : 为玩家提供游戏内虚拟货币以换取完成任务的选项。例如,可以向用户提供安装第三方应用的选项,以换取 50 个游戏内硬币,这些硬币可用于购买角色的升级。在这种情况下,第三方应用发行商为该安装向服务支付费用,服务反过来向游戏开发者支付该费用的一部分。这些通常被称为“发现”服务,因为出版商付费让新用户发现他们的应用。
  • 直销 : 用户直接为游戏付费并安装。这是在广告和虚拟货币出现之前的第一种货币化方式。
  • 许可 : 游戏开发商将游戏许可给其他公司进行捆绑、预装或其他大规模部署的广泛选择。

当然,通过你的游戏赚钱的方法还有很多,但这些绝对是最常见的方法。接下来将对它们进行更深入的讨论。

广告

广告是安卓游戏货币化的第一个“大”方式之一。有趣的是,我们可以告诉你,广告最好被视为一种补充,而不是主要的货币化方法,因为它的波动性。移动广告空间在创收方面非常不可靠,而且竞争非常激烈。基本上,有很多移动应用广告,但这些广告背后的钱并不多,所以自然地,钱在整个生态系统中分散开来。这是否意味着在你的免费安卓游戏中放一些广告不是一个好主意?肯定不会。仅仅通过广告收入仍然有可能赚很多钱,但是要准备好测试许多不同的广告解决方案,找到最适合你的市场定位的一个。

广告提供商

广告提供商直接从广告商那里投放广告,并为你的点击付费。你只得到广告商为点击付费的一部分,其余的归广告提供商,这就是广告提供商赚钱的方式。广告内容的范围可以从汽车零件到膳食补充剂。通常情况下,你需要为你的应用配置关键词,这样内容才能更贴*你的用户。广告提供商使用各种格式,但最常见的是横幅广告和全屏广告。目标正在快速移动,但这里有几个主要广告提供商的例子:

  • 谷歌(www.admob.com)旗下的 AdMob
  • 千禧传媒(www.millenialmedia.com)
  • 灰色条纹(www.greystripe.com)

广告聚合服务

对于移动广告,不仅仅是实现横幅或全屏广告的问题。你也可以考虑广告聚合服务。聚合服务为你的游戏提供来自许多提供商的广告。通过这种方式,您可以建立一个单一的广告位置(如“横幅广告”),并依靠一个聚合器来搜索不同的广告提供商,为您的广告空间寻找最有价值的广告,从而让您在该位置获得最高报酬的广告。在过去的几年中,许多这样的服务已经出现,但是这里有一个当前主要参与者的快速列表:

  • mop ub(www . mop ub . com
  • 阿德维尔(www.adwhirl.com)
  • inner active(inner-active.com)
  • Mobclix ( www.mobclix.com

这些服务中的每一个都有自己的 API,其中大多数通常都很容易使用。对于任何特定的服务,请访问他们的网站并查看他们的文档,以获得有关实现的更多技术信息。

游戏广告技巧

所以,如果你已经决定在你的游戏中做广告,最好的方法是什么?有时候越少越好,所以让我们来浏览一下与你的广告相关的清单:

  • 打断你的玩家,因为他们正在进行
  • 挫败你的玩家
  • 一个广告总是出现,破坏了原本美好的体验
  • 欺骗用户点击

广告很容易做过头。广告的目的是从你的游戏中赚钱,对吗?广告多不代表钱多;相反,更多的广告通常会导致你的游戏收视率降低,而收视率降低会导致用户减少,这无论如何都不是好事。保持广告曝光有品味。用户希望用一些广告来换取免费游戏。在空闲时间将广告放在菜单的顶部或底部是很好的做法。

全屏广告,等等!当用户开始你的游戏时,你可能想立刻显示广告,但是你会失去可信度。等到玩家第三次、第四次、甚至第五次启动游戏时,才显示全屏广告。

专注于建立良好的游戏体验,你的玩家会更信任你的广告。如果你太关注如何最大化你的支出,你的用户会感觉到,你的游戏会受到影响。

应用内产品

应用内产品(IAP)可以成为巨大的赚钱机器。总的想法是,玩家想要你游戏中提供的东西,并愿意支付少量的钱来得到它。Google Play 商店有两种应用内产品选项:

  • 管理物品:人物、等级、能力等物品。被管理的项目只能购买一次,并且它们附属于购买者,而不是附属于设备;也就是说,如果买家在一个设备上购买了该物品,只要买家在所有设备上使用相同的谷歌账户,该物品就可以在买家的其他设备上使用。
  • 未被管理的物品 : 虚拟货币、硬币、消耗性升级、入场券以及其他任何被玩家“用完”并需要“补充”的物品不受管理的物品可以反复购买,增加现有的数量或金额。未被管理的项目也会附加到 Google 帐户,而不是购买它们的设备。

除此之外,您还可以设置定期计费的订阅。你的游戏在任何时间点都将订阅视为“开”或“关”,因此你可以在玩家订阅时提供任何内容、服务、升级或其他功能。

用于处理 IAP 的 Google Play 服务称为应用内计费。Google Play 开发者控制台为你的游戏提供了一个设置可购买物品的界面。命名您的可购买产品的最兼容方式是使用您的应用包名称作为前缀;例如,com . badlogicgames . awesome game . 300 积分可用于 300 积分的购买。

那么,设置可购买产品的好策略是什么呢?没有明确的答案,因为这完全取决于你的游戏。一个非常有效的策略是使用虚拟货币做任何事情。

虚拟货币

虚拟货币(VC) 是游戏中的货币。无论你称它为黄金、宝石、红宝石、硬币、信用或你发明的名字,VC 是你的游戏为玩家存储的一个数字,允许玩家购买东西。你的 VC 不需要和真金白银有一对一的关系,和游戏有明确的关系就可以了。风险投资很好,因为获得它成为游戏的主要或次要目标,你可以利用这个目标来赚钱。在不深入了解 it 心理学的情况下,人们似乎有购买 VC 的动机,因为他们要么希望在游戏中找到捷径,享受个性化的游戏体验,要么就是喜欢买东西。

VC 是目前游戏提供的最受欢迎的可购买物品类型之一,通常按等级出售,大额购买有折扣;例如,0.99 美分的 100 点,3.99 美元的 500 点,29.99 美元的 5000 点,等等。

许多游戏程序员使用的一个好策略是奖励游戏中的 VC 行为,比如打碎金砖或赢得关卡。一些游戏有计时关卡,在更短的时间内击败关卡会奖励更多的 VC。其他游戏有多种类型的 VC,以便程序员可以控制一种类型的 VC 或另一种类型的 VC 的稀缺程度,迫使玩家在购买物品和升级之间更仔细地选择,或者玩得更多以获得足够的一种类型的 VC,以便他们可以购买特定的升级或物品。一个常见的策略是让游戏免费,允许用户收集 VC 和有机购买东西,但也提供快捷方式和作弊作为 IAP,例如让玩家选择更快地前进——基本上,付给你真钱,以换取能够更快地玩游戏或得分更高。这种策略及其变体确实是非常有效的货币化工具。

有一些与风险投资相关的服务,比如 tap joy(www.tapjoy.com),让你不仅可以通过风险投资来赚钱,还可以通过向用户提供更多风险投资来赚钱。这些服务提供类似“安装另一个游戏,获得 50 个硬币”或“点击这个广告,获得 25 个硬币”的激励到目前为止,这种类型的服务似乎是有效的。这项服务向你——发行商——支付这些行为的实际金额,你的玩家喜欢它,因为他们不必花任何实际的钱来获得他们如此渴望的风险投资。

卖还是不卖

在早期的移动游戏市场,主要的收入模式是提供免费的、功能有限的游戏版本和付费的、功能齐全的版本,目标是让玩家迷上试用版并想要更多,从而导致他们购买付费版本。从那时起,游戏开发人员向用户“免费”提供了越来越多的功能,并且更加依赖于他们的游戏的货币化,如“游戏货币化”一节中讨论的那些形式。重要的是真正研究市场,看看新游戏在直接进入付费模式时表现如何。你可以通过查看 Google Play 商店中热门游戏的详细信息来收集粗略的信息。用户比以往任何时候都更担心为他们还没有参与的事情付费,他们有如此多的免费选项,他们可能会直接跳过一个新的付费游戏。

另一方面,Google Play 仍然有一个“付费”类别,因此在某些情况下,发布一个你认为可以在该类别中崛起的付费游戏可能是有意义的。无论哪种方式,选择很大程度上取决于你的产品有多受欢迎。例如,一款使用可识别的授权角色的游戏,无论内容是什么,都有可能卖出复制品。一个全新的游戏,没有以前的记录,可能很难卖出任何拷贝。做这个决定时,你会想把所有营销方面都考虑进去。

批准

许可是快速赚钱的一个好方法。当我们说“许可”时,我们指的是另一家公司向你支付一定数量游戏发行权的交易。例如,一家主要的移动服务提供商或智能手机供应商可能希望将您的游戏预装在即将发布的新款手机上,作为对潜在买家的额外奖励。在这笔交易中,该公司要么要求你让他们免费预装游戏,要么要求你给他们报一些单价。这些公司愿意支付的单位价格通常比游戏的零售价格低几倍。例如,如果你的游戏正常售价为 2.99 美元,捆绑价格可能是每台 20 到 50 美分。这完全取决于很多因素,比如你的游戏有多受欢迎,会有多少安装。很明显,你会想要协商你能得到的最好的交易,但是如果你把你的游戏设计成自由发布,并且依靠 VC 和 IAPs 的货币化,你不仅可以免费发布它,而且可以通过大大扩大的用户群获得巨大的回报。在这种情况下,大家都是赢家。

对于任何协议,确保你阅读合同并协商对你合适的条款。这需要一点额外的时间和努力,但从长远来看会越来越多。

发现你的游戏

已经有成千上万的安卓游戏上市了,还有更多游戏即将上市。只要浏览一下 Google Play 上最受欢迎的游戏列表,你肯定会看到许多你从未听说过的新游戏,它们是由你不知道存在的新公司开发的。一些开发者很幸运,但是大多数流行的游戏并不是偶然流行起来的。他们的发行商知道如何让用户发现游戏。如何让你的游戏被新用户发现?发现是任何游戏成功的关键,虽然你总是可以通过购买广告形式的可见性来解决问题,但这远不如其他一些方法划算,下面将讨论。

社交网络整合

如果你不使用脸书和推特,那么无论如何,现在就把这本书放下,在你注册了这两个网站后再回来。当涉及到让你的游戏为人所知时,像这样的社交网站是举足轻重的。不过,策略一开始并不那么明显。你应该为你的游戏创建一个脸书页面,并告诉你所有的朋友喜欢它吗?那会给你带来一些下载,但不会更多。发微博有帮助吗?一点点。这两种方法都不能让你的游戏获得商业成功所需的知名度。

从营销的角度来看,这两个社交网站的一些优点是,几乎每个人都使用它们,它们可以免费使用,并且它们对更有创意的解决方案很友好。这里有一些你如何利用这些网站来营销你的游戏的例子:给在脸书上“喜欢”你的游戏的用户 50 个免费的风险投资信用。给在推文中提到你的游戏的用户 50 个免费 VC 积分。每月举行一次高分竞赛,使用像 GREE 这样的服务,奖品是一个新的 Android 设备,只允许那些在脸书上喜欢你的人注册。在最后一个例子中,你必须真的购买一个设备作为奖品,当然,但是就激励“喜欢”而言,这样的策略真的很有效。很容易创造激励让人们互相分享你的游戏,这些网络是这种信息分享的完美*台。

脸书和 Twitter 都提供了 Android SDKs,你可以下载并使用它们来将网络与你的游戏整合在一起。具体情况一直在变化,所以在这里记录它们没有太大意义,但是两者都很容易使用,所以没有理由不尝试一下!

提示 GREE 最*收购了 OpenFeint,将其用户迁移到 GREE *台。格力提供风险投资、高分、成就、朋友和其他此类服务。查看位于 https://developer.gree.net/en/的格力开发者中心了解更多信息。

发现服务

有些公司,比如 AppBrain,其唯一的目的就是帮助你的游戏被发现。其他公司,如 TapJoy、Scoreloop 和 Flurry,也有发现服务。这些服务中的大多数都提供了将你的游戏“放到网络中”的方法,这样它就可以被其他游戏推广。你可以支付安装费,并控制一场运动,让你的游戏进入许多人的手中。还记得我们说过 TapJoy 有一项服务,它为你的玩家安装另一个应用或游戏支付 VC,并反过来付给你真钱吗?这是等式的另一边,你是付钱给 TapJoy 让玩家安装你的游戏以换取 VC 的人。

不同的公司提供不同的发现方法,但简而言之,如果你想让你的游戏被发现,并且你有预算,你可能想看看这些服务中的一个或多个。当你把这样的服务和一个好的社交网络策略结合起来,你可能会让雪球越滚越大,为你的游戏制造轰动。

博客和传统网络媒体

让你的游戏被发现的另一个策略是为故事收集试点,为演示创建视频,并将所有这些发送到评论新 Android 应用和游戏的博客。这些网站的编辑收到了大量评论应用和游戏的请求,所以要为他们做尽可能多的工作,提前给他们所有他们需要的信息。耐心点,你可能会得到一些评论。评论本身不会决定一款游戏的成败,但它是创造滚雪球效应的一部分,而滚雪球效应可以导致一款成功的游戏。

通过设计实现货币化

我们已经谈了很多关于游戏赚钱的方法,但我们想明确一点:设计一款游戏来赚钱比把一款已经完成的游戏改造成一个赚钱的方案要容易得多。一款旨在赚钱的游戏可能包含以下一个或多个元素:

  • 影响游戏性的可选修改器

  • 促进

  • 升级

  • 欺骗

  • 不影响游戏性的可选内容

  • 外皮

  • 特性

  • 实际上完全相同的项目变化

  • 附加内容

  • 新水*

  • 新电影艺术

  • 关卡的可解锁部分

  • 新零件

  • 具有以下属性的虚拟货币

  • 可以通过常规游戏获得

  • 用于购买升级

  • 用于购买附加内容

  • 可以用真钱购买

使用这些元素,你可以从一开始就设计一个游戏,为用户购买游戏中的虚拟货币和虚拟商品提供非常有说服力的理由。有时你需要改变风险投资提供的服务,因为一个新的功能或因为一家公司已经倒闭。这种设计足够普遍,允许交换基于 VC 的服务和购买服务,而不会因为改变或删除内容或在切换服务时花费太多开发时间而损害游戏。因此,表现出以下特性的游戏可以说是通过设计可以货币化的:

  • 允许玩家购买虚拟产品和货币
  • 为玩家购买虚拟产品和货币提供激励

这个计划不是唯一的方法,但它确实是一个成功的方法。有许多成功的 Android 游戏以某种形式使用了这种方案,我们建议考虑一下,或者至少记下一般的想法,并将它们应用到你的游戏中。

通过设计发现

没人发现的游戏有什么好?事实是,现在市场竞争如此激烈,有如此多的免费游戏,为了让你的游戏获得巨大成功,你需要在设计阶段特别注意人们将如何发现你的游戏。有些游戏看起来似乎是突然冒出来的,只是偶然变得流行,但通常在设计这些游戏时会考虑到它们的可发现性。

容易被发现的游戏具有超越“哇”的因素和良好的游戏设计的元素。他们鼓励玩家告诉其他人这个游戏。就像一个被设计为货币化的游戏一样,一个被设计为可被发现的游戏包含了大部分或所有相同的元素(虚拟货币、虚拟商品、可解锁物品、附加内容等)。)作为向其他人讲述游戏的激励。

以下是一些如何在你的游戏内容中加入鼓励玩家与他人分享你的游戏的想法:

  • 制作一个只能通过输入从另一个玩家处收到的推荐代码来解锁的内容
  • 提供额外的内容或风险投资,用于在脸书上发布关于游戏的微博或分享或喜欢它
  • 奖励所有推荐给其他玩家的 VC 玩家
  • 整合脸书或其他社交媒体来发布成就和新的高分
  • 创建游戏的另一部分,作为一个脸书应用来玩,但以某种方式绑定到移动游戏

增加奖励的方式还有很多,但如果你创造性地思考如何奖励与他人分享你游戏的人,你就走上了正确的道路,这款游戏自然会变得更受欢迎。

摘要

仅仅开发一款游戏并发布它并不足以引起人们的注意或从中赚钱。如果你采用我们在本章中概述的一些策略,你将有更高的成功机会。技术的潮流总是潮起潮落,但本章提供的一般经验和想法应该保持不变,所以请利用您在这里学到的知识,思考如何创建额外的应用和方法来营销和赚钱您的游戏。

十五、发布您的游戏

成为 Android 游戏开发者的最后一步是把你的游戏带给你的玩家。有两条可能的路线:

  • 从你项目的 bin/文件夹中取出 APK 文件,放到网上,告诉你的朋友下载并安装到他们的设备上。
  • 像专业人士一样,在 Google Play 上发布您的应用。

第一个选项是一个很好的方法,让其他人在你把你的应用放到 Google Play 商店之前测试它。他们所需要做的就是得到 APK 的文件,并把它安装到他们的设备上。一旦你的游戏准备好了,真正的乐趣就开始了。

关于测试的一句话

正如我们在前面的章节中看到的,设备之间有各种不同。在你发布你的应用之前,确保它能在一些常见的设备和不同的 Android 版本上运行良好。有几种方法可以解决这个问题。你可以购买一系列具有不同硬件功能的设备,运行不同版本的 Android 并进行内部测试,也可以购买任何一种新的 Android 测试服务。我们很幸运地拥有几款手机和*板电脑,涵盖不同的设备类别和代次,以供测试。然而,根据你的预算,这两种方法都不是你的选择。你可能不得不依靠模拟器(但不要太多,因为它确实不可靠),或者最好依靠几个朋友来帮助你。

另一种测试你的应用的方法是在 Google Play 商店放一个测试版。您可以在标题中清楚地将您的应用标记为 beta,这样用户就知道会发生什么。当然,一些用户会很乐意忽略所有的警告,仍然抱怨你潜在的未完成的应用的质量。这就是生活,你可能不得不面对负面的和不公正的评论。但是请记住:你的用户才是王道。不要生他们的气——而是想办法改进你的应用。

以下是我们在应用发布前常用的测试设备列表:

  • 三星 Galaxy Leo/I5801,320×240 像素屏幕
  • HTC Hero 搭载 Android 1.5,480×320 像素屏幕
  • 搭载安卓 1.6,480×320 像素屏幕的 HTC G1
  • 摩托罗拉 Milestone/Droid 搭载 Android 2.1,854×480 像素屏幕
  • HTC Desire HD 采用 Android 2.2,800×480 像素屏幕
  • Nexus One 搭载 Android 2.3,800×480 像素屏幕
  • HTC Evo 4G 搭载 Android 2.2,800×480 像素屏幕
  • 搭载 Android 2.2 和 4.0 的三星 Galaxy S,800×480 像素屏幕
  • 三星 Nexus S 搭载 Android 4.1,800×480 像素屏幕
  • 三星 Galaxy Nexus 搭载 Android 4.1,800×480 像素屏幕
  • 三星 Galaxy Tab 10.1 搭载 Android 3.1,1280×800 像素屏幕
  • 谷歌 Nexus 7 *板,搭载安卓 4.1,1280×800 像素屏幕

如你所见,我们涵盖了相当多的屏幕尺寸/分辨率和设备代。如果你寻找外部测试人员,确保你覆盖了这里列出的大多数设备。当然,更新的设备也应该在你的列表中,但更多的是为了兼容性测试,而不是性能测试。如果没有其他事情,请确保您至少在几台谷歌官方设备上进行测试,包括 Nexus 7、Galaxy Nexus、Nexus S 和 Nexus One 设备。如果你的游戏在这些设备上运行得不好,如果你不做出改变,你肯定会遇到麻烦。

最后,您必须接受这样一个事实,即您无法在所有设备上测试您的应用。您很可能会收到莫名其妙的错误报告,这很可能是因为用户运行的自定义 rom 的行为不符合预期。无论如何,不要慌;这在某种程度上是正常的。但是,如果错误的问题太严重了,你就必须想出一个方案来解决它。幸运的是,Google Play 在这方面帮助了我们。我们一会儿会看到它是如何工作的。

注意除了 Google Play 的错误报告功能,还有另一个很好的解决方案叫做 Android 应用崩溃报告(ACRA),这是一个开源库,专门用来报告你的 Android 应用的所有崩溃。在 http://code.google.com/p/acra/的 ?? 可以买到,而且非常容易使用。只需按照 Google 代码页上的指南将其集成到您的应用中。

成为注册开发者

Google 使得在 Google Play 商店上发布您的应用变得非常容易。你所要做的就是一次性支付 25 美元注册一个 Android 开发者账户。假设你生活在谷歌支持分发应用的国家名单中的一个国家,这个帐户将允许你在 Google Play 上发布你的应用。你是只能发布免费应用还是既能发布免费应用又能发布付费应用,这取决于你所在的国家。要发布付费应用,你必须居住在谷歌支持商家的较短名单上的国家之一。谷歌还列出了可以免费分发应用的国家和可以分发付费应用的国家。谷歌正在努力扩大这些列表,以便您的应用可以覆盖全球。

你的谷歌 Play 商店出版商账户与谷歌账户直接绑定。除非取消限制,否则您不得将发布者帐户与 Google 帐户分开。在决定是注册现有帐户还是注册新的专用帐户时,考虑这一点很重要。一旦你做出了决定,并准备好了你的谷歌账户,访问play.google.com/apps/publish/signup,按照那里给出的指示注册谷歌 Play 商店。

除了你的 Android 开发者账户,如果你想出售你的应用,你还需要注册一个免费的 Google Checkout 商家账户。在开发者帐户注册过程中,您可以选择这样做。我们不是律师,所以在这一点上我们不能给你任何法律建议,但在你这样做之前,请确保你理解销售应用的法律含义。如果有疑问,考虑就此事咨询专家。我们无意以此吓退你,因为这个过程总体来说是相当简化的,但是你应该准备好让你政府的税务部门了解你的销售活动。

谷歌将从你辛苦赚来的钱中抽取一定比例(撰写本文时为 30%)用于分发你的应用和提供基础设施。这似乎是各种*台上所有应用商店的标准做法。

签署你的游戏的 APK

成功注册成为 Android 官方开发者后,就该准备将应用发布到 Google Play 了。为了发布你的申请,你必须签署 APK 文件。在你这样做之前,你应该确保一切就绪。这里有一个在签署 APK 文件之前要做的事情的清单:

  • 从清单文件中的标签中删除 Android:debuggeable 属性,或者将其设置为“false”。
  • 标签中,您会发现 android:versionCode 和 android:versionName 属性。如果您已经发布了应用的早期版本,则必须增加 versionCode 属性,并且还应该更改 versionName 属性。versionCode 属性必须是整数;versionName 属性可以是您喜欢的任何内容。
  • 如果您的构建目标等于或高于 SDK level 8 (Android 2.2),您还应该确保标签将 android:installLocation 属性设置为 preferExternal 或 auto。这将通过确保您的应用尽可能安装在外部存储上来满足您的用户。
  • 确保只指定游戏真正需要的权限。用户不喜欢安装似乎要求不必要权限的应用。检查清单文件中的标签。
  • 确认您正确设置了 android:minSdkVersion 和 android:targetSdkVersion 属性。您的应用只能在运行 Android 版本等于或高于指定 SDK 版本的手机上才能在 Google Play 上看到。

仔细检查 所有这些项目。完成后,您可以通过以下步骤最终导出一个已签名的 APK 文件,该文件已准备好上传到 Google Play:

  1. Right-click your project in the Package Explorer view and select Android Tools image Export Signed Application Package to launch the Export Android Application wizard. You’ll be greeted with the Project Checks dialog, shown in Figure 15-1.

    9781430246770_Fig15-01.jpg

    图 15-1。签名导出对话框

  2. Click the Next button to move to the Keystore selection dialog, shown in Figure 15-2.

    9781430246770_Fig15-02.jpg

    图 15-2。选择或创建密钥库

  3. A keystoreis a password-protected file that stores the key with which you sign your APK file. Since you haven’t created a keystore yet, you’ll do so now in this dialog. With the “Create new keystore” radio button selected, just provide the location where you want to store the keystore, along with the password that you will use to secure it. Click the Next button to move to the Key Creation dialog, shown in Figure 15-3.

    9781430246770_Fig15-03.jpg

    图 15-3。创建用于签署 APK 的密钥

  4. To create a valid key, you have to complete the Alias, Password, and Validity (years) fields, as well as enter a name in the First and Last Name field. The rest of the fields are optional, but it’s a good idea to fill them out nevertheless. Click Next, and you are shown the final dialog (see Figure 15-4).

    9781430246770_Fig15-04.jpg

    图 15-4。指定目标文件

  5. 指定存储导出的 APK 文件的位置,并确保记下路径。稍后,当您想要上传 APK 时,您将需要它。单击完成。

当您想要发布以前发布的应用的新版本时,您可以重用第一次使用向导时创建的密钥库。启动向导,当您到达图 15-2 中的所示的密钥库选择对话框时,选择“使用现有密钥库”单选按钮,提供您先前创建的密钥库文件的位置,并提供密钥库的密码。当你点击下一步,你会看到“键别名选择”对话框,如图图 15-5 所示。只需选择您之前创建的密钥,为其提供密码,单击 Next,然后像以前一样继续操作。在这两种情况下,结果将是一个签名的 APK 文件,可以上传到 Google Play。

注意一旦你上传了一个签名的 APK,你必须使用相同的密钥来签署同一应用的任何后续版本。

9781430246770_Fig15-05.jpg

图 15-5。重用密钥

那么,你创造了你的第一个签名 APK——祝贺你!现在,我们将抛出一个扳手到工程,并通知您关于多 APK 支持。对于单个应用,您可以创建多个 apk,这些 apk 使用设备功能过滤来为安装您的应用的每个用户找到“最佳选择”。这是一个很棒的功能,因为这意味着您可以做如下事情:

  • 提供与特定 GPU 兼容的特定映像集。
  • 针对旧版本 Android 的有限功能集。
  • 为更大的屏幕尺寸提供更大比例的图形,为所有其他屏幕尺寸提供常规比例的图形。

随着时间的推移,谷歌肯定会添加更多的过滤器,但这里概述的一套过滤器可以让你真正专注于目标设备(如*板电脑),而不必通过太多的限制来保持第一代设备支持的合理下载量。

将您的游戏放入 Google Play

是时候在 Google Play 网站上登录您的开发者帐户了。只需前往play.google.com/apps/publish并登录即可。你会看到如图图 15-6 所示的界面。

9781430246770_Fig15-06.jpg

图 15-6。开发者,欢迎来到 Google Play!

这个界面就是谷歌所说的 Android 开发者控制台,也就是你最初注册时瞥见的。现在我们准备好实际使用它了。单击屏幕底部的上传应用按钮,您可以通过打开编辑应用屏幕来上传应用。让我们来看一下编辑应用屏幕的一些部分,第一部分,上传素材(在产品详细信息选项卡上),如图图 15-7 所示。

9781430246770_Fig15-07.jpg

图 15-7。编辑 Android 开发者控制台的应用屏幕

上传素材

发布你的游戏需要大量的素材和信息。屏幕截图、图标、描述、类别等等,都有所需的大小和格式,在产品详情选项卡上列出。

如图图 15-7 所示,你必须提供至少两张你的应用的截图。它们必须是特定的格式(24 位 JPEG 或 PNG)和大小(320×480、480×800、480×854、1280×720 或 1280×800)。当用户在 Google Play 商店(手机应用和 http://play.google.com/storeT2 的官方网站)查看您的应用详情时,将会显示这些截图。

接下来,你必须上传一个 512×512、高分辨率、32 位的 PNG 或 JPEG 格式的应用图标。目前,只有当用户在 Google Play 商店网站上查看您的应用时,才会显示这一点。弄得花哨一点。

如果您的游戏是特色游戏,宣传图片(180w×120h,24 位 PNG 或 JPEG)和特色图片(1024×500,24 位 PNG 或 JPEG)会显示在 Google Play 商店中。成为特色是一件大事,因为这意味着当用户在移动应用或网站上打开 Google Play 商店时,你的游戏将是他们首先看到的应用之一。谁被选中取决于谷歌,只有他们知道这个决定的依据。

你可以提供一个应用的 YouTube 推广视频的链接。这将出现在 Google Play 商店网站上,这是一个重要的营销工具,因为它让用户在决定下载之前可以很好地预览你的游戏。这也是谷歌在考虑是否为你的游戏添加特色时可能会考虑的事情。

如果您的游戏有隐私政策,规定了如何存储、处理和发布用户信息,您可以在隐私政策字段中添加信息链接。如果您没有隐私政策,只需勾选“此时不提交隐私政策 URL”

请注意,Google Play 开发者界面会不断变化,可能会包括此处未列出的其他功能。

产品详情

向下滚动到编辑应用屏幕的列表细节部分,如图 15-8 所示。这些细节将在 Google Play 商店向用户显示。您可以指定语言,提供标题(最多 30 个字符),还可以添加应用的描述(最多 4000 个字符)。额外的 500 个字符可用于通知用户应用最新版本中的最新更改。推广文本(最多 80 个字符)将仅在您的应用被 Google 选中时使用。

9781430246770_Fig15-08.jpg

图 15-8。Android 开发人员控制台的列表详细信息部分

接下来,您必须指定您的应用的类型和类别。对于每种应用类型,都有一系列类别可供选择。对于游戏,您可以指定街机和行动,大脑和益智,纸牌和赌场,休闲,或赛车和体育。选项让分类有点混乱(赛车不是运动?),他们可以更好地思考。希望未来能有所改变。

发布选项

“编辑应用”屏幕的“发布选项”部分允许您指定是否要复制保护应用、其内容分级以及希望其可用的位置。

复制保护功能已被否决,您不应该使用它,因为它不是有效的,而且会产生一些其他问题。相反,Google 提供了一个 API 来将许可服务集成到您的应用中。这项服务是为了让用户更难盗版你的游戏。评估这项服务超出了本书的范围;如果你对盗版有疑虑,我们建议你去 Android 开发者网站。与许多数字版权管理(DRM)方案一样,用户报告了无法运行或安装使用许可服务的应用的问题。当然,对此不能全信。如果你需要的话,这是你目前 DRM 的最佳选择。

内容分级允许您指定目标受众。您可以通过单击内容分级标题下的了解更多链接,找到对您自己的应用进行分级的指导原则。Google Play 将根据您给应用的内容评级对其进行过滤。所以,仔细评估适合你游戏的评分。Google 可能会决定您的应用需要更成熟的内容分级,在这种情况下,Google 团队将指定他们认为更合适的分级。

您可以选择应用可用的位置。当然,通常您希望它随处可用。然而,尽管这不太可能,但可能存在这样的情况,即您希望仅在选定的位置发布应用,这可能是出于法律原因。

现在你必须决定用户是否会为你的游戏付费。这是最终决定。一旦你实现了两个选项中的一个,你就不能改变它,除非你用不同的密钥重新发布你的游戏。如果你这样做,你会失去任何用户评论,你也会疏远你的用户。想想你的游戏要走的路线。我们不会给你游戏定价的建议,因为这取决于多种因素。0.99 美元的价格似乎是大多数游戏的标准,也是用户的期望。然而,没有什么能阻止你在这里尝试一下。如果你出售你的游戏,确保你了解这样做所涉及的法律问题。

最后,在发布选项部分的底部,您将获得一些关于您的应用支持的设备的信息。此处列出了屏幕布局支持和所需设备功能等属性。当您点击显示设备链接时,您将会看到图 15-9 中的屏幕。这将列出所有与您的 apk 兼容的设备。您可以在此对话框中搜索设备并手动排除设备。当某个设备被排除或不在支持列表中时,您的游戏将不会在该特定设备的 Google Play 商店中显示。如果你遇到游戏在特定设备上不显示的问题,这是用来调试问题的屏幕。

9781430246770_Fig15-09.jpg

图 15-9。设备可用性

在完成“产品详细信息”选项卡的最后两个部分之前,请切换到“APK 文件”选项卡,阅读以下有关管理 APK 文件的信息。

APK 档案管理

编辑应用屏幕上的第二个选项卡, APK 文件,让您管理您的 APK,并提供一些关于正在应用的设备过滤的附加信息。图 15-10 显示了游戏《抗原:爆发》的 APK 文件标签。

9781430246770_Fig15-10.jpg

图 15-10。 APK 文件管理

虽然这里只有一个 APK 是活动的,但你可以清楚地看到,通过改变 API 级别、支持的屏幕、OpenGL 纹理以及不同 apk 的原生*台,可以支持多个 apk。这款 APK 支持 API 级别 3–16+,小到超大屏幕,所有 OpenGL 纹理,以及 armeabi)设备。

在点击应用图标下的“更多”后,正如我们已经在图 15-10 中所做的,你可以看到游戏的所有权限和功能。这个游戏需要球场位置、互联网、振动和蓝牙功能,用户在安装游戏之前将会看到这些要求。

在这里添加更多的 APK 就像点击上传 APK 一样简单。升级时,请记住,您只能有一个具有相同功能支持的活动 APK,因此请确保在完成之前停用您的旧 APK。

发布!

回到“产品详细信息”标签,您将指定的最后几件事情是您的联系信息、您对 Android 内容指南的同意(链接在同一页面上——阅读它们),以及确认您的应用符合所有美国出口法律,如果您的游戏是标准的 Android 应用,这是一个安全的赌注。

在提供了所有这些信息之后,是时候点击页面底部巨大的发布按钮,让你的游戏面向全球数百万人开放了!没有审查过程,因此在一两个小时的服务器传播时间后,您的游戏将在 Google Play 商店的所有支持设备上上线。

关于开发人员控制台的更多信息

一旦你的游戏在 Google Play 上发布,你会想要跟踪它的状态。到目前为止有多少人下载了?发生过撞车事故吗?用户在说什么?你可以在 Android 开发者控制台查看所有这些(见图 15-6 )。

对于您发布的每个应用,您可以获得一些信息:

  • 您游戏的总体评分和评分数量
  • 用户的评论(只需点击相应应用的评论链接)
  • 应用安装的次数
  • 应用的活动安装数量
  • 错误报告

我们对错误报告特别感兴趣。图 15-11 显示了游戏《抗原:爆发》收到的错误报告。

9781430246770_Fig15-11.jpg

图 15-11。错误报告概述

总共有一次冻结和七次崩溃。抗原:Outbreak 上市一年多了,那还不错。当然,您可以进一步深入到具体的错误。开发人员控制台的错误报告功能将为您提供关于崩溃和冻结的详细信息,例如发生问题的设备型号、完整的堆栈跟踪等等。当你试图找出你的应用到底出了什么问题时,这可能会有很大的帮助。对市场的评论不会比一般的问题识别有更多的帮助。

注意错误报告是一项设备端功能,旧版 Android 不支持。如前所述,如果你想完全确信你已经发现了所有问题,我们建议你去看看 ACRA。

摘要

在 Google Play 上发布你的游戏轻而易举,而且准入门槛非常低。你现在已经掌握了在 Android 上设计、实现和发布你的第一个游戏的所有必要知识。愿原力与你同在!

十六、下一步是什么?

我们在这本书里谈了很多东西,但是关于开发 Android 游戏还有很多东西需要学习。如果你对本书中的所有内容都感到满意,你可能会想深入了解。这简短的一章为你的旅程提供了一些思路和方向。

位置感知

我们只是在第一章第一章和第四章第三章中简单地提到了这一点,我们没有在任何游戏中利用它。所有 Android 设备都带有某种类型的传感器,可以让你确定它们的位置。这本身是一个足够有趣的特性,在游戏中使用它可以产生一些创新的和前所未见的游戏机制。大多数安卓游戏几乎不使用这个功能。你能想出在游戏中使用 GPS 传感器的有趣方式吗?

多人游戏功能

这是一本初学者的书,我们还没有谈到如何创建多人游戏。可以说 Android 为你提供了这样的 API。根据游戏类型的不同,实现多人游戏功能的难度也不同。回合制游戏,如国际象棋或纸牌游戏,实现起来非常简单。快节奏的动作游戏或即时战略游戏完全是另一回事。在这两种情况下,你都需要对网络编程有所了解,这个主题在网上有很多资料。

OpenGL ES 2.0/3.0 及更多

到目前为止,可以说你只看到了 OpenGL ES 的一半。我们专门使用了 OpenGL ES 1.0,因为这是目前 Android 上支持最广泛的版本。它的固定功能特性非常适合 3D 图形编程。然而,有一个更新、更好的 OpenGL ES 版本可以让你直接在 GPU 上编码。它与您在本书中看到的非常不同,因为您负责所有的本质细节,例如从纹理中获取单个纹理元素或手动转换顶点的坐标,所有这些都直接在 GPU 上进行。

与 OpenGL ES 1.0 和 1.1 的固定功能管道相反,OpenGL ES 2.0 具有所谓的“基于着色器”或可编程的管道。较新的 OpenGL ES 规范,如 3.0,遵循相同的模型并向后兼容 2.0,所以我们只是将它们统称为 2.0。对于许多 3D(和 2D)游戏来说,OpenGL ES 1.x 已经足够了。但是,如果你想变得更有趣,你可以考虑看看 OpenGL ES 2.0!不要害怕——您在本书中学到的所有概念都可以很容易地转移到可编程管道中。

有一个谷歌支持的 Android 库,名为 Renderscript,这是一个高级接口,用于构建基于 OpenGL ES 2.0 的华丽效果,而没有使用其乏味的 API 实现它们的所有痛苦。不要误解我们——OpenGL ES 2.0 很棒,我们在这方面有丰富的经验——但是 Renderscript 无疑提供了一种创建许多图形效果的更简单的方法,并且它是 Android 股票版本附带的许多默认动态壁纸的核心。

我们还没有触及动画 3D 模型和一些更高级的 OpenGL ES 1.x 概念,如顶点缓冲对象。和 OpenGL ES 2.0 一样,你可以在网上找到很多资源,也可以在书里找到。Smithmick 和 Vernma 的Pro OpenGL ES for Android(a press,2012)有更深入的信息和关于 2.0 的一章。你知道最基本的。现在是时候学习更多知识了!

框架和引擎

如果你在购买这本书时对游戏开发知识知之甚少,你可能会想为什么我们不选择使用 Android 游戏开发中现有的框架。重新发明轮子不好,对吧?如果你想彻底理解其中的原理就不会。虽然学习它们有时可能会很乏味,但最终会有回报的。当你用你在这里获得的知识武装起来时,在那里获得任何预先设定的解决方案会容易得多,我们希望你能认识到给你带来的优势。

对于 Android,存在几种商业和非商业的开源框架和引擎。框架和引擎有什么区别?

  • 框架让你可以控制游戏开发环境的方方面面。这是以不得不找出自己的做事方式为代价的(例如,你如何组织你的游戏世界,你如何处理屏幕和过渡,等等)。在这本书里,我们开发了一个非常简单的框架,在这个框架上我们可以构建我们的游戏。
  • 对于特定的任务,引擎更加精简。它规定了你应该如何做事情,为你的游戏提供了简单易用的模块和通用架构。不利的一面是你的游戏可能不适合引擎提供给你的解决方案。通常,您必须修改引擎本身来实现您的目标,这取决于源代码是否可用。引擎可以大大加快最初的开发时间,但是如果您遇到一个问题,而该引擎并不是为这个问题而制造的,那么它们可能会使开发速度变慢,甚至停滞不前。

最后,这是个人品味、预算和目标的问题。作为独立开发人员,我们更喜欢框架,因为它们通常更容易理解,因为它们让我们按照我们希望的方式做事。

也就是说,选择你的毒药。这里有一个可以加速你开发过程的框架和引擎列表:

  • 虚幻开发套件 ( www.udk.com):😃 一个运行在众多*台上的商业游戏引擎,由 Epic Games 开发。史诗制作游戏,如虚幻锦标赛,所以这个引擎是优质的东西。它使用自己的脚本语言。
  • Unity(Unity 3d . com)::)又一个工具和功能都很棒的商业游戏引擎。它也可以在多种*台上运行,包括 iOS 和 Android,或者在浏览器中运行。它很容易学习,并允许一对夫妇的游戏逻辑编码语言;Java 不在其中。
  • jPCT-AE(www . jPCT . net/jPCT-AE/)::)Android 基于 Java 的 jPCT 引擎的一个端口。它在 3D 编程方面有一些很棒的特性。它可以在桌面和安卓系统上运行。闭源。
  • ardor 3D(www . ardor 3D . com)::)一个非常强大的基于 Java 的 3D 引擎。它可以在 Android 和桌面上运行,并且是开源的,有很好的文档。
  • libGDX(libGDX . badlogicgames . com)::)Mario zech ner 为 2D 和 3D 游戏开发的基于 Java 的开源游戏开发框架。它可以在 Windows、Linux、Mac OS X、HTLM5、iOS 和 Android 上运行,无需任何代码修改。您可以在桌面上开发和测试,而不需要连接设备和上传您的 APK 文件(或者必须使用缓慢的模拟器)。读完这本书后,你可能会有宾至如归的感觉——这都是我们邪恶计划的一部分。你有没有注意到这个点只是比其他的点稍微大一点?
  • Slick-AE(Slick . coke and code . com)::)基于 Java 的 Slick 框架到 Android 的一个移植,构建在 libgdx 之上。它有大量的功能和一个易于使用的 2D 游戏开发 API。当然是跨*台和开源。
  • and engine(www . and engine . org)::)一个不错的基于 Java 的,只支持 Android 的 2D 引擎,部分基于 libgdx 代码(开源为赢)。它在概念上类似于著名的 iOS 版 cocos2d 游戏开发引擎。
  • battery tech SDK(www . batterypoweredgames . com/battery tech)::)c++ 开源商业库,支持跨*台游戏代码,正式支持 Android、iOS、Windows、Mac OS X 作为构建目标。
  • Moai(get Moai . com)::)c++ 中的又一个开源商业库,以跨*台的游戏代码针对 Android 和 iOS。
  • 木瓜的社交游戏引擎(papayamobile . com/developer/Engine)::)免费的安卓专属 2D 游戏引擎,包括物理 API、OpenGL 支持、粒子特效等等。

越来越多的中间件、框架和引擎一直在出现,所以这个列表并不详尽。我们建议在某个时候尝试这些选项。他们可以帮助你加快游戏开发的速度。

网络资源

网络上充满了游戏开发资源。总的来说,谷歌将是你最好的朋友,但是有一些特别的地方你应该去看看,包括这些:

  • www.gamedev.net: 网络上最古老的游戏开发网站之一,拥有大量关于各种游戏开发主题的文章。
  • www.gamasutra.com: 游戏开发的又一个老巨人。更面向行业,有很多事后分析和对专业游戏开发世界的洞察。
  • 一个关于游戏开发的大型 wiki,里面充满了关于不同*台、语言等游戏编程的文章。
  • www.flipcode.com/archives/:现已不复存在的 flipcode 遗址的档案。这里可以找到一些珍珠。虽然有时有点过时,但它仍然是一个非常好的资源。
  • www . Java-gaming . org:Java 游戏开发者的头号去处。众所周知,像 ??《我的世界》的马库斯·佩尔森这样的名人经常光顾这个地方。

结束语

写这本书的时候,我们经历了许多不眠之夜,接着是几天布满血丝的眼睛。我们可以说,虽然生活中很少有什么事情比制作视频游戏和向他人传播我们的知识更让我们享受,但能坚持到这个项目的最后几段肯定是很棒的。

写这本书是一种乐趣(早上,没有那么多),我们希望我们给了你你来这里的目的。有太多的东西有待发现,太多的技术、算法和想法有待探索。这对你来说只是个开始。前面还有更多要学的。

我们相信,有了我们分析和讨论的材质,你就有了一个坚实的基础,这将使你更快地掌握新的思想和概念。再也不用陷入复制粘贴代码的陷阱了。更好的是,我们讨论的几乎所有东西都可以很好地移植到任何其他*台上(给予或接受一些语言或 API 差异)。我们希望你能看到全局,这将使你能够开始构建你梦想中的游戏。