安卓游戏编程学习指南-全-
安卓游戏编程学习指南(全)
原文:
zh.annas-archive.org/md5/0da6d0a48281457a6362beeb7f4a6e1d译者:飞龙
前言
本书将带领读者进行一次充满乐趣的旅程,他们不仅将学习最新的 Android N SDK,还将了解其他 API 以及如何使用它们创建高度交互和有趣的游戏。本书将展示如何从头开始创建一个专为 Android 平台设计的完整游戏。它将从设置 Android N SDK 和其他仓库开始,然后展示如何定制开发环境。之后,它将展示如何创建游戏元素、对象、游戏布局、游戏架构和游戏循环。它将创建可重用的 Java 代码脚本,这将有助于你在其他游戏项目中。游戏开发的一个关键部分是无缝集成图像和图形。随着我们继续前进,我们将展示如何高效地处理动态图像、创建精灵动画、粒子爆炸、游戏实体、位图字体等等。原型设计可以显著减少开发时间;读者将使用 libgdx 库实现原型设计技术。在本书的结尾,读者将清楚地了解如何改进游戏物理和碰撞系统,以使游戏看起来更真实。
本书涵盖的内容
第一章,Android N 简介和 Android SDK 的安装,通过逐步指南指导读者安装必要的软件。
第二章,熟悉 Android Studio,旨在使读者对项目布局和开始使用 Android Studio 开发游戏所需的组件感到舒适。
第三章,管理输入,指导读者如何从用户那里获取输入。
第四章,创建精灵和交互对象,教授如何在屏幕上显示图像并将它们转换为交互对象。
第五章,为您的游戏添加动画,向您展示如何使用精灵图集创建动画。
第六章,碰撞检测和基本人工智能,探讨了各种碰撞检测方法。建议用户对坐标系统的一些基本概念有一定的数学理解。
第七章,添加边界和使用精灵创建爆炸效果,介绍了为游戏创建边界,然后继续创建一个爆炸类以创建爆炸效果。
第八章,添加爆炸和创建用户界面,展示了在创建爆炸类后如何在屏幕上生成我们的爆炸效果,然后介绍了如何创建一个显示指令和计分的用户界面。
第九章,将您的游戏从 2D 转换为 3D,讨论了如何从制作 2D 游戏过渡到 3D 游戏。
第十章,进一步探索 3D 游戏,向玩家介绍在游戏中进一步使用 3D 对象。
您需要这本书什么
这本书旨在帮助那些想要开始使用 Android 进行本地开发的人。它还介绍了 Android 的最新版本 Android N,并指导读者通过 10 个简单的章节从开发简单应用过渡到复杂 3D 游戏的过程。
这本书是为谁准备的
这本书适合任何对 Java 有基本知识并且对为 Android 制作游戏感兴趣的人。不需要先前的 Android 或游戏开发知识;然而,这将是一个加分项。
术语
在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“例如,您可以将www.google.com视为一个网站的域名。”代码块设置如下:
<TextView
android:text="Hello World!"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/TextView"
tools:text="helloWorld"
android:textAppearance="@style/TextAppearance.AppCompat.Headline" />
新术语和重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“点击文本,如以下截图所示:”
警告或重要提示将以这样的框显示。
小贴士和技巧将像这样显示。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您的账户下载这本书的示例代码文件。www.packtpub.com。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的“支持”标签上。
-
点击“代码下载与勘误”。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
文件下载完成后,请确保使用最新版本解压或提取文件夹:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/-Learning-Android-Game-Development。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
勘误
尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
互联网上版权材料的侵权是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。请通过copyright@packtpub.com与我们联系,并提供疑似侵权材料的链接。我们感谢您的帮助,以保护我们的作者和我们为您提供有价值内容的能力。
问题
如果您对本书的任何方面有问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。
第一章:Android N 简介及 Android SDK 的安装
欢迎来到 Android 和游戏开发的世界。你即将开始一段旅程,这将为你打下将最狂野的想象转化为游戏的基础。本书将成为你创造精彩游戏的垫脚石。如果你是初学者,你将经历一段陡峭但舒适的学习曲线,并在本书结束时,你将创建出自己的游戏。
本书章节被划分为极其易于理解的部分,无需游戏开发方面的先验经验。然而,编程经验是必须的。
本章将指导你了解 Android N 的入门,以及安装所需软件的步骤。简而言之,你将在本章学习以下内容:
-
Android N 的简要介绍
-
通过一些表现良好的游戏的例子介绍游戏开发
-
安装 Android Studio
-
Android Studio 的组成部分以及为 Android N 设置
-
Android 基本概念的快速介绍
Android N 简介
所有这一切始于 2005 年,当时谷歌收购了一家新公司,这家公司后来彻底改变了移动计算的未来。是的,你猜对了!被收购的公司是 Android 操作系统的开发者。从那时起,Android 经历了许多发展,由于谷歌的力量,其用户基础显著增长。在撰写本书时,Android N 是该操作系统的最新版本。Android 的市场份额一直在增长,目前占总移动计算设备的 87.6%。这是一个巨大的数字,因此,从开发者的角度来看,为这个平台开发是极其重要的,因为大多数移动用户都是 Android 用户。
Android N 代表 Android Nougat。你必须了解 Android 版本的命名约定。如果不了解,它们是按字母顺序递增命名的,每个版本都以甜点命名,除了前两个版本。以下是对不同 Android 版本的快速浏览:
-
Alpha
-
Beta
-
Cupcake
-
Donut
-
Eclair
-
Froyo
-
Gingerbread
-
Honeycomb
-
Ice Cream Sandwich
-
Jellybean
-
Kit Kat
-
Lollipop
-
Marshmallow
-
Nougat
你可以从官方来源了解更多关于 Android 历史的信息:www.android.com/history/。
应用开发的世界很有趣——但比这更有趣的是,一个特定的领域,那就是游戏开发。移动游戏在 Google Play Store 上的下载量最高,因此,对于游戏开发者来说,这是一个最激动人心的时刻,因为谷歌已经建立了一个庞大的分发渠道,使得移动游戏开发者发布游戏变得极其容易。那些需要等待数月甚至数年才能与主要发行商达成出版协议的日子已经过去了。在当今时代,您只需在 Google Play Store 上注册为开发者,几小时内就可以发布您的第一款游戏(如果准备好了),并从真实用户那里获得反馈。
安卓游戏的世界见证了巨大的成功故事,例如愤怒的小鸟、糖果传奇、地铁跑酷等等。即使是玩法简单的游戏,如 Flappy Bird,也取得了极大的成功,据估计,在游戏的巅峰时期,它每天的广告收入约为 50,000 美元。这难道不令人兴奋吗?你只需点击一下,就能让你的游戏接触到数十亿潜在的用户,如果你的游戏受到关注,那么你将享受人生中最美好的时光。
您可以制作从基于文本的游戏到第三人称射击游戏如此简单或复杂的游戏。您的限制仅限于您的想象力。此外,您今天可以轻松在线获取所有需要的资源。这本书将为您在安卓游戏开发的世界中提供一个便捷的参考,并使用最新的安卓版本,因此您将保持知识的更新。您不一定需要具备开发安卓平台游戏的先前经验;然而,如果您有,那将是一个加分项。不过,请放心,这本书的语言将会尽可能容易理解,在整个开发您第一个安卓游戏的过程中,您将会非常享受。
因此,无需多言,让我们直接进入这个激动人心的旅程,使用我们可用的最新组件和工具来开发安卓游戏。我希望你在阅读和实施这本书的同时能有一个愉快的时光,并强烈建议你在阅读这本书的过程中做笔记。
软件需求
为了开始我们的安卓游戏开发之旅,我们需要在您的计算机上安装某些软件。我们将使用截至本书写作时的最新版本的 Android Studio 来开始。本章将指导您完成安装过程。
在开始安装之前,请确保您的计算机满足以下系统要求:
-
Windows:
-
Microsoft® Windows® 7/8/10
-
至少 3 GB RAM,建议 8 GB RAM;另外还需要 1 GB 用于 Android 模拟器
-
至少 2 GB 的可用磁盘空间,建议 4 GB(IDE 为 500 MB,Android SDK 和模拟器系统镜像为 1.5 GB)
-
最小屏幕分辨率 1280 x 800
-
对于加速模拟器,需要 64 位操作系统和具有 Intel® VT-x、Intel® EM64T (Intel® 64)和执行禁用(XD)位功能支持的 Intel®处理器,或者支持 AMD 虚拟化(AMD-V)的 AMD 处理器
-
-
Mac:
-
Mac® OS X® 10.10(Yosemite)或更高版本,最高至 10.12(macOS Sierra)
-
最小内存 3 GB RAM,推荐 8 GB RAM;此外,Android 模拟器还需要 1 GB 内存
-
最小可用磁盘空间 2 GB,推荐 4 GB(IDE 占用 500 MB + Android SDK 和模拟器系统镜像占用 1.5 GB)
-
1280 x 800 最小屏幕分辨率
-
-
Linux:
-
GNOME 或 KDE 桌面环境
-
能够运行 32 位应用程序的 64 位发行版
-
GNU C 库(glibc)2.11 或更高版本
-
最小内存 3 GB RAM,推荐 8 GB RAM;此外,Android 模拟器还需要 1 GB 内存
-
最小可用磁盘空间 2 GB,推荐 4 GB(IDE 占用 500 MB + Android SDK 和模拟器系统镜像占用 1.5 GB)
-
最小屏幕分辨率 1280 x 800
-
对于加速模拟器,需要具有 Intel® VT-x、Intel® EM64T (Intel® 64)和执行禁用(XD)位功能支持的 Intel®处理器,或者支持 AMD 虚拟化(AMD-V)的 AMD 处理器
-
模拟器加速需要安装Intel 硬件加速执行管理器(Intel HAXM)或基于内核的虚拟机(KVM),它们都是虚拟机管理程序。如果没有安装所需的虚拟机管理程序,Android Studio 通常会提示您安装它。没有加速,模拟器将从虚拟机获取机器代码,并将其逐块转换为与主机计算机架构兼容。这个过程可能相当慢。然而,如果虚拟机和主机计算机的架构匹配(例如 x86 上的 x86),模拟器可以跳过代码转换,并直接在真实的 CPU 上使用虚拟机运行。在这种情况下,模拟器的速度可以接近您的实际计算机。
您可以从以下 URL 开始安装 Android Studio:
developer.android.com/studio/index.html
为了编写这本书,我们使用了具有最低系统要求的 Windows 10 系统。一旦您下载了 Android Studio 的.exe文件,请按照以下步骤完成安装:
-
打开您刚刚下载的
.exe文件 -
按照设置向导进行操作,并使用标准安装方式安装它
完成此操作后,您将准备好使用需要 Android N 的 SDK 组件启动 Android Studio;Android 的 SDK 工具版本为 25.0.0。
安装步骤如下:

按“下一步”开始设置:

确保您有足够的安装空间,然后通过点击“下一步”继续:

仔细阅读条款和协议后,按“我同意”继续:

选择您希望安装 Android Studio 的路径,然后按“下一步”:

创建一个启动菜单项以快速访问,并按“安装”:

等待安装过程完成:

你现在已经完成了 Android Studio 的安装;按完成继续。
现在,你需要配置 Android Studio 以使用 Android N SDK。完成此操作的步骤如下所示:

由于我们正在安装一个全新的副本,请选择截图中的最后一个选项并按确定:

按下下一步继续:

选择标准安装以使用推荐设置:

按完成开始下载所需组件。
按下完成后,你的电脑将开始下载 Android N SDK 所需组件,因此请确保你的互联网连接正常,然后坐下来享受一杯咖啡,同时 SDK 在你的系统上安装:

你现在已经成功安装了 Android Studio 和 Android N 所需的组件。
下载完 SDK 的所有组件后,你将准备好启动 Android Studio,并会看到以下屏幕菜单:

恭喜!你现在可以开始使用 Android Studio 了!
安卓的精髓
在 Android Studio 中开始新项目之前,有一些基本概念你必须熟悉。因此,让我们看看我们章节中将要处理的一些常见术语。
包名
你将遇到的第一件事是所谓的包名。实际上,理解起来相当简单。包名就像一个反向 URL。将包名视为你的应用的域名——就像一个网站一样,只是方向相反。例如,你可以将www.google.com视为一个网站的域名;以完全相同的方式,Android 应用的命名规范与网站相反。因此,你可以将你的应用命名为类似com.google.www的东西。没有严格的规定说你的包名必须从com开始,但它是最普遍接受的约定。你也可以使用你自己的约定随机命名包名,例如abc.xyz.lmn、mygame.mycompany.myname等等。此外,非常重要的一点是,包名必须是唯一的,并且不应与谷歌应用商店上任何其他现有应用的包名匹配。
选择一个独特的包名非常重要,因为 URL 会被谷歌索引,这对你的游戏或应用在谷歌应用商店中被注意到至关重要。因此,请确保你为你的游戏使用一个独特的包名。还有一个有趣的现实是,如果你在包名上已经确定,即使应用尚未上线,你也可以预测你的应用 URL。因此,你不能使用另一个已上线应用相同的包名,因为它已经在谷歌应用商店上。你的应用将根据以下 URL 约定上线:
https://play.google.com/store/apps/details?id=*package_name_here*
因此,如果你的包名是 abc.xyz.lmn,那么你的应用的 URL 将是以下格式:
https://play.google.com/store/apps/details?id=abc.xyz.lmn
布局
下一个概念是布局。我们将在下一章处理布局,但为了给你一个简单的介绍,让我们提供一些示例。我们将制作一个游戏,在游戏中,我们不需要显示状态栏,这意味着我们需要一个全屏布局。如果你在制作一个应用,你可能不会介意在顶部显示状态栏。所以在这种情况下,你可以使用相对布局或线性布局。这本书真正有趣的地方在于,到结束时,你还将对如何创建非游戏应用有一个基本的了解。因此,强烈建议你正确掌握前三章的知识。
AndroidManifest 文件
在制作 Android 应用或游戏时,另一个重要的概念是AndroidManifest文件。简单来说,这个文件包含了应用所需的所有规则,或者用更广泛的话说,是权限。你一定在 Google Play Store 上注意到,在下载任何应用之前,都会弹出一个对话框告诉你应用运行所需的权限。这些权限是基本规则,需要在AndroidManifest文件中提供,以便让用户了解将收集哪些信息。例如,如果一个应用需要访问互联网,那么开发者必须确保在清单文件中包含互联网权限。如果开发者没有在清单文件中写入这个权限,那么应用将无法访问该功能,访问联系人、相册、摄像头以及其他一切也是如此。
在开始开发任何 Android 游戏或应用之前,你需要牢记这三件最重要的事情。
在下一章中,我们将开始我们的第一个 Android Studio 项目。
概述
在本章中,我们学习了有关 Android 的一些基本信息以及如何安装 Android Studio,这将有助于我们在开发应用的道路上。我们还配置了 Android Studio,使其与 Android N 的组件兼容;你现在可以开始 Android 游戏开发了。
现在我们已经安装了 Android Studio,我们将在下一章学习如何执行/运行我们的第一个程序。系好安全带,你将有一段刺激的旅程!
第二章:熟悉 Android Studio
本章将指导你通过 Android Studio,并在本章结束时,你将成功执行你的第一个 Android 项目。这将是一个对你理解 Android 项目结构至关重要的章节,并将帮助你理解后续章节。你将学习以下内容:
-
Android 项目的通用项目结构
-
默认类说明
-
XML 文件和不同类型的 XML 文件
-
设置 Android 模拟器
-
执行你的第一个 Hello World 程序
理解 Android 项目结构
因此,在我们上一章中,我们成功设置了 Android Studio,并配备了开始工作的必要组件。让我们开始吧。为了了解 Android 项目结构,我们首先必须打开一个新的项目。
创建你的第一个 Android Studio 项目
打开你的 Android Studio,点击“开始新的 Android Studio 项目”,如下截图所示:

一旦你开始一个新的项目,你将看到以下截图:

在这个屏幕上,填写你第一个 Android 应用的详细信息:
-
应用名称是应用的名字,当它安装到手机上时,将显示在图标上。
-
公司域名是应用的一个标识符。确保你在所有应用中保持这一名称的一致性,以实现更好的组织和规范。
-
包名是应用的重要唯一标识符。我们在第一章中学习了它,并看到了它的命名约定。如有关于包名及其命名约定的疑问,请参考该部分。
-
包含 C++支持是可选的。为了本书的目的,我们将保持未选中状态。
-
项目位置是指你的项目文件夹将在电脑上的路径。
在填写所有这些详细信息后,按下“下一步”。
现在,你将看到这个屏幕:

你的 Android 应用的不同平台
为了本书的目的,我们只会使用手机和平板电脑。然而,一旦你对开发周期感到熟悉,你也可以尝试在其他平台上进行实验。
最小 SDK 是运行你的应用所需的最小 OS 版本。建议你选择尽可能多的 OS 版本上运行应用的最小版本。然而,请注意,一些功能在后续版本中已被弃用,因此建议使用最小 API 14:Android 4.0(IceCreamSandwich)以实现无烦恼的开发。你还将被提示你的应用将支持多少设备,如图所示。这些数据是实时的,并且随着 OS 市场份额的变化而变化。
如果你想了解更新后的市场份额以及与各种版本及其相应市场份额相关的更多有趣内容,你可以访问官方 Android 网站developer.android.com/about/dashboards/index.html。
选中我们的全屏活动后,按下一步,如下所示:

不同类型的活动
你可以说活动只是一个默认布局。由于我们正在创建一个游戏,我们将使用全屏活动。正如你在前面的截图中所见,你有许多不同的活动选项可以选择,它们本身相当直观。因此,让我们选择全屏活动并按下一步:

活动命名
活动名称是你将生成的 Java 类的名称。我们将大量使用这个文件,所以请确保你记得它。
布局名称是处理你的应用视觉上如何以及显示什么组件的 XML 文件的名称。对于 XML 文件,需要遵循一些命名约定。如下所示:
-
名称必须全部小写
-
使用下划线而不是空格来分隔两个单词。
-
总是在文件名前加上资源类型的前缀;例如,如果你的 XML 文件对应一个活动,如
MainActivity.java,那么你的文件名应该是activity_main.xml。 -
如果你有一个特定组的子项,例如主活动的列表项,那么它可以命名为
activity_main_list_item.xml。 -
这样,你可以根据你的喜好在文件名后缀或前缀添加一个关键字。
标题是将在你的应用顶部栏中显示的名称。你为活动使用的标题和主活动的类名可能不同;标题也可能包含空格。
完成所有这些后,点击“完成”并给自己一个掌声。你已经成功学习了如何创建 Android Studio 项目。在此之后,根据你系统的性能等待几秒钟/几分钟,以便项目设置;一旦准备好,你将看到以下屏幕:

我们的 Android Studio 项目文件夹
如果由于某种原因,等待后你仍然看不到这个屏幕,那么点击屏幕左上角垂直对齐的“1:项目”选项,它位于“FirstGame”下方。
恭喜!你现在已经成功创建了你第一个应用。在这个时候,你可以运行项目并查看它在模拟器上的运行情况;然而,在我们这样做之前,让我们了解一下这个项目的文件夹结构。让我们展开每个文件夹以进一步了解它。现在,这部分非常重要,因为它将作为你在 Android Studio 中几乎每项基本操作的基础,所以请确保你正确理解这一点。
Android 项目的项目结构
点击每个文件夹左侧的小箭头以展开其视图:

项目文件夹内的各种文件夹
正如你所见,在我们的项目文件夹中有多个文件夹。我们的主要项目文件夹包含三个子文件夹,分别是 manifest、java 和 res;我们现在将分别探索这些文件夹:
-
manifest:这个文件夹包含你的AndroidManifest.xml文件,它负责给你的应用授权,正如我们在前面的章节中学到的。 -
java:这个文件夹包含所有你的.java文件,这些是你的 Java 代码文件。通常,这个文件夹有多个子文件夹,其中包含你的包名,在其中,是源代码文件。你可以在前面的屏幕截图中观察到我们有一个java文件夹,其中包含一个名为nikhil.nikmlnkr.game的文件夹,这实际上是我们的游戏包名,我们有一个FullScreenActivity在其中。 -
res:这个文件夹包含所有你的资源。这里的res简单来说就是资源,它可以包括从简单的字符串数据到图像再到复杂的 XML 布局的任何内容。简单来说,屏幕上显示的大多数东西都存储在这里。你可以使用你保存在res文件夹中的资源来设计你的前端。
现在我们已经知道了这些文件夹是什么,让我们开始设计我们的第一个程序。同时,为了保持我们的编程背景,让我们从著名的 Hello World 示例开始。从这一点开始,事情将会变得非常有趣。到目前为止,你已经掌握了开始开发所需的大部分基础知识。现在,只需几分钟的时间,你就可以准备好在你的手机上运行的你第一个 Android 应用。所以,无需多言,让我们开始吧!
如果你看到一个屏幕上有两个大蓝色窗口,上面写着 DUMMY CONTENT,就像前面的屏幕截图所示,那么你就可以开始了;否则,只需导航到 res/layout/ 文件夹,双击 activity_fullscreen.xml 以打开我们将要工作的 XML 布局。
现在,你面前有许多选项;不要感到不知所措或害怕。你很快就会学会并掌握使用面前工具创建令人惊叹的用户界面的艺术。
创建我们的 Hello World!程序
那么,让我们创建我们的第一个 Hello World 程序。正如你所见,在你面前除了蓝色的 DUMMY CONTENT 窗口外,还有一个调色板。只需将 TextView 组件拖放到你的 DUMMY CONTENT 屏幕上,就像你在下面的屏幕截图中看到的那样:

将 TextView 组件拖放到我们的屏幕上
注意当你成功地将 TextView 放置在 DUMMY CONTENT 屏幕上时出现的新属性窗口。
现在,您可以在屏幕上看到空白文本视图组件,但上面没有任何内容。要让它显示内容,我们需要稍微修改其属性。查看屏幕右侧的属性,在文本组件中输入Hello World!。您还可以看到我们的文本非常小。让我们将其改为稍大一些,以便我们可以清楚地看到。定位到属性窗口中名为 textAppearance 的最后一个选项。点击其旁边的下拉菜单,并从其中选择任何适合您选择的选项。在这个例子中,我们将使用 AppCompat.Headline;然而,您可以选择任何您想要的。完成这些操作后,您将看到类似以下内容:

您的第一个 Hello World!程序已准备就绪!
就这样!您现在可以执行程序了!简单易行,对吧?让我们继续执行这段代码。为此,我们需要运行一个称为模拟器的东西。模拟器简单来说就是一个虚拟设备,它将在您的 PC 上充当 Android 设备,这样您就不必每次都实际在 Android 设备上测试您的应用。您需要遵循几个步骤来设置模拟器,让我们开始吧。您只需设置一次模拟器,下次它将为您准备好。
设置模拟器
点击绿色的播放图标以启动执行您的第一个程序并设置模拟器:

播放按钮位于屏幕顶部,如图所示
现在,点击“创建新虚拟设备”并确保已勾选“未来启动使用相同选择”:

选择您选择的设备,然后点击“下一步”:

选择 Nougat(API Level 24),然后点击“下一步”:

在 AVD 名称字段中为您的模拟器命名(可选),选择竖屏启动方向,然后点击“完成”:

您可以看到您创建的最新设备现在将出现在可用虚拟设备列表中;选择它并点击“确定”:

选择您的设备后点击“确定”
仅适用于 Windows 系统
现在你可能会收到以下消息,如图所示:

如果您收到“仅适用于 Windows 系统”的消息,请按照以下步骤操作;否则,可以跳过这部分:
-
通过重启计算机并按Delete、Esc或F1键进入 BIOS(具体按键取决于您的系统)。
-
前往处理器/芯片组设置。
-
启用虚拟化技术。
这可能是由几个问题引起的。HAXM 问题通常是由于英特尔芯片组引起的。英特尔硬件加速执行管理器(Intel® HAXM)是一个硬件辅助的虚拟化引擎(管理程序),它使用英特尔虚拟化技术(Intel® VT)来加速主机上的 Android 应用程序仿真。结合英特尔提供的 Android x86 模拟器镜像和官方的 Android SDK 管理器,HAXM 允许在启用 Intel VT 的系统上实现更快的 Android 仿真:

一旦你这样做,屏幕下方将打开一个小窗口,你的构建过程将开始。等待一段时间,你的模拟器将打开:

恭喜!你的 Hello World!程序运行成功。
给自己一个掌声。你现在已经成功执行了你的第一个程序。
摘要
在本章中,你已经了解了我们的 Android 项目结构,以及如何使用 XML 文件创建元素的基本理解。我们还学习了如何设置 Android 模拟器,并执行了我们的 Hello World!程序。
在下一章中,我们将探讨如何管理输入,并深入理解 XML 文件,这将成为我们制作游戏的核心基础。我们还将学习如何将你的 XML 文件链接到源代码,更改文本,以及其他酷炫的功能。
第三章:管理输入
现在我们已经学会了如何在 Android 模拟器上运行程序,是时候我们做一些更酷的事情了,这将使我们具备制作游戏所需的知识。在本章中,我们将继续探讨 XML 文件,并进入获取用户输入的领域。简而言之,我们将学习以下内容:
-
进一步探索不同类型的 XML 文件和资源文件夹
-
创建按钮并将它们链接起来以获取输入
-
与加速度计读数一起工作
-
移动触摸输入
所以,让我们开始吧。
资源文件夹的详细情况
在上一章中,我们使用了 activity_fullscreen.xml 文件来编辑应用程序的前端。现在,我们将查看更多这类 XML 文件,并了解它们如何对我们制作游戏有所帮助。为了理解这类文件,我们首先需要了解一些关于它们的基本信息。首先,关于 XML 的最基本信息是它是一种扩展标记语言的简称。现在,如果你已经学习了 HTML,你会知道它的全称与它非常相似——超文本标记语言。它们的语法也非常相似,但 XML 文件的功能是存储数据。如果你按照 XML 文件的定义来看,它可能如下所示:XML 是一种软件和硬件独立的存储和传输数据的工具。
你可以在 en.wikipedia.org/wiki/XML 上了解更多关于 XML 文件的信息。
我们还没有在代码中看到 XML 文件,所以让我们来做这件事。点击以下截图所示的“文本”:

代码编辑的文本模式就在设计按钮旁边
现在,你实际上可以看到打开的 XML 代码:

这是你的默认 XML 代码
仔细关注这段代码,你会发现如下内容:
<TextView
android:text="Hello World!"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/TextView"
tools:text="helloWorld"
android:textAppearance="@style/TextAppearance.AppCompat.Headline" />
如果你看到这个,你可以在代码的第一行看到你的Hello World!文本。我们在上一章中通过视觉方式更改的数据可以通过代码在这里进行更改。对于所有组件来说,几乎都是一样的,随着你进一步练习,你将了解不同的组件。
现在,这并不是唯一的 XML 文件类型。正如我们在定义中读到的,XML 文件用于存储数据。让我们看看其他类型的 XML 文件,这些文件可以用来存储游戏得分、文件名、文本数据等等。让我们取一个这样的新类型的 XML 文件,它已经存在于我们的项目文件夹中,以便进一步了解:

strings.xml 文件包含所有字符串数据,位于 res/values/ 文件夹中
导航到 app/res/values/ 文件夹,并双击 strings.xml 文件。
当你打开这个文件时,你可以看到这个文件的代码。在这里,你可以观察到每个值都有多个值和一个以 name 形式的 ID。仔细观察第二行,如下所示:
<string name="app_name">First Game</string>
记住这个名字吗?我们在开始项目时将其设置为我们的应用程序名称。它存储在app_name的值中。另外,如果您回到activity_fullscreen.xml文件并使用Ctrl + F搜索app_name,您将找到此条目。您可以自己探索一下。
还要查看其他文件以了解情况。以下是项目文件夹中四个 XML 文件的基本说明:
-
attrs.xml:这声明了允许根据 API 级别更改按钮栏样式的自定义主题属性 -
colors.xml:这定义了可以使用十六进制代码使用的颜色 -
strings.xml:这包含所有字符串相关值的数据库 -
styles.xml:这设置了应用程序的基本主题
那么,关于 XML 文件的内容就到这里了。现在让我们继续到更有趣的部分——输入。
获取用户输入
您可以从 Android 设备获取输入的多种方式。以下是一些方法:
-
UI 按钮:绘制在您的应用程序 UI 上的按钮
-
硬件按钮:您的 Android 设备上的按键
-
触摸屏输入:基于屏幕坐标映射的触摸
-
加速度计读数:运动传感器读数
我们将查看每种输入类型。所以,让我们从第一种输入类型,UI 按钮开始。
按钮输入
按钮输入是 Android 项目中使用的最常见组件类型之一。让我们回到设计模式,并在屏幕上创建一个按钮:

从文本模式恢复到设计模式
点击设计按钮,切换回我们的 activity_fullscreen.xml 文件上的视觉编辑模式。
在这里,我们需要对我们的 XML 文件进行一些更改。按照以下步骤操作:
-
从调色板中向下滚动,找到线性布局(水平)并将其拖放到 fullscreen_content_controls 内部。
-
在调色板下方拖动您的 TextView 到您刚刚创建的 LinearLayout(水平)下。
-
从调色板中选择按钮组件并将其拖放到组件树窗口的 LinearLayout(水平)中。
完成此操作后,您将得到如下输出:

您现在屏幕上有一个按钮和文本。
如果我们没有使用线性布局组件,那么我们的按钮和文本将相互重叠,因为如果您观察组件树,我们的 TextView 最初是在 FrameLayout 内部,它没有对齐选项。您可以尝试将您的按钮和文本拖放到 FrameLayout 内部,自己看看。
现在,让我们继续制作这个按钮工作。点击您刚刚创建的按钮,在右侧的属性窗口中查看 ID 属性。这是让您的代码知道与哪个按钮交互的属性;将其更改为myFirstButton:

您现在已为您的按钮分配了一个自定义 ID。
对于 TextView,重复相同的步骤,将其 ID 更改为 myTextView。记住这两个 ID,然后继续我们的下一步。现在,我们将实际将这个视觉按钮链接到我们的代码中,以便我们可以更改 TextView 组件的文本。前往你的 app/java/nikhil.nikmlnkr.game 文件夹,并打开 FullscreenActivity。
注意,nikhil.nikmlnkr.game 是本书中使用的包名。你的包名可能因你最初设置的而不同,所以根据你的包名导航到文件夹。为了本书的目的,我们将假设包名是 nikhil.nikmlnkr.game。
打开该文件后,我们将打开应用程序的主 Java 代码。你可以看到那里已经写了很多代码。不要被它吓到,因为很快你就会自己理解每个代码块是关于什么的。让我们先编写一些自己的代码,以便开始将按钮链接到代码文件;搜索 void onCreate 函数。
onCreate() 方法是初始化你的活动的地方。如果活动被启动且应用程序未加载,则两个 onCreate() 方法都将被调用。你可以在这里初始化你的变量和方法。
你可以看到类似这样的内容:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fullscreen);
mVisible = true;
mControlsView = findViewById
(R.id.fullscreen_content_controls);
mContentView = findViewById(R.id.fullscreen_content);
// Manually show or hide the System UI
mContentView.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View view) {
toggle();
}
});
/* Upon interacting with UI controls, delay any scheduled hide()
operations to prevent the jarring behavior of controls going away while
interacting with the UI.*/
findViewById(R.id.dummy_button).
setOnTouchListener(mDelayHideTouchListener);
}
现在,将其修改为看起来像这样;更改已用粗体标出:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fullscreen);
// Declare variable references for our TextView and Button with // their IDs final TextView tv =
(TextView)findViewById(R.id.myTextView); final Button button =
(Button) findViewById(R.id.myFirstButton);
// Make an OnClickListener to listen to button click events button.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { // Perform action on click
tv.setText("Button Clicked");
} }); mVisible = true;
mControlsView =
findViewById(R.id.fullscreen_content_controls);
mContentView =
findViewById(R.id.fullscreen_content);
// Manually show or hide the System UI
mContentView.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View view) {
toggle();
}
});
// Upon interacting with UI controls, delay any scheduled hide()
// operations to prevent the jarring behavior of controls going
// away while interacting with the UI.
findViewById(R.id.dummy_button).
setOnTouchListener(mDelayHideTouchListener);
}
通过编写此代码,你已经完成了以下操作:
-
将你的按钮 ID 链接到你的代码
-
将你的文本视图 ID 链接到你的代码
-
创建了一个按钮点击监听器,这是从按钮获取输入所必需的
准备好之后,按照我们在上一章中学到的步骤在模拟器上运行你的应用程序:

按下我们的按钮时,文本将更改为“Button Clicked”,正如我们在文件中编码的那样。
硬件按钮输入
现在,让我们测试一下我们的硬件按钮,并使用 Toast 示例。阅读 Toast 时不要感到饿!它不是你将要吃的那个。Toast 是一个在屏幕上显示几秒钟后消失的消息。你将在几分钟内看到它是什么。所以,现在打开你编写的 FullscreenActivity.java 文件,并在 onCreate 方法之后,输入以下内容:
@Override
public void onBackPressed() {
// your code.
Toast.makeText(FullscreenActivity.this,
"Back button pressed", Toast.LENGTH_SHORT).show();
}
观察这里的代码。在第一行,我们使用了关键字 @Override。这是因为我们正在覆盖父类功能,这是 Android 默认行为,以便执行我们想要的另一项操作。如果你不覆盖该函数,那么默认情况下 Android 将关闭应用程序,因为这是返回按钮的功能。
如果你收到一个带有红色下划线的 Toast 错误,请点击你得到错误的地方,然后按 Alt + Enter。这将添加运行它所需的缺失导入。完成后,再次在模拟器上运行你的应用程序,你将看到以下输出:

因此,现在你已经成功地将你的硬件按钮映射到显示 Toast 消息。太棒了!让我们继续前进,尝试跟踪我们的触摸坐标。
触摸输入
现在我们已经了解了如何映射我们的硬件按钮,让我们更深入地探讨 Android 中最常用的输入方式:触摸输入。然而,在我们理解触摸输入之前,我们必须了解用于跟踪屏幕上触摸的坐标系。让我们看一下下面的插图来理解这一点:

Android 中的坐标系
为了跟踪我们的触摸,我们必须对屏幕坐标如何在我们的设备上映射有一个基本的了解。正如你在前面的图像中看到的,我们的屏幕坐标从左上角的(0,0)开始,到右下角的(w,h)结束,其中 w 是屏幕宽度,h 是屏幕高度。所以,如果你的手机分辨率为 480 x 850,那么你的右下角坐标将是w=480,h=850。因此,你的极端坐标将被映射为(480,850)。只需记住这一点,你很快就会理解为什么我们需要知道这个。
现在,打开你的 XML 文件,并拖放另一个 TextView 组件:

给它一个 ID coords 和文本 Coords:

现在,点击组件树视图中的 LinearLayout,并给它一个 ID 为parent。
现在,前往你的FullscreenActivity.java代码文件,并修改你的onCreate函数,使其看起来像这样:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fullscreen);
final TextView tv =
(TextView) findViewById(R.id.myTextView);
final Button button =
(Button) findViewById(R.id.myFirstButton);
button.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
// Perform action on click
tv.setText("Button Clicked");
}
});
mVisible = true;
mControlsView =
findViewById(R.id.fullscreen_content_controls);
mContentView =
findViewById(R.id.fullscreen_content);
final LinearLayout parent =
(LinearLayout) findViewById(R.id.parent);
final TextView text = (TextView) findViewById(R.id.coords);
parent.setOnTouchListener(new View.OnTouchListener() {
public boolean onTouch(View v, MotionEvent ev) {
text.setText
("Touch at " + ev.getX() + ", " + ev.getY());
return true;
}
});
// Set up the user interaction to manually show or hide the system UI.
mContentView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
toggle();
}
});
// Upon interacting with UI controls, delay any scheduled hide()
// operations to prevent the jarring behavior of controls going
// away while interacting with the UI.
findViewById(R.id.dummy_button).
setOnTouchListener(mDelayHideTouchListener);
}
为了解释前面的代码,我们在这里所做的是相当简单的:我们为我们的 LinearLayout 声明了一个引用parent,然后为我们的新创建的 coords(TextView)声明了另一个引用。现在,在我们的声明之后,我们指示parent类在其上有一个Touch listener属性。这将帮助我们获取我们触摸的坐标。正如你在前面的代码中看到的,我们有一个MotionEvent变量ev,它将给我们坐标。然后,在下一条线中,我们以x和y坐标的形式获取了值,然后将其设置为 coords(TextView)上的文本。
现在运行你的代码,当模拟器启动时,尝试随机点击任何地方;这将给出你刚刚触摸的坐标:

它将显示你在屏幕上触摸的位置。
现在,到这个时候,你必须已经注意到,拖放并正确对齐所有文本相当繁琐,对吧?这是因为我们正在使用 LinearLayout。有一种方法可以正确组织所有文本。那就是通过 RelativeLayout。让我们看看一个工作示例,帮助你更好地理解这一点。前往你的 XML 文件中的 TextView,就像我们在上一章中学到的那样,将你看到的 LinearLayout 更改为 RelativeLayout。以下是相应的参考代码:
<RelativeLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/parent">
<Button
android:text="Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/myFirstButton"
android:layout_weight="1" />
<TextView
android:text="Hello World!"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/myTextView"
tools:text="helloWorld"
android:textAppearance="@style/TextAppearance.AppCompat.Headline"
android:layout_weight="1"
android:layout_alignBaseline="@+id/myFirstButton"
android:layout_alignBottom="@+id/myFirstButton"
android:layout_toRightOf="@+id/myFirstButton"
android:layout_toEndOf="@+id/myFirstButton" />
<TextView
android:text="Coords"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/coords"
android:layout_weight="1"
android:textAppearance="@style/TextAppearance.AppCompat.Headline"
android:layout_below="@+id/myFirstButton"
android:layout_alignRight="@+id/myFirstButton"
android:layout_alignEnd="@+id/myFirstButton" />
</RelativeLayout>
现在,你已经成功地将你的 LinearLayout 转换为 RelativeLayout,这将为你提供更多的设计选项控制。你现在可以更轻松地调整屏幕上的组件,因此你可以将它们对齐如下:

完成这些操作后,你可以轻松地通过简单地拖动它们到任何你想要的位置来调整你的文本。当你使用 RelativeLayouts 时,你不会弄乱布局。现在,让我们继续到下一个示例,获取加速度计输入,并在屏幕上选择另一个 TextView 组件,如前述代码所示,给它一个 ID accel 并设置文本为加速度计。然而,在我们继续相同的代码之前,我们需要在我们的现有代码中进行一个小的更改。
由于我们刚刚将我们的 LinearLayout 更改为 RelativeLayout,我们还需要在我们的 Java 代码文件中为我们的触摸功能引用相同的 RelativeLayout。打开你的 Java 代码文件,你将注意到它已经在 LinearLayout 上给出了错误提示。这是因为实际上在我们的项目中没有 ID 为 parent 的 LinearLayout,因为我们将其更改为 RelativeLayout。不过,不用担心这个问题;只需输入 RelativeLayout 而不是 LinearLayout,问题就解决了。以下是方法:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fullscreen);
final TextView tv =
(TextView) findViewById(R.id.myTextView);
final Button button =
(Button) findViewById(R.id.myFirstButton);
button.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
// Perform action on click
tv.setText("Button Clicked");
}
});
mVisible = true;
mControlsView =
findViewById(R.id.fullscreen_content_controls);
mContentView =
findViewById(R.id.fullscreen_content);
final RelativeLayout parent =
(RelativeLayout) findViewById(R.id.parent);
final TextView text =
(TextView) findViewById(R.id.coords);
parent.setOnTouchListener(new View.OnTouchListener() {
public boolean onTouch(View v, MotionEvent ev) {
text.setText
("Touch at " + ev.getX() + ", " + ev.getY());
return true;
}
});
// Set up the user interaction to manually show or hide the system UI.
mContentView.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View view) {
toggle();
}
});
// Upon interacting with UI controls, delay any scheduled hide()
// operations to prevent the jarring behavior of controls going
// away while interacting with the UI.
findViewById(R.id.dummy_button).
setOnTouchListener(mDelayHideTouchListener);
}
运行你的应用一次,你就可以看到它现在看起来多么整洁:

现在,我们已经成功地对我们的布局进行了相当整洁的调整,并实现了触摸监听器以检测触摸。
现在,我们继续本章的最后一个输入类型,加速度计输入。
加速度计输入
我们现在将查看 Android 上的加速度计组件。如果你不知道加速度计是什么,它是一种用于检测 Android 中运动的设备。用通俗易懂的话来说,我们可以称它为运动传感器。最好的例子是任何允许你根据手机运动来控制汽车的赛车游戏。这真的很有趣,你可以在后续章节中将运动应用到对象上,所以请确保你正确理解这一点。从sensorManager开始输入你看到的以下代码块:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fullscreen);
final TextView tv =
(TextView) findViewById(R.id.myTextView);
final Button button =
(Button) findViewById(R.id.myFirstButton);
button.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
// Perform action on click
tv.setText("Button Clicked");
}
});
mVisible = true;
mControlsView =
findViewById(R.id.fullscreen_content_controls);
mContentView =
findViewById(R.id.fullscreen_content);
final RelativeLayout parent =
(RelativeLayout) findViewById(R.id.parent);
final TextView text =
(TextView) findViewById(R.id.coords);
parent.setOnTouchListener(new View.OnTouchListener() {
public boolean onTouch(View v, MotionEvent ev) {
text.setText
("Touch at " + ev.getX() + ", " + ev.getY());
return true;
}
});
SensorManager sensorManager =
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
Sensor sensor =
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
sensorManager.registerListener(new SensorEventListener() {
@Override
public void onSensorChanged(SensorEvent event) {
float x = event.values[0];
float y = event.values[1];
float z = event.values[2];
TextView acc = (TextView) findViewById(R.id.accel);
acc.setText("x: "+x+", y: "+y+", z: "+z);
}
@Override
public void onAccuracyChanged(Sensor sensor,
int accuracy){
}
}, sensor, SensorManager.SENSOR_DELAY_FASTEST);
// Set up the user interaction to manually show or hide the system UI.
mContentView.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View view) {
toggle();
}
});
// Upon interacting with UI controls, delay any scheduled hide()
// operations to prevent the jarring behavior of controls going
// away while interacting with the UI.
findViewById(R.id.dummy_button).
setOnTouchListener(mDelayHideTouchListener);
}
再次,如果你在输入时遇到与导入语句相关的错误,请按 Alt + Enter,你将收到建议。从列表中选择相应的导入语句以解决错误。现在,代码将被解释。我们为 Android 的sensorManager组件创建了一个变量,该组件负责跟踪我们的加速度计值。正如你在其后的行中可以看到的,我们将传感器类型设置为TYPE_ACCELEROMETER。接下来,我们为我们的组件创建了一个另一个监听器,然后我们获取了x、y和z值。之后,我们简单地引用了 XML 文件中的 accel TextView 组件,并将其文本设置为显示这些值。
现在,很明显你无法在模拟器上看到加速度计值的变化,所以你将不得不在移动设备上测试它。然而,出于好奇,尝试在你的电脑上运行代码:

你会看到一些值,但它们不会变化,因为我们的 PC 没有运动传感器。
因此,现在让我们在我们的移动设备上运行我们的应用。我们可以用两种方式来做这件事:
-
通过 USB 直接构建和部署到我们的设备,这将允许我们在物理设备上直接运行应用
-
构建 apk 文件,然后将 apk 文件传输到我们的手机并安装它
让我们开始吧!
通过 USB 构建 和 部署
要使你的设备准备好调试,首先需要启用调试功能。为了在开发者选项中启用调试功能,需要访问这些设置,请打开系统设置中的开发者选项。在 Android 4.2 及更高版本中,开发者选项屏幕默认隐藏。要使其可见,请转到设置 | 关于手机,然后连续点击七次“构建号”:

然后,返回上一屏幕,在底部找到开发者选项:

现在,在你的设备上启用 USB 调试选项:

使用 Android 设备,你可以像在模拟器上一样开发和调试你的 Android 应用。在开始之前,只需做一些事情:
在你的清单文件或 build.gradle 文件中验证你的应用是否可调试。
在构建文件中,确保 debug 构建类型的 debuggable 属性设置为 true。构建类型属性会覆盖清单设置:
android {
buildTypes {
debug {
debuggable true
}
在 AndroidManifest.xml 文件中,将 android:debuggable="true" 添加到 <application> 元素中。
注意:如果你在清单文件中手动启用调试,确保你在发布构建中禁用它(你发布的应用通常不应该可调试)。
-
在设备系统设置中,在设置 | 开发者选项下启用 USB 调试。
注意:在 Android 4.2 及更高版本中,开发者选项默认隐藏。要使其可用,请转到设置 | 关于手机,然后连续点击七次“构建号”。返回上一屏幕以找到开发者选项。
-
设置你的系统以检测你的设备:
-
如果你在 Windows 上开发,你需要安装 ADB 的 USB 驱动程序。有关安装指南和 OEM 驱动程序的链接,请参阅OEM USB 驱动程序文档。
-
如果你正在 macOS X 上开发,它就会正常工作,所以跳过这一步。
-
如果你正在 Ubuntu Linux 上开发,你需要添加一个 udev 规则文件,该文件包含你想要用于开发的每种设备的 USB 配置。在规则文件中,每个设备制造商由一个唯一的供应商 ID 标识,如
ATTR{idVendor}属性所指定。有关供应商 ID 的列表,请参阅 USB 供应商 ID,如下所示。要在 Ubuntu Linux 上设置设备检测,请执行以下操作:-
以 root 身份登录并创建此文件:
/etc/udev/rules.d/51-android.rules。使用以下格式将每个供应商添加到文件中:
SUBSYSTEM=="usb", ATTR{idVendor}=="0bb4", MODE="0666", GROUP="plugdev"在此示例中,供应商 ID 是为 HTC 设计的。MODE 分配指定了读写权限,GROUP 定义了哪个 Unix 组拥有设备节点。
注意:规则语法可能因环境而略有不同。如有需要,请咨询您系统的 udev 文档。有关规则语法的概述,请参阅此指南 编写 udev 规则。
-
现在,执行
chmod a+r /etc/udev/rules.d/51-android.rules。
-
-
注意:当你将运行 Android 4.2.2 或更高版本的设备连接到计算机时,系统会显示一个对话框,询问是否接受允许通过此计算机进行调试的 RSA 密钥。这种安全机制保护用户设备,因为它确保除非你能够解锁设备并确认对话框,否则无法执行 USB 调试和其他 ADB 命令。这需要你有 ADB 版本 1.0.31(在 SDK 平台工具 r16.0.1 及更高版本中可用)才能在运行 Android 4.2.2 或更高版本的设备上进行调试。
当通过 USB 连接时,你可以通过执行 SDK 平台工具目录中的 ADB devices 来验证你的设备是否已连接。如果已连接,你将看到设备名称作为设备列出。
在运行或调试应用程序时,你将看到一个设备选择对话框,其中列出可用的模拟器(s)和连接的设备(s)。选择你想要安装和运行应用程序的设备。
如果使用 Android 调试桥接(ADB),你可以使用 -d 标志来针对你的连接设备发出命令。
你可以在 developer.android.com/studio/run/device.html 找到供应商 ID。
构建 apk 并在设备上安装
从顶部的任务栏,你看到文件、编辑等,点击构建,然后点击生成 APK。完成此操作后,它将开始为你生成 apk。完成后,右键单击左侧可见的应用程序文件夹,然后点击在资源管理器中显示:

在资源管理器中显示将打开包含你的项目文件夹的窗口:

在你的项目文件夹中,导航到app/build/outputs/apk/文件夹,在那里你会看到你的app-debug.apk文件。将这个文件传输到你的手机上,安装 apk 并运行它:

点击“安装”:

点击“打开”:

当你移动手机时,加速度计的值将会改变。
恭喜!你也学会了如何创建 apk 并在你的实际设备上运行它。
摘要
在本章中,你学习了如何接收不同类型的用户输入,以及如何生成 apk 并在你的实际设备上运行它。你还学习了各种类型的 XML 文件。
在下一章中,我们将学习如何创建精灵——不,不是冷饮!我们将学习如何创建图像以及如何玩转颜色。下一章将是我们进入屏幕图形创作的起点,对你来说将是一个巨大的进步。
第四章:创建精灵和交互对象
我们已经几乎学到了创建 Android 中各种组件所需的所有基础知识,因此我们现在可以继续做一些更有趣的事情。现在,在这个阶段,我们将开始制作一个真正的 2D 游戏。它将是一个类似于马里奥的小型 2D 横版滚动游戏。然而,在我们这样做之前,让我们首先谈谈游戏作为一个开发概念。为了更好地了解游戏,你需要了解一点博弈论。所以,在我们继续创建屏幕上的图像和背景之前,让我们深入探讨一些博弈论。以下是本章我们将涵盖的一些主题列表:
-
博弈论
-
使用颜色
-
在屏幕上创建图像
-
制作连续滚动的背景
让我们从第一个开始。
博弈论
如果你仔细观察游戏的源代码,你会注意到游戏只是一系列错觉,用于创建某些效果并在屏幕上显示。也许,最好的例子就是我们即将开发的这个游戏。为了使你的角色向前移动,你可以做以下两件事之一:
-
让角色向前移动
-
让背景在后面移动
让我们更详细地看看这个。前两点可以通过一些错觉来实现;让我们了解如何。
幻觉
上一节中提到的任何两种东西都会给你一种错觉,即角色正在向某个方向移动。如果你正确地记得马里奥,那么你会注意到云彩和草地是相同的,只是它们的颜色改变了。这是因为当时控制台平台的内存限制:

游戏开发者使用许多这样的技巧来确保他们的游戏能够运行。当然,在当今时代,我们不必过于担心内存限制,因为我们的移动设备具有阿波罗 11 号火箭的能力,它曾在月球上着陆。现在,考虑到提到的两种情况;我们将在我们的游戏中使用其中之一来使我们的角色移动。
我们还必须理解,每个游戏都是一个活动的循环。与应用程序不同,你需要在每一帧上绘制你的游戏资源。移动或其他效果的错觉将随着移动设备每秒可以绘制的帧数增加而变得更强烈。这个概念被称为每秒帧数(FPS)。它几乎与旧电影的概念相似,其中一大块胶片通过每帧滚动来在屏幕上投影。看看下面的截图,以更好地理解这个概念:

游戏角色的精灵图集
自从上一章以来,你可能一直在想精灵是什么意思,如果不是流行的冷饮。正如你在前面的屏幕截图中所见,精灵图集只是一个由多个图像组成的图像,这些图像本身又包含多个图像,以创建动画,因此精灵只是一个图像。如果我们想让我们的角色跑步,我们只需简单地读取文件Run_000并按顺序播放到Run_009,这样角色看起来就像是在跑步。我们将在第五章“为你的游戏添加动画”中查看这一点,我们将继续进行。
当你制作游戏时,你将处理的大多数事情都将基于操纵你的移动。因此,你需要清楚你的坐标系,因为它会很有用——无论是从枪中发射子弹、角色移动,还是简单地转身四处张望——所有这些都基于简单的移动组件。
游戏循环
在其核心,每个游戏基本上都是一个事件的循环。它是一组调用各种函数和代码块以在屏幕上执行绘制调用的设置,从而使游戏可玩。大多数情况下,你的游戏由三个部分组成:
-
初始化
-
更新
-
绘制
初始化游戏意味着为你的游戏设置一个入口点,通过这个入口点可以调用其他两个部分。你的游戏从这里开始,并且只调用一次。
一旦你的游戏初始化,你需要开始调用你的事件,这些事件可以通过你的update函数进行管理。
draw函数负责在屏幕上绘制所有图像数据。屏幕上显示的所有内容,包括你的背景、图像,甚至是你的 GUI,都是draw方法的职责。
至少可以说,你的游戏循环是游戏的核心。这只是一个游戏循环的基本概述,你还可以添加更多复杂性。然而,目前,这些信息已经足够你开始。
以下图像完美地说明了什么是游戏循环:

图片来源:gamedevelopment.tutsplus.com/articles/gamedev-glossary-what-is-the-game-loop--gamedev-2469
游戏设计文档
在开始游戏之前,创建一个游戏设计文档(GDD)是至关重要的。这份文档是你将要制作的游戏的基础。总的来说,当我们开始制作游戏时,有 99%的时间我们会失去对计划的功能的跟踪,并偏离核心游戏体验。因此,始终建议有一个 GDD,以便保持专注。GDD 包括以下内容:
-
游戏玩法机制
-
故事(如果有)
-
关卡设计
-
声音和音乐
-
UI 规划和游戏控制
你可以通过以下链接了解更多关于游戏设计文档的信息:
原型设计
在制作游戏时,我们需要同时进行测试。游戏是最复杂的软件之一,如果我们在一部分出错,可能会破坏整个游戏。这个过程可以称为原型设计。制作游戏的原型是游戏最重要的方面之一,因为这是你测试游戏基本机制的地方。原型应该是具有基本功能的一个简单的工作模型,也可以称为游戏的简化版本。
表面和画布
我们看到了如何使用从我们的 Android 应用调色板中的组件创建图像和按钮。然而,在游戏中,这个概念略有不同。在游戏中,我们与一个称为画布的东西一起工作,它用于在表面上绘制图像。为了给你一个基本的概念,表面是任何在其上持有像素的东西。基本上,表面持有你的画布,然后将其映射到视图上。所有的图像操作都是基于这个。因此,为了在游戏中绘制任何东西,我们将使用SurfaceView组件。
处理颜色和图像
现在我们已经学习了这些概念,并且对在 Android Studio 上的工作有了了解,我们可以开始从头开始制作我们的游戏。让我们清除activity_fullscreen.xml文件中的所有按钮和文本视图,并从我们的 Java 代码中移除所有引用,使其看起来大致如下:

看看你调色板下面的组件树窗口,以获得适当的参考:

注意,此时我们回到了应用的原点。
完成这些后,只需运行并测试你的应用一次,检查是否有任何错误;如果没有,则继续进行。现在我们将看看如何使用十六进制颜色代码创建基本颜色,然后继续创建背景以及其他组件的图像。
创建颜色
这相当简单,我们也在上一章中看到了这一点。只需前往位于app/res/values/文件夹中的colors.xml文件。在这里,你可以观察到多个十六进制颜色代码值。十六进制颜色代码代表一个六位数的字母数字值,负责赋予颜色。值从#000000(黑色)到#ffffff(白色)。
在我们的colors.xml中,如果你观察,可以看到已经预定义了值,如下面的截图所示:

十六进制颜色代码值
在这个文件中,你可以调整这些值,或者简单地添加你自己的值。比如说,如果你想添加红色,你只需将以下行添加到这段代码中,以获取红色值:
<color name="red">#ff0000</color>
如果您仔细观察语法,您会注意到这个十六进制代码的格式为RRGGBB,这意味着前两个字母数字组成红色成分,第二个两个数字对应绿色,最后两个数字对应蓝色。此外,使用名称字段,您可以在任何组件中使用这种颜色,就像我们在背景中所做的那样。现在,尝试调整colorPrimary中的某些值,看看会发生什么变化。
此外,请注意,在十六进制颜色代码中,我们只能使用 0-9 的数字和 a-f 的字符。因此,如果您使用类似于#99z9pt 的东西,那么它不会产生任何颜色或数字输出。尝试这样做,以便您对这种颜色代码概念有更清晰的理解。
这就是 Android Studio 中关于颜色的全部内容。您可以使用它们在背景、表面等地方使用您对 XML 文件的理解,就像我们在前面的章节中看到的那样,或者您也可以从 Java 代码中程序化地设置它们。现在,让我们继续到最有趣的部分,即处理图像。
创建图像
我们已经查看了一个 Android 项目的项目结构。因此,到现在我们已经清楚代码文件和资源放置的位置的区别。图像是资源,因此它们可以放在 Android Studio 项目的res文件夹或 AssetManager 中,但更具体地说,我们不能仅仅将它们放在res文件夹中。
按照惯例,有一个专门用于图像资源的特定文件夹,即drawable文件夹。您可以在app/res/drawable中找到此文件夹。如果您看不到它,则可以通过在res文件夹上右键单击并选择“新建 | Android 资源目录”,然后在“资源类型”中选择drawable来手动创建它。如果您的文件夹中已经存在drawable文件夹,则无需这样做:

创建 drawable 文件夹
此文件夹包含项目中所有图像资源,包括资源,如背景图片、图标和精灵图集。为了本游戏的目的,我们将所有图像资源放入drawable文件夹。现在我们将创建一个背景图片,在下一章中,我们将学习如何在上面的图片上放置另一张图片。完成这些后,我们将在背景上创建一个新的OnClickListener,以便更改图片。因此,我们需要三个图像资源。让我们称它们为以下:
-
background_image -
image_1 -
image_2
对于本章,我们已选取以下库存图片。您可以根据自己的需要自由选择任何图片。
background_image:以下是我们游戏将使用的背景图片:

image_1:以下是我们玩家的一个帧:

image_2:以下是我们角色的另一个图像帧:

因此,我们将在background_image的上方放置image_1。当我们点击image_1时,它将转换为image_2。让我们获取一些库存图片资源并将它们放入我们的res/drawable文件夹中。为此,只需在你想放入drawable文件夹的任何图片上右键单击并选择复制:

然后,在 drawable 文件夹上右键单击并选择粘贴:

然后,你会弹出一个对话框。点击“确定”以成功将你的图片资源导入到你的project文件夹中。
现在我们已经放置了图像资源,是时候将它们显示在我们的屏幕上了。你可以立即通过获取一个 ImageView 组件并将其属性设置为你的期望图片来实现这一点,但由于我们正在制作游戏,我们将采用Canvas 方式。为此,首先我们需要替换我们FullscreenActivity.java中的全部 Java 代码,使其看起来像这样:
package nikhil.nikmlnkr.game;
import android.os.Bundle;
import android.view.Window;
import android.view.WindowManager;
import android.app.Activity;
public class FullscreenActivity extends Activity{
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//Set our game to full screen mode
getWindow().setFlags
(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
//Set no title on screen
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(new GameView(this));
}
}
你需要在现有代码中做的所有更改都已用粗体标出。
注意这里我们已经消除了所有切换状态栏的功能,只保留了我们的onCreate方法。我们还用getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);手动将游戏设置为全屏模式。我们还用requestWindowFeature(Window.FEATURE_NO_TITLE);代码消除了标题屏幕窗口。现在,注意这里你会在你的GameView(this)代码上得到一个错误。这是因为我们还没有创建我们的GameView类。所以,让我们先创建这个类,但在那之前,让我们在我们的清单文件中做一个小的修改。
打开位于app/manifests/文件夹中的AndroidManifest.xml文件。在你的<activity>代码中,添加方向为横屏,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="nikhil.nikmlnkr.game">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:screenOrientation="landscape"
android:name=".FullscreenActivity"
android:configChanges="orientation
|keyboardHidden|screenSize"
android:label="@string/app_name"
android:theme="@style/FullscreenTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name=
"android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
这将明确告诉应用程序我们的游戏处于横屏模式。
现在,让我们继续并创建我们的GameView类。要做到这一点,只需在你的app/java/packagename文件夹上右键单击并选择新建 | Java 类,如下所示:

在这样做之后,将打开一个新窗口,询问你想要创建的新类的详细信息。只需在名称文本框中输入GameView并按 OK 继续,如图所示:

我们需要再创建两个类来设置我们的侧滑背景图片,所以重复同样的步骤,创建以下两个类:
-
BackgroundImage -
MainGameThread
因此,现在你的项目中总共有四个类:
-
BackgroundImage -
FullscreenActivity -
GameView -
MainGameThread
我们的目的是在屏幕上有一个背景图片,它会连续滚动通过我们的视图。让我们首先打开我们的BackgroundImage.java文件,并在其中写入以下代码:
package nikhil.nikmlnkr.game;
import android.graphics.Bitmap;
import android.graphics.Canvas;
public class BackgroundImage {
private int xc, yc, dxc;
private Bitmap backgroundImage;
public BackgroundImage(Bitmap res)
{
backgroundImage = res;
}
public void setVector(int dxc)
{
this.dxc = dxc;
}
public void update()
{
xc += dxc;
if(xc < -GameView.WIDTH){
xc=0;
}
}
public void draw(Canvas canvas)
{
canvas.drawBitmap(backgroundImage, xc, yc,null);
if(xc < 0)
{
canvas.drawBitmap
(backgroundImage, xc + GameView.WIDTH, yc, null);
}
}
}
让我们尝试理解这段代码。我们在这里简单地创建了一个类,以下是类中每个代码块的逐步分解:
-
我们导入了 Bitmap 和 Canvas,这是进行图像和画布操作所需的。
-
我们声明了私有变量
xc、yc和dxc,它们分别是x、y坐标和x方向的位移。这些变量的默认值将是零,因为我们还没有初始化它们。 -
然后,我们声明了一个私有的 Bitmap 变量,它将保存要显示在屏幕上的实际图像文件。
-
接下来,我们为我们的类创建了一个构造函数,这样我们就可以通过 res 引用将其传递进去,并在构造函数中,我们将这个引用与步骤 3 中声明的背景图像变量相等。
-
我们创建了一个向量位移方法,以便将单位向量添加到图像的位置值中,使其移动。
-
然后,我们使用了每次都会被调用的更新方法,并将所有的位移和重置逻辑放入其中。如果图像超出我们的屏幕,我们就将位置重置为 0,以产生连续移动的效果。
-
现在,如果你还记得,我们在开始时讨论过,为了在屏幕上绘制任何东西,你需要一个画布。因此,使用 draw 方法,我们将画布作为一个引用变量,并将我们的绘制逻辑包含在其中。注意,我们在这里绘制了两次背景图像。这是因为如果我们的图像持续滚动,中间将会有一个空白区域,它将显示为黑色,所以我们使用相同的图像,并在屏幕上稍微远离主图像的位置绘制两次,以产生连续循环的效果。为了理解这个活生生的例子,你可以尝试移除
canvas.drawBitmap()代码中的任何一个,自己看看效果。
这就是BackgroundImage.java文件的内容。到这时,你可能在你的GameView.WIDTH代码上遇到一些错误。不要担心,我们稍后会解决它。在解决这个错误之前,我们必须首先设置我们的游戏线程,因为所有的更新函数都将从我们的线程中调用。我们的MainGameThread.java文件的目标如下:
-
对更新函数进行连续调用
-
以每秒帧数为基础进行性能评估
因此,打开你的MainGameThread.java文件,并输入以下内容:
package nikhil.nikmlnkr.game;
import android.graphics.Canvas;
import android.view.SurfaceHolder;
public class MainGameThread extends Thread
{
private int framesPerSecond = 30;
private double averageFPS;
private SurfaceHolder surfaceHolder;
private GameView gameView;
private boolean running;
public static Canvas canvas;
public MainGameThread(SurfaceHolder surfaceHolder, GameView
gameView){
super();
this.surfaceHolder = surfaceHolder;
this.gameView = gameView;
}
public void setRunning(boolean b){
running=b;
}
@Override
public void run(){
long startTime;
long timeMillis;
long waitTime;
long totalTime = 0;
int frameCount =0;
long targetTime = 1000/framesPerSecond;
while(running) {
startTime = System.nanoTime();
canvas = null;
//try locking the canvas for pixel editing
try {
canvas = this.surfaceHolder.lockCanvas();
synchronized (surfaceHolder) {
this.gameView.update();
this.gameView.draw(canvas);
}
} catch (Exception e) {
}
finally{
if(canvas!=null)
{
try {
surfaceHolder.unlockCanvasAndPost(canvas);
}
catch(Exception e){e.printStackTrace();}
}
}
timeMillis = (System.nanoTime() - startTime) / 1000000;
waitTime = targetTime-timeMillis;
try{
this.sleep(waitTime);
}catch(Exception e){}
totalTime += System.nanoTime()-startTime;
frameCount++;
if(frameCount == framesPerSecond){
averageFPS = 1000/((totalTime/frameCount)/1000000);
frameCount = 0;
totalTime = 0;
System.out.println(averageFPS);
}
}
}
}
在我们的MainGameThread.java文件中,我们做了以下操作:
-
我们创建了运行线程所需的所有变量。
-
我们为
MainGameThread文件创建了一个构造函数,通过传入SurfaceHolder和GameView引用,并将它们的引用设置为this文件。 -
我们创建了一个方法来跟踪我们的运行线程,它有一个布尔值返回值。
-
我们重写了线程的默认
run方法,使其执行我们需要的特定操作:-
我们使用 surfaceholder 变量来操作图像的像素数据。
-
我们计算每秒的帧数。
-
我们计算每秒的平均帧数,并在控制台视图中显示它。
-
通过这样做,我们现在已经准备好了我们的线程类,最后只剩下我们的GameView类。在我们的GameView中,我们将实际上把我们在屏幕上构建的所有图像数据放上去并显示出来。所以,让我们打开GameView.java文件并开始输入以下内容:
package nikhil.nikmlnkr.game;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
public class GameView extends SurfaceView implements SurfaceHolder.Callback {
public static final int WIDTH = 1920;
public static final int HEIGHT = 1080;
private MainGameThread mainThread;
private BackgroundImage bgImg;
public GameView(Context context){
super(context);
//set callback to the surfaceholder to track events
getHolder().addCallback(this);
mainThread = new MainGameThread(getHolder(), this);
//make gamePanel focusable so it can handle events
setFocusable(true);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format,
int width, int height){}
@Override
public void surfaceDestroyed(SurfaceHolder holder){
boolean retry = true;
while(retry){
try{
mainThread.setRunning(false);
mainThread.join();
}catch(InterruptedException e){e.printStackTrace();}
retry = false;
}
}
@Override
public void surfaceCreated(SurfaceHolder holder){
bgImg = new BackgroundImage(BitmapFactory.decodeResource
(getResources(), R.drawable.background_image));
bgImg.setVector(-5);
//we can safely start the game loop
mainThread.setRunning(true);
mainThread.start();
}
@Override
public boolean onTouchEvent(MotionEvent event){
return super.onTouchEvent(event);
}
public void update(){
bgImg.update();
}
@Override
public void draw(Canvas canvas){
final float scaleFactorX = getWidth()/WIDTH;
final float scaleFactorY = getHeight()/HEIGHT;
if(canvas != null) {
final int savedState = canvas.save();
canvas.scale(scaleFactorX, scaleFactorY);
bgImg.draw(canvas);
canvas.restoreToCount(savedState);
}
}
}
这段代码很容易理解。我们的GameView是我们创建 Surface 并在此之上绘制一切的地方。因此,我们扩展了SurfaceView并实现了我们的回调,SurfaceHolder。这给了我们访问一些预写方法的权限,我们将覆盖这些方法。在你理解这些方法之前,让我们先尝试理解这段代码背后的逻辑。它可以分为以下几部分:
-
我们为
GameView类创建默认构造函数,然后调用start我们的MainGameThread。 -
使用预定义的方法,我们覆盖它们并在我们的 Surface 之上创建我们的 Canvas。
-
我们在
BackgroundImage类中调用更新函数。 -
我们动态地设置图像的缩放以匹配手机的分辨率。
现在我们知道了逻辑,我们就可以逐个阅读这些方法并理解它们。在我们的代码中,我们有以下方法:
-
surfaceChanged:我们创建了一个带有参数的空方法。如果我们的 Surface 发生变化,那么这个方法就会被调用 -
surfaceDestroyed:如果 Surface 被销毁,这个方法就会被调用 -
surfaceCreated:在我们的 Surface 创建后,我们可以开始游戏循环;这就是我们初始化背景图像并设置其资源的地方,正如你在代码中所看到的,bgImg = newBackgroundImage(BitmapFactory.decodeResource(getResources(), R.drawable.background_image)); -
onTouchEvent:每当屏幕上有触摸时,这个方法就会被调用 -
update:这个方法是更新方法,在这里,我们调用BackgroundImage的update方法 -
draw:这个方法调用将我们的图像绘制到屏幕上,我们进行一些计算以缩放图像并将其适当地设置到手机上,以符合其分辨率
在你完成这个文件后,别忘了检查你的FullscreenActivity.java文件,并确保其代码看起来像这样:
package nikhil.nikmlnkr.game;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.app.Activity;
public class FullscreenActivity extends Activity{
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//Set our game to full screen mode
getWindow().setFlags
(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
//Set no title on screen
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(new GameView(this));
}
}
现在建议你为这个游戏构建一个 apk 并在你的设备上测试它,因为如果你在模拟器上尝试运行它,它会运行得非常慢。
在你的设备/模拟器上构建并执行它,你将看到如下输出,其中你的背景图像会持续滚动:

就这样!你的侧边滚动背景已经启动并运行。
摘要
恭喜!你已经成功学习了如何在 Android Studio 中创建图像并处理颜色。你还在游戏中实现了侧边滚动背景,这将是游戏后续部分的基础。
在下一章中,我们将创建我们的玩家角色,并在我们的图像对象上实现点击监听器,同时学习如何使用我们刚刚学到的精灵表的概念来动画化对象。
第五章:为你的游戏添加动画
我们已经学习了如何在屏幕上创建精灵以及使我们的背景图像连续滚动。精灵基本上只是图像,用于游戏资源。现在,是时候给它添加一些更多的风味,使其变得有趣。在本章中,我们将使我们的玩家角色出现在屏幕上,位于我们的背景之上,并添加一个使玩家跑步的动画。在本章中,你将学习以下内容:
-
基于精灵图集创建精灵动画
-
运行基本动画
-
创建一个抽象类作为我们未来游戏对象的基础
到本章结束时,你将在屏幕上看到一个正在运行的字符。那么,让我们开始吧!
添加动画让你的游戏更加精彩
为了开始本章,我们需要一组图像,我们称之为精灵。我们在上一章中看到了如何使用 image_1 和 image_2 作为例子,但我们将在此基础上进行扩展,使其更有趣。我们将尝试在精灵图集中制作我们玩家角色的正确运行周期。为了更好地理解这一点,让我们看看以下图像:

我们的运行周期动画精灵
在前面的图像中,你可以观察到我们有一组以文本 Run 开头,后面跟着一组代表帧数的数字的图像。所以,基本上,我们将从帧 Run_000 循环到 Run_009,这将给我们一个连续的运行效果。然而,为了简化,我们将把这些帧合并成一张单独的图像,并从中读取像素数据。此外,为了保持简单,我们只会制作三个帧的运行周期动画。如果你想的话,你可以添加更多帧以获得更好的动画质量。因此,我们将使用以下精灵图集:

这就是实际的精灵图集的样子;我们将把这个精灵图集命名为 player_run.png
我们将处理像素以运行我们的动画,因此了解这张图像的尺寸对我们来说至关重要。简单来说,我们需要知道这张图像的宽度和高度(以像素为单位)。我们图像的一帧的尺寸是 200 x 82 像素,其中 200 代表图像的宽度,82 代表图像的高度。在我们的精灵图集中有三个这样的精灵。因此,我们只需将宽度乘以我们的精灵图集中的图像数量,在这个例子中是三个。所以,我们图像的总宽度是 600 像素。正如你所见,我们使用的是具有风景方向的图像,这意味着我们的图像宽度大于高度,所以我们的高度维度保持不变。所以,基本上,为了运行我们的动画,我们只需要水平扫描我们的帧。我们的精灵图集最终分辨率是 200 x 82。
你的图像分辨率可能与本书中的不同,所以在继续编写代码之前,请确保正确地计算出这些数字。
因此,让我们深入到动画我们的玩家角色的艺术中;然而,在我们这样做之前,我们还需要在屏幕上创建我们的玩家角色。以下是我们的当前任务列表:
-
在屏幕上创建一个玩家。
-
让运行动画播放。
完成这两个目标后,我们将获取触摸输入以便让我们的玩家跳跃。
创建我们的玩家角色
在上一章中,我们看到了如何创建我们的背景图像。理论上,创建我们的玩家角色与上一章几乎相同,但由于我们将在本章的后续部分处理动画,我们需要对我们的代码进行一些修改。让我们首先开始做这件事。
现在,作为一个程序员,你必须记住,做一件特定的事情有无数种方法;因此,为了更清晰地展示,我们将稍微修改我们的背景图像代码。打开你的BackgroundImage.java文件,并删除setVector方法。现在,你将看到我们的GameView.java文件中会有一个错误,因为我们的setVector方法不存在;让我们修复它。创建一个静态最终变量,它可以从任何类中访问。然后,我们将将其设置为速度变量。然后,在我们的BackgroundImage.java文件的构造函数中,我们将设置位移变量为这个速度。以下是我们将如何修改我们的代码块。代码更改用粗体标出。
以下是为BackgroundImage.java编写的代码:
package nikhil.nikmlnkr.game;
import android.graphics.Bitmap;
import android.graphics.Canvas;
public class BackgroundImage {
private int xc, yc, dxc;
private Bitmap backgroundImage;
public BackgroundImage(Bitmap res){
backgroundImage = res;
dxc = GameView.MOVINGSPEED;
}
public void update(){
xc += dxc;
if(xc < -GameView.WIDTH){
xc=0;
}
}
public void draw(Canvas canvas){
canvas.drawBitmap(backgroundImage, xc, yc,null);
if(xc < 0){
canvas.drawBitmap(backgroundImage, xc + GameView.WIDTH, yc, null);
}
}
}
接下来,让我们看看GameView.java的代码:
package nikhil.nikmlnkr.game;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
public class GameView extends SurfaceView implements SurfaceHolder.Callback {
public static final int WIDTH = 1920;
public static final int HEIGHT = 1080;
public static final int MOVINGSPEED = -5; private MainGameThread mainThread;
private BackgroundImage bgImg;
public GameView(Context context){
super(context);
//set callback to the surfaceholder to track events
getHolder().addCallback(this);
mainThread = new MainGameThread(getHolder(), this);
//make gamePanel focusable so it can handle events
setFocusable(true);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format,
int width, int height){}
@Override
public void surfaceDestroyed(SurfaceHolder holder){
boolean retry = true;
while(retry){
try{
mainThread.setRunning(false);
mainThread.join();
}catch(InterruptedException e){e.printStackTrace();
}
retry = false;
}
}
@Override
public void surfaceCreated(SurfaceHolder holder){
bgImg = new BackgroundImage(BitmapFactory.decodeResource
(getResources(), R.drawable.background_image));
//we can safely start the game loop
mainThread.setRunning(true);
mainThread.start();
}
@Override
public boolean onTouchEvent(MotionEvent event){
return super.onTouchEvent(event);
}
public void update(){
bgImg.update();
}
@Override
public void draw(Canvas canvas){
final float scaleFactorX = getWidth()/WIDTH;
final float scaleFactorY = getHeight()/HEIGHT;
if(canvas != null) {
final int savedState = canvas.save();
canvas.scale(scaleFactorX, scaleFactorY);
bgImg.draw(canvas);
canvas.restoreToCount(savedState);
}
}
}
现在,我们在这里实际上并没有做任何事情。我们只是用不同的变量修改了一个逻辑。以类似的方式,你也可以应用你自己的逻辑来想出不同的方法来做一件特定的事情。现在,让我们真正地让我们的玩家角色出现在屏幕上。
尝试使用你自己的逻辑调整代码,并保持输出与挑战相同,以测试你的技能。
我们现在将为我们的未来游戏对象创建一个抽象类,例如我们的玩家角色、导弹以及其他所有东西。我们这样做是因为有一些数据集,几乎是我们将在未来为我们的游戏创建的每个对象中都需要。因此,为了重用和持久性,我们将创建这个类。我们将称之为GameObj.java。按照我们之前章节中学到的步骤创建你的新类,并在其中编写以下代码:
package nikhil.nikmlnkr.game;
import android.graphics.Rect;
/**
* Created by Nikhil on 13-01-2017.
*/
public abstract class GameObj {
protected int xc, yc, dxc, dyc;
//Our x and y coordinates along with their displacement variables
protected int width, height;
//width and height of our objects
public int getXC() {
return xc;
}
public int getYC() {
return yc;
}
public void setXC(int xc) {
this.xc = xc;
}
public void setYC(int yc) {
this.yc = yc;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public Rect getRectangle() {
return new Rect(xc, yc, xc + width, yc + height);
}
}
因此,我们现在有了我们的抽象类GameObj.java文件,其中包含了它们的变量和获取设置方法。注意这里我们还有一个getRectangle()方法。这个方法将在下一章中使用,当我们处理碰撞时。理论上,为了检测任何对象的碰撞,我们需要获取它的矩形边界。无论如何,让我们继续前进,现在让我们利用这个新创建的抽象类来创建我们的Player类。
创建一个新的类,并将其命名为PlayerCharacter.java,然后在其中编写以下代码:
package nikhil.nikmlnkr.game;
import android.graphics.Bitmap;
import android.graphics.Canvas;
/**
* Created by Nikhil on 13-01-2017.
*/
public class PlayerCharacter extends GameObj{
private Bitmap spriteSheet;
private int score;
private double dya;
private boolean up, playing;
private AnimationClass ac = new AnimationClass();
private long startTime;
public PlayerCharacter(Bitmap res, int w, int h, int noOfFrames) {
xc = 100;
yc = GameView.HEIGHT/2;
dyc = 0;
score = 0;
height = h;
width = w;
Bitmap[] img = new Bitmap[noOfFrames];
spriteSheet = res;
for(int i=0; i < img.length;i++){
img[i] = Bitmap.createBitmap(spriteSheet, i*width, 0, width, height);
}
ac.setFrames(img);
ac.setDelay(10);
startTime = System.nanoTime();
}
public void setUp(boolean b){
up = b;
}
public void update() {
long elapsed = (System.nanoTime()-startTime)/1000000;
if(elapsed > 100) {
score++;
startTime = System.nanoTime();
}
ac.update();
}
public void draw(Canvas canvas) {
canvas.drawBitmap(ac.getImage(), xc, yc, null);
}
public int getScore() {
return score;
}
public boolean getPlaying(){
return playing;
}
public void setPlaying(boolean b) {
playing = b;
}
public void resetDYA() {
dya = 0;
}
public void resetScore () {
score = 0;
}
}
让我们现在更详细地了解我们在 PlayerCharacter 代码中做了些什么:
-
我们创建了一个类,并将其扩展到我们的抽象类
GameObj,以获取我们PlayerCharacter所需的所有默认变量 -
我们为我们的
PlayerCharacter创建了默认构造函数,其中我们传递了所需的数据来在屏幕上绘制PlayerCharacter,就像图像组件一样,通过res变量传递,以及我们的图像的宽度和高度,以及我们动画中需要的帧数 -
在我们的构造函数中,我们创建了一个
for循环,它会遍历我们的精灵图,并给我们动画效果,之后会有一个 10 毫秒的延迟来播放我们的动画 -
然后,我们创建了我们的
setUp()函数,该函数将处理玩家的跳跃功能 -
在我们的
update()函数中,我们创建了一个事件循环,它将分数分配给玩家,并保持玩家在上限和下限之间 -
此后,我们创建了
draw()方法,该方法从我们的ac变量获取动画,并将其绘制到我们的画布上 -
然后是其他变量的简单获取和设置方法
我们的角色玩家已经准备好了;然而,我们仍然需要编写我们的 AnimationClass。正如你可以清楚地观察到的,你必须在 AnimationClass 行上得到一个错误。所以,让我们继续创建我们的 AnimationClass.java 文件,并在其中写入以下代码:
package nikhil.nikmlnkr.game;
import android.graphics.Bitmap;
/**
* Created by Nikhil on 13-01-2017.
*/
public class AnimationClass {
private Bitmap[] frames;
private int currentFrame;
private long startTime, delay;
private boolean playedOnce;
public void setFrames(Bitmap[] frames){
this.frames = frames;
currentFrame = 0;
startTime = System.nanoTime();
}
public void setDelay(long d){
delay = d;
}
public void setFrame(int i) {
currentFrame = i;
}
public void update() {
long elapsed = (System.nanoTime()-startTime)/1000000;
if(elapsed > delay) {
currentFrame++;
startTime = System.nanoTime();
}
if(currentFrame == frames.length) {
currentFrame = 0;
playedOnce = true;
}
}
public Bitmap getImage(){
return frames[currentFrame];
}
public int getFrame(){
return currentFrame;
}
public boolean playedOnce() {
return playedOnce;
}
}
这个 AnimationClass.java 文件对于运行我们角色的动画非常重要。我们的 AnimationClass 有三个主要功能,即 setFrames()、update() 和 getImage()。让我们看看在这个类中我们做了些什么:
-
我们创建了访问我们帧所需的变量
-
我们创建了控制动画帧开始时间和延迟的变量
-
我们创建了一个布尔值,用于触发动画
-
接下来,我们创建了一个
setFrames()函数,用于设置帧并将当前帧在开始时设置为0 -
之后,我们创建了一个
setDelay()函数,以便告诉动画在短间隔内运行 -
我们随后创建了一个
setFrame()函数来设置正在运行的动画中的当前帧 -
在我们的
update()函数中,我们开始使用一些简单的数学计算来计算延迟(以毫秒为单位),并在某些间隔内切换图像的帧 -
我们添加了一个条件,如果我们的最后一帧已经过去,那么我们将当前帧重置为 0,以给出连续循环的效果
-
在我们的
getImage()函数中,我们简单地返回当前正在屏幕上绘制的图像的值 -
getFrame()函数返回当前正在显示的帧的整数值 -
使用最后一个函数
playedOnce()作为我们未来游戏玩法的一个占位符
setFrames() 和 setFrame() 是两个不同的函数。setFrames() 用于从 0 初始化我们的帧,而 setFrame() 用于在运行时单独设置帧。
现在,在继续下一部分之前,确保你已经在你的 drawable 文件夹中有你想要用于玩家角色的图像。一旦你有了它,现在就是继续下一步并实例化屏幕上的玩家的时候了。这个过程与我们的背景图像相同。将你的 GameView.java 文件代码修改如下;与前一段代码相比,更改已被加粗显示:
package nikhil.nikmlnkr.game;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
public class GameView extends SurfaceView implements SurfaceHolder.Callback {
public static final int WIDTH = 1920;
public static final int HEIGHT = 1080;
public static final int MOVINGSPEED = -5;
private MainGameThread mainThread;
private BackgroundImage bgImg;
private PlayerCharacter playerCharacter;
public GameView(Context context) {
super(context);
//set callback to the surfaceholder to track events
getHolder().addCallback(this);
mainThread = new MainGameThread(getHolder(), this);
//make gamePanel focusable so it can handle events
setFocusable(true);
}
@Override
public void surfaceChanged
(SurfaceHolder holder, int format, int width, int height){}
@Override
public void surfaceDestroyed(SurfaceHolder holder){
boolean retry = true;
while(retry)
{
try{mainThread.setRunning(false);
mainThread.join();
}catch(InterruptedException e){e.printStackTrace();}
retry = false;
}
}
@Override
public void surfaceCreated(SurfaceHolder holder){
bgImg = new
BackgroundImage(BitmapFactory.decodeResource
(getResources(),
R.drawable.background_image));
Drawable d = getResources().getDrawable
(R.drawable.player_run);
int w = d.getIntrinsicWidth();
int h = d.getIntrinsicHeight();
playerCharacter = new PlayerCharacter
(BitmapFactory.decodeResource
(getResources(),R.drawable.player_run),w/3,h,3);
//we can safely start the game loop
mainThread.setRunning(true);
mainThread.start();
}
@Override
public boolean onTouchEvent(MotionEvent event){
return super.onTouchEvent(event);
}
public void update(){
bgImg.update();
playerCharacter.update();
}
@Override
public void draw(Canvas canvas){
final float scaleFactorX = getWidth()/WIDTH;
final float scaleFactorY = getHeight()/HEIGHT;
if(canvas!=null) {
final int savedState = canvas.save();
canvas.scale(scaleFactorX, scaleFactorY);
bgImg.draw(canvas);
playerCharacter.draw(canvas);
canvas.restoreToCount(savedState);
}
}
}
让我们分析一下我们在这里做了什么:
-
我们使用变量
playerCharacter为我们的PlayerCharacter创建了一个引用变量。 -
然后,在我们的
surfaceCreated()方法中,我们向playerCharacter类构造函数传递了它所需的所有值。我们将player_run图像传递到这段代码中,以便它获取我们的玩家精灵表。在PlayerCharacter(BitmapFactory.decodeResource(getResources(),R.drawable.player_run),w/3,h,3);构造函数的参数中,'w' 对应图像的宽度,'h' 对应图像的高度。这里的参数 3 将取决于你的精灵帧数。如果你有六个帧在你的精灵中,那么参数将是(w/6,h,6)。 -
之后,在我们的
update()方法中,该方法位于bgImg.update()之后,我们调用了playerCharacter.update()方法,该方法调用我们的PlayerCharacter的更新函数,从而从AnimationClass.java文件中播放动画。 -
最后,我们使用
playerCharacter.draw(canvas);代码在画布上绘制了我们的玩家角色。
完成这些步骤后,在模拟器或你的 Android 手机设备上构建并运行你的游戏;你将得到以下输出:

哈哈!我们的玩家角色现在在屏幕上奔跑(字面意思)
如果你一切操作正确,那么你的玩家角色将立即在屏幕上开始奔跑,而你的背景将持续滚动;几乎我们的一半游戏工作已经完成。
由于我们创建了 GameObj 类,我们将大量依赖它来创建我们的进一步游戏对象,例如导弹、粒子效果等。
恭喜!你刚刚创建了你第一个动画!
摘要
在本章中,你学习了如何有效地从你的精灵表中创建动画,以及创建一个抽象类来为你的未来游戏对象打下基础。在下一章中,我们将学习如何通过基于触摸输入来控制动画,使我们的游戏更加有趣。我们将学习碰撞检测,并创建一个从屏幕最右侧生成的导弹 AI,我们的目标将是躲避导弹。
我们将学习不同的碰撞技术以及创建交互式对象,在收集它们时我们也会添加分数,而不仅仅是随着时间的推移增加分数。
第六章:碰撞检测和基本人工智能
你已经学到了如何在我们的游戏中播放动画的概述,现在我们将更进一步地进入这个激动人心的游戏开发之旅,通过学习制作游戏感觉真实所需的最复杂但至关重要的概念之一。通过添加动画,我们可以使我们的游戏看起来更真实,但游戏感觉真实也同样重要,因为这是使其有趣的原因。在本章中,我们将更详细地探讨以下两个概念:
-
碰撞检测
-
人工智能
因此,本章将分为两个主要部分。碰撞检测和人工智能的研究本身是非常广泛的课题。为了简化以服务于我们的目的,我们将查看这些主题的最基本版本,以便获得入门级理解,以便能够在我们的游戏中应用这些概念的知识。所以,无需多言,让我们深入这个复杂但令人兴奋的碰撞检测主题。
碰撞检测
简单来说,碰撞是两个物体之间短暂相互作用。有各种不同类型的碰撞,如弹性碰撞和非弹性碰撞。研究两个或多个物体重叠的交集称为碰撞检测。它是计算数学中最复杂的部分之一,并分为许多类型,例如:
-
边界框碰撞:这是一种最简单的碰撞技术形式,其中我们取两个矩形并检查它们是否重叠。为此,我们需要每个矩形的四个坐标,即 x 和 y 位置以及两个矩形的宽度和高度。
-
圆形碰撞:这是第二种最简单的碰撞类型,其中我们测试两个圆之间的碰撞。在这里,我们考虑两个圆的半径和圆心的 x 和 y 位置来测试重叠。
-
分离轴定理:这种碰撞比前面提到的两种要复杂一些,主要是因为它主要用于检测两个多边形之间的碰撞。
当然,还有许多其他类型的碰撞,但这本身就是一个庞大的概念。为了本书的目的,我们将处理更简单的碰撞形式,并根据对这些概念的理解,你可以进一步实验更复杂的碰撞类型。
现在,让我们看看你所学的三种碰撞类型的算法。
这些算法只是伪代码。伪意味着虚假,这意味着这些代码不应被执行。它们只是在这里提到,以便理解不同的碰撞技术。
在我们继续我们的游戏项目之前,让我们逐一查看它们。
碰撞检测技术算法
我们非常需要为理解这些碰撞技术打下基础,因此了解这些技术是如何工作的非常重要。让我们来看看检测碰撞的不同算法。在我们本章进一步处理碰撞时,我们将使用这些概念。由于我们在这本书中只处理简单类型,我们将只查看边界框和圆形碰撞检测技术的算法。
边界框碰撞
在我们前面的解释中,我们看到边界框碰撞技术是最简单的之一。这是因为我们只是在两个矩形之间进行测试。考虑以下伪代码以更好地理解:
rectangle1 = {x: 5, y: 5, width: 50, height: 50}
rectangle2 = {x: 20, y: 10, width: 10, height: 10}
if(rectangle1.x < rectangle2.x + rectangle2.width && rectangle1.width > rectangle2.x && rectangle1.y < rectangle2.y + rectangle2.height && rectangle1.height + rectangle1.y > rectangle2.y)
{
//Bounding Box Collision Detected
}
// Taking the values from our variables
if (5 < 30 && 55 > 20 && 5 < 20 && 55 > 10) {
// Bounding Box Collision Detected!
}
如您从前面的代码中可以看到,通过这种技术检测碰撞所需的数学运算相当简单。我们只处理基本的 x、y 坐标以及 width 和 height。
圆形碰撞
另一种简单的碰撞类型,它涉及绘制两个圆心之间的距离以检测碰撞。其算法如下:
circle1 = {radius: 20, x: 5, y: 5};
circle2 = {radius: 12, x: 10, y: 5};
dx = circle1.x - circle2.x;
dy = circle1.y - circle2.y;
distance = Math.sqrt(dx * dx + dy * dy);
if (distance < circle1.radius + circle2.radius) {
// Circle Collision!
}
如您在此处所观察到的,我们有两个圆。然后我们取它们各自的 x 和 y 坐标距离。之后,我们取它们平方和的平方根。这就是计算两个圆之间距离的简单公式。然后在我们的测试条件下,我们检查这个距离是否小于两个圆的和。
现在我们对碰撞检测的算法有了基本的了解,我们就可以继续在游戏中检测碰撞了。让我们开始吧!
在我们的游戏中检测碰撞
由于我们在这里处理的是一个游戏,我们将以实际应用的方式处理理论。因此,让我们通过一个玩家面前接近的岩石的例子来研究碰撞技术。我们的游戏是一个横版滚动游戏,因此将有多个障碍物和可收集物品朝我们走来。通过简单的边界框碰撞技术,我们可以检测玩家与其他对象之间的碰撞并执行相应的函数。
在我们创建即将到来的岩石之后,我们还将创建即将到来的硬币,以便玩家也能得分。然而,在我们继续进行之前,我们必须想出一种让我们的玩家避开这些障碍的方法。我们将实现碰撞检测,如果玩家与岩石相撞,他将死亡。因此,玩家跳跃是至关重要的。我们将在触摸输入上赋予玩家跳跃能力。所以,让我们让我们的玩家跳跃吧!
让我们的玩家跳跃
由于在接下来的章节中,我们将在屏幕上随机生成障碍物和金币,我们将修改我们的玩家,使其能够在屏幕上上下移动。所以,如果你触摸屏幕,玩家将上升;如果你停止触摸屏幕,我们的玩家将下降。让我们首先看看我们需要在代码中实现的变化,然后一步一步地分解它们。我们将为这段代码部分在GameView.java文件上工作。
最初,我们的动画是在游戏开始时立即开始的。但我们需要对我们的动作有更多的控制,所以我们使用了getPlaying()函数来实现这一点。以下是我们将如何解决这个问题:
我们在update()方法中添加了一个条件,即只有当玩家正在玩游戏时,我们才会更新背景图像和玩家角色。这意味着如果没有信号,游戏将不会开始:
public void update(){
if(playerCharacter.getPlaying()) { bgImg.update(); playerCharacter.update(); }
}
我们现在将使用onTouchEvent()来通知我们的游戏已经开始,以及何时上升和下降。在我们的onTouchEvent()的第一个条件中,我们将检查屏幕上是否有触摸事件。ACTION_DOWN表示屏幕被触摸。
在这里,如果被阻塞,我们将有一个另一个 if 块,它检查玩家是否正在玩游戏。如果玩家没有在玩游戏,那么我们将setPlaying()函数设置为true值,从而启动游戏循环,然后由于步骤 1 中的条件,它将启动update()方法。否则,它将简单地告诉玩家屏幕被触摸,因此我们的up布尔变量被设置为true,这意味着玩家上升。
然后,我们将编写return true语句,它负责通知触摸事件,因为我们的onTouchEvent()有一个布尔返回类型。
接下来,我们定义玩家下移的条件。在我们的例子中,这仅仅意味着不再接收到触摸输入,这意味着我们的手指已经从屏幕上抬起。ACTION_UP定义了这个事件。
如果发生此事件,则将我们的up变量设置为false,因此玩家将下降。
我们将为事件再次编写一个return true语句。以下是我们的onTouchEvent()代码将看起来像:
@Override
public boolean onTouchEvent(MotionEvent event)
{
if(event.getAction() == MotionEvent.ACTION_DOWN) {
if(!playerCharacter.getPlaying()){
playerCharacter.setPlaying(true);
} else {
playerCharacter.setUp(true);
}
return true;
}
if(event.getAction() == MotionEvent.ACTION_UP){
playerCharacter.setUp(false);
return true;
} return super.onTouchEvent(event);
}
从这次编辑中,我们只定义了设置玩家上下功能所需的调用。然而,我们还需要为我们的玩家添加加速度和减速度,以便实际上能够上下移动。我们将通过编辑我们的PlayerCharacter.java文件来实现这一点。让我们在这个文件中编写一些跳跃代码。打开它,并在update()方法中写下以下加粗的代码:
public void update() {
long elapsed = (System.nanoTime()-startTime)/1000000;
if(elapsed > 100) {
score++;
startTime = System.nanoTime();
}
ac.update();
if(up){
dyc = (int)(dya-=1.1);
}
else {
dyc = (int)(dya+=1.1);
}
if(dyc > 10) {
dyc = 10;
}
if(dyc < -10) {
dyc = -10;
}
yc += dyc*2;
dyc = 0;
}
这段代码块非常容易理解。让我们分解它,以便更好地掌握:
-
我们检查我们的
up变量是否为真。 -
如果它是
true,那么我们将添加一个正加速度值,该值在每次更新时增加 1.1。你可以根据你的喜好调整这些值。 -
如果它是
false,那么我们将在每次更新中添加一个负加速度值,该值会减少 1.1。 -
在此之后,我们不想加速度或减速度超过某个限制,所以我们将其限制在某个范围内。在我们的例子中,我们将其限制在最大 10 和最小 -10。
-
我们将加速度值的两倍添加到
y坐标上。 -
我们将减速度设置为 0,以便在下一个
update()调用中重置它。
你可以调整加速度值来自行测试并熟悉期望的速度。要更改加速度的速度,只需更改 dyc 中的值。
这将使我们的玩家高兴地跳跃!构建你的游戏并在你的设备或模拟器上测试它。你将在这里观察到两件事:
-
当你开始游戏时,游戏会暂停,并且只有在触摸屏幕时才会播放。这是因为我们的
GameView.java文件中的 if 条件。 -
由于我们在
PlayerCharacter.java文件中提供的加速度值,你的玩家现在可以上下移动。
现在我们已经建立了这个,是时候处理迎面而来的障碍物并避开它们了!哦,顺便说一下,还要了解碰撞过程。
避免迎面而来的石头
在我们继续进行任何操作之前,我们首先需要一块看起来不错的石头。不是开玩笑,真的!我们需要一块看起来卡通的石头图像,这将符合我们游戏的主题。所以,让我们为自己找一块石头。 为了本章的目的,我们将使用这块石头:

这是我们基本的石头
正如我们在前面的部分中所做的那样,我们需要为石头创建动画,因此我们将创建我们拥有的石头的角色表。然后,我们只需将其命名为 rock.png 并开始使用它。 有一个滚动的石头会更好,因为这在游戏中更有意义,所以我们将使用 滚动的石头角色表。

我们的 rock.png 角色表
当然,正如我们之前所看到的,这块石头也只是一个图像,并且会像之前一样放置在 res/drawable 文件夹中。所以,拿起任何一块石头并将其放入 res/drawable 文件夹以开始。
现在,由于我们将处理一个新的对象,所以到这一点为止,必须非常清楚我们需要创建一个新的类。所以,让我们继续并创建一个新的类,命名为 Rock.java。这将包含我们障碍石头的所有代码。然后,你将有一个空白 java 文件,你需要将其扩展到我们的 GameObj.java 文件,就像我们游戏中的其他任何游戏对象一样:
package nikhil.nikmlnkr.game;
/**
* Created by Nikhil on 30-01-2017.
*/
public class Rock extends GameObj {
}
现在,准备好!我们将对这个进行一些重头戏的编码。让我们一步一步地看看。在这个文件中,我们需要三个方法:
-
构造函数:这将包含我们石头的参数,例如x、y位置、宽度、高度、得分等等 -
Update方法:正如之前所见,这是在每一帧都会被调用的方法Draw方法:这个方法负责在屏幕上实际绘制我们的对象
让我们从构造函数和变量开始。我们需要一个score和speed变量,以及一个用于Bitmap和预定义的AnimationClass的变量,因为我们还将对这个石头进行动画处理。让我们开始吧。我们首先声明游戏所需的变量,包括score、speed、我们的animationClass引用和spriteSheet引用。我们还取了一个Random数字引用变量,用于根据游戏循环中即将看到的唯一条件生成分数。目前,我们没有在屏幕上显示分数,但很快你将看到它:
package nikhil.nikmlnkr.game;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import java.util.Random;
/**
* Created by Nikhil on 30-01-2017.
*/
public class Rock extends GameObj{
private int score;
private int speed;
private Random rnd = new Random ();
private AnimationClass animationClass = new AnimationClass();
private Bitmap spriteSheet;
}
现在,我们将编写我们的构造函数。构造函数需要一系列参数,如x、y坐标、width、height、score和noOfFrames。我们基本上将它们的引用作为参数传递给我们的方法,因此我们需要在构造函数中提供xc、yc、w、h、s和noOfFrames作为参数。
之后,我们需要我们的石头在生存时间更长后跑得更快,因此我们将编写代码,使得随着分数的增加,我们的石头会变得更快。我们将使用一点数学知识来完成这个任务,其中我们将使用我们的随机变量。基本上,这将是我们公式:speed = 7 + (int) (rnd.nextDouble()*score/30);。然后我们将设置我们的Bitmap引用变量,以便它可以扫描我们的精灵图集,随后通过一个for循环扫描相同的图集。最后,我们将设置我们的帧到animationClass,并为动画添加延迟。让我们看看这在我们的代码中是如何工作的:
public Rock (Bitmap res, int xc, int yc, int w, int h, int s, int noOfFrames) {
this.xc = xc;
this.yc = yc;
width = w;
height = h;
score = s;
speed = 7 + (int) (rnd.nextDouble()*score/30);
if(speed > 35)
speed = 35;
Bitmap[] img = new Bitmap[noOfFrames];
spriteSheet = res;
for(int i=0; i<img.length; i++) {
img[i] = Bitmap.createBitmap(spriteSheet, 0, i*height,
width, height);
}
animationClass.setFrames(img);
animationClass.setDelay(100);
}
在这部分代码中,我们没有做任何新的工作,只是创建了一个石头游戏对象及其在屏幕上的绘制方法。这与我们为PlayerCharacter所做的是同一件事。
现在我们已经准备好了构造函数,我们可以继续编写update()和draw()方法的代码。在我们的update()方法中,我们不需要做太多。我们只需将我们的石头从右向左移动,因此我们将使用我们的speed变量,在每一帧中将石头向左移动一定的单位。我们将使用draw()方法,借助animationClass引用变量,在屏幕上简单地绘制我们的石头:
public void update() {
xc -= speed;
animationClass.update();
}
public void draw(Canvas canvas) {
try {
canvas.drawBitmap(animationClass.getImage(), xc, yc, null);
} catch (Exception e) {}
}
如前述代码所示,我们简单地创建了一个update()方法,并使用speed变量将石头向左移动一定的单位。之后,我们立即调用了animationClass的update()方法,以便更新石头的精灵图集,并产生一种石头滚动移动的效果。
我们随后简单地使用了drawBitmap()方法在屏幕上绘制我们的石头。
我们创建了我们的对象,现在是我们将其显示在屏幕上的时间,一旦我们完成了这个目标,我们将继续检测石头与玩家碰撞时的情况。
现在,我们将创建岩石在GameView.java文件中,这将以连续间隔生成岩石。同时,当我们的玩家与岩石发生碰撞时,游戏将暂停。所以,让我们打开我们的GameView.java文件,开始编写这段代码。
我们将创建三个新的引用变量,rockStartTime、rocks和rnd,分别对应岩石游戏对象的起始时间、实际岩石游戏对象以及用于随机化屏幕上岩石生成位置的随机变量。由于我们将在屏幕上生成多个岩石,我们将它的数据类型设置为ArrayList,因为动态数组可以通过ArrayList来支持。我们将主要需要这个,因为当我们的岩石离开屏幕空间后,我们将移除它们,从而实现资源的适当内存管理:
private long rockStartTime; private ArrayList<Rock> rocks;
private Random rnd = new Random();
我们将对我们的surfaceDestroyed()方法进行一些改进,并创建一个counter以及调整我们的retry变量块,以避免无限循环的情况。代码更改用粗体标出:
@Override
public void surfaceDestroyed(SurfaceHolder holder){
boolean retry = true;
int counter = 0; while(retry && counter <1000)
{
counter++;
try {mainThread.setRunning(false);
mainThread.join();
retry = false;
} catch(InterruptedException e){e.printStackTrace();}
}
}
在我们的surfaceCreated()方法中,我们将我们的引用变量rocks赋值为Rock类,并将rockStartTime变量初始化为当前System.nanoTime(),如下所示:
@Override
public void surfaceCreated(SurfaceHolder holder){
bgImg = new BackgroundImage(BitmapFactory.decodeResource
(getResources(), R.drawable.background_image));
playerCharacter = new
PlayerCharacter(BitmapFactory.decodeResource
(getResources(),R.drawable.player_run),200,246,3);
rocks = new ArrayList<Rock>();
rockStartTime = System.nanoTime();
//we can safely start the game loop
mainThread.setRunning(true);
mainThread.start();
}
在我们的更新方法中,我们处理屏幕上生成岩石的“真正难点”。这里有多个事情需要管理,所以让我们进一步分解它们。
我们将声明我们的rockElapsed变量,它跟踪岩石在屏幕上停留的时间。
然后,我们定义我们希望在屏幕上生成岩石的频率。您可以根据所需的效应在if条件中随意操作这个值。
在这个if块内部,我们还有一个嵌套的if块,其主要目的是定义岩石的生成位置。在这里,我们定义岩石的生成方式,使得第一个岩石元素位于屏幕的中间部分,正如您可以从数学计算中看到的那样。或者,如果它不是第一个生成的岩石元素,那么我们告诉它随机生成在任何位置,只要我们的rnd随机变量出现。
我们首先定义我们的碰撞逻辑。在这里,我们将使用一个for循环来遍历屏幕上的所有岩石对象,如果任何元素与我们的玩家角色发生碰撞,那么我们将暂停游戏。
最后,如果岩石超出我们定义的屏幕空间,那么我们就从我们的ArrayList中remove该对象。
让我们为这个写代码:
public void update(){
if(playerCharacter.getPlaying()) {
bgImg.update();
playerCharacter.update();
//spawn rocks on screen
long rockElapsed = (System.nanoTime() -
rockStartTime/1000000);
if(rockElapsed>(2000 - playerCharacter.getScore()/4)){
if(rocks.size() == 0){
rocks.add(new Rock(BitmapFactory.decodeResource
(getResources(), R.drawable.rock),
WIDTH+10, HEIGHT/2, 66, 82,
playerCharacter.getScore(),3));
} else {
rocks.add(new Rock(BitmapFactory.decodeResource
(getResources(), R.drawable.rock),
WIDTH+10, (int)
(rnd.nextDouble() * (HEIGHT)), 66, 82,
playerCharacter.getScore(),3));
}
rockStartTime = System.nanoTime();
}
for(int i=0; i<rocks.size();i++) {
rocks.get(i).update();
if(collision(rocks.get(i),playerCharacter)) {
rocks.remove(i);
playerCharacter.setPlaying(false);
break;
}
//remove rocks if they go out of the screen
if(rocks.get(i).getXC()<-100) {
rocks.remove(i);
break;
}
}
}
}
我们接下来编写我们的碰撞检测函数。我们这样做是基于你在本章前面学到的边界框碰撞技术。你可以很容易地从这段代码中观察到,Rect.intersects(a.getRectangle(), b.getRectangle()),我们只是使用预定义在我们的android.graphics.Rect导入中的Rect类来比较我们两个对象的矩形。如果存在重叠的矩形,那么这个函数返回一个true值;否则,它返回false。这个函数的返回类型是boolean,因此它返回一个boolean值是很重要的:
public boolean collision(GameObj a, GameObj b) {
if(Rect.intersects(a.getRectangle(), b.getRectangle())) {
return true;
}
return false;
}
然后最后,我们在屏幕上绘制我们的岩石对象。我们再次使用for循环和我们的draw()方法来完成:
@Override
public void draw(Canvas canvas)
{
final float scaleFactorX = getWidth()/WIDTH;
final float scaleFactorY = getHeight()/HEIGHT;
if(canvas!=null) {
final int savedState = canvas.save();
canvas.scale(scaleFactorX, scaleFactorY);
bgImg.draw(canvas);
playerCharacter.draw(canvas);
for(Rock r : rocks) {
r.draw(canvas);
}
canvas.restoreToCount(savedState);
}
}
这将使我们的游戏具备碰撞检测技术。编写完这段代码后,我们的碰撞技术在游戏中的工作方式应该非常明显。我们创建了一个返回类型为Boolean的函数,该函数将检测我们传递给它的两个对象的矩形之间的碰撞。
让我们快速回顾一下我们的代码块,逐一列出每个代码块在GameView.java文件中的具体职责,以获得更好的清晰度。
定义我们的变量
我们通过添加我们想要的变量,如rockStartTime、rocks和rnd来定义所需的变量:
public static final int WIDTH = 1920;
public static final int HEIGHT = 1080;
public static final int MOVINGSPEED = -5;
private long rockStartTime;
private MainGameThread mainThread;
private BackgroundImage bgImg;
private PlayerCharacter playerCharacter;
private ArrayList<Rock> rocks;
private Random rnd = new Random();
这些变量为我们提供了可以工作的对象引用。
解决无限循环问题
在我们前面的代码中,有可能遇到无限循环的情况,所以我们替换了重试并添加了一个计数器,以在surfaceDestroyed()方法中无限循环情况发生时提供额外的安全性。我们的重试可能在每次运行时返回一个false值或一个true值,因此有可能出现无限循环的情况。为了避免这种情况,我们有一个计数器,每次 while 循环运行时都会增加,并在之后停止。你可以自己尝试一下,看看如果不使用计数器会发生什么问题:
@Override
public void surfaceDestroyed(SurfaceHolder holder){
boolean retry = true;
int counter = 0;
while(retry && counter <1000)
{
counter++;
try{mainThread.setRunning(false);
mainThread.join();
retry = false;
}catch(InterruptedException e){e.printStackTrace();}
}
}
这解决了我们的无限循环问题。
初始化我们的变量
在定义变量之后,初始化它们也是至关重要的。我们从surfaceCreated()方法中这样做,如下所示:
@Override
public void surfaceCreated(SurfaceHolder holder){
bgImg = new BackgroundImage(BitmapFactory.decodeResource
(getResources(), R.drawable.background_image));
playerCharacter = new PlayerCharacter(BitmapFactory.decodeResource
(getResources(),R.drawable.player_run),200,246,3);
rocks = new ArrayList<Rock>();
rockStartTime = System.nanoTime();
//we can safely start the game loop
mainThread.setRunning(true);
mainThread.start();
}
这样就处理好了我们的变量值。
碰撞行为
在此之后,我们定义了如果两个对象之间发生碰撞时会发生什么,这定义在我们的update()方法中。我们还处理了岩石飞出屏幕的情况:
long rockElapsed = (System.nanoTime() - rockStartTime/1000000);
if(rockElapsed>(2000 - playerCharacter.getScore())){
if(rocks.size() == 0){
rocks.add(new Rock(BitmapFactory.decodeResource
(getResources(), R.drawable.rock),
WIDTH+10, HEIGHT/2, 66, 82,
playerCharacter.getScore(),3));
} else {
rocks.add(new Rock(BitmapFactory.decodeResource
(getResources(), R.drawable.rock),
WIDTH+10, (int) (rnd.nextDouble() * (HEIGHT)), 66, 82,
playerCharacter.getScore(),3));
}
rockStartTime = System.nanoTime();
}
for(int i=0; i<rocks.size();i++) {
rocks.get(i).update();
if(collision(rocks.get(i),playerCharacter)) {
rocks.remove(i);
playerCharacter.setPlaying(false);
break;
}
//remove rocks if they go out of the screen
if(rocks.get(i).getXC()<-100) {
rocks.remove(i);
break;
}
}
我们现在已经准备好了碰撞行为。
碰撞函数
我们定义了碰撞行为,但编写一个定义碰撞本身的逻辑也是必要的。因此,我们创建了一个碰撞检测函数:
public boolean collision(GameObj a, GameObj b) {
if(Rect.intersects(a.getRectangle(), b.getRectangle())) {
return true;
}
return false;
}
我们的碰撞函数现在已经准备好了。
在屏幕上绘制我们的对象
一旦我们完成,我们只需在我们的draw()方法的if块中绘制我们的对象即可:
for(Rock r : rocks) {
r.draw(canvas);
}
这样就处理好了检测碰撞所需的所有功能。
现在我们已经完成了碰撞逻辑,让我们对我们的玩家角色做一些调整。那里有一些未使用的变量,所以让我们去掉它们,并进一步定制它。在此之前,它们是了解事物工作原理所必需的。但现在它们对我们来说几乎毫无用处,所以没有必要让它们随意存在。打开你的PlayerCharacter.java文件,对你的代码进行以下更改:
package nikhil.nikmlnkr.game;
import android.graphics.Bitmap;
import android.graphics.Canvas;
/**
* Created by Nikhil on 13-01-2017.
*/
public class PlayerCharacter extends GameObj{
private Bitmap spriteSheet;
private int score;
private boolean up, playing;
private AnimationClass ac = new AnimationClass();
private long startTime;
public PlayerCharacter(Bitmap res, int w, int h, int noOfFrames) {
xc = 100;
yc = GameView.HEIGHT/2;
//removing the dya variable dyc = 0;
score = 0;
height = h;
width = w;
Bitmap[] img = new Bitmap[noOfFrames];
spriteSheet = res;
for(int i=0; i<img.length;i++){
img[i] = Bitmap.createBitmap(spriteSheet, i*width, 0,
width, height);
}
ac.setFrames(img);
ac.setDelay(10);
startTime = System.nanoTime();
}
public void setUp(boolean b){
up = b;
}
public void update() {
long elapsed = (System.nanoTime()-startTime)/1000000;
if(elapsed > 100) {
score++;
startTime = System.nanoTime();
}
ac.update();
if(up){
dyc -=1;
}
else {
dyc +=1;
}
if(dyc > 10) {
dyc = 10;
}
if(dyc < -10) {
dyc = -10;
}
yc += dyc*2;
//removing the dya variable
}
public void draw(Canvas canvas) {
canvas.drawBitmap(ac.getImage(), xc, yc, null);
}
public int getScore() {
return score;
}
public boolean getPlaying(){
return playing;
}
public void setPlaying(boolean b) {
playing = b;
}
public void resetDYC() {
dyc = 0;
}
public void resetScore () {
score = 0;
}
}
这些只是微小的调整,相当直观,不需要任何单独的解释。现在我们已经建立了所有这些,我们最终可以测试我们的碰撞技术了!所以,让我们继续前进,构建你的游戏,并在你的设备或模拟器上测试它。你将看到类似这样的东西:

哈喽!我们的岩石现在出现在屏幕上了……但是等等,发生了什么?
所以,好消息是我们的岩石终于出现在屏幕上了,坏消息是它们遍布整个屏幕。我们将很快解决这个问题,但现在我们可以测试我们的碰撞功能。一旦我们的岩石与玩家角色碰撞,游戏将暂停。现在我们需要调整它们的频率,以便控制它们的生成。
打开你的GameView.java文件,对以下加粗的部分进行更改。删除所有加粗的注释代码,并添加以下if(rocks.size() < 2)语句:
public class GameView extends SurfaceView implements SurfaceHolder.Callback
{
public static final int WIDTH = 1920;
public static final int HEIGHT = 1080;
public static final int MOVINGSPEED = -5;
//private long rockStartTime; private MainGameThread mainThread;
private BackgroundImage bgImg;
private PlayerCharacter playerCharacter;
private ArrayList<Rock> rocks;
private Random rnd = new Random();
//Constructor, surfaceDestroyed and surfceChanged methods remain
same
@Override
public void surfaceCreated(SurfaceHolder holder){
bgImg = new BackgroundImage(BitmapFactory.decodeResource
(getResources(), R.drawable.background_image));
playerCharacter = new
PlayerCharacter(BitmapFactory.decodeResource
(getResources(),R.drawable.player_run),200,246,3);
rocks = new ArrayList<Rock>();
//rockStartTime = System.nanoTime();
//we can safely start the game loop
mainThread.setRunning(true);
mainThread.start();
}
//onTouchEvent remains same
public void update()
{
if(playerCharacter.getPlaying()) {
bgImg.update();
playerCharacter.update();
//spawn rocks on screen
//long rockElapsed = (System.nanoTime() -
rockStartTime/1000000);
//if(rockElapsed>(2000 - playerCharacter.getScore())){
if(rocks.size() < 2){
if(rocks.size() == 0){
rocks.add(new Rock(BitmapFactory.decodeResource
(getResources(), R.drawable.rock),
WIDTH+10, HEIGHT/2, 120, 82,
playerCharacter.getScore(),3));
} else {
rocks.add(new Rock(BitmapFactory.decodeResource
(getResources(), R.drawable.rock),
WIDTH+10, (int) (rnd.nextDouble() * (HEIGHT)),
120, 82, playerCharacter.getScore(),3));
}
rockStartTime = System.nanoTime();
} //Bracket ends here
for(int i=0; i<rocks.size();i++) {
rocks.get(i).update();
if(collision(rocks.get(i),playerCharacter)) {
rocks.remove(i);
playerCharacter.setPlaying(false);
break;
}
//remove rocks if they go out of the screen
if(rocks.get(i).getXC()<-100) {
rocks.remove(i);
break;
}
}
}
}
public boolean collision(GameObj a, GameObj b) {
if(Rect.intersects(a.getRectangle(), b.getRectangle())) {
return true;
}
return false;
}
@Override
public void draw(Canvas canvas)
{
//same as the draw method seen earlier
}
}
我们只是注释掉了rockElapsed和rockStartTime变量,并用rocks.size()条件替换了我们的if条件,这个条件告诉游戏在任何给定时间内只生成2块岩石。这样,我们就控制了屏幕上生成的岩石频率。你可以根据需要调整你的频率:

好吧,这看起来比之前的好多了
当然,还有一个问题,那就是岩石的出现;这取决于你用来创建精灵动画的图像。但到目前为止,我们将继续进行下一步。此外,请注意,以类似的方式,你也可以创建可收集物品,如硬币,因此建议你创建一个硬币类,玩家可以收集并添加到他的分数中。继续前进,进行实验!
现在让我们来了解我们的下一个概念,那就是人工智能。由于我们游戏中不会使用任何人工智能,所以我们只需浏览一下这个主题的概念。我们当前的游戏是一个简单的横向卷轴游戏,不需要任何敌人。因此,让我们开始学习人工智能,或者像许多人所说的,AI。当然,如果你有在游戏中添加敌人的想法,那么你总是可以使用从前面章节中学到的概念,并将它们与你即将学习的概念结合起来,创造出一些酷炫的东西!
人工智能
人工智能(A.I.)是研究能够执行人类任务和自动化,而不需要人类参与的系统的学科。在游戏中,这个概念被广泛用于为游戏敌人创建真实的行为。让我们在本章中了解人工智能的基本概念。这部分内容将是理论性的,所以如果你只想专注于准备你的游戏,可以自由跳过这部分。然而,强烈建议你阅读这部分内容,因为如果你想在游戏中创建人工智能,这部分的概念将会非常有用。
人工智能的历史
在游戏中,人工智能被用来为用户无法控制的对象创建智能行为。这可以是屏幕上看到的龙,或者简单地是一个始终跟随你的角色。简单来说,它是一种为看似无生命的物体提供类似人类智能的手段。在技术层面上,人工智能是研究算法的学科,这些算法包括来自机器人学、计算机图形学、计算机科学和控制理论的技巧。有许多创建逼真人工智能的算法。
自从游戏行业诞生以来,游戏中的人工智能研究一直是这个行业的一部分,因为游戏提供了在无生命物体中模拟人工智能行为的一种最佳方式。然而,如果你回顾到 20 世纪 50 年代,你会观察到,与今天相比,人工智能是一个相对简单的概念。1951 年制作的 Nim 游戏是人工智能的第一个例子:

1951 年的 Nim 游戏
这款游戏是一个简单的井字棋游戏,它展示了在对抗计算机时人工智能的第一个例子。1974 年,在街机游戏中首次出现了几个引人注目的例子:Taito 游戏《速度竞赛》(一款赛车视频游戏)、Atari 游戏《Qwak》(一款鸭狩猎光枪射击游戏)和《Pursuit》(一款战斗机空战模拟器)。1972 年的两款基于文本的计算机游戏《Hunt the Wumpus》和《Star Trek》也拥有敌人。敌人的移动基于存储的模式。微处理器的引入使得更多的计算和随机元素可以叠加到移动模式中。让我们来谈谈一些有趣的 AI 算法。
人工智能算法
人工智能的算法可以包括许多方面。本质上,将这些分解成简单术语,它们只是搜索算法。以下是一些最流行的搜索算法,这些算法可以应用于人工智能逻辑。在中间,有一些伪代码用于理解这些示例。一旦你了解了这些,我们就可以为我们的游戏制定一些基本的 AI 逻辑,如果你愿意从你的角度加入的话。
广度优先搜索
这个算法从根节点开始,然后首先探索所有相邻节点,然后移动到下一层的相邻节点,这返回了到解决方案的最短路径。它使用先进先出(FIFO)队列数据结构。
这个算法的缺点是它消耗了大量的内存,因为为了创建下一个节点,必须保存每个层级的节点。
在这里了解更多:en.wikipedia.org/wiki/Breadth-first_search
深度优先搜索
该算法的实现使用递归和后进先出(LIFO)的栈数据结构,它创建与我们的第一种方法相同的节点,但顺序不同。由于每个迭代从根节点到叶节点存储节点,因此空间需求相当线性。
该算法的缺点是,有可能这个算法可能不会终止,并且在一个路径上无限期地继续,在某些情况下执行时间会增加。它不能检查重复节点。
在这里了解更多:en.wikipedia.org/wiki/Depth-first_search
双向搜索
在这个技术中,搜索从初始状态开始,并从目标状态反向进行,直到两者都满足以识别共同状态,然后从初始状态到目标状态的路径被连接起来。
在这里了解更多:en.wikipedia.org/wiki/Bidirectional_search
一致成本搜索
在这个算法中,排序是通过增加节点到路径的成本来进行的,并且具有最低成本的节点被扩展。它也普遍被称为迪杰斯特拉算法。
这个算法的缺点是,由于可能有多个长路径,这种技术必须探索它们所有。
在这里了解更多:en.wikipedia.org/wiki/Dijkstra%27s_algorithm
迭代加深深度优先搜索
执行到第 1 层的深度优先搜索,然后对第 2 层执行相同的操作,依此类推,直到找到解决方案。直到所有较低层节点生成,节点才不会被创建。
在这里了解更多:en.wikipedia.org/wiki/Iterative_deepening_depth-first_search
前述算法复杂度比较
让我们通过比较前五种算法来查看一些有趣的结果。以下是算法基于各种标准的性能:
| 标准 | 广度优先 | 深度优先 | 双向 | 一致成本 | 加深 |
|---|---|---|---|---|---|
| 时间 | b^d | b^m | b^(d/2) | b^d | b^d |
| 空间 | b^d | b^m | b^(d/2) | b^d | b^d |
| 优化性 | 是 | 否 | 是 | 是 | 是 |
| 完全性 | 是 | 否 | 是 | 是 | 是 |
*搜索
这个算法最著名的是最佳优先搜索算法,并且在游戏中用于路径查找方面也广泛使用。它的性能非常高效,通过这个算法避免了扩展已经昂贵的路径。
f(n) = g(n) + h(n),其中g(n)是到达目标节点的成本(到目前为止),h(n)是从节点到目标估计的成本,而f(n)是通过n到目标的路径估计总成本。它是通过增加f(n)来使用优先队列实现的。
更多信息请参阅:en.wikipedia.org/wiki/A*_search_algorithm
创建你自己的人工智能
现在,关于这个游戏,我们实际上并不需要人工智能,因为我们的大多数障碍本身对我们来说就是一个巨大的挑战。然而,我们可以有一些思考的食物。这里有一个练习给你。利用你在前几章中获得的知识,尝试创建一个用于生成敌人的 AI 类,如果它进入一定的半径,它将开始向你射击弹丸。以下是创建 AI 的目标:
-
从屏幕右侧生成敌人
-
让它们向左手方向移动
-
进入一定半径后,它们将开始向你射击弹丸
-
如果你与弹丸相撞,游戏将暂停
你可以使用你的 Rock.java 类作为射击弹丸。你可以有一个猴子从屏幕的侧面进入,如果它进入一定的半径,它将开始向你扔石头。自己试试看;看到你有什么想法会很有趣!
所以,这本书关于碰撞和人工智能的部分就到这里了。
摘要
在本章中,你学习了大量的各种碰撞技术以及多个人工智能算法。我们现在知道如何根据边界框碰撞技术创建碰撞。
在下一章中,我们将为我们的玩家添加地面,并了解我们如何根据我们的碰撞创建爆炸。
第七章:添加边界和使用精灵创建爆炸
在我们前面的章节中,我们介绍了成功检测碰撞的部分。现在我们已经了解了如何处理碰撞,我们可以利用我们的知识来为游戏添加一些酷炫的功能。本章将会非常简洁,如果你正确理解了碰撞检测的概念,那么对你来说这将是一个轻松的任务。以下是本章我们将要完成的内容:
-
为玩家添加一个作为边界的地面
-
检测玩家与岩石之间的碰撞
-
在碰撞发生的位置生成一个爆炸精灵
注意,从前面的任务中,我们已经完成了第二个任务,所以我们必须专注于第一个和第三个任务。让我们深入创建玩家的地面,因为现在玩家正在无限地下落。我们还将添加一个上边界,以将玩家限制在屏幕内,否则玩家会直接穿过屏幕。再次,我们将本章分为两部分:
-
将边界添加到我们的游戏中
-
使用精灵创建爆炸
让我们开始吧!
添加边界
由于我们非常熟悉创建新类的流程,我们将简单地创建两个新类,分别用于上边界和下边界,并分别命名为 UpperBoundary.java 和 LowerBoundary.java。我们对边界有以下目标:
-
让它们出现在游戏屏幕的顶部和底部
-
如果玩家与它们发生碰撞,则重置游戏
在这些目标指导下,我们将继续创建游戏边界。
创建边界类
在实际创建边界之前,我们需要一个图像精灵,以便在屏幕上显示它们。为此,我们将使用一个简单的单色精灵。以下是我们将用于游戏的精灵:

我们的 ground.png 文件
此外,我们将此文件放置在 res/drawable 文件夹中,就像我们之前处理图像文件一样。完成这些后,继续下一部分。
因此,现在让我们创建 UpperBoundary.java 类。继续前进,创建一个新的类,并在其中写入以下代码:
public class UpperBoundary extends GameObj {
private Bitmap img;
public UpperBoundary(Bitmap res, int xc, int yc, int h) {
height = h;
width = 20;
this.xc = xc;
this.yc = yc;
dxc = GameView.MOVINGSPEED;
img = Bitmap.createBitmap(res, 0, 0, width, height);
}
public void update(){
xc += dxc;
}
public void draw(Canvas canvas) {
try{
canvas.drawBitmap(img, xc, yc, null);
} catch(Exception e) {
};
}
}
在这段代码中,我们只是创建了一个边界类,包括其构造函数和 update() 以及 draw() 函数。这里的构造函数非常简单易懂。每次我们创建边界的一个实例时,我们将传递一个精灵、x y 位置和高度。我们还会设置一个速度,使边界向后移动,给人一种玩家正在向前移动的错觉。
就像我们之前所有的游戏对象一样,这个类也扩展了我们的主 GameObj 类。以类似的方式,我们也将创建 LowerBoundary.java 类。我们将做的唯一改变是在 height 和 width 变量上,其余的整个代码与 UpperBoundary.java 文件相同:
public class LowerBoundary extends GameObj {
public LowerBoundary(Bitmap res, int xc, int yc) {
height = 200;
width = 20;
}
}
注意,我们保持height为常数,因为我们希望将底部边界生成得尽可能低,因此200是一个安全的限制。你可以根据自己的喜好实验这些值。
现在我们已经准备好了我们的类,是时候将它们引入我们的游戏了。
在我们的游戏中创建边界
我们将一步一步地进行,因为这部分可能会有些棘手。我们还需要处理很多关于边界的数学计算,以及大量的新变量。让我们从在GameView.java文件中声明一些变量开始。
在这部分,我们只会在我们的GameView.java文件中工作。
创建所需的变量
下面是我们将要声明的新的变量:
private ArrayList<UpperBoundary> upperBoundary;
private ArrayList<LowerBoundary> lowerBoundary;
private int maxBoundaryHeight;
private int minBoundaryHeight;
private boolean upBound = true;
private boolean lowBound = true;
private int progressDenom = 20;
private boolean newGameCreated;
我们创建upperBoundary和lowerBoundary变量作为ArrayList,以跟踪屏幕上的实际游戏对象,然后我们还创建了两个整数变量maxBoundaryHeight和minBoundaryHeight,以跟踪上边界的最大和最小高度。我们还创建了两个布尔变量upBound和lowBound,如果我们的边界超出指定的最小或最大高度。progressDenom变量被创建为一个整数,以便为地面创建一个酷炫的模式,而不仅仅是平面。最后,我们有一个newGameCreated布尔变量,如果我们的玩家与任何对象相撞,它将自动重置游戏。
我们已经设置了变量。现在,我们将继续在游戏开始时引用我们的边界。
引用我们的边界
正如我们在岩石中分配了一个值一样,我们也会为我们的边界做同样的事情。我们将在surfaceCreated()方法中这样做,通过添加以下标记的变量:
@Override
public void surfaceCreated(SurfaceHolder holder){
upperBoundary = new ArrayList<UpperBoundary>();
lowerBoundary = new ArrayList<LowerBoundary>();
}
看起来很整洁!现在到了棘手的部分。我们需要为我们的两个边界编写更新逻辑。不要将这个与我们的单个边界的update()方法混淆。那个更新方法将简单地使地面向后移动。我们还需要实际编写在屏幕上生成它们的逻辑。让我们看看如何做到这一点。
更新我们的边界
我们将在每 50 分和每 40 分时更新我们的上边界和下边界。让我们为我们的边界编写代码。这涉及到很多复杂的数学计算,所以要注意。然而,与此相反,这里的每一步都很直观。以下是我们的边界的基本逻辑:
-
每 50 分或 40 分更新一次
-
将我们的图像添加到屏幕上
-
每一帧之后,调用边界类中的
update()方法 -
如果边界超出屏幕,则将其移除
-
如果任何一个边界超过了其最大值或最小值,则相应地将其
upBound或lowBound变量设置为true或false,具体取决于其位置
这是我们的边界逻辑,并且对于我们的上边界和下边界都是重复的。这个代码块是在我们的 draw() 方法之后编写的。我们按照以下方式编写它们的代码:
public void updateUpperBound () {
if(playerCharacter.getScore() % 50 == 0){
upperBoundary.add(new
UpperBoundary(BitmapFactory.decodeResource
(getResources(), R.drawable.ground),
upperBoundary.get(upperBoundary.size()-1).
getXC() + 20, 0, (int)((rnd.nextDouble()*
(maxBoundaryHeight))+1)));
}
for(int i=0; i<upperBoundary.size();i++) {
upperBoundary.get(i).update();
if(upperBoundary.get(i).getXC() < -20){
upperBoundary.remove(i);
if(upperBoundary.get(upperBoundary.size()-1).
getHeight() >= maxBoundaryHeight) {
upBound = false;
}
if(upperBoundary.get(upperBoundary.size()-1).
getHeight() <= minBoundaryHeight) {
upBound = true;
}
if(upBound){
upperBoundary.add(new
UpperBoundary(BitmapFactory.decodeResource
(getResources(), R.drawable.ground),
upperBoundary.get(upperBoundary.size()-1).
getXC() + 20, 0,upperBoundary.get
(upperBoundary.size()-1).getHeight()+1));
} else {
upperBoundary.add(new
UpperBoundary(BitmapFactory.decodeResource
(getResources(), R.drawable.ground),
upperBoundary.get(upperBoundary.size()-1).
getXC() + 20, 0, upperBoundary.get
(upperBoundary.size()-1).getHeight()-1));
}
}
}
}
public void updateLowerBound () {
if(playerCharacter.getScore() % 40 == 0) {
lowerBoundary.add(new
LowerBoundary(BitmapFactory.decodeResource
(getResources(),R.drawable.ground),
lowerBoundary.get(lowerBoundary.size() - 1).
getXC() + 20,(int)((rnd.nextDouble()*
maxBoundaryHeight) + (HEIGHT - maxBoundaryHeight))));
}
for(int i=0;i<lowerBoundary.size();i++) {
lowerBoundary.get(i).update();
if(lowerBoundary.get(i).getXC()<-20){
lowerBoundary.remove(i);
if(lowerBoundary.get(lowerBoundary.size()-1).
getHeight() >= maxBoundaryHeight) {
lowBound = false;
}
if(lowerBoundary.get(lowerBoundary.size()-1).
getHeight() <= minBoundaryHeight) {
lowBound = true;
}
if(lowBound) {
lowerBoundary.add(new
LowerBoundary(BitmapFactory.decodeResource
(getResources(),R.drawable.ground),
lowerBoundary.get(lowerBoundary.size() - 1).
getXC() + 20, lowerBoundary.
get(lowerBoundary.size() - 1).getYC() + 1));
} else {
lowerBoundary.add(new
LowerBoundary(BitmapFactory.decodeResource
(getResources(),R.drawable.ground),
lowerBoundary.get(lowerBoundary.size() - 1).
getXC() + 20, lowerBoundary.
get(lowerBoundary.size() - 1).getYC() - 1));
}
}
}
}
现在,我们必须实际上在我们的屏幕上绘制我们的边界,所以我们将进入我们的 draw() 方法来完成这个任务。
在屏幕上绘制我们的边界
就像我们之前的图像一样,我们使用 draw() 方法来编写我们的代码,以便在屏幕上显示我们的地面:
for(UpperBoundary ub : upperBoundary){
ub.draw(canvas);
}
for(LowerBoundary lb: lowerBoundary) {
lb.draw(canvas);
}
即使这部分也已经处理好了。现在,我们必须查看我们地面的碰撞部分。我们需要检测玩家与地面之间的碰撞。
检测地面和玩家之间的碰撞
由于我们已经有了一个碰撞方法,我们只需继续使用该函数。由于前一章,我们已经清楚地理解了碰撞是如何工作的,所以我们将在我们的 GameView.java 文件的 update() 方法中编写以下代码:
for(int i=0; i<lowerBoundary.size();i++) {
if(collision(lowerBoundary.get(i),playerCharacter)) {
playerCharacter.setPlaying(false);
}
}
for(int i=0; i<upperBoundary.size();i++) {
if(collision(upperBoundary.get(i),playerCharacter)) {
playerCharacter.setPlaying(false);
}
}
玩家和地面之间的碰撞检测已完成。现在,我们还需要分配我们的最大和最小边界高度,并根据我们的 progressDenom 进行调整。
最大和最小边界高度
在我们的 update() 方法中,我们将根据玩家得分和 progressDenom 分配这些值。我们还将使用此方法调用我们在此章中较早创建的 updateUpperBound() 和 updateLowerBound() 方法:
this.updateUpperBound();
this.updateLowerBound();
maxBoundaryHeight = 30+playerCharacter.getScore() / progressDenom;
if(maxBoundaryHeight > HEIGHT/4)maxBoundaryHeight = HEIGHT/4;
minBoundaryHeight = 5 + playerCharacter.getScore()/progressDenom;
我们几乎完成了。现在,唯一剩下要创建的是我们的 newGame() 函数。
创建一个新的游戏
我们将创建一个 newGame() 函数,该函数将在玩家与对象碰撞时被调用。我们现在什么都没做,只是将对象重置为游戏开始时的初始状态。所以,我们这样做:
public void newGame () {
lowerBoundary.clear();
upperBoundary.clear();
rocks.clear();
minBoundaryHeight = 5;
maxBoundaryHeight = 30;
playerCharacter.resetScore();
playerCharacter.resetDYC();
playerCharacter.setYC(HEIGHT/2);
for(int i = 0; i * 20 < WIDTH + 40;i++) {
if(i == 0) {
upperBoundary.add(new
UpperBoundary(BitmapFactory.decodeResource
(getResources(),R.drawable.ground),
i * 20, 0, 10));
} else {
upperBoundary.add(new
UpperBoundary(BitmapFactory.decodeResource
(getResources(),R.drawable.ground),
i * 20, 0, upperBoundary.get(i - 1).getHeight() + 1));
}
}
for(int i = 0; i*20<WIDTH+40;i++) {
if(i==0) {
lowerBoundary.add(new
LowerBoundary(BitmapFactory.decodeResource
(getResources(),R.drawable.ground),
i * 20, HEIGHT - minBoundaryHeight));
} else {
lowerBoundary.add(new
LowerBoundary(BitmapFactory.decodeResource
(getResources(),R.drawable.ground),
i * 20, lowerBoundary.get(i - 1).getYC() - 1));
}
}
newGameCreated = true;
}
此外,我们仍然需要在某个地方调用这个函数。根据我们的目标,我们需要它在玩家崩溃后调用。所以,我们在 if(playerCharacter.getPlaying()) 条件之后在我们的更新函数中添加一个 else 块,如下所示:
else {
newGameCreated = false;
if(!newGameCreated) {
newGame();
}
}
我们的代码已经准备好了。让我们回顾一下代码中标记的变化,并检查你是否遗漏了任何步骤:
package nikhil.nikmlnkr.game;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import java.util.ArrayList;
import java.util.Random;
public class GameView extends SurfaceView implements SurfaceHolder.Callback
{
public static final int WIDTH = 1920;
public static final int HEIGHT = 1080;
public static final int MOVINGSPEED = -5;
private MainGameThread mainThread;
private BackgroundImage bgImg;
private PlayerCharacter playerCharacter;
private ArrayList<Rock> rocks;
//Our new variable names
private ArrayList<UpperBoundary> upperBoundary; private ArrayList<LowerBoundary> lowerBoundary;
private int maxBoundaryHeight; private int minBoundaryHeight;
private boolean upBound = true; private boolean lowBound = true;
private int progressDenom = 20;
private boolean newGameCreated;
private Random rnd = new Random();
public GameView(Context context){
super(context);
//set callback to the surfaceholder to track events
getHolder().addCallback(this);
mainThread = new MainGameThread(getHolder(), this);
//make gamePanel focusable so it can handle events
setFocusable(true);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height){}
@Override
public void surfaceDestroyed(SurfaceHolder holder){
boolean retry = true;
int counter = 0;
while(retry && counter <1000){
counter++;
try{
mainThread.setRunning(false);
mainThread.join();
retry = false;
}catch(InterruptedException e){e.printStackTrace();
}
}
}
@Override
public void surfaceCreated(SurfaceHolder holder){
bgImg = new
BackgroundImage(BitmapFactory.decodeResource
(getResources(), R.drawable.background_image));
playerCharacter = new
PlayerCharacter(BitmapFactory.decodeResource
(getResources(),R.drawable.player_run),200,246,3);
rocks = new ArrayList<Rock>();
//Referencing our upperBoundary and lowerBoundary variables upperBoundary = new ArrayList<UpperBoundary>(); lowerBoundary = new ArrayList<LowerBoundary>();
//we can safely start the game loop
mainThread.setRunning(true);
mainThread.start();
}
@Override
public boolean onTouchEvent(MotionEvent event){
if(event.getAction() == MotionEvent.ACTION_DOWN) {
if(!playerCharacter.getPlaying()){
playerCharacter.setPlaying(true);
playerCharacter.setUp(true); //minor change } else {
playerCharacter.setUp(true);
}
return true;
}
if(event.getAction() == MotionEvent.ACTION_UP){
playerCharacter.setUp(false);
return true;
}
return super.onTouchEvent(event);
}
public void update()
{
if(playerCharacter.getPlaying()) {
bgImg.update();
playerCharacter.update();
this.updateUpperBound(); this.updateLowerBound();
maxBoundaryHeight =
30 + playerCharacter.getScore() / progressDenom;
if(maxBoundaryHeight > HEIGHT/4)
maxBoundaryHeight = HEIGHT/4;
minBoundaryHeight =
5 + playerCharacter.getScore()/progressDenom;
for(int i=0; i<lowerBoundary.size();i++) { if(collision(lowerBoundary.get(i),
playerCharacter)) { playerCharacter.setPlaying(false); } }
for(int i=0; i<upperBoundary.size();i++) { if(collision(upperBoundary.get(i),
playerCharacter)) { playerCharacter.setPlaying(false); } }
//spawn rocks on screen
if(rocks.size() < 2){
if(rocks.size() == 0){
rocks.add(new
Rock(BitmapFactory.decodeResource
(getResources(), R.drawable.rock),
WIDTH + 10, HEIGHT/2, 200, 200,
playerCharacter.getScore(),3));
} else {
rocks.add(new
Rock(BitmapFactory.decodeResource
(getResources(), R.drawable.rock),
WIDTH+10, (int) (rnd.nextDouble() *
(HEIGHT - maxBoundaryHeight * 2))
+ maxBoundaryHeight, 200, 200,
playerCharacter.getScore(),3));
}
}
for(int i=0; i<rocks.size();i++) {
rocks.get(i).update();
if(collision(rocks.get(i),playerCharacter)) {
rocks.remove(i);
playerCharacter.setPlaying(false);
break;
}
//remove rocks if they go out of the screen
if(rocks.get(i).getXC() < -100) {
rocks.remove(i);
break;
}
}
} else {
//We created an else block to trigger our newGameCreated
variable and to set a new game newGameCreated = false; if(!newGameCreated) { newGame(); } }
}
public boolean collision(GameObj a, GameObj b) {
if(Rect.intersects(a.getRectangle(), b.getRectangle())) {
return true;
}
return false;
}
@Override
public void draw(Canvas canvas){
final float scaleFactorX = getWidth()/WIDTH;
final float scaleFactorY = getHeight()/HEIGHT;
if(canvas != null) {
final int savedState = canvas.save();
canvas.scale(scaleFactorX, scaleFactorY);
bgImg.draw(canvas);
playerCharacter.draw(canvas);
for(Rock r : rocks) {
r.draw(canvas);
}
//Drawing our upperBoundary for(UpperBoundary ub : upperBoundary){ ub.draw(canvas); } //Drawing our lowerBoundary for(LowerBoundary lb: lowerBoundary) { lb.draw(canvas); }
canvas.restoreToCount(savedState);
}
}
public void updateUpperBound () {
//Refer code block above }
public void updateLowerBound () {
//Refer code block above for this }
public void newGame () {
//Refer code block in above part }
现在,构建并运行你的游戏;我们已经准备好了边界!:

玩家现在不会无限坠落
我们现在可以继续创建屏幕上的爆炸效果的下一部分。
创建爆炸效果
我们在这里的游戏几乎完成了,只剩下以下两个部分:
-
添加爆炸效果的粒子效果
-
在屏幕上显示我们的得分
我们将这部分分为两个部分,其中我们将完成本章的一半爆炸,另一半将与游戏的 UI 一起完成,从而结束这个游戏。所以,让我们现在开始。对于我们的爆炸效果,我们需要一个精灵图集。我们将使用以下精灵图集来制作我们的游戏:

我们的爆炸精灵图集
我们将创建一个名为ExplosionEffect.java的新类。请注意,我们不会将此文件扩展到GameObj文件中,因为我们不需要这个图像的任何碰撞组件。我们只需在屏幕上生成它并使其保持在同一位置。所以,打开你的ExplosionEffect.java文件,让我们首先定义我们的变量:
private int xc;
private int yc;
private int height;
private int width;
private int row;
private AnimationClass ac = new AnimationClass();
private Bitmap spriteSheet;
如您所见,我们只需要x、y坐标以及height和width作为整数值。此外,请注意,在这里我们将同时处理精灵图中的行和列,而之前的精灵我们只使用了一行或一列,因此我们需要一个额外的变量row来帮助我们解决这个问题。我们需要AnimationClass变量来运行我们的动画,最后但同样重要的是,我们的Bitmap spriteSheet变量。
然后,我们将定义我们类的构造函数如下:
public ExplosionEffect(Bitmap res, int xc, int yc, int w, int h, int noOfFrames){
this.xc = xc;
this.yc = yc;
this.width = w;
this.height = h;
Bitmap[] img = new Bitmap[noOfFrames];
spriteSheet = res;
for(int i = 0; i < img.length; i++) {
if(i % 5 == 0 && i > 0)
row++;
img[i] = Bitmap.createBitmap
(spriteSheet, (i - (5 * row)) * width, row * height,
width, height);
}
ac.setFrames(img);
ac.setDelay(10);
}
如果您仔细观察,您会看到我们只是在重复之前为游戏对象所做的步骤,只是这里我们有一个之前定义的额外row变量,这将帮助我们扫描精灵图的行。
现在,我们只剩下这个类的draw()和update()方法。我们还将创建一个用于精灵图getHeight()的方法,以便我们在使用这个方法实际生成爆炸效果时进行计算:
public void draw(Canvas canvas) {
if(!ac.playedOnce()){
canvas.drawBitmap(ac.getImage(),xc,yc,null);
}
}
public void update() {
if(!ac.playedOnce()){
ac.update();
}
}
public int getHeight() {
return height;
}
完成这些后,确保你的ExplosionEffect.java文件看起来像这样:
public class ExplosionEffect {
//refer variables created above
public ExplosionEffect(Bitmap res, int xc, int yc, int w, int h, int noOfFrames){
this.xc = xc;
this.yc = yc;
this.width = w;
this.height = h;
Bitmap[] img = new Bitmap[noOfFrames];
spriteSheet = res;
for(int i = 0; i < img.length; i++) {
if(i % 5 == 0 && i > 0)
row++;
img[i] = Bitmap.createBitmap
(spriteSheet, (i - (5 * row)) * width, row * height,
width, height);
}
ac.setFrames(img);
ac.setDelay(10);
}
public void draw(Canvas canvas) {
if(!ac.playedOnce()){
canvas.drawBitmap(ac.getImage(),xc,yc,null);
}
}
public void update() {
if(!ac.playedOnce()){
ac.update();
}
}
public int getHeight() {
return height;
}
}
我们已经准备好我们的ExplosionEffect.java文件;这就是本章的全部内容。我们已经准备好我们的爆炸类,在下一章中,我们将在岩石与玩家碰撞后开始在屏幕上创建爆炸效果。
摘要
我们学习了如何为我们的游戏创建边界,并且创建了包含所需组件及其构造函数的爆炸效果文件。
我们现在在游戏中有了适当的上下边界,并且我们在玩家与岩石碰撞后添加爆炸效果的基础已经建立。
在下一章中,当玩家与岩石碰撞后,我们将在屏幕上生成爆炸效果,并将分数作为用户界面组件显示在屏幕上。
第八章:添加爆炸和创建 UI
恭喜你走到了这一步!到现在为止,你必须已经装备了几乎所有的基本知识,以便你开始你的游戏开发之旅。这一章将作为我们游戏的收尾,我们将通过向游戏场景添加爆炸来完成我们的爆炸部分。一旦我们完成了这个,我们将为我们的游戏创建一个简单的 UI,它将在屏幕上显示我们的分数和距离。所以,系好安全带!你即将完成我们开始的游戏。在本章中,我们将学习以下内容:
-
将爆炸添加到我们的游戏中
-
创建一个带有玩家操作说明的教程
-
使用我们的 UI 在屏幕上显示分数
然而,在我们继续到我们的 UI 之前,让我们首先完成我们的爆炸效果。
将爆炸添加到我们的游戏中
在第七章,添加边界和使用精灵创建爆炸中,我们已经创建了我们的ExplosionEffect.java类文件。现在,我们只剩下最后一个任务:在屏幕上生成我们的爆炸。现在,仅作参考,我们将查看我们将用于爆炸文件的图像:

Explosion.png文件精灵图集
注意,我们爆炸的最后几帧几乎没有任何图像。这是因为我们不会销毁这个对象;我们只是简单地生成它,并让它现在播放其动画。
让我们继续前进,让我们的爆炸在游戏屏幕上运行。同样,我们将我们的过程分解成简单的步骤。
创建变量
如您现在所知,我们将处理我们的GameView.java文件来显示爆炸。所以打开你的GameView.java文件。我们将首先创建一些变量,如下所示:
private ExplosionEffect explosionEffect;
private long startReset;
private boolean reset;
private boolean started;
我们创建了explosionEffect变量来获取对ExplosionEffect类以及其他我们创建的变量的引用,以便在玩家碰撞后重置玩家。所以,基本上,我们将要做的是一旦玩家与一块石头碰撞,就会播放爆炸动画,并且玩家将重置到初始状态。
一些优化和改进
我们将通过在创建表面后移动mainThread来对我们的游戏进行一些优化。因此,我们将从构造函数中移除mainThread = new MainGameThread(getHolder(), this);并将其写入我们在surfaceCreated()方法中开始运行它的地方,如下所示:
@Override
public void surfaceCreated(SurfaceHolder holder){
bgImg = new BackgroundImage(BitmapFactory.decodeResource
(getResources(), R.drawable.background_image));
playerCharacter = new PlayerCharacter(BitmapFactory.decodeResource
(getResources(),R.drawable.player_run),200,246,3);
rocks = new ArrayList<Rock>();
upperBoundary = new ArrayList<UpperBoundary>();
lowerBoundary = new ArrayList<LowerBoundary>();
mainThread = new MainGameThread(getHolder(), this);
//we can safely start the game loop
mainThread.setRunning(true);
mainThread.start();
}
看起来很整洁!现在,我们还需要对我们的触摸事件做一些改进,因为如果我们的玩家与一块石头碰撞,我们将重置游戏到初始状态。所以,我们需要确保只有在游戏处于播放模式或创建或重置了新游戏时才能播放。因此,我们将修改我们的onTouchEvent(),使其看起来如下:
@Override
public boolean onTouchEvent(MotionEvent event)
{
if(event.getAction() == MotionEvent.ACTION_DOWN) {
if(!playerCharacter.getPlaying() && newGameCreated && reset){
playerCharacter.setPlaying(true);
playerCharacter.setUp(true);
}
if(playerCharacter.getPlaying()){
if(!started)started = true;
reset = false;
playerCharacter.setUp(true);
} return true;
}
}
在这里,我们只是设置了一些参数,以便更好地控制玩家移动。正如您从我们的第一个if块中可以看到,如果getPlaying()、newGameCreated和reset返回一个false值,那么我们将setPlaying设置为true,并将setUp设置为true。
此外,如果我们的getPlaying()已经是true,那么我们将检查游戏是否已经开始;如果没有,我们将started变量设置为true,reset设置为false,并将setUp()设置为true。
现在,我们已经设置了新游戏、游戏和重置逻辑,以便玩游戏。我们在这里处理重置变量,但还必须在之前创建的newGame()函数中处理它们。然而,在我们这样做之前,让我们引用我们的爆炸效果并确切地告诉它在哪里以及何时生成。
生成我们的爆炸效果
我们需要爆炸在玩家与我们的岩石碰撞后生成。一旦玩家与岩石碰撞,游戏就结束了。所以,所有这些都已经在我们的update()函数中处理,其中我们已经为newGame()函数创建了一个 else 块。让我们利用这个块并编写我们的生成爆炸的逻辑。我们的目标是以下内容:
-
生成爆炸效果
-
在碰撞后启动计时器等待一段时间
-
在一定时间后重置游戏
注意,我们的图像尺寸是 500 x 500,因此我们将把我们的图像分成每个帧的相等部分,从而得到 25 个部分,每个部分尺寸为 100 x 100。我们将每个部分传递给我们的构造函数作为宽度、高度和帧数。如果您用于游戏的图像尺寸不同,那么您需要计算您的尺寸,然后使用与您的图像尺寸相应的值。
我们将进入update()函数的else块并修改我们之前编写的代码,使其看起来像这样:
else {
playerCharacter.resetDYC();
if(!reset) {
newGameCreated = false;
startReset = System.nanoTime();
reset = true;
explosionEffect = new ExplosionEffect(BitmapFactory
.decodeResource(getResources(),R.drawable.explosion)
playerCharacter.getXC(),playerCharacter.getYC()
-30,100,100,25);
}
//Code block after this part remains the same
}
因此,我们现在正在重置玩家的y加速度并生成我们的爆炸。然后,在等待一段时间后,我们调用函数来重置我们的游戏。
在屏幕上绘制爆炸效果
我们仍然需要在屏幕上绘制我们的爆炸效果,是的,您说得对!我们将在我们的draw()方法中这样做。我们还必须确保我们只绘制一次爆炸,即在游戏开始时,因此我们将使用我们的started变量来跟踪它:
if(started) {
explosionEffect.draw(canvas);
}
我们已经完成了我们的绘制逻辑。我们已经完成了爆炸效果的整个逻辑,现在您的GameView.java文件的整个代码应该看起来像以下这样;本章所做的所有更改都以粗体标注:
//Package name and import statements remain same as previous chapter
public class GameView extends SurfaceView implements SurfaceHolder.Callback
{
//Same variables as defined earlier
private ExplosionEffect explosionEffect;
private long startReset;
private boolean reset;
private boolean started;
private Random rnd = new Random();
//GameView constructor, SurfaceChanged and surfaceDestroyed methods
remain same
@Override
public void surfaceCreated(SurfaceHolder holder){
//bgImg, playerCharacter, rocks, upperBoundary and
lowerBoundary code same as before
mainThread = new MainGameThread(getHolder(), this); //main thread code after this as earlier
}
@Override
public boolean onTouchEvent(MotionEvent event)
{
if(event.getAction() == MotionEvent.ACTION_DOWN) {
if(!playerCharacter.getPlaying()
&& newGameCreated && reset){
playerCharacter.setPlaying(true);
playerCharacter.setUp(true);
}
if(playerCharacter.getPlaying()){
if(!started)started = true;
reset = false;
playerCharacter.setUp(true);
} return true;
}
//MotionEvent.ACTION_UP code same as earlier
return super.onTouchEvent(event);
}
public void update()
{
if(playerCharacter.getPlaying()) {
//Same code as earlier
} else {
playerCharacter.resetDYC();
if(!reset) {
newGameCreated = false;
startReset = System.nanoTime();
reset = true;
explosionEffect = new ExplosionEffect(BitmapFactory
.decodeResource(getResources(),R.drawable.explosion),
playerCharacter.getXC(),playerCharacter.getYC()
-30,100,100,25);
}
explosionEffect.update();
long resetElapsed = (System.nanoTime()-startReset)/1000000;
if(resetElapsed > 2500 && !newGameCreated) {
newGame();
}
if(!newGameCreated) {
newGame();
}
}
}
//collision code remains same. no change
@Override
public void draw(Canvas canvas)
{
//Same as till lower boundary and upper boundary code block
if(started) {
explosionEffect.draw(canvas);
}
canvas.restoreToCount(savedState);
}
}
//No change in updateUpperBound, updateLowerBound and update method
}
因此,现在您可以通过在您的设备上玩游戏来测试您的爆炸效果!

我们的爆炸效果正在发挥作用
咔嚓!我们现在完成了我们的精灵爆炸效果!现在,我们将继续进行这个游戏的最后一部分——UI。
为我们的游戏创建 UI
如果你不太熟悉 UI 这个术语,你一定想知道这是什么?UI 是用户界面的缩写。简单来说,UI 可以包括你需要在游戏屏幕或屏幕控制上显示的所有信息。UI 的常见元素包括以下内容:
-
屏幕上显示的文本
-
按钮
-
操控盘
-
指导说明
在本章的这一部分,我们将学习如何在屏幕上显示文本。我们还将指导玩家如何玩游戏。我们将在屏幕上显示以下内容:
-
跑步距离
-
最佳得分
-
游戏玩法说明
因此,在这里我们需要显示最佳得分。然而,我们还没有创建最佳得分变量。对于这部分,我们将完全在我们的GameView.java文件中工作。所以,让我们在这个类中定义我们的最佳得分变量:
private int bestScore;
现在,我们已经准备好在屏幕上显示我们的 UI 数据。我们的 UI 将完全基于draw()函数,因此我们将定义一个名为drawText()的方法,它应该在我们的类中的draw()方法中被调用。所以,在我们实际调用drawText()方法之前,让我们为它编写一些代码。
在此之前,让我们先计算最佳得分。显然,我们的最佳得分将在第一局游戏结束后计算,所以我们将这个逻辑放入newGame()函数中。这个逻辑相当简单。如果当前得分大于初始化为0的bestScore,那么我们的bestScore等于通过playerCharacter.getScore();获取的当前得分,这将在newGame()函数中:
if(playerCharacter.getScore() > bestScore) {
bestScore = playerCharacter.getScore();
}
好的,问题解决了,现在我们有了bestScore变量,可以用来存储最佳得分;我们的newGame()函数看起来是这样的:
public void newGame () {
//clear code and minBoundaryHeight, maxBoundaryHeight code same as
before
if(playerCharacter.getScore() > bestScore) {
bestScore = playerCharacter.getScore();
}
//Rest of the code block after this is the same as previous
}
我们现在可以编写我们的drawText()方法。为此,我们将使用 Android 中的Paint类。Paint类包含有关样式、颜色以及如何绘制几何图形、文本和位图的信息。使用这个类,我们可以定义文本的颜色、大小和字体。然后,以我们的画布作为参考,我们可以在画布上绘制文本。所以,让我们在屏幕上显示当前距离和最佳得分:
public void drawText(Canvas canvas) {
Paint p = new Paint();
p.setColor(Color.BLACK);
p.setTextSize(30);
p.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
canvas.drawText("DISTANCE: "+
(playerCharacter.getScore()*3),10,HEIGHT-10,p);
canvas.drawText("BEST: "+ bestScore,WIDTH - 215,HEIGHT-10,p);
}
好的,看起来很棒,但是嘿,我们还有一个部分没有完成:教程。游戏开始时,我们需要指导玩家如何玩游戏。所以,我们将添加一个if语句来控制教程信息的可见性。我们将指导玩家进行“点击开始”、“按住向上移动”和“松开向下移动”:
if(!playerCharacter.getPlaying() && newGameCreated && reset) {
Paint p1 = new Paint();
p1.setTextSize(40);
p1.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
canvas.drawText("TAP TO START",WIDTH/2-50,HEIGHT/2,p1);
p1.setTextSize(20);
canvas.drawText("KEEP PRESSED TO GO UP",WIDTH/2 -
50,HEIGHT/2+20,p1);
canvas.drawText("RELEASE TO GO DOWN",WIDTH/2 - 50,HEIGHT/2+40,p1);
}
好的,我们已经完成了drawText()方法,整体看起来是这样的:
public void drawText(Canvas canvas) {
Paint p = new Paint();
p.setColor(Color.BLACK);
p.setTextSize(30);
p.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
canvas.drawText("DISTANCE: "+
(playerCharacter.getScore()*3),10,HEIGHT-10,p);
canvas.drawText("BEST: "+ bestScore,WIDTH - 215,HEIGHT-10,p);
if(!playerCharacter.getPlaying() && newGameCreated && reset) {
Paint p1 = new Paint();
p1.setTextSize(40);
p1.setTypeface(Typeface.create(Typeface.DEFAULT,
Typeface.BOLD));
canvas.drawText("TAP TO START",WIDTH/2-50,HEIGHT/2,p1);
p1.setTextSize(20);
canvas.drawText("KEEP PRESSED TO GO UP",WIDTH/2 -
50,HEIGHT/2+20,p1);
canvas.drawText("RELEASE TO GO DOWN",WIDTH/2 -
50,HEIGHT/2+40,p1);
}
}
现在,还有最后一件事要做。我们需要调用我们的drawText()方法。你猜我们在哪里做这件事?我们将在我们类中的draw()方法中这样做;让我们来做吧:
@Override
public void draw(Canvas canvas)
{
final float scaleFactorX = getWidth()/WIDTH;
final float scaleFactorY = getHeight()/HEIGHT;
if(canvas!=null) {
//No changes in code till if(started) statement
if(started) {
explosionEffect.draw(canvas);
}
drawText(canvas);
canvas.restoreToCount(savedState);
}
}
你已经完成了在屏幕上显示文本组件的代码,现在有一个带有 UI 的运行游戏,可以在屏幕上显示教程信息、已覆盖的距离和最佳得分。
让我们回顾一下我们在本章的这一部分所做的代码更改;代码更改以粗体标注:
//package name and import statements remain the same as before
public class GameView extends SurfaceView implements SurfaceHolder.Callback
{
//no change in variable names
//GameView constructor. No change needed, write as is
//surfaceChanged method constant as before
//surfaceDestroyed method same as before
//surfaceCreated method same as before
//onTouchEvent same as before
//collision method written as is
@Override
public void draw(Canvas canvas)
{
final float scaleFactorX = getWidth()/WIDTH;
final float scaleFactorY = getHeight()/HEIGHT;
if(canvas!=null) {
final int savedState = canvas.save();
canvas.scale(scaleFactorX, scaleFactorY);
bgImg.draw(canvas);
playerCharacter.draw(canvas);
for(Rock r : rocks) {
r.draw(canvas);
}
for(UpperBoundary ub : upperBoundary){
ub.draw(canvas);
}
for(LowerBoundary lb: lowerBoundary) {
lb.draw(canvas);
}
if(started) {
explosionEffect.draw(canvas);
}
drawText(canvas);
canvas.restoreToCount(savedState);
}
}
//updateUpperBound code remains same
//updateLowerBound code remains same
public void newGame () {
lowerBoundary.clear();
upperBoundary.clear();
rocks.clear();
minBoundaryHeight = 5;
maxBoundaryHeight = 30;
if(playerCharacter.getScore() > bestScore) {
bestScore = playerCharacter.getScore(); }
playerCharacter.resetScore();
playerCharacter.resetDYC();
playerCharacter.setYC(HEIGHT/2);
for(int i=0; i*20<WIDTH+40;i++) {
if(i==0) {
upperBoundary.add(new UpperBoundary
(BitmapFactory.decodeResource(getResources(),
R.drawable.ground),i*20,0,10));
} else {
upperBoundary.add(new UpperBoundary
(BitmapFactory.decodeResource(getResources(),
R.drawable.ground),i*20,0,
upperBoundary.get(i-1).getHeight()+1));
}
}
for(int i = 0; i*20<WIDTH+40;i++) {
if(i==0) {
lowerBoundary.add(new LowerBoundary
(BitmapFactory.decodeResource(getResources(),
R.drawable.ground),i*20, HEIGHT-minBoundaryHeight));
} else {
lowerBoundary.add(new LowerBoundary
(BitmapFactory.decodeResource
(getResources(),R.drawable.ground),
i*20, lowerBoundary.get(i-1).getYC()-1));
}
}
newGameCreated = true;
}
public void drawText(Canvas canvas) {
Paint p = new Paint();
p.setColor(Color.BLACK);
p.setTextSize(30);
p.setTypeface(Typeface.create(Typeface.DEFAULT,
Typeface.BOLD));
canvas.drawText("DISTANCE: "+
(playerCharacter.getScore()*3),10,HEIGHT-10,p);
canvas.drawText("BEST: "+ bestScore,WIDTH - 215,HEIGHT-10,p);
if(!playerCharacter.getPlaying() && newGameCreated && reset) {
Paint p1 = new Paint();
p1.setTextSize(40);
p1.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
canvas.drawText("TAP TO START",WIDTH/2-50,HEIGHT/2,p1);
p1.setTextSize(20);
canvas.drawText("KEEP PRESSED TO GO UP",WIDTH/2 -
50,HEIGHT/2+20,p1);
canvas.drawText("RELEASE TO GO DOWN",WIDTH/2 -
50,HEIGHT/2+40,p1);
}
}
}
如果你已经完成了所有这些步骤,那么你现在可以继续在你的设备或模拟器上测试你的游戏了。你将得到如下输出:

显示我们信息的教程
在游戏过程中,我们也可以看到我们的得分:

随着游戏的进行,覆盖的距离和最佳得分更新
因此,我们完成了我们的 2D 游戏。现在,基于你对前几章的理解,你可以向这个游戏中添加元素,比如金币、更多障碍,以及你能想象到的任何东西。现在建议你尽可能根据你的理解来定制这个游戏,或者你也可以完全开始创建一个新的游戏。
摘要
我们创建并显示了我们教程信息、已覆盖的距离和屏幕上的最佳得分。
我们学习了如何在屏幕上创建文本,帮助我们显示得分,并最终实现了爆炸逻辑,使得在撞到岩石后爆炸会在屏幕上显示。这就是我们的 3D 游戏的全部内容。在此之后,你可以构建你的游戏,在你的设备上测试它,甚至进一步调整以添加更多障碍或使其变得尽可能有趣。你唯一受限制的是你的想象力。
有了这个,我们完成了我们的 2D 游戏,我们将在下一章中探讨如何过渡到 3D 世界。
第九章:将您的游戏从 2D 转换为 3D
现在,请注意,在 Android Studio 中从头开始制作 3D 游戏是一项艰巨的任务。并不是绝对必要在 3D 中从头开始制作游戏,因为市场上有很多工具和游戏引擎可用,这些工具和引擎消除了从头开始创建一切的需求。然而,了解幕后如何操作的知识将肯定有助于你长期发展。
在本章中,我们将学习一些高级概念的基础知识,到本章结束时,你将了解如何在 Android Studio 中创建基本 3D 对象。为了本章的目的,我们将创建一个新的 Android Studio 项目,因为我们不会继续在先前的游戏项目文件夹中继续。然而,在我们开始之前,让我们简要地看看我们将学习的概念。在本章中,我们将学习以下概念:
-
OpenGL ES 简介
-
使用 OpenGL ES 学习 3D 坐标系
-
使用 OpenGL ES 创建场景
-
项目文件夹
-
渲染类和主活动
-
定义形状
-
为了创建我们的 3D 图形,我们将使用 OpenGL ES 进行渲染。让我们了解 OpenGL ES 是什么。
OpenGL ES 简介
在 Android 上,通过 OpenGL 库支持高性能的 2D 和 3D 图形。我们使用 OpenGL ES API,该 API 用于此。此 API 指定了 3D 图形处理硬件的接口。OpenGL 是一个庞大的库,其中一部分是 OpenGL ES,它是专门为嵌入式设备创建的。
在 Android 上支持的 OpenGL ES API 的各个版本如下:
-
1.0 和 1.1:支持 Android 1.0 及更高版本
-
2.0:支持 Android 2.2(API 级别 8)及更高版本
-
3.0:支持 Android 4.3(API 级别 18)及更高版本
-
3.1:支持 Android 5.0(API 级别 21)及更高版本
你可以在 Android 官方网站上阅读更多关于此的信息:developer.android.com/guide/topics/graphics/opengl.html。
使用 OpenGL ES,你可以在 Android 上创建 3D 图形。由于我们将创建 3D 对象,我们需要了解坐标系。让我们快速看一下我们将用于我们示例的 3D 坐标系。
学习 3D 坐标系
在制作我们的 2D 游戏时,我们只处理x和y轴的值。然而,当你制作 3D 游戏时,你必须处理三个轴的值:x、y和z。到目前为止,我们对x和y轴的值是如何工作的已经很清楚。以类似的方式,我们的z轴被投影到我们移动设备的正面和背面。以下图像将更好地解释我们 3D 坐标系中的三个轴:

手机上的正x、y和z轴
在前面的图像中您看到的轴是正方向。如果您取它们的相反方向,您将得到负值。原点从(0,0,0)开始,您的值可以是浮点值,这将表示您的对象在 3D 空间中的位置。
这里有一个您在处理 OpenGL 时可能会遇到的一个经典问题,与设备屏幕尺寸有关。OpenGL 中的网格假设一个屏幕,它是正方形并且具有统一的坐标系统。然而,如果您的屏幕尺寸不同,那么非正方形屏幕被视为一个完美的正方形屏幕。为了理解这一点,请看以下图示:

理解默认的 OpenGL 坐标系统
如前图所示,我们有两个不同的屏幕,一个是正方形形状的,另一个是矩形的。现在,这里发生的情况是,尽管您的屏幕是矩形,但它被视为正方形,因此您的图形被拉伸。为了解决这个问题,您需要应用 OpenGL 投影模式和相机视图来变换坐标,以便在任何显示上获得适当的比例。
要这样做,我们创建一个投影矩阵和一个相机视图矩阵,然后将它们应用到我们的 OpenGL 渲染管线中。投影矩阵帮助我们通过重新计算我们的图形矩阵来处理坐标,以便它们正确地映射到我们的设备屏幕上。相机视图矩阵帮助创建一个变换,从特定的眼睛位置渲染对象。
你可以在en.wikipedia.org/wiki/OpenGL_ES了解更多关于 OpenGL ES 的信息。
现在我们已经了解了这些基本概念,我们可以开始使用 OpenGL ES 在 Android 中创建我们的 3D 游戏场景;让我们开始吧!
使用 OpenGL ES 创建 3D 场景
就像我们在 2D 游戏示例中所做的那样,我们的大部分绘图机制将保持不变。您会发现这两个过程之间有很多相似之处。我们将为这个示例创建一个新的项目文件夹,所以让我们继续创建一个新的项目,就像我们在第八章中做的那样,添加爆炸和创建用户界面。
创建我们的项目文件夹
我们将遵循我们在第二章中做的步骤,熟悉 Android Studio。首先,我们将从我们的顶部菜单创建一个新的项目。
要创建一个新的项目,请执行以下步骤:
- 前往文件 | 新建 | 新项目...,如下截图所示:

- 填写你的应用程序名称、公司域名和包名详细信息;然后点击下一步:

- 选择您的目标设备并点击下一步:

- 选择空活动并点击下一步:

- 填写你的活动名称、布局名称,然后点击完成:

好吧,我们现在已经设置了我们的新项目文件夹。此外,我们将为这个项目使用横屏模式方向,所以我们将它在AndroidManifest.xml文件中定义。从app/manifests/AndroidManifest.xml文件打开您的清单文件,并做出以下标记为粗体的更改:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.nikmlnkr.my3Dgame">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
让我们现在定义我们的主活动并为我们的 3D 游戏视图创建一个渲染类。
创建一个渲染类和定义主活动
为了在屏幕上看到任何东西,我们必须在屏幕上渲染它。因此,我们需要创建一个渲染类来处理我们功能中的渲染部分。渲染是通过计算机程序从 2D 或 3D 模型生成图像的过程。我们将命名我们的渲染类为MyGLRenderer,但在我们这样做之前,让我们定义我们的主活动。所以打开您的MainActivity.java文件,我们将在那里创建三个默认方法。删除屏幕上的所有内容,除了您的包名第一行。我们将有三个方法:
-
onCreate(): 这个方法初始化我们的主活动 -
onPause(): 当应用进入后台时,这个方法处理应用。 -
onResume(): 用户返回应用后,这个方法会被调用。
此外,我们在这里处理的是 OpenGL 图形,因此我们需要一个GLSurfaceView,就像我们在我们的 2D 游戏中做的那样,我们有一个用于游戏的表面视图。
因此,让我们在MainActivity.java文件中创建它们,如下所示:
package com.nikmlnkr.my3Dgame;
import android.app.Activity;
import android.opengl.GLSurfaceView;
import android.os.Bundle;
public class MainActivity extends Activity {
private GLSurfaceView gv;
//Our onCreate method
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
gv = new GLSurfaceView(this);
gv.setRenderer(new MyGLRenderer(this));
this.setContentView(gv);
}
//Resume method
@Override
protected void onResume() {
super.onResume();
gv.onResume();
}
//Pause method
@Override
protected void onPause() {
super.onPause();
gv.onPause();
}
}
好吧,看起来没问题,但现在我们在MyGLRenderer行上遇到了错误。这是因为我们还没有创建我们的渲染器类。让我们现在创建我们的渲染器类。创建一个新的 Java 类文件,命名为MyGLRenderer.java,让我们开始编写我们的渲染器类。
Renderer类是您将进行大部分对象显示的地方。
接口GLSurfaceView.Renderer负责让 OpenGL 渲染一个帧,因此我们需要在我们的代码中实现它作为一个接口;所以我们将从我们的第一行开始,这将做同样的事情。我们将扩展我们的类到GLSurfaceView.Renderer接口,并编写一个默认构造函数,如下所示:
public class MyGLRenderer implements GLSurfaceView.Renderer {
Context ct; //Context variable
//Constructor of our renderer class
public MyGLRenderer(Context ct) {
this.ct = ct;
}
}
现在,在这个类中,就像我们在我们的 2D 精灵示例游戏中做的那样,我们需要三个方法来在屏幕上绘制;它们是onSurfaceCreated()、onSurfaceChanged()和onDraw()。然而,在 OpenGL 中,onDraw()方法实际上是onDrawFrame(),所以让我们逐一定义这三个方法。
首先,我们将从我们的onSurfaceChanged()方法开始。在这个方法中,我们将创建我们的表面。这个方法用于初始化我们的场景。在这里,我们将在屏幕上创建一个简单的黑色屏幕。为了设置我们的颜色,我们将使用 RGBA 值。对于黑色,我们需要(0,0,0,1)的 RGBA 值。我们还会在这里添加一些更多的初始化因素:
@Override
public void onSurfaceCreated(GL10 gles, EGLConfig c) {
gles.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
//Clear color and set to black
gles.glClearDepthf(1.0f);
//Clear depth
gles.glEnable(GL10.GL_DEPTH_TEST);
//Enable depth test
gles.glDepthFunc(GL10.GL_LEQUAL);
//Set depth function
gles.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT,
GL10.GL_NICEST);
//set gl to nicest
gles.glShadeModel(GL10.GL_SMOOTH);
//set shade model to smooth
gles.glDisable(GL10.GL_DITHER);
//disable dither
}
这些参数基本上是必需的,用于设置 OpenGL ES 环境中的各种方面。你可以通过www.khronos.org/registry/OpenGL-Refpages/es3.0/了解更多关于这些特定方法和更多信息。
这个方法在onSurfaceCreated()方法之后被调用,并且每次屏幕分辨率改变时也会被调用。基本上,这个方法负责创建我们在本章中较早看到的显示矩阵,这反过来又会在任何屏幕上创建一个均匀的形状:
@Override
public void onSurfaceChanged(GL10 gles, int w, int h) {
if (h == 0) h = 1;
float aspect = (float)w / h;
gles.glViewport(0, 0, w, h);
//dynamically set the width and height of our viewport as per screen
resolution
gles.glMatrixMode(GL10.GL_PROJECTION);
//set our matrix mode projection
gles.glLoadIdentity();
GLU.gluPerspective(gles, 45, aspect, 0.1f, 100.f);
gles.glMatrixMode(GL10.GL_MODELVIEW);
//set our camera view matrix mode
gles.glLoadIdentity();
}
最后,我们有onDrawFrame()函数,它用于绘制当前帧。在每一帧之后,我们需要清除之前绘制的屏幕,因此在这个函数中,在渲染任何其他代码之前,我们调用glClear()。现在,我们只需编写我们的清除代码,然后在清除代码之后,在这个函数中绘制我们的对象形状:
@Override
public void onDrawFrame(GL10 gles) {
gles.glClear(GL10.GL_COLOR_BUFFER_BIT |
GL10.GL_DEPTH_BUFFER_BIT);
//clear depth buffer
}
最后,这是你的Renderer类的整个代码看起来像这样:
package com.nikmlnkr.my3Dgame;
/**
* Created by nikhilmalankar on 05/03/17.
*/
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
import android.content.Context;
import android.opengl.GLSurfaceView;
import android.opengl.GLU;
public class MyGLRenderer implements GLSurfaceView.Renderer {
Context ct; //Our context variable
// Constructor of our renderer
public MyGLRenderer(Context ct) {
this.ct = ct;
}
// Call back when the surface is first created or re-created
@Override
public void onSurfaceCreated(GL10 gles, EGLConfig c) {
gles.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
gles.glClearDepthf(1.0f);
gles.glEnable(GL10.GL_DEPTH_TEST);
gles.glDepthFunc(GL10.GL_LEQUAL);
gles.glHint(GL10.GL_PERSPECTIVE_CORRECTION_HINT,
GL10.GL_NICEST);
gles.glShadeModel(GL10.GL_SMOOTH);
gles.glDisable(GL10.GL_DITHER);
}
@Override
public void onSurfaceChanged(GL10 gles, int w, int h) {
if (h == 0) h = 1;
float aspect = (float)w / h;
gles.glViewport(0, 0, w, h);
gles.glMatrixMode(GL10.GL_PROJECTION);
gles.glLoadIdentity();
GLU.gluPerspective(gles, 45, aspect, 0.1f, 100.f);
gles.glMatrixMode(GL10.GL_MODELVIEW);
gles.glLoadIdentity();
}
@Override
public void onDrawFrame(GL10 gles) {
gles.glClear(GL10.GL_COLOR_BUFFER_BIT |
GL10.GL_DEPTH_BUFFER_BIT);
//clear our depth buffer
}
}
现在,我们将学习如何定义和绘制,或者更准确地说,渲染我们的 3D 对象在屏幕上。首先,我们将从一个基本的三角形形状开始,然后我们将绘制一个金字塔,然后我们将使它们两个在屏幕上旋转。
定义形状
为了在屏幕上绘制某些东西,我们首先需要定义其形状,然后渲染它。因此,我们不会在我们的渲染类中定义我们的形状。我们将创建一个新的类来定义我们的形状。正如讨论的那样,我们首先创建一个基本的三角形。
为了创建我们的三角形,我们首先需要定义其顶点。因此,让我们创建一个新的类名为Triangle.java,并开始编写我们的逻辑来定义 3D 三角形的形状。在我们开始编写代码之前,让我们尝试理解我们实际上要做什么。
我们将在屏幕上绘制顶点。此外,正如你之前看到的 OpenGL 坐标如何工作,我们将在三个方向上绘制我们的顶点。对于我们的三角形,我们需要绘制三个点。因此,我们将在一个正y 轴上绘制一个点,其他两个点分别绘制在正负x 轴上:

绘制我们的三角形
为了这个目的,我们将使用一个三维数组来存储我们的顶点,并在我们的类中定义它们作为变量:
package com.nikmlnkr.my3Dgame;
/**
* Created by nikhilmalankar on 05/03/17.
*/
public class Triangle {
private float[] v = { // Vertices of our triangle
0.0f, 1.0f, 0.0f, // 0\. top vertices
-1.0f, -1.0f, 0.0f, // 1\. left-bottom vertices
1.0f, -1.0f, 0.0f // 2\. right-bottom vertices
};
}
这样我们就处理了顶点,并定义了其形状,好吧,某种程度上是这样,但当然,还有更多。我们还需要定义一个顶点缓冲区并将这些数据传输到其中。为此,我们将定义我们的顶点缓冲区变量为 nio 的缓冲区,因为它们位于本地堆上,不会被垃圾回收。我们也会为我们的索引缓冲区做同样的事情,这将使我们的三角形以逆时针(CCW)方向排列,并且正z方向朝向屏幕。因此,首先我们将定义我们的vertexBuffer和indexBuffer变量,然后在我们的默认构造函数中,我们将设置我们的vertexBuffer和indexBuffer:
private FloatBuffer vb;
private ByteBuffer ib
private byte[] ind = { 0, 1, 2 };
public Triangle() {
ByteBuffer vbb = ByteBuffer.allocateDirect(v.length * 4);
vbb.order(ByteOrder.nativeOrder());
vb = vbb.asFloatBuffer();
vb.put(v);
vb.position(0);
ib = ByteBuffer.allocateDirect(ind.length);
ib.put(ind);
ib.position(0);
}
好的,现在我们已经处理好了这些,我们实际上还必须在屏幕上绘制我们的三角形;为此,我们将使用我们的draw()方法。要做到这一点,我们将遵循以下四个简单步骤:
-
我们启用了顶点数组客户端状态。
-
我们指定缓冲区位置。
-
我们使用
glDrawElements()来渲染我们的原始形状,该函数使用索引数组来引用顶点数组。 -
我们禁用了我们的顶点数组客户端状态。
既然我们已经理解了其理论的工作原理,那么让我们继续通过在代码中实现它来实践:
public void draw(GL10 gles) {
gles.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gles.glVertexPointer(3, GL10.GL_FLOAT, 0, vb);
gles.glDrawElements(GL10.GL_TRIANGLES, ind.length,
GL10.GL_UNSIGNED_BYTE, ib);
gles.glDisableClientState(GL10.GL_VERTEX_ARRAY);
}
我们的定义形状是完美的,我们的整个Triangle.java文件的代码块将看起来像这样:
package com.nikmlnkr.my3Dgame;
/**
* Created by nikhilmalankar on 05/03/17.
*/
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import javax.microedition.khronos.opengles.GL10;
public class Triangle {
private FloatBuffer vb;
private ByteBuffer ib;
private float[] v = { // Vertices of our triangle
0.0f, 1.0f, 0.0f, // 0\. top vertices
-1.0f, -1.0f, 0.0f, // 1\. left-bottom vertices
1.0f, -1.0f, 0.0f // 2\. right-bottom vertices
};
private byte[] indices = { 0, 1, 2 };
public Triangle() {
ByteBuffer vbb = ByteBuffer.allocateDirect(v.length * 4);
vbb.order(ByteOrder.nativeOrder());
vb = vbb.asFloatBuffer();
vb.put(v);
vb.position(0);
ib = ByteBuffer.allocateDirect(ind.length);
ib.put(ind);
ib.position(0);
}
public void draw(GL10 gles) {
gles.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gles.glVertexPointer(3, GL10.GL_FLOAT, 0, vb);
gles.glDrawElements(GL10.GL_TRIANGLES, ind.length,
GL10.GL_UNSIGNED_BYTE, ib);
gles.glDisableClientState(GL10.GL_VERTEX_ARRAY);
}
}
然而,如果你将此部署到你的设备上,你将无法在屏幕上看到你的三角形。这是因为我们还没有在我们的屏幕上渲染它。还记得我们讨论了如何使用MyGLRenderer文件来帮助渲染所有我们的对象吗?我们还没有告诉那个文件使用我们的三角形进行渲染,但这就是本章的全部内容。我们将在下一章中介绍对象的渲染。
摘要
在本章中,我们学习了如何从 2D 游戏过渡到 3D 游戏,以及 OpenGL 的相关概念。我们还学习了如何创建我们的主活动以及我们的自定义渲染器。之后,我们学习了如何定义一个基本形状。
总结一下,在本章中,我们涵盖了以下内容。我们介绍了 OpenGL ES,并查看了解同中的坐标系。我们学习了如何创建一个空白场景以及渲染过程。然后我们创建了我们的基本渲染器并定义了我们的三角形形状。
在下一章中,我们将学习如何在屏幕上渲染我们定义的形状并对其进行旋转。我们还将学习如何在下一章中创建一个 3D 金字塔,并在下一章结束时,你将完成这本书,并且也将为创建 2D 和 3D 游戏打下基础。
第十章:进一步工作在 3D 游戏上
现在我们已经定义了我们的 3D 形状,让我们在屏幕上渲染它。然而,在我们开始本章之前,让我们总结一下这本书内容中我们所学到的所有内容,因为这是我们最后一章。
我们学习了关于 Android 的许多有趣的东西。我们从学习 Android 的历史开始,很快便转向了安装开发 Android 应用所需的软件。之后,我们安装了 Android Studio,并使用 Android-N 的最新组件进行了配置。
一旦我们的环境设置完成,我们学习了如何创建一个基本的 Android 应用,并了解了 Android 的各种概念,如包名、输入、模拟器等。在熟悉 Android 之后,我们学习了如何从制作应用过渡到制作游戏,其中我们学习了如何创建表面和画布,这将允许我们为游戏创建图形,然后我们继续学习游戏开发的各种概念。
随着我们章节的深入,我们开始学习制作游戏的过程,在几章之内,我们学会了如何从头开始使用 UI 创建 2D 游戏。我们用同样的方法完成了一个完整的 2D 游戏开发,然后转向将事物转化为 3D。
在我们之前的章节中,我们了解到我们可以使用 OpenGL ES 为原生 Android 创建 3D 游戏,因此我们开始定义基本形状。
在这本书的最后一章,我们将学习以下主题:
-
在屏幕上渲染我们的对象
-
给我们的对象添加颜色
-
旋转我们的两个 3D 对象
-
创建一个金字塔
所以,无需多言,让我们开始这本书的最后一部分,这将为我们开始 3D 游戏开发之旅奠定所需的基础。
在屏幕上渲染我们的对象
在我们之前的章节中,我们为我们的Triangle对象创建了一个类;然而,如果你运行你的游戏,它仍然会显示一个空白屏幕,因为我们还没有使用我们的渲染类来显示它。我们需要为我们的新定义的类创建一个对象,然后使用我们的 GL 引用,我们在屏幕上绘制/渲染它。打开你的MyGLRenderer.java文件,让我们首先声明一个三角形变量的声明。我们不会修改我们在onSurfaceCreated()或onSurfaceChanged()方法中编写的任何代码。
为了在我们的屏幕上绘制我们的对象,我们将简单地遵循以下步骤:
-
定义我们的
Triangle类的变量。 -
在我们的构造函数中为其分配一个引用。
-
使用 gl,在我们的
Triangle中访问draw()方法,将其显示在屏幕上。
让我们看看我们如何做到这一点;只需在你的MyGLRenderer.java文件中输入以下加粗代码:
//The import statements are same as our previous chapter
public class MyGLRenderer implements GLSurfaceView.Renderer {
Triangle triangle;
// Constructor
public MyGLRenderer(Context context) {
triangle = new Triangle();
}
//onSurfaceCreated and onSurfaceChanged methods remain same as previous chapter
@Override
public void onDrawFrame(GL10 gles) {
gles.glClear(GL10.GL_COLOR_BUFFER_BIT |
GL10.GL_DEPTH_BUFFER_BIT);
gles.glLoadIdentity();
gles.glTranslatef(-1.5f, 0.0f, -6.0f);
triangle.draw(gles);
}
}
非常简单,就是这样!你的三角形对象已经准备好在屏幕上渲染了。你不相信吗?那就试试吧;在你的模拟器/设备上测试和运行它,你将得到以下输出:

我们的三角形终于渲染在屏幕上了!
你可能会认为这个物体仍然是 2D 的,但请等到我们旋转它的部分。然而,在那之前,让我们学习如何给这个物体添加颜色。所以,让我们添加一些颜色。
给我们的物体添加颜色
在这部分,我们将演示我们如何使用不同的颜色来纹理我们的物体。我们将使用colorBuffer中的 RGB 值从顶点的颜色中获取值。之后,我们将启用颜色数组客户端状态,然后这些颜色将与顶点一起在glDrawElements()中渲染。
在这里,我们再次使用 nio 的FloatBuffer来声明我们的colorBuffer变量。以下是我们要用到的步骤来给我们的物体添加颜色:
-
我们声明我们的
colorBuffer变量。 -
我们声明我们的颜色数组变量。
-
我们将我们的颜色顶点数据复制到我们的缓冲区。
-
启用我们的颜色数组。
-
定义颜色数组缓冲区。
-
禁用颜色数组。
此外,由于这是三角形的原生属性,我们将在这我们创建的Triangle.java类中编写此代码,而不是在MyGLRenderer.java类中。在输入代码时,请记住前面的步骤,并输入以下加粗的代码:
//Import statements remain the same
public class Triangle {
private FloatBuffer vb;
private FloatBuffer cb;
private ByteBuffer ib;
private float[] v = { // Vertices of the triangle
0.0f, 1.0f, 0.0f, // 0\. top vertices
-1.0f, -1.0f, 0.0f, // 1\. left-bottom vertices
1.0f, -1.0f, 0.0f // 2\. right-bottom vertices
};
private byte[] ind = { 0, 1, 2 };
private float[] colors = {
1.0f, 0.0f, 0.0f, 1.0f, //R
0.0f, 1.0f, 0.0f, 1.0f, //G
0.0f, 0.0f, 1.0f, 1.0f //B
};
public Triangle() {
//start of code same as earlier
ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length * 4);
cbb.order(ByteOrder.nativeOrder());
cb = cbb.asFloatBuffer();
cb.put(colors);
cb.position(0);
//index buffer code same as earlier
}
public void draw(GL10 gles) {
//code as before
gles.glDisableClientState(GL10.GL_COLOR_ARRAY);
}
}
现在,我们有一个彩色的三角形。如果你使用了指定的颜色值,你会得到这样的输出。当然,你可以自由地通过改变colors变量中的 RGB 值来更改和调整颜色。

我们彩色的三角形
好的,现在我们将使我们的三角形旋转,这将澄清我们的疑问,即它是否真的是一个 3D 物体。
旋转我们的物体
到现在为止,你必须已经明白,任何对物体渲染的改变都必须在MyGLRenderer中进行,而任何属于我们物体的本地属性则需要在相应的物体文件中进行。因此,我们将把我们的旋转代码写在MyGLRenderer.java文件中,因为旋转一个物体是渲染过程的一部分。
这里是我们旋转三角形的逻辑:
-
我们获取我们的旋转角度
-
我们在指定的旋转角度旋转我们的物体。
-
我们增加我们的旋转角度
让我们来做这件事;我们将在开始时简单地声明两个变量来表示旋转角度和速度;然后在我们的onDrawFrame()方法中实现我们的旋转逻辑,如下所示:
//Import statements as before
public class MyGLRenderer implements GLSurfaceView.Renderer {
Triangle triangle;
private float angleTriangle = 0.0f;
private float speedTriangle = 0.5f;
public MyGLRenderer(Context context) {
triangle = new Triangle();
}
// No changes in our onSurfaceCreated and onSurfaceChanged methods so ignore this part
@Override
public void onDrawFrame(GL10 gles) {
//Same as previous part
angleTriangle += speedTriangle;
}
}
编译此代码后,你的三角形将开始旋转。酷吧?构建并运行,看看你的旋转三角形在动作中的样子!:

我们旋转的三角形
太棒了!现在,让我们利用这些知识来创建一个合适的 3D 物体,我们的金字塔!我们对创建物体的过程非常清楚,所以我们将快速浏览金字塔的部分。
让我们重复相同的步骤来创建金字塔。这次我们的物体将完全是 3D 的,而不仅仅是 2D 平面物体;所以,让我们开始吧!
创建一个金字塔 3D 物体
现在您已经了解了如何定义形状并在屏幕上渲染对象,创建我们的 3D 对象将相对容易。我们将遵循与创建我们的三角形几乎相同的程序。我们将创建金字塔,就在我们的三角形旁边;让我们开始吧。
定义形状
正如我们从前面的部分学到的,我们将首先创建一个金字塔的类来定义我们的形状。所以创建一个名为Pyramid.java的文件来定义金字塔对象的形状。
我们的金字塔有五个面,因此我们需要五个顶点来绘制金字塔。所以,在您创建了Pyramid.java之后,我们将定义我们的顶点,如下所示:
//Package name of our game
public class Pyramid {
private float[] vp = { // 5 vertices of the pyramid in (x,y,z)
-1.0f, -1.0f, -1.0f, //left-bottom-back
1.0f, -1.0f, -1.0f, //right-bottom-back
1.0f, -1.0f, 1.0f, //right-bottom-front
-1.0f, -1.0f, 1.0f, //left-bottom-front
0.0f, 1.0f, 0.0f //top
};
}
好吧,现在我们已经设置了顶点,但就像我们的三角形一样,我们仍然需要处理我们的缓冲区和索引。我们将快速定义我们的形状和颜色以及将构成金字塔面的索引的浮点缓冲区和字节数据缓冲区:
private FloatBuffer vb; // Buffer for vertex-array
private FloatBuffer cb; // Buffer for color-array
private ByteBuffer ib; // Buffer for index-array
private float[] colors = { // Colors of the 5 vertices in RGBA
0.0f, 0.0f, 1.0f, 1.0f, // blue
0.0f, 1.0f, 0.0f, 1.0f, // green
0.0f, 0.0f, 1.0f, 1.0f, // blue
0.0f, 1.0f, 0.0f, 1.0f, // green
1.0f, 0.0f, 0.0f, 1.0f // red
};
private byte[] ind = { // Vertex indices
2, 4, 3, // front face
1, 4, 2, // right face
0, 4, 1, // back face
4, 0, 3 // left face
};
现在,我们将根据三角形的逻辑编写我们的Pyramid构造函数:
public Pyramid() {
ByteBuffer vbb = ByteBuffer.allocateDirect(vp.length * 4);
vbb.order(ByteOrder.nativeOrder());
vb = vbb.asFloatBuffer();
vb.put(vp);
vb.position(0);
ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length * 4);
cbb.order(ByteOrder.nativeOrder());
cb = cbb.asFloatBuffer();
cb.put(colors);
cb.position(0);
ib = ByteBuffer.allocateDirect(ind.length);
ib.put(ind);
ib.position(0);
}
最后,当然,如以下代码所示,我们的draw()方法:
public void draw(GL10 gles) {
gles.glFrontFace(GL10.GL_CCW);
gles.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gles.glVertexPointer(3, GL10.GL_FLOAT, 0, vb);
gles.glEnableClientState(GL10.GL_COLOR_ARRAY);
gles.glColorPointer(4, GL10.GL_FLOAT, 0, cb);
gles.glDrawElements(GL10.GL_TRIANGLES, ind.length,
GL10.GL_UNSIGNED_BYTE,ib);
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
gl.glDisableClientState(GL10.GL_COLOR_ARRAY);
}
我们已经完成了对Pyramid形状的定义。所以,最终您的整个Pyramid代码将如下所示:
//Package name and our import statements
public class Pyramid {
//Our 3 buffer variables
private float[] vp = {
-1.0f, -1.0f, -1.0f, //left-bottom-back
1.0f, -1.0f, -1.0f, //right-bottom-back
1.0f, -1.0f, 1.0f, //right-bottom-front
-1.0f, -1.0f, 1.0f, //left-bottom-front
0.0f, 1.0f, 0.0f //top
};
private float[] colors = {
0.0f, 0.0f, 1.0f, 1.0f, //blue
0.0f, 1.0f, 0.0f, 1.0f, //green
0.0f, 0.0f, 1.0f, 1.0f, //blue
0.0f, 1.0f, 0.0f, 1.0f, //green
1.0f, 0.0f, 0.0f, 1.0f //red
};
private byte[] ind = { // Vertex indices
2, 4, 3, // front face (CCW)
1, 4, 2, // right face
0, 4, 1, // back face
4, 0, 3 // left face
};
public Pyramid() {
ByteBuffer vbb = ByteBuffer.allocateDirect(vp.length * 4);
vbb.order(ByteOrder.nativeOrder());
vb = vbb.asFloatBuffer();
vb.put(vp);
vb.position(0);
ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length * 4);
cbb.order(ByteOrder.nativeOrder());
cb = cbb.asFloatBuffer();
cb.put(colors);
cb.position(0);
ib = ByteBuffer.allocateDirect(ind.length);
ib.put(ind);
ib.position(0);
}
// Draw the shape
public void draw(GL10 gles) {
gles.glFrontFace(GL10.GL_CCW);
gles.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gles.glVertexPointer(3, GL10.GL_FLOAT, 0, vb);
gles.glEnableClientState(GL10.GL_COLOR_ARRAY);
gles.glColorPointer(4, GL10.GL_FLOAT, 0, cb);
gles.glDrawElements(GL10.GL_TRIANGLES, ind.length,
GL10.GL_UNSIGNED_BYTE,ib);
gles.glDisableClientState(GL10.GL_VERTEX_ARRAY);
gles.glDisableClientState(GL10.GL_COLOR_ARRAY);
}
}
好的,设置好了,但我们仍然看不到屏幕上的金字塔,因为我们还没有渲染它;现在让我们渲染一下,以便将金字塔显示在屏幕上。
渲染我们的 3D 对象
根据我们对之前与三角形一起工作的示例的理解,我们非常清楚,为了渲染我们的对象,我们必须使用我们的MyGLRenderer.java类。由于我们对 3D 渲染的工作原理非常清楚,我们也会在相同的代码中添加我们的旋转代码。
这里有一个关键的事情需要考虑,我们将绘制金字塔在三角形旁边,所以我们必须确保我们不使金字塔与三角形重叠。为此,我们将使用gl.Translate()代码,我们将在绘制三角形后观察onDrawFrame()代码块中的后续操作。
我们将工作在MyGLRenderer.java文件上,所以打开该文件,并编写以下加粗代码;其余部分保持不变:
//Package name and import statements
public class MyGLRenderer implements GLSurfaceView.Renderer {
//our triangle object variables
private Pyramid pyramid;
private static float anglePyramid = 0;
private static float speedPyramid = 2.0f;
public MyGLRenderer(Context context) {
//our triangle object reference remains same
pyramid = new Pyramid();
}
//Again there's no change in onSurfaceCreated and onSurfaceChanged methods so type them as is in previous chapter
@Override
public void onDrawFrame(GL10 gles) {
//Triangle code remains same gles.glLoadIdentity();
gles.glTranslatef(1.5f, 0.0f, -6.0f);
gles.glRotatef(anglePyramid, 0.1f, 1.0f, -0.1f);
pyramid.draw(gles);
//angleTriangle speed assign here
anglePyramid += speedPyramid;
}
}
好吧,所以一切看起来都准备好了。构建并运行您的项目,您将得到以下输出:

我们的两个对象现在都显示在屏幕上了
恭喜!您已经成功使用 OpenGL 创建了一个 3D 对象。以类似的方式,您可以根据顶点创建任何类型的对象。
这只是使用 OpenGL 创建 3D 基本形状游戏的基石。创建一个完整的 3D 游戏是一项巨大的任务,而这只是您能做的事情的一瞥。
我们建议您购买我们关于 3D 游戏开发的其它书籍,以便更深入地学习创建 3D 游戏。
摘要
在本章中,我们学习了如何创建 3D 对象并在屏幕上渲染它们。我们还学习了如何给对象添加颜色以及以 3D 方向旋转它们。随着本章的结束,我们完成了这本书。
我们已经学习了如何使用原生 Android 创建 2D 和 3D 游戏。通过本章获得的知识,你可以开始你的游戏开发之旅,并开始为 Android 创建自己的游戏。
这本书关于使用 Android 创建游戏的内容到此结束。通过本书获得的知识将成为你未来制作游戏的基石。在原生 Android 上创建游戏的优点主要是,游戏的文件大小最终会相当小,这正是用户通常所寻求的。如果你使用引擎创建任何游戏,最终你会得到一个游戏,其文件大小在开发过程结束时相对较大;你需要进一步优化它,但在这方面你所能做的还是有限的。因此,开发原生应用不仅允许你制作出文件大小更小的游戏,还能增强你对整个过程的了解和核心理解。
随着本书的结束,现在就取决于你和你丰富的想象力,开始利用本书所学到的知识来创造游戏。当然,使用引擎来创建游戏无疑是一个更快的流程;如果你想要跳过为每一件事创建自己的类,那么建议你从游戏引擎开始。Packt 提供了多种书籍来帮助你完成这个过程。也许,你开始使用游戏引擎的最佳起点是 Unity 游戏引擎。如果你对学习更多并给你的游戏开发过程带来提升感兴趣,那么最好的建议就是阅读 Packt 关于 Unity 游戏引擎 的书籍。
祝你在创建下一个游戏时一切顺利!


浙公网安备 33010602011771号